feat: refactor agent manager, media library, and player UX

- Split admin users page into player/tier-1/tier-2 tabs with affiliation labels and context-specific create dialogs

- Add media library with uploaded_files migration, list/delete unused files API, and admin nav route

- Enforce player username format (alphanumeric 3-32) on frontend and backend via shared package

- Improve admin dialog/panel styling; refine player parlay and match bet card kickoff display

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-09 17:56:28 +08:00
parent d5e7c8edb3
commit df20444be9
27 changed files with 2136 additions and 563 deletions

View File

@@ -42,6 +42,7 @@ const zh: Record<string, string> = {
'nav.contents': '公共管理',
'nav.audit': '操作日志',
'nav.smoke_tests': '自动化测试',
'nav.media': '媒体库',
'nav.players': '直属玩家',
'nav.subAgents': '下级代理',
'nav.myBets': '注单查询',
@@ -222,6 +223,7 @@ const en: Record<string, string> = {
'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<string, string> = {
'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',

View File

@@ -2,6 +2,7 @@
export const adminPagesMs: Record<string, string> = {
'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<string, string> = {
'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, 332 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<string, string> = {
'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<string, string> = {
'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 (332 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<string, string> = {
'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',
};

View File

@@ -2,6 +2,7 @@
export const adminPagesZh: Record<string, string> = {
'common.detail': '详情',
'common.create': '创建',
'common.create_btn': '+ 新建',
'common.save': '保存',
'common.close': '关闭',
'common.import': '导入',
@@ -77,12 +78,16 @@ export const adminPagesZh: Record<string, string> = {
'user.msg.password_saved': '密码已更新,当前可查密码:{password}',
'user.hint.password_reset_to_view': '旧账号暂无记录。请在下方「重置密码」填写新密码并保存,即可在此查看。',
'user.ph.username_unique': '登录用户名,唯一',
'user.ph.username_player': '字母与数字332 位',
'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<string, string> = {
'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<string, string> = {
'matchEditor.ph.selection_name': '玩家端显示名称',
'err.username_required': '请填写用户名',
'err.username_player_invalid': '玩家用户名仅可使用英文字母和数字332 位),不可含中文或特殊符号',
'err.password_min': '密码至少 8 位',
'err.password_mismatch': '两次密码不一致',
'err.credit_negative': '授信额度不能为负',
@@ -733,11 +745,45 @@ export const adminPagesZh: Record<string, string> = {
'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<string, string> = {
'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<string, string> = {
'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, 332 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<string, string> = {
'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<string, string> = {
'matchEditor.ph.selection_name': 'Name shown to players',
'err.username_required': 'Username is required',
'err.username_player_invalid': 'Player username must be 332 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<string, string> = {
'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',
};