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;
|
||||
overflow: hidden;
|
||||
}
|
||||
.admin-list-page > .page-header,
|
||||
.admin-list-page > .page-toolbar,
|
||||
.admin-list-page > .filter-card,
|
||||
.admin-list-page > .tool-card {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.admin-list-page > .page-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.admin-list-page > .tool-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.admin-list-page > .filter-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.admin-list-page > .page-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.admin-list-page > .data-card {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import VChart from 'vue-echarts';
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import './echarts-setup';
|
||||
import { VChart, type EChartsOption } from './echarts-setup';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -19,7 +17,7 @@ const style = computed(() => ({ height: props.height, width: '100%' }));
|
||||
<template>
|
||||
<div class="chart-panel">
|
||||
<div v-if="title" class="chart-title">{{ title }}</div>
|
||||
<v-chart class="chart-canvas" :option="option" :style="style" autoresize />
|
||||
<VChart class="chart-canvas" :option="option" :style="style" autoresize />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,22 +1,8 @@
|
||||
import { use } from 'echarts/core';
|
||||
import { BarChart, LineChart, PieChart } from 'echarts/charts';
|
||||
import {
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
TitleComponent,
|
||||
GraphicComponent,
|
||||
} from 'echarts/components';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
/**
|
||||
* 管理端图表统一入口:使用完整 ECharts 构建,避免多实例 + 按需注册遗漏
|
||||
*/
|
||||
import * as echarts from 'echarts';
|
||||
import ECharts from 'vue-echarts';
|
||||
|
||||
use([
|
||||
CanvasRenderer,
|
||||
BarChart,
|
||||
LineChart,
|
||||
PieChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
TitleComponent,
|
||||
GraphicComponent,
|
||||
]);
|
||||
export { echarts, ECharts as VChart };
|
||||
export type { EChartsOption } from 'echarts';
|
||||
|
||||
@@ -33,7 +33,7 @@ const zh: Record<string, string> = {
|
||||
'nav.users': '玩家管理',
|
||||
'nav.agents': '代理管理',
|
||||
'nav.matches': '赛事管理',
|
||||
'nav.outright': '世界杯夺冠',
|
||||
'nav.outrights': '优胜冠军',
|
||||
'nav.bets': '注单管理',
|
||||
'nav.cashback': '返水管理',
|
||||
'nav.audit': '操作日志',
|
||||
@@ -51,6 +51,8 @@ const zh: Record<string, string> = {
|
||||
'common.search': '查询',
|
||||
'common.reset': '重置',
|
||||
'common.edit': '编辑',
|
||||
'common.yes': '是',
|
||||
'common.no': '否',
|
||||
'common.delete': '删除',
|
||||
'common.cancel': '取消',
|
||||
'common.confirm': '确定',
|
||||
@@ -182,7 +184,7 @@ const en: Record<string, string> = {
|
||||
'nav.users': 'Players',
|
||||
'nav.agents': 'Agents',
|
||||
'nav.matches': 'Matches',
|
||||
'nav.outright': 'WC Winner',
|
||||
'nav.outrights': 'Outrights',
|
||||
'nav.bets': 'Bets',
|
||||
'nav.cashback': 'Cashback',
|
||||
'nav.audit': 'Audit Log',
|
||||
@@ -200,6 +202,8 @@ const en: Record<string, string> = {
|
||||
'common.search': 'Search',
|
||||
'common.reset': 'Reset',
|
||||
'common.edit': 'Edit',
|
||||
'common.yes': 'Yes',
|
||||
'common.no': 'No',
|
||||
'common.delete': 'Delete',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.confirm': 'OK',
|
||||
@@ -331,7 +335,7 @@ const ms: Record<string, string> = {
|
||||
'nav.users': 'Pemain',
|
||||
'nav.agents': 'Ejen',
|
||||
'nav.matches': 'Perlawanan',
|
||||
'nav.outright': 'Juara Piala Dunia',
|
||||
'nav.outrights': 'Juara',
|
||||
'nav.bets': 'Pertaruhan',
|
||||
'nav.cashback': 'Rebat',
|
||||
'nav.audit': 'Log audit',
|
||||
@@ -349,6 +353,8 @@ const ms: Record<string, string> = {
|
||||
'common.search': 'Cari',
|
||||
'common.reset': 'Set semula',
|
||||
'common.edit': 'Edit',
|
||||
'common.yes': 'Ya',
|
||||
'common.no': 'Tidak',
|
||||
'common.delete': 'Padam',
|
||||
'common.cancel': 'Batal',
|
||||
'common.confirm': 'OK',
|
||||
|
||||
@@ -249,8 +249,8 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'msg.outright_odds_saved': 'Odds juara disimpan',
|
||||
'msg.load_failed': 'Gagal memuatkan',
|
||||
|
||||
'page.outright.title': 'Juara Piala Dunia 48',
|
||||
'page.outright.desc': '48 pasukan tetap; laraskan odds juara',
|
||||
'page.outrights.title': 'Juara',
|
||||
'page.outrights.desc': 'Cipta dan edit pasaran juara; Piala Dunia 2026 boleh import asas',
|
||||
'outright.col.rank': 'Kedudukan',
|
||||
'outright.col.team_zh': 'Pasukan (ZH)',
|
||||
'outright.col.team_en': 'Pasukan (EN)',
|
||||
|
||||
@@ -249,18 +249,51 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'msg.outright_odds_saved': '夺冠赔率已保存',
|
||||
'msg.load_failed': '加载失败',
|
||||
|
||||
'page.outright.title': '世界杯 48 强夺冠',
|
||||
'page.outright.desc': '固定 48 支球队,可调整夺冠赔率(玩家端「优胜冠军」)',
|
||||
'page.outrights.title': '优胜冠军',
|
||||
'page.outrights.desc': '可新建任意联赛冠军盘、编辑队伍与赔率;世界杯 48 强提供一键导入基准数据',
|
||||
'outright.col.rank': '排名',
|
||||
'outright.col.team_zh': '队伍(中文)',
|
||||
'outright.col.team_en': '队伍(英文)',
|
||||
'outright.col.code': '代码',
|
||||
'outright.col.odds': '夺冠赔率',
|
||||
'outright.btn.save_odds': '保存全部赔率',
|
||||
'outright.btn.apply_canonical': '应用表格基准数据',
|
||||
'outright.btn.save_meta': '保存赛事信息',
|
||||
'outright.btn.publish': '发布',
|
||||
'outright.btn.unpublish': '撤回发布',
|
||||
'outright.back_list': '返回列表',
|
||||
'outright.section.edit': '编辑冠军赛事',
|
||||
'outright.col.teams': '队伍数',
|
||||
'outright.col.player_visible': '玩家端',
|
||||
'outright.col.league_en': '联赛(英文)',
|
||||
'outright.expand_no_teams': '暂无队伍,请进入编辑页添加',
|
||||
'outright.btn.add_team': '添加队伍',
|
||||
'outright.btn.create_event': '新建冠军赛事',
|
||||
'outright.btn.import_wc2026': '导入世界杯 48 强',
|
||||
'outright.btn.apply_canonical': '应用世界杯基准表',
|
||||
'outright.field.league': '所属联赛',
|
||||
'outright.section.settings': '赛事设置',
|
||||
'outright.section.teams': '队伍与赔率',
|
||||
'outright.field.title': '赛事标题',
|
||||
'outright.field.title_placeholder': '玩家端展示的冠军赛事名称',
|
||||
'outright.field.title_zh': '标题(中文)',
|
||||
'outright.field.title_en': '标题(英文)',
|
||||
'outright.field.status': '发布状态',
|
||||
'outright.status.draft': '草稿',
|
||||
'outright.status.published': '已发布',
|
||||
'msg.outright_canonical_applied': '已按基准表写入 48 强夺冠赔率',
|
||||
'outright.team_count': '已配置 {n} / {total} 队',
|
||||
'outright.team_count_open': '共 {n} 支队伍',
|
||||
'outright.empty_events': '暂无冠军赛事',
|
||||
'outright.empty_hint': '点击「新建冠军赛事」或「导入世界杯 48 强」开始配置',
|
||||
'outright.err_odds_min': '赔率须大于 1.00',
|
||||
'outright.err_team_code': '请填写队伍代码',
|
||||
'outright.err_league': '请选择联赛',
|
||||
'outright.confirm_remove': '确定移除「{name}」?(关闭该投注项)',
|
||||
'outright.not_on_player': '玩家端不可见',
|
||||
'outright.player_hidden_title': '此赛事尚未在玩家端展示',
|
||||
'outright.hidden_reason.NOT_PUBLISHED': '请将发布状态设为「已发布」并保存赛事信息。',
|
||||
'outright.hidden_reason.NO_SELECTIONS': '请至少添加 1 支开放状态的队伍。',
|
||||
'outright.hidden_reason.MARKET_CLOSED': '冠军盘口未开放,请联系技术检查盘口状态。',
|
||||
'msg.load_matches_failed': '加载赛事失败',
|
||||
'msg.cashback_issued': '返水已发放',
|
||||
'msg.freeze_confirm_title': '{action}账号',
|
||||
@@ -520,18 +553,51 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'msg.outright_odds_saved': 'Outright odds saved',
|
||||
'msg.load_failed': 'Load failed',
|
||||
|
||||
'page.outright.title': 'World Cup 48 — Winner',
|
||||
'page.outright.desc': 'Fixed 48 teams; adjust winner odds (player Outright tab)',
|
||||
'page.outrights.title': 'Outrights',
|
||||
'page.outrights.desc': 'Create and edit any winner market; WC 2026 has one-click baseline import',
|
||||
'outright.col.rank': 'Rank',
|
||||
'outright.col.team_zh': 'Team (ZH)',
|
||||
'outright.col.team_en': 'Team (EN)',
|
||||
'outright.col.code': 'Code',
|
||||
'outright.col.odds': 'Winner odds',
|
||||
'outright.btn.save_odds': 'Save all odds',
|
||||
'outright.btn.apply_canonical': 'Apply baseline table',
|
||||
'outright.btn.save_meta': 'Save event info',
|
||||
'outright.btn.publish': 'Publish',
|
||||
'outright.btn.unpublish': 'Unpublish',
|
||||
'outright.back_list': 'Back to list',
|
||||
'outright.section.edit': 'Edit outright',
|
||||
'outright.col.teams': 'Teams',
|
||||
'outright.col.player_visible': 'Player',
|
||||
'outright.col.league_en': 'League (EN)',
|
||||
'outright.expand_no_teams': 'No teams — open Edit to add',
|
||||
'outright.btn.add_team': 'Add team',
|
||||
'outright.btn.create_event': 'New outright event',
|
||||
'outright.btn.import_wc2026': 'Import WC 2026 (48)',
|
||||
'outright.btn.apply_canonical': 'Apply WC baseline',
|
||||
'outright.field.league': 'League',
|
||||
'outright.section.settings': 'Event settings',
|
||||
'outright.section.teams': 'Teams & odds',
|
||||
'outright.field.title': 'Event title',
|
||||
'outright.field.title_placeholder': 'Title shown on player outright tab',
|
||||
'outright.field.title_zh': 'Title (ZH)',
|
||||
'outright.field.title_en': 'Title (EN)',
|
||||
'outright.field.status': 'Status',
|
||||
'outright.status.draft': 'Draft',
|
||||
'outright.status.published': 'Published',
|
||||
'msg.outright_canonical_applied': '48-team winner odds applied from baseline',
|
||||
'outright.team_count': '{n} / {total} teams',
|
||||
'outright.team_count_open': '{n} teams',
|
||||
'outright.empty_events': 'No outright events',
|
||||
'outright.empty_hint': 'Create an event or import WC 2026 baseline',
|
||||
'outright.err_odds_min': 'Odds must be greater than 1.00',
|
||||
'outright.err_team_code': 'Team code required',
|
||||
'outright.err_league': 'Select a league',
|
||||
'outright.confirm_remove': 'Remove "{name}"? (closes selection)',
|
||||
'outright.not_on_player': 'Hidden on player',
|
||||
'outright.player_hidden_title': 'Not visible on player app yet',
|
||||
'outright.hidden_reason.NOT_PUBLISHED': 'Set status to Published and save event info.',
|
||||
'outright.hidden_reason.NO_SELECTIONS': 'Add at least one open team selection.',
|
||||
'outright.hidden_reason.MARKET_CLOSED': 'Winner market is not open.',
|
||||
'msg.load_matches_failed': 'Failed to load matches',
|
||||
'msg.cashback_issued': 'Cashback issued',
|
||||
'msg.freeze_confirm_title': '{action} account',
|
||||
|
||||
@@ -15,7 +15,7 @@ const adminMenus = computed(() => [
|
||||
{ path: '/users', label: t('nav.users') },
|
||||
{ path: '/agents', label: t('nav.agents') },
|
||||
{ path: '/matches', label: t('nav.matches') },
|
||||
{ path: '/world-cup-outright', label: t('nav.outright') },
|
||||
{ path: '/outrights', label: t('nav.outrights'), matchPrefix: true },
|
||||
{ path: '/bets', label: t('nav.bets') },
|
||||
{ path: '/cashback', label: t('nav.cashback') },
|
||||
{ path: '/audit', label: t('nav.audit') },
|
||||
@@ -30,9 +30,15 @@ const agentMenus = computed(() => [
|
||||
|
||||
const menus = computed(() => (auth.isAdmin.value ? adminMenus.value : agentMenus.value));
|
||||
|
||||
const currentLabel = computed(() =>
|
||||
menus.value.find(m => m.path === route.path)?.label ?? ''
|
||||
);
|
||||
const currentLabel = computed(() => {
|
||||
const hit = menus.value.find((m) => {
|
||||
if ('matchPrefix' in m && m.matchPrefix) {
|
||||
return route.path === m.path || route.path.startsWith(`${m.path}/`);
|
||||
}
|
||||
return route.path === m.path;
|
||||
});
|
||||
return hit?.label ?? '';
|
||||
});
|
||||
|
||||
const userInitial = computed(() =>
|
||||
(auth.user?.username ?? '').charAt(0).toUpperCase()
|
||||
@@ -55,7 +61,12 @@ function logout() {
|
||||
<nav class="nav">
|
||||
<RouterLink
|
||||
v-for="m in menus" :key="m.path" :to="m.path"
|
||||
class="nav-item" :class="{ active: route.path === m.path }"
|
||||
class="nav-item"
|
||||
:class="{
|
||||
active:
|
||||
route.path === m.path ||
|
||||
('matchPrefix' in m && m.matchPrefix && route.path.startsWith(`${m.path}/`)),
|
||||
}"
|
||||
>
|
||||
{{ m.label }}
|
||||
</RouterLink>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import './components/dashboard/echarts-setup';
|
||||
import { createApp } from 'vue';
|
||||
import ElementPlus from 'element-plus';
|
||||
import 'element-plus/dist/index.css';
|
||||
|
||||
@@ -27,10 +27,18 @@ const router = createRouter({
|
||||
meta: { adminOnly: true },
|
||||
},
|
||||
{
|
||||
path: 'world-cup-outright',
|
||||
component: () => import('../views/WorldCupOutright.vue'),
|
||||
path: 'outrights',
|
||||
name: 'admin-outrights',
|
||||
component: () => import('../views/outrights/OutrightList.vue'),
|
||||
meta: { adminOnly: true },
|
||||
},
|
||||
{
|
||||
path: 'outrights/:matchId/edit',
|
||||
name: 'admin-outright-edit',
|
||||
component: () => import('../views/outrights/OutrightEventEditor.vue'),
|
||||
meta: { adminOnly: true },
|
||||
},
|
||||
{ path: 'world-cup-outright', redirect: '/outrights' },
|
||||
{
|
||||
path: 'bets',
|
||||
component: () => import('../views/Bets.vue'),
|
||||
|
||||
@@ -224,11 +224,7 @@ function creditTypeLabel(type: string) {
|
||||
|
||||
<template>
|
||||
<div class="admin-list-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">{{ t('page.agents.title') }}</h2>
|
||||
<span class="page-desc">{{ t('page.agents.desc') }}</span>
|
||||
</div>
|
||||
<div class="page-toolbar">
|
||||
<el-button type="primary" @click="openCreate">{{ t('agent.create_btn') }}</el-button>
|
||||
</div>
|
||||
|
||||
@@ -489,14 +485,6 @@ function creditTypeLabel(type: string) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; margin: 0 0 4px; }
|
||||
.page-desc { font-size: 13px; color: #666; }
|
||||
.filter-card { border-radius: 12px; }
|
||||
.data-card { border-radius: 12px; }
|
||||
.field-hint { font-size: 12px; color: #888; margin-top: 4px; }
|
||||
|
||||
@@ -69,11 +69,6 @@ function formatTime(v: string) {
|
||||
|
||||
<template>
|
||||
<div class="admin-list-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">{{ t('page.audit.title') }}</h2>
|
||||
<span class="page-desc">{{ t('page.audit.desc') }}</span>
|
||||
</div>
|
||||
|
||||
<el-card class="filter-card" shadow="never">
|
||||
<el-form inline>
|
||||
<el-form-item :label="t('common.module')">
|
||||
@@ -123,9 +118,6 @@ function formatTime(v: string) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header { display: flex; align-items: baseline; gap: 12px; }
|
||||
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; }
|
||||
.page-desc { font-size: 13px; color: #3a3a3a; }
|
||||
.filter-card { border-radius: 12px; }
|
||||
.data-card { border-radius: 12px; }
|
||||
</style>
|
||||
|
||||
@@ -105,11 +105,6 @@ async function openDetail(row: BetListRow) {
|
||||
|
||||
<template>
|
||||
<div class="admin-list-page bets-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">{{ t('page.bets.title') }}</h2>
|
||||
<span class="page-desc">{{ t('page.bets.desc') }}</span>
|
||||
</div>
|
||||
|
||||
<el-card class="filter-card" shadow="never">
|
||||
<el-form inline class="bets-filter-form">
|
||||
<el-form-item :label="t('common.keyword')">
|
||||
@@ -297,9 +292,6 @@ async function openDetail(row: BetListRow) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 20px; }
|
||||
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; }
|
||||
.page-desc { font-size: 13px; color: #666; }
|
||||
.filter-card { margin-bottom: 16px; border-radius: 12px; }
|
||||
.data-card { border-radius: 12px; }
|
||||
|
||||
|
||||
@@ -30,11 +30,6 @@ async function confirm() {
|
||||
|
||||
<template>
|
||||
<div class="page-scroll">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">{{ t('page.cashback.title') }}</h2>
|
||||
<span class="page-desc">{{ t('page.cashback.desc') }}</span>
|
||||
</div>
|
||||
|
||||
<el-card class="tool-card" shadow="never">
|
||||
<div class="filter-row">
|
||||
<el-form inline>
|
||||
@@ -73,9 +68,6 @@ async function confirm() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 20px; }
|
||||
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; }
|
||||
.page-desc { font-size: 13px; color: #3a3a3a; }
|
||||
.tool-card { margin-bottom: 16px; border-radius: 12px; }
|
||||
.preview-card { border-radius: 12px; }
|
||||
.preview-title { font-size: 15px; font-weight: 600; color: #e0e0e0; margin-bottom: 16px; }
|
||||
|
||||
@@ -206,23 +206,12 @@ const kpiSecondary = computed(() => {
|
||||
|
||||
<template>
|
||||
<div class="dashboard-page" v-loading="loading">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">{{ t('dash.title') }}</h2>
|
||||
<span class="page-desc">
|
||||
{{ t('dash.desc') }}
|
||||
<template v-if="s?.generatedAt">
|
||||
· {{ t('common.updated_at') }} {{ formatTime(s.generatedAt) }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="s">
|
||||
<el-card class="overview-board" shadow="never">
|
||||
<div class="board-head">
|
||||
<span class="board-title">{{ t('dash.board_title') }}</span>
|
||||
<span class="board-hint">{{ t('dash.board_hint') }}</span>
|
||||
<div v-if="s.generatedAt" class="board-head">
|
||||
<span class="dash-updated">
|
||||
{{ t('common.updated_at') }} {{ formatTime(s.generatedAt) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="kpi-grid kpi-primary">
|
||||
@@ -259,9 +248,6 @@ const kpiSecondary = computed(() => {
|
||||
|
||||
<style scoped>
|
||||
.dashboard-page { padding-bottom: 32px; }
|
||||
.page-header { margin-bottom: 20px; }
|
||||
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; margin: 0 0 4px; }
|
||||
.page-desc { font-size: 13px; color: #666; }
|
||||
|
||||
.overview-board {
|
||||
border-radius: 14px;
|
||||
@@ -274,18 +260,13 @@ const kpiSecondary = computed(() => {
|
||||
}
|
||||
.board-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
justify-content: flex-end;
|
||||
margin: -4px 0 14px;
|
||||
}
|
||||
.board-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #e8e8e8;
|
||||
}
|
||||
.board-hint {
|
||||
font-size: 12px;
|
||||
.dash-updated {
|
||||
font-size: 11px;
|
||||
color: #555;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.kpi-grid {
|
||||
|
||||
@@ -272,15 +272,9 @@ function canDelete(row: unknown) {
|
||||
|
||||
<template>
|
||||
<div class="admin-list-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">{{ t('page.matches.title') }}</h2>
|
||||
<span class="page-desc">{{ t('page.matches.desc') }}</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button @click="openImport">{{ t('common.import') }}</el-button>
|
||||
<el-button type="primary" @click="openCreate">{{ t('match.create_btn') }}</el-button>
|
||||
</div>
|
||||
<div class="page-toolbar">
|
||||
<el-button @click="openImport">{{ t('common.import') }}</el-button>
|
||||
<el-button type="primary" @click="openCreate">{{ t('match.create_btn') }}</el-button>
|
||||
</div>
|
||||
|
||||
<el-card class="filter-card" shadow="never">
|
||||
@@ -468,28 +462,6 @@ function canDelete(row: unknown) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #e0e0e0;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.page-desc {
|
||||
font-size: 13px;
|
||||
color: #3a3a3a;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.filter-card { border-radius: 12px; }
|
||||
.data-card {
|
||||
border-radius: 12px;
|
||||
|
||||
@@ -242,11 +242,7 @@ function statusLabel(s: string) {
|
||||
|
||||
<template>
|
||||
<div class="admin-list-page users-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">{{ t('page.users.title') }}</h2>
|
||||
<span class="page-desc">{{ t('page.users.desc') }}</span>
|
||||
</div>
|
||||
<div class="page-toolbar">
|
||||
<el-button type="primary" @click="openCreate">{{ t('user.create_btn') }}</el-button>
|
||||
</div>
|
||||
|
||||
@@ -592,14 +588,6 @@ function statusLabel(s: string) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; margin: 0 0 4px; }
|
||||
.page-desc { font-size: 13px; color: #666; }
|
||||
.filter-card { margin-bottom: 16px; border-radius: 12px; }
|
||||
.data-card { border-radius: 12px; }
|
||||
.pager { margin-top: 16px; display: flex; justify-content: flex-end; }
|
||||
|
||||
@@ -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: {
|
||||
// 避免 src 内遗留的 .js 抢先于 .ts/.vue 被解析(曾导致 i18n 文案缺失)
|
||||
extensions: ['.mjs', '.ts', '.mts', '.tsx', '.vue', '.js', '.jsx', '.json'],
|
||||
dedupe: ['echarts', 'vue-echarts', 'vue'],
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['echarts', 'vue-echarts'],
|
||||
},
|
||||
publicDir: resolve(__dirname, '../../packages/shared/public'),
|
||||
server: {
|
||||
|
||||
Reference in New Issue
Block a user