diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 6b33a44..3a9a526 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -193,6 +193,7 @@ function authRequiredError(): ApiClientError { message: 'not_authenticated', code: 40100, status: 401, + handled: true, }) } @@ -200,6 +201,13 @@ function expireStoredSession(): void { clearStoredSession({ notifyAuthExpired: true }) } +function markAuthErrorHandled(error: unknown): unknown { + if (error instanceof ApiClientError && error.status === 401) { + error.handled = true + } + return error +} + export function isAuthSessionExpiredError(error: unknown): boolean { return error instanceof ApiClientError && error.status === 401 } @@ -250,7 +258,7 @@ export async function refreshStoredAuthSession( expireStoredSession() } - throw error + throw markAuthErrorHandled(error) }) .finally(() => { storedRefreshPromise = null @@ -893,7 +901,7 @@ async function subscribeSSE( if (isAuthSessionExpiredError(error)) { expireStoredSession() } - throw error + throw markAuthErrorHandled(error) } } @@ -948,6 +956,7 @@ async function buildSSERequestError(response: Response): Promise status: response.status, detail: payload.detail, requestId: payload.request_id, + handled: response.status === 401, }) } } catch { @@ -966,6 +975,7 @@ async function buildSSERequestError(response: Response): Promise message: 'unknown_error', status: response.status, detail: detail ?? `stream request failed with status ${response.status}`, + handled: response.status === 401, }) } diff --git a/apps/admin-web/src/lib/errors.ts b/apps/admin-web/src/lib/errors.ts index a01796d..3601758 100644 --- a/apps/admin-web/src/lib/errors.ts +++ b/apps/admin-web/src/lib/errors.ts @@ -1,5 +1,15 @@ import { ApiClientError } from '@geo/http-client' +const authSessionErrorMessages = new Set([ + 'not_authenticated', + 'missing_bearer_token', + 'invalid_access_token', + 'invalid_refresh_token', + 'refresh_session_expired', + 'refresh_token_mismatch', + 'token_revoked', +]) + const errorMessageMap: Record = { invalid_credentials: '邮箱或密码错误', login_rate_limited: '登录请求过于频繁', @@ -79,7 +89,19 @@ const errorMessageMap: Record = { unknown_error: '发生未知错误', } +export function isHandledAuthError(error: unknown): boolean { + return ( + error instanceof ApiClientError && + error.status === 401 && + (error.handled === true || authSessionErrorMessages.has(error.message)) + ) +} + export function formatError(error: unknown): string { + if (isHandledAuthError(error)) { + return '登录已过期,请重新登录' + } + if (error instanceof ApiClientError) { const message = errorMessageMap[error.message] ?? error.message.replaceAll('_', ' ') return error.detail ? `${message}: ${error.detail}` : message diff --git a/apps/admin-web/src/main.ts b/apps/admin-web/src/main.ts index 4c3ab4a..a7a5b98 100644 --- a/apps/admin-web/src/main.ts +++ b/apps/admin-web/src/main.ts @@ -46,13 +46,14 @@ import { Tree, TreeSelect, Upload, + message, } from 'ant-design-vue' import { createApp } from 'vue' import 'ant-design-vue/dist/reset.css' import App from './App.vue' import { i18n } from './i18n' -import { subscribeAuthExpired } from './lib/session' +import { subscribeAuthExpired, subscribeStoredSession } from './lib/session' import { router } from './router' import { pinia } from './stores/pinia' import './styles.css' @@ -68,6 +69,17 @@ const queryClient = new QueryClient({ }) const app = createApp(App) +let authExpiredNotified = false + +message.config({ + maxCount: 1, +}) + +subscribeStoredSession((snapshot) => { + if (snapshot.accessToken) { + authExpiredNotified = false + } +}) subscribeAuthExpired(() => { const current = router.currentRoute.value @@ -75,10 +87,15 @@ subscribeAuthExpired(() => { return } - void router.replace({ - name: 'login', - query: current.fullPath ? { redirect: current.fullPath } : undefined, - }) + if (!authExpiredNotified) { + authExpiredNotified = true + message.destroy() + message.warning('登录已过期,请重新登录') + void router.replace({ + name: 'login', + query: current.fullPath ? { redirect: current.fullPath } : undefined, + }) + } }) const IconFont = createFromIconfontCN({ diff --git a/apps/ops-web/src/lib/errors.ts b/apps/ops-web/src/lib/errors.ts new file mode 100644 index 0000000..a10ae69 --- /dev/null +++ b/apps/ops-web/src/lib/errors.ts @@ -0,0 +1,16 @@ +import { message } from 'ant-design-vue' + +import { isHandledOpsApiError, OpsApiError } from '@/lib/http' + +export function showOpsError(error: unknown, fallback = '请求失败'): void { + if (isHandledOpsApiError(error)) { + return + } + + if (error instanceof OpsApiError) { + message.error(error.detail || error.message) + return + } + + message.error(fallback) +} diff --git a/apps/ops-web/src/lib/http.ts b/apps/ops-web/src/lib/http.ts index b639bfe..a73b7cb 100644 --- a/apps/ops-web/src/lib/http.ts +++ b/apps/ops-web/src/lib/http.ts @@ -7,6 +7,7 @@ export class OpsApiError extends Error { code: number detail?: string requestId?: string + handled?: boolean constructor(input: { message: string @@ -14,6 +15,7 @@ export class OpsApiError extends Error { status?: number detail?: string requestId?: string + handled?: boolean }) { super(input.message) this.name = 'OpsApiError' @@ -21,6 +23,7 @@ export class OpsApiError extends Error { this.status = input.status this.detail = input.detail this.requestId = input.requestId + this.handled = input.handled } } @@ -33,11 +36,20 @@ interface ApiEnvelope { } let onUnauthorized: (() => void) | null = null +let unauthorizedHandled = false export function bindUnauthorizedHandler(handler: () => void): void { onUnauthorized = handler } +export function resetUnauthorizedHandling(): void { + unauthorizedHandled = false +} + +export function isHandledOpsApiError(error: unknown): boolean { + return error instanceof OpsApiError && error.handled === true +} + const client: AxiosInstance = axios.create({ baseURL: '/api/ops', timeout: 30000, @@ -63,10 +75,14 @@ client.interceptors.response.use( (error: AxiosError>) => { const status = error.response?.status const payload = error.response?.data + const handled = status === 401 && !isLoginRequest(error.config?.url, error.config?.method) - if (status === 401 && !isLoginRequest(error.config?.url, error.config?.method)) { + if (handled) { clearStoredSession() - onUnauthorized?.() + if (!unauthorizedHandled) { + unauthorizedHandled = true + onUnauthorized?.() + } } return Promise.reject( @@ -76,6 +92,7 @@ client.interceptors.response.use( status, detail: typeof payload?.detail === 'string' ? payload.detail : undefined, requestId: typeof payload?.request_id === 'string' ? payload.request_id : undefined, + handled, }), ) }, diff --git a/apps/ops-web/src/main.ts b/apps/ops-web/src/main.ts index 4d41d3d..1d42906 100644 --- a/apps/ops-web/src/main.ts +++ b/apps/ops-web/src/main.ts @@ -38,6 +38,7 @@ import { createApp } from 'vue' import { bindUnauthorizedHandler } from '@/lib/http' import { router } from '@/router' +import { useAuthStore } from '@/stores/auth' import { pinia } from '@/stores/pinia' import 'ant-design-vue/dist/reset.css' import App from './App.vue' @@ -55,8 +56,15 @@ const queryClient = new QueryClient({ const app = createApp(App) +message.config({ + maxCount: 1, +}) + bindUnauthorizedHandler(() => { + const auth = useAuthStore(pinia) + auth.logout() void router.replace({ name: 'login' }) + message.destroy() message.warning('登录已过期,请重新登录') }) ;[ diff --git a/apps/ops-web/src/stores/auth.ts b/apps/ops-web/src/stores/auth.ts index 1a7fedc..899989d 100644 --- a/apps/ops-web/src/stores/auth.ts +++ b/apps/ops-web/src/stores/auth.ts @@ -1,7 +1,7 @@ import { defineStore } from 'pinia' import { computed, ref } from 'vue' -import { http } from '@/lib/http' +import { http, resetUnauthorizedHandling } from '@/lib/http' import { type OperatorView, clearStoredSession, @@ -47,6 +47,7 @@ export const useAuthStore = defineStore('ops-auth', () => { password: payload.password, }) .then((result) => { + resetUnauthorizedHandling() accessToken.value = result.access_token expiresAt.value = result.expires_at operator.value = result.operator diff --git a/apps/ops-web/src/views/AccountsView.vue b/apps/ops-web/src/views/AccountsView.vue index 2827f3a..5aed57c 100644 --- a/apps/ops-web/src/views/AccountsView.vue +++ b/apps/ops-web/src/views/AccountsView.vue @@ -130,7 +130,8 @@ import { message } from 'ant-design-vue' import dayjs from 'dayjs' import { computed, onMounted, reactive, ref } from 'vue' -import { OpsApiError, http } from '@/lib/http' +import { showOpsError } from '@/lib/errors' +import { http } from '@/lib/http' import { useAuthStore } from '@/stores/auth' interface AccountRow { @@ -211,7 +212,7 @@ async function reload() { rows.value = result.items total.value = result.total } catch (error) { - if (error instanceof OpsApiError) message.error(error.detail || error.message) + showOpsError(error) } finally { loading.value = false } @@ -266,7 +267,7 @@ async function submitCreate() { resetCreate() void reload() } catch (error) { - if (error instanceof OpsApiError) message.error(error.detail || error.message) + showOpsError(error) } finally { createLoading.value = false } @@ -297,7 +298,7 @@ async function submitResetPassword() { message.success('重置成功') resetOpen.value = false } catch (error) { - if (error instanceof OpsApiError) message.error(error.detail || error.message) + showOpsError(error) } finally { resetLoading.value = false } @@ -310,7 +311,7 @@ async function toggleStatus(row: AccountRow) { message.success(next === 'active' ? '已启用' : '已停用') void reload() } catch (error) { - if (error instanceof OpsApiError) message.error(error.detail || error.message) + showOpsError(error) } } diff --git a/apps/ops-web/src/views/AdminUsersView.vue b/apps/ops-web/src/views/AdminUsersView.vue index 594082c..cbe723f 100644 --- a/apps/ops-web/src/views/AdminUsersView.vue +++ b/apps/ops-web/src/views/AdminUsersView.vue @@ -302,7 +302,8 @@ import { message } from 'ant-design-vue' import dayjs from 'dayjs' import { computed, onMounted, reactive, ref } from 'vue' -import { OpsApiError, http } from '@/lib/http' +import { showOpsError } from '@/lib/errors' +import { http } from '@/lib/http' interface AdminUserRow { id: number @@ -447,7 +448,7 @@ async function reload() { rows.value = result.items total.value = result.total } catch (error) { - if (error instanceof OpsApiError) message.error(error.detail || error.message) + showOpsError(error) } finally { loading.value = false } @@ -462,7 +463,7 @@ async function loadMeta() { plans.value = planResult roles.value = roleResult } catch (error) { - if (error instanceof OpsApiError) message.error(error.detail || error.message) + showOpsError(error) } } @@ -526,7 +527,7 @@ async function submitCreate() { resetCreate() void reload() } catch (error) { - if (error instanceof OpsApiError) message.error(error.detail || error.message) + showOpsError(error) } finally { createLoading.value = false } @@ -608,7 +609,7 @@ async function submitEdit() { editOpen.value = false void reload() } catch (error) { - if (error instanceof OpsApiError) message.error(error.detail || error.message) + showOpsError(error) } finally { editLoading.value = false } @@ -624,7 +625,7 @@ async function resetPlanUsage(row: AdminUserRow) { rows.value = rows.value.map((item) => (item.id === row.id ? result.user : item)) void reload() } catch (error) { - if (error instanceof OpsApiError) message.error(error.detail || error.message) + showOpsError(error) } finally { resetUsageLoadingId.value = null } @@ -644,7 +645,7 @@ async function resetLoginLock(row: AdminUserRow) { message.warning('未发现登录锁 Redis key,请检查 ops-api 与 admin-web 使用的 Redis 地址和 DB 是否一致') } } catch (error) { - if (error instanceof OpsApiError) message.error(error.detail || error.message) + showOpsError(error) } finally { resetLoginLockLoadingId.value = null } @@ -675,7 +676,7 @@ async function submitResetPassword() { message.success('密码已重置') resetOpen.value = false } catch (error) { - if (error instanceof OpsApiError) message.error(error.detail || error.message) + showOpsError(error) } finally { resetLoading.value = false } @@ -711,7 +712,7 @@ async function submitKol() { kolOpen.value = false void reload() } catch (error) { - if (error instanceof OpsApiError) message.error(error.detail || error.message) + showOpsError(error) } finally { kolLoading.value = false } @@ -724,7 +725,7 @@ async function toggleStatus(row: AdminUserRow) { message.success(next === 'active' ? '已启用' : '已停用') void reload() } catch (error) { - if (error instanceof OpsApiError) message.error(error.detail || error.message) + showOpsError(error) } } diff --git a/apps/ops-web/src/views/AuditLogsView.vue b/apps/ops-web/src/views/AuditLogsView.vue index 3a26ea0..b1dc6f1 100644 --- a/apps/ops-web/src/views/AuditLogsView.vue +++ b/apps/ops-web/src/views/AuditLogsView.vue @@ -78,7 +78,8 @@ import { message } from 'ant-design-vue' import dayjs, { type Dayjs } from 'dayjs' import { computed, onMounted, reactive, ref, watch } from 'vue' -import { OpsApiError, http } from '@/lib/http' +import { showOpsError } from '@/lib/errors' +import { http } from '@/lib/http' interface AuditRow { id: number @@ -161,7 +162,7 @@ async function reload() { rows.value = result.items total.value = result.total } catch (error) { - if (error instanceof OpsApiError) message.error(error.detail || error.message) + showOpsError(error) } finally { loading.value = false } diff --git a/apps/ops-web/src/views/JobsView.vue b/apps/ops-web/src/views/JobsView.vue index 6d004a4..a202074 100644 --- a/apps/ops-web/src/views/JobsView.vue +++ b/apps/ops-web/src/views/JobsView.vue @@ -168,7 +168,8 @@ import { message } from 'ant-design-vue' import dayjs, { type Dayjs } from 'dayjs' import { computed, onMounted, reactive, ref } from 'vue' -import { OpsApiError, http } from '@/lib/http' +import { showOpsError } from '@/lib/errors' +import { http } from '@/lib/http' interface JobSummary { uid: string @@ -429,11 +430,7 @@ function formatJSON(value: unknown): string { } function showError(error: unknown) { - if (error instanceof OpsApiError) { - message.error(error.detail || error.message) - } else { - message.error('请求失败') - } + showOpsError(error) } onMounted(() => { diff --git a/apps/ops-web/src/views/KolSubscriptionsView.vue b/apps/ops-web/src/views/KolSubscriptionsView.vue index 51ee05c..8a098d5 100644 --- a/apps/ops-web/src/views/KolSubscriptionsView.vue +++ b/apps/ops-web/src/views/KolSubscriptionsView.vue @@ -232,7 +232,8 @@ import { message } from 'ant-design-vue' import dayjs from 'dayjs' import { computed, onMounted, reactive, ref } from 'vue' -import { OpsApiError, http } from '@/lib/http' +import { showOpsError } from '@/lib/errors' +import { http } from '@/lib/http' interface KolSubscriptionRow { id: number @@ -349,7 +350,7 @@ async function reload() { rows.value = result.items total.value = result.total } catch (error) { - if (error instanceof OpsApiError) message.error(error.detail || error.message) + showOpsError(error) } finally { loading.value = false } @@ -363,7 +364,7 @@ async function loadPackages(keyword = '') { size: 50, }) } catch (error) { - if (error instanceof OpsApiError) message.error(error.detail || error.message) + showOpsError(error) } finally { packageLoading.value = false } @@ -437,7 +438,7 @@ async function submitManualBind() { page.value = 1 void reload() } catch (error) { - if (error instanceof OpsApiError) message.error(error.detail || error.message) + showOpsError(error) } finally { manualBindLoading.value = false } @@ -472,7 +473,7 @@ async function submitApprove() { approveOpen.value = false void reload() } catch (error) { - if (error instanceof OpsApiError) message.error(error.detail || error.message) + showOpsError(error) } finally { approveLoading.value = false } @@ -487,7 +488,7 @@ async function revoke(row: KolSubscriptionRow) { message.success('订阅已撤销') void reload() } catch (error) { - if (error instanceof OpsApiError) message.error(error.detail || error.message) + showOpsError(error) } finally { revokeLoadingId.value = null } diff --git a/apps/ops-web/src/views/ProfileView.vue b/apps/ops-web/src/views/ProfileView.vue index ce51f51..48141a8 100644 --- a/apps/ops-web/src/views/ProfileView.vue +++ b/apps/ops-web/src/views/ProfileView.vue @@ -38,7 +38,8 @@ import { message } from 'ant-design-vue' import { reactive, ref } from 'vue' -import { OpsApiError, http } from '@/lib/http' +import { showOpsError } from '@/lib/errors' +import { http } from '@/lib/http' import { useAuthStore } from '@/stores/auth' const auth = useAuthStore() @@ -71,9 +72,7 @@ async function onSubmit() { // router redirect handled by 401 interceptor or next route guard if (typeof window !== 'undefined') window.location.href = '/login' } catch (error) { - if (error instanceof OpsApiError) { - message.error(error.detail || error.message) - } + showOpsError(error) } finally { loading.value = false } diff --git a/apps/ops-web/src/views/SiteDomainMappingsView.vue b/apps/ops-web/src/views/SiteDomainMappingsView.vue index 9589018..de947d8 100644 --- a/apps/ops-web/src/views/SiteDomainMappingsView.vue +++ b/apps/ops-web/src/views/SiteDomainMappingsView.vue @@ -143,7 +143,8 @@ import { message } from 'ant-design-vue' import dayjs from 'dayjs' import { computed, onMounted, reactive, ref } from 'vue' -import { OpsApiError, http } from '@/lib/http' +import { showOpsError } from '@/lib/errors' +import { http } from '@/lib/http' interface SiteDomainMappingRow { id: number @@ -342,9 +343,7 @@ function formatDate(value: string | null | undefined): string { } function handleError(error: unknown) { - if (error instanceof OpsApiError) { - message.error(error.detail || error.message) - } + showOpsError(error) } onMounted(() => { diff --git a/apps/ops-web/src/views/compliance/ComplianceDictionariesView.vue b/apps/ops-web/src/views/compliance/ComplianceDictionariesView.vue index 07aa43e..23a10fd 100644 --- a/apps/ops-web/src/views/compliance/ComplianceDictionariesView.vue +++ b/apps/ops-web/src/views/compliance/ComplianceDictionariesView.vue @@ -136,7 +136,7 @@ import { platformLabel, } from '@/lib/compliance-display' import { complianceApi, type OpsComplianceDictionary } from '@/lib/compliance' -import { OpsApiError } from '@/lib/http' +import { showOpsError } from '@/lib/errors' const router = useRouter() @@ -220,9 +220,7 @@ function formatDate(value: string): string { } function showError(error: unknown) { - if (error instanceof OpsApiError) { - message.error(error.detail || error.message) - } + showOpsError(error) } onMounted(() => { diff --git a/apps/ops-web/src/views/compliance/ComplianceDictionaryEditView.vue b/apps/ops-web/src/views/compliance/ComplianceDictionaryEditView.vue index 65dadb0..7ec956b 100644 --- a/apps/ops-web/src/views/compliance/ComplianceDictionaryEditView.vue +++ b/apps/ops-web/src/views/compliance/ComplianceDictionaryEditView.vue @@ -233,7 +233,7 @@ import { type OpsComplianceDictionary, type OpsComplianceTerm, } from '@/lib/compliance' -import { OpsApiError } from '@/lib/http' +import { showOpsError } from '@/lib/errors' type MatchType = 'exact' | 'regex' @@ -514,9 +514,7 @@ function resetTermForm() { } function showError(error: unknown) { - if (error instanceof OpsApiError) { - message.error(error.detail || error.message) - } + showOpsError(error) } onMounted(() => { diff --git a/apps/ops-web/src/views/compliance/ComplianceManualReviewDetailView.vue b/apps/ops-web/src/views/compliance/ComplianceManualReviewDetailView.vue index db250ce..f270b4a 100644 --- a/apps/ops-web/src/views/compliance/ComplianceManualReviewDetailView.vue +++ b/apps/ops-web/src/views/compliance/ComplianceManualReviewDetailView.vue @@ -134,7 +134,7 @@ import { sourceLabel, } from '@/lib/compliance-display' import { complianceApi } from '@/lib/compliance' -import { OpsApiError } from '@/lib/http' +import { showOpsError } from '@/lib/errors' const route = useRoute() const router = useRouter() @@ -195,9 +195,7 @@ function formatDate(value: string): string { } function showError(error: unknown) { - if (error instanceof OpsApiError) { - message.error(error.detail || error.message) - } + showOpsError(error) } onMounted(() => { diff --git a/apps/ops-web/src/views/compliance/ComplianceManualReviewsView.vue b/apps/ops-web/src/views/compliance/ComplianceManualReviewsView.vue index ac3f197..e0db262 100644 --- a/apps/ops-web/src/views/compliance/ComplianceManualReviewsView.vue +++ b/apps/ops-web/src/views/compliance/ComplianceManualReviewsView.vue @@ -89,7 +89,6 @@ import type { ComplianceManualReview } from '@geo/shared-types' import { ReloadOutlined } from '@ant-design/icons-vue' import type { TableColumnsType, TablePaginationConfig } from 'ant-design-vue' -import { message } from 'ant-design-vue' import dayjs from 'dayjs' import { computed, onMounted, reactive, ref } from 'vue' import { useRouter } from 'vue-router' @@ -101,7 +100,7 @@ import { reviewStatusLabel, } from '@/lib/compliance-display' import { complianceApi } from '@/lib/compliance' -import { OpsApiError } from '@/lib/http' +import { showOpsError } from '@/lib/errors' const router = useRouter() @@ -166,9 +165,7 @@ function formatDate(value: string): string { } function showError(error: unknown) { - if (error instanceof OpsApiError) { - message.error(error.detail || error.message) - } + showOpsError(error) } onMounted(() => { diff --git a/apps/ops-web/src/views/compliance/CompliancePolicyView.vue b/apps/ops-web/src/views/compliance/CompliancePolicyView.vue index 8afce1a..4e6ffce 100644 --- a/apps/ops-web/src/views/compliance/CompliancePolicyView.vue +++ b/apps/ops-web/src/views/compliance/CompliancePolicyView.vue @@ -123,7 +123,7 @@ import { type CompliancePolicy, type OpsComplianceDictionary, } from '@/lib/compliance' -import { OpsApiError } from '@/lib/http' +import { showOpsError } from '@/lib/errors' const policy = ref(null) const dictionaries = ref([]) @@ -220,9 +220,7 @@ function formatDate(value: string): string { } function showError(error: unknown) { - if (error instanceof OpsApiError) { - message.error(error.detail || error.message) - } + showOpsError(error) } onMounted(() => { diff --git a/apps/ops-web/src/views/compliance/ComplianceRecordsView.vue b/apps/ops-web/src/views/compliance/ComplianceRecordsView.vue index d48d31e..7fa4dac 100644 --- a/apps/ops-web/src/views/compliance/ComplianceRecordsView.vue +++ b/apps/ops-web/src/views/compliance/ComplianceRecordsView.vue @@ -99,7 +99,6 @@ import type { ComplianceCheckResult } from '@geo/shared-types' import { ReloadOutlined } from '@ant-design/icons-vue' import type { TableColumnsType, TablePaginationConfig } from 'ant-design-vue' -import { message } from 'ant-design-vue' import dayjs from 'dayjs' import { computed, onMounted, reactive, ref } from 'vue' @@ -114,7 +113,7 @@ import { reviewStatusLabel, } from '@/lib/compliance-display' import { complianceApi } from '@/lib/compliance' -import { OpsApiError } from '@/lib/http' +import { showOpsError } from '@/lib/errors' const rows = ref([]) const loading = ref(false) @@ -187,9 +186,7 @@ function formatDate(value: string): string { } function showError(error: unknown) { - if (error instanceof OpsApiError) { - message.error(error.detail || error.message) - } + showOpsError(error) } onMounted(() => { diff --git a/apps/ops-web/src/views/compliance/ComplianceStatsView.vue b/apps/ops-web/src/views/compliance/ComplianceStatsView.vue index 3b66c5b..5fa8cae 100644 --- a/apps/ops-web/src/views/compliance/ComplianceStatsView.vue +++ b/apps/ops-web/src/views/compliance/ComplianceStatsView.vue @@ -74,12 +74,11 @@