diff --git a/apps/admin/src/components/LeagueRowActions.vue b/apps/admin/src/components/LeagueRowActions.vue index 40f87f1..f1f24bd 100644 --- a/apps/admin/src/components/LeagueRowActions.vue +++ b/apps/admin/src/components/LeagueRowActions.vue @@ -2,6 +2,7 @@ export interface LeagueRowView { id: string; isPublished: boolean; + isOutrightSettled?: boolean; isPublishing: boolean; labels: { edit: string; @@ -30,7 +31,12 @@ defineEmits<{ {{ row.labels.edit }} - + {{ row.labels.createFixture }} (); -const { t, locale, localeTag } = useAdminLocale(); +const { t, locale } = useAdminLocale(); const auth = useAuthStore(); const visible = computed({ @@ -68,17 +69,6 @@ function depositMethodLabel(row: WalletTxRow) { return walletDepositMethodLabel(row, t); } -function formatTime(v: string) { - return new Date(v).toLocaleString(localeTag.value, { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }); -} - function dateParams() { if (!dateRange.value?.length) return {}; const [from, to] = dateRange.value; @@ -172,8 +162,12 @@ watch( - - {{ formatTime(row.createdAt) }} + + + + {{ formatAdminDateTimeBrief(row.createdAt, locale) }} + + {{ row.transactionId }} @@ -181,45 +175,27 @@ watch( {{ walletTypeLabel(row.transactionType) }} - + {{ depositMethodLabel(row) }} - + - - - {{ formatAmount(row.amount) }} - - + + {{ formatAmountFull(txDisplayAmount(row)) }} + - - - - {{ formatAmount(row.balanceBefore) }} - - + + {{ formatAmountFull(row.balanceBefore) }} - - - - {{ formatAmount(row.balanceAfter) }} - - + + {{ formatAmountFull(row.balanceAfter) }} - - - - {{ formatAmount(row.frozenBefore) }} - - + + {{ formatAmountFull(row.frozenBefore) }} - - - - {{ formatAmount(row.frozenAfter) }} - - + + {{ formatAmountFull(row.frozenAfter) }} @@ -237,8 +213,8 @@ watch( {{ row.operatorUsername ?? '—' }} - - {{ row.remark ?? '—' }} + + {{ walletRemarkLabel(row.remark, row.transactionType, t) }} diff --git a/apps/admin/src/i18n/admin-pages-ms.ts b/apps/admin/src/i18n/admin-pages-ms.ts index d2fdeb7..6298d9b 100644 --- a/apps/admin/src/i18n/admin-pages-ms.ts +++ b/apps/admin/src/i18n/admin-pages-ms.ts @@ -231,6 +231,9 @@ export const adminPagesMs: Record = { 'finance.remark.admin_deposit': 'Deposit admin', 'finance.remark.admin_withdraw': 'Pengeluaran admin', 'finance.remark.initial_balance': 'Baki permulaan akaun', + 'finance.remark.revoke_deposit': 'Deposit diluluskan dibatalkan {orderNo}', + 'finance.remark.deposit_order': 'Pesanan deposit {orderNo}', + 'finance.remark.cashback_batch': 'Kumpulan cashback {batchNo}', 'agent.col.no_records': 'Tiada rekod', 'agent.btn.confirm_adjust': 'Sahkan', 'agent.field.select_user': 'Pilih pengguna', @@ -278,6 +281,8 @@ export const adminPagesMs: Record = { 'match.hint.create_league': 'Kejohanan baharu tidak diterbitkan secara lalai; terbitkan untuk paparan pemain, kemudian kembangkan untuk tambah perlawanan.', 'league.status.PUBLISHED': 'Diterbitkan', 'league.status.UNPUBLISHED': 'Tidak diterbitkan', + 'league.status.OUTRIGHT_SETTLED': 'Kejohanan diselesaikan', + 'league.hint.outright_settled_no_fixture': 'Pasaran juara telah diselesaikan; perlawanan baharu tidak boleh ditambah.', 'league.btn.unpublish': 'Nyahterbit', 'league.confirm_unpublish': 'Pemain tidak lagi melihat kejohanan ini; anda masih boleh edit dan terbitkan semula di admin. Teruskan?', 'msg.league_published': 'Kejohanan diterbitkan', diff --git a/apps/admin/src/i18n/admin-pages.ts b/apps/admin/src/i18n/admin-pages.ts index 92b4033..a4e9845 100644 --- a/apps/admin/src/i18n/admin-pages.ts +++ b/apps/admin/src/i18n/admin-pages.ts @@ -237,6 +237,9 @@ export const adminPagesZh: Record = { 'finance.remark.admin_deposit': '管理员上分', 'finance.remark.admin_withdraw': '管理员下分', 'finance.remark.initial_balance': '开户初始余额', + 'finance.remark.revoke_deposit': '撤销已通过充值 {orderNo}', + 'finance.remark.deposit_order': '充值订单 {orderNo}', + 'finance.remark.cashback_batch': '返水批次 {batchNo}', 'agent.col.no_records': '暂无记录', 'agent.btn.confirm_adjust': '确认调整', 'agent.field.select_user': '选择用户', @@ -295,6 +298,8 @@ export const adminPagesZh: Record = { 'match.hint.create_league': '新建联赛默认为未发布,请在列表点击「发布」后玩家端可见;展开该行可添加单场。', 'league.status.PUBLISHED': '已发布', 'league.status.UNPUBLISHED': '未发布', + 'league.status.OUTRIGHT_SETTLED': '赛事已结算', + 'league.hint.outright_settled_no_fixture': '优胜赛已结算,不可再新增单场。', 'league.btn.unpublish': '下架', 'league.confirm_unpublish': '下架后玩家端将不再展示该联赛,管理端仍可编辑与重新发布。是否继续?', 'msg.league_published': '联赛已发布', @@ -1336,6 +1341,9 @@ export const adminPagesEn: Record = { 'finance.remark.admin_deposit': 'Admin deposit', 'finance.remark.admin_withdraw': 'Admin withdraw', 'finance.remark.initial_balance': 'Initial account balance', + 'finance.remark.revoke_deposit': 'Revoked approved deposit {orderNo}', + 'finance.remark.deposit_order': 'Deposit order {orderNo}', + 'finance.remark.cashback_batch': 'Cashback batch {batchNo}', 'agent.col.no_records': 'No records', 'agent.btn.confirm_adjust': 'Confirm', 'agent.field.select_user': 'Select user', @@ -1394,6 +1402,8 @@ export const adminPagesEn: Record = { 'match.hint.create_league': 'New tournaments are unpublished by default; use Publish in the list for player visibility, then expand to add fixtures.', 'league.status.PUBLISHED': 'Published', 'league.status.UNPUBLISHED': 'Unpublished', + 'league.status.OUTRIGHT_SETTLED': 'Tournament settled', + 'league.hint.outright_settled_no_fixture': 'Outright market is settled; new fixtures cannot be added.', 'league.btn.unpublish': 'Unpublish', 'league.confirm_unpublish': 'Players will no longer see this tournament; you can still edit and republish in admin. Continue?', 'msg.league_published': 'Tournament published', diff --git a/apps/admin/src/i18n/pages/en.ts b/apps/admin/src/i18n/pages/en.ts index 488aecd..753a0ef 100644 --- a/apps/admin/src/i18n/pages/en.ts +++ b/apps/admin/src/i18n/pages/en.ts @@ -242,6 +242,9 @@ const adminPages: Record = { 'finance.remark.admin_deposit': 'Admin deposit', 'finance.remark.admin_withdraw': 'Admin withdraw', 'finance.remark.initial_balance': 'Initial account balance', + 'finance.remark.revoke_deposit': 'Revoked approved deposit {orderNo}', + 'finance.remark.deposit_order': 'Deposit order {orderNo}', + 'finance.remark.cashback_batch': 'Cashback batch {batchNo}', 'agent.col.no_records': 'No records', 'agent.btn.confirm_adjust': 'Confirm', 'agent.field.select_user': 'Select user', @@ -300,6 +303,8 @@ const adminPages: Record = { 'match.hint.create_league': 'New tournaments are unpublished by default; use Publish in the list for player visibility, then expand to add fixtures.', 'league.status.PUBLISHED': 'Published', 'league.status.UNPUBLISHED': 'Unpublished', + 'league.status.OUTRIGHT_SETTLED': 'Tournament settled', + 'league.hint.outright_settled_no_fixture': 'Outright market is settled; new fixtures cannot be added.', 'league.btn.unpublish': 'Unpublish', 'league.confirm_unpublish': 'Players will no longer see this tournament; you can still edit and republish in admin. Continue?', 'msg.league_published': 'Tournament published', diff --git a/apps/admin/src/i18n/pages/zh.ts b/apps/admin/src/i18n/pages/zh.ts index c2e9cb7..3c40730 100644 --- a/apps/admin/src/i18n/pages/zh.ts +++ b/apps/admin/src/i18n/pages/zh.ts @@ -243,6 +243,9 @@ const adminPages: Record = { 'finance.remark.admin_deposit': '管理员上分', 'finance.remark.admin_withdraw': '管理员下分', 'finance.remark.initial_balance': '开户初始余额', + 'finance.remark.revoke_deposit': '撤销已通过充值 {orderNo}', + 'finance.remark.deposit_order': '充值订单 {orderNo}', + 'finance.remark.cashback_batch': '返水批次 {batchNo}', 'agent.col.no_records': '暂无记录', 'agent.btn.confirm_adjust': '确认调整', 'agent.field.select_user': '选择用户', @@ -301,6 +304,8 @@ const adminPages: Record = { 'match.hint.create_league': '新建联赛默认为未发布,请在列表点击「发布」后玩家端可见;展开该行可添加单场。', 'league.status.PUBLISHED': '已发布', 'league.status.UNPUBLISHED': '未发布', + 'league.status.OUTRIGHT_SETTLED': '赛事已结算', + 'league.hint.outright_settled_no_fixture': '优胜赛已结算,不可再新增单场。', 'league.btn.unpublish': '下架', 'league.confirm_unpublish': '下架后玩家端将不再展示该联赛,管理端仍可编辑与重新发布。是否继续?', 'msg.league_published': '联赛已发布', diff --git a/apps/admin/src/utils/adminListStale.ts b/apps/admin/src/utils/adminListStale.ts new file mode 100644 index 0000000..202f39e --- /dev/null +++ b/apps/admin/src/utils/adminListStale.ts @@ -0,0 +1,13 @@ +const STALE_KEY = 'admin:list-stale'; + +/** 标记赛事相关列表需在下次激活时刷新(结算/预览等变更后端统计后调用) */ +export function markAdminListStale() { + sessionStorage.setItem(STALE_KEY, '1'); +} + +/** 若曾标记过 stale 则清除标记并返回 true */ +export function consumeAdminListStale(): boolean { + if (sessionStorage.getItem(STALE_KEY) !== '1') return false; + sessionStorage.removeItem(STALE_KEY); + return true; +} diff --git a/apps/admin/src/utils/format-datetime.ts b/apps/admin/src/utils/format-datetime.ts new file mode 100644 index 0000000..8fa20ed --- /dev/null +++ b/apps/admin/src/utils/format-datetime.ts @@ -0,0 +1,39 @@ +import { getAdminLocale } from '../i18n'; +import type { AdminLocale } from '../i18n/admin-messages'; + +function resolveLocale(locale?: AdminLocale): AdminLocale { + return locale ?? getAdminLocale(); +} + +/** 列表展示:年月日 */ +export function formatAdminDateTimeBrief( + value: string | null | undefined, + locale?: AdminLocale, +): string { + if (!value) return '—'; + const d = new Date(value); + if (Number.isNaN(d.getTime())) return '—'; + return d.toLocaleString(resolveLocale(locale), { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); +} + +/** 悬停详情:含秒 */ +export function formatAdminDateTimeFull( + value: string | null | undefined, + locale?: AdminLocale, +): string { + if (!value) return '—'; + const d = new Date(value); + if (Number.isNaN(d.getTime())) return '—'; + return d.toLocaleString(resolveLocale(locale), { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} diff --git a/apps/admin/src/utils/walletTx.ts b/apps/admin/src/utils/walletTx.ts index f98c9f9..0d714e7 100644 --- a/apps/admin/src/utils/walletTx.ts +++ b/apps/admin/src/utils/walletTx.ts @@ -1,3 +1,5 @@ +export { txDisplayAmount } from '@thebet365/shared'; + export const TX_KEY_MAP: Record = { MANUAL_DEPOSIT: 'finance.tx.deposit', ADMIN_DEPOSIT: 'finance.tx.admin_deposit', @@ -64,3 +66,45 @@ export function walletDepositMethodLabel( if (type === 'PLAYER_DEPOSIT') return t('finance.tx.player_deposit'); return '—'; } + +const WALLET_REMARK_EXACT: Record = { + 'Agent deposit': 'finance.remark.agent_deposit', + 'Agent withdraw': 'finance.remark.agent_withdraw', + '代理上分': 'finance.remark.agent_deposit', + '代理下分': 'finance.remark.agent_withdraw', + '管理员上分': 'finance.remark.admin_deposit', + '管理员下分': 'finance.remark.admin_withdraw', + '开户初始余额': 'finance.remark.initial_balance', + 'Resettlement adjustment': 'finance.tx.resettle', +}; + +/** 钱包流水备注:系统英文/中文模板按当前语言展示 */ +export function walletRemarkLabel( + remark: string | null | undefined, + transactionType: string, + t: (key: string, params?: Record) => string, +): string { + const raw = remark?.trim(); + if (!raw) { + if (transactionType === 'MANUAL_DEPOSIT') return t('finance.remark.agent_deposit'); + if (transactionType === 'MANUAL_WITHDRAW') return t('finance.remark.agent_withdraw'); + return '—'; + } + + const exactKey = WALLET_REMARK_EXACT[raw]; + if (exactKey) return t(exactKey); + + const revokeEn = raw.match(/^Revoke approved deposit\s+([A-Z0-9]+)$/i); + if (revokeEn) return t('finance.remark.revoke_deposit', { orderNo: revokeEn[1] }); + + const revokeZh = raw.match(/^撤销已通过充值\s+([A-Z0-9]+)$/); + if (revokeZh) return t('finance.remark.revoke_deposit', { orderNo: revokeZh[1] }); + + const depositOrder = raw.match(/^Deposit order\s+([A-Z0-9]+)$/i); + if (depositOrder) return t('finance.remark.deposit_order', { orderNo: depositOrder[1] }); + + const cashbackBatch = raw.match(/^Cashback batch\s+(.+)$/i); + if (cashbackBatch) return t('finance.remark.cashback_batch', { batchNo: cashbackBatch[1].trim() }); + + return raw; +} diff --git a/apps/admin/src/views/FinanceLogs.vue b/apps/admin/src/views/FinanceLogs.vue index 3e3290f..ef72d03 100644 --- a/apps/admin/src/views/FinanceLogs.vue +++ b/apps/admin/src/views/FinanceLogs.vue @@ -8,8 +8,9 @@ import { useAdminLocale } from '../composables/useAdminLocale'; import api from '../api'; import AdminTableEmpty from '../components/AdminTableEmpty.vue'; import { formatAmount, formatAmountFull } from '../utils/format-amount'; -import { walletTxTypeKey } from '../utils/walletTx'; -const { t, locale, localeTag } = useAdminLocale(); +import { formatAdminDateTimeBrief, formatAdminDateTimeFull } from '../utils/format-datetime'; +import { walletTxTypeKey, walletRemarkLabel } from '../utils/walletTx'; +const { t, locale } = useAdminLocale(); const auth = useAuthStore(); const route = useRoute(); const router = useRouter(); @@ -84,36 +85,8 @@ function transferTypeLabel(type: string) { return key ? t(key) : type; } -const TRANSFER_REMARK_KEYS: Record = { - 'Agent deposit': 'finance.remark.agent_deposit', - 'Agent withdraw': 'finance.remark.agent_withdraw', - '代理上分': 'finance.remark.agent_deposit', - '代理下分': 'finance.remark.agent_withdraw', - '管理员上分': 'finance.remark.admin_deposit', - '管理员下分': 'finance.remark.admin_withdraw', - '开户初始余额': 'finance.remark.initial_balance', -}; - function transferRemarkLabel(remark: string | null | undefined, transactionType: string) { - const raw = remark?.trim(); - if (!raw) { - if (transactionType === 'MANUAL_DEPOSIT') return t('finance.remark.agent_deposit'); - if (transactionType === 'MANUAL_WITHDRAW') return t('finance.remark.agent_withdraw'); - return '—'; - } - const key = TRANSFER_REMARK_KEYS[raw]; - return key ? t(key) : raw; -} - -function formatTime(v: string) { - return new Date(v).toLocaleString(localeTag.value, { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }); + return walletRemarkLabel(remark, transactionType, t); } function dateParams() { @@ -339,8 +312,12 @@ watch( - - {{ formatTime(row.createdAt) }} + + + + {{ formatAdminDateTimeBrief(row.createdAt, locale) }} + + @@ -410,8 +387,12 @@ watch( - - {{ formatTime(row.createdAt) }} + + + + {{ formatAdminDateTimeBrief(row.createdAt, locale) }} + + {{ row.transactionId }} diff --git a/apps/admin/src/views/Matches.vue b/apps/admin/src/views/Matches.vue index f12c21b..ead8952 100644 --- a/apps/admin/src/views/Matches.vue +++ b/apps/admin/src/views/Matches.vue @@ -3,6 +3,7 @@ import { ref, computed, watch, onBeforeUnmount, onDeactivated } from 'vue'; defineOptions({ name: 'AdminMatches' }); import { useStaleListLifecycle } from '../composables/useStaleList'; +import { consumeAdminListStale } from '../utils/adminListStale'; import { useRoute, useRouter } from 'vue-router'; import { useAdminLocale } from '../composables/useAdminLocale'; import { resolveFormError } from '../i18n/form-validation'; @@ -38,6 +39,7 @@ const isMatchChildRoute = computed(() => interface LeagueTableRow extends Record { id: string; isPublished: boolean; + isOutrightSettled: boolean; isPublishing: boolean; labels: { edit: string; @@ -50,7 +52,7 @@ interface LeagueTableRow extends Record { displayNameZh: string; displayNameEn: string; displayStatusLabel: string; - displayStatusTagType: 'success' | 'info'; + displayStatusTagType: 'success' | 'info' | 'warning'; displayMatchCount: number; displayBetCount: number; displayBetCountActive: boolean; @@ -121,6 +123,7 @@ function mapLeagueRows(items: unknown[], publishingId = ''): LeagueTableRow[] { const r = rowOf(item); const id = String(r.id ?? ''); const published = Boolean(r.isPublished); + const outrightSettled = Boolean(r.isOutrightSettled); const stats = r.betStats as | { betCount?: number; totalStake?: string; pendingCount?: number } | undefined; @@ -129,13 +132,18 @@ function mapLeagueRows(items: unknown[], publishingId = ''): LeagueTableRow[] { ...r, id, isPublished: published, + isOutrightSettled: outrightSettled, isPublishing: publishingId === id, labels, displaySeq: start + index + 1, displayNameZh: String(r.leagueZh ?? '').trim() || '—', displayNameEn: String(r.leagueEn ?? '').trim() || '—', - displayStatusLabel: published ? t('league.status.PUBLISHED') : t('league.status.UNPUBLISHED'), - displayStatusTagType: published ? 'success' : 'info', + displayStatusLabel: outrightSettled + ? t('league.status.OUTRIGHT_SETTLED') + : published + ? t('league.status.PUBLISHED') + : t('league.status.UNPUBLISHED'), + displayStatusTagType: outrightSettled ? 'warning' : published ? 'success' : 'info', displayMatchCount: Number(r.matchCount ?? 0), displayBetCount: betCount, displayBetCountActive: betCount > 0, @@ -163,6 +171,7 @@ function persistListUiState() { type LoadOptions = { restore?: boolean }; async function load(options: LoadOptions = {}) { + consumeAdminListStale(); const saved = options.restore ? readMatchesListUiState() : null; if (saved) { page.value = saved.page; @@ -189,6 +198,17 @@ function onSearch() { void runLoad(true); } +const MATCH_CHILD_ROUTE = /^\/matches\/leagues\/[^/]+/; + +watch( + () => route.path, + (path, prevPath) => { + if (prevPath && MATCH_CHILD_ROUTE.test(prevPath) && path === '/matches') { + void load(); + } + }, +); + async function initialLoad() { if (isMatchChildRoute.value) return; const qStatus = route.query.status; diff --git a/apps/admin/src/views/MatchesOutrights.vue b/apps/admin/src/views/MatchesOutrights.vue index fe5011b..81b9d74 100644 --- a/apps/admin/src/views/MatchesOutrights.vue +++ b/apps/admin/src/views/MatchesOutrights.vue @@ -1,5 +1,6 @@ @@ -204,6 +220,14 @@ function outrightTeamCount(row: unknown) { {{ leagueNameEn(row) }} + + + + {{ t('league.status.OUTRIGHT_SETTLED') }} + + — + + {{ outrightTeamCount(row) }} @@ -270,4 +294,7 @@ function outrightTeamCount(row: unknown) { color: var(--text-muted); font-size: 13px; } +.status-dash { + color: var(--text-muted); +} diff --git a/apps/admin/src/views/Settlement.vue b/apps/admin/src/views/Settlement.vue index d2dbe0a..92bf6ea 100644 --- a/apps/admin/src/views/Settlement.vue +++ b/apps/admin/src/views/Settlement.vue @@ -23,7 +23,7 @@ import { betTypeLabel, betResultLabel, } from '../utils/bet-labels'; -import { adminSelectionLabel } from '../utils/adminSelectionLabel'; +import { markAdminListStale } from '../utils/adminListStale'; import type { AdminMatchDetail } from './match-form'; import AdminSubNav from '../components/AdminSubNav.vue'; @@ -658,6 +658,7 @@ async function confirmResettle() { resettleDialogVisible.value = false; await loadMatch(); void loadSettlementHistory(); + markAdminListStale(); } catch (e: unknown) { ElMessage.error(settlementApiError(e, t('settlement.preview_failed'))); } finally { @@ -726,6 +727,7 @@ async function previewSettlement() { previewPageSize.value = itemsPage.pageSize; previewDialogVisible.value = true; await loadMatch(); + markAdminListStale(); } catch (e: unknown) { ElMessage.error(settlementApiError(e, t('settlement.preview_failed'))); } finally { @@ -754,6 +756,7 @@ async function confirm() { previewDialogVisible.value = false; await loadMatch(); void loadSettlementHistory(); + markAdminListStale(); } catch (e: unknown) { ElMessage.error(settlementApiError(e, t('settlement.preview_failed'))); } finally { diff --git a/apps/admin/src/views/matches/LeagueMatchesPage.vue b/apps/admin/src/views/matches/LeagueMatchesPage.vue index f2eb289..9db1296 100644 --- a/apps/admin/src/views/matches/LeagueMatchesPage.vue +++ b/apps/admin/src/views/matches/LeagueMatchesPage.vue @@ -34,9 +34,15 @@ const leagueTitle = computed(() => { const panelRef = ref<{ reload: () => void } | null>(null); const createVisible = ref(false); const createLoading = ref(false); +const isOutrightSettled = ref(false); const form = ref(emptyMatchForm()); +function onLeagueMeta(meta: { isOutrightSettled: boolean }) { + isOutrightSettled.value = meta.isOutrightSettled; +} + function openCreateFixture() { + if (isOutrightSettled.value) return; form.value = emptyMatchForm(); form.value.leagueId = leagueId.value; createVisible.value = true; @@ -85,9 +91,10 @@ watch(leagueId, () => { :subtitle="t('match.league_fixtures_subtitle')" > - + {{ t('match.create_fixture_btn') }} + {{ t('league.hint.outright_settled_no_fixture') }} @@ -97,6 +104,7 @@ watch(leagueId, () => { :league-id="leagueId" :filter-status="filterStatus" :keyword="filterKeyword" + @league-meta="onLeagueMeta" /> @@ -254,6 +262,12 @@ watch(leagueId, () => { font-weight: 700; } +.settled-hint { + font-size: 13px; + color: var(--warning-text); + font-weight: 600; +} + @media (max-width: 760px) { .teams-row { grid-template-columns: 1fr; diff --git a/apps/admin/src/views/matches/LeagueMatchesPanel.vue b/apps/admin/src/views/matches/LeagueMatchesPanel.vue index 4eec792..d5e2e4e 100644 --- a/apps/admin/src/views/matches/LeagueMatchesPanel.vue +++ b/apps/admin/src/views/matches/LeagueMatchesPanel.vue @@ -1,5 +1,6 @@ - + {{ expanded ? '−' : '+' }} - {{ headTitle }} + + {{ headTitle }} + {{ t('bet.outright_settled') }} + {{ event.leagueName }} @@ -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)" /> @@ -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; diff --git a/apps/player/src/components/outright/OutrightOptionCard.vue b/apps/player/src/components/outright/OutrightOptionCard.vue index 587b3d8..eb1ad12 100644 --- a/apps/player/src/components/outright/OutrightOptionCard.vue +++ b/apps/player/src/components/outright/OutrightOptionCard.vue @@ -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(() => { - + { 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; diff --git a/apps/player/src/components/outright/OutrightPanel.vue b/apps/player/src/components/outright/OutrightPanel.vue index de4ccd7..1afae97 100644 --- a/apps/player/src/components/outright/OutrightPanel.vue +++ b/apps/player/src/components/outright/OutrightPanel.vue @@ -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() { {{ t('bet.outright_events_summary', { events: eventCount, teams: totalSelections }) }} + + {{ t('bet.outright_settled_hint') }} + 0 ? 'pos' : 'neg'; +} + export const TX_KEY_MAP: Record = { 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'; } + diff --git a/apps/player/src/views/WalletDetailView.vue b/apps/player/src/views/WalletDetailView.vue index a42ebf9..53d47ab 100644 --- a/apps/player/src/views/WalletDetailView.vue +++ b/apps/player/src/views/WalletDetailView.vue @@ -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 = () => ({ {{ txLabel(tx) }} {{ txSubtitle(tx) }} - + {{ formatMoney(tx.amount, locale) }} @@ -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; } diff --git a/apps/player/src/views/WalletTransactionDetailView.vue b/apps/player/src/views/WalletTransactionDetailView.vue index 19e4344..eb8e194 100644 --- a/apps/player/src/views/WalletTransactionDetailView.vue +++ b/apps/player/src/views/WalletTransactionDetailView.vue @@ -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; diff --git a/apps/player/src/views/WalletView.vue b/apps/player/src/views/WalletView.vue index 64842e1..45e2cf9 100644 --- a/apps/player/src/views/WalletView.vue +++ b/apps/player/src/views/WalletView.vue @@ -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 = () => ({ {{ txLabel(tx) }} {{ txSubtitle(tx) }} - + {{ formatMoney(tx.amount, locale) }} @@ -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; } diff --git a/packages/shared/src/api-errors.js b/packages/shared/src/api-errors.js index 5b21cbf..53b3f70 100644 --- a/packages/shared/src/api-errors.js +++ b/packages/shared/src/api-errors.js @@ -112,6 +112,11 @@ export const API_ERROR_MESSAGES = { 'en-US': 'Cannot unpublish league after outright market is settled', 'ms-MY': 'Liga tidak boleh ditarik selepas pasaran juara diselesaikan', }, + LEAGUE_OUTRIGHT_SETTLED: { + 'zh-CN': '优胜赛已结算,不可再新增单场', + 'en-US': 'Outright market is settled; new fixtures cannot be added', + 'ms-MY': 'Pasaran juara telah diselesaikan; perlawanan baharu tidak boleh ditambah', + }, MATCH_UNPUBLISH_FORBIDDEN: { 'zh-CN': '当前状态不可下架', 'en-US': 'Match cannot be unpublished in current status', diff --git a/packages/shared/src/api-errors.ts b/packages/shared/src/api-errors.ts index ab46def..e58b4aa 100644 --- a/packages/shared/src/api-errors.ts +++ b/packages/shared/src/api-errors.ts @@ -114,6 +114,11 @@ export const API_ERROR_MESSAGES = { 'en-US': 'Cannot unpublish league after outright market is settled', 'ms-MY': 'Liga tidak boleh ditarik selepas pasaran juara diselesaikan', }, + LEAGUE_OUTRIGHT_SETTLED: { + 'zh-CN': '优胜赛已结算,不可再新增单场', + 'en-US': 'Outright market is settled; new fixtures cannot be added', + 'ms-MY': 'Pasaran juara telah diselesaikan; perlawanan baharu tidak boleh ditambah', + }, MATCH_UNPUBLISH_FORBIDDEN: { 'zh-CN': '当前状态不可下架', 'en-US': 'Match cannot be unpublished in current status', diff --git a/packages/shared/src/index.js b/packages/shared/src/index.js index 3cbc3b6..faac469 100644 --- a/packages/shared/src/index.js +++ b/packages/shared/src/index.js @@ -126,4 +126,5 @@ export * from './playerUsername'; export * from './initial-depositRemark'; export * from './phone-countries'; export * from './match-time'; +export * from './walletTx'; export * from './api-errors'; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 5c36d7d..e853092 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -130,6 +130,7 @@ export * from './playerUsername'; export * from './initial-depositRemark'; export * from './phone-countries'; export * from './match-time'; +export * from './walletTx'; export interface ApiResponse { success: boolean; diff --git a/packages/shared/src/walletTx.js b/packages/shared/src/walletTx.js new file mode 100644 index 0000000..ef5d354 --- /dev/null +++ b/packages/shared/src/walletTx.js @@ -0,0 +1,11 @@ +/** 钱包流水展示用金额:输单结算 amount 为 0(可用余额未变),用冻结差额表示亏损 */ +export function txDisplayAmount(tx) { + const type = tx.transactionType.toUpperCase(); + const amt = parseFloat(tx.amount); + if (type === 'BET_SETTLE_LOSE' && amt === 0 && tx.frozenBefore != null && tx.frozenAfter != null) { + const frozenDelta = parseFloat(tx.frozenBefore) - parseFloat(tx.frozenAfter); + if (frozenDelta > 0) + return (-frozenDelta).toString(); + } + return tx.amount; +} diff --git a/packages/shared/src/walletTx.ts b/packages/shared/src/walletTx.ts new file mode 100644 index 0000000..90a70ed --- /dev/null +++ b/packages/shared/src/walletTx.ts @@ -0,0 +1,15 @@ +/** 钱包流水展示用金额:输单结算 amount 为 0(可用余额未变),用冻结差额表示亏损 */ +export function txDisplayAmount(tx: { + transactionType: string; + amount: string; + frozenBefore?: string; + frozenAfter?: string; +}): string { + const type = tx.transactionType.toUpperCase(); + const amt = parseFloat(tx.amount); + if (type === 'BET_SETTLE_LOSE' && amt === 0 && tx.frozenBefore != null && tx.frozenAfter != null) { + const frozenDelta = parseFloat(tx.frozenBefore) - parseFloat(tx.frozenAfter); + if (frozenDelta > 0) return (-frozenDelta).toString(); + } + return tx.amount; +}
{{ t('bet.outright_events_summary', { events: eventCount, teams: totalSelections }) }}
+ {{ t('bet.outright_settled_hint') }} +