feat: WC2026 赛事 seed、生产上线初始化脚本与目录归档

重构 seed 为 WC2026 72 场小组赛与 48 强优胜盘;新增 production 模式仅保留 admin 与赛事示例;提供 prod-init-db 全量重置脚本;管理端 i18n 分包与赛事归档能力。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-12 18:17:00 +08:00
parent 8f14e85ebd
commit e7e938f261
94 changed files with 12332 additions and 976 deletions

View File

@@ -7,9 +7,12 @@
"scripts": {
"dev": "vite --port 5174 --host",
"build": "vue-tsc -b && vite build",
"build:analyze": "vue-tsc -b && vite build --mode analyze && node scripts/report-chunk-sizes.mjs",
"build:report": "node scripts/report-chunk-sizes.mjs",
"preview": "vite preview"
},
"dependencies": {
"@thebet365/shared": "workspace:*",
"axios": "^1.7.9",
"echarts": "^6.1.0",
"element-plus": "^2.9.3",
@@ -20,6 +23,7 @@
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"rollup-plugin-visualizer": "^7.0.1",
"typescript": "^5.7.3",
"vite": "^6.0.11",
"vue-tsc": "^2.2.0"

View File

@@ -0,0 +1,32 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { gzipSync } from 'zlib';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dist = path.join(__dirname, '../dist/assets');
if (!fs.existsSync(dist)) {
console.error('dist/assets not found — run pnpm build first');
process.exit(1);
}
const files = fs
.readdirSync(dist)
.filter((f) => f.endsWith('.js'))
.map((f) => {
const buf = fs.readFileSync(path.join(dist, f));
return {
file: f,
raw: buf.length,
gzip: gzipSync(buf).length,
};
})
.sort((a, b) => b.gzip - a.gzip);
console.log('Admin bundle chunk sizes (gzip):');
for (const row of files) {
console.log(`${(row.gzip / 1024).toFixed(1)} KB gzip ${(row.raw / 1024).toFixed(1)} KB raw ${row.file}`);
}
const totalGzip = files.reduce((s, r) => s + r.gzip, 0);
console.log(`\nTotal JS (all chunks): ${(totalGzip / 1024).toFixed(1)} KB gzip`);

View File

@@ -0,0 +1,51 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.join(__dirname, '../src/i18n');
const am = fs.readFileSync(path.join(root, 'admin-messages.ts'), 'utf8');
const ap = fs.readFileSync(path.join(root, 'admin-pages.ts'), 'utf8');
const zhStart = am.indexOf('const zh:');
const enStart = am.indexOf('const en:');
const msStart = am.indexOf('const ms:');
const exportStart = am.indexOf('export const adminMessages');
const header = am.slice(0, zhStart);
const zhBody = am.slice(zhStart, enStart).replace(/^const zh/, 'const messages');
const enBody = am.slice(enStart, msStart).replace(/^const en/, 'const messages');
const msBody = am.slice(msStart, exportStart).replace(/^const ms/, 'const messages');
const apZhEnd = ap.indexOf('export const adminPagesEn');
function toDefaultExport(block, constName) {
const body = block
.replace(`export const ${constName}`, 'const adminPages')
.trimEnd();
return `${body}\n\nexport default adminPages;\n`;
}
const apZh = toDefaultExport(ap.slice(0, apZhEnd), 'adminPagesZh');
const apEn = toDefaultExport(ap.slice(apZhEnd), 'adminPagesEn');
fs.mkdirSync(path.join(root, 'bundles'), { recursive: true });
fs.mkdirSync(path.join(root, 'pages'), { recursive: true });
fs.writeFileSync(path.join(root, 'pages/zh.ts'), `${apZh.trim()}\n`);
fs.writeFileSync(path.join(root, 'pages/en.ts'), `${apEn.trim()}\n`);
const bundles = [
['zh-CN', zhBody, "import adminPages from '../pages/zh';"],
['en-US', enBody, "import adminPages from '../pages/en';"],
['ms-MY', msBody, "import adminPages from '../admin-pages-ms';"],
];
for (const [loc, body, pagesImport] of bundles) {
const content = `${pagesImport}\n${body.replace(/\.\.\.adminPages\w+/g, '...adminPages')}\nexport default { ...messages, ...adminPages };\n`;
fs.writeFileSync(path.join(root, 'bundles', `${loc}.ts`), content);
}
const typesOnly = header
.replace(/import \{ adminPagesEn, adminPagesZh \} from '\.\/admin-pages';\r?\n/, '')
.replace(/import \{ adminPagesMs \} from '\.\/admin-pages-ms';\r?\n/, '')
+ `export const adminMessages: Record<AdminLocale, Record<string, string>> = {\n 'zh-CN': {},\n 'en-US': {},\n 'ms-MY': {},\n};\n`;
fs.writeFileSync(path.join(root, 'admin-messages.ts'), typesOnly);
console.log('split i18n ok');

View File

@@ -1,23 +1,38 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, shallowRef, watch } from 'vue';
import { RouterView } from 'vue-router';
import { ElConfigProvider } from 'element-plus';
import zhCn from 'element-plus/es/locale/lang/zh-cn';
import en from 'element-plus/es/locale/lang/en';
import ms from 'element-plus/es/locale/lang/ms';
import type { Language } from 'element-plus/es/locale';
import { useAdminLocale } from './composables/useAdminLocale';
const { locale } = useAdminLocale();
const elLocale = shallowRef<Language | undefined>(undefined);
const elLocale = computed(() => {
if (locale.value.startsWith('zh')) return zhCn;
if (locale.value.startsWith('ms')) return ms;
return en;
});
async function loadElLocale(tag: string) {
if (tag.startsWith('zh')) {
elLocale.value = (await import('element-plus/es/locale/lang/zh-cn')).default;
return;
}
if (tag.startsWith('ms')) {
elLocale.value = (await import('element-plus/es/locale/lang/ms')).default;
return;
}
elLocale.value = (await import('element-plus/es/locale/lang/en')).default;
}
watch(
() => locale.value,
(tag) => {
void loadElLocale(tag);
},
{ immediate: true },
);
const configLocale = computed(() => elLocale.value);
</script>
<template>
<ElConfigProvider :locale="elLocale">
<ElConfigProvider :locale="configLocale">
<RouterView />
</ElConfigProvider>
</template>

View File

@@ -27,6 +27,7 @@ const emit = defineEmits<{
deposit: [];
withdraw: [];
freeze: [];
delete: [];
}>();
const { t } = useAdminLocale();
@@ -58,6 +59,9 @@ const { t } = useAdminLocale();
<el-button v-else link type="primary" size="small" @click="emit('freeze')">
{{ t('common.unfreeze') }}
</el-button>
<el-button link type="danger" size="small" @click="emit('delete')">
{{ t('common.delete') }}
</el-button>
</template>
<template #menu>
<el-dropdown-item v-if="showDetail" @click="emit('detail')">{{ t('common.detail') }}</el-dropdown-item>
@@ -71,6 +75,9 @@ const { t } = useAdminLocale();
<span class="action-warning">{{ t('common.freeze') }}</span>
</el-dropdown-item>
<el-dropdown-item v-else @click="emit('freeze')">{{ t('common.unfreeze') }}</el-dropdown-item>
<el-dropdown-item divided @click="emit('delete')">
<span class="action-danger">{{ t('common.delete') }}</span>
</el-dropdown-item>
</template>
</AdminResponsiveRowActions>
</template>
@@ -79,4 +86,7 @@ const { t } = useAdminLocale();
:deep(.action-warning) {
color: var(--el-color-warning);
}
:deep(.action-danger) {
color: var(--el-color-danger);
}
</style>

View File

@@ -287,7 +287,6 @@ watch(
<div class="table-wrap">
<el-table
v-loading="loading"
:key="locale"
:data="items"
stripe
size="small"

View File

@@ -0,0 +1,166 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue';
import { ElMessage } from 'element-plus';
import api from '../api';
import { useAdminLocale } from '../composables/useAdminLocale';
type LeagueBlockingMatch = {
id: string;
status: string;
isOutright: boolean;
title: string;
pendingCount: number;
};
type LeagueArchivePreview = {
leagueId: string;
canArchive: boolean;
blockingMatches: LeagueBlockingMatch[];
totalPendingBets: number;
};
const props = defineProps<{
modelValue: boolean;
leagueId: string;
leagueName?: string;
}>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
archived: [];
}>();
const { t } = useAdminLocale();
const loading = ref(false);
const submitting = ref(false);
const preview = ref<LeagueArchivePreview | null>(null);
const displayName = computed(() => props.leagueName || props.leagueId);
watch(
() => [props.modelValue, props.leagueId] as const,
([open]) => {
if (open && props.leagueId) void loadPreview();
},
);
function close() {
emit('update:modelValue', false);
}
async function loadPreview() {
loading.value = true;
preview.value = null;
try {
const { data } = await api.get(`/admin/leagues/${props.leagueId}/archive-preview`);
preview.value = data.data as LeagueArchivePreview;
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
close();
} finally {
loading.value = false;
}
}
async function confirmArchive() {
if (!preview.value?.canArchive) return;
submitting.value = true;
try {
await api.post(`/admin/leagues/${props.leagueId}/archive`);
ElMessage.success(t('archive.league_done'));
close();
emit('archived');
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.delete_failed'));
} finally {
submitting.value = false;
}
}
function matchTypeLabel(row: LeagueBlockingMatch) {
return row.isOutright ? t('nav.outrights') : t('match.col.matchup');
}
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="t('archive.league_title')"
width="640px"
destroy-on-close
@update:model-value="emit('update:modelValue', $event)"
>
<div v-loading="loading">
<p class="archive-target">{{ t('archive.league_target', { name: displayName }) }}</p>
<p class="archive-hint">{{ t('archive.league_hint') }}</p>
<template v-if="preview">
<el-alert
v-if="!preview.canArchive"
type="warning"
:closable="false"
show-icon
class="archive-alert"
>
{{ t('archive.league_blocked') }}
</el-alert>
<el-table
v-if="preview.blockingMatches.length"
:data="preview.blockingMatches"
size="small"
stripe
class="blocking-table"
>
<el-table-column prop="title" :label="t('match.col.matchup')" min-width="160" />
<el-table-column :label="t('common.type')" width="88">
<template #default="{ row }">{{ matchTypeLabel(row) }}</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="100">
<template #default="{ row }">
<el-tag size="small" type="info">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('match.col.pending_bets')" width="88" align="center">
<template #default="{ row }">{{ row.pendingCount }}</template>
</el-table-column>
</el-table>
<p v-else-if="preview.canArchive" class="archive-ok">{{ t('archive.league_ready') }}</p>
</template>
</div>
<template #footer>
<el-button @click="close">{{ t('common.cancel') }}</el-button>
<el-button
type="danger"
:disabled="!preview?.canArchive"
:loading="submitting"
@click="confirmArchive"
>
{{ t('archive.league_confirm') }}
</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.archive-target {
margin: 0 0 8px;
font-weight: 600;
}
.archive-hint {
margin: 0 0 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.archive-alert {
margin-bottom: 12px;
}
.blocking-table {
margin-bottom: 8px;
}
.archive-ok {
margin: 0;
color: var(--el-color-success);
font-size: 13px;
}
</style>

View File

@@ -0,0 +1,182 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue';
import { ElMessage } from 'element-plus';
import api from '../api';
import { useAdminLocale } from '../composables/useAdminLocale';
import { formatAmount } from '../utils/format-amount';
type MatchArchiveWarning = 'PENDING_BETS' | 'UNSETTLED_MATCH' | 'PREVIEW_BATCH';
type MatchArchivePreview = {
matchId: string;
matchStatus: string;
isOutright: boolean;
title: string;
pendingBetCount: number;
pendingStake: string;
hasPreviewSettlementBatch: boolean;
requiresForce: boolean;
warnings: MatchArchiveWarning[];
};
const props = defineProps<{
modelValue: boolean;
matchId: string;
title?: string;
}>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
archived: [];
}>();
const { t } = useAdminLocale();
const loading = ref(false);
const submitting = ref(false);
const preview = ref<MatchArchivePreview | null>(null);
const refundPendingBets = ref(false);
const displayTitle = computed(() => props.title || preview.value?.title || props.matchId);
watch(
() => [props.modelValue, props.matchId] as const,
([open]) => {
if (open && props.matchId) void loadPreview();
},
);
function close() {
emit('update:modelValue', false);
}
async function loadPreview() {
loading.value = true;
preview.value = null;
try {
const { data } = await api.get(`/admin/matches/${props.matchId}/archive-preview`);
preview.value = data.data as MatchArchivePreview;
refundPendingBets.value = (preview.value.pendingBetCount ?? 0) > 0;
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
close();
} finally {
loading.value = false;
}
}
async function confirmArchive(force: boolean) {
if (!preview.value) return;
submitting.value = true;
try {
const { data } = await api.post(`/admin/matches/${props.matchId}/archive`, {
force,
refundPendingBets: refundPendingBets.value,
});
const voided = (data.data as { voidedCount?: number })?.voidedCount ?? 0;
ElMessage.success(
voided > 0 ? t('archive.msg_done_refund', { n: voided }) : t('archive.msg_done'),
);
close();
emit('archived');
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.delete_failed'));
} finally {
submitting.value = false;
}
}
function warningLabel(code: MatchArchiveWarning) {
return t(`archive.warning.${code}`);
}
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="t('archive.match_title')"
width="520px"
destroy-on-close
@update:model-value="emit('update:modelValue', $event)"
>
<div v-loading="loading">
<p class="archive-target">{{ t('archive.target', { title: displayTitle }) }}</p>
<template v-if="preview">
<ul v-if="preview.warnings.length" class="archive-warnings">
<li v-for="w in preview.warnings" :key="w">{{ warningLabel(w) }}</li>
</ul>
<p v-if="preview.pendingBetCount > 0" class="archive-stat">
{{
t('archive.pending_summary', {
count: preview.pendingBetCount,
stake: formatAmount(preview.pendingStake),
})
}}
</p>
<p class="archive-hint">{{ t('archive.soft_delete_hint') }}</p>
<el-checkbox
v-if="preview.pendingBetCount > 0"
v-model="refundPendingBets"
class="archive-refund"
>
{{ t('archive.refund_pending') }}
</el-checkbox>
<p v-if="preview.pendingBetCount > 0 && refundPendingBets" class="archive-parlay-hint">
{{ t('archive.parlay_void_hint') }}
</p>
</template>
</div>
<template #footer>
<el-button @click="close">{{ t('common.cancel') }}</el-button>
<template v-if="preview">
<el-button
v-if="!preview.requiresForce"
type="danger"
:loading="submitting"
@click="confirmArchive(false)"
>
{{ t('common.delete') }}
</el-button>
<el-button
v-else
type="danger"
:loading="submitting"
@click="confirmArchive(true)"
>
{{ t('archive.force_delete') }}
</el-button>
</template>
</template>
</el-dialog>
</template>
<style scoped>
.archive-target {
margin: 0 0 12px;
font-weight: 600;
}
.archive-warnings {
margin: 0 0 12px;
padding-left: 18px;
color: var(--el-color-warning);
}
.archive-stat {
margin: 0 0 8px;
font-size: 13px;
}
.archive-hint {
margin: 0 0 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.archive-refund {
display: flex;
margin-bottom: 8px;
}
.archive-parlay-hint {
margin: 0;
font-size: 12px;
color: var(--el-color-warning);
}
</style>

View File

@@ -1,6 +1,10 @@
<script setup lang="ts">
import { computed } from 'vue';
import { VChart, type EChartsOption } from './echarts-setup';
import { computed, defineAsyncComponent } from 'vue';
import type { EChartsOption } from 'echarts';
const VChart = defineAsyncComponent(() =>
import('./echarts-setup').then((m) => m.VChart),
);
const props = withDefaults(
defineProps<{
@@ -17,7 +21,9 @@ const style = computed(() => ({ height: props.height, width: '100%' }));
<template>
<div class="chart-panel">
<div v-if="title" class="chart-title">{{ title }}</div>
<VChart class="chart-canvas" :option="option" :style="style" autoresize />
<Suspense>
<VChart class="chart-canvas" :option="option" :style="style" autoresize />
</Suspense>
</div>
</template>

View File

@@ -5,6 +5,7 @@ import {
type AdminLocale,
} from '../i18n/admin-messages';
import { ADMIN_LOCALE_STORAGE_KEY } from '../i18n';
import { preloadAdminLocale } from '../i18n/locale-loader';
import { resolveAdminMessage } from '../i18n/resolve-message';
export type { AdminLocale };
@@ -24,7 +25,8 @@ export function useAdminLocale() {
);
}
function setLocale(code: AdminLocale) {
async function setLocale(code: AdminLocale) {
await preloadAdminLocale(code);
locale.value = code;
localStorage.setItem(ADMIN_LOCALE_STORAGE_KEY, code);
document.cookie = `${ADMIN_LOCALE_STORAGE_KEY}=${encodeURIComponent(code)};path=/;max-age=31536000;SameSite=Lax`;

View File

@@ -75,10 +75,18 @@ const zh: Record<string, string> = {
'deposit.approved_amount_label': '批准金额(可调整)',
'deposit.remark_label': '备注(可选)',
'deposit.confirm_approve': '确认批准',
'deposit.approve_check_hint': '请仔细核对截图中的转账金额,确保与下方「批准金额」完全一致后再提交。',
'deposit.confirm_approve_message': '即将批准金额 {amount}。请再次确认该金额与截图一致,确认后将入账玩家钱包。',
'deposit.confirm_reject': '确认拒绝',
'deposit.reject_reason_label': '拒绝原因 *',
'deposit.reject_reason_ph': '请输入拒绝原因...',
'deposit.reason_required': '请输入拒绝原因',
'deposit.revoke': '撤回',
'deposit.confirm_revoke': '将作废批准后 5 分钟内的待结算注单、扣回已入账金额,并恢复为待审核。确定撤回?',
'deposit.reopen_review': '重新审核',
'deposit.confirm_reopen': '订单将恢复为待审核,可再次批准或拒绝。确定继续?',
'deposit.delete': '删除',
'deposit.confirm_delete': '【仅删除记录】将永久删除本订单记录及截图文件。确定继续?',
'deposit.prev': '上一页',
'deposit.next': '下一页',
'deposit.add_method': '+ 添加',
@@ -211,7 +219,7 @@ const zh: Record<string, string> = {
'nav.matches.fixtures': '赛事配置',
'nav.matches.outrights': '优胜赛配置(盘口)',
'page.matches.title': '赛事管理',
'page.matches.desc': '草稿可编辑、删除;已发布可改开赛时间与热门',
'page.matches.desc': '草稿可编辑、删除;已发布可改开赛时间与热门;未结算可下架',
'page.bets.title': '注单管理',
'page.bets.desc': '筛选、分页查看全平台注单,支持详情与投注项',
'page.cashback.title': '返水管理',
@@ -336,10 +344,18 @@ const en: Record<string, string> = {
'deposit.approved_amount_label': 'Approved Amount (adjust if needed)',
'deposit.remark_label': 'Remark (optional)',
'deposit.confirm_approve': 'Confirm Approve',
'deposit.approve_check_hint': 'Carefully verify the transfer amount in the screenshot matches the approved amount below before submitting.',
'deposit.confirm_approve_message': 'You are about to approve {amount}. Confirm it matches the screenshot; this will credit the player wallet.',
'deposit.confirm_reject': 'Confirm Reject',
'deposit.reject_reason_label': 'Reject Reason *',
'deposit.reject_reason_ph': 'Enter the reason for rejection...',
'deposit.reason_required': 'Please enter a reject reason',
'deposit.revoke': 'Revoke',
'deposit.confirm_revoke': 'Pending bets placed within 5 minutes of approval will be voided, credited balance reversed, and the order reset to pending. Continue?',
'deposit.reopen_review': 'Re-review',
'deposit.confirm_reopen': 'The order will return to pending for approve or reject. Continue?',
'deposit.delete': 'Delete',
'deposit.confirm_delete': '[Records only] Permanently delete this order record and screenshot. Continue?',
'deposit.prev': 'Prev',
'deposit.next': 'Next',
'deposit.add_method': '+ Add',
@@ -470,7 +486,7 @@ const en: Record<string, string> = {
'nav.matches.fixtures': 'Fixtures',
'nav.matches.outrights': 'Outright odds',
'page.matches.title': 'Matches',
'page.matches.desc': 'Edit/delete drafts; adjust kickoff and featured when published',
'page.matches.desc': 'Edit/delete drafts; adjust kickoff and featured when published; unpublish while unsettled',
'page.bets.title': 'Bets',
'page.bets.desc': 'Filter and paginate all bets with leg details',
'page.cashback.title': 'Cashback',
@@ -595,10 +611,18 @@ const ms: Record<string, string> = {
'deposit.approved_amount_label': 'Jumlah Diluluskan (laraskan jika perlu)',
'deposit.remark_label': 'Catatan (pilihan)',
'deposit.confirm_approve': 'Sahkan Luluskan',
'deposit.approve_check_hint': 'Semak jumlah pindahan dalam tangkapan skrin sepadan dengan jumlah diluluskan di bawah sebelum hantar.',
'deposit.confirm_approve_message': 'Akan meluluskan {amount}. Sahkan ia sepadan dengan tangkapan skrin; baki pemain akan dikreditkan.',
'deposit.confirm_reject': 'Sahkan Tolak',
'deposit.reject_reason_label': 'Sebab Penolakan *',
'deposit.reject_reason_ph': 'Masukkan sebab penolakan...',
'deposit.reason_required': 'Sila masukkan sebab penolakan',
'deposit.revoke': 'Tarik balik',
'deposit.confirm_revoke': 'Pertaruhan menunggu dalam 5 minit selepas kelulusan akan dibatalkan, baki dikembalikan, dan pesanan kembali menunggu. Teruskan?',
'deposit.reopen_review': 'Semak semula',
'deposit.confirm_reopen': 'Pesanan kembali menunggu untuk lulus atau tolak. Teruskan?',
'deposit.delete': 'Padam',
'deposit.confirm_delete': '[Rekod sahaja] Padam rekod pesanan dan tangkapan skrin. Teruskan?',
'deposit.prev': 'Sebelum',
'deposit.next': 'Seterus',
'deposit.add_method': '+ Tambah',
@@ -729,7 +753,7 @@ const ms: Record<string, string> = {
'nav.matches.fixtures': 'Konfigurasi perlawanan',
'nav.matches.outrights': 'Odds juara',
'page.matches.title': 'Perlawanan',
'page.matches.desc': 'Edit/padam draf; laraskan masa mula dan pilihan utama bila diterbitkan',
'page.matches.desc': 'Edit/padam draf; laraskan masa mula bila diterbitkan; nyahterbit jika belum selesai',
'page.bets.title': 'Pertaruhan',
'page.bets.desc': 'Tapis dan halaman semua pertaruhan dengan butiran pilihan',
'page.cashback.title': 'Rebat',

View File

@@ -273,12 +273,20 @@ export const adminPagesMs: Record<string, string> = {
'league.status.PUBLISHED': 'Diterbitkan',
'league.status.UNPUBLISHED': 'Tidak diterbitkan',
'league.btn.unpublish': 'Nyahterbit',
'league.confirm_unpublish': 'Pemain tidak lagi melihat kejohanan ini; anda masih boleh edit dan terbitkan semula di admin. Teruskan?',
'msg.league_published': 'Kejohanan diterbitkan',
'msg.league_unpublished': 'Kejohanan dinyahterbit',
'match.btn.unpublish': 'Nyahterbit',
'match.confirm_unpublish': 'Perlawanan kembali ke draf: tersembunyi dari pemain dan tiada pertaruhan baharu; pertaruhan belum selesai kekal. Teruskan?',
'msg.match_unpublished': 'Perlawanan dinyahterbit',
'match.hint.edit_published': 'Diterbitkan: edit masa mula, pilihan utama, nama paparan; tertutup/selesai dikunci.',
'match.expand_league_hint': 'Kembangkan liga untuk urus perlawanan; odds juara di tab Odds juara.',
'match.expand_outright_hint': 'Kembangkan liga untuk sunting odds juara; pasukan perlawanan disegerakkan auto, boleh tambah pasukan belum dijadualkan.',
'outright.odds_only_hint': 'Pasukan daripada perlawanan disegerakkan auto; boleh tambah pasukan manual dan sunting odds di sini.',
'outright.odds_only_hint': 'Pasukan daripada perlawanan disegerakkan auto; boleh tambah pasukan manual dan sunting odds di sini. Pasaran juara ikut terbitan liga — tiada langkah terbit berasingan.',
'outright.league_unpublished_hint': 'Liga belum diterbitkan. Tetapkan liga kepada Diterbitkan di halaman ini untuk membuka pertaruhan juara secara automatik.',
'outright.unsettled_fixtures_hint': '{n} perlawanan dalam liga ini masih belum diselesaikan. Selesaikan dahulu sebelum juara.',
'outright.btn.reopen': 'Buka semula pertaruhan',
'outright.confirm_reopen': 'Membuka semula akan menerima pertaruhan juara baharu. Teruskan?',
'outright.fixture_sync_added': '{n} pasukan disegerakkan auto daripada perlawanan',
'outright.col.teams_from_fixtures': 'Pasukan (daripada perlawanan)',
'outright.col.teams_total': 'Pasukan odds juara',
@@ -415,7 +423,27 @@ export const adminPagesMs: Record<string, string> = {
'match.import_start': 'Import',
'match.import_json_ph': '{"matches":[...]}',
'match.delete_confirm_title': 'Padam perlawanan',
'match.delete_confirm_body': 'Padam "{title}"? Hanya draf tanpa pertaruhan.',
'match.delete_confirm_body': 'Padam "{title}"? Data kekal tetapi disembunyikan.',
'archive.match_title': 'Padam perlawanan',
'archive.league_title': 'Padam liga',
'archive.target': 'Perlawanan: {title}',
'archive.league_target': 'Liga: {name}',
'archive.soft_delete_hint': 'Padam lembut: data kekal, disembunyikan dari senarai.',
'archive.pending_summary': '{count} pertaruhan tertunda, stake {stake}',
'archive.refund_pending': 'Bayar balik pertaruhan tertunda',
'archive.parlay_void_hint': 'Slip parlay yang melibatkan perlawanan ini akan dibatalkan sepenuhnya.',
'archive.force_delete': 'Padam paksa',
'archive.msg_done': 'Dipadam',
'archive.msg_done_refund': 'Dipadam; {n} pertaruhan dibayar balik',
'archive.league_hint': 'Liga hanya boleh dipadam apabila semua perlawanan dan outright selesai tanpa pertaruhan tertunda.',
'archive.league_blocked': 'Masih ada perlawanan belum selesai atau pertaruhan tertunda.',
'archive.league_ready': 'Liga ini boleh dipadam; semua acara anak akan disembunyikan.',
'archive.league_confirm': 'Padam liga',
'archive.league_done': 'Liga dipadam',
'archive.warning.PENDING_BETS': 'Terdapat pertaruhan tertunda',
'archive.warning.UNSETTLED_MATCH': 'Perlawanan belum dalam status akhir',
'archive.warning.PREVIEW_BATCH': 'Terdapat batch pratonton penyelesaian',
'common.type': 'Jenis',
'match.ph.league_en': 'FIFA World Cup 2026',
'match.ph.league_zh': 'Piala Dunia 2026',
'match.ph.kickoff': '2026-06-11T19:00:00Z',
@@ -528,6 +556,12 @@ export const adminPagesMs: Record<string, string> = {
'settlement.preview_failed': 'Gagal menjana pratonton penyelesaian',
'settlement.err_score_not_recorded': 'Sila masukkan skor separuh masa dan penuh masa sebelum penyelesaian',
'settlement.must_close_first': 'Tutup pertaruhan sebelum penyelesaian',
'settlement.outright.page_title': 'Penyelesaian juara',
'settlement.outright.title': 'Juara outright',
'settlement.outright.winner': 'Pasukan juara',
'settlement.outright.winner_ph': 'Pilih juara',
'settlement.outright.preview_hint': 'Pilih juara, pratonton bayaran, kemudian sahkan penyelesaian',
'settlement.outright.winner_required': 'Sila pilih pasukan juara dahulu',
'settlement.preview_title': 'Pratonton penyelesaian',
'settlement.single_count': 'Pertaruhan tunggal',
'settlement.est_payout': 'Anggaran bayaran',
@@ -739,6 +773,9 @@ export const adminPagesMs: Record<string, string> = {
'teamLogo.kind.crest': 'Lambang',
'outright.err_country': 'Sila pilih negara',
'outright.btn.save_odds': 'Simpan semua odds',
'outright.btn.close': 'Tutup pertaruhan',
'outright.btn.settle': 'Selesaikan',
'outright.confirm_close': 'Selepas tutup, tiada pertaruhan juara baharu. Teruskan?',
'outright.btn.apply_canonical': 'Guna data jadual asas',
'msg.outright_canonical_applied': 'Odds 48 pasukan telah dikemas kini',
'outright.team_count': '{n} / {total} pasukan',
@@ -867,3 +904,5 @@ export const adminPagesMs: Record<string, string> = {
'media.refresh': 'Muat Semula',
'media.unused_count': '{n} tidak digunakan',
};
export default adminPagesMs;

View File

@@ -22,6 +22,7 @@ export const adminPagesZh: Record<string, string> = {
'common.col_id': 'ID',
'common.times': '次',
'common.bets_count_unit': '笔',
'common.type': '类型',
'user.create_btn': '+ 新建玩家',
'user.filter.username_ph': '用户名',
@@ -294,12 +295,20 @@ export const adminPagesZh: Record<string, string> = {
'league.status.PUBLISHED': '已发布',
'league.status.UNPUBLISHED': '未发布',
'league.btn.unpublish': '下架',
'league.confirm_unpublish': '下架后玩家端将不再展示该联赛,管理端仍可编辑与重新发布。是否继续?',
'msg.league_published': '联赛已发布',
'msg.league_unpublished': '联赛已下架',
'match.btn.unpublish': '下架',
'match.confirm_unpublish': '下架后赛事将回到草稿,玩家端不可见且无法新下注,已有未结注单保留。是否继续?',
'msg.match_unpublished': '赛事已下架',
'match.hint.edit_published': '已发布:可修改开赛时间、热门及显示名称;封盘/已结算后不可编辑。',
'match.expand_league_hint': '展开联赛可管理单场赛事;夺冠盘口请「优胜赛配置(盘口)」中设置赔率。',
'match.expand_league_hint': '展开联赛可管理单场赛事;优胜冠军盘口请「优胜赛配置。',
'match.expand_outright_hint': '展开联赛可编辑夺冠赔率;单场球队会自动同步,也可手动补充尚未赛程的球队。',
'outright.odds_only_hint': '单场赛程中的球队会自动加入;可手动添加尚未参赛的球队,并在此调整赔率。',
'outright.odds_only_hint': '单场赛程中的球队会自动加入;可手动添加尚未参赛的球队,并在此调整赔率。冠军盘随联赛发布,无需单独发布。',
'outright.league_unpublished_hint': '联赛尚未发布,请在本页编辑联赛并设为「已发布」后,冠军盘将自动开放投注。',
'outright.unsettled_fixtures_hint': '该联赛仍有 {n} 场单场未结算,请先完成单场结算后再结算冠军盘。',
'outright.btn.reopen': '解盘',
'outright.confirm_reopen': '解盘后将重新接受优胜冠军投注,是否继续?',
'outright.fixture_sync_added': '已从单场自动同步 {n} 支球队',
'outright.col.teams_from_fixtures': '参赛球队(来自单场)',
'outright.col.teams_total': '冠军盘球队',
@@ -436,7 +445,26 @@ export const adminPagesZh: Record<string, string> = {
'match.import_start': '开始导入',
'match.import_json_ph': '{"matches":[...]}',
'match.delete_confirm_title': '删除确认',
'match.delete_confirm_body': '确定删除赛事「{title}」?仅草稿且无注单时可删除。',
'match.delete_confirm_body': '确定删除赛事「{title}」?数据将保留但不再展示。',
'archive.match_title': '删除赛事',
'archive.league_title': '删除联赛',
'archive.target': '赛事:{title}',
'archive.league_target': '联赛:{name}',
'archive.soft_delete_hint': '此为软删除:数据保留,前后台列表不再展示。',
'archive.pending_summary': '未结注单 {count} 笔,涉及金额 {stake}',
'archive.refund_pending': '退还未结注单金额',
'archive.parlay_void_hint': '含串关注单时将整单作废并全额退还。',
'archive.force_delete': '强制删除',
'archive.msg_done': '已删除',
'archive.msg_done_refund': '已删除,已退还 {n} 笔注单',
'archive.league_hint': '仅当下属单场与优胜赛均已结算且无未结注单时可删除联赛。',
'archive.league_blocked': '仍有未结算赛事或未结注单,请先处理下属赛事。',
'archive.league_ready': '可以删除该联赛,将同时隐藏下属全部赛事。',
'archive.league_confirm': '确认删除联赛',
'archive.league_done': '联赛已删除',
'archive.warning.PENDING_BETS': '存在未结注单',
'archive.warning.UNSETTLED_MATCH': '赛事尚未进入终态(未结算/未取消)',
'archive.warning.PREVIEW_BATCH': '存在待确认的结算预览批次',
'match.ph.league_en': 'FIFA World Cup 2026',
'match.ph.league_zh': '2026 世界杯',
'match.ph.kickoff': '2026-06-11T19:00:00Z',
@@ -462,6 +490,7 @@ export const adminPagesZh: Record<string, string> = {
'matchEditor.field.stage': '阶段',
'matchEditor.field.group': '小组',
'matchEditor.field.display_order': '排序',
'matchEditor.field.correct_score_enabled': '波胆玩法',
'matchEditor.field.promo_label': '促销标签',
'matchEditor.field.promo_label_optional': '促销标签(可选)',
'matchEditor.field.line_value': '盘口线',
@@ -551,6 +580,12 @@ export const adminPagesZh: Record<string, string> = {
'settlement.preview_failed': '生成结算预览失败',
'settlement.err_score_not_recorded': '请先填写半场与全场比分后再生成预览',
'settlement.must_close_first': '请先封盘后再结算',
'settlement.outright.page_title': '优胜冠军结算',
'settlement.outright.title': '优胜冠军',
'settlement.outright.winner': '冠军队伍',
'settlement.outright.winner_ph': '选择夺冠队伍',
'settlement.outright.preview_hint': '选择冠军后生成预览,确认后将派彩给中奖注单',
'settlement.outright.winner_required': '请先选择冠军队伍',
'settlement.preview_title': '结算预览',
'settlement.single_count': '单关注单数',
'settlement.preview_pending_bets': '待结算注单',
@@ -812,6 +847,9 @@ export const adminPagesZh: Record<string, string> = {
'teamLogo.kind.crest': '队徽',
'outright.err_country': '请选择国家',
'outright.btn.save_odds': '保存全部赔率',
'outright.btn.close': '封盘',
'outright.btn.settle': '去结算',
'outright.confirm_close': '封盘后将停止接受新的优胜冠军投注,是否继续?',
'outright.btn.save_meta': '保存赛事信息',
'outright.btn.publish': '发布',
'outright.btn.unpublish': '撤回发布',
@@ -900,6 +938,13 @@ export const adminPagesZh: Record<string, string> = {
'msg.freeze_extra': '冻结后该账号将无法登录。',
'msg.freeze_done': '已{action}',
'msg.freeze_failed': '{action}失败',
'msg.delete_player_title': '删除玩家',
'msg.delete_player_body': '确定要删除玩家「{name}」吗?删除后该玩家将无法登录,此操作不可撤销。',
'msg.delete_player_confirm_title': '二次确认',
'msg.delete_player_confirm_hint': '请输入玩家用户名「{name}」以确认删除:',
'msg.delete_player_mismatch': '用户名输入不正确,请重新输入',
'msg.delete_player_done': '玩家已删除',
'msg.delete_player_failed': '删除失败',
'smoke.intro': '在后台一键运行自动化冒烟测试,覆盖结算引擎、下注规则、代理额度逻辑、返水规则、数据库探针,以及下注→结算→钱包全链路集成用例。',
'smoke.intro_rule': '规则类用例与 Jest 单元测试同源逻辑,不写入业务数据。',
@@ -993,6 +1038,7 @@ export const adminPagesEn: Record<string, string> = {
'common.col_id': 'ID',
'common.times': 'times',
'common.bets_count_unit': 'bets',
'common.type': 'Type',
'user.create_btn': '+ New player',
'user.filter.username_ph': 'Username',
@@ -1265,12 +1311,20 @@ export const adminPagesEn: Record<string, string> = {
'league.status.PUBLISHED': 'Published',
'league.status.UNPUBLISHED': 'Unpublished',
'league.btn.unpublish': 'Unpublish',
'league.confirm_unpublish': 'Players will no longer see this tournament; you can still edit and republish in admin. Continue?',
'msg.league_published': 'Tournament published',
'msg.league_unpublished': 'Tournament unpublished',
'match.btn.unpublish': 'Unpublish',
'match.confirm_unpublish': 'The fixture will return to draft: hidden from players and no new bets; existing unsettled bets remain. Continue?',
'msg.match_unpublished': 'Fixture unpublished',
'match.hint.edit_published': 'Published: edit kickoff, featured, display names; closed/settled are locked.',
'match.expand_league_hint': 'Expand a league to manage fixtures; set winner odds under Outright odds.',
'match.expand_league_hint': 'Expand a league to manage fixtures; use Outright odds for winner markets.',
'match.expand_outright_hint': 'Expand a league to edit winner odds; fixture teams sync automatically, and you can add teams not yet on the schedule.',
'outright.odds_only_hint': 'Teams from fixtures are added automatically; add extra teams manually and edit winner odds here.',
'outright.odds_only_hint': 'Teams from fixtures are added automatically; add extra teams manually and edit winner odds here. Outright follows league publish—no separate publish step.',
'outright.league_unpublished_hint': 'League is not published yet. Set the league to Published on this page to open outright betting automatically.',
'outright.unsettled_fixtures_hint': '{n} fixture(s) in this league are still unsettled. Settle them before settling the outright market.',
'outright.btn.reopen': 'Reopen betting',
'outright.confirm_reopen': 'Reopening will accept new outright bets again. Continue?',
'outright.fixture_sync_added': '{n} team(s) synced automatically from fixtures',
'outright.col.teams_from_fixtures': 'Teams (from fixtures)',
'outright.col.teams_total': 'Outright teams',
@@ -1407,7 +1461,26 @@ export const adminPagesEn: Record<string, string> = {
'match.import_start': 'Import',
'match.import_json_ph': '{"matches":[...]}',
'match.delete_confirm_title': 'Delete match',
'match.delete_confirm_body': 'Delete "{title}"? Draft with no bets only.',
'match.delete_confirm_body': 'Delete "{title}"? Data is kept but hidden from lists.',
'archive.match_title': 'Delete match',
'archive.league_title': 'Delete league',
'archive.target': 'Match: {title}',
'archive.league_target': 'League: {name}',
'archive.soft_delete_hint': 'Soft delete: data remains, hidden from admin and player lists.',
'archive.pending_summary': '{count} pending bet(s), stake {stake}',
'archive.refund_pending': 'Refund pending bet stakes',
'archive.parlay_void_hint': 'Parlay slips touching this match are voided in full.',
'archive.force_delete': 'Force delete',
'archive.msg_done': 'Deleted',
'archive.msg_done_refund': 'Deleted; refunded {n} bet(s)',
'archive.league_hint': 'League can be deleted only when all fixtures and outrights are settled with no pending bets.',
'archive.league_blocked': 'Unsettled fixtures or pending bets remain under this league.',
'archive.league_ready': 'This league can be deleted; all child events will be hidden.',
'archive.league_confirm': 'Delete league',
'archive.league_done': 'League deleted',
'archive.warning.PENDING_BETS': 'Pending bets exist',
'archive.warning.UNSETTLED_MATCH': 'Match is not in a terminal state',
'archive.warning.PREVIEW_BATCH': 'A settlement preview batch is pending',
'match.ph.league_en': 'FIFA World Cup 2026',
'match.ph.league_zh': '2026 World Cup',
'match.ph.kickoff': '2026-06-11T19:00:00Z',
@@ -1433,6 +1506,7 @@ export const adminPagesEn: Record<string, string> = {
'matchEditor.field.stage': 'Stage',
'matchEditor.field.group': 'Group',
'matchEditor.field.display_order': 'Sort order',
'matchEditor.field.correct_score_enabled': 'Correct score',
'matchEditor.field.promo_label': 'Promo label',
'matchEditor.field.promo_label_optional': 'Promo label (optional)',
'matchEditor.field.line_value': 'Line',
@@ -1522,6 +1596,12 @@ export const adminPagesEn: Record<string, string> = {
'settlement.preview_failed': 'Failed to generate settlement preview',
'settlement.err_score_not_recorded': 'Enter half-time and full-time scores before preview',
'settlement.must_close_first': 'Close betting before settlement',
'settlement.outright.page_title': 'Outright settlement',
'settlement.outright.title': 'Outright winner',
'settlement.outright.winner': 'Winning team',
'settlement.outright.winner_ph': 'Select champion',
'settlement.outright.preview_hint': 'Select the winner, preview payouts, then confirm settlement',
'settlement.outright.winner_required': 'Please select the winning team first',
'settlement.preview_title': 'Settlement preview',
'settlement.single_count': 'Single bets',
'settlement.preview_pending_bets': 'Pending bets',
@@ -1784,6 +1864,9 @@ export const adminPagesEn: Record<string, string> = {
'teamLogo.kind.crest': 'Crest',
'outright.err_country': 'Please select a country',
'outright.btn.save_odds': 'Save all odds',
'outright.btn.close': 'Close betting',
'outright.btn.settle': 'Settle',
'outright.confirm_close': 'Closing will stop new outright bets. Continue?',
'outright.btn.save_meta': 'Save event info',
'outright.btn.publish': 'Publish',
'outright.btn.unpublish': 'Unpublish',
@@ -1872,6 +1955,13 @@ export const adminPagesEn: Record<string, string> = {
'msg.freeze_extra': ' They will not be able to sign in.',
'msg.freeze_done': '{action} completed',
'msg.freeze_failed': '{action} failed',
'msg.delete_player_title': 'Delete player',
'msg.delete_player_body': 'Delete player "{name}"? This action is irreversible — they will no longer be able to sign in.',
'msg.delete_player_confirm_title': 'Final confirmation',
'msg.delete_player_confirm_hint': 'Type the player username "{name}" to confirm deletion:',
'msg.delete_player_mismatch': 'Username does not match. Please try again.',
'msg.delete_player_done': 'Player deleted',
'msg.delete_player_failed': 'Delete failed',
'smoke.intro': 'Run automated smoke tests from the admin console: settlement, betting rules, agent credit logic, cashback rules, read-only DB probes, and bet→settle→wallet integration.',
'smoke.intro_rule': 'Rule cases reuse the same logic as Jest unit tests; no business data is written.',

View File

@@ -0,0 +1,3 @@
import adminPages from '../pages/en';
export default adminPages;

View File

@@ -0,0 +1,3 @@
import adminPages from '../admin-pages-ms';
export default adminPages;

View File

@@ -0,0 +1,3 @@
import adminPages from '../pages/zh';
export default adminPages;

View File

@@ -1,19 +1,23 @@
import { createI18n } from 'vue-i18n';
import { adminMessages, type AdminLocale } from './admin-messages';
import { ensureAdminLocaleLoaded } from './locale-loader';
export const ADMIN_LOCALE_STORAGE_KEY = 'admin_locale';
const saved = (localStorage.getItem(ADMIN_LOCALE_STORAGE_KEY) as AdminLocale) || 'zh-CN';
export const i18n = createI18n({
legacy: false,
locale: saved,
fallbackLocale: ['en-US', 'zh-CN'],
messages: adminMessages,
});
export function getAdminLocale(): AdminLocale {
return i18n.global.locale.value as AdminLocale;
export async function createAdminI18n() {
await ensureAdminLocaleLoaded(saved);
return createI18n({
legacy: false,
locale: saved,
fallbackLocale: ['en-US', 'zh-CN'] as AdminLocale[],
messages: adminMessages,
});
}
export default i18n;
export function getAdminLocale(): AdminLocale {
return (localStorage.getItem(ADMIN_LOCALE_STORAGE_KEY) as AdminLocale) || 'zh-CN';
}
export default adminMessages;

View File

@@ -0,0 +1,32 @@
import type { AdminLocale } from './admin-messages';
import { adminMessages } from './admin-messages';
const loaders: Record<AdminLocale, () => Promise<{ default: Record<string, string> }>> = {
'zh-CN': () => import('./bundles/zh-CN'),
'en-US': () => import('./bundles/en-US'),
'ms-MY': () => import('./bundles/ms-MY'),
};
const inflight = new Map<AdminLocale, Promise<void>>();
/** 按语言动态加载文案包(仅当前 locale 进入主路径,其余为独立 chunk。 */
export async function ensureAdminLocaleLoaded(locale: AdminLocale): Promise<void> {
if (Object.keys(adminMessages[locale]).length > 0) return;
const pending = inflight.get(locale);
if (pending) return pending;
const task = loaders[locale]().then((mod) => {
adminMessages[locale] = mod.default;
});
inflight.set(locale, task);
try {
await task;
} finally {
inflight.delete(locale);
}
}
/** 切换语言时预加载目标 locale与 ensureAdminLocaleLoaded 相同,供 setLocale 调用)。 */
export async function preloadAdminLocale(locale: AdminLocale): Promise<void> {
await ensureAdminLocaleLoaded(locale);
}

View File

@@ -0,0 +1,1009 @@
const adminPages: Record<string, string> = {
'common.detail': 'Details',
'common.create': 'Create',
'common.create_btn': '+ New',
'common.save': 'Save',
'common.close': 'Close',
'common.import': 'Import',
'common.publish': 'Publish',
'common.topup': 'Top up',
'common.adjust_credit': 'Adjust credit',
'common.freeze': 'Freeze',
'common.unfreeze': 'Unfreeze',
'common.settle': 'Settle',
'common.resettle': 'Resettle',
'common.close_betting': 'Close',
'common.reopen_betting': 'Reopen betting',
'common.never_login': 'Never signed in',
'common.optional': 'Optional',
'common.to': 'To',
'common.module': 'Module',
'common.col_id': 'ID',
'common.times': 'times',
'common.bets_count_unit': 'bets',
'user.create_btn': '+ New player',
'user.filter.username_ph': 'Username',
'user.filter.agent': 'Agent',
'user.filter.agent_ph': 'All',
'user.col.username': 'Username',
'user.col.agent': 'Agent',
'user.col.agent_cashback': 'Agent cashback',
'user.col.player_cashback': 'Player cashback',
'user.col.invite_code': 'Invite code',
'user.col.balance': 'Available / Frozen',
'user.col.bets': 'Bets',
'user.col.stake_payout': 'Stake / Payout',
'user.col.last_login': 'Last login',
'user.col.created': 'Registered',
'user.status.ACTIVE': 'Active',
'user.status.SUSPENDED': 'Suspended',
'user.dialog.create': 'New player',
'user.dialog.edit': 'Edit player',
'user.dialog.deposit': 'Top up player',
'user.dialog.detail': 'Player details',
'user.field.password': 'Password',
'user.field.confirm_password': 'Confirm password',
'user.field.initial_balance': 'Initial balance',
'user.field.deposit_remark': 'Top-up note',
'user.field.initial_deposit_kind': 'Ledger note',
'user.initial_deposit_kind.daily': 'Regular top-up',
'user.initial_deposit_kind.opening_bonus': 'Opening bonus',
'user.initial_deposit_kind.custom': 'Custom',
'user.ph.initial_deposit_custom': 'Enter ledger note (min. 2 characters)',
'user.field.amount': 'Amount',
'user.field.remark': 'Note',
'user.field.account_status': 'Account status',
'user.field.available': 'Available balance',
'user.field.frozen_balance': 'Frozen balance',
'user.field.bets_summary': 'Bets / stake',
'user.field.total_payout': 'Total payout',
'user.field.login_fail': 'Failed logins',
'user.field.phone': 'Phone',
'user.field.email': 'Email',
'user.field.allow_password_change': 'Allow player password change',
'user.field.allow_username_change': 'Allow player username change',
'user.field.view_password': 'Login password',
'user.field.reset_password': 'Reset password',
'user.password_not_stored': 'Not stored (player changed it or never saved)',
'user.btn.show_password': 'Show',
'user.btn.hide_password': 'Hide',
'user.ph.reset_password': 'Leave empty to keep; new value will be viewable',
'user.ph.reset_password_short': 'Leave empty to keep',
'user.page_settings': 'Global settings',
'user.global_settings': 'Password & account (global)',
'user.global_settings_hint': 'Controls whether all players can change password or username in the app',
'user.reset_database': 'Reset database',
'user.reset_database_hint': 'Wipes all business data and restores initial demo seed (users, matches, bets, ledger, etc.). This cannot be undone.',
'user.reset_database_confirm_label': 'Type RESET to confirm',
'user.reset_database_confirm_ph': 'RESET',
'user.reset_database_btn': 'Reset to initial data',
'user.reset_database_disabled_prod': 'Disabled in production unless ALLOW_DB_RESET=true is set on the server',
'user.reset_database_success': 'Database reset complete. Sign in again with demo accounts.',
'user.reset_database_accounts': 'Demo accounts',
'user.section.basic_info': 'Basic info',
'user.section.affiliation': 'Affiliation',
'user.section.contact': 'Contact',
'user.section.account_overview': 'Account overview',
'user.section.password_mgmt': 'Password management',
'user.field.current_password': 'Current password',
'user.msg.created_with_password': 'Player created. Login password: {password}',
'user.msg.password_saved': 'Password updated. Viewable password: {password}',
'user.hint.password_reset_to_view': 'No stored password. Set one below under Reset password and save to view it here.',
'user.ph.username_unique': 'Unique login username',
'user.ph.username_player': 'Letters and digits, 332 chars',
'user.hint.username_player': 'English letters and digits only; no Chinese or special characters',
'user.ph.no_agent': 'None (platform direct)',
'user.hint.no_agent': 'Leave empty for platform-managed player',
'user.hint.platform_direct_player': 'This player belongs to the platform (admin direct).',
'user.hint.initial_balance': 'Auto top-up on create; 0 = no initial top-up',
'user.hint.deposit_remark': 'Written to ledger when initial balance > 0',
'user.hint.freeze_in_list': 'Freeze/unfreeze from the list actions',
'user.hint.agent_change': 'Empty = platform direct; changes recalc agent credit',
'user.hint.agent_readonly': 'Agent assignment cannot be changed after creation',
'user.hint.allow_password_change': 'When off, no player can change password in the app',
'user.hint.allow_username_change': 'When on, all players can change login username in profile',
'user.hint.view_password': 'Only passwords set on create/reset; cleared after player self-change',
'user.hint.reset_password': 'Takes effect immediately and updates viewable password above',
'user.btn.create': 'Create',
'user.btn.save_profile': 'Save',
'user.btn.confirm_deposit': 'Confirm top-up',
'user.deposit_remark_default': 'Admin top-up',
'user.withdraw_remark_default': 'Admin withdraw',
'user.field.account_type': 'Account type',
'user.type.player': 'Player',
'user.type.tier1_agent': 'Tier-1 agent',
'user.type.sub_agent': 'Tier-2 agent',
'user.hint.account_type': 'Agents use credit limits; players can belong to an agent and receive top-ups',
'agent.create_btn': '+ New tier-1 agent',
'agent.create_sub_btn': '+ New tier-2 agent',
'agent.create_sub': 'Create tier-2 agent',
'agent.create_child_btn': '+ New sub-agent',
'agent.dialog.create_child_agent': 'New sub-agent',
'agent.create_level_agent': 'Create L{level} agent',
'agent.create_level_agent_btn': '+ New L{level} agent',
'agent.level_name': 'Tier-{level} agent',
'agent.level_tab': 'L{level} agents',
'agent.dialog.create_level_agent': 'New L{level} agent',
'agent.hint.select_parent_for_level': 'Select a level-{level} agent as parent',
'agent.err.parent_level_mismatch': 'Invalid parent level for creating a level-{level} agent',
'agent.hint.creating_under_agent': 'Create account under this agent',
'agent.filter.username_ph': 'Username',
'agent_mgr.tab.players': 'Players',
'agent_mgr.tab.agents': 'Agents',
'agent.col.level': 'Level',
'agent.col.credit': 'Limit / Used / Available',
'agent.col.direct_players': 'Direct players',
'agent.direct_players_title': 'Direct players · {name}',
'agent.platform_row_name': 'Platform',
'agent.col.sub_agents': 'Sub-agents',
'agent.col.cashback': 'Cashback rate',
'agent.col.phone': 'Phone',
'agent.col.created': 'Created',
'agent.dialog.create': 'New tier-1 agent',
'agent.dialog.edit': 'Edit agent',
'agent.dialog.credit': 'Adjust credit limit',
'agent.field.agent_id': 'Agent ID',
'agent.dialog.detail': 'Agent details',
'agent.field.credit_limit': 'Credit limit',
'agent.field.cashback_rate': 'Cashback rate',
'agent.field.adjust_amount': 'Adjustment',
'agent.field.used_credit': 'Used credit',
'agent.field.available_credit': 'Available credit',
'agent.field.player_liability': 'Player liability',
'agent.field.sub_agent_exposure': 'Sub-agent exposure',
'agent.hint.credit_limit': 'Max total top-up capacity for direct players',
'agent.hint.cashback_example': 'e.g. enter 1 for 1%',
'agent.field.max_single_deposit': 'Max single top-up',
'agent.field.max_daily_deposit': 'Max daily top-up',
'agent.hint.deposit_limit_empty': '0 = unlimited; sub-agents cannot exceed parent limits',
'agent.hint.credit_adjust': 'Positive increases, negative decreases',
'agent.hint.credit_remark': 'Optional, written to credit ledger',
'agent.section.credit_log': 'Recent credit changes',
'agent.credit.increase': 'Increase',
'agent.credit.decrease': 'Decrease',
'agent.col.credit_type': 'Type',
'agent.col.credit_change': 'Change',
'agent.col.credit_before': 'Before',
'agent.col.credit_after': 'After',
'agent.credit_tx.filter_agent_ph': 'Agent username',
'agent.credit_tx.filter_agent_id': 'Agent ID',
'agent.credit_tx.filter_agent_id_ph': 'User ID',
'agent.credit_tx.col.operator': 'Operator',
'agent.credit_tx.view_all': 'View all credit ledger',
'finance.tab.credit': 'Credit ledger',
'finance.tab.transfer': 'Transfer ledger',
'finance.tab.wallet': 'Wallet ledger',
'finance.filter.type_category': 'Transaction type',
'finance.filter.type_category_all': 'All',
'finance.filter.type_category_deposit': 'Transfers',
'finance.filter.type_category_bet': 'Bets',
'finance.filter.type_category_cashback': 'Cashback',
'finance.col.frozen_before': 'Frozen before',
'finance.col.frozen_after': 'Frozen after',
'finance.col.reference': 'Related bet',
'finance.tx.adjust': 'Balance adjustment',
'finance.tx.bet_freeze': 'Bet freeze',
'finance.tx.bet_deduct': 'Bet deduct',
'finance.tx.bet_win': 'Bet payout',
'finance.tx.bet_lose': 'Bet settlement',
'finance.tx.bet_push': 'Push refund',
'finance.tx.bet_refund': 'Bet refund',
'finance.tx.bet_void': 'Bet void',
'finance.tx.cashback': 'Cashback',
'finance.tx.resettle': 'Resettlement',
'user.action.view_wallet_ledger': 'View wallet ledger',
'user.action.ledger_short': 'Ledger',
'user.wallet_ledger_dialog_title': 'Wallet ledger — {name}',
'agent.hierarchy.settings_title': 'Agent hierarchy',
'agent.hierarchy.settings_hint': '0 means unlimited levels. Agents at the cap cannot create sub-agents.',
'agent.hierarchy.max_level': 'Max agent level',
'agent.hierarchy.default_sub_credit_ratio': 'Default sub-agent credit ratio',
'agent.hierarchy.default_sub_credit_ratio_hint': 'When creating a sub-agent, pre-fill credit as parent available × this ratio',
'agent.hierarchy.create_credit_default_hint': 'Default {ratio}% ({amount}), capped by parent available credit; adjustable',
'agent.hierarchy.create_credit_quick_hint': 'Parent available {amount} — click a ratio to fill',
'agent.hierarchy.create_level_hint': 'Will be created as level {n} agent',
'agent.field.parent_agent': 'Parent agent',
'agent.col.parent_chain': 'Parent chain',
'role.agent_level': 'Level {n} agent',
'finance.filter.date_range': 'Date range',
'finance.filter.player_ph': 'Player username',
'finance.filter.parent_agent_ph': 'Parent agent username or ID',
'finance.filter.operator_ph': 'Operator username',
'finance.col.player': 'Player',
'finance.col.parent_agent': 'Parent agent',
'finance.col.tx_id': 'Transaction ID',
'finance.col.balance_change': 'Balance change',
'finance.col.balance_before': 'Balance before',
'finance.col.balance_after': 'Balance after',
'finance.col.tx_type': 'Type',
'finance.col.deposit_method': 'Deposit method',
'finance.deposit_method.manual_admin': 'Admin manual deposit',
'finance.deposit_method.manual_agent': 'Agent manual deposit',
'finance.deposit_method.manual': 'Manual deposit',
'finance.tx.deposit': 'Deposit',
'finance.tx.admin_deposit': 'Admin top-up',
'finance.tx.agent_deposit': 'Agent top-up',
'finance.tx.player_deposit': 'Self deposit',
'finance.tx.withdraw': 'Withdraw',
'finance.tx.admin_withdraw': 'Admin withdraw',
'finance.tx.agent_withdraw': 'Agent withdraw',
'finance.tx.request_id': 'Request ID',
'finance.remark.agent_deposit': 'Agent deposit',
'finance.remark.agent_withdraw': 'Agent withdraw',
'finance.remark.admin_deposit': 'Admin deposit',
'finance.remark.admin_withdraw': 'Admin withdraw',
'finance.remark.initial_balance': 'Initial account balance',
'agent.col.no_records': 'No records',
'agent.btn.confirm_adjust': 'Confirm',
'agent.field.select_user': 'Select user',
'agent.ph.select_user': 'Search player username',
'agent.hint.select_user': 'Pick an existing player account to promote to tier-1 agent (no new login)',
'agent.freeze.confirm_freeze_title': 'Confirm suspend agent',
'agent.freeze.confirm_freeze_body': 'Suspend agent "{name}"? They will not be able to sign in to the agent portal.',
'agent.freeze.confirm_unfreeze_body': 'Restore agent "{name}" to active status?',
'agent.freeze.opt_freeze_direct_players': 'Also freeze direct players',
'agent.freeze.opt_block_player_login': 'Block direct player login',
'agent.unfreeze.confirm_title': 'Confirm restore agent',
'agent.unfreeze.opt_unfreeze_direct_players': 'Also unfreeze direct players',
'agent.msg.cascade_freeze_done': 'Agent suspended and direct players frozen',
'agent.msg.cascade_unfreeze_done': 'Agent restored and direct players unfrozen',
'agent.msg.freeze_done': '{action} completed',
'match.create_btn': '+ New league',
'match.create_fixture_btn': '+ Add match',
'match.btn.markets': 'Markets',
'match.filter.keyword_ph': 'Tournament / team code',
'match.filter.status_hint': 'Filters fixtures inside a league and the fixture count column; empty leagues stay visible',
'match.col.league': 'Tournament',
'match.col.league_en': 'League (EN)',
'match.col.fixture_count': 'Fixtures',
'match.col.bet_count': 'Bets',
'match.col.total_stake': 'Total stake',
'match.col.pending_bets': 'Pending',
'match.col.league_code': 'Code',
'match.col.matchup': 'Matchup',
'match.col.kickoff': 'Kickoff',
'match.dialog.create_league': 'New tournament',
'match.dialog.edit_league': 'Edit tournament',
'match.dialog.create_fixture': 'New fixture',
'match.dialog.create': 'New fixture',
'match.dialog.edit': 'Edit fixture',
'match.dialog.import': 'Import matches',
'match.field.league_en': 'League (EN)',
'match.field.league_zh': 'League (ZH)',
'match.field.league_ms': 'League (MS)',
'match.field.league_logo': 'Tournament logo',
'match.field.lang_zh': 'ZH',
'match.field.lang_en': 'EN',
'match.field.lang_ms': 'MS',
'match.field.kickoff': 'Kickoff time',
'match.field.home_team': 'Home team',
'match.field.away_team': 'Away team',
'match.field.home_en': 'Home (EN)',
'match.field.home_zh': 'Home (ZH)',
'match.field.home_ms': 'Home (MS)',
'match.field.away_en': 'Away (EN)',
'match.field.away_zh': 'Away (ZH)',
'match.field.away_ms': 'Away (MS)',
'match.field.featured': 'Featured',
'match.hint.create_draft': 'Saved as draft; expand the tournament and publish each fixture to open markets.',
'match.hint.create_league': 'New tournaments are unpublished by default; use Publish in the list for player visibility, then expand to add fixtures.',
'league.status.PUBLISHED': 'Published',
'league.status.UNPUBLISHED': 'Unpublished',
'league.btn.unpublish': 'Unpublish',
'league.confirm_unpublish': 'Players will no longer see this tournament; you can still edit and republish in admin. Continue?',
'msg.league_published': 'Tournament published',
'msg.league_unpublished': 'Tournament unpublished',
'match.btn.unpublish': 'Unpublish',
'match.confirm_unpublish': 'The fixture will return to draft: hidden from players and no new bets; existing unsettled bets remain. Continue?',
'msg.match_unpublished': 'Fixture unpublished',
'match.hint.edit_published': 'Published: edit kickoff, featured, display names; closed/settled are locked.',
'match.expand_league_hint': 'Expand a league to manage fixtures; use Outright odds for winner markets.',
'match.expand_outright_hint': 'Expand a league to edit winner odds; fixture teams sync automatically, and you can add teams not yet on the schedule.',
'outright.odds_only_hint': 'Teams from fixtures are added automatically; add extra teams manually and edit winner odds here. Outright follows league publish—no separate publish step.',
'outright.league_unpublished_hint': 'League is not published yet. Set the league to Published on this page to open outright betting automatically.',
'outright.unsettled_fixtures_hint': '{n} fixture(s) in this league are still unsettled. Settle them before settling the outright market.',
'outright.btn.reopen': 'Reopen betting',
'outright.confirm_reopen': 'Reopening will accept new outright bets again. Continue?',
'outright.fixture_sync_added': '{n} team(s) synced automatically from fixtures',
'outright.col.teams_from_fixtures': 'Teams (from fixtures)',
'outright.col.teams_total': 'Outright teams',
'outright.empty_no_teams': 'No teams yet — add fixtures under Fixtures or click Add team.',
'match.outright.setup': 'Set up',
'match.outright.section_hint': 'Winner market for this league; fixtures are listed below',
'match.expand_markets_hint': 'Click Markets on a fixture to open the dedicated markets page.',
'match.no_fixtures': 'No fixtures under this tournament yet.',
'match.ph.league_ms': 'World Cup 2027',
'bet.filter.keyword_ph': 'Bet no. / username',
'bet.filter.date_from': 'Placed from',
'bet.filter.date_start_ph': 'Start',
'bet.filter.date_end_ph': 'End',
'bet.col.serial': 'No.',
'bet.col.bet_no': 'Bet no.',
'bet.col.player': 'Player',
'bet.col.agent': 'Agent',
'bet.col.selection': 'Pick',
'bet.col.content': 'Selections',
'bet.content.bet_counts': '{singles} single · {parlays} parlay',
'bet.col.match': 'Match',
'bet.legs_more': '+{n} more…',
'bet.col.selection_count': 'Legs',
'bet.col.stake': 'Stake',
'bet.col.odds': 'Odds',
'bet.col.payout': 'Payout',
'bet.col.placed_at': 'Placed at',
'bet.col.cashbacked': 'Cashbacked',
'bet.dialog.detail': 'Bet details',
'bet.field.total_odds': 'Total odds',
'bet.field.currency': 'Currency',
'bet.field.potential_win': 'Potential win',
'bet.field.actual_payout': 'Actual payout',
'bet.field.bet_status': 'Bet status',
'bet.field.settlement_status': 'Settlement',
'bet.field.settled_at': 'Settled at',
'bet.field.request_id': 'Request ID',
'bet.selections_title': 'Selections ({n})',
'bet.col.market': 'Market',
'bet.col.period': 'Period',
'bet.col.line': 'Line',
'bet.col.result': 'Result',
'audit.module_ph': 'e.g. USERS, AGENTS',
'audit.col.action': 'Action',
'audit.col.module': 'Module',
'audit.col.target_id': 'Target ID',
'audit.col.time': 'Time',
'audit.action.CREATE_PLAYER': 'Create player',
'audit.action.UPDATE_PLAYER': 'Update player',
'audit.action.RESET_DATABASE': 'Reset database',
'audit.action.CREATE_AGENT': 'Create agent',
'audit.action.UPDATE_AGENT': 'Update agent',
'audit.action.UPDATE_PLAYER_ACCOUNT_SETTINGS': 'Update player account settings',
'audit.action.UPDATE_AGENT_SUSPEND_SETTINGS': 'Update agent suspend settings',
'audit.action.UPDATE_BETTING_LIMITS': 'Update betting limits',
'audit.action.CONFIRM_SETTLEMENT': 'Confirm settlement',
'audit.action.CONFIRM_RESETTLE': 'Confirm resettlement',
'audit.action.CONFIRM_CASHBACK': 'Confirm cashback payout',
'audit.action.CANCEL_CASHBACK': 'Cancel cashback batch',
'audit.module.USERS': 'Players',
'audit.module.AGENTS': 'Agents',
'audit.module.SYSTEM': 'System',
'audit.module.SETTINGS': 'Settings',
'audit.module.SETTLEMENT': 'Settlement',
'audit.module.CASHBACK': 'Cashback',
'cashback.start_date': 'Start date',
'cashback.end_date': 'End date',
'cashback.preview_btn': 'Preview',
'cashback.preview_title': 'Cashback preview',
'cashback.stat.players': 'Players',
'cashback.stat.total': 'Total cashback',
'cashback.stat.lines': 'Line items',
'cashback.stat.effective_stake': 'Total effective stake',
'cashback.stat.bet_count': 'Eligible bets',
'cashback.stat.avg_rate': 'Average rate',
'cashback.batch_no': 'Batch no.',
'cashback.history_title': 'Cashback history',
'cashback.history_empty': 'No cashback batches yet',
'cashback.filter_status': 'Status',
'cashback.status.PREVIEW': 'Pending',
'cashback.status.CONFIRMED': 'Paid out',
'cashback.col.period': 'Period',
'cashback.col.status': 'Status',
'cashback.col.bet_count': 'Bets',
'cashback.col.created_at': 'Created',
'cashback.col.confirmed_at': 'Paid at',
'cashback.col.operator': 'Operator',
'cashback.view_detail': 'Details',
'cashback.detail_title': 'Batch breakdown',
'cashback.detail_summary': 'Batch summary',
'cashback.table_title': 'Player cashback breakdown',
'cashback.table_total': 'Total',
'cashback.empty_items': 'No eligible cashback in this period',
'cashback.col.index': '#',
'cashback.col.player': 'Player',
'cashback.col.agent': 'Agent',
'cashback.col.balance': 'Balance',
'cashback.col.effective_stake': 'Effective stake',
'cashback.col.rate': 'Rate',
'cashback.col.amount': 'Cashback',
'cashback.confirm_issue': 'Confirm payout',
'cashback.cancel_issue': 'Void',
'cashback.confirm_prompt': 'Pay out this cashback batch to player wallets? Cashback is credited by the platform directly and is not deducted from agents. This cannot be undone.',
'cashback.cancel_prompt': 'Void this pending batch? No wallet credit will be made; you can preview again.',
'cashback.status.CANCELLED': 'Voided',
'cashback.rules_title': 'Cashback rules',
'cashback.rule_period': 'Pick a date range. Bets are included by settlement time within that period.',
'cashback.rule_eligible': 'Included: settled bets with result WON or LOST (singles by stake; parlays counted once by parlay stake). Excluded: pending, cancelled, void, push, zero-rate bets, and bets already paid cashback.',
'cashback.rule_formula': 'Per bet: stake × applicable cashback rate. Amounts are summed per player into one line item.',
'cashback.rule_rate': 'Rate priority: player rule > agent rule > global rule > default rate (agent-line players use their agent default; platform-direct players use the rate under Global settings; override per player in player management). Enter as percent, e.g. 1 = 1%.',
'cashback.rule_flow': 'Flow: preview (one pending batch per period) → review → confirm payout; void if not needed. Paid periods cannot be previewed again.',
'cashback.rule_platform': 'Payout: cashback is credited to player cash balance by the platform; it is not deducted from agent credit or balance.',
'cashback.rule_note_zero': 'If preview is 0, check for settled WON/LOST bets in the period and a cashback rate above 0 (including platform-direct default in Global settings and per-player/agent overrides).',
'cashback.use_custom_rate': 'Custom cashback rate',
'cashback.use_default_rate': 'Use default rate {rate}',
'cashback.settings_title': 'Cashback settings',
'cashback.platform_direct_default_rate': 'Platform-direct default cashback rate',
'cashback.admin_invite_default_rate': 'Admin invite cashback rate',
'cashback.admin_invite_default_hint': 'Used when players register with an admin invite code; defaults to platform-direct rate. Agent invites use the rate set for that agent under Agent management.',
'cashback.platform_direct_default_hint': 'Used for self-registration without an invite code unless overridden per player.',
'user.field.player_id': 'Player ID',
'user.field.bet_count': 'Bet count',
'user.field.total_stake': 'Total stake',
'user.field.registered_at': 'Registered',
'user.ph.remark_initial': 'Ledger note when initial balance > 0',
'user.bets_edit_value': '{n} bets / {stake}',
'user.login_fail_value': '{n} times',
'match.import_hint': 'Paste JSON with matches array. Imports as draft; publish from the list.',
'match.import_start': 'Import',
'match.import_json_ph': '{"matches":[...]}',
'match.delete_confirm_title': 'Delete match',
'match.delete_confirm_body': 'Delete "{title}"? Draft with no bets only.',
'archive.match_title': 'Delete match',
'archive.league_title': 'Delete league',
'archive.target': 'Match: {title}',
'archive.league_target': 'League: {name}',
'archive.soft_delete_hint': 'Soft delete: data remains, hidden from admin and player lists.',
'archive.pending_summary': '{count} pending bet(s), stake {stake}',
'archive.refund_pending': 'Refund pending bet stakes',
'archive.parlay_void_hint': 'Parlay slips touching this match are voided in full.',
'archive.force_delete': 'Force delete',
'archive.msg_done': 'Deleted',
'archive.msg_done_refund': 'Deleted; refunded {n} bet(s)',
'archive.league_hint': 'League can be deleted only when all fixtures and outrights are settled with no pending bets.',
'archive.league_blocked': 'Unsettled fixtures or pending bets remain under this league.',
'archive.league_ready': 'This league can be deleted; all child events will be hidden.',
'archive.league_confirm': 'Delete league',
'archive.league_done': 'League deleted',
'archive.warning.PENDING_BETS': 'Pending bets exist',
'archive.warning.UNSETTLED_MATCH': 'Match is not in a terminal state',
'archive.warning.PREVIEW_BATCH': 'A settlement preview batch is pending',
'match.ph.league_en': 'FIFA World Cup 2026',
'match.ph.league_zh': '2026 World Cup',
'match.ph.kickoff': '2026-06-11T19:00:00Z',
'match.ph.home_en': 'Mexico',
'match.ph.home_zh': 'Mexico',
'match.ph.home_ms': 'Mexico',
'match.ph.away_en': 'South Africa',
'match.ph.away_zh': 'South Africa',
'match.ph.away_ms': 'Afrika Selatan',
'matchEditor.manage_btn': 'Basic info',
'matchEditor.back': 'Back to list',
'matchEditor.title': 'Edit basic info',
'matchEditor.section_info': 'Basic info',
'matchEditor.section_markets': 'Markets & odds',
'matchEditor.field.league_logo': 'Logo',
'matchEditor.field.home_logo': 'Logo',
'matchEditor.field.away_logo': 'Logo',
'matchEditor.field.pick_flag': 'Pick flag',
'matchEditor.field.custom_logo_url': 'Custom image URL',
'matchEditor.ph.logo_url': 'https://...',
'matchEditor.field.match_name': 'Display name',
'matchEditor.field.stage': 'Stage',
'matchEditor.field.group': 'Group',
'matchEditor.field.display_order': 'Sort order',
'matchEditor.field.promo_label': 'Promo label',
'matchEditor.field.promo_label_optional': 'Promo label (optional)',
'matchEditor.field.line_value': 'Line',
'matchEditor.ph.kickoff': 'Select kickoff date & time',
'matchEditor.group.league': 'League',
'matchEditor.hint.league_readonly': 'Edit league name and logo from the tournament list; shown here read-only.',
'matchEditor.group.home': 'Home team',
'matchEditor.group.away': 'Away team',
'matchEditor.group.schedule': 'Schedule & display',
'matchEditor.save_info': 'Save info',
'matchEditor.save_market': 'Save market',
'matchEditor.save_odds': 'Save odds',
'matchEditor.generate_templates': 'Generate templates',
'matchEditor.templates_generated': 'Market templates created',
'matchEditor.no_markets': 'No markets yet — publish the match or generate templates.',
'matchEditor.market.FT_1X2': 'FT 1X2',
'matchEditor.market.FT_HANDICAP': 'FT handicap',
'matchEditor.market.FT_OVER_UNDER': 'FT O/U',
'matchEditor.market.FT_ODD_EVEN': 'FT odd/even',
'matchEditor.market.HT_1X2': 'HT 1X2',
'matchEditor.market.HT_HANDICAP': 'HT handicap',
'matchEditor.market.HT_OVER_UNDER': 'HT O/U',
'matchEditor.market.FT_CORRECT_SCORE': 'FT correct score',
'matchEditor.market.HT_CORRECT_SCORE': 'HT correct score',
'matchEditor.market.SH_CORRECT_SCORE': '2H correct score',
'matchEditor.period.FT': 'Full time',
'matchEditor.period.HT': 'Half time',
'matchEditor.period.SH': 'Second half',
'matchEditor.period.OUTRIGHT': 'Outright',
'matchEditor.selection.HOME': 'Home',
'matchEditor.selection.DRAW': 'Draw',
'matchEditor.selection.AWAY': 'Away',
'matchEditor.selection.OVER': 'Over',
'matchEditor.selection.UNDER': 'Under',
'matchEditor.selection.ODD': 'Odd',
'matchEditor.selection.EVEN': 'Even',
'matchEditor.selection.OTHER_DRAW': 'Draw (other score)',
'matchEditor.selection.OTHER_HOME': 'Home win (other score)',
'matchEditor.selection.OTHER_AWAY': 'Away win (other score)',
'matchEditor.col.selection_code': 'Option',
'matchEditor.col.selection_name': 'Display name',
'matchEditor.col.odds': 'Odds',
'matchEditor.ph.selection_name': 'Name shown to players',
'err.username_required': 'Username is required',
'err.username_player_invalid': 'Player username must be 332 English letters or digits only',
'err.password_min': 'Password must be at least 8 characters',
'err.password_mismatch': 'Passwords do not match',
'err.credit_negative': 'Credit limit cannot be negative',
'err.insufficient_credit': 'Insufficient available credit. Reduce the amount or request a limit increase.',
'err.kickoff_required': 'Kickoff time is required',
'err.team_country_required': 'Select home and away teams',
'err.teams_required': 'Enter home and away team names (ZH or EN)',
'err.teams_same': 'Home and away teams must be different',
'err.league_required': 'League name is required',
'err.user_required': 'Please select a user',
'err.agent_no_parent': 'Tier-1 agents cannot have a parent player',
'err.agent_no_initial_deposit': 'Do not set initial player balance when creating an agent',
'err.initial_deposit_kind_required': 'Select a ledger note when initial balance > 0',
'err.initial_deposit_custom_required': 'Custom ledger note must be at least 2 characters',
'settlement.back': 'Back to matches',
'settlement.kickoff': 'Kick-off',
'settlement.stats_title': 'Betting statistics',
'settlement.stats_total_bets': 'Bets',
'settlement.stats_single': 'Singles',
'settlement.stats_parlay': 'Parlays',
'settlement.stats_total_stake': 'Total stake',
'settlement.stats_potential': 'Max potential win',
'settlement.chart.bet_type': 'Single vs parlay',
'settlement.chart.status': 'Bet status mix',
'settlement.chart.stake_by_selection': 'Top selections by single stake',
'settlement.stats_by_market': 'By market / selection',
'settlement.bet_list': 'Related bets',
'settlement.bet_list_hint': 'Grouped by bet; same-match parlays show ×legs',
'settlement.no_bets': 'No bets on this match',
'settlement.col.market': 'Market',
'settlement.col.selection': 'Selection',
'settlement.col.legs': 'Legs',
'settlement.col.single_stake': 'Single stake',
'settlement.col.parlay_legs': 'Parlay legs',
'settlement.ht_score': 'Half-time score',
'settlement.ft_score': 'Full-time score',
'settlement.record_score': 'Save score',
'settlement.preview_hint': 'Preview moves the match to pending settlement and calculates payouts (scores are saved on confirm; you can reopen betting before confirming)',
'settlement.preview_btn': 'Preview settlement',
'settlement.preview_failed': 'Failed to generate settlement preview',
'settlement.err_score_not_recorded': 'Enter half-time and full-time scores before preview',
'settlement.must_close_first': 'Close betting before settlement',
'settlement.outright.page_title': 'Outright settlement',
'settlement.outright.title': 'Outright winner',
'settlement.outright.winner': 'Winning team',
'settlement.outright.winner_ph': 'Select champion',
'settlement.outright.preview_hint': 'Select the winner, preview payouts, then confirm settlement',
'settlement.outright.winner_required': 'Please select the winning team first',
'settlement.preview_title': 'Settlement preview',
'settlement.single_count': 'Single bets',
'settlement.preview_pending_bets': 'Pending bets',
'settlement.preview_bet_mix': 'Single / parlay',
'settlement.preview_items_title': 'Per-bet preview ({n})',
'settlement.preview_items_scroll_hint': 'Scroll when the list is long',
'settlement.preview_col.result': 'Match result',
'settlement.preview_zero_parlay_hint':
'Est. payout is 0: {legs} winning leg(s) on this match are in cross-match parlays ({pending} bet(s) awaiting other fixtures).',
'settlement.preview_zero_lost_hint':
'Est. payout is 0: {count} parlay(s) already lost on this match; other bets did not win here either.',
'settlement.preview_zero_default_hint':
'Est. payout is 0: no winning or refundable outcome on this match yet.',
'settlement.preview.result.WIN': 'Win',
'settlement.preview.result.LOSE': 'Lose',
'settlement.preview.result.LOST': 'Parlay lost',
'settlement.preview.result.WON': 'Parlay won',
'settlement.preview.result.PUSH': 'Push',
'settlement.preview.result.PENDING_OTHER_MATCHES': 'Awaiting other matches',
'settlement.est_payout': 'Est. payout',
'settlement.refund_amount': 'Refund amount',
'settlement.confirm_btn': 'Confirm settlement',
'settlement.resettle_reason': 'Resettle reason',
'settlement.resettle_preview': 'Resettle preview',
'settlement.resettle_preview_title': 'Resettle preview',
'settlement.resettle_affected': 'Affected bets',
'settlement.resettle_topup': 'Top-up required',
'settlement.resettle_clawback': 'Clawback required',
'settlement.resettle_confirm': 'Confirm resettle',
'user.betting_limits': 'Betting limits',
'user.betting_limits_hint': 'Global stake/payout/daily limits for player bets',
'user.limit.min_stake': 'Min stake',
'user.limit.max_stake_single': 'Max single stake',
'user.limit.max_stake_parlay': 'Max parlay stake',
'user.limit.max_payout_single': 'Max single payout',
'user.limit.max_payout_parlay': 'Max parlay payout',
'user.limit.daily_stake': 'Daily stake limit',
'settlement.smart.btn': 'Smart score',
'settlement.smart.title': 'Smart score suggestions',
'settlement.smart.hint': 'Enumerates valid scores from pending single bets and estimates payout. Parlays are excluded. Click a card to apply.',
'settlement.smart.target_hold': 'Target house hold',
'settlement.smart.recalc': 'Recalculate',
'settlement.smart.apply': 'Apply score',
'settlement.smart.applied': 'Score applied',
'settlement.smart.no_bets': 'No pending single bets',
'settlement.smart.empty': 'No suggestion found',
'settlement.smart.meta': 'Singles {singles}, parlays {parlays} skipped, {n} scores compared',
'settlement.smart.hold': 'Hold',
'settlement.smart.payout': 'Payout',
'settlement.smart.win_stake': 'Win stake %',
'settlement.smart.wl': 'W/L bets',
'settlement.smart.strategy.MIN_PAYOUT': 'Max house hold (min payout)',
'settlement.smart.strategy.MAX_PAYOUT': 'Max player payout',
'settlement.smart.strategy.BALANCED': 'Balanced (~50% win stake)',
'settlement.smart.strategy.TARGET_HOLD': 'Target hold rate',
'msg.score_recorded': 'Score saved',
'msg.settlement_confirmed': 'Settlement confirmed',
'msg.resettle_confirmed': 'Resettle confirmed',
'agent_portal.create_player_section': 'Create player',
'agent_portal.deposit_section': 'Top up',
'agent_portal.create_player_btn': '+ New player',
'agent_portal.create_tier2_btn': '+ New tier-2 agent',
'agent_portal.username_ph': 'Enter username',
'agent_portal.agent_username_ph': 'Agent username',
'agent_portal.player_id_ph': 'Player ID',
'agent_portal.withdraw_btn': 'Withdraw {amount}',
'agent_portal.withdraw_btn_label': 'Withdraw',
'agent_portal.transfer_title_deposit': 'Top up {name}',
'agent_portal.transfer_title_withdraw': 'Withdraw from {name}',
'transfer.context.player_section': 'Player balance',
'transfer.context.agent_section': 'Credit agent · {name} (L{level})',
'transfer.context.withdrawable': 'Withdrawable',
'transfer.context.deposit_cap': 'Max top-up this time',
'transfer.context.daily_used': 'Topped up today',
'transfer.context.unlimited': 'No limit',
'transfer.context.no_agent': 'Platform-direct player — not limited by agent credit',
'transfer.context.admin_credit_only': 'Admin top-up is capped by parent available credit only (not single/daily limits)',
'transfer.context.withdraw_exceed': 'Withdrawal cannot exceed player available balance',
'credit.context.target_section': 'Target agent credit',
'credit.context.parent_section': 'Parent agent · {name}',
'credit.context.max_increase': 'Max increase',
'credit.context.no_parent': 'Tier-1 agents are platform-managed; increases are not capped by a parent',
'credit.context.after_adjust': 'Credit limit after adjustment',
'credit.context.direct_liability': 'Direct player exposure',
'credit.context.child_exposure': 'Sub-agent exposure',
'credit.context.acting_agent': 'Current agent',
'agent_portal.create_player_dialog': 'New direct player',
'agent_portal.edit_player_dialog': 'Edit direct player',
'agent_portal.my_cashback_rate': 'Cashback rate',
'agent_portal.credit_available_hint': 'Available credit: {amount} (top-ups deduct from your limit)',
'agent_portal.sub_agent_players_readonly': 'Players under this sub-agent are read-only here. Account opening and top-ups are handled by the sub-agent.',
'agent_portal.sub_agent_downline_readonly': 'All subordinate agents and players below this sub-agent are read-only. You may only operate direct sub-agents; account opening and top-ups are handled by each level.',
'agent_portal.sub_agent_downline_readonly_level': 'All agents and players below this L{level} agent are read-only. You may only operate direct sub-agents; account opening and top-ups are handled by each level.',
'agent_portal.downline_agents_title': 'Subordinate agents',
'agent_portal.downline_players_title': 'Subordinate players',
'agent_portal.no_downline_agents': 'No subordinate agents',
'agent_portal.no_downline_players': 'No subordinate players',
'agent_portal.initial_deposit_hint': 'Optional. Initial top-up from your credit at account creation',
'agent_portal.search_player_ph': 'Username or ID',
'agent_portal.no_players': 'No direct players yet. Use the button above to create one.',
'invite.title': 'Invitation code & register link',
'invite.menu_btn': 'Invite',
'invite.dialog_title': 'Invitations',
'invite.tab_generate': 'Generate',
'invite.tab_history': 'History',
'invite.hint': 'Generate an invite code and register link. Players may enter the code to register; blank registers as platform-direct.',
'invite.generate_btn': 'Generate code / link',
'invite.regenerate_btn': 'Regenerate',
'invite.generate_ok': 'Invitation code generated',
'invite.generate_failed': 'Failed to generate — please retry',
'invite.not_generated': 'No invitation code yet',
'invite.code': 'Code',
'invite.cashback_rate': 'Cashback rate',
'invite.cashback_rate_hint': 'Players registering with this code use this rate. Defaults to the global admin-invite cashback rate.',
'invite.link': 'Register link',
'invite.copy_code': 'Copy code',
'invite.copy_code_short': 'Copy',
'invite.copy_link': 'Copy register link',
'invite.copy_link_short': 'Copy link',
'invite.copy_code_ok': 'Invitation code copied',
'invite.copy_link_ok': 'Register link copied',
'invite.copy_failed': 'Copy failed — please copy manually',
'invite.unavailable': 'Invitation code unavailable',
'invite.history_title': 'Invitation history',
'invite.view_history': 'View history',
'invite.history_hint': 'Admins see all codes; agents see their own and sub-agents codes.',
'invite.page_desc': 'View all invitation codes, status, and registration counts.',
'invite.history_load_failed': 'Failed to load invitation history',
'invite.filter_status': 'Status',
'invite.filter_sponsor': 'Sponsor',
'invite.filter_code': 'Code',
'invite.filter_code_ph': 'Enter code',
'invite.col_status': 'Status',
'invite.col_sponsor': 'Sponsor',
'invite.col_registrant': 'Registrant',
'invite.not_registered': 'Not registered',
'invite.col_cashback_rate': 'Cashback',
'invite.col_created': 'Created',
'invite.col_revoked': 'Revoked',
'invite.status.ACTIVE': 'Active',
'invite.status.USED': 'Used',
'invite.status.REVOKED': 'Revoked',
'invite.revoke_btn': 'Revoke',
'invite.revoke_title': 'Revoke invitation code',
'invite.revoke_confirm': 'Revoke code {code}? It will no longer work for registration.',
'invite.revoke_ok': 'Invitation code revoked',
'invite.revoke_failed': 'Failed to revoke — please retry',
'invite.delete_title': 'Delete invitation record',
'invite.delete_confirm': 'Delete history for code {code}? This cannot be undone.',
'invite.delete_ok': 'Invitation record deleted',
'invite.delete_failed': 'Failed to delete — please retry',
'agent_portal.search_sub_agent_ph': 'Username or ID',
'agent_portal.no_sub_agents': 'No tier-2 agents yet. Use the button above to create one.',
'agent_portal.no_sub_agents_level': 'No L{level} agents yet. Use the button above to create one.',
'agent_portal.sub_agent_players_readonly_level': 'Direct players under this L{level} agent are read-only here. Account opening and top-ups are handled by that agent.',
'agent_portal.create_sub_agent_dialog': 'New tier-2 agent',
'agent_portal.sub_agent_credit_hint': 'Initial credit is allocated from your available limit',
'agent_portal.adjust_credit_dialog': 'Adjust credit for {name}',
'agent_portal.credit_adjust_hint': 'Positive to increase, negative to decrease',
'msg.agent_sub_created': 'Sub-agent created',
'msg.withdraw_ok': 'Withdrawal successful',
'msg.form_invalid': 'Please check the form',
'msg.player_created': 'Player created',
'msg.agent_created': 'Agent created',
'msg.create_failed': 'Create failed',
'msg.saved': 'Saved',
'msg.save_failed': 'Save failed',
'msg.deleted': 'Deleted',
'msg.delete_failed': 'Delete failed',
'msg.league_created': 'Tournament created',
'msg.league_updated': 'Tournament updated',
'msg.match_created_draft': 'Fixture created (draft)',
'msg.published': 'Published with markets',
'msg.closed': 'Betting closed',
'msg.reopened': 'Betting reopened',
'match.reopen_kickoff_title': 'Set new kickoff time',
'match.reopen_kickoff_hint': 'Kickoff has passed. Choose a new future start time before reopening.',
'match.reopen_kickoff_invalid': 'Please choose a future kickoff time',
'msg.invalid_json': 'Invalid JSON',
'msg.import_failed': 'Import failed',
'msg.import_done': 'Import: {imported} ok, {skipped} skipped, {failed} failed / {total} total',
'msg.topup_ok': 'Top-up successful',
'msg.topup_failed': 'Top-up failed',
'msg.transfer_failed': 'Operation failed',
'msg.amount_gt_zero': 'Amount must be greater than 0',
'msg.credit_zero': 'Adjustment cannot be 0',
'msg.credit_adjusted': 'Credit updated',
'msg.credit_adjust_failed': 'Adjustment failed',
'msg.outright_no_edit': 'Outright cannot be edited here',
'msg.outright_odds_saved': 'Outright odds saved',
'msg.load_failed': 'Load failed',
'content.btn.create': 'New content',
'content.btn.enable': 'Enable',
'content.btn.disable': 'Disable',
'content.dialog.create': 'New public content',
'content.dialog.edit': 'Edit public content',
'content.confirm_delete': 'Delete "{title}"?',
'content.type.BANNER': 'Home banners',
'content.type.ANNOUNCEMENT': 'Announcements',
'content.hint.announcement': 'Shown in the player top marquee; fill title or body (body recommended)',
'content.status.DRAFT': 'Draft',
'content.status.ACTIVE': 'Active',
'content.status.INACTIVE': 'Inactive',
'content.col.sort': 'Sort',
'content.col.preview': 'Preview',
'content.col.title': 'Title / summary',
'content.col.player_visible': 'Player visible',
'content.col.schedule': 'Schedule',
'content.col.link': 'Link',
'content.field.link_type': 'Link type',
'content.field.link_target': 'Link target',
'content.field.start_time': 'Start time',
'content.field.end_time': 'End time',
'content.field.title': 'Title',
'content.field.title_ph': 'Optional; can match body',
'content.field.body': 'Body',
'content.field.announce_text': 'Marquee text',
'content.field.image_url': 'Image URL',
'content.upload.upload_btn': 'Upload Image',
'content.upload.uploading': 'Uploading…',
'content.upload.success': 'Image uploaded',
'content.upload.failed': 'Upload failed',
'content.upload.size_error': 'Image must be under 5 MB',
'content.upload.remove': 'Remove image',
'content.upload.pick_media': 'Pick from library',
'content.upload.pick_media_title': 'Select Banner Image',
'content.upload.no_media': 'No banner images in library — upload one first',
'content.upload.url_placeholder': 'Or paste image URL',
'content.link.none': 'No link',
'content.locale.zh-CN': 'Chinese (Simplified)',
'content.locale.en-US': 'English',
'content.locale.ms-MY': 'Malay',
'content.hidden_reason.NOT_ACTIVE': 'Not active or draft',
'content.hidden_reason.NOT_STARTED': 'Not started yet',
'content.hidden_reason.EXPIRED': 'Expired',
'content.hidden_reason.INCOMPLETE': 'Incomplete translations',
'content.batch.selected': '{n} selected',
'content.batch.enable': 'Enable selected',
'content.batch.disable': 'Disable selected',
'content.batch.delete': 'Delete selected',
'content.confirm_batch_enable': 'Enable {n} selected item(s)?',
'content.confirm_batch_disable': 'Disable {n} selected item(s)?',
'content.confirm_batch_delete': 'Delete {n} selected item(s)?',
'content.batch.all_ok': '{n} item(s) processed',
'content.batch.partial': '{ok} succeeded, {fail} failed',
'page.outrights.title': 'Outrights',
'page.outrights.desc': 'Create and edit any winner market; WC 2026 has one-click baseline import',
'outright.col.rank': 'Rank',
'outright.col.team_zh': 'Team (ZH)',
'outright.col.team_en': 'Team (EN)',
'outright.col.code': 'Code',
'outright.col.country': 'Country',
'outright.col.odds': 'Winner odds',
'outright.country_ph': 'Search or select country',
'teamLogo.kind.flag': 'Flag',
'teamLogo.kind.crest': 'Crest',
'outright.err_country': 'Please select a country',
'outright.btn.save_odds': 'Save all odds',
'outright.btn.close': 'Close betting',
'outright.btn.settle': 'Settle',
'outright.confirm_close': 'Closing will stop new outright bets. Continue?',
'outright.btn.save_meta': 'Save event info',
'outright.btn.publish': 'Publish',
'outright.btn.unpublish': 'Unpublish',
'outright.back_list': 'Back to list',
'outright.section.edit': 'Edit outright',
'outright.col.teams': 'Teams',
'outright.col.player_visible': 'Player',
'outright.col.league_en': 'League (EN)',
'outright.expand_no_teams': 'No teams — open Edit to add',
'outright.fixtures_sync_hint': 'Teams come from league fixtures; adjust odds and publish status only.',
'outright.empty_no_fixtures': 'No fixtures in this league — add matches under Fixtures first.',
'outright.btn.add_team': 'Add team',
'outright.add.filter_fixture': 'From fixtures',
'outright.add.filter_all': 'All built-in',
'outright.add.filter_custom': 'Custom',
'outright.add.custom_hint': 'Enter team code and Chinese/English names; logo via upload or URL.',
'outright.add.field_code': 'Team code',
'outright.add.field_logo': 'Logo',
'outright.add.ph_code': 'e.g. TEAM01',
'outright.add.ph_name_zh': 'Chinese name',
'outright.add.ph_name_en': 'English name',
'outright.add.err_code_required': 'Team code is required',
'outright.add.err_name_required': 'Enter at least Chinese or English name',
'outright.add.err_duplicate': 'This team code is already on the outright market',
'outright.add.select_all': 'Select all',
'outright.add.clear_selection': 'Clear selection',
'outright.add.selected_count': '{n} selected',
'outright.add.empty_fixture': 'No fixture teams to add (teams in matches but not yet on the outright market)',
'outright.add.empty_all': 'All built-in teams are already on the outright market',
'outright.add.default_odds': 'Default odds',
'outright.add.search_ph': 'Search name or code',
'outright.add.err_none': 'Select at least one team',
'outright.batch.mode': 'Batch manage',
'outright.batch.exit': 'Exit batch',
'outright.batch.apply_odds': 'Apply odds',
'outright.batch.remove': 'Remove selected',
'outright.batch.confirm_remove': 'Remove {n} selected team(s)?',
'outright.batch.err_none': 'Select teams first',
'outright.batch.apply_ok': 'Updated odds for {n} team(s) — click Save all odds',
'outright.batch.remove_ok': 'Removed {n} team(s)',
'outright.batch.remove_partial': '{ok} removed, {fail} failed',
'outright.sort.label': 'Sort',
'outright.sort.rank': 'Rank',
'outright.sort.name': 'Team name',
'outright.sort.code': 'Code',
'outright.sort.odds': 'Odds (current)',
'outright.sort.saved_odds': 'Odds (saved)',
'outright.sort.asc': 'Ascending',
'outright.sort.desc': 'Descending',
'msg.outright_teams_added': 'Added {n} team(s) ({skipped} skipped)',
'outright.btn.create_event': 'New outright event',
'outright.btn.import_wc2026': 'Import WC 2026 (48)',
'outright.btn.apply_canonical': 'Apply WC baseline',
'outright.field.league': 'League',
'outright.section.settings': 'Event settings',
'outright.section.teams': 'Teams & odds',
'outright.field.title': 'Event title',
'outright.field.title_placeholder': 'Title shown on player outright tab',
'outright.field.title_zh': 'Title (ZH)',
'outright.field.title_en': 'Title (EN)',
'outright.field.title_ms': 'Title (MS)',
'outright.field.status': 'Status',
'outright.status.draft': 'Draft',
'outright.status.published': 'Published',
'msg.outright_canonical_applied': '48-team winner odds applied from baseline',
'outright.team_count': '{n} / {total} teams',
'outright.team_count_open': '{n} teams',
'outright.empty_events': 'No outright events',
'outright.empty_hint': 'Create an event or import WC 2026 baseline',
'outright.err_odds_min': 'Odds must be greater than 1.00',
'outright.err_team_code': 'Team code required',
'outright.err_league': 'Select a league',
'outright.confirm_remove': 'Remove "{name}"? (closes selection)',
'outright.not_on_player': 'Hidden on player',
'outright.player_hidden_title': 'Not visible on player app yet',
'outright.hidden_reason.NOT_PUBLISHED': 'Set status to Published and save event info.',
'outright.hidden_reason.NO_SELECTIONS': 'Add at least one open team selection.',
'outright.hidden_reason.MARKET_CLOSED': 'Winner market is not open.',
'msg.load_matches_failed': 'Failed to load matches',
'msg.cashback_issued': 'Cashback issued',
'msg.cashback_cancelled': 'Cashback batch voided',
'msg.cashback_preview_ready': 'Preview ready — review and confirm payout',
'msg.cashback_preview_replaced': 'Replaced {n} older preview(s) for this period',
'msg.freeze_confirm_title': '{action} account',
'msg.freeze_confirm_body': '{action} player "{name}"?{extra}',
'msg.freeze_extra': ' They will not be able to sign in.',
'msg.freeze_done': '{action} completed',
'msg.freeze_failed': '{action} failed',
'smoke.intro': 'Run automated smoke tests from the admin console: settlement, betting rules, agent credit logic, cashback rules, read-only DB probes, and bet→settle→wallet integration.',
'smoke.intro_rule': 'Rule cases reuse the same logic as Jest unit tests; no business data is written.',
'smoke.intro_db': 'The Database suite only checks connectivity and config (read-only).',
'smoke.intro_bet_flow': 'The Bet-flow suite creates temporary matches/players, verifies freeze/payout/agent credit, then cleans up.',
'smoke.intro_note': 'Covers most UAT regression; spot-check cashback payout manually if needed.',
'smoke.field.suites': 'Suites',
'smoke.ph.suites': 'Select suites to run',
'smoke.btn.run': 'Run tests',
'smoke.last_run': 'Last run',
'smoke.results_title': 'Case results',
'smoke.empty': 'No run yet. Click Run tests.',
'smoke.stat.pass': 'Pass',
'smoke.stat.fail': 'Fail',
'smoke.stat.total': 'Total',
'smoke.col.id': 'ID',
'smoke.col.suite': 'Suite',
'smoke.col.name': 'Case',
'smoke.col.uat': 'UAT',
'smoke.col.duration': 'Duration',
'smoke.col.steps': 'Steps',
'smoke.col.message': 'Message',
'smoke.no_steps': 'No step details',
'smoke.status.PASS': 'Pass',
'smoke.status.FAIL': 'Fail',
'smoke.status.SKIP': 'Skip',
'smoke.msg.all_passed': 'All passed ({n})',
'smoke.msg.has_failures': '{n} case(s) failed — see details',
'smoke.msg.run_failed': 'Failed to run tests',
'smoke.log_title': 'Detailed log',
'smoke.btn.copy_all': 'Copy full log',
'smoke.btn.copy_one': 'Copy',
'smoke.msg.copy_ok': 'Copied to clipboard',
'smoke.msg.copy_failed': 'Copy failed — select the log manually',
'audit.action.RUN_SMOKE_TESTS': 'Run smoke tests',
'media.title': 'Media Library',
'media.upload_btn': 'Upload File',
'media.category.all': 'All',
'media.category.banners': 'Banners',
'media.category.teams': 'Team Logos',
'media.category.contents': 'Content Images',
'media.col.preview': 'Preview',
'media.col.filename': 'Filename',
'media.col.category': 'Category',
'media.col.size': 'Size',
'media.col.status': 'Status',
'media.col.uploaded': 'Uploaded',
'media.col.actions': 'Actions',
'media.status.used': 'In Use',
'media.status.unused': 'Unused',
'media.purge_btn': 'Purge Unused',
'media.purge_confirm': 'Delete {n} unused file(s)? This cannot be undone.',
'media.purge_none': 'No unused files',
'media.purge_success': '{n} file(s) deleted',
'media.delete_confirm': 'Delete this file?',
'media.delete_success': 'Deleted',
'media.upload_success': 'Upload successful',
'media.upload_failed': 'Upload failed',
'media.copy_url': 'Copy URL',
'media.url_copied': 'URL copied',
'media.upload_dialog': 'Upload File',
'media.upload_hint': 'PNG, JPG, WEBP, GIF, SVG — max 5 MB',
'media.upload_category': 'Category',
'media.drop_hint': 'Drop file here or click to select',
'media.no_files': 'No files yet',
'media.refresh': 'Refresh',
'media.unused_count': '{n} unused',
};
export default adminPages;

View File

@@ -0,0 +1,1017 @@
/** 列表页 / 弹窗文案(并入 admin-messages */
const adminPages: Record<string, string> = {
'common.detail': '详情',
'common.create': '创建',
'common.create_btn': '+ 新建',
'common.save': '保存',
'common.close': '关闭',
'common.import': '导入',
'common.publish': '发布',
'common.topup': '上分',
'common.adjust_credit': '调额',
'common.freeze': '冻结',
'common.unfreeze': '解冻',
'common.settle': '结算',
'common.resettle': '重新结算',
'common.close_betting': '封盘',
'common.reopen_betting': '解除封盘',
'common.never_login': '从未登录',
'common.optional': '选填',
'common.to': '止',
'common.module': '模块',
'common.col_id': 'ID',
'common.times': '次',
'common.bets_count_unit': '笔',
'user.create_btn': '+ 新建玩家',
'user.filter.username_ph': '用户名',
'user.filter.agent': '所属代理',
'user.filter.agent_ph': '全部',
'user.col.username': '用户名',
'user.col.agent': '所属代理',
'user.col.agent_cashback': '代理返水率',
'user.col.player_cashback': '玩家返水率',
'user.col.invite_code': '邀请码',
'user.col.balance': '可用 / 冻结',
'user.col.bets': '注单',
'user.col.stake_payout': '投注 / 派彩',
'user.col.last_login': '最后登录',
'user.col.created': '注册时间',
'user.status.ACTIVE': '正常',
'user.status.SUSPENDED': '停用',
'user.dialog.create': '新建玩家',
'user.dialog.edit': '编辑玩家',
'user.dialog.deposit': '玩家上分',
'user.dialog.detail': '玩家详情',
'user.field.password': '登录密码',
'user.field.confirm_password': '确认密码',
'user.field.initial_balance': '初始余额',
'user.field.deposit_remark': '上分备注',
'user.field.initial_deposit_kind': '流水说明',
'user.initial_deposit_kind.daily': '日常充值',
'user.initial_deposit_kind.opening_bonus': '开户赠金',
'user.initial_deposit_kind.custom': '自定义',
'user.ph.initial_deposit_custom': '请输入流水说明(至少 2 个字符)',
'user.field.amount': '金额',
'user.field.remark': '备注',
'user.field.account_status': '账号状态',
'user.field.available': '可用余额',
'user.field.frozen_balance': '冻结余额',
'user.field.bets_summary': '注单 / 投注',
'user.field.total_payout': '累计派彩',
'user.field.login_fail': '登录失败',
'user.field.phone': '手机',
'user.field.email': '邮箱',
'user.field.allow_password_change': '允许玩家改密码',
'user.field.allow_username_change': '允许玩家改账号名',
'user.field.view_password': '登录密码',
'user.field.reset_password': '重置密码',
'user.password_not_stored': '未记录(玩家已自行修改或未保存)',
'user.btn.show_password': '查看',
'user.btn.hide_password': '隐藏',
'user.ph.reset_password': '留空则不修改;填写后将更新并可查看',
'user.ph.reset_password_short': '留空不修改',
'user.page_settings': '全局设置',
'user.global_settings': '密码与账号管理(全局)',
'user.global_settings_hint': '控制所有玩家是否可在 App 内改密码、改账号名',
'user.reset_database': '重置数据库',
'user.reset_database_hint': '清空全部业务数据并恢复为初始演示数据(用户、赛事、注单、账变等)。此操作不可撤销。',
'user.reset_database_confirm_label': '请输入 RESET 以确认',
'user.reset_database_confirm_ph': '输入 RESET',
'user.reset_database_btn': '重置为初始数据',
'user.reset_database_disabled_prod': '生产环境已禁用;需服务端设置 ALLOW_DB_RESET=true',
'user.reset_database_success': '数据库已重置,请使用初始账号重新登录',
'user.reset_database_accounts': '演示账号',
'user.section.basic_info': '基本信息',
'user.section.affiliation': '归属设置',
'user.section.contact': '联系方式',
'user.section.account_overview': '账户概览',
'user.section.password_mgmt': '密码管理',
'user.field.current_password': '当前密码',
'user.msg.created_with_password': '玩家已创建,登录密码:{password}',
'user.msg.password_saved': '密码已更新,当前可查密码:{password}',
'user.hint.password_reset_to_view': '旧账号暂无记录。请在下方「重置密码」填写新密码并保存,即可在此查看。',
'user.ph.username_unique': '登录用户名,唯一',
'user.ph.username_player': '字母与数字332 位',
'user.hint.username_player': '仅允许英文字母和数字,不可含中文或特殊符号',
'user.ph.no_agent': '不设置(平台直属玩家)',
'user.hint.no_agent': '留空表示不挂靠代理,由平台直接管理',
'user.hint.platform_direct_player': '该玩家隶属于平台(管理员直属)',
'user.hint.initial_balance': '创建后自动上分0 表示不开户上分',
'user.hint.deposit_remark': '有初始余额时写入流水备注',
'user.hint.freeze_in_list': '冻结/解冻请在列表操作列进行',
'user.hint.agent_change': '留空表示平台直属;变更后会重算相关代理已用授信',
'user.hint.agent_readonly': '所属代理创建后不可修改',
'user.hint.allow_password_change': '关闭后所有玩家均不可在客户端修改密码',
'user.hint.allow_username_change': '开启后所有玩家均可在资料页修改登录账号名',
'user.hint.view_password': '仅保存后台创建或重置时的密码;玩家自行改密后会清除',
'user.hint.reset_password': '重置后立即生效,并更新上方可查密码',
'user.btn.create': '创建',
'user.btn.save_profile': '保存资料',
'user.btn.confirm_deposit': '确认上分',
'user.deposit_remark_default': '管理员上分',
'user.withdraw_remark_default': '管理员下分',
'user.field.account_type': '账号类型',
'user.type.player': '玩家',
'user.type.tier1_agent': '一级代理',
'user.type.sub_agent': '二级代理',
'user.hint.account_type': '代理使用授信额度;玩家可挂靠代理并上分',
'agent.create_btn': '+ 新建一级代理',
'agent.create_sub_btn': '+ 新建二级代理',
'agent.create_sub': '创建二级代理',
'agent.create_child_btn': '+ 新建下级代理',
'agent.dialog.create_child_agent': '新建下级代理',
'agent.create_level_agent': '创建{level}级代理',
'agent.create_level_agent_btn': '+ 新建{level}级代理',
'agent.level_name': '{level}级代理',
'agent.level_tab': '{level}级代理',
'agent.dialog.create_level_agent': '新建{level}级代理',
'agent.hint.select_parent_for_level': '请选择 {level} 级代理作为上级',
'agent.err.parent_level_mismatch': '上级代理层级不正确,无法创建 {level} 级代理',
'agent.hint.creating_under_agent': '在此代理下创建账号',
'agent.filter.username_ph': '用户名',
'agent_mgr.tab.players': '玩家',
'agent_mgr.tab.agents': '代理',
'agent.col.level': '层级',
'agent.col.credit': '授信/已用/可用',
'agent.col.direct_players': '直属玩家',
'agent.direct_players_title': '直属玩家 · {name}',
'agent.platform_row_name': '平台',
'agent.col.sub_agents': '下级代理',
'agent.col.cashback': '返水率',
'agent.col.phone': '手机',
'agent.col.created': '创建时间',
'agent.dialog.create': '新建一级代理',
'agent.dialog.edit': '编辑代理',
'agent.dialog.credit': '调整授信额度',
'agent.field.agent_id': '代理 ID',
'agent.dialog.detail': '代理详情',
'agent.field.credit_limit': '授信额度',
'agent.field.cashback_rate': '返水比例',
'agent.field.adjust_amount': '调整金额',
'agent.field.used_credit': '已用额度',
'agent.field.available_credit': '可用授信',
'agent.field.player_liability': '玩家负债',
'agent.field.sub_agent_exposure': '下级代理敞口',
'agent.hint.credit_limit': '代理可向直属玩家上分的总额度上限',
'agent.hint.cashback_example': '例如填写 1 表示 1%',
'agent.field.max_single_deposit': '单笔上分限额',
'agent.field.max_daily_deposit': '日上分限额',
'agent.hint.deposit_limit_empty': '0 表示不限;下级代理不能超过上级设置',
'agent.hint.credit_adjust': '正数为增加授信,负数为减少',
'agent.hint.credit_remark': '选填,写入额度流水',
'agent.section.credit_log': '最近额度变动',
'agent.credit.increase': '增加',
'agent.credit.decrease': '减少',
'agent.col.credit_type': '类型',
'agent.col.credit_change': '变动',
'agent.col.credit_before': '变动前',
'agent.col.credit_after': '变动后',
'agent.credit_tx.filter_agent_ph': '代理用户名',
'agent.credit_tx.filter_agent_id': '代理 ID',
'agent.credit_tx.filter_agent_id_ph': '用户 ID',
'agent.credit_tx.col.operator': '操作人',
'agent.credit_tx.view_all': '查看全部额度流水',
'finance.tab.credit': '额度流水',
'finance.tab.transfer': '上下分流水',
'finance.tab.wallet': '钱包流水',
'finance.filter.type_category': '流水类型',
'finance.filter.type_category_all': '全部',
'finance.filter.type_category_deposit': '上下分',
'finance.filter.type_category_bet': '投注',
'finance.filter.type_category_cashback': '返水',
'finance.col.frozen_before': '变动前冻结',
'finance.col.frozen_after': '变动后冻结',
'finance.col.reference': '关联注单',
'finance.tx.adjust': '余额调整',
'finance.tx.bet_freeze': '投注冻结',
'finance.tx.bet_deduct': '投注扣款',
'finance.tx.bet_win': '投注派彩',
'finance.tx.bet_lose': '投注结算',
'finance.tx.bet_push': '走水返还',
'finance.tx.bet_refund': '投注退款',
'finance.tx.bet_void': '注单作废',
'finance.tx.cashback': '返水',
'finance.tx.resettle': '重结算调整',
'user.action.view_wallet_ledger': '查看资金流水',
'user.action.ledger_short': '流水',
'user.wallet_ledger_dialog_title': '{name} 的资金流水',
'agent.hierarchy.settings_title': '代理层级设置',
'agent.hierarchy.settings_hint': '0 表示不限制代理层级;达到上限的代理将无法创建下级。',
'agent.hierarchy.max_level': '最大代理层级',
'agent.hierarchy.default_sub_credit_ratio': '下级默认授信比例',
'agent.hierarchy.default_sub_credit_ratio_hint': '创建下级代理时,授信额度默认预填为上级可用授信 × 此比例',
'agent.hierarchy.create_credit_default_hint': '默认 {ratio}%{amount}),不超过上级可用授信,可手动调整',
'agent.hierarchy.create_credit_quick_hint': '上级可用授信 {amount},点击比例快速填入',
'agent.hierarchy.create_level_hint': '将创建为 {n} 级代理',
'agent.field.parent_agent': '上级代理',
'agent.col.parent_chain': '上级链路',
'role.agent_level': '{n}级代理',
'finance.filter.date_range': '时间范围',
'finance.filter.player_ph': '玩家用户名',
'finance.filter.parent_agent_ph': '上级代理用户名或 ID',
'finance.filter.operator_ph': '操作人用户名',
'finance.col.player': '玩家',
'finance.col.parent_agent': '上级代理',
'finance.col.tx_id': '流水号',
'finance.col.balance_change': '余额变动',
'finance.col.balance_before': '变动前余额',
'finance.col.balance_after': '变动后余额',
'finance.col.tx_type': '类型',
'finance.col.deposit_method': '充值方式',
'finance.deposit_method.manual_admin': '管理员人工充值',
'finance.deposit_method.manual_agent': '代理人工充值',
'finance.deposit_method.manual': '人工充值',
'finance.tx.deposit': '充值',
'finance.tx.admin_deposit': '管理员上分',
'finance.tx.agent_deposit': '代理上分',
'finance.tx.player_deposit': '自助充值',
'finance.tx.withdraw': '下分',
'finance.tx.admin_withdraw': '管理员下分',
'finance.tx.agent_withdraw': '代理下分',
'finance.tx.request_id': '请求 ID',
'finance.remark.agent_deposit': '代理上分',
'finance.remark.agent_withdraw': '代理下分',
'finance.remark.admin_deposit': '管理员上分',
'finance.remark.admin_withdraw': '管理员下分',
'finance.remark.initial_balance': '开户初始余额',
'agent.col.no_records': '暂无记录',
'agent.btn.confirm_adjust': '确认调整',
'agent.field.select_user': '选择用户',
'agent.ph.select_user': '搜索玩家用户名',
'agent.hint.select_user': '从已有玩家账号中选择,将其设为一级代理(不新建登录账号)',
'agent.freeze.confirm_freeze_title': '确认停用代理',
'agent.freeze.confirm_freeze_body': '确定停用代理「{name}」?停用后该代理无法登录代理端。',
'agent.freeze.confirm_unfreeze_body': '确定恢复代理「{name}」为正常状态?',
'agent.freeze.opt_freeze_direct_players': '同时冻结直属玩家',
'agent.freeze.opt_block_player_login': '禁止直属玩家登录',
'agent.unfreeze.confirm_title': '确认恢复代理',
'agent.unfreeze.opt_unfreeze_direct_players': '同时解冻直属玩家',
'agent.msg.cascade_freeze_done': '已停用代理并冻结其直属玩家',
'agent.msg.cascade_unfreeze_done': '已恢复代理并解冻其直属玩家',
'agent.msg.freeze_done': '已{action}',
'match.create_btn': '+ 新增联赛',
'match.create_fixture_btn': '+ 新增本场赛事',
'match.btn.markets': '盘口',
'match.filter.keyword_ph': '赛事名 / 球队代码',
'match.filter.status_hint': '仅筛选展开后的单场列表与「单场」列计数,不会隐藏新建的空联赛',
'match.col.league': '赛事',
'match.col.league_en': '联赛(英文)',
'match.col.fixture_count': '单场',
'match.col.bet_count': '注单数',
'match.col.total_stake': '总投注额',
'match.col.pending_bets': '待结算',
'match.col.league_code': '代码',
'match.col.matchup': '对阵',
'match.col.kickoff': '开赛时间',
'match.dialog.create_league': '新增赛事',
'match.dialog.edit_league': '编辑赛事',
'match.dialog.create_fixture': '新增单场',
'match.dialog.create': '新增单场',
'match.dialog.edit': '编辑单场',
'match.dialog.import': '导入赛事',
'match.field.league_en': '联赛(英)',
'match.field.league_zh': '联赛(中)',
'match.field.league_ms': '联赛(马来)',
'match.field.league_logo': '赛事 Logo',
'match.field.lang_zh': '中',
'match.field.lang_en': 'EN',
'match.field.lang_ms': 'MS',
'match.field.kickoff': '开赛时间',
'match.field.home_team': '主队',
'match.field.away_team': '客队',
'match.field.home_en': '主队(英)',
'match.field.home_zh': '主队(中)',
'match.field.home_ms': '主队(马来)',
'match.field.away_en': '客队(英)',
'match.field.away_zh': '客队(中)',
'match.field.away_ms': '客队(马来)',
'match.field.featured': '热门',
'match.hint.create_draft': '创建后为草稿,请展开赛事后在单场行点击「发布」并生成盘口。',
'match.hint.create_league': '新建联赛默认为未发布,请在列表点击「发布」后玩家端可见;展开该行可添加单场。',
'league.status.PUBLISHED': '已发布',
'league.status.UNPUBLISHED': '未发布',
'league.btn.unpublish': '下架',
'league.confirm_unpublish': '下架后玩家端将不再展示该联赛,管理端仍可编辑与重新发布。是否继续?',
'msg.league_published': '联赛已发布',
'msg.league_unpublished': '联赛已下架',
'match.btn.unpublish': '下架',
'match.confirm_unpublish': '下架后赛事将回到草稿,玩家端不可见且无法新下注,已有未结注单保留。是否继续?',
'msg.match_unpublished': '赛事已下架',
'match.hint.edit_published': '已发布:可修改开赛时间、热门及显示名称;封盘/已结算后不可编辑。',
'match.expand_league_hint': '展开联赛可管理单场赛事;优胜冠军盘口请到「优胜赛配置」。',
'match.expand_outright_hint': '展开联赛可编辑夺冠赔率;单场球队会自动同步,也可手动补充尚未赛程的球队。',
'outright.odds_only_hint': '单场赛程中的球队会自动加入;可手动添加尚未参赛的球队,并在此调整赔率。冠军盘随联赛发布,无需单独发布。',
'outright.league_unpublished_hint': '联赛尚未发布,请在本页编辑联赛并设为「已发布」后,冠军盘将自动开放投注。',
'outright.unsettled_fixtures_hint': '该联赛仍有 {n} 场单场未结算,请先完成单场结算后再结算冠军盘。',
'outright.btn.reopen': '解盘',
'outright.confirm_reopen': '解盘后将重新接受优胜冠军投注,是否继续?',
'outright.fixture_sync_added': '已从单场自动同步 {n} 支球队',
'outright.col.teams_from_fixtures': '参赛球队(来自单场)',
'outright.col.teams_total': '冠军盘球队',
'outright.empty_no_teams': '暂无球队,请先在「赛事配置」添加单场或点击「添加队伍」。',
'match.outright.setup': '配置',
'match.outright.section_hint': '按联赛配置冠军盘,与下方单场列表同属本联赛',
'match.expand_markets_hint': '在单场列表点击「盘口」进入单独页面设置盘口与赔率。',
'match.no_fixtures': '该赛事下暂无单场。',
'match.ph.league_ms': '2027 世界杯',
'bet.filter.keyword_ph': '流水编号 / 玩家用户名',
'bet.filter.date_from': '投注日起',
'bet.filter.date_start_ph': '开始',
'bet.filter.date_end_ph': '结束',
'bet.col.serial': '单号',
'bet.col.bet_no': '流水编号',
'bet.col.player': '玩家',
'bet.col.agent': '所属代理',
'bet.col.selection': '选项',
'bet.col.content': '投注内容',
'bet.content.bet_counts': '{singles}单 · {parlays}串',
'bet.col.match': '赛事',
'bet.legs_more': '还有 {n} 项…',
'bet.col.selection_count': '投注项数',
'bet.col.stake': '投注额',
'bet.col.odds': '赔率',
'bet.col.payout': '派彩',
'bet.col.placed_at': '投注时间',
'bet.col.cashbacked': '已回水',
'bet.dialog.detail': '注单详情',
'bet.field.total_odds': '总赔率',
'bet.field.currency': '币种',
'bet.field.potential_win': '可赢额',
'bet.field.actual_payout': '实际派彩',
'bet.field.bet_status': '注单状态',
'bet.field.settlement_status': '结算状态',
'bet.field.settled_at': '结算时间',
'bet.field.request_id': '请求 ID',
'bet.selections_title': '投注项({n}',
'bet.col.market': '玩法',
'bet.col.period': '时段',
'bet.col.line': '盘口',
'bet.col.result': '赛果',
'audit.module_ph': '如 USERS、AGENTS',
'audit.col.action': '操作',
'audit.col.module': '模块',
'audit.col.target_id': '目标 ID',
'audit.col.time': '时间',
'audit.action.CREATE_PLAYER': '新建玩家',
'audit.action.UPDATE_PLAYER': '更新玩家',
'audit.action.RESET_DATABASE': '重置数据库',
'audit.action.CREATE_AGENT': '新建代理',
'audit.action.UPDATE_AGENT': '更新代理',
'audit.action.UPDATE_PLAYER_ACCOUNT_SETTINGS': '更新玩家账号设置',
'audit.action.UPDATE_AGENT_SUSPEND_SETTINGS': '更新代理停押设置',
'audit.action.UPDATE_BETTING_LIMITS': '更新投注限额',
'audit.action.CONFIRM_SETTLEMENT': '确认结算',
'audit.action.CONFIRM_RESETTLE': '确认重结算',
'audit.action.CONFIRM_CASHBACK': '确认发放返水',
'audit.action.CANCEL_CASHBACK': '作废返水批次',
'audit.module.USERS': '玩家',
'audit.module.AGENTS': '代理',
'audit.module.SYSTEM': '系统',
'audit.module.SETTINGS': '系统设置',
'audit.module.SETTLEMENT': '结算',
'audit.module.CASHBACK': '返水',
'cashback.start_date': '开始日期',
'cashback.end_date': '结束日期',
'cashback.preview_btn': '生成预览',
'cashback.preview_title': '返水预览',
'cashback.stat.players': '涉及玩家数',
'cashback.stat.total': '返水总金额',
'cashback.stat.lines': '明细条数',
'cashback.stat.effective_stake': '有效投注总额',
'cashback.stat.bet_count': '计入注单数',
'cashback.stat.avg_rate': '平均返水比例',
'cashback.batch_no': '批次号',
'cashback.history_title': '返水记录',
'cashback.history_empty': '暂无返水批次记录',
'cashback.filter_status': '批次状态',
'cashback.status.PREVIEW': '待发放',
'cashback.status.CONFIRMED': '已发放',
'cashback.col.period': '统计周期',
'cashback.col.status': '状态',
'cashback.col.bet_count': '注单数',
'cashback.col.created_at': '生成时间',
'cashback.col.confirmed_at': '发放时间',
'cashback.col.operator': '操作人',
'cashback.view_detail': '查看明细',
'cashback.detail_title': '返水批次明细',
'cashback.detail_summary': '批次汇总',
'cashback.table_title': '玩家返水明细',
'cashback.table_total': '合计',
'cashback.empty_items': '本周期内无符合条件的返水记录',
'cashback.col.index': '#',
'cashback.col.player': '玩家',
'cashback.col.agent': '所属代理',
'cashback.col.balance': '当前余额',
'cashback.col.effective_stake': '有效投注',
'cashback.col.rate': '返水比例',
'cashback.col.amount': '返水金额',
'cashback.confirm_issue': '确认发放',
'cashback.cancel_issue': '作废',
'cashback.confirm_prompt': '确认向玩家钱包发放本批次返水?返水由平台直接入账,不从代理扣款。此操作不可撤销。',
'cashback.cancel_prompt': '确认作废该待发放批次?作废后不会入账,可重新生成预览。',
'cashback.status.CANCELLED': '已作废',
'cashback.rules_title': '返水规则说明',
'cashback.rule_period': '选择开始/结束日期,统计该周期内、按注单结算时间落在区间内的有效投注。',
'cashback.rule_eligible': '计入:已结算且结果为「赢」或「输」的注单(单关按本金,串关按整单本金计一次)。不计入:未结算、已取消、作废、走水,以及返水比例为 0 的注单;已返水过的注单不会重复计入。',
'cashback.rule_formula': '单笔返水 = 投注本金 × 适用返水比例;同一玩家多笔注单汇总后生成一条返水明细。',
'cashback.rule_rate': '返水比例优先级:玩家专属规则 > 代理线规则 > 全局规则 > 默认返水率(代理邀请用代理返水;管理员邀请用「管理员邀请返水」;无邀请码用「平台直属默认返水」;可在玩家管理中单独覆盖)。以百分比填写,如 1 表示 1%。',
'cashback.rule_flow': '操作流程:生成预览(同周期仅保留一条待发放)→ 核对明细 → 确认发放;不需要的可作废。已发放周期不可重复预览。',
'cashback.rule_platform': '发放方式:返水由平台直接打入玩家现金余额,不从代理信用或余额中扣除。',
'cashback.rule_note_zero': '预览为 0 时,请检查:周期内是否有已结算输赢注单、返水比例是否大于 0含全局设置中的平台直属默认比例与玩家/代理单独设置)。',
'cashback.use_custom_rate': '单独设置返水比例',
'cashback.use_default_rate': '使用默认比例 {rate}',
'cashback.settings_title': '返水设置',
'cashback.platform_direct_default_rate': '平台直属默认返水比例',
'cashback.platform_direct_default_hint': '无邀请码自助注册的玩家在未单独设置时使用此比例。',
'cashback.admin_invite_default_rate': '管理员邀请返水比例',
'cashback.admin_invite_default_hint': '玩家通过管理员邀请码注册时使用;默认与平台直属相同,可单独调整。代理邀请玩家使用管理员在代理管理中为该代理设置的返水比例。',
'user.field.player_id': '玩家 ID',
'user.field.bet_count': '注单数',
'user.field.total_stake': '累计投注',
'user.field.registered_at': '注册时间',
'user.ph.remark_initial': '有初始余额时写入流水备注',
'user.bets_edit_value': '{n} 笔 / {stake}',
'user.login_fail_value': '{n} 次',
'match.import_hint': '粘贴含 matches 的 JSON导入后为草稿需在列表发布。',
'match.import_start': '开始导入',
'match.import_json_ph': '{"matches":[...]}',
'match.delete_confirm_title': '删除确认',
'match.delete_confirm_body': '确定删除赛事「{title}」?仅草稿且无注单时可删除。',
'archive.match_title': '删除赛事',
'archive.league_title': '删除联赛',
'archive.target': '赛事:{title}',
'archive.league_target': '联赛:{name}',
'archive.soft_delete_hint': '此为软删除:数据保留,前后台列表不再展示。',
'archive.pending_summary': '未结注单 {count} 笔,涉及金额 {stake}',
'archive.refund_pending': '退还未结注单金额',
'archive.parlay_void_hint': '含串关注单时将整单作废并全额退还。',
'archive.force_delete': '强制删除',
'archive.msg_done': '已删除',
'archive.msg_done_refund': '已删除,已退还 {n} 笔注单',
'archive.league_hint': '仅当下属单场与优胜赛均已结算且无未结注单时可删除联赛。',
'archive.league_blocked': '仍有未结算赛事或未结注单,请先处理下属赛事。',
'archive.league_ready': '可以删除该联赛,将同时隐藏下属全部赛事。',
'archive.league_confirm': '确认删除联赛',
'archive.league_done': '联赛已删除',
'archive.warning.PENDING_BETS': '存在未结注单',
'archive.warning.UNSETTLED_MATCH': '赛事尚未进入终态(未结算/未取消)',
'archive.warning.PREVIEW_BATCH': '存在待确认的结算预览批次',
'match.ph.league_en': 'FIFA World Cup 2026',
'match.ph.league_zh': '2026 世界杯',
'match.ph.kickoff': '2026-06-11T19:00:00Z',
'match.ph.home_en': 'Mexico',
'match.ph.home_zh': '墨西哥',
'match.ph.home_ms': 'Mexico',
'match.ph.away_en': 'South Africa',
'match.ph.away_zh': '南非',
'match.ph.away_ms': 'Afrika Selatan',
'matchEditor.manage_btn': '基本信息',
'matchEditor.back': '返回列表',
'matchEditor.title': '编辑基本信息',
'matchEditor.section_info': '基本信息',
'matchEditor.section_markets': '盘口与赔率',
'matchEditor.field.league_logo': 'Logo',
'matchEditor.field.home_logo': 'Logo',
'matchEditor.field.away_logo': 'Logo',
'matchEditor.field.pick_flag': '选择国旗',
'matchEditor.field.custom_logo_url': '自定义图片 URL',
'matchEditor.ph.logo_url': 'https://...',
'matchEditor.field.match_name': '赛事显示名',
'matchEditor.field.stage': '阶段',
'matchEditor.field.group': '小组',
'matchEditor.field.display_order': '排序',
'matchEditor.field.correct_score_enabled': '波胆玩法',
'matchEditor.field.promo_label': '促销标签',
'matchEditor.field.promo_label_optional': '促销标签(可选)',
'matchEditor.field.line_value': '盘口线',
'matchEditor.ph.kickoff': '选择开赛日期与时间',
'matchEditor.group.league': '联赛信息',
'matchEditor.hint.league_readonly': '联赛名称与 Logo 请在赛事管理列表中点击「编辑」维护,此处仅展示。',
'matchEditor.group.home': '主队',
'matchEditor.group.away': '客队',
'matchEditor.group.schedule': '赛程与展示',
'matchEditor.save_info': '保存基本信息',
'matchEditor.save_market': '保存盘口设置',
'matchEditor.save_odds': '保存赔率',
'matchEditor.generate_templates': '生成默认盘口',
'matchEditor.templates_generated': '盘口模板已生成',
'matchEditor.no_markets': '暂无盘口,请先发布赛事或点击「生成默认盘口」。',
'matchEditor.market.FT_1X2': '全场 1X2',
'matchEditor.market.FT_HANDICAP': '全场让球',
'matchEditor.market.FT_OVER_UNDER': '全场大小',
'matchEditor.market.FT_ODD_EVEN': '全场单双',
'matchEditor.market.HT_1X2': '半场 1X2',
'matchEditor.market.HT_HANDICAP': '半场让球',
'matchEditor.market.HT_OVER_UNDER': '半场大小',
'matchEditor.market.FT_CORRECT_SCORE': '全场波胆',
'matchEditor.market.HT_CORRECT_SCORE': '半场波胆',
'matchEditor.market.SH_CORRECT_SCORE': '下半场波胆',
'matchEditor.period.FT': '全场',
'matchEditor.period.HT': '半场',
'matchEditor.period.SH': '下半场',
'matchEditor.period.OUTRIGHT': '冠军',
'matchEditor.selection.HOME': '主',
'matchEditor.selection.DRAW': '和',
'matchEditor.selection.AWAY': '客',
'matchEditor.selection.OVER': '大',
'matchEditor.selection.UNDER': '小',
'matchEditor.selection.ODD': '单',
'matchEditor.selection.EVEN': '双',
'matchEditor.selection.OTHER_DRAW': '和局其它比分',
'matchEditor.selection.OTHER_HOME': '主胜其它比分',
'matchEditor.selection.OTHER_AWAY': '客胜其它比分',
'matchEditor.col.selection_code': '选项',
'matchEditor.col.selection_name': '显示名',
'matchEditor.col.odds': '赔率',
'matchEditor.ph.selection_name': '玩家端显示名称',
'err.username_required': '请填写用户名',
'err.username_player_invalid': '玩家用户名仅可使用英文字母和数字332 位),不可含中文或特殊符号',
'err.password_min': '密码至少 8 位',
'err.password_mismatch': '两次密码不一致',
'err.credit_negative': '授信额度不能为负',
'err.insufficient_credit': '可用授信不足,请减少上分金额或联系上级调额',
'err.kickoff_required': '请填写开赛时间',
'err.team_country_required': '请选择主客队',
'err.teams_required': '请填写主客队名称(中文、英文或马来文至少一项)',
'err.teams_same': '主客队不能相同,请填写不同的队名',
'err.league_required': '请填写联赛名称(中文、英文或马来文至少一项)',
'err.user_required': '请选择用户',
'err.agent_no_parent': '一级代理不可设置上级玩家',
'err.agent_no_initial_deposit': '设为代理时请勿填写玩家初始余额',
'err.initial_deposit_kind_required': '有初始余额时请选择上分流水说明',
'err.initial_deposit_custom_required': '自定义流水说明至少 2 个字符',
'settlement.back': '返回赛事列表',
'settlement.kickoff': '开赛时间',
'settlement.stats_title': '下注统计',
'settlement.stats_total_bets': '注单数',
'settlement.stats_single': '单关',
'settlement.stats_parlay': '串关',
'settlement.stats_total_stake': '总投注额',
'settlement.stats_potential': '最大可赢',
'settlement.chart.bet_type': '单关 / 串关占比',
'settlement.chart.status': '注单状态分布',
'settlement.chart.stake_by_selection': '选项单关投注额 TOP6',
'settlement.stats_by_market': '按玩法 / 选项汇总',
'settlement.bet_list': '相关注单',
'settlement.bet_list_hint': '按注单聚合;同场串关含多腿时显示 ×腿数',
'settlement.no_bets': '本场暂无注单',
'settlement.col.market': '玩法',
'settlement.col.selection': '选项',
'settlement.col.legs': '腿数',
'settlement.col.single_stake': '单关投注额',
'settlement.col.parlay_legs': '串关腿数',
'settlement.ht_score': '半场比分',
'settlement.ft_score': '全场比分',
'settlement.record_score': '录入比分',
'settlement.preview_hint': '填写比分后点击生成预览,赛事将进入待结算并计算派彩(正式比分在确认结算后保存;未确认前仍可解除封盘)',
'settlement.preview_btn': '生成结算预览',
'settlement.preview_failed': '生成结算预览失败',
'settlement.err_score_not_recorded': '请先填写半场与全场比分后再生成预览',
'settlement.must_close_first': '请先封盘后再结算',
'settlement.outright.page_title': '优胜冠军结算',
'settlement.outright.title': '优胜冠军',
'settlement.outright.winner': '冠军队伍',
'settlement.outright.winner_ph': '选择夺冠队伍',
'settlement.outright.preview_hint': '选择冠军后生成预览,确认后将派彩给中奖注单',
'settlement.outright.winner_required': '请先选择冠军队伍',
'settlement.preview_title': '结算预览',
'settlement.single_count': '单关注单数',
'settlement.preview_pending_bets': '待结算注单',
'settlement.preview_bet_mix': '单关 / 串关',
'settlement.preview_items_title': '逐笔预览({n} 笔)',
'settlement.preview_items_scroll_hint': '笔数较多时可滚动查看',
'settlement.preview_col.result': '本场结果',
'settlement.preview_zero_parlay_hint':
'预计派彩为 0本场虽有 {legs} 条赢腿,但均在跨场串关内({pending} 笔待其他场次),须等其他比赛结算后才派彩。',
'settlement.preview_zero_lost_hint':
'预计派彩为 0{count} 笔串关在本场已有输腿,整单作废;其余单关/串关本场亦未中奖。',
'settlement.preview_zero_default_hint': '预计派彩为 0本场相关注单均未中奖或暂不满足派彩条件。',
'settlement.preview.result.WIN': '赢',
'settlement.preview.result.LOSE': '输',
'settlement.preview.result.LOST': '整单输',
'settlement.preview.result.WON': '整单赢',
'settlement.preview.result.PUSH': '走盘',
'settlement.preview.result.PENDING_OTHER_MATCHES': '待其他场次',
'settlement.est_payout': '预计派彩',
'settlement.refund_amount': '退款金额',
'settlement.confirm_btn': '确认结算',
'settlement.resettle_reason': '重结算原因',
'settlement.resettle_preview': '重结算预览',
'settlement.resettle_preview_title': '重结算预览',
'settlement.resettle_affected': '影响注单数',
'settlement.resettle_topup': '需补发金额',
'settlement.resettle_clawback': '需扣回金额',
'settlement.resettle_confirm': '确认重结算',
'user.betting_limits': '投注限额',
'user.betting_limits_hint': '全局下注校验:最小/最大投注、最高派彩、每日投注上限',
'user.limit.min_stake': '最小投注',
'user.limit.max_stake_single': '单关最大投注',
'user.limit.max_stake_parlay': '串关最大投注',
'user.limit.max_payout_single': '单关最高派彩',
'user.limit.max_payout_parlay': '串关最高派彩',
'user.limit.daily_stake': '每日投注上限',
'settlement.smart.btn': '智能比分',
'settlement.smart.title': '智能推荐比分',
'settlement.smart.hint': '根据本场待结算单关注单,在合理比分范围内穷举并计算派彩;串关仅统计不参与推荐。点击方案可填入录分框。',
'settlement.smart.target_hold': '目标平台留存',
'settlement.smart.recalc': '重新计算',
'settlement.smart.apply': '采用此比分',
'settlement.smart.applied': '已填入推荐比分',
'settlement.smart.no_bets': '本场无待结算单关注单',
'settlement.smart.empty': '未找到可用方案',
'settlement.smart.meta': '单关 {singles} 笔,串关 {parlays} 笔未参与,已比对 {n} 组比分',
'settlement.smart.hold': '留存',
'settlement.smart.payout': '派彩',
'settlement.smart.win_stake': '玩家赢注占比',
'settlement.smart.wl': '赢/输单',
'settlement.smart.strategy.MIN_PAYOUT': '平台最大留存(最低派彩)',
'settlement.smart.strategy.MAX_PAYOUT': '玩家最大派彩',
'settlement.smart.strategy.BALANCED': '投注均衡约50%赢注额)',
'settlement.smart.strategy.TARGET_HOLD': '目标留存率',
'msg.score_recorded': '比分已录入',
'msg.settlement_confirmed': '结算已确认',
'msg.resettle_confirmed': '重结算已确认',
'agent_portal.create_player_section': '创建玩家',
'agent_portal.deposit_section': '上分操作',
'agent_portal.create_player_btn': '+ 创建玩家',
'agent_portal.create_tier2_btn': '+ 创建二级代理',
'agent_portal.username_ph': '输入用户名',
'agent_portal.agent_username_ph': '代理用户名',
'agent_portal.player_id_ph': '玩家 ID',
'agent_portal.withdraw_btn': '下分 {amount}',
'agent_portal.withdraw_btn_label': '下分',
'agent_portal.transfer_title_deposit': '给 {name} 上分',
'agent_portal.transfer_title_withdraw': '从 {name} 下分',
'transfer.context.player_section': '玩家余额',
'transfer.context.agent_section': '授信代理 · {name}L{level}',
'transfer.context.withdrawable': '可下分金额',
'transfer.context.deposit_cap': '本次最多可上分',
'transfer.context.daily_used': '今日已上分',
'transfer.context.unlimited': '不限',
'transfer.context.no_agent': '平台直属玩家,上分不受代理授信约束',
'transfer.context.admin_credit_only': '管理员上分仅受上级可用授信约束,不受单笔/日限',
'transfer.context.withdraw_exceed': '下分金额不能超过玩家可用余额',
'credit.context.target_section': '目标代理授信',
'credit.context.parent_section': '上级代理 · {name}',
'credit.context.max_increase': '最多可增加授信',
'credit.context.no_parent': '一级代理由平台直管,增信不受上级约束',
'credit.context.after_adjust': '调整后授信额度',
'credit.context.direct_liability': '直属玩家占用',
'credit.context.child_exposure': '下级代理占用',
'credit.context.acting_agent': '当前代理',
'agent_portal.create_player_dialog': '新建直属玩家',
'agent_portal.edit_player_dialog': '编辑直属玩家',
'agent_portal.my_cashback_rate': '反水比例',
'agent_portal.credit_available_hint': '当前可用授信:{amount}(上分将从授信中扣除)',
'agent_portal.sub_agent_players_readonly': '以下为该二级代理直属玩家,仅可查看;开户、上分等操作由二级代理自行处理。',
'agent_portal.sub_agent_downline_readonly': '以下为该二级代理下级所有代理与玩家,仅可查看;您只能操作直属二级代理,开户、上分等由各级代理自行处理。',
'agent_portal.sub_agent_downline_readonly_level': '以下为该{level}级代理下级所有代理与玩家,仅可查看;您只能操作直属下级代理,开户、上分等由各级代理自行处理。',
'agent_portal.downline_agents_title': '下级代理',
'agent_portal.downline_players_title': '下级玩家',
'agent_portal.no_downline_agents': '暂无下级代理',
'agent_portal.no_downline_players': '暂无下级玩家',
'agent_portal.initial_deposit_hint': '可选。开户时从您的授信中给玩家上分,不能超过可用授信',
'agent_portal.search_player_ph': '用户名或 ID',
'agent_portal.no_players': '暂无直属玩家,点击右上角创建',
'invite.title': '邀请码与注册链接',
'invite.menu_btn': '邀请',
'invite.dialog_title': '邀请管理',
'invite.tab_generate': '生成邀请',
'invite.tab_history': '邀请历史',
'invite.hint': '点击生成邀请码与注册链接;玩家填邀请码注册,不填则为平台直属玩家',
'invite.generate_btn': '生成邀请码/链接',
'invite.regenerate_btn': '重新生成',
'invite.generate_ok': '邀请码已生成',
'invite.generate_failed': '生成失败,请重试',
'invite.not_generated': '尚未生成邀请码',
'invite.code': '邀请码',
'invite.cashback_rate': '返水比例',
'invite.cashback_rate_hint': '该邀请码注册的玩家将使用此返水比例;默认读取全局「管理员邀请返水比例」。',
'invite.link': '注册链接',
'invite.copy_code': '复制邀请码',
'invite.copy_code_short': '复制码',
'invite.copy_link_short': '复制链接',
'invite.copy_link': '复制注册链接',
'invite.copy_code_ok': '邀请码已复制',
'invite.copy_link_ok': '注册链接已复制',
'invite.copy_failed': '复制失败,请手动复制',
'invite.unavailable': '邀请码暂不可用',
'invite.history_title': '邀请历史',
'invite.view_history': '查看历史',
'invite.history_hint': '管理员可查看全部邀请码;代理仅可查看自己及下级代理的邀请码',
'invite.page_desc': '查看全部邀请码历史与状态,生成或作废邀请码',
'invite.history_load_failed': '加载邀请历史失败',
'invite.filter_status': '状态',
'invite.filter_sponsor': '邀请人',
'invite.filter_code': '邀请码',
'invite.filter_code_ph': '输入邀请码',
'invite.col_status': '状态',
'invite.col_sponsor': '邀请人',
'invite.col_registrant': '注册人',
'invite.not_registered': '未注册',
'invite.col_cashback_rate': '返水比例',
'invite.col_created': '创建时间',
'invite.col_revoked': '作废时间',
'invite.status.ACTIVE': '有效',
'invite.status.USED': '已使用',
'invite.status.REVOKED': '已作废',
'invite.revoke_btn': '作废',
'invite.revoke_title': '作废邀请码',
'invite.revoke_confirm': '确定作废邀请码 {code}?作废后该码将无法用于注册。',
'invite.revoke_ok': '邀请码已作废',
'invite.revoke_failed': '作废失败,请重试',
'invite.delete_title': '删除邀请记录',
'invite.delete_confirm': '确定删除邀请码 {code} 的历史记录?删除后不可恢复。',
'invite.delete_ok': '邀请记录已删除',
'invite.delete_failed': '删除失败,请重试',
'agent_portal.search_sub_agent_ph': '用户名或 ID',
'agent_portal.no_sub_agents': '暂无二级代理,点击右上角创建',
'agent_portal.no_sub_agents_level': '暂无{level}级代理,点击右上角创建',
'agent_portal.sub_agent_players_readonly_level': '以下为该{level}级代理直属玩家,仅可查看;开户、上分等操作由该代理自行处理。',
'agent_portal.create_sub_agent_dialog': '新建二级代理',
'agent_portal.sub_agent_credit_hint': '初始授信从您的可用额度中划拨,不能超过可用授信',
'agent_portal.adjust_credit_dialog': '调整 {name} 授信',
'agent_portal.credit_adjust_hint': '正数为增加授信,负数为减少授信',
'msg.agent_sub_created': '下级代理已创建',
'msg.withdraw_ok': '下分成功',
'msg.form_invalid': '请检查表单',
'msg.player_created': '玩家已创建',
'msg.agent_created': '一级代理已创建',
'msg.create_failed': '创建失败',
'msg.saved': '已保存',
'msg.save_failed': '保存失败',
'msg.deleted': '已删除',
'msg.delete_failed': '删除失败',
'msg.league_created': '赛事已创建',
'msg.league_updated': '赛事已更新',
'msg.match_created_draft': '单场已创建(草稿)',
'msg.published': '已发布并生成盘口',
'msg.closed': '已封盘',
'msg.reopened': '已解除封盘',
'match.reopen_kickoff_title': '设置新的开赛时间',
'match.reopen_kickoff_hint': '开赛时间已过,请选择新的未来开赛时间后再解除封盘。',
'match.reopen_kickoff_invalid': '请选择未来的开赛时间',
'msg.invalid_json': 'JSON 格式无效',
'msg.import_failed': '导入失败',
'msg.import_done': '导入完成:成功 {imported},跳过 {skipped},失败 {failed} / 共 {total}',
'msg.topup_ok': '上分成功',
'msg.topup_failed': '上分失败',
'msg.transfer_failed': '操作失败',
'msg.amount_gt_zero': '金额须大于 0',
'msg.credit_zero': '调整金额不能为 0',
'msg.credit_adjusted': '授信已调整',
'msg.credit_adjust_failed': '调整失败',
'msg.outright_no_edit': '冠军盘不支持在此编辑',
'msg.outright_odds_saved': '夺冠赔率已保存',
'msg.load_failed': '加载失败',
'content.btn.create': '新建内容',
'content.btn.enable': '启用',
'content.btn.disable': '停用',
'content.dialog.create': '新建公共内容',
'content.dialog.edit': '编辑公共内容',
'content.confirm_delete': '确定删除「{title}」?',
'content.type.BANNER': '首页轮播',
'content.type.ANNOUNCEMENT': '公告滚动',
'content.hint.announcement': '显示在玩家端顶部跑马灯;标题与正文填一项即可,建议正文为主',
'content.status.DRAFT': '草稿',
'content.status.ACTIVE': '已启用',
'content.status.INACTIVE': '已停用',
'content.col.sort': '排序',
'content.col.preview': '预览',
'content.col.title': '标题/摘要',
'content.col.player_visible': '玩家可见',
'content.col.schedule': '展示时段',
'content.col.link': '跳转',
'content.field.link_type': '链接类型',
'content.field.link_target': '链接目标',
'content.field.start_time': '开始时间',
'content.field.end_time': '结束时间',
'content.field.title': '标题',
'content.field.title_ph': '选填,可与正文相同',
'content.field.body': '正文',
'content.field.announce_text': '滚动文案',
'content.field.image_url': '图片地址',
'content.upload.upload_btn': '上传图片',
'content.upload.uploading': '上传中…',
'content.upload.success': '图片上传成功',
'content.upload.failed': '图片上传失败',
'content.upload.size_error': '图片大小不能超过 5MB',
'content.upload.remove': '移除图片',
'content.upload.pick_media': '从媒体库选择',
'content.upload.pick_media_title': '选择 Banner 图片',
'content.upload.no_media': '媒体库中暂无 Banner 图片,请先上传',
'content.upload.url_placeholder': '或手动粘贴图片 URL',
'content.link.none': '无跳转',
'content.locale.zh-CN': '简体中文',
'content.locale.en-US': 'English',
'content.locale.ms-MY': 'Bahasa Melayu',
'content.hidden_reason.NOT_ACTIVE': '未启用或草稿',
'content.hidden_reason.NOT_STARTED': '未到开始时间',
'content.hidden_reason.EXPIRED': '已过结束时间',
'content.hidden_reason.INCOMPLETE': '多语言内容不完整',
'content.batch.selected': '已选 {n} 项',
'content.batch.enable': '批量启用',
'content.batch.disable': '批量停用',
'content.batch.delete': '批量删除',
'content.confirm_batch_enable': '确定启用选中的 {n} 项?',
'content.confirm_batch_disable': '确定停用选中的 {n} 项?',
'content.confirm_batch_delete': '确定删除选中的 {n} 项?',
'content.batch.all_ok': '已成功处理 {n} 项',
'content.batch.partial': '成功 {ok} 项,失败 {fail} 项',
'page.outrights.title': '优胜冠军',
'page.outrights.desc': '可新建任意联赛冠军盘、编辑队伍与赔率;世界杯 48 强提供一键导入基准数据',
'outright.col.rank': '排名',
'outright.col.team_zh': '队伍(中文)',
'outright.col.team_en': '队伍(英文)',
'outright.col.code': '代码',
'outright.col.country': '国家/地区',
'outright.col.odds': '夺冠赔率',
'outright.country_ph': '搜索或选择国家',
'teamLogo.kind.flag': '国旗',
'teamLogo.kind.crest': '队徽',
'outright.err_country': '请选择国家',
'outright.btn.save_odds': '保存全部赔率',
'outright.btn.close': '封盘',
'outright.btn.settle': '去结算',
'outright.confirm_close': '封盘后将停止接受新的优胜冠军投注,是否继续?',
'outright.btn.save_meta': '保存赛事信息',
'outright.btn.publish': '发布',
'outright.btn.unpublish': '撤回发布',
'outright.back_list': '返回列表',
'outright.section.edit': '编辑冠军赛事',
'outright.col.teams': '队伍数',
'outright.col.player_visible': '玩家端',
'outright.col.league_en': '联赛(英文)',
'outright.expand_no_teams': '暂无队伍,请进入编辑页添加',
'outright.fixtures_sync_hint': '参赛队伍来自本联赛单场赛程,仅可调整赔率与发布状态。',
'outright.empty_no_fixtures': '该联赛暂无单场,请先在「赛事配置」中添加比赛。',
'outright.btn.add_team': '添加队伍',
'outright.add.filter_fixture': '已有队伍',
'outright.add.filter_all': '全部内置',
'outright.add.filter_custom': '自定义',
'outright.add.custom_hint': '手动填写球队代码与中/英文名称Logo 可上传或填写 URL。',
'outright.add.field_code': '球队代码',
'outright.add.field_logo': 'Logo',
'outright.add.ph_code': '如 TEAM01',
'outright.add.ph_name_zh': '中文队名',
'outright.add.ph_name_en': '英文队名',
'outright.add.err_code_required': '请填写球队代码',
'outright.add.err_name_required': '请至少填写中文或英文队名',
'outright.add.err_duplicate': '该球队代码已在冠军盘中',
'outright.add.select_all': '全选',
'outright.add.clear_selection': '取消全选',
'outright.add.selected_count': '已选 {n} 支',
'outright.add.empty_fixture': '暂无待添加的参赛球队(单场中已有且未在冠军盘的球队会显示在此)',
'outright.add.empty_all': '所有内置球队均已加入冠军盘',
'outright.add.default_odds': '默认赔率',
'outright.add.search_ph': '搜索队名或代码',
'outright.add.err_none': '请至少选择一支球队',
'outright.batch.mode': '批量管理',
'outright.batch.exit': '退出批量',
'outright.batch.apply_odds': '应用赔率',
'outright.batch.remove': '批量移除',
'outright.batch.confirm_remove': '确定移除选中的 {n} 支队伍?',
'outright.batch.err_none': '请先选择队伍',
'outright.batch.apply_ok': '已更新 {n} 支队伍的赔率,请点击「保存全部赔率」',
'outright.batch.remove_ok': '已移除 {n} 支队伍',
'outright.batch.remove_partial': '成功 {ok} 支,失败 {fail} 支',
'outright.sort.label': '排序',
'outright.sort.rank': '排名',
'outright.sort.name': '队名',
'outright.sort.code': '代码',
'outright.sort.odds': '赔率(当前)',
'outright.sort.saved_odds': '赔率(已保存)',
'outright.sort.asc': '升序',
'outright.sort.desc': '降序',
'msg.outright_teams_added': '已添加 {n} 支球队(跳过 {skipped} 支)',
'outright.btn.create_event': '新建冠军赛事',
'outright.btn.import_wc2026': '导入世界杯 48 强',
'outright.btn.apply_canonical': '应用世界杯基准表',
'outright.field.league': '所属联赛',
'outright.section.settings': '赛事设置',
'outright.section.teams': '队伍与赔率',
'outright.field.title': '赛事标题',
'outright.field.title_placeholder': '玩家端展示的冠军赛事名称',
'outright.field.title_zh': '标题(中文)',
'outright.field.title_en': '标题(英文)',
'outright.field.title_ms': '标题(马来)',
'outright.field.status': '发布状态',
'outright.status.draft': '草稿',
'outright.status.published': '已发布',
'msg.outright_canonical_applied': '已按基准表写入 48 强夺冠赔率',
'outright.team_count': '已配置 {n} / {total} 队',
'outright.team_count_open': '共 {n} 支队伍',
'outright.empty_events': '暂无冠军赛事',
'outright.empty_hint': '点击「新建冠军赛事」或「导入世界杯 48 强」开始配置',
'outright.err_odds_min': '赔率须大于 1.00',
'outright.err_team_code': '请填写队伍代码',
'outright.err_league': '请选择联赛',
'outright.confirm_remove': '确定移除「{name}」?(关闭该投注项)',
'outright.not_on_player': '玩家端不可见',
'outright.player_hidden_title': '此赛事尚未在玩家端展示',
'outright.hidden_reason.NOT_PUBLISHED': '请将发布状态设为「已发布」并保存赛事信息。',
'outright.hidden_reason.NO_SELECTIONS': '请至少添加 1 支开放状态的队伍。',
'outright.hidden_reason.MARKET_CLOSED': '冠军盘口未开放,请联系技术检查盘口状态。',
'msg.load_matches_failed': '加载赛事失败',
'msg.cashback_issued': '返水已发放',
'msg.cashback_cancelled': '返水批次已作废',
'msg.cashback_preview_ready': '预览已生成,请核对后确认发放',
'msg.cashback_preview_replaced': '已替换同周期 {n} 条旧预览',
'msg.freeze_confirm_title': '{action}账号',
'msg.freeze_confirm_body': '确定要{action}玩家「{name}」吗?{extra}',
'msg.freeze_extra': '冻结后该账号将无法登录。',
'msg.freeze_done': '已{action}',
'msg.freeze_failed': '{action}失败',
'msg.delete_player_title': '删除玩家',
'msg.delete_player_body': '确定要删除玩家「{name}」吗?删除后该玩家将无法登录,此操作不可撤销。',
'msg.delete_player_confirm_title': '二次确认',
'msg.delete_player_confirm_hint': '请输入玩家用户名「{name}」以确认删除:',
'msg.delete_player_mismatch': '用户名输入不正确,请重新输入',
'msg.delete_player_done': '玩家已删除',
'msg.delete_player_failed': '删除失败',
'smoke.intro': '在后台一键运行自动化冒烟测试,覆盖结算引擎、下注规则、代理额度逻辑、返水规则、数据库探针,以及下注→结算→钱包全链路集成用例。',
'smoke.intro_rule': '规则类用例与 Jest 单元测试同源逻辑,不写入业务数据。',
'smoke.intro_db': '「数据库」套件仅做连接与配置探针(只读)。',
'smoke.intro_bet_flow': '「下注结算链路」套件会创建临时赛事/玩家并自动清理,验证冻结、派彩与代理额度。',
'smoke.intro_note': '可替代大部分 UAT 手工回归;发返水等仍建议抽样人工确认。',
'smoke.field.suites': '测试套件',
'smoke.ph.suites': '选择要运行的套件',
'smoke.btn.run': '运行测试',
'smoke.last_run': '最近一次运行',
'smoke.results_title': '用例结果',
'smoke.empty': '尚未运行测试,请点击「运行测试」。',
'smoke.stat.pass': '通过',
'smoke.stat.fail': '失败',
'smoke.stat.total': '合计',
'smoke.col.id': '编号',
'smoke.col.suite': '套件',
'smoke.col.name': '用例',
'smoke.col.uat': 'UAT',
'smoke.col.duration': '耗时',
'smoke.col.steps': '步骤',
'smoke.col.message': '说明',
'smoke.no_steps': '无步骤明细',
'smoke.status.PASS': '通过',
'smoke.status.FAIL': '失败',
'smoke.status.SKIP': '跳过',
'smoke.msg.all_passed': '全部通过({n} 项)',
'smoke.msg.has_failures': '有 {n} 项失败,请查看明细',
'smoke.msg.run_failed': '测试运行失败',
'smoke.log_title': '详细日志',
'smoke.btn.copy_all': '复制全部日志',
'smoke.btn.copy_one': '复制',
'smoke.msg.copy_ok': '已复制到剪贴板',
'smoke.msg.copy_failed': '复制失败,请手动选中日志复制',
'audit.action.RUN_SMOKE_TESTS': '运行自动化测试',
'media.title': '媒体库',
'media.upload_btn': '上传文件',
'media.category.all': '全部',
'media.category.banners': 'Banner',
'media.category.teams': '赛事 Logo',
'media.category.contents': '内容图片',
'media.col.preview': '预览',
'media.col.filename': '文件名',
'media.col.category': '分类',
'media.col.size': '大小',
'media.col.status': '使用状态',
'media.col.uploaded': '上传时间',
'media.col.actions': '操作',
'media.status.used': '使用中',
'media.status.unused': '未使用',
'media.purge_btn': '清除未使用',
'media.purge_confirm': '确认删除 {n} 个未使用的文件?此操作不可撤销。',
'media.purge_none': '暂无未使用的文件',
'media.purge_success': '已清除 {n} 个文件',
'media.delete_confirm': '确认删除此文件?',
'media.delete_success': '已删除',
'media.upload_success': '上传成功',
'media.upload_failed': '上传失败',
'media.copy_url': '复制链接',
'media.url_copied': '链接已复制',
'media.upload_dialog': '上传文件',
'media.upload_hint': '支持 PNG、JPG、WEBP、GIF、SVG最大 5MB',
'media.upload_category': '分类',
'media.drop_hint': '拖拽文件至此,或点击选择',
'media.no_files': '暂无文件',
'media.refresh': '刷新',
'media.unused_count': '{n} 个未使用',
};
export default adminPages;

View File

@@ -1,17 +1,12 @@
import './components/dashboard/echarts-setup';
import { createApp } from 'vue';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import App from './App.vue';
import router from './router';
import i18n from './i18n';
import { ensureStaffSession } from './utils/session-hydrate';
import { createAdminI18n } from './i18n';
async function bootstrap() {
if (localStorage.getItem('manage_token')) {
await ensureStaffSession();
}
const i18n = await createAdminI18n();
createApp(App).use(i18n).use(router).use(ElementPlus).mount('#app');
}

View File

@@ -1,6 +1,7 @@
/** 赛事列表 UI 状态(返回列表时恢复展开等) */
const STORAGE_KEY = 'admin_matches_list_ui';
export const MAX_EXPANDED_LEAGUES = 3;
export type MatchesListUiState = {
expandedLeagueIds: string[];
@@ -20,20 +21,33 @@ function defaultState(): MatchesListUiState {
};
}
function capExpanded(ids: string[]): string[] {
return ids.slice(0, MAX_EXPANDED_LEAGUES);
}
export function readMatchesListUiState(): MatchesListUiState | null {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as MatchesListUiState;
if (!Array.isArray(parsed.expandedLeagueIds)) return null;
return parsed;
return {
...parsed,
expandedLeagueIds: capExpanded(parsed.expandedLeagueIds),
};
} catch {
return null;
}
}
export function writeMatchesListUiState(state: MatchesListUiState) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
sessionStorage.setItem(
STORAGE_KEY,
JSON.stringify({
...state,
expandedLeagueIds: capExpanded(state.expandedLeagueIds),
}),
);
}
export function patchMatchesListUiState(patch: Partial<MatchesListUiState>) {
@@ -45,7 +59,6 @@ export function patchMatchesListUiState(patch: Partial<MatchesListUiState>) {
export function ensureLeagueExpanded(leagueId: string) {
if (!leagueId) return;
const base = readMatchesListUiState() ?? defaultState();
const ids = new Set(base.expandedLeagueIds);
ids.add(leagueId);
writeMatchesListUiState({ ...base, expandedLeagueIds: [...ids] });
const ids = capExpanded([...new Set([...base.expandedLeagueIds, leagueId])]);
writeMatchesListUiState({ ...base, expandedLeagueIds: ids });
}

View File

@@ -6,6 +6,10 @@ import {
} from '../stores/auth';
let hydratePromise: Promise<boolean> | null = null;
let lastHydrateAt = 0;
/** 跨路由复用 /me 结果,减少菜单切换时的重复鉴权请求 */
const HYDRATE_TTL_MS = 60_000;
function isStaffUserType(value: unknown): value is StaffUserType {
return value === 'ADMIN' || value === 'AGENT';
@@ -13,6 +17,7 @@ function isStaffUserType(value: unknown): value is StaffUserType {
export function resetStaffSessionHydration() {
hydratePromise = null;
lastHydrateAt = 0;
}
function hasCompleteStaffUser(u: StaffUser | null | undefined): u is StaffUser {
@@ -23,6 +28,13 @@ function hasCompleteStaffUser(u: StaffUser | null | undefined): u is StaffUser {
export async function hydrateStaffSession(): Promise<boolean> {
const auth = useAuthStore();
if (!auth.token.value) return false;
const freshEnough =
lastHydrateAt > 0 &&
Date.now() - lastHydrateAt < HYDRATE_TTL_MS &&
hasCompleteStaffUser(auth.user.value);
if (freshEnough) return true;
if (hydratePromise) return hydratePromise;
hydratePromise = (async () => {
@@ -51,6 +63,7 @@ export async function hydrateStaffSession(): Promise<boolean> {
canManageSubAgents: raw.canManageSubAgents === true,
inviteCode: raw.inviteCode ?? null,
});
lastHydrateAt = Date.now();
return true;
} catch (e: unknown) {
const status = (e as { response?: { status?: number } })?.response?.status;

View File

@@ -143,7 +143,7 @@ watch(
<el-card class="data-card" shadow="never">
<div class="table-wrap">
<el-table :key="locale" :data="items" stripe>
<el-table :data="items" stripe>
<template #empty>
<AdminTableEmpty />
</template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, computed, watch, reactive } from 'vue';
import { ref, onMounted, computed, watch, reactive, h } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
@@ -219,6 +219,10 @@ const resetAllowed = ref(false);
const resetLoading = ref(false);
const resetConfirmPhrase = ref('');
const settingsCollapseOpen = ref<string[]>([]);
const settingsLoaded = ref(false);
const resetDbStatusLoaded = ref(false);
const agentOptionsLoading = ref(false);
const MAX_EXPANDED_AGENT_ROWS = 2;
const createDialogTitle = computed(() => {
if (createAccountMode.value === 1) return t('agent.dialog.create');
@@ -383,19 +387,55 @@ function resolveCreateParentLabel(agentId: string) {
/* ─── Init ─── */
onMounted(() => {
loadPlayerSettings();
loadBettingLimits();
loadHierarchySettings();
loadPlatformDirectSettings();
loadResetDatabaseStatus();
loadAgentOptions();
void loadUsersPageInit();
loadAllPlayers();
loadTier1Agents();
loadAgentLevelCounts().then(() => {
for (const lvl of visibleSubAgentTabLevels.value) {
loadSubAgentsAtLevel(lvl);
});
async function loadUsersPageInit() {
try {
const { data } = await api.get('/admin/users/page-init');
const payload = data.data as {
playerSettings?: typeof playerSettings.value;
bettingLimits?: typeof bettingLimits.value;
hierarchySettings?: { maxAgentLevel: number };
platformDirect?: { platformDirectRate?: number | string; adminInviteRate?: number | string };
agentLevelCounts?: Record<number, number>;
};
if (payload.playerSettings) playerSettings.value = payload.playerSettings;
if (payload.bettingLimits) bettingLimits.value = payload.bettingLimits;
if (payload.hierarchySettings) {
hierarchySettings.value = {
maxAgentLevel: payload.hierarchySettings.maxAgentLevel ?? 0,
};
}
});
if (payload.platformDirect) {
platformDirectRate.value = decimalRateToPercent(payload.platformDirect.platformDirectRate ?? 0);
adminInviteRate.value = decimalRateToPercent(
payload.platformDirect.adminInviteRate ?? payload.platformDirect.platformDirectRate ?? 0,
);
}
if (payload.agentLevelCounts) {
agentLevelCounts.value = payload.agentLevelCounts;
for (const lvl of visibleSubAgentTabLevels.value) {
loadSubAgentsAtLevel(lvl);
}
}
settingsLoaded.value = true;
} catch {
/* keep defaults */
}
}
watch(settingsCollapseOpen, (open) => {
if (!open.includes('settings')) return;
if (!resetDbStatusLoaded.value) {
resetDbStatusLoaded.value = true;
void loadResetDatabaseStatus();
}
if (!settingsLoaded.value) {
void loadUsersPageInit();
}
});
/* ─── Load tier-1 agents ─── */
@@ -513,15 +553,27 @@ async function load() {
reloadAgentLists();
}
async function loadAgentOptions() {
async function loadAgentOptions(keyword = '') {
agentOptionsLoading.value = true;
try {
const { data } = await api.get('/admin/agents/options');
const { data } = await api.get('/admin/agents/options', {
params: {
keyword: keyword.trim() || undefined,
limit: 50,
},
});
agentOptions.value = data.data;
} catch {
agentOptions.value = [];
} finally {
agentOptionsLoading.value = false;
}
}
function onAgentOptionsSearch(keyword: string) {
void loadAgentOptions(keyword);
}
async function loadAllPlayers() {
playerLoading.value = true;
try {
@@ -605,6 +657,10 @@ function onAgentRowClick(row: AgentRow, event: MouseEvent) {
if (next.has(userId)) {
next.delete(userId);
} else {
if (next.size >= MAX_EXPANDED_AGENT_ROWS) {
const [first] = next;
if (first) next.delete(first);
}
next.add(userId);
if (!agentPlayersMap.value[userId]) void loadExpansionData(userId);
}
@@ -1096,6 +1152,64 @@ async function toggleFreezePlayer(row: PlayerRow) {
}
}
/* ─── Delete Player ─── */
async function deletePlayer(row: PlayerRow) {
try {
await ElMessageBox.confirm(
t('msg.delete_player_body', { name: row.username }),
t('msg.delete_player_title'),
{
type: 'warning',
confirmButtonText: t('common.delete'),
cancelButtonText: t('common.cancel'),
},
);
} catch {
return;
}
// Second confirmation — type username to confirm
const input = ref('');
try {
await ElMessageBox({
title: t('msg.delete_player_confirm_title'),
message: () =>
h('div', {}, [
h('p', { style: 'margin: 0 0 8px; font-size: 13px; color: var(--el-text-color-regular)' },
t('msg.delete_player_confirm_hint', { name: row.username })),
h('input', {
value: input.value,
placeholder: row.username,
onInput: (e: Event) => { input.value = (e.target as HTMLInputElement).value; },
style: 'width: 100%; padding: 6px 8px; border: 1px solid var(--el-border-color); border-radius: 4px; font-size: 13px; box-sizing: border-box',
}),
]),
showCancelButton: true,
confirmButtonText: t('common.delete'),
cancelButtonText: t('common.cancel'),
beforeClose: (action: string, _instance: unknown, done: () => void) => {
if (action === 'confirm') {
if (input.value.trim() !== row.username) {
ElMessage.warning(t('msg.delete_player_mismatch'));
return;
}
}
done();
},
});
} catch {
return;
}
try {
await api.delete(`/admin/users/${row.id}`);
ElMessage.success(t('msg.delete_player_done'));
load();
refreshExpandedParents();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.delete_player_failed'));
}
}
/* ─── Freeze / Unfreeze Agent ─── */
const freezeAgentIsSuspend = computed(() => {
if (!freezeAgentTarget.value) return true;
@@ -1347,7 +1461,13 @@ function creditTypeLabel(type: string) {
v-model="playerFilterAgent"
:placeholder="t('user.filter.agent_ph')"
clearable
filterable
remote
reserve-keyword
:remote-method="onAgentOptionsSearch"
:loading="agentOptionsLoading"
style="width: 200px"
@focus="() => { if (!agentOptions.length) void loadAgentOptions(); }"
>
<el-option
v-for="a in agentOptions"
@@ -1417,6 +1537,7 @@ function creditTypeLabel(type: string) {
@deposit="openTransfer('deposit', row)"
@withdraw="openTransfer('withdraw', row)"
@freeze="toggleFreezePlayer(row)"
@delete="deletePlayer(row)"
/>
</template>
</el-table-column>
@@ -1524,6 +1645,7 @@ function creditTypeLabel(type: string) {
@deposit="openTransfer('deposit', player)"
@withdraw="openTransfer('withdraw', player)"
@freeze="toggleFreezePlayer(player)"
@delete="deletePlayer(player)"
/>
</template>
</el-table-column>
@@ -1679,6 +1801,7 @@ function creditTypeLabel(type: string) {
@deposit="openTransfer('deposit', player)"
@withdraw="openTransfer('withdraw', player)"
@freeze="toggleFreezePlayer(player)"
@delete="deletePlayer(player)"
/>
</template>
</el-table-column>

View File

@@ -79,7 +79,7 @@ function formatTime(v: string) {
<el-card class="data-card" shadow="never">
<div class="table-wrap">
<el-table :key="locale" :data="logs" stripe>
<el-table :data="logs" stripe>
<template #empty>
<AdminTableEmpty />
</template>

View File

@@ -308,7 +308,7 @@ function batchDelete() {
function resetForm() {
form.value = {
sortOrder: total.value + 1,
sortOrder: 0,
status: 'DRAFT',
linkType: '',
linkTarget: '',
@@ -381,6 +381,7 @@ async function submitForm() {
saving.value = true;
try {
const payload = buildPayload();
const isCreate = !editingId.value;
if (editingId.value) {
const { contentType: _type, ...updateBody } = payload;
await api.put(`/admin/contents/${editingId.value}`, updateBody);
@@ -389,6 +390,7 @@ async function submitForm() {
}
ElMessage.success(t('msg.saved'));
dialogVisible.value = false;
if (isCreate) page.value = 1;
await load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string; message?: string | string[] } } };
@@ -471,23 +473,37 @@ void load();
<el-button type="primary" plain size="small" @click="openCreate">
{{ t('content.btn.create') }}
</el-button>
<span v-if="hasSelection" class="batch-hint">
{{ t('content.batch.selected', { n: selectedRows.length }) }}
</span>
<el-button
size="small"
:disabled="!hasSelection || saving"
@click="batchEnable"
>
{{ t('content.batch.enable') }}
</el-button>
<el-button
size="small"
:disabled="!hasSelection || saving"
@click="batchDisable"
>
{{ t('content.batch.disable') }}
</el-button>
<el-button
type="danger"
plain
size="small"
:disabled="!hasSelection || saving"
@click="batchDelete"
>
{{ t('content.batch.delete') }}
</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"
@@ -730,22 +746,6 @@ void load();
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;
@@ -757,6 +757,13 @@ void load();
margin-top: 4px;
}
.batch-hint {
font-size: 12px;
color: #888;
margin: 0 4px;
white-space: nowrap;
}
.thumb {
width: 56px;
height: 32px;

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
import api from '../api';
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
@@ -86,8 +87,33 @@ function openScreenshot(url: string) {
previewVisible.value = true;
}
async function confirmDepositAction(message: string, title: string): Promise<boolean> {
try {
await ElMessageBox.confirm(message, title, {
type: 'warning',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
});
return true;
} catch {
return false;
}
}
function showApiError(e: unknown) {
const msg = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
ElMessage.error(msg || 'Error');
}
async function handleApprove() {
if (!approveTarget.value) return;
const amountText = formatAmount(String(approveAmount.value));
if (!await confirmDepositAction(
t('deposit.confirm_approve_message', { amount: amountText }),
t('deposit.confirm_approve'),
)) {
return;
}
try {
await api.post(`/admin/deposit-orders/${approveTarget.value.id}/approve`, {
approvedAmount: approveAmount.value,
@@ -95,15 +121,15 @@ async function handleApprove() {
});
approveDialogVisible.value = false;
await fetchList();
} catch (e: any) {
alert(e.response?.data?.message || 'Error');
} catch (e: unknown) {
showApiError(e);
}
}
async function handleReject() {
if (!rejectTarget.value) return;
if (!rejectReason.value.trim()) {
alert(t('deposit.reason_required'));
ElMessage.warning(t('deposit.reason_required'));
return;
}
try {
@@ -112,8 +138,45 @@ async function handleReject() {
});
rejectDialogVisible.value = false;
await fetchList();
} catch (e: any) {
alert(e.response?.data?.message || 'Error');
} catch (e: unknown) {
showApiError(e);
}
}
const DEPOSIT_REVOKE_WINDOW_MS = 5 * 60 * 1000;
function canRevokeApproved(row: DepositOrderRow): boolean {
if (row.status !== 'APPROVED' || !row.reviewedAt) return false;
return Date.now() - new Date(row.reviewedAt).getTime() <= DEPOSIT_REVOKE_WINDOW_MS;
}
async function handleReopen(row: DepositOrderRow) {
if (!await confirmDepositAction(t('deposit.confirm_reopen'), t('deposit.reopen_review'))) return;
try {
await api.post(`/admin/deposit-orders/${row.id}/reopen`);
await fetchList();
} catch (e: unknown) {
showApiError(e);
}
}
async function handleRevoke(row: DepositOrderRow) {
if (!await confirmDepositAction(t('deposit.confirm_revoke'), t('deposit.revoke'))) return;
try {
await api.post(`/admin/deposit-orders/${row.id}/reopen`);
await fetchList();
} catch (e: unknown) {
showApiError(e);
}
}
async function handleDelete(row: DepositOrderRow) {
if (!await confirmDepositAction(t('deposit.confirm_delete'), t('deposit.delete'))) return;
try {
await api.delete(`/admin/deposit-orders/${row.id}`);
await fetchList();
} catch (e: unknown) {
showApiError(e);
}
}
@@ -202,6 +265,21 @@ onMounted(fetchList);
<template v-if="row.status === 'PENDING'">
<button class="btn-sm btn-approve" @click="openApprove(row)">{{ t('deposit.approve') }}</button>
<button class="btn-sm btn-reject" @click="openReject(row)">{{ t('deposit.reject') }}</button>
<button class="btn-sm btn-delete" @click="handleDelete(row)">{{ t('deposit.delete') }}</button>
</template>
<template v-else-if="row.status === 'APPROVED'">
<button
v-if="canRevokeApproved(row)"
class="btn-sm btn-reopen"
@click="handleRevoke(row)"
>
{{ t('deposit.revoke') }}
</button>
<button class="btn-sm btn-delete" @click="handleDelete(row)">{{ t('deposit.delete') }}</button>
</template>
<template v-else-if="row.status === 'REJECTED'">
<button class="btn-sm btn-reopen" @click="handleReopen(row)">{{ t('deposit.reopen_review') }}</button>
<button class="btn-sm btn-delete" @click="handleDelete(row)">{{ t('deposit.delete') }}</button>
</template>
</td>
</tr>
@@ -218,6 +296,7 @@ onMounted(fetchList);
<div v-if="approveDialogVisible" class="dialog-overlay" @click.self="approveDialogVisible = false">
<div class="dialog-box">
<h3>{{ t('deposit.approve_title') }}</h3>
<p class="approve-check-hint">{{ t('deposit.approve_check_hint') }}</p>
<div v-if="approveTarget" class="approve-content">
<div class="info-row">
<span>{{ t('deposit.player') }}:</span> <strong>{{ approveTarget.playerUsername }}</strong>
@@ -305,6 +384,11 @@ onMounted(fetchList);
.btn-approve:hover { background: rgba(61, 115, 88, 0.24); }
.btn-reject { background: #3a1a1a; color: #f56c6c; }
.btn-reject:hover { background: #4a2525; }
.btn-reopen { background: #2a2410; color: #e6a23c; }
.btn-reopen:hover { background: #3a3218; }
.btn-delete { background: #2a2a2a; color: #aaa; }
.btn-delete:hover { background: #3a3a3a; color: #f56c6c; }
.actions-cell { white-space: nowrap; }
.pagination { display: flex; justify-content: center; align-items: center; gap: 12px; margin-top: 16px; }
.pagination button { background: #333; color: #ddd; border: none; border-radius: 4px; padding: 6px 14px; cursor: pointer; }
.pagination button:disabled { opacity: 0.4; cursor: default; }
@@ -312,6 +396,16 @@ onMounted(fetchList);
.dialog-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 999; }
.dialog-box { background: #1e1e1e; border-radius: 8px; padding: 24px; min-width: 440px; max-width: 560px; }
.dialog-box h3 { margin: 0 0 16px; font-size: 16px; }
.approve-check-hint {
margin: -8px 0 14px;
padding: 10px 12px;
border-radius: 6px;
background: rgba(230, 162, 60, 0.12);
border: 1px solid rgba(230, 162, 60, 0.35);
color: #e6a23c;
font-size: 13px;
line-height: 1.5;
}
.info-row { margin-bottom: 8px; font-size: 13px; }
.info-row span { color: #888; margin-right: 4px; }
.approve-screenshot { width: 200px; max-height: 200px; object-fit: contain; border-radius: 4px; cursor: pointer; margin-top: 4px; }

View File

@@ -4,15 +4,17 @@ import { useRoute } from 'vue-router';
import { useAdminLocale } from '../composables/useAdminLocale';
import { resolveFormError } from '../i18n/form-validation';
import api from '../api';
import { ElMessage } from 'element-plus';
import { ElMessage, ElMessageBox } from 'element-plus';
import LeagueMatchesPanel from './matches/LeagueMatchesPanel.vue';
import MatchesSubNav from '../components/MatchesSubNav.vue';
import CountryFlagSelect from '../components/outright/CountryFlagSelect.vue';
import LogoUrlField from '../components/LogoUrlField.vue';
import LeagueArchiveDialog from '../components/LeagueArchiveDialog.vue';
import { getBuiltinCountry } from '../data/builtinCountries';
import {
readMatchesListUiState,
writeMatchesListUiState,
MAX_EXPANDED_LEAGUES,
} from '../utils/matchesListState';
import { formatAmount } from '../utils/format-amount';
import {
@@ -39,6 +41,9 @@ const leagueDialogMode = ref<'create' | 'edit'>('create');
const leagueEditingId = ref('');
const leagueForm = ref({ leagueEn: '', leagueZh: '', leagueMs: '', logoUrl: '', deleteOldLogo: false, originalLogoUrl: '' });
const publishingLeagueId = ref('');
const leagueArchiveVisible = ref(false);
const leagueArchiveId = ref('');
const leagueArchiveName = ref('');
const leagueDialogTitle = computed(() =>
leagueDialogMode.value === 'edit'
@@ -155,7 +160,18 @@ function openEditLeague(row: unknown) {
async function toggleLeaguePublish(row: unknown) {
const r = rowOf(row);
const id = String(r.id ?? '');
if (leagueIsPublished(row)) return;
const published = leagueIsPublished(row);
if (published) {
try {
await ElMessageBox.confirm(t('league.confirm_unpublish'), t('common.confirm'), {
type: 'warning',
confirmButtonText: t('league.btn.unpublish'),
cancelButtonText: t('common.cancel'),
});
} catch {
return;
}
}
publishingLeagueId.value = id;
try {
await api.put(`/admin/leagues/${id}`, {
@@ -163,9 +179,9 @@ async function toggleLeaguePublish(row: unknown) {
leagueZh: String(r.leagueZh ?? ''),
leagueMs: String(r.leagueMs ?? ''),
logoUrl: String(r.logoUrl ?? '').trim() || undefined,
isActive: true,
isActive: !published,
});
ElMessage.success(t('msg.league_published'));
ElMessage.success(published ? t('msg.league_unpublished') : t('msg.league_published'));
await load({ keepExpand: true });
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
@@ -255,7 +271,7 @@ async function submitCreate() {
const lid = form.value.leagueId.trim();
await load({ keepExpand: true });
if (lid && !expandedRowKeys.value.includes(lid)) {
expandedRowKeys.value = [...expandedRowKeys.value, lid];
expandedRowKeys.value = capExpandedLeagueIds([...expandedRowKeys.value, lid]);
persistListUiState();
}
} catch (e: unknown) {
@@ -266,15 +282,24 @@ async function submitCreate() {
}
}
function capExpandedLeagueIds(ids: string[]): string[] {
return ids.slice(0, MAX_EXPANDED_LEAGUES);
}
function onExpandChange(_row: unknown, expanded: unknown[]) {
expandedRowKeys.value = expanded.map((r) => leagueId(r));
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;
const id = leagueId(row);
expandedRowKeys.value = expandedRowKeys.value.includes(id) ? [] : [id];
if (expandedRowKeys.value.includes(id)) {
expandedRowKeys.value = expandedRowKeys.value.filter((k) => k !== id);
} else {
const next = [...expandedRowKeys.value, id];
expandedRowKeys.value = capExpandedLeagueIds(next);
}
persistListUiState();
}
@@ -339,6 +364,16 @@ function isLeagueExpanded(id: string) {
return expandedRowKeys.value.includes(id);
}
function openLeagueArchive(row: unknown) {
leagueArchiveId.value = leagueId(row);
leagueArchiveName.value = leagueTitle(row);
leagueArchiveVisible.value = true;
}
function onLeagueArchived() {
void load();
}
</script>
<template>
@@ -378,6 +413,7 @@ function isLeagueExpanded(id: string) {
</div>
<section class="list-panel">
<p class="list-hint">{{ t('match.expand_league_hint') }}</p>
<div class="table-wrap">
<el-table
:data="leagues"
@@ -473,6 +509,18 @@ function isLeagueExpanded(id: string) {
>
{{ 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>
</template>
@@ -609,6 +657,13 @@ function isLeagueExpanded(id: string) {
<el-button type="primary" :loading="createLoading" @click="submitCreate">{{ t('user.btn.create') }}</el-button>
</template>
</el-dialog>
<LeagueArchiveDialog
v-model="leagueArchiveVisible"
:league-id="leagueArchiveId"
:league-name="leagueArchiveName"
@archived="onLeagueArchived"
/>
</div>
</template>
@@ -780,4 +835,5 @@ function isLeagueExpanded(id: string) {
:deep(.logo-url-field) {
width: 100%;
}
</style>

View File

@@ -17,7 +17,6 @@ interface PaymentMethod {
displayName: string | null;
sortOrder: number;
isActive: boolean;
showOnPlayer: boolean;
createdAt: string;
translations?: {
displayName?: Record<string, string>;
@@ -42,7 +41,6 @@ const form = ref({
displayName: '',
sortOrder: 0,
isActive: true,
showOnPlayer: true,
translations: {
displayName: { 'zh-CN': '', 'en-US': '', 'ms-MY': '' },
bankName: { 'zh-CN': '', 'en-US': '', 'ms-MY': '' },
@@ -76,7 +74,6 @@ function openCreate() {
displayName: '',
sortOrder: 0,
isActive: true,
showOnPlayer: true,
translations: {
displayName: { 'zh-CN': '', 'en-US': '', 'ms-MY': '' },
bankName: { 'zh-CN': '', 'en-US': '', 'ms-MY': '' },
@@ -98,7 +95,6 @@ function openEdit(row: PaymentMethod) {
displayName: row.displayName ?? '',
sortOrder: row.sortOrder,
isActive: row.isActive,
showOnPlayer: row.showOnPlayer,
translations: {
displayName: {
'zh-CN': t.displayName?.['zh-CN'] ?? '',
@@ -153,9 +149,9 @@ async function handleDelete(row: PaymentMethod) {
} catch { /* */ }
}
async function toggleField(row: PaymentMethod, field: 'isActive' | 'showOnPlayer') {
async function toggleActive(row: PaymentMethod) {
try {
await api.put(`/admin/payment-methods/${row.id}`, { [field]: !row[field] });
await api.put(`/admin/payment-methods/${row.id}`, { isActive: !row.isActive });
await fetchList();
} catch { /* */ }
}
@@ -205,7 +201,6 @@ onMounted(fetchList);
<th>{{ t('deposit.details') }}</th>
<th>{{ t('deposit.sort') }}</th>
<th>{{ t('deposit.active') }}</th>
<th>{{ t('deposit.show_player') }}</th>
<th>{{ t('common.actions') }}</th>
</tr>
</thead>
@@ -227,17 +222,11 @@ onMounted(fetchList);
<td>
<span :class="row.isActive ? 'status-on' : 'status-off'">{{ row.isActive ? 'ON' : 'OFF' }}</span>
</td>
<td>
<span :class="row.showOnPlayer ? 'status-on' : 'status-off'">{{ row.showOnPlayer ? 'ON' : 'OFF' }}</span>
</td>
<td class="actions-cell">
<button class="btn-sm" @click="openEdit(row)">{{ t('common.edit') }}</button>
<button class="btn-sm btn-toggle" @click="toggleField(row, 'isActive')">
<button class="btn-sm btn-toggle" @click="toggleActive(row)">
{{ row.isActive ? t('common.disable') : t('common.enable') }}
</button>
<button class="btn-sm btn-toggle" @click="toggleField(row, 'showOnPlayer')">
{{ row.showOnPlayer ? t('common.hide_player') : t('common.show_player') }}
</button>
<button class="btn-sm btn-danger" @click="handleDelete(row)">{{ t('common.delete') }}</button>
</td>
</tr>
@@ -321,7 +310,6 @@ onMounted(fetchList);
</div>
<div class="form-group row-checks">
<label><input type="checkbox" v-model="form.isActive" /> {{ t('deposit.active') }}</label>
<label><input type="checkbox" v-model="form.showOnPlayer" /> {{ t('deposit.show_on_player') }}</label>
</div>
<div class="dialog-actions">
<button class="btn-cancel" @click="dialogVisible = false">{{ t('common.cancel') }}</button>

View File

@@ -1,10 +1,13 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, defineAsyncComponent } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import api from '../api';
import { ElMessage } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
import { VChart } from '../components/dashboard/echarts-setup';
const VChart = defineAsyncComponent(() =>
import('../components/dashboard/echarts-setup').then((m) => m.VChart),
);
import { formatAmount } from '../utils/format-amount';
import {
buildBetTypePieOption,
@@ -72,11 +75,22 @@ const loading = ref(false);
const previewing = ref(false);
const match = ref<AdminMatchDetail | null>(null);
const score = ref({ htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 });
const winnerTeamId = ref('');
const outrightSelections = ref<
Array<{ teamId: string; teamCode: string; teamZh: string; teamEn: string }>
>([]);
const preview = ref<Record<string, unknown> | null>(null);
const resettlePreview = ref<Record<string, unknown> | null>(null);
const resettleReason = ref('');
const stats = ref<SettlementBetStats | null>(null);
const statsSummary = ref<Pick<SettlementBetStats, 'summary' | 'bySelection'> | null>(null);
const betsList = ref<SettlementBetStats['bets'] | null>(null);
const statsLoading = ref(false);
const betsLoading = ref(false);
const stats = computed((): SettlementBetStats | null => {
if (!statsSummary.value || !betsList.value) return null;
return { ...statsSummary.value, bets: betsList.value };
});
const betPage = ref(1);
const betPageSize = ref(10);
const previewPage = ref(1);
@@ -85,6 +99,42 @@ const previewPageSize = ref(10);
// 智能比分推荐已暂时关闭(后端 smart-score.solver.ts 保留,恢复时接回 UI 与 POST /settlement/smart-score
const matchId = computed(() => String(route.params.id ?? ''));
const isOutright = computed(() => match.value?.isOutright === true);
const outrightTitle = computed(() => {
const m = match.value;
if (!m) return '';
const name = m.matchName?.trim();
if (name) return name;
if (locale.value === 'en-US') return m.leagueEn || m.leagueZh;
if (locale.value === 'ms-MY') return m.leagueMs || m.leagueEn || m.leagueZh;
return m.leagueZh || m.leagueEn;
});
const settlementPageTitle = computed(() =>
isOutright.value ? t('settlement.outright.page_title') : t('page.settlement.title'),
);
function outrightTeamLabel(row: { teamZh: string; teamEn: string; teamCode: string }) {
const name =
locale.value === 'en-US'
? row.teamEn || row.teamZh
: locale.value === 'ms-MY'
? row.teamEn || row.teamZh
: row.teamZh || row.teamEn;
return `${name} (${row.teamCode})`;
}
function buildSettlementPayload(): Record<string, unknown> | null {
if (isOutright.value) {
if (!winnerTeamId.value) {
ElMessage.warning(t('settlement.outright.winner_required'));
return null;
}
return { winnerTeamId: Number(winnerTeamId.value) };
}
return { ...score.value };
}
type PreviewItem = {
betNo: string;
betType: string;
@@ -258,16 +308,12 @@ function matchBetSelectionSummary(
.join(' · ');
}
async function loadStats() {
async function loadStatsSummary() {
if (!matchId.value) return;
statsLoading.value = true;
try {
const { data } = await api.get(`/admin/matches/${matchId.value}/settlement/stats`, {
params: { page: betPage.value, pageSize: betPageSize.value },
});
stats.value = data.data as SettlementBetStats;
betPage.value = stats.value.bets.page;
betPageSize.value = stats.value.bets.pageSize;
const { data } = await api.get(`/admin/matches/${matchId.value}/settlement/summary`);
statsSummary.value = data.data as Pick<SettlementBetStats, 'summary' | 'bySelection'>;
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
@@ -276,15 +322,39 @@ async function loadStats() {
}
}
async function loadBets() {
if (!matchId.value) return;
betsLoading.value = true;
try {
const { data } = await api.get(`/admin/matches/${matchId.value}/settlement/bets`, {
params: { page: betPage.value, pageSize: betPageSize.value },
});
const payload = data.data as SettlementBetStats['bets'];
betsList.value = payload;
betPage.value = payload.page;
betPageSize.value = payload.pageSize;
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
} finally {
betsLoading.value = false;
}
}
async function loadStats() {
betPage.value = 1;
await Promise.all([loadStatsSummary(), loadBets()]);
}
function onBetPageChange(page: number) {
betPage.value = page;
void loadStats();
void loadBets();
}
function onBetPageSizeChange(size: number) {
betPageSize.value = size;
betPage.value = 1;
void loadStats();
void loadBets();
}
async function loadMatch() {
@@ -292,26 +362,49 @@ async function loadMatch() {
loading.value = true;
try {
const { data } = await api.get(`/admin/matches/${matchId.value}`);
const detail = data.data as AdminMatchDetail & {
score?: { htHome: number; htAway: number; ftHome: number; ftAway: number } | null;
};
if (detail.isOutright) {
ElMessage.warning(t('msg.outright_no_edit'));
router.replace('/matches');
return;
}
const detail = data.data as AdminMatchDetail;
const settleable =
detail.status === 'CLOSED' ||
detail.status === 'PENDING_SETTLEMENT' ||
detail.status === 'SETTLED';
if (!settleable) {
ElMessage.warning(t('settlement.must_close_first'));
router.replace('/matches');
router.replace(detail.isOutright ? '/matches/outrights' : '/matches');
return;
}
match.value = detail;
if (detail.score) {
score.value = { ...detail.score };
score.value = {
htHome: detail.score.htHome,
htAway: detail.score.htAway,
ftHome: detail.score.ftHome,
ftAway: detail.score.ftAway,
};
winnerTeamId.value = detail.score.winnerTeamId ?? '';
} else {
winnerTeamId.value = '';
}
if (detail.isOutright) {
const outrightRes = await api.get(`/admin/outrights/${matchId.value}`);
const payload = outrightRes.data.data as {
selections: Array<{
teamId: string | null;
teamCode: string;
teamZh: string;
teamEn: string;
status: string;
}>;
};
outrightSelections.value = (payload.selections ?? [])
.filter((s) => s.teamId)
.map((s) => ({
teamId: s.teamId as string,
teamCode: s.teamCode,
teamZh: s.teamZh,
teamEn: s.teamEn,
}));
} else {
outrightSelections.value = [];
}
betPage.value = 1;
await loadStats();
@@ -326,8 +419,10 @@ async function loadMatch() {
const isSettled = computed(() => match.value?.status === 'SETTLED');
async function previewResettlement() {
const payload = buildSettlementPayload();
if (!payload) return;
const { data } = await api.post(`/admin/matches/${matchId.value}/resettle/preview`, {
...score.value,
...payload,
reason: resettleReason.value.trim() || undefined,
});
resettlePreview.value = data.data;
@@ -347,6 +442,9 @@ function settlementApiError(e: unknown, fallback: string) {
if (raw === 'Score not recorded' || raw === 'Score not found') {
return t('settlement.err_score_not_recorded');
}
if (raw === 'SETTLEMENT_WINNER_REQUIRED') {
return t('settlement.outright.winner_required');
}
return raw;
}
@@ -368,12 +466,14 @@ async function loadPreviewItems(page = previewPage.value, pageSize = previewPage
}
async function previewSettlement() {
const payload = buildSettlementPayload();
if (!payload) return;
preview.value = null;
previewPage.value = 1;
previewing.value = true;
try {
const { data } = await api.post(`/admin/matches/${matchId.value}/settlement/preview`, {
...score.value,
...payload,
page: 1,
pageSize: previewPageSize.value,
});
@@ -416,7 +516,7 @@ onMounted(() => {
<template>
<div v-loading="loading" class="settlement-page">
<AdminSubNav
:title="t('page.settlement.title')"
:title="settlementPageTitle"
:subtitle="`#${matchId}`"
>
<template #extra>
@@ -426,7 +526,13 @@ onMounted(() => {
<el-card v-if="match" class="settle-top-card" shadow="never">
<p v-if="leagueLabel" class="match-league">{{ leagueLabel }}</p>
<div class="match-inline">
<div v-if="isOutright" class="outright-settle-head">
<p class="outright-settle-title">{{ outrightTitle }}</p>
<span class="kickoff-inline">
<span class="meta-k">{{ t('settlement.outright.title') }}</span>
</span>
</div>
<div v-else class="match-inline">
<div class="team-chip">
<img
v-if="match.homeTeamLogoUrl"
@@ -455,7 +561,24 @@ onMounted(() => {
</div>
<div class="settle-score-row">
<div class="score-inline-group">
<div v-if="isOutright" class="outright-winner-row">
<span class="score-title">{{ t('settlement.outright.winner') }}</span>
<el-select
v-model="winnerTeamId"
filterable
clearable
class="outright-winner-select"
:placeholder="t('settlement.outright.winner_ph')"
>
<el-option
v-for="row in outrightSelections"
:key="row.teamId"
:label="outrightTeamLabel(row)"
:value="row.teamId"
/>
</el-select>
</div>
<div v-else class="score-inline-group">
<div class="score-block compact">
<span class="score-title">{{ t('settlement.ht_score') }}</span>
<div class="score-inputs">
@@ -483,7 +606,9 @@ onMounted(() => {
>
{{ t('settlement.preview_btn') }}
</el-button>
<span class="preview-hint">{{ t('settlement.preview_hint') }}</span>
<span class="preview-hint">{{
isOutright ? t('settlement.outright.preview_hint') : t('settlement.preview_hint')
}}</span>
</template>
<template v-else>
<el-input
@@ -638,7 +763,7 @@ onMounted(() => {
{{ t('settlement.bet_list') }} ({{ stats.bets.total }})
<span class="subsection-hint">{{ t('settlement.bet_list_hint') }}</span>
</div>
<div class="table-wrap">
<div v-loading="betsLoading" class="table-wrap">
<el-table
v-if="stats.bets.items.length"
:data="stats.bets.items"
@@ -751,6 +876,32 @@ onMounted(() => {
border-top: 1px solid #2a2a2a;
}
.outright-settle-head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px 16px;
}
.outright-settle-title {
margin: 0;
font-size: 16px;
font-weight: 700;
color: var(--green-text);
}
.outright-winner-row {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 240px;
flex: 1;
}
.outright-winner-select {
width: min(100%, 360px);
}
.settle-actions {
display: flex;
align-items: center;

View File

@@ -235,7 +235,7 @@ onMounted(async () => {
</el-select>
</div>
<div class="table-wrap">
<el-table :key="locale" :data="filteredResults" stripe row-key="id">
<el-table :data="filteredResults" stripe row-key="id">
<template #empty>
<AdminTableEmpty />
</template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch, reactive } from 'vue';
import { ref, computed, onMounted, watch, reactive, h } from 'vue';
import { useAdminLocale } from '../../composables/useAdminLocale';
import { useAuthStore } from '../../stores/auth';
import api from '../../api';
@@ -181,10 +181,15 @@ function ensureSubAgentState(level: number): SubAgentLevelState {
}
/* ─── Agent row expansion (direct players) ─── */
const MAX_EXPANDED_AGENT_ROWS = 2;
const expandedSet = ref(new Set<string>());
const expandedRowKeys = computed(() => Array.from(expandedSet.value));
const agentPlayersMap = ref<Record<string, ScopedPlayerRow[]>>({});
const agentPlayersMeta = ref<
Record<string, { total: number; page: number; pageSize: number }>
>({});
const expandLoading = ref<Record<string, boolean>>({});
const EXPAND_PLAYER_PAGE_SIZE = 20;
const createVisible = ref(false);
const createLoading = ref(false);
@@ -439,7 +444,8 @@ function openPlayerWalletLedger(
/* ─── Agent row expansion ─── */
async function onExpandChange(row: AgentSubAgentRow, expandedRows: AgentSubAgentRow[]) {
expandedSet.value = new Set(expandedRows.map((r) => r.userId));
const ids = expandedRows.map((r) => r.userId).slice(0, MAX_EXPANDED_AGENT_ROWS);
expandedSet.value = new Set(ids);
if (expandedSet.value.has(row.userId) && !agentPlayersMap.value[row.userId]) {
await loadExpansionData(row.userId);
}
@@ -452,19 +458,41 @@ function onSubAgentRowClick(row: AgentSubAgentRow, _column: unknown, event: Mous
if (next.has(userId)) {
next.delete(userId);
} else {
if (next.size >= MAX_EXPANDED_AGENT_ROWS) {
const [first] = next;
if (first) next.delete(first);
}
next.add(userId);
if (!agentPlayersMap.value[userId]) void loadExpansionData(userId);
}
expandedSet.value = next;
}
async function loadExpansionData(agentUserId: string) {
async function loadExpansionData(agentUserId: string, page = 1) {
expandLoading.value[agentUserId] = true;
try {
const { data } = await api.get(`/agent/agents/${agentUserId}/players`);
agentPlayersMap.value[agentUserId] = (data.data ?? []) as ScopedPlayerRow[];
const { data } = await api.get(`/agent/agents/${agentUserId}/players`, {
params: { page, pageSize: EXPAND_PLAYER_PAGE_SIZE },
});
const payload = data.data as {
items: ScopedPlayerRow[];
total: number;
page: number;
pageSize: number;
};
agentPlayersMap.value[agentUserId] = payload.items ?? [];
agentPlayersMeta.value[agentUserId] = {
total: payload.total ?? 0,
page: payload.page ?? page,
pageSize: payload.pageSize ?? EXPAND_PLAYER_PAGE_SIZE,
};
} catch {
agentPlayersMap.value[agentUserId] = [];
agentPlayersMeta.value[agentUserId] = {
total: 0,
page: 1,
pageSize: EXPAND_PLAYER_PAGE_SIZE,
};
} finally {
expandLoading.value[agentUserId] = false;
}
@@ -474,6 +502,20 @@ function getPlayers(agentUserId: string) {
return agentPlayersMap.value[agentUserId] ?? [];
}
function getPlayersMeta(agentUserId: string) {
return (
agentPlayersMeta.value[agentUserId] ?? {
total: getPlayers(agentUserId).length,
page: 1,
pageSize: EXPAND_PLAYER_PAGE_SIZE,
}
);
}
function onExpandPlayersPageChange(agentUserId: string, page: number) {
void loadExpansionData(agentUserId, page);
}
function refreshExpandedAgentPlayers() {
for (const uid of expandedSet.value) {
void loadExpansionData(uid);
@@ -580,6 +622,60 @@ async function toggleFreeze(row: ScopedPlayerRow) {
}
}
/* ─── Delete ─── */
async function deletePlayerRow(row: ScopedPlayerRow) {
if (!canOperatePlayer(row)) return;
try {
await ElMessageBox.confirm(
t('msg.delete_player_body', { name: row.username }),
t('msg.delete_player_title'),
{
type: 'warning',
confirmButtonText: t('common.delete'),
cancelButtonText: t('common.cancel'),
},
);
} catch { return; }
const input = ref('');
try {
await ElMessageBox({
title: t('msg.delete_player_confirm_title'),
message: () =>
h('div', {}, [
h('p', { style: 'margin: 0 0 8px; font-size: 13px; color: var(--el-text-color-regular)' },
t('msg.delete_player_confirm_hint', { name: row.username })),
h('input', {
value: input.value,
placeholder: row.username,
onInput: (e: Event) => { input.value = (e.target as HTMLInputElement).value; },
style: 'width: 100%; padding: 6px 8px; border: 1px solid var(--el-border-color); border-radius: 4px; font-size: 13px; box-sizing: border-box',
}),
]),
showCancelButton: true,
confirmButtonText: t('common.delete'),
cancelButtonText: t('common.cancel'),
beforeClose: (action: string, _instance: unknown, done: () => void) => {
if (action === 'confirm') {
if (input.value.trim() !== row.username) {
ElMessage.warning(t('msg.delete_player_mismatch'));
return;
}
}
done();
},
});
} catch { return; }
try {
await api.delete(`/agent/players/${row.id}`);
ElMessage.success(t('msg.delete_player_done'));
loadAllPlayers();
refreshExpandedAgentPlayers();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.delete_player_failed'));
}
}
/* ─── Transfer ─── */
async function openTransfer(type: 'deposit' | 'withdraw', row: ScopedPlayerRow) {
if (!canOperatePlayer(row)) return;
@@ -938,6 +1034,7 @@ function statusTagType(s: string) {
@deposit="openTransfer('deposit', row)"
@withdraw="openTransfer('withdraw', row)"
@freeze="toggleFreeze(row)"
@delete="deletePlayerRow(row)"
/>
<el-button
v-else-if="canViewPlayer(row)"
@@ -1040,7 +1137,7 @@ function statusTagType(s: string) {
<div v-else class="expand-panel-body">
<p v-if="!isDirectChildAgent(row)" class="expand-readonly-hint">{{ expandReadonlyHint }}</p>
<div class="expand-section-title">
{{ directPlayersTabLabel(row.username, getPlayers(row.userId).length) }}
{{ directPlayersTabLabel(row.username, getPlayersMeta(row.userId).total) }}
</div>
<el-table
:data="getPlayers(row.userId)"
@@ -1079,6 +1176,19 @@ function statusTagType(s: string) {
<template #default="{ row: player }">{{ formatTime(player.createdAt) }}</template>
</el-table-column>
</el-table>
<div
v-if="getPlayersMeta(row.userId).total > getPlayersMeta(row.userId).pageSize"
class="expand-pager"
>
<el-pagination
small
layout="total, prev, pager, next"
:total="getPlayersMeta(row.userId).total"
:current-page="getPlayersMeta(row.userId).page"
:page-size="getPlayersMeta(row.userId).pageSize"
@current-change="(p: number) => onExpandPlayersPageChange(row.userId, p)"
/>
</div>
</div>
</div>
</template>
@@ -1480,6 +1590,11 @@ function statusTagType(s: string) {
.expand-section-title--spaced { margin-top: 14px; }
.expand-readonly-hint { font-size: 12px; color: #999; margin: 0 0 8px; }
.nested-table { margin-bottom: 4px; }
.expand-pager {
display: flex;
justify-content: flex-end;
padding-top: 8px;
}
/* ─── Shared ─── */
.field-hint { margin-top: 6px; font-size: 12px; color: #666; line-height: 1.4; }

View File

@@ -23,6 +23,7 @@ export interface MatchCreateForm {
awayTeamEn: string;
awayTeamMs: string;
isHot: boolean;
correctScoreEnabled: boolean;
displayOrder: number;
matchName: string;
stage: string;
@@ -48,6 +49,7 @@ export function emptyMatchForm(): MatchCreateForm {
awayTeamEn: '',
awayTeamMs: '',
isHot: false,
correctScoreEnabled: true,
displayOrder: 0,
matchName: '',
stage: '',
@@ -81,6 +83,7 @@ export type AdminMatchDetail = {
status: string;
isOutright: boolean;
isHot: boolean;
correctScoreEnabled: boolean;
displayOrder: number;
startTime: string;
leagueId?: string;
@@ -107,6 +110,7 @@ export type AdminMatchDetail = {
htAway: number;
ftHome: number;
ftAway: number;
winnerTeamId?: string | null;
} | null;
markets?: AdminMarket[];
};
@@ -143,6 +147,7 @@ export function formFromDetail(d: AdminMatchDetail): MatchCreateForm {
awayTeamEn: d.awayTeamEn,
awayTeamMs: d.awayTeamMs ?? '',
isHot: d.isHot,
correctScoreEnabled: d.correctScoreEnabled ?? true,
displayOrder: d.displayOrder ?? 0,
matchName: d.matchName ?? '',
stage: d.stage ?? '',
@@ -239,6 +244,7 @@ export function buildPlatformPayload(form: MatchCreateForm) {
awayTeamMs: form.awayTeamMs.trim() || undefined,
startTime: normalizeStartTimeForApi(form.startTime),
isHot: form.isHot,
correctScoreEnabled: form.correctScoreEnabled,
displayOrder: form.displayOrder,
matchName: form.matchName.trim() || undefined,
stage: form.stage.trim() || undefined,
@@ -284,6 +290,7 @@ export function buildMatchUpdatePayload(form: MatchCreateForm) {
awayTeamMs: form.awayTeamMs.trim() || undefined,
startTime: normalizeStartTimeForApi(form.startTime),
isHot: form.isHot,
correctScoreEnabled: form.correctScoreEnabled,
displayOrder: form.displayOrder,
matchName: form.matchName.trim() || undefined,
stage: form.stage.trim() || undefined,

View File

@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox, ElDatePicker } from 'element-plus';
import { useAdminLocale } from '../../composables/useAdminLocale';
import api from '../../api';
import MatchArchiveDialog from '../../components/MatchArchiveDialog.vue';
import { ensureLeagueExpanded } from '../../utils/matchesListState';
import { formatAmount } from '../../utils/format-amount';
const props = defineProps<{
@@ -19,8 +20,15 @@ const emit = defineEmits<{
const { t, locale } = useAdminLocale();
const router = useRouter();
const archiveVisible = ref(false);
const archiveMatchId = ref('');
const archiveTitle = ref('');
const matches = ref<unknown[]>([]);
const loading = ref(false);
const matchPage = ref(1);
const matchPageSize = ref(20);
const matchTotal = ref(0);
let loadTimer: ReturnType<typeof setTimeout> | null = null;
async function load() {
loading.value = true;
@@ -30,9 +38,20 @@ async function load() {
status: props.filterStatus || undefined,
keyword: props.keyword.trim() || undefined,
locale: locale.value,
page: matchPage.value,
pageSize: matchPageSize.value,
},
});
matches.value = data.data.items;
const payload = data.data as {
items: unknown[];
total: number;
page: number;
pageSize: number;
};
matches.value = payload.items;
matchTotal.value = payload.total;
matchPage.value = payload.page;
matchPageSize.value = payload.pageSize;
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_matches_failed'));
@@ -41,12 +60,32 @@ async function load() {
}
}
function scheduleLoad(resetPage = false) {
if (resetPage) matchPage.value = 1;
if (loadTimer) clearTimeout(loadTimer);
loadTimer = setTimeout(() => {
loadTimer = null;
void load();
}, 200);
}
watch(
() => [props.leagueId, props.filterStatus, props.keyword, locale.value] as const,
() => load(),
() => [props.leagueId, props.filterStatus, props.keyword] as const,
() => scheduleLoad(true),
{ immediate: true },
);
function onMatchPageChange(page: number) {
matchPage.value = page;
void load();
}
function onMatchPageSizeChange(size: number) {
matchPageSize.value = size;
matchPage.value = 1;
void load();
}
function notifyParent() {
emit('changed');
load();
@@ -72,6 +111,21 @@ async function publish(id: string) {
notifyParent();
}
async function unpublish(id: string) {
try {
await ElMessageBox.confirm(t('match.confirm_unpublish'), t('common.confirm'), {
type: 'warning',
confirmButtonText: t('match.btn.unpublish'),
cancelButtonText: t('common.cancel'),
});
} catch {
return;
}
await api.post(`/admin/matches/${id}/unpublish`);
ElMessage.success(t('msg.match_unpublished'));
notifyParent();
}
async function close(id: string) {
await api.post(`/admin/matches/${id}/close`);
ElMessage.success(t('msg.closed'));
@@ -156,12 +210,24 @@ function canManage(row: unknown) {
const s = matchStatus(row);
return s === 'DRAFT' || s === 'PUBLISHED';
}
function canDeleteRow(row: unknown) {
return matchStatus(row) === 'DRAFT';
function canDeleteRow(_row: unknown) {
return true;
}
function openArchive(row: unknown) {
archiveMatchId.value = matchId(row);
archiveTitle.value = matchTitle(row);
archiveVisible.value = true;
}
function onMatchArchived() {
notifyParent();
}
function canPublishRow(row: unknown) {
return matchStatus(row) === 'DRAFT';
}
function canUnpublishRow(row: unknown) {
const s = matchStatus(row);
return s === 'PUBLISHED' || s === 'CLOSED' || s === 'PENDING_SETTLEMENT';
}
function canCloseRow(row: unknown) {
return matchStatus(row) === 'PUBLISHED';
}
@@ -242,22 +308,7 @@ async function reopenRow(row: unknown) {
}
async function confirmDelete(row: unknown) {
const id = matchId(row);
const title = matchTitle(row);
try {
await ElMessageBox.confirm(t('match.delete_confirm_body', { title }), t('match.delete_confirm_title'), {
type: 'warning',
confirmButtonText: t('common.delete'),
cancelButtonText: t('common.cancel'),
});
await api.delete(`/admin/matches/${id}`);
ElMessage.success(t('msg.deleted'));
notifyParent();
} 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 ?? t('msg.delete_failed'));
}
openArchive(row);
}
defineExpose({ reload: load });
@@ -328,13 +379,21 @@ defineExpose({ reload: load });
</div>
<div class="action-group">
<el-button
v-if="canPublishRow(row)"
size="small"
type="success"
:disabled="!canPublishRow(row)"
@click="publish(matchId(row))"
>
{{ t('common.publish') }}
</el-button>
<el-button
v-else-if="canUnpublishRow(row)"
size="small"
type="warning"
@click="unpublish(matchId(row))"
>
{{ t('match.btn.unpublish') }}
</el-button>
<el-button
size="small"
type="warning"
@@ -365,7 +424,6 @@ defineExpose({ reload: load });
size="small"
type="danger"
plain
:disabled="!canDeleteRow(row)"
@click="confirmDelete(row)"
>
{{ t('common.delete') }}
@@ -375,6 +433,24 @@ defineExpose({ reload: load });
</el-table-column>
</el-table>
<p v-if="!loading && !matches.length" class="empty-hint">{{ t('match.no_fixtures') }}</p>
<div v-if="matchTotal > matchPageSize" class="nested-pager">
<el-pagination
v-model:current-page="matchPage"
v-model:page-size="matchPageSize"
:total="matchTotal"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
small
@current-change="onMatchPageChange"
@size-change="onMatchPageSizeChange"
/>
</div>
<MatchArchiveDialog
v-model="archiveVisible"
:match-id="archiveMatchId"
:title="archiveTitle"
@archived="onMatchArchived"
/>
</div>
</template>
@@ -383,6 +459,11 @@ defineExpose({ reload: load });
padding: 10px 12px 12px;
background: #0a0a0a;
}
.nested-pager {
display: flex;
justify-content: flex-end;
padding-top: 8px;
}
.actions-col-header {
display: inline-flex;
flex-direction: row;

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../../composables/useAdminLocale';
import api from '../../api';
@@ -48,6 +49,9 @@ const emit = defineEmits<{
}>();
const { t, locale } = useAdminLocale();
const router = useRouter();
const matchStatus = ref('');
function teamDisplayName(row: { teamCode: string; teamZh: string; teamEn: string }) {
return teamRowDisplayName(row, locale.value);
@@ -55,8 +59,11 @@ function teamDisplayName(row: { teamCode: string; teamZh: string; teamEn: string
const loading = ref(false);
const savingOdds = ref(false);
const reopening = ref(false);
const adding = ref(false);
const matchId = ref('');
const leagueIsPublished = ref(true);
const unsettledFixtureCount = ref(0);
const selections = ref<SelectionRow[]>([]);
const addVisible = ref(false);
@@ -133,6 +140,95 @@ const sortedSelections = computed(() => {
return rows;
});
const canCloseOutright = computed(() => matchStatus.value === 'PUBLISHED');
const canReopenOutright = computed(() =>
['CLOSED', 'PENDING_SETTLEMENT'].includes(matchStatus.value),
);
const canSettleOutright = computed(() =>
['CLOSED', 'PENDING_SETTLEMENT', 'SETTLED'].includes(matchStatus.value),
);
const canProceedSettle = computed(
() => matchStatus.value === 'SETTLED' || unsettledFixtureCount.value === 0,
);
const showLeagueUnpublishedHint = computed(
() => !leagueIsPublished.value && matchStatus.value === 'DRAFT',
);
const showUnsettledFixturesHint = computed(
() =>
unsettledFixtureCount.value > 0 &&
['CLOSED', 'PENDING_SETTLEMENT'].includes(matchStatus.value),
);
const settleButtonLabel = computed(() =>
matchStatus.value === 'SETTLED' ? t('common.resettle') : t('common.settle'),
);
const statusLabel = computed(() => {
const key = `match.status.${matchStatus.value}`;
const label = t(key);
return label === key ? matchStatus.value : label;
});
const statusTagType = computed(() => {
switch (matchStatus.value) {
case 'PUBLISHED':
return 'success';
case 'CLOSED':
case 'PENDING_SETTLEMENT':
return 'warning';
case 'SETTLED':
return 'info';
default:
return 'info';
}
});
async function reopenOutright() {
if (!matchId.value) return;
try {
await ElMessageBox.confirm(t('outright.confirm_reopen'), t('common.confirm'), {
type: 'warning',
});
} catch {
return;
}
reopening.value = true;
try {
await api.post(`/admin/matches/${matchId.value}/reopen`);
ElMessage.success(t('msg.reopened'));
await load();
emit('updated');
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
reopening.value = false;
}
}
async function closeOutright() {
if (!matchId.value) return;
try {
await ElMessageBox.confirm(t('outright.confirm_close'), t('common.confirm'), {
type: 'warning',
});
} catch {
return;
}
await api.post(`/admin/matches/${matchId.value}/close`);
ElMessage.success(t('msg.closed'));
await load();
emit('updated');
}
function goSettle() {
if (!matchId.value) return;
if (!canProceedSettle.value) {
ElMessage.warning(
t('outright.unsettled_fixtures_hint', { n: unsettledFixtureCount.value }),
);
return;
}
void router.push(`/settlement/${matchId.value}`);
}
async function load() {
if (!props.leagueId) return;
loading.value = true;
@@ -140,6 +236,9 @@ async function load() {
const { data } = await api.get(`/admin/leagues/${props.leagueId}/outright`);
const payload = data.data as {
id: string;
status?: string;
leagueIsPublished?: boolean;
unsettledFixtureCount?: number;
fixtureSyncAdded?: number;
fixtureSyncReopened?: number;
selections: Array<{
@@ -154,6 +253,9 @@ async function load() {
}>;
};
matchId.value = payload.id;
matchStatus.value = payload.status ?? '';
leagueIsPublished.value = payload.leagueIsPublished ?? true;
unsettledFixtureCount.value = payload.unsettledFixtureCount ?? 0;
selections.value = (payload.selections ?? [])
.filter((s) => s.status === 'OPEN')
.map((s) => ({
@@ -472,8 +574,48 @@ watch(
<template>
<div v-loading="loading" class="outright-odds-panel">
<div class="outright-odds-panel__head">
<p class="outright-odds-panel__hint">{{ t('outright.odds_only_hint') }}</p>
<div class="outright-odds-panel__head-text">
<p class="outright-odds-panel__hint">{{ t('outright.odds_only_hint') }}</p>
<p v-if="showLeagueUnpublishedHint" class="outright-odds-panel__workflow-hint">
{{ t('outright.league_unpublished_hint') }}
</p>
<p v-if="showUnsettledFixturesHint" class="outright-odds-panel__workflow-hint">
{{ t('outright.unsettled_fixtures_hint', { n: unsettledFixtureCount }) }}
</p>
</div>
<div class="outright-odds-panel__actions">
<el-tag v-if="matchStatus" size="small" :type="statusTagType" effect="dark">
{{ statusLabel }}
</el-tag>
<el-button
v-if="canCloseOutright"
type="warning"
plain
size="small"
@click="closeOutright"
>
{{ t('outright.btn.close') }}
</el-button>
<el-button
v-if="canReopenOutright"
type="primary"
plain
size="small"
:loading="reopening"
@click="reopenOutright"
>
{{ t('outright.btn.reopen') }}
</el-button>
<el-button
v-if="canSettleOutright"
type="success"
plain
size="small"
:disabled="!canProceedSettle"
@click="goSettle"
>
{{ settleButtonLabel }}
</el-button>
<el-button
v-if="selections.length"
:type="batchMode ? 'warning' : 'default'"
@@ -763,13 +905,17 @@ watch(
}
.outright-odds-panel__head {
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
flex-shrink: 0;
margin-bottom: 12px;
}
.outright-odds-panel__head-text {
flex: 1;
min-width: 200px;
}
.outright-odds-panel__actions {
display: flex;
align-items: center;
@@ -782,6 +928,12 @@ watch(
color: #777;
line-height: 1.5;
}
.outright-odds-panel__workflow-hint {
margin: 6px 0 0;
font-size: 12px;
color: #e6a23c;
line-height: 1.5;
}
.outright-odds-panel__batch {
display: flex;
align-items: center;

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { ref, watch, computed } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import { useAdminLocale } from '../../composables/useAdminLocale';
import api from '../../api';
import MatchArchiveDialog from '../../components/MatchArchiveDialog.vue';
export interface LeagueOutrightSummary {
id: string;
@@ -36,6 +37,7 @@ const emit = defineEmits<{
const { t } = useAdminLocale();
const router = useRouter();
const archiveVisible = ref(false);
const loading = ref(false);
const applying = ref(false);
@@ -52,6 +54,16 @@ function goEdit() {
router.push({ name: 'admin-outright-edit', params: { matchId: props.event.id } });
}
function goSettle() {
if (!props.event) return;
router.push(`/settlement/${props.event.id}`);
}
const canSettleOutright = computed(() => {
const s = props.event?.status;
return s === 'CLOSED' || s === 'PENDING_SETTLEMENT' || s === 'SETTLED';
});
async function loadDetail() {
if (!props.event) {
selections.value = [];
@@ -96,6 +108,15 @@ watch(
() => loadDetail(),
{ immediate: true },
);
function openArchive() {
if (!props.event) return;
archiveVisible.value = true;
}
function onOutrightArchived() {
emit('updated');
}
</script>
<template>
@@ -124,6 +145,9 @@ watch(
<el-button type="primary" size="small" @click="goEdit">
{{ t('common.edit') }}
</el-button>
<el-button v-if="canSettleOutright" size="small" @click="goSettle">
{{ t('outright.btn.settle') }}
</el-button>
<el-button
v-if="event.canImportCanonical"
size="small"
@@ -132,6 +156,9 @@ watch(
>
{{ t('outright.btn.apply_canonical') }}
</el-button>
<el-button size="small" type="danger" plain @click="openArchive">
{{ t('common.delete') }}
</el-button>
</template>
<el-button v-else type="primary" plain size="small" @click="emit('create')">
{{ t('match.outright.setup') }}
@@ -161,6 +188,13 @@ watch(
</el-table>
<p v-else-if="!loading" class="meta-empty">{{ t('outright.expand_no_teams') }}</p>
</div>
<MatchArchiveDialog
v-if="event"
v-model="archiveVisible"
:match-id="event.id"
:title="event.matchName || t('nav.outrights')"
@archived="onOutrightArchived"
/>
</section>
</template>

View File

@@ -234,6 +234,11 @@ async function saveMeta() {
<el-switch v-model="form.isHot" size="small" />
</el-form-item>
</el-col>
<el-col :xs="12" :sm="6">
<el-form-item :label="t('matchEditor.field.correct_score_enabled')">
<el-switch v-model="form.correctScoreEnabled" size="small" />
</el-form-item>
</el-col>
</el-row>
</div>
</el-form>

View File

@@ -1,14 +1,25 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [vue()],
export default defineConfig(({ mode }) => {
const analyze = process.env.ANALYZE === '1' || mode === 'analyze';
return {
plugins: [
vue(),
analyze &&
visualizer({
filename: 'dist/stats.html',
gzipSize: true,
brotliSize: true,
open: false,
}),
].filter(Boolean),
resolve: {
// 避免 src 内遗留的 .js 抢先于 .ts/.vue 被解析(曾导致 i18n 文案缺失)
extensions: ['.mjs', '.ts', '.mts', '.tsx', '.vue', '.js', '.jsx', '.json'],
alias: {
// shared 的 dist 为 CommonJSVite 无法按命名导出加载;直连源码
'@thebet365/shared': resolve(__dirname, '../../packages/shared/src/index.ts'),
},
dedupe: ['echarts', 'vue-echarts', 'vue'],
@@ -16,6 +27,32 @@ export default defineConfig({
optimizeDeps: {
include: ['echarts', 'vue-echarts'],
},
build: {
chunkSizeWarningLimit: 600,
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes('node_modules')) {
if (id.includes('/src/i18n/bundles/')) {
const m = id.match(/bundles\/(zh-CN|en-US|ms-MY)/);
if (m) return `i18n-${m[1]}`;
}
if (id.includes('/src/i18n/pages/')) return 'i18n-pages';
if (id.includes('echarts-setup') || id.includes('vue-echarts') || id.includes('node_modules/echarts')) {
return 'echarts';
}
return undefined;
}
if (id.includes('element-plus')) return 'element-plus';
if (id.includes('echarts')) return 'echarts';
if (id.includes('vue-i18n')) return 'vue-i18n';
if (id.includes('vue-router')) return 'vue-router';
if (id.includes('vue/') || id.includes('vue/dist')) return 'vue';
return undefined;
},
},
},
},
publicDir: resolve(__dirname, '../../packages/shared/public'),
server: {
port: 5174,
@@ -24,4 +61,5 @@ export default defineConfig({
'/uploads': { target: 'http://localhost:3000', changeOrigin: true },
},
},
};
});