diff --git a/apps/web/package.json b/apps/web/package.json index 62598da..b0f547b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -25,6 +25,7 @@ "pinia": "^3.0.2", "vue": "^3.5.13", "vue-echarts": "^7.0.3", + "vue-i18n": "^11.4.2", "vue-router": "^4.5.0" }, "devDependencies": { diff --git a/apps/web/src/components/PageHeader.vue b/apps/web/src/components/PageHeader.vue index bc0f48d..88349f8 100644 --- a/apps/web/src/components/PageHeader.vue +++ b/apps/web/src/components/PageHeader.vue @@ -6,8 +6,8 @@
-

{{ title }}

-

{{ subtitle }}

+

{{ props.title }}

+

{{ props.subtitle }}

@@ -16,7 +16,7 @@ import type { Component } from 'vue' import { NIcon } from 'naive-ui' -withDefaults( +const props = withDefaults( defineProps<{ title: string subtitle: string diff --git a/apps/web/src/components/ReminderRulesPreview.vue b/apps/web/src/components/ReminderRulesPreview.vue index 216b5e4..8ea36a8 100644 --- a/apps/web/src/components/ReminderRulesPreview.vue +++ b/apps/web/src/components/ReminderRulesPreview.vue @@ -11,13 +11,13 @@ - {{ isPreviewVisible ? '收起提醒预览' : '预览提醒规则' }} + {{ isPreviewVisible ? t('common.actions.collapse') : t('settings.buttons.previewReminderRules') }}
-
到期前提醒
+
{{ t('subscriptions.labels.advanceReminders') }}
{{ advanceEvaluation.error }}
@@ -26,11 +26,11 @@ {{ entry.description }} -
暂无到期前提醒规则
+
{{ t('validation.reminderRules.noAdvance') }}
-
过期提醒
+
{{ t('subscriptions.labels.overdueReminders') }}
{{ overdueEvaluation.error }}
@@ -39,7 +39,7 @@ {{ entry.description }} -
暂无过期提醒规则
+
{{ t('validation.reminderRules.noOverdue') }}
@@ -47,9 +47,10 @@ diff --git a/apps/web/src/composables/api.ts b/apps/web/src/composables/api.ts index b549452..48d12a7 100644 --- a/apps/web/src/composables/api.ts +++ b/apps/web/src/composables/api.ts @@ -36,6 +36,8 @@ import type { } from '@/types/api' import { clearAuthSession, getStoredToken } from '@/utils/auth-storage' import { getApiBaseUrl } from '@/utils/api-base' +import { getAppLocale } from '@/locales' +import { getMessage } from '@subtracker/shared' const client = axios.create({ baseURL: getApiBaseUrl(import.meta.env.VITE_API_BASE_URL), @@ -49,6 +51,7 @@ client.interceptors.request.use((request) => { if (token) { request.headers.Authorization = `Bearer ${token}` } + request.headers['X-SubTracker-Locale'] = getAppLocale() return request }) @@ -68,7 +71,8 @@ client.interceptors.response.use( ? Object.values(fieldErrors).flatMap((messages) => messages ?? []).find(Boolean) : null const message = firstFieldError || errorPayload?.message - return Promise.reject(new Error(message || error.message || '请求失败')) + const fallback = getMessage(getAppLocale(), 'common.errors.requestFailed') + return Promise.reject(new Error(message || error.message || fallback)) } ) diff --git a/apps/web/src/locales/index.ts b/apps/web/src/locales/index.ts new file mode 100644 index 0000000..a64ec62 --- /dev/null +++ b/apps/web/src/locales/index.ts @@ -0,0 +1,79 @@ +import { computed, ref } from 'vue' +import { createI18n } from 'vue-i18n' +import { dateEnUS, dateZhCN, enUS, zhCN } from 'naive-ui' +import { + DEFAULT_APP_LOCALE, + LOCALE_PREFERENCE_STORAGE_KEY, + getDefaultAiDashboardSummaryPrompt, + getDefaultAiSubscriptionPrompt, + normalizeAppLocale, + sharedMessages, + type AppLocale +} from '@subtracker/shared' + +const messages = sharedMessages + +function readStoredLocalePreference(): AppLocale { + if (typeof window === 'undefined') return DEFAULT_APP_LOCALE + + const stored = window.localStorage.getItem(LOCALE_PREFERENCE_STORAGE_KEY) + if (stored) { + return normalizeAppLocale(stored, DEFAULT_APP_LOCALE) + } + + return normalizeAppLocale(window.navigator.language, DEFAULT_APP_LOCALE) +} + +const localeRef = ref(readStoredLocalePreference()) + +export const i18n = createI18n({ + legacy: false, + locale: localeRef.value, + fallbackLocale: DEFAULT_APP_LOCALE, + messages +}) + +function syncLocale(locale: AppLocale) { + localeRef.value = locale + i18n.global.locale.value = locale +} + +export function setAppLocale(locale: AppLocale) { + const normalized = normalizeAppLocale(locale, DEFAULT_APP_LOCALE) + syncLocale(normalized) + + if (typeof window !== 'undefined') { + window.localStorage.setItem(LOCALE_PREFERENCE_STORAGE_KEY, normalized) + } +} + +export function useAppLocale() { + const naiveLocale = computed(() => (localeRef.value === 'en-US' ? enUS : zhCN)) + const naiveDateLocale = computed(() => (localeRef.value === 'en-US' ? dateEnUS : dateZhCN)) + + return { + locale: computed(() => localeRef.value), + naiveLocale, + naiveDateLocale, + setLocale: setAppLocale + } +} + +export function getAppLocale() { + return localeRef.value +} + +export function t(key: string, params?: Record) { + // Ensure callers that use this shared wrapper inside template/computed/render + // establish a reactive dependency on the current locale. + localeRef.value + return params ? i18n.global.t(key, params) : i18n.global.t(key) +} + +export function getDefaultAiPromptByLocale() { + return getDefaultAiSubscriptionPrompt(localeRef.value) +} + +export function getDefaultAiSummaryPromptByLocale() { + return getDefaultAiDashboardSummaryPrompt(localeRef.value) +} diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index ed8c9bf..0307912 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -4,6 +4,7 @@ import { createPinia } from 'pinia' import { create, NConfigProvider, NMessageProvider } from 'naive-ui' import App from './App.vue' import { router } from './router' +import { i18n } from './locales' import './style.css' const naive = create({ @@ -16,5 +17,6 @@ const queryClient = new QueryClient() app.use(createPinia()) app.use(router) app.use(naive) +app.use(i18n) app.use(VueQueryPlugin, { queryClient }) app.mount('#app') diff --git a/apps/web/src/pages/BudgetPage.vue b/apps/web/src/pages/BudgetPage.vue index 3601cfa..ce5f3ee 100644 --- a/apps/web/src/pages/BudgetPage.vue +++ b/apps/web/src/pages/BudgetPage.vue @@ -1,15 +1,15 @@