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
View File
@@ -22,5 +22,3 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
/test_scripts
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 wangdage12
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+27 -3
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
```
+1 -1
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
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",
-2
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
-16
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
} }
/** 创建资源响应数据类型 */ /** 创建资源响应数据类型 */
-83
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})`
}
+7 -15
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',
-637
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 + ' '
// 便
// 使setTimeoutDOM
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>
+2 -39
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>
+3 -16
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)
+3 -15
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',
-15
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()
}) })
-20
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
} }
+9 -182
View File
@@ -13,51 +13,12 @@
下载中心 下载中心
</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
// msixzip 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;
} }
/* 最新版本卡片 */ /* 最新版本卡片 */
-847
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 {
// EndIds0
const endIds: EndIds = {
'100': 0,
'200': 0,
'301': 0,
'302': 0,
'500': 0,
}
gachaData.value = await getGachaLogDataApi(uid, endIds)
//
const firstType = gachaTypes.value[0]
if (firstType) {
selectedType.value = firstType.queryType
}
} catch (error) {
ElMessage.error('获取祈愿数据失败')
console.error(error)
} finally {
loading.value = false
}
}
// UID
function handleUidChange(uid: string) {
if (uid) {
loadGachaData(uid)
}
}
//
onMounted(async () => {
await loadMetadata()
await loadEntries()
})
</script>
<style scoped>
.gacha-log-page {
padding: 0;
}
.header-card {
margin-bottom: 20px;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 16px;
}
.left-section {
display: flex;
align-items: center;
gap: 12px;
}
.uid-select {
width: 280px;
}
.loading-card,
.empty-card {
margin-bottom: 20px;
}
.loading-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 0;
color: var(--text-color);
}
.loading-content .el-icon {
margin-bottom: 16px;
color: var(--aside-active);
}
/* 总览网格 */
.overview-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.stat-card {
cursor: pointer;
transition: all 0.3s;
border: 2px solid transparent;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.stat-card.active {
border-color: var(--aside-active);
}
.stat-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-color);
}
.type-name {
font-size: 16px;
font-weight: 600;
color: var(--text-color);
}
.stat-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.stat-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.stat-row .label {
color: var(--text-color);
opacity: 0.7;
font-size: 14px;
}
.stat-row .value {
font-weight: 600;
font-size: 14px;
}
.stat-row .value.star5 {
color: #f59e0b;
}
.stat-row .value.star4 {
color: #a855f7;
}
.stat-row .value.highlight {
color: var(--aside-active);
}
/* 5星预览 */
.star5-preview {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--border-color);
flex-wrap: wrap;
}
.preview-item {
display: flex;
align-items: center;
}
.item-icon {
width: 48px;
height: 48px;
border-radius: 8px;
object-fit: cover;
background: rgba(0, 0, 0, 0.1);
}
.item-icon.star5 {
box-shadow: 0 0 8px rgba(245, 158, 11, 0.5);
}
.more-count {
font-size: 12px;
color: var(--text-color);
opacity: 0.7;
margin-left: 4px;
}
/* 详细卡片 */
.detail-card {
margin-bottom: 20px;
width: 100%;
}
.detail-card :deep(.el-card__body) {
padding: 0;
}
.detail-content {
width: 100%;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.item-cell {
display: flex;
align-items: center;
gap: 8px;
}
.item-icon-small {
width: 32px;
height: 32px;
border-radius: 4px;
object-fit: cover;
background: rgba(0, 0, 0, 0.1);
}
.item-icon-small.star5 {
box-shadow: 0 0 6px rgba(245, 158, 11, 0.6);
}
.item-icon-small.star4 {
box-shadow: 0 0 4px rgba(168, 85, 247, 0.5);
}
.item-name {
font-weight: 500;
}
.item-name.star5 {
color: #f59e0b;
}
.item-name.star4 {
color: #a855f7;
}
.record-id {
font-family: monospace;
font-size: 12px;
opacity: 0.6;
}
/* 列表卡片 */
.list-card {
margin-bottom: 20px;
}
/* 抽数标签渐变色 */
.pity-tag {
display: inline-block;
padding: 2px 10px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
min-width: 50px;
text-align: center;
}
/* 列表加载状态 */
.list-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 16px;
color: var(--text-color);
opacity: 0.7;
}
.list-loading .el-icon {
font-size: 18px;
}
/* 列表结束提示 */
.list-end {
text-align: center;
padding: 16px;
color: var(--text-color);
opacity: 0.5;
font-size: 13px;
}
/* 响应式 */
@media (max-width: 768px) {
.header-content {
flex-direction: column;
align-items: stretch;
}
.left-section,
.right-section {
justify-content: center;
}
.uid-select {
width: 100%;
}
.overview-grid {
grid-template-columns: 1fr;
}
}
</style>
+69 -94
View File
@@ -2,27 +2,28 @@
<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 @click="handleReset">重置</el-button> <el-button type="primary" @click="handleSearch" :loading="loading">搜索</el-button>
</div> <el-button @click="handleReset">重置</el-button>
</div> </el-form-item>
</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>
<!-- 操作按钮 --> <!-- 操作按钮 -->
@@ -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
}
//
function executeSearch() {
if (githubSearch.value) {
githubSearch.value.handleSearch()
} else {
handleGitHubSearch(searchQuery.value, currentFilters.value)
} }
fetchUserList(searchForm.keyword.trim())
} }
//
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>