Compare commits

..

6 Commits

Author SHA1 Message Date
wangdage12 eca8f9856e Merge pull request #4 from wangdage12/dev
下载页面支持筛选测试版包,管理后台中用户列表支持高级筛选
2026-02-10 20:36:43 +08:00
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
13 changed files with 57 additions and 5155 deletions
-2
View File
@@ -22,5 +22,3 @@ dist-ssr
*.njsproj
*.sln
*.sw?
/test_scripts
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 wangdage12
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+27 -3
View File
@@ -1,7 +1,31 @@
# Snap.Hutao服务器管理后台
还没写完,写完后使用
该项目用于管理Snap.Hutao项目的服务器的后台系统,提供官网页面和用户、公告管理等功能。
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
## 部署
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
确保你已经安装了Node.js和npm。
克隆仓库到本地,在项目根目录下运行以下命令安装依赖:
```bash
npm install
```
**编辑`.env.x`中的VITE_API_BASE_URL变量值为你的API地址,环境变量文件名中的`x`为开发环境(development)或者生产环境(production**
### 启动开发服务器
运行以下命令启动开发服务器:
```bash
npm run dev
```
### 构建静态文件
运行以下命令构建生产环境的静态文件:
```bash
npm run build
```
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>胡桃工具箱-WDG Snap Hutao</title>
<title>ht-web</title>
</head>
<body>
<div id="app"></div>
-102
View File
@@ -9,7 +9,6 @@
"version": "0.0.0",
"dependencies": {
"@primer/css": "^22.1.0",
"@sentry/vue": "^10.42.0",
"@tsparticles/slim": "^3.9.1",
"@tsparticles/vue3": "^3.0.1",
"axios": "^1.13.2",
@@ -1418,107 +1417,6 @@
"win32"
]
},
"node_modules/@sentry-internal/browser-utils": {
"version": "10.42.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.42.0.tgz",
"integrity": "sha512-HCEICKvepxN4/6NYfnMMMlppcSwIEwtS66X6d1/mwaHdi2ivw0uGl52p7Nfhda/lIJArbrkWprxl0WcjZajhQA==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.42.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/feedback": {
"version": "10.42.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.42.0.tgz",
"integrity": "sha512-lpPcHsog10MVYFTWE0Pf8vQRqQWwZHJpkVl2FEb9/HDdHFyTBUhCVoWo1KyKaG7GJl9AVKMAg7bp9SSNArhFNQ==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.42.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay": {
"version": "10.42.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.42.0.tgz",
"integrity": "sha512-Zh3EoaH39x2lqVY1YyVB2vJEyCIrT+YLUQxYl1yvP0MJgLxaR6akVjkgxbSUJahan4cX5DxpZiEHfzdlWnYPyQ==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "10.42.0",
"@sentry/core": "10.42.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay-canvas": {
"version": "10.42.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.42.0.tgz",
"integrity": "sha512-am3m1Fj8ihoPfoYo41Qq4KeCAAICn4bySso8Oepu9dMNe9Lcnsf+reMRS2qxTPg3pZDc4JEMOcLyNCcgnAfrHw==",
"license": "MIT",
"dependencies": {
"@sentry-internal/replay": "10.42.0",
"@sentry/core": "10.42.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/browser": {
"version": "10.42.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.42.0.tgz",
"integrity": "sha512-iXxYjXNEBwY1MH4lDSDZZUNjzPJDK7/YLwVIJq/3iBYpIQVIhaJsoJnf3clx9+NfJ8QFKyKfcvgae61zm+hgTA==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "10.42.0",
"@sentry-internal/feedback": "10.42.0",
"@sentry-internal/replay": "10.42.0",
"@sentry-internal/replay-canvas": "10.42.0",
"@sentry/core": "10.42.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/core": {
"version": "10.42.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.42.0.tgz",
"integrity": "sha512-L4rMrXMqUKBanpjpMT+TuAVk6xAijz6AWM6RiEYpohAr7SGcCEc1/T0+Ep1eLV8+pwWacfU27OvELIyNeOnGzA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/vue": {
"version": "10.42.0",
"resolved": "https://registry.npmjs.org/@sentry/vue/-/vue-10.42.0.tgz",
"integrity": "sha512-D6mYt6zRV1YXMZ8xmGKXzb0VHSLANUxpDAC3tfCeRYZ9P0MEHlNI6aapvjiNAh+0Vi9bOaHIUkzpatbE1gWhOg==",
"license": "MIT",
"dependencies": {
"@sentry/browser": "10.42.0",
"@sentry/core": "10.42.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@tanstack/vue-router": "^1.64.0",
"pinia": "2.x || 3.x",
"vue": "2.x || 3.x"
},
"peerDependenciesMeta": {
"@tanstack/vue-router": {
"optional": true
},
"pinia": {
"optional": true
}
}
},
"node_modules/@tsparticles/basic": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/basic/-/basic-3.9.1.tgz",
-1
View File
@@ -10,7 +10,6 @@
},
"dependencies": {
"@primer/css": "^22.1.0",
"@sentry/vue": "^10.42.0",
"@tsparticles/slim": "^3.9.1",
"@tsparticles/vue3": "^3.0.1",
"axios": "^1.13.2",
File diff suppressed because it is too large Load Diff
-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})`
}
+2 -39
View File
@@ -91,50 +91,13 @@ const toggle = () => (isCollapse.value = !isCollapse.value)
const activeMenu = computed(() => route.path)
//
/**
* 检查用户是否具有完整管理员权限
*/
const hasFullAdminPermission = computed(() => {
const userInfo = userStore.userInfo
return userInfo?.IsMaintainer === true && userInfo?.IsLicensedDeveloper === true
})
/**
* 递归过滤路由移除没有权限的菜单项
*/
const filterRoutesByPermission = (routes: any[]): any[] => {
return routes
.filter(route => {
//
if (route.meta?.requiresFullAdmin && !hasFullAdminPermission.value) {
return false
}
return true
})
.map(route => {
//
if (route.children && route.children.length > 0) {
const filteredChildren = filterRoutesByPermission(route.children)
if (filteredChildren.length === 0 && route.component?.name === 'RouterViewPlaceholder') {
return null
}
return { ...route, children: filteredChildren }
}
return route
})
.filter(route => route !== null)
}
/**
* 获取菜单路由根据权限过滤
* 获取菜单路由直接使用配置的路由结构
*/
const menuRoutes = computed(() => {
// /dashboard
const dashboardRoute = router.options.routes.find(r => r.path === '/dashboard')
const children = dashboardRoute?.children || []
return filterRoutesByPermission(children)
return dashboardRoute?.children || []
})
</script>
+3 -16
View File
@@ -4,32 +4,18 @@ import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import '@/styles/index.scss'
import '@/router/permission'
import App from './App.vue'
import router from './router'
import { useThemeStore } from './stores/theme'
import Particles from '@tsparticles/vue3'
import { loadSlim } from '@tsparticles/slim'
import type { Engine } from '@tsparticles/engine'
import * as Sentry from "@sentry/vue";
const app = createApp(App)
const pinia = createPinia()
Sentry.init({
app,
dsn: "https://2e38c08821de95d002b6e6253d3cd599@o4507525750521856.ingest.us.sentry.io/4511014276169728",
// Setting this option to true will send default PII data to Sentry.
// For example, automatic IP address collection on events
sendDefaultPii: true,
integrations: [Sentry.browserTracingIntegration({ router })],
tracesSampleRate: 0.2,
tracePropagationTargets: ["localhost", "https://htserver.wdg.cloudns.ch/"],
// Session Replay
replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
replaysOnErrorSampleRate: 1.0 // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.
});
app.use(pinia)
app.use(router)
app.use(ElementPlus)
@@ -38,6 +24,7 @@ app.use(Particles, {
await loadSlim(engine)
}
})
import '@/router/permission'
// 初始化主题
const themeStore = useThemeStore(pinia)
+3 -15
View File
@@ -1,13 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'
import DefaultLayout from '@/layouts/DefaultLayout.vue'
/**
*
* - hidden: 是否在菜单中隐藏
* - title: 菜单标题
* - icon: 菜单图标
* - requiresFullAdmin: 是否需要完整管理员权限IsMaintainer && IsLicensedDeveloper
*/
const routes = [
{
path: '/login',
@@ -37,12 +30,7 @@ const routes = [
{
path: 'user',
component: () => import('@/views/user/index.vue'),
meta: { title: '用户管理', icon: 'User', requiresFullAdmin: true },
},
{
path: 'gacha-log',
component: () => import('@/views/gacha-log/index.vue'),
meta: { title: '祈愿记录', icon: 'TrendCharts' },
meta: { title: '用户管理', icon: 'User' },
},
{
path: 'system',
@@ -52,12 +40,12 @@ const routes = [
{
path: 'menu',
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '菜单管理', icon: 'Menu', requiresFullAdmin: true },
meta: { title: '菜单管理', icon: 'Menu' },
},
{
path: 'role',
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '角色管理', icon: 'UserFilled', requiresFullAdmin: true },
meta: { title: '角色管理', icon: 'UserFilled' },
},
{
path: 'announcement',
-15
View File
@@ -1,14 +1,6 @@
import router from './index'
import { useUserStore } from '@/stores/user'
/**
*
* IsMaintainer IsLicensedDeveloper true
*/
const hasFullAdminPermission = (userInfo: { IsMaintainer: boolean; IsLicensedDeveloper: boolean } | null): boolean => {
return userInfo?.IsMaintainer === true && userInfo?.IsLicensedDeveloper === true
}
router.beforeEach(async (to, _ , next) => {
const userStore = useUserStore()
@@ -42,12 +34,5 @@ router.beforeEach(async (to, _ , next) => {
}
}
// 检查路由权限
if (to.meta.requiresFullAdmin && !hasFullAdminPermission(userStore.userInfo)) {
// 没有完整管理员权限,跳转到首页
next('/dashboard/home')
return
}
next()
})
-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>