feat(admin,player,api): 优胜冠军通用管理与界面精简
管理端新增冠军盘列表/编辑、展开懒加载与 ECharts 修复;各列表页去掉重复标题。玩家端支持多赛事冠军盘、分批加载与语言切换刷新。API 扩展 outright CRUD 与列表性能优化。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
]);
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -272,15 +272,9 @@ 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>
|
<el-button @click="openImport">{{ t('common.import') }}</el-button>
|
||||||
<h2 class="page-title">{{ t('page.matches.title') }}</h2>
|
<el-button type="primary" @click="openCreate">{{ t('match.create_btn') }}</el-button>
|
||||||
<span class="page-desc">{{ t('page.matches.desc') }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="header-actions">
|
|
||||||
<el-button @click="openImport">{{ t('common.import') }}</el-button>
|
|
||||||
<el-button type="primary" @click="openCreate">{{ t('match.create_btn') }}</el-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-card class="filter-card" shadow="never">
|
<el-card class="filter-card" shadow="never">
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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>
|
|
||||||
433
apps/admin/src/views/outrights/OutrightEventEditor.vue
Normal file
433
apps/admin/src/views/outrights/OutrightEventEditor.vue
Normal 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>
|
||||||
384
apps/admin/src/views/outrights/OutrightList.vue
Normal file
384
apps/admin/src/views/outrights/OutrightList.vue
Normal 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>
|
||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) => sel.selectionCode !== OUTRIGHT_PLACEHOLDER_CODE)
|
||||||
.filter((sel) => WC2026_OUTRIGHT_CODES.has(sel.selectionCode))
|
.map(async (sel) => {
|
||||||
.map(async (sel) => {
|
const team = await this.prisma.team.findUnique({
|
||||||
const teamCode = sel.selectionCode.replace(/^TEAM_/, '');
|
where: { code: sel.selectionCode },
|
||||||
const team = await this.prisma.team.findUnique({ where: { code: teamCode } });
|
});
|
||||||
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(),
|
||||||
|
|||||||
@@ -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,86 +18,518 @@ 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) => {
|
||||||
this.getTranslation('LEAGUE', match.leagueId, 'zh-CN'),
|
const league = m.league;
|
||||||
this.getTranslation('LEAGUE', match.leagueId, 'en-US'),
|
const leagueCode = league.code;
|
||||||
this.prisma.market.findUniqueOrThrow({
|
const [leagueZh, leagueEn] = await Promise.all([
|
||||||
where: { id: marketId },
|
this.getTranslation('LEAGUE', league.id, 'zh-CN'),
|
||||||
include: {
|
this.getTranslation('LEAGUE', league.id, 'en-US'),
|
||||||
selections: { orderBy: { sortOrder: 'asc' } },
|
]);
|
||||||
},
|
const market = m.markets[0];
|
||||||
}),
|
const openCount = market?.selections.length ?? 0;
|
||||||
]);
|
const selectionCount = market?._count.selections ?? 0;
|
||||||
|
const visibility = this.playerVisibilityByCounts(
|
||||||
const teamByCode = new Map(
|
m.status,
|
||||||
WC2026_OUTRIGHT_TEAMS.map((t) => [t.code, t]),
|
market,
|
||||||
);
|
openCount,
|
||||||
|
);
|
||||||
const canonicalSelections = market.selections.filter((sel) =>
|
|
||||||
CANONICAL_CODES.has(sel.selectionCode),
|
|
||||||
);
|
|
||||||
|
|
||||||
const selections = await Promise.all(
|
|
||||||
canonicalSelections.map(async (sel) => {
|
|
||||||
const meta = teamByCode.get(sel.selectionCode);
|
|
||||||
const team = await this.prisma.team.findUnique({ where: { code: sel.selectionCode } });
|
|
||||||
const teamZh = team
|
|
||||||
? await this.getTranslation('TEAM', team.id, 'zh-CN')
|
|
||||||
: sel.selectionName;
|
|
||||||
const teamEn = team
|
|
||||||
? await this.getTranslation('TEAM', team.id, 'en-US')
|
|
||||||
: sel.selectionName;
|
|
||||||
return {
|
return {
|
||||||
id: sel.id.toString(),
|
id: m.id.toString(),
|
||||||
teamCode: sel.selectionCode,
|
leagueId: league.id.toString(),
|
||||||
rank: meta?.rank ?? sel.sortOrder + 1,
|
leagueCode,
|
||||||
teamZh,
|
leagueZh,
|
||||||
teamEn,
|
leagueEn,
|
||||||
odds: sel.odds.toString(),
|
matchName: m.matchName ?? '',
|
||||||
oddsVersion: sel.oddsVersion.toString(),
|
status: m.status,
|
||||||
status: sel.status,
|
selectionCount,
|
||||||
|
canImportCanonical: leagueCode === WC2026_LEAGUE_CODE,
|
||||||
|
playerVisible: visibility.playerVisible,
|
||||||
|
playerHiddenReason: visibility.playerHiddenReason,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
selections.sort((a, b) => a.rank - b.rank);
|
async listLeagueOptions() {
|
||||||
|
const leagues = await this.prisma.league.findMany({
|
||||||
|
where: { isActive: true, deletedAt: null, sportType: 'FOOTBALL' },
|
||||||
|
orderBy: { displayOrder: 'asc' },
|
||||||
|
});
|
||||||
|
return Promise.all(
|
||||||
|
leagues.map(async (l) => ({
|
||||||
|
id: l.id.toString(),
|
||||||
|
code: l.code,
|
||||||
|
nameZh: await this.getTranslation('LEAGUE', l.id, 'zh-CN'),
|
||||||
|
nameEn: await this.getTranslation('LEAGUE', l.id, 'en-US'),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getForAdmin(matchId: bigint) {
|
||||||
|
const match = await this.getOutrightMatchOrThrow(matchId);
|
||||||
|
const market = await this.ensureOutrightMarket(match.id);
|
||||||
|
const league = await this.prisma.league.findUniqueOrThrow({
|
||||||
|
where: { id: match.leagueId },
|
||||||
|
});
|
||||||
|
|
||||||
|
const [leagueZh, leagueEn] = await Promise.all([
|
||||||
|
this.getTranslation('LEAGUE', match.leagueId, 'zh-CN'),
|
||||||
|
this.getTranslation('LEAGUE', match.leagueId, 'en-US'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const fullMarket = await this.prisma.market.findUniqueOrThrow({
|
||||||
|
where: { id: market.id },
|
||||||
|
include: {
|
||||||
|
selections: { orderBy: { sortOrder: 'asc' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selections = await Promise.all(
|
||||||
|
fullMarket.selections
|
||||||
|
.filter((s) => s.selectionCode !== PLACEHOLDER_TEAM_CODE)
|
||||||
|
.map(async (sel, index) => {
|
||||||
|
const team = await this.prisma.team.findUnique({
|
||||||
|
where: { code: sel.selectionCode },
|
||||||
|
});
|
||||||
|
const teamZh = team
|
||||||
|
? await this.getTranslation('TEAM', team.id, 'zh-CN')
|
||||||
|
: sel.selectionName;
|
||||||
|
const teamEn = team
|
||||||
|
? await this.getTranslation('TEAM', team.id, 'en-US')
|
||||||
|
: sel.selectionName;
|
||||||
|
return {
|
||||||
|
id: sel.id.toString(),
|
||||||
|
teamCode: sel.selectionCode,
|
||||||
|
rank: sel.sortOrder + 1 || index + 1,
|
||||||
|
teamZh: teamZh || sel.selectionName,
|
||||||
|
teamEn: teamEn || sel.selectionName,
|
||||||
|
odds: sel.odds.toString(),
|
||||||
|
oddsVersion: sel.oddsVersion.toString(),
|
||||||
|
status: sel.status,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const visibility = this.playerVisibility(
|
||||||
|
match.status,
|
||||||
|
fullMarket,
|
||||||
|
fullMarket.selections.filter(
|
||||||
|
(s) => s.selectionCode !== PLACEHOLDER_TEAM_CODE,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
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(
|
||||||
|
|||||||
232
apps/player/src/components/outright/OutrightEventSection.vue
Normal file
232
apps/player/src/components/outright/OutrightEventSection.vue
Normal 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>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)"
|
||||||
</div>
|
@load-more="loadMore(event)"
|
||||||
</section>
|
@pick="openBet(event, $event)"
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
15
apps/player/src/composables/useOnLocaleChange.ts
Normal file
15
apps/player/src/composables/useOnLocaleChange.ts
Normal 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -1,82 +1,162 @@
|
|||||||
/** 球队 code / 名称 → ISO 3166-1 alpha-2,用于 flagcdn 国旗图 */
|
/** 球队 code / 名称 → ISO 3166-1(flagcdn.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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -307,7 +307,7 @@ pnpm db:migrate
|
|||||||
pnpm db:seed
|
pnpm db:seed
|
||||||
```
|
```
|
||||||
|
|
||||||
或在管理后台 **世界杯夺冠 → 应用表格基准数据**。
|
或在管理后台 **优胜冠军 → 导入世界杯 48 强**。
|
||||||
|
|
||||||
### 8. Windows 下 Prisma / ts-node 权限问题
|
### 8. Windows 下 Prisma / ts-node 权限问题
|
||||||
|
|
||||||
|
|||||||
@@ -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) — 项目概览
|
||||||
|
|||||||
Reference in New Issue
Block a user