Initial project
This commit is contained in:
5
.editorconfig
Normal file
5
.editorconfig
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
[*.{js,jsx,ts,tsx,vue}]
|
||||||
|
indent_style = tab
|
||||||
|
indent_size = 4
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
2
.env.development
Normal file
2
.env.development
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# 接口
|
||||||
|
VITE_API=https://api.imyeyu.dev
|
||||||
15
.eslintignore
Normal file
15
.eslintignore
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
*.sh
|
||||||
|
node_modules
|
||||||
|
*.md
|
||||||
|
*.woff
|
||||||
|
*.ttf
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
dist
|
||||||
|
/public
|
||||||
|
/docs
|
||||||
|
.husky
|
||||||
|
.local
|
||||||
|
.eslintrc.cjs
|
||||||
|
dist
|
||||||
|
pnpm-lock.yaml
|
||||||
82
.eslintrc.cjs
Normal file
82
.eslintrc.cjs
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
module.exports = {
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"es2021": true,
|
||||||
|
"node": true
|
||||||
|
},
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended",
|
||||||
|
"plugin:vue/vue3-essential",
|
||||||
|
"./.eslintrc-auto-import.json"
|
||||||
|
],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"node": true
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
".eslintrc.{js}"
|
||||||
|
],
|
||||||
|
"parserOptions": {
|
||||||
|
"sourceType": "script"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": "latest",
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"@typescript-eslint",
|
||||||
|
"vue"
|
||||||
|
],
|
||||||
|
"rules": { // 注释是解释使用该设置的效果,而不是设置属性本身
|
||||||
|
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
|
||||||
|
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
|
||||||
|
// 其他
|
||||||
|
"camelcase": 2, // 变量驼峰式命名
|
||||||
|
"quotes": ["error", "double"], // 强制双引字符串
|
||||||
|
"eqeqeq": ["error", "always"], // 强制全等比较
|
||||||
|
"semi": ["error", "always"], // 强制语句分号结束
|
||||||
|
"max-len": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"code": 180
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// 逗号
|
||||||
|
"comma-style": [2, "last"], // 逗号出现在行末 [first, last]
|
||||||
|
"comma-dangle": [2, "never"], // 数组或对象不可带最后一个逗号 [never, always, always-multiline]
|
||||||
|
// 空格
|
||||||
|
"no-trailing-spaces": "error", // 禁止行末存在空格
|
||||||
|
"comma-spacing": [2, { "before": false, "after": true }], // 逗号后需要空格
|
||||||
|
"semi-spacing": ["error", { "before": false, "after": true }], // 分号后需要空格
|
||||||
|
"computed-property-spacing": [2, "never"], // 以方括号取对象属性时,[ 后面和 ] 前面需要空格, [never, always]
|
||||||
|
"space-before-function-paren": ["error", { // 函数括号前空格
|
||||||
|
"anonymous": "always", // 针对匿名函数表达式,比如 function () {}
|
||||||
|
"named": "never", // 针对命名函数表达式,比如 function foo() {}
|
||||||
|
"asyncArrow": "always" // 针对异步的箭头函数表达式,比如 async () => {}
|
||||||
|
}],
|
||||||
|
// 缩进
|
||||||
|
"no-mixed-spaces-and-tabs": "off", // 允许混合缩进
|
||||||
|
"no-tabs": ["error", { allowIndentationTabs: true }], // 使用 Tab 缩进
|
||||||
|
"indent": ["error", "tab", { // Tab 缩进相关
|
||||||
|
SwitchCase: 1 // Switch Case 缩进一级
|
||||||
|
}],
|
||||||
|
// 框架
|
||||||
|
"@typescript-eslint/ban-types": "off", // TS 允许空对象
|
||||||
|
"@typescript-eslint/no-empty-function": "off", // TS 允许空函数
|
||||||
|
"@typescript-eslint/no-explicit-any": "off", // TS 允许 any 类型
|
||||||
|
"@typescript-eslint/explicit-module-boundary-types": "off", // TS 允许显式模块边界类型?
|
||||||
|
"vue/no-multiple-template-root": "off", // Vue3 支持多个根节点
|
||||||
|
"@typescript-eslint/no-this-alias": "off", // 允许 this 变量本地化
|
||||||
|
"vue/no-v-model-argument": "off", // 允许 v-model 支持参数
|
||||||
|
"vue/multi-word-component-names": "off", // 允许单个词语的组件名
|
||||||
|
"@typescript-eslint/no-unused-expressions": [ // 允许逻辑与(&&)、逻辑或(||)短路
|
||||||
|
"error",
|
||||||
|
{ "allowShortCircuit": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
145
.gitignore
vendored
145
.gitignore
vendored
@ -1,138 +1,29 @@
|
|||||||
# ---> Node
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
.pnpm-debug.log*
|
|
||||||
|
|
||||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
node_modules
|
||||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
|
||||||
|
|
||||||
# Runtime data
|
|
||||||
pids
|
|
||||||
*.pid
|
|
||||||
*.seed
|
|
||||||
*.pid.lock
|
|
||||||
|
|
||||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
|
||||||
lib-cov
|
|
||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
|
||||||
coverage
|
|
||||||
*.lcov
|
|
||||||
|
|
||||||
# nyc test coverage
|
|
||||||
.nyc_output
|
|
||||||
|
|
||||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
|
||||||
.grunt
|
|
||||||
|
|
||||||
# Bower dependency directory (https://bower.io/)
|
|
||||||
bower_components
|
|
||||||
|
|
||||||
# node-waf configuration
|
|
||||||
.lock-wscript
|
|
||||||
|
|
||||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
|
||||||
build/Release
|
|
||||||
|
|
||||||
# Dependency directories
|
|
||||||
node_modules/
|
|
||||||
jspm_packages/
|
|
||||||
|
|
||||||
# Snowpack dependency directory (https://snowpack.dev/)
|
|
||||||
web_modules/
|
|
||||||
|
|
||||||
# TypeScript cache
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
# Optional npm cache directory
|
|
||||||
.npm
|
|
||||||
|
|
||||||
# Optional eslint cache
|
|
||||||
.eslintcache
|
|
||||||
|
|
||||||
# Optional stylelint cache
|
|
||||||
.stylelintcache
|
|
||||||
|
|
||||||
# Microbundle cache
|
|
||||||
.rpt2_cache/
|
|
||||||
.rts2_cache_cjs/
|
|
||||||
.rts2_cache_es/
|
|
||||||
.rts2_cache_umd/
|
|
||||||
|
|
||||||
# Optional REPL history
|
|
||||||
.node_repl_history
|
|
||||||
|
|
||||||
# Output of 'npm pack'
|
|
||||||
*.tgz
|
|
||||||
|
|
||||||
# Yarn Integrity file
|
|
||||||
.yarn-integrity
|
|
||||||
|
|
||||||
# dotenv environment variable files
|
|
||||||
.env
|
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
.env.local
|
|
||||||
|
|
||||||
# parcel-bundler cache (https://parceljs.org/)
|
|
||||||
.cache
|
|
||||||
.parcel-cache
|
|
||||||
|
|
||||||
# Next.js build output
|
|
||||||
.next
|
|
||||||
out
|
|
||||||
|
|
||||||
# Nuxt.js build / generate output
|
|
||||||
.nuxt
|
|
||||||
dist
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
# Gatsby files
|
# Editor directories and files
|
||||||
.cache/
|
.vscode/*
|
||||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
!.vscode/extensions.json
|
||||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
.idea
|
||||||
# public
|
.DS_Store
|
||||||
|
*.suo
|
||||||
# vuepress build output
|
*.ntvs*
|
||||||
.vuepress/dist
|
*.njsproj
|
||||||
|
*.sln
|
||||||
# vuepress v2.x temp and cache directory
|
*.sw?
|
||||||
.temp
|
|
||||||
.cache
|
|
||||||
|
|
||||||
# vitepress build output
|
|
||||||
**/.vitepress/dist
|
|
||||||
|
|
||||||
# vitepress cache directory
|
|
||||||
**/.vitepress/cache
|
|
||||||
|
|
||||||
# Docusaurus cache and generated files
|
|
||||||
.docusaurus
|
|
||||||
|
|
||||||
# Serverless directories
|
|
||||||
.serverless/
|
|
||||||
|
|
||||||
# FuseBox cache
|
|
||||||
.fusebox/
|
|
||||||
|
|
||||||
# DynamoDB Local files
|
|
||||||
.dynamodb/
|
|
||||||
|
|
||||||
# TernJS port file
|
|
||||||
.tern-port
|
|
||||||
|
|
||||||
# Stores VSCode versions used for testing VSCode extensions
|
|
||||||
.vscode-test
|
|
||||||
|
|
||||||
# yarn v2
|
|
||||||
.yarn/cache
|
|
||||||
.yarn/unplugged
|
|
||||||
.yarn/build-state.yml
|
|
||||||
.yarn/install-state.gz
|
|
||||||
.pnp.*
|
|
||||||
|
|
||||||
|
/.env.production
|
||||||
|
/.eslintrc-auto-import.json
|
||||||
|
/components.d.ts
|
||||||
|
/src/auto-imports.d.ts
|
||||||
|
|||||||
19
README.md
19
README.md
@ -1,3 +1,18 @@
|
|||||||
# blog
|
# Vue 3 + TypeScript + Vite
|
||||||
|
|
||||||
博客前端
|
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
|
||||||
|
|
||||||
|
## Type Support For `.vue` Imports in TS
|
||||||
|
|
||||||
|
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
|
||||||
|
|
||||||
|
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
|
||||||
|
|
||||||
|
1. Disable the built-in TypeScript Extension
|
||||||
|
1. Run `Extensions: Show Built-in Extensions` from VSCode's command palette
|
||||||
|
2. Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
|
||||||
|
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
|
||||||
|
|||||||
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<link rel="icon" href="public/favicon.ico" type="image/x-icon">
|
||||||
|
<title>夜雨的技术生活</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
43
package.json
Normal file
43
package.json
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "blog",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "4",
|
||||||
|
"less": "^4.3.0",
|
||||||
|
"timi-web": "link:..\\timi-web",
|
||||||
|
"timi-tdesign-pc": "link:..\\timi-tdesign-pc",
|
||||||
|
"tdesign-vue-next": "1.12.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.15.18",
|
||||||
|
"@vitejs/plugin-vue": "^5.2.4",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.25.0",
|
||||||
|
"@typescript-eslint/parser": "^8.25.0",
|
||||||
|
"@vue/cli-plugin-babel": "^5.0.8",
|
||||||
|
"@vue/compiler-sfc": "^3.5.13",
|
||||||
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
|
"@vue/eslint-config-typescript": "^14.4.0",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"vite": "6.3.4",
|
||||||
|
"vue-tsc": "^2.2.10",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
"eslint-plugin-prettier": "^5.2.3",
|
||||||
|
"eslint-plugin-promise": "^7.2.1",
|
||||||
|
"eslint-plugin-vue": "^9.32.0",
|
||||||
|
"less": "^4.3.0",
|
||||||
|
"prettier": "^3.5.2",
|
||||||
|
"unplugin-auto-import": "^19.2.0",
|
||||||
|
"unplugin-vue-components": "^28.5.0",
|
||||||
|
"vite-plugin-dts": "^4.5.3",
|
||||||
|
"vite-plugin-vue-setup-extend": "^0.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
9610
pnpm-lock.yaml
generated
Normal file
9610
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Sitemap: ./sitemap.xml
|
||||||
26
src/Root.vue
Normal file
26
src/Root.vue
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<b-e-flower-fall class="background" />
|
||||||
|
<root-layout v-if="isReady" class="diselect" icp="粤ICP备2025368555号-1" domain="imyeyu.com" author="夜雨">
|
||||||
|
<router-view />
|
||||||
|
</root-layout>
|
||||||
|
<popup />
|
||||||
|
<user-profile-popup />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { BEFlowerFall, Popup, SettingMapper } from "timi-web";
|
||||||
|
import { RootLayout, UserProfilePopup } from "timi-tdesign-pc";
|
||||||
|
|
||||||
|
const isReady = ref(false);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await SettingMapper.loadSetting();
|
||||||
|
isReady.value = true;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less">
|
||||||
|
.background {
|
||||||
|
background: url("@/assets/img/bg/main.png") fixed right bottom;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
37
src/api/ArticleAPI.ts
Normal file
37
src/api/ArticleAPI.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { Article, ArticleRanking, ArticleView } from "@/types/Article";
|
||||||
|
import { axios, Page, PageResult } from "timi-web";
|
||||||
|
|
||||||
|
async function page(page: Page): Promise<PageResult<Article<any>>> {
|
||||||
|
return axios.post("/article/list", page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文章
|
||||||
|
*
|
||||||
|
* @param id 文章 ID
|
||||||
|
* @returns 文章数据
|
||||||
|
*/
|
||||||
|
async function view(id: number): Promise<ArticleView<any>> {
|
||||||
|
return axios.get(`/article/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 喜欢文章,后端有限调用,1240ms 一次
|
||||||
|
*
|
||||||
|
* @param id 文章 ID
|
||||||
|
* @returns 最新喜欢数量
|
||||||
|
*/
|
||||||
|
async function like(id: number): Promise<number> {
|
||||||
|
return axios.get(`/article/like/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listRanking(): Promise<ArticleRanking[]> {
|
||||||
|
return axios.get("/article/list/ranking");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
view,
|
||||||
|
like,
|
||||||
|
page,
|
||||||
|
listRanking
|
||||||
|
};
|
||||||
11
src/api/BlogAPI.ts
Normal file
11
src/api/BlogAPI.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { Friend } from "@/types/Friend";
|
||||||
|
import { axios } from "timi-web";
|
||||||
|
|
||||||
|
async function friends(): Promise<Friend[]> {
|
||||||
|
return axios.get("/friend");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
friends
|
||||||
|
};
|
||||||
|
|
||||||
BIN
src/assets/img/bg/main.png
Normal file
BIN
src/assets/img/bg/main.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
BIN
src/assets/img/bg/platform.png
Normal file
BIN
src/assets/img/bg/platform.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
BIN
src/assets/img/disk.png
Normal file
BIN
src/assets/img/disk.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
BIN
src/assets/img/nagiasu.png
Normal file
BIN
src/assets/img/nagiasu.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
BIN
src/assets/media/fragile.mp3
Normal file
BIN
src/assets/media/fragile.mp3
Normal file
Binary file not shown.
92
src/components/LoginMenu.vue
Normal file
92
src/components/LoginMenu.vue
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login-menu">
|
||||||
|
<t-dropdown
|
||||||
|
v-if="userStore.isLogged() && userStore.loginUser.user"
|
||||||
|
class="logged"
|
||||||
|
trigger="click"
|
||||||
|
v-popup:config="popupUserStore.config"
|
||||||
|
@mouseenter="popupUserStore.user.value = userStore.loginUser.user"
|
||||||
|
>
|
||||||
|
<t-button class="button" variant="text">
|
||||||
|
<template #icon>
|
||||||
|
<t-avatar
|
||||||
|
class="icon"
|
||||||
|
:class="(<any>ImageType)[userStore.loginUser.user.profile.avatarType]"
|
||||||
|
:image="UserAPI.getAvatarURL(userStore.loginUser.user.profile)"
|
||||||
|
size="small"
|
||||||
|
shape="round"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<span v-text="userStore.loginUser.user.name"></span>
|
||||||
|
<template #suffix>
|
||||||
|
<icon name="ARROW_1_S" />
|
||||||
|
</template>
|
||||||
|
</t-button>
|
||||||
|
<t-dropdown-menu>
|
||||||
|
<t-dropdown-item @click="toUserSpace">
|
||||||
|
<template #prefixIcon>
|
||||||
|
<icon name="USER" />
|
||||||
|
</template>
|
||||||
|
<span>个人中心</span>
|
||||||
|
</t-dropdown-item>
|
||||||
|
<t-dropdown-item @click="userStore.logout()">
|
||||||
|
<template #prefixIcon>
|
||||||
|
<icon />
|
||||||
|
</template>
|
||||||
|
<span>退出登录</span>
|
||||||
|
</t-dropdown-item>
|
||||||
|
</t-dropdown-menu>
|
||||||
|
</t-dropdown>
|
||||||
|
<t-button
|
||||||
|
v-else
|
||||||
|
variant="text"
|
||||||
|
@click="visitableAuth = true"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<icon class="icon" name="USER" />
|
||||||
|
</template>
|
||||||
|
<span>登录</span>
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
<t-dialog
|
||||||
|
v-model:visible="visitableAuth"
|
||||||
|
attach="body"
|
||||||
|
:close-btn="false"
|
||||||
|
:header="false"
|
||||||
|
:footer="false"
|
||||||
|
>
|
||||||
|
<authorize-form
|
||||||
|
v-if="visitableAuth"
|
||||||
|
@login-success="visitableAuth = false"
|
||||||
|
@register-success="visitableAuth = false"
|
||||||
|
/>
|
||||||
|
</t-dialog>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { Icon, ImageType, SettingKey, SettingMapper, UserAPI, userStore } from "timi-web";
|
||||||
|
import { AuthorizeForm, popupUserStore } from "timi-tdesign-pc";
|
||||||
|
|
||||||
|
const visitableAuth = ref<boolean>(false);
|
||||||
|
|
||||||
|
const toUserSpace = () => {
|
||||||
|
const loginUserId = userStore.loginUser.token?.id;
|
||||||
|
if (loginUserId) {
|
||||||
|
window.open(`${SettingMapper.getDomainLink(SettingKey.DOMAIN_SPACE)}/${loginUserId}`, "_blank");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.login-menu {
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-right: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logged {
|
||||||
|
|
||||||
|
&.button {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
59
src/components/article/ArticleList.vue
Normal file
59
src/components/article/ArticleList.vue
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<div class="article-list">
|
||||||
|
<article
|
||||||
|
class="article"
|
||||||
|
v-for="item in list"
|
||||||
|
:key="item.id"
|
||||||
|
>
|
||||||
|
<header class="header">
|
||||||
|
<h4 class="title" >
|
||||||
|
<router-link class="link black" :to="`/aid${item.id}.html`">
|
||||||
|
<span v-text="item.title"></span>
|
||||||
|
</router-link>
|
||||||
|
</h4>
|
||||||
|
</header>
|
||||||
|
<section class="digest gray" v-text="item.digest"></section>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { Article } from "@/types/Article";
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
list: Article<any>[]
|
||||||
|
}>(), {
|
||||||
|
list: () => []
|
||||||
|
});
|
||||||
|
const { list } = toRefs(props);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.article-list {
|
||||||
|
padding: 1rem;
|
||||||
|
|
||||||
|
.article {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #EEE;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
padding: .25rem 1rem;
|
||||||
|
border-left: 3px solid var(--td-brand-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.digest {
|
||||||
|
height: 3rem;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: .25rem;
|
||||||
|
padding-left: calc(1rem + 3px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
164
src/components/article/template/ArticleAbout.vue
Normal file
164
src/components/article/template/ArticleAbout.vue
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
<template>
|
||||||
|
<div class="article-about">
|
||||||
|
<div v-if="article">
|
||||||
|
<markdown-view class="content" :content="article.data" />
|
||||||
|
<p
|
||||||
|
class="update-at gray"
|
||||||
|
v-text="'最后编辑时间:' + Time.toPassedDateTime(article.updatedAt || article.createdAt)"
|
||||||
|
></p>
|
||||||
|
</div>
|
||||||
|
<div class="spectrum" @click="toggleBGM" v-popup:text="'点击暂停/播放'">
|
||||||
|
<canvas ref="canvas" :width="canvasWidth" height="80"></canvas>
|
||||||
|
</div>
|
||||||
|
<audio ref="player" autoplay>
|
||||||
|
<source src="@/assets/media/fragile.mp3" type="audio/mpeg" />
|
||||||
|
</audio>
|
||||||
|
<p class="survival-time light-gray" v-text="survivalTime"></p>
|
||||||
|
<comment
|
||||||
|
v-if="SettingMapper.is(SettingKey.ENABLE_COMMENT) && article && article.showComment"
|
||||||
|
:bizType="CommentBizType.ARTICLE"
|
||||||
|
:bizId="1"
|
||||||
|
:titleStickyOffset="49"
|
||||||
|
:canComment="article.canComment"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { CommentBizType, MarkdownView, SettingKey, SettingMapper, Time } from "timi-web";
|
||||||
|
import ArticleAPI from "@/api/ArticleAPI";
|
||||||
|
import { ArticleView } from "@/types/Article";
|
||||||
|
import { Comment } from "timi-tdesign-pc";
|
||||||
|
|
||||||
|
// 文章
|
||||||
|
const article = ref<ArticleView<any>>();
|
||||||
|
onMounted(async () => article.value = await ArticleAPI.view(1));
|
||||||
|
|
||||||
|
// 运行时间
|
||||||
|
const survivalTime = ref<string>("网站已运行 ---- 年 --- 天 -- 小时 -- 分钟 -- 秒");
|
||||||
|
const survivalTimer = ref();
|
||||||
|
onMounted(async () => {
|
||||||
|
const begin = new Date("2017/10/9 22:32:52");
|
||||||
|
clearInterval(survivalTimer.value);
|
||||||
|
survivalTimer.value = setInterval(() => {
|
||||||
|
const r = Time.between(begin);
|
||||||
|
survivalTime.value = `网站已运行 ${r.y} 年 ${r.d} 天 ${r.h} 小时 ${r.m.toString().padStart(2, "0")} 分钟 ${r.s.toString().padStart(2, "0")} 秒`;
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
onBeforeUnmount(() => clearInterval(survivalTimer.value));
|
||||||
|
|
||||||
|
// 音乐
|
||||||
|
const player = ref<HTMLAudioElement>();
|
||||||
|
const canvas = ref<HTMLCanvasElement>();
|
||||||
|
const canvasWidth = ref(480);
|
||||||
|
const spectrumRender = ref<number>();
|
||||||
|
|
||||||
|
const toggleBGM = () => {
|
||||||
|
if (player.value) {
|
||||||
|
if (player.value.paused) {
|
||||||
|
player.value.play();
|
||||||
|
} else {
|
||||||
|
player.value.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawSpectrum = () => {
|
||||||
|
if (!canvas.value || !player.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ctx: any = new AudioContext();
|
||||||
|
const analyser = ctx.createAnalyser();
|
||||||
|
const audioSrc = ctx.createMediaElementSource(<HTMLMediaElement>player.value);
|
||||||
|
audioSrc.connect(analyser);
|
||||||
|
analyser.connect(ctx.destination);
|
||||||
|
|
||||||
|
const cWidth = canvas.value.width,
|
||||||
|
meterSize = 7,
|
||||||
|
cHeight = canvas.value.height - 2,
|
||||||
|
meterWidth = 6,
|
||||||
|
capHeight = 2,
|
||||||
|
meterNum = 2400 / meterSize,
|
||||||
|
capYPositionArray = [];
|
||||||
|
|
||||||
|
ctx = canvas.value.getContext("2d");
|
||||||
|
// 渐变
|
||||||
|
const gradient = ctx.createLinearGradient(0, 0, 0, 100);
|
||||||
|
gradient.addColorStop(1, "#A67D7B");
|
||||||
|
gradient.addColorStop(0.5, "#A67D7B");
|
||||||
|
gradient.addColorStop(0, "#A67D7B");
|
||||||
|
|
||||||
|
const capStyle = "#A67D7B";
|
||||||
|
// 动画
|
||||||
|
(function renderFrame() {
|
||||||
|
const array = new Uint8Array(analyser.frequencyBinCount);
|
||||||
|
analyser.getByteFrequencyData(array);
|
||||||
|
const step = Math.round(array.length / meterNum);
|
||||||
|
ctx.clearRect(0, 0, cWidth, cHeight);
|
||||||
|
for (let i = 0; i < meterNum; i++) {
|
||||||
|
const value = array[i * step];
|
||||||
|
if (capYPositionArray.length < Math.round(meterNum)) {
|
||||||
|
capYPositionArray.push(value);
|
||||||
|
}
|
||||||
|
ctx.fillStyle = capStyle;
|
||||||
|
if (value < capYPositionArray[i]) {
|
||||||
|
ctx.fillRect(4 + i * meterSize, cHeight - (--capYPositionArray[i] / 3.2), meterWidth, capHeight);
|
||||||
|
} else {
|
||||||
|
ctx.fillRect(4 + i * meterSize, cHeight - value / 3.2, meterWidth, capHeight);
|
||||||
|
capYPositionArray[i] = value;
|
||||||
|
}
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fillRect(4 + i * meterSize, cHeight - value / 3.2 + capHeight, meterWidth, cHeight);
|
||||||
|
}
|
||||||
|
spectrumRender.value = requestAnimationFrame(renderFrame);
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
onMounted(async () => {
|
||||||
|
if (player.value) {
|
||||||
|
player.value.volume = .2;
|
||||||
|
player.value.addEventListener("loadeddata", () => {
|
||||||
|
if (player.value && 2 <= player.value.readyState) {
|
||||||
|
// TODO: 成功 play 才允许绘制
|
||||||
|
drawSpectrum();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (spectrumRender.value) {
|
||||||
|
cancelAnimationFrame(spectrumRender.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.article-about {
|
||||||
|
|
||||||
|
.content {
|
||||||
|
width: calc(100% - 2rem);
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-at {
|
||||||
|
text-align: right;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spectrum {
|
||||||
|
width: 20rem;
|
||||||
|
height: 320px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
background: url("@/assets/img/nagiasu.png") bottom no-repeat;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: end;
|
||||||
|
background-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.survival-time {
|
||||||
|
height: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
346
src/components/article/template/ArticleMusic.vue
Normal file
346
src/components/article/template/ArticleMusic.vue
Normal file
@ -0,0 +1,346 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="article" class="article-music">
|
||||||
|
<p class="time" v-text="articleTime" @click="isCreatedAt = !isCreatedAt"></p>
|
||||||
|
<!-- 光盘 -->
|
||||||
|
<div class="disk-box" v-if="article.extendData">
|
||||||
|
<div class="cover">
|
||||||
|
<img :src="coverUrl" alt="专辑封面" />
|
||||||
|
</div>
|
||||||
|
<!-- SVG 绘制扇形文字 -->
|
||||||
|
<div class="disk">
|
||||||
|
<svg viewBox="0 0 100 100">
|
||||||
|
<path d="M 0,50 a 50,50 0 1,1 0,1 z" id="circle" />
|
||||||
|
<text fill="#F4F4F4">
|
||||||
|
<textPath xlink:href="#circle">{{ article.extendData.title }}</textPath>
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<markdown-view class="description" :content="article.data" />
|
||||||
|
<div class="content selectable" v-if="article.extendData">
|
||||||
|
<section class="list">
|
||||||
|
<h4 class="title">曲目:</h4>
|
||||||
|
<ul class="items">
|
||||||
|
<li class="item" v-for="(item, i) in article.extendData.list" :key="i" :data-i="`${i + 1}.`">
|
||||||
|
<div class="value">
|
||||||
|
<span v-text="item.title"></span>
|
||||||
|
<icon v-if="item.audio" class="icon cur-pointer" name="MUSIC" @click="doActiveMedia(i)" />
|
||||||
|
<icon v-if="item.video" class="icon cur-pointer" name="VIDEO" @click="doActiveMedia(i)" />
|
||||||
|
</div>
|
||||||
|
<div v-if="activeMediaIndex === i" class="media">
|
||||||
|
<audio v-if="item.audio" :src="Toolkit.toResURL(item.audio)" controls></audio>
|
||||||
|
<video v-if="item.video" :src="Toolkit.toResURL(item.video)" controls></video>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section class="info">
|
||||||
|
<h4 class="title">信息:</h4>
|
||||||
|
<ul class="items">
|
||||||
|
<li class="item" v-for="(item, i) in article.extendData.info" :key="i">
|
||||||
|
<span class="label diselect" v-text="`${item.key}:`"></span>
|
||||||
|
<span class="gray" v-text="item.value"></span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<comment
|
||||||
|
v-if="SettingMapper.is(SettingKey.ENABLE_COMMENT) && article && article.showComment"
|
||||||
|
:bizType="CommentBizType.ARTICLE"
|
||||||
|
:bizId="article.id!"
|
||||||
|
:titleStickyOffset="80"
|
||||||
|
:canComment="article.canComment"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ArticleAttachType, ArticleMusicExtendData, ArticleView } from "@/types/Article.ts";
|
||||||
|
import { CommentBizType, CommonAPI, Icon, MarkdownView, SettingKey, SettingMapper, Time, Toolkit } from "timi-web";
|
||||||
|
import { Comment } from "timi-tdesign-pc";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
article?: ArticleView<ArticleMusicExtendData>,
|
||||||
|
}>();
|
||||||
|
const { article } = toRefs(props);
|
||||||
|
|
||||||
|
const isCreatedAt = ref<boolean>(false);
|
||||||
|
const articleTime = computed(() => {
|
||||||
|
if (article.value) {
|
||||||
|
if (isCreatedAt.value || !article.value.updatedAt) {
|
||||||
|
return "发布于 " + Time.toPassedDateTime(article.value.createdAt);
|
||||||
|
} else {
|
||||||
|
return "编辑于 " + Time.toPassedDateTime(article.value.updatedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 专辑封面
|
||||||
|
const coverUrl = computed(() => {
|
||||||
|
if (article.value && article.value.attachmentList) {
|
||||||
|
for (let i = 0; i < article.value.attachmentList.length; i++) {
|
||||||
|
const item = article.value.attachmentList[i];
|
||||||
|
const attachType = (<any>ArticleAttachType)[item.attachType!];
|
||||||
|
if (attachType === ArticleAttachType.COVER) {
|
||||||
|
return CommonAPI.getAttachmentReadAPI(item.mongoId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
})
|
||||||
|
|
||||||
|
// 展开可播放媒体
|
||||||
|
const activeMediaIndex = ref();
|
||||||
|
function doActiveMedia(index: number) {
|
||||||
|
if (index === activeMediaIndex.value) {
|
||||||
|
activeMediaIndex.value = undefined;
|
||||||
|
} else {
|
||||||
|
activeMediaIndex.value = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.article-music {
|
||||||
|
|
||||||
|
.time {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disk-box {
|
||||||
|
width: 628px;
|
||||||
|
height: 430px;
|
||||||
|
margin: 12px auto;
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
left: 3rem;
|
||||||
|
width: 456px;
|
||||||
|
height: 428px;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 2px 0 0 2px;
|
||||||
|
position: absolute;
|
||||||
|
transition: .3s;
|
||||||
|
|
||||||
|
/* 蒙板 */
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 458px;
|
||||||
|
height: 430px;
|
||||||
|
z-index: 3;
|
||||||
|
position: absolute;
|
||||||
|
background: url('~@/assets/img/disk.png') no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: var(--tui-border);
|
||||||
|
box-shadow: var(--tui-shadow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.disk {
|
||||||
|
top: 50%;
|
||||||
|
right: 3rem;
|
||||||
|
color: #F4F4F4;
|
||||||
|
width: 16rem;
|
||||||
|
height: 16rem;
|
||||||
|
border: 3px solid #888;
|
||||||
|
padding: 40px;
|
||||||
|
z-index: 1;
|
||||||
|
position: absolute;
|
||||||
|
font-size: .5rem;
|
||||||
|
transform: rotateZ(50deg);
|
||||||
|
transition: .3s;
|
||||||
|
margin-top: calc(-43px - 8rem);
|
||||||
|
background: #212121;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
margin: -23px 0 0 -23px;
|
||||||
|
border: 8px solid #BBB;
|
||||||
|
position: absolute;
|
||||||
|
background: #F4F4F4;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
display: block;
|
||||||
|
overflow: visible;
|
||||||
|
|
||||||
|
path {
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:active {
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
left: 0;
|
||||||
|
transition: 520ms var(--tui-bezier);
|
||||||
|
}
|
||||||
|
|
||||||
|
.disk {
|
||||||
|
right: 0;
|
||||||
|
transform: rotateZ(120deg);
|
||||||
|
transition: 520ms var(--tui-bezier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
padding: 12px 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.list {
|
||||||
|
flex: 10;
|
||||||
|
border: 1px solid #CDDEF0;
|
||||||
|
min-width: 440px;
|
||||||
|
background: #FFF;
|
||||||
|
margin-right: 1.5rem;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
background: #CDDEF0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items {
|
||||||
|
margin: 0;
|
||||||
|
position: relative;
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 0;
|
||||||
|
|
||||||
|
.item {
|
||||||
|
padding: 4px 0;
|
||||||
|
display: flex;
|
||||||
|
line-height: 1.5;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: attr(data-i);
|
||||||
|
color: #525870;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
position: absolute;
|
||||||
|
text-align: right;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(2n) {
|
||||||
|
background: #F4F4F4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
text-indent: 2.5em;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-left: .5rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
fill: #FF7A9B;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.media {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
audio,
|
||||||
|
video {
|
||||||
|
width: calc(100% - 5em);
|
||||||
|
margin: 4px auto;
|
||||||
|
border: var(--tui-border);
|
||||||
|
box-shadow: var(--tui-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
audio {
|
||||||
|
height: 2.5rem;
|
||||||
|
background: #F1F3F4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
color: #666;
|
||||||
|
flex: 2;
|
||||||
|
min-width: 220px;
|
||||||
|
text-align: left;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: .5rem 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items {
|
||||||
|
padding: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 0;
|
||||||
|
|
||||||
|
.item {
|
||||||
|
padding: 6px 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-bottom: 1px dashed #777;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: #525870;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media screen and (max-width: 680px) {
|
||||||
|
.article-music {
|
||||||
|
|
||||||
|
.disk-box {
|
||||||
|
width: calc(628px * .5);
|
||||||
|
height: calc(430px * .5);
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
left: 3rem;
|
||||||
|
width: calc(456px * .5);
|
||||||
|
height: calc(428px * .5);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
width: calc(458px * .5);
|
||||||
|
height: calc(430px * .5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.disk {
|
||||||
|
width: calc(16rem * .4);
|
||||||
|
height: calc(16rem * .4);
|
||||||
|
margin-top: calc(-43px - 8rem * .4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
52
src/components/article/template/ArticlePublic.vue
Normal file
52
src/components/article/template/ArticlePublic.vue
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="article" class="article-public">
|
||||||
|
<div class="time" v-text="articleTime" @click="isCreatedAt = !isCreatedAt"></div>
|
||||||
|
<markdown-view class="content" :content="article.data" />
|
||||||
|
<comment
|
||||||
|
v-if="SettingMapper.is(SettingKey.ENABLE_COMMENT) && article.showComment"
|
||||||
|
:bizType="CommentBizType.ARTICLE"
|
||||||
|
:bizId="article.id!"
|
||||||
|
:titleStickyOffset="80"
|
||||||
|
:canComment="article.canComment"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { CommentBizType, MarkdownView, SettingKey, SettingMapper, Time } from "timi-web";
|
||||||
|
import { ArticleView } from "@/types/Article";
|
||||||
|
import { Comment } from "timi-tdesign-pc";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
article?: ArticleView<any>,
|
||||||
|
}>();
|
||||||
|
const { article } = toRefs(props);
|
||||||
|
|
||||||
|
const isCreatedAt = ref<boolean>(false);
|
||||||
|
|
||||||
|
const articleTime = computed(() => {
|
||||||
|
if (article.value) {
|
||||||
|
if (isCreatedAt.value || !article.value.updatedAt) {
|
||||||
|
return "发布于 " + Time.toPassedDateTime(article.value.createdAt);
|
||||||
|
} else {
|
||||||
|
return "编辑于 " + Time.toPassedDateTime(article.value.updatedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.article-public {
|
||||||
|
|
||||||
|
.time {
|
||||||
|
text-align: center;
|
||||||
|
margin: 1rem 0 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
width: calc(100% - 2rem);
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
168
src/components/article/template/ArticleSoftware.vue
Normal file
168
src/components/article/template/ArticleSoftware.vue
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="article" class="article-software">
|
||||||
|
<p class="time" v-text="articleTime" @click="isCreatedAt = !isCreatedAt"></p>
|
||||||
|
<div class="header">
|
||||||
|
<img class="logo" :src="coverUrl" alt="程序封面" />
|
||||||
|
<div v-if="article.extendData" class="info">
|
||||||
|
<div>
|
||||||
|
<span>官网:</span>
|
||||||
|
<a :href="article.extendData.url" v-text="article.extendData.url"></a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>版本:</span>
|
||||||
|
<span class="selectable" v-text="article.extendData.version"></span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>文件格式:</span>
|
||||||
|
<span class="selectable" v-text="article.extendData.format"></span>
|
||||||
|
</div>
|
||||||
|
<div class="runtimes">
|
||||||
|
<span>运行环境:</span>
|
||||||
|
<span
|
||||||
|
class="runtime"
|
||||||
|
v-for="item in article.extendData.runtime"
|
||||||
|
v-text="`${item} `"
|
||||||
|
:key="item"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>大小:</span>
|
||||||
|
<span v-text="IOSize.format(article.extendData.size)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="downloads">
|
||||||
|
<span>下载:</span>
|
||||||
|
<a
|
||||||
|
class="download"
|
||||||
|
v-for="item in article.extendData.downloads"
|
||||||
|
:href="Toolkit.toResURL(item.value)"
|
||||||
|
:key="item.value"
|
||||||
|
v-text="item.type"
|
||||||
|
target="_blank"
|
||||||
|
></a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>解压密码:</span>
|
||||||
|
<span class="selectable" v-text="article.extendData.password"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<markdown-view class="content" :content="article.data" />
|
||||||
|
</div>
|
||||||
|
<comment
|
||||||
|
v-if="SettingMapper.is(SettingKey.ENABLE_COMMENT) && article && article.showComment"
|
||||||
|
:bizType="CommentBizType.ARTICLE"
|
||||||
|
:bizId="article.id!"
|
||||||
|
:titleStickyOffset="80"
|
||||||
|
:canComment="article.canComment"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ArticleAttachType, ArticleSoftwareExtendData, ArticleView } from "@/types/Article.ts";
|
||||||
|
import { CommentBizType, CommonAPI, IOSize, MarkdownView, SettingKey, SettingMapper, Time, Toolkit } from "timi-web";
|
||||||
|
import { Comment } from "timi-tdesign-pc";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
article?: ArticleView<ArticleSoftwareExtendData>,
|
||||||
|
}>();
|
||||||
|
const { article } = toRefs(props);
|
||||||
|
|
||||||
|
const isCreatedAt = ref<boolean>(false);
|
||||||
|
const articleTime = computed(() => {
|
||||||
|
if (article.value) {
|
||||||
|
if (isCreatedAt.value || !article.value.updatedAt) {
|
||||||
|
return "发布于 " + Time.toPassedDateTime(article.value.createdAt);
|
||||||
|
} else {
|
||||||
|
return "编辑于 " + Time.toPassedDateTime(article.value.updatedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const coverUrl = computed(() => {
|
||||||
|
if (article.value && article.value.attachmentList) {
|
||||||
|
for (let i = 0; i < article.value.attachmentList.length; i++) {
|
||||||
|
const item = article.value.attachmentList[i];
|
||||||
|
const attachType = (<any>ArticleAttachType)[item.attachType!];
|
||||||
|
if (attachType === ArticleAttachType.COVER) {
|
||||||
|
return CommonAPI.getAttachmentReadAPI(item.mongoId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.article-software {
|
||||||
|
|
||||||
|
.time {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 140px;
|
||||||
|
height: 140px;
|
||||||
|
transition: 520ms var(--tui-bezier);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
font-size: 13px;
|
||||||
|
border-left: 2px solid var(--tui-light-gray);
|
||||||
|
margin-left: 1rem;
|
||||||
|
padding-left: 1rem;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
|
||||||
|
> span:first-child {
|
||||||
|
color: #666;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads {
|
||||||
|
|
||||||
|
.download {
|
||||||
|
border: 1px solid #FF7A9B;
|
||||||
|
padding: 1px 4px;
|
||||||
|
background: transparent;
|
||||||
|
margin-right: 6px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #FFF;
|
||||||
|
background: #FF7A9B;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: #FFAAC0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
width: calc(100% - 2rem);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 650px) {
|
||||||
|
|
||||||
|
.article-software {
|
||||||
|
|
||||||
|
.header {
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 70px;
|
||||||
|
height: 70px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
107
src/layout/IndexLayout.vue
Normal file
107
src/layout/IndexLayout.vue
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
<template>
|
||||||
|
<div class="index-layout">
|
||||||
|
<t-head-menu class="menu" v-model="menuValue">
|
||||||
|
<template #logo>
|
||||||
|
<div
|
||||||
|
v-if="owner"
|
||||||
|
class="owner"
|
||||||
|
v-popup:config="popupUserStore.config"
|
||||||
|
@mouseenter="popupUserStore.user.value = owner"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="avatar ir-pixelated"
|
||||||
|
:class="(<any>ImageType)[owner.profile.avatarType]"
|
||||||
|
:src="UserAPI.getAvatarURL(owner.profile)"
|
||||||
|
alt="avatar"
|
||||||
|
/>
|
||||||
|
<div class="name">夜雨</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<t-menu-item to="/?p=0" :value="Menu.INDEX">主页</t-menu-item>
|
||||||
|
<t-menu-item
|
||||||
|
:href="SettingMapper.getDomainLink(SettingKey.DOMAIN_GIT)"
|
||||||
|
:value="Menu.GIT" target="_blank"
|
||||||
|
>开源项目</t-menu-item>
|
||||||
|
<t-menu-item to="/about" :value="Menu.ABOUT">关于</t-menu-item>
|
||||||
|
<template #operations v-if="SettingMapper.is(SettingKey.ENABLE_LOGIN)">
|
||||||
|
<login-menu v-if="showUser" />
|
||||||
|
</template>
|
||||||
|
</t-head-menu>
|
||||||
|
<router-view :key="route.name!" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { deviceStore, ImageType, SettingKey, SettingMapper, UserAPI, UserView } from "timi-web";
|
||||||
|
import LoginMenu from "@/components/LoginMenu.vue";
|
||||||
|
import { popupUserStore } from "timi-tdesign-pc";
|
||||||
|
|
||||||
|
// 个人信息
|
||||||
|
const owner = ref<UserView>();
|
||||||
|
onMounted(async () => owner.value = await UserAPI.view(1));
|
||||||
|
|
||||||
|
// 路由
|
||||||
|
enum Menu {
|
||||||
|
INDEX = "INDEX",
|
||||||
|
GIT = "GIT",
|
||||||
|
ABOUT = "ABOUT"
|
||||||
|
}
|
||||||
|
|
||||||
|
const regexMenu: {
|
||||||
|
[key: string]: Menu
|
||||||
|
} = {
|
||||||
|
"ArticleIndex|ArticleDetail": Menu.INDEX,
|
||||||
|
"About": Menu.ABOUT
|
||||||
|
};
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const menuValue = ref();
|
||||||
|
|
||||||
|
const syncMenuRouter = (routerName?: string) => {
|
||||||
|
if (routerName) {
|
||||||
|
for (const item in regexMenu) {
|
||||||
|
if (new RegExp(item).test(routerName as string)) {
|
||||||
|
menuValue.value = regexMenu[item];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
watch(() => route.name, (routerName) => syncMenuRouter(routerName as string));
|
||||||
|
onMounted(() => syncMenuRouter(route.name as string));
|
||||||
|
|
||||||
|
// 登录
|
||||||
|
const showUser = computed(() => !deviceStore.isPhone.value);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.index-layout {
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
top: 0;
|
||||||
|
z-index: 12;
|
||||||
|
position: sticky;
|
||||||
|
background: rgba(255, 255, 255, .8);
|
||||||
|
border-bottom: var(--tui-border);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
|
||||||
|
.owner {
|
||||||
|
height: calc(100% - .5rem);
|
||||||
|
margin: 0 1rem 0 0;
|
||||||
|
padding: .25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
57
src/main.ts
Normal file
57
src/main.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { createApp } from "vue";
|
||||||
|
import router from "@/router";
|
||||||
|
import { NavigationGuardNext, RouteLocationNormalized } from "vue-router";
|
||||||
|
|
||||||
|
import { axios, Network, userStore, VDraggable, VPopup } from "timi-web";
|
||||||
|
import { DialogPlugin } from "tdesign-vue-next";
|
||||||
|
|
||||||
|
import "tdesign-vue-next/dist/tdesign.css";
|
||||||
|
import "timi-tdesign-pc/style.css";
|
||||||
|
import "timi-web/style.css";
|
||||||
|
|
||||||
|
import Root from "@/Root.vue";
|
||||||
|
|
||||||
|
console.log(`
|
||||||
|
______ __ _ _
|
||||||
|
/ __\\ \\ \\ \\ \\
|
||||||
|
/ . . \\ ' \\ \\ \\ \\
|
||||||
|
( ) imyeyu.com ) ) ) )
|
||||||
|
'\\ ___ /' / / / /
|
||||||
|
====='===='=====================/_/_/_/
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ---------- 网络 ----------
|
||||||
|
axios.defaults.baseURL = import.meta.env.VITE_API;
|
||||||
|
axios.interceptors.request.use(Network.userTokenInterceptors);
|
||||||
|
// ---------- 路由 ----------
|
||||||
|
router.beforeEach(async (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext): Promise<void> => {
|
||||||
|
await userStore.login4Storage();
|
||||||
|
if (to.matched.length !== 0) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (from.name) {
|
||||||
|
next({name: from.name});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 找不到页面
|
||||||
|
const alert = DialogPlugin.alert({
|
||||||
|
header: "错误:404",
|
||||||
|
body: "找不到该页面",
|
||||||
|
confirmBtn: {
|
||||||
|
content: "关闭",
|
||||||
|
variant: "base",
|
||||||
|
theme: "default"
|
||||||
|
},
|
||||||
|
onConfirm: () => {
|
||||||
|
alert.destroy();
|
||||||
|
next("/");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// ---------- Vue ----------
|
||||||
|
const app = createApp(Root);
|
||||||
|
app.use(router);
|
||||||
|
app.directive("draggable", VDraggable as any);
|
||||||
|
app.directive("popup", VPopup as any);
|
||||||
|
app.mount("#root");
|
||||||
37
src/router/index.ts
Normal file
37
src/router/index.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import IndexLayout from "@/layout/IndexLayout.vue";
|
||||||
|
import ArticleDetail from "@/views/ArticleDetail.vue";
|
||||||
|
import ArticleIndex from "@/views/ArticleIndex.vue";
|
||||||
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
|
|
||||||
|
export default createRouter({
|
||||||
|
history: createWebHistory("/"),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
name: "Index",
|
||||||
|
component: IndexLayout,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
name: "ArticleIndex",
|
||||||
|
component: ArticleIndex
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/aid:id.html",
|
||||||
|
name: "ArticleDetail",
|
||||||
|
component: ArticleDetail
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/about",
|
||||||
|
name: "About",
|
||||||
|
component: ArticleDetail
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 兼容性保留
|
||||||
|
path: "/article/aid:id.html",
|
||||||
|
redirect: to => `/aid${to.params.id}.html`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
115
src/types/Article.ts
Normal file
115
src/types/Article.ts
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import { AttachmentView, Model } from "timi-web";
|
||||||
|
|
||||||
|
// 文章
|
||||||
|
export type Article<E extends ArticleMusicExtendData | ArticleSoftwareExtendData> = {
|
||||||
|
title?: string;
|
||||||
|
type: ArticleType;
|
||||||
|
classId: number;
|
||||||
|
digest?: string;
|
||||||
|
data?: string;
|
||||||
|
extendData?: E;
|
||||||
|
reads: number;
|
||||||
|
likes: number;
|
||||||
|
showComment: boolean;
|
||||||
|
canComment: boolean;
|
||||||
|
canRanking: boolean;
|
||||||
|
} & Model;
|
||||||
|
|
||||||
|
export type ArticleView<E extends ArticleMusicExtendData | ArticleSoftwareExtendData> = {
|
||||||
|
comments?: number;
|
||||||
|
clazz: ArticleClass;
|
||||||
|
labels: ArticleLabel[];
|
||||||
|
attachmentList: AttachmentView[];
|
||||||
|
} & Article<E>;
|
||||||
|
|
||||||
|
export enum ArticleType {
|
||||||
|
|
||||||
|
/** 关于 */
|
||||||
|
ABOUT,
|
||||||
|
|
||||||
|
/** 公版 */
|
||||||
|
PUBLIC,
|
||||||
|
|
||||||
|
/** 音乐 */
|
||||||
|
MUSIC,
|
||||||
|
|
||||||
|
/** 软件 */
|
||||||
|
SOFTWARE
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ArticleAttachType {
|
||||||
|
|
||||||
|
COVER,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文章分类
|
||||||
|
export type ArticleClass = {
|
||||||
|
name?: string;
|
||||||
|
isOther?: boolean;
|
||||||
|
order?: number;
|
||||||
|
|
||||||
|
count?: number;
|
||||||
|
} & Model;
|
||||||
|
|
||||||
|
// 文章标签
|
||||||
|
export type ArticleLabel = {
|
||||||
|
name?: string;
|
||||||
|
count?: number;
|
||||||
|
} & Model;
|
||||||
|
|
||||||
|
// 每周排行
|
||||||
|
export type ArticleRanking = {
|
||||||
|
title?: string;
|
||||||
|
count?: number;
|
||||||
|
recentAt?: number;
|
||||||
|
} & Model;
|
||||||
|
|
||||||
|
export type ArticleMusicExtendData = {
|
||||||
|
title: string;
|
||||||
|
list: ArticleMusicItem[];
|
||||||
|
info: {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ArticleSoftwareExtendData = {
|
||||||
|
url?: string;
|
||||||
|
downloads: ArticleSoftwareDownload[];
|
||||||
|
format: string;
|
||||||
|
runtime: ArticleSoftwareRuntime[];
|
||||||
|
size: number;
|
||||||
|
version: string;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ArticleSoftwareDownloadType {
|
||||||
|
|
||||||
|
TIMI_MONGO,
|
||||||
|
|
||||||
|
TIMI_COS,
|
||||||
|
|
||||||
|
URL
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ArticleSoftwareRuntime {
|
||||||
|
|
||||||
|
JVM,
|
||||||
|
|
||||||
|
WINDOWS,
|
||||||
|
|
||||||
|
LINUX,
|
||||||
|
|
||||||
|
MAC_OS
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ArticleSoftwareDownload = {
|
||||||
|
type: ArticleSoftwareDownloadType;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ArticleMusicItem = {
|
||||||
|
title: string;
|
||||||
|
audio?: string;
|
||||||
|
video?: string;
|
||||||
|
}
|
||||||
8
src/types/Friend.ts
Normal file
8
src/types/Friend.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { Model } from "timi-web";
|
||||||
|
|
||||||
|
/** 友链 */
|
||||||
|
export type Friend = {
|
||||||
|
icon?: string;
|
||||||
|
name: string;
|
||||||
|
link: string;
|
||||||
|
} & Model
|
||||||
126
src/views/ArticleDetail.vue
Normal file
126
src/views/ArticleDetail.vue
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
<template>
|
||||||
|
<article class="article-detail">
|
||||||
|
<header v-if="!isAbout" class="header">
|
||||||
|
<div class="relative">
|
||||||
|
<t-button
|
||||||
|
class="back"
|
||||||
|
theme="default"
|
||||||
|
variant="text"
|
||||||
|
content="返回"
|
||||||
|
@click="router.back()"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<icon class="icon" name="ARROW_1_W" />
|
||||||
|
</template>
|
||||||
|
</t-button>
|
||||||
|
<div class="title">
|
||||||
|
<h4
|
||||||
|
v-if="article"
|
||||||
|
class="value selectable clip-text"
|
||||||
|
v-text="article.title"
|
||||||
|
></h4>
|
||||||
|
</div>
|
||||||
|
<t-button
|
||||||
|
class="comment"
|
||||||
|
theme="default"
|
||||||
|
variant="text"
|
||||||
|
content="评论"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<icon class="icon" name="MESSAGE" />
|
||||||
|
</template>
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<component :is="template" :article="article" />
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { Icon } from "timi-web";
|
||||||
|
import ArticleAPI from "@/api/ArticleAPI";
|
||||||
|
import ArticleAbout from "@/components/article/template/ArticleAbout.vue";
|
||||||
|
import ArticleMusic from "@/components/article/template/ArticleMusic.vue";
|
||||||
|
import ArticlePublic from "@/components/article/template/ArticlePublic.vue";
|
||||||
|
import ArticleSoftware from "@/components/article/template/ArticleSoftware.vue";
|
||||||
|
import { ArticleView } from "@/types/Article";
|
||||||
|
import { type Component } from "vue";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const article = ref<ArticleView<any>>();
|
||||||
|
const isAbout = ref<boolean>(false);
|
||||||
|
const template = ref<Component | null>(null);
|
||||||
|
const templates: Record<string, Component> = {
|
||||||
|
ABOUT: ArticleAbout,
|
||||||
|
MUSIC: ArticleMusic,
|
||||||
|
PUBLIC: ArticlePublic,
|
||||||
|
SOFTWARE: ArticleSoftware
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(article, () => {
|
||||||
|
if (article.value) {
|
||||||
|
template.value = markRaw(templates[article.value.type]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
isAbout.value = route.name === "About";
|
||||||
|
|
||||||
|
const id = isAbout.value ? 1 : route.params.id as any as number;
|
||||||
|
article.value = await ArticleAPI.view(id);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.article-detail {
|
||||||
|
|
||||||
|
.header {
|
||||||
|
top: 49px;
|
||||||
|
z-index: 3;
|
||||||
|
position: sticky;
|
||||||
|
background: rgba(255, 255, 255, .8);
|
||||||
|
border-bottom: 1px solid var(--td-component-border);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
|
||||||
|
.relative {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
padding: 0 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: -1;
|
||||||
|
position: absolute;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 2rem;
|
||||||
|
|
||||||
|
.value {
|
||||||
|
margin: 0 auto;
|
||||||
|
display: block;
|
||||||
|
max-width: calc(100% - 120px - 10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment {
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 9px;
|
||||||
|
position: absolute;
|
||||||
|
line-height: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
238
src/views/ArticleIndex.vue
Normal file
238
src/views/ArticleIndex.vue
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
<template>
|
||||||
|
<div class="article-index">
|
||||||
|
<div class="list">
|
||||||
|
<article-list
|
||||||
|
v-if="pageResult"
|
||||||
|
:list="pageResult.list"
|
||||||
|
/>
|
||||||
|
<t-pagination
|
||||||
|
class="pages"
|
||||||
|
v-if="pageResult"
|
||||||
|
:total="pageResult.total"
|
||||||
|
:pageSize="16"
|
||||||
|
:showPageSize="false"
|
||||||
|
:totalContent="isShowPageTotal"
|
||||||
|
:current="currentPage"
|
||||||
|
:onCurrentChange="onPageChangeEvent"
|
||||||
|
>
|
||||||
|
<template #totalContent v-if="isShowPageTotal">
|
||||||
|
<div style="flex: 1" v-text="`共 ${pageResult.total} 个项目`"></div>
|
||||||
|
</template>
|
||||||
|
</t-pagination>
|
||||||
|
</div>
|
||||||
|
<aside class="aside">
|
||||||
|
<section class="ranking">
|
||||||
|
<h4 class="title">
|
||||||
|
<icon class="icon" name="LIST" />
|
||||||
|
<span>排行</span>
|
||||||
|
</h4>
|
||||||
|
<ul class="items">
|
||||||
|
<li
|
||||||
|
class="item"
|
||||||
|
v-for="item in ranking"
|
||||||
|
:key="item.id"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="title black clip-text"
|
||||||
|
:href="`/aid${item.id}.html`"
|
||||||
|
v-text="item.title"
|
||||||
|
:title="item.title"
|
||||||
|
@click.prevent="Toolkit.leftClickCallback($event, () => router.push(`/aid${item.id}.html`))"
|
||||||
|
></a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section class="friend">
|
||||||
|
<h4 class="title">
|
||||||
|
<icon class="icon" name="LINK_0" />
|
||||||
|
<span>友链</span>
|
||||||
|
</h4>
|
||||||
|
<div class="items">
|
||||||
|
<div
|
||||||
|
class="item"
|
||||||
|
v-for="friend in friends"
|
||||||
|
:key="friend.link"
|
||||||
|
>
|
||||||
|
<img class="icon ir-pixelated" :src="friend.icon" :alt="friend.name" />
|
||||||
|
<a
|
||||||
|
class="name black"
|
||||||
|
:href="friend.link"
|
||||||
|
target="_blank"
|
||||||
|
v-text="friend.name"
|
||||||
|
></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { deviceStore, Icon, Page, PageResult, Toolkit } from "timi-web";
|
||||||
|
import ArticleAPI from "@/api/ArticleAPI";
|
||||||
|
import { Article, ArticleRanking } from "@/types/Article";
|
||||||
|
import ArticleList from "@/components/article/ArticleList.vue";
|
||||||
|
import { Friend } from "@/types/Friend.ts";
|
||||||
|
import BlogAPI from "@/api/BlogAPI.ts";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
// 页码
|
||||||
|
const currentPage = computed(() => page.index + 1);
|
||||||
|
// 分页参数
|
||||||
|
const page = reactive<Page>({
|
||||||
|
index: 0,
|
||||||
|
size: 16
|
||||||
|
});
|
||||||
|
// 数据列表
|
||||||
|
const pageResult = ref<PageResult<Article<any>>>();
|
||||||
|
// 获取列表
|
||||||
|
const fetchList = Toolkit.debounce(async () => pageResult.value = await ArticleAPI.page(page));
|
||||||
|
|
||||||
|
// 监听路由分页
|
||||||
|
watch(
|
||||||
|
() => route.query.p,
|
||||||
|
async (newP) => {
|
||||||
|
const pNum = Number(newP);
|
||||||
|
if (Number.isInteger(pNum) && 0 <= pNum) {
|
||||||
|
page.index = pNum;
|
||||||
|
await fetchList();
|
||||||
|
} else {
|
||||||
|
// 参数无效时重置为第一页
|
||||||
|
await router.push({
|
||||||
|
path: route.path,
|
||||||
|
query: {
|
||||||
|
...route.query,
|
||||||
|
p: 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
function onPageChangeEvent(current: number) {
|
||||||
|
const newIndex = current - 1;
|
||||||
|
router.push({
|
||||||
|
path: route.path,
|
||||||
|
query: {
|
||||||
|
...route.query,
|
||||||
|
p: newIndex
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页总数
|
||||||
|
const isShowPageTotal = computed(() => !deviceStore.isPhone.value);
|
||||||
|
|
||||||
|
// 排行
|
||||||
|
const ranking = reactive<ArticleRanking[]>([]);
|
||||||
|
onMounted(async () => {
|
||||||
|
ranking.push(...await ArticleAPI.listRanking());
|
||||||
|
});
|
||||||
|
|
||||||
|
// 友链
|
||||||
|
const friends = reactive<Friend[]>([]);
|
||||||
|
onMounted(async () => {
|
||||||
|
friends.push(...await BlogAPI.friends());
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.article-index {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: var(--tui-border);
|
||||||
|
|
||||||
|
.list {
|
||||||
|
border-right: var(--tui-border);
|
||||||
|
|
||||||
|
.pages {
|
||||||
|
bottom: -1px;
|
||||||
|
padding: var(--tui-page-padding);
|
||||||
|
position: sticky;
|
||||||
|
background: rgba(255, 255, 255, .8);
|
||||||
|
border-top: var(--tui-border);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.aside {
|
||||||
|
top: 49px;
|
||||||
|
width: 240px;
|
||||||
|
height: fit-content;
|
||||||
|
position: sticky;
|
||||||
|
min-width: 240px;
|
||||||
|
|
||||||
|
h4.title {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
padding: .5rem;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: var(--tui-border);
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-right: .5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking {
|
||||||
|
|
||||||
|
.items {
|
||||||
|
margin: 0;
|
||||||
|
padding: .5rem .5rem .5rem 2rem;
|
||||||
|
|
||||||
|
.item {
|
||||||
|
|
||||||
|
.title {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.friend {
|
||||||
|
margin-top: 2rem;
|
||||||
|
|
||||||
|
.items {
|
||||||
|
display: flex;
|
||||||
|
padding: .5rem;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-right: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 680px) {
|
||||||
|
.article-index {
|
||||||
|
|
||||||
|
.list {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aside {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
15
src/vite-env.d.ts
vendored
Normal file
15
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
declare module "*.vue" {
|
||||||
|
import type { DefineComponent } from "vue";
|
||||||
|
const component: DefineComponent<{}, {}, any>;
|
||||||
|
export default component;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "prismjs";
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
42
tsconfig.json
Normal file
42
tsconfig.json
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": [
|
||||||
|
"ESNext",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts",
|
||||||
|
"src/**/*.d.ts",
|
||||||
|
"src/**/*.vue",
|
||||||
|
"node_modules/tdesign-vue-next/global.d.ts"
|
||||||
|
],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
12
tsconfig.node.json
Normal file
12
tsconfig.node.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"vite.config.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
91
vite.config.ts
Normal file
91
vite.config.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
import dts from "vite-plugin-dts";
|
||||||
|
import { resolve } from "path";
|
||||||
|
import VueSetupExtend from "vite-plugin-vue-setup-extend";
|
||||||
|
import AutoImport from "unplugin-auto-import/vite";
|
||||||
|
import Components from "unplugin-vue-components/vite";
|
||||||
|
import { TDesignResolver } from "unplugin-vue-components/resolvers";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base: "/",
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
dts({
|
||||||
|
include: "./src"
|
||||||
|
}),
|
||||||
|
VueSetupExtend(),
|
||||||
|
AutoImport({
|
||||||
|
imports: [
|
||||||
|
"vue",
|
||||||
|
"vue-router",
|
||||||
|
"pinia",
|
||||||
|
{
|
||||||
|
"axios": [
|
||||||
|
["default", "axios"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
dts: "src/auto-imports.d.ts",
|
||||||
|
eslintrc: {
|
||||||
|
enabled: true,
|
||||||
|
globalsPropValue: true
|
||||||
|
},
|
||||||
|
resolvers: [
|
||||||
|
TDesignResolver({
|
||||||
|
library: "vue-next"
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
Components({
|
||||||
|
dirs: [
|
||||||
|
"src/components",
|
||||||
|
"src/layout",
|
||||||
|
"src/views/components"
|
||||||
|
],
|
||||||
|
resolvers: [
|
||||||
|
TDesignResolver({
|
||||||
|
library: "vue-next"
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
],
|
||||||
|
css: {
|
||||||
|
preprocessorOptions: {
|
||||||
|
less: {
|
||||||
|
math: "always",
|
||||||
|
lessOptions: {
|
||||||
|
modifyVars: {
|
||||||
|
"@btn-border-radius": "0"
|
||||||
|
},
|
||||||
|
javascriptEnabled: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
"process.env": {}
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": resolve(__dirname, "src")
|
||||||
|
},
|
||||||
|
extensions: [".js", ".json", ".ts"]
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 80,
|
||||||
|
host: "imyeyu.dev"
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
sourcemap: false,
|
||||||
|
minify: "terser",
|
||||||
|
terserOptions: {
|
||||||
|
compress: {
|
||||||
|
// eslint-disable-next-line camelcase
|
||||||
|
drop_console: true,
|
||||||
|
// eslint-disable-next-line camelcase
|
||||||
|
drop_debugger: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user