diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 7b847c1b..b831c9ff 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -39,16 +39,6 @@ watch( { immediate: true } ) -watch( - () => appStore.siteName, - (newName) => { - if (newName) { - document.title = `${newName} - AI API Gateway` - } - }, - { immediate: true } -) - // Watch for authentication state and manage subscription data watch( () => authStore.isAuthenticated, diff --git a/frontend/src/__tests__/integration/data-import.spec.ts b/frontend/src/__tests__/integration/data-import.spec.ts index 1fe870ab..bc9de148 100644 --- a/frontend/src/__tests__/integration/data-import.spec.ts +++ b/frontend/src/__tests__/integration/data-import.spec.ts @@ -58,12 +58,16 @@ describe('ImportDataModal', () => { const input = wrapper.find('input[type="file"]') const file = new File(['invalid json'], 'data.json', { type: 'application/json' }) + Object.defineProperty(file, 'text', { + value: () => Promise.resolve('invalid json') + }) Object.defineProperty(input.element, 'files', { value: [file] }) await input.trigger('change') await wrapper.find('form').trigger('submit') + await Promise.resolve() expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportParseFailed') }) diff --git a/frontend/src/__tests__/integration/proxy-data-import.spec.ts b/frontend/src/__tests__/integration/proxy-data-import.spec.ts index f0433898..21bf3a63 100644 --- a/frontend/src/__tests__/integration/proxy-data-import.spec.ts +++ b/frontend/src/__tests__/integration/proxy-data-import.spec.ts @@ -58,12 +58,16 @@ describe('Proxy ImportDataModal', () => { const input = wrapper.find('input[type="file"]') const file = new File(['invalid json'], 'data.json', { type: 'application/json' }) + Object.defineProperty(file, 'text', { + value: () => Promise.resolve('invalid json') + }) Object.defineProperty(input.element, 'files', { value: [file] }) await input.trigger('change') await wrapper.find('form').trigger('submit') + await Promise.resolve() expect(showError).toHaveBeenCalledWith('admin.proxies.dataImportParseFailed') }) diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index 838df569..18d2e968 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -209,7 +209,7 @@
('whitelist') const allowedModels = ref([]) const modelMappings = ref([]) +const getModelMappingKey = createStableObjectKeyResolver('bulk-model-mapping') const selectedErrorCodes = ref([]) const customErrorCodeInput = ref(null) const interceptWarmupRequests = ref(false) diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 0047592f..66a1d98e 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -714,7 +714,7 @@
@@ -966,7 +966,7 @@
@@ -2097,6 +2097,7 @@ import ProxySelector from '@/components/common/ProxySelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format' +import { createStableObjectKeyResolver } from '@/utils/stableObjectKey' import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue' // Type for exposed OAuthAuthorizationFlow component @@ -2227,6 +2228,9 @@ const antigravityModelMappings = ref([]) const antigravityPresetMappings = computed(() => getPresetMappingsByPlatform('antigravity')) const tempUnschedEnabled = ref(false) const tempUnschedRules = ref([]) +const getModelMappingKey = createStableObjectKeyResolver('create-model-mapping') +const getAntigravityModelMappingKey = createStableObjectKeyResolver('create-antigravity-model-mapping') +const getTempUnschedRuleKey = createStableObjectKeyResolver('create-temp-unsched-rule') const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('google_one') const geminiAIStudioOAuthEnabled = ref(false) diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 8986a350..32b8d5a9 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -169,7 +169,7 @@
@@ -542,7 +542,7 @@
@@ -1093,6 +1093,7 @@ import ProxySelector from '@/components/common/ProxySelector.vue' import GroupSelector from '@/components/common/GroupSelector.vue' import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue' import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format' +import { createStableObjectKeyResolver } from '@/utils/stableObjectKey' import { getPresetMappingsByPlatform, commonErrorCodes, @@ -1158,6 +1159,9 @@ const antigravityWhitelistModels = ref([]) const antigravityModelMappings = ref([]) const tempUnschedEnabled = ref(false) const tempUnschedRules = ref([]) +const getModelMappingKey = createStableObjectKeyResolver('edit-model-mapping') +const getAntigravityModelMappingKey = createStableObjectKeyResolver('edit-antigravity-model-mapping') +const getTempUnschedRuleKey = createStableObjectKeyResolver('edit-temp-unsched-rule') // Mixed channel warning dialog state const showMixedChannelWarning = ref(false) diff --git a/frontend/src/components/admin/account/ImportDataModal.vue b/frontend/src/components/admin/account/ImportDataModal.vue index 0d6de420..6c120be3 100644 --- a/frontend/src/components/admin/account/ImportDataModal.vue +++ b/frontend/src/components/admin/account/ImportDataModal.vue @@ -143,6 +143,24 @@ const handleClose = () => { emit('close') } +const readFileAsText = async (sourceFile: File): Promise => { + if (typeof sourceFile.text === 'function') { + return sourceFile.text() + } + + if (typeof sourceFile.arrayBuffer === 'function') { + const buffer = await sourceFile.arrayBuffer() + return new TextDecoder().decode(buffer) + } + + return await new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(String(reader.result ?? '')) + reader.onerror = () => reject(reader.error || new Error('Failed to read file')) + reader.readAsText(sourceFile) + }) +} + const handleImport = async () => { if (!file.value) { appStore.showError(t('admin.accounts.dataImportSelectFile')) @@ -151,7 +169,7 @@ const handleImport = async () => { importing.value = true try { - const text = await file.value.text() + const text = await readFileAsText(file.value) const dataPayload = JSON.parse(text) const res = await adminAPI.accounts.importData({ diff --git a/frontend/src/components/admin/proxy/ImportDataModal.vue b/frontend/src/components/admin/proxy/ImportDataModal.vue index 6999ecc1..1ff71551 100644 --- a/frontend/src/components/admin/proxy/ImportDataModal.vue +++ b/frontend/src/components/admin/proxy/ImportDataModal.vue @@ -143,6 +143,24 @@ const handleClose = () => { emit('close') } +const readFileAsText = async (sourceFile: File): Promise => { + if (typeof sourceFile.text === 'function') { + return sourceFile.text() + } + + if (typeof sourceFile.arrayBuffer === 'function') { + const buffer = await sourceFile.arrayBuffer() + return new TextDecoder().decode(buffer) + } + + return await new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(String(reader.result ?? '')) + reader.onerror = () => reject(reader.error || new Error('Failed to read file')) + reader.readAsText(sourceFile) + }) +} + const handleImport = async () => { if (!file.value) { appStore.showError(t('admin.proxies.dataImportSelectFile')) @@ -151,7 +169,7 @@ const handleImport = async () => { importing.value = true try { - const text = await file.value.text() + const text = await readFileAsText(file.value) const dataPayload = JSON.parse(text) const res = await adminAPI.proxies.importData({ data: dataPayload }) diff --git a/frontend/src/components/common/DataTable.vue b/frontend/src/components/common/DataTable.vue index c1e4333d..43755301 100644 --- a/frontend/src/components/common/DataTable.vue +++ b/frontend/src/components/common/DataTable.vue @@ -3,7 +3,7 @@ + + diff --git a/frontend/src/components/user/UserAttributesConfigModal.vue b/frontend/src/components/user/UserAttributesConfigModal.vue index 11474a22..9aa41a47 100644 --- a/frontend/src/components/user/UserAttributesConfigModal.vue +++ b/frontend/src/components/user/UserAttributesConfigModal.vue @@ -143,7 +143,7 @@
-
+
(null) const deletingAttribute = ref(null) +const getOptionKey = createStableObjectKeyResolver('user-attr-option') const form = reactive({ key: '', @@ -315,7 +317,7 @@ const openEditModal = (attr: UserAttributeDefinition) => { form.placeholder = attr.placeholder || '' form.required = attr.required form.enabled = attr.enabled - form.options = attr.options ? [...attr.options] : [] + form.options = attr.options ? attr.options.map((opt) => ({ ...opt })) : [] showEditModal.value = true } diff --git a/frontend/src/components/user/profile/TotpDisableDialog.vue b/frontend/src/components/user/profile/TotpDisableDialog.vue index daca4067..cd93764c 100644 --- a/frontend/src/components/user/profile/TotpDisableDialog.vue +++ b/frontend/src/components/user/profile/TotpDisableDialog.vue @@ -88,7 +88,7 @@ diff --git a/frontend/src/components/user/profile/TotpSetupModal.vue b/frontend/src/components/user/profile/TotpSetupModal.vue index 3d9b79ec..b544e75b 100644 --- a/frontend/src/components/user/profile/TotpSetupModal.vue +++ b/frontend/src/components/user/profile/TotpSetupModal.vue @@ -175,7 +175,7 @@ diff --git a/frontend/src/components/user/profile/__tests__/totp-timer-cleanup.spec.ts b/frontend/src/components/user/profile/__tests__/totp-timer-cleanup.spec.ts new file mode 100644 index 00000000..0259f902 --- /dev/null +++ b/frontend/src/components/user/profile/__tests__/totp-timer-cleanup.spec.ts @@ -0,0 +1,108 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import TotpSetupModal from '@/components/user/profile/TotpSetupModal.vue' +import TotpDisableDialog from '@/components/user/profile/TotpDisableDialog.vue' + +const mocks = vi.hoisted(() => ({ + showSuccess: vi.fn(), + showError: vi.fn(), + getVerificationMethod: vi.fn(), + sendVerifyCode: vi.fn() +})) + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string) => key + }) +})) + +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ + showSuccess: mocks.showSuccess, + showError: mocks.showError + }) +})) + +vi.mock('@/api', () => ({ + totpAPI: { + getVerificationMethod: mocks.getVerificationMethod, + sendVerifyCode: mocks.sendVerifyCode, + initiateSetup: vi.fn(), + enable: vi.fn(), + disable: vi.fn() + } +})) + +const flushPromises = async () => { + await Promise.resolve() + await Promise.resolve() +} + +describe('TOTP 弹窗定时器清理', () => { + let intervalSeed = 1000 + let setIntervalSpy: ReturnType + let clearIntervalSpy: ReturnType + + beforeEach(() => { + intervalSeed = 1000 + mocks.showSuccess.mockReset() + mocks.showError.mockReset() + mocks.getVerificationMethod.mockReset() + mocks.sendVerifyCode.mockReset() + + mocks.getVerificationMethod.mockResolvedValue({ method: 'email' }) + mocks.sendVerifyCode.mockResolvedValue({ success: true }) + + setIntervalSpy = vi.spyOn(window, 'setInterval').mockImplementation(((handler: TimerHandler) => { + void handler + intervalSeed += 1 + return intervalSeed as unknown as number + }) as typeof window.setInterval) + clearIntervalSpy = vi.spyOn(window, 'clearInterval') + }) + + afterEach(() => { + setIntervalSpy.mockRestore() + clearIntervalSpy.mockRestore() + }) + + it('TotpSetupModal 卸载时清理倒计时定时器', async () => { + const wrapper = mount(TotpSetupModal) + await flushPromises() + + const sendButton = wrapper + .findAll('button') + .find((button) => button.text().includes('profile.totp.sendCode')) + + expect(sendButton).toBeTruthy() + await sendButton!.trigger('click') + await flushPromises() + + expect(setIntervalSpy).toHaveBeenCalledTimes(1) + const timerId = setIntervalSpy.mock.results[0]?.value + + wrapper.unmount() + + expect(clearIntervalSpy).toHaveBeenCalledWith(timerId) + }) + + it('TotpDisableDialog 卸载时清理倒计时定时器', async () => { + const wrapper = mount(TotpDisableDialog) + await flushPromises() + + const sendButton = wrapper + .findAll('button') + .find((button) => button.text().includes('profile.totp.sendCode')) + + expect(sendButton).toBeTruthy() + await sendButton!.trigger('click') + await flushPromises() + + expect(setIntervalSpy).toHaveBeenCalledTimes(1) + const timerId = setIntervalSpy.mock.results[0]?.value + + wrapper.unmount() + + expect(clearIntervalSpy).toHaveBeenCalledWith(timerId) + }) +}) diff --git a/frontend/src/composables/__tests__/useKeyedDebouncedSearch.spec.ts b/frontend/src/composables/__tests__/useKeyedDebouncedSearch.spec.ts new file mode 100644 index 00000000..4866746a --- /dev/null +++ b/frontend/src/composables/__tests__/useKeyedDebouncedSearch.spec.ts @@ -0,0 +1,100 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import { useKeyedDebouncedSearch } from '@/composables/useKeyedDebouncedSearch' + +const flushPromises = () => Promise.resolve() + +describe('useKeyedDebouncedSearch', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('为不同 key 独立防抖触发搜索', async () => { + const search = vi.fn().mockResolvedValue([]) + const onSuccess = vi.fn() + + const searcher = useKeyedDebouncedSearch({ + delay: 100, + search, + onSuccess + }) + + searcher.trigger('a', 'foo') + searcher.trigger('b', 'bar') + + expect(search).not.toHaveBeenCalled() + + vi.advanceTimersByTime(100) + await flushPromises() + + expect(search).toHaveBeenCalledTimes(2) + expect(search).toHaveBeenNthCalledWith( + 1, + 'foo', + expect.objectContaining({ key: 'a', signal: expect.any(AbortSignal) }) + ) + expect(search).toHaveBeenNthCalledWith( + 2, + 'bar', + expect.objectContaining({ key: 'b', signal: expect.any(AbortSignal) }) + ) + expect(onSuccess).toHaveBeenCalledTimes(2) + }) + + it('同 key 新请求会取消旧请求并忽略过期响应', async () => { + const resolves: Array<(value: string[]) => void> = [] + const search = vi.fn().mockImplementation( + () => new Promise((resolve) => { + resolves.push(resolve) + }) + ) + const onSuccess = vi.fn() + + const searcher = useKeyedDebouncedSearch({ + delay: 50, + search, + onSuccess + }) + + searcher.trigger('rule-1', 'first') + vi.advanceTimersByTime(50) + await flushPromises() + + searcher.trigger('rule-1', 'second') + vi.advanceTimersByTime(50) + await flushPromises() + + expect(search).toHaveBeenCalledTimes(2) + + resolves[1](['second']) + await flushPromises() + expect(onSuccess).toHaveBeenCalledTimes(1) + expect(onSuccess).toHaveBeenLastCalledWith('rule-1', ['second']) + + resolves[0](['first']) + await flushPromises() + expect(onSuccess).toHaveBeenCalledTimes(1) + }) + + it('clearKey 会取消未执行任务', () => { + const search = vi.fn().mockResolvedValue([]) + const onSuccess = vi.fn() + + const searcher = useKeyedDebouncedSearch({ + delay: 100, + search, + onSuccess + }) + + searcher.trigger('a', 'foo') + searcher.clearKey('a') + + vi.advanceTimersByTime(100) + + expect(search).not.toHaveBeenCalled() + expect(onSuccess).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/composables/useKeyedDebouncedSearch.ts b/frontend/src/composables/useKeyedDebouncedSearch.ts new file mode 100644 index 00000000..81133c38 --- /dev/null +++ b/frontend/src/composables/useKeyedDebouncedSearch.ts @@ -0,0 +1,103 @@ +import { getCurrentInstance, onUnmounted } from 'vue' + +export interface KeyedDebouncedSearchContext { + key: string + signal: AbortSignal +} + +interface UseKeyedDebouncedSearchOptions { + delay?: number + search: (keyword: string, context: KeyedDebouncedSearchContext) => Promise + onSuccess: (key: string, result: T) => void + onError?: (key: string, error: unknown) => void +} + +/** + * 多实例隔离的防抖搜索:每个 key 有独立的防抖、请求取消与过期响应保护。 + */ +export function useKeyedDebouncedSearch(options: UseKeyedDebouncedSearchOptions) { + const delay = options.delay ?? 300 + const timers = new Map>() + const controllers = new Map() + const versions = new Map() + + const clearKey = (key: string) => { + const timer = timers.get(key) + if (timer) { + clearTimeout(timer) + timers.delete(key) + } + + const controller = controllers.get(key) + if (controller) { + controller.abort() + controllers.delete(key) + } + + versions.delete(key) + } + + const clearAll = () => { + const allKeys = new Set([ + ...timers.keys(), + ...controllers.keys(), + ...versions.keys() + ]) + + allKeys.forEach((key) => clearKey(key)) + } + + const trigger = (key: string, keyword: string) => { + const nextVersion = (versions.get(key) ?? 0) + 1 + versions.set(key, nextVersion) + + const existingTimer = timers.get(key) + if (existingTimer) { + clearTimeout(existingTimer) + timers.delete(key) + } + + const inFlight = controllers.get(key) + if (inFlight) { + inFlight.abort() + controllers.delete(key) + } + + const timer = setTimeout(async () => { + timers.delete(key) + + const controller = new AbortController() + controllers.set(key, controller) + const requestVersion = versions.get(key) + + try { + const result = await options.search(keyword, { key, signal: controller.signal }) + if (controller.signal.aborted) return + if (versions.get(key) !== requestVersion) return + options.onSuccess(key, result) + } catch (error) { + if (controller.signal.aborted) return + if (versions.get(key) !== requestVersion) return + options.onError?.(key, error) + } finally { + if (controllers.get(key) === controller) { + controllers.delete(key) + } + } + }, delay) + + timers.set(key, timer) + } + + if (getCurrentInstance()) { + onUnmounted(() => { + clearAll() + }) + } + + return { + trigger, + clearKey, + clearAll + } +} diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts index 486fb3bc..00e34dc2 100644 --- a/frontend/src/i18n/index.ts +++ b/frontend/src/i18n/index.ts @@ -1,53 +1,83 @@ import { createI18n } from 'vue-i18n' -import en from './locales/en' -import zh from './locales/zh' + +type LocaleCode = 'en' | 'zh' + +type LocaleMessages = Record const LOCALE_KEY = 'sub2api_locale' +const DEFAULT_LOCALE: LocaleCode = 'en' -function getDefaultLocale(): string { - // Check localStorage first +const localeLoaders: Record Promise<{ default: LocaleMessages }>> = { + en: () => import('./locales/en'), + zh: () => import('./locales/zh') +} + +function isLocaleCode(value: string): value is LocaleCode { + return value === 'en' || value === 'zh' +} + +function getDefaultLocale(): LocaleCode { const saved = localStorage.getItem(LOCALE_KEY) - if (saved && ['en', 'zh'].includes(saved)) { + if (saved && isLocaleCode(saved)) { return saved } - // Check browser language const browserLang = navigator.language.toLowerCase() if (browserLang.startsWith('zh')) { return 'zh' } - return 'en' + return DEFAULT_LOCALE } export const i18n = createI18n({ legacy: false, locale: getDefaultLocale(), - fallbackLocale: 'en', - messages: { - en, - zh - }, + fallbackLocale: DEFAULT_LOCALE, + messages: {}, // 禁用 HTML 消息警告 - 引导步骤使用富文本内容(driver.js 支持 HTML) // 这些内容是内部定义的,不存在 XSS 风险 warnHtmlMessage: false }) -export function setLocale(locale: string) { - if (['en', 'zh'].includes(locale)) { - i18n.global.locale.value = locale as 'en' | 'zh' - localStorage.setItem(LOCALE_KEY, locale) - document.documentElement.setAttribute('lang', locale) +const loadedLocales = new Set() + +export async function loadLocaleMessages(locale: LocaleCode): Promise { + if (loadedLocales.has(locale)) { + return } + + const loader = localeLoaders[locale] + const module = await loader() + i18n.global.setLocaleMessage(locale, module.default) + loadedLocales.add(locale) } -export function getLocale(): string { - return i18n.global.locale.value +export async function initI18n(): Promise { + const current = getLocale() + await loadLocaleMessages(current) + document.documentElement.setAttribute('lang', current) +} + +export async function setLocale(locale: string): Promise { + if (!isLocaleCode(locale)) { + return + } + + await loadLocaleMessages(locale) + i18n.global.locale.value = locale + localStorage.setItem(LOCALE_KEY, locale) + document.documentElement.setAttribute('lang', locale) +} + +export function getLocale(): LocaleCode { + const current = i18n.global.locale.value + return isLocaleCode(current) ? current : DEFAULT_LOCALE } export const availableLocales = [ { code: 'en', name: 'English', flag: '🇺🇸' }, { code: 'zh', name: '中文', flag: '🇨🇳' } -] +] as const export default i18n diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 11c0b1e8..23f9d297 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -2,28 +2,33 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' import router from './router' -import i18n from './i18n' +import i18n, { initI18n } from './i18n' +import { useAppStore } from '@/stores/app' import './style.css' -const app = createApp(App) -const pinia = createPinia() -app.use(pinia) +async function bootstrap() { + const app = createApp(App) + const pinia = createPinia() + app.use(pinia) -// Initialize settings from injected config BEFORE mounting (prevents flash) -// This must happen after pinia is installed but before router and i18n -import { useAppStore } from '@/stores/app' -const appStore = useAppStore() -appStore.initFromInjectedConfig() + // Initialize settings from injected config BEFORE mounting (prevents flash) + // This must happen after pinia is installed but before router and i18n + const appStore = useAppStore() + appStore.initFromInjectedConfig() -// Set document title immediately after config is loaded -if (appStore.siteName && appStore.siteName !== 'Sub2API') { - document.title = `${appStore.siteName} - AI API Gateway` + // Set document title immediately after config is loaded + if (appStore.siteName && appStore.siteName !== 'Sub2API') { + document.title = `${appStore.siteName} - AI API Gateway` + } + + await initI18n() + + app.use(router) + app.use(i18n) + + // 等待路由器完成初始导航后再挂载,避免竞态条件导致的空白渲染 + await router.isReady() + app.mount('#app') } -app.use(router) -app.use(i18n) - -// 等待路由器完成初始导航后再挂载,避免竞态条件导致的空白渲染 -router.isReady().then(() => { - app.mount('#app') -}) +bootstrap() diff --git a/frontend/src/router/__tests__/title.spec.ts b/frontend/src/router/__tests__/title.spec.ts new file mode 100644 index 00000000..3a892837 --- /dev/null +++ b/frontend/src/router/__tests__/title.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { resolveDocumentTitle } from '@/router/title' + +describe('resolveDocumentTitle', () => { + it('路由存在标题时,使用“路由标题 - 站点名”格式', () => { + expect(resolveDocumentTitle('Usage Records', 'My Site')).toBe('Usage Records - My Site') + }) + + it('路由无标题时,回退到站点名', () => { + expect(resolveDocumentTitle(undefined, 'My Site')).toBe('My Site') + }) + + it('站点名为空时,回退默认站点名', () => { + expect(resolveDocumentTitle('Dashboard', '')).toBe('Dashboard - Sub2API') + expect(resolveDocumentTitle(undefined, ' ')).toBe('Sub2API') + }) + + it('站点名变更时仅影响后续路由标题计算', () => { + const before = resolveDocumentTitle('Admin Dashboard', 'Alpha') + const after = resolveDocumentTitle('Admin Dashboard', 'Beta') + + expect(before).toBe('Admin Dashboard - Alpha') + expect(after).toBe('Admin Dashboard - Beta') + }) +}) diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 4bb46cee..1a67cac6 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -8,6 +8,7 @@ import { useAuthStore } from '@/stores/auth' import { useAppStore } from '@/stores/app' import { useNavigationLoadingState } from '@/composables/useNavigationLoading' import { useRoutePrefetch } from '@/composables/useRoutePrefetch' +import { resolveDocumentTitle } from './title' /** * Route definitions with lazy loading @@ -389,12 +390,7 @@ router.beforeEach((to, _from, next) => { // Set page title const appStore = useAppStore() - const siteName = appStore.siteName || 'Sub2API' - if (to.meta.title) { - document.title = `${to.meta.title} - ${siteName}` - } else { - document.title = siteName - } + document.title = resolveDocumentTitle(to.meta.title, appStore.siteName) // Check if route requires authentication const requiresAuth = to.meta.requiresAuth !== false // Default to true diff --git a/frontend/src/router/title.ts b/frontend/src/router/title.ts new file mode 100644 index 00000000..e0db24b0 --- /dev/null +++ b/frontend/src/router/title.ts @@ -0,0 +1,12 @@ +/** + * 统一生成页面标题,避免多处写入 document.title 产生覆盖冲突。 + */ +export function resolveDocumentTitle(routeTitle: unknown, siteName?: string): string { + const normalizedSiteName = typeof siteName === 'string' && siteName.trim() ? siteName.trim() : 'Sub2API' + + if (typeof routeTitle === 'string' && routeTitle.trim()) { + return `${routeTitle.trim()} - ${normalizedSiteName}` + } + + return normalizedSiteName +} diff --git a/frontend/src/utils/__tests__/stableObjectKey.spec.ts b/frontend/src/utils/__tests__/stableObjectKey.spec.ts new file mode 100644 index 00000000..5a6f99f4 --- /dev/null +++ b/frontend/src/utils/__tests__/stableObjectKey.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { createStableObjectKeyResolver } from '@/utils/stableObjectKey' + +describe('createStableObjectKeyResolver', () => { + it('对同一对象返回稳定 key', () => { + const resolve = createStableObjectKeyResolver<{ value: string }>('rule') + const obj = { value: 'a' } + + const key1 = resolve(obj) + const key2 = resolve(obj) + + expect(key1).toBe(key2) + expect(key1.startsWith('rule-')).toBe(true) + }) + + it('不同对象返回不同 key', () => { + const resolve = createStableObjectKeyResolver<{ value: string }>('rule') + + const key1 = resolve({ value: 'a' }) + const key2 = resolve({ value: 'a' }) + + expect(key1).not.toBe(key2) + }) + + it('不同 resolver 互不影响', () => { + const resolveA = createStableObjectKeyResolver<{ id: number }>('a') + const resolveB = createStableObjectKeyResolver<{ id: number }>('b') + const obj = { id: 1 } + + const keyA = resolveA(obj) + const keyB = resolveB(obj) + + expect(keyA).not.toBe(keyB) + expect(keyA.startsWith('a-')).toBe(true) + expect(keyB.startsWith('b-')).toBe(true) + }) +}) diff --git a/frontend/src/utils/stableObjectKey.ts b/frontend/src/utils/stableObjectKey.ts new file mode 100644 index 00000000..a61414f0 --- /dev/null +++ b/frontend/src/utils/stableObjectKey.ts @@ -0,0 +1,19 @@ +let globalStableObjectKeySeed = 0 + +/** + * 为对象实例生成稳定 key(基于 WeakMap,不污染业务对象) + */ +export function createStableObjectKeyResolver(prefix = 'item') { + const keyMap = new WeakMap() + + return (item: T): string => { + const cached = keyMap.get(item) + if (cached) { + return cached + } + + const key = `${prefix}-${++globalStableObjectKeySeed}` + keyMap.set(item, key) + return key + } +} diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index c6d15e2d..4d6dccf6 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -759,8 +759,8 @@
@@ -786,7 +786,7 @@ {{ account.name }}