diff --git a/apps/admin/src/App.vue b/apps/admin/src/App.vue index 17202e8..086c21a 100644 --- a/apps/admin/src/App.vue +++ b/apps/admin/src/App.vue @@ -314,7 +314,7 @@ body::-webkit-scrollbar { } .admin-list-page > .list-panel { border: 1px solid rgba(255, 255, 255, 0.06); - background: rgba(18, 18, 18, 0.85); + background: #121212; padding: 0 10px 10px; } .admin-list-page > .data-card .el-card__body { @@ -379,7 +379,6 @@ body { border-color: var(--border) !important; border-radius: var(--radius) !important; box-shadow: var(--shadow) !important; - backdrop-filter: blur(10px); } .el-card__header { border-bottom-color: #1e1e1e !important; @@ -394,7 +393,7 @@ body { .el-table::before { background-color: #222 !important; } .el-table th.el-table__cell { background: rgba(255,255,255,0.02) !important; - color: #555 !important; + color: #888 !important; font-size: 11px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; border-bottom-color: #1e1e1e !important; @@ -535,7 +534,69 @@ body { box-shadow: 0 1px 0 rgba(255, 255, 255, 0.16) inset, 0 2px 10px rgba(0, 0, 0, 0.4), 0 0 16px rgba(47, 181, 106, 0.25) !important; } -.el-form-item__label { color: var(--text-muted) !important; font-size: 11px !important; font-weight: 600 !important; letter-spacing: 0.04em !important; } +.el-form-item__label { color: #aaa !important; font-size: 12px !important; font-weight: 600 !important; letter-spacing: 0.02em !important; } + +/* ── Dialog / overlay:实心背景,避免噪点透底发糊 ── */ +.el-overlay { + background-color: rgba(0, 0, 0, 0.72) !important; + backdrop-filter: none !important; +} +.el-dialog { + background: #1a1a1a !important; + border: 1px solid #333 !important; + border-radius: var(--radius) !important; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.65) !important; +} +.el-dialog__header { + border-bottom: 1px solid #2a2a2a !important; + padding: 16px 20px 14px !important; + margin-right: 0 !important; +} +.el-dialog__title { + font-size: 16px !important; + font-weight: 700 !important; + color: #f0f0f0 !important; + letter-spacing: 0.02em !important; +} +.el-dialog__headerbtn .el-dialog__close { + color: #888 !important; +} +.el-dialog__headerbtn:hover .el-dialog__close { + color: #fff !important; +} +.el-dialog__body { + padding: 18px 20px !important; + font-size: 14px !important; + color: #ddd !important; +} +.el-dialog__footer { + border-top: 1px solid #2a2a2a !important; + padding: 12px 20px 16px !important; +} +.el-dialog .el-form-item__label { + font-size: 13px !important; + color: #aaa !important; +} +.el-dialog .el-descriptions__label { + color: #888 !important; + font-size: 12px !important; + font-weight: 600 !important; + background: #141414 !important; +} +.el-dialog .el-descriptions__content { + color: #e0e0e0 !important; + font-size: 13px !important; + background: #1a1a1a !important; +} +.el-dialog .el-descriptions__cell { + border-color: #2a2a2a !important; +} +.user-edit-dialog .el-dialog__body, +.agent-edit-dialog .el-dialog__body, +.create-account-dialog .el-dialog__body { + max-height: min(70vh, 640px); + overflow-y: auto; +} .el-statistic__head { color: #555 !important; font-size: 11px !important; font-weight: 700 !important; letter-spacing: 0.06em !important; text-transform: uppercase !important; } .el-statistic__content .el-statistic__number { font-size: 26px !important; font-weight: 800 !important; color: #fff !important; } diff --git a/apps/admin/src/components/AgentCreditContext.vue b/apps/admin/src/components/AgentCreditContext.vue index 6780e22..0ddd418 100644 --- a/apps/admin/src/components/AgentCreditContext.vue +++ b/apps/admin/src/components/AgentCreditContext.vue @@ -103,9 +103,9 @@ function fmtFull(value: string | null | undefined) { margin-top: 12px; } .credit-context-title { - font-size: 12px; - font-weight: 600; - color: #888; + font-size: 13px; + font-weight: 700; + color: #bbb; margin-bottom: 8px; } .credit-context-alert { diff --git a/apps/admin/src/components/WalletTransferContext.vue b/apps/admin/src/components/WalletTransferContext.vue index be3c3e5..0b83e96 100644 --- a/apps/admin/src/components/WalletTransferContext.vue +++ b/apps/admin/src/components/WalletTransferContext.vue @@ -103,9 +103,9 @@ function limitLabel(value: string | null) { .transfer-context { margin-bottom: 16px; min-height: 48px; } .transfer-context-section + .transfer-context-section { margin-top: 12px; } .transfer-context-title { - font-size: 12px; - font-weight: 600; - color: #888; + font-size: 13px; + font-weight: 700; + color: #bbb; margin-bottom: 8px; } .transfer-context-alert { margin-bottom: 0; } diff --git a/apps/admin/src/i18n/admin-messages.ts b/apps/admin/src/i18n/admin-messages.ts index c485e3d..4f2e8a0 100644 --- a/apps/admin/src/i18n/admin-messages.ts +++ b/apps/admin/src/i18n/admin-messages.ts @@ -42,6 +42,7 @@ const zh: Record = { 'nav.contents': '公共管理', 'nav.audit': '操作日志', 'nav.smoke_tests': '自动化测试', + 'nav.media': '媒体库', 'nav.players': '直属玩家', 'nav.subAgents': '下级代理', 'nav.myBets': '注单查询', @@ -222,6 +223,7 @@ const en: Record = { 'nav.contents': 'Public Content', 'nav.audit': 'Audit Log', 'nav.smoke_tests': 'Smoke tests', + 'nav.media': 'Media Library', 'nav.players': 'My Players', 'nav.subAgents': 'Sub-Agents', 'nav.myBets': 'Bet Search', @@ -402,6 +404,7 @@ const ms: Record = { 'nav.contents': 'Kandungan awam', 'nav.audit': 'Log audit', 'nav.smoke_tests': 'Ujian asap', + 'nav.media': 'Perpustakaan Media', 'nav.players': 'Pemain saya', 'nav.subAgents': 'Sub-ejen', 'nav.myBets': 'Carian pertaruhan', diff --git a/apps/admin/src/i18n/admin-pages-ms.ts b/apps/admin/src/i18n/admin-pages-ms.ts index 57c45e0..d1fc5f2 100644 --- a/apps/admin/src/i18n/admin-pages-ms.ts +++ b/apps/admin/src/i18n/admin-pages-ms.ts @@ -2,6 +2,7 @@ export const adminPagesMs: Record = { 'common.detail': 'Butiran', 'common.create': 'Cipta', + 'common.create_btn': '+ Baharu', 'common.save': 'Simpan', 'common.close': 'Tutup', 'common.import': 'Import', @@ -77,12 +78,16 @@ export const adminPagesMs: Record = { 'user.msg.password_saved': 'Kata laluan dikemas kini: {password}', 'user.hint.password_reset_to_view': 'Tiada rekod. Isi Set semula kata laluan di bawah dan simpan untuk lihat di sini.', 'user.ph.username_unique': 'Nama log masuk unik', + 'user.ph.username_player': 'Huruf dan digit, 3–32 aksara', + 'user.hint.username_player': 'Hanya huruf dan digit Inggeris; tiada Cina atau simbol khas', 'user.ph.no_agent': 'Tiada (terus platform)', 'user.hint.no_agent': 'Biarkan kosong untuk pemain diurus platform', + 'user.hint.platform_direct_player': 'Pemain ini di bawah platform (terus admin).', 'user.hint.initial_balance': 'Auto tambah baki semasa cipta; 0 = tiada bonus', 'user.hint.deposit_remark': 'Ditulis ke lejar jika baki permulaan > 0', 'user.hint.freeze_in_list': 'Beku/nyahbeku dari lajur tindakan senarai', 'user.hint.agent_change': 'Kosong = terus platform; perubahan dikira semula kredit ejen', + 'user.hint.agent_readonly': 'Ejen induk tidak boleh diubah selepas penciptaan', 'user.hint.allow_password_change': 'Matikan: semua pemain tidak boleh ubah kata laluan', 'user.hint.allow_username_change': 'Hidupkan: semua pemain boleh ubah nama log masuk', 'user.hint.view_password': 'Hanya kata laluan cipta/set semula admin; dibersihkan jika pemain ubah sendiri', @@ -98,12 +103,18 @@ export const adminPagesMs: Record = { 'user.hint.account_type': 'Ejen guna had kredit; pemain boleh di bawah ejen', 'agent.create_btn': '+ Ejen peringkat 1 baharu', + 'agent.create_sub_btn': '+ Ejen peringkat 2 baharu', 'agent.create_sub': 'Cipta sub-ejen', + 'agent.hint.sub_agent_parent': 'Ejen peringkat 2 mesti di bawah ejen peringkat 1', 'agent.hint.creating_under_agent': 'Cipta akaun di bawah ejen ini', 'agent.filter.username_ph': 'Nama pengguna', + 'agent_mgr.tab.players': 'Pemain', + 'agent_mgr.tab.agents': 'Ejen', 'agent.col.level': 'Peringkat', 'agent.col.credit': 'Had / Digunakan / Tersedia', 'agent.col.direct_players': 'Pemain terus', + 'agent.direct_players_title': 'Pemain terus · {name}', + 'agent.platform_row_name': 'Platform', 'agent.col.sub_agents': 'Sub-ejen', 'agent.col.cashback': 'Kadar rebat', 'agent.col.phone': 'Telefon', @@ -378,6 +389,7 @@ export const adminPagesMs: Record = { 'matchEditor.ph.selection_name': 'Nama dipaparkan kepada pemain', 'err.username_required': 'Sila isi nama pengguna', + 'err.username_player_invalid': 'Nama pemain hanya huruf/digit Inggeris (3–32 aksara)', 'err.password_min': 'Kata laluan sekurang-kurangnya 8 aksara', 'err.password_mismatch': 'Kata laluan tidak sepadan', 'err.credit_negative': 'Had kredit tidak boleh negatif', @@ -638,4 +650,37 @@ export const adminPagesMs: Record = { 'smoke.msg.copy_ok': 'Disalin ke papan keratan', 'smoke.msg.copy_failed': 'Gagal menyalin — pilih log secara manual', 'audit.action.RUN_SMOKE_TESTS': 'Jalankan ujian asap', + + 'media.title': 'Perpustakaan Media', + 'media.upload_btn': 'Muat Naik Fail', + 'media.category.all': 'Semua', + 'media.category.banners': 'Banner', + 'media.category.teams': 'Logo Pasukan', + 'media.category.contents': 'Gambar Kandungan', + 'media.col.preview': 'Pratonton', + 'media.col.filename': 'Nama Fail', + 'media.col.category': 'Kategori', + 'media.col.size': 'Saiz', + 'media.col.status': 'Status', + 'media.col.uploaded': 'Dimuat Naik', + 'media.col.actions': 'Tindakan', + 'media.status.used': 'Digunakan', + 'media.status.unused': 'Tidak Digunakan', + 'media.purge_btn': 'Buang Yang Tidak Digunakan', + 'media.purge_confirm': 'Padam {n} fail yang tidak digunakan? Ini tidak boleh dibatalkan.', + 'media.purge_none': 'Tiada fail yang tidak digunakan', + 'media.purge_success': '{n} fail dipadam', + 'media.delete_confirm': 'Padam fail ini?', + 'media.delete_success': 'Dipadam', + 'media.upload_success': 'Berjaya dimuat naik', + 'media.upload_failed': 'Muat naik gagal', + 'media.copy_url': 'Salin URL', + 'media.url_copied': 'URL disalin', + 'media.upload_dialog': 'Muat Naik Fail', + 'media.upload_hint': 'PNG, JPG, WEBP, GIF, SVG — maks 5 MB', + 'media.upload_category': 'Kategori', + 'media.drop_hint': 'Lepaskan fail di sini atau klik untuk pilih', + 'media.no_files': 'Tiada fail lagi', + 'media.refresh': 'Muat Semula', + 'media.unused_count': '{n} tidak digunakan', }; diff --git a/apps/admin/src/i18n/admin-pages.ts b/apps/admin/src/i18n/admin-pages.ts index f2d4344..9c9117d 100644 --- a/apps/admin/src/i18n/admin-pages.ts +++ b/apps/admin/src/i18n/admin-pages.ts @@ -2,6 +2,7 @@ export const adminPagesZh: Record = { 'common.detail': '详情', 'common.create': '创建', + 'common.create_btn': '+ 新建', 'common.save': '保存', 'common.close': '关闭', 'common.import': '导入', @@ -77,12 +78,16 @@ export const adminPagesZh: Record = { 'user.msg.password_saved': '密码已更新,当前可查密码:{password}', 'user.hint.password_reset_to_view': '旧账号暂无记录。请在下方「重置密码」填写新密码并保存,即可在此查看。', 'user.ph.username_unique': '登录用户名,唯一', + 'user.ph.username_player': '字母与数字,3–32 位', + 'user.hint.username_player': '仅允许英文字母和数字,不可含中文或特殊符号', 'user.ph.no_agent': '不设置(平台直属玩家)', 'user.hint.no_agent': '留空表示不挂靠代理,由平台直接管理', + 'user.hint.platform_direct_player': '该玩家隶属于平台(管理员直属)', 'user.hint.initial_balance': '创建后自动上分,0 表示不开户赠金', 'user.hint.deposit_remark': '有初始余额时写入流水备注', 'user.hint.freeze_in_list': '冻结/解冻请在列表操作列进行', 'user.hint.agent_change': '留空表示平台直属;变更后会重算相关代理已用授信', + 'user.hint.agent_readonly': '所属代理创建后不可修改', 'user.hint.allow_password_change': '关闭后所有玩家均不可在客户端修改密码', 'user.hint.allow_username_change': '开启后所有玩家均可在资料页修改登录账号名', 'user.hint.view_password': '仅保存后台创建或重置时的密码;玩家自行改密后会清除', @@ -98,12 +103,18 @@ export const adminPagesZh: Record = { 'user.hint.account_type': '代理使用授信额度;玩家可挂靠代理并上分', 'agent.create_btn': '+ 新建一级代理', + 'agent.create_sub_btn': '+ 新建二级代理', 'agent.create_sub': '创建二级代理', + 'agent.hint.sub_agent_parent': '二级代理必须挂靠在一级代理名下', 'agent.hint.creating_under_agent': '在此代理下创建账号', 'agent.filter.username_ph': '用户名', + 'agent_mgr.tab.players': '玩家', + 'agent_mgr.tab.agents': '代理', 'agent.col.level': '层级', 'agent.col.credit': '授信 / 已用 / 可用', 'agent.col.direct_players': '直属玩家', + 'agent.direct_players_title': '直属玩家 · {name}', + 'agent.platform_row_name': '平台', 'agent.col.sub_agents': '下级代理', 'agent.col.cashback': '返水率', 'agent.col.phone': '手机', @@ -394,6 +405,7 @@ export const adminPagesZh: Record = { 'matchEditor.ph.selection_name': '玩家端显示名称', 'err.username_required': '请填写用户名', + 'err.username_player_invalid': '玩家用户名仅可使用英文字母和数字(3–32 位),不可含中文或特殊符号', 'err.password_min': '密码至少 8 位', 'err.password_mismatch': '两次密码不一致', 'err.credit_negative': '授信额度不能为负', @@ -733,11 +745,45 @@ export const adminPagesZh: Record = { 'smoke.msg.copy_ok': '已复制到剪贴板', 'smoke.msg.copy_failed': '复制失败,请手动选中日志复制', 'audit.action.RUN_SMOKE_TESTS': '运行自动化测试', + + 'media.title': '媒体库', + 'media.upload_btn': '上传文件', + 'media.category.all': '全部', + 'media.category.banners': 'Banner', + 'media.category.teams': '赛事 Logo', + 'media.category.contents': '内容图片', + 'media.col.preview': '预览', + 'media.col.filename': '文件名', + 'media.col.category': '分类', + 'media.col.size': '大小', + 'media.col.status': '使用状态', + 'media.col.uploaded': '上传时间', + 'media.col.actions': '操作', + 'media.status.used': '使用中', + 'media.status.unused': '未使用', + 'media.purge_btn': '清除未使用', + 'media.purge_confirm': '确认删除 {n} 个未使用的文件?此操作不可撤销。', + 'media.purge_none': '暂无未使用的文件', + 'media.purge_success': '已清除 {n} 个文件', + 'media.delete_confirm': '确认删除此文件?', + 'media.delete_success': '已删除', + 'media.upload_success': '上传成功', + 'media.upload_failed': '上传失败', + 'media.copy_url': '复制链接', + 'media.url_copied': '链接已复制', + 'media.upload_dialog': '上传文件', + 'media.upload_hint': '支持 PNG、JPG、WEBP、GIF、SVG,最大 5MB', + 'media.upload_category': '分类', + 'media.drop_hint': '拖拽文件至此,或点击选择', + 'media.no_files': '暂无文件', + 'media.refresh': '刷新', + 'media.unused_count': '{n} 个未使用', }; export const adminPagesEn: Record = { 'common.detail': 'Details', 'common.create': 'Create', + 'common.create_btn': '+ New', 'common.save': 'Save', 'common.close': 'Close', 'common.import': 'Import', @@ -813,12 +859,16 @@ export const adminPagesEn: Record = { 'user.msg.password_saved': 'Password updated. Viewable password: {password}', 'user.hint.password_reset_to_view': 'No stored password. Set one below under Reset password and save to view it here.', 'user.ph.username_unique': 'Unique login username', + 'user.ph.username_player': 'Letters and digits, 3–32 chars', + 'user.hint.username_player': 'English letters and digits only; no Chinese or special characters', 'user.ph.no_agent': 'None (platform direct)', 'user.hint.no_agent': 'Leave empty for platform-managed player', + 'user.hint.platform_direct_player': 'This player belongs to the platform (admin direct).', 'user.hint.initial_balance': 'Auto top-up on create; 0 = no bonus', 'user.hint.deposit_remark': 'Written to ledger when initial balance > 0', 'user.hint.freeze_in_list': 'Freeze/unfreeze from the list actions', 'user.hint.agent_change': 'Empty = platform direct; changes recalc agent credit', + 'user.hint.agent_readonly': 'Agent assignment cannot be changed after creation', 'user.hint.allow_password_change': 'When off, no player can change password in the app', 'user.hint.allow_username_change': 'When on, all players can change login username in profile', 'user.hint.view_password': 'Only passwords set on create/reset; cleared after player self-change', @@ -834,12 +884,18 @@ export const adminPagesEn: Record = { 'user.hint.account_type': 'Agents use credit limits; players can belong to an agent and receive top-ups', 'agent.create_btn': '+ New tier-1 agent', + 'agent.create_sub_btn': '+ New tier-2 agent', 'agent.create_sub': 'Create sub-agent', + 'agent.hint.sub_agent_parent': 'Tier-2 agents must belong to a tier-1 agent', 'agent.hint.creating_under_agent': 'Create account under this agent', 'agent.filter.username_ph': 'Username', + 'agent_mgr.tab.players': 'Players', + 'agent_mgr.tab.agents': 'Agents', 'agent.col.level': 'Level', 'agent.col.credit': 'Limit / Used / Available', 'agent.col.direct_players': 'Direct players', + 'agent.direct_players_title': 'Direct players · {name}', + 'agent.platform_row_name': 'Platform', 'agent.col.sub_agents': 'Sub-agents', 'agent.col.cashback': 'Cashback rate', 'agent.col.phone': 'Phone', @@ -1130,6 +1186,7 @@ export const adminPagesEn: Record = { 'matchEditor.ph.selection_name': 'Name shown to players', 'err.username_required': 'Username is required', + 'err.username_player_invalid': 'Player username must be 3–32 English letters or digits only', 'err.password_min': 'Password must be at least 8 characters', 'err.password_mismatch': 'Passwords do not match', 'err.credit_negative': 'Credit limit cannot be negative', @@ -1470,4 +1527,37 @@ export const adminPagesEn: Record = { 'smoke.msg.copy_ok': 'Copied to clipboard', 'smoke.msg.copy_failed': 'Copy failed — select the log manually', 'audit.action.RUN_SMOKE_TESTS': 'Run smoke tests', + + 'media.title': 'Media Library', + 'media.upload_btn': 'Upload File', + 'media.category.all': 'All', + 'media.category.banners': 'Banners', + 'media.category.teams': 'Team Logos', + 'media.category.contents': 'Content Images', + 'media.col.preview': 'Preview', + 'media.col.filename': 'Filename', + 'media.col.category': 'Category', + 'media.col.size': 'Size', + 'media.col.status': 'Status', + 'media.col.uploaded': 'Uploaded', + 'media.col.actions': 'Actions', + 'media.status.used': 'In Use', + 'media.status.unused': 'Unused', + 'media.purge_btn': 'Purge Unused', + 'media.purge_confirm': 'Delete {n} unused file(s)? This cannot be undone.', + 'media.purge_none': 'No unused files', + 'media.purge_success': '{n} file(s) deleted', + 'media.delete_confirm': 'Delete this file?', + 'media.delete_success': 'Deleted', + 'media.upload_success': 'Upload successful', + 'media.upload_failed': 'Upload failed', + 'media.copy_url': 'Copy URL', + 'media.url_copied': 'URL copied', + 'media.upload_dialog': 'Upload File', + 'media.upload_hint': 'PNG, JPG, WEBP, GIF, SVG — max 5 MB', + 'media.upload_category': 'Category', + 'media.drop_hint': 'Drop file here or click to select', + 'media.no_files': 'No files yet', + 'media.refresh': 'Refresh', + 'media.unused_count': '{n} unused', }; diff --git a/apps/admin/src/layouts/ManageLayout.vue b/apps/admin/src/layouts/ManageLayout.vue index 9203f01..4bd34fa 100644 --- a/apps/admin/src/layouts/ManageLayout.vue +++ b/apps/admin/src/layouts/ManageLayout.vue @@ -22,6 +22,7 @@ const adminMenus = computed(() => [ { path: '/cashback', label: t('nav.cashback') }, { path: '/bets', label: t('nav.bets') }, { path: '/contents', label: t('nav.contents') }, + { path: '/media', label: t('nav.media') }, { path: '/audit', label: t('nav.audit') }, { path: '/smoke-tests', label: t('nav.smoke_tests') }, ]); @@ -175,6 +176,7 @@ watch(() => route.path, () => { {{ currentLabel }} +
@@ -390,6 +392,17 @@ watch(() => route.path, () => { box-shadow: 0 0 8px rgba(47, 181, 106, 0.45); } +.topbar-page-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + margin-left: 8px; +} +.topbar-page-actions:empty { + display: none; +} + .topbar-right { display: flex; align-items: center; gap: 12px; flex-shrink: 0; diff --git a/apps/admin/src/router/index.ts b/apps/admin/src/router/index.ts index 1e9ccd3..9cde1f6 100644 --- a/apps/admin/src/router/index.ts +++ b/apps/admin/src/router/index.ts @@ -87,8 +87,8 @@ const router = createRouter({ meta: { adminOnly: true }, }, { - path: 'smoke-tests', - component: () => import('../views/SmokeTests.vue'), + path: 'media', + component: () => import('../views/MediaLibrary.vue'), meta: { adminOnly: true }, }, { diff --git a/apps/admin/src/views/AgentManager.vue b/apps/admin/src/views/AgentManager.vue index 4686e0e..b113eaf 100644 --- a/apps/admin/src/views/AgentManager.vue +++ b/apps/admin/src/views/AgentManager.vue @@ -19,6 +19,8 @@ import { type PlayerDetail, type PlayerCreateForm, type PlayerEditForm, + formatPlayerAffiliationLabel, + assertPlayerUsername, } from './user-form'; import { emptyAgentEditForm, @@ -28,6 +30,8 @@ import { type AgentEditForm, } from './agent-form'; import { subAgentAccountStatus } from './agent/agent-sub-agent-form'; + +type DisplayAgentRow = AgentRow; import { formatAmount, formatAmountFull, @@ -47,26 +51,42 @@ import { type AgentCreditAdjustContext, } from '../utils/agent-credit-context'; -/* ─── Main agent list ─── */ -const agents = ref([]); -const total = ref(0); -const page = ref(1); -const pageSize = ref(20); -const keyword = ref(''); -const filterStatus = ref(''); +/* ─── Tier-1 agent list ─── */ +const tier1Agents = ref([]); +const tier1Total = ref(0); +const tier1Page = ref(1); +const tier1PageSize = ref(20); +const tier1Keyword = ref(''); +const tier1FilterStatus = ref(''); + +/* ─── Tier-2 agent list ─── */ +const tier2Agents = ref([]); +const tier2Total = ref(0); +const tier2Page = ref(1); +const tier2PageSize = ref(20); +const tier2Keyword = ref(''); +const tier2FilterStatus = ref(''); + +/* ─── View tab: players | tier1Agents | tier2Agents ─── */ +const activeViewTab = ref('players'); + +/* ─── All players list ─── */ +const allPlayers = ref([]); +const playerTotal = ref(0); +const playerPage = ref(1); +const playerPageSize = ref(20); +const playerKeyword = ref(''); +const playerFilterStatus = ref(''); +const playerFilterAgent = ref(''); +const playerLoading = ref(false); +const agentOptions = ref<{ id: string; username: string; level: number; parentUsername?: string | null }[]>([]); /* ─── Expansion state ─── */ const expandedSet = ref(new Set()); const agentPlayersMap = ref>({}); -const agentSubAgentsMap = ref>({}); const expandLoading = ref>({}); -const innerTabMap = ref>({}); -const agentTableRef = ref(); - -/* Sub-agent nested expansion (players under tier-2 agents) */ -const subAgentExpandedKeys = ref([]); -const subAgentPlayersMap = ref>({}); -const subAgentExpandLoading = ref>({}); +const tier1AgentTableRef = ref(); +const tier2AgentTableRef = ref(); /* ─── Dialogs ─── */ const createVisible = ref(false); @@ -82,7 +102,8 @@ const creditLoading = ref(false); /* ─── Create form (unified) ─── */ const createForm = ref(emptyPlayerCreateForm()); -const createParentAgentId = ref(''); // set when creating from expansion +const createParentAgentId = ref(''); +const createParentLocked = ref(false); const createAccountMode = ref(0); // 0=player, 1=tier1Agent, 2=subAgent /* ─── Edit forms ─── */ @@ -121,74 +142,201 @@ const resetLoading = ref(false); const resetConfirmPhrase = ref(''); const settingsCollapseOpen = ref([]); +const createDialogTitle = computed(() => { + if (createAccountMode.value === 1) return t('agent.dialog.create'); + if (createAccountMode.value === 2) return t('agent_portal.create_sub_agent_dialog'); + return t('user.dialog.create'); +}); + +const tier1AgentOptions = computed(() => agentOptions.value.filter((a) => a.level === 1)); + +function agentOptionLabel(a: { username: string; id: string; level: number; parentUsername?: string | null }) { + if (a.level === 2 && a.parentUsername) { + return `${a.parentUsername} / ${a.username} (#${a.id})`; + } + return `${a.username} (#${a.id})`; +} + +function resolveCreateParentLabel(agentId: string) { + const hit = agentOptions.value.find((a) => a.id === agentId); + if (hit) return agentOptionLabel(hit); + const tier1 = tier1Agents.value.find((a) => a.userId === agentId); + if (tier1) return tier1.username; + const tier2 = tier2Agents.value.find((a) => a.userId === agentId); + if (tier2) { + return tier2.parentUsername ? `${tier2.parentUsername} / ${tier2.username}` : tier2.username; + } + return agentId; +} + /* ─── Init ─── */ onMounted(() => { loadPlayerSettings(); loadBettingLimits(); loadAgentSuspendSettings(); loadResetDatabaseStatus(); - load(); + loadAgentOptions(); + loadAllPlayers(); + loadTier1Agents(); + loadTier2Agents(); }); +/* ─── Load tier-1 agents ─── */ +async function loadTier1Agents() { + const { data } = await api.get('/admin/agents', { + params: { + page: tier1Page.value, + pageSize: tier1PageSize.value, + keyword: tier1Keyword.value.trim() || undefined, + status: tier1FilterStatus.value || undefined, + level: 1, + }, + }); + tier1Agents.value = data.data.items as AgentRow[]; + tier1Total.value = data.data.total; +} + +function onTier1PageChange(p: number) { + tier1Page.value = p; + loadTier1Agents(); +} + +function onTier1SizeChange(size: number) { + tier1PageSize.value = size; + tier1Page.value = 1; + loadTier1Agents(); +} + +function searchTier1Agents() { + tier1Page.value = 1; + loadTier1Agents(); +} + +/* ─── Load tier-2 agents ─── */ +async function loadTier2Agents() { + const { data } = await api.get('/admin/agents', { + params: { + page: tier2Page.value, + pageSize: tier2PageSize.value, + keyword: tier2Keyword.value.trim() || undefined, + status: tier2FilterStatus.value || undefined, + level: 2, + }, + }); + tier2Agents.value = data.data.items as AgentRow[]; + tier2Total.value = data.data.total; +} + +function onTier2PageChange(p: number) { + tier2Page.value = p; + loadTier2Agents(); +} + +function onTier2SizeChange(size: number) { + tier2PageSize.value = size; + tier2Page.value = 1; + loadTier2Agents(); +} + +function searchTier2Agents() { + tier2Page.value = 1; + loadTier2Agents(); +} + +function reloadAgentLists() { + loadTier1Agents(); + loadTier2Agents(); +} + /* ─── Load main agent list ─── */ async function load() { - const { data } = await api.get('/admin/agents', { - params: { - page: page.value, - pageSize: pageSize.value, - keyword: keyword.value.trim() || undefined, - }, - }); - agents.value = data.data.items as AgentRow[]; - total.value = data.data.total; + reloadAgentLists(); } -function onPageChange(p: number) { - page.value = p; - load(); +async function loadAgentOptions() { + try { + const { data } = await api.get('/admin/agents/options'); + agentOptions.value = data.data; + } catch { + agentOptions.value = []; + } } -function onSizeChange(size: number) { - pageSize.value = size; - page.value = 1; - load(); +async function loadAllPlayers() { + playerLoading.value = true; + try { + const params: Record = { + page: playerPage.value, + pageSize: playerPageSize.value, + keyword: playerKeyword.value.trim() || undefined, + status: playerFilterStatus.value || undefined, + }; + if (playerFilterAgent.value) { + params.parentId = playerFilterAgent.value; + } + const { data } = await api.get('/admin/users', { params }); + allPlayers.value = data.data.items as PlayerRow[]; + playerTotal.value = data.data.total; + } catch { + allPlayers.value = []; + playerTotal.value = 0; + } finally { + playerLoading.value = false; + } +} + +function searchPlayers() { + playerPage.value = 1; + loadAllPlayers(); +} + +function onPlayerPageChange(p: number) { + playerPage.value = p; + loadAllPlayers(); +} + +function onPlayerSizeChange(size: number) { + playerPageSize.value = size; + playerPage.value = 1; + loadAllPlayers(); +} + +function affiliationLabel(row: PlayerRow) { + return formatPlayerAffiliationLabel(row, t('user.type.player'), t('agent.platform_row_name')); +} + +function directPlayersTabLabel(ownerName: string, count: number) { + return `${t('agent.direct_players_title', { name: ownerName })} (${count})`; +} + +function onTier1AgentRowClick(row: AgentRow, _column: unknown, event: MouseEvent) { + onAgentRowClick(row, tier1AgentTableRef, _column, event); +} + +function onTier2AgentRowClick(row: AgentRow, _column: unknown, event: MouseEvent) { + onAgentRowClick(row, tier2AgentTableRef, _column, event); } /* ─── Expansion ─── */ -function getInnerTab(agentId: string) { - return innerTabMap.value[agentId] || 'players'; -} - -function setInnerTab(agentId: string, tab: string) { - innerTabMap.value[agentId] = tab; -} - -async function onExpandChange(row: AgentRow, expandedRows: AgentRow[]) { - // Track expanded rows - expandedSet.value = new Set(expandedRows.map(r => r.userId)); - // Load data if newly expanded +async function onExpandChange(row: DisplayAgentRow, expandedRows: DisplayAgentRow[]) { + expandedSet.value = new Set(expandedRows.map((r) => r.userId)); if (expandedSet.value.has(row.userId) && !agentPlayersMap.value[row.userId]) { await loadExpansionData(row.userId); } } -function onAgentRowClick(row: AgentRow, _column: unknown, event: MouseEvent) { +function onAgentRowClick(row: AgentRow, tableRef: typeof tier1AgentTableRef, _column: unknown, event: MouseEvent) { if (!shouldToggleExpandOnRowClick(event)) return; - agentTableRef.value?.toggleRowExpansion(row); + tableRef.value?.toggleRowExpansion(row); } async function loadExpansionData(agentId: string) { expandLoading.value[agentId] = true; try { - const [playersRes, subAgentsRes] = await Promise.all([ - api.get('/admin/users', { params: { parentId: agentId, pageSize: 100 } }), - api.get('/admin/agents', { params: { parentAgentId: agentId, pageSize: 100 } }), - ]); - agentPlayersMap.value[agentId] = playersRes.data.data.items; - agentSubAgentsMap.value[agentId] = subAgentsRes.data.data.items; + const { data } = await api.get('/admin/users', { params: { parentId: agentId, pageSize: 100 } }); + agentPlayersMap.value[agentId] = data.data.items as PlayerRow[]; } catch { agentPlayersMap.value[agentId] = []; - agentSubAgentsMap.value[agentId] = []; } finally { expandLoading.value[agentId] = false; } @@ -198,51 +346,9 @@ function getPlayers(agentId: string) { return agentPlayersMap.value[agentId] || []; } -function getSubAgents(agentId: string) { - return agentSubAgentsMap.value[agentId] || []; -} - -async function loadSubAgentPlayers(subAgentUserId: string) { - subAgentExpandLoading.value[subAgentUserId] = true; - try { - const { data } = await api.get('/admin/users', { - params: { parentId: subAgentUserId, pageSize: 100 }, - }); - subAgentPlayersMap.value[subAgentUserId] = data.data.items as PlayerRow[]; - } catch { - subAgentPlayersMap.value[subAgentUserId] = []; - } finally { - subAgentExpandLoading.value[subAgentUserId] = false; - } -} - -function getSubAgentPlayers(subAgentUserId: string) { - return subAgentPlayersMap.value[subAgentUserId] || []; -} - -function onSubAgentExpand(_parentAgentId: string, row: AgentRow, expandedRows: AgentRow[]) { - subAgentExpandedKeys.value = expandedRows.map((r) => r.userId); - if (!subAgentPlayersMap.value[row.userId]) { - loadSubAgentPlayers(row.userId); - } -} - -function onSubAgentRowClick(_parentAgentId: string, row: AgentRow, _column: unknown, event: MouseEvent) { - if (!shouldToggleExpandOnRowClick(event)) return; - const id = row.userId; - if (subAgentExpandedKeys.value.includes(id)) { - subAgentExpandedKeys.value = subAgentExpandedKeys.value.filter((k) => k !== id); - } else { - subAgentExpandedKeys.value = [...subAgentExpandedKeys.value, id]; - if (!subAgentPlayersMap.value[id]) { - loadSubAgentPlayers(id); - } - } -} - -function refreshExpandedSubAgentPlayers() { - for (const subId of subAgentExpandedKeys.value) { - loadSubAgentPlayers(subId); +function refreshExpandedAgentPlayers() { + for (const agentId of expandedSet.value) { + loadExpansionData(agentId); } } @@ -361,18 +467,30 @@ async function saveAgentSuspendSettings() { } /* ─── Create (unified) ─── */ -function openCreateGlobal() { +function openCreateAccount() { createForm.value = emptyPlayerCreateForm(); + createForm.value.asTier1Agent = false; createParentAgentId.value = ''; + createParentLocked.value = false; createAccountMode.value = 0; createVisible.value = true; } +function openCreateTier1Agent() { + createForm.value = emptyPlayerCreateForm(); + createForm.value.asTier1Agent = true; + createParentAgentId.value = ''; + createParentLocked.value = false; + createAccountMode.value = 1; + createVisible.value = true; +} + function openCreatePlayer(parentAgentUserId: string) { createForm.value = emptyPlayerCreateForm(); createForm.value.asTier1Agent = false; createForm.value.parentId = parentAgentUserId; createParentAgentId.value = parentAgentUserId; + createParentLocked.value = true; createAccountMode.value = 0; createVisible.value = true; } @@ -381,27 +499,18 @@ function openCreateSubAgent(parentAgentUserId: string) { createForm.value = emptyPlayerCreateForm(); createForm.value.asTier1Agent = false; createParentAgentId.value = parentAgentUserId; + createParentLocked.value = true; createAccountMode.value = 2; createVisible.value = true; } -function onAccountModeChange(mode: number) { - createAccountMode.value = mode; - if (mode === 0) { - // Player - createForm.value.asTier1Agent = false; - if (createParentAgentId.value) { - createForm.value.parentId = createParentAgentId.value; - } - } else if (mode === 1) { - // Tier-1 agent - createForm.value.asTier1Agent = true; - createForm.value.parentId = ''; - } else if (mode === 2) { - // Sub-agent - createForm.value.asTier1Agent = false; - createForm.value.parentId = ''; - } +function openCreateSubAgentFromToolbar() { + createForm.value = emptyPlayerCreateForm(); + createForm.value.asTier1Agent = false; + createParentAgentId.value = ''; + createParentLocked.value = false; + createAccountMode.value = 2; + createVisible.value = true; } async function submitCreate() { @@ -410,7 +519,7 @@ async function submitCreate() { let payload: Record; try { if (isSubAgent) { - // Sub-agent creation + if (!createParentAgentId.value) throw new Error(t('agent.hint.sub_agent_parent')); if (!createForm.value.username.trim()) throw new Error(t('err.username_required')); if (createForm.value.password.length < 8) throw new Error(t('err.password_min')); if (createForm.value.password !== createForm.value.confirmPassword) throw new Error(t('err.password_mismatch')); @@ -450,7 +559,7 @@ async function submitCreate() { refreshExpandedParents(); const parentId = createParentAgentId.value || createForm.value.parentId; if (parentId) { - await loadSubAgentPlayers(parentId); + await loadExpansionData(parentId); } } catch (e: unknown) { const err = e as { response?: { data?: { error?: string } } }; @@ -474,12 +583,17 @@ async function submitEditPlayer() { ElMessage.warning(t('err.password_min')); return; } + try { + assertPlayerUsername(editPlayerForm.value.username); + } catch (e) { + ElMessage.warning(resolveFormError(e, t)); + return; + } editPlayerLoading.value = true; try { const newPwd = editPlayerForm.value.newPassword.trim(); const { data } = await api.put(`/admin/users/${editingId.value}`, { username: editPlayerForm.value.username.trim(), - parentId: editPlayerForm.value.parentId || '', phone: editPlayerForm.value.phone.trim() || undefined, email: editPlayerForm.value.email.trim() || undefined, password: newPwd || undefined, @@ -724,10 +838,9 @@ async function toggleFreezeAgent(row: AgentRow) { /* ─── Helpers ─── */ function refreshExpandedParents() { - for (const agentId of expandedSet.value) { - loadExpansionData(agentId); - } - refreshExpandedSubAgentPlayers(); + loadAllPlayers(); + reloadAgentLists(); + refreshExpandedAgentPlayers(); } const { @@ -878,41 +991,156 @@ function creditTypeLabel(type: string) { - -
-
+ + + +
+
+ + + + + + + + + + + + + + + + + {{ t('common.search') }} + + +
+ {{ t('user.create_btn') }} +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+
+ + + +
+
- + - + - {{ t('common.search') }} + {{ t('common.search') }}
- {{ t('agent.create_btn') }} + {{ t('agent.create_btn') }}
-
- - -
+
- +