forked from GuChen/maimaiDX-API-Web-Server
397 lines
17 KiB
Vue
397 lines
17 KiB
Vue
<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> |