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

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