diff --git a/.gitignore b/.gitignore index 8e62dc7..031e880 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ coverage .env .env.local *.log +/.tmp/ +/docs/ apps/api/prisma/dev.db apps/api/prisma/dev.db-journal apps/api/prisma/test.db diff --git a/apps/web/src/pages/SettingsPage.vue b/apps/web/src/pages/SettingsPage.vue index 4b1d2a6..91a91a5 100644 --- a/apps/web/src/pages/SettingsPage.vue +++ b/apps/web/src/pages/SettingsPage.vue @@ -568,7 +568,7 @@ /> - 需先启用至少一个直达通知渠道 + {{ t('settings.helps.forgotPasswordChannelRequired') }} )[upper] diff --git a/apps/web/src/utils/reminder-rules.ts b/apps/web/src/utils/reminder-rules.ts index 5fdc4a3..07228ee 100644 --- a/apps/web/src/utils/reminder-rules.ts +++ b/apps/web/src/utils/reminder-rules.ts @@ -1,3 +1,5 @@ +import { DEFAULT_APP_LOCALE, getMessage, type AppLocale } from '@subtracker/shared' + export type ReminderRulesKind = 'advance' | 'overdue' export type ReminderRuleEntry = { @@ -51,31 +53,31 @@ function formatI18n(template: string, params: Record) { return template.replace(/\{(\w+)\}/g, (_match, key) => String(params[key] ?? `{${key}}`)) } -function getDefaultI18n(): ReminderRulesI18n { +export function getDefaultReminderRulesI18n(locale: AppLocale = DEFAULT_APP_LOCALE): ReminderRulesI18n { return { - fallback: '沿用系统默认', - emptyTitle: '请先输入规则后再演算', - resultTitle: '演算结果', - invalidTitle: '规则格式有误', - defaultRulesLabel: '系统默认规则', - defaultAdvanceRulesLabel: '系统默认到期前规则', - defaultOverdueRulesLabel: '系统默认过期规则', - fallbackPreviewTitle: '当前未填写,以下按{label}演算', - fallbackInvalidTitle: '{label}格式有误', - noAdvance: '暂无到期前提醒规则', - noOverdue: '暂无过期提醒规则', - parseFailed: '规则解析失败', - invalidSegmentFormat: '规则 "{segment}" 格式无效,应为 天数&HH:mm', - invalidDaysInteger: '规则 "{segment}" 中的天数必须为整数', - invalidOverdueDays: '规则 "{segment}" 中的天数必须大于等于 1', - invalidAdvanceDays: '规则 "{segment}" 中的天数不能小于 0', - invalidTime: '规则 "{segment}" 中的时间必须为 HH:mm', - inlineAdvanceSameDay: '当天 {time}', - inlineAdvanceBefore: '提前 {days} 天 {time}', - inlineOverdue: '过期 {days} 天 {time}', - evalAdvanceSameDay: '到期当天 {time} 提醒', - evalAdvanceBefore: '提前 {days} 天 {time} 提醒', - evalOverdue: '过期 {days} 天 {time} 提醒' + fallback: getMessage(locale, 'validation.reminderRules.fallback'), + emptyTitle: getMessage(locale, 'validation.reminderRules.emptyTitle'), + resultTitle: getMessage(locale, 'validation.reminderRules.resultTitle'), + invalidTitle: getMessage(locale, 'validation.reminderRules.invalidTitle'), + defaultRulesLabel: getMessage(locale, 'validation.reminderRules.defaultRulesLabel'), + defaultAdvanceRulesLabel: getMessage(locale, 'validation.reminderRules.defaultAdvanceRulesLabel'), + defaultOverdueRulesLabel: getMessage(locale, 'validation.reminderRules.defaultOverdueRulesLabel'), + fallbackPreviewTitle: getMessage(locale, 'validation.reminderRules.fallbackPreviewTitle', { label: '{label}' }), + fallbackInvalidTitle: getMessage(locale, 'validation.reminderRules.fallbackInvalidTitle', { label: '{label}' }), + noAdvance: getMessage(locale, 'validation.reminderRules.noAdvance'), + noOverdue: getMessage(locale, 'validation.reminderRules.noOverdue'), + parseFailed: getMessage(locale, 'validation.reminderRules.parseFailed'), + invalidSegmentFormat: getMessage(locale, 'validation.reminderRules.invalidSegmentFormat', { segment: '{segment}' }), + invalidDaysInteger: getMessage(locale, 'validation.reminderRules.invalidDaysInteger', { segment: '{segment}' }), + invalidOverdueDays: getMessage(locale, 'validation.reminderRules.invalidOverdueDays', { segment: '{segment}' }), + invalidAdvanceDays: getMessage(locale, 'validation.reminderRules.invalidAdvanceDays', { segment: '{segment}' }), + invalidTime: getMessage(locale, 'validation.reminderRules.invalidTime', { segment: '{segment}' }), + inlineAdvanceSameDay: getMessage(locale, 'validation.reminderRules.inlineAdvanceSameDay', { time: '{time}' }), + inlineAdvanceBefore: getMessage(locale, 'validation.reminderRules.inlineAdvanceBefore', { days: '{days}', time: '{time}' }), + inlineOverdue: getMessage(locale, 'validation.reminderRules.inlineOverdue', { days: '{days}', time: '{time}' }), + evalAdvanceSameDay: getMessage(locale, 'validation.reminderRules.evalAdvanceSameDay', { time: '{time}' }), + evalAdvanceBefore: getMessage(locale, 'validation.reminderRules.evalAdvanceBefore', { days: '{days}', time: '{time}' }), + evalOverdue: getMessage(locale, 'validation.reminderRules.evalOverdue', { days: '{days}', time: '{time}' }) } } @@ -172,20 +174,21 @@ function toEvaluationDescription(rule: ParsedReminderRule, kind: ReminderRulesKi export function formatReminderRulesText( value: string | null | undefined, kind: ReminderRulesKind, - fallback = getDefaultI18n().fallback, + fallback?: string, options?: { i18n?: Partial } ) { - const copy = { ...getDefaultI18n(), ...options?.i18n } - if (!value?.trim()) return fallback + const copy = { ...getDefaultReminderRulesI18n(), ...options?.i18n } + const fallbackText = fallback ?? copy.fallback + if (!value?.trim()) return fallbackText try { const parts = parseReminderRulesStrict(value, kind, copy) - if (!parts.length) return fallback + if (!parts.length) return fallbackText return parts.map((item) => toInlineDescription(item, kind, copy)).join(';') } catch { - return fallback + return fallbackText } } @@ -197,7 +200,7 @@ export function listReminderRuleDescriptions( i18n?: Partial } ) { - const copy = { ...getDefaultI18n(), ...options?.i18n } + const copy = { ...getDefaultReminderRulesI18n(), ...options?.i18n } const currentValue = value?.trim() ?? '' try { @@ -223,7 +226,7 @@ export function evaluateReminderRules( i18n?: Partial } ): ReminderRulesEvaluation { - const copy = { ...getDefaultI18n(), ...options?.i18n } + const copy = { ...getDefaultReminderRulesI18n(), ...options?.i18n } const fallbackLabel = options?.fallbackLabel ?? (kind === 'advance' ? copy.defaultAdvanceRulesLabel : kind === 'overdue' ? copy.defaultOverdueRulesLabel : copy.defaultRulesLabel) diff --git a/apps/web/src/utils/statistics-top-subscriptions.ts b/apps/web/src/utils/statistics-top-subscriptions.ts index 5364879..4d9fa2a 100644 --- a/apps/web/src/utils/statistics-top-subscriptions.ts +++ b/apps/web/src/utils/statistics-top-subscriptions.ts @@ -1,3 +1,5 @@ +import { getMessage } from '@subtracker/shared' +import { getAppLocale } from '@/locales' import type { StatisticsOverview } from '@/types/api' interface TopSubscriptionsThemeTokens { @@ -15,6 +17,7 @@ export function buildTopSubscriptionsOption( ) { const data = (items ?? []).slice(0, 10) if (!data.length) return null + const locale = getAppLocale() return { tooltip: { @@ -29,7 +32,7 @@ export function buildTopSubscriptionsOption( grid: { left: 160, right: 24, top: 20, bottom: 20 }, xAxis: { type: 'value', - name: `金额(${baseCurrency})`, + name: getMessage(locale, 'statistics.labels.amountAxis', { currency: baseCurrency }), nameTextStyle: { color: theme?.secondaryTextColor }, diff --git a/apps/web/src/utils/timezone.ts b/apps/web/src/utils/timezone.ts index 8b09582..a209a94 100644 --- a/apps/web/src/utils/timezone.ts +++ b/apps/web/src/utils/timezone.ts @@ -2,6 +2,7 @@ import dayjs from 'dayjs' import utc from 'dayjs/plugin/utc.js' import timezone from 'dayjs/plugin/timezone.js' import customParseFormat from 'dayjs/plugin/customParseFormat.js' +import { getMessage } from '@subtracker/shared' import { getAppLocale } from '@/locales' dayjs.extend(utc) @@ -154,7 +155,7 @@ export function addIntervalToPickerTs( export function formatMonthLabelInTimezone(value: Date | string | number, timezoneValue = DEFAULT_APP_TIMEZONE) { const locale = getAppLocale() const date = toTimezonedDayjs(typeof value === 'number' ? new Date(value) : value, timezoneValue) - return locale === 'en-US' ? date.format('MMMM YYYY') : date.format('YYYY 年 M 月') + return date.locale(locale).format(getMessage(locale, 'formatting.monthLabel.long')) } export function addIntervalToBusinessDateString( diff --git a/apps/web/tests/unit/components/settings-import-export.test.ts b/apps/web/tests/unit/components/settings-import-export.test.ts index 57b6bb4..ae9f225 100644 --- a/apps/web/tests/unit/components/settings-import-export.test.ts +++ b/apps/web/tests/unit/components/settings-import-export.test.ts @@ -76,7 +76,7 @@ describe('settings import export section', () => { expect(source).toContain('forgotPasswordToggleUnlocked') expect(source).toContain('savingForgotPasswordToggle') expect(source).toContain('handleForgotPasswordToggleChange') - expect(source).toContain('需先启用至少一个直达通知渠道') + expect(source).toContain("t('settings.helps.forgotPasswordChannelRequired')") expect(source).toContain("@update:value=\"handleForgotPasswordToggleChange\"") expect(source).toContain('switch-group switch-group--single') expect(source).toContain('switch-inline-label') diff --git a/apps/web/tests/unit/utils/reminder-rules.test.ts b/apps/web/tests/unit/utils/reminder-rules.test.ts index 4259b8b..bf7c94a 100644 --- a/apps/web/tests/unit/utils/reminder-rules.test.ts +++ b/apps/web/tests/unit/utils/reminder-rules.test.ts @@ -76,4 +76,40 @@ describe('reminder rules helpers', () => { { key: '2&09:30', description: '过期 2 天 09:30' } ]) }) + + it('supports localized reminder rule copy overrides', () => { + const copy = { + fallback: 'Use the system default', + resultTitle: 'Preview result', + invalidTitle: 'Invalid rule format', + defaultRulesLabel: 'system default rules', + defaultAdvanceRulesLabel: 'system default pre-renewal rules', + defaultOverdueRulesLabel: 'system default overdue rules', + fallbackPreviewTitle: 'No value entered. Previewing with {label}', + fallbackInvalidTitle: '{label} is invalid', + noAdvance: 'No pre-renewal reminder rules', + noOverdue: 'No overdue reminder rules', + parseFailed: 'Failed to parse rules', + invalidSegmentFormat: 'Rule "{segment}" is invalid. Expected format: days&HH:mm', + invalidDaysInteger: 'The days value in rule "{segment}" must be an integer', + invalidOverdueDays: 'The days value in rule "{segment}" must be greater than or equal to 1', + invalidAdvanceDays: 'The days value in rule "{segment}" cannot be less than 0', + invalidTime: 'The time value in rule "{segment}" must use HH:mm', + inlineAdvanceSameDay: 'On the due date at {time}', + inlineAdvanceBefore: '{days} day(s) before at {time}', + inlineOverdue: 'On overdue day {days} at {time}', + evalAdvanceSameDay: 'Remind on the due date at {time}', + evalAdvanceBefore: 'Remind {days} day(s) before at {time}', + evalOverdue: 'Remind on overdue day {days} at {time}' + } + + expect(formatReminderRulesText('', 'advance', undefined, { i18n: copy })).toBe('Use the system default') + expect(formatReminderRulesText('3&09:30;0&09:30;', 'advance', undefined, { i18n: copy })).toBe( + '3 day(s) before at 09:30;On the due date at 09:30' + ) + + const result = evaluateReminderRules('1&09:30;', 'overdue', { i18n: copy }) + expect(result.title).toBe('Preview result') + expect(result.entries.map((item) => item.description)).toEqual(['Remind on overdue day 1 at 09:30']) + }) }) diff --git a/apps/web/tests/unit/utils/statistics-top-subscriptions.test.ts b/apps/web/tests/unit/utils/statistics-top-subscriptions.test.ts index 148cf60..b5cdf91 100644 --- a/apps/web/tests/unit/utils/statistics-top-subscriptions.test.ts +++ b/apps/web/tests/unit/utils/statistics-top-subscriptions.test.ts @@ -23,6 +23,7 @@ describe('buildTopSubscriptionsOption', () => { expect(option?.yAxis.data).toEqual(['Netflix']) expect(option?.yAxis.inverse).toBe(true) + expect(option?.xAxis.name).toBe('金额(CNY)') expect(option?.series[0].data).toEqual([88]) })