feat(admin): 管理端列表分页、控制台图表与赛事导入

- 玩家/代理/赛事/注单/审计列表分页,默认每页 10 条,无页面滚动条布局

- ECharts 控制台概览、注单管理中文化与列宽优化

- zhibo 赛事字段迁移与导入,玩家编辑可改所属代理

- 管理端 API 分页与 dashboard 统计接口

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-03 13:49:31 +08:00
parent 2c356b2048
commit 80adc0e928
45 changed files with 6564 additions and 499 deletions

View File

@@ -1,31 +1,185 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import api from '../api';
import { ElMessage } from 'element-plus';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
emptyMatchForm,
buildPlatformPayload,
formFromDetail,
type MatchCreateForm,
type AdminMatchDetail,
} from './match-form';
const router = useRouter();
const matches = ref<unknown[]>([]);
const form = ref({
leagueId: '',
homeTeamId: '',
awayTeamId: '',
startTime: '',
});
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const filterStatus = ref('');
const keyword = ref('');
const createVisible = ref(false);
const editVisible = ref(false);
const importVisible = ref(false);
const createLoading = ref(false);
const editLoading = ref(false);
const importLoading = ref(false);
const importJson = ref('');
const form = ref<MatchCreateForm>(emptyMatchForm());
const editingId = ref('');
const editingStatus = ref('');
const isEditPublished = computed(() => editingStatus.value === 'PUBLISHED');
onMounted(load);
async function load() {
const { data } = await api.get('/admin/matches');
matches.value = data.data;
const { data } = await api.get('/admin/matches', {
params: {
page: page.value,
pageSize: pageSize.value,
status: filterStatus.value || undefined,
keyword: keyword.value.trim() || undefined,
},
});
matches.value = data.data.items;
total.value = data.data.total;
}
async function create() {
await api.post('/admin/matches', form.value);
ElMessage.success('赛事已创建');
function onPageChange(p: number) {
page.value = p;
load();
}
function onSizeChange(size: number) {
pageSize.value = size;
page.value = 1;
load();
}
function openCreate() {
form.value = emptyMatchForm();
editingId.value = '';
createVisible.value = true;
}
function openImport() {
importJson.value = '';
importVisible.value = true;
}
async function openEdit(id: string) {
try {
const { data } = await api.get(`/admin/matches/${id}`);
const detail = data.data as AdminMatchDetail;
if (detail.isOutright) {
ElMessage.warning('冠军盘不支持在此编辑');
return;
}
editingId.value = id;
editingStatus.value = detail.status;
form.value = formFromDetail(detail);
editVisible.value = true;
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? '加载赛事失败');
}
}
async function submitCreate() {
let payload: ReturnType<typeof buildPlatformPayload>;
try {
payload = buildPlatformPayload(form.value);
} catch (e) {
ElMessage.warning(e instanceof Error ? e.message : '请检查表单');
return;
}
createLoading.value = true;
try {
await api.post('/admin/matches', payload);
ElMessage.success('赛事已创建(草稿)');
createVisible.value = false;
load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? '创建失败');
} finally {
createLoading.value = false;
}
}
async function submitEdit() {
let payload: ReturnType<typeof buildPlatformPayload>;
try {
payload = buildPlatformPayload(form.value);
} catch (e) {
ElMessage.warning(e instanceof Error ? e.message : '请检查表单');
return;
}
editLoading.value = true;
try {
await api.put(`/admin/matches/${editingId.value}`, payload);
ElMessage.success('已保存');
editVisible.value = false;
load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? '保存失败');
} finally {
editLoading.value = false;
}
}
async function confirmDelete(row: unknown) {
const id = matchId(row);
const title = matchTitle(row);
try {
await ElMessageBox.confirm(`确定删除赛事「${title}」?仅草稿且无注单时可删除。`, '删除确认', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
});
await api.delete(`/admin/matches/${id}`);
ElMessage.success('已删除');
load();
} catch (e) {
if (e === 'cancel' || (e as { message?: string })?.message === 'cancel') return;
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? '删除失败');
}
}
async function submitImport() {
let payload: unknown;
try {
payload = JSON.parse(importJson.value);
} catch {
ElMessage.error('JSON 格式无效');
return;
}
importLoading.value = true;
try {
const { data } = await api.post('/admin/matches/import', payload);
const r = data.data as {
imported: number;
skipped: number;
failed: number;
total: number;
};
ElMessage.success(
`导入完成:成功 ${r.imported},跳过 ${r.skipped},失败 ${r.failed} / 共 ${r.total}`,
);
importVisible.value = false;
load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? '导入失败');
} finally {
importLoading.value = false;
}
}
async function publish(id: string) {
await api.post(`/admin/matches/${id}/publish`);
await api.post(`/admin/matches/${id}/markets/templates`, {
@@ -44,31 +198,298 @@ async function close(id: string) {
function settle(id: string) {
router.push(`/settlement/${id}`);
}
type TagType = '' | 'info' | 'success' | 'warning' | 'danger';
const statusLabels: Record<string, string> = {
DRAFT: '草稿',
PUBLISHED: '已发布',
CLOSED: '已封盘',
SETTLED: '已结算',
};
const statusTagTypes: Record<string, TagType> = {
DRAFT: 'info',
PUBLISHED: 'warning',
CLOSED: 'danger',
SETTLED: 'success',
};
function rowOf(row: unknown) {
return row as Record<string, unknown>;
}
function matchStatus(row: unknown) {
return String(rowOf(row).status ?? '');
}
function matchStatusLabel(row: unknown) {
return statusLabels[matchStatus(row)] ?? matchStatus(row);
}
function matchStatusType(row: unknown): TagType {
return statusTagTypes[matchStatus(row)] ?? 'info';
}
function matchId(row: unknown) {
return String(rowOf(row).id ?? '');
}
function matchTime(row: unknown) {
return new Date(String(rowOf(row).startTime)).toLocaleString();
}
function matchTitle(row: unknown) {
const r = rowOf(row);
if (r.matchName) return String(r.matchName);
const home = (r.homeTeam as { code?: string })?.code ?? '';
const away = (r.awayTeam as { code?: string })?.code ?? '';
return home && away ? `${home} vs ${away}` : '—';
}
function canEdit(row: unknown) {
const r = rowOf(row);
if (r.isOutright) return false;
return matchStatus(row) === 'DRAFT' || matchStatus(row) === 'PUBLISHED';
}
function canDelete(row: unknown) {
const r = rowOf(row);
if (r.isOutright) return false;
return matchStatus(row) === 'DRAFT';
}
</script>
<template>
<h2>赛事管理</h2>
<el-card style="margin-bottom: 16px">
<div class="admin-list-page">
<div class="page-header">
<div>
<h2 class="page-title">赛事管理</h2>
<span class="page-desc">草稿可编辑删除已发布可改开赛时间与热门</span>
</div>
<div class="header-actions">
<el-button @click="openImport">导入</el-button>
<el-button type="primary" @click="openCreate">+ 新增赛事</el-button>
</div>
</div>
<el-card class="filter-card" shadow="never">
<el-form inline>
<el-input v-model="form.leagueId" placeholder="联赛ID" style="width: 100px" />
<el-input v-model="form.homeTeamId" placeholder="主队ID" style="width: 100px" />
<el-input v-model="form.awayTeamId" placeholder="客队ID" style="width: 100px" />
<el-input v-model="form.startTime" placeholder="开赛时间 ISO" style="width: 200px" />
<el-button type="primary" @click="create">创建赛事</el-button>
<el-form-item label="关键词">
<el-input
v-model="keyword"
placeholder="赛事名 / 球队代码"
clearable
style="width: 200px"
@keyup.enter="load"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filterStatus" placeholder="全部" clearable style="width: 120px">
<el-option label="草稿" value="DRAFT" />
<el-option label="已发布" value="PUBLISHED" />
<el-option label="已封盘" value="CLOSED" />
<el-option label="已结算" value="SETTLED" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="load">查询</el-button>
</el-form-item>
</el-form>
</el-card>
<el-table :data="matches">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="status" label="状态" />
<el-table-column label="开赛时间">
<template #default="{ row }">{{ new Date((row as { startTime: string }).startTime).toLocaleString() }}</template>
</el-table-column>
<el-table-column label="操作" width="300">
<template #default="{ row }">
<el-button v-if="(row as { status: string }).status === 'DRAFT'" size="small" @click="publish((row as { id: string }).id)">发布</el-button>
<el-button v-if="(row as { status: string }).status === 'PUBLISHED'" size="small" @click="close((row as { id: string }).id)">封盘</el-button>
<el-button size="small" type="warning" @click="settle((row as { id: string }).id)">结算</el-button>
</template>
</el-table-column>
</el-table>
<el-card class="data-card" shadow="never">
<div class="table-wrap">
<el-table :data="matches" stripe>
<el-table-column prop="id" label="ID" width="72" />
<el-table-column label="对阵" min-width="200">
<template #default="{ row }">{{ matchTitle(row) }}</template>
</el-table-column>
<el-table-column label="状态" width="96">
<template #default="{ row }">
<el-tag :type="matchStatusType(row)" size="small">{{ matchStatusLabel(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="开赛时间" min-width="160">
<template #default="{ row }">{{ matchTime(row) }}</template>
</el-table-column>
<el-table-column label="操作" width="340" align="center" fixed="right">
<template #default="{ row }">
<el-button
v-if="canEdit(row)"
size="small"
plain
@click="openEdit(matchId(row))"
>
编辑
</el-button>
<el-button
v-if="canDelete(row)"
size="small"
type="danger"
plain
@click="confirmDelete(row)"
>
删除
</el-button>
<el-button
v-if="matchStatus(row) === 'DRAFT'"
size="small"
type="primary"
plain
@click="publish(matchId(row))"
>
发布
</el-button>
<el-button
v-if="matchStatus(row) === 'PUBLISHED'"
size="small"
type="danger"
plain
@click="close(matchId(row))"
>
封盘
</el-button>
<el-button size="small" type="warning" plain @click="settle(matchId(row))">
结算
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
background
@current-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</el-card>
<el-dialog v-model="createVisible" title="新增赛事" width="520px" destroy-on-close>
<el-form label-width="96px">
<el-form-item label="联赛(英)">
<el-input v-model="form.leagueEn" placeholder="FIFA World Cup 2026" />
</el-form-item>
<el-form-item label="联赛(中)">
<el-input v-model="form.leagueZh" placeholder="2026 世界杯" />
</el-form-item>
<el-form-item label="开赛时间" required>
<el-input v-model="form.startTime" placeholder="2026-06-11T19:00:00Z" />
</el-form-item>
<el-form-item label="主队(英)">
<el-input v-model="form.homeTeamEn" placeholder="Mexico" />
</el-form-item>
<el-form-item label="主队(中)">
<el-input v-model="form.homeTeamZh" placeholder="墨西哥" />
</el-form-item>
<el-form-item label="客队(英)">
<el-input v-model="form.awayTeamEn" placeholder="South Africa" />
</el-form-item>
<el-form-item label="客队(中)">
<el-input v-model="form.awayTeamZh" placeholder="南非" />
</el-form-item>
<el-form-item label="热门">
<el-switch v-model="form.isHot" />
</el-form-item>
<p class="field-hint">创建后为草稿请在列表点击发布并生成盘口</p>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="createLoading" @click="submitCreate">创建</el-button>
</template>
</el-dialog>
<el-dialog v-model="editVisible" title="编辑赛事" width="520px" destroy-on-close>
<el-form label-width="96px">
<p v-if="isEditPublished" class="field-hint edit-hint">
已发布可修改开赛时间热门及显示名称封盘/已结算后不可编辑
</p>
<el-form-item label="联赛(英)">
<el-input v-model="form.leagueEn" :disabled="isEditPublished" />
</el-form-item>
<el-form-item label="联赛(中)">
<el-input v-model="form.leagueZh" :disabled="isEditPublished" />
</el-form-item>
<el-form-item label="开赛时间" required>
<el-input v-model="form.startTime" />
</el-form-item>
<el-form-item label="主队(英)">
<el-input v-model="form.homeTeamEn" :disabled="isEditPublished" />
</el-form-item>
<el-form-item label="主队(中)">
<el-input v-model="form.homeTeamZh" :disabled="isEditPublished" />
</el-form-item>
<el-form-item label="客队(英)">
<el-input v-model="form.awayTeamEn" :disabled="isEditPublished" />
</el-form-item>
<el-form-item label="客队(中)">
<el-input v-model="form.awayTeamZh" :disabled="isEditPublished" />
</el-form-item>
<el-form-item label="热门">
<el-switch v-model="form.isHot" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="editLoading" @click="submitEdit">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="importVisible" title="导入赛事" width="640px" destroy-on-close>
<p class="dialog-hint">粘贴含 <code>matches</code> JSON导入后为草稿需在列表发布</p>
<el-input
v-model="importJson"
type="textarea"
:rows="14"
placeholder='{"matches":[...]}'
/>
<template #footer>
<el-button @click="importVisible = false">取消</el-button>
<el-button type="primary" :loading="importLoading" @click="submitImport">开始导入</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.page-title {
font-size: 20px;
font-weight: 700;
color: #e0e0e0;
margin: 0 0 4px;
}
.page-desc {
font-size: 13px;
color: #3a3a3a;
}
.header-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.filter-card { border-radius: 12px; }
.data-card {
border-radius: 12px;
}
.dialog-hint {
font-size: 13px;
color: #666;
margin: 0 0 12px;
line-height: 1.5;
}
.dialog-hint code {
color: #aaa;
}
.field-hint {
font-size: 12px;
color: #666;
margin: 0;
line-height: 1.5;
}
.edit-hint {
margin-bottom: 16px;
}
</style>