sync(theme-2): 从 main 同步优胜赛结算态与钱包流水展示优化

- checkout shared/api/admin 自 d3c2114
- player: outright 结算态逻辑、wallet txAmountClass、i18n 新增 key(保留蓝白主题样式)
This commit is contained in:
2026-06-23 13:49:43 +08:00
parent 021d216613
commit 26e2adf786
36 changed files with 577 additions and 127 deletions

View File

@@ -2,6 +2,7 @@
export interface LeagueRowView { export interface LeagueRowView {
id: string; id: string;
isPublished: boolean; isPublished: boolean;
isOutrightSettled?: boolean;
isPublishing: boolean; isPublishing: boolean;
labels: { labels: {
edit: string; edit: string;
@@ -30,7 +31,12 @@ defineEmits<{
<el-button size="small" type="primary" @click.stop="$emit('edit')"> <el-button size="small" type="primary" @click.stop="$emit('edit')">
{{ row.labels.edit }} {{ row.labels.edit }}
</el-button> </el-button>
<el-button size="small" type="primary" @click.stop="$emit('createFixture')"> <el-button
v-if="!row.isOutrightSettled"
size="small"
type="primary"
@click.stop="$emit('createFixture')"
>
{{ row.labels.createFixture }} {{ row.labels.createFixture }}
</el-button> </el-button>
<el-button <el-button

View File

@@ -4,8 +4,9 @@ import { useAuthStore } from '../stores/auth';
import { useAdminLocale } from '../composables/useAdminLocale'; import { useAdminLocale } from '../composables/useAdminLocale';
import api from '../api'; import api from '../api';
import AdminTableEmpty from './AdminTableEmpty.vue'; import AdminTableEmpty from './AdminTableEmpty.vue';
import { formatAmount, formatAmountFull } from '../utils/format-amount'; import { formatAmountFull } from '../utils/format-amount';
import { walletDepositMethodLabel, walletTxTypeKey } from '../utils/walletTx'; import { formatAdminDateTimeBrief, formatAdminDateTimeFull } from '../utils/format-datetime';
import { walletDepositMethodLabel, walletTxTypeKey, txDisplayAmount, walletRemarkLabel } from '../utils/walletTx';
interface WalletTxRow { interface WalletTxRow {
id: string; id: string;
@@ -34,7 +35,7 @@ const emit = defineEmits<{
'update:modelValue': [value: boolean]; 'update:modelValue': [value: boolean];
}>(); }>();
const { t, locale, localeTag } = useAdminLocale(); const { t, locale } = useAdminLocale();
const auth = useAuthStore(); const auth = useAuthStore();
const visible = computed({ const visible = computed({
@@ -68,17 +69,6 @@ function depositMethodLabel(row: WalletTxRow) {
return walletDepositMethodLabel(row, t); 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() { function dateParams() {
if (!dateRange.value?.length) return {}; if (!dateRange.value?.length) return {};
const [from, to] = dateRange.value; const [from, to] = dateRange.value;
@@ -172,8 +162,12 @@ watch(
<template #empty> <template #empty>
<AdminTableEmpty /> <AdminTableEmpty />
</template> </template>
<el-table-column :label="t('audit.col.time')" min-width="140"> <el-table-column :label="t('audit.col.time')" min-width="88">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template> <template #default="{ row }">
<el-tooltip :content="formatAdminDateTimeFull(row.createdAt, locale)" placement="top">
<span>{{ formatAdminDateTimeBrief(row.createdAt, locale) }}</span>
</el-tooltip>
</template>
</el-table-column> </el-table-column>
<el-table-column :label="t('finance.col.tx_id')" min-width="120" show-overflow-tooltip> <el-table-column :label="t('finance.col.tx_id')" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.transactionId }}</template> <template #default="{ row }">{{ row.transactionId }}</template>
@@ -181,45 +175,27 @@ watch(
<el-table-column :label="t('finance.col.tx_type')" min-width="80"> <el-table-column :label="t('finance.col.tx_type')" min-width="80">
<template #default="{ row }">{{ walletTypeLabel(row.transactionType) }}</template> <template #default="{ row }">{{ walletTypeLabel(row.transactionType) }}</template>
</el-table-column> </el-table-column>
<el-table-column :label="t('finance.col.deposit_method')" min-width="100" show-overflow-tooltip> <el-table-column :label="t('finance.col.deposit_method')" min-width="72" show-overflow-tooltip>
<template #default="{ row }">{{ depositMethodLabel(row) }}</template> <template #default="{ row }">{{ depositMethodLabel(row) }}</template>
</el-table-column> </el-table-column>
<el-table-column :label="t('finance.col.balance_change')" min-width="96" align="right"> <el-table-column :label="t('finance.col.balance_change')" min-width="110" align="right">
<template #default="{ row }"> <template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.amount)" placement="top"> <span :class="parseFloat(txDisplayAmount(row)) >= 0 ? 'amt-pos' : 'amt-neg'">
<span :class="parseFloat(row.amount) >= 0 ? 'amt-pos' : 'amt-neg'"> {{ formatAmountFull(txDisplayAmount(row)) }}
{{ formatAmount(row.amount) }} </span>
</span>
</el-tooltip>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="t('finance.col.balance_before')" min-width="90" align="right"> <el-table-column :label="t('finance.col.balance_before')" min-width="110" align="right">
<template #default="{ row }"> <template #default="{ row }">{{ formatAmountFull(row.balanceBefore) }}</template>
<el-tooltip :content="formatAmountFull(row.balanceBefore)" placement="top">
<span>{{ formatAmount(row.balanceBefore) }}</span>
</el-tooltip>
</template>
</el-table-column> </el-table-column>
<el-table-column :label="t('finance.col.balance_after')" min-width="90" align="right"> <el-table-column :label="t('finance.col.balance_after')" min-width="110" align="right">
<template #default="{ row }"> <template #default="{ row }">{{ formatAmountFull(row.balanceAfter) }}</template>
<el-tooltip :content="formatAmountFull(row.balanceAfter)" placement="top">
<span>{{ formatAmount(row.balanceAfter) }}</span>
</el-tooltip>
</template>
</el-table-column> </el-table-column>
<el-table-column :label="t('finance.col.frozen_before')" min-width="90" align="right"> <el-table-column :label="t('finance.col.frozen_before')" min-width="110" align="right">
<template #default="{ row }"> <template #default="{ row }">{{ formatAmountFull(row.frozenBefore) }}</template>
<el-tooltip :content="formatAmountFull(row.frozenBefore)" placement="top">
<span>{{ formatAmount(row.frozenBefore) }}</span>
</el-tooltip>
</template>
</el-table-column> </el-table-column>
<el-table-column :label="t('finance.col.frozen_after')" min-width="90" align="right"> <el-table-column :label="t('finance.col.frozen_after')" min-width="110" align="right">
<template #default="{ row }"> <template #default="{ row }">{{ formatAmountFull(row.frozenAfter) }}</template>
<el-tooltip :content="formatAmountFull(row.frozenAfter)" placement="top">
<span>{{ formatAmount(row.frozenAfter) }}</span>
</el-tooltip>
</template>
</el-table-column> </el-table-column>
<el-table-column :label="t('finance.col.reference')" min-width="105" show-overflow-tooltip> <el-table-column :label="t('finance.col.reference')" min-width="105" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
@@ -237,8 +213,8 @@ watch(
<el-table-column :label="t('agent.credit_tx.col.operator')" min-width="85"> <el-table-column :label="t('agent.credit_tx.col.operator')" min-width="85">
<template #default="{ row }">{{ row.operatorUsername ?? '—' }}</template> <template #default="{ row }">{{ row.operatorUsername ?? '—' }}</template>
</el-table-column> </el-table-column>
<el-table-column :label="t('user.field.remark')" min-width="100" show-overflow-tooltip> <el-table-column :label="t('user.field.remark')" min-width="180">
<template #default="{ row }">{{ row.remark ?? '—' }}</template> <template #default="{ row }">{{ walletRemarkLabel(row.remark, row.transactionType, t) }}</template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</div> </div>

View File

@@ -231,6 +231,9 @@ export const adminPagesMs: Record<string, string> = {
'finance.remark.admin_deposit': 'Deposit admin', 'finance.remark.admin_deposit': 'Deposit admin',
'finance.remark.admin_withdraw': 'Pengeluaran admin', 'finance.remark.admin_withdraw': 'Pengeluaran admin',
'finance.remark.initial_balance': 'Baki permulaan akaun', '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.col.no_records': 'Tiada rekod',
'agent.btn.confirm_adjust': 'Sahkan', 'agent.btn.confirm_adjust': 'Sahkan',
'agent.field.select_user': 'Pilih pengguna', 'agent.field.select_user': 'Pilih pengguna',
@@ -278,6 +281,8 @@ export const adminPagesMs: Record<string, string> = {
'match.hint.create_league': 'Kejohanan baharu tidak diterbitkan secara lalai; terbitkan untuk paparan pemain, kemudian kembangkan untuk tambah perlawanan.', '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.PUBLISHED': 'Diterbitkan',
'league.status.UNPUBLISHED': 'Tidak 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.btn.unpublish': 'Nyahterbit',
'league.confirm_unpublish': 'Pemain tidak lagi melihat kejohanan ini; anda masih boleh edit dan terbitkan semula di admin. Teruskan?', 'league.confirm_unpublish': 'Pemain tidak lagi melihat kejohanan ini; anda masih boleh edit dan terbitkan semula di admin. Teruskan?',
'msg.league_published': 'Kejohanan diterbitkan', 'msg.league_published': 'Kejohanan diterbitkan',

View File

@@ -237,6 +237,9 @@ export const adminPagesZh: Record<string, string> = {
'finance.remark.admin_deposit': '管理员上分', 'finance.remark.admin_deposit': '管理员上分',
'finance.remark.admin_withdraw': '管理员下分', 'finance.remark.admin_withdraw': '管理员下分',
'finance.remark.initial_balance': '开户初始余额', 'finance.remark.initial_balance': '开户初始余额',
'finance.remark.revoke_deposit': '撤销已通过充值 {orderNo}',
'finance.remark.deposit_order': '充值订单 {orderNo}',
'finance.remark.cashback_batch': '返水批次 {batchNo}',
'agent.col.no_records': '暂无记录', 'agent.col.no_records': '暂无记录',
'agent.btn.confirm_adjust': '确认调整', 'agent.btn.confirm_adjust': '确认调整',
'agent.field.select_user': '选择用户', 'agent.field.select_user': '选择用户',
@@ -295,6 +298,8 @@ export const adminPagesZh: Record<string, string> = {
'match.hint.create_league': '新建联赛默认为未发布,请在列表点击「发布」后玩家端可见;展开该行可添加单场。', 'match.hint.create_league': '新建联赛默认为未发布,请在列表点击「发布」后玩家端可见;展开该行可添加单场。',
'league.status.PUBLISHED': '已发布', 'league.status.PUBLISHED': '已发布',
'league.status.UNPUBLISHED': '未发布', 'league.status.UNPUBLISHED': '未发布',
'league.status.OUTRIGHT_SETTLED': '赛事已结算',
'league.hint.outright_settled_no_fixture': '优胜赛已结算,不可再新增单场。',
'league.btn.unpublish': '下架', 'league.btn.unpublish': '下架',
'league.confirm_unpublish': '下架后玩家端将不再展示该联赛,管理端仍可编辑与重新发布。是否继续?', 'league.confirm_unpublish': '下架后玩家端将不再展示该联赛,管理端仍可编辑与重新发布。是否继续?',
'msg.league_published': '联赛已发布', 'msg.league_published': '联赛已发布',
@@ -1336,6 +1341,9 @@ export const adminPagesEn: Record<string, string> = {
'finance.remark.admin_deposit': 'Admin deposit', 'finance.remark.admin_deposit': 'Admin deposit',
'finance.remark.admin_withdraw': 'Admin withdraw', 'finance.remark.admin_withdraw': 'Admin withdraw',
'finance.remark.initial_balance': 'Initial account balance', '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.col.no_records': 'No records',
'agent.btn.confirm_adjust': 'Confirm', 'agent.btn.confirm_adjust': 'Confirm',
'agent.field.select_user': 'Select user', 'agent.field.select_user': 'Select user',
@@ -1394,6 +1402,8 @@ export const adminPagesEn: Record<string, string> = {
'match.hint.create_league': 'New tournaments are unpublished by default; use Publish in the list for player visibility, then expand to add fixtures.', '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.PUBLISHED': 'Published',
'league.status.UNPUBLISHED': 'Unpublished', '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.btn.unpublish': 'Unpublish',
'league.confirm_unpublish': 'Players will no longer see this tournament; you can still edit and republish in admin. Continue?', 'league.confirm_unpublish': 'Players will no longer see this tournament; you can still edit and republish in admin. Continue?',
'msg.league_published': 'Tournament published', 'msg.league_published': 'Tournament published',

View File

@@ -206,6 +206,8 @@ const adminPages: Record<string, string> = {
'agent.hierarchy.max_level': 'Max agent level', 'agent.hierarchy.max_level': 'Max agent level',
'agent.hierarchy.default_sub_credit_ratio': 'Default sub-agent credit ratio', 'agent.hierarchy.default_sub_credit_ratio': 'Default sub-agent credit ratio',
'agent.hierarchy.default_sub_credit_ratio_hint': 'When creating a sub-agent, pre-fill credit as parent available × this ratio', 'agent.hierarchy.default_sub_credit_ratio_hint': 'When creating a sub-agent, pre-fill credit as parent available × this ratio',
'agent.suspend.settings_title': 'Default agent suspend behavior',
'agent.suspend.settings_hint': 'Each suspend/unfreeze action can override these; used as dialog defaults',
'agent.hierarchy.create_credit_default_hint': 'Default {ratio}% ({amount}), capped by parent available credit; adjustable', 'agent.hierarchy.create_credit_default_hint': 'Default {ratio}% ({amount}), capped by parent available credit; adjustable',
'agent.hierarchy.create_credit_quick_hint': 'Parent available {amount} — click a ratio to fill', 'agent.hierarchy.create_credit_quick_hint': 'Parent available {amount} — click a ratio to fill',
'agent.hierarchy.create_level_hint': 'Will be created as level {n} agent', 'agent.hierarchy.create_level_hint': 'Will be created as level {n} agent',
@@ -240,6 +242,9 @@ const adminPages: Record<string, string> = {
'finance.remark.admin_deposit': 'Admin deposit', 'finance.remark.admin_deposit': 'Admin deposit',
'finance.remark.admin_withdraw': 'Admin withdraw', 'finance.remark.admin_withdraw': 'Admin withdraw',
'finance.remark.initial_balance': 'Initial account balance', '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.col.no_records': 'No records',
'agent.btn.confirm_adjust': 'Confirm', 'agent.btn.confirm_adjust': 'Confirm',
'agent.field.select_user': 'Select user', 'agent.field.select_user': 'Select user',
@@ -298,6 +303,8 @@ const adminPages: Record<string, string> = {
'match.hint.create_league': 'New tournaments are unpublished by default; use Publish in the list for player visibility, then expand to add fixtures.', '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.PUBLISHED': 'Published',
'league.status.UNPUBLISHED': 'Unpublished', '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.btn.unpublish': 'Unpublish',
'league.confirm_unpublish': 'Players will no longer see this tournament; you can still edit and republish in admin. Continue?', 'league.confirm_unpublish': 'Players will no longer see this tournament; you can still edit and republish in admin. Continue?',
'msg.league_published': 'Tournament published', 'msg.league_published': 'Tournament published',
@@ -885,6 +892,10 @@ const adminPages: Record<string, string> = {
'content.inbox_notify.inbox_enabled_hint': 'When off, the player hub opens support only (no mailbox tab)', 'content.inbox_notify.inbox_enabled_hint': 'When off, the player hub opens support only (no mailbox tab)',
'content.inbox_notify.deposit': 'Deposit results', 'content.inbox_notify.deposit': 'Deposit results',
'content.inbox_notify.deposit_hint': 'Auto-send inbox messages on approve or reject', 'content.inbox_notify.deposit_hint': 'Auto-send inbox messages on approve or reject',
'content.inbox_notify.banner': 'Homepage promo',
'content.inbox_notify.banner_hint': 'Auto-broadcast when publishing a banner with inbox notify checked',
'content.inbox_notify.announcement': 'Announcements / ticker',
'content.inbox_notify.announcement_hint': 'Auto-broadcast when publishing notice/ticker with inbox notify checked',
'content.inbox_notify.manual_title': 'Check "Inbox notify" when creating:', 'content.inbox_notify.manual_title': 'Check "Inbox notify" when creating:',
'content.inbox_notify.banner_note': 'Homepage promo', 'content.inbox_notify.banner_note': 'Homepage promo',
'content.inbox_notify.announcement_note': 'Announcements / ticker', 'content.inbox_notify.announcement_note': 'Announcements / ticker',

View File

@@ -207,6 +207,8 @@ const adminPages: Record<string, string> = {
'agent.hierarchy.max_level': '最大代理层级', 'agent.hierarchy.max_level': '最大代理层级',
'agent.hierarchy.default_sub_credit_ratio': '下级默认授信比例', 'agent.hierarchy.default_sub_credit_ratio': '下级默认授信比例',
'agent.hierarchy.default_sub_credit_ratio_hint': '创建下级代理时,授信额度默认预填为上级可用授信 × 此比例', 'agent.hierarchy.default_sub_credit_ratio_hint': '创建下级代理时,授信额度默认预填为上级可用授信 × 此比例',
'agent.suspend.settings_title': '停用代理默认行为',
'agent.suspend.settings_hint': '单次停用/解冻操作仍可单独勾选覆盖;此处为对话框默认勾选状态',
'agent.hierarchy.create_credit_default_hint': '默认 {ratio}%{amount}),不超过上级可用授信,可手动调整', 'agent.hierarchy.create_credit_default_hint': '默认 {ratio}%{amount}),不超过上级可用授信,可手动调整',
'agent.hierarchy.create_credit_quick_hint': '上级可用授信 {amount},点击比例快速填入', 'agent.hierarchy.create_credit_quick_hint': '上级可用授信 {amount},点击比例快速填入',
'agent.hierarchy.create_level_hint': '将创建为 {n} 级代理', 'agent.hierarchy.create_level_hint': '将创建为 {n} 级代理',
@@ -241,6 +243,9 @@ const adminPages: Record<string, string> = {
'finance.remark.admin_deposit': '管理员上分', 'finance.remark.admin_deposit': '管理员上分',
'finance.remark.admin_withdraw': '管理员下分', 'finance.remark.admin_withdraw': '管理员下分',
'finance.remark.initial_balance': '开户初始余额', 'finance.remark.initial_balance': '开户初始余额',
'finance.remark.revoke_deposit': '撤销已通过充值 {orderNo}',
'finance.remark.deposit_order': '充值订单 {orderNo}',
'finance.remark.cashback_batch': '返水批次 {batchNo}',
'agent.col.no_records': '暂无记录', 'agent.col.no_records': '暂无记录',
'agent.btn.confirm_adjust': '确认调整', 'agent.btn.confirm_adjust': '确认调整',
'agent.field.select_user': '选择用户', 'agent.field.select_user': '选择用户',
@@ -299,6 +304,8 @@ const adminPages: Record<string, string> = {
'match.hint.create_league': '新建联赛默认为未发布,请在列表点击「发布」后玩家端可见;展开该行可添加单场。', 'match.hint.create_league': '新建联赛默认为未发布,请在列表点击「发布」后玩家端可见;展开该行可添加单场。',
'league.status.PUBLISHED': '已发布', 'league.status.PUBLISHED': '已发布',
'league.status.UNPUBLISHED': '未发布', 'league.status.UNPUBLISHED': '未发布',
'league.status.OUTRIGHT_SETTLED': '赛事已结算',
'league.hint.outright_settled_no_fixture': '优胜赛已结算,不可再新增单场。',
'league.btn.unpublish': '下架', 'league.btn.unpublish': '下架',
'league.confirm_unpublish': '下架后玩家端将不再展示该联赛,管理端仍可编辑与重新发布。是否继续?', 'league.confirm_unpublish': '下架后玩家端将不再展示该联赛,管理端仍可编辑与重新发布。是否继续?',
'msg.league_published': '联赛已发布', 'msg.league_published': '联赛已发布',
@@ -882,6 +889,10 @@ const adminPages: Record<string, string> = {
'content.inbox_notify.inbox_enabled_hint': '关闭后玩家端入口直达客服,不展示邮箱标签页', 'content.inbox_notify.inbox_enabled_hint': '关闭后玩家端入口直达客服,不展示邮箱标签页',
'content.inbox_notify.deposit': '充值结果', 'content.inbox_notify.deposit': '充值结果',
'content.inbox_notify.deposit_hint': '审核通过或拒绝时自动发送站内信', 'content.inbox_notify.deposit_hint': '审核通过或拒绝时自动发送站内信',
'content.inbox_notify.banner': '首页推广',
'content.inbox_notify.banner_hint': '发布 Banner 且勾选邮箱通知时自动群发',
'content.inbox_notify.announcement': '公告 / 跑马灯',
'content.inbox_notify.announcement_hint': '发布通知或跑马灯且勾选邮箱通知时自动群发',
'content.inbox_notify.manual_title': '以下类型在新建时勾选「邮箱通知」', 'content.inbox_notify.manual_title': '以下类型在新建时勾选「邮箱通知」',
'content.inbox_notify.banner_note': '首页推广', 'content.inbox_notify.banner_note': '首页推广',
'content.inbox_notify.announcement_note': '通知公告 / 跑马灯', 'content.inbox_notify.announcement_note': '通知公告 / 跑马灯',

View File

@@ -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;
}

View File

@@ -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',
});
}

View File

@@ -1,3 +1,5 @@
export { txDisplayAmount } from '@thebet365/shared';
export const TX_KEY_MAP: Record<string, string> = { export const TX_KEY_MAP: Record<string, string> = {
MANUAL_DEPOSIT: 'finance.tx.deposit', MANUAL_DEPOSIT: 'finance.tx.deposit',
ADMIN_DEPOSIT: 'finance.tx.admin_deposit', ADMIN_DEPOSIT: 'finance.tx.admin_deposit',
@@ -64,3 +66,45 @@ export function walletDepositMethodLabel(
if (type === 'PLAYER_DEPOSIT') return t('finance.tx.player_deposit'); if (type === 'PLAYER_DEPOSIT') return t('finance.tx.player_deposit');
return '—'; return '—';
} }
const WALLET_REMARK_EXACT: Record<string, string> = {
'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, unknown>) => 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;
}

View File

@@ -8,8 +8,9 @@ import { useAdminLocale } from '../composables/useAdminLocale';
import api from '../api'; import api from '../api';
import AdminTableEmpty from '../components/AdminTableEmpty.vue'; import AdminTableEmpty from '../components/AdminTableEmpty.vue';
import { formatAmount, formatAmountFull } from '../utils/format-amount'; import { formatAmount, formatAmountFull } from '../utils/format-amount';
import { walletTxTypeKey } from '../utils/walletTx'; import { formatAdminDateTimeBrief, formatAdminDateTimeFull } from '../utils/format-datetime';
const { t, locale, localeTag } = useAdminLocale(); import { walletTxTypeKey, walletRemarkLabel } from '../utils/walletTx';
const { t, locale } = useAdminLocale();
const auth = useAuthStore(); const auth = useAuthStore();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -84,36 +85,8 @@ function transferTypeLabel(type: string) {
return key ? t(key) : type; return key ? t(key) : type;
} }
const TRANSFER_REMARK_KEYS: Record<string, string> = {
'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) { function transferRemarkLabel(remark: string | null | undefined, transactionType: string) {
const raw = remark?.trim(); return walletRemarkLabel(remark, transactionType, t);
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',
});
} }
function dateParams() { function dateParams() {
@@ -339,8 +312,12 @@ watch(
<AdminTableEmpty /> <AdminTableEmpty />
</template> </template>
<el-table-column type="index" :index="(i: number) => (creditPage - 1) * creditPageSize + i + 1" :label="t('common.seq')" width="70" align="center" /> <el-table-column type="index" :index="(i: number) => (creditPage - 1) * creditPageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
<el-table-column :label="t('audit.col.time')" min-width="158"> <el-table-column :label="t('audit.col.time')" min-width="88">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template> <template #default="{ row }">
<el-tooltip :content="formatAdminDateTimeFull(row.createdAt, locale)" placement="top">
<span>{{ formatAdminDateTimeBrief(row.createdAt, locale) }}</span>
</el-tooltip>
</template>
</el-table-column> </el-table-column>
<el-table-column :label="t('user.col.username')" min-width="110"> <el-table-column :label="t('user.col.username')" min-width="110">
<template #default="{ row }"> <template #default="{ row }">
@@ -410,8 +387,12 @@ watch(
<AdminTableEmpty /> <AdminTableEmpty />
</template> </template>
<el-table-column type="index" :index="(i: number) => (transferPage - 1) * transferPageSize + i + 1" :label="t('common.seq')" width="70" align="center" /> <el-table-column type="index" :index="(i: number) => (transferPage - 1) * transferPageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
<el-table-column :label="t('audit.col.time')" min-width="158"> <el-table-column :label="t('audit.col.time')" min-width="88">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template> <template #default="{ row }">
<el-tooltip :content="formatAdminDateTimeFull(row.createdAt, locale)" placement="top">
<span>{{ formatAdminDateTimeBrief(row.createdAt, locale) }}</span>
</el-tooltip>
</template>
</el-table-column> </el-table-column>
<el-table-column :label="t('finance.col.tx_id')" min-width="130" show-overflow-tooltip> <el-table-column :label="t('finance.col.tx_id')" min-width="130" show-overflow-tooltip>
<template #default="{ row }">{{ row.transactionId }}</template> <template #default="{ row }">{{ row.transactionId }}</template>

View File

@@ -3,6 +3,7 @@ import { ref, computed, watch, onBeforeUnmount, onDeactivated } from 'vue';
defineOptions({ name: 'AdminMatches' }); defineOptions({ name: 'AdminMatches' });
import { useStaleListLifecycle } from '../composables/useStaleList'; import { useStaleListLifecycle } from '../composables/useStaleList';
import { consumeAdminListStale } from '../utils/adminListStale';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { useAdminLocale } from '../composables/useAdminLocale'; import { useAdminLocale } from '../composables/useAdminLocale';
import { resolveFormError } from '../i18n/form-validation'; import { resolveFormError } from '../i18n/form-validation';
@@ -38,6 +39,7 @@ const isMatchChildRoute = computed(() =>
interface LeagueTableRow extends Record<string, unknown> { interface LeagueTableRow extends Record<string, unknown> {
id: string; id: string;
isPublished: boolean; isPublished: boolean;
isOutrightSettled: boolean;
isPublishing: boolean; isPublishing: boolean;
labels: { labels: {
edit: string; edit: string;
@@ -50,7 +52,7 @@ interface LeagueTableRow extends Record<string, unknown> {
displayNameZh: string; displayNameZh: string;
displayNameEn: string; displayNameEn: string;
displayStatusLabel: string; displayStatusLabel: string;
displayStatusTagType: 'success' | 'info'; displayStatusTagType: 'success' | 'info' | 'warning';
displayMatchCount: number; displayMatchCount: number;
displayBetCount: number; displayBetCount: number;
displayBetCountActive: boolean; displayBetCountActive: boolean;
@@ -121,6 +123,7 @@ function mapLeagueRows(items: unknown[], publishingId = ''): LeagueTableRow[] {
const r = rowOf(item); const r = rowOf(item);
const id = String(r.id ?? ''); const id = String(r.id ?? '');
const published = Boolean(r.isPublished); const published = Boolean(r.isPublished);
const outrightSettled = Boolean(r.isOutrightSettled);
const stats = r.betStats as const stats = r.betStats as
| { betCount?: number; totalStake?: string; pendingCount?: number } | { betCount?: number; totalStake?: string; pendingCount?: number }
| undefined; | undefined;
@@ -129,13 +132,18 @@ function mapLeagueRows(items: unknown[], publishingId = ''): LeagueTableRow[] {
...r, ...r,
id, id,
isPublished: published, isPublished: published,
isOutrightSettled: outrightSettled,
isPublishing: publishingId === id, isPublishing: publishingId === id,
labels, labels,
displaySeq: start + index + 1, displaySeq: start + index + 1,
displayNameZh: String(r.leagueZh ?? '').trim() || '—', displayNameZh: String(r.leagueZh ?? '').trim() || '—',
displayNameEn: String(r.leagueEn ?? '').trim() || '—', displayNameEn: String(r.leagueEn ?? '').trim() || '—',
displayStatusLabel: published ? t('league.status.PUBLISHED') : t('league.status.UNPUBLISHED'), displayStatusLabel: outrightSettled
displayStatusTagType: published ? 'success' : 'info', ? 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), displayMatchCount: Number(r.matchCount ?? 0),
displayBetCount: betCount, displayBetCount: betCount,
displayBetCountActive: betCount > 0, displayBetCountActive: betCount > 0,
@@ -163,6 +171,7 @@ function persistListUiState() {
type LoadOptions = { restore?: boolean }; type LoadOptions = { restore?: boolean };
async function load(options: LoadOptions = {}) { async function load(options: LoadOptions = {}) {
consumeAdminListStale();
const saved = options.restore ? readMatchesListUiState() : null; const saved = options.restore ? readMatchesListUiState() : null;
if (saved) { if (saved) {
page.value = saved.page; page.value = saved.page;
@@ -189,6 +198,17 @@ function onSearch() {
void runLoad(true); 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() { async function initialLoad() {
if (isMatchChildRoute.value) return; if (isMatchChildRoute.value) return;
const qStatus = route.query.status; const qStatus = route.query.status;

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onBeforeUnmount, onDeactivated } from 'vue'; import { ref, computed, onBeforeUnmount, onDeactivated, watch } from 'vue';
import { consumeAdminListStale } from '../utils/adminListStale';
defineOptions({ name: 'AdminMatchesOutrights' }); defineOptions({ name: 'AdminMatchesOutrights' });
import { useStaleListLifecycle } from '../composables/useStaleList'; import { useStaleListLifecycle } from '../composables/useStaleList';
@@ -38,6 +39,7 @@ function persistListUiState() {
type LoadOptions = { restore?: boolean }; type LoadOptions = { restore?: boolean };
async function load(options: LoadOptions = {}) { async function load(options: LoadOptions = {}) {
consumeAdminListStale();
const saved = options.restore ? readMatchesListUiState() : null; const saved = options.restore ? readMatchesListUiState() : null;
if (saved) { if (saved) {
page.value = saved.page; page.value = saved.page;
@@ -92,6 +94,17 @@ async function initialLoad() {
await resolveLeagueFromQuery(); await resolveLeagueFromQuery();
} }
const OUTRIGHT_CHILD_ROUTE = /^\/matches\/outrights\/leagues\/[^/]+/;
watch(
() => route.path,
(path, prevPath) => {
if (prevPath && OUTRIGHT_CHILD_ROUTE.test(prevPath) && path === '/matches/outrights') {
void load();
}
},
);
const { loading: listLoading, runLoad } = useStaleListLifecycle(() => leagues.value.length > 0, initialLoad); const { loading: listLoading, runLoad } = useStaleListLifecycle(() => leagues.value.length > 0, initialLoad);
onBeforeUnmount(persistListUiState); onBeforeUnmount(persistListUiState);
onDeactivated(persistListUiState); onDeactivated(persistListUiState);
@@ -146,6 +159,9 @@ function leagueTitle(row: unknown) {
function outrightTeamCount(row: unknown) { function outrightTeamCount(row: unknown) {
return Number(rowOf(row).outrightTeamCount ?? 0); return Number(rowOf(row).outrightTeamCount ?? 0);
} }
function outrightSettled(row: unknown) {
return Boolean(rowOf(row).isOutrightSettled);
}
</script> </script>
<template> <template>
@@ -204,6 +220,14 @@ function outrightTeamCount(row: unknown) {
<span class="league-en">{{ leagueNameEn(row) }}</span> <span class="league-en">{{ leagueNameEn(row) }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="t('common.status')" width="108" align="center">
<template #default="{ row }">
<el-tag v-if="outrightSettled(row)" size="small" type="warning" effect="plain">
{{ t('league.status.OUTRIGHT_SETTLED') }}
</el-tag>
<span v-else class="status-dash"></span>
</template>
</el-table-column>
<el-table-column :label="t('outright.col.teams_total')" width="120" align="center"> <el-table-column :label="t('outright.col.teams_total')" width="120" align="center">
<template #default="{ row }">{{ outrightTeamCount(row) }}</template> <template #default="{ row }">{{ outrightTeamCount(row) }}</template>
</el-table-column> </el-table-column>
@@ -270,4 +294,7 @@ function outrightTeamCount(row: unknown) {
color: var(--text-muted); color: var(--text-muted);
font-size: 13px; font-size: 13px;
} }
.status-dash {
color: var(--text-muted);
}
</style> </style>

View File

@@ -23,7 +23,7 @@ import {
betTypeLabel, betTypeLabel,
betResultLabel, betResultLabel,
} from '../utils/bet-labels'; } from '../utils/bet-labels';
import { adminSelectionLabel } from '../utils/adminSelectionLabel'; import { markAdminListStale } from '../utils/adminListStale';
import type { AdminMatchDetail } from './match-form'; import type { AdminMatchDetail } from './match-form';
import AdminSubNav from '../components/AdminSubNav.vue'; import AdminSubNav from '../components/AdminSubNav.vue';
@@ -658,6 +658,7 @@ async function confirmResettle() {
resettleDialogVisible.value = false; resettleDialogVisible.value = false;
await loadMatch(); await loadMatch();
void loadSettlementHistory(); void loadSettlementHistory();
markAdminListStale();
} catch (e: unknown) { } catch (e: unknown) {
ElMessage.error(settlementApiError(e, t('settlement.preview_failed'))); ElMessage.error(settlementApiError(e, t('settlement.preview_failed')));
} finally { } finally {
@@ -726,6 +727,7 @@ async function previewSettlement() {
previewPageSize.value = itemsPage.pageSize; previewPageSize.value = itemsPage.pageSize;
previewDialogVisible.value = true; previewDialogVisible.value = true;
await loadMatch(); await loadMatch();
markAdminListStale();
} catch (e: unknown) { } catch (e: unknown) {
ElMessage.error(settlementApiError(e, t('settlement.preview_failed'))); ElMessage.error(settlementApiError(e, t('settlement.preview_failed')));
} finally { } finally {
@@ -754,6 +756,7 @@ async function confirm() {
previewDialogVisible.value = false; previewDialogVisible.value = false;
await loadMatch(); await loadMatch();
void loadSettlementHistory(); void loadSettlementHistory();
markAdminListStale();
} catch (e: unknown) { } catch (e: unknown) {
ElMessage.error(settlementApiError(e, t('settlement.preview_failed'))); ElMessage.error(settlementApiError(e, t('settlement.preview_failed')));
} finally { } finally {

View File

@@ -34,9 +34,15 @@ const leagueTitle = computed(() => {
const panelRef = ref<{ reload: () => void } | null>(null); const panelRef = ref<{ reload: () => void } | null>(null);
const createVisible = ref(false); const createVisible = ref(false);
const createLoading = ref(false); const createLoading = ref(false);
const isOutrightSettled = ref(false);
const form = ref<MatchCreateForm>(emptyMatchForm()); const form = ref<MatchCreateForm>(emptyMatchForm());
function onLeagueMeta(meta: { isOutrightSettled: boolean }) {
isOutrightSettled.value = meta.isOutrightSettled;
}
function openCreateFixture() { function openCreateFixture() {
if (isOutrightSettled.value) return;
form.value = emptyMatchForm(); form.value = emptyMatchForm();
form.value.leagueId = leagueId.value; form.value.leagueId = leagueId.value;
createVisible.value = true; createVisible.value = true;
@@ -85,9 +91,10 @@ watch(leagueId, () => {
:subtitle="t('match.league_fixtures_subtitle')" :subtitle="t('match.league_fixtures_subtitle')"
> >
<template #extra> <template #extra>
<el-button type="primary" @click="openCreateFixture"> <el-button v-if="!isOutrightSettled" type="primary" @click="openCreateFixture">
{{ t('match.create_fixture_btn') }} {{ t('match.create_fixture_btn') }}
</el-button> </el-button>
<span v-else class="settled-hint">{{ t('league.hint.outright_settled_no_fixture') }}</span>
</template> </template>
</AdminSubNav> </AdminSubNav>
@@ -97,6 +104,7 @@ watch(leagueId, () => {
:league-id="leagueId" :league-id="leagueId"
:filter-status="filterStatus" :filter-status="filterStatus"
:keyword="filterKeyword" :keyword="filterKeyword"
@league-meta="onLeagueMeta"
/> />
</section> </section>
@@ -254,6 +262,12 @@ watch(leagueId, () => {
font-weight: 700; font-weight: 700;
} }
.settled-hint {
font-size: 13px;
color: var(--warning-text);
font-weight: 600;
}
@media (max-width: 760px) { @media (max-width: 760px) {
.teams-row { .teams-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, h, defineAsyncComponent } from 'vue'; import { ref, watch, h, defineAsyncComponent, onActivated } from 'vue';
import { consumeAdminListStale } from '../../utils/adminListStale';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox, ElDatePicker } from 'element-plus'; import { ElMessage, ElMessageBox, ElDatePicker } from 'element-plus';
import { useAdminLocale } from '../../composables/useAdminLocale'; import { useAdminLocale } from '../../composables/useAdminLocale';
@@ -32,6 +33,7 @@ const props = withDefaults(
const emit = defineEmits<{ const emit = defineEmits<{
changed: []; changed: [];
'add-match': []; 'add-match': [];
'league-meta': [meta: { isOutrightSettled: boolean }];
}>(); }>();
const { t, locale } = useAdminLocale(); const { t, locale } = useAdminLocale();
@@ -99,8 +101,8 @@ function resetFilters() {
onFilterChange(); onFilterChange();
} }
async function load() { async function load(options: { silent?: boolean } = {}) {
loading.value = true; if (!options.silent) loading.value = true;
try { try {
const { data } = await api.get(`/admin/leagues/${props.leagueId}/matches`, { const { data } = await api.get(`/admin/leagues/${props.leagueId}/matches`, {
params: { params: {
@@ -124,19 +126,31 @@ async function load() {
total: number; total: number;
page: number; page: number;
pageSize: number; pageSize: number;
league?: { isOutrightSettled?: boolean };
}; };
matches.value = payload.items; matches.value = payload.items;
matchTotal.value = payload.total; matchTotal.value = payload.total;
emit('league-meta', {
isOutrightSettled: Boolean(payload.league?.isOutrightSettled),
});
matchPage.value = payload.page; matchPage.value = payload.page;
matchPageSize.value = payload.pageSize; matchPageSize.value = payload.pageSize;
} catch (e: unknown) { } catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } }; const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_matches_failed')); ElMessage.error(err.response?.data?.error ?? t('msg.load_matches_failed'));
} finally { } finally {
loading.value = false; if (!options.silent) loading.value = false;
} }
} }
onActivated(() => {
if (!props.leagueId) return;
const stale = consumeAdminListStale();
if (stale || matches.value.length > 0) {
void load({ silent: !stale && matches.value.length > 0 });
}
});
function scheduleLoad(resetPage = false) { function scheduleLoad(resetPage = false) {
if (!props.leagueId) return; if (!props.leagueId) return;
if (resetPage) matchPage.value = 1; if (resetPage) matchPage.value = 1;

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue'; import { computed, ref, watch, onActivated } from 'vue';
import { consumeAdminListStale } from '../../utils/adminListStale';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus'; import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../../composables/useAdminLocale'; import { useAdminLocale } from '../../composables/useAdminLocale';
@@ -237,9 +238,9 @@ function goSettle() {
}); });
} }
async function load() { async function load(options: { silent?: boolean } = {}) {
if (!props.leagueId) return; if (!props.leagueId) return;
loading.value = true; if (!options.silent) loading.value = true;
try { try {
const { data } = await api.get(`/admin/leagues/${props.leagueId}/outright`); const { data } = await api.get(`/admin/leagues/${props.leagueId}/outright`);
const payload = data.data as { const payload = data.data as {
@@ -283,10 +284,18 @@ async function load() {
const err = e as { response?: { data?: { error?: string } } }; const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed')); ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
} finally { } finally {
loading.value = false; if (!options.silent) loading.value = false;
} }
} }
onActivated(() => {
if (!props.leagueId) return;
const stale = consumeAdminListStale();
if (stale || matchId.value || selections.value.length > 0) {
void load({ silent: !stale && Boolean(matchId.value || selections.value.length) });
}
});
function resetCustomTeamForm() { function resetCustomTeamForm() {
customTeam.value = { teamCode: '', teamZh: '', teamEn: '', logoUrl: '' }; customTeam.value = { teamCode: '', teamZh: '', teamEn: '', logoUrl: '' };
} }

View File

@@ -306,7 +306,7 @@ describe('MatchesService listAdminLeagueMatches', () => {
const leagueId = BigInt(1); const leagueId = BigInt(1);
let prisma: { let prisma: {
match: { findMany: jest.Mock; count: jest.Mock }; match: { findMany: jest.Mock; findFirst: jest.Mock; count: jest.Mock };
entityTranslation: { findFirst: jest.Mock; findMany: jest.Mock }; entityTranslation: { findFirst: jest.Mock; findMany: jest.Mock };
}; };
let matchBetStats: { betStatsForMatches: jest.Mock }; let matchBetStats: { betStatsForMatches: jest.Mock };
@@ -314,7 +314,11 @@ describe('MatchesService listAdminLeagueMatches', () => {
beforeEach(() => { beforeEach(() => {
prisma = { prisma = {
match: { findMany: jest.fn(), count: jest.fn() }, match: {
findMany: jest.fn(),
findFirst: jest.fn().mockResolvedValue(null),
count: jest.fn(),
},
entityTranslation: { entityTranslation: {
findFirst: jest.fn().mockResolvedValue(null), findFirst: jest.fn().mockResolvedValue(null),
findMany: jest.fn().mockResolvedValue([]), findMany: jest.fn().mockResolvedValue([]),
@@ -387,3 +391,38 @@ describe('MatchesService listAdminLeagueMatches', () => {
expect(result.items[1].id).toBe('11'); expect(result.items[1].id).toBe('11');
}); });
}); });
describe('MatchesService createMatch outright guard', () => {
const leagueId = BigInt(1);
it('rejects fixture creation when outright is settled', async () => {
const prisma = {
match: { create: jest.fn() },
};
const outright = {
assertLeagueAllowsNewFixtures: jest.fn().mockRejectedValue(
Object.assign(new Error('LEAGUE_OUTRIGHT_SETTLED'), {
response: { code: 'LEAGUE_OUTRIGHT_SETTLED' },
}),
),
};
const service = new MatchesService(
prisma as never,
outright as never,
{ betStatsForMatches: jest.fn() } as never,
);
await expect(
service.createMatch({
leagueId,
homeTeamId: BigInt(10),
awayTeamId: BigInt(11),
startTime: new Date('2026-06-01T12:00:00Z'),
}),
).rejects.toMatchObject({
response: expect.objectContaining({ code: 'LEAGUE_OUTRIGHT_SETTLED' }),
});
expect(outright.assertLeagueAllowsNewFixtures).toHaveBeenCalledWith(leagueId);
expect(prisma.match.create).not.toHaveBeenCalled();
});
});

View File

@@ -105,6 +105,7 @@ export class MatchesService {
externalStatus: string; externalStatus: string;
}>; }>;
}) { }) {
await this.outright.assertLeagueAllowsNewFixtures(data.leagueId);
const status = data.status ?? 'DRAFT'; const status = data.status ?? 'DRAFT';
return this.prisma.match.create({ return this.prisma.match.create({
data: { data: {
@@ -492,8 +493,11 @@ export class MatchesService {
isOutright: true, isOutright: true,
deletedAt: null, deletedAt: null,
}, },
select: { id: true, leagueId: true }, select: { id: true, leagueId: true, status: true },
}); });
const outrightStatusByLeague = new Map(
outrightMatches.map((m) => [m.leagueId.toString(), m.status]),
);
const outrightTeamCounts = new Map<string, number>(); const outrightTeamCounts = new Map<string, number>();
if (outrightMatches.length > 0) { if (outrightMatches.length > 0) {
const matchIdToLeagueId = new Map( const matchIdToLeagueId = new Map(
@@ -543,6 +547,10 @@ export class MatchesService {
fixtureTeamSets.get(item.id)?.size ?? 0; fixtureTeamSets.get(item.id)?.size ?? 0;
(item as { outrightTeamCount?: number }).outrightTeamCount = (item as { outrightTeamCount?: number }).outrightTeamCount =
outrightTeamCounts.get(item.id) ?? 0; outrightTeamCounts.get(item.id) ?? 0;
const outrightStatus = outrightStatusByLeague.get(item.id) ?? null;
(item as { outrightStatus?: string | null }).outrightStatus = outrightStatus;
(item as { isOutrightSettled?: boolean }).isOutrightSettled =
outrightStatus === 'SETTLED';
} }
return { items, total, page: opts.page, pageSize: opts.pageSize }; return { items, total, page: opts.page, pageSize: opts.pageSize };
@@ -562,6 +570,16 @@ export class MatchesService {
startTo?: Date; startTo?: Date;
}, },
) { ) {
const outrightMatch = await this.prisma.match.findFirst({
where: { leagueId, isOutright: true, deletedAt: null },
select: { status: true },
orderBy: { id: 'asc' },
});
const leagueMeta = {
outrightStatus: outrightMatch?.status ?? null,
isOutrightSettled: outrightMatch?.status === 'SETTLED',
};
const where: Prisma.MatchWhereInput = { const where: Prisma.MatchWhereInput = {
leagueId, leagueId,
deletedAt: null, deletedAt: null,
@@ -648,7 +666,7 @@ export class MatchesService {
const total = filteredItems.length; const total = filteredItems.length;
const paginatedItems = filteredItems.slice((page - 1) * pageSize, page * pageSize); const paginatedItems = filteredItems.slice((page - 1) * pageSize, page * pageSize);
return { items: paginatedItems, total, page, pageSize }; return { items: paginatedItems, total, page, pageSize, league: leagueMeta };
} }
const orderBy = const orderBy =
@@ -698,10 +716,8 @@ export class MatchesService {
}; };
}), }),
); );
return { items, total, page, pageSize }; return { items, total, page, pageSize, league: leagueMeta };
} }
/** 批量汇总多场关联注单(按 bet 去重计注单数) */
async betStatsForMatches( async betStatsForMatches(
matchIds: bigint[], matchIds: bigint[],
): Promise<Map<string, MatchBetStatsSummary>> { ): Promise<Map<string, MatchBetStatsSummary>> {

View File

@@ -259,6 +259,18 @@ export class OutrightService {
await this.syncOutrightStatusWithLeague(existing, league); await this.syncOutrightStatusWithLeague(existing, league);
} }
/** 优胜赛(冠军盘)已结算时禁止再新增单场 */
async assertLeagueAllowsNewFixtures(leagueId: bigint) {
const outright = await this.prisma.match.findFirst({
where: { leagueId, isOutright: true, deletedAt: null },
select: { status: true },
orderBy: { id: 'asc' },
});
if (outright?.status === 'SETTLED') {
throw appBadRequest('LEAGUE_OUTRIGHT_SETTLED');
}
}
/** 联赛下尚未结算/取消的单场数量(不含冠军盘) */ /** 联赛下尚未结算/取消的单场数量(不含冠军盘) */
async countUnsettledLeagueFixtures(leagueId: bigint): Promise<number> { async countUnsettledLeagueFixtures(leagueId: bigint): Promise<number> {
return this.prisma.match.count({ return this.prisma.match.count({
@@ -288,7 +300,9 @@ export class OutrightService {
where: { leagueId, isOutright: true, deletedAt: null }, where: { leagueId, isOutright: true, deletedAt: null },
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
}); });
if (!match) return { addedCount: 0, reopenedCount: 0 }; if (!match || match.status === 'SETTLED') {
return { addedCount: 0, reopenedCount: 0 };
}
return this.syncSelectionsFromLeagueFixtures(match.id); return this.syncSelectionsFromLeagueFixtures(match.id);
} }
@@ -687,18 +701,19 @@ export class OutrightService {
const matches = await this.prisma.match.findMany({ const matches = await this.prisma.match.findMany({
where: { where: {
status: 'PUBLISHED', status: { in: ['PUBLISHED', 'SETTLED'] },
isOutright: true, isOutright: true,
sportType: 'FOOTBALL', sportType: 'FOOTBALL',
deletedAt: null, deletedAt: null,
league: { isActive: true, deletedAt: null }, league: { isActive: true, deletedAt: null },
}, },
include: { include: {
score: true,
markets: { markets: {
where: { marketType: OUTRIGHT_MARKET_TYPE, status: 'OPEN' }, where: { marketType: OUTRIGHT_MARKET_TYPE },
include: { include: {
selections: { selections: {
where: { status: 'OPEN' }, where: { selectionCode: { not: PLACEHOLDER_TEAM_CODE } },
orderBy: { sortOrder: 'asc' }, orderBy: { sortOrder: 'asc' },
}, },
}, },
@@ -716,10 +731,24 @@ export class OutrightService {
const market = match.markets[0]; const market = match.markets[0];
if (!market) continue; if (!market) continue;
const isSettled = match.status === 'SETTLED';
const visibleSelections = isSettled
? market.selections
: market.selections.filter((sel) => sel.status === 'OPEN');
if (!visibleSelections.length) continue;
let winnerTeamCode: string | null = null;
if (match.score?.winnerTeamId) {
const winner = await this.prisma.team.findUnique({
where: { id: match.score.winnerTeamId },
select: { code: true },
});
winnerTeamCode = winner?.code ?? null;
}
const selections = await Promise.all( const selections = await Promise.all(
market.selections visibleSelections.map(async (sel) => {
.filter((sel) => sel.selectionCode !== PLACEHOLDER_TEAM_CODE)
.map(async (sel) => {
const team = await this.prisma.team.findUnique({ const team = await this.prisma.team.findUnique({
where: { code: sel.selectionCode }, where: { code: sel.selectionCode },
}); });
@@ -743,12 +772,11 @@ export class OutrightService {
logoUrl: team?.logoUrl ?? null, logoUrl: team?.logoUrl ?? null,
odds: sel.odds.toString(), odds: sel.odds.toString(),
oddsVersion: sel.oddsVersion.toString(), oddsVersion: sel.oddsVersion.toString(),
isWinner: Boolean(winnerTeamCode && sel.selectionCode === winnerTeamCode),
}; };
}), }),
); );
if (!selections.length) continue;
const [titleZh, titleEn, titleMs] = await Promise.all([ const [titleZh, titleEn, titleMs] = await Promise.all([
this.getOutrightTitle(match.id, 'zh-CN'), this.getOutrightTitle(match.id, 'zh-CN'),
this.getOutrightTitle(match.id, 'en-US'), this.getOutrightTitle(match.id, 'en-US'),
@@ -764,6 +792,11 @@ export class OutrightService {
match.matchName?.trim() || match.matchName?.trim() ||
`*${leagueName || 'Outright'} ${locale.startsWith('zh') ? '冠军' : 'Winner'}`; `*${leagueName || 'Outright'} ${locale.startsWith('zh') ? '冠军' : 'Winner'}`;
const bettingOpen =
match.status === 'PUBLISHED' &&
market.status === 'OPEN' &&
market.selections.some((sel) => sel.status === 'OPEN');
results.push({ results.push({
id: match.id.toString(), id: match.id.toString(),
leagueId: match.leagueId.toString(), leagueId: match.leagueId.toString(),
@@ -771,6 +804,9 @@ export class OutrightService {
leagueName: leagueName || '', leagueName: leagueName || '',
title: title.startsWith('*') ? title : `*${title}`, title: title.startsWith('*') ? title : `*${title}`,
marketId: market.id.toString(), marketId: market.id.toString(),
status: match.status,
bettingOpen,
winnerTeamCode,
selectionCount: selections.length, selectionCount: selections.length,
selections, selections,
}); });

View File

@@ -6,6 +6,8 @@ import { formatMoneyCompact, parseAmount } from '../utils/localeDisplay';
interface Transaction { interface Transaction {
transactionType: string; transactionType: string;
amount: string; amount: string;
frozenBefore?: string;
frozenAfter?: string;
createdAt: string; createdAt: string;
transactionId?: string; transactionId?: string;
} }

View File

@@ -14,6 +14,7 @@ export interface OutrightSelection {
logoUrl?: string | null; logoUrl?: string | null;
odds: string; odds: string;
oddsVersion: string; oddsVersion: string;
isWinner?: boolean;
} }
export interface OutrightEvent { export interface OutrightEvent {
@@ -22,6 +23,8 @@ export interface OutrightEvent {
leagueCode?: string; leagueCode?: string;
leagueName: string; leagueName: string;
title: string; title: string;
status?: string;
bettingOpen?: boolean;
selectionCount?: number; selectionCount?: number;
selections: OutrightSelection[]; selections: OutrightSelection[];
} }
@@ -47,16 +50,21 @@ const headMeta = computed(() => {
const total = props.event.selectionCount ?? props.event.selections.length; const total = props.event.selectionCount ?? props.event.selections.length;
return t('bet.outright_teams_count', { n: total }); return t('bet.outright_teams_count', { n: total });
}); });
const isSettled = computed(() => props.event.bettingOpen === false || props.event.status === 'SETTLED');
</script> </script>
<template> <template>
<section class="event-block"> <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-icon" :class="{ open: expanded }">
<span class="toggle-mark">{{ expanded ? '' : '+' }}</span> <span class="toggle-mark">{{ expanded ? '' : '+' }}</span>
</span> </span>
<span class="event-head-text"> <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"> <span v-if="event.leagueName && event.leagueName !== headTitle" class="event-league">
{{ event.leagueName }} {{ event.leagueName }}
</span> </span>
@@ -74,6 +82,8 @@ const headMeta = computed(() => {
:team-name="sel.teamName" :team-name="sel.teamName"
:logo-url="sel.logoUrl" :logo-url="sel.logoUrl"
:odds="sel.odds" :odds="sel.odds"
:disabled="isSettled"
:is-winner="Boolean(sel.isWinner)"
@pick="emit('pick', sel)" @pick="emit('pick', sel)"
/> />
</div> </div>
@@ -150,6 +160,24 @@ const headMeta = computed(() => {
gap: 2px; 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: #F8971F;
border: 1px solid rgba(248, 151, 31, 0.45);
border-radius: 999px;
padding: 1px 7px;
line-height: 1.4;
}
.event-title { .event-title {
font-size: 13px; font-size: 13px;
font-weight: 800; font-weight: 800;

View File

@@ -7,6 +7,8 @@ const props = defineProps<{
teamName: string; teamName: string;
odds: string; odds: string;
logoUrl?: string | null; logoUrl?: string | null;
disabled?: boolean;
isWinner?: boolean;
}>(); }>();
const emit = defineEmits<{ pick: [] }>(); const emit = defineEmits<{ pick: [] }>();
@@ -56,7 +58,14 @@ onUnmounted(() => {
</script> </script>
<template> <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 <img
v-if="imgVisible && flag && !flagFailed" v-if="imgVisible && flag && !flagFailed"
:src="flag" :src="flag"
@@ -93,6 +102,21 @@ onUnmounted(() => {
box-shadow: 0 0 0 1px rgba(0, 61, 107, 0.12); box-shadow: 0 0 0 1px rgba(0, 61, 107, 0.12);
} }
.option-card--disabled {
cursor: default;
opacity: 0.72;
}
.option-card--disabled:active {
border-color: #E5E7EB;
box-shadow: none;
}
.option-card--winner {
border-color: rgba(248, 151, 31, 0.75);
box-shadow: 0 0 0 1px rgba(248, 151, 31, 0.25);
}
.flag { .flag {
width: 28px; width: 28px;
height: 19px; height: 19px;

View File

@@ -114,6 +114,7 @@ function toggle(id: string) {
} }
function openBet(event: OutrightEvent, sel: OutrightSelection) { function openBet(event: OutrightEvent, sel: OutrightSelection) {
if (event.bettingOpen === false || event.status === 'SETTLED') return;
if (!auth.token) { if (!auth.token) {
goLogin(); goLogin();
return; return;
@@ -144,6 +145,9 @@ function closeModal() {
<p v-if="eventCount > 1" class="panel-summary"> <p v-if="eventCount > 1" class="panel-summary">
{{ t('bet.outright_events_summary', { events: eventCount, teams: totalSelections }) }} {{ t('bet.outright_events_summary', { events: eventCount, teams: totalSelections }) }}
</p> </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"> <div class="event-list">
<OutrightEventSection <OutrightEventSection
@@ -182,6 +186,18 @@ function closeModal() {
line-height: 1.4; line-height: 1.4;
} }
.panel-settled-hint {
margin: 0 0 12px;
padding: 8px 10px;
border-radius: 8px;
background: rgba(248, 151, 31, 0.08);
border: 1px solid rgba(248, 151, 31, 0.22);
font-size: 12px;
font-weight: 600;
color: #F8971F;
line-height: 1.45;
}
.event-list { .event-list {
padding-bottom: 8px; padding-bottom: 8px;
} }

View File

@@ -370,6 +370,8 @@ export default {
outright_player_only: 'Player login required', outright_player_only: 'Player login required',
outright_shown_count: '{shown} / {total} teams shown', outright_shown_count: '{shown} / {total} teams shown',
outright_load_more: 'Load more', outright_load_more: 'Load more',
outright_settled: 'Settled',
outright_settled_hint: 'This event is settled. Odds and results are view-only.',
cancel: 'Cancel', cancel: 'Cancel',
parlay_max_legs: 'Parlay allows up to 5 legs', parlay_max_legs: 'Parlay allows up to 5 legs',
parlay_block_outright: 'Outright cannot be parlayed', parlay_block_outright: 'Outright cannot be parlayed',

View File

@@ -376,6 +376,8 @@ export default {
outright_player_only: 'Log masuk pemain diperlukan', outright_player_only: 'Log masuk pemain diperlukan',
outright_shown_count: '{shown} / {total} pasukan dipaparkan', outright_shown_count: '{shown} / {total} pasukan dipaparkan',
outright_load_more: 'Muat lagi', outright_load_more: 'Muat lagi',
outright_settled: 'Selesai',
outright_settled_hint: 'Acara ini telah diselesaikan. Hanya paparan odds dan keputusan.',
cancel: 'Batal', cancel: 'Batal',
parlay_max_legs: 'Maksimum 5 pilihan parlay', parlay_max_legs: 'Maksimum 5 pilihan parlay',
parlay_block_outright: 'Outright tidak boleh parlay', parlay_block_outright: 'Outright tidak boleh parlay',

View File

@@ -370,6 +370,8 @@ export default {
outright_player_only: '请使用玩家账号登录后查看', outright_player_only: '请使用玩家账号登录后查看',
outright_shown_count: '已显示 {shown} / {total} 队', outright_shown_count: '已显示 {shown} / {total} 队',
outright_load_more: '加载更多', outright_load_more: '加载更多',
outright_settled: '已结算',
outright_settled_hint: '本赛事已结算,仅可查看赔率与冠军结果',
cancel: '取消', cancel: '取消',
parlay_max_legs: '串关最多 5 项', parlay_max_legs: '串关最多 5 项',
parlay_block_outright: '冠军盘不可串关', parlay_block_outright: '冠军盘不可串关',

View File

@@ -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> = { export const TX_KEY_MAP: Record<string, string> = {
MANUAL_DEPOSIT: 'wallet.tx_deposit', MANUAL_DEPOSIT: 'wallet.tx_deposit',
ADMIN_DEPOSIT: 'wallet.tx_admin_deposit', ADMIN_DEPOSIT: 'wallet.tx_admin_deposit',
@@ -87,3 +98,4 @@ export function isCashbackType(type: string): boolean {
const t = type.toUpperCase(); const t = type.toUpperCase();
return t === 'CASHBACK' || t === 'CASHBACK_DEPOSIT'; return t === 'CASHBACK' || t === 'CASHBACK_DEPOSIT';
} }

View File

@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import api from '../api'; import api from '../api';
import { formatMoney } from '../utils/localeDisplay'; 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 GoldSpinner from '../components/GoldSpinner.vue';
import WalletStatsPanel from '../components/WalletStatsPanel.vue'; import WalletStatsPanel from '../components/WalletStatsPanel.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh'; import { usePullToRefresh } from '../composables/usePullToRefresh';
@@ -19,6 +19,8 @@ type Transaction = {
summaryKind?: 'opening_bonus' | null; summaryKind?: 'opening_bonus' | null;
referenceType?: string | null; referenceType?: string | null;
amount: string; amount: string;
frozenBefore?: string;
frozenAfter?: string;
createdAt: string; createdAt: string;
transactionId: string; transactionId: string;
}; };
@@ -198,7 +200,7 @@ const pullIndicatorStyle = () => ({
<span class="tx-type">{{ txLabel(tx) }}</span> <span class="tx-type">{{ txLabel(tx) }}</span>
<span v-if="txSubtitle(tx)" class="tx-summary">{{ txSubtitle(tx) }}</span> <span v-if="txSubtitle(tx)" class="tx-summary">{{ txSubtitle(tx) }}</span>
</div> </div>
<span :class="parseFloat(tx.amount) >= 0 ? 'pos' : 'neg'"> <span :class="txAmountClass(tx.amount)">
{{ formatMoney(tx.amount, locale) }} {{ formatMoney(tx.amount, locale) }}
</span> </span>
</div> </div>
@@ -358,6 +360,7 @@ const pullIndicatorStyle = () => ({
.tx-type { font-weight: 700; color: #1A1A2E; } .tx-type { font-weight: 700; color: #1A1A2E; }
.pos { color: #003D6B; font-weight: 800; font-size: 15px; } .pos { color: #003D6B; font-weight: 800; font-size: 15px; }
.neg { color: #DC2626; font-weight: 700; } .neg { color: #DC2626; font-weight: 700; }
.zero { color: #6B7280; font-weight: 700; font-size: 15px; }
.tx-time { font-size: 11px; color: #6B7280; } .tx-time { font-size: 11px; color: #6B7280; }
.tx-arrow { font-size: 16px; color: #6B7280; font-weight: 700; line-height: 1; } .tx-arrow { font-size: 16px; color: #6B7280; font-weight: 700; line-height: 1; }

View File

@@ -4,7 +4,7 @@ import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import api from '../api'; import api from '../api';
import { formatMoney } from '../utils/localeDisplay'; 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 GoldSpinner from '../components/GoldSpinner.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh'; import { usePullToRefresh } from '../composables/usePullToRefresh';
@@ -81,10 +81,7 @@ const summaryText = computed(() => {
return txSummaryLabel(tx.value, t); return txSummaryLabel(tx.value, t);
}); });
const amountClass = computed(() => { const amountClass = computed(() => (tx.value ? txAmountClass(tx.value.amount) : 'zero'));
if (!tx.value) return '';
return parseFloat(tx.value.amount) >= 0 ? 'pos' : 'neg';
});
const formattedTime = computed(() => { const formattedTime = computed(() => {
if (!tx.value) return ''; if (!tx.value) return '';
@@ -280,6 +277,11 @@ function goCashbackDetail() {
background: linear-gradient(135deg, rgba(220, 38, 38, 0.04), #FFFFFF); background: linear-gradient(135deg, rgba(220, 38, 38, 0.04), #FFFFFF);
} }
.hero.zero {
border-color: #E5E7EB;
background: #F5F7FA;
}
.hero-type { .hero-type {
font-size: 12px; font-size: 12px;
font-weight: 800; font-weight: 800;
@@ -304,6 +306,7 @@ function goCashbackDetail() {
.hero.pos .hero-amount { color: #003D6B; } .hero.pos .hero-amount { color: #003D6B; }
.hero.neg .hero-amount { color: #DC2626; } .hero.neg .hero-amount { color: #DC2626; }
.hero.zero .hero-amount { color: #6B7280; }
.hero-time { .hero-time {
font-size: 11px; font-size: 11px;
@@ -352,6 +355,7 @@ function goCashbackDetail() {
.pos { color: #003D6B !important; } .pos { color: #003D6B !important; }
.neg { color: #DC2626 !important; } .neg { color: #DC2626 !important; }
.zero { color: #6B7280 !important; }
.mono { .mono {
font-family: ui-monospace, monospace; font-family: ui-monospace, monospace;

View File

@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import api from '../api'; import api from '../api';
import { formatMoney, formatMoneyCompact } from '../utils/localeDisplay'; 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 { parseCashbackApiData, sumCashbackAmount } from '../utils/cashback';
import GoldSpinner from '../components/GoldSpinner.vue'; import GoldSpinner from '../components/GoldSpinner.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh'; import { usePullToRefresh } from '../composables/usePullToRefresh';
@@ -19,6 +19,8 @@ type Transaction = {
summaryKind?: 'opening_bonus' | null; summaryKind?: 'opening_bonus' | null;
referenceType?: string | null; referenceType?: string | null;
amount: string; amount: string;
frozenBefore?: string;
frozenAfter?: string;
createdAt: string; createdAt: string;
transactionId: string; transactionId: string;
}; };
@@ -121,7 +123,7 @@ const pullIndicatorStyle = () => ({
<span class="tx-type">{{ txLabel(tx) }}</span> <span class="tx-type">{{ txLabel(tx) }}</span>
<span v-if="txSubtitle(tx)" class="tx-summary">{{ txSubtitle(tx) }}</span> <span v-if="txSubtitle(tx)" class="tx-summary">{{ txSubtitle(tx) }}</span>
</div> </div>
<span :class="parseFloat(tx.amount) >= 0 ? 'pos' : 'neg'"> <span :class="txAmountClass(tx.amount)">
{{ formatMoney(tx.amount, locale) }} {{ formatMoney(tx.amount, locale) }}
</span> </span>
</div> </div>
@@ -225,6 +227,7 @@ const pullIndicatorStyle = () => ({
.tx-type { font-weight: 700; color: #1A1A2E; } .tx-type { font-weight: 700; color: #1A1A2E; }
.pos { color: #003D6B; font-weight: 800; font-size: 14px; } .pos { color: #003D6B; font-weight: 800; font-size: 14px; }
.neg { color: #DC2626; font-weight: 700; } .neg { color: #DC2626; font-weight: 700; }
.zero { color: #6B7280; font-weight: 700; font-size: 14px; }
.tx-time { font-size: 11px; color: #6B7280; } .tx-time { font-size: 11px; color: #6B7280; }
.tx-arrow { font-size: 16px; color: #6B7280; font-weight: 700; line-height: 1; } .tx-arrow { font-size: 16px; color: #6B7280; font-weight: 700; line-height: 1; }

View File

@@ -112,6 +112,11 @@ export const API_ERROR_MESSAGES = {
'en-US': 'Cannot unpublish league after outright market is settled', 'en-US': 'Cannot unpublish league after outright market is settled',
'ms-MY': 'Liga tidak boleh ditarik selepas pasaran juara diselesaikan', '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: { MATCH_UNPUBLISH_FORBIDDEN: {
'zh-CN': '当前状态不可下架', 'zh-CN': '当前状态不可下架',
'en-US': 'Match cannot be unpublished in current status', 'en-US': 'Match cannot be unpublished in current status',
@@ -747,6 +752,26 @@ export const API_ERROR_MESSAGES = {
'en-US': 'Batch already confirmed', 'en-US': 'Batch already confirmed',
'ms-MY': 'Batch sudah disahkan', 'ms-MY': 'Batch sudah disahkan',
}, },
SETTLEMENT_BATCH_STALE: {
'zh-CN': '结算批次已过期,请使用最新预览批次',
'en-US': 'Settlement batch is stale; use the latest preview batch',
'ms-MY': 'Batch penyelesaian lapuk; guna batch pratonton terkini',
},
SETTLEMENT_BET_UPDATE_FAILED: {
'zh-CN': '注单 {betNo} 结算状态更新失败,请重试',
'en-US': 'Failed to update bet {betNo} for settlement',
'ms-MY': 'Gagal mengemas kini pertaruhan {betNo} untuk penyelesaian',
},
SETTLEMENT_SCORE_INVALID: {
'zh-CN': '比分无效:半场比分不能大于全场比分',
'en-US': 'Invalid score: half-time cannot exceed full-time',
'ms-MY': 'Skor tidak sah: separuh masa tidak boleh melebihi masa penuh',
},
SETTLEMENT_MARKET_UNSUPPORTED: {
'zh-CN': '盘口 {marketType} 暂不支持结算',
'en-US': 'Market type {marketType} is not supported for settlement',
'ms-MY': 'Jenis pasaran {marketType} belum disokong untuk penyelesaian',
},
SCORE_NOT_FOUND: { SCORE_NOT_FOUND: {
'zh-CN': '比分不存在', 'zh-CN': '比分不存在',
'en-US': 'Score not found', 'en-US': 'Score not found',

View File

@@ -114,6 +114,11 @@ export const API_ERROR_MESSAGES = {
'en-US': 'Cannot unpublish league after outright market is settled', 'en-US': 'Cannot unpublish league after outright market is settled',
'ms-MY': 'Liga tidak boleh ditarik selepas pasaran juara diselesaikan', '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: { MATCH_UNPUBLISH_FORBIDDEN: {
'zh-CN': '当前状态不可下架', 'zh-CN': '当前状态不可下架',
'en-US': 'Match cannot be unpublished in current status', 'en-US': 'Match cannot be unpublished in current status',
@@ -749,6 +754,26 @@ export const API_ERROR_MESSAGES = {
'en-US': 'Batch already confirmed', 'en-US': 'Batch already confirmed',
'ms-MY': 'Batch sudah disahkan', 'ms-MY': 'Batch sudah disahkan',
}, },
SETTLEMENT_BATCH_STALE: {
'zh-CN': '结算批次已过期,请使用最新预览批次',
'en-US': 'Settlement batch is stale; use the latest preview batch',
'ms-MY': 'Batch penyelesaian lapuk; guna batch pratonton terkini',
},
SETTLEMENT_BET_UPDATE_FAILED: {
'zh-CN': '注单 {betNo} 结算状态更新失败,请重试',
'en-US': 'Failed to update bet {betNo} for settlement',
'ms-MY': 'Gagal mengemas kini pertaruhan {betNo} untuk penyelesaian',
},
SETTLEMENT_SCORE_INVALID: {
'zh-CN': '比分无效:半场比分不能大于全场比分',
'en-US': 'Invalid score: half-time cannot exceed full-time',
'ms-MY': 'Skor tidak sah: separuh masa tidak boleh melebihi masa penuh',
},
SETTLEMENT_MARKET_UNSUPPORTED: {
'zh-CN': '盘口 {marketType} 暂不支持结算',
'en-US': 'Market type {marketType} is not supported for settlement',
'ms-MY': 'Jenis pasaran {marketType} belum disokong untuk penyelesaian',
},
SCORE_NOT_FOUND: { SCORE_NOT_FOUND: {
'zh-CN': '比分不存在', 'zh-CN': '比分不存在',
'en-US': 'Score not found', 'en-US': 'Score not found',

View File

@@ -126,4 +126,5 @@ export * from './playerUsername';
export * from './initial-depositRemark'; export * from './initial-depositRemark';
export * from './phone-countries'; export * from './phone-countries';
export * from './match-time'; export * from './match-time';
export * from './walletTx';
export * from './api-errors'; export * from './api-errors';

View File

@@ -130,6 +130,7 @@ export * from './playerUsername';
export * from './initial-depositRemark'; export * from './initial-depositRemark';
export * from './phone-countries'; export * from './phone-countries';
export * from './match-time'; export * from './match-time';
export * from './walletTx';
export interface ApiResponse<T = unknown> { export interface ApiResponse<T = unknown> {
success: boolean; success: boolean;

View File

@@ -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;
}

View File

@@ -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;
}