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

@@ -10,8 +10,11 @@ JWT_PLAYER_EXPIRES=24h
JWT_ADMIN_EXPIRES=2h
JWT_AGENT_EXPIRES=8h
# 首次部署写入演示账号与默认数据,完成后改为 false
# 首次部署写入默认数据(生产环境仅 admin + WC2026 赛事;本地开发含 agent1/player1 演示账号)
# 灌完数据后改为 false 并重启 api
SEED_DATABASE=true
# 可选:覆盖 admin 初始密码(仅 seed/重置时生效)
# ADMIN_INITIAL_PASSWORD=YourStrongPasswordHere
# 对外端口(宝塔/Nginx 反代时可改回 80/443 或保留以下端口)
API_PORT=3000

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 },
},
},
};
});

View File

@@ -4,6 +4,12 @@
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": false,
"tsConfigPath": "tsconfig.build.json"
"tsConfigPath": "tsconfig.build.json",
"assets": [
{
"include": "infrastructure/database/seed-data/**/*.json",
"outDir": "dist"
}
]
}
}

View File

@@ -13,6 +13,8 @@
"db:migrate": "prisma migrate dev",
"db:migrate:deploy": "prisma migrate deploy && prisma generate",
"db:seed": "ts-node prisma/seed.ts",
"db:reset": "ts-node src/infrastructure/database/reset-and-seed-cli.ts --yes --production",
"db:reset:dev": "ts-node src/infrastructure/database/reset-and-seed-cli.ts --yes --dev",
"db:studio": "prisma studio"
},
"dependencies": {

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "matches" ADD COLUMN IF NOT EXISTS "correct_score_enabled" BOOLEAN NOT NULL DEFAULT TRUE;

View File

@@ -318,6 +318,7 @@ model Match {
venueJson Json? @map("venue_json")
kickoffJson Json? @map("kickoff_json")
externalStatus String? @map("external_status") @db.VarChar(32)
correctScoreEnabled Boolean @default(true) @map("correct_score_enabled")
createdBy BigInt? @map("created_by")
updatedBy BigInt? @map("updated_by")
createdAt DateTime @default(now()) @map("created_at")

View File

@@ -0,0 +1,152 @@
/**
* 从 zhibo 导出的 world-cup-group-stage-matches.json 生成 seed 用裁剪 JSON 与球队映射 TS。
* 用法: node apps/api/scripts/build-wc2026-seed-json.mjs <源JSON路径>
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const apiRoot = path.resolve(__dirname, '..');
const seedDataDir = path.join(apiRoot, 'src/infrastructure/database/seed-data');
const outrightTeamsPath = path.join(apiRoot, 'src/domains/catalog/wc2026-outright-teams.ts');
const sourcePath = process.argv[2];
if (!sourcePath) {
console.error('用法: node build-wc2026-seed-json.mjs <源JSON路径>');
process.exit(1);
}
const raw = JSON.parse(fs.readFileSync(path.resolve(sourcePath), 'utf8'));
const outrightSrc = fs.readFileSync(outrightTeamsPath, 'utf8');
const outrightTeams = [...outrightSrc.matchAll(/code: '([^']+)', names: \{ 'zh-CN': '([^']+)', 'en-US': '([^']+)'/g)].map((m) => ({
code: m[1],
zh: m[2],
en: m[3],
}));
function pickNames(names) {
return {
zh: names?.zh ?? null,
en: names?.en ?? null,
zhTw: names?.zhTw ?? null,
vi: names?.vi ?? null,
km: names?.km ?? null,
ms: names?.ms ?? null,
};
}
function pickTeam(team) {
return {
id: team.id,
name: team.name,
names: pickNames(team.names),
image: team.image ?? '',
};
}
function slimMatch(m) {
return {
officialMatchNo: m.officialMatchNo,
stage: m.stage,
groupName: m.groupName,
liveMatchId: m.liveMatchId,
additionMatchId: m.additionMatchId,
channelId: m.channelId,
matchName: m.matchName,
league: { type: m.league.type, en: m.league.en, zh: m.league.zh },
kickoff: {
utcTimeStart: m.kickoff.utcTimeStart,
utcTimeStop: m.kickoff.utcTimeStop,
utcIso: m.kickoff.utcIso,
chinaTime: m.kickoff.chinaTime,
venueTime: m.kickoff.venueTime,
venueTimezone: m.kickoff.venueTimezone,
},
homeTeam: pickTeam(m.homeTeam),
awayTeam: pickTeam(m.awayTeam),
status: { state: m.status.state, isHot: m.status.isHot ?? 0 },
venue: {
names: pickNames(m.venue?.names),
city: pickNames(m.venue?.city),
},
sortOrder: m.sortOrder,
isPublished: m.isPublished,
};
}
function resolveCanonicalCode(team) {
if (team.id == null) return null;
const en = (team.name || team.names?.en || '').toLowerCase();
const zh = team.names?.zh || '';
const hit = outrightTeams.find(
(o) =>
o.en.toLowerCase() === en ||
o.zh === zh ||
(o.en === 'Turkey' && en.includes('türkiye')) ||
(o.en === 'Czech' && en === 'czechia') ||
(o.en === 'Bosnia' && en.includes('bosnia')) ||
(o.en === 'Ivory Coast' && en.includes('côte')) ||
(o.en === 'DR Congo' && en.includes('congo')) ||
(o.en === 'Curacao' && en.includes('cura')),
);
return hit?.code ?? null;
}
const matches = (raw.matches || []).map(slimMatch);
const bundle = { count: matches.length, matches };
const teamById = new Map();
for (const m of raw.matches || []) {
for (const t of [m.homeTeam, m.awayTeam]) {
if (t?.id != null) teamById.set(t.id, t);
}
}
const zhiboToCode = {};
const logoByCode = {};
const unmatched = [];
for (const [id, team] of teamById) {
const code = resolveCanonicalCode(team);
if (code) {
zhiboToCode[id] = code;
if (team.image) logoByCode[code] = team.image;
} else {
unmatched.push({ id, name: team.name });
}
}
fs.mkdirSync(seedDataDir, { recursive: true });
const jsonOut = path.join(seedDataDir, 'wc2026-group-stage.json');
fs.writeFileSync(jsonOut, JSON.stringify(bundle, null, 2) + '\n', 'utf8');
const mapLines = Object.entries(zhiboToCode)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([id, code]) => ` ${id}: '${code}',`)
.join('\n');
const logoLines = Object.entries(logoByCode)
.sort(([a], [b]) => a.localeCompare(b))
.map(([code, url]) => ` ${code}: '${url.replace(/'/g, "\\'")}',`)
.join('\n');
const mapTs = `/** 由 build-wc2026-seed-json.mjs 生成 — zhibo externalId → WC2026 canonical code */
export const WC2026_ZIBO_ID_TO_CODE: Record<number, string> = {
${mapLines}
};
/** zhibo 球队 logoseed 时写入 teams.logo_url */
export const WC2026_TEAM_LOGO_BY_CODE: Record<string, string> = {
${logoLines}
};
`;
const mapOut = path.join(seedDataDir, 'wc2026-zhibo-team-map.ts');
fs.writeFileSync(mapOut, mapTs, 'utf8');
console.log(`Wrote ${jsonOut} (${matches.length} matches)`);
console.log(`Wrote ${mapOut} (${Object.keys(zhiboToCode).length} team mappings)`);
if (unmatched.length) {
console.warn('Unmatched teams:', unmatched);
process.exit(1);
}

View File

@@ -27,6 +27,7 @@ import { UsersService } from '../../domains/identity/users.service';
import { AgentsService } from '../../domains/agent/agents.service';
import { WalletService } from '../../domains/ledger/wallet.service';
import { MatchesService } from '../../domains/catalog/matches.service';
import { CatalogArchiveService } from '../../domains/catalog/catalog-archive.service';
import { OutrightService } from '../../domains/catalog/outright.service';
import { MarketsService } from '../../domains/odds/markets.service';
import { SettlementService } from '../../domains/settlement/settlement.service';
@@ -501,6 +502,10 @@ class CreatePlatformMatchDto {
@IsOptional()
@IsString()
awayTeamLogoUrl?: string;
@IsOptional()
@IsBoolean()
correctScoreEnabled?: boolean;
}
class UpdatePlatformMatchDto {
@@ -554,6 +559,10 @@ class UpdatePlatformMatchDto {
@IsOptional()
@IsString()
awayTeamLogoUrl?: string;
@IsOptional()
@IsBoolean()
correctScoreEnabled?: boolean;
}
class ReopenMatchDto {
@@ -562,6 +571,16 @@ class ReopenMatchDto {
startTime?: string;
}
class ArchiveMatchDto {
@IsOptional()
@IsBoolean()
force?: boolean;
@IsOptional()
@IsBoolean()
refundPendingBets?: boolean;
}
class BatchMatchOddsDto {
@IsArray()
updates!: OutrightOddsUpdateItemDto[];
@@ -924,6 +943,7 @@ export class AdminController {
private agents: AgentsService,
private wallet: WalletService,
private matches: MatchesService,
private catalogArchive: CatalogArchiveService,
private outright: OutrightService,
private markets: MarketsService,
private settlement: SettlementService,
@@ -948,6 +968,31 @@ export class AdminController {
return jsonResponse(overview);
}
@Get('users/page-init')
@RequirePermissions(P.agentsView)
async getUsersPageInit() {
const [
playerSettings,
bettingLimits,
hierarchySettings,
platformDirect,
agentLevelCounts,
] = await Promise.all([
this.systemConfig.getPlayerAccountSettings(),
this.bettingLimits.getLimits(),
this.systemConfig.getAgentHierarchySettings(),
this.systemConfig.getPlatformDirectCashbackSettings(),
this.agents.countAgentsByLevel(),
]);
return jsonResponse({
playerSettings,
bettingLimits,
hierarchySettings,
platformDirect,
agentLevelCounts,
});
}
@Get('users/settings/account')
@RequirePermissions(P.settings)
async getPlayerAccountSettings() {
@@ -1126,6 +1171,23 @@ export class AdminController {
return jsonResponse(detail);
}
@Delete('users/:id')
@RequirePermissions(P.usersCreate)
async deletePlayer(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
) {
await this.users.softDeletePlayer(BigInt(id));
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'DELETE_PLAYER',
module: 'USERS',
targetId: id,
});
return jsonResponse({ deleted: true });
}
@Post('users')
@RequirePermissions(P.usersCreate)
async createPlayer(
@@ -1184,9 +1246,20 @@ export class AdminController {
@Get('agents/options')
@RequirePermissions(P.agentsView)
async listAgentOptions() {
async listAgentOptions(
@Query('keyword') keyword?: string,
@Query('limit') limit?: string,
) {
const take = Math.min(100, Math.max(1, parseInt(limit ?? '50', 10) || 50));
const kw = keyword?.trim();
const agents = await this.prisma.user.findMany({
where: { userType: 'AGENT', deletedAt: null },
where: {
userType: 'AGENT',
deletedAt: null,
...(kw
? { username: { contains: kw, mode: 'insensitive' as const } }
: {}),
},
select: {
id: true,
username: true,
@@ -1194,6 +1267,7 @@ export class AdminController {
parent: { select: { username: true } },
},
orderBy: [{ agentLevel: 'asc' }, { username: 'asc' }],
take,
});
return jsonResponse(
agents.map((a) => ({
@@ -1464,6 +1538,20 @@ export class AdminController {
return jsonResponse(league);
}
@Get('leagues/:leagueId/archive-preview')
@RequirePermissions(P.matches)
async getLeagueArchivePreview(@Param('leagueId') leagueId: string) {
const preview = await this.catalogArchive.getLeagueArchivePreview(BigInt(leagueId));
return jsonResponse(preview);
}
@Post('leagues/:leagueId/archive')
@RequirePermissions(P.matches)
async archiveLeague(@Param('leagueId') leagueId: string) {
const result = await this.catalogArchive.archiveLeague(BigInt(leagueId));
return jsonResponse(result);
}
@Get('leagues')
@RequirePermissions(P.matches, P.reports)
async listLeagues(
@@ -1499,13 +1587,17 @@ export class AdminController {
@Query('status') status?: string,
@Query('keyword') keyword?: string,
@Query('locale') locale?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const items = await this.matches.listAdminLeagueMatches(BigInt(leagueId), {
const result = await this.matches.listAdminLeagueMatches(BigInt(leagueId), {
status: status || undefined,
keyword: keyword || undefined,
locale: locale || undefined,
page: page ? Math.max(1, parseInt(page, 10) || 1) : 1,
pageSize: pageSize ? Math.min(100, Math.max(1, parseInt(pageSize, 10) || 20)) : 20,
});
return jsonResponse({ items });
return jsonResponse(result);
}
@Post('teams')
@@ -1581,6 +1673,7 @@ export class AdminController {
groupName: dto.groupName,
homeTeamLogoUrl: dto.homeTeamLogoUrl,
awayTeamLogoUrl: dto.awayTeamLogoUrl,
correctScoreEnabled: dto.correctScoreEnabled,
updatedBy: operatorId,
});
await this.outright.syncOutrightTeamsForLeagueIfExists(match.leagueId);
@@ -1594,6 +1687,28 @@ export class AdminController {
return jsonResponse({ deleted: true });
}
@Get('matches/:id/archive-preview')
@RequirePermissions(P.matches)
async getMatchArchivePreview(@Param('id') id: string) {
const preview = await this.catalogArchive.getMatchArchivePreview(BigInt(id));
return jsonResponse(preview);
}
@Post('matches/:id/archive')
@RequirePermissions(P.matches)
async archiveMatch(@Param('id') id: string, @Body() dto: ArchiveMatchDto) {
const matchId = BigInt(id);
const result = await this.catalogArchive.archiveMatch(matchId, {
force: dto.force === true,
});
let voidedCount = 0;
if (dto.refundPendingBets) {
const voided = await this.settlement.voidMatchBets(matchId);
voidedCount = voided.voidedCount;
}
return jsonResponse({ ...result, voidedCount });
}
@Post('matches')
@RequirePermissions(P.matches)
async createMatch(@CurrentUser('id') operatorId: bigint, @Body() dto: CreatePlatformMatchDto) {
@@ -1612,6 +1727,7 @@ export class AdminController {
awayTeamMs: dto.awayTeamMs,
startTime: new Date(dto.startTime),
isHot: dto.isHot,
correctScoreEnabled: dto.correctScoreEnabled,
displayOrder: dto.displayOrder,
matchName: dto.matchName,
stage: dto.stage,
@@ -1642,6 +1758,13 @@ export class AdminController {
return jsonResponse(match);
}
@Post('matches/:id/unpublish')
@RequirePermissions(P.matches)
async unpublishMatch(@Param('id') id: string) {
const match = await this.matches.unpublishMatch(BigInt(id));
return jsonResponse(match);
}
@Post('matches/:id/close')
@RequirePermissions(P.matches)
async closeMatch(@Param('id') id: string) {
@@ -1880,6 +2003,27 @@ export class AdminController {
return jsonResponse(data);
}
@Get('matches/:id/settlement/summary')
@RequirePermissions(P.settlement, P.reports)
async getMatchSettlementSummary(@Param('id') id: string) {
const data = await this.settlement.getMatchBetStatsSummary(BigInt(id));
return jsonResponse(data);
}
@Get('matches/:id/settlement/bets')
@RequirePermissions(P.settlement, P.reports)
async getMatchSettlementBets(
@Param('id') id: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const data = await this.settlement.getMatchBetStatsBets(BigInt(id), {
page: page ? Math.max(1, parseInt(page, 10) || 1) : 1,
pageSize: pageSize ? Math.min(100, Math.max(1, parseInt(pageSize, 10) || 10)) : 10,
});
return jsonResponse(data);
}
@Get('matches/:id/settlement/stats')
@RequirePermissions(P.settlement, P.reports)
async getMatchSettlementStats(
@@ -2495,4 +2639,30 @@ export class AdminController {
);
return jsonResponse(result);
}
@Post('deposit-orders/:id/reopen')
@RequirePermissions(P.depositReview)
async reopenDepositOrder(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
) {
const result = await this.depositService.reopenDepositOrderForReview(
BigInt(id),
operatorId,
);
return jsonResponse(result);
}
@Delete('deposit-orders/:id')
@RequirePermissions(P.depositReview)
async deleteDepositOrder(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
) {
const result = await this.depositService.deleteDepositOrder(
BigInt(id),
operatorId,
);
return jsonResponse(result);
}
}

View File

@@ -3,6 +3,7 @@ import {
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
@@ -176,9 +177,18 @@ export class AgentPortalController {
}
@Get('players')
async listPlayers(@CurrentUser('id') agentId: bigint) {
const players = await this.agents.getDirectPlayers(agentId);
return jsonResponse(players);
async listPlayers(
@CurrentUser('id') agentId: bigint,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const result = await this.agents.getDirectPlayers(agentId, {
page: page ? Math.max(1, parseInt(page, 10) || 1) : 1,
pageSize: pageSize
? Math.min(100, Math.max(1, parseInt(pageSize, 10) || 20))
: 100,
});
return jsonResponse(result.items);
}
@Get('players/scoped')
@@ -261,6 +271,15 @@ export class AgentPortalController {
return jsonResponse(detail);
}
@Delete('players/:id')
async deletePlayer(
@CurrentUser('id') agentId: bigint,
@Param('id') playerId: string,
) {
await this.agents.deleteDirectPlayer(agentId, BigInt(playerId));
return jsonResponse({ deleted: true });
}
@Get('agents')
async listSubAgents(@CurrentUser('id') agentId: bigint, @CurrentUser('agentLevel') level: number) {
const maxLevel = await this.agents.getMaxAgentLevel();
@@ -401,13 +420,23 @@ export class AgentPortalController {
@CurrentUser('id') agentId: bigint,
@CurrentUser('agentLevel') level: number,
@Param('id') subAgentId: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
if (!(await this.canManageSubAgents(level))) {
return jsonResponse([]);
return jsonResponse({ items: [], total: 0, page: 1, pageSize: 20 });
}
await this.agents.assertDescendantAgent(agentId, BigInt(subAgentId));
const players = await this.agents.getPortalAgentDirectPlayers(agentId, BigInt(subAgentId));
return jsonResponse(players);
const result = await this.agents.getPortalAgentDirectPlayers(
agentId,
BigInt(subAgentId),
{
page: page ? Math.max(1, parseInt(page, 10) || 1) : 1,
pageSize: pageSize
? Math.min(100, Math.max(1, parseInt(pageSize, 10) || 20))
: 20,
},
);
return jsonResponse(result);
}
@Post('agents/:id/credit')

View File

@@ -806,6 +806,34 @@ export class AgentsService {
return this.getDirectPlayerDetail(agentId, playerId);
}
async deleteDirectPlayer(agentId: bigint, playerId: bigint) {
await this.requireDirectPlayer(agentId, playerId);
const betCount = await this.prisma.bet.count({
where: {
userId: playerId,
status: 'PENDING',
},
});
if (betCount > 0) {
throw appBadRequest('PLAYER_HAS_PENDING_BETS');
}
const wallet = await this.prisma.wallet.findUnique({ where: { userId: playerId } });
if (wallet) {
const available = new Decimal(wallet.availableBalance);
const frozen = new Decimal(wallet.frozenBalance);
if (available.gt(0) || frozen.gt(0)) {
throw appBadRequest('PLAYER_HAS_BALANCE');
}
}
return this.prisma.user.update({
where: { id: playerId },
data: { deletedAt: new Date(), status: 'SUSPENDED' },
});
}
async listAgentsAdmin(params?: {
page?: number;
pageSize?: number;
@@ -1696,9 +1724,18 @@ export class AgentsService {
return user;
}
async getPortalAgentDirectPlayers(rootAgentId: bigint, targetAgentId: bigint) {
async getPortalAgentDirectPlayers(
rootAgentId: bigint,
targetAgentId: bigint,
opts?: { page?: number; pageSize?: number },
) {
await this.assertDescendantAgent(rootAgentId, targetAgentId);
const players = await this.getDirectPlayers(targetAgentId);
const page = Math.max(1, opts?.page ?? 1);
const pageSize = Math.min(100, Math.max(1, opts?.pageSize ?? 20));
const { items: players, total } = await this.getDirectPlayers(targetAgentId, {
page,
pageSize,
});
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: targetAgentId },
select: {
@@ -1714,7 +1751,7 @@ export class AgentsService {
players.map((p) => ({ id: BigInt(p.id), parentId: targetAgentId })),
parentCashbackMap,
);
return players.map((p) => ({
const mapped = players.map((p) => ({
...p,
parentAgentId: targetKey,
parentAgentUsername,
@@ -1722,18 +1759,30 @@ export class AgentsService {
inChain: true,
isDirect: targetKey === rootKey,
}));
return { items: mapped, total, page, pageSize };
}
async getDirectPlayers(agentId: bigint) {
const rows = await this.prisma.user.findMany({
where: { parentId: agentId, userType: 'PLAYER', deletedAt: null },
include: {
wallet: true,
usedInvite: { select: { code: true } },
},
orderBy: { createdAt: 'desc' },
});
return rows.map((u) => ({
async getDirectPlayers(
agentId: bigint,
opts?: { page?: number; pageSize?: number },
) {
const where = { parentId: agentId, userType: 'PLAYER' as const, deletedAt: null };
const page = Math.max(1, opts?.page ?? 1);
const pageSize = Math.min(100, Math.max(1, opts?.pageSize ?? 20));
const [total, rows] = await Promise.all([
this.prisma.user.count({ where }),
this.prisma.user.findMany({
where,
include: {
wallet: true,
usedInvite: { select: { code: true } },
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
]);
const items = rows.map((u) => ({
id: u.id.toString(),
username: u.username,
status: u.status,
@@ -1746,6 +1795,7 @@ export class AgentsService {
}
: undefined,
}));
return { items, total, page, pageSize };
}
async getChildAgents(agentId: bigint) {

View File

@@ -68,6 +68,14 @@ export class BetsService {
if (!selection.market.match.isOutright && !isPreMatchKickoff(selection.market.match.startTime)) {
throw appBadRequest('PRE_MATCH_ONLY');
}
// Block correct-score bets when the match has the CS toggle turned off
const CS_MARKET_TYPES = ['FT_CORRECT_SCORE', 'HT_CORRECT_SCORE', 'SH_CORRECT_SCORE'];
if (
CS_MARKET_TYPES.includes(selection.market.marketType) &&
!(selection.market.match.correctScoreEnabled ?? true)
) {
throw appBadRequest('CORRECT_SCORE_DISABLED');
}
if (selection.oddsVersion !== oddsVersion) {
throw appBadRequest('ODDS_CHANGED');
}

View File

@@ -0,0 +1,174 @@
import { Decimal } from '@prisma/client/runtime/library';
import { CatalogArchiveService } from './catalog-archive.service';
describe('CatalogArchiveService', () => {
const matchId = BigInt(10);
const leagueId = BigInt(1);
let prisma: {
match: { findFirst: jest.Mock; update: jest.Mock; findMany: jest.Mock; updateMany: jest.Mock };
league: { findFirst: jest.Mock; update: jest.Mock };
bet: { findMany: jest.Mock };
settlementBatch: { findFirst: jest.Mock; findMany: jest.Mock };
market: { updateMany: jest.Mock };
marketSelection: { updateMany: jest.Mock };
entityTranslation: { findFirst: jest.Mock };
$transaction: jest.Mock;
};
let matches: { betStatsForMatches: jest.Mock };
let service: CatalogArchiveService;
beforeEach(() => {
prisma = {
match: {
findFirst: jest.fn(),
update: jest.fn(),
findMany: jest.fn(),
updateMany: jest.fn(),
},
league: { findFirst: jest.fn(), update: jest.fn() },
bet: { findMany: jest.fn().mockResolvedValue([]) },
settlementBatch: { findFirst: jest.fn().mockResolvedValue(null), findMany: jest.fn().mockResolvedValue([]) },
market: { updateMany: jest.fn() },
marketSelection: { updateMany: jest.fn() },
entityTranslation: { findFirst: jest.fn().mockResolvedValue(null) },
$transaction: jest.fn(async (fn: (tx: typeof prisma) => Promise<void>) => fn(prisma)),
};
matches = { betStatsForMatches: jest.fn().mockResolvedValue(new Map()) };
service = new CatalogArchiveService(prisma as never, matches as never);
});
const baseMatch = {
id: matchId,
status: 'PUBLISHED',
isOutright: false,
matchName: null,
homeTeamId: BigInt(2),
awayTeamId: BigInt(3),
homeTeam: { code: 'A' },
awayTeam: { code: 'B' },
league: { id: leagueId },
};
it('preview flags pending bets and unsettled match', async () => {
prisma.match.findFirst.mockResolvedValue(baseMatch);
prisma.bet.findMany.mockResolvedValue([{ stake: new Decimal(50) }, { stake: new Decimal(25) }]);
const preview = await service.getMatchArchivePreview(matchId);
expect(preview.pendingBetCount).toBe(2);
expect(preview.pendingStake).toBe('75');
expect(preview.requiresForce).toBe(true);
expect(preview.warnings).toEqual(expect.arrayContaining(['PENDING_BETS', 'UNSETTLED_MATCH']));
});
it('archive without force throws ARCHIVE_BLOCKED when warnings exist', async () => {
prisma.match.findFirst.mockResolvedValue(baseMatch);
prisma.bet.findMany.mockResolvedValue([{ stake: new Decimal(10) }]);
await expect(service.archiveMatch(matchId, { force: false })).rejects.toMatchObject({
response: expect.objectContaining({ code: 'ARCHIVE_BLOCKED' }),
});
});
it('archive with force soft-deletes and cancels match', async () => {
prisma.match.findFirst.mockResolvedValue({ ...baseMatch, status: 'DRAFT' });
prisma.match.update.mockResolvedValue({});
const result = await service.archiveMatch(matchId, { force: true });
expect(result.matchId).toBe(matchId.toString());
expect(prisma.marketSelection.updateMany).toHaveBeenCalled();
expect(prisma.market.updateMany).toHaveBeenCalled();
expect(prisma.match.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: matchId },
data: expect.objectContaining({ status: 'CANCELLED', deletedAt: expect.any(Date) }),
}),
);
});
it('league preview blocks when child match is not terminal', async () => {
prisma.league.findFirst.mockResolvedValue({ id: leagueId });
prisma.match.findMany.mockResolvedValue([
{
id: matchId,
status: 'CLOSED',
isOutright: false,
matchName: null,
homeTeamId: BigInt(2),
awayTeamId: BigInt(3),
homeTeam: { code: 'A' },
awayTeam: { code: 'B' },
},
]);
matches.betStatsForMatches.mockResolvedValue(
new Map([[matchId.toString(), { betCount: 0, totalStake: '0', pendingCount: 0 }]]),
);
const preview = await service.getLeagueArchivePreview(leagueId);
expect(preview.canArchive).toBe(false);
expect(preview.blockingMatches).toHaveLength(1);
expect(preview.blockingMatches[0].status).toBe('CLOSED');
});
it('league archive cascades when all children are settled', async () => {
prisma.league.findFirst.mockResolvedValue({ id: leagueId });
prisma.match.findMany
.mockResolvedValueOnce([
{
id: matchId,
status: 'SETTLED',
isOutright: false,
matchName: null,
homeTeamId: BigInt(2),
awayTeamId: BigInt(3),
homeTeam: { code: 'A' },
awayTeam: { code: 'B' },
},
])
.mockResolvedValueOnce([{ id: matchId, status: 'SETTLED' }]);
matches.betStatsForMatches.mockResolvedValue(
new Map([[matchId.toString(), { betCount: 1, totalStake: '100', pendingCount: 0 }]]),
);
const result = await service.archiveLeague(leagueId);
expect(result.leagueId).toBe(leagueId.toString());
expect(prisma.match.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: { in: [matchId] } },
data: { deletedAt: expect.any(Date) },
}),
);
expect(prisma.league.update).toHaveBeenCalledWith(
expect.objectContaining({
data: { deletedAt: expect.any(Date), isActive: false },
}),
);
});
it('league archive throws LEAGUE_ARCHIVE_NOT_READY when blocked', async () => {
prisma.league.findFirst.mockResolvedValue({ id: leagueId });
prisma.match.findMany.mockResolvedValue([
{
id: matchId,
status: 'PUBLISHED',
isOutright: false,
matchName: null,
homeTeamId: BigInt(2),
awayTeamId: BigInt(3),
homeTeam: { code: 'A' },
awayTeam: { code: 'B' },
},
]);
matches.betStatsForMatches.mockResolvedValue(
new Map([[matchId.toString(), { betCount: 0, totalStake: '0', pendingCount: 0 }]]),
);
await expect(service.archiveLeague(leagueId)).rejects.toMatchObject({
response: expect.objectContaining({ code: 'LEAGUE_ARCHIVE_NOT_READY' }),
});
});
});

View File

@@ -0,0 +1,302 @@
import { Injectable } from '@nestjs/common';
import { Decimal } from '@prisma/client/runtime/library';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { appBadRequest, appConflict, appNotFound } from '../../shared/common/app-error';
import { MatchesService } from './matches.service';
const TERMINAL_MATCH_STATUSES = new Set(['SETTLED', 'CANCELLED', 'VOID']);
export type MatchArchiveWarning = 'PENDING_BETS' | 'UNSETTLED_MATCH' | 'PREVIEW_BATCH';
export type MatchArchivePreview = {
matchId: string;
matchStatus: string;
isOutright: boolean;
title: string;
pendingBetCount: number;
pendingStake: string;
hasPreviewSettlementBatch: boolean;
requiresForce: boolean;
warnings: MatchArchiveWarning[];
};
export type LeagueBlockingMatch = {
id: string;
status: string;
isOutright: boolean;
title: string;
pendingCount: number;
};
export type LeagueArchivePreview = {
leagueId: string;
canArchive: boolean;
blockingMatches: LeagueBlockingMatch[];
totalPendingBets: number;
};
@Injectable()
export class CatalogArchiveService {
constructor(
private prisma: PrismaService,
private matches: MatchesService,
) {}
async getMatchArchivePreview(matchId: bigint): Promise<MatchArchivePreview> {
const match = await this.requireActiveMatch(matchId);
const [pending, hasPreviewBatch] = await Promise.all([
this.pendingBetSummary(matchId),
this.hasPreviewBatch(matchId),
]);
const warnings = this.buildMatchWarnings(match.status, pending.pendingBetCount, hasPreviewBatch);
const requiresForce = warnings.length > 0;
const title = await this.matchTitle(match);
return {
matchId: match.id.toString(),
matchStatus: match.status,
isOutright: match.isOutright,
title,
pendingBetCount: pending.pendingBetCount,
pendingStake: pending.pendingStake,
hasPreviewSettlementBatch: hasPreviewBatch,
requiresForce,
warnings,
};
}
async archiveMatch(matchId: bigint, opts: { force: boolean }) {
const match = await this.requireActiveMatch(matchId);
if (match.status === 'DRAFT') {
throw appBadRequest('MATCH_DELETE_DRAFT_ONLY');
}
if (match.status === 'SETTLED') {
throw appBadRequest('ARCHIVE_BLOCKED');
}
const preview = await this.getMatchArchivePreview(matchId);
if (preview.requiresForce && !opts.force) {
throw appConflict('ARCHIVE_BLOCKED', preview);
}
const now = new Date();
await this.prisma.$transaction(async (tx) => {
await tx.marketSelection.updateMany({
where: { market: { matchId } },
data: { status: 'CLOSED' },
});
await tx.market.updateMany({
where: { matchId },
data: { status: 'CLOSED' },
});
await tx.match.update({
where: { id: matchId },
data: {
deletedAt: now,
status:
match.status === 'CANCELLED' || match.status === 'VOID' ? match.status : 'CANCELLED',
},
});
});
return { matchId: matchId.toString(), archivedAt: now.toISOString() };
}
async getLeagueArchivePreview(leagueId: bigint): Promise<LeagueArchivePreview> {
const league = await this.prisma.league.findFirst({
where: { id: leagueId, deletedAt: null },
});
if (!league) throw appNotFound('LEAGUE_NOT_FOUND');
const matches = await this.prisma.match.findMany({
where: { leagueId, deletedAt: null },
include: { homeTeam: true, awayTeam: true },
orderBy: [{ isOutright: 'desc' }, { id: 'asc' }],
});
const matchIds = matches.map((m) => m.id);
const stats = await this.matches.betStatsForMatches(matchIds);
const previewBatches = matchIds.length
? await this.prisma.settlementBatch.findMany({
where: { matchId: { in: matchIds }, status: 'PREVIEW' },
select: { matchId: true },
})
: [];
const previewBatchMatchIds = new Set(previewBatches.map((b) => b.matchId?.toString()));
const blockingMatches: LeagueBlockingMatch[] = [];
let totalPendingBets = 0;
for (const match of matches) {
const mid = match.id.toString();
const stat = stats.get(mid) ?? { betCount: 0, totalStake: '0', pendingCount: 0 };
totalPendingBets += stat.pendingCount;
const hasPreview = previewBatchMatchIds.has(mid);
const blocks = this.isLeagueMatchBlocking(match.status, stat.betCount, stat.pendingCount, hasPreview);
if (blocks) {
blockingMatches.push({
id: mid,
status: match.status,
isOutright: match.isOutright,
title: await this.matchTitle(match),
pendingCount: stat.pendingCount,
});
}
}
if (totalPendingBets > 0 && !blockingMatches.length) {
// Pending bets exist but each match might be terminal — still block league archive
for (const match of matches) {
const mid = match.id.toString();
const stat = stats.get(mid)!;
if (stat.pendingCount > 0) {
blockingMatches.push({
id: mid,
status: match.status,
isOutright: match.isOutright,
title: await this.matchTitle(match),
pendingCount: stat.pendingCount,
});
}
}
}
const canArchive = blockingMatches.length === 0 && totalPendingBets === 0;
return {
leagueId: leagueId.toString(),
canArchive,
blockingMatches,
totalPendingBets,
};
}
async archiveLeague(leagueId: bigint) {
const league = await this.prisma.league.findFirst({
where: { id: leagueId, deletedAt: null },
});
if (!league) throw appNotFound('LEAGUE_NOT_FOUND');
const preview = await this.getLeagueArchivePreview(leagueId);
if (!preview.canArchive) {
throw appConflict('LEAGUE_ARCHIVE_NOT_READY', preview);
}
const now = new Date();
await this.prisma.$transaction(async (tx) => {
const matches = await tx.match.findMany({
where: { leagueId, deletedAt: null },
select: { id: true, status: true },
});
const matchIds = matches.map((m) => m.id);
if (matchIds.length) {
await tx.marketSelection.updateMany({
where: { market: { matchId: { in: matchIds } } },
data: { status: 'CLOSED' },
});
await tx.market.updateMany({
where: { matchId: { in: matchIds } },
data: { status: 'CLOSED' },
});
await tx.match.updateMany({
where: { id: { in: matchIds } },
data: { deletedAt: now },
});
}
await tx.league.update({
where: { id: leagueId },
data: { deletedAt: now, isActive: false },
});
});
return { leagueId: leagueId.toString(), archivedAt: now.toISOString() };
}
private async requireActiveMatch(matchId: bigint) {
const match = await this.prisma.match.findFirst({
where: { id: matchId, deletedAt: null },
include: { homeTeam: true, awayTeam: true, league: true },
});
if (!match) throw appNotFound('MATCH_NOT_FOUND');
return match;
}
private async pendingBetSummary(matchId: bigint) {
const bets = await this.prisma.bet.findMany({
where: { status: 'PENDING', selections: { some: { matchId } } },
select: { stake: true },
});
let pendingStake = new Decimal(0);
for (const bet of bets) {
pendingStake = pendingStake.add(bet.stake);
}
return {
pendingBetCount: bets.length,
pendingStake: pendingStake.toString(),
};
}
private async hasPreviewBatch(matchId: bigint) {
const batch = await this.prisma.settlementBatch.findFirst({
where: { matchId, status: 'PREVIEW' },
select: { id: true },
});
return batch != null;
}
private buildMatchWarnings(
status: string,
pendingBetCount: number,
hasPreviewBatch: boolean,
): MatchArchiveWarning[] {
const warnings: MatchArchiveWarning[] = [];
if (pendingBetCount > 0) warnings.push('PENDING_BETS');
if (!TERMINAL_MATCH_STATUSES.has(status) && status !== 'DRAFT') {
warnings.push('UNSETTLED_MATCH');
}
if (hasPreviewBatch) warnings.push('PREVIEW_BATCH');
return warnings;
}
private isLeagueMatchBlocking(
status: string,
betCount: number,
pendingCount: number,
hasPreviewBatch: boolean,
): boolean {
if (pendingCount > 0) return true;
if (hasPreviewBatch) return true;
if (TERMINAL_MATCH_STATUSES.has(status)) return false;
if (status === 'DRAFT' && betCount === 0) return false;
return true;
}
private async matchTitle(match: {
id: bigint;
isOutright: boolean;
matchName: string | null;
homeTeamId: bigint;
awayTeamId: bigint;
homeTeam?: { code: string } | null;
awayTeam?: { code: string } | null;
}) {
if (match.isOutright) {
const name = match.matchName?.trim();
if (name) return name;
return `Outright #${match.id}`;
}
const [home, away] = await Promise.all([
this.getTranslationExact('TEAM', match.homeTeamId, 'zh-CN'),
this.getTranslationExact('TEAM', match.awayTeamId, 'zh-CN'),
]);
if (home && away) return `${home} vs ${away}`;
return `${match.homeTeam?.code ?? '?'} vs ${match.awayTeam?.code ?? '?'}`;
}
private async getTranslationExact(entityType: string, entityId: bigint, locale: string) {
const row = await this.prisma.entityTranslation.findFirst({
where: { entityType, entityId, locale, fieldName: 'name' },
});
return row?.value ?? '';
}
}

View File

@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { MarketsModule } from '../odds/markets.module';
import { CatalogArchiveService } from './catalog-archive.service';
import { MatchesService } from './matches.service';
import { OutrightService } from './outright.service';
@Module({
imports: [MarketsModule],
providers: [MatchesService, OutrightService],
exports: [MatchesService, OutrightService],
providers: [MatchesService, OutrightService, CatalogArchiveService],
exports: [MatchesService, OutrightService, CatalogArchiveService],
})
export class MatchesModule {}

View File

@@ -0,0 +1,115 @@
import { MatchesService } from './matches.service';
describe('MatchesService publish/unpublish', () => {
const leagueId = BigInt(1);
const matchId = BigInt(10);
let prisma: {
league: { findFirst: jest.Mock; findUniqueOrThrow: jest.Mock; update: jest.Mock };
match: { findFirst: jest.Mock; update: jest.Mock };
entityTranslation: { findFirst: jest.Mock; upsert: jest.Mock };
settlementBatch: { deleteMany: jest.Mock };
};
let outright: { syncWithLeaguePublished: jest.Mock };
let service: MatchesService;
beforeEach(() => {
prisma = {
league: {
findFirst: jest.fn(),
findUniqueOrThrow: jest.fn(),
update: jest.fn(),
},
match: {
findFirst: jest.fn(),
update: jest.fn(),
},
entityTranslation: { findFirst: jest.fn().mockResolvedValue(null), upsert: jest.fn().mockResolvedValue({}) },
settlementBatch: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }) },
};
outright = { syncWithLeaguePublished: jest.fn().mockResolvedValue(undefined) };
service = new MatchesService(prisma as never, outright as never);
});
describe('updatePlatformLeague unpublish', () => {
const baseLeague = { id: leagueId, code: 'EPL', isActive: true, logoUrl: null, displayOrder: 0 };
beforeEach(() => {
prisma.league.findFirst.mockResolvedValue(baseLeague);
prisma.league.update.mockResolvedValue({});
prisma.league.findUniqueOrThrow.mockResolvedValue({ ...baseLeague, isActive: false });
});
it('rejects unpublish when outright is settled', async () => {
prisma.match.findFirst.mockResolvedValue({ status: 'SETTLED' });
await expect(
service.updatePlatformLeague(leagueId, {
leagueEn: 'EPL',
leagueZh: '英超',
isActive: false,
}),
).rejects.toMatchObject({
response: expect.objectContaining({ code: 'LEAGUE_UNPUBLISH_SETTLED' }),
});
expect(prisma.league.update).not.toHaveBeenCalled();
});
it('allows unpublish when outright is not settled', async () => {
prisma.match.findFirst.mockResolvedValue({ status: 'PUBLISHED' });
const result = await service.updatePlatformLeague(leagueId, {
leagueEn: 'EPL',
leagueZh: '英超',
isActive: false,
});
expect(prisma.league.update).toHaveBeenCalledWith({
where: { id: leagueId },
data: { isActive: false },
});
expect(result.isPublished).toBe(false);
});
});
describe('unpublishMatch', () => {
const baseMatch = {
id: matchId,
status: 'PUBLISHED',
isOutright: false,
deletedAt: null,
};
beforeEach(() => {
prisma.match.findFirst.mockResolvedValue(baseMatch);
prisma.match.update.mockResolvedValue({ ...baseMatch, status: 'DRAFT' });
});
it('unpublishes published fixture to draft', async () => {
await service.unpublishMatch(matchId);
expect(prisma.match.update).toHaveBeenCalledWith({
where: { id: matchId },
data: { status: 'DRAFT', closeTime: null },
});
});
it('rejects unpublish when settled', async () => {
prisma.match.findFirst.mockResolvedValue({ ...baseMatch, status: 'SETTLED' });
await expect(service.unpublishMatch(matchId)).rejects.toMatchObject({
response: expect.objectContaining({ code: 'MATCH_UNPUBLISH_FORBIDDEN' }),
});
});
it('clears preview settlement batch when pending settlement', async () => {
prisma.match.findFirst.mockResolvedValue({ ...baseMatch, status: 'PENDING_SETTLEMENT' });
await service.unpublishMatch(matchId);
expect(prisma.settlementBatch.deleteMany).toHaveBeenCalledWith({
where: { matchId, status: 'PREVIEW' },
});
});
});
});

View File

@@ -23,6 +23,7 @@ import {
translationsFromZhiboNames,
} from './zhibo-match.mapper';
import { syncWc2026OutrightMarket } from './wc2026-outright.sync';
import { OutrightService } from './outright.service';
const OUTRIGHT_PLACEHOLDER_CODE = 'OUT';
@@ -45,7 +46,10 @@ export type ListPublishedOptions = {
@Injectable()
export class MatchesService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
private outright: OutrightService,
) {}
async createLeague(code: string, translations: Record<string, string>) {
const league = await this.prisma.league.create({ data: { code } });
@@ -85,6 +89,7 @@ export class MatchesService {
awayTeamId: bigint;
startTime: Date;
isHot?: boolean;
correctScoreEnabled?: boolean;
displayOrder?: number;
createdBy?: bigint;
status?: string;
@@ -110,6 +115,7 @@ export class MatchesService {
awayTeamId: data.awayTeamId,
startTime: data.startTime,
isHot: data.isHot ?? false,
correctScoreEnabled: data.correctScoreEnabled ?? true,
displayOrder: data.displayOrder ?? 0,
createdBy: data.createdBy,
status,
@@ -307,7 +313,13 @@ export class MatchesService {
if (data.displayOrder != null) updates.displayOrder = data.displayOrder;
if (data.isActive !== undefined) {
if (league.isActive && data.isActive === false) {
throw appBadRequest('LEAGUE_UNPUBLISH_FORBIDDEN');
const outright = await this.prisma.match.findFirst({
where: { leagueId, isOutright: true, deletedAt: null },
select: { status: true },
});
if (outright?.status === 'SETTLED') {
throw appBadRequest('LEAGUE_UNPUBLISH_SETTLED');
}
}
updates.isActive = data.isActive;
}
@@ -315,6 +327,10 @@ export class MatchesService {
await this.prisma.league.update({ where: { id: leagueId }, data: updates });
}
if (data.isActive === true) {
await this.outright.syncWithLeaguePublished(leagueId);
}
const [en, zh, ms] = await Promise.all([
this.getTranslationExact('LEAGUE', leagueId, 'en-US'),
this.getTranslationExact('LEAGUE', leagueId, 'zh-CN'),
@@ -537,7 +553,13 @@ export class MatchesService {
async listAdminLeagueMatches(
leagueId: bigint,
opts: { status?: string; keyword?: string; locale?: string },
opts: {
status?: string;
keyword?: string;
locale?: string;
page?: number;
pageSize?: number;
},
) {
const where: Prisma.MatchWhereInput = {
leagueId,
@@ -553,15 +575,22 @@ export class MatchesService {
{ awayTeam: { code: { contains: kw, mode: 'insensitive' } } },
];
}
const items = await this.prisma.match.findMany({
where,
include: { homeTeam: true, awayTeam: true },
orderBy: [{ displayOrder: 'asc' }, { startTime: 'desc' }],
});
const page = Math.max(1, opts.page ?? 1);
const pageSize = Math.min(100, Math.max(1, opts.pageSize ?? 20));
const [total, rows] = await Promise.all([
this.prisma.match.count({ where }),
this.prisma.match.findMany({
where,
include: { homeTeam: true, awayTeam: true },
orderBy: [{ displayOrder: 'asc' }, { startTime: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
]);
const locale = opts.locale ?? 'zh-CN';
const betStatsMap = await this.betStatsForMatches(items.map((m) => m.id));
return Promise.all(
items.map(async (m) => {
const betStatsMap = await this.betStatsForMatches(rows.map((m) => m.id));
const items = await Promise.all(
rows.map(async (m) => {
const [homeTeamName, awayTeamName] = await Promise.all([
this.getTranslation('TEAM', m.homeTeamId, locale),
this.getTranslation('TEAM', m.awayTeamId, locale),
@@ -588,6 +617,7 @@ export class MatchesService {
};
}),
);
return { items, total, page, pageSize };
}
/** 批量汇总多场关联注单(按 bet 去重计注单数) */
@@ -688,6 +718,7 @@ export class MatchesService {
awayTeamMs?: string;
startTime: Date;
isHot?: boolean;
correctScoreEnabled?: boolean;
displayOrder?: number;
matchName?: string;
stage?: string;
@@ -802,6 +833,7 @@ export class MatchesService {
awayTeamId: awayTeam.id,
startTime: data.startTime,
isHot: data.isHot ?? false,
correctScoreEnabled: data.correctScoreEnabled ?? true,
displayOrder: data.displayOrder ?? 0,
createdBy: data.createdBy,
status: 'DRAFT',
@@ -849,6 +881,7 @@ export class MatchesService {
status: match.status,
isOutright: match.isOutright,
isHot: match.isHot,
correctScoreEnabled: match.correctScoreEnabled,
displayOrder: match.displayOrder,
startTime: match.startTime.toISOString(),
leagueId: match.leagueId.toString(),
@@ -876,6 +909,7 @@ export class MatchesService {
htAway: scoreRow.htAwayScore ?? 0,
ftHome: scoreRow.ftHomeScore ?? 0,
ftAway: scoreRow.ftAwayScore ?? 0,
winnerTeamId: scoreRow.winnerTeamId?.toString() ?? null,
}
: null,
markets: markets.map((m) => ({
@@ -915,6 +949,7 @@ export class MatchesService {
groupName?: string;
homeTeamLogoUrl?: string;
awayTeamLogoUrl?: string;
correctScoreEnabled?: boolean;
updatedBy?: bigint;
},
) {
@@ -971,6 +1006,7 @@ export class MatchesService {
matchName,
stage: data.stage !== undefined ? data.stage.trim() || null : match.stage,
groupName: data.groupName !== undefined ? data.groupName.trim() || null : match.groupName,
correctScoreEnabled: data.correctScoreEnabled ?? match.correctScoreEnabled,
updatedBy: data.updatedBy,
},
});
@@ -1111,6 +1147,26 @@ export class MatchesService {
});
}
async unpublishMatch(matchId: bigint) {
const match = await this.requireAdminMatch(matchId);
if (match.isOutright) {
throw appBadRequest('OUTRIGHT_EDIT_VIA_MARKETS');
}
const allowed = ['PUBLISHED', 'CLOSED', 'PENDING_SETTLEMENT'];
if (!allowed.includes(match.status)) {
throw appBadRequest('MATCH_UNPUBLISH_FORBIDDEN');
}
if (match.status === 'PENDING_SETTLEMENT') {
await this.prisma.settlementBatch.deleteMany({
where: { matchId, status: 'PREVIEW' },
});
}
return this.prisma.match.update({
where: { id: matchId },
data: { status: 'DRAFT', closeTime: null },
});
}
async closeMatch(matchId: bigint) {
return this.prisma.match.update({
where: { id: matchId },
@@ -1120,7 +1176,24 @@ export class MatchesService {
async reopenMatch(matchId: bigint, startTime?: Date) {
const match = await this.requireAdminMatch(matchId);
if (match.isOutright) throw appBadRequest('OUTRIGHT_EDIT_VIA_MARKETS');
if (match.isOutright) {
const scoreRow = await this.prisma.matchScore.findUnique({ where: { matchId } });
if (scoreRow?.winnerTeamId) throw appBadRequest('MATCH_NOT_REOPENABLE');
if (match.status === 'SETTLED') throw appBadRequest('MATCH_NOT_REOPENABLE');
const reopenable =
match.status === 'CLOSED' || match.status === 'PENDING_SETTLEMENT';
if (!reopenable) throw appBadRequest('MATCH_NOT_REOPENABLE');
if (match.status === 'PENDING_SETTLEMENT') {
await this.prisma.settlementBatch.deleteMany({
where: { matchId, status: 'PREVIEW' },
});
}
return this.prisma.match.update({
where: { id: matchId },
data: { status: 'PUBLISHED', closeTime: null },
});
}
const scoreRow = await this.prisma.matchScore.findUnique({ where: { matchId } });
if (scoreRow) throw appBadRequest('MATCH_NOT_REOPENABLE');
@@ -1188,6 +1261,7 @@ export class MatchesService {
startTime: Date;
status?: string;
isHot?: boolean;
correctScoreEnabled?: boolean;
displayOrder?: number;
matchName?: string | null;
stage?: string | null;
@@ -1221,6 +1295,7 @@ export class MatchesService {
awayTeamLogoUrl: m.awayTeam?.logoUrl ?? null,
startTime: m.startTime.toISOString(),
isHot: m.isHot ?? false,
correctScoreEnabled: m.correctScoreEnabled ?? true,
displayOrder: m.displayOrder ?? 0,
matchName: m.matchName ?? null,
stage: m.stage ?? null,
@@ -1252,9 +1327,13 @@ export class MatchesService {
}),
};
if (m.markets && !options?.omitMarkets) {
const csEnabled = m.correctScoreEnabled ?? true;
const CORRECT_SCORE_TYPES = ['FT_CORRECT_SCORE', 'HT_CORRECT_SCORE', 'SH_CORRECT_SCORE'];
return {
...base,
markets: m.markets.map((market) => ({
markets: m.markets
.filter((market) => csEnabled || !CORRECT_SCORE_TYPES.includes(market.marketType as string))
.map((market) => ({
id: (market.id as bigint).toString(),
marketType: market.marketType as string,
period: market.period as string,

View File

@@ -66,6 +66,7 @@ export class OutrightService {
m.status,
market,
openCount,
league.isActive,
);
const [titleZh, titleEn, titleMs] = await Promise.all([
this.getOutrightTitle(m.id, 'zh-CN'),
@@ -141,6 +142,7 @@ export class OutrightService {
: sel.selectionName;
return {
id: sel.id.toString(),
teamId: team?.id.toString() ?? null,
teamCode: sel.selectionCode,
rank: sel.sortOrder + 1 || index + 1,
teamZh: teamZh || sel.selectionName,
@@ -159,6 +161,11 @@ export class OutrightService {
fullMarket.selections.filter(
(s) => s.selectionCode !== PLACEHOLDER_TEAM_CODE,
),
league.isActive,
);
const unsettledFixtureCount = await this.countUnsettledLeagueFixtures(
match.leagueId,
);
const [titleZh, titleEn, titleMs] = await Promise.all([
@@ -178,6 +185,8 @@ export class OutrightService {
titleEn: titleEn || match.matchName || '',
titleMs,
status: match.status,
leagueIsPublished: league.isActive,
unsettledFixtureCount,
marketId: fullMarket.id.toString(),
marketStatus: fullMarket.status,
canImportCanonical: league.code === WC2026_LEAGUE_CODE,
@@ -191,15 +200,16 @@ export class OutrightService {
/** 按联赛获取或创建冠军盘,并从单场赛程同步参赛队伍 */
async getOrCreateAndSyncForLeague(leagueId: bigint) {
const league = await this.prisma.league.findUnique({
where: { id: leagueId },
});
if (!league) throw appNotFound('LEAGUE_NOT_FOUND');
let match = await this.prisma.match.findFirst({
where: { leagueId, isOutright: true, deletedAt: null },
orderBy: { id: 'asc' },
});
if (!match) {
const league = await this.prisma.league.findUnique({
where: { id: leagueId },
});
if (!league) throw appNotFound('LEAGUE_NOT_FOUND');
const [leagueZh, leagueEn, leagueMs] = await Promise.all([
this.getTranslation('LEAGUE', leagueId, 'zh-CN'),
this.getTranslation('LEAGUE', leagueId, 'en-US'),
@@ -210,12 +220,17 @@ export class OutrightService {
titleZh: leagueZh || league.code,
titleEn: leagueEn || league.code,
titleMs: leagueMs || undefined,
status: 'DRAFT',
status: league.isActive ? 'PUBLISHED' : 'DRAFT',
});
match = await this.prisma.match.findFirstOrThrow({
where: { leagueId, isOutright: true, deletedAt: null },
orderBy: { id: 'asc' },
});
} else {
await this.syncOutrightStatusWithLeague(match, league);
match = await this.prisma.match.findFirstOrThrow({
where: { id: match.id },
});
}
const sync = await this.syncSelectionsFromLeagueFixtures(match.id);
const data = await this.getForAdmin(match.id);
@@ -226,6 +241,47 @@ export class OutrightService {
};
}
/** 联赛发布后同步冠军盘状态(随联赛发布,无需单独发布) */
async syncWithLeaguePublished(leagueId: bigint) {
const league = await this.prisma.league.findUnique({
where: { id: leagueId },
});
if (!league?.isActive) return;
const existing = await this.prisma.match.findFirst({
where: { leagueId, isOutright: true, deletedAt: null },
orderBy: { id: 'asc' },
});
if (!existing) {
await this.getOrCreateAndSyncForLeague(leagueId);
return;
}
await this.syncOutrightStatusWithLeague(existing, league);
}
/** 联赛下尚未结算/取消的单场数量(不含冠军盘) */
async countUnsettledLeagueFixtures(leagueId: bigint): Promise<number> {
return this.prisma.match.count({
where: {
leagueId,
isOutright: false,
deletedAt: null,
status: { notIn: ['SETTLED', 'CANCELLED'] },
},
});
}
private async syncOutrightStatusWithLeague(
match: { id: bigint; status: string },
league: { isActive: boolean },
) {
if (!league.isActive || match.status !== 'DRAFT') return;
await this.prisma.match.update({
where: { id: match.id },
data: { status: 'PUBLISHED', publishTime: new Date() },
});
}
/** 若联赛已有冠军盘,则从单场同步球队(不自动创建冠军盘) */
async syncOutrightTeamsForLeagueIfExists(leagueId: bigint) {
const match = await this.prisma.match.findFirst({
@@ -635,6 +691,7 @@ export class OutrightService {
isOutright: true,
sportType: 'FOOTBALL',
deletedAt: null,
league: { isActive: true, deletedAt: null },
},
include: {
markets: {
@@ -804,18 +861,28 @@ export class OutrightService {
matchStatus: string,
market: { status: string } | null | undefined,
selections: Array<{ selectionCode: string; status: string }>,
leagueIsActive = true,
): { playerVisible: boolean; playerHiddenReason: string | null } {
const openCount = selections.filter(
(s) => s.status === 'OPEN' && s.selectionCode !== PLACEHOLDER_TEAM_CODE,
).length;
return this.playerVisibilityByCounts(matchStatus, market, openCount);
return this.playerVisibilityByCounts(
matchStatus,
market,
openCount,
leagueIsActive,
);
}
private playerVisibilityByCounts(
matchStatus: string,
market: { status: string } | null | undefined,
openSelectionCount: number,
leagueIsActive = true,
): { playerVisible: boolean; playerHiddenReason: string | null } {
if (!leagueIsActive) {
return { playerVisible: false, playerHiddenReason: 'LEAGUE_INACTIVE' };
}
if (matchStatus !== 'PUBLISHED') {
return { playerVisible: false, playerHiddenReason: 'NOT_PUBLISHED' };
}

View File

@@ -64,7 +64,7 @@ export async function syncWc2026OutrightMarket(
const forceCanonical = options.forceCanonical ?? false;
const league = await prisma.league.findUnique({ where: { code: WC2026_LEAGUE_CODE } });
if (!league) {
throw new Error(`League ${WC2026_LEAGUE_CODE} not found — run seedSportsDemo first`);
throw new Error(`League ${WC2026_LEAGUE_CODE} not found — run seedCatalog first`);
}
const placeholder = await upsertTeam(prisma, {
@@ -96,10 +96,14 @@ export async function syncWc2026OutrightMarket(
displayOrder: 0,
},
});
} else if (match.status === 'DRAFT') {
} else if (match.status === 'DRAFT' || match.status === 'SETTLED' || match.status === 'CLOSED') {
match = await prisma.match.update({
where: { id: match.id },
data: { status: 'PUBLISHED', publishTime: match.publishTime ?? new Date() },
data: {
status: 'PUBLISHED',
publishTime: match.publishTime ?? new Date(),
closeTime: null,
},
});
}

View File

@@ -5,6 +5,7 @@ import { resolveTranslationFallback } from '@thebet365/shared';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { WalletService } from '../ledger/wallet.service';
import { appBadRequest } from '../../shared/common/app-error';
import { deleteUploadFileByUrl } from '../../shared/uploads/delete-upload-file';
function generateOrderNo(): string {
const ts = Date.now().toString(36).toUpperCase();
@@ -12,6 +13,9 @@ function generateOrderNo(): string {
return `DEP${ts}${rand}`;
}
/** 已通过充值订单允许撤回的时间窗口 */
const DEPOSIT_REVOKE_WINDOW_MS = 5 * 60 * 1000;
@Injectable()
export class DepositService {
constructor(
@@ -21,6 +25,20 @@ export class DepositService {
// ============ Payment Methods (Admin CRUD) ============
/** isActive 与 showOnPlayer 合并为同一开关DB 两列保持同步以兼容旧数据。 */
private normalizePaymentMethodActive(data: {
isActive?: boolean;
showOnPlayer?: boolean;
}): { isActive?: boolean; showOnPlayer?: boolean } {
if (data.isActive !== undefined) {
return { isActive: data.isActive, showOnPlayer: data.isActive };
}
if (data.showOnPlayer !== undefined) {
return { isActive: data.showOnPlayer, showOnPlayer: data.showOnPlayer };
}
return {};
}
async createPaymentMethod(data: {
methodType: string;
bankName?: string;
@@ -38,6 +56,7 @@ export class DepositService {
bankName?: Record<string, string>;
};
}) {
const active = data.isActive ?? data.showOnPlayer ?? true;
const method = await this.prisma.paymentMethod.create({
data: {
methodType: data.methodType,
@@ -48,8 +67,8 @@ export class DepositService {
qrCodeUrl: data.qrCodeUrl,
displayName: data.displayName,
sortOrder: data.sortOrder ?? 0,
isActive: data.isActive ?? true,
showOnPlayer: data.showOnPlayer ?? true,
isActive: active,
showOnPlayer: active,
createdBy: data.createdBy,
},
});
@@ -78,9 +97,10 @@ export class DepositService {
},
) {
const { translations, ...rest } = data;
const activePatch = this.normalizePaymentMethodActive(rest);
const method = await this.prisma.paymentMethod.update({
where: { id },
data: rest,
data: { ...rest, ...activePatch },
});
if (translations) {
await this.upsertPaymentMethodTranslations(id, translations);
@@ -128,7 +148,6 @@ export class DepositService {
async listPlayerPaymentMethods(methodType?: string, locale?: string) {
const where: Prisma.PaymentMethodWhereInput = {
isActive: true,
showOnPlayer: true,
};
if (methodType) {
where.methodType = methodType;
@@ -438,4 +457,124 @@ export class DepositService {
return { success: true };
}
private async reverseApprovedDepositCredit(
order: {
playerId: bigint;
orderNo: string;
approvedAmount: Decimal | null;
amount: Decimal;
},
operatorId: bigint,
remark: string,
) {
const credit = order.approvedAmount ?? order.amount;
await this.wallet.withdraw(
order.playerId,
credit,
operatorId,
remark,
order.orderNo,
'PLAYER_DEPOSIT_REVERSAL',
);
}
/** 已拒绝恢复待审核已通过5 分钟内):作废期间待结算注单并扣回入账 */
async reopenDepositOrderForReview(orderId: bigint, operatorId: bigint) {
const order = await this.prisma.depositOrder.findUnique({ where: { id: orderId } });
if (!order) throw appBadRequest('ORDER_NOT_FOUND');
if (order.status === 'PENDING') throw appBadRequest('ORDER_ALREADY_PENDING');
if (order.status === 'REJECTED') {
await this.prisma.depositOrder.update({
where: { id: orderId },
data: {
status: 'PENDING',
approvedAmount: null,
reviewerId: null,
reviewedAt: null,
rejectReason: null,
remark: null,
},
});
return { success: true };
}
if (order.status !== 'APPROVED') {
throw appBadRequest('ORDER_NOT_APPROVED');
}
if (!order.reviewedAt || Date.now() - order.reviewedAt.getTime() > DEPOSIT_REVOKE_WINDOW_MS) {
throw appBadRequest('DEPOSIT_REVOKE_WINDOW_EXPIRED');
}
const reviewedAt = order.reviewedAt;
return this.prisma.$transaction(async (tx) => {
const betsAfterReview = await tx.bet.findMany({
where: {
userId: order.playerId,
placedAt: { gte: reviewedAt },
status: { not: 'VOID' },
},
});
const settled = betsAfterReview.filter((b) => b.status !== 'PENDING');
if (settled.length > 0) {
throw appBadRequest('DEPOSIT_REVOKE_SETTLED_BETS');
}
for (const bet of betsAfterReview) {
await this.wallet.settleBet(
bet.userId,
bet.stake,
bet.stake,
bet.betNo,
'VOID',
tx,
);
await tx.bet.update({
where: { id: bet.id },
data: { status: 'VOID', actualReturn: bet.stake, settledAt: new Date() },
});
}
const credit = order.approvedAmount ?? order.amount;
await this.wallet.withdraw(
order.playerId,
credit,
operatorId,
`Revoke approved deposit ${order.orderNo}`,
order.orderNo,
'PLAYER_DEPOSIT_REVERSAL',
tx,
);
await tx.depositOrder.update({
where: { id: orderId },
data: {
status: 'PENDING',
approvedAmount: null,
reviewerId: null,
reviewedAt: null,
rejectReason: null,
remark: null,
},
});
return { success: true, voidedBets: betsAfterReview.length };
});
}
/** 删除充值订单记录及截图(不调整玩家钱包或注单,与撤销无关) */
async deleteDepositOrder(orderId: bigint, _operatorId: bigint) {
const order = await this.prisma.depositOrder.findUnique({ where: { id: orderId } });
if (!order) throw appBadRequest('ORDER_NOT_FOUND');
const screenshotUrl = order.screenshotUrl;
await this.prisma.depositOrder.delete({ where: { id: orderId } });
await deleteUploadFileByUrl(screenshotUrl);
return { success: true };
}
}

View File

@@ -119,6 +119,10 @@ export class AuthService {
throw appForbidden('AGENT_ACCOUNT_SUSPENDED');
}
if (portal === 'player' && user.status === 'SUSPENDED') {
throw appForbidden('ACCOUNT_SUSPENDED');
}
if (portal === 'player' && user.parentId) {
const parentAgent = await this.prisma.user.findUnique({
where: { id: user.parentId },

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { appUnauthorized } from '../../shared/common/app-error';
import { appForbidden, appUnauthorized } from '../../shared/common/app-error';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
@@ -36,9 +36,20 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
},
},
});
if (!user || user.status !== 'ACTIVE') {
if (!user) {
throw appUnauthorized('INVALID_CREDENTIALS');
}
if (user.status === 'DISABLED') {
throw appForbidden('ACCOUNT_DISABLED');
}
if (user.status === 'SUSPENDED') {
throw appForbidden(
user.userType === 'AGENT' ? 'AGENT_ACCOUNT_SUSPENDED' : 'ACCOUNT_SUSPENDED',
);
}
if (user.status !== 'ACTIVE') {
throw appForbidden('ACCOUNT_DISABLED');
}
const permissions =
user.adminRole?.role?.permissions?.map((rp) => rp.permission.code) ?? [];
const roleCode = user.adminRole?.role?.code ?? payload.role;

View File

@@ -8,17 +8,34 @@ import { maskPhoneForLog, shortSessionId } from '../sms-log.util';
@Injectable()
export class ChuanglanClient {
private readonly logger = new Logger(ChuanglanClient.name);
private readonly cfg;
private readonly cfg: ReturnType<typeof loadChuanglanConfig>;
constructor(config: ConfigService) {
this.cfg = loadChuanglanConfig(config);
if (!this.cfg) {
this.logger.warn(
'Chuanglan SMS not configured (missing CHUANGLAN_ACCOUNT or CHUANGLAN_PASSWORD); SMS send will fail until credentials are set',
);
}
}
async sendSms(mobile: string, msg: string, uid?: string): Promise<SmsSendResult> {
const nonce = String(Date.now());
const maskedMobile = maskPhoneForLog(mobile);
const session = uid ? shortSessionId(uid) : 'n/a';
if (!this.cfg) {
this.logger.error(
`Chuanglan not configured mobile=${maskedMobile} session=${session}`,
);
return {
success: false,
code: 'NOT_CONFIGURED',
message: 'Missing CHUANGLAN_ACCOUNT or CHUANGLAN_PASSWORD',
};
}
const nonce = String(Date.now());
this.logger.log(`Chuanglan request mobile=${maskedMobile} session=${session}`);
const body: Record<string, string> = {

View File

@@ -15,11 +15,11 @@ export interface SmsBusinessConfig {
debugLogCode: boolean;
}
export function loadChuanglanConfig(config: ConfigService): ChuanglanConfig {
export function loadChuanglanConfig(config: ConfigService): ChuanglanConfig | null {
const account = config.get<string>('CHUANGLAN_ACCOUNT');
const password = config.get<string>('CHUANGLAN_PASSWORD');
if (!account || !password) {
throw new Error('Missing CHUANGLAN_ACCOUNT or CHUANGLAN_PASSWORD');
return null;
}
return {
account,

View File

@@ -482,4 +482,37 @@ export class UsersService {
});
}
}
async softDeletePlayer(playerId: bigint) {
const user = await this.prisma.user.findFirst({
where: { id: playerId, deletedAt: null },
});
if (!user) throw appNotFound('USER_NOT_FOUND');
if (user.userType !== 'PLAYER') {
throw appBadRequest('NOT_PLAYER');
}
// Block deletion when the player has any unresolved bets
const betCount = await this.prisma.bet.count({
where: {
userId: playerId,
status: 'PENDING',
},
});
if (betCount > 0) {
throw appBadRequest('PLAYER_HAS_PENDING_BETS');
}
// Block deletion when wallet still has balance
const wallet = await this.prisma.wallet.findUnique({ where: { userId: playerId } });
if (wallet) {
const available = new Decimal(wallet.availableBalance);
const frozen = new Decimal(wallet.frozenBalance);
if (available.gt(0) || frozen.gt(0)) {
throw appBadRequest('PLAYER_HAS_BALANCE');
}
}
return this.prisma.user.update({
where: { id: playerId },
data: { deletedAt: new Date(), status: 'SUSPENDED' },
});
}
}

View File

@@ -84,17 +84,18 @@ export class WalletService {
remark?: string,
referenceId?: string,
transactionType = 'MANUAL_WITHDRAW',
tx?: TxClient,
) {
const amt = new Decimal(amount);
if (amt.lte(0)) throw appBadRequest('AMOUNT_MUST_BE_POSITIVE');
return this.prisma.$transaction(async (tx) => {
const w = await this.lockWallet(tx, userId);
const run = async (client: TxClient) => {
const w = await this.lockWallet(client, userId);
const balanceBefore = new Decimal(w.available_balance);
if (balanceBefore.lt(amt)) throw appBadRequest('INSUFFICIENT_BALANCE');
const balanceAfter = balanceBefore.sub(amt);
await tx.wallet.update({
await client.wallet.update({
where: { id: w.id },
data: {
availableBalance: balanceAfter,
@@ -102,7 +103,7 @@ export class WalletService {
},
});
await tx.walletTransaction.create({
await client.walletTransaction.create({
data: {
transactionId: generateTransactionId(),
userId,
@@ -121,7 +122,10 @@ export class WalletService {
});
return { balanceAfter };
});
};
if (tx) return run(tx);
return this.prisma.$transaction(run);
}
async freezeForBet(userId: bigint, stake: Decimal | number, betId: string) {

View File

@@ -229,6 +229,7 @@ export class ContentService {
async listActive(contentType: string, locale: string) {
const now = new Date();
const type = this.assertContentType(contentType);
const items = await this.prisma.content.findMany({
where: {
contentType,
@@ -237,7 +238,10 @@ export class ContentService {
AND: [{ OR: [{ endTime: null }, { endTime: { gte: now } }] }],
},
include: { translations: true },
orderBy: { sortOrder: 'asc' },
orderBy:
type === 'BANNER'
? [{ createdAt: 'desc' }, { id: 'desc' }]
: [{ sortOrder: 'asc' }, { id: 'asc' }],
});
return items
@@ -277,7 +281,7 @@ export class ContentService {
this.prisma.content.findMany({
where,
include: { translations: true },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),

View File

@@ -0,0 +1,242 @@
import { SettlementService } from './settlement.service';
import { Decimal } from '@prisma/client/runtime/library';
describe('SettlementService outright winner flow', () => {
const matchId = BigInt(100);
const operatorId = BigInt(1);
const winnerTeamId = BigInt(10);
const batchId = BigInt(500);
const winningBetId = BigInt(1001);
const losingBetId = BigInt(1002);
const winningSelId = BigInt(201);
const losingSelId = BigInt(202);
const outrightMatch = {
id: matchId,
isOutright: true,
status: 'CLOSED',
deletedAt: null,
};
const winnerTeam = { id: winnerTeamId, code: 'BRA' };
const winningBet = {
id: winningBetId,
betNo: 'BET-WIN',
betType: 'SINGLE',
status: 'PENDING',
stake: new Decimal(100),
agentId: null,
userId: BigInt(50),
user: { id: BigInt(50) },
selections: [
{
id: BigInt(301),
matchId,
marketType: 'OUTRIGHT_WINNER',
selectionId: winningSelId,
selectionNameSnapshot: '巴西',
handicapLine: null,
totalLine: null,
odds: new Decimal(3),
resultStatus: null,
sortOrder: 0,
},
],
};
const losingBet = {
id: losingBetId,
betNo: 'BET-LOSE',
betType: 'SINGLE',
status: 'PENDING',
stake: new Decimal(50),
agentId: null,
userId: BigInt(51),
user: { id: BigInt(51) },
selections: [
{
id: BigInt(302),
matchId,
marketType: 'OUTRIGHT_WINNER',
selectionId: losingSelId,
selectionNameSnapshot: '阿根廷',
handicapLine: null,
totalLine: null,
odds: new Decimal(5),
resultStatus: null,
sortOrder: 0,
},
],
};
let matchScoreUpsert: jest.Mock;
let matchFindFirst: jest.Mock;
let matchUpdate: jest.Mock;
let teamFindUnique: jest.Mock;
let marketSelectionFindFirst: jest.Mock;
let marketSelectionFindMany: jest.Mock;
let matchScoreFindUnique: jest.Mock;
let settlementBatchCreate: jest.Mock;
let settlementBatchFindUnique: jest.Mock;
let betFindMany: jest.Mock;
let transaction: jest.Mock;
let wallet: { settleBet: jest.Mock };
let agents: Record<string, jest.Mock>;
let service: SettlementService;
beforeEach(() => {
matchScoreUpsert = jest.fn().mockResolvedValue({});
matchFindFirst = jest.fn().mockResolvedValue(outrightMatch);
matchUpdate = jest.fn().mockResolvedValue({});
teamFindUnique = jest.fn().mockResolvedValue(winnerTeam);
marketSelectionFindFirst = jest
.fn()
.mockResolvedValue({ id: winningSelId, selectionCode: 'BRA' });
marketSelectionFindMany = jest.fn().mockResolvedValue([
{ id: winningSelId, selectionCode: 'BRA' },
{ id: losingSelId, selectionCode: 'ARG' },
]);
matchScoreFindUnique = jest.fn();
settlementBatchCreate = jest.fn().mockResolvedValue({
id: batchId,
matchId,
htHomeScore: 0,
htAwayScore: 0,
ftHomeScore: 0,
ftAwayScore: 0,
status: 'PREVIEW',
});
settlementBatchFindUnique = jest.fn();
betFindMany = jest.fn();
transaction = jest.fn(async (fn: (client: unknown) => Promise<void>) =>
fn({
matchScore: { upsert: matchScoreUpsert },
bet: { update: jest.fn().mockResolvedValue({}) },
betSelection: { update: jest.fn().mockResolvedValue({}) },
settlementItem: { create: jest.fn().mockResolvedValue({}) },
settlementBatch: { update: jest.fn().mockResolvedValue({}) },
match: { update: jest.fn().mockResolvedValue({}) },
}),
);
const prisma = {
match: { findFirst: matchFindFirst, update: matchUpdate },
team: { findUnique: teamFindUnique },
marketSelection: {
findFirst: marketSelectionFindFirst,
findMany: marketSelectionFindMany,
},
matchScore: {
findUnique: matchScoreFindUnique,
upsert: matchScoreUpsert,
},
settlementBatch: {
create: settlementBatchCreate,
findUnique: settlementBatchFindUnique,
update: jest.fn().mockResolvedValue({}),
},
bet: { findMany: betFindMany },
$transaction: transaction,
};
wallet = { settleBet: jest.fn().mockResolvedValue(undefined) };
agents = { recalculateUsedCredit: jest.fn().mockResolvedValue(undefined) };
service = new SettlementService(prisma as never, wallet as never, agents as never);
});
it('previewSettlement persists winnerTeamId and previews WIN/LOSE', async () => {
betFindMany.mockResolvedValue([winningBet, losingBet]);
const preview = await service.previewSettlement(matchId, operatorId, {
winnerTeamId,
});
expect(matchScoreUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { matchId },
create: expect.objectContaining({ winnerTeamId }),
update: expect.objectContaining({ winnerTeamId }),
}),
);
expect(preview.winnerTeamCode).toBe('BRA');
expect(preview.items.items).toEqual(
expect.arrayContaining([
expect.objectContaining({ betNo: 'BET-WIN', result: 'WIN', payout: '300' }),
expect.objectContaining({ betNo: 'BET-LOSE', result: 'LOSE', payout: '0' }),
]),
);
});
it('confirmSettlement settles outright bets as WON/LOST using stored winnerTeamId', async () => {
const txBetUpdate = jest.fn().mockResolvedValue({});
transaction.mockImplementation(async (fn: (client: unknown) => Promise<void>) => {
await fn({
matchScore: { upsert: matchScoreUpsert },
bet: { update: txBetUpdate },
betSelection: { update: jest.fn().mockResolvedValue({}) },
settlementItem: { create: jest.fn().mockResolvedValue({}) },
settlementBatch: { update: jest.fn().mockResolvedValue({}) },
match: { update: jest.fn().mockResolvedValue({}) },
});
});
settlementBatchFindUnique.mockResolvedValue({
id: batchId,
matchId,
status: 'PREVIEW',
htHomeScore: 0,
htAwayScore: 0,
ftHomeScore: 0,
ftAwayScore: 0,
match: { ...outrightMatch, status: 'PENDING_SETTLEMENT' },
});
matchScoreFindUnique.mockResolvedValue({
matchId,
htHomeScore: 0,
htAwayScore: 0,
ftHomeScore: 0,
ftAwayScore: 0,
winnerTeamId,
});
betFindMany.mockResolvedValue([winningBet, losingBet]);
const result = await service.confirmSettlement(batchId, operatorId);
expect(matchScoreUpsert).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({ winnerTeamId }),
}),
);
expect(wallet.settleBet).toHaveBeenCalledTimes(2);
expect(wallet.settleBet.mock.calls[0]).toEqual([
winningBet.userId,
expect.anything(),
expect.anything(),
'BET-WIN',
'WIN',
expect.anything(),
]);
expect(wallet.settleBet.mock.calls[1]).toEqual([
losingBet.userId,
expect.anything(),
expect.anything(),
'BET-LOSE',
'LOSE',
expect.anything(),
]);
expect(txBetUpdate).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: winningBetId },
data: expect.objectContaining({ status: 'WON' }),
}),
);
expect(txBetUpdate).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: losingBetId },
data: expect.objectContaining({ status: 'LOST' }),
}),
);
expect(result).toEqual({ success: true, batchId: batchId.toString() });
});
});

View File

@@ -46,6 +46,24 @@ export class SettlementService {
return team?.code ?? null;
}
private async assertOutrightLeagueFixturesSettled(match: {
leagueId: bigint;
isOutright: boolean;
}) {
if (!match.isOutright) return;
const unsettled = await this.prisma.match.count({
where: {
leagueId: match.leagueId,
isOutright: false,
deletedAt: null,
status: { notIn: ['SETTLED', 'CANCELLED'] },
},
});
if (unsettled > 0) {
throw appBadRequest('OUTRIGHT_LEAGUE_FIXTURES_UNSETTLED');
}
}
private buildSettleInput(
sel: BetSelectionLeg,
selectionCode: string,
@@ -107,6 +125,10 @@ export class SettlementService {
if (!match) throw appNotFound('MATCH_NOT_FOUND');
this.assertMatchClosedForSettlement(match.status);
if (match.isOutright) {
await this.assertOutrightLeagueFixturesSettled(match);
}
if (match.isOutright) {
if (!winnerTeamId) {
throw appBadRequest('SETTLEMENT_WINNER_REQUIRED');
@@ -172,6 +194,10 @@ export class SettlementService {
if (!match) throw appNotFound('MATCH_NOT_FOUND');
this.assertMatchClosedForSettlement(match.status);
if (match.isOutright) {
await this.assertOutrightLeagueFixturesSettled(match);
}
const scoreSource = await this.resolvePreviewScoreSource(matchId, match.isOutright, opts);
const computation = await this.computePreviewComputation(matchId, scoreSource);
const batch = await this.prisma.settlementBatch.create({
@@ -190,6 +216,10 @@ export class SettlementService {
},
});
if (match.isOutright && scoreSource.winnerTeamId) {
await this.upsertMatchScoreRecord(matchId, scoreSource, operatorId);
}
if (match.status !== 'PENDING_SETTLEMENT' && match.status !== 'SETTLED') {
await this.prisma.match.update({
where: { id: matchId },
@@ -306,6 +336,41 @@ export class SettlementService {
};
}
private async upsertMatchScoreRecord(
matchId: bigint,
scoreSource: {
htHome: number;
htAway: number;
ftHome: number;
ftAway: number;
winnerTeamId?: bigint | null;
},
operatorId: bigint,
tx?: Parameters<Parameters<PrismaService['$transaction']>[0]>[0],
) {
const client = tx ?? this.prisma;
await client.matchScore.upsert({
where: { matchId },
create: {
matchId,
htHomeScore: scoreSource.htHome,
htAwayScore: scoreSource.htAway,
ftHomeScore: scoreSource.ftHome,
ftAwayScore: scoreSource.ftAway,
winnerTeamId: scoreSource.winnerTeamId ?? null,
recordedBy: operatorId,
},
update: {
htHomeScore: scoreSource.htHome,
htAwayScore: scoreSource.htAway,
ftHomeScore: scoreSource.ftHome,
ftAwayScore: scoreSource.ftAway,
winnerTeamId: scoreSource.winnerTeamId ?? null,
recordedBy: operatorId,
},
});
}
private async resolvePreviewScoreSource(
matchId: bigint,
isOutright: boolean,
@@ -601,6 +666,10 @@ export class SettlementService {
throw appBadRequest('MATCH_NOT_SETTLEABLE');
}
if (batch.match.isOutright) {
await this.assertOutrightLeagueFixturesSettled(batch.match);
}
const scoreInput: ScoreInput = {
htHome: batch.htHomeScore ?? 0,
htAway: batch.htAwayScore ?? 0,
@@ -624,25 +693,18 @@ export class SettlementService {
const agentIds = new Set<bigint>();
await this.prisma.$transaction(async (tx) => {
await tx.matchScore.upsert({
where: { matchId: batch.matchId },
create: {
matchId: batch.matchId,
htHomeScore: scoreInput.htHome,
htAwayScore: scoreInput.htAway,
ftHomeScore: scoreInput.ftHome,
ftAwayScore: scoreInput.ftAway,
await this.upsertMatchScoreRecord(
batch.matchId,
{
htHome: scoreInput.htHome,
htAway: scoreInput.htAway,
ftHome: scoreInput.ftHome,
ftAway: scoreInput.ftAway,
winnerTeamId: existingScore?.winnerTeamId ?? null,
recordedBy: operatorId,
},
update: {
htHomeScore: scoreInput.htHome,
htAwayScore: scoreInput.htAway,
ftHomeScore: scoreInput.ftHome,
ftAwayScore: scoreInput.ftAway,
recordedBy: operatorId,
},
});
operatorId,
tx,
);
for (const bet of pendingBets) {
if (bet.betType === 'SINGLE' && bet.selections.length === 1) {
@@ -833,56 +895,58 @@ export class SettlementService {
return { success: true, batchId: batchId.toString() };
}
async getMatchBetStats(
matchId: bigint,
opts?: { page?: number; pageSize?: number },
) {
private async assertMatchReadyForBetStats(matchId: bigint) {
const match = await this.prisma.match.findFirst({
where: { id: matchId, deletedAt: null },
});
if (!match) throw appNotFound('MATCH_NOT_FOUND');
this.assertMatchClosedForSettlement(match.status);
return match;
}
const legs = await this.prisma.betSelection.findMany({
where: { matchId },
include: {
bet: {
select: {
id: true,
betNo: true,
betType: true,
stake: true,
status: true,
settlementStatus: true,
potentialReturn: true,
actualReturn: true,
placedAt: true,
user: { select: { username: true } },
},
async getMatchBetStatsSummary(matchId: bigint) {
await this.assertMatchReadyForBetStats(matchId);
const betWhere = { selections: { some: { matchId } } };
const [
legCount,
totalBets,
singleBets,
parlayBets,
stakeAgg,
statusGroups,
legsForSelection,
] = await Promise.all([
this.prisma.betSelection.count({ where: { matchId } }),
this.prisma.bet.count({ where: betWhere }),
this.prisma.bet.count({ where: { ...betWhere, betType: 'SINGLE' } }),
this.prisma.bet.count({ where: { ...betWhere, betType: 'PARLAY' } }),
this.prisma.bet.aggregate({
where: betWhere,
_sum: { stake: true, potentialReturn: true },
}),
this.prisma.bet.groupBy({
by: ['status'],
where: betWhere,
_count: { _all: true },
}),
this.prisma.betSelection.findMany({
where: { matchId },
select: {
marketId: true,
selectionId: true,
marketType: true,
period: true,
selectionNameSnapshot: true,
bet: { select: { betType: true, stake: true } },
},
},
orderBy: [{ marketType: 'asc' }, { sortOrder: 'asc' }, { id: 'asc' }],
});
orderBy: [{ marketType: 'asc' }, { sortOrder: 'asc' }, { id: 'asc' }],
}),
]);
const betById = new Map<string, (typeof legs)[0]['bet']>();
for (const leg of legs) {
betById.set(leg.betId.toString(), leg.bet);
}
let totalStake = new Decimal(0);
let totalPotential = new Decimal(0);
let singleBets = 0;
let parlayBets = 0;
const statusCounts: Record<string, number> = {};
for (const bet of betById.values()) {
totalStake = totalStake.add(bet.stake);
if (bet.potentialReturn) {
totalPotential = totalPotential.add(bet.potentialReturn);
}
if (bet.betType === 'SINGLE') singleBets += 1;
else if (bet.betType === 'PARLAY') parlayBets += 1;
statusCounts[bet.status] = (statusCounts[bet.status] ?? 0) + 1;
for (const row of statusGroups) {
statusCounts[row.status] = row._count._all;
}
type SelAgg = {
@@ -896,7 +960,7 @@ export class SettlementService {
};
const selMap = new Map<string, SelAgg>();
for (const leg of legs) {
for (const leg of legsForSelection) {
const key = `${leg.marketId.toString()}:${leg.selectionId.toString()}`;
let row = selMap.get(key);
if (!row) {
@@ -935,67 +999,84 @@ export class SettlementService {
return a.selectionName.localeCompare(b.selectionName);
});
const betsById = new Map<
string,
{
bet: (typeof legs)[0]['bet'];
matchLegs: (typeof legs);
}
>();
for (const leg of legs) {
const key = leg.betId.toString();
const row = betsById.get(key) ?? { bet: leg.bet, matchLegs: [] };
row.matchLegs.push(leg);
betsById.set(key, row);
}
return {
summary: {
totalBets,
singleBets,
parlayBets,
totalStake: (stakeAgg._sum.stake ?? new Decimal(0)).toString(),
totalPotentialReturn: (stakeAgg._sum.potentialReturn ?? new Decimal(0)).toString(),
statusCounts,
legCount,
},
bySelection,
};
}
const allBets = Array.from(betsById.values())
.map(({ bet, matchLegs }) => ({
id: bet.id.toString(),
betNo: bet.betNo,
username: matchLegs[0].bet.user.username,
betType: bet.betType,
status: bet.status,
settlementStatus: bet.settlementStatus,
stake: bet.stake.toString(),
potentialReturn: bet.potentialReturn?.toString() ?? null,
actualReturn: bet.actualReturn.toString(),
placedAt: bet.placedAt.toISOString(),
legCountOnMatch: matchLegs.length,
selections: matchLegs.map((leg) => ({
marketType: leg.marketType,
period: leg.period,
selectionName: leg.selectionNameSnapshot,
odds: leg.odds.toString(),
})),
}))
.sort(
(a, b) =>
new Date(b.placedAt).getTime() - new Date(a.placedAt).getTime(),
);
async getMatchBetStatsBets(
matchId: bigint,
opts?: { page?: number; pageSize?: number },
) {
await this.assertMatchReadyForBetStats(matchId);
const betWhere = { selections: { some: { matchId } } };
const totalBets = await this.prisma.bet.count({ where: betWhere });
const page = Math.max(1, opts?.page ?? 1);
const pageSize = Math.min(100, Math.max(1, opts?.pageSize ?? 10));
const total = allBets.length;
const start = (page - 1) * pageSize;
const betRows = await this.prisma.bet.findMany({
where: betWhere,
orderBy: { placedAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
user: { select: { username: true } },
selections: {
where: { matchId },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
},
},
});
const items = betRows.map((bet) => ({
id: bet.id.toString(),
betNo: bet.betNo,
username: bet.user.username,
betType: bet.betType,
status: bet.status,
settlementStatus: bet.settlementStatus,
stake: bet.stake.toString(),
potentialReturn: bet.potentialReturn?.toString() ?? null,
actualReturn: bet.actualReturn.toString(),
placedAt: bet.placedAt.toISOString(),
legCountOnMatch: bet.selections.length,
selections: bet.selections.map((leg) => ({
marketType: leg.marketType,
period: leg.period,
selectionName: leg.selectionNameSnapshot,
odds: leg.odds.toString(),
})),
}));
return {
summary: {
totalBets: betById.size,
singleBets,
parlayBets,
totalStake: totalStake.toString(),
totalPotentialReturn: totalPotential.toString(),
statusCounts,
legCount: legs.length,
},
bySelection,
bets: {
items: allBets.slice(start, start + pageSize),
total,
page,
pageSize,
},
items,
total: totalBets,
page,
pageSize,
};
}
async getMatchBetStats(
matchId: bigint,
opts?: { page?: number; pageSize?: number },
) {
const [summaryPart, bets] = await Promise.all([
this.getMatchBetStatsSummary(matchId),
this.getMatchBetStatsBets(matchId, opts),
]);
return {
...summaryPart,
bets,
};
}
@@ -1144,6 +1225,20 @@ export class SettlementService {
},
});
if (match.isOutright && winnerTeamId) {
await this.upsertMatchScoreRecord(
matchId,
{
htHome: scoreInput.htHome,
htAway: scoreInput.htAway,
ftHome: scoreInput.ftHome,
ftAway: scoreInput.ftAway,
winnerTeamId,
},
operatorId,
);
}
return {
batch,
score: scoreInput,
@@ -1170,11 +1265,10 @@ export class SettlementService {
ftHome: batch.ftHomeScore ?? 0,
ftAway: batch.ftAwayScore ?? 0,
};
const winnerTeamCode = await this.resolveWinnerTeamCode(
(
await this.prisma.matchScore.findUnique({ where: { matchId: batch.matchId } })
)?.winnerTeamId,
);
const existingScore = await this.prisma.matchScore.findUnique({
where: { matchId: batch.matchId },
});
const winnerTeamCode = await this.resolveWinnerTeamCode(existingScore?.winnerTeamId ?? null);
const settledBets = await this.prisma.bet.findMany({
where: {
@@ -1188,24 +1282,18 @@ export class SettlementService {
const agentIds = new Set<bigint>();
await this.prisma.$transaction(async (tx) => {
await tx.matchScore.upsert({
where: { matchId: batch.matchId },
create: {
matchId: batch.matchId,
htHomeScore: scoreInput.htHome,
htAwayScore: scoreInput.htAway,
ftHomeScore: scoreInput.ftHome,
ftAwayScore: scoreInput.ftAway,
recordedBy: operatorId,
await this.upsertMatchScoreRecord(
batch.matchId,
{
htHome: scoreInput.htHome,
htAway: scoreInput.htAway,
ftHome: scoreInput.ftHome,
ftAway: scoreInput.ftAway,
winnerTeamId: existingScore?.winnerTeamId ?? null,
},
update: {
htHomeScore: scoreInput.htHome,
htAwayScore: scoreInput.htAway,
ftHomeScore: scoreInput.ftHome,
ftAwayScore: scoreInput.ftAway,
recordedBy: operatorId,
},
});
operatorId,
tx,
);
for (const bet of settledBets) {
const oldPayout = new Decimal(bet.actualReturn);

View File

@@ -0,0 +1,34 @@
import type { PrismaClient } from '@prisma/client';
import { resolveSeedAccounts, resolveSeedMode, runSeed, type RunSeedOptions, type SeedMode } from './run-seed';
export type ResetSeedOptions = RunSeedOptions;
/** 清空 public 下除 _prisma_migrations 外的全部表(含用户、充值、注单、赛事等) */
export async function truncateApplicationTables(prisma: PrismaClient): Promise<void> {
const rows = await prisma.$queryRaw<Array<{ tablename: string }>>`
SELECT tablename
FROM pg_tables
WHERE schemaname = 'public'
AND tablename <> '_prisma_migrations'
`;
const tableNames = rows.map((r) => `"${r.tablename}"`);
if (tableNames.length === 0) return;
await prisma.$executeRawUnsafe(
`TRUNCATE TABLE ${tableNames.join(', ')} RESTART IDENTITY CASCADE`,
);
}
function resolveResetSeedMode(options?: ResetSeedOptions): SeedMode {
if (options?.mode) return options.mode;
return resolveSeedMode();
}
/** 全量初始化:清表 + seed。production 模式仅保留 admin + WC2026 赛事目录。 */
export async function resetAndSeedDatabase(prisma: PrismaClient, options?: ResetSeedOptions) {
const mode = resolveResetSeedMode(options);
await truncateApplicationTables(prisma);
await runSeed(prisma, { mode });
return { seedMode: mode, demoAccounts: resolveSeedAccounts(mode) };
}

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { appForbidden } from '../../shared/common/app-error';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { DEMO_ACCOUNTS, runSeed } from './run-seed';
import { resetAndSeedDatabase } from './database-init';
@Injectable()
export class DatabaseResetService {
@@ -17,25 +17,8 @@ export class DatabaseResetService {
throw appForbidden('DB_RESET_FORBIDDEN');
}
await this.truncateApplicationTables();
await runSeed(this.prisma);
return { demoAccounts: DEMO_ACCOUNTS };
}
private async truncateApplicationTables() {
const rows = await this.prisma.$queryRaw<Array<{ tablename: string }>>`
SELECT tablename
FROM pg_tables
WHERE schemaname = 'public'
AND tablename <> '_prisma_migrations'
`;
const tableNames = rows.map((r) => `"${r.tablename}"`);
if (tableNames.length === 0) return;
await this.prisma.$executeRawUnsafe(
`TRUNCATE TABLE ${tableNames.join(', ')} RESTART IDENTITY CASCADE`,
);
return resetAndSeedDatabase(this.prisma, {
mode: process.env.NODE_ENV === 'production' ? 'production' : 'dev',
});
}
}

View File

@@ -0,0 +1,59 @@
import { PrismaClient } from '@prisma/client';
import { resetAndSeedDatabase } from './database-init';
import { resolveSeedMode } from './run-seed';
function assertInitAllowed() {
if (process.argv.includes('--yes')) {
process.env.INIT_DATABASE_CONFIRM = 'YES';
}
if (process.argv.includes('--dev')) {
process.env.SEED_MODE = 'dev';
}
if (process.argv.includes('--production')) {
process.env.SEED_MODE = 'production';
}
if (process.env.INIT_DATABASE_CONFIRM !== 'YES') {
console.error(
'[init-db] 拒绝执行:须设置 INIT_DATABASE_CONFIRM=YES 或传入 --yes 以确认清空全部业务数据。',
);
console.error('[init-db] 生产上线: pnpm db:reset:prod');
console.error('[init-db] 本地演示: pnpm db:reset:dev');
process.exit(1);
}
const isProd = process.env.NODE_ENV === 'production';
if (isProd && process.env.ALLOW_DB_RESET !== 'true') {
console.error(
'[init-db] 生产环境须同时设置 ALLOW_DB_RESET=true与管理端「重置数据库」相同策略。',
);
process.exit(1);
}
}
async function main() {
assertInitAllowed();
const mode = resolveSeedMode();
const prisma = new PrismaClient();
try {
console.log(`[init-db] 正在清空全部业务表并重新 seedmode=${mode})…`);
const result = await resetAndSeedDatabase(prisma, { mode });
console.log('[init-db] 完成。保留账号:');
for (const line of result.demoAccounts) {
console.log(` - ${line}`);
}
if (result.seedMode === 'production') {
console.log('[init-db] 已写入 WC2026 赛事示例数据(无代理/玩家/充值/注单)。');
}
} finally {
await prisma.$disconnect();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -1,6 +1,6 @@
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { syncWc2026OutrightMarket } from '../../domains/catalog/wc2026-outright.sync';
import { seedCatalog } from './seed-catalog';
import { ensureUserInviteCode } from '../../shared/common/invite-code.util';
export const DEMO_ACCOUNTS = [
@@ -9,505 +9,30 @@ export const DEMO_ACCOUNTS = [
'player1 / Player@123',
] as const;
export const PRODUCTION_SEED_ACCOUNTS = [
'admin / Admin@123',
] as const;
export type SeedMode = 'dev' | 'production';
export type RunSeedOptions = {
mode?: SeedMode;
};
export function resolveSeedMode(options?: RunSeedOptions): SeedMode {
if (options?.mode) return options.mode;
if (process.env.SEED_MODE === 'dev') return 'dev';
if (process.env.SEED_MODE === 'production') return 'production';
return process.env.NODE_ENV === 'production' ? 'production' : 'dev';
}
export function resolveSeedAccounts(mode: SeedMode): readonly string[] {
return mode === 'production' ? PRODUCTION_SEED_ACCOUNTS : DEMO_ACCOUNTS;
}
let prisma: PrismaClient;
/** 为演示赛事补齐详情页玩法(与后台 markets 模板一致) */
async function seedDemoMarkets(matchId: bigint) {
const configs: Array<{
marketType: string;
period: string;
lineValue?: number;
sortOrder: number;
selections: Array<{ code: string; name: string; odds: number }>;
}> = [
{
marketType: 'FT_1X2',
period: 'FT',
sortOrder: 1,
selections: [
{ code: 'HOME', name: '主胜', odds: 2.5 },
{ code: 'DRAW', name: '和', odds: 3.2 },
{ code: 'AWAY', name: '客胜', odds: 2.8 },
],
},
{
marketType: 'FT_HANDICAP',
period: 'FT',
lineValue: -0.5,
sortOrder: 2,
selections: [
{ code: 'HOME', name: '主 -0.5', odds: 1.9 },
{ code: 'AWAY', name: '客 +0.5', odds: 1.9 },
],
},
{
marketType: 'FT_OVER_UNDER',
period: 'FT',
lineValue: 2.5,
sortOrder: 3,
selections: [
{ code: 'OVER', name: '大 2.5', odds: 1.85 },
{ code: 'UNDER', name: '小 2.5', odds: 1.95 },
],
},
{
marketType: 'FT_ODD_EVEN',
period: 'FT',
sortOrder: 4,
selections: [
{ code: 'ODD', name: '单', odds: 1.9 },
{ code: 'EVEN', name: '双', odds: 1.9 },
],
},
{
marketType: 'HT_1X2',
period: 'HT',
sortOrder: 5,
selections: [
{ code: 'HOME', name: '半场主', odds: 3.0 },
{ code: 'DRAW', name: '半场和', odds: 2.0 },
{ code: 'AWAY', name: '半场客', odds: 3.5 },
],
},
{
marketType: 'HT_HANDICAP',
period: 'HT',
lineValue: -0.5,
sortOrder: 6,
selections: [
{ code: 'HOME', name: '半场主 -0.5', odds: 1.9 },
{ code: 'AWAY', name: '半场客 +0.5', odds: 1.9 },
],
},
{
marketType: 'HT_OVER_UNDER',
period: 'HT',
lineValue: 1.5,
sortOrder: 7,
selections: [
{ code: 'OVER', name: '半场大 1.5', odds: 2.0 },
{ code: 'UNDER', name: '半场小 1.5', odds: 1.75 },
],
},
{
marketType: 'FT_CORRECT_SCORE',
period: 'FT',
sortOrder: 8,
selections: [
{ code: 'SCORE_1_0', name: '1-0', odds: 4.86 },
{ code: 'SCORE_2_0', name: '2-0', odds: 5.22 },
{ code: 'SCORE_2_1', name: '2-1', odds: 7.92 },
{ code: 'SCORE_3_0', name: '3-0', odds: 8.28 },
{ code: 'SCORE_0_0', name: '0-0', odds: 8.64 },
{ code: 'SCORE_1_1', name: '1-1', odds: 7.47 },
{ code: 'SCORE_2_2', name: '2-2', odds: 24.3 },
{ code: 'SCORE_3_3', name: '3-3', odds: 175.5 },
{ code: 'SCORE_0_1', name: '0-1', odds: 14.4 },
{ code: 'SCORE_0_2', name: '0-2', odds: 45.9 },
{ code: 'SCORE_1_2', name: '1-2', odds: 23.4 },
{ code: 'SCORE_0_3', name: '0-3', odds: 207 },
],
},
{
marketType: 'HT_CORRECT_SCORE',
period: 'HT',
sortOrder: 9,
selections: [
{ code: 'SCORE_1_0', name: '1-0', odds: 4.5 },
{ code: 'SCORE_2_0', name: '2-0', odds: 8.0 },
{ code: 'SCORE_0_0', name: '0-0', odds: 5.5 },
{ code: 'SCORE_1_1', name: '1-1', odds: 7.0 },
{ code: 'SCORE_0_1', name: '0-1', odds: 6.5 },
{ code: 'SCORE_0_2', name: '0-2', odds: 18.0 },
],
},
{
marketType: 'SH_CORRECT_SCORE',
period: 'SH',
sortOrder: 10,
selections: [
{ code: 'SCORE_1_0', name: '1-0', odds: 4.5 },
{ code: 'SCORE_2_0', name: '2-0', odds: 8.0 },
{ code: 'SCORE_0_0', name: '0-0', odds: 5.5 },
{ code: 'SCORE_1_1', name: '1-1', odds: 7.0 },
{ code: 'SCORE_0_1', name: '0-1', odds: 6.5 },
{ code: 'SCORE_0_2', name: '0-2', odds: 18.0 },
],
},
];
for (const cfg of configs) {
const exists = await prisma.market.findFirst({
where: { matchId, marketType: cfg.marketType },
include: { _count: { select: { selections: true } } },
});
if (exists) {
const needRefresh =
cfg.marketType.includes('CORRECT_SCORE') &&
exists._count.selections < cfg.selections.length;
if (needRefresh) {
await prisma.market.delete({ where: { id: exists.id } });
} else {
continue;
}
}
await prisma.market.create({
data: {
matchId,
marketType: cfg.marketType,
period: cfg.period,
lineValue: cfg.lineValue,
allowSingle: true,
allowParlay: true,
sortOrder: cfg.sortOrder,
status: 'OPEN',
selections: {
create: cfg.selections.map((s, i) => ({
selectionCode: s.code,
selectionName: s.name,
odds: s.odds,
sortOrder: i,
status: 'OPEN',
})),
},
},
});
}
}
async function upsertLeagueName(leagueId: bigint, names: Record<string, string>) {
for (const [locale, value] of Object.entries(names)) {
await prisma.entityTranslation.upsert({
where: {
entityType_entityId_locale_fieldName: {
entityType: 'LEAGUE',
entityId: leagueId,
locale,
fieldName: 'name',
},
},
create: { entityType: 'LEAGUE', entityId: leagueId, locale, fieldName: 'name', value },
update: { value },
});
}
}
async function upsertTeam(
code: string,
names: Record<string, string>,
) {
const team = await prisma.team.upsert({
where: { code },
create: { code },
update: {},
});
for (const [locale, value] of Object.entries(names)) {
await prisma.entityTranslation.upsert({
where: {
entityType_entityId_locale_fieldName: {
entityType: 'TEAM',
entityId: team.id,
locale,
fieldName: 'name',
},
},
create: { entityType: 'TEAM', entityId: team.id, locale, fieldName: 'name', value },
update: { value },
});
}
return team;
}
async function ensurePublishedMatch(opts: {
leagueId: bigint;
homeTeamId: bigint;
awayTeamId: bigint;
startTime: Date;
isHot?: boolean;
displayOrder?: number;
}) {
let match = await prisma.match.findFirst({
where: {
leagueId: opts.leagueId,
homeTeamId: opts.homeTeamId,
awayTeamId: opts.awayTeamId,
status: 'PUBLISHED',
},
});
if (!match) {
match = await prisma.match.create({
data: {
leagueId: opts.leagueId,
homeTeamId: opts.homeTeamId,
awayTeamId: opts.awayTeamId,
startTime: opts.startTime,
status: 'PUBLISHED',
isHot: opts.isHot ?? false,
displayOrder: opts.displayOrder ?? 0,
publishTime: new Date(),
},
});
} else {
match = await prisma.match.update({
where: { id: match.id },
data: {
startTime: opts.startTime,
isHot: opts.isHot ?? match.isHot,
displayOrder: opts.displayOrder ?? match.displayOrder,
},
});
}
await seedDemoMarkets(match.id);
return match;
}
function hoursFromNow(hours: number) {
return new Date(Date.now() + hours * 3600 * 1000);
}
async function seedSportsDemo() {
const epl = await prisma.league.upsert({
where: { code: 'EPL' },
create: { code: 'EPL' },
update: {},
});
await upsertLeagueName(epl.id, { 'zh-CN': '英超', 'en-US': 'Premier League' });
const wc = await prisma.league.upsert({
where: { code: 'WC2026' },
create: { code: 'WC2026' },
update: {},
});
await upsertLeagueName(wc.id, {
'zh-CN': '2026世界杯(在加拿大,墨西哥和美国)',
'en-US': '2026 FIFA World Cup',
});
const teams: Array<[string, Record<string, string>]> = [
['MUN', { 'zh-CN': '曼联', 'en-US': 'Man United' }],
['CHE', { 'zh-CN': '切尔西', 'en-US': 'Chelsea' }],
['MEX', { 'zh-CN': '墨西哥', 'en-US': 'Mexico' }],
['RSA', { 'zh-CN': '南非', 'en-US': 'South Africa' }],
['CZE', { 'zh-CN': '捷克', 'en-US': 'Czech Republic' }],
['KOR', { 'zh-CN': '韩国', 'en-US': 'South Korea' }],
['CAN', { 'zh-CN': '加拿大', 'en-US': 'Canada' }],
['BIH', { 'zh-CN': '波黑', 'en-US': 'Bosnia' }],
['USA', { 'zh-CN': '美国', 'en-US': 'USA' }],
['PAR', { 'zh-CN': '巴拉圭', 'en-US': 'Paraguay' }],
['SUI', { 'zh-CN': '瑞士', 'en-US': 'Switzerland' }],
['BRA', { 'zh-CN': '巴西', 'en-US': 'Brazil' }],
['SCO', { 'zh-CN': '苏格兰', 'en-US': 'Scotland' }],
['TUR', { 'zh-CN': '土耳其', 'en-US': 'Turkey' }],
['ARG', { 'zh-CN': '阿根廷', 'en-US': 'Argentina' }],
['FRA', { 'zh-CN': '法国', 'en-US': 'France' }],
];
const teamMap = new Map<string, { id: bigint }>();
for (const [code, names] of teams) {
teamMap.set(code, await upsertTeam(code, names));
}
const get = (code: string) => {
const t = teamMap.get(code);
if (!t) throw new Error(`Team ${code} missing`);
return t;
};
// 英超:明日开赛 → 早盘
await ensurePublishedMatch({
leagueId: epl.id,
homeTeamId: get('MUN').id,
awayTeamId: get('CHE').id,
startTime: hoursFromNow(26),
isHot: true,
displayOrder: 1,
});
// 英超:今晚开赛 → 今日
await ensurePublishedMatch({
leagueId: epl.id,
homeTeamId: get('CHE').id,
awayTeamId: get('MUN').id,
startTime: hoursFromNow(8),
isHot: false,
displayOrder: 2,
});
const wcFixtures: Array<{
home: string;
away: string;
start: Date;
hot?: boolean;
order: number;
}> = [
{ home: 'MEX', away: 'RSA', start: new Date('2026-06-12T03:00:00Z'), hot: true, order: 1 },
{ home: 'CZE', away: 'KOR', start: new Date('2026-06-12T07:00:00Z'), order: 2 },
{ home: 'CAN', away: 'BIH', start: new Date('2026-06-13T00:00:00Z'), order: 3 },
{ home: 'USA', away: 'PAR', start: new Date('2026-06-13T03:00:00Z'), hot: true, order: 4 },
{ home: 'SUI', away: 'BRA', start: new Date('2026-06-14T16:00:00Z'), order: 5 },
{ home: 'SCO', away: 'TUR', start: new Date('2026-06-14T19:00:00Z'), order: 6 },
{ home: 'FRA', away: 'ARG', start: new Date('2026-06-15T20:00:00Z'), hot: true, order: 7 },
];
for (const f of wcFixtures) {
await ensurePublishedMatch({
leagueId: wc.id,
homeTeamId: get(f.home).id,
awayTeamId: get(f.away).id,
startTime: f.start,
isHot: f.hot,
displayOrder: f.order,
});
}
console.log(` Sports demo: ${wcFixtures.length + 2} published matches`);
}
async function seedOutrightDemo() {
const wc = await prisma.league.findUnique({ where: { code: 'WC2026' } });
if (!wc) return;
const { matchId, marketId } = await syncWc2026OutrightMarket(prisma, {
forceCanonical: true,
});
const count = await prisma.marketSelection.count({ where: { marketId } });
console.log(` WC2026 outright: match ${matchId}, ${count} selections`);
}
async function seedPlayerDemo() {
const player = await prisma.user.findUnique({
where: { username: 'player1' },
include: { wallet: true },
});
if (!player?.wallet) return;
await prisma.wallet.update({
where: { id: player.wallet.id },
data: { availableBalance: 88888.88 },
});
await prisma.walletTransaction.upsert({
where: { transactionId: 'DEMO-DEP-001' },
create: {
transactionId: 'DEMO-DEP-001',
userId: player.id,
walletId: player.wallet.id,
transactionType: 'DEPOSIT',
amount: 50000,
balanceBefore: 1000,
balanceAfter: 51000,
frozenBefore: 0,
frozenAfter: 0,
remark: '演示充值',
},
update: {},
});
await prisma.walletTransaction.upsert({
where: { transactionId: 'DEMO-DEP-002' },
create: {
transactionId: 'DEMO-DEP-002',
userId: player.id,
walletId: player.wallet.id,
transactionType: 'DEPOSIT',
amount: 37888.88,
balanceBefore: 51000,
balanceAfter: 88888.88,
frozenBefore: 0,
frozenAfter: 0,
remark: '演示充值(二笔)',
},
update: {},
});
const sampleSel = await prisma.marketSelection.findFirst({
where: {
status: 'OPEN',
market: { marketType: 'FT_1X2', match: { status: 'PUBLISHED' } },
},
include: { market: { include: { match: true } } },
});
if (sampleSel && !(await prisma.bet.findUnique({ where: { betNo: 'DEMO-BET-001' } }))) {
const odds = Number(sampleSel.odds);
const stake = 200;
await prisma.bet.create({
data: {
betNo: 'DEMO-BET-001',
userId: player.id,
agentId: player.parentId,
betType: 'SINGLE',
stake,
totalOdds: odds,
potentialReturn: stake * odds,
status: 'PENDING',
requestId: 'seed-demo-bet-001',
selections: {
create: {
matchId: sampleSel.market.matchId,
marketId: sampleSel.marketId,
selectionId: sampleSel.id,
marketType: sampleSel.market.marketType,
period: sampleSel.market.period,
selectionNameSnapshot: sampleSel.selectionName,
odds: sampleSel.odds,
oddsVersion: sampleSel.oddsVersion,
},
},
},
});
}
const settledSel = await prisma.marketSelection.findFirst({
where: {
market: { marketType: 'FT_1X2' },
selectionCode: 'DRAW',
},
include: { market: true },
});
if (settledSel && !(await prisma.bet.findUnique({ where: { betNo: 'DEMO-BET-002' } }))) {
const odds = Number(settledSel.odds);
const stake = 50;
await prisma.bet.create({
data: {
betNo: 'DEMO-BET-002',
userId: player.id,
agentId: player.parentId,
betType: 'SINGLE',
stake,
totalOdds: odds,
potentialReturn: stake * odds,
actualReturn: stake * odds,
status: 'WON',
settlementStatus: 'SETTLED',
settledAt: new Date(Date.now() - 86400000),
requestId: 'seed-demo-bet-002',
selections: {
create: {
matchId: settledSel.market.matchId,
marketId: settledSel.marketId,
selectionId: settledSel.id,
marketType: settledSel.market.marketType,
period: settledSel.market.period,
selectionNameSnapshot: settledSel.selectionName,
odds: settledSel.odds,
oddsVersion: settledSel.oddsVersion,
resultStatus: 'WIN',
effectiveOdds: settledSel.odds,
},
},
},
});
}
console.log(' Player demo: wallet + transactions + sample bets');
}
export async function runSeed(client: PrismaClient) {
prisma = client;
console.log('Seeding database...');
async function seedRolesAndConfig() {
const superAdminRole = await prisma.role.upsert({
where: { code: 'SUPER_ADMIN' },
create: { code: 'SUPER_ADMIN', name: 'Super Admin', description: 'Full access' },
@@ -579,10 +104,12 @@ export async function runSeed(client: PrismaClient) {
});
}
const hash = await bcrypt.hash('Admin@123', 10);
const agentHash = await bcrypt.hash('Agent@123', 10);
const playerHash = await bcrypt.hash('Player@123', 10);
return superAdminRole;
}
async function seedAdminUser(superAdminRole: { id: bigint }) {
const adminPassword = process.env.ADMIN_INITIAL_PASSWORD ?? 'Admin@123';
const hash = await bcrypt.hash(adminPassword, 10);
await prisma.user.upsert({
where: { username: 'admin' },
create: {
@@ -593,6 +120,11 @@ export async function runSeed(client: PrismaClient) {
},
update: {},
});
}
async function seedDevDemoUsers() {
const agentHash = await bcrypt.hash('Agent@123', 10);
const playerHash = await bcrypt.hash('Player@123', 10);
const agent1 = await prisma.user.upsert({
where: { username: 'agent1' },
@@ -651,7 +183,70 @@ export async function runSeed(client: PrismaClient) {
},
update: {},
});
}
async function seedPlayerDemo() {
const player = await prisma.user.findUnique({
where: { username: 'player1' },
include: { wallet: true },
});
if (!player?.wallet) return;
await prisma.wallet.update({
where: { id: player.wallet.id },
data: { availableBalance: 88888.88 },
});
await prisma.walletTransaction.upsert({
where: { transactionId: 'DEMO-DEP-001' },
create: {
transactionId: 'DEMO-DEP-001',
userId: player.id,
walletId: player.wallet.id,
transactionType: 'DEPOSIT',
amount: 50000,
balanceBefore: 1000,
balanceAfter: 51000,
frozenBefore: 0,
frozenAfter: 0,
remark: '演示充值',
},
update: {},
});
await prisma.walletTransaction.upsert({
where: { transactionId: 'DEMO-DEP-002' },
create: {
transactionId: 'DEMO-DEP-002',
userId: player.id,
walletId: player.wallet.id,
transactionType: 'DEPOSIT',
amount: 37888.88,
balanceBefore: 51000,
balanceAfter: 88888.88,
frozenBefore: 0,
frozenAfter: 0,
remark: '演示充值(二笔)',
},
update: {},
});
const legacyDemoBets = await prisma.bet.findMany({
where: { betNo: { in: ['DEMO-BET-001', 'DEMO-BET-002'] } },
select: { id: true },
});
if (legacyDemoBets.length > 0) {
const betIds = legacyDemoBets.map((b) => b.id);
await prisma.settlementItem.deleteMany({ where: { betId: { in: betIds } } });
await prisma.cashbackBet.deleteMany({ where: { betId: { in: betIds } } });
await prisma.betSelection.deleteMany({ where: { betId: { in: betIds } } });
await prisma.bet.deleteMany({ where: { id: { in: betIds } } });
}
console.log(' Player demo: wallet + transactions (no catalog demo bets)');
}
async function seedI18nMessages() {
const messages = [
{ key: 'nav.home', zh: '首页', ms: 'Laman Utama', en: 'Home' },
{ key: 'nav.football', zh: '足球', ms: 'Bola Sepak', en: 'Football' },
@@ -668,11 +263,9 @@ export async function runSeed(client: PrismaClient) {
});
}
}
}
await seedSportsDemo();
await seedOutrightDemo();
await seedPlayerDemo();
async function seedDefaultSiteContent() {
await prisma.content.create({
data: {
contentType: 'BANNER',
@@ -746,7 +339,9 @@ export async function runSeed(client: PrismaClient) {
},
},
}).catch(() => {});
}
async function ensureStaffInviteCodes() {
const staffWithoutInvite = await prisma.user.findMany({
where: { userType: { in: ['ADMIN', 'AGENT'] }, inviteCode: null },
select: { id: true },
@@ -754,6 +349,30 @@ export async function runSeed(client: PrismaClient) {
for (const row of staffWithoutInvite) {
await ensureUserInviteCode(prisma, row.id);
}
console.log(`Seed completed! ${DEMO_ACCOUNTS.join(', ')}`);
}
export async function runSeed(client: PrismaClient, options?: RunSeedOptions) {
prisma = client;
const mode = resolveSeedMode(options);
console.log(`Seeding database (mode=${mode})...`);
const superAdminRole = await seedRolesAndConfig();
await seedAdminUser(superAdminRole);
if (mode === 'dev') {
await seedDevDemoUsers();
}
await seedI18nMessages();
await seedCatalog(prisma);
if (mode === 'dev') {
await seedPlayerDemo();
}
await seedDefaultSiteContent();
await ensureStaffInviteCodes();
const accounts = resolveSeedAccounts(mode);
console.log(`Seed completed! ${accounts.join(', ')}`);
}

View File

@@ -0,0 +1,350 @@
import type { PrismaClient } from '@prisma/client';
import { syncWc2026OutrightMarket } from '../../domains/catalog/wc2026-outright.sync';
import {
WC2026_LEAGUE_CODE,
WC2026_OUTRIGHT_TEAMS,
} from '../../domains/catalog/wc2026-outright-teams';
import type { ZhiboMatchExport, ZhiboTeamExport } from '../../domains/catalog/zhibo-match.types';
import {
leagueCodeFromExport,
resolveInternalStatus,
resolveIsHot,
resolveStartTime,
teamCodeFromExport,
toKickoffJson,
toVenueJson,
translationsFromZhiboNames,
} from '../../domains/catalog/zhibo-match.mapper';
import {
WC2026_GROUP_STAGE_BUNDLE,
WC2026_TEAM_LOGO_BY_CODE,
WC2026_ZIBO_ID_TO_CODE,
} from './seed-data';
import { seedDemoMarkets } from './seed-demo-markets';
export { seedDemoMarkets };
/** 旧版演示联赛(已从 seed 移除,增量 seed 时软删其赛事) */
const LEGACY_DEMO_LEAGUE_CODES = ['EPL'] as const;
const CATALOG_PUBLISHABLE_STATUSES = ['DRAFT', 'PUBLISHED'] as const;
function getSeedLiveMatchIds(): bigint[] {
return (WC2026_GROUP_STAGE_BUNDLE.matches ?? [])
.filter((m) => m.liveMatchId != null)
.map((m) => BigInt(m.liveMatchId!));
}
async function softDeleteLegacyDemoLeagueMatches(prisma: PrismaClient) {
const leagues = await prisma.league.findMany({
where: { code: { in: [...LEGACY_DEMO_LEAGUE_CODES] } },
select: { id: true },
});
if (leagues.length === 0) return;
const leagueIds = leagues.map((l) => l.id);
await prisma.match.updateMany({
where: { leagueId: { in: leagueIds }, deletedAt: null },
data: { deletedAt: new Date() },
});
await prisma.league.updateMany({
where: { id: { in: leagueIds } },
data: { isActive: false },
});
}
async function softDeleteStaleWc2026GroupMatches(prisma: PrismaClient, leagueId: bigint) {
const seedIds = getSeedLiveMatchIds();
await prisma.match.updateMany({
where: {
leagueId,
isOutright: false,
deletedAt: null,
OR: [{ liveMatchId: null }, { liveMatchId: { notIn: seedIds } }],
},
data: { deletedAt: new Date() },
});
}
async function purgeMatchCatalogBettingData(prisma: PrismaClient, matchIds: bigint[]) {
if (matchIds.length === 0) return;
const selections = await prisma.betSelection.findMany({
where: { matchId: { in: matchIds } },
select: { betId: true },
});
const betIds = [...new Set(selections.map((s) => s.betId))];
if (betIds.length > 0) {
await prisma.settlementItem.deleteMany({ where: { betId: { in: betIds } } });
await prisma.cashbackBet.deleteMany({ where: { betId: { in: betIds } } });
await prisma.betSelection.deleteMany({ where: { betId: { in: betIds } } });
await prisma.bet.deleteMany({ where: { id: { in: betIds } } });
}
await prisma.settlementBatch.deleteMany({ where: { matchId: { in: matchIds } } });
await prisma.matchScore.deleteMany({ where: { matchId: { in: matchIds } } });
}
/** 增量 seed清理 legacy 赛事、移除 WC2026 下注/结算/比分,保证仅保留 seed 包内小组赛 */
async function resetWc2026CatalogState(prisma: PrismaClient, leagueId: bigint) {
await softDeleteLegacyDemoLeagueMatches(prisma);
await softDeleteStaleWc2026GroupMatches(prisma, leagueId);
const activeMatches = await prisma.match.findMany({
where: { leagueId, deletedAt: null },
select: { id: true },
});
await purgeMatchCatalogBettingData(
prisma,
activeMatches.map((m) => m.id),
);
}
async function normalizeWc2026OutrightMatch(prisma: PrismaClient, leagueId: bigint) {
const outright = await prisma.match.findFirst({
where: { leagueId, isOutright: true, deletedAt: null },
});
if (!outright) return;
if (!CATALOG_PUBLISHABLE_STATUSES.includes(outright.status as (typeof CATALOG_PUBLISHABLE_STATUSES)[number])) {
await prisma.match.update({
where: { id: outright.id },
data: {
status: 'PUBLISHED',
publishTime: outright.publishTime ?? new Date(),
closeTime: null,
},
});
}
await prisma.matchScore.deleteMany({ where: { matchId: outright.id } });
}
/** 清除 seed 后仍残留的已结算/已封盘等非发布态(小组赛 upsert 已写入 DRAFT/PUBLISHED */
async function ensureWc2026PublishableStatuses(prisma: PrismaClient, leagueId: bigint) {
await prisma.match.updateMany({
where: {
leagueId,
deletedAt: null,
status: { notIn: [...CATALOG_PUBLISHABLE_STATUSES] },
},
data: {
status: 'PUBLISHED',
closeTime: null,
publishTime: new Date(),
},
});
}
async function upsertEntityTranslations(
prisma: PrismaClient,
entityType: 'LEAGUE' | 'TEAM',
entityId: bigint,
translations: Record<string, string>,
) {
for (const [locale, value] of Object.entries(translations)) {
await prisma.entityTranslation.upsert({
where: {
entityType_entityId_locale_fieldName: {
entityType,
entityId,
locale,
fieldName: 'name',
},
},
create: { entityType, entityId, locale, fieldName: 'name', value },
update: { value },
});
}
}
export async function seedWc2026League(prisma: PrismaClient) {
const league = await prisma.league.upsert({
where: { code: WC2026_LEAGUE_CODE },
create: { code: WC2026_LEAGUE_CODE, sportType: 'FOOTBALL', isActive: true },
update: { sportType: 'FOOTBALL', isActive: true },
});
await upsertEntityTranslations(prisma, 'LEAGUE', league.id, {
'zh-CN': '2026世界杯(在加拿大,墨西哥和美国)',
'en-US': '2026 FIFA World Cup',
});
return league;
}
export async function seedWc2026Teams(prisma: PrismaClient) {
for (const entry of WC2026_OUTRIGHT_TEAMS) {
const logoUrl = WC2026_TEAM_LOGO_BY_CODE[entry.code] ?? null;
const externalIdEntry = Object.entries(WC2026_ZIBO_ID_TO_CODE).find(([, code]) => code === entry.code);
const externalId = externalIdEntry ? Number(externalIdEntry[0]) : undefined;
const team = await prisma.team.upsert({
where: { code: entry.code },
create: {
code: entry.code,
externalId,
logoUrl,
},
update: {
externalId: externalId ?? undefined,
logoUrl: logoUrl ?? undefined,
},
});
await upsertEntityTranslations(prisma, 'TEAM', team.id, entry.names);
}
}
async function upsertWc2026TeamFromZhibo(prisma: PrismaClient, team: ZhiboTeamExport) {
const canonicalCode =
team.id != null ? WC2026_ZIBO_ID_TO_CODE[team.id] : undefined;
const outrightEntry = canonicalCode
? WC2026_OUTRIGHT_TEAMS.find((t) => t.code === canonicalCode)
: undefined;
const code = canonicalCode ?? teamCodeFromExport(team);
const zhiboTranslations = translationsFromZhiboNames(team.names, team.name);
const translations = outrightEntry
? { ...outrightEntry.names, ...zhiboTranslations }
: zhiboTranslations;
const logoUrl =
(canonicalCode && WC2026_TEAM_LOGO_BY_CODE[canonicalCode]) ||
team.image ||
undefined;
const record = await prisma.team.upsert({
where: { code },
create: {
code,
externalId: team.id ?? undefined,
logoUrl,
},
update: {
externalId: team.id ?? undefined,
logoUrl: logoUrl ?? undefined,
},
});
await upsertEntityTranslations(prisma, 'TEAM', record.id, translations);
return record;
}
async function findExistingZhiboMatch(
prisma: PrismaClient,
leagueId: bigint,
homeTeamId: bigint,
awayTeamId: bigint,
item: ZhiboMatchExport,
) {
if (item.liveMatchId != null) {
return prisma.match.findUnique({
where: { liveMatchId: BigInt(item.liveMatchId) },
});
}
if (item.officialMatchNo != null) {
return prisma.match.findFirst({
where: {
leagueId,
homeTeamId,
awayTeamId,
officialMatchNo: item.officialMatchNo,
},
});
}
return null;
}
async function upsertWc2026GroupMatch(prisma: PrismaClient, item: ZhiboMatchExport, leagueId: bigint) {
const [homeTeam, awayTeam] = await Promise.all([
upsertWc2026TeamFromZhibo(prisma, item.homeTeam),
upsertWc2026TeamFromZhibo(prisma, item.awayTeam),
]);
const status = resolveInternalStatus(item);
const startTime = resolveStartTime(item.kickoff);
const liveMatchId = item.liveMatchId != null ? BigInt(item.liveMatchId) : undefined;
const matchData = {
leagueId,
homeTeamId: homeTeam.id,
awayTeamId: awayTeam.id,
startTime,
isHot: resolveIsHot(item),
displayOrder: item.sortOrder,
status,
publishTime: status === 'PUBLISHED' ? new Date() : undefined,
officialMatchNo: item.officialMatchNo,
stage: item.stage,
groupName: item.groupName,
liveMatchId,
additionMatchId: item.additionMatchId != null ? BigInt(item.additionMatchId) : null,
channelId: item.channelId,
matchName: item.matchName,
venueJson: toVenueJson(item.venue),
kickoffJson: toKickoffJson(item.kickoff),
externalStatus: item.status.state,
};
const existing = await findExistingZhiboMatch(
prisma,
leagueId,
homeTeam.id,
awayTeam.id,
item,
);
const match = existing
? await prisma.match.update({
where: { id: existing.id },
data: {
...matchData,
deletedAt: null,
closeTime: null,
publishTime: status === 'PUBLISHED' ? (existing.publishTime ?? matchData.publishTime ?? new Date()) : null,
},
})
: await prisma.match.create({ data: matchData });
await prisma.matchScore.deleteMany({ where: { matchId: match.id } });
await seedDemoMarkets(prisma, match.id);
return match;
}
export async function seedWc2026GroupMatches(prisma: PrismaClient, leagueId: bigint) {
const matches = WC2026_GROUP_STAGE_BUNDLE.matches ?? [];
let created = 0;
let updated = 0;
for (const item of matches) {
const code = leagueCodeFromExport(item.league);
if (code !== WC2026_LEAGUE_CODE) {
throw new Error(`Unexpected league in seed bundle: ${item.league.en}`);
}
const before = item.liveMatchId != null
? await prisma.match.findUnique({ where: { liveMatchId: BigInt(item.liveMatchId) } })
: null;
await upsertWc2026GroupMatch(prisma, item, leagueId);
if (before) updated += 1;
else created += 1;
}
return { total: matches.length, created, updated };
}
export async function seedWc2026Outright(prisma: PrismaClient) {
const { matchId, marketId } = await syncWc2026OutrightMarket(prisma, {
forceCanonical: true,
});
const count = await prisma.marketSelection.count({ where: { marketId } });
return { matchId, marketId, selectionCount: count };
}
export async function seedCatalog(prisma: PrismaClient) {
const league = await seedWc2026League(prisma);
await seedWc2026Teams(prisma);
await resetWc2026CatalogState(prisma, league.id);
const groupResult = await seedWc2026GroupMatches(prisma, league.id);
const outrightResult = await seedWc2026Outright(prisma);
await normalizeWc2026OutrightMatch(prisma, league.id);
await ensureWc2026PublishableStatuses(prisma, league.id);
console.log(
` WC2026 catalog: ${groupResult.total} group matches (${groupResult.created} new, ${groupResult.updated} updated), outright ${outrightResult.selectionCount} selections`,
);
return { league, groupResult, outrightResult };
}

View File

@@ -1,9 +1,10 @@
import { PrismaClient } from '@prisma/client';
import { runSeed } from './run-seed';
import { resolveSeedMode, runSeed } from './run-seed';
const prisma = new PrismaClient();
const mode = resolveSeedMode();
runSeed(prisma)
runSeed(prisma, { mode })
.catch((err) => {
console.error(err);
process.exit(1);

View File

@@ -0,0 +1,10 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import type { ZhiboMatchesBundleExport } from '../../../domains/catalog/zhibo-match.types';
export { WC2026_ZIBO_ID_TO_CODE, WC2026_TEAM_LOGO_BY_CODE } from './wc2026-zhibo-team-map';
const bundlePath = path.join(__dirname, 'wc2026-group-stage.json');
export const WC2026_GROUP_STAGE_BUNDLE = JSON.parse(
fs.readFileSync(bundlePath, 'utf8'),
) as ZhiboMatchesBundleExport;

View File

@@ -0,0 +1,5189 @@
{
"count": 72,
"matches": [
{
"officialMatchNo": 1,
"stage": "group",
"groupName": "A",
"liveMatchId": 20148965,
"additionMatchId": 20260001,
"channelId": null,
"matchName": "Mexico - South Africa",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781204400,
"utcTimeStop": 1781211600,
"utcIso": "2026-06-11T19:00:00Z",
"chinaTime": "2026-06-12 03:00:00 Asia/Shanghai",
"venueTime": "2026-06-11 13:00:00 America/Mexico_City",
"venueTimezone": "America/Mexico_City"
},
"homeTeam": {
"id": 11188,
"name": "Mexico",
"names": {
"zh": "墨西哥",
"en": "Mexico",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018908829.png"
},
"awayTeam": {
"id": 11173,
"name": "South Africa",
"names": {
"zh": "南非",
"en": "South Africa",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018843635.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Estadio Azteca",
"en": "Estadio Azteca",
"zhTw": null,
"vi": "Estadio Azteca",
"km": "Estadio Azteca",
"ms": "Estadio Azteca"
},
"city": {
"zh": "墨西哥城",
"en": "Mexico City",
"zhTw": null,
"vi": "Mexico City",
"km": "Mexico City",
"ms": "Mexico City"
}
},
"sortOrder": 1,
"isPublished": true
},
{
"officialMatchNo": 2,
"stage": "group",
"groupName": "A",
"liveMatchId": 20148822,
"additionMatchId": 20260002,
"channelId": null,
"matchName": "South Korea - Czechia",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781229600,
"utcTimeStop": 1781236800,
"utcIso": "2026-06-12T02:00:00Z",
"chinaTime": "2026-06-12 10:00:00 Asia/Shanghai",
"venueTime": "2026-06-11 20:00:00 America/Mexico_City",
"venueTimezone": "America/Mexico_City"
},
"homeTeam": {
"id": 11261,
"name": "South Korea",
"names": {
"zh": "韩国",
"en": "South Korea",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164974972753.png"
},
"awayTeam": {
"id": 50467,
"name": "Czechia",
"names": {
"zh": "捷克",
"en": "Czechia",
"zhTw": "捷克",
"vi": "Séc",
"km": "ឆែក",
"ms": "Czechia"
},
"image": "https://flagcdn.com/cz.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Estadio Akron",
"en": "Estadio Akron",
"zhTw": null,
"vi": "Estadio Akron",
"km": "Estadio Akron",
"ms": "Estadio Akron"
},
"city": {
"zh": "瓜达拉哈拉",
"en": "Guadalajara",
"zhTw": null,
"vi": "Guadalajara",
"km": "Guadalajara",
"ms": "Guadalajara"
}
},
"sortOrder": 2,
"isPublished": true
},
{
"officialMatchNo": 25,
"stage": "group",
"groupName": "A",
"liveMatchId": 20148794,
"additionMatchId": 20260025,
"channelId": null,
"matchName": "Czechia - South Africa",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781798400,
"utcTimeStop": 1781805600,
"utcIso": "2026-06-18T16:00:00Z",
"chinaTime": "2026-06-19 00:00:00 Asia/Shanghai",
"venueTime": "2026-06-18 12:00:00 America/New_York",
"venueTimezone": "America/New_York"
},
"homeTeam": {
"id": 50467,
"name": "Czechia",
"names": {
"zh": "捷克",
"en": "Czechia",
"zhTw": "捷克",
"vi": "Séc",
"km": "ឆែក",
"ms": "Czechia"
},
"image": "https://flagcdn.com/cz.svg"
},
"awayTeam": {
"id": 11173,
"name": "South Africa",
"names": {
"zh": "南非",
"en": "South Africa",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018843635.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Mercedes-Benz Stadium",
"en": "Mercedes-Benz Stadium",
"zhTw": null,
"vi": "Mercedes-Benz Stadium",
"km": "Mercedes-Benz Stadium",
"ms": "Mercedes-Benz Stadium"
},
"city": {
"zh": "亚特兰大",
"en": "Atlanta",
"zhTw": null,
"vi": "Atlanta",
"km": "Atlanta",
"ms": "Atlanta"
}
},
"sortOrder": 25,
"isPublished": true
},
{
"officialMatchNo": 28,
"stage": "group",
"groupName": "A",
"liveMatchId": 20148739,
"additionMatchId": 20260028,
"channelId": null,
"matchName": "Mexico - South Korea",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781830800,
"utcTimeStop": 1781838000,
"utcIso": "2026-06-19T01:00:00Z",
"chinaTime": "2026-06-19 09:00:00 Asia/Shanghai",
"venueTime": "2026-06-18 19:00:00 America/Mexico_City",
"venueTimezone": "America/Mexico_City"
},
"homeTeam": {
"id": 11188,
"name": "Mexico",
"names": {
"zh": "墨西哥",
"en": "Mexico",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018908829.png"
},
"awayTeam": {
"id": 11261,
"name": "South Korea",
"names": {
"zh": "韩国",
"en": "South Korea",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164974972753.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Estadio Akron",
"en": "Estadio Akron",
"zhTw": null,
"vi": "Estadio Akron",
"km": "Estadio Akron",
"ms": "Estadio Akron"
},
"city": {
"zh": "瓜达拉哈拉",
"en": "Guadalajara",
"zhTw": null,
"vi": "Guadalajara",
"km": "Guadalajara",
"ms": "Guadalajara"
}
},
"sortOrder": 28,
"isPublished": true
},
{
"officialMatchNo": 49,
"stage": "group",
"groupName": "A",
"liveMatchId": 20148795,
"additionMatchId": 20260049,
"channelId": null,
"matchName": "Czechia - Mexico",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782349200,
"utcTimeStop": 1782356400,
"utcIso": "2026-06-25T01:00:00Z",
"chinaTime": "2026-06-25 09:00:00 Asia/Shanghai",
"venueTime": "2026-06-24 19:00:00 America/Mexico_City",
"venueTimezone": "America/Mexico_City"
},
"homeTeam": {
"id": 50467,
"name": "Czechia",
"names": {
"zh": "捷克",
"en": "Czechia",
"zhTw": "捷克",
"vi": "Séc",
"km": "ឆែក",
"ms": "Czechia"
},
"image": "https://flagcdn.com/cz.svg"
},
"awayTeam": {
"id": 11188,
"name": "Mexico",
"names": {
"zh": "墨西哥",
"en": "Mexico",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018908829.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Estadio Azteca",
"en": "Estadio Azteca",
"zhTw": null,
"vi": "Estadio Azteca",
"km": "Estadio Azteca",
"ms": "Estadio Azteca"
},
"city": {
"zh": "墨西哥城",
"en": "Mexico City",
"zhTw": null,
"vi": "Mexico City",
"km": "Mexico City",
"ms": "Mexico City"
}
},
"sortOrder": 49,
"isPublished": true
},
{
"officialMatchNo": 50,
"stage": "group",
"groupName": "A",
"liveMatchId": 20148740,
"additionMatchId": 20260050,
"channelId": null,
"matchName": "South Africa - South Korea",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782349200,
"utcTimeStop": 1782356400,
"utcIso": "2026-06-25T01:00:00Z",
"chinaTime": "2026-06-25 09:00:00 Asia/Shanghai",
"venueTime": "2026-06-24 19:00:00 America/Mexico_City",
"venueTimezone": "America/Mexico_City"
},
"homeTeam": {
"id": 11173,
"name": "South Africa",
"names": {
"zh": "南非",
"en": "South Africa",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018843635.png"
},
"awayTeam": {
"id": 11261,
"name": "South Korea",
"names": {
"zh": "韩国",
"en": "South Korea",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164974972753.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Estadio BBVA",
"en": "Estadio BBVA",
"zhTw": null,
"vi": "Estadio BBVA",
"km": "Estadio BBVA",
"ms": "Estadio BBVA"
},
"city": {
"zh": "蒙特雷",
"en": "Monterrey",
"zhTw": null,
"vi": "Monterrey",
"km": "Monterrey",
"ms": "Monterrey"
}
},
"sortOrder": 50,
"isPublished": true
},
{
"officialMatchNo": 3,
"stage": "group",
"groupName": "B",
"liveMatchId": 20148741,
"additionMatchId": 20260003,
"channelId": null,
"matchName": "Canada - Bosnia and Herzegovina",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781290800,
"utcTimeStop": 1781298000,
"utcIso": "2026-06-12T19:00:00Z",
"chinaTime": "2026-06-13 03:00:00 Asia/Shanghai",
"venueTime": "2026-06-12 15:00:00 America/Toronto",
"venueTimezone": "America/Toronto"
},
"homeTeam": {
"id": 11166,
"name": "Canada",
"names": {
"zh": "加拿大",
"en": "Canada",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018903917.png"
},
"awayTeam": {
"id": 11153,
"name": "Bosnia and Herzegovina",
"names": {
"zh": "波黑",
"en": "Bosnia and Herzegovina",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983517667.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "BMO Field",
"en": "BMO Field",
"zhTw": null,
"vi": "BMO Field",
"km": "BMO Field",
"ms": "BMO Field"
},
"city": {
"zh": "多伦多",
"en": "Toronto",
"zhTw": null,
"vi": "Toronto",
"km": "Toronto",
"ms": "Toronto"
}
},
"sortOrder": 3,
"isPublished": true
},
{
"officialMatchNo": 5,
"stage": "group",
"groupName": "B",
"liveMatchId": 20148742,
"additionMatchId": 20260005,
"channelId": null,
"matchName": "Qatar - Switzerland",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781377200,
"utcTimeStop": 1781384400,
"utcIso": "2026-06-13T19:00:00Z",
"chinaTime": "2026-06-14 03:00:00 Asia/Shanghai",
"venueTime": "2026-06-13 12:00:00 America/Los_Angeles",
"venueTimezone": "America/Los_Angeles"
},
"homeTeam": {
"id": 11267,
"name": "Qatar",
"names": {
"zh": "卡塔尔",
"en": "Qatar",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166968529845.png"
},
"awayTeam": {
"id": 11036,
"name": "Switzerland",
"names": {
"zh": "瑞士",
"en": "Switzerland",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983533378.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Levi's Stadium",
"en": "Levi's Stadium",
"zhTw": null,
"vi": "Levi's Stadium",
"km": "Levi's Stadium",
"ms": "Levi's Stadium"
},
"city": {
"zh": "San Francisco Bay Area",
"en": "San Francisco Bay Area",
"zhTw": null,
"vi": "San Francisco Bay Area",
"km": "San Francisco Bay Area",
"ms": "San Francisco Bay Area"
}
},
"sortOrder": 5,
"isPublished": true
},
{
"officialMatchNo": 26,
"stage": "group",
"groupName": "B",
"liveMatchId": 20148743,
"additionMatchId": 20260026,
"channelId": null,
"matchName": "Switzerland - Bosnia and Herzegovina",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781809200,
"utcTimeStop": 1781816400,
"utcIso": "2026-06-18T19:00:00Z",
"chinaTime": "2026-06-19 03:00:00 Asia/Shanghai",
"venueTime": "2026-06-18 12:00:00 America/Los_Angeles",
"venueTimezone": "America/Los_Angeles"
},
"homeTeam": {
"id": 11036,
"name": "Switzerland",
"names": {
"zh": "瑞士",
"en": "Switzerland",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983533378.png"
},
"awayTeam": {
"id": 11153,
"name": "Bosnia and Herzegovina",
"names": {
"zh": "波黑",
"en": "Bosnia and Herzegovina",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983517667.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "SoFi Stadium",
"en": "SoFi Stadium",
"zhTw": null,
"vi": "SoFi Stadium",
"km": "SoFi Stadium",
"ms": "SoFi Stadium"
},
"city": {
"zh": "洛杉矶",
"en": "Los Angeles",
"zhTw": null,
"vi": "Los Angeles",
"km": "Los Angeles",
"ms": "Los Angeles"
}
},
"sortOrder": 26,
"isPublished": true
},
{
"officialMatchNo": 27,
"stage": "group",
"groupName": "B",
"liveMatchId": 20148966,
"additionMatchId": 20260027,
"channelId": null,
"matchName": "Canada - Qatar",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781820000,
"utcTimeStop": 1781827200,
"utcIso": "2026-06-18T22:00:00Z",
"chinaTime": "2026-06-19 06:00:00 Asia/Shanghai",
"venueTime": "2026-06-18 15:00:00 America/Los_Angeles",
"venueTimezone": "America/Los_Angeles"
},
"homeTeam": {
"id": 11166,
"name": "Canada",
"names": {
"zh": "加拿大",
"en": "Canada",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018903917.png"
},
"awayTeam": {
"id": 11267,
"name": "Qatar",
"names": {
"zh": "卡塔尔",
"en": "Qatar",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166968529845.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "BC Place",
"en": "BC Place",
"zhTw": null,
"vi": "BC Place",
"km": "BC Place",
"ms": "BC Place"
},
"city": {
"zh": "温哥华",
"en": "Vancouver",
"zhTw": null,
"vi": "Vancouver",
"km": "Vancouver",
"ms": "Vancouver"
}
},
"sortOrder": 27,
"isPublished": true
},
{
"officialMatchNo": 51,
"stage": "group",
"groupName": "B",
"liveMatchId": 20148745,
"additionMatchId": 20260051,
"channelId": null,
"matchName": "Switzerland - Canada",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782327600,
"utcTimeStop": 1782334800,
"utcIso": "2026-06-24T19:00:00Z",
"chinaTime": "2026-06-25 03:00:00 Asia/Shanghai",
"venueTime": "2026-06-24 12:00:00 America/Los_Angeles",
"venueTimezone": "America/Los_Angeles"
},
"homeTeam": {
"id": 11036,
"name": "Switzerland",
"names": {
"zh": "瑞士",
"en": "Switzerland",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983533378.png"
},
"awayTeam": {
"id": 11166,
"name": "Canada",
"names": {
"zh": "加拿大",
"en": "Canada",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018903917.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "BC Place",
"en": "BC Place",
"zhTw": null,
"vi": "BC Place",
"km": "BC Place",
"ms": "BC Place"
},
"city": {
"zh": "温哥华",
"en": "Vancouver",
"zhTw": null,
"vi": "Vancouver",
"km": "Vancouver",
"ms": "Vancouver"
}
},
"sortOrder": 51,
"isPublished": true
},
{
"officialMatchNo": 52,
"stage": "group",
"groupName": "B",
"liveMatchId": 20148746,
"additionMatchId": null,
"channelId": null,
"matchName": "Bosnia and Herzegovina - Qatar",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782327600,
"utcTimeStop": 1782334800,
"utcIso": "2026-06-24T19:00:00Z",
"chinaTime": "2026-06-25 03:00:00 Asia/Shanghai",
"venueTime": "2026-06-24 12:00:00 America/Los_Angeles",
"venueTimezone": "America/Los_Angeles"
},
"homeTeam": {
"id": null,
"name": "Bosnia and Herzegovina",
"names": {
"zh": null,
"en": null,
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": ""
},
"awayTeam": {
"id": null,
"name": "Qatar",
"names": {
"zh": null,
"en": null,
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": ""
},
"status": {
"state": "off",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Lumen Field",
"en": "Lumen Field",
"zhTw": null,
"vi": "Lumen Field",
"km": "Lumen Field",
"ms": "Lumen Field"
},
"city": {
"zh": "西雅图",
"en": "Seattle",
"zhTw": null,
"vi": "Seattle",
"km": "Seattle",
"ms": "Seattle"
}
},
"sortOrder": 52,
"isPublished": true
},
{
"officialMatchNo": 6,
"stage": "group",
"groupName": "C",
"liveMatchId": 20148747,
"additionMatchId": 20260006,
"channelId": null,
"matchName": "Brazil - Morocco",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781388000,
"utcTimeStop": 1781395200,
"utcIso": "2026-06-13T22:00:00Z",
"chinaTime": "2026-06-14 06:00:00 Asia/Shanghai",
"venueTime": "2026-06-13 18:00:00 America/New_York",
"venueTimezone": "America/New_York"
},
"homeTeam": {
"id": 11150,
"name": "Brazil",
"names": {
"zh": "巴西",
"en": "Brazil",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018879430.png"
},
"awayTeam": {
"id": 11182,
"name": "Morocco",
"names": {
"zh": "摩洛哥",
"en": "Morocco",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1hkv9bnt8sv.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "MetLife Stadium",
"en": "MetLife Stadium",
"zhTw": null,
"vi": "MetLife Stadium",
"km": "MetLife Stadium",
"ms": "MetLife Stadium"
},
"city": {
"zh": "纽约/新泽西",
"en": "New York / New Jersey",
"zhTw": null,
"vi": "New York / New Jersey",
"km": "New York / New Jersey",
"ms": "New York / New Jersey"
}
},
"sortOrder": 6,
"isPublished": true
},
{
"officialMatchNo": 7,
"stage": "group",
"groupName": "C",
"liveMatchId": 20148748,
"additionMatchId": 20260007,
"channelId": null,
"matchName": "Haiti - Scotland",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781398800,
"utcTimeStop": 1781406000,
"utcIso": "2026-06-14T01:00:00Z",
"chinaTime": "2026-06-14 09:00:00 Asia/Shanghai",
"venueTime": "2026-06-13 21:00:00 America/New_York",
"venueTimezone": "America/New_York"
},
"homeTeam": {
"id": 11270,
"name": "Haiti",
"names": {
"zh": "海地",
"en": "Haiti",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018900888.png"
},
"awayTeam": {
"id": 11030,
"name": "Scotland",
"names": {
"zh": "苏格兰",
"en": "Scotland",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164984175759.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Gillette Stadium",
"en": "Gillette Stadium",
"zhTw": null,
"vi": "Gillette Stadium",
"km": "Gillette Stadium",
"ms": "Gillette Stadium"
},
"city": {
"zh": "波士顿",
"en": "Boston",
"zhTw": null,
"vi": "Boston",
"km": "Boston",
"ms": "Boston"
}
},
"sortOrder": 7,
"isPublished": true
},
{
"officialMatchNo": 29,
"stage": "group",
"groupName": "C",
"liveMatchId": 20148749,
"additionMatchId": 20260029,
"channelId": null,
"matchName": "Scotland - Morocco",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781906400,
"utcTimeStop": 1781913600,
"utcIso": "2026-06-19T22:00:00Z",
"chinaTime": "2026-06-20 06:00:00 Asia/Shanghai",
"venueTime": "2026-06-19 18:00:00 America/New_York",
"venueTimezone": "America/New_York"
},
"homeTeam": {
"id": 11030,
"name": "Scotland",
"names": {
"zh": "苏格兰",
"en": "Scotland",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164984175759.png"
},
"awayTeam": {
"id": 11182,
"name": "Morocco",
"names": {
"zh": "摩洛哥",
"en": "Morocco",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1hkv9bnt8sv.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Gillette Stadium",
"en": "Gillette Stadium",
"zhTw": null,
"vi": "Gillette Stadium",
"km": "Gillette Stadium",
"ms": "Gillette Stadium"
},
"city": {
"zh": "波士顿",
"en": "Boston",
"zhTw": null,
"vi": "Boston",
"km": "Boston",
"ms": "Boston"
}
},
"sortOrder": 29,
"isPublished": true
},
{
"officialMatchNo": 31,
"stage": "group",
"groupName": "C",
"liveMatchId": 20148750,
"additionMatchId": 20260031,
"channelId": null,
"matchName": "Brazil - Haiti",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781917200,
"utcTimeStop": 1781924400,
"utcIso": "2026-06-20T01:00:00Z",
"chinaTime": "2026-06-20 09:00:00 Asia/Shanghai",
"venueTime": "2026-06-19 21:00:00 America/New_York",
"venueTimezone": "America/New_York"
},
"homeTeam": {
"id": 11150,
"name": "Brazil",
"names": {
"zh": "巴西",
"en": "Brazil",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018879430.png"
},
"awayTeam": {
"id": 11270,
"name": "Haiti",
"names": {
"zh": "海地",
"en": "Haiti",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018900888.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Lincoln Financial Field",
"en": "Lincoln Financial Field",
"zhTw": null,
"vi": "Lincoln Financial Field",
"km": "Lincoln Financial Field",
"ms": "Lincoln Financial Field"
},
"city": {
"zh": "费城",
"en": "Philadelphia",
"zhTw": null,
"vi": "Philadelphia",
"km": "Philadelphia",
"ms": "Philadelphia"
}
},
"sortOrder": 31,
"isPublished": true
},
{
"officialMatchNo": 53,
"stage": "group",
"groupName": "C",
"liveMatchId": 20148751,
"additionMatchId": 20260053,
"channelId": null,
"matchName": "Scotland - Brazil",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782338400,
"utcTimeStop": 1782345600,
"utcIso": "2026-06-24T22:00:00Z",
"chinaTime": "2026-06-25 06:00:00 Asia/Shanghai",
"venueTime": "2026-06-24 18:00:00 America/New_York",
"venueTimezone": "America/New_York"
},
"homeTeam": {
"id": 11030,
"name": "Scotland",
"names": {
"zh": "苏格兰",
"en": "Scotland",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164984175759.png"
},
"awayTeam": {
"id": 11150,
"name": "Brazil",
"names": {
"zh": "巴西",
"en": "Brazil",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018879430.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Hard Rock Stadium",
"en": "Hard Rock Stadium",
"zhTw": null,
"vi": "Hard Rock Stadium",
"km": "Hard Rock Stadium",
"ms": "Hard Rock Stadium"
},
"city": {
"zh": "迈阿密",
"en": "Miami",
"zhTw": null,
"vi": "Miami",
"km": "Miami",
"ms": "Miami"
}
},
"sortOrder": 53,
"isPublished": true
},
{
"officialMatchNo": 54,
"stage": "group",
"groupName": "C",
"liveMatchId": 20148752,
"additionMatchId": 20260054,
"channelId": null,
"matchName": "Morocco - Haiti",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782338400,
"utcTimeStop": 1782345600,
"utcIso": "2026-06-24T22:00:00Z",
"chinaTime": "2026-06-25 06:00:00 Asia/Shanghai",
"venueTime": "2026-06-24 18:00:00 America/New_York",
"venueTimezone": "America/New_York"
},
"homeTeam": {
"id": 11182,
"name": "Morocco",
"names": {
"zh": "摩洛哥",
"en": "Morocco",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1hkv9bnt8sv.png"
},
"awayTeam": {
"id": 11270,
"name": "Haiti",
"names": {
"zh": "海地",
"en": "Haiti",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018900888.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Mercedes-Benz Stadium",
"en": "Mercedes-Benz Stadium",
"zhTw": null,
"vi": "Mercedes-Benz Stadium",
"km": "Mercedes-Benz Stadium",
"ms": "Mercedes-Benz Stadium"
},
"city": {
"zh": "亚特兰大",
"en": "Atlanta",
"zhTw": null,
"vi": "Atlanta",
"km": "Atlanta",
"ms": "Atlanta"
}
},
"sortOrder": 54,
"isPublished": true
},
{
"officialMatchNo": 4,
"stage": "group",
"groupName": "D",
"liveMatchId": 20148753,
"additionMatchId": 20260004,
"channelId": null,
"matchName": "USA - Paraguay",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781312400,
"utcTimeStop": 1781319600,
"utcIso": "2026-06-13T01:00:00Z",
"chinaTime": "2026-06-13 09:00:00 Asia/Shanghai",
"venueTime": "2026-06-12 18:00:00 America/Los_Angeles",
"venueTimezone": "America/Los_Angeles"
},
"homeTeam": {
"id": 11168,
"name": "USA",
"names": {
"zh": "美国",
"en": "USA",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165102965258.png"
},
"awayTeam": {
"id": 11148,
"name": "Paraguay",
"names": {
"zh": "巴拉圭",
"en": "Paraguay",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018876382.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "SoFi Stadium",
"en": "SoFi Stadium",
"zhTw": null,
"vi": "SoFi Stadium",
"km": "SoFi Stadium",
"ms": "SoFi Stadium"
},
"city": {
"zh": "洛杉矶",
"en": "Los Angeles",
"zhTw": null,
"vi": "Los Angeles",
"km": "Los Angeles",
"ms": "Los Angeles"
}
},
"sortOrder": 4,
"isPublished": true
},
{
"officialMatchNo": 8,
"stage": "group",
"groupName": "D",
"liveMatchId": 20148968,
"additionMatchId": 20260008,
"channelId": null,
"matchName": "Australia - Türkiye",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781323200,
"utcTimeStop": 1781330400,
"utcIso": "2026-06-13T04:00:00Z",
"chinaTime": "2026-06-13 12:00:00 Asia/Shanghai",
"venueTime": "2026-06-12 21:00:00 America/Los_Angeles",
"venueTimezone": "America/Los_Angeles"
},
"homeTeam": {
"id": 11273,
"name": "Australia",
"names": {
"zh": "澳大利亚",
"en": "Australia",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018939680.png"
},
"awayTeam": {
"id": 50468,
"name": "Türkiye",
"names": {
"zh": "土耳其",
"en": "Türkiye",
"zhTw": "土耳其",
"vi": "Thổ Nhĩ Kỳ",
"km": "តួកគី",
"ms": "Turki"
},
"image": "https://flagcdn.com/tr.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "BC Place",
"en": "BC Place",
"zhTw": null,
"vi": "BC Place",
"km": "BC Place",
"ms": "BC Place"
},
"city": {
"zh": "温哥华",
"en": "Vancouver",
"zhTw": null,
"vi": "Vancouver",
"km": "Vancouver",
"ms": "Vancouver"
}
},
"sortOrder": 8,
"isPublished": true
},
{
"officialMatchNo": 30,
"stage": "group",
"groupName": "D",
"liveMatchId": 20148754,
"additionMatchId": 20260030,
"channelId": null,
"matchName": "USA - Australia",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781895600,
"utcTimeStop": 1781902800,
"utcIso": "2026-06-19T19:00:00Z",
"chinaTime": "2026-06-20 03:00:00 Asia/Shanghai",
"venueTime": "2026-06-19 12:00:00 America/Los_Angeles",
"venueTimezone": "America/Los_Angeles"
},
"homeTeam": {
"id": 11168,
"name": "USA",
"names": {
"zh": "美国",
"en": "USA",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165102965258.png"
},
"awayTeam": {
"id": 11273,
"name": "Australia",
"names": {
"zh": "澳大利亚",
"en": "Australia",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018939680.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Lumen Field",
"en": "Lumen Field",
"zhTw": null,
"vi": "Lumen Field",
"km": "Lumen Field",
"ms": "Lumen Field"
},
"city": {
"zh": "西雅图",
"en": "Seattle",
"zhTw": null,
"vi": "Seattle",
"km": "Seattle",
"ms": "Seattle"
}
},
"sortOrder": 30,
"isPublished": true
},
{
"officialMatchNo": 32,
"stage": "group",
"groupName": "D",
"liveMatchId": 20148790,
"additionMatchId": 20260032,
"channelId": null,
"matchName": "Türkiye - Paraguay",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781928000,
"utcTimeStop": 1781935200,
"utcIso": "2026-06-20T04:00:00Z",
"chinaTime": "2026-06-20 12:00:00 Asia/Shanghai",
"venueTime": "2026-06-19 21:00:00 America/Los_Angeles",
"venueTimezone": "America/Los_Angeles"
},
"homeTeam": {
"id": 50468,
"name": "Türkiye",
"names": {
"zh": "土耳其",
"en": "Türkiye",
"zhTw": "土耳其",
"vi": "Thổ Nhĩ Kỳ",
"km": "តួកគី",
"ms": "Turki"
},
"image": "https://flagcdn.com/tr.svg"
},
"awayTeam": {
"id": 11148,
"name": "Paraguay",
"names": {
"zh": "巴拉圭",
"en": "Paraguay",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018876382.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Levi's Stadium",
"en": "Levi's Stadium",
"zhTw": null,
"vi": "Levi's Stadium",
"km": "Levi's Stadium",
"ms": "Levi's Stadium"
},
"city": {
"zh": "San Francisco Bay Area",
"en": "San Francisco Bay Area",
"zhTw": null,
"vi": "San Francisco Bay Area",
"km": "San Francisco Bay Area",
"ms": "San Francisco Bay Area"
}
},
"sortOrder": 32,
"isPublished": true
},
{
"officialMatchNo": 55,
"stage": "group",
"groupName": "D",
"liveMatchId": 20148791,
"additionMatchId": 20260055,
"channelId": null,
"matchName": "Türkiye - USA",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782439200,
"utcTimeStop": 1782446400,
"utcIso": "2026-06-26T02:00:00Z",
"chinaTime": "2026-06-26 10:00:00 Asia/Shanghai",
"venueTime": "2026-06-25 19:00:00 America/Los_Angeles",
"venueTimezone": "America/Los_Angeles"
},
"homeTeam": {
"id": 50468,
"name": "Türkiye",
"names": {
"zh": "土耳其",
"en": "Türkiye",
"zhTw": "土耳其",
"vi": "Thổ Nhĩ Kỳ",
"km": "តួកគី",
"ms": "Turki"
},
"image": "https://flagcdn.com/tr.svg"
},
"awayTeam": {
"id": 11168,
"name": "USA",
"names": {
"zh": "美国",
"en": "USA",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165102965258.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "SoFi Stadium",
"en": "SoFi Stadium",
"zhTw": null,
"vi": "SoFi Stadium",
"km": "SoFi Stadium",
"ms": "SoFi Stadium"
},
"city": {
"zh": "洛杉矶",
"en": "Los Angeles",
"zhTw": null,
"vi": "Los Angeles",
"km": "Los Angeles",
"ms": "Los Angeles"
}
},
"sortOrder": 55,
"isPublished": true
},
{
"officialMatchNo": 56,
"stage": "group",
"groupName": "D",
"liveMatchId": 20148755,
"additionMatchId": 20260056,
"channelId": null,
"matchName": "Paraguay - Australia",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782439200,
"utcTimeStop": 1782446400,
"utcIso": "2026-06-26T02:00:00Z",
"chinaTime": "2026-06-26 10:00:00 Asia/Shanghai",
"venueTime": "2026-06-25 19:00:00 America/Los_Angeles",
"venueTimezone": "America/Los_Angeles"
},
"homeTeam": {
"id": 11148,
"name": "Paraguay",
"names": {
"zh": "巴拉圭",
"en": "Paraguay",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018876382.png"
},
"awayTeam": {
"id": 11273,
"name": "Australia",
"names": {
"zh": "澳大利亚",
"en": "Australia",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018939680.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Levi's Stadium",
"en": "Levi's Stadium",
"zhTw": null,
"vi": "Levi's Stadium",
"km": "Levi's Stadium",
"ms": "Levi's Stadium"
},
"city": {
"zh": "San Francisco Bay Area",
"en": "San Francisco Bay Area",
"zhTw": null,
"vi": "San Francisco Bay Area",
"km": "San Francisco Bay Area",
"ms": "San Francisco Bay Area"
}
},
"sortOrder": 56,
"isPublished": true
},
{
"officialMatchNo": 9,
"stage": "group",
"groupName": "E",
"liveMatchId": 20148816,
"additionMatchId": 20260009,
"channelId": null,
"matchName": "Germany - Curaçao",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781456400,
"utcTimeStop": 1781463600,
"utcIso": "2026-06-14T17:00:00Z",
"chinaTime": "2026-06-15 01:00:00 Asia/Shanghai",
"venueTime": "2026-06-14 12:00:00 America/Chicago",
"venueTimezone": "America/Chicago"
},
"homeTeam": {
"id": 11038,
"name": "Germany",
"names": {
"zh": "德国",
"en": "Germany",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983677229.png"
},
"awayTeam": {
"id": 19979,
"name": "Curaçao",
"names": {
"zh": "库拉索",
"en": "Curacao",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1gwbryn6pn1.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "NRG Stadium",
"en": "NRG Stadium",
"zhTw": null,
"vi": "NRG Stadium",
"km": "NRG Stadium",
"ms": "NRG Stadium"
},
"city": {
"zh": "休斯敦",
"en": "Houston",
"zhTw": null,
"vi": "Houston",
"km": "Houston",
"ms": "Houston"
}
},
"sortOrder": 9,
"isPublished": true
},
{
"officialMatchNo": 11,
"stage": "group",
"groupName": "E",
"liveMatchId": 20148798,
"additionMatchId": 20260011,
"channelId": null,
"matchName": "Côte d'Ivoire - Ecuador",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781478000,
"utcTimeStop": 1781485200,
"utcIso": "2026-06-14T23:00:00Z",
"chinaTime": "2026-06-15 07:00:00 Asia/Shanghai",
"venueTime": "2026-06-14 19:00:00 America/New_York",
"venueTimezone": "America/New_York"
},
"homeTeam": {
"id": 50469,
"name": "Côte d'Ivoire",
"names": {
"zh": "科特迪瓦",
"en": "Côte d'Ivoire",
"zhTw": "科特迪瓦",
"vi": "Bờ Biển Ngà",
"km": "កូតឌីវ័រ",
"ms": "Cote dIvoire"
},
"image": "https://flagcdn.com/ci.svg"
},
"awayTeam": {
"id": 11151,
"name": "Ecuador",
"names": {
"zh": "厄瓜多尔",
"en": "Ecuador",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018888622.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Lincoln Financial Field",
"en": "Lincoln Financial Field",
"zhTw": null,
"vi": "Lincoln Financial Field",
"km": "Lincoln Financial Field",
"ms": "Lincoln Financial Field"
},
"city": {
"zh": "费城",
"en": "Philadelphia",
"zhTw": null,
"vi": "Philadelphia",
"km": "Philadelphia",
"ms": "Philadelphia"
}
},
"sortOrder": 11,
"isPublished": true
},
{
"officialMatchNo": 34,
"stage": "group",
"groupName": "E",
"liveMatchId": 20148969,
"additionMatchId": 20260034,
"channelId": null,
"matchName": "Germany - Côte d'Ivoire",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781985600,
"utcTimeStop": 1781992800,
"utcIso": "2026-06-20T20:00:00Z",
"chinaTime": "2026-06-21 04:00:00 Asia/Shanghai",
"venueTime": "2026-06-20 16:00:00 America/Toronto",
"venueTimezone": "America/Toronto"
},
"homeTeam": {
"id": 11038,
"name": "Germany",
"names": {
"zh": "德国",
"en": "Germany",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983677229.png"
},
"awayTeam": {
"id": 50469,
"name": "Côte d'Ivoire",
"names": {
"zh": "科特迪瓦",
"en": "Côte d'Ivoire",
"zhTw": "科特迪瓦",
"vi": "Bờ Biển Ngà",
"km": "កូតឌីវ័រ",
"ms": "Cote dIvoire"
},
"image": "https://flagcdn.com/ci.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "BMO Field",
"en": "BMO Field",
"zhTw": null,
"vi": "BMO Field",
"km": "BMO Field",
"ms": "BMO Field"
},
"city": {
"zh": "多伦多",
"en": "Toronto",
"zhTw": null,
"vi": "Toronto",
"km": "Toronto",
"ms": "Toronto"
}
},
"sortOrder": 34,
"isPublished": true
},
{
"officialMatchNo": 35,
"stage": "group",
"groupName": "E",
"liveMatchId": 20148817,
"additionMatchId": 20260035,
"channelId": null,
"matchName": "Ecuador - Curaçao",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782000000,
"utcTimeStop": 1782007200,
"utcIso": "2026-06-21T00:00:00Z",
"chinaTime": "2026-06-21 08:00:00 Asia/Shanghai",
"venueTime": "2026-06-20 19:00:00 America/Chicago",
"venueTimezone": "America/Chicago"
},
"homeTeam": {
"id": 11151,
"name": "Ecuador",
"names": {
"zh": "厄瓜多尔",
"en": "Ecuador",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018888622.png"
},
"awayTeam": {
"id": 19979,
"name": "Curaçao",
"names": {
"zh": "库拉索",
"en": "Curacao",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1gwbryn6pn1.png"
},
"status": {
"state": "off",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Arrowhead Stadium",
"en": "Arrowhead Stadium",
"zhTw": null,
"vi": "Arrowhead Stadium",
"km": "Arrowhead Stadium",
"ms": "Arrowhead Stadium"
},
"city": {
"zh": "堪萨斯城",
"en": "Kansas City",
"zhTw": null,
"vi": "Kansas City",
"km": "Kansas City",
"ms": "Kansas City"
}
},
"sortOrder": 35,
"isPublished": true
},
{
"officialMatchNo": 57,
"stage": "group",
"groupName": "E",
"liveMatchId": 20148756,
"additionMatchId": 20260057,
"channelId": null,
"matchName": "Ecuador - Germany",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782417600,
"utcTimeStop": 1782424800,
"utcIso": "2026-06-25T20:00:00Z",
"chinaTime": "2026-06-26 04:00:00 Asia/Shanghai",
"venueTime": "2026-06-25 16:00:00 America/New_York",
"venueTimezone": "America/New_York"
},
"homeTeam": {
"id": 11151,
"name": "Ecuador",
"names": {
"zh": "厄瓜多尔",
"en": "Ecuador",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018888622.png"
},
"awayTeam": {
"id": 11038,
"name": "Germany",
"names": {
"zh": "德国",
"en": "Germany",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983677229.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "MetLife Stadium",
"en": "MetLife Stadium",
"zhTw": null,
"vi": "MetLife Stadium",
"km": "MetLife Stadium",
"ms": "MetLife Stadium"
},
"city": {
"zh": "纽约/新泽西",
"en": "New York / New Jersey",
"zhTw": null,
"vi": "New York / New Jersey",
"km": "New York / New Jersey",
"ms": "New York / New Jersey"
}
},
"sortOrder": 57,
"isPublished": true
},
{
"officialMatchNo": 58,
"stage": "group",
"groupName": "E",
"liveMatchId": 20148841,
"additionMatchId": 20260058,
"channelId": null,
"matchName": "Curaçao - Côte d'Ivoire",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782417600,
"utcTimeStop": 1782424800,
"utcIso": "2026-06-25T20:00:00Z",
"chinaTime": "2026-06-26 04:00:00 Asia/Shanghai",
"venueTime": "2026-06-25 16:00:00 America/New_York",
"venueTimezone": "America/New_York"
},
"homeTeam": {
"id": 19979,
"name": "Curaçao",
"names": {
"zh": "库拉索",
"en": "Curacao",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1gwbryn6pn1.png"
},
"awayTeam": {
"id": 50469,
"name": "Côte d'Ivoire",
"names": {
"zh": "科特迪瓦",
"en": "Côte d'Ivoire",
"zhTw": "科特迪瓦",
"vi": "Bờ Biển Ngà",
"km": "កូតឌីវ័រ",
"ms": "Cote dIvoire"
},
"image": "https://flagcdn.com/ci.svg"
},
"status": {
"state": "off",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Lincoln Financial Field",
"en": "Lincoln Financial Field",
"zhTw": null,
"vi": "Lincoln Financial Field",
"km": "Lincoln Financial Field",
"ms": "Lincoln Financial Field"
},
"city": {
"zh": "费城",
"en": "Philadelphia",
"zhTw": null,
"vi": "Philadelphia",
"km": "Philadelphia",
"ms": "Philadelphia"
}
},
"sortOrder": 58,
"isPublished": true
},
{
"officialMatchNo": 10,
"stage": "group",
"groupName": "F",
"liveMatchId": 20148757,
"additionMatchId": 20260010,
"channelId": null,
"matchName": "Netherlands - Japan",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781467200,
"utcTimeStop": 1781474400,
"utcIso": "2026-06-14T20:00:00Z",
"chinaTime": "2026-06-15 04:00:00 Asia/Shanghai",
"venueTime": "2026-06-14 15:00:00 America/Chicago",
"venueTimezone": "America/Chicago"
},
"homeTeam": {
"id": 11034,
"name": "Netherlands",
"names": {
"zh": "荷兰",
"en": "Netherlands",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/16498334962.png"
},
"awayTeam": {
"id": 11266,
"name": "Japan",
"names": {
"zh": "日本",
"en": "Japan",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166919982535.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "AT&T Stadium",
"en": "AT&T Stadium",
"zhTw": null,
"vi": "AT&T Stadium",
"km": "AT&T Stadium",
"ms": "AT&T Stadium"
},
"city": {
"zh": "达拉斯",
"en": "Dallas",
"zhTw": null,
"vi": "Dallas",
"km": "Dallas",
"ms": "Dallas"
}
},
"sortOrder": 10,
"isPublished": true
},
{
"officialMatchNo": 12,
"stage": "group",
"groupName": "F",
"liveMatchId": 20148758,
"additionMatchId": 20260012,
"channelId": null,
"matchName": "Sweden - Tunisia",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781488800,
"utcTimeStop": 1781496000,
"utcIso": "2026-06-15T02:00:00Z",
"chinaTime": "2026-06-15 10:00:00 Asia/Shanghai",
"venueTime": "2026-06-14 20:00:00 America/Mexico_City",
"venueTimezone": "America/Mexico_City"
},
"homeTeam": {
"id": 11032,
"name": "Sweden",
"names": {
"zh": "瑞典",
"en": "Sweden",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983505795.png"
},
"awayTeam": {
"id": 11190,
"name": "Tunisia",
"names": {
"zh": "突尼斯",
"en": "Tunisia",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018860211.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Estadio BBVA",
"en": "Estadio BBVA",
"zhTw": null,
"vi": "Estadio BBVA",
"km": "Estadio BBVA",
"ms": "Estadio BBVA"
},
"city": {
"zh": "蒙特雷",
"en": "Monterrey",
"zhTw": null,
"vi": "Monterrey",
"km": "Monterrey",
"ms": "Monterrey"
}
},
"sortOrder": 12,
"isPublished": true
},
{
"officialMatchNo": 33,
"stage": "group",
"groupName": "F",
"liveMatchId": 20148759,
"additionMatchId": 20260033,
"channelId": null,
"matchName": "Netherlands - Sweden",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781974800,
"utcTimeStop": 1781982000,
"utcIso": "2026-06-20T17:00:00Z",
"chinaTime": "2026-06-21 01:00:00 Asia/Shanghai",
"venueTime": "2026-06-20 12:00:00 America/Chicago",
"venueTimezone": "America/Chicago"
},
"homeTeam": {
"id": 11034,
"name": "Netherlands",
"names": {
"zh": "荷兰",
"en": "Netherlands",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/16498334962.png"
},
"awayTeam": {
"id": 11032,
"name": "Sweden",
"names": {
"zh": "瑞典",
"en": "Sweden",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983505795.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "NRG Stadium",
"en": "NRG Stadium",
"zhTw": null,
"vi": "NRG Stadium",
"km": "NRG Stadium",
"ms": "NRG Stadium"
},
"city": {
"zh": "休斯敦",
"en": "Houston",
"zhTw": null,
"vi": "Houston",
"km": "Houston",
"ms": "Houston"
}
},
"sortOrder": 33,
"isPublished": true
},
{
"officialMatchNo": 36,
"stage": "group",
"groupName": "F",
"liveMatchId": 20148760,
"additionMatchId": 20260036,
"channelId": null,
"matchName": "Tunisia - Japan",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782014400,
"utcTimeStop": 1782021600,
"utcIso": "2026-06-21T04:00:00Z",
"chinaTime": "2026-06-21 12:00:00 Asia/Shanghai",
"venueTime": "2026-06-20 22:00:00 America/Mexico_City",
"venueTimezone": "America/Mexico_City"
},
"homeTeam": {
"id": 11190,
"name": "Tunisia",
"names": {
"zh": "突尼斯",
"en": "Tunisia",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018860211.png"
},
"awayTeam": {
"id": 11266,
"name": "Japan",
"names": {
"zh": "日本",
"en": "Japan",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166919982535.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Estadio BBVA",
"en": "Estadio BBVA",
"zhTw": null,
"vi": "Estadio BBVA",
"km": "Estadio BBVA",
"ms": "Estadio BBVA"
},
"city": {
"zh": "蒙特雷",
"en": "Monterrey",
"zhTw": null,
"vi": "Monterrey",
"km": "Monterrey",
"ms": "Monterrey"
}
},
"sortOrder": 36,
"isPublished": true
},
{
"officialMatchNo": 59,
"stage": "group",
"groupName": "F",
"liveMatchId": 20148761,
"additionMatchId": 20260059,
"channelId": null,
"matchName": "Japan - Sweden",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782428400,
"utcTimeStop": 1782435600,
"utcIso": "2026-06-25T23:00:00Z",
"chinaTime": "2026-06-26 07:00:00 Asia/Shanghai",
"venueTime": "2026-06-25 18:00:00 America/Chicago",
"venueTimezone": "America/Chicago"
},
"homeTeam": {
"id": 11266,
"name": "Japan",
"names": {
"zh": "日本",
"en": "Japan",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166919982535.png"
},
"awayTeam": {
"id": 11032,
"name": "Sweden",
"names": {
"zh": "瑞典",
"en": "Sweden",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983505795.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "AT&T Stadium",
"en": "AT&T Stadium",
"zhTw": null,
"vi": "AT&T Stadium",
"km": "AT&T Stadium",
"ms": "AT&T Stadium"
},
"city": {
"zh": "达拉斯",
"en": "Dallas",
"zhTw": null,
"vi": "Dallas",
"km": "Dallas",
"ms": "Dallas"
}
},
"sortOrder": 59,
"isPublished": true
},
{
"officialMatchNo": 60,
"stage": "group",
"groupName": "F",
"liveMatchId": 20148762,
"additionMatchId": 20260060,
"channelId": null,
"matchName": "Tunisia - Netherlands",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782428400,
"utcTimeStop": 1782435600,
"utcIso": "2026-06-25T23:00:00Z",
"chinaTime": "2026-06-26 07:00:00 Asia/Shanghai",
"venueTime": "2026-06-25 18:00:00 America/Chicago",
"venueTimezone": "America/Chicago"
},
"homeTeam": {
"id": 11190,
"name": "Tunisia",
"names": {
"zh": "突尼斯",
"en": "Tunisia",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018860211.png"
},
"awayTeam": {
"id": 11034,
"name": "Netherlands",
"names": {
"zh": "荷兰",
"en": "Netherlands",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/16498334962.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Arrowhead Stadium",
"en": "Arrowhead Stadium",
"zhTw": null,
"vi": "Arrowhead Stadium",
"km": "Arrowhead Stadium",
"ms": "Arrowhead Stadium"
},
"city": {
"zh": "堪萨斯城",
"en": "Kansas City",
"zhTw": null,
"vi": "Kansas City",
"km": "Kansas City",
"ms": "Kansas City"
}
},
"sortOrder": 60,
"isPublished": true
},
{
"officialMatchNo": 14,
"stage": "group",
"groupName": "G",
"liveMatchId": 20148763,
"additionMatchId": 20260014,
"channelId": null,
"matchName": "Belgium - Egypt",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781550000,
"utcTimeStop": 1781557200,
"utcIso": "2026-06-15T19:00:00Z",
"chinaTime": "2026-06-16 03:00:00 Asia/Shanghai",
"venueTime": "2026-06-15 12:00:00 America/Los_Angeles",
"venueTimezone": "America/Los_Angeles"
},
"homeTeam": {
"id": 11033,
"name": "Belgium",
"names": {
"zh": "比利时",
"en": "Belgium",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1j0aw46xvtk.png"
},
"awayTeam": {
"id": 11109,
"name": "Egypt",
"names": {
"zh": "埃及",
"en": "Egypt",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018770975.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Lumen Field",
"en": "Lumen Field",
"zhTw": null,
"vi": "Lumen Field",
"km": "Lumen Field",
"ms": "Lumen Field"
},
"city": {
"zh": "西雅图",
"en": "Seattle",
"zhTw": null,
"vi": "Seattle",
"km": "Seattle",
"ms": "Seattle"
}
},
"sortOrder": 14,
"isPublished": true
},
{
"officialMatchNo": 16,
"stage": "group",
"groupName": "G",
"liveMatchId": 20148823,
"additionMatchId": 20260016,
"channelId": null,
"matchName": "Iran - New Zealand",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781571600,
"utcTimeStop": 1781578800,
"utcIso": "2026-06-16T01:00:00Z",
"chinaTime": "2026-06-16 09:00:00 Asia/Shanghai",
"venueTime": "2026-06-15 18:00:00 America/Los_Angeles",
"venueTimezone": "America/Los_Angeles"
},
"homeTeam": {
"id": 11154,
"name": "Iran",
"names": {
"zh": "伊朗",
"en": "Iran",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018993893.png"
},
"awayTeam": {
"id": 12310,
"name": "New Zealand",
"names": {
"zh": "新西兰",
"en": "New Zealand",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018693635.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "SoFi Stadium",
"en": "SoFi Stadium",
"zhTw": null,
"vi": "SoFi Stadium",
"km": "SoFi Stadium",
"ms": "SoFi Stadium"
},
"city": {
"zh": "洛杉矶",
"en": "Los Angeles",
"zhTw": null,
"vi": "Los Angeles",
"km": "Los Angeles",
"ms": "Los Angeles"
}
},
"sortOrder": 16,
"isPublished": true
},
{
"officialMatchNo": 38,
"stage": "group",
"groupName": "G",
"liveMatchId": 20148764,
"additionMatchId": 20260038,
"channelId": null,
"matchName": "Belgium - Iran",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782068400,
"utcTimeStop": 1782075600,
"utcIso": "2026-06-21T19:00:00Z",
"chinaTime": "2026-06-22 03:00:00 Asia/Shanghai",
"venueTime": "2026-06-21 12:00:00 America/Los_Angeles",
"venueTimezone": "America/Los_Angeles"
},
"homeTeam": {
"id": 11033,
"name": "Belgium",
"names": {
"zh": "比利时",
"en": "Belgium",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1j0aw46xvtk.png"
},
"awayTeam": {
"id": 11154,
"name": "Iran",
"names": {
"zh": "伊朗",
"en": "Iran",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018993893.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "SoFi Stadium",
"en": "SoFi Stadium",
"zhTw": null,
"vi": "SoFi Stadium",
"km": "SoFi Stadium",
"ms": "SoFi Stadium"
},
"city": {
"zh": "洛杉矶",
"en": "Los Angeles",
"zhTw": null,
"vi": "Los Angeles",
"km": "Los Angeles",
"ms": "Los Angeles"
}
},
"sortOrder": 38,
"isPublished": true
},
{
"officialMatchNo": 40,
"stage": "group",
"groupName": "G",
"liveMatchId": 20148796,
"additionMatchId": 20260040,
"channelId": null,
"matchName": "New Zealand - Egypt",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782090000,
"utcTimeStop": 1782097200,
"utcIso": "2026-06-22T01:00:00Z",
"chinaTime": "2026-06-22 09:00:00 Asia/Shanghai",
"venueTime": "2026-06-21 18:00:00 America/Los_Angeles",
"venueTimezone": "America/Los_Angeles"
},
"homeTeam": {
"id": 12310,
"name": "New Zealand",
"names": {
"zh": "新西兰",
"en": "New Zealand",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018693635.png"
},
"awayTeam": {
"id": 11109,
"name": "Egypt",
"names": {
"zh": "埃及",
"en": "Egypt",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018770975.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "BC Place",
"en": "BC Place",
"zhTw": null,
"vi": "BC Place",
"km": "BC Place",
"ms": "BC Place"
},
"city": {
"zh": "温哥华",
"en": "Vancouver",
"zhTw": null,
"vi": "Vancouver",
"km": "Vancouver",
"ms": "Vancouver"
}
},
"sortOrder": 40,
"isPublished": true
},
{
"officialMatchNo": 61,
"stage": "group",
"groupName": "G",
"liveMatchId": 20148765,
"additionMatchId": 20260061,
"channelId": null,
"matchName": "Egypt - Iran",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782529200,
"utcTimeStop": 1782536400,
"utcIso": "2026-06-27T03:00:00Z",
"chinaTime": "2026-06-27 11:00:00 Asia/Shanghai",
"venueTime": "2026-06-26 20:00:00 America/Los_Angeles",
"venueTimezone": "America/Los_Angeles"
},
"homeTeam": {
"id": 11109,
"name": "Egypt",
"names": {
"zh": "埃及",
"en": "Egypt",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018770975.png"
},
"awayTeam": {
"id": 11154,
"name": "Iran",
"names": {
"zh": "伊朗",
"en": "Iran",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018993893.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Lumen Field",
"en": "Lumen Field",
"zhTw": null,
"vi": "Lumen Field",
"km": "Lumen Field",
"ms": "Lumen Field"
},
"city": {
"zh": "西雅图",
"en": "Seattle",
"zhTw": null,
"vi": "Seattle",
"km": "Seattle",
"ms": "Seattle"
}
},
"sortOrder": 61,
"isPublished": true
},
{
"officialMatchNo": 62,
"stage": "group",
"groupName": "G",
"liveMatchId": 20148797,
"additionMatchId": 20260062,
"channelId": null,
"matchName": "New Zealand - Belgium",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782529200,
"utcTimeStop": 1782536400,
"utcIso": "2026-06-27T03:00:00Z",
"chinaTime": "2026-06-27 11:00:00 Asia/Shanghai",
"venueTime": "2026-06-26 20:00:00 America/Los_Angeles",
"venueTimezone": "America/Los_Angeles"
},
"homeTeam": {
"id": 12310,
"name": "New Zealand",
"names": {
"zh": "新西兰",
"en": "New Zealand",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018693635.png"
},
"awayTeam": {
"id": 11033,
"name": "Belgium",
"names": {
"zh": "比利时",
"en": "Belgium",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1j0aw46xvtk.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "BC Place",
"en": "BC Place",
"zhTw": null,
"vi": "BC Place",
"km": "BC Place",
"ms": "BC Place"
},
"city": {
"zh": "温哥华",
"en": "Vancouver",
"zhTw": null,
"vi": "Vancouver",
"km": "Vancouver",
"ms": "Vancouver"
}
},
"sortOrder": 62,
"isPublished": true
},
{
"officialMatchNo": 13,
"stage": "group",
"groupName": "H",
"liveMatchId": 20148766,
"additionMatchId": 20260013,
"channelId": null,
"matchName": "Spain - Cape Verde",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781539200,
"utcTimeStop": 1781546400,
"utcIso": "2026-06-15T16:00:00Z",
"chinaTime": "2026-06-16 00:00:00 Asia/Shanghai",
"venueTime": "2026-06-15 12:00:00 America/New_York",
"venueTimezone": "America/New_York"
},
"homeTeam": {
"id": 11144,
"name": "Spain",
"names": {
"zh": "西班牙",
"en": "Spain",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983321668.png"
},
"awayTeam": {
"id": 11161,
"name": "Cape Verde",
"names": {
"zh": "佛得角",
"en": "Cape Verde",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018791043.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Mercedes-Benz Stadium",
"en": "Mercedes-Benz Stadium",
"zhTw": null,
"vi": "Mercedes-Benz Stadium",
"km": "Mercedes-Benz Stadium",
"ms": "Mercedes-Benz Stadium"
},
"city": {
"zh": "亚特兰大",
"en": "Atlanta",
"zhTw": null,
"vi": "Atlanta",
"km": "Atlanta",
"ms": "Atlanta"
}
},
"sortOrder": 13,
"isPublished": true
},
{
"officialMatchNo": 15,
"stage": "group",
"groupName": "H",
"liveMatchId": 20148767,
"additionMatchId": 20260015,
"channelId": null,
"matchName": "Saudi Arabia - Uruguay",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781560800,
"utcTimeStop": 1781568000,
"utcIso": "2026-06-15T22:00:00Z",
"chinaTime": "2026-06-16 06:00:00 Asia/Shanghai",
"venueTime": "2026-06-15 18:00:00 America/New_York",
"venueTimezone": "America/New_York"
},
"homeTeam": {
"id": 11254,
"name": "Saudi Arabia",
"names": {
"zh": "沙特阿拉伯",
"en": "Saudi Arabia",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018978353.png"
},
"awayTeam": {
"id": 11139,
"name": "Uruguay",
"names": {
"zh": "乌拉圭",
"en": "Uruguay",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h1kc21h0g2r.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Hard Rock Stadium",
"en": "Hard Rock Stadium",
"zhTw": null,
"vi": "Hard Rock Stadium",
"km": "Hard Rock Stadium",
"ms": "Hard Rock Stadium"
},
"city": {
"zh": "迈阿密",
"en": "Miami",
"zhTw": null,
"vi": "Miami",
"km": "Miami",
"ms": "Miami"
}
},
"sortOrder": 15,
"isPublished": true
},
{
"officialMatchNo": 37,
"stage": "group",
"groupName": "H",
"liveMatchId": 20148768,
"additionMatchId": 20260037,
"channelId": null,
"matchName": "Spain - Saudi Arabia",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782057600,
"utcTimeStop": 1782064800,
"utcIso": "2026-06-21T16:00:00Z",
"chinaTime": "2026-06-22 00:00:00 Asia/Shanghai",
"venueTime": "2026-06-21 12:00:00 America/New_York",
"venueTimezone": "America/New_York"
},
"homeTeam": {
"id": 11144,
"name": "Spain",
"names": {
"zh": "西班牙",
"en": "Spain",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983321668.png"
},
"awayTeam": {
"id": 11254,
"name": "Saudi Arabia",
"names": {
"zh": "沙特阿拉伯",
"en": "Saudi Arabia",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018978353.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Mercedes-Benz Stadium",
"en": "Mercedes-Benz Stadium",
"zhTw": null,
"vi": "Mercedes-Benz Stadium",
"km": "Mercedes-Benz Stadium",
"ms": "Mercedes-Benz Stadium"
},
"city": {
"zh": "亚特兰大",
"en": "Atlanta",
"zhTw": null,
"vi": "Atlanta",
"km": "Atlanta",
"ms": "Atlanta"
}
},
"sortOrder": 37,
"isPublished": true
},
{
"officialMatchNo": 39,
"stage": "group",
"groupName": "H",
"liveMatchId": 20148769,
"additionMatchId": 20260039,
"channelId": null,
"matchName": "Uruguay - Cape Verde",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782079200,
"utcTimeStop": 1782086400,
"utcIso": "2026-06-21T22:00:00Z",
"chinaTime": "2026-06-22 06:00:00 Asia/Shanghai",
"venueTime": "2026-06-21 18:00:00 America/New_York",
"venueTimezone": "America/New_York"
},
"homeTeam": {
"id": 11139,
"name": "Uruguay",
"names": {
"zh": "乌拉圭",
"en": "Uruguay",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h1kc21h0g2r.png"
},
"awayTeam": {
"id": 11161,
"name": "Cape Verde",
"names": {
"zh": "佛得角",
"en": "Cape Verde",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018791043.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Hard Rock Stadium",
"en": "Hard Rock Stadium",
"zhTw": null,
"vi": "Hard Rock Stadium",
"km": "Hard Rock Stadium",
"ms": "Hard Rock Stadium"
},
"city": {
"zh": "迈阿密",
"en": "Miami",
"zhTw": null,
"vi": "Miami",
"km": "Miami",
"ms": "Miami"
}
},
"sortOrder": 39,
"isPublished": true
},
{
"officialMatchNo": 63,
"stage": "group",
"groupName": "H",
"liveMatchId": 20148770,
"additionMatchId": 20260063,
"channelId": null,
"matchName": "Cape Verde - Saudi Arabia",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782518400,
"utcTimeStop": 1782525600,
"utcIso": "2026-06-27T00:00:00Z",
"chinaTime": "2026-06-27 08:00:00 Asia/Shanghai",
"venueTime": "2026-06-26 19:00:00 America/Chicago",
"venueTimezone": "America/Chicago"
},
"homeTeam": {
"id": 11161,
"name": "Cape Verde",
"names": {
"zh": "佛得角",
"en": "Cape Verde",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018791043.png"
},
"awayTeam": {
"id": 11254,
"name": "Saudi Arabia",
"names": {
"zh": "沙特阿拉伯",
"en": "Saudi Arabia",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018978353.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "NRG Stadium",
"en": "NRG Stadium",
"zhTw": null,
"vi": "NRG Stadium",
"km": "NRG Stadium",
"ms": "NRG Stadium"
},
"city": {
"zh": "休斯敦",
"en": "Houston",
"zhTw": null,
"vi": "Houston",
"km": "Houston",
"ms": "Houston"
}
},
"sortOrder": 63,
"isPublished": true
},
{
"officialMatchNo": 64,
"stage": "group",
"groupName": "H",
"liveMatchId": 20148771,
"additionMatchId": 20260064,
"channelId": null,
"matchName": "Uruguay - Spain",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782518400,
"utcTimeStop": 1782525600,
"utcIso": "2026-06-27T00:00:00Z",
"chinaTime": "2026-06-27 08:00:00 Asia/Shanghai",
"venueTime": "2026-06-26 18:00:00 America/Mexico_City",
"venueTimezone": "America/Mexico_City"
},
"homeTeam": {
"id": 11139,
"name": "Uruguay",
"names": {
"zh": "乌拉圭",
"en": "Uruguay",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h1kc21h0g2r.png"
},
"awayTeam": {
"id": 11144,
"name": "Spain",
"names": {
"zh": "西班牙",
"en": "Spain",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983321668.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Estadio Akron",
"en": "Estadio Akron",
"zhTw": null,
"vi": "Estadio Akron",
"km": "Estadio Akron",
"ms": "Estadio Akron"
},
"city": {
"zh": "瓜达拉哈拉",
"en": "Guadalajara",
"zhTw": null,
"vi": "Guadalajara",
"km": "Guadalajara",
"ms": "Guadalajara"
}
},
"sortOrder": 64,
"isPublished": true
},
{
"officialMatchNo": 17,
"stage": "group",
"groupName": "I",
"liveMatchId": 20148772,
"additionMatchId": 20260017,
"channelId": null,
"matchName": "France - Senegal",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781636400,
"utcTimeStop": 1781643600,
"utcIso": "2026-06-16T19:00:00Z",
"chinaTime": "2026-06-17 03:00:00 Asia/Shanghai",
"venueTime": "2026-06-16 15:00:00 America/New_York",
"venueTimezone": "America/New_York"
},
"homeTeam": {
"id": 11037,
"name": "France",
"names": {
"zh": "法国",
"en": "France",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983446440.png"
},
"awayTeam": {
"id": 11184,
"name": "Senegal",
"names": {
"zh": "塞内加尔",
"en": "Senegal",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018850327.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "MetLife Stadium",
"en": "MetLife Stadium",
"zhTw": null,
"vi": "MetLife Stadium",
"km": "MetLife Stadium",
"ms": "MetLife Stadium"
},
"city": {
"zh": "纽约/新泽西",
"en": "New York / New Jersey",
"zhTw": null,
"vi": "New York / New Jersey",
"km": "New York / New Jersey",
"ms": "New York / New Jersey"
}
},
"sortOrder": 17,
"isPublished": true
},
{
"officialMatchNo": 18,
"stage": "group",
"groupName": "I",
"liveMatchId": 20148773,
"additionMatchId": 20260018,
"channelId": null,
"matchName": "Iraq - Norway",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781647200,
"utcTimeStop": 1781654400,
"utcIso": "2026-06-16T22:00:00Z",
"chinaTime": "2026-06-17 06:00:00 Asia/Shanghai",
"venueTime": "2026-06-16 18:00:00 America/New_York",
"venueTimezone": "America/New_York"
},
"homeTeam": {
"id": 11238,
"name": "Iraq",
"names": {
"zh": "伊拉克",
"en": "Iraq",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018992317.png"
},
"awayTeam": {
"id": 11029,
"name": "Norway",
"names": {
"zh": "挪威",
"en": "Norway",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983671192.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Gillette Stadium",
"en": "Gillette Stadium",
"zhTw": null,
"vi": "Gillette Stadium",
"km": "Gillette Stadium",
"ms": "Gillette Stadium"
},
"city": {
"zh": "波士顿",
"en": "Boston",
"zhTw": null,
"vi": "Boston",
"km": "Boston",
"ms": "Boston"
}
},
"sortOrder": 18,
"isPublished": true
},
{
"officialMatchNo": 41,
"stage": "group",
"groupName": "I",
"liveMatchId": 20148774,
"additionMatchId": 20260041,
"channelId": null,
"matchName": "France - Iraq",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782162000,
"utcTimeStop": 1782169200,
"utcIso": "2026-06-22T21:00:00Z",
"chinaTime": "2026-06-23 05:00:00 Asia/Shanghai",
"venueTime": "2026-06-22 17:00:00 America/New_York",
"venueTimezone": "America/New_York"
},
"homeTeam": {
"id": 11037,
"name": "France",
"names": {
"zh": "法国",
"en": "France",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983446440.png"
},
"awayTeam": {
"id": 11238,
"name": "Iraq",
"names": {
"zh": "伊拉克",
"en": "Iraq",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018992317.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Lincoln Financial Field",
"en": "Lincoln Financial Field",
"zhTw": null,
"vi": "Lincoln Financial Field",
"km": "Lincoln Financial Field",
"ms": "Lincoln Financial Field"
},
"city": {
"zh": "费城",
"en": "Philadelphia",
"zhTw": null,
"vi": "Philadelphia",
"km": "Philadelphia",
"ms": "Philadelphia"
}
},
"sortOrder": 41,
"isPublished": true
},
{
"officialMatchNo": 42,
"stage": "group",
"groupName": "I",
"liveMatchId": 20148775,
"additionMatchId": 20260042,
"channelId": null,
"matchName": "Norway - Senegal",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782172800,
"utcTimeStop": 1782180000,
"utcIso": "2026-06-23T00:00:00Z",
"chinaTime": "2026-06-23 08:00:00 Asia/Shanghai",
"venueTime": "2026-06-22 20:00:00 America/New_York",
"venueTimezone": "America/New_York"
},
"homeTeam": {
"id": 11029,
"name": "Norway",
"names": {
"zh": "挪威",
"en": "Norway",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983671192.png"
},
"awayTeam": {
"id": 11184,
"name": "Senegal",
"names": {
"zh": "塞内加尔",
"en": "Senegal",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018850327.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "MetLife Stadium",
"en": "MetLife Stadium",
"zhTw": null,
"vi": "MetLife Stadium",
"km": "MetLife Stadium",
"ms": "MetLife Stadium"
},
"city": {
"zh": "纽约/新泽西",
"en": "New York / New Jersey",
"zhTw": null,
"vi": "New York / New Jersey",
"km": "New York / New Jersey",
"ms": "New York / New Jersey"
}
},
"sortOrder": 42,
"isPublished": true
},
{
"officialMatchNo": 65,
"stage": "group",
"groupName": "I",
"liveMatchId": 20148776,
"additionMatchId": 20260065,
"channelId": null,
"matchName": "Norway - France",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782500400,
"utcTimeStop": 1782507600,
"utcIso": "2026-06-26T19:00:00Z",
"chinaTime": "2026-06-27 03:00:00 Asia/Shanghai",
"venueTime": "2026-06-26 15:00:00 America/New_York",
"venueTimezone": "America/New_York"
},
"homeTeam": {
"id": 11029,
"name": "Norway",
"names": {
"zh": "挪威",
"en": "Norway",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983671192.png"
},
"awayTeam": {
"id": 11037,
"name": "France",
"names": {
"zh": "法国",
"en": "France",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983446440.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Gillette Stadium",
"en": "Gillette Stadium",
"zhTw": null,
"vi": "Gillette Stadium",
"km": "Gillette Stadium",
"ms": "Gillette Stadium"
},
"city": {
"zh": "波士顿",
"en": "Boston",
"zhTw": null,
"vi": "Boston",
"km": "Boston",
"ms": "Boston"
}
},
"sortOrder": 65,
"isPublished": true
},
{
"officialMatchNo": 66,
"stage": "group",
"groupName": "I",
"liveMatchId": 20148777,
"additionMatchId": 20260066,
"channelId": null,
"matchName": "Senegal - Iraq",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782500400,
"utcTimeStop": 1782507600,
"utcIso": "2026-06-26T19:00:00Z",
"chinaTime": "2026-06-27 03:00:00 Asia/Shanghai",
"venueTime": "2026-06-26 15:00:00 America/Toronto",
"venueTimezone": "America/Toronto"
},
"homeTeam": {
"id": 11184,
"name": "Senegal",
"names": {
"zh": "塞内加尔",
"en": "Senegal",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018850327.png"
},
"awayTeam": {
"id": 11238,
"name": "Iraq",
"names": {
"zh": "伊拉克",
"en": "Iraq",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018992317.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "BMO Field",
"en": "BMO Field",
"zhTw": null,
"vi": "BMO Field",
"km": "BMO Field",
"ms": "BMO Field"
},
"city": {
"zh": "多伦多",
"en": "Toronto",
"zhTw": null,
"vi": "Toronto",
"km": "Toronto",
"ms": "Toronto"
}
},
"sortOrder": 66,
"isPublished": true
},
{
"officialMatchNo": 19,
"stage": "group",
"groupName": "J",
"liveMatchId": 20148818,
"additionMatchId": 20260019,
"channelId": null,
"matchName": "Argentina - Algeria",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781658000,
"utcTimeStop": 1781665200,
"utcIso": "2026-06-17T01:00:00Z",
"chinaTime": "2026-06-17 09:00:00 Asia/Shanghai",
"venueTime": "2026-06-16 20:00:00 America/Chicago",
"venueTimezone": "America/Chicago"
},
"homeTeam": {
"id": 11138,
"name": "Argentina",
"names": {
"zh": "阿根廷",
"en": "Argentina",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018869978.png"
},
"awayTeam": {
"id": 20195,
"name": "Algeria",
"names": {
"zh": "阿尔及利亚",
"en": "Algeria",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018769444.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Arrowhead Stadium",
"en": "Arrowhead Stadium",
"zhTw": null,
"vi": "Arrowhead Stadium",
"km": "Arrowhead Stadium",
"ms": "Arrowhead Stadium"
},
"city": {
"zh": "堪萨斯城",
"en": "Kansas City",
"zhTw": null,
"vi": "Kansas City",
"km": "Kansas City",
"ms": "Kansas City"
}
},
"sortOrder": 19,
"isPublished": true
},
{
"officialMatchNo": 20,
"stage": "group",
"groupName": "J",
"liveMatchId": 20148778,
"additionMatchId": 20260020,
"channelId": null,
"matchName": "Austria - Jordan",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781668800,
"utcTimeStop": 1781676000,
"utcIso": "2026-06-17T04:00:00Z",
"chinaTime": "2026-06-17 12:00:00 Asia/Shanghai",
"venueTime": "2026-06-16 21:00:00 America/Los_Angeles",
"venueTimezone": "America/Los_Angeles"
},
"homeTeam": {
"id": 11035,
"name": "Austria",
"names": {
"zh": "奥地利",
"en": "Austria",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h3eak1vzg7.png"
},
"awayTeam": {
"id": 11244,
"name": "Jordan",
"names": {
"zh": "约旦",
"en": "Jordan",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018997867.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Levi's Stadium",
"en": "Levi's Stadium",
"zhTw": null,
"vi": "Levi's Stadium",
"km": "Levi's Stadium",
"ms": "Levi's Stadium"
},
"city": {
"zh": "San Francisco Bay Area",
"en": "San Francisco Bay Area",
"zhTw": null,
"vi": "San Francisco Bay Area",
"km": "San Francisco Bay Area",
"ms": "San Francisco Bay Area"
}
},
"sortOrder": 20,
"isPublished": true
},
{
"officialMatchNo": 43,
"stage": "group",
"groupName": "J",
"liveMatchId": 20148779,
"additionMatchId": 20260043,
"channelId": null,
"matchName": "Argentina - Austria",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782147600,
"utcTimeStop": 1782154800,
"utcIso": "2026-06-22T17:00:00Z",
"chinaTime": "2026-06-23 01:00:00 Asia/Shanghai",
"venueTime": "2026-06-22 12:00:00 America/Chicago",
"venueTimezone": "America/Chicago"
},
"homeTeam": {
"id": 11138,
"name": "Argentina",
"names": {
"zh": "阿根廷",
"en": "Argentina",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018869978.png"
},
"awayTeam": {
"id": 11035,
"name": "Austria",
"names": {
"zh": "奥地利",
"en": "Austria",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h3eak1vzg7.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "AT&T Stadium",
"en": "AT&T Stadium",
"zhTw": null,
"vi": "AT&T Stadium",
"km": "AT&T Stadium",
"ms": "AT&T Stadium"
},
"city": {
"zh": "达拉斯",
"en": "Dallas",
"zhTw": null,
"vi": "Dallas",
"km": "Dallas",
"ms": "Dallas"
}
},
"sortOrder": 43,
"isPublished": true
},
{
"officialMatchNo": 44,
"stage": "group",
"groupName": "J",
"liveMatchId": 20148819,
"additionMatchId": 20260044,
"channelId": null,
"matchName": "Jordan - Algeria",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782183600,
"utcTimeStop": 1782190800,
"utcIso": "2026-06-23T03:00:00Z",
"chinaTime": "2026-06-23 11:00:00 Asia/Shanghai",
"venueTime": "2026-06-22 20:00:00 America/Los_Angeles",
"venueTimezone": "America/Los_Angeles"
},
"homeTeam": {
"id": 11244,
"name": "Jordan",
"names": {
"zh": "约旦",
"en": "Jordan",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018997867.png"
},
"awayTeam": {
"id": 20195,
"name": "Algeria",
"names": {
"zh": "阿尔及利亚",
"en": "Algeria",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018769444.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Levi's Stadium",
"en": "Levi's Stadium",
"zhTw": null,
"vi": "Levi's Stadium",
"km": "Levi's Stadium",
"ms": "Levi's Stadium"
},
"city": {
"zh": "San Francisco Bay Area",
"en": "San Francisco Bay Area",
"zhTw": null,
"vi": "San Francisco Bay Area",
"km": "San Francisco Bay Area",
"ms": "San Francisco Bay Area"
}
},
"sortOrder": 44,
"isPublished": true
},
{
"officialMatchNo": 67,
"stage": "group",
"groupName": "J",
"liveMatchId": 20148792,
"additionMatchId": 20260067,
"channelId": null,
"matchName": "Algeria - Austria",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782612000,
"utcTimeStop": 1782619200,
"utcIso": "2026-06-28T02:00:00Z",
"chinaTime": "2026-06-28 10:00:00 Asia/Shanghai",
"venueTime": "2026-06-27 21:00:00 America/Chicago",
"venueTimezone": "America/Chicago"
},
"homeTeam": {
"id": 20195,
"name": "Algeria",
"names": {
"zh": "阿尔及利亚",
"en": "Algeria",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018769444.png"
},
"awayTeam": {
"id": 11035,
"name": "Austria",
"names": {
"zh": "奥地利",
"en": "Austria",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h3eak1vzg7.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Arrowhead Stadium",
"en": "Arrowhead Stadium",
"zhTw": null,
"vi": "Arrowhead Stadium",
"km": "Arrowhead Stadium",
"ms": "Arrowhead Stadium"
},
"city": {
"zh": "堪萨斯城",
"en": "Kansas City",
"zhTw": null,
"vi": "Kansas City",
"km": "Kansas City",
"ms": "Kansas City"
}
},
"sortOrder": 67,
"isPublished": true
},
{
"officialMatchNo": 68,
"stage": "group",
"groupName": "J",
"liveMatchId": 20148780,
"additionMatchId": 20260068,
"channelId": null,
"matchName": "Jordan - Argentina",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782612000,
"utcTimeStop": 1782619200,
"utcIso": "2026-06-28T02:00:00Z",
"chinaTime": "2026-06-28 10:00:00 Asia/Shanghai",
"venueTime": "2026-06-27 21:00:00 America/Chicago",
"venueTimezone": "America/Chicago"
},
"homeTeam": {
"id": 11244,
"name": "Jordan",
"names": {
"zh": "约旦",
"en": "Jordan",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018997867.png"
},
"awayTeam": {
"id": 11138,
"name": "Argentina",
"names": {
"zh": "阿根廷",
"en": "Argentina",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018869978.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "AT&T Stadium",
"en": "AT&T Stadium",
"zhTw": null,
"vi": "AT&T Stadium",
"km": "AT&T Stadium",
"ms": "AT&T Stadium"
},
"city": {
"zh": "达拉斯",
"en": "Dallas",
"zhTw": null,
"vi": "Dallas",
"km": "Dallas",
"ms": "Dallas"
}
},
"sortOrder": 68,
"isPublished": true
},
{
"officialMatchNo": 21,
"stage": "group",
"groupName": "K",
"liveMatchId": 20148820,
"additionMatchId": 20260021,
"channelId": null,
"matchName": "Portugal - DR Congo",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781715600,
"utcTimeStop": 1781722800,
"utcIso": "2026-06-17T17:00:00Z",
"chinaTime": "2026-06-18 01:00:00 Asia/Shanghai",
"venueTime": "2026-06-17 12:00:00 America/Chicago",
"venueTimezone": "America/Chicago"
},
"homeTeam": {
"id": 11137,
"name": "Portugal",
"names": {
"zh": "葡萄牙",
"en": "Portugal",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983333830.png"
},
"awayTeam": {
"id": 50470,
"name": "DR Congo",
"names": {
"zh": "刚果民主共和国",
"en": "DR Congo",
"zhTw": "刚果民主共和国",
"vi": "CHDC Congo",
"km": "DR Congo",
"ms": "DR Congo"
},
"image": "https://flagcdn.com/cd.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "NRG Stadium",
"en": "NRG Stadium",
"zhTw": null,
"vi": "NRG Stadium",
"km": "NRG Stadium",
"ms": "NRG Stadium"
},
"city": {
"zh": "休斯敦",
"en": "Houston",
"zhTw": null,
"vi": "Houston",
"km": "Houston",
"ms": "Houston"
}
},
"sortOrder": 21,
"isPublished": true
},
{
"officialMatchNo": 24,
"stage": "group",
"groupName": "K",
"liveMatchId": 20148781,
"additionMatchId": 20260024,
"channelId": null,
"matchName": "Uzbekistan - Colombia",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781748000,
"utcTimeStop": 1781755200,
"utcIso": "2026-06-18T02:00:00Z",
"chinaTime": "2026-06-18 10:00:00 Asia/Shanghai",
"venueTime": "2026-06-17 20:00:00 America/Mexico_City",
"venueTimezone": "America/Mexico_City"
},
"homeTeam": {
"id": 11239,
"name": "Uzbekistan",
"names": {
"zh": "乌兹别克斯坦",
"en": "Uzbekistan",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166261269158.png"
},
"awayTeam": {
"id": 11147,
"name": "Colombia",
"names": {
"zh": "哥伦比亚",
"en": "Colombia",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018896247.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Estadio Azteca",
"en": "Estadio Azteca",
"zhTw": null,
"vi": "Estadio Azteca",
"km": "Estadio Azteca",
"ms": "Estadio Azteca"
},
"city": {
"zh": "墨西哥城",
"en": "Mexico City",
"zhTw": null,
"vi": "Mexico City",
"km": "Mexico City",
"ms": "Mexico City"
}
},
"sortOrder": 24,
"isPublished": true
},
{
"officialMatchNo": 45,
"stage": "group",
"groupName": "K",
"liveMatchId": 20148782,
"additionMatchId": 20260045,
"channelId": null,
"matchName": "Portugal - Uzbekistan",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782234000,
"utcTimeStop": 1782241200,
"utcIso": "2026-06-23T17:00:00Z",
"chinaTime": "2026-06-24 01:00:00 Asia/Shanghai",
"venueTime": "2026-06-23 12:00:00 America/Chicago",
"venueTimezone": "America/Chicago"
},
"homeTeam": {
"id": 11137,
"name": "Portugal",
"names": {
"zh": "葡萄牙",
"en": "Portugal",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983333830.png"
},
"awayTeam": {
"id": 11239,
"name": "Uzbekistan",
"names": {
"zh": "乌兹别克斯坦",
"en": "Uzbekistan",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166261269158.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "NRG Stadium",
"en": "NRG Stadium",
"zhTw": null,
"vi": "NRG Stadium",
"km": "NRG Stadium",
"ms": "NRG Stadium"
},
"city": {
"zh": "休斯敦",
"en": "Houston",
"zhTw": null,
"vi": "Houston",
"km": "Houston",
"ms": "Houston"
}
},
"sortOrder": 45,
"isPublished": true
},
{
"officialMatchNo": 48,
"stage": "group",
"groupName": "K",
"liveMatchId": 20148821,
"additionMatchId": 20260048,
"channelId": null,
"matchName": "Colombia - DR Congo",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782266400,
"utcTimeStop": 1782273600,
"utcIso": "2026-06-24T02:00:00Z",
"chinaTime": "2026-06-24 10:00:00 Asia/Shanghai",
"venueTime": "2026-06-23 20:00:00 America/Mexico_City",
"venueTimezone": "America/Mexico_City"
},
"homeTeam": {
"id": 11147,
"name": "Colombia",
"names": {
"zh": "哥伦比亚",
"en": "Colombia",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018896247.png"
},
"awayTeam": {
"id": 50470,
"name": "DR Congo",
"names": {
"zh": "刚果民主共和国",
"en": "DR Congo",
"zhTw": "刚果民主共和国",
"vi": "CHDC Congo",
"km": "DR Congo",
"ms": "DR Congo"
},
"image": "https://flagcdn.com/cd.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Estadio Akron",
"en": "Estadio Akron",
"zhTw": null,
"vi": "Estadio Akron",
"km": "Estadio Akron",
"ms": "Estadio Akron"
},
"city": {
"zh": "瓜达拉哈拉",
"en": "Guadalajara",
"zhTw": null,
"vi": "Guadalajara",
"km": "Guadalajara",
"ms": "Guadalajara"
}
},
"sortOrder": 48,
"isPublished": true
},
{
"officialMatchNo": 69,
"stage": "group",
"groupName": "K",
"liveMatchId": 20148783,
"additionMatchId": 20260069,
"channelId": null,
"matchName": "Colombia - Portugal",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782603000,
"utcTimeStop": 1782610200,
"utcIso": "2026-06-27T23:30:00Z",
"chinaTime": "2026-06-28 07:30:00 Asia/Shanghai",
"venueTime": "2026-06-27 19:30:00 America/New_York",
"venueTimezone": "America/New_York"
},
"homeTeam": {
"id": 11147,
"name": "Colombia",
"names": {
"zh": "哥伦比亚",
"en": "Colombia",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018896247.png"
},
"awayTeam": {
"id": 11137,
"name": "Portugal",
"names": {
"zh": "葡萄牙",
"en": "Portugal",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983333830.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Hard Rock Stadium",
"en": "Hard Rock Stadium",
"zhTw": null,
"vi": "Hard Rock Stadium",
"km": "Hard Rock Stadium",
"ms": "Hard Rock Stadium"
},
"city": {
"zh": "迈阿密",
"en": "Miami",
"zhTw": null,
"vi": "Miami",
"km": "Miami",
"ms": "Miami"
}
},
"sortOrder": 69,
"isPublished": true
},
{
"officialMatchNo": 70,
"stage": "group",
"groupName": "K",
"liveMatchId": 20148793,
"additionMatchId": 20260070,
"channelId": null,
"matchName": "DR Congo - Uzbekistan",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782603000,
"utcTimeStop": 1782610200,
"utcIso": "2026-06-27T23:30:00Z",
"chinaTime": "2026-06-28 07:30:00 Asia/Shanghai",
"venueTime": "2026-06-27 19:30:00 America/New_York",
"venueTimezone": "America/New_York"
},
"homeTeam": {
"id": 50470,
"name": "DR Congo",
"names": {
"zh": "刚果民主共和国",
"en": "DR Congo",
"zhTw": "刚果民主共和国",
"vi": "CHDC Congo",
"km": "DR Congo",
"ms": "DR Congo"
},
"image": "https://flagcdn.com/cd.svg"
},
"awayTeam": {
"id": 11239,
"name": "Uzbekistan",
"names": {
"zh": "乌兹别克斯坦",
"en": "Uzbekistan",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166261269158.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Mercedes-Benz Stadium",
"en": "Mercedes-Benz Stadium",
"zhTw": null,
"vi": "Mercedes-Benz Stadium",
"km": "Mercedes-Benz Stadium",
"ms": "Mercedes-Benz Stadium"
},
"city": {
"zh": "亚特兰大",
"en": "Atlanta",
"zhTw": null,
"vi": "Atlanta",
"km": "Atlanta",
"ms": "Atlanta"
}
},
"sortOrder": 70,
"isPublished": true
},
{
"officialMatchNo": 22,
"stage": "group",
"groupName": "L",
"liveMatchId": 20148784,
"additionMatchId": 20260022,
"channelId": null,
"matchName": "England - Croatia",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781726400,
"utcTimeStop": 1781733600,
"utcIso": "2026-06-17T20:00:00Z",
"chinaTime": "2026-06-18 04:00:00 Asia/Shanghai",
"venueTime": "2026-06-17 15:00:00 America/Chicago",
"venueTimezone": "America/Chicago"
},
"homeTeam": {
"id": 11116,
"name": "England",
"names": {
"zh": "英格兰",
"en": "England",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983421453.png"
},
"awayTeam": {
"id": 11140,
"name": "Croatia",
"names": {
"zh": "克罗地亚",
"en": "Croatia",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983770149.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "AT&T Stadium",
"en": "AT&T Stadium",
"zhTw": null,
"vi": "AT&T Stadium",
"km": "AT&T Stadium",
"ms": "AT&T Stadium"
},
"city": {
"zh": "达拉斯",
"en": "Dallas",
"zhTw": null,
"vi": "Dallas",
"km": "Dallas",
"ms": "Dallas"
}
},
"sortOrder": 22,
"isPublished": true
},
{
"officialMatchNo": 23,
"stage": "group",
"groupName": "L",
"liveMatchId": 20148967,
"additionMatchId": 20260023,
"channelId": null,
"matchName": "Ghana - Panama",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781737200,
"utcTimeStop": 1781744400,
"utcIso": "2026-06-17T23:00:00Z",
"chinaTime": "2026-06-18 07:00:00 Asia/Shanghai",
"venueTime": "2026-06-17 19:00:00 America/Toronto",
"venueTimezone": "America/Toronto"
},
"homeTeam": {
"id": 11179,
"name": "Ghana",
"names": {
"zh": "加纳",
"en": "Ghana",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018802248.png"
},
"awayTeam": {
"id": 11169,
"name": "Panama",
"names": {
"zh": "巴拿马",
"en": "Panama",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018878036.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "BMO Field",
"en": "BMO Field",
"zhTw": null,
"vi": "BMO Field",
"km": "BMO Field",
"ms": "BMO Field"
},
"city": {
"zh": "多伦多",
"en": "Toronto",
"zhTw": null,
"vi": "Toronto",
"km": "Toronto",
"ms": "Toronto"
}
},
"sortOrder": 23,
"isPublished": true
},
{
"officialMatchNo": 46,
"stage": "group",
"groupName": "L",
"liveMatchId": 20148786,
"additionMatchId": 20260046,
"channelId": null,
"matchName": "England - Ghana",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782244800,
"utcTimeStop": 1782252000,
"utcIso": "2026-06-23T20:00:00Z",
"chinaTime": "2026-06-24 04:00:00 Asia/Shanghai",
"venueTime": "2026-06-23 16:00:00 America/New_York",
"venueTimezone": "America/New_York"
},
"homeTeam": {
"id": 11116,
"name": "England",
"names": {
"zh": "英格兰",
"en": "England",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983421453.png"
},
"awayTeam": {
"id": 11179,
"name": "Ghana",
"names": {
"zh": "加纳",
"en": "Ghana",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018802248.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Gillette Stadium",
"en": "Gillette Stadium",
"zhTw": null,
"vi": "Gillette Stadium",
"km": "Gillette Stadium",
"ms": "Gillette Stadium"
},
"city": {
"zh": "波士顿",
"en": "Boston",
"zhTw": null,
"vi": "Boston",
"km": "Boston",
"ms": "Boston"
}
},
"sortOrder": 46,
"isPublished": true
},
{
"officialMatchNo": 47,
"stage": "group",
"groupName": "L",
"liveMatchId": 20148787,
"additionMatchId": 20260047,
"channelId": null,
"matchName": "Panama - Croatia",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782255600,
"utcTimeStop": 1782262800,
"utcIso": "2026-06-23T23:00:00Z",
"chinaTime": "2026-06-24 07:00:00 Asia/Shanghai",
"venueTime": "2026-06-23 19:00:00 America/Toronto",
"venueTimezone": "America/Toronto"
},
"homeTeam": {
"id": 11169,
"name": "Panama",
"names": {
"zh": "巴拿马",
"en": "Panama",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018878036.png"
},
"awayTeam": {
"id": 11140,
"name": "Croatia",
"names": {
"zh": "克罗地亚",
"en": "Croatia",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983770149.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "BMO Field",
"en": "BMO Field",
"zhTw": null,
"vi": "BMO Field",
"km": "BMO Field",
"ms": "BMO Field"
},
"city": {
"zh": "多伦多",
"en": "Toronto",
"zhTw": null,
"vi": "Toronto",
"km": "Toronto",
"ms": "Toronto"
}
},
"sortOrder": 47,
"isPublished": true
},
{
"officialMatchNo": 71,
"stage": "group",
"groupName": "L",
"liveMatchId": 20148788,
"additionMatchId": 20260071,
"channelId": null,
"matchName": "Panama - England",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782594000,
"utcTimeStop": 1782601200,
"utcIso": "2026-06-27T21:00:00Z",
"chinaTime": "2026-06-28 05:00:00 Asia/Shanghai",
"venueTime": "2026-06-27 17:00:00 America/New_York",
"venueTimezone": "America/New_York"
},
"homeTeam": {
"id": 11169,
"name": "Panama",
"names": {
"zh": "巴拿马",
"en": "Panama",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018878036.png"
},
"awayTeam": {
"id": 11116,
"name": "England",
"names": {
"zh": "英格兰",
"en": "England",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983421453.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "MetLife Stadium",
"en": "MetLife Stadium",
"zhTw": null,
"vi": "MetLife Stadium",
"km": "MetLife Stadium",
"ms": "MetLife Stadium"
},
"city": {
"zh": "纽约/新泽西",
"en": "New York / New Jersey",
"zhTw": null,
"vi": "New York / New Jersey",
"km": "New York / New Jersey",
"ms": "New York / New Jersey"
}
},
"sortOrder": 71,
"isPublished": true
},
{
"officialMatchNo": 72,
"stage": "group",
"groupName": "L",
"liveMatchId": 20148789,
"additionMatchId": 20260072,
"channelId": null,
"matchName": "Croatia - Ghana",
"league": {
"type": "FOOTBALL",
"en": "FIFA World Cup 2026",
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1782594000,
"utcTimeStop": 1782601200,
"utcIso": "2026-06-27T21:00:00Z",
"chinaTime": "2026-06-28 05:00:00 Asia/Shanghai",
"venueTime": "2026-06-27 17:00:00 America/New_York",
"venueTimezone": "America/New_York"
},
"homeTeam": {
"id": 11140,
"name": "Croatia",
"names": {
"zh": "克罗地亚",
"en": "Croatia",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983770149.png"
},
"awayTeam": {
"id": 11179,
"name": "Ghana",
"names": {
"zh": "加纳",
"en": "Ghana",
"zhTw": "",
"vi": null,
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018802248.png"
},
"status": {
"state": "scheduled",
"isHot": 1000
},
"venue": {
"names": {
"zh": "Lincoln Financial Field",
"en": "Lincoln Financial Field",
"zhTw": null,
"vi": "Lincoln Financial Field",
"km": "Lincoln Financial Field",
"ms": "Lincoln Financial Field"
},
"city": {
"zh": "费城",
"en": "Philadelphia",
"zhTw": null,
"vi": "Philadelphia",
"km": "Philadelphia",
"ms": "Philadelphia"
}
},
"sortOrder": 72,
"isPublished": true
}
]
}

View File

@@ -0,0 +1,103 @@
/** 由 build-wc2026-seed-json.mjs 生成 — zhibo externalId → WC2026 canonical code */
export const WC2026_ZIBO_ID_TO_CODE: Record<number, string> = {
11029: 'NOR',
11030: 'SCO',
11032: 'SWE',
11033: 'BEL',
11034: 'NED',
11035: 'AUT',
11036: 'SUI',
11037: 'FRA',
11038: 'GER',
11109: 'EGY',
11116: 'ENG',
11137: 'POR',
11138: 'ARG',
11139: 'URU',
11140: 'CRO',
11144: 'ESP',
11147: 'COL',
11148: 'PAR',
11150: 'BRA',
11151: 'ECU',
11153: 'BIH',
11154: 'IRN',
11161: 'CPV',
11166: 'CAN',
11168: 'USA',
11169: 'PAN',
11173: 'RSA',
11179: 'GHA',
11182: 'MAR',
11184: 'SEN',
11188: 'MEX',
11190: 'TUN',
11238: 'IRQ',
11239: 'UZB',
11244: 'JOR',
11254: 'KSA',
11261: 'KOR',
11266: 'JPN',
11267: 'QAT',
11270: 'HAI',
11273: 'AUS',
12310: 'NZL',
19979: 'CUW',
20195: 'ALG',
50467: 'CZE',
50468: 'TUR',
50469: 'CIV',
50470: 'COD',
};
/** zhibo 球队 logoseed 时写入 teams.logo_url */
export const WC2026_TEAM_LOGO_BY_CODE: Record<string, string> = {
ALG: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018769444.png',
ARG: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018869978.png',
AUS: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018939680.png',
AUT: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h3eak1vzg7.png',
BEL: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1j0aw46xvtk.png',
BIH: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983517667.png',
BRA: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018879430.png',
CAN: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018903917.png',
CIV: 'https://flagcdn.com/ci.svg',
COD: 'https://flagcdn.com/cd.svg',
COL: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018896247.png',
CPV: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018791043.png',
CRO: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983770149.png',
CUW: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1gwbryn6pn1.png',
CZE: 'https://flagcdn.com/cz.svg',
ECU: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018888622.png',
EGY: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018770975.png',
ENG: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983421453.png',
ESP: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983321668.png',
FRA: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983446440.png',
GER: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983677229.png',
GHA: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018802248.png',
HAI: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018900888.png',
IRN: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018993893.png',
IRQ: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018992317.png',
JOR: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018997867.png',
JPN: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166919982535.png',
KOR: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164974972753.png',
KSA: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018978353.png',
MAR: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1hkv9bnt8sv.png',
MEX: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018908829.png',
NED: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/16498334962.png',
NOR: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983671192.png',
NZL: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018693635.png',
PAN: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018878036.png',
PAR: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018876382.png',
POR: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983333830.png',
QAT: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166968529845.png',
RSA: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018843635.png',
SCO: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164984175759.png',
SEN: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018850327.png',
SUI: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983533378.png',
SWE: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983505795.png',
TUN: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018860211.png',
TUR: 'https://flagcdn.com/tr.svg',
URU: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h1kc21h0g2r.png',
USA: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165102965258.png',
UZB: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166261269158.png',
};

View File

@@ -0,0 +1,165 @@
import type { PrismaClient } from '@prisma/client';
/** 为演示赛事补齐详情页玩法(与后台 markets 模板一致) */
export async function seedDemoMarkets(prisma: PrismaClient, matchId: bigint) {
const configs: Array<{
marketType: string;
period: string;
lineValue?: number;
sortOrder: number;
selections: Array<{ code: string; name: string; odds: number }>;
}> = [
{
marketType: 'FT_1X2',
period: 'FT',
sortOrder: 1,
selections: [
{ code: 'HOME', name: '主胜', odds: 2.5 },
{ code: 'DRAW', name: '和', odds: 3.2 },
{ code: 'AWAY', name: '客胜', odds: 2.8 },
],
},
{
marketType: 'FT_HANDICAP',
period: 'FT',
lineValue: -0.5,
sortOrder: 2,
selections: [
{ code: 'HOME', name: '主 -0.5', odds: 1.9 },
{ code: 'AWAY', name: '客 +0.5', odds: 1.9 },
],
},
{
marketType: 'FT_OVER_UNDER',
period: 'FT',
lineValue: 2.5,
sortOrder: 3,
selections: [
{ code: 'OVER', name: '大 2.5', odds: 1.85 },
{ code: 'UNDER', name: '小 2.5', odds: 1.95 },
],
},
{
marketType: 'FT_ODD_EVEN',
period: 'FT',
sortOrder: 4,
selections: [
{ code: 'ODD', name: '单', odds: 1.9 },
{ code: 'EVEN', name: '双', odds: 1.9 },
],
},
{
marketType: 'HT_1X2',
period: 'HT',
sortOrder: 5,
selections: [
{ code: 'HOME', name: '半场主', odds: 3.0 },
{ code: 'DRAW', name: '半场和', odds: 2.0 },
{ code: 'AWAY', name: '半场客', odds: 3.5 },
],
},
{
marketType: 'HT_HANDICAP',
period: 'HT',
lineValue: -0.5,
sortOrder: 6,
selections: [
{ code: 'HOME', name: '半场主 -0.5', odds: 1.9 },
{ code: 'AWAY', name: '半场客 +0.5', odds: 1.9 },
],
},
{
marketType: 'HT_OVER_UNDER',
period: 'HT',
lineValue: 1.5,
sortOrder: 7,
selections: [
{ code: 'OVER', name: '半场大 1.5', odds: 2.0 },
{ code: 'UNDER', name: '半场小 1.5', odds: 1.75 },
],
},
{
marketType: 'FT_CORRECT_SCORE',
period: 'FT',
sortOrder: 8,
selections: [
{ code: 'SCORE_1_0', name: '1-0', odds: 4.86 },
{ code: 'SCORE_2_0', name: '2-0', odds: 5.22 },
{ code: 'SCORE_2_1', name: '2-1', odds: 7.92 },
{ code: 'SCORE_3_0', name: '3-0', odds: 8.28 },
{ code: 'SCORE_0_0', name: '0-0', odds: 8.64 },
{ code: 'SCORE_1_1', name: '1-1', odds: 7.47 },
{ code: 'SCORE_2_2', name: '2-2', odds: 24.3 },
{ code: 'SCORE_3_3', name: '3-3', odds: 175.5 },
{ code: 'SCORE_0_1', name: '0-1', odds: 14.4 },
{ code: 'SCORE_0_2', name: '0-2', odds: 45.9 },
{ code: 'SCORE_1_2', name: '1-2', odds: 23.4 },
{ code: 'SCORE_0_3', name: '0-3', odds: 207 },
],
},
{
marketType: 'HT_CORRECT_SCORE',
period: 'HT',
sortOrder: 9,
selections: [
{ code: 'SCORE_1_0', name: '1-0', odds: 4.5 },
{ code: 'SCORE_2_0', name: '2-0', odds: 8.0 },
{ code: 'SCORE_0_0', name: '0-0', odds: 5.5 },
{ code: 'SCORE_1_1', name: '1-1', odds: 7.0 },
{ code: 'SCORE_0_1', name: '0-1', odds: 6.5 },
{ code: 'SCORE_0_2', name: '0-2', odds: 18.0 },
],
},
{
marketType: 'SH_CORRECT_SCORE',
period: 'SH',
sortOrder: 10,
selections: [
{ code: 'SCORE_1_0', name: '1-0', odds: 4.5 },
{ code: 'SCORE_2_0', name: '2-0', odds: 8.0 },
{ code: 'SCORE_0_0', name: '0-0', odds: 5.5 },
{ code: 'SCORE_1_1', name: '1-1', odds: 7.0 },
{ code: 'SCORE_0_1', name: '0-1', odds: 6.5 },
{ code: 'SCORE_0_2', name: '0-2', odds: 18.0 },
],
},
];
for (const cfg of configs) {
const exists = await prisma.market.findFirst({
where: { matchId, marketType: cfg.marketType },
include: { _count: { select: { selections: true } } },
});
if (exists) {
const needRefresh =
cfg.marketType.includes('CORRECT_SCORE') &&
exists._count.selections < cfg.selections.length;
if (needRefresh) {
await prisma.market.delete({ where: { id: exists.id } });
} else {
continue;
}
}
await prisma.market.create({
data: {
matchId,
marketType: cfg.marketType,
period: cfg.period,
lineValue: cfg.lineValue,
allowSingle: true,
allowParlay: true,
sortOrder: cfg.sortOrder,
status: 'OPEN',
selections: {
create: cfg.selections.map((s, i) => ({
selectionCode: s.code,
selectionName: s.name,
odds: s.odds,
sortOrder: i,
status: 'OPEN',
})),
},
},
});
}
}

View File

@@ -31,6 +31,10 @@ export function appForbidden(code: ApiErrorCode, params?: ApiErrorParams) {
return new ForbiddenException(body(code, params));
}
export function appConflict(code: ApiErrorCode, data?: unknown, params?: ApiErrorParams) {
return new HttpException({ ...body(code, params), data: data ?? null }, HttpStatus.CONFLICT);
}
export function appUnauthorized(code: ApiErrorCode, params?: ApiErrorParams) {
return new UnauthorizedException(body(code, params));
}

View File

@@ -27,6 +27,7 @@ export class GlobalExceptionFilter implements ExceptionFilter {
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let code: ApiErrorCode = 'INTERNAL_SERVER_ERROR';
let params: ApiErrorParams | undefined;
let extraData: unknown = null;
let message = formatApiErrorMessage('INTERNAL_SERVER_ERROR', locale);
if (exception instanceof HttpException) {
@@ -37,6 +38,9 @@ export class GlobalExceptionFilter implements ExceptionFilter {
code = res.code;
params = res.params;
message = formatApiErrorMessage(code, locale, params);
if (typeof res === 'object' && res !== null && 'data' in res) {
extraData = (res as { data?: unknown }).data ?? null;
}
} else if (typeof res === 'string') {
message = res;
} else if (typeof res === 'object' && res !== null) {
@@ -60,7 +64,7 @@ export class GlobalExceptionFilter implements ExceptionFilter {
error: message,
code,
params: params ?? null,
data: null,
data: extraData,
});
}
}

View File

@@ -0,0 +1,18 @@
import { unlink } from 'fs/promises';
import { join } from 'path';
import { getUploadRoot } from './upload-paths';
const UPLOAD_URL_PREFIX = '/uploads/';
/** 按 `/uploads/{category}/{filename}` 删除磁盘文件;路径非法或文件不存在时静默跳过 */
export async function deleteUploadFileByUrl(url: string): Promise<void> {
if (!url?.startsWith(UPLOAD_URL_PREFIX)) return;
const relative = url.slice(UPLOAD_URL_PREFIX.length);
if (!relative || relative.includes('..') || relative.includes('\\')) return;
const root = getUploadRoot();
try {
await unlink(join(root, relative));
} catch {
/* already removed */
}
}

View File

@@ -1,4 +1,13 @@
import axios from 'axios';
import { isApiErrorCode } from '@thebet365/shared';
import { useAuthStore } from '../stores/auth';
const ACCOUNT_BLOCKED_CODES = new Set([
'ACCOUNT_SUSPENDED',
'ACCOUNT_DISABLED',
'AGENT_ACCOUNT_SUSPENDED',
'PARENT_AGENT_SUSPENDED',
]);
const api = axios.create({ baseURL: '/api' });
@@ -12,16 +21,28 @@ api.interceptors.request.use((config) => {
return config;
});
function clearSession() {
localStorage.removeItem('token');
localStorage.removeItem('user');
try {
useAuthStore().logout();
} catch {
// Pinia may not be ready during bootstrap
}
}
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
const url: string = err.config?.url ?? '';
// Don't redirect on login/auth failures — let the caller handle the error
if (!url.includes('/auth/login') && !url.includes('/auth/register')) {
localStorage.removeItem('token');
// 不再强制跳转登录页,让调用方处理 401
}
const status = err.response?.status;
const url: string = err.config?.url ?? '';
const isAuthEndpoint = url.includes('/auth/login') || url.includes('/auth/register');
const code = err.response?.data?.code;
const blockedAccount =
typeof code === 'string' && isApiErrorCode(code) && ACCOUNT_BLOCKED_CODES.has(code);
if (!isAuthEndpoint && (status === 401 || (status === 403 && blockedAccount))) {
clearSession();
}
return Promise.reject(err);
},

View File

@@ -52,6 +52,10 @@ watch(
.team-emblem {
flex-shrink: 0;
display: block;
}
/* 队徽(非国旗):圆角 + 投影 */
.team-emblem--logo {
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
}
@@ -74,6 +78,8 @@ watch(
/* 国旗:横向比例 + 铺满 */
.team-emblem:not(.team-emblem--logo) {
object-fit: cover;
border-radius: 3px;
background: transparent;
}
.team-emblem--sm:not(.team-emblem--logo) {

View File

@@ -78,6 +78,8 @@ export default {
go_register: 'No account? Register now',
have_account: 'Already have an account? Log in',
register_btn: 'Register',
registering: 'Registering…',
sending_sms: 'Sending…',
register_failed: 'Registration failed, please try again',
continue_browsing: 'Skip login',
username_placeholder: 'Enter username',
@@ -195,7 +197,7 @@ export default {
amount_label: 'Amount',
amount_placeholder: 'Enter recharge amount',
screenshot_label: 'Upload Screenshot',
upload_hint: 'Click to upload screenshot (max 5MB)',
upload_hint: 'Click to upload (max 10MB original, compressed to within 1MB)',
compressing: 'Compressing',
submit: 'Submit',
submitting: 'Submitting',
@@ -208,7 +210,8 @@ export default {
upload_screenshot: 'Please upload a screenshot',
submit_failed: 'Submit failed, please retry',
file_must_be_image: 'Please upload an image file',
file_too_large: 'File exceeds 10MB',
file_too_large: 'Original file must be 10MB or less',
compress_failed: 'Could not compress the image to within 1MB. Try a smaller screenshot.',
status_pending: 'Processing',
status_approved: 'Approved',
status_rejected: 'Rejected',

View File

@@ -84,6 +84,8 @@ export default {
go_register: 'Tiada akaun? Daftar sekarang',
have_account: 'Sudah ada akaun? Log masuk',
register_btn: 'Daftar',
registering: 'Mendaftar…',
sending_sms: 'Menghantar…',
register_failed: 'Pendaftaran gagal, sila cuba lagi',
continue_browsing: 'Langkau log masuk',
username_placeholder: 'Masukkan nama pengguna',
@@ -201,7 +203,7 @@ export default {
amount_label: 'Jumlah',
amount_placeholder: 'Masukkan jumlah topup',
screenshot_label: 'Muat Naik Screenshot',
upload_hint: 'Klik untuk muat naik (maks 5MB)',
upload_hint: 'Klik untuk muat naik (asal maks 10MB, dimampatkan ≤1MB)',
compressing: 'Memampat',
submit: 'Hantar',
submitting: 'Menghantar',
@@ -214,7 +216,8 @@ export default {
upload_screenshot: 'Sila muat naik screenshot',
submit_failed: 'Gagal, sila cuba lagi',
file_must_be_image: 'Sila muat naik fail imej',
file_too_large: 'Fail melebihi 10MB',
file_too_large: 'Fail asal melebihi 10MB',
compress_failed: 'Gagal memampatkan imej ke ≤1MB. Sila gunakan tangkapan skrin lebih kecil.',
status_pending: 'Memproses',
status_approved: 'Diluluskan',
status_rejected: 'Ditolak',

View File

@@ -78,6 +78,8 @@ export default {
go_register: '没有账号?立即注册',
have_account: '已有账号?去登录',
register_btn: '注册',
registering: '注册中…',
sending_sms: '发送中…',
register_failed: '注册失败,请重试',
continue_browsing: '暂不登录',
username_placeholder: '请输入账号',
@@ -195,7 +197,7 @@ export default {
amount_label: '充值金额',
amount_placeholder: '请输入充值金额',
screenshot_label: '上传转账截图',
upload_hint: '点击上传截图(最大 5MB',
upload_hint: '点击上传截图(原图不超过 10MB将自动压缩至 1MB 以内',
compressing: '压缩中',
submit: '提交充值',
submitting: '提交中',
@@ -208,7 +210,8 @@ export default {
upload_screenshot: '请上传转账截图',
submit_failed: '提交失败,请重试',
file_must_be_image: '请上传图片文件',
file_too_large: '文件不能超过 10MB',
file_too_large: '原图不能超过 10MB',
compress_failed: '图片压缩失败或未压到 1MB 以内,请换一张更小的截图',
status_pending: '充值中',
status_approved: '已通过',
status_rejected: '已拒绝',

View File

@@ -67,6 +67,7 @@ interface MatchDetail {
status?: string;
bettingOpen?: boolean;
matchPhase?: MatchPhase;
correctScoreEnabled?: boolean;
score?: {
htHome: number;
htAway: number;
@@ -138,6 +139,14 @@ const marketsByType = computed(() => {
return map;
});
const CS_MARKET_TYPES = new Set(['FT_CORRECT_SCORE', 'HT_CORRECT_SCORE', 'SH_CORRECT_SCORE']);
const visibleMarketTypes = computed(() => {
const csEnabled = match.value?.correctScoreEnabled ?? true;
if (csEnabled) return DETAIL_MARKET_TYPES;
return DETAIL_MARKET_TYPES.filter((t) => !CS_MARKET_TYPES.has(t));
});
function marketPromoLabel(marketType: string) {
const m = marketsByType.value.get(marketType);
return m?.promoLabel?.trim() || '';
@@ -472,7 +481,7 @@ function hasSlipPickForMarket(marketType: string) {
<div class="market-list">
<div
v-for="marketType in DETAIL_MARKET_TYPES"
v-for="marketType in visibleMarketTypes"
:key="marketType"
class="market-group"
:class="{ open: isExpanded(marketType) }"

View File

@@ -59,6 +59,33 @@ function selectMethod(m: PaymentMethod) {
selectedMethod.value = m;
}
const MAX_ORIGINAL_BYTES = 10 * 1024 * 1024;
const MAX_SCREENSHOT_BYTES = 1024 * 1024;
async function compressScreenshot(file: File): Promise<File> {
const baseOptions = {
maxSizeMB: 1,
maxWidthOrHeight: 1920,
useWebWorker: true,
maxIteration: 15,
} as const;
const attempts = [
{ ...baseOptions, initialQuality: 0.85 },
{ ...baseOptions, initialQuality: 0.65, maxWidthOrHeight: 1600 },
{ ...baseOptions, initialQuality: 0.5, maxWidthOrHeight: 1280 },
];
for (const options of attempts) {
const compressed = (await imageCompression(file, options)) as File;
if (compressed.size <= MAX_SCREENSHOT_BYTES) {
return compressed;
}
}
throw new Error('COMPRESS_TOO_LARGE');
}
async function handleFileChange(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
@@ -70,27 +97,22 @@ async function handleFileChange(event: Event) {
return;
}
// Max 10MB before compression
if (file.size > 10 * 1024 * 1024) {
if (file.size > MAX_ORIGINAL_BYTES) {
alert(t('recharge.file_too_large'));
input.value = '';
return;
}
// Compress image
compressing.value = true;
try {
const compressed = await imageCompression(file, {
maxSizeMB: 1,
maxWidthOrHeight: 1920,
useWebWorker: true,
});
screenshotFile.value = compressed as File;
const compressed = await compressScreenshot(file);
screenshotFile.value = compressed;
screenshotPreview.value = URL.createObjectURL(compressed);
} catch {
// Fallback: use original if compression fails
screenshotFile.value = file;
screenshotPreview.value = URL.createObjectURL(file);
alert(t('recharge.compress_failed'));
screenshotFile.value = null;
screenshotPreview.value = '';
input.value = '';
} finally {
compressing.value = false;
}
@@ -115,6 +137,10 @@ async function handleSubmit() {
alert(t('recharge.upload_screenshot'));
return;
}
if (screenshotFile.value.size > MAX_SCREENSHOT_BYTES) {
alert(t('recharge.compress_failed'));
return;
}
submitting.value = true;
try {

View File

@@ -8,6 +8,7 @@ import { useAppLocale } from '../composables/useAppLocale';
import { useSmsCode } from '../composables/useSmsCode';
import LocaleSwitcher from '../components/LocaleSwitcher.vue';
import PhoneCountrySelect from '../components/PhoneCountrySelect.vue';
import GoldSpinner from '../components/GoldSpinner.vue';
import loginBg from '../assets/images/h5bg.webp';
const { t, locale } = useI18n();
@@ -104,7 +105,11 @@ const fieldError = () => {
<div class="login-lang">
<LocaleSwitcher compact />
</div>
<form @submit.prevent="submit" class="login-form ps-gold-frame">
<form @submit.prevent="submit" class="login-form ps-gold-frame" :class="{ 'is-busy': loading }">
<div v-if="loading" class="form-busy-overlay" aria-hidden="true">
<GoldSpinner :size="40" :active="true" />
<p class="form-busy-text">{{ t('auth.registering') }}</p>
</div>
<h2 class="form-title">{{ t('auth.register') }}</h2>
<div class="field">
@@ -116,6 +121,7 @@ const fieldError = () => {
autocomplete="username"
maxlength="32"
minlength="7"
:disabled="loading"
:placeholder="t('auth.username_register_placeholder')"
/>
</div>
@@ -130,6 +136,7 @@ const fieldError = () => {
type="tel"
required
autocomplete="tel-national"
:disabled="loading"
:placeholder="t('auth.phone_local_placeholder')"
/>
</div>
@@ -144,40 +151,55 @@ const fieldError = () => {
inputmode="numeric"
maxlength="6"
autocomplete="one-time-code"
:disabled="loading"
:placeholder="t('auth.sms_code_placeholder')"
/>
<button
type="button"
class="btn-secondary"
:disabled="sending || countdown > 0 || !phone.trim()"
:disabled="sending || countdown > 0 || !phone.trim() || loading"
@click="sendCode"
>
{{ countdown > 0 ? `${countdown}s` : t('auth.send_sms') }}
<span v-if="sending" class="btn-inline-loading">
<GoldSpinner :size="14" :active="true" />
{{ t('auth.sending_sms') }}
</span>
<span v-else>{{ countdown > 0 ? `${countdown}s` : t('auth.send_sms') }}</span>
</button>
</div>
</div>
<div class="field">
<label>{{ t('auth.password') }}</label>
<input v-model="password" class="field-input" type="password" required autocomplete="new-password" minlength="8" />
<input v-model="password" class="field-input" type="password" required autocomplete="new-password" minlength="8" :disabled="loading" />
</div>
<div class="field">
<label>{{ t('auth.confirm_password') }}</label>
<input v-model="confirmPassword" class="field-input" type="password" required autocomplete="new-password" minlength="8" />
<input v-model="confirmPassword" class="field-input" type="password" required autocomplete="new-password" minlength="8" :disabled="loading" />
</div>
<div class="field field-optional">
<label>{{ t('auth.invite_code') }} <span class="optional-tag">{{ t('auth.optional') }}</span></label>
<input v-model="inviteCode" class="field-input" autocomplete="off" />
<input v-model="inviteCode" class="field-input" autocomplete="off" :disabled="loading" />
</div>
<p v-if="fieldError()" class="error">{{ fieldError() }}</p>
<button type="submit" class="btn-login btn-gold-outline" :disabled="loading">
{{ t('auth.register_btn') }}
<button
type="submit"
class="btn-login btn-gold-outline"
:class="{ 'is-loading': loading }"
:disabled="loading"
:aria-busy="loading"
>
<span v-if="loading" class="btn-inline-loading">
<GoldSpinner :size="18" :active="true" />
{{ t('auth.registering') }}
</span>
<span v-else>{{ t('auth.register_btn') }}</span>
</button>
<button type="button" class="btn-skip" @click="goLogin">
<button type="button" class="btn-skip" :disabled="loading" @click="goLogin">
{{ t('auth.have_account') }}
</button>
</form>
@@ -212,6 +234,7 @@ const fieldError = () => {
}
.login-form {
position: relative;
width: 100%;
max-width: 320px;
display: flex;
@@ -220,6 +243,40 @@ const fieldError = () => {
padding: 12px;
}
.login-form.is-busy {
pointer-events: none;
}
.form-busy-overlay {
position: absolute;
inset: 0;
z-index: 3;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
border-radius: inherit;
background: rgba(8, 8, 8, 0.72);
backdrop-filter: blur(2px);
pointer-events: all;
}
.form-busy-text {
margin: 0;
font-size: 13px;
font-weight: 700;
color: rgba(240, 216, 117, 0.95);
letter-spacing: 0.04em;
}
.btn-inline-loading {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.form-title {
margin: 0 0 2px;
font-size: 16px;
@@ -315,6 +372,10 @@ label {
cursor: not-allowed;
}
.btn-login.is-loading:disabled {
opacity: 1;
}
.btn-skip {
margin-top: 2px;
padding: 8px 14px;

View File

@@ -40,25 +40,14 @@ services:
context: .
dockerfile: docker/api/Dockerfile
container_name: thebet365-api
env_file:
- .env.docker
environment:
DATABASE_URL: postgresql://thebet365:${POSTGRES_PASSWORD:-thebet365}@postgres:5432/thebet365
REDIS_URL: redis://redis:6379
JWT_SECRET: ${JWT_SECRET:-change-me-in-production-use-long-random-string}
JWT_PLAYER_EXPIRES: ${JWT_PLAYER_EXPIRES:-24h}
JWT_ADMIN_EXPIRES: ${JWT_ADMIN_EXPIRES:-2h}
JWT_AGENT_EXPIRES: ${JWT_AGENT_EXPIRES:-8h}
PORT: 3000
NODE_ENV: production
UPLOAD_DIR: /app/uploads
SEED_DATABASE: ${SEED_DATABASE:-false}
CHUANGLAN_ACCOUNT: ${CHUANGLAN_ACCOUNT}
CHUANGLAN_PASSWORD: ${CHUANGLAN_PASSWORD}
CHUANGLAN_ENDPOINT: ${CHUANGLAN_ENDPOINT:-https://sgap.253.com/send/sms}
CHUANGLAN_CONNECT_TIMEOUT_MS: ${CHUANGLAN_CONNECT_TIMEOUT_MS:-10000}
CHUANGLAN_READ_TIMEOUT_MS: ${CHUANGLAN_READ_TIMEOUT_MS:-10000}
SMS_CODE_TTL_SECONDS: ${SMS_CODE_TTL_SECONDS:-300}
SMS_RATE_LIMIT_SECONDS: ${SMS_RATE_LIMIT_SECONDS:-60}
SMS_DEBUG_LOG_CODE: ${SMS_DEBUG_LOG_CODE:-false}
volumes:
- uploads_data:/app/uploads
depends_on:

View File

@@ -127,7 +127,7 @@ API 端口 3000 建议**不要**直接公网暴露Swagger 仅供内网或通
## 五、演示账号
`SEED_DATABASE=true` 首次启动后可用(详见 [默认数据说明.md](./默认数据说明.md)
`SEED_DATABASE=true` 首次启动后可用(生产环境**仅创建 admin**,不含代理/玩家;详见 [默认数据说明.md](./默认数据说明.md)
| 角色 | 用户名 | 密码 | 入口 |
|------|--------|------|------|
@@ -149,9 +149,14 @@ docker compose -f docker-compose.prod.yml --env-file .env.docker down -v
# 仅重建 API
docker compose -f docker-compose.prod.yml --env-file .env.docker up -d --build api
# 手动种子(容器已运行时)
# 手动种子(容器已运行时,仅增量写入,不会删除已有数据
docker compose -f docker-compose.prod.yml --env-file .env.docker exec api npx prisma db seed
# 全量初始化(生产上线:仅 admin + WC2026 赛事,会先备份到 ./backups/
CONFIRM=YES ./scripts/prod-init-db.sh
# Windows PowerShell:
# $env:CONFIRM = "YES"; .\scripts\prod-init-db.ps1
# 查看 API 日志
docker compose -f docker-compose.prod.yml --env-file .env.docker logs -f api
```

View File

@@ -15,6 +15,8 @@
"db:migrate": "pnpm --filter @thebet365/api db:migrate",
"db:migrate:deploy": "pnpm --filter @thebet365/api db:migrate:deploy",
"db:seed": "pnpm --filter @thebet365/api db:seed",
"db:reset": "pnpm --filter @thebet365/api db:reset",
"db:reset:dev": "pnpm --filter @thebet365/api db:reset:dev",
"db:studio": "pnpm --filter @thebet365/api db:studio",
"docker:up": "docker compose -f docker-compose.prod.yml --env-file .env.docker up -d --build",
"docker:down": "docker compose -f docker-compose.prod.yml --env-file .env.docker down",

View File

@@ -19,6 +19,11 @@ export const API_ERROR_MESSAGES = {
'en-US': 'Account disabled',
'ms-MY': 'Akaun telah dinyahaktifkan',
},
ACCOUNT_SUSPENDED: {
'zh-CN': '账号已冻结',
'en-US': 'Account suspended',
'ms-MY': 'Akaun digantung',
},
AGENT_ACCOUNT_SUSPENDED: {
'zh-CN': '代理账号已停用',
'en-US': 'Agent account suspended',
@@ -89,10 +94,15 @@ export const API_ERROR_MESSAGES = {
'en-US': 'League not found',
'ms-MY': 'Liga tidak dijumpai',
},
LEAGUE_UNPUBLISH_FORBIDDEN: {
'zh-CN': '已发布的联赛不可下架',
'en-US': 'Published leagues cannot be unpublished',
'ms-MY': 'Liga yang diterbitkan tidak boleh ditarik',
LEAGUE_UNPUBLISH_SETTLED: {
'zh-CN': '联赛冠军盘已结算,不可下架',
'en-US': 'Cannot unpublish league after outright market is settled',
'ms-MY': 'Liga tidak boleh ditarik selepas pasaran juara diselesaikan',
},
MATCH_UNPUBLISH_FORBIDDEN: {
'zh-CN': '当前状态不可下架',
'en-US': 'Match cannot be unpublished in current status',
'ms-MY': 'Perlawanan tidak boleh ditarik dalam status semasa',
},
TEAM_CODE_REQUIRED: {
'zh-CN': '请填写球队代码',
@@ -159,6 +169,21 @@ export const API_ERROR_MESSAGES = {
'en-US': 'Match has bets and cannot be deleted',
'ms-MY': 'Perlawanan mempunyai pertaruhan dan tidak boleh dipadam',
},
ARCHIVE_BLOCKED: {
'zh-CN': '存在未结注单或未结算状态,需确认强制删除',
'en-US': 'Unsettled bets or match state require forced archive',
'ms-MY': 'Pertaruhan belum selesai atau status perlawanan memerlukan arkib paksa',
},
LEAGUE_ARCHIVE_NOT_READY: {
'zh-CN': '联赛下仍有未结算赛事或未结注单,无法删除',
'en-US': 'League still has unsettled fixtures or pending bets',
'ms-MY': 'Liga masih mempunyai perlawanan belum selesai atau pertaruhan tertunda',
},
ALREADY_ARCHIVED: {
'zh-CN': '已删除或已隐藏',
'en-US': 'Already archived',
'ms-MY': 'Sudah diarkibkan',
},
MATCHES_ARRAY_REQUIRED: {
'zh-CN': '请提供 matches 数组',
'en-US': 'matches array is required',
@@ -194,6 +219,11 @@ export const API_ERROR_MESSAGES = {
'en-US': 'Pre-match betting only; match has started',
'ms-MY': 'Pertaruhan pra-perlawanan sahaja; perlawanan telah bermula',
},
CORRECT_SCORE_DISABLED: {
'zh-CN': '该赛事未开放波胆投注',
'en-US': 'Correct score betting is disabled for this match',
'ms-MY': 'Pertaruhan skor tepat tidak dibuka untuk perlawanan ini',
},
ODDS_CHANGED: {
'zh-CN': '赔率已变更,请重新确认',
'en-US': 'Odds changed, please confirm again',
@@ -289,6 +319,21 @@ export const API_ERROR_MESSAGES = {
'en-US': 'Player not found',
'ms-MY': 'Pemain tidak dijumpai',
},
NOT_PLAYER: {
'zh-CN': '该用户不是玩家',
'en-US': 'User is not a player',
'ms-MY': 'Pengguna bukan pemain',
},
PLAYER_HAS_PENDING_BETS: {
'zh-CN': '玩家仍有未结算注单,无法删除',
'en-US': 'Player has pending bets and cannot be deleted',
'ms-MY': 'Pemain masih ada pertaruhan belum selesai dan tidak boleh dipadam',
},
PLAYER_HAS_BALANCE: {
'zh-CN': '玩家钱包仍有余额,无法删除',
'en-US': 'Player wallet still has balance and cannot be deleted',
'ms-MY': 'Dompet pemain masih ada baki dan tidak boleh dipadam',
},
MANAGE_DIRECT_PLAYERS_ONLY: {
'zh-CN': '仅可管理直属玩家',
'en-US': 'Can only manage direct players',
@@ -609,6 +654,11 @@ export const API_ERROR_MESSAGES = {
'en-US': 'Invalid selection for this outright event',
'ms-MY': 'Pilihan tidak sah untuk outright ini',
},
OUTRIGHT_LEAGUE_FIXTURES_UNSETTLED: {
'zh-CN': '该联赛仍有未结算的单场赛事,请先完成单场结算后再结算冠军盘',
'en-US': 'This league still has unsettled fixture matches. Settle them before settling the outright market.',
'ms-MY': 'Liga ini masih ada perlawanan belum diselesaikan. Selesaikan dahulu sebelum juara.',
},
OUTRIGHT_EVENT_NOT_FOUND: {
'zh-CN': '冠军盘赛事不存在',
'en-US': 'Outright event not found',
@@ -794,6 +844,26 @@ export const API_ERROR_MESSAGES = {
'en-US': 'Order is not in pending status',
'ms-MY': 'Pesanan bukan dalam status menunggu',
},
ORDER_ALREADY_PENDING: {
'zh-CN': '订单已是待审核状态',
'en-US': 'Order is already pending review',
'ms-MY': 'Pesanan sudah menunggu semakan',
},
ORDER_NOT_APPROVED: {
'zh-CN': '仅已通过的充值订单可撤销',
'en-US': 'Only approved deposit orders can be revoked',
'ms-MY': 'Hanya pesanan deposit yang diluluskan boleh dibatalkan',
},
DEPOSIT_REVOKE_WINDOW_EXPIRED: {
'zh-CN': '批准已超过 5 分钟,无法撤回',
'en-US': 'Approval was more than 5 minutes ago; revoke is no longer allowed',
'ms-MY': 'Kelulusan melebihi 5 minit; pembatalan tidak dibenarkan',
},
DEPOSIT_REVOKE_SETTLED_BETS: {
'zh-CN': '批准后有注单已结算,无法撤回;请先处理相关注单',
'en-US': 'Bets placed after approval have already settled; revoke is blocked',
'ms-MY': 'Terdapat pertaruhan selepas kelulusan yang telah diselesaikan; pembatalan disekat',
},
REASON_REQUIRED: {
'zh-CN': '请填写拒绝原因',
'en-US': 'Rejection reason is required',

207
pnpm-lock.yaml generated
View File

@@ -10,6 +10,9 @@ importers:
apps/admin:
dependencies:
'@thebet365/shared':
specifier: workspace:*
version: link:../../packages/shared
axios:
specifier: ^1.7.9
version: 1.16.1
@@ -35,6 +38,9 @@ importers:
'@vitejs/plugin-vue':
specifier: ^5.2.1
version: 5.2.1(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(terser@5.48.0))(vue@3.5.35(typescript@5.7.3))
rollup-plugin-visualizer:
specifier: ^7.0.1
version: 7.0.1(rollup@4.61.0)
typescript:
specifier: ^5.7.3
version: 5.7.3
@@ -1629,6 +1635,10 @@ packages:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-regex@6.2.2:
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
engines: {node: '>=12'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
@@ -1637,6 +1647,10 @@ packages:
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
engines: {node: '>=10'}
ansi-styles@6.2.3:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
ansis@4.2.0:
resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==}
engines: {node: '>=14'}
@@ -1757,6 +1771,10 @@ packages:
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
bundle-name@4.1.0:
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
engines: {node: '>=18'}
busboy@1.6.0:
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
engines: {node: '>=10.16.0'}
@@ -1856,6 +1874,10 @@ packages:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
cliui@9.0.1:
resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==}
engines: {node: '>=20'}
clone@1.0.4:
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
engines: {node: '>=0.8'}
@@ -1993,9 +2015,21 @@ packages:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
default-browser-id@5.0.1:
resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==}
engines: {node: '>=18'}
default-browser@5.5.0:
resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==}
engines: {node: '>=18'}
defaults@1.0.4:
resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==}
define-lazy-prop@3.0.0:
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
engines: {node: '>=12'}
defu@6.1.7:
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
@@ -2070,6 +2104,9 @@ packages:
resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==}
engines: {node: '>=12'}
emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@@ -2284,6 +2321,10 @@ packages:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
get-east-asian-width@1.6.0:
resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==}
engines: {node: '>=18'}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
@@ -2408,6 +2449,11 @@ packages:
resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==}
engines: {node: '>= 0.4'}
is-docker@3.0.0:
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
hasBin: true
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
@@ -2416,6 +2462,15 @@ packages:
resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==}
engines: {node: '>=6'}
is-in-ssh@1.0.0:
resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==}
engines: {node: '>=20'}
is-inside-container@1.0.0:
resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
engines: {node: '>=14.16'}
hasBin: true
is-interactive@1.0.0:
resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==}
engines: {node: '>=8'}
@@ -2435,6 +2490,10 @@ packages:
resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
engines: {node: '>=10'}
is-wsl@3.1.1:
resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
engines: {node: '>=16'}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
@@ -2921,6 +2980,10 @@ packages:
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
engines: {node: '>=6'}
open@11.0.0:
resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==}
engines: {node: '>=20'}
ora@5.4.1:
resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==}
engines: {node: '>=10'}
@@ -3045,6 +3108,10 @@ packages:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=14}
powershell-utils@0.1.0:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
engines: {node: '>=20'}
pretty-format@29.0.0:
resolution: {integrity: sha512-tMkFRn1vxRwZdiDETcveuNeonRKDg4doOvI+iyb1sOAtxYioGzRicqnsr+d3C/lLv9hBiM/2lDBi5ilR81h2bQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -3155,6 +3222,19 @@ packages:
resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==}
engines: {node: '>=8'}
rollup-plugin-visualizer@7.0.1:
resolution: {integrity: sha512-UJUT4+1Ho4OcWmPYU3sYXgUqI8B8Ayfe06MX7y0qCJ1K8aGoKtR/NDd/2nZqM7ADkrzny+I99Ul7GgyoiVNAgg==}
engines: {node: '>=22'}
hasBin: true
peerDependencies:
rolldown: 1.x || ^1.0.0-beta || ^1.0.0-rc
rollup: 2.x || 3.x || 4.x
peerDependenciesMeta:
rolldown:
optional: true
rollup:
optional: true
rollup@4.61.0:
resolution: {integrity: sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@@ -3164,6 +3244,10 @@ packages:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'}
run-applescript@7.1.0:
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
engines: {node: '>=18'}
rxjs@7.8.1:
resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==}
@@ -3298,6 +3382,10 @@ packages:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
string-width@7.2.0:
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
engines: {node: '>=18'}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
@@ -3313,6 +3401,10 @@ packages:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
strip-ansi@7.2.0:
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
engines: {node: '>=12'}
strip-bom@3.0.0:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
@@ -3721,6 +3813,10 @@ packages:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
wrap-ansi@9.0.0:
resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==}
engines: {node: '>=18'}
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
@@ -3728,6 +3824,10 @@ packages:
resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
wsl-utils@0.3.1:
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
engines: {node: '>=20'}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -3739,10 +3839,18 @@ packages:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yargs-parser@22.0.0:
resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
yargs@18.0.0:
resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
yn@3.1.1:
resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}
engines: {node: '>=6'}
@@ -5287,12 +5395,16 @@ snapshots:
ansi-regex@5.0.1: {}
ansi-regex@6.2.2: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
ansi-styles@5.2.0: {}
ansi-styles@6.2.3: {}
ansis@4.2.0: {}
anymatch@3.1.3:
@@ -5457,6 +5569,10 @@ snapshots:
base64-js: 1.5.1
ieee754: 1.2.1
bundle-name@4.1.0:
dependencies:
run-applescript: 7.1.0
busboy@1.6.0:
dependencies:
streamsearch: 1.1.0
@@ -5547,10 +5663,16 @@ snapshots:
cliui@8.0.1:
dependencies:
string-width: 4.2.3
string-width: 4.2.0
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
cliui@9.0.1:
dependencies:
string-width: 7.2.0
strip-ansi: 7.2.0
wrap-ansi: 9.0.0
clone@1.0.4: {}
cluster-key-slot@1.1.1: {}
@@ -5663,10 +5785,19 @@ snapshots:
deepmerge@4.3.1: {}
default-browser-id@5.0.1: {}
default-browser@5.5.0:
dependencies:
bundle-name: 4.1.0
default-browser-id: 5.0.1
defaults@1.0.4:
dependencies:
clone: 1.0.4
define-lazy-prop@3.0.0: {}
defu@6.1.7: {}
delayed-stream@1.0.0: {}
@@ -5738,6 +5869,8 @@ snapshots:
emittery@0.13.1: {}
emoji-regex@10.6.0: {}
emoji-regex@8.0.0: {}
empathic@2.0.0: {}
@@ -5997,6 +6130,8 @@ snapshots:
get-caller-file@2.0.5: {}
get-east-asian-width@1.6.0: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -6140,10 +6275,18 @@ snapshots:
dependencies:
hasown: 2.0.4
is-docker@3.0.0: {}
is-fullwidth-code-point@3.0.0: {}
is-generator-fn@2.1.0: {}
is-in-ssh@1.0.0: {}
is-inside-container@1.0.0:
dependencies:
is-docker: 3.0.0
is-interactive@1.0.0: {}
is-number@7.0.0: {}
@@ -6154,6 +6297,10 @@ snapshots:
is-unicode-supported@0.1.0: {}
is-wsl@3.1.1:
dependencies:
is-inside-container: 1.0.0
isexe@2.0.0: {}
istanbul-lib-coverage@3.2.2: {}
@@ -6803,6 +6950,15 @@ snapshots:
dependencies:
mimic-fn: 2.1.0
open@11.0.0:
dependencies:
default-browser: 5.5.0
define-lazy-prop: 3.0.0
is-in-ssh: 1.0.0
is-inside-container: 1.0.0
powershell-utils: 0.1.0
wsl-utils: 0.3.1
ora@5.4.1:
dependencies:
bl: 4.1.0
@@ -6918,6 +7074,8 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
powershell-utils@0.1.0: {}
pretty-format@29.0.0:
dependencies:
'@jest/schemas': 29.0.0
@@ -7019,6 +7177,15 @@ snapshots:
onetime: 5.1.0
signal-exit: 3.0.7
rollup-plugin-visualizer@7.0.1(rollup@4.61.0):
dependencies:
open: 11.0.0
picomatch: 4.0.4
source-map: 0.7.4
yargs: 18.0.0
optionalDependencies:
rollup: 4.61.0
rollup@4.61.0:
dependencies:
'@types/estree': 1.0.9
@@ -7060,6 +7227,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
run-applescript@7.1.0: {}
rxjs@7.8.1:
dependencies:
tslib: 2.8.1
@@ -7211,6 +7380,12 @@ snapshots:
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
string-width@7.2.0:
dependencies:
emoji-regex: 10.6.0
get-east-asian-width: 1.6.0
strip-ansi: 7.2.0
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
@@ -7227,6 +7402,10 @@ snapshots:
dependencies:
ansi-regex: 5.0.1
strip-ansi@7.2.0:
dependencies:
ansi-regex: 6.2.2
strip-bom@3.0.0: {}
strip-bom@4.0.0: {}
@@ -7557,8 +7736,14 @@ snapshots:
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
string-width: 4.1.0
strip-ansi: 6.0.0
wrap-ansi@9.0.0:
dependencies:
ansi-styles: 6.2.3
string-width: 7.2.0
strip-ansi: 7.2.0
wrappy@1.0.2: {}
@@ -7567,12 +7752,19 @@ snapshots:
imurmurhash: 0.1.4
signal-exit: 3.0.7
wsl-utils@0.3.1:
dependencies:
is-wsl: 3.1.1
powershell-utils: 0.1.0
y18n@5.0.8: {}
yallist@3.1.1: {}
yargs-parser@21.1.1: {}
yargs-parser@22.0.0: {}
yargs@17.7.2:
dependencies:
cliui: 8.0.1
@@ -7583,6 +7775,15 @@ snapshots:
y18n: 5.0.8
yargs-parser: 21.1.1
yargs@18.0.0:
dependencies:
cliui: 9.0.1
escalade: 3.2.0
get-caller-file: 2.0.5
string-width: 7.2.0
y18n: 5.0.8
yargs-parser: 22.0.0
yn@3.1.1: {}
yocto-queue@0.1.0: {}

65
scripts/prod-init-db.ps1 Normal file
View File

@@ -0,0 +1,65 @@
# 生产上线:清空全部业务数据,仅保留 admin + WC2026 赛事示例
#
# 用法PowerShell项目根目录
# $env:CONFIRM = "YES"; .\scripts\prod-init-db.ps1
# $env:CONFIRM = "YES"; .\scripts\prod-init-db.ps1 -SkipBackup
param(
[switch]$SkipBackup
)
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
$ComposeFile = Join-Path $Root "docker-compose.prod.yml"
$EnvFile = Join-Path $Root ".env.docker"
if ($env:CONFIRM -ne "YES") {
Write-Host "错误:将删除全部业务数据,仅保留 admin 与 WC2026 赛事示例。"
Write-Host ""
Write-Host '确认后执行: $env:CONFIRM = "YES"; .\scripts\prod-init-db.ps1'
exit 1
}
$composeArgs = @("-f", $ComposeFile)
if (Test-Path $EnvFile) {
$composeArgs += @("--env-file", $EnvFile)
}
Set-Location $Root
Write-Host "[prod-init-db] 检查容器…"
docker compose @composeArgs ps --status running api postgres 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Host "[prod-init-db] 请先启动 docker compose 栈"
exit 1
}
if (-not $SkipBackup) {
$backupDir = Join-Path $Root "backups"
New-Item -ItemType Directory -Force -Path $backupDir | Out-Null
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
$backupFile = Join-Path $backupDir "thebet365-$stamp.sql"
Write-Host "[prod-init-db] 备份 → $backupFile"
docker compose @composeArgs exec -T postgres pg_dump -U thebet365 -d thebet365 -F p | Set-Content -Encoding utf8 $backupFile
} else {
Write-Host "[prod-init-db] 已跳过备份"
}
Write-Host "[prod-init-db] 迁移…"
docker compose @composeArgs exec -T api sh -c "cd /app/apps/api && npx prisma migrate deploy && npx prisma generate"
Write-Host "[prod-init-db] 生产模式初始化…"
docker compose @composeArgs exec -T `
-e INIT_DATABASE_CONFIRM=YES `
-e ALLOW_DB_RESET=true `
-e SEED_MODE=production `
-e NODE_ENV=production `
api `
node dist/infrastructure/database/reset-and-seed-cli.js --yes --production
Write-Host "[prod-init-db] 清空 Redis…"
docker compose @composeArgs exec -T redis redis-cli FLUSHALL 2>$null | Out-Null
Write-Host ""
Write-Host "[prod-init-db] 完成。管理员 admin / Admin@123含 WC2026 赛事示例。"
Write-Host "请将 .env.docker 中 SEED_DATABASE 设为 false 后 restart api"

83
scripts/prod-init-db.sh Normal file
View File

@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# 生产上线:清空全部业务数据,仅保留 admin + WC2026 赛事示例(无代理/玩家/充值/注单)
#
# 与「pnpm db:seed」的区别
# db:seed 增量写入,不删已有玩家、充值订单等
# 本脚本 TRUNCATE 全部业务表 + production seed
#
# 用法项目根目录Linux 服务器):
# chmod +x scripts/prod-init-db.sh
# CONFIRM=YES ./scripts/prod-init-db.sh
# CONFIRM=YES ./scripts/prod-init-db.sh --skip-backup
#
# 前置docker compose -f docker-compose.prod.yml up -d 且 api/postgres 在运行
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
COMPOSE=(docker compose -f "$ROOT/docker-compose.prod.yml")
ENV_FILE="$ROOT/.env.docker"
SKIP_BACKUP=false
if [ "${CONFIRM:-}" != "YES" ]; then
echo "错误:将删除全部业务数据(玩家、代理、充值、注单、审计等),仅保留 admin 与 WC2026 赛事示例。"
echo ""
echo "若已确认,请执行:"
echo " CONFIRM=YES $0 [--skip-backup]"
exit 1
fi
for arg in "$@"; do
if [ "$arg" = "--skip-backup" ]; then
SKIP_BACKUP=true
fi
done
if [ -f "$ENV_FILE" ]; then
COMPOSE+=(--env-file "$ENV_FILE")
fi
cd "$ROOT"
echo "[prod-init-db] 检查容器…"
"${COMPOSE[@]}" ps --status running api postgres redis >/dev/null 2>&1 || {
echo "[prod-init-db] 请先启动: docker compose -f docker-compose.prod.yml --env-file .env.docker up -d"
exit 1
}
if [ "$SKIP_BACKUP" = false ]; then
BACKUP_DIR="$ROOT/backups"
mkdir -p "$BACKUP_DIR"
STAMP="$(date +%Y%m%d-%H%M%S)"
BACKUP_FILE="$BACKUP_DIR/thebet365-$STAMP.sql"
echo "[prod-init-db] 备份 PostgreSQL → $BACKUP_FILE"
"${COMPOSE[@]}" exec -T postgres pg_dump -U thebet365 -d thebet365 -F p > "$BACKUP_FILE"
echo "[prod-init-db] 备份完成"
else
echo "[prod-init-db] 已跳过备份 (--skip-backup)"
fi
echo "[prod-init-db] 执行数据库迁移…"
"${COMPOSE[@]}" exec -T api sh -c 'cd /app/apps/api && npx prisma migrate deploy && npx prisma generate'
echo "[prod-init-db] 生产模式初始化admin + WC2026 赛事)…"
"${COMPOSE[@]}" exec -T \
-e INIT_DATABASE_CONFIRM=YES \
-e ALLOW_DB_RESET=true \
-e SEED_MODE=production \
-e NODE_ENV=production \
api \
node dist/infrastructure/database/reset-and-seed-cli.js --yes --production
echo "[prod-init-db] 清空 Redis…"
"${COMPOSE[@]}" exec -T redis redis-cli FLUSHALL >/dev/null || true
echo ""
echo "[prod-init-db] 完成。"
echo " 管理员: admin / Admin@123建议登录后立即修改密码"
echo " 赛事: WC2026 小组赛 72 场 + 48 强优胜盘"
echo ""
echo "后续建议:"
echo " 1. .env.docker 中 SEED_DATABASE=false避免重启 api 重复 seed"
echo " 2. docker compose -f docker-compose.prod.yml --env-file .env.docker restart api"
echo " 3. 勿长期保留 ALLOW_DB_RESET=true"