diff --git a/.gitignore b/.gitignore index ecacdb7..84576aa 100644 --- a/.gitignore +++ b/.gitignore @@ -14,10 +14,10 @@ apps/player/src/**/*.js apps/admin/src/**/*.js apps/api/prisma/migrations/*_migration_lock.toml -# 用户上传文件(保留目录结构与示例 Banner) +# 用户上传文件(保留目录结构与示例 Banner SVG) uploads/**/* !uploads/banners/ -!uploads/banners/** +!uploads/banners/*.svg !uploads/teams/ !uploads/teams/.gitkeep !uploads/contents/ diff --git a/apps/admin/src/i18n/admin-messages.ts b/apps/admin/src/i18n/admin-messages.ts index 4f2e8a0..186e660 100644 --- a/apps/admin/src/i18n/admin-messages.ts +++ b/apps/admin/src/i18n/admin-messages.ts @@ -38,6 +38,7 @@ const zh: Record = { 'nav.outrights': '优胜冠军', 'nav.bets': '注单管理', 'nav.credit_transactions': '额度流水', + 'nav.finance_logs': '财务流水', 'nav.cashback': '返水管理', 'nav.contents': '公共管理', 'nav.audit': '操作日志', @@ -219,6 +220,7 @@ const en: Record = { '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 = { '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', diff --git a/apps/admin/src/i18n/admin-pages-ms.ts b/apps/admin/src/i18n/admin-pages-ms.ts index d1fc5f2..75aa525 100644 --- a/apps/admin/src/i18n/admin-pages-ms.ts +++ b/apps/admin/src/i18n/admin-pages-ms.ts @@ -147,6 +147,21 @@ export const adminPagesMs: Record = { '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 = { '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', diff --git a/apps/admin/src/i18n/admin-pages.ts b/apps/admin/src/i18n/admin-pages.ts index 9c9117d..e539ecc 100644 --- a/apps/admin/src/i18n/admin-pages.ts +++ b/apps/admin/src/i18n/admin-pages.ts @@ -150,6 +150,21 @@ export const adminPagesZh: Record = { '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 = { '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 = { '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 = { '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', diff --git a/apps/admin/src/layouts/ManageLayout.vue b/apps/admin/src/layouts/ManageLayout.vue index 4bd34fa..86185e8 100644 --- a/apps/admin/src/layouts/ManageLayout.vue +++ b/apps/admin/src/layouts/ManageLayout.vue @@ -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') }, ]); diff --git a/apps/admin/src/router/index.ts b/apps/admin/src/router/index.ts index 9cde1f6..bfbc94b 100644 --- a/apps/admin/src/router/index.ts +++ b/apps/admin/src/router/index.ts @@ -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', diff --git a/apps/admin/src/views/AgentManager.vue b/apps/admin/src/views/AgentManager.vue index b113eaf..be60ecd 100644 --- a/apps/admin/src/views/AgentManager.vue +++ b/apps/admin/src/views/AgentManager.vue @@ -301,7 +301,7 @@ function onPlayerSizeChange(size: number) { loadAllPlayers(); } -function affiliationLabel(row: PlayerRow) { +function affiliationLabel(row: Pick) { 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') }} diff --git a/apps/admin/src/views/Contents.vue b/apps/admin/src/views/Contents.vue index 1ecd685..26aa851 100644 --- a/apps/admin/src/views/Contents.vue +++ b/apps/admin/src/views/Contents.vue @@ -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(null); + +/** Media picker state */ +const mediaPickerVisible = ref(false); +const mediaPickerLocale = ref(''); +const mediaFiles = ref([]); +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'" > - + + + + +
{{ t('common.loading') }}
+
{{ t('content.upload.no_media') }}
+
+
+
+ +
SVG
+
+
{{ file.filename }}
+
+
+
@@ -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; +} diff --git a/apps/admin/src/views/FinanceLogs.vue b/apps/admin/src/views/FinanceLogs.vue new file mode 100644 index 0000000..d7404ca --- /dev/null +++ b/apps/admin/src/views/FinanceLogs.vue @@ -0,0 +1,456 @@ + + + + + diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts index 395196d..2cc4643 100644 --- a/apps/admin/vite.config.ts +++ b/apps/admin/vite.config.ts @@ -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 }, + }, }, }); diff --git a/apps/api/src/applications/admin/admin.controller.ts b/apps/api/src/applications/admin/admin.controller.ts index 5956dd5..b0873f8 100644 --- a/apps/api/src/applications/admin/admin.controller.ts +++ b/apps/api/src/applications/admin/admin.controller.ts @@ -1085,14 +1085,20 @@ export class AdminController { @Query('pageSize') pageSize?: string, @Query('agentId') agentId?: string, @Query('keyword') keyword?: string, + @Query('operatorKeyword') operatorKeyword?: string, @Query('transactionType') transactionType?: string, + @Query('dateFrom') dateFrom?: string, + @Query('dateTo') dateTo?: string, ) { const result = await this.agents.listCreditTransactions({ page: page ? parseInt(page, 10) : 1, pageSize: pageSize ? parseInt(pageSize, 10) : 20, agentId: agentId ? BigInt(agentId) : undefined, keyword, + operatorKeyword, transactionType, + dateFrom: dateFrom ? new Date(dateFrom) : undefined, + dateTo: dateTo ? new Date(dateTo) : undefined, }); return jsonResponse(result); } @@ -1187,7 +1193,7 @@ export class AdminController { @Post('wallet/withdraw') @RequirePermissions(P.walletWithdraw) async withdraw(@CurrentUser('id') operatorId: bigint, @Body() dto: DepositDto & { userId: string }) { - const result = await this.wallet.withdraw( + const result = await this.agents.adminWithdrawFromPlayer( BigInt(dto.userId), dto.amount, operatorId, @@ -1204,6 +1210,35 @@ export class AdminController { return jsonResponse(result); } + @Get('wallet/transfer-transactions') + @RequirePermissions(P.walletDeposit, P.walletWithdraw, P.reports) + async listWalletTransferTransactions( + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + @Query('playerId') playerId?: string, + @Query('parentAgentId') parentAgentId?: string, + @Query('parentAgentKeyword') parentAgentKeyword?: string, + @Query('keyword') keyword?: string, + @Query('operatorKeyword') operatorKeyword?: string, + @Query('transactionType') transactionType?: string, + @Query('dateFrom') dateFrom?: string, + @Query('dateTo') dateTo?: string, + ) { + const result = await this.wallet.listTransferTransactions({ + page: page ? parseInt(page, 10) : 1, + pageSize: pageSize ? parseInt(pageSize, 10) : 20, + playerId: playerId ? BigInt(playerId) : undefined, + parentAgentId: parentAgentId ? BigInt(parentAgentId) : undefined, + parentAgentKeyword, + keyword, + operatorKeyword, + transactionType, + dateFrom: dateFrom ? new Date(dateFrom) : undefined, + dateTo: dateTo ? new Date(dateTo) : undefined, + }); + return jsonResponse(result); + } + @Post('leagues') @RequirePermissions(P.matches) async createLeague( diff --git a/apps/api/src/applications/agent/agent-portal.controller.ts b/apps/api/src/applications/agent/agent-portal.controller.ts index becd314..a93536c 100644 --- a/apps/api/src/applications/agent/agent-portal.controller.ts +++ b/apps/api/src/applications/agent/agent-portal.controller.ts @@ -359,17 +359,84 @@ export class AgentPortalController { return jsonResponse(summary); } - @Get('wallet-transactions') - async walletTransactions(@CurrentUser('id') agentId: bigint, @Query('playerId') playerId?: string) { - const players = playerId - ? [BigInt(playerId)] - : (await this.agents.getDirectPlayers(agentId)).map((p) => p.id); - - const transactions = await this.prisma.walletTransaction.findMany({ - where: { userId: { in: players } }, - orderBy: { createdAt: 'desc' }, - take: 50, + @Get('credit-transactions') + async listCreditTransactions( + @CurrentUser('id') agentId: bigint, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + @Query('agentId') filterAgentId?: string, + @Query('keyword') keyword?: string, + @Query('operatorKeyword') operatorKeyword?: string, + @Query('transactionType') transactionType?: string, + @Query('dateFrom') dateFrom?: string, + @Query('dateTo') dateTo?: string, + ) { + const scopedAgentIds = await this.agents.getSubtreeAgentIds(agentId); + const parsedAgentId = filterAgentId ? BigInt(filterAgentId) : undefined; + if (parsedAgentId && !scopedAgentIds.some((id) => id === parsedAgentId)) { + return jsonResponse({ items: [], total: 0, page: 1, pageSize: 20 }); + } + const result = await this.agents.listCreditTransactions({ + page: page ? parseInt(page, 10) : 1, + pageSize: pageSize ? parseInt(pageSize, 10) : 20, + agentId: parsedAgentId, + keyword, + operatorKeyword, + transactionType, + scopedAgentIds, + dateFrom: dateFrom ? new Date(dateFrom) : undefined, + dateTo: dateTo ? new Date(dateTo) : undefined, }); - return jsonResponse(transactions); + return jsonResponse(result); + } + + @Get('wallet-transactions') + async walletTransactions( + @CurrentUser('id') agentId: bigint, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + @Query('playerId') playerId?: string, + @Query('parentAgentId') parentAgentId?: string, + @Query('parentAgentKeyword') parentAgentKeyword?: string, + @Query('keyword') keyword?: string, + @Query('operatorKeyword') operatorKeyword?: string, + @Query('transactionType') transactionType?: string, + @Query('dateFrom') dateFrom?: string, + @Query('dateTo') dateTo?: string, + ) { + const scopedParentAgentIds = await this.agents.getSubtreeAgentIds(agentId); + const parsedParentAgentId = parentAgentId ? BigInt(parentAgentId) : undefined; + if ( + parsedParentAgentId && + !scopedParentAgentIds.some((id) => id === parsedParentAgentId) + ) { + return jsonResponse({ items: [], total: 0, page: 1, pageSize: 20 }); + } + if (playerId) { + const player = await this.prisma.user.findFirst({ + where: { id: BigInt(playerId), userType: 'PLAYER', deletedAt: null }, + select: { id: true, parentId: true }, + }); + if ( + !player?.parentId || + !scopedParentAgentIds.some((id) => id === player.parentId) + ) { + return jsonResponse({ items: [], total: 0, page: 1, pageSize: 20 }); + } + } + const result = await this.wallet.listTransferTransactions({ + page: page ? parseInt(page, 10) : 1, + pageSize: pageSize ? parseInt(pageSize, 10) : 20, + playerId: playerId ? BigInt(playerId) : undefined, + parentAgentId: parsedParentAgentId, + parentAgentKeyword, + scopedParentAgentIds, + keyword, + operatorKeyword, + transactionType, + dateFrom: dateFrom ? new Date(dateFrom) : undefined, + dateTo: dateTo ? new Date(dateTo) : undefined, + }); + return jsonResponse(result); } } diff --git a/apps/api/src/domains/agent/agents.service.ts b/apps/api/src/domains/agent/agents.service.ts index 4381fd7..a444235 100644 --- a/apps/api/src/domains/agent/agents.service.ts +++ b/apps/api/src/domains/agent/agents.service.ts @@ -265,6 +265,31 @@ export class AgentsService { return result; } + /** 管理员给玩家下分:扣款后刷新上级代理占用额度 */ + async adminWithdrawFromPlayer( + playerId: bigint, + amount: number, + operatorId: bigint, + remark?: string, + requestId?: string, + ) { + const result = await this.wallet.withdraw( + playerId, + amount, + operatorId, + remark, + requestId, + ); + const player = await this.prisma.user.findUnique({ + where: { id: playerId }, + select: { parentId: true }, + }); + if (player?.parentId) { + await this.recalculateUsedCredit(player.parentId); + } + return result; + } + /** 上下分弹窗:玩家余额 + 授信代理可用额度/限额上下文 */ async getPlayerTransferContext( playerId: bigint, @@ -732,7 +757,11 @@ export class AgentsService { pageSize?: number; agentId?: bigint; keyword?: string; + operatorKeyword?: string; transactionType?: string; + scopedAgentIds?: bigint[]; + dateFrom?: Date; + dateTo?: Date; }) { const page = Math.max(1, params.page ?? 1); const pageSize = Math.min(100, Math.max(1, params.pageSize ?? 20)); @@ -744,6 +773,31 @@ export class AgentsService { where.transactionType = params.transactionType.trim(); } + if (params.dateFrom || params.dateTo) { + where.createdAt = {}; + if (params.dateFrom) where.createdAt.gte = params.dateFrom; + if (params.dateTo) where.createdAt.lte = params.dateTo; + } + + const scopedIds = params.scopedAgentIds?.length ? params.scopedAgentIds : undefined; + + const operatorKeyword = params.operatorKeyword?.trim(); + if (operatorKeyword) { + const matchedOps = await this.prisma.user.findMany({ + where: { + deletedAt: null, + username: { contains: operatorKeyword, mode: 'insensitive' }, + }, + select: { id: true }, + take: 50, + }); + const operatorIds = matchedOps.map((u) => u.id); + if (!operatorIds.length) { + return { items: [], total: 0, page, pageSize }; + } + where.operatorId = { in: operatorIds }; + } + const keyword = params.keyword?.trim(); if (keyword) { const matched = await this.prisma.user.findMany({ @@ -755,7 +809,11 @@ export class AgentsService { select: { id: true }, take: 50, }); - const agentUserIds = matched.map((u) => u.id); + let agentUserIds = matched.map((u) => u.id); + if (scopedIds) { + const scopedSet = new Set(scopedIds.map((id) => id.toString())); + agentUserIds = agentUserIds.filter((id) => scopedSet.has(id.toString())); + } if (!agentUserIds.length) { return { items: [], total: 0, page, pageSize }; } @@ -768,7 +826,12 @@ export class AgentsService { where.agentId = { in: agentUserIds }; } } else if (params.agentId) { + if (scopedIds && !scopedIds.some((id) => id === params.agentId)) { + return { items: [], total: 0, page, pageSize }; + } where.agentId = params.agentId; + } else if (scopedIds) { + where.agentId = { in: scopedIds }; } const [rows, total] = await Promise.all([ diff --git a/apps/api/src/domains/ledger/wallet.service.ts b/apps/api/src/domains/ledger/wallet.service.ts index ae29d89..6063fad 100644 --- a/apps/api/src/domains/ledger/wallet.service.ts +++ b/apps/api/src/domains/ledger/wallet.service.ts @@ -308,6 +308,184 @@ export class WalletService { return { items, total, page, pageSize }; } + async listTransferTransactions(params: { + page?: number; + pageSize?: number; + playerId?: bigint; + parentAgentId?: bigint; + parentAgentKeyword?: string; + scopedParentAgentIds?: bigint[]; + keyword?: string; + operatorKeyword?: string; + transactionType?: string; + dateFrom?: Date; + dateTo?: Date; + }) { + const page = Math.max(1, params.page ?? 1); + const pageSize = Math.min(100, Math.max(1, params.pageSize ?? 20)); + const skip = (page - 1) * pageSize; + + const transferTypes = ['MANUAL_DEPOSIT', 'MANUAL_WITHDRAW']; + const where: Prisma.WalletTransactionWhereInput = { + transactionType: params.transactionType?.trim() + ? params.transactionType.trim() + : { in: transferTypes }, + }; + + if (params.dateFrom || params.dateTo) { + where.createdAt = {}; + if (params.dateFrom) where.createdAt.gte = params.dateFrom; + if (params.dateTo) where.createdAt.lte = params.dateTo; + } + + const operatorKeyword = params.operatorKeyword?.trim(); + if (operatorKeyword) { + const matchedOps = await this.prisma.user.findMany({ + where: { + deletedAt: null, + username: { contains: operatorKeyword, mode: 'insensitive' }, + }, + select: { id: true }, + take: 50, + }); + const operatorIds = matchedOps.map((u) => u.id); + if (!operatorIds.length) { + return { items: [], total: 0, page, pageSize }; + } + where.operatorId = { in: operatorIds }; + } + + let playerIds: bigint[] | undefined; + + if (params.playerId) { + playerIds = [params.playerId]; + } else { + const playerWhere: Prisma.UserWhereInput = { + userType: 'PLAYER', + deletedAt: null, + }; + + if (params.parentAgentId) { + playerWhere.parentId = params.parentAgentId; + } else if (params.parentAgentKeyword?.trim()) { + const matchedAgents = await this.prisma.user.findMany({ + where: { + userType: 'AGENT', + deletedAt: null, + username: { contains: params.parentAgentKeyword.trim(), mode: 'insensitive' }, + ...(params.scopedParentAgentIds?.length + ? { id: { in: params.scopedParentAgentIds } } + : {}), + }, + select: { id: true }, + take: 50, + }); + const agentIds = matchedAgents.map((a) => a.id); + if (!agentIds.length) { + return { items: [], total: 0, page, pageSize }; + } + playerWhere.parentId = { in: agentIds }; + } else if (params.scopedParentAgentIds?.length) { + playerWhere.parentId = { in: params.scopedParentAgentIds }; + } + + const keyword = params.keyword?.trim(); + if (keyword) { + playerWhere.username = { contains: keyword, mode: 'insensitive' }; + } + + if (params.parentAgentId || params.parentAgentKeyword?.trim() || params.scopedParentAgentIds?.length || keyword) { + const players = await this.prisma.user.findMany({ + where: playerWhere, + select: { id: true }, + take: 500, + }); + playerIds = players.map((p) => p.id); + if (!playerIds.length) { + return { items: [], total: 0, page, pageSize }; + } + } + } + + if (playerIds) { + where.userId = { in: playerIds }; + } + + const [rows, total] = await Promise.all([ + this.prisma.walletTransaction.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take: pageSize, + }), + this.prisma.walletTransaction.count({ where }), + ]); + + const userIds = [...new Set(rows.map((r) => r.userId))]; + const operatorIds = [ + ...new Set(rows.map((r) => r.operatorId).filter((id): id is bigint => id != null)), + ]; + + const [players, operators] = await Promise.all([ + userIds.length + ? this.prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, username: true, parentId: true }, + }) + : [], + operatorIds.length + ? this.prisma.user.findMany({ + where: { id: { in: operatorIds } }, + select: { id: true, username: true }, + }) + : [], + ]); + + const parentIds = [ + ...new Set(players.map((p) => p.parentId).filter((id): id is bigint => id != null)), + ]; + const parentAgents = parentIds.length + ? await this.prisma.user.findMany({ + where: { id: { in: parentIds } }, + select: { id: true, username: true }, + }) + : []; + + const playerById = new Map(players.map((p) => [p.id.toString(), p])); + const operatorById = new Map(operators.map((u) => [u.id.toString(), u.username])); + const parentById = new Map(parentAgents.map((a) => [a.id.toString(), a.username])); + + return { + items: rows.map((row) => { + const player = playerById.get(row.userId.toString()); + const parentId = player?.parentId; + return { + id: row.id.toString(), + transactionId: row.transactionId, + playerId: row.userId.toString(), + playerUsername: player?.username ?? null, + parentAgentId: parentId?.toString() ?? null, + parentAgentUsername: parentId ? (parentById.get(parentId.toString()) ?? null) : null, + transactionType: row.transactionType, + amount: row.amount.toString(), + balanceBefore: row.balanceBefore.toString(), + balanceAfter: row.balanceAfter.toString(), + frozenBefore: row.frozenBefore.toString(), + frozenAfter: row.frozenAfter.toString(), + operatorId: row.operatorId?.toString() ?? null, + operatorUsername: row.operatorId + ? (operatorById.get(row.operatorId.toString()) ?? null) + : null, + remark: row.remark, + createdAt: row.createdAt, + }; + }), + total, + page, + pageSize, + }; + } + async getTransactionStats(userId: bigint) { const [aggregates, byType] = await Promise.all([ this.prisma.walletTransaction.aggregate({ diff --git a/apps/player/src/components/BannerCarousel.vue b/apps/player/src/components/BannerCarousel.vue index e0495ac..2ddee87 100644 --- a/apps/player/src/components/BannerCarousel.vue +++ b/apps/player/src/components/BannerCarousel.vue @@ -56,7 +56,9 @@ function onBannerClick(banner: BannerItem) { return; } if (banner.linkType === 'URL' && banner.linkTarget) { - window.open(banner.linkTarget, '_blank'); + let url = banner.linkTarget.trim(); + if (!/^https?:\/\//i.test(url)) url = `https://${url}`; + window.open(url, '_blank'); } } diff --git a/apps/player/src/constants/defaultBanner.ts b/apps/player/src/constants/defaultBanner.ts index 502a6cb..9d8e4a0 100644 --- a/apps/player/src/constants/defaultBanner.ts +++ b/apps/player/src/constants/defaultBanner.ts @@ -21,7 +21,10 @@ function pickImageUrl(url?: string | null): string { } export function resolveBanners(banners: BannerItem[] | undefined | null): BannerItem[] { - const fromApi = (banners ?? []).map((banner) => ({ + // API 数据尚未到达(加载中),返回空数组避免闪烁默认图 + if (banners == null) return []; + + const fromApi = banners.map((banner) => ({ ...banner, translation: { ...banner.translation, @@ -31,6 +34,7 @@ export function resolveBanners(banners: BannerItem[] | undefined | null): Banner if (fromApi.length > 0) return fromApi; + // API 返回了但列表为空,展示默认 Banner const defaultSlide: BannerItem = { ...DEFAULT_BANNER, translation: { diff --git a/docker/nginx/admin.conf b/docker/nginx/admin.conf index c98df63..87e2b3a 100644 --- a/docker/nginx/admin.conf +++ b/docker/nginx/admin.conf @@ -18,6 +18,15 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + location /uploads/ { + proxy_pass http://api:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location / { try_files $uri $uri/ /index.html; }