diff --git a/apps/admin/src/i18n/admin-pages-ms.ts b/apps/admin/src/i18n/admin-pages-ms.ts index a90e235..fc3066e 100644 --- a/apps/admin/src/i18n/admin-pages-ms.ts +++ b/apps/admin/src/i18n/admin-pages-ms.ts @@ -51,6 +51,22 @@ export const adminPagesMs: Record = { 'user.field.login_fail': 'Log masuk gagal', 'user.field.phone': 'Telefon', 'user.field.email': 'E-mel', + 'user.field.allow_password_change': 'Benarkan pemain tukar kata laluan', + 'user.field.allow_username_change': 'Benarkan pemain tukar nama akaun', + 'user.field.view_password': 'Kata laluan log masuk', + 'user.field.reset_password': 'Set semula kata laluan', + 'user.password_not_stored': 'Tiada rekod (pemain telah ubah sendiri)', + 'user.btn.show_password': 'Lihat', + 'user.btn.hide_password': 'Sembunyi', + 'user.ph.reset_password': 'Biarkan kosong untuk kekalkan; nilai baharu boleh dilihat', + 'user.ph.reset_password_short': 'Biarkan kosong', + 'user.global_settings': 'Kata laluan & akaun (global)', + 'user.global_settings_hint': 'Kawal sama ada semua pemain boleh ubah kata laluan/nama akaun dalam app', + 'user.section.password_mgmt': 'Pengurusan kata laluan', + 'user.field.current_password': 'Kata laluan semasa', + 'user.msg.created_with_password': 'Pemain dicipta. Kata laluan: {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.ph.username_unique': 'Nama log masuk unik', 'user.ph.no_agent': 'Tiada (terus platform)', 'user.hint.no_agent': 'Biarkan kosong untuk pemain diurus platform', @@ -58,6 +74,10 @@ export const adminPagesMs: Record = { 'user.hint.deposit_remark': 'Ditulis ke lejar jika baki permulaan > 0', 'user.hint.freeze_in_list': 'Beku/nyahbeku dari lajur tindakan senarai', 'user.hint.agent_change': 'Kosong = terus platform; perubahan dikira semula kredit ejen', + 'user.hint.allow_password_change': 'Matikan: semua pemain tidak boleh ubah kata laluan', + 'user.hint.allow_username_change': 'Hidupkan: semua pemain boleh ubah nama log masuk', + 'user.hint.view_password': 'Hanya kata laluan cipta/set semula admin; dibersihkan jika pemain ubah sendiri', + 'user.hint.reset_password': 'Berkuat kuasa serta-merta dan kemas kini kata laluan boleh lihat', 'user.btn.create': 'Cipta', 'user.btn.save_profile': 'Simpan', 'user.btn.confirm_deposit': 'Sahkan tambah baki', @@ -222,6 +242,9 @@ export const adminPagesMs: Record = { 'agent_portal.agent_username_ph': 'Nama pengguna ejen', 'agent_portal.player_id_ph': 'ID pemain', 'agent_portal.withdraw_btn': 'Keluarkan {amount}', + 'agent_portal.withdraw_btn_label': 'Keluarkan', + 'agent_portal.transfer_title_deposit': 'Tambah baki {name}', + 'agent_portal.transfer_title_withdraw': 'Keluarkan dari {name}', 'msg.agent_sub_created': 'Sub-ejen dicipta', 'msg.withdraw_ok': 'Pengeluaran berjaya', @@ -241,6 +264,7 @@ export const adminPagesMs: Record = { 'msg.import_done': 'Import: {imported} ok, {skipped} dilangkau, {failed} gagal / {total} jumlah', 'msg.topup_ok': 'Tambah baki berjaya', 'msg.topup_failed': 'Tambah baki gagal', + 'msg.transfer_failed': 'Operasi gagal', 'msg.amount_gt_zero': 'Jumlah mesti lebih daripada 0', 'msg.credit_zero': 'Pelarasan tidak boleh 0', 'msg.credit_adjusted': 'Kredit dikemas kini', diff --git a/apps/admin/src/i18n/admin-pages.ts b/apps/admin/src/i18n/admin-pages.ts index 90a7057..d56e438 100644 --- a/apps/admin/src/i18n/admin-pages.ts +++ b/apps/admin/src/i18n/admin-pages.ts @@ -51,6 +51,22 @@ export const adminPagesZh: Record = { 'user.field.login_fail': '登录失败', 'user.field.phone': '手机', 'user.field.email': '邮箱', + 'user.field.allow_password_change': '允许玩家改密码', + 'user.field.allow_username_change': '允许玩家改账号名', + 'user.field.view_password': '登录密码', + 'user.field.reset_password': '重置密码', + 'user.password_not_stored': '未记录(玩家已自行修改或未保存)', + 'user.btn.show_password': '查看', + 'user.btn.hide_password': '隐藏', + 'user.ph.reset_password': '留空则不修改;填写后将更新并可查看', + 'user.ph.reset_password_short': '留空不修改', + 'user.global_settings': '密码与账号管理(全局)', + 'user.global_settings_hint': '控制所有玩家是否可在 App 内改密码、改账号名', + 'user.section.password_mgmt': '密码管理', + 'user.field.current_password': '当前密码', + 'user.msg.created_with_password': '玩家已创建,登录密码:{password}', + 'user.msg.password_saved': '密码已更新,当前可查密码:{password}', + 'user.hint.password_reset_to_view': '旧账号暂无记录。请在下方「重置密码」填写新密码并保存,即可在此查看。', 'user.ph.username_unique': '登录用户名,唯一', 'user.ph.no_agent': '不设置(平台直属玩家)', 'user.hint.no_agent': '留空表示不挂靠代理,由平台直接管理', @@ -58,6 +74,10 @@ export const adminPagesZh: Record = { 'user.hint.deposit_remark': '有初始余额时写入流水备注', 'user.hint.freeze_in_list': '冻结/解冻请在列表操作列进行', 'user.hint.agent_change': '留空表示平台直属;变更后会重算相关代理已用授信', + 'user.hint.allow_password_change': '关闭后所有玩家均不可在客户端修改密码', + 'user.hint.allow_username_change': '开启后所有玩家均可在资料页修改登录账号名', + 'user.hint.view_password': '仅保存后台创建或重置时的密码;玩家自行改密后会清除', + 'user.hint.reset_password': '重置后立即生效,并更新上方可查密码', 'user.btn.create': '创建', 'user.btn.save_profile': '保存资料', 'user.btn.confirm_deposit': '确认上分', @@ -222,6 +242,9 @@ export const adminPagesZh: Record = { 'agent_portal.agent_username_ph': '代理用户名', 'agent_portal.player_id_ph': '玩家 ID', 'agent_portal.withdraw_btn': '下分 {amount}', + 'agent_portal.withdraw_btn_label': '下分', + 'agent_portal.transfer_title_deposit': '给 {name} 上分', + 'agent_portal.transfer_title_withdraw': '从 {name} 下分', 'msg.agent_sub_created': '下级代理已创建', 'msg.withdraw_ok': '下分成功', @@ -241,6 +264,7 @@ export const adminPagesZh: Record = { 'msg.import_done': '导入完成:成功 {imported},跳过 {skipped},失败 {failed} / 共 {total}', 'msg.topup_ok': '上分成功', 'msg.topup_failed': '上分失败', + 'msg.transfer_failed': '操作失败', 'msg.amount_gt_zero': '金额须大于 0', 'msg.credit_zero': '调整金额不能为 0', 'msg.credit_adjusted': '授信已调整', @@ -403,6 +427,22 @@ export const adminPagesEn: Record = { 'user.field.login_fail': 'Failed logins', 'user.field.phone': 'Phone', 'user.field.email': 'Email', + 'user.field.allow_password_change': 'Allow player password change', + 'user.field.allow_username_change': 'Allow player username change', + 'user.field.view_password': 'Login password', + 'user.field.reset_password': 'Reset password', + 'user.password_not_stored': 'Not stored (player changed it or never saved)', + 'user.btn.show_password': 'Show', + 'user.btn.hide_password': 'Hide', + 'user.ph.reset_password': 'Leave empty to keep; new value will be viewable', + 'user.ph.reset_password_short': 'Leave empty to keep', + 'user.global_settings': 'Password & account (global)', + 'user.global_settings_hint': 'Controls whether all players can change password or username in the app', + 'user.section.password_mgmt': 'Password management', + 'user.field.current_password': 'Current password', + 'user.msg.created_with_password': 'Player created. Login 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.ph.username_unique': 'Unique login username', 'user.ph.no_agent': 'None (platform direct)', 'user.hint.no_agent': 'Leave empty for platform-managed player', @@ -410,6 +450,10 @@ export const adminPagesEn: Record = { 'user.hint.deposit_remark': 'Written to ledger when initial balance > 0', 'user.hint.freeze_in_list': 'Freeze/unfreeze from the list actions', 'user.hint.agent_change': 'Empty = platform direct; changes recalc agent credit', + 'user.hint.allow_password_change': 'When off, no player can change password in the app', + 'user.hint.allow_username_change': 'When on, all players can change login username in profile', + 'user.hint.view_password': 'Only passwords set on create/reset; cleared after player self-change', + 'user.hint.reset_password': 'Takes effect immediately and updates viewable password above', 'user.btn.create': 'Create', 'user.btn.save_profile': 'Save', 'user.btn.confirm_deposit': 'Confirm top-up', @@ -574,6 +618,9 @@ export const adminPagesEn: Record = { 'agent_portal.agent_username_ph': 'Agent username', 'agent_portal.player_id_ph': 'Player ID', 'agent_portal.withdraw_btn': 'Withdraw {amount}', + 'agent_portal.withdraw_btn_label': 'Withdraw', + 'agent_portal.transfer_title_deposit': 'Top up {name}', + 'agent_portal.transfer_title_withdraw': 'Withdraw from {name}', 'msg.agent_sub_created': 'Sub-agent created', 'msg.withdraw_ok': 'Withdrawal successful', @@ -593,6 +640,7 @@ export const adminPagesEn: Record = { 'msg.import_done': 'Import: {imported} ok, {skipped} skipped, {failed} failed / {total} total', 'msg.topup_ok': 'Top-up successful', 'msg.topup_failed': 'Top-up failed', + 'msg.transfer_failed': 'Operation failed', 'msg.amount_gt_zero': 'Amount must be greater than 0', 'msg.credit_zero': 'Adjustment cannot be 0', 'msg.credit_adjusted': 'Credit updated', diff --git a/apps/admin/src/views/Users.vue b/apps/admin/src/views/Users.vue index bff7ee2..0ae57ec 100644 --- a/apps/admin/src/views/Users.vue +++ b/apps/admin/src/views/Users.vue @@ -46,12 +46,39 @@ const detail = ref(null); const editingId = ref(''); const depositForm = ref({ userId: '', amount: 100, remark: '' }); +const playerSettings = ref({ allowPasswordChange: true, allowUsernameChange: false }); +const settingsSaving = ref(false); onMounted(() => { loadAgentOptions(); + loadPlayerSettings(); load(); }); +async function loadPlayerSettings() { + try { + const { data } = await api.get('/admin/users/settings/account'); + playerSettings.value = data.data; + } catch { + /* 使用默认值 */ + } +} + +async function savePlayerSettings() { + settingsSaving.value = true; + try { + const { data } = await api.put('/admin/users/settings/account', playerSettings.value); + playerSettings.value = data.data; + ElMessage.success(t('msg.saved')); + } catch (e: unknown) { + const err = e as { response?: { data?: { error?: string } } }; + ElMessage.error(err.response?.data?.error ?? t('msg.save_failed')); + loadPlayerSettings(); + } finally { + settingsSaving.value = false; + } +} + async function loadAgentOptions() { const { data } = await api.get('/admin/agents/options'); agentOptions.value = data.data; @@ -122,7 +149,9 @@ async function submitCreate() { try { await api.post('/admin/users', payload); ElMessage.success( - createForm.value.asTier1Agent ? t('msg.agent_created') : t('msg.player_created'), + createForm.value.asTier1Agent + ? t('msg.agent_created') + : t('user.msg.created_with_password', { password: createForm.value.password }), ); createVisible.value = false; load(); @@ -163,13 +192,27 @@ async function toggleFreeze(row: PlayerRow) { } async function submitEdit() { + if (editForm.value.newPassword && editForm.value.newPassword.length < 8) { + ElMessage.warning(t('err.password_min')); + return; + } editLoading.value = true; try { - await api.put(`/admin/users/${editingId.value}`, { + const newPwd = editForm.value.newPassword.trim(); + const { data } = await api.put(`/admin/users/${editingId.value}`, { + username: editForm.value.username.trim(), parentId: editForm.value.parentId || '', phone: editForm.value.phone.trim() || undefined, email: editForm.value.email.trim() || undefined, + password: newPwd || undefined, }); + const updated = data.data as PlayerDetail; + if (newPwd) { + editForm.value.managedPassword = updated.managedPassword ?? newPwd; + editForm.value.newPassword = ''; + ElMessage.success(t('user.msg.password_saved', { password: editForm.value.managedPassword })); + return; + } ElMessage.success(t('msg.saved')); editVisible.value = false; load(); @@ -246,6 +289,29 @@ function statusLabel(s: string) { {{ t('user.create_btn') }} + +
+ {{ t('user.global_settings') }} + {{ t('user.global_settings_hint') }} + + + + + + + + +
+
+ @@ -455,20 +521,42 @@ function statusLabel(s: string) { - - - - - + + +
+ ID {{ editForm.id }} + {{ statusLabel(editForm.status) }} +
+ - - - - - {{ statusLabel(editForm.status) }} - - {{ t('user.hint.freeze_in_list') }} + + +
+
{{ t('user.section.password_mgmt') }}
+ + {{ editForm.managedPassword }} + + +

+ {{ t('user.hint.password_reset_to_view') }} +

+ + + +
+ -
{{ t('user.hint.agent_change') }}
- - - - - - - - - - - - - - - - - - - - - - + + + + {{ formatAmount(editForm.availableBalance) }} + + + {{ formatAmount(editForm.frozenBalance) }} + + + {{ t('user.bets_edit_value', { n: editForm.betCount, stake: formatAmount(editForm.totalStake) }) }} + + + {{ formatAmount(editForm.totalReturn) }} + + + {{ editForm.lastLoginAt ? formatTime(editForm.lastLoginAt) : t('common.never_login') }} + · {{ t('user.login_fail_value', { n: editForm.loginFailCount }) }} + +
@@ -549,6 +629,12 @@ function statusLabel(s: string) { {{ detail.id }} {{ detail.username }} + + {{ detail.managedPassword ?? '—' }} + + + {{ t('user.hint.password_reset_to_view') }} + {{ statusLabel(detail.status) }} @@ -588,7 +674,8 @@ function statusLabel(s: string) { diff --git a/apps/admin/src/views/user-form.ts b/apps/admin/src/views/user-form.ts index 9eb3e56..b0a2a5a 100644 --- a/apps/admin/src/views/user-form.ts +++ b/apps/admin/src/views/user-form.ts @@ -31,6 +31,8 @@ export interface PlayerEditForm { loginFailCount: number; phone: string; email: string; + managedPassword: string | null; + newPassword: string; } export interface PlayerRow { @@ -42,6 +44,7 @@ export interface PlayerRow { parentUsername: string | null; phone: string | null; email: string | null; + managedPassword: string | null; availableBalance: string; frozenBalance: string; lastLoginAt: string | null; @@ -90,6 +93,8 @@ export function emptyPlayerEditForm(): PlayerEditForm { loginFailCount: 0, phone: '', email: '', + managedPassword: null, + newPassword: '', }; } @@ -110,6 +115,8 @@ export function editFormFromDetail(d: PlayerDetail): PlayerEditForm { loginFailCount: d.loginFailCount, phone: d.phone ?? '', email: d.email ?? '', + managedPassword: d.managedPassword ?? null, + newPassword: '', }; } diff --git a/apps/api/package.json b/apps/api/package.json index 6ef20c4..58d9d07 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -11,7 +11,7 @@ "test:cov": "jest --coverage", "db:generate": "prisma generate", "db:migrate": "prisma migrate dev", - "db:migrate:deploy": "prisma migrate deploy", + "db:migrate:deploy": "prisma migrate deploy && prisma generate", "db:seed": "ts-node prisma/seed.ts", "db:studio": "prisma studio" }, diff --git a/apps/api/prisma/migrations/20260603120000_user_avatar_key/migration.sql b/apps/api/prisma/migrations/20260603120000_user_avatar_key/migration.sql new file mode 100644 index 0000000..efcf599 --- /dev/null +++ b/apps/api/prisma/migrations/20260603120000_user_avatar_key/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable: user_preferences 增加头像(内置球员 key) +ALTER TABLE "user_preferences" ADD COLUMN IF NOT EXISTS "avatar_key" VARCHAR(128); diff --git a/apps/api/prisma/migrations/20260604120000_user_account_controls/migration.sql b/apps/api/prisma/migrations/20260604120000_user_account_controls/migration.sql new file mode 100644 index 0000000..df01d08 --- /dev/null +++ b/apps/api/prisma/migrations/20260604120000_user_account_controls/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable: 玩家账号权限与后台可查密码 +ALTER TABLE "user_preferences" ADD COLUMN IF NOT EXISTS "allow_password_change" BOOLEAN NOT NULL DEFAULT true; +ALTER TABLE "user_preferences" ADD COLUMN IF NOT EXISTS "allow_username_change" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "user_preferences" ADD COLUMN IF NOT EXISTS "managed_password" VARCHAR(128); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index bab2d2d..a9f31da 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -52,13 +52,17 @@ model UserAuth { } model UserPreference { - id BigInt @id @default(autoincrement()) - userId BigInt @unique @map("user_id") - locale String @default("en-US") @db.VarChar(10) - phone String? @db.VarChar(32) - email String? @db.VarChar(128) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id BigInt @id @default(autoincrement()) + userId BigInt @unique @map("user_id") + locale String @default("en-US") @db.VarChar(10) + phone String? @db.VarChar(32) + email String? @db.VarChar(128) + avatarKey String? @map("avatar_key") @db.VarChar(128) + allowPasswordChange Boolean @default(true) @map("allow_password_change") + allowUsernameChange Boolean @default(false) @map("allow_username_change") + managedPassword String? @map("managed_password") @db.VarChar(128) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id]) diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 8533514..64029fd 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -579,7 +579,7 @@ async function main() { parentId: agent1.id, auth: { create: { passwordHash: playerHash } }, wallet: { create: { availableBalance: 1000 } }, - preferences: { create: { locale: 'zh-CN' } }, + preferences: { create: { locale: 'zh-CN', managedPassword: 'Player@123' } }, }, update: {}, }); diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 1534ef9..b4fb56d 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -4,6 +4,7 @@ import { ScheduleModule } from '@nestjs/schedule'; import { APP_GUARD } from '@nestjs/core'; import { JwtAuthGuard } from './domains/identity/guards'; import { PrismaModule } from './shared/prisma/prisma.module'; +import { SystemConfigModule } from './shared/config/system-config.module'; import { IdentityModule } from './domains/identity/identity.module'; import { AgentsModule } from './domains/agent/agents.module'; import { WalletModule } from './domains/ledger/wallet.module'; @@ -21,6 +22,7 @@ import { AgentPortalModule } from './applications/agent/agent-portal.module'; ConfigModule.forRoot({ isGlobal: true }), ScheduleModule.forRoot(), PrismaModule, + SystemConfigModule, IdentityModule, AgentsModule, WalletModule, diff --git a/apps/api/src/applications/admin/admin.controller.ts b/apps/api/src/applications/admin/admin.controller.ts index b745463..6057d85 100644 --- a/apps/api/src/applications/admin/admin.controller.ts +++ b/apps/api/src/applications/admin/admin.controller.ts @@ -29,6 +29,7 @@ import { AuditService } from '../../domains/operations/audit/audit.service'; import { BetsService } from '../../domains/betting/bets.service'; import { PrismaService } from '../../shared/prisma/prisma.service'; import { AdminDashboardService } from './admin-dashboard.service'; +import { SystemConfigService } from '../../shared/config/system-config.service'; import { IsString, IsNumber, @@ -127,6 +128,25 @@ class UpdatePlayerAdminDto { @IsOptional() @IsString() parentId?: string; + + @IsOptional() + @IsString() + username?: string; + + @IsOptional() + @IsString() + @MinLength(8) + password?: string; +} + +class PlayerAccountSettingsDto { + @IsOptional() + @IsBoolean() + allowPasswordChange?: boolean; + + @IsOptional() + @IsBoolean() + allowUsernameChange?: boolean; } class CreateAgentAdminDto { @@ -442,6 +462,7 @@ export class AdminController { private bets: BetsService, private prisma: PrismaService, private readonly dashboardService: AdminDashboardService, + private systemConfig: SystemConfigService, ) {} @Get('dashboard') @@ -450,6 +471,28 @@ export class AdminController { return jsonResponse(overview); } + @Get('users/settings/account') + async getPlayerAccountSettings() { + const settings = await this.systemConfig.getPlayerAccountSettings(); + return jsonResponse(settings); + } + + @Put('users/settings/account') + async updatePlayerAccountSettings( + @CurrentUser('id') operatorId: bigint, + @Body() dto: PlayerAccountSettingsDto, + ) { + const settings = await this.systemConfig.updatePlayerAccountSettings(dto); + await this.audit.log({ + operatorId, + operatorType: 'ADMIN', + action: 'UPDATE_PLAYER_ACCOUNT_SETTINGS', + module: 'USERS', + afterData: JSON.stringify(settings), + }); + return jsonResponse(settings); + } + @Get('users') async listUsers( @Query('page') page?: string, diff --git a/apps/api/src/applications/player/player.controller.ts b/apps/api/src/applications/player/player.controller.ts index e555d74..7a6cb63 100644 --- a/apps/api/src/applications/player/player.controller.ts +++ b/apps/api/src/applications/player/player.controller.ts @@ -13,6 +13,7 @@ import { JwtAuthGuard, PlayerGuard } from '../../domains/identity/guards'; import { CurrentUser } from '../../shared/common/decorators'; import { jsonResponse } from '../../shared/common/filters'; import { UsersService } from '../../domains/identity/users.service'; +import { SystemConfigService } from '../../shared/config/system-config.service'; import { WalletService } from '../../domains/ledger/wallet.service'; import { MatchesService } from '../../domains/catalog/matches.service'; import { OutrightService } from '../../domains/catalog/outright.service'; @@ -72,6 +73,14 @@ class UpdateProfileDto { @IsOptional() @IsString() email?: string; + + @IsOptional() + @IsString() + avatarKey?: string; + + @IsOptional() + @IsString() + username?: string; } @ApiTags('Player') @@ -87,12 +96,39 @@ export class PlayerController { private bets: BetsService, private content: ContentService, private cashback: CashbackService, + private systemConfig: SystemConfigService, ) {} + private async formatPlayerProfile(user: NonNullable>>) { + const accountSettings = await this.systemConfig.getPlayerAccountSettings(); + const prefs = user.preferences; + const viewablePassword = prefs?.managedPassword ?? null; + const safePrefs = prefs + ? (({ + managedPassword: _m, + allowPasswordChange: _a, + allowUsernameChange: _b, + ...rest + }) => rest)(prefs) + : {}; + return { + ...user, + id: user.id.toString(), + parentId: user.parentId?.toString() ?? null, + preferences: { + ...safePrefs, + viewablePassword, + allowPasswordChange: accountSettings.allowPasswordChange, + allowUsernameChange: accountSettings.allowUsernameChange, + }, + }; + } + @Get('profile') async profile(@CurrentUser('id') userId: bigint) { const user = await this.users.findById(userId); - return jsonResponse(user); + if (!user) return jsonResponse(null); + return jsonResponse(await this.formatPlayerProfile(user)); } @Post('language') @@ -104,7 +140,8 @@ export class PlayerController { @Patch('profile') async updateProfile(@CurrentUser('id') userId: bigint, @Body() dto: UpdateProfileDto) { const user = await this.users.updateProfile(userId, dto); - return jsonResponse(user); + if (!user) return jsonResponse(null); + return jsonResponse(await this.formatPlayerProfile(user)); } @Get('home') diff --git a/apps/api/src/domains/agent/agents.service.ts b/apps/api/src/domains/agent/agents.service.ts index a7e2aa7..503f818 100644 --- a/apps/api/src/domains/agent/agents.service.ts +++ b/apps/api/src/domains/agent/agents.service.ts @@ -626,6 +626,7 @@ export class AgentsService { locale, phone: data.phone?.trim() || null, email: data.email?.trim() || null, + managedPassword: data.password, }, }); diff --git a/apps/api/src/domains/identity/auth.service.ts b/apps/api/src/domains/identity/auth.service.ts index 902eaf5..670a773 100644 --- a/apps/api/src/domains/identity/auth.service.ts +++ b/apps/api/src/domains/identity/auth.service.ts @@ -3,6 +3,7 @@ import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import * as bcrypt from 'bcryptjs'; import { PrismaService } from '../../shared/prisma/prisma.service'; +import { SystemConfigService } from '../../shared/config/system-config.service'; const MAX_LOGIN_FAILS = 5; const LOCK_DURATION_MS = 15 * 60 * 1000; @@ -20,6 +21,7 @@ export class AuthService { private prisma: PrismaService, private jwt: JwtService, private config: ConfigService, + private systemConfig: SystemConfigService, ) {} /** 平台管理员 / 代理统一登录(按 userType 签发对应 JWT) */ @@ -107,6 +109,11 @@ export class AuthService { const auth = await this.prisma.userAuth.findUnique({ where: { userId } }); if (!auth) throw new UnauthorizedException('User not found'); + const settings = await this.systemConfig.getPlayerAccountSettings(); + if (!settings.allowPasswordChange) { + throw new ForbiddenException('当前平台未开放玩家自行修改密码'); + } + const valid = await bcrypt.compare(oldPassword, auth.passwordHash); if (!valid) throw new UnauthorizedException('Invalid old password'); @@ -115,6 +122,10 @@ export class AuthService { where: { userId }, data: { passwordHash: hash }, }); + await this.prisma.userPreference.updateMany({ + where: { userId }, + data: { managedPassword: null }, + }); return { success: true }; } diff --git a/apps/api/src/domains/identity/users.service.ts b/apps/api/src/domains/identity/users.service.ts index 3cf256b..42ec1b1 100644 --- a/apps/api/src/domains/identity/users.service.ts +++ b/apps/api/src/domains/identity/users.service.ts @@ -1,6 +1,8 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { SUPPORTED_LOCALES } from '@thebet365/shared'; +import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import { SUPPORTED_LOCALES, isValidAvatarKey } from '@thebet365/shared'; import { PrismaService } from '../../shared/prisma/prisma.service'; +import { SystemConfigService } from '../../shared/config/system-config.service'; import { AgentsService } from '../agent/agents.service'; export type PlayerListFilters = { @@ -14,6 +16,7 @@ export class UsersService { constructor( private prisma: PrismaService, private agents: AgentsService, + private systemConfig: SystemConfigService, ) {} private formatPlayerRow( @@ -26,7 +29,11 @@ export class UsersService { createdAt: Date; updatedAt: Date; wallet?: { availableBalance: { toString(): string }; frozenBalance: { toString(): string } } | null; - preferences?: { phone: string | null; email: string | null } | null; + preferences?: { + phone: string | null; + email: string | null; + managedPassword?: string | null; + } | null; parent?: { username: string } | null; auth?: { lastLoginAt: Date | null } | null; }, @@ -41,6 +48,7 @@ export class UsersService { parentUsername: u.parent?.username ?? null, phone: u.preferences?.phone ?? null, email: u.preferences?.email ?? null, + managedPassword: u.preferences?.managedPassword ?? null, availableBalance: u.wallet?.availableBalance?.toString() ?? '0', frozenBalance: u.wallet?.frozenBalance?.toString() ?? '0', lastLoginAt: u.auth?.lastLoginAt ?? null, @@ -81,13 +89,61 @@ export class UsersService { }); } - async updateProfile(userId: bigint, data: { phone?: string; email?: string }) { - const phone = data.phone?.trim() || null; - const email = data.email?.trim() || null; + async updateProfile( + userId: bigint, + data: { phone?: string; email?: string; avatarKey?: string | null; username?: string }, + ) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + include: { preferences: true }, + }); + if (!user) throw new NotFoundException('User not found'); + + if (data.username !== undefined) { + const nextUsername = data.username.trim(); + if (!nextUsername) throw new BadRequestException('账号名称不能为空'); + const settings = await this.systemConfig.getPlayerAccountSettings(); + if (!settings.allowUsernameChange) { + throw new ForbiddenException('当前平台未开放玩家自行修改账号名称'); + } + if (nextUsername !== user.username) { + const taken = await this.prisma.user.findUnique({ where: { username: nextUsername } }); + if (taken) throw new BadRequestException('账号名称已被占用'); + await this.prisma.user.update({ + where: { id: userId }, + data: { username: nextUsername }, + }); + } + } + + const phone = data.phone !== undefined ? data.phone.trim() || null : undefined; + const email = data.email !== undefined ? data.email.trim() || null : undefined; + let avatarKey: string | null | undefined; + if (data.avatarKey !== undefined) { + avatarKey = data.avatarKey?.trim() || null; + if (avatarKey && !isValidAvatarKey(avatarKey)) { + throw new BadRequestException('无效头像'); + } + } + + const existing = await this.prisma.userPreference.findUnique({ where: { userId } }); + if (!existing && phone === undefined && email === undefined && avatarKey === undefined) { + return this.findById(userId); + } + await this.prisma.userPreference.upsert({ where: { userId }, - create: { userId, phone, email }, - update: { phone, email }, + create: { + userId, + phone: phone ?? null, + email: email ?? null, + ...(avatarKey !== undefined ? { avatarKey } : {}), + }, + update: { + ...(phone !== undefined ? { phone } : {}), + ...(email !== undefined ? { email } : {}), + ...(avatarKey !== undefined ? { avatarKey } : {}), + }, }); return this.findById(userId); } @@ -195,10 +251,13 @@ export class UsersService { phone?: string; email?: string; parentId?: string | null; + username?: string; + password?: string; }, ) { const user = await this.prisma.user.findFirst({ where: { id: playerId, userType: 'PLAYER', deletedAt: null }, + include: { auth: true }, }); if (!user) throw new NotFoundException('玩家不存在'); @@ -206,6 +265,35 @@ export class UsersService { throw new BadRequestException('无效状态'); } + if (data.username !== undefined) { + const nextUsername = data.username.trim(); + if (!nextUsername) throw new BadRequestException('账号名称不能为空'); + if (nextUsername !== user.username) { + const taken = await this.prisma.user.findUnique({ where: { username: nextUsername } }); + if (taken) throw new BadRequestException('账号名称已被占用'); + await this.prisma.user.update({ + where: { id: playerId }, + data: { username: nextUsername }, + }); + } + } + + if (data.password !== undefined) { + const nextPassword = data.password; + if (nextPassword.length < 8) throw new BadRequestException('密码至少 8 位'); + if (!user.auth) throw new BadRequestException('账号认证信息缺失'); + const hash = await bcrypt.hash(nextPassword, 10); + await this.prisma.userAuth.update({ + where: { userId: playerId }, + data: { passwordHash: hash, loginFailCount: 0, lockedUntil: null }, + }); + await this.prisma.userPreference.upsert({ + where: { userId: playerId }, + create: { userId: playerId, managedPassword: nextPassword }, + update: { managedPassword: nextPassword }, + }); + } + if (data.status) { await this.prisma.user.update({ where: { id: playerId }, @@ -253,25 +341,43 @@ export class UsersService { }); } - if (data.phone !== undefined || data.email !== undefined || data.locale) { - const phone = data.phone !== undefined ? data.phone?.trim() || null : undefined; - const email = data.email !== undefined ? data.email?.trim() || null : undefined; + const prefPatch: { + locale?: string; + phone?: string | null; + email?: string | null; + } = {}; + + if (data.locale) prefPatch.locale = data.locale; + if (data.phone !== undefined) prefPatch.phone = data.phone.trim() || null; + if (data.email !== undefined) prefPatch.email = data.email.trim() || null; + + if (Object.keys(prefPatch).length > 0) { await this.prisma.userPreference.upsert({ where: { userId: playerId }, create: { userId: playerId, locale: data.locale ?? user.locale, - phone: phone ?? null, - email: email ?? null, - }, - update: { - ...(data.locale ? { locale: data.locale } : {}), - ...(phone !== undefined ? { phone } : {}), - ...(email !== undefined ? { email } : {}), + phone: prefPatch.phone ?? null, + email: prefPatch.email ?? null, }, + update: prefPatch, }); } return this.getPlayerAdminDetail(playerId); } + + async getPlayerAccountPermissions() { + return this.systemConfig.getPlayerAccountSettings(); + } + + async clearManagedPassword(userId: bigint) { + const pref = await this.prisma.userPreference.findUnique({ where: { userId } }); + if (pref?.managedPassword) { + await this.prisma.userPreference.update({ + where: { userId }, + data: { managedPassword: null }, + }); + } + } } diff --git a/apps/api/src/shared/config/system-config.module.ts b/apps/api/src/shared/config/system-config.module.ts new file mode 100644 index 0000000..9f88521 --- /dev/null +++ b/apps/api/src/shared/config/system-config.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { SystemConfigService } from './system-config.service'; + +@Global() +@Module({ + providers: [SystemConfigService], + exports: [SystemConfigService], +}) +export class SystemConfigModule {} diff --git a/apps/api/src/shared/config/system-config.service.ts b/apps/api/src/shared/config/system-config.service.ts new file mode 100644 index 0000000..096cdaf --- /dev/null +++ b/apps/api/src/shared/config/system-config.service.ts @@ -0,0 +1,59 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +export const PLAYER_ALLOW_PASSWORD_CHANGE = 'player.allow_password_change'; +export const PLAYER_ALLOW_USERNAME_CHANGE = 'player.allow_username_change'; + +export type PlayerAccountSettings = { + allowPasswordChange: boolean; + allowUsernameChange: boolean; +}; + +@Injectable() +export class SystemConfigService { + constructor(private prisma: PrismaService) {} + + async getBoolean(key: string, defaultValue: boolean): Promise { + const row = await this.prisma.systemConfig.findUnique({ where: { configKey: key } }); + if (!row) return defaultValue; + return row.configValue === 'true' || row.configValue === '1'; + } + + async setBoolean(key: string, value: boolean, description?: string) { + await this.prisma.systemConfig.upsert({ + where: { configKey: key }, + create: { + configKey: key, + configValue: value ? 'true' : 'false', + description, + }, + update: { configValue: value ? 'true' : 'false' }, + }); + } + + async getPlayerAccountSettings(): Promise { + const [allowPasswordChange, allowUsernameChange] = await Promise.all([ + this.getBoolean(PLAYER_ALLOW_PASSWORD_CHANGE, true), + this.getBoolean(PLAYER_ALLOW_USERNAME_CHANGE, false), + ]); + return { allowPasswordChange, allowUsernameChange }; + } + + async updatePlayerAccountSettings(data: Partial) { + if (data.allowPasswordChange !== undefined) { + await this.setBoolean( + PLAYER_ALLOW_PASSWORD_CHANGE, + data.allowPasswordChange, + '玩家是否可在客户端修改密码', + ); + } + if (data.allowUsernameChange !== undefined) { + await this.setBoolean( + PLAYER_ALLOW_USERNAME_CHANGE, + data.allowUsernameChange, + '玩家是否可在客户端修改登录账号名', + ); + } + return this.getPlayerAccountSettings(); + } +} diff --git a/apps/player/src/assets/images/vs.png b/apps/player/src/assets/images/vs.png new file mode 100644 index 0000000..323b66e Binary files /dev/null and b/apps/player/src/assets/images/vs.png differ diff --git a/apps/player/src/components/PlayerAvatarModal.vue b/apps/player/src/components/PlayerAvatarModal.vue new file mode 100644 index 0000000..6dd6887 --- /dev/null +++ b/apps/player/src/components/PlayerAvatarModal.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/apps/player/src/components/PlayerAvatarPicker.vue b/apps/player/src/components/PlayerAvatarPicker.vue new file mode 100644 index 0000000..ef2645b --- /dev/null +++ b/apps/player/src/components/PlayerAvatarPicker.vue @@ -0,0 +1,162 @@ + + + + + diff --git a/apps/player/src/components/UserAvatarMenu.vue b/apps/player/src/components/UserAvatarMenu.vue index aa335cb..05f0377 100644 --- a/apps/player/src/components/UserAvatarMenu.vue +++ b/apps/player/src/components/UserAvatarMenu.vue @@ -1,17 +1,25 @@ diff --git a/apps/player/src/main.ts b/apps/player/src/main.ts index 5ae24e2..66d2f7b 100644 --- a/apps/player/src/main.ts +++ b/apps/player/src/main.ts @@ -174,6 +174,20 @@ const i18n = createI18n({ profile: { edit: '修改资料', language: '语言', + avatar: '选择头像', + avatar_change: '修改头像', + avatar_confirm: '确定', + section_contact: '联系方式', + section_account: '账号信息', + change_password: '修改密码', + show_password: '查看', + hide_password: '隐藏', + password_unavailable: '••••••••', + password_unavailable_hint: '密码不可查看,如需重置请联系客服', + section_password: '修改密码(可选)', + avatar_hint: '从内置球员中选择头像', + avatar_search: '搜索球员、位置或国家', + avatar_empty: '未找到匹配球员', phone: '手机号', email: '邮箱', phone_placeholder: '请输入手机号', @@ -193,6 +207,10 @@ const i18n = createI18n({ password_failed: '密码修改失败', password_mismatch: '两次新密码不一致', password_incomplete: '修改密码需填写当前密码、新密码及确认密码', + username_placeholder: '登录账号名', + username_readonly_hint: '账号名称由后台管理,如需修改请联系客服', + username_updated: '账号名称已更新', + password_disabled: '当前账号不允许自行修改密码,请联系客服', rules_title: '投注规则', rules_p1: '本平台第一版仅支持足球赛前盘,不含滚球、Cash Out、改单及系统串关。', rules_p2: '串关为 2 串 1 至 5 串 1,不可同场串关;冠军盘、四分盘让球/大小不可进入串关。', @@ -365,6 +383,20 @@ const i18n = createI18n({ profile: { edit: 'Edit Profile', language: 'Language', + avatar: 'Avatar', + avatar_change: 'Change avatar', + avatar_confirm: 'Confirm', + section_contact: 'Contact', + section_account: 'Account', + change_password: 'Change password', + show_password: 'Show', + hide_password: 'Hide', + password_unavailable: '••••••••', + password_unavailable_hint: 'Password not available; contact support to reset', + section_password: 'Change password (optional)', + avatar_hint: 'Choose from built-in player portraits', + avatar_search: 'Search player, position or country', + avatar_empty: 'No players found', phone: 'Phone', email: 'Email', phone_placeholder: 'Phone number', @@ -384,6 +416,10 @@ const i18n = createI18n({ password_failed: 'Password change failed', password_mismatch: 'Passwords do not match', password_incomplete: 'Fill current, new and confirm password to change password', + username_placeholder: 'Login username', + username_readonly_hint: 'Username is managed by admin; contact support to change', + username_updated: 'Username updated', + password_disabled: 'Password change is disabled for this account; contact support', rules_title: 'Betting Rules', rules_p1: 'Football pre-match only in v1. No live betting, Cash Out, bet edits, or system parlays.', rules_p2: 'Parlays: 2–5 legs, different matches only. Outright and quarter-ball HDP/O-U are excluded from parlays.', @@ -562,6 +598,20 @@ const i18n = createI18n({ profile: { edit: 'Edit Profil', language: 'Bahasa', + avatar: 'Avatar', + avatar_change: 'Tukar avatar', + avatar_confirm: 'Sahkan', + section_contact: 'Maklumat hubungan', + section_account: 'Akaun', + change_password: 'Tukar kata laluan', + show_password: 'Lihat', + hide_password: 'Sembunyi', + password_unavailable: '••••••••', + password_unavailable_hint: 'Kata laluan tidak tersedia; hubungi sokongan', + section_password: 'Tukar kata laluan (pilihan)', + avatar_hint: 'Pilih dari potret pemain terbina', + avatar_search: 'Cari pemain, posisi atau negara', + avatar_empty: 'Tiada pemain dijumpai', phone: 'Telefon', email: 'E-mel', phone_placeholder: 'Nombor telefon', @@ -581,6 +631,10 @@ const i18n = createI18n({ password_failed: 'Gagal tukar kata laluan', password_mismatch: 'Kata laluan tidak sepadan', password_incomplete: 'Isi kata laluan semasa, baharu dan pengesahan untuk menukar', + username_placeholder: 'Nama log masuk', + username_readonly_hint: 'Nama akaun diurus admin; hubungi sokongan untuk ubah', + username_updated: 'Nama akaun dikemas kini', + password_disabled: 'Akaun ini tidak dibenarkan tukar kata laluan; hubungi sokongan', rules_title: 'Peraturan Pertaruhan', rules_p1: 'Versi pertama: hanya bola sepak pra-perlawanan. Tiada live, Cash Out, edit pertaruhan atau parlay sistem.', rules_p2: 'Parlay 2–5 perlawanan, bukan perlawanan sama. Outright dan suku bola HDP/O-U tidak boleh parlay.', diff --git a/apps/player/src/views/HomeView.vue b/apps/player/src/views/HomeView.vue index 16b7952..fed4d44 100644 --- a/apps/player/src/views/HomeView.vue +++ b/apps/player/src/views/HomeView.vue @@ -4,14 +4,34 @@ import { useI18n } from 'vue-i18n'; import emptyMatchesImg from '../assets/images/empty-matches.svg'; import BannerCarousel from '../components/BannerCarousel.vue'; import { usePlayerHome } from '../composables/usePlayerHome'; +import { teamFlagUrl } from '../utils/teamFlag'; -const { t } = useI18n(); +const { t, locale } = useI18n(); const router = useRouter(); const { banners, hotMatches, loading } = usePlayerHome(); function goMatch(id: string) { router.push(`/match/${id}`); } + +function formatKickoff(startTime: string) { + return new Date(startTime).toLocaleString(locale.value, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + +function homeFlag(match: (typeof hotMatches.value)[number]) { + return teamFlagUrl(match.homeTeamCode, match.homeTeamName); +} + +function awayFlag(match: (typeof hotMatches.value)[number]) { + return teamFlagUrl(match.awayTeamCode, match.awayTeamName); +} diff --git a/apps/player/src/views/ProfileEditView.vue b/apps/player/src/views/ProfileEditView.vue index 1c051e5..9a4e0bb 100644 --- a/apps/player/src/views/ProfileEditView.vue +++ b/apps/player/src/views/ProfileEditView.vue @@ -1,15 +1,25 @@