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

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, watch, reactive } from 'vue';
import { useAdminLocale } from '../../composables/useAdminLocale';
import { useAuthStore } from '../../stores/auth';
import api from '../../api';
@@ -29,13 +29,16 @@ import {
shouldToggleExpandOnRowClick,
expandableTableRowClassName,
} from '../../utils/expandable-table';
import type { TableInstance } from 'element-plus';
import WalletTransferContext from '../../components/WalletTransferContext.vue';
import InitialDepositRemarkField from '../../components/InitialDepositRemarkField.vue';
import AgentCreditContext from '../../components/AgentCreditContext.vue';
import PlayerWalletLedgerDialog from '../../components/PlayerWalletLedgerDialog.vue';
import InviteManageDialog from '../../components/InviteManageDialog.vue';
import AdminTableWrap from '../../components/AdminTableWrap.vue';
import AdminAgentRowActions from '../../components/AdminAgentRowActions.vue';
import AdminPlayerRowActions from '../../components/AdminPlayerRowActions.vue';
import AdminRowActionsDropdown from '../../components/AdminRowActionsDropdown.vue';
import AdminDetailGrid from '../../components/AdminDetailGrid.vue';
import AdminDetailItem from '../../components/AdminDetailItem.vue';
import {
depositAmountCap,
parsePlayerAvailable,
@@ -46,6 +49,15 @@ import {
type AgentCreditAdjustContext,
} from '../../utils/agent-credit-context';
import { formatAgentLevelNumeral } from '../../utils/agent-level-label';
import { formatRatePercent } from '../../utils/rate-percent';
type ScopedPlayerRow = AgentPlayerRow & {
parentAgentId?: string | null;
parentAgentUsername?: string;
cashbackRate?: string;
inChain?: boolean;
isDirect?: boolean;
};
const { t, localeTag } = useAdminLocale();
const auth = useAuthStore();
@@ -53,9 +65,11 @@ const inviteDialogOpen = ref(false);
const profile = ref<{
level?: number;
maxAgentLevel?: number;
creditLimit?: string;
usedCredit?: string;
availableCredit?: string;
cashbackRate?: string;
canManageSubAgents?: boolean;
}>({});
@@ -63,19 +77,53 @@ const canManageSubAgents = computed(
() => profile.value.canManageSubAgents === true || auth.canManageSubAgents.value,
);
const myAgentId = computed(() => String(auth.user.value?.id ?? ''));
const myAgentLevel = computed(() => profile.value.level ?? auth.user.value?.agentLevel ?? 1);
const childAgentLevel = computed(() => myAgentLevel.value + 1);
const hierarchySettings = computed(() => ({ maxAgentLevel: profile.value.maxAgentLevel ?? 0 }));
function lvlLabel(level: number) {
return formatAgentLevelNumeral(level, localeTag.value);
}
const childAgentTierName = computed(() =>
t('agent.level_name', { level: lvlLabel(childAgentLevel.value) }),
);
function agentTierName(level: number) {
return t('agent.level_name', { level: lvlLabel(level) });
}
const subAgentsTabLabel = computed(() =>
`${childAgentTierName.value} (${subAgents.value.length})`,
function agentLevelTabName(level: number) {
return `agentLevel-${level}`;
}
const agentLevelCounts = ref<Record<number, number>>({});
function agentLevelTabLabel(level: number) {
const count = agentLevelCounts.value[level] ?? 0;
return `${agentTierName(level)} (${count})`;
}
const visibleSubAgentTabLevels = computed(() => {
if (!canManageSubAgents.value) return [] as number[];
const counts = agentLevelCounts.value;
const max = hierarchySettings.value.maxAgentLevel;
const start = childAgentLevel.value;
const levels = new Set<number>([start]);
if (max === 0) {
for (const [lvl, cnt] of Object.entries(counts)) {
const n = Number(lvl);
if (n > myAgentLevel.value && cnt > 0) levels.add(n);
}
} else {
for (let n = start + 1; n <= max; n++) {
if (n > myAgentLevel.value && (counts[n] ?? 0) > 0) levels.add(n);
}
}
return [...levels].sort((a, b) => a - b);
});
const scopedAgentOptions = computed(() =>
agentOptions.value.filter(
(a) => a.id === myAgentId.value || a.level > myAgentLevel.value,
),
);
const createSubBtnLabel = computed(() => {
@@ -88,23 +136,55 @@ const createSubDialogTitle = computed(() => {
return t('agent.dialog.create_level_agent', { level: lvlLabel(childAgentLevel.value) });
});
const noSubAgentsHint = computed(() => {
if (childAgentLevel.value === 2) return t('agent_portal.no_sub_agents');
return t('agent_portal.no_sub_agents_level', { level: lvlLabel(childAgentLevel.value) });
});
const expandReadonlyHint = computed(() =>
t('agent_portal.sub_agent_downline_readonly_level', { level: lvlLabel(childAgentLevel.value) }),
);
const subAgentPlayersReadonlyHint = computed(() => {
if (childAgentLevel.value === 2) return t('agent_portal.sub_agent_players_readonly');
return t('agent_portal.sub_agent_players_readonly_level', { level: lvlLabel(childAgentLevel.value) });
});
/* ─── View tab: players | agentLevel-N ─── */
const activeViewTab = ref('players');
/* ─── Top-level tab: players | subAgents ─── */
const activeTab = ref('players');
/* ─── Players ─── */
const players = ref<AgentPlayerRow[]>([]);
/* ─── All players (subtree) ─── */
const allPlayers = ref<ScopedPlayerRow[]>([]);
const playerTotal = ref(0);
const playerPage = ref(1);
const playerPageSize = ref(20);
const playerKeyword = ref('');
const playerFilterStatus = ref('');
const playerFilterAgent = ref('');
const loadingPlayers = ref(false);
const keyword = ref('');
const agentOptions = ref<{ id: string; username: string; level: number; parentUsername?: string | null }[]>([]);
/* ─── Sub-agent lists by level ─── */
type SubAgentLevelState = {
agents: AgentSubAgentRow[];
total: number;
page: number;
pageSize: number;
keyword: string;
filterStatus: string;
};
const subAgentLevelState = reactive<Record<number, SubAgentLevelState>>({});
function ensureSubAgentState(level: number): SubAgentLevelState {
if (!subAgentLevelState[level]) {
subAgentLevelState[level] = {
agents: [],
total: 0,
page: 1,
pageSize: 20,
keyword: '',
filterStatus: '',
};
}
return subAgentLevelState[level];
}
/* ─── Agent row expansion (direct players) ─── */
const expandedSet = ref(new Set<string>());
const expandedRowKeys = computed(() => Array.from(expandedSet.value));
const agentPlayersMap = ref<Record<string, ScopedPlayerRow[]>>({});
const expandLoading = ref<Record<string, boolean>>({});
const createVisible = ref(false);
const createLoading = ref(false);
@@ -117,23 +197,11 @@ const editForm = ref(emptyAgentPlayerEditForm());
const transferVisible = ref(false);
const transferLoading = ref(false);
const transferType = ref<'deposit' | 'withdraw'>('deposit');
const transferTarget = ref<AgentPlayerRow | null>(null);
const transferTarget = ref<ScopedPlayerRow | null>(null);
const transferAmount = ref(100);
const transferContext = ref<WalletTransferContextData | null>(null);
const transferContextLoading = ref(false);
/* ─── Sub-agents ─── */
const subAgentKeyword = ref('');
const subAgents = ref<AgentSubAgentRow[]>([]);
const loadingAgents = ref(false);
const subAgentTableRef = ref<TableInstance>();
/* Sub-agent expansion */
const expandedSet = ref(new Set<string>());
const subAgentPlayersMap = ref<Record<string, AgentPlayerRow[]>>({});
const subAgentExpandLoading = ref<Record<string, boolean>>({});
/* Sub-agent create dialog */
const createSubVisible = ref(false);
const createSubLoading = ref(false);
const createSubForm = ref(emptyAgentSubAgentCreateForm());
@@ -157,21 +225,28 @@ const availableCreditNum = computed(() => {
const initialDepositRange = computed(() => ({ min: 0, max: availableCreditNum.value }));
const filteredPlayers = computed(() => {
const q = keyword.value.trim().toLowerCase();
if (!q) return players.value;
return players.value.filter(
(p) => p.username.toLowerCase().includes(q) || String(p.id).includes(q),
);
});
const playerTabLabel = computed(() => `${t('user.type.player')} (${playerTotal.value})`);
const filteredSubAgents = computed(() => {
const q = subAgentKeyword.value.trim().toLowerCase();
if (!q) return subAgents.value;
return subAgents.value.filter(
(a) => a.username.toLowerCase().includes(q) || a.userId.includes(q),
);
});
function agentOptionLabel(a: { username: string; id: string; level: number; parentUsername?: string | null }) {
const chain = a.parentUsername ? `${a.parentUsername} / ${a.username}` : a.username;
return `L${a.level} ${chain} (#${a.id})`;
}
function isDirectChildAgent(row: AgentSubAgentRow) {
return row.parentAgentId === myAgentId.value;
}
function canViewPlayer(row: ScopedPlayerRow) {
return row.inChain !== false;
}
function canOperatePlayer(row: ScopedPlayerRow) {
return row.isDirect === true && canViewPlayer(row);
}
function directPlayersTabLabel(ownerName: string, count: number) {
return `${t('agent.direct_players_title', { name: ownerName })} (${count})`;
}
const transferAmountRange = computed(() => {
if (transferType.value === 'withdraw') {
@@ -207,9 +282,23 @@ const transferAmountCapError = computed(() => {
/* ─── Init ─── */
onMounted(async () => {
await loadProfile();
await loadPlayers();
await loadAgentOptions();
await loadAllPlayers();
if (canManageSubAgents.value) {
await loadSubAgents();
await reloadSubAgentTabs();
}
});
watch(activeViewTab, (tab) => {
const m = /^agentLevel-(\d+)$/.exec(tab);
if (m) loadSubAgentsAtLevel(Number(m[1]));
});
watch(visibleSubAgentTabLevels, (levels, prev) => {
for (const lvl of levels) {
if (!prev?.includes(lvl) || !subAgentLevelState[lvl]?.agents.length) {
loadSubAgentsAtLevel(lvl);
}
}
});
@@ -220,70 +309,175 @@ async function loadProfile() {
} catch { /* empty */ }
}
async function loadPlayers() {
async function loadAgentOptions() {
try {
const { data } = await api.get('/agent/agents/options');
agentOptions.value = data.data ?? [];
} catch {
agentOptions.value = [];
}
}
async function loadAllPlayers() {
loadingPlayers.value = true;
try {
const { data } = await api.get('/agent/players');
players.value = data.data as AgentPlayerRow[];
const { data } = await api.get('/agent/players/scoped', {
params: {
page: playerPage.value,
pageSize: playerPageSize.value,
keyword: playerKeyword.value.trim() || undefined,
status: playerFilterStatus.value || undefined,
parentAgentId: playerFilterAgent.value || undefined,
},
});
allPlayers.value = (data.data.items ?? []) as ScopedPlayerRow[];
playerTotal.value = data.data.total ?? 0;
} catch {
allPlayers.value = [];
playerTotal.value = 0;
} finally {
loadingPlayers.value = false;
}
}
function searchPlayers() {
playerPage.value = 1;
loadAllPlayers();
}
function onPlayerPageChange(p: number) {
playerPage.value = p;
loadAllPlayers();
}
function onPlayerSizeChange(size: number) {
playerPageSize.value = size;
playerPage.value = 1;
loadAllPlayers();
}
async function loadAgentLevelCounts() {
try {
const { data } = await api.get('/agent/agents/level-counts');
const raw = data.data ?? {};
const normalized: Record<number, number> = {};
for (const [lvl, cnt] of Object.entries(raw)) {
normalized[Number(lvl)] = Number(cnt) || 0;
}
agentLevelCounts.value = normalized;
} catch {
agentLevelCounts.value = {};
}
}
async function loadSubAgentsAtLevel(level: number) {
const st = ensureSubAgentState(level);
try {
const { data } = await api.get('/agent/agents/by-level', {
params: {
level,
page: st.page,
pageSize: st.pageSize,
keyword: st.keyword.trim() || undefined,
status: st.filterStatus || undefined,
},
});
st.agents = (data.data.items ?? []) as AgentSubAgentRow[];
st.total = data.data.total ?? 0;
} catch {
st.agents = [];
st.total = 0;
}
}
function onSubAgentPageChange(level: number, p: number) {
ensureSubAgentState(level).page = p;
loadSubAgentsAtLevel(level);
}
function onSubAgentSizeChange(level: number, size: number) {
const st = ensureSubAgentState(level);
st.pageSize = size;
st.page = 1;
loadSubAgentsAtLevel(level);
}
function bindSubAgentPageChange(level: number) {
return (p: number) => onSubAgentPageChange(level, p);
}
function bindSubAgentSizeChange(level: number) {
return (size: number) => onSubAgentSizeChange(level, size);
}
function searchSubAgentsAtLevel(level: number) {
ensureSubAgentState(level).page = 1;
loadSubAgentsAtLevel(level);
}
async function reloadSubAgentTabs() {
await loadAgentLevelCounts();
for (const lvl of visibleSubAgentTabLevels.value) {
await loadSubAgentsAtLevel(lvl);
}
}
const walletLedgerVisible = ref(false);
const walletLedgerPlayerId = ref('');
const walletLedgerPlayerUsername = ref<string | null>(null);
function openPlayerWalletLedger(playerId: string, playerUsername?: string | null) {
function openPlayerWalletLedger(
playerId: string,
playerUsername?: string | null,
row?: ScopedPlayerRow,
) {
if (row && !canViewPlayer(row)) return;
walletLedgerPlayerId.value = playerId;
walletLedgerPlayerUsername.value = playerUsername ?? null;
walletLedgerVisible.value = true;
}
async function loadSubAgents() {
loadingAgents.value = true;
try {
const { data } = await api.get('/agent/agents');
const items = data.data;
if (Array.isArray(items)) {
subAgents.value = items as AgentSubAgentRow[];
} else {
subAgents.value = [];
}
} catch {
subAgents.value = [];
} finally {
loadingAgents.value = false;
}
}
/* ─── Sub-agent expansion ─── */
async function onSubAgentExpand(row: AgentSubAgentRow, expandedRows: AgentSubAgentRow[]) {
expandedSet.value = new Set(expandedRows.map(r => r.userId));
if (expandedSet.value.has(row.userId) && !subAgentPlayersMap.value[row.userId]) {
await loadSubAgentPlayers(row.userId);
/* ─── Agent row expansion ─── */
async function onExpandChange(row: AgentSubAgentRow, expandedRows: AgentSubAgentRow[]) {
expandedSet.value = new Set(expandedRows.map((r) => r.userId));
if (expandedSet.value.has(row.userId) && !agentPlayersMap.value[row.userId]) {
await loadExpansionData(row.userId);
}
}
function onSubAgentRowClick(row: AgentSubAgentRow, _column: unknown, event: MouseEvent) {
if (!shouldToggleExpandOnRowClick(event)) return;
subAgentTableRef.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 loadSubAgentPlayers(subAgentUserId: string) {
subAgentExpandLoading.value[subAgentUserId] = true;
async function loadExpansionData(agentUserId: string) {
expandLoading.value[agentUserId] = true;
try {
const { data } = await api.get(`/agent/agents/${subAgentUserId}/players`);
subAgentPlayersMap.value[subAgentUserId] = data.data as AgentPlayerRow[];
const { data } = await api.get(`/agent/agents/${agentUserId}/players`);
agentPlayersMap.value[agentUserId] = (data.data ?? []) as ScopedPlayerRow[];
} catch {
subAgentPlayersMap.value[subAgentUserId] = [];
agentPlayersMap.value[agentUserId] = [];
} finally {
subAgentExpandLoading.value[subAgentUserId] = false;
expandLoading.value[agentUserId] = false;
}
}
function getSubAgentPlayers(userId: string) {
return subAgentPlayersMap.value[userId] || [];
function getPlayers(agentUserId: string) {
return agentPlayersMap.value[agentUserId] ?? [];
}
function refreshExpandedAgentPlayers() {
for (const uid of expandedSet.value) {
void loadExpansionData(uid);
}
}
/* ─── Player CRUD ─── */
@@ -310,7 +504,7 @@ async function submitCreate() {
await api.post('/agent/players', payload);
ElMessage.success(t('user.msg.created_with_password', { password }));
createVisible.value = false;
loadPlayers();
loadAllPlayers();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.create_failed'));
@@ -319,7 +513,8 @@ async function submitCreate() {
}
}
async function openEdit(row: AgentPlayerRow) {
async function openEdit(row: ScopedPlayerRow) {
if (!canOperatePlayer(row)) return;
try {
const { data } = await api.get(`/agent/players/${row.id}`);
editForm.value = editFormFromAgentDetail(data.data as AgentPlayerDetail);
@@ -347,13 +542,13 @@ async function submitEdit() {
editForm.value.managedPassword = updated.managedPassword ?? newPwd;
editForm.value.newPassword = '';
ElMessage.success(t('user.msg.password_saved', { password: editForm.value.managedPassword }));
loadPlayers();
loadAllPlayers();
return;
}
ElMessage.success(t('msg.saved'));
editVisible.value = false;
loadPlayers();
refreshSubAgentPlayers();
loadAllPlayers();
refreshExpandedAgentPlayers();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
@@ -363,7 +558,8 @@ async function submitEdit() {
}
/* ─── Freeze ─── */
async function toggleFreeze(row: AgentPlayerRow) {
async function toggleFreeze(row: ScopedPlayerRow) {
if (!canOperatePlayer(row)) return;
const freeze = row.status === 'ACTIVE';
const action = freeze ? t('common.freeze') : t('common.unfreeze');
try {
@@ -376,8 +572,8 @@ async function toggleFreeze(row: AgentPlayerRow) {
try {
await api.put(`/agent/players/${row.id}`, { status: freeze ? 'SUSPENDED' : 'ACTIVE' });
ElMessage.success(t('msg.freeze_done', { action }));
loadPlayers();
refreshSubAgentPlayers();
loadAllPlayers();
refreshExpandedAgentPlayers();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.freeze_failed', { action }));
@@ -385,7 +581,8 @@ async function toggleFreeze(row: AgentPlayerRow) {
}
/* ─── Transfer ─── */
async function openTransfer(type: 'deposit' | 'withdraw', row: AgentPlayerRow) {
async function openTransfer(type: 'deposit' | 'withdraw', row: ScopedPlayerRow) {
if (!canOperatePlayer(row)) return;
transferType.value = type;
transferTarget.value = row;
transferContext.value = null;
@@ -434,7 +631,7 @@ async function submitTransfer() {
ElMessage.success(t('msg.withdraw_ok'));
}
transferVisible.value = false;
loadPlayers();
loadAllPlayers();
loadProfile();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
@@ -470,8 +667,9 @@ async function submitCreateSub() {
await api.post('/agent/agents', payload);
ElMessage.success(t('msg.agent_created'));
createSubVisible.value = false;
loadSubAgents();
await reloadSubAgentTabs();
loadProfile();
activeViewTab.value = agentLevelTabName(childAgentLevel.value);
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.create_failed'));
@@ -508,12 +706,12 @@ async function submitEditSub() {
editSubForm.value.managedPassword = updated.managedPassword ?? newPwd;
editSubForm.value.newPassword = '';
ElMessage.success(t('user.msg.password_saved', { password: editSubForm.value.managedPassword }));
loadSubAgents();
await reloadSubAgentTabs();
return;
}
ElMessage.success(t('msg.saved'));
editSubVisible.value = false;
loadSubAgents();
await reloadSubAgentTabs();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
@@ -557,7 +755,7 @@ async function submitCreditSub() {
});
ElMessage.success(t('msg.credit_adjusted'));
creditVisible.value = false;
loadSubAgents();
await reloadSubAgentTabs();
loadProfile();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
@@ -581,7 +779,7 @@ async function toggleFreezeSub(row: AgentSubAgentRow) {
try {
await api.put(`/agent/agents/${row.userId}`, { status: freeze ? 'SUSPENDED' : 'ACTIVE' });
ElMessage.success(t('msg.freeze_done', { action }));
loadSubAgents();
await reloadSubAgentTabs();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.freeze_failed', { action }));
@@ -589,12 +787,6 @@ async function toggleFreezeSub(row: AgentSubAgentRow) {
}
/* ─── Helpers ─── */
function refreshSubAgentPlayers() {
for (const uid of expandedSet.value) {
loadSubAgentPlayers(uid);
}
}
function creditLine(row: AgentSubAgentRow) {
return `${formatAmount(row.creditLimit)} / ${formatAmount(row.usedCredit)} / ${formatAmount(row.availableCredit)}`;
}
@@ -626,6 +818,11 @@ function statusTagType(s: string) {
<div class="admin-list-page agent-portal-mgr">
<!-- Credit strip -->
<div class="credit-strip">
<div class="credit-item">
<span class="credit-label">{{ t('agent_portal.my_cashback_rate') }}</span>
<span class="credit-value c-green">{{ formatRatePercent(profile.cashbackRate) }}</span>
</div>
<div class="credit-divider" />
<div class="credit-item">
<span class="credit-label">{{ t('agent.field.available_credit') }}</span>
<span class="credit-value c-green">{{ formatAmount(profile.availableCredit) }}</span>
@@ -649,13 +846,43 @@ function statusTagType(s: string) {
{{ t('invite.menu_btn') }}
</el-button>
<!-- Top-level tabs -->
<el-tabs v-model="activeTab" class="portal-top-tabs portal-top-tabs--with-invite">
<!-- Tab: 直属玩家 -->
<el-tab-pane :label="`${t('nav.players')} (${players.length})`" name="players">
<div class="inner-toolbar">
<el-form inline size="small" style="flex: 1">
<el-form-item :label="t('common.search')">
<el-input v-model="keyword" :placeholder="t('agent_portal.search_player_ph')" clearable style="width: 180px" />
<el-tabs v-model="activeViewTab" class="portal-top-tabs portal-top-tabs--with-invite">
<!-- Tab: 玩家 -->
<el-tab-pane :label="playerTabLabel" name="players">
<div class="inner-toolbar list-panel-toolbar">
<el-form inline size="small" class="list-chrome__grow">
<el-form-item :label="t('common.keyword')">
<el-input
v-model="playerKeyword"
:placeholder="t('agent_portal.search_player_ph')"
clearable
style="width: 180px"
@keyup.enter="searchPlayers"
/>
</el-form-item>
<el-form-item :label="t('user.filter.agent')">
<el-select
v-model="playerFilterAgent"
:placeholder="t('user.filter.agent_ph')"
clearable
style="width: 200px"
>
<el-option
v-for="a in scopedAgentOptions"
:key="a.id"
:label="agentOptionLabel(a)"
:value="a.id"
/>
</el-select>
</el-form-item>
<el-form-item :label="t('common.status')">
<el-select v-model="playerFilterStatus" :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" size="small" @click="searchPlayers">{{ t('common.search') }}</el-button>
</el-form-item>
</el-form>
<el-button type="primary" size="small" @click="openCreate">
@@ -663,10 +890,19 @@ function statusTagType(s: string) {
</el-button>
</div>
<el-table v-loading="loadingPlayers" :data="filteredPlayers" stripe size="small" class="inner-table">
<AdminTableWrap>
<el-table v-loading="loadingPlayers" :data="allPlayers" stripe size="small" class="inner-table">
<template #empty><div class="empty-hint">{{ t('agent_portal.no_players') }}</div></template>
<el-table-column prop="id" :label="t('common.col_id')" width="60" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
<el-table-column :label="t('user.col.agent')" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ row.parentAgentUsername ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column :label="t('user.col.player_cashback')" min-width="88" align="right">
<template #default="{ row }">{{ formatRatePercent(row.cashbackRate) }}</template>
</el-table-column>
<el-table-column :label="t('user.col.invite_code')" min-width="96" show-overflow-tooltip>
<template #default="{ row }">
<code v-if="row.inviteCode" class="invite-code-cell">{{ row.inviteCode }}</code>
@@ -680,7 +916,7 @@ function statusTagType(s: string) {
</el-table-column>
<el-table-column :label="t('user.field.available')" min-width="100" align="right">
<template #default="{ row }">
<template v-if="row.wallet?.availableBalance != null">
<template v-if="canViewPlayer(row) && row.wallet?.availableBalance != null">
<el-tooltip :content="formatAmountFull(row.wallet.availableBalance)" placement="top">
<span>{{ formatAmount(row.wallet.availableBalance) }}</span>
</el-tooltip>
@@ -688,91 +924,171 @@ function statusTagType(s: string) {
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" min-width="300" align="center" fixed="right">
<el-table-column :label="t('user.col.created')" min-width="148">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" min-width="300" align="center">
<template #default="{ row }">
<AdminPlayerRowActions
v-if="canOperatePlayer(row)"
:row="row"
:show-detail="false"
@ledger="openPlayerWalletLedger(row.id, row.username)"
@ledger="openPlayerWalletLedger(row.id, row.username, row)"
@edit="openEdit(row)"
@deposit="openTransfer('deposit', row)"
@withdraw="openTransfer('withdraw', row)"
@freeze="toggleFreeze(row)"
/>
<el-button
v-else-if="canViewPlayer(row)"
link
type="primary"
size="small"
@click="openPlayerWalletLedger(row.id, row.username, row)"
>
{{ t('user.action.ledger_short') }}
</el-button>
<span v-else></span>
</template>
</el-table-column>
</el-table>
</AdminTableWrap>
<div class="pager">
<el-pagination
v-model:current-page="playerPage"
v-model:page-size="playerPageSize"
:total="playerTotal"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
background
small
@current-change="onPlayerPageChange"
@size-change="onPlayerSizeChange"
/>
</div>
</el-tab-pane>
<!-- Tab: 下级代理 (仅一级代理可见) -->
<el-tab-pane v-if="canManageSubAgents" :label="subAgentsTabLabel" name="subAgents">
<div class="inner-toolbar">
<el-form inline size="small" style="flex: 1">
<el-form-item :label="t('common.search')">
<el-input v-model="subAgentKeyword" :placeholder="t('agent_portal.search_sub_agent_ph')" clearable style="width: 180px" />
<!-- Tab: 各级代理 -->
<el-tab-pane
v-for="agentLevel in visibleSubAgentTabLevels"
:key="agentLevel"
:label="agentLevelTabLabel(agentLevel)"
:name="agentLevelTabName(agentLevel)"
>
<div class="inner-toolbar list-panel-toolbar">
<el-form inline size="small" class="list-chrome__grow">
<el-form-item :label="t('common.keyword')">
<el-input
v-model="ensureSubAgentState(agentLevel).keyword"
:placeholder="t('agent_portal.search_sub_agent_ph')"
clearable
style="width: 180px"
@keyup.enter="searchSubAgentsAtLevel(agentLevel)"
/>
</el-form-item>
<el-form-item :label="t('common.status')">
<el-select
v-model="ensureSubAgentState(agentLevel).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" size="small" @click="searchSubAgentsAtLevel(agentLevel)">{{ t('common.search') }}</el-button>
</el-form-item>
</el-form>
<el-button type="primary" size="small" @click="openCreateSub">
<el-button
v-if="agentLevel === childAgentLevel"
type="primary"
size="small"
@click="openCreateSub"
>
{{ createSubBtnLabel }}
</el-button>
</div>
<AdminTableWrap>
<el-table
ref="subAgentTableRef"
v-loading="loadingAgents"
:data="filteredSubAgents"
:data="ensureSubAgentState(agentLevel).agents"
stripe
size="small"
row-key="userId"
:expand-row-keys="expandedRowKeys"
:row-class-name="expandableTableRowClassName"
class="inner-table expandable-table"
@expand-change="onSubAgentExpand"
@expand-change="onExpandChange"
@row-click="onSubAgentRowClick"
>
<template #empty><div class="empty-hint">{{ noSubAgentsHint }}</div></template>
<template #empty>
<div class="empty-hint">
{{ agentLevel === childAgentLevel
? (childAgentLevel === 2 ? t('agent_portal.no_sub_agents') : t('agent_portal.no_sub_agents_level', { level: lvlLabel(agentLevel) }))
: t('agent_portal.no_sub_agents_level', { level: lvlLabel(agentLevel) }) }}
</div>
</template>
<el-table-column type="expand">
<template #default="{ row }">
<div class="expand-panel">
<div v-if="subAgentExpandLoading[row.userId]" class="expand-loading">
{{ t('common.loading') || '加载中...' }}
<div v-if="expandLoading[row.userId]" class="expand-loading">
{{ t('common.loading') }}
</div>
<template v-else>
<div class="expand-section-title">{{ t('nav.players') }} ({{ getSubAgentPlayers(row.userId).length }})</div>
<p class="expand-readonly-hint">{{ subAgentPlayersReadonlyHint }}</p>
<el-table :data="getSubAgentPlayers(row.userId)" stripe size="small" class="inner-table nested-table">
<template #empty><div class="empty-hint">暂无数据</div></template>
<div v-else class="expand-panel-body">
<p v-if="!isDirectChildAgent(row)" class="expand-readonly-hint">{{ expandReadonlyHint }}</p>
<div class="expand-section-title">
{{ directPlayersTabLabel(row.username, getPlayers(row.userId).length) }}
</div>
<el-table
:data="getPlayers(row.userId)"
stripe
size="small"
class="inner-table nested-table"
>
<template #empty><div class="empty-hint">{{ t('agent_portal.no_players') }}</div></template>
<el-table-column prop="id" :label="t('common.col_id')" width="60" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
<el-table-column :label="t('user.col.player_cashback')" min-width="88" align="right">
<template #default="{ row: player }">
{{ formatRatePercent(player.cashbackRate) }}
</template>
</el-table-column>
<el-table-column :label="t('user.col.invite_code')" min-width="96" show-overflow-tooltip>
<template #default="{ row: p }">
<code v-if="p.inviteCode" class="invite-code-cell">{{ p.inviteCode }}</code>
<template #default="{ row: player }">
<code v-if="player.inviteCode" class="invite-code-cell">{{ player.inviteCode }}</code>
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="80" align="center">
<template #default="{ row: p }">
<el-tag :type="statusTagType(p.status)" size="small">{{ statusLabel(p.status) }}</el-tag>
<template #default="{ row: player }">
<el-tag :type="statusTagType(player.status)" size="small">{{ statusLabel(player.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('user.field.available')" min-width="100" align="right">
<template #default="{ row: p }">
<template v-if="p.wallet?.availableBalance != null">
<span>{{ formatAmount(p.wallet.availableBalance) }}</span>
<template #default="{ row: player }">
<template v-if="canViewPlayer(player) && player.wallet?.availableBalance != null">
<span>{{ formatAmount(player.wallet.availableBalance) }}</span>
</template>
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('user.col.created')" min-width="148">
<template #default="{ row: p }">{{ formatTime(p.createdAt) }}</template>
<template #default="{ row: player }">{{ formatTime(player.createdAt) }}</template>
</el-table-column>
</el-table>
</template>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="userId" label="ID" width="60" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
<el-table-column v-if="agentLevel > childAgentLevel" :label="t('agent.col.parent_chain')" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.parentChainLabel ?? row.parentUsername ?? '—' }}</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="80" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(subAgentAccountStatus(row))" size="small">
@@ -787,23 +1103,41 @@ function statusTagType(s: string) {
</el-tooltip>
</template>
</el-table-column>
<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 prop="directPlayerCount" :label="t('agent.col.direct_players')" width="80" align="center" />
<el-table-column :label="t('user.col.created')" min-width="148">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</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="240" align="center">
<template #default="{ row }">
<AdminRowActionsDropdown>
<el-dropdown-item @click="openEditSub(row)">{{ t('common.edit') }}</el-dropdown-item>
<el-dropdown-item @click="openCreditSub(row)">{{ t('common.adjust_credit') }}</el-dropdown-item>
<el-dropdown-item v-if="subAgentAccountStatus(row) === 'ACTIVE'" divided @click="toggleFreezeSub(row)">
<span class="action-warning">{{ t('common.freeze') }}</span>
</el-dropdown-item>
<el-dropdown-item v-else divided @click="toggleFreezeSub(row)">{{ t('common.unfreeze') }}</el-dropdown-item>
</AdminRowActionsDropdown>
<AdminAgentRowActions
v-if="isDirectChildAgent(row)"
:row="{ userId: row.userId, status: subAgentAccountStatus(row) }"
:show-detail="false"
@edit="openEditSub(row)"
@credit="openCreditSub(row)"
@freeze="toggleFreezeSub(row)"
/>
<span v-else class="readonly-actions-hint"></span>
</template>
</el-table-column>
</el-table>
</AdminTableWrap>
<div class="pager">
<el-pagination
v-model:current-page="ensureSubAgentState(agentLevel).page"
v-model:page-size="ensureSubAgentState(agentLevel).pageSize"
:total="ensureSubAgentState(agentLevel).total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
background
small
@current-change="bindSubAgentPageChange(agentLevel)"
@size-change="bindSubAgentSizeChange(agentLevel)"
/>
</div>
</el-tab-pane>
</el-tabs>
</div>
@@ -837,8 +1171,15 @@ function statusTagType(s: string) {
<el-input-number v-model="createForm.initialDeposit" :min="initialDepositRange.min" :max="initialDepositRange.max" :step="100" style="width: 100%" />
<div class="field-hint">{{ t('agent_portal.initial_deposit_hint') }}</div>
</el-form-item>
<el-form-item v-if="createForm.initialDeposit > 0" :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="agent"
/>
</el-form-item>
</el-form>
<template #footer>
@@ -848,45 +1189,68 @@ function statusTagType(s: string) {
</el-dialog>
<!-- Edit Player -->
<el-dialog v-model="editVisible" :title="t('agent_portal.edit_player_dialog')" width="480px" destroy-on-close class="user-edit-dialog">
<el-dialog v-model="editVisible" :title="t('agent_portal.edit_player_dialog')" 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 {{ 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_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="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')" />
<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="editForm.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.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>
<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="editForm.managedPassword" class="password-plain">{{ editForm.managedPassword }}</span>
<span v-else class="password-empty"></span>
</div>
<p v-if="!editForm.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="editForm.newPassword" type="text" autocomplete="off" :placeholder="t('user.ph.reset_password_short')" />
</div>
</div>
</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="editForm.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="editForm.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(editForm.availableBalance) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.frozen_balance')">{{ formatAmount(editForm.frozenBalance) }}</AdminDetailItem>
<AdminDetailItem :label="t('user.field.bets_summary')">
{{ t('user.bets_edit_value', { n: editForm.betCount, stake: formatAmount(editForm.totalStake) }) }}
</AdminDetailItem>
<AdminDetailItem :label="t('user.field.total_payout')">{{ formatAmount(editForm.totalReturn) }}</AdminDetailItem>
<AdminDetailItem :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 }) }}
</AdminDetailItem>
</AdminDetailGrid>
</div>
</el-form>
<template #footer>
<el-button size="small" @click="editVisible = false">{{ t('common.cancel') }}</el-button>
@@ -954,7 +1318,7 @@ function statusTagType(s: string) {
</el-dialog>
<!-- Edit Sub-Agent -->
<el-dialog v-model="editSubVisible" :title="t('agent.dialog.edit')" width="480px" destroy-on-close>
<el-dialog v-model="editSubVisible" :title="t('agent.dialog.edit')" width="480px" destroy-on-close class="agent-edit-dialog">
<el-form label-width="84px" size="small" class="compact-edit-form">
<div class="edit-meta">
<span>ID {{ editSubForm.userId }}</span>
@@ -1113,6 +1477,7 @@ function statusTagType(s: string) {
.expand-panel { padding: 4px 16px 8px; }
.expand-loading { text-align: center; padding: 16px; color: #888; font-size: 13px; }
.expand-section-title { font-size: 13px; font-weight: 600; color: #888; margin-bottom: 6px; }
.expand-section-title--spaced { margin-top: 14px; }
.expand-readonly-hint { font-size: 12px; color: #999; margin: 0 0 8px; }
.nested-table { margin-bottom: 4px; }
@@ -1121,18 +1486,4 @@ function statusTagType(s: string) {
.empty-hint { padding: 32px 0; color: #666; font-size: 13px; }
.create-alert { margin-bottom: 16px; }
.create-form { margin-top: 4px; }
/* ─── Edit dialog ─── */
.edit-meta { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; font-size: 12px; color: #666; }
.password-mgmt-block {
margin: 8px 0 16px; padding: 12px 14px;
border-radius: 8px; border: 1px solid #1e1e1e;
background: rgba(255,255,255,.02);
}
.block-title { font-size: 12px; font-weight: 600; color: #888; margin-bottom: 10px; }
.password-plain { font-family: ui-monospace, monospace; color: #e0e0e0; }
.password-empty { color: #555; }
.block-hint { margin: -4px 0 10px; }
.edit-stats { margin-top: 8px; }
.compact-edit-form :deep(.el-form-item) { margin-bottom: 10px; }
</style>