This commit is contained in:
wchino
2026-06-13 17:38:25 +08:00
parent e7e938f261
commit 7b33d9f9fa
190 changed files with 23222 additions and 4336 deletions

View File

@@ -7,6 +7,10 @@ import api from '../../api';
import MatchArchiveDialog from '../../components/MatchArchiveDialog.vue';
import { ensureLeagueExpanded } from '../../utils/matchesListState';
import { formatAmount } from '../../utils/format-amount';
import {
formatPlatformMatchDateTime,
platformPickerDateTimeToIso,
} from '@thebet365/shared';
const props = defineProps<{
leagueId: string;
filterStatus: string;
@@ -180,7 +184,7 @@ function matchId(row: unknown) {
return String(rowOf(row).id ?? '');
}
function matchTime(row: unknown) {
return new Date(String(rowOf(row).startTime)).toLocaleString();
return formatPlatformMatchDateTime(String(rowOf(row).startTime), locale.value);
}
function betCount(row: unknown) {
return Number(rowOf(row).betCount ?? 0);
@@ -260,6 +264,13 @@ async function promptReopenKickoff(): Promise<string | null> {
},
t('match.reopen_kickoff_hint'),
),
h(
'p',
{
style: 'margin: 0 0 12px; font-size: 12px; color: var(--el-text-color-secondary)',
},
t('match.timezone.platform_hint'),
),
h(ElDatePicker, {
modelValue: kickoff.value,
'onUpdate:modelValue': (v: string) => {
@@ -275,7 +286,8 @@ async function promptReopenKickoff(): Promise<string | null> {
cancelButtonText: t('common.cancel'),
beforeClose: (action, _instance, done) => {
if (action === 'confirm') {
if (!kickoff.value || new Date(kickoff.value) <= new Date()) {
const iso = kickoff.value ? platformPickerDateTimeToIso(kickoff.value) : '';
if (!iso || Number.isNaN(new Date(iso).getTime()) || new Date(iso) <= new Date()) {
ElMessage.warning(t('match.reopen_kickoff_invalid'));
return;
}
@@ -283,7 +295,7 @@ async function promptReopenKickoff(): Promise<string | null> {
done();
},
});
return kickoff.value || null;
return kickoff.value ? platformPickerDateTimeToIso(kickoff.value) : null;
} catch {
return null;
}
@@ -316,6 +328,11 @@ defineExpose({ reload: load });
<template>
<div class="league-matches-panel">
<div class="nested-panel-toolbar">
<el-button type="primary" size="small" @click.stop="emit('add-match')">
{{ t('match.create_fixture_btn') }}
</el-button>
</div>
<el-table v-loading="loading" :data="matches" stripe row-key="id" class="nested-match-table">
<el-table-column prop="id" label="ID" width="64" />
<el-table-column :label="t('match.col.matchup')" min-width="180">
@@ -328,7 +345,7 @@ defineExpose({ reload: load });
<el-tag :type="matchStatusType(row)" size="small">{{ matchStatusLabel(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('match.col.kickoff')" min-width="150">
<el-table-column :label="t('match.col.kickoff')" min-width="190">
<template #default="{ row }">{{ matchTime(row) }}</template>
</el-table-column>
<el-table-column :label="t('match.col.bet_count')" width="72" align="center">
@@ -347,18 +364,15 @@ defineExpose({ reload: load });
<span v-else class="bet-stat-zero">0</span>
</template>
</el-table-column>
<el-table-column width="460" align="center">
<el-table-column width="256" align="center">
<template #header>
<div class="actions-col-header">
<span class="actions-col-header__label">{{ t('common.actions') }}</span>
<el-button type="primary" size="small" @click.stop="emit('add-match')">
{{ t('match.create_fixture_btn') }}
</el-button>
</div>
</template>
<template #default="{ row }">
<div class="action-btns">
<div class="action-group">
<div class="action-row action-row--primary">
<el-button
size="small"
type="primary"
@@ -371,13 +385,12 @@ defineExpose({ reload: load });
size="small"
type="primary"
plain
class="action-btn--markets"
:disabled="!canManage(row)"
@click="openMarkets(matchId(row))"
>
{{ t('match.btn.markets') }}
</el-button>
</div>
<div class="action-group">
<el-button
v-if="canPublishRow(row)"
size="small"
@@ -394,6 +407,8 @@ defineExpose({ reload: load });
>
{{ t('match.btn.unpublish') }}
</el-button>
</div>
<div class="action-row action-row--workflow">
<el-button
size="small"
type="warning"
@@ -419,15 +434,15 @@ defineExpose({ reload: load });
>
{{ settleButtonLabel(row) }}
</el-button>
<el-button
size="small"
type="danger"
plain
@click="confirmDelete(row)"
>
{{ t('common.delete') }}
</el-button>
</div>
<el-button
size="small"
type="danger"
plain
@click="confirmDelete(row)"
>
{{ t('common.delete') }}
</el-button>
</div>
</template>
</el-table-column>
@@ -456,8 +471,17 @@ defineExpose({ reload: load });
<style scoped>
.league-matches-panel {
width: min(100%, calc(100vw - 272px));
max-width: calc(100vw - 272px);
padding: 10px 12px 12px;
background: #0a0a0a;
overflow: hidden;
background: #fbfaf7;
}
.nested-panel-toolbar {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-bottom: 8px;
}
.nested-pager {
display: flex;
@@ -477,8 +501,8 @@ defineExpose({ reload: load });
.actions-col-header__label {
font-size: 12px;
font-weight: 600;
color: #aaa;
font-weight: 700;
color: var(--text-muted);
line-height: 1;
}
@@ -502,43 +526,70 @@ defineExpose({ reload: load });
}
.empty-hint {
font-size: 12px;
color: #666;
color: var(--text-muted);
margin: 8px 0 0;
}
.matchup-link {
color: var(--green-text);
color: var(--primary-link);
}
.bet-stat-active {
color: var(--green-text);
color: var(--success-text);
font-weight: 600;
}
.bet-stat-zero {
color: #555;
color: #aaa49a;
}
.action-btns {
display: flex;
flex-wrap: wrap;
display: grid;
grid-template-rows: repeat(2, 26px);
align-items: center;
gap: 6px 8px;
justify-content: center;
gap: 6px;
min-width: 0;
}
.action-group {
display: inline-flex;
flex-wrap: wrap;
gap: 4px;
.action-row {
display: inline-grid;
grid-auto-flow: column;
grid-auto-columns: max-content;
align-items: center;
justify-content: center;
gap: 5px;
min-width: 0;
padding: 2px 4px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05);
background: #ffffff;
border: 1px solid var(--border-soft);
}
.action-row--primary {
background: #fbfaf7;
}
.action-row--workflow {
background: #ffffff;
}
.action-btns :deep(.el-button) {
margin: 0 !important;
min-width: 52px;
padding: 4px 10px !important;
min-width: 48px;
height: 22px !important;
min-height: 22px !important;
padding: 0 9px !important;
font-size: 12px !important;
line-height: 1 !important;
border-radius: 6px;
}
.action-btns :deep(.action-btn--markets:not(.is-disabled):not(:disabled)) {
background: var(--info-bg) !important;
border-color: var(--info-border) !important;
color: var(--info-text) !important;
}
.action-btns :deep(.action-btn--markets:not(.is-disabled):not(:disabled):hover),
.action-btns :deep(.action-btn--markets:not(.is-disabled):not(:disabled):focus) {
background: #d5ecfb !important;
border-color: #9fd0ed !important;
color: #155b86 !important;
}
.action-btns :deep(.el-button:not(.is-disabled):not(:disabled)) {
cursor: pointer;
}
@@ -547,4 +598,21 @@ defineExpose({ reload: load });
.action-btns :deep(.el-button:disabled) {
cursor: not-allowed;
}
@media (max-width: 760px) {
.league-matches-panel {
width: calc(100vw - 32px);
max-width: calc(100vw - 32px);
overflow-x: auto;
}
.nested-panel-toolbar,
.nested-pager {
justify-content: flex-start;
}
.action-btns {
justify-content: flex-start;
}
}
</style>

View File

@@ -901,7 +901,8 @@ watch(
display: flex;
flex-direction: column;
padding: 12px 16px 16px;
border-bottom: 1px solid #1a1a1a;
border-bottom: 1px solid var(--border-soft);
background: #fbfaf7;
}
.outright-odds-panel__head {
display: flex;
@@ -1027,8 +1028,8 @@ watch(
min-width: 0;
flex: 1;
padding: 8px 10px 8px 8px;
background: #111;
border: 1px solid #262626;
background: #ffffff;
border: 1px solid var(--border);
border-radius: 8px;
transition:
border-color 0.15s ease,
@@ -1036,7 +1037,8 @@ watch(
}
.team-row-wrap:hover .team-row {
border-color: #3a3a3a;
border-color: #d5cfc3;
background: var(--accent-hover);
}
.team-row-wrap--batch-selected .team-row {
@@ -1160,12 +1162,13 @@ watch(
text-align: right;
}
.team-row__odds {
width: 72px;
.team-row__odds,
.team-row__odds.el-input-number {
width: 86px;
}
.team-row__odds :deep(.el-input-number) {
width: 72px;
.team-row__odds :deep(.el-input__wrapper) {
padding-right: 28px !important;
}
.add-teams-dialog__toolbar {
@@ -1306,4 +1309,93 @@ watch(
letter-spacing: 0.05em;
color: #555;
}
.outright-odds-panel__hint,
.outright-odds-panel__batch-count,
.outright-odds-panel__sort-label,
.outright-odds-panel__empty,
.team-row__rank,
.team-row__meta,
.team-row__odds-label,
.add-teams-dialog__count,
.add-teams-dialog__odds,
.add-teams-dialog__empty,
.add-teams-dialog__custom-hint,
.add-team-pick__code {
color: var(--text-muted);
}
.outright-odds-panel__workflow-hint {
color: var(--warning-text);
}
.outright-odds-panel__batch,
.team-row,
.add-team-pick {
border-color: var(--border);
background: #ffffff;
}
.team-row-wrap:hover .team-row,
.add-team-pick:hover {
border-color: #d5cfc3;
background: var(--accent-hover);
}
.team-row-wrap--batch-selected .team-row,
.add-team-pick--selected {
border-color: var(--primary);
background: var(--accent-subtle);
}
.team-list-scroll {
scrollbar-color: #d5cfc3 transparent;
}
.team-list-scroll::-webkit-scrollbar-thumb {
background: #d5cfc3;
}
.team-row__trash {
color: var(--text-muted);
}
.team-row__trash:hover {
background: var(--danger-bg);
color: var(--danger-text);
}
.team-row__flag {
box-shadow: none;
}
.team-row__name,
.add-team-pick__name {
color: var(--text);
}
.add-teams-dialog__actions {
border-bottom-color: var(--border-soft);
}
.add-teams-dialog__custom :deep(.el-form-item__label) {
color: var(--text-muted);
}
@media (max-width: 960px) {
.team-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
.team-list {
grid-template-columns: 1fr;
}
.outright-odds-panel__batch,
.outright-odds-panel__sort {
align-items: stretch;
}
}
</style>

View File

@@ -203,8 +203,8 @@ function onOutrightArchived() {
margin-bottom: 10px;
padding: 10px 12px;
border-radius: 8px;
background: rgba(212, 175, 55, 0.04);
border: 1px solid rgba(212, 175, 55, 0.14);
background: var(--warning-bg);
border: 1px solid var(--warning-border);
}
.panel-head {
display: flex;
@@ -222,11 +222,11 @@ function onOutrightArchived() {
.panel-title {
font-size: 13px;
font-weight: 700;
color: var(--green-text);
color: var(--warning-text);
}
.panel-hint {
font-size: 11px;
color: #666;
color: var(--text-muted);
line-height: 1.4;
}
.panel-actions {
@@ -241,18 +241,18 @@ function onOutrightArchived() {
.meta-line {
margin: 0 0 4px;
font-size: 12px;
color: #aaa;
color: var(--text);
}
.meta-warn {
margin: 0 0 8px;
font-size: 12px;
color: #e6a23c;
color: var(--warning-text);
line-height: 1.45;
}
.meta-empty {
margin: 4px 0 0;
font-size: 12px;
color: #666;
color: var(--text-muted);
}
.preview-table {
margin-top: 6px;

View File

@@ -194,9 +194,9 @@ async function saveMeta() {
</el-row>
</div>
<div class="form-section">
<div class="form-section form-section--schedule">
<div class="section-label">{{ t('matchEditor.group.schedule') }}</div>
<el-row :gutter="12">
<el-row :gutter="12" class="schedule-fields">
<el-col :xs="24" :sm="12">
<el-form-item :label="t('match.field.kickoff')" required>
<el-date-picker
@@ -207,6 +207,7 @@ async function saveMeta() {
:placeholder="t('matchEditor.ph.kickoff')"
style="width: 100%"
/>
<p class="field-hint schedule-timezone-hint">{{ t('match.timezone.platform_hint') }}</p>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
@@ -234,11 +235,6 @@ async function saveMeta() {
<el-switch v-model="form.isHot" size="small" />
</el-form-item>
</el-col>
<el-col :xs="12" :sm="6">
<el-form-item :label="t('matchEditor.field.correct_score_enabled')">
<el-switch v-model="form.correctScoreEnabled" size="small" />
</el-form-item>
</el-col>
</el-row>
</div>
</el-form>
@@ -258,13 +254,15 @@ async function saveMeta() {
flex-direction: column;
gap: 12px;
padding-bottom: 24px;
color: var(--text);
}
.panel {
background: #111;
border: 1px solid #252525;
background: #ffffff;
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 14px;
box-shadow: var(--shadow);
}
.panel-head {
@@ -274,14 +272,14 @@ async function saveMeta() {
gap: 10px;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid #222;
border-bottom: 1px solid var(--border-soft);
}
.panel-title {
font-size: 13px;
font-weight: 700;
color: #ccc;
letter-spacing: 0.04em;
color: var(--text);
letter-spacing: 0;
}
.panel-foot {
@@ -289,7 +287,7 @@ async function saveMeta() {
justify-content: flex-end;
margin-top: 8px;
padding-top: 12px;
border-top: 1px solid #222;
border-top: 1px solid var(--border-soft);
}
.form-section {
@@ -303,9 +301,9 @@ async function saveMeta() {
.section-label {
font-size: 11px;
font-weight: 700;
color: var(--green-text);
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--success-text);
letter-spacing: 0;
text-transform: none;
margin-bottom: 6px;
}
@@ -313,6 +311,44 @@ async function saveMeta() {
margin-bottom: 8px;
}
.form-section--schedule {
padding-top: 2px;
}
.schedule-fields {
row-gap: 8px;
}
.schedule-fields :deep(.el-form-item) {
align-items: center;
min-width: 0;
}
.schedule-fields :deep(.el-form-item__label) {
flex: 0 0 auto;
width: auto !important;
min-width: 64px;
padding-right: 10px;
white-space: nowrap;
word-break: keep-all;
line-height: 32px;
}
.schedule-fields :deep(.el-form-item__content) {
flex: 1;
min-width: 0;
}
.schedule-fields :deep(.el-date-editor),
.schedule-fields :deep(.el-input),
.schedule-fields :deep(.el-input-number) {
width: 100%;
}
.schedule-fields :deep(.el-switch) {
flex-shrink: 0;
}
.logo-inline {
display: flex;
align-items: center;
@@ -339,7 +375,7 @@ async function saveMeta() {
.meta-form :deep(.el-input__inner),
.meta-form :deep(.el-input-number .el-input__inner) {
color: #fff !important;
color: var(--text) !important;
}
.field-hint {
@@ -349,6 +385,10 @@ async function saveMeta() {
line-height: 1.4;
}
.schedule-timezone-hint {
margin: 4px 0 0;
}
.league-readonly-grid {
display: flex;
align-items: flex-start;
@@ -360,7 +400,7 @@ async function saveMeta() {
height: 40px;
object-fit: contain;
border-radius: 6px;
background: #1a1a1a;
background: #f4f0e8;
}
.league-readonly-names {
@@ -374,7 +414,7 @@ async function saveMeta() {
display: flex;
gap: 8px;
font-size: 13px;
color: #ddd;
color: var(--text);
}
.league-readonly-lang {
@@ -382,4 +422,58 @@ async function saveMeta() {
color: #8e8e93;
font-size: 12px;
}
.panel-head,
.panel-foot {
border-color: var(--border-soft);
}
.panel-title,
.preview-title,
.preview-bar-title,
.league-readonly-line {
color: var(--text);
}
.section-label {
color: var(--success-text);
letter-spacing: 0;
}
.logo-inline-label,
.meta-form :deep(.el-form-item__label),
.field-hint,
.league-readonly-lang {
color: var(--text-muted) !important;
}
.meta-form :deep(.el-input__inner),
.meta-form :deep(.el-input-number .el-input__inner) {
color: var(--text) !important;
}
.league-readonly-logo img {
background: #f4f0e8;
}
@media (max-width: 760px) {
.panel-head,
.panel-foot,
.league-readonly-grid {
align-items: flex-start;
flex-direction: column;
}
.schedule-fields :deep(.el-form-item) {
align-items: stretch;
flex-direction: column;
}
.schedule-fields :deep(.el-form-item__label) {
width: auto !important;
min-width: 0;
padding-right: 0;
line-height: 1.4;
}
}
</style>

View File

@@ -17,6 +17,12 @@ const loading = ref(false);
const status = ref('DRAFT');
const matchLabel = ref('');
function matchup(home: string, away: string) {
const h = home.trim();
const a = away.trim();
return h && a ? `${h} vs ${a}` : '';
}
async function load() {
if (!matchId.value) return;
loading.value = true;
@@ -29,11 +35,15 @@ async function load() {
return;
}
status.value = detail.status;
const home = detail.homeTeamZh || detail.homeTeamEn || detail.homeTeamCode || '';
const away = detail.awayTeamZh || detail.awayTeamEn || detail.awayTeamCode || '';
matchLabel.value =
detail.matchName?.trim() ||
(home && away ? `${home} vs ${away}` : `#${matchId.value}`);
const zh = matchup(
detail.homeTeamZh || detail.homeTeamCode || detail.homeTeamEn || '',
detail.awayTeamZh || detail.awayTeamCode || detail.awayTeamEn || '',
);
const en = matchup(
detail.homeTeamEn || detail.homeTeamCode || detail.homeTeamZh || '',
detail.awayTeamEn || detail.awayTeamCode || detail.awayTeamZh || '',
);
matchLabel.value = zh && en && zh !== en ? `${zh} / ${en}` : zh || en || `#${matchId.value}`;
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
@@ -46,7 +56,7 @@ watch(matchId, load, { immediate: true });
</script>
<template>
<div v-loading="loading" class="match-markets-page page-scroll">
<div v-loading="loading" class="match-markets-page">
<AdminSubNav
:title="t('matchEditor.section_markets')"
:subtitle="matchLabel"
@@ -64,13 +74,21 @@ watch(matchId, load, { immediate: true });
<style scoped>
.match-markets-page {
padding: 0 4px 24px;
height: 100%;
min-height: 0;
padding: 0 4px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel {
background: #111;
border: 1px solid #2a2a2a;
border-radius: 12px;
flex: 1;
min-height: 0;
background: #ffffff;
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: var(--shadow);
overflow: hidden;
}
</style>

View File

@@ -1,81 +1,249 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { computed, ref, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../../composables/useAdminLocale';
import api from '../../api';
import type { AdminMatchDetail } from '../match-form';
import { defaultSelectionName } from '../../utils/selectionDefaults';
import { adminSelectionLabel } from '../../utils/adminSelectionLabel';
const props = defineProps<{
matchId: string;
}>();
const { t } = useAdminLocale();
const { t, locale } = useAdminLocale();
const LOCALES = [
{ code: 'zh-CN', label: '中文' },
{ code: 'en-US', label: 'English' },
{ code: 'ms-MY', label: 'Bahasa Melayu' },
] as const;
const UI_TEXT: Record<string, Record<string, string>> = {
add: { 'zh-CN': '添加', 'en-US': 'Add', 'ms-MY': 'Tambah' },
addMarket: { 'zh-CN': '增加盘口', 'en-US': 'Add market', 'ms-MY': 'Tambah pasaran' },
addScore: { 'zh-CN': '新增比分', 'en-US': 'Add score', 'ms-MY': 'Tambah skor' },
cancel: { 'zh-CN': '取消', 'en-US': 'Cancel', 'ms-MY': 'Batal' },
close: { 'zh-CN': '关闭', 'en-US': 'Close', 'ms-MY': 'Tutup' },
copyCreated: { 'zh-CN': '已复制盘口线', 'en-US': 'Market line duplicated', 'ms-MY': 'Garisan pasaran disalin' },
copyMarket: { 'zh-CN': '复制盘口', 'en-US': 'Duplicate market', 'ms-MY': 'Salin pasaran' },
copyMarketCreated: { 'zh-CN': '已复制盘口', 'en-US': 'Market duplicated', 'ms-MY': 'Pasaran disalin' },
copyMarketShort: { 'zh-CN': '复制盘口', 'en-US': 'Duplicate', 'ms-MY': 'Salin pasaran' },
copyLine: { 'zh-CN': '复制盘口线', 'en-US': 'Duplicate line', 'ms-MY': 'Salin garisan' },
copyLineShort: { 'zh-CN': '复制线', 'en-US': 'Copy', 'ms-MY': 'Salin' },
defaultTemplate: { 'zh-CN': '默认', 'en-US': 'Default', 'ms-MY': 'Lalai' },
fillScoreRange: { 'zh-CN': '补齐 0-4 比分', 'en-US': 'Fill 0-4 scores', 'ms-MY': 'Lengkapkan skor 0-4' },
hidden: { 'zh-CN': '玩家端隐藏', 'en-US': 'Hidden on player', 'ms-MY': 'Disembunyikan pemain' },
lineValue: { 'zh-CN': '盘口线', 'en-US': 'Line', 'ms-MY': 'Garisan' },
loadTemplate: { 'zh-CN': '载入模板', 'en-US': 'Load template', 'ms-MY': 'Muat templat' },
marketDetails: { 'zh-CN': '赛事详情', 'en-US': 'Match details', 'ms-MY': 'Butiran perlawanan' },
marketName: { 'zh-CN': '盘口名称', 'en-US': 'Market name', 'ms-MY': 'Nama pasaran' },
marketToAdd: { 'zh-CN': '选择要添加的盘口', 'en-US': 'Choose a market to add', 'ms-MY': 'Pilih pasaran untuk ditambah' },
marketUnavailable: { 'zh-CN': '暂不开放玩家端', 'en-US': 'Not available on player', 'ms-MY': 'Belum tersedia untuk pemain' },
matchMarkets: { 'zh-CN': '赛事盘口', 'en-US': 'Match markets', 'ms-MY': 'Pasaran perlawanan' },
moveDown: { 'zh-CN': '下移', 'en-US': 'Move down', 'ms-MY': 'Turun' },
moveUp: { 'zh-CN': '上移', 'en-US': 'Move up', 'ms-MY': 'Naik' },
odds: { 'zh-CN': '赔率', 'en-US': 'Odds', 'ms-MY': 'Odds' },
parlay: { 'zh-CN': '串关', 'en-US': 'Parlay', 'ms-MY': 'Parlay' },
playerVisible: { 'zh-CN': '玩家端显示', 'en-US': 'Show on player', 'ms-MY': 'Papar pemain' },
promoLabel: { 'zh-CN': '促销标签', 'en-US': 'Promo label', 'ms-MY': 'Label promosi' },
saveAsTemplate: { 'zh-CN': '保存为模板', 'en-US': 'Save as template', 'ms-MY': 'Simpan templat' },
saveTemplateTitle: { 'zh-CN': '保存为盘口模板', 'en-US': 'Save as market template', 'ms-MY': 'Simpan sebagai templat pasaran' },
selectMarket: { 'zh-CN': '从盘口池选择', 'en-US': 'Select from market pool', 'ms-MY': 'Pilih daripada kumpulan pasaran' },
selectTemplate: { 'zh-CN': '选择模板', 'en-US': 'Select template', 'ms-MY': 'Pilih templat' },
single: { 'zh-CN': '单关', 'en-US': 'Single', 'ms-MY': 'Tunggal' },
status: { 'zh-CN': '状态', 'en-US': 'Status', 'ms-MY': 'Status' },
statusClosed: { 'zh-CN': '已关闭', 'en-US': 'Closed', 'ms-MY': 'Ditutup' },
statusOpen: { 'zh-CN': '可售', 'en-US': 'Open', 'ms-MY': 'Dibuka' },
statusSuspended: { 'zh-CN': '暂停', 'en-US': 'Suspended', 'ms-MY': 'Digantung' },
templateLoaded: { 'zh-CN': '模板已载入', 'en-US': 'Template loaded', 'ms-MY': 'Templat dimuatkan' },
templateNameInput: { 'zh-CN': '请输入模板名称', 'en-US': 'Enter template name', 'ms-MY': 'Masukkan nama templat' },
templateSaved: { 'zh-CN': '模板已保存', 'en-US': 'Template saved', 'ms-MY': 'Templat disimpan' },
unsaved: { 'zh-CN': '未保存', 'en-US': 'Unsaved', 'ms-MY': 'Belum disimpan' },
};
type LocalizedText = Record<string, string>;
interface MarketDefinition {
marketType: string;
marketKey: string;
period: string;
sortOrder: number;
defaultLineValue: number | null;
allowSingle: boolean;
allowParlay: boolean;
showOnPlayer: boolean;
settlementSupported: boolean;
usesLineValue: boolean;
renderType: 'standard' | 'correctScore' | 'unsupported';
nameI18n: LocalizedText;
selectionTemplate: Array<{
code: string;
name: string;
nameI18n: LocalizedText;
odds: number;
}>;
}
interface SelectionRow {
id: string;
id?: string;
selectionCode: string;
selectionName: string;
nameI18n: LocalizedText;
odds: number;
status: string;
editOdds: number;
sortOrder: number;
}
interface MarketRow {
id: string;
id?: string;
marketType: string;
marketKey: string;
period: string;
lineValue: number | null;
paramsJson: Record<string, unknown> | null;
status: string;
allowSingle: boolean;
allowParlay: boolean;
showOnPlayer: boolean;
sortOrder: number;
promoLabel: string;
editPromoLabel: string;
editLineValue: number | null;
promoLabelI18n: LocalizedText;
nameI18n: LocalizedText;
selections: SelectionRow[];
}
const DEFAULT_MARKET_TYPES = [
'FT_1X2',
'FT_HANDICAP',
'FT_OVER_UNDER',
'FT_ODD_EVEN',
'HT_1X2',
'HT_HANDICAP',
'HT_OVER_UNDER',
'FT_CORRECT_SCORE',
'HT_CORRECT_SCORE',
'SH_CORRECT_SCORE',
];
interface MarketTemplateSummary {
id: string;
name: string;
nameI18n: LocalizedText;
isDefault: boolean;
status: string;
}
const loading = ref(false);
const savingMarketId = ref<string | null>(null);
const saving = ref(false);
const applying = ref(false);
const definitions = ref<MarketDefinition[]>([]);
const templates = ref<MarketTemplateSummary[]>([]);
const selectedTemplateId = ref('');
const markets = ref<MarketRow[]>([]);
const selectedMarketIndex = ref(0);
const scoreHome = ref(0);
const scoreAway = ref(0);
const addMarketVisible = ref(false);
const selectedDefinitionType = ref('');
const definitionMap = computed(() => new Map(definitions.value.map((d) => [d.marketType, d])));
const definitionOptions = computed(() => [...definitions.value].sort((a, b) => a.sortOrder - b.sortOrder));
const selectedMarket = computed(() => markets.value[selectedMarketIndex.value] ?? markets.value[0] ?? null);
const displayLocales = computed(() => {
const zhLabels: Record<string, string> = {
'zh-CN': '中文',
'en-US': '英语',
'ms-MY': '马来语',
};
return LOCALES.map((loc) => ({
code: loc.code,
label: locale.value === 'zh-CN' ? zhLabels[loc.code] : loc.label,
}));
});
const dirty = ref(false);
function localized(input?: Record<string, string> | null, fallback = ''): LocalizedText {
const out: LocalizedText = {};
for (const loc of LOCALES) {
const value = input?.[loc.code]?.trim();
if (value) out[loc.code] = value;
}
if (!Object.keys(out).length && fallback) out['zh-CN'] = fallback;
return out;
}
function resolveText(map?: LocalizedText | null, fallback = '') {
const order = Array.from(new Set([locale.value, 'en-US', 'zh-CN', 'ms-MY']));
for (const loc of order) {
const value = map?.[loc]?.trim();
if (value) return value;
}
return Object.values(map ?? {}).find((value) => value?.trim()) || fallback;
}
function ui(key: string) {
return resolveText(UI_TEXT[key], key);
}
function statusLabel(status: string) {
if (status === 'OPEN') return ui('statusOpen');
if (status === 'SUSPENDED') return ui('statusSuspended');
if (status === 'CLOSED') return ui('statusClosed');
return status;
}
function ensureLocaleKeys(map: LocalizedText, fallback = '') {
for (const loc of LOCALES) {
if (map[loc.code] == null) map[loc.code] = loc.code === 'zh-CN' ? fallback : '';
}
return map;
}
function mapMarkets(detail: AdminMatchDetail) {
markets.value = (detail.markets ?? []).map((m) => ({
id: m.id,
marketType: m.marketType,
period: m.period,
lineValue: m.lineValue,
status: m.status,
promoLabel: m.promoLabel,
editPromoLabel: m.promoLabel,
editLineValue: m.lineValue,
selections: m.selections.map((s) => ({
id: s.id,
selectionCode: s.selectionCode,
selectionName: s.selectionName,
odds: s.odds,
status: s.status,
editOdds: s.odds,
})),
}));
markets.value = (detail.markets ?? []).map((m, index) => {
const def = definitionMap.value.get(m.marketType);
const nameI18n = ensureLocaleKeys(localized(m.nameI18n, def ? resolveText(def.nameI18n, m.marketType) : m.marketType), m.marketType);
const promoLabelI18n = ensureLocaleKeys(localized(m.promoLabelI18n, m.promoLabel || ''), m.promoLabel || '');
return {
id: m.id,
marketType: m.marketType,
marketKey: m.marketKey || m.marketType,
period: m.period,
lineValue: m.lineValue,
paramsJson: m.paramsJson ?? null,
status: m.status,
allowSingle: true,
allowParlay: true,
showOnPlayer: m.showOnPlayer ?? true,
sortOrder: m.sortOrder ?? index,
promoLabel: m.promoLabel ?? '',
promoLabelI18n,
nameI18n,
selections: m.selections.map((s, sIndex) => ({
id: s.id,
selectionCode: s.selectionCode,
selectionName: s.selectionName,
nameI18n: ensureLocaleKeys(localized(s.nameI18n, s.selectionName), s.selectionName),
odds: s.odds,
status: s.status,
sortOrder: s.sortOrder ?? sIndex,
})),
};
});
selectedMarketIndex.value = Math.min(selectedMarketIndex.value, Math.max(0, markets.value.length - 1));
dirty.value = false;
}
function usesLineValue(marketType: string) {
return definitionMap.value.get(marketType)?.usesLineValue ?? false;
}
function isLineMarket(market: MarketRow) {
return usesLineValue(market.marketType);
}
async function loadDefinitionsAndTemplates() {
const [{ data: defRes }, { data: tplRes }] = await Promise.all([
api.get('/admin/market-definitions'),
api.get('/admin/market-templates'),
]);
definitions.value = defRes.data ?? [];
templates.value = tplRes.data ?? [];
selectedTemplateId.value =
templates.value.find((tpl) => tpl.isDefault)?.id || templates.value[0]?.id || '';
}
async function load() {
if (!props.matchId) return;
loading.value = true;
try {
await loadDefinitionsAndTemplates();
const { data } = await api.get(`/admin/matches/${props.matchId}`);
mapMarkets(data.data as AdminMatchDetail);
} catch (e: unknown) {
@@ -94,387 +262,846 @@ watch(
{ immediate: true },
);
function marketLabel(type: string) {
const key = `matchEditor.market.${type}`;
const v = t(key);
return v !== key ? v : type;
function markDirty() {
dirty.value = true;
}
function selectionCodeLabel(code: string, market?: MarketRow) {
return adminSelectionLabel(t, code, {
lineValue: market?.editLineValue ?? market?.lineValue,
period: market?.period,
function isCorrectScoreMarket(market: MarketRow) {
return ['FT_CORRECT_SCORE', 'HT_CORRECT_SCORE', 'SH_CORRECT_SCORE'].includes(market.marketType);
}
function marketTitle(market: MarketRow) {
const line = isLineMarket(market) && market.lineValue != null ? ` ${market.lineValue}` : '';
return `${resolveText(market.nameI18n, market.marketType)}${line}`;
}
function definitionLabel(def: MarketDefinition) {
const label = resolveText(def.nameI18n, def.marketType);
return def.settlementSupported ? label : `${label}${ui('marketUnavailable')}`;
}
function templateLabel(tpl: MarketTemplateSummary) {
const label = resolveText(tpl.nameI18n, tpl.name || '');
return tpl.isDefault ? `${label}${ui('defaultTemplate')}` : label;
}
function marketMetaText(market: MarketRow) {
const parts = [statusLabel(market.status)];
if (isLineMarket(market) && market.lineValue != null) parts.push(`${ui('lineValue')} ${market.lineValue}`);
if (!market.showOnPlayer) parts.push(ui('hidden'));
return parts.join(' · ');
}
function selectionTitle(selection: SelectionRow) {
const score = /^SCORE_(\d+)_(\d+)$/.exec(selection.selectionCode);
if (score) return `${score[1]}:${score[2]}`;
return resolveText(selection.nameI18n, selection.selectionName || selection.selectionCode);
}
function isScoreSelection(selection: SelectionRow) {
return /^SCORE_\d+_\d+$/.test(selection.selectionCode);
}
function openAddMarketDialog() {
selectedDefinitionType.value = definitionOptions.value[0]?.marketType ?? '';
addMarketVisible.value = true;
}
function addSelectedMarket() {
const def = definitionMap.value.get(selectedDefinitionType.value);
if (!def) return;
addMarket(def);
addMarketVisible.value = false;
selectedDefinitionType.value = '';
}
function addMarket(def: MarketDefinition) {
const row: MarketRow = {
marketType: def.marketType,
marketKey: def.marketKey,
period: def.period,
lineValue: def.defaultLineValue,
paramsJson: null,
status: 'OPEN',
allowSingle: def.allowSingle,
allowParlay: def.allowParlay,
showOnPlayer: def.showOnPlayer && def.settlementSupported,
sortOrder: markets.value.length,
promoLabel: '',
promoLabelI18n: ensureLocaleKeys({}),
nameI18n: ensureLocaleKeys(localized(def.nameI18n, def.marketType), def.marketType),
selections: def.selectionTemplate.map((s, index) => ({
selectionCode: s.code,
selectionName: s.name,
nameI18n: ensureLocaleKeys(localized(s.nameI18n, s.name), s.name),
odds: Number(s.odds),
status: 'OPEN',
sortOrder: index,
})),
};
markets.value.push(row);
selectedMarketIndex.value = markets.value.length - 1;
markDirty();
}
function copyLocalizedName(map: LocalizedText) {
const suffix: Record<string, string> = {
'zh-CN': ' 副本',
'en-US': ' Copy',
'ms-MY': ' Salinan',
};
const next = ensureLocaleKeys(localized(map, resolveText(map)));
for (const loc of LOCALES) {
const base = next[loc.code] || resolveText(next);
next[loc.code] = `${base}${suffix[loc.code]}`;
}
return next;
}
function clonePlain<T>(value: T): T {
return value == null ? value : JSON.parse(JSON.stringify(value));
}
function nextCopiedLineValue(source: MarketRow) {
const used = new Set(
markets.value
.filter((market) => market.marketType === source.marketType && market.lineValue != null)
.map((market) => Number(market.lineValue).toFixed(2)),
);
let candidate = Number(((source.lineValue ?? 0) + 0.25).toFixed(2));
while (used.has(candidate.toFixed(2))) {
candidate = Number((candidate + 0.25).toFixed(2));
}
return candidate;
}
function duplicateMarket(index: number) {
const source = markets.value[index];
if (!source) return;
const shouldCopyLine = isLineMarket(source);
const sourceCopy = clonePlain(source);
const paramsCopy = clonePlain(source.paramsJson);
const copy: MarketRow = {
...sourceCopy,
id: undefined,
lineValue: shouldCopyLine ? nextCopiedLineValue(source) : null,
paramsJson: shouldCopyLine
? paramsCopy
: { ...(paramsCopy ?? {}), copyId: `copy-${Date.now()}-${index}` },
nameI18n: copyLocalizedName(source.nameI18n),
sortOrder: markets.value.length,
selections: source.selections.map((s) => ({ ...clonePlain(s), id: undefined })),
};
markets.value.splice(index + 1, 0, copy);
selectedMarketIndex.value = index + 1;
reorder();
markDirty();
ElMessage.success(ui(shouldCopyLine ? 'copyCreated' : 'copyMarketCreated'));
}
function removeMarket(index: number) {
markets.value.splice(index, 1);
selectedMarketIndex.value = Math.min(selectedMarketIndex.value, Math.max(0, markets.value.length - 1));
reorder();
markDirty();
}
function moveMarket(index: number, direction: -1 | 1) {
const target = index + direction;
if (target < 0 || target >= markets.value.length) return;
const [row] = markets.value.splice(index, 1);
markets.value.splice(target, 0, row);
selectedMarketIndex.value = target;
reorder();
markDirty();
}
function reorder() {
markets.value.forEach((m, index) => {
m.sortOrder = index;
});
}
function hasLine(market: MarketRow) {
return market.lineValue != null || market.editLineValue != null;
function addScoreRange(market: MarketRow) {
const existing = new Set(market.selections.map((s) => s.selectionCode));
for (let home = 0; home <= 4; home += 1) {
for (let away = 0; away <= 4; away += 1) {
const code = `SCORE_${home}_${away}`;
if (existing.has(code)) continue;
market.selections.push({
selectionCode: code,
selectionName: `${home}-${away}`,
nameI18n: ensureLocaleKeys({ 'zh-CN': `${home}-${away}`, 'en-US': `${home}-${away}`, 'ms-MY': `${home}-${away}` }),
odds: 8,
status: 'OPEN',
sortOrder: market.selections.length,
});
}
}
markDirty();
}
const CORRECT_SCORE_TYPES = new Set([
'FT_CORRECT_SCORE',
'HT_CORRECT_SCORE',
'SH_CORRECT_SCORE',
]);
function isMultiRowMarket(market: MarketRow) {
return CORRECT_SCORE_TYPES.has(market.marketType) || market.selections.length > 6;
function addScore(market: MarketRow) {
const home = Math.max(0, Number(scoreHome.value) || 0);
const away = Math.max(0, Number(scoreAway.value) || 0);
const code = `SCORE_${home}_${away}`;
if (market.selections.some((s) => s.selectionCode === code)) return;
market.selections.push({
selectionCode: code,
selectionName: `${home}-${away}`,
nameI18n: ensureLocaleKeys({ 'zh-CN': `${home}-${away}`, 'en-US': `${home}-${away}`, 'ms-MY': `${home}-${away}` }),
odds: 8,
status: 'OPEN',
sortOrder: market.selections.length,
});
markDirty();
}
function normPromo(value: string | null | undefined) {
return (value ?? '').trim() || null;
function removeSelection(market: MarketRow, index: number) {
market.selections.splice(index, 1);
market.selections.forEach((s, i) => {
s.sortOrder = i;
});
markDirty();
}
function normLine(value: number | null | undefined) {
return value == null ? null : Number(value);
function payloadMarkets() {
return markets.value.map((m, index) => ({
id: m.id,
marketType: m.marketType,
marketKey: m.marketKey,
lineKey: null,
period: m.period,
lineValue: usesLineValue(m.marketType) ? m.lineValue : null,
paramsJson: m.paramsJson,
status: m.status,
allowSingle: m.allowSingle,
allowParlay: m.allowParlay,
showOnPlayer: m.showOnPlayer,
sortOrder: index,
promoLabel: resolveText(m.promoLabelI18n, m.promoLabel),
promoLabelI18n: localized(m.promoLabelI18n),
nameI18n: localized(m.nameI18n, m.marketType),
selections: m.selections.map((s, sIndex) => ({
id: s.id,
selectionCode: s.selectionCode,
selectionName: resolveText(s.nameI18n, s.selectionName),
nameI18n: localized(s.nameI18n, s.selectionName),
odds: Number(s.odds),
status: s.status,
sortOrder: sIndex,
})),
}));
}
function isMarketDirty(market: MarketRow) {
if (normPromo(market.editPromoLabel) !== normPromo(market.promoLabel)) return true;
if (normLine(market.editLineValue) !== normLine(market.lineValue)) return true;
return market.selections.some((s) => Number(s.editOdds) !== Number(s.odds));
}
async function generateTemplates() {
loading.value = true;
async function applyTemplate() {
if (!selectedTemplateId.value) return;
applying.value = true;
try {
await api.post(`/admin/matches/${props.matchId}/markets/templates`, {
marketTypes: DEFAULT_MARKET_TYPES,
await api.post(`/admin/matches/${props.matchId}/markets/apply-template`, {
templateId: selectedTemplateId.value,
});
ElMessage.success(t('matchEditor.templates_generated'));
ElMessage.success(ui('templateLoaded'));
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;
applying.value = false;
}
}
async function saveMarket(market: MarketRow) {
const invalid = market.selections.find((s) => !s.editOdds || s.editOdds <= 1);
async function saveAll() {
const invalid = markets.value.flatMap((m) => m.selections).find((s) => !s.odds || s.odds <= 1);
if (invalid) {
ElMessage.warning(t('outright.err_odds_min'));
return;
}
savingMarketId.value = market.id;
saving.value = true;
try {
await api.patch(`/admin/markets/${market.id}`, {
promoLabel: market.editPromoLabel.trim() || null,
lineValue: market.editLineValue,
await api.put(`/admin/matches/${props.matchId}/markets/bulk`, {
markets: payloadMarkets(),
});
await api.put(`/admin/matches/${props.matchId}/odds`, {
updates: market.selections.map((s) => ({
selectionId: s.id,
odds: s.editOdds,
})),
});
for (const s of market.selections) {
const name = defaultSelectionName(s.selectionCode, {
lineValue: market.editLineValue,
period: market.period,
});
if (name !== s.selectionName) {
await api.patch(`/admin/selections/${s.id}`, {
selectionName: name,
});
}
}
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 {
savingMarketId.value = null;
saving.value = false;
}
}
async function saveAsTemplate() {
let value = '';
try {
const result = await ElMessageBox.prompt(ui('templateNameInput'), ui('saveTemplateTitle'), {
confirmButtonText: t('common.save'),
cancelButtonText: t('common.cancel'),
inputValue: `${resolveText({ 'zh-CN': '自定义足球盘口模板', 'en-US': 'Custom football market template', 'ms-MY': 'Templat pasaran bola sepak tersuai' })}`,
});
value = result.value?.trim() || '';
} catch {
return;
}
if (!value) return;
saving.value = true;
try {
await api.post('/admin/market-templates', {
name: value,
nameI18n: { 'zh-CN': value },
items: payloadMarkets(),
});
ElMessage.success(ui('templateSaved'));
await loadDefinitionsAndTemplates();
} 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;
}
}
</script>
<template>
<div v-loading="loading" class="match-markets-panel">
<div class="panel-head">
<span class="panel-title">{{ t('matchEditor.section_markets') }}</span>
<el-button size="small" type="primary" plain @click="generateTemplates">
{{ t('matchEditor.generate_templates') }}
</el-button>
</div>
<p v-if="!markets.length" class="empty-hint">{{ t('matchEditor.no_markets') }}</p>
<div v-else class="market-lines">
<div
v-for="market in markets"
:key="market.id"
class="market-line"
:class="{ 'market-line--wrap': isMultiRowMarket(market) }"
>
<label class="field-promo-wrap">
<span class="promo-label">{{ t('matchEditor.field.promo_label_optional') }}</span>
<el-input
v-model="market.editPromoLabel"
size="small"
class="field-promo"
clearable
<div v-loading="loading" class="market-editor">
<div class="market-toolbar">
<div class="template-tools">
<el-select v-model="selectedTemplateId" size="small" class="template-select" :placeholder="ui('selectTemplate')">
<el-option
v-for="tpl in templates"
:key="tpl.id"
:label="templateLabel(tpl)"
:value="tpl.id"
/>
</label>
<div class="market-line-head">
<span class="market-label" :title="market.marketType">{{ marketLabel(market.marketType) }}</span>
<el-input-number
v-if="hasLine(market)"
v-model="market.editLineValue"
size="small"
class="field-line"
:step="0.25"
controls-position="right"
/>
<el-button
v-if="isMultiRowMarket(market)"
size="small"
:type="isMarketDirty(market) ? 'primary' : 'default'"
class="btn-save"
:disabled="!isMarketDirty(market)"
:loading="savingMarketId === market.id"
@click="saveMarket(market)"
>
{{ t('common.save') }}
</el-button>
</div>
<div
class="selections-wrap"
:class="isMultiRowMarket(market) ? 'selections-grid' : 'selections-inline'"
>
<div v-for="sel in market.selections" :key="sel.id" class="sel-inline">
<span class="sel-label" :title="sel.selectionCode">{{
selectionCodeLabel(sel.selectionCode, market)
}}</span>
<el-input-number
v-model="sel.editOdds"
size="small"
class="sel-odds"
:min="1.01"
:step="0.01"
controls-position="right"
/>
</div>
</div>
<el-button
v-if="!isMultiRowMarket(market)"
size="small"
:type="isMarketDirty(market) ? 'primary' : 'default'"
class="btn-save"
:disabled="!isMarketDirty(market)"
:loading="savingMarketId === market.id"
@click="saveMarket(market)"
>
</el-select>
<el-button size="small" :loading="applying" @click="applyTemplate">{{ ui('loadTemplate') }}</el-button>
<el-button size="small" @click="openAddMarketDialog">{{ ui('addMarket') }}</el-button>
<el-button size="small" @click="saveAsTemplate">{{ ui('saveAsTemplate') }}</el-button>
</div>
<div class="save-tools">
<el-tag v-if="dirty" size="small" type="warning">{{ ui('unsaved') }}</el-tag>
<el-button size="small" type="primary" :loading="saving" @click="saveAll">
{{ t('common.save') }}
</el-button>
</div>
</div>
<div class="editor-grid">
<section class="market-list">
<div class="section-title">{{ ui('matchMarkets') }}</div>
<p v-if="!markets.length" class="empty-hint">{{ t('matchEditor.no_markets') }}</p>
<div
v-for="(market, index) in markets"
:key="market.id || `${market.marketType}-${index}`"
class="market-card"
:class="{ active: selectedMarketIndex === index, hidden: !market.showOnPlayer }"
@click="selectedMarketIndex = index"
>
<div class="card-main">
<strong>{{ marketTitle(market) }}</strong>
<span>{{ marketMetaText(market) }}</span>
</div>
<div class="card-actions" @click.stop>
<el-button
class="card-action-btn"
size="small"
text
:title="ui('moveUp')"
:disabled="index === 0"
@click="moveMarket(index, -1)"
>
</el-button>
<el-button
class="card-action-btn"
size="small"
text
:title="ui('moveDown')"
:disabled="index === markets.length - 1"
@click="moveMarket(index, 1)"
>
</el-button>
<el-button
class="card-action-btn copy-btn"
size="small"
text
:title="ui(isLineMarket(market) ? 'copyLine' : 'copyMarket')"
@click="duplicateMarket(index)"
>
{{ ui(isLineMarket(market) ? 'copyLineShort' : 'copyMarketShort') }}
</el-button>
<el-button class="card-action-btn" size="small" text type="danger" :title="ui('close')" @click="removeMarket(index)">
{{ ui('close') }}
</el-button>
</div>
</div>
</section>
<section v-if="selectedMarket" class="detail-panel">
<div class="detail-head">
<div>
<div class="section-title">{{ ui('marketDetails') }}</div>
<strong>{{ marketTitle(selectedMarket) }}</strong>
</div>
<el-switch
v-model="selectedMarket.showOnPlayer"
:active-text="ui('playerVisible')"
@change="markDirty"
/>
</div>
<div class="form-grid">
<label>
<span>{{ ui('status') }}</span>
<el-select v-model="selectedMarket.status" size="small" @change="markDirty">
<el-option :label="ui('statusOpen')" value="OPEN" />
<el-option :label="ui('statusSuspended')" value="SUSPENDED" />
<el-option :label="ui('statusClosed')" value="CLOSED" />
</el-select>
</label>
<label v-if="isLineMarket(selectedMarket)">
<span>{{ ui('lineValue') }}</span>
<el-input-number
v-model="selectedMarket.lineValue"
size="small"
:step="0.25"
controls-position="right"
@change="markDirty"
/>
</label>
<label>
<span>{{ ui('single') }}</span>
<el-switch v-model="selectedMarket.allowSingle" @change="markDirty" />
</label>
<label>
<span>{{ ui('parlay') }}</span>
<el-switch v-model="selectedMarket.allowParlay" @change="markDirty" />
</label>
</div>
<div class="locale-block">
<div class="block-title">{{ ui('marketName') }}</div>
<div class="locale-grid">
<label v-for="loc in displayLocales" :key="loc.code">
<span>{{ loc.label }}</span>
<el-input v-model="selectedMarket.nameI18n[loc.code]" size="small" @input="markDirty" />
</label>
</div>
</div>
<div class="locale-block">
<div class="block-title">{{ ui('promoLabel') }}</div>
<div class="locale-grid">
<label v-for="loc in displayLocales" :key="loc.code">
<span>{{ loc.label }}</span>
<el-input v-model="selectedMarket.promoLabelI18n[loc.code]" size="small" clearable @input="markDirty" />
</label>
</div>
</div>
<div v-if="isCorrectScoreMarket(selectedMarket)" class="score-tools">
<el-button size="small" @click="addScoreRange(selectedMarket)">{{ ui('fillScoreRange') }}</el-button>
<el-input-number v-model="scoreHome" size="small" :min="0" controls-position="right" />
<span>-</span>
<el-input-number v-model="scoreAway" size="small" :min="0" controls-position="right" />
<el-button size="small" @click="addScore(selectedMarket)">{{ ui('addScore') }}</el-button>
</div>
<div class="selection-list">
<div
v-for="(sel, index) in selectedMarket.selections"
:key="sel.id || `${sel.selectionCode}-${index}`"
class="selection-row"
:class="{ 'selection-row--score': isScoreSelection(sel) }"
>
<div class="selection-meta">
<strong :title="sel.selectionCode">{{ selectionTitle(sel) }}</strong>
<label class="selection-field selection-field--status">
<span>{{ ui('status') }}</span>
<el-select v-model="sel.status" size="small" @change="markDirty">
<el-option :label="ui('statusOpen')" value="OPEN" />
<el-option :label="ui('statusSuspended')" value="SUSPENDED" />
<el-option :label="ui('statusClosed')" value="CLOSED" />
</el-select>
</label>
<label class="selection-field selection-field--odds">
<span>{{ ui('odds') }}</span>
<el-input-number
v-model="sel.odds"
size="small"
:min="1.01"
:step="0.01"
controls-position="right"
@change="markDirty"
/>
</label>
<el-button size="small" text type="danger" @click="removeSelection(selectedMarket, index)">{{ ui('close') }}</el-button>
</div>
<div v-if="!isScoreSelection(sel)" class="locale-grid compact">
<label v-for="loc in displayLocales" :key="loc.code">
<span>{{ loc.label }}</span>
<el-input v-model="sel.nameI18n[loc.code]" size="small" @input="markDirty" />
</label>
</div>
</div>
</div>
</section>
</div>
<el-dialog v-model="addMarketVisible" class="add-market-dialog" :title="ui('addMarket')" width="460px" destroy-on-close>
<label class="add-market-field">
<span>{{ ui('marketToAdd') }}</span>
<el-select
v-model="selectedDefinitionType"
class="full-select"
filterable
:placeholder="ui('selectMarket')"
>
<el-option
v-for="def in definitionOptions"
:key="def.marketType"
:label="definitionLabel(def)"
:value="def.marketType"
/>
</el-select>
</label>
<template #footer>
<el-button @click="addMarketVisible = false">{{ ui('cancel') }}</el-button>
<el-button type="primary" :disabled="!selectedDefinitionType" @click="addSelectedMarket">
{{ ui('add') }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.match-markets-panel {
padding: 10px 12px 12px 16px;
.market-editor {
background: #0a0a0a;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-head {
.market-toolbar {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.panel-title {
font-size: 13px;
font-weight: 700;
color: #ccc;
letter-spacing: 0.04em;
}
.empty-hint {
color: #888;
font-size: 13px;
line-height: 1.5;
margin: 0;
}
.market-lines {
display: flex;
flex-direction: column;
gap: 6px;
}
.market-line {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border: 1px solid #252525;
border-radius: 6px;
background: #111;
min-width: 0;
}
.market-line--wrap {
flex-direction: column;
align-items: stretch;
gap: 8px;
padding: 8px;
}
.market-line--wrap .field-promo-wrap {
width: 100%;
max-width: 280px;
}
.field-promo-wrap {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.promo-label {
flex-shrink: 0;
font-size: 11px;
font-weight: 600;
color: #8e8e93;
white-space: nowrap;
}
.field-promo {
flex: 0 0 88px;
min-width: 0;
}
.market-line--wrap .field-promo {
flex: 1;
max-width: 160px;
}
.market-line-head {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.market-line:not(.market-line--wrap) .market-line-head {
flex: 0 1 auto;
}
.market-line--wrap .market-line-head {
width: 100%;
}
.market-line--wrap .market-line-head .btn-save {
margin-left: auto;
}
.market-label {
flex: 0 0 76px;
font-size: 11px;
font-weight: 700;
color: #e8e8e8;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.field-line {
flex: 0 0 88px;
min-width: 0;
}
.field-line :deep(.el-input-number) {
width: 100%;
}
.selections-inline {
display: flex;
flex: 1;
align-items: center;
gap: 6px;
min-width: 0;
}
.selections-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(112px, 1fr));
gap: 6px;
width: 100%;
}
.selections-grid .sel-inline {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 6px 8px;
}
.selections-grid .sel-label {
min-width: 0;
max-width: 100%;
white-space: nowrap;
line-height: 1.2;
}
.selections-grid .sel-odds {
width: 100%;
}
.selections-grid .sel-odds :deep(.el-input-number) {
width: 100%;
}
.sel-inline {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
padding: 2px 6px;
border-radius: 4px;
gap: 12px;
padding: 10px 12px;
border-bottom: 1px solid #242424;
background: #0d0d0d;
}
.sel-label {
flex-shrink: 0;
font-size: 11px;
.template-tools,
.save-tools {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.template-select {
width: 260px;
}
.editor-grid {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(320px, 420px) minmax(480px, 1fr);
align-items: stretch;
gap: 10px;
padding: 10px 12px 16px;
overflow: hidden;
}
.market-list,
.detail-panel {
min-width: 0;
min-height: 0;
border: 1px solid #262626;
border-radius: 8px;
background: #111;
padding: 10px;
overflow: auto;
}
.section-title,
.block-title {
color: #b8b8b8;
font-size: 12px;
font-weight: 700;
color: var(--green-text);
min-width: 14px;
text-align: center;
margin-bottom: 8px;
}
.empty-hint {
margin: 0;
color: #777;
font-size: 13px;
}
.market-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px;
margin-bottom: 6px;
border: 1px solid #282828;
border-radius: 6px;
background: #151515;
cursor: pointer;
}
.market-card.active {
border-color: #9f853d;
background: #1a1710;
}
.market-card.hidden {
opacity: 0.66;
}
.card-main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
flex: 1;
}
.card-main strong,
.detail-head strong {
color: #f0f0f0;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sel-odds {
width: 88px;
.card-main span {
color: #777;
font-size: 11px;
}
.sel-odds :deep(.el-input-number) {
.card-actions {
flex: 0 0 auto;
display: grid;
grid-template-columns: 30px 30px 46px 40px;
gap: 2px;
justify-content: end;
}
.card-action-btn {
width: 100%;
height: 26px;
padding: 0;
margin-left: 0 !important;
font-size: 12px;
}
.copy-btn {
font-size: 11px;
}
.detail-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
margin-bottom: 12px;
}
.form-grid,
.locale-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin-bottom: 12px;
}
.locale-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.locale-grid.compact {
margin-bottom: 0;
}
label {
display: flex;
flex-direction: column;
gap: 4px;
color: #aaa;
font-size: 12px;
}
.locale-block {
margin-bottom: 12px;
}
.score-tools {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.selection-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.selection-row {
padding: 8px;
border: 1px solid #262626;
border-radius: 6px;
background: #141414;
}
.selection-meta {
display: grid;
grid-template-columns: minmax(90px, 1fr) 120px 132px 70px;
gap: 8px;
align-items: center;
margin-bottom: 8px;
}
.selection-row--score .selection-meta {
margin-bottom: 0;
}
.selection-meta strong {
color: #ddd;
font-size: 12px;
}
.selection-field {
gap: 3px;
}
.selection-field > span {
color: #f3c969;
font-size: 11px;
font-weight: 800;
line-height: 1;
}
.selection-field--status > span {
color: #7dd3fc;
}
.selection-field--odds :deep(.el-input-number),
.selection-field--odds :deep(.el-input) {
width: 100%;
}
.btn-save {
flex-shrink: 0;
min-width: 52px;
.add-market-field {
margin: 0;
}
.btn-save.is-disabled,
.btn-save:disabled {
opacity: 0.45;
.full-select {
width: 100%;
}
@media (max-width: 720px) {
.market-toolbar,
.editor-grid,
.form-grid,
.locale-grid,
.selection-meta {
display: flex;
flex-direction: column;
align-items: stretch;
}
.template-select {
width: 100%;
}
.market-list,
.detail-panel {
min-height: 320px;
}
}
.market-editor {
background: #ffffff;
color: var(--text);
}
.market-toolbar {
border-bottom-color: var(--border);
background: #fbfaf7;
}
.market-list,
.detail-panel,
.market-card,
.selection-row {
border-color: var(--border);
background: #ffffff;
color: var(--text);
}
.market-list,
.detail-panel {
box-shadow: var(--shadow);
}
.section-title,
.block-title,
.card-main strong,
.detail-head strong,
.selection-meta strong {
color: var(--text);
}
.empty-hint,
.card-main span,
label,
.selection-field > span {
color: var(--text-muted);
}
.market-card:hover {
border-color: #d5cfc3;
background: var(--accent-hover);
}
.market-card.active {
border-color: var(--primary);
background: var(--accent-subtle);
}
.selection-row {
background: #fbfaf7;
}
.selection-field > span {
font-weight: 750;
}
.selection-field--status > span {
color: var(--info-text);
}
@media (max-width: 720px) {
.market-toolbar {
align-items: stretch;
}
.template-tools,
.save-tools {
align-items: stretch;
width: 100%;
}
}
</style>