feat(i18n): 管理端与玩家端三语支持(中/英/马来语)

- 管理后台 adminT 文案库、结算与代理端页面、表单校验
- 玩家端 vue-i18n 补全首页/公告/串关与 ms 文案
- Element Plus ms 语言包与共享 locale 工具
This commit is contained in:
2026-06-03 15:05:36 +08:00
parent 80adc0e928
commit cbfa18d1d3
63 changed files with 3081 additions and 1038 deletions

View File

@@ -1,8 +1,13 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { 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';
const { t } = useAdminLocale();
import {
emptyMatchForm,
buildPlatformPayload,
@@ -74,7 +79,7 @@ async function openEdit(id: string) {
const { data } = await api.get(`/admin/matches/${id}`);
const detail = data.data as AdminMatchDetail;
if (detail.isOutright) {
ElMessage.warning('冠军盘不支持在此编辑');
ElMessage.warning(t('msg.outright_no_edit'));
return;
}
editingId.value = id;
@@ -83,7 +88,7 @@ async function openEdit(id: string) {
editVisible.value = true;
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? '加载赛事失败');
ElMessage.error(err.response?.data?.error ?? t('msg.load_matches_failed'));
}
}
@@ -92,18 +97,18 @@ async function submitCreate() {
try {
payload = buildPlatformPayload(form.value);
} catch (e) {
ElMessage.warning(e instanceof Error ? e.message : '请检查表单');
ElMessage.warning(resolveFormError(e, t));
return;
}
createLoading.value = true;
try {
await api.post('/admin/matches', payload);
ElMessage.success('赛事已创建(草稿)');
ElMessage.success(t('msg.match_created_draft'));
createVisible.value = false;
load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? '创建失败');
ElMessage.error(err.response?.data?.error ?? t('msg.create_failed'));
} finally {
createLoading.value = false;
}
@@ -114,18 +119,18 @@ async function submitEdit() {
try {
payload = buildPlatformPayload(form.value);
} catch (e) {
ElMessage.warning(e instanceof Error ? e.message : '请检查表单');
ElMessage.warning(resolveFormError(e, t));
return;
}
editLoading.value = true;
try {
await api.put(`/admin/matches/${editingId.value}`, payload);
ElMessage.success('已保存');
ElMessage.success(t('msg.saved'));
editVisible.value = false;
load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? '保存失败');
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
editLoading.value = false;
}
@@ -135,18 +140,18 @@ async function confirmDelete(row: unknown) {
const id = matchId(row);
const title = matchTitle(row);
try {
await ElMessageBox.confirm(`确定删除赛事「${title}」?仅草稿且无注单时可删除。`, '删除确认', {
await ElMessageBox.confirm(t('match.delete_confirm_body', { title }), t('match.delete_confirm_title'), {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
confirmButtonText: t('common.delete'),
cancelButtonText: t('common.cancel'),
});
await api.delete(`/admin/matches/${id}`);
ElMessage.success('已删除');
ElMessage.success(t('msg.deleted'));
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 ?? '删除失败');
ElMessage.error(err.response?.data?.error ?? t('msg.delete_failed'));
}
}
@@ -155,7 +160,7 @@ async function submitImport() {
try {
payload = JSON.parse(importJson.value);
} catch {
ElMessage.error('JSON 格式无效');
ElMessage.error(t('msg.invalid_json'));
return;
}
importLoading.value = true;
@@ -168,13 +173,18 @@ async function submitImport() {
total: number;
};
ElMessage.success(
`导入完成:成功 ${r.imported},跳过 ${r.skipped},失败 ${r.failed} / 共 ${r.total}`,
t('msg.import_done', {
imported: r.imported,
skipped: r.skipped,
failed: r.failed,
total: r.total,
}),
);
importVisible.value = false;
load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? '导入失败');
ElMessage.error(err.response?.data?.error ?? t('msg.import_failed'));
} finally {
importLoading.value = false;
}
@@ -183,15 +193,26 @@ async function submitImport() {
async function publish(id: string) {
await api.post(`/admin/matches/${id}/publish`);
await api.post(`/admin/matches/${id}/markets/templates`, {
marketTypes: ['FT_1X2', 'FT_HANDICAP', 'FT_OVER_UNDER', 'FT_ODD_EVEN'],
marketTypes: [
'FT_1X2',
'FT_HANDICAP',
'FT_OVER_UNDER',
'FT_ODD_EVEN',
'HT_1X2',
'HT_HANDICAP',
'HT_OVER_UNDER',
'FT_CORRECT_SCORE',
'HT_CORRECT_SCORE',
'SH_CORRECT_SCORE',
],
});
ElMessage.success('已发布并生成盘口');
ElMessage.success(t('msg.published'));
load();
}
async function close(id: string) {
await api.post(`/admin/matches/${id}/close`);
ElMessage.success('已封盘');
ElMessage.success(t('msg.closed'));
load();
}
@@ -200,12 +221,11 @@ function settle(id: string) {
}
type TagType = '' | 'info' | 'success' | 'warning' | 'danger';
const statusLabels: Record<string, string> = {
DRAFT: '草稿',
PUBLISHED: '已发布',
CLOSED: '已封盘',
SETTLED: '已结算',
};
function matchStatusText(status: string) {
const key = `match.status.${status}`;
const v = t(key);
return v !== key ? v : status;
}
const statusTagTypes: Record<string, TagType> = {
DRAFT: 'info',
PUBLISHED: 'warning',
@@ -220,7 +240,7 @@ function matchStatus(row: unknown) {
return String(rowOf(row).status ?? '');
}
function matchStatusLabel(row: unknown) {
return statusLabels[matchStatus(row)] ?? matchStatus(row);
return matchStatusText(matchStatus(row));
}
function matchStatusType(row: unknown): TagType {
return statusTagTypes[matchStatus(row)] ?? 'info';
@@ -254,36 +274,36 @@ function canDelete(row: unknown) {
<div class="admin-list-page">
<div class="page-header">
<div>
<h2 class="page-title">赛事管理</h2>
<span class="page-desc">草稿可编辑删除已发布可改开赛时间与热门</span>
<h2 class="page-title">{{ t('page.matches.title') }}</h2>
<span class="page-desc">{{ t('page.matches.desc') }}</span>
</div>
<div class="header-actions">
<el-button @click="openImport">导入</el-button>
<el-button type="primary" @click="openCreate">+ 新增赛事</el-button>
<el-button @click="openImport">{{ t('common.import') }}</el-button>
<el-button type="primary" @click="openCreate">{{ t('match.create_btn') }}</el-button>
</div>
</div>
<el-card class="filter-card" shadow="never">
<el-form inline>
<el-form-item label="关键词">
<el-form-item :label="t('common.keyword')">
<el-input
v-model="keyword"
placeholder="赛事名 / 球队代码"
:placeholder="t('match.filter.keyword_ph')"
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-form-item :label="t('common.status')">
<el-select v-model="filterStatus" :placeholder="t('common.all')" clearable style="width: 120px">
<el-option :label="t('match.status.DRAFT')" value="DRAFT" />
<el-option :label="t('match.status.PUBLISHED')" value="PUBLISHED" />
<el-option :label="t('match.status.CLOSED')" value="CLOSED" />
<el-option :label="t('match.status.SETTLED')" value="SETTLED" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="load">查询</el-button>
<el-button type="primary" @click="load">{{ t('common.search') }}</el-button>
</el-form-item>
</el-form>
</el-card>
@@ -292,18 +312,18 @@ function canDelete(row: unknown) {
<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">
<el-table-column :label="t('match.col.matchup')" min-width="200">
<template #default="{ row }">{{ matchTitle(row) }}</template>
</el-table-column>
<el-table-column label="状态" width="96">
<el-table-column :label="t('common.status')" 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">
<el-table-column :label="t('match.col.kickoff')" min-width="160">
<template #default="{ row }">{{ matchTime(row) }}</template>
</el-table-column>
<el-table-column label="操作" width="340" align="center" fixed="right">
<el-table-column :label="t('common.actions')" width="340" align="center" fixed="right">
<template #default="{ row }">
<el-button
v-if="canEdit(row)"
@@ -311,7 +331,7 @@ function canDelete(row: unknown) {
plain
@click="openEdit(matchId(row))"
>
编辑
{{ t('common.edit') }}
</el-button>
<el-button
v-if="canDelete(row)"
@@ -320,7 +340,7 @@ function canDelete(row: unknown) {
plain
@click="confirmDelete(row)"
>
删除
{{ t('common.delete') }}
</el-button>
<el-button
v-if="matchStatus(row) === 'DRAFT'"
@@ -329,7 +349,7 @@ function canDelete(row: unknown) {
plain
@click="publish(matchId(row))"
>
发布
{{ t('common.publish') }}
</el-button>
<el-button
v-if="matchStatus(row) === 'PUBLISHED'"
@@ -338,10 +358,10 @@ function canDelete(row: unknown) {
plain
@click="close(matchId(row))"
>
封盘
{{ t('common.close_betting') }}
</el-button>
<el-button size="small" type="warning" plain @click="settle(matchId(row))">
结算
{{ t('common.settle') }}
</el-button>
</template>
</el-table-column>
@@ -361,87 +381,87 @@ function canDelete(row: unknown) {
</div>
</el-card>
<el-dialog v-model="createVisible" title="新增赛事" width="520px" destroy-on-close>
<el-dialog v-model="createVisible" :title="t('match.dialog.create')" 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 :label="t('match.field.league_en')">
<el-input v-model="form.leagueEn" :placeholder="t('match.ph.league_en')" />
</el-form-item>
<el-form-item label="联赛(中)">
<el-input v-model="form.leagueZh" placeholder="2026 世界杯" />
<el-form-item :label="t('match.field.league_zh')">
<el-input v-model="form.leagueZh" :placeholder="t('match.ph.league_zh')" />
</el-form-item>
<el-form-item label="开赛时间" required>
<el-input v-model="form.startTime" placeholder="2026-06-11T19:00:00Z" />
<el-form-item :label="t('match.field.kickoff')" required>
<el-input v-model="form.startTime" :placeholder="t('match.ph.kickoff')" />
</el-form-item>
<el-form-item label="主队(英)">
<el-input v-model="form.homeTeamEn" placeholder="Mexico" />
<el-form-item :label="t('match.field.home_en')">
<el-input v-model="form.homeTeamEn" :placeholder="t('match.ph.home_en')" />
</el-form-item>
<el-form-item label="主队(中)">
<el-input v-model="form.homeTeamZh" placeholder="墨西哥" />
<el-form-item :label="t('match.field.home_zh')">
<el-input v-model="form.homeTeamZh" :placeholder="t('match.ph.home_zh')" />
</el-form-item>
<el-form-item label="客队(英)">
<el-input v-model="form.awayTeamEn" placeholder="South Africa" />
<el-form-item :label="t('match.field.away_en')">
<el-input v-model="form.awayTeamEn" :placeholder="t('match.ph.away_en')" />
</el-form-item>
<el-form-item label="客队(中)">
<el-input v-model="form.awayTeamZh" placeholder="南非" />
<el-form-item :label="t('match.field.away_zh')">
<el-input v-model="form.awayTeamZh" :placeholder="t('match.ph.away_zh')" />
</el-form-item>
<el-form-item label="热门">
<el-form-item :label="t('match.field.featured')">
<el-switch v-model="form.isHot" />
</el-form-item>
<p class="field-hint">创建后为草稿请在列表点击发布并生成盘口</p>
<p class="field-hint">{{ t('match.hint.create_draft') }}</p>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="createLoading" @click="submitCreate">创建</el-button>
<el-button @click="createVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="createLoading" @click="submitCreate">{{ t('user.btn.create') }}</el-button>
</template>
</el-dialog>
<el-dialog v-model="editVisible" title="编辑赛事" width="520px" destroy-on-close>
<el-dialog v-model="editVisible" :title="t('match.dialog.edit')" width="520px" destroy-on-close>
<el-form label-width="96px">
<p v-if="isEditPublished" class="field-hint edit-hint">
已发布可修改开赛时间热门及显示名称封盘/已结算后不可编辑
{{ t('match.hint.edit_published') }}
</p>
<el-form-item label="联赛(英)">
<el-form-item :label="t('match.field.league_en')">
<el-input v-model="form.leagueEn" :disabled="isEditPublished" />
</el-form-item>
<el-form-item label="联赛(中)">
<el-form-item :label="t('match.field.league_zh')">
<el-input v-model="form.leagueZh" :disabled="isEditPublished" />
</el-form-item>
<el-form-item label="开赛时间" required>
<el-form-item :label="t('match.field.kickoff')" required>
<el-input v-model="form.startTime" />
</el-form-item>
<el-form-item label="主队(英)">
<el-form-item :label="t('match.field.home_en')">
<el-input v-model="form.homeTeamEn" :disabled="isEditPublished" />
</el-form-item>
<el-form-item label="主队(中)">
<el-form-item :label="t('match.field.home_zh')">
<el-input v-model="form.homeTeamZh" :disabled="isEditPublished" />
</el-form-item>
<el-form-item label="客队(英)">
<el-form-item :label="t('match.field.away_en')">
<el-input v-model="form.awayTeamEn" :disabled="isEditPublished" />
</el-form-item>
<el-form-item label="客队(中)">
<el-form-item :label="t('match.field.away_zh')">
<el-input v-model="form.awayTeamZh" :disabled="isEditPublished" />
</el-form-item>
<el-form-item label="热门">
<el-form-item :label="t('match.field.featured')">
<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>
<el-button @click="editVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="editLoading" @click="submitEdit">{{ t('common.save') }}</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-dialog v-model="importVisible" :title="t('match.dialog.import')" width="640px" destroy-on-close>
<p class="dialog-hint">{{ t('match.import_hint') }}</p>
<el-input
v-model="importJson"
type="textarea"
:rows="14"
placeholder='{"matches":[...]}'
:placeholder="t('match.import_json_ph')"
/>
<template #footer>
<el-button @click="importVisible = false">取消</el-button>
<el-button type="primary" :loading="importLoading" @click="submitImport">开始导入</el-button>
<el-button @click="importVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="importLoading" @click="submitImport">{{ t('match.import_start') }}</el-button>
</template>
</el-dialog>
</div>