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:
@@ -314,7 +314,7 @@ body::-webkit-scrollbar {
|
|||||||
}
|
}
|
||||||
.admin-list-page > .list-panel {
|
.admin-list-page > .list-panel {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
background: rgba(18, 18, 18, 0.85);
|
background: #121212;
|
||||||
padding: 0 10px 10px;
|
padding: 0 10px 10px;
|
||||||
}
|
}
|
||||||
.admin-list-page > .data-card .el-card__body {
|
.admin-list-page > .data-card .el-card__body {
|
||||||
@@ -379,7 +379,6 @@ body {
|
|||||||
border-color: var(--border) !important;
|
border-color: var(--border) !important;
|
||||||
border-radius: var(--radius) !important;
|
border-radius: var(--radius) !important;
|
||||||
box-shadow: var(--shadow) !important;
|
box-shadow: var(--shadow) !important;
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
}
|
}
|
||||||
.el-card__header {
|
.el-card__header {
|
||||||
border-bottom-color: #1e1e1e !important;
|
border-bottom-color: #1e1e1e !important;
|
||||||
@@ -394,7 +393,7 @@ body {
|
|||||||
.el-table::before { background-color: #222 !important; }
|
.el-table::before { background-color: #222 !important; }
|
||||||
.el-table th.el-table__cell {
|
.el-table th.el-table__cell {
|
||||||
background: rgba(255,255,255,0.02) !important;
|
background: rgba(255,255,255,0.02) !important;
|
||||||
color: #555 !important;
|
color: #888 !important;
|
||||||
font-size: 11px; font-weight: 700;
|
font-size: 11px; font-weight: 700;
|
||||||
letter-spacing: 0.06em; text-transform: uppercase;
|
letter-spacing: 0.06em; text-transform: uppercase;
|
||||||
border-bottom-color: #1e1e1e !important;
|
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;
|
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__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; }
|
.el-statistic__content .el-statistic__number { font-size: 26px !important; font-weight: 800 !important; color: #fff !important; }
|
||||||
|
|||||||
@@ -103,9 +103,9 @@ function fmtFull(value: string | null | undefined) {
|
|||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
.credit-context-title {
|
.credit-context-title {
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: #888;
|
color: #bbb;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
.credit-context-alert {
|
.credit-context-alert {
|
||||||
|
|||||||
@@ -103,9 +103,9 @@ function limitLabel(value: string | null) {
|
|||||||
.transfer-context { margin-bottom: 16px; min-height: 48px; }
|
.transfer-context { margin-bottom: 16px; min-height: 48px; }
|
||||||
.transfer-context-section + .transfer-context-section { margin-top: 12px; }
|
.transfer-context-section + .transfer-context-section { margin-top: 12px; }
|
||||||
.transfer-context-title {
|
.transfer-context-title {
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: #888;
|
color: #bbb;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
.transfer-context-alert { margin-bottom: 0; }
|
.transfer-context-alert { margin-bottom: 0; }
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const zh: Record<string, string> = {
|
|||||||
'nav.contents': '公共管理',
|
'nav.contents': '公共管理',
|
||||||
'nav.audit': '操作日志',
|
'nav.audit': '操作日志',
|
||||||
'nav.smoke_tests': '自动化测试',
|
'nav.smoke_tests': '自动化测试',
|
||||||
|
'nav.media': '媒体库',
|
||||||
'nav.players': '直属玩家',
|
'nav.players': '直属玩家',
|
||||||
'nav.subAgents': '下级代理',
|
'nav.subAgents': '下级代理',
|
||||||
'nav.myBets': '注单查询',
|
'nav.myBets': '注单查询',
|
||||||
@@ -222,6 +223,7 @@ const en: Record<string, string> = {
|
|||||||
'nav.contents': 'Public Content',
|
'nav.contents': 'Public Content',
|
||||||
'nav.audit': 'Audit Log',
|
'nav.audit': 'Audit Log',
|
||||||
'nav.smoke_tests': 'Smoke tests',
|
'nav.smoke_tests': 'Smoke tests',
|
||||||
|
'nav.media': 'Media Library',
|
||||||
'nav.players': 'My Players',
|
'nav.players': 'My Players',
|
||||||
'nav.subAgents': 'Sub-Agents',
|
'nav.subAgents': 'Sub-Agents',
|
||||||
'nav.myBets': 'Bet Search',
|
'nav.myBets': 'Bet Search',
|
||||||
@@ -402,6 +404,7 @@ const ms: Record<string, string> = {
|
|||||||
'nav.contents': 'Kandungan awam',
|
'nav.contents': 'Kandungan awam',
|
||||||
'nav.audit': 'Log audit',
|
'nav.audit': 'Log audit',
|
||||||
'nav.smoke_tests': 'Ujian asap',
|
'nav.smoke_tests': 'Ujian asap',
|
||||||
|
'nav.media': 'Perpustakaan Media',
|
||||||
'nav.players': 'Pemain saya',
|
'nav.players': 'Pemain saya',
|
||||||
'nav.subAgents': 'Sub-ejen',
|
'nav.subAgents': 'Sub-ejen',
|
||||||
'nav.myBets': 'Carian pertaruhan',
|
'nav.myBets': 'Carian pertaruhan',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
export const adminPagesMs: Record<string, string> = {
|
export const adminPagesMs: Record<string, string> = {
|
||||||
'common.detail': 'Butiran',
|
'common.detail': 'Butiran',
|
||||||
'common.create': 'Cipta',
|
'common.create': 'Cipta',
|
||||||
|
'common.create_btn': '+ Baharu',
|
||||||
'common.save': 'Simpan',
|
'common.save': 'Simpan',
|
||||||
'common.close': 'Tutup',
|
'common.close': 'Tutup',
|
||||||
'common.import': 'Import',
|
'common.import': 'Import',
|
||||||
@@ -77,12 +78,16 @@ export const adminPagesMs: Record<string, string> = {
|
|||||||
'user.msg.password_saved': 'Kata laluan dikemas kini: {password}',
|
'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.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_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.ph.no_agent': 'Tiada (terus platform)',
|
||||||
'user.hint.no_agent': 'Biarkan kosong untuk pemain diurus 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.initial_balance': 'Auto tambah baki semasa cipta; 0 = tiada bonus',
|
||||||
'user.hint.deposit_remark': 'Ditulis ke lejar jika baki permulaan > 0',
|
'user.hint.deposit_remark': 'Ditulis ke lejar jika baki permulaan > 0',
|
||||||
'user.hint.freeze_in_list': 'Beku/nyahbeku dari lajur tindakan senarai',
|
'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_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_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.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',
|
'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',
|
'user.hint.account_type': 'Ejen guna had kredit; pemain boleh di bawah ejen',
|
||||||
|
|
||||||
'agent.create_btn': '+ Ejen peringkat 1 baharu',
|
'agent.create_btn': '+ Ejen peringkat 1 baharu',
|
||||||
|
'agent.create_sub_btn': '+ Ejen peringkat 2 baharu',
|
||||||
'agent.create_sub': 'Cipta sub-ejen',
|
'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.hint.creating_under_agent': 'Cipta akaun di bawah ejen ini',
|
||||||
'agent.filter.username_ph': 'Nama pengguna',
|
'agent.filter.username_ph': 'Nama pengguna',
|
||||||
|
'agent_mgr.tab.players': 'Pemain',
|
||||||
|
'agent_mgr.tab.agents': 'Ejen',
|
||||||
'agent.col.level': 'Peringkat',
|
'agent.col.level': 'Peringkat',
|
||||||
'agent.col.credit': 'Had / Digunakan / Tersedia',
|
'agent.col.credit': 'Had / Digunakan / Tersedia',
|
||||||
'agent.col.direct_players': 'Pemain terus',
|
'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.sub_agents': 'Sub-ejen',
|
||||||
'agent.col.cashback': 'Kadar rebat',
|
'agent.col.cashback': 'Kadar rebat',
|
||||||
'agent.col.phone': 'Telefon',
|
'agent.col.phone': 'Telefon',
|
||||||
@@ -378,6 +389,7 @@ export const adminPagesMs: Record<string, string> = {
|
|||||||
'matchEditor.ph.selection_name': 'Nama dipaparkan kepada pemain',
|
'matchEditor.ph.selection_name': 'Nama dipaparkan kepada pemain',
|
||||||
|
|
||||||
'err.username_required': 'Sila isi nama pengguna',
|
'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_min': 'Kata laluan sekurang-kurangnya 8 aksara',
|
||||||
'err.password_mismatch': 'Kata laluan tidak sepadan',
|
'err.password_mismatch': 'Kata laluan tidak sepadan',
|
||||||
'err.credit_negative': 'Had kredit tidak boleh negatif',
|
'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_ok': 'Disalin ke papan keratan',
|
||||||
'smoke.msg.copy_failed': 'Gagal menyalin — pilih log secara manual',
|
'smoke.msg.copy_failed': 'Gagal menyalin — pilih log secara manual',
|
||||||
'audit.action.RUN_SMOKE_TESTS': 'Jalankan ujian asap',
|
'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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
export const adminPagesZh: Record<string, string> = {
|
export const adminPagesZh: Record<string, string> = {
|
||||||
'common.detail': '详情',
|
'common.detail': '详情',
|
||||||
'common.create': '创建',
|
'common.create': '创建',
|
||||||
|
'common.create_btn': '+ 新建',
|
||||||
'common.save': '保存',
|
'common.save': '保存',
|
||||||
'common.close': '关闭',
|
'common.close': '关闭',
|
||||||
'common.import': '导入',
|
'common.import': '导入',
|
||||||
@@ -77,12 +78,16 @@ export const adminPagesZh: Record<string, string> = {
|
|||||||
'user.msg.password_saved': '密码已更新,当前可查密码:{password}',
|
'user.msg.password_saved': '密码已更新,当前可查密码:{password}',
|
||||||
'user.hint.password_reset_to_view': '旧账号暂无记录。请在下方「重置密码」填写新密码并保存,即可在此查看。',
|
'user.hint.password_reset_to_view': '旧账号暂无记录。请在下方「重置密码」填写新密码并保存,即可在此查看。',
|
||||||
'user.ph.username_unique': '登录用户名,唯一',
|
'user.ph.username_unique': '登录用户名,唯一',
|
||||||
|
'user.ph.username_player': '字母与数字,3–32 位',
|
||||||
|
'user.hint.username_player': '仅允许英文字母和数字,不可含中文或特殊符号',
|
||||||
'user.ph.no_agent': '不设置(平台直属玩家)',
|
'user.ph.no_agent': '不设置(平台直属玩家)',
|
||||||
'user.hint.no_agent': '留空表示不挂靠代理,由平台直接管理',
|
'user.hint.no_agent': '留空表示不挂靠代理,由平台直接管理',
|
||||||
|
'user.hint.platform_direct_player': '该玩家隶属于平台(管理员直属)',
|
||||||
'user.hint.initial_balance': '创建后自动上分,0 表示不开户赠金',
|
'user.hint.initial_balance': '创建后自动上分,0 表示不开户赠金',
|
||||||
'user.hint.deposit_remark': '有初始余额时写入流水备注',
|
'user.hint.deposit_remark': '有初始余额时写入流水备注',
|
||||||
'user.hint.freeze_in_list': '冻结/解冻请在列表操作列进行',
|
'user.hint.freeze_in_list': '冻结/解冻请在列表操作列进行',
|
||||||
'user.hint.agent_change': '留空表示平台直属;变更后会重算相关代理已用授信',
|
'user.hint.agent_change': '留空表示平台直属;变更后会重算相关代理已用授信',
|
||||||
|
'user.hint.agent_readonly': '所属代理创建后不可修改',
|
||||||
'user.hint.allow_password_change': '关闭后所有玩家均不可在客户端修改密码',
|
'user.hint.allow_password_change': '关闭后所有玩家均不可在客户端修改密码',
|
||||||
'user.hint.allow_username_change': '开启后所有玩家均可在资料页修改登录账号名',
|
'user.hint.allow_username_change': '开启后所有玩家均可在资料页修改登录账号名',
|
||||||
'user.hint.view_password': '仅保存后台创建或重置时的密码;玩家自行改密后会清除',
|
'user.hint.view_password': '仅保存后台创建或重置时的密码;玩家自行改密后会清除',
|
||||||
@@ -98,12 +103,18 @@ export const adminPagesZh: Record<string, string> = {
|
|||||||
'user.hint.account_type': '代理使用授信额度;玩家可挂靠代理并上分',
|
'user.hint.account_type': '代理使用授信额度;玩家可挂靠代理并上分',
|
||||||
|
|
||||||
'agent.create_btn': '+ 新建一级代理',
|
'agent.create_btn': '+ 新建一级代理',
|
||||||
|
'agent.create_sub_btn': '+ 新建二级代理',
|
||||||
'agent.create_sub': '创建二级代理',
|
'agent.create_sub': '创建二级代理',
|
||||||
|
'agent.hint.sub_agent_parent': '二级代理必须挂靠在一级代理名下',
|
||||||
'agent.hint.creating_under_agent': '在此代理下创建账号',
|
'agent.hint.creating_under_agent': '在此代理下创建账号',
|
||||||
'agent.filter.username_ph': '用户名',
|
'agent.filter.username_ph': '用户名',
|
||||||
|
'agent_mgr.tab.players': '玩家',
|
||||||
|
'agent_mgr.tab.agents': '代理',
|
||||||
'agent.col.level': '层级',
|
'agent.col.level': '层级',
|
||||||
'agent.col.credit': '授信 / 已用 / 可用',
|
'agent.col.credit': '授信 / 已用 / 可用',
|
||||||
'agent.col.direct_players': '直属玩家',
|
'agent.col.direct_players': '直属玩家',
|
||||||
|
'agent.direct_players_title': '直属玩家 · {name}',
|
||||||
|
'agent.platform_row_name': '平台',
|
||||||
'agent.col.sub_agents': '下级代理',
|
'agent.col.sub_agents': '下级代理',
|
||||||
'agent.col.cashback': '返水率',
|
'agent.col.cashback': '返水率',
|
||||||
'agent.col.phone': '手机',
|
'agent.col.phone': '手机',
|
||||||
@@ -394,6 +405,7 @@ export const adminPagesZh: Record<string, string> = {
|
|||||||
'matchEditor.ph.selection_name': '玩家端显示名称',
|
'matchEditor.ph.selection_name': '玩家端显示名称',
|
||||||
|
|
||||||
'err.username_required': '请填写用户名',
|
'err.username_required': '请填写用户名',
|
||||||
|
'err.username_player_invalid': '玩家用户名仅可使用英文字母和数字(3–32 位),不可含中文或特殊符号',
|
||||||
'err.password_min': '密码至少 8 位',
|
'err.password_min': '密码至少 8 位',
|
||||||
'err.password_mismatch': '两次密码不一致',
|
'err.password_mismatch': '两次密码不一致',
|
||||||
'err.credit_negative': '授信额度不能为负',
|
'err.credit_negative': '授信额度不能为负',
|
||||||
@@ -733,11 +745,45 @@ export const adminPagesZh: Record<string, string> = {
|
|||||||
'smoke.msg.copy_ok': '已复制到剪贴板',
|
'smoke.msg.copy_ok': '已复制到剪贴板',
|
||||||
'smoke.msg.copy_failed': '复制失败,请手动选中日志复制',
|
'smoke.msg.copy_failed': '复制失败,请手动选中日志复制',
|
||||||
'audit.action.RUN_SMOKE_TESTS': '运行自动化测试',
|
'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> = {
|
export const adminPagesEn: Record<string, string> = {
|
||||||
'common.detail': 'Details',
|
'common.detail': 'Details',
|
||||||
'common.create': 'Create',
|
'common.create': 'Create',
|
||||||
|
'common.create_btn': '+ New',
|
||||||
'common.save': 'Save',
|
'common.save': 'Save',
|
||||||
'common.close': 'Close',
|
'common.close': 'Close',
|
||||||
'common.import': 'Import',
|
'common.import': 'Import',
|
||||||
@@ -813,12 +859,16 @@ export const adminPagesEn: Record<string, string> = {
|
|||||||
'user.msg.password_saved': 'Password updated. Viewable password: {password}',
|
'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.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_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.ph.no_agent': 'None (platform direct)',
|
||||||
'user.hint.no_agent': 'Leave empty for platform-managed player',
|
'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.initial_balance': 'Auto top-up on create; 0 = no bonus',
|
||||||
'user.hint.deposit_remark': 'Written to ledger when initial balance > 0',
|
'user.hint.deposit_remark': 'Written to ledger when initial balance > 0',
|
||||||
'user.hint.freeze_in_list': 'Freeze/unfreeze from the list actions',
|
'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_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_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.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',
|
'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',
|
'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_btn': '+ New tier-1 agent',
|
||||||
|
'agent.create_sub_btn': '+ New tier-2 agent',
|
||||||
'agent.create_sub': 'Create sub-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.hint.creating_under_agent': 'Create account under this agent',
|
||||||
'agent.filter.username_ph': 'Username',
|
'agent.filter.username_ph': 'Username',
|
||||||
|
'agent_mgr.tab.players': 'Players',
|
||||||
|
'agent_mgr.tab.agents': 'Agents',
|
||||||
'agent.col.level': 'Level',
|
'agent.col.level': 'Level',
|
||||||
'agent.col.credit': 'Limit / Used / Available',
|
'agent.col.credit': 'Limit / Used / Available',
|
||||||
'agent.col.direct_players': 'Direct players',
|
'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.sub_agents': 'Sub-agents',
|
||||||
'agent.col.cashback': 'Cashback rate',
|
'agent.col.cashback': 'Cashback rate',
|
||||||
'agent.col.phone': 'Phone',
|
'agent.col.phone': 'Phone',
|
||||||
@@ -1130,6 +1186,7 @@ export const adminPagesEn: Record<string, string> = {
|
|||||||
'matchEditor.ph.selection_name': 'Name shown to players',
|
'matchEditor.ph.selection_name': 'Name shown to players',
|
||||||
|
|
||||||
'err.username_required': 'Username is required',
|
'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_min': 'Password must be at least 8 characters',
|
||||||
'err.password_mismatch': 'Passwords do not match',
|
'err.password_mismatch': 'Passwords do not match',
|
||||||
'err.credit_negative': 'Credit limit cannot be negative',
|
'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_ok': 'Copied to clipboard',
|
||||||
'smoke.msg.copy_failed': 'Copy failed — select the log manually',
|
'smoke.msg.copy_failed': 'Copy failed — select the log manually',
|
||||||
'audit.action.RUN_SMOKE_TESTS': 'Run smoke tests',
|
'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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const adminMenus = computed(() => [
|
|||||||
{ path: '/cashback', label: t('nav.cashback') },
|
{ path: '/cashback', label: t('nav.cashback') },
|
||||||
{ path: '/bets', label: t('nav.bets') },
|
{ path: '/bets', label: t('nav.bets') },
|
||||||
{ path: '/contents', label: t('nav.contents') },
|
{ path: '/contents', label: t('nav.contents') },
|
||||||
|
{ path: '/media', label: t('nav.media') },
|
||||||
{ path: '/audit', label: t('nav.audit') },
|
{ path: '/audit', label: t('nav.audit') },
|
||||||
{ path: '/smoke-tests', label: t('nav.smoke_tests') },
|
{ path: '/smoke-tests', label: t('nav.smoke_tests') },
|
||||||
]);
|
]);
|
||||||
@@ -175,6 +176,7 @@ watch(() => route.path, () => {
|
|||||||
</nav>
|
</nav>
|
||||||
<span v-else class="topbar-page-label">{{ currentLabel }}</span>
|
<span v-else class="topbar-page-label">{{ currentLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="topbar-page-actions" class="topbar-page-actions" />
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar-right">
|
<div class="topbar-right">
|
||||||
<div class="user-chip">
|
<div class="user-chip">
|
||||||
@@ -390,6 +392,17 @@ watch(() => route.path, () => {
|
|||||||
box-shadow: 0 0 8px rgba(47, 181, 106, 0.45);
|
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 {
|
.topbar-right {
|
||||||
display: flex; align-items: center; gap: 12px;
|
display: flex; align-items: center; gap: 12px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|||||||
@@ -87,8 +87,8 @@ const router = createRouter({
|
|||||||
meta: { adminOnly: true },
|
meta: { adminOnly: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'smoke-tests',
|
path: 'media',
|
||||||
component: () => import('../views/SmokeTests.vue'),
|
component: () => import('../views/MediaLibrary.vue'),
|
||||||
meta: { adminOnly: true },
|
meta: { adminOnly: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
type PlayerDetail,
|
type PlayerDetail,
|
||||||
type PlayerCreateForm,
|
type PlayerCreateForm,
|
||||||
type PlayerEditForm,
|
type PlayerEditForm,
|
||||||
|
formatPlayerAffiliationLabel,
|
||||||
|
assertPlayerUsername,
|
||||||
} from './user-form';
|
} from './user-form';
|
||||||
import {
|
import {
|
||||||
emptyAgentEditForm,
|
emptyAgentEditForm,
|
||||||
@@ -28,6 +30,8 @@ import {
|
|||||||
type AgentEditForm,
|
type AgentEditForm,
|
||||||
} from './agent-form';
|
} from './agent-form';
|
||||||
import { subAgentAccountStatus } from './agent/agent-sub-agent-form';
|
import { subAgentAccountStatus } from './agent/agent-sub-agent-form';
|
||||||
|
|
||||||
|
type DisplayAgentRow = AgentRow;
|
||||||
import {
|
import {
|
||||||
formatAmount,
|
formatAmount,
|
||||||
formatAmountFull,
|
formatAmountFull,
|
||||||
@@ -47,26 +51,42 @@ import {
|
|||||||
type AgentCreditAdjustContext,
|
type AgentCreditAdjustContext,
|
||||||
} from '../utils/agent-credit-context';
|
} from '../utils/agent-credit-context';
|
||||||
|
|
||||||
/* ─── Main agent list ─── */
|
/* ─── Tier-1 agent list ─── */
|
||||||
const agents = ref<AgentRow[]>([]);
|
const tier1Agents = ref<AgentRow[]>([]);
|
||||||
const total = ref(0);
|
const tier1Total = ref(0);
|
||||||
const page = ref(1);
|
const tier1Page = ref(1);
|
||||||
const pageSize = ref(20);
|
const tier1PageSize = ref(20);
|
||||||
const keyword = ref('');
|
const tier1Keyword = ref('');
|
||||||
const filterStatus = ref('');
|
const tier1FilterStatus = ref('');
|
||||||
|
|
||||||
|
/* ─── Tier-2 agent list ─── */
|
||||||
|
const tier2Agents = ref<AgentRow[]>([]);
|
||||||
|
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<PlayerRow[]>([]);
|
||||||
|
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 ─── */
|
/* ─── Expansion state ─── */
|
||||||
const expandedSet = ref(new Set<string>());
|
const expandedSet = ref(new Set<string>());
|
||||||
const agentPlayersMap = ref<Record<string, PlayerRow[]>>({});
|
const agentPlayersMap = ref<Record<string, PlayerRow[]>>({});
|
||||||
const agentSubAgentsMap = ref<Record<string, AgentRow[]>>({});
|
|
||||||
const expandLoading = ref<Record<string, boolean>>({});
|
const expandLoading = ref<Record<string, boolean>>({});
|
||||||
const innerTabMap = ref<Record<string, string>>({});
|
const tier1AgentTableRef = ref();
|
||||||
const agentTableRef = ref();
|
const tier2AgentTableRef = ref();
|
||||||
|
|
||||||
/* Sub-agent nested expansion (players under tier-2 agents) */
|
|
||||||
const subAgentExpandedKeys = ref<string[]>([]);
|
|
||||||
const subAgentPlayersMap = ref<Record<string, PlayerRow[]>>({});
|
|
||||||
const subAgentExpandLoading = ref<Record<string, boolean>>({});
|
|
||||||
|
|
||||||
/* ─── Dialogs ─── */
|
/* ─── Dialogs ─── */
|
||||||
const createVisible = ref(false);
|
const createVisible = ref(false);
|
||||||
@@ -82,7 +102,8 @@ const creditLoading = ref(false);
|
|||||||
|
|
||||||
/* ─── Create form (unified) ─── */
|
/* ─── Create form (unified) ─── */
|
||||||
const createForm = ref<PlayerCreateForm>(emptyPlayerCreateForm());
|
const createForm = ref<PlayerCreateForm>(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
|
const createAccountMode = ref(0); // 0=player, 1=tier1Agent, 2=subAgent
|
||||||
|
|
||||||
/* ─── Edit forms ─── */
|
/* ─── Edit forms ─── */
|
||||||
@@ -121,74 +142,201 @@ const resetLoading = ref(false);
|
|||||||
const resetConfirmPhrase = ref('');
|
const resetConfirmPhrase = ref('');
|
||||||
const settingsCollapseOpen = ref<string[]>([]);
|
const settingsCollapseOpen = ref<string[]>([]);
|
||||||
|
|
||||||
|
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 ─── */
|
/* ─── Init ─── */
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadPlayerSettings();
|
loadPlayerSettings();
|
||||||
loadBettingLimits();
|
loadBettingLimits();
|
||||||
loadAgentSuspendSettings();
|
loadAgentSuspendSettings();
|
||||||
loadResetDatabaseStatus();
|
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 ─── */
|
/* ─── Load main agent list ─── */
|
||||||
async function load() {
|
async function load() {
|
||||||
const { data } = await api.get('/admin/agents', {
|
reloadAgentLists();
|
||||||
params: {
|
|
||||||
page: page.value,
|
|
||||||
pageSize: pageSize.value,
|
|
||||||
keyword: keyword.value.trim() || undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
agents.value = data.data.items as AgentRow[];
|
|
||||||
total.value = data.data.total;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPageChange(p: number) {
|
async function loadAgentOptions() {
|
||||||
page.value = p;
|
try {
|
||||||
load();
|
const { data } = await api.get('/admin/agents/options');
|
||||||
|
agentOptions.value = data.data;
|
||||||
|
} catch {
|
||||||
|
agentOptions.value = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSizeChange(size: number) {
|
async function loadAllPlayers() {
|
||||||
pageSize.value = size;
|
playerLoading.value = true;
|
||||||
page.value = 1;
|
try {
|
||||||
load();
|
const params: Record<string, unknown> = {
|
||||||
|
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 ─── */
|
/* ─── Expansion ─── */
|
||||||
function getInnerTab(agentId: string) {
|
async function onExpandChange(row: DisplayAgentRow, expandedRows: DisplayAgentRow[]) {
|
||||||
return innerTabMap.value[agentId] || 'players';
|
expandedSet.value = new Set(expandedRows.map((r) => r.userId));
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
if (expandedSet.value.has(row.userId) && !agentPlayersMap.value[row.userId]) {
|
if (expandedSet.value.has(row.userId) && !agentPlayersMap.value[row.userId]) {
|
||||||
await loadExpansionData(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;
|
if (!shouldToggleExpandOnRowClick(event)) return;
|
||||||
agentTableRef.value?.toggleRowExpansion(row);
|
tableRef.value?.toggleRowExpansion(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadExpansionData(agentId: string) {
|
async function loadExpansionData(agentId: string) {
|
||||||
expandLoading.value[agentId] = true;
|
expandLoading.value[agentId] = true;
|
||||||
try {
|
try {
|
||||||
const [playersRes, subAgentsRes] = await Promise.all([
|
const { data } = await api.get('/admin/users', { params: { parentId: agentId, pageSize: 100 } });
|
||||||
api.get('/admin/users', { params: { parentId: agentId, pageSize: 100 } }),
|
agentPlayersMap.value[agentId] = data.data.items as PlayerRow[];
|
||||||
api.get('/admin/agents', { params: { parentAgentId: agentId, pageSize: 100 } }),
|
|
||||||
]);
|
|
||||||
agentPlayersMap.value[agentId] = playersRes.data.data.items;
|
|
||||||
agentSubAgentsMap.value[agentId] = subAgentsRes.data.data.items;
|
|
||||||
} catch {
|
} catch {
|
||||||
agentPlayersMap.value[agentId] = [];
|
agentPlayersMap.value[agentId] = [];
|
||||||
agentSubAgentsMap.value[agentId] = [];
|
|
||||||
} finally {
|
} finally {
|
||||||
expandLoading.value[agentId] = false;
|
expandLoading.value[agentId] = false;
|
||||||
}
|
}
|
||||||
@@ -198,51 +346,9 @@ function getPlayers(agentId: string) {
|
|||||||
return agentPlayersMap.value[agentId] || [];
|
return agentPlayersMap.value[agentId] || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSubAgents(agentId: string) {
|
function refreshExpandedAgentPlayers() {
|
||||||
return agentSubAgentsMap.value[agentId] || [];
|
for (const agentId of expandedSet.value) {
|
||||||
}
|
loadExpansionData(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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,18 +467,30 @@ async function saveAgentSuspendSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Create (unified) ─── */
|
/* ─── Create (unified) ─── */
|
||||||
function openCreateGlobal() {
|
function openCreateAccount() {
|
||||||
createForm.value = emptyPlayerCreateForm();
|
createForm.value = emptyPlayerCreateForm();
|
||||||
|
createForm.value.asTier1Agent = false;
|
||||||
createParentAgentId.value = '';
|
createParentAgentId.value = '';
|
||||||
|
createParentLocked.value = false;
|
||||||
createAccountMode.value = 0;
|
createAccountMode.value = 0;
|
||||||
createVisible.value = true;
|
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) {
|
function openCreatePlayer(parentAgentUserId: string) {
|
||||||
createForm.value = emptyPlayerCreateForm();
|
createForm.value = emptyPlayerCreateForm();
|
||||||
createForm.value.asTier1Agent = false;
|
createForm.value.asTier1Agent = false;
|
||||||
createForm.value.parentId = parentAgentUserId;
|
createForm.value.parentId = parentAgentUserId;
|
||||||
createParentAgentId.value = parentAgentUserId;
|
createParentAgentId.value = parentAgentUserId;
|
||||||
|
createParentLocked.value = true;
|
||||||
createAccountMode.value = 0;
|
createAccountMode.value = 0;
|
||||||
createVisible.value = true;
|
createVisible.value = true;
|
||||||
}
|
}
|
||||||
@@ -381,27 +499,18 @@ function openCreateSubAgent(parentAgentUserId: string) {
|
|||||||
createForm.value = emptyPlayerCreateForm();
|
createForm.value = emptyPlayerCreateForm();
|
||||||
createForm.value.asTier1Agent = false;
|
createForm.value.asTier1Agent = false;
|
||||||
createParentAgentId.value = parentAgentUserId;
|
createParentAgentId.value = parentAgentUserId;
|
||||||
|
createParentLocked.value = true;
|
||||||
createAccountMode.value = 2;
|
createAccountMode.value = 2;
|
||||||
createVisible.value = true;
|
createVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onAccountModeChange(mode: number) {
|
function openCreateSubAgentFromToolbar() {
|
||||||
createAccountMode.value = mode;
|
createForm.value = emptyPlayerCreateForm();
|
||||||
if (mode === 0) {
|
createForm.value.asTier1Agent = false;
|
||||||
// Player
|
createParentAgentId.value = '';
|
||||||
createForm.value.asTier1Agent = false;
|
createParentLocked.value = false;
|
||||||
if (createParentAgentId.value) {
|
createAccountMode.value = 2;
|
||||||
createForm.value.parentId = createParentAgentId.value;
|
createVisible.value = true;
|
||||||
}
|
|
||||||
} 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 = '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitCreate() {
|
async function submitCreate() {
|
||||||
@@ -410,7 +519,7 @@ async function submitCreate() {
|
|||||||
let payload: Record<string, unknown>;
|
let payload: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
if (isSubAgent) {
|
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.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.length < 8) throw new Error(t('err.password_min'));
|
||||||
if (createForm.value.password !== createForm.value.confirmPassword) throw new Error(t('err.password_mismatch'));
|
if (createForm.value.password !== createForm.value.confirmPassword) throw new Error(t('err.password_mismatch'));
|
||||||
@@ -450,7 +559,7 @@ async function submitCreate() {
|
|||||||
refreshExpandedParents();
|
refreshExpandedParents();
|
||||||
const parentId = createParentAgentId.value || createForm.value.parentId;
|
const parentId = createParentAgentId.value || createForm.value.parentId;
|
||||||
if (parentId) {
|
if (parentId) {
|
||||||
await loadSubAgentPlayers(parentId);
|
await loadExpansionData(parentId);
|
||||||
}
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { response?: { data?: { error?: string } } };
|
const err = e as { response?: { data?: { error?: string } } };
|
||||||
@@ -474,12 +583,17 @@ async function submitEditPlayer() {
|
|||||||
ElMessage.warning(t('err.password_min'));
|
ElMessage.warning(t('err.password_min'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
assertPlayerUsername(editPlayerForm.value.username);
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.warning(resolveFormError(e, t));
|
||||||
|
return;
|
||||||
|
}
|
||||||
editPlayerLoading.value = true;
|
editPlayerLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const newPwd = editPlayerForm.value.newPassword.trim();
|
const newPwd = editPlayerForm.value.newPassword.trim();
|
||||||
const { data } = await api.put(`/admin/users/${editingId.value}`, {
|
const { data } = await api.put(`/admin/users/${editingId.value}`, {
|
||||||
username: editPlayerForm.value.username.trim(),
|
username: editPlayerForm.value.username.trim(),
|
||||||
parentId: editPlayerForm.value.parentId || '',
|
|
||||||
phone: editPlayerForm.value.phone.trim() || undefined,
|
phone: editPlayerForm.value.phone.trim() || undefined,
|
||||||
email: editPlayerForm.value.email.trim() || undefined,
|
email: editPlayerForm.value.email.trim() || undefined,
|
||||||
password: newPwd || undefined,
|
password: newPwd || undefined,
|
||||||
@@ -724,10 +838,9 @@ async function toggleFreezeAgent(row: AgentRow) {
|
|||||||
|
|
||||||
/* ─── Helpers ─── */
|
/* ─── Helpers ─── */
|
||||||
function refreshExpandedParents() {
|
function refreshExpandedParents() {
|
||||||
for (const agentId of expandedSet.value) {
|
loadAllPlayers();
|
||||||
loadExpansionData(agentId);
|
reloadAgentLists();
|
||||||
}
|
refreshExpandedAgentPlayers();
|
||||||
refreshExpandedSubAgentPlayers();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -878,41 +991,156 @@ function creditTypeLabel(type: string) {
|
|||||||
</el-collapse-item>
|
</el-collapse-item>
|
||||||
</el-collapse>
|
</el-collapse>
|
||||||
|
|
||||||
<!-- ─── Filter bar ─── -->
|
<el-tabs v-model="activeViewTab" class="mgr-top-tabs">
|
||||||
<div class="list-chrome">
|
<!-- ─── Tab: 全部玩家(默认) ─── -->
|
||||||
<div class="list-chrome__row">
|
<el-tab-pane :label="`${t('user.type.player')} (${playerTotal})`" name="players">
|
||||||
|
<section class="list-panel player-list-panel">
|
||||||
|
<div class="list-panel-toolbar">
|
||||||
|
<el-form inline class="list-chrome__grow">
|
||||||
|
<el-form-item :label="t('common.keyword')">
|
||||||
|
<el-input
|
||||||
|
v-model="playerKeyword"
|
||||||
|
:placeholder="t('user.filter.username_ph')"
|
||||||
|
clearable
|
||||||
|
style="width: 180px"
|
||||||
|
@keyup.enter="searchPlayers"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="t('user.filter.agent')">
|
||||||
|
<el-select
|
||||||
|
v-model="playerFilterAgent"
|
||||||
|
:placeholder="t('user.filter.agent_ph')"
|
||||||
|
clearable
|
||||||
|
style="width: 200px"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="a in agentOptions"
|
||||||
|
:key="a.id"
|
||||||
|
:label="agentOptionLabel(a)"
|
||||||
|
:value="a.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="t('common.status')">
|
||||||
|
<el-select v-model="playerFilterStatus" :placeholder="t('common.all')" clearable style="width: 120px">
|
||||||
|
<el-option :label="t('user.status.ACTIVE')" value="ACTIVE" />
|
||||||
|
<el-option :label="t('user.status.SUSPENDED')" value="SUSPENDED" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="searchPlayers">{{ t('common.search') }}</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<div class="list-chrome__actions">
|
||||||
|
<el-button type="primary" @click="openCreateAccount">{{ t('user.create_btn') }}</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<el-table v-loading="playerLoading" :data="allPlayers" stripe>
|
||||||
|
<template #empty>
|
||||||
|
<AdminTableEmpty />
|
||||||
|
</template>
|
||||||
|
<el-table-column prop="id" label="ID" width="72" />
|
||||||
|
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
|
||||||
|
<el-table-column :label="t('common.status')" width="88">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('user.col.agent')" min-width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag size="small" type="info" class="affiliation-tag">{{ affiliationLabel(row) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('user.col.balance')" min-width="128" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip :content="`${formatAmountFull(row.availableBalance)} / ${formatAmountFull(row.frozenBalance)}`" placement="top">
|
||||||
|
<span class="amount-compact">{{ formatAmount(row.availableBalance) }} / {{ formatAmount(row.frozenBalance) }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="betCount" :label="t('user.col.bets')" width="64" align="center" />
|
||||||
|
<el-table-column :label="t('user.col.stake_payout')" min-width="108" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="amount-compact">{{ formatAmount(row.totalStake) }} / {{ formatAmount(row.totalReturn) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('user.col.last_login')" width="108">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip v-if="row.lastLoginAt" :content="formatTime(row.lastLoginAt)" placement="top">
|
||||||
|
<span>{{ formatLastLogin(row.lastLoginAt) }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
<span v-else class="text-muted">{{ t('common.never_login') }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('user.col.created')" width="108">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip :content="formatTime(row.createdAt)" placement="top">
|
||||||
|
<span>{{ formatLastLogin(row.createdAt) }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('common.actions')" width="340" fixed="right" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="action-btns">
|
||||||
|
<el-button size="small" link type="primary" @click="openDetailPlayer(row.id)">{{ t('common.detail') }}</el-button>
|
||||||
|
<el-button size="small" link type="primary" @click="openEditPlayer(row.id)">{{ t('common.edit') }}</el-button>
|
||||||
|
<el-button size="small" link type="success" @click="openTransfer('deposit', row)">{{ t('common.topup') }}</el-button>
|
||||||
|
<el-button size="small" link type="warning" @click="openTransfer('withdraw', row)">{{ t('agent_portal.withdraw_btn_label') }}</el-button>
|
||||||
|
<el-button v-if="row.status === 'ACTIVE'" size="small" link type="warning" @click="toggleFreezePlayer(row)">{{ t('common.freeze') }}</el-button>
|
||||||
|
<el-button v-else size="small" link type="primary" @click="toggleFreezePlayer(row)">{{ t('common.unfreeze') }}</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
<div class="pager">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="playerPage"
|
||||||
|
v-model:page-size="playerPageSize"
|
||||||
|
:total="playerTotal"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
background
|
||||||
|
@current-change="onPlayerPageChange"
|
||||||
|
@size-change="onPlayerSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- ─── Tab: 一级代理 ─── -->
|
||||||
|
<el-tab-pane :label="`${t('user.type.tier1_agent')} (${tier1Total})`" name="tier1Agents">
|
||||||
|
<section class="list-panel agent-list-panel">
|
||||||
|
<div class="list-panel-toolbar">
|
||||||
<el-form inline class="list-chrome__grow">
|
<el-form inline class="list-chrome__grow">
|
||||||
<el-form-item :label="t('common.keyword')">
|
<el-form-item :label="t('common.keyword')">
|
||||||
<el-input v-model="keyword" :placeholder="t('agent.filter.username_ph')" clearable style="width: 180px" @keyup.enter="load" />
|
<el-input v-model="tier1Keyword" :placeholder="t('agent.filter.username_ph')" clearable style="width: 180px" @keyup.enter="searchTier1Agents" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('common.status')">
|
<el-form-item :label="t('common.status')">
|
||||||
<el-select v-model="filterStatus" :placeholder="t('common.all')" clearable style="width: 120px">
|
<el-select v-model="tier1FilterStatus" :placeholder="t('common.all')" clearable style="width: 120px">
|
||||||
<el-option :label="t('user.status.ACTIVE')" value="ACTIVE" />
|
<el-option :label="t('user.status.ACTIVE')" value="ACTIVE" />
|
||||||
<el-option :label="t('user.status.SUSPENDED')" value="SUSPENDED" />
|
<el-option :label="t('user.status.SUSPENDED')" value="SUSPENDED" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" @click="load">{{ t('common.search') }}</el-button>
|
<el-button type="primary" @click="searchTier1Agents">{{ t('common.search') }}</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<div class="list-chrome__actions">
|
<div class="list-chrome__actions">
|
||||||
<el-button type="primary" @click="openCreateGlobal">{{ t('agent.create_btn') }}</el-button>
|
<el-button type="primary" @click="openCreateTier1Agent">{{ t('agent.create_btn') }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ─── Agent table ─── -->
|
|
||||||
<section class="list-panel">
|
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<el-table
|
<el-table
|
||||||
ref="agentTableRef"
|
ref="tier1AgentTableRef"
|
||||||
:data="agents"
|
:data="tier1Agents"
|
||||||
stripe
|
stripe
|
||||||
row-key="userId"
|
row-key="userId"
|
||||||
:row-class-name="expandableTableRowClassName"
|
:row-class-name="expandableTableRowClassName"
|
||||||
class="expandable-table"
|
class="expandable-table"
|
||||||
@expand-change="onExpandChange"
|
@expand-change="onExpandChange"
|
||||||
@row-click="onAgentRowClick"
|
@row-click="onTier1AgentRowClick"
|
||||||
>
|
>
|
||||||
<template #empty>
|
<template #empty>
|
||||||
<AdminTableEmpty />
|
<AdminTableEmpty />
|
||||||
@@ -925,16 +1153,12 @@ function creditTypeLabel(type: string) {
|
|||||||
<div v-if="expandLoading[row.userId]" class="expand-loading">
|
<div v-if="expandLoading[row.userId]" class="expand-loading">
|
||||||
{{ t('common.loading') || '加载中...' }}
|
{{ t('common.loading') || '加载中...' }}
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<div v-else class="expand-panel-body">
|
||||||
<el-tabs :model-value="getInnerTab(row.userId)" @update:model-value="setInnerTab(row.userId, $event)" class="inner-tabs">
|
<div class="expand-section-header">
|
||||||
<!-- ── Players tab ── -->
|
<div class="expand-section-title">{{ directPlayersTabLabel(row.username, getPlayers(row.userId).length) }}</div>
|
||||||
<el-tab-pane :label="`${t('nav.players')} (${getPlayers(row.userId).length})`" name="players">
|
<el-button type="primary" size="small" @click="openCreatePlayer(row.userId)">{{ t('user.create_btn') }}</el-button>
|
||||||
<div class="inner-toolbar">
|
</div>
|
||||||
<el-button type="primary" size="small" @click="openCreatePlayer(row.userId)">
|
<el-table :data="getPlayers(row.userId)" stripe class="inner-table">
|
||||||
+ {{ t('user.create_btn') }}
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
<el-table :data="getPlayers(row.userId)" stripe size="small" class="inner-table">
|
|
||||||
<template #empty><AdminTableEmpty /></template>
|
<template #empty><AdminTableEmpty /></template>
|
||||||
<el-table-column prop="id" label="ID" width="60" />
|
<el-table-column prop="id" label="ID" width="60" />
|
||||||
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
|
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
|
||||||
@@ -977,122 +1201,13 @@ function creditTypeLabel(type: string) {
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-tab-pane>
|
</div>
|
||||||
|
|
||||||
<!-- ── Sub-agents tab ── -->
|
|
||||||
<el-tab-pane :label="`${t('nav.subAgents')} (${getSubAgents(row.userId).length})`" name="subAgents">
|
|
||||||
<div class="inner-toolbar">
|
|
||||||
<el-button type="primary" size="small" @click="openCreateSubAgent(row.userId)">
|
|
||||||
+ {{ t('agent.create_sub') || '创建二级代理' }}
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
<el-table
|
|
||||||
:data="getSubAgents(row.userId)"
|
|
||||||
stripe
|
|
||||||
size="small"
|
|
||||||
row-key="userId"
|
|
||||||
:expand-row-keys="subAgentExpandedKeys"
|
|
||||||
:row-class-name="expandableTableRowClassName"
|
|
||||||
class="inner-table expandable-table"
|
|
||||||
@expand-change="(sub: AgentRow, rows: AgentRow[]) => onSubAgentExpand(row.userId, sub, rows)"
|
|
||||||
@row-click="(sub: AgentRow, col: unknown, e: MouseEvent) => onSubAgentRowClick(row.userId, sub, col, e)"
|
|
||||||
>
|
|
||||||
<template #empty><AdminTableEmpty /></template>
|
|
||||||
<el-table-column type="expand">
|
|
||||||
<template #default="{ row: sub }">
|
|
||||||
<div class="expand-panel">
|
|
||||||
<div v-if="subAgentExpandLoading[sub.userId]" class="expand-loading">
|
|
||||||
{{ t('common.loading') }}
|
|
||||||
</div>
|
|
||||||
<template v-else>
|
|
||||||
<div class="expand-section-header">
|
|
||||||
<div class="expand-section-title">
|
|
||||||
{{ t('nav.players') }} ({{ getSubAgentPlayers(sub.userId).length }})
|
|
||||||
</div>
|
|
||||||
<el-button type="primary" size="small" @click.stop="openCreatePlayer(sub.userId)">
|
|
||||||
+ {{ t('agent_portal.create_player_btn').replace(/^\+ /, '') }}
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
<el-table :data="getSubAgentPlayers(sub.userId)" stripe size="small" class="inner-table nested-table">
|
|
||||||
<template #empty><AdminTableEmpty /></template>
|
|
||||||
<el-table-column prop="id" label="ID" width="60" />
|
|
||||||
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
|
|
||||||
<el-table-column :label="t('common.status')" width="80">
|
|
||||||
<template #default="{ row: player }">
|
|
||||||
<el-tag :type="statusTagType(player.status)" size="small">
|
|
||||||
{{ statusLabel(player.status) }}
|
|
||||||
</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column :label="t('user.field.available')" min-width="100" align="right">
|
|
||||||
<template #default="{ row: player }">
|
|
||||||
<span class="amount-compact">{{ formatAmount(player.availableBalance) }}</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column :label="t('common.actions')" width="340" fixed="right" align="center">
|
|
||||||
<template #default="{ row: player }">
|
|
||||||
<div class="action-btns" @click.stop>
|
|
||||||
<el-button size="small" link type="primary" @click="openDetailPlayer(player.id)">{{ t('common.detail') }}</el-button>
|
|
||||||
<el-button size="small" link type="primary" @click="openEditPlayer(player.id)">{{ t('common.edit') }}</el-button>
|
|
||||||
<el-button size="small" link type="success" @click="openTransfer('deposit', player)">{{ t('common.topup') }}</el-button>
|
|
||||||
<el-button size="small" link type="warning" @click="openTransfer('withdraw', player)">{{ t('agent_portal.withdraw_btn_label') }}</el-button>
|
|
||||||
<el-button v-if="player.status === 'ACTIVE'" size="small" link type="warning" @click="toggleFreezePlayer(player)">{{ t('common.freeze') }}</el-button>
|
|
||||||
<el-button v-else size="small" link type="primary" @click="toggleFreezePlayer(player)">{{ t('common.unfreeze') }}</el-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="userId" label="ID" width="60" />
|
|
||||||
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
|
|
||||||
<el-table-column :label="t('common.status')" width="80">
|
|
||||||
<template #default="{ row: sub }">
|
|
||||||
<el-tag :type="statusTagType(subAgentAccountStatus(sub))" size="small">
|
|
||||||
{{ statusLabel(subAgentAccountStatus(sub)) }}
|
|
||||||
</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column :label="t('agent.col.credit')" min-width="150" align="right">
|
|
||||||
<template #default="{ row: sub }">
|
|
||||||
<el-tooltip :content="creditLineFull(sub)" placement="top">
|
|
||||||
<span class="amount-compact">{{ creditLine(sub) }}</span>
|
|
||||||
</el-tooltip>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="directPlayerCount" :label="t('agent.col.direct_players')" width="80" align="center" />
|
|
||||||
<el-table-column :label="t('agent.col.cashback')" width="70" align="right">
|
|
||||||
<template #default="{ row: sub }">{{ sub.cashbackRate }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column :label="t('common.actions')" width="300" fixed="right" align="center">
|
|
||||||
<template #default="{ row: sub }">
|
|
||||||
<div class="action-btns">
|
|
||||||
<el-button size="small" link type="primary" @click="openDetailAgent(sub.userId)">{{ t('common.detail') }}</el-button>
|
|
||||||
<el-button size="small" link type="primary" @click="openEditAgent(sub.userId)">{{ t('common.edit') }}</el-button>
|
|
||||||
<el-button size="small" link type="primary" @click="openCredit(sub.userId)">{{ t('common.adjust_credit') }}</el-button>
|
|
||||||
<el-button
|
|
||||||
v-if="subAgentAccountStatus(sub) === 'ACTIVE'"
|
|
||||||
size="small"
|
|
||||||
link
|
|
||||||
type="warning"
|
|
||||||
@click="toggleFreezeAgent(sub)"
|
|
||||||
>{{ t('common.freeze') }}</el-button>
|
|
||||||
<el-button v-else size="small" link type="primary" @click="toggleFreezeAgent(sub)">{{ t('common.unfreeze') }}</el-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
</el-tab-pane>
|
|
||||||
</el-tabs>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
||||||
<el-table-column prop="userId" label="ID" width="72" />
|
<el-table-column prop="userId" label="ID" width="72" />
|
||||||
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
|
<el-table-column prop="username" :label="t('user.col.username')" min-width="140" />
|
||||||
<el-table-column :label="t('common.status')" width="88">
|
<el-table-column :label="t('common.status')" width="88">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||||
@@ -1113,12 +1228,13 @@ function creditTypeLabel(type: string) {
|
|||||||
<el-table-column :label="t('agent.col.cashback')" width="80" align="right">
|
<el-table-column :label="t('agent.col.cashback')" width="80" align="right">
|
||||||
<template #default="{ row }">{{ row.cashbackRate }}</template>
|
<template #default="{ row }">{{ row.cashbackRate }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="t('common.actions')" width="340" fixed="right" align="center">
|
<el-table-column :label="t('common.actions')" width="400" fixed="right" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="action-btns" @click.stop>
|
<div class="action-btns" @click.stop>
|
||||||
<el-button size="small" link type="primary" @click="openDetailAgent(row.userId)">{{ t('common.detail') }}</el-button>
|
<el-button size="small" link type="primary" @click="openDetailAgent(row.userId)">{{ t('common.detail') }}</el-button>
|
||||||
<el-button size="small" link type="primary" @click="openEditAgent(row.userId)">{{ t('common.edit') }}</el-button>
|
<el-button size="small" link type="primary" @click="openEditAgent(row.userId)">{{ t('common.edit') }}</el-button>
|
||||||
<el-button size="small" link type="primary" @click="openCredit(row.userId)">{{ t('common.adjust_credit') }}</el-button>
|
<el-button size="small" link type="primary" @click="openCredit(row.userId)">{{ t('common.adjust_credit') }}</el-button>
|
||||||
|
<el-button size="small" link type="primary" @click="openCreateSubAgent(row.userId)">{{ t('agent.create_sub') }}</el-button>
|
||||||
<el-button v-if="row.status === 'ACTIVE'" size="small" link type="warning" @click="toggleFreezeAgent(row)">{{ t('common.freeze') }}</el-button>
|
<el-button v-if="row.status === 'ACTIVE'" size="small" link type="warning" @click="toggleFreezeAgent(row)">{{ t('common.freeze') }}</el-button>
|
||||||
<el-button v-else size="small" link type="primary" @click="toggleFreezeAgent(row)">{{ t('common.unfreeze') }}</el-button>
|
<el-button v-else size="small" link type="primary" @click="toggleFreezeAgent(row)">{{ t('common.unfreeze') }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1128,25 +1244,151 @@ function creditTypeLabel(type: string) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="pager">
|
<div class="pager">
|
||||||
<el-pagination
|
<el-pagination
|
||||||
v-model:current-page="page"
|
v-model:current-page="tier1Page"
|
||||||
v-model:page-size="pageSize"
|
v-model:page-size="tier1PageSize"
|
||||||
:total="total"
|
:total="tier1Total"
|
||||||
:page-sizes="[10, 20, 50, 100]"
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
layout="total, sizes, prev, pager, next"
|
layout="total, sizes, prev, pager, next"
|
||||||
background
|
background
|
||||||
@current-change="onPageChange"
|
@current-change="onTier1PageChange"
|
||||||
@size-change="onSizeChange"
|
@size-change="onTier1SizeChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- ─── Tab: 二级代理 ─── -->
|
||||||
|
<el-tab-pane :label="`${t('user.type.sub_agent')} (${tier2Total})`" name="tier2Agents">
|
||||||
|
<section class="list-panel agent-list-panel">
|
||||||
|
<div class="list-panel-toolbar">
|
||||||
|
<el-form inline class="list-chrome__grow">
|
||||||
|
<el-form-item :label="t('common.keyword')">
|
||||||
|
<el-input v-model="tier2Keyword" :placeholder="t('agent.filter.username_ph')" clearable style="width: 180px" @keyup.enter="searchTier2Agents" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="t('common.status')">
|
||||||
|
<el-select v-model="tier2FilterStatus" :placeholder="t('common.all')" clearable style="width: 120px">
|
||||||
|
<el-option :label="t('user.status.ACTIVE')" value="ACTIVE" />
|
||||||
|
<el-option :label="t('user.status.SUSPENDED')" value="SUSPENDED" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="searchTier2Agents">{{ t('common.search') }}</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<div class="list-chrome__actions">
|
||||||
|
<el-button type="primary" @click="openCreateSubAgentFromToolbar">{{ t('agent.create_sub_btn') }}</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<el-table
|
||||||
|
ref="tier2AgentTableRef"
|
||||||
|
:data="tier2Agents"
|
||||||
|
stripe
|
||||||
|
row-key="userId"
|
||||||
|
:row-class-name="expandableTableRowClassName"
|
||||||
|
class="expandable-table"
|
||||||
|
@expand-change="onExpandChange"
|
||||||
|
@row-click="onTier2AgentRowClick"
|
||||||
|
>
|
||||||
|
<template #empty>
|
||||||
|
<AdminTableEmpty />
|
||||||
|
</template>
|
||||||
|
<el-table-column type="expand">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="expand-panel">
|
||||||
|
<div v-if="expandLoading[row.userId]" class="expand-loading">{{ t('common.loading') }}</div>
|
||||||
|
<div v-else class="expand-panel-body">
|
||||||
|
<div class="expand-section-header">
|
||||||
|
<div class="expand-section-title">{{ directPlayersTabLabel(row.username, getPlayers(row.userId).length) }}</div>
|
||||||
|
<el-button type="primary" size="small" @click="openCreatePlayer(row.userId)">{{ t('user.create_btn') }}</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="getPlayers(row.userId)" stripe class="inner-table">
|
||||||
|
<template #empty><AdminTableEmpty /></template>
|
||||||
|
<el-table-column prop="id" label="ID" width="60" />
|
||||||
|
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
|
||||||
|
<el-table-column :label="t('common.status')" width="80">
|
||||||
|
<template #default="{ row: player }">
|
||||||
|
<el-tag :type="statusTagType(player.status)" size="small">{{ statusLabel(player.status) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('user.col.balance')" min-width="120" align="right">
|
||||||
|
<template #default="{ row: player }">
|
||||||
|
<span class="amount-compact">{{ formatAmount(player.availableBalance) }} / {{ formatAmount(player.frozenBalance) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('common.actions')" width="340" fixed="right" align="center">
|
||||||
|
<template #default="{ row: player }">
|
||||||
|
<div class="action-btns">
|
||||||
|
<el-button size="small" link type="primary" @click="openDetailPlayer(player.id)">{{ t('common.detail') }}</el-button>
|
||||||
|
<el-button size="small" link type="primary" @click="openEditPlayer(player.id)">{{ t('common.edit') }}</el-button>
|
||||||
|
<el-button size="small" link type="success" @click="openTransfer('deposit', player)">{{ t('common.topup') }}</el-button>
|
||||||
|
<el-button size="small" link type="warning" @click="openTransfer('withdraw', player)">{{ t('agent_portal.withdraw_btn_label') }}</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="userId" label="ID" width="72" />
|
||||||
|
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
|
||||||
|
<el-table-column :label="t('user.type.tier1_agent')" min-width="120">
|
||||||
|
<template #default="{ row }">{{ row.parentUsername ?? '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('common.status')" width="88">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="statusTagType(subAgentAccountStatus(row))" size="small">{{ statusLabel(subAgentAccountStatus(row)) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('agent.col.credit')" min-width="168" align="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tooltip :content="creditLineFull(row)" placement="top">
|
||||||
|
<span class="amount-compact">{{ creditLine(row) }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="directPlayerCount" :label="t('agent.col.direct_players')" width="80" align="center" />
|
||||||
|
<el-table-column :label="t('common.actions')" width="340" fixed="right" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="action-btns" @click.stop>
|
||||||
|
<el-button size="small" link type="primary" @click="openDetailAgent(row.userId)">{{ t('common.detail') }}</el-button>
|
||||||
|
<el-button size="small" link type="primary" @click="openEditAgent(row.userId)">{{ t('common.edit') }}</el-button>
|
||||||
|
<el-button size="small" link type="primary" @click="openCredit(row.userId)">{{ t('common.adjust_credit') }}</el-button>
|
||||||
|
<el-button v-if="subAgentAccountStatus(row) === 'ACTIVE'" size="small" link type="warning" @click="toggleFreezeAgent(row)">{{ t('common.freeze') }}</el-button>
|
||||||
|
<el-button v-else size="small" link type="primary" @click="toggleFreezeAgent(row)">{{ t('common.unfreeze') }}</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
<div class="pager">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="tier2Page"
|
||||||
|
v-model:page-size="tier2PageSize"
|
||||||
|
:total="tier2Total"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
background
|
||||||
|
@current-change="onTier2PageChange"
|
||||||
|
@size-change="onTier2SizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
|
||||||
<!-- ═══════════ DIALOGS ═══════════ -->
|
<!-- ═══════════ DIALOGS ═══════════ -->
|
||||||
|
|
||||||
<!-- ── Create (unified) ── -->
|
<!-- ── Create (unified) ── -->
|
||||||
<el-dialog v-model="createVisible" :title="t('user.dialog.create')" width="520px" destroy-on-close>
|
<el-dialog v-model="createVisible" :title="createDialogTitle" width="520px" destroy-on-close class="create-account-dialog">
|
||||||
<el-form label-width="100px">
|
<el-form label-width="100px">
|
||||||
<el-form-item :label="t('user.col.username')" required>
|
<el-form-item :label="t('user.col.username')" required>
|
||||||
<el-input v-model="createForm.username" :placeholder="t('user.ph.username_unique')" />
|
<el-input
|
||||||
|
v-model="createForm.username"
|
||||||
|
:placeholder="createAccountMode === 0 ? t('user.ph.username_player') : t('user.ph.username_unique')"
|
||||||
|
/>
|
||||||
|
<div v-if="createAccountMode === 0" class="field-hint">{{ t('user.hint.username_player') }}</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('user.field.password')" required>
|
<el-form-item :label="t('user.field.password')" required>
|
||||||
<el-input v-model="createForm.password" type="text" autocomplete="off" />
|
<el-input v-model="createForm.password" type="text" autocomplete="off" />
|
||||||
@@ -1154,31 +1396,40 @@ function creditTypeLabel(type: string) {
|
|||||||
<el-form-item :label="t('user.field.confirm_password')" required>
|
<el-form-item :label="t('user.field.confirm_password')" required>
|
||||||
<el-input v-model="createForm.confirmPassword" type="text" autocomplete="off" />
|
<el-input v-model="createForm.confirmPassword" type="text" autocomplete="off" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('user.field.account_type')">
|
|
||||||
<el-radio-group :model-value="createAccountMode" @update:model-value="onAccountModeChange">
|
<!-- 玩家:从代理行/展开区进入时锁定所属代理 -->
|
||||||
<el-radio :value="0">{{ t('user.type.player') }}</el-radio>
|
<el-form-item v-if="createAccountMode === 0 && createParentLocked" :label="t('user.filter.agent')">
|
||||||
<el-radio :value="1" :disabled="!!createParentAgentId">{{ t('user.type.tier1_agent') }}</el-radio>
|
<el-tag type="info" class="account-type-tag">{{ resolveCreateParentLabel(createParentAgentId) }}</el-tag>
|
||||||
<el-radio :value="2" :disabled="!createParentAgentId">{{ t('user.type.sub_agent') }}</el-radio>
|
<div class="field-hint">{{ t('agent.hint.creating_under_agent') }}</div>
|
||||||
</el-radio-group>
|
|
||||||
<div class="field-hint">
|
|
||||||
<template v-if="createParentAgentId">
|
|
||||||
{{ t('agent.hint.creating_under_agent') }}
|
|
||||||
</template>
|
|
||||||
<template v-else>{{ t('user.hint.account_type') }}</template>
|
|
||||||
</div>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<!-- Player fields (when mode is player and no parent agent context) -->
|
<!-- 玩家:从玩家 Tab 进入时可选择一级/二级代理 -->
|
||||||
<template v-if="createAccountMode === 0 && !createParentAgentId">
|
<template v-if="createAccountMode === 0 && !createParentLocked">
|
||||||
<el-form-item :label="t('user.filter.agent')">
|
<el-form-item :label="t('user.filter.agent')">
|
||||||
<el-select v-model="createForm.parentId" :placeholder="t('user.ph.no_agent')" clearable style="width: 100%">
|
<el-select v-model="createForm.parentId" :placeholder="t('user.ph.no_agent')" clearable style="width: 100%">
|
||||||
<el-option v-for="a in agents" :key="a.userId" :label="`${a.username} (#${a.userId})`" :value="a.userId" />
|
<el-option v-for="a in agentOptions" :key="a.id" :label="agentOptionLabel(a)" :value="a.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<div class="field-hint">{{ t('user.hint.no_agent') }}</div>
|
<div class="field-hint">{{ t('user.hint.no_agent') }}</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Agent fields (tier1 or sub-agent) -->
|
<!-- 二级代理:从一级代理行进入时锁定上级 -->
|
||||||
|
<el-form-item v-if="createAccountMode === 2 && createParentLocked" :label="t('user.type.tier1_agent')">
|
||||||
|
<el-tag type="info" class="account-type-tag">{{ resolveCreateParentLabel(createParentAgentId) }}</el-tag>
|
||||||
|
<div class="field-hint">{{ t('agent.hint.creating_under_agent') }}</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<!-- 二级代理:从二级 Tab 进入时选择一级代理 -->
|
||||||
|
<template v-if="createAccountMode === 2 && !createParentLocked">
|
||||||
|
<el-form-item :label="t('user.type.tier1_agent')" required>
|
||||||
|
<el-select v-model="createParentAgentId" :placeholder="t('user.filter.agent_ph')" style="width: 100%">
|
||||||
|
<el-option v-for="a in tier1AgentOptions" :key="a.id" :label="agentOptionLabel(a)" :value="a.id" />
|
||||||
|
</el-select>
|
||||||
|
<div class="field-hint">{{ t('agent.hint.sub_agent_parent') }}</div>
|
||||||
|
</el-form-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 代理字段(一级 / 二级) -->
|
||||||
<template v-if="createAccountMode === 1 || createAccountMode === 2">
|
<template v-if="createAccountMode === 1 || createAccountMode === 2">
|
||||||
<el-form-item :label="t('agent.field.credit_limit')" required>
|
<el-form-item :label="t('agent.field.credit_limit')" required>
|
||||||
<el-input-number v-model="createForm.creditLimit" :min="0" :step="10000" style="width: 100%" />
|
<el-input-number v-model="createForm.creditLimit" :min="0" :step="10000" style="width: 100%" />
|
||||||
@@ -1230,7 +1481,8 @@ function creditTypeLabel(type: string) {
|
|||||||
<el-tag :type="statusTagType(editPlayerForm.status)" size="small">{{ statusLabel(editPlayerForm.status) }}</el-tag>
|
<el-tag :type="statusTagType(editPlayerForm.status)" size="small">{{ statusLabel(editPlayerForm.status) }}</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<el-form-item :label="t('user.col.username')">
|
<el-form-item :label="t('user.col.username')">
|
||||||
<el-input v-model="editPlayerForm.username" :placeholder="t('user.ph.username_unique')" />
|
<el-input v-model="editPlayerForm.username" :placeholder="t('user.ph.username_player')" />
|
||||||
|
<div class="field-hint">{{ t('user.hint.username_player') }}</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<div class="password-mgmt-block">
|
<div class="password-mgmt-block">
|
||||||
<div class="block-title">{{ t('user.section.password_mgmt') }}</div>
|
<div class="block-title">{{ t('user.section.password_mgmt') }}</div>
|
||||||
@@ -1243,10 +1495,9 @@ function creditTypeLabel(type: string) {
|
|||||||
<el-input v-model="editPlayerForm.newPassword" type="text" autocomplete="off" :placeholder="t('user.ph.reset_password_short')" />
|
<el-input v-model="editPlayerForm.newPassword" type="text" autocomplete="off" :placeholder="t('user.ph.reset_password_short')" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</div>
|
</div>
|
||||||
<el-form-item :label="t('user.filter.agent')">
|
<el-form-item :label="t('user.col.agent')">
|
||||||
<el-select v-model="editPlayerForm.parentId" :placeholder="t('user.ph.no_agent')" clearable style="width: 100%">
|
<el-tag size="small" type="info" class="affiliation-tag">{{ affiliationLabel(editPlayerForm) }}</el-tag>
|
||||||
<el-option v-for="a in agents" :key="a.userId" :label="`${a.username} (#${a.userId})`" :value="a.userId" />
|
<div class="field-hint">{{ t('user.hint.agent_readonly') }}</div>
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('user.field.phone')">
|
<el-form-item :label="t('user.field.phone')">
|
||||||
<el-input v-model="editPlayerForm.phone" :placeholder="t('common.optional')" />
|
<el-input v-model="editPlayerForm.phone" :placeholder="t('common.optional')" />
|
||||||
@@ -1411,7 +1662,7 @@ function creditTypeLabel(type: string) {
|
|||||||
<el-descriptions-item :label="t('common.status')">
|
<el-descriptions-item :label="t('common.status')">
|
||||||
<el-tag :type="statusTagType(playerDetail.status)" size="small">{{ statusLabel(playerDetail.status) }}</el-tag>
|
<el-tag :type="statusTagType(playerDetail.status)" size="small">{{ statusLabel(playerDetail.status) }}</el-tag>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item :label="t('user.col.agent')">{{ playerDetail.parentUsername ?? t('common.platform_direct') }}</el-descriptions-item>
|
<el-descriptions-item :label="t('user.col.agent')">{{ affiliationLabel(playerDetail) }}</el-descriptions-item>
|
||||||
<el-descriptions-item :label="t('user.field.available')">
|
<el-descriptions-item :label="t('user.field.available')">
|
||||||
{{ formatAmount(playerDetail.availableBalance) }}
|
{{ formatAmount(playerDetail.availableBalance) }}
|
||||||
<span v-if="shouldCompact(playerDetail.availableBalance)" class="amount-full-hint">({{ formatAmountFull(playerDetail.availableBalance) }})</span>
|
<span v-if="shouldCompact(playerDetail.availableBalance)" class="amount-full-hint">({{ formatAmountFull(playerDetail.availableBalance) }})</span>
|
||||||
@@ -1509,9 +1760,75 @@ function creditTypeLabel(type: string) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.agent-mgr-page > .agent-list-panel,
|
||||||
|
.agent-mgr-page > .mgr-top-tabs {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.agent-mgr-page > .mgr-top-tabs :deep(.el-tabs__content) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.agent-mgr-page > .mgr-top-tabs :deep(.el-tab-pane) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.player-list-panel,
|
||||||
|
.agent-list-panel {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.mgr-top-tabs :deep(.el-tabs__header) {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.mgr-top-tabs :deep(.el-tabs__item) {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Table toolbar ─── */
|
||||||
|
.list-panel-toolbar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 0 8px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
--list-chrome-control-h: 32px;
|
||||||
|
--el-component-size: 32px;
|
||||||
|
}
|
||||||
|
.list-panel-toolbar .list-chrome__actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.list-panel-toolbar :deep(.el-form-item) {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
.list-panel-toolbar :deep(.el-input__wrapper),
|
||||||
|
.list-panel-toolbar :deep(.el-select__wrapper) {
|
||||||
|
height: var(--list-chrome-control-h) !important;
|
||||||
|
min-height: var(--list-chrome-control-h) !important;
|
||||||
|
}
|
||||||
|
.list-panel-toolbar :deep(.el-button:not(.is-link)) {
|
||||||
|
height: var(--list-chrome-control-h) !important;
|
||||||
|
min-height: var(--list-chrome-control-h) !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Expansion ─── */
|
/* ─── Expansion ─── */
|
||||||
.expand-panel {
|
.expand-panel {
|
||||||
padding: 4px 16px 8px;
|
margin: 4px 0 8px;
|
||||||
|
padding: 12px 14px 14px;
|
||||||
|
background: #141414;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
border-left: 3px solid rgba(47, 181, 106, 0.45);
|
||||||
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
.expand-loading {
|
.expand-loading {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -1546,22 +1863,34 @@ function creditTypeLabel(type: string) {
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
.inner-tabs :deep(.el-tabs__item) {
|
.inner-tabs :deep(.el-tabs__item) {
|
||||||
height: 32px;
|
height: 34px;
|
||||||
line-height: 32px;
|
line-height: 34px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #888;
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
}
|
}
|
||||||
|
.inner-tabs :deep(.el-tabs__item.is-active) {
|
||||||
|
color: #d4fde5;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.inner-tabs :deep(.el-tabs__active-bar) {
|
||||||
|
background-color: var(--green-bright);
|
||||||
|
height: 2px;
|
||||||
|
}
|
||||||
.inner-tabs :deep(.el-tabs__content) {
|
.inner-tabs :deep(.el-tabs__content) {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.inner-toolbar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
.inner-table {
|
.inner-table {
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
.inner-table :deep(.el-table__cell) {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.account-type-tag {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
.expandable-table :deep(.row-expandable) {
|
.expandable-table :deep(.row-expandable) {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -1581,6 +1910,11 @@ function creditTypeLabel(type: string) {
|
|||||||
}
|
}
|
||||||
.field-hint { font-size: 12px; color: #888; margin-top: 4px; }
|
.field-hint { font-size: 12px; color: #888; margin-top: 4px; }
|
||||||
.amount-compact { white-space: nowrap; font-variant-numeric: tabular-nums; cursor: default; }
|
.amount-compact { white-space: nowrap; font-variant-numeric: tabular-nums; cursor: default; }
|
||||||
|
.affiliation-tag {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
.amount-full-hint { font-size: 11px; color: #666; margin-left: 4px; }
|
.amount-full-hint { font-size: 11px; color: #666; margin-left: 4px; }
|
||||||
.text-muted { color: #666; font-size: 12px; }
|
.text-muted { color: #666; font-size: 12px; }
|
||||||
.password-mgmt-block {
|
.password-mgmt-block {
|
||||||
|
|||||||
643
apps/admin/src/views/MediaLibrary.vue
Normal file
643
apps/admin/src/views/MediaLibrary.vue
Normal file
@@ -0,0 +1,643 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue';
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
|
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||||
|
import api from '../api';
|
||||||
|
|
||||||
|
const { t } = useAdminLocale();
|
||||||
|
|
||||||
|
interface MediaFile {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
category: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
url: string;
|
||||||
|
uploadedBy: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
inUse: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORIES = ['banners', 'teams', 'contents'] as const;
|
||||||
|
type Category = (typeof CATEGORIES)[number];
|
||||||
|
|
||||||
|
const files = ref<MediaFile[]>([]);
|
||||||
|
const total = ref(0);
|
||||||
|
const loading = ref(false);
|
||||||
|
const purging = ref(false);
|
||||||
|
const uploading = ref(false);
|
||||||
|
|
||||||
|
const activeCategory = ref<Category | ''>('');
|
||||||
|
const currentPage = ref(1);
|
||||||
|
const pageSize = 40;
|
||||||
|
|
||||||
|
const uploadDialogVisible = ref(false);
|
||||||
|
const uploadCategory = ref<Category>('banners');
|
||||||
|
const uploadFile = ref<File | null>(null);
|
||||||
|
const dropActive = ref(false);
|
||||||
|
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const unusedCount = computed(() => files.value.filter((f) => !f.inUse).length);
|
||||||
|
|
||||||
|
function categoryLabel(cat: string) {
|
||||||
|
const key = `media.category.${cat}` as const;
|
||||||
|
return t(key as any) || cat;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number) {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
return new Date(iso).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFiles() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const params: Record<string, string | number> = { page: currentPage.value, pageSize };
|
||||||
|
if (activeCategory.value) params.category = activeCategory.value;
|
||||||
|
const res = await api.get('/admin/files', { params });
|
||||||
|
files.value = res.data.data.items;
|
||||||
|
total.value = res.data.data.total;
|
||||||
|
} catch {
|
||||||
|
ElMessage.error(t('common.loading'));
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(activeCategory, () => {
|
||||||
|
currentPage.value = 1;
|
||||||
|
loadFiles();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(currentPage, loadFiles);
|
||||||
|
|
||||||
|
onMounted(loadFiles);
|
||||||
|
|
||||||
|
async function confirmDelete(file: MediaFile) {
|
||||||
|
await ElMessageBox.confirm(t('media.delete_confirm'), { type: 'warning' });
|
||||||
|
try {
|
||||||
|
await api.delete(`/admin/files/${file.id}`);
|
||||||
|
ElMessage.success(t('media.delete_success'));
|
||||||
|
loadFiles();
|
||||||
|
} catch {
|
||||||
|
ElMessage.error(t('media.upload_failed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function purgeUnused() {
|
||||||
|
if (unusedCount.value === 0) {
|
||||||
|
ElMessage.info(t('media.purge_none'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const msg = t('media.purge_confirm').replace('{n}', String(unusedCount.value));
|
||||||
|
await ElMessageBox.confirm(msg, { type: 'warning' });
|
||||||
|
purging.value = true;
|
||||||
|
try {
|
||||||
|
const res = await api.delete('/admin/files/unused');
|
||||||
|
const deleted = res.data.data.deleted;
|
||||||
|
ElMessage.success(t('media.purge_success').replace('{n}', String(deleted)));
|
||||||
|
loadFiles();
|
||||||
|
} catch {
|
||||||
|
ElMessage.error(t('media.upload_failed'));
|
||||||
|
} finally {
|
||||||
|
purging.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyUrl(url: string) {
|
||||||
|
navigator.clipboard.writeText(location.origin + url).then(
|
||||||
|
() => ElMessage.success(t('media.url_copied')),
|
||||||
|
() => ElMessage.error(t('media.upload_failed')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUploadDialog() {
|
||||||
|
uploadFile.value = null;
|
||||||
|
uploadDialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileChange(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
if (input.files?.[0]) {
|
||||||
|
uploadFile.value = input.files[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
dropActive.value = false;
|
||||||
|
if (e.dataTransfer?.files?.[0]) {
|
||||||
|
uploadFile.value = e.dataTransfer.files[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doUpload() {
|
||||||
|
if (!uploadFile.value) return;
|
||||||
|
uploading.value = true;
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', uploadFile.value);
|
||||||
|
await api.post(`/admin/uploads?category=${uploadCategory.value}`, fd, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
ElMessage.success(t('media.upload_success'));
|
||||||
|
uploadDialogVisible.value = false;
|
||||||
|
uploadFile.value = null;
|
||||||
|
if (fileInputRef.value) fileInputRef.value.value = '';
|
||||||
|
loadFiles();
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.response?.data?.message || t('media.upload_failed');
|
||||||
|
ElMessage.error(msg);
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="media-page">
|
||||||
|
<!-- Header toolbar -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="filter-tabs">
|
||||||
|
<button
|
||||||
|
class="tab-btn"
|
||||||
|
:class="{ active: activeCategory === '' }"
|
||||||
|
@click="activeCategory = ''"
|
||||||
|
>{{ t('media.category.all') }}</button>
|
||||||
|
<button
|
||||||
|
v-for="cat in CATEGORIES"
|
||||||
|
:key="cat"
|
||||||
|
class="tab-btn"
|
||||||
|
:class="{ active: activeCategory === cat }"
|
||||||
|
@click="activeCategory = cat"
|
||||||
|
>{{ categoryLabel(cat) }}</button>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<span v-if="unusedCount > 0" class="unused-badge">
|
||||||
|
{{ t('media.unused_count').replace('{n}', String(unusedCount)) }}
|
||||||
|
</span>
|
||||||
|
<button class="btn btn-ghost" :disabled="purging" @click="purgeUnused">
|
||||||
|
{{ t('media.purge_btn') }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" @click="openUploadDialog">
|
||||||
|
+ {{ t('media.upload_btn') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File grid -->
|
||||||
|
<div v-if="loading" class="state-center">{{ t('common.loading') }}</div>
|
||||||
|
<div v-else-if="files.length === 0" class="state-center muted">{{ t('media.no_files') }}</div>
|
||||||
|
<div v-else class="file-grid">
|
||||||
|
<div v-for="file in files" :key="file.id" class="file-card">
|
||||||
|
<div class="card-thumb">
|
||||||
|
<img
|
||||||
|
v-if="file.mimeType !== 'image/svg+xml'"
|
||||||
|
:src="file.url"
|
||||||
|
:alt="file.filename"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div v-else class="svg-badge">SVG</div>
|
||||||
|
<span class="use-badge" :class="file.inUse ? 'badge-used' : 'badge-unused'">
|
||||||
|
{{ file.inUse ? t('media.status.used') : t('media.status.unused') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="card-filename" :title="file.filename">{{ file.filename }}</div>
|
||||||
|
<div class="card-meta">
|
||||||
|
<span class="cat-tag">{{ categoryLabel(file.category) }}</span>
|
||||||
|
<span>{{ formatSize(file.size) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-date">{{ formatDate(file.createdAt) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class="act-btn" @click="copyUrl(file.url)">{{ t('media.copy_url') }}</button>
|
||||||
|
<button class="act-btn act-delete" @click="confirmDelete(file)">{{ t('common.delete') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div v-if="total > pageSize" class="pagination">
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost"
|
||||||
|
:disabled="currentPage <= 1"
|
||||||
|
@click="currentPage--"
|
||||||
|
>«</button>
|
||||||
|
<span class="page-info">{{ currentPage }} / {{ Math.ceil(total / pageSize) }}</span>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost"
|
||||||
|
:disabled="currentPage >= Math.ceil(total / pageSize)"
|
||||||
|
@click="currentPage++"
|
||||||
|
>»</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload dialog -->
|
||||||
|
<div v-if="uploadDialogVisible" class="dialog-overlay" @click.self="uploadDialogVisible = false">
|
||||||
|
<div class="dialog">
|
||||||
|
<div class="dialog-header">
|
||||||
|
<span>{{ t('media.upload_dialog') }}</span>
|
||||||
|
<button class="dialog-close" @click="uploadDialogVisible = false">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-body">
|
||||||
|
<div class="form-row">
|
||||||
|
<label>{{ t('media.upload_category') }}</label>
|
||||||
|
<div class="cat-buttons">
|
||||||
|
<button
|
||||||
|
v-for="cat in CATEGORIES"
|
||||||
|
:key="cat"
|
||||||
|
class="tab-btn"
|
||||||
|
:class="{ active: uploadCategory === cat }"
|
||||||
|
@click="uploadCategory = cat"
|
||||||
|
>{{ categoryLabel(cat) }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="drop-zone"
|
||||||
|
:class="{ 'drop-active': dropActive }"
|
||||||
|
@dragover.prevent="dropActive = true"
|
||||||
|
@dragleave="dropActive = false"
|
||||||
|
@drop="onDrop"
|
||||||
|
@click="fileInputRef?.click()"
|
||||||
|
>
|
||||||
|
<div v-if="!uploadFile" class="drop-hint">{{ t('media.drop_hint') }}</div>
|
||||||
|
<div v-else class="drop-selected">
|
||||||
|
<span class="sel-name">{{ uploadFile.name }}</span>
|
||||||
|
<span class="sel-size">{{ formatSize(uploadFile.size) }}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml"
|
||||||
|
style="display:none"
|
||||||
|
@change="onFileChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="upload-hint-text">{{ t('media.upload_hint') }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<button class="btn btn-ghost" @click="uploadDialogVisible = false">{{ t('common.cancel') }}</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="!uploadFile || uploading"
|
||||||
|
@click="doUpload"
|
||||||
|
>{{ uploading ? t('common.loading') : t('common.confirm') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.media-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Toolbar ── */
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.filter-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.unused-badge {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #f0a020;
|
||||||
|
background: rgba(240, 160, 32, 0.12);
|
||||||
|
border: 1px solid rgba(240, 160, 32, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tabs / Buttons ── */
|
||||||
|
.tab-btn {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
background: transparent;
|
||||||
|
color: #888;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.tab-btn:hover { border-color: #444; color: #ccc; }
|
||||||
|
.tab-btn.active {
|
||||||
|
background: rgba(47, 181, 106, 0.14);
|
||||||
|
border-color: rgba(47, 181, 106, 0.5);
|
||||||
|
color: #2fb56a;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 7px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: all 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
border-color: #2a2a2a;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.btn-ghost:hover:not(:disabled) { border-color: #444; color: #ccc; }
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #2fb56a, #248f54);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
border-color: transparent;
|
||||||
|
box-shadow: 0 2px 8px rgba(47, 181, 106, 0.3);
|
||||||
|
}
|
||||||
|
.btn-primary:hover:not(:disabled) { filter: brightness(1.08); }
|
||||||
|
|
||||||
|
/* ── Grid ── */
|
||||||
|
.file-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-card {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid #1e1e1e;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.file-card:hover {
|
||||||
|
border-color: #333;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-thumb {
|
||||||
|
position: relative;
|
||||||
|
height: 120px;
|
||||||
|
background: #111;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.card-thumb img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.svg-badge {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #666;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.use-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.badge-used {
|
||||||
|
background: rgba(47, 181, 106, 0.2);
|
||||||
|
color: #2fb56a;
|
||||||
|
border: 1px solid rgba(47, 181, 106, 0.35);
|
||||||
|
}
|
||||||
|
.badge-unused {
|
||||||
|
background: rgba(120, 120, 120, 0.18);
|
||||||
|
color: #666;
|
||||||
|
border: 1px solid rgba(120, 120, 120, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 10px 12px 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.card-filename {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #ccc;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.card-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
.cat-tag {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.card-date {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
border-top: 1px solid #1a1a1a;
|
||||||
|
}
|
||||||
|
.act-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 7px 4px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-right: 1px solid #1a1a1a;
|
||||||
|
color: #666;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.act-btn:last-child { border-right: none; }
|
||||||
|
.act-btn:hover { background: rgba(255, 255, 255, 0.04); color: #ccc; }
|
||||||
|
.act-delete:hover { color: #e05555; }
|
||||||
|
|
||||||
|
/* ── Pagination ── */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
.page-info {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
min-width: 60px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty / loading states ── */
|
||||||
|
.state-center {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
.muted { color: #333; }
|
||||||
|
|
||||||
|
/* ── Upload dialog ── */
|
||||||
|
.dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.65);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 500;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.dialog {
|
||||||
|
background: #141414;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
.dialog-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid #1e1e1e;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
.dialog-close {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #555;
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
.dialog-close:hover { color: #ccc; }
|
||||||
|
|
||||||
|
.dialog-body {
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.form-row label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.cat-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone {
|
||||||
|
border: 2px dashed #2a2a2a;
|
||||||
|
border-radius: 10px;
|
||||||
|
min-height: 120px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.drop-zone:hover,
|
||||||
|
.drop-zone.drop-active {
|
||||||
|
border-color: rgba(47, 181, 106, 0.5);
|
||||||
|
background: rgba(47, 181, 106, 0.04);
|
||||||
|
}
|
||||||
|
.drop-hint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #444;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.drop-selected {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.sel-name {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #ccc;
|
||||||
|
font-weight: 500;
|
||||||
|
word-break: break-all;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.sel-size {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-hint-text {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid #1e1e1e;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
type PlayerDetail,
|
type PlayerDetail,
|
||||||
type PlayerCreateForm,
|
type PlayerCreateForm,
|
||||||
type PlayerEditForm,
|
type PlayerEditForm,
|
||||||
|
formatPlayerAffiliationLabel,
|
||||||
|
assertPlayerUsername,
|
||||||
} from './user-form';
|
} from './user-form';
|
||||||
import {
|
import {
|
||||||
formatAmount,
|
formatAmount,
|
||||||
@@ -188,6 +190,10 @@ async function loadAgentOptions() {
|
|||||||
agentOptions.value = data.data;
|
agentOptions.value = data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function playerAffiliationLabel(row: PlayerEditForm) {
|
||||||
|
return formatPlayerAffiliationLabel(row, t('user.type.player'), t('agent.platform_row_name'));
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const { data } = await api.get('/admin/users', {
|
const { data } = await api.get('/admin/users', {
|
||||||
params: {
|
params: {
|
||||||
@@ -295,12 +301,17 @@ async function submitEdit() {
|
|||||||
ElMessage.warning(t('err.password_min'));
|
ElMessage.warning(t('err.password_min'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
assertPlayerUsername(editForm.value.username);
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.warning(resolveFormError(e, t));
|
||||||
|
return;
|
||||||
|
}
|
||||||
editLoading.value = true;
|
editLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const newPwd = editForm.value.newPassword.trim();
|
const newPwd = editForm.value.newPassword.trim();
|
||||||
const { data } = await api.put(`/admin/users/${editingId.value}`, {
|
const { data } = await api.put(`/admin/users/${editingId.value}`, {
|
||||||
username: editForm.value.username.trim(),
|
username: editForm.value.username.trim(),
|
||||||
parentId: editForm.value.parentId || '',
|
|
||||||
phone: editForm.value.phone.trim() || undefined,
|
phone: editForm.value.phone.trim() || undefined,
|
||||||
email: editForm.value.email.trim() || undefined,
|
email: editForm.value.email.trim() || undefined,
|
||||||
password: newPwd || undefined,
|
password: newPwd || undefined,
|
||||||
@@ -587,7 +598,8 @@ function statusLabel(s: string) {
|
|||||||
<el-dialog v-model="createVisible" :title="t('user.dialog.create')" width="520px" destroy-on-close>
|
<el-dialog v-model="createVisible" :title="t('user.dialog.create')" width="520px" destroy-on-close>
|
||||||
<el-form label-width="100px">
|
<el-form label-width="100px">
|
||||||
<el-form-item :label="t('user.col.username')" required>
|
<el-form-item :label="t('user.col.username')" required>
|
||||||
<el-input v-model="createForm.username" :placeholder="t('user.ph.username_unique')" />
|
<el-input v-model="createForm.username" :placeholder="t('user.ph.username_player')" />
|
||||||
|
<div v-if="!createForm.asTier1Agent" class="field-hint">{{ t('user.hint.username_player') }}</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('user.field.password')" required>
|
<el-form-item :label="t('user.field.password')" required>
|
||||||
<el-input v-model="createForm.password" type="text" autocomplete="off" />
|
<el-input v-model="createForm.password" type="text" autocomplete="off" />
|
||||||
@@ -678,7 +690,8 @@ function statusLabel(s: string) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-form-item :label="t('user.col.username')">
|
<el-form-item :label="t('user.col.username')">
|
||||||
<el-input v-model="editForm.username" :placeholder="t('user.ph.username_unique')" />
|
<el-input v-model="editForm.username" :placeholder="t('user.ph.username_player')" />
|
||||||
|
<div class="field-hint">{{ t('user.hint.username_player') }}</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<div class="password-mgmt-block">
|
<div class="password-mgmt-block">
|
||||||
@@ -700,20 +713,9 @@ function statusLabel(s: string) {
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-form-item :label="t('user.filter.agent')">
|
<el-form-item :label="t('user.col.agent')">
|
||||||
<el-select
|
<el-tag size="small" type="info">{{ playerAffiliationLabel(editForm) }}</el-tag>
|
||||||
v-model="editForm.parentId"
|
<div class="field-hint">{{ t('user.hint.agent_readonly') }}</div>
|
||||||
:placeholder="t('user.ph.no_agent')"
|
|
||||||
clearable
|
|
||||||
style="width: 100%"
|
|
||||||
>
|
|
||||||
<el-option
|
|
||||||
v-for="a in agentOptions"
|
|
||||||
:key="a.id"
|
|
||||||
:label="`${a.username} (#${a.id})`"
|
|
||||||
:value="a.id"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('user.field.phone')">
|
<el-form-item :label="t('user.field.phone')">
|
||||||
<el-input v-model="editForm.phone" :placeholder="t('common.optional')" />
|
<el-input v-model="editForm.phone" :placeholder="t('common.optional')" />
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export interface AgentRow {
|
|||||||
username: string;
|
username: string;
|
||||||
userStatus: string;
|
userStatus: string;
|
||||||
level: number;
|
level: number;
|
||||||
|
parentAgentId?: string | null;
|
||||||
|
parentUsername?: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
creditLimit: string;
|
creditLimit: string;
|
||||||
usedCredit: string;
|
usedCredit: string;
|
||||||
|
|||||||
@@ -747,7 +747,8 @@ function statusTagType(s: string) {
|
|||||||
/>
|
/>
|
||||||
<el-form label-width="100px" class="create-form">
|
<el-form label-width="100px" class="create-form">
|
||||||
<el-form-item :label="t('user.col.username')" required>
|
<el-form-item :label="t('user.col.username')" required>
|
||||||
<el-input v-model="createForm.username" :placeholder="t('agent_portal.username_ph')" />
|
<el-input v-model="createForm.username" :placeholder="t('user.ph.username_player')" />
|
||||||
|
<div class="field-hint">{{ t('user.hint.username_player') }}</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('user.field.password')" required>
|
<el-form-item :label="t('user.field.password')" required>
|
||||||
<el-input v-model="createForm.password" type="text" autocomplete="off" />
|
<el-input v-model="createForm.password" type="text" autocomplete="off" />
|
||||||
@@ -783,7 +784,8 @@ function statusTagType(s: string) {
|
|||||||
<el-tag :type="statusTagType(editForm.status)" size="small">{{ statusLabel(editForm.status) }}</el-tag>
|
<el-tag :type="statusTagType(editForm.status)" size="small">{{ statusLabel(editForm.status) }}</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<el-form-item :label="t('user.col.username')">
|
<el-form-item :label="t('user.col.username')">
|
||||||
<el-input v-model="editForm.username" :placeholder="t('user.ph.username_unique')" />
|
<el-input v-model="editForm.username" :placeholder="t('user.ph.username_player')" />
|
||||||
|
<div class="field-hint">{{ t('user.hint.username_player') }}</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<div class="password-mgmt-block">
|
<div class="password-mgmt-block">
|
||||||
<div class="block-title">{{ t('user.section.password_mgmt') }}</div>
|
<div class="block-title">{{ t('user.section.password_mgmt') }}</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { FormValidationError } from '../../i18n/form-validation';
|
import { FormValidationError } from '../../i18n/form-validation';
|
||||||
|
import { assertPlayerUsername } from '../user-form';
|
||||||
|
|
||||||
export interface AgentPlayerCreateForm {
|
export interface AgentPlayerCreateForm {
|
||||||
username: string;
|
username: string;
|
||||||
@@ -65,7 +66,7 @@ export function emptyAgentPlayerCreateForm(): AgentPlayerCreateForm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildAgentCreatePlayerPayload(form: AgentPlayerCreateForm) {
|
export function buildAgentCreatePlayerPayload(form: AgentPlayerCreateForm) {
|
||||||
if (!form.username.trim()) throw new FormValidationError('err.username_required');
|
assertPlayerUsername(form.username);
|
||||||
if (form.password.length < 8) throw new FormValidationError('err.password_min');
|
if (form.password.length < 8) throw new FormValidationError('err.password_min');
|
||||||
if (form.password !== form.confirmPassword) throw new FormValidationError('err.password_mismatch');
|
if (form.password !== form.confirmPassword) throw new FormValidationError('err.password_mismatch');
|
||||||
if (form.initialDeposit < 0) throw new FormValidationError('err.amount_negative');
|
if (form.initialDeposit < 0) throw new FormValidationError('err.amount_negative');
|
||||||
@@ -119,7 +120,7 @@ export function editFormFromAgentDetail(d: AgentPlayerDetail): AgentPlayerEditFo
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildAgentUpdatePlayerPayload(form: AgentPlayerEditForm) {
|
export function buildAgentUpdatePlayerPayload(form: AgentPlayerEditForm) {
|
||||||
if (!form.username.trim()) throw new FormValidationError('err.username_required');
|
assertPlayerUsername(form.username);
|
||||||
if (form.newPassword && form.newPassword.length < 8) {
|
if (form.newPassword && form.newPassword.length < 8) {
|
||||||
throw new FormValidationError('err.password_min');
|
throw new FormValidationError('err.password_min');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import { FormValidationError } from '../i18n/form-validation';
|
import { FormValidationError } from '../i18n/form-validation';
|
||||||
|
|
||||||
|
/** 玩家用户名:仅英文字母与数字,3–32 位 */
|
||||||
|
export const PLAYER_USERNAME_PATTERN = /^[a-zA-Z0-9]{3,32}$/;
|
||||||
|
|
||||||
|
export function assertPlayerUsername(username: string): void {
|
||||||
|
const trimmed = username.trim();
|
||||||
|
if (!trimmed) throw new FormValidationError('err.username_required');
|
||||||
|
if (!PLAYER_USERNAME_PATTERN.test(trimmed)) {
|
||||||
|
throw new FormValidationError('err.username_player_invalid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface PlayerCreateForm {
|
export interface PlayerCreateForm {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -21,8 +32,8 @@ export interface PlayerEditForm {
|
|||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
status: string;
|
status: string;
|
||||||
parentId: string;
|
|
||||||
parentUsername: string | null;
|
parentUsername: string | null;
|
||||||
|
affiliationAgents?: string[];
|
||||||
availableBalance: string;
|
availableBalance: string;
|
||||||
frozenBalance: string;
|
frozenBalance: string;
|
||||||
betCount: number;
|
betCount: number;
|
||||||
@@ -44,6 +55,8 @@ export interface PlayerRow {
|
|||||||
locale: string;
|
locale: string;
|
||||||
parentId: string | null;
|
parentId: string | null;
|
||||||
parentUsername: string | null;
|
parentUsername: string | null;
|
||||||
|
/** 归属代理链:一级代理、二级代理(如有) */
|
||||||
|
affiliationAgents?: string[];
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
managedPassword: string | null;
|
managedPassword: string | null;
|
||||||
@@ -62,6 +75,19 @@ export interface PlayerDetail extends PlayerRow {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 玩家归属标签,格式:玩家-平台 | 玩家-一级代理 | 玩家-一级代理-二级代理 */
|
||||||
|
export function formatPlayerAffiliationLabel(
|
||||||
|
row: Pick<PlayerRow, 'affiliationAgents'>,
|
||||||
|
playerLabel: string,
|
||||||
|
platformLabel: string,
|
||||||
|
): string {
|
||||||
|
const agents = row.affiliationAgents ?? [];
|
||||||
|
if (agents.length === 0) {
|
||||||
|
return [playerLabel, platformLabel].join('-');
|
||||||
|
}
|
||||||
|
return [playerLabel, ...agents].join('-');
|
||||||
|
}
|
||||||
|
|
||||||
export function emptyPlayerCreateForm(): PlayerCreateForm {
|
export function emptyPlayerCreateForm(): PlayerCreateForm {
|
||||||
return {
|
return {
|
||||||
username: '',
|
username: '',
|
||||||
@@ -85,7 +111,6 @@ export function emptyPlayerEditForm(): PlayerEditForm {
|
|||||||
id: '',
|
id: '',
|
||||||
username: '',
|
username: '',
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
parentId: '',
|
|
||||||
parentUsername: null,
|
parentUsername: null,
|
||||||
availableBalance: '0',
|
availableBalance: '0',
|
||||||
frozenBalance: '0',
|
frozenBalance: '0',
|
||||||
@@ -107,8 +132,8 @@ export function editFormFromDetail(d: PlayerDetail): PlayerEditForm {
|
|||||||
id: d.id,
|
id: d.id,
|
||||||
username: d.username,
|
username: d.username,
|
||||||
status: d.status,
|
status: d.status,
|
||||||
parentId: d.parentId ?? '',
|
|
||||||
parentUsername: d.parentUsername,
|
parentUsername: d.parentUsername,
|
||||||
|
affiliationAgents: d.affiliationAgents,
|
||||||
availableBalance: d.availableBalance,
|
availableBalance: d.availableBalance,
|
||||||
frozenBalance: d.frozenBalance,
|
frozenBalance: d.frozenBalance,
|
||||||
betCount: d.betCount,
|
betCount: d.betCount,
|
||||||
@@ -144,6 +169,7 @@ export function buildCreatePlayerPayload(form: PlayerCreateForm) {
|
|||||||
maxDailyDeposit: form.maxDailyDeposit > 0 ? form.maxDailyDeposit : undefined,
|
maxDailyDeposit: form.maxDailyDeposit > 0 ? form.maxDailyDeposit : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
assertPlayerUsername(form.username);
|
||||||
return {
|
return {
|
||||||
username: form.username.trim(),
|
username: form.username.trim(),
|
||||||
password: form.password,
|
password: form.password,
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "uploaded_files" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"filename" TEXT NOT NULL,
|
||||||
|
"category" VARCHAR(32) NOT NULL,
|
||||||
|
"mime_type" VARCHAR(64) NOT NULL,
|
||||||
|
"size" INTEGER NOT NULL,
|
||||||
|
"url" VARCHAR(500) NOT NULL,
|
||||||
|
"uploaded_by" BIGINT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "uploaded_files_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "uploaded_files_filename_key" ON "uploaded_files"("filename");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "uploaded_files_category_idx" ON "uploaded_files"("category");
|
||||||
@@ -595,6 +595,22 @@ model I18nMessage {
|
|||||||
@@map("i18n_messages")
|
@@map("i18n_messages")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ Media / File Uploads ============
|
||||||
|
|
||||||
|
model UploadedFile {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
filename String @unique
|
||||||
|
category String @db.VarChar(32)
|
||||||
|
mimeType String @map("mime_type") @db.VarChar(64)
|
||||||
|
size Int
|
||||||
|
url String @db.VarChar(500)
|
||||||
|
uploadedBy BigInt? @map("uploaded_by")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@index([category])
|
||||||
|
@@map("uploaded_files")
|
||||||
|
}
|
||||||
|
|
||||||
// ============ System Config & Audit ============
|
// ============ System Config & Audit ============
|
||||||
|
|
||||||
model SystemConfig {
|
model SystemConfig {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { mkdir, writeFile } from 'fs/promises';
|
import { mkdir, writeFile, unlink } from 'fs/promises';
|
||||||
import { extname, join } from 'path';
|
import { extname, join } from 'path';
|
||||||
import { JwtAuthGuard, AdminGuard, PermissionsGuard } from '../../domains/identity/guards';
|
import { JwtAuthGuard, AdminGuard, PermissionsGuard } from '../../domains/identity/guards';
|
||||||
import { ContentService } from '../../domains/operations/content/content.service';
|
import { ContentService } from '../../domains/operations/content/content.service';
|
||||||
@@ -228,11 +228,6 @@ class UpdatePlayerAdminDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
email?: string;
|
email?: string;
|
||||||
|
|
||||||
/** 传空字符串表示改为平台直属(无代理) */
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
parentId?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
username?: string;
|
username?: string;
|
||||||
@@ -941,6 +936,7 @@ export class AdminController {
|
|||||||
@Query('pageSize') pageSize?: string,
|
@Query('pageSize') pageSize?: string,
|
||||||
@Query('keyword') keyword?: string,
|
@Query('keyword') keyword?: string,
|
||||||
@Query('parentId') parentId?: string,
|
@Query('parentId') parentId?: string,
|
||||||
|
@Query('platformDirect') platformDirect?: string,
|
||||||
@Query('status') status?: string,
|
@Query('status') status?: string,
|
||||||
) {
|
) {
|
||||||
const result = await this.users.listPlayers(
|
const result = await this.users.listPlayers(
|
||||||
@@ -949,6 +945,7 @@ export class AdminController {
|
|||||||
{
|
{
|
||||||
keyword,
|
keyword,
|
||||||
parentId: parentId ? BigInt(parentId) : undefined,
|
parentId: parentId ? BigInt(parentId) : undefined,
|
||||||
|
platformDirect: platformDirect === 'true' || platformDirect === '1',
|
||||||
status,
|
status,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1040,12 +1037,22 @@ export class AdminController {
|
|||||||
@RequirePermissions(P.agentsView)
|
@RequirePermissions(P.agentsView)
|
||||||
async listAgentOptions() {
|
async listAgentOptions() {
|
||||||
const agents = await this.prisma.user.findMany({
|
const agents = await this.prisma.user.findMany({
|
||||||
where: { userType: 'AGENT', deletedAt: null, agentLevel: 1 },
|
where: { userType: 'AGENT', deletedAt: null },
|
||||||
select: { id: true, username: true },
|
select: {
|
||||||
orderBy: { username: 'asc' },
|
id: true,
|
||||||
|
username: true,
|
||||||
|
agentLevel: true,
|
||||||
|
parent: { select: { username: true } },
|
||||||
|
},
|
||||||
|
orderBy: [{ agentLevel: 'asc' }, { username: 'asc' }],
|
||||||
});
|
});
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
agents.map((a) => ({ id: a.id.toString(), username: a.username })),
|
agents.map((a) => ({
|
||||||
|
id: a.id.toString(),
|
||||||
|
username: a.username,
|
||||||
|
level: a.agentLevel ?? 1,
|
||||||
|
parentUsername: a.parent?.username ?? null,
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1055,12 +1062,17 @@ export class AdminController {
|
|||||||
@Query('page') page?: string,
|
@Query('page') page?: string,
|
||||||
@Query('pageSize') pageSize?: string,
|
@Query('pageSize') pageSize?: string,
|
||||||
@Query('keyword') keyword?: string,
|
@Query('keyword') keyword?: string,
|
||||||
|
@Query('status') status?: string,
|
||||||
|
@Query('level') level?: string,
|
||||||
@Query('parentAgentId') parentAgentId?: string,
|
@Query('parentAgentId') parentAgentId?: string,
|
||||||
) {
|
) {
|
||||||
|
const parsedLevel = level === '2' ? 2 : level === '1' ? 1 : undefined;
|
||||||
const result = await this.agents.listAgentsAdmin({
|
const result = await this.agents.listAgentsAdmin({
|
||||||
page: page ? parseInt(page, 10) : 1,
|
page: page ? parseInt(page, 10) : 1,
|
||||||
pageSize: pageSize ? parseInt(pageSize, 10) : 10,
|
pageSize: pageSize ? parseInt(pageSize, 10) : 10,
|
||||||
keyword,
|
keyword,
|
||||||
|
status,
|
||||||
|
level: parsedLevel,
|
||||||
parentAgentId: parentAgentId ? BigInt(parentAgentId) : undefined,
|
parentAgentId: parentAgentId ? BigInt(parentAgentId) : undefined,
|
||||||
});
|
});
|
||||||
return jsonResponse(result);
|
return jsonResponse(result);
|
||||||
@@ -1835,7 +1847,7 @@ export class AdminController {
|
|||||||
@RequirePermissions(P.content, P.matches)
|
@RequirePermissions(P.content, P.matches)
|
||||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } }))
|
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } }))
|
||||||
async uploadAsset(
|
async uploadAsset(
|
||||||
@CurrentUser() user: AdminUploadUser,
|
@CurrentUser() user: AdminUploadUser & { id?: bigint },
|
||||||
@UploadedFile() file: UploadedImage | undefined,
|
@UploadedFile() file: UploadedImage | undefined,
|
||||||
@Query('category') rawCategory?: string,
|
@Query('category') rawCategory?: string,
|
||||||
) {
|
) {
|
||||||
@@ -1849,13 +1861,104 @@ export class AdminController {
|
|||||||
await mkdir(targetDir, { recursive: true });
|
await mkdir(targetDir, { recursive: true });
|
||||||
await writeFile(join(targetDir, filename), file.buffer);
|
await writeFile(join(targetDir, filename), file.buffer);
|
||||||
|
|
||||||
return jsonResponse({
|
const url = `/uploads/${category}/${filename}`;
|
||||||
category,
|
await this.prisma.uploadedFile.create({
|
||||||
filename,
|
data: {
|
||||||
size: file.size,
|
filename,
|
||||||
mimeType: file.mimetype,
|
category,
|
||||||
url: `/uploads/${category}/${filename}`,
|
mimeType: file.mimetype,
|
||||||
|
size: file.size,
|
||||||
|
url,
|
||||||
|
uploadedBy: user.id ?? null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return jsonResponse({ category, filename, size: file.size, mimeType: file.mimetype, url });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('files')
|
||||||
|
@RequirePermissions(P.content, P.matches)
|
||||||
|
async listFiles(
|
||||||
|
@Query('category') category?: string,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('pageSize') pageSize?: string,
|
||||||
|
) {
|
||||||
|
const where = category && UPLOAD_CATEGORIES.includes(category as any) ? { category } : {};
|
||||||
|
const take = Math.min(parseInt(pageSize ?? '50', 10) || 50, 200);
|
||||||
|
const skip = (Math.max(parseInt(page ?? '1', 10) || 1, 1) - 1) * take;
|
||||||
|
|
||||||
|
const [files, total] = await Promise.all([
|
||||||
|
this.prisma.uploadedFile.findMany({ where, orderBy: { createdAt: 'desc' }, take, skip }),
|
||||||
|
this.prisma.uploadedFile.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const usedUrls = await this.getUsedFileUrls();
|
||||||
|
const items = files.map((f) => ({ ...f, inUse: usedUrls.has(f.url) }));
|
||||||
|
|
||||||
|
return jsonResponse({ items, total, page: skip / take + 1, pageSize: take });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('files/unused')
|
||||||
|
@RequirePermissions(P.content)
|
||||||
|
async purgeUnusedFiles(@CurrentUser('id') operatorId: bigint) {
|
||||||
|
const all = await this.prisma.uploadedFile.findMany();
|
||||||
|
const usedUrls = await this.getUsedFileUrls();
|
||||||
|
const unused = all.filter((f) => !usedUrls.has(f.url));
|
||||||
|
|
||||||
|
const root = getUploadRoot();
|
||||||
|
let deleted = 0;
|
||||||
|
for (const f of unused) {
|
||||||
|
try {
|
||||||
|
await unlink(join(root, f.category, f.filename));
|
||||||
|
} catch { /* file already missing from disk */ }
|
||||||
|
await this.prisma.uploadedFile.delete({ where: { id: f.id } });
|
||||||
|
deleted++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.audit.log({
|
||||||
|
operatorId,
|
||||||
|
operatorType: 'ADMIN',
|
||||||
|
action: 'PURGE_UNUSED_FILES',
|
||||||
|
module: 'MEDIA',
|
||||||
|
afterData: JSON.stringify({ deleted }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse({ deleted });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('files/:id')
|
||||||
|
@RequirePermissions(P.content, P.matches)
|
||||||
|
async deleteFile(
|
||||||
|
@CurrentUser() user: AdminUploadUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
const record = await this.prisma.uploadedFile.findUnique({ where: { id } });
|
||||||
|
if (!record) throw new BadRequestException('File not found');
|
||||||
|
assertUploadPermission(user, record.category as any);
|
||||||
|
|
||||||
|
const root = getUploadRoot();
|
||||||
|
try {
|
||||||
|
await unlink(join(root, record.category, record.filename));
|
||||||
|
} catch { /* already gone */ }
|
||||||
|
await this.prisma.uploadedFile.delete({ where: { id } });
|
||||||
|
|
||||||
|
return jsonResponse({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getUsedFileUrls(): Promise<Set<string>> {
|
||||||
|
const [ctRows, leagueRows, teamRows, prefRows] = await Promise.all([
|
||||||
|
this.prisma.contentTranslation.findMany({ select: { imageUrl: true } }),
|
||||||
|
this.prisma.league.findMany({ select: { logoUrl: true } }),
|
||||||
|
this.prisma.team.findMany({ select: { logoUrl: true } }),
|
||||||
|
this.prisma.userPreference.findMany({ select: { avatarKey: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const urls = new Set<string>();
|
||||||
|
for (const r of ctRows) if (r.imageUrl) urls.add(r.imageUrl);
|
||||||
|
for (const r of leagueRows) if (r.logoUrl) urls.add(r.logoUrl);
|
||||||
|
for (const r of teamRows) if (r.logoUrl) urls.add(r.logoUrl);
|
||||||
|
for (const r of prefRows) if (r.avatarKey) urls.add(r.avatarKey);
|
||||||
|
return urls;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('contents')
|
@Get('contents')
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { AuthService } from '../identity/auth.service';
|
|||||||
import { SystemConfigService } from '../../shared/config/system-config.service';
|
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||||||
import { Decimal } from '@prisma/client/runtime/library';
|
import { Decimal } from '@prisma/client/runtime/library';
|
||||||
import { generateBatchNo } from '../../shared/common/decorators';
|
import { generateBatchNo } from '../../shared/common/decorators';
|
||||||
|
import { assertPlayerUsername } from '@thebet365/shared';
|
||||||
|
|
||||||
function dec(v: Decimal | null | undefined) {
|
function dec(v: Decimal | null | undefined) {
|
||||||
return v?.toString() ?? '0';
|
return v?.toString() ?? '0';
|
||||||
@@ -475,6 +476,11 @@ export class AgentsService {
|
|||||||
if (data.username !== undefined) {
|
if (data.username !== undefined) {
|
||||||
const nextUsername = data.username.trim();
|
const nextUsername = data.username.trim();
|
||||||
if (!nextUsername) throw new BadRequestException('账号名称不能为空');
|
if (!nextUsername) throw new BadRequestException('账号名称不能为空');
|
||||||
|
try {
|
||||||
|
assertPlayerUsername(nextUsername);
|
||||||
|
} catch (e) {
|
||||||
|
throw new BadRequestException(e instanceof Error ? e.message : '玩家用户名格式无效');
|
||||||
|
}
|
||||||
if (nextUsername !== user.username) {
|
if (nextUsername !== user.username) {
|
||||||
const taken = await this.prisma.user.findUnique({ where: { username: nextUsername } });
|
const taken = await this.prisma.user.findUnique({ where: { username: nextUsername } });
|
||||||
if (taken) throw new BadRequestException('账号名称已被占用');
|
if (taken) throw new BadRequestException('账号名称已被占用');
|
||||||
@@ -531,6 +537,8 @@ export class AgentsService {
|
|||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
|
status?: string;
|
||||||
|
level?: 1 | 2;
|
||||||
parentAgentId?: bigint;
|
parentAgentId?: bigint;
|
||||||
}) {
|
}) {
|
||||||
const page = Math.max(1, params?.page ?? 1);
|
const page = Math.max(1, params?.page ?? 1);
|
||||||
@@ -538,15 +546,26 @@ export class AgentsService {
|
|||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
const where: Prisma.AgentProfileWhereInput = {};
|
const where: Prisma.AgentProfileWhereInput = {};
|
||||||
if (params?.parentAgentId !== undefined) {
|
if (params?.level === 2) {
|
||||||
|
where.level = 2;
|
||||||
|
} else if (params?.level === 1) {
|
||||||
|
where.level = 1;
|
||||||
|
} else if (params?.parentAgentId !== undefined) {
|
||||||
where.parentAgentId = params.parentAgentId;
|
where.parentAgentId = params.parentAgentId;
|
||||||
} else {
|
} else {
|
||||||
// Default: only show top-level agents (no parent)
|
|
||||||
where.parentAgentId = null;
|
where.parentAgentId = null;
|
||||||
}
|
}
|
||||||
const kw = params?.keyword?.trim();
|
const kw = params?.keyword?.trim();
|
||||||
|
const status = params?.status?.trim();
|
||||||
|
const userWhere: Prisma.UserWhereInput = {};
|
||||||
|
if (status && ['ACTIVE', 'SUSPENDED'].includes(status)) {
|
||||||
|
userWhere.status = status;
|
||||||
|
}
|
||||||
if (kw) {
|
if (kw) {
|
||||||
where.user = { username: { contains: kw, mode: 'insensitive' } };
|
userWhere.username = { contains: kw, mode: 'insensitive' };
|
||||||
|
}
|
||||||
|
if (Object.keys(userWhere).length > 0) {
|
||||||
|
where.user = userWhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [profiles, total] = await Promise.all([
|
const [profiles, total] = await Promise.all([
|
||||||
@@ -592,6 +611,18 @@ export class AgentsService {
|
|||||||
childAgentCounts.map((g) => [g.parentAgentId?.toString(), g._count._all]),
|
childAgentCounts.map((g) => [g.parentAgentId?.toString(), g._count._all]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const parentAgentIds = [
|
||||||
|
...new Set(profiles.map((p) => p.parentAgentId).filter((id): id is bigint => id != null)),
|
||||||
|
];
|
||||||
|
const parentUsers =
|
||||||
|
parentAgentIds.length > 0
|
||||||
|
? await this.prisma.user.findMany({
|
||||||
|
where: { id: { in: parentAgentIds } },
|
||||||
|
select: { id: true, username: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const parentUsernameMap = new Map(parentUsers.map((u) => [u.id.toString(), u.username]));
|
||||||
|
|
||||||
const items = profiles.map((p) => {
|
const items = profiles.map((p) => {
|
||||||
const available = new Decimal(p.creditLimit).sub(p.usedCredit);
|
const available = new Decimal(p.creditLimit).sub(p.usedCredit);
|
||||||
return {
|
return {
|
||||||
@@ -602,6 +633,9 @@ export class AgentsService {
|
|||||||
level: p.level,
|
level: p.level,
|
||||||
status: p.status,
|
status: p.status,
|
||||||
parentAgentId: p.parentAgentId?.toString() ?? null,
|
parentAgentId: p.parentAgentId?.toString() ?? null,
|
||||||
|
parentUsername: p.parentAgentId
|
||||||
|
? parentUsernameMap.get(p.parentAgentId.toString()) ?? null
|
||||||
|
: null,
|
||||||
creditLimit: p.creditLimit.toString(),
|
creditLimit: p.creditLimit.toString(),
|
||||||
usedCredit: p.usedCredit.toString(),
|
usedCredit: p.usedCredit.toString(),
|
||||||
availableCredit: available.toString(),
|
availableCredit: available.toString(),
|
||||||
@@ -1235,6 +1269,12 @@ export class AgentsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
assertPlayerUsername(data.username);
|
||||||
|
} catch (e) {
|
||||||
|
throw new BadRequestException(e instanceof Error ? e.message : '玩家用户名格式无效');
|
||||||
|
}
|
||||||
|
|
||||||
const hash = await this.auth.hashPassword(data.password);
|
const hash = await this.auth.hashPassword(data.password);
|
||||||
const locale = data.locale ?? 'zh-CN';
|
const locale = data.locale ?? 'zh-CN';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
import { SUPPORTED_LOCALES, isValidAvatarKey } from '@thebet365/shared';
|
import { SUPPORTED_LOCALES, isValidAvatarKey, assertPlayerUsername } from '@thebet365/shared';
|
||||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||||
import { SystemConfigService } from '../../shared/config/system-config.service';
|
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||||||
import { AgentsService } from '../agent/agents.service';
|
import { AgentsService } from '../agent/agents.service';
|
||||||
@@ -8,6 +8,7 @@ import { AgentsService } from '../agent/agents.service';
|
|||||||
export type PlayerListFilters = {
|
export type PlayerListFilters = {
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
parentId?: bigint;
|
parentId?: bigint;
|
||||||
|
platformDirect?: boolean;
|
||||||
status?: string;
|
status?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -19,6 +20,20 @@ export class UsersService {
|
|||||||
private systemConfig: SystemConfigService,
|
private systemConfig: SystemConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private buildAffiliationAgents(
|
||||||
|
parent?: {
|
||||||
|
username: string;
|
||||||
|
agentLevel: number | null;
|
||||||
|
parent?: { username: string; agentLevel: number | null } | null;
|
||||||
|
} | null,
|
||||||
|
): string[] {
|
||||||
|
if (!parent) return [];
|
||||||
|
if (parent.agentLevel === 2 && parent.parent?.username) {
|
||||||
|
return [parent.parent.username, parent.username];
|
||||||
|
}
|
||||||
|
return [parent.username];
|
||||||
|
}
|
||||||
|
|
||||||
private formatPlayerRow(
|
private formatPlayerRow(
|
||||||
u: {
|
u: {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
@@ -34,11 +49,16 @@ export class UsersService {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
managedPassword?: string | null;
|
managedPassword?: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
parent?: { username: string } | null;
|
parent?: {
|
||||||
|
username: string;
|
||||||
|
agentLevel: number | null;
|
||||||
|
parent?: { username: string; agentLevel: number | null } | null;
|
||||||
|
} | null;
|
||||||
auth?: { lastLoginAt: Date | null } | null;
|
auth?: { lastLoginAt: Date | null } | null;
|
||||||
},
|
},
|
||||||
bet?: { count: number; totalStake: string; totalReturn: string },
|
bet?: { count: number; totalStake: string; totalReturn: string },
|
||||||
) {
|
) {
|
||||||
|
const affiliationAgents = this.buildAffiliationAgents(u.parent);
|
||||||
return {
|
return {
|
||||||
id: u.id.toString(),
|
id: u.id.toString(),
|
||||||
username: u.username,
|
username: u.username,
|
||||||
@@ -46,6 +66,7 @@ export class UsersService {
|
|||||||
locale: u.locale,
|
locale: u.locale,
|
||||||
parentId: u.parentId?.toString() ?? null,
|
parentId: u.parentId?.toString() ?? null,
|
||||||
parentUsername: u.parent?.username ?? null,
|
parentUsername: u.parent?.username ?? null,
|
||||||
|
affiliationAgents,
|
||||||
phone: u.preferences?.phone ?? null,
|
phone: u.preferences?.phone ?? null,
|
||||||
email: u.preferences?.email ?? null,
|
email: u.preferences?.email ?? null,
|
||||||
managedPassword: u.preferences?.managedPassword ?? null,
|
managedPassword: u.preferences?.managedPassword ?? null,
|
||||||
@@ -102,6 +123,11 @@ export class UsersService {
|
|||||||
if (data.username !== undefined) {
|
if (data.username !== undefined) {
|
||||||
const nextUsername = data.username.trim();
|
const nextUsername = data.username.trim();
|
||||||
if (!nextUsername) throw new BadRequestException('账号名称不能为空');
|
if (!nextUsername) throw new BadRequestException('账号名称不能为空');
|
||||||
|
try {
|
||||||
|
assertPlayerUsername(nextUsername);
|
||||||
|
} catch (e) {
|
||||||
|
throw new BadRequestException(e instanceof Error ? e.message : '玩家用户名格式无效');
|
||||||
|
}
|
||||||
const settings = await this.systemConfig.getPlayerAccountSettings();
|
const settings = await this.systemConfig.getPlayerAccountSettings();
|
||||||
if (!settings.allowUsernameChange) {
|
if (!settings.allowUsernameChange) {
|
||||||
throw new ForbiddenException('当前平台未开放玩家自行修改账号名称');
|
throw new ForbiddenException('当前平台未开放玩家自行修改账号名称');
|
||||||
@@ -172,14 +198,18 @@ export class UsersService {
|
|||||||
const where: {
|
const where: {
|
||||||
userType: string;
|
userType: string;
|
||||||
deletedAt: null;
|
deletedAt: null;
|
||||||
parentId?: bigint;
|
parentId?: bigint | null;
|
||||||
status?: string;
|
status?: string;
|
||||||
OR?: { username?: { contains: string; mode: 'insensitive' } }[];
|
OR?: { username?: { contains: string; mode: 'insensitive' } }[];
|
||||||
} = {
|
} = {
|
||||||
userType: 'PLAYER',
|
userType: 'PLAYER',
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
};
|
};
|
||||||
if (filters.parentId) where.parentId = filters.parentId;
|
if (filters.platformDirect) {
|
||||||
|
where.parentId = null;
|
||||||
|
} else if (filters.parentId) {
|
||||||
|
where.parentId = filters.parentId;
|
||||||
|
}
|
||||||
if (filters.status) where.status = filters.status;
|
if (filters.status) where.status = filters.status;
|
||||||
if (filters.keyword?.trim()) {
|
if (filters.keyword?.trim()) {
|
||||||
const kw = filters.keyword.trim();
|
const kw = filters.keyword.trim();
|
||||||
@@ -193,7 +223,14 @@ export class UsersService {
|
|||||||
include: {
|
include: {
|
||||||
wallet: true,
|
wallet: true,
|
||||||
preferences: true,
|
preferences: true,
|
||||||
parent: { select: { id: true, username: true } },
|
parent: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
agentLevel: true,
|
||||||
|
parent: { select: { username: true, agentLevel: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
auth: { select: { lastLoginAt: true } },
|
auth: { select: { lastLoginAt: true } },
|
||||||
},
|
},
|
||||||
skip,
|
skip,
|
||||||
@@ -218,7 +255,14 @@ export class UsersService {
|
|||||||
include: {
|
include: {
|
||||||
wallet: true,
|
wallet: true,
|
||||||
preferences: true,
|
preferences: true,
|
||||||
parent: { select: { id: true, username: true, agentLevel: true } },
|
parent: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
agentLevel: true,
|
||||||
|
parent: { select: { username: true, agentLevel: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
auth: { select: { lastLoginAt: true, loginFailCount: true, lockedUntil: true } },
|
auth: { select: { lastLoginAt: true, loginFailCount: true, lockedUntil: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -250,7 +294,6 @@ export class UsersService {
|
|||||||
locale?: string;
|
locale?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
parentId?: string | null;
|
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
},
|
},
|
||||||
@@ -268,6 +311,11 @@ export class UsersService {
|
|||||||
if (data.username !== undefined) {
|
if (data.username !== undefined) {
|
||||||
const nextUsername = data.username.trim();
|
const nextUsername = data.username.trim();
|
||||||
if (!nextUsername) throw new BadRequestException('账号名称不能为空');
|
if (!nextUsername) throw new BadRequestException('账号名称不能为空');
|
||||||
|
try {
|
||||||
|
assertPlayerUsername(nextUsername);
|
||||||
|
} catch (e) {
|
||||||
|
throw new BadRequestException(e instanceof Error ? e.message : '玩家用户名格式无效');
|
||||||
|
}
|
||||||
if (nextUsername !== user.username) {
|
if (nextUsername !== user.username) {
|
||||||
const taken = await this.prisma.user.findUnique({ where: { username: nextUsername } });
|
const taken = await this.prisma.user.findUnique({ where: { username: nextUsername } });
|
||||||
if (taken) throw new BadRequestException('账号名称已被占用');
|
if (taken) throw new BadRequestException('账号名称已被占用');
|
||||||
@@ -301,39 +349,6 @@ export class UsersService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.parentId !== undefined) {
|
|
||||||
const newParentId =
|
|
||||||
data.parentId === null || data.parentId === ''
|
|
||||||
? null
|
|
||||||
: BigInt(data.parentId);
|
|
||||||
|
|
||||||
if (newParentId !== null) {
|
|
||||||
const parent = await this.prisma.user.findUnique({
|
|
||||||
where: { id: newParentId },
|
|
||||||
});
|
|
||||||
if (!parent || parent.userType !== 'AGENT') {
|
|
||||||
throw new BadRequestException('上级必须为代理账号');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldParentId = user.parentId;
|
|
||||||
const changed =
|
|
||||||
(oldParentId?.toString() ?? null) !== (newParentId?.toString() ?? null);
|
|
||||||
|
|
||||||
if (changed) {
|
|
||||||
await this.prisma.user.update({
|
|
||||||
where: { id: playerId },
|
|
||||||
data: { parentId: newParentId },
|
|
||||||
});
|
|
||||||
if (oldParentId) {
|
|
||||||
await this.agents.recalculateUsedCredit(oldParentId);
|
|
||||||
}
|
|
||||||
if (newParentId) {
|
|
||||||
await this.agents.recalculateUsedCredit(newParentId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.locale) {
|
if (data.locale) {
|
||||||
await this.prisma.user.update({
|
await this.prisma.user.update({
|
||||||
where: { id: playerId },
|
where: { id: playerId },
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ const subtitle = computed(() => {
|
|||||||
return props.bet.pickLabel || props.bet.leagueName || '';
|
return props.bet.pickLabel || props.bet.leagueName || '';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const stakeAmount = computed(() => formatMoney(props.bet.stake, locale.value));
|
||||||
|
|
||||||
const returnAmount = computed(() => {
|
const returnAmount = computed(() => {
|
||||||
if (statusKey.value === 'won') return formatMoney(props.bet.actualReturn, locale.value);
|
if (statusKey.value === 'won') return formatMoney(props.bet.actualReturn, locale.value);
|
||||||
if (statusKey.value === 'pending') return formatMoney(props.bet.potentialReturn, locale.value);
|
if (statusKey.value === 'pending') return formatMoney(props.bet.potentialReturn, locale.value);
|
||||||
@@ -94,6 +96,9 @@ function goDetail() {
|
|||||||
<span class="subtitle">{{ subtitle }} · {{ placedDate }}</span>
|
<span class="subtitle">{{ subtitle }} · {{ placedDate }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-right">
|
<div class="card-right">
|
||||||
|
<span class="stake-amt">
|
||||||
|
{{ t('history.stake') }} {{ stakeAmount }}
|
||||||
|
</span>
|
||||||
<span class="return-amt" :class="{ highlight: returnHighlight, pending: returnPending, lost: returnLost }">
|
<span class="return-amt" :class="{ highlight: returnHighlight, pending: returnPending, lost: returnLost }">
|
||||||
{{ returnAmount }}
|
{{ returnAmount }}
|
||||||
</span>
|
</span>
|
||||||
@@ -176,6 +181,12 @@ function goDetail() {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stake-amt {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.return-amt {
|
.return-amt {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
|
|||||||
@@ -18,51 +18,68 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{ bet: [id: string] }>();
|
const emit = defineEmits<{ bet: [id: string] }>();
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
|
|
||||||
function teamBgStyle(
|
function formatKickoff(startTime: string) {
|
||||||
code?: string,
|
const d = new Date(startTime);
|
||||||
name?: string,
|
const now = new Date();
|
||||||
logoUrl?: string | null,
|
const isToday =
|
||||||
) {
|
d.getFullYear() === now.getFullYear() &&
|
||||||
const url = teamFlagUrl(code, name, logoUrl);
|
d.getMonth() === now.getMonth() &&
|
||||||
if (!url) return {};
|
d.getDate() === now.getDate();
|
||||||
const isCustomLogo = Boolean(logoUrl?.trim());
|
const timeStr = d.toLocaleTimeString(locale.value, { hour: '2-digit', minute: '2-digit' });
|
||||||
return {
|
if (isToday) return `${t('bet.today')} ${timeStr}`;
|
||||||
backgroundImage: `url(${url})`,
|
const dateStr = d.toLocaleDateString(locale.value, { month: 'numeric', day: 'numeric' });
|
||||||
backgroundSize: isCustomLogo ? '36px auto' : '48px auto',
|
return `${dateStr} ${timeStr}`;
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const homeBgStyle = computed(() =>
|
const kickoffText = computed(() => formatKickoff(props.match.startTime));
|
||||||
teamBgStyle(
|
|
||||||
props.match.homeTeamCode,
|
const homeFlagUrl = computed(() =>
|
||||||
props.match.homeTeamName,
|
teamFlagUrl(props.match.homeTeamCode, props.match.homeTeamName, props.match.homeTeamLogoUrl),
|
||||||
props.match.homeTeamLogoUrl,
|
);
|
||||||
),
|
const awayFlagUrl = computed(() =>
|
||||||
|
teamFlagUrl(props.match.awayTeamCode, props.match.awayTeamName, props.match.awayTeamLogoUrl),
|
||||||
);
|
);
|
||||||
|
|
||||||
const awayBgStyle = computed(() =>
|
const homeIsLogo = computed(() => Boolean(props.match.homeTeamLogoUrl?.trim()));
|
||||||
teamBgStyle(
|
const awayIsLogo = computed(() => Boolean(props.match.awayTeamLogoUrl?.trim()));
|
||||||
props.match.awayTeamCode,
|
|
||||||
props.match.awayTeamName,
|
|
||||||
props.match.awayTeamLogoUrl,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<article class="match-card">
|
<article class="match-card">
|
||||||
<div class="bg-split" aria-hidden="true">
|
|
||||||
<div class="bg-half home-bg" :style="homeBgStyle" />
|
|
||||||
<div class="bg-half away-bg" :style="awayBgStyle" />
|
|
||||||
<div class="bg-veil" />
|
|
||||||
</div>
|
|
||||||
<div class="teams-row">
|
<div class="teams-row">
|
||||||
<span class="name home-name">{{ match.homeTeamName }}</span>
|
<!-- Home -->
|
||||||
<span class="vs">VS</span>
|
<div class="team">
|
||||||
<span class="name away-name">{{ match.awayTeamName }}</span>
|
<span class="team-name">{{ match.homeTeamName }}</span>
|
||||||
|
<img
|
||||||
|
v-if="homeFlagUrl"
|
||||||
|
:src="homeFlagUrl"
|
||||||
|
class="team-flag"
|
||||||
|
:class="{ 'flag-logo': homeIsLogo }"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Center -->
|
||||||
|
<div class="center-col">
|
||||||
|
<span class="kickoff">{{ kickoffText }}</span>
|
||||||
|
<span class="vs">VS</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Away -->
|
||||||
|
<div class="team">
|
||||||
|
<span class="team-name">{{ match.awayTeamName }}</span>
|
||||||
|
<img
|
||||||
|
v-if="awayFlagUrl"
|
||||||
|
:src="awayFlagUrl"
|
||||||
|
class="team-flag"
|
||||||
|
:class="{ 'flag-logo': awayIsLogo }"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" class="bet-btn btn-gold-outline" @click="emit('bet', match.id)">
|
<button type="button" class="bet-btn btn-gold-outline" @click="emit('bet', match.id)">
|
||||||
{{ t('bet.place_bet_short') }}
|
{{ t('bet.place_bet_short') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -71,105 +88,98 @@ const awayBgStyle = computed(() =>
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.match-card {
|
.match-card {
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #0d0d0d;
|
background: #0d0d0d;
|
||||||
border: 1px solid rgba(255, 215, 0, 0.25);
|
border: 1px solid rgba(255, 215, 0, 0.25);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 8px 10px 10px;
|
padding: 10px 8px 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team {
|
||||||
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-split {
|
.team-name {
|
||||||
position: absolute;
|
flex: 1;
|
||||||
inset: 0;
|
display: flex;
|
||||||
z-index: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-half {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
opacity: 0.48;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-bg {
|
|
||||||
clip-path: polygon(0 0, 54% 0, 46% 100%, 0 100%);
|
|
||||||
background-position: 22% 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.away-bg {
|
|
||||||
clip-path: polygon(54% 0, 100% 0, 100% 100%, 46% 100%);
|
|
||||||
background-position: 78% 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-veil {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: linear-gradient(
|
|
||||||
102deg,
|
|
||||||
rgba(0, 0, 0, 0.52) 0%,
|
|
||||||
rgba(0, 0, 0, 0.28) 46%,
|
|
||||||
rgba(0, 0, 0, 0.28) 54%,
|
|
||||||
rgba(0, 0, 0, 0.52) 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.teams-row {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
width: 100%;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr auto 1fr;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
justify-content: center;
|
||||||
min-height: 32px;
|
font-size: 17px;
|
||||||
}
|
|
||||||
|
|
||||||
.name {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
color: var(--primary-light);
|
color: var(--primary-light);
|
||||||
line-height: 1.25;
|
line-height: 1.2;
|
||||||
text-shadow: 0 1px 5px rgba(0, 0, 0, 0.9);
|
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.95);
|
||||||
word-break: keep-all;
|
word-break: break-word;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0 6px;
|
||||||
|
align-self: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-name {
|
.team-flag {
|
||||||
text-align: left;
|
width: 72px;
|
||||||
padding-left: 2px;
|
height: 48px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 3px;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.away-name {
|
.team-flag.flag-logo {
|
||||||
text-align: right;
|
width: 48px;
|
||||||
padding-right: 2px;
|
height: 48px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-col {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kickoff {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #b0a060;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.vs {
|
.vs {
|
||||||
flex-shrink: 0;
|
font-size: 22px;
|
||||||
font-size: 10px;
|
font-weight: 900;
|
||||||
font-weight: 800;
|
color: #fff;
|
||||||
color: var(--text-muted);
|
letter-spacing: 0.08em;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
letter-spacing: 0.06em;
|
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.9);
|
||||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bet-btn {
|
.bet-btn {
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
width: auto;
|
width: auto;
|
||||||
min-width: 72px;
|
min-width: 80px;
|
||||||
max-width: 42%;
|
padding: 5px 24px;
|
||||||
margin-top: 0;
|
|
||||||
padding: 5px 18px;
|
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { useBetSlipStore } from '../../stores/betSlip';
|
import { useBetSlipStore } from '../../stores/betSlip';
|
||||||
import { PARLAY_MAX_LEGS, canSelectForParlay } from '@thebet365/shared';
|
import { PARLAY_MAX_LEGS, canSelectForParlay } from '@thebet365/shared';
|
||||||
import { PARLAY_MARKET_TYPES, PARLAY_SELECTION_KEYS } from '../../utils/parlayColumns';
|
import { PARLAY_MARKET_TYPES, PARLAY_SELECTION_KEYS, PARLAY_MARKET_GROUPS } from '../../utils/parlayColumns';
|
||||||
import BetGuideHelp from '../BetGuideHelp.vue';
|
import BetGuideHelp from '../BetGuideHelp.vue';
|
||||||
import GoldSpinner from '../GoldSpinner.vue';
|
import GoldSpinner from '../GoldSpinner.vue';
|
||||||
import TeamEmblem from '../TeamEmblem.vue';
|
import TeamEmblem from '../TeamEmblem.vue';
|
||||||
@@ -282,51 +282,68 @@ function toggleCollapse(id: string) {
|
|||||||
<div v-else-if="filteredMatches.length" class="match-list">
|
<div v-else-if="filteredMatches.length" class="match-list">
|
||||||
<div v-for="match in filteredMatches" :key="match.id" class="match-card" :class="{ collapsed: collapsed.has(match.id) }">
|
<div v-for="match in filteredMatches" :key="match.id" class="match-card" :class="{ collapsed: collapsed.has(match.id) }">
|
||||||
<button type="button" class="match-head" @click="toggleCollapse(match.id)">
|
<button type="button" class="match-head" @click="toggleCollapse(match.id)">
|
||||||
<span class="m-league">{{ match.leagueName }}</span>
|
<div class="match-head-top">
|
||||||
<TeamEmblem
|
<span class="m-league">{{ match.leagueName }}</span>
|
||||||
size="sm"
|
<span class="toggle-dot" :class="{ open: !collapsed.has(match.id) }">
|
||||||
:team-code="match.homeTeamCode"
|
{{ collapsed.has(match.id) ? '+' : '−' }}
|
||||||
:team-name="match.homeTeamName"
|
</span>
|
||||||
:logo-url="match.homeTeamLogoUrl"
|
</div>
|
||||||
/>
|
<div class="match-head-teams">
|
||||||
<span class="m-teams">{{ match.homeTeamName }} vs {{ match.awayTeamName }}</span>
|
<div class="m-team home">
|
||||||
<TeamEmblem
|
<TeamEmblem
|
||||||
size="sm"
|
size="sm"
|
||||||
:team-code="match.awayTeamCode"
|
:team-code="match.homeTeamCode"
|
||||||
:team-name="match.awayTeamName"
|
:team-name="match.homeTeamName"
|
||||||
:logo-url="match.awayTeamLogoUrl"
|
:logo-url="match.homeTeamLogoUrl"
|
||||||
/>
|
/>
|
||||||
<span class="m-time">{{ formatKickoff(match.startTime) }}</span>
|
<span class="m-name">{{ match.homeTeamName }}</span>
|
||||||
<span class="toggle-dot" :class="{ open: !collapsed.has(match.id) }">{{ collapsed.has(match.id) ? '+' : '−' }}</span>
|
</div>
|
||||||
|
<div class="m-center">
|
||||||
|
<span class="m-time">{{ formatKickoff(match.startTime) }}</span>
|
||||||
|
<span class="m-vs">VS</span>
|
||||||
|
</div>
|
||||||
|
<div class="m-team away">
|
||||||
|
<span class="m-name">{{ match.awayTeamName }}</span>
|
||||||
|
<TeamEmblem
|
||||||
|
size="sm"
|
||||||
|
:team-code="match.awayTeamCode"
|
||||||
|
:team-name="match.awayTeamName"
|
||||||
|
:logo-url="match.awayTeamLogoUrl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div v-show="!collapsed.has(match.id)" class="market-blocks">
|
<div v-show="!collapsed.has(match.id)" class="market-blocks">
|
||||||
<div
|
<div v-for="group in PARLAY_MARKET_GROUPS" :key="group.headerKey" class="market-group">
|
||||||
v-for="col in PARLAY_MARKET_TYPES"
|
<div class="group-header">{{ colLabel(group.headerKey) }}</div>
|
||||||
:key="col.key"
|
<div
|
||||||
class="market-block"
|
v-for="col in group.columns"
|
||||||
>
|
:key="col.key"
|
||||||
<span class="block-label">{{ colLabel(col.labelKey) }}</span>
|
class="market-row"
|
||||||
<div class="block-btns">
|
>
|
||||||
<template
|
<span class="block-label">{{ colLabel(col.labelKey) }}</span>
|
||||||
v-if="
|
<div class="block-btns">
|
||||||
getMarket(match, col.key) &&
|
<template
|
||||||
isParlayEligibleMarket(getMarket(match, col.key)!)
|
v-if="
|
||||||
"
|
getMarket(match, col.key) &&
|
||||||
>
|
isParlayEligibleMarket(getMarket(match, col.key)!)
|
||||||
<button
|
"
|
||||||
v-for="sel in getMarket(match, col.key)!.selections"
|
|
||||||
:key="sel.id"
|
|
||||||
type="button"
|
|
||||||
class="odd-btn"
|
|
||||||
:class="{ picked: isPicked(sel.id) }"
|
|
||||||
@click="pickSelection(match, getMarket(match, col.key)!, sel)"
|
|
||||||
>
|
>
|
||||||
<span class="odd-label">{{ selLabel(sel) }}</span>
|
<button
|
||||||
<span class="odd-val">{{ formatOdds(sel.odds) }}</span>
|
v-for="sel in getMarket(match, col.key)!.selections"
|
||||||
</button>
|
:key="sel.id"
|
||||||
</template>
|
type="button"
|
||||||
<span v-else class="market-empty">—</span>
|
class="odd-btn"
|
||||||
|
:class="{ picked: isPicked(sel.id) }"
|
||||||
|
@click="pickSelection(match, getMarket(match, col.key)!, sel)"
|
||||||
|
>
|
||||||
|
<span class="odd-label">{{ selLabel(sel) }}</span>
|
||||||
|
<span class="odd-val">{{ formatOdds(sel.odds) }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<span v-else class="market-empty">—</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -452,20 +469,79 @@ function toggleCollapse(id: string) {
|
|||||||
.match-head {
|
.match-head {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 6px;
|
align-items: stretch;
|
||||||
padding: 10px;
|
gap: 4px;
|
||||||
|
padding: 8px 10px;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.match-head:active {
|
.match-head:active {
|
||||||
background: rgba(255, 255, 255, 0.015);
|
background: rgba(255, 255, 255, 0.015);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.match-head-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-head-teams {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-team {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-team.away {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-name {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--primary-light);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-vs {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #555;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-time {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.toggle-dot {
|
.toggle-dot {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
@@ -492,26 +568,12 @@ function toggleCollapse(id: string) {
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-teams {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--primary-light);
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-time {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.match-head :deep(.team-emblem) {
|
.match-head :deep(.team-emblem) {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
@@ -520,12 +582,29 @@ function toggleCollapse(id: string) {
|
|||||||
|
|
||||||
.market-blocks {
|
.market-blocks {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
padding: 8px 10px 10px;
|
padding: 8px 10px 10px;
|
||||||
|
border-top: 1px solid #1e1e1e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.market-block {
|
.market-group {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 4px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-header {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #888;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 0 2px 2px;
|
||||||
|
border-bottom: 1px solid #1e1e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
@@ -538,6 +617,9 @@ function toggleCollapse(id: string) {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
padding: 0 2px;
|
padding: 0 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-btns {
|
.block-btns {
|
||||||
@@ -583,7 +665,6 @@ function toggleCollapse(id: string) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 44px;
|
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
color: #444;
|
color: #444;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ const i18n = createI18n({
|
|||||||
tab_parlay: '串关投注',
|
tab_parlay: '串关投注',
|
||||||
tab_today: '今日',
|
tab_today: '今日',
|
||||||
tab_early: '早盘',
|
tab_early: '早盘',
|
||||||
|
today: '今日',
|
||||||
loading: '加载中…',
|
loading: '加载中…',
|
||||||
no_matches: '暂无赛事',
|
no_matches: '暂无赛事',
|
||||||
outright_coming: '优胜冠军玩法即将上线',
|
outright_coming: '优胜冠军玩法即将上线',
|
||||||
@@ -207,6 +208,10 @@ const i18n = createI18n({
|
|||||||
market_ht_handicap: '半场 让球',
|
market_ht_handicap: '半场 让球',
|
||||||
market_ht_ou: '半场 大小',
|
market_ht_ou: '半场 大小',
|
||||||
market_ht_1x2: '半场 独赢盘',
|
market_ht_1x2: '半场 独赢盘',
|
||||||
|
parlay_lbl_handicap: '让球',
|
||||||
|
parlay_lbl_ou: '大小',
|
||||||
|
parlay_lbl_1x2: '独赢盘',
|
||||||
|
parlay_lbl_oe: '单/双',
|
||||||
parlay_sel_home: '主',
|
parlay_sel_home: '主',
|
||||||
parlay_sel_away: '客',
|
parlay_sel_away: '客',
|
||||||
parlay_sel_draw: '和',
|
parlay_sel_draw: '和',
|
||||||
@@ -450,6 +455,7 @@ const i18n = createI18n({
|
|||||||
tab_parlay: 'Parlay',
|
tab_parlay: 'Parlay',
|
||||||
tab_today: 'Today',
|
tab_today: 'Today',
|
||||||
tab_early: 'Early',
|
tab_early: 'Early',
|
||||||
|
today: 'Today',
|
||||||
loading: 'Loading…',
|
loading: 'Loading…',
|
||||||
no_matches: 'No matches',
|
no_matches: 'No matches',
|
||||||
outright_coming: 'Outright markets coming soon',
|
outright_coming: 'Outright markets coming soon',
|
||||||
@@ -508,6 +514,10 @@ const i18n = createI18n({
|
|||||||
market_ht_handicap: 'HT Handicap',
|
market_ht_handicap: 'HT Handicap',
|
||||||
market_ht_ou: 'HT O/U',
|
market_ht_ou: 'HT O/U',
|
||||||
market_ht_1x2: 'HT 1X2',
|
market_ht_1x2: 'HT 1X2',
|
||||||
|
parlay_lbl_handicap: 'Handicap',
|
||||||
|
parlay_lbl_ou: 'O/U',
|
||||||
|
parlay_lbl_1x2: '1X2',
|
||||||
|
parlay_lbl_oe: 'Odd/Even',
|
||||||
parlay_sel_home: 'H',
|
parlay_sel_home: 'H',
|
||||||
parlay_sel_away: 'A',
|
parlay_sel_away: 'A',
|
||||||
parlay_sel_draw: 'D',
|
parlay_sel_draw: 'D',
|
||||||
@@ -757,6 +767,7 @@ const i18n = createI18n({
|
|||||||
tab_parlay: 'Berganda',
|
tab_parlay: 'Berganda',
|
||||||
tab_today: 'Hari Ini',
|
tab_today: 'Hari Ini',
|
||||||
tab_early: 'Awal',
|
tab_early: 'Awal',
|
||||||
|
today: 'Hari Ini',
|
||||||
loading: 'Memuatkan…',
|
loading: 'Memuatkan…',
|
||||||
no_matches: 'Tiada perlawanan',
|
no_matches: 'Tiada perlawanan',
|
||||||
outright_coming: 'Pasaran juara akan datang',
|
outright_coming: 'Pasaran juara akan datang',
|
||||||
@@ -815,6 +826,10 @@ const i18n = createI18n({
|
|||||||
market_ht_handicap: 'Handicap Separuh',
|
market_ht_handicap: 'Handicap Separuh',
|
||||||
market_ht_ou: 'Atas/Bawah Separuh',
|
market_ht_ou: 'Atas/Bawah Separuh',
|
||||||
market_ht_1x2: '1X2 Separuh',
|
market_ht_1x2: '1X2 Separuh',
|
||||||
|
parlay_lbl_handicap: 'Handicap',
|
||||||
|
parlay_lbl_ou: 'Atas/Bawah',
|
||||||
|
parlay_lbl_1x2: '1X2',
|
||||||
|
parlay_lbl_oe: 'Ganjil/Genap',
|
||||||
parlay_sel_home: 'R',
|
parlay_sel_home: 'R',
|
||||||
parlay_sel_away: 'P',
|
parlay_sel_away: 'P',
|
||||||
parlay_sel_draw: 'S',
|
parlay_sel_draw: 'S',
|
||||||
|
|||||||
@@ -11,6 +11,27 @@ export const PARLAY_MARKET_TYPES = [
|
|||||||
|
|
||||||
export type ParlayMarketType = (typeof PARLAY_MARKET_TYPES)[number]['key'];
|
export type ParlayMarketType = (typeof PARLAY_MARKET_TYPES)[number]['key'];
|
||||||
|
|
||||||
|
/** 按全场 / 半场分组(headerKey 对应 bet.ft / bet.ht) */
|
||||||
|
export const PARLAY_MARKET_GROUPS = [
|
||||||
|
{
|
||||||
|
headerKey: 'ft',
|
||||||
|
columns: [
|
||||||
|
{ key: 'FT_HANDICAP', labelKey: 'parlay_lbl_handicap' },
|
||||||
|
{ key: 'FT_OVER_UNDER', labelKey: 'parlay_lbl_ou' },
|
||||||
|
{ key: 'FT_1X2', labelKey: 'parlay_lbl_1x2' },
|
||||||
|
{ key: 'FT_ODD_EVEN', labelKey: 'parlay_lbl_oe' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headerKey: 'ht',
|
||||||
|
columns: [
|
||||||
|
{ key: 'HT_HANDICAP', labelKey: 'parlay_lbl_handicap' },
|
||||||
|
{ key: 'HT_OVER_UNDER', labelKey: 'parlay_lbl_ou' },
|
||||||
|
{ key: 'HT_1X2', labelKey: 'parlay_lbl_1x2' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
/** 选项简称 i18n key(串关格内展示) */
|
/** 选项简称 i18n key(串关格内展示) */
|
||||||
export const PARLAY_SELECTION_KEYS: Record<string, string> = {
|
export const PARLAY_SELECTION_KEYS: Record<string, string> = {
|
||||||
HOME: 'parlay_sel_home',
|
HOME: 'parlay_sel_home',
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export * from './betting-rules';
|
|||||||
export * from './locale';
|
export * from './locale';
|
||||||
export * from './builtinPlayers';
|
export * from './builtinPlayers';
|
||||||
export * from './playerLocale';
|
export * from './playerLocale';
|
||||||
|
export * from './playerUsername';
|
||||||
|
|
||||||
export interface ApiResponse<T = unknown> {
|
export interface ApiResponse<T = unknown> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|||||||
19
packages/shared/src/playerUsername.ts
Normal file
19
packages/shared/src/playerUsername.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/** 玩家用户名:仅英文字母与数字,3–32 位 */
|
||||||
|
export const PLAYER_USERNAME_PATTERN = /^[a-zA-Z0-9]{3,32}$/;
|
||||||
|
|
||||||
|
export const PLAYER_USERNAME_RULE_MESSAGE =
|
||||||
|
'玩家用户名仅可使用英文字母和数字(3–32 位),不可含中文或特殊符号';
|
||||||
|
|
||||||
|
export function isValidPlayerUsername(username: string): boolean {
|
||||||
|
return PLAYER_USERNAME_PATTERN.test(username.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertPlayerUsername(username: string): void {
|
||||||
|
const trimmed = username.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error('玩家用户名不能为空');
|
||||||
|
}
|
||||||
|
if (!isValidPlayerUsername(trimmed)) {
|
||||||
|
throw new Error(PLAYER_USERNAME_RULE_MESSAGE);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user