Compare commits

..

5 Commits

Author SHA1 Message Date
wangdage12
ef40a8f6f6 Merge pull request #3 from wangdage12/dev
添加下载页面和下载资源管理后台
2026-02-05 22:14:06 +08:00
wangdage12
ecbf732e36 Merge pull request #2 from wangdage12/dev
修复路由问题
2026-01-29 13:22:40 +08:00
wangdage12
98a85f24eb Update README with project details and setup instructions
Added project description, deployment instructions, and commands for starting the development server and building static files.
2026-01-29 12:41:15 +08:00
wangdage12
bba35b3e20 Merge pull request #1 from wangdage12/dev
[V1.0.0] 添加基本功能
2026-01-29 12:31:17 +08:00
wangdage12
b0a7b98091 Add MIT License to the project 2025-12-28 17:07:00 +08:00
19 changed files with 142 additions and 6140 deletions

2
.gitignore vendored
View File

@@ -22,5 +22,3 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
/test_scripts

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 wangdage12
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

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

View File

@@ -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>胡桃工具箱-WDG Snap Hutao</title> <title>ht-web</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

122
package-lock.json generated
View File

@@ -8,8 +8,6 @@
"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",
@@ -1084,25 +1082,6 @@
"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",
@@ -1418,107 +1397,6 @@
"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",

View File

@@ -9,8 +9,6 @@
"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",

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,6 @@ export interface DownloadResource {
file_hash: string | null file_hash: string | null
file_size: string | null file_size: string | null
is_active: boolean | null is_active: boolean | null
is_test?: boolean
package_type: string package_type: string
version: string version: string
} }
@@ -37,20 +36,6 @@ export function getLatestVersionApi(): Promise<DownloadResource> {
}) })
} }
/**
* 获取测试版本
* 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 * GET /web-api/download-resources
@@ -101,7 +86,6 @@ export interface CreateResourceRequest {
file_size?: string | null file_size?: string | null
file_hash?: string | null file_hash?: string | null
is_active?: boolean | null is_active?: boolean | null
is_test?: boolean
} }
/** 创建资源响应数据类型 */ /** 创建资源响应数据类型 */

View File

@@ -1,83 +0,0 @@
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})`
}

View File

@@ -44,22 +44,14 @@ export function getUserInfoApi(): Promise<UserInfo> {
/** /**
* 获取用户列表 * 获取用户列表
* GET /web-api/users * GET /web-api/users
* @param params 搜索和筛选参数 * @param q 搜索参数可搜索用户名、邮箱、_id
* @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(params?: { export function getUserListApi(q?: string): Promise<UserListItem[]> {
q?: string const params: Record<string, any> = {}
role?: string if (q) {
email?: string params.q = q
username?: string }
id?: string
is?: string
}): Promise<UserListItem[]> {
return request({ return request({
url: '/web-api/users', url: '/web-api/users',
method: 'get', method: 'get',

View File

@@ -1,637 +0,0 @@
<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>

View File

@@ -91,50 +91,13 @@ 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')
const children = dashboardRoute?.children || [] return dashboardRoute?.children || []
return filterRoutesByPermission(children)
}) })
</script> </script>

View File

@@ -4,32 +4,18 @@ 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)
@@ -38,6 +24,7 @@ app.use(Particles, {
await loadSlim(engine) await loadSlim(engine)
} }
}) })
import '@/router/permission'
// 初始化主题 // 初始化主题
const themeStore = useThemeStore(pinia) const themeStore = useThemeStore(pinia)

View File

@@ -1,13 +1,6 @@
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',
@@ -37,12 +30,7 @@ const routes = [
{ {
path: 'user', path: 'user',
component: () => import('@/views/user/index.vue'), component: () => import('@/views/user/index.vue'),
meta: { title: '用户管理', icon: 'User', requiresFullAdmin: true }, meta: { title: '用户管理', icon: 'User' },
},
{
path: 'gacha-log',
component: () => import('@/views/gacha-log/index.vue'),
meta: { title: '祈愿记录', icon: 'TrendCharts' },
}, },
{ {
path: 'system', path: 'system',
@@ -52,12 +40,12 @@ const routes = [
{ {
path: 'menu', path: 'menu',
component: () => import('@/views/dashboard/index.vue'), component: () => import('@/views/dashboard/index.vue'),
meta: { title: '菜单管理', icon: 'Menu', requiresFullAdmin: true }, meta: { title: '菜单管理', icon: 'Menu' },
}, },
{ {
path: 'role', path: 'role',
component: () => import('@/views/dashboard/index.vue'), component: () => import('@/views/dashboard/index.vue'),
meta: { title: '角色管理', icon: 'UserFilled', requiresFullAdmin: true }, meta: { title: '角色管理', icon: 'UserFilled' },
}, },
{ {
path: 'announcement', path: 'announcement',

View File

@@ -1,14 +1,6 @@
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()
@@ -42,12 +34,5 @@ router.beforeEach(async (to, _ , next) => {
} }
} }
// 检查路由权限
if (to.meta.requiresFullAdmin && !hasFullAdminPermission(userStore.userInfo)) {
// 没有完整管理员权限,跳转到首页
next('/dashboard/home')
return
}
next() next()
}) })

View File

@@ -231,14 +231,6 @@
/> />
</el-form-item> </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-form-item label="新功能描述">
<el-input <el-input
v-model="createForm.features" v-model="createForm.features"
@@ -322,14 +314,6 @@
/> />
</el-form-item> </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-form-item label="新功能描述">
<el-input <el-input
v-model="editForm.features" v-model="editForm.features"
@@ -396,7 +380,6 @@ const createForm = reactive<CreateResourceRequest>({
file_size: '', file_size: '',
file_hash: '', file_hash: '',
is_active: true, is_active: true,
is_test: false,
}) })
const createRules: FormRules = { const createRules: FormRules = {
@@ -427,7 +410,6 @@ const editForm = reactive<CreateResourceRequest>({
file_size: '', file_size: '',
file_hash: '', file_hash: '',
is_active: true, is_active: true,
is_test: false,
}) })
const editRules: FormRules = { const editRules: FormRules = {
@@ -517,7 +499,6 @@ function handleCreate() {
file_size: '', file_size: '',
file_hash: '', file_hash: '',
is_active: true, is_active: true,
is_test: false,
}) })
createDialogVisible.value = true createDialogVisible.value = true
} }
@@ -562,7 +543,6 @@ function handleEdit(resource: DownloadResource) {
file_size: resource.file_size || '', file_size: resource.file_size || '',
file_hash: resource.file_hash || '', file_hash: resource.file_hash || '',
is_active: resource.is_active ?? true, is_active: resource.is_active ?? true,
is_test: resource.is_test ?? false,
}) })
editDialogVisible.value = true editDialogVisible.value = true
} }

View File

@@ -14,50 +14,11 @@
</h1> </h1>
<p class="section-description">选择适合您的安装包立即开始使用 Snap Hutao<br>系统要求新版本Windows10及Windows11</p> <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 v-if="latestVersion" class="latest-version-card">
<div class="latest-header"> <div class="latest-header">
<el-tag <el-tag type="success" size="large" effect="dark">最新版本</el-tag>
:type="versionType === 'test' ? 'warning' : 'success'"
size="large"
effect="dark"
>
{{ versionType === 'test' ? '最新测试版本' : '最新版本' }}
</el-tag>
<h2 class="version-title">{{ latestVersion.version }}</h2> <h2 class="version-title">{{ latestVersion.version }}</h2>
</div> </div>
@@ -109,18 +70,16 @@
</div> </div>
</div> </div>
<!-- 历史版本列表仅正式版显示 --> <el-divider>历史版本</el-divider>
<el-divider v-if="versionType === 'stable'">历史版本</el-divider>
<!-- 历史版本列表 -->
<div v-loading="loading" class="history-list"> <div v-loading="loading" class="history-list">
<!-- 正式版显示历史版本空状态 --> <div v-if="historyVersions.length === 0 && !loading" class="empty-state">
<div v-if="versionType === 'stable' && historyVersions.length === 0 && !loading" class="empty-state">
<el-icon><FolderOpened /></el-icon> <el-icon><FolderOpened /></el-icon>
<p>暂无历史版本</p> <p>暂无历史版本</p>
</div> </div>
<!-- 正式版显示历史版本列表 --> <div v-else class="version-table">
<div v-else-if="versionType === 'stable' && historyVersions.length > 0" class="version-table">
<div <div
v-for="(item, index) in historyVersions" v-for="(item, index) in historyVersions"
:key="index" :key="index"
@@ -170,12 +129,6 @@
</div> </div>
</div> </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>
</div> </div>
@@ -194,7 +147,7 @@ import {
FolderOpened, FolderOpened,
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import Header from '@/components/Header.vue' import Header from '@/components/Header.vue'
import { getDownloadResourcesApi, getTestVersionApi } from '@/api/download' import { getDownloadResourcesApi } from '@/api/download'
const router = useRouter() const router = useRouter()
@@ -247,7 +200,6 @@ const latestVersion = ref<VersionInfo | null>(null)
const historyVersions = ref<VersionInfo[]>([]) const historyVersions = ref<VersionInfo[]>([])
const loading = ref(false) const loading = ref(false)
const downloading = ref(false) const downloading = ref(false)
const versionType = ref<'stable' | 'test'>('stable')
/** /**
* 格式化日期 * 格式化日期
@@ -277,12 +229,7 @@ function downloadFile(item: VersionInfo, packageType: 'msi' | 'msix') {
const a = document.createElement('a') const a = document.createElement('a')
a.href = downloadUrl a.href = downloadUrl
// 如果是msix的话文件是用zip格式压缩的 a.download = `Snap.Hutao.${item.version}.${packageType}`
if (packageType === 'msix') {
a.download = `Snap.Hutao.${item.version}.zip`
} else {
a.download = `Snap.Hutao.${item.version}.msi`
}
a.target = '_blank' a.target = '_blank'
document.body.appendChild(a) document.body.appendChild(a)
a.click() a.click()
@@ -309,7 +256,7 @@ function getTooltipText(item: VersionInfo, packageType: 'msi' | 'msix') {
} }
/** /**
* 加载正式版本 * 加载所有版本
*/ */
async function loadAllVersions() { async function loadAllVersions() {
try { try {
@@ -372,91 +319,6 @@ async function loadAllVersions() {
} }
} }
/**
* 加载测试版本
*/
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(() => { onMounted(() => {
loadAllVersions() loadAllVersions()
}) })
@@ -498,42 +360,7 @@ onMounted(() => {
font-size: 16px; font-size: 16px;
color: var(--text-color); color: var(--text-color);
opacity: 0.8; opacity: 0.8;
margin-bottom: 20px; margin-bottom: 10px;
}
/* 版本类型切换 */
.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;
} }
/* 最新版本卡片 */ /* 最新版本卡片 */

View File

@@ -1,847 +0,0 @@
<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>

View File

@@ -2,20 +2,21 @@
<div class="user-management"> <div class="user-management">
<!-- 搜索栏和用户统计并排 --> <!-- 搜索栏和用户统计并排 -->
<div class="search-statistics-row"> <div class="search-statistics-row">
<!-- GitHub样式搜索 --> <!-- 搜索 -->
<div class="search-container"> <el-form :inline="true" :model="searchForm" class="search-form">
<GitHubSearchInput <el-form-item label="关键词">
ref="githubSearch" <el-input
v-model="searchQuery" v-model="searchForm.keyword"
placeholder="搜索用户... 例如: role:developer username:test" placeholder="请输入用户名、邮箱或ID"
@search="handleGitHubSearch" clearable
@keyup.enter="handleSearch"
/> />
<div class="search-actions"> </el-form-item>
<el-button type="primary" @click="executeSearch" :loading="loading">搜索</el-button> <el-form-item>
<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" />
@@ -66,41 +67,45 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed, onMounted } from 'vue' import { ref, reactive, 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'
const githubSearch = ref<InstanceType<typeof GitHubSearchInput>>() interface SearchForm {
const searchQuery = ref('') keyword: string
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(() =>
displayUserList.value.filter(user => user.IsMaintainer).length userList.value.filter(user => user.IsMaintainer).length
) )
const developerCount = computed(() => const developerCount = computed(() =>
displayUserList.value.filter(user => user.IsLicensedDeveloper).length userList.value.filter(user => user.IsLicensedDeveloper).length
) )
// 获取用户列表 // 获取用户列表
async function fetchUserList(filters?: Record<string, string>) { async function fetchUserList(searchKeyword?: string) {
loading.value = true loading.value = true
try { try {
const data = await getUserListApi(filters) const data = await getUserListApi(searchKeyword)
userList.value = data userList.value = data
if (filters && Object.keys(filters).length > 0) { if (searchKeyword) {
ElMessage.success(`找到 ${data.length} 个匹配的用户`) ElMessage.success(`搜索完成,找到 ${data.length} 个匹配的用户`)
isSearchMode.value = true isSearchMode.value = true
} else { } else {
ElMessage.success('用户列表加载成功') ElMessage.success('用户列表加载成功')
@@ -115,37 +120,40 @@ async function fetchUserList(filters?: Record<string, string>) {
} }
} }
// 处理GitHub搜索 function handleSearch() {
function handleGitHubSearch(_query: string, filters: Record<string, string>) { if (!searchForm.keyword.trim()) {
currentFilters.value = filters ElMessage.warning('请输入搜索关键词')
fetchUserList(filters) return
}
fetchUserList(searchForm.keyword.trim())
} }
// 执行搜索(通过搜索框)
function executeSearch() {
if (githubSearch.value) {
githubSearch.value.handleSearch()
} else {
handleGitHubSearch(searchQuery.value, currentFilters.value)
}
}
// 重置搜索
function handleReset() { function handleReset() {
searchQuery.value = '' searchForm.keyword = ''
currentFilters.value = {} isSearchMode.value = false
fetchUserList() fetchUserList() // 重新获取全部用户列表
} }
// 刷新列表
function handleRefresh() { function handleRefresh() {
if (isSearchMode.value && Object.keys(currentFilters.value).length > 0) { if (isSearchMode.value && searchForm.keyword) {
fetchUserList(currentFilters.value) fetchUserList(searchForm.keyword)
} 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()
@@ -156,58 +164,25 @@ 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 { /* 保持原有样式,可根据需要调整宽度 */
width: 600px; margin-bottom: 0;
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;
flex-shrink: 0; margin-top: 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>