feat: add finance logs page, banner upload, and admin withdraw fix

## 财务流水
- 新增 FinanceLogs.vue(/finance-logs):额度流水 + 上下分流水双 Tab,支持时间/代理/玩家/操作人筛选与分页
- 管理员与代理共用页面,API 按角色自动切换(/admin/* 或 /agent/*)
- 侧栏「财务流水」替代原「额度流水」;代理侧栏同步新增入口
- /agent-credit-transactions 重定向至 /finance-logs?tab=credit,旧链接仍可用
- 后端:新增 GET /admin/wallet/transfer-transactions;增强额度/上下分列表筛选
- 代理端:新增 GET /agent/credit-transactions;GET /agent/wallet-transactions 支持分页与筛选
- 修复:管理员下分改为 adminWithdrawFromPlayer(),下分后重算上级代理 usedCredit

## 内容管理 Banner
- Contents.vue:各语言 Banner 支持本地上传、媒体库选择、手动填 URL(≤5MB)
- vite 开发代理 /uploads;生产 nginx 反代 /uploads/ 至 API

## 玩家端 Banner
- BannerCarousel:外链无协议时自动补 https://
- defaultBanner:API 加载中不闪默认图,仅空列表时展示默认 Banner

## 其他
- AgentManager:查看额度流水链接改为 /finance-logs
- i18n:finance.*、nav.finance_logs、content.upload.*(中/英/马来)

未纳入本次提交:.pnpm-store/、release/ 部署包、uploads/banners/ 下测试上传图片

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-10 10:02:53 +08:00
parent df20444be9
commit 6124313369
17 changed files with 1233 additions and 25 deletions

View File

@@ -38,6 +38,7 @@ const zh: Record<string, string> = {
'nav.outrights': '优胜冠军',
'nav.bets': '注单管理',
'nav.credit_transactions': '额度流水',
'nav.finance_logs': '财务流水',
'nav.cashback': '返水管理',
'nav.contents': '公共管理',
'nav.audit': '操作日志',
@@ -219,6 +220,7 @@ const en: Record<string, string> = {
'nav.outrights': 'Outrights',
'nav.bets': 'Bets',
'nav.credit_transactions': 'Credit ledger',
'nav.finance_logs': 'Finance ledger',
'nav.cashback': 'Cashback',
'nav.contents': 'Public Content',
'nav.audit': 'Audit Log',
@@ -400,6 +402,7 @@ const ms: Record<string, string> = {
'nav.outrights': 'Juara',
'nav.bets': 'Pertaruhan',
'nav.credit_transactions': 'Lejar kredit',
'nav.finance_logs': 'Lejar kewangan',
'nav.cashback': 'Rebat',
'nav.contents': 'Kandungan awam',
'nav.audit': 'Log audit',

View File

@@ -147,6 +147,21 @@ export const adminPagesMs: Record<string, string> = {
'agent.credit_tx.filter_agent_id_ph': 'ID pengguna',
'agent.credit_tx.col.operator': 'Operator',
'agent.credit_tx.view_all': 'Lihat semua lejar kredit',
'finance.tab.credit': 'Lejar kredit',
'finance.tab.transfer': 'Lejar pemindahan',
'finance.filter.date_range': 'Julat tarikh',
'finance.filter.player_ph': 'Nama pengguna pemain',
'finance.filter.parent_agent_ph': 'Nama/ID ejen induk',
'finance.filter.operator_ph': 'Nama pengguna operator',
'finance.col.player': 'Pemain',
'finance.col.parent_agent': 'Ejen induk',
'finance.col.tx_id': 'ID transaksi',
'finance.col.balance_change': 'Perubahan baki',
'finance.col.balance_before': 'Baki sebelum',
'finance.col.balance_after': 'Baki selepas',
'finance.tx.deposit': 'Deposit',
'finance.tx.withdraw': 'Pengeluaran',
'finance.tx.request_id': 'ID permintaan',
'agent.col.no_records': 'Tiada rekod',
'agent.btn.confirm_adjust': 'Sahkan',
'agent.field.select_user': 'Pilih pengguna',
@@ -535,6 +550,16 @@ export const adminPagesMs: Record<string, string> = {
'content.field.body': 'Kandungan',
'content.field.announce_text': 'Teks ticker',
'content.field.image_url': 'URL imej',
'content.upload.upload_btn': 'Muat naik imej',
'content.upload.uploading': 'Memuat naik…',
'content.upload.success': 'Imej berjaya dimuat naik',
'content.upload.failed': 'Gagal memuat naik',
'content.upload.size_error': 'Imej mestilah di bawah 5 MB',
'content.upload.remove': 'Buang imej',
'content.upload.pick_media': 'Pilih dari pustaka',
'content.upload.pick_media_title': 'Pilih Imej Banner',
'content.upload.no_media': 'Tiada imej banner dalam pustaka — muat naik dahulu',
'content.upload.url_placeholder': 'Atau tampal URL imej',
'content.link.none': 'Tiada pautan',
'content.locale.zh-CN': 'Cina Ringkas',
'content.locale.en-US': 'English',

View File

@@ -150,6 +150,21 @@ export const adminPagesZh: Record<string, string> = {
'agent.credit_tx.filter_agent_id_ph': '用户 ID',
'agent.credit_tx.col.operator': '操作人',
'agent.credit_tx.view_all': '查看全部额度流水',
'finance.tab.credit': '额度流水',
'finance.tab.transfer': '上下分流水',
'finance.filter.date_range': '时间范围',
'finance.filter.player_ph': '玩家用户名',
'finance.filter.parent_agent_ph': '上级代理用户名或 ID',
'finance.filter.operator_ph': '操作人用户名',
'finance.col.player': '玩家',
'finance.col.parent_agent': '上级代理',
'finance.col.tx_id': '流水号',
'finance.col.balance_change': '余额变动',
'finance.col.balance_before': '变动前余额',
'finance.col.balance_after': '变动后余额',
'finance.tx.deposit': '上分',
'finance.tx.withdraw': '下分',
'finance.tx.request_id': '请求 ID',
'agent.col.no_records': '暂无记录',
'agent.btn.confirm_adjust': '确认调整',
'agent.field.select_user': '选择用户',
@@ -601,6 +616,16 @@ export const adminPagesZh: Record<string, string> = {
'content.field.body': '正文',
'content.field.announce_text': '滚动文案',
'content.field.image_url': '图片地址',
'content.upload.upload_btn': '上传图片',
'content.upload.uploading': '上传中…',
'content.upload.success': '图片上传成功',
'content.upload.failed': '图片上传失败',
'content.upload.size_error': '图片大小不能超过 5MB',
'content.upload.remove': '移除图片',
'content.upload.pick_media': '从媒体库选择',
'content.upload.pick_media_title': '选择 Banner 图片',
'content.upload.no_media': '媒体库中暂无 Banner 图片,请先上传',
'content.upload.url_placeholder': '或手动粘贴图片 URL',
'content.link.none': '无跳转',
'content.locale.zh-CN': '简体中文',
'content.locale.en-US': 'English',
@@ -931,6 +956,21 @@ export const adminPagesEn: Record<string, string> = {
'agent.credit_tx.filter_agent_id_ph': 'User ID',
'agent.credit_tx.col.operator': 'Operator',
'agent.credit_tx.view_all': 'View all credit ledger',
'finance.tab.credit': 'Credit ledger',
'finance.tab.transfer': 'Transfer ledger',
'finance.filter.date_range': 'Date range',
'finance.filter.player_ph': 'Player username',
'finance.filter.parent_agent_ph': 'Parent agent username or ID',
'finance.filter.operator_ph': 'Operator username',
'finance.col.player': 'Player',
'finance.col.parent_agent': 'Parent agent',
'finance.col.tx_id': 'Transaction ID',
'finance.col.balance_change': 'Balance change',
'finance.col.balance_before': 'Balance before',
'finance.col.balance_after': 'Balance after',
'finance.tx.deposit': 'Deposit',
'finance.tx.withdraw': 'Withdraw',
'finance.tx.request_id': 'Request ID',
'agent.col.no_records': 'No records',
'agent.btn.confirm_adjust': 'Confirm',
'agent.field.select_user': 'Select user',
@@ -1383,6 +1423,16 @@ export const adminPagesEn: Record<string, string> = {
'content.field.body': 'Body',
'content.field.announce_text': 'Marquee text',
'content.field.image_url': 'Image URL',
'content.upload.upload_btn': 'Upload Image',
'content.upload.uploading': 'Uploading…',
'content.upload.success': 'Image uploaded',
'content.upload.failed': 'Upload failed',
'content.upload.size_error': 'Image must be under 5 MB',
'content.upload.remove': 'Remove image',
'content.upload.pick_media': 'Pick from library',
'content.upload.pick_media_title': 'Select Banner Image',
'content.upload.no_media': 'No banner images in library — upload one first',
'content.upload.url_placeholder': 'Or paste image URL',
'content.link.none': 'No link',
'content.locale.zh-CN': 'Chinese (Simplified)',
'content.locale.en-US': 'English',

View File

@@ -18,7 +18,7 @@ const adminMenus = computed(() => [
{ path: '/', label: t('nav.dashboard') },
{ path: '/matches', label: t('nav.matches'), matchPrefix: true },
{ path: '/users', label: t('nav.agents_players') },
{ path: '/agent-credit-transactions', label: t('nav.credit_transactions') },
{ path: '/finance-logs', label: t('nav.finance_logs') },
{ path: '/cashback', label: t('nav.cashback') },
{ path: '/bets', label: t('nav.bets') },
{ path: '/contents', label: t('nav.contents') },
@@ -30,6 +30,7 @@ const adminMenus = computed(() => [
const agentMenus = computed(() => [
{ path: '/', label: t('nav.dashboard') },
{ path: '/my-players', label: t('nav.agents_players') },
{ path: '/finance-logs', label: t('nav.finance_logs') },
{ path: '/my-bets', label: t('nav.myBets') },
]);

View File

@@ -17,10 +17,16 @@ const router = createRouter({
component: () => import('../views/AgentManager.vue'),
meta: { adminOnly: true },
},
{
path: 'finance-logs',
component: () => import('../views/FinanceLogs.vue'),
},
{
path: 'agent-credit-transactions',
component: () => import('../views/AgentCreditTransactions.vue'),
meta: { adminOnly: true },
redirect: (to) => ({
path: '/finance-logs',
query: { ...to.query, tab: 'credit' },
}),
},
{
path: 'agents',

View File

@@ -301,7 +301,7 @@ function onPlayerSizeChange(size: number) {
loadAllPlayers();
}
function affiliationLabel(row: PlayerRow) {
function affiliationLabel(row: Pick<PlayerRow, 'affiliationAgents'>) {
return formatPlayerAffiliationLabel(row, t('user.type.player'), t('agent.platform_row_name'));
}
@@ -1726,7 +1726,7 @@ function creditTypeLabel(type: string) {
v-if="agentDetail"
link
type="primary"
@click="router.push({ path: '/agent-credit-transactions', query: { agentId: agentDetail.userId } })"
@click="router.push({ path: '/finance-logs', query: { tab: 'credit', agentId: agentDetail.userId } })"
>
{{ t('agent.credit_tx.view_all') }}
</el-button>

View File

@@ -8,6 +8,89 @@ import AdminTableEmpty from '../components/AdminTableEmpty.vue';
const { t, localeTag } = useAdminLocale();
/* ── Image upload helpers ── */
const IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif,image/svg+xml';
const MAX_UPLOAD_SIZE = 5 * 1024 * 1024;
interface MediaFile {
id: string;
filename: string;
category: string;
mimeType: string;
size: number;
url: string;
inUse: boolean;
createdAt: string;
}
/** Per-locale uploading state */
const uploadingLocale = ref<string | null>(null);
/** Media picker state */
const mediaPickerVisible = ref(false);
const mediaPickerLocale = ref('');
const mediaFiles = ref<MediaFile[]>([]);
const mediaLoading = ref(false);
async function uploadBannerImage(locale: string, file: File) {
if (file.size > MAX_UPLOAD_SIZE) {
ElMessage.error(t('content.upload.size_error'));
return;
}
uploadingLocale.value = locale;
try {
const fd = new FormData();
fd.append('file', file);
const { data } = await api.post('/admin/uploads?category=banners', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
});
const url = data.data?.url as string;
if (url) {
const tr = form.value.translations.find((item) => item.locale === locale);
if (tr) tr.imageUrl = url;
ElMessage.success(t('content.upload.success'));
}
} catch (err: any) {
const msg = err?.response?.data?.message || t('content.upload.failed');
ElMessage.error(String(msg));
} finally {
uploadingLocale.value = null;
}
}
function onBannerFileChange(e: Event, locale: string) {
const input = e.target as HTMLInputElement;
if (input.files?.[0]) {
void uploadBannerImage(locale, input.files[0]);
input.value = '';
}
}
function removeBannerImage(locale: string) {
const tr = form.value.translations.find((item) => item.locale === locale);
if (tr) tr.imageUrl = '';
}
async function openMediaPicker(locale: string) {
mediaPickerLocale.value = locale;
mediaPickerVisible.value = true;
mediaLoading.value = true;
try {
const res = await api.get('/admin/files', { params: { category: 'banners', pageSize: 200 } });
mediaFiles.value = res.data.data.items ?? [];
} catch {
mediaFiles.value = [];
} finally {
mediaLoading.value = false;
}
}
function pickMediaFile(file: MediaFile) {
const tr = form.value.translations.find((item) => item.locale === mediaPickerLocale.value);
if (tr) tr.imageUrl = file.url;
mediaPickerVisible.value = false;
}
type StoredContentType = 'BANNER' | 'NOTICE' | 'TICKER';
type AdminTab = 'BANNER' | 'ANNOUNCEMENT';
type ContentStatus = 'DRAFT' | 'ACTIVE' | 'INACTIVE';
@@ -269,6 +352,10 @@ function buildPayload() {
}
async function submitForm() {
if (uploadingLocale.value) {
ElMessage.warning(t('content.upload.uploading'));
return;
}
saving.value = true;
try {
const payload = buildPayload();
@@ -532,7 +619,39 @@ void load();
:label="t('content.field.image_url')"
:required="form.status === 'ACTIVE'"
>
<el-input v-model="tr.imageUrl" placeholder="/uploads/banners/welcome.svg" />
<div class="banner-upload-field">
<!-- Image preview -->
<div v-if="tr.imageUrl" class="banner-preview">
<img :src="tr.imageUrl" alt="" class="banner-preview-img" />
<button type="button" class="banner-preview-remove" :title="t('content.upload.remove')" @click="removeBannerImage(tr.locale)">&times;</button>
</div>
<!-- Upload actions -->
<div class="banner-upload-actions">
<label
class="banner-upload-btn"
:class="{ 'is-uploading': uploadingLocale === tr.locale }"
>
<input
type="file"
:accept="IMAGE_ACCEPT"
style="display: none"
:disabled="uploadingLocale === tr.locale"
@change="onBannerFileChange($event, tr.locale)"
/>
{{ uploadingLocale === tr.locale ? t('content.upload.uploading') : t('content.upload.upload_btn') }}
</label>
<button type="button" class="banner-pick-btn" @click="openMediaPicker(tr.locale)">
{{ t('content.upload.pick_media') }}
</button>
</div>
<!-- Manual URL fallback -->
<el-input
v-model="tr.imageUrl"
:placeholder="t('content.upload.url_placeholder')"
size="small"
class="banner-url-input"
/>
</div>
</el-form-item>
<el-form-item
:label="isAnnouncement ? t('content.field.announce_text') : t('content.field.body')"
@@ -544,11 +663,31 @@ void load();
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="saving" @click="submitForm">
<el-button type="primary" :loading="saving" :disabled="!!uploadingLocale" @click="submitForm">
{{ t('common.save') }}
</el-button>
</template>
</el-dialog>
<!-- Media picker dialog -->
<el-dialog v-model="mediaPickerVisible" :title="t('content.upload.pick_media_title')" width="680px" destroy-on-close append-to-body>
<div v-if="mediaLoading" class="media-picker-loading">{{ t('common.loading') }}</div>
<div v-else-if="mediaFiles.length === 0" class="media-picker-empty">{{ t('content.upload.no_media') }}</div>
<div v-else class="media-picker-grid">
<div
v-for="file in mediaFiles"
:key="file.id"
class="media-picker-card"
@click="pickMediaFile(file)"
>
<div class="media-picker-thumb">
<img v-if="file.mimeType !== 'image/svg+xml'" :src="file.url" :alt="file.filename" loading="lazy" />
<div v-else class="media-picker-svg">SVG</div>
</div>
<div class="media-picker-name" :title="file.filename">{{ file.filename }}</div>
</div>
</div>
</el-dialog>
</div>
</template>
@@ -633,4 +772,171 @@ void load();
color: #888;
margin-bottom: 8px;
}
/* ── Banner image upload widget ── */
.banner-upload-field {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.banner-preview {
position: relative;
width: 100%;
max-width: 320px;
border-radius: 8px;
overflow: hidden;
border: 1px solid #252525;
background: #111;
}
.banner-preview-img {
width: 100%;
height: 120px;
object-fit: cover;
display: block;
}
.banner-preview-remove {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.7);
color: #fff;
border: 1px solid #333;
font-size: 16px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.banner-preview-remove:hover {
background: rgba(224, 85, 85, 0.85);
}
.banner-upload-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.banner-upload-btn {
display: inline-flex;
align-items: center;
padding: 6px 14px;
border-radius: 6px;
font-size: 12px;
font-family: inherit;
cursor: pointer;
border: 1px solid rgba(47, 181, 106, 0.5);
background: rgba(47, 181, 106, 0.1);
color: #2fb56a;
font-weight: 600;
transition: all 0.15s;
}
.banner-upload-btn:hover {
background: rgba(47, 181, 106, 0.2);
}
.banner-upload-btn.is-uploading {
opacity: 0.6;
cursor: wait;
}
.banner-pick-btn {
display: inline-flex;
align-items: center;
padding: 6px 14px;
border-radius: 6px;
font-size: 12px;
font-family: inherit;
cursor: pointer;
border: 1px solid #2a2a2a;
background: transparent;
color: #888;
transition: all 0.15s;
}
.banner-pick-btn:hover {
border-color: #444;
color: #ccc;
}
.banner-url-input {
max-width: 320px;
}
.banner-url-input :deep(.el-input__wrapper) {
font-size: 12px;
}
/* ── Media picker ── */
.media-picker-loading,
.media-picker-empty {
text-align: center;
padding: 40px 0;
color: #555;
font-size: 14px;
}
.media-picker-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 12px;
max-height: 420px;
overflow-y: auto;
}
.media-picker-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid #1e1e1e;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
}
.media-picker-card:hover {
border-color: rgba(47, 181, 106, 0.5);
box-shadow: 0 2px 12px rgba(47, 181, 106, 0.15);
}
.media-picker-thumb {
height: 80px;
background: #111;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.media-picker-thumb img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.media-picker-svg {
font-size: 14px;
font-weight: 700;
color: #666;
letter-spacing: 0.1em;
}
.media-picker-name {
padding: 6px 8px;
font-size: 11px;
color: #888;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@@ -0,0 +1,456 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAuthStore } from '../stores/auth';
import { useAdminLocale } from '../composables/useAdminLocale';
import api from '../api';
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
import { formatAmount, formatAmountFull } from '../utils/format-amount';
const { t, locale, localeTag } = useAdminLocale();
const auth = useAuthStore();
const route = useRoute();
const router = useRouter();
const activeTab = ref<'credit' | 'transfer'>('credit');
interface CreditTxRow {
id: string;
agentId: string;
agentUsername: string | null;
transactionType: string;
amount: string;
creditBefore: string;
creditAfter: string;
operatorUsername: string | null;
requestId: string | null;
remark: string | null;
createdAt: string;
}
interface TransferTxRow {
id: string;
transactionId: string;
playerId: string;
playerUsername: string | null;
parentAgentId: string | null;
parentAgentUsername: string | null;
transactionType: string;
amount: string;
balanceBefore: string;
balanceAfter: string;
operatorUsername: string | null;
remark: string | null;
createdAt: string;
}
const creditItems = ref<CreditTxRow[]>([]);
const creditTotal = ref(0);
const creditPage = ref(1);
const creditPageSize = ref(20);
const transferItems = ref<TransferTxRow[]>([]);
const transferTotal = ref(0);
const transferPage = ref(1);
const transferPageSize = ref(20);
const keyword = ref('');
const agentId = ref('');
const playerKeyword = ref('');
const parentAgentKeyword = ref('');
const operatorKeyword = ref('');
const transactionType = ref('');
const dateRange = ref<[Date, Date] | null>(null);
const creditApiPath = computed(() =>
auth.isAdmin.value ? '/admin/agents/credit-transactions' : '/agent/credit-transactions',
);
const transferApiPath = computed(() =>
auth.isAdmin.value ? '/admin/wallet/transfer-transactions' : '/agent/wallet-transactions',
);
function creditTypeLabel(type: string) {
if (type === 'CREDIT_INCREASE') return t('agent.credit.increase');
if (type === 'CREDIT_DECREASE') return t('agent.credit.decrease');
return type;
}
function transferTypeLabel(type: string) {
if (type === 'MANUAL_DEPOSIT') return t('finance.tx.deposit');
if (type === 'MANUAL_WITHDRAW') return t('finance.tx.withdraw');
return type;
}
function formatTime(v: string) {
return new Date(v).toLocaleString(localeTag.value, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
function dateParams() {
if (!dateRange.value?.length) return {};
const [from, to] = dateRange.value;
const end = new Date(to);
end.setHours(23, 59, 59, 999);
return {
dateFrom: from.toISOString(),
dateTo: end.toISOString(),
};
}
async function loadCredit() {
const { data } = await api.get(creditApiPath.value, {
params: {
page: creditPage.value,
pageSize: creditPageSize.value,
keyword: keyword.value.trim() || undefined,
agentId: agentId.value.trim() || undefined,
operatorKeyword: operatorKeyword.value.trim() || undefined,
transactionType: transactionType.value || undefined,
...dateParams(),
},
});
creditItems.value = (data.data?.items ?? []) as CreditTxRow[];
creditTotal.value = data.data?.total ?? 0;
}
async function loadTransfer() {
const parentRaw = parentAgentKeyword.value.trim();
const parentIsId = parentRaw && /^\d+$/.test(parentRaw);
const { data } = await api.get(transferApiPath.value, {
params: {
page: transferPage.value,
pageSize: transferPageSize.value,
keyword: playerKeyword.value.trim() || undefined,
...(parentIsId ? { parentAgentId: parentRaw } : parentRaw ? { parentAgentKeyword: parentRaw } : {}),
operatorKeyword: operatorKeyword.value.trim() || undefined,
transactionType: transactionType.value || undefined,
...dateParams(),
},
});
transferItems.value = (data.data?.items ?? []) as TransferTxRow[];
transferTotal.value = data.data?.total ?? 0;
}
function onSearch() {
if (activeTab.value === 'credit') {
creditPage.value = 1;
void loadCredit();
} else {
transferPage.value = 1;
void loadTransfer();
}
}
function onTabChange(tab: string | number | boolean) {
const next = tab === 'transfer' ? 'transfer' : 'credit';
activeTab.value = next;
transactionType.value = '';
void router.replace({ query: { ...route.query, tab: next } });
if (next === 'credit' && !creditItems.value.length) void loadCredit();
if (next === 'transfer' && !transferItems.value.length) void loadTransfer();
}
function openAgentFilter(id: string) {
activeTab.value = 'credit';
agentId.value = id;
keyword.value = '';
creditPage.value = 1;
void router.replace({ query: { tab: 'credit', agentId: id } });
void loadCredit();
}
function openParentAgentFilter(id: string) {
activeTab.value = 'transfer';
parentAgentKeyword.value = id;
playerKeyword.value = '';
transferPage.value = 1;
void router.replace({ query: { tab: 'transfer', parentAgentId: id } });
void loadTransfer();
}
onMounted(() => {
const tab = route.query.tab;
if (tab === 'transfer') activeTab.value = 'transfer';
const qAgent = route.query.agentId;
if (typeof qAgent === 'string' && qAgent.trim()) agentId.value = qAgent.trim();
const qParent = route.query.parentAgentId;
if (typeof qParent === 'string' && qParent.trim()) parentAgentKeyword.value = qParent.trim();
if (activeTab.value === 'credit') void loadCredit();
else void loadTransfer();
});
watch(
() => route.query.agentId,
(q) => {
const next = typeof q === 'string' ? q.trim() : '';
if (next !== agentId.value) {
agentId.value = next;
if (activeTab.value === 'credit') {
creditPage.value = 1;
void loadCredit();
}
}
},
);
</script>
<template>
<div class="admin-list-page finance-logs">
<el-tabs v-model="activeTab" class="finance-tabs" @tab-change="onTabChange">
<el-tab-pane :label="t('finance.tab.credit')" name="credit" />
<el-tab-pane :label="t('finance.tab.transfer')" name="transfer" />
</el-tabs>
<el-card class="filter-card" shadow="never">
<el-form inline>
<el-form-item :label="t('finance.filter.date_range')">
<el-date-picker
v-model="dateRange"
type="daterange"
:start-placeholder="t('common.to')"
:end-placeholder="t('common.to')"
value-format="YYYY-MM-DD"
style="width: 240px"
/>
</el-form-item>
<template v-if="activeTab === 'credit'">
<el-form-item :label="t('common.keyword')">
<el-input
v-model="keyword"
:placeholder="t('agent.credit_tx.filter_agent_ph')"
clearable
style="width: 160px"
@keyup.enter="onSearch"
/>
</el-form-item>
<el-form-item :label="t('agent.credit_tx.filter_agent_id')">
<el-input
v-model="agentId"
:placeholder="t('agent.credit_tx.filter_agent_id_ph')"
clearable
style="width: 120px"
@keyup.enter="onSearch"
/>
</el-form-item>
<el-form-item :label="t('agent.col.credit_type')">
<el-select v-model="transactionType" clearable :placeholder="t('common.all')" style="width: 110px">
<el-option :label="t('agent.credit.increase')" value="CREDIT_INCREASE" />
<el-option :label="t('agent.credit.decrease')" value="CREDIT_DECREASE" />
</el-select>
</el-form-item>
</template>
<template v-else>
<el-form-item :label="t('finance.col.player')">
<el-input
v-model="playerKeyword"
:placeholder="t('finance.filter.player_ph')"
clearable
style="width: 160px"
@keyup.enter="onSearch"
/>
</el-form-item>
<el-form-item :label="t('finance.col.parent_agent')">
<el-input
v-model="parentAgentKeyword"
:placeholder="t('finance.filter.parent_agent_ph')"
clearable
style="width: 140px"
@keyup.enter="onSearch"
/>
</el-form-item>
<el-form-item :label="t('agent.col.credit_type')">
<el-select v-model="transactionType" clearable :placeholder="t('common.all')" style="width: 110px">
<el-option :label="t('finance.tx.deposit')" value="MANUAL_DEPOSIT" />
<el-option :label="t('finance.tx.withdraw')" value="MANUAL_WITHDRAW" />
</el-select>
</el-form-item>
</template>
<el-form-item :label="t('agent.credit_tx.col.operator')">
<el-input
v-model="operatorKeyword"
:placeholder="t('finance.filter.operator_ph')"
clearable
style="width: 140px"
@keyup.enter="onSearch"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSearch">{{ t('common.search') }}</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card v-show="activeTab === 'credit'" class="data-card" shadow="never">
<div class="table-wrap">
<el-table :key="`${locale}-credit`" :data="creditItems" stripe>
<template #empty>
<AdminTableEmpty />
</template>
<el-table-column :label="t('audit.col.time')" min-width="158">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column :label="t('user.col.username')" min-width="110">
<template #default="{ row }">
<el-button
v-if="row.agentUsername"
link
type="primary"
@click="openAgentFilter(row.agentId)"
>
{{ row.agentUsername }}
</el-button>
<span v-else>{{ row.agentId }}</span>
</template>
</el-table-column>
<el-table-column :label="t('agent.col.credit_type')" width="88">
<template #default="{ row }">{{ creditTypeLabel(row.transactionType) }}</template>
</el-table-column>
<el-table-column :label="t('agent.col.credit_change')" width="108" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.amount)" placement="top">
<span :class="parseFloat(row.amount) >= 0 ? 'amt-pos' : 'amt-neg'">
{{ formatAmount(row.amount) }}
</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('agent.col.credit_before')" width="108" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.creditBefore)" placement="top">
<span>{{ formatAmount(row.creditBefore) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('agent.col.credit_after')" width="108" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.creditAfter)" placement="top">
<span>{{ formatAmount(row.creditAfter) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('agent.credit_tx.col.operator')" min-width="100">
<template #default="{ row }">{{ row.operatorUsername ?? '—' }}</template>
</el-table-column>
<el-table-column :label="t('finance.tx.request_id')" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.requestId ?? '—' }}</template>
</el-table-column>
<el-table-column prop="remark" :label="t('user.field.remark')" min-width="120" show-overflow-tooltip />
</el-table>
</div>
<div class="pager">
<el-pagination
v-model:current-page="creditPage"
v-model:page-size="creditPageSize"
:total="creditTotal"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@current-change="() => loadCredit()"
@size-change="() => { creditPage = 1; loadCredit(); }"
/>
</div>
</el-card>
<el-card v-show="activeTab === 'transfer'" class="data-card" shadow="never">
<div class="table-wrap">
<el-table :key="`${locale}-transfer`" :data="transferItems" stripe>
<template #empty>
<AdminTableEmpty />
</template>
<el-table-column :label="t('audit.col.time')" min-width="158">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column :label="t('finance.col.tx_id')" min-width="130" show-overflow-tooltip>
<template #default="{ row }">{{ row.transactionId }}</template>
</el-table-column>
<el-table-column :label="t('finance.col.player')" min-width="110">
<template #default="{ row }">{{ row.playerUsername ?? row.playerId }}</template>
</el-table-column>
<el-table-column :label="t('finance.col.parent_agent')" min-width="110">
<template #default="{ row }">
<el-button
v-if="row.parentAgentUsername && row.parentAgentId"
link
type="primary"
@click="openParentAgentFilter(row.parentAgentId!)"
>
{{ row.parentAgentUsername }}
</el-button>
<span v-else-if="row.parentAgentId">{{ row.parentAgentId }}</span>
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column :label="t('agent.col.credit_type')" width="72">
<template #default="{ row }">{{ transferTypeLabel(row.transactionType) }}</template>
</el-table-column>
<el-table-column :label="t('finance.col.balance_change')" width="108" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.amount)" placement="top">
<span :class="parseFloat(row.amount) >= 0 ? 'amt-pos' : 'amt-neg'">
{{ formatAmount(row.amount) }}
</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('finance.col.balance_before')" width="108" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.balanceBefore)" placement="top">
<span>{{ formatAmount(row.balanceBefore) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('finance.col.balance_after')" width="108" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.balanceAfter)" placement="top">
<span>{{ formatAmount(row.balanceAfter) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('agent.credit_tx.col.operator')" min-width="100">
<template #default="{ row }">{{ row.operatorUsername ?? '—' }}</template>
</el-table-column>
<el-table-column prop="remark" :label="t('user.field.remark')" min-width="120" show-overflow-tooltip />
</el-table>
</div>
<div class="pager">
<el-pagination
v-model:current-page="transferPage"
v-model:page-size="transferPageSize"
:total="transferTotal"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@current-change="() => loadTransfer()"
@size-change="() => { transferPage = 1; loadTransfer(); }"
/>
</div>
</el-card>
</div>
</template>
<style scoped>
.finance-tabs {
margin-bottom: 12px;
}
.finance-tabs :deep(.el-tabs__header) {
margin-bottom: 0;
}
.amt-pos {
color: var(--el-color-success);
font-weight: 600;
}
.amt-neg {
color: var(--el-color-danger);
font-weight: 600;
}
</style>

View File

@@ -15,6 +15,9 @@ export default defineConfig({
publicDir: resolve(__dirname, '../../packages/shared/public'),
server: {
port: 5174,
proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true } },
proxy: {
'/api': { target: 'http://localhost:3000', changeOrigin: true },
'/uploads': { target: 'http://localhost:3000', changeOrigin: true },
},
},
});