From 05f8fd3805a8a8b4d20b8f77d47856628e52d14c Mon Sep 17 00:00:00 2001 From: whyour Date: Thu, 11 Jun 2026 02:19:04 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E6=8F=90=E7=A4=BA=E4=BF=A1?= =?UTF-8?q?=E6=81=AF=E5=9B=BD=E9=99=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + back/api/config.ts | 3 +- back/api/cron.ts | 3 +- back/api/env.ts | 5 +- back/api/log.ts | 9 ++-- back/api/script.ts | 9 ++-- back/api/system.ts | 18 +++++++ back/config/index.ts | 2 + back/data/system.ts | 1 + back/loaders/initData.ts | 12 +++++ back/services/cron.ts | 3 +- back/services/system.ts | 35 +++++++++++--- back/services/user.ts | 13 ++--- back/shared/i18n.ts | 96 +++++++++++++++++++++++++++++++++++++ shell/share.sh | 1 + src/layouts/index.tsx | 17 +++++++ src/pages/setting/other.tsx | 4 ++ 17 files changed, 206 insertions(+), 26 deletions(-) create mode 100644 back/shared/i18n.ts diff --git a/.gitignore b/.gitignore index c9fc9200..766153eb 100644 --- a/.gitignore +++ b/.gitignore @@ -28,5 +28,6 @@ __pycache__ /shell/preload/notify.* /shell/preload/*-notify.json /shell/preload/__ql_notify__.* +/shell/preload/lang_env.sh .deepseek/ diff --git a/back/api/config.ts b/back/api/config.ts index 3fe3f463..7d9552a3 100644 --- a/back/api/config.ts +++ b/back/api/config.ts @@ -6,6 +6,7 @@ import * as fs from 'fs/promises'; import { celebrate, Joi } from 'celebrate'; import { join } from 'path'; import { SAMPLE_FILES } from '../config/const'; +import { t } from '../shared/i18n'; import ConfigService from '../services/config'; import { writeFileWithLock } from '../shared/utils'; const route = Router(); @@ -72,7 +73,7 @@ export default (app: Router) => { try { const { name, content } = req.body; if (config.blackFileList.includes(name)) { - res.send({ code: 403, message: '文件无法访问' }); + res.send({ code: 403, message: t('文件无法访问') }); } let path = join(config.configPath, name); if (name.startsWith('data/scripts/')) { diff --git a/back/api/cron.ts b/back/api/cron.ts index 2c1b6609..2ebe802b 100644 --- a/back/api/cron.ts +++ b/back/api/cron.ts @@ -9,6 +9,7 @@ import { RunningInstanceModel, InstanceStatus, } from '../data/runningInstance'; +import { t } from '../shared/i18n'; const route = Router(); @@ -64,7 +65,7 @@ export default (app: Router) => { try { const cronViewService = Container.get(CronViewService); if (req.body.type === 1) { - return res.send({ code: 400, message: '参数错误' }); + return res.send({ code: 400, message: t('参数错误') }); } else { const data = await cronViewService.update(req.body); return res.send({ code: 200, data }); diff --git a/back/api/env.ts b/back/api/env.ts index e58cd3ae..24ee76b5 100644 --- a/back/api/env.ts +++ b/back/api/env.ts @@ -6,6 +6,7 @@ import { Container } from 'typedi'; import { Logger } from 'winston'; import config from '../config'; import { safeJSONParse } from '../config/util'; +import { t } from '../shared/i18n'; import EnvService from '../services/env'; const route = Router(); @@ -57,7 +58,7 @@ export default (app: Router) => { try { const envService = Container.get(EnvService); if (!req.body?.length) { - return res.send({ code: 400, message: '参数不正确' }); + return res.send({ code: 400, message: t('参数不正确') }); } const data = await envService.create(req.body); return res.send({ code: 200, data }); @@ -299,7 +300,7 @@ export default (app: Router) => { } else { return res.send({ code: 400, - message: '每条数据 name 或者 value 字段不能为空,参考导出文件格式', + message: t('每条数据 name 或者 value 字段不能为空,参考导出文件格式'), }); } } catch (e) { diff --git a/back/api/log.ts b/back/api/log.ts index 020d572e..0c1d03e8 100644 --- a/back/api/log.ts +++ b/back/api/log.ts @@ -3,6 +3,7 @@ import { NextFunction, Request, Response, Router } from 'express'; import { Container } from 'typedi'; import { Logger } from 'winston'; import config from '../config'; +import { t } from '../shared/i18n'; import { getFileContentByName, readDirs, @@ -42,7 +43,7 @@ export default (app: Router) => { if (!finalPath || blacklist.includes(req.query.path as string)) { return res.send({ code: 403, - message: '暂无权限', + message: t('暂无权限'), }); } const content = await getFileContentByName(finalPath); @@ -65,7 +66,7 @@ export default (app: Router) => { if (!finalPath || blacklist.includes(req.query.path as string)) { return res.send({ code: 403, - message: '暂无权限', + message: t('暂无权限'), }); } const content = await getFileContentByName(finalPath); @@ -96,7 +97,7 @@ export default (app: Router) => { if (!finalPath || blacklist.includes(path)) { return res.send({ code: 403, - message: '暂无权限', + message: t('暂无权限'), }); } await rmPath(finalPath); @@ -126,7 +127,7 @@ export default (app: Router) => { if (!filePath) { return res.send({ code: 403, - message: '暂无权限', + message: t('暂无权限'), }); } return res.download(filePath, filename, (err) => { diff --git a/back/api/script.ts b/back/api/script.ts index 1ecce902..8b1faaad 100644 --- a/back/api/script.ts +++ b/back/api/script.ts @@ -7,6 +7,7 @@ import * as fs from 'fs/promises'; import { celebrate, Joi } from 'celebrate'; import path, { join, parse } from 'path'; import ScriptService from '../services/script'; +import { t } from '../shared/i18n'; import multer from 'multer'; import { writeFileWithLock } from '../shared/utils'; const route = Router(); @@ -155,7 +156,7 @@ export default (app: Router) => { if (config.writePathList.every((x) => !path.startsWith(x))) { return res.send({ code: 403, - message: '暂无权限', + message: t('暂无权限'), }); } @@ -217,7 +218,7 @@ export default (app: Router) => { if (!filePath) { return res.send({ code: 403, - message: '暂无权限', + message: t('暂无权限'), }); } await writeFileWithLock(filePath, content); @@ -251,7 +252,7 @@ export default (app: Router) => { if (!filePath) { return res.send({ code: 403, - message: '暂无权限', + message: t('暂无权限'), }); } await rmPath(filePath); @@ -284,7 +285,7 @@ export default (app: Router) => { if (!filePath) { return res.send({ code: 403, - message: '暂无权限', + message: t('暂无权限'), }); } return res.download(filePath, filename, (err) => { diff --git a/back/api/system.ts b/back/api/system.ts index f51c6575..86e9f3d9 100644 --- a/back/api/system.ts +++ b/back/api/system.ts @@ -426,6 +426,24 @@ export default (app: Router) => { }, ); + route.put( + '/config/lang', + celebrate({ + body: Joi.object({ + lang: Joi.string().allow('').allow(null), + }), + }), + async (req: Request, res: Response, next: NextFunction) => { + try { + const systemService = Container.get(SystemService); + const result = await systemService.updateLanguage(req.body); + res.send(result); + } catch (e) { + return next(e); + } + }, + ); + route.put( '/config/global-ssh-key', celebrate({ diff --git a/back/config/index.ts b/back/config/index.ts index fb1c9ca5..72596f1f 100644 --- a/back/config/index.ts +++ b/back/config/index.ts @@ -110,6 +110,7 @@ const jsEnvFile = path.join(preloadPath, 'env.js'); const pyEnvFile = path.join(preloadPath, 'env.py'); const jsNotifyFile = path.join(preloadPath, '__ql_notify__.js'); const pyNotifyFile = path.join(preloadPath, '__ql_notify__.py'); +const langEnvFile = path.join(preloadPath, 'lang_env.sh'); const confFile = path.join(configPath, 'config.sh'); const crontabFile = path.join(configPath, 'crontab.list'); const authConfigFile = path.join(configPath, 'auth.json'); @@ -155,6 +156,7 @@ export default { pyEnvFile, jsNotifyFile, pyNotifyFile, + langEnvFile, dbPath, uploadPath, configPath, diff --git a/back/data/system.ts b/back/data/system.ts index 2dc14f50..6343c5e1 100644 --- a/back/data/system.ts +++ b/back/data/system.ts @@ -31,6 +31,7 @@ export enum AuthDataType { } export interface SystemConfigInfo { + lang?: string; logRemoveFrequency?: number; cronConcurrency?: number; dependenceProxy?: string; diff --git a/back/loaders/initData.ts b/back/loaders/initData.ts index f8dc7758..83ef8cf1 100644 --- a/back/loaders/initData.ts +++ b/back/loaders/initData.ts @@ -19,6 +19,7 @@ import { shareStore } from '../shared/store'; import Logger from './logger'; import { AppModel } from '../data/open'; import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance'; +import { setLang } from '../shared/i18n'; export default async () => { const cronService = Container.get(CronService); @@ -224,6 +225,17 @@ export default async () => { // 初始化保存一次ck和定时任务数据 await cronService.autosave_crontab(); + + // 确保 lang_env.sh 存在,提供默认 QL_LANG + try { + const langEnvExist = await fileExist(config.langEnvFile); + if (!langEnvExist) { + const lang = systemConfig.info?.lang || 'zh'; + await writeFile(config.langEnvFile, `export QL_LANG='${lang}'\n`); + } + } catch {} + setLang(systemConfig.info?.lang || 'zh'); + await envService.set_envs(); const authInfo = await userService.getAuthInfo(); diff --git a/back/services/cron.ts b/back/services/cron.ts index f2a80276..ee14a669 100644 --- a/back/services/cron.ts +++ b/back/services/cron.ts @@ -28,6 +28,7 @@ import dayjs from 'dayjs'; import pickBy from 'lodash/pickBy'; import omit from 'lodash/omit'; import { writeFileWithLock } from '../shared/utils'; +import { t } from '../shared/i18n'; import { ScheduleType } from '../interface/schedule'; import { logStreamManager } from '../shared/logStreamManager'; @@ -550,7 +551,7 @@ export default class CronService { where: { id: instanceId, status: InstanceStatus.running }, }); if (!instance) { - return { code: 400, message: '实例不存在或已停止' }; + return { code: 400, message: t('实例不存在或已停止') }; } if (instance.pid) { try { diff --git a/back/services/system.ts b/back/services/system.ts index 97b500f1..4f658622 100644 --- a/back/services/system.ts +++ b/back/services/system.ts @@ -37,6 +37,7 @@ import ScheduleService, { TaskCallbacks } from './schedule'; import SockService from './sock'; import os from 'os'; import dayjs from 'dayjs'; +import { t, setLang } from '../shared/i18n'; import { updateLinuxMirrorFile } from '../config/util'; @Service() @@ -87,7 +88,7 @@ export default class SystemService { }); return { code: 200, data: { ...result, code } }; } else { - return { code: 400, message: '通知发送失败,请检查参数' }; + return { code: 400, message: t('通知发送失败,请检查参数') }; } } @@ -381,9 +382,9 @@ export default class SystemService { notificationInfo, ); if (isSuccess) { - return { code: 200, message: '通知发送成功' }; + return { code: 200, message: t('通知发送成功') }; } else { - return { code: 400, message: '通知发送失败,请检查系统设置/通知配置' }; + return { code: 400, message: t('通知发送失败,请检查系统设置/通知配置') }; } } @@ -401,7 +402,7 @@ export default class SystemService { public async stop({ command, pid }: { command: string; pid: number }) { if (!pid && !command) { - return { code: 400, message: '参数错误' }; + return { code: 400, message: t('参数错误') }; } if (pid) { @@ -417,7 +418,7 @@ export default class SystemService { await killTask(_pid); return { code: 200 }; } else { - return { code: 400, message: '任务未找到' }; + return { code: 400, message: t('任务未找到') }; } } @@ -512,10 +513,30 @@ export default class SystemService { if (success) { return { code: 200, data: info }; } else { - return { code: 400, message: '设置时区失败' }; + return { code: 400, message: t('设置时区失败') }; } } + public async updateLanguage(info: SystemModelInfo) { + const oDoc = await this.getSystemConfig(); + const lang = info.lang || 'zh'; + await this.updateAuthDb({ + ...oDoc, + info: { ...oDoc.info, lang }, + }); + // Write to standalone lang_env.sh, sourced by shell scripts + try { + await fs.promises.writeFile( + config.langEnvFile, + `export QL_LANG='${lang}'\n`, + ); + } catch (error) { + this.logger.error(`Failed to write lang_env.sh: ${error}`); + } + setLang(lang); + return { code: 200, data: { lang } }; + } + public async updateGlobalSshKey(info: SystemModelInfo) { const oDoc = await this.getSystemConfig(); const result = await this.updateAuthDb({ @@ -539,7 +560,7 @@ export default class SystemService { public async cleanDependence(type: 'node' | 'python3') { if (!type || !['node', 'python3'].includes(type)) { - return { code: 400, message: '参数错误' }; + return { code: 400, message: t('参数错误') }; } try { const finalPath = path.join(config.dependenceCachePath, type); diff --git a/back/services/user.ts b/back/services/user.ts index 362cb693..31d66864 100644 --- a/back/services/user.ts +++ b/back/services/user.ts @@ -25,6 +25,7 @@ import uniq from 'lodash/uniq'; import pickBy from 'lodash/pickBy'; import isNil from 'lodash/isNil'; import { shareStore } from '../shared/store'; +import { t } from '../shared/i18n'; @Service() export default class UserService { @@ -258,17 +259,17 @@ export default class UserService { password: string; }) { if (password === 'admin') { - return { code: 400, message: '密码不能设置为admin' }; + return { code: 400, message: t('密码不能设置为admin') }; } const authInfo = await this.getAuthInfo(); await this.updateAuthInfo(authInfo, { username, password }); - return { code: 200, message: '更新成功' }; + return { code: 200, message: t('更新成功') }; } public async updateAvatar(avatar: string) { const authInfo = await this.getAuthInfo(); await this.updateAuthInfo(authInfo, { avatar }); - return { code: 200, data: avatar, message: '更新成功' }; + return { code: 200, data: avatar, message: t('更新成功') }; } public async initTwoFactor() { @@ -302,7 +303,7 @@ export default class UserService { const authInfo = await this.getAuthInfo(); const { isTwoFactorChecking, twoFactorSecret } = authInfo; if (!isTwoFactorChecking) { - return { code: 450, message: '未知错误' }; + return { code: 450, message: t('未知错误') }; } const isValid = authenticator.verify({ token: code, @@ -326,7 +327,7 @@ export default class UserService { lastaddr: address, platform: req.platform, }); - return { code: 430, message: '验证失败' }; + return { code: 430, message: t('验证失败') }; } } @@ -398,7 +399,7 @@ export default class UserService { }); return { code: 200, data: { ...result, code } }; } else { - return { code: 400, message: '通知发送失败,请检查参数' }; + return { code: 400, message: t('通知发送失败,请检查参数') }; } } diff --git a/back/shared/i18n.ts b/back/shared/i18n.ts new file mode 100644 index 00000000..e260633f --- /dev/null +++ b/back/shared/i18n.ts @@ -0,0 +1,96 @@ +const messages: Record> = { + zh: {}, + en: { + '暂无权限': 'Access denied', + '参数错误': 'Invalid parameter', + '参数不正确': 'Invalid parameter', + '文件无法访问': 'File not accessible', + '文件不存在': 'File not found', + '路径不存在': 'Path does not exist', + '路径不正确': 'Invalid path', + '通知发送失败,请检查参数': 'Notification failed, check parameters', + '通知发送失败,请检查系统设置/通知配置': 'Notification failed, check system settings', + '通知发送成功': 'Notification sent successfully', + '设置时区失败': 'Failed to set timezone', + '任务未找到': 'Task not found', + '密码不能设置为admin': 'Password cannot be admin', + '更新成功': 'Update successful', + '未知错误': 'Unknown error', + '验证失败': 'Verification failed', + '实例不存在或已停止': 'Instance does not exist or stopped', + '实例已停止': 'Instance stopped', + '确认停止实例': 'Confirm to stop instance', + '确认停止运行实例': 'Confirm to stop running instance', + '确认停止': 'Confirm to stop', + '确认停止定时任务': 'Confirm to stop scheduled task', + '确认删除': 'Confirm to delete', + '确认删除定时任务': 'Confirm to delete scheduled task', + '确认删除选中的定时任务吗': 'Confirm to delete selected tasks?', + '确认运行': 'Confirm to run', + '确认运行定时任务': 'Confirm to run scheduled task', + '确认保存': 'Confirm to save', + '确认保存文件': 'Confirm to save file', + '确认重启': 'Confirm restart', + '确认启用': 'Confirm to enable', + '确认禁用': 'Confirm to disable', + '确认': 'Confirm', + '删除成功': 'Deleted successfully', + '操作成功': 'Operation successful', + '参数不完整': 'Incomplete parameters', + '默认路径不支持删除': 'Default path cannot be deleted', + '必须在日志目录下': 'Must be within log directory', + '备份数据上传成功,确认覆盖数据': 'Backup uploaded, confirm overwrite', + '如果恢复失败,可进入容器执行': 'If restore fails, run in container:', + '系统将在': 'System will', + '秒后自动刷新': 'refresh in seconds', + '生成数据中...': 'Generating data...', + '每条数据 name 或者 value 字段不能为空,参考导出文件格式': 'Each entry must have name and value, see export format', + '不支持当前依赖类型': 'Unsupported dependency type', + '依赖已存在': 'Dependency already exists', + '依赖不存在': 'Dependency does not exist', + '该脚本正在运行中': 'Script is running', + '该脚本未在运行中': 'Script is not running', + '文件内容为空': 'File content is empty', + '文件名不能为空': 'File name cannot be empty', + '标签不能为空': 'Label cannot be empty', + '名称不能为空': 'Name cannot be empty', + '名称不能为保留关键字': 'Name cannot be reserved keyword', + '名称已存在': 'Name already exists', + '密码错误': 'Incorrect password', + '用户不存在': 'User does not exist', + '请输入用户名和密码': 'Please enter username and password', + '无权访问': 'Access denied', + '登录成功': 'Login successful', + '退出成功': 'Logout successful', + 'Token 已失效': 'Token expired', + 'Token 无效': 'Invalid token', + '两步骤验证已开启': '2FA enabled', + '两步骤验证已关闭': '2FA disabled', + '验证码错误': 'Invalid verification code', + '验证码已过期': 'Verification code expired', + '请先开启两步骤验证': 'Please enable 2FA first', + '两步骤验证密钥不能为空': '2FA secret cannot be empty', + '用户已存在': 'User already exists', + '用户名不能为admin': 'Username cannot be admin', + '不能删除自己': 'Cannot delete yourself', + '不能禁用自己': 'Cannot disable yourself', + }, +}; + +let currentLang: string = 'zh'; + +export function setLang(lang: string) { + currentLang = lang || 'zh'; +} + +export function getLang(): string { + return currentLang; +} + +export function t(key: string, lang?: string): string { + const effectiveLang = lang || currentLang; + if (effectiveLang === 'en' && messages.en[key]) { + return messages.en[key]; + } + return key; +} diff --git a/shell/share.sh b/shell/share.sh index 42f1254e..1fb3e93c 100755 --- a/shell/share.sh +++ b/shell/share.sh @@ -78,6 +78,7 @@ load_ql_envs() { import_config() { [[ -f $file_config_user ]] && . $file_config_user + [[ -f $dir_preload/lang_env.sh ]] && . $dir_preload/lang_env.sh load_ql_envs command_timeout_time=${CommandTimeoutTime:-""} diff --git a/src/layouts/index.tsx b/src/layouts/index.tsx index 9e917002..abc5c759 100644 --- a/src/layouts/index.tsx +++ b/src/layouts/index.tsx @@ -134,6 +134,23 @@ export default function () { getHealthStatus(); }, []); + useEffect(() => { + request + .get(`${config.apiPrefix}system/config`) + .then(({ data }: any) => { + if (!data?.info?.lang) { + const browserLang = + localStorage.getItem('lang') || + navigator.language?.slice(0, 2) || + 'zh'; + request + .put(`${config.apiPrefix}system/config/lang`, { lang: browserLang }) + .catch(() => {}); + } + }) + .catch(() => {}); + }, []); + useEffect(() => { if (theme === 'vs-dark') { document.body.setAttribute('data-dark', 'true'); diff --git a/src/pages/setting/other.tsx b/src/pages/setting/other.tsx index e2ef1973..8ff0674f 100644 --- a/src/pages/setting/other.tsx +++ b/src/pages/setting/other.tsx @@ -89,6 +89,10 @@ const Other = ({ const handleLangChange = (v: string) => { localStorage.setItem('lang', v); + const backendLang = v || navigator.language?.slice(0, 2) || 'zh'; + request + .put(`${config.apiPrefix}system/config/lang`, { lang: backendLang }) + .catch(() => {}); setTimeout(() => { window.location.reload(); }, 500);