feat(admin): 管理端列表分页、控制台图表与赛事导入
- 玩家/代理/赛事/注单/审计列表分页,默认每页 10 条,无页面滚动条布局 - ECharts 控制台概览、注单管理中文化与列宽优化 - zhibo 赛事字段迁移与导入,玩家编辑可改所属代理 - 管理端 API 分页与 dashboard 统计接口 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user