feat(admin,player,api): 玩家账号密码管理与代理上下分

新增玩家头像、可查密码与全局改密/改账号开关;玩家资料页合并账号密码展示;代理直属玩家列表支持自定义上下分。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-04 11:36:53 +08:00
parent f76728dc3e
commit a8e4ead618
81 changed files with 1763 additions and 217 deletions

View File

@@ -51,6 +51,22 @@ export const adminPagesMs: Record<string, string> = {
'user.field.login_fail': 'Log masuk gagal', 'user.field.login_fail': 'Log masuk gagal',
'user.field.phone': 'Telefon', 'user.field.phone': 'Telefon',
'user.field.email': 'E-mel', '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.username_unique': 'Nama log masuk unik',
'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',
@@ -58,6 +74,10 @@ export const adminPagesMs: Record<string, string> = {
'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.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.create': 'Cipta',
'user.btn.save_profile': 'Simpan', 'user.btn.save_profile': 'Simpan',
'user.btn.confirm_deposit': 'Sahkan tambah baki', 'user.btn.confirm_deposit': 'Sahkan tambah baki',
@@ -222,6 +242,9 @@ export const adminPagesMs: Record<string, string> = {
'agent_portal.agent_username_ph': 'Nama pengguna ejen', 'agent_portal.agent_username_ph': 'Nama pengguna ejen',
'agent_portal.player_id_ph': 'ID pemain', 'agent_portal.player_id_ph': 'ID pemain',
'agent_portal.withdraw_btn': 'Keluarkan {amount}', '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.agent_sub_created': 'Sub-ejen dicipta',
'msg.withdraw_ok': 'Pengeluaran berjaya', 'msg.withdraw_ok': 'Pengeluaran berjaya',
@@ -241,6 +264,7 @@ export const adminPagesMs: Record<string, string> = {
'msg.import_done': 'Import: {imported} ok, {skipped} dilangkau, {failed} gagal / {total} jumlah', 'msg.import_done': 'Import: {imported} ok, {skipped} dilangkau, {failed} gagal / {total} jumlah',
'msg.topup_ok': 'Tambah baki berjaya', 'msg.topup_ok': 'Tambah baki berjaya',
'msg.topup_failed': 'Tambah baki gagal', 'msg.topup_failed': 'Tambah baki gagal',
'msg.transfer_failed': 'Operasi gagal',
'msg.amount_gt_zero': 'Jumlah mesti lebih daripada 0', 'msg.amount_gt_zero': 'Jumlah mesti lebih daripada 0',
'msg.credit_zero': 'Pelarasan tidak boleh 0', 'msg.credit_zero': 'Pelarasan tidak boleh 0',
'msg.credit_adjusted': 'Kredit dikemas kini', 'msg.credit_adjusted': 'Kredit dikemas kini',

View File

@@ -51,6 +51,22 @@ export const adminPagesZh: Record<string, string> = {
'user.field.login_fail': '登录失败', 'user.field.login_fail': '登录失败',
'user.field.phone': '手机', 'user.field.phone': '手机',
'user.field.email': '邮箱', '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.username_unique': '登录用户名,唯一',
'user.ph.no_agent': '不设置(平台直属玩家)', 'user.ph.no_agent': '不设置(平台直属玩家)',
'user.hint.no_agent': '留空表示不挂靠代理,由平台直接管理', 'user.hint.no_agent': '留空表示不挂靠代理,由平台直接管理',
@@ -58,6 +74,10 @@ export const adminPagesZh: Record<string, string> = {
'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.allow_password_change': '关闭后所有玩家均不可在客户端修改密码',
'user.hint.allow_username_change': '开启后所有玩家均可在资料页修改登录账号名',
'user.hint.view_password': '仅保存后台创建或重置时的密码;玩家自行改密后会清除',
'user.hint.reset_password': '重置后立即生效,并更新上方可查密码',
'user.btn.create': '创建', 'user.btn.create': '创建',
'user.btn.save_profile': '保存资料', 'user.btn.save_profile': '保存资料',
'user.btn.confirm_deposit': '确认上分', 'user.btn.confirm_deposit': '确认上分',
@@ -222,6 +242,9 @@ export const adminPagesZh: Record<string, string> = {
'agent_portal.agent_username_ph': '代理用户名', 'agent_portal.agent_username_ph': '代理用户名',
'agent_portal.player_id_ph': '玩家 ID', 'agent_portal.player_id_ph': '玩家 ID',
'agent_portal.withdraw_btn': '下分 {amount}', '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.agent_sub_created': '下级代理已创建',
'msg.withdraw_ok': '下分成功', 'msg.withdraw_ok': '下分成功',
@@ -241,6 +264,7 @@ export const adminPagesZh: Record<string, string> = {
'msg.import_done': '导入完成:成功 {imported},跳过 {skipped},失败 {failed} / 共 {total}', 'msg.import_done': '导入完成:成功 {imported},跳过 {skipped},失败 {failed} / 共 {total}',
'msg.topup_ok': '上分成功', 'msg.topup_ok': '上分成功',
'msg.topup_failed': '上分失败', 'msg.topup_failed': '上分失败',
'msg.transfer_failed': '操作失败',
'msg.amount_gt_zero': '金额须大于 0', 'msg.amount_gt_zero': '金额须大于 0',
'msg.credit_zero': '调整金额不能为 0', 'msg.credit_zero': '调整金额不能为 0',
'msg.credit_adjusted': '授信已调整', 'msg.credit_adjusted': '授信已调整',
@@ -403,6 +427,22 @@ export const adminPagesEn: Record<string, string> = {
'user.field.login_fail': 'Failed logins', 'user.field.login_fail': 'Failed logins',
'user.field.phone': 'Phone', 'user.field.phone': 'Phone',
'user.field.email': 'Email', '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.username_unique': 'Unique login username',
'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',
@@ -410,6 +450,10 @@ export const adminPagesEn: Record<string, string> = {
'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.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.create': 'Create',
'user.btn.save_profile': 'Save', 'user.btn.save_profile': 'Save',
'user.btn.confirm_deposit': 'Confirm top-up', 'user.btn.confirm_deposit': 'Confirm top-up',
@@ -574,6 +618,9 @@ export const adminPagesEn: Record<string, string> = {
'agent_portal.agent_username_ph': 'Agent username', 'agent_portal.agent_username_ph': 'Agent username',
'agent_portal.player_id_ph': 'Player ID', 'agent_portal.player_id_ph': 'Player ID',
'agent_portal.withdraw_btn': 'Withdraw {amount}', '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.agent_sub_created': 'Sub-agent created',
'msg.withdraw_ok': 'Withdrawal successful', 'msg.withdraw_ok': 'Withdrawal successful',
@@ -593,6 +640,7 @@ export const adminPagesEn: Record<string, string> = {
'msg.import_done': 'Import: {imported} ok, {skipped} skipped, {failed} failed / {total} total', 'msg.import_done': 'Import: {imported} ok, {skipped} skipped, {failed} failed / {total} total',
'msg.topup_ok': 'Top-up successful', 'msg.topup_ok': 'Top-up successful',
'msg.topup_failed': 'Top-up failed', 'msg.topup_failed': 'Top-up failed',
'msg.transfer_failed': 'Operation failed',
'msg.amount_gt_zero': 'Amount must be greater than 0', 'msg.amount_gt_zero': 'Amount must be greater than 0',
'msg.credit_zero': 'Adjustment cannot be 0', 'msg.credit_zero': 'Adjustment cannot be 0',
'msg.credit_adjusted': 'Credit updated', 'msg.credit_adjusted': 'Credit updated',

View File

@@ -46,12 +46,39 @@ const detail = ref<PlayerDetail | null>(null);
const editingId = ref(''); const editingId = ref('');
const depositForm = ref({ userId: '', amount: 100, remark: '' }); const depositForm = ref({ userId: '', amount: 100, remark: '' });
const playerSettings = ref({ allowPasswordChange: true, allowUsernameChange: false });
const settingsSaving = ref(false);
onMounted(() => { onMounted(() => {
loadAgentOptions(); loadAgentOptions();
loadPlayerSettings();
load(); 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() { async function loadAgentOptions() {
const { data } = await api.get('/admin/agents/options'); const { data } = await api.get('/admin/agents/options');
agentOptions.value = data.data; agentOptions.value = data.data;
@@ -122,7 +149,9 @@ async function submitCreate() {
try { try {
await api.post('/admin/users', payload); await api.post('/admin/users', payload);
ElMessage.success( 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; createVisible.value = false;
load(); load();
@@ -163,13 +192,27 @@ async function toggleFreeze(row: PlayerRow) {
} }
async function submitEdit() { async function submitEdit() {
if (editForm.value.newPassword && editForm.value.newPassword.length < 8) {
ElMessage.warning(t('err.password_min'));
return;
}
editLoading.value = true; editLoading.value = true;
try { 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 || '', 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,
}); });
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')); ElMessage.success(t('msg.saved'));
editVisible.value = false; editVisible.value = false;
load(); load();
@@ -246,6 +289,29 @@ function statusLabel(s: string) {
<el-button type="primary" @click="openCreate">{{ t('user.create_btn') }}</el-button> <el-button type="primary" @click="openCreate">{{ t('user.create_btn') }}</el-button>
</div> </div>
<el-card class="settings-card" shadow="never">
<div class="global-settings">
<span class="settings-title">{{ t('user.global_settings') }}</span>
<span class="settings-desc">{{ t('user.global_settings_hint') }}</span>
<el-form inline size="small" class="settings-form">
<el-form-item :label="t('user.field.allow_password_change')">
<el-switch
v-model="playerSettings.allowPasswordChange"
:loading="settingsSaving"
@change="savePlayerSettings"
/>
</el-form-item>
<el-form-item :label="t('user.field.allow_username_change')">
<el-switch
v-model="playerSettings.allowUsernameChange"
:loading="settingsSaving"
@change="savePlayerSettings"
/>
</el-form-item>
</el-form>
</div>
</el-card>
<el-card class="filter-card" shadow="never"> <el-card class="filter-card" shadow="never">
<el-form inline> <el-form inline>
<el-form-item :label="t('common.keyword')"> <el-form-item :label="t('common.keyword')">
@@ -455,20 +521,42 @@ function statusLabel(s: string) {
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="editVisible" :title="t('user.dialog.edit')" width="560px" destroy-on-close> <el-dialog
<el-form label-width="100px"> v-model="editVisible"
<el-form-item :label="t('user.field.player_id')"> :title="t('user.dialog.edit')"
<el-input :model-value="editForm.id" disabled /> width="480px"
</el-form-item> destroy-on-close
class="user-edit-dialog"
>
<el-form label-width="84px" size="small" class="compact-edit-form">
<div class="edit-meta">
<span>ID {{ editForm.id }}</span>
<el-tag :type="statusTagType(editForm.status)" size="small">{{ statusLabel(editForm.status) }}</el-tag>
</div>
<el-form-item :label="t('user.col.username')"> <el-form-item :label="t('user.col.username')">
<el-input :model-value="editForm.username" disabled /> <el-input v-model="editForm.username" :placeholder="t('user.ph.username_unique')" />
</el-form-item> </el-form-item>
<el-form-item :label="t('user.field.account_status')">
<el-tag :type="statusTagType(editForm.status)" size="small"> <div class="password-mgmt-block">
{{ statusLabel(editForm.status) }} <div class="block-title">{{ t('user.section.password_mgmt') }}</div>
</el-tag> <el-form-item :label="t('user.field.current_password')">
<span class="field-hint inline-hint">{{ t('user.hint.freeze_in_list') }}</span> <span v-if="editForm.managedPassword" class="password-plain">{{ editForm.managedPassword }}</span>
<span v-else class="password-empty"></span>
</el-form-item> </el-form-item>
<p v-if="!editForm.managedPassword" class="field-hint block-hint">
{{ t('user.hint.password_reset_to_view') }}
</p>
<el-form-item :label="t('user.field.reset_password')">
<el-input
v-model="editForm.newPassword"
type="text"
autocomplete="off"
:placeholder="t('user.ph.reset_password_short')"
/>
</el-form-item>
</div>
<el-form-item :label="t('user.filter.agent')"> <el-form-item :label="t('user.filter.agent')">
<el-select <el-select
v-model="editForm.parentId" v-model="editForm.parentId"
@@ -483,46 +571,38 @@ function statusLabel(s: string) {
:value="a.id" :value="a.id"
/> />
</el-select> </el-select>
<div class="field-hint">{{ t('user.hint.agent_change') }}</div>
</el-form-item> </el-form-item>
<el-form-item :label="t('user.field.available')">
<el-input :model-value="formatAmount(editForm.availableBalance)" disabled />
</el-form-item>
<el-form-item :label="t('user.field.frozen_balance')">
<el-input :model-value="formatAmount(editForm.frozenBalance)" disabled />
</el-form-item>
<el-form-item :label="t('user.field.bets_summary')">
<el-input
:model-value="t('user.bets_edit_value', { n: editForm.betCount, stake: formatAmount(editForm.totalStake) })"
disabled
/>
</el-form-item>
<el-form-item :label="t('user.field.total_payout')">
<el-input :model-value="formatAmount(editForm.totalReturn)" disabled />
</el-form-item>
<el-form-item :label="t('user.col.last_login')">
<el-input
:model-value="editForm.lastLoginAt ? formatTime(editForm.lastLoginAt) : t('common.never_login')"
disabled
/>
</el-form-item>
<el-form-item :label="t('user.field.login_fail')">
<el-input :model-value="t('user.login_fail_value', { n: editForm.loginFailCount })" disabled />
</el-form-item>
<el-form-item :label="t('user.col.created')">
<el-input :model-value="formatTime(editForm.createdAt)" disabled />
</el-form-item>
<el-divider />
<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')" />
</el-form-item> </el-form-item>
<el-form-item :label="t('user.field.email')"> <el-form-item :label="t('user.field.email')">
<el-input v-model="editForm.email" :placeholder="t('common.optional')" /> <el-input v-model="editForm.email" :placeholder="t('common.optional')" />
</el-form-item> </el-form-item>
<el-descriptions :column="2" size="small" border class="edit-stats">
<el-descriptions-item :label="t('user.field.available')">
{{ formatAmount(editForm.availableBalance) }}
</el-descriptions-item>
<el-descriptions-item :label="t('user.field.frozen_balance')">
{{ formatAmount(editForm.frozenBalance) }}
</el-descriptions-item>
<el-descriptions-item :label="t('user.field.bets_summary')">
{{ t('user.bets_edit_value', { n: editForm.betCount, stake: formatAmount(editForm.totalStake) }) }}
</el-descriptions-item>
<el-descriptions-item :label="t('user.field.total_payout')">
{{ formatAmount(editForm.totalReturn) }}
</el-descriptions-item>
<el-descriptions-item :label="t('user.col.last_login')" :span="2">
{{ editForm.lastLoginAt ? formatTime(editForm.lastLoginAt) : t('common.never_login') }}
· {{ t('user.login_fail_value', { n: editForm.loginFailCount }) }}
</el-descriptions-item>
</el-descriptions>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="editVisible = false">{{ t('common.cancel') }}</el-button> <el-button size="small" @click="editVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="editLoading" @click="submitEdit">{{ t('user.btn.save_profile') }}</el-button> <el-button size="small" type="primary" :loading="editLoading" @click="submitEdit">
{{ t('user.btn.save_profile') }}
</el-button>
</template> </template>
</el-dialog> </el-dialog>
@@ -549,6 +629,12 @@ function statusLabel(s: string) {
<el-descriptions :column="2" border size="small"> <el-descriptions :column="2" border size="small">
<el-descriptions-item :label="t('common.col_id')">{{ detail.id }}</el-descriptions-item> <el-descriptions-item :label="t('common.col_id')">{{ detail.id }}</el-descriptions-item>
<el-descriptions-item :label="t('user.col.username')">{{ detail.username }}</el-descriptions-item> <el-descriptions-item :label="t('user.col.username')">{{ detail.username }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.current_password')">
{{ detail.managedPassword ?? '—' }}
</el-descriptions-item>
<el-descriptions-item v-if="!detail.managedPassword" :span="2">
<span class="field-hint">{{ t('user.hint.password_reset_to_view') }}</span>
</el-descriptions-item>
<el-descriptions-item :label="t('common.status')"> <el-descriptions-item :label="t('common.status')">
<el-tag :type="statusTagType(detail.status)" size="small"> <el-tag :type="statusTagType(detail.status)" size="small">
{{ statusLabel(detail.status) }} {{ statusLabel(detail.status) }}
@@ -588,7 +674,8 @@ function statusLabel(s: string) {
</template> </template>
<style scoped> <style scoped>
.filter-card { margin-bottom: 16px; border-radius: 12px; } .filter-card { margin-bottom: 12px; border-radius: 12px; }
.settings-card { margin-bottom: 12px; border-radius: 12px; }
.data-card { border-radius: 12px; } .data-card { border-radius: 12px; }
.pager { margin-top: 16px; display: flex; justify-content: flex-end; } .pager { margin-top: 16px; display: flex; justify-content: flex-end; }
.field-hint { font-size: 12px; color: #888; margin-top: 4px; } .field-hint { font-size: 12px; color: #888; margin-top: 4px; }
@@ -596,6 +683,69 @@ function statusLabel(s: string) {
.amount-compact { white-space: nowrap; font-variant-numeric: tabular-nums; cursor: default; } .amount-compact { white-space: nowrap; font-variant-numeric: tabular-nums; cursor: default; }
.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; }
.global-settings {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 20px;
}
.settings-title {
font-size: 13px;
font-weight: 600;
color: #ccc;
margin-right: 4px;
}
.settings-desc {
font-size: 12px;
color: #888;
flex: 1;
min-width: 200px;
}
.password-mgmt-block {
margin: 4px 0 10px;
padding: 10px 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: rgba(0, 0, 0, 0.15);
}
.block-title {
font-size: 12px;
font-weight: 700;
color: #e8a84a;
margin-bottom: 8px;
letter-spacing: 0.04em;
}
.password-plain {
font-family: ui-monospace, monospace;
font-size: 14px;
font-weight: 600;
color: #f0d090;
letter-spacing: 0.06em;
}
.password-empty {
color: #666;
}
.block-hint {
margin: -4px 0 8px;
}
.settings-form :deep(.el-form-item) {
margin-bottom: 0;
margin-right: 16px;
}
.edit-meta {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
font-size: 12px;
color: #888;
}
.compact-edit-form :deep(.el-form-item) {
margin-bottom: 10px;
}
.edit-stats {
margin-top: 4px;
}
</style> </style>
<style> <style>

View File

@@ -7,40 +7,84 @@ import { formatAmount, formatAmountFull } from '../../utils/format-amount';
const { t } = useAdminLocale(); const { t } = useAdminLocale();
const players = ref<unknown[]>([]); type PlayerRow = {
id: string;
username: string;
wallet?: { availableBalance: string };
};
const players = ref<PlayerRow[]>([]);
const form = ref({ username: '', password: 'Player@123' }); const form = ref({ username: '', password: 'Player@123' });
const depositForm = ref({ playerId: '', amount: 100, requestId: '' });
const transferVisible = ref(false);
const transferLoading = ref(false);
const transferType = ref<'deposit' | 'withdraw'>('deposit');
const transferTarget = ref<PlayerRow | null>(null);
const transferAmount = ref(100);
onMounted(load); onMounted(load);
async function load() { async function load() {
const { data } = await api.get('/agent/players'); const { data } = await api.get('/agent/players');
players.value = data.data; players.value = data.data as PlayerRow[];
} }
async function create() { async function create() {
if (!form.value.username.trim()) {
ElMessage.warning(t('err.username_required'));
return;
}
try {
await api.post('/agent/players', form.value); await api.post('/agent/players', form.value);
ElMessage.success(t('msg.player_created')); ElMessage.success(t('msg.player_created'));
form.value.username = '';
load(); load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.create_failed'));
}
} }
async function deposit() { function openTransfer(type: 'deposit' | 'withdraw', row: PlayerRow) {
depositForm.value.requestId = `dep-${Date.now()}`; transferType.value = type;
await api.post(`/agent/players/${depositForm.value.playerId}/deposit`, { transferTarget.value = row;
amount: depositForm.value.amount, transferAmount.value = 100;
requestId: depositForm.value.requestId, transferVisible.value = true;
}); }
async function submitTransfer() {
if (!transferTarget.value) return;
if (transferAmount.value <= 0) {
ElMessage.warning(t('msg.amount_gt_zero'));
return;
}
const playerId = transferTarget.value.id;
const amount = transferAmount.value;
transferLoading.value = true;
try {
const requestId = `${transferType.value === 'deposit' ? 'dep' : 'wd'}-${playerId}-${Date.now()}`;
if (transferType.value === 'deposit') {
await api.post(`/agent/players/${playerId}/deposit`, { amount, requestId });
ElMessage.success(t('msg.topup_ok')); ElMessage.success(t('msg.topup_ok'));
} else {
await api.post(`/agent/players/${playerId}/withdraw`, { amount, requestId });
ElMessage.success(t('msg.withdraw_ok'));
}
transferVisible.value = false;
load(); load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.transfer_failed'));
} finally {
transferLoading.value = false;
}
} }
async function withdraw(playerId: string, amount: number) { function transferTitle() {
await api.post(`/agent/players/${playerId}/withdraw`, { const name = transferTarget.value?.username ?? '';
amount, return transferType.value === 'deposit'
requestId: `wd-${Date.now()}`, ? t('agent_portal.transfer_title_deposit', { name })
}); : t('agent_portal.transfer_title_withdraw', { name });
ElMessage.success(t('msg.withdraw_ok'));
load();
} }
</script> </script>
@@ -52,80 +96,94 @@ async function withdraw(playerId: string, amount: number) {
</div> </div>
<el-card class="tool-card" shadow="never"> <el-card class="tool-card" shadow="never">
<div class="tool-row">
<div class="tool-section">
<div class="tool-section-title">{{ t('agent_portal.create_player_section') }}</div> <div class="tool-section-title">{{ t('agent_portal.create_player_section') }}</div>
<el-form inline> <el-form inline>
<el-form-item :label="t('user.col.username')"> <el-form-item :label="t('user.col.username')">
<el-input v-model="form.username" :placeholder="t('agent_portal.username_ph')" style="width: 150px" /> <el-input v-model="form.username" :placeholder="t('agent_portal.username_ph')" style="width: 160px" />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" @click="create">{{ t('agent_portal.create_player_btn') }}</el-button> <el-button type="primary" @click="create">{{ t('agent_portal.create_player_btn') }}</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</div>
<div class="tool-divider" />
<div class="tool-section">
<div class="tool-section-title">{{ t('agent_portal.deposit_section') }}</div>
<el-form inline>
<el-form-item :label="t('user.field.player_id')">
<el-input v-model="depositForm.playerId" :placeholder="t('agent_portal.player_id_ph')" style="width: 110px" />
</el-form-item>
<el-form-item :label="t('user.field.amount')">
<el-input-number v-model="depositForm.amount" :min="1" style="width: 130px" />
</el-form-item>
<el-form-item>
<el-button type="success" @click="deposit">{{ t('common.topup') }}</el-button>
</el-form-item>
</el-form>
</div>
</div>
</el-card> </el-card>
<el-card class="data-card" shadow="never"> <el-card class="data-card" shadow="never">
<div class="table-wrap"> <div class="table-wrap">
<el-table :data="players" stripe> <el-table :data="players" stripe>
<el-table-column prop="id" :label="t('common.col_id')" width="80" /> <el-table-column prop="id" :label="t('common.col_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="120" />
<el-table-column :label="t('user.field.available')" min-width="100" align="right"> <el-table-column :label="t('user.field.available')" min-width="100" align="right">
<template #default="{ row }"> <template #default="{ row }">
<template v-if="(row as { wallet?: { availableBalance: string } }).wallet?.availableBalance != null"> <template v-if="row.wallet?.availableBalance != null">
<el-tooltip <el-tooltip :content="formatAmountFull(row.wallet.availableBalance)" placement="top">
:content="formatAmountFull((row as { wallet: { availableBalance: string } }).wallet.availableBalance)" <span>{{ formatAmount(row.wallet.availableBalance) }}</span>
placement="top"
>
<span>{{ formatAmount((row as { wallet: { availableBalance: string } }).wallet.availableBalance) }}</span>
</el-tooltip> </el-tooltip>
</template> </template>
<span v-else></span> <span v-else></span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="t('common.actions')" width="120" align="center"> <el-table-column :label="t('common.actions')" width="168" align="center" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button size="small" type="warning" plain @click="withdraw((row as { id: string }).id, 50)"> <el-button size="small" type="success" link @click="openTransfer('deposit', row)">
{{ t('agent_portal.withdraw_btn', { amount: 50 }) }} {{ t('common.topup') }}
</el-button>
<el-button size="small" type="warning" link @click="openTransfer('withdraw', row)">
{{ t('agent_portal.withdraw_btn_label') }}
</el-button> </el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</div> </div>
</el-card> </el-card>
<el-dialog v-model="transferVisible" :title="transferTitle()" width="360px" destroy-on-close>
<el-form label-width="72px">
<el-form-item :label="t('common.col_id')">
<span>{{ transferTarget?.id }}</span>
</el-form-item>
<el-form-item :label="t('user.field.amount')">
<el-input-number
v-model="transferAmount"
:min="0.01"
:step="10"
:precision="2"
style="width: 100%"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="transferVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="transferLoading" @click="submitTransfer">
{{ t('common.confirm') }}
</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.page-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 20px; } .page-header {
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; }
.page-desc { font-size: 13px; color: #3a3a3a; }
.tool-card { margin-bottom: 16px; border-radius: 12px; }
.data-card { border-radius: 12px; }
.tool-row {
display: flex; display: flex;
gap: 0; align-items: baseline;
align-items: flex-start; gap: 12px;
margin-bottom: 20px;
}
.page-title {
font-size: 20px;
font-weight: 700;
color: #e0e0e0;
}
.page-desc {
font-size: 13px;
color: #3a3a3a;
}
.tool-card {
margin-bottom: 16px;
border-radius: 12px;
}
.data-card {
border-radius: 12px;
} }
.tool-section { flex: 1; padding-right: 24px; }
.tool-section-title { .tool-section-title {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
@@ -134,10 +192,4 @@ async function withdraw(playerId: string, amount: number) {
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.tool-divider {
width: 1px;
background: #eee;
align-self: stretch;
margin: 0 24px 0 0;
}
</style> </style>

View File

@@ -31,6 +31,8 @@ export interface PlayerEditForm {
loginFailCount: number; loginFailCount: number;
phone: string; phone: string;
email: string; email: string;
managedPassword: string | null;
newPassword: string;
} }
export interface PlayerRow { export interface PlayerRow {
@@ -42,6 +44,7 @@ export interface PlayerRow {
parentUsername: string | null; parentUsername: string | null;
phone: string | null; phone: string | null;
email: string | null; email: string | null;
managedPassword: string | null;
availableBalance: string; availableBalance: string;
frozenBalance: string; frozenBalance: string;
lastLoginAt: string | null; lastLoginAt: string | null;
@@ -90,6 +93,8 @@ export function emptyPlayerEditForm(): PlayerEditForm {
loginFailCount: 0, loginFailCount: 0,
phone: '', phone: '',
email: '', email: '',
managedPassword: null,
newPassword: '',
}; };
} }
@@ -110,6 +115,8 @@ export function editFormFromDetail(d: PlayerDetail): PlayerEditForm {
loginFailCount: d.loginFailCount, loginFailCount: d.loginFailCount,
phone: d.phone ?? '', phone: d.phone ?? '',
email: d.email ?? '', email: d.email ?? '',
managedPassword: d.managedPassword ?? null,
newPassword: '',
}; };
} }

View File

@@ -11,7 +11,7 @@
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
"db:generate": "prisma generate", "db:generate": "prisma generate",
"db:migrate": "prisma migrate dev", "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:seed": "ts-node prisma/seed.ts",
"db:studio": "prisma studio" "db:studio": "prisma studio"
}, },

View File

@@ -0,0 +1,2 @@
-- AlterTable: user_preferences 增加头像(内置球员 key
ALTER TABLE "user_preferences" ADD COLUMN IF NOT EXISTS "avatar_key" VARCHAR(128);

View File

@@ -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);

View File

@@ -57,6 +57,10 @@ model UserPreference {
locale String @default("en-US") @db.VarChar(10) locale String @default("en-US") @db.VarChar(10)
phone String? @db.VarChar(32) phone String? @db.VarChar(32)
email String? @db.VarChar(128) 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") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")

View File

@@ -579,7 +579,7 @@ async function main() {
parentId: agent1.id, parentId: agent1.id,
auth: { create: { passwordHash: playerHash } }, auth: { create: { passwordHash: playerHash } },
wallet: { create: { availableBalance: 1000 } }, wallet: { create: { availableBalance: 1000 } },
preferences: { create: { locale: 'zh-CN' } }, preferences: { create: { locale: 'zh-CN', managedPassword: 'Player@123' } },
}, },
update: {}, update: {},
}); });

View File

@@ -4,6 +4,7 @@ import { ScheduleModule } from '@nestjs/schedule';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD } from '@nestjs/core';
import { JwtAuthGuard } from './domains/identity/guards'; import { JwtAuthGuard } from './domains/identity/guards';
import { PrismaModule } from './shared/prisma/prisma.module'; import { PrismaModule } from './shared/prisma/prisma.module';
import { SystemConfigModule } from './shared/config/system-config.module';
import { IdentityModule } from './domains/identity/identity.module'; import { IdentityModule } from './domains/identity/identity.module';
import { AgentsModule } from './domains/agent/agents.module'; import { AgentsModule } from './domains/agent/agents.module';
import { WalletModule } from './domains/ledger/wallet.module'; import { WalletModule } from './domains/ledger/wallet.module';
@@ -21,6 +22,7 @@ import { AgentPortalModule } from './applications/agent/agent-portal.module';
ConfigModule.forRoot({ isGlobal: true }), ConfigModule.forRoot({ isGlobal: true }),
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
PrismaModule, PrismaModule,
SystemConfigModule,
IdentityModule, IdentityModule,
AgentsModule, AgentsModule,
WalletModule, WalletModule,

View File

@@ -29,6 +29,7 @@ import { AuditService } from '../../domains/operations/audit/audit.service';
import { BetsService } from '../../domains/betting/bets.service'; import { BetsService } from '../../domains/betting/bets.service';
import { PrismaService } from '../../shared/prisma/prisma.service'; import { PrismaService } from '../../shared/prisma/prisma.service';
import { AdminDashboardService } from './admin-dashboard.service'; import { AdminDashboardService } from './admin-dashboard.service';
import { SystemConfigService } from '../../shared/config/system-config.service';
import { import {
IsString, IsString,
IsNumber, IsNumber,
@@ -127,6 +128,25 @@ class UpdatePlayerAdminDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
parentId?: string; parentId?: string;
@IsOptional()
@IsString()
username?: string;
@IsOptional()
@IsString()
@MinLength(8)
password?: string;
}
class PlayerAccountSettingsDto {
@IsOptional()
@IsBoolean()
allowPasswordChange?: boolean;
@IsOptional()
@IsBoolean()
allowUsernameChange?: boolean;
} }
class CreateAgentAdminDto { class CreateAgentAdminDto {
@@ -442,6 +462,7 @@ export class AdminController {
private bets: BetsService, private bets: BetsService,
private prisma: PrismaService, private prisma: PrismaService,
private readonly dashboardService: AdminDashboardService, private readonly dashboardService: AdminDashboardService,
private systemConfig: SystemConfigService,
) {} ) {}
@Get('dashboard') @Get('dashboard')
@@ -450,6 +471,28 @@ export class AdminController {
return jsonResponse(overview); 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') @Get('users')
async listUsers( async listUsers(
@Query('page') page?: string, @Query('page') page?: string,

View File

@@ -13,6 +13,7 @@ import { JwtAuthGuard, PlayerGuard } from '../../domains/identity/guards';
import { CurrentUser } from '../../shared/common/decorators'; import { CurrentUser } from '../../shared/common/decorators';
import { jsonResponse } from '../../shared/common/filters'; import { jsonResponse } from '../../shared/common/filters';
import { UsersService } from '../../domains/identity/users.service'; import { UsersService } from '../../domains/identity/users.service';
import { SystemConfigService } from '../../shared/config/system-config.service';
import { WalletService } from '../../domains/ledger/wallet.service'; import { WalletService } from '../../domains/ledger/wallet.service';
import { MatchesService } from '../../domains/catalog/matches.service'; import { MatchesService } from '../../domains/catalog/matches.service';
import { OutrightService } from '../../domains/catalog/outright.service'; import { OutrightService } from '../../domains/catalog/outright.service';
@@ -72,6 +73,14 @@ class UpdateProfileDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
email?: string; email?: string;
@IsOptional()
@IsString()
avatarKey?: string;
@IsOptional()
@IsString()
username?: string;
} }
@ApiTags('Player') @ApiTags('Player')
@@ -87,12 +96,39 @@ export class PlayerController {
private bets: BetsService, private bets: BetsService,
private content: ContentService, private content: ContentService,
private cashback: CashbackService, private cashback: CashbackService,
private systemConfig: SystemConfigService,
) {} ) {}
private async formatPlayerProfile(user: NonNullable<Awaited<ReturnType<UsersService['findById']>>>) {
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') @Get('profile')
async profile(@CurrentUser('id') userId: bigint) { async profile(@CurrentUser('id') userId: bigint) {
const user = await this.users.findById(userId); const user = await this.users.findById(userId);
return jsonResponse(user); if (!user) return jsonResponse(null);
return jsonResponse(await this.formatPlayerProfile(user));
} }
@Post('language') @Post('language')
@@ -104,7 +140,8 @@ export class PlayerController {
@Patch('profile') @Patch('profile')
async updateProfile(@CurrentUser('id') userId: bigint, @Body() dto: UpdateProfileDto) { async updateProfile(@CurrentUser('id') userId: bigint, @Body() dto: UpdateProfileDto) {
const user = await this.users.updateProfile(userId, dto); const user = await this.users.updateProfile(userId, dto);
return jsonResponse(user); if (!user) return jsonResponse(null);
return jsonResponse(await this.formatPlayerProfile(user));
} }
@Get('home') @Get('home')

View File

@@ -626,6 +626,7 @@ export class AgentsService {
locale, locale,
phone: data.phone?.trim() || null, phone: data.phone?.trim() || null,
email: data.email?.trim() || null, email: data.email?.trim() || null,
managedPassword: data.password,
}, },
}); });

View File

@@ -3,6 +3,7 @@ import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcryptjs'; import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../shared/prisma/prisma.service'; import { PrismaService } from '../../shared/prisma/prisma.service';
import { SystemConfigService } from '../../shared/config/system-config.service';
const MAX_LOGIN_FAILS = 5; const MAX_LOGIN_FAILS = 5;
const LOCK_DURATION_MS = 15 * 60 * 1000; const LOCK_DURATION_MS = 15 * 60 * 1000;
@@ -20,6 +21,7 @@ export class AuthService {
private prisma: PrismaService, private prisma: PrismaService,
private jwt: JwtService, private jwt: JwtService,
private config: ConfigService, private config: ConfigService,
private systemConfig: SystemConfigService,
) {} ) {}
/** 平台管理员 / 代理统一登录(按 userType 签发对应 JWT */ /** 平台管理员 / 代理统一登录(按 userType 签发对应 JWT */
@@ -107,6 +109,11 @@ export class AuthService {
const auth = await this.prisma.userAuth.findUnique({ where: { userId } }); const auth = await this.prisma.userAuth.findUnique({ where: { userId } });
if (!auth) throw new UnauthorizedException('User not found'); 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); const valid = await bcrypt.compare(oldPassword, auth.passwordHash);
if (!valid) throw new UnauthorizedException('Invalid old password'); if (!valid) throw new UnauthorizedException('Invalid old password');
@@ -115,6 +122,10 @@ export class AuthService {
where: { userId }, where: { userId },
data: { passwordHash: hash }, data: { passwordHash: hash },
}); });
await this.prisma.userPreference.updateMany({
where: { userId },
data: { managedPassword: null },
});
return { success: true }; return { success: true };
} }

View File

@@ -1,6 +1,8 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { SUPPORTED_LOCALES } from '@thebet365/shared'; import * as bcrypt from 'bcryptjs';
import { SUPPORTED_LOCALES, isValidAvatarKey } 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 { AgentsService } from '../agent/agents.service'; import { AgentsService } from '../agent/agents.service';
export type PlayerListFilters = { export type PlayerListFilters = {
@@ -14,6 +16,7 @@ export class UsersService {
constructor( constructor(
private prisma: PrismaService, private prisma: PrismaService,
private agents: AgentsService, private agents: AgentsService,
private systemConfig: SystemConfigService,
) {} ) {}
private formatPlayerRow( private formatPlayerRow(
@@ -26,7 +29,11 @@ export class UsersService {
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
wallet?: { availableBalance: { toString(): string }; frozenBalance: { toString(): string } } | null; 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; parent?: { username: string } | null;
auth?: { lastLoginAt: Date | null } | null; auth?: { lastLoginAt: Date | null } | null;
}, },
@@ -41,6 +48,7 @@ export class UsersService {
parentUsername: u.parent?.username ?? null, parentUsername: u.parent?.username ?? null,
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,
availableBalance: u.wallet?.availableBalance?.toString() ?? '0', availableBalance: u.wallet?.availableBalance?.toString() ?? '0',
frozenBalance: u.wallet?.frozenBalance?.toString() ?? '0', frozenBalance: u.wallet?.frozenBalance?.toString() ?? '0',
lastLoginAt: u.auth?.lastLoginAt ?? null, lastLoginAt: u.auth?.lastLoginAt ?? null,
@@ -81,13 +89,61 @@ export class UsersService {
}); });
} }
async updateProfile(userId: bigint, data: { phone?: string; email?: string }) { async updateProfile(
const phone = data.phone?.trim() || null; userId: bigint,
const email = data.email?.trim() || null; 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({ await this.prisma.userPreference.upsert({
where: { userId }, where: { userId },
create: { userId, phone, email }, create: {
update: { phone, email }, userId,
phone: phone ?? null,
email: email ?? null,
...(avatarKey !== undefined ? { avatarKey } : {}),
},
update: {
...(phone !== undefined ? { phone } : {}),
...(email !== undefined ? { email } : {}),
...(avatarKey !== undefined ? { avatarKey } : {}),
},
}); });
return this.findById(userId); return this.findById(userId);
} }
@@ -195,10 +251,13 @@ export class UsersService {
phone?: string; phone?: string;
email?: string; email?: string;
parentId?: string | null; parentId?: string | null;
username?: string;
password?: string;
}, },
) { ) {
const user = await this.prisma.user.findFirst({ const user = await this.prisma.user.findFirst({
where: { id: playerId, userType: 'PLAYER', deletedAt: null }, where: { id: playerId, userType: 'PLAYER', deletedAt: null },
include: { auth: true },
}); });
if (!user) throw new NotFoundException('玩家不存在'); if (!user) throw new NotFoundException('玩家不存在');
@@ -206,6 +265,35 @@ export class UsersService {
throw new BadRequestException('无效状态'); 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) { if (data.status) {
await this.prisma.user.update({ await this.prisma.user.update({
where: { id: playerId }, where: { id: playerId },
@@ -253,25 +341,43 @@ export class UsersService {
}); });
} }
if (data.phone !== undefined || data.email !== undefined || data.locale) { const prefPatch: {
const phone = data.phone !== undefined ? data.phone?.trim() || null : undefined; locale?: string;
const email = data.email !== undefined ? data.email?.trim() || null : undefined; 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({ await this.prisma.userPreference.upsert({
where: { userId: playerId }, where: { userId: playerId },
create: { create: {
userId: playerId, userId: playerId,
locale: data.locale ?? user.locale, locale: data.locale ?? user.locale,
phone: phone ?? null, phone: prefPatch.phone ?? null,
email: email ?? null, email: prefPatch.email ?? null,
},
update: {
...(data.locale ? { locale: data.locale } : {}),
...(phone !== undefined ? { phone } : {}),
...(email !== undefined ? { email } : {}),
}, },
update: prefPatch,
}); });
} }
return this.getPlayerAdminDetail(playerId); 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 },
});
}
}
} }

View File

@@ -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 {}

View File

@@ -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<boolean> {
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<PlayerAccountSettings> {
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<PlayerAccountSettings>) {
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();
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

View File

@@ -0,0 +1,125 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import PlayerAvatarPicker from './PlayerAvatarPicker.vue';
const props = defineProps<{
open: boolean;
modelValue: string | null;
}>();
const emit = defineEmits<{
close: [];
confirm: [value: string | null];
}>();
const { t } = useI18n();
const draft = ref<string | null>(null);
watch(
() => props.open,
(visible) => {
if (visible) draft.value = props.modelValue;
},
);
function close() {
emit('close');
}
function confirm() {
emit('confirm', draft.value);
}
</script>
<template>
<Teleport to="body">
<div v-if="open" class="overlay" @click.self="close">
<div class="modal" role="dialog" aria-modal="true" :aria-label="t('profile.avatar')">
<button type="button" class="close-x" :aria-label="t('bet.cancel')" @click="close"></button>
<h3 class="title">{{ t('profile.avatar') }}</h3>
<PlayerAvatarPicker v-model="draft" />
<div class="actions">
<button type="button" class="btn-cancel" @click="close">{{ t('bet.cancel') }}</button>
<button type="button" class="btn-confirm btn-gold-outline" @click="confirm">
{{ t('profile.avatar_confirm') }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.overlay {
position: fixed;
inset: 0;
z-index: 210;
background: rgba(0, 0, 0, 0.72);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
backdrop-filter: blur(4px);
}
.modal {
position: relative;
width: 100%;
max-width: 360px;
max-height: 85vh;
overflow-y: auto;
background: linear-gradient(165deg, #1a1810 0%, #121212 45%, #0a0a0a 100%);
border: 1px solid var(--border-gold-soft);
border-radius: var(--radius);
padding: 16px 14px 14px;
box-shadow: var(--shadow), 0 0 24px rgba(212, 175, 55, 0.08);
}
.close-x {
position: absolute;
top: 10px;
right: 10px;
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.35);
color: var(--text-muted);
font-size: 12px;
line-height: 1;
padding: 0;
}
.title {
margin: 0 28px 12px 0;
font-size: 15px;
font-weight: 800;
color: var(--primary-light);
}
.actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.btn-cancel,
.btn-confirm {
flex: 1;
min-height: 40px;
border-radius: 6px;
font-size: 13px;
font-weight: 800;
}
.btn-cancel {
border: 1px solid var(--border);
background: #0a0a0a;
color: var(--text-muted);
}
.btn-confirm {
border: none;
}
</style>

View File

@@ -0,0 +1,162 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { BUILTIN_PLAYERS, playerAvatarUrl } from '@thebet365/shared';
const props = defineProps<{
modelValue: string | null;
}>();
const emit = defineEmits<{
'update:modelValue': [value: string | null];
}>();
const { t } = useI18n();
const keyword = ref('');
const filtered = computed(() => {
const q = keyword.value.trim().toLowerCase();
if (!q) return BUILTIN_PLAYERS;
return BUILTIN_PLAYERS.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.country.toLowerCase().includes(q) ||
p.position.toLowerCase().includes(q),
);
});
function select(key: string) {
emit('update:modelValue', props.modelValue === key ? null : key);
}
</script>
<template>
<div class="picker">
<div class="picker-head">
<label class="picker-label">{{ t('profile.avatar') }}</label>
<input
v-model="keyword"
type="search"
class="picker-search"
:placeholder="t('profile.avatar_search')"
/>
</div>
<div class="picker-grid">
<button
v-for="player in filtered"
:key="player.id"
type="button"
class="picker-item"
:class="{ active: modelValue === player.id }"
@click="select(player.id)"
>
<img :src="playerAvatarUrl(player.id) ?? ''" :alt="player.name" class="picker-photo" />
<span class="picker-name">{{ player.name }}</span>
<span class="picker-meta">{{ player.position }} · {{ player.country }}</span>
</button>
</div>
<p v-if="!filtered.length" class="picker-empty">{{ t('profile.avatar_empty') }}</p>
</div>
</template>
<style scoped>
.picker {
margin-bottom: 12px;
}
.picker-head {
margin-bottom: 10px;
}
.picker-label {
display: block;
font-size: 11px;
color: var(--text-muted);
font-weight: 600;
margin-bottom: 6px;
}
.picker-search {
width: 100%;
padding: 8px 10px;
border-radius: 6px;
border: 1px solid var(--border);
background: #0a0a0a;
color: var(--text);
font-size: 13px;
}
.picker-search:focus {
outline: none;
border-color: var(--border-gold-soft);
}
.picker-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
max-height: 280px;
overflow-y: auto;
padding-right: 2px;
}
.picker-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px 4px 6px;
border-radius: 8px;
border: 1px solid var(--border);
background: #0a0a0a;
cursor: pointer;
min-width: 0;
}
.picker-item.active {
border-color: var(--border-gold-soft);
box-shadow: 0 0 0 1px rgba(212, 175, 55, 0.18);
background: rgba(212, 175, 55, 0.08);
}
.picker-photo {
width: 52px;
height: 52px;
border-radius: 50%;
object-fit: cover;
object-position: top;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.picker-name {
width: 100%;
font-size: 10px;
font-weight: 700;
color: var(--text);
text-align: center;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.picker-meta {
width: 100%;
font-size: 9px;
color: var(--text-muted);
text-align: center;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.picker-empty {
margin: 8px 0 0;
font-size: 12px;
color: var(--text-muted);
text-align: center;
}
</style>

View File

@@ -1,17 +1,25 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'; import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { playerAvatarUrl, randomAvatarKey } from '@thebet365/shared';
import { useAuthStore } from '../stores/auth'; import { useAuthStore } from '../stores/auth';
import { usePlayerProfile } from '../composables/usePlayerProfile';
const { t } = useI18n(); const { t } = useI18n();
const auth = useAuthStore(); const auth = useAuthStore();
const router = useRouter(); const router = useRouter();
const { avatarUrl, loadProfile } = usePlayerProfile();
const open = ref(false); const open = ref(false);
const initial = computed(() => { const displayAvatarUrl = computed(() => {
const name = auth.user?.username ?? '?'; if (avatarUrl.value) return avatarUrl.value;
return name.charAt(0).toUpperCase(); const seed = auth.user?.username;
return seed ? playerAvatarUrl(randomAvatarKey(seed)) : null;
});
onMounted(() => {
void loadProfile();
}); });
function toggle() { function toggle() {
@@ -37,7 +45,7 @@ function logout() {
<template> <template>
<div class="avatar-wrap"> <div class="avatar-wrap">
<button type="button" class="avatar-btn" :aria-expanded="open" @click="toggle"> <button type="button" class="avatar-btn" :aria-expanded="open" @click="toggle">
<span class="avatar-letter">{{ initial }}</span> <img v-if="displayAvatarUrl" :src="displayAvatarUrl" alt="" class="avatar-img" />
</button> </button>
<div v-if="open" class="avatar-menu"> <div v-if="open" class="avatar-menu">
@@ -68,6 +76,15 @@ function logout() {
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
flex-shrink: 0; flex-shrink: 0;
overflow: hidden;
padding: 0;
}
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: top;
} }
.avatar-letter { .avatar-letter {

View File

@@ -9,6 +9,8 @@ export interface PlayerHomeMatch {
id: string; id: string;
homeTeamName: string; homeTeamName: string;
awayTeamName: string; awayTeamName: string;
homeTeamCode?: string;
awayTeamCode?: string;
startTime: string; startTime: string;
isHot?: boolean; isHot?: boolean;
} }

View File

@@ -0,0 +1,143 @@
import { ref, computed } from 'vue';
import { isValidAvatarKey, playerAvatarUrl, randomAvatarKey } from '@thebet365/shared';
import api from '../api';
type ProfileData = {
id?: string | number;
username?: string;
locale?: string;
preferences?: {
phone?: string | null;
email?: string | null;
avatarKey?: string | null;
allowPasswordChange?: boolean;
allowUsernameChange?: boolean;
viewablePassword?: string | null;
};
wallet?: { availableBalance: string; frozenBalance: string };
};
const AVATAR_CACHE_PREFIX = 'player_avatar_key:';
const profileRaw = ref<ProfileData | null>(null);
const loading = ref(false);
let loadPromise: Promise<void> | null = null;
let assigningDefault = false;
function profileSeed(profile: ProfileData | null): string {
if (!profile) return '';
return String(profile.id ?? profile.username ?? '');
}
function readCachedAvatarKey(seed: string): string | null {
if (!seed) return null;
try {
const key = localStorage.getItem(`${AVATAR_CACHE_PREFIX}${seed}`);
return key && isValidAvatarKey(key) ? key : null;
} catch {
return null;
}
}
function writeCachedAvatarKey(seed: string, key: string) {
if (!seed || !key) return;
try {
localStorage.setItem(`${AVATAR_CACHE_PREFIX}${seed}`, key);
} catch {
/* ignore */
}
}
function applyAvatarKey(key: string | null) {
if (!profileRaw.value) {
profileRaw.value = { preferences: { avatarKey: key } };
return;
}
profileRaw.value = {
...profileRaw.value,
preferences: {
...profileRaw.value.preferences,
avatarKey: key,
},
};
}
async function ensureDefaultAvatar() {
if (assigningDefault || !profileRaw.value) return;
const seed = profileSeed(profileRaw.value);
const current =
profileRaw.value.preferences?.avatarKey ?? readCachedAvatarKey(seed);
if (current && isValidAvatarKey(current)) {
applyAvatarKey(current);
return;
}
assigningDefault = true;
const key = randomAvatarKey(seed);
try {
try {
await api.patch('/player/profile', { avatarKey: key });
} catch {
/* 数据库未迁移等情况仍展示本地头像 */
}
applyAvatarKey(key);
writeCachedAvatarKey(seed, key);
} finally {
assigningDefault = false;
}
}
async function loadProfile(force = false) {
if (loadPromise) return loadPromise;
if (!force && profileRaw.value) {
await ensureDefaultAvatar();
return;
}
loadPromise = (async () => {
loading.value = true;
try {
const { data } = await api.get('/player/profile');
profileRaw.value = data.data ?? null;
await ensureDefaultAvatar();
} finally {
loading.value = false;
loadPromise = null;
}
})();
return loadPromise;
}
const avatarKey = computed(() => {
const saved = profileRaw.value?.preferences?.avatarKey;
if (saved && isValidAvatarKey(saved)) return saved;
const seed = profileSeed(profileRaw.value);
if (!seed) return null;
const cached = readCachedAvatarKey(seed);
if (cached) return cached;
return randomAvatarKey(seed);
});
const avatarUrl = computed(() => playerAvatarUrl(avatarKey.value));
function setAvatarKey(key: string | null) {
applyAvatarKey(key);
const seed = profileSeed(profileRaw.value);
if (key && seed) writeCachedAvatarKey(seed, key);
}
export function usePlayerProfile() {
return {
profileRaw,
loading,
avatarKey,
avatarUrl,
loadProfile,
setAvatarKey,
};
}

View File

@@ -13,6 +13,7 @@ import BottomNavIcon from '../components/BottomNavIcon.vue';
import { computed, onMounted, watch } from 'vue'; import { computed, onMounted, watch } from 'vue';
import { usePlayerHome } from '../composables/usePlayerHome'; import { usePlayerHome } from '../composables/usePlayerHome';
import { useOnLocaleChange } from '../composables/useOnLocaleChange'; import { useOnLocaleChange } from '../composables/useOnLocaleChange';
import { usePlayerProfile } from '../composables/usePlayerProfile';
const { t } = useI18n(); const { t } = useI18n();
const auth = useAuthStore(); const auth = useAuthStore();
@@ -22,6 +23,7 @@ const slip = useBetSlipStore();
const showAnnouncement = computed(() => !route.path.startsWith('/profile')); const showAnnouncement = computed(() => !route.path.startsWith('/profile'));
const { announcements, load: loadPlayerHome } = usePlayerHome(); const { announcements, load: loadPlayerHome } = usePlayerHome();
const { loadProfile } = usePlayerProfile();
useOnLocaleChange(loadPlayerHome); useOnLocaleChange(loadPlayerHome);
@@ -32,8 +34,12 @@ onMounted(() => {
watch( watch(
() => auth.token, () => auth.token,
(token) => { (token) => {
if (token) void loadPlayerHome(); if (token) {
void loadPlayerHome();
void loadProfile(true);
}
}, },
{ immediate: true },
); );
</script> </script>

View File

@@ -174,6 +174,20 @@ const i18n = createI18n({
profile: { profile: {
edit: '修改资料', edit: '修改资料',
language: '语言', 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: '手机号', phone: '手机号',
email: '邮箱', email: '邮箱',
phone_placeholder: '请输入手机号', phone_placeholder: '请输入手机号',
@@ -193,6 +207,10 @@ const i18n = createI18n({
password_failed: '密码修改失败', password_failed: '密码修改失败',
password_mismatch: '两次新密码不一致', password_mismatch: '两次新密码不一致',
password_incomplete: '修改密码需填写当前密码、新密码及确认密码', password_incomplete: '修改密码需填写当前密码、新密码及确认密码',
username_placeholder: '登录账号名',
username_readonly_hint: '账号名称由后台管理,如需修改请联系客服',
username_updated: '账号名称已更新',
password_disabled: '当前账号不允许自行修改密码,请联系客服',
rules_title: '投注规则', rules_title: '投注规则',
rules_p1: '本平台第一版仅支持足球赛前盘不含滚球、Cash Out、改单及系统串关。', rules_p1: '本平台第一版仅支持足球赛前盘不含滚球、Cash Out、改单及系统串关。',
rules_p2: '串关为 2 串 1 至 5 串 1不可同场串关冠军盘、四分盘让球/大小不可进入串关。', rules_p2: '串关为 2 串 1 至 5 串 1不可同场串关冠军盘、四分盘让球/大小不可进入串关。',
@@ -365,6 +383,20 @@ const i18n = createI18n({
profile: { profile: {
edit: 'Edit Profile', edit: 'Edit Profile',
language: 'Language', 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', phone: 'Phone',
email: 'Email', email: 'Email',
phone_placeholder: 'Phone number', phone_placeholder: 'Phone number',
@@ -384,6 +416,10 @@ const i18n = createI18n({
password_failed: 'Password change failed', password_failed: 'Password change failed',
password_mismatch: 'Passwords do not match', password_mismatch: 'Passwords do not match',
password_incomplete: 'Fill current, new and confirm password to change password', 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_title: 'Betting Rules',
rules_p1: 'Football pre-match only in v1. No live betting, Cash Out, bet edits, or system parlays.', rules_p1: 'Football pre-match only in v1. No live betting, Cash Out, bet edits, or system parlays.',
rules_p2: 'Parlays: 25 legs, different matches only. Outright and quarter-ball HDP/O-U are excluded from parlays.', rules_p2: 'Parlays: 25 legs, different matches only. Outright and quarter-ball HDP/O-U are excluded from parlays.',
@@ -562,6 +598,20 @@ const i18n = createI18n({
profile: { profile: {
edit: 'Edit Profil', edit: 'Edit Profil',
language: 'Bahasa', 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', phone: 'Telefon',
email: 'E-mel', email: 'E-mel',
phone_placeholder: 'Nombor telefon', phone_placeholder: 'Nombor telefon',
@@ -581,6 +631,10 @@ const i18n = createI18n({
password_failed: 'Gagal tukar kata laluan', password_failed: 'Gagal tukar kata laluan',
password_mismatch: 'Kata laluan tidak sepadan', password_mismatch: 'Kata laluan tidak sepadan',
password_incomplete: 'Isi kata laluan semasa, baharu dan pengesahan untuk menukar', 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_title: 'Peraturan Pertaruhan',
rules_p1: 'Versi pertama: hanya bola sepak pra-perlawanan. Tiada live, Cash Out, edit pertaruhan atau parlay sistem.', rules_p1: 'Versi pertama: hanya bola sepak pra-perlawanan. Tiada live, Cash Out, edit pertaruhan atau parlay sistem.',
rules_p2: 'Parlay 25 perlawanan, bukan perlawanan sama. Outright dan suku bola HDP/O-U tidak boleh parlay.', rules_p2: 'Parlay 25 perlawanan, bukan perlawanan sama. Outright dan suku bola HDP/O-U tidak boleh parlay.',

View File

@@ -4,14 +4,34 @@ import { useI18n } from 'vue-i18n';
import emptyMatchesImg from '../assets/images/empty-matches.svg'; import emptyMatchesImg from '../assets/images/empty-matches.svg';
import BannerCarousel from '../components/BannerCarousel.vue'; import BannerCarousel from '../components/BannerCarousel.vue';
import { usePlayerHome } from '../composables/usePlayerHome'; import { usePlayerHome } from '../composables/usePlayerHome';
import { teamFlagUrl } from '../utils/teamFlag';
const { t } = useI18n(); const { t, locale } = useI18n();
const router = useRouter(); const router = useRouter();
const { banners, hotMatches, loading } = usePlayerHome(); const { banners, hotMatches, loading } = usePlayerHome();
function goMatch(id: string) { function goMatch(id: string) {
router.push(`/match/${id}`); 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);
}
</script> </script>
<template> <template>
@@ -19,9 +39,23 @@ function goMatch(id: string) {
<BannerCarousel :banners="banners" /> <BannerCarousel :banners="banners" />
<h2 class="section-title">{{ t('home.hot_matches') }}</h2> <h2 class="section-title">{{ t('home.hot_matches') }}</h2>
<div v-for="match in hotMatches" :key="match.id" class="card match-card" @click="goMatch(match.id)"> <div
v-for="match in hotMatches"
:key="match.id"
class="card match-card"
@click="goMatch(match.id)"
>
<div class="match-info">
<div class="match-teams">{{ match.homeTeamName }} vs {{ match.awayTeamName }}</div> <div class="match-teams">{{ match.homeTeamName }} vs {{ match.awayTeamName }}</div>
<div class="match-time">{{ new Date(match.startTime).toLocaleString() }}</div> <div class="match-time">{{ formatKickoff(match.startTime) }}</div>
</div>
<div class="match-flags" aria-hidden="true">
<img v-if="homeFlag(match)" :src="homeFlag(match)" alt="" class="flag" />
<span v-else class="flag-ph"></span>
<span class="vs">VS</span>
<img v-if="awayFlag(match)" :src="awayFlag(match)" alt="" class="flag" />
<span v-else class="flag-ph"></span>
</div>
</div> </div>
<div v-if="!loading && !hotMatches.length" class="empty"> <div v-if="!loading && !hotMatches.length" class="empty">
@@ -32,10 +66,83 @@ function goMatch(id: string) {
</template> </template>
<style scoped> <style scoped>
.match-card { cursor: pointer; transition: border-color 0.2s, box-shadow 0.2s; } .match-card {
.match-card:active { border-color: var(--border-gold-soft); } display: flex;
.match-teams { font-weight: 800; margin-bottom: 8px; font-size: 16px; } align-items: center;
.match-time { font-size: 13px; color: var(--text-muted); font-weight: 500; } justify-content: space-between;
.empty { text-align: center; color: var(--text-muted); padding: 40px 20px; font-weight: 600; } gap: 12px;
.empty-icon { width: 96px; height: 96px; margin-bottom: 14px; } cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
}
.match-card:active {
border-color: var(--border-gold-soft);
}
.match-info {
flex: 1;
min-width: 0;
}
.match-teams {
font-weight: 800;
margin-bottom: 8px;
font-size: 16px;
line-height: 1.3;
}
.match-time {
font-size: 13px;
color: var(--text-muted);
font-weight: 500;
}
.match-flags {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid #2a2a2a;
border-radius: 8px;
}
.flag {
width: 32px;
height: 22px;
object-fit: cover;
border-radius: 3px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
}
.flag-ph {
width: 32px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
opacity: 0.45;
}
.vs {
font-size: 11px;
font-weight: 900;
color: var(--primary-light);
letter-spacing: 0.04em;
}
.empty {
text-align: center;
color: var(--text-muted);
padding: 40px 20px;
font-weight: 600;
}
.empty-icon {
width: 96px;
height: 96px;
margin-bottom: 14px;
}
</style> </style>

View File

@@ -1,15 +1,25 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { playerAvatarUrl, randomAvatarKey } from '@thebet365/shared';
import api from '../api'; import api from '../api';
import PlayerAvatarModal from '../components/PlayerAvatarModal.vue';
import { usePlayerProfile } from '../composables/usePlayerProfile';
import { useAuthStore } from '../stores/auth';
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const auth = useAuthStore();
const { loadProfile, setAvatarKey, profileRaw, avatarUrl, avatarKey } = usePlayerProfile();
const username = ref(''); const username = ref('');
const viewablePassword = ref('');
const passwordVisible = ref(false);
const phone = ref(''); const phone = ref('');
const email = ref(''); const email = ref('');
const avatarModalOpen = ref(false);
const passwordChangeOpen = ref(false);
const oldPassword = ref(''); const oldPassword = ref('');
const newPassword = ref(''); const newPassword = ref('');
const confirmPassword = ref(''); const confirmPassword = ref('');
@@ -17,14 +27,60 @@ const message = ref('');
const error = ref(''); const error = ref('');
const saving = ref(false); const saving = ref(false);
onMounted(async () => { const allowPasswordChange = computed(
const { data } = await api.get('/player/profile'); () => profileRaw.value?.preferences?.allowPasswordChange ?? true,
const user = data.data; );
username.value = user?.username ?? ''; const allowUsernameChange = computed(
() => profileRaw.value?.preferences?.allowUsernameChange ?? false,
);
const passwordDisplay = computed(() => viewablePassword.value || '');
const canTogglePassword = computed(() => !!viewablePassword.value);
const passwordInputType = computed(() =>
passwordVisible.value && canTogglePassword.value ? 'text' : 'password',
);
const displayAvatarUrl = computed(() => {
if (avatarUrl.value) return avatarUrl.value;
const seed = profileRaw.value?.username ?? auth.user?.username;
return seed ? playerAvatarUrl(randomAvatarKey(seed)) : null;
});
function syncFromProfile() {
const user = profileRaw.value;
username.value = user?.username ?? auth.user?.username ?? '';
viewablePassword.value = user?.preferences?.viewablePassword ?? '';
phone.value = user?.preferences?.phone ?? ''; phone.value = user?.preferences?.phone ?? '';
email.value = user?.preferences?.email ?? ''; email.value = user?.preferences?.email ?? '';
}
function togglePasswordVisible() {
if (!canTogglePassword.value) return;
passwordVisible.value = !passwordVisible.value;
}
onMounted(async () => {
await loadProfile(true);
syncFromProfile();
}); });
function openAvatarModal() {
avatarModalOpen.value = true;
}
async function confirmAvatar(key: string | null) {
avatarModalOpen.value = false;
try {
await api.patch('/player/profile', { avatarKey: key });
setAvatarKey(key);
} catch (e: unknown) {
setAvatarKey(key);
error.value =
(e as { response?: { data?: { message?: string } } })?.response?.data?.message ||
t('profile.save_failed');
}
}
function wantsPasswordChange() { function wantsPasswordChange() {
return !!(oldPassword.value || newPassword.value || confirmPassword.value); return !!(oldPassword.value || newPassword.value || confirmPassword.value);
} }
@@ -48,11 +104,21 @@ async function saveAll() {
const parts: string[] = []; const parts: string[] = [];
try { try {
await api.patch('/player/profile', { const profilePayload: { phone?: string; email?: string; username?: string } = {
phone: phone.value.trim() || undefined, phone: phone.value.trim() || undefined,
email: email.value.trim() || undefined, email: email.value.trim() || undefined,
}); };
if (allowUsernameChange.value) {
profilePayload.username = username.value.trim();
}
await api.patch('/player/profile', profilePayload);
if (allowUsernameChange.value && username.value.trim() && auth.user) {
auth.user.username = username.value.trim();
}
parts.push(t('profile.saved')); parts.push(t('profile.saved'));
if (allowUsernameChange.value && profilePayload.username) {
parts.push(t('profile.username_updated'));
}
} catch (e: unknown) { } catch (e: unknown) {
error.value = error.value =
(e as { response?: { data?: { message?: string } } })?.response?.data?.message || (e as { response?: { data?: { message?: string } } })?.response?.data?.message ||
@@ -62,14 +128,22 @@ async function saveAll() {
} }
if (wantsPasswordChange()) { if (wantsPasswordChange()) {
if (!allowPasswordChange.value) {
error.value = t('profile.password_disabled');
saving.value = false;
return;
}
try { try {
await api.post('/player/auth/change-password', { await api.post('/player/auth/change-password', {
oldPassword: oldPassword.value, oldPassword: oldPassword.value,
newPassword: newPassword.value, newPassword: newPassword.value,
}); });
viewablePassword.value = newPassword.value;
passwordVisible.value = false;
oldPassword.value = ''; oldPassword.value = '';
newPassword.value = ''; newPassword.value = '';
confirmPassword.value = ''; confirmPassword.value = '';
passwordChangeOpen.value = false;
parts.push(t('profile.password_changed')); parts.push(t('profile.password_changed'));
} catch (e: unknown) { } catch (e: unknown) {
error.value = error.value =
@@ -96,21 +170,59 @@ function back() {
<h2 class="page-title">{{ t('profile.edit') }}</h2> <h2 class="page-title">{{ t('profile.edit') }}</h2>
</header> </header>
<section class="avatar-card">
<div class="avatar-circle">
<img v-if="displayAvatarUrl" :src="displayAvatarUrl" alt="" class="avatar-img" />
</div>
<button type="button" class="avatar-change-btn" @click="openAvatarModal">
{{ t('profile.avatar_change') }}
</button>
</section>
<form class="form-card" @submit.prevent="saveAll"> <form class="form-card" @submit.prevent="saveAll">
<h3 class="section-title">{{ t('profile.section_account') }}</h3>
<div class="field"> <div class="field">
<label>{{ t('auth.username') }}</label> <label>{{ t('auth.username') }}</label>
<input :value="username" class="readonly" disabled /> <input
</div> v-model="username"
<div class="field"> class="field-input"
<label>{{ t('profile.phone') }}</label> :class="{ readonly: !allowUsernameChange }"
<input v-model="phone" type="tel" class="field-input" :placeholder="t('profile.phone_placeholder')" /> :disabled="!allowUsernameChange"
</div> :placeholder="t('profile.username_placeholder')"
<div class="field"> />
<label>{{ t('profile.email') }}</label> <p v-if="!allowUsernameChange" class="field-hint inline-hint">{{ t('profile.username_readonly_hint') }}</p>
<input v-model="email" type="email" class="field-input" :placeholder="t('profile.email_placeholder')" />
</div> </div>
<p class="field-hint">{{ t('profile.password_optional_hint') }}</p> <div class="field">
<label>{{ t('auth.password') }}</label>
<div class="input-eye-wrap">
<input
:type="passwordInputType"
:value="passwordDisplay"
class="field-input input-with-eye"
readonly
:placeholder="canTogglePassword ? '' : t('profile.password_unavailable')"
/>
<button
type="button"
class="eye-btn"
:disabled="!canTogglePassword"
:aria-label="passwordVisible ? t('profile.hide_password') : t('profile.show_password')"
@click="togglePasswordVisible"
>
{{ passwordVisible ? t('profile.hide_password') : t('profile.show_password') }}
</button>
</div>
<p v-if="!canTogglePassword" class="field-hint inline-hint">{{ t('profile.password_unavailable_hint') }}</p>
</div>
<template v-if="allowPasswordChange">
<button type="button" class="section-toggle compact-toggle" @click="passwordChangeOpen = !passwordChangeOpen">
<span>{{ t('profile.change_password') }}</span>
<span class="chevron" :class="{ open: passwordChangeOpen }"></span>
</button>
<div v-show="passwordChangeOpen" class="password-block">
<div class="field"> <div class="field">
<label>{{ t('profile.old_password') }}</label> <label>{{ t('profile.old_password') }}</label>
<input <input
@@ -141,12 +253,34 @@ function back() {
:placeholder="t('profile.confirm_password_placeholder')" :placeholder="t('profile.confirm_password_placeholder')"
/> />
</div> </div>
</div>
</template>
<div class="section-divider" />
<h3 class="section-title">{{ t('profile.section_contact') }}</h3>
<div class="field">
<label>{{ t('profile.phone') }}</label>
<input v-model="phone" type="tel" class="field-input" :placeholder="t('profile.phone_placeholder')" />
</div>
<div class="field field-last">
<label>{{ t('profile.email') }}</label>
<input v-model="email" type="email" class="field-input" :placeholder="t('profile.email_placeholder')" />
</div>
<button type="submit" class="btn-action btn-gold-outline" :disabled="saving"> <button type="submit" class="btn-action btn-gold-outline" :disabled="saving">
{{ t('profile.save') }} {{ t('profile.save') }}
</button> </button>
</form> </form>
<PlayerAvatarModal
:open="avatarModalOpen"
:model-value="avatarKey"
@close="avatarModalOpen = false"
@confirm="confirmAvatar"
/>
<p v-if="message" class="msg ok">{{ message }}</p> <p v-if="message" class="msg ok">{{ message }}</p>
<p v-if="error" class="msg err">{{ error }}</p> <p v-if="error" class="msg err">{{ error }}</p>
</div> </div>
@@ -154,11 +288,14 @@ function back() {
<style scoped> <style scoped>
.edit-page { .edit-page {
padding: 8px 0 12px; padding: 8px 0 20px;
display: flex;
flex-direction: column;
gap: 12px;
} }
.page-head { .page-head {
margin-bottom: 10px; margin-bottom: 0;
} }
.back-btn { .back-btn {
@@ -177,22 +314,150 @@ function back() {
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
.avatar-card,
.form-card { .form-card {
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
padding: 12px; }
.avatar-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px 16px;
}
.avatar-circle {
width: 80px;
height: 80px;
border-radius: 50%;
border: 2px solid var(--border-gold-soft);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(145deg, #2a2210, #141008);
}
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: top;
}
.avatar-change-btn {
padding: 7px 18px;
border-radius: 999px;
border: 1px solid var(--border-gold-soft);
background: rgba(212, 175, 55, 0.08);
color: var(--primary-light);
font-size: 13px;
font-weight: 700;
}
.form-card {
padding: 16px;
}
.section-title {
margin: 0 0 14px;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
}
.section-divider {
height: 1px;
background: var(--border);
margin: 6px 0 12px;
}
.section-toggle {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 0 12px;
background: none;
color: var(--text);
font-size: 14px;
font-weight: 700;
}
.chevron {
color: var(--text-muted);
font-size: 20px;
line-height: 1;
transition: transform 0.15s ease;
}
.chevron.open {
transform: rotate(90deg);
}
.password-block {
padding-bottom: 4px;
} }
.field { .field {
margin-bottom: 10px; margin-bottom: 14px;
}
.field-last {
margin-bottom: 18px;
} }
.field-hint { .field-hint {
font-size: 11px; font-size: 11px;
color: var(--text-muted); color: var(--text-muted);
margin: 4px 0 10px; margin: 6px 0 0;
line-height: 1.4; line-height: 1.45;
}
.inline-hint {
margin-bottom: 0;
}
.input-eye-wrap {
position: relative;
display: flex;
align-items: stretch;
}
.input-with-eye {
padding-right: 52px;
}
.eye-btn {
position: absolute;
right: 0;
top: 0;
bottom: 0;
min-width: 48px;
padding: 0 10px;
border: none;
border-left: 1px solid var(--border);
border-radius: 0 6px 6px 0;
background: rgba(212, 175, 55, 0.06);
color: var(--primary-light);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
}
.eye-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.compact-toggle {
padding: 2px 0 10px;
font-size: 13px;
} }
label { label {
@@ -200,14 +465,14 @@ label {
font-size: 11px; font-size: 11px;
color: var(--text-muted); color: var(--text-muted);
font-weight: 600; font-weight: 600;
margin-bottom: 4px; margin-bottom: 6px;
} }
.field-input, .field-input,
.readonly { .readonly {
display: block; display: block;
width: 100%; width: 100%;
padding: 9px 11px; padding: 10px 12px;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
border-radius: 6px; border-radius: 6px;
@@ -243,7 +508,7 @@ label {
.btn-action { .btn-action {
width: 100%; width: 100%;
margin-top: 4px; margin-top: 4px;
padding: 10px 14px; padding: 12px 14px;
border-radius: 6px; border-radius: 6px;
font-size: 13px; font-size: 13px;
font-weight: 800; font-weight: 800;
@@ -256,7 +521,7 @@ label {
} }
.msg { .msg {
margin-top: 10px; margin-top: 0;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
} }

View File

@@ -13,6 +13,7 @@
"test": "pnpm -r run test", "test": "pnpm -r run test",
"db:generate": "pnpm --filter @thebet365/api db:generate", "db:generate": "pnpm --filter @thebet365/api db:generate",
"db:migrate": "pnpm --filter @thebet365/api db:migrate", "db:migrate": "pnpm --filter @thebet365/api db:migrate",
"db:migrate:deploy": "pnpm --filter @thebet365/api db:migrate:deploy",
"db:seed": "pnpm --filter @thebet365/api db:seed", "db:seed": "pnpm --filter @thebet365/api db:seed",
"db:studio": "pnpm --filter @thebet365/api db:studio" "db:studio": "pnpm --filter @thebet365/api db:studio"
}, },

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,104 @@
export type BuiltinPlayer = {
id: string;
name: string;
position: string;
country: string;
filename: string;
};
const BUILTIN_PLAYER_FILENAMES = [
'何塞·曼努埃尔·洛佩斯-前锋-阿根廷.jpg',
'佩德里-中场-西班牙.jpg',
'卢卡·莫德里奇-中场-克罗地亚.jpg',
'华金·皮克雷斯-中场-乌拉圭.jpg',
'乔治亚·德·阿拉斯凯塔-中场-乌拉圭.jpg',
'乌古尔坎·卡基尔-守門員-土耳其.jpg',
'亚历杭德罗·曾德哈斯-前锋-美国.jpg',
'恩德里克-前锋-巴西.jpg',
'克里斯蒂亚诺·罗纳尔多-前锋-葡萄牙.jpg',
'克里斯蒂安·罗梅罗-后卫-阿根廷.jpg',
'内马尔-前锋-巴西.jpg',
'凯南·耶尔德兹-前锋-土耳其.jpg',
'卡塞米罗-中场-巴西.jpg',
'卢卡斯·帕奎塔-中场-巴西.jpg',
'基利安·姆巴佩-前锋-法国.jpg',
'孟菲斯·德派-前锋-荷兰.jpg',
'奥斯曼·登贝莱-前锋-法国.jpg',
'布鲁诺·吉马良斯-中场-巴西.jpg',
'布鲁诺·费尔南德斯-中场-葡萄牙.jpg',
'布卡约·萨卡-前锋-英格兰.jpg',
'德尼兹·居尔-前锋-土耳其.jpg',
'德尼兹·温达夫-前锋-德国.jpg',
'拉斐尔·迪亚斯·贝洛利-前锋-巴西.jpg',
'拉明·亚马尔-前锋-西班牙.jpg',
'朱利安·阿尔瓦雷斯-前锋-阿根廷.jpg',
'梅西-前锋-阿根廷.jpg',
'迈克尔·奥利塞-前锋-法国.jpg',
'穆罕默德·萨拉赫-前锋-埃及.jpg',
'维尼修斯·儒尼奥尔-前锋-巴西.jpg',
'维克托·哲凯赖什-前锋-瑞典.jpg',
'圣地亚哥·吉梅内斯-前锋-墨西哥.jpg',
'埃德森·阿尔瓦雷斯-后卫-墨西哥.jpg',
'埃贝雷奇·埃泽-中场-英格兰.jpg',
'埃尔林·哈兰德-前锋-挪威.jpg',
'蒂博·库尔图瓦-守門員-比利时.jpg',
'曼努埃尔·诺伊尔-守門員-德国.jpg',
'祖德·贝林厄姆-中场-英格兰.jpg',
'伦纳特·卡尔-中场-德国.jpg',
'费德里科·巴尔韦德-中场-乌拉圭.jpg',
'贾马尔·慕斯拉-中场-德国.jpg',
'路易斯·迪亚斯-前锋-哥伦比亚.jpg',
'阿什拉夫·哈基米-后卫-摩洛哥.jpg',
'阿利松·贝克尔-守門員-巴西.jpg',
'阿尔达·居莱尔-前锋-土耳其.jpg',
'马西斯·拉扬·切尔基-中场-法国.jpg',
'马库斯·拉什福德-前锋-英格兰.jpg',
'哈里·凯恩-前锋-英格兰.jpg',
'尼科·威廉斯-前锋-西班牙.jpg',
'巴勃罗·加维-中场-西班牙.jpg',
'吉列尔莫·奥乔亚-守門員-墨西哥.jpg',
] as const;
function parsePlayerFilename(filename: string): BuiltinPlayer {
const base = filename.replace(/\.jpg$/i, '');
const parts = base.split('-');
const country = parts.pop() ?? '';
const position = parts.pop() ?? '';
const name = parts.join('-');
return { id: base, name, position, country, filename };
}
export const BUILTIN_PLAYERS: BuiltinPlayer[] = BUILTIN_PLAYER_FILENAMES.map(parsePlayerFilename);
const AVATAR_KEY_SET = new Set(BUILTIN_PLAYERS.map((p) => p.id));
export function isValidAvatarKey(key: string | null | undefined): boolean {
if (!key) return true;
return AVATAR_KEY_SET.has(key);
}
export function playerAvatarUrl(key: string | null | undefined): string | null {
if (!key) return null;
const player = BUILTIN_PLAYERS.find((p) => p.id === key);
if (!player) return null;
return `/球员/${player.filename}`;
}
export function getBuiltinPlayer(key: string | null | undefined): BuiltinPlayer | null {
if (!key) return null;
return BUILTIN_PLAYERS.find((p) => p.id === key) ?? null;
}
/** 按 seed 稳定随机,无 seed 时完全随机 */
export function randomAvatarKey(seed?: string | number | null): string {
if (!BUILTIN_PLAYERS.length) return '';
if (seed === undefined || seed === null || seed === '') {
return BUILTIN_PLAYERS[Math.floor(Math.random() * BUILTIN_PLAYERS.length)].id;
}
const text = String(seed);
let hash = 0;
for (let i = 0; i < text.length; i += 1) {
hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
}
return BUILTIN_PLAYERS[hash % BUILTIN_PLAYERS.length].id;
}

View File

@@ -117,6 +117,7 @@ export const PARLAY_MAX_LEGS = 5;
export * from './betting-rules'; export * from './betting-rules';
export * from './locale'; export * from './locale';
export * from './builtinPlayers';
export interface ApiResponse<T = unknown> { export interface ApiResponse<T = unknown> {
success: boolean; success: boolean;