feat: 开户备注、账单展示优化与后台代理管理增强

- 新增初始上分备注(日常上分/开户赠金/自定义)及前后台校验与展示

- 优化钱包流水类型与备注显示,区分管理员/代理/玩家上下分

- 修复登录后语言被后端覆盖的问题,登录时同步当前语言到服务端

- 后台代理/玩家表格操作栏重构,充值订单增加备注列

- 前台个人中心、充值、账单与验证码组件体验优化

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-11 17:23:58 +08:00
parent 10485ecfaf
commit 03e72ca9b2
46 changed files with 3721 additions and 1059 deletions

View File

@@ -45,12 +45,14 @@ import {
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
import PlayerWalletLedgerDialog from '../components/PlayerWalletLedgerDialog.vue';
import WalletTransferContext from '../components/WalletTransferContext.vue';
import InitialDepositRemarkField from '../components/InitialDepositRemarkField.vue';
import AgentCreditContext from '../components/AgentCreditContext.vue';
import RatePercentInput from '../components/RatePercentInput.vue';
import { formatRatePercent, percentToDecimalRate, decimalRateToPercent } from '../utils/rate-percent';
import InviteCodePanel from '../components/InviteCodePanel.vue';
import InviteManageDialog from '../components/InviteManageDialog.vue';
import AdminRowActionsDropdown from '../components/AdminRowActionsDropdown.vue';
import AdminTableWrap from '../components/AdminTableWrap.vue';
import AdminAgentRowActions from '../components/AdminAgentRowActions.vue';
import AdminPlayerRowActions from '../components/AdminPlayerRowActions.vue';
import AdminDetailGrid from '../components/AdminDetailGrid.vue';
import AdminDetailItem from '../components/AdminDetailItem.vue';
@@ -83,11 +85,6 @@ type SubAgentLevelState = {
const subAgentLevelState = reactive<Record<number, SubAgentLevelState>>({});
const agentLevelCounts = ref<Record<number, number>>({});
const subAgentTableRefs = ref<Record<number, { toggleRowExpansion: (row: AgentRow) => void } | null>>({});
function setSubAgentTableRef(level: number, el: unknown) {
subAgentTableRefs.value[level] = el as { toggleRowExpansion: (row: AgentRow) => void } | null;
}
function ensureSubAgentState(level: number): SubAgentLevelState {
if (!subAgentLevelState[level]) {
@@ -155,7 +152,8 @@ const agentOptions = ref<{ id: string; username: string; level: number; parentUs
const expandedSet = ref(new Set<string>());
const agentPlayersMap = ref<Record<string, PlayerRow[]>>({});
const expandLoading = ref<Record<string, boolean>>({});
const tier1AgentTableRef = ref();
const expandedRowKeys = computed(() => Array.from(expandedSet.value));
const createToolbarChildLevel = ref<number | null>(null);
@@ -402,17 +400,23 @@ onMounted(() => {
/* ─── Load tier-1 agents ─── */
async function loadTier1Agents() {
const { data } = await api.get('/admin/agents', {
params: {
page: tier1Page.value,
pageSize: tier1PageSize.value,
keyword: tier1Keyword.value.trim() || undefined,
status: tier1FilterStatus.value || undefined,
level: 1,
},
});
tier1Agents.value = data.data.items as AgentRow[];
tier1Total.value = data.data.total;
try {
const { data } = await api.get('/admin/agents', {
params: {
page: tier1Page.value,
pageSize: tier1PageSize.value,
keyword: tier1Keyword.value.trim() || undefined,
status: tier1FilterStatus.value || undefined,
level: 1,
},
});
tier1Agents.value = data.data.items as AgentRow[];
tier1Total.value = data.data.total;
} catch (e) {
tier1Agents.value = [];
tier1Total.value = 0;
ElMessage.error(resolveApiError(e, t, 'msg.load_failed'));
}
}
function onTier1PageChange(p: number) {
@@ -447,17 +451,23 @@ async function loadAgentLevelCounts() {
async function loadSubAgentsAtLevel(level: number) {
const st = ensureSubAgentState(level);
const { data } = await api.get('/admin/agents', {
params: {
page: st.page,
pageSize: st.pageSize,
keyword: st.keyword.trim() || undefined,
status: st.filterStatus || undefined,
level,
},
});
st.agents = data.data.items as AgentRow[];
st.total = data.data.total;
try {
const { data } = await api.get('/admin/agents', {
params: {
page: st.page,
pageSize: st.pageSize,
keyword: st.keyword.trim() || undefined,
status: st.filterStatus || undefined,
level,
},
});
st.agents = data.data.items as AgentRow[];
st.total = data.data.total;
} catch (e) {
st.agents = [];
st.total = 0;
ElMessage.error(resolveApiError(e, t, 'msg.load_failed'));
}
}
function onSubAgentPageChange(level: number, p: number) {
@@ -560,18 +570,11 @@ function directPlayersTabLabel(ownerName: string, count: number) {
}
function onTier1AgentRowClick(row: AgentRow, _column: unknown, event: MouseEvent) {
onAgentRowClick(row, tier1AgentTableRef, _column, event);
onAgentRowClick(row, event);
}
function onSubAgentRowClick(level: number, row: AgentRow, _column: unknown, event: MouseEvent) {
if (!shouldToggleExpandOnRowClick(event)) return;
subAgentTableRefs.value[level]?.toggleRowExpansion(row);
}
function bindSubAgentRowClick(level: number) {
return (row: AgentRow, column: unknown, event: MouseEvent) => {
onSubAgentRowClick(level, row, column, event);
};
function onSubAgentRowClick(row: AgentRow, _column: unknown, event: MouseEvent) {
onAgentRowClick(row, event);
}
watch(activeViewTab, (tab) => {
@@ -595,9 +598,17 @@ async function onExpandChange(row: DisplayAgentRow, expandedRows: DisplayAgentRo
}
}
function onAgentRowClick(row: AgentRow, tableRef: typeof tier1AgentTableRef, _column: unknown, event: MouseEvent) {
function onAgentRowClick(row: AgentRow, event: MouseEvent) {
if (!shouldToggleExpandOnRowClick(event)) return;
tableRef.value?.toggleRowExpansion(row);
const userId = row.userId;
const next = new Set(expandedSet.value);
if (next.has(userId)) {
next.delete(userId);
} else {
next.add(userId);
if (!agentPlayersMap.value[userId]) void loadExpansionData(userId);
}
expandedSet.value = next;
}
async function loadExpansionData(agentId: string) {
@@ -1360,7 +1371,7 @@ function creditTypeLabel(type: string) {
<el-button type="primary" @click="openCreateAccount">{{ t('user.create_btn') }}</el-button>
</div>
</div>
<div class="table-wrap">
<AdminTableWrap>
<el-table v-loading="playerLoading" :data="allPlayers" stripe>
<template #empty>
<AdminTableEmpty />
@@ -1396,7 +1407,7 @@ function creditTypeLabel(type: string) {
<span class="amount-compact">{{ formatAmount(row.totalStake) }} / {{ formatAmount(row.totalReturn) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" min-width="360" align="center" fixed="right">
<el-table-column :label="t('common.actions')" min-width="340" align="center">
<template #default="{ row }">
<AdminPlayerRowActions
:row="row"
@@ -1410,7 +1421,7 @@ function creditTypeLabel(type: string) {
</template>
</el-table-column>
</el-table>
</div>
</AdminTableWrap>
<div class="pager">
<el-pagination
v-model:current-page="playerPage"
@@ -1448,12 +1459,12 @@ function creditTypeLabel(type: string) {
<el-button type="primary" @click="openCreateTier1Agent">{{ t('agent.create_btn') }}</el-button>
</div>
</div>
<div class="table-wrap">
<AdminTableWrap>
<el-table
ref="tier1AgentTableRef"
:data="tier1Agents"
stripe
row-key="userId"
:expand-row-keys="expandedRowKeys"
:row-class-name="expandableTableRowClassName"
class="expandable-table compact-agent-table"
@expand-change="onExpandChange"
@@ -1544,24 +1555,22 @@ function creditTypeLabel(type: string) {
<el-table-column :label="t('agent.col.cashback')" min-width="80" align="right">
<template #default="{ row }">{{ formatRatePercent(row.cashbackRate) }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="96" align="center" fixed="right">
<el-table-column :label="t('common.actions')" min-width="300" align="center">
<template #default="{ row }">
<AdminRowActionsDropdown>
<el-dropdown-item @click="openDetailAgent(row.userId)">{{ t('common.detail') }}</el-dropdown-item>
<el-dropdown-item @click="openEditAgent(row.userId)">{{ t('common.edit') }}</el-dropdown-item>
<el-dropdown-item @click="openCredit(row.userId)">{{ t('common.adjust_credit') }}</el-dropdown-item>
<el-dropdown-item v-if="canAgentCreateSub(row)" @click="openCreateSubAgent(row.userId)">
{{ childAgentActionLabel(row.level) }}
</el-dropdown-item>
<el-dropdown-item v-if="row.status === 'ACTIVE'" divided @click="toggleFreezeAgent(row)">
<span class="action-warning">{{ t('common.freeze') }}</span>
</el-dropdown-item>
<el-dropdown-item v-else divided @click="toggleFreezeAgent(row)">{{ t('common.unfreeze') }}</el-dropdown-item>
</AdminRowActionsDropdown>
<AdminAgentRowActions
:row="{ userId: row.userId, status: row.status }"
:can-create-sub="canAgentCreateSub(row)"
:create-sub-label="childAgentActionLabel(row.level)"
@detail="openDetailAgent(row.userId)"
@edit="openEditAgent(row.userId)"
@credit="openCredit(row.userId)"
@create-sub="openCreateSubAgent(row.userId)"
@freeze="toggleFreezeAgent(row)"
/>
</template>
</el-table-column>
</el-table>
</div>
</AdminTableWrap>
<div class="pager">
<el-pagination
v-model:current-page="tier1Page"
@@ -1617,16 +1626,16 @@ function creditTypeLabel(type: string) {
</el-button>
</div>
</div>
<div class="table-wrap">
<AdminTableWrap>
<el-table
:ref="(el: unknown) => setSubAgentTableRef(agentLevel, el)"
:data="ensureSubAgentState(agentLevel).agents"
stripe
row-key="userId"
:expand-row-keys="expandedRowKeys"
:row-class-name="expandableTableRowClassName"
class="expandable-table compact-agent-table"
@expand-change="onExpandChange"
@row-click="bindSubAgentRowClick(agentLevel)"
@row-click="onSubAgentRowClick"
>
<template #empty>
<AdminTableEmpty />
@@ -1696,24 +1705,22 @@ function creditTypeLabel(type: string) {
</template>
</el-table-column>
<el-table-column prop="directPlayerCount" :label="t('agent.col.direct_players')" min-width="72" align="center" />
<el-table-column :label="t('common.actions')" width="96" align="center" fixed="right">
<el-table-column :label="t('common.actions')" min-width="300" align="center">
<template #default="{ row }">
<AdminRowActionsDropdown>
<el-dropdown-item @click="openDetailAgent(row.userId)">{{ t('common.detail') }}</el-dropdown-item>
<el-dropdown-item @click="openEditAgent(row.userId)">{{ t('common.edit') }}</el-dropdown-item>
<el-dropdown-item @click="openCredit(row.userId)">{{ t('common.adjust_credit') }}</el-dropdown-item>
<el-dropdown-item v-if="canAgentCreateSub(row)" @click="openCreateSubAgent(row.userId)">
{{ childAgentActionLabel(row.level) }}
</el-dropdown-item>
<el-dropdown-item v-if="subAgentAccountStatus(row) === 'ACTIVE'" divided @click="toggleFreezeAgent(row)">
<span class="action-warning">{{ t('common.freeze') }}</span>
</el-dropdown-item>
<el-dropdown-item v-else divided @click="toggleFreezeAgent(row)">{{ t('common.unfreeze') }}</el-dropdown-item>
</AdminRowActionsDropdown>
<AdminAgentRowActions
:row="{ userId: row.userId, status: subAgentAccountStatus(row) }"
:can-create-sub="canAgentCreateSub(row)"
:create-sub-label="childAgentActionLabel(row.level)"
@detail="openDetailAgent(row.userId)"
@edit="openEditAgent(row.userId)"
@credit="openCredit(row.userId)"
@create-sub="openCreateSubAgent(row.userId)"
@freeze="toggleFreezeAgent(row)"
/>
</template>
</el-table-column>
</el-table>
</div>
</AdminTableWrap>
<div class="pager">
<el-pagination
:current-page="ensureSubAgentState(agentLevel).page"
@@ -1908,8 +1915,15 @@ function creditTypeLabel(type: string) {
<el-form-item :label="t('user.field.initial_balance')">
<el-input-number v-model="createForm.initialDeposit" :min="0" :step="100" style="width: 100%" />
</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
v-if="createForm.initialDeposit > 0"
:label="t('user.field.initial_deposit_kind')"
>
<InitialDepositRemarkField
v-model:kind="createForm.initialDepositRemarkKind"
v-model:custom="createForm.initialDepositRemarkCustom"
operator="admin"
/>
</el-form-item>
</template>
@@ -1931,61 +1945,90 @@ function creditTypeLabel(type: string) {
</el-dialog>
<!-- Edit Player -->
<el-dialog v-model="editPlayerVisible" :title="t('user.dialog.edit')" width="480px" destroy-on-close class="user-edit-dialog">
<el-dialog v-model="editPlayerVisible" :title="t('user.dialog.edit')" width="560px" destroy-on-close class="user-edit-dialog">
<el-form label-width="84px" size="small" class="compact-edit-form">
<div class="edit-meta">
<span>ID {{ editPlayerForm.id }}</span>
<el-tag :type="statusTagType(editPlayerForm.status)" size="small">{{ statusLabel(editPlayerForm.status) }}</el-tag>
</div>
<el-form-item :label="t('user.col.username')">
<el-input v-model="editPlayerForm.username" :placeholder="t('user.ph.username_player')" />
<div class="field-hint">{{ t('user.hint.username_player') }}</div>
</el-form-item>
<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="editPlayerForm.managedPassword" class="password-plain">{{ editPlayerForm.managedPassword }}</span>
<span v-else class="password-empty"></span>
</el-form-item>
<p v-if="!editPlayerForm.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="editPlayerForm.newPassword" type="text" autocomplete="off" :placeholder="t('user.ph.reset_password_short')" />
<div class="edit-form-section">
<div class="section-title">{{ t('user.section.basic_info') }}</div>
<el-form-item :label="t('user.col.username')">
<el-input v-model="editPlayerForm.username" :placeholder="t('user.ph.username_player')" />
<div class="field-hint">{{ t('user.hint.username_player') }}</div>
</el-form-item>
</div>
<el-form-item :label="t('user.col.agent')">
<el-tag size="small" type="info" class="affiliation-tag">{{ affiliationLabel(editPlayerForm) }}</el-tag>
<div class="field-hint">{{ t('user.hint.agent_readonly') }}</div>
</el-form-item>
<el-form-item :label="t('agent.field.cashback_rate')">
<div class="cashback-edit-block">
<el-checkbox v-model="editPlayerForm.useCustomCashback">
{{ t('cashback.use_custom_rate') }}
</el-checkbox>
<RatePercentInput
v-if="editPlayerForm.useCustomCashback"
v-model="editPlayerForm.customCashbackRate"
/>
<p v-else class="field-hint block-hint">
{{ t('cashback.use_default_rate', { rate: `${editPlayerForm.defaultCashbackRate.toFixed(2)}%` }) }}
</p>
<div class="edit-form-section">
<div class="password-mgmt-block">
<div class="block-title">{{ t('user.section.password_mgmt') }}</div>
<div class="password-field-row">
<span class="password-field-label">{{ t('user.field.current_password') }}</span>
<span v-if="editPlayerForm.managedPassword" class="password-plain">{{ editPlayerForm.managedPassword }}</span>
<span v-else class="password-empty"></span>
</div>
<p v-if="!editPlayerForm.managedPassword" class="field-hint block-hint">{{ t('user.hint.password_reset_to_view') }}</p>
<div class="password-field-row">
<span class="password-field-label">{{ t('user.field.reset_password') }}</span>
<el-input v-model="editPlayerForm.newPassword" type="text" autocomplete="off" :placeholder="t('user.ph.reset_password_short')" />
</div>
</div>
</el-form-item>
<el-form-item :label="t('user.field.phone')">
<el-input v-model="editPlayerForm.phone" :placeholder="t('common.optional')" />
</el-form-item>
<el-form-item :label="t('user.field.email')">
<el-input v-model="editPlayerForm.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(editPlayerForm.availableBalance) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.frozen_balance')">{{ formatAmount(editPlayerForm.frozenBalance) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.bets_summary')">{{ t('user.bets_edit_value', { n: editPlayerForm.betCount, stake: formatAmount(editPlayerForm.totalStake) }) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.total_payout')">{{ formatAmount(editPlayerForm.totalReturn) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.col.last_login')" :span="2">
{{ editPlayerForm.lastLoginAt ? formatTime(editPlayerForm.lastLoginAt) : t('common.never_login') }}
· {{ t('user.login_fail_value', { n: editPlayerForm.loginFailCount }) }}
</el-descriptions-item>
</el-descriptions>
</div>
<div class="edit-form-section">
<div class="section-title">{{ t('user.section.affiliation') }}</div>
<el-form-item :label="t('user.col.agent')">
<el-tag size="small" type="info" class="affiliation-tag">{{ affiliationLabel(editPlayerForm) }}</el-tag>
<div class="field-hint">{{ t('user.hint.agent_readonly') }}</div>
</el-form-item>
<el-form-item :label="t('agent.field.cashback_rate')">
<div class="cashback-edit-block">
<el-checkbox v-model="editPlayerForm.useCustomCashback">
{{ t('cashback.use_custom_rate') }}
</el-checkbox>
<RatePercentInput
v-if="editPlayerForm.useCustomCashback"
v-model="editPlayerForm.customCashbackRate"
/>
<span v-else class="field-hint inline-hint">
{{ `${editPlayerForm.defaultCashbackRate.toFixed(2)}%` }}
</span>
</div>
</el-form-item>
</div>
<div class="edit-form-section">
<div class="section-title">{{ t('user.section.contact') }}</div>
<el-row :gutter="12" class="contact-row">
<el-col :span="12">
<el-form-item :label="t('user.field.phone')">
<el-input v-model="editPlayerForm.phone" :placeholder="t('common.optional')" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="t('user.field.email')">
<el-input v-model="editPlayerForm.email" :placeholder="t('common.optional')" />
</el-form-item>
</el-col>
</el-row>
</div>
<div class="edit-form-section edit-stats-panel">
<div class="section-title">{{ t('user.section.account_overview') }}</div>
<AdminDetailGrid :columns="3">
<AdminDetailItem :label="t('user.field.available')">{{ formatAmount(editPlayerForm.availableBalance) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.frozen_balance')">{{ formatAmount(editPlayerForm.frozenBalance) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.bets_summary')">
{{ t('user.bets_edit_value', { n: editPlayerForm.betCount, stake: formatAmount(editPlayerForm.totalStake) }) }}
</AdminDetailItem>
<AdminDetailItem :label="t('user.field.total_payout')">{{ formatAmount(editPlayerForm.totalReturn) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.col.last_login')" :span="2">
{{ editPlayerForm.lastLoginAt ? formatTime(editPlayerForm.lastLoginAt) : t('common.never_login') }}
· {{ t('user.login_fail_value', { n: editPlayerForm.loginFailCount }) }}
</AdminDetailItem>
</AdminDetailGrid>
</div>
</el-form>
<template #footer>
<el-button size="small" @click="editPlayerVisible = false">{{ t('common.cancel') }}</el-button>
@@ -2132,11 +2175,7 @@ function creditTypeLabel(type: string) {
<AdminDetailItem :label="t('user.col.agent')">{{ affiliationLabel(playerDetail) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.col.invite_code')">{{ playerDetail.inviteCode ?? '—' }}</AdminDetailItem>
<AdminDetailItem :label="t('agent.field.cashback_rate')">
{{
playerDetail.customCashbackRate != null
? formatRatePercent(playerDetail.customCashbackRate)
: t('cashback.use_default_rate', { rate: formatRatePercent(playerDetail.defaultCashbackRate) })
}}
{{ formatRatePercent(playerDetail.customCashbackRate ?? playerDetail.defaultCashbackRate) }}
</AdminDetailItem>
<AdminDetailItem :label="t('user.field.available')">
{{ formatAmount(playerDetail.availableBalance) }}
@@ -2161,11 +2200,6 @@ function creditTypeLabel(type: string) {
<span v-if="!playerDetail.managedPassword" class="admin-detail-hint">{{ t('user.hint.password_reset_to_view') }}</span>
</AdminDetailItem>
</AdminDetailGrid>
<div class="detail-actions">
<el-button type="primary" link @click="openPlayerWalletLedger(playerDetail.id, playerDetail.username)">
{{ t('user.action.view_wallet_ledger') }}
</el-button>
</div>
</template>
</el-dialog>
@@ -2571,41 +2605,6 @@ function creditTypeLabel(type: string) {
}
.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;