sync(theme-3): 同步 main 最新 API/Admin 与玩家端逻辑

从 main 同步站内信手动发送、媒体库选择、列表子路由重构等 API/Admin 改动;玩家端仅合并 ADMIN_CUSTOM 富文本、消息预览与充值截图压缩逻辑,保留 theme-3 样式。
This commit is contained in:
2026-06-22 14:11:47 +08:00
parent f92c5e8a59
commit d8e08aaed0
50 changed files with 4653 additions and 1186 deletions

View File

@@ -1,15 +1,15 @@
<script setup lang="ts">
import { ref, computed, onBeforeUnmount } from 'vue';
import { ref, computed, watch, onBeforeUnmount, onDeactivated } from 'vue';
defineOptions({ name: 'AdminMatches' });
import { useStaleListLifecycle } from '../composables/useStaleList';
import { useRoute } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { useAdminLocale } from '../composables/useAdminLocale';
import { resolveFormError } from '../i18n/form-validation';
import api from '../api';
import { ElMessage, ElMessageBox } from 'element-plus';
import LeagueMatchesPanel from './matches/LeagueMatchesPanel.vue';
import MatchesSubNav from '../components/MatchesSubNav.vue';
import LeagueRowActions from '../components/LeagueRowActions.vue';
import CountryFlagSelect from '../components/outright/CountryFlagSelect.vue';
import LogoUrlField from '../components/LogoUrlField.vue';
import LeagueArchiveDialog from '../components/LeagueArchiveDialog.vue';
@@ -17,7 +17,6 @@ import { getBuiltinCountry } from '../data/builtinCountries';
import {
readMatchesListUiState,
writeMatchesListUiState,
MAX_EXPANDED_LEAGUES,
} from '../utils/matchesListState';
import { formatAmount } from '../utils/format-amount';
import {
@@ -28,15 +27,44 @@ import {
type MatchCreateForm,
} from './match-form';
const { t } = useAdminLocale();
const { t, localeTag } = useAdminLocale();
const route = useRoute();
const leagues = ref<unknown[]>([]);
const router = useRouter();
const isMatchChildRoute = computed(() =>
/^\/matches\/leagues\/[^/]+/.test(route.path),
);
interface LeagueTableRow extends Record<string, unknown> {
id: string;
isPublished: boolean;
isPublishing: boolean;
labels: {
edit: string;
createFixture: string;
publish: string;
unpublish: string;
delete: string;
};
displaySeq: number;
displayNameZh: string;
displayNameEn: string;
displayStatusLabel: string;
displayStatusTagType: 'success' | 'info';
displayMatchCount: number;
displayBetCount: number;
displayBetCountActive: boolean;
displayTotalStake: string;
displayPendingBets: number;
displayCode: string;
}
const leagues = ref<LeagueTableRow[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const filterStatus = ref('');
const keyword = ref('');
const expandedRowKeys = ref<string[]>([]);
const createLeagueVisible = ref(false);
const createLeagueLoading = ref(false);
@@ -61,9 +89,70 @@ const createUnderLeagueLabel = ref('');
const isFixtureCreate = computed(() => !!form.value.leagueId.trim());
function rowOf(row: unknown) {
return row as Record<string, unknown>;
}
function leagueId(row: unknown) {
return String(rowOf(row).id ?? '');
}
function leagueTitle(row: unknown) {
const r = rowOf(row);
const zh = String(r.leagueZh ?? '').trim();
const en = String(r.leagueEn ?? '').trim();
return zh || en || String(r.code ?? '—');
}
function leagueActionLabels() {
return {
edit: t('common.edit'),
createFixture: t('match.create_fixture_btn'),
publish: t('common.publish'),
unpublish: t('league.btn.unpublish'),
delete: t('common.delete'),
};
}
function mapLeagueRows(items: unknown[], publishingId = ''): LeagueTableRow[] {
const labels = leagueActionLabels();
const start = (page.value - 1) * pageSize.value;
return items.map((item, index) => {
const r = rowOf(item);
const id = String(r.id ?? '');
const published = Boolean(r.isPublished);
const stats = r.betStats as
| { betCount?: number; totalStake?: string; pendingCount?: number }
| undefined;
const betCount = Number(stats?.betCount ?? 0);
return {
...r,
id,
isPublished: published,
isPublishing: publishingId === id,
labels,
displaySeq: start + index + 1,
displayNameZh: String(r.leagueZh ?? '').trim() || '—',
displayNameEn: String(r.leagueEn ?? '').trim() || '—',
displayStatusLabel: published ? t('league.status.PUBLISHED') : t('league.status.UNPUBLISHED'),
displayStatusTagType: published ? 'success' : 'info',
displayMatchCount: Number(r.matchCount ?? 0),
displayBetCount: betCount,
displayBetCountActive: betCount > 0,
displayTotalStake: formatAmount(String(stats?.totalStake ?? '0')),
displayPendingBets: Number(stats?.pendingCount ?? 0),
displayCode: String(r.code ?? ''),
};
});
}
function remapLeagueRows(publishingId = publishingLeagueId.value) {
if (!leagues.value.length) return;
leagues.value = mapLeagueRows(leagues.value, publishingId);
}
function persistListUiState() {
writeMatchesListUiState({
expandedLeagueIds: [...expandedRowKeys.value],
page: page.value,
pageSize: pageSize.value,
filterStatus: filterStatus.value,
@@ -71,15 +160,10 @@ function persistListUiState() {
});
}
function applyExpandedFromSaved(savedIds: string[]) {
const allowed = new Set(leagues.value.map((row) => leagueId(row)));
expandedRowKeys.value = savedIds.filter((id) => allowed.has(id));
}
type LoadOptions = { restoreExpand?: boolean; keepExpand?: boolean };
type LoadOptions = { restore?: boolean };
async function load(options: LoadOptions = {}) {
const saved = options.restoreExpand ? readMatchesListUiState() : null;
const saved = options.restore ? readMatchesListUiState() : null;
if (saved) {
page.value = saved.page;
pageSize.value = saved.pageSize;
@@ -95,26 +179,18 @@ async function load(options: LoadOptions = {}) {
keyword: keyword.value.trim() || undefined,
},
});
leagues.value = data.data.items;
leagues.value = mapLeagueRows(data.data.items);
total.value = data.data.total;
if (options.restoreExpand && saved) {
applyExpandedFromSaved(saved.expandedLeagueIds);
} else if (!options.keepExpand) {
expandedRowKeys.value = [];
} else {
applyExpandedFromSaved(expandedRowKeys.value);
}
persistListUiState();
}
function onSearch() {
page.value = 1;
expandedRowKeys.value = [];
void runLoad(true);
}
async function initialLoad() {
if (isMatchChildRoute.value) return;
const qStatus = route.query.status;
if (typeof qStatus === 'string' && qStatus.trim()) {
filterStatus.value = qStatus.trim();
@@ -122,21 +198,29 @@ async function initialLoad() {
await load();
return;
}
await load({ restoreExpand: true });
const qLeague = route.query.leagueId;
if (typeof qLeague === 'string' && qLeague.trim()) {
router.replace(`/matches/leagues/${qLeague.trim()}`);
return;
}
await load({ restore: true });
}
const { loading: listLoading, runLoad } = useStaleListLifecycle(() => leagues.value.length > 0, initialLoad);
onBeforeUnmount(persistListUiState);
onDeactivated(persistListUiState);
watch(localeTag, () => remapLeagueRows());
function onPageChange(p: number) {
page.value = p;
load({ keepExpand: true });
load();
}
function onSizeChange(size: number) {
pageSize.value = size;
page.value = 1;
load({ keepExpand: true });
load();
}
function openCreateLeague() {
@@ -178,6 +262,7 @@ async function toggleLeaguePublish(row: unknown) {
}
}
publishingLeagueId.value = id;
remapLeagueRows(id);
try {
await api.put(`/admin/leagues/${id}`, {
leagueEn: String(r.leagueEn ?? ''),
@@ -187,7 +272,7 @@ async function toggleLeaguePublish(row: unknown) {
isActive: !published,
});
ElMessage.success(published ? t('msg.league_unpublished') : t('msg.league_published'));
await load({ keepExpand: true });
await load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
@@ -230,7 +315,7 @@ async function submitLeagueForm() {
}
createLeagueVisible.value = false;
load({ keepExpand: true });
load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
@@ -274,10 +359,16 @@ async function submitCreate() {
createUnderLeagueLabel.value = '';
createVisible.value = false;
const lid = form.value.leagueId.trim();
await load({ keepExpand: true });
if (lid && !expandedRowKeys.value.includes(lid)) {
expandedRowKeys.value = capExpandedLeagueIds([...expandedRowKeys.value, lid]);
persistListUiState();
await load();
if (lid) {
router.push({
path: `/matches/leagues/${lid}`,
query: {
...(filterStatus.value ? { status: filterStatus.value } : {}),
...(keyword.value.trim() ? { keyword: keyword.value.trim() } : {}),
title: createUnderLeagueLabel.value || leagueTitle({ id: lid }),
},
});
}
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
@@ -287,88 +378,32 @@ async function submitCreate() {
}
}
function capExpandedLeagueIds(ids: string[]): string[] {
return ids.slice(0, MAX_EXPANDED_LEAGUES);
}
function onExpandChange(_row: unknown, expanded: unknown[]) {
expandedRowKeys.value = capExpandedLeagueIds(expanded.map((r) => leagueId(r)));
persistListUiState();
}
function onRowClick(row: unknown, _column: unknown, event: MouseEvent) {
if ((event.target as HTMLElement).closest('.el-table__expand-icon')) return;
function openLeaguePage(row: unknown, _column: unknown, event: Event) {
const target = event.target;
const el = target instanceof Element ? target : target instanceof Node ? target.parentElement : null;
if (!el) return;
if (el.closest('.league-row-actions') || el.closest('.el-button')) return;
const id = leagueId(row);
if (expandedRowKeys.value.includes(id)) {
expandedRowKeys.value = expandedRowKeys.value.filter((k) => k !== id);
} else {
const next = [...expandedRowKeys.value, id];
expandedRowKeys.value = capExpandedLeagueIds(next);
}
persistListUiState();
if (!id) return;
void router.push({
name: 'admin-league-matches',
params: { leagueId: id },
query: {
...(filterStatus.value ? { status: filterStatus.value } : {}),
...(keyword.value.trim() ? { keyword: keyword.value.trim() } : {}),
title: leagueTitle(row),
},
});
}
function rowClassName() {
return 'row-expandable';
}
function rowOf(row: unknown) {
return row as Record<string, unknown>;
}
function leagueId(row: unknown) {
return String(rowOf(row).id ?? '');
}
function leagueTitle(row: unknown) {
const r = rowOf(row);
const zh = String(r.leagueZh ?? '').trim();
const en = String(r.leagueEn ?? '').trim();
return zh || en || String(r.code ?? '—');
}
function leagueNameZh(row: unknown) {
const zh = String(rowOf(row).leagueZh ?? '').trim();
return zh || '—';
}
function leagueNameEn(row: unknown) {
const en = String(rowOf(row).leagueEn ?? '').trim();
return en || '—';
}
function leagueMatchCount(row: unknown) {
return Number(rowOf(row).matchCount ?? 0);
return 'row-navigable';
}
function leagueIsPublished(row: unknown) {
return Boolean(rowOf(row).isPublished);
}
function leagueStatusLabel(row: unknown) {
return leagueIsPublished(row) ? t('league.status.PUBLISHED') : t('league.status.UNPUBLISHED');
}
function leagueStatusTagType(row: unknown): 'success' | 'info' {
return leagueIsPublished(row) ? 'success' : 'info';
}
function leagueBetStats(row: unknown) {
return rowOf(row).betStats as
| { betCount?: number; totalStake?: string; pendingCount?: number }
| undefined;
}
function leagueBetCount(row: unknown) {
return Number(leagueBetStats(row)?.betCount ?? 0);
}
function leagueTotalStake(row: unknown) {
return formatAmount(String(leagueBetStats(row)?.totalStake ?? '0'));
}
function leaguePendingBets(row: unknown) {
return Number(leagueBetStats(row)?.pendingCount ?? 0);
}
function isLeagueExpanded(id: string) {
return expandedRowKeys.value.includes(id);
}
function openLeagueArchive(row: unknown) {
leagueArchiveId.value = leagueId(row);
leagueArchiveName.value = leagueTitle(row);
@@ -382,7 +417,9 @@ function onLeagueArchived() {
</script>
<template>
<div class="admin-list-page matches-page">
<div class="matches-shell">
<router-view v-if="isMatchChildRoute" />
<div v-else class="admin-list-page matches-page">
<div class="list-chrome">
<div class="list-chrome__row">
<div class="list-chrome__left">
@@ -423,115 +460,66 @@ function onLeagueArchived() {
</div>
<section v-loading="listLoading" class="list-panel">
<p class="list-hint">{{ t('match.expand_league_hint') }}</p>
<p class="list-hint">{{ t('match.open_league_hint') }}</p>
<div class="table-wrap">
<el-table
:data="leagues"
stripe
row-key="id"
:expand-row-keys="expandedRowKeys"
:row-class-name="rowClassName"
@expand-change="onExpandChange"
@row-click="onRowClick"
@row-click="openLeaguePage"
>
<el-table-column type="expand" width="40">
<el-table-column prop="displaySeq" :label="t('common.seq')" width="70" align="center" />
<el-table-column width="40" align="center" class-name="league-logo-cell">
<template #default="{ row }">
<template v-if="isLeagueExpanded(leagueId(row))">
<LeagueMatchesPanel
:league-id="leagueId(row)"
:filter-status="filterStatus"
:keyword="keyword"
@changed="() => load({ keepExpand: true })"
/>
</template>
</template>
</el-table-column>
<el-table-column type="index" :index="(i: number) => (page - 1) * pageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
<el-table-column :label="t('match.col.league')" width="148" show-overflow-tooltip>
<template #default="{ row }">
<div class="league-cell">
<img
v-if="rowOf(row).logoUrl"
:src="String(rowOf(row).logoUrl)"
alt=""
class="league-logo"
/>
<span class="matchup-link">{{ leagueNameZh(row) }}</span>
</div>
</template>
</el-table-column>
<el-table-column :label="t('match.col.league_en')" width="180" show-overflow-tooltip>
<template #default="{ row }">
<span class="league-en">{{ leagueNameEn(row) }}</span>
<img
v-if="row.logoUrl"
:src="String(row.logoUrl)"
alt=""
class="league-logo"
/>
</template>
</el-table-column>
<el-table-column
prop="displayNameZh"
:label="t('match.col.league')"
width="108"
show-overflow-tooltip
class-name="league-name-cell"
/>
<el-table-column prop="displayNameEn" :label="t('match.col.league_en')" width="180" show-overflow-tooltip class-name="league-en-cell" />
<el-table-column :label="t('common.status')" width="88" align="center">
<template #default="{ row }">
<el-tag :type="leagueStatusTagType(row)" size="small" effect="plain">
{{ leagueStatusLabel(row) }}
<el-tag :type="row.displayStatusTagType" size="small" effect="plain">
{{ row.displayStatusLabel }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('match.col.fixture_count')" width="88" align="center">
<template #default="{ row }">{{ leagueMatchCount(row) }}</template>
</el-table-column>
<el-table-column prop="displayMatchCount" :label="t('match.col.fixture_count')" width="88" align="center" />
<el-table-column :label="t('match.col.bet_count')" width="72" align="center">
<template #default="{ row }">
<span :class="{ 'bet-stat-active': leagueBetCount(row) > 0 }">{{ leagueBetCount(row) }}</span>
<span :class="{ 'bet-stat-active': row.displayBetCountActive }">{{ row.displayBetCount }}</span>
</template>
</el-table-column>
<el-table-column :label="t('match.col.total_stake')" width="108" align="right">
<template #default="{ row }">{{ leagueTotalStake(row) }}</template>
</el-table-column>
<el-table-column prop="displayTotalStake" :label="t('match.col.total_stake')" width="108" align="right" />
<el-table-column :label="t('match.col.pending_bets')" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="leaguePendingBets(row) > 0" type="warning" size="small" effect="plain">
{{ leaguePendingBets(row) }}
<el-tag v-if="row.displayPendingBets > 0" type="warning" size="small" effect="plain">
{{ row.displayPendingBets }}
</el-tag>
<span v-else class="bet-stat-zero">0</span>
</template>
</el-table-column>
<el-table-column :label="t('match.col.league_code')" width="120" show-overflow-tooltip>
<template #default="{ row }">{{ rowOf(row).code }}</template>
</el-table-column>
<el-table-column width="280" align="center" fixed="right">
<template #header>
<div class="actions-col-header">
<span class="actions-col-header__label">{{ t('common.actions') }}</span>
</div>
</template>
<el-table-column prop="displayCode" :label="t('match.col.league_code')" width="120" show-overflow-tooltip />
<el-table-column :label="t('common.actions')" width="280" align="center" fixed="right">
<template #default="{ row }">
<div class="league-row-actions">
<div class="league-action-group">
<el-button size="small" type="primary" @click.stop="openEditLeague(row)">
{{ t('common.edit') }}
</el-button>
<el-button size="small" type="primary" @click.stop="openCreateFixture(row)">
{{ t('match.create_fixture_btn') }}
</el-button>
<el-button
v-if="!leagueIsPublished(row)"
size="small"
type="success"
:loading="publishingLeagueId === leagueId(row)"
@click.stop="toggleLeaguePublish(row)"
>
{{ t('common.publish') }}
</el-button>
<el-button
v-else
size="small"
type="warning"
:loading="publishingLeagueId === leagueId(row)"
@click.stop="toggleLeaguePublish(row)"
>
{{ t('league.btn.unpublish') }}
</el-button>
<el-button size="small" type="danger" plain @click.stop="openLeagueArchive(row)">
{{ t('common.delete') }}
</el-button>
</div>
</div>
<LeagueRowActions
:row="row"
@edit="() => openEditLeague(row)"
@create-fixture="() => openCreateFixture(row)"
@toggle-publish="() => toggleLeaguePublish(row)"
@archive="() => openLeagueArchive(row)"
/>
</template>
</el-table-column>
</el-table>
@@ -675,9 +663,22 @@ function onLeagueArchived() {
@archived="onLeagueArchived"
/>
</div>
</div>
</template>
<style scoped>
.matches-shell {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.matches-shell > :deep(.league-matches-page) {
flex: 1;
min-height: 0;
}
.team-country-select {
width: 100%;
}
@@ -727,19 +728,10 @@ function onLeagueArchived() {
width: 100% !important;
}
.matches-page :deep(.el-table__expanded-cell) {
padding: 0 !important;
background: #fbfaf7;
}
.list-panel :deep(.row-expandable) {
.list-panel :deep(.row-navigable) {
cursor: pointer;
}
.list-panel :deep(.row-no-expand .el-table__expand-icon) {
visibility: hidden;
pointer-events: none;
}
.matchup-link {
color: var(--green-text);
@@ -752,12 +744,6 @@ function onLeagueArchived() {
color: #aaa49a;
}
.league-cell {
display: flex;
align-items: center;
gap: 8px;
}
.league-logo {
width: 28px;
height: 28px;
@@ -765,6 +751,15 @@ function onLeagueArchived() {
flex-shrink: 0;
}
.matches-page .table-wrap :deep(.league-name-cell .cell) {
color: var(--primary-link);
font-weight: 600;
}
.matches-page .table-wrap :deep(.league-logo-cell .cell) {
padding: 0 4px;
}
.league-en {
color: var(--text-muted);
font-size: 13px;
@@ -775,35 +770,6 @@ function onLeagueArchived() {
font-weight: 700;
}
.actions-col-header {
display: inline-flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 4px;
box-sizing: border-box;
white-space: nowrap;
}
.actions-col-header__label {
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
line-height: 1;
}
.actions-col-header :deep(.el-button) {
margin: 0 !important;
padding: 6px 10px !important;
height: 28px !important;
min-height: 28px !important;
font-size: 12px !important;
font-weight: 600;
border-radius: 6px;
white-space: nowrap;
}
.matches-page .table-wrap :deep(.el-table__header .el-table__cell) {
padding: 6px 0;
}
@@ -812,41 +778,6 @@ function onLeagueArchived() {
overflow: visible;
}
.league-row-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 6px 8px;
flex-wrap: wrap;
}
.league-action-group {
display: inline-flex;
flex-wrap: wrap;
gap: 4px;
padding: 2px 4px;
border-radius: 8px;
background: #fbfaf7;
border: 1px solid var(--border-soft);
}
.league-row-actions :deep(.el-button) {
margin: 0 !important;
min-width: 52px;
padding: 4px 10px !important;
font-size: 12px !important;
border-radius: 6px;
}
.league-row-actions :deep(.el-button:not(.is-disabled):not(:disabled)) {
cursor: pointer;
}
.league-row-actions :deep(.el-button.is-disabled),
.league-row-actions :deep(.el-button:disabled) {
cursor: not-allowed;
}
:deep(.logo-url-field) {
width: 100%;
}
@@ -887,14 +818,6 @@ function onLeagueArchived() {
gap: 12px;
}
.actions-col-header {
flex-wrap: wrap;
justify-content: flex-start;
}
.league-row-actions {
justify-content: flex-start;
}
}
</style>