feat(admin+api+player): 优胜赛结算态、禁止新增单场与钱包流水展示优化
- API/管理端:优胜赛 SETTLED 后禁止新增单场,列表与子页展示结算状态 - 玩家端:已结算 outright 只读展示并高亮冠军 - 管理端:结算后 stale 标记驱动列表刷新;财务流水时间与备注 i18n 优化 - shared:txDisplayAmount 与 LEAGUE_OUTRIGHT_SETTLED 错误码
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -242,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',
|
||||||
@@ -300,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',
|
||||||
|
|||||||
@@ -243,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': '选择用户',
|
||||||
@@ -301,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': '联赛已发布',
|
||||||
|
|||||||
13
apps/admin/src/utils/adminListStale.ts
Normal file
13
apps/admin/src/utils/adminListStale.ts
Normal 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;
|
||||||
|
}
|
||||||
39
apps/admin/src/utils/format-datetime.ts
Normal file
39
apps/admin/src/utils/format-datetime.ts
Normal 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: '' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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>> {
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { formatMoneyCompact, parseAmount } from '../utils/localeDisplay';
|
import { 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -149,6 +159,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: #c9a227;
|
||||||
|
border: 1px solid rgba(201, 162, 39, 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;
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -99,6 +108,20 @@ onUnmounted(() => {
|
|||||||
border-color: var(--border-gold-soft);
|
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 {
|
.flag {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 19px;
|
height: 19px;
|
||||||
|
|||||||
@@ -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(201, 162, 39, 0.08);
|
||||||
|
border: 1px solid rgba(201, 162, 39, 0.22);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #c9a227;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
.event-list {
|
.event-list {
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -367,6 +367,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',
|
||||||
|
|||||||
@@ -373,6 +373,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',
|
||||||
|
|||||||
@@ -367,6 +367,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: '冠军盘不可串关',
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: var(--text); }
|
.tx-type { font-weight: 700; color: var(--text); }
|
||||||
.pos { color: var(--primary-light); font-weight: 800; font-size: 15px; }
|
.pos { color: var(--primary-light); font-weight: 800; font-size: 15px; }
|
||||||
.neg { color: var(--danger); font-weight: 700; }
|
.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-time { font-size: 11px; color: var(--text-muted); }
|
||||||
.tx-arrow { font-size: 16px; color: #555; font-weight: 700; line-height: 1; }
|
.tx-arrow { font-size: 16px; color: #555; font-weight: 700; line-height: 1; }
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useRoute, useRouter } from 'vue-router';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { 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 '';
|
||||||
@@ -279,6 +276,11 @@ function goCashbackDetail() {
|
|||||||
background: linear-gradient(135deg, rgba(224, 80, 80, 0.1), rgba(224, 80, 80, 0.03));
|
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 {
|
.hero-type {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -303,6 +305,7 @@ function goCashbackDetail() {
|
|||||||
|
|
||||||
.hero.pos .hero-amount { color: var(--primary-light); }
|
.hero.pos .hero-amount { color: var(--primary-light); }
|
||||||
.hero.neg .hero-amount { color: var(--danger); }
|
.hero.neg .hero-amount { color: var(--danger); }
|
||||||
|
.hero.zero .hero-amount { color: var(--text-muted, #888); }
|
||||||
|
|
||||||
.hero-time {
|
.hero-time {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -350,6 +353,7 @@ function goCashbackDetail() {
|
|||||||
|
|
||||||
.pos { color: var(--primary-light) !important; }
|
.pos { color: var(--primary-light) !important; }
|
||||||
.neg { color: var(--danger) !important; }
|
.neg { color: var(--danger) !important; }
|
||||||
|
.zero { color: var(--text-muted, #888) !important; }
|
||||||
|
|
||||||
.mono {
|
.mono {
|
||||||
font-family: ui-monospace, monospace;
|
font-family: ui-monospace, monospace;
|
||||||
|
|||||||
@@ -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: var(--text); }
|
.tx-type { font-weight: 700; color: var(--text); }
|
||||||
.pos { color: var(--primary-light); font-weight: 800; font-size: 15px; }
|
.pos { color: var(--primary-light); font-weight: 800; font-size: 15px; }
|
||||||
.neg { color: var(--danger); font-weight: 700; }
|
.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-time { font-size: 11px; color: var(--text-muted); }
|
||||||
.tx-arrow { font-size: 16px; color: #555; font-weight: 700; line-height: 1; }
|
.tx-arrow { font-size: 16px; color: #555; font-weight: 700; line-height: 1; }
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
11
packages/shared/src/walletTx.js
Normal file
11
packages/shared/src/walletTx.js
Normal 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;
|
||||||
|
}
|
||||||
15
packages/shared/src/walletTx.ts
Normal file
15
packages/shared/src/walletTx.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user