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

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

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, watch } from 'vue'; import { ref, onMounted, onActivated, watch } from 'vue';
import { useAdminLocale } from '../composables/useAdminLocale'; import { useAdminLocale } from '../composables/useAdminLocale';
import { useAuditLabels } from '../utils/audit-labels'; import { useAuditLabels } from '../utils/audit-labels';
import api from '../api'; import api from '../api';
@@ -39,8 +39,12 @@ const total = ref(0);
const page = ref(1); const page = ref(1);
const pageSize = ref(10); const pageSize = ref(10);
const filterModule = ref(''); const filterModule = ref('');
const loading = ref(false);
onMounted(load); onMounted(() => void load());
onActivated(() => {
if (logs.value.length > 0) void load({ silent: true });
});
watch( watch(
() => props.endpoint, () => 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, { const { data } = await api.get(props.endpoint, {
params: { params: {
page: page.value, page: page.value,
@@ -62,6 +68,9 @@ async function load() {
}); });
logs.value = (data.data.items ?? []) as AuditRow[]; logs.value = (data.data.items ?? []) as AuditRow[];
total.value = data.data.total ?? 0; total.value = data.data.total ?? 0;
} finally {
if (!opts?.silent) loading.value = false;
}
} }
function onPageChange(p: number) { function onPageChange(p: number) {
@@ -123,7 +132,7 @@ function operatorDisplay(row: AuditRow): string {
</el-form> </el-form>
</el-card> </el-card>
<el-card class="data-card" shadow="never"> <el-card v-loading="loading" class="data-card" shadow="never">
<div class="table-wrap"> <div class="table-wrap">
<el-table :key="locale" :data="logs" stripe> <el-table :key="locale" :data="logs" stripe>
<template #empty> <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 AdminNavIcon from '../components/AdminNavIcon.vue';
import { resolveAdminBreadcrumb } from '../utils/admin-breadcrumb'; import { resolveAdminBreadcrumb } from '../utils/admin-breadcrumb';
import { useDepositPendingCount } from '../composables/useDepositPendingCount'; import { useDepositPendingCount } from '../composables/useDepositPendingCount';
import { prefetchRouteChunks } from '../utils/route-prefetch';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -29,7 +30,7 @@ const keepAliveIncludes = [
'AdminCashback', 'AdminCashback',
'AdminMatches', 'AdminMatches',
'AdminMatchesOutrights', 'AdminMatchesOutrights',
'AdminDepositOrders', 'AdminDepositManage',
'AdminStaffManage', 'AdminStaffManage',
'AdminFinanceLogs', 'AdminFinanceLogs',
'AdminAgentManager', 'AdminAgentManager',
@@ -174,6 +175,10 @@ function onNavClick() {
} }
} }
function prefetchNavRoute(path: string) {
prefetchRouteChunks(router, path);
}
function logout() { function logout() {
auth.logout(); auth.logout();
router.push('/login'); router.push('/login');
@@ -233,6 +238,8 @@ watch(() => route.path, () => {
: isMatchesSectionPath(route.path))), : isMatchesSectionPath(route.path))),
}" }"
@click="onNavClick" @click="onNavClick"
@mouseenter="prefetchNavRoute(m.path)"
@focus="prefetchNavRoute(m.path)"
> >
<AdminNavIcon :name="m.icon" /> <AdminNavIcon :name="m.icon" />
<span class="nav-label"> <span class="nav-label">

View File

@@ -1,7 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '../stores/auth'; import { useAuthStore } from '../stores/auth';
import { useSmokeTestsAllowed } from '../composables/useSmokeTestsAllowed'; import { useSmokeTestsAllowed } from '../composables/useSmokeTestsAllowed';
import { ensureStaffSession, isSessionFresh } from '../utils/session-hydrate'; import { hydrateStaffSession } from '../utils/session-hydrate';
import { reconcileStaffSessionFromToken } from '../stores/auth'; import { reconcileStaffSessionFromToken } from '../stores/auth';
import { AdminPerm } from '../constants/permissions'; import { AdminPerm } from '../constants/permissions';
import { adminCanAccess, firstAdminFallback } from '../utils/admin-access'; import { adminCanAccess, firstAdminFallback } from '../utils/admin-access';
@@ -192,13 +192,9 @@ router.beforeEach(async (to) => {
const hasToken = !!auth.token.value; const hasToken = !!auth.token.value;
if (hasToken) { if (hasToken) {
if (isSessionFresh()) { reconcileStaffSessionFromToken();
// Session 在 TTL 内且完整:只做同步 reconcile跳过网络请求 // 后台刷新 session不阻塞路由切换401 由 api 拦截器处理
reconcileStaffSessionFromToken(); void hydrateStaffSession();
} else {
// Session 过期或不完整:才 await 远程刷新
await ensureStaffSession();
}
} }
const hasUser = !!auth.user.value?.userType; const hasUser = !!auth.user.value?.userType;
@@ -247,9 +243,8 @@ router.beforeEach(async (to) => {
} }
if (to.meta.smokeTestsOnly) { if (to.meta.smokeTestsOnly) {
const { ensureLoaded, allowed } = useSmokeTestsAllowed(); const { allowed } = useSmokeTestsAllowed();
await ensureLoaded(); if (allowed.value === false) return '/';
if (!allowed.value) return '/';
} }
return true; 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 ─── */ /* ─── 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(() => { onMounted(() => {
if (canManageSettings.value) { loadActiveViewTabData();
void loadUsersPageInit();
}
if (canViewUsers.value) {
loadAllPlayers();
}
if (canViewAgents.value) {
loadTier1Agents();
}
}); });
// KeepAlive 激活时静默刷新列表不重复page-init // KeepAlive 激活时静默刷新当前 tab 列表(不重复 page-init
onActivated(() => { onActivated(() => {
if (canViewUsers.value && allPlayers.value.length > 0) void loadAllPlayers(); const tab = activeViewTab.value;
if (canViewAgents.value && tier1Agents.value.length > 0) void loadTier1Agents(); 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() { async function loadUsersPageInit() {
@@ -456,9 +486,6 @@ async function loadUsersPageInit() {
} }
if (payload.agentLevelCounts) { if (payload.agentLevelCounts) {
agentLevelCounts.value = payload.agentLevelCounts; agentLevelCounts.value = payload.agentLevelCounts;
for (const lvl of visibleSubAgentTabLevels.value) {
loadSubAgentsAtLevel(lvl);
}
} }
settingsLoaded.value = true; settingsLoaded.value = true;
} catch { } catch {
@@ -670,14 +697,22 @@ function onSubAgentRowClick(row: AgentRow, _column: unknown, event: MouseEvent)
watch(activeViewTab, (tab) => { watch(activeViewTab, (tab) => {
const m = /^agentLevel-(\d+)$/.exec(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) => { watch(visibleSubAgentTabLevels, (levels) => {
for (const lvl of levels) { const tab = activeViewTab.value;
if (!prev?.includes(lvl) || !subAgentLevelState[lvl]?.agents.length) { const m = /^agentLevel-(\d+)$/.exec(tab);
loadSubAgentsAtLevel(lvl); 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"> <script setup lang="ts">
defineOptions({ name: 'AdminAudit' });
import AuditLogTable from '../components/AuditLogTable.vue'; import AuditLogTable from '../components/AuditLogTable.vue';
</script> </script>

View File

@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onActivated } from 'vue'; import { ref } from 'vue';
defineOptions({ name: 'AdminBets' }); defineOptions({ name: 'AdminBets' });
import { useStaleListLifecycle } from '../composables/useStaleList';
import api from '../api'; import api from '../api';
import { formatAmount, formatAmountFull } from '../utils/format-amount'; import { formatAmount, formatAmountFull } from '../utils/format-amount';
import { import {
@@ -34,16 +35,6 @@ const detailVisible = ref(false);
const detail = ref<BetDetail | null>(null); const detail = ref<BetDetail | null>(null);
const detailLoading = ref(false); 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() { async function load() {
const { data } = await api.get('/admin/bets', { const { data } = await api.get('/admin/bets', {
params: { params: {
@@ -60,15 +51,17 @@ async function load() {
total.value = data.data.total; total.value = data.data.total;
} }
const { loading: listLoading, runLoad } = useStaleListLifecycle(() => bets.value.length > 0, load);
function onPageChange(p: number) { function onPageChange(p: number) {
page.value = p; page.value = p;
load(); void runLoad(true);
} }
function onSizeChange(size: number) { function onSizeChange(size: number) {
pageSize.value = size; pageSize.value = size;
page.value = 1; page.value = 1;
load(); void runLoad(true);
} }
function resetFilters() { function resetFilters() {
@@ -78,7 +71,13 @@ function resetFilters() {
placedFrom.value = ''; placedFrom.value = '';
placedTo.value = ''; placedTo.value = '';
page.value = 1; 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) { function parentLabel(row: BetListRow) {
@@ -166,13 +165,13 @@ async function openDetail(row: BetListRow) {
/> />
</el-form-item> </el-form-item>
<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-button @click="resetFilters">{{ t('common.reset') }}</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </el-card>
<el-card class="data-card" shadow="never"> <el-card v-loading="listLoading" class="data-card" shadow="never">
<div class="table-wrap"> <div class="table-wrap">
<el-table :data="bets" stripe class="bets-table"> <el-table :data="bets" stripe class="bets-table">
<template #empty> <template #empty>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onActivated, onBeforeUnmount } from 'vue'; import { ref, onBeforeUnmount } from 'vue';
defineOptions({ name: 'AdminMatchesOutrights' }); defineOptions({ name: 'AdminMatchesOutrights' });
import { useStaleListLifecycle } from '../composables/useStaleList';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { useAdminLocale } from '../composables/useAdminLocale'; import { useAdminLocale } from '../composables/useAdminLocale';
import api from '../api'; import api from '../api';
@@ -95,12 +96,12 @@ async function resolveExpandFromQuery() {
} }
} }
onMounted(async () => { async function initialLoad() {
await load({ restoreExpand: true }); await load({ restoreExpand: true });
await resolveExpandFromQuery(); 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); onBeforeUnmount(persistListUiState);
function onPageChange(p: number) { function onPageChange(p: number) {
@@ -177,7 +178,7 @@ function isLeagueExpanded(id: string) {
</div> </div>
</div> </div>
<section class="list-panel"> <section v-loading="listLoading" class="list-panel">
<p class="list-hint">{{ t('match.expand_outright_hint') }}</p> <p class="list-hint">{{ t('match.expand_outright_hint') }}</p>
<div class="table-wrap"> <div class="table-wrap">
<el-table <el-table

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
<script setup lang="ts"> <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 { useAdminLocale } from '../../composables/useAdminLocale';
import { useAuthStore } from '../../stores/auth'; import { useAuthStore } from '../../stores/auth';
import api from '../../api'; import api from '../../api';
@@ -288,22 +290,52 @@ const transferAmountCapError = computed(() => {
onMounted(async () => { onMounted(async () => {
await loadProfile(); await loadProfile();
await loadAgentOptions(); await loadAgentOptions();
await loadAllPlayers(); const tab = activeViewTab.value;
if (canManageSubAgents.value) { if (tab === 'players') {
await loadAllPlayers();
} else if (canManageSubAgents.value) {
await reloadSubAgentTabs(); 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) => { watch(activeViewTab, (tab) => {
const m = /^agentLevel-(\d+)$/.exec(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) => { watch(visibleSubAgentTabLevels, (levels) => {
for (const lvl of levels) { const tab = activeViewTab.value;
if (!prev?.includes(lvl) || !subAgentLevelState[lvl]?.agents.length) { const m = /^agentLevel-(\d+)$/.exec(tab);
loadSubAgentsAtLevel(lvl); 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'; if (id.includes('/src/i18n/admin-pages-ms')) return 'i18n-ms-MY';
// admin-pages.ts 如仍被引用,归入共享 chunk当前已无引用保留规则作兜底 // admin-pages.ts 如仍被引用,归入共享 chunk当前已无引用保留规则作兜底
if (id.includes('/src/i18n/admin-pages')) return 'i18n-pages'; 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')) { if (id.includes('echarts-setup') || id.includes('vue-echarts') || id.includes('node_modules/echarts')) {
return '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] **keepalive-layout**`ManageLayout``RouterView` 增加 `KeepAlive` + 列表页 `defineOptions({ name })`2026-06-18
- [x] **list-stale-cache**:高频列表页改 `onActivated` + stale-while-revalidate避免 remount 全量 refetch2026-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 - [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] **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 - [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` | | Vite 分包 | `apps/admin/vite.config.ts` |
| 重型用户页 | `apps/admin/src/views/AgentManager.vue` | | 重型用户页 | `apps/admin/src/views/AgentManager.vue` |
| 充值 tab | `apps/admin/src/views/DepositManage.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。