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

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>