fix(frontend): 菜单点击偶发无响应三处根因——①路由懒加载chunk失败被静默吞掉(新增onError自动整页重载+保险丝防循环) ②首页入口被权限接口阻塞(改乐观显示+缓存TTL10分钟+超时30s→8s) ③菜单hover预取目标页chunk(工具卡片/工作流步骤/顶部导航)
This commit is contained in:
@@ -69,6 +69,7 @@
|
||||
:class="{ 'workflow-step--disabled': !step.toolId || !stepHref(step.toolId) }"
|
||||
:style="{ '--gc': currentGroup.color }"
|
||||
@click="onWorkflowStepClick(step)"
|
||||
@mouseenter="prefetchRouteChunk(router, stepHref(step.toolId))"
|
||||
>
|
||||
<span class="step-circle" :style="circleStyle(currentGroup)">{{ index + 1 }}</span>
|
||||
<span class="step-name">{{ step.name }}</span>
|
||||
@@ -88,7 +89,7 @@
|
||||
|
||||
<div class="card-grid">
|
||||
<template v-for="tool in currentGroup.tools" :key="tool.id">
|
||||
<router-link v-if="resolveHref(tool.href)" :to="resolveHref(tool.href)!" class="tool-card" :style="cardStyle(currentGroup)">
|
||||
<router-link v-if="resolveHref(tool.href)" :to="resolveHref(tool.href)!" class="tool-card" :style="cardStyle(currentGroup)" @mouseenter="prefetchToolChunk(tool)">
|
||||
<div class="tool-card-top">
|
||||
<ToolIcon :name="tool.id" />
|
||||
<span v-if="tool.tag" class="tool-tag" :style="tagStyle(currentGroup)">{{ tool.tag }}</span>
|
||||
@@ -129,6 +130,7 @@ import { TUTORIAL_DOWNLOAD_URL } from '@/pages/amazon/download-btn'
|
||||
import { filterGroupsByPermission, filterWorkflowByPermission, TOOL_GROUPS, TOOL_MAP } from '@/pages/amazon/tool-catalog'
|
||||
import type { ToolGroup, ToolInfo, VisibleGroup, WorkflowStep } from '@/pages/amazon/tool-catalog'
|
||||
import { resolvePageHref } from '@/shared/page-prefix'
|
||||
import { prefetchRouteChunk } from '@/shared/route-prefetch'
|
||||
import { getPywebviewApi } from '@/shared/bridges/pywebview'
|
||||
import { getCurrentUserAppColumnKeys } from '@/shared/api/permission'
|
||||
|
||||
@@ -187,6 +189,11 @@ function onWorkflowStepClick(step: WorkflowStep) {
|
||||
showSoon(target?.href ? `${step.name}仅桌面客户端可用` : `${step.name}暂未开通,敬请期待`)
|
||||
}
|
||||
|
||||
/** 卡片 hover 预取目标页 chunk:弱网下点击跳转不再等 chunk 下载 */
|
||||
function prefetchToolChunk(tool: ToolInfo) {
|
||||
prefetchRouteChunk(router, resolveHref(tool.href))
|
||||
}
|
||||
|
||||
function onPendingClick(tool: ToolInfo) {
|
||||
if (!tool.href) {
|
||||
showSoon(`${tool.name}暂未开通,敬请期待`)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<div class="nav-section-title">{{ group.label }}</div>
|
||||
<div class="nav-section-items">
|
||||
<template v-for="item in group.items" :key="item.key">
|
||||
<router-link v-if="item.href" :to="item.href" class="nav-item" :class="{ active: active === item.key }">
|
||||
<router-link v-if="item.href" :to="item.href" class="nav-item" :class="{ active: active === item.key }" @mouseenter="onNavItemHover(item.href)">
|
||||
{{ item.label }}
|
||||
</router-link>
|
||||
<span v-else class="nav-item disabled" :class="{ active: active === item.key }">
|
||||
@@ -36,7 +36,11 @@ import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import BrandApiSecretSettingsButton from '@/pages/brand/components/BrandApiSecretSettingsButton.vue'
|
||||
import { getCurrentUserAppColumnKeys } from '@/shared/api/permission'
|
||||
import { prefetchRouteChunk } from '@/shared/route-prefetch'
|
||||
import DownloadProgressPanel from '@/shared/components/DownloadProgressPanel.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
type ActiveNavKey =
|
||||
| 'brand'
|
||||
@@ -160,6 +164,11 @@ onMounted(async () => {
|
||||
allowedColumnKeys.value = []
|
||||
}
|
||||
})
|
||||
|
||||
/** 导航项 hover 预取目标页 chunk:弱网下点击跳转不再等 chunk 下载 */
|
||||
function onNavItemHover(href?: string) {
|
||||
prefetchRouteChunk(router, href)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="home-root" :class="{ 'nav-permissions-loading': !permissionReady }">
|
||||
<div class="home-root">
|
||||
<header class="header">
|
||||
<span class="header-title">数富AI</span>
|
||||
<div class="header-right">
|
||||
@@ -59,7 +59,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { restoreLoginUser } from '@/shared/auth/ensure-auth'
|
||||
import { getCurrentUserAppColumnRaw, type PermissionMenuItem } from '@/shared/api/permission'
|
||||
import { getCurrentUserAppColumnRaw, readCachedAppColumnPermissions, type PermissionMenuItem } from '@/shared/api/permission'
|
||||
import { useVersionUpdate } from '@/shared/composables/useVersionUpdate'
|
||||
import { resolvePageHref } from '@/shared/page-prefix'
|
||||
|
||||
@@ -73,10 +73,10 @@ const { checking, updating, currentVersion, hasUpdate, canDownload, hint, checke
|
||||
const logoutHref = computed(() => `${resolvePageHref('/new_web_source/login.html')}?logout=1`)
|
||||
|
||||
// 桌面端首页仅保留一级入口:亚马逊 / 视频;图片已收进视频页(/image-video)二级菜单。
|
||||
// 入口先乐观显示(接口返回后按权限隐藏),避免权限接口慢时首页入口空白"点不了"。
|
||||
type PermissionState = { amazon: boolean; video: boolean }
|
||||
const permissionState = ref<PermissionState>({ amazon: false, video: false })
|
||||
const permissionState = ref<PermissionState>({ amazon: true, video: true })
|
||||
const permissionUnavailable = ref(false)
|
||||
const permissionReady = ref(false)
|
||||
|
||||
const amazonHref = computed(() => resolvePageHref('/new_web_source/amazon-console.html'))
|
||||
const videoHref = computed(() => resolvePageHref('/new_web_source/image-video.html'))
|
||||
@@ -125,18 +125,24 @@ async function loadPermissions() {
|
||||
const currentUid = getUid()
|
||||
if (!currentUid) {
|
||||
permissionUnavailable.value = true
|
||||
permissionReady.value = true
|
||||
return
|
||||
}
|
||||
// 先用本地缓存立即渲染入口(有缓存时接口失败也不让入口消失)
|
||||
const cached = readCachedAppColumnPermissions(Number(currentUid))
|
||||
if (cached) {
|
||||
permissionState.value = computePermissionState(cached)
|
||||
}
|
||||
try {
|
||||
// 经 shared/api/permission 层拉取原始权限菜单项(含本地缓存写入;接口失败走 catch)
|
||||
const items = await getCurrentUserAppColumnRaw()
|
||||
permissionState.value = computePermissionState(items)
|
||||
permissionUnavailable.value = false
|
||||
} catch {
|
||||
// 无缓存才退回"显示全部入口";有缓存已按缓存显示,保持现状即可
|
||||
if (!cached) {
|
||||
permissionUnavailable.value = true
|
||||
}
|
||||
permissionReady.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCurrentUser() {
|
||||
@@ -339,10 +345,6 @@ body {
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.home-root.nav-permissions-loading .entrance-btn {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.entrances {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
|
||||
@@ -135,4 +135,41 @@ const router = createRouter({
|
||||
},
|
||||
})
|
||||
|
||||
// 懒加载 chunk 加载失败(网络抖动/瞬间断流)会被 vue-router 静默吞掉,
|
||||
// 菜单点击表现为"点了没反应";此处统一重载页面(重新拉最新 index.html 与新 chunk)。
|
||||
// 用会话内计数做保险丝,网络持续异常时最多重载 2 次,不再死循环。
|
||||
router.onError((error) => {
|
||||
const message = String((error as Error)?.message || '')
|
||||
const isChunkLoadError =
|
||||
message.includes('Failed to fetch dynamically imported module') ||
|
||||
message.includes('Importing a module script failed')
|
||||
if (isChunkLoadError) {
|
||||
const RELOAD_KEY = 'spa-chunk-reload-count'
|
||||
let count = 0
|
||||
try {
|
||||
count = Number(sessionStorage.getItem(RELOAD_KEY) || '0') || 0
|
||||
} catch {
|
||||
/* 存储不可用时按首次处理 */
|
||||
}
|
||||
if (count < 2) {
|
||||
try {
|
||||
sessionStorage.setItem(RELOAD_KEY, String(count + 1))
|
||||
} catch {
|
||||
/* 忽略存储异常 */
|
||||
}
|
||||
console.error(`[router] 页面资源加载失败,重载页面(第 ${count + 1} 次):`, error)
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
try {
|
||||
sessionStorage.removeItem(RELOAD_KEY)
|
||||
} catch {
|
||||
/* 忽略存储异常 */
|
||||
}
|
||||
console.error('[router] 页面资源多次加载失败,停止重载:', error)
|
||||
} else {
|
||||
console.error('[router] 路由导航失败:', error)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -37,6 +37,40 @@ function getAppPermissionCacheKey(uid: number) {
|
||||
return `app_column_permissions:${String(uid)}`
|
||||
}
|
||||
|
||||
/** 权限缓存有效期:10 分钟内直接用本地缓存渲染菜单/入口,避免接口慢时入口消失 */
|
||||
const PERMISSION_CACHE_TTL_MS = 10 * 60 * 1000
|
||||
|
||||
interface CachedAppPermissions {
|
||||
ts: number
|
||||
items: PermissionMenuItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取本地缓存的权限菜单项(兼容旧版纯数组格式:无时间戳视为长期有效)。
|
||||
* 接口失败/超时时由调用方用它兜底,保证菜单不因权限接口抖动而消失。
|
||||
*/
|
||||
export function readCachedAppColumnPermissions(uid: number): PermissionMenuItem[] | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(getAppPermissionCacheKey(uid)) || ''
|
||||
if (!raw) return null
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed as PermissionMenuItem[]
|
||||
}
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === 'object' &&
|
||||
Array.isArray((parsed as CachedAppPermissions).items) &&
|
||||
Date.now() - Number((parsed as CachedAppPermissions).ts || 0) < PERMISSION_CACHE_TTL_MS
|
||||
) {
|
||||
return (parsed as CachedAppPermissions).items
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getAuthToken() {
|
||||
return typeof window === 'undefined' ? '' : window.localStorage.getItem('aiimage_auth_token') || ''
|
||||
}
|
||||
@@ -70,6 +104,8 @@ async function fetchAppColumnPermissions() {
|
||||
{
|
||||
params: { menuType: 'app' },
|
||||
headers,
|
||||
// 权限接口只决定菜单显隐:8s 未返回即失败走缓存兜底,避免入口区长时间空白
|
||||
timeout: 8000,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -79,7 +115,7 @@ async function fetchAppColumnPermissions() {
|
||||
|
||||
const items = res.data || res.items || []
|
||||
try {
|
||||
window.localStorage.setItem(cacheKey, JSON.stringify(items))
|
||||
window.localStorage.setItem(cacheKey, JSON.stringify({ ts: Date.now(), items }))
|
||||
} catch (_error) {}
|
||||
|
||||
return items
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Router } from 'vue-router'
|
||||
|
||||
/**
|
||||
* 路由懒加载 chunk 预取
|
||||
*
|
||||
* 页面均为动态 import(每页独立 chunk,见 src/router/index.ts),首次进入某页
|
||||
* 需现场从线上拉取 chunk。菜单 hover 时提前触发 import()(结果被 router 缓存,
|
||||
* 点击时直接命中缓存),弱网下显著减少"点了没反应/跳转慢"的等待时间。
|
||||
*/
|
||||
|
||||
/** 触发目标路由的懒加载 chunk 预取;href 为空或预取失败时静默(点击时会走 onError 兜底) */
|
||||
export function prefetchRouteChunk(router: Router, href?: string): void {
|
||||
if (!href) return
|
||||
let target = href.trim()
|
||||
const hashIndex = target.indexOf('#')
|
||||
if (hashIndex >= 0) {
|
||||
target = target.slice(0, hashIndex)
|
||||
}
|
||||
try {
|
||||
const resolved = router.resolve(target)
|
||||
const component = resolved.matched[0]?.components?.default as unknown
|
||||
// 懒加载路由的 default 是 () => import(...) 加载器:调用即触发 chunk 下载(结果被 vue-router 缓存)
|
||||
if (typeof component === 'function') {
|
||||
const result = (component as () => unknown)()
|
||||
if (result && typeof (result as Promise<unknown>).then === 'function') {
|
||||
void (result as Promise<unknown>).catch(() => {
|
||||
/* 预取失败静默,点击时统一由 router.onError 兜底 */
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 路径无法解析时忽略 */
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user