1. 后台/API:赛事详情返回 allowSingle/allowParlay,管理端 mapMarkets 正确回读 2. 玩家端:仅关单关时仍可点选加入串关;投注抽屉禁用单关提交并提示 3. 玩家端:盘口暂停/已关闭状态标签;MarketTypeTile 展示 statusLabel Co-authored-by: Cursor <cursoragent@cursor.com>
1108 lines
33 KiB
Vue
1108 lines
33 KiB
Vue
<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>
|