Files
thebet365/apps/admin/src/views/matches/MatchMarketsPanel.vue
Mars 9f1d263741 fix(admin/player/api): 同步单关串关持久化、仅串关可选与盘口状态展示
1. 后台/API:赛事详情返回 allowSingle/allowParlay,管理端 mapMarkets 正确回读
2. 玩家端:仅关单关时仍可点选加入串关;投注抽屉禁用单关提交并提示
3. 玩家端:盘口暂停/已关闭状态标签;MarketTypeTile 展示 statusLabel

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:09:23 +08:00

1108 lines
33 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
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';
const props = defineProps<{
matchId: string;
}>();
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;
selectionCode: string;
selectionName: string;
nameI18n: LocalizedText;
odds: number;
status: string;
sortOrder: number;
}
interface MarketRow {
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;
promoLabelI18n: LocalizedText;
nameI18n: LocalizedText;
selections: SelectionRow[];
}
interface MarketTemplateSummary {
id: string;
name: string;
nameI18n: LocalizedText;
isDefault: boolean;
status: string;
}
const loading = ref(false);
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, 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: m.allowSingle ?? true,
allowParlay: m.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) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
} finally {
loading.value = false;
}
}
watch(
() => props.matchId,
() => {
load();
},
{ immediate: true },
);
function markDirty() {
dirty.value = true;
}
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 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();
}
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 removeSelection(market: MarketRow, index: number) {
market.selections.splice(index, 1);
market.selections.forEach((s, i) => {
s.sortOrder = i;
});
markDirty();
}
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,
})),
}));
}
async function applyTemplate() {
if (!selectedTemplateId.value) return;
applying.value = true;
try {
await api.post(`/admin/matches/${props.matchId}/markets/apply-template`, {
templateId: selectedTemplateId.value,
});
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 {
applying.value = false;
}
}
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;
}
saving.value = true;
try {
await api.put(`/admin/matches/${props.matchId}/markets/bulk`, {
markets: payloadMarkets(),
});
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 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="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"
/>
</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>
.market-editor {
background: #0a0a0a;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.market-toolbar {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-bottom: 1px solid #242424;
background: #0d0d0d;
}
.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;
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;
}
.card-main span {
color: #777;
font-size: 11px;
}
.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%;
}
.add-market-field {
margin: 0;
}
.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>