diff --git a/CLAUDE.md b/CLAUDE.md index d239ad4..3c26385 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ - **后端**:Go + GoFrame (v2) + goose(数据库迁移) - **数据库**:PostgreSQL(唯一,不用 MySQL/SQLite) - **缓存**:Redis + 内存缓存(双层缓存) -- **前端**:Vue 3 + Vite + TailwindCSS + Naive UI(管理后台) +- **前端**:Vue 3 + Vite + TailwindCSS + Arco Design Vue(管理后台) - **包管理**:bun - **对象存储**:S3 / OSS / COS / MinIO(禁止纯本地磁盘作为生产存储) @@ -383,9 +383,9 @@ wallets 表包含 `frozen_balance` 字段,用于支付中/退款中的金额 ### 前端 - Vue 3 Composition API + ` + + + + + + + + 重新加载 + 返回仪表盘 + + + + + + + diff --git a/web/admin/src/main.ts b/web/admin/src/main.ts index 661008c..4ba0800 100644 --- a/web/admin/src/main.ts +++ b/web/admin/src/main.ts @@ -27,6 +27,9 @@ console.log( ) const app = createApp(App) +app.config.errorHandler = (error, _instance, info) => { + console.error('[VueError]', info, error) +} app.use(createPinia()) app.use(ArcoVue) app.use(router) diff --git a/web/admin/src/router/index.ts b/web/admin/src/router/index.ts index 08a104f..e0f3b09 100644 --- a/web/admin/src/router/index.ts +++ b/web/admin/src/router/index.ts @@ -2,10 +2,9 @@ import { createRouter, createWebHashHistory } from 'vue-router' import type { RouteRecordRaw } from 'vue-router' import adminRoutes from './admin' import { useAuthStore } from '@/stores/auth' -import { shouldRefresh, getRefreshToken } from '@/utils/request' +import request, { shouldRefresh, getRefreshToken } from '@/utils/request' import { useSiteName } from '@/composables/useSiteName' import { useTopProgress } from '@/composables/useTopProgress' -import axios from 'axios' const routes: RouteRecordRaw[] = [ { @@ -35,10 +34,11 @@ export function markSystemInitialized(): void { async function checkSetupStatus(): Promise { if (setupChecked) return systemInitialized === true try { - const res = await axios.get('/api/setup/status', { timeout: 5000 }) + const res = await request.get('/setup/status', { timeout: 5000 }) systemInitialized = res.data?.data?.initialized === true } catch { - systemInitialized = true + // Allow the current navigation, but retry the setup check next time. + return true } setupChecked = true return systemInitialized === true diff --git a/web/admin/src/utils/request.ts b/web/admin/src/utils/request.ts index 52d6919..112a7ba 100644 --- a/web/admin/src/utils/request.ts +++ b/web/admin/src/utils/request.ts @@ -82,7 +82,12 @@ export function shouldRefresh(): boolean { } let isRefreshing = false -let pendingRequests: Array<(token: string) => void> = [] +interface PendingRequest { + resolve: (token: string) => void + reject: (error: unknown) => void +} + +let pendingRequests: PendingRequest[] = [] let tokenRefreshedCallback: ((tokens: TokenPair) => void) | null = null export function onTokenRefreshed(cb: (tokens: TokenPair) => void): void { @@ -90,14 +95,21 @@ export function onTokenRefreshed(cb: (tokens: TokenPair) => void): void { } function replayPending(token: string): void { - pendingRequests.forEach((cb) => cb(token)) + pendingRequests.forEach(({ resolve }) => resolve(token)) pendingRequests = [] } -function clearPending(): void { +function rejectPending(error: unknown): void { + pendingRequests.forEach(({ reject }) => reject(error)) pendingRequests = [] } +function waitForRefresh(): Promise { + return new Promise((resolve, reject) => { + pendingRequests.push({ resolve, reject }) + }) +} + const request: AxiosInstance = axios.create({ baseURL: '/api', timeout: 30_000, @@ -132,18 +144,18 @@ request.interceptors.request.use( async (config: InternalAxiosRequestConfig) => { if (shouldRefresh() && !config.url?.includes('/auth/refresh')) { if (isRefreshing) { - await new Promise((resolve) => { - pendingRequests.push(() => resolve()) - }) + await waitForRefresh() } else { isRefreshing = true try { const newTokens = await doRefresh() replayPending(newTokens.accessToken) - } catch { - clearPending() + } catch (error) { + rejectPending(error) clearTokens() + showRequestError(error, config) window.location.hash = '#/admin/login' + return Promise.reject(error) } finally { isRefreshing = false } @@ -168,6 +180,26 @@ function showErrorToast(message: string, config?: InternalAxiosRequestConfig) { Message.error(message) } +function showRequestError(error: unknown, config?: InternalAxiosRequestConfig) { + if (config?._suppressErrorMsg || !axios.isAxiosError(error)) return + if ((error as any)._errorToastShown) return + ;(error as any)._errorToastShown = true + const responseData = error.response?.data + if (responseData && typeof responseData === 'object' && 'message' in responseData) { + Message.error(String(responseData.message || '请求失败')) + return + } + if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') { + Message.error('请求超时,请稍后重试') + return + } + if (!error.response) { + Message.error('网络连接异常,请检查后端服务是否正常') + return + } + Message.error(`服务器请求失败(${error.response.status})`) +} + request.interceptors.response.use( (response) => { const data = response.data @@ -204,18 +236,20 @@ request.interceptors.response.use( ;(err as any).isDemoModeError = error.response.data.code === DEMO_MODE_CODE return Promise.reject(err) } + showRequestError(error, originalRequest) return Promise.reject(error) } if (isRefreshing) { - return new Promise((resolve) => { - pendingRequests.push((newToken: string) => { - originalRequest.headers = originalRequest.headers ?? {} - originalRequest.headers.Authorization = `Bearer ${newToken}` - originalRequest._retry = true - resolve(request(originalRequest)) - }) - }) + try { + const newToken = await waitForRefresh() + originalRequest.headers = originalRequest.headers ?? {} + originalRequest.headers.Authorization = `Bearer ${newToken}` + originalRequest._retry = true + return request(originalRequest) + } catch (refreshError) { + return Promise.reject(refreshError) + } } originalRequest._retry = true @@ -228,11 +262,12 @@ request.interceptors.response.use( originalRequest.headers = originalRequest.headers ?? {} originalRequest.headers.Authorization = `Bearer ${newTokens.accessToken}` return request(originalRequest) - } catch { - clearPending() + } catch (refreshError) { + rejectPending(refreshError) clearTokens() + showRequestError(refreshError, originalRequest) window.location.hash = '#/admin/login' - return Promise.reject(error) + return Promise.reject(refreshError) } finally { isRefreshing = false } diff --git a/web/admin/src/views/AdminLayout.vue b/web/admin/src/views/AdminLayout.vue index cf960bc..00691f2 100644 --- a/web/admin/src/views/AdminLayout.vue +++ b/web/admin/src/views/AdminLayout.vue @@ -5,6 +5,7 @@ import { Message } from '@arco-design/web-vue' import axios from 'axios' import { marked } from 'marked' import request from '@/utils/request' +import RouteErrorBoundary from '@/components/RouteErrorBoundary.vue' import { useWatermark } from '@/composables/useWatermark' import { useSiteName } from '@/composables/useSiteName' import { @@ -612,9 +613,11 @@ onUnmounted(() => { - + - + + + diff --git a/web/tenant/src/components/common/RouteErrorBoundary.vue b/web/tenant/src/components/common/RouteErrorBoundary.vue new file mode 100644 index 0000000..98ff179 --- /dev/null +++ b/web/tenant/src/components/common/RouteErrorBoundary.vue @@ -0,0 +1,46 @@ + + + + + + + + + + 页面加载失败 + 页面数据异常,请重试或返回仪表盘 + + + + 重新加载 + + + + 返回仪表盘 + + + + + diff --git a/web/tenant/src/main.ts b/web/tenant/src/main.ts index 425faee..b7926af 100644 --- a/web/tenant/src/main.ts +++ b/web/tenant/src/main.ts @@ -29,6 +29,9 @@ console.log( ) const app = createApp(App) +app.config.errorHandler = (error, _instance, info) => { + console.error('[VueError]', info, error) +} app.use(head) app.use(createPinia()) app.use(router) diff --git a/web/tenant/src/utils/request.ts b/web/tenant/src/utils/request.ts index d56b413..1d65c96 100644 --- a/web/tenant/src/utils/request.ts +++ b/web/tenant/src/utils/request.ts @@ -82,7 +82,12 @@ export function shouldRefresh(): boolean { } let isRefreshing = false -let pendingRequests: Array<(token: string) => void> = [] +interface PendingRequest { + resolve: (token: string) => void + reject: (error: unknown) => void +} + +let pendingRequests: PendingRequest[] = [] let tokenRefreshedCallback: ((tokens: TokenPair) => void) | null = null export function onTokenRefreshed(cb: (tokens: TokenPair) => void): void { @@ -90,14 +95,21 @@ export function onTokenRefreshed(cb: (tokens: TokenPair) => void): void { } function replayPending(token: string): void { - pendingRequests.forEach((cb) => cb(token)) + pendingRequests.forEach(({ resolve }) => resolve(token)) pendingRequests = [] } -function clearPending(): void { +function rejectPending(error: unknown): void { + pendingRequests.forEach(({ reject }) => reject(error)) pendingRequests = [] } +function waitForRefresh(): Promise { + return new Promise((resolve, reject) => { + pendingRequests.push({ resolve, reject }) + }) +} + const request: AxiosInstance = axios.create({ baseURL: '/api', timeout: 30_000, @@ -149,18 +161,18 @@ request.interceptors.request.use( async (config: InternalAxiosRequestConfig) => { if (!isPublicPath(config.url) && shouldRefresh() && !config.url?.includes('/auth/refresh')) { if (isRefreshing) { - await new Promise((resolve) => { - pendingRequests.push(() => resolve()) - }) + await waitForRefresh() } else { isRefreshing = true try { const newTokens = await doRefresh() replayPending(newTokens.accessToken) - } catch { - clearPending() + } catch (error) { + rejectPending(error) clearTokens() + showRequestError(error, config) window.location.hash = '#/tenant/login' + return Promise.reject(error) } finally { isRefreshing = false } @@ -185,6 +197,27 @@ function showErrorToast(message: string, config?: InternalAxiosRequestConfig | A toast.error(message) } +function showRequestError(error: unknown, config?: InternalAxiosRequestConfig | AxiosRequestConfig) { + if (config && '_suppressErrorMsg' in config && (config as any)._suppressErrorMsg) return + if (!axios.isAxiosError(error)) return + if ((error as any)._errorToastShown) return + ;(error as any)._errorToastShown = true + const responseData = error.response?.data + if (responseData && typeof responseData === 'object' && 'message' in responseData) { + toast.error(String(responseData.message || '请求失败')) + return + } + if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') { + toast.error('请求超时,请稍后重试') + return + } + if (!error.response) { + toast.error('网络连接异常,请检查后端服务是否正常') + return + } + toast.error(`服务器请求失败(${error.response.status})`) +} + request.interceptors.response.use( (response) => { const data = response.data @@ -222,22 +255,20 @@ request.interceptors.response.use( ;(err as any).isDemoModeError = error.response.data.code === DEMO_MODE_CODE return Promise.reject(err) } - // Network or other unexpected errors - if (!error.response) { - showErrorToast('网络连接异常,请稍后重试', originalRequest) - } + showRequestError(error, originalRequest) return Promise.reject(error) } if (isRefreshing) { - return new Promise((resolve) => { - pendingRequests.push((newToken: string) => { - originalRequest.headers = originalRequest.headers ?? {} - originalRequest.headers.Authorization = `Bearer ${newToken}` - originalRequest._retry = true - resolve(request(originalRequest)) - }) - }) + try { + const newToken = await waitForRefresh() + originalRequest.headers = originalRequest.headers ?? {} + originalRequest.headers.Authorization = `Bearer ${newToken}` + originalRequest._retry = true + return request(originalRequest) + } catch (refreshError) { + return Promise.reject(refreshError) + } } originalRequest._retry = true @@ -250,11 +281,12 @@ request.interceptors.response.use( originalRequest.headers = originalRequest.headers ?? {} originalRequest.headers.Authorization = `Bearer ${newTokens.accessToken}` return request(originalRequest) - } catch { - clearPending() + } catch (refreshError) { + rejectPending(refreshError) clearTokens() + showRequestError(refreshError, originalRequest) window.location.hash = '#/tenant/login' - return Promise.reject(error) + return Promise.reject(refreshError) } finally { isRefreshing = false } diff --git a/web/tenant/src/views/Layout.vue b/web/tenant/src/views/Layout.vue index a928885..21e3ccb 100644 --- a/web/tenant/src/views/Layout.vue +++ b/web/tenant/src/views/Layout.vue @@ -10,6 +10,7 @@ import { toast } from '@/utils/toast' import Icon from '@/components/common/Icon.vue' import MaintenanceBanner from '@/components/common/MaintenanceBanner.vue' import AnnouncementBanner from '@/components/common/AnnouncementBanner.vue' +import RouteErrorBoundary from '@/components/common/RouteErrorBoundary.vue' import { marked } from 'marked' import request from '@/utils/request' @@ -510,7 +511,11 @@ onBeforeUnmount(() => { class="tenant-main px-3 pb-5 pt-4 md:px-5 md:pb-6 lg:pt-5" :class="{ 'tenant-table-main': route.meta.viewportTable }" > - + + + + +
页面数据异常,请重试或返回仪表盘