feat: refactor agent manager, media library, and player UX

- Split admin users page into player/tier-1/tier-2 tabs with affiliation labels and context-specific create dialogs

- Add media library with uploaded_files migration, list/delete unused files API, and admin nav route

- Enforce player username format (alphanumeric 3-32) on frontend and backend via shared package

- Improve admin dialog/panel styling; refine player parlay and match bet card kickoff display

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-09 17:56:28 +08:00
parent d5e7c8edb3
commit df20444be9
27 changed files with 2136 additions and 563 deletions

View File

@@ -19,6 +19,8 @@ import {
type PlayerDetail,
type PlayerCreateForm,
type PlayerEditForm,
formatPlayerAffiliationLabel,
assertPlayerUsername,
} from './user-form';
import {
emptyAgentEditForm,
@@ -28,6 +30,8 @@ import {
type AgentEditForm,
} from './agent-form';
import { subAgentAccountStatus } from './agent/agent-sub-agent-form';
type DisplayAgentRow = AgentRow;
import {
formatAmount,
formatAmountFull,
@@ -47,26 +51,42 @@ import {
type AgentCreditAdjustContext,
} from '../utils/agent-credit-context';
/* ─── Main agent list ─── */
const agents = ref<AgentRow[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const keyword = ref('');
const filterStatus = ref('');
/* ─── Tier-1 agent list ─── */
const tier1Agents = ref<AgentRow[]>([]);
const tier1Total = ref(0);
const tier1Page = ref(1);
const tier1PageSize = ref(20);
const tier1Keyword = ref('');
const tier1FilterStatus = ref('');
/* ─── Tier-2 agent list ─── */
const tier2Agents = ref<AgentRow[]>([]);
const tier2Total = ref(0);
const tier2Page = ref(1);
const tier2PageSize = ref(20);
const tier2Keyword = ref('');
const tier2FilterStatus = ref('');
/* ─── View tab: players | tier1Agents | tier2Agents ─── */
const activeViewTab = ref('players');
/* ─── All players list ─── */
const allPlayers = ref<PlayerRow[]>([]);
const playerTotal = ref(0);
const playerPage = ref(1);
const playerPageSize = ref(20);
const playerKeyword = ref('');
const playerFilterStatus = ref('');
const playerFilterAgent = ref('');
const playerLoading = ref(false);
const agentOptions = ref<{ id: string; username: string; level: number; parentUsername?: string | null }[]>([]);
/* ─── Expansion state ─── */
const expandedSet = ref(new Set<string>());
const agentPlayersMap = ref<Record<string, PlayerRow[]>>({});
const agentSubAgentsMap = ref<Record<string, AgentRow[]>>({});
const expandLoading = ref<Record<string, boolean>>({});
const innerTabMap = ref<Record<string, string>>({});
const agentTableRef = ref();
/* Sub-agent nested expansion (players under tier-2 agents) */
const subAgentExpandedKeys = ref<string[]>([]);
const subAgentPlayersMap = ref<Record<string, PlayerRow[]>>({});
const subAgentExpandLoading = ref<Record<string, boolean>>({});
const tier1AgentTableRef = ref();
const tier2AgentTableRef = ref();
/* ─── Dialogs ─── */
const createVisible = ref(false);
@@ -82,7 +102,8 @@ const creditLoading = ref(false);
/* ─── Create form (unified) ─── */
const createForm = ref<PlayerCreateForm>(emptyPlayerCreateForm());
const createParentAgentId = ref(''); // set when creating from expansion
const createParentAgentId = ref('');
const createParentLocked = ref(false);
const createAccountMode = ref(0); // 0=player, 1=tier1Agent, 2=subAgent
/* ─── Edit forms ─── */
@@ -121,74 +142,201 @@ const resetLoading = ref(false);
const resetConfirmPhrase = ref('');
const settingsCollapseOpen = ref<string[]>([]);
const createDialogTitle = computed(() => {
if (createAccountMode.value === 1) return t('agent.dialog.create');
if (createAccountMode.value === 2) return t('agent_portal.create_sub_agent_dialog');
return t('user.dialog.create');
});
const tier1AgentOptions = computed(() => agentOptions.value.filter((a) => a.level === 1));
function agentOptionLabel(a: { username: string; id: string; level: number; parentUsername?: string | null }) {
if (a.level === 2 && a.parentUsername) {
return `${a.parentUsername} / ${a.username} (#${a.id})`;
}
return `${a.username} (#${a.id})`;
}
function resolveCreateParentLabel(agentId: string) {
const hit = agentOptions.value.find((a) => a.id === agentId);
if (hit) return agentOptionLabel(hit);
const tier1 = tier1Agents.value.find((a) => a.userId === agentId);
if (tier1) return tier1.username;
const tier2 = tier2Agents.value.find((a) => a.userId === agentId);
if (tier2) {
return tier2.parentUsername ? `${tier2.parentUsername} / ${tier2.username}` : tier2.username;
}
return agentId;
}
/* ─── Init ─── */
onMounted(() => {
loadPlayerSettings();
loadBettingLimits();
loadAgentSuspendSettings();
loadResetDatabaseStatus();
load();
loadAgentOptions();
loadAllPlayers();
loadTier1Agents();
loadTier2Agents();
});
/* ─── 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;
}
function onTier1PageChange(p: number) {
tier1Page.value = p;
loadTier1Agents();
}
function onTier1SizeChange(size: number) {
tier1PageSize.value = size;
tier1Page.value = 1;
loadTier1Agents();
}
function searchTier1Agents() {
tier1Page.value = 1;
loadTier1Agents();
}
/* ─── Load tier-2 agents ─── */
async function loadTier2Agents() {
const { data } = await api.get('/admin/agents', {
params: {
page: tier2Page.value,
pageSize: tier2PageSize.value,
keyword: tier2Keyword.value.trim() || undefined,
status: tier2FilterStatus.value || undefined,
level: 2,
},
});
tier2Agents.value = data.data.items as AgentRow[];
tier2Total.value = data.data.total;
}
function onTier2PageChange(p: number) {
tier2Page.value = p;
loadTier2Agents();
}
function onTier2SizeChange(size: number) {
tier2PageSize.value = size;
tier2Page.value = 1;
loadTier2Agents();
}
function searchTier2Agents() {
tier2Page.value = 1;
loadTier2Agents();
}
function reloadAgentLists() {
loadTier1Agents();
loadTier2Agents();
}
/* ─── Load main agent list ─── */
async function load() {
const { data } = await api.get('/admin/agents', {
params: {
page: page.value,
pageSize: pageSize.value,
keyword: keyword.value.trim() || undefined,
},
});
agents.value = data.data.items as AgentRow[];
total.value = data.data.total;
reloadAgentLists();
}
function onPageChange(p: number) {
page.value = p;
load();
async function loadAgentOptions() {
try {
const { data } = await api.get('/admin/agents/options');
agentOptions.value = data.data;
} catch {
agentOptions.value = [];
}
}
function onSizeChange(size: number) {
pageSize.value = size;
page.value = 1;
load();
async function loadAllPlayers() {
playerLoading.value = true;
try {
const params: Record<string, unknown> = {
page: playerPage.value,
pageSize: playerPageSize.value,
keyword: playerKeyword.value.trim() || undefined,
status: playerFilterStatus.value || undefined,
};
if (playerFilterAgent.value) {
params.parentId = playerFilterAgent.value;
}
const { data } = await api.get('/admin/users', { params });
allPlayers.value = data.data.items as PlayerRow[];
playerTotal.value = data.data.total;
} catch {
allPlayers.value = [];
playerTotal.value = 0;
} finally {
playerLoading.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();
}
function affiliationLabel(row: PlayerRow) {
return formatPlayerAffiliationLabel(row, t('user.type.player'), t('agent.platform_row_name'));
}
function directPlayersTabLabel(ownerName: string, count: number) {
return `${t('agent.direct_players_title', { name: ownerName })} (${count})`;
}
function onTier1AgentRowClick(row: AgentRow, _column: unknown, event: MouseEvent) {
onAgentRowClick(row, tier1AgentTableRef, _column, event);
}
function onTier2AgentRowClick(row: AgentRow, _column: unknown, event: MouseEvent) {
onAgentRowClick(row, tier2AgentTableRef, _column, event);
}
/* ─── Expansion ─── */
function getInnerTab(agentId: string) {
return innerTabMap.value[agentId] || 'players';
}
function setInnerTab(agentId: string, tab: string) {
innerTabMap.value[agentId] = tab;
}
async function onExpandChange(row: AgentRow, expandedRows: AgentRow[]) {
// Track expanded rows
expandedSet.value = new Set(expandedRows.map(r => r.userId));
// Load data if newly expanded
async function onExpandChange(row: DisplayAgentRow, expandedRows: DisplayAgentRow[]) {
expandedSet.value = new Set(expandedRows.map((r) => r.userId));
if (expandedSet.value.has(row.userId) && !agentPlayersMap.value[row.userId]) {
await loadExpansionData(row.userId);
}
}
function onAgentRowClick(row: AgentRow, _column: unknown, event: MouseEvent) {
function onAgentRowClick(row: AgentRow, tableRef: typeof tier1AgentTableRef, _column: unknown, event: MouseEvent) {
if (!shouldToggleExpandOnRowClick(event)) return;
agentTableRef.value?.toggleRowExpansion(row);
tableRef.value?.toggleRowExpansion(row);
}
async function loadExpansionData(agentId: string) {
expandLoading.value[agentId] = true;
try {
const [playersRes, subAgentsRes] = await Promise.all([
api.get('/admin/users', { params: { parentId: agentId, pageSize: 100 } }),
api.get('/admin/agents', { params: { parentAgentId: agentId, pageSize: 100 } }),
]);
agentPlayersMap.value[agentId] = playersRes.data.data.items;
agentSubAgentsMap.value[agentId] = subAgentsRes.data.data.items;
const { data } = await api.get('/admin/users', { params: { parentId: agentId, pageSize: 100 } });
agentPlayersMap.value[agentId] = data.data.items as PlayerRow[];
} catch {
agentPlayersMap.value[agentId] = [];
agentSubAgentsMap.value[agentId] = [];
} finally {
expandLoading.value[agentId] = false;
}
@@ -198,51 +346,9 @@ function getPlayers(agentId: string) {
return agentPlayersMap.value[agentId] || [];
}
function getSubAgents(agentId: string) {
return agentSubAgentsMap.value[agentId] || [];
}
async function loadSubAgentPlayers(subAgentUserId: string) {
subAgentExpandLoading.value[subAgentUserId] = true;
try {
const { data } = await api.get('/admin/users', {
params: { parentId: subAgentUserId, pageSize: 100 },
});
subAgentPlayersMap.value[subAgentUserId] = data.data.items as PlayerRow[];
} catch {
subAgentPlayersMap.value[subAgentUserId] = [];
} finally {
subAgentExpandLoading.value[subAgentUserId] = false;
}
}
function getSubAgentPlayers(subAgentUserId: string) {
return subAgentPlayersMap.value[subAgentUserId] || [];
}
function onSubAgentExpand(_parentAgentId: string, row: AgentRow, expandedRows: AgentRow[]) {
subAgentExpandedKeys.value = expandedRows.map((r) => r.userId);
if (!subAgentPlayersMap.value[row.userId]) {
loadSubAgentPlayers(row.userId);
}
}
function onSubAgentRowClick(_parentAgentId: string, row: AgentRow, _column: unknown, event: MouseEvent) {
if (!shouldToggleExpandOnRowClick(event)) return;
const id = row.userId;
if (subAgentExpandedKeys.value.includes(id)) {
subAgentExpandedKeys.value = subAgentExpandedKeys.value.filter((k) => k !== id);
} else {
subAgentExpandedKeys.value = [...subAgentExpandedKeys.value, id];
if (!subAgentPlayersMap.value[id]) {
loadSubAgentPlayers(id);
}
}
}
function refreshExpandedSubAgentPlayers() {
for (const subId of subAgentExpandedKeys.value) {
loadSubAgentPlayers(subId);
function refreshExpandedAgentPlayers() {
for (const agentId of expandedSet.value) {
loadExpansionData(agentId);
}
}
@@ -361,18 +467,30 @@ async function saveAgentSuspendSettings() {
}
/* ─── Create (unified) ─── */
function openCreateGlobal() {
function openCreateAccount() {
createForm.value = emptyPlayerCreateForm();
createForm.value.asTier1Agent = false;
createParentAgentId.value = '';
createParentLocked.value = false;
createAccountMode.value = 0;
createVisible.value = true;
}
function openCreateTier1Agent() {
createForm.value = emptyPlayerCreateForm();
createForm.value.asTier1Agent = true;
createParentAgentId.value = '';
createParentLocked.value = false;
createAccountMode.value = 1;
createVisible.value = true;
}
function openCreatePlayer(parentAgentUserId: string) {
createForm.value = emptyPlayerCreateForm();
createForm.value.asTier1Agent = false;
createForm.value.parentId = parentAgentUserId;
createParentAgentId.value = parentAgentUserId;
createParentLocked.value = true;
createAccountMode.value = 0;
createVisible.value = true;
}
@@ -381,27 +499,18 @@ function openCreateSubAgent(parentAgentUserId: string) {
createForm.value = emptyPlayerCreateForm();
createForm.value.asTier1Agent = false;
createParentAgentId.value = parentAgentUserId;
createParentLocked.value = true;
createAccountMode.value = 2;
createVisible.value = true;
}
function onAccountModeChange(mode: number) {
createAccountMode.value = mode;
if (mode === 0) {
// Player
createForm.value.asTier1Agent = false;
if (createParentAgentId.value) {
createForm.value.parentId = createParentAgentId.value;
}
} else if (mode === 1) {
// Tier-1 agent
createForm.value.asTier1Agent = true;
createForm.value.parentId = '';
} else if (mode === 2) {
// Sub-agent
createForm.value.asTier1Agent = false;
createForm.value.parentId = '';
}
function openCreateSubAgentFromToolbar() {
createForm.value = emptyPlayerCreateForm();
createForm.value.asTier1Agent = false;
createParentAgentId.value = '';
createParentLocked.value = false;
createAccountMode.value = 2;
createVisible.value = true;
}
async function submitCreate() {
@@ -410,7 +519,7 @@ async function submitCreate() {
let payload: Record<string, unknown>;
try {
if (isSubAgent) {
// Sub-agent creation
if (!createParentAgentId.value) throw new Error(t('agent.hint.sub_agent_parent'));
if (!createForm.value.username.trim()) throw new Error(t('err.username_required'));
if (createForm.value.password.length < 8) throw new Error(t('err.password_min'));
if (createForm.value.password !== createForm.value.confirmPassword) throw new Error(t('err.password_mismatch'));
@@ -450,7 +559,7 @@ async function submitCreate() {
refreshExpandedParents();
const parentId = createParentAgentId.value || createForm.value.parentId;
if (parentId) {
await loadSubAgentPlayers(parentId);
await loadExpansionData(parentId);
}
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
@@ -474,12 +583,17 @@ async function submitEditPlayer() {
ElMessage.warning(t('err.password_min'));
return;
}
try {
assertPlayerUsername(editPlayerForm.value.username);
} catch (e) {
ElMessage.warning(resolveFormError(e, t));
return;
}
editPlayerLoading.value = true;
try {
const newPwd = editPlayerForm.value.newPassword.trim();
const { data } = await api.put(`/admin/users/${editingId.value}`, {
username: editPlayerForm.value.username.trim(),
parentId: editPlayerForm.value.parentId || '',
phone: editPlayerForm.value.phone.trim() || undefined,
email: editPlayerForm.value.email.trim() || undefined,
password: newPwd || undefined,
@@ -724,10 +838,9 @@ async function toggleFreezeAgent(row: AgentRow) {
/* ─── Helpers ─── */
function refreshExpandedParents() {
for (const agentId of expandedSet.value) {
loadExpansionData(agentId);
}
refreshExpandedSubAgentPlayers();
loadAllPlayers();
reloadAgentLists();
refreshExpandedAgentPlayers();
}
const {
@@ -878,41 +991,156 @@ function creditTypeLabel(type: string) {
</el-collapse-item>
</el-collapse>
<!-- Filter bar -->
<div class="list-chrome">
<div class="list-chrome__row">
<el-tabs v-model="activeViewTab" class="mgr-top-tabs">
<!-- Tab: 全部玩家默认 -->
<el-tab-pane :label="`${t('user.type.player')} (${playerTotal})`" name="players">
<section class="list-panel player-list-panel">
<div class="list-panel-toolbar">
<el-form inline class="list-chrome__grow">
<el-form-item :label="t('common.keyword')">
<el-input
v-model="playerKeyword"
:placeholder="t('user.filter.username_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 agentOptions"
: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" @click="searchPlayers">{{ t('common.search') }}</el-button>
</el-form-item>
</el-form>
<div class="list-chrome__actions">
<el-button type="primary" @click="openCreateAccount">{{ t('user.create_btn') }}</el-button>
</div>
</div>
<div class="table-wrap">
<el-table v-loading="playerLoading" :data="allPlayers" 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="200">
<template #default="{ row }">
<el-tag size="small" type="info" class="affiliation-tag">{{ affiliationLabel(row) }}</el-tag>
</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 }">
<div class="action-btns">
<el-button size="small" link type="primary" @click="openDetailPlayer(row.id)">{{ t('common.detail') }}</el-button>
<el-button size="small" link type="primary" @click="openEditPlayer(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="toggleFreezePlayer(row)">{{ t('common.freeze') }}</el-button>
<el-button v-else size="small" link type="primary" @click="toggleFreezePlayer(row)">{{ t('common.unfreeze') }}</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
<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
@current-change="onPlayerPageChange"
@size-change="onPlayerSizeChange"
/>
</div>
</section>
</el-tab-pane>
<!-- Tab: 一级代理 -->
<el-tab-pane :label="`${t('user.type.tier1_agent')} (${tier1Total})`" name="tier1Agents">
<section class="list-panel agent-list-panel">
<div class="list-panel-toolbar">
<el-form inline class="list-chrome__grow">
<el-form-item :label="t('common.keyword')">
<el-input v-model="keyword" :placeholder="t('agent.filter.username_ph')" clearable style="width: 180px" @keyup.enter="load" />
<el-input v-model="tier1Keyword" :placeholder="t('agent.filter.username_ph')" clearable style="width: 180px" @keyup.enter="searchTier1Agents" />
</el-form-item>
<el-form-item :label="t('common.status')">
<el-select v-model="filterStatus" :placeholder="t('common.all')" clearable style="width: 120px">
<el-select v-model="tier1FilterStatus" :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-button type="primary" @click="searchTier1Agents">{{ t('common.search') }}</el-button>
</el-form-item>
</el-form>
<div class="list-chrome__actions">
<el-button type="primary" @click="openCreateGlobal">{{ t('agent.create_btn') }}</el-button>
<el-button type="primary" @click="openCreateTier1Agent">{{ t('agent.create_btn') }}</el-button>
</div>
</div>
</div>
<!-- Agent table -->
<section class="list-panel">
<div class="table-wrap">
<el-table
ref="agentTableRef"
:data="agents"
ref="tier1AgentTableRef"
:data="tier1Agents"
stripe
row-key="userId"
:row-class-name="expandableTableRowClassName"
class="expandable-table"
@expand-change="onExpandChange"
@row-click="onAgentRowClick"
@row-click="onTier1AgentRowClick"
>
<template #empty>
<AdminTableEmpty />
@@ -925,16 +1153,12 @@ function creditTypeLabel(type: string) {
<div v-if="expandLoading[row.userId]" class="expand-loading">
{{ t('common.loading') || '加载中...' }}
</div>
<template v-else>
<el-tabs :model-value="getInnerTab(row.userId)" @update:model-value="setInnerTab(row.userId, $event)" class="inner-tabs">
<!-- Players tab -->
<el-tab-pane :label="`${t('nav.players')} (${getPlayers(row.userId).length})`" name="players">
<div class="inner-toolbar">
<el-button type="primary" size="small" @click="openCreatePlayer(row.userId)">
+ {{ t('user.create_btn') }}
</el-button>
</div>
<el-table :data="getPlayers(row.userId)" stripe size="small" class="inner-table">
<div v-else class="expand-panel-body">
<div class="expand-section-header">
<div class="expand-section-title">{{ directPlayersTabLabel(row.username, getPlayers(row.userId).length) }}</div>
<el-button type="primary" size="small" @click="openCreatePlayer(row.userId)">{{ t('user.create_btn') }}</el-button>
</div>
<el-table :data="getPlayers(row.userId)" stripe class="inner-table">
<template #empty><AdminTableEmpty /></template>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
@@ -977,122 +1201,13 @@ function creditTypeLabel(type: string) {
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- Sub-agents tab -->
<el-tab-pane :label="`${t('nav.subAgents')} (${getSubAgents(row.userId).length})`" name="subAgents">
<div class="inner-toolbar">
<el-button type="primary" size="small" @click="openCreateSubAgent(row.userId)">
+ {{ t('agent.create_sub') || '创建二级代理' }}
</el-button>
</div>
<el-table
:data="getSubAgents(row.userId)"
stripe
size="small"
row-key="userId"
:expand-row-keys="subAgentExpandedKeys"
:row-class-name="expandableTableRowClassName"
class="inner-table expandable-table"
@expand-change="(sub: AgentRow, rows: AgentRow[]) => onSubAgentExpand(row.userId, sub, rows)"
@row-click="(sub: AgentRow, col: unknown, e: MouseEvent) => onSubAgentRowClick(row.userId, sub, col, e)"
>
<template #empty><AdminTableEmpty /></template>
<el-table-column type="expand">
<template #default="{ row: sub }">
<div class="expand-panel">
<div v-if="subAgentExpandLoading[sub.userId]" class="expand-loading">
{{ t('common.loading') }}
</div>
<template v-else>
<div class="expand-section-header">
<div class="expand-section-title">
{{ t('nav.players') }} ({{ getSubAgentPlayers(sub.userId).length }})
</div>
<el-button type="primary" size="small" @click.stop="openCreatePlayer(sub.userId)">
+ {{ t('agent_portal.create_player_btn').replace(/^\+ /, '') }}
</el-button>
</div>
<el-table :data="getSubAgentPlayers(sub.userId)" stripe size="small" class="inner-table nested-table">
<template #empty><AdminTableEmpty /></template>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
<el-table-column :label="t('common.status')" width="80">
<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: player }">
<span class="amount-compact">{{ formatAmount(player.availableBalance) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="340" fixed="right" align="center">
<template #default="{ row: player }">
<div class="action-btns" @click.stop>
<el-button size="small" link type="primary" @click="openDetailPlayer(player.id)">{{ t('common.detail') }}</el-button>
<el-button size="small" link type="primary" @click="openEditPlayer(player.id)">{{ t('common.edit') }}</el-button>
<el-button size="small" link type="success" @click="openTransfer('deposit', player)">{{ t('common.topup') }}</el-button>
<el-button size="small" link type="warning" @click="openTransfer('withdraw', player)">{{ t('agent_portal.withdraw_btn_label') }}</el-button>
<el-button v-if="player.status === 'ACTIVE'" size="small" link type="warning" @click="toggleFreezePlayer(player)">{{ t('common.freeze') }}</el-button>
<el-button v-else size="small" link type="primary" @click="toggleFreezePlayer(player)">{{ t('common.unfreeze') }}</el-button>
</div>
</template>
</el-table-column>
</el-table>
</template>
</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="100" />
<el-table-column :label="t('common.status')" width="80">
<template #default="{ row: sub }">
<el-tag :type="statusTagType(subAgentAccountStatus(sub))" size="small">
{{ statusLabel(subAgentAccountStatus(sub)) }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('agent.col.credit')" min-width="150" align="right">
<template #default="{ row: sub }">
<el-tooltip :content="creditLineFull(sub)" placement="top">
<span class="amount-compact">{{ creditLine(sub) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="directPlayerCount" :label="t('agent.col.direct_players')" width="80" align="center" />
<el-table-column :label="t('agent.col.cashback')" width="70" align="right">
<template #default="{ row: sub }">{{ sub.cashbackRate }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="300" fixed="right" align="center">
<template #default="{ row: sub }">
<div class="action-btns">
<el-button size="small" link type="primary" @click="openDetailAgent(sub.userId)">{{ t('common.detail') }}</el-button>
<el-button size="small" link type="primary" @click="openEditAgent(sub.userId)">{{ t('common.edit') }}</el-button>
<el-button size="small" link type="primary" @click="openCredit(sub.userId)">{{ t('common.adjust_credit') }}</el-button>
<el-button
v-if="subAgentAccountStatus(sub) === 'ACTIVE'"
size="small"
link
type="warning"
@click="toggleFreezeAgent(sub)"
>{{ t('common.freeze') }}</el-button>
<el-button v-else size="small" link type="primary" @click="toggleFreezeAgent(sub)">{{ t('common.unfreeze') }}</el-button>
</div>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</template>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="userId" label="ID" width="72" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="140" />
<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>
@@ -1113,12 +1228,13 @@ function creditTypeLabel(type: string) {
<el-table-column :label="t('agent.col.cashback')" width="80" align="right">
<template #default="{ row }">{{ row.cashbackRate }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="340" fixed="right" align="center">
<el-table-column :label="t('common.actions')" width="400" fixed="right" align="center">
<template #default="{ row }">
<div class="action-btns" @click.stop>
<el-button size="small" link type="primary" @click="openDetailAgent(row.userId)">{{ t('common.detail') }}</el-button>
<el-button size="small" link type="primary" @click="openEditAgent(row.userId)">{{ t('common.edit') }}</el-button>
<el-button size="small" link type="primary" @click="openCredit(row.userId)">{{ t('common.adjust_credit') }}</el-button>
<el-button size="small" link type="primary" @click="openCreateSubAgent(row.userId)">{{ t('agent.create_sub') }}</el-button>
<el-button v-if="row.status === 'ACTIVE'" size="small" link type="warning" @click="toggleFreezeAgent(row)">{{ t('common.freeze') }}</el-button>
<el-button v-else size="small" link type="primary" @click="toggleFreezeAgent(row)">{{ t('common.unfreeze') }}</el-button>
</div>
@@ -1128,25 +1244,151 @@ function creditTypeLabel(type: string) {
</div>
<div class="pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
v-model:current-page="tier1Page"
v-model:page-size="tier1PageSize"
:total="tier1Total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
background
@current-change="onPageChange"
@size-change="onSizeChange"
@current-change="onTier1PageChange"
@size-change="onTier1SizeChange"
/>
</div>
</section>
</el-tab-pane>
<!-- Tab: 二级代理 -->
<el-tab-pane :label="`${t('user.type.sub_agent')} (${tier2Total})`" name="tier2Agents">
<section class="list-panel agent-list-panel">
<div class="list-panel-toolbar">
<el-form inline class="list-chrome__grow">
<el-form-item :label="t('common.keyword')">
<el-input v-model="tier2Keyword" :placeholder="t('agent.filter.username_ph')" clearable style="width: 180px" @keyup.enter="searchTier2Agents" />
</el-form-item>
<el-form-item :label="t('common.status')">
<el-select v-model="tier2FilterStatus" :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="searchTier2Agents">{{ t('common.search') }}</el-button>
</el-form-item>
</el-form>
<div class="list-chrome__actions">
<el-button type="primary" @click="openCreateSubAgentFromToolbar">{{ t('agent.create_sub_btn') }}</el-button>
</div>
</div>
<div class="table-wrap">
<el-table
ref="tier2AgentTableRef"
:data="tier2Agents"
stripe
row-key="userId"
:row-class-name="expandableTableRowClassName"
class="expandable-table"
@expand-change="onExpandChange"
@row-click="onTier2AgentRowClick"
>
<template #empty>
<AdminTableEmpty />
</template>
<el-table-column type="expand">
<template #default="{ row }">
<div class="expand-panel">
<div v-if="expandLoading[row.userId]" class="expand-loading">{{ t('common.loading') }}</div>
<div v-else class="expand-panel-body">
<div class="expand-section-header">
<div class="expand-section-title">{{ directPlayersTabLabel(row.username, getPlayers(row.userId).length) }}</div>
<el-button type="primary" size="small" @click="openCreatePlayer(row.userId)">{{ t('user.create_btn') }}</el-button>
</div>
<el-table :data="getPlayers(row.userId)" stripe class="inner-table">
<template #empty><AdminTableEmpty /></template>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
<el-table-column :label="t('common.status')" width="80">
<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.col.balance')" min-width="120" align="right">
<template #default="{ row: player }">
<span class="amount-compact">{{ formatAmount(player.availableBalance) }} / {{ formatAmount(player.frozenBalance) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="340" fixed="right" align="center">
<template #default="{ row: player }">
<div class="action-btns">
<el-button size="small" link type="primary" @click="openDetailPlayer(player.id)">{{ t('common.detail') }}</el-button>
<el-button size="small" link type="primary" @click="openEditPlayer(player.id)">{{ t('common.edit') }}</el-button>
<el-button size="small" link type="success" @click="openTransfer('deposit', player)">{{ t('common.topup') }}</el-button>
<el-button size="small" link type="warning" @click="openTransfer('withdraw', player)">{{ t('agent_portal.withdraw_btn_label') }}</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="userId" label="ID" width="72" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
<el-table-column :label="t('user.type.tier1_agent')" min-width="120">
<template #default="{ row }">{{ row.parentUsername ?? '—' }}</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="88">
<template #default="{ row }">
<el-tag :type="statusTagType(subAgentAccountStatus(row))" size="small">{{ statusLabel(subAgentAccountStatus(row)) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('agent.col.credit')" min-width="168" align="right">
<template #default="{ row }">
<el-tooltip :content="creditLineFull(row)" placement="top">
<span class="amount-compact">{{ creditLine(row) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="directPlayerCount" :label="t('agent.col.direct_players')" width="80" align="center" />
<el-table-column :label="t('common.actions')" width="340" fixed="right" align="center">
<template #default="{ row }">
<div class="action-btns" @click.stop>
<el-button size="small" link type="primary" @click="openDetailAgent(row.userId)">{{ t('common.detail') }}</el-button>
<el-button size="small" link type="primary" @click="openEditAgent(row.userId)">{{ t('common.edit') }}</el-button>
<el-button size="small" link type="primary" @click="openCredit(row.userId)">{{ t('common.adjust_credit') }}</el-button>
<el-button v-if="subAgentAccountStatus(row) === 'ACTIVE'" size="small" link type="warning" @click="toggleFreezeAgent(row)">{{ t('common.freeze') }}</el-button>
<el-button v-else size="small" link type="primary" @click="toggleFreezeAgent(row)">{{ t('common.unfreeze') }}</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
<div class="pager">
<el-pagination
v-model:current-page="tier2Page"
v-model:page-size="tier2PageSize"
:total="tier2Total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
background
@current-change="onTier2PageChange"
@size-change="onTier2SizeChange"
/>
</div>
</section>
</el-tab-pane>
</el-tabs>
<!-- DIALOGS -->
<!-- Create (unified) -->
<el-dialog v-model="createVisible" :title="t('user.dialog.create')" width="520px" destroy-on-close>
<el-dialog v-model="createVisible" :title="createDialogTitle" width="520px" destroy-on-close class="create-account-dialog">
<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-input
v-model="createForm.username"
:placeholder="createAccountMode === 0 ? t('user.ph.username_player') : t('user.ph.username_unique')"
/>
<div v-if="createAccountMode === 0" class="field-hint">{{ t('user.hint.username_player') }}</div>
</el-form-item>
<el-form-item :label="t('user.field.password')" required>
<el-input v-model="createForm.password" type="text" autocomplete="off" />
@@ -1154,31 +1396,40 @@ function creditTypeLabel(type: string) {
<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 :model-value="createAccountMode" @update:model-value="onAccountModeChange">
<el-radio :value="0">{{ t('user.type.player') }}</el-radio>
<el-radio :value="1" :disabled="!!createParentAgentId">{{ t('user.type.tier1_agent') }}</el-radio>
<el-radio :value="2" :disabled="!createParentAgentId">{{ t('user.type.sub_agent') }}</el-radio>
</el-radio-group>
<div class="field-hint">
<template v-if="createParentAgentId">
{{ t('agent.hint.creating_under_agent') }}
</template>
<template v-else>{{ t('user.hint.account_type') }}</template>
</div>
<!-- 玩家从代理行/展开区进入时锁定所属代理 -->
<el-form-item v-if="createAccountMode === 0 && createParentLocked" :label="t('user.filter.agent')">
<el-tag type="info" class="account-type-tag">{{ resolveCreateParentLabel(createParentAgentId) }}</el-tag>
<div class="field-hint">{{ t('agent.hint.creating_under_agent') }}</div>
</el-form-item>
<!-- Player fields (when mode is player and no parent agent context) -->
<template v-if="createAccountMode === 0 && !createParentAgentId">
<!-- 玩家从玩家 Tab 进入时可选择一级/二级代理 -->
<template v-if="createAccountMode === 0 && !createParentLocked">
<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 agents" :key="a.userId" :label="`${a.username} (#${a.userId})`" :value="a.userId" />
<el-option v-for="a in agentOptions" :key="a.id" :label="agentOptionLabel(a)" :value="a.id" />
</el-select>
<div class="field-hint">{{ t('user.hint.no_agent') }}</div>
</el-form-item>
</template>
<!-- Agent fields (tier1 or sub-agent) -->
<!-- 二级代理从一级代理行进入时锁定上级 -->
<el-form-item v-if="createAccountMode === 2 && createParentLocked" :label="t('user.type.tier1_agent')">
<el-tag type="info" class="account-type-tag">{{ resolveCreateParentLabel(createParentAgentId) }}</el-tag>
<div class="field-hint">{{ t('agent.hint.creating_under_agent') }}</div>
</el-form-item>
<!-- 二级代理从二级 Tab 进入时选择一级代理 -->
<template v-if="createAccountMode === 2 && !createParentLocked">
<el-form-item :label="t('user.type.tier1_agent')" required>
<el-select v-model="createParentAgentId" :placeholder="t('user.filter.agent_ph')" style="width: 100%">
<el-option v-for="a in tier1AgentOptions" :key="a.id" :label="agentOptionLabel(a)" :value="a.id" />
</el-select>
<div class="field-hint">{{ t('agent.hint.sub_agent_parent') }}</div>
</el-form-item>
</template>
<!-- 代理字段一级 / 二级 -->
<template v-if="createAccountMode === 1 || createAccountMode === 2">
<el-form-item :label="t('agent.field.credit_limit')" required>
<el-input-number v-model="createForm.creditLimit" :min="0" :step="10000" style="width: 100%" />
@@ -1230,7 +1481,8 @@ function creditTypeLabel(type: string) {
<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_unique')" />
<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>
@@ -1243,10 +1495,9 @@ function creditTypeLabel(type: string) {
<el-input v-model="editPlayerForm.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="editPlayerForm.parentId" :placeholder="t('user.ph.no_agent')" clearable style="width: 100%">
<el-option v-for="a in agents" :key="a.userId" :label="`${a.username} (#${a.userId})`" :value="a.userId" />
</el-select>
<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('user.field.phone')">
<el-input v-model="editPlayerForm.phone" :placeholder="t('common.optional')" />
@@ -1411,7 +1662,7 @@ function creditTypeLabel(type: string) {
<el-descriptions-item :label="t('common.status')">
<el-tag :type="statusTagType(playerDetail.status)" size="small">{{ statusLabel(playerDetail.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item :label="t('user.col.agent')">{{ playerDetail.parentUsername ?? t('common.platform_direct') }}</el-descriptions-item>
<el-descriptions-item :label="t('user.col.agent')">{{ affiliationLabel(playerDetail) }}</el-descriptions-item>
<el-descriptions-item :label="t('user.field.available')">
{{ formatAmount(playerDetail.availableBalance) }}
<span v-if="shouldCompact(playerDetail.availableBalance)" class="amount-full-hint">{{ formatAmountFull(playerDetail.availableBalance) }}</span>
@@ -1509,9 +1760,75 @@ function creditTypeLabel(type: string) {
</template>
<style scoped>
.agent-mgr-page > .agent-list-panel,
.agent-mgr-page > .mgr-top-tabs {
flex: 1;
min-height: 0;
}
.agent-mgr-page > .mgr-top-tabs :deep(.el-tabs__content) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.agent-mgr-page > .mgr-top-tabs :deep(.el-tab-pane) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.player-list-panel,
.agent-list-panel {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.mgr-top-tabs :deep(.el-tabs__header) {
margin-bottom: 8px;
}
.mgr-top-tabs :deep(.el-tabs__item) {
font-size: 14px;
font-weight: 500;
}
/* ─── Table toolbar ─── */
.list-panel-toolbar {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 0 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
--list-chrome-control-h: 32px;
--el-component-size: 32px;
}
.list-panel-toolbar .list-chrome__actions {
flex-shrink: 0;
}
.list-panel-toolbar :deep(.el-form-item) {
margin-bottom: 0 !important;
margin-right: 12px;
}
.list-panel-toolbar :deep(.el-input__wrapper),
.list-panel-toolbar :deep(.el-select__wrapper) {
height: var(--list-chrome-control-h) !important;
min-height: var(--list-chrome-control-h) !important;
}
.list-panel-toolbar :deep(.el-button:not(.is-link)) {
height: var(--list-chrome-control-h) !important;
min-height: var(--list-chrome-control-h) !important;
}
/* ─── Expansion ─── */
.expand-panel {
padding: 4px 16px 8px;
margin: 4px 0 8px;
padding: 12px 14px 14px;
background: #141414;
border: 1px solid #2a2a2a;
border-left: 3px solid rgba(47, 181, 106, 0.45);
border-radius: 8px;
}
.expand-loading {
text-align: center;
@@ -1546,22 +1863,34 @@ function creditTypeLabel(type: string) {
margin-bottom: 0;
}
.inner-tabs :deep(.el-tabs__item) {
height: 32px;
line-height: 32px;
height: 34px;
line-height: 34px;
font-size: 13px;
font-weight: 500;
color: #888;
padding: 0 14px;
}
.inner-tabs :deep(.el-tabs__item.is-active) {
color: #d4fde5;
font-weight: 700;
}
.inner-tabs :deep(.el-tabs__active-bar) {
background-color: var(--green-bright);
height: 2px;
}
.inner-tabs :deep(.el-tabs__content) {
padding: 0;
}
.inner-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 4px;
}
.inner-table {
border-radius: 6px;
}
.inner-table :deep(.el-table__cell) {
font-size: 13px;
}
.account-type-tag {
font-size: 13px;
font-weight: 600;
}
.expandable-table :deep(.row-expandable) {
cursor: pointer;
}
@@ -1581,6 +1910,11 @@ function creditTypeLabel(type: string) {
}
.field-hint { font-size: 12px; color: #888; margin-top: 4px; }
.amount-compact { white-space: nowrap; font-variant-numeric: tabular-nums; cursor: default; }
.affiliation-tag {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.amount-full-hint { font-size: 11px; color: #666; margin-left: 4px; }
.text-muted { color: #666; font-size: 12px; }
.password-mgmt-block {

View File

@@ -0,0 +1,643 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
import api from '../api';
const { t } = useAdminLocale();
interface MediaFile {
id: string;
filename: string;
category: string;
mimeType: string;
size: number;
url: string;
uploadedBy: string | null;
createdAt: string;
inUse: boolean;
}
const CATEGORIES = ['banners', 'teams', 'contents'] as const;
type Category = (typeof CATEGORIES)[number];
const files = ref<MediaFile[]>([]);
const total = ref(0);
const loading = ref(false);
const purging = ref(false);
const uploading = ref(false);
const activeCategory = ref<Category | ''>('');
const currentPage = ref(1);
const pageSize = 40;
const uploadDialogVisible = ref(false);
const uploadCategory = ref<Category>('banners');
const uploadFile = ref<File | null>(null);
const dropActive = ref(false);
const fileInputRef = ref<HTMLInputElement | null>(null);
const unusedCount = computed(() => files.value.filter((f) => !f.inUse).length);
function categoryLabel(cat: string) {
const key = `media.category.${cat}` as const;
return t(key as any) || cat;
}
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDate(iso: string) {
return new Date(iso).toLocaleString();
}
async function loadFiles() {
loading.value = true;
try {
const params: Record<string, string | number> = { page: currentPage.value, pageSize };
if (activeCategory.value) params.category = activeCategory.value;
const res = await api.get('/admin/files', { params });
files.value = res.data.data.items;
total.value = res.data.data.total;
} catch {
ElMessage.error(t('common.loading'));
} finally {
loading.value = false;
}
}
watch(activeCategory, () => {
currentPage.value = 1;
loadFiles();
});
watch(currentPage, loadFiles);
onMounted(loadFiles);
async function confirmDelete(file: MediaFile) {
await ElMessageBox.confirm(t('media.delete_confirm'), { type: 'warning' });
try {
await api.delete(`/admin/files/${file.id}`);
ElMessage.success(t('media.delete_success'));
loadFiles();
} catch {
ElMessage.error(t('media.upload_failed'));
}
}
async function purgeUnused() {
if (unusedCount.value === 0) {
ElMessage.info(t('media.purge_none'));
return;
}
const msg = t('media.purge_confirm').replace('{n}', String(unusedCount.value));
await ElMessageBox.confirm(msg, { type: 'warning' });
purging.value = true;
try {
const res = await api.delete('/admin/files/unused');
const deleted = res.data.data.deleted;
ElMessage.success(t('media.purge_success').replace('{n}', String(deleted)));
loadFiles();
} catch {
ElMessage.error(t('media.upload_failed'));
} finally {
purging.value = false;
}
}
function copyUrl(url: string) {
navigator.clipboard.writeText(location.origin + url).then(
() => ElMessage.success(t('media.url_copied')),
() => ElMessage.error(t('media.upload_failed')),
);
}
function openUploadDialog() {
uploadFile.value = null;
uploadDialogVisible.value = true;
}
function onFileChange(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files?.[0]) {
uploadFile.value = input.files[0];
}
}
function onDrop(e: DragEvent) {
e.preventDefault();
dropActive.value = false;
if (e.dataTransfer?.files?.[0]) {
uploadFile.value = e.dataTransfer.files[0];
}
}
async function doUpload() {
if (!uploadFile.value) return;
uploading.value = true;
try {
const fd = new FormData();
fd.append('file', uploadFile.value);
await api.post(`/admin/uploads?category=${uploadCategory.value}`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
});
ElMessage.success(t('media.upload_success'));
uploadDialogVisible.value = false;
uploadFile.value = null;
if (fileInputRef.value) fileInputRef.value.value = '';
loadFiles();
} catch (err: any) {
const msg = err?.response?.data?.message || t('media.upload_failed');
ElMessage.error(msg);
} finally {
uploading.value = false;
}
}
</script>
<template>
<div class="media-page">
<!-- Header toolbar -->
<div class="toolbar">
<div class="filter-tabs">
<button
class="tab-btn"
:class="{ active: activeCategory === '' }"
@click="activeCategory = ''"
>{{ t('media.category.all') }}</button>
<button
v-for="cat in CATEGORIES"
:key="cat"
class="tab-btn"
:class="{ active: activeCategory === cat }"
@click="activeCategory = cat"
>{{ categoryLabel(cat) }}</button>
</div>
<div class="toolbar-right">
<span v-if="unusedCount > 0" class="unused-badge">
{{ t('media.unused_count').replace('{n}', String(unusedCount)) }}
</span>
<button class="btn btn-ghost" :disabled="purging" @click="purgeUnused">
{{ t('media.purge_btn') }}
</button>
<button class="btn btn-primary" @click="openUploadDialog">
+ {{ t('media.upload_btn') }}
</button>
</div>
</div>
<!-- File grid -->
<div v-if="loading" class="state-center">{{ t('common.loading') }}</div>
<div v-else-if="files.length === 0" class="state-center muted">{{ t('media.no_files') }}</div>
<div v-else class="file-grid">
<div v-for="file in files" :key="file.id" class="file-card">
<div class="card-thumb">
<img
v-if="file.mimeType !== 'image/svg+xml'"
:src="file.url"
:alt="file.filename"
loading="lazy"
/>
<div v-else class="svg-badge">SVG</div>
<span class="use-badge" :class="file.inUse ? 'badge-used' : 'badge-unused'">
{{ file.inUse ? t('media.status.used') : t('media.status.unused') }}
</span>
</div>
<div class="card-body">
<div class="card-filename" :title="file.filename">{{ file.filename }}</div>
<div class="card-meta">
<span class="cat-tag">{{ categoryLabel(file.category) }}</span>
<span>{{ formatSize(file.size) }}</span>
</div>
<div class="card-date">{{ formatDate(file.createdAt) }}</div>
</div>
<div class="card-actions">
<button class="act-btn" @click="copyUrl(file.url)">{{ t('media.copy_url') }}</button>
<button class="act-btn act-delete" @click="confirmDelete(file)">{{ t('common.delete') }}</button>
</div>
</div>
</div>
<!-- Pagination -->
<div v-if="total > pageSize" class="pagination">
<button
class="btn btn-ghost"
:disabled="currentPage <= 1"
@click="currentPage--"
>&laquo;</button>
<span class="page-info">{{ currentPage }} / {{ Math.ceil(total / pageSize) }}</span>
<button
class="btn btn-ghost"
:disabled="currentPage >= Math.ceil(total / pageSize)"
@click="currentPage++"
>&raquo;</button>
</div>
<!-- Upload dialog -->
<div v-if="uploadDialogVisible" class="dialog-overlay" @click.self="uploadDialogVisible = false">
<div class="dialog">
<div class="dialog-header">
<span>{{ t('media.upload_dialog') }}</span>
<button class="dialog-close" @click="uploadDialogVisible = false">&times;</button>
</div>
<div class="dialog-body">
<div class="form-row">
<label>{{ t('media.upload_category') }}</label>
<div class="cat-buttons">
<button
v-for="cat in CATEGORIES"
:key="cat"
class="tab-btn"
:class="{ active: uploadCategory === cat }"
@click="uploadCategory = cat"
>{{ categoryLabel(cat) }}</button>
</div>
</div>
<div
class="drop-zone"
:class="{ 'drop-active': dropActive }"
@dragover.prevent="dropActive = true"
@dragleave="dropActive = false"
@drop="onDrop"
@click="fileInputRef?.click()"
>
<div v-if="!uploadFile" class="drop-hint">{{ t('media.drop_hint') }}</div>
<div v-else class="drop-selected">
<span class="sel-name">{{ uploadFile.name }}</span>
<span class="sel-size">{{ formatSize(uploadFile.size) }}</span>
</div>
<input
ref="fileInputRef"
type="file"
accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml"
style="display:none"
@change="onFileChange"
/>
</div>
<div class="upload-hint-text">{{ t('media.upload_hint') }}</div>
</div>
<div class="dialog-footer">
<button class="btn btn-ghost" @click="uploadDialogVisible = false">{{ t('common.cancel') }}</button>
<button
class="btn btn-primary"
:disabled="!uploadFile || uploading"
@click="doUpload"
>{{ uploading ? t('common.loading') : t('common.confirm') }}</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.media-page {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
min-height: 0;
}
/* ── Toolbar ── */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
flex-shrink: 0;
}
.filter-tabs {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.unused-badge {
font-size: 12px;
color: #f0a020;
background: rgba(240, 160, 32, 0.12);
border: 1px solid rgba(240, 160, 32, 0.3);
border-radius: 4px;
padding: 3px 8px;
}
/* ── Tabs / Buttons ── */
.tab-btn {
padding: 6px 14px;
border-radius: 6px;
border: 1px solid #2a2a2a;
background: transparent;
color: #888;
font-size: 13px;
font-family: inherit;
cursor: pointer;
transition: all 0.15s;
}
.tab-btn:hover { border-color: #444; color: #ccc; }
.tab-btn.active {
background: rgba(47, 181, 106, 0.14);
border-color: rgba(47, 181, 106, 0.5);
color: #2fb56a;
font-weight: 600;
}
.btn {
padding: 7px 16px;
border-radius: 6px;
font-size: 13px;
font-family: inherit;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.15s;
white-space: nowrap;
}
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-ghost {
background: transparent;
border-color: #2a2a2a;
color: #888;
}
.btn-ghost:hover:not(:disabled) { border-color: #444; color: #ccc; }
.btn-primary {
background: linear-gradient(135deg, #2fb56a, #248f54);
color: #fff;
font-weight: 600;
border-color: transparent;
box-shadow: 0 2px 8px rgba(47, 181, 106, 0.3);
}
.btn-primary:hover:not(:disabled) { filter: brightness(1.08); }
/* ── Grid ── */
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 14px;
overflow-y: auto;
flex: 1;
padding-right: 4px;
}
.file-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid #1e1e1e;
border-radius: 10px;
overflow: hidden;
display: flex;
flex-direction: column;
transition: border-color 0.15s, box-shadow 0.15s;
}
.file-card:hover {
border-color: #333;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}
.card-thumb {
position: relative;
height: 120px;
background: #111;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.card-thumb img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.svg-badge {
font-size: 14px;
font-weight: 700;
color: #666;
letter-spacing: 0.1em;
}
.use-badge {
position: absolute;
top: 6px;
right: 6px;
font-size: 10px;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
letter-spacing: 0.04em;
}
.badge-used {
background: rgba(47, 181, 106, 0.2);
color: #2fb56a;
border: 1px solid rgba(47, 181, 106, 0.35);
}
.badge-unused {
background: rgba(120, 120, 120, 0.18);
color: #666;
border: 1px solid rgba(120, 120, 120, 0.2);
}
.card-body {
padding: 10px 12px 6px;
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.card-filename {
font-size: 12px;
color: #ccc;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.card-meta {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: #555;
}
.cat-tag {
background: rgba(255, 255, 255, 0.05);
border: 1px solid #2a2a2a;
border-radius: 3px;
padding: 1px 5px;
font-size: 10px;
color: #666;
}
.card-date {
font-size: 10px;
color: #444;
}
.card-actions {
display: flex;
border-top: 1px solid #1a1a1a;
}
.act-btn {
flex: 1;
padding: 7px 4px;
background: transparent;
border: none;
border-right: 1px solid #1a1a1a;
color: #666;
font-size: 11px;
font-family: inherit;
cursor: pointer;
transition: all 0.15s;
}
.act-btn:last-child { border-right: none; }
.act-btn:hover { background: rgba(255, 255, 255, 0.04); color: #ccc; }
.act-delete:hover { color: #e05555; }
/* ── Pagination ── */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
flex-shrink: 0;
padding-bottom: 4px;
}
.page-info {
font-size: 13px;
color: #666;
min-width: 60px;
text-align: center;
}
/* ── Empty / loading states ── */
.state-center {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
font-size: 14px;
color: #444;
}
.muted { color: #333; }
/* ── Upload dialog ── */
.dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.65);
display: flex;
align-items: center;
justify-content: center;
z-index: 500;
padding: 20px;
}
.dialog {
background: #141414;
border: 1px solid #2a2a2a;
border-radius: 12px;
width: 100%;
max-width: 480px;
display: flex;
flex-direction: column;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6);
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #1e1e1e;
font-size: 15px;
font-weight: 600;
color: #e0e0e0;
}
.dialog-close {
background: transparent;
border: none;
color: #555;
font-size: 20px;
cursor: pointer;
line-height: 1;
padding: 0 4px;
}
.dialog-close:hover { color: #ccc; }
.dialog-body {
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.form-row {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-row label {
font-size: 13px;
color: #888;
}
.cat-buttons {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.drop-zone {
border: 2px dashed #2a2a2a;
border-radius: 10px;
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
padding: 16px;
}
.drop-zone:hover,
.drop-zone.drop-active {
border-color: rgba(47, 181, 106, 0.5);
background: rgba(47, 181, 106, 0.04);
}
.drop-hint {
font-size: 13px;
color: #444;
text-align: center;
}
.drop-selected {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.sel-name {
font-size: 13px;
color: #ccc;
font-weight: 500;
word-break: break-all;
text-align: center;
}
.sel-size {
font-size: 11px;
color: #555;
}
.upload-hint-text {
font-size: 11px;
color: #444;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px 20px;
border-top: 1px solid #1e1e1e;
}
</style>

View File

@@ -18,6 +18,8 @@ import {
type PlayerDetail,
type PlayerCreateForm,
type PlayerEditForm,
formatPlayerAffiliationLabel,
assertPlayerUsername,
} from './user-form';
import {
formatAmount,
@@ -188,6 +190,10 @@ async function loadAgentOptions() {
agentOptions.value = data.data;
}
function playerAffiliationLabel(row: PlayerEditForm) {
return formatPlayerAffiliationLabel(row, t('user.type.player'), t('agent.platform_row_name'));
}
async function load() {
const { data } = await api.get('/admin/users', {
params: {
@@ -295,12 +301,17 @@ async function submitEdit() {
ElMessage.warning(t('err.password_min'));
return;
}
try {
assertPlayerUsername(editForm.value.username);
} catch (e) {
ElMessage.warning(resolveFormError(e, t));
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,
@@ -587,7 +598,8 @@ function statusLabel(s: string) {
<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-input v-model="createForm.username" :placeholder="t('user.ph.username_player')" />
<div v-if="!createForm.asTier1Agent" class="field-hint">{{ t('user.hint.username_player') }}</div>
</el-form-item>
<el-form-item :label="t('user.field.password')" required>
<el-input v-model="createForm.password" type="text" autocomplete="off" />
@@ -678,7 +690,8 @@ function statusLabel(s: string) {
</div>
<el-form-item :label="t('user.col.username')">
<el-input v-model="editForm.username" :placeholder="t('user.ph.username_unique')" />
<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">
@@ -700,20 +713,9 @@ function statusLabel(s: string) {
</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 :label="t('user.col.agent')">
<el-tag size="small" type="info">{{ playerAffiliationLabel(editForm) }}</el-tag>
<div class="field-hint">{{ t('user.hint.agent_readonly') }}</div>
</el-form-item>
<el-form-item :label="t('user.field.phone')">
<el-input v-model="editForm.phone" :placeholder="t('common.optional')" />

View File

@@ -48,6 +48,8 @@ export interface AgentRow {
username: string;
userStatus: string;
level: number;
parentAgentId?: string | null;
parentUsername?: string | null;
status: string;
creditLimit: string;
usedCredit: string;

View File

@@ -747,7 +747,8 @@ function statusTagType(s: string) {
/>
<el-form label-width="100px" class="create-form">
<el-form-item :label="t('user.col.username')" required>
<el-input v-model="createForm.username" :placeholder="t('agent_portal.username_ph')" />
<el-input v-model="createForm.username" :placeholder="t('user.ph.username_player')" />
<div class="field-hint">{{ t('user.hint.username_player') }}</div>
</el-form-item>
<el-form-item :label="t('user.field.password')" required>
<el-input v-model="createForm.password" type="text" autocomplete="off" />
@@ -783,7 +784,8 @@ function statusTagType(s: string) {
<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-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>

View File

@@ -1,4 +1,5 @@
import { FormValidationError } from '../../i18n/form-validation';
import { assertPlayerUsername } from '../user-form';
export interface AgentPlayerCreateForm {
username: string;
@@ -65,7 +66,7 @@ export function emptyAgentPlayerCreateForm(): AgentPlayerCreateForm {
}
export function buildAgentCreatePlayerPayload(form: AgentPlayerCreateForm) {
if (!form.username.trim()) throw new FormValidationError('err.username_required');
assertPlayerUsername(form.username);
if (form.password.length < 8) throw new FormValidationError('err.password_min');
if (form.password !== form.confirmPassword) throw new FormValidationError('err.password_mismatch');
if (form.initialDeposit < 0) throw new FormValidationError('err.amount_negative');
@@ -119,7 +120,7 @@ export function editFormFromAgentDetail(d: AgentPlayerDetail): AgentPlayerEditFo
}
export function buildAgentUpdatePlayerPayload(form: AgentPlayerEditForm) {
if (!form.username.trim()) throw new FormValidationError('err.username_required');
assertPlayerUsername(form.username);
if (form.newPassword && form.newPassword.length < 8) {
throw new FormValidationError('err.password_min');
}

View File

@@ -1,5 +1,16 @@
import { FormValidationError } from '../i18n/form-validation';
/** 玩家用户名仅英文字母与数字332 位 */
export const PLAYER_USERNAME_PATTERN = /^[a-zA-Z0-9]{3,32}$/;
export function assertPlayerUsername(username: string): void {
const trimmed = username.trim();
if (!trimmed) throw new FormValidationError('err.username_required');
if (!PLAYER_USERNAME_PATTERN.test(trimmed)) {
throw new FormValidationError('err.username_player_invalid');
}
}
export interface PlayerCreateForm {
username: string;
password: string;
@@ -21,8 +32,8 @@ export interface PlayerEditForm {
id: string;
username: string;
status: string;
parentId: string;
parentUsername: string | null;
affiliationAgents?: string[];
availableBalance: string;
frozenBalance: string;
betCount: number;
@@ -44,6 +55,8 @@ export interface PlayerRow {
locale: string;
parentId: string | null;
parentUsername: string | null;
/** 归属代理链:一级代理、二级代理(如有) */
affiliationAgents?: string[];
phone: string | null;
email: string | null;
managedPassword: string | null;
@@ -62,6 +75,19 @@ export interface PlayerDetail extends PlayerRow {
updatedAt: string;
}
/** 玩家归属标签,格式:玩家-平台 | 玩家-一级代理 | 玩家-一级代理-二级代理 */
export function formatPlayerAffiliationLabel(
row: Pick<PlayerRow, 'affiliationAgents'>,
playerLabel: string,
platformLabel: string,
): string {
const agents = row.affiliationAgents ?? [];
if (agents.length === 0) {
return [playerLabel, platformLabel].join('-');
}
return [playerLabel, ...agents].join('-');
}
export function emptyPlayerCreateForm(): PlayerCreateForm {
return {
username: '',
@@ -85,7 +111,6 @@ export function emptyPlayerEditForm(): PlayerEditForm {
id: '',
username: '',
status: 'ACTIVE',
parentId: '',
parentUsername: null,
availableBalance: '0',
frozenBalance: '0',
@@ -107,8 +132,8 @@ export function editFormFromDetail(d: PlayerDetail): PlayerEditForm {
id: d.id,
username: d.username,
status: d.status,
parentId: d.parentId ?? '',
parentUsername: d.parentUsername,
affiliationAgents: d.affiliationAgents,
availableBalance: d.availableBalance,
frozenBalance: d.frozenBalance,
betCount: d.betCount,
@@ -144,6 +169,7 @@ export function buildCreatePlayerPayload(form: PlayerCreateForm) {
maxDailyDeposit: form.maxDailyDeposit > 0 ? form.maxDailyDeposit : undefined,
};
}
assertPlayerUsername(form.username);
return {
username: form.username.trim(),
password: form.password,