forked from GuChen/maimaiDX-API-Web-Server
Initial commit: Add maimaiDX API web application with AimeDB scanning and logging features
This commit is contained in:
105
frontend/src/App.vue
Normal file
105
frontend/src/App.vue
Normal 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>
|
||||
1
frontend/src/assets/logo.svg
Normal file
1
frontend/src/assets/logo.svg
Normal 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 |
10
frontend/src/assets/main.css
Normal file
10
frontend/src/assets/main.css
Normal 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;
|
||||
}
|
||||
41
frontend/src/components/HelloWorld.vue
Normal file
41
frontend/src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
msg: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="greetings">
|
||||
<h1 class="green">{{ msg }}</h1>
|
||||
<h3>
|
||||
You’ve 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>
|
||||
136
frontend/src/components/LogViewer.vue
Normal file
136
frontend/src/components/LogViewer.vue
Normal 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>
|
||||
94
frontend/src/components/TheWelcome.vue
Normal file
94
frontend/src/components/TheWelcome.vue
Normal 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>
|
||||
|
||||
Vue’s
|
||||
<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>
|
||||
87
frontend/src/components/WelcomeItem.vue
Normal file
87
frontend/src/components/WelcomeItem.vue
Normal 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>
|
||||
7
frontend/src/components/icons/IconCommunity.vue
Normal file
7
frontend/src/components/icons/IconCommunity.vue
Normal 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>
|
||||
7
frontend/src/components/icons/IconDocumentation.vue
Normal file
7
frontend/src/components/icons/IconDocumentation.vue
Normal 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>
|
||||
7
frontend/src/components/icons/IconEcosystem.vue
Normal file
7
frontend/src/components/icons/IconEcosystem.vue
Normal 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>
|
||||
7
frontend/src/components/icons/IconSupport.vue
Normal file
7
frontend/src/components/icons/IconSupport.vue
Normal 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>
|
||||
19
frontend/src/components/icons/IconTooling.vue
Normal file
19
frontend/src/components/icons/IconTooling.vue
Normal 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
20
frontend/src/main.ts
Normal 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')
|
||||
45
frontend/src/router/index.ts
Normal file
45
frontend/src/router/index.ts
Normal 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
|
||||
143
frontend/src/utils/logger.ts
Normal file
143
frontend/src/utils/logger.ts
Normal 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
|
||||
};
|
||||
15
frontend/src/views/AboutView.vue
Normal file
15
frontend/src/views/AboutView.vue
Normal 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>
|
||||
110
frontend/src/views/AimeDBView.vue
Normal file
110
frontend/src/views/AimeDBView.vue
Normal 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>
|
||||
105
frontend/src/views/AuthLiteDeliveryView.vue
Normal file
105
frontend/src/views/AuthLiteDeliveryView.vue
Normal 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>
|
||||
18
frontend/src/views/HomeView.vue
Normal file
18
frontend/src/views/HomeView.vue
Normal 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>
|
||||
182
frontend/src/views/LogsView.vue
Normal file
182
frontend/src/views/LogsView.vue
Normal 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>
|
||||
62
frontend/src/views/MusicView.vue
Normal file
62
frontend/src/views/MusicView.vue
Normal 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>
|
||||
397
frontend/src/views/ProfileView.vue
Normal file
397
frontend/src/views/ProfileView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user