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:
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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') },
|
||||
]);
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)">×</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>
|
||||
|
||||
456
apps/admin/src/views/FinanceLogs.vue
Normal file
456
apps/admin/src/views/FinanceLogs.vue
Normal 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>
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user