feat(i18n): 添加多语言支持,优化用户界面文本

在此提交中,我们引入了多语言支持,允许用户根据其语言偏好查看界面文本。新增了国际化库(i18next)并在多个组件中实现了翻译功能,确保错误消息和界面文本能够根据用户的语言设置动态变化。这一改动旨在提升用户体验,使得应用能够服务更广泛的用户群体,增强可访问性和友好性。同时,更新了相关文档以指导开发者如何使用新的国际化功能。
This commit is contained in:
Jeff Gu committed 2026-04-15 09:31:29 +08:00
1 parent 8261eeda34
commit a7c65b9476
33 files changed
+2328 -609

No files matched your search

+3 -2
View File
@@ -32,6 +32,7 @@ import {
UsageLogSearchEndpoint,
} from "./analytics_api"
import { getSystemConfig, isTelegramSecurityEnabled } from "../system-config"
import { t } from "../i18n"
import {
clearAdminSessionCookie,
getAdminSessionTokenFromRequest,
@@ -81,8 +82,8 @@ app.use('/api/admin/*', async (c, next) => {
return c.text(
securityEnabled
? "Telegram 登录验证已开启,请先完成验证码登录"
: "Unauthorized",
? t(c.get('lang') || 'zh-CN', 'auth.telegramRequired')
: t(c.get('lang') || 'zh-CN', 'auth.unauthorized'),
401
);
});
+12 -7
View File
@@ -12,6 +12,7 @@ import {
saveSystemConfig,
} from "../system-config";
import { sendTelegramTestNotification } from "./auth_shared";
import { t } from "../i18n";
const adminSecurityConfigSchema = z.object({
enabled: z.boolean(),
@@ -41,12 +42,12 @@ const telegramTestResponseSchema = z.object({
verifiedAt: z.string(),
});
const ensureSystemConfigValid = (config: SystemConfig) => {
const ensureSystemConfigValid = (config: SystemConfig, lang: string) => {
if (
config.adminSecurity.enabled
&& !isTelegramSecurityEnabled(config.adminSecurity)
) {
throw new Error("开启 Telegram 验证前,请先填写 Bot Token 和 Chat ID");
throw new Error(t(lang, "system.telegramConfigRequired"));
}
};
@@ -90,12 +91,13 @@ export class SystemConfigUpdateEndpoint extends OpenAPIRoute {
async handle(c: Context<HonoCustomType>) {
const body = await c.req.json<SystemConfig>();
const config = normalizeSystemConfig(body);
const lang = c.get('lang') || 'zh-CN';
try {
ensureSystemConfigValid(config);
ensureSystemConfigValid(config, lang);
} catch (error) {
return c.text(
error instanceof Error ? error.message : "系统设置无效",
error instanceof Error ? error.message : t(lang, "system.configInvalid"),
400
);
}
@@ -128,11 +130,12 @@ export class TelegramTestMessageEndpoint extends OpenAPIRoute {
};
async handle(c: Context<HonoCustomType>) {
const lang = c.get('lang') || 'zh-CN';
const parsedBody = telegramTestRequestSchema.safeParse(
await c.req.json<Pick<AdminSecurityConfig, "telegramBotToken" | "telegramChatId">>()
);
if (!parsedBody.success) {
return c.text(parsedBody.error.issues[0]?.message || "Telegram 配置无效", 400);
return c.text(parsedBody.error.issues[0]?.message || t(lang, "system.telegramConfigInvalid"), 400);
}
const body = parsedBody.data;
@@ -142,14 +145,16 @@ export class TelegramTestMessageEndpoint extends OpenAPIRoute {
} satisfies AdminSecurityConfig;
if (!isTelegramSecurityEnabled(securityConfig)) {
return c.text("请先填写有效的 Bot Token 和 Chat ID", 400);
return c.text(t(lang, "system.fillBotTokenAndChatId"), 400);
}
try {
await sendTelegramTestNotification(securityConfig, c);
} catch (error) {
return c.text(
`Telegram 测试消息发送失败:${error instanceof Error ? error.message : "unknown error"}`,
t(lang, "system.telegramTestFailed", {
error: error instanceof Error ? error.message : "unknown error",
}),
500
);
}
+18 -12
View File
@@ -2,6 +2,7 @@ import { Context } from "hono";
import { BILLING_RAW_SCALE, LEGACY_TO_RAW_FACTOR } from "../billing";
import { DEFAULT_USAGE_ANALYTICS_DATASET_NAME } from "./usage-logger";
import { t } from "../i18n";
export type AnalyticsRange = "24h" | "7d" | "30d" | "90d";
export type AnalyticsBreakdownDimension = "token" | "channel" | "model" | "provider";
@@ -285,14 +286,16 @@ const formatSqlDateTime = (date: Date): string => {
return date.toISOString().slice(0, 19).replace("T", " ");
};
const parseDateTimeInput = (value: string | undefined, fieldLabel: string): string | undefined => {
const parseDateTimeInput = (value: string | undefined, fieldKey: string, lang: string): string | undefined => {
if (!value) {
return undefined;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
throw new AnalyticsQueryValidationError(`${fieldLabel} 格式无效`);
throw new AnalyticsQueryValidationError(
t(lang, "analytics.invalidFormat", { field: t(lang, fieldKey) })
);
}
return formatSqlDateTime(date);
@@ -373,13 +376,14 @@ const buildTrendWindow = (range: AnalyticsRange, config: RangeConfig) => {
const buildCustomTimeWindow = (
requestedStart?: string,
requestedEnd?: string
requestedEnd?: string,
lang: string = "zh-CN"
): TimeWindow => {
const startTime = parseDateTimeInput(requestedStart, "开始时间");
const endTime = parseDateTimeInput(requestedEnd, "结束时间");
const startTime = parseDateTimeInput(requestedStart, "analytics.startTime", lang);
const endTime = parseDateTimeInput(requestedEnd, "analytics.endTime", lang);
if (startTime && endTime && startTime >= endTime) {
throw new AnalyticsQueryValidationError("开始时间必须早于结束时间");
throw new AnalyticsQueryValidationError(t(lang, "analytics.startBeforeEnd"));
}
const clauses: string[] = [];
@@ -563,13 +567,13 @@ const hasExtendedLogSchema = (support: DatasetColumnSupport): boolean => {
return EXTENDED_LOG_COLUMNS.every((columnName) => isColumnAvailable(support, columnName));
};
const getUsageLogCompatibilityWarning = (support: DatasetColumnSupport): string | undefined => {
const getUsageLogCompatibilityWarning = (support: DatasetColumnSupport, lang: string): string | undefined => {
if (!hasAnyLegacyLogSchema(support)) {
return "当前 Analytics Engine dataset 不包含使用日志基础字段,通常说明它是旧的、空的,或并非当前版本写入的 usage log dataset。";
return t(lang, "analytics.schemaV1Warning");
}
if (!hasExtendedLogSchema(support)) {
return "当前 Analytics Engine dataset 仍是旧日志 schema,request id / trace id / IP / UA / 地理位置 / 错误摘要 等新字段需要新版数据列可用后才可查询。";
return t(lang, "analytics.schemaV2Warning");
}
return undefined;
@@ -770,6 +774,7 @@ export const queryUsageEvents = async (
) => {
const { range, config } = getRangeConfig(requestedRange);
const dataset = getDatasetName(c);
const lang = c.get('lang') || 'zh-CN';
const limit = Math.min(Math.max(Number(requestedLimit || 40) || 40, 1), 100);
const countRows = await runAnalyticsQuery<Record<string, unknown>>(c, `
SELECT
@@ -789,7 +794,7 @@ WHERE ${buildRangeWhereClause(config)}
}
const columnSupport = await getDatasetColumnSupport(c, dataset, buildRangeWhereClause(config));
const compatibilityWarning = getUsageLogCompatibilityWarning(columnSupport);
const compatibilityWarning = getUsageLogCompatibilityWarning(columnSupport, lang);
const normalizedTotalCostExpression = buildNormalizedCostExpression(columnSupport, DOUBLE_FIELDS.totalCost);
const normalizedCacheCostExpression = buildNormalizedCostExpression(columnSupport, DOUBLE_FIELDS.cacheCost);
const rows = await runAnalyticsQuery<Record<string, unknown>>(c, `
@@ -874,7 +879,8 @@ export const queryUsageLogRecords = async (
params: UsageLogQueryParams
) => {
const dataset = getDatasetName(c);
const timeWindow = buildCustomTimeWindow(params.start, params.end);
const lang = c.get('lang') || 'zh-CN';
const timeWindow = buildCustomTimeWindow(params.start, params.end, lang);
const requestedPage = Math.min(Math.max(Number(params.page || 1) || 1, 1), 1000);
const dimension = (params.dimension && params.dimension in LOG_FILTER_FIELDS
? params.dimension
@@ -900,7 +906,7 @@ WHERE ${timeWindow.whereClause}
}
const columnSupport = await getDatasetColumnSupport(c, dataset, timeWindow.whereClause);
const compatibilityWarning = getUsageLogCompatibilityWarning(columnSupport);
const compatibilityWarning = getUsageLogCompatibilityWarning(columnSupport, lang);
if (!hasAnyLegacyLogSchema(columnSupport)) {
return buildUsageLogEmptyResponse(
+24
View File
@@ -0,0 +1,24 @@
const en: Record<string, string> = {
// auth
"auth.telegramRequired": "Telegram login verification is enabled. Please complete verification first",
"auth.unauthorized": "Unauthorized",
// system
"system.botTokenRequired": "Bot Token cannot be empty",
"system.chatIdRequired": "Chat ID cannot be empty",
"system.telegramConfigRequired": "Please fill in Bot Token and Chat ID before enabling Telegram verification",
"system.configInvalid": "Invalid system configuration",
"system.telegramConfigInvalid": "Invalid Telegram configuration",
"system.fillBotTokenAndChatId": "Please fill in a valid Bot Token and Chat ID first",
"system.telegramTestFailed": "Telegram test message failed: {{error}}",
// analytics
"analytics.invalidFormat": "{{field}} has invalid format",
"analytics.startTime": "Start time",
"analytics.endTime": "End time",
"analytics.startBeforeEnd": "Start time must be earlier than end time",
"analytics.schemaV1Warning": "The current Analytics Engine dataset does not contain basic usage log fields, which usually means it is old, empty, or not a usage log dataset written by the current version.",
"analytics.schemaV2Warning": "The current Analytics Engine dataset is still using the legacy log schema. Fields such as request id / trace id / IP / UA / geolocation / error summary require the new data columns to be available before they can be queried.",
};
export default en;
+109
View File
@@ -0,0 +1,109 @@
import type { Context } from "hono";
import zhCN from "./zh-CN";
import zhTW from "./zh-TW";
import en from "./en";
type SupportedLang = "zh-CN" | "zh-TW" | "en";
const locales: Record<SupportedLang, Record<string, string>> = {
"zh-CN": zhCN,
"zh-TW": zhTW,
"en": en,
};
const DEFAULT_LANG: SupportedLang = "zh-CN";
const ACCEPT_LANGUAGE_PATTERN = /([a-zA-Z]{1,8}(?:-[a-zA-Z0-9]{1,8})*)\s*(?:;\s*q\s*=\s*([\d.]+))?/g;
/**
* Resolve the preferred language from the request context.
*
* Priority: `x-lang` header > `Accept-Language` header > default (`zh-CN`).
*/
export const resolveLanguage = (c: Context<HonoCustomType>): string => {
const explicit = c.req.header("x-lang");
if (explicit) {
const matched = matchLang(explicit);
if (matched) {
return matched;
}
}
const acceptLanguage = c.req.header("accept-language");
if (acceptLanguage) {
const candidates = parseAcceptLanguage(acceptLanguage);
for (const candidate of candidates) {
const matched = matchLang(candidate);
if (matched) {
return matched;
}
}
}
return DEFAULT_LANG;
};
/**
* Translate a key for the given language.
*
* Supports simple `{{variable}}` interpolation via an optional `params` map.
*/
export const t = (
lang: string,
key: string,
params?: Record<string, string>
): string => {
const locale = locales[lang as SupportedLang] ?? locales[DEFAULT_LANG];
let text = locale[key] ?? locales[DEFAULT_LANG][key] ?? key;
if (params) {
for (const [name, value] of Object.entries(params)) {
text = text.replaceAll(`{{${name}}}`, value);
}
}
return text;
};
/**
* Match a raw language tag to one of the supported locales.
* Returns the matched locale key, or `undefined` if no match.
*/
const matchLang = (tag: string): SupportedLang | undefined => {
const normalized = tag.trim().toLowerCase();
if (normalized === "zh-cn" || normalized === "zh-hans") {
return "zh-CN";
}
if (normalized === "zh-tw" || normalized === "zh-hant" || normalized === "zh-hk") {
return "zh-TW";
}
if (normalized === "en" || normalized.startsWith("en-")) {
return "en";
}
if (normalized === "zh" || normalized.startsWith("zh-")) {
return "zh-CN";
}
return undefined;
};
/**
* Parse an `Accept-Language` header value and return language tags
* sorted by quality (descending).
*/
const parseAcceptLanguage = (header: string): string[] => {
const entries: Array<{ tag: string; quality: number }> = [];
let match: RegExpExecArray | null;
ACCEPT_LANGUAGE_PATTERN.lastIndex = 0;
while ((match = ACCEPT_LANGUAGE_PATTERN.exec(header)) !== null) {
entries.push({
tag: match[1],
quality: match[2] !== undefined ? parseFloat(match[2]) : 1.0,
});
}
entries.sort((a, b) => b.quality - a.quality);
return entries.map((e) => e.tag);
};
+24
View File
@@ -0,0 +1,24 @@
const zhCN: Record<string, string> = {
// auth
"auth.telegramRequired": "Telegram 登录验证已开启,请先完成验证码登录",
"auth.unauthorized": "Unauthorized",
// system
"system.botTokenRequired": "Bot Token 不能为空",
"system.chatIdRequired": "Chat ID 不能为空",
"system.telegramConfigRequired": "开启 Telegram 验证前,请先填写 Bot Token 和 Chat ID",
"system.configInvalid": "系统设置无效",
"system.telegramConfigInvalid": "Telegram 配置无效",
"system.fillBotTokenAndChatId": "请先填写有效的 Bot Token 和 Chat ID",
"system.telegramTestFailed": "Telegram 测试消息发送失败:{{error}}",
// analytics
"analytics.invalidFormat": "{{field}} 格式无效",
"analytics.startTime": "开始时间",
"analytics.endTime": "结束时间",
"analytics.startBeforeEnd": "开始时间必须早于结束时间",
"analytics.schemaV1Warning": "当前 Analytics Engine dataset 不包含使用日志基础字段,通常说明它是旧的、空的,或并非当前版本写入的 usage log dataset。",
"analytics.schemaV2Warning": "当前 Analytics Engine dataset 仍是旧日志 schema,request id / trace id / IP / UA / 地理位置 / 错误摘要 等新字段需要新版数据列可用后才可查询。",
};
export default zhCN;
+24
View File
@@ -0,0 +1,24 @@
const zhTW: Record<string, string> = {
// auth
"auth.telegramRequired": "Telegram 登入驗證已開啟,請先完成驗證碼登入",
"auth.unauthorized": "Unauthorized",
// system
"system.botTokenRequired": "Bot Token 不能為空",
"system.chatIdRequired": "Chat ID 不能為空",
"system.telegramConfigRequired": "開啟 Telegram 驗證前,請先填寫 Bot Token 和 Chat ID",
"system.configInvalid": "系統設定無效",
"system.telegramConfigInvalid": "Telegram 配置無效",
"system.fillBotTokenAndChatId": "請先填寫有效的 Bot Token 和 Chat ID",
"system.telegramTestFailed": "Telegram 測試訊息傳送失敗:{{error}}",
// analytics
"analytics.invalidFormat": "{{field}} 格式無效",
"analytics.startTime": "開始時間",
"analytics.endTime": "結束時間",
"analytics.startBeforeEnd": "開始時間必須早於結束時間",
"analytics.schemaV1Warning": "目前 Analytics Engine dataset 不包含使用日誌基礎欄位,通常表示它是舊的、空的,或並非目前版本寫入的 usage log dataset。",
"analytics.schemaV2Warning": "目前 Analytics Engine dataset 仍是舊日誌 schema,request id / trace id / IP / UA / 地理位置 / 錯誤摘要 等新欄位需要新版資料列可用後才可查詢。",
};
export default zhTW;
+7
View File
@@ -6,6 +6,7 @@ import { api as adminApi } from './admin'
import { fromHono } from 'chanfana';
import db from './db';
import { getSystemConfig } from './system-config';
import { resolveLanguage } from './i18n';
const FRONTEND_ENTRY = '/'
const FRONTEND_STATIC_PATHS = new Set([
@@ -100,6 +101,12 @@ const openapi = fromHono(app, {
// cors
openapi.use('/*', cors());
app.use('*', async (c, next) => {
const lang = resolveLanguage(c)
c.set('lang', lang)
await next()
})
app.use('*', async (c, next) => {
const requestUrl = new URL(c.req.url)