feat(admin,player,api): 优胜冠军通用管理与界面精简

管理端新增冠军盘列表/编辑、展开懒加载与 ECharts 修复;各列表页去掉重复标题。玩家端支持多赛事冠军盘、分批加载与语言切换刷新。API 扩展 outright CRUD 与列表性能优化。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-04 09:17:01 +08:00
parent 9b63d67e7c
commit 27580b2479
39 changed files with 2250 additions and 578 deletions

View File

@@ -111,20 +111,24 @@ html, body, #app {
min-height: 0;
overflow: hidden;
}
.admin-list-page > .page-header,
.admin-list-page > .page-toolbar,
.admin-list-page > .filter-card,
.admin-list-page > .tool-card {
flex-shrink: 0;
}
.admin-list-page > .page-toolbar {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.admin-list-page > .tool-card {
margin-bottom: 16px;
}
.admin-list-page > .filter-card {
margin-bottom: 16px;
}
.admin-list-page > .page-header {
margin-bottom: 20px;
}
.admin-list-page > .data-card {
flex: 1;
min-height: 0;

View File

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

View File

@@ -1,22 +1,8 @@
import { use } from 'echarts/core';
import { BarChart, LineChart, PieChart } from 'echarts/charts';
import {
GridComponent,
TooltipComponent,
LegendComponent,
TitleComponent,
GraphicComponent,
} from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
/**
* 管理端图表统一入口:使用完整 ECharts 构建,避免多实例 + 按需注册遗漏
*/
import * as echarts from 'echarts';
import ECharts from 'vue-echarts';
use([
CanvasRenderer,
BarChart,
LineChart,
PieChart,
GridComponent,
TooltipComponent,
LegendComponent,
TitleComponent,
GraphicComponent,
]);
export { echarts, ECharts as VChart };
export type { EChartsOption } from 'echarts';

View File

@@ -33,7 +33,7 @@ const zh: Record<string, string> = {
'nav.users': '玩家管理',
'nav.agents': '代理管理',
'nav.matches': '赛事管理',
'nav.outright': '世界杯夺冠',
'nav.outrights': '优胜冠军',
'nav.bets': '注单管理',
'nav.cashback': '返水管理',
'nav.audit': '操作日志',
@@ -51,6 +51,8 @@ const zh: Record<string, string> = {
'common.search': '查询',
'common.reset': '重置',
'common.edit': '编辑',
'common.yes': '是',
'common.no': '否',
'common.delete': '删除',
'common.cancel': '取消',
'common.confirm': '确定',
@@ -182,7 +184,7 @@ const en: Record<string, string> = {
'nav.users': 'Players',
'nav.agents': 'Agents',
'nav.matches': 'Matches',
'nav.outright': 'WC Winner',
'nav.outrights': 'Outrights',
'nav.bets': 'Bets',
'nav.cashback': 'Cashback',
'nav.audit': 'Audit Log',
@@ -200,6 +202,8 @@ const en: Record<string, string> = {
'common.search': 'Search',
'common.reset': 'Reset',
'common.edit': 'Edit',
'common.yes': 'Yes',
'common.no': 'No',
'common.delete': 'Delete',
'common.cancel': 'Cancel',
'common.confirm': 'OK',
@@ -331,7 +335,7 @@ const ms: Record<string, string> = {
'nav.users': 'Pemain',
'nav.agents': 'Ejen',
'nav.matches': 'Perlawanan',
'nav.outright': 'Juara Piala Dunia',
'nav.outrights': 'Juara',
'nav.bets': 'Pertaruhan',
'nav.cashback': 'Rebat',
'nav.audit': 'Log audit',
@@ -349,6 +353,8 @@ const ms: Record<string, string> = {
'common.search': 'Cari',
'common.reset': 'Set semula',
'common.edit': 'Edit',
'common.yes': 'Ya',
'common.no': 'Tidak',
'common.delete': 'Padam',
'common.cancel': 'Batal',
'common.confirm': 'OK',

View File

@@ -249,8 +249,8 @@ export const adminPagesMs: Record<string, string> = {
'msg.outright_odds_saved': 'Odds juara disimpan',
'msg.load_failed': 'Gagal memuatkan',
'page.outright.title': 'Juara Piala Dunia 48',
'page.outright.desc': '48 pasukan tetap; laraskan odds juara',
'page.outrights.title': 'Juara',
'page.outrights.desc': 'Cipta dan edit pasaran juara; Piala Dunia 2026 boleh import asas',
'outright.col.rank': 'Kedudukan',
'outright.col.team_zh': 'Pasukan (ZH)',
'outright.col.team_en': 'Pasukan (EN)',

View File

@@ -249,18 +249,51 @@ export const adminPagesZh: Record<string, string> = {
'msg.outright_odds_saved': '夺冠赔率已保存',
'msg.load_failed': '加载失败',
'page.outright.title': '世界杯 48 强夺冠',
'page.outright.desc': '固定 48 支球队,可调整夺冠赔率(玩家端「优胜冠军」)',
'page.outrights.title': '优胜冠军',
'page.outrights.desc': '可新建任意联赛冠军盘、编辑队伍与赔率;世界杯 48 强提供一键导入基准数据',
'outright.col.rank': '排名',
'outright.col.team_zh': '队伍(中文)',
'outright.col.team_en': '队伍(英文)',
'outright.col.code': '代码',
'outright.col.odds': '夺冠赔率',
'outright.btn.save_odds': '保存全部赔率',
'outright.btn.apply_canonical': '应用表格基准数据',
'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.btn.add_team': '添加队伍',
'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.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.freeze_confirm_title': '{action}账号',
@@ -520,18 +553,51 @@ export const adminPagesEn: Record<string, string> = {
'msg.outright_odds_saved': 'Outright odds saved',
'msg.load_failed': 'Load failed',
'page.outright.title': 'World Cup 48 — Winner',
'page.outright.desc': 'Fixed 48 teams; adjust winner odds (player Outright tab)',
'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.odds': 'Winner odds',
'outright.btn.save_odds': 'Save all odds',
'outright.btn.apply_canonical': 'Apply baseline table',
'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.btn.add_team': 'Add team',
'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.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.freeze_confirm_title': '{action} account',

View File

@@ -15,7 +15,7 @@ const adminMenus = computed(() => [
{ path: '/users', label: t('nav.users') },
{ path: '/agents', label: t('nav.agents') },
{ path: '/matches', label: t('nav.matches') },
{ path: '/world-cup-outright', label: t('nav.outright') },
{ path: '/outrights', label: t('nav.outrights'), matchPrefix: true },
{ path: '/bets', label: t('nav.bets') },
{ path: '/cashback', label: t('nav.cashback') },
{ path: '/audit', label: t('nav.audit') },
@@ -30,9 +30,15 @@ const agentMenus = computed(() => [
const menus = computed(() => (auth.isAdmin.value ? adminMenus.value : agentMenus.value));
const currentLabel = computed(() =>
menus.value.find(m => m.path === route.path)?.label ?? ''
);
const currentLabel = computed(() => {
const hit = menus.value.find((m) => {
if ('matchPrefix' in m && m.matchPrefix) {
return route.path === m.path || route.path.startsWith(`${m.path}/`);
}
return route.path === m.path;
});
return hit?.label ?? '';
});
const userInitial = computed(() =>
(auth.user?.username ?? '').charAt(0).toUpperCase()
@@ -55,7 +61,12 @@ function logout() {
<nav class="nav">
<RouterLink
v-for="m in menus" :key="m.path" :to="m.path"
class="nav-item" :class="{ active: route.path === m.path }"
class="nav-item"
:class="{
active:
route.path === m.path ||
('matchPrefix' in m && m.matchPrefix && route.path.startsWith(`${m.path}/`)),
}"
>
{{ m.label }}
</RouterLink>

View File

@@ -1,3 +1,4 @@
import './components/dashboard/echarts-setup';
import { createApp } from 'vue';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';

View File

@@ -27,10 +27,18 @@ const router = createRouter({
meta: { adminOnly: true },
},
{
path: 'world-cup-outright',
component: () => import('../views/WorldCupOutright.vue'),
path: 'outrights',
name: 'admin-outrights',
component: () => import('../views/outrights/OutrightList.vue'),
meta: { adminOnly: true },
},
{
path: 'outrights/:matchId/edit',
name: 'admin-outright-edit',
component: () => import('../views/outrights/OutrightEventEditor.vue'),
meta: { adminOnly: true },
},
{ path: 'world-cup-outright', redirect: '/outrights' },
{
path: 'bets',
component: () => import('../views/Bets.vue'),

View File

@@ -224,11 +224,7 @@ function creditTypeLabel(type: string) {
<template>
<div class="admin-list-page">
<div class="page-header">
<div>
<h2 class="page-title">{{ t('page.agents.title') }}</h2>
<span class="page-desc">{{ t('page.agents.desc') }}</span>
</div>
<div class="page-toolbar">
<el-button type="primary" @click="openCreate">{{ t('agent.create_btn') }}</el-button>
</div>
@@ -489,14 +485,6 @@ function creditTypeLabel(type: string) {
</template>
<style scoped>
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; margin: 0 0 4px; }
.page-desc { font-size: 13px; color: #666; }
.filter-card { border-radius: 12px; }
.data-card { border-radius: 12px; }
.field-hint { font-size: 12px; color: #888; margin-top: 4px; }

View File

@@ -69,11 +69,6 @@ function formatTime(v: string) {
<template>
<div class="admin-list-page">
<div class="page-header">
<h2 class="page-title">{{ t('page.audit.title') }}</h2>
<span class="page-desc">{{ t('page.audit.desc') }}</span>
</div>
<el-card class="filter-card" shadow="never">
<el-form inline>
<el-form-item :label="t('common.module')">
@@ -123,9 +118,6 @@ function formatTime(v: string) {
</template>
<style scoped>
.page-header { display: flex; align-items: baseline; gap: 12px; }
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; }
.page-desc { font-size: 13px; color: #3a3a3a; }
.filter-card { border-radius: 12px; }
.data-card { border-radius: 12px; }
</style>

View File

@@ -105,11 +105,6 @@ async function openDetail(row: BetListRow) {
<template>
<div class="admin-list-page bets-page">
<div class="page-header">
<h2 class="page-title">{{ t('page.bets.title') }}</h2>
<span class="page-desc">{{ t('page.bets.desc') }}</span>
</div>
<el-card class="filter-card" shadow="never">
<el-form inline class="bets-filter-form">
<el-form-item :label="t('common.keyword')">
@@ -297,9 +292,6 @@ async function openDetail(row: BetListRow) {
</template>
<style scoped>
.page-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 20px; }
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; }
.page-desc { font-size: 13px; color: #666; }
.filter-card { margin-bottom: 16px; border-radius: 12px; }
.data-card { border-radius: 12px; }

View File

@@ -30,11 +30,6 @@ async function confirm() {
<template>
<div class="page-scroll">
<div class="page-header">
<h2 class="page-title">{{ t('page.cashback.title') }}</h2>
<span class="page-desc">{{ t('page.cashback.desc') }}</span>
</div>
<el-card class="tool-card" shadow="never">
<div class="filter-row">
<el-form inline>
@@ -73,9 +68,6 @@ async function confirm() {
</template>
<style scoped>
.page-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 20px; }
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; }
.page-desc { font-size: 13px; color: #3a3a3a; }
.tool-card { margin-bottom: 16px; border-radius: 12px; }
.preview-card { border-radius: 12px; }
.preview-title { font-size: 15px; font-weight: 600; color: #e0e0e0; margin-bottom: 16px; }

View File

@@ -206,23 +206,12 @@ const kpiSecondary = computed(() => {
<template>
<div class="dashboard-page" v-loading="loading">
<div class="page-header">
<div>
<h2 class="page-title">{{ t('dash.title') }}</h2>
<span class="page-desc">
{{ t('dash.desc') }}
<template v-if="s?.generatedAt">
· {{ t('common.updated_at') }} {{ formatTime(s.generatedAt) }}
</template>
</span>
</div>
</div>
<template v-if="s">
<el-card class="overview-board" shadow="never">
<div class="board-head">
<span class="board-title">{{ t('dash.board_title') }}</span>
<span class="board-hint">{{ t('dash.board_hint') }}</span>
<div v-if="s.generatedAt" class="board-head">
<span class="dash-updated">
{{ t('common.updated_at') }} {{ formatTime(s.generatedAt) }}
</span>
</div>
<div class="kpi-grid kpi-primary">
@@ -259,9 +248,6 @@ const kpiSecondary = computed(() => {
<style scoped>
.dashboard-page { padding-bottom: 32px; }
.page-header { margin-bottom: 20px; }
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; margin: 0 0 4px; }
.page-desc { font-size: 13px; color: #666; }
.overview-board {
border-radius: 14px;
@@ -274,18 +260,13 @@ const kpiSecondary = computed(() => {
}
.board-head {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 18px;
justify-content: flex-end;
margin: -4px 0 14px;
}
.board-title {
font-size: 16px;
font-weight: 700;
color: #e8e8e8;
}
.board-hint {
font-size: 12px;
.dash-updated {
font-size: 11px;
color: #555;
letter-spacing: 0.02em;
}
.kpi-grid {

View File

@@ -272,15 +272,9 @@ function canDelete(row: unknown) {
<template>
<div class="admin-list-page">
<div class="page-header">
<div>
<h2 class="page-title">{{ t('page.matches.title') }}</h2>
<span class="page-desc">{{ t('page.matches.desc') }}</span>
</div>
<div class="header-actions">
<el-button @click="openImport">{{ t('common.import') }}</el-button>
<el-button type="primary" @click="openCreate">{{ t('match.create_btn') }}</el-button>
</div>
<div class="page-toolbar">
<el-button @click="openImport">{{ t('common.import') }}</el-button>
<el-button type="primary" @click="openCreate">{{ t('match.create_btn') }}</el-button>
</div>
<el-card class="filter-card" shadow="never">
@@ -468,28 +462,6 @@ function canDelete(row: unknown) {
</template>
<style scoped>
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.page-title {
font-size: 20px;
font-weight: 700;
color: #e0e0e0;
margin: 0 0 4px;
}
.page-desc {
font-size: 13px;
color: #3a3a3a;
}
.header-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.filter-card { border-radius: 12px; }
.data-card {
border-radius: 12px;

View File

@@ -242,11 +242,7 @@ function statusLabel(s: string) {
<template>
<div class="admin-list-page users-page">
<div class="page-header">
<div>
<h2 class="page-title">{{ t('page.users.title') }}</h2>
<span class="page-desc">{{ t('page.users.desc') }}</span>
</div>
<div class="page-toolbar">
<el-button type="primary" @click="openCreate">{{ t('user.create_btn') }}</el-button>
</div>
@@ -592,14 +588,6 @@ function statusLabel(s: string) {
</template>
<style scoped>
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; margin: 0 0 4px; }
.page-desc { font-size: 13px; color: #666; }
.filter-card { margin-bottom: 16px; border-radius: 12px; }
.data-card { border-radius: 12px; }
.pager { margin-top: 16px; display: flex; justify-content: flex-end; }

View File

@@ -1,150 +0,0 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ElMessage } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
import api from '../api';
const { t } = useAdminLocale();
interface SelectionRow {
id: string;
teamCode: string;
rank: number;
teamZh: string;
teamEn: string;
odds: string;
oddsVersion: string;
status: string;
editOdds: number;
}
const loading = ref(false);
const saving = ref(false);
const leagueZh = ref('');
const leagueEn = ref('');
const selections = ref<SelectionRow[]>([]);
const expectedCount = ref(48);
async function load() {
loading.value = true;
try {
const { data } = await api.get('/admin/outrights/wc2026');
const payload = data.data as {
leagueZh: string;
leagueEn: string;
selections: SelectionRow[];
expectedCount: number;
};
leagueZh.value = payload.leagueZh;
leagueEn.value = payload.leagueEn;
expectedCount.value = payload.expectedCount;
selections.value = payload.selections.map((s) => ({
...s,
editOdds: Number(s.odds),
}));
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
} finally {
loading.value = false;
}
}
async function applyCanonical() {
loading.value = true;
try {
await api.post('/admin/outrights/wc2026/apply-canonical');
ElMessage.success(t('msg.outright_canonical_applied'));
await load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
loading.value = false;
}
}
async function saveAll() {
const invalid = selections.value.find((s) => !s.editOdds || s.editOdds <= 1);
if (invalid) {
ElMessage.warning(t('outright.err_odds_min'));
return;
}
saving.value = true;
try {
await api.put('/admin/outrights/wc2026/odds', {
updates: selections.value.map((s) => ({
selectionId: s.id,
odds: s.editOdds,
})),
});
ElMessage.success(t('msg.outright_odds_saved'));
await load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
saving.value = false;
}
}
onMounted(load);
</script>
<template>
<div class="admin-list-page">
<div class="page-header">
<h2 class="page-title">{{ t('page.outright.title') }}</h2>
<span class="page-desc">{{ t('page.outright.desc') }}</span>
</div>
<el-card class="data-card" shadow="never" v-loading="loading">
<div class="meta-row">
<span>{{ leagueZh }} / {{ leagueEn }}</span>
<span class="meta-count">
{{ t('outright.team_count', { n: selections.length, total: expectedCount }) }}
</span>
</div>
<div class="table-wrap">
<el-table :data="selections" stripe>
<el-table-column prop="rank" :label="t('outright.col.rank')" width="72" align="center" />
<el-table-column prop="teamZh" :label="t('outright.col.team_zh')" min-width="120" />
<el-table-column prop="teamEn" :label="t('outright.col.team_en')" min-width="140" />
<el-table-column prop="teamCode" :label="t('outright.col.code')" width="88" />
<el-table-column :label="t('outright.col.odds')" width="160" align="right">
<template #default="{ row }">
<el-input-number
v-model="row.editOdds"
:min="1.01"
:step="0.05"
:precision="2"
controls-position="right"
size="small"
style="width: 130px"
/>
</template>
</el-table-column>
</el-table>
</div>
<div class="footer-actions">
<el-button @click="load">{{ t('common.reset') }}</el-button>
<el-button :loading="loading" @click="applyCanonical">
{{ t('outright.btn.apply_canonical') }}
</el-button>
<el-button type="primary" :loading="saving" @click="saveAll">
{{ t('outright.btn.save_odds') }}
</el-button>
</div>
</el-card>
</div>
</template>
<style scoped>
.page-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; }
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; }
.page-desc { font-size: 13px; color: #888; }
.data-card { border-radius: 12px; }
.meta-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; font-size: 13px; color: #aaa; }
.meta-count { color: #666; }
.footer-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 16px; }
</style>

View File

@@ -0,0 +1,433 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../../composables/useAdminLocale';
import api from '../../api';
const route = useRoute();
const router = useRouter();
const { t } = useAdminLocale();
const matchId = computed(() => String(route.params.matchId ?? ''));
interface SelectionRow {
id: string;
teamCode: string;
rank: number;
teamZh: string;
teamEn: string;
odds: string;
oddsVersion: string;
status: string;
editOdds: number;
}
const loading = ref(false);
const saving = ref(false);
const meta = ref({
leagueZh: '',
leagueEn: '',
leagueCode: '',
matchName: '',
status: 'DRAFT',
expectedCanonicalCount: null as number | null,
playerVisible: true,
playerHiddenReason: null as string | null,
});
const selections = ref<SelectionRow[]>([]);
const addVisible = ref(false);
const addForm = ref({
teamCode: '',
teamZh: '',
teamEn: '',
odds: 10,
});
async function load() {
if (!matchId.value) return;
loading.value = true;
try {
const { data } = await api.get(`/admin/outrights/${matchId.value}`);
const payload = data.data as {
leagueZh: string;
leagueEn: string;
leagueCode: string;
matchName: string;
status: string;
expectedCanonicalCount: number | null;
playerVisible: boolean;
playerHiddenReason: string | null;
selections: SelectionRow[];
};
meta.value = {
leagueZh: payload.leagueZh,
leagueEn: payload.leagueEn,
leagueCode: payload.leagueCode,
matchName: payload.matchName,
status: payload.status,
expectedCanonicalCount: payload.expectedCanonicalCount,
playerVisible: payload.playerVisible,
playerHiddenReason: payload.playerHiddenReason,
};
selections.value = payload.selections.map((s) => ({
...s,
editOdds: Number(s.odds),
}));
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
} finally {
loading.value = false;
}
}
watch(matchId, load, { immediate: true });
async function saveMeta() {
saving.value = true;
try {
await api.put(`/admin/outrights/${matchId.value}`, {
status: meta.value.status,
matchName: meta.value.matchName,
});
ElMessage.success(t('msg.saved'));
await load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
saving.value = false;
}
}
async function saveAllOdds() {
const invalid = selections.value.find((s) => !s.editOdds || s.editOdds <= 1);
if (invalid) {
ElMessage.warning(t('outright.err_odds_min'));
return;
}
saving.value = true;
try {
await api.put(`/admin/outrights/${matchId.value}/odds`, {
updates: selections.value.map((s) => ({
selectionId: s.id,
odds: s.editOdds,
})),
});
ElMessage.success(t('msg.outright_odds_saved'));
await load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
saving.value = false;
}
}
async function submitAdd() {
if (!addForm.value.teamCode.trim()) {
ElMessage.warning(t('outright.err_team_code'));
return;
}
saving.value = true;
try {
await api.post(`/admin/outrights/${matchId.value}/selections`, {
teamCode: addForm.value.teamCode.trim().toUpperCase(),
teamZh: addForm.value.teamZh,
teamEn: addForm.value.teamEn,
odds: addForm.value.odds,
});
ElMessage.success(t('msg.saved'));
addVisible.value = false;
addForm.value = { teamCode: '', teamZh: '', teamEn: '', odds: 10 };
await load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
saving.value = false;
}
}
async function removeSelection(row: SelectionRow) {
try {
await ElMessageBox.confirm(
t('outright.confirm_remove', { name: row.teamZh || row.teamCode }),
{ type: 'warning' },
);
} catch {
return;
}
loading.value = true;
try {
await api.delete(`/admin/outrights/${matchId.value}/selections/${row.id}`);
ElMessage.success(t('msg.saved'));
await load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
loading.value = false;
}
}
const playerHiddenTip = computed(() => {
const key = meta.value.playerHiddenReason;
if (!key) return '';
return t(`outright.hidden_reason.${key}`);
});
const teamCountLabel = computed(() => {
const total = meta.value.expectedCanonicalCount;
if (total != null) {
return t('outright.team_count', { n: selections.value.length, total });
}
return t('outright.team_count_open', { n: selections.value.length });
});
const isPublished = computed(() => meta.value.status === 'PUBLISHED');
const leagueLabel = computed(() => {
const name = meta.value.leagueZh || meta.value.leagueEn;
const code = meta.value.leagueCode;
if (name && code) return `${name} (${code})`;
return name || code || '';
});
async function saveMetaWithStatus(status: 'DRAFT' | 'PUBLISHED') {
meta.value.status = status;
await saveMeta();
}
</script>
<template>
<div class="event-editor" v-loading="loading">
<div class="editor-topbar">
<el-button size="small" text @click="router.push({ name: 'admin-outrights' })">
{{ t('outright.back_list') }}
</el-button>
</div>
<el-alert
v-if="!meta.playerVisible"
type="warning"
:closable="false"
show-icon
class="player-alert"
:title="t('outright.player_hidden_title')"
:description="playerHiddenTip"
/>
<section class="panel settings-panel">
<div class="settings-top">
<el-input
v-model="meta.matchName"
size="small"
class="title-input"
:placeholder="t('outright.field.title_placeholder')"
@keyup.enter="saveMeta"
/>
<div class="settings-actions">
<el-button size="small" :loading="saving" @click="saveMeta">
{{ t('common.save') }}
</el-button>
<el-button
v-if="!isPublished"
type="primary"
size="small"
:loading="saving"
@click="saveMetaWithStatus('PUBLISHED')"
>
{{ t('outright.btn.publish') }}
</el-button>
<el-button
v-else
size="small"
:loading="saving"
@click="saveMetaWithStatus('DRAFT')"
>
{{ t('outright.btn.unpublish') }}
</el-button>
</div>
</div>
<p v-if="leagueLabel" class="league-meta">
<span class="league-meta-k">{{ t('outright.field.league') }}</span>
{{ leagueLabel }}
</p>
</section>
<section class="panel teams-panel">
<div class="panel-head compact">
<span class="panel-title">{{ t('outright.section.teams') }} · {{ teamCountLabel }}</span>
<el-button type="primary" plain size="small" @click="addVisible = true">
{{ t('outright.btn.add_team') }}
</el-button>
</div>
<div class="table-wrap">
<el-table :data="selections" stripe size="small" empty-text="">
<el-table-column prop="rank" :label="t('outright.col.rank')" width="72" align="center" />
<el-table-column prop="teamZh" :label="t('outright.col.team_zh')" min-width="120" />
<el-table-column prop="teamEn" :label="t('outright.col.team_en')" min-width="140" />
<el-table-column prop="teamCode" :label="t('outright.col.code')" width="88" />
<el-table-column :label="t('outright.col.odds')" width="160" align="right">
<template #default="{ row }">
<el-input-number
v-model="row.editOdds"
:min="1.01"
:step="0.05"
:precision="2"
controls-position="right"
size="small"
style="width: 130px"
/>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="88" align="center">
<template #default="{ row }">
<el-button link type="danger" @click="removeSelection(row)">
{{ t('common.delete') }}
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="panel-foot compact">
<el-button size="small" @click="load">{{ t('common.reset') }}</el-button>
<el-button type="primary" size="small" :loading="saving" @click="saveAllOdds">
{{ t('outright.btn.save_odds') }}
</el-button>
</div>
</section>
<el-dialog v-model="addVisible" :title="t('outright.btn.add_team')" width="420px">
<el-form label-width="100px">
<el-form-item :label="t('outright.col.code')">
<el-input v-model="addForm.teamCode" placeholder="FRA" />
</el-form-item>
<el-form-item :label="t('outright.col.team_zh')">
<el-input v-model="addForm.teamZh" />
</el-form-item>
<el-form-item :label="t('outright.col.team_en')">
<el-input v-model="addForm.teamEn" />
</el-form-item>
<el-form-item :label="t('outright.col.odds')">
<el-input-number v-model="addForm.odds" :min="1.01" :step="0.05" :precision="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="saving" @click="submitAdd">{{ t('common.confirm') }}</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.event-editor {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 8px;
overflow: auto;
}
.editor-topbar {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
margin-bottom: 2px;
}
.player-alert {
flex-shrink: 0;
margin-bottom: 0;
}
.player-alert :deep(.el-alert__content) {
padding: 4px 0;
}
.panel {
background: #111;
border: 1px solid #252525;
border-radius: 8px;
padding: 10px 12px;
flex-shrink: 0;
}
.teams-panel {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.settings-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.league-meta {
margin: 6px 0 0;
font-size: 11px;
color: #555;
line-height: 1.4;
}
.league-meta-k {
color: #444;
margin-right: 4px;
}
.settings-actions {
display: flex;
flex-shrink: 0;
gap: 6px;
}
.title-input {
flex: 1;
min-width: 0;
}
.panel-head.compact {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.panel-title {
font-size: 13px;
font-weight: 700;
color: #ccc;
}
.panel-foot.compact {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #222;
}
.teams-panel .panel-foot.compact {
margin-top: auto;
}
.table-wrap {
flex: 1;
min-height: 80px;
overflow: auto;
}
</style>

View File

@@ -0,0 +1,384 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import { useAdminLocale } from '../../composables/useAdminLocale';
import api from '../../api';
interface OutrightEventItem {
id: string;
leagueId: string;
leagueCode: string;
leagueZh: string;
leagueEn: string;
matchName: string;
status: string;
selectionCount: number;
canImportCanonical: boolean;
playerVisible: boolean;
playerHiddenReason: string | null;
}
interface SelectionPreview {
rank: number;
teamZh: string;
teamEn: string;
teamCode: string;
odds: string;
}
interface RowDetail {
leagueZh: string;
leagueEn: string;
matchName: string;
playerVisible: boolean;
playerHiddenReason: string | null;
selections: SelectionPreview[];
}
interface LeagueOption {
id: string;
code: string;
nameZh: string;
nameEn: string;
}
const router = useRouter();
const { t } = useAdminLocale();
const listReady = ref(false);
const listLoading = ref(false);
const importing = ref(false);
const events = ref<OutrightEventItem[]>([]);
const leagues = ref<LeagueOption[]>([]);
const expandLoadingId = ref<string | null>(null);
const rowDetails = ref<Record<string, RowDetail>>({});
const createVisible = ref(false);
const createLoading = ref(false);
const createForm = ref({
leagueId: '',
titleZh: '',
titleEn: '',
status: 'PUBLISHED',
});
function eventTitle(ev: OutrightEventItem) {
return ev.matchName || ev.leagueZh || ev.leagueEn || '—';
}
function leagueLabel(ev: OutrightEventItem) {
const name = ev.leagueZh || ev.leagueEn;
return name ? `${name} (${ev.leagueCode})` : ev.leagueCode;
}
function hiddenTip(reason: string | null) {
if (!reason) return '';
return t(`outright.hidden_reason.${reason}`);
}
function goEdit(id: string) {
router.push({ name: 'admin-outright-edit', params: { matchId: id } });
}
async function loadEvents(silent = false) {
if (!silent) listLoading.value = true;
try {
const { data } = await api.get('/admin/outrights');
events.value = data.data ?? [];
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
} finally {
listReady.value = true;
listLoading.value = false;
}
}
async function loadRowDetail(row: OutrightEventItem) {
if (rowDetails.value[row.id]) return;
expandLoadingId.value = row.id;
try {
const { data } = await api.get(`/admin/outrights/${row.id}`);
const payload = data.data as {
leagueZh: string;
leagueEn: string;
matchName: string;
playerVisible: boolean;
playerHiddenReason: string | null;
selections: SelectionPreview[];
};
rowDetails.value[row.id] = {
leagueZh: payload.leagueZh,
leagueEn: payload.leagueEn,
matchName: payload.matchName,
playerVisible: payload.playerVisible,
playerHiddenReason: payload.playerHiddenReason,
selections: payload.selections ?? [],
};
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
} finally {
expandLoadingId.value = null;
}
}
async function onExpandChange(row: OutrightEventItem, expanded: OutrightEventItem[]) {
const opened = expanded.some((r) => r.id === row.id);
if (!opened) return;
await loadRowDetail(row);
}
async function loadLeagues() {
try {
const { data } = await api.get('/admin/outrights/leagues');
leagues.value = data.data ?? [];
} catch {
leagues.value = [];
}
}
async function importWc2026() {
importing.value = true;
try {
const { data } = await api.post('/admin/outrights/import/wc2026');
ElMessage.success(t('msg.outright_canonical_applied'));
listReady.value = false;
rowDetails.value = {};
await loadEvents(false);
const id = data.data?.id as string | undefined;
if (id) goEdit(id);
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
importing.value = false;
}
}
async function submitCreate() {
if (!createForm.value.leagueId) {
ElMessage.warning(t('outright.err_league'));
return;
}
createLoading.value = true;
try {
const { data } = await api.post('/admin/outrights', createForm.value);
ElMessage.success(t('msg.saved'));
createVisible.value = false;
createForm.value = { leagueId: '', titleZh: '', titleEn: '', status: 'PUBLISHED' };
listReady.value = false;
rowDetails.value = {};
await loadEvents(false);
const id = data.data?.id as string;
if (id) goEdit(id);
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
createLoading.value = false;
}
}
function refreshList() {
listReady.value = false;
rowDetails.value = {};
void loadEvents(false);
}
onMounted(() => {
void loadLeagues();
void loadEvents(true);
});
</script>
<template>
<div class="outright-list-page admin-list-page">
<div class="page-toolbar">
<el-button size="small" :loading="importing" @click="importWc2026">
{{ t('outright.btn.import_wc2026') }}
</el-button>
<el-button size="small" :loading="listLoading" @click="refreshList">
{{ t('common.reset') }}
</el-button>
<el-button type="primary" size="small" @click="createVisible = true">
{{ t('outright.btn.create_event') }}
</el-button>
</div>
<el-card class="list-card" shadow="never">
<el-table
:data="events"
row-key="id"
size="small"
:empty-text="listReady ? '' : ''"
@expand-change="onExpandChange"
>
<el-table-column type="expand" width="44">
<template #default="{ row }">
<div v-loading="expandLoadingId === row.id" class="expand-body">
<template v-if="rowDetails[row.id]">
<p v-if="rowDetails[row.id].leagueEn" class="expand-line">
<span class="expand-k">{{ t('outright.col.league_en') }}</span>
{{ rowDetails[row.id].leagueEn }}
</p>
<p class="expand-line">
<span class="expand-k">ID</span>
{{ row.id }}
</p>
<p v-if="!rowDetails[row.id].playerVisible" class="expand-warn">
{{ hiddenTip(rowDetails[row.id].playerHiddenReason) }}
</p>
<el-table
v-if="rowDetails[row.id].selections.length"
:data="rowDetails[row.id].selections"
size="small"
class="preview-table"
max-height="240"
>
<el-table-column prop="rank" :label="t('outright.col.rank')" width="56" />
<el-table-column prop="teamZh" :label="t('outright.col.team_zh')" min-width="100" />
<el-table-column prop="teamCode" :label="t('outright.col.code')" width="72" />
<el-table-column prop="odds" :label="t('outright.col.odds')" width="88" align="right" />
</el-table>
<p v-else class="expand-empty">{{ t('outright.expand_no_teams') }}</p>
<div class="expand-actions">
<el-button type="primary" size="small" @click="goEdit(row.id)">
{{ t('common.edit') }}
</el-button>
</div>
</template>
</div>
</template>
</el-table-column>
<el-table-column :label="t('outright.field.title')" min-width="160">
<template #default="{ row }">{{ eventTitle(row) }}</template>
</el-table-column>
<el-table-column :label="t('outright.field.league')" min-width="180">
<template #default="{ row }">{{ leagueLabel(row) }}</template>
</el-table-column>
<el-table-column :label="t('outright.field.status')" width="96" align="center">
<template #default="{ row }">
<el-tag
size="small"
:type="row.status === 'PUBLISHED' ? 'success' : 'info'"
effect="dark"
>
{{
row.status === 'PUBLISHED'
? t('outright.status.published')
: t('outright.status.draft')
}}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('outright.col.teams')" width="88" align="center">
<template #default="{ row }">{{ row.selectionCount }}</template>
</el-table-column>
<el-table-column :label="t('outright.col.player_visible')" width="108" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.playerVisible ? 'success' : 'warning'" effect="plain">
{{ row.playerVisible ? t('common.yes') : t('common.no') }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="88" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="goEdit(row.id)">
{{ t('common.edit') }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="createVisible" :title="t('outright.btn.create_event')" width="440px">
<el-form label-width="100px" size="small">
<el-form-item :label="t('outright.field.league')">
<el-select v-model="createForm.leagueId" filterable style="width: 100%">
<el-option
v-for="lg in leagues"
:key="lg.id"
:value="lg.id"
:label="`${lg.nameZh || lg.code} (${lg.code})`"
/>
</el-select>
</el-form-item>
<el-form-item :label="t('outright.field.title_zh')">
<el-input v-model="createForm.titleZh" />
</el-form-item>
<el-form-item :label="t('outright.field.title_en')">
<el-input v-model="createForm.titleEn" />
</el-form-item>
</el-form>
<template #footer>
<el-button size="small" @click="createVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" size="small" :loading="createLoading" @click="submitCreate">
{{ t('common.confirm') }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.outright-list-page {
display: flex;
flex-direction: column;
min-height: 0;
}
.list-card {
flex: 1;
min-height: 0;
border-radius: 10px;
display: flex;
flex-direction: column;
}
.list-card :deep(.el-card__body) {
padding: 0;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.expand-body {
padding: 10px 12px 12px 20px;
min-height: 48px;
}
.expand-line {
margin: 0 0 4px;
font-size: 12px;
color: #999;
}
.expand-k {
display: inline-block;
min-width: 72px;
color: #666;
margin-right: 6px;
}
.expand-warn {
margin: 0 0 8px;
font-size: 12px;
color: #c9a227;
}
.expand-empty {
margin: 0;
font-size: 12px;
color: #666;
}
.preview-table {
margin-top: 6px;
}
.expand-actions {
margin-top: 10px;
}
</style>

View File

@@ -7,6 +7,10 @@ export default defineConfig({
resolve: {
// 避免 src 内遗留的 .js 抢先于 .ts/.vue 被解析(曾导致 i18n 文案缺失)
extensions: ['.mjs', '.ts', '.mts', '.tsx', '.vue', '.js', '.jsx', '.json'],
dedupe: ['echarts', 'vue-echarts', 'vue'],
},
optimizeDeps: {
include: ['echarts', 'vue-echarts'],
},
publicDir: resolve(__dirname, '../../packages/shared/public'),
server: {

View File

@@ -260,6 +260,52 @@ class BatchOutrightOddsDto {
updates!: OutrightOddsUpdateItemDto[];
}
class CreateOutrightDto {
@IsString()
leagueId!: string;
@IsString()
titleZh!: string;
@IsString()
titleEn!: string;
@IsOptional()
@IsString()
status?: string;
}
class UpdateOutrightDto {
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsString()
matchName?: string;
@IsOptional()
isHot?: boolean;
@IsOptional()
displayOrder?: number;
}
class AddOutrightSelectionDto {
@IsString()
teamCode!: string;
@IsString()
teamZh!: string;
@IsString()
teamEn!: string;
@IsNumber()
@Min(1.01)
odds!: number;
}
class CashbackPreviewDto {
@IsString()
periodStart!: string;
@@ -648,24 +694,111 @@ export class AdminController {
return jsonResponse(selection);
}
@Get('outrights/wc2026')
async getWc2026Outright() {
const data = await this.outright.getWc2026ForAdmin();
@Get('outrights')
async listOutrights() {
const data = await this.outright.listForAdmin();
return jsonResponse(data);
}
@Get('outrights/leagues')
async listOutrightLeagues() {
const data = await this.outright.listLeagueOptions();
return jsonResponse(data);
}
@Post('outrights')
async createOutright(@Body() dto: CreateOutrightDto) {
const data = await this.outright.createForAdmin({
leagueId: BigInt(dto.leagueId),
titleZh: dto.titleZh,
titleEn: dto.titleEn,
status: dto.status,
});
return jsonResponse(data);
}
@Post('outrights/import/wc2026')
async importWc2026Outright() {
const data = await this.outright.importWc2026Canonical();
return jsonResponse(data);
}
/** @deprecated */
@Get('outrights/wc2026')
async getWc2026OutrightLegacy() {
const list = await this.outright.listForAdmin();
const wc = list.find((e) => e.leagueCode === 'WC2026');
if (!wc) throw new BadRequestException('WC2026 outright not found — run import');
return jsonResponse(await this.outright.getForAdmin(BigInt(wc.id)));
}
/** @deprecated */
@Put('outrights/wc2026/odds')
async updateWc2026OutrightOdds(
async updateWc2026OutrightOddsLegacy(
@CurrentUser('id') operatorId: bigint,
@Body() dto: BatchOutrightOddsDto,
) {
const data = await this.outright.updateWc2026Odds(dto.updates, operatorId);
const list = await this.outright.listForAdmin();
const wc = list.find((e) => e.leagueCode === 'WC2026');
if (!wc) throw new BadRequestException('WC2026 outright not found');
return jsonResponse(
await this.outright.batchUpdateOdds(BigInt(wc.id), dto.updates, operatorId),
);
}
/** @deprecated */
@Post('outrights/wc2026/apply-canonical')
async applyWc2026CanonicalLegacy() {
return jsonResponse(await this.outright.importWc2026Canonical());
}
@Get('outrights/:matchId')
async getOutright(@Param('matchId') matchId: string) {
const data = await this.outright.getForAdmin(BigInt(matchId));
return jsonResponse(data);
}
@Post('outrights/wc2026/apply-canonical')
async applyWc2026Canonical() {
const data = await this.outright.applyWc2026Canonical();
@Put('outrights/:matchId')
async updateOutright(
@Param('matchId') matchId: string,
@Body() dto: UpdateOutrightDto,
) {
const data = await this.outright.updateForAdmin(BigInt(matchId), dto);
return jsonResponse(data);
}
@Put('outrights/:matchId/odds')
async updateOutrightOdds(
@CurrentUser('id') operatorId: bigint,
@Param('matchId') matchId: string,
@Body() dto: BatchOutrightOddsDto,
) {
const data = await this.outright.batchUpdateOdds(
BigInt(matchId),
dto.updates,
operatorId,
);
return jsonResponse(data);
}
@Post('outrights/:matchId/selections')
async addOutrightSelection(
@Param('matchId') matchId: string,
@Body() dto: AddOutrightSelectionDto,
) {
const data = await this.outright.addSelection(BigInt(matchId), dto);
return jsonResponse(data);
}
@Delete('outrights/:matchId/selections/:selectionId')
async removeOutrightSelection(
@Param('matchId') matchId: string,
@Param('selectionId') selectionId: string,
) {
const data = await this.outright.closeSelection(
BigInt(matchId),
BigInt(selectionId),
);
return jsonResponse(data);
}

View File

@@ -15,6 +15,7 @@ import { jsonResponse } from '../../shared/common/filters';
import { UsersService } from '../../domains/identity/users.service';
import { WalletService } from '../../domains/ledger/wallet.service';
import { MatchesService } from '../../domains/catalog/matches.service';
import { OutrightService } from '../../domains/catalog/outright.service';
import { BetsService } from '../../domains/betting/bets.service';
import { ContentService } from '../../domains/operations/content/content.service';
import { CashbackService } from '../../domains/operations/cashback/cashback.service';
@@ -82,6 +83,7 @@ export class PlayerController {
private users: UsersService,
private wallet: WalletService,
private matches: MatchesService,
private outright: OutrightService,
private bets: BetsService,
private content: ContentService,
private cashback: CashbackService,
@@ -134,7 +136,7 @@ export class PlayerController {
@Get('outrights')
async listOutrights(@CurrentUser('locale') locale: string) {
const items = await this.matches.listOutrights(locale);
const items = await this.outright.listForPlayer(locale);
return jsonResponse(items);
}

View File

@@ -14,12 +14,9 @@ import {
toVenueJson,
translationsFromZhiboNames,
} from './zhibo-match.mapper';
import { WC2026_OUTRIGHT_TEAMS } from './wc2026-outright-teams';
import { syncWc2026OutrightMarket } from './wc2026-outright.sync';
const WC2026_OUTRIGHT_RANK = new Map(
WC2026_OUTRIGHT_TEAMS.map((t) => [t.code, t.rank]),
);
const WC2026_OUTRIGHT_CODES = new Set(WC2026_OUTRIGHT_TEAMS.map((t) => t.code));
const OUTRIGHT_PLACEHOLDER_CODE = 'OUT';
@Injectable()
export class MatchesService {
@@ -570,8 +567,19 @@ export class MatchesService {
}
async listOutrights(locale = 'en-US') {
try {
await syncWc2026OutrightMarket(this.prisma, { forceCanonical: false });
} catch {
/* 联赛未 seed 时忽略,仍返回已有数据 */
}
const matches = await this.prisma.match.findMany({
where: { status: 'PUBLISHED', isOutright: true, sportType: 'FOOTBALL' },
where: {
status: 'PUBLISHED',
isOutright: true,
sportType: 'FOOTBALL',
deletedAt: null,
},
include: {
markets: {
where: { marketType: 'OUTRIGHT_WINNER', status: 'OPEN' },
@@ -592,27 +600,28 @@ export class MatchesService {
const market = match.markets[0];
if (!market) continue;
const selections = (
await Promise.all(
market.selections
.filter((sel) => WC2026_OUTRIGHT_CODES.has(sel.selectionCode))
.map(async (sel) => {
const teamCode = sel.selectionCode.replace(/^TEAM_/, '');
const team = await this.prisma.team.findUnique({ where: { code: teamCode } });
const teamName = team
? await this.getTranslation('TEAM', team.id, locale)
: sel.selectionName;
return {
id: sel.id.toString(),
teamCode,
teamName,
rank: WC2026_OUTRIGHT_RANK.get(teamCode) ?? 999,
odds: sel.odds.toString(),
oddsVersion: sel.oddsVersion.toString(),
};
}),
)
).sort((a, b) => a.rank - b.rank);
const selections = await Promise.all(
market.selections
.filter((sel) => sel.selectionCode !== OUTRIGHT_PLACEHOLDER_CODE)
.map(async (sel) => {
const team = await this.prisma.team.findUnique({
where: { code: sel.selectionCode },
});
const teamName = team
? await this.getTranslation('TEAM', team.id, locale)
: sel.selectionName;
return {
id: sel.id.toString(),
teamCode: sel.selectionCode,
teamName,
rank: sel.sortOrder + 1,
odds: sel.odds.toString(),
oddsVersion: sel.oddsVersion.toString(),
};
}),
);
if (selections.length === 0) continue;
results.push({
id: match.id.toString(),

View File

@@ -1,10 +1,15 @@
import { Injectable } from '@nestjs/common';
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { MarketsService } from '../odds/markets.service';
import { WC2026_OUTRIGHT_TEAMS } from './wc2026-outright-teams';
import { WC2026_LEAGUE_CODE, WC2026_OUTRIGHT_TEAMS } from './wc2026-outright-teams';
import { syncWc2026OutrightMarket } from './wc2026-outright.sync';
const CANONICAL_CODES = new Set(WC2026_OUTRIGHT_TEAMS.map((t) => t.code));
const PLACEHOLDER_TEAM_CODE = 'OUT';
const OUTRIGHT_MARKET_TYPE = 'OUTRIGHT_WINNER';
@Injectable()
export class OutrightService {
@@ -13,86 +18,518 @@ export class OutrightService {
private markets: MarketsService,
) {}
async getWc2026ForAdmin() {
const { matchId, marketId } = await syncWc2026OutrightMarket(this.prisma, {
forceCanonical: false,
async listForAdmin() {
const matches = await this.prisma.match.findMany({
where: { isOutright: true, sportType: 'FOOTBALL', deletedAt: null },
include: {
league: true,
markets: {
where: { marketType: OUTRIGHT_MARKET_TYPE },
select: {
id: true,
status: true,
_count: {
select: {
selections: {
where: {
status: { not: 'CLOSED' },
selectionCode: { not: PLACEHOLDER_TEAM_CODE },
},
},
},
},
selections: {
where: {
status: 'OPEN',
selectionCode: { not: PLACEHOLDER_TEAM_CODE },
},
select: { id: true },
take: 1,
},
},
},
},
orderBy: [{ displayOrder: 'asc' }, { startTime: 'asc' }],
});
const match = await this.prisma.match.findUniqueOrThrow({ where: { id: matchId } });
const [leagueZh, leagueEn, market] = await Promise.all([
this.getTranslation('LEAGUE', match.leagueId, 'zh-CN'),
this.getTranslation('LEAGUE', match.leagueId, 'en-US'),
this.prisma.market.findUniqueOrThrow({
where: { id: marketId },
include: {
selections: { orderBy: { sortOrder: 'asc' } },
},
}),
]);
const teamByCode = new Map(
WC2026_OUTRIGHT_TEAMS.map((t) => [t.code, t]),
);
const canonicalSelections = market.selections.filter((sel) =>
CANONICAL_CODES.has(sel.selectionCode),
);
const selections = await Promise.all(
canonicalSelections.map(async (sel) => {
const meta = teamByCode.get(sel.selectionCode);
const team = await this.prisma.team.findUnique({ where: { code: sel.selectionCode } });
const teamZh = team
? await this.getTranslation('TEAM', team.id, 'zh-CN')
: sel.selectionName;
const teamEn = team
? await this.getTranslation('TEAM', team.id, 'en-US')
: sel.selectionName;
return Promise.all(
matches.map(async (m) => {
const league = m.league;
const leagueCode = league.code;
const [leagueZh, leagueEn] = await Promise.all([
this.getTranslation('LEAGUE', league.id, 'zh-CN'),
this.getTranslation('LEAGUE', league.id, 'en-US'),
]);
const market = m.markets[0];
const openCount = market?.selections.length ?? 0;
const selectionCount = market?._count.selections ?? 0;
const visibility = this.playerVisibilityByCounts(
m.status,
market,
openCount,
);
return {
id: sel.id.toString(),
teamCode: sel.selectionCode,
rank: meta?.rank ?? sel.sortOrder + 1,
teamZh,
teamEn,
odds: sel.odds.toString(),
oddsVersion: sel.oddsVersion.toString(),
status: sel.status,
id: m.id.toString(),
leagueId: league.id.toString(),
leagueCode,
leagueZh,
leagueEn,
matchName: m.matchName ?? '',
status: m.status,
selectionCount,
canImportCanonical: leagueCode === WC2026_LEAGUE_CODE,
playerVisible: visibility.playerVisible,
playerHiddenReason: visibility.playerHiddenReason,
};
}),
);
}
selections.sort((a, b) => a.rank - b.rank);
async listLeagueOptions() {
const leagues = await this.prisma.league.findMany({
where: { isActive: true, deletedAt: null, sportType: 'FOOTBALL' },
orderBy: { displayOrder: 'asc' },
});
return Promise.all(
leagues.map(async (l) => ({
id: l.id.toString(),
code: l.code,
nameZh: await this.getTranslation('LEAGUE', l.id, 'zh-CN'),
nameEn: await this.getTranslation('LEAGUE', l.id, 'en-US'),
})),
);
}
async getForAdmin(matchId: bigint) {
const match = await this.getOutrightMatchOrThrow(matchId);
const market = await this.ensureOutrightMarket(match.id);
const league = await this.prisma.league.findUniqueOrThrow({
where: { id: match.leagueId },
});
const [leagueZh, leagueEn] = await Promise.all([
this.getTranslation('LEAGUE', match.leagueId, 'zh-CN'),
this.getTranslation('LEAGUE', match.leagueId, 'en-US'),
]);
const fullMarket = await this.prisma.market.findUniqueOrThrow({
where: { id: market.id },
include: {
selections: { orderBy: { sortOrder: 'asc' } },
},
});
const selections = await Promise.all(
fullMarket.selections
.filter((s) => s.selectionCode !== PLACEHOLDER_TEAM_CODE)
.map(async (sel, index) => {
const team = await this.prisma.team.findUnique({
where: { code: sel.selectionCode },
});
const teamZh = team
? await this.getTranslation('TEAM', team.id, 'zh-CN')
: sel.selectionName;
const teamEn = team
? await this.getTranslation('TEAM', team.id, 'en-US')
: sel.selectionName;
return {
id: sel.id.toString(),
teamCode: sel.selectionCode,
rank: sel.sortOrder + 1 || index + 1,
teamZh: teamZh || sel.selectionName,
teamEn: teamEn || sel.selectionName,
odds: sel.odds.toString(),
oddsVersion: sel.oddsVersion.toString(),
status: sel.status,
};
}),
);
const visibility = this.playerVisibility(
match.status,
fullMarket,
fullMarket.selections.filter(
(s) => s.selectionCode !== PLACEHOLDER_TEAM_CODE,
),
);
return {
matchId: matchId.toString(),
marketId: marketId.toString(),
matchStatus: match.status,
marketStatus: market.status,
id: match.id.toString(),
leagueId: match.leagueId.toString(),
leagueCode: league.code,
leagueZh,
leagueEn,
matchName: match.matchName ?? '',
status: match.status,
marketId: fullMarket.id.toString(),
marketStatus: fullMarket.status,
canImportCanonical: league.code === WC2026_LEAGUE_CODE,
expectedCanonicalCount:
league.code === WC2026_LEAGUE_CODE ? WC2026_OUTRIGHT_TEAMS.length : null,
playerVisible: visibility.playerVisible,
playerHiddenReason: visibility.playerHiddenReason,
selections,
expectedCount: WC2026_OUTRIGHT_TEAMS.length,
};
}
async applyWc2026Canonical() {
await syncWc2026OutrightMarket(this.prisma, { forceCanonical: true });
return this.getWc2026ForAdmin();
async createForAdmin(data: {
leagueId: bigint;
titleZh: string;
titleEn: string;
status?: string;
isHot?: boolean;
displayOrder?: number;
startTime?: Date;
}) {
const league = await this.prisma.league.findUnique({
where: { id: data.leagueId },
});
if (!league) throw new NotFoundException('League not found');
const placeholder = await this.ensurePlaceholderTeam();
const status = data.status ?? 'PUBLISHED';
const match = await this.prisma.match.create({
data: {
leagueId: data.leagueId,
homeTeamId: placeholder.id,
awayTeamId: placeholder.id,
isOutright: true,
matchName: data.titleEn || data.titleZh,
startTime: data.startTime ?? new Date('2030-01-01T00:00:00Z'),
status,
publishTime: status === 'PUBLISHED' ? new Date() : undefined,
isHot: data.isHot ?? false,
displayOrder: data.displayOrder ?? 0,
},
});
await this.ensureOutrightMarket(match.id);
return this.getForAdmin(match.id);
}
async updateWc2026Odds(
async updateForAdmin(
matchId: bigint,
data: {
status?: string;
matchName?: string;
isHot?: boolean;
displayOrder?: number;
titleZh?: string;
titleEn?: string;
},
) {
const match = await this.getOutrightMatchOrThrow(matchId);
const status = data.status ?? match.status;
await this.prisma.match.update({
where: { id: matchId },
data: {
status,
matchName: data.matchName,
isHot: data.isHot,
displayOrder: data.displayOrder,
publishTime:
status === 'PUBLISHED' && !match.publishTime
? new Date()
: match.publishTime,
},
});
return this.getForAdmin(matchId);
}
async addSelection(
matchId: bigint,
data: {
teamCode: string;
teamZh: string;
teamEn: string;
odds: number;
},
) {
if (!data.teamCode?.trim()) {
throw new BadRequestException('Team code required');
}
if (data.odds <= 1) {
throw new BadRequestException('Odds must be greater than 1');
}
const match = await this.getOutrightMatchOrThrow(matchId);
const market = await this.ensureOutrightMarket(match.id);
const code = data.teamCode.trim().toUpperCase();
const team = await this.prisma.team.upsert({
where: { code },
create: { code },
update: {},
});
await this.upsertTeamTranslations(team.id, {
'zh-CN': data.teamZh.trim() || data.teamEn,
'en-US': data.teamEn.trim() || data.teamZh,
});
const existing = await this.prisma.marketSelection.findFirst({
where: { marketId: market.id, selectionCode: code },
});
if (existing) {
throw new BadRequestException('Selection already exists for this team code');
}
const maxSort = await this.prisma.marketSelection.aggregate({
where: { marketId: market.id },
_max: { sortOrder: true },
});
await this.prisma.marketSelection.create({
data: {
marketId: market.id,
selectionCode: code,
selectionName: data.teamZh.trim() || data.teamEn,
odds: data.odds,
sortOrder: (maxSort._max.sortOrder ?? -1) + 1,
status: 'OPEN',
},
});
return this.getForAdmin(matchId);
}
async closeSelection(matchId: bigint, selectionId: bigint) {
const match = await this.getOutrightMatchOrThrow(matchId);
const market = await this.ensureOutrightMarket(match.id);
const sel = await this.prisma.marketSelection.findFirst({
where: { id: selectionId, marketId: market.id },
});
if (!sel) throw new NotFoundException('Selection not found');
await this.prisma.marketSelection.update({
where: { id: selectionId },
data: { status: 'CLOSED' },
});
return this.getForAdmin(matchId);
}
async batchUpdateOdds(
matchId: bigint,
updates: Array<{ selectionId: string; odds: number }>,
operatorId: bigint,
) {
const results = await this.markets.batchUpdateOdds(
await this.getOutrightMatchOrThrow(matchId);
const market = await this.ensureOutrightMarket(matchId);
const allowed = new Set(
(
await this.prisma.marketSelection.findMany({
where: { marketId: market.id },
select: { id: true },
})
).map((s) => s.id.toString()),
);
for (const u of updates) {
if (!allowed.has(u.selectionId)) {
throw new BadRequestException('Invalid selection for this outright event');
}
}
await this.markets.batchUpdateOdds(
updates.map((u) => ({ selectionId: BigInt(u.selectionId), odds: u.odds })),
operatorId,
);
return results.map((r) => ({
id: r.id.toString(),
odds: r.odds.toString(),
oddsVersion: r.oddsVersion.toString(),
}));
return this.getForAdmin(matchId);
}
async importWc2026Canonical() {
const { matchId } = await syncWc2026OutrightMarket(this.prisma, {
forceCanonical: true,
});
return this.getForAdmin(matchId);
}
async listForPlayer(locale: string) {
await this.trySyncWc2026();
const matches = await this.prisma.match.findMany({
where: {
status: 'PUBLISHED',
isOutright: true,
sportType: 'FOOTBALL',
deletedAt: null,
},
include: {
markets: {
where: { marketType: OUTRIGHT_MARKET_TYPE, status: 'OPEN' },
include: {
selections: {
where: { status: 'OPEN' },
orderBy: { sortOrder: 'asc' },
},
},
},
},
orderBy: [{ displayOrder: 'asc' }, { startTime: 'asc' }],
});
const results = [];
for (const match of matches) {
const league = await this.prisma.league.findUniqueOrThrow({
where: { id: match.leagueId },
});
const leagueName = await this.getTranslation('LEAGUE', match.leagueId, locale);
const market = match.markets[0];
if (!market) continue;
const selections = await Promise.all(
market.selections
.filter((sel) => sel.selectionCode !== PLACEHOLDER_TEAM_CODE)
.map(async (sel) => {
const team = await this.prisma.team.findUnique({
where: { code: sel.selectionCode },
});
const translated = team
? await this.getTranslation('TEAM', team.id, locale)
: '';
const teamZh = team
? await this.getTranslation('TEAM', team.id, 'zh-CN')
: sel.selectionName;
const teamEn = team
? await this.getTranslation('TEAM', team.id, 'en-US')
: sel.selectionName;
const teamName =
translated ||
(locale.startsWith('zh') ? teamZh : teamEn) ||
sel.selectionName;
return {
id: sel.id.toString(),
teamCode: sel.selectionCode,
teamName,
odds: sel.odds.toString(),
oddsVersion: sel.oddsVersion.toString(),
};
}),
);
if (!selections.length) continue;
const title =
match.matchName?.trim() ||
`*${leagueName || 'Outright'} ${locale.startsWith('zh') ? '冠军' : 'Winner'}`;
results.push({
id: match.id.toString(),
leagueId: match.leagueId.toString(),
leagueCode: league.code,
leagueName: leagueName || '',
title: title.startsWith('*') ? title : `*${title}`,
marketId: market.id.toString(),
selectionCount: selections.length,
selections,
});
}
return results;
}
/** @deprecated 使用 listForPlayer */
async getWc2026ForPlayer(locale: string) {
return this.listForPlayer(locale);
}
private async trySyncWc2026() {
try {
await syncWc2026OutrightMarket(this.prisma, { forceCanonical: false });
} catch {
/* 联赛未 seed 时忽略 */
}
}
private async getOutrightMatchOrThrow(matchId: bigint) {
const match = await this.prisma.match.findFirst({
where: { id: matchId, isOutright: true, deletedAt: null },
});
if (!match) throw new NotFoundException('Outright event not found');
return match;
}
private async ensureOutrightMarket(matchId: bigint) {
let market = await this.prisma.market.findFirst({
where: { matchId, marketType: OUTRIGHT_MARKET_TYPE },
});
if (!market) {
market = await this.prisma.market.create({
data: {
matchId,
marketType: OUTRIGHT_MARKET_TYPE,
period: 'OUTRIGHT',
allowSingle: true,
allowParlay: false,
sortOrder: 1,
status: 'OPEN',
},
});
}
return market;
}
private async ensurePlaceholderTeam() {
const existing = await this.prisma.team.findUnique({
where: { code: PLACEHOLDER_TEAM_CODE },
});
if (existing) return existing;
return this.prisma.team.create({
data: { code: PLACEHOLDER_TEAM_CODE },
});
}
private async upsertTeamTranslations(
teamId: bigint,
names: Record<string, string>,
) {
for (const [locale, value] of Object.entries(names)) {
if (!value) continue;
await this.prisma.entityTranslation.upsert({
where: {
entityType_entityId_locale_fieldName: {
entityType: 'TEAM',
entityId: teamId,
locale,
fieldName: 'name',
},
},
create: {
entityType: 'TEAM',
entityId: teamId,
locale,
fieldName: 'name',
value,
},
update: { value },
});
}
}
private playerVisibility(
matchStatus: string,
market: { status: string } | null | undefined,
selections: Array<{ selectionCode: string; status: string }>,
): { 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);
}
private playerVisibilityByCounts(
matchStatus: string,
market: { status: string } | null | undefined,
openSelectionCount: number,
): { playerVisible: boolean; playerHiddenReason: string | null } {
if (matchStatus !== 'PUBLISHED') {
return { playerVisible: false, playerHiddenReason: 'NOT_PUBLISHED' };
}
if (!market || market.status !== 'OPEN') {
return { playerVisible: false, playerHiddenReason: 'MARKET_CLOSED' };
}
if (openSelectionCount === 0) {
return { playerVisible: false, playerHiddenReason: 'NO_SELECTIONS' };
}
return { playerVisible: true, playerHiddenReason: null };
}
private async getTranslation(

View File

@@ -0,0 +1,232 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import OutrightOptionCard from './OutrightOptionCard.vue';
import saishiImg from '../../assets/images/saishi.png';
export interface OutrightSelection {
id: string;
teamCode: string;
teamName: string;
odds: string;
oddsVersion: string;
}
export interface OutrightEvent {
id: string;
leagueId: string;
leagueCode?: string;
leagueName: string;
title: string;
selectionCount?: number;
selections: OutrightSelection[];
}
const props = defineProps<{
event: OutrightEvent;
expanded: boolean;
visibleLimit: number;
loadingMore: boolean;
}>();
const emit = defineEmits<{
toggle: [];
loadMore: [];
pick: [selection: OutrightSelection];
}>();
const { t } = useI18n();
const INITIAL_BATCH = 20;
const headTitle = computed(() => {
const raw = props.event.title.replace(/^\*+/, '').trim();
return raw || props.event.leagueName || t('bet.tab_outright');
});
const headMeta = computed(() => {
const total = props.event.selectionCount ?? props.event.selections.length;
return t('bet.outright_teams_count', { n: total });
});
const visibleSelections = computed(() =>
props.event.selections.slice(0, props.visibleLimit),
);
const hasMore = computed(
() => props.event.selections.length > props.visibleLimit,
);
const showLoadMore = computed(
() => props.event.selections.length > INITIAL_BATCH && hasMore.value,
);
</script>
<template>
<section class="event-block">
<button type="button" class="event-head" :aria-expanded="expanded" @click="emit('toggle')">
<span class="toggle-icon" :class="{ open: expanded }">
<span class="toggle-mark">{{ expanded ? '' : '+' }}</span>
</span>
<span class="event-head-text">
<span class="event-title">*{{ headTitle }}</span>
<span v-if="event.leagueName && event.leagueName !== headTitle" class="event-league">
{{ event.leagueName }}
</span>
<span class="event-meta">{{ headMeta }}</span>
</span>
<img :src="saishiImg" alt="" class="event-saishi" />
</button>
<div v-show="expanded" class="options-wrap">
<div class="options-grid">
<OutrightOptionCard
v-for="sel in visibleSelections"
:key="sel.id"
:team-code="sel.teamCode"
:team-name="sel.teamName"
:odds="sel.odds"
@pick="emit('pick', sel)"
/>
</div>
<div v-if="showLoadMore" class="load-more-zone">
<p class="load-more-hint">
{{
t('bet.outright_shown_count', {
shown: visibleSelections.length,
total: event.selections.length,
})
}}
</p>
<button
type="button"
class="load-more-btn"
:disabled="loadingMore"
@click="emit('loadMore')"
>
{{ loadingMore ? t('bet.loading') : t('bet.outright_load_more') }}
</button>
</div>
</div>
</section>
</template>
<style scoped>
.event-block {
margin-bottom: 10px;
}
.event-head {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 12px 10px;
background: #141414;
border: 1px solid #2e2e2e;
border-radius: 6px;
text-align: left;
}
.toggle-icon {
flex-shrink: 0;
width: 26px;
height: 26px;
border-radius: 50%;
background: #141414;
border: 1px solid var(--border-gold-soft);
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.15s;
}
.toggle-icon.open {
border-color: var(--primary-light);
}
.toggle-mark {
color: var(--primary-light);
font-size: 17px;
font-weight: 900;
line-height: 1;
}
.event-head-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.event-title {
font-size: 13px;
font-weight: 800;
color: var(--primary-light);
line-height: 1.35;
}
.event-league {
font-size: 11px;
font-weight: 600;
color: #888;
line-height: 1.3;
}
.event-meta {
font-size: 11px;
font-weight: 600;
color: #666;
}
.event-saishi {
flex-shrink: 0;
height: 44px;
width: auto;
max-width: 40px;
object-fit: contain;
padding-left: 8px;
border-left: 1px solid #2a2a2a;
}
.options-wrap {
padding: 10px 0 4px;
}
.options-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.load-more-zone {
padding: 14px 8px 6px;
text-align: center;
}
.load-more-hint {
margin: 0 0 10px;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
}
.load-more-btn {
width: 100%;
max-width: 280px;
padding: 11px 16px;
border-radius: 8px;
border: 1px solid var(--border-gold-soft);
background: linear-gradient(180deg, #1f1f1f, #141414);
color: var(--primary-light);
font-size: 13px;
font-weight: 800;
font-family: inherit;
letter-spacing: 0.04em;
}
.load-more-btn:disabled {
opacity: 0.65;
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, ref, watch } from 'vue';
import { teamFlagUrl } from '../../utils/teamFlag';
const props = defineProps<{
@@ -11,6 +11,18 @@ const props = defineProps<{
const emit = defineEmits<{ pick: [] }>();
const flag = computed(() => teamFlagUrl(props.teamCode, props.teamName));
const flagFailed = ref(false);
function onFlagError() {
flagFailed.value = true;
}
watch(
() => [props.teamCode, props.teamName] as const,
() => {
flagFailed.value = false;
},
);
function formatOdds(odds: string) {
const n = parseFloat(odds);
@@ -20,7 +32,15 @@ function formatOdds(odds: string) {
<template>
<button type="button" class="option-card" @click="emit('pick')">
<img v-if="flag" :src="flag" alt="" class="flag" />
<img
v-if="flag && !flagFailed"
:src="flag"
alt=""
class="flag"
loading="lazy"
@error="onFlagError"
/>
<span v-else class="flag-placeholder" aria-hidden="true"></span>
<span class="name">{{ teamName }}</span>
<span class="odds">[ {{ formatOdds(odds) }} ]</span>
</button>
@@ -53,6 +73,16 @@ function formatOdds(odds: string) {
border-radius: 2px;
}
.flag-placeholder {
width: 28px;
height: 19px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
opacity: 0.55;
}
.name {
font-size: 11px;
font-weight: 800;

View File

@@ -1,56 +1,118 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import api from '../../api';
import OutrightOptionCard from './OutrightOptionCard.vue';
import OutrightEventSection, {
type OutrightEvent,
type OutrightSelection,
} from './OutrightEventSection.vue';
import OutrightBetModal, { type OutrightPick } from './OutrightBetModal.vue';
import emptyMatchesImg from '../../assets/images/empty-matches.svg';
import saishiImg from '../../assets/images/saishi.png';
interface OutrightSelection {
id: string;
teamCode: string;
teamName: string;
odds: string;
oddsVersion: string;
}
interface OutrightEvent {
id: string;
leagueId: string;
leagueName: string;
title: string;
selections: OutrightSelection[];
}
import { useOnLocaleChange } from '../../composables/useOnLocaleChange';
const { t } = useI18n();
const INITIAL_BATCH = 20;
const LOAD_MORE_STEP = 28;
const loading = ref(true);
const loadError = ref('');
const loadingMoreId = ref<string | null>(null);
const events = ref<OutrightEvent[]>([]);
const expanded = ref<Set<string>>(new Set());
const visibleLimits = ref<Record<string, number>>({});
const modalOpen = ref(false);
const activePick = ref<OutrightPick | null>(null);
const eventCount = computed(() => events.value.length);
const totalSelections = computed(() =>
events.value.reduce((sum, e) => sum + e.selections.length, 0),
);
function resetVisibleLimits() {
const next: Record<string, number> = {};
for (const e of events.value) {
next[e.id] =
e.selections.length <= INITIAL_BATCH
? e.selections.length
: INITIAL_BATCH;
}
visibleLimits.value = next;
}
function syncExpandedAfterLoad() {
const ids = events.value.map((e) => e.id);
const kept = new Set([...expanded.value].filter((id) => ids.includes(id)));
if (kept.size > 0) {
expanded.value = kept;
return;
}
if (ids.length === 1) {
expanded.value = new Set(ids);
} else if (ids.length <= 3) {
expanded.value = new Set(ids);
} else {
expanded.value = new Set([ids[0]]);
}
}
function hasMoreSelections(event: OutrightEvent) {
const limit = visibleLimits.value[event.id] ?? INITIAL_BATCH;
return event.selections.length > limit;
}
function loadMore(event: OutrightEvent) {
if (loadingMoreId.value || !hasMoreSelections(event)) return;
loadingMoreId.value = event.id;
const current = visibleLimits.value[event.id] ?? INITIAL_BATCH;
visibleLimits.value = {
...visibleLimits.value,
[event.id]: Math.min(current + LOAD_MORE_STEP, event.selections.length),
};
loadingMoreId.value = null;
}
async function load() {
loading.value = true;
loadError.value = '';
try {
const { data } = await api.get('/player/outrights');
events.value = data.data ?? [];
if (events.value.length && expanded.value.size === 0) {
expanded.value = new Set([events.value[0].id]);
const list = (data?.data ?? []) as OutrightEvent[];
events.value = list.filter((e) => e.selections?.length > 0);
resetVisibleLimits();
syncExpandedAfterLoad();
} catch (e: unknown) {
events.value = [];
const err = e as { response?: { status?: number; data?: { error?: string } } };
if (err.response?.status === 403) {
loadError.value = t('bet.outright_player_only');
} else {
loadError.value = err.response?.data?.error ?? t('bet.outright_load_failed');
}
} finally {
loading.value = false;
}
}
onMounted(load);
useOnLocaleChange(load);
function toggle(id: string) {
const next = new Set(expanded.value);
if (next.has(id)) next.delete(id);
else next.add(id);
expanded.value = next;
if (next.has(id) && visibleLimits.value[id] == null) {
const event = events.value.find((e) => e.id === id);
if (event) {
visibleLimits.value = {
...visibleLimits.value,
[id]:
event.selections.length <= INITIAL_BATCH
? event.selections.length
: INITIAL_BATCH,
};
}
}
}
function openBet(event: OutrightEvent, sel: OutrightSelection) {
@@ -75,32 +137,31 @@ function closeModal() {
<div class="outright-panel">
<div v-if="loading" class="state">{{ t('bet.loading') }}</div>
<div v-else-if="events.length" class="event-list">
<section v-for="event in events" :key="event.id" class="event-block">
<button type="button" class="event-head" @click="toggle(event.id)">
<span class="toggle-icon">
<span class="toggle-mark">{{ expanded.has(event.id) ? '' : '+' }}</span>
</span>
<span class="event-title">{{ event.title }}</span>
<img :src="saishiImg" alt="" class="event-saishi" />
</button>
<template v-else-if="events.length">
<p v-if="eventCount > 1" class="panel-summary">
{{ t('bet.outright_events_summary', { events: eventCount, teams: totalSelections }) }}
</p>
<div v-if="expanded.has(event.id)" class="options-grid">
<OutrightOptionCard
v-for="sel in event.selections"
:key="sel.id"
:team-code="sel.teamCode"
:team-name="sel.teamName"
:odds="sel.odds"
@pick="openBet(event, sel)"
/>
</div>
</section>
</div>
<div class="event-list">
<OutrightEventSection
v-for="event in events"
:key="event.id"
:event="event"
:expanded="expanded.has(event.id)"
:visible-limit="visibleLimits[event.id] ?? INITIAL_BATCH"
:loading-more="loadingMoreId === event.id"
@toggle="toggle(event.id)"
@load-more="loadMore(event)"
@pick="openBet(event, $event)"
/>
</div>
</template>
<div v-else class="empty">
<img :src="emptyMatchesImg" alt="" class="empty-icon" />
<p>{{ t('bet.no_outright') }}</p>
<p v-if="loadError">{{ loadError }}</p>
<p v-else>{{ t('bet.no_outright') }}</p>
<p v-if="!loadError" class="empty-hint">{{ t('bet.no_outright_hint') }}</p>
</div>
<OutrightBetModal :open="modalOpen" :pick="activePick" @close="closeModal" />
@@ -112,64 +173,17 @@ function closeModal() {
padding: 4px 12px 0;
}
.event-block {
margin-bottom: 10px;
.panel-summary {
margin: 0 0 12px;
padding: 0 4px;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
line-height: 1.4;
}
.event-head {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 12px 10px;
background: #141414;
border: 1px solid #2e2e2e;
border-radius: 6px;
text-align: left;
}
.toggle-icon {
flex-shrink: 0;
width: 26px;
height: 26px;
border-radius: 50%;
background: #141414;
border: 1px solid var(--border-gold-soft);
display: flex;
align-items: center;
justify-content: center;
}
.toggle-mark {
color: var(--primary-light);
font-size: 17px;
font-weight: 900;
line-height: 1;
}
.event-title {
flex: 1;
font-size: 13px;
font-weight: 800;
color: var(--primary-light);
line-height: 1.35;
}
.event-saishi {
flex-shrink: 0;
height: 44px;
width: auto;
max-width: 40px;
object-fit: contain;
padding-left: 8px;
border-left: 1px solid #2a2a2a;
}
.options-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 6px;
padding: 10px 0 4px;
.event-list {
padding-bottom: 8px;
}
.state,
@@ -185,4 +199,11 @@ function closeModal() {
height: 96px;
margin-bottom: 14px;
}
.empty-hint {
margin-top: 8px;
font-size: 12px;
font-weight: 500;
opacity: 0.85;
}
</style>

View File

@@ -1,11 +1,12 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import api from '../../api';
import { useBetSlipStore } from '../../stores/betSlip';
import { PARLAY_MAX_LEGS, canSelectForParlay } from '@thebet365/shared';
import { PARLAY_MARKET_TYPES, PARLAY_SELECTION_KEYS } from '../../utils/parlayColumns';
import BetGuideHelp from '../BetGuideHelp.vue';
import { useOnLocaleChange } from '../../composables/useOnLocaleChange';
type TimeFilter = 'all' | 'today';
@@ -43,7 +44,7 @@ const timeFilter = ref<TimeFilter>('all');
const parlayMarketKeys = PARLAY_MARKET_TYPES.map((c) => c.key);
onMounted(async () => {
async function loadParlayMatches() {
loading.value = true;
try {
const { data } = await api.get('/player/matches');
@@ -53,7 +54,9 @@ onMounted(async () => {
} finally {
loading.value = false;
}
});
}
useOnLocaleChange(loadParlayMatches);
function parseLine(v: string | number | null | undefined) {
if (v == null || v === '') return null;

View File

@@ -27,7 +27,9 @@ export function useAppLocale() {
}
async function setLocale(code: string) {
applyLocale(code);
if (!(SUPPORTED_LOCALES as readonly string[]).includes(code)) return;
if (locale.value === code) return;
if (auth.token) {
try {
await api.post('/player/language', { locale: code });
@@ -39,6 +41,7 @@ export function useAppLocale() {
/* 离线或 token 过期时仍保留本地语言 */
}
}
applyLocale(code);
}
function initFromUser(userLocale?: string | null) {

View File

@@ -0,0 +1,15 @@
import { onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
/** 挂载时加载一次;语言切换后重新拉取服务端本地化数据 */
export function useOnLocaleChange(loader: () => void | Promise<void>) {
const { locale } = useI18n();
onMounted(() => {
void loader();
});
watch(locale, (next, prev) => {
if (prev && next !== prev) void loader();
});
}

View File

@@ -10,10 +10,10 @@ import LocaleSwitcher from '../components/LocaleSwitcher.vue';
import { useAppLocale } from '../composables/useAppLocale';
import AnnouncementMarquee from '../components/AnnouncementMarquee.vue';
import BottomNavIcon from '../components/BottomNavIcon.vue';
import { computed, onMounted } from 'vue';
import { computed, onMounted, watch } from 'vue';
import { useAnnouncements } from '../composables/useAnnouncements';
const { t } = useI18n();
const { t, locale } = useI18n();
const auth = useAuthStore();
const { initFromUser } = useAppLocale();
const route = useRoute();
@@ -26,6 +26,10 @@ onMounted(() => {
loadAnnouncements();
if (auth.user?.locale) initFromUser(auth.user.locale);
});
watch(locale, (next, prev) => {
if (prev && next !== prev) void loadAnnouncements();
});
</script>
<template>

View File

@@ -79,6 +79,13 @@ const i18n = createI18n({
stake_max: '全部',
placing: '提交中…',
no_outright: '暂无冠军盘口',
no_outright_hint: '请使用玩家账号登录;若仍无数据,请联系管理员在后台发布优胜冠军赛事',
outright_events_summary: '共 {events} 个冠军赛事 · {teams} 支队伍',
outright_teams_count: '{n} 支队伍',
outright_load_failed: '冠军盘加载失败,请检查网络或稍后重试',
outright_player_only: '请使用玩家账号登录后查看',
outright_shown_count: '已显示 {shown} / {total} 队',
outright_load_more: '加载更多',
cancel: '取消',
parlay_title: '串关投注',
parlay_guide_title: '串关怎么投?',
@@ -263,6 +270,13 @@ const i18n = createI18n({
stake_max: 'Max',
placing: 'Placing…',
no_outright: 'No outright markets',
no_outright_hint: 'Sign in as a player. If empty, ask admin to publish outright events.',
outright_events_summary: '{events} outright events · {teams} teams',
outright_teams_count: '{n} teams',
outright_load_failed: 'Failed to load outright markets',
outright_player_only: 'Player login required',
outright_shown_count: '{shown} / {total} teams shown',
outright_load_more: 'Load more',
cancel: 'Cancel',
parlay_title: 'Parlay',
parlay_guide_title: 'How to parlay',
@@ -453,6 +467,13 @@ const i18n = createI18n({
stake_max: 'Maks',
placing: 'Memproses…',
no_outright: 'Tiada pasaran juara',
no_outright_hint: 'Log masuk sebagai pemain. Jika kosong, minta admin terbitkan acara juara.',
outright_events_summary: '{events} acara juara · {teams} pasukan',
outright_teams_count: '{n} pasukan',
outright_load_failed: 'Gagal memuatkan pasaran juara',
outright_player_only: 'Log masuk pemain diperlukan',
outright_shown_count: '{shown} / {total} pasukan dipaparkan',
outright_load_more: 'Muat lagi',
cancel: 'Batal',
parlay_title: 'Pertaruhan Berganda',
parlay_guide_title: 'Cara parlay',

View File

@@ -1,82 +1,162 @@
/** 球队 code / 名称 → ISO 3166-1 alpha-2用于 flagcdn 国旗图 */
/** 球队 code / 名称 → ISO 3166-1flagcdn.com含世界杯 48 强 */
const CODE_TO_ISO: Record<string, string> = {
MEX: 'mx',
USA: 'us',
CAN: 'ca',
BRA: 'br',
ARG: 'ar',
ENG: 'gb',
MUN: 'gb',
CHE: 'gb',
LIV: 'gb',
MCI: 'gb',
CZE: 'cz',
KOR: 'kr',
BIH: 'ba',
PAR: 'py',
RSA: 'za',
SUI: 'ch',
SCO: 'gb-sct',
TUR: 'tr',
CZE: 'cz',
BIH: 'ba',
// 世界杯 2026 48 强
FRA: 'fr',
ESP: 'es',
ENG: 'gb',
GER: 'de',
ENG: 'gb-eng',
BRA: 'br',
ARG: 'ar',
POR: 'pt',
GER: 'de',
NED: 'nl',
NOR: 'no',
BEL: 'be',
COL: 'co',
JPN: 'jp',
URU: 'uy',
USA: 'us',
MAR: 'ma',
CRO: 'hr',
MEX: 'mx',
SUI: 'ch',
TUR: 'tr',
SEN: 'sn',
KOR: 'kr',
AUT: 'at',
ECU: 'ec',
SWE: 'se',
IRN: 'ir',
GHA: 'gh',
ALG: 'dz',
BIH: 'ba',
EGY: 'eg',
TUN: 'tn',
CAN: 'ca',
PAN: 'pa',
AUS: 'au',
CZE: 'cz',
KSA: 'sa',
NZL: 'nz',
COD: 'cd',
UZB: 'uz',
IRQ: 'iq',
RSA: 'za',
CIV: 'ci',
JOR: 'jo',
PAR: 'py',
HAI: 'ht',
QAT: 'qa',
CPV: 'cv',
CUW: 'cw',
SCO: 'gb-sct',
// 俱乐部 / 演示联赛
MUN: 'gb',
CHE: 'gb',
LIV: 'gb',
MCI: 'gb',
};
const NAME_TO_ISO: Record<string, string> = {
Mexico: 'mx',
'South Africa': 'za',
'United States': 'us',
USA: 'us',
Canada: 'ca',
France: 'fr',
Spain: 'es',
England: 'gb-eng',
Brazil: 'br',
Argentina: 'ar',
Portugal: 'pt',
Germany: 'de',
Netherlands: 'nl',
Norway: 'no',
Belgium: 'be',
Colombia: 'co',
Japan: 'jp',
Uruguay: 'uy',
USA: 'us',
'United States': 'us',
Morocco: 'ma',
Croatia: 'hr',
Mexico: 'mx',
Switzerland: 'ch',
Scotland: 'gb-sct',
Turkey: 'tr',
Senegal: 'sn',
'South Korea': 'kr',
Austria: 'at',
Ecuador: 'ec',
Sweden: 'se',
Iran: 'ir',
Ghana: 'gh',
Algeria: 'dz',
Bosnia: 'ba',
Egypt: 'eg',
Tunisia: 'tn',
Canada: 'ca',
Panama: 'pa',
Australia: 'au',
Czech: 'cz',
'Czech Republic': 'cz',
'Saudi Arabia': 'sa',
'New Zealand': 'nz',
'DR Congo': 'cd',
Uzbekistan: 'uz',
Iraq: 'iq',
'South Africa': 'za',
'Ivory Coast': 'ci',
Jordan: 'jo',
Paraguay: 'py',
西: 'mx',
: 'us',
: 'ca',
: 'gb',
西: 'gb',
西: 'mx',
: 'za',
: 'cz',
: 'kr',
: 'ba',
: 'py',
: 'ch',
西: 'br',
: 'gb-sct',
: 'tr',
: 'fr',
: 'ar',
Haiti: 'ht',
Qatar: 'qa',
'Cape Verde': 'cv',
Curacao: 'cw',
Scotland: 'gb-sct',
: 'fr',
西: 'es',
: 'gb',
: 'de',
: 'gb-eng',
西: 'br',
: 'ar',
: 'pt',
: 'de',
: 'nl',
: 'no',
: 'be',
: 'co',
: 'jp',
: 'uy',
: 'us',
: 'ma',
: 'hr',
西: 'mx',
: 'ch',
: 'tr',
: 'sn',
: 'kr',
: 'at',
: 'ec',
: 'se',
: 'ir',
: 'gh',
: 'dz',
: 'ba',
: 'eg',
: 'tn',
: 'ca',
: 'pa',
: 'au',
: 'cz',
: 'sa',
西: 'nz',
'刚果(金)': 'cd',
: 'uz',
: 'iq',
: 'za',
: 'ci',
: 'jo',
: 'py',
: 'ht',
: 'qa',
: 'cv',
: 'cw',
: 'gb-sct',
: 'gb',
西: 'gb',
};
export function teamFlagUrl(code?: string, name?: string): string {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { ref, computed, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
@@ -8,6 +8,7 @@ import LeagueAccordionItem from '../components/LeagueAccordionItem.vue';
import OutrightPanel from '../components/outright/OutrightPanel.vue';
import ParlayPanel from '../components/parlay/ParlayPanel.vue';
import emptyMatchesImg from '../assets/images/empty-matches.svg';
import { useOnLocaleChange } from '../composables/useOnLocaleChange';
type MainTab = 'matches' | 'outright' | 'parlay';
type TimeTab = 'today' | 'early';
@@ -39,7 +40,7 @@ const matches = ref<Match[]>([]);
const loading = ref(true);
const expandedLeagues = ref<Set<string>>(new Set());
onMounted(async () => {
async function loadMatches() {
loading.value = true;
try {
const { data } = await api.get('/player/matches');
@@ -47,7 +48,9 @@ onMounted(async () => {
} finally {
loading.value = false;
}
});
}
useOnLocaleChange(loadMatches);
function dayStart(d: Date) {
const x = new Date(d);
@@ -186,7 +189,9 @@ function goMatch(id: string) {
</div>
</template>
<OutrightPanel v-else-if="mainTab === 'outright'" />
<div v-else-if="mainTab === 'outright'" class="outright-tab">
<OutrightPanel />
</div>
<ParlayPanel v-else-if="mainTab === 'parlay'" />
</div>
@@ -290,4 +295,8 @@ function goMatch(id: string) {
.parlay-tab.tab-gold-active {
flex: 1.15;
}
.outright-tab {
min-height: 0;
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
@@ -8,6 +8,7 @@ import api from '../api';
import emptyMatchesImg from '../assets/images/empty-matches.svg';
import BannerCarousel from '../components/BannerCarousel.vue';
import { resolveBanners } from '../constants/defaultBanner';
import { useOnLocaleChange } from '../composables/useOnLocaleChange';
const router = useRouter();
const home = ref<{
@@ -38,10 +39,12 @@ interface Match {
const displayBanners = computed(() => resolveBanners(home.value?.banners));
onMounted(async () => {
async function loadHome() {
const { data } = await api.get('/player/home');
home.value = data.data;
});
}
useOnLocaleChange(loadHome);
function goMatch(id: string) {
router.push(`/match/${id}`);

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { ref, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
@@ -14,6 +14,7 @@ import CorrectScoreConfirmModal, {
type CsConfirmLine,
} from '../components/match-detail/CorrectScoreConfirmModal.vue';
import { isCorrectScoreMarket, parseScoreCode } from '../utils/correctScoreLayout';
import { useOnLocaleChange } from '../composables/useOnLocaleChange';
const route = useRoute();
const router = useRouter();
@@ -197,7 +198,7 @@ async function loadMatch() {
}
}
onMounted(loadMatch);
useOnLocaleChange(loadMatch);
function isSelected(id: string) {
return slip.items.some((i) => i.selectionId === id);

View File

@@ -1,16 +1,15 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import api from '../api';
import BetHistoryCard, { type BetHistoryItem } from '../components/BetHistoryCard.vue';
import { useOnLocaleChange } from '../composables/useOnLocaleChange';
const { t } = useI18n();
const bets = ref<{ items: BetHistoryItem[]; total: number }>({ items: [], total: 0 });
const loading = ref(true);
onMounted(load);
async function load() {
loading.value = true;
try {
@@ -20,6 +19,8 @@ async function load() {
loading.value = false;
}
}
useOnLocaleChange(load);
</script>
<template>