feat(i18n): 添加多语言支持,优化用户界面文本
在此提交中,我们引入了多语言支持,允许用户根据其语言偏好查看界面文本。新增了国际化库(i18next)并在多个组件中实现了翻译功能,确保错误消息和界面文本能够根据用户的语言设置动态变化。这一改动旨在提升用户体验,使得应用能够服务更广泛的用户群体,增强可访问性和友好性。同时,更新了相关文档以指导开发者如何使用新的国际化功能。
This commit is contained in:
1 parent
8261eeda34
commit
a7c65b9476
33 files changed
+2328
-609
No files matched your search
+3
-2
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user