feat(admin,player,api): 公共管理与优胜冠军国旗、玩家端内容对接

新增公共内容 CRUD 与批量操作;公告滚动合并管理;优胜冠军内置国家选择与单行保存;玩家端统一 usePlayerHome 对接轮播与跑马灯。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-04 10:25:42 +08:00
parent 27580b2479
commit f76728dc3e
21 changed files with 1966 additions and 136 deletions

View File

@@ -0,0 +1,633 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import type { TableInstance } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
import api from '../api';
const { t, localeTag } = useAdminLocale();
type StoredContentType = 'BANNER' | 'NOTICE' | 'TICKER';
type AdminTab = 'BANNER' | 'ANNOUNCEMENT';
type ContentStatus = 'DRAFT' | 'ACTIVE' | 'INACTIVE';
interface TranslationForm {
locale: string;
title: string;
body: string;
imageUrl: string;
}
interface ContentItem {
id: string;
contentType: StoredContentType;
sortOrder: number;
status: ContentStatus;
linkType: string | null;
linkTarget: string | null;
startTime: string | null;
endTime: string | null;
previewTitle: string;
previewImageUrl: string | null;
playerVisible: boolean;
playerHiddenReason: string | null;
translations: TranslationForm[];
}
const ADMIN_TABS: AdminTab[] = ['BANNER', 'ANNOUNCEMENT'];
const LOCALES = ['zh-CN', 'en-US', 'ms-MY'] as const;
const activeType = ref<AdminTab>('BANNER');
const filterStatus = ref<ContentStatus | ''>('');
const loading = ref(false);
const saving = ref(false);
const items = ref<ContentItem[]>([]);
const tableRef = ref<TableInstance>();
const selectedRows = ref<ContentItem[]>([]);
const hasSelection = computed(() => selectedRows.value.length > 0);
const dialogVisible = ref(false);
const editingId = ref<string | null>(null);
const editingContentType = ref<StoredContentType>('NOTICE');
const form = ref({
sortOrder: 0,
status: 'DRAFT' as ContentStatus,
linkType: '' as '' | 'ROUTE' | 'URL',
linkTarget: '',
startTime: '' as string,
endTime: '' as string,
translations: emptyTranslations(),
});
function emptyTranslations(): TranslationForm[] {
return LOCALES.map((locale) => ({
locale,
title: '',
body: '',
imageUrl: '',
}));
}
function localeLabel(code: string) {
const key = `content.locale.${code}`;
const label = t(key);
return label === key ? code : label;
}
function statusLabel(status: string) {
const key = `content.status.${status}`;
const label = t(key);
return label === key ? status : label;
}
function statusTagType(status: string) {
if (status === 'ACTIVE') return 'success';
if (status === 'DRAFT') return 'info';
return 'warning';
}
function hiddenTip(reason: string | null) {
if (!reason) return '';
const key = `content.hidden_reason.${reason}`;
const label = t(key);
return label === key ? reason : label;
}
function formatTime(v: string | null) {
if (!v) return '—';
return new Date(v).toLocaleString(localeTag.value, {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
const isBanner = computed(() => activeType.value === 'BANNER');
const isAnnouncement = computed(() => activeType.value === 'ANNOUNCEMENT');
const dialogTitle = computed(() =>
editingId.value ? t('content.dialog.edit') : t('content.dialog.create'),
);
async function load() {
loading.value = true;
try {
const { data } = await api.get('/admin/contents', {
params: {
type: activeType.value,
status: filterStatus.value || undefined,
},
});
items.value = data.data ?? [];
selectedRows.value = [];
tableRef.value?.clearSelection();
} 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([activeType, filterStatus], () => {
selectedRows.value = [];
tableRef.value?.clearSelection();
void load();
});
function onSelectionChange(rows: ContentItem[]) {
selectedRows.value = rows;
}
async function runBatch(
action: (row: ContentItem) => Promise<void>,
confirmKey?: string,
) {
const rows = [...selectedRows.value];
if (!rows.length) return;
if (confirmKey) {
try {
await ElMessageBox.confirm(t(confirmKey, { n: rows.length }), { type: 'warning' });
} catch {
return;
}
}
saving.value = true;
let ok = 0;
let fail = 0;
try {
for (const row of rows) {
try {
await action(row);
ok += 1;
} catch {
fail += 1;
}
}
if (fail === 0) {
ElMessage.success(t('content.batch.all_ok', { n: ok }));
} else {
ElMessage.warning(t('content.batch.partial', { ok, fail }));
}
await load();
} finally {
saving.value = false;
}
}
function batchEnable() {
void runBatch(
(row) => api.patch(`/admin/contents/${row.id}/status`, { status: 'ACTIVE' }),
'content.confirm_batch_enable',
);
}
function batchDisable() {
void runBatch(
(row) => api.patch(`/admin/contents/${row.id}/status`, { status: 'INACTIVE' }),
'content.confirm_batch_disable',
);
}
function batchDelete() {
void runBatch(
(row) => api.delete(`/admin/contents/${row.id}`),
'content.confirm_batch_delete',
);
}
function resetForm() {
form.value = {
sortOrder: items.value.length + 1,
status: 'DRAFT',
linkType: '',
linkTarget: '',
startTime: '',
endTime: '',
translations: emptyTranslations(),
};
}
function openCreate() {
editingId.value = null;
resetForm();
dialogVisible.value = true;
}
function openEdit(row: ContentItem) {
editingId.value = row.id;
editingContentType.value = row.contentType;
const byLocale = new Map(row.translations.map((tr) => [tr.locale, tr]));
form.value = {
sortOrder: row.sortOrder,
status: row.status,
linkType: (row.linkType as '' | 'ROUTE' | 'URL') || '',
linkTarget: row.linkTarget ?? '',
startTime: row.startTime ? row.startTime.slice(0, 19) : '',
endTime: row.endTime ? row.endTime.slice(0, 19) : '',
translations: LOCALES.map((locale) => {
const tr = byLocale.get(locale);
return {
locale,
title: tr?.title ?? '',
body: tr?.body ?? '',
imageUrl: tr?.imageUrl ?? '',
};
}),
};
dialogVisible.value = true;
}
function buildPayload() {
const contentType: StoredContentType = editingId.value
? editingContentType.value
: isBanner.value
? 'BANNER'
: 'NOTICE';
return {
contentType,
sortOrder: form.value.sortOrder,
status: form.value.status,
linkType: isBanner.value && form.value.linkType ? form.value.linkType : null,
linkTarget:
isBanner.value && form.value.linkType ? form.value.linkTarget.trim() : null,
startTime: form.value.startTime || null,
endTime: form.value.endTime || null,
translations: form.value.translations.map((tr) => ({
locale: tr.locale,
title: tr.title.trim() || undefined,
body: tr.body.trim() || undefined,
imageUrl: tr.imageUrl.trim() || undefined,
})),
};
}
async function submitForm() {
saving.value = true;
try {
const payload = buildPayload();
if (editingId.value) {
const { contentType: _type, ...updateBody } = payload;
await api.put(`/admin/contents/${editingId.value}`, updateBody);
} else {
await api.post('/admin/contents', payload);
}
ElMessage.success(t('msg.saved'));
dialogVisible.value = false;
await load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string; message?: string | string[] } } };
const msg = err.response?.data?.error
?? (Array.isArray(err.response?.data?.message)
? err.response?.data?.message.join(', ')
: err.response?.data?.message)
?? t('msg.save_failed');
ElMessage.error(String(msg));
} finally {
saving.value = false;
}
}
async function setStatus(row: ContentItem, status: ContentStatus) {
saving.value = true;
try {
await api.patch(`/admin/contents/${row.id}/status`, { status });
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 removeItem(row: ContentItem) {
try {
await ElMessageBox.confirm(
t('content.confirm_delete', { title: row.previewTitle || row.id }),
{ type: 'warning' },
);
} catch {
return;
}
saving.value = true;
try {
await api.delete(`/admin/contents/${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 {
saving.value = false;
}
}
void load();
</script>
<template>
<div class="admin-list-page contents-page">
<el-card class="filter-card" shadow="never">
<el-tabs v-model="activeType" class="type-tabs">
<el-tab-pane
v-for="tp in ADMIN_TABS"
:key="tp"
:label="t(`content.type.${tp}`)"
:name="tp"
/>
</el-tabs>
<p v-if="isAnnouncement" class="type-hint">{{ t('content.hint.announcement') }}</p>
<el-form inline class="filter-row">
<el-form-item :label="t('common.status')">
<el-select v-model="filterStatus" clearable style="width: 140px">
<el-option :label="t('common.all')" value="" />
<el-option
v-for="st in ['DRAFT', 'ACTIVE', 'INACTIVE']"
:key="st"
:label="statusLabel(st)"
:value="st"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" size="small" @click="load">{{ t('common.search') }}</el-button>
<el-button type="primary" plain size="small" @click="openCreate">
{{ t('content.btn.create') }}
</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card v-loading="loading" class="data-card" shadow="never">
<div v-if="hasSelection" class="table-toolbar">
<span class="batch-hint">{{ t('content.batch.selected', { n: selectedRows.length }) }}</span>
<el-button size="small" :disabled="saving" @click="batchEnable">
{{ t('content.batch.enable') }}
</el-button>
<el-button size="small" :disabled="saving" @click="batchDisable">
{{ t('content.batch.disable') }}
</el-button>
<el-button size="small" type="danger" :disabled="saving" @click="batchDelete">
{{ t('content.batch.delete') }}
</el-button>
</div>
<div class="table-wrap">
<el-table
ref="tableRef"
:data="items"
row-key="id"
stripe
size="small"
empty-text=""
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="44" :selectable="() => !saving" />
<el-table-column prop="sortOrder" :label="t('content.col.sort')" width="64" align="center" />
<el-table-column v-if="isBanner" :label="t('content.col.preview')" width="88" align="center">
<template #default="{ row }">
<img
v-if="row.previewImageUrl"
:src="row.previewImageUrl"
alt=""
class="thumb"
/>
<span v-else class="thumb-empty"></span>
</template>
</el-table-column>
<el-table-column :label="t('content.col.title')" min-width="160">
<template #default="{ row }">
<span class="preview-title">{{ row.previewTitle || '—' }}</span>
<p v-if="!row.playerVisible && row.playerHiddenReason" class="hidden-tip">
{{ hiddenTip(row.playerHiddenReason) }}
</p>
</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="96" align="center">
<template #default="{ row }">
<el-tag size="small" :type="statusTagType(row.status)" effect="dark">
{{ statusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('content.col.player_visible')" width="88" 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('content.col.schedule')" min-width="140">
<template #default="{ row }">
<span class="schedule-line">{{ formatTime(row.startTime) }}</span>
<span class="schedule-sep"></span>
<span class="schedule-line">{{ formatTime(row.endTime) }}</span>
</template>
</el-table-column>
<el-table-column v-if="isBanner" :label="t('content.col.link')" min-width="120">
<template #default="{ row }">
<template v-if="row.linkType">
{{ row.linkType }} · {{ row.linkTarget || '' }}
</template>
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="200" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">
{{ t('common.edit') }}
</el-button>
<el-button
v-if="row.status !== 'ACTIVE'"
link
type="success"
:disabled="saving"
@click="setStatus(row, 'ACTIVE')"
>
{{ t('content.btn.enable') }}
</el-button>
<el-button
v-else
link
type="warning"
:disabled="saving"
@click="setStatus(row, 'INACTIVE')"
>
{{ t('content.btn.disable') }}
</el-button>
<el-button link type="danger" :disabled="saving" @click="removeItem(row)">
{{ t('common.delete') }}
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="640px" destroy-on-close>
<el-form label-width="96px" size="small">
<el-form-item :label="t('content.col.sort')">
<el-input-number v-model="form.sortOrder" :min="0" :step="1" />
</el-form-item>
<el-form-item :label="t('common.status')">
<el-select v-model="form.status" style="width: 160px">
<el-option
v-for="st in ['DRAFT', 'ACTIVE', 'INACTIVE']"
:key="st"
:label="statusLabel(st)"
:value="st"
/>
</el-select>
</el-form-item>
<template v-if="isBanner">
<el-form-item :label="t('content.field.link_type')">
<el-select v-model="form.linkType" clearable style="width: 160px">
<el-option :label="t('content.link.none')" value="" />
<el-option label="ROUTE" value="ROUTE" />
<el-option label="URL" value="URL" />
</el-select>
</el-form-item>
<el-form-item v-if="form.linkType" :label="t('content.field.link_target')">
<el-input
v-model="form.linkTarget"
:placeholder="form.linkType === 'ROUTE' ? '/football' : 'https://'"
/>
</el-form-item>
</template>
<el-form-item :label="t('content.field.start_time')">
<el-date-picker
v-model="form.startTime"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ss"
clearable
style="width: 100%"
/>
</el-form-item>
<el-form-item :label="t('content.field.end_time')">
<el-date-picker
v-model="form.endTime"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ss"
clearable
style="width: 100%"
/>
</el-form-item>
<div v-for="tr in form.translations" :key="tr.locale" class="locale-block">
<div class="locale-head">{{ localeLabel(tr.locale) }}</div>
<el-form-item :label="t('content.field.title')">
<el-input v-model="tr.title" :placeholder="t('content.field.title_ph')" />
</el-form-item>
<el-form-item
v-if="isBanner"
:label="t('content.field.image_url')"
:required="form.status === 'ACTIVE'"
>
<el-input v-model="tr.imageUrl" placeholder="/uploads/banners/welcome.svg" />
</el-form-item>
<el-form-item
:label="isAnnouncement ? t('content.field.announce_text') : t('content.field.body')"
:required="isAnnouncement && form.status === 'ACTIVE'"
>
<el-input v-model="tr.body" type="textarea" :rows="isAnnouncement ? 2 : 3" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="saving" @click="submitForm">
{{ t('common.save') }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.contents-page .type-tabs :deep(.el-tabs__header) {
margin-bottom: 12px;
}
.table-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding: 10px 12px;
border-bottom: 1px solid #222;
flex-shrink: 0;
}
.batch-hint {
font-size: 12px;
color: #888;
margin-right: 4px;
}
.type-hint {
margin: 0 0 8px;
font-size: 12px;
color: #666;
line-height: 1.5;
}
.filter-row {
margin-top: 4px;
}
.thumb {
width: 56px;
height: 32px;
object-fit: cover;
border-radius: 4px;
background: #222;
}
.thumb-empty {
color: #555;
font-size: 12px;
}
.preview-title {
font-size: 13px;
color: #ccc;
}
.hidden-tip {
margin: 4px 0 0;
font-size: 11px;
color: #c9a227;
}
.schedule-line {
font-size: 11px;
color: #888;
}
.schedule-sep {
margin: 0 4px;
color: #555;
font-size: 11px;
}
.locale-block {
margin-top: 12px;
padding: 10px 12px;
border: 1px solid #252525;
border-radius: 8px;
background: rgba(255, 255, 255, 0.02);
}
.locale-head {
font-size: 12px;
font-weight: 700;
color: #888;
margin-bottom: 8px;
}
</style>

View File

@@ -4,6 +4,13 @@ import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../../composables/useAdminLocale';
import api from '../../api';
import CountryFlagSelect from '../../components/outright/CountryFlagSelect.vue';
import {
countryFlagUrl,
getBuiltinCountry,
resolveCountryCode,
type BuiltinCountry,
} from '../../data/builtinCountries';
const route = useRoute();
const router = useRouter();
@@ -20,11 +27,14 @@ interface SelectionRow {
odds: string;
oddsVersion: string;
status: string;
logoUrl: string | null;
editOdds: number;
editCountryCode: string;
}
const loading = ref(false);
const saving = ref(false);
const savingRowId = ref<string | null>(null);
const meta = ref({
leagueZh: '',
leagueEn: '',
@@ -39,12 +49,29 @@ const selections = ref<SelectionRow[]>([]);
const addVisible = ref(false);
const addForm = ref({
countryCode: '',
teamCode: '',
teamZh: '',
teamEn: '',
odds: 10,
});
function applyCountry(target: {
countryCode: string;
teamCode: string;
teamZh: string;
teamEn: string;
}, country: BuiltinCountry) {
target.countryCode = country.code;
target.teamCode = country.code;
target.teamZh = country.nameZh;
target.teamEn = country.nameEn;
}
function onAddCountryPick(country: BuiltinCountry) {
applyCountry(addForm.value, country);
}
async function load() {
if (!matchId.value) return;
loading.value = true;
@@ -59,7 +86,7 @@ async function load() {
expectedCanonicalCount: number | null;
playerVisible: boolean;
playerHiddenReason: string | null;
selections: SelectionRow[];
selections: Array<SelectionRow & { logoUrl?: string | null }>;
};
meta.value = {
leagueZh: payload.leagueZh,
@@ -73,7 +100,9 @@ async function load() {
};
selections.value = payload.selections.map((s) => ({
...s,
logoUrl: s.logoUrl ?? null,
editOdds: Number(s.odds),
editCountryCode: resolveCountryCode(s.teamCode, s.logoUrl ?? null),
}));
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
@@ -127,21 +156,27 @@ async function saveAllOdds() {
}
async function submitAdd() {
if (!addForm.value.teamCode.trim()) {
ElMessage.warning(t('outright.err_team_code'));
if (!addForm.value.countryCode) {
ElMessage.warning(t('outright.err_country'));
return;
}
const country = getBuiltinCountry(addForm.value.countryCode);
if (!country) {
ElMessage.warning(t('outright.err_country'));
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,
teamCode: country.code,
teamZh: country.nameZh,
teamEn: country.nameEn,
logoUrl: countryFlagUrl(country),
odds: addForm.value.odds,
});
ElMessage.success(t('msg.saved'));
addVisible.value = false;
addForm.value = { teamCode: '', teamZh: '', teamEn: '', odds: 10 };
addForm.value = { countryCode: '', teamCode: '', teamZh: '', teamEn: '', odds: 10 };
await load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
@@ -200,6 +235,71 @@ async function saveMetaWithStatus(status: 'DRAFT' | 'PUBLISHED') {
meta.value.status = status;
await saveMeta();
}
function rowDisplayName(row: SelectionRow, field: 'zh' | 'en') {
const picked = getBuiltinCountry(row.editCountryCode);
if (picked && row.editCountryCode !== resolveCountryCode(row.teamCode, row.logoUrl)) {
return field === 'zh' ? picked.nameZh : picked.nameEn;
}
return field === 'zh' ? row.teamZh : row.teamEn;
}
function rowDisplayCode(row: SelectionRow) {
const picked = getBuiltinCountry(row.editCountryCode);
if (picked && row.editCountryCode !== resolveCountryCode(row.teamCode, row.logoUrl)) {
return picked.code;
}
return row.teamCode;
}
function isRowDirty(row: SelectionRow) {
const countryDirty =
row.editCountryCode !== resolveCountryCode(row.teamCode, row.logoUrl);
const oddsDirty = row.editOdds !== Number(row.odds);
return countryDirty || oddsDirty;
}
async function saveRow(row: SelectionRow) {
if (!isRowDirty(row)) return;
if (!row.editOdds || row.editOdds <= 1) {
ElMessage.warning(t('outright.err_odds_min'));
return;
}
savingRowId.value = row.id;
try {
const country = getBuiltinCountry(row.editCountryCode);
const countryDirty =
row.editCountryCode !== resolveCountryCode(row.teamCode, row.logoUrl);
if (countryDirty) {
if (!country) {
ElMessage.warning(t('outright.err_country'));
return;
}
await api.patch(`/admin/outrights/${matchId.value}/selections/${row.id}`, {
teamCode: country.code,
teamZh: country.nameZh,
teamEn: country.nameEn,
logoUrl: countryFlagUrl(country),
});
}
if (row.editOdds !== Number(row.odds)) {
await api.put(`/admin/outrights/${matchId.value}/odds`, {
updates: [{ selectionId: row.id, odds: row.editOdds }],
});
}
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 {
savingRowId.value = null;
}
}
</script>
<template>
@@ -269,9 +369,23 @@ async function saveMetaWithStatus(status: 'DRAFT' | 'PUBLISHED') {
<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.country')" min-width="220">
<template #default="{ row }">
<CountryFlagSelect
v-model="row.editCountryCode"
:disabled="!!savingRowId"
/>
</template>
</el-table-column>
<el-table-column :label="t('outright.col.team_zh')" min-width="120">
<template #default="{ row }">{{ rowDisplayName(row, 'zh') }}</template>
</el-table-column>
<el-table-column :label="t('outright.col.team_en')" min-width="140">
<template #default="{ row }">{{ rowDisplayName(row, 'en') }}</template>
</el-table-column>
<el-table-column :label="t('outright.col.code')" width="88">
<template #default="{ row }">{{ rowDisplayCode(row) }}</template>
</el-table-column>
<el-table-column :label="t('outright.col.odds')" width="160" align="right">
<template #default="{ row }">
<el-input-number
@@ -285,9 +399,23 @@ async function saveMetaWithStatus(status: 'DRAFT' | 'PUBLISHED') {
/>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="88" align="center">
<el-table-column :label="t('common.actions')" width="120" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="danger" @click="removeSelection(row)">
<el-button
link
type="primary"
:loading="savingRowId === row.id"
:disabled="!isRowDirty(row) || (!!savingRowId && savingRowId !== row.id)"
@click="saveRow(row)"
>
{{ t('common.save') }}
</el-button>
<el-button
link
type="danger"
:disabled="!!savingRowId"
@click="removeSelection(row)"
>
{{ t('common.delete') }}
</el-button>
</template>
@@ -303,16 +431,23 @@ async function saveMetaWithStatus(status: 'DRAFT' | 'PUBLISHED') {
</div>
</section>
<el-dialog v-model="addVisible" :title="t('outright.btn.add_team')" width="420px">
<el-dialog v-model="addVisible" :title="t('outright.btn.add_team')" width="440px">
<el-form label-width="100px">
<el-form-item :label="t('outright.col.code')">
<el-input v-model="addForm.teamCode" placeholder="FRA" />
<el-form-item :label="t('outright.col.country')" required>
<CountryFlagSelect
v-model="addForm.countryCode"
size="default"
@pick="onAddCountryPick"
/>
</el-form-item>
<el-form-item :label="t('outright.col.team_zh')">
<el-input v-model="addForm.teamZh" />
<el-form-item v-if="addForm.teamCode" :label="t('outright.col.code')">
<span class="readonly-field">{{ addForm.teamCode }}</span>
</el-form-item>
<el-form-item :label="t('outright.col.team_en')">
<el-input v-model="addForm.teamEn" />
<el-form-item v-if="addForm.teamZh" :label="t('outright.col.team_zh')">
<span class="readonly-field">{{ addForm.teamZh }}</span>
</el-form-item>
<el-form-item v-if="addForm.teamEn" :label="t('outright.col.team_en')">
<span class="readonly-field">{{ addForm.teamEn }}</span>
</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" />
@@ -430,4 +565,9 @@ async function saveMetaWithStatus(status: 'DRAFT' | 'PUBLISHED') {
min-height: 80px;
overflow: auto;
}
.readonly-field {
font-size: 13px;
color: #aaa;
}
</style>