接口提示信息国际化

This commit is contained in:
whyour 2026-06-11 02:19:04 +08:00
parent 946731ac8d
commit 05f8fd3805
17 changed files with 206 additions and 26 deletions

1
.gitignore vendored
View File

@ -28,5 +28,6 @@ __pycache__
/shell/preload/notify.* /shell/preload/notify.*
/shell/preload/*-notify.json /shell/preload/*-notify.json
/shell/preload/__ql_notify__.* /shell/preload/__ql_notify__.*
/shell/preload/lang_env.sh
.deepseek/ .deepseek/

View File

@ -6,6 +6,7 @@ import * as fs from 'fs/promises';
import { celebrate, Joi } from 'celebrate'; import { celebrate, Joi } from 'celebrate';
import { join } from 'path'; import { join } from 'path';
import { SAMPLE_FILES } from '../config/const'; import { SAMPLE_FILES } from '../config/const';
import { t } from '../shared/i18n';
import ConfigService from '../services/config'; import ConfigService from '../services/config';
import { writeFileWithLock } from '../shared/utils'; import { writeFileWithLock } from '../shared/utils';
const route = Router(); const route = Router();
@ -72,7 +73,7 @@ export default (app: Router) => {
try { try {
const { name, content } = req.body; const { name, content } = req.body;
if (config.blackFileList.includes(name)) { if (config.blackFileList.includes(name)) {
res.send({ code: 403, message: '文件无法访问' }); res.send({ code: 403, message: t('文件无法访问') });
} }
let path = join(config.configPath, name); let path = join(config.configPath, name);
if (name.startsWith('data/scripts/')) { if (name.startsWith('data/scripts/')) {

View File

@ -9,6 +9,7 @@ import {
RunningInstanceModel, RunningInstanceModel,
InstanceStatus, InstanceStatus,
} from '../data/runningInstance'; } from '../data/runningInstance';
import { t } from '../shared/i18n';
const route = Router(); const route = Router();
@ -64,7 +65,7 @@ export default (app: Router) => {
try { try {
const cronViewService = Container.get(CronViewService); const cronViewService = Container.get(CronViewService);
if (req.body.type === 1) { if (req.body.type === 1) {
return res.send({ code: 400, message: '参数错误' }); return res.send({ code: 400, message: t('参数错误') });
} else { } else {
const data = await cronViewService.update(req.body); const data = await cronViewService.update(req.body);
return res.send({ code: 200, data }); return res.send({ code: 200, data });

View File

@ -6,6 +6,7 @@ import { Container } from 'typedi';
import { Logger } from 'winston'; import { Logger } from 'winston';
import config from '../config'; import config from '../config';
import { safeJSONParse } from '../config/util'; import { safeJSONParse } from '../config/util';
import { t } from '../shared/i18n';
import EnvService from '../services/env'; import EnvService from '../services/env';
const route = Router(); const route = Router();
@ -57,7 +58,7 @@ export default (app: Router) => {
try { try {
const envService = Container.get(EnvService); const envService = Container.get(EnvService);
if (!req.body?.length) { if (!req.body?.length) {
return res.send({ code: 400, message: '参数不正确' }); return res.send({ code: 400, message: t('参数不正确') });
} }
const data = await envService.create(req.body); const data = await envService.create(req.body);
return res.send({ code: 200, data }); return res.send({ code: 200, data });
@ -299,7 +300,7 @@ export default (app: Router) => {
} else { } else {
return res.send({ return res.send({
code: 400, code: 400,
message: '每条数据 name 或者 value 字段不能为空,参考导出文件格式', message: t('每条数据 name 或者 value 字段不能为空,参考导出文件格式'),
}); });
} }
} catch (e) { } catch (e) {

View File

@ -3,6 +3,7 @@ import { NextFunction, Request, Response, Router } from 'express';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { Logger } from 'winston'; import { Logger } from 'winston';
import config from '../config'; import config from '../config';
import { t } from '../shared/i18n';
import { import {
getFileContentByName, getFileContentByName,
readDirs, readDirs,
@ -42,7 +43,7 @@ export default (app: Router) => {
if (!finalPath || blacklist.includes(req.query.path as string)) { if (!finalPath || blacklist.includes(req.query.path as string)) {
return res.send({ return res.send({
code: 403, code: 403,
message: '暂无权限', message: t('暂无权限'),
}); });
} }
const content = await getFileContentByName(finalPath); const content = await getFileContentByName(finalPath);
@ -65,7 +66,7 @@ export default (app: Router) => {
if (!finalPath || blacklist.includes(req.query.path as string)) { if (!finalPath || blacklist.includes(req.query.path as string)) {
return res.send({ return res.send({
code: 403, code: 403,
message: '暂无权限', message: t('暂无权限'),
}); });
} }
const content = await getFileContentByName(finalPath); const content = await getFileContentByName(finalPath);
@ -96,7 +97,7 @@ export default (app: Router) => {
if (!finalPath || blacklist.includes(path)) { if (!finalPath || blacklist.includes(path)) {
return res.send({ return res.send({
code: 403, code: 403,
message: '暂无权限', message: t('暂无权限'),
}); });
} }
await rmPath(finalPath); await rmPath(finalPath);
@ -126,7 +127,7 @@ export default (app: Router) => {
if (!filePath) { if (!filePath) {
return res.send({ return res.send({
code: 403, code: 403,
message: '暂无权限', message: t('暂无权限'),
}); });
} }
return res.download(filePath, filename, (err) => { return res.download(filePath, filename, (err) => {

View File

@ -7,6 +7,7 @@ import * as fs from 'fs/promises';
import { celebrate, Joi } from 'celebrate'; import { celebrate, Joi } from 'celebrate';
import path, { join, parse } from 'path'; import path, { join, parse } from 'path';
import ScriptService from '../services/script'; import ScriptService from '../services/script';
import { t } from '../shared/i18n';
import multer from 'multer'; import multer from 'multer';
import { writeFileWithLock } from '../shared/utils'; import { writeFileWithLock } from '../shared/utils';
const route = Router(); const route = Router();
@ -155,7 +156,7 @@ export default (app: Router) => {
if (config.writePathList.every((x) => !path.startsWith(x))) { if (config.writePathList.every((x) => !path.startsWith(x))) {
return res.send({ return res.send({
code: 403, code: 403,
message: '暂无权限', message: t('暂无权限'),
}); });
} }
@ -217,7 +218,7 @@ export default (app: Router) => {
if (!filePath) { if (!filePath) {
return res.send({ return res.send({
code: 403, code: 403,
message: '暂无权限', message: t('暂无权限'),
}); });
} }
await writeFileWithLock(filePath, content); await writeFileWithLock(filePath, content);
@ -251,7 +252,7 @@ export default (app: Router) => {
if (!filePath) { if (!filePath) {
return res.send({ return res.send({
code: 403, code: 403,
message: '暂无权限', message: t('暂无权限'),
}); });
} }
await rmPath(filePath); await rmPath(filePath);
@ -284,7 +285,7 @@ export default (app: Router) => {
if (!filePath) { if (!filePath) {
return res.send({ return res.send({
code: 403, code: 403,
message: '暂无权限', message: t('暂无权限'),
}); });
} }
return res.download(filePath, filename, (err) => { return res.download(filePath, filename, (err) => {

View File

@ -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( route.put(
'/config/global-ssh-key', '/config/global-ssh-key',
celebrate({ celebrate({

View File

@ -110,6 +110,7 @@ const jsEnvFile = path.join(preloadPath, 'env.js');
const pyEnvFile = path.join(preloadPath, 'env.py'); const pyEnvFile = path.join(preloadPath, 'env.py');
const jsNotifyFile = path.join(preloadPath, '__ql_notify__.js'); const jsNotifyFile = path.join(preloadPath, '__ql_notify__.js');
const pyNotifyFile = path.join(preloadPath, '__ql_notify__.py'); const pyNotifyFile = path.join(preloadPath, '__ql_notify__.py');
const langEnvFile = path.join(preloadPath, 'lang_env.sh');
const confFile = path.join(configPath, 'config.sh'); const confFile = path.join(configPath, 'config.sh');
const crontabFile = path.join(configPath, 'crontab.list'); const crontabFile = path.join(configPath, 'crontab.list');
const authConfigFile = path.join(configPath, 'auth.json'); const authConfigFile = path.join(configPath, 'auth.json');
@ -155,6 +156,7 @@ export default {
pyEnvFile, pyEnvFile,
jsNotifyFile, jsNotifyFile,
pyNotifyFile, pyNotifyFile,
langEnvFile,
dbPath, dbPath,
uploadPath, uploadPath,
configPath, configPath,

View File

@ -31,6 +31,7 @@ export enum AuthDataType {
} }
export interface SystemConfigInfo { export interface SystemConfigInfo {
lang?: string;
logRemoveFrequency?: number; logRemoveFrequency?: number;
cronConcurrency?: number; cronConcurrency?: number;
dependenceProxy?: string; dependenceProxy?: string;

View File

@ -19,6 +19,7 @@ import { shareStore } from '../shared/store';
import Logger from './logger'; import Logger from './logger';
import { AppModel } from '../data/open'; import { AppModel } from '../data/open';
import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance'; import { InstanceStatus, RunningInstanceModel } from '../data/runningInstance';
import { setLang } from '../shared/i18n';
export default async () => { export default async () => {
const cronService = Container.get(CronService); const cronService = Container.get(CronService);
@ -224,6 +225,17 @@ export default async () => {
// 初始化保存一次ck和定时任务数据 // 初始化保存一次ck和定时任务数据
await cronService.autosave_crontab(); 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(); await envService.set_envs();
const authInfo = await userService.getAuthInfo(); const authInfo = await userService.getAuthInfo();

View File

@ -28,6 +28,7 @@ import dayjs from 'dayjs';
import pickBy from 'lodash/pickBy'; import pickBy from 'lodash/pickBy';
import omit from 'lodash/omit'; import omit from 'lodash/omit';
import { writeFileWithLock } from '../shared/utils'; import { writeFileWithLock } from '../shared/utils';
import { t } from '../shared/i18n';
import { ScheduleType } from '../interface/schedule'; import { ScheduleType } from '../interface/schedule';
import { logStreamManager } from '../shared/logStreamManager'; import { logStreamManager } from '../shared/logStreamManager';
@ -550,7 +551,7 @@ export default class CronService {
where: { id: instanceId, status: InstanceStatus.running }, where: { id: instanceId, status: InstanceStatus.running },
}); });
if (!instance) { if (!instance) {
return { code: 400, message: '实例不存在或已停止' }; return { code: 400, message: t('实例不存在或已停止') };
} }
if (instance.pid) { if (instance.pid) {
try { try {

View File

@ -37,6 +37,7 @@ import ScheduleService, { TaskCallbacks } from './schedule';
import SockService from './sock'; import SockService from './sock';
import os from 'os'; import os from 'os';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { t, setLang } from '../shared/i18n';
import { updateLinuxMirrorFile } from '../config/util'; import { updateLinuxMirrorFile } from '../config/util';
@Service() @Service()
@ -87,7 +88,7 @@ export default class SystemService {
}); });
return { code: 200, data: { ...result, code } }; return { code: 200, data: { ...result, code } };
} else { } else {
return { code: 400, message: '通知发送失败,请检查参数' }; return { code: 400, message: t('通知发送失败,请检查参数') };
} }
} }
@ -381,9 +382,9 @@ export default class SystemService {
notificationInfo, notificationInfo,
); );
if (isSuccess) { if (isSuccess) {
return { code: 200, message: '通知发送成功' }; return { code: 200, message: t('通知发送成功') };
} else { } 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 }) { public async stop({ command, pid }: { command: string; pid: number }) {
if (!pid && !command) { if (!pid && !command) {
return { code: 400, message: '参数错误' }; return { code: 400, message: t('参数错误') };
} }
if (pid) { if (pid) {
@ -417,7 +418,7 @@ export default class SystemService {
await killTask(_pid); await killTask(_pid);
return { code: 200 }; return { code: 200 };
} else { } else {
return { code: 400, message: '任务未找到' }; return { code: 400, message: t('任务未找到') };
} }
} }
@ -512,10 +513,30 @@ export default class SystemService {
if (success) { if (success) {
return { code: 200, data: info }; return { code: 200, data: info };
} else { } 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) { public async updateGlobalSshKey(info: SystemModelInfo) {
const oDoc = await this.getSystemConfig(); const oDoc = await this.getSystemConfig();
const result = await this.updateAuthDb({ const result = await this.updateAuthDb({
@ -539,7 +560,7 @@ export default class SystemService {
public async cleanDependence(type: 'node' | 'python3') { public async cleanDependence(type: 'node' | 'python3') {
if (!type || !['node', 'python3'].includes(type)) { if (!type || !['node', 'python3'].includes(type)) {
return { code: 400, message: '参数错误' }; return { code: 400, message: t('参数错误') };
} }
try { try {
const finalPath = path.join(config.dependenceCachePath, type); const finalPath = path.join(config.dependenceCachePath, type);

View File

@ -25,6 +25,7 @@ import uniq from 'lodash/uniq';
import pickBy from 'lodash/pickBy'; import pickBy from 'lodash/pickBy';
import isNil from 'lodash/isNil'; import isNil from 'lodash/isNil';
import { shareStore } from '../shared/store'; import { shareStore } from '../shared/store';
import { t } from '../shared/i18n';
@Service() @Service()
export default class UserService { export default class UserService {
@ -258,17 +259,17 @@ export default class UserService {
password: string; password: string;
}) { }) {
if (password === 'admin') { if (password === 'admin') {
return { code: 400, message: '密码不能设置为admin' }; return { code: 400, message: t('密码不能设置为admin') };
} }
const authInfo = await this.getAuthInfo(); const authInfo = await this.getAuthInfo();
await this.updateAuthInfo(authInfo, { username, password }); await this.updateAuthInfo(authInfo, { username, password });
return { code: 200, message: '更新成功' }; return { code: 200, message: t('更新成功') };
} }
public async updateAvatar(avatar: string) { public async updateAvatar(avatar: string) {
const authInfo = await this.getAuthInfo(); const authInfo = await this.getAuthInfo();
await this.updateAuthInfo(authInfo, { avatar }); await this.updateAuthInfo(authInfo, { avatar });
return { code: 200, data: avatar, message: '更新成功' }; return { code: 200, data: avatar, message: t('更新成功') };
} }
public async initTwoFactor() { public async initTwoFactor() {
@ -302,7 +303,7 @@ export default class UserService {
const authInfo = await this.getAuthInfo(); const authInfo = await this.getAuthInfo();
const { isTwoFactorChecking, twoFactorSecret } = authInfo; const { isTwoFactorChecking, twoFactorSecret } = authInfo;
if (!isTwoFactorChecking) { if (!isTwoFactorChecking) {
return { code: 450, message: '未知错误' }; return { code: 450, message: t('未知错误') };
} }
const isValid = authenticator.verify({ const isValid = authenticator.verify({
token: code, token: code,
@ -326,7 +327,7 @@ export default class UserService {
lastaddr: address, lastaddr: address,
platform: req.platform, 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 } }; return { code: 200, data: { ...result, code } };
} else { } else {
return { code: 400, message: '通知发送失败,请检查参数' }; return { code: 400, message: t('通知发送失败,请检查参数') };
} }
} }

96
back/shared/i18n.ts Normal file
View File

@ -0,0 +1,96 @@
const messages: Record<string, Record<string, string>> = {
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;
}

View File

@ -78,6 +78,7 @@ load_ql_envs() {
import_config() { import_config() {
[[ -f $file_config_user ]] && . $file_config_user [[ -f $file_config_user ]] && . $file_config_user
[[ -f $dir_preload/lang_env.sh ]] && . $dir_preload/lang_env.sh
load_ql_envs load_ql_envs
command_timeout_time=${CommandTimeoutTime:-""} command_timeout_time=${CommandTimeoutTime:-""}

View File

@ -134,6 +134,23 @@ export default function () {
getHealthStatus(); 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(() => { useEffect(() => {
if (theme === 'vs-dark') { if (theme === 'vs-dark') {
document.body.setAttribute('data-dark', 'true'); document.body.setAttribute('data-dark', 'true');

View File

@ -89,6 +89,10 @@ const Other = ({
const handleLangChange = (v: string) => { const handleLangChange = (v: string) => {
localStorage.setItem('lang', v); localStorage.setItem('lang', v);
const backendLang = v || navigator.language?.slice(0, 2) || 'zh';
request
.put(`${config.apiPrefix}system/config/lang`, { lang: backendLang })
.catch(() => {});
setTimeout(() => { setTimeout(() => {
window.location.reload(); window.location.reload();
}, 500); }, 500);