Compare commits

..

18 Commits

Author SHA1 Message Date
wangdage12 503cfb3fee 添加截图 2026-03-09 20:28:02 +08:00
wangdage12 626251e110 Merge pull request #5 from wangdage12/dev
添加祈愿记录页面、根据用户权限显示侧边栏项目
2026-03-09 20:09:46 +08:00
fanbook-wangdage 0527c185ec 添加router参数 2026-03-09 19:52:12 +08:00
fanbook-wangdage c97cd7cd5d 添加Session Replay配置 2026-03-09 19:39:33 +08:00
fanbook-wangdage b86a765bb1 添加sentry 2026-03-09 19:32:15 +08:00
fanbook-wangdage 1bba83a3c6 添加祈愿记录页面,根据用户权限显示侧边栏项目 2026-03-09 19:19:18 +08:00
wangdage12 eca8f9856e Merge pull request #4 from wangdage12/dev
下载页面支持筛选测试版包,管理后台中用户列表支持高级筛选
2026-02-10 20:36:43 +08:00
fanbook-wangdage 29409e3e95 添加用户列表的高级筛选功能 2026-02-10 20:20:40 +08:00
fanbook-wangdage e58057665b 下载页面支持显示测试版 2026-02-10 12:58:41 +08:00
fanbook-wangdage 47a4c63de2 msix是使用zip打包的 2026-02-08 13:47:20 +08:00
wangdage12 ef40a8f6f6 Merge pull request #3 from wangdage12/dev
添加下载页面和下载资源管理后台
2026-02-05 22:14:06 +08:00
fanbook-wangdage 7e08de8250 添加下载页面和下载资源管理后台 2026-02-05 21:52:41 +08:00
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
wangdage12 b0a7b98091 Add MIT License to the project 2025-12-28 17:07:00 +08:00
27 changed files with 8451 additions and 116 deletions
+2
View File
@@ -22,3 +22,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
/test_scripts
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 wangdage12
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+34 -3
View File
@@ -1,7 +1,38 @@
# 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.
<img width="1843" height="818" alt="image" src="https://github.com/user-attachments/assets/1c5f21fb-23cf-4c32-9936-db13dfc3d3d4" />
<img width="1837" height="521" alt="image" src="https://github.com/user-attachments/assets/bd7e373c-60a1-44c6-b16b-54f3fb352e4e" />
<img width="1625" height="566" alt="image" src="https://github.com/user-attachments/assets/87ec56e3-68d9-4996-8fa8-ce08f8c1c896" />
<img width="1632" height="414" alt="image" src="https://github.com/user-attachments/assets/85fa4946-c50c-4f6f-8c37-0170b8f7246a" />
<img width="1629" height="749" alt="image" src="https://github.com/user-attachments/assets/4cb02464-b2a9-41c2-bc14-9dba10c26132" />
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
```
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ht-web</title>
<title>胡桃工具箱-WDG Snap Hutao</title>
</head>
<body>
<div id="app"></div>
+602
View File
@@ -8,6 +8,10 @@
"name": "ht-web",
"version": "0.0.0",
"dependencies": {
"@primer/css": "^22.1.0",
"@sentry/vue": "^10.42.0",
"@tsparticles/slim": "^3.9.1",
"@tsparticles/vue3": "^3.0.1",
"axios": "^1.13.2",
"element-plus": "^2.13.0",
"pinia": "^3.0.4",
@@ -1080,6 +1084,25 @@
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@primer/css": {
"version": "22.1.0",
"resolved": "https://registry.npmjs.org/@primer/css/-/css-22.1.0.tgz",
"integrity": "sha512-Nwg9QaRiBeu0BU6h+Su0X07daihX1obiuqGRG8y+SexOnvWhN2J5n4OFAvGfQsit07Y7Q6gGoK+yVU5tb8CtDA==",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
},
"peerDependencies": {
"@primer/primitives": "10.x || 11.x"
}
},
"node_modules/@primer/primitives": {
"version": "11.4.0",
"resolved": "https://registry.npmjs.org/@primer/primitives/-/primitives-11.4.0.tgz",
"integrity": "sha512-JIt98Fs0c8vhOw3uNf+sxqmvCdo0VoCZPBRg4frNK/xNpDMZsQh7V0Rp7wiGbr3f1w+4oqv40sfgaftMQTnwXQ==",
"license": "MIT",
"peer": true
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.53",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
@@ -1395,6 +1418,585 @@
"win32"
]
},
"node_modules/@sentry-internal/browser-utils": {
"version": "10.42.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.42.0.tgz",
"integrity": "sha512-HCEICKvepxN4/6NYfnMMMlppcSwIEwtS66X6d1/mwaHdi2ivw0uGl52p7Nfhda/lIJArbrkWprxl0WcjZajhQA==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.42.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/feedback": {
"version": "10.42.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.42.0.tgz",
"integrity": "sha512-lpPcHsog10MVYFTWE0Pf8vQRqQWwZHJpkVl2FEb9/HDdHFyTBUhCVoWo1KyKaG7GJl9AVKMAg7bp9SSNArhFNQ==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.42.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay": {
"version": "10.42.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.42.0.tgz",
"integrity": "sha512-Zh3EoaH39x2lqVY1YyVB2vJEyCIrT+YLUQxYl1yvP0MJgLxaR6akVjkgxbSUJahan4cX5DxpZiEHfzdlWnYPyQ==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "10.42.0",
"@sentry/core": "10.42.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay-canvas": {
"version": "10.42.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.42.0.tgz",
"integrity": "sha512-am3m1Fj8ihoPfoYo41Qq4KeCAAICn4bySso8Oepu9dMNe9Lcnsf+reMRS2qxTPg3pZDc4JEMOcLyNCcgnAfrHw==",
"license": "MIT",
"dependencies": {
"@sentry-internal/replay": "10.42.0",
"@sentry/core": "10.42.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/browser": {
"version": "10.42.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.42.0.tgz",
"integrity": "sha512-iXxYjXNEBwY1MH4lDSDZZUNjzPJDK7/YLwVIJq/3iBYpIQVIhaJsoJnf3clx9+NfJ8QFKyKfcvgae61zm+hgTA==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "10.42.0",
"@sentry-internal/feedback": "10.42.0",
"@sentry-internal/replay": "10.42.0",
"@sentry-internal/replay-canvas": "10.42.0",
"@sentry/core": "10.42.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/core": {
"version": "10.42.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.42.0.tgz",
"integrity": "sha512-L4rMrXMqUKBanpjpMT+TuAVk6xAijz6AWM6RiEYpohAr7SGcCEc1/T0+Ep1eLV8+pwWacfU27OvELIyNeOnGzA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/vue": {
"version": "10.42.0",
"resolved": "https://registry.npmjs.org/@sentry/vue/-/vue-10.42.0.tgz",
"integrity": "sha512-D6mYt6zRV1YXMZ8xmGKXzb0VHSLANUxpDAC3tfCeRYZ9P0MEHlNI6aapvjiNAh+0Vi9bOaHIUkzpatbE1gWhOg==",
"license": "MIT",
"dependencies": {
"@sentry/browser": "10.42.0",
"@sentry/core": "10.42.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@tanstack/vue-router": "^1.64.0",
"pinia": "2.x || 3.x",
"vue": "2.x || 3.x"
},
"peerDependenciesMeta": {
"@tanstack/vue-router": {
"optional": true
},
"pinia": {
"optional": true
}
}
},
"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",
+4
View File
@@ -9,6 +9,10 @@
"preview": "vite preview"
},
"dependencies": {
"@primer/css": "^22.1.0",
"@sentry/vue": "^10.42.0",
"@tsparticles/slim": "^3.9.1",
"@tsparticles/vue3": "^3.0.1",
"axios": "^1.13.2",
"element-plus": "^2.13.0",
"pinia": "^3.0.4",
Binary file not shown.

After

Width:  |  Height:  |  Size: 1008 KiB

+1
View File
@@ -0,0 +1 @@
Copyright © 2025 DGP Studio, All Rights Reserved.
File diff suppressed because it is too large Load Diff
+135
View File
@@ -0,0 +1,135 @@
import request from '@/utils/request'
/** 下载资源信息 */
export interface DownloadResource {
id?: string
created_at: string
created_by: string
download_url: string
features: string | null
file_hash: string | null
file_size: string | null
is_active: boolean | null
is_test?: boolean
package_type: string
version: string
}
/**
* 获取所有发布的资源
* GET /download-resources
*/
export function getDownloadResourcesApi(): Promise<DownloadResource[]> {
return request({
url: '/download-resources',
method: 'get',
})
}
/**
* 获取最新版本
* GET /download-resources/latest
*/
export function getLatestVersionApi(): Promise<DownloadResource> {
return request({
url: '/download-resources/latest',
method: 'get',
})
}
/**
* 获取测试版本
* GET /download-resources?is_test=true
*/
export function getTestVersionApi(): Promise<DownloadResource[]> {
return request({
url: '/download-resources',
method: 'get',
params: {
is_test: true
}
})
}
/**
* 获取资源列表(包含未激活的)
* GET /web-api/download-resources
* @param package_type 筛选包类型(msi或者msix
* @param is_active 筛选是否激活
*/
export function getDownloadResourceListApi(params?: {
package_type?: string
is_active?: string
}): Promise<DownloadResource[]> {
return request({
url: '/web-api/download-resources',
method: 'get',
params,
})
}
/**
* 获取单个资源详情
* GET /web-api/download-resources/{resource_id}
* @param resource_id 资源id
*/
export function getDownloadResourceDetailApi(resource_id: string): Promise<DownloadResource> {
return request({
url: `/web-api/download-resources/${resource_id}`,
method: 'get',
})
}
/**
* 删除下载资源
* DELETE /web-api/download-resources/{resource_id}
* @param resource_id 资源id
*/
export function deleteDownloadResourceApi(resource_id: string): Promise<null> {
return request({
url: `/web-api/download-resources/${resource_id}`,
method: 'delete',
})
}
/** 创建资源请求参数类型 */
export interface CreateResourceRequest {
version: string
package_type: string
download_url: string
features?: string | null
file_size?: string | null
file_hash?: string | null
is_active?: boolean | null
is_test?: boolean
}
/** 创建资源响应数据类型 */
export interface CreateResourceResponse {
id: string
}
/**
* 创建新版本资源信息
* POST /web-api/download-resources
*/
export function createDownloadResourceApi(params: CreateResourceRequest): Promise<CreateResourceResponse> {
return request({
url: '/web-api/download-resources',
method: 'post',
data: params,
})
}
/**
* 更新下载资源
* PUT /web-api/download-resources/{resource_id}
* @param resource_id 资源id
*/
export function updateDownloadResourceApi(resource_id: string, params: Partial<CreateResourceRequest>): Promise<null> {
return request({
url: `/web-api/download-resources/${resource_id}`,
method: 'put',
data: params,
})
}
+83
View File
@@ -0,0 +1,83 @@
import request from '@/utils/request'
/** 祈愿记录条目 */
export interface GachaLogEntry {
Excluded: boolean
ItemCount: number
Uid: string
}
/** 祈愿记录数据项 */
export interface GachaLogItem {
GachaType: number
Id: number
ItemId: number
QueryType: number
Time: string
}
/** EndIds 类型 - 各祈愿类型的起始ID */
export interface EndIds {
'100': number // 新手祈愿
'200': number // 常驻祈愿
'301': number // 角色活动祈愿
'302': number // 武器活动祈愿
'500': number // 集录祈愿
}
/**
* 获取云抽卡记录列表
* GET /GachaLog/Entries
*/
export function getGachaLogEntriesApi(): Promise<GachaLogEntry[]> {
return request({
url: '/GachaLog/Entries',
method: 'get',
})
}
/**
* 获取云抽卡数据
* POST /GachaLog/Retrieve
* @param uid 用户游戏UID
* @param endIds 各个祈愿类型的起始ID
*/
export function getGachaLogDataApi(uid: string, endIds: EndIds): Promise<GachaLogItem[]> {
return request({
url: '/GachaLog/Retrieve',
method: 'post',
data: {
Uid: uid,
EndIds: endIds,
},
})
}
/**
* 祈愿类型映射 (QueryType -> 名称)
*/
export const GACHA_TYPE_NAMES: Record<number, string> = {
100: '新手祈愿',
200: '常驻祈愿',
301: '角色活动祈愿',
302: '武器活动祈愿',
500: '集录祈愿',
}
/**
* GachaType 到 QueryType 的映射
* 用于合并共享保底的卡池
*/
export function gachaTypeToQueryType(gachaType: number): number {
// 400 是角色活动祈愿的子类型,合并到 301
if (gachaType === 400) return 301
return gachaType
}
/**
* 获取物品的祈愿类型名称
*/
export function getGachaTypeName(gachaType: number): string {
const queryType = gachaTypeToQueryType(gachaType)
return GACHA_TYPE_NAMES[queryType] || `未知类型(${gachaType})`
}
+15 -7
View File
@@ -44,14 +44,22 @@ export function getUserInfoApi(): Promise<UserInfo> {
/**
* 获取用户列表
* GET /web-api/users
* @param q 搜索参数,可搜索用户名、邮箱、_id
* @param params 搜索和筛选参数
* @param params.q 搜索关键词,可搜索用户名、邮箱、_id
* @param params.role 按角色筛选:maintainer, developer, user
* @param params.email 按邮箱筛选
* @param params.username 按用户名筛选
* @param params.id 按用户ID筛选
* @param params.is 按状态筛选:licensed, not-licensed
*/
export function getUserListApi(q?: string): Promise<UserListItem[]> {
const params: Record<string, any> = {}
if (q) {
params.q = q
}
export function getUserListApi(params?: {
q?: string
role?: string
email?: string
username?: string
id?: string
is?: string
}): Promise<UserListItem[]> {
return request({
url: '/web-api/users',
method: 'get',
+637
View File
@@ -0,0 +1,637 @@
<template>
<div class="github-search-container gh-search-root" :class="{ 'dark-mode': isDarkMode }">
<div class="search-input-wrapper">
<!-- 语法高亮显示层 -->
<div class="syntax-highlight-layer" v-if="searchValue" v-html="highlightedText"></div>
<!-- 实际输入框 -->
<input
ref="searchInput"
v-model="searchValue"
type="text"
class="form-control"
:class="{ 'has-content': searchValue }"
:placeholder="placeholder"
@input="handleInput"
@keydown="handleKeydown"
@focus="showSuggestions = true"
@blur="handleBlur"
/>
<div class="search-icon">
<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true" class="octicon octicon-search">
<path d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z"></path>
</svg>
</div>
</div>
<!-- 自动补全建议下拉菜单 -->
<div v-if="showSuggestions && filteredSuggestions.length > 0" class="search-suggestions">
<div
v-for="(suggestion, index) in filteredSuggestions"
:key="index"
class="suggestion-item"
:class="{ active: selectedIndex === index }"
@mousedown.prevent="selectSuggestion(suggestion)"
>
<span class="suggestion-text">{{ suggestion.text }}</span>
<span class="suggestion-desc">{{ suggestion.description }}</span>
</div>
</div>
<!-- 搜索限定符帮助提示 -->
<div v-if="showSuggestions && currentQualifier && currentQualifierHelp" class="qualifier-help">
<div class="help-title">{{ currentQualifierHelp.title }}</div>
<div class="help-desc">{{ currentQualifierHelp.description }}</div>
<div class="help-examples">
<div v-for="(example, idx) in currentQualifierHelp.examples" :key="idx" class="example-item">
<code>{{ example }}</code>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
interface SearchQualifier {
key: string
title: string
description: string
examples: string[]
autocomplete?: string[]
}
interface SearchSuggestion {
text: string
description: string
type: 'qualifier' | 'value'
qualifier?: string
}
const props = defineProps<{
modelValue: string
placeholder?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
'search': [query: string, filters: Record<string, string>]
}>()
const searchValue = ref(props.modelValue)
const searchInput = ref<HTMLInputElement>()
const showSuggestions = ref(false)
const selectedIndex = ref(0)
const isDarkMode = ref(false)
// 检测暗色模式
const checkDarkMode = () => {
isDarkMode.value = document.documentElement.classList.contains('dark')
}
onMounted(() => {
checkDarkMode()
// 监听主题变化
const observer = new MutationObserver(checkDarkMode)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
})
onUnmounted(() => {
observer.disconnect()
})
})
// 搜索限定符定义
const qualifiers: SearchQualifier[] = [
{
key: 'role',
title: '角色',
description: '按用户角色筛选',
examples: ['role:maintainer', 'role:developer', 'role:user']
},
{
key: 'email',
title: '邮箱',
description: '按邮箱地址筛选',
examples: ['email:test@example.com', 'email:outlook.com']
},
{
key: 'username',
title: '用户名',
description: '按用户名筛选',
examples: ['username:testuser', 'username:admin']
},
{
key: 'id',
title: '用户ID',
description: '按用户ID筛选',
examples: ['id:123456789', 'id:507f1f77bcf86cd799439011']
}
// 这个应该没必要,这个实际上就是筛选开发者权限,和role:developer重复了
// {
// key: 'is',
// title: '状态',
// description: '按权限状态筛选',
// examples: ['is:licensed', 'is:not-licensed'],
// autocomplete: ['licensed', 'not-licensed']
// }
]
// 检查是否为已知的限定符
function isQualifier(text: string): boolean {
return qualifiers.some(q => q.key === text)
}
// 生成高亮的 HTML
const highlightedText = computed(() => {
if (!searchValue.value) return ''
let result = ''
const text = searchValue.value
// 按空格分割成段
const segments = text.split(' ')
segments.forEach((segment, index) => {
if (segment === '') {
// 空段表示有空格
result += '<span class="hl-space"> </span>'
} else {
// 检查是否包含冒号
const colonIndex = segment.indexOf(':')
if (colonIndex !== -1) {
// 有冒号,可能是限定符:值格式
const qualifier = segment.slice(0, colonIndex)
const value = segment.slice(colonIndex + 1)
if (isQualifier(qualifier)) {
// 已知限定符
result += `<span class="hl-qualifier">${escapeHtml(qualifier)}</span><span class="hl-colon">:</span><span class="hl-value">${escapeHtml(value)}</span>`
} else {
// 未知限定符,作为普通文本处理
result += `<span class="hl-unknown">${escapeHtml(qualifier)}</span><span class="hl-colon">:</span><span class="hl-value">${escapeHtml(value)}</span>`
}
} else {
// 没有冒号,作为普通文本
result += `<span class="hl-text">${escapeHtml(segment)}</span>`
}
}
// 添加段之间的空格(除了最后一段)
if (index < segments.length - 1) {
result += '<span class="hl-space"> </span>'
}
})
return result
})
// HTML 转义
function escapeHtml(text: string): string {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
// 当前输入的限定符(基于当前段)
const currentQualifier = computed(() => {
const lastSpaceIndex = searchValue.value.lastIndexOf(' ')
const afterLastSpace = searchValue.value.slice(lastSpaceIndex + 1)
const match = afterLastSpace.match(/^(\w+):/)
return match ? match[1] : null
})
// 当前限定符的帮助信息
const currentQualifierHelp = computed(() => {
if (!currentQualifier.value) return null
return qualifiers.find(q => q.key === currentQualifier.value) || null
})
// 建议列表
const suggestions = computed<SearchSuggestion[]>(() => {
const result: SearchSuggestion[] = []
// 检查是否正在输入新的限定符(最后一个空格后)
const lastSpaceIndex = searchValue.value.lastIndexOf(' ')
const afterLastSpace = searchValue.value.slice(lastSpaceIndex + 1)
const hasColonInCurrentSegment = afterLastSpace.includes(':')
// 如果当前段没有冒号,则显示限定符建议
if (!hasColonInCurrentSegment) {
const input = afterLastSpace.toLowerCase()
// 如果输入为空,显示所有限定符;否则显示匹配的限定符
const shouldShowAll = input === ''
qualifiers.forEach(q => {
if (shouldShowAll || q.key.startsWith(input)) {
result.push({
text: `${q.key}:`,
description: q.title,
type: 'qualifier'
})
}
})
}
// 如果当前段有冒号,则显示该限定符的值建议
if (hasColonInCurrentSegment && currentQualifier.value && currentQualifierHelp.value) {
const colonIndex = afterLastSpace.indexOf(':')
const value = afterLastSpace.slice(colonIndex + 1)
const input = value.toLowerCase()
const qualifier = currentQualifierHelp.value
if (qualifier.autocomplete) {
qualifier.autocomplete.forEach(val => {
if (val.startsWith(input)) {
result.push({
text: `${qualifier.key}:${val}`,
description: '',
type: 'value',
qualifier: qualifier.key
})
}
})
}
// 为特定限定符添加常用值建议
if (qualifier.key === 'role') {
const roles = ['maintainer', 'developer', 'user']
roles.forEach(role => {
if (role.startsWith(input)) {
result.push({
text: `role:${role}`,
description: role === 'maintainer' ? '运维人员' : role === 'developer' ? '开发者' : '普通用户',
type: 'value',
qualifier: 'role'
})
}
})
}
}
return result
})
// 过滤后的建议
const filteredSuggestions = computed(() => {
return suggestions.value
})
// 处理输入
function handleInput() {
emit('update:modelValue', searchValue.value)
selectedIndex.value = 0
// 如果有建议内容,自动显示建议框
if (suggestions.value.length > 0) {
showSuggestions.value = true
}
}
// 处理键盘事件
function handleKeydown(event: KeyboardEvent) {
if (!showSuggestions.value || filteredSuggestions.value.length === 0) return
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
selectedIndex.value = Math.min(selectedIndex.value + 1, filteredSuggestions.value.length - 1)
break
case 'ArrowUp':
event.preventDefault()
selectedIndex.value = Math.max(selectedIndex.value - 1, 0)
break
case 'Enter':
const selectedSuggestion = filteredSuggestions.value[selectedIndex.value]
if (selectedSuggestion) {
selectSuggestion(selectedSuggestion)
} else {
handleSearch()
}
break
case 'Escape':
// ESC键关闭建议框
showSuggestions.value = false
break
}
}
// 选择建议
function selectSuggestion(suggestion: SearchSuggestion) {
if (suggestion.type === 'qualifier') {
// 如果是限定符,替换当前输入的限定符部分
const lastSpaceIndex = searchValue.value.lastIndexOf(' ')
const beforeLastSpace = searchValue.value.slice(0, lastSpaceIndex + 1)
searchValue.value = beforeLastSpace + suggestion.text
// 选择限定符后不关闭建议框,让用户继续输入值
} else {
// 如果是值,替换整个限定符+值,并添加空格以便输入下一个限定符
const lastSpaceIndex = searchValue.value.lastIndexOf(' ')
const beforeLastSpace = searchValue.value.slice(0, lastSpaceIndex + 1)
searchValue.value = beforeLastSpace + suggestion.text + ' '
// 选择值后重新打开建议框,方便继续输入下一个限定符
// 使用setTimeout确保DOM更新后再打开
setTimeout(() => {
showSuggestions.value = true
}, 0)
}
emit('update:modelValue', searchValue.value)
nextTick(() => {
searchInput.value?.focus()
})
}
// 处理失去焦点
function handleBlur() {
// 延迟隐藏,以便能够点击建议项
setTimeout(() => {
showSuggestions.value = false
}, 200)
}
// 解析搜索查询
function parseSearchQuery(query: string): { keyword: string; filters: Record<string, string> } {
const filters: Record<string, string> = {}
const parts = query.split(/\s+/)
const keywords: string[] = []
parts.forEach(part => {
if (part.includes(':')) {
const [key, value] = part.split(':')
if (key && value) {
filters[key] = value
}
} else if (part.trim()) {
keywords.push(part)
}
})
return {
keyword: keywords.join(' '),
filters
}
}
// 执行搜索
function handleSearch() {
const { filters } = parseSearchQuery(searchValue.value)
emit('search', searchValue.value, filters)
showSuggestions.value = false
}
// 监听modelValue变化
watch(() => props.modelValue, (newValue) => {
searchValue.value = newValue
})
// 暴露搜索方法
defineExpose({
handleSearch
})
</script>
<style>
/* 定义全局CSS变量 - 亮色模式 */
.gh-search-root {
--color-fg-default: #24292f;
--color-fg-muted: #57606a;
--color-accent-fg: #0969da;
--color-accent-muted: rgba(9, 105, 218, 0.1);
--color-accent-subtle: #ddf4ff;
--color-bg-subtle: #f6f8fa;
--color-bg-canvas: #ffffff;
--color-canvas-overlay: #ffffff;
--color-border-default: #d0d7de;
--color-shadow-medium: 0 3px 6px rgba(140, 149, 159, 0.15);
/* 高亮颜色 - 亮色模式 */
--hl-qualifier: #cf222e;
--hl-colon: #cf222e;
--hl-value: #0550ae;
--hl-text: #24292f;
--hl-unknown: #6e7781;
}
/* 定义全局CSS变量 - 暗色模式 */
.gh-search-root.dark-mode {
--color-fg-default: #c9d1d9;
--color-fg-muted: #8b949e;
--color-accent-fg: #58a6ff;
--color-accent-muted: rgba(88, 166, 255, 0.15);
--color-accent-subtle: rgba(88, 166, 255, 0.15);
--color-bg-subtle: #161b22;
--color-bg-canvas: #0d1117;
--color-canvas-overlay: #161b22;
--color-border-default: #30363d;
--color-shadow-medium: 0 3px 6px rgba(0, 0, 0, 0.5);
/* 高亮颜色 - 暗色模式 */
--hl-qualifier: #ff7b72;
--hl-colon: #ff7b72;
--hl-value: #79c0ff;
--hl-text: #c9d1d9;
--hl-unknown: #8b949e;
}
/* 语法高亮样式 - 非scoped,用于v-html生成的HTML */
.gh-search-root .hl-qualifier {
color: var(--hl-qualifier);
font-weight: 500;
}
.gh-search-root .hl-colon {
color: var(--hl-colon);
font-weight: 500;
}
.gh-search-root .hl-value {
color: var(--hl-value);
}
.gh-search-root .hl-text {
color: var(--hl-text);
}
.gh-search-root .hl-unknown {
color: var(--hl-unknown);
}
.gh-search-root .hl-space {
color: var(--color-fg-muted);
}
</style>
<style scoped>
.github-search-container {
position: relative;
width: 100%;
}
.search-input-wrapper {
position: relative;
width: 100%;
background-color: var(--color-bg-subtle);
border-radius: 6px;
box-sizing: border-box;
}
/* 语法高亮显示层 */
.syntax-highlight-layer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
padding: 8px 36px 8px 12px;
font-size: 14px;
line-height: 20px;
font-family: inherit;
font-weight: normal;
white-space: pre;
overflow: hidden;
pointer-events: none;
z-index: 1;
color: var(--color-fg-default);
background: transparent;
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.form-control {
width: 100%;
padding: 8px 36px 8px 12px;
font-size: 14px;
line-height: 20px;
font-family: inherit;
font-weight: normal;
color: var(--color-fg-default);
caret-color: var(--color-fg-default);
background: transparent;
border: 1px solid var(--color-border-default);
border-radius: 6px;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
position: relative;
z-index: 2;
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.form-control.has-content {
color: transparent;
-webkit-text-fill-color: transparent;
}
.form-control:focus {
outline: none;
}
.form-control:focus + .search-icon {
color: var(--color-accent-fg);
}
.search-input-wrapper:focus-within {
border-color: var(--color-accent-fg);
box-shadow: 0 0 0 3px var(--color-accent-muted);
background-color: var(--color-bg-canvas);
}
.form-control::placeholder {
color: var(--color-fg-muted);
}
.search-icon {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--color-fg-muted);
pointer-events: none;
}
.search-suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1000;
margin-top: 4px;
background-color: var(--color-canvas-overlay);
border: 1px solid var(--color-border-default);
border-radius: 6px;
box-shadow: var(--color-shadow-medium);
max-height: 300px;
overflow-y: auto;
}
.suggestion-item {
padding: 8px 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 12px;
transition: background-color 0.15s ease-in-out;
}
.suggestion-item:hover,
.suggestion-item.active {
background-color: var(--color-accent-subtle);
}
.suggestion-text {
font-size: 14px;
font-weight: 600;
color: var(--color-accent-fg);
}
.suggestion-desc {
font-size: 12px;
color: var(--color-fg-muted);
}
.qualifier-help {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 999;
margin-top: 4px;
padding: 12px;
background-color: var(--color-canvas-overlay);
border: 1px solid var(--color-border-default);
border-radius: 6px;
box-shadow: var(--color-shadow-medium);
}
.help-title {
font-size: 14px;
font-weight: 600;
color: var(--color-fg-default);
margin-bottom: 4px;
}
.help-desc {
font-size: 12px;
color: var(--color-fg-muted);
margin-bottom: 8px;
}
.help-examples {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.example-item {
code {
display: inline-block;
padding: 2px 6px;
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
color: var(--color-accent-fg);
background-color: var(--color-accent-subtle);
border-radius: 3px;
}
}
</style>
+95
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>
+7
View File
@@ -0,0 +1,7 @@
<template>
<router-view></router-view>
</template>
<script setup lang="ts">
// 这是一个占位组件,用于嵌套路由
</script>
+5 -4
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,7 +12,7 @@
<span>{{ item.meta?.title }}</span>
</template>
<SidebarItem :routes="item.children!" :parent-path="item.path" />
<SidebarItem :routes="item.children!" :parent-path="getFullPath(item)" />
</el-sub-menu>
<!-- 普通菜单 -->
@@ -38,7 +38,7 @@ interface Props {
}
const props = withDefaults(defineProps<Props>(), {
parentPath: ''
parentPath: '/dashboard'
})
const hasChildren = (route: RouteRecordRaw) =>
@@ -50,6 +50,7 @@ const getFullPath = (route: RouteRecordRaw) => {
return route.path
}
// 否则拼接父级路径
return props.parentPath ? `/${props.parentPath}/${route.path}` : `/${route.path}`
const basePath = props.parentPath || ''
return basePath ? `${basePath}/${route.path}` : `/${route.path}`
}
</script>
+43 -6
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
@@ -91,13 +91,50 @@ const toggle = () => (isCollapse.value = !isCollapse.value)
const activeMenu = computed(() => route.path)
// 根据用户权限过滤菜单路由
/**
* 获取菜单路由,直接使用配置的路由结构
* 检查用户是否具有完整管理员权限
*/
const hasFullAdminPermission = computed(() => {
const userInfo = userStore.userInfo
return userInfo?.IsMaintainer === true && userInfo?.IsLicensedDeveloper === true
})
/**
* 递归过滤路由,移除没有权限的菜单项
*/
const filterRoutesByPermission = (routes: any[]): any[] => {
return routes
.filter(route => {
// 如果路由需要完整管理员权限,检查用户是否有权限
if (route.meta?.requiresFullAdmin && !hasFullAdminPermission.value) {
return false
}
return true
})
.map(route => {
// 如果有子路由,递归过滤
if (route.children && route.children.length > 0) {
const filteredChildren = filterRoutesByPermission(route.children)
if (filteredChildren.length === 0 && route.component?.name === 'RouterViewPlaceholder') {
return null
}
return { ...route, children: filteredChildren }
}
return route
})
.filter(route => route !== null)
}
/**
* 获取菜单路由,根据权限过滤
*/
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')
const children = dashboardRoute?.children || []
return filterRoutesByPermission(children)
})
</script>
+24 -4
View File
@@ -4,20 +4,40 @@ import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import '@/styles/index.scss'
import '@/router/permission'
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'
import * as Sentry from "@sentry/vue";
const app = createApp(App)
const pinia = createPinia()
Sentry.init({
app,
dsn: "https://2e38c08821de95d002b6e6253d3cd599@o4507525750521856.ingest.us.sentry.io/4511014276169728",
// Setting this option to true will send default PII data to Sentry.
// For example, automatic IP address collection on events
sendDefaultPii: true,
integrations: [Sentry.browserTracingIntegration({ router })],
tracesSampleRate: 0.2,
tracePropagationTargets: ["localhost", "https://htserver.wdg.cloudns.ch/"],
// Session Replay
replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
replaysOnErrorSampleRate: 1.0 // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.
});
app.use(pinia)
app.use(router)
app.use(ElementPlus)
import '@/router/permission'
app.use(Particles, {
init: async (engine: Engine) => {
await loadSlim(engine)
}
})
// 初始化主题
const themeStore = useThemeStore(pinia)
+33 -5
View File
@@ -1,6 +1,13 @@
import { createRouter, createWebHistory } from 'vue-router'
import DefaultLayout from '@/layouts/DefaultLayout.vue'
/**
* 路由元信息说明:
* - hidden: 是否在菜单中隐藏
* - title: 菜单标题
* - icon: 菜单图标
* - requiresFullAdmin: 是否需要完整管理员权限(IsMaintainer && IsLicensedDeveloper
*/
const routes = [
{
path: '/login',
@@ -9,38 +16,59 @@ const routes = [
},
{
path: '/',
component: () => import('@/views/home/index.vue'),
meta: { hidden: true }
},
{
path: '/download',
component: () => import('@/views/download/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/user/index.vue'),
meta: { title: '用户管理', icon: 'User' },
meta: { title: '用户管理', icon: 'User', requiresFullAdmin: true },
},
{
path: 'gacha-log',
component: () => import('@/views/gacha-log/index.vue'),
meta: { title: '祈愿记录', icon: 'TrendCharts' },
},
{
path: 'system',
component: () => import('@/components/RouterViewPlaceholder.vue'),
meta: { title: '系统管理', icon: 'Setting' },
children: [
{
path: 'menu',
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '菜单管理', icon: 'Menu' },
meta: { title: '菜单管理', icon: 'Menu', requiresFullAdmin: true },
},
{
path: 'role',
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '角色管理', icon: 'UserFilled' },
meta: { title: '角色管理', icon: 'UserFilled', requiresFullAdmin: true },
},
{
path: 'announcement',
component: () => import('@/views/announcement/index.vue'),
meta: { title: '公告管理', icon: 'Bell' },
},
{
path: 'download-manager',
component: () => import('@/views/download-manager/index.vue'),
meta: { title: '下载资源管理', icon: 'Download' },
},
],
},
],
+17 -1
View File
@@ -1,12 +1,21 @@
import router from './index'
import { useUserStore } from '@/stores/user'
/**
* 检查用户是否具有完整管理员权限
* 需要 IsMaintainer 和 IsLicensedDeveloper 都为 true
*/
const hasFullAdminPermission = (userInfo: { IsMaintainer: boolean; IsLicensedDeveloper: boolean } | null): boolean => {
return userInfo?.IsMaintainer === true && userInfo?.IsLicensedDeveloper === true
}
router.beforeEach(async (to, _ , next) => {
const userStore = useUserStore()
// 未登录
if (!userStore.token) {
if (to.path === '/login') {
// 主页(/)、登录页和下载页允许未登录访问
if (to.path === '/' || to.path === '/login' || to.path === '/download') {
next()
} else {
next('/login')
@@ -33,5 +42,12 @@ router.beforeEach(async (to, _ , next) => {
}
}
// 检查路由权限
if (to.meta.requiresFullAdmin && !hasFullAdminPermission(userStore.userInfo)) {
// 没有完整管理员权限,跳转到首页
next('/dashboard/home')
return
}
next()
})
+6 -6
View File
@@ -2,13 +2,13 @@ import axios from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
const request = axios.create({
const axiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000,
})
/** 请求拦截:自动加 Token */
request.interceptors.request.use((config) => {
axiosInstance.interceptors.request.use((config: any) => {
const userStore = useUserStore()
if (userStore.token) {
config.headers = config.headers || {}
@@ -18,8 +18,8 @@ request.interceptors.request.use((config) => {
})
/** 响应拦截:兼容 code / retcode */
request.interceptors.response.use(
(response) => {
axiosInstance.interceptors.response.use(
(response: any) => {
const res = response.data
// 登录接口:code
@@ -43,7 +43,7 @@ request.interceptors.response.use(
// 兜底
return res
},
(error) => {
(error: any) => {
// 处理401未授权错误
if (error.response?.status === 401) {
const userStore = useUserStore()
@@ -63,4 +63,4 @@ request.interceptors.response.use(
}
)
export default request
export default axiosInstance
+661
View File
@@ -0,0 +1,661 @@
<template>
<div class="resource-management">
<!-- 搜索栏和统计 -->
<div class="search-statistics-row">
<el-form :inline="true" :model="searchForm" class="search-form">
<el-form-item label="包类型">
<el-select
v-model="searchForm.package_type"
placeholder="全部"
clearable
@change="handleSearch"
>
<el-option label="MSI" value="msi" />
<el-option label="MSIX" value="msix" />
</el-select>
</el-form-item>
<el-form-item label="激活状态">
<el-select
v-model="searchForm.is_active"
placeholder="全部"
clearable
@change="handleSearch"
>
<el-option label="已激活" value="true" />
<el-option label="未激活" value="false" />
</el-select>
</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 && resourceList.length > 0">
<el-statistic title="资源总数" :value="resourceList.length" />
<el-statistic title="MSI包" :value="msiCount" />
<el-statistic title="MSIX包" :value="msixCount" />
<el-statistic title="已激活" :value="activeCount" />
</div>
</div>
<!-- 操作按钮 -->
<div class="toolbar">
<el-button type="success" @click="handleCreate">创建资源</el-button>
<el-button type="primary" @click="handleRefresh" :loading="loading">刷新</el-button>
</div>
<!-- 资源表格 -->
<el-table
:data="resourceList"
style="width: 100%"
border
v-loading="loading"
element-loading-text="正在加载资源数据..."
>
<el-table-column prop="version" label="版本号" width="120" />
<el-table-column label="包类型" width="100">
<template #default="scope">
<el-tag :type="scope.row.package_type === 'msix' ? 'success' : 'primary'" size="small">
{{ scope.row.package_type.toUpperCase() }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="激活状态" width="100">
<template #default="scope">
<el-tag :type="scope.row.is_active ? 'success' : 'info'" size="small">
{{ scope.row.is_active ? '已激活' : '未激活' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="file_size" label="文件大小" width="120" />
<el-table-column label="下载链接" min-width="200">
<template #default="scope">
<el-link
:href="scope.row.download_url"
target="_blank"
type="primary"
:underline="false"
>
{{ scope.row.download_url }}
</el-link>
</template>
</el-table-column>
<el-table-column prop="features" label="新功能描述" min-width="250" show-overflow-tooltip />
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="scope">
{{ formatTime(scope.row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="scope">
<el-button
size="small"
type="primary"
link
@click="handleView(scope.row)"
>
详情
</el-button>
<el-button
size="small"
type="warning"
link
@click="handleEdit(scope.row)"
>
编辑
</el-button>
<el-button
size="small"
type="danger"
link
@click="handleDelete(scope.row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 空状态 -->
<el-empty v-if="!loading && resourceList.length === 0" description="暂无资源数据" />
<!-- 资源详情弹窗 -->
<el-dialog
v-model="detailDialogVisible"
title="资源详情"
width="60%"
>
<div v-if="currentResource" class="resource-detail">
<el-descriptions :column="2" border>
<el-descriptions-item label="版本号">
<el-tag type="primary">{{ currentResource.version }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="包类型">
<el-tag :type="currentResource.package_type === 'msix' ? 'success' : 'primary'">
{{ currentResource.package_type.toUpperCase() }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="激活状态">
<el-tag :type="currentResource.is_active ? 'success' : 'info'">
{{ currentResource.is_active ? '已激活' : '未激活' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="文件大小">
{{ currentResource.file_size || '-' }}
</el-descriptions-item>
<el-descriptions-item label="文件哈希" :span="2">
{{ currentResource.file_hash || '-' }}
</el-descriptions-item>
<el-descriptions-item label="创建者ID">
{{ currentResource.created_by }}
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ formatTime(currentResource.created_at) }}
</el-descriptions-item>
<el-descriptions-item label="下载链接" :span="2">
<el-link
:href="currentResource.download_url"
target="_blank"
type="primary"
>
{{ currentResource.download_url }}
</el-link>
</el-descriptions-item>
<el-descriptions-item label="新功能描述" :span="2">
<div class="features-content">
{{ currentResource.features || '无' }}
</div>
</el-descriptions-item>
</el-descriptions>
</div>
</el-dialog>
<!-- 创建资源弹窗 -->
<el-dialog
v-model="createDialogVisible"
title="创建资源"
width="60%"
>
<el-form
ref="createFormRef"
:model="createForm"
:rules="createRules"
label-width="120px"
>
<el-form-item label="版本号" prop="version">
<el-input
v-model="createForm.version"
placeholder="请输入版本号,如:1.0.0"
/>
</el-form-item>
<el-form-item label="包类型" prop="package_type">
<el-select
v-model="createForm.package_type"
placeholder="请选择包类型"
style="width: 100%"
>
<el-option label="MSI" value="msi" />
<el-option label="MSIX" value="msix" />
</el-select>
</el-form-item>
<el-form-item label="下载链接" prop="download_url">
<el-input
v-model="createForm.download_url"
placeholder="请输入下载URL"
/>
</el-form-item>
<el-form-item label="文件大小">
<el-input
v-model="createForm.file_size"
placeholder="可选,如:114514KB"
/>
</el-form-item>
<el-form-item label="文件哈希">
<el-input
v-model="createForm.file_hash"
placeholder="可选,文件哈希值"
/>
</el-form-item>
<el-form-item label="激活状态">
<el-switch
v-model="createForm.is_active"
active-text="已激活"
inactive-text="未激活"
/>
</el-form-item>
<el-form-item label="测试版">
<el-switch
v-model="createForm.is_test"
active-text="是"
inactive-text="否"
/>
</el-form-item>
<el-form-item label="新功能描述">
<el-input
v-model="createForm.features"
type="textarea"
:rows="4"
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="editRules"
label-width="120px"
>
<el-form-item label="版本号" prop="version">
<el-input
v-model="editForm.version"
placeholder="请输入版本号,如:1.0.0"
/>
</el-form-item>
<el-form-item label="包类型" prop="package_type">
<el-select
v-model="editForm.package_type"
placeholder="请选择包类型"
style="width: 100%"
>
<el-option label="MSI" value="msi" />
<el-option label="MSIX" value="msix" />
</el-select>
</el-form-item>
<el-form-item label="下载链接" prop="download_url">
<el-input
v-model="editForm.download_url"
placeholder="请输入下载URL"
/>
</el-form-item>
<el-form-item label="文件大小">
<el-input
v-model="editForm.file_size"
placeholder="可选,如:114514KB"
/>
</el-form-item>
<el-form-item label="文件哈希">
<el-input
v-model="editForm.file_hash"
placeholder="可选,文件哈希值"
/>
</el-form-item>
<el-form-item label="激活状态">
<el-switch
v-model="editForm.is_active"
active-text="已激活"
inactive-text="未激活"
/>
</el-form-item>
<el-form-item label="测试版">
<el-switch
v-model="editForm.is_test"
active-text="是"
inactive-text="否"
/>
</el-form-item>
<el-form-item label="新功能描述">
<el-input
v-model="editForm.features"
type="textarea"
:rows="4"
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>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import {
getDownloadResourceListApi,
deleteDownloadResourceApi,
createDownloadResourceApi,
updateDownloadResourceApi,
type DownloadResource,
type CreateResourceRequest,
} from '@/api/download'
interface SearchForm {
package_type: string
is_active: string
}
const searchForm = reactive<SearchForm>({
package_type: '',
is_active: '',
})
const resourceList = ref<DownloadResource[]>([])
const loading = ref(false)
const detailDialogVisible = ref(false)
const currentResource = ref<DownloadResource | null>(null)
//
const createDialogVisible = ref(false)
const createLoading = ref(false)
const createFormRef = ref<FormInstance>()
const createForm = reactive<CreateResourceRequest>({
version: '',
package_type: 'msix',
download_url: '',
features: '',
file_size: '',
file_hash: '',
is_active: true,
is_test: false,
})
const createRules: FormRules = {
version: [
{ required: true, message: '请输入版本号', trigger: 'blur' },
{ pattern: /^\d+\.\d+\.\d+$/, message: '版本号格式应为 x.y.z,如 1.0.0', trigger: 'blur' },
],
package_type: [
{ required: true, message: '请选择包类型', trigger: 'change' },
],
download_url: [
{ required: true, message: '请输入下载链接', trigger: 'blur' },
{ type: 'url', message: '请输入有效的URL地址', trigger: 'blur' },
],
}
//
const editDialogVisible = ref(false)
const editLoading = ref(false)
const editFormRef = ref<FormInstance>()
const currentEditId = ref<string | null>(null)
const editForm = reactive<CreateResourceRequest>({
version: '',
package_type: 'msix',
download_url: '',
features: '',
file_size: '',
file_hash: '',
is_active: true,
is_test: false,
})
const editRules: FormRules = {
version: [
{ required: true, message: '请输入版本号', trigger: 'blur' },
{ pattern: /^\d+\.\d+\.\d+$/, message: '版本号格式应为 x.y.z,如 1.0.0', trigger: 'blur' },
],
package_type: [
{ required: true, message: '请选择包类型', trigger: 'change' },
],
download_url: [
{ required: true, message: '请输入下载链接', trigger: 'blur' },
{ type: 'url', message: '请输入有效的URL地址', trigger: 'blur' },
],
}
//
const msiCount = computed(() =>
resourceList.value.filter(item => item.package_type === 'msi').length
)
const msixCount = computed(() =>
resourceList.value.filter(item => item.package_type === 'msix').length
)
const activeCount = computed(() =>
resourceList.value.filter(item => item.is_active === true).length
)
//
function formatTime(dateStr: string) {
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
//
async function fetchResourceList() {
loading.value = true
try {
const params: Record<string, any> = {}
if (searchForm.package_type) {
params.package_type = searchForm.package_type
}
if (searchForm.is_active) {
params.is_active = searchForm.is_active
}
const data = await getDownloadResourceListApi(Object.keys(params).length > 0 ? params : undefined)
resourceList.value = data || []
} catch (error) {
console.error('获取资源列表失败:', error)
ElMessage.error('获取资源列表失败')
resourceList.value = []
} finally {
loading.value = false
}
}
function handleSearch() {
fetchResourceList()
}
function handleReset() {
searchForm.package_type = ''
searchForm.is_active = ''
fetchResourceList()
}
function handleRefresh() {
fetchResourceList()
}
function handleCreate() {
//
Object.assign(createForm, {
version: '',
package_type: 'msix',
download_url: '',
features: '',
file_size: '',
file_hash: '',
is_active: true,
is_test: false,
})
createDialogVisible.value = true
}
async function handleCreateSubmit() {
if (!createFormRef.value) return
try {
await createFormRef.value.validate()
createLoading.value = true
const result = await createDownloadResourceApi(createForm)
if (result && result.id) {
ElMessage.success('资源创建成功')
createDialogVisible.value = false
//
await fetchResourceList()
} else {
ElMessage.error('创建资源失败')
}
} catch (error) {
ElMessage.error('创建资源失败')
} finally {
createLoading.value = false
}
}
function handleEdit(resource: DownloadResource) {
if (!resource.id) {
ElMessage.error('资源ID不存在,无法编辑')
return
}
currentEditId.value = resource.id
//
Object.assign(editForm, {
version: resource.version,
package_type: resource.package_type,
download_url: resource.download_url,
features: resource.features || '',
file_size: resource.file_size || '',
file_hash: resource.file_hash || '',
is_active: resource.is_active ?? true,
is_test: resource.is_test ?? false,
})
editDialogVisible.value = true
}
async function handleEditSubmit() {
if (!editFormRef.value || !currentEditId.value) return
try {
await editFormRef.value.validate()
editLoading.value = true
await updateDownloadResourceApi(currentEditId.value, editForm)
ElMessage.success('资源更新成功')
editDialogVisible.value = false
//
await fetchResourceList()
} catch (error) {
ElMessage.error('更新资源失败')
} finally {
editLoading.value = false
}
}
function handleView(resource: DownloadResource) {
currentResource.value = resource
detailDialogVisible.value = true
}
async function handleDelete(resource: DownloadResource) {
try {
await ElMessageBox.confirm(
`确定要删除版本 ${resource.version}${resource.package_type.toUpperCase()} 包吗?`,
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
)
if (!resource.id) {
ElMessage.error('资源ID不存在,无法删除')
return
}
await deleteDownloadResourceApi(resource.id)
ElMessage.success('资源删除成功')
await fetchResourceList()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除资源失败')
}
}
}
onMounted(() => {
fetchResourceList()
})
</script>
<style scoped>
.resource-management {
padding: 24px;
}
.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;
}
.features-content {
white-space: pre-wrap;
word-break: break-word;
line-height: 1.6;
}
</style>
+730
View File
@@ -0,0 +1,730 @@
<template>
<div class="download-page">
<Header
:app-icon="appIcon"
:app-name="appName"
:actions="headerActions"
/>
<div class="download-content">
<div class="download-section">
<h1 class="section-title">
<el-icon><Download /></el-icon>
下载中心
</h1>
<p class="section-description">选择适合您的安装包立即开始使用 Snap Hutao<br>系统要求新版本Windows10及Windows11</p>
<!-- 版本类型切换 -->
<div class="version-tabs">
<el-tabs v-model="versionType" @tab-change="switchVersionType">
<el-tab-pane label="正式版" name="stable">
<template #label>
<span class="tab-label">
<el-icon><Document /></el-icon>
正式版
</span>
</template>
</el-tab-pane>
<el-tab-pane label="测试版" name="test">
<template #label>
<span class="tab-label">
<el-icon><Box /></el-icon>
测试版
</span>
</template>
</el-tab-pane>
</el-tabs>
<!-- 测试版警告提示 -->
<el-alert
v-if="versionType === 'test'"
title="测试版说明"
type="warning"
:closable="false"
show-icon
>
<p>测试版可能存在未知的 Bug 和稳定性问题仅供测试和体验新功能使用如需稳定使用请下载正式版</p>
</el-alert>
</div>
<!-- 最新版本下载区域 -->
<div v-if="latestVersion" class="latest-version-card">
<div class="latest-header">
<el-tag
:type="versionType === 'test' ? 'warning' : 'success'"
size="large"
effect="dark"
>
{{ versionType === 'test' ? '最新测试版本' : '最新版本' }}
</el-tag>
<h2 class="version-title">{{ latestVersion.version }}</h2>
</div>
<div v-if="latestVersion.features" class="features-section">
<h3 class="features-title">更新内容</h3>
<p class="features-text">{{ latestVersion.features }}</p>
</div>
<div class="download-buttons">
<el-tooltip
v-if="latestVersion.packages.msi"
effect="dark"
placement="top"
:content="getTooltipText(latestVersion, 'msi')"
>
<el-button
type="primary"
size="large"
@click="downloadFile(latestVersion, 'msi')"
:loading="downloading"
>
<el-icon><Document /></el-icon>
<span>下载 MSI 安装包</span>
</el-button>
</el-tooltip>
<el-tooltip
v-if="latestVersion.packages.msix"
effect="dark"
placement="top"
:content="getTooltipText(latestVersion, 'msix')"
>
<el-button
type="success"
size="large"
@click="downloadFile(latestVersion, 'msix')"
:loading="downloading"
>
<el-icon><Box /></el-icon>
<span>下载 MSIX 安装包</span>
</el-button>
</el-tooltip>
</div>
<div class="version-info">
<span class="info-item">
<el-icon><Clock /></el-icon>
发布时间{{ formatDate(latestVersion.created_at) }}
</span>
</div>
</div>
<!-- 历史版本列表仅正式版显示 -->
<el-divider v-if="versionType === 'stable'">历史版本</el-divider>
<div v-loading="loading" class="history-list">
<!-- 正式版显示历史版本空状态 -->
<div v-if="versionType === 'stable' && historyVersions.length === 0 && !loading" class="empty-state">
<el-icon><FolderOpened /></el-icon>
<p>暂无历史版本</p>
</div>
<!-- 正式版显示历史版本列表 -->
<div v-else-if="versionType === 'stable' && historyVersions.length > 0" class="version-table">
<div
v-for="(item, index) in historyVersions"
:key="index"
class="version-item"
>
<div class="version-main">
<div class="version-number">{{ item.version }}</div>
<div class="version-meta">
<span class="meta-item">
<el-icon><Clock /></el-icon>
{{ formatDate(item.created_at) }}
</span>
</div>
<div v-if="item.features" class="version-features">
{{ item.features }}
</div>
</div>
<div class="version-actions">
<el-tooltip
v-if="item.packages.msi"
effect="dark"
placement="top"
:content="getTooltipText(item, 'msi')"
>
<el-button
type="primary"
size="small"
@click="downloadFile(item, 'msi')"
>
MSI
</el-button>
</el-tooltip>
<el-tooltip
v-if="item.packages.msix"
effect="dark"
placement="top"
:content="getTooltipText(item, 'msix')"
>
<el-button
type="success"
size="small"
@click="downloadFile(item, 'msix')"
>
MSIX
</el-button>
</el-tooltip>
</div>
</div>
</div>
<!-- 测试版显示空状态 -->
<div v-if="versionType === 'test' && !latestVersion && !loading" class="empty-state">
<el-icon><FolderOpened /></el-icon>
<p>暂无测试版本</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
Download,
Document,
Box,
Clock,
FolderOpened,
} from '@element-plus/icons-vue'
import Header from '@/components/Header.vue'
import { getDownloadResourcesApi, getTestVersionApi } from '@/api/download'
const router = useRouter()
//
const appIcon = ref('/HT_logo.png')
const appName = ref('Snap Hutao')
//
const headerActions = ref([
{
id: 'home',
label: '返回首页',
icon: undefined,
component: 'el-button',
props: {
type: 'default',
link: true
},
onClick: () => {
router.push('/')
}
}
])
//
interface VersionInfo {
version: string
created_at: string
created_by: string
features: string | null
file_hash: string | null
file_size: string | null
is_active: boolean | null
packages: {
msi: string | null
msix: string | null
msi_size: string | null
msix_size: string | null
}
}
//
const packageDescriptions = {
msi: '传统安装包,方便部署,但是可能有BUG。',
msix: '现代化安装包,稳定性更好,原生体验,推荐使用,安装稍繁琐,解压以后右键Add-AppDevPackage.ps1,选择“以PowerShell运行”进行安装。',
}
//
const latestVersion = ref<VersionInfo | null>(null)
const historyVersions = ref<VersionInfo[]>([])
const loading = ref(false)
const downloading = ref(false)
const versionType = ref<'stable' | 'test'>('stable')
/**
* 格式化日期
*/
function formatDate(dateStr: string) {
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
/**
* 下载文件
*/
function downloadFile(item: VersionInfo, packageType: 'msi' | 'msix') {
const downloadUrl = item.packages[packageType]
if (!downloadUrl) {
ElMessage.warning(`${item.version} 版本暂无 ${packageType.toUpperCase()} 安装包`)
return
}
downloading.value = true
const a = document.createElement('a')
a.href = downloadUrl
// msixzip
if (packageType === 'msix') {
a.download = `Snap.Hutao.${item.version}.zip`
} else {
a.download = `Snap.Hutao.${item.version}.msi`
}
a.target = '_blank'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
ElMessage.success(`开始下载 ${item.version} 版本的 ${packageType.toUpperCase()} 安装包`)
setTimeout(() => {
downloading.value = false
}, 1000)
}
/**
* 获取下载按钮的提示内容
*/
function getTooltipText(item: VersionInfo, packageType: 'msi' | 'msix') {
const size = item.packages[`${packageType}_size` as keyof typeof item.packages] as string | null
const desc = packageDescriptions[packageType]
if (size) {
return `${desc}\n文件大小:${size}`
}
return desc
}
/**
* 加载正式版本
*/
async function loadAllVersions() {
try {
loading.value = true
const data = await getDownloadResourcesApi()
if (!data || data.length === 0) {
return
}
//
const versionMap = new Map<string, VersionInfo>()
data.forEach((item) => {
if (!versionMap.has(item.version)) {
//
versionMap.set(item.version, {
version: item.version,
created_at: item.created_at,
created_by: item.created_by,
features: item.features,
file_hash: item.file_hash,
file_size: item.file_size,
is_active: item.is_active,
packages: {
msi: null,
msix: null,
msi_size: null,
msix_size: null,
},
})
}
//
const versionInfo = versionMap.get(item.version)
if (versionInfo) {
if (item.package_type === 'msi') {
versionInfo.packages.msi = item.download_url
versionInfo.packages.msi_size = item.file_size
} else if (item.package_type === 'msix') {
versionInfo.packages.msix = item.download_url
versionInfo.packages.msix_size = item.file_size
}
}
})
//
const versions = Array.from(versionMap.values()).sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
if (versions.length > 0) {
latestVersion.value = versions[0] ?? null
historyVersions.value = versions.slice(1)
}
} catch (error) {
console.error('加载版本列表失败:', error)
ElMessage.error('加载版本列表失败')
} finally {
loading.value = false
}
}
/**
* 加载测试版本
*/
async function loadTestVersion() {
try {
loading.value = true
const data = await getTestVersionApi()
if (!data || data.length === 0) {
latestVersion.value = null
historyVersions.value = []
return
}
//
const versionMap = new Map<string, VersionInfo>()
data.forEach((item) => {
if (!versionMap.has(item.version)) {
//
versionMap.set(item.version, {
version: item.version,
created_at: item.created_at,
created_by: item.created_by,
features: item.features,
file_hash: item.file_hash,
file_size: item.file_size,
is_active: item.is_active,
packages: {
msi: null,
msix: null,
msi_size: null,
msix_size: null,
},
})
}
//
const versionInfo = versionMap.get(item.version)
if (versionInfo) {
if (item.package_type === 'msi') {
versionInfo.packages.msi = item.download_url
versionInfo.packages.msi_size = item.file_size
} else if (item.package_type === 'msix') {
versionInfo.packages.msix = item.download_url
versionInfo.packages.msix_size = item.file_size
}
}
})
//
const versions = Array.from(versionMap.values()).sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
if (versions.length > 0) {
latestVersion.value = versions[0] ?? null
//
historyVersions.value = []
} else {
latestVersion.value = null
historyVersions.value = []
}
} catch (error) {
console.error('加载测试版本失败:', error)
ElMessage.error('加载测试版本失败')
} finally {
loading.value = false
}
}
/**
* 切换版本类型
*/
function switchVersionType(type: 'stable' | 'test') {
versionType.value = type
latestVersion.value = null
historyVersions.value = []
if (type === 'stable') {
loadAllVersions()
} else {
loadTestVersion()
}
}
onMounted(() => {
loadAllVersions()
})
</script>
<style scoped>
.download-page {
min-height: 100vh;
background: var(--main-bg);
display: flex;
flex-direction: column;
}
.download-content {
flex: 1;
padding: 40px 20px;
}
.download-section {
max-width: 1000px;
margin: 0 auto;
}
.section-title {
display: flex;
align-items: center;
gap: 12px;
font-size: 36px;
font-weight: 700;
margin-bottom: 12px;
color: var(--text-color);
background: linear-gradient(135deg, var(--aside-active), #67c23a);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.section-description {
font-size: 16px;
color: var(--text-color);
opacity: 0.8;
margin-bottom: 20px;
}
/* 版本类型切换 */
.version-tabs {
margin-bottom: 32px;
}
.version-tabs :deep(.el-tabs__header) {
margin: 0;
}
.version-tabs :deep(.el-tabs__nav-wrap::after) {
display: none;
}
.version-tabs :deep(.el-tabs__item) {
font-size: 16px;
padding: 0 24px;
}
.version-tabs .tab-label {
display: flex;
align-items: center;
gap: 6px;
}
.version-tabs :deep(.el-alert) {
margin-top: 16px;
padding: 12px 16px;
}
.version-tabs :deep(.el-alert__description) {
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
/* 最新版本卡片 */
.latest-version-card {
background: var(--card-bg);
border-radius: 16px;
padding: 32px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
margin-bottom: 40px;
}
.latest-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
}
.version-title {
font-size: 32px;
font-weight: 600;
color: var(--text-color);
margin: 0;
}
.features-section {
margin-bottom: 24px;
}
.features-title {
font-size: 16px;
font-weight: 600;
color: var(--text-color);
margin-bottom: 12px;
}
.features-text {
font-size: 14px;
color: var(--text-color);
opacity: 0.8;
line-height: 1.6;
white-space: pre-wrap;
background: var(--main-bg);
padding: 16px;
border-radius: 8px;
}
.download-buttons {
display: flex;
gap: 16px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.download-buttons .el-button {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 32px;
font-size: 16px;
border-radius: 8px;
min-width: 200px;
justify-content: center;
}
.version-info {
display: flex;
gap: 24px;
flex-wrap: wrap;
}
.info-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: var(--text-color);
opacity: 0.7;
}
/* 历史版本列表 */
.history-list {
min-height: 200px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-color);
opacity: 0.5;
}
.empty-state .el-icon {
font-size: 64px;
margin-bottom: 16px;
}
.version-table {
display: flex;
flex-direction: column;
gap: 16px;
}
.version-item {
background: var(--card-bg);
border-radius: 12px;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
transition: all 0.3s ease;
}
.version-item:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
.version-main {
flex: 1;
}
.version-number {
font-size: 20px;
font-weight: 600;
color: var(--text-color);
margin-bottom: 8px;
}
.version-meta {
display: flex;
gap: 16px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
color: var(--text-color);
opacity: 0.7;
}
.version-features {
font-size: 14px;
color: var(--text-color);
opacity: 0.8;
line-height: 1.6;
white-space: pre-wrap;
background: var(--main-bg);
padding: 12px;
border-radius: 6px;
max-height: 80px;
overflow-y: auto;
}
.version-actions {
display: flex;
gap: 12px;
flex-shrink: 0;
}
/* 响应式 */
@media (max-width: 768px) {
.section-title {
font-size: 28px;
}
.latest-version-card {
padding: 24px;
}
.download-buttons {
flex-direction: column;
}
.download-buttons .el-button {
width: 100%;
}
.version-item {
flex-direction: column;
align-items: flex-start;
}
.version-actions {
width: 100%;
justify-content: flex-end;
}
}
</style>
+847
View File
@@ -0,0 +1,847 @@
<template>
<div class="gacha-log-page">
<!-- 头部UID选择和视图切换 -->
<el-card class="header-card">
<div class="header-content">
<div class="left-section">
<el-select
v-model="selectedUid"
placeholder="选择UID"
class="uid-select"
@change="handleUidChange"
>
<el-option
v-for="entry in entries"
:key="entry.Uid"
:label="`${entry.Uid} (${entry.ItemCount}条记录)`"
:value="entry.Uid"
/>
</el-select>
<el-tag v-if="currentEntry?.Excluded" type="warning">已排除全球统计</el-tag>
</div>
<div class="right-section">
<el-radio-group v-model="viewMode" size="default">
<el-radio-button value="overview">总览统计</el-radio-button>
<el-radio-button value="list">详细列表</el-radio-button>
</el-radio-group>
</div>
</div>
</el-card>
<el-card v-if="loading" class="loading-card">
<div class="loading-content">
<el-icon class="is-loading" :size="40"><Loading /></el-icon>
<p>正在加载祈愿记录...</p>
</div>
</el-card>
<el-card v-else-if="!selectedUid && entries.length === 0" class="empty-card">
<el-empty description="暂无祈愿记录" />
</el-card>
<!-- 总览统计视图 -->
<template v-else-if="viewMode === 'overview' && gachaData.length > 0">
<div class="overview-grid">
<el-card
v-for="type in gachaTypes"
:key="type.queryType"
class="stat-card"
:class="{ active: selectedType === type.queryType }"
@click="selectedType = type.queryType"
>
<div class="stat-header">
<span class="type-name">{{ type.name }}</span>
<el-tag :type="type.color || undefined" size="small">{{ type.count }}</el-tag>
</div>
<div class="stat-body">
<div class="stat-row">
<span class="label">5</span>
<span class="value star5">{{ type.star5Count }}</span>
</div>
<div class="stat-row">
<span class="label">4</span>
<span class="value star4">{{ type.star4Count }}</span>
</div>
<div class="stat-row">
<span class="label">平均出货</span>
<span class="value">{{ type.avgPity?.toFixed(1) || '-' }}</span>
</div>
<div class="stat-row">
<span class="label">已垫</span>
<span class="value highlight">{{ type.currentPity }}</span>
</div>
</div>
<!-- 5星物品展示 -->
<div v-if="type.star5Items.length > 0" class="star5-preview">
<div
v-for="item in type.star5Items.slice(0, 5)"
:key="item.Id"
class="preview-item"
>
<img
:src="getItemIcon(item.ItemId)"
:alt="getItemName(item.ItemId)"
:title="getItemName(item.ItemId)"
class="item-icon star5"
/>
</div>
<span v-if="type.star5Items.length > 5" class="more-count">
+{{ type.star5Items.length - 5 }}
</span>
</div>
</el-card>
</div>
<!-- 选中类型的详细信息 -->
<el-card v-if="selectedTypeStat" class="detail-card">
<template #header>
<div class="card-header">
<span>{{ selectedTypeStat.name }} - 详细记录 ({{ selectedTypeStat.star5Count }}个5星)</span>
</div>
</template>
<div class="detail-content">
<el-table :data="selectedTypeStat.star5ItemsWithPity" stripe style="width: 100%">
<el-table-column prop="ItemId" label="物品" min-width="200">
<template #default="{ row }">
<div class="item-cell">
<img
:src="getItemIcon(row.ItemId)"
:alt="getItemName(row.ItemId)"
class="item-icon-small star5"
/>
<span class="item-name star5">{{ getItemName(row.ItemId) }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="Time" label="获取时间" min-width="180">
<template #default="{ row }">
{{ formatTime(row.Time) }}
</template>
</el-table-column>
<el-table-column label="5星出货抽数" min-width="120">
<template #default="{ row }">
<span class="pity-tag" :style="getPityStyle(row.pity)">{{ row.pity }}</span>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</template>
<!-- 列表视图 -->
<el-card v-else-if="viewMode === 'list' && gachaData.length > 0" class="list-card">
<template #header>
<div class="card-header">
<span>祈愿记录列表 ({{ filteredGachaData.length }})</span>
<el-select v-model="listFilterType" placeholder="筛选祈愿类型" clearable size="small" style="width: 150px">
<el-option label="全部" :value="0" />
<el-option v-for="type in gachaTypes" :key="type.queryType" :label="type.name" :value="type.queryType" />
</el-select>
</div>
</template>
<el-scrollbar ref="scrollbarRef" height="550px" @end-reached="handleScrollEnd">
<el-table
:data="displayedListData"
stripe
style="width: 100%"
>
<el-table-column prop="ItemId" label="物品" width="220">
<template #default="{ row }">
<div class="item-cell">
<img
:src="getItemIcon(row.ItemId)"
:alt="getItemName(row.ItemId)"
class="item-icon-small"
:class="getQualityClass(row.ItemId)"
/>
<span class="item-name" :class="getQualityClass(row.ItemId)">
{{ getItemName(row.ItemId) }}
</span>
</div>
</template>
</el-table-column>
<el-table-column prop="GachaType" label="祈愿类型" width="140">
<template #default="{ row }">
<el-tag size="small">{{ getGachaTypeName(row.GachaType) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="Time" label="获取时间" width="180">
<template #default="{ row }">
{{ formatTime(row.Time) }}
</template>
</el-table-column>
<el-table-column prop="Id" label="记录ID" min-width="180">
<template #default="{ row }">
<span class="record-id">{{ row.Id }}</span>
</template>
</el-table-column>
</el-table>
<div v-if="listLoading" class="list-loading">
<el-icon class="is-loading"><Loading /></el-icon>
<span>加载中...</span>
</div>
<div v-else-if="noMoreListData && filteredGachaData.length > 0" class="list-end">
已加载全部记录
</div>
</el-scrollbar>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { Loading } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { ScrollbarDirection } from 'element-plus'
import {
getGachaLogEntriesApi,
getGachaLogDataApi,
type GachaLogEntry,
type GachaLogItem,
type EndIds,
getGachaTypeName,
gachaTypeToQueryType,
GACHA_TYPE_NAMES,
} from '@/api/gachaLog'
//
interface GachaItemMeta {
id: number
name: string
icon: string
quality: number
type: 'avatar' | 'weapon' | 'material'
}
//
interface GachaTypeStat {
queryType: number
name: string
count: number
star5Count: number
star4Count: number
star3Count: number
star5Items: GachaLogItem[]
star4Items: GachaLogItem[]
avgPity: number
currentPity: number
color: '' | 'success' | 'warning' | 'info' | 'danger'
}
//
const loading = ref(false)
const entries = ref<GachaLogEntry[]>([])
const selectedUid = ref<string>('')
const gachaData = ref<GachaLogItem[]>([])
const viewMode = ref<'overview' | 'list'>('overview')
const selectedType = ref<number>(301)
const listFilterType = ref<number>(0)
const metadata = ref<Record<string, GachaItemMeta>>({})
//
const LIST_PAGE_SIZE = 50 //
const listDisplayCount = ref(LIST_PAGE_SIZE)
const listLoading = ref(false)
const scrollbarRef = ref()
const savedScrollTop = ref(0) //
//
const currentEntry = computed(() => {
return entries.value.find(e => e.Uid === selectedUid.value)
})
//
async function loadMetadata() {
try {
const response = await fetch('/metadata/gacha_items.json') //
metadata.value = await response.json()
} catch (error) {
console.error('加载元数据失败:', error)
}
}
//
function getItemName(itemId: number): string {
const item = metadata.value[String(itemId)]
return item?.name || `未知物品(${itemId})`
}
// URL
const STATIC_BASE_URL = 'https://htserver.wdg.cloudns.ch/static/raw'
//
function getItemIcon(itemId: number): string {
const item = metadata.value[String(itemId)]
if (!item || !item.icon) return '/HT_logo.png' //
const iconName = item.icon
// : AvatarIcon/{Icon}.png
// : EquipIcon/{Icon}.png
const category = item.type === 'avatar' ? 'AvatarIcon' : 'EquipIcon'
return `${STATIC_BASE_URL}/${category}/${iconName}.png`
}
//
function getQualityClass(itemId: number): string {
const item = metadata.value[String(itemId)]
const quality = item?.quality || 3
if (quality >= 5) return 'star5'
if (quality === 4) return 'star4'
return 'star3'
}
// 5
function getPityStyle(pity: number): Record<string, string> {
let bgColor = ''
let textColor = '#fff'
if (pity <= 10) {
//
bgColor = '#ffd700'
textColor = '#333'
} else if (pity <= 30) {
// 绿
bgColor = '#22c55e'
} else if (pity <= 60) {
//
bgColor = '#3b82f6'
} else if (pity <= 75) {
//
bgColor = '#f59e0b'
} else {
//
bgColor = '#ef4444'
}
return {
backgroundColor: bgColor,
color: textColor,
}
}
//
function formatTime(time: string): string {
if (!time) return '-'
try {
const date = new Date(time)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
} catch {
return time
}
}
// 5
interface Star5ItemWithPity extends GachaLogItem {
pity: number //
}
//
interface GachaTypeStatExt extends GachaTypeStat {
star5ItemsWithPity: Star5ItemWithPity[]
}
//
const gachaTypes = computed<GachaTypeStatExt[]>(() => {
const typeStats: Record<number, GachaTypeStatExt> = {}
//
Object.keys(GACHA_TYPE_NAMES).forEach(type => {
const queryType = Number(type)
typeStats[queryType] = {
queryType,
name: GACHA_TYPE_NAMES[queryType] || '未知祈愿',
count: 0,
star5Count: 0,
star4Count: 0,
star3Count: 0,
star5Items: [],
star4Items: [],
star5ItemsWithPity: [],
avgPity: 0,
currentPity: 0,
color: queryType === 100 ? 'info' : queryType === 200 ? '' : queryType === 301 ? 'danger' : queryType === 302 ? 'warning' : 'success',
}
})
//
const sortedData = [...gachaData.value].sort((a, b) => new Date(a.Time).getTime() - new Date(b.Time).getTime())
//
const pityCounters: Record<number, number> = {}
Object.keys(GACHA_TYPE_NAMES).forEach(type => {
pityCounters[Number(type)] = 0
})
//
sortedData.forEach(item => {
const queryType = gachaTypeToQueryType(item.GachaType)
const stat = typeStats[queryType]
if (!stat) return
stat.count++
if (pityCounters[queryType] === undefined) {
pityCounters[queryType] = 0
}
pityCounters[queryType]++
const itemMeta = metadata.value[String(item.ItemId)]
const quality = itemMeta?.quality || 3
if (quality >= 5) {
stat.star5Count++
//
const itemWithPity: Star5ItemWithPity = {
...item,
pity: pityCounters[queryType] ?? 0
}
stat.star5ItemsWithPity.push(itemWithPity)
stat.star5Items.push(item)
//
pityCounters[queryType] = 0
} else if (quality === 4) {
stat.star4Count++
stat.star4Items.push(item)
} else {
stat.star3Count++
}
})
//
Object.values(typeStats).forEach(stat => {
if (!stat) return
//
const qType = stat.queryType
stat.currentPity = (qType && pityCounters[qType]) || 0
if (stat.star5ItemsWithPity.length > 0) {
//
const totalPity = stat.star5ItemsWithPity.reduce((sum, item) => sum + item.pity, 0)
stat.avgPity = totalPity / stat.star5ItemsWithPity.length
// 5
stat.star5ItemsWithPity.sort((a, b) => new Date(b.Time).getTime() - new Date(a.Time).getTime())
stat.star5Items = stat.star5ItemsWithPity.map(item => {
const { pity: _pity, ...rest } = item
return rest
})
}
})
//
return [301, 302, 200, 500, 100]
.map(type => typeStats[type])
.filter((stat): stat is GachaTypeStatExt => stat !== undefined && stat.count > 0)
})
//
const selectedTypeStat = computed<GachaTypeStatExt | undefined>(() => {
return gachaTypes.value.find(t => t.queryType === selectedType.value)
})
//
const filteredGachaData = computed(() => {
let data = [...gachaData.value]
if (listFilterType.value > 0) {
data = data.filter(item => gachaTypeToQueryType(item.GachaType) === listFilterType.value)
}
//
data.sort((a, b) => new Date(b.Time).getTime() - new Date(a.Time).getTime())
return data
})
//
const noMoreListData = computed(() => {
return listDisplayCount.value >= filteredGachaData.value.length
})
//
const displayedListData = computed(() => {
return filteredGachaData.value.slice(0, listDisplayCount.value)
})
//
function loadMoreListData() {
if (listLoading.value || noMoreListData.value) return
//
if (scrollbarRef.value?.wrapRef) {
savedScrollTop.value = scrollbarRef.value.wrapRef.scrollTop
}
listLoading.value = true
setTimeout(() => {
listDisplayCount.value += LIST_PAGE_SIZE
// DOM
nextTick(() => {
if (scrollbarRef.value?.wrapRef) {
scrollbarRef.value.wrapRef.scrollTop = savedScrollTop.value
}
listLoading.value = false
})
}, 50)
}
//
function handleScrollEnd(direction: ScrollbarDirection) {
if (direction === 'bottom' && !listLoading.value && !noMoreListData.value) {
loadMoreListData()
}
}
//
function resetListDisplay() {
listDisplayCount.value = LIST_PAGE_SIZE
}
//
watch(listFilterType, () => {
resetListDisplay()
})
//
async function loadEntries() {
loading.value = true
try {
entries.value = await getGachaLogEntriesApi()
const firstEntry = entries.value[0]
if (firstEntry) {
selectedUid.value = firstEntry.Uid
await loadGachaData(selectedUid.value)
}
} catch (error) {
ElMessage.error('获取祈愿记录列表失败')
console.error(error)
} finally {
loading.value = false
}
}
//
async function loadGachaData(uid: string) {
loading.value = true
gachaData.value = []
try {
// EndIds0
const endIds: EndIds = {
'100': 0,
'200': 0,
'301': 0,
'302': 0,
'500': 0,
}
gachaData.value = await getGachaLogDataApi(uid, endIds)
//
const firstType = gachaTypes.value[0]
if (firstType) {
selectedType.value = firstType.queryType
}
} catch (error) {
ElMessage.error('获取祈愿数据失败')
console.error(error)
} finally {
loading.value = false
}
}
// UID
function handleUidChange(uid: string) {
if (uid) {
loadGachaData(uid)
}
}
//
onMounted(async () => {
await loadMetadata()
await loadEntries()
})
</script>
<style scoped>
.gacha-log-page {
padding: 0;
}
.header-card {
margin-bottom: 20px;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 16px;
}
.left-section {
display: flex;
align-items: center;
gap: 12px;
}
.uid-select {
width: 280px;
}
.loading-card,
.empty-card {
margin-bottom: 20px;
}
.loading-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 0;
color: var(--text-color);
}
.loading-content .el-icon {
margin-bottom: 16px;
color: var(--aside-active);
}
/* 总览网格 */
.overview-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.stat-card {
cursor: pointer;
transition: all 0.3s;
border: 2px solid transparent;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.stat-card.active {
border-color: var(--aside-active);
}
.stat-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-color);
}
.type-name {
font-size: 16px;
font-weight: 600;
color: var(--text-color);
}
.stat-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.stat-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.stat-row .label {
color: var(--text-color);
opacity: 0.7;
font-size: 14px;
}
.stat-row .value {
font-weight: 600;
font-size: 14px;
}
.stat-row .value.star5 {
color: #f59e0b;
}
.stat-row .value.star4 {
color: #a855f7;
}
.stat-row .value.highlight {
color: var(--aside-active);
}
/* 5星预览 */
.star5-preview {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--border-color);
flex-wrap: wrap;
}
.preview-item {
display: flex;
align-items: center;
}
.item-icon {
width: 48px;
height: 48px;
border-radius: 8px;
object-fit: cover;
background: rgba(0, 0, 0, 0.1);
}
.item-icon.star5 {
box-shadow: 0 0 8px rgba(245, 158, 11, 0.5);
}
.more-count {
font-size: 12px;
color: var(--text-color);
opacity: 0.7;
margin-left: 4px;
}
/* 详细卡片 */
.detail-card {
margin-bottom: 20px;
width: 100%;
}
.detail-card :deep(.el-card__body) {
padding: 0;
}
.detail-content {
width: 100%;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.item-cell {
display: flex;
align-items: center;
gap: 8px;
}
.item-icon-small {
width: 32px;
height: 32px;
border-radius: 4px;
object-fit: cover;
background: rgba(0, 0, 0, 0.1);
}
.item-icon-small.star5 {
box-shadow: 0 0 6px rgba(245, 158, 11, 0.6);
}
.item-icon-small.star4 {
box-shadow: 0 0 4px rgba(168, 85, 247, 0.5);
}
.item-name {
font-weight: 500;
}
.item-name.star5 {
color: #f59e0b;
}
.item-name.star4 {
color: #a855f7;
}
.record-id {
font-family: monospace;
font-size: 12px;
opacity: 0.6;
}
/* 列表卡片 */
.list-card {
margin-bottom: 20px;
}
/* 抽数标签渐变色 */
.pity-tag {
display: inline-block;
padding: 2px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
min-width: 50px;
text-align: center;
}
/* 列表加载状态 */
.list-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 16px;
color: var(--text-color);
opacity: 0.7;
}
.list-loading .el-icon {
font-size: 18px;
}
/* 列表结束提示 */
.list-end {
text-align: center;
padding: 16px;
color: var(--text-color);
opacity: 0.5;
font-size: 13px;
}
/* 响应式 */
@media (max-width: 768px) {
.header-content {
flex-direction: column;
align-items: stretch;
}
.left-section,
.right-section {
justify-content: center;
}
.uid-select {
width: 100%;
}
.overview-grid {
grid-template-columns: 1fr;
}
}
</style>
+229
View File
@@ -0,0 +1,229 @@
<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: 'primary',
size: 'large',
icon: undefined,
onClick: () => {
router.push('/download')
}
},
{
id: 'btn3',
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>
+88 -4
View File
@@ -1,7 +1,12 @@
<template>
<div class="login-container">
<!-- 粒子动画背景 -->
<vue-particles
id="tsparticles"
:options="particleOptions"
/>
<el-card class="login-card">
<h2 class="title">系统登录</h2>
<h2 class="title">Snap Hutao服务器管理-登录</h2>
<el-form
ref="loginFormRef"
@@ -49,6 +54,7 @@ import { webLoginApi } from '@/api/auth'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
//
const loginFormRef = ref<FormInstance>()
@@ -88,14 +94,14 @@ const handleLogin = async () => {
const userStore = useUserStore()
//
const tokenData = response.data || response
const tokenData = response as any
userStore.setToken(tokenData.access_token)
//
await userStore.fetchUserInfo()
ElMessage.success('登录成功')
router.push({ path: '/' })
router.push({ path: '/dashboard' })
} catch (error: any) {
ElMessage.error(error.message || '登录失败,请重试')
} finally {
@@ -104,6 +110,81 @@ const handleLogin = async () => {
})
}
//
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>
@@ -117,12 +198,15 @@ const handleLogin = async () => {
}
.login-card {
width: 360px;
width: 400px;
padding: 20px;
position: relative;
z-index: 10;
}
.title {
text-align: center;
margin-bottom: 20px;
}
</style>
+84 -59
View File
@@ -2,21 +2,20 @@
<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"
<!-- GitHub样式搜索 -->
<div class="search-container">
<GitHubSearchInput
ref="githubSearch"
v-model="searchQuery"
placeholder="搜索用户... 例如: role:developer username:test"
@search="handleGitHubSearch"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch" :loading="loading">搜索</el-button>
<div class="search-actions">
<el-button type="primary" @click="executeSearch" :loading="loading">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</div>
</div>
<!-- 用户统计 -->
<div class="statistics" v-if="!loading && displayUserList.length > 0">
<el-statistic title="当前显示用户数" :value="displayUserList.length" />
@@ -67,45 +66,41 @@
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import GitHubSearchInput from '@/components/GitHubSearchInput.vue'
import { getUserListApi, type UserListItem } from '@/api/user'
interface SearchForm {
keyword: string
}
const searchForm = reactive<SearchForm>({
keyword: ''
})
const githubSearch = ref<InstanceType<typeof GitHubSearchInput>>()
const searchQuery = ref('')
const currentFilters = ref<Record<string, string>>({})
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
displayUserList.value.filter(user => user.IsMaintainer).length
)
const developerCount = computed(() =>
userList.value.filter(user => user.IsLicensedDeveloper).length
displayUserList.value.filter(user => user.IsLicensedDeveloper).length
)
//
async function fetchUserList(searchKeyword?: string) {
async function fetchUserList(filters?: Record<string, string>) {
loading.value = true
try {
const data = await getUserListApi(searchKeyword)
const data = await getUserListApi(filters)
userList.value = data
if (searchKeyword) {
ElMessage.success(`搜索完成,找到 ${data.length} 个匹配的用户`)
if (filters && Object.keys(filters).length > 0) {
ElMessage.success(`找到 ${data.length} 个匹配的用户`)
isSearchMode.value = true
} else {
ElMessage.success('用户列表加载成功')
@@ -120,40 +115,37 @@ async function fetchUserList(searchKeyword?: string) {
}
}
function handleSearch() {
if (!searchForm.keyword.trim()) {
ElMessage.warning('请输入搜索关键词')
return
}
fetchUserList(searchForm.keyword.trim())
// GitHub
function handleGitHubSearch(_query: string, filters: Record<string, string>) {
currentFilters.value = filters
fetchUserList(filters)
}
//
function executeSearch() {
if (githubSearch.value) {
githubSearch.value.handleSearch()
} else {
handleGitHubSearch(searchQuery.value, currentFilters.value)
}
}
//
function handleReset() {
searchForm.keyword = ''
isSearchMode.value = false
fetchUserList() //
searchQuery.value = ''
currentFilters.value = {}
fetchUserList()
}
//
function handleRefresh() {
if (isSearchMode.value && searchForm.keyword) {
fetchUserList(searchForm.keyword)
if (isSearchMode.value && Object.keys(currentFilters.value).length > 0) {
fetchUserList(currentFilters.value)
} else {
fetchUserList()
}
}
// function handleAdd() {
// ElMessage.info('')
// }
// function handleEdit(row: UserListItem) {
// ElMessage.info(` ${row.UserName} `)
// }
// function handleDelete(row: UserListItem) {
// ElMessage.info(` ${row.UserName} `)
// }
//
onMounted(() => {
fetchUserList()
@@ -164,25 +156,58 @@ onMounted(() => {
.user-management {
padding: 24px;
}
/* 新增flex布局 */
.search-statistics-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 16px;
gap: 24px;
}
.search-form {
/* 保持原有样式,可根据需要调整宽度 */
margin-bottom: 0;
.search-container {
width: 600px;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 12px;
}
.search-container :deep(.github-search-container) {
flex: 1;
min-width: 0;
}
.search-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.statistics {
margin-left: 32px;
display: flex;
gap: 24px;
margin-top: 0;
flex-shrink: 0;
}
.toolbar {
margin-bottom: 16px;
}
/* 响应式调整 */
@media (max-width: 1200px) {
.search-statistics-row {
flex-direction: column;
}
.search-container {
width: 100%;
}
.statistics {
width: 100%;
justify-content: flex-start;
flex-wrap: wrap;
}
}
</style>