perf(admin): 实现切页即时导航与列表 loading 壳

非阻塞 session 刷新、侧边栏 chunk 预取、KeepAlive 对齐,以及按 tab 延迟加载重型列表页。
This commit is contained in:
2026-06-18 16:45:01 +08:00
parent ca482b1916
commit 8da730762b
21 changed files with 321 additions and 117 deletions

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue';
import { ref, onMounted, onActivated, watch } from 'vue';
import { useAdminLocale } from '../composables/useAdminLocale';
import { useAuditLabels } from '../utils/audit-labels';
import api from '../api';
@@ -39,8 +39,12 @@ const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const filterModule = ref('');
const loading = ref(false);
onMounted(load);
onMounted(() => void load());
onActivated(() => {
if (logs.value.length > 0) void load({ silent: true });
});
watch(
() => props.endpoint,
@@ -50,7 +54,9 @@ watch(
},
);
async function load() {
async function load(opts?: { silent?: boolean }) {
if (!opts?.silent) loading.value = true;
try {
const { data } = await api.get(props.endpoint, {
params: {
page: page.value,
@@ -62,6 +68,9 @@ async function load() {
});
logs.value = (data.data.items ?? []) as AuditRow[];
total.value = data.data.total ?? 0;
} finally {
if (!opts?.silent) loading.value = false;
}
}
function onPageChange(p: number) {
@@ -123,7 +132,7 @@ function operatorDisplay(row: AuditRow): string {
</el-form>
</el-card>
<el-card class="data-card" shadow="never">
<el-card v-loading="loading" class="data-card" shadow="never">
<div class="table-wrap">
<el-table :key="locale" :data="logs" stripe>
<template #empty>

View File

@@ -0,0 +1,27 @@
import { ref, onMounted, onActivated } from 'vue';
/**
* 列表页 mount / KeepAlive activated 生命周期:有缓存则后台静默刷新,无缓存则显示 loading。
*/
export function useStaleListLifecycle(
hasData: () => boolean,
loadFn: () => void | Promise<void>,
) {
const loading = ref(false);
async function runLoad(showSpinner: boolean) {
if (showSpinner) loading.value = true;
try {
await loadFn();
} finally {
loading.value = false;
}
}
onMounted(() => void runLoad(!hasData()));
onActivated(() => {
if (hasData()) void runLoad(false);
});
return { loading, runLoad };
}

View File

@@ -10,6 +10,7 @@ import AdminLocaleSwitcher from '../components/AdminLocaleSwitcher.vue';
import AdminNavIcon from '../components/AdminNavIcon.vue';
import { resolveAdminBreadcrumb } from '../utils/admin-breadcrumb';
import { useDepositPendingCount } from '../composables/useDepositPendingCount';
import { prefetchRouteChunks } from '../utils/route-prefetch';
const route = useRoute();
const router = useRouter();
@@ -29,7 +30,7 @@ const keepAliveIncludes = [
'AdminCashback',
'AdminMatches',
'AdminMatchesOutrights',
'AdminDepositOrders',
'AdminDepositManage',
'AdminStaffManage',
'AdminFinanceLogs',
'AdminAgentManager',
@@ -174,6 +175,10 @@ function onNavClick() {
}
}
function prefetchNavRoute(path: string) {
prefetchRouteChunks(router, path);
}
function logout() {
auth.logout();
router.push('/login');
@@ -233,6 +238,8 @@ watch(() => route.path, () => {
: isMatchesSectionPath(route.path))),
}"
@click="onNavClick"
@mouseenter="prefetchNavRoute(m.path)"
@focus="prefetchNavRoute(m.path)"
>
<AdminNavIcon :name="m.icon" />
<span class="nav-label">

View File

@@ -1,7 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '../stores/auth';
import { useSmokeTestsAllowed } from '../composables/useSmokeTestsAllowed';
import { ensureStaffSession, isSessionFresh } from '../utils/session-hydrate';
import { hydrateStaffSession } from '../utils/session-hydrate';
import { reconcileStaffSessionFromToken } from '../stores/auth';
import { AdminPerm } from '../constants/permissions';
import { adminCanAccess, firstAdminFallback } from '../utils/admin-access';
@@ -192,13 +192,9 @@ router.beforeEach(async (to) => {
const hasToken = !!auth.token.value;
if (hasToken) {
if (isSessionFresh()) {
// Session 在 TTL 内且完整:只做同步 reconcile跳过网络请求
reconcileStaffSessionFromToken();
} else {
// Session 过期或不完整:才 await 远程刷新
await ensureStaffSession();
}
reconcileStaffSessionFromToken();
// 后台刷新 session不阻塞路由切换401 由 api 拦截器处理
void hydrateStaffSession();
}
const hasUser = !!auth.user.value?.userType;
@@ -247,9 +243,8 @@ router.beforeEach(async (to) => {
}
if (to.meta.smokeTestsOnly) {
const { ensureLoaded, allowed } = useSmokeTestsAllowed();
await ensureLoaded();
if (!allowed.value) return '/';
const { allowed } = useSmokeTestsAllowed();
if (allowed.value === false) return '/';
}
return true;

View File

@@ -0,0 +1,25 @@
import type { Router } from 'vue-router';
const prefetchedPaths = new Set<string>();
/** 预取目标路径上所有 lazy route 的 JS chunk侧边栏 hover/focus 时调用)。 */
export function prefetchRouteChunks(router: Router, path: string) {
const key = path.split('?')[0] || '/';
if (prefetchedPaths.has(key)) return;
let matched = false;
try {
const resolved = router.resolve(path);
for (const record of resolved.matched) {
const loader = record.components?.default;
if (typeof loader === 'function') {
matched = true;
void (loader as () => Promise<unknown>)();
}
}
} catch {
return;
}
if (matched) prefetchedPaths.add(key);
}

View File

@@ -414,21 +414,51 @@ function resolveCreateParentLabel(agentId: string) {
}
/* ─── Init ─── */
let pageInitPromise: Promise<void> | null = null;
function ensurePageInit(): Promise<void> {
if (settingsLoaded.value) return Promise.resolve();
if (!pageInitPromise) {
pageInitPromise = loadUsersPageInit().finally(() => {
pageInitPromise = null;
});
}
return pageInitPromise;
}
function loadActiveViewTabData() {
const tab = activeViewTab.value;
if (tab === 'players') {
if (canViewUsers.value) void loadAllPlayers();
return;
}
if (!canViewAgents.value) return;
if (tab === 'tier1Agents') {
void loadTier1Agents();
return;
}
const m = /^agentLevel-(\d+)$/.exec(tab);
if (m) {
void ensurePageInit().then(() => loadSubAgentsAtLevel(Number(m[1])));
}
}
onMounted(() => {
if (canManageSettings.value) {
void loadUsersPageInit();
}
if (canViewUsers.value) {
loadAllPlayers();
}
if (canViewAgents.value) {
loadTier1Agents();
}
loadActiveViewTabData();
});
// KeepAlive 激活时静默刷新列表不重复page-init
// KeepAlive 激活时静默刷新当前 tab 列表(不重复 page-init
onActivated(() => {
if (canViewUsers.value && allPlayers.value.length > 0) void loadAllPlayers();
if (canViewAgents.value && tier1Agents.value.length > 0) void loadTier1Agents();
const tab = activeViewTab.value;
if (tab === 'players' && canViewUsers.value && allPlayers.value.length > 0) void loadAllPlayers();
else if (tab === 'tier1Agents' && canViewAgents.value && tier1Agents.value.length > 0) void loadTier1Agents();
else {
const m = /^agentLevel-(\d+)$/.exec(tab);
if (m) {
const lvl = Number(m[1]);
const st = subAgentLevelState[lvl];
if (st?.agents.length) loadSubAgentsAtLevel(lvl);
}
}
});
async function loadUsersPageInit() {
@@ -456,9 +486,6 @@ async function loadUsersPageInit() {
}
if (payload.agentLevelCounts) {
agentLevelCounts.value = payload.agentLevelCounts;
for (const lvl of visibleSubAgentTabLevels.value) {
loadSubAgentsAtLevel(lvl);
}
}
settingsLoaded.value = true;
} catch {
@@ -670,14 +697,22 @@ function onSubAgentRowClick(row: AgentRow, _column: unknown, event: MouseEvent)
watch(activeViewTab, (tab) => {
const m = /^agentLevel-(\d+)$/.exec(tab);
if (m) loadSubAgentsAtLevel(Number(m[1]));
if (m) {
void ensurePageInit().then(() => loadSubAgentsAtLevel(Number(m[1])));
return;
}
if (tab === 'players' && canViewUsers.value && !allPlayers.value.length) void loadAllPlayers();
if (tab === 'tier1Agents' && canViewAgents.value && !tier1Agents.value.length) void loadTier1Agents();
});
watch(visibleSubAgentTabLevels, (levels, prev) => {
for (const lvl of levels) {
if (!prev?.includes(lvl) || !subAgentLevelState[lvl]?.agents.length) {
loadSubAgentsAtLevel(lvl);
}
watch(visibleSubAgentTabLevels, (levels) => {
const tab = activeViewTab.value;
const m = /^agentLevel-(\d+)$/.exec(tab);
if (!m) return;
const activeLevel = Number(m[1]);
if (levels.includes(activeLevel)) {
const st = subAgentLevelState[activeLevel];
if (!st?.agents.length) loadSubAgentsAtLevel(activeLevel);
}
});

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
defineOptions({ name: 'AdminAudit' });
import AuditLogTable from '../components/AuditLogTable.vue';
</script>

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import { ref, onMounted, onActivated } from 'vue';
import { ref } from 'vue';
defineOptions({ name: 'AdminBets' });
import { useStaleListLifecycle } from '../composables/useStaleList';
import api from '../api';
import { formatAmount, formatAmountFull } from '../utils/format-amount';
import {
@@ -34,16 +35,6 @@ const detailVisible = ref(false);
const detail = ref<BetDetail | null>(null);
const detailLoading = ref(false);
onMounted(load);
// KeepAlive 激活时:有缓存数据则后台静默刷新,避免白屏等待
onActivated(() => { if (bets.value.length > 0) void load(); });
function betContentCounts(row: BetListRow) {
const singles = row.betType === 'SINGLE' ? 1 : 0;
const parlays = row.betType === 'PARLAY' ? 1 : 0;
return t('bet.content.bet_counts', { singles, parlays });
}
async function load() {
const { data } = await api.get('/admin/bets', {
params: {
@@ -60,15 +51,17 @@ async function load() {
total.value = data.data.total;
}
const { loading: listLoading, runLoad } = useStaleListLifecycle(() => bets.value.length > 0, load);
function onPageChange(p: number) {
page.value = p;
load();
void runLoad(true);
}
function onSizeChange(size: number) {
pageSize.value = size;
page.value = 1;
load();
void runLoad(true);
}
function resetFilters() {
@@ -78,7 +71,13 @@ function resetFilters() {
placedFrom.value = '';
placedTo.value = '';
page.value = 1;
load();
void runLoad(true);
}
function betContentCounts(row: BetListRow) {
const singles = row.betType === 'SINGLE' ? 1 : 0;
const parlays = row.betType === 'PARLAY' ? 1 : 0;
return t('bet.content.bet_counts', { singles, parlays });
}
function parentLabel(row: BetListRow) {
@@ -166,13 +165,13 @@ async function openDetail(row: BetListRow) {
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="load">{{ t('common.search') }}</el-button>
<el-button type="primary" @click="runLoad(true)">{{ t('common.search') }}</el-button>
<el-button @click="resetFilters">{{ t('common.reset') }}</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="data-card" shadow="never">
<el-card v-loading="listLoading" class="data-card" shadow="never">
<div class="table-wrap">
<el-table :data="bets" stripe class="bets-table">
<template #empty>

View File

@@ -129,8 +129,8 @@ function tableSummary(param: {
});
}
async function loadHistory() {
historyLoading.value = true;
async function loadHistory(opts?: { silent?: boolean }) {
if (!opts?.silent) historyLoading.value = true;
try {
const { data } = await api.get('/admin/cashbacks', {
params: {
@@ -144,7 +144,7 @@ async function loadHistory() {
} catch (err) {
ElMessage.error(resolveApiError(err, t, 'msg.load_failed'));
} finally {
historyLoading.value = false;
if (!opts?.silent) historyLoading.value = false;
}
}
@@ -250,9 +250,10 @@ function onHistoryStatusChange() {
loadHistory();
}
onMounted(loadHistory);
// KeepAlive 激活时静默刷新
onActivated(() => { if (history.value.length > 0) void loadHistory(); });
onMounted(() => void loadHistory());
onActivated(() => {
if (history.value.length > 0) void loadHistory({ silent: true });
});
</script>
<template>

View File

@@ -1,5 +1,7 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
defineOptions({ name: 'AdminContents' });
import { ref, computed, watch, onActivated } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import type { TableInstance } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
@@ -237,6 +239,11 @@ watch([activeType, filterStatus], () => {
void load();
});
onActivated(() => {
if (activeType.value === 'INBOX_NOTIFY') return;
if (items.value.length > 0) void load();
});
function onSelectionChange(rows: ContentItem[]) {
selectedRows.value = rows;
}

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
defineOptions({ name: 'AdminDepositManage' });
import { ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAdminLocale } from '../composables/useAdminLocale';

View File

@@ -77,8 +77,8 @@ const auditTarget = ref<DepositOrderRow | null>(null);
const auditLogs = ref<DepositAuditLogRow[]>([]);
const auditLoading = ref(false);
async function fetchList() {
loading.value = true;
async function fetchList(opts?: { silent?: boolean }) {
if (!opts?.silent) loading.value = true;
try {
const params: any = { page: page.value, pageSize: pageSize.value };
if (statusFilter.value) params.status = statusFilter.value;
@@ -90,7 +90,7 @@ async function fetchList() {
total.value = result.total ?? 0;
void refreshDepositPendingCount();
} catch { /* */ } finally {
loading.value = false;
if (!opts?.silent) loading.value = false;
}
}
@@ -249,13 +249,14 @@ function refreshList() {
function prevPage() { if (page.value > 1) { page.value--; fetchList(); } }
function nextPage() { if (page.value * pageSize.value < total.value) { page.value++; fetchList(); } }
onMounted(fetchList);
// KeepAlive 激活时静默刷新
onActivated(() => { if (items.value.length > 0) void fetchList(); });
onMounted(() => void fetchList());
onActivated(() => {
if (items.value.length > 0) void fetchList({ silent: true });
});
</script>
<template>
<div class="page-deposit-orders">
<div v-loading="loading" class="page-deposit-orders">
<div class="toolbar">
<h2>{{ t('deposit.deposit_orders_title') }}</h2>
</div>

View File

@@ -50,11 +50,13 @@ const creditItems = ref<CreditTxRow[]>([]);
const creditTotal = ref(0);
const creditPage = ref(1);
const creditPageSize = ref(10);
const creditLoading = ref(false);
const transferItems = ref<TransferTxRow[]>([]);
const transferTotal = ref(0);
const transferPage = ref(1);
const transferPageSize = ref(10);
const transferLoading = ref(false);
const keyword = ref('');
const agentId = ref('');
@@ -125,7 +127,9 @@ function dateParams() {
};
}
async function loadCredit() {
async function loadCredit(opts?: { silent?: boolean }) {
if (!opts?.silent) creditLoading.value = true;
try {
const { data } = await api.get(creditApiPath.value, {
params: {
page: creditPage.value,
@@ -139,9 +143,14 @@ async function loadCredit() {
});
creditItems.value = (data.data?.items ?? []) as CreditTxRow[];
creditTotal.value = data.data?.total ?? 0;
} finally {
if (!opts?.silent) creditLoading.value = false;
}
}
async function loadTransfer() {
async function loadTransfer(opts?: { silent?: boolean }) {
if (!opts?.silent) transferLoading.value = true;
try {
const parentRaw = parentAgentKeyword.value.trim();
const parentIsId = parentRaw && /^\d+$/.test(parentRaw);
const { data } = await api.get(transferApiPath.value, {
@@ -157,6 +166,9 @@ async function loadTransfer() {
});
transferItems.value = (data.data?.items ?? []) as TransferTxRow[];
transferTotal.value = data.data?.total ?? 0;
} finally {
if (!opts?.silent) transferLoading.value = false;
}
}
function onSearch() {
@@ -206,10 +218,9 @@ onMounted(() => {
if (activeTab.value === 'credit') void loadCredit();
else void loadTransfer();
});
// KeepAlive 激活时静默刷新
onActivated(() => {
if (activeTab.value === 'credit' && creditItems.value.length > 0) void loadCredit();
else if (activeTab.value === 'transfer' && transferItems.value.length > 0) void loadTransfer();
if (activeTab.value === 'credit' && creditItems.value.length > 0) void loadCredit({ silent: true });
else if (activeTab.value === 'transfer' && transferItems.value.length > 0) void loadTransfer({ silent: true });
});
watch(
@@ -321,7 +332,7 @@ watch(
</el-form>
</el-card>
<el-card v-show="activeTab === 'credit'" class="data-card" shadow="never">
<el-card v-show="activeTab === 'credit'" v-loading="creditLoading" class="data-card" shadow="never">
<div class="table-wrap">
<el-table :key="`${locale}-credit`" :data="creditItems" stripe>
<template #empty>
@@ -392,7 +403,7 @@ watch(
</div>
</el-card>
<el-card v-show="activeTab === 'transfer'" class="data-card" shadow="never">
<el-card v-show="activeTab === 'transfer'" v-loading="transferLoading" class="data-card" shadow="never">
<div class="table-wrap">
<el-table :key="`${locale}-transfer`" :data="transferItems" stripe>
<template #empty>

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import { ref, computed, onMounted, onActivated, onBeforeUnmount } from 'vue';
import { ref, computed, onBeforeUnmount } from 'vue';
defineOptions({ name: 'AdminMatches' });
import { useStaleListLifecycle } from '../composables/useStaleList';
import { useRoute } from 'vue-router';
import { useAdminLocale } from '../composables/useAdminLocale';
import { resolveFormError } from '../i18n/form-validation';
@@ -110,21 +111,21 @@ async function load(options: LoadOptions = {}) {
function onSearch() {
page.value = 1;
expandedRowKeys.value = [];
load();
void runLoad(true);
}
onMounted(() => {
async function initialLoad() {
const qStatus = route.query.status;
if (typeof qStatus === 'string' && qStatus.trim()) {
filterStatus.value = qStatus.trim();
page.value = 1;
load();
await load();
return;
}
load({ restoreExpand: true });
});
// KeepAlive 激活时静默刷新,保持展开状态
onActivated(() => { if (leagues.value.length > 0) void load({ keepExpand: true }); });
await load({ restoreExpand: true });
}
const { loading: listLoading, runLoad } = useStaleListLifecycle(() => leagues.value.length > 0, initialLoad);
onBeforeUnmount(persistListUiState);
function onPageChange(p: number) {
@@ -421,7 +422,7 @@ function onLeagueArchived() {
<p v-if="filterStatus" class="list-hint">{{ t('match.filter.status_hint') }}</p>
</div>
<section class="list-panel">
<section v-loading="listLoading" class="list-panel">
<p class="list-hint">{{ t('match.expand_league_hint') }}</p>
<div class="table-wrap">
<el-table

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import { ref, onMounted, onActivated, onBeforeUnmount } from 'vue';
import { ref, onBeforeUnmount } from 'vue';
defineOptions({ name: 'AdminMatchesOutrights' });
import { useStaleListLifecycle } from '../composables/useStaleList';
import { useRoute } from 'vue-router';
import { useAdminLocale } from '../composables/useAdminLocale';
import api from '../api';
@@ -95,12 +96,12 @@ async function resolveExpandFromQuery() {
}
}
onMounted(async () => {
async function initialLoad() {
await load({ restoreExpand: true });
await resolveExpandFromQuery();
});
// KeepAlive 激活时静默刷新
onActivated(() => { if (leagues.value.length > 0) void load({ keepExpand: true }); });
}
const { loading: listLoading, runLoad } = useStaleListLifecycle(() => leagues.value.length > 0, initialLoad);
onBeforeUnmount(persistListUiState);
function onPageChange(p: number) {
@@ -177,7 +178,7 @@ function isLeagueExpanded(id: string) {
</div>
</div>
<section class="list-panel">
<section v-loading="listLoading" class="list-panel">
<p class="list-hint">{{ t('match.expand_outright_hint') }}</p>
<div class="table-wrap">
<el-table

View File

@@ -1,5 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
defineOptions({ name: 'AdminMediaLibrary' });
import { ref, computed, onMounted, onActivated, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
import api from '../api';
@@ -54,8 +56,8 @@ function formatDate(iso: string) {
return new Date(iso).toLocaleString();
}
async function loadFiles() {
loading.value = true;
async function loadFiles(opts?: { silent?: boolean }) {
if (!opts?.silent) loading.value = true;
try {
const params: Record<string, string | number> = { page: currentPage.value, pageSize };
if (activeCategory.value) params.category = activeCategory.value;
@@ -65,7 +67,7 @@ async function loadFiles() {
} catch {
ElMessage.error(t('common.loading'));
} finally {
loading.value = false;
if (!opts?.silent) loading.value = false;
}
}
@@ -74,9 +76,14 @@ watch(activeCategory, () => {
loadFiles();
});
watch(currentPage, loadFiles);
watch(currentPage, () => {
void loadFiles();
});
onMounted(loadFiles);
onMounted(() => void loadFiles());
onActivated(() => {
if (files.value.length > 0) void loadFiles({ silent: true });
});
async function confirmDelete(file: MediaFile) {
await ElMessageBox.confirm(t('media.delete_confirm'), { type: 'warning' });

View File

@@ -88,8 +88,8 @@ async function loadRoles() {
roles.value = (data.data ?? []) as RoleOption[];
}
async function load() {
loading.value = true;
async function load(opts?: { silent?: boolean }) {
if (!opts?.silent) loading.value = true;
try {
const { data } = await api.get('/admin/staff', {
params: {
@@ -104,7 +104,7 @@ async function load() {
rows.value = [];
total.value = 0;
} finally {
loading.value = false;
if (!opts?.silent) loading.value = false;
}
}
@@ -251,8 +251,9 @@ onMounted(async () => {
await loadRoles();
await load();
});
// KeepAlive 激活时静默刷新
onActivated(() => { if (rows.value.length > 0) void load(); });
onActivated(() => {
if (rows.value.length > 0) void load({ silent: true });
});
</script>
<template>

View File

@@ -1,5 +1,8 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
defineOptions({ name: 'AgentBets' });
import { ref } from 'vue';
import { useStaleListLifecycle } from '../../composables/useStaleList';
import { useAdminLocale } from '../../composables/useAdminLocale';
import api from '../../api';
import { formatAmount } from '../../utils/format-amount';
@@ -22,8 +25,6 @@ const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
onMounted(load);
async function load() {
const { data } = await api.get('/agent/bets', {
params: { page: page.value, pageSize: pageSize.value },
@@ -32,15 +33,17 @@ async function load() {
total.value = data.data.total ?? 0;
}
const { loading, runLoad } = useStaleListLifecycle(() => bets.value.length > 0, load);
function onPageChange(p: number) {
page.value = p;
load();
void runLoad(true);
}
function onSizeChange(size: number) {
pageSize.value = size;
page.value = 1;
load();
void runLoad(true);
}
</script>
@@ -51,7 +54,7 @@ function onSizeChange(size: number) {
<span class="page-desc">{{ t('page.agent_bets.desc') }}</span>
</div>
<el-card class="data-card" shadow="never">
<el-card v-loading="loading" class="data-card" shadow="never">
<div class="table-wrap">
<el-table :data="bets" stripe>
<el-table-column prop="id" :label="t('bet.col.serial')" width="56" align="center" />

View File

@@ -1,5 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch, reactive, h } from 'vue';
defineOptions({ name: 'AgentPlayers' });
import { ref, computed, onMounted, onActivated, watch, reactive, h } from 'vue';
import { useAdminLocale } from '../../composables/useAdminLocale';
import { useAuthStore } from '../../stores/auth';
import api from '../../api';
@@ -288,22 +290,52 @@ const transferAmountCapError = computed(() => {
onMounted(async () => {
await loadProfile();
await loadAgentOptions();
await loadAllPlayers();
if (canManageSubAgents.value) {
const tab = activeViewTab.value;
if (tab === 'players') {
await loadAllPlayers();
} else if (canManageSubAgents.value) {
await reloadSubAgentTabs();
const m = /^agentLevel-(\d+)$/.exec(tab);
if (m) loadSubAgentsAtLevel(Number(m[1]));
}
});
onActivated(() => {
const tab = activeViewTab.value;
if (tab === 'players' && allPlayers.value.length > 0) void loadAllPlayers();
else {
const m = /^agentLevel-(\d+)$/.exec(tab);
if (m) {
const lvl = Number(m[1]);
const st = subAgentLevelState[lvl];
if (st?.agents.length) loadSubAgentsAtLevel(lvl);
}
}
});
watch(activeViewTab, (tab) => {
const m = /^agentLevel-(\d+)$/.exec(tab);
if (m) loadSubAgentsAtLevel(Number(m[1]));
if (m) {
if (!subAgentLevelState[Number(m[1])]?.agents.length) {
if (canManageSubAgents.value && !Object.keys(subAgentLevelState).length) {
void reloadSubAgentTabs().then(() => loadSubAgentsAtLevel(Number(m[1])));
} else {
loadSubAgentsAtLevel(Number(m[1]));
}
}
return;
}
if (tab === 'players' && !allPlayers.value.length) void loadAllPlayers();
});
watch(visibleSubAgentTabLevels, (levels, prev) => {
for (const lvl of levels) {
if (!prev?.includes(lvl) || !subAgentLevelState[lvl]?.agents.length) {
loadSubAgentsAtLevel(lvl);
}
watch(visibleSubAgentTabLevels, (levels) => {
const tab = activeViewTab.value;
const m = /^agentLevel-(\d+)$/.exec(tab);
if (!m) return;
const activeLevel = Number(m[1]);
if (levels.includes(activeLevel)) {
const st = subAgentLevelState[activeLevel];
if (!st?.agents.length) loadSubAgentsAtLevel(activeLevel);
}
});

View File

@@ -52,6 +52,10 @@ export default defineConfig(({ mode }) => {
if (id.includes('/src/i18n/admin-pages-ms')) return 'i18n-ms-MY';
// admin-pages.ts 如仍被引用,归入共享 chunk当前已无引用保留规则作兜底
if (id.includes('/src/i18n/admin-pages')) return 'i18n-pages';
if (id.includes('/src/views/AgentManager.vue')) return 'admin-users';
if (id.includes('/src/views/Bets.vue')) return 'admin-bets';
if (id.includes('/src/views/Matches.vue')) return 'admin-matches';
if (id.includes('/src/views/MatchesOutrights.vue')) return 'admin-matches-outrights';
if (id.includes('echarts-setup') || id.includes('vue-echarts') || id.includes('node_modules/echarts')) {
return 'echarts';
}

View File

@@ -7,11 +7,13 @@
## 任务清单
- [ ] **measure-baseline**DevTools Network/Performance 记录慢路径基线(`/users``/bets`、充值 tab 切换)
- [x] **measure-baseline**DevTools 验收步骤见下文「九、度量验收」目标guard 无阻塞 /me、切回列表无重复全量 API、hover 预取后 RouteChanged < 100ms
- [x] **instant-nav-phase1**KeepAlive name 对齐、非阻塞 guard、侧边栏 chunk 预取2026-06-18
- [x] **instant-nav-phase2**`useStaleListLifecycle` + 列表页 v-loading 壳、AgentManager 按 tab 延迟 API2026-06-18
- [x] **keepalive-layout**`ManageLayout``RouterView` 增加 `KeepAlive` + 列表页 `defineOptions({ name })`2026-06-18
- [x] **list-stale-cache**:高频列表页改 `onActivated` + stale-while-revalidate避免 remount 全量 refetch2026-06-18
- [x] **fix-deposit-tabs**`DepositManage``v-if``v-show`2026-06-18
- [ ] **lighten-agent-manager**`AgentManager` 挂载 API 合并或按 tab 延迟加载onActivated 部分已做,拆分 SFC 待后续)
- [ ] **lighten-agent-manager**`AgentManager` 挂载 API 合并或按 tab 延迟加载onActivated + 按 tab 延迟已做,拆分 SFC 待后续)
- [x] **guard-session**`beforeEach` 去阻塞式 `ensureStaffSession`TTL 内同步快速路径);`api` 拦截器减少 per-request reconcile2026-06-18
- [x] **bundle-i18n-ep**Element Plus 按需引入 + 落地 `split-i18n` + `App.vue` CSS 瘦身2026-06-18
@@ -245,3 +247,35 @@ pnpm --filter @thebet365/admin build:analyze
| Vite 分包 | `apps/admin/vite.config.ts` |
| 重型用户页 | `apps/admin/src/views/AgentManager.vue` |
| 充值 tab | `apps/admin/src/views/DepositManage.vue` |
| 路由 chunk 预取 | `apps/admin/src/utils/route-prefetch.ts` |
| 列表 stale 生命周期 | `apps/admin/src/composables/useStaleList.ts` |
---
## 九、度量验收DevTools
### Performance — 点击 → 路由切换
1. 打开 Chrome DevTools → **Performance**,勾选 Screenshots
2. 录制hover 侧边栏「注单管理」约 0.5s → 点击 → 停止
3. 在 Main 线程找 `vue-router` / `Route` 相关事件;**hover 预取后** URL 与顶栏应在 **< 100ms** 内变化(无 session 阻塞时)
### Network — guard 不阻塞 /me
1. DevTools → **Network**,过滤 `auth/me`
2. 登录后 60s 内连续切换 3 个菜单:**不应**每次切页都出现 `/manage/auth/me`
3. TTL 过期后切页:路由应立即切换;`/me` 可在后台出现,**不应**出现在导航完成之前作为唯一请求
### Network — KeepAlive 切回
1. 进入 `/bets` 等待列表加载 → 切到 `/matches` → 再切回 `/bets`
2. 第二次进入:**不应**重复全量 `GET /admin/bets`(或仅 silent refresh表格不白屏
3. `/users` 二次进入mount 时不应并行 3+ bootstrap API仅当前 tab + 可选 silent refresh
### 构建分析
```bash
pnpm --filter @thebet365/admin build:analyze
```
确认 `admin-users``admin-bets``admin-matches` 等独立 chunk 存在,主包不含完整列表页 SFC。