From 477fb256825e4b5293bf5660cbba4bfb201aefc2 Mon Sep 17 00:00:00 2001 From: SmileQWQ Date: Mon, 11 May 2026 08:16:12 +0800 Subject: [PATCH] feat: add shared i18n foundation - add shared locale schemas, key-based message catalogs and locale helpers for zh-CN and en-US - route API errors, notifications and default AI prompts through locale-aware message lookups with request and settings fallbacks - cover locale parsing, settings defaults and i18n-aware backend flows with regression tests --- apps/api/src/app.ts | 21 +- apps/api/src/fastify.d.ts | 2 + apps/api/src/http.ts | 30 +- apps/api/src/i18n.ts | 30 + apps/api/src/routes/ai.ts | 38 +- apps/api/src/routes/auth.ts | 94 +- apps/api/src/routes/calendar.ts | 4 +- apps/api/src/routes/exchange-rates.ts | 4 +- apps/api/src/routes/imports.ts | 32 +- apps/api/src/routes/notifications.ts | 95 +- apps/api/src/routes/settings.ts | 113 +- apps/api/src/routes/subscriptions.ts | 180 ++- apps/api/src/routes/tags.ts | 28 +- apps/api/src/routes/version.ts | 8 +- apps/api/src/services/ai-summary.service.ts | 18 +- apps/api/src/services/ai.service.ts | 11 +- .../services/channel-notification.service.ts | 199 +-- .../src/services/forgot-password.service.ts | 87 +- apps/api/src/services/notification.service.ts | 8 +- apps/api/src/services/settings.service.ts | 8 + apps/api/src/services/webhook.service.ts | 40 +- apps/api/tests/integration/ai-routes.test.ts | 28 + .../api/tests/integration/auth-routes.test.ts | 32 + .../integration/notifications-routes.test.ts | 81 +- .../tests/integration/settings-routes.test.ts | 26 + .../integration/subscriptions-routes.test.ts | 20 +- .../api/tests/unit/ai-summary.service.test.ts | 11 +- apps/api/tests/unit/ai.service.test.ts | 25 +- .../unit/forgot-password.service.test.ts | 36 + packages/shared/package.json | 8 +- packages/shared/src/i18n.ts | 73 + packages/shared/src/index.test.ts | 21 + packages/shared/src/index.ts | 90 +- packages/shared/src/locale-core.ts | 54 + packages/shared/src/locales/en-US.ts | 1183 +++++++++++++++++ packages/shared/src/locales/zh-CN.ts | 1182 ++++++++++++++++ 36 files changed, 3536 insertions(+), 384 deletions(-) create mode 100644 apps/api/src/i18n.ts create mode 100644 packages/shared/src/i18n.ts create mode 100644 packages/shared/src/locale-core.ts create mode 100644 packages/shared/src/locales/en-US.ts create mode 100644 packages/shared/src/locales/zh-CN.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 3e4e485..fca4497 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -3,8 +3,11 @@ import cors from '@fastify/cors' import rateLimit from '@fastify/rate-limit' import { readFile } from 'node:fs/promises' import path from 'node:path' +import { DEFAULT_APP_LOCALE } from '@subtracker/shared' +import { getMessage } from '@subtracker/shared' import { config } from './config' import { sendError } from './http' +import { detectRequestLocale } from './i18n' import { authRoutes } from './routes/auth' import { subscriptionRoutes } from './routes/subscriptions' import { statisticsRoutes } from './routes/statistics' @@ -31,11 +34,11 @@ export async function buildApp() { await app.register(rateLimit, { global: false, - errorResponseBuilder: (_request, context) => ({ + errorResponseBuilder: (request, context) => ({ statusCode: 429, error: { code: 'too_many_attempts', - message: '登录失败次数过多,请稍后再试', + message: getMessage(request.locale ?? DEFAULT_APP_LOCALE, 'api.errors.tooManyAttempts'), details: { retryAfterSeconds: Math.max(1, Math.ceil(context.ttl / 1000)) } @@ -63,11 +66,14 @@ export async function buildApp() { reply.header('Content-Type', mimeMap[ext] ?? 'application/octet-stream') return reply.send(file) } catch { - return sendError(reply, 404, 'not_found', 'Logo not found') + return sendError(reply, 404, 'not_found', 'api.errors.logoNotFound', undefined, { + locale: request.locale ?? DEFAULT_APP_LOCALE + }) } }) app.addHook('onRequest', async (request, reply) => { + request.locale = detectRequestLocale(request) const url = request.url.split('?')[0] if ( request.method === 'OPTIONS' || @@ -87,7 +93,9 @@ export async function buildApp() { const user = await verifyToken(token) if (!user) { - return sendError(reply, 401, 'unauthorized', '请先登录') + return sendError(reply, 401, 'unauthorized', 'api.errors.unauthorized', undefined, { + locale: request.locale + }) } request.auth = user @@ -112,8 +120,9 @@ export async function buildApp() { app.setErrorHandler((error, _request, reply) => { app.log.error(error) - const message = error instanceof Error ? error.message : 'Unknown server error' - return sendError(reply, 500, 'internal_error', message) + return sendError(reply, 500, 'internal_error', 'api.errors.internal', undefined, { + locale: _request.locale ?? DEFAULT_APP_LOCALE + }) }) return app diff --git a/apps/api/src/fastify.d.ts b/apps/api/src/fastify.d.ts index 804b61c..bc17877 100644 --- a/apps/api/src/fastify.d.ts +++ b/apps/api/src/fastify.d.ts @@ -1,4 +1,5 @@ import 'fastify' +import type { AppLocale } from '@subtracker/shared' declare module 'fastify' { interface FastifyRequest { @@ -6,5 +7,6 @@ declare module 'fastify' { username: string mustChangePassword: boolean } + locale?: AppLocale } } diff --git a/apps/api/src/http.ts b/apps/api/src/http.ts index d12d29c..ab1513b 100644 --- a/apps/api/src/http.ts +++ b/apps/api/src/http.ts @@ -1,4 +1,6 @@ import type { FastifyReply } from 'fastify' +import { resolveAppLocaleFromAcceptLanguage, type AppLocale } from '@subtracker/shared' +import { translateErrorMessage } from './i18n' export function sendOk(reply: FastifyReply, data: T, meta?: Record) { return reply.status(200).send({ data, meta }) @@ -8,11 +10,35 @@ export function sendCreated(reply: FastifyReply, data: T) { return reply.status(201).send({ data }) } -export function sendError(reply: FastifyReply, status: number, code: string, message: string, details?: unknown) { +export function sendError( + reply: FastifyReply, + status: number, + code: string, + messageKey: string, + details?: unknown, + options?: { + locale?: AppLocale + params?: Record + } +) { + const request = (reply as FastifyReply & { + request?: { locale?: AppLocale; headers?: Record } + }).request + const requestLocale = + request?.locale ?? + resolveAppLocaleFromAcceptLanguage( + typeof request?.headers?.['x-subtracker-locale'] === 'string' + ? request.headers['x-subtracker-locale'] + : request?.headers?.['accept-language'], + 'zh-CN' + ) return reply.status(status).send({ error: { code, - message, + message: translateErrorMessage(messageKey, { + locale: options?.locale ?? requestLocale, + params: options?.params + }), details } }) diff --git a/apps/api/src/i18n.ts b/apps/api/src/i18n.ts new file mode 100644 index 0000000..e5762bc --- /dev/null +++ b/apps/api/src/i18n.ts @@ -0,0 +1,30 @@ +import type { FastifyRequest } from 'fastify' +import { + DEFAULT_APP_LOCALE, + getMessage, + type AppLocale, + resolveAppLocaleFromAcceptLanguage +} from '@subtracker/shared' + +export type TranslateOptions = { + locale?: AppLocale + params?: Record +} + +export function detectRequestLocale(request: Pick, fallbackLocale = DEFAULT_APP_LOCALE): AppLocale { + const localeHeader = request.headers['x-subtracker-locale'] + if (typeof localeHeader === 'string' && localeHeader.trim()) { + return resolveAppLocaleFromAcceptLanguage(localeHeader, fallbackLocale) + } + + const acceptLanguage = request.headers['accept-language'] + return resolveAppLocaleFromAcceptLanguage(acceptLanguage, fallbackLocale) +} + +export function translateMessage(messageKey: string, options?: TranslateOptions) { + return getMessage(options?.locale ?? DEFAULT_APP_LOCALE, messageKey, options?.params) +} + +export function translateErrorMessage(messageKey: string, options?: TranslateOptions) { + return translateMessage(messageKey, options) +} diff --git a/apps/api/src/routes/ai.ts b/apps/api/src/routes/ai.ts index 617090f..b93443b 100644 --- a/apps/api/src/routes/ai.ts +++ b/apps/api/src/routes/ai.ts @@ -20,7 +20,9 @@ export async function aiRoutes(app: FastifyInstance) { try { return sendOk(reply, await getDashboardAiSummary()) } catch (error) { - return sendError(reply, 400, 'ai_summary_fetch_failed', error instanceof Error ? error.message : 'AI summary fetch failed') + return sendError(reply, 400, 'ai_summary_fetch_failed', error instanceof Error ? error.message : 'api.errors.ai.summaryFetchFailed', undefined, { + locale: _request.locale + }) } }) @@ -28,7 +30,14 @@ export async function aiRoutes(app: FastifyInstance) { try { return sendOk(reply, await generateDashboardAiSummary()) } catch (error) { - return sendError(reply, 400, 'ai_summary_generate_failed', error instanceof Error ? error.message : 'AI summary generate failed') + return sendError( + reply, + 400, + 'ai_summary_generate_failed', + error instanceof Error ? error.message : 'api.errors.ai.summaryGenerateFailed', + undefined, + { locale: _request.locale } + ) } }) @@ -37,7 +46,9 @@ export async function aiRoutes(app: FastifyInstance) { if (request.body) { const parsed = AiConfigSchema.partial().safeParse(request.body) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid AI config payload', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidAiConfigPayload', parsed.error.flatten(), { + locale: request.locale + }) } return sendOk( reply, @@ -47,7 +58,9 @@ export async function aiRoutes(app: FastifyInstance) { return sendOk(reply, await testAiConnection()) } catch (error) { - return sendError(reply, 400, 'ai_test_failed', error instanceof Error ? error.message : 'AI test failed') + return sendError(reply, 400, 'ai_test_failed', error instanceof Error ? error.message : 'api.errors.ai.connectionTestFailed', undefined, { + locale: request.locale + }) } }) @@ -56,14 +69,23 @@ export async function aiRoutes(app: FastifyInstance) { if (request.body) { const parsed = AiConfigSchema.partial().safeParse(request.body) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid AI config payload', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidAiConfigPayload', parsed.error.flatten(), { + locale: request.locale + }) } return sendOk(reply, await testAiVisionConnection(normalizeAiConfigPayload(parsed.data))) } return sendOk(reply, await testAiVisionConnection()) } catch (error) { - return sendError(reply, 400, 'ai_vision_test_failed', error instanceof Error ? error.message : 'AI vision test failed') + return sendError( + reply, + 400, + 'ai_vision_test_failed', + error instanceof Error ? error.message : 'api.errors.ai.visionTestFailed', + undefined, + { locale: request.locale } + ) } }) @@ -71,7 +93,9 @@ export async function aiRoutes(app: FastifyInstance) { try { return sendOk(reply, await recognizeSubscriptionByAi(request.body)) } catch (error) { - return sendError(reply, 400, 'ai_recognition_failed', error instanceof Error ? error.message : 'AI recognition failed') + return sendError(reply, 400, 'ai_recognition_failed', error instanceof Error ? error.message : 'api.errors.ai.recognitionFailed', undefined, { + locale: request.locale + }) } }) } diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index f4ee90b..813b246 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -1,5 +1,11 @@ import { FastifyInstance } from 'fastify' -import { ChangeCredentialsSchema, ForgotPasswordRequestSchema, ForgotPasswordResetSchema, LoginSchema } from '@subtracker/shared' +import { + ChangeCredentialsSchema, + ForgotPasswordRequestSchema, + ForgotPasswordResetSchema, + LoginSchema, + getMessage +} from '@subtracker/shared' import { z } from 'zod' import { sendError, sendOk } from '../http' import { changeCredentials, changeDefaultPassword, loginWithCredentials } from '../services/auth.service' @@ -15,10 +21,10 @@ function resolveLoginValidationMessage(body: unknown) { const username = payload.username?.trim() ?? '' const password = payload.password?.trim() ?? '' - if (!username && !password) return '请输入用户名和密码' - if (!username) return '请输入用户名' - if (!password) return '请输入密码' - return '登录信息格式不正确' + if (!username && !password) return 'auth.validation.usernameAndPasswordRequired' + if (!username) return 'auth.validation.usernameRequired' + if (!password) return 'auth.validation.passwordRequired' + return 'auth.validation.loginPayloadInvalid' } export async function authRoutes(app: FastifyInstance) { @@ -42,7 +48,9 @@ export async function authRoutes(app: FastifyInstance) { async (request, reply) => { const parsed = LoginSchema.safeParse(request.body) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', resolveLoginValidationMessage(request.body), parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', resolveLoginValidationMessage(request.body), parsed.error.flatten(), { + locale: request.locale + }) } const result = await loginWithCredentials(parsed.data.username, parsed.data.password, { @@ -50,7 +58,9 @@ export async function authRoutes(app: FastifyInstance) { rememberDays: parsed.data.rememberDays }) if (!result) { - return sendError(reply, 401, 'invalid_credentials', '用户名或密码错误') + return sendError(reply, 401, 'invalid_credentials', 'api.errors.auth.invalidCredentials', undefined, { + locale: request.locale + }) } return sendOk(reply, result) @@ -59,7 +69,9 @@ export async function authRoutes(app: FastifyInstance) { app.get('/auth/me', async (request, reply) => { if (!request.auth) { - return sendError(reply, 401, 'unauthorized', '请先登录') + return sendError(reply, 401, 'unauthorized', 'api.errors.unauthorized', undefined, { + locale: request.locale + }) } return sendOk(reply, { @@ -70,12 +82,16 @@ export async function authRoutes(app: FastifyInstance) { app.post('/auth/change-credentials', async (request, reply) => { const parsed = ChangeCredentialsSchema.safeParse(request.body) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid credentials payload', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidCredentialsPayload', parsed.error.flatten(), { + locale: request.locale + }) } const result = await changeCredentials(parsed.data) if (!result) { - return sendError(reply, 401, 'invalid_credentials', '原用户名或原密码错误') + return sendError(reply, 401, 'invalid_credentials', 'api.errors.auth.currentCredentialsInvalid', undefined, { + locale: request.locale + }) } return sendOk(reply, result) @@ -89,12 +105,16 @@ export async function authRoutes(app: FastifyInstance) { .safeParse(request.body) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid password payload', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidPasswordPayload', parsed.error.flatten(), { + locale: request.locale + }) } const result = await changeDefaultPassword(parsed.data.newPassword) if (!result) { - return sendError(reply, 400, 'default_password_change_not_allowed', 'Default password change is not allowed') + return sendError(reply, 400, 'default_password_change_not_allowed', 'api.errors.auth.defaultPasswordChangeNotAllowed', undefined, { + locale: request.locale + }) } return sendOk(reply, result) @@ -107,11 +127,11 @@ export async function authRoutes(app: FastifyInstance) { rateLimit: { max: 3, timeWindow: 10 * 60 * 1000, - errorResponseBuilder: (_request, context) => ({ + errorResponseBuilder: (request, context) => ({ statusCode: 429, error: { code: 'forgot_password_request_rate_limited', - message: '验证码发送过于频繁,请稍后再试', + message: getMessage(request.locale ?? 'zh-CN', 'api.errors.auth.forgotPasswordRequestRateLimited'), details: { retryAfterSeconds: Math.max(1, Math.ceil(context.ttl / 1000)) } @@ -123,14 +143,26 @@ export async function authRoutes(app: FastifyInstance) { async (request, reply) => { const parsed = ForgotPasswordRequestSchema.safeParse(request.body) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid forgot password request payload', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidForgotPasswordRequestPayload', parsed.error.flatten(), { + locale: request.locale + }) } - const result = await requestForgotPasswordChallenge(parsed.data.username, request.ip) + const result = await requestForgotPasswordChallenge(parsed.data.username, request.ip, request.locale) if (!result.ok) { - return sendError(reply, result.error.status, result.error.code, result.error.message, { - retryAfterSeconds: result.error.retryAfterSeconds - }) + return sendError( + reply, + result.error.status, + result.error.code, + result.error.message, + { + retryAfterSeconds: result.error.retryAfterSeconds + }, + { + locale: request.locale, + params: result.error.messageParams + } + ) } return sendOk(reply, { accepted: true }) @@ -144,11 +176,11 @@ export async function authRoutes(app: FastifyInstance) { rateLimit: { max: 5, timeWindow: 10 * 60 * 1000, - errorResponseBuilder: (_request, context) => ({ + errorResponseBuilder: (request, context) => ({ statusCode: 429, error: { code: 'forgot_password_reset_rate_limited', - message: '验证失败次数过多,请稍后再试', + message: getMessage(request.locale ?? 'zh-CN', 'api.errors.auth.forgotPasswordResetRateLimited'), details: { retryAfterSeconds: Math.max(1, Math.ceil(context.ttl / 1000)) } @@ -160,7 +192,9 @@ export async function authRoutes(app: FastifyInstance) { async (request, reply) => { const parsed = ForgotPasswordResetSchema.safeParse(request.body) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid forgot password reset payload', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidForgotPasswordResetPayload', parsed.error.flatten(), { + locale: request.locale + }) } const result = await resetPasswordWithForgotPasswordCode({ @@ -169,9 +203,19 @@ export async function authRoutes(app: FastifyInstance) { }) if (!result.ok) { - return sendError(reply, result.error.status, result.error.code, result.error.message, { - retryAfterSeconds: result.error.retryAfterSeconds - }) + return sendError( + reply, + result.error.status, + result.error.code, + result.error.message, + { + retryAfterSeconds: result.error.retryAfterSeconds + }, + { + locale: request.locale, + params: result.error.messageParams + } + ) } return sendOk(reply, result.result) diff --git a/apps/api/src/routes/calendar.ts b/apps/api/src/routes/calendar.ts index 8f704e5..a1fbb2f 100644 --- a/apps/api/src/routes/calendar.ts +++ b/apps/api/src/routes/calendar.ts @@ -23,7 +23,9 @@ export async function calendarRoutes(app: FastifyInstance) { const parsed = querySchema.safeParse(request.query) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid query', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidQuery', parsed.error.flatten(), { + locale: request.locale + }) } const timezone = await getAppTimezone() diff --git a/apps/api/src/routes/exchange-rates.ts b/apps/api/src/routes/exchange-rates.ts index a2a3a58..ea03035 100644 --- a/apps/api/src/routes/exchange-rates.ts +++ b/apps/api/src/routes/exchange-rates.ts @@ -14,7 +14,9 @@ export async function exchangeRateRoutes(app: FastifyInstance) { const latest = await ensureExchangeRates() return sendOk(reply, latest) } catch (error) { - return sendError(reply, 500, 'refresh_failed', 'Failed to refresh exchange rates', error) + return sendError(reply, 500, 'refresh_failed', 'api.errors.exchangeRates.refreshFailed', error, { + locale: _.locale + }) } }) } diff --git a/apps/api/src/routes/imports.ts b/apps/api/src/routes/imports.ts index 44b678d..4b29211 100644 --- a/apps/api/src/routes/imports.ts +++ b/apps/api/src/routes/imports.ts @@ -13,33 +13,43 @@ export async function importRoutes(app: FastifyInstance) { app.post('/import/wallos/inspect', async (request, reply) => { const parsed = WallosImportInspectSchema.safeParse(request.body) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid Wallos inspect payload', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidWallosInspectPayload', parsed.error.flatten(), { + locale: request.locale + }) } try { return sendOk(reply, await inspectWallosImportFile(parsed.data)) } catch (error) { - return sendError(reply, 400, 'wallos_inspect_failed', error instanceof Error ? error.message : 'Wallos inspect failed') + return sendError(reply, 400, 'wallos_inspect_failed', error instanceof Error ? error.message : 'api.errors.imports.wallosInspectFailed', undefined, { + locale: request.locale + }) } }) app.post('/import/wallos/commit', async (request, reply) => { const parsed = WallosImportCommitSchema.safeParse(request.body) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid Wallos commit payload', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidWallosCommitPayload', parsed.error.flatten(), { + locale: request.locale + }) } try { return sendOk(reply, await commitWallosImport(parsed.data)) } catch (error) { - return sendError(reply, 400, 'wallos_commit_failed', error instanceof Error ? error.message : 'Wallos import failed') + return sendError(reply, 400, 'wallos_commit_failed', error instanceof Error ? error.message : 'api.errors.imports.wallosCommitFailed', undefined, { + locale: request.locale + }) } }) app.post('/import/subtracker/inspect', async (request, reply) => { const parsed = SubtrackerBackupInspectSchema.safeParse(request.body) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid SubTracker backup inspect payload', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidSubtrackerBackupInspectPayload', parsed.error.flatten(), { + locale: request.locale + }) } try { @@ -49,7 +59,9 @@ export async function importRoutes(app: FastifyInstance) { reply, 400, 'subtracker_backup_inspect_failed', - error instanceof Error ? error.message : 'SubTracker backup inspect failed' + error instanceof Error ? error.message : 'api.errors.imports.subtrackerBackupInspectFailed', + undefined, + { locale: request.locale } ) } }) @@ -57,7 +69,9 @@ export async function importRoutes(app: FastifyInstance) { app.post('/import/subtracker/commit', async (request, reply) => { const parsed = SubtrackerBackupCommitSchema.safeParse(request.body) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid SubTracker backup commit payload', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidSubtrackerBackupCommitPayload', parsed.error.flatten(), { + locale: request.locale + }) } try { @@ -67,7 +81,9 @@ export async function importRoutes(app: FastifyInstance) { reply, 400, 'subtracker_backup_commit_failed', - error instanceof Error ? error.message : 'SubTracker backup restore failed' + error instanceof Error ? error.message : 'api.errors.imports.subtrackerBackupCommitFailed', + undefined, + { locale: request.locale } ) } }) diff --git a/apps/api/src/routes/notifications.ts b/apps/api/src/routes/notifications.ts index 0902e79..e633cf3 100644 --- a/apps/api/src/routes/notifications.ts +++ b/apps/api/src/routes/notifications.ts @@ -11,6 +11,7 @@ import { TelegramConfigSchema } from '@subtracker/shared' import { sendError, sendOk } from '../http' +import { detectRequestLocale } from '../i18n' import { sendTestEmailNotification, sendTestEmailNotificationWithConfig, @@ -51,11 +52,15 @@ export async function notificationRoutes(app: FastifyInstance) { app.put('/notifications/webhook', async (request, reply) => { const parsed = NotificationWebhookSettingsSchema.safeParse(request.body) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid webhook settings payload', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidWebhookSettingsPayload', parsed.error.flatten(), { + locale: request.locale + }) } if (parsed.data.enabled && !parsed.data.url) { - return sendError(reply, 422, 'validation_error', '启用 Webhook 时必须填写 URL') + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidWebhookUrlRequired', undefined, { + locale: request.locale + }) } const saved = await upsertPrimaryWebhookEndpoint(parsed.data) @@ -63,24 +68,31 @@ export async function notificationRoutes(app: FastifyInstance) { }) app.post('/notifications/scan-debug', async (request, reply) => { + const locale = request.locale ?? detectRequestLocale(request) const parsed = NotificationScanDebugSchema.safeParse(request.body ?? {}) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid scan debug payload', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidScanDebugPayload', parsed.error.flatten(), { + locale: request.locale + }) } const result = await scanRenewalNotifications(parsed.data.now ? new Date(parsed.data.now) : new Date(), { dryRun: parsed.data.dryRun, - includeDebugCandidates: true + includeDebugCandidates: true, + locale }) return sendOk(reply, result) }) app.post('/notifications/test/email', async (request, reply) => { + const locale = request.locale ?? detectRequestLocale(request) try { if (request.body) { const parsed = EmailNotificationTestSchema.safeParse(request.body) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid email config payload', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidEmailConfigPayload', parsed.error.flatten(), { + locale: request.locale + }) } await sendTestEmailNotificationWithConfig({ emailProvider: parsed.data.emailProvider, @@ -99,115 +111,142 @@ export async function notificationRoutes(app: FastifyInstance) { from: parsed.data.resendConfig.from ?? '', to: parsed.data.resendConfig.to ?? '' } - }) + }, { locale }) } else { - await sendTestEmailNotification() + await sendTestEmailNotification({ locale }) } return sendOk(reply, { success: true }) } catch (error) { - return sendError(reply, 400, 'email_test_failed', error instanceof Error ? error.message : 'Email test failed') + return sendError(reply, 400, 'email_test_failed', error instanceof Error ? error.message : 'api.errors.notifications.emailTestFailed', undefined, { + locale: request.locale + }) } }) app.post('/notifications/test/pushplus', async (request, reply) => { + const locale = request.locale ?? detectRequestLocale(request) try { if (request.body) { const parsed = PushPlusConfigSchema.partial().safeParse(request.body) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid PushPlus config payload', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidPushplusConfigPayload', parsed.error.flatten(), { + locale: request.locale + }) } const result = await sendTestPushplusNotificationWithConfig({ token: parsed.data.token ?? '', topic: parsed.data.topic ?? '' - }) + }, { locale }) return sendOk(reply, result) } - const result = await sendTestPushplusNotification() + const result = await sendTestPushplusNotification({ locale }) return sendOk(reply, result) } catch (error) { - return sendError(reply, 400, 'pushplus_test_failed', error instanceof Error ? error.message : 'PushPlus test failed') + return sendError(reply, 400, 'pushplus_test_failed', error instanceof Error ? error.message : 'api.errors.notifications.pushplusTestFailed', undefined, { + locale: request.locale + }) } }) app.post('/notifications/test/telegram', async (request, reply) => { + const locale = request.locale ?? detectRequestLocale(request) try { if (request.body) { const parsed = TelegramConfigSchema.partial().safeParse(request.body) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid Telegram config payload', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidTelegramConfigPayload', parsed.error.flatten(), { + locale: request.locale + }) } const result = await sendTestTelegramNotificationWithConfig({ botToken: parsed.data.botToken ?? '', chatId: parsed.data.chatId ?? '' - }) + }, { locale }) return sendOk(reply, result) } - const result = await sendTestTelegramNotification() + const result = await sendTestTelegramNotification({ locale }) return sendOk(reply, result) } catch (error) { - return sendError(reply, 400, 'telegram_test_failed', error instanceof Error ? error.message : 'Telegram test failed') + return sendError(reply, 400, 'telegram_test_failed', error instanceof Error ? error.message : 'api.errors.notifications.telegramTestFailed', undefined, { + locale: request.locale + }) } }) app.post('/notifications/test/serverchan', async (request, reply) => { + const locale = request.locale ?? detectRequestLocale(request) try { if (request.body) { const parsed = ServerchanConfigSchema.partial().safeParse(request.body) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid Server 酱 config payload', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidServerchanConfigPayload', parsed.error.flatten(), { + locale: request.locale + }) } const result = await sendTestServerchanNotificationWithConfig({ sendkey: parsed.data.sendkey ?? '' - }) + }, { locale }) return sendOk(reply, result) } - const result = await sendTestServerchanNotification() + const result = await sendTestServerchanNotification({ locale }) return sendOk(reply, result) } catch (error) { - return sendError(reply, 400, 'serverchan_test_failed', error instanceof Error ? error.message : 'Serverchan test failed') + return sendError(reply, 400, 'serverchan_test_failed', error instanceof Error ? error.message : 'api.errors.notifications.serverchanTestFailed', undefined, { + locale: request.locale + }) } }) app.post('/notifications/test/gotify', async (request, reply) => { + const locale = request.locale ?? detectRequestLocale(request) try { if (request.body) { const parsed = GotifyConfigSchema.partial().safeParse(request.body) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid Gotify config payload', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidGotifyConfigPayload', parsed.error.flatten(), { + locale: request.locale + }) } const result = await sendTestGotifyNotificationWithConfig({ url: parsed.data.url ?? '', token: parsed.data.token ?? '', ignoreSsl: parsed.data.ignoreSsl ?? false - }) + }, { locale }) return sendOk(reply, result) } - const result = await sendTestGotifyNotification() + const result = await sendTestGotifyNotification({ locale }) return sendOk(reply, result) } catch (error) { - return sendError(reply, 400, 'gotify_test_failed', error instanceof Error ? error.message : 'Gotify test failed') + return sendError(reply, 400, 'gotify_test_failed', error instanceof Error ? error.message : 'api.errors.notifications.gotifyTestFailed', undefined, { + locale: request.locale + }) } }) app.post('/notifications/test/webhook', async (request, reply) => { + const locale = request.locale ?? detectRequestLocale(request) try { if (request.body) { const parsed = NotificationWebhookSettingsSchema.safeParse(request.body) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid webhook settings payload', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidWebhookSettingsPayload', parsed.error.flatten(), { + locale: request.locale + }) } - const result = await sendTestWebhookNotificationWithConfig(parsed.data) + const result = await sendTestWebhookNotificationWithConfig(parsed.data, { locale }) return sendOk(reply, result) } - const result = await sendTestWebhookNotification() + const result = await sendTestWebhookNotification({ locale }) return sendOk(reply, result) } catch (error) { - return sendError(reply, 400, 'webhook_test_failed', error instanceof Error ? error.message : 'Webhook test failed') + return sendError(reply, 400, 'webhook_test_failed', error instanceof Error ? error.message : 'api.errors.notifications.webhookTestFailed', undefined, { + locale: request.locale + }) } }) } diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index c503b7f..9be5f26 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -2,10 +2,13 @@ import { FastifyInstance } from 'fastify' import { DEFAULT_ADVANCE_REMINDER_RULES, DEFAULT_OVERDUE_REMINDER_RULES, - SettingsSchema + SettingsSchema, + getMessage, + type AppLocale } from '@subtracker/shared' import { prisma } from '../db' import { sendError, sendOk } from '../http' +import { detectRequestLocale } from '../i18n' import { getAppSettings, setSetting } from '../services/settings.service' import { validateNotificationTargetUrl } from '../services/notification-url.service' import { @@ -34,23 +37,46 @@ function hasDirectForgotPasswordChannelEnabled(settings: { ) } -function validateSettingsPayload(settings: Awaited>) { +function validateSettingsPayload( + settings: Awaited>, + locale: AppLocale = 'zh-CN' +) { + const labels = { + smtpHost: getMessage(locale, 'settings.labels.smtpHost'), + port: getMessage(locale, 'common.labels.port'), + username: getMessage(locale, 'common.labels.username'), + password: getMessage(locale, 'common.labels.password'), + from: getMessage(locale, 'common.labels.from'), + to: getMessage(locale, 'common.labels.to'), + resendApiUrl: getMessage(locale, 'settings.labels.resendApiUrl'), + resendApiKey: getMessage(locale, 'settings.labels.resendApiKey'), + botToken: getMessage(locale, 'settings.labels.botToken'), + chatId: getMessage(locale, 'settings.labels.chatId'), + sendKey: getMessage(locale, 'settings.labels.sendKey'), + url: getMessage(locale, 'common.labels.url'), + token: getMessage(locale, 'common.labels.token'), + providerName: getMessage(locale, 'settings.labels.providerName'), + model: getMessage(locale, 'common.labels.model'), + apiBaseUrl: getMessage(locale, 'settings.labels.apiBaseUrl'), + apiKey: getMessage(locale, 'settings.labels.apiKey') + } + if (settings.emailNotificationsEnabled) { const missingEmailFields = settings.emailProvider === 'resend' ? [ - ['Resend API URL', settings.resendConfig.apiBaseUrl], - ['Resend API Key', settings.resendConfig.apiKey], - ['发件人', settings.resendConfig.from], - ['收件人', settings.resendConfig.to] + [labels.resendApiUrl, settings.resendConfig.apiBaseUrl], + [labels.resendApiKey, settings.resendConfig.apiKey], + [labels.from, settings.resendConfig.from], + [labels.to, settings.resendConfig.to] ] : [ - ['SMTP Host', settings.smtpConfig.host], - ['端口', settings.smtpConfig.port], - ['用户名', settings.smtpConfig.username], - ['密码', settings.smtpConfig.password], - ['发件人', settings.smtpConfig.from], - ['收件人', settings.smtpConfig.to] + [labels.smtpHost, settings.smtpConfig.host], + [labels.port, settings.smtpConfig.port], + [labels.username, settings.smtpConfig.username], + [labels.password, settings.smtpConfig.password], + [labels.from, settings.smtpConfig.from], + [labels.to, settings.smtpConfig.to] ] const missingEmailLabels = missingEmailFields @@ -58,55 +84,71 @@ function validateSettingsPayload(settings: Awaited label) if (missingEmailLabels.length) { - throw new Error(`启用邮箱通知时必须填写:${missingEmailLabels.join('、')}`) + throw new Error( + getMessage(locale, 'api.errors.settings.emailFieldsRequired', { + fields: missingEmailLabels.join(locale === 'en-US' ? ', ' : '、') + }) + ) } } if (settings.pushplusNotificationsEnabled && !settings.pushplusConfig.token.trim()) { - throw new Error('启用 PushPlus 时必须填写 Token') + throw new Error(getMessage(locale, 'api.errors.settings.pushplusTokenRequired')) } const missingTelegramFields = [ - ['Bot Token', settings.telegramConfig.botToken], - ['Chat ID', settings.telegramConfig.chatId] + [labels.botToken, settings.telegramConfig.botToken], + [labels.chatId, settings.telegramConfig.chatId] ] .filter(([, value]) => !String(value ?? '').trim()) .map(([label]) => label) if (settings.telegramNotificationsEnabled && missingTelegramFields.length) { - throw new Error(`启用 Telegram 通知时必须填写:${missingTelegramFields.join('、')}`) + throw new Error( + getMessage(locale, 'api.errors.settings.telegramFieldsRequired', { + fields: missingTelegramFields.join(locale === 'en-US' ? ', ' : '、') + }) + ) } if (settings.serverchanNotificationsEnabled && !settings.serverchanConfig.sendkey.trim()) { - throw new Error('启用 Server 酱时必须填写 SendKey') + throw new Error(getMessage(locale, 'api.errors.settings.serverchanSendKeyRequired')) } if (settings.gotifyNotificationsEnabled) { const missingGotifyFields = [ - ['URL', settings.gotifyConfig.url], - ['Token', settings.gotifyConfig.token] + [labels.url, settings.gotifyConfig.url], + [labels.token, settings.gotifyConfig.token] ] .filter(([, value]) => !String(value ?? '').trim()) .map(([label]) => label) if (missingGotifyFields.length) { - throw new Error(`启用 Gotify 时必须填写:${missingGotifyFields.join('、')}`) + throw new Error( + getMessage(locale, 'api.errors.settings.gotifyFieldsRequired', { + fields: missingGotifyFields.join(locale === 'en-US' ? ', ' : '、') + }) + ) } validateNotificationTargetUrl(settings.gotifyConfig.url.trim(), 'Gotify URL') } const missingAiFields = [ - ['Provider 名称', settings.aiConfig.providerName], - ['Model', settings.aiConfig.model], - ['API Base URL', settings.aiConfig.baseUrl], - ['API Key', settings.aiConfig.apiKey] + [labels.providerName, settings.aiConfig.providerName], + [labels.model, settings.aiConfig.model], + [labels.apiBaseUrl, settings.aiConfig.baseUrl], + [labels.apiKey, settings.aiConfig.apiKey] ] .filter(([, value]) => !String(value ?? '').trim()) .map(([label]) => label) if (settings.aiConfig.enabled && missingAiFields.length) { - throw new Error(`启用 AI 能力时必须填写:${missingAiFields.join('、')}`) + throw new Error( + getMessage(locale, 'api.errors.settings.aiFieldsRequired', { + fields: missingAiFields.join(locale === 'en-US' ? ', ' : '、') + }) + ) } } @@ -154,9 +196,12 @@ export async function settingsRoutes(app: FastifyInstance) { }) app.patch('/settings', async (request, reply) => { + const locale = request.locale ?? detectRequestLocale(request) const parsed = SettingsSchema.partial().safeParse(request.body) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid settings payload', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidSettingsPayload', parsed.error.flatten(), { + locale + }) } const currentSettings = await getAppSettings() @@ -165,7 +210,9 @@ export async function settingsRoutes(app: FastifyInstance) { try { normalizedReminderSettings = normalizeReminderSettingsPayload(parsed.data, currentSettings) } catch (error) { - return sendError(reply, 422, 'validation_error', error instanceof Error ? error.message : 'Invalid reminder rules') + return sendError(reply, 422, 'validation_error', error instanceof Error ? error.message : 'api.errors.validation.invalidReminderRules', undefined, { + locale + }) } const nextSettings = { @@ -198,16 +245,20 @@ export async function settingsRoutes(app: FastifyInstance) { } try { - validateSettingsPayload(nextSettings) + validateSettingsPayload(nextSettings, locale) } catch (error) { - return sendError(reply, 422, 'validation_error', error instanceof Error ? error.message : 'Invalid settings payload') + return sendError(reply, 422, 'validation_error', error instanceof Error ? error.message : 'api.errors.validation.invalidSettingsPayload', undefined, { + locale + }) } if ( parsed.data.forgotPasswordEnabled === true && !hasDirectForgotPasswordChannelEnabled(nextSettings) ) { - return sendError(reply, 422, 'validation_error', '请先启用至少一个可直达的通知渠道,再开启忘记密码') + return sendError(reply, 422, 'validation_error', 'api.errors.auth.forgotPasswordChannelRequired', undefined, { + locale + }) } if (!hasDirectForgotPasswordChannelEnabled(nextSettings)) { diff --git a/apps/api/src/routes/subscriptions.ts b/apps/api/src/routes/subscriptions.ts index 1ee95d3..cf593a9 100644 --- a/apps/api/src/routes/subscriptions.ts +++ b/apps/api/src/routes/subscriptions.ts @@ -3,12 +3,14 @@ import type { Prisma } from '@prisma/client' import { z } from 'zod' import { prisma } from '../db' import { sendCreated, sendError, sendOk } from '../http' +import type { AppLocale } from '@subtracker/shared' import { CreateSubscriptionSchema, LogoSearchSchema, LogoUploadSchema, RenewSubscriptionSchema, - UpdateSubscriptionSchema + UpdateSubscriptionSchema, + getMessage } from '@subtracker/shared' import { appendSubscriptionOrder, @@ -89,6 +91,17 @@ function parseBatchStatus(input: unknown) { .safeParse(input) } +function getSubscriptionValidationMessageKey(error: string) { + switch (error) { + case 'Only active subscriptions can be paused in batch mode': + return 'api.errors.subscriptions.batchPauseOnlyActive' + case 'Only active subscriptions can be cancelled in batch mode': + return 'api.errors.subscriptions.batchCancelOnlyActive' + default: + return error + } +} + function normalizeSubscriptionPayloadWebsiteUrl>( payload: T ): { payload: T; websiteUrlError: string | null } { @@ -114,6 +127,7 @@ async function runBatchAction( ids: string[], action: (id: string) => Promise, options?: { + locale?: AppLocale validate?: (rows: Array<{ id: string; status: string }>) => string | null } ) { @@ -136,7 +150,7 @@ async function runBatchAction( failures: [ { id: missingId ?? 'unknown', - message: 'Subscription not found' + message: 'api.errors.subscriptions.notFound' } ] } @@ -149,7 +163,7 @@ async function runBatchAction( failureCount: ids.length, failures: ids.map((id) => ({ id, - message: validationError + message: getSubscriptionValidationMessageKey(validationError) })) } } @@ -164,7 +178,7 @@ async function runBatchAction( } catch (error) { failures.push({ id, - message: error instanceof Error ? error.message : 'Unknown error' + message: error instanceof Error ? error.message : getMessage(options?.locale ?? 'zh-CN', 'common.errors.requestFailed') }) } } @@ -205,7 +219,9 @@ export async function subscriptionRoutes(app: FastifyInstance) { app.post('/subscriptions/logo/search', async (request, reply) => { const parsed = LogoSearchSchema.safeParse(request.body) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid logo search payload', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidLogoSearchPayload', parsed.error.flatten(), { + locale: request.locale + }) } return sendOk(reply, await searchSubscriptionLogos(parsed.data)) @@ -218,26 +234,34 @@ export async function subscriptionRoutes(app: FastifyInstance) { app.delete('/subscriptions/logo/library/:filename', async (request, reply) => { const parsed = z.object({ filename: z.string().min(1).max(255) }).safeParse(request.params) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid logo filename', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidLogoFilename', parsed.error.flatten(), { + locale: request.locale + }) } try { return sendOk(reply, await deleteLocalLogoFromLibrary(parsed.data.filename)) } catch (error) { - return sendError(reply, 400, 'logo_delete_failed', error instanceof Error ? error.message : 'Logo delete failed') + return sendError(reply, 400, 'logo_delete_failed', error instanceof Error ? error.message : 'api.errors.subscriptions.logoDeleteFailed', undefined, { + locale: request.locale + }) } }) app.post('/subscriptions/logo/upload', async (request, reply) => { const parsed = LogoUploadSchema.safeParse(request.body) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid logo upload payload', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidLogoUploadPayload', parsed.error.flatten(), { + locale: request.locale + }) } try { return sendOk(reply, await saveUploadedLogo(parsed.data)) } catch (error) { - return sendError(reply, 400, 'logo_upload_failed', error instanceof Error ? error.message : 'Logo upload failed') + return sendError(reply, 400, 'logo_upload_failed', error instanceof Error ? error.message : 'api.errors.subscriptions.logoUploadFailed', undefined, { + locale: request.locale + }) } }) @@ -248,13 +272,17 @@ export async function subscriptionRoutes(app: FastifyInstance) { }).safeParse(request.body) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid logo import payload', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidLogoImportPayload', parsed.error.flatten(), { + locale: request.locale + }) } try { return sendOk(reply, await importRemoteLogo(parsed.data)) } catch (error) { - return sendError(reply, 400, 'logo_import_failed', error instanceof Error ? error.message : 'Logo import failed') + return sendError(reply, 400, 'logo_import_failed', error instanceof Error ? error.message : 'api.errors.subscriptions.logoImportFailed', undefined, { + locale: request.locale + }) } }) @@ -267,7 +295,9 @@ export async function subscriptionRoutes(app: FastifyInstance) { const parsed = querySchema.safeParse(request.query) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid query', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidQuery', parsed.error.flatten(), { + locale: request.locale + }) } const where: Record = {} @@ -313,7 +343,9 @@ export async function subscriptionRoutes(app: FastifyInstance) { }).safeParse(request.body) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid reorder payload', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidReorderPayload', parsed.error.flatten(), { + locale: request.locale + }) } await setSubscriptionOrder(parsed.data.ids) @@ -323,7 +355,9 @@ export async function subscriptionRoutes(app: FastifyInstance) { app.post('/subscriptions/batch/renew', async (request, reply) => { const parsed = parseBatchIds(request.body) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid batch renew payload', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidBatchRenewPayload', parsed.error.flatten(), { + locale: request.locale + }) } const result = await runBatchAction(parsed.data.ids, async (id) => { @@ -336,7 +370,9 @@ export async function subscriptionRoutes(app: FastifyInstance) { app.post('/subscriptions/batch/status', async (request, reply) => { const parsed = parseBatchStatus(request.body) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid batch status payload', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidBatchStatusPayload', parsed.error.flatten(), { + locale: request.locale + }) } const result = await runBatchAction(parsed.data.ids, async (id) => { @@ -352,7 +388,9 @@ export async function subscriptionRoutes(app: FastifyInstance) { app.post('/subscriptions/batch/pause', async (request, reply) => { const parsed = parseBatchIds(request.body) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid batch pause payload', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidBatchPausePayload', parsed.error.flatten(), { + locale: request.locale + }) } const result = await runBatchAction( @@ -364,8 +402,9 @@ export async function subscriptionRoutes(app: FastifyInstance) { }) }, { + locale: request.locale, validate: (rows) => - rows.some((row) => row.status !== 'active') ? 'Only active subscriptions can be paused in batch mode' : null + rows.some((row) => row.status !== 'active') ? 'api.errors.subscriptions.batchPauseOnlyActive' : null } ) @@ -375,7 +414,9 @@ export async function subscriptionRoutes(app: FastifyInstance) { app.post('/subscriptions/batch/cancel', async (request, reply) => { const parsed = parseBatchIds(request.body) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid batch cancel payload', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidBatchCancelPayload', parsed.error.flatten(), { + locale: request.locale + }) } const result = await runBatchAction( @@ -387,8 +428,9 @@ export async function subscriptionRoutes(app: FastifyInstance) { }) }, { + locale: request.locale, validate: (rows) => - rows.some((row) => row.status !== 'active') ? 'Only active subscriptions can be cancelled in batch mode' : null + rows.some((row) => row.status !== 'active') ? 'api.errors.subscriptions.batchCancelOnlyActive' : null } ) @@ -398,7 +440,9 @@ export async function subscriptionRoutes(app: FastifyInstance) { app.post('/subscriptions/batch/delete', async (request, reply) => { const parsed = parseBatchIds(request.body) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid batch delete payload', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidBatchDeletePayload', parsed.error.flatten(), { + locale: request.locale + }) } const rows = await prisma.subscription.findMany({ @@ -414,10 +458,12 @@ export async function subscriptionRoutes(app: FastifyInstance) { if (rows.length !== parsed.data.ids.length) { const existing = new Set(rows.map((item) => item.id)) const missingId = parsed.data.ids.find((id) => !existing.has(id)) - return sendError(reply, 404, 'not_found', 'Subscription not found', { + return sendError(reply, 404, 'not_found', 'api.errors.subscriptions.notFound', { successCount: 0, failureCount: 1, - failures: [{ id: missingId ?? 'unknown', message: 'Subscription not found' }] + failures: [{ id: missingId ?? 'unknown', message: 'api.errors.subscriptions.notFound' }] + }, { + locale: request.locale }) } @@ -428,7 +474,7 @@ export async function subscriptionRoutes(app: FastifyInstance) { if (row.status === 'active') { failures.push({ id: row.id, - message: 'Active subscriptions cannot be deleted directly' + message: 'api.errors.subscriptions.activeDeleteBlocked' }) continue } @@ -442,7 +488,7 @@ export async function subscriptionRoutes(app: FastifyInstance) { } catch (error) { failures.push({ id: row.id, - message: error instanceof Error ? error.message : 'Unknown error' + message: error instanceof Error ? error.message : getMessage(request.locale ?? 'zh-CN', 'common.errors.requestFailed') }) } } @@ -457,7 +503,9 @@ export async function subscriptionRoutes(app: FastifyInstance) { app.get('/subscriptions/:id', async (request, reply) => { const params = z.object({ id: z.string() }).safeParse(request.params) if (!params.success) { - return sendError(reply, 422, 'validation_error', 'Invalid subscription id') + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidSubscriptionId', undefined, { + locale: request.locale + }) } const row = await prisma.subscription.findUnique({ @@ -466,7 +514,9 @@ export async function subscriptionRoutes(app: FastifyInstance) { }) if (!row) { - return sendError(reply, 404, 'not_found', 'Subscription not found') + return sendError(reply, 404, 'not_found', 'api.errors.subscriptions.notFound', undefined, { + locale: request.locale + }) } const timezone = await getAppTimezone() @@ -477,7 +527,9 @@ export async function subscriptionRoutes(app: FastifyInstance) { app.get('/subscriptions/:id/payment-records', async (request, reply) => { const params = z.object({ id: z.string() }).safeParse(request.params) if (!params.success) { - return sendError(reply, 422, 'validation_error', 'Invalid subscription id') + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidSubscriptionId', undefined, { + locale: request.locale + }) } const records = await prisma.paymentRecord.findMany({ @@ -491,16 +543,20 @@ export async function subscriptionRoutes(app: FastifyInstance) { app.post('/subscriptions', async (request, reply) => { const normalizedPayload = normalizeSubscriptionPayloadWebsiteUrl((request.body ?? {}) as Record) if (normalizedPayload.websiteUrlError) { - return sendError(reply, 422, 'validation_error', 'websiteUrl 格式无效,请填写合法网址', { + return sendError(reply, 422, 'validation_error', 'api.errors.subscriptions.websiteUrlInvalid', { fieldErrors: { websiteUrl: [normalizedPayload.websiteUrlError] } + }, { + locale: request.locale }) } const parsed = CreateSubscriptionSchema.safeParse(normalizedPayload.payload) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid subscription payload', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidSubscriptionPayload', parsed.error.flatten(), { + locale: request.locale + }) } let normalizedLogo @@ -510,7 +566,9 @@ export async function subscriptionRoutes(app: FastifyInstance) { logoSource: parsed.data.logoSource ?? null }) } catch (error) { - return sendError(reply, 400, 'logo_import_failed', error instanceof Error ? error.message : 'Logo import failed') + return sendError(reply, 400, 'logo_import_failed', error instanceof Error ? error.message : 'api.errors.subscriptions.logoImportFailed', undefined, { + locale: request.locale + }) } const tagIds = normalizeTagIds(parsed.data.tagIds) @@ -519,7 +577,9 @@ export async function subscriptionRoutes(app: FastifyInstance) { try { reminderFields = await resolveSubscriptionReminderFields(parsed.data) } catch (error) { - return sendError(reply, 422, 'validation_error', error instanceof Error ? error.message : 'Invalid reminder rules') + return sendError(reply, 422, 'validation_error', error instanceof Error ? error.message : 'api.errors.validation.invalidReminderRules', undefined, { + locale: request.locale + }) } const timezone = await getAppTimezone() @@ -566,21 +626,27 @@ export async function subscriptionRoutes(app: FastifyInstance) { app.patch('/subscriptions/:id', async (request, reply) => { const params = z.object({ id: z.string() }).safeParse(request.params) if (!params.success) { - return sendError(reply, 422, 'validation_error', 'Invalid subscription id') + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidSubscriptionId', undefined, { + locale: request.locale + }) } const normalizedPayload = normalizeSubscriptionPayloadWebsiteUrl((request.body ?? {}) as Record) if (normalizedPayload.websiteUrlError) { - return sendError(reply, 422, 'validation_error', 'websiteUrl 格式无效,请填写合法网址', { + return sendError(reply, 422, 'validation_error', 'api.errors.subscriptions.websiteUrlInvalid', { fieldErrors: { websiteUrl: [normalizedPayload.websiteUrlError] } + }, { + locale: request.locale }) } const parsed = UpdateSubscriptionSchema.safeParse(normalizedPayload.payload) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid update payload', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidUpdatePayload', parsed.error.flatten(), { + locale: request.locale + }) } const payload = parsed.data @@ -603,7 +669,7 @@ export async function subscriptionRoutes(app: FastifyInstance) { }) if (!existing) { - throw new Error('Subscription not found') + throw new Error('api.errors.subscriptions.notFound') } const normalizedNextRenewalDate = @@ -660,21 +726,29 @@ export async function subscriptionRoutes(app: FastifyInstance) { return sendOk(reply, flattenSubscriptionTags(updated)) } catch (error) { if (error instanceof Error && error.message.includes('Logo')) { - return sendError(reply, 400, 'logo_import_failed', error.message) + return sendError(reply, 400, 'logo_import_failed', error.message, undefined, { + locale: request.locale + }) } - return sendError(reply, 404, 'not_found', 'Subscription not found') + return sendError(reply, 404, 'not_found', 'api.errors.subscriptions.notFound', undefined, { + locale: request.locale + }) } }) app.post('/subscriptions/:id/renew', async (request, reply) => { const params = z.object({ id: z.string() }).safeParse(request.params) if (!params.success) { - return sendError(reply, 422, 'validation_error', 'Invalid subscription id') + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidSubscriptionId', undefined, { + locale: request.locale + }) } const parsed = RenewSubscriptionSchema.safeParse(request.body ?? {}) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid renew payload', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidRenewPayload', parsed.error.flatten(), { + locale: request.locale + }) } try { @@ -688,14 +762,18 @@ export async function subscriptionRoutes(app: FastifyInstance) { return sendOk(reply, result) } catch (error) { - return sendError(reply, 404, 'not_found', error instanceof Error ? error.message : 'Renew failed') + return sendError(reply, 404, 'not_found', error instanceof Error ? error.message : 'api.errors.subscriptions.renewFailed', undefined, { + locale: request.locale + }) } }) app.post('/subscriptions/:id/pause', async (request, reply) => { const params = z.object({ id: z.string() }).safeParse(request.params) if (!params.success) { - return sendError(reply, 422, 'validation_error', 'Invalid subscription id') + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidSubscriptionId', undefined, { + locale: request.locale + }) } const updated = await prisma.subscription.update({ @@ -709,7 +787,9 @@ export async function subscriptionRoutes(app: FastifyInstance) { app.post('/subscriptions/:id/cancel', async (request, reply) => { const params = z.object({ id: z.string() }).safeParse(request.params) if (!params.success) { - return sendError(reply, 422, 'validation_error', 'Invalid subscription id') + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidSubscriptionId', undefined, { + locale: request.locale + }) } const updated = await prisma.subscription.update({ @@ -723,7 +803,9 @@ export async function subscriptionRoutes(app: FastifyInstance) { app.delete('/subscriptions/:id', async (request, reply) => { const params = z.object({ id: z.string() }).safeParse(request.params) if (!params.success) { - return sendError(reply, 422, 'validation_error', 'Invalid subscription id') + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidSubscriptionId', undefined, { + locale: request.locale + }) } try { @@ -733,11 +815,15 @@ export async function subscriptionRoutes(app: FastifyInstance) { }) if (!existing) { - return sendError(reply, 404, 'not_found', 'Subscription not found') + return sendError(reply, 404, 'not_found', 'api.errors.subscriptions.notFound', undefined, { + locale: request.locale + }) } if (existing.status === 'active') { - return sendError(reply, 422, 'subscription_delete_not_allowed', '正常中的订阅不能直接删除,请先暂停或停用') + return sendError(reply, 422, 'subscription_delete_not_allowed', 'api.errors.subscriptions.activeDeleteNotAllowed', undefined, { + locale: request.locale + }) } await prisma.subscription.delete({ @@ -747,7 +833,9 @@ export async function subscriptionRoutes(app: FastifyInstance) { await removeSubscriptionOrder(params.data.id) return sendOk(reply, { id: params.data.id, deleted: true }) } catch { - return sendError(reply, 404, 'not_found', 'Subscription not found') + return sendError(reply, 404, 'not_found', 'api.errors.subscriptions.notFound', undefined, { + locale: request.locale + }) } }) } diff --git a/apps/api/src/routes/tags.ts b/apps/api/src/routes/tags.ts index 199807b..18ad9ed 100644 --- a/apps/api/src/routes/tags.ts +++ b/apps/api/src/routes/tags.ts @@ -13,7 +13,9 @@ export async function tagRoutes(app: FastifyInstance) { app.post('/tags', async (request, reply) => { const parsed = TagSchema.safeParse(request.body) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid tag payload', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidTagPayload', parsed.error.flatten(), { + locale: request.locale + }) } try { @@ -27,19 +29,25 @@ export async function tagRoutes(app: FastifyInstance) { }) return sendCreated(reply, created) } catch (error) { - return sendError(reply, 409, 'conflict', 'Tag name already exists', error) + return sendError(reply, 409, 'conflict', 'api.errors.tags.nameExists', error, { + locale: request.locale + }) } }) app.patch('/tags/:id', async (request, reply) => { const params = z.object({ id: z.string() }).safeParse(request.params) if (!params.success) { - return sendError(reply, 422, 'validation_error', 'Invalid tag id') + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidTagId', undefined, { + locale: request.locale + }) } const parsed = TagSchema.partial().refine((value) => Object.keys(value).length > 0, 'Empty update payload').safeParse(request.body) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid tag payload', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidTagPayload', parsed.error.flatten(), { + locale: request.locale + }) } try { @@ -54,14 +62,18 @@ export async function tagRoutes(app: FastifyInstance) { }) return sendOk(reply, updated) } catch (error) { - return sendError(reply, 409, 'conflict', 'Tag update failed', error) + return sendError(reply, 409, 'conflict', 'api.errors.tags.updateFailed', error, { + locale: request.locale + }) } }) app.delete('/tags/:id', async (request, reply) => { const params = z.object({ id: z.string() }).safeParse(request.params) if (!params.success) { - return sendError(reply, 422, 'validation_error', 'Invalid tag id') + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidTagId', undefined, { + locale: request.locale + }) } try { @@ -77,7 +89,9 @@ export async function tagRoutes(app: FastifyInstance) { return sendOk(reply, { id: params.data.id, deleted: true }) } catch (error) { - return sendError(reply, 404, 'not_found', 'Tag not found', error) + return sendError(reply, 404, 'not_found', 'api.errors.tags.notFound', error, { + locale: request.locale + }) } }) } diff --git a/apps/api/src/routes/version.ts b/apps/api/src/routes/version.ts index 7bc34b3..786944c 100644 --- a/apps/api/src/routes/version.ts +++ b/apps/api/src/routes/version.ts @@ -12,14 +12,18 @@ export async function versionRoutes(app: FastifyInstance) { .safeParse(request.query) if (!parsed.success) { - return sendError(reply, 422, 'validation_error', 'Invalid currentVersion query', parsed.error.flatten()) + return sendError(reply, 422, 'validation_error', 'api.errors.validation.invalidCurrentVersionQuery', parsed.error.flatten(), { + locale: request.locale + }) } try { const summary = await getVersionUpdateSummary(parsed.data.currentVersion) return sendOk(reply, summary) } catch (error) { - return sendError(reply, 502, 'version_update_fetch_failed', error instanceof Error ? error.message : 'Failed to fetch releases') + return sendError(reply, 502, 'version_update_fetch_failed', error instanceof Error ? error.message : 'api.errors.version.updateFetchFailed', undefined, { + locale: request.locale + }) } }) } diff --git a/apps/api/src/services/ai-summary.service.ts b/apps/api/src/services/ai-summary.service.ts index 5a008ef..d4f738a 100644 --- a/apps/api/src/services/ai-summary.service.ts +++ b/apps/api/src/services/ai-summary.service.ts @@ -1,14 +1,14 @@ import crypto from 'node:crypto' import { - DEFAULT_AI_DASHBOARD_SUMMARY_PREVIEW_PROMPT, - DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT, formatAiSummaryPreviewText, + getDefaultAiDashboardSummaryPreviewPrompt, + getDefaultAiDashboardSummaryPrompt, type AiDashboardSummaryDto, type DashboardOverview } from '@subtracker/shared' import { ensureAiSummaryConfig } from './ai.service' import { getOverviewStatistics } from './statistics.service' -import { getAiConfig } from './settings.service' +import { getAiConfig, getSystemDefaultLocale } from './settings.service' type CachedDashboardSummary = { scope: 'dashboard-overview' @@ -59,14 +59,14 @@ function logAiSummary(stage: string, details?: Record) { console.log(`[ai-summary] ${stage}`, details) } -function resolveDashboardSummaryPrompt(promptTemplate?: string | null) { +async function resolveDashboardSummaryPrompt(promptTemplate?: string | null) { const normalized = String(promptTemplate ?? '').trim() - return normalized || DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT + return normalized || getDefaultAiDashboardSummaryPrompt(await getSystemDefaultLocale()) } -function resolveDashboardSummaryPreviewPrompt() { - return DEFAULT_AI_DASHBOARD_SUMMARY_PREVIEW_PROMPT +async function resolveDashboardSummaryPreviewPrompt() { + return getDefaultAiDashboardSummaryPreviewPrompt(await getSystemDefaultLocale()) } function extractChatCompletionText(payload: ChatCompletionPayload) { @@ -208,7 +208,7 @@ async function requestDashboardSummaryPreviewMarkdown(params: { messages: [ { role: 'system', - content: resolveDashboardSummaryPreviewPrompt() + content: await resolveDashboardSummaryPreviewPrompt() }, { role: 'user', @@ -261,7 +261,7 @@ async function requestDashboardSummaryMarkdown(params: { messages: [ { role: 'system', - content: resolveDashboardSummaryPrompt(params.promptTemplate) + content: await resolveDashboardSummaryPrompt(params.promptTemplate) }, { role: 'user', diff --git a/apps/api/src/services/ai.service.ts b/apps/api/src/services/ai.service.ts index b62cdae..af6e0a4 100644 --- a/apps/api/src/services/ai.service.ts +++ b/apps/api/src/services/ai.service.ts @@ -1,9 +1,9 @@ import { mkdir } from 'node:fs/promises' import path from 'node:path' import { createWorker, type Worker } from 'tesseract.js' -import { AiRecognizeSubscriptionSchema, DEFAULT_AI_SUBSCRIPTION_PROMPT } from '@subtracker/shared' +import { AiRecognizeSubscriptionSchema, getDefaultAiSubscriptionPrompt, type AppLocale } from '@subtracker/shared' import type { AiRecognitionResultDto } from '@subtracker/shared' -import { getAiConfig } from './settings.service' +import { getAiConfig, getSystemDefaultLocale } from './settings.service' export type AiSettings = Awaited> @@ -115,8 +115,9 @@ async function extractTextFromImageWithOcr(imageBase64: string) { return (result.data.text || '').trim() } -function buildRecognitionSystemPrompt(aiConfig: AiSettings, forceJsonPromptOnly = false) { - const basePrompt = aiConfig.promptTemplate?.trim() || DEFAULT_AI_SUBSCRIPTION_PROMPT +async function buildRecognitionSystemPrompt(aiConfig: AiSettings, forceJsonPromptOnly = false, locale?: AppLocale) { + const resolvedLocale = locale ?? (await getSystemDefaultLocale()) + const basePrompt = aiConfig.promptTemplate?.trim() || getDefaultAiSubscriptionPrompt(resolvedLocale) if (!forceJsonPromptOnly) { return basePrompt } @@ -175,7 +176,7 @@ async function requestStructuredJsonCompletion(params: { messages: [ { role: 'system', - content: buildRecognitionSystemPrompt(params.aiConfig, promptOnlyJson) + content: await buildRecognitionSystemPrompt(params.aiConfig, promptOnlyJson) }, { role: 'user', diff --git a/apps/api/src/services/channel-notification.service.ts b/apps/api/src/services/channel-notification.service.ts index 432fb68..9f3b996 100644 --- a/apps/api/src/services/channel-notification.service.ts +++ b/apps/api/src/services/channel-notification.service.ts @@ -3,6 +3,9 @@ import https from 'node:https' import nodemailer from 'nodemailer' import { DEFAULT_RESEND_API_URL, + DEFAULT_APP_LOCALE, + getMessage, + type AppLocale, type EmailConfigInput, type GotifyConfigInput, type ResendConfigInput, @@ -12,7 +15,7 @@ import { type WebhookEventType } from '@subtracker/shared' import { dispatchWebhookEvent } from './webhook.service' -import { getAppTimezone, getNotificationChannelSettings, getSetting, setSetting } from './settings.service' +import { getAppTimezone, getNotificationChannelSettings, getSetting, getSystemDefaultLocale, setSetting } from './settings.service' import { validateNotificationTargetUrl } from './notification-url.service' import { toIsoDate } from '../utils/date' import { formatDateInTimezone } from '../utils/timezone' @@ -58,6 +61,18 @@ type ForgotPasswordNotificationPayload = { expiresInMinutes: number } +type NotificationLocaleContext = { + locale?: AppLocale +} + +type DirectChannelDispatchOptions = { + channel: 'email' | 'pushplus' | 'telegram' | 'serverchan' | 'gotify' + enabled: boolean + disabledMessage: string + alreadySentMessage: string + send: (message: DirectNotificationMessage) => Promise +} + const NOTIFICATION_DEDUP_KEY_PREFIX = 'notification:' export const NOTIFICATION_DEDUP_RETENTION_DAYS = 30 @@ -87,19 +102,8 @@ export async function cleanupOldNotificationDedupSettings( return result.count } -const CHANNEL_LABELS: Record = { - webhook: 'Webhook', - email: '邮箱', - pushplus: 'PushPlus', - telegram: 'Telegram', - serverchan: 'Server 酱', - gotify: 'Gotify' -} - -const CHANNEL_STATUS_LABELS: Record = { - success: '成功', - skipped: '跳过', - failed: '失败' +async function resolveNotificationLocale(locale?: AppLocale): Promise { + return locale ?? (await getSystemDefaultLocale().catch(() => DEFAULT_APP_LOCALE)) } function getNotificationLogName(params: NotificationDispatchParams) { @@ -107,17 +111,17 @@ function getNotificationLogName(params: NotificationDispatchParams) { return typeof name === 'string' && name.trim() ? name.trim() : params.resourceKey } -function formatChannelResult(result: NotificationChannelResult) { - const label = CHANNEL_LABELS[result.channel] - const status = CHANNEL_STATUS_LABELS[result.status] +function formatChannelResult(result: NotificationChannelResult, locale: AppLocale) { + const label = getMessage(locale, `notifications.channels.${result.channel}`) + const status = getMessage(locale, `notifications.status.${result.status}`) return result.message ? `${label}${status}(${result.message})` : `${label}${status}` } -function logNotificationDispatch(params: NotificationDispatchParams, results: NotificationChannelResult[]) { +function logNotificationDispatch(params: NotificationDispatchParams, results: NotificationChannelResult[], locale: AppLocale) { const successCount = results.filter((result) => result.status === 'success').length const failed = results.filter((result) => result.status === 'failed') const skipped = results.filter((result) => result.status === 'skipped') - const details = results.map(formatChannelResult).join(';') + const details = results.map((result) => formatChannelResult(result, locale)).join(';') const baseMessage = `[notification] ${getNotificationLogName(params)}:通知渠道 ${successCount} 个成功,${failed.length} 个失败,${skipped.length} 个跳过。${details}` if (failed.length) { @@ -200,28 +204,19 @@ function resolveDispatchParamsForChannel( }) } -function buildForgotPasswordTitle() { - return 'SubTracker 密码重置验证码' +function buildForgotPasswordTitle(locale: AppLocale) { + return getMessage(locale, 'notifications.forgotPassword.title') } -function buildForgotPasswordBody(payload: ForgotPasswordNotificationPayload) { +function buildForgotPasswordBody(payload: ForgotPasswordNotificationPayload, locale: AppLocale) { return [ - `用户名:${payload.username}`, - `验证码:${payload.code}`, - `有效期:${payload.expiresInMinutes} 分钟`, - '如果这不是你的操作,请忽略本次通知。' + getMessage(locale, 'notifications.forgotPassword.username', { username: payload.username }), + getMessage(locale, 'notifications.forgotPassword.code', { code: payload.code }), + getMessage(locale, 'notifications.forgotPassword.expiresInMinutes', { minutes: payload.expiresInMinutes }), + getMessage(locale, 'notifications.forgotPassword.ignoreHint') ].join('\n') } -function buildForgotPasswordMessage(payload: ForgotPasswordNotificationPayload): DirectNotificationMessage { - const text = buildForgotPasswordBody(payload) - return { - title: buildForgotPasswordTitle(), - text, - html: `
${text}
` - } -} - async function sendSmtpEmailWithConfig(message: DirectNotificationMessage, config: EmailConfigInput) { const { host, port, secure, username, password, from, to } = config if (!host || !port || !username || !password || !from || !to) { @@ -293,14 +288,6 @@ async function sendEmailWithProvider( await sendSmtpEmailWithConfig(message, smtpConfig) } -type DirectChannelDispatchOptions = { - channel: 'email' | 'pushplus' | 'telegram' | 'serverchan' | 'gotify' - enabled: boolean - disabledMessage: string - alreadySentMessage: string - send: (message: DirectNotificationMessage) => Promise -} - async function dispatchDirectChannelNotification( params: NotificationDispatchParams, options: DirectChannelDispatchOptions @@ -332,7 +319,9 @@ async function dispatchDirectChannelNotification( } } -async function sendEmailNotification(params: NotificationDispatchParams): Promise { +async function sendEmailNotification( + params: NotificationDispatchParams +): Promise { const settings = await getNotificationChannelSettings() return dispatchDirectChannelNotification(params, { channel: 'email', @@ -409,7 +398,9 @@ async function sendPushplusWithConfig( } } -async function sendPushplusNotification(params: NotificationDispatchParams): Promise { +async function sendPushplusNotification( + params: NotificationDispatchParams +): Promise { const settings = await getNotificationChannelSettings() return dispatchDirectChannelNotification(params, { channel: 'pushplus', @@ -454,7 +445,9 @@ async function sendTelegramWithConfig(message: DirectNotificationMessage, config } } -async function sendTelegramNotification(params: NotificationDispatchParams): Promise { +async function sendTelegramNotification( + params: NotificationDispatchParams +): Promise { const settings = await getNotificationChannelSettings() return dispatchDirectChannelNotification(params, { channel: 'telegram', @@ -514,7 +507,9 @@ async function sendServerchanWithConfig(message: DirectNotificationMessage, conf } } -async function sendServerchanNotification(params: NotificationDispatchParams): Promise { +async function sendServerchanNotification( + params: NotificationDispatchParams +): Promise { const settings = await getNotificationChannelSettings() return dispatchDirectChannelNotification(params, { channel: 'serverchan', @@ -573,7 +568,9 @@ async function sendGotifyWithConfig(message: DirectNotificationMessage, config: }) } -async function sendGotifyNotification(params: NotificationDispatchParams): Promise { +async function sendGotifyNotification( + params: NotificationDispatchParams +): Promise { const settings = await getNotificationChannelSettings() return dispatchDirectChannelNotification(params, { channel: 'gotify', @@ -584,11 +581,15 @@ async function sendGotifyNotification(params: NotificationDispatchParams): Promi }) } -export async function dispatchNotificationEvent(params: NotificationDispatchParams) { +export async function dispatchNotificationEvent( + params: NotificationDispatchParams, + context: NotificationLocaleContext = {} +) { const results: NotificationChannelResult[] = [] + const locale = await resolveNotificationLocale(context.locale) try { - const webhookResult = await dispatchWebhookEvent(params) + const webhookResult = await dispatchWebhookEvent(params, { locale }) results.push(webhookResult) } catch (error) { results.push({ @@ -633,52 +634,66 @@ export async function dispatchNotificationEvent(params: NotificationDispatchPara }))) as NotificationChannelResult results.push(gotifyResult) - logNotificationDispatch(params, results) + logNotificationDispatch(params, results, locale) return results } -function buildTestReminderPayload() { +function buildTestReminderPayload(locale: AppLocale) { return { - name: '测试订阅', + name: getMessage(locale, 'notifications.tests.subscriptionName'), nextRenewalDate: '', amount: 19.9, currency: 'CNY', - tagNames: ['测试标签'], + tagNames: [getMessage(locale, 'notifications.tests.tagName')], websiteUrl: 'https://example.com', - notes: '这是一条测试通知', + notes: getMessage(locale, 'notifications.tests.note'), phase: 'upcoming', daysUntilRenewal: 3, daysOverdue: 0 } } -async function buildTestReminderMessage() { +async function buildTestReminderMessage(locale: AppLocale) { const timezone = await getAppTimezone() return buildNotificationMessage({ eventType: 'subscription.reminder_due', resourceKey: 'test:notification', periodKey: `${toIsoDate(new Date(), timezone)}:upcoming`, payload: { - ...buildTestReminderPayload(), + ...buildTestReminderPayload(locale), nextRenewalDate: formatDateInTimezone(new Date(), timezone) } }) } -export async function sendTestEmailNotification() { +export async function sendTestEmailNotification(context: NotificationLocaleContext = {}) { const settings = await getNotificationChannelSettings() if (!settings.emailNotificationsEnabled) { throw new Error('邮箱通知未启用或配置不完整') } - await sendEmailWithProvider(await buildTestReminderMessage(), settings.emailProvider, settings.smtpConfig, settings.resendConfig) + const locale = await resolveNotificationLocale(context.locale) + await sendEmailWithProvider( + await buildTestReminderMessage(locale), + settings.emailProvider, + settings.smtpConfig, + settings.resendConfig + ) } -export async function sendForgotPasswordVerificationCode(payload: ForgotPasswordNotificationPayload) { +export async function sendForgotPasswordVerificationCode( + payload: ForgotPasswordNotificationPayload, + context: NotificationLocaleContext = {} +) { const settings = await getNotificationChannelSettings() const results: NotificationChannelResult[] = [] - const message = buildForgotPasswordMessage(payload) + const locale = await resolveNotificationLocale(context.locale) + const message: DirectNotificationMessage = { + title: buildForgotPasswordTitle(locale), + text: buildForgotPasswordBody(payload, locale), + html: `
${buildForgotPasswordBody(payload, locale)}
` + } if (settings.emailNotificationsEnabled) { try { @@ -762,17 +777,24 @@ export async function sendTestEmailNotificationWithConfig(config: { emailProvider: 'smtp' | 'resend' smtpConfig: EmailConfigInput resendConfig: ResendConfigInput -}) { - await sendEmailWithProvider(await buildTestReminderMessage(), config.emailProvider, config.smtpConfig, config.resendConfig) +}, context: NotificationLocaleContext = {}) { + const locale = await resolveNotificationLocale(context.locale) + await sendEmailWithProvider( + await buildTestReminderMessage(locale), + config.emailProvider, + config.smtpConfig, + config.resendConfig + ) } -export async function sendTestPushplusNotification() { +export async function sendTestPushplusNotification(context: NotificationLocaleContext = {}) { const settings = await getNotificationChannelSettings() if (!settings.pushplusNotificationsEnabled) { throw new Error('PushPlus 通知未启用或配置不完整') } - await sendPushplusWithConfig(await buildTestReminderMessage(), settings.pushplusConfig) + const locale = await resolveNotificationLocale(context.locale) + await sendPushplusWithConfig(await buildTestReminderMessage(locale), settings.pushplusConfig) return { accepted: true, @@ -780,57 +802,76 @@ export async function sendTestPushplusNotification() { } } -export async function sendTestPushplusNotificationWithConfig(config: PushPlusConfigInput) { - return sendPushplusWithConfig(await buildTestReminderMessage(), config) +export async function sendTestPushplusNotificationWithConfig( + config: PushPlusConfigInput, + context: NotificationLocaleContext = {} +) { + const locale = await resolveNotificationLocale(context.locale) + return sendPushplusWithConfig(await buildTestReminderMessage(locale), config) } -export async function sendTestTelegramNotification() { +export async function sendTestTelegramNotification(context: NotificationLocaleContext = {}) { const settings = await getNotificationChannelSettings() if (!settings.telegramNotificationsEnabled) { throw new Error('Telegram 通知未启用或配置不完整') } - await sendTelegramWithConfig(await buildTestReminderMessage(), settings.telegramConfig) + const locale = await resolveNotificationLocale(context.locale) + await sendTelegramWithConfig(await buildTestReminderMessage(locale), settings.telegramConfig) return { success: true } } -export async function sendTestTelegramNotificationWithConfig(config: TelegramConfigInput) { - await sendTelegramWithConfig(await buildTestReminderMessage(), config) +export async function sendTestTelegramNotificationWithConfig( + config: TelegramConfigInput, + context: NotificationLocaleContext = {} +) { + const locale = await resolveNotificationLocale(context.locale) + await sendTelegramWithConfig(await buildTestReminderMessage(locale), config) return { success: true } } -export async function sendTestServerchanNotification() { +export async function sendTestServerchanNotification(context: NotificationLocaleContext = {}) { const settings = await getNotificationChannelSettings() if (!settings.serverchanNotificationsEnabled) { throw new Error('Server 酱通知未启用或配置不完整') } - await sendServerchanWithConfig(await buildTestReminderMessage(), settings.serverchanConfig) + const locale = await resolveNotificationLocale(context.locale) + await sendServerchanWithConfig(await buildTestReminderMessage(locale), settings.serverchanConfig) return { success: true } } -export async function sendTestServerchanNotificationWithConfig(config: ServerchanConfigInput) { - await sendServerchanWithConfig(await buildTestReminderMessage(), config) +export async function sendTestServerchanNotificationWithConfig( + config: ServerchanConfigInput, + context: NotificationLocaleContext = {} +) { + const locale = await resolveNotificationLocale(context.locale) + await sendServerchanWithConfig(await buildTestReminderMessage(locale), config) return { success: true } } -export async function sendTestGotifyNotification() { +export async function sendTestGotifyNotification(context: NotificationLocaleContext = {}) { const settings = await getNotificationChannelSettings() if (!settings.gotifyNotificationsEnabled) { throw new Error('Gotify 通知未启用或配置不完整') } - await sendGotifyWithConfig(await buildTestReminderMessage(), settings.gotifyConfig) + const locale = await resolveNotificationLocale(context.locale) + await sendGotifyWithConfig(await buildTestReminderMessage(locale), settings.gotifyConfig) return { success: true } } -export async function sendTestGotifyNotificationWithConfig(config: GotifyConfigInput) { - await sendGotifyWithConfig(await buildTestReminderMessage(), config) +export async function sendTestGotifyNotificationWithConfig( + config: GotifyConfigInput, + context: NotificationLocaleContext = {} +) { + const locale = await resolveNotificationLocale(context.locale) + await sendGotifyWithConfig(await buildTestReminderMessage(locale), config) return { success: true } } diff --git a/apps/api/src/services/forgot-password.service.ts b/apps/api/src/services/forgot-password.service.ts index cc3002f..70094ae 100644 --- a/apps/api/src/services/forgot-password.service.ts +++ b/apps/api/src/services/forgot-password.service.ts @@ -1,4 +1,5 @@ import { createHash, randomInt } from 'node:crypto' +import type { AppLocale } from '@subtracker/shared' import { getNotificationChannelSettings, getSetting, setSetting } from './settings.service' import { getStoredCredentials, resetPasswordForStoredUsername } from './auth.service' import { sendForgotPasswordVerificationCode } from './channel-notification.service' @@ -27,6 +28,33 @@ type StoredRateLimitRecord = { windowStartedAt: number } +type ForgotPasswordError = { + status: number + code: string + message: string + retryAfterSeconds?: number + messageParams?: Record +} + +type ForgotPasswordFailure = { + ok: false + error: ForgotPasswordError +} + +type ForgotPasswordRequestResult = + | { + ok: true + accepted: true + } + | ForgotPasswordFailure + +type ForgotPasswordResetResult = + | { + ok: true + result: NonNullable>> + } + | ForgotPasswordFailure + function hashVerificationCode(code: string) { return createHash('sha256').update(code).digest('hex') } @@ -95,15 +123,19 @@ export async function clearForgotPasswordChallenge() { await setSetting(FORGOT_PASSWORD_CHALLENGE_KEY, null) } -export async function requestForgotPasswordChallenge(username: string, remoteAddress: string) { +export async function requestForgotPasswordChallenge( + username: string, + remoteAddress: string, + locale?: AppLocale +): Promise { if (!(await isForgotPasswordEnabled())) { return { ok: false as const, error: { status: 403, code: 'forgot_password_disabled', - message: '当前未开启忘记密码,或未配置可用通知渠道' - } + message: 'api.errors.auth.forgotPasswordDisabled' + } satisfies ForgotPasswordError } } @@ -120,9 +152,9 @@ export async function requestForgotPasswordChallenge(username: string, remoteAdd error: { status: 429, code: 'forgot_password_request_rate_limited', - message: '验证码发送过于频繁,请稍后再试', + message: 'api.errors.auth.forgotPasswordRequestRateLimited', retryAfterSeconds - } + } satisfies ForgotPasswordError } } @@ -143,9 +175,9 @@ export async function requestForgotPasswordChallenge(username: string, remoteAdd error: { status: 429, code: 'forgot_password_request_cooldown', - message: '验证码刚刚发送过,请稍后再试', + message: 'api.errors.auth.forgotPasswordRequestCooldown', retryAfterSeconds: Math.max(1, Math.ceil(remainingCooldownMs / 1000)) - } + } satisfies ForgotPasswordError } } } @@ -155,7 +187,7 @@ export async function requestForgotPasswordChallenge(username: string, remoteAdd username: credentials.username, code, expiresInMinutes: CHALLENGE_TTL_MS / 60_000 - }) + }, { locale }) if (!dispatchResults.some((item) => item.status === 'success')) { return { @@ -163,8 +195,8 @@ export async function requestForgotPasswordChallenge(username: string, remoteAdd error: { status: 400, code: 'forgot_password_delivery_failed', - message: '验证码发送失败,请检查通知配置' - } + message: 'api.errors.auth.forgotPasswordDeliveryFailed' + } satisfies ForgotPasswordError } } @@ -187,15 +219,15 @@ export async function resetPasswordWithForgotPasswordCode(input: { code: string newPassword: string remoteAddress: string -}) { +}): Promise { if (!(await isForgotPasswordEnabled())) { return { ok: false as const, error: { status: 403, code: 'forgot_password_disabled', - message: '当前未开启忘记密码,或未配置可用通知渠道' - } + message: 'api.errors.auth.forgotPasswordDisabled' + } satisfies ForgotPasswordError } } @@ -212,9 +244,9 @@ export async function resetPasswordWithForgotPasswordCode(input: { error: { status: 429, code: 'forgot_password_reset_rate_limited', - message: '验证失败次数过多,请稍后再试', + message: 'api.errors.auth.forgotPasswordResetRateLimited', retryAfterSeconds - } + } satisfies ForgotPasswordError } } @@ -227,8 +259,8 @@ export async function resetPasswordWithForgotPasswordCode(input: { error: { status: 400, code: 'forgot_password_challenge_not_found', - message: '验证码无效或已失效' - } + message: 'api.errors.auth.forgotPasswordChallengeNotFound' + } satisfies ForgotPasswordError } } @@ -239,8 +271,8 @@ export async function resetPasswordWithForgotPasswordCode(input: { error: { status: 400, code: 'forgot_password_attempts_exhausted', - message: '验证码尝试次数已用尽,请重新获取' - } + message: 'api.errors.auth.forgotPasswordAttemptsExhausted' + } satisfies ForgotPasswordError } } @@ -260,8 +292,17 @@ export async function resetPasswordWithForgotPasswordCode(input: { error: { status: 400, code: 'forgot_password_code_invalid', - message: nextAttempts > 0 ? `验证码错误,还可重试 ${nextAttempts} 次` : '验证码错误次数过多,请重新获取' - } + message: + nextAttempts > 0 + ? 'api.errors.auth.forgotPasswordCodeInvalidWithAttempts' + : 'api.errors.auth.forgotPasswordCodeInvalid', + messageParams: + nextAttempts > 0 + ? { + attempts: nextAttempts + } + : undefined + } satisfies ForgotPasswordError } } @@ -272,8 +313,8 @@ export async function resetPasswordWithForgotPasswordCode(input: { error: { status: 400, code: 'forgot_password_reset_failed', - message: '密码重置失败' - } + message: 'api.errors.auth.forgotPasswordResetFailed' + } satisfies ForgotPasswordError } } diff --git a/apps/api/src/services/notification.service.ts b/apps/api/src/services/notification.service.ts index b265c54..3ec12df 100644 --- a/apps/api/src/services/notification.service.ts +++ b/apps/api/src/services/notification.service.ts @@ -1,4 +1,5 @@ import dayjs from 'dayjs' +import { type AppLocale } from '@subtracker/shared' import { prisma } from '../db' import { toIsoDate } from '../utils/date' import { dispatchNotificationEvent, type NotificationChannelResult } from './channel-notification.service' @@ -63,6 +64,7 @@ export type NotificationScanOverrides = Partial< > & { dryRun?: boolean includeDebugCandidates?: boolean + locale?: AppLocale } type ReminderSubscriptionLike = { @@ -106,8 +108,6 @@ type ReminderDebugCandidate = { }> } -type ReminderSummarySection = NotificationSummarySection & { phase: ReminderPhase } - type ReminderMatch = { eventType: 'subscription.reminder_due' | 'subscription.overdue' phase: ReminderPhase @@ -441,7 +441,7 @@ export async function scanRenewalNotifications( message: 'dry_run' } ] - : await dispatchNotificationEvent(buildDispatchParamsFromDedupEntries([entry])) + : await dispatchNotificationEvent(buildDispatchParamsFromDedupEntries([entry]), { locale: appSettings.locale }) notifications.push({ subscriptionId: entry.subscriptionId, @@ -477,7 +477,7 @@ export async function scanRenewalNotifications( message: 'dry_run' } ] - : await dispatchNotificationEvent(mergedParams) + : await dispatchNotificationEvent(mergedParams, { locale: appSettings.locale }) notifications.push({ subscriptionId: 'merged:summary', diff --git a/apps/api/src/services/settings.service.ts b/apps/api/src/services/settings.service.ts index a101b95..8f3dd6f 100644 --- a/apps/api/src/services/settings.service.ts +++ b/apps/api/src/services/settings.service.ts @@ -1,9 +1,11 @@ import { AiConfigSchema, + DEFAULT_APP_LOCALE, DEFAULT_RESEND_API_URL, DEFAULT_AI_CONFIG, DEFAULT_TIMEZONE, SettingsSchema, + type AppLocale, type SettingsInput } from '@subtracker/shared' import { prisma } from '../db' @@ -82,6 +84,7 @@ export async function getAppSettings(): Promise { const rows = await prisma.setting.findMany() const settingsMap = new Map(rows.map((row) => [row.key, row.valueJson])) + const systemDefaultLocale = readSettingsValue(settingsMap, 'systemDefaultLocale', DEFAULT_APP_LOCALE) const baseCurrency = readSettingsValue(settingsMap, 'baseCurrency', config.baseCurrency) const timezoneFallback = normalizeAppTimezone(process.env.TZ ?? DEFAULT_TIMEZONE) const timezone = readSettingsValue(settingsMap, 'timezone', timezoneFallback) @@ -134,6 +137,7 @@ export async function getAppSettings(): Promise { const aiConfig = AiConfigSchema.parse(readSettingsValue(settingsMap, 'aiConfig', DEFAULT_AI_CONFIG)) return SettingsSchema.parse({ + systemDefaultLocale, baseCurrency, timezone, defaultNotifyDays: deriveNotifyDaysBeforeFromAdvanceRules(defaultAdvanceReminderRules) || defaultNotifyDays, @@ -176,6 +180,10 @@ export async function getAiConfig() { return AiConfigSchema.parse(await getSetting('aiConfig', DEFAULT_AI_CONFIG)) } +export async function getSystemDefaultLocale(): Promise { + return getSetting('systemDefaultLocale', DEFAULT_APP_LOCALE) +} + export async function getDefaultAdvanceReminderRulesSetting() { const defaultNotifyDays = await getSetting('defaultNotifyDays', config.defaultNotifyDays) const notifyOnDueDay = await getSetting('notifyOnDueDay', true) diff --git a/apps/api/src/services/webhook.service.ts b/apps/api/src/services/webhook.service.ts index c32a5e7..2ca8a0d 100644 --- a/apps/api/src/services/webhook.service.ts +++ b/apps/api/src/services/webhook.service.ts @@ -3,12 +3,15 @@ import https from 'node:https' import { Prisma } from '@prisma/client' import { DEFAULT_NOTIFICATION_WEBHOOK_PAYLOAD_TEMPLATE, + DEFAULT_APP_LOCALE, NotificationWebhookSettingsSchema, + getMessage, + type AppLocale, type NotificationWebhookSettingsInput, type WebhookEventType } from '@subtracker/shared' import { prisma } from '../db' -import { getSetting, setSetting } from './settings.service' +import { getSetting, getSystemDefaultLocale, setSetting } from './settings.service' import { validateNotificationTargetUrl } from './notification-url.service' import { buildDispatchParamsFromDedupEntries, @@ -26,6 +29,10 @@ export type WebhookTestResult = { responseBody: string } +type WebhookLocaleContext = { + locale?: AppLocale +} + const PRIMARY_WEBHOOK_SETTINGS_KEY = 'notificationWebhook' function defaultWebhookSettings(): NotificationWebhookSettingsInput { @@ -97,6 +104,10 @@ function applyPayloadTemplate(template: string, params: { eventType: WebhookEven return Object.entries(values).reduce((result, [key, value]) => result.replaceAll(`{{${key}}}`, value), template) } +async function resolveWebhookLocale(locale?: AppLocale) { + return locale ?? (await getSystemDefaultLocale().catch(() => DEFAULT_APP_LOCALE)) +} + async function sendWebhookRequest( input: NotificationWebhookSettingsInput, params: { eventType: WebhookEventType | 'test'; payload: DeliveryPayload } @@ -146,32 +157,36 @@ export async function upsertPrimaryWebhookEndpoint(input: PrimaryWebhookInput) { return normalized } -export async function sendTestWebhookNotification() { +export async function sendTestWebhookNotification(context: WebhookLocaleContext = {}) { const endpoint = await getPrimaryWebhookEndpoint() if (!endpoint.url) { - throw new Error('Webhook 配置不完整,请先填写 URL') + throw new Error('api.errors.notifications.webhookConfigIncomplete') } - return sendTestWebhookNotificationWithConfig(endpoint) + return sendTestWebhookNotificationWithConfig(endpoint, context) } -export async function sendTestWebhookNotificationWithConfig(input: PrimaryWebhookInput): Promise { +export async function sendTestWebhookNotificationWithConfig( + input: PrimaryWebhookInput, + context: WebhookLocaleContext = {} +): Promise { const normalized = normalizeWebhookSettings(input) if (!normalized.url) { - throw new Error('Webhook 配置不完整,请先填写 URL') + throw new Error('api.errors.notifications.webhookConfigIncomplete') } + const locale = await resolveWebhookLocale(context.locale) const result = await sendWebhookRequest(normalized, { eventType: 'test', payload: { id: 'test-subscription', - name: '测试订阅', + name: getMessage(locale, 'notifications.tests.subscriptionName'), amount: 10, currency: 'USD', nextRenewalDate: new Date().toISOString(), - tagNames: ['测试标签'], + tagNames: [getMessage(locale, 'notifications.tests.tagName')], websiteUrl: 'https://example.com/test-subscription', - notes: '这是一条测试通知', + notes: getMessage(locale, 'notifications.tests.note'), phase: 'upcoming', daysUntilRenewal: 5, daysOverdue: 0 @@ -179,7 +194,7 @@ export async function sendTestWebhookNotificationWithConfig(input: PrimaryWebhoo }) if (result.statusCode >= 400) { - throw new Error(`Webhook 测试失败:HTTP ${result.statusCode} ${result.responseBody || ''}`.trim()) + throw new Error(`${getMessage(locale, 'api.errors.notifications.webhookTestFailed')}: HTTP ${result.statusCode} ${result.responseBody || ''}`.trim()) } return { @@ -285,7 +300,10 @@ async function updateWebhookDeliveryRecords( ) } -export async function dispatchWebhookEvent(params: NotificationDispatchParams) { +export async function dispatchWebhookEvent( + params: NotificationDispatchParams, + _context: WebhookLocaleContext = {} +) { const config = normalizeWebhookSettings(await getPrimaryWebhookEndpoint()) if (!config.enabled || !config.url) { return { diff --git a/apps/api/tests/integration/ai-routes.test.ts b/apps/api/tests/integration/ai-routes.test.ts index 0eb9b27..a1323ad 100644 --- a/apps/api/tests/integration/ai-routes.test.ts +++ b/apps/api/tests/integration/ai-routes.test.ts @@ -164,6 +164,34 @@ describe('ai routes', () => { expect(res.json().error.message).toContain('视觉输入能力') }) + it('returns english ai errors when X-SubTracker-Locale is en-US', async () => { + summaryRouteMocks.testAiVisionConnectionMock.mockRejectedValue(new Error('AI vision test failed')) + + const res = await app.inject({ + method: 'POST', + url: '/ai/test-vision', + headers: { + 'X-SubTracker-Locale': 'en-US' + }, + payload: { + enabled: true, + providerName: 'Custom', + providerPreset: 'custom', + baseUrl: 'https://api.example.com', + apiKey: 'token', + model: 'vision-model', + timeoutMs: 30000, + capabilities: { + vision: false, + structuredOutput: true + } + } + }) + + expect(res.statusCode).toBe(400) + expect(res.json().error.message).toContain('AI vision test failed') + }) + it('returns dashboard ai summary state', async () => { summaryRouteMocks.getDashboardAiSummaryMock.mockResolvedValue({ scope: 'dashboard-overview', diff --git a/apps/api/tests/integration/auth-routes.test.ts b/apps/api/tests/integration/auth-routes.test.ts index 9cf1d58..c76c703 100644 --- a/apps/api/tests/integration/auth-routes.test.ts +++ b/apps/api/tests/integration/auth-routes.test.ts @@ -80,6 +80,38 @@ describe('auth routes', () => { expect(res.json().error.message).toBe('请输入用户名和密码') }) + it('returns english validation and auth errors when X-SubTracker-Locale is en-US', async () => { + const validationRes = await app.inject({ + method: 'POST', + url: '/auth/login', + headers: { + 'X-SubTracker-Locale': 'en-US' + }, + payload: { + username: '', + password: '' + } + }) + + expect(validationRes.statusCode).toBe(422) + expect(validationRes.json().error.message).toBe('Enter username and password') + + const authRes = await app.inject({ + method: 'POST', + url: '/auth/login', + headers: { + 'X-SubTracker-Locale': 'en-US' + }, + payload: { + username: 'admin', + password: 'wrong-password' + } + }) + + expect(authRes.statusCode).toBe(401) + expect(authRes.json().error.message).toBe('Incorrect username or password') + }) + it('returns mustChangePassword in login response', async () => { authMocks.loginWithCredentialsMock.mockResolvedValue({ token: 'token', diff --git a/apps/api/tests/integration/notifications-routes.test.ts b/apps/api/tests/integration/notifications-routes.test.ts index 265e265..815481e 100644 --- a/apps/api/tests/integration/notifications-routes.test.ts +++ b/apps/api/tests/integration/notifications-routes.test.ts @@ -12,7 +12,9 @@ const notificationMocks = vi.hoisted(() => ({ sendTestServerchanNotificationWithConfigMock: vi.fn(), sendTestGotifyNotificationMock: vi.fn(), sendTestGotifyNotificationWithConfigMock: vi.fn(), - scanRenewalNotificationsMock: vi.fn() + scanRenewalNotificationsMock: vi.fn(), + sendTestWebhookNotificationMock: vi.fn(), + sendTestWebhookNotificationWithConfigMock: vi.fn() })) vi.mock('../../src/services/channel-notification.service', () => ({ @@ -41,8 +43,8 @@ vi.mock('../../src/services/webhook.service', () => ({ payloadTemplate: '{}', ignoreSsl: false })), - sendTestWebhookNotification: vi.fn(), - sendTestWebhookNotificationWithConfig: vi.fn(), + sendTestWebhookNotification: notificationMocks.sendTestWebhookNotificationMock, + sendTestWebhookNotificationWithConfig: notificationMocks.sendTestWebhookNotificationWithConfigMock, upsertPrimaryWebhookEndpoint: vi.fn() })) @@ -65,6 +67,8 @@ describe('notification routes', () => { notificationMocks.sendTestGotifyNotificationMock.mockReset() notificationMocks.sendTestGotifyNotificationWithConfigMock.mockReset() notificationMocks.scanRenewalNotificationsMock.mockReset() + notificationMocks.sendTestWebhookNotificationMock.mockReset() + notificationMocks.sendTestWebhookNotificationWithConfigMock.mockReset() notificationMocks.sendTestEmailNotificationMock.mockResolvedValue({ success: true }) notificationMocks.sendTestEmailNotificationWithConfigMock.mockResolvedValue({ success: true }) notificationMocks.sendTestPushplusNotificationMock.mockResolvedValue({ accepted: true, message: 'ok' }) @@ -75,6 +79,8 @@ describe('notification routes', () => { notificationMocks.sendTestServerchanNotificationWithConfigMock.mockResolvedValue({ success: true }) notificationMocks.sendTestGotifyNotificationMock.mockResolvedValue({ success: true }) notificationMocks.sendTestGotifyNotificationWithConfigMock.mockResolvedValue({ success: true }) + notificationMocks.sendTestWebhookNotificationMock.mockResolvedValue({ success: true, statusCode: 200, responseBody: '' }) + notificationMocks.sendTestWebhookNotificationWithConfigMock.mockResolvedValue({ success: true, statusCode: 200, responseBody: '' }) notificationMocks.scanRenewalNotificationsMock.mockResolvedValue({ processedCount: 1, matchedReminderCount: 1, @@ -128,24 +134,27 @@ describe('notification routes', () => { }) expect(res.statusCode).toBe(200) - expect(notificationMocks.sendTestEmailNotificationWithConfigMock).toHaveBeenCalledWith({ - emailProvider: 'resend', - smtpConfig: { - host: '', - port: 587, - secure: false, - username: '', - password: '', - from: '', - to: '' + expect(notificationMocks.sendTestEmailNotificationWithConfigMock).toHaveBeenCalledWith( + { + emailProvider: 'resend', + smtpConfig: { + host: '', + port: 587, + secure: false, + username: '', + password: '', + from: '', + to: '' + }, + resendConfig: { + apiBaseUrl: 'https://api.resend.com/emails', + apiKey: 're_test', + from: 'SubTracker ', + to: 'user@example.com' + } }, - resendConfig: { - apiBaseUrl: 'https://api.resend.com/emails', - apiKey: 're_test', - from: 'SubTracker ', - to: 'user@example.com' - } - }) + { locale: 'zh-CN' } + ) }) it('tests serverchan notification with stored config', async () => { @@ -179,15 +188,45 @@ describe('notification routes', () => { url: '/notifications/scan-debug', payload: { now: '2026-05-01T17:15:00.000+08:00' + }, + headers: { + 'X-SubTracker-Locale': 'en-US' } }) expect(res.statusCode).toBe(200) expect(notificationMocks.scanRenewalNotificationsMock).toHaveBeenCalledWith(new Date('2026-05-01T17:15:00.000+08:00'), { dryRun: true, - includeDebugCandidates: true + includeDebugCandidates: true, + locale: 'en-US' }) expect(res.json().data.processedCount).toBe(1) }) + it('passes locale to webhook test with payload', async () => { + const res = await app.inject({ + method: 'POST', + url: '/notifications/test/webhook', + headers: { + 'X-SubTracker-Locale': 'en-US' + }, + payload: { + enabled: true, + url: 'https://example.com/hook', + requestMethod: 'POST', + headers: 'Content-Type: application/json', + payloadTemplate: '{}', + ignoreSsl: false + } + }) + + expect(res.statusCode).toBe(200) + expect(notificationMocks.sendTestWebhookNotificationWithConfigMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://example.com/hook' + }), + { locale: 'en-US' } + ) + }) + }) diff --git a/apps/api/tests/integration/settings-routes.test.ts b/apps/api/tests/integration/settings-routes.test.ts index 7bfcd0c..207b099 100644 --- a/apps/api/tests/integration/settings-routes.test.ts +++ b/apps/api/tests/integration/settings-routes.test.ts @@ -135,6 +135,32 @@ describe('settings routes validation', () => { expect(res.json().error.message).toContain('启用 AI 能力时必须填写') }) + it('returns english validation errors when locale header is en-US', async () => { + const res = await app.inject({ + method: 'PATCH', + url: '/settings', + headers: { + 'X-SubTracker-Locale': 'en-US' + }, + payload: { + emailNotificationsEnabled: true, + emailProvider: 'smtp', + smtpConfig: { + host: '', + port: 587, + secure: false, + username: '', + password: '', + from: '', + to: '' + } + } + }) + + expect(res.statusCode).toBe(422) + expect(res.json().error.message).toBe('To enable Email notifications, fill in: SMTP Host, Username, Password, From, To') + }) + it('accepts dashboard summary switch without forcing AI recognition to be enabled', async () => { const res = await app.inject({ method: 'PATCH', diff --git a/apps/api/tests/integration/subscriptions-routes.test.ts b/apps/api/tests/integration/subscriptions-routes.test.ts index 0b5a194..7d35907 100644 --- a/apps/api/tests/integration/subscriptions-routes.test.ts +++ b/apps/api/tests/integration/subscriptions-routes.test.ts @@ -260,7 +260,7 @@ describe('subscription routes', () => { expect(res.json().data).toMatchObject({ successCount: 1, failureCount: 1, - failures: [{ id: 'sub_1', message: 'Active subscriptions cannot be deleted directly' }] + failures: [{ id: 'sub_1', message: 'api.errors.subscriptions.activeDeleteBlocked' }] }) }) @@ -293,6 +293,24 @@ describe('subscription routes', () => { expect(res.json().error.code).toBe('subscription_delete_not_allowed') }) + it('returns english delete-not-allowed errors when X-SubTracker-Locale is en-US', async () => { + routeMocks.prismaMock.subscription.findUnique.mockResolvedValue({ + id: 'sub_1', + status: 'active' + }) + + const res = await app.inject({ + method: 'DELETE', + url: '/subscriptions/sub_1', + headers: { + 'X-SubTracker-Locale': 'en-US' + } + }) + + expect(res.statusCode).toBe(422) + expect(res.json().error.message).toBe('Active subscriptions cannot be deleted directly. Pause or cancel them first.') + }) + it('allows deleting a paused subscription directly', async () => { routeMocks.prismaMock.subscription.findUnique.mockResolvedValue({ id: 'sub_1', diff --git a/apps/api/tests/unit/ai-summary.service.test.ts b/apps/api/tests/unit/ai-summary.service.test.ts index 4915c47..4ad8af0 100644 --- a/apps/api/tests/unit/ai-summary.service.test.ts +++ b/apps/api/tests/unit/ai-summary.service.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { DEFAULT_AI_CONFIG, DEFAULT_AI_DASHBOARD_SUMMARY_PREVIEW_PROMPT, DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT, type DashboardOverview } from '@subtracker/shared' +import { DEFAULT_AI_CONFIG, type DashboardOverview } from '@subtracker/shared' const aiSummaryMocks = vi.hoisted(() => ({ getAiConfigMock: vi.fn(), @@ -7,7 +7,8 @@ const aiSummaryMocks = vi.hoisted(() => ({ })) vi.mock('../../src/services/settings.service', () => ({ - getAiConfig: aiSummaryMocks.getAiConfigMock + getAiConfig: aiSummaryMocks.getAiConfigMock, + getSystemDefaultLocale: vi.fn(async () => 'en-US') })) vi.mock('../../src/services/statistics.service', () => ({ @@ -287,10 +288,10 @@ describe('ai summary service', () => { await generateDashboardAiSummary() const requestBody = JSON.parse(String((((fetchMock.mock.calls[0] as unknown) as [unknown, RequestInit])[1])?.body)) - expect(requestBody.messages[0].content).toContain(DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT.trim().slice(0, 20)) + expect(requestBody.messages[0].content).toContain('subscription operations summary assistant') const previewRequestBody = JSON.parse(String((((fetchMock.mock.calls[1] as unknown) as [unknown, RequestInit])[1])?.body)) - expect(previewRequestBody.messages[0].content).toContain(DEFAULT_AI_DASHBOARD_SUMMARY_PREVIEW_PROMPT.trim().slice(0, 20)) + expect(previewRequestBody.messages[0].content).toContain('summary compression assistant') }) it('always uses dedicated dashboard summary prompt even if recognition prompt is customized', async () => { @@ -322,7 +323,7 @@ describe('ai summary service', () => { await generateDashboardAiSummary() const requestBody = JSON.parse(String((((fetchMock.mock.calls[0] as unknown) as [unknown, RequestInit])[1])?.body)) - expect(requestBody.messages[0].content).toContain(DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT.trim().slice(0, 20)) + expect(requestBody.messages[0].content).toContain('subscription operations summary assistant') expect(requestBody.messages[0].content).not.toContain('只返回 JSON') }) diff --git a/apps/api/tests/unit/ai.service.test.ts b/apps/api/tests/unit/ai.service.test.ts index 1819a9e..13eeb38 100644 --- a/apps/api/tests/unit/ai.service.test.ts +++ b/apps/api/tests/unit/ai.service.test.ts @@ -20,7 +20,8 @@ const recognizeMock = vi.fn(async () => ({ })) vi.mock('../../src/services/settings.service', () => ({ - getAiConfig: vi.fn(async () => mockedSettings.aiConfig) + getAiConfig: vi.fn(async () => mockedSettings.aiConfig), + getSystemDefaultLocale: vi.fn(async () => 'en-US') })) vi.mock('tesseract.js', () => ({ @@ -126,6 +127,28 @@ describe('ai service', () => { expect(secondBody.messages[0].content).toContain('合法 JSON 对象') }) + it('uses english default prompt when system default locale is en-US', async () => { + const fetchMock = vi.fn(async () => + jsonResponse({ + choices: [ + { + message: { + content: '{"name":"Netflix"}' + } + } + ] + }) + ) + vi.stubGlobal('fetch', fetchMock) + + await recognizeSubscriptionByAi({ + text: 'Netflix 9.99 USD monthly' + }) + + const requestBody = JSON.parse(String((((fetchMock.mock.calls[0] as unknown) as [unknown, RequestInit])[1])?.body)) + expect(requestBody.messages[0].content).toContain('subscription billing extractor') + }) + it('uses OCR text path when vision capability is disabled', async () => { mockedSettings.aiConfig.capabilities.vision = false diff --git a/apps/api/tests/unit/forgot-password.service.test.ts b/apps/api/tests/unit/forgot-password.service.test.ts index 7345d9f..408d860 100644 --- a/apps/api/tests/unit/forgot-password.service.test.ts +++ b/apps/api/tests/unit/forgot-password.service.test.ts @@ -64,4 +64,40 @@ describe('forgot password service', () => { const { isForgotPasswordEnabled } = await import('../../src/services/forgot-password.service') await expect(isForgotPasswordEnabled()).resolves.toBe(true) }) + + it('passes locale to forgot-password notification dispatch', async () => { + const store = new Map([['forgotPasswordEnabled', true]]) + forgotPasswordState.getSettingMock.mockImplementation(async (key: string, fallback: unknown) => + store.has(key) ? store.get(key) : fallback + ) + forgotPasswordState.setSettingMock.mockImplementation(async (key: string, value: unknown) => { + store.set(key, value) + }) + forgotPasswordState.getNotificationChannelSettingsMock.mockResolvedValue({ + emailNotificationsEnabled: true, + pushplusNotificationsEnabled: false, + telegramNotificationsEnabled: false, + serverchanNotificationsEnabled: false, + gotifyNotificationsEnabled: false + }) + forgotPasswordState.getStoredCredentialsMock.mockResolvedValue({ + username: 'admin', + passwordHash: 'hash', + passwordSalt: 'salt' + }) + forgotPasswordState.sendForgotPasswordVerificationCodeMock.mockResolvedValue([ + { channel: 'email', status: 'success' } + ]) + + const { requestForgotPasswordChallenge } = await import('../../src/services/forgot-password.service') + const result = await requestForgotPasswordChallenge('admin', '127.0.0.1', 'en-US') + + expect(result.ok).toBe(true) + expect(forgotPasswordState.sendForgotPasswordVerificationCodeMock).toHaveBeenCalledWith( + expect.objectContaining({ + username: 'admin' + }), + { locale: 'en-US' } + ) + }) }) diff --git a/packages/shared/package.json b/packages/shared/package.json index e25b1ed..01d32c0 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -12,10 +12,16 @@ "import": "./dist/index.js", "require": "./dist/index.cjs", "default": "./dist/index.js" + }, + "./i18n": { + "types": "./src/i18n.ts", + "import": "./dist/i18n.js", + "require": "./dist/i18n.cjs", + "default": "./dist/i18n.js" } }, "scripts": { - "build": "tsup src/index.ts --format esm,cjs --dts", + "build": "tsup src/index.ts src/i18n.ts --format esm,cjs --dts", "test": "vitest run", "lint": "tsc --noEmit" }, diff --git a/packages/shared/src/i18n.ts b/packages/shared/src/i18n.ts new file mode 100644 index 0000000..99a28dd --- /dev/null +++ b/packages/shared/src/i18n.ts @@ -0,0 +1,73 @@ +import type { AppLocale } from './locale-core' +import { DEFAULT_APP_LOCALE, normalizeAppLocale, resolveAppLocaleFromAcceptLanguage } from './locale-core' +import zhCnMessages from './locales/zh-CN' +import enUsMessages from './locales/en-US' + +type MessageValue = string | MessageTree +type MessageTree = { + [key: string]: MessageValue +} + +export type TranslationParams = Record + +export const sharedMessages = { + 'zh-CN': zhCnMessages, + 'en-US': enUsMessages +} as const satisfies Record + +export const SUPPORTED_APP_LOCALES = Object.freeze(Object.keys(sharedMessages) as AppLocale[]) + +function isMessageTree(value: MessageValue | undefined): value is MessageTree { + return typeof value === 'object' && value !== null +} + +function getNestedMessage(tree: MessageTree, key: string): string | undefined { + const segments = key.split('.') + let current: MessageValue | undefined = tree + + for (const segment of segments) { + if (!isMessageTree(current)) return undefined + current = current[segment] + } + + return typeof current === 'string' ? current : undefined +} + +function interpolateMessage(template: string, params?: TranslationParams) { + if (!params) return template + + return template.replace(/\{(\w+)\}/g, (_match, key) => { + const value = params[key] + return value === undefined || value === null ? `{${key}}` : String(value) + }) +} + +export function getMessage(locale: AppLocale, key: string, params?: TranslationParams) { + const normalizedLocale = normalizeAppLocale(locale) + const template = + getNestedMessage(sharedMessages[normalizedLocale], key) ?? + getNestedMessage(sharedMessages[DEFAULT_APP_LOCALE], key) ?? + key + + return interpolateMessage(template, params) +} + +export function detectLocaleFromAcceptLanguage(value: unknown, fallback: AppLocale = DEFAULT_APP_LOCALE) { + return resolveAppLocaleFromAcceptLanguage(value, fallback) +} + +export function getDefaultAiSubscriptionPrompt(locale: AppLocale = DEFAULT_APP_LOCALE) { + return getMessage(locale, 'ai.prompts.subscription.default') +} + +export function getDefaultAiDashboardSummaryPrompt(locale: AppLocale = DEFAULT_APP_LOCALE) { + return getMessage(locale, 'ai.prompts.dashboard.summary.default') +} + +export function getDefaultAiDashboardSummaryPreviewPrompt(locale: AppLocale = DEFAULT_APP_LOCALE) { + return getMessage(locale, 'ai.prompts.dashboard.preview.default') +} + +export const DEFAULT_AI_SUBSCRIPTION_PROMPT = getDefaultAiSubscriptionPrompt(DEFAULT_APP_LOCALE) +export const DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT = getDefaultAiDashboardSummaryPrompt(DEFAULT_APP_LOCALE) +export const DEFAULT_AI_DASHBOARD_SUMMARY_PREVIEW_PROMPT = getDefaultAiDashboardSummaryPreviewPrompt(DEFAULT_APP_LOCALE) diff --git a/packages/shared/src/index.test.ts b/packages/shared/src/index.test.ts index 020c9f7..27cc5f0 100644 --- a/packages/shared/src/index.test.ts +++ b/packages/shared/src/index.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + AppLocaleSchema, AiDashboardSummaryStatusSchema, CreateSubscriptionSchema, DEFAULT_AI_DASHBOARD_SUMMARY_PREVIEW_PROMPT, @@ -7,7 +8,12 @@ import { DEFAULT_ADVANCE_REMINDER_RULES, DEFAULT_OVERDUE_REMINDER_RULES, formatAiSummaryPreviewText, + getDefaultAiDashboardSummaryPreviewPrompt, + getDefaultAiDashboardSummaryPrompt, + getDefaultAiSubscriptionPrompt, normalizeWebsiteUrlInput, + normalizeAppLocale, + resolveAppLocaleFromAcceptLanguage, SettingsSchema, SubtrackerBackupCommitSchema, SubtrackerBackupInspectSchema, @@ -33,6 +39,7 @@ describe('shared schema', () => { it('should provide reminder-related setting defaults', () => { const parsed = SettingsSchema.parse({}) + expect(parsed.systemDefaultLocale).toBe('zh-CN') expect(parsed.timezone).toBe('Asia/Shanghai') expect(parsed.defaultNotifyDays).toBe(3) expect(parsed.defaultAdvanceReminderRules).toBe(DEFAULT_ADVANCE_REMINDER_RULES) @@ -114,6 +121,20 @@ describe('shared schema', () => { expect(() => AiDashboardSummaryStatusSchema.parse('unknown')).toThrow() }) + it('should normalize app locale values and accept-language headers', () => { + expect(AppLocaleSchema.parse('en-US')).toBe('en-US') + expect(normalizeAppLocale('en')).toBe('en-US') + expect(normalizeAppLocale('ZH-hans-CN')).toBe('zh-CN') + expect(resolveAppLocaleFromAcceptLanguage('en-GB,en;q=0.9,zh-CN;q=0.8')).toBe('en-US') + expect(resolveAppLocaleFromAcceptLanguage('', 'en-US')).toBe('en-US') + }) + + it('should provide locale-aware default ai prompts', () => { + expect(getDefaultAiSubscriptionPrompt('en-US')).toContain('subscription billing extractor') + expect(getDefaultAiDashboardSummaryPrompt('en-US')).toContain('subscription operations summary assistant') + expect(getDefaultAiDashboardSummaryPreviewPrompt('en-US')).toContain('summary compression assistant') + }) + it('formats ai summary preview text into multiple readable lines', () => { expect( formatAiSummaryPreviewText( diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index dc70e66..9072b0a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,4 +1,12 @@ import { z } from 'zod' +import { + AppLocaleSchema, + DEFAULT_APP_LOCALE, + LOCALE_PREFERENCE_STORAGE_KEY, + normalizeAppLocale, + resolveAppLocaleFromAcceptLanguage, + type AppLocale +} from './locale-core' const WEBSITE_URL_ERROR_MESSAGE = '请输入合法网址,例如 https://example.com' const FQDN_LABEL_RE = /^[a-z_\u00a1-\uffff0-9-]+$/i @@ -107,66 +115,6 @@ function formatWebsiteUrl(url: URL): string { return href } -export const DEFAULT_AI_SUBSCRIPTION_PROMPT = `你是订阅账单信息提取助手。请从输入的文本或截图中提取订阅信息,并且只返回 JSON。 -输出字段: -- name -- description -- amount -- currency -- billingIntervalCount -- billingIntervalUnit(day|week|month|quarter|year) -- startDate(YYYY-MM-DD) -- nextRenewalDate(YYYY-MM-DD) -- notifyDaysBefore -- websiteUrl -- notes -- confidence(0~1) -- rawText - -规则: -1. 不确定就留空,不要猜。 -2. 金额必须是数字。 -3. 币种必须是 3 位大写代码,例如 CNY、USD。 -4. 周期单位必须在 day/week/month/quarter/year 中。 -5. 只返回 JSON,不要返回 Markdown。` - -export const DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT = `你是订阅运营摘要助手。请基于用户当前的订阅统计数据,输出一份简洁、准确、可执行的 Markdown 总结。 - -目标: -1. 帮助用户快速理解当前订阅规模、支出结构、预算压力和近期续订风险。 -2. 总结数据中的明显模式、异常点和需要关注的事项。 -3. 给出中性、可执行、但不依赖具体服务功能知识的建议。 - -硬性要求: -- 只能基于输入数据分析,不要虚构事实。 -- 不要假设你了解某个订阅服务的功能细节。 -- 不要输出“取消某服务更省钱”“某两个服务功能重叠”之类的建议。 -- 不要臆测用户偏好、使用频率或用途。 -- 不要输出 JSON,不要输出代码块,只输出 Markdown 正文。 - -输出建议结构: -## 总览 -## 支出结构 -## 近期风险 -## 值得注意的模式 -## 中性建议 - -写作要求: -- 使用简体中文。 -- 结论明确,少空话。 -- 每个小节控制在 2~5 条要点内。 -- 如果某部分没有明显异常,直接说明“暂无显著异常”或“整体平稳”。` - -export const DEFAULT_AI_DASHBOARD_SUMMARY_PREVIEW_PROMPT = `你是订阅统计摘要压缩助手。请根据已经生成好的完整 AI 总结,提炼出一个默认折叠展示用的超简短摘要。 - -硬性要求: -- 只输出简体中文纯文本,不要输出 Markdown,不要输出代码块。 -- 输出 2 到 3 行,每行一句,自然换行。 -- 不要输出标题,不要输出项目符号,不要编号。 -- 只保留最重要的结论:订阅规模、预算压力、近期风险。 -- 不要发散,不要补充原文没有的信息。 -- 如果原文信息有限,就直接给出 1 到 2 句自然语言摘要。` - function normalizePreviewSource(text: string) { return String(text ?? '') .replace(/\r\n/g, '\n') @@ -189,6 +137,27 @@ export function formatAiSummaryPreviewText(text: string) { return normalizePreviewSource(text) } +export { + DEFAULT_AI_DASHBOARD_SUMMARY_PREVIEW_PROMPT, + DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT, + DEFAULT_AI_SUBSCRIPTION_PROMPT, + SUPPORTED_APP_LOCALES, + detectLocaleFromAcceptLanguage, + getDefaultAiDashboardSummaryPreviewPrompt, + getDefaultAiDashboardSummaryPrompt, + getDefaultAiSubscriptionPrompt, + getMessage, + sharedMessages +} from './i18n' +export { + type AppLocale, + AppLocaleSchema, + DEFAULT_APP_LOCALE, + LOCALE_PREFERENCE_STORAGE_KEY, + normalizeAppLocale, + resolveAppLocaleFromAcceptLanguage +} from './locale-core' + export const SubscriptionStatusSchema = z.enum(['active', 'paused', 'cancelled', 'expired']) export const BillingIntervalUnitSchema = z.enum(['day', 'week', 'month', 'quarter', 'year']) export const WebhookRequestMethodSchema = z.enum(['POST', 'PUT', 'PATCH', 'DELETE']) @@ -374,6 +343,7 @@ export const AiConfigSchema = z.object({ }) export const SettingsSchema = z.object({ + systemDefaultLocale: AppLocaleSchema.default(DEFAULT_APP_LOCALE), baseCurrency: z.string().length(3).default('CNY').transform((v) => v.toUpperCase()), timezone: TimeZoneSchema.default(DEFAULT_TIMEZONE), defaultNotifyDays: z.number().int().min(0).max(365).default(3), diff --git a/packages/shared/src/locale-core.ts b/packages/shared/src/locale-core.ts new file mode 100644 index 0000000..f692b0d --- /dev/null +++ b/packages/shared/src/locale-core.ts @@ -0,0 +1,54 @@ +import { z } from 'zod' + +export const AppLocaleSchema = z.enum(['zh-CN', 'en-US']) +export type AppLocale = z.infer + +export const DEFAULT_APP_LOCALE: AppLocale = 'zh-CN' +export const LOCALE_PREFERENCE_STORAGE_KEY = 'subtracker-locale-preference' + +const APP_LOCALE_ALIASES: Record = { + zh: 'zh-CN', + 'zh-cn': 'zh-CN', + 'zh-hans': 'zh-CN', + 'zh-hans-cn': 'zh-CN', + en: 'en-US', + 'en-us': 'en-US' +} + +function tryNormalizeAppLocale(value: unknown): AppLocale | null { + const normalized = String(value ?? '').trim() + if (!normalized) return null + + const lower = normalized.toLowerCase() + if (APP_LOCALE_ALIASES[lower]) { + return APP_LOCALE_ALIASES[lower] + } + + for (const [prefix, locale] of Object.entries(APP_LOCALE_ALIASES)) { + if (lower.startsWith(`${prefix}-`)) { + return locale + } + } + + return null +} + +export function normalizeAppLocale(value: unknown, fallback: AppLocale = DEFAULT_APP_LOCALE): AppLocale { + return tryNormalizeAppLocale(value) ?? fallback +} + +export function resolveAppLocaleFromAcceptLanguage(value: unknown, fallback: AppLocale = DEFAULT_APP_LOCALE) { + const raw = String(value ?? '').trim() + if (!raw) return fallback + + for (const segment of raw.split(',')) { + const candidate = segment.split(';')[0]?.trim() + if (!candidate) continue + const locale = tryNormalizeAppLocale(candidate) + if (locale) { + return locale + } + } + + return fallback +} diff --git a/packages/shared/src/locales/en-US.ts b/packages/shared/src/locales/en-US.ts new file mode 100644 index 0000000..41e8223 --- /dev/null +++ b/packages/shared/src/locales/en-US.ts @@ -0,0 +1,1183 @@ +export default { + common: { + actions: { + save: 'Save', + refresh: 'Refresh', + update: 'Update', + test: 'Test', + cancel: 'Cancel', + confirm: 'Confirm', + close: 'Close', + expand: 'Expand', + collapse: 'Collapse', + delete: 'Delete', + keep: 'Keep', + restore: 'Restore', + search: 'Search', + reset: 'Reset', + preview: 'Preview', + import: 'Import', + export: 'Export', + create: 'Create', + edit: 'Edit', + reorder: 'Reorder', + done: 'Done', + signOut: 'Sign out', + connectionTest: 'Connection test', + visionTest: 'Vision test' + }, + status: { + enabled: 'Enabled', + disabled: 'Disabled', + active: 'Active', + paused: 'Paused', + cancelled: 'Cancelled', + expired: 'Expired', + stale: 'Stale', + fresh: 'Fresh' + }, + labels: { + name: 'Name', + description: 'Description', + notes: 'Notes', + amount: 'Amount', + currency: 'Currency', + status: 'Status', + tags: 'Tags', + startDate: 'Start date', + nextRenewal: 'Next renewal', + autoRenew: 'Auto renew', + notifications: 'Notifications', + createdAt: 'Created at', + from: 'From', + to: 'To', + provider: 'Provider', + username: 'Username', + password: 'Password', + token: 'Token', + url: 'URL', + unit: 'Unit', + frequency: 'Frequency', + actions: 'Actions', + port: 'Port', + code: 'Verification code', + model: 'Model', + requestMethod: 'Request method', + host: 'Host', + secure: 'Secure', + chatId: 'Chat ID', + botToken: 'Bot Token', + sendKey: 'SendKey', + apiBaseUrl: 'API Base URL', + apiKey: 'API Key', + topic: 'Topic' + }, + empty: { + noData: 'No data', + noDescription: 'No description', + noNotes: 'No notes', + noTags: 'No tags' + }, + errors: { + requestFailed: 'Request failed' + }, + placeholders: { + noFileSelected: 'No file selected' + }, + separators: { + list: ', ' + }, + locales: { + zhCN: 'Simplified Chinese', + enUS: 'English' + }, + units: { + day: 'Day', + week: 'Week', + month: 'Month', + quarter: 'Quarter', + year: 'Year', + minutes: 'minutes' + } + }, + app: { + brand: 'SubTracker', + shellTitle: 'Subscription Console', + shellSubtitle: 'Multi-currency · Reminders · Statistics · Calendar', + notSignedIn: 'Not signed in', + changeDefaultPasswordTitle: 'Change default password first', + changeDefaultPasswordWarning: 'You are still using the default admin password. Change it before continuing.', + newPassword: 'New password', + confirmNewPassword: 'Confirm new password', + versionUpdates: 'Version updates', + releaseUnknown: 'Unknown publish time', + viewRelease: 'View Release', + noNewRelease: 'No release is newer than the current version.', + updateAvailable: 'New version available. Current: {currentVersion}, latest: {latestVersion}', + alreadyLatest: 'You are already on the latest version ({version})', + menu: { + dashboard: 'Dashboard', + subscriptions: 'Subscriptions', + calendar: 'Subscription Calendar', + statistics: 'Expense Statistics', + budgets: 'Budget Statistics', + settings: 'Settings' + }, + auth: { + login: 'Login' + }, + theme: { + current: 'Current theme: {current}. Click to switch to {next}', + light: 'Light', + dark: 'Dark' + } + }, + auth: { + validation: { + usernameAndPasswordRequired: 'Enter username and password', + usernameRequired: 'Enter a username', + passwordRequired: 'Enter a password', + loginPayloadInvalid: 'Invalid login payload', + codeRequired: 'Enter the 6-digit verification code', + codeFormat: 'The verification code must be 6 digits', + newPasswordRequired: 'Enter the new password', + newPasswordMin: 'The new password must be at least 4 characters long', + passwordMismatch: 'The two new passwords do not match' + }, + success: { + login: 'Signed in successfully', + forgotPasswordCodeSent: 'If the username is valid and notifications are enabled, the verification code has been sent.', + passwordResetAndLoggedIn: 'Password reset succeeded and you have been signed in automatically', + defaultPasswordChanged: 'Default password changed' + }, + error: { + login: 'Sign in failed', + forgotPasswordCodeSend: 'Failed to send the verification code', + passwordReset: 'Password reset failed' + } + }, + login: { + title: 'Sign in to SubTracker', + subtitle: 'Enter your username and password', + rememberMe: 'Remember me', + rememberMeDays: '({days} days)', + forgotPassword: 'Forgot password', + collapseForgotPassword: 'Hide password recovery', + sendCode: 'Send verification code', + verifyAndResetPassword: 'Verify and reset password', + usernamePlaceholder: 'Enter username', + passwordPlaceholder: 'Enter password', + codePlaceholder: 'Enter the 6-digit verification code', + newPasswordPlaceholder: 'Enter the new password', + confirmNewPasswordPlaceholder: 'Enter the new password again' + }, + settings: { + page: { + title: 'Settings', + subtitle: 'Manage base settings, budgets, exchange rates, notifications, and AI' + }, + sections: { + basic: 'Basic settings', + exchangeSnapshot: 'Exchange rate snapshot', + currentRates: 'Current rates (common currencies)', + converter: 'Currency converter', + notifications: 'Notification settings', + ai: 'AI settings', + credentials: 'Credentials', + importExport: 'Import and export', + backup: 'Backup', + migration: 'Migration', + about: 'About', + credits: 'Credits' + }, + labels: { + baseCurrency: 'Base currency', + timezone: 'Business timezone', + rememberSessionDays: 'Remember session days', + timezoneSample: 'Current timezone sample', + monthlyBudget: 'Monthly budget (base currency)', + yearlyBudget: 'Yearly budget (base currency)', + advanceReminderRules: 'Advance reminder rules', + overdueReminderRules: 'Overdue reminder rules', + mergeNotifications: 'Merge multi-subscription notifications', + enableTagBudgets: 'Enable tag monthly budgets', + sourceCurrency: 'Source currency', + targetCurrency: 'Target currency', + providerPreset: 'Provider preset', + capabilitySwitches: 'Capability switches', + structuredOutput: 'Prefer structured JSON output', + requestTimeout: 'Request timeout (ms)', + customRecognitionPrompt: 'Custom recognition prompt', + customSummaryPrompt: 'Custom summary prompt', + enableAi: 'Enable AI', + aiSummary: 'AI summary', + aiVisionCapability: 'Vision input', + configurationDetails: 'Configuration details', + advancedConfig: 'Advanced settings', + notificationProvider: 'Notification provider', + providerName: 'Provider name', + providerUrl: 'Endpoint', + fetchedAt: 'Fetched at', + snapshotStatus: 'Snapshot status', + requestMethod: 'Request method', + customHeaders: 'Custom headers', + payloadTemplate: 'Payload template', + availableVariables: 'Available variables:', + ignoreSsl: 'Ignore SSL verification', + smtpHost: 'SMTP Host', + secure: 'Secure', + resendApiUrl: 'Resend API URL', + resendApiKey: 'Resend API Key', + botToken: 'Bot Token', + chatId: 'Chat ID', + sendKey: 'SendKey', + topic: 'Topic', + apiBaseUrl: 'API Base URL', + apiKey: 'API Key', + oldUsername: 'Current username', + oldPassword: 'Current password', + newUsername: 'New username', + newPassword: 'New password', + enableForgotPassword: 'Allow password recovery via notification code', + restoreSettings: 'Also overwrite current settings' + }, + helps: { + advanceReminderRules: + 'Format: days&time; For example, 3&09:30; means remind 3 days in advance at 09:30, and 0&09:30; means remind on the due day. Separate multiple rules with ;', + overdueReminderRules: + 'Format: days&time; For example, 1&09:30; means remind at 09:30 after 1 overdue day. Separate multiple rules with ;', + notificationSettings: + 'Manage Email, PushPlus, Telegram, ServerChan, Gotify, and Webhook in one place. Each channel can be saved and tested independently.', + aiSettings: 'The AI master switch controls recognition and connection tests. AI summaries can be enabled or disabled separately.', + structuredOutput: + 'When enabled, the system prefers vendor-supported structured JSON output. If unsupported, it automatically falls back to prompt-based JSON output.', + backup: 'Backup and restore via ZIP, including subscriptions, tags, payment records, ordering, settings, and local logos.', + migration: 'Import data from similar third-party projects' + }, + buttons: { + previewReminderRules: 'Preview reminder rules', + collapseReminderPreview: 'Collapse reminder preview', + exportBackup: 'Export backup', + restoreBackup: 'Restore backup', + importWallos: 'Import Wallos', + swapCurrencies: 'Swap source and target currencies' + }, + placeholders: { + optional: 'Optional', + notFilledRecipient: 'Recipient not set', + notFilledSmtpHost: 'SMTP Host not set', + fromAddress: 'SubTracker ', + advanceReminderRules: 'Example: 3&09:30;0&09:30;', + overdueReminderRules: 'Example: 1&09:30;2&09:30;3&09:30;', + multiEmail: 'Separate multiple emails with commas', + chatIdExample: 'For example: 123456789 or -100xxxxxxxxxx', + gotifyUrl: 'https://gotify.example.com', + webhookUrl: 'https://example.com/hook', + aiBaseUrl: 'https://api.deepseek.com', + customHeaders: + 'Supports a JSON object or one header per line, for example:\nContent-Type: application/json\nX-App: SubTracker', + customRecognitionPrompt: + 'Leave empty to use the system recognition prompt. Dashboard AI summaries always use the system summary prompt.', + customSummaryPrompt: 'Leave empty to use the system summary prompt.' + }, + summary: { + baseCurrencyTag: 'Base {currency}', + supportedCurrenciesTag: '{count} currencies supported', + selectCurrenciesToConvert: 'Select currencies to convert', + emailResend: 'Resend · To: {to}', + emailSmtp: 'Host: {host} · To: {to}' + }, + channels: { + email: 'Email notifications', + pushplus: 'PushPlus', + telegram: 'Telegram Bot', + serverchan: 'ServerChan', + gotify: 'Gotify', + webhook: 'Webhook' + }, + options: { + emailProvider: { + smtp: 'SMTP', + resend: 'Resend' + }, + aiProviderPreset: { + custom: 'Custom', + aliyunBailian: 'Alibaba Bailian', + tencentHunyuan: 'Tencent Hunyuan', + volcengineArk: 'Volcengine Ark' + } + }, + validation: { + emailMissingFields: 'Email notification is missing required fields: {fields}', + pushplusMissingFields: 'PushPlus is missing required fields: {fields}', + telegramMissingFields: 'Telegram is missing required fields: {fields}', + serverchanMissingFields: 'ServerChan is missing required fields: {fields}', + gotifyMissingFields: 'Gotify is missing required fields: {fields}', + webhookMissingFields: 'Webhook is missing required fields: {fields}', + aiMissingFields: 'AI settings are missing required fields: {fields}' + }, + messages: { + basicSaved: 'Basic settings saved', + basicSaveFailed: 'Failed to save basic settings', + emailSaved: 'Email notification settings saved', + emailDisabled: 'Email notifications disabled', + pushplusSaved: 'PushPlus settings saved', + pushplusDisabled: 'PushPlus disabled', + telegramSaved: 'Telegram settings saved', + telegramDisabled: 'Telegram disabled', + serverchanSaved: 'ServerChan settings saved', + serverchanDisabled: 'ServerChan disabled', + gotifySaved: 'Gotify settings saved', + gotifyDisabled: 'Gotify disabled', + aiSaved: 'AI settings saved', + aiDisabled: 'AI disabled', + aiConnectionTestSuccess: 'Connection test succeeded: {provider} / {model} / {response}', + aiConnectionTestFailed: 'AI connection test failed', + aiVisionTestSuccess: 'Vision test succeeded: {provider} / {model} / {response}', + aiVisionTestFailed: 'AI vision test failed', + ratesRefreshed: 'Exchange rates refreshed', + credentialsUpdated: 'Credentials updated', + emailTestSent: 'Test email sent', + emailTestFailed: 'Email test failed', + pushplusTestSubmittedWithCode: 'PushPlus test request submitted. Ticket: {code}', + pushplusTestSubmitted: 'PushPlus test request submitted', + pushplusTestFailed: 'PushPlus test failed', + telegramTestSent: 'Telegram test message sent', + telegramTestFailed: 'Telegram test failed', + serverchanTestSent: 'ServerChan test message sent', + serverchanTestFailed: 'ServerChan test failed', + gotifyTestSent: 'Gotify test message sent', + gotifyTestFailed: 'Gotify test failed', + zipExportStarted: 'ZIP export started', + zipExportFailed: 'ZIP export failed', + backupRestored: 'Backup restored', + backupAppendedWithSettings: 'Backup appended and system settings overwritten', + backupAppended: 'Backup appended', + wallosImported: 'Wallos data imported', + webhookSaved: 'Webhook settings saved', + webhookDisabled: 'Webhook disabled', + webhookTestSuccessWithPreview: 'Webhook test succeeded, HTTP {statusCode}: {preview}', + webhookTestSuccess: 'Webhook test succeeded, HTTP {statusCode}', + webhookTestFailed: 'Webhook test failed' + }, + about: { + releaseNotes: 'Release Notes', + license: 'License', + issues: 'Issues and Requests', + author: 'The author', + documentation: 'Documentation', + readmeDeployment: 'README / DEPLOYMENT', + credits: { + wallos: 'Wallos', + vueVite: 'Vue 3 / Vite', + naiveUi: 'Naive UI', + fastifyPrisma: 'Fastify / Prisma', + piniaTanstackEcharts: 'Pinia / TanStack Query / ECharts' + } + } + }, + dashboard: { + page: { + title: 'Dashboard', + subtitle: 'Overview subscription scale, budget usage, upcoming renewals, and spend distribution' + }, + cards: { + activeSubscriptions: 'Active subscriptions', + renewalsIn7Days: 'Renewals in 7 days', + estimatedMonthlySpend: 'Estimated monthly spend', + estimatedYearlySpend: 'Estimated yearly spend' + }, + sections: { + monthlyBudgetUsage: 'Monthly budget usage', + yearlyBudgetUsage: 'Yearly budget usage', + tagBudgetOverview: 'Tag budget overview', + tagMonthlySpend: 'Tag monthly spend', + monthlyTrend: 'Monthly payment trend (next 12 months)', + upcoming30: 'Upcoming renewals (30 days)' + }, + labels: { + usedPrefix: 'Used', + budgetPrefix: '/ Budget', + configuredTagBudgets: 'Configured tag budgets', + nearingBudget: 'Near budget', + overBudget: 'Over budget', + topUsageRate: 'Top usage rate' + }, + empty: { + noMonthlyBudget: 'No monthly budget set', + noYearlyBudget: 'No yearly budget set', + noTagBudgetConfigured: 'No tag budgets configured', + noData: 'No data' + }, + table: { + subscription: 'Subscription', + nextRenewal: 'Next renewal', + originalAmount: 'Original amount', + convertedAmount: 'Converted amount', + status: 'Status' + } + }, + budgets: { + page: { + title: 'Budget statistics', + subtitle: 'Review overall budget usage and tag monthly budget analysis' + }, + sections: { + monthlyBudgetUsage: 'Monthly budget usage', + yearlyBudgetUsage: 'Yearly budget usage', + tagBudgetUsageRate: 'Tag budget usage rate', + budgetSummary: 'Budget summary', + topUsageRate: 'Top 3 usage rates', + tagBudgetUsageTable: 'Tag budget usage table', + tagBudget: 'Tag budgets' + }, + labels: { + usedPrefix: 'Used', + budgetPrefix: '/ Budget', + configuredTagBudgets: 'Configured tag budgets', + nearingBudget: 'Near budget', + overBudget: 'Over budget', + tag: 'Tag', + spent: 'Spent', + budget: 'Budget', + remainingOrOver: 'Remaining / Over', + usageRate: 'Usage rate', + status: 'Status', + remainingPrefix: 'Remaining', + overPrefix: 'Over' + }, + empty: { + noMonthlyBudget: 'No monthly budget set', + noYearlyBudget: 'No yearly budget set', + noTagBudgetData: 'No tag budget data', + noConfiguredTagBudgets: 'No tag budgets configured' + }, + hints: { + section: 'Tag monthly budgets are independent from overall budgets and only apply to tags with configured budgets.' + }, + buttons: { + setTagBudgets: 'Set tag monthly budgets' + }, + status: { + normal: 'Normal', + warning: 'Near budget', + over: 'Over budget' + }, + messages: { + saved: 'Tag monthly budgets saved' + } + }, + calendar: { + page: { + title: 'Subscription calendar', + subtitle: 'Review subscription date distribution with month and list views' + }, + cards: { + currentMonth: 'Current month', + currentMonthSuffix: 'Currently viewed month', + monthlyRenewalCount: 'Renewals this month', + monthlyRenewalCountSuffix: 'Subscriptions in the current month', + monthlySpend: 'Estimated spend this month', + convertedSuffix: 'Converted by exchange rate', + selectedDateRenewals: 'Renewals on selected date' + }, + tabs: { + month: 'Month view', + list: 'List view' + }, + detail: { + dayRenewalsTitle: 'Renewals on {date}', + dayRenewalsSummary: '{count} items · {currency} {amount}', + noRenewalOnDay: 'No renewals on this day', + converted: 'Converted', + itemsSuffix: 'items' + }, + table: { + subscription: 'Subscription', + date: 'Date', + amount: 'Original amount', + convertedAmount: 'Converted amount', + status: 'Status' + } + }, + statistics: { + page: { + title: 'Expense statistics', + subtitle: 'Analyze subscription spend through trend, structure, and risk' + }, + ai: { + title: 'AI summary', + generatedAtPrefix: 'Last generated: ', + collapseDetails: 'Collapse details', + viewDetails: 'View details', + regenerate: 'Regenerate summary', + expandedHint: 'Generated from current statistics and does not modify subscription data', + generatingHint: 'Generating an AI summary from current statistics, please wait...', + unconfiguredHint: 'Enable AI and AI summaries in Settings first. The statistics page will then generate summaries automatically.', + failedFallback: 'AI summary generation failed. Please try again later.', + noSummary: 'No AI summary', + previewLabel: 'Preview', + updated: 'AI summary updated', + failed: 'Failed to generate AI summary' + }, + sections: { + monthlyTrend: 'Monthly payment trend (next 12 months)', + tagSpend: 'Tag monthly spend share', + statusDistribution: 'Status distribution', + autoRenewShare: 'Auto-renew share', + currencyDistribution: 'Subscription currency distribution', + upcoming30: 'Renewal distribution in the next 30 days', + top10: 'Top 10 monthly subscription spend' + }, + empty: { + noData: 'No data', + noUpcoming30: 'No renewals in the next 30 days' + }, + series: { + trend: 'Projected amount', + renewalCount: 'Renewals', + amount: 'Amount' + }, + labels: { + renewalsCountAxis: 'Renewals', + autoRenew: 'Auto renew', + manualRenew: 'Manual renew', + renewalCountTooltip: 'Subscriptions', + amountTooltip: 'Monthly amount' + }, + status: { + active: 'Active', + paused: 'Paused', + cancelled: 'Cancelled', + expired: 'Expired' + } + }, + tags: { + manage: { + title: 'Tag management', + description: 'Create, edit, and delete tags here.', + create: 'New tag', + tag: 'Tag', + sortOrder: 'Order', + subscriptionCount: 'Subscriptions', + actions: 'Actions', + deleteInUseConfirm: 'Deleting this tag removes it from subscriptions. Continue?', + deleteConfirm: 'Delete this tag?' + }, + form: { + editTitle: 'Edit tag', + createTitle: 'New tag', + nameLabel: 'Tag name', + namePlaceholder: 'Example: Cloud services', + colorLabel: 'Color', + colorPlaceholder: '#3b82f6 or rgb(59,130,246)', + sortOrderLabel: 'Order' + }, + budget: { + title: 'Set tag monthly budgets', + description: 'Set monthly budgets for tags that need separate spend control. Tags without a budget are excluded from tag budget analysis.', + searchPlaceholder: 'Search tags', + budgetPlaceholder: 'Not set ({currency})' + } + }, + subscriptions: { + page: { + title: 'Subscriptions', + subtitle: 'Manage subscriptions across billing cycles and currencies', + searchPlaceholder: 'Search by name or description', + statusPlaceholder: 'Status', + sortPlaceholder: 'Sort by', + collapseTagFilter: 'Collapse tag filter', + expandTagFilter: 'Expand tag filter', + listTitle: 'Subscription list', + noSubscriptions: 'No subscriptions', + selectedItems: '{count} selected', + selectCurrentPage: 'Select current page', + clearSelection: 'Clear selection', + batchMode: 'Batch mode', + exitBatchMode: 'Exit batch mode', + dragHandleEnabledTitle: 'Drag to reorder', + dragHandleDisabledTitle: 'Drag sorting is unavailable for the current sort mode' + }, + sort: { + custom: 'Custom order', + renewal: 'By next renewal', + amountDesc: 'By amount descending', + name: 'By name' + }, + status: { + active: 'Active', + paused: 'Paused', + cancelled: 'Cancelled', + expired: 'Expired' + }, + actions: { + tagManagement: 'Tag management', + create: 'New subscription', + batchRenew: 'Batch renew', + setActive: 'Set active', + setPaused: 'Set paused', + setCancelled: 'Set cancelled', + batchDelete: 'Batch delete', + reorder: 'Reorder', + finishReorder: 'Done reordering', + detail: 'Details', + records: 'Records', + edit: 'Edit', + renew: 'Renew', + pause: 'Pause', + cancel: 'Cancel', + resume: 'Resume' + }, + labels: { + nextRenewal: 'Next renewal', + autoRenew: 'Auto renew', + note: 'Notes:', + currentCycle: 'Current cycle', + remainingValue: 'Remaining value', + interval: 'Billing interval', + originalAmount: 'Original amount', + advanceReminders: 'Advance reminders', + overdueReminders: 'Overdue reminders' + }, + values: { + interval: 'Every {count} {unit}' + }, + confirm: { + pause: 'Pause this subscription?', + cancel: 'Cancel this subscription?', + resume: 'Resume this subscription to active status?', + delete: 'Delete "{name}" and its renewal records/history? This action cannot be undone.' + }, + messages: { + subscriptionUpdated: 'Subscription updated', + subscriptionCreated: 'Subscription created', + subscriptionSaveFailed: 'Save failed: {message}', + tagCreated: 'Tag created', + tagCreateFailed: 'Failed to create tag: {message}', + tagUpdated: 'Tag updated', + tagUpdateFailed: 'Failed to update tag: {message}', + tagDeleted: 'Deleted tag: {name}', + tagDeleteFailed: 'Failed to delete tag: {message}', + resetToCurrent: 'Reset to the current subscription content', + resetForm: 'Form reset', + logoSearchEmpty: 'No logos found', + logoSearchFailed: 'Failed to search logos', + localLogoLoadFailed: 'Failed to load local logos', + localLogoFirst: 'No name or website provided, showing saved local logos first.', + logoSavedAndApplied: 'Saved locally and applied', + logoImportFailed: 'Failed to import logo', + logoReused: 'Reused from local library', + localLogoDeleted: 'Local logo deleted', + logoDeleteFailed: 'Failed to delete logo', + logoUploadSuccess: 'Logo uploaded successfully', + logoUploadFailed: 'Failed to upload logo', + chooseRequiredDates: 'Select the start date and next renewal date', + dragSortEnabled: 'Drag sorting enabled. Use the handle to reorder items.', + orderUpdated: 'Order updated', + orderUpdateFailed: 'Failed to update sort order', + batchActionSuccess: '{label} succeeded for {count} items', + batchActionPartial: '{label} finished: {success} succeeded, {failure} failed', + batchDeleteSuccess: 'Batch delete succeeded for {count} items', + batchDeletePartial: + 'Batch delete finished: deleted {success} items, skipped {skipped} active items, and failed {failure} items', + renewed: 'Renewed: {name}', + paused: 'Paused', + resumed: 'Resumed', + cancelled: 'Cancelled', + deleted: 'Deleted: {name}' + }, + batch: { + selectFirst: 'Select subscriptions first', + renewSuccess: 'Batch renew succeeded for {count} items', + renewPartial: 'Batch renew finished: {success} succeeded, {failure} failed', + statusConfirm: 'Set the selected {count} subscriptions to {status}?', + deleteConfirmAll: 'Delete the selected {count} subscriptions? This action cannot be undone.', + deleteConfirmPartial: + 'Delete subscriptions in batch? {deletable} items will be deleted and {blocked} active items will be skipped. This action cannot be undone.' + }, + detail: { + title: 'Subscription details', + remainingDays: '{days} days remaining / {ratio}' + }, + form: { + titleCreate: 'Create subscription', + titleEdit: 'Subscription details', + savingDescription: 'Saving, please wait...', + namePlaceholder: 'For example: GitHub Pro', + descriptionPlaceholder: 'Optional, briefly describe the subscription', + amountPlaceholder: 'Enter an amount, use 0 for free subscriptions', + currencyPlaceholder: 'Select a currency', + frequencyPlaceholder: 'Select frequency', + unitPlaceholder: 'Select a unit', + tagPlaceholder: 'Select tags', + websiteLabel: 'Official / platform URL', + nextRenewalLabel: 'Next renewal', + recalculateNextRenewal: 'Recalculate next renewal based on start date and billing interval', + advanceReminderRulesPlaceholder: 'Leave empty to use the system default, e.g. 3&09:30;0&09:30;', + overdueReminderRulesPlaceholder: 'Leave empty to use the system default, e.g. 1&09:30;2&09:30;', + notesPlaceholder: 'Optional, record account, plan, or special notes', + notificationEnabledLabel: 'Enable reminder notifications', + logo: { + upload: 'Click to upload', + placeholder: 'Logo', + panelTitle: 'Select a logo', + webTab: 'Web search ({count})', + libraryTab: 'Saved locally ({count})', + searching: 'Searching for logos...', + noSearchResults: 'No web search results are available right now', + loadingLocal: 'Loading local logos...', + noLocalResults: 'No reusable local logos yet', + usedCount: 'Used {count} times', + source: { + upload: 'Local upload', + remote: 'Remote import', + wallosZip: 'Wallos ZIP', + local: 'Local library' + } + }, + actions: { + aiRecognize: 'AI recognition', + previewReminderRules: 'Preview reminder rules', + collapseReminderPreview: 'Collapse reminder preview' + } + }, + aiModal: { + title: 'AI subscription recognition', + description: + 'Enter text, upload an image, or paste a screenshot directly. If the current model does not support image recognition, the system falls back to local OCR before sending text to the model. Results only fill the form and are not saved automatically.', + loading: 'Recognizing, please wait...', + loadingHint: 'The result appears automatically when ready. No need to click again.', + textInput: 'Text input', + textLabel: 'Text', + textPlaceholder: 'Paste subscription emails, payment records, order text, and more', + imageInput: 'Image input', + imageLabel: 'Image', + uploadImage: 'Upload image', + clearImage: 'Clear image', + imageTip: 'Supports screenshot upload and direct paste', + pasteHint: 'You can also paste a screenshot directly here', + imagePreviewAlt: 'Recognition image preview', + confidence: 'Confidence', + confidenceWithValue: 'Confidence: {value}%', + recognize: 'Start recognition', + recognizing: 'Recognizing', + applyResult: 'Apply result', + resultTitle: 'Recognition result', + rawText: 'Raw extracted text', + rawTextTitle: 'Raw extracted text', + result: 'Recognition result', + noInput: 'Enter text or upload an image first', + pleaseProvideInput: 'Enter text or upload an image first', + recognitionCompleted: 'Recognition completed', + recognitionFailed: 'AI recognition failed', + field: 'Field', + recognizedResult: 'Recognized result', + fields: { + name: 'Name', + description: 'Description', + amount: 'Amount', + currency: 'Currency', + billingIntervalCount: 'Frequency', + billingIntervalUnit: 'Unit', + startDate: 'Start date', + nextRenewalDate: 'Next renewal', + notifyDaysBefore: 'Reminder days', + websiteUrl: 'Website', + notes: 'Notes' + } + }, + paymentRecords: { + title: 'Renewal records', + noData: 'No renewal records', + renewedAt: 'Renewed at', + convertedAmount: 'Converted amount', + periodStart: 'Period start', + periodEnd: 'Period end' + }, + backupModal: { + title: 'Restore backup', + description: + 'This ZIP restores subscriptions, tags, payment records, ordering, settings, and local logos. It does not restore credentials, session secrets, webhook history, or exchange rate snapshots.', + pickZip: 'Choose ZIP file', + noFileSelected: 'No file selected', + previewBackup: 'Preview backup', + subscriptions: 'Subscriptions', + tags: 'Tags', + paymentRecords: 'Payment records', + localLogos: 'Local logos', + restoreMode: 'Restore mode', + replaceMode: 'Clear existing data and restore', + appendMode: 'Keep existing data and append restore', + replaceWarning: + 'This deletes current subscriptions, tags, payment records, ordering, settings, and local logos before restoring from the file.', + appendHelp: + 'For append restore: tags with the same name are reused; subscriptions and payment records are skipped idempotently by backup CUID; settings overwrite is controlled separately.', + restoreSettingsLabel: 'Also overwrite current settings', + restorePreview: 'Restore preview', + existingSameNameTags: 'Existing tags with the same name:', + existingSubscriptions: 'Existing subscriptions with the same CUID:', + existingPaymentRecords: 'Existing payment records with the same CUID:', + warnings: 'Warnings', + confirmRestore: 'Confirm restore', + invalidZip: 'The backup ZIP could not be parsed', + previewFailed: 'Failed to preview backup', + previewGenerated: 'Backup preview generated', + nothingImported: 'No new data was imported. Duplicate entries were skipped automatically.', + restoreCompleted: 'Restore completed: {subscriptions} subscriptions, {tags} new tags, {payments} payment records, {logos} logos', + restoreFailed: 'Restore failed' + } + }, + imports: { + wallos: { + title: 'Import Wallos data', + description: 'Upload Wallos JSON, SQLite database, or ZIP files. Only tags actually used by subscriptions are imported.', + pickFile: 'Choose file', + preview: 'Generate preview', + confirmImport: 'Confirm import', + previewTitle: 'Import preview', + warningTitle: 'Warnings', + sourceTimezoneLabel: 'Wallos source timezone (advanced)', + sourceTimezonePlaceholder: 'Defaults to the current business timezone', + sourceTimezoneHint: 'Adjust this only if the exported Wallos instance used a different TZ. Otherwise keep the default.', + importTypeLabel: 'Import type', + importableSubscriptionsLabel: 'Importable subscriptions', + importedTagsLabel: 'Imported tags', + zipLogoLabel: 'ZIP logo matches', + tagPreviewTitle: 'Tag preview', + noImportableTags: 'No importable tags', + subscriptionPreviewTitle: 'Subscription preview', + jsonWarning: + 'Wallos JSON import detected. Wallos DB import is recommended first because the DB includes more complete data such as start_date and full currency codes. JSON can still be imported, but some fields may degrade, such as inferred currency or fallback start dates.', + noWarnings: 'No extra warnings', + warningCount: '{count} warnings', + sourceId: 'Source ID', + tagName: 'Tag name', + order: 'Order', + name: 'Name', + amount: 'Amount', + frequency: 'Frequency', + nextRenewal: 'Next renewal', + tags: 'Tags', + noTags: 'No tags', + autoRenew: 'Auto renew', + yes: 'Yes', + no: 'No', + status: 'Status', + logo: 'Logo', + logoNone: 'None', + logoPending: 'Pending match', + logoReady: 'Ready from ZIP', + previewGenerated: 'Import preview generated', + previewFailed: 'Failed to generate preview', + importCompleted: 'Import completed: {subscriptions} subscriptions, {tags} tags, {logos} logos', + importFailed: 'Import failed', + fileTypes: { + json: 'JSON', + db: 'SQLite', + zip: 'ZIP' + } + } + }, + notifications: { + channels: { + email: 'Email', + pushplus: 'PushPlus', + telegram: 'Telegram', + serverchan: 'ServerChan', + gotify: 'Gotify', + webhook: 'Webhook' + }, + status: { + success: 'Success', + skipped: 'Skipped', + failed: 'Failed' + }, + phases: { + summary: 'Subscription reminder summary', + upcoming: 'Upcoming renewals', + dueToday: 'Due today', + overdue: 'Overdue reminders', + daysUntil: 'Due in {days} days', + overdueDay: 'Overdue day {days}' + }, + labels: { + reminderType: 'Reminder type: {value}', + subscriptionCount: 'Subscriptions: {count}', + subscriptionName: 'Subscription: {name}', + date: 'Date: {value}', + amount: 'Amount: {value}', + details: 'Details: {value}', + tags: 'Tags: {value}', + website: 'Website: {value}', + notes: 'Notes: {value}', + sectionTitle: '{title} ({count})' + }, + values: { + daysUntil: '{days} days left', + overdueDays: '{days} days overdue' + }, + titles: { + summaryCount: '{phase}: {count} subscriptions', + single: '{phase}: {name}' + }, + forgotPassword: { + title: 'SubTracker password reset verification code', + username: 'Username: {username}', + code: 'Verification code: {code}', + expiresInMinutes: 'Expires in: {minutes} minutes', + ignoreHint: 'If this was not requested by you, you can ignore this notification.' + }, + tests: { + subscriptionName: 'Test subscription', + tagName: 'Test tag', + note: 'This is a test notification.' + } + }, + validation: { + websiteUrlInvalid: 'Enter a valid URL, for example https://example.com', + subscriptionForm: { + nameRequired: 'Enter a name', + nameTooLong: 'Name cannot exceed 150 characters', + descriptionTooLong: 'Description cannot exceed 500 characters', + amountInvalid: 'Enter a valid amount', + currencyInvalid: 'Select a valid currency', + billingIntervalCountInvalid: 'Frequency must be a positive integer', + billingIntervalUnitRequired: 'Select a billing interval unit', + startDateRequired: 'Select a start date', + nextRenewalDateRequired: 'Select the next renewal date', + nextRenewalDateEarlierThanStartDate: 'The next renewal date cannot be earlier than the start date', + notesTooLong: 'Notes cannot exceed 1000 characters' + }, + reminderRules: { + fallback: 'Use system default', + emptyTitle: 'Enter rules before previewing', + resultTitle: 'Preview result', + invalidTitle: 'Invalid rule format', + defaultRulesLabel: 'system default rules', + defaultAdvanceRulesLabel: 'System default advance rules', + defaultOverdueRulesLabel: 'System default overdue rules', + fallbackPreviewTitle: 'No value entered. Previewing with {label}', + fallbackInvalidTitle: '{label} is invalid', + noAdvance: 'No advance 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 day at {time}', + inlineAdvanceBefore: '{days} day(s) before at {time}', + inlineOverdue: '{days} overdue day(s) at {time}', + evalAdvanceSameDay: 'Remind on the due day at {time}', + evalAdvanceBefore: 'Remind {days} day(s) before at {time}', + evalOverdue: 'Remind {days} overdue day(s) later at {time}' + } + }, + ai: { + status: { + textFast: 'Recognizing. This usually finishes within a few seconds. Please do not click repeatedly.', + textSlow: 'Still recognizing. The model may be responding slowly, please wait a little longer.', + imageFast: 'Recognizing image and text. This usually takes 5-10 seconds. Please do not close the window.', + imageSlow: 'Image recognition is still running. The external model is responding slowly, please wait a bit longer.' + }, + prompts: { + subscription: { + default: `You are a subscription billing extractor. Extract subscription details from the input text or screenshot and return JSON only. +Output fields: +- name +- description +- amount +- currency +- billingIntervalCount +- billingIntervalUnit(day|week|month|quarter|year) +- startDate(YYYY-MM-DD) +- nextRenewalDate(YYYY-MM-DD) +- notifyDaysBefore +- websiteUrl +- notes +- confidence(0~1) +- rawText + +Rules: +1. Leave uncertain fields empty. Do not guess. +2. Amount must be numeric. +3. Currency must be a 3-letter uppercase code such as CNY or USD. +4. billingIntervalUnit must be one of day/week/month/quarter/year. +5. Return JSON only. Do not return Markdown.` + }, + dashboard: { + summary: { + default: `You are a subscription operations summary assistant. Based on the user's current subscription statistics, generate a concise, accurate, and actionable Markdown summary. + +Goals: +1. Help the user quickly understand subscription scale, spending structure, budget pressure, and short-term renewal risk. +2. Summarize clear patterns, anomalies, and items worth attention in the data. +3. Provide neutral, actionable advice without relying on knowledge of specific subscription services. + +Hard requirements: +- Analyze only the input data. Do not invent facts. +- Do not assume you understand the feature details of any subscription service. +- Do not suggest things like "canceling a service saves money" or "two services overlap". +- Do not speculate about user preference, usage frequency, or intent. +- Do not output JSON or code blocks. Output Markdown body only. + +Suggested output structure: +## Overview +## Spending structure +## Near-term risk +## Notable patterns +## Neutral suggestions + +Writing requirements: +- Use clear English. +- Be decisive and concise. +- Keep each section to 2 to 5 bullets. +- If a section has no clear anomaly, say so directly.` + }, + preview: { + default: `You are a summary compression assistant. Based on an already generated full AI summary, condense it into a very short preview suitable for a collapsed default view. + +Hard requirements: +- Output plain English text only. No Markdown and no code blocks. +- Output 2 to 3 lines, one sentence per line, with natural line breaks. +- Do not output a title, bullets, or numbering. +- Keep only the most important conclusions: subscription scale, budget pressure, and short-term risk. +- Do not add information that was not present in the original summary. +- If the original summary is limited, return 1 to 2 natural sentences directly.` + } + } + } + }, + api: { + errors: { + unauthorized: 'Please sign in first', + tooManyAttempts: 'Too many failed sign-in attempts. Please try again later.', + internal: 'Unknown server error', + logoNotFound: 'Logo not found', + conflict: 'Resource conflict', + validation: { + invalidSettingsPayload: 'Invalid settings payload', + invalidReminderRules: 'Invalid reminder rules', + invalidEmailConfigPayload: 'Invalid email config payload', + invalidPushplusConfigPayload: 'Invalid PushPlus config payload', + invalidTelegramConfigPayload: 'Invalid Telegram config payload', + invalidServerchanConfigPayload: 'Invalid ServerChan config payload', + invalidGotifyConfigPayload: 'Invalid Gotify config payload', + invalidWebhookSettingsPayload: 'Invalid webhook settings payload', + invalidForgotPasswordRequestPayload: 'Invalid forgot password request payload', + invalidForgotPasswordResetPayload: 'Invalid forgot password reset payload', + invalidPasswordPayload: 'Invalid password payload', + invalidCredentialsPayload: 'Invalid credentials payload', + invalidAiConfigPayload: 'Invalid AI config payload', + invalidScanDebugPayload: 'Invalid notification scan debug payload', + invalidWallosInspectPayload: 'Invalid Wallos inspect payload', + invalidWallosCommitPayload: 'Invalid Wallos import payload', + invalidSubtrackerBackupInspectPayload: 'Invalid SubTracker backup inspect payload', + invalidSubtrackerBackupCommitPayload: 'Invalid SubTracker backup commit payload', + invalidTagPayload: 'Invalid tag payload', + invalidTagId: 'Invalid tag id', + invalidCurrentVersionQuery: 'Invalid currentVersion query', + invalidLogoSearchPayload: 'Invalid logo search payload', + invalidLogoFilename: 'Invalid logo filename', + invalidLogoUploadPayload: 'Invalid logo upload payload', + invalidLogoImportPayload: 'Invalid logo import payload', + invalidQuery: 'Invalid query', + invalidReorderPayload: 'Invalid reorder payload', + invalidBatchRenewPayload: 'Invalid batch renew payload', + invalidBatchStatusPayload: 'Invalid batch status payload', + invalidBatchPausePayload: 'Invalid batch pause payload', + invalidBatchCancelPayload: 'Invalid batch cancel payload', + invalidBatchDeletePayload: 'Invalid batch delete payload', + invalidSubscriptionId: 'Invalid subscription id', + invalidSubscriptionPayload: 'Invalid subscription payload', + invalidUpdatePayload: 'Invalid update payload', + invalidRenewPayload: 'Invalid renew payload', + invalidWebhookUrlRequired: 'URL is required when Webhook is enabled' + }, + auth: { + invalidCredentials: 'Incorrect username or password', + currentCredentialsInvalid: 'Incorrect current username or password', + defaultPasswordChangeNotAllowed: 'Default password change is not allowed right now', + forgotPasswordDisabled: 'Forgot password is disabled or no available notification channel is configured.', + forgotPasswordRequestRateLimited: 'Verification codes are being requested too frequently. Please try again later.', + forgotPasswordRequestCooldown: 'A verification code was just sent. Please try again later.', + forgotPasswordDeliveryFailed: 'Failed to send the verification code. Check the notification configuration.', + forgotPasswordResetRateLimited: 'Too many failed verification attempts. Please try again later.', + forgotPasswordChallengeNotFound: 'The verification code is invalid or has expired.', + forgotPasswordAttemptsExhausted: 'No verification attempts remain. Request a new code.', + forgotPasswordCodeInvalid: 'Too many incorrect verification attempts. Request a new code.', + forgotPasswordCodeInvalidWithAttempts: 'Incorrect verification code. {attempts} attempt(s) remaining.', + forgotPasswordResetFailed: 'Failed to reset the password', + forgotPasswordChannelRequired: 'Enable at least one direct notification channel before turning on forgot password.' + }, + settings: { + emailFieldsRequired: 'To enable Email notifications, fill in: {fields}', + pushplusTokenRequired: 'To enable PushPlus, fill in Token', + telegramFieldsRequired: 'To enable Telegram notifications, fill in: {fields}', + serverchanSendKeyRequired: 'To enable ServerChan, fill in SendKey', + gotifyFieldsRequired: 'To enable Gotify, fill in: {fields}', + aiFieldsRequired: 'To enable AI capability, fill in: {fields}' + }, + ai: { + disabled: 'AI capability is disabled', + configIncomplete: 'AI configuration is incomplete', + summaryDisabled: 'AI summary is disabled', + summaryConfigIncomplete: 'AI summary configuration is incomplete', + invalidRecognitionInput: 'Invalid AI recognition input', + noValidContent: 'AI did not return valid content', + noRecognizableText: 'No text content is available for recognition', + ocrNoValidText: 'OCR did not extract valid text from the image. Enter the text manually instead.', + connectionTestFailed: 'AI connection test failed', + visionTestFailed: 'AI vision test failed', + recognitionFailed: 'AI recognition failed', + summaryFetchFailed: 'Failed to fetch AI summary', + summaryGenerateFailed: 'Failed to generate AI summary' + }, + notifications: { + emailDisabledOrIncomplete: 'Email notifications are disabled or incomplete', + pushplusDisabledOrIncomplete: 'PushPlus notifications are disabled or incomplete', + telegramDisabledOrIncomplete: 'Telegram notifications are disabled or incomplete', + serverchanDisabledOrIncomplete: 'ServerChan notifications are disabled or incomplete', + gotifyDisabledOrIncomplete: 'Gotify notifications are disabled or incomplete', + webhookConfigIncomplete: 'Webhook configuration is incomplete', + webhookTestFailed: 'Webhook test failed', + emailTestFailed: 'Email test failed', + pushplusTestFailed: 'PushPlus test failed', + telegramTestFailed: 'Telegram test failed', + serverchanTestFailed: 'ServerChan test failed', + gotifyTestFailed: 'Gotify test failed' + }, + imports: { + wallosInspectFailed: 'Wallos inspect failed', + wallosCommitFailed: 'Wallos import failed', + subtrackerBackupInspectFailed: 'SubTracker backup inspect failed', + subtrackerBackupCommitFailed: 'SubTracker backup restore failed' + }, + tags: { + nameExists: 'Tag name already exists', + updateFailed: 'Failed to update tag', + notFound: 'Tag not found' + }, + version: { + updateFetchFailed: 'Failed to fetch version updates' + }, + exchangeRates: { + refreshFailed: 'Failed to refresh exchange rates' + }, + subscriptions: { + notFound: 'Subscription not found', + logoDeleteFailed: 'Failed to delete logo', + logoUploadFailed: 'Failed to upload logo', + logoImportFailed: 'Failed to import logo', + renewFailed: 'Renew failed', + activeDeleteNotAllowed: 'Active subscriptions cannot be deleted directly. Pause or cancel them first.', + batchPauseOnlyActive: 'Only active subscriptions can be paused in batch mode', + batchCancelOnlyActive: 'Only active subscriptions can be cancelled in batch mode', + activeDeleteBlocked: 'Active subscriptions cannot be deleted directly', + websiteUrlInvalid: 'websiteUrl is invalid. Enter a valid URL' + } + } + } +} as const diff --git a/packages/shared/src/locales/zh-CN.ts b/packages/shared/src/locales/zh-CN.ts new file mode 100644 index 0000000..671a339 --- /dev/null +++ b/packages/shared/src/locales/zh-CN.ts @@ -0,0 +1,1182 @@ +export default { + common: { + actions: { + save: '保存', + refresh: '刷新', + update: '修改', + test: '测试', + cancel: '取消', + confirm: '确认', + close: '关闭', + expand: '展开', + collapse: '收起', + delete: '删除', + keep: '保留', + restore: '恢复', + search: '查询', + reset: '重置', + preview: '预览', + import: '导入', + export: '导出', + create: '新建', + edit: '编辑', + reorder: '调整顺序', + done: '完成调整', + signOut: '退出登录', + connectionTest: '连接测试', + visionTest: '视觉测试' + }, + status: { + enabled: '已启用', + disabled: '未启用', + active: '正常', + paused: '暂停', + cancelled: '停用', + expired: '过期', + stale: '旧快照', + fresh: '最新' + }, + labels: { + name: '名称', + description: '描述', + notes: '备注', + amount: '金额', + currency: '货币', + status: '状态', + tags: '标签', + startDate: '开始日期', + nextRenewal: '下次续订', + autoRenew: '自动续订', + notifications: '提醒通知', + createdAt: '创建时间', + from: '发件人', + to: '收件人', + provider: '来源名称', + username: '用户名', + password: '密码', + token: 'Token', + url: 'URL', + unit: '单位', + frequency: '频率', + actions: '操作', + port: '端口', + code: '验证码', + model: 'Model', + requestMethod: '请求方法', + host: 'Host', + secure: 'Secure', + chatId: 'Chat ID', + botToken: 'Bot Token', + sendKey: 'SendKey', + apiBaseUrl: 'API Base URL', + apiKey: 'API Key', + topic: 'Topic' + }, + empty: { + noData: '暂无数据', + noDescription: '暂无描述', + noNotes: '暂无备注', + noTags: '未打标签' + }, + errors: { + requestFailed: '请求失败' + }, + placeholders: { + noFileSelected: '未选择文件' + }, + separators: { + list: '、' + }, + locales: { + zhCN: '简体中文', + enUS: 'English' + }, + units: { + day: '天', + week: '周', + month: '月', + quarter: '季', + year: '年', + minutes: '分钟' + } + }, + app: { + brand: 'SubTracker', + shellTitle: '订阅管理台', + shellSubtitle: '多币种 · 提醒 · 统计 · 日历', + notSignedIn: '未登录', + changeDefaultPasswordTitle: '请先修改默认密码', + changeDefaultPasswordWarning: '当前仍在使用默认管理员密码。为了继续使用系统,请先修改密码。', + newPassword: '新密码', + confirmNewPassword: '再次输入新密码', + versionUpdates: '版本更新', + releaseUnknown: '发布时间未知', + viewRelease: '查看 Release', + noNewRelease: '暂无比当前版本更高的 release。', + updateAvailable: '检测到新版本,当前版本 {currentVersion},最新版本 {latestVersion}', + alreadyLatest: '当前已经是最新版本({version})', + menu: { + dashboard: '仪表盘', + subscriptions: '订阅管理', + calendar: '订阅日历', + statistics: '费用统计', + budgets: '预算统计', + settings: '系统设置' + }, + auth: { + login: '登录' + }, + theme: { + current: '当前主题:{current},点击切换到{next}', + light: '浅色', + dark: '深色' + } + }, + auth: { + validation: { + usernameAndPasswordRequired: '请输入用户名和密码', + usernameRequired: '请输入用户名', + passwordRequired: '请输入密码', + loginPayloadInvalid: '登录信息格式不正确', + codeRequired: '请输入 6 位验证码', + codeFormat: '验证码必须为 6 位数字', + newPasswordRequired: '请输入新密码', + newPasswordMin: '新密码至少 4 位', + passwordMismatch: '两次输入的新密码不一致' + }, + success: { + login: '登录成功', + forgotPasswordCodeSent: '如果用户名有效且通知已启用,验证码已发送', + passwordResetAndLoggedIn: '密码已重置并自动登录', + defaultPasswordChanged: '默认密码已修改' + }, + error: { + login: '登录失败', + forgotPasswordCodeSend: '验证码发送失败', + passwordReset: '密码重置失败' + } + }, + login: { + title: '登录 SubTracker', + subtitle: '请输入您的用户名和密码', + rememberMe: '记住我', + rememberMeDays: '({days} 天)', + forgotPassword: '忘记密码', + collapseForgotPassword: '收起找回密码', + sendCode: '发送验证码', + verifyAndResetPassword: '验证并重置密码', + usernamePlaceholder: '请输入用户名', + passwordPlaceholder: '请输入密码', + codePlaceholder: '请输入 6 位验证码', + newPasswordPlaceholder: '请输入新密码', + confirmNewPasswordPlaceholder: '请再次输入新密码' + }, + settings: { + page: { + title: '系统设置', + subtitle: '管理基础参数、预算、汇率、通知与 AI 能力' + }, + sections: { + basic: '基础设置', + exchangeSnapshot: '汇率快照', + currentRates: '当前汇率(常用货币)', + converter: '汇率转换器', + notifications: '通知设置', + ai: 'AI 能力设置', + credentials: '登录凭据', + importExport: '导入和导出', + backup: '备份', + migration: '迁移', + about: 'About', + credits: 'Credits' + }, + labels: { + baseCurrency: '基准货币', + timezone: '业务时区', + rememberSessionDays: '记住登录天数', + timezoneSample: '当前时区示例', + monthlyBudget: '月预算(基准货币)', + yearlyBudget: '年预算(基准货币)', + advanceReminderRules: '到期前提醒规则', + overdueReminderRules: '过期提醒规则', + mergeNotifications: '多订阅合并通知', + enableTagBudgets: '启用标签月预算', + sourceCurrency: '源货币', + targetCurrency: '目标货币', + providerPreset: 'Provider 预设', + capabilitySwitches: '能力开关', + structuredOutput: '优先结构化 JSON 输出', + requestTimeout: '请求超时(毫秒)', + customRecognitionPrompt: '自定义识别提示词', + customSummaryPrompt: '自定义总结提示词', + enableAi: '启用 AI 能力', + aiSummary: 'AI 总结', + aiVisionCapability: '模型视觉输入', + configurationDetails: '配置详情', + advancedConfig: '高级配置', + notificationProvider: '通知提供商', + providerName: 'Provider 名称', + providerUrl: '接口地址', + fetchedAt: '拉取时间', + snapshotStatus: '数据状态', + requestMethod: '请求方法', + customHeaders: '自定义请求头', + payloadTemplate: 'Payload 模板', + availableVariables: '可用变量:', + ignoreSsl: '忽略 SSL 校验', + smtpHost: 'SMTP Host', + secure: 'Secure', + resendApiUrl: 'Resend API URL', + resendApiKey: 'Resend API Key', + botToken: 'Bot Token', + chatId: 'Chat ID', + sendKey: 'SendKey', + topic: 'Topic', + apiBaseUrl: 'API Base URL', + apiKey: 'API Key', + oldUsername: '原用户名', + oldPassword: '原密码', + newUsername: '新用户名', + newPassword: '新密码', + enableForgotPassword: '允许通过通知验证码找回密码', + restoreSettings: '同时覆盖当前系统设置' + }, + helps: { + advanceReminderRules: + '格式说明:天数&时间;,例如 3&09:30; 表示提前 3 天在 09:30 提醒,0&09:30; 表示到期当天提醒;多条规则用 ; 分隔', + overdueReminderRules: + '格式说明:天数&时间;,例如 1&09:30; 表示过期 1 天后在 09:30 提醒;多条规则用 ; 分隔', + notificationSettings: + '统一管理邮箱、PushPlus、Telegram、Server 酱、Gotify 与 Webhook。每个渠道都可以单独保存并单独测试。', + aiSettings: 'AI 能力总开关控制识别与连接测试;AI 总结可单独开启或关闭。', + structuredOutput: + '开启后会优先使用厂商支持的结构化 JSON 输出;若不支持,系统会自动降级为普通 JSON 提示词模式。', + backup: + '支持通过 ZIP 进行备份与恢复,包含订阅、标签、支付记录、排序、系统设置与本地 Logo', + migration: '从第三方同类项目导入数据' + }, + buttons: { + previewReminderRules: '预览提醒规则', + collapseReminderPreview: '收起提醒预览', + exportBackup: '导出备份', + restoreBackup: '恢复备份', + importWallos: '导入 Wallos', + swapCurrencies: '交换源货币和目标货币' + }, + placeholders: { + optional: '可选', + notFilledRecipient: '未填写收件人', + notFilledSmtpHost: '未填写 SMTP Host', + fromAddress: 'SubTracker ', + advanceReminderRules: '例如:3&09:30;0&09:30;', + overdueReminderRules: '例如:1&09:30;2&09:30;3&09:30;', + multiEmail: '多个邮箱请用英文逗号分隔', + chatIdExample: '例如:123456789 或 -100xxxxxxxxxx', + gotifyUrl: 'https://gotify.example.com', + webhookUrl: 'https://example.com/hook', + aiBaseUrl: 'https://api.deepseek.com', + customHeaders: + '支持 JSON 对象或每行一个 Header,例如:\nContent-Type: application/json\nX-App: SubTracker', + customRecognitionPrompt: + '留空时,订阅识别使用系统识别提示词;仪表盘 AI 总结始终使用系统总结提示词', + customSummaryPrompt: '留空时,AI 总结使用系统预设总结提示词' + }, + summary: { + baseCurrencyTag: '基准货币 {currency}', + supportedCurrenciesTag: '支持 {count} 种货币', + selectCurrenciesToConvert: '请选择要转换的货币', + emailResend: 'Resend · 收件人:{to}', + emailSmtp: 'Host:{host} · 收件人:{to}' + }, + channels: { + email: '邮箱通知', + pushplus: 'PushPlus', + telegram: 'Telegram Bot', + serverchan: 'Server 酱', + gotify: 'Gotify', + webhook: 'Webhook' + }, + options: { + emailProvider: { + smtp: 'SMTP', + resend: 'Resend' + }, + aiProviderPreset: { + custom: '自定义', + aliyunBailian: '阿里百炼', + tencentHunyuan: '腾讯混元', + volcengineArk: '火山方舟' + } + }, + validation: { + emailMissingFields: '邮箱通知缺少必填项:{fields}', + pushplusMissingFields: 'PushPlus 缺少必填项:{fields}', + telegramMissingFields: 'Telegram 缺少必填项:{fields}', + serverchanMissingFields: 'Server 酱缺少必填项:{fields}', + gotifyMissingFields: 'Gotify 缺少必填项:{fields}', + webhookMissingFields: 'Webhook 缺少必填项:{fields}', + aiMissingFields: 'AI 能力缺少必填项:{fields}' + }, + messages: { + basicSaved: '基础设置已保存', + basicSaveFailed: '基础设置保存失败', + emailSaved: '邮箱通知配置已保存', + emailDisabled: '邮箱通知已关闭', + pushplusSaved: 'PushPlus 配置已保存', + pushplusDisabled: 'PushPlus 已关闭', + telegramSaved: 'Telegram 配置已保存', + telegramDisabled: 'Telegram 已关闭', + serverchanSaved: 'Server 酱配置已保存', + serverchanDisabled: 'Server 酱已关闭', + gotifySaved: 'Gotify 配置已保存', + gotifyDisabled: 'Gotify 已关闭', + aiSaved: 'AI 能力配置已保存', + aiDisabled: 'AI 能力已关闭', + aiConnectionTestSuccess: '连接测试成功:{provider} / {model} / {response}', + aiConnectionTestFailed: 'AI 连接测试失败', + aiVisionTestSuccess: '视觉测试成功:{provider} / {model} / {response}', + aiVisionTestFailed: 'AI 视觉测试失败', + ratesRefreshed: '汇率已刷新', + credentialsUpdated: '登录凭据已更新', + emailTestSent: '测试邮件已发送', + emailTestFailed: '邮箱测试失败', + pushplusTestSubmittedWithCode: 'PushPlus 测试请求已提交,流水号:{code}', + pushplusTestSubmitted: 'PushPlus 测试请求已提交', + pushplusTestFailed: 'PushPlus 测试失败', + telegramTestSent: 'Telegram 测试消息已发送', + telegramTestFailed: 'Telegram 测试失败', + serverchanTestSent: 'Server 酱测试消息已发送', + serverchanTestFailed: 'Server 酱测试失败', + gotifyTestSent: 'Gotify 测试消息已发送', + gotifyTestFailed: 'Gotify 测试失败', + zipExportStarted: 'ZIP 导出已开始', + zipExportFailed: 'ZIP 导出失败', + backupRestored: '备份已恢复', + backupAppendedWithSettings: '备份已追加恢复,并覆盖了系统设置', + backupAppended: '备份已追加恢复', + wallosImported: 'Wallos 数据已导入', + webhookSaved: 'Webhook 配置已保存', + webhookDisabled: 'Webhook 已关闭', + webhookTestSuccessWithPreview: 'Webhook 测试成功,HTTP {statusCode}:{preview}', + webhookTestSuccess: 'Webhook 测试成功,HTTP {statusCode}', + webhookTestFailed: 'Webhook 测试失败' + }, + about: { + releaseNotes: 'Release Notes', + license: 'License', + issues: 'Issues and Requests', + author: 'The author', + documentation: 'Documentation', + readmeDeployment: 'README / DEPLOYMENT', + credits: { + wallos: 'Wallos', + vueVite: 'Vue 3 / Vite', + naiveUi: 'Naive UI', + fastifyPrisma: 'Fastify / Prisma', + piniaTanstackEcharts: 'Pinia / TanStack Query / ECharts' + } + } + }, + dashboard: { + page: { + title: '仪表盘', + subtitle: '总览订阅规模、预算使用、待续订与支出分布' + }, + cards: { + activeSubscriptions: '活跃订阅', + renewalsIn7Days: '7 天内续订', + estimatedMonthlySpend: '本月预计支出', + estimatedYearlySpend: '年度预计支出' + }, + sections: { + monthlyBudgetUsage: '月预算使用', + yearlyBudgetUsage: '年预算使用', + tagBudgetOverview: '标签预算概况', + tagMonthlySpend: '标签月度支出', + monthlyTrend: '月支付趋势(未来12个月)', + upcoming30: '即将续订(30天)' + }, + labels: { + usedPrefix: '已使用', + budgetPrefix: '/ 预算', + configuredTagBudgets: '已配置标签预算', + nearingBudget: '接近预算', + overBudget: '超标', + topUsageRate: '使用率最高' + }, + empty: { + noMonthlyBudget: '未设置月预算', + noYearlyBudget: '未设置年预算', + noTagBudgetConfigured: '尚未配置标签预算', + noData: '暂无数据' + }, + table: { + subscription: '订阅', + nextRenewal: '下次续订', + originalAmount: '原始金额', + convertedAmount: '折算金额', + status: '状态' + } + }, + budgets: { + page: { + title: '预算统计', + subtitle: '查看总预算使用情况与标签月预算分析' + }, + sections: { + monthlyBudgetUsage: '月预算使用', + yearlyBudgetUsage: '年预算使用', + tagBudgetUsageRate: '标签预算使用率', + budgetSummary: '预算摘要', + topUsageRate: '使用率最高 Top 3', + tagBudgetUsageTable: '标签预算使用表', + tagBudget: '标签预算' + }, + labels: { + usedPrefix: '已使用', + budgetPrefix: '/ 预算', + configuredTagBudgets: '已配置标签预算', + nearingBudget: '接近预算', + overBudget: '超标', + tag: '标签', + spent: '已使用', + budget: '预算', + remainingOrOver: '剩余 / 超出', + usageRate: '使用率', + status: '状态', + remainingPrefix: '剩余', + overPrefix: '超出' + }, + empty: { + noMonthlyBudget: '未设置月预算', + noYearlyBudget: '未设置年预算', + noTagBudgetData: '暂无标签预算数据', + noConfiguredTagBudgets: '尚未配置标签预算' + }, + hints: { + section: '标签月预算与总预算相互独立,仅对已配置预算的标签生效。' + }, + buttons: { + setTagBudgets: '设置标签月预算' + }, + status: { + normal: '正常', + warning: '接近预算', + over: '超标' + }, + messages: { + saved: '标签月预算已保存' + } + }, + calendar: { + page: { + title: '订阅日历', + subtitle: '查看订阅日期分布,支持月视图和列表视图' + }, + cards: { + currentMonth: '当前月份', + currentMonthSuffix: '当前正在查看的月份', + monthlyRenewalCount: '本月应续订数量', + monthlyRenewalCountSuffix: '当前月份内的订阅数', + monthlySpend: '本月预计需支出', + convertedSuffix: '已按汇率折算', + selectedDateRenewals: '选中日期订阅数' + }, + tabs: { + month: '月视图', + list: '列表视图' + }, + detail: { + dayRenewalsTitle: '当天续订({date})', + dayRenewalsSummary: '共 {count} 笔 · {currency} {amount}', + noRenewalOnDay: '当天无续订', + converted: '折算', + itemsSuffix: '笔' + }, + table: { + subscription: '订阅', + date: '日期', + amount: '原始金额', + convertedAmount: '折算金额', + status: '状态' + } + }, + statistics: { + page: { + title: '费用统计', + subtitle: '从趋势、结构和风险三个维度分析订阅支出' + }, + ai: { + title: 'AI 总结', + generatedAtPrefix: '最近生成:', + collapseDetails: '收起详情', + viewDetails: '查看详情', + regenerate: '重新生成总结', + expandedHint: '基于当前统计自动生成,不会修改订阅数据', + generatingHint: '正在基于当前统计生成 AI 总结,请稍候…', + unconfiguredHint: '请先前往系统设置启用 AI 能力与 AI 总结,之后统计页面会自动生成总结。', + failedFallback: 'AI 总结生成失败,请稍后重试。', + noSummary: '暂无 AI 总结', + previewLabel: '摘要', + updated: 'AI 总结已更新', + failed: 'AI 总结生成失败' + }, + sections: { + monthlyTrend: '月支付趋势(未来12个月)', + tagSpend: '标签月度支出占比', + statusDistribution: '状态分布', + autoRenewShare: '自动续订占比', + currencyDistribution: '订阅币种分布', + upcoming30: '未来30天续订分布', + top10: '月订阅支出 TOP10' + }, + empty: { + noData: '暂无数据', + noUpcoming30: '未来30天暂无续订' + }, + series: { + trend: '预测金额', + renewalCount: '续订数', + amount: '金额' + }, + labels: { + renewalsCountAxis: '续订数', + autoRenew: '自动续订', + manualRenew: '手动续订', + renewalCountTooltip: '订阅数', + amountTooltip: '月度金额' + }, + status: { + active: '正常', + paused: '暂停', + cancelled: '停用', + expired: '过期' + } + }, + tags: { + manage: { + title: '标签管理', + description: '在这里统一新增、编辑和删除标签。', + create: '新增标签', + tag: '标签', + sortOrder: '排序', + subscriptionCount: '订阅数', + actions: '操作', + deleteInUseConfirm: '删除后,该标签会从订阅上移除,确认继续?', + deleteConfirm: '确认删除该标签?' + }, + form: { + editTitle: '编辑标签', + createTitle: '新增标签', + nameLabel: '标签名称', + namePlaceholder: '例如:云服务', + colorLabel: '颜色', + colorPlaceholder: '#3b82f6 或 rgb(59,130,246)', + sortOrderLabel: '排序' + }, + budget: { + title: '设置标签月预算', + description: '为需要单独控制支出的标签设置月预算。未设置的标签不会参与标签预算分析。', + searchPlaceholder: '搜索标签', + budgetPlaceholder: '未设置({currency})' + } + }, + subscriptions: { + page: { + title: '订阅管理', + subtitle: '管理不同周期、不同币种的订阅', + searchPlaceholder: '搜索名称/描述', + statusPlaceholder: '状态', + sortPlaceholder: '排序方式', + collapseTagFilter: '收起标签筛选', + expandTagFilter: '展开标签筛选', + listTitle: '订阅列表', + noSubscriptions: '暂无订阅', + selectedItems: '已选 {count} 项', + selectCurrentPage: '全选当前页', + clearSelection: '清空选择', + batchMode: '批量管理', + exitBatchMode: '退出批量管理', + dragHandleEnabledTitle: '拖拽调整顺序', + dragHandleDisabledTitle: '当前排序不可拖拽' + }, + sort: { + custom: '自定义顺序', + renewal: '按下次续订', + amountDesc: '按金额从高到低', + name: '按名称' + }, + status: { + active: '正常', + paused: '暂停', + cancelled: '停用', + expired: '过期' + }, + actions: { + tagManagement: '标签管理', + create: '新建订阅', + batchRenew: '批量续订', + setActive: '设为正常', + setPaused: '设为暂停', + setCancelled: '设为停用', + batchDelete: '批量删除', + reorder: '调整顺序', + finishReorder: '完成调整', + detail: '详情', + records: '记录', + edit: '编辑', + renew: '续订', + pause: '暂停', + cancel: '取消', + resume: '恢复' + }, + labels: { + nextRenewal: '下次续订', + autoRenew: '自动续订', + note: '备注:', + currentCycle: '当前周期', + remainingValue: '剩余价值', + interval: '订阅频率', + originalAmount: '原始金额', + advanceReminders: '到期前提醒', + overdueReminders: '过期提醒' + }, + values: { + interval: '每 {count} {unit}' + }, + confirm: { + pause: '确认暂停该订阅?', + cancel: '确认取消该订阅?', + resume: '确认恢复该订阅为正常状态?', + delete: '将删除“{name}”及其续订记录与相关历史,此操作不可恢复,确认继续?' + }, + messages: { + subscriptionUpdated: '订阅已更新', + subscriptionCreated: '订阅已创建', + subscriptionSaveFailed: '保存失败:{message}', + tagCreated: '标签已创建', + tagCreateFailed: '标签创建失败:{message}', + tagUpdated: '标签已更新', + tagUpdateFailed: '标签更新失败:{message}', + tagDeleted: '已删除标签:{name}', + tagDeleteFailed: '标签删除失败:{message}', + resetToCurrent: '已重置为当前订阅内容', + resetForm: '已重置表单', + logoSearchEmpty: '没有找到可用 Logo', + logoSearchFailed: 'Logo 搜索失败', + localLogoLoadFailed: '读取本地 Logo 失败', + localLogoFirst: '未填写名称或官网时,先为你展示本地已保存 Logo。', + logoSavedAndApplied: '已保存到本地并应用', + logoImportFailed: 'Logo 导入失败', + logoReused: '已从本地库复用', + localLogoDeleted: '本地 Logo 已删除', + logoDeleteFailed: '删除 Logo 失败', + logoUploadSuccess: 'Logo 上传成功', + logoUploadFailed: 'Logo 上传失败', + chooseRequiredDates: '请选择开始日期和下次续订日期', + dragSortEnabled: '已开启拖拽排序,仅拖拽手柄可调整顺序', + orderUpdated: '顺序已更新', + orderUpdateFailed: '排序更新失败', + batchActionSuccess: '{label}成功,共 {count} 项', + batchActionPartial: '{label}完成:成功 {success} 项,失败 {failure} 项', + batchDeleteSuccess: '批量删除成功,共 {count} 项', + batchDeletePartial: '批量删除完成:已删除 {success} 项,跳过 {skipped} 项正常订阅,失败 {failure} 项', + renewed: '已续订:{name}', + paused: '已暂停', + resumed: '已恢复', + cancelled: '已停用', + deleted: '已删除:{name}' + }, + batch: { + selectFirst: '请先选择订阅', + renewSuccess: '批量续订成功,共 {count} 项', + renewPartial: '批量续订完成:成功 {success} 项,失败 {failure} 项', + statusConfirm: '确认将已选的 {count} 项订阅设为{status}吗?', + deleteConfirmAll: '确认批量删除已选的 {count} 项订阅吗?此操作不可恢复。', + deleteConfirmPartial: '确认批量删除吗?将删除 {deletable} 项,并跳过 {blocked} 项正常订阅。此操作不可恢复。' + }, + detail: { + title: '订阅详情', + remainingDays: '剩余 {days} 天 / {ratio}' + }, + form: { + titleCreate: '新建订阅', + titleEdit: '订阅信息', + savingDescription: '保存中,请稍候...', + namePlaceholder: '例如:GitHub Pro', + descriptionPlaceholder: '可选,简单记录订阅用途', + amountPlaceholder: '输入金额,免费可填 0', + currencyPlaceholder: '选择货币', + frequencyPlaceholder: '选择频率', + unitPlaceholder: '选择单位', + tagPlaceholder: '选择标签', + websiteLabel: '官网 / 平台地址', + nextRenewalLabel: '下次续订', + recalculateNextRenewal: '按开始日期和频率重新计算下次续订', + advanceReminderRulesPlaceholder: '留空则沿用系统默认,例如:3&09:30;0&09:30;', + overdueReminderRulesPlaceholder: '留空则沿用系统默认,例如:1&09:30;2&09:30;', + notesPlaceholder: '可选,记录账号、套餐或特别说明', + notificationEnabledLabel: '启用提醒通知', + logo: { + upload: '点击上传', + placeholder: 'Logo', + panelTitle: '选择 Logo', + webTab: '网络搜索 ({count})', + libraryTab: '本地已保存 ({count})', + searching: '正在搜索 Logo...', + noSearchResults: '当前没有可用的网络搜索结果', + loadingLocal: '正在加载本地 Logo...', + noLocalResults: '本地还没有可复用的 Logo', + usedCount: '已用 {count} 次', + source: { + upload: '本地上传', + remote: '远程导入', + wallosZip: 'Wallos ZIP', + local: '本地库' + } + }, + actions: { + aiRecognize: 'AI 识别', + previewReminderRules: '预览提醒规则', + collapseReminderPreview: '收起提醒预览' + } + }, + aiModal: { + title: 'AI 识别订阅', + description: + '支持输入文本、上传图片或直接粘贴截图。若当前模型不支持图片识别,将自动回退到本地 OCR 提取文本后再交给模型清洗。识别结果只会回填表单,不会自动保存。', + loading: '识别中,请稍候...', + loadingHint: '完成后会自动展示识别结果,无需重复点击。', + textInput: '文本输入', + textLabel: '文本内容', + textPlaceholder: '粘贴订阅邮件、支付记录、订单文本等', + imageInput: '图片输入', + imageLabel: '图片', + uploadImage: '上传图片', + clearImage: '清空图片', + imageTip: '支持截图上传,也支持直接粘贴图片', + pasteHint: '也可以直接在此区域粘贴截图', + imagePreviewAlt: '识别图片预览', + confidence: '置信度', + confidenceWithValue: '置信度:{value}%', + recognize: '开始识别', + recognizing: '识别中', + applyResult: '应用结果', + resultTitle: '识别结果', + rawText: '原始提取文本', + rawTextTitle: '原始提取文本', + result: '识别结果', + field: '字段', + recognizedResult: '识别结果', + noInput: '请先输入文本或上传图片', + pleaseProvideInput: '请先输入文本或上传图片', + recognitionCompleted: '识别完成', + recognitionFailed: 'AI 识别失败', + fields: { + name: '名称', + description: '描述', + amount: '金额', + currency: '货币', + billingIntervalCount: '频率', + billingIntervalUnit: '单位', + startDate: '开始日期', + nextRenewalDate: '下次续订', + notifyDaysBefore: '提醒天数', + websiteUrl: '网址', + notes: '备注' + } + }, + paymentRecords: { + title: '续订记录', + noData: '暂无续订记录', + renewedAt: '续订时间', + convertedAmount: '折算金额', + periodStart: '周期开始', + periodEnd: '周期结束' + }, + backupModal: { + title: '恢复备份', + description: + '该 ZIP 会恢复订阅、标签、支付记录、排序、系统设置与本地 Logo;不会恢复登录凭据、会话密钥、Webhook 历史和汇率快照', + pickZip: '选择 ZIP 文件', + noFileSelected: '未选择文件', + previewBackup: '预览备份', + subscriptions: '订阅', + tags: '标签', + paymentRecords: '支付记录', + localLogos: '本地 Logo', + restoreMode: '恢复模式', + replaceMode: '清空现有数据后恢复', + appendMode: '保留现有数据并追加恢复', + replaceWarning: + '将删除当前实例中的订阅、标签、支付记录、排序、系统设置和本地 Logo,然后再按文件内容重新恢复', + appendHelp: + '追加恢复时:同名标签会复用现有标签;订阅与支付记录按备份中的唯一标识(CUID)幂等跳过;系统设置是否覆盖由你单独选择', + restoreSettingsLabel: '同时覆盖当前系统设置', + restorePreview: '恢复预览', + existingSameNameTags: '现有同名标签:', + existingSubscriptions: '现有同唯一标识(CUID)订阅:', + existingPaymentRecords: '现有同唯一标识(CUID)支付记录:', + warnings: '警告信息', + confirmRestore: '确认恢复', + invalidZip: '备份 ZIP 无法解析', + previewFailed: '备份预览失败', + previewGenerated: '已生成备份预览', + nothingImported: '未导入任何新数据,重复项已自动跳过', + restoreCompleted: '恢复完成:{subscriptions} 条订阅,{tags} 个新标签,{payments} 条支付记录,{logos} 个 Logo', + restoreFailed: '恢复失败' + } + }, + imports: { + wallos: { + title: '导入 Wallos 数据', + description: '支持上传 Wallos 的 JSON、SQLite 数据库或 ZIP 包。当前只导入实际被订阅使用到的标签。', + pickFile: '选择文件', + preview: '生成预览', + confirmImport: '确认导入', + previewTitle: '导入预览', + warningTitle: '警告信息', + sourceTimezoneLabel: 'Wallos 源时区(高级)', + sourceTimezonePlaceholder: '默认使用当前业务时区', + sourceTimezoneHint: '仅在导出的 Wallos 实例使用了不同的 TZ 时需要调整,否则保持默认即可。', + importTypeLabel: '导入类型', + importableSubscriptionsLabel: '可导入订阅', + importedTagsLabel: '实际导入标签', + zipLogoLabel: 'ZIP Logo 匹配', + tagPreviewTitle: '标签预览', + noImportableTags: '没有可导入的标签', + subscriptionPreviewTitle: '订阅预览', + jsonWarning: + '检测到 Wallos JSON 导入。推荐优先使用 Wallos DB 导入:DB 包含 start_date、完整币种代码等更完整信息;JSON 虽可导入,但可能出现开始日期代填、币种推断等字段降级。', + noWarnings: '没有额外警告', + warningCount: '共 {count} 条警告', + sourceId: '来源 ID', + tagName: '标签名', + order: '排序', + name: '名称', + amount: '金额', + frequency: '频率', + nextRenewal: '下次续订', + tags: '标签', + noTags: '未打标签', + autoRenew: '自动续订', + yes: '是', + no: '否', + status: '状态', + logo: 'Logo', + logoNone: '无', + logoPending: '待匹配', + logoReady: 'ZIP 可导入', + previewGenerated: '已生成导入预览', + previewFailed: '预览生成失败', + importCompleted: '导入完成:{subscriptions} 条订阅,{tags} 个标签,{logos} 个 Logo', + importFailed: '导入失败', + fileTypes: { + json: 'JSON', + db: 'SQLite', + zip: 'ZIP' + } + } + }, + notifications: { + channels: { + email: '邮箱', + pushplus: 'PushPlus', + telegram: 'Telegram', + serverchan: 'Server 酱', + gotify: 'Gotify', + webhook: 'Webhook' + }, + status: { + success: '成功', + skipped: '跳过', + failed: '失败' + }, + phases: { + summary: '订阅提醒汇总', + upcoming: '即将到期', + dueToday: '今天到期', + overdue: '过期提醒', + daysUntil: '还有 {days} 天到期', + overdueDay: '已过期第 {days} 天' + }, + labels: { + reminderType: '提醒类型:{value}', + subscriptionCount: '订阅数量:{count} 项', + subscriptionName: '订阅名称:{name}', + date: '日期:{value}', + amount: '金额:{value}', + details: '说明:{value}', + tags: '标签:{value}', + website: '网址:{value}', + notes: '备注:{value}', + sectionTitle: '{title}({count} 项)' + }, + values: { + daysUntil: '还有 {days} 天', + overdueDays: '过期 {days} 天' + }, + titles: { + summaryCount: '{phase}:共 {count} 项订阅', + single: '{phase}:{name}' + }, + forgotPassword: { + title: 'SubTracker 密码重置验证码', + username: '用户名:{username}', + code: '验证码:{code}', + expiresInMinutes: '有效期:{minutes} 分钟', + ignoreHint: '如果这不是你的操作,请忽略本次通知。' + }, + tests: { + subscriptionName: '测试订阅', + tagName: '测试标签', + note: '这是一条测试通知' + } + }, + validation: { + websiteUrlInvalid: '请输入合法网址,例如 https://example.com', + subscriptionForm: { + nameRequired: '请填写名称', + nameTooLong: '名称不能超过 150 个字符', + descriptionTooLong: '描述不能超过 500 个字符', + amountInvalid: '请填写有效金额', + currencyInvalid: '请选择合法货币', + billingIntervalCountInvalid: '频率必须为正整数', + billingIntervalUnitRequired: '请选择频率单位', + startDateRequired: '请选择开始日期', + nextRenewalDateRequired: '请选择下次续订日期', + nextRenewalDateEarlierThanStartDate: '下次续订日期不能早于开始日期', + notesTooLong: '备注不能超过 1000 个字符' + }, + reminderRules: { + 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} 提醒' + } + }, + ai: { + status: { + textFast: '识别中,通常几秒内完成,请勿重复点击。', + textSlow: '仍在识别中,模型响应可能稍慢,请继续稍候。', + imageFast: '正在识别图片与文本,通常需要 5-10 秒,请勿关闭窗口。', + imageSlow: '图片识别仍在进行中,外部模型响应较慢,请再稍候片刻。' + }, + prompts: { + subscription: { + default: `你是订阅账单信息提取助手。请从输入的文本或截图中提取订阅信息,并且只返回 JSON。 +输出字段: +- name +- description +- amount +- currency +- billingIntervalCount +- billingIntervalUnit(day|week|month|quarter|year) +- startDate(YYYY-MM-DD) +- nextRenewalDate(YYYY-MM-DD) +- notifyDaysBefore +- websiteUrl +- notes +- confidence(0~1) +- rawText + +规则: +1. 不确定就留空,不要猜。 +2. 金额必须是数字。 +3. 币种必须是 3 位大写代码,例如 CNY、USD。 +4. 周期单位必须在 day/week/month/quarter/year 中。 +5. 只返回 JSON,不要返回 Markdown。` + }, + dashboard: { + summary: { + default: `你是订阅运营摘要助手。请基于用户当前的订阅统计数据,输出一份简洁、准确、可执行的 Markdown 总结。 + +目标: +1. 帮助用户快速理解当前订阅规模、支出结构、预算压力和近期续订风险。 +2. 总结数据中的明显模式、异常点和需要关注的事项。 +3. 给出中性、可执行、但不依赖具体服务功能知识的建议。 + +硬性要求: +- 只能基于输入数据分析,不要虚构事实。 +- 不要假设你了解某个订阅服务的功能细节。 +- 不要输出“取消某服务更省钱”“某两个服务功能重叠”之类的建议。 +- 不要臆测用户偏好、使用频率或用途。 +- 不要输出 JSON,不要输出代码块,只输出 Markdown 正文。 + +输出建议结构: +## 总览 +## 支出结构 +## 近期风险 +## 值得注意的模式 +## 中性建议 + +写作要求: +- 使用简体中文。 +- 结论明确,少空话。 +- 每个小节控制在 2~5 条要点内。 +- 如果某部分没有明显异常,直接说明“暂无显著异常”或“整体平稳”。` + }, + preview: { + default: `你是订阅统计摘要压缩助手。请根据已经生成好的完整 AI 总结,提炼出一个默认折叠展示用的超简短摘要。 + +硬性要求: +- 只输出简体中文纯文本,不要输出 Markdown,不要输出代码块。 +- 输出 2 到 3 行,每行一句,自然换行。 +- 不要输出标题,不要输出项目符号,不要编号。 +- 只保留最重要的结论:订阅规模、预算压力、近期风险。 +- 不要发散,不要补充原文没有的信息。 +- 如果原文信息有限,就直接给出 1 到 2 句自然语言摘要。` + } + } + } + }, + api: { + errors: { + unauthorized: '请先登录', + tooManyAttempts: '登录失败次数过多,请稍后再试', + internal: '未知服务端错误', + logoNotFound: 'Logo 不存在', + conflict: '资源冲突', + validation: { + invalidSettingsPayload: '设置请求体不合法', + invalidReminderRules: '提醒规则不合法', + invalidEmailConfigPayload: '邮箱配置请求体不合法', + invalidPushplusConfigPayload: 'PushPlus 配置请求体不合法', + invalidTelegramConfigPayload: 'Telegram 配置请求体不合法', + invalidServerchanConfigPayload: 'Server 酱配置请求体不合法', + invalidGotifyConfigPayload: 'Gotify 配置请求体不合法', + invalidWebhookSettingsPayload: 'Webhook 配置请求体不合法', + invalidForgotPasswordRequestPayload: '忘记密码请求体不合法', + invalidForgotPasswordResetPayload: '忘记密码重置请求体不合法', + invalidPasswordPayload: '密码请求体不合法', + invalidCredentialsPayload: '账号密码请求体不合法', + invalidAiConfigPayload: 'AI 配置请求体不合法', + invalidScanDebugPayload: '通知调试扫描请求体不合法', + invalidWallosInspectPayload: 'Wallos 预览请求体不合法', + invalidWallosCommitPayload: 'Wallos 导入请求体不合法', + invalidSubtrackerBackupInspectPayload: 'SubTracker 备份预览请求体不合法', + invalidSubtrackerBackupCommitPayload: 'SubTracker 备份恢复请求体不合法', + invalidTagPayload: '标签请求体不合法', + invalidTagId: '标签 ID 不合法', + invalidCurrentVersionQuery: '当前版本查询参数不合法', + invalidLogoSearchPayload: 'Logo 搜索请求体不合法', + invalidLogoFilename: 'Logo 文件名不合法', + invalidLogoUploadPayload: 'Logo 上传请求体不合法', + invalidLogoImportPayload: 'Logo 导入请求体不合法', + invalidQuery: '查询参数不合法', + invalidReorderPayload: '排序请求体不合法', + invalidBatchRenewPayload: '批量续订请求体不合法', + invalidBatchStatusPayload: '批量状态更新请求体不合法', + invalidBatchPausePayload: '批量暂停请求体不合法', + invalidBatchCancelPayload: '批量停用请求体不合法', + invalidBatchDeletePayload: '批量删除请求体不合法', + invalidSubscriptionId: '订阅 ID 不合法', + invalidSubscriptionPayload: '订阅请求体不合法', + invalidUpdatePayload: '订阅更新请求体不合法', + invalidRenewPayload: '续订请求体不合法', + invalidWebhookUrlRequired: '启用 Webhook 时必须填写 URL' + }, + auth: { + invalidCredentials: '用户名或密码错误', + currentCredentialsInvalid: '原用户名或原密码错误', + defaultPasswordChangeNotAllowed: '默认密码修改当前不可用', + forgotPasswordDisabled: '当前未开启忘记密码,或未配置可用通知渠道', + forgotPasswordRequestRateLimited: '验证码发送过于频繁,请稍后再试', + forgotPasswordRequestCooldown: '验证码刚刚发送过,请稍后再试', + forgotPasswordDeliveryFailed: '验证码发送失败,请检查通知配置', + forgotPasswordResetRateLimited: '验证失败次数过多,请稍后再试', + forgotPasswordChallengeNotFound: '验证码无效或已失效', + forgotPasswordAttemptsExhausted: '验证码尝试次数已用尽,请重新获取', + forgotPasswordCodeInvalid: '验证码错误次数过多,请重新获取', + forgotPasswordCodeInvalidWithAttempts: '验证码错误,还可重试 {attempts} 次', + forgotPasswordResetFailed: '密码重置失败', + forgotPasswordChannelRequired: '请先启用至少一个可直达的通知渠道,再开启忘记密码' + }, + settings: { + emailFieldsRequired: '启用邮箱通知时必须填写:{fields}', + pushplusTokenRequired: '启用 PushPlus 时必须填写 Token', + telegramFieldsRequired: '启用 Telegram 通知时必须填写:{fields}', + serverchanSendKeyRequired: '启用 Server 酱时必须填写 SendKey', + gotifyFieldsRequired: '启用 Gotify 时必须填写:{fields}', + aiFieldsRequired: '启用 AI 能力时必须填写:{fields}' + }, + ai: { + disabled: 'AI 能力未启用', + configIncomplete: 'AI 配置不完整', + summaryDisabled: 'AI 总结未启用', + summaryConfigIncomplete: 'AI 总结配置不完整', + invalidRecognitionInput: 'AI 识别输入不合法', + noValidContent: 'AI 未返回有效内容', + noRecognizableText: '未获取到可用于识别的文本内容', + ocrNoValidText: '图片 OCR 未识别出有效文本,请改为手动输入文本内容', + connectionTestFailed: 'AI 连接测试失败', + visionTestFailed: 'AI 视觉测试失败', + recognitionFailed: 'AI 识别失败', + summaryFetchFailed: '获取 AI 总结失败', + summaryGenerateFailed: '生成 AI 总结失败' + }, + notifications: { + emailDisabledOrIncomplete: '邮箱通知未启用或配置不完整', + pushplusDisabledOrIncomplete: 'PushPlus 通知未启用或配置不完整', + telegramDisabledOrIncomplete: 'Telegram 通知未启用或配置不完整', + serverchanDisabledOrIncomplete: 'Server 酱通知未启用或配置不完整', + gotifyDisabledOrIncomplete: 'Gotify 通知未启用或配置不完整', + webhookConfigIncomplete: 'Webhook 配置不完整', + webhookTestFailed: 'Webhook 测试失败', + emailTestFailed: '邮箱测试失败', + pushplusTestFailed: 'PushPlus 测试失败', + telegramTestFailed: 'Telegram 测试失败', + serverchanTestFailed: 'Server 酱测试失败', + gotifyTestFailed: 'Gotify 测试失败' + }, + imports: { + wallosInspectFailed: 'Wallos 预览失败', + wallosCommitFailed: 'Wallos 导入失败', + subtrackerBackupInspectFailed: 'SubTracker 备份预览失败', + subtrackerBackupCommitFailed: 'SubTracker 备份恢复失败' + }, + tags: { + nameExists: '标签名称已存在', + updateFailed: '标签更新失败', + notFound: '标签不存在' + }, + version: { + updateFetchFailed: '获取版本更新失败' + }, + exchangeRates: { + refreshFailed: '刷新汇率失败' + }, + subscriptions: { + notFound: '订阅不存在', + logoDeleteFailed: 'Logo 删除失败', + logoUploadFailed: 'Logo 上传失败', + logoImportFailed: 'Logo 导入失败', + renewFailed: '续订失败', + activeDeleteNotAllowed: '正常中的订阅不能直接删除,请先暂停或停用', + batchPauseOnlyActive: '批量暂停仅支持正常状态的订阅', + batchCancelOnlyActive: '批量停用仅支持正常状态的订阅', + activeDeleteBlocked: '正常中的订阅不能直接删除', + websiteUrlInvalid: 'websiteUrl 格式无效,请填写合法网址' + } + } + } +} as const