feat(admin+api+player): 优胜赛结算态、禁止新增单场与钱包流水展示优化
- API/管理端:优胜赛 SETTLED 后禁止新增单场,列表与子页展示结算状态 - 玩家端:已结算 outright 只读展示并高亮冠军 - 管理端:结算后 stale 标记驱动列表刷新;财务流水时间与备注 i18n 优化 - shared:txDisplayAmount 与 LEAGUE_OUTRIGHT_SETTLED 错误码
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { formatMoneyCompact, parseAmount } from '../utils/localeDisplay';
|
||||
import { parseAmount } from '../utils/localeDisplay';
|
||||
|
||||
interface Transaction {
|
||||
transactionType: string;
|
||||
amount: string;
|
||||
frozenBefore?: string;
|
||||
frozenAfter?: string;
|
||||
createdAt: string;
|
||||
transactionId?: string;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface OutrightSelection {
|
||||
logoUrl?: string | null;
|
||||
odds: string;
|
||||
oddsVersion: string;
|
||||
isWinner?: boolean;
|
||||
}
|
||||
|
||||
export interface OutrightEvent {
|
||||
@@ -22,6 +23,8 @@ export interface OutrightEvent {
|
||||
leagueCode?: string;
|
||||
leagueName: string;
|
||||
title: string;
|
||||
status?: string;
|
||||
bettingOpen?: boolean;
|
||||
selectionCount?: number;
|
||||
selections: OutrightSelection[];
|
||||
}
|
||||
@@ -47,16 +50,21 @@ const headMeta = computed(() => {
|
||||
const total = props.event.selectionCount ?? props.event.selections.length;
|
||||
return t('bet.outright_teams_count', { n: total });
|
||||
});
|
||||
|
||||
const isSettled = computed(() => props.event.bettingOpen === false || props.event.status === 'SETTLED');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="event-block">
|
||||
<button type="button" class="event-head" :class="{ 'is-expanded': expanded }" :aria-expanded="expanded" @click="emit('toggle')">
|
||||
<button type="button" class="event-head" :class="{ 'is-expanded': expanded, 'is-settled': isSettled }" :aria-expanded="expanded" @click="emit('toggle')">
|
||||
<span class="toggle-icon" :class="{ open: expanded }">
|
||||
<span class="toggle-mark">{{ expanded ? '−' : '+' }}</span>
|
||||
</span>
|
||||
<span class="event-head-text">
|
||||
<span class="event-title">{{ headTitle }}</span>
|
||||
<span class="event-title-row">
|
||||
<span class="event-title">{{ headTitle }}</span>
|
||||
<span v-if="isSettled" class="event-settled-tag">{{ t('bet.outright_settled') }}</span>
|
||||
</span>
|
||||
<span v-if="event.leagueName && event.leagueName !== headTitle" class="event-league">
|
||||
{{ event.leagueName }}
|
||||
</span>
|
||||
@@ -74,6 +82,8 @@ const headMeta = computed(() => {
|
||||
:team-name="sel.teamName"
|
||||
:logo-url="sel.logoUrl"
|
||||
:odds="sel.odds"
|
||||
:disabled="isSettled"
|
||||
:is-winner="Boolean(sel.isWinner)"
|
||||
@pick="emit('pick', sel)"
|
||||
/>
|
||||
</div>
|
||||
@@ -149,6 +159,24 @@ const headMeta = computed(() => {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.event-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.event-settled-tag {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
color: #c9a227;
|
||||
border: 1px solid rgba(201, 162, 39, 0.45);
|
||||
border-radius: 999px;
|
||||
padding: 1px 7px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.event-title {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
|
||||
@@ -7,6 +7,8 @@ const props = defineProps<{
|
||||
teamName: string;
|
||||
odds: string;
|
||||
logoUrl?: string | null;
|
||||
disabled?: boolean;
|
||||
isWinner?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ pick: [] }>();
|
||||
@@ -56,7 +58,14 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button ref="cardRef" type="button" class="option-card" @click="emit('pick')">
|
||||
<button
|
||||
ref="cardRef"
|
||||
type="button"
|
||||
class="option-card"
|
||||
:class="{ 'option-card--disabled': disabled, 'option-card--winner': isWinner }"
|
||||
:disabled="disabled"
|
||||
@click="emit('pick')"
|
||||
>
|
||||
<img
|
||||
v-if="imgVisible && flag && !flagFailed"
|
||||
:src="flag"
|
||||
@@ -99,6 +108,20 @@ onUnmounted(() => {
|
||||
border-color: var(--border-gold-soft);
|
||||
}
|
||||
|
||||
.option-card--disabled {
|
||||
cursor: default;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.option-card--disabled:active {
|
||||
border-color: rgba(140, 140, 140, 0.35);
|
||||
}
|
||||
|
||||
.option-card--winner {
|
||||
border-color: rgba(201, 162, 39, 0.75);
|
||||
box-shadow: 0 0 0 1px rgba(201, 162, 39, 0.25);
|
||||
}
|
||||
|
||||
.flag {
|
||||
width: 28px;
|
||||
height: 19px;
|
||||
|
||||
@@ -114,6 +114,7 @@ function toggle(id: string) {
|
||||
}
|
||||
|
||||
function openBet(event: OutrightEvent, sel: OutrightSelection) {
|
||||
if (event.bettingOpen === false || event.status === 'SETTLED') return;
|
||||
if (!auth.token) {
|
||||
goLogin();
|
||||
return;
|
||||
@@ -144,6 +145,9 @@ function closeModal() {
|
||||
<p v-if="eventCount > 1" class="panel-summary">
|
||||
{{ t('bet.outright_events_summary', { events: eventCount, teams: totalSelections }) }}
|
||||
</p>
|
||||
<p v-if="events.some((e) => e.bettingOpen === false || e.status === 'SETTLED')" class="panel-settled-hint">
|
||||
{{ t('bet.outright_settled_hint') }}
|
||||
</p>
|
||||
|
||||
<div class="event-list">
|
||||
<OutrightEventSection
|
||||
@@ -182,6 +186,18 @@ function closeModal() {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.panel-settled-hint {
|
||||
margin: 0 0 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: rgba(201, 162, 39, 0.08);
|
||||
border: 1px solid rgba(201, 162, 39, 0.22);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #c9a227;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.event-list {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -367,6 +367,8 @@ export default {
|
||||
outright_player_only: 'Player login required',
|
||||
outright_shown_count: '{shown} / {total} teams shown',
|
||||
outright_load_more: 'Load more',
|
||||
outright_settled: 'Settled',
|
||||
outright_settled_hint: 'This event is settled. Odds and results are view-only.',
|
||||
cancel: 'Cancel',
|
||||
parlay_max_legs: 'Parlay allows up to 5 legs',
|
||||
parlay_block_outright: 'Outright cannot be parlayed',
|
||||
|
||||
@@ -373,6 +373,8 @@ export default {
|
||||
outright_player_only: 'Log masuk pemain diperlukan',
|
||||
outright_shown_count: '{shown} / {total} pasukan dipaparkan',
|
||||
outright_load_more: 'Muat lagi',
|
||||
outright_settled: 'Selesai',
|
||||
outright_settled_hint: 'Acara ini telah diselesaikan. Hanya paparan odds dan keputusan.',
|
||||
cancel: 'Batal',
|
||||
parlay_max_legs: 'Maksimum 5 pilihan parlay',
|
||||
parlay_block_outright: 'Outright tidak boleh parlay',
|
||||
|
||||
@@ -367,6 +367,8 @@ export default {
|
||||
outright_player_only: '请使用玩家账号登录后查看',
|
||||
outright_shown_count: '已显示 {shown} / {total} 队',
|
||||
outright_load_more: '加载更多',
|
||||
outright_settled: '已结算',
|
||||
outright_settled_hint: '本赛事已结算,仅可查看赔率与冠军结果',
|
||||
cancel: '取消',
|
||||
parlay_max_legs: '串关最多 5 项',
|
||||
parlay_block_outright: '冠军盘不可串关',
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
import { txDisplayAmount } from '@thebet365/shared';
|
||||
|
||||
export { txDisplayAmount };
|
||||
|
||||
/** 流水金额样式:0 用中性色,正数金色,负数红色 */
|
||||
export function txAmountClass(amount: string): 'zero' | 'pos' | 'neg' {
|
||||
const n = parseFloat(amount);
|
||||
if (n === 0) return 'zero';
|
||||
return n > 0 ? 'pos' : 'neg';
|
||||
}
|
||||
|
||||
export const TX_KEY_MAP: Record<string, string> = {
|
||||
MANUAL_DEPOSIT: 'wallet.tx_deposit',
|
||||
ADMIN_DEPOSIT: 'wallet.tx_admin_deposit',
|
||||
@@ -87,3 +98,4 @@ export function isCashbackType(type: string): boolean {
|
||||
const t = type.toUpperCase();
|
||||
return t === 'CASHBACK' || t === 'CASHBACK_DEPOSIT';
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import api from '../api';
|
||||
import { formatMoney } from '../utils/localeDisplay';
|
||||
import { isBetType, isDepositType, isWithdrawType, isCashbackType, txTypeKey, txDisplayType, txSummaryLabel } from '../utils/walletTx';
|
||||
import { isBetType, isDepositType, isWithdrawType, isCashbackType, txTypeKey, txDisplayType, txAmountClass, txSummaryLabel } from '../utils/walletTx';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import WalletStatsPanel from '../components/WalletStatsPanel.vue';
|
||||
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
||||
@@ -19,6 +19,8 @@ type Transaction = {
|
||||
summaryKind?: 'opening_bonus' | null;
|
||||
referenceType?: string | null;
|
||||
amount: string;
|
||||
frozenBefore?: string;
|
||||
frozenAfter?: string;
|
||||
createdAt: string;
|
||||
transactionId: string;
|
||||
};
|
||||
@@ -198,7 +200,7 @@ const pullIndicatorStyle = () => ({
|
||||
<span class="tx-type">{{ txLabel(tx) }}</span>
|
||||
<span v-if="txSubtitle(tx)" class="tx-summary">{{ txSubtitle(tx) }}</span>
|
||||
</div>
|
||||
<span :class="parseFloat(tx.amount) >= 0 ? 'pos' : 'neg'">
|
||||
<span :class="txAmountClass(tx.amount)">
|
||||
{{ formatMoney(tx.amount, locale) }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -358,6 +360,7 @@ const pullIndicatorStyle = () => ({
|
||||
.tx-type { font-weight: 700; color: var(--text); }
|
||||
.pos { color: var(--primary-light); font-weight: 800; font-size: 15px; }
|
||||
.neg { color: var(--danger); font-weight: 700; }
|
||||
.zero { color: var(--text-muted); font-weight: 700; font-size: 15px; }
|
||||
.tx-time { font-size: 11px; color: var(--text-muted); }
|
||||
.tx-arrow { font-size: 16px; color: #555; font-weight: 700; line-height: 1; }
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import api from '../api';
|
||||
import { formatMoney } from '../utils/localeDisplay';
|
||||
import { txTypeKey, isCashbackType, txDisplayType, txSummaryLabel, txRemarkLabel, isDepositReversalType } from '../utils/walletTx';
|
||||
import { txTypeKey, isCashbackType, txDisplayType, txAmountClass, txSummaryLabel, txRemarkLabel, isDepositReversalType } from '../utils/walletTx';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
||||
|
||||
@@ -81,10 +81,7 @@ const summaryText = computed(() => {
|
||||
return txSummaryLabel(tx.value, t);
|
||||
});
|
||||
|
||||
const amountClass = computed(() => {
|
||||
if (!tx.value) return '';
|
||||
return parseFloat(tx.value.amount) >= 0 ? 'pos' : 'neg';
|
||||
});
|
||||
const amountClass = computed(() => (tx.value ? txAmountClass(tx.value.amount) : 'zero'));
|
||||
|
||||
const formattedTime = computed(() => {
|
||||
if (!tx.value) return '';
|
||||
@@ -279,6 +276,11 @@ function goCashbackDetail() {
|
||||
background: linear-gradient(135deg, rgba(224, 80, 80, 0.1), rgba(224, 80, 80, 0.03));
|
||||
}
|
||||
|
||||
.hero.zero {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
background: #141414;
|
||||
}
|
||||
|
||||
.hero-type {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
@@ -303,6 +305,7 @@ function goCashbackDetail() {
|
||||
|
||||
.hero.pos .hero-amount { color: var(--primary-light); }
|
||||
.hero.neg .hero-amount { color: var(--danger); }
|
||||
.hero.zero .hero-amount { color: var(--text-muted, #888); }
|
||||
|
||||
.hero-time {
|
||||
font-size: 11px;
|
||||
@@ -350,6 +353,7 @@ function goCashbackDetail() {
|
||||
|
||||
.pos { color: var(--primary-light) !important; }
|
||||
.neg { color: var(--danger) !important; }
|
||||
.zero { color: var(--text-muted, #888) !important; }
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, monospace;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import api from '../api';
|
||||
import { formatMoney, formatMoneyCompact } from '../utils/localeDisplay';
|
||||
import { txTypeKey, txDisplayType, txSummaryLabel } from '../utils/walletTx';
|
||||
import { txTypeKey, txDisplayType, txAmountClass, txSummaryLabel } from '../utils/walletTx';
|
||||
import { parseCashbackApiData, sumCashbackAmount } from '../utils/cashback';
|
||||
import GoldSpinner from '../components/GoldSpinner.vue';
|
||||
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
||||
@@ -19,6 +19,8 @@ type Transaction = {
|
||||
summaryKind?: 'opening_bonus' | null;
|
||||
referenceType?: string | null;
|
||||
amount: string;
|
||||
frozenBefore?: string;
|
||||
frozenAfter?: string;
|
||||
createdAt: string;
|
||||
transactionId: string;
|
||||
};
|
||||
@@ -121,7 +123,7 @@ const pullIndicatorStyle = () => ({
|
||||
<span class="tx-type">{{ txLabel(tx) }}</span>
|
||||
<span v-if="txSubtitle(tx)" class="tx-summary">{{ txSubtitle(tx) }}</span>
|
||||
</div>
|
||||
<span :class="parseFloat(tx.amount) >= 0 ? 'pos' : 'neg'">
|
||||
<span :class="txAmountClass(tx.amount)">
|
||||
{{ formatMoney(tx.amount, locale) }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -225,6 +227,7 @@ const pullIndicatorStyle = () => ({
|
||||
.tx-type { font-weight: 700; color: var(--text); }
|
||||
.pos { color: var(--primary-light); font-weight: 800; font-size: 15px; }
|
||||
.neg { color: var(--danger); font-weight: 700; }
|
||||
.zero { color: var(--text-muted); font-weight: 700; font-size: 15px; }
|
||||
.tx-time { font-size: 11px; color: var(--text-muted); }
|
||||
.tx-arrow { font-size: 16px; color: #555; font-weight: 700; line-height: 1; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user