Initial commit: Add maimaiDX API web application with AimeDB scanning and logging features

This commit is contained in:
kejiz
2025-09-18 10:19:08 +08:00
commit 4e83f159f0
84 changed files with 14012 additions and 0 deletions

105
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,105 @@
<template>
<el-container class="main-layout">
<el-aside :width="isCollapsed ? '64px' : '200px'" class="main-aside">
<el-menu
default-active="/profile"
class="el-menu-vertical-demo"
:collapse="isCollapsed"
router
>
<div class="header-container">
<span v-if="!isCollapsed">SDGB API TOOLS</span>
</div>
<el-menu-item index="/">
<el-icon><HomeFilled /></el-icon>
<template #title>首页</template>
</el-menu-item>
<el-menu-item index="/music">
<el-icon><Headset /></el-icon>
<template #title>乐曲列表</template>
</el-menu-item>
<el-menu-item index="/profile">
<el-icon><User /></el-icon>
<template #title>用户中心</template>
</el-menu-item>
<el-menu-item index="/aime-db">
<el-icon><CreditCard /></el-icon>
<template #title>Aime卡扫描</template>
</el-menu-item>
<el-menu-item index="/auth-lite-delivery">
<el-icon><Link /></el-icon>
<template #title>更新链接</template>
</el-menu-item>
<el-menu-item index="/logs">
<el-icon><Document /></el-icon>
<template #title>系统日志</template>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header class="main-header">
<el-icon @click="isCollapsed = !isCollapsed" class="collapse-icon">
<component :is="isCollapsed ? 'Expand' : 'Fold'" />
</el-icon>
<span>SDGB 1.51</span>
</el-header>
<el-main>
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { HomeFilled, Headset, User, Fold, Expand, Link, CreditCard, Document } from '@element-plus/icons-vue'
const isCollapsed = ref(false)
</script>
<style>
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.main-layout {
height: 100vh;
}
.main-aside {
transition: width 0.3s;
background-color: #fff;
border-right: 1px solid #e6e6e6;
}
.el-menu {
border-right: none;
}
.header-container {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: bold;
}
.main-header {
display: flex;
align-items: center;
background-color: #fff;
border-bottom: 1px solid #e6e6e6;
}
.collapse-icon {
font-size: 24px;
cursor: pointer;
margin-right: 20px;
}
.el-main {
background-color: #f4f7fa;
}
</style>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

View File

@@ -0,0 +1,10 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
padding: 20px;
}

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
defineProps<{
msg: string
}>()
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve successfully created a project with
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>. What's next?
</h3>
</div>
</template>
<style scoped>
h1 {
font-weight: 500;
font-size: 2.6rem;
position: relative;
top: -10px;
}
h3 {
font-size: 1.2rem;
}
.greetings h1,
.greetings h3 {
text-align: center;
}
@media (min-width: 1024px) {
.greetings h1,
.greetings h3 {
text-align: left;
}
}
</style>

View File

@@ -0,0 +1,136 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import logger, { getLogBuffer } from '@/utils/logger'
const logs = ref<any[]>(getLogBuffer())
const filterLevel = ref('ALL')
const filterText = ref('')
// 过滤后的日志
const filteredLogs = computed(() => {
let filtered = logs.value
// 按级别过滤
if (filterLevel.value !== 'ALL') {
filtered = filtered.filter(log => log.level === filterLevel.value)
}
// 按文本过滤
if (filterText.value) {
const searchText = filterText.value.toLowerCase()
filtered = filtered.filter(log =>
log.message.toLowerCase().includes(searchText) ||
(log.data && JSON.stringify(log.data).toLowerCase().includes(searchText))
)
}
// 按时间倒序排列
return filtered.slice().reverse()
})
// 刷新日志
function refreshLogs() {
logs.value = getLogBuffer()
}
// 清空日志
function clearLogs() {
logger.clearLogBuffer()
logs.value = []
}
// 获取日志级别对应的标签类型
function getTagType(level: string) {
switch (level) {
case 'ERROR': return 'danger'
case 'WARN': return 'warning'
case 'INFO': return 'success'
case 'DEBUG': return 'info'
default: return 'info'
}
}
// 格式化时间戳
function formatTimestamp(timestamp: string) {
return new Date(timestamp).toLocaleString()
}
</script>
<template>
<div class="log-viewer">
<div class="toolbar">
<el-input
v-model="filterText"
placeholder="搜索日志..."
clearable
style="width: 200px; margin-right: 10px;"
/>
<el-select
v-model="filterLevel"
placeholder="选择级别"
style="width: 120px; margin-right: 10px;"
>
<el-option label="全部" value="ALL" />
<el-option label="DEBUG" value="DEBUG" />
<el-option label="INFO" value="INFO" />
<el-option label="WARN" value="WARN" />
<el-option label="ERROR" value="ERROR" />
</el-select>
<el-button @click="refreshLogs">刷新</el-button>
<el-button @click="clearLogs">清空</el-button>
</div>
<el-table :data="filteredLogs" stripe style="width: 100%" height="400">
<el-table-column prop="timestamp" label="时间" width="180">
<template #default="scope">
{{ formatTimestamp(scope.row.timestamp) }}
</template>
</el-table-column>
<el-table-column prop="level" label="级别" width="100">
<template #default="scope">
<el-tag :type="getTagType(scope.row.level)">
{{ scope.row.level }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="消息" />
<el-table-column label="数据" width="100">
<template #default="scope">
<el-popover
v-if="scope.row.data"
placement="left"
:width="300"
trigger="hover"
>
<template #reference>
<el-button size="small" type="primary" link>查看</el-button>
</template>
<pre>{{ JSON.stringify(scope.row.data, null, 2) }}</pre>
</el-popover>
</template>
</el-table-column>
</el-table>
</div>
</template>
<style scoped>
.log-viewer {
width: 100%;
}
.toolbar {
margin-bottom: 15px;
display: flex;
align-items: center;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
background-color: #f4f4f5;
padding: 10px;
border-radius: 4px;
max-height: 300px;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,94 @@
<script setup lang="ts">
import WelcomeItem from './WelcomeItem.vue'
import DocumentationIcon from './icons/IconDocumentation.vue'
import ToolingIcon from './icons/IconTooling.vue'
import EcosystemIcon from './icons/IconEcosystem.vue'
import CommunityIcon from './icons/IconCommunity.vue'
import SupportIcon from './icons/IconSupport.vue'
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
</script>
<template>
<WelcomeItem>
<template #icon>
<DocumentationIcon />
</template>
<template #heading>Documentation</template>
Vues
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
provides you with all information you need to get started.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<ToolingIcon />
</template>
<template #heading>Tooling</template>
This project is served and bundled with
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
recommended IDE setup is
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
+
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener">Vue - Official</a>. If
you need to test your components and web pages, check out
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
and
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
/
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
<br />
More instructions are available in
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
>.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<EcosystemIcon />
</template>
<template #heading>Ecosystem</template>
Get official tools and libraries for your project:
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
you need more resources, we suggest paying
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
a visit.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<CommunityIcon />
</template>
<template #heading>Community</template>
Got stuck? Ask your question on
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
(our official Discord server), or
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
>StackOverflow</a
>. You should also follow the official
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
Bluesky account or the
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
X account for latest news in the Vue world.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<SupportIcon />
</template>
<template #heading>Support Vue</template>
As an independent project, Vue relies on community backing for its sustainability. You can help
us by
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
</WelcomeItem>
</template>

View File

@@ -0,0 +1,87 @@
<template>
<div class="item">
<i>
<slot name="icon"></slot>
</i>
<div class="details">
<h3>
<slot name="heading"></slot>
</h3>
<slot></slot>
</div>
</div>
</template>
<style scoped>
.item {
margin-top: 2rem;
display: flex;
position: relative;
}
.details {
flex: 1;
margin-left: 1rem;
}
i {
display: flex;
place-items: center;
place-content: center;
width: 32px;
height: 32px;
color: var(--color-text);
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
@media (min-width: 1024px) {
.item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
i {
top: calc(50% - 25px);
left: -26px;
position: absolute;
border: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 8px;
width: 50px;
height: 50px;
}
.item:before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:first-of-type:before {
display: none;
}
.item:last-of-type:after {
display: none;
}
}
</style>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>

View File

@@ -0,0 +1,19 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>

20
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,20 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
app.mount('#app')

View File

@@ -0,0 +1,45 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import MusicView from '../views/MusicView.vue'
import ProfileView from '../views/ProfileView.vue'
import AuthLiteDeliveryView from '../views/AuthLiteDeliveryView.vue'
import AimeDBView from '../views/AimeDBView.vue'
import LogsView from '../views/LogsView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView
},
{
path: '/music',
name: 'music',
component: MusicView
},
{
path: '/profile',
name: 'profile',
component: ProfileView
},
{
path: '/aime-db',
name: 'aime-db',
component: AimeDBView
},
{
path: '/auth-lite-delivery',
name: 'auth-lite-delivery',
component: AuthLiteDeliveryView
},
{
path: '/logs',
name: 'logs',
component: LogsView
}
]
})
export default router

View File

@@ -0,0 +1,143 @@
// 日志级别枚举
const LogLevel = {
DEBUG: 0,
INFO: 1,
WARN: 2,
ERROR: 3
};
// 当前日志级别
let currentLogLevel = LogLevel.INFO;
// 日志缓冲区
let logBuffer = [];
const MAX_BUFFER_SIZE = 1000;
// 设置日志级别
export function setLogLevel(level) {
currentLogLevel = level;
}
// 获取当前时间戳
function getTimestamp() {
return new Date().toISOString();
}
// 写入日志到缓冲区
function writeToBuffer(level, message, data = null) {
const logEntry = {
timestamp: getTimestamp(),
level: Object.keys(LogLevel).find(key => LogLevel[key] === level),
message: message,
data: data
};
logBuffer.push(logEntry);
// 保持缓冲区大小在限制内
if (logBuffer.length > MAX_BUFFER_SIZE) {
logBuffer = logBuffer.slice(-MAX_BUFFER_SIZE);
}
// 在控制台输出日志
switch (level) {
case LogLevel.DEBUG:
console.debug(`[DEBUG] ${message}`, data);
break;
case LogLevel.INFO:
console.info(`[INFO] ${message}`, data);
break;
case LogLevel.WARN:
console.warn(`[WARN] ${message}`, data);
break;
case LogLevel.ERROR:
console.error(`[ERROR] ${message}`, data);
break;
}
}
// Debug级别日志
export function logDebug(message, data = null) {
if (currentLogLevel <= LogLevel.DEBUG) {
writeToBuffer(LogLevel.DEBUG, message, data);
}
}
// Info级别日志
export function logInfo(message, data = null) {
if (currentLogLevel <= LogLevel.INFO) {
writeToBuffer(LogLevel.INFO, message, data);
}
}
// Warn级别日志
export function logWarn(message, data = null) {
if (currentLogLevel <= LogLevel.WARN) {
writeToBuffer(LogLevel.WARN, message, data);
}
}
// Error级别日志
export function logError(message, data = null) {
if (currentLogLevel <= LogLevel.ERROR) {
writeToBuffer(LogLevel.ERROR, message, data);
}
}
// 获取日志缓冲区
export function getLogBuffer() {
return [...logBuffer]; // 返回副本
}
// 清空日志缓冲区
export function clearLogBuffer() {
logBuffer = [];
}
// 将日志保存到文件
export function saveLogsToFile() {
const logs = getLogBuffer();
const logText = logs.map(entry =>
`${entry.timestamp} [${entry.level}] ${entry.message} ${entry.data ? JSON.stringify(entry.data) : ''}`
).join('\n');
const blob = new Blob([logText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `frontend-logs-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// API请求日志
export function logApiRequest(method, url, data = null) {
logInfo(`API Request: ${method} ${url}`, data);
}
// API响应日志
export function logApiResponse(status, data = null) {
if (status >= 400) {
logError(`API Response: ${status}`, data);
} else {
logInfo(`API Response: ${status}`, data);
}
}
export default {
LogLevel,
setLogLevel,
logDebug,
logInfo,
logWarn,
logError,
getLogBuffer,
clearLogBuffer,
saveLogsToFile,
logApiRequest,
logApiResponse
};

View File

@@ -0,0 +1,15 @@
<template>
<div class="about">
<h1>This is an about page</h1>
</div>
</template>
<style>
@media (min-width: 1024px) {
.about {
min-height: 100vh;
display: flex;
align-items: center;
}
}
</style>

View File

@@ -0,0 +1,110 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import axios from 'axios'
import { ElMessage } from 'element-plus'
const qrContent = ref('')
const isLoading = ref(false)
const scanResult = ref<any>(null)
const apiBaseUrl = computed(() => {
return `http://${window.location.hostname}:8000/api`
})
async function scanQRCode() {
if (!qrContent.value) {
ElMessage.error('请输入二维码内容')
return
}
isLoading.value = true
scanResult.value = null
try {
const { data } = await axios.post(`${apiBaseUrl.value}/aime_scan`, { qrContent: qrContent.value })
scanResult.value = data
if (data.errorID) {
ElMessage.error(`扫描失败: 错误代码 ${data.errorID}`)
} else {
ElMessage.success('二维码扫描成功')
}
} catch (e: any) {
const message = e.response?.data?.detail || e.message
ElMessage.error(`扫描失败: ${message}`)
console.error(e)
} finally {
isLoading.value = false
}
}
function clearResult() {
scanResult.value = null
}
</script>
<template>
<div class="aime-db-view">
<el-card header="AimeDB 二维码扫描" style="width: 100%;">
<el-form :inline="true" @submit.prevent="scanQRCode">
<el-form-item label="二维码内容">
<el-input
v-model="qrContent"
placeholder="请输入Aime卡二维码内容"
clearable
style="width: 300px;"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
@click="scanQRCode"
:loading="isLoading"
>
扫描
</el-button>
<el-button @click="clearResult">清空结果</el-button>
</el-form-item>
</el-form>
<el-card v-if="scanResult" header="扫描结果" style="margin-top: 20px;">
<div v-if="scanResult.errorID">
<el-alert
title="扫描失败"
:description="`错误代码: ${scanResult.errorID}`"
type="error"
show-icon
/>
</div>
<div v-else>
<el-descriptions :column="1" border>
<el-descriptions-item label="用户ID">{{ scanResult.userID }}</el-descriptions-item>
<el-descriptions-item label="Aime卡ID">{{ scanResult.aimeID }}</el-descriptions-item>
<el-descriptions-item label="访问代码">{{ scanResult.accessCode }}</el-descriptions-item>
<el-descriptions-item label="芯片ID">{{ scanResult.chipID }}</el-descriptions-item>
<el-descriptions-item label="返回代码">{{ scanResult.returnCode }}</el-descriptions-item>
</el-descriptions>
</div>
<el-divider />
<h3>原始数据</h3>
<pre>{{ JSON.stringify(scanResult, null, 2) }}</pre>
</el-card>
</el-card>
</div>
</template>
<style scoped>
.aime-db-view {
padding: 20px;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
background-color: #f4f4f5;
padding: 10px;
border-radius: 4px;
max-height: 300px;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,105 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import axios from 'axios'
import { ElMessage } from 'element-plus'
const authLiteDeliveryData = ref<any>(null)
const isLoading = ref(false)
const parsedIniData = ref<any>(null) // New ref for parsed INI data
const apiBaseUrl = computed(() => {
return `http://${window.location.hostname}:8000/api`
})
async function getAuthLiteDelivery() {
isLoading.value = true
parsedIniData.value = null; // Clear previous parsed data
try {
const { data } = await axios.get(`${apiBaseUrl.value}/get_auth_lite_delivery`)
if (data.error) throw new Error(data.error)
authLiteDeliveryData.value = data
ElMessage.success('已获取 AuthLiteDelivery 信息')
} catch (e: any) {
const message = e.response?.data?.detail || e.message
ElMessage.error(`获取失败: ${message}`)
console.error(e)
} finally {
isLoading.value = false
}
}
async function parseIni(url: string) {
isLoading.value = true
parsedIniData.value = null; // Clear previous parsed data
try {
const { data } = await axios.post(`${apiBaseUrl.value}/parse_update_ini`, { url })
if (data.error) throw new Error(data.error)
parsedIniData.value = data.parsedData
ElMessage.success('INI 文件解析成功')
} catch (e: any) {
const message = e.response?.data?.detail || e.message
ElMessage.error(`INI 文件解析失败: ${message}`)
console.error(e)
} finally {
isLoading.value = false
}
}
</script>
<template>
<div class="auth-lite-delivery-view">
<el-card header="AuthLiteDelivery 信息获取" style="width: 100%;">
<el-form :inline="true" @submit.prevent="getAuthLiteDelivery">
<el-form-item>
<el-button type="primary" @click="getAuthLiteDelivery" :loading="isLoading">获取 AuthLiteDelivery</el-button>
</el-form-item>
</el-form>
<el-card v-if="authLiteDeliveryData" header="AuthLiteDelivery 结果" style="margin-top: 20px;">
<p>更新链接列表:</p>
<el-space wrap>
<div v-for="(link, index) in authLiteDeliveryData.updateLinks" :key="index" class="link-item">
<el-link :href="link" target="_blank">{{ link }}</el-link>
<el-button type="text" @click="parseIni(link)" :loading="isLoading">解析</el-button>
</div>
</el-space>
</el-card>
<el-card v-if="parsedIniData" header="解析后的 INI 内容" style="margin-top: 20px;">
<el-descriptions :column="1" border>
<el-descriptions-item label="游戏描述">{{ parsedIniData.game_desc }}</el-descriptions-item>
<el-descriptions-item label="发布时间">{{ parsedIniData.release_time }}</el-descriptions-item>
<el-descriptions-item label="主更新包">{{ parsedIniData.main_file }}</el-descriptions-item>
<el-descriptions-item label="可选更新包">
<ul v-if="parsedIniData.optional_files && parsedIniData.optional_files.length">
<li v-for="(file, idx) in parsedIniData.optional_files" :key="idx">{{ file }}</li>
</ul>
<span v-else></span>
</el-descriptions-item>
</el-descriptions>
<el-divider />
<h3>原始解析数据 (完整)</h3>
<pre>{{ JSON.stringify(parsedIniData, null, 2) }}</pre>
</el-card>
</el-card>
</div>
</template>
<style scoped>
.auth-lite-delivery-view {
padding: 20px;
}
.link-item {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 5px;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
background-color: #f4f4f5;
padding: 10px;
border-radius: 4px;
}
</style>

View File

@@ -0,0 +1,18 @@
<template>
<div class="home">
<h1>舞萌DX 2025(SDGB 1.51)API网页工具</h1>
<p>本工具仅供学习交流使用请勿用于非法用途</p>
<p>目前已经实现的功能</p>
<p>用户所有信息查看需要获取二维码</p>
<p>用户信息预览免获取二维码</p>
<p>领取登录奖励等等</p>
<img src='https://i.mji.rip/2025/09/18/5bdc72c934da195de76f2df4677a26d3.png'></img>
</div>
</template>
<style scoped>
.home {
padding: 20px;
text-align: center;
}
</style>

View File

@@ -0,0 +1,182 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import axios from 'axios'
import { ElMessage } from 'element-plus'
import LogViewer from '@/components/LogViewer.vue'
import logger, { getLogBuffer, saveLogsToFile } from '@/utils/logger'
const backendLogs = ref<any[]>([])
const frontendLogs = ref<any[]>(getLogBuffer())
const isLoading = ref(false)
const autoRefresh = ref(false)
const refreshInterval = ref<NodeJS.Timeout | null>(null)
const apiBaseUrl = `http://${window.location.hostname}:8000/api`
// 获取后端日志
async function fetchBackendLogs() {
isLoading.value = true
try {
logger.logInfo('Fetching backend logs')
const { data } = await axios.get(`${apiBaseUrl}/logs`)
if (data.error) {
throw new Error(data.error)
}
backendLogs.value = data.logs || []
ElMessage.success('后端日志获取成功')
} catch (e: any) {
logger.logError('Failed to fetch backend logs', e)
ElMessage.error('获取后端日志失败: ' + (e.response?.data?.detail || e.message))
// 使用模拟数据
backendLogs.value = [
{ timestamp: new Date().toISOString(), level: 'INFO', message: 'Backend started successfully' },
{ timestamp: new Date().toISOString(), level: 'INFO', message: 'Aime scan request received' },
{ timestamp: new Date().toISOString(), level: 'WARN', message: 'QR content validation failed' }
]
} finally {
isLoading.value = false
}
}
// 刷新所有日志
function refreshLogs() {
frontendLogs.value = getLogBuffer()
fetchBackendLogs()
}
// 开始自动刷新
function startAutoRefresh() {
if (refreshInterval.value) {
clearInterval(refreshInterval.value)
}
refreshInterval.value = setInterval(() => {
refreshLogs()
}, 5000) // 每5秒刷新一次
}
// 停止自动刷新
function stopAutoRefresh() {
if (refreshInterval.value) {
clearInterval(refreshInterval.value)
refreshInterval.value = null
}
}
// 切换自动刷新
function toggleAutoRefresh() {
if (autoRefresh.value) {
startAutoRefresh()
} else {
stopAutoRefresh()
}
}
// 保存日志到文件
function saveLogs() {
saveLogsToFile()
ElMessage.success('日志已保存到文件')
}
// 组件挂载时的操作
onMounted(() => {
refreshLogs()
})
// 组件卸载时的操作
onUnmounted(() => {
stopAutoRefresh()
})
</script>
<template>
<div class="logs-view">
<el-card header="系统日志" style="width: 100%;">
<div class="toolbar">
<el-button type="primary" @click="refreshLogs" :loading="isLoading">刷新日志</el-button>
<el-checkbox v-model="autoRefresh" @change="toggleAutoRefresh">自动刷新</el-checkbox>
<el-button @click="clearFrontendLogs">清空前端日志</el-button>
<el-button @click="saveLogs">保存日志到文件</el-button>
</div>
<el-tabs type="border-card">
<el-tab-pane label="前端日志">
<el-table :data="frontendLogs" stripe style="width: 100%" height="500">
<el-table-column prop="timestamp" label="时间" width="180">
<template #default="scope">
{{ new Date(scope.row.timestamp).toLocaleString() }}
</template>
</el-table-column>
<el-table-column prop="level" label="级别" width="80">
<template #default="scope">
<el-tag :type="scope.row.level === 'ERROR' ? 'danger' :
scope.row.level === 'WARN' ? 'warning' :
scope.row.level === 'INFO' ? 'success' : 'info'">
{{ scope.row.level }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="消息" />
<el-table-column prop="data" label="数据" width="200">
<template #default="scope">
<el-popover
v-if="scope.row.data"
placement="left"
:width="300"
trigger="hover"
>
<template #reference>
<el-button size="small">查看数据</el-button>
</template>
<pre>{{ JSON.stringify(scope.row.data, null, 2) }}</pre>
</el-popover>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="后端日志">
<el-table :data="backendLogs" stripe style="width: 100%" height="500">
<el-table-column prop="timestamp" label="时间" width="180">
<template #default="scope">
{{ new Date(scope.row.timestamp).toLocaleString() }}
</template>
</el-table-column>
<el-table-column prop="level" label="级别" width="80">
<template #default="scope">
<el-tag :type="scope.row.level === 'ERROR' ? 'danger' :
scope.row.level === 'WARN' ? 'warning' :
scope.row.level === 'INFO' ? 'success' : 'info'">
{{ scope.row.level }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="消息" />
</el-table>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</template>
<style scoped>
.logs-view {
padding: 20px;
}
.toolbar {
margin-bottom: 20px;
display: flex;
gap: 10px;
align-items: center;
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
background-color: #f4f4f5;
padding: 10px;
border-radius: 4px;
max-height: 300px;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import axios from 'axios'
import { ElMessage } from 'element-plus'
const musicList = ref<any[]>([])
const isLoading = ref(true)
const searchTerm = ref('')
const apiBaseUrl = computed(() => `http://${window.location.hostname}:8000/api`)
const filteredMusic = computed(() => {
if (!searchTerm.value) {
return musicList.value
}
return musicList.value.filter(music =>
music.name.toLowerCase().includes(searchTerm.value.toLowerCase())
)
})
onMounted(async () => {
try {
const { data } = await axios.get(`${apiBaseUrl.value}/music`)
if (!Array.isArray(data)) {
throw new Error('Invalid data format received from API.')
}
musicList.value = data
if (data.length === 0) {
ElMessage.warning('API returned empty music list.')
}
} catch (e: any) {
ElMessage.error(`无法加载乐曲列表: ${e.message}`)
console.error(e)
} finally {
isLoading.value = false
}
})
</script>
<template>
<div class="music-view">
<el-card header="乐曲列表">
<el-input
v-model="searchTerm"
placeholder="搜索歌曲名"
clearable
style="margin-bottom: 20px;"
/>
<el-table :data="filteredMusic" stripe v-loading="isLoading" height="75vh" empty-text="没有数据">
<el-table-column prop="id" label="ID" width="100" sortable />
<el-table-column prop="name" label="标题" sortable />
<el-table-column prop="version" label="版本" width="120" sortable />
</el-table>
</el-card>
</div>
</template>
<style scoped>
.music-view {
padding: 20px;
}
</style>

View File

@@ -0,0 +1,397 @@
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import axios from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus'
import logger from '@/utils/logger'
// --- API Configuration ---
const apiBaseUrl = computed(() => {
return `http://${window.location.hostname}:8000/api`
})
// --- State Management ---
const inputUserId = ref('')
const isLoading = ref(false)
const userData = ref<any>(null)
const previewData = ref<any>(null)
const musicResults = ref<any[] | null>(null)
const anyData = ref<any>(null)
// AimeDB Scan State
const qrContent = ref('')
const isScanning = ref(false)
// Action-specific state
const fishImportToken = ref('')
const itemKind = ref('')
const itemIds = ref('')
const ticketType = ref('')
const apiName = ref('')
const scoreForm = reactive({
musicId: '',
levelId: '',
achievement: '',
dxScore: ''
})
// --- Generic Action Wrapper ---
async function performAction(action: () => Promise<void>, confirmOptions?: { title: string; message: string }) {
if (!inputUserId.value) {
ElMessage.error('请先输入用户ID');
logger.logWarn('Perform action failed: User ID is empty');
return;
}
if (confirmOptions) {
try {
await ElMessageBox.confirm(confirmOptions.message, confirmOptions.title, {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
} catch {
ElMessage.info('操作已取消');
logger.logInfo('Action cancelled by user');
return;
}
}
isLoading.value = true;
try {
await action();
} catch (e: any) {
const message = e.response?.data?.detail || e.message;
logger.logError('Action failed', { error: message, stack: e.stack });
ElMessage.error(message);
console.error(e);
} finally {
isLoading.value = false;
}
}
// --- AimeDB Scan Function ---
async function scanAimeQR() {
if (!qrContent.value) {
ElMessage.error('请输入二维码内容')
logger.logWarn('Aime scan failed: QR content is empty')
return
}
logger.logInfo('Starting Aime QR scan', { qrContent: qrContent.value.substring(0, 20) + '...' })
isScanning.value = true
try {
const requestData = { qrContent: qrContent.value }
logger.logApiRequest('POST', '/api/aime_scan', requestData)
const { data } = await axios.post(`${apiBaseUrl.value}/aime_scan`, requestData)
logger.logApiResponse(200, data)
if (data.errorID) {
logger.logWarn('Aime scan failed', { errorID: data.errorID })
ElMessage.error(`扫描失败: 错误代码 ${data.errorID}`)
} else if (data.userID) {
inputUserId.value = data.userID
logger.logInfo('Aime scan successful', { userID: data.userID })
ElMessage.success('二维码扫描成功用户ID已填充')
} else {
logger.logWarn('Aime scan returned no userID')
ElMessage.error('扫描成功但未返回有效用户ID')
}
} catch (e: any) {
logger.logError('Aime scan failed with exception', e)
const message = e.response?.data?.detail || e.message
ElMessage.error(`扫描失败: ${message}`)
console.error(e)
} finally {
isScanning.value = false
}
}
// --- API Functions ---
const fetchFullProfile = () => {
if (!inputUserId.value) {
ElMessage.error('请输入用户ID');
logger.logWarn('Fetch full profile failed: User ID is empty');
return;
}
logger.logInfo('Fetching full profile', { userId: inputUserId.value });
performAction(async () => {
const url = `${apiBaseUrl.value}/user/all?userId=${inputUserId.value}`;
logger.logApiRequest('GET', url);
const { data } = await axios.get(url);
logger.logApiResponse(200, data);
if (data.error) throw new Error(data.error);
userData.value = data.upsertUserAll.userData[0];
previewData.value = null;
musicResults.value = null;
ElMessage.success('已加载完整用户信息');
logger.logInfo('Successfully loaded full profile', { userId: inputUserId.value });
});
};
const fetchPreview = () => {
if (!inputUserId.value) {
ElMessage.error('请输入用户ID');
logger.logWarn('Fetch preview failed: User ID is empty');
return;
}
logger.logInfo('Fetching user preview', { userId: inputUserId.value });
performAction(async () => {
const url = `${apiBaseUrl.value}/user/preview?userId=${inputUserId.value}`;
logger.logApiRequest('GET', url);
const { data } = await axios.get(url);
logger.logApiResponse(200, data);
if (data.error) throw new Error(data.error);
previewData.value = data;
userData.value = null;
musicResults.value = null;
ElMessage.success('已加载用户预览信息');
logger.logInfo('Successfully loaded user preview', { userId: inputUserId.value });
});
};
const fetchScores = () => {
logger.logInfo('Fetching user scores', { userId: inputUserId.value });
performAction(async () => {
const url = `${apiBaseUrl.value}/user/music_results?userId=${inputUserId.value}`;
logger.logApiRequest('GET', url);
const { data } = await axios.get(url);
logger.logApiResponse(200, data);
if (data.error) throw new Error(data.error);
musicResults.value = data.userMusicResults;
ElMessage.success(`已找到 ${musicResults.value?.length || 0} 条分数记录`);
logger.logInfo('Successfully loaded user scores', { userId: inputUserId.value, count: musicResults.value?.length || 0 });
})};
// --- Action Functions ---
const claimBonus = () => {
logger.logInfo('Claiming login bonus', { userId: inputUserId.value });
performAction(async () => {
const url = `${apiBaseUrl.value}/action/claim_login_bonus`;
const requestData = { userId: inputUserId.value };
logger.logApiRequest('POST', url, requestData);
const { data } = await axios.post(url, requestData);
logger.logApiResponse(200, data);
if (data.error) throw new Error(data.error);
ElMessage.success(data.message);
logger.logInfo('Successfully claimed login bonus', { userId: inputUserId.value });
})};
const doUnlockItem = () => {
logger.logInfo('Unlocking item', { userId: inputUserId.value, itemKind: itemKind.value, itemIds: itemIds.value });
performAction(async () => {
if (!itemKind.value || !itemIds.value) throw new Error('道具种类和ID不能为空');
const payload = { userId: inputUserId.value, itemKind: itemKind.value, itemIds: itemIds.value };
const url = `${apiBaseUrl.value}/action/unlock_item`;
logger.logApiRequest('POST', url, payload);
const { data } = await axios.post(url, payload);
logger.logApiResponse(200, data);
if (data.error) throw new Error(data.error);
ElMessage.success(data.message);
logger.logInfo('Successfully unlocked item', { userId: inputUserId.value, itemKind: itemKind.value });
})};
const uploadToFish = () => performAction(async () => {
if (!fishImportToken.value) throw new Error('水鱼查分器导入Token不能为空')
const payload = { userId: inputUserId.value, fishImportToken: fishImportToken.value }
const { data } = await axios.post(`${apiBaseUrl.value}/user/upload_to_diving_fish`, payload)
if (data.error) throw new Error(data.error)
ElMessage.success(data.message)
})
const buyTicket = () => performAction(async () => {
if (!ticketType.value) throw new Error('票券类型不能为空')
const payload = { userId: inputUserId.value, ticketType: ticketType.value }
const { data } = await axios.post(`${apiBaseUrl.value}/action/buy_ticket`, payload)
if (data.error) throw new Error(data.error)
ElMessage.success(data.message)
})
const forceLogout = () => performAction(async () => {
const { data } = await axios.post(`${apiBaseUrl.value}/action/force_logout`, { userId: inputUserId.value })
if (data.error) throw new Error(data.error)
ElMessage.info(data.message)
}, { title: '确认强制登出', message: '这会尝试将用户从当前登录的机台上强制登出,确定吗?' })
const wipeTickets = () => performAction(async () => {
const { data } = await axios.post(`${apiBaseUrl.value}/action/wipe_tickets`, { userId: inputUserId.value })
if (data.error) throw new Error(data.error)
ElMessage.success(data.message)
}, { title: '确认清空票券', message: '此操作会清空该用户的所有票券,确定吗?' })
const uploadScore = () => performAction(async () => {
const { musicId, levelId, achievement, dxScore } = scoreForm
if (!musicId || !levelId || !achievement || !dxScore) throw new Error('上传分数所需的所有字段均不能为空')
const payload = { ...scoreForm, userId: inputUserId.value }
const { data } = await axios.post(`${apiBaseUrl.value}/action/upload_score`, payload)
if (data.error) throw new Error(data.error)
ElMessage.success(data.message)
})
const deleteScore = () => performAction(async () => {
const { musicId, levelId } = scoreForm
if (!musicId || !levelId) throw new Error('删除分数需要乐曲ID和难度ID')
const payload = { musicId, levelId, userId: inputUserId.value }
const { data } = await axios.post(`${apiBaseUrl.value}/action/delete_score`, payload)
if (data.error) throw new Error(data.error)
ElMessage.success(data.message)
}, { title: '确认删除分数', message: `确定要删除乐曲 ${scoreForm.musicId} (难度 ${scoreForm.levelId}) 的分数记录吗?` })
const getAny = () => performAction(async () => {
if (!apiName.value) throw new Error('API名称不能为空')
const { data } = await axios.get(`${apiBaseUrl.value}/get_any?userId=${inputUserId.value}&apiName=${apiName.value}`)
if (data.error) throw new Error(data.error)
anyData.value = data
ElMessage.success(`已成功调用 ${apiName.value}`)
})
</script>
<template>
<el-space direction="vertical" alignment="start" :size="20" style="width: 100%;">
<el-card header="用户选择" style="width: 100%;">
<el-form :inline="true" @submit.prevent="fetchFullProfile">
<el-form-item label="用户ID">
<el-input v-model="inputUserId" placeholder="请输入用户ID" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="fetchFullProfile" :loading="isLoading">获取完整信息</el-button>
<el-button @click="fetchPreview" :loading="isLoading">获取预览</el-button>
</el-form-item>
</el-form>
<el-divider />
<h3>Aime卡扫描</h3>
<el-form :inline="true" @submit.prevent="scanAimeQR">
<el-form-item label="二维码内容">
<el-input v-model="qrContent" placeholder="请输入Aime卡二维码内容" clearable style="width: 300px;" />
</el-form-item>
<el-form-item>
<el-button type="success" @click="scanAimeQR" :loading="isScanning">扫描并填充用户ID</el-button>
</el-form-item>
</el-form>
</el-card>
<div class="card-container">
<el-card v-if="userData" header="完整用户信息" class="data-card">
<el-descriptions :column="1" border>
<el-descriptions-item label="用户名">{{ userData.userName }}</el-descriptions-item>
<el-descriptions-item label="Rating">{{ userData.playerRating }}</el-descriptions-item>
<el-descriptions-item label="最高Rating">{{ userData.highestRating }}</el-descriptions-item>
<el-descriptions-item label="游玩次数">{{ userData.playCount }}</el-descriptions-item>
<el-descriptions-item label="封禁状态">{{ userData.banState }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card v-if="previewData" header="用户预览" class="data-card">
<el-descriptions :column="1" border>
<el-descriptions-item label="用户名">{{ previewData.userName }}</el-descriptions-item>
<el-descriptions-item label="Rating">{{ previewData.playerRating }}</el-descriptions-item>
<el-descriptions-item label="是否在线">{{ previewData.isLogin }}</el-descriptions-item>
<el-descriptions-item label="封禁状态">{{ previewData.banState }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card v-if="inputUserId" header="玩家操作" class="data-card">
<el-collapse accordion>
<el-collapse-item title="通用操作" name="1">
<el-button type="success" @click="claimBonus" :loading="isLoading">领取登录奖励</el-button>
</el-collapse-item>
<el-collapse-item title="道具管理" name="2">
<p>解锁道具 (例如: 种类 10 为搭档)</p>
<el-form :inline="true" @submit.prevent="doUnlockItem">
<el-form-item><el-input v-model="itemKind" placeholder="道具种类" /></el-form-item>
<el-form-item><el-input v-model="itemIds" placeholder="道具ID (英文逗号分隔)" /></el-form-item>
<el-form-item><el-button type="warning" @click="doUnlockItem" :loading="isLoading">解锁</el-button></el-form-item>
</el-form>
</el-collapse-item>
<el-collapse-item title="票券管理" name="3">
<el-form :inline="true" @submit.prevent="buyTicket">
<el-form-item><el-input v-model="ticketType" placeholder="票券种类" /></el-form-item>
<el-form-item><el-button type="primary" @click="buyTicket" :loading="isLoading">购买</el-button></el-form-item>
</el-form>
</el-collapse-item>
<el-collapse-item title="分数管理" name="4">
<el-tabs type="border-card">
<el-tab-pane label="上传分数">
<el-form :inline="true" @submit.prevent="uploadScore">
<el-form-item><el-input v-model="scoreForm.musicId" placeholder="乐曲ID" /></el-form-item>
<el-form-item><el-input v-model="scoreForm.levelId" placeholder="难度 (0-4)" /></el-form-item>
<el-form-item><el-input v-model="scoreForm.achievement" placeholder="达成率 (例如: 1005000)" /></el-form-item>
<el-form-item><el-input v-model="scoreForm.dxScore" placeholder="DX分数" /></el-form-item>
<el-form-item><el-button type="primary" @click="uploadScore" :loading="isLoading">上传</el-button></el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="删除分数">
<el-form :inline="true" @submit.prevent="deleteScore">
<el-form-item><el-input v-model="scoreForm.musicId" placeholder="乐曲ID" /></el-form-item>
<el-form-item><el-input v-model="scoreForm.levelId" placeholder="难度 (0-4)" /></el-form-item>
<el-form-item><el-button type="danger" @click="deleteScore" :loading="isLoading">删除</el-button></el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
</el-collapse-item>
<el-collapse-item title="高级/危险操作" name="5">
<el-form :inline="true" @submit.prevent="uploadToFish">
<el-form-item label="上传至水鱼"><el-input v-model="fishImportToken" type="password" placeholder="水鱼查分器Token" show-password /></el-form-item>
<el-form-item><el-button type="info" @click="uploadToFish" :loading="isLoading">上传</el-button></el-form-item>
</el-form>
<el-divider />
<el-form :inline="true" @submit.prevent="getAny">
<el-form-item label="通用接口调用"><el-input v-model="apiName" placeholder="API名称 (例如: GetUserChargeApi)" /></el-form-item>
<el-form-item><el-button type="info" @click="getAny" :loading="isLoading">调用</el-button></el-form-item>
</el-form>
<el-card v-if="anyData" header="Result" style="margin-top: 10px;">
<pre>{{ JSON.stringify(anyData, null, 2) }}</pre>
</el-card>
<el-divider />
<el-button type="danger" @click="wipeTickets" :loading="isLoading">清空所有票券</el-button>
<el-button type="danger" @click="forceLogout" :loading="isLoading">强制登出</el-button>
</el-collapse-item>
</el-collapse>
</el-card>
</div>
<el-card v-if="inputUserId" header="分数详情" style="width: 100%;">
<el-button @click="fetchScores" :loading="isLoading">获取所有分数</el-button>
<el-table v-if="musicResults" :data="musicResults" stripe style="width: 100%; margin-top: 20px;" height="400">
<el-table-column prop="musicId" label="乐曲ID" sortable />
<el-table-column prop="level" label="难度" sortable />
<el-table-column label="达成率" sortable>
<template #default="scope">
{{ (scope.row.achievement / 10000).toFixed(4) }}%
</template>
</el-table-column>
<el-table-column prop="playCount" label="游玩次数" sortable />
</el-table>
</el-card>
</el-space>
</template>
<style scoped>
.card-container {
display: flex;
flex-wrap: wrap;
gap: 20px;
}
.data-card {
width: 100%;
margin-bottom: 20px;
}
@media (min-width: 768px) {
.data-card {
width: 450px;
}
}
pre {
white-space: pre-wrap;
word-wrap: break-word;
background-color: #f4f4f5;
padding: 10px;
border-radius: 4px;
}
</style>