Compare commits

..

12 Commits

Author SHA1 Message Date
wangdage12
503cfb3fee 添加截图 2026-03-09 20:28:02 +08:00
wangdage12
626251e110 Merge pull request #5 from wangdage12/dev
添加祈愿记录页面、根据用户权限显示侧边栏项目
2026-03-09 20:09:46 +08:00
fanbook-wangdage
0527c185ec 添加router参数 2026-03-09 19:52:12 +08:00
fanbook-wangdage
c97cd7cd5d 添加Session Replay配置 2026-03-09 19:39:33 +08:00
fanbook-wangdage
b86a765bb1 添加sentry 2026-03-09 19:32:15 +08:00
fanbook-wangdage
1bba83a3c6 添加祈愿记录页面,根据用户权限显示侧边栏项目 2026-03-09 19:19:18 +08:00
wangdage12
eca8f9856e Merge pull request #4 from wangdage12/dev
下载页面支持筛选测试版包,管理后台中用户列表支持高级筛选
2026-02-10 20:36:43 +08:00
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 5207 additions and 12 deletions

2
.gitignore vendored
View File

@@ -22,3 +22,5 @@ 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,38 @@
# 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. <img width="1843" height="818" alt="image" src="https://github.com/user-attachments/assets/1c5f21fb-23cf-4c32-9936-db13dfc3d3d4" />
<img width="1837" height="521" alt="image" src="https://github.com/user-attachments/assets/bd7e373c-60a1-44c6-b16b-54f3fb352e4e" />
<img width="1625" height="566" alt="image" src="https://github.com/user-attachments/assets/87ec56e3-68d9-4996-8fa8-ce08f8c1c896" />
<img width="1632" height="414" alt="image" src="https://github.com/user-attachments/assets/85fa4946-c50c-4f6f-8c37-0170b8f7246a" />
<img width="1629" height="749" alt="image" src="https://github.com/user-attachments/assets/4cb02464-b2a9-41c2-bc14-9dba10c26132" />
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
## 部署
确保你已经安装了Node.js和npm。
克隆仓库到本地,在项目根目录下运行以下命令安装依赖:
```bash
npm install
```
**编辑`.env.x`中的VITE_API_BASE_URL变量值为你的API地址环境变量文件名中的`x`为开发环境development或者生产环境production**
### 启动开发服务器
运行以下命令启动开发服务器:
```bash
npm run dev
```
### 构建静态文件
运行以下命令构建生产环境的静态文件:
```bash
npm run build
```

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

102
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@primer/css": "^22.1.0", "@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",
@@ -1417,6 +1418,107 @@
"win32" "win32"
] ]
}, },
"node_modules/@sentry-internal/browser-utils": {
"version": "10.42.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.42.0.tgz",
"integrity": "sha512-HCEICKvepxN4/6NYfnMMMlppcSwIEwtS66X6d1/mwaHdi2ivw0uGl52p7Nfhda/lIJArbrkWprxl0WcjZajhQA==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.42.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/feedback": {
"version": "10.42.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.42.0.tgz",
"integrity": "sha512-lpPcHsog10MVYFTWE0Pf8vQRqQWwZHJpkVl2FEb9/HDdHFyTBUhCVoWo1KyKaG7GJl9AVKMAg7bp9SSNArhFNQ==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.42.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay": {
"version": "10.42.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.42.0.tgz",
"integrity": "sha512-Zh3EoaH39x2lqVY1YyVB2vJEyCIrT+YLUQxYl1yvP0MJgLxaR6akVjkgxbSUJahan4cX5DxpZiEHfzdlWnYPyQ==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "10.42.0",
"@sentry/core": "10.42.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay-canvas": {
"version": "10.42.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.42.0.tgz",
"integrity": "sha512-am3m1Fj8ihoPfoYo41Qq4KeCAAICn4bySso8Oepu9dMNe9Lcnsf+reMRS2qxTPg3pZDc4JEMOcLyNCcgnAfrHw==",
"license": "MIT",
"dependencies": {
"@sentry-internal/replay": "10.42.0",
"@sentry/core": "10.42.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/browser": {
"version": "10.42.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.42.0.tgz",
"integrity": "sha512-iXxYjXNEBwY1MH4lDSDZZUNjzPJDK7/YLwVIJq/3iBYpIQVIhaJsoJnf3clx9+NfJ8QFKyKfcvgae61zm+hgTA==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "10.42.0",
"@sentry-internal/feedback": "10.42.0",
"@sentry-internal/replay": "10.42.0",
"@sentry-internal/replay-canvas": "10.42.0",
"@sentry/core": "10.42.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/core": {
"version": "10.42.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.42.0.tgz",
"integrity": "sha512-L4rMrXMqUKBanpjpMT+TuAVk6xAijz6AWM6RiEYpohAr7SGcCEc1/T0+Ep1eLV8+pwWacfU27OvELIyNeOnGzA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/vue": {
"version": "10.42.0",
"resolved": "https://registry.npmjs.org/@sentry/vue/-/vue-10.42.0.tgz",
"integrity": "sha512-D6mYt6zRV1YXMZ8xmGKXzb0VHSLANUxpDAC3tfCeRYZ9P0MEHlNI6aapvjiNAh+0Vi9bOaHIUkzpatbE1gWhOg==",
"license": "MIT",
"dependencies": {
"@sentry/browser": "10.42.0",
"@sentry/core": "10.42.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@tanstack/vue-router": "^1.64.0",
"pinia": "2.x || 3.x",
"vue": "2.x || 3.x"
},
"peerDependenciesMeta": {
"@tanstack/vue-router": {
"optional": true
},
"pinia": {
"optional": true
}
}
},
"node_modules/@tsparticles/basic": { "node_modules/@tsparticles/basic": {
"version": "3.9.1", "version": "3.9.1",
"resolved": "https://registry.npmjs.org/@tsparticles/basic/-/basic-3.9.1.tgz", "resolved": "https://registry.npmjs.org/@tsparticles/basic/-/basic-3.9.1.tgz",

View File

@@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"@primer/css": "^22.1.0", "@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

83
src/api/gachaLog.ts Normal file
View File

@@ -0,0 +1,83 @@
import request from '@/utils/request'
/** 祈愿记录条目 */
export interface GachaLogEntry {
Excluded: boolean
ItemCount: number
Uid: string
}
/** 祈愿记录数据项 */
export interface GachaLogItem {
GachaType: number
Id: number
ItemId: number
QueryType: number
Time: string
}
/** EndIds 类型 - 各祈愿类型的起始ID */
export interface EndIds {
'100': number // 新手祈愿
'200': number // 常驻祈愿
'301': number // 角色活动祈愿
'302': number // 武器活动祈愿
'500': number // 集录祈愿
}
/**
* 获取云抽卡记录列表
* GET /GachaLog/Entries
*/
export function getGachaLogEntriesApi(): Promise<GachaLogEntry[]> {
return request({
url: '/GachaLog/Entries',
method: 'get',
})
}
/**
* 获取云抽卡数据
* POST /GachaLog/Retrieve
* @param uid 用户游戏UID
* @param endIds 各个祈愿类型的起始ID
*/
export function getGachaLogDataApi(uid: string, endIds: EndIds): Promise<GachaLogItem[]> {
return request({
url: '/GachaLog/Retrieve',
method: 'post',
data: {
Uid: uid,
EndIds: endIds,
},
})
}
/**
* 祈愿类型映射 (QueryType -> 名称)
*/
export const GACHA_TYPE_NAMES: Record<number, string> = {
100: '新手祈愿',
200: '常驻祈愿',
301: '角色活动祈愿',
302: '武器活动祈愿',
500: '集录祈愿',
}
/**
* GachaType 到 QueryType 的映射
* 用于合并共享保底的卡池
*/
export function gachaTypeToQueryType(gachaType: number): number {
// 400 是角色活动祈愿的子类型,合并到 301
if (gachaType === 400) return 301
return gachaType
}
/**
* 获取物品的祈愿类型名称
*/
export function getGachaTypeName(gachaType: number): string {
const queryType = gachaTypeToQueryType(gachaType)
return GACHA_TYPE_NAMES[queryType] || `未知类型(${gachaType})`
}

View File

@@ -91,13 +91,50 @@ const toggle = () => (isCollapse.value = !isCollapse.value)
const activeMenu = computed(() => route.path) const activeMenu = computed(() => route.path)
// 根据用户权限过滤菜单路由
/** /**
* 获取菜单路由,直接使用配置的路由结构 * 检查用户是否具有完整管理员权限
*/
const hasFullAdminPermission = computed(() => {
const userInfo = userStore.userInfo
return userInfo?.IsMaintainer === true && userInfo?.IsLicensedDeveloper === true
})
/**
* 递归过滤路由,移除没有权限的菜单项
*/
const filterRoutesByPermission = (routes: any[]): any[] => {
return routes
.filter(route => {
// 如果路由需要完整管理员权限,检查用户是否有权限
if (route.meta?.requiresFullAdmin && !hasFullAdminPermission.value) {
return false
}
return true
})
.map(route => {
// 如果有子路由,递归过滤
if (route.children && route.children.length > 0) {
const filteredChildren = filterRoutesByPermission(route.children)
if (filteredChildren.length === 0 && route.component?.name === 'RouterViewPlaceholder') {
return null
}
return { ...route, children: filteredChildren }
}
return route
})
.filter(route => route !== null)
}
/**
* 获取菜单路由,根据权限过滤
*/ */
const menuRoutes = computed(() => { const menuRoutes = computed(() => {
// 查找 /dashboard 路由的子路由 // 查找 /dashboard 路由的子路由
const dashboardRoute = router.options.routes.find(r => r.path === '/dashboard') const dashboardRoute = router.options.routes.find(r => r.path === '/dashboard')
return dashboardRoute?.children || [] const children = dashboardRoute?.children || []
return filterRoutesByPermission(children)
}) })
</script> </script>

View File

@@ -4,18 +4,32 @@ import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css' import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css' import 'element-plus/theme-chalk/dark/css-vars.css'
import '@/styles/index.scss' import '@/styles/index.scss'
import '@/router/permission'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import { useThemeStore } from './stores/theme' import { useThemeStore } from './stores/theme'
import Particles from '@tsparticles/vue3' import Particles from '@tsparticles/vue3'
import { loadSlim } from '@tsparticles/slim' import { loadSlim } from '@tsparticles/slim'
import type { Engine } from '@tsparticles/engine' import type { Engine } from '@tsparticles/engine'
import * as Sentry from "@sentry/vue";
const app = createApp(App) const app = createApp(App)
const pinia = createPinia() const pinia = createPinia()
Sentry.init({
app,
dsn: "https://2e38c08821de95d002b6e6253d3cd599@o4507525750521856.ingest.us.sentry.io/4511014276169728",
// Setting this option to true will send default PII data to Sentry.
// For example, automatic IP address collection on events
sendDefaultPii: true,
integrations: [Sentry.browserTracingIntegration({ router })],
tracesSampleRate: 0.2,
tracePropagationTargets: ["localhost", "https://htserver.wdg.cloudns.ch/"],
// Session Replay
replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
replaysOnErrorSampleRate: 1.0 // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.
});
app.use(pinia) app.use(pinia)
app.use(router) app.use(router)
app.use(ElementPlus) app.use(ElementPlus)
@@ -24,7 +38,6 @@ app.use(Particles, {
await loadSlim(engine) await loadSlim(engine)
} }
}) })
import '@/router/permission'
// 初始化主题 // 初始化主题
const themeStore = useThemeStore(pinia) const themeStore = useThemeStore(pinia)

View File

@@ -1,6 +1,13 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import DefaultLayout from '@/layouts/DefaultLayout.vue' import DefaultLayout from '@/layouts/DefaultLayout.vue'
/**
* 路由元信息说明:
* - hidden: 是否在菜单中隐藏
* - title: 菜单标题
* - icon: 菜单图标
* - requiresFullAdmin: 是否需要完整管理员权限IsMaintainer && IsLicensedDeveloper
*/
const routes = [ const routes = [
{ {
path: '/login', path: '/login',
@@ -30,7 +37,12 @@ const routes = [
{ {
path: 'user', path: 'user',
component: () => import('@/views/user/index.vue'), component: () => import('@/views/user/index.vue'),
meta: { title: '用户管理', icon: 'User' }, meta: { title: '用户管理', icon: 'User', requiresFullAdmin: true },
},
{
path: 'gacha-log',
component: () => import('@/views/gacha-log/index.vue'),
meta: { title: '祈愿记录', icon: 'TrendCharts' },
}, },
{ {
path: 'system', path: 'system',
@@ -40,12 +52,12 @@ const routes = [
{ {
path: 'menu', path: 'menu',
component: () => import('@/views/dashboard/index.vue'), component: () => import('@/views/dashboard/index.vue'),
meta: { title: '菜单管理', icon: 'Menu' }, meta: { title: '菜单管理', icon: 'Menu', requiresFullAdmin: true },
}, },
{ {
path: 'role', path: 'role',
component: () => import('@/views/dashboard/index.vue'), component: () => import('@/views/dashboard/index.vue'),
meta: { title: '角色管理', icon: 'UserFilled' }, meta: { title: '角色管理', icon: 'UserFilled', requiresFullAdmin: true },
}, },
{ {
path: 'announcement', path: 'announcement',

View File

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

View File

@@ -0,0 +1,847 @@
<template>
<div class="gacha-log-page">
<!-- 头部UID选择和视图切换 -->
<el-card class="header-card">
<div class="header-content">
<div class="left-section">
<el-select
v-model="selectedUid"
placeholder="选择UID"
class="uid-select"
@change="handleUidChange"
>
<el-option
v-for="entry in entries"
:key="entry.Uid"
:label="`${entry.Uid} (${entry.ItemCount}条记录)`"
:value="entry.Uid"
/>
</el-select>
<el-tag v-if="currentEntry?.Excluded" type="warning">已排除全球统计</el-tag>
</div>
<div class="right-section">
<el-radio-group v-model="viewMode" size="default">
<el-radio-button value="overview">总览统计</el-radio-button>
<el-radio-button value="list">详细列表</el-radio-button>
</el-radio-group>
</div>
</div>
</el-card>
<el-card v-if="loading" class="loading-card">
<div class="loading-content">
<el-icon class="is-loading" :size="40"><Loading /></el-icon>
<p>正在加载祈愿记录...</p>
</div>
</el-card>
<el-card v-else-if="!selectedUid && entries.length === 0" class="empty-card">
<el-empty description="暂无祈愿记录" />
</el-card>
<!-- 总览统计视图 -->
<template v-else-if="viewMode === 'overview' && gachaData.length > 0">
<div class="overview-grid">
<el-card
v-for="type in gachaTypes"
:key="type.queryType"
class="stat-card"
:class="{ active: selectedType === type.queryType }"
@click="selectedType = type.queryType"
>
<div class="stat-header">
<span class="type-name">{{ type.name }}</span>
<el-tag :type="type.color || undefined" size="small">{{ type.count }}</el-tag>
</div>
<div class="stat-body">
<div class="stat-row">
<span class="label">5</span>
<span class="value star5">{{ type.star5Count }}</span>
</div>
<div class="stat-row">
<span class="label">4</span>
<span class="value star4">{{ type.star4Count }}</span>
</div>
<div class="stat-row">
<span class="label">平均出货</span>
<span class="value">{{ type.avgPity?.toFixed(1) || '-' }}</span>
</div>
<div class="stat-row">
<span class="label">已垫</span>
<span class="value highlight">{{ type.currentPity }}</span>
</div>
</div>
<!-- 5星物品展示 -->
<div v-if="type.star5Items.length > 0" class="star5-preview">
<div
v-for="item in type.star5Items.slice(0, 5)"
:key="item.Id"
class="preview-item"
>
<img
:src="getItemIcon(item.ItemId)"
:alt="getItemName(item.ItemId)"
:title="getItemName(item.ItemId)"
class="item-icon star5"
/>
</div>
<span v-if="type.star5Items.length > 5" class="more-count">
+{{ type.star5Items.length - 5 }}
</span>
</div>
</el-card>
</div>
<!-- 选中类型的详细信息 -->
<el-card v-if="selectedTypeStat" class="detail-card">
<template #header>
<div class="card-header">
<span>{{ selectedTypeStat.name }} - 详细记录 ({{ selectedTypeStat.star5Count }}个5星)</span>
</div>
</template>
<div class="detail-content">
<el-table :data="selectedTypeStat.star5ItemsWithPity" stripe style="width: 100%">
<el-table-column prop="ItemId" label="物品" min-width="200">
<template #default="{ row }">
<div class="item-cell">
<img
:src="getItemIcon(row.ItemId)"
:alt="getItemName(row.ItemId)"
class="item-icon-small star5"
/>
<span class="item-name star5">{{ getItemName(row.ItemId) }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="Time" label="获取时间" min-width="180">
<template #default="{ row }">
{{ formatTime(row.Time) }}
</template>
</el-table-column>
<el-table-column label="5星出货抽数" min-width="120">
<template #default="{ row }">
<span class="pity-tag" :style="getPityStyle(row.pity)">{{ row.pity }}</span>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</template>
<!-- 列表视图 -->
<el-card v-else-if="viewMode === 'list' && gachaData.length > 0" class="list-card">
<template #header>
<div class="card-header">
<span>祈愿记录列表 ({{ filteredGachaData.length }})</span>
<el-select v-model="listFilterType" placeholder="筛选祈愿类型" clearable size="small" style="width: 150px">
<el-option label="全部" :value="0" />
<el-option v-for="type in gachaTypes" :key="type.queryType" :label="type.name" :value="type.queryType" />
</el-select>
</div>
</template>
<el-scrollbar ref="scrollbarRef" height="550px" @end-reached="handleScrollEnd">
<el-table
:data="displayedListData"
stripe
style="width: 100%"
>
<el-table-column prop="ItemId" label="物品" width="220">
<template #default="{ row }">
<div class="item-cell">
<img
:src="getItemIcon(row.ItemId)"
:alt="getItemName(row.ItemId)"
class="item-icon-small"
:class="getQualityClass(row.ItemId)"
/>
<span class="item-name" :class="getQualityClass(row.ItemId)">
{{ getItemName(row.ItemId) }}
</span>
</div>
</template>
</el-table-column>
<el-table-column prop="GachaType" label="祈愿类型" width="140">
<template #default="{ row }">
<el-tag size="small">{{ getGachaTypeName(row.GachaType) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="Time" label="获取时间" width="180">
<template #default="{ row }">
{{ formatTime(row.Time) }}
</template>
</el-table-column>
<el-table-column prop="Id" label="记录ID" min-width="180">
<template #default="{ row }">
<span class="record-id">{{ row.Id }}</span>
</template>
</el-table-column>
</el-table>
<div v-if="listLoading" class="list-loading">
<el-icon class="is-loading"><Loading /></el-icon>
<span>加载中...</span>
</div>
<div v-else-if="noMoreListData && filteredGachaData.length > 0" class="list-end">
已加载全部记录
</div>
</el-scrollbar>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { Loading } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { ScrollbarDirection } from 'element-plus'
import {
getGachaLogEntriesApi,
getGachaLogDataApi,
type GachaLogEntry,
type GachaLogItem,
type EndIds,
getGachaTypeName,
gachaTypeToQueryType,
GACHA_TYPE_NAMES,
} from '@/api/gachaLog'
// 元数据类型
interface GachaItemMeta {
id: number
name: string
icon: string
quality: number
type: 'avatar' | 'weapon' | 'material'
}
// 祈愿类型统计
interface GachaTypeStat {
queryType: number
name: string
count: number
star5Count: number
star4Count: number
star3Count: number
star5Items: GachaLogItem[]
star4Items: GachaLogItem[]
avgPity: number
currentPity: number
color: '' | 'success' | 'warning' | 'info' | 'danger'
}
// 状态
const loading = ref(false)
const entries = ref<GachaLogEntry[]>([])
const selectedUid = ref<string>('')
const gachaData = ref<GachaLogItem[]>([])
const viewMode = ref<'overview' | 'list'>('overview')
const selectedType = ref<number>(301)
const listFilterType = ref<number>(0)
const metadata = ref<Record<string, GachaItemMeta>>({})
// 列表无限滚动相关
const LIST_PAGE_SIZE = 50 // 每次加载的条数
const listDisplayCount = ref(LIST_PAGE_SIZE)
const listLoading = ref(false)
const scrollbarRef = ref()
const savedScrollTop = ref(0) // 保存加载前的滚动位置
// 当前选中的条目
const currentEntry = computed(() => {
return entries.value.find(e => e.Uid === selectedUid.value)
})
// 获取元数据
async function loadMetadata() {
try {
const response = await fetch('/metadata/gacha_items.json') // 这个是精简之后的元数据
metadata.value = await response.json()
} catch (error) {
console.error('加载元数据失败:', error)
}
}
// 获取物品名称
function getItemName(itemId: number): string {
const item = metadata.value[String(itemId)]
return item?.name || `未知物品(${itemId})`
}
// 静态资源基础URL
const STATIC_BASE_URL = 'https://htserver.wdg.cloudns.ch/static/raw'
// 获取物品图标
function getItemIcon(itemId: number): string {
const item = metadata.value[String(itemId)]
if (!item || !item.icon) return '/HT_logo.png' // 没图标,占位
const iconName = item.icon
// 角色: AvatarIcon/{Icon}.png
// 武器: EquipIcon/{Icon}.png
const category = item.type === 'avatar' ? 'AvatarIcon' : 'EquipIcon'
return `${STATIC_BASE_URL}/${category}/${iconName}.png`
}
// 获取品质样式类
function getQualityClass(itemId: number): string {
const item = metadata.value[String(itemId)]
const quality = item?.quality || 3
if (quality >= 5) return 'star5'
if (quality === 4) return 'star4'
return 'star3'
}
// 根据抽数计算渐变颜色5星列表的标签颜色
function getPityStyle(pity: number): Record<string, string> {
let bgColor = ''
let textColor = '#fff'
if (pity <= 10) {
// 金色:非常欧
bgColor = '#ffd700'
textColor = '#333'
} else if (pity <= 30) {
// 绿色:运气很好
bgColor = '#22c55e'
} else if (pity <= 60) {
// 蓝色:正常
bgColor = '#3b82f6'
} else if (pity <= 75) {
// 橙色:快到保底
bgColor = '#f59e0b'
} else {
// 红色:保底区
bgColor = '#ef4444'
}
return {
backgroundColor: bgColor,
color: textColor,
}
}
// 格式化时间
function formatTime(time: string): string {
if (!time) return '-'
try {
const date = new Date(time)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
} catch {
return time
}
}
// 5星物品的出货抽数记录
interface Star5ItemWithPity extends GachaLogItem {
pity: number // 出货抽数
}
// 祈愿类型统计(扩展)
interface GachaTypeStatExt extends GachaTypeStat {
star5ItemsWithPity: Star5ItemWithPity[]
}
// 祈愿类型统计计算
const gachaTypes = computed<GachaTypeStatExt[]>(() => {
const typeStats: Record<number, GachaTypeStatExt> = {}
// 初始化所有类型
Object.keys(GACHA_TYPE_NAMES).forEach(type => {
const queryType = Number(type)
typeStats[queryType] = {
queryType,
name: GACHA_TYPE_NAMES[queryType] || '未知祈愿',
count: 0,
star5Count: 0,
star4Count: 0,
star3Count: 0,
star5Items: [],
star4Items: [],
star5ItemsWithPity: [],
avgPity: 0,
currentPity: 0,
color: queryType === 100 ? 'info' : queryType === 200 ? '' : queryType === 301 ? 'danger' : queryType === 302 ? 'warning' : 'success',
}
})
// 按时间正序排序(从旧到新)
const sortedData = [...gachaData.value].sort((a, b) => new Date(a.Time).getTime() - new Date(b.Time).getTime())
// 每种类型的当前抽数计数器
const pityCounters: Record<number, number> = {}
Object.keys(GACHA_TYPE_NAMES).forEach(type => {
pityCounters[Number(type)] = 0
})
// 统计数据并计算抽数
sortedData.forEach(item => {
const queryType = gachaTypeToQueryType(item.GachaType)
const stat = typeStats[queryType]
if (!stat) return
stat.count++
if (pityCounters[queryType] === undefined) {
pityCounters[queryType] = 0
}
pityCounters[queryType]++
const itemMeta = metadata.value[String(item.ItemId)]
const quality = itemMeta?.quality || 3
if (quality >= 5) {
stat.star5Count++
// 记录当前抽数
const itemWithPity: Star5ItemWithPity = {
...item,
pity: pityCounters[queryType] ?? 0
}
stat.star5ItemsWithPity.push(itemWithPity)
stat.star5Items.push(item)
// 重置计数器
pityCounters[queryType] = 0
} else if (quality === 4) {
stat.star4Count++
stat.star4Items.push(item)
} else {
stat.star3Count++
}
})
// 计算平均出货抽数和当前垫抽数
Object.values(typeStats).forEach(stat => {
if (!stat) return
// 当前垫抽数就是计数器的值
const qType = stat.queryType
stat.currentPity = (qType && pityCounters[qType]) || 0
if (stat.star5ItemsWithPity.length > 0) {
// 平均出货抽数
const totalPity = stat.star5ItemsWithPity.reduce((sum, item) => sum + item.pity, 0)
stat.avgPity = totalPity / stat.star5ItemsWithPity.length
// 按时间倒序排列5星物品
stat.star5ItemsWithPity.sort((a, b) => new Date(b.Time).getTime() - new Date(a.Time).getTime())
stat.star5Items = stat.star5ItemsWithPity.map(item => {
const { pity: _pity, ...rest } = item
return rest
})
}
})
// 返回有数据的类型,按顺序排列
return [301, 302, 200, 500, 100]
.map(type => typeStats[type])
.filter((stat): stat is GachaTypeStatExt => stat !== undefined && stat.count > 0)
})
// 当前选中的类型统计
const selectedTypeStat = computed<GachaTypeStatExt | undefined>(() => {
return gachaTypes.value.find(t => t.queryType === selectedType.value)
})
// 列表过滤后的数据
const filteredGachaData = computed(() => {
let data = [...gachaData.value]
if (listFilterType.value > 0) {
data = data.filter(item => gachaTypeToQueryType(item.GachaType) === listFilterType.value)
}
// 按时间倒序排列
data.sort((a, b) => new Date(b.Time).getTime() - new Date(a.Time).getTime())
return data
})
// 是否还有更多数据
const noMoreListData = computed(() => {
return listDisplayCount.value >= filteredGachaData.value.length
})
// 当前显示的列表数据
const displayedListData = computed(() => {
return filteredGachaData.value.slice(0, listDisplayCount.value)
})
// 加载更多列表数据,为了防止滚动条持续在底部导致重复加载,需要恢复滚动条位置
function loadMoreListData() {
if (listLoading.value || noMoreListData.value) return
// 记录当前滚动位置
if (scrollbarRef.value?.wrapRef) {
savedScrollTop.value = scrollbarRef.value.wrapRef.scrollTop
}
listLoading.value = true
setTimeout(() => {
listDisplayCount.value += LIST_PAGE_SIZE
// 等待DOM更新后恢复滚动位置
nextTick(() => {
if (scrollbarRef.value?.wrapRef) {
scrollbarRef.value.wrapRef.scrollTop = savedScrollTop.value
}
listLoading.value = false
})
}, 50)
}
// 滚动到底部
function handleScrollEnd(direction: ScrollbarDirection) {
if (direction === 'bottom' && !listLoading.value && !noMoreListData.value) {
loadMoreListData()
}
}
// 重置列表显示数量
function resetListDisplay() {
listDisplayCount.value = LIST_PAGE_SIZE
}
// 监听筛选条件变化,重置列表显示
watch(listFilterType, () => {
resetListDisplay()
})
// 加载祈愿记录列表
async function loadEntries() {
loading.value = true
try {
entries.value = await getGachaLogEntriesApi()
const firstEntry = entries.value[0]
if (firstEntry) {
selectedUid.value = firstEntry.Uid
await loadGachaData(selectedUid.value)
}
} catch (error) {
ElMessage.error('获取祈愿记录列表失败')
console.error(error)
} finally {
loading.value = false
}
}
// 加载祈愿数据
async function loadGachaData(uid: string) {
loading.value = true
gachaData.value = []
try {
// 获取所有记录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>