Compare commits

..

8 Commits

Author SHA1 Message Date
wangdage12
ecbf732e36 Merge pull request #2 from wangdage12/dev
修复路由问题
2026-01-29 13:22:40 +08:00
fanbook-wangdage
079358a910 修复路由问题 2026-01-29 13:15:18 +08:00
wangdage12
98a85f24eb Update README with project details and setup instructions
Added project description, deployment instructions, and commands for starting the development server and building static files.
2026-01-29 12:41:15 +08:00
wangdage12
bba35b3e20 Merge pull request #1 from wangdage12/dev
[V1.0.0] 添加基本功能
2026-01-29 12:31:17 +08:00
fanbook-wangdage
163248f90f 为登录页面添加粒子背景、添加主页 2026-01-29 12:19:44 +08:00
fanbook-wangdage
d4de66cd8a 添加公告中的发行版字段 2026-01-16 11:38:18 +08:00
fanbook-wangdage
29b8e6e8bf 解决构建错误 2025-12-21 20:13:58 +08:00
fanbook-wangdage
5f48917d3f 添加部分用户管理功能和公告管理功能 2025-12-21 17:10:02 +08:00
23 changed files with 2224 additions and 25 deletions

1
.env.development Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL = http://localhost:5222/

1
.env.production Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL = https://htserver.wdg.cloudns.ch/api/

View File

@@ -1,7 +1,31 @@
# Snap.Hutao服务器管理后台
还没写完,写完后使用
该项目用于管理Snap.Hutao项目的服务器的后台系统提供官网页面和用户、公告管理等功能。
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.
## 部署
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
确保你已经安装了Node.js和npm。
克隆仓库到本地,在项目根目录下运行以下命令安装依赖:
```bash
npm install
```
**编辑`.env.x`中的VITE_API_BASE_URL变量值为你的API地址环境变量文件名中的`x`为开发环境development或者生产环境production**
### 启动开发服务器
运行以下命令启动开发服务器:
```bash
npm run dev
```
### 构建静态文件
运行以下命令构建生产环境的静态文件:
```bash
npm run build
```

480
package-lock.json generated
View File

@@ -8,6 +8,8 @@
"name": "ht-web",
"version": "0.0.0",
"dependencies": {
"@tsparticles/slim": "^3.9.1",
"@tsparticles/vue3": "^3.0.1",
"axios": "^1.13.2",
"element-plus": "^2.13.0",
"pinia": "^3.0.4",
@@ -1395,6 +1397,484 @@
"win32"
]
},
"node_modules/@tsparticles/basic": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/basic/-/basic-3.9.1.tgz",
"integrity": "sha512-ijr2dHMx0IQHqhKW3qA8tfwrR2XYbbWYdaJMQuBo2CkwBVIhZ76U+H20Y492j/NXpd1FUnt2aC0l4CEVGVGdeQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/matteobruni"
},
{
"type": "github",
"url": "https://github.com/sponsors/tsparticles"
},
{
"type": "buymeacoffee",
"url": "https://www.buymeacoffee.com/matteobruni"
}
],
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1",
"@tsparticles/move-base": "3.9.1",
"@tsparticles/plugin-hex-color": "3.9.1",
"@tsparticles/plugin-hsl-color": "3.9.1",
"@tsparticles/plugin-rgb-color": "3.9.1",
"@tsparticles/shape-circle": "3.9.1",
"@tsparticles/updater-color": "3.9.1",
"@tsparticles/updater-opacity": "3.9.1",
"@tsparticles/updater-out-modes": "3.9.1",
"@tsparticles/updater-size": "3.9.1"
}
},
"node_modules/@tsparticles/engine": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/engine/-/engine-3.9.1.tgz",
"integrity": "sha512-DpdgAhWMZ3Eh2gyxik8FXS6BKZ8vyea+Eu5BC4epsahqTGY9V3JGGJcXC6lRJx6cPMAx1A0FaQAojPF3v6rkmQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/matteobruni"
},
{
"type": "github",
"url": "https://github.com/sponsors/tsparticles"
},
{
"type": "buymeacoffee",
"url": "https://www.buymeacoffee.com/matteobruni"
}
],
"hasInstallScript": true,
"license": "MIT"
},
"node_modules/@tsparticles/interaction-external-attract": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/interaction-external-attract/-/interaction-external-attract-3.9.1.tgz",
"integrity": "sha512-5AJGmhzM9o4AVFV24WH5vSqMBzOXEOzIdGLIr+QJf4fRh9ZK62snsusv/ozKgs2KteRYQx+L7c5V3TqcDy2upg==",
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1"
}
},
"node_modules/@tsparticles/interaction-external-bounce": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/interaction-external-bounce/-/interaction-external-bounce-3.9.1.tgz",
"integrity": "sha512-bv05+h70UIHOTWeTsTI1AeAmX6R3s8nnY74Ea6p6AbQjERzPYIa0XY19nq/hA7+Nrg+EissP5zgoYYeSphr85A==",
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1"
}
},
"node_modules/@tsparticles/interaction-external-bubble": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/interaction-external-bubble/-/interaction-external-bubble-3.9.1.tgz",
"integrity": "sha512-tbd8ox/1GPl+zr+KyHQVV1bW88GE7OM6i4zql801YIlCDrl9wgTDdDFGIy9X7/cwTvTrCePhrfvdkUamXIribQ==",
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1"
}
},
"node_modules/@tsparticles/interaction-external-connect": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/interaction-external-connect/-/interaction-external-connect-3.9.1.tgz",
"integrity": "sha512-sq8YfUNsIORjXHzzW7/AJQtfi/qDqLnYG2qOSE1WOsog39MD30RzmiOloejOkfNeUdcGUcfsDgpUuL3UhzFUOA==",
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1"
}
},
"node_modules/@tsparticles/interaction-external-grab": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/interaction-external-grab/-/interaction-external-grab-3.9.1.tgz",
"integrity": "sha512-QwXza+sMMWDaMiFxd8y2tJwUK6c+nNw554+/9+tEZeTTk2fCbB0IJ7p/TH6ZGWDL0vo2muK54Njv2fEey191ow==",
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1"
}
},
"node_modules/@tsparticles/interaction-external-pause": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/interaction-external-pause/-/interaction-external-pause-3.9.1.tgz",
"integrity": "sha512-Gzv4/FeNir0U/tVM9zQCqV1k+IAgaFjDU3T30M1AeAsNGh/rCITV2wnT7TOGFkbcla27m4Yxa+Fuab8+8pzm+g==",
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1"
}
},
"node_modules/@tsparticles/interaction-external-push": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/interaction-external-push/-/interaction-external-push-3.9.1.tgz",
"integrity": "sha512-GvnWF9Qy4YkZdx+WJL2iy9IcgLvzOIu3K7aLYJFsQPaxT8d9TF8WlpoMlWKnJID6H5q4JqQuMRKRyWH8aAKyQw==",
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1"
}
},
"node_modules/@tsparticles/interaction-external-remove": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/interaction-external-remove/-/interaction-external-remove-3.9.1.tgz",
"integrity": "sha512-yPThm4UDWejDOWW5Qc8KnnS2EfSo5VFcJUQDWc1+Wcj17xe7vdSoiwwOORM0PmNBzdDpSKQrte/gUnoqaUMwOA==",
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1"
}
},
"node_modules/@tsparticles/interaction-external-repulse": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/interaction-external-repulse/-/interaction-external-repulse-3.9.1.tgz",
"integrity": "sha512-/LBppXkrMdvLHlEKWC7IykFhzrz+9nebT2fwSSFXK4plEBxDlIwnkDxd3FbVOAbnBvx4+L8+fbrEx+RvC8diAw==",
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1"
}
},
"node_modules/@tsparticles/interaction-external-slow": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/interaction-external-slow/-/interaction-external-slow-3.9.1.tgz",
"integrity": "sha512-1ZYIR/udBwA9MdSCfgADsbDXKSFS0FMWuPWz7bm79g3sUxcYkihn+/hDhc6GXvNNR46V1ocJjrj0u6pAynS1KQ==",
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1"
}
},
"node_modules/@tsparticles/interaction-particles-attract": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/interaction-particles-attract/-/interaction-particles-attract-3.9.1.tgz",
"integrity": "sha512-CYYYowJuGwRLUixQcSU/48PTKM8fCUYThe0hXwQ+yRMLAn053VHzL7NNZzKqEIeEyt5oJoy9KcvubjKWbzMBLQ==",
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1"
}
},
"node_modules/@tsparticles/interaction-particles-collisions": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/interaction-particles-collisions/-/interaction-particles-collisions-3.9.1.tgz",
"integrity": "sha512-ggGyjW/3v1yxvYW1IF1EMT15M6w31y5zfNNUPkqd/IXRNPYvm0Z0ayhp+FKmz70M5p0UxxPIQHTvAv9Jqnuj8w==",
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1"
}
},
"node_modules/@tsparticles/interaction-particles-links": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/interaction-particles-links/-/interaction-particles-links-3.9.1.tgz",
"integrity": "sha512-MsLbMjy1vY5M5/hu/oa5OSRZAUz49H3+9EBMTIOThiX+a+vpl3sxc9AqNd9gMsPbM4WJlub8T6VBZdyvzez1Vg==",
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1"
}
},
"node_modules/@tsparticles/move-base": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/move-base/-/move-base-3.9.1.tgz",
"integrity": "sha512-X4huBS27d8srpxwOxliWPUt+NtCwY+8q/cx1DvQxyqmTA8VFCGpcHNwtqiN+9JicgzOvSuaORVqUgwlsc7h4pQ==",
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1"
}
},
"node_modules/@tsparticles/move-parallax": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/move-parallax/-/move-parallax-3.9.1.tgz",
"integrity": "sha512-whlOR0bVeyh6J/hvxf/QM3DqvNnITMiAQ0kro6saqSDItAVqg4pYxBfEsSOKq7EhjxNvfhhqR+pFMhp06zoCVA==",
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1"
}
},
"node_modules/@tsparticles/plugin-easing-quad": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/plugin-easing-quad/-/plugin-easing-quad-3.9.1.tgz",
"integrity": "sha512-C2UJOca5MTDXKUTBXj30Kiqr5UyID+xrY/LxicVWWZPczQW2bBxbIbfq9ULvzGDwBTxE2rdvIB8YFKmDYO45qw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/matteobruni"
},
{
"type": "github",
"url": "https://github.com/sponsors/tsparticles"
},
{
"type": "buymeacoffee",
"url": "https://www.buymeacoffee.com/matteobruni"
}
],
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1"
}
},
"node_modules/@tsparticles/plugin-hex-color": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/plugin-hex-color/-/plugin-hex-color-3.9.1.tgz",
"integrity": "sha512-vZgZ12AjUicJvk7AX4K2eAmKEQX/D1VEjEPFhyjbgI7A65eX72M465vVKIgNA6QArLZ1DLs7Z787LOE6GOBWsg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/matteobruni"
},
{
"type": "github",
"url": "https://github.com/sponsors/tsparticles"
},
{
"type": "buymeacoffee",
"url": "https://www.buymeacoffee.com/matteobruni"
}
],
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1"
}
},
"node_modules/@tsparticles/plugin-hsl-color": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/plugin-hsl-color/-/plugin-hsl-color-3.9.1.tgz",
"integrity": "sha512-jJd1iGgRwX6eeNjc1zUXiJivaqC5UE+SC2A3/NtHwwoQrkfxGWmRHOsVyLnOBRcCPgBp/FpdDe6DIDjCMO715w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/matteobruni"
},
{
"type": "github",
"url": "https://github.com/sponsors/tsparticles"
},
{
"type": "buymeacoffee",
"url": "https://www.buymeacoffee.com/matteobruni"
}
],
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1"
}
},
"node_modules/@tsparticles/plugin-rgb-color": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/plugin-rgb-color/-/plugin-rgb-color-3.9.1.tgz",
"integrity": "sha512-SBxk7f1KBfXeTnnklbE2Hx4jBgh6I6HOtxb+Os1gTp0oaghZOkWcCD2dP4QbUu7fVNCMOcApPoMNC8RTFcy9wQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/matteobruni"
},
{
"type": "github",
"url": "https://github.com/sponsors/tsparticles"
},
{
"type": "buymeacoffee",
"url": "https://www.buymeacoffee.com/matteobruni"
}
],
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1"
}
},
"node_modules/@tsparticles/shape-circle": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/shape-circle/-/shape-circle-3.9.1.tgz",
"integrity": "sha512-DqZFLjbuhVn99WJ+A9ajz9YON72RtCcvubzq6qfjFmtwAK7frvQeb6iDTp6Ze9FUipluxVZWVRG4vWTxi2B+/g==",
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1"
}
},
"node_modules/@tsparticles/shape-emoji": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/shape-emoji/-/shape-emoji-3.9.1.tgz",
"integrity": "sha512-ifvY63usuT+hipgVHb8gelBHSeF6ryPnMxAAEC1RGHhhXfpSRWMtE6ybr+pSsYU52M3G9+TF84v91pSwNrb9ZQ==",
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1"
}
},
"node_modules/@tsparticles/shape-image": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/shape-image/-/shape-image-3.9.1.tgz",
"integrity": "sha512-fCA5eme8VF3oX8yNVUA0l2SLDKuiZObkijb0z3Ky0qj1HUEVlAuEMhhNDNB9E2iELTrWEix9z7BFMePp2CC7AA==",
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1"
}
},
"node_modules/@tsparticles/shape-line": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/shape-line/-/shape-line-3.9.1.tgz",
"integrity": "sha512-wT8NSp0N9HURyV05f371cHKcNTNqr0/cwUu6WhBzbshkYGy1KZUP9CpRIh5FCrBpTev34mEQfOXDycgfG0KiLQ==",
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1"
}
},
"node_modules/@tsparticles/shape-polygon": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/shape-polygon/-/shape-polygon-3.9.1.tgz",
"integrity": "sha512-dA77PgZdoLwxnliH6XQM/zF0r4jhT01pw5y7XTeTqws++hg4rTLV9255k6R6eUqKq0FPSW1/WBsBIl7q/MmrqQ==",
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1"
}
},
"node_modules/@tsparticles/shape-square": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/shape-square/-/shape-square-3.9.1.tgz",
"integrity": "sha512-DKGkDnRyZrAm7T2ipqNezJahSWs6xd9O5LQLe5vjrYm1qGwrFxJiQaAdlb00UNrexz1/SA7bEoIg4XKaFa7qhQ==",
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1"
}
},
"node_modules/@tsparticles/shape-star": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/shape-star/-/shape-star-3.9.1.tgz",
"integrity": "sha512-kdMJpi8cdeb6vGrZVSxTG0JIjCwIenggqk0EYeKAwtOGZFBgL7eHhF2F6uu1oq8cJAbXPujEoabnLsz6mW8XaA==",
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1"
}
},
"node_modules/@tsparticles/slim": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/slim/-/slim-3.9.1.tgz",
"integrity": "sha512-CL5cDmADU7sDjRli0So+hY61VMbdroqbArmR9Av+c1Fisa5ytr6QD7Jv62iwU2S6rvgicEe9OyRmSy5GIefwZw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/matteobruni"
},
{
"type": "github",
"url": "https://github.com/sponsors/tsparticles"
},
{
"type": "buymeacoffee",
"url": "https://www.buymeacoffee.com/matteobruni"
}
],
"license": "MIT",
"dependencies": {
"@tsparticles/basic": "3.9.1",
"@tsparticles/engine": "3.9.1",
"@tsparticles/interaction-external-attract": "3.9.1",
"@tsparticles/interaction-external-bounce": "3.9.1",
"@tsparticles/interaction-external-bubble": "3.9.1",
"@tsparticles/interaction-external-connect": "3.9.1",
"@tsparticles/interaction-external-grab": "3.9.1",
"@tsparticles/interaction-external-pause": "3.9.1",
"@tsparticles/interaction-external-push": "3.9.1",
"@tsparticles/interaction-external-remove": "3.9.1",
"@tsparticles/interaction-external-repulse": "3.9.1",
"@tsparticles/interaction-external-slow": "3.9.1",
"@tsparticles/interaction-particles-attract": "3.9.1",
"@tsparticles/interaction-particles-collisions": "3.9.1",
"@tsparticles/interaction-particles-links": "3.9.1",
"@tsparticles/move-parallax": "3.9.1",
"@tsparticles/plugin-easing-quad": "3.9.1",
"@tsparticles/shape-emoji": "3.9.1",
"@tsparticles/shape-image": "3.9.1",
"@tsparticles/shape-line": "3.9.1",
"@tsparticles/shape-polygon": "3.9.1",
"@tsparticles/shape-square": "3.9.1",
"@tsparticles/shape-star": "3.9.1",
"@tsparticles/updater-life": "3.9.1",
"@tsparticles/updater-rotate": "3.9.1",
"@tsparticles/updater-stroke-color": "3.9.1"
}
},
"node_modules/@tsparticles/updater-color": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/updater-color/-/updater-color-3.9.1.tgz",
"integrity": "sha512-XGWdscrgEMA8L5E7exsE0f8/2zHKIqnTrZymcyuFBw2DCB6BIV+5z6qaNStpxrhq3DbIxxhqqcybqeOo7+Alpg==",
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1"
}
},
"node_modules/@tsparticles/updater-life": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/updater-life/-/updater-life-3.9.1.tgz",
"integrity": "sha512-Oi8aF2RIwMMsjssUkCB6t3PRpENHjdZf6cX92WNfAuqXtQphr3OMAkYFJFWkvyPFK22AVy3p/cFt6KE5zXxwAA==",
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1"
}
},
"node_modules/@tsparticles/updater-opacity": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/updater-opacity/-/updater-opacity-3.9.1.tgz",
"integrity": "sha512-w778LQuRZJ+IoWzeRdrGykPYSSaTeWfBvLZ2XwYEkh/Ss961InOxZKIpcS6i5Kp/Zfw0fS1ZAuqeHwuj///Osw==",
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1"
}
},
"node_modules/@tsparticles/updater-out-modes": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/updater-out-modes/-/updater-out-modes-3.9.1.tgz",
"integrity": "sha512-cKQEkAwbru+hhKF+GTsfbOvuBbx2DSB25CxOdhtW2wRvDBoCnngNdLw91rs+0Cex4tgEeibkebrIKFDDE6kELg==",
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1"
}
},
"node_modules/@tsparticles/updater-rotate": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/updater-rotate/-/updater-rotate-3.9.1.tgz",
"integrity": "sha512-9BfKaGfp28JN82MF2qs6Ae/lJr9EColMfMTHqSKljblwbpVDHte4umuwKl3VjbRt87WD9MGtla66NTUYl+WxuQ==",
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1"
}
},
"node_modules/@tsparticles/updater-size": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/updater-size/-/updater-size-3.9.1.tgz",
"integrity": "sha512-3NSVs0O2ApNKZXfd+y/zNhTXSFeG1Pw4peI8e6z/q5+XLbmue9oiEwoPy/tQLaark3oNj3JU7Q903ZijPyXSzw==",
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1"
}
},
"node_modules/@tsparticles/updater-stroke-color": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/updater-stroke-color/-/updater-stroke-color-3.9.1.tgz",
"integrity": "sha512-3x14+C2is9pZYTg9T2TiA/aM1YMq4wLdYaZDcHm3qO30DZu5oeQq0rm/6w+QOGKYY1Z3Htg9rlSUZkhTHn7eDA==",
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "3.9.1"
}
},
"node_modules/@tsparticles/vue3": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@tsparticles/vue3/-/vue3-3.0.1.tgz",
"integrity": "sha512-BxaSZ0wtxq33SDsrqLkLWoV88Jd5BnBoYjyVhKSNzOLOesCiG8Z5WQC1QZGTez79l/gBe0xaCDF0ng1e2iKJvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/matteobruni"
},
{
"type": "github",
"url": "https://github.com/sponsors/tsparticles"
},
{
"type": "buymeacoffee",
"url": "https://www.buymeacoffee.com/matteobruni"
}
],
"license": "MIT",
"dependencies": {
"@tsparticles/engine": "^3.0.3",
"vue": "^3.3.13"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",

View File

@@ -9,6 +9,8 @@
"preview": "vite preview"
},
"dependencies": {
"@tsparticles/slim": "^3.9.1",
"@tsparticles/vue3": "^3.0.1",
"axios": "^1.13.2",
"element-plus": "^2.13.0",
"pinia": "^3.0.4",

BIN
public/HT_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1008 KiB

1
public/README.md Normal file
View File

@@ -0,0 +1 @@
Copyright © 2025 DGP Studio, All Rights Reserved.

89
src/api/announcement.ts Normal file
View File

@@ -0,0 +1,89 @@
import request from '@/utils/request'
/** 公告数据类型 */
export interface Announcement {
Content: string
Id: number
LastUpdateTime: number
Link: string | null
Locale: string | null
MaxPresentVersion: string | null
Severity: number
Title: string
Distribution: string | null
}
/** 公告列表响应数据类型 */
export interface AnnouncementListResponse {
code: number
data: Announcement[]
message: string
}
/**
* 获取公告列表 API
* POST /Announcement/List
*/
export function getAnnouncementListApi(): Promise<Announcement[]> {
return request({
url: '/Announcement/List',
method: 'post',
})
}
/** 创建公告请求参数类型 */
export interface CreateAnnouncementRequest {
Content: string
Title: string
Link?: string | null
Locale?: string | null
MaxPresentVersion?: string | null
Severity?: number | null
Distribution?: string | null
}
/** 创建公告响应数据类型 */
export interface CreateAnnouncementResponse {
code: number
data: {
Id: number
}
message: string
}
/**
* 创建公告 API
* POST /web-api/announcement
* 注意由于request.ts拦截器处理实际返回的是data部分即 { Id: number }
*/
export function createAnnouncementApi(params: CreateAnnouncementRequest): Promise<{ Id: number }> {
return request({
url: '/web-api/announcement',
method: 'post',
data: params,
})
}
/**
* 编辑公告 API
* PUT /web-api/announcement/{announcement_id}
*/
export function updateAnnouncementApi(id: number, params: CreateAnnouncementRequest): Promise<null> {
return request({
url: `/web-api/announcement/${id}`,
method: 'put',
data: params,
})
}
/**
* 删除公告 API
* DELETE /web-api/announcement/{announcement_id}
*/
export function deleteAnnouncementApi(id: number): Promise<null> {
return request({
url: `/web-api/announcement/${id}`,
method: 'delete',
})
}

25
src/api/auth.ts Normal file
View File

@@ -0,0 +1,25 @@
import request from '@/utils/request'
/** 登录请求参数 */
export interface WebLoginParams {
email: string
password: string
}
/** 登录返回数据 */
export interface WebLoginResult {
access_token: string
expires_in: number
}
/**
* WEB 登录 API
* POST /web-api/login
*/
export function webLoginApi(data: WebLoginParams) {
return request<WebLoginResult>({
url: '/web-api/login',
method: 'post',
data,
})
}

60
src/api/user.ts Normal file
View File

@@ -0,0 +1,60 @@
import request from '@/utils/request'
/** 用户信息数据结构 */
export interface UserInfo {
CdnExpireAt: string
GachaLogExpireAt: string
IsLicensedDeveloper: boolean
IsMaintainer: boolean
NormalizedUserName: string
UserName: string
}
/** 用户列表中的用户数据结构 */
export interface UserListItem {
CdnExpireAt: string
CreatedAt: string
GachaLogExpireAt: string
IsLicensedDeveloper: boolean
IsMaintainer: boolean
NormalizedUserName: string
UserName: string
_id: string
email: string
}
/** API响应数据结构 */
export interface ApiResponse<T> {
code: number
data: T
message: string
}
/**
* 获取用户信息
* GET /Passport/v2/UserInfo
*/
export function getUserInfoApi(): Promise<UserInfo> {
return request({
url: '/Passport/v2/UserInfo',
method: 'get',
})
}
/**
* 获取用户列表
* GET /web-api/users
* @param q 搜索参数可搜索用户名、邮箱、_id
*/
export function getUserListApi(q?: string): Promise<UserListItem[]> {
const params: Record<string, any> = {}
if (q) {
params.q = q
}
return request({
url: '/web-api/users',
method: 'get',
params,
})
}

95
src/components/Header.vue Normal file
View File

@@ -0,0 +1,95 @@
<template>
<div class="app-header">
<div class="header-left">
<img :src="appIcon" alt="App Icon" class="app-icon" />
<span class="app-name">{{ appName }}</span>
</div>
<div class="header-right">
<template v-for="action in headerActions" :key="action.id">
<component
:is="action.component || 'el-button'"
v-bind="action.props"
@click="action.onClick"
class="header-action-btn"
>
<template v-if="action.icon">
<el-icon>
<component :is="action.icon" />
</el-icon>
</template>
<span>{{ action.label }}</span>
</component>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface HeaderAction {
id: string
label: string
icon?: any
component?: any
props?: Record<string, any>
onClick: () => void
}
interface Props {
appIcon?: string
appName?: string
actions?: HeaderAction[]
}
const props = withDefaults(defineProps<Props>(), {
appIcon: '/vite.svg',
appName: 'Snap Hutao Web',
actions: () => []
})
const headerActions = computed(() => props.actions)
</script>
<style scoped>
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 32px;
background: var(--header-bg);
border-bottom: 1px solid var(--border-color);
min-height: 70px;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.app-icon {
width: 40px;
height: 40px;
border-radius: 8px;
object-fit: contain;
}
.app-name {
font-size: 20px;
font-weight: 600;
color: var(--text-color);
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.header-action-btn {
display: flex;
align-items: center;
gap: 6px;
}
</style>

View File

@@ -0,0 +1,7 @@
<template>
<router-view></router-view>
</template>
<script setup lang="ts">
// 这是一个占位组件,用于嵌套路由
</script>

View File

@@ -3,7 +3,7 @@
<!-- 有子路由 -->
<el-sub-menu
v-if="hasChildren(item)"
:index="item.path"
:index="getFullPath(item)"
>
<template #title>
<el-icon v-if="item.meta?.icon">
@@ -12,13 +12,13 @@
<span>{{ item.meta?.title }}</span>
</template>
<SidebarItem :routes="item.children!" />
<SidebarItem :routes="item.children!" :parent-path="getFullPath(item)" />
</el-sub-menu>
<!-- 普通菜单 -->
<el-menu-item
v-else
:index="item.path"
:index="getFullPath(item)"
>
<el-icon v-if="item.meta?.icon">
<component :is="icons[item.meta.icon as keyof typeof icons]" />
@@ -32,10 +32,25 @@
import type { RouteRecordRaw } from 'vue-router'
import * as icons from '@element-plus/icons-vue'
defineProps<{
interface Props {
routes: RouteRecordRaw[]
}>()
parentPath?: string
}
const props = withDefaults(defineProps<Props>(), {
parentPath: '/dashboard'
})
const hasChildren = (route: RouteRecordRaw) =>
route.children && route.children.length > 0
const getFullPath = (route: RouteRecordRaw) => {
// 如果是绝对路径,直接返回
if (route.path.startsWith('/')) {
return route.path
}
// 否则拼接父级路径
const basePath = props.parentPath || ''
return basePath ? `${basePath}/${route.path}` : `/${route.path}`
}
</script>

View File

@@ -2,8 +2,8 @@
<el-container class="layout">
<el-aside :width="isCollapse ? '64px' : '200px'" class="aside">
<div class="logo" :class="{ collapsed: isCollapse }">
<span v-if="!isCollapse">Vite Admin</span>
<span v-else>VA</span>
<span v-if="!isCollapse">Snap Hutao Web</span>
<span v-else></span>
</div>
<el-menu
@@ -34,6 +34,20 @@
@change="themeStore.toggleTheme"
/>
</div>
<el-dropdown class="user-dropdown" @command="handleCommand">
<span class="user-info">
<el-avatar :size="32" :icon="UserFilled" />
<span class="username">{{ userStore.userInfo?.UserName || '用户' }}</span>
<el-icon class="el-icon--right"><arrow-down /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">个人中心</el-dropdown-item>
<el-dropdown-item divided command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
@@ -47,14 +61,31 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Fold, Expand, Moon, Sunny } from '@element-plus/icons-vue'
import { Fold, Expand, Moon, Sunny, UserFilled, ArrowDown } from '@element-plus/icons-vue'
import SidebarItem from '../components/SidebarItem.vue'
import { useThemeStore } from '../stores/theme'
import { useUserStore } from '../stores/user'
const isCollapse = ref(false)
const route = useRoute()
const router = useRouter()
const themeStore = useThemeStore()
const userStore = useUserStore()
// 处理下拉菜单命令
const handleCommand = (command: string) => {
switch (command) {
case 'profile':
// 跳转到个人中心
router.push('/profile')
break
case 'logout':
// 退出登录
userStore.logout()
router.push('/login')
break
}
}
const toggle = () => (isCollapse.value = !isCollapse.value)
@@ -64,9 +95,9 @@ const activeMenu = computed(() => route.path)
* 获取菜单路由,直接使用配置的路由结构
*/
const menuRoutes = computed(() => {
// 直接返回路由配置中的子路由
const mainRoute = router.options.routes.find(r => r.path === '/')
return mainRoute?.children || []
// 查找 /dashboard 路由的子路由
const dashboardRoute = router.options.routes.find(r => r.path === '/dashboard')
return dashboardRoute?.children || []
})
</script>
@@ -171,4 +202,19 @@ const menuRoutes = computed(() => {
background-color: var(--main-bg);
padding: 20px;
}
.user-dropdown {
margin-left: 20px;
}
.user-info {
display: flex;
align-items: center;
cursor: pointer;
color: var(--text-color);
}
.username {
margin: 0 8px;
}
</style>

View File

@@ -8,6 +8,10 @@ import '@/styles/index.scss'
import App from './App.vue'
import router from './router'
import { useThemeStore } from './stores/theme'
import Particles from '@tsparticles/vue3'
import { loadSlim } from '@tsparticles/slim'
import type { Engine } from '@tsparticles/engine'
const app = createApp(App)
const pinia = createPinia()
@@ -15,6 +19,12 @@ const pinia = createPinia()
app.use(pinia)
app.use(router)
app.use(ElementPlus)
app.use(Particles, {
init: async (engine: Engine) => {
await loadSlim(engine)
}
})
import '@/router/permission'
// 初始化主题
const themeStore = useThemeStore(pinia)

View File

@@ -9,21 +9,27 @@ const routes = [
},
{
path: '/',
component: () => import('@/views/home/index.vue'),
meta: { hidden: true }
},
{
path: '/dashboard',
component: DefaultLayout,
redirect: '/dashboard',
redirect: '/dashboard/home',
children: [
{
path: 'dashboard',
path: 'home',
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '首页', icon: 'House' },
},
{
path: 'user',
component: () => import('@/views/dashboard/index.vue'),
component: () => import('@/views/user/index.vue'),
meta: { title: '用户管理', icon: 'User' },
},
{
path: 'system',
component: () => import('@/components/RouterViewPlaceholder.vue'),
meta: { title: '系统管理', icon: 'Setting' },
children: [
{
@@ -36,6 +42,11 @@ const routes = [
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '角色管理', icon: 'UserFilled' },
},
{
path: 'announcement',
component: () => import('@/views/announcement/index.vue'),
meta: { title: '公告管理', icon: 'Bell' },
},
],
},
],

38
src/router/permission.ts Normal file
View File

@@ -0,0 +1,38 @@
import router from './index'
import { useUserStore } from '@/stores/user'
router.beforeEach(async (to, _ , next) => {
const userStore = useUserStore()
// 未登录
if (!userStore.token) {
// 主页(/)允许未登录访问
if (to.path === '/' || to.path === '/login') {
next()
} else {
next('/login')
}
return
}
// 已登录还去 login
if (to.path === '/login') {
next('/')
return
}
// 如果没有用户信息,尝试获取
if (!userStore.userInfo) {
try {
await userStore.fetchUserInfo()
} catch (error) {
// 获取用户信息失败可能token已过期跳转到登录页
console.error('获取用户信息失败:', error)
userStore.logout()
next('/login')
return
}
}
next()
})

31
src/stores/user.ts Normal file
View File

@@ -0,0 +1,31 @@
import { defineStore } from 'pinia'
import { getUserInfoApi } from '@/api/user'
import type { UserInfo } from '@/api/user'
export const useUserStore = defineStore('user', {
state: () => ({
token: localStorage.getItem('token') || '',
userInfo: null as UserInfo | null,
}),
actions: {
setToken(token: string) {
this.token = token
// 持久化存储token
localStorage.setItem('token', token)
},
async fetchUserInfo() {
const userData = await getUserInfoApi()
this.userInfo = userData
return userData
},
logout() {
this.token = ''
this.userInfo = null
// 清除本地存储的token如果有
localStorage.removeItem('token')
},
},
})

View File

@@ -1,18 +1,66 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
const request = axios.create({
baseURL: '/api',
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000,
})
/** 请求拦截:自动加 Token */
request.interceptors.request.use((config) => {
// 可加 token
const userStore = useUserStore()
if (userStore.token) {
config.headers = config.headers || {}
config.headers.Authorization = `Bearer ${userStore.token}`
}
return config
})
/** 响应拦截:兼容 code / retcode */
request.interceptors.response.use(
(res) => res.data,
(err) => Promise.reject(err)
(response) => {
const res = response.data
// 登录接口code
if ('code' in res) {
if (res.code !== 0) {
ElMessage.error(res.message || '请求失败')
return Promise.reject(res.message)
}
return res.data
}
// 用户信息接口retcode
if ('retcode' in res) {
if (res.retcode !== 0) {
ElMessage.error(res.message || '请求失败')
return Promise.reject(res.message)
}
return res.data
}
// 兜底
return res
},
(error) => {
// 处理401未授权错误
if (error.response?.status === 401) {
const userStore = useUserStore()
userStore.logout()
// 如果不在登录页,则跳转到登录页
if (window.location.pathname !== '/login') {
window.location.href = '/login'
}
ElMessage.error('登录已过期,请重新登录')
return Promise.reject(new Error('登录已过期'))
}
ElMessage.error(error.message || '网络错误')
return Promise.reject(error)
}
)
export default request

View File

@@ -0,0 +1,603 @@
<template>
<el-card>
<template #header>
<div class="card-header">
<span>公告管理</span>
<div>
<el-button type="success" @click="handleCreate">创建公告</el-button>
<el-button type="primary" @click="handleRefresh">刷新</el-button>
</div>
</div>
</template>
<el-table
v-loading="loading"
:data="announcementList"
style="width: 100%"
>
<el-table-column prop="Id" label="ID" width="80" />
<el-table-column prop="Title" label="标题" width="200" />
<el-table-column prop="Content" label="内容" show-overflow-tooltip />
<el-table-column prop="Severity" label="严重等级" width="100">
<template #default="{ row }">
<el-tag
:type="getSeverityType(row.Severity)"
size="small"
>
{{ getSeverityText(row.Severity) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="Distribution" label="发行版" width="120">
<template #default="{ row }">
<el-tag v-if="row.Distribution" size="small" type="info">
{{ row.Distribution }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="Link" label="链接" width="200">
<template #default="{ row }">
<el-link
v-if="row.Link"
:href="row.Link"
target="_blank"
type="primary"
>
查看详情
</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="LastUpdateTime" label="更新时间" width="180">
<template #default="{ row }">
{{ formatTime(row.LastUpdateTime) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button
size="small"
type="primary"
link
@click="handleView(row)"
>
查看
</el-button>
<el-button
size="small"
type="warning"
link
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
size="small"
type="danger"
link
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 公告详情弹窗 -->
<el-dialog
v-model="dialogVisible"
title="公告详情"
width="50%"
>
<div
v-if="currentAnnouncement"
:class="['announcement-box', getSeverityClass(currentAnnouncement.Severity)]"
>
<!-- 标题 -->
<div class="announcement-title">
{{ currentAnnouncement.Title }}
</div>
<!-- 内容 -->
<div class="announcement-content">
<pre>{{ currentAnnouncement.Content }}</pre>
</div>
<!-- 底部信息 -->
<div class="announcement-footer">
<span class="announcement-time">
{{ formatTime(currentAnnouncement.LastUpdateTime) }}
</span>
<el-link
v-if="currentAnnouncement.Link"
:href="currentAnnouncement.Link"
target="_blank"
type="primary"
>
查看详情
</el-link>
</div>
</div>
</el-dialog>
<!-- 创建公告弹窗 -->
<el-dialog
v-model="createDialogVisible"
title="创建公告"
width="60%"
>
<el-form
ref="createFormRef"
:model="createForm"
:rules="createRules"
label-width="100px"
>
<el-form-item label="标题" prop="Title">
<el-input
v-model="createForm.Title"
placeholder="请输入公告标题"
/>
</el-form-item>
<el-form-item label="内容" prop="Content">
<el-input
v-model="createForm.Content"
type="textarea"
:rows="6"
placeholder="请输入公告内容"
/>
</el-form-item>
<el-form-item label="链接">
<el-input
v-model="createForm.Link"
placeholder="可选,详细信息链接"
/>
</el-form-item>
<el-form-item label="严重等级">
<el-select
v-model="createForm.Severity"
placeholder="请选择严重等级"
clearable
>
<el-option label="信息" :value="0" />
<el-option label="低" :value="1" />
<el-option label="中" :value="2" />
<el-option label="高" :value="3" />
</el-select>
</el-form-item>
<el-form-item label="语言代码">
<el-input
v-model="createForm.Locale"
placeholder="可选,如 zh-CN, en-US"
/>
</el-form-item>
<el-form-item label="最大显示版本">
<el-input
v-model="createForm.MaxPresentVersion"
placeholder="可选,最大显示版本号"
/>
</el-form-item>
<el-form-item label="发行版名称">
<el-input
v-model="createForm.Distribution"
placeholder="可选,用于区分不同发行版,默认为空表示所有发行版"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="createDialogVisible = false">取消</el-button>
<el-button
type="primary"
:loading="createLoading"
@click="handleCreateSubmit"
>
创建
</el-button>
</div>
</template>
</el-dialog>
<!-- 编辑公告弹窗 -->
<el-dialog
v-model="editDialogVisible"
title="编辑公告"
width="60%"
>
<el-form
ref="editFormRef"
:model="editForm"
:rules="createRules"
label-width="100px"
>
<el-form-item label="标题" prop="Title">
<el-input
v-model="editForm.Title"
placeholder="请输入公告标题"
/>
</el-form-item>
<el-form-item label="内容" prop="Content">
<el-input
v-model="editForm.Content"
type="textarea"
:rows="6"
placeholder="请输入公告内容"
/>
</el-form-item>
<el-form-item label="链接">
<el-input
v-model="editForm.Link"
placeholder="可选,详细信息链接"
/>
</el-form-item>
<el-form-item label="严重等级">
<el-select
v-model="editForm.Severity"
placeholder="请选择严重等级"
clearable
>
<el-option label="信息" :value="0" />
<el-option label="低" :value="1" />
<el-option label="中" :value="2" />
<el-option label="高" :value="3" />
</el-select>
</el-form-item>
<el-form-item label="语言代码">
<el-input
v-model="editForm.Locale"
placeholder="可选,如 zh-CN, en-US"
/>
</el-form-item>
<el-form-item label="最大显示版本">
<el-input
v-model="editForm.MaxPresentVersion"
placeholder="可选,最大显示版本号"
/>
</el-form-item>
<el-form-item label="发行版名称">
<el-input
v-model="editForm.Distribution"
placeholder="可选,用于区分不同发行版,默认为空表示所有发行版"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button
type="primary"
:loading="editLoading"
@click="handleEditSubmit"
>
保存
</el-button>
</div>
</template>
</el-dialog>
</el-card>
</template>
<script setup lang="ts">
import { ref, onMounted, reactive } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { getAnnouncementListApi, createAnnouncementApi, updateAnnouncementApi, deleteAnnouncementApi, type Announcement, type CreateAnnouncementRequest } from '@/api/announcement'
const loading = ref(false)
const announcementList = ref<Announcement[]>([])
const dialogVisible = ref(false)
const currentAnnouncement = ref<Announcement | null>(null)
// 创建公告相关
const createDialogVisible = ref(false)
const createLoading = ref(false)
const createFormRef = ref<FormInstance>()
// 编辑公告相关
const editDialogVisible = ref(false)
const editLoading = ref(false)
const editFormRef = ref<FormInstance>()
const currentEditId = ref<number | null>(null)
const createForm = reactive<CreateAnnouncementRequest>({
Title: '',
Content: '',
Link: '',
Locale: '',
MaxPresentVersion: '',
Severity: 0,
Distribution: '',
})
const editForm = reactive<CreateAnnouncementRequest>({
Title: '',
Content: '',
Link: '',
Locale: '',
MaxPresentVersion: '',
Severity: 0,
Distribution: '',
})
const createRules: FormRules = {
Title: [
{ required: true, message: '请输入公告标题', trigger: 'blur' },
{ min: 1, max: 200, message: '标题长度应为 1 到 200 个字符', trigger: 'blur' },
],
Content: [
{ required: true, message: '请输入公告内容', trigger: 'blur' },
{ min: 1, max: 2000, message: '内容长度应为 1 到 2000 个字符', trigger: 'blur' },
],
}
const getAnnouncementList = async () => {
loading.value = true
try {
const data = await getAnnouncementListApi()
announcementList.value = data || []
} catch (error) {
ElMessage.error('获取公告列表失败')
} finally {
loading.value = false
}
}
const handleRefresh = () => {
getAnnouncementList()
}
const handleView = (announcement: Announcement) => {
currentAnnouncement.value = announcement
dialogVisible.value = true
}
const getSeverityType = (severity: number) => {
if (severity === 0) return 'info'
if (severity === 1) return 'success'
if (severity === 2) return 'warning'
return 'danger'
}
const getSeverityText = (severity: number) => {
if (severity === 0) return '信息'
if (severity === 1) return '低'
if (severity === 2) return '中'
return '高'
}
const formatTime = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleString()
}
const getSeverityClass = (severity: number) => {
if (severity === 0) return 'announcement-box-info'
if (severity === 1) return 'announcement-box-success'
if (severity === 2) return 'announcement-box-warning'
return 'announcement-box-danger'
}
const handleCreate = () => {
// 重置表单
Object.assign(createForm, {
Title: '',
Content: '',
Link: '',
Locale: '',
MaxPresentVersion: '',
Severity: 0,
Distribution: '',
})
createDialogVisible.value = true
}
const handleCreateSubmit = async () => {
if (!createFormRef.value) return
try {
await createFormRef.value.validate()
createLoading.value = true
// 由于request.ts拦截器已经处理了code字段这里直接返回data
const result = await createAnnouncementApi(createForm)
// result 直接就是 { Id: number }
if (result && result.Id) {
ElMessage.success('公告创建成功')
createDialogVisible.value = false
// 刷新列表
await getAnnouncementList()
} else {
ElMessage.error('创建公告失败')
}
} catch (error) {
ElMessage.error('创建公告失败')
} finally {
createLoading.value = false
}
}
const handleEdit = (announcement: Announcement) => {
currentEditId.value = announcement.Id
// 填充表单数据
Object.assign(editForm, {
Title: announcement.Title,
Content: announcement.Content,
Link: announcement.Link || '',
Locale: announcement.Locale || '',
MaxPresentVersion: announcement.MaxPresentVersion || '',
Severity: announcement.Severity,
Distribution: announcement.Distribution || '',
})
editDialogVisible.value = true
}
const handleEditSubmit = async () => {
if (!editFormRef.value || !currentEditId.value) return
try {
await editFormRef.value.validate()
editLoading.value = true
await updateAnnouncementApi(currentEditId.value, editForm)
ElMessage.success('公告更新成功')
editDialogVisible.value = false
// 刷新列表
await getAnnouncementList()
} catch (error) {
ElMessage.error('更新公告失败')
} finally {
editLoading.value = false
}
}
const handleDelete = async (announcement: Announcement) => {
try {
await ElMessageBox.confirm(
`确定要删除公告"${announcement.Title}"吗?`,
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
)
await deleteAnnouncementApi(announcement.Id)
ElMessage.success('公告删除成功')
// 刷新列表
await getAnnouncementList()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除公告失败')
}
}
}
onMounted(() => {
getAnnouncementList()
})
</script>
<style>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
/* 公告正文容器 */
.announcement-box {
padding: 16px;
border-radius: 6px;
background-color: var(--announcement-bg);
color: var(--announcement-text);
transition: background-color 0.2s, color 0.2s;
}
/* 不同严重等级的公告背景色和文字色 */
.announcement-box-info {
background-color: var(--announcement-bg-info);
color: var(--announcement-text-info);
}
.announcement-box-success {
background-color: var(--announcement-bg-success);
color: var(--announcement-text-success);
}
.announcement-box-warning {
background-color: var(--announcement-bg-warning);
color: var(--announcement-text-warning);
}
.announcement-box-danger {
background-color: var(--announcement-bg-danger);
color: var(--announcement-text-danger);
}
/* 标题 */
.announcement-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 12px;
}
/* 内容 */
.announcement-content {
margin-bottom: 16px;
}
.announcement-content pre {
white-space: pre-wrap;
word-break: break-word;
margin: 0;
font-family: inherit;
line-height: 1.6;
}
/* 底部 */
.announcement-footer {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
}
/* 时间 */
.announcement-time {
color: var(--announcement-time);
}
/* ===== 明亮模式变量 ===== */
:root {
--announcement-bg: #eaf3ff;
--announcement-text: #303133;
--announcement-time: #909399;
--announcement-bg-info: #e1e1e1;
--announcement-text-info: #000000;
--announcement-bg-success: #f0f9eb;
--announcement-text-success: #3a7a3a;
--announcement-bg-warning: #fdf6ec;
--announcement-text-warning: #b26a00;
--announcement-bg-danger: #fef0f0;
--announcement-text-danger: #a94442;
}
/* ===== 暗色模式Element Plus ===== */
html.dark {
--announcement-bg: #1f1f1f;
--announcement-text: #e5eaf3;
--announcement-time: #a3a6ad;
--announcement-bg-info: #575e64;
--announcement-text-info: #ffffff;
--announcement-bg-success: #1e2b22;
--announcement-text-success: #a5d6a7;
--announcement-bg-warning: #2c211b;
--announcement-text-warning: #ffd54f;
--announcement-bg-danger: #2a1a1a;
--announcement-text-danger: #ef9a9a;
}
</style>

219
src/views/home/index.vue Normal file
View File

@@ -0,0 +1,219 @@
<template>
<div class="home-page">
<Header
:app-icon="appIcon"
:app-name="appName"
:actions="headerActions"
/>
<div class="home-content">
<div class="hero-section">
<img :src="appIcon" alt="App Icon" class="hero-icon" />
<h1 class="hero-title">{{ appName }}</h1>
<p class="hero-description">
{{ appDescription }}
</p>
<div class="hero-buttons">
<template v-for="button in heroButtons" :key="button.id">
<el-button
:type="button.type || 'primary'"
:size="button.size || 'large'"
@click="button.onClick"
>
<el-icon v-if="button.icon">
<component :is="button.icon" />
</el-icon>
<span>{{ button.label }}</span>
</el-button>
</template>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { Setting, Document, ChatDotRound } from '@element-plus/icons-vue'
import Header from '@/components/Header.vue'
import { ElMessage } from 'element-plus'
const router = useRouter()
// 配置项
const appIcon = ref('/HT_logo.png')
const appName = ref('Snap Hutao')
const appDescription = ref('胡桃工具箱是一款以 MIT 协议开源的原神工具箱,专为现代化 Windows 平台设计,旨在改善桌面端玩家的游戏体验。')
// 页头右侧按钮配置
const headerActions = ref([
{
id: 'github',
label: 'GitHub 仓库',
icon: Document,
component: 'el-button',
props: {
type: 'default',
link: true
},
onClick: () => {
window.open('https://github.com/wangdage12/Snap.Hutao', '_blank')
}
},
{
id: 'discord',
label: '加入 Discord 服务器',
icon: ChatDotRound,
component: 'el-button',
props: {
type: 'default',
link: true
},
onClick: () => {
window.open('https://discord.gg/ucH3mgeWpQ', '_blank')
}
},
{
id: 'console',
label: '控制台',
icon: Setting,
component: 'el-button',
props: {
type: 'primary'
},
onClick: () => {
router.push('/dashboard')
}
}
])
// 主页中心按钮配置
const heroButtons = ref([
{
id: 'btn1',
label: '快速开始',
type: 'primary',
size: 'large',
icon: undefined,
onClick: () => {
window.open('https://github.com/wangdage12/Snap.Hutao', '_blank')
}
},
{
id: 'btn2',
label: '查看文档',
type: 'default',
size: 'large',
icon: undefined,
onClick: () => {
// window.open('https://github.com/wangdage12/Snap.Server.Web', '_blank')
ElMessage.info('文档正在编写中,敬请期待!')
}
}
])
</script>
<style scoped>
.home-page {
min-height: 100vh;
background: var(--main-bg);
display: flex;
flex-direction: column;
}
.home-content {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
}
.hero-section {
text-align: center;
max-width: 800px;
}
.hero-icon {
width: 120px;
height: 120px;
margin-bottom: 10px;
border-radius: 20px;
object-fit: contain;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
.hero-title {
font-size: 48px;
font-weight: 700;
margin-bottom: 16px;
color: var(--text-color);
background: linear-gradient(135deg, var(--aside-active), #67c23a);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero-description {
font-size: 18px;
color: var(--text-color);
line-height: 1.6;
margin-bottom: 40px;
opacity: 0.8;
}
.hero-buttons {
display: flex;
gap: 16px;
justify-content: center;
flex-wrap: wrap;
}
.hero-buttons .el-button {
display: flex;
align-items: center;
gap: 6px;
padding: 16px 32px;
font-size: 16px;
border-radius: 8px;
transition: all 0.3s ease;
}
.hero-buttons .el-button:hover {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}
@media (max-width: 768px) {
.app-header {
padding: 12px 16px;
}
.hero-title {
font-size: 36px;
}
.hero-description {
font-size: 16px;
}
.hero-buttons {
flex-direction: column;
align-items: stretch;
}
.hero-buttons .el-button {
width: 100%;
}
}
</style>

View File

@@ -1,7 +1,212 @@
<template>
<div class="login-container">
<h1>Login Page</h1>
<!-- Login form goes here -->
<!-- 粒子动画背景 -->
<vue-particles
id="tsparticles"
:options="particleOptions"
/>
<el-card class="login-card">
<h2 class="title">Snap Hutao服务器管理-登录</h2>
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="rules"
label-width="80px"
>
<el-form-item label="邮箱" prop="email">
<el-input
v-model="loginForm.email"
placeholder="请输入邮箱"
clearable
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
show-password
clearable
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="loading"
style="width: 100%"
@click="handleLogin"
>
登录
</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { webLoginApi } from '@/api/auth'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
// 表单实例
const loginFormRef = ref<FormInstance>()
// loading 状态
const loading = ref(false)
// 表单数据
const loginForm = reactive({
email: '',
password: '',
})
const router = useRouter()
// 校验规则
const rules: FormRules = {
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码至少 6 个字符', trigger: 'blur' },
],
}
// 登录方法
const handleLogin = async () => {
if (!loginFormRef.value) return
await loginFormRef.value.validate(async (valid) => {
if (!valid) return
loading.value = true
try {
const response = await webLoginApi(loginForm)
const userStore = useUserStore()
// 处理响应数据结构
const tokenData = response.data || response
userStore.setToken(tokenData.access_token)
// 获取用户信息
await userStore.fetchUserInfo()
ElMessage.success('登录成功')
router.push({ path: '/dashboard' })
} catch (error: any) {
ElMessage.error(error.message || '登录失败,请重试')
} finally {
loading.value = false
}
})
}
// 粒子配置选项
const particleOptions = ref({
background: {
color: "#0d0d0d"
},
fpsLimit: 60,
particles: {
number: {
value: 200,
density: {
enable: true,
area: 800
}
},
color: {
value: "#00bcd4"
},
shape: {
type: "circle"
},
opacity: {
value: 0.6
},
size: {
value: 2
},
move: {
enable: true,
speed: 0.6,
direction: "none",
outModes: "out"
},
links: {
enable: true,
distance: 120,
color: "#00bcd4",
opacity: 0.4,
width: 1
}
},
interactivity: {
events: {
onHover: {
enable: true,
mode: "grab"
},
onClick: {
enable: true,
mode: "push"
}
},
modes: {
grab: {
distance: 180,
links: {
opacity: 0.6
}
},
push: {
quantity: 4
}
}
},
detectRetina: true
})
</script>
<style scoped>
.login-container {
width: 100vw;
height: 100vh;
background: linear-gradient(135deg, #409eff, #66b1ff);
display: flex;
align-items: center;
justify-content: center;
}
.login-card {
width: 400px;
padding: 20px;
position: relative;
z-index: 10;
}
.title {
text-align: center;
margin-bottom: 20px;
}
</style>

188
src/views/user/index.vue Normal file
View File

@@ -0,0 +1,188 @@
<template>
<div class="user-management">
<!-- 搜索栏和用户统计并排 -->
<div class="search-statistics-row">
<!-- 搜索栏 -->
<el-form :inline="true" :model="searchForm" class="search-form">
<el-form-item label="关键词">
<el-input
v-model="searchForm.keyword"
placeholder="请输入用户名、邮箱或ID"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch" :loading="loading">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 用户统计 -->
<div class="statistics" v-if="!loading && displayUserList.length > 0">
<el-statistic title="当前显示用户数" :value="displayUserList.length" />
<el-statistic title="运维人员" :value="maintainerCount" />
<el-statistic title="开发者" :value="developerCount" />
<el-statistic v-if="isSearchMode" title="搜索模式" value="进行中" />
</div>
</div>
<!-- 操作按钮 -->
<div class="toolbar">
<el-button type="primary" @click="handleRefresh" :loading="loading">刷新</el-button>
</div>
<!-- 用户表格 -->
<el-table
:data="displayUserList"
style="width: 100%"
border
v-loading="loading"
element-loading-text="正在加载用户数据..."
>
<el-table-column prop="_id" label="ID" width="120" />
<el-table-column prop="UserName" label="用户名" min-width="150" />
<el-table-column prop="email" label="邮箱" min-width="200" />
<el-table-column label="角色" width="150">
<template #default="scope">
<el-tag v-if="scope.row.IsMaintainer" type="danger">运维</el-tag>
<el-tag v-if="scope.row.IsLicensedDeveloper" type="success">开发者</el-tag>
<el-tag v-if="!scope.row.IsMaintainer && !scope.row.IsLicensedDeveloper" type="info">普通用户</el-tag>
</template>
</el-table-column>
<el-table-column prop="CreatedAt" label="注册时间" width="180" />
<el-table-column label="权限状态" width="120">
<template #default="scope">
<el-tag :type="scope.row.IsMaintainer || scope.row.IsLicensedDeveloper ? 'success' : 'info'">
{{ scope.row.IsMaintainer || scope.row.IsLicensedDeveloper ? '高权限' : '普通' }}
</el-tag>
</template>
</el-table-column>
</el-table>
<!-- 空状态 -->
<el-empty v-if="!loading && displayUserList.length === 0" description="暂无用户数据" />
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getUserListApi, type UserListItem } from '@/api/user'
interface SearchForm {
keyword: string
}
const searchForm = reactive<SearchForm>({
keyword: ''
})
const userList = ref<UserListItem[]>([])
const loading = ref(false)
const isSearchMode = ref(false)
// 显示的用户列表(根据是否在搜索模式决定显示全部还是搜索结果)
const displayUserList = computed(() => {
return userList.value
})
// 统计数据
const maintainerCount = computed(() =>
userList.value.filter(user => user.IsMaintainer).length
)
const developerCount = computed(() =>
userList.value.filter(user => user.IsLicensedDeveloper).length
)
// 获取用户列表
async function fetchUserList(searchKeyword?: string) {
loading.value = true
try {
const data = await getUserListApi(searchKeyword)
userList.value = data
if (searchKeyword) {
ElMessage.success(`搜索完成,找到 ${data.length} 个匹配的用户`)
isSearchMode.value = true
} else {
ElMessage.success('用户列表加载成功')
isSearchMode.value = false
}
} catch (error) {
console.error('获取用户列表失败:', error)
ElMessage.error('获取用户列表失败,请检查权限设置')
userList.value = []
} finally {
loading.value = false
}
}
function handleSearch() {
if (!searchForm.keyword.trim()) {
ElMessage.warning('请输入搜索关键词')
return
}
fetchUserList(searchForm.keyword.trim())
}
function handleReset() {
searchForm.keyword = ''
isSearchMode.value = false
fetchUserList() // 重新获取全部用户列表
}
function handleRefresh() {
if (isSearchMode.value && searchForm.keyword) {
fetchUserList(searchForm.keyword)
} else {
fetchUserList()
}
}
// function handleAdd() {
// ElMessage.info('新增用户功能待实现')
// }
// function handleEdit(row: UserListItem) {
// ElMessage.info(`编辑用户 ${row.UserName} 功能待实现`)
// }
// function handleDelete(row: UserListItem) {
// ElMessage.info(`删除用户 ${row.UserName} 功能待实现`)
// }
// 页面加载时获取用户列表
onMounted(() => {
fetchUserList()
})
</script>
<style scoped>
.user-management {
padding: 24px;
}
/* 新增flex布局 */
.search-statistics-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 16px;
}
.search-form {
/* 保持原有样式,可根据需要调整宽度 */
margin-bottom: 0;
flex: 1;
}
.statistics {
margin-left: 32px;
display: flex;
gap: 24px;
margin-top: 0;
}
.toolbar {
margin-bottom: 16px;
}
</style>