mirror of
https://github.com/wangdage12/Snap.Server.Web.git
synced 2026-03-28 09:12:16 +08:00
Compare commits
8 Commits
ecbf732e36
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0527c185ec | ||
|
|
c97cd7cd5d | ||
|
|
b86a765bb1 | ||
|
|
1bba83a3c6 | ||
|
|
29409e3e95 | ||
|
|
e58057665b | ||
|
|
47a4c63de2 | ||
|
|
7e08de8250 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -22,3 +22,5 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
/test_scripts
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>ht-web</title>
|
<title>胡桃工具箱-WDG Snap Hutao</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
122
package-lock.json
generated
122
package-lock.json
generated
@@ -8,6 +8,8 @@
|
|||||||
"name": "ht-web",
|
"name": "ht-web",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@primer/css": "^22.1.0",
|
||||||
|
"@sentry/vue": "^10.42.0",
|
||||||
"@tsparticles/slim": "^3.9.1",
|
"@tsparticles/slim": "^3.9.1",
|
||||||
"@tsparticles/vue3": "^3.0.1",
|
"@tsparticles/vue3": "^3.0.1",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
@@ -1082,6 +1084,25 @@
|
|||||||
"url": "https://opencollective.com/popperjs"
|
"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": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.53",
|
"version": "1.0.0-beta.53",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
|
||||||
@@ -1397,6 +1418,107 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/@tsparticles/basic": {
|
||||||
"version": "3.9.1",
|
"version": "3.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tsparticles/basic/-/basic-3.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tsparticles/basic/-/basic-3.9.1.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@primer/css": "^22.1.0",
|
||||||
|
"@sentry/vue": "^10.42.0",
|
||||||
"@tsparticles/slim": "^3.9.1",
|
"@tsparticles/slim": "^3.9.1",
|
||||||
"@tsparticles/vue3": "^3.0.1",
|
"@tsparticles/vue3": "^3.0.1",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
|
|||||||
4031
public/metadata/gacha_items.json
Normal file
4031
public/metadata/gacha_items.json
Normal file
File diff suppressed because it is too large
Load Diff
135
src/api/download.ts
Normal file
135
src/api/download.ts
Normal 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
src/api/gachaLog.ts
Normal file
83
src/api/gachaLog.ts
Normal 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})`
|
||||||
|
}
|
||||||
@@ -44,14 +44,22 @@ export function getUserInfoApi(): Promise<UserInfo> {
|
|||||||
/**
|
/**
|
||||||
* 获取用户列表
|
* 获取用户列表
|
||||||
* GET /web-api/users
|
* 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[]> {
|
export function getUserListApi(params?: {
|
||||||
const params: Record<string, any> = {}
|
q?: string
|
||||||
if (q) {
|
role?: string
|
||||||
params.q = q
|
email?: string
|
||||||
}
|
username?: string
|
||||||
|
id?: string
|
||||||
|
is?: string
|
||||||
|
}): Promise<UserListItem[]> {
|
||||||
return request({
|
return request({
|
||||||
url: '/web-api/users',
|
url: '/web-api/users',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
|
|||||||
637
src/components/GitHubSearchInput.vue
Normal file
637
src/components/GitHubSearchInput.vue
Normal 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>
|
||||||
@@ -91,13 +91,50 @@ const toggle = () => (isCollapse.value = !isCollapse.value)
|
|||||||
|
|
||||||
const activeMenu = computed(() => route.path)
|
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 menuRoutes = computed(() => {
|
||||||
// 查找 /dashboard 路由的子路由
|
// 查找 /dashboard 路由的子路由
|
||||||
const dashboardRoute = router.options.routes.find(r => r.path === '/dashboard')
|
const dashboardRoute = router.options.routes.find(r => r.path === '/dashboard')
|
||||||
return dashboardRoute?.children || []
|
const children = dashboardRoute?.children || []
|
||||||
|
return filterRoutesByPermission(children)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
19
src/main.ts
19
src/main.ts
@@ -4,18 +4,32 @@ import ElementPlus from 'element-plus'
|
|||||||
import 'element-plus/dist/index.css'
|
import 'element-plus/dist/index.css'
|
||||||
import 'element-plus/theme-chalk/dark/css-vars.css'
|
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||||
import '@/styles/index.scss'
|
import '@/styles/index.scss'
|
||||||
|
import '@/router/permission'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
import { useThemeStore } from './stores/theme'
|
import { useThemeStore } from './stores/theme'
|
||||||
import Particles from '@tsparticles/vue3'
|
import Particles from '@tsparticles/vue3'
|
||||||
import { loadSlim } from '@tsparticles/slim'
|
import { loadSlim } from '@tsparticles/slim'
|
||||||
import type { Engine } from '@tsparticles/engine'
|
import type { Engine } from '@tsparticles/engine'
|
||||||
|
import * as Sentry from "@sentry/vue";
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
const pinia = createPinia()
|
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(pinia)
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.use(ElementPlus)
|
app.use(ElementPlus)
|
||||||
@@ -24,7 +38,6 @@ app.use(Particles, {
|
|||||||
await loadSlim(engine)
|
await loadSlim(engine)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
import '@/router/permission'
|
|
||||||
|
|
||||||
// 初始化主题
|
// 初始化主题
|
||||||
const themeStore = useThemeStore(pinia)
|
const themeStore = useThemeStore(pinia)
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import DefaultLayout from '@/layouts/DefaultLayout.vue'
|
import DefaultLayout from '@/layouts/DefaultLayout.vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路由元信息说明:
|
||||||
|
* - hidden: 是否在菜单中隐藏
|
||||||
|
* - title: 菜单标题
|
||||||
|
* - icon: 菜单图标
|
||||||
|
* - requiresFullAdmin: 是否需要完整管理员权限(IsMaintainer && IsLicensedDeveloper)
|
||||||
|
*/
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
@@ -12,6 +19,11 @@ const routes = [
|
|||||||
component: () => import('@/views/home/index.vue'),
|
component: () => import('@/views/home/index.vue'),
|
||||||
meta: { hidden: true }
|
meta: { hidden: true }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/download',
|
||||||
|
component: () => import('@/views/download/index.vue'),
|
||||||
|
meta: { hidden: true }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/dashboard',
|
path: '/dashboard',
|
||||||
component: DefaultLayout,
|
component: DefaultLayout,
|
||||||
@@ -25,7 +37,12 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: 'user',
|
path: 'user',
|
||||||
component: () => import('@/views/user/index.vue'),
|
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',
|
path: 'system',
|
||||||
@@ -35,18 +52,23 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: 'menu',
|
path: 'menu',
|
||||||
component: () => import('@/views/dashboard/index.vue'),
|
component: () => import('@/views/dashboard/index.vue'),
|
||||||
meta: { title: '菜单管理', icon: 'Menu' },
|
meta: { title: '菜单管理', icon: 'Menu', requiresFullAdmin: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'role',
|
path: 'role',
|
||||||
component: () => import('@/views/dashboard/index.vue'),
|
component: () => import('@/views/dashboard/index.vue'),
|
||||||
meta: { title: '角色管理', icon: 'UserFilled' },
|
meta: { title: '角色管理', icon: 'UserFilled', requiresFullAdmin: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'announcement',
|
path: 'announcement',
|
||||||
component: () => import('@/views/announcement/index.vue'),
|
component: () => import('@/views/announcement/index.vue'),
|
||||||
meta: { title: '公告管理', icon: 'Bell' },
|
meta: { title: '公告管理', icon: 'Bell' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'download-manager',
|
||||||
|
component: () => import('@/views/download-manager/index.vue'),
|
||||||
|
meta: { title: '下载资源管理', icon: 'Download' },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
import router from './index'
|
import router from './index'
|
||||||
import { useUserStore } from '@/stores/user'
|
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) => {
|
router.beforeEach(async (to, _ , next) => {
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
// 未登录
|
// 未登录
|
||||||
if (!userStore.token) {
|
if (!userStore.token) {
|
||||||
// 主页(/)允许未登录访问
|
// 主页(/)、登录页和下载页允许未登录访问
|
||||||
if (to.path === '/' || to.path === '/login') {
|
if (to.path === '/' || to.path === '/login' || to.path === '/download') {
|
||||||
next()
|
next()
|
||||||
} else {
|
} else {
|
||||||
next('/login')
|
next('/login')
|
||||||
@@ -34,5 +42,12 @@ router.beforeEach(async (to, _ , next) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查路由权限
|
||||||
|
if (to.meta.requiresFullAdmin && !hasFullAdminPermission(userStore.userInfo)) {
|
||||||
|
// 没有完整管理员权限,跳转到首页
|
||||||
|
next('/dashboard/home')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
next()
|
next()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import axios from 'axios'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
const request = axios.create({
|
const axiosInstance = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 请求拦截:自动加 Token */
|
/** 请求拦截:自动加 Token */
|
||||||
request.interceptors.request.use((config) => {
|
axiosInstance.interceptors.request.use((config: any) => {
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
if (userStore.token) {
|
if (userStore.token) {
|
||||||
config.headers = config.headers || {}
|
config.headers = config.headers || {}
|
||||||
@@ -18,8 +18,8 @@ request.interceptors.request.use((config) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
/** 响应拦截:兼容 code / retcode */
|
/** 响应拦截:兼容 code / retcode */
|
||||||
request.interceptors.response.use(
|
axiosInstance.interceptors.response.use(
|
||||||
(response) => {
|
(response: any) => {
|
||||||
const res = response.data
|
const res = response.data
|
||||||
|
|
||||||
// 登录接口:code
|
// 登录接口:code
|
||||||
@@ -43,7 +43,7 @@ request.interceptors.response.use(
|
|||||||
// 兜底
|
// 兜底
|
||||||
return res
|
return res
|
||||||
},
|
},
|
||||||
(error) => {
|
(error: any) => {
|
||||||
// 处理401未授权错误
|
// 处理401未授权错误
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@@ -63,4 +63,4 @@ request.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export default request
|
export default axiosInstance
|
||||||
661
src/views/download-manager/index.vue
Normal file
661
src/views/download-manager/index.vue
Normal 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
src/views/download/index.vue
Normal file
730
src/views/download/index.vue
Normal 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
|
||||||
|
// 如果是msix的话,文件是用zip格式压缩的
|
||||||
|
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
src/views/gacha-log/index.vue
Normal file
847
src/views/gacha-log/index.vue
Normal 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 {
|
||||||
|
// 获取所有记录,EndIds全部设为0
|
||||||
|
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>
|
||||||
@@ -90,18 +90,28 @@ const headerActions = ref([
|
|||||||
|
|
||||||
// 主页中心按钮配置
|
// 主页中心按钮配置
|
||||||
const heroButtons = ref([
|
const heroButtons = ref([
|
||||||
|
// {
|
||||||
|
// id: 'btn1',
|
||||||
|
// label: '快速开始',
|
||||||
|
// type: 'primary',
|
||||||
|
// size: 'large',
|
||||||
|
// icon: undefined,
|
||||||
|
// onClick: () => {
|
||||||
|
// window.open('https://github.com/wangdage12/Snap.Hutao', '_blank')
|
||||||
|
// }
|
||||||
|
// },
|
||||||
{
|
{
|
||||||
id: 'btn1',
|
id: 'btn2',
|
||||||
label: '快速开始',
|
label: '立即下载',
|
||||||
type: 'primary',
|
type: 'primary',
|
||||||
size: 'large',
|
size: 'large',
|
||||||
icon: undefined,
|
icon: undefined,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
window.open('https://github.com/wangdage12/Snap.Hutao', '_blank')
|
router.push('/download')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'btn2',
|
id: 'btn3',
|
||||||
label: '查看文档',
|
label: '查看文档',
|
||||||
type: 'default',
|
type: 'default',
|
||||||
size: 'large',
|
size: 'large',
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ const handleLogin = async () => {
|
|||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
// 处理响应数据结构
|
// 处理响应数据结构
|
||||||
const tokenData = response.data || response
|
const tokenData = response as any
|
||||||
userStore.setToken(tokenData.access_token)
|
userStore.setToken(tokenData.access_token)
|
||||||
|
|
||||||
// 获取用户信息
|
// 获取用户信息
|
||||||
|
|||||||
@@ -2,28 +2,27 @@
|
|||||||
<div class="user-management">
|
<div class="user-management">
|
||||||
<!-- 搜索栏和用户统计并排 -->
|
<!-- 搜索栏和用户统计并排 -->
|
||||||
<div class="search-statistics-row">
|
<div class="search-statistics-row">
|
||||||
<!-- 搜索栏 -->
|
<!-- GitHub样式搜索框 -->
|
||||||
<el-form :inline="true" :model="searchForm" class="search-form">
|
<div class="search-container">
|
||||||
<el-form-item label="关键词">
|
<GitHubSearchInput
|
||||||
<el-input
|
ref="githubSearch"
|
||||||
v-model="searchForm.keyword"
|
v-model="searchQuery"
|
||||||
placeholder="请输入用户名、邮箱或ID"
|
placeholder="搜索用户... 例如: role:developer username:test"
|
||||||
clearable
|
@search="handleGitHubSearch"
|
||||||
@keyup.enter="handleSearch"
|
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
<div class="search-actions">
|
||||||
<el-form-item>
|
<el-button type="primary" @click="executeSearch" :loading="loading">搜索</el-button>
|
||||||
<el-button type="primary" @click="handleSearch" :loading="loading">搜索</el-button>
|
<el-button @click="handleReset">重置</el-button>
|
||||||
<el-button @click="handleReset">重置</el-button>
|
</div>
|
||||||
</el-form-item>
|
</div>
|
||||||
</el-form>
|
|
||||||
<!-- 用户统计 -->
|
<!-- 用户统计 -->
|
||||||
<div class="statistics" v-if="!loading && displayUserList.length > 0">
|
<div class="statistics" v-if="!loading && displayUserList.length > 0">
|
||||||
<el-statistic title="当前显示用户数" :value="displayUserList.length" />
|
<el-statistic title="当前显示用户数" :value="displayUserList.length" />
|
||||||
<el-statistic title="运维人员" :value="maintainerCount" />
|
<el-statistic title="运维人员" :value="maintainerCount" />
|
||||||
<el-statistic title="开发者" :value="developerCount" />
|
<el-statistic title="开发者" :value="developerCount" />
|
||||||
<el-statistic v-if="isSearchMode" title="搜索模式" value="进行中" />
|
<el-statistic v-if="isSearchMode" title="搜索模式" value="进行中" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 操作按钮 -->
|
<!-- 操作按钮 -->
|
||||||
@@ -67,45 +66,41 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
import GitHubSearchInput from '@/components/GitHubSearchInput.vue'
|
||||||
import { getUserListApi, type UserListItem } from '@/api/user'
|
import { getUserListApi, type UserListItem } from '@/api/user'
|
||||||
|
|
||||||
interface SearchForm {
|
const githubSearch = ref<InstanceType<typeof GitHubSearchInput>>()
|
||||||
keyword: string
|
const searchQuery = ref('')
|
||||||
}
|
const currentFilters = ref<Record<string, string>>({})
|
||||||
|
|
||||||
const searchForm = reactive<SearchForm>({
|
|
||||||
keyword: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const userList = ref<UserListItem[]>([])
|
const userList = ref<UserListItem[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const isSearchMode = ref(false)
|
const isSearchMode = ref(false)
|
||||||
|
|
||||||
// 显示的用户列表(根据是否在搜索模式决定显示全部还是搜索结果)
|
// 显示的用户列表(直接使用后端返回的数据)
|
||||||
const displayUserList = computed(() => {
|
const displayUserList = computed(() => {
|
||||||
return userList.value
|
return userList.value
|
||||||
})
|
})
|
||||||
|
|
||||||
// 统计数据
|
// 统计数据(基于当前显示的列表)
|
||||||
const maintainerCount = computed(() =>
|
const maintainerCount = computed(() =>
|
||||||
userList.value.filter(user => user.IsMaintainer).length
|
displayUserList.value.filter(user => user.IsMaintainer).length
|
||||||
)
|
)
|
||||||
|
|
||||||
const developerCount = computed(() =>
|
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
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await getUserListApi(searchKeyword)
|
const data = await getUserListApi(filters)
|
||||||
userList.value = data
|
userList.value = data
|
||||||
|
|
||||||
if (searchKeyword) {
|
if (filters && Object.keys(filters).length > 0) {
|
||||||
ElMessage.success(`搜索完成,找到 ${data.length} 个匹配的用户`)
|
ElMessage.success(`找到 ${data.length} 个匹配的用户`)
|
||||||
isSearchMode.value = true
|
isSearchMode.value = true
|
||||||
} else {
|
} else {
|
||||||
ElMessage.success('用户列表加载成功')
|
ElMessage.success('用户列表加载成功')
|
||||||
@@ -120,40 +115,37 @@ async function fetchUserList(searchKeyword?: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSearch() {
|
// 处理GitHub搜索
|
||||||
if (!searchForm.keyword.trim()) {
|
function handleGitHubSearch(_query: string, filters: Record<string, string>) {
|
||||||
ElMessage.warning('请输入搜索关键词')
|
currentFilters.value = filters
|
||||||
return
|
fetchUserList(filters)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行搜索(通过搜索框)
|
||||||
|
function executeSearch() {
|
||||||
|
if (githubSearch.value) {
|
||||||
|
githubSearch.value.handleSearch()
|
||||||
|
} else {
|
||||||
|
handleGitHubSearch(searchQuery.value, currentFilters.value)
|
||||||
}
|
}
|
||||||
fetchUserList(searchForm.keyword.trim())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 重置搜索
|
||||||
function handleReset() {
|
function handleReset() {
|
||||||
searchForm.keyword = ''
|
searchQuery.value = ''
|
||||||
isSearchMode.value = false
|
currentFilters.value = {}
|
||||||
fetchUserList() // 重新获取全部用户列表
|
fetchUserList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 刷新列表
|
||||||
function handleRefresh() {
|
function handleRefresh() {
|
||||||
if (isSearchMode.value && searchForm.keyword) {
|
if (isSearchMode.value && Object.keys(currentFilters.value).length > 0) {
|
||||||
fetchUserList(searchForm.keyword)
|
fetchUserList(currentFilters.value)
|
||||||
} else {
|
} else {
|
||||||
fetchUserList()
|
fetchUserList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// function handleAdd() {
|
|
||||||
// ElMessage.info('新增用户功能待实现')
|
|
||||||
// }
|
|
||||||
|
|
||||||
// function handleEdit(row: UserListItem) {
|
|
||||||
// ElMessage.info(`编辑用户 ${row.UserName} 功能待实现`)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// function handleDelete(row: UserListItem) {
|
|
||||||
// ElMessage.info(`删除用户 ${row.UserName} 功能待实现`)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// 页面加载时获取用户列表
|
// 页面加载时获取用户列表
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchUserList()
|
fetchUserList()
|
||||||
@@ -164,25 +156,58 @@ onMounted(() => {
|
|||||||
.user-management {
|
.user-management {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
/* 新增flex布局 */
|
|
||||||
.search-statistics-row {
|
.search-statistics-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
gap: 24px;
|
||||||
}
|
}
|
||||||
.search-form {
|
|
||||||
/* 保持原有样式,可根据需要调整宽度 */
|
.search-container {
|
||||||
margin-bottom: 0;
|
width: 600px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container :deep(.github-search-container) {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.statistics {
|
.statistics {
|
||||||
margin-left: 32px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
margin-top: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
margin-bottom: 16px;
|
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>
|
</style>
|
||||||
Reference in New Issue
Block a user