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; min-height: 0;
overflow: hidden; overflow: hidden;
} }
.admin-list-page > .page-header, .admin-list-page > .page-toolbar,
.admin-list-page > .filter-card, .admin-list-page > .filter-card,
.admin-list-page > .tool-card { .admin-list-page > .tool-card {
flex-shrink: 0; 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 { .admin-list-page > .tool-card {
margin-bottom: 16px; margin-bottom: 16px;
} }
.admin-list-page > .filter-card { .admin-list-page > .filter-card {
margin-bottom: 16px; margin-bottom: 16px;
} }
.admin-list-page > .page-header {
margin-bottom: 20px;
}
.admin-list-page > .data-card { .admin-list-page > .data-card {
flex: 1; flex: 1;
min-height: 0; min-height: 0;

View File

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

View File

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

View File

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

View File

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

View File

@@ -249,18 +249,51 @@ export const adminPagesZh: Record<string, string> = {
'msg.outright_odds_saved': '夺冠赔率已保存', 'msg.outright_odds_saved': '夺冠赔率已保存',
'msg.load_failed': '加载失败', 'msg.load_failed': '加载失败',
'page.outright.title': '世界杯 48 强夺冠', 'page.outrights.title': '优胜冠军',
'page.outright.desc': '固定 48 支球队,可调整夺冠赔率(玩家端「优胜冠军」)', 'page.outrights.desc': '可新建任意联赛冠军盘、编辑队伍与赔率;世界杯 48 强提供一键导入基准数据',
'outright.col.rank': '排名', 'outright.col.rank': '排名',
'outright.col.team_zh': '队伍(中文)', 'outright.col.team_zh': '队伍(中文)',
'outright.col.team_en': '队伍(英文)', 'outright.col.team_en': '队伍(英文)',
'outright.col.code': '代码', 'outright.col.code': '代码',
'outright.col.odds': '夺冠赔率', 'outright.col.odds': '夺冠赔率',
'outright.btn.save_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 强夺冠赔率', 'msg.outright_canonical_applied': '已按基准表写入 48 强夺冠赔率',
'outright.team_count': '已配置 {n} / {total} 队', '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_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.load_matches_failed': '加载赛事失败',
'msg.cashback_issued': '返水已发放', 'msg.cashback_issued': '返水已发放',
'msg.freeze_confirm_title': '{action}账号', 'msg.freeze_confirm_title': '{action}账号',
@@ -520,18 +553,51 @@ export const adminPagesEn: Record<string, string> = {
'msg.outright_odds_saved': 'Outright odds saved', 'msg.outright_odds_saved': 'Outright odds saved',
'msg.load_failed': 'Load failed', 'msg.load_failed': 'Load failed',
'page.outright.title': 'World Cup 48 — Winner', 'page.outrights.title': 'Outrights',
'page.outright.desc': 'Fixed 48 teams; adjust winner odds (player Outright tab)', 'page.outrights.desc': 'Create and edit any winner market; WC 2026 has one-click baseline import',
'outright.col.rank': 'Rank', 'outright.col.rank': 'Rank',
'outright.col.team_zh': 'Team (ZH)', 'outright.col.team_zh': 'Team (ZH)',
'outright.col.team_en': 'Team (EN)', 'outright.col.team_en': 'Team (EN)',
'outright.col.code': 'Code', 'outright.col.code': 'Code',
'outright.col.odds': 'Winner odds', 'outright.col.odds': 'Winner odds',
'outright.btn.save_odds': 'Save all 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', 'msg.outright_canonical_applied': '48-team winner odds applied from baseline',
'outright.team_count': '{n} / {total} teams', '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_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.load_matches_failed': 'Failed to load matches',
'msg.cashback_issued': 'Cashback issued', 'msg.cashback_issued': 'Cashback issued',
'msg.freeze_confirm_title': '{action} account', 'msg.freeze_confirm_title': '{action} account',

View File

@@ -15,7 +15,7 @@ const adminMenus = computed(() => [
{ path: '/users', label: t('nav.users') }, { path: '/users', label: t('nav.users') },
{ path: '/agents', label: t('nav.agents') }, { path: '/agents', label: t('nav.agents') },
{ path: '/matches', label: t('nav.matches') }, { 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: '/bets', label: t('nav.bets') },
{ path: '/cashback', label: t('nav.cashback') }, { path: '/cashback', label: t('nav.cashback') },
{ path: '/audit', label: t('nav.audit') }, { path: '/audit', label: t('nav.audit') },
@@ -30,9 +30,15 @@ const agentMenus = computed(() => [
const menus = computed(() => (auth.isAdmin.value ? adminMenus.value : agentMenus.value)); const menus = computed(() => (auth.isAdmin.value ? adminMenus.value : agentMenus.value));
const currentLabel = computed(() => const currentLabel = computed(() => {
menus.value.find(m => m.path === route.path)?.label ?? '' 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(() => const userInitial = computed(() =>
(auth.user?.username ?? '').charAt(0).toUpperCase() (auth.user?.username ?? '').charAt(0).toUpperCase()
@@ -55,7 +61,12 @@ function logout() {
<nav class="nav"> <nav class="nav">
<RouterLink <RouterLink
v-for="m in menus" :key="m.path" :to="m.path" 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 }} {{ m.label }}
</RouterLink> </RouterLink>

View File

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

View File

@@ -27,10 +27,18 @@ const router = createRouter({
meta: { adminOnly: true }, meta: { adminOnly: true },
}, },
{ {
path: 'world-cup-outright', path: 'outrights',
component: () => import('../views/WorldCupOutright.vue'), name: 'admin-outrights',
component: () => import('../views/outrights/OutrightList.vue'),
meta: { adminOnly: true }, 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', path: 'bets',
component: () => import('../views/Bets.vue'), component: () => import('../views/Bets.vue'),

View File

@@ -224,11 +224,7 @@ function creditTypeLabel(type: string) {
<template> <template>
<div class="admin-list-page"> <div class="admin-list-page">
<div class="page-header"> <div class="page-toolbar">
<div>
<h2 class="page-title">{{ t('page.agents.title') }}</h2>
<span class="page-desc">{{ t('page.agents.desc') }}</span>
</div>
<el-button type="primary" @click="openCreate">{{ t('agent.create_btn') }}</el-button> <el-button type="primary" @click="openCreate">{{ t('agent.create_btn') }}</el-button>
</div> </div>
@@ -489,14 +485,6 @@ function creditTypeLabel(type: string) {
</template> </template>
<style scoped> <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; } .filter-card { border-radius: 12px; }
.data-card { border-radius: 12px; } .data-card { border-radius: 12px; }
.field-hint { font-size: 12px; color: #888; margin-top: 4px; } .field-hint { font-size: 12px; color: #888; margin-top: 4px; }

View File

@@ -69,11 +69,6 @@ function formatTime(v: string) {
<template> <template>
<div class="admin-list-page"> <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-card class="filter-card" shadow="never">
<el-form inline> <el-form inline>
<el-form-item :label="t('common.module')"> <el-form-item :label="t('common.module')">
@@ -123,9 +118,6 @@ function formatTime(v: string) {
</template> </template>
<style scoped> <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; } .filter-card { border-radius: 12px; }
.data-card { border-radius: 12px; } .data-card { border-radius: 12px; }
</style> </style>

View File

@@ -105,11 +105,6 @@ async function openDetail(row: BetListRow) {
<template> <template>
<div class="admin-list-page bets-page"> <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-card class="filter-card" shadow="never">
<el-form inline class="bets-filter-form"> <el-form inline class="bets-filter-form">
<el-form-item :label="t('common.keyword')"> <el-form-item :label="t('common.keyword')">
@@ -297,9 +292,6 @@ async function openDetail(row: BetListRow) {
</template> </template>
<style scoped> <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; } .filter-card { margin-bottom: 16px; border-radius: 12px; }
.data-card { border-radius: 12px; } .data-card { border-radius: 12px; }

View File

@@ -30,11 +30,6 @@ async function confirm() {
<template> <template>
<div class="page-scroll"> <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"> <el-card class="tool-card" shadow="never">
<div class="filter-row"> <div class="filter-row">
<el-form inline> <el-form inline>
@@ -73,9 +68,6 @@ async function confirm() {
</template> </template>
<style scoped> <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; } .tool-card { margin-bottom: 16px; border-radius: 12px; }
.preview-card { border-radius: 12px; } .preview-card { border-radius: 12px; }
.preview-title { font-size: 15px; font-weight: 600; color: #e0e0e0; margin-bottom: 16px; } .preview-title { font-size: 15px; font-weight: 600; color: #e0e0e0; margin-bottom: 16px; }

View File

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

View File

@@ -272,16 +272,10 @@ function canDelete(row: unknown) {
<template> <template>
<div class="admin-list-page"> <div class="admin-list-page">
<div class="page-header"> <div class="page-toolbar">
<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 @click="openImport">{{ t('common.import') }}</el-button>
<el-button type="primary" @click="openCreate">{{ t('match.create_btn') }}</el-button> <el-button type="primary" @click="openCreate">{{ t('match.create_btn') }}</el-button>
</div> </div>
</div>
<el-card class="filter-card" shadow="never"> <el-card class="filter-card" shadow="never">
<el-form inline> <el-form inline>
@@ -468,28 +462,6 @@ function canDelete(row: unknown) {
</template> </template>
<style scoped> <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; } .filter-card { border-radius: 12px; }
.data-card { .data-card {
border-radius: 12px; border-radius: 12px;

View File

@@ -242,11 +242,7 @@ function statusLabel(s: string) {
<template> <template>
<div class="admin-list-page users-page"> <div class="admin-list-page users-page">
<div class="page-header"> <div class="page-toolbar">
<div>
<h2 class="page-title">{{ t('page.users.title') }}</h2>
<span class="page-desc">{{ t('page.users.desc') }}</span>
</div>
<el-button type="primary" @click="openCreate">{{ t('user.create_btn') }}</el-button> <el-button type="primary" @click="openCreate">{{ t('user.create_btn') }}</el-button>
</div> </div>
@@ -592,14 +588,6 @@ function statusLabel(s: string) {
</template> </template>
<style scoped> <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; } .filter-card { margin-bottom: 16px; border-radius: 12px; }
.data-card { border-radius: 12px; } .data-card { border-radius: 12px; }
.pager { margin-top: 16px; display: flex; justify-content: flex-end; } .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: { resolve: {
// 避免 src 内遗留的 .js 抢先于 .ts/.vue 被解析(曾导致 i18n 文案缺失) // 避免 src 内遗留的 .js 抢先于 .ts/.vue 被解析(曾导致 i18n 文案缺失)
extensions: ['.mjs', '.ts', '.mts', '.tsx', '.vue', '.js', '.jsx', '.json'], 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'), publicDir: resolve(__dirname, '../../packages/shared/public'),
server: { server: {

View File

@@ -260,6 +260,52 @@ class BatchOutrightOddsDto {
updates!: OutrightOddsUpdateItemDto[]; 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 { class CashbackPreviewDto {
@IsString() @IsString()
periodStart!: string; periodStart!: string;
@@ -648,24 +694,111 @@ export class AdminController {
return jsonResponse(selection); return jsonResponse(selection);
} }
@Get('outrights/wc2026') @Get('outrights')
async getWc2026Outright() { async listOutrights() {
const data = await this.outright.getWc2026ForAdmin(); const data = await this.outright.listForAdmin();
return jsonResponse(data); 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') @Put('outrights/wc2026/odds')
async updateWc2026OutrightOdds( async updateWc2026OutrightOddsLegacy(
@CurrentUser('id') operatorId: bigint, @CurrentUser('id') operatorId: bigint,
@Body() dto: BatchOutrightOddsDto, @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); return jsonResponse(data);
} }
@Post('outrights/wc2026/apply-canonical') @Put('outrights/:matchId')
async applyWc2026Canonical() { async updateOutright(
const data = await this.outright.applyWc2026Canonical(); @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); return jsonResponse(data);
} }

View File

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

View File

@@ -14,12 +14,9 @@ import {
toVenueJson, toVenueJson,
translationsFromZhiboNames, translationsFromZhiboNames,
} from './zhibo-match.mapper'; } from './zhibo-match.mapper';
import { WC2026_OUTRIGHT_TEAMS } from './wc2026-outright-teams'; import { syncWc2026OutrightMarket } from './wc2026-outright.sync';
const WC2026_OUTRIGHT_RANK = new Map( const OUTRIGHT_PLACEHOLDER_CODE = 'OUT';
WC2026_OUTRIGHT_TEAMS.map((t) => [t.code, t.rank]),
);
const WC2026_OUTRIGHT_CODES = new Set(WC2026_OUTRIGHT_TEAMS.map((t) => t.code));
@Injectable() @Injectable()
export class MatchesService { export class MatchesService {
@@ -570,8 +567,19 @@ export class MatchesService {
} }
async listOutrights(locale = 'en-US') { async listOutrights(locale = 'en-US') {
try {
await syncWc2026OutrightMarket(this.prisma, { forceCanonical: false });
} catch {
/* 联赛未 seed 时忽略,仍返回已有数据 */
}
const matches = await this.prisma.match.findMany({ const matches = await this.prisma.match.findMany({
where: { status: 'PUBLISHED', isOutright: true, sportType: 'FOOTBALL' }, where: {
status: 'PUBLISHED',
isOutright: true,
sportType: 'FOOTBALL',
deletedAt: null,
},
include: { include: {
markets: { markets: {
where: { marketType: 'OUTRIGHT_WINNER', status: 'OPEN' }, where: { marketType: 'OUTRIGHT_WINNER', status: 'OPEN' },
@@ -592,27 +600,28 @@ export class MatchesService {
const market = match.markets[0]; const market = match.markets[0];
if (!market) continue; if (!market) continue;
const selections = ( const selections = await Promise.all(
await Promise.all(
market.selections market.selections
.filter((sel) => WC2026_OUTRIGHT_CODES.has(sel.selectionCode)) .filter((sel) => sel.selectionCode !== OUTRIGHT_PLACEHOLDER_CODE)
.map(async (sel) => { .map(async (sel) => {
const teamCode = sel.selectionCode.replace(/^TEAM_/, ''); const team = await this.prisma.team.findUnique({
const team = await this.prisma.team.findUnique({ where: { code: teamCode } }); where: { code: sel.selectionCode },
});
const teamName = team const teamName = team
? await this.getTranslation('TEAM', team.id, locale) ? await this.getTranslation('TEAM', team.id, locale)
: sel.selectionName; : sel.selectionName;
return { return {
id: sel.id.toString(), id: sel.id.toString(),
teamCode, teamCode: sel.selectionCode,
teamName, teamName,
rank: WC2026_OUTRIGHT_RANK.get(teamCode) ?? 999, rank: sel.sortOrder + 1,
odds: sel.odds.toString(), odds: sel.odds.toString(),
oddsVersion: sel.oddsVersion.toString(), oddsVersion: sel.oddsVersion.toString(),
}; };
}), }),
) );
).sort((a, b) => a.rank - b.rank);
if (selections.length === 0) continue;
results.push({ results.push({
id: match.id.toString(), 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 { PrismaService } from '../../shared/prisma/prisma.service';
import { MarketsService } from '../odds/markets.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'; 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() @Injectable()
export class OutrightService { export class OutrightService {
@@ -13,35 +18,114 @@ export class OutrightService {
private markets: MarketsService, private markets: MarketsService,
) {} ) {}
async getWc2026ForAdmin() { async listForAdmin() {
const { matchId, marketId } = await syncWc2026OutrightMarket(this.prisma, { const matches = await this.prisma.match.findMany({
forceCanonical: false, 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 } }); return Promise.all(
const [leagueZh, leagueEn, market] = await 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: 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,
};
}),
);
}
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, 'zh-CN'),
this.getTranslation('LEAGUE', match.leagueId, 'en-US'), this.getTranslation('LEAGUE', match.leagueId, 'en-US'),
this.prisma.market.findUniqueOrThrow({ ]);
where: { id: marketId },
const fullMarket = await this.prisma.market.findUniqueOrThrow({
where: { id: market.id },
include: { include: {
selections: { orderBy: { sortOrder: 'asc' } }, 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( const selections = await Promise.all(
canonicalSelections.map(async (sel) => { fullMarket.selections
const meta = teamByCode.get(sel.selectionCode); .filter((s) => s.selectionCode !== PLACEHOLDER_TEAM_CODE)
const team = await this.prisma.team.findUnique({ where: { code: sel.selectionCode } }); .map(async (sel, index) => {
const team = await this.prisma.team.findUnique({
where: { code: sel.selectionCode },
});
const teamZh = team const teamZh = team
? await this.getTranslation('TEAM', team.id, 'zh-CN') ? await this.getTranslation('TEAM', team.id, 'zh-CN')
: sel.selectionName; : sel.selectionName;
@@ -51,9 +135,9 @@ export class OutrightService {
return { return {
id: sel.id.toString(), id: sel.id.toString(),
teamCode: sel.selectionCode, teamCode: sel.selectionCode,
rank: meta?.rank ?? sel.sortOrder + 1, rank: sel.sortOrder + 1 || index + 1,
teamZh, teamZh: teamZh || sel.selectionName,
teamEn, teamEn: teamEn || sel.selectionName,
odds: sel.odds.toString(), odds: sel.odds.toString(),
oddsVersion: sel.oddsVersion.toString(), oddsVersion: sel.oddsVersion.toString(),
status: sel.status, status: sel.status,
@@ -61,38 +145,391 @@ export class OutrightService {
}), }),
); );
selections.sort((a, b) => a.rank - b.rank); const visibility = this.playerVisibility(
match.status,
fullMarket,
fullMarket.selections.filter(
(s) => s.selectionCode !== PLACEHOLDER_TEAM_CODE,
),
);
return { return {
matchId: matchId.toString(), id: match.id.toString(),
marketId: marketId.toString(), leagueId: match.leagueId.toString(),
matchStatus: match.status, leagueCode: league.code,
marketStatus: market.status,
leagueZh, leagueZh,
leagueEn, 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, selections,
expectedCount: WC2026_OUTRIGHT_TEAMS.length,
}; };
} }
async applyWc2026Canonical() { async createForAdmin(data: {
await syncWc2026OutrightMarket(this.prisma, { forceCanonical: true }); leagueId: bigint;
return this.getWc2026ForAdmin(); 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 }>, updates: Array<{ selectionId: string; odds: number }>,
operatorId: bigint, 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 })), updates.map((u) => ({ selectionId: BigInt(u.selectionId), odds: u.odds })),
operatorId, operatorId,
); );
return results.map((r) => ({ return this.getForAdmin(matchId);
id: r.id.toString(), }
odds: r.odds.toString(),
oddsVersion: r.oddsVersion.toString(), 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( 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"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, ref, watch } from 'vue';
import { teamFlagUrl } from '../../utils/teamFlag'; import { teamFlagUrl } from '../../utils/teamFlag';
const props = defineProps<{ const props = defineProps<{
@@ -11,6 +11,18 @@ const props = defineProps<{
const emit = defineEmits<{ pick: [] }>(); const emit = defineEmits<{ pick: [] }>();
const flag = computed(() => teamFlagUrl(props.teamCode, props.teamName)); 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) { function formatOdds(odds: string) {
const n = parseFloat(odds); const n = parseFloat(odds);
@@ -20,7 +32,15 @@ function formatOdds(odds: string) {
<template> <template>
<button type="button" class="option-card" @click="emit('pick')"> <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="name">{{ teamName }}</span>
<span class="odds">[ {{ formatOdds(odds) }} ]</span> <span class="odds">[ {{ formatOdds(odds) }} ]</span>
</button> </button>
@@ -53,6 +73,16 @@ function formatOdds(odds: string) {
border-radius: 2px; border-radius: 2px;
} }
.flag-placeholder {
width: 28px;
height: 19px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
opacity: 0.55;
}
.name { .name {
font-size: 11px; font-size: 11px;
font-weight: 800; font-weight: 800;

View File

@@ -1,56 +1,118 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import api from '../../api'; 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 OutrightBetModal, { type OutrightPick } from './OutrightBetModal.vue';
import emptyMatchesImg from '../../assets/images/empty-matches.svg'; import emptyMatchesImg from '../../assets/images/empty-matches.svg';
import saishiImg from '../../assets/images/saishi.png'; import { useOnLocaleChange } from '../../composables/useOnLocaleChange';
interface OutrightSelection {
id: string;
teamCode: string;
teamName: string;
odds: string;
oddsVersion: string;
}
interface OutrightEvent {
id: string;
leagueId: string;
leagueName: string;
title: string;
selections: OutrightSelection[];
}
const { t } = useI18n(); const { t } = useI18n();
const INITIAL_BATCH = 20;
const LOAD_MORE_STEP = 28;
const loading = ref(true); const loading = ref(true);
const loadError = ref('');
const loadingMoreId = ref<string | null>(null);
const events = ref<OutrightEvent[]>([]); const events = ref<OutrightEvent[]>([]);
const expanded = ref<Set<string>>(new Set()); const expanded = ref<Set<string>>(new Set());
const visibleLimits = ref<Record<string, number>>({});
const modalOpen = ref(false); const modalOpen = ref(false);
const activePick = ref<OutrightPick | null>(null); 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() { async function load() {
loading.value = true; loading.value = true;
loadError.value = '';
try { try {
const { data } = await api.get('/player/outrights'); const { data } = await api.get('/player/outrights');
events.value = data.data ?? []; const list = (data?.data ?? []) as OutrightEvent[];
if (events.value.length && expanded.value.size === 0) { events.value = list.filter((e) => e.selections?.length > 0);
expanded.value = new Set([events.value[0].id]); 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 { } finally {
loading.value = false; loading.value = false;
} }
} }
onMounted(load); useOnLocaleChange(load);
function toggle(id: string) { function toggle(id: string) {
const next = new Set(expanded.value); const next = new Set(expanded.value);
if (next.has(id)) next.delete(id); if (next.has(id)) next.delete(id);
else next.add(id); else next.add(id);
expanded.value = next; 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) { function openBet(event: OutrightEvent, sel: OutrightSelection) {
@@ -75,32 +137,31 @@ function closeModal() {
<div class="outright-panel"> <div class="outright-panel">
<div v-if="loading" class="state">{{ t('bet.loading') }}</div> <div v-if="loading" class="state">{{ t('bet.loading') }}</div>
<div v-else-if="events.length" class="event-list"> <template v-else-if="events.length">
<section v-for="event in events" :key="event.id" class="event-block"> <p v-if="eventCount > 1" class="panel-summary">
<button type="button" class="event-head" @click="toggle(event.id)"> {{ t('bet.outright_events_summary', { events: eventCount, teams: totalSelections }) }}
<span class="toggle-icon"> </p>
<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>
<div v-if="expanded.has(event.id)" class="options-grid"> <div class="event-list">
<OutrightOptionCard <OutrightEventSection
v-for="sel in event.selections" v-for="event in events"
:key="sel.id" :key="event.id"
:team-code="sel.teamCode" :event="event"
:team-name="sel.teamName" :expanded="expanded.has(event.id)"
:odds="sel.odds" :visible-limit="visibleLimits[event.id] ?? INITIAL_BATCH"
@pick="openBet(event, sel)" :loading-more="loadingMoreId === event.id"
@toggle="toggle(event.id)"
@load-more="loadMore(event)"
@pick="openBet(event, $event)"
/> />
</div> </div>
</section> </template>
</div>
<div v-else class="empty"> <div v-else class="empty">
<img :src="emptyMatchesImg" alt="" class="empty-icon" /> <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> </div>
<OutrightBetModal :open="modalOpen" :pick="activePick" @close="closeModal" /> <OutrightBetModal :open="modalOpen" :pick="activePick" @close="closeModal" />
@@ -112,64 +173,17 @@ function closeModal() {
padding: 4px 12px 0; padding: 4px 12px 0;
} }
.event-block { .panel-summary {
margin-bottom: 10px; margin: 0 0 12px;
padding: 0 4px;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
line-height: 1.4;
} }
.event-head { .event-list {
width: 100%; padding-bottom: 8px;
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;
} }
.state, .state,
@@ -185,4 +199,11 @@ function closeModal() {
height: 96px; height: 96px;
margin-bottom: 14px; margin-bottom: 14px;
} }
.empty-hint {
margin-top: 8px;
font-size: 12px;
font-weight: 500;
opacity: 0.85;
}
</style> </style>

View File

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

View File

@@ -27,7 +27,9 @@ export function useAppLocale() {
} }
async function setLocale(code: string) { async function setLocale(code: string) {
applyLocale(code); if (!(SUPPORTED_LOCALES as readonly string[]).includes(code)) return;
if (locale.value === code) return;
if (auth.token) { if (auth.token) {
try { try {
await api.post('/player/language', { locale: code }); await api.post('/player/language', { locale: code });
@@ -39,6 +41,7 @@ export function useAppLocale() {
/* 离线或 token 过期时仍保留本地语言 */ /* 离线或 token 过期时仍保留本地语言 */
} }
} }
applyLocale(code);
} }
function initFromUser(userLocale?: string | null) { 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 { useAppLocale } from '../composables/useAppLocale';
import AnnouncementMarquee from '../components/AnnouncementMarquee.vue'; import AnnouncementMarquee from '../components/AnnouncementMarquee.vue';
import BottomNavIcon from '../components/BottomNavIcon.vue'; import BottomNavIcon from '../components/BottomNavIcon.vue';
import { computed, onMounted } from 'vue'; import { computed, onMounted, watch } from 'vue';
import { useAnnouncements } from '../composables/useAnnouncements'; import { useAnnouncements } from '../composables/useAnnouncements';
const { t } = useI18n(); const { t, locale } = useI18n();
const auth = useAuthStore(); const auth = useAuthStore();
const { initFromUser } = useAppLocale(); const { initFromUser } = useAppLocale();
const route = useRoute(); const route = useRoute();
@@ -26,6 +26,10 @@ onMounted(() => {
loadAnnouncements(); loadAnnouncements();
if (auth.user?.locale) initFromUser(auth.user.locale); if (auth.user?.locale) initFromUser(auth.user.locale);
}); });
watch(locale, (next, prev) => {
if (prev && next !== prev) void loadAnnouncements();
});
</script> </script>
<template> <template>

View File

@@ -79,6 +79,13 @@ const i18n = createI18n({
stake_max: '全部', stake_max: '全部',
placing: '提交中…', placing: '提交中…',
no_outright: '暂无冠军盘口', 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: '取消', cancel: '取消',
parlay_title: '串关投注', parlay_title: '串关投注',
parlay_guide_title: '串关怎么投?', parlay_guide_title: '串关怎么投?',
@@ -263,6 +270,13 @@ const i18n = createI18n({
stake_max: 'Max', stake_max: 'Max',
placing: 'Placing…', placing: 'Placing…',
no_outright: 'No outright markets', 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', cancel: 'Cancel',
parlay_title: 'Parlay', parlay_title: 'Parlay',
parlay_guide_title: 'How to parlay', parlay_guide_title: 'How to parlay',
@@ -453,6 +467,13 @@ const i18n = createI18n({
stake_max: 'Maks', stake_max: 'Maks',
placing: 'Memproses…', placing: 'Memproses…',
no_outright: 'Tiada pasaran juara', 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', cancel: 'Batal',
parlay_title: 'Pertaruhan Berganda', parlay_title: 'Pertaruhan Berganda',
parlay_guide_title: 'Cara parlay', 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> = { const CODE_TO_ISO: Record<string, string> = {
MEX: 'mx', // 世界杯 2026 48 强
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',
FRA: 'fr', FRA: 'fr',
ESP: 'es', ESP: 'es',
ENG: 'gb', ENG: 'gb-eng',
GER: 'de', BRA: 'br',
ARG: 'ar',
POR: 'pt', POR: 'pt',
GER: 'de',
NED: 'nl', NED: 'nl',
NOR: 'no', NOR: 'no',
BEL: 'be', BEL: 'be',
COL: 'co', COL: 'co',
JPN: 'jp', JPN: 'jp',
URU: 'uy',
USA: 'us',
MAR: 'ma', MAR: 'ma',
CRO: 'hr', CRO: 'hr',
MEX: 'mx',
SUI: 'ch',
TUR: 'tr',
SEN: 'sn', 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> = { const NAME_TO_ISO: Record<string, string> = {
Mexico: 'mx', France: 'fr',
'South Africa': 'za', Spain: 'es',
'United States': 'us', England: 'gb-eng',
USA: 'us',
Canada: 'ca',
Brazil: 'br', 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', Switzerland: 'ch',
Scotland: 'gb-sct',
Turkey: 'tr', Turkey: 'tr',
Senegal: 'sn',
'South Korea': 'kr', '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', Paraguay: 'py',
西: 'mx', Haiti: 'ht',
: 'us', Qatar: 'qa',
: 'ca', 'Cape Verde': 'cv',
: 'gb', Curacao: 'cw',
西: 'gb', Scotland: 'gb-sct',
西: 'mx',
: 'za',
: 'cz',
: 'kr',
: 'ba',
: 'py',
: 'ch',
西: 'br',
: 'gb-sct',
: 'tr',
: 'fr',
: 'ar',
: 'fr', : 'fr',
西: 'es', 西: 'es',
: 'gb', : 'gb-eng',
: 'de', 西: 'br',
: 'ar',
: 'pt', : 'pt',
: 'de',
: 'nl', : 'nl',
: 'no', : 'no',
: 'be', : 'be',
: 'co', : 'co',
: 'jp', : 'jp',
: 'uy',
: 'us',
: 'ma', : 'ma',
: 'hr', : 'hr',
西: 'mx',
: 'ch',
: 'tr',
: 'sn', : '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 { export function teamFlagUrl(code?: string, name?: string): string {

View File

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

View File

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

View File

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

View File

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

View File

@@ -307,7 +307,7 @@ pnpm db:migrate
pnpm db:seed pnpm db:seed
``` ```
或在管理后台 **世界杯夺冠 → 应用表格基准数据** 或在管理后台 **优胜冠军 → 导入世界杯 48 强**
### 8. Windows 下 Prisma / ts-node 权限问题 ### 8. Windows 下 Prisma / ts-node 权限问题

View File

@@ -93,9 +93,9 @@
| 数据来源 | `apps/api/src/domains/catalog/wc2026-outright-teams.ts` | | 数据来源 | `apps/api/src/domains/catalog/wc2026-outright-teams.ts` |
| 队伍数 | **48** 支,含排名与中英文名 | | 队伍数 | **48** 支,含排名与中英文名 |
| 默认赔率 | 如法国 4.95、英格兰 6.3、苏格兰 2500 等 | | 默认赔率 | 如法国 4.95、英格兰 6.3、苏格兰 2500 等 |
| 玩家端 | 足球页**「优胜冠军」** | | 玩家端 | 投注页 `/bet`**「优胜冠军」**:按后台已发布赛事折叠展示,多赛事可分别展开;选项过多时点击 **加载更多**(须玩家账号) |
| 管理端 | 菜单 **世界杯夺冠」** → 可改赔率 | | 管理端 | 菜单 **优胜冠军」** → 选择赛事 → 可增删队伍、改赔率;世界杯可 **导入 48 强基准** |
| 恢复基准 | 点击 **应用表格基准数据」** 与代码表对齐 | | 恢复基准 | **优胜冠军**点击 **导入世界杯 48 强」** 与代码表对齐 |
`pnpm db:seed` 会以 `forceCanonical: true` 同步 48 强;已有选项的赔率仅在「应用基准」或重新 seed 时按文件覆盖。 `pnpm db:seed` 会以 `forceCanonical: true` 同步 48 强;已有选项的赔率仅在「应用基准」或重新 seed 时按文件覆盖。
@@ -149,7 +149,19 @@
--- ---
## 八、如何查看 / 重置 ## 八、玩家端看不到「优胜冠军」时
1. 使用 **玩家账号** 登录(`player1` / `Player@123`),代理或管理员账号无法访问 `GET /player/outrights`
2. 确认 API 已启动:`pnpm dev:api`
3. 确认已种子:`pnpm db:seed`(或管理端 **优胜冠军 → 导入世界杯 48 强**)。
4. 进入 **投注** 页,点顶部 **优胜冠军**(不是「赛事」标签)。
5. 浏览器 F12 → Network`/api/player/outrights` 是否 200 且 `data` 数组非空。
**后台有数据、玩家端没有**:多为玩家接口原先只查 `OPEN` 盘口,与后台查询不一致;现已改为与管理端「优胜冠军」页共用 `OutrightService` 数据源。修改后需 **重启 API**
---
## 九、如何查看 / 重置
```bash ```bash
# 可视化浏览所有表 # 可视化浏览所有表
@@ -159,7 +171,7 @@ pnpm db:studio
pnpm db:seed pnpm db:seed
``` ```
仅想恢复 **48 强夺冠赔率** 为代码基准:管理后台 → **世界杯夺冠****应用表格基准数据** 仅想恢复 **48 强夺冠赔率** 为代码基准:管理后台 → **优胜冠军****导入世界杯 48 强**
清空数据库后重来: 清空数据库后重来:
@@ -172,7 +184,7 @@ pnpm db:seed
--- ---
## 相关文档 ## 十、相关文档
- [项目启动指南.md](./项目启动指南.md) — 安装、启动、排错 - [项目启动指南.md](./项目启动指南.md) — 安装、启动、排错
- [README.md](../README.md) — 项目概览 - [README.md](../README.md) — 项目概览