feat(admin,api,player): 赛事分组管理、盘口独立页与多语言展示优化
- 管理端按联赛展示单场,新增赛事/单场流程与列表展开状态保持 - 盘口赔率迁至独立页面,保存按钮仅在有修改时高亮 - API 新增联赛列表与子场查询,按 locale 返回队名并修复编译 - 波胆其它选项与促销标签等 i18n 补齐,文案更易懂
This commit is contained in:
480
apps/admin/src/views/matches/MatchMarketsPanel.vue
Normal file
480
apps/admin/src/views/matches/MatchMarketsPanel.vue
Normal file
@@ -0,0 +1,480 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useAdminLocale } from '../../composables/useAdminLocale';
|
||||
import api from '../../api';
|
||||
import type { AdminMatchDetail } from '../match-form.ts';
|
||||
import { defaultSelectionName } from '../../utils/selectionDefaults.ts';
|
||||
import { adminSelectionLabel } from '../../utils/adminSelectionLabel.ts';
|
||||
|
||||
const props = defineProps<{
|
||||
matchId: string;
|
||||
}>();
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
|
||||
interface SelectionRow {
|
||||
id: string;
|
||||
selectionCode: string;
|
||||
selectionName: string;
|
||||
odds: number;
|
||||
status: string;
|
||||
editOdds: number;
|
||||
}
|
||||
|
||||
interface MarketRow {
|
||||
id: string;
|
||||
marketType: string;
|
||||
period: string;
|
||||
lineValue: number | null;
|
||||
status: string;
|
||||
promoLabel: string;
|
||||
editPromoLabel: string;
|
||||
editLineValue: number | null;
|
||||
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',
|
||||
];
|
||||
|
||||
const loading = ref(false);
|
||||
const savingMarketId = ref<string | null>(null);
|
||||
const markets = ref<MarketRow[]>([]);
|
||||
|
||||
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,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!props.matchId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
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 marketLabel(type: string) {
|
||||
const key = `matchEditor.market.${type}`;
|
||||
const v = t(key);
|
||||
return v !== key ? v : type;
|
||||
}
|
||||
|
||||
function selectionCodeLabel(code: string, market?: MarketRow) {
|
||||
return adminSelectionLabel(t, code, {
|
||||
lineValue: market?.editLineValue ?? market?.lineValue,
|
||||
period: market?.period,
|
||||
});
|
||||
}
|
||||
|
||||
function hasLine(market: MarketRow) {
|
||||
return market.lineValue != null || market.editLineValue != null;
|
||||
}
|
||||
|
||||
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 normPromo(value: string | null | undefined) {
|
||||
return (value ?? '').trim() || null;
|
||||
}
|
||||
|
||||
function normLine(value: number | null | undefined) {
|
||||
return value == null ? null : Number(value);
|
||||
}
|
||||
|
||||
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;
|
||||
try {
|
||||
await api.post(`/admin/matches/${props.matchId}/markets/templates`, {
|
||||
marketTypes: DEFAULT_MARKET_TYPES,
|
||||
});
|
||||
ElMessage.success(t('matchEditor.templates_generated'));
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMarket(market: MarketRow) {
|
||||
const invalid = market.selections.find((s) => !s.editOdds || s.editOdds <= 1);
|
||||
if (invalid) {
|
||||
ElMessage.warning(t('outright.err_odds_min'));
|
||||
return;
|
||||
}
|
||||
|
||||
savingMarketId.value = market.id;
|
||||
try {
|
||||
await api.patch(`/admin/markets/${market.id}`, {
|
||||
promoLabel: market.editPromoLabel.trim() || null,
|
||||
lineValue: market.editLineValue,
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
</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
|
||||
/>
|
||||
</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)"
|
||||
>
|
||||
{{ t('common.save') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.match-markets-panel {
|
||||
padding: 10px 12px 12px 16px;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
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;
|
||||
background: #0d0d0d;
|
||||
}
|
||||
|
||||
.sel-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--green-text);
|
||||
min-width: 14px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sel-odds {
|
||||
width: 88px;
|
||||
}
|
||||
|
||||
.sel-odds :deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
flex-shrink: 0;
|
||||
min-width: 52px;
|
||||
}
|
||||
|
||||
.btn-save.is-disabled,
|
||||
.btn-save:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user