新增代理管理器与二级代理体系,完善信用额度/上下分上下文与冻结策略;代理端玩家与子代理管理增强;玩家端新增钱包详情页与交易筛选优化。 Co-authored-by: Cursor <cursoragent@cursor.com>
922 lines
32 KiB
Vue
922 lines
32 KiB
Vue
<script setup lang="ts">
|
||
import { ref, onMounted, computed } from 'vue';
|
||
import { useRouter } from 'vue-router';
|
||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||
import { resolveFormError } from '../i18n/form-validation';
|
||
import api from '../api';
|
||
import { clearStaffSession } from '../stores/auth';
|
||
|
||
const { t, localeTag } = useAdminLocale();
|
||
const router = useRouter();
|
||
import {
|
||
emptyPlayerCreateForm,
|
||
emptyPlayerEditForm,
|
||
editFormFromDetail,
|
||
buildCreatePlayerPayload,
|
||
type PlayerRow,
|
||
type PlayerDetail,
|
||
type PlayerCreateForm,
|
||
type PlayerEditForm,
|
||
} from './user-form.ts';
|
||
import {
|
||
formatAmount,
|
||
formatAmountFull,
|
||
shouldCompactAmount as shouldCompact,
|
||
} from '../utils/format-amount';
|
||
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
||
import WalletTransferContext from '../components/WalletTransferContext.vue';
|
||
import { useAdminPlayerTransfer } from '../composables/useAdminPlayerTransfer';
|
||
|
||
const users = ref<PlayerRow[]>([]);
|
||
const total = ref(0);
|
||
const page = ref(1);
|
||
const pageSize = ref(10);
|
||
const keyword = ref('');
|
||
const filterStatus = ref('');
|
||
const filterParentId = ref('');
|
||
|
||
const agentOptions = ref<{ id: string; username: string }[]>([]);
|
||
|
||
const createVisible = ref(false);
|
||
const editVisible = ref(false);
|
||
const detailVisible = ref(false);
|
||
const createLoading = ref(false);
|
||
const editLoading = ref(false);
|
||
|
||
const createForm = ref<PlayerCreateForm>(emptyPlayerCreateForm());
|
||
const editForm = ref<PlayerEditForm>(emptyPlayerEditForm());
|
||
const detail = ref<PlayerDetail | null>(null);
|
||
const editingId = ref('');
|
||
|
||
const {
|
||
transferVisible,
|
||
transferLoading,
|
||
transferType,
|
||
transferTarget,
|
||
transferAmount,
|
||
transferRemark,
|
||
transferContext,
|
||
transferContextLoading,
|
||
transferAmountRange,
|
||
transferAmountDisabled,
|
||
transferTitle,
|
||
openTransfer,
|
||
submitTransfer,
|
||
} = useAdminPlayerTransfer(() => load());
|
||
|
||
const playerSettings = ref({ allowPasswordChange: true, allowUsernameChange: false });
|
||
const bettingLimits = ref({
|
||
minStake: 1,
|
||
maxStakeSingle: 50000,
|
||
maxStakeParlay: 20000,
|
||
maxPayoutSingle: 500000,
|
||
maxPayoutParlay: 1000000,
|
||
dailyStakeLimit: 200000,
|
||
});
|
||
const settingsSaving = ref(false);
|
||
const limitsSaving = ref(false);
|
||
const resetAllowed = ref(false);
|
||
const resetLoading = ref(false);
|
||
const resetConfirmPhrase = ref('');
|
||
const settingsCollapseOpen = ref<string[]>([]);
|
||
|
||
onMounted(() => {
|
||
loadAgentOptions();
|
||
loadPlayerSettings();
|
||
loadBettingLimits();
|
||
loadResetDatabaseStatus();
|
||
load();
|
||
});
|
||
|
||
async function loadResetDatabaseStatus() {
|
||
try {
|
||
const { data } = await api.get('/admin/system/reset-database');
|
||
resetAllowed.value = !!data.data?.allowed;
|
||
} catch {
|
||
resetAllowed.value = false;
|
||
}
|
||
}
|
||
|
||
async function resetDatabase() {
|
||
if (resetConfirmPhrase.value !== 'RESET') {
|
||
ElMessage.warning(t('user.reset_database_confirm_label'));
|
||
return;
|
||
}
|
||
try {
|
||
await ElMessageBox.confirm(t('user.reset_database_hint'), t('user.reset_database'), {
|
||
type: 'warning',
|
||
confirmButtonText: t('user.reset_database_btn'),
|
||
cancelButtonText: t('common.cancel'),
|
||
});
|
||
} catch {
|
||
return;
|
||
}
|
||
|
||
resetLoading.value = true;
|
||
try {
|
||
const { data } = await api.post('/admin/system/reset-database', {
|
||
confirmPhrase: 'RESET',
|
||
});
|
||
const accounts: string[] = data.data?.demoAccounts ?? [];
|
||
ElMessage.success({
|
||
message: `${t('user.reset_database_success')}\n${t('user.reset_database_accounts')}: ${accounts.join(' · ')}`,
|
||
duration: 8000,
|
||
});
|
||
clearStaffSession();
|
||
await router.push('/login');
|
||
} catch (e: unknown) {
|
||
const err = e as { response?: { data?: { error?: string } } };
|
||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||
} finally {
|
||
resetLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function loadBettingLimits() {
|
||
try {
|
||
const { data } = await api.get('/admin/settings/betting-limits');
|
||
bettingLimits.value = data.data;
|
||
} catch {
|
||
/* 使用默认值 */
|
||
}
|
||
}
|
||
|
||
async function saveBettingLimits() {
|
||
limitsSaving.value = true;
|
||
try {
|
||
const { data } = await api.put('/admin/settings/betting-limits', bettingLimits.value);
|
||
bettingLimits.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'));
|
||
loadBettingLimits();
|
||
} finally {
|
||
limitsSaving.value = false;
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
async function load() {
|
||
const { data } = await api.get('/admin/users', {
|
||
params: {
|
||
page: page.value,
|
||
pageSize: pageSize.value,
|
||
keyword: keyword.value || undefined,
|
||
status: filterStatus.value || undefined,
|
||
parentId: filterParentId.value || undefined,
|
||
},
|
||
});
|
||
users.value = data.data.items;
|
||
total.value = data.data.total;
|
||
}
|
||
|
||
function onPageChange(p: number) {
|
||
page.value = p;
|
||
load();
|
||
}
|
||
|
||
function onSizeChange(size: number) {
|
||
pageSize.value = size;
|
||
page.value = 1;
|
||
load();
|
||
}
|
||
|
||
function openCreate() {
|
||
createForm.value = emptyPlayerCreateForm();
|
||
createVisible.value = true;
|
||
}
|
||
|
||
function parentLabel(row: PlayerRow) {
|
||
return row.parentUsername ?? t('common.platform_direct');
|
||
}
|
||
|
||
async function openDetail(id: string) {
|
||
const { data } = await api.get(`/admin/users/${id}`);
|
||
detail.value = data.data as PlayerDetail;
|
||
detailVisible.value = true;
|
||
}
|
||
|
||
async function openEdit(id: string) {
|
||
const { data } = await api.get(`/admin/users/${id}`);
|
||
const d = data.data as PlayerDetail;
|
||
editingId.value = id;
|
||
editForm.value = editFormFromDetail(d);
|
||
editVisible.value = true;
|
||
}
|
||
|
||
async function submitCreate() {
|
||
let payload: ReturnType<typeof buildCreatePlayerPayload>;
|
||
try {
|
||
payload = buildCreatePlayerPayload(createForm.value);
|
||
} catch (e) {
|
||
ElMessage.warning(resolveFormError(e, t));
|
||
return;
|
||
}
|
||
createLoading.value = true;
|
||
try {
|
||
await api.post('/admin/users', payload);
|
||
ElMessage.success(
|
||
createForm.value.asTier1Agent
|
||
? t('msg.agent_created')
|
||
: t('user.msg.created_with_password', { password: createForm.value.password }),
|
||
);
|
||
createVisible.value = false;
|
||
load();
|
||
} catch (e: unknown) {
|
||
const err = e as { response?: { data?: { error?: string } } };
|
||
ElMessage.error(err.response?.data?.error ?? t('msg.create_failed'));
|
||
} finally {
|
||
createLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function toggleFreeze(row: PlayerRow) {
|
||
const freeze = row.status === 'ACTIVE';
|
||
const action = freeze ? t('common.freeze') : t('common.unfreeze');
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
t('msg.freeze_confirm_body', {
|
||
action,
|
||
name: row.username,
|
||
extra: freeze ? t('msg.freeze_extra') : '',
|
||
}),
|
||
t('msg.freeze_confirm_title', { action }),
|
||
{ type: 'warning', confirmButtonText: action, cancelButtonText: t('common.cancel') },
|
||
);
|
||
} catch {
|
||
return;
|
||
}
|
||
try {
|
||
await api.put(`/admin/users/${row.id}`, {
|
||
status: freeze ? 'SUSPENDED' : 'ACTIVE',
|
||
});
|
||
ElMessage.success(t('msg.freeze_done', { action }));
|
||
load();
|
||
} catch (e: unknown) {
|
||
const err = e as { response?: { data?: { error?: string } } };
|
||
ElMessage.error(err.response?.data?.error ?? t('msg.freeze_failed', { action }));
|
||
}
|
||
}
|
||
|
||
async function submitEdit() {
|
||
if (editForm.value.newPassword && editForm.value.newPassword.length < 8) {
|
||
ElMessage.warning(t('err.password_min'));
|
||
return;
|
||
}
|
||
editLoading.value = true;
|
||
try {
|
||
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();
|
||
} catch (e: unknown) {
|
||
const err = e as { response?: { data?: { error?: string } } };
|
||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||
} finally {
|
||
editLoading.value = false;
|
||
}
|
||
}
|
||
|
||
function formatTime(v: string) {
|
||
if (!v) return '—';
|
||
return new Date(v).toLocaleString(localeTag.value);
|
||
}
|
||
|
||
function formatLastLogin(v: string | null) {
|
||
if (!v) return t('common.never_login');
|
||
const d = new Date(v);
|
||
const now = new Date();
|
||
const sameDay =
|
||
d.getFullYear() === now.getFullYear() &&
|
||
d.getMonth() === now.getMonth() &&
|
||
d.getDate() === now.getDate();
|
||
if (sameDay) {
|
||
return d.toLocaleTimeString(localeTag.value, { hour: '2-digit', minute: '2-digit' });
|
||
}
|
||
return d.toLocaleString(localeTag.value, {
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
}
|
||
|
||
function statusTagType(s: string) {
|
||
return s === 'ACTIVE' ? 'success' : 'warning';
|
||
}
|
||
|
||
function statusLabel(s: string) {
|
||
const key = `user.status.${s}`;
|
||
const v = t(key);
|
||
return v !== key ? v : s;
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="admin-list-page users-page">
|
||
<el-collapse v-model="settingsCollapseOpen" class="list-settings">
|
||
<el-collapse-item :title="t('user.page_settings')" name="settings">
|
||
<div class="list-settings-block">
|
||
<p class="list-settings-title">{{ t('user.global_settings') }}</p>
|
||
<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>
|
||
<div class="list-settings-block">
|
||
<p class="list-settings-title">{{ t('user.betting_limits') }}</p>
|
||
<el-form inline size="small" class="settings-form limits-form">
|
||
<el-form-item :label="t('user.limit.min_stake')">
|
||
<el-input-number v-model="bettingLimits.minStake" :min="0" :step="1" controls-position="right" />
|
||
</el-form-item>
|
||
<el-form-item :label="t('user.limit.max_stake_single')">
|
||
<el-input-number v-model="bettingLimits.maxStakeSingle" :min="0" :step="100" controls-position="right" />
|
||
</el-form-item>
|
||
<el-form-item :label="t('user.limit.max_stake_parlay')">
|
||
<el-input-number v-model="bettingLimits.maxStakeParlay" :min="0" :step="100" controls-position="right" />
|
||
</el-form-item>
|
||
<el-form-item :label="t('user.limit.max_payout_single')">
|
||
<el-input-number v-model="bettingLimits.maxPayoutSingle" :min="0" :step="1000" controls-position="right" />
|
||
</el-form-item>
|
||
<el-form-item :label="t('user.limit.max_payout_parlay')">
|
||
<el-input-number v-model="bettingLimits.maxPayoutParlay" :min="0" :step="1000" controls-position="right" />
|
||
</el-form-item>
|
||
<el-form-item :label="t('user.limit.daily_stake')">
|
||
<el-input-number v-model="bettingLimits.dailyStakeLimit" :min="0" :step="1000" controls-position="right" />
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" :loading="limitsSaving" @click="saveBettingLimits">
|
||
{{ t('common.save') }}
|
||
</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
</div>
|
||
<div class="list-settings-block list-settings-block--danger">
|
||
<p class="list-settings-title">{{ t('user.reset_database') }}</p>
|
||
<p class="list-settings-hint">{{ t('user.reset_database_hint') }}</p>
|
||
<el-alert
|
||
v-if="!resetAllowed"
|
||
type="warning"
|
||
:closable="false"
|
||
show-icon
|
||
class="reset-db-alert"
|
||
:title="t('user.reset_database_disabled_prod')"
|
||
/>
|
||
<el-form inline size="small" class="settings-form reset-db-form">
|
||
<el-form-item :label="t('user.reset_database_confirm_label')">
|
||
<el-input
|
||
v-model="resetConfirmPhrase"
|
||
:placeholder="t('user.reset_database_confirm_ph')"
|
||
style="width: 160px"
|
||
:disabled="!resetAllowed"
|
||
autocomplete="off"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button
|
||
type="danger"
|
||
plain
|
||
:loading="resetLoading"
|
||
:disabled="!resetAllowed || resetConfirmPhrase !== 'RESET'"
|
||
@click="resetDatabase"
|
||
>
|
||
{{ t('user.reset_database_btn') }}
|
||
</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
</div>
|
||
</el-collapse-item>
|
||
</el-collapse>
|
||
|
||
<div class="list-chrome">
|
||
<div class="list-chrome__row">
|
||
<el-form inline class="list-chrome__grow">
|
||
<el-form-item :label="t('common.keyword')">
|
||
<el-input
|
||
v-model="keyword"
|
||
:placeholder="t('user.filter.username_ph')"
|
||
clearable
|
||
style="width: 160px"
|
||
@keyup.enter="load"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item :label="t('user.filter.agent')">
|
||
<el-select
|
||
v-model="filterParentId"
|
||
:placeholder="t('user.filter.agent_ph')"
|
||
clearable
|
||
style="width: 180px"
|
||
>
|
||
<el-option
|
||
v-for="a in agentOptions"
|
||
:key="a.id"
|
||
:label="a.username"
|
||
:value="a.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item :label="t('common.status')">
|
||
<el-select v-model="filterStatus" :placeholder="t('common.all')" clearable style="width: 120px">
|
||
<el-option :label="t('user.status.ACTIVE')" value="ACTIVE" />
|
||
<el-option :label="t('user.status.SUSPENDED')" value="SUSPENDED" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" @click="load">{{ t('common.search') }}</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
<div class="list-chrome__actions">
|
||
<el-button type="primary" @click="openCreate">{{ t('user.create_btn') }}</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<section class="list-panel">
|
||
<div class="table-wrap">
|
||
<el-table :data="users" stripe>
|
||
<template #empty>
|
||
<AdminTableEmpty />
|
||
</template>
|
||
<el-table-column prop="id" label="ID" width="72" />
|
||
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
|
||
<el-table-column :label="t('common.status')" width="88">
|
||
<template #default="{ row }">
|
||
<el-tag :type="statusTagType(row.status)" size="small">
|
||
{{ statusLabel(row.status) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="t('user.col.agent')" min-width="120">
|
||
<template #default="{ row }">{{ parentLabel(row) }}</template>
|
||
</el-table-column>
|
||
<el-table-column :label="t('user.col.balance')" min-width="128" align="right">
|
||
<template #default="{ row }">
|
||
<el-tooltip
|
||
:content="`${formatAmountFull(row.availableBalance)} / ${formatAmountFull(row.frozenBalance)}`"
|
||
placement="top"
|
||
>
|
||
<span class="amount-compact">
|
||
{{ formatAmount(row.availableBalance) }} / {{ formatAmount(row.frozenBalance) }}
|
||
</span>
|
||
</el-tooltip>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="betCount" :label="t('user.col.bets')" width="64" align="center" />
|
||
<el-table-column :label="t('user.col.stake_payout')" min-width="108" align="right">
|
||
<template #default="{ row }">
|
||
<span class="amount-compact">
|
||
{{ formatAmount(row.totalStake) }} / {{ formatAmount(row.totalReturn) }}
|
||
</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="t('user.col.last_login')" width="108">
|
||
<template #default="{ row }">
|
||
<el-tooltip v-if="row.lastLoginAt" :content="formatTime(row.lastLoginAt)" placement="top">
|
||
<span>{{ formatLastLogin(row.lastLoginAt) }}</span>
|
||
</el-tooltip>
|
||
<span v-else class="text-muted">{{ t('common.never_login') }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="t('user.col.created')" width="108">
|
||
<template #default="{ row }">
|
||
<el-tooltip :content="formatTime(row.createdAt)" placement="top">
|
||
<span>{{ formatLastLogin(row.createdAt) }}</span>
|
||
</el-tooltip>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="t('common.actions')" width="340" fixed="right" align="center">
|
||
<template #default="{ row }">
|
||
<el-button size="small" link type="primary" @click="openDetail(row.id)">{{ t('common.detail') }}</el-button>
|
||
<el-button size="small" link type="primary" @click="openEdit(row.id)">{{ t('common.edit') }}</el-button>
|
||
<el-button size="small" link type="success" @click="openTransfer('deposit', row)">{{ t('common.topup') }}</el-button>
|
||
<el-button size="small" link type="warning" @click="openTransfer('withdraw', row)">{{ t('agent_portal.withdraw_btn_label') }}</el-button>
|
||
<el-button
|
||
v-if="row.status === 'ACTIVE'"
|
||
size="small"
|
||
link
|
||
type="warning"
|
||
@click="toggleFreeze(row)"
|
||
>
|
||
{{ t('common.freeze') }}
|
||
</el-button>
|
||
<el-button
|
||
v-else
|
||
size="small"
|
||
link
|
||
type="primary"
|
||
@click="toggleFreeze(row)"
|
||
>
|
||
{{ t('common.unfreeze') }}
|
||
</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
<div class="pager">
|
||
<el-pagination
|
||
v-model:current-page="page"
|
||
v-model:page-size="pageSize"
|
||
:total="total"
|
||
:page-sizes="[10, 20, 50, 100]"
|
||
layout="total, sizes, prev, pager, next"
|
||
background
|
||
@current-change="onPageChange"
|
||
@size-change="onSizeChange"
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
<el-dialog v-model="createVisible" :title="t('user.dialog.create')" width="520px" destroy-on-close>
|
||
<el-form label-width="100px">
|
||
<el-form-item :label="t('user.col.username')" required>
|
||
<el-input v-model="createForm.username" :placeholder="t('user.ph.username_unique')" />
|
||
</el-form-item>
|
||
<el-form-item :label="t('user.field.password')" required>
|
||
<el-input v-model="createForm.password" type="text" autocomplete="off" />
|
||
</el-form-item>
|
||
<el-form-item :label="t('user.field.confirm_password')" required>
|
||
<el-input v-model="createForm.confirmPassword" type="text" autocomplete="off" />
|
||
</el-form-item>
|
||
<el-form-item :label="t('user.field.account_type')">
|
||
<el-radio-group v-model="createForm.asTier1Agent">
|
||
<el-radio :value="false">{{ t('user.type.player') }}</el-radio>
|
||
<el-radio :value="true">{{ t('user.type.tier1_agent') }}</el-radio>
|
||
</el-radio-group>
|
||
<div class="field-hint">{{ t('user.hint.account_type') }}</div>
|
||
</el-form-item>
|
||
<template v-if="!createForm.asTier1Agent">
|
||
<el-form-item :label="t('user.filter.agent')">
|
||
<el-select
|
||
v-model="createForm.parentId"
|
||
:placeholder="t('user.ph.no_agent')"
|
||
clearable
|
||
style="width: 100%"
|
||
>
|
||
<el-option
|
||
v-for="a in agentOptions"
|
||
:key="a.id"
|
||
:label="`${a.username} (#${a.id})`"
|
||
:value="a.id"
|
||
/>
|
||
</el-select>
|
||
<div class="field-hint">{{ t('user.hint.no_agent') }}</div>
|
||
</el-form-item>
|
||
</template>
|
||
<template v-else>
|
||
<el-form-item :label="t('agent.field.credit_limit')" required>
|
||
<el-input-number
|
||
v-model="createForm.creditLimit"
|
||
:min="0"
|
||
:step="10000"
|
||
style="width: 100%"
|
||
/>
|
||
<div class="field-hint">{{ t('agent.hint.credit_limit') }}</div>
|
||
</el-form-item>
|
||
<el-form-item :label="t('agent.field.cashback_rate')">
|
||
<el-input-number
|
||
v-model="createForm.cashbackRate"
|
||
:min="0"
|
||
:max="1"
|
||
:step="0.001"
|
||
:precision="4"
|
||
style="width: 100%"
|
||
/>
|
||
<div class="field-hint">{{ t('agent.hint.cashback_example') }}</div>
|
||
</el-form-item>
|
||
</template>
|
||
<el-form-item :label="t('user.field.phone')">
|
||
<el-input v-model="createForm.phone" :placeholder="t('common.optional')" />
|
||
</el-form-item>
|
||
<el-form-item :label="t('user.field.email')">
|
||
<el-input v-model="createForm.email" :placeholder="t('common.optional')" />
|
||
</el-form-item>
|
||
<template v-if="!createForm.asTier1Agent">
|
||
<el-form-item :label="t('user.field.initial_balance')">
|
||
<el-input-number v-model="createForm.initialDeposit" :min="0" :step="100" style="width: 100%" />
|
||
<div class="field-hint">{{ t('user.hint.initial_balance') }}</div>
|
||
</el-form-item>
|
||
<el-form-item :label="t('user.field.deposit_remark')">
|
||
<el-input v-model="createForm.remark" :placeholder="t('user.ph.remark_initial')" />
|
||
</el-form-item>
|
||
</template>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="createVisible = false">{{ t('common.cancel') }}</el-button>
|
||
<el-button type="primary" :loading="createLoading" @click="submitCreate">{{ t('user.btn.create') }}</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<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 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"
|
||
:placeholder="t('user.ph.no_agent')"
|
||
clearable
|
||
style="width: 100%"
|
||
>
|
||
<el-option
|
||
v-for="a in agentOptions"
|
||
:key="a.id"
|
||
:label="`${a.username} (#${a.id})`"
|
||
:value="a.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item :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 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>
|
||
|
||
<el-dialog v-model="transferVisible" :title="transferTitle()" width="520px" destroy-on-close>
|
||
<WalletTransferContext
|
||
:context="transferContext"
|
||
:mode="transferType"
|
||
:loading="transferContextLoading"
|
||
/>
|
||
<el-form label-width="88px">
|
||
<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="transferAmountRange.min"
|
||
:max="transferAmountRange.max"
|
||
:disabled="transferAmountDisabled"
|
||
:step="10"
|
||
:precision="2"
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item :label="t('user.field.remark')">
|
||
<el-input v-model="transferRemark" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="transferVisible = false">{{ t('common.cancel') }}</el-button>
|
||
<el-button
|
||
type="primary"
|
||
:loading="transferLoading"
|
||
:disabled="transferAmountDisabled"
|
||
@click="submitTransfer"
|
||
>
|
||
{{ t('common.confirm') }}
|
||
</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<el-dialog v-model="detailVisible" :title="t('user.dialog.detail')" width="560px" destroy-on-close>
|
||
<template v-if="detail">
|
||
<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) }}
|
||
</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item :label="t('user.col.agent')">
|
||
{{ detail.parentUsername ?? t('common.platform_direct') }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item :label="t('user.field.available')">
|
||
{{ formatAmount(detail.availableBalance) }}
|
||
<span v-if="shouldCompact(detail.availableBalance)" class="amount-full-hint">
|
||
({{ formatAmountFull(detail.availableBalance) }})
|
||
</span>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item :label="t('user.field.frozen_balance')">
|
||
{{ formatAmount(detail.frozenBalance) }}
|
||
<span v-if="shouldCompact(detail.frozenBalance)" class="amount-full-hint">
|
||
({{ formatAmountFull(detail.frozenBalance) }})
|
||
</span>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item :label="t('user.field.phone')">{{ detail.phone ?? '—' }}</el-descriptions-item>
|
||
<el-descriptions-item :label="t('user.field.email')">{{ detail.email ?? '—' }}</el-descriptions-item>
|
||
<el-descriptions-item :label="t('user.field.bet_count')">{{ detail.betCount }}</el-descriptions-item>
|
||
<el-descriptions-item :label="t('user.field.total_stake')">{{ formatAmount(detail.totalStake) }}</el-descriptions-item>
|
||
<el-descriptions-item :label="t('user.field.total_payout')">{{ formatAmount(detail.totalReturn) }}</el-descriptions-item>
|
||
<el-descriptions-item :label="t('user.col.last_login')">
|
||
{{ detail.lastLoginAt ? formatTime(detail.lastLoginAt) : t('common.never_login') }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item :label="t('user.field.login_fail')">{{ t('user.login_fail_value', { n: detail.loginFailCount }) }}</el-descriptions-item>
|
||
<el-descriptions-item :label="t('user.field.registered_at')" :span="2">
|
||
{{ formatTime(detail.createdAt) }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.settings-form :deep(.el-form-item) {
|
||
margin-bottom: 0;
|
||
margin-right: 12px;
|
||
}
|
||
.field-hint { font-size: 12px; color: #888; margin-top: 4px; }
|
||
.inline-hint { margin-top: 0; margin-left: 10px; display: inline-block; }
|
||
.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; }
|
||
.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;
|
||
}
|
||
.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;
|
||
}
|
||
.list-settings-block--danger {
|
||
margin-top: 12px;
|
||
padding-top: 12px;
|
||
border-top: 1px dashed rgba(245, 108, 108, 0.35);
|
||
}
|
||
.list-settings-hint {
|
||
font-size: 12px;
|
||
color: #888;
|
||
margin: 0 0 10px;
|
||
line-height: 1.5;
|
||
}
|
||
.reset-db-alert {
|
||
margin-bottom: 10px;
|
||
}
|
||
</style>
|
||
|
||
<style>
|
||
/* 玩家列表「冻结」:橙黄底白字 */
|
||
.users-page .el-button.is-link.el-button--warning {
|
||
color: #ffffff !important;
|
||
background: linear-gradient(165deg, #e8a84a 0%, #c47a18 42%, #9a5c10 100%) !important;
|
||
border: 1px solid rgba(232, 168, 74, 0.45) !important;
|
||
border-radius: 6px !important;
|
||
padding: 5px 11px !important;
|
||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.12) inset, 0 1px 6px rgba(0, 0, 0, 0.35) !important;
|
||
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2);
|
||
}
|
||
.users-page .el-button.is-link.el-button--warning:hover,
|
||
.users-page .el-button.is-link.el-button--warning:focus {
|
||
color: #ffffff !important;
|
||
background: linear-gradient(165deg, #f0bc62 0%, #d48a28 42%, #a86814 100%) !important;
|
||
border-color: rgba(240, 188, 98, 0.55) !important;
|
||
}
|
||
</style>
|