feat(admin,player,api): 优胜冠军通用管理与界面精简

管理端新增冠军盘列表/编辑、展开懒加载与 ECharts 修复;各列表页去掉重复标题。玩家端支持多赛事冠军盘、分批加载与语言切换刷新。API 扩展 outright CRUD 与列表性能优化。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-04 09:17:01 +08:00
parent 9b63d67e7c
commit 27580b2479
39 changed files with 2250 additions and 578 deletions

View File

@@ -0,0 +1,433 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../../composables/useAdminLocale';
import api from '../../api';
const route = useRoute();
const router = useRouter();
const { t } = useAdminLocale();
const matchId = computed(() => String(route.params.matchId ?? ''));
interface SelectionRow {
id: string;
teamCode: string;
rank: number;
teamZh: string;
teamEn: string;
odds: string;
oddsVersion: string;
status: string;
editOdds: number;
}
const loading = ref(false);
const saving = ref(false);
const meta = ref({
leagueZh: '',
leagueEn: '',
leagueCode: '',
matchName: '',
status: 'DRAFT',
expectedCanonicalCount: null as number | null,
playerVisible: true,
playerHiddenReason: null as string | null,
});
const selections = ref<SelectionRow[]>([]);
const addVisible = ref(false);
const addForm = ref({
teamCode: '',
teamZh: '',
teamEn: '',
odds: 10,
});
async function load() {
if (!matchId.value) return;
loading.value = true;
try {
const { data } = await api.get(`/admin/outrights/${matchId.value}`);
const payload = data.data as {
leagueZh: string;
leagueEn: string;
leagueCode: string;
matchName: string;
status: string;
expectedCanonicalCount: number | null;
playerVisible: boolean;
playerHiddenReason: string | null;
selections: SelectionRow[];
};
meta.value = {
leagueZh: payload.leagueZh,
leagueEn: payload.leagueEn,
leagueCode: payload.leagueCode,
matchName: payload.matchName,
status: payload.status,
expectedCanonicalCount: payload.expectedCanonicalCount,
playerVisible: payload.playerVisible,
playerHiddenReason: payload.playerHiddenReason,
};
selections.value = payload.selections.map((s) => ({
...s,
editOdds: Number(s.odds),
}));
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
} finally {
loading.value = false;
}
}
watch(matchId, load, { immediate: true });
async function saveMeta() {
saving.value = true;
try {
await api.put(`/admin/outrights/${matchId.value}`, {
status: meta.value.status,
matchName: meta.value.matchName,
});
ElMessage.success(t('msg.saved'));
await load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
saving.value = false;
}
}
async function saveAllOdds() {
const invalid = selections.value.find((s) => !s.editOdds || s.editOdds <= 1);
if (invalid) {
ElMessage.warning(t('outright.err_odds_min'));
return;
}
saving.value = true;
try {
await api.put(`/admin/outrights/${matchId.value}/odds`, {
updates: selections.value.map((s) => ({
selectionId: s.id,
odds: s.editOdds,
})),
});
ElMessage.success(t('msg.outright_odds_saved'));
await load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
saving.value = false;
}
}
async function submitAdd() {
if (!addForm.value.teamCode.trim()) {
ElMessage.warning(t('outright.err_team_code'));
return;
}
saving.value = true;
try {
await api.post(`/admin/outrights/${matchId.value}/selections`, {
teamCode: addForm.value.teamCode.trim().toUpperCase(),
teamZh: addForm.value.teamZh,
teamEn: addForm.value.teamEn,
odds: addForm.value.odds,
});
ElMessage.success(t('msg.saved'));
addVisible.value = false;
addForm.value = { teamCode: '', teamZh: '', teamEn: '', odds: 10 };
await load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
saving.value = false;
}
}
async function removeSelection(row: SelectionRow) {
try {
await ElMessageBox.confirm(
t('outright.confirm_remove', { name: row.teamZh || row.teamCode }),
{ type: 'warning' },
);
} catch {
return;
}
loading.value = true;
try {
await api.delete(`/admin/outrights/${matchId.value}/selections/${row.id}`);
ElMessage.success(t('msg.saved'));
await load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
loading.value = false;
}
}
const playerHiddenTip = computed(() => {
const key = meta.value.playerHiddenReason;
if (!key) return '';
return t(`outright.hidden_reason.${key}`);
});
const teamCountLabel = computed(() => {
const total = meta.value.expectedCanonicalCount;
if (total != null) {
return t('outright.team_count', { n: selections.value.length, total });
}
return t('outright.team_count_open', { n: selections.value.length });
});
const isPublished = computed(() => meta.value.status === 'PUBLISHED');
const leagueLabel = computed(() => {
const name = meta.value.leagueZh || meta.value.leagueEn;
const code = meta.value.leagueCode;
if (name && code) return `${name} (${code})`;
return name || code || '';
});
async function saveMetaWithStatus(status: 'DRAFT' | 'PUBLISHED') {
meta.value.status = status;
await saveMeta();
}
</script>
<template>
<div class="event-editor" v-loading="loading">
<div class="editor-topbar">
<el-button size="small" text @click="router.push({ name: 'admin-outrights' })">
{{ t('outright.back_list') }}
</el-button>
</div>
<el-alert
v-if="!meta.playerVisible"
type="warning"
:closable="false"
show-icon
class="player-alert"
:title="t('outright.player_hidden_title')"
:description="playerHiddenTip"
/>
<section class="panel settings-panel">
<div class="settings-top">
<el-input
v-model="meta.matchName"
size="small"
class="title-input"
:placeholder="t('outright.field.title_placeholder')"
@keyup.enter="saveMeta"
/>
<div class="settings-actions">
<el-button size="small" :loading="saving" @click="saveMeta">
{{ t('common.save') }}
</el-button>
<el-button
v-if="!isPublished"
type="primary"
size="small"
:loading="saving"
@click="saveMetaWithStatus('PUBLISHED')"
>
{{ t('outright.btn.publish') }}
</el-button>
<el-button
v-else
size="small"
:loading="saving"
@click="saveMetaWithStatus('DRAFT')"
>
{{ t('outright.btn.unpublish') }}
</el-button>
</div>
</div>
<p v-if="leagueLabel" class="league-meta">
<span class="league-meta-k">{{ t('outright.field.league') }}</span>
{{ leagueLabel }}
</p>
</section>
<section class="panel teams-panel">
<div class="panel-head compact">
<span class="panel-title">{{ t('outright.section.teams') }} · {{ teamCountLabel }}</span>
<el-button type="primary" plain size="small" @click="addVisible = true">
{{ t('outright.btn.add_team') }}
</el-button>
</div>
<div class="table-wrap">
<el-table :data="selections" stripe size="small" empty-text="">
<el-table-column prop="rank" :label="t('outright.col.rank')" width="72" align="center" />
<el-table-column prop="teamZh" :label="t('outright.col.team_zh')" min-width="120" />
<el-table-column prop="teamEn" :label="t('outright.col.team_en')" min-width="140" />
<el-table-column prop="teamCode" :label="t('outright.col.code')" width="88" />
<el-table-column :label="t('outright.col.odds')" width="160" align="right">
<template #default="{ row }">
<el-input-number
v-model="row.editOdds"
:min="1.01"
:step="0.05"
:precision="2"
controls-position="right"
size="small"
style="width: 130px"
/>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="88" align="center">
<template #default="{ row }">
<el-button link type="danger" @click="removeSelection(row)">
{{ t('common.delete') }}
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="panel-foot compact">
<el-button size="small" @click="load">{{ t('common.reset') }}</el-button>
<el-button type="primary" size="small" :loading="saving" @click="saveAllOdds">
{{ t('outright.btn.save_odds') }}
</el-button>
</div>
</section>
<el-dialog v-model="addVisible" :title="t('outright.btn.add_team')" width="420px">
<el-form label-width="100px">
<el-form-item :label="t('outright.col.code')">
<el-input v-model="addForm.teamCode" placeholder="FRA" />
</el-form-item>
<el-form-item :label="t('outright.col.team_zh')">
<el-input v-model="addForm.teamZh" />
</el-form-item>
<el-form-item :label="t('outright.col.team_en')">
<el-input v-model="addForm.teamEn" />
</el-form-item>
<el-form-item :label="t('outright.col.odds')">
<el-input-number v-model="addForm.odds" :min="1.01" :step="0.05" :precision="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="saving" @click="submitAdd">{{ t('common.confirm') }}</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.event-editor {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 8px;
overflow: auto;
}
.editor-topbar {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
margin-bottom: 2px;
}
.player-alert {
flex-shrink: 0;
margin-bottom: 0;
}
.player-alert :deep(.el-alert__content) {
padding: 4px 0;
}
.panel {
background: #111;
border: 1px solid #252525;
border-radius: 8px;
padding: 10px 12px;
flex-shrink: 0;
}
.teams-panel {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.settings-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.league-meta {
margin: 6px 0 0;
font-size: 11px;
color: #555;
line-height: 1.4;
}
.league-meta-k {
color: #444;
margin-right: 4px;
}
.settings-actions {
display: flex;
flex-shrink: 0;
gap: 6px;
}
.title-input {
flex: 1;
min-width: 0;
}
.panel-head.compact {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.panel-title {
font-size: 13px;
font-weight: 700;
color: #ccc;
}
.panel-foot.compact {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #222;
}
.teams-panel .panel-foot.compact {
margin-top: auto;
}
.table-wrap {
flex: 1;
min-height: 80px;
overflow: auto;
}
</style>

View File

@@ -0,0 +1,384 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import { useAdminLocale } from '../../composables/useAdminLocale';
import api from '../../api';
interface OutrightEventItem {
id: string;
leagueId: string;
leagueCode: string;
leagueZh: string;
leagueEn: string;
matchName: string;
status: string;
selectionCount: number;
canImportCanonical: boolean;
playerVisible: boolean;
playerHiddenReason: string | null;
}
interface SelectionPreview {
rank: number;
teamZh: string;
teamEn: string;
teamCode: string;
odds: string;
}
interface RowDetail {
leagueZh: string;
leagueEn: string;
matchName: string;
playerVisible: boolean;
playerHiddenReason: string | null;
selections: SelectionPreview[];
}
interface LeagueOption {
id: string;
code: string;
nameZh: string;
nameEn: string;
}
const router = useRouter();
const { t } = useAdminLocale();
const listReady = ref(false);
const listLoading = ref(false);
const importing = ref(false);
const events = ref<OutrightEventItem[]>([]);
const leagues = ref<LeagueOption[]>([]);
const expandLoadingId = ref<string | null>(null);
const rowDetails = ref<Record<string, RowDetail>>({});
const createVisible = ref(false);
const createLoading = ref(false);
const createForm = ref({
leagueId: '',
titleZh: '',
titleEn: '',
status: 'PUBLISHED',
});
function eventTitle(ev: OutrightEventItem) {
return ev.matchName || ev.leagueZh || ev.leagueEn || '—';
}
function leagueLabel(ev: OutrightEventItem) {
const name = ev.leagueZh || ev.leagueEn;
return name ? `${name} (${ev.leagueCode})` : ev.leagueCode;
}
function hiddenTip(reason: string | null) {
if (!reason) return '';
return t(`outright.hidden_reason.${reason}`);
}
function goEdit(id: string) {
router.push({ name: 'admin-outright-edit', params: { matchId: id } });
}
async function loadEvents(silent = false) {
if (!silent) listLoading.value = true;
try {
const { data } = await api.get('/admin/outrights');
events.value = data.data ?? [];
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
} finally {
listReady.value = true;
listLoading.value = false;
}
}
async function loadRowDetail(row: OutrightEventItem) {
if (rowDetails.value[row.id]) return;
expandLoadingId.value = row.id;
try {
const { data } = await api.get(`/admin/outrights/${row.id}`);
const payload = data.data as {
leagueZh: string;
leagueEn: string;
matchName: string;
playerVisible: boolean;
playerHiddenReason: string | null;
selections: SelectionPreview[];
};
rowDetails.value[row.id] = {
leagueZh: payload.leagueZh,
leagueEn: payload.leagueEn,
matchName: payload.matchName,
playerVisible: payload.playerVisible,
playerHiddenReason: payload.playerHiddenReason,
selections: payload.selections ?? [],
};
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
} finally {
expandLoadingId.value = null;
}
}
async function onExpandChange(row: OutrightEventItem, expanded: OutrightEventItem[]) {
const opened = expanded.some((r) => r.id === row.id);
if (!opened) return;
await loadRowDetail(row);
}
async function loadLeagues() {
try {
const { data } = await api.get('/admin/outrights/leagues');
leagues.value = data.data ?? [];
} catch {
leagues.value = [];
}
}
async function importWc2026() {
importing.value = true;
try {
const { data } = await api.post('/admin/outrights/import/wc2026');
ElMessage.success(t('msg.outright_canonical_applied'));
listReady.value = false;
rowDetails.value = {};
await loadEvents(false);
const id = data.data?.id as string | undefined;
if (id) goEdit(id);
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
importing.value = false;
}
}
async function submitCreate() {
if (!createForm.value.leagueId) {
ElMessage.warning(t('outright.err_league'));
return;
}
createLoading.value = true;
try {
const { data } = await api.post('/admin/outrights', createForm.value);
ElMessage.success(t('msg.saved'));
createVisible.value = false;
createForm.value = { leagueId: '', titleZh: '', titleEn: '', status: 'PUBLISHED' };
listReady.value = false;
rowDetails.value = {};
await loadEvents(false);
const id = data.data?.id as string;
if (id) goEdit(id);
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
createLoading.value = false;
}
}
function refreshList() {
listReady.value = false;
rowDetails.value = {};
void loadEvents(false);
}
onMounted(() => {
void loadLeagues();
void loadEvents(true);
});
</script>
<template>
<div class="outright-list-page admin-list-page">
<div class="page-toolbar">
<el-button size="small" :loading="importing" @click="importWc2026">
{{ t('outright.btn.import_wc2026') }}
</el-button>
<el-button size="small" :loading="listLoading" @click="refreshList">
{{ t('common.reset') }}
</el-button>
<el-button type="primary" size="small" @click="createVisible = true">
{{ t('outright.btn.create_event') }}
</el-button>
</div>
<el-card class="list-card" shadow="never">
<el-table
:data="events"
row-key="id"
size="small"
:empty-text="listReady ? '' : ''"
@expand-change="onExpandChange"
>
<el-table-column type="expand" width="44">
<template #default="{ row }">
<div v-loading="expandLoadingId === row.id" class="expand-body">
<template v-if="rowDetails[row.id]">
<p v-if="rowDetails[row.id].leagueEn" class="expand-line">
<span class="expand-k">{{ t('outright.col.league_en') }}</span>
{{ rowDetails[row.id].leagueEn }}
</p>
<p class="expand-line">
<span class="expand-k">ID</span>
{{ row.id }}
</p>
<p v-if="!rowDetails[row.id].playerVisible" class="expand-warn">
{{ hiddenTip(rowDetails[row.id].playerHiddenReason) }}
</p>
<el-table
v-if="rowDetails[row.id].selections.length"
:data="rowDetails[row.id].selections"
size="small"
class="preview-table"
max-height="240"
>
<el-table-column prop="rank" :label="t('outright.col.rank')" width="56" />
<el-table-column prop="teamZh" :label="t('outright.col.team_zh')" min-width="100" />
<el-table-column prop="teamCode" :label="t('outright.col.code')" width="72" />
<el-table-column prop="odds" :label="t('outright.col.odds')" width="88" align="right" />
</el-table>
<p v-else class="expand-empty">{{ t('outright.expand_no_teams') }}</p>
<div class="expand-actions">
<el-button type="primary" size="small" @click="goEdit(row.id)">
{{ t('common.edit') }}
</el-button>
</div>
</template>
</div>
</template>
</el-table-column>
<el-table-column :label="t('outright.field.title')" min-width="160">
<template #default="{ row }">{{ eventTitle(row) }}</template>
</el-table-column>
<el-table-column :label="t('outright.field.league')" min-width="180">
<template #default="{ row }">{{ leagueLabel(row) }}</template>
</el-table-column>
<el-table-column :label="t('outright.field.status')" width="96" align="center">
<template #default="{ row }">
<el-tag
size="small"
:type="row.status === 'PUBLISHED' ? 'success' : 'info'"
effect="dark"
>
{{
row.status === 'PUBLISHED'
? t('outright.status.published')
: t('outright.status.draft')
}}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('outright.col.teams')" width="88" align="center">
<template #default="{ row }">{{ row.selectionCount }}</template>
</el-table-column>
<el-table-column :label="t('outright.col.player_visible')" width="108" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.playerVisible ? 'success' : 'warning'" effect="plain">
{{ row.playerVisible ? t('common.yes') : t('common.no') }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="88" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="goEdit(row.id)">
{{ t('common.edit') }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="createVisible" :title="t('outright.btn.create_event')" width="440px">
<el-form label-width="100px" size="small">
<el-form-item :label="t('outright.field.league')">
<el-select v-model="createForm.leagueId" filterable style="width: 100%">
<el-option
v-for="lg in leagues"
:key="lg.id"
:value="lg.id"
:label="`${lg.nameZh || lg.code} (${lg.code})`"
/>
</el-select>
</el-form-item>
<el-form-item :label="t('outright.field.title_zh')">
<el-input v-model="createForm.titleZh" />
</el-form-item>
<el-form-item :label="t('outright.field.title_en')">
<el-input v-model="createForm.titleEn" />
</el-form-item>
</el-form>
<template #footer>
<el-button size="small" @click="createVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" size="small" :loading="createLoading" @click="submitCreate">
{{ t('common.confirm') }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.outright-list-page {
display: flex;
flex-direction: column;
min-height: 0;
}
.list-card {
flex: 1;
min-height: 0;
border-radius: 10px;
display: flex;
flex-direction: column;
}
.list-card :deep(.el-card__body) {
padding: 0;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.expand-body {
padding: 10px 12px 12px 20px;
min-height: 48px;
}
.expand-line {
margin: 0 0 4px;
font-size: 12px;
color: #999;
}
.expand-k {
display: inline-block;
min-width: 72px;
color: #666;
margin-right: 6px;
}
.expand-warn {
margin: 0 0 8px;
font-size: 12px;
color: #c9a227;
}
.expand-empty {
margin: 0;
font-size: 12px;
color: #666;
}
.preview-table {
margin-top: 6px;
}
.expand-actions {
margin-top: 10px;
}
</style>