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])
})