fix(admin,api,player): 返水注单去重、操作日志 i18n 与钱包紧凑金额
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -226,10 +226,22 @@ export const adminPagesMs: Record<string, string> = {
|
|||||||
'audit.col.time': 'Masa',
|
'audit.col.time': 'Masa',
|
||||||
'audit.action.CREATE_PLAYER': 'Cipta pemain',
|
'audit.action.CREATE_PLAYER': 'Cipta pemain',
|
||||||
'audit.action.UPDATE_PLAYER': 'Kemas kini pemain',
|
'audit.action.UPDATE_PLAYER': 'Kemas kini pemain',
|
||||||
|
'audit.action.RESET_DATABASE': 'Set semula pangkalan data',
|
||||||
'audit.action.CREATE_AGENT': 'Cipta ejen',
|
'audit.action.CREATE_AGENT': 'Cipta ejen',
|
||||||
'audit.action.UPDATE_AGENT': 'Kemas kini ejen',
|
'audit.action.UPDATE_AGENT': 'Kemas kini ejen',
|
||||||
|
'audit.action.UPDATE_PLAYER_ACCOUNT_SETTINGS': 'Kemas kini tetapan akaun pemain',
|
||||||
|
'audit.action.UPDATE_AGENT_SUSPEND_SETTINGS': 'Kemas kini tetapan penggantungan ejen',
|
||||||
|
'audit.action.UPDATE_BETTING_LIMITS': 'Kemas kini had pertaruhan',
|
||||||
|
'audit.action.CONFIRM_SETTLEMENT': 'Sahkan penyelesaian',
|
||||||
|
'audit.action.CONFIRM_RESETTLE': 'Sahkan penyelesaian semula',
|
||||||
|
'audit.action.CONFIRM_CASHBACK': 'Sahkan bayaran rebat',
|
||||||
|
'audit.action.CANCEL_CASHBACK': 'Batalkan kelompok rebat',
|
||||||
'audit.module.USERS': 'Pemain',
|
'audit.module.USERS': 'Pemain',
|
||||||
'audit.module.AGENTS': 'Ejen',
|
'audit.module.AGENTS': 'Ejen',
|
||||||
|
'audit.module.SYSTEM': 'Sistem',
|
||||||
|
'audit.module.SETTINGS': 'Tetapan',
|
||||||
|
'audit.module.SETTLEMENT': 'Penyelesaian',
|
||||||
|
'audit.module.CASHBACK': 'Rebat',
|
||||||
|
|
||||||
'cashback.start_date': 'Tarikh mula',
|
'cashback.start_date': 'Tarikh mula',
|
||||||
'cashback.end_date': 'Tarikh tamat',
|
'cashback.end_date': 'Tarikh tamat',
|
||||||
|
|||||||
@@ -245,8 +245,19 @@ export const adminPagesZh: Record<string, string> = {
|
|||||||
'audit.action.RESET_DATABASE': '重置数据库',
|
'audit.action.RESET_DATABASE': '重置数据库',
|
||||||
'audit.action.CREATE_AGENT': '新建代理',
|
'audit.action.CREATE_AGENT': '新建代理',
|
||||||
'audit.action.UPDATE_AGENT': '更新代理',
|
'audit.action.UPDATE_AGENT': '更新代理',
|
||||||
|
'audit.action.UPDATE_PLAYER_ACCOUNT_SETTINGS': '更新玩家账号设置',
|
||||||
|
'audit.action.UPDATE_AGENT_SUSPEND_SETTINGS': '更新代理停押设置',
|
||||||
|
'audit.action.UPDATE_BETTING_LIMITS': '更新投注限额',
|
||||||
|
'audit.action.CONFIRM_SETTLEMENT': '确认结算',
|
||||||
|
'audit.action.CONFIRM_RESETTLE': '确认重结算',
|
||||||
|
'audit.action.CONFIRM_CASHBACK': '确认发放返水',
|
||||||
|
'audit.action.CANCEL_CASHBACK': '作废返水批次',
|
||||||
'audit.module.USERS': '玩家',
|
'audit.module.USERS': '玩家',
|
||||||
'audit.module.AGENTS': '代理',
|
'audit.module.AGENTS': '代理',
|
||||||
|
'audit.module.SYSTEM': '系统',
|
||||||
|
'audit.module.SETTINGS': '系统设置',
|
||||||
|
'audit.module.SETTLEMENT': '结算',
|
||||||
|
'audit.module.CASHBACK': '返水',
|
||||||
|
|
||||||
'cashback.start_date': '开始日期',
|
'cashback.start_date': '开始日期',
|
||||||
'cashback.end_date': '结束日期',
|
'cashback.end_date': '结束日期',
|
||||||
@@ -929,8 +940,19 @@ export const adminPagesEn: Record<string, string> = {
|
|||||||
'audit.action.RESET_DATABASE': 'Reset database',
|
'audit.action.RESET_DATABASE': 'Reset database',
|
||||||
'audit.action.CREATE_AGENT': 'Create agent',
|
'audit.action.CREATE_AGENT': 'Create agent',
|
||||||
'audit.action.UPDATE_AGENT': 'Update agent',
|
'audit.action.UPDATE_AGENT': 'Update agent',
|
||||||
|
'audit.action.UPDATE_PLAYER_ACCOUNT_SETTINGS': 'Update player account settings',
|
||||||
|
'audit.action.UPDATE_AGENT_SUSPEND_SETTINGS': 'Update agent suspend settings',
|
||||||
|
'audit.action.UPDATE_BETTING_LIMITS': 'Update betting limits',
|
||||||
|
'audit.action.CONFIRM_SETTLEMENT': 'Confirm settlement',
|
||||||
|
'audit.action.CONFIRM_RESETTLE': 'Confirm resettlement',
|
||||||
|
'audit.action.CONFIRM_CASHBACK': 'Confirm cashback payout',
|
||||||
|
'audit.action.CANCEL_CASHBACK': 'Cancel cashback batch',
|
||||||
'audit.module.USERS': 'Players',
|
'audit.module.USERS': 'Players',
|
||||||
'audit.module.AGENTS': 'Agents',
|
'audit.module.AGENTS': 'Agents',
|
||||||
|
'audit.module.SYSTEM': 'System',
|
||||||
|
'audit.module.SETTINGS': 'Settings',
|
||||||
|
'audit.module.SETTLEMENT': 'Settlement',
|
||||||
|
'audit.module.CASHBACK': 'Cashback',
|
||||||
|
|
||||||
'cashback.start_date': 'Start date',
|
'cashback.start_date': 'Start date',
|
||||||
'cashback.end_date': 'End date',
|
'cashback.end_date': 'End date',
|
||||||
|
|||||||
@@ -1,22 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||||
|
import { useAuditLabels } from '../utils/audit-labels';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
||||||
|
|
||||||
const { t, locale, localeTag } = useAdminLocale();
|
const { t, locale, localeTag } = useAdminLocale();
|
||||||
|
const { auditActionLabel, auditModuleLabel } = useAuditLabels();
|
||||||
function auditActionLabel(action: string) {
|
|
||||||
const key = `audit.action.${action}`;
|
|
||||||
const label = t(key);
|
|
||||||
return label === key ? action : label;
|
|
||||||
}
|
|
||||||
|
|
||||||
function auditModuleLabel(module: string) {
|
|
||||||
const key = `audit.module.${module}`;
|
|
||||||
const label = t(key);
|
|
||||||
return label === key ? module : label;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AuditRow {
|
interface AuditRow {
|
||||||
action: string;
|
action: string;
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "cashback_bets" (
|
||||||
|
"id" BIGSERIAL NOT NULL,
|
||||||
|
"batch_id" BIGINT NOT NULL,
|
||||||
|
"bet_id" BIGINT NOT NULL,
|
||||||
|
"user_id" BIGINT NOT NULL,
|
||||||
|
"stake" DECIMAL(18,4) NOT NULL,
|
||||||
|
"amount" DECIMAL(18,4) NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "cashback_bets_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "cashback_bets_bet_id_key" ON "cashback_bets"("bet_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "cashback_bets_batch_id_idx" ON "cashback_bets"("batch_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "cashback_bets_user_id_idx" ON "cashback_bets"("user_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "cashback_bets" ADD CONSTRAINT "cashback_bets_batch_id_fkey" FOREIGN KEY ("batch_id") REFERENCES "cashback_batches"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "cashback_bets" ADD CONSTRAINT "cashback_bets_bet_id_fkey" FOREIGN KEY ("bet_id") REFERENCES "bets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -400,6 +400,7 @@ model Bet {
|
|||||||
|
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
selections BetSelection[]
|
selections BetSelection[]
|
||||||
|
cashbackClaims CashbackBet[]
|
||||||
|
|
||||||
@@unique([userId, requestId])
|
@@unique([userId, requestId])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@ -508,6 +509,7 @@ model CashbackBatch {
|
|||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
items CashbackItem[]
|
items CashbackItem[]
|
||||||
|
bets CashbackBet[]
|
||||||
|
|
||||||
@@map("cashback_batches")
|
@@map("cashback_batches")
|
||||||
}
|
}
|
||||||
@@ -529,6 +531,24 @@ model CashbackItem {
|
|||||||
@@map("cashback_items")
|
@@map("cashback_items")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 返水批次占用的注单(每笔注单全局仅能计入一次待发放/已发放批次) */
|
||||||
|
model CashbackBet {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
batchId BigInt @map("batch_id")
|
||||||
|
betId BigInt @unique @map("bet_id")
|
||||||
|
userId BigInt @map("user_id")
|
||||||
|
stake Decimal @db.Decimal(18, 4)
|
||||||
|
amount Decimal @db.Decimal(18, 4)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
batch CashbackBatch @relation(fields: [batchId], references: [id], onDelete: Cascade)
|
||||||
|
bet Bet @relation(fields: [betId], references: [id])
|
||||||
|
|
||||||
|
@@index([batchId])
|
||||||
|
@@index([userId])
|
||||||
|
@@map("cashback_bets")
|
||||||
|
}
|
||||||
|
|
||||||
// ============ Content & i18n ============
|
// ============ Content & i18n ============
|
||||||
|
|
||||||
model Content {
|
model Content {
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ type AggregatedItem = {
|
|||||||
agentUsername: string | null;
|
agentUsername: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BetCashbackLine = {
|
||||||
|
betId: bigint;
|
||||||
|
userId: bigint;
|
||||||
|
stake: Decimal;
|
||||||
|
amount: Decimal;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CashbackService {
|
export class CashbackService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -26,13 +33,27 @@ export class CashbackService {
|
|||||||
private wallet: WalletService,
|
private wallet: WalletService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/** 已被待发放/已发放返水批次占用的注单 */
|
||||||
|
private async loadClaimedBetIds(): Promise<Set<string>> {
|
||||||
|
const rows = await this.prisma.cashbackBet.findMany({
|
||||||
|
where: {
|
||||||
|
batch: { status: { in: ['PREVIEW', 'CONFIRMED'] } },
|
||||||
|
},
|
||||||
|
select: { betId: true },
|
||||||
|
});
|
||||||
|
return new Set(rows.map((r) => r.betId.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
private async aggregatePeriod(periodStart: Date, periodEnd: Date): Promise<{
|
private async aggregatePeriod(periodStart: Date, periodEnd: Date): Promise<{
|
||||||
items: AggregatedItem[];
|
items: AggregatedItem[];
|
||||||
|
betLines: BetCashbackLine[];
|
||||||
totalAmount: Decimal;
|
totalAmount: Decimal;
|
||||||
totalEffectiveStake: Decimal;
|
totalEffectiveStake: Decimal;
|
||||||
totalBetCount: number;
|
totalBetCount: number;
|
||||||
|
eligibleBetCount: number;
|
||||||
|
skippedClaimedCount: number;
|
||||||
}> {
|
}> {
|
||||||
const [settledBets, rules, agentProfiles] = await Promise.all([
|
const [settledBets, rules, agentProfiles, claimedBetIds] = await Promise.all([
|
||||||
this.prisma.bet.findMany({
|
this.prisma.bet.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: { in: ['WON', 'LOST'] },
|
status: { in: ['WON', 'LOST'] },
|
||||||
@@ -47,6 +68,7 @@ export class CashbackService {
|
|||||||
this.prisma.agentProfile.findMany({
|
this.prisma.agentProfile.findMany({
|
||||||
select: { userId: true, cashbackRate: true },
|
select: { userId: true, cashbackRate: true },
|
||||||
}),
|
}),
|
||||||
|
this.loadClaimedBetIds(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const agentRateById = new Map(
|
const agentRateById = new Map(
|
||||||
@@ -63,6 +85,9 @@ export class CashbackService {
|
|||||||
string,
|
string,
|
||||||
{ userId: bigint; stake: Decimal; amount: Decimal; betCount: number }
|
{ userId: bigint; stake: Decimal; amount: Decimal; betCount: number }
|
||||||
>();
|
>();
|
||||||
|
const betLines: BetCashbackLine[] = [];
|
||||||
|
let eligibleBetCount = 0;
|
||||||
|
let skippedClaimedCount = 0;
|
||||||
|
|
||||||
for (const bet of settledBets) {
|
for (const bet of settledBets) {
|
||||||
const agentId = bet.user.parentId;
|
const agentId = bet.user.parentId;
|
||||||
@@ -80,6 +105,21 @@ export class CashbackService {
|
|||||||
|
|
||||||
if (rate.lte(0)) continue;
|
if (rate.lte(0)) continue;
|
||||||
|
|
||||||
|
eligibleBetCount += 1;
|
||||||
|
|
||||||
|
if (claimedBetIds.has(bet.id.toString())) {
|
||||||
|
skippedClaimedCount += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineAmount = bet.stake.mul(rate);
|
||||||
|
betLines.push({
|
||||||
|
betId: bet.id,
|
||||||
|
userId: bet.userId,
|
||||||
|
stake: bet.stake,
|
||||||
|
amount: lineAmount,
|
||||||
|
});
|
||||||
|
|
||||||
const key = bet.userId.toString();
|
const key = bet.userId.toString();
|
||||||
const existing = playerAgg.get(key) ?? {
|
const existing = playerAgg.get(key) ?? {
|
||||||
userId: bet.userId,
|
userId: bet.userId,
|
||||||
@@ -88,7 +128,7 @@ export class CashbackService {
|
|||||||
betCount: 0,
|
betCount: 0,
|
||||||
};
|
};
|
||||||
existing.stake = existing.stake.add(bet.stake);
|
existing.stake = existing.stake.add(bet.stake);
|
||||||
existing.amount = existing.amount.add(bet.stake.mul(rate));
|
existing.amount = existing.amount.add(lineAmount);
|
||||||
existing.betCount += 1;
|
existing.betCount += 1;
|
||||||
playerAgg.set(key, existing);
|
playerAgg.set(key, existing);
|
||||||
}
|
}
|
||||||
@@ -130,7 +170,15 @@ export class CashbackService {
|
|||||||
const totalEffectiveStake = items.reduce((s, i) => s.add(i.effectiveStake), new Decimal(0));
|
const totalEffectiveStake = items.reduce((s, i) => s.add(i.effectiveStake), new Decimal(0));
|
||||||
const totalBetCount = items.reduce((s, i) => s + i.betCount, 0);
|
const totalBetCount = items.reduce((s, i) => s + i.betCount, 0);
|
||||||
|
|
||||||
return { items, totalAmount, totalEffectiveStake, totalBetCount };
|
return {
|
||||||
|
items,
|
||||||
|
betLines,
|
||||||
|
totalAmount,
|
||||||
|
totalEffectiveStake,
|
||||||
|
totalBetCount,
|
||||||
|
eligibleBetCount,
|
||||||
|
skippedClaimedCount,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizePeriodStart(input: Date): Date {
|
private normalizePeriodStart(input: Date): Date {
|
||||||
@@ -155,6 +203,7 @@ export class CashbackService {
|
|||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
for (const b of stale) {
|
for (const b of stale) {
|
||||||
|
await tx.cashbackBet.deleteMany({ where: { batchId: b.id } });
|
||||||
await tx.cashbackItem.deleteMany({ where: { batchId: b.id } });
|
await tx.cashbackItem.deleteMany({ where: { batchId: b.id } });
|
||||||
await tx.cashbackBatch.delete({ where: { id: b.id } });
|
await tx.cashbackBatch.delete({ where: { id: b.id } });
|
||||||
}
|
}
|
||||||
@@ -175,10 +224,20 @@ export class CashbackService {
|
|||||||
throw new BadRequestException('该统计周期已发放返水,不可重复生成预览');
|
throw new BadRequestException('该统计周期已发放返水,不可重复生成预览');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { items, totalAmount, totalEffectiveStake, totalBetCount } =
|
const {
|
||||||
await this.aggregatePeriod(start, end);
|
items,
|
||||||
|
betLines,
|
||||||
|
totalAmount,
|
||||||
|
totalEffectiveStake,
|
||||||
|
totalBetCount,
|
||||||
|
eligibleBetCount,
|
||||||
|
skippedClaimedCount,
|
||||||
|
} = await this.aggregatePeriod(start, end);
|
||||||
|
|
||||||
if (items.length === 0 || totalAmount.lte(0)) {
|
if (items.length === 0 || totalAmount.lte(0)) {
|
||||||
|
if (eligibleBetCount > 0 && skippedClaimedCount >= eligibleBetCount) {
|
||||||
|
throw new BadRequestException('该周期内的有效注单均已计入其他返水批次,无法生成预览');
|
||||||
|
}
|
||||||
throw new BadRequestException('该周期内无符合条件的返水,无法生成预览');
|
throw new BadRequestException('该周期内无符合条件的返水,无法生成预览');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +271,18 @@ export class CashbackService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const line of betLines) {
|
||||||
|
await tx.cashbackBet.create({
|
||||||
|
data: {
|
||||||
|
batchId: batch.id,
|
||||||
|
betId: line.betId,
|
||||||
|
userId: line.userId,
|
||||||
|
stake: line.stake,
|
||||||
|
amount: line.amount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return replaced;
|
return replaced;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -330,7 +401,7 @@ export class CashbackService {
|
|||||||
async confirmBatch(batchId: bigint, operatorId: bigint) {
|
async confirmBatch(batchId: bigint, operatorId: bigint) {
|
||||||
const batch = await this.prisma.cashbackBatch.findUnique({
|
const batch = await this.prisma.cashbackBatch.findUnique({
|
||||||
where: { id: batchId },
|
where: { id: batchId },
|
||||||
include: { items: true },
|
include: { items: true, bets: true },
|
||||||
});
|
});
|
||||||
if (!batch) throw new BadRequestException('Batch not found');
|
if (!batch) throw new BadRequestException('Batch not found');
|
||||||
if (batch.status !== 'PREVIEW') throw new BadRequestException('该批次不可发放');
|
if (batch.status !== 'PREVIEW') throw new BadRequestException('该批次不可发放');
|
||||||
@@ -350,6 +421,19 @@ export class CashbackService {
|
|||||||
throw new BadRequestException('该统计周期已发放返水');
|
throw new BadRequestException('该统计周期已发放返水');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const betIds = batch.bets.map((b) => b.betId);
|
||||||
|
if (betIds.length > 0) {
|
||||||
|
const conflict = await this.prisma.cashbackBet.findFirst({
|
||||||
|
where: {
|
||||||
|
betId: { in: betIds },
|
||||||
|
batch: { status: 'CONFIRMED' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (conflict) {
|
||||||
|
throw new BadRequestException('部分注单已在其他批次发放返水,请作废本预览后重新生成');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const item of batch.items) {
|
for (const item of batch.items) {
|
||||||
if (item.amount.gt(0)) {
|
if (item.amount.gt(0)) {
|
||||||
await this.wallet.deposit(
|
await this.wallet.deposit(
|
||||||
@@ -378,10 +462,13 @@ export class CashbackService {
|
|||||||
throw new BadRequestException('只能作废待发放批次');
|
throw new BadRequestException('只能作废待发放批次');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.prisma.cashbackBatch.update({
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.cashbackBet.deleteMany({ where: { batchId } });
|
||||||
|
await tx.cashbackBatch.update({
|
||||||
where: { id: batchId },
|
where: { id: batchId },
|
||||||
data: { status: 'CANCELLED' },
|
data: { status: 'CANCELLED' },
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<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 { formatMoney, parseAmount } from '../utils/localeDisplay';
|
import { formatMoneyCompact, parseAmount } from '../utils/localeDisplay';
|
||||||
|
|
||||||
interface Transaction {
|
interface Transaction {
|
||||||
transactionType: string;
|
transactionType: string;
|
||||||
@@ -50,24 +50,24 @@ const stats = computed(() => {
|
|||||||
<div class="wallet-stats-panel">
|
<div class="wallet-stats-panel">
|
||||||
<div class="stats-row">
|
<div class="stats-row">
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<span class="stat-val income">{{ formatMoney(stats.income, locale) }}</span>
|
<span class="stat-val income">{{ formatMoneyCompact(stats.income, locale) }}</span>
|
||||||
<span class="stat-label">{{ t('wallet.stats_income') }}</span>
|
<span class="stat-label">{{ t('wallet.stats_income') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-divider" />
|
<div class="stat-divider" />
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<span class="stat-val expense">{{ formatMoney(stats.expense, locale) }}</span>
|
<span class="stat-val expense">{{ formatMoneyCompact(stats.expense, locale) }}</span>
|
||||||
<span class="stat-label">{{ t('wallet.stats_expense') }}</span>
|
<span class="stat-label">{{ t('wallet.stats_expense') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-divider" />
|
<div class="stat-divider" />
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<span class="stat-val" :class="stats.net >= 0 ? 'income' : 'expense'">
|
<span class="stat-val" :class="stats.net >= 0 ? 'income' : 'expense'">
|
||||||
{{ formatMoney(stats.net, locale) }}
|
{{ formatMoneyCompact(stats.net, locale) }}
|
||||||
</span>
|
</span>
|
||||||
<span class="stat-label">{{ t('wallet.stats_net') }}</span>
|
<span class="stat-label">{{ t('wallet.stats_net') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-divider" />
|
<div class="stat-divider" />
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<span class="stat-val cashback">{{ formatMoney(stats.cashback, locale) }}</span>
|
<span class="stat-val cashback">{{ formatMoneyCompact(stats.cashback, locale) }}</span>
|
||||||
<span class="stat-label">{{ t('wallet.stats_cashback') }}</span>
|
<span class="stat-label">{{ t('wallet.stats_cashback') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,3 +55,35 @@ export function formatMoney(amount: unknown, locale: string): string {
|
|||||||
return `${getLocaleDisplay(locale).currency} ${value.toFixed(2)}`;
|
return `${getLocaleDisplay(locale).currency} ${value.toFixed(2)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatMoneyCompact(amount: unknown, locale: string): string {
|
||||||
|
const value = parseAmount(amount);
|
||||||
|
const abs = Math.abs(value);
|
||||||
|
const sign = value < 0 ? '-' : '';
|
||||||
|
const { currency } = getLocaleDisplay(locale);
|
||||||
|
const sym = currency === 'CNY' ? '¥' : currency === 'MYR' ? 'RM' : '$';
|
||||||
|
|
||||||
|
if (abs >= 10000 && locale === 'zh-CN') {
|
||||||
|
const v = abs / 10000;
|
||||||
|
return `${sign}${sym}${v % 1 === 0 ? v.toFixed(0) : v.toFixed(1)}万`;
|
||||||
|
}
|
||||||
|
if (abs >= 1000000) {
|
||||||
|
const v = abs / 1000000;
|
||||||
|
return `${sign}${sym}${v % 1 === 0 ? v.toFixed(0) : v.toFixed(1)}M`;
|
||||||
|
}
|
||||||
|
if (abs >= 10000) {
|
||||||
|
const v = abs / 1000;
|
||||||
|
return `${sign}${sym}${v % 1 === 0 ? v.toFixed(0) : v.toFixed(1)}K`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat(locale, {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(value);
|
||||||
|
} catch {
|
||||||
|
return `${sym}${value.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user