feat(admin,player,api): 玩家账号密码管理与代理上下分
新增玩家头像、可查密码与全局改密/改账号开关;玩家资料页合并账号密码展示;代理直属玩家列表支持自定义上下分。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -51,6 +51,22 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'user.field.login_fail': 'Log masuk gagal',
|
||||
'user.field.phone': 'Telefon',
|
||||
'user.field.email': 'E-mel',
|
||||
'user.field.allow_password_change': 'Benarkan pemain tukar kata laluan',
|
||||
'user.field.allow_username_change': 'Benarkan pemain tukar nama akaun',
|
||||
'user.field.view_password': 'Kata laluan log masuk',
|
||||
'user.field.reset_password': 'Set semula kata laluan',
|
||||
'user.password_not_stored': 'Tiada rekod (pemain telah ubah sendiri)',
|
||||
'user.btn.show_password': 'Lihat',
|
||||
'user.btn.hide_password': 'Sembunyi',
|
||||
'user.ph.reset_password': 'Biarkan kosong untuk kekalkan; nilai baharu boleh dilihat',
|
||||
'user.ph.reset_password_short': 'Biarkan kosong',
|
||||
'user.global_settings': 'Kata laluan & akaun (global)',
|
||||
'user.global_settings_hint': 'Kawal sama ada semua pemain boleh ubah kata laluan/nama akaun dalam app',
|
||||
'user.section.password_mgmt': 'Pengurusan kata laluan',
|
||||
'user.field.current_password': 'Kata laluan semasa',
|
||||
'user.msg.created_with_password': 'Pemain dicipta. Kata laluan: {password}',
|
||||
'user.msg.password_saved': 'Kata laluan dikemas kini: {password}',
|
||||
'user.hint.password_reset_to_view': 'Tiada rekod. Isi Set semula kata laluan di bawah dan simpan untuk lihat di sini.',
|
||||
'user.ph.username_unique': 'Nama log masuk unik',
|
||||
'user.ph.no_agent': 'Tiada (terus platform)',
|
||||
'user.hint.no_agent': 'Biarkan kosong untuk pemain diurus platform',
|
||||
@@ -58,6 +74,10 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'user.hint.deposit_remark': 'Ditulis ke lejar jika baki permulaan > 0',
|
||||
'user.hint.freeze_in_list': 'Beku/nyahbeku dari lajur tindakan senarai',
|
||||
'user.hint.agent_change': 'Kosong = terus platform; perubahan dikira semula kredit ejen',
|
||||
'user.hint.allow_password_change': 'Matikan: semua pemain tidak boleh ubah kata laluan',
|
||||
'user.hint.allow_username_change': 'Hidupkan: semua pemain boleh ubah nama log masuk',
|
||||
'user.hint.view_password': 'Hanya kata laluan cipta/set semula admin; dibersihkan jika pemain ubah sendiri',
|
||||
'user.hint.reset_password': 'Berkuat kuasa serta-merta dan kemas kini kata laluan boleh lihat',
|
||||
'user.btn.create': 'Cipta',
|
||||
'user.btn.save_profile': 'Simpan',
|
||||
'user.btn.confirm_deposit': 'Sahkan tambah baki',
|
||||
@@ -222,6 +242,9 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'agent_portal.agent_username_ph': 'Nama pengguna ejen',
|
||||
'agent_portal.player_id_ph': 'ID pemain',
|
||||
'agent_portal.withdraw_btn': 'Keluarkan {amount}',
|
||||
'agent_portal.withdraw_btn_label': 'Keluarkan',
|
||||
'agent_portal.transfer_title_deposit': 'Tambah baki {name}',
|
||||
'agent_portal.transfer_title_withdraw': 'Keluarkan dari {name}',
|
||||
'msg.agent_sub_created': 'Sub-ejen dicipta',
|
||||
'msg.withdraw_ok': 'Pengeluaran berjaya',
|
||||
|
||||
@@ -241,6 +264,7 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'msg.import_done': 'Import: {imported} ok, {skipped} dilangkau, {failed} gagal / {total} jumlah',
|
||||
'msg.topup_ok': 'Tambah baki berjaya',
|
||||
'msg.topup_failed': 'Tambah baki gagal',
|
||||
'msg.transfer_failed': 'Operasi gagal',
|
||||
'msg.amount_gt_zero': 'Jumlah mesti lebih daripada 0',
|
||||
'msg.credit_zero': 'Pelarasan tidak boleh 0',
|
||||
'msg.credit_adjusted': 'Kredit dikemas kini',
|
||||
|
||||
@@ -51,6 +51,22 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'user.field.login_fail': '登录失败',
|
||||
'user.field.phone': '手机',
|
||||
'user.field.email': '邮箱',
|
||||
'user.field.allow_password_change': '允许玩家改密码',
|
||||
'user.field.allow_username_change': '允许玩家改账号名',
|
||||
'user.field.view_password': '登录密码',
|
||||
'user.field.reset_password': '重置密码',
|
||||
'user.password_not_stored': '未记录(玩家已自行修改或未保存)',
|
||||
'user.btn.show_password': '查看',
|
||||
'user.btn.hide_password': '隐藏',
|
||||
'user.ph.reset_password': '留空则不修改;填写后将更新并可查看',
|
||||
'user.ph.reset_password_short': '留空不修改',
|
||||
'user.global_settings': '密码与账号管理(全局)',
|
||||
'user.global_settings_hint': '控制所有玩家是否可在 App 内改密码、改账号名',
|
||||
'user.section.password_mgmt': '密码管理',
|
||||
'user.field.current_password': '当前密码',
|
||||
'user.msg.created_with_password': '玩家已创建,登录密码:{password}',
|
||||
'user.msg.password_saved': '密码已更新,当前可查密码:{password}',
|
||||
'user.hint.password_reset_to_view': '旧账号暂无记录。请在下方「重置密码」填写新密码并保存,即可在此查看。',
|
||||
'user.ph.username_unique': '登录用户名,唯一',
|
||||
'user.ph.no_agent': '不设置(平台直属玩家)',
|
||||
'user.hint.no_agent': '留空表示不挂靠代理,由平台直接管理',
|
||||
@@ -58,6 +74,10 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'user.hint.deposit_remark': '有初始余额时写入流水备注',
|
||||
'user.hint.freeze_in_list': '冻结/解冻请在列表操作列进行',
|
||||
'user.hint.agent_change': '留空表示平台直属;变更后会重算相关代理已用授信',
|
||||
'user.hint.allow_password_change': '关闭后所有玩家均不可在客户端修改密码',
|
||||
'user.hint.allow_username_change': '开启后所有玩家均可在资料页修改登录账号名',
|
||||
'user.hint.view_password': '仅保存后台创建或重置时的密码;玩家自行改密后会清除',
|
||||
'user.hint.reset_password': '重置后立即生效,并更新上方可查密码',
|
||||
'user.btn.create': '创建',
|
||||
'user.btn.save_profile': '保存资料',
|
||||
'user.btn.confirm_deposit': '确认上分',
|
||||
@@ -222,6 +242,9 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'agent_portal.agent_username_ph': '代理用户名',
|
||||
'agent_portal.player_id_ph': '玩家 ID',
|
||||
'agent_portal.withdraw_btn': '下分 {amount}',
|
||||
'agent_portal.withdraw_btn_label': '下分',
|
||||
'agent_portal.transfer_title_deposit': '给 {name} 上分',
|
||||
'agent_portal.transfer_title_withdraw': '从 {name} 下分',
|
||||
'msg.agent_sub_created': '下级代理已创建',
|
||||
'msg.withdraw_ok': '下分成功',
|
||||
|
||||
@@ -241,6 +264,7 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'msg.import_done': '导入完成:成功 {imported},跳过 {skipped},失败 {failed} / 共 {total}',
|
||||
'msg.topup_ok': '上分成功',
|
||||
'msg.topup_failed': '上分失败',
|
||||
'msg.transfer_failed': '操作失败',
|
||||
'msg.amount_gt_zero': '金额须大于 0',
|
||||
'msg.credit_zero': '调整金额不能为 0',
|
||||
'msg.credit_adjusted': '授信已调整',
|
||||
@@ -403,6 +427,22 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'user.field.login_fail': 'Failed logins',
|
||||
'user.field.phone': 'Phone',
|
||||
'user.field.email': 'Email',
|
||||
'user.field.allow_password_change': 'Allow player password change',
|
||||
'user.field.allow_username_change': 'Allow player username change',
|
||||
'user.field.view_password': 'Login password',
|
||||
'user.field.reset_password': 'Reset password',
|
||||
'user.password_not_stored': 'Not stored (player changed it or never saved)',
|
||||
'user.btn.show_password': 'Show',
|
||||
'user.btn.hide_password': 'Hide',
|
||||
'user.ph.reset_password': 'Leave empty to keep; new value will be viewable',
|
||||
'user.ph.reset_password_short': 'Leave empty to keep',
|
||||
'user.global_settings': 'Password & account (global)',
|
||||
'user.global_settings_hint': 'Controls whether all players can change password or username in the app',
|
||||
'user.section.password_mgmt': 'Password management',
|
||||
'user.field.current_password': 'Current password',
|
||||
'user.msg.created_with_password': 'Player created. Login password: {password}',
|
||||
'user.msg.password_saved': 'Password updated. Viewable password: {password}',
|
||||
'user.hint.password_reset_to_view': 'No stored password. Set one below under Reset password and save to view it here.',
|
||||
'user.ph.username_unique': 'Unique login username',
|
||||
'user.ph.no_agent': 'None (platform direct)',
|
||||
'user.hint.no_agent': 'Leave empty for platform-managed player',
|
||||
@@ -410,6 +450,10 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'user.hint.deposit_remark': 'Written to ledger when initial balance > 0',
|
||||
'user.hint.freeze_in_list': 'Freeze/unfreeze from the list actions',
|
||||
'user.hint.agent_change': 'Empty = platform direct; changes recalc agent credit',
|
||||
'user.hint.allow_password_change': 'When off, no player can change password in the app',
|
||||
'user.hint.allow_username_change': 'When on, all players can change login username in profile',
|
||||
'user.hint.view_password': 'Only passwords set on create/reset; cleared after player self-change',
|
||||
'user.hint.reset_password': 'Takes effect immediately and updates viewable password above',
|
||||
'user.btn.create': 'Create',
|
||||
'user.btn.save_profile': 'Save',
|
||||
'user.btn.confirm_deposit': 'Confirm top-up',
|
||||
@@ -574,6 +618,9 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'agent_portal.agent_username_ph': 'Agent username',
|
||||
'agent_portal.player_id_ph': 'Player ID',
|
||||
'agent_portal.withdraw_btn': 'Withdraw {amount}',
|
||||
'agent_portal.withdraw_btn_label': 'Withdraw',
|
||||
'agent_portal.transfer_title_deposit': 'Top up {name}',
|
||||
'agent_portal.transfer_title_withdraw': 'Withdraw from {name}',
|
||||
'msg.agent_sub_created': 'Sub-agent created',
|
||||
'msg.withdraw_ok': 'Withdrawal successful',
|
||||
|
||||
@@ -593,6 +640,7 @@ export const adminPagesEn: Record<string, string> = {
|
||||
'msg.import_done': 'Import: {imported} ok, {skipped} skipped, {failed} failed / {total} total',
|
||||
'msg.topup_ok': 'Top-up successful',
|
||||
'msg.topup_failed': 'Top-up failed',
|
||||
'msg.transfer_failed': 'Operation failed',
|
||||
'msg.amount_gt_zero': 'Amount must be greater than 0',
|
||||
'msg.credit_zero': 'Adjustment cannot be 0',
|
||||
'msg.credit_adjusted': 'Credit updated',
|
||||
|
||||
@@ -46,12 +46,39 @@ const detail = ref<PlayerDetail | null>(null);
|
||||
const editingId = ref('');
|
||||
|
||||
const depositForm = ref({ userId: '', amount: 100, remark: '' });
|
||||
const playerSettings = ref({ allowPasswordChange: true, allowUsernameChange: false });
|
||||
const settingsSaving = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
loadAgentOptions();
|
||||
loadPlayerSettings();
|
||||
load();
|
||||
});
|
||||
|
||||
async function loadPlayerSettings() {
|
||||
try {
|
||||
const { data } = await api.get('/admin/users/settings/account');
|
||||
playerSettings.value = data.data;
|
||||
} catch {
|
||||
/* 使用默认值 */
|
||||
}
|
||||
}
|
||||
|
||||
async function savePlayerSettings() {
|
||||
settingsSaving.value = true;
|
||||
try {
|
||||
const { data } = await api.put('/admin/users/settings/account', playerSettings.value);
|
||||
playerSettings.value = data.data;
|
||||
ElMessage.success(t('msg.saved'));
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
loadPlayerSettings();
|
||||
} finally {
|
||||
settingsSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAgentOptions() {
|
||||
const { data } = await api.get('/admin/agents/options');
|
||||
agentOptions.value = data.data;
|
||||
@@ -122,7 +149,9 @@ async function submitCreate() {
|
||||
try {
|
||||
await api.post('/admin/users', payload);
|
||||
ElMessage.success(
|
||||
createForm.value.asTier1Agent ? t('msg.agent_created') : t('msg.player_created'),
|
||||
createForm.value.asTier1Agent
|
||||
? t('msg.agent_created')
|
||||
: t('user.msg.created_with_password', { password: createForm.value.password }),
|
||||
);
|
||||
createVisible.value = false;
|
||||
load();
|
||||
@@ -163,13 +192,27 @@ async function toggleFreeze(row: PlayerRow) {
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
if (editForm.value.newPassword && editForm.value.newPassword.length < 8) {
|
||||
ElMessage.warning(t('err.password_min'));
|
||||
return;
|
||||
}
|
||||
editLoading.value = true;
|
||||
try {
|
||||
await api.put(`/admin/users/${editingId.value}`, {
|
||||
const newPwd = editForm.value.newPassword.trim();
|
||||
const { data } = await api.put(`/admin/users/${editingId.value}`, {
|
||||
username: editForm.value.username.trim(),
|
||||
parentId: editForm.value.parentId || '',
|
||||
phone: editForm.value.phone.trim() || undefined,
|
||||
email: editForm.value.email.trim() || undefined,
|
||||
password: newPwd || undefined,
|
||||
});
|
||||
const updated = data.data as PlayerDetail;
|
||||
if (newPwd) {
|
||||
editForm.value.managedPassword = updated.managedPassword ?? newPwd;
|
||||
editForm.value.newPassword = '';
|
||||
ElMessage.success(t('user.msg.password_saved', { password: editForm.value.managedPassword }));
|
||||
return;
|
||||
}
|
||||
ElMessage.success(t('msg.saved'));
|
||||
editVisible.value = false;
|
||||
load();
|
||||
@@ -246,6 +289,29 @@ function statusLabel(s: string) {
|
||||
<el-button type="primary" @click="openCreate">{{ t('user.create_btn') }}</el-button>
|
||||
</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-form inline>
|
||||
<el-form-item :label="t('common.keyword')">
|
||||
@@ -455,20 +521,42 @@ function statusLabel(s: string) {
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="editVisible" :title="t('user.dialog.edit')" width="560px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item :label="t('user.field.player_id')">
|
||||
<el-input :model-value="editForm.id" disabled />
|
||||
</el-form-item>
|
||||
<el-dialog
|
||||
v-model="editVisible"
|
||||
:title="t('user.dialog.edit')"
|
||||
width="480px"
|
||||
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-input :model-value="editForm.username" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.field.account_status')">
|
||||
<el-tag :type="statusTagType(editForm.status)" size="small">
|
||||
{{ statusLabel(editForm.status) }}
|
||||
</el-tag>
|
||||
<span class="field-hint inline-hint">{{ t('user.hint.freeze_in_list') }}</span>
|
||||
<el-input v-model="editForm.username" :placeholder="t('user.ph.username_unique')" />
|
||||
</el-form-item>
|
||||
|
||||
<div class="password-mgmt-block">
|
||||
<div class="block-title">{{ t('user.section.password_mgmt') }}</div>
|
||||
<el-form-item :label="t('user.field.current_password')">
|
||||
<span v-if="editForm.managedPassword" class="password-plain">{{ editForm.managedPassword }}</span>
|
||||
<span v-else class="password-empty">—</span>
|
||||
</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-select
|
||||
v-model="editForm.parentId"
|
||||
@@ -483,46 +571,38 @@ function statusLabel(s: string) {
|
||||
:value="a.id"
|
||||
/>
|
||||
</el-select>
|
||||
<div class="field-hint">{{ t('user.hint.agent_change') }}</div>
|
||||
</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-input v-model="editForm.phone" :placeholder="t('common.optional')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('user.field.email')">
|
||||
<el-input v-model="editForm.email" :placeholder="t('common.optional')" />
|
||||
</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>
|
||||
<template #footer>
|
||||
<el-button @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" @click="editVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button size="small" type="primary" :loading="editLoading" @click="submitEdit">
|
||||
{{ t('user.btn.save_profile') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -549,6 +629,12 @@ function statusLabel(s: string) {
|
||||
<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('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-tag :type="statusTagType(detail.status)" size="small">
|
||||
{{ statusLabel(detail.status) }}
|
||||
@@ -588,7 +674,8 @@ function statusLabel(s: string) {
|
||||
</template>
|
||||
|
||||
<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; }
|
||||
.pager { margin-top: 16px; display: flex; justify-content: flex-end; }
|
||||
.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-full-hint { font-size: 11px; color: #666; margin-left: 4px; }
|
||||
.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>
|
||||
|
||||
@@ -7,40 +7,84 @@ import { formatAmount, formatAmountFull } from '../../utils/format-amount';
|
||||
|
||||
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 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);
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get('/agent/players');
|
||||
players.value = data.data;
|
||||
players.value = data.data as PlayerRow[];
|
||||
}
|
||||
|
||||
async function create() {
|
||||
await api.post('/agent/players', form.value);
|
||||
ElMessage.success(t('msg.player_created'));
|
||||
load();
|
||||
if (!form.value.username.trim()) {
|
||||
ElMessage.warning(t('err.username_required'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.post('/agent/players', form.value);
|
||||
ElMessage.success(t('msg.player_created'));
|
||||
form.value.username = '';
|
||||
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() {
|
||||
depositForm.value.requestId = `dep-${Date.now()}`;
|
||||
await api.post(`/agent/players/${depositForm.value.playerId}/deposit`, {
|
||||
amount: depositForm.value.amount,
|
||||
requestId: depositForm.value.requestId,
|
||||
});
|
||||
ElMessage.success(t('msg.topup_ok'));
|
||||
load();
|
||||
function openTransfer(type: 'deposit' | 'withdraw', row: PlayerRow) {
|
||||
transferType.value = type;
|
||||
transferTarget.value = row;
|
||||
transferAmount.value = 100;
|
||||
transferVisible.value = true;
|
||||
}
|
||||
|
||||
async function withdraw(playerId: string, amount: number) {
|
||||
await api.post(`/agent/players/${playerId}/withdraw`, {
|
||||
amount,
|
||||
requestId: `wd-${Date.now()}`,
|
||||
});
|
||||
ElMessage.success(t('msg.withdraw_ok'));
|
||||
load();
|
||||
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'));
|
||||
} else {
|
||||
await api.post(`/agent/players/${playerId}/withdraw`, { amount, requestId });
|
||||
ElMessage.success(t('msg.withdraw_ok'));
|
||||
}
|
||||
transferVisible.value = false;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function transferTitle() {
|
||||
const name = transferTarget.value?.username ?? '';
|
||||
return transferType.value === 'deposit'
|
||||
? t('agent_portal.transfer_title_deposit', { name })
|
||||
: t('agent_portal.transfer_title_withdraw', { name });
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -52,80 +96,94 @@ async function withdraw(playerId: string, amount: number) {
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<el-form inline>
|
||||
<el-form-item :label="t('user.col.username')">
|
||||
<el-input v-model="form.username" :placeholder="t('agent_portal.username_ph')" style="width: 150px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="create">{{ t('agent_portal.create_player_btn') }}</el-button>
|
||||
</el-form-item>
|
||||
</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>
|
||||
<div class="tool-section-title">{{ t('agent_portal.create_player_section') }}</div>
|
||||
<el-form inline>
|
||||
<el-form-item :label="t('user.col.username')">
|
||||
<el-input v-model="form.username" :placeholder="t('agent_portal.username_ph')" style="width: 160px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="create">{{ t('agent_portal.create_player_btn') }}</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="data-card" shadow="never">
|
||||
<div class="table-wrap">
|
||||
<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 :label="t('user.field.available')" min-width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="(row as { wallet?: { availableBalance: string } }).wallet?.availableBalance != null">
|
||||
<el-tooltip
|
||||
:content="formatAmountFull((row as { wallet: { availableBalance: string } }).wallet.availableBalance)"
|
||||
placement="top"
|
||||
>
|
||||
<span>{{ formatAmount((row as { wallet: { availableBalance: string } }).wallet.availableBalance) }}</span>
|
||||
<template v-if="row.wallet?.availableBalance != null">
|
||||
<el-tooltip :content="formatAmountFull(row.wallet.availableBalance)" placement="top">
|
||||
<span>{{ formatAmount(row.wallet.availableBalance) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</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 }">
|
||||
<el-button size="small" type="warning" plain @click="withdraw((row as { id: string }).id, 50)">
|
||||
{{ t('agent_portal.withdraw_btn', { amount: 50 }) }}
|
||||
<el-button size="small" type="success" link @click="openTransfer('deposit', row)">
|
||||
{{ t('common.topup') }}
|
||||
</el-button>
|
||||
<el-button size="small" type="warning" link @click="openTransfer('withdraw', row)">
|
||||
{{ t('agent_portal.withdraw_btn_label') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header { display: flex; align-items: baseline; 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-row {
|
||||
.page-header {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
align-items: flex-start;
|
||||
align-items: baseline;
|
||||
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 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
@@ -134,10 +192,4 @@ async function withdraw(playerId: string, amount: number) {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.tool-divider {
|
||||
width: 1px;
|
||||
background: #eee;
|
||||
align-self: stretch;
|
||||
margin: 0 24px 0 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface PlayerEditForm {
|
||||
loginFailCount: number;
|
||||
phone: string;
|
||||
email: string;
|
||||
managedPassword: string | null;
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
export interface PlayerRow {
|
||||
@@ -42,6 +44,7 @@ export interface PlayerRow {
|
||||
parentUsername: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
managedPassword: string | null;
|
||||
availableBalance: string;
|
||||
frozenBalance: string;
|
||||
lastLoginAt: string | null;
|
||||
@@ -90,6 +93,8 @@ export function emptyPlayerEditForm(): PlayerEditForm {
|
||||
loginFailCount: 0,
|
||||
phone: '',
|
||||
email: '',
|
||||
managedPassword: null,
|
||||
newPassword: '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -110,6 +115,8 @@ export function editFormFromDetail(d: PlayerDetail): PlayerEditForm {
|
||||
loginFailCount: d.loginFailCount,
|
||||
phone: d.phone ?? '',
|
||||
email: d.email ?? '',
|
||||
managedPassword: d.managedPassword ?? null,
|
||||
newPassword: '',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user