diff --git a/.gitignore b/.gitignore index 2428ab44..b1095392 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,12 @@ __pycache__ # Local security audit artifacts /audit/ + +# Local browser tests, screenshots, reports, and temporary test output +/.playwright-cli/ +/output/ +/playwright-report/ +/test-results/ +/blob-report/ +/coverage/ +/.nyc_output/ diff --git a/back/api/clientIp.ts b/back/api/clientIp.ts index 172c8e9b..88d06a6d 100644 --- a/back/api/clientIp.ts +++ b/back/api/clientIp.ts @@ -1,5 +1,6 @@ import { NextFunction, Request, Response, Router } from 'express'; import { celebrate, Joi } from 'celebrate'; +import { t } from '../shared/i18n'; import { diagnoseClientIp, getTrustProxyConfig, @@ -36,7 +37,7 @@ export default (app: Router) => { } catch (error) { res.send({ code: 400, - message: error instanceof Error ? error.message : '配置更新失败', + message: error instanceof Error ? error.message : t('配置更新失败'), }); } }, diff --git a/back/app.ts b/back/app.ts index 96276014..8a195c00 100644 --- a/back/app.ts +++ b/back/app.ts @@ -5,6 +5,7 @@ import { Container } from 'typedi'; import config from './config'; import Logger from './loaders/logger'; import { errStack } from './shared/errors'; +import { initializeLanguageSync } from './shared/languageSync'; import { type GrpcServerService } from './services/grpc'; import { type HttpServerService } from './services/http'; @@ -27,6 +28,7 @@ class Application { if (cluster.isPrimary) { await this.initializeDatabase(); } + await initializeLanguageSync(); if (cluster.isPrimary) { this.startMasterProcess(); } else { diff --git a/back/loaders/express.ts b/back/loaders/express.ts index 3d68aa95..aa28079f 100644 --- a/back/loaders/express.ts +++ b/back/loaders/express.ts @@ -6,7 +6,7 @@ import config from '../config'; import { UnauthorizedError, expressjwt } from 'express-jwt'; import { getPlatform, getToken } from '../config/util'; import rewrite from 'express-urlrewrite'; -import { errors } from 'celebrate'; +import validationErrors from '../middlewares/validationErrors'; import { serveEnv } from '../config/serverEnv'; import { shareStore } from '../shared/store'; import { isValidToken, isDefaultAuthInfo } from '../shared/auth'; @@ -197,7 +197,7 @@ export default ({ app }: { app: Application }) => { }); }); - app.use(errors()); + app.use(validationErrors); app.use(uniqueConstraintError); app.use( diff --git a/back/loaders/initTask.ts b/back/loaders/initTask.ts index 7424a6b5..e7f48c66 100644 --- a/back/loaders/initTask.ts +++ b/back/loaders/initTask.ts @@ -63,7 +63,7 @@ export default async () => { } systemService.updateTimezone(data.info); - + // Apply global SSH key if configured if (data.info.globalSshKey) { await sshKeyService.addGlobalSSHKey(data.info.globalSshKey, 'global'); @@ -78,7 +78,7 @@ export default async () => { assertSubscriptionAlias(doc.alias); await subscriptionService.handleTask(doc, !sub.is_disabled); } catch (error) { - Logger.warn('跳过订阅 %s 的无效定时配置: %s', sub.id, error); + Logger.warn(t('跳过订阅 %s 的无效定时配置: %s'), sub.id, error); } } }; diff --git a/back/middlewares/validationErrors.ts b/back/middlewares/validationErrors.ts new file mode 100644 index 00000000..c9421391 --- /dev/null +++ b/back/middlewares/validationErrors.ts @@ -0,0 +1,18 @@ +import { errors, isCelebrateError } from 'celebrate'; +import { ErrorRequestHandler } from 'express'; +import { t } from '../shared/i18n'; + +const respond = errors(); + +// Schemas are compiled once at startup; resolve their messages per request so +// changing the system language also changes errors from those existing schemas. +const validationErrors: ErrorRequestHandler = (error, req, res, next) => { + if (isCelebrateError(error)) { + for (const detail of error.details.values()) { + detail.message = detail.details.map((item) => t(item.message)).join('. '); + } + } + return respond(error, req, res, next); +}; + +export default validationErrors; diff --git a/back/schedule/addCron.ts b/back/schedule/addCron.ts index 5ebdf7a5..dfeec64a 100644 --- a/back/schedule/addCron.ts +++ b/back/schedule/addCron.ts @@ -5,7 +5,7 @@ import { isValidCronSchedule } from '../shared/cronSchedule'; import { scheduleStacks } from './data'; import { runCron } from '../shared/runCron'; import Logger from '../loaders/logger'; -import { tf } from '../shared/i18n'; +import { t, tf } from '../shared/i18n'; // Validate the entire batch before replacing any existing jobs. const isValidCronField = (cron: string): boolean => { @@ -67,7 +67,7 @@ const addCron = ( createCronJob( schedule, async () => { - Logger.info('[schedule][准备运行任务] 命令: %s', item.command); + Logger.info(t('[schedule][准备运行任务] 命令: %s'), item.command); await runCron( item.command, item, @@ -104,7 +104,7 @@ const addCron = ( scheduleStacks.set(id, jobs); jobs.forEach((job) => job.start()); Logger.info( - '[schedule][创建定时任务] 任务ID: %s, 规则数: %s', + t('[schedule][创建定时任务] 任务ID: %s, 规则数: %s'), id, jobs.length, ); diff --git a/back/schedule/delCron.ts b/back/schedule/delCron.ts index 1f5f86ae..193ce105 100644 --- a/back/schedule/delCron.ts +++ b/back/schedule/delCron.ts @@ -1,3 +1,4 @@ +import { t } from '../shared/i18n'; import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js'; import { DeleteCronRequest, DeleteCronResponse } from '../protos/cron'; import { scheduleStacks } from './data'; @@ -9,24 +10,24 @@ const delCron = ( ) => { for (const id of call.request.ids) { if (scheduleStacks.has(id)) { - Logger.info( - '[schedule][取消定时任务] 任务ID: %s', - id, - ); + Logger.info(t('[schedule][取消定时任务] 任务ID: %s'), id); // 防御性过滤历史调度栈中的空任务, // 否则对 null 调 cancel() 会让整个取消流程抛出 UNKNOWN 错误, // 进而导致 HTTP 端的 remove() 跳过 setCrontab(),造成 crontab.list 残留。 - scheduleStacks.get(id)?.filter((x) => x != null).forEach((x) => { - try { - x.cancel(); - } catch (error: any) { - Logger.warn( - '[schedule][取消任务失败] 任务ID: %s, 错误: %s', - id, - error?.message || error, - ); - } - }); + scheduleStacks + .get(id) + ?.filter((x) => x != null) + .forEach((x) => { + try { + x.cancel(); + } catch (error: any) { + Logger.warn( + t('[schedule][取消任务失败] 任务ID: %s, 错误: %s'), + id, + error?.message || error, + ); + } + }); scheduleStacks.delete(id); } } diff --git a/back/services/cron.ts b/back/services/cron.ts index 2adc6edd..538088a8 100644 --- a/back/services/cron.ts +++ b/back/services/cron.ts @@ -641,7 +641,7 @@ export default class CronService { stopped.add(pid); } catch (error) { this.logger.error( - '[panel][停止任务失败] PID: %s, 错误: %s', + t('[panel][停止任务失败] PID: %s, 错误: %s'), pid, asError(error).message ); @@ -695,7 +695,7 @@ export default class CronService { await killTask(instance.pid); } catch (error) { this.logger.error( - `[panel][停止实例失败] 实例ID: ${instanceId}, PID: ${instance.pid}, 错误: ${error}`, + t("[panel][停止实例失败] 实例ID: %s, PID: %s, 错误: %s"), instanceId, instance.pid, error, ); } } @@ -791,20 +791,20 @@ export default class CronService { const result = await completed; if (result.error) { this.logger.error( - '[panel][执行任务失败] 任务ID: %s, 错误: %s', + t('[panel][执行任务失败] 任务ID: %s, 错误: %s'), id, result.error.message ); } this.logger.info( - '[panel][执行任务结束] 任务ID: %s, 退出码: %j', + t('[panel][执行任务结束] 任务ID: %s, 退出码: %j'), id, result.code ); return { ...cron, pid: cp.pid, ...result } as any; } catch (error) { this.logger.error( - '[panel][创建任务失败] 任务ID: %s, 错误: %s', + t('[panel][创建任务失败] 任务ID: %s, 错误: %s'), cronId, asError(error).message ); @@ -813,7 +813,7 @@ export default class CronService { if (absolutePath) await logStreamManager.closeStream(absolutePath); } catch (error) { this.logger.error( - '[panel][关闭任务日志失败] %s', + t('[panel][关闭任务日志失败] %s'), asError(error).message ); } @@ -845,7 +845,7 @@ export default class CronService { ); } catch (error) { this.logger.error( - '[panel][清理任务状态失败] %s', + t('[panel][清理任务状态失败] %s'), asError(error).message ); } diff --git a/back/services/metrics.ts b/back/services/metrics.ts index 08a859a4..cb65c372 100644 --- a/back/services/metrics.ts +++ b/back/services/metrics.ts @@ -1,3 +1,4 @@ +import { t } from '../shared/i18n'; import { performance } from 'perf_hooks'; import Logger from '../loaders/logger'; @@ -126,7 +127,7 @@ export class MetricsService { timestamp: Date.now(), metrics: this.getMetrics(), }; - Logger.info('性能指标报告:', report); + Logger.info(t('性能指标报告:'), report); return report; } } diff --git a/back/services/schedule.ts b/back/services/schedule.ts index d61b56b9..a303fbe5 100644 --- a/back/services/schedule.ts +++ b/back/services/schedule.ts @@ -1,3 +1,4 @@ +import { t } from '../shared/i18n'; import { Service, Inject } from 'typedi'; import winston from 'winston'; import { createCronJob, CronJob } from '../shared/cronScheduler'; @@ -83,7 +84,7 @@ export default class ScheduleService { let cp: ChildProcessWithoutNullStreams | undefined; let result: ProcessResult = { code: null, signal: null }; try { - this.logger.info('[panel][开始执行任务] 任务ID: %s', others.id); + this.logger.info(t('[panel][开始执行任务] 任务ID: %s'), others.id); await callbacks.onBefore?.(startTime); cp = spawn(command, { shell: '/bin/bash' }); const child = cp; @@ -103,7 +104,7 @@ export default class ScheduleService { if (result.error) { this.logger.error( - '[panel][执行任务失败] 任务ID: %s, 错误: %s', + t('[panel][执行任务失败] 任务ID: %s, 错误: %s'), others.id, result.error.message, ); @@ -111,7 +112,7 @@ export default class ScheduleService { await callbacks.onError?.(result.error.message); } catch (error) { this.logger.error( - '[panel][任务错误回调失败] %s', + t('[panel][任务错误回调失败] %s'), asError(error).message, ); } @@ -127,12 +128,12 @@ export default class ScheduleService { } catch (error) { result.error ??= asError(error); this.logger.error( - '[panel][任务结束回调失败] %s', + t('[panel][任务结束回调失败] %s'), asError(error).message, ); } this.logger.info( - '[panel][执行任务结束] 任务ID: %s, 退出码: %j', + t('[panel][执行任务结束] 任务ID: %s, 退出码: %j'), others.id, result.code, ); @@ -141,7 +142,7 @@ export default class ScheduleService { ).catch((error) => { // Queue/setup failures must not become unhandled rejections in detached callers. rejectStart(asError(error)); - this.logger.error('[panel][任务队列失败] %s', asError(error).message); + this.logger.error(t('[panel][任务队列失败] %s'), asError(error).message); return { ...others, code: null, signal: null, error: asError(error) }; }); @@ -156,7 +157,7 @@ export default class ScheduleService { ) { const _id = this.formatId(id); this.logger.info( - '[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s', + t('[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s'), _id, schedule, name, @@ -183,7 +184,7 @@ export default class ScheduleService { } catch (error) { // A persisted invalid subscription must not become an unhandled startup rejection. this.logger.warn( - '[panel][跳过无效定时任务] 任务ID: %s, cron: %s, 错误: %s', + t('[panel][跳过无效定时任务] 任务ID: %s, cron: %s, 错误: %s'), _id, schedule, asError(error).message, @@ -204,7 +205,7 @@ export default class ScheduleService { async cancelCronTask({ id = 0, name }: ScheduleTaskType) { const _id = this.formatId(id); - this.logger.info('[panel][取消定时任务] 任务名: %s', name); + this.logger.info(t('[panel][取消定时任务] 任务名: %s'), name); if (this.scheduleStacks.has(_id)) { this.scheduleStacks.get(_id)?.cancel(); this.scheduleStacks.delete(_id); @@ -219,7 +220,7 @@ export default class ScheduleService { ) { const _id = this.formatId(id); this.logger.info( - '[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s', + t('[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s'), _id, name, command, @@ -236,7 +237,7 @@ export default class ScheduleService { }, (err) => { this.logger.error( - '[panel][执行任务失败] 命令: %s, 错误信息: %j', + t('[panel][执行任务失败] 命令: %s, 错误信息: %j'), command, err, ); @@ -264,7 +265,7 @@ export default class ScheduleService { async cancelIntervalTask({ id = 0, name }: ScheduleTaskType) { const _id = this.formatId(id); this.logger.info( - '[panel][取消interval任务] 任务ID: %s, 任务名: %s', + t('[panel][取消interval任务] 任务ID: %s, 任务名: %s'), _id, name, ); diff --git a/back/services/sshKey.ts b/back/services/sshKey.ts index f140df92..f08a46bf 100644 --- a/back/services/sshKey.ts +++ b/back/services/sshKey.ts @@ -1,3 +1,4 @@ +import { t } from '../shared/i18n'; import { Service, Inject } from 'typedi'; import winston from 'winston'; import fs from 'fs/promises'; @@ -61,10 +62,10 @@ export default class SshKeyService { const filePath = resolveSubscriptionPath(this.sshPath, alias); try { await rmPath(filePath); - } catch { } + } catch {} await writeFileWithLock(filePath, `${key}${os.EOL}`, { mode: '400' }); } catch (error) { - this.logger.error('生成私钥文件失败', error); + this.logger.error(t('生成私钥文件失败'), error); } } @@ -73,7 +74,7 @@ export default class SshKeyService { const filePath = resolveSubscriptionPath(this.sshPath, alias); await rmPath(filePath); } catch (error) { - this.logger.error('删除私钥文件失败', error); + this.logger.error(t('删除私钥文件失败'), error); } } @@ -82,9 +83,13 @@ export default class SshKeyService { host: string, proxy?: string, ) { - if (!/^[a-zA-Z0-9.-]+$/.test(host) || - (proxy && !/^[a-zA-Z0-9.:[\]-]+$/.test(proxy))) { - throw Object.assign(new Error('Invalid SSH host or proxy'), { status: 400 }); + if ( + !/^[a-zA-Z0-9.-]+$/.test(host) || + (proxy && !/^[a-zA-Z0-9.:[\]-]+$/.test(proxy)) + ) { + throw Object.assign(new Error('Invalid SSH host or proxy'), { + status: 400, + }); } if (host === 'github.com') { host = `ssh.github.com\n Port 443\n HostkeyAlgorithms +ssh-rsa`; @@ -92,7 +97,10 @@ export default class SshKeyService { const proxyStr = proxy ? ` ProxyCommand nc -v -x ${proxy} %h %p 2>/dev/null\n` : ''; - const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${resolveSubscriptionPath(this.sshPath, alias)}\n StrictHostKeyChecking no\n${proxyStr}`; + const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${resolveSubscriptionPath( + this.sshPath, + alias, + )}\n StrictHostKeyChecking no\n${proxyStr}`; await writeFileWithLock( `${resolveSubscriptionPath(this.sshPath, alias, '.config')}`, config, @@ -108,7 +116,7 @@ export default class SshKeyService { const filePath = resolveSubscriptionPath(this.sshPath, alias, '.config'); await rmPath(filePath); } catch (error) { - this.logger.error(`删除ssh配置文件${alias}失败`, error); + this.logger.error(t('删除ssh配置文件%s失败'), alias, error); } } @@ -147,7 +155,7 @@ export default class SshKeyService { ); await this.generateSingleSshConfig(alias, host, doc.proxy); } catch (error) { - this.logger.warn('跳过订阅 %s 的无效 SSH 配置: %s', doc.id, error); + this.logger.warn(t('跳过订阅 %s 的无效 SSH 配置: %s'), doc.id, error); } } } @@ -167,7 +175,10 @@ export default class SshKeyService { private async generateGlobalSshConfig(alias: string) { // Create a config that matches all hosts, making this key globally available - const config = `Host *\n IdentityFile ${resolveSubscriptionPath(this.sshPath, alias)}\n StrictHostKeyChecking no\n`; + const config = `Host *\n IdentityFile ${resolveSubscriptionPath( + this.sshPath, + alias, + )}\n StrictHostKeyChecking no\n`; await writeFileWithLock( `${resolveSubscriptionPath(this.sshPath, alias, '.config')}`, config, diff --git a/back/services/system.ts b/back/services/system.ts index b8ac8e64..79c1544a 100644 --- a/back/services/system.ts +++ b/back/services/system.ts @@ -5,7 +5,7 @@ import { promisify } from 'util'; import { shellQuote } from '../shared/shellQuote'; import { Response } from 'express'; import fs from 'fs'; -import { Agent, request } from 'undici'; +import { request } from 'undici'; import path from 'path'; import { Inject, Service } from 'typedi'; import winston from 'winston'; @@ -282,26 +282,24 @@ export default class SystemService { } public async checkUpdate() { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15000); try { const currentVersionContent = await parseVersion(config.versionFile); - let lastVersionContent; - try { - const { body } = await request( - `${config.lastVersionFile}?t=${Date.now()}`, - { - dispatcher: new Agent({ - keepAliveTimeout: 30000, - keepAliveMaxTimeout: 30000, - }), - }, - ); - const text = await body.text(); - lastVersionContent = parseContentVersion(text); - } catch (error) {} - - if (!lastVersionContent) { - lastVersionContent = currentVersionContent; + const { body, statusCode } = await request( + `${config.lastVersionFile}?t=${Date.now()}`, + { signal: controller.signal }, + ); + const text = await body.text(); + if (statusCode !== 200) throw new Error('Version request failed'); + const lastVersionContent = parseContentVersion(text); + if ( + !lastVersionContent || + typeof lastVersionContent.version !== 'string' || + !/^\d+\.\d+\.\d+$/.test(lastVersionContent.version) + ) { + throw new Error('Invalid version response'); } return { @@ -319,8 +317,10 @@ export default class SystemService { } catch (error: any) { return { code: 400, - message: error.message, + message: t('检查更新失败,请检查网络后重试'), }; + } finally { + clearTimeout(timeout); } } diff --git a/back/services/user.ts b/back/services/user.ts index da982793..9bc306e4 100644 --- a/back/services/user.ts +++ b/back/services/user.ts @@ -173,7 +173,7 @@ export default class UserService { t('你于') + dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') + t('在') + - address + + t(address) + ' ' + req.platform + t('端') + @@ -219,7 +219,7 @@ export default class UserService { t('你于') + dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss') + t('在') + - address + + t(address) + ' ' + req.platform + t('端') + diff --git a/back/shared/cronScheduler.ts b/back/shared/cronScheduler.ts index 65443a7d..20068a4b 100644 --- a/back/shared/cronScheduler.ts +++ b/back/shared/cronScheduler.ts @@ -1,3 +1,4 @@ +import { t } from './i18n'; import { createTask, ScheduledTask } from 'node-cron'; import { parseCronSchedule } from './cronSchedule'; @@ -36,7 +37,7 @@ export function createCronJob( if (cancelled || !started || !accepts(index, date)) return; if (missed) { options.logger.warn( - '[schedule][补执行迟到任务] 任务: %s, 计划时间: %s', + t('[schedule][补执行迟到任务] 任务: %s, 计划时间: %s'), options.name, date.toISOString(), ); @@ -45,7 +46,7 @@ export function createCronJob( await callback(date); } catch (error) { options.logger.error( - '[schedule][定时回调失败] 任务: %s, 错误: %s', + t('[schedule][定时回调失败] 任务: %s, 错误: %s'), options.name, error instanceof Error ? error.message : String(error), ); diff --git a/back/shared/i18n.ts b/back/shared/i18n.ts index fbd83984..abd2faf1 100644 --- a/back/shared/i18n.ts +++ b/back/shared/i18n.ts @@ -1,112 +1,229 @@ -import { shareStore } from './store'; - const messages: Record> = { zh: {}, en: { - '资源已存在,请检查重复的名称或值': 'Resource already exists; check for duplicate names or values', - '暂无权限': 'Access denied', - '参数错误': 'Invalid parameter', - '参数不正确': 'Invalid parameter', - '文件无法访问': 'File not accessible', - '文件不存在': 'File not found', - '路径不存在': 'Path does not exist', - '路径不正确': 'Invalid path', + '[schedule][停止已运行任务] 任务ID: %s, PID: %s': + '[schedule][Stopping running task] Task ID: %s, PID: %s', + + '接口已下线,请使用 /configs/detail 接口': + 'This endpoint has been retired. Use /configs/detail instead.', + '接口已下线,请使用 /logs/detail 接口': + 'This endpoint has been retired. Use /logs/detail instead.', + '接口已下线,请使用 /scripts/detail 接口': + 'This endpoint has been retired. Use /scripts/detail instead.', + '仅支持 PNG、JPEG、GIF、WebP、AVIF 图片': + 'Only PNG, JPEG, GIF, WebP and AVIF images are supported', + 请先初始化: 'Please initialize the panel first', + 请先关闭两步验证: 'Please disable two-factor authentication first', + '失败次数过多,请稍后重试': + 'Too many failed attempts. Please try again later.', + 配置更新失败: 'Failed to update configuration', + 'trust proxy 配置格式无效': 'Invalid trust proxy configuration', + '代理层数不能超过 20': 'Proxy hop count cannot exceed 20', + '环境变量 QL_TRUST_PROXY 已生效,系统设置不可覆盖': + 'QL_TRUST_PROXY is active and cannot be overridden by system settings', + 系统配置不存在: 'System configuration does not exist', + '历史版本仅支持不超过 1 MiB 的 UTF-8 文本脚本': + 'Version history only supports UTF-8 text scripts up to 1 MiB', + 脚本不存在: 'Script does not exist', + 请选择脚本文件: 'Please select a script file', + '历史版本读取失败,请检查历史文件': + 'Failed to read version history. Check the history file.', + 历史版本不存在或已清理: + 'This history version does not exist or has been removed', + '文件已变化,请刷新历史版本后重试': + 'The file has changed. Refresh version history and try again.', + '历史版本容量不足,无法安全保存': + 'Version history storage is full. The file cannot be saved safely.', + '跳过订阅 %s 的无效定时配置: %s': + 'Skipping invalid schedule for subscription %s: %s', + '[schedule][准备运行任务] 命令: %s': + '[schedule][Preparing task] Command: %s', + '[schedule][创建定时任务] 任务ID: %s, 规则数: %s': + '[schedule][Creating scheduled task] Task ID: %s, schedules: %s', + '[schedule][取消定时任务] 任务ID: %s': + '[schedule][Cancelling scheduled task] Task ID: %s', + '[schedule][取消任务失败] 任务ID: %s, 错误: %s': + '[schedule][Failed to cancel task] Task ID: %s, error: %s', + '[panel][停止任务失败] PID: %s, 错误: %s': + '[panel][Failed to stop task] PID: %s, error: %s', + '[panel][停止实例失败] 实例ID: %s, PID: %s, 错误: %s': + '[panel][Failed to stop instance] Instance ID: %s, PID: %s, error: %s', + '[panel][执行任务失败] 任务ID: %s, 错误: %s': + '[panel][Task failed] Task ID: %s, error: %s', + '[panel][执行任务结束] 任务ID: %s, 退出码: %j': + '[panel][Task finished] Task ID: %s, exit code: %j', + '[panel][创建任务失败] 任务ID: %s, 错误: %s': + '[panel][Failed to create task] Task ID: %s, error: %s', + '[panel][关闭任务日志失败] %s': '[panel][Failed to close task log] %s', + '[panel][清理任务状态失败] %s': '[panel][Failed to clear task status] %s', + '性能指标报告:': 'Performance metrics:', + '[panel][开始执行任务] 任务ID: %s': '[panel][Starting task] Task ID: %s', + '[panel][任务错误回调失败] %s': '[panel][Task error callback failed] %s', + '[panel][任务结束回调失败] %s': + '[panel][Task completion callback failed] %s', + '[panel][任务队列失败] %s': '[panel][Task queue failed] %s', + '[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s': + '[panel][Creating cron task] Task ID: %s, cron: %s, name: %s, command: %s', + '[panel][跳过无效定时任务] 任务ID: %s, cron: %s, 错误: %s': + '[panel][Skipping invalid schedule] Task ID: %s, cron: %s, error: %s', + '[panel][取消定时任务] 任务名: %s': + '[panel][Cancelling scheduled task] Name: %s', + '[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s': + '[panel][Creating interval task] Task ID: %s, name: %s, command: %s', + '[panel][执行任务失败] 命令: %s, 错误信息: %j': + '[panel][Task failed] Command: %s, error: %j', + '[panel][取消interval任务] 任务ID: %s, 任务名: %s': + '[panel][Cancelling interval task] Task ID: %s, name: %s', + 生成私钥文件失败: 'Failed to generate private key file', + 删除私钥文件失败: 'Failed to delete private key file', + '删除ssh配置文件%s失败': 'Failed to delete SSH configuration file %s', + '跳过订阅 %s 的无效 SSH 配置: %s': + 'Skipping invalid SSH configuration for subscription %s: %s', + '[schedule][补执行迟到任务] 任务: %s, 计划时间: %s': + '[schedule][Running late task] Task: %s, scheduled time: %s', + '[schedule][定时回调失败] 任务: %s, 错误: %s': + '[schedule][Scheduled callback failed] Task: %s, error: %s', + '[schedule][任务队列] 空闲中...': '[schedule][Task queue] Idle...', + '[schedule][任务加入队列] 运行中任务数: %s, 等待中任务数: %s': + '[schedule][Task queued] Running: %s, waiting: %s', + '[schedule][开始处理任务] 运行中任务数: %s, 等待中任务数: %s': + '[schedule][Processing task] Running: %s, waiting: %s', + '[schedule][任务处理成功] 参数 %s': + '[schedule][Task succeeded] Arguments: %s', + '[schedule][任务处理错误] 参数 %s': '[schedule][Task failed] Arguments: %s', + '[schedule][任务处理结束] 运行中任务数: %s, 等待中任务数: %s': + '[schedule][Task finished] Running: %s, waiting: %s', + '[schedule][任务重复运行] 通知失败 %s': + '[schedule][Duplicate task] Notification failed: %s', + '[schedule][任务重复运行] 参数 %s': + '[schedule][Duplicate task] Arguments: %s', + '[schedule][停止已运行任务] 任务ID: %s': + '[schedule][Stopping running task] Task ID: %s', + '[schedule][检查已运行任务失败] 任务ID: %s, 错误: %s': + '[schedule][Failed to check running task] Task ID: %s, error: %s', + '[schedule][开始执行任务] 参数 %s': + '[schedule][Starting task] Arguments: %s', + '[schedule][任务标准错误] 命令: %s, 信息: %s': + '[schedule][Task stderr] Command: %s, message: %s', + '[schedule][执行任务失败] 命令: %s, 错误: %s': + '[schedule][Task failed] Command: %s, error: %s', + '[schedule][执行任务结束] 任务ID: %s, 退出码: %j': + '[schedule][Task finished] Task ID: %s, exit code: %j', + '[schedule][创建任务失败] 命令: %s, 错误: %s': + '[schedule][Failed to create task] Command: %s, error: %s', + + '检查更新失败,请检查网络后重试': + 'Failed to check for updates. Check your network and try again.', + '资源已存在,请检查重复的名称或值': + 'Resource already exists; check for duplicate names or values', + 暂无权限: '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', + '通知发送失败,请检查系统设置/通知配置': + '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', + 系统将在: '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', + '每条数据 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', - '文件路径无效': 'Invalid file path', - '保存成功': 'Saved successfully', + 两步骤验证已开启: '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', + 文件路径无效: 'Invalid file path', + 保存成功: 'Saved successfully', 'client_id 或 client_seret 有误': 'Invalid client_id or client_secret', - '订阅执行完成': 'Subscription completed', - 'wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行': 'wxPusher requires at least one of TopicIds or Uids', + 订阅执行完成: 'Subscription completed', + 'wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行': + 'wxPusher requires at least one of TopicIds or Uids', 'wxPusher SPT 不能为空': 'wxPusher SPT cannot be empty', 'wxPusher SPT 最多支持 10 个': 'wxPusher SPT supports at most 10 tokens', 'Url 或者 Body 中必须包含 $title': 'Url or Body must contain $title', '绝对路径必须在日志目录内或使用 /dev/null': 'Absolute path must be within log directory or use /dev/null', - '请先登录': 'Please login first', + 请先登录: 'Please login first', '运行中...': 'Running...', '日志不存在...': 'Log does not exist...', - '未分类': 'Uncategorized', - '任务重复运行': 'Duplicate task execution', - '日志设置为忽略': 'Log set to ignore', - '定时规则不能为空': 'Schedule rule cannot be empty', - '无效的定时规则': 'Invalid schedule rule', + 未分类: 'Uncategorized', + 任务重复运行: 'Duplicate task execution', + 日志设置为忽略: 'Log set to ignore', + 定时规则不能为空: 'Schedule rule cannot be empty', + 无效的定时规则: 'Invalid schedule rule', '日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符': 'Log name can only contain Chinese characters, letters, numbers, underscores, hyphens, dots, and path separators', - '日志名称不能超过100个字符': 'Log name cannot exceed 100 characters', - '错误的用户名密码,请重试': 'Incorrect username or password, please try again', + 日志名称不能超过100个字符: 'Log name cannot exceed 100 characters', + '错误的用户名密码,请重试': + 'Incorrect username or password, please try again', '该 IP 已被列入黑名单': 'This IP address has been blocked', '已加入 IP 黑名单': 'IP address added to the blacklist', '已移出 IP 黑名单': 'IP address removed from the blacklist', - '青龙快讯': 'QingLong', - '登录通知': 'Login Notification', - '你于': 'You at ', - '在': ' in ', - 端: '', + 青龙快讯: 'QingLong', + 登录通知: 'Login Notification', + 你于: 'You at ', + 在: ' in ', + 端: ' client', + 内网IP: 'Private IP', 登录失败: 'login failed', ',ip地址': ', IP: ', '任务#%s': 'Task#%s', @@ -131,12 +248,13 @@ const messages: Record> = { 青龙: 'QingLong', '【蛟龙】测试通知 https://t.me/jiao_long': '[JiaoLong] Test notification https://t.me/jiao_long', - '生成token': 'Generate token', - '删除日志': 'Delete logs', + 生成token: 'Generate token', + 删除日志: 'Delete logs', '## 开始执行... %s\n': '## Start executing... %s\n', '执行before命令...': 'Execute before command...', '执行after命令...': 'Execute after command...', - '## 执行结束... %s 耗时 %s 秒': '## Execution finished... %s elapsed %s seconds', + '## 执行结束... %s 耗时 %s 秒': + '## Execution finished... %s elapsed %s seconds', '调度器注册失败,任务创建已回滚': 'Scheduler registration failed, task creation rolled back', '调度器注册失败,任务更新已回滚': @@ -152,9 +270,18 @@ const messages: Record> = { let currentLang: string = 'zh'; -export function setLang(lang: string) { +export function setLang(lang: string, persist = true) { currentLang = lang || 'zh'; - shareStore.setLang(currentLang); + if (!persist) return; + // Translation is also used by standalone scheduler and CLI helpers. Load + // persistent storage only when changing the language, not when reading it. + const { shareStore } = require('./store') as typeof import('./store'); + const language = currentLang; + return shareStore.setLang(language).then(() => { + if (currentLang === language && process.connected && process.send) { + process.send({ type: 'language-changed', lang: language }, () => {}); + } + }); } /** 系统默认语言:Intl 检测 → 'zh'(仅返回 zh/en) */ diff --git a/back/shared/languageSync.ts b/back/shared/languageSync.ts new file mode 100644 index 00000000..ae4d2b8e --- /dev/null +++ b/back/shared/languageSync.ts @@ -0,0 +1,32 @@ +import cluster from 'cluster'; +import { setLang, systemLang } from './i18n'; + +function languageMessage( + message: unknown, +): message is { type: string; lang: string } { + const value = message as { type?: string; lang?: string } | null; + return ( + value?.type === 'language-changed' && ['zh', 'en'].includes(value.lang!) + ); +} + +// Each worker owns a translation cache. Relay saved settings to live workers; +// a replacement worker restores the persisted language before serving requests. +export async function initializeLanguageSync() { + if (cluster.isPrimary) { + cluster.on('message', (_worker, message) => { + if (!languageMessage(message)) return; + setLang(message.lang, false); + for (const worker of Object.values(cluster.workers || {})) { + if (worker?.isConnected()) worker.send(message, () => {}); + } + }); + return; + } else { + process.on('message', (message) => { + if (languageMessage(message)) setLang(message.lang, false); + }); + } + const { shareStore } = await import('./store'); + setLang((await shareStore.getLang()) || systemLang(), false); +} diff --git a/back/shared/pLimit.ts b/back/shared/pLimit.ts index 969a6d58..4a2f5cae 100644 --- a/back/shared/pLimit.ts +++ b/back/shared/pLimit.ts @@ -79,29 +79,36 @@ class TaskLimit { private handleEvents() { this.cronLimit.on('add', () => { Logger.info( - `[schedule][任务加入队列] 运行中任务数: ${this.cronLimitActiveCount}, 等待中任务数: ${this.cronLimitPendingCount}`, + t('[schedule][任务加入队列] 运行中任务数: %s, 等待中任务数: %s'), + this.cronLimitActiveCount, + this.cronLimitPendingCount, ); }); this.cronLimit.on('active', () => { Logger.info( - `[schedule][开始处理任务] 运行中任务数: ${ - this.cronLimitActiveCount + 1 - }, 等待中任务数: ${this.cronLimitPendingCount}`, + t('[schedule][开始处理任务] 运行中任务数: %s, 等待中任务数: %s'), + this.cronLimitActiveCount + 1, + this.cronLimitPendingCount, ); }); this.cronLimit.on('completed', (param) => { - Logger.info(`[schedule][任务处理成功] 参数 ${JSON.stringify(param)}`); + Logger.info(t('[schedule][任务处理成功] 参数 %s'), JSON.stringify(param)); }); this.cronLimit.on('error', (error) => { - Logger.error(`[schedule][任务处理错误] 参数 ${JSON.stringify(error)}`); + Logger.error( + t('[schedule][任务处理错误] 参数 %s'), + JSON.stringify(error), + ); }); this.cronLimit.on('next', () => { Logger.info( - `[schedule][任务处理结束] 运行中任务数: ${this.cronLimitActiveCount}, 等待中任务数: ${this.cronLimitPendingCount}`, + t('[schedule][任务处理结束] 运行中任务数: %s, 等待中任务数: %s'), + this.cronLimitActiveCount, + this.cronLimitPendingCount, ); }); this.cronLimit.on('idle', () => { - Logger.info(`[schedule][任务队列] 空闲中...`); + Logger.info(t(`[schedule][任务队列] 空闲中...`)); }); } @@ -172,13 +179,14 @@ class TaskLimit { (err, res) => { if (err) { Logger.error( - `[schedule][任务重复运行] 通知失败 ${JSON.stringify(err)}`, + t('[schedule][任务重复运行] 通知失败 %s'), + JSON.stringify(err), ); } }, ); } - Logger.warn(`[schedule][任务重复运行] 参数 ${JSON.stringify(cron)}`); + Logger.warn(t('[schedule][任务重复运行] 参数 %s'), JSON.stringify(cron)); return; } this.queuedCrons.set(cron.id, result); diff --git a/back/shared/runCron.ts b/back/shared/runCron.ts index 8a862681..e5b9606e 100644 --- a/back/shared/runCron.ts +++ b/back/shared/runCron.ts @@ -1,3 +1,4 @@ +import { t } from './i18n'; import { spawn } from 'cross-spawn'; import taskLimit from './pLimit'; import Logger from '../loaders/logger'; @@ -37,7 +38,9 @@ export function runCron( existingCron.status === CrontabStatus.queued) ) { Logger.info( - `[schedule][停止已运行任务] 任务ID: ${cron.id}, PID: ${existingCron.pid}`, + t('[schedule][停止已运行任务] 任务ID: %s, PID: %s'), + cron.id, + existingCron.pid, ); await killTask(existingCron.pid, true); // Mark old running instances as stopped @@ -59,7 +62,9 @@ export function runCron( } } catch (error) { Logger.error( - `[schedule][检查已运行任务失败] 任务ID: ${cron.id}, 错误: ${error}`, + t('[schedule][检查已运行任务失败] 任务ID: %s, 错误: %s'), + cron.id, + error, ); throw error; } @@ -67,17 +72,18 @@ export function runCron( // In particular, invalidation can happen while stopping a previous run. if (!isCurrent()) return; Logger.info( - `[schedule][开始执行任务] 参数 ${JSON.stringify({ + t('[schedule][开始执行任务] 参数 %s'), + JSON.stringify({ ...cron, command: cmd, - })}`, + }), ); const cp = spawn(cmd, { shell: '/bin/bash' }); const { completed } = observeChildProcess(cp, { onStderr: async (message) => { Logger.info( - '[schedule][任务标准错误] 命令: %s, 信息: %s', + t('[schedule][任务标准错误] 命令: %s, 信息: %s'), cmd, message, ); @@ -86,20 +92,20 @@ export function runCron( const result = await completed; if (result.error) { Logger.error( - '[schedule][执行任务失败] 命令: %s, 错误: %s', + t('[schedule][执行任务失败] 命令: %s, 错误: %s'), cmd, result.error.message, ); } Logger.info( - '[schedule][执行任务结束] 任务ID: %s, 退出码: %j', + t('[schedule][执行任务结束] 任务ID: %s, 退出码: %j'), cron.id, result.code, ); return { ...cron, command: cmd, pid: cp.pid, ...result } as any; } catch (error) { Logger.error( - '[schedule][创建任务失败] 命令: %s, 错误: %s', + t('[schedule][创建任务失败] 命令: %s, 错误: %s'), cmd, asError(error).message, ); diff --git a/back/shared/trustProxy.ts b/back/shared/trustProxy.ts index 4afc3a8c..2d625277 100644 --- a/back/shared/trustProxy.ts +++ b/back/shared/trustProxy.ts @@ -1,6 +1,7 @@ import express, { Application, Request } from 'express'; import { AuthDataType, SystemModel } from '../data/system'; import { normalizeClientIp } from './clientIp'; +import { t } from './i18n'; const DEFAULT_TRUST_PROXY = 'loopback'; @@ -30,10 +31,10 @@ export function resolveTrustProxy(value?: string): TrustProxyValue { function validateTrustProxy(value: string): string { const setting = normalizeSetting(value); if (setting.length > 500 || /[\r\n]/.test(setting)) { - throw new Error('trust proxy 配置格式无效'); + throw new Error(t('trust proxy 配置格式无效')); } if (/^\d+$/.test(setting) && Number(setting) > 20) { - throw new Error('代理层数不能超过 20'); + throw new Error(t('代理层数不能超过 20')); } const probe = express(); @@ -73,7 +74,7 @@ export async function initializeTrustProxy(app: Application) { export async function updateTrustProxy(value: string) { if (getEnvironmentSetting()) { - throw new Error('环境变量 QL_TRUST_PROXY 已生效,系统设置不可覆盖'); + throw new Error(t('环境变量 QL_TRUST_PROXY 已生效,系统设置不可覆盖')); } const trustProxy = validateTrustProxy(value); @@ -81,7 +82,7 @@ export async function updateTrustProxy(value: string) { where: { type: AuthDataType.systemConfig }, }); if (!doc) { - throw new Error('系统配置不存在'); + throw new Error(t('系统配置不存在')); } const plain = doc.get({ plain: true }); diff --git a/cli/src/internal/execution/preloadSource.ts b/cli/src/internal/execution/preloadSource.ts index 4bd26dbd..04a10b98 100644 --- a/cli/src/internal/execution/preloadSource.ts +++ b/cli/src/internal/execution/preloadSource.ts @@ -82,7 +82,7 @@ export const nodePreloadSource: string = [ '{ if [ -n "$inline" ]; then eval "$inline"; fi; } &&', "printf '%s\\\\n' '__sitecustomize__' &&", 'node -e \'require("fs").writeFileSync(process.argv[1], JSON.stringify(process.env))\' "$output"`;', - " if (task_before) console.log('执行前置命令\\n');", + " if (task_before) console.log(process.env.QL_LANG === 'en' ? 'Executing before command\\n' : '执行前置命令\\n');", ' const hookArgs = JSON.parse(process.env.QL_CLI_PRELOAD_ARGS || JSON.stringify([fileName]));', " const res = execFileSync('/bin/bash', ['-c', commands, 'ql-before', file_task_before, tempFile, task_before || '', ...hookArgs], {", " encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024,", @@ -115,7 +115,7 @@ export const nodePreloadSource: string = [ ' console.log(output);', ' }', ' if (task_before) {', - " console.log('执行前置命令结束\\n');", + " console.log(process.env.QL_LANG === 'en' ? 'Before command finished\\n' : '执行前置命令结束\\n');", ' }', ' } catch (error) {', " if (!error.message.includes('spawnSync /bin/bash E2BIG')) {", @@ -124,7 +124,7 @@ export const nodePreloadSource: string = [ ' // environment variable is too large', ' }', ' if (task_before) {', - " console.log('执行前置命令结束\\n');", + " console.log(process.env.QL_LANG === 'en' ? 'Before command finished\\n' : '执行前置命令结束\\n');", ' }', ' }', '', @@ -214,7 +214,7 @@ export const pythonPreloadSource: string = [ ' ', ' task_before = os.getenv("task_before", "")', ' if task_before:', - ' print("执行前置命令\\n")', + ' print("Executing before command\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令\\n")', ' command = """file="$1"; output="$2"; inline="$3"; shift 3', 'source "$file" "$@" &&', '{ if [ -n "$inline" ]; then eval "$inline"; fi; } &&', @@ -244,12 +244,12 @@ export const pythonPreloadSource: string = [ ' if len(output) > 0:', ' print(output)', ' if task_before:', - ' print("执行前置命令结束\\n")', + ' print("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")', '', ' except subprocess.CalledProcessError as error:', ' print(f"\\ue926 run task before error: {error}")', ' if task_before:', - ' print("执行前置命令结束\\n")', + ' print("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")', ' except OSError as error:', ' error_message = str(error)', ' if "Argument list too long" not in error_message:', @@ -257,11 +257,11 @@ export const pythonPreloadSource: string = [ ' # else:', ' # environment variable is too large', ' if task_before:', - ' print("执行前置命令结束\\n")', + ' print("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")', ' except Exception as error:', ' print(f"\\ue926 run task before error: {error}")', ' if task_before:', - ' print("执行前置命令结束\\n")', + ' print("Before command finished\\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\\n")', '', ' import task_before', '', diff --git a/cli/src/internal/maintenance/upgrade.ts b/cli/src/internal/maintenance/upgrade.ts index c877f230..1dbd2674 100644 --- a/cli/src/internal/maintenance/upgrade.ts +++ b/cli/src/internal/maintenance/upgrade.ts @@ -11,13 +11,21 @@ import { installPanelDependencies, startPanel, stopPanel } from './operator'; // The Bash image updater and local CLI must enforce the same build provenance. const artifacts = require('./upgradeArtifacts.cjs') as { releaseBranch(env: NodeJS.ProcessEnv): string; - verifyUpgrade(source: string, staticRoot: string): Promise; + verifyUpgrade( + source: string, + staticRoot: string, + env?: NodeJS.ProcessEnv, + ): Promise; waitForHealth( env: NodeJS.ProcessEnv, timeout?: number, signal?: AbortSignal, ): Promise; - withUpgradeLock(tmp: string, operation: () => Promise): Promise; + withUpgradeLock( + tmp: string, + operation: () => Promise, + env?: NodeJS.ProcessEnv, + ): Promise; stageUpgrade(options: { root: string; tmp: string; @@ -30,6 +38,7 @@ const artifacts = require('./upgradeArtifacts.cjs') as { selectedUpgrade( tmp: string, branch: string, + env?: NodeJS.ProcessEnv, ): Promise<{ source: string; static: string }>; }; @@ -69,17 +78,20 @@ export async function stageUpgrade( context: LocalContext, mirror: 'github' | 'gitee', ): Promise<{ source: string; static: string }> { - return artifacts.withUpgradeLock(context.paths.dir_tmp!, () => - artifacts.stageUpgrade({ - root: context.root, - tmp: context.paths.dir_tmp!, - env: context.env, - mirror, - signal: operationSignal(), - run: (program, args, capture) => - checkedProcess(program, args, { env: context.env, capture }), - install: (source) => installPanelDependencies(context, source), - }), + return artifacts.withUpgradeLock( + context.paths.dir_tmp!, + () => + artifacts.stageUpgrade({ + root: context.root, + tmp: context.paths.dir_tmp!, + env: context.env, + mirror, + signal: operationSignal(), + run: (program, args, capture) => + checkedProcess(program, args, { env: context.env, capture }), + install: (source) => installPanelDependencies(context, source), + }), + context.env, ); } @@ -89,6 +101,7 @@ async function selectedUpgrade( return artifacts.selectedUpgrade( context.paths.dir_tmp!, releaseBranch(context), + context.env, ); } @@ -259,7 +272,7 @@ export async function reloadPanel( const staticRoot = selected.static; await validateTree(source, context.env); await validateTree(staticRoot, context.env); - await artifacts.verifyUpgrade(source, staticRoot); + await artifacts.verifyUpgrade(source, staticRoot, context.env); const entries = await fs.readdir(source); const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']); const relativeData = path.relative(context.root, context.data); @@ -284,15 +297,18 @@ export async function reloadPanel( if (selection) replacements.push({ source: selection.source, target: selection.target }); try { - return await artifacts.withUpgradeLock(context.paths.dir_tmp!, () => - replaceAndReload(context, replacements, { - stop: stopPanel, - start: async (ctx) => { - const service = await startPanel(ctx); - await artifacts.waitForHealth(ctx.env, 60000, operationSignal()); - return service; - }, - }), + return await artifacts.withUpgradeLock( + context.paths.dir_tmp!, + () => + replaceAndReload(context, replacements, { + stop: stopPanel, + start: async (ctx) => { + const service = await startPanel(ctx); + await artifacts.waitForHealth(ctx.env, 60000, operationSignal()); + return service; + }, + }), + context.env, ); } finally { if (selection) diff --git a/cli/src/internal/maintenance/upgradeArtifacts.cjs b/cli/src/internal/maintenance/upgradeArtifacts.cjs index f3038170..8db7c2bc 100644 --- a/cli/src/internal/maintenance/upgradeArtifacts.cjs +++ b/cli/src/internal/maintenance/upgradeArtifacts.cjs @@ -7,11 +7,39 @@ const { spawn } = require('node:child_process'); const http = require('node:http'); const { setTimeout: delay } = require('node:timers/promises'); +const upgradeEnglish = { + '更新包下载及校验成功...': 'Update package downloaded and verified...', + '更新及启动检查成功...': + 'Update and startup checks completed successfully...', + '更新失败: %s': 'Update failed: %s', + '构建清单无效。': 'Invalid or dirty build manifest', + '缺少构建产物: %s': 'Missing build output: %s', + '源码与构建依赖不一致。': 'Source and build lockfiles differ', + '构建包含非法文件: %s': 'Unsupported build entry: %s', + '构建文件缺失、损坏或混入旧文件。': 'Build checksum mismatch', + '已有更新或重载正在执行。': 'Another upgrade is running', + 'GitHub 下载失败,切换 Gitee...': 'GitHub download failed; retrying Gitee', + '升级归档路径无效。': 'Invalid archive paths', + '升级归档包含符号链接。': 'Archive contains symlinks', + '暂存升级指针无效。': 'Invalid staged upgrade pointer', + '暂存目录无效。': 'Invalid staging directory', + '暂存升级就绪记录不匹配。': 'Invalid upgrade readiness', + '保留备份: %s': 'Retained backup: %s', + '后端启动检查失败。': 'Backend failed health check', + '保留生命周期快照: %s': 'Retained lifecycle snapshot: %s', +}; + +function upgradeMessage(key, env = process.env, ...values) { + const template = env.QL_LANG === 'en' ? upgradeEnglish[key] ?? key : key; + let index = 0; + return template.replace(/%s/g, () => String(values[index++] ?? '')); +} + const hash = (bytes) => createHash('sha256').update(bytes).digest('hex'); const releaseBranch = (env) => ['develop', 'debian-dev'].includes(env.QL_BRANCH) ? 'develop' : 'master'; -async function manifestAt(staticRoot) { +async function manifestAt(staticRoot, env = process.env) { const manifest = JSON.parse( await fs.readFile(path.join(staticRoot, 'build-info.json'), 'utf8'), ); @@ -24,22 +52,20 @@ async function manifestAt(staticRoot) { typeof manifest.files !== 'object' || Array.isArray(manifest.files) ) - throw new Error('Invalid or dirty build manifest / 构建清单无效。'); + throw new Error(upgradeMessage('构建清单无效。', env)); for (const required of ['build/app.js', 'dist/index.html']) if (!Object.hasOwn(manifest.files, required)) - throw new Error(`Missing build output / 缺少构建产物: ${required}`); + throw new Error(upgradeMessage('缺少构建产物: %s', env, required)); return manifest; } -async function verifyUpgrade(source, staticRoot) { - const manifest = await manifestAt(staticRoot); +async function verifyUpgrade(source, staticRoot, env = process.env) { + const manifest = await manifestAt(staticRoot, env); if ( hash(await fs.readFile(path.join(source, 'pnpm-lock.yaml'))) !== manifest.lockfileSha256 ) - throw new Error( - 'Source and build lockfiles differ / 源码与构建依赖不一致。', - ); + throw new Error(upgradeMessage('源码与构建依赖不一致。', env)); await fs.access(path.join(source, 'package.json')); await fs.access(path.join(source, 'sample/config.sample.sh')); const files = {}; @@ -50,8 +76,7 @@ async function verifyUpgrade(source, staticRoot) { const filename = path.join(directory, entry.name); if (entry.isDirectory()) await visit(filename, `${name}/`); else if (entry.isFile()) files[name] = hash(await fs.readFile(filename)); - else - throw new Error(`Unsupported build entry / 构建包含非法文件: ${name}`); + else throw new Error(upgradeMessage('构建包含非法文件: %s', env, name)); } } await visit(staticRoot); @@ -61,13 +86,11 @@ async function verifyUpgrade(source, staticRoot) { JSON.stringify(actual) !== JSON.stringify(expected) || actual.some((name) => files[name] !== manifest.files[name]) ) - throw new Error( - 'Build checksum mismatch / 构建文件缺失、损坏或混入旧文件。', - ); + throw new Error(upgradeMessage('构建文件缺失、损坏或混入旧文件。', env)); return manifest; } -async function withUpgradeLock(tmp, operation) { +async function withUpgradeLock(tmp, operation, env = process.env) { await fs.mkdir(tmp, { recursive: true }); const lock = path.join(tmp, 'upgrade.lock'); const token = randomUUID(); @@ -123,9 +146,7 @@ async function withUpgradeLock(tmp, operation) { } } if (!stale) - throw new Error( - 'Another upgrade is running / 已有更新或重载正在执行。', - ); + throw new Error(upgradeMessage('已有更新或重载正在执行。', env)); // Removing the observed unique marker is a compare-and-delete. A // contender cannot unlink the marker belonging to a replacement lock. // Legacy pid files are supported; new owners never reuse that filename. @@ -150,7 +171,16 @@ async function withUpgradeLock(tmp, operation) { } } -async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) { +async function stageUpgrade({ + root, + tmp, + env, + mirror, + fallbackMirror, + run, + install, + signal, +}) { const workspace = await fs.mkdtemp(path.join(tmp, 'upgrade-')); const branch = releaseBranch(env); try { @@ -158,23 +188,38 @@ async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) { const archive = path.join(workspace, `${repository}.zip`); const url = mirror === 'github' - ? `https://github.com/whyour/${repository}/archive/${ref}.zip` + ? `https://codeload.github.com/whyour/${repository}/zip/${ref}` : `https://gitee.com/whyour/${repository}/repository/archive/${ref}.zip`; - await run('curl', [ - '--fail', - '--location', - '--proto', - '=https', - '--proto-redir', - '=https', - '--connect-timeout', - '30', - '--max-time', - '600', - '--output', - archive, - url, - ]); + const download = (target) => + run('curl', [ + '--fail', + '--location', + '--proto', + '=https', + '--proto-redir', + '=https', + '--connect-timeout', + '30', + '--max-time', + '600', + '--speed-limit', + '1024', + '--speed-time', + '30', + '--output', + archive, + target, + ]); + try { + await download(url); + } catch (error) { + signal?.throwIfAborted(); + if (mirror !== 'github' || fallbackMirror !== 'gitee') throw error; + console.warn(upgradeMessage('GitHub 下载失败,切换 Gitee...', env)); + await download( + `https://gitee.com/whyour/${repository}/repository/archive/${ref}.zip`, + ); + } const listing = await run('unzip', ['-Z1', archive], true); const expected = `${repository}-${ref}`; const names = listing.stdout.split('\n').filter(Boolean); @@ -189,21 +234,21 @@ async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) { name.split('/')[0] !== expected, ) ) - throw new Error('Invalid archive paths / 升级归档路径无效。'); + throw new Error(upgradeMessage('升级归档路径无效。', env)); const modes = await run('unzip', ['-Z', '-l', archive], true); if (modes.stdout.split('\n').some((line) => /^l[rwx-]{9}\s/.test(line))) - throw new Error('Archive contains symlinks / 升级归档包含符号链接。'); + throw new Error(upgradeMessage('升级归档包含符号链接。', env)); await run('unzip', ['-oq', archive, '-d', workspace]); return path.join(workspace, expected); } // Static branches are mutable snapshots. Their manifest pins the exact // source revision, avoiding a race between two independent branch reads. const staticRoot = await extract('qinglong-static', branch); - const manifest = await manifestAt(staticRoot); + const manifest = await manifestAt(staticRoot, env); const downloadedSource = await extract('qinglong', manifest.sourceCommit); const source = path.join(workspace, `qinglong-${branch}`); await fs.rename(downloadedSource, source); - await verifyUpgrade(source, staticRoot); + await verifyUpgrade(source, staticRoot, env); let dependenciesChanged = false; for (const name of ['package.json', 'pnpm-lock.yaml']) { const current = await fs @@ -222,7 +267,7 @@ async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) { if (dependenciesChanged) { await install(source); await fs.access(path.join(source, 'node_modules')); - await verifyUpgrade(source, staticRoot); + await verifyUpgrade(source, staticRoot, env); } signal?.throwIfAborted(); const selected = { source, static: staticRoot }; @@ -269,7 +314,7 @@ async function stageUpgrade({ root, tmp, env, mirror, run, install, signal }) { } } -async function selectedUpgrade(tmp, branch) { +async function selectedUpgrade(tmp, branch, env = process.env) { const pointer = JSON.parse( await fs.readFile(path.join(tmp, `upgrade-ready-${branch}.json`), 'utf8'), ); @@ -277,7 +322,7 @@ async function selectedUpgrade(tmp, branch) { typeof pointer.directory !== 'string' || !/^upgrade-[A-Za-z0-9_-]+$/.test(pointer.directory) ) - throw new Error('Invalid staged upgrade pointer / 暂存升级指针无效。'); + throw new Error(upgradeMessage('暂存升级指针无效。', env)); const workspace = path.join(tmp, pointer.directory); const selected = { source: path.join(workspace, `qinglong-${branch}`), @@ -286,22 +331,22 @@ async function selectedUpgrade(tmp, branch) { for (const directory of [workspace, selected.source, selected.static]) { const stat = await fs.lstat(directory); if (!stat.isDirectory() || stat.isSymbolicLink()) - throw new Error('Invalid staging directory / 暂存目录无效。'); + throw new Error(upgradeMessage('暂存目录无效。', env)); } const ready = JSON.parse( await fs.readFile(path.join(workspace, 'ready.json'), 'utf8'), ); if (ready.source !== selected.source || ready.static !== selected.static) - throw new Error('Invalid upgrade readiness / 暂存升级就绪记录不匹配。'); - const manifest = await verifyUpgrade(selected.source, selected.static); + throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env)); + const manifest = await verifyUpgrade(selected.source, selected.static, env); if (ready.sourceCommit !== manifest.sourceCommit) - throw new Error('Invalid upgrade readiness / 暂存升级就绪记录不匹配。'); + throw new Error(upgradeMessage('暂存升级就绪记录不匹配。', env)); if (ready.dependenciesChanged) await fs.access(path.join(selected.source, 'node_modules')); return selected; } -async function replaceAndReload(replacements, lifecycle) { +async function replaceAndReload(replacements, lifecycle, env = process.env) { const changed = []; let attemptedStart = false; lifecycle.signal?.throwIfAborted(); @@ -373,7 +418,7 @@ async function replaceAndReload(replacements, lifecycle) { await fs .rm(item.backup, { recursive: true, force: true }) .catch(() => - console.error(`Retained backup / 保留备份: ${item.backup}`), + console.error(upgradeMessage('保留备份: %s', env, item.backup)), ); } @@ -386,7 +431,7 @@ async function reloadSystem({ env, lifecycle, }) { - const selected = await selectedUpgrade(tmp, releaseBranch(env)); + const selected = await selectedUpgrade(tmp, releaseBranch(env), env); const reserved = new Set(['data', '.tmp', 'static', '.git', '.env']); const relative = path.relative(root, data); if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) @@ -404,7 +449,7 @@ async function reloadSystem({ target: path.join(config, 'config.sample.sh'), }, ); - await replaceAndReload(replacements, lifecycle); + await replaceAndReload(replacements, lifecycle, env); // Cleanup after a committed installation must not report a failed update. await fs .rm(path.join(tmp, `upgrade-ready-${releaseBranch(env)}.json`), { @@ -472,7 +517,7 @@ async function waitForHealth(env, timeout = 60000, signal) { } } } - throw new Error('Backend failed health check / 后端启动检查失败。'); + throw new Error(upgradeMessage('后端启动检查失败。', env)); } function runProcess(env, program, args, capture = false, signal) { @@ -575,9 +620,7 @@ esac await fs .rm(directory, { recursive: true, force: true }) .catch(() => - console.error( - `Retained lifecycle snapshot / 保留生命周期快照: ${directory}`, - ), + console.error(upgradeMessage('保留生命周期快照: %s', env, directory)), ); } } @@ -595,49 +638,56 @@ async function main() { ); const run = (program, args, capture) => runProcess(env, program, args, capture, controller.signal); - await withUpgradeLock(tmp, () => - withLifecycleHooks(root, tmp, env, async (hook) => { - const lifecycle = { - signal: controller.signal, - stop: () => hook('stop'), - start: async (recover) => { - await hook('start', [], recover ? undefined : controller.signal); - await waitForHealth( + await withUpgradeLock( + tmp, + () => + withLifecycleHooks(root, tmp, env, async (hook) => { + const lifecycle = { + signal: controller.signal, + stop: () => hook('stop'), + start: async (recover) => { + await hook('start', [], recover ? undefined : controller.signal); + await waitForHealth( + env, + 60000, + recover ? undefined : controller.signal, + ); + }, + }; + if (process.argv[2] === 'update') { + await stageUpgrade({ + root, + tmp, env, - 60000, - recover ? undefined : controller.signal, - ); - }, - }; - if (process.argv[2] === 'update') { - await stageUpgrade({ + mirror: process.argv[3], + // The Bash updater chooses its source automatically. An explicitly + // selected CLI mirror does not opt into this network-only failover. + fallbackMirror: 'gitee', + run, + signal: controller.signal, + install: (source) => hook('install', [source], controller.signal), + }); + console.log(upgradeMessage('更新包下载及校验成功...', env)); + if (process.argv[4] !== 'true') return; + } else if (process.argv[2] !== 'reload') + throw new Error('Unknown upgrade command.'); + await reloadSystem({ root, tmp, env, - mirror: process.argv[3], - run, - signal: controller.signal, - install: (source) => hook('install', [source], controller.signal), + data: env.dir_data || path.join(root, 'data'), + staticRoot: env.dir_static || path.join(root, 'static'), + config: env.dir_config || path.join(root, 'data/config'), + lifecycle, }); - console.log('更新包下载及校验成功...'); - if (process.argv[4] !== 'true') return; - } else if (process.argv[2] !== 'reload') - throw new Error('Unknown upgrade command.'); - await reloadSystem({ - root, - tmp, - env, - data: env.dir_data || path.join(root, 'data'), - staticRoot: env.dir_static || path.join(root, 'static'), - config: env.dir_config || path.join(root, 'data/config'), - lifecycle, - }); - console.log('更新及启动检查成功...'); - }), + console.log(upgradeMessage('更新及启动检查成功...', env)); + }), + env, ); } module.exports = { + upgradeMessage, releaseBranch, verifyUpgrade, withUpgradeLock, @@ -652,6 +702,6 @@ module.exports = { }; if (require.main === module) main().catch((error) => { - console.error(`更新失败: ${error.message}`); + console.error(upgradeMessage('更新失败: %s', process.env, error.message)); process.exitCode = 1; }); diff --git a/cli/src/internal/subscription/subscriptionRunner.ts b/cli/src/internal/subscription/subscriptionRunner.ts index 9caae3e4..13c83a4d 100644 --- a/cli/src/internal/subscription/subscriptionRunner.ts +++ b/cli/src/internal/subscription/subscriptionRunner.ts @@ -214,8 +214,13 @@ async function reconcile( if (added || removed) { try { await api.call('system/notify', 'PUT', { - title: `${namespace} 订阅同步`, - content: `新增 ${added} 个任务,删除 ${removed} 个任务`, + title: translate(context.env, '%s 订阅同步', namespace), + content: translate( + context.env, + '新增 %s 个任务,删除 %s 个任务', + added, + removed, + ), }); } catch { operationOutput( diff --git a/cli/src/shared/i18n/en.ts b/cli/src/shared/i18n/en.ts index f1cbdaae..48f5dd92 100644 --- a/cli/src/shared/i18n/en.ts +++ b/cli/src/shared/i18n/en.ts @@ -1,8 +1,13 @@ // Migrated from shell/lang/en.sh; the original remains for differential evaluation. export const english: Record = { - '需要双因素验证,请调用 user two-factor-login。': 'Two-factor authentication required; use user two-factor-login.', - 'QL_URL 和 QL_ACCESS_TOKEN 必须同时提供。': 'Provide QL_URL and QL_ACCESS_TOKEN together.', - '接口返回 JSON,未保存下载文件。': 'The endpoint returned JSON; no download was saved.', + '%s 订阅同步': '%s subscription sync', + '新增 %s 个任务,删除 %s 个任务': 'Added %s tasks, removed %s tasks', + '需要双因素验证,请调用 user two-factor-login。': + 'Two-factor authentication required; use user two-factor-login.', + 'QL_URL 和 QL_ACCESS_TOKEN 必须同时提供。': + 'Provide QL_URL and QL_ACCESS_TOKEN together.', + '接口返回 JSON,未保存下载文件。': + 'The endpoint returned JSON; no download was saved.', '已选择的 CLI 安装路径必须为绝对路径。': 'The selected CLI installation path must be absolute.', '保留 CLI 入口仅支持已确认的 2.x 升级载荷。': @@ -152,9 +157,12 @@ export const english: Record = { '应用凭据和 %s 权限': 'application credentials and %s permission', 未知: 'unknown', '请检查%s。': 'Check %s.', - '资源冲突,请检查重复的名称或值。': 'Resource conflict; check for duplicate names or values.', - '服务端错误,请检查服务状态和日志。': 'Server error; check service status and logs.', - '请求未成功,请检查请求参数和服务日志。': 'Request unsuccessful; check request parameters and service logs.', + '资源冲突,请检查重复的名称或值。': + 'Resource conflict; check for duplicate names or values.', + '服务端错误,请检查服务状态和日志。': + 'Server error; check service status and logs.', + '请求未成功,请检查请求参数和服务日志。': + 'Request unsuccessful; check request parameters and service logs.', 'API 请求失败(%s %s)。%s%s': 'API request failed (%s %s). %s%s', ' 执行结果可能未知,请先检查%s状态再考虑重试。': @@ -169,8 +177,7 @@ export const english: Record = { 'API request rejected (code %s). Check %s.', 'CLI 配置必须是当前用户拥有的私有普通文件(0600)。': 'CLI config must be an owned private regular file (0600).', - 'CLI 配置无效,请运行 ql login。': - 'Invalid CLI config. Run ql login.', + 'CLI 配置无效,请运行 ql login。': 'Invalid CLI config. Run ql login.', '尚未登录,请运行 ql login。': 'Not logged in. Run ql login.', '无法读取 CLI 配置,请检查文件权限并运行 ql login。': 'Cannot read CLI config. Check file permissions and run ql login.', diff --git a/cli/test/internal/operator.test.cjs b/cli/test/internal/operator.test.cjs index 5e3ca6a4..59ad89d5 100644 --- a/cli/test/internal/operator.test.cjs +++ b/cli/test/internal/operator.test.cjs @@ -287,7 +287,7 @@ test('upgrade staging extracts both archives before publishing readiness and rej ['-qry', path.join(archives, 'qinglong.zip'), `qinglong-${sourceCommit}`], { cwd: payloads }, ); - await assert.rejects(stageUpgrade(ctx, 'github'), /symlinks/); + await assert.rejects(stageUpgrade(ctx, 'github'), /symlinks|符号链接/); assert.deepEqual( (await fs.readdir(ctx.paths.dir_tmp)).sort(), [ diff --git a/cli/test/linux/mounted-data.test.cjs b/cli/test/linux/mounted-data.test.cjs index 9316ce9f..5b8ec7f3 100644 --- a/cli/test/linux/mounted-data.test.cjs +++ b/cli/test/linux/mounted-data.test.cjs @@ -23,7 +23,7 @@ test( assert.notEqual(before.dev, (await fs.stat(root)).dev); const context = createContext( { root, 'data-dir': data }, - { PATH: '/nonexistent' }, + { PATH: '/nonexistent', QL_LANG: 'en' }, ); t.after(async () => { await stopPanel(context); diff --git a/cli/test/linux/preload-paths.test.cjs b/cli/test/linux/preload-paths.test.cjs index 7cda51bd..d4395739 100644 --- a/cli/test/linux/preload-paths.test.cjs +++ b/cli/test/linux/preload-paths.test.cjs @@ -23,7 +23,7 @@ test( [ '--test', '--test-reporter=tap', - path.resolve(__dirname, '../preload.test.cjs'), + path.resolve(__dirname, '../internal/preload.test.cjs'), ], { env, diff --git a/cli/test/linux/published-archive.test.cjs b/cli/test/linux/published-archive.test.cjs index a038747b..56309479 100644 --- a/cli/test/linux/published-archive.test.cjs +++ b/cli/test/linux/published-archive.test.cjs @@ -43,6 +43,7 @@ test( context.paths.dir_tmp, ); installations.push(directory); + await fs.mkdir(path.join(directory, 'node_modules'), { recursive: true }); }, ); const controller = new AbortController(); @@ -70,9 +71,16 @@ test( ), ); assert.equal(pointer.directory, path.basename(workspace)); + const manifest = JSON.parse( + await fs.readFile(path.join(staged.static, 'build-info.json'), 'utf8'), + ); assert.deepEqual( JSON.parse(await fs.readFile(path.join(workspace, 'ready.json'), 'utf8')), - staged, + { + ...staged, + sourceCommit: manifest.sourceCommit, + dependenciesChanged: true, + }, ); assert.equal( await fs.readFile(path.join(root, 'package.json'), 'utf8'), diff --git a/docker/Dockerfile.debian b/docker/Dockerfile.debian index fed71e8b..871feb18 100644 --- a/docker/Dockerfile.debian +++ b/docker/Dockerfile.debian @@ -11,6 +11,7 @@ RUN set -x && \ apt-get update && \ apt-get install --no-install-recommends -y libatomic1 && \ npm i -g pnpm@8.3.1 && \ + test "$(pnpm --version)" = "8.3.1" && \ cd /tmp/build && \ pnpm install --prod --frozen-lockfile @@ -70,6 +71,7 @@ RUN set -x && \ git config --global user.name "qinglong" && \ git config --global http.postBuffer 524288000 && \ npm install -g pnpm@8.3.1 pm2 ts-node typescript@5 && \ + test "$(pnpm --version)" = "8.3.1" && \ npm cache clean --force && \ rm -rf /root/.cache && \ rm -rf /root/.npm && \ @@ -114,7 +116,8 @@ USER root RUN ln -sf ${QL_DIR}/shell/task.sh /usr/local/bin/task \ && ln -sf ${QL_DIR}/shell/update.sh /usr/local/bin/ql \ - && chmod +x /usr/local/bin/task /usr/local/bin/ql + && chmod +x /usr/local/bin/task /usr/local/bin/ql \ + && test "$(pnpm --version)" = "8.3.1" WORKDIR ${QL_DIR} diff --git a/shell/api.sh b/shell/api.sh index 03926e99..1166de3e 100755 --- a/shell/api.sh +++ b/shell/api.sh @@ -236,7 +236,7 @@ find_cron_api() { update_auth_config() { local body="$1" - local tip="$2" + local tip=$(t "$2") local currentTimeStamp=$(date +%s) local api=$(ql_api_request \ "http://localhost:${ql_port}/open/system/auth/reset?t=$currentTimeStamp" \ diff --git a/shell/lang/en.sh b/shell/lang/en.sh index 38778c9c..dd27c3e4 100644 --- a/shell/lang/en.sh +++ b/shell/lang/en.sh @@ -108,4 +108,42 @@ declare -gA LANG_MESSAGES=( ['暂不支持此系统部署 %s']='Unsupported system for deployment: %s' # update.sh ['命令输入错误...\n']='Invalid command...\n' + ['%s 删除任务%s']='%s removed tasks%s' + ['%s 新增任务']='%s added tasks' + ['新增任务通知']='New task notification' + ['失效']='obsolete' + ['新']='new' + ['%s 命令使用方法:']='Usage: %s' + ['更新并重启青龙']='Update and restart QingLong' + ['运行自定义脚本']='Run custom scripts' + ['更新单个脚本文件']='Update a script' + ['更新单个仓库的脚本']='Update scripts from a repository' + ['删除旧日志']='Delete old logs' + ['启动tg-bot']='Start tg-bot' + ['检测青龙环境并修复']='Check and repair the QingLong environment' + ['查看青龙运行日志']='View QingLong service logs' + ['重置登录错误次数']='Reset failed login attempts' + ['禁用两步登录']='Disable two-factor login' + ['修改登录密码']='Change login password' + ['修改登录用户名']='Change login username' + ['禁用两步验证']='Disable two-factor authentication' + ['重置密码']='Reset password' + ['重置用户名']='Reset username' + [', 任务状态更新失败(%s)']=', task status update failed (%s)' + [', 状态更新失败(%s)']=', status update failed (%s)' + ['<未识别出活动名称>']='' + ['task命令运行本程序自动添加进crontab的脚本,需要输入脚本的绝对路径或去掉 “%s/” 目录后的相对路径(定时任务中请写作相对路径),用法为:']='task runs scripts registered in crontab. Use an absolute path or a path relative to %s/ (use relative paths in scheduled tasks). Usage:' + ['1.%s # 依次执行,如果设置了随机延迟,将随机延迟一定秒数']='1.%s # Run sequentially, with the configured random delay' + ['2.%s now # 依次执行,无论是否设置了随机延迟,均立即运行,前台会输出日志,同时记录在日志文件中']='2.%s now # Run immediately, displaying and saving logs' + ['3.%s conc <环境变量名称> <账号编号,空格分隔>(可选的) # 并发执行,无论是否设置了随机延迟,均立即运行,前台不产生日志,直接记录在日志文件中,且可指定账号执行']='3.%s conc [account-numbers...] # Run selected accounts concurrently, saving logs' + ['4.%s desi <环境变量名称> <账号编号,空格分隔> # 指定账号执行,无论是否设置了随机延迟,均立即运行']='4.%s desi # Run selected accounts immediately' + ['\n当前有以下脚本可以运行:']='\nAvailable scripts:' + ['\n暂无脚本可以执行']='\nNo scripts available' + ['🚀 1. 检测配置文件...']='🚀 1. Checking configuration...' + ['🔄 2. 启动 nginx...']='🔄 2. Starting nginx...' + ['⚙️ 3. 启动 pm2 服务...']='⚙️ 3. Starting pm2 services...' + ['🤖 4. 启动 bot...']='🤖 4. Starting bot...' + ['🛠️ 5. 执行自定义脚本...']='🛠️ 5. Running custom scripts...' + ['🎉 启动成功!']='🎉 Started successfully!' + ['\n## 执行结束... %s 耗时 %s 秒']='\n## Execution finished... %s elapsed %s seconds' ) diff --git a/shell/otask.sh b/shell/otask.sh index b729fb21..97ee3708 100755 --- a/shell/otask.sh +++ b/shell/otask.sh @@ -45,7 +45,7 @@ gen_array_scripts() { let i++ array_scripts[i]=$file array_scripts_name[i]=$(grep -- "new Env" "$file" | awk -F '[(]' '{print $2}' | awk -F '[)]' '{print $1}' | sed 's:.*\('\''\|"\)\([^"'\'']*\)\('\''\|"\).*:\2:' | sed 's:"::g' | sed "s:'::g" | head -1) - [[ -z ${array_scripts_name[i]} ]] && array_scripts_name[i]="<未识别出活动名称>" + [[ -z ${array_scripts_name[i]} ]] && array_scripts_name[i]="$(t '<未识别出活动名称>')" fi done cd "$dir_current" || return $? @@ -54,18 +54,18 @@ gen_array_scripts() { ## 使用说明 usage() { gen_array_scripts - echo -e "task命令运行本程序自动添加进crontab的脚本,需要输入脚本的绝对路径或去掉 “$dir_scripts/” 目录后的相对路径(定时任务中请写作相对路径),用法为:" - echo -e "1.$cmd_task # 依次执行,如果设置了随机延迟,将随机延迟一定秒数" - echo -e "2.$cmd_task now # 依次执行,无论是否设置了随机延迟,均立即运行,前台会输出日志,同时记录在日志文件中" - echo -e "3.$cmd_task conc <环境变量名称> <账号编号,空格分隔>(可选的) # 并发执行,无论是否设置了随机延迟,均立即运行,前台不产生日志,直接记录在日志文件中,且可指定账号执行" - echo -e "4.$cmd_task desi <环境变量名称> <账号编号,空格分隔> # 指定账号执行,无论是否设置了随机延迟,均立即运行" + t 'task命令运行本程序自动添加进crontab的脚本,需要输入脚本的绝对路径或去掉 “%s/” 目录后的相对路径(定时任务中请写作相对路径),用法为:' "$dir_scripts" + t '1.%s # 依次执行,如果设置了随机延迟,将随机延迟一定秒数' "$cmd_task" + t '2.%s now # 依次执行,无论是否设置了随机延迟,均立即运行,前台会输出日志,同时记录在日志文件中' "$cmd_task" + t '3.%s conc <环境变量名称> <账号编号,空格分隔>(可选的) # 并发执行,无论是否设置了随机延迟,均立即运行,前台不产生日志,直接记录在日志文件中,且可指定账号执行' "$cmd_task" + t '4.%s desi <环境变量名称> <账号编号,空格分隔> # 指定账号执行,无论是否设置了随机延迟,均立即运行' "$cmd_task" if [[ ${#array_scripts[*]} -gt 0 ]]; then - echo -e "\n当前有以下脚本可以运行:" + t '\n当前有以下脚本可以运行:' for ((i = 0; i < ${#array_scripts[*]}; i++)); do echo -e "$(($i + 1)). ${array_scripts_name[i]}:${array_scripts[i]}" done else - echo -e "\n暂无脚本可以执行" + t '\n暂无脚本可以执行' fi } diff --git a/shell/preload/sitecustomize.js b/shell/preload/sitecustomize.js index b8d5bcbe..80ef9daa 100644 --- a/shell/preload/sitecustomize.js +++ b/shell/preload/sitecustomize.js @@ -82,7 +82,7 @@ function run() { ].filter(Boolean); if (task_before) { - console.log('执行前置命令\n'); + console.log(process.env.QL_LANG === 'en' ? 'Executing before command\n' : '执行前置命令\n'); } const res = execSync(commands.join(' && '), { @@ -122,7 +122,7 @@ function run() { console.log(output); } if (task_before) { - console.log('执行前置命令结束\n'); + console.log(process.env.QL_LANG === 'en' ? 'Before command finished\n' : '执行前置命令结束\n'); } } catch (error) { if (!error.message.includes('spawnSync /bin/bash E2BIG')) { @@ -131,7 +131,7 @@ function run() { // environment variable is too large } if (task_before) { - console.log('执行前置命令结束\n'); + console.log(process.env.QL_LANG === 'en' ? 'Before command finished\n' : '执行前置命令结束\n'); } } diff --git a/shell/preload/sitecustomize.py b/shell/preload/sitecustomize.py index f0d94488..2aca7b2c 100644 --- a/shell/preload/sitecustomize.py +++ b/shell/preload/sitecustomize.py @@ -56,7 +56,7 @@ def run(): if task_before: escaped_task_before = task_before.replace('"', '\\"').replace("$", "\\$") commands.append(f"eval '{escaped_task_before}'") - print("执行前置命令\n") + print("Executing before command\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令\n") commands.append(f"echo -e '{split_str}'") @@ -88,12 +88,12 @@ def run(): if len(output) > 0: print(output) if task_before: - print("执行前置命令结束\n") + print("Before command finished\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\n") except subprocess.CalledProcessError as error: print(f"\ue926 run task before error: {error}") if task_before: - print("执行前置命令结束\n") + print("Before command finished\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\n") except OSError as error: error_message = str(error) if "Argument list too long" not in error_message: @@ -101,11 +101,11 @@ def run(): # else: # environment variable is too large if task_before: - print("执行前置命令结束\n") + print("Before command finished\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\n") except Exception as error: print(f"\ue926 run task before error: {error}") if task_before: - print("执行前置命令结束\n") + print("Before command finished\n" if os.environ.get("QL_LANG") == "en" else "执行前置命令结束\n") import task_before diff --git a/shell/share.sh b/shell/share.sh index eb49502f..4a4f2379 100755 --- a/shell/share.sh +++ b/shell/share.sh @@ -395,7 +395,7 @@ handle_task_start() { if [[ $ID ]]; then local error=$(update_cron "\"$ID\"" "0" "$$" "$log_path" "$begin_timestamp") if [[ $error ]]; then - error_message=", 任务状态更新失败(${error})" + error_message=$(t ', 任务状态更新失败(%s)' "$error") fi fi t '## 开始执行... %s\n' "${begin_time}${error_message}" @@ -437,7 +437,7 @@ handle_task_end() { if [[ $ID ]]; then local error=$(update_cron "\"$ID\"" "1" "$$" "$log_path" "$begin_timestamp" "$diff_time" "$exit_code") if [[ $error ]]; then - error_message=", 状态更新失败(${error})" + error_message=$(t ', 状态更新失败(%s)' "$error") fi fi diff --git a/shell/start-simplify.sh b/shell/start-simplify.sh index 36e4ceaf..de68281b 100644 --- a/shell/start-simplify.sh +++ b/shell/start-simplify.sh @@ -46,28 +46,28 @@ log_with_style() { printf "\n[%s] [%7s] %s\n" "${timestamp}" "${level}" "${message}" } -log_with_style "INFO" "🚀 1. 检测配置文件..." import_config "$@" +log_with_style "INFO" "$(t '🚀 1. 检测配置文件...')" make_dir /etc/nginx/conf.d make_dir /run/nginx fix_config pm2 l &>/dev/null -log_with_style "INFO" "🔄 2. 启动 nginx..." +log_with_style "INFO" "$(t '🔄 2. 启动 nginx...')" nginx -s reload 2>/dev/null || nginx -c /etc/nginx/nginx.conf -log_with_style "INFO" "⚙️ 3. 启动 pm2 服务..." +log_with_style "INFO" "$(t '⚙️ 3. 启动 pm2 服务...')" reload_pm2 if [[ $command != "reload" ]]; then if [[ $AutoStartBot == true ]]; then - log_with_style "INFO" "🤖 4. 启动 bot..." + log_with_style "INFO" "$(t '🤖 4. 启动 bot...')" nohup ql bot >"$dir_log/bot.log" 2>&1 & fi if [[ $EnableExtraShell == true ]]; then - log_with_style "INFO" "🛠️ 5. 执行自定义脚本..." + log_with_style "INFO" "$(t '🛠️ 5. 执行自定义脚本...')" nohup ql extra >"$dir_log/extra.log" 2>&1 & fi @@ -75,4 +75,4 @@ if [[ $command != "reload" ]]; then pm2 save fi -log_with_style "SUCCESS" "🎉 启动成功!" +log_with_style "SUCCESS" "$(t '🎉 启动成功!')" diff --git a/shell/start.sh b/shell/start.sh index 3da0734d..c6940339 100644 --- a/shell/start.sh +++ b/shell/start.sh @@ -8,6 +8,16 @@ set -x startup_message() { local format="$1" shift + if [[ ${QL_LANG:-zh} == en ]]; then + case "$format" in + '未找到 qinglong 模块,请先执行 npm i -g @whyour/qinglong 安装') format='Module not found. Run: npm i -g @whyour/qinglong' ;; + '请先手动设置 export QL_DIR=%s,环境变量,并手动添加到系统环境变量,然后再次执行命令 qinglong 启动服务') format='Set export QL_DIR=%s in your system environment, then run qinglong to start the service' ;; + 'QL_DIR 必须为绝对路径,例如 /ql') format='QL_DIR must be an absolute path, e.g. /ql' ;; + '请先手动设置数据存储目录 export QL_DATA_DIR 环境变量,目录必须以斜杠开头的绝对路径,并且以 /data 结尾,例如 /ql/data 并手动添加到系统环境变量') format='Set QL_DATA_DIR to an absolute path ending in /data, e.g. /ql/data, in your system environment' ;; + 'QL_DATA_DIR 必须为以 /data 结尾的绝对路径,例如 /ql/data,如果有历史数据,请新建 data 目录,把历史数据放到 data 目录中') format='QL_DATA_DIR must be an absolute path ending in /data, e.g. /ql/data. Move existing data into that directory' ;; + '暂不支持此系统部署 %s') format='Unsupported system for deployment: %s' ;; + esac + fi printf -- "$format\n" "$@" >&2 } diff --git a/shell/update.sh b/shell/update.sh index 0324d1df..cfd308eb 100755 --- a/shell/update.sh +++ b/shell/update.sh @@ -31,7 +31,7 @@ diff_cron() { ## 输出是否有新的或失效的定时任务,$1:新的或失效的任务清单文件路径,$2:新/失效 output_list_add_drop() { local list=$1 - local type=$2 + local type=$(t "$2") if [[ -s $list ]]; then t '检测到有%s的定时任务:' "$type" cat $list @@ -67,7 +67,7 @@ del_cron() { done if [[ $ids ]]; then result=$(del_cron_api "$ids") - notify_api "$path 删除任务${result}" "$detail" + notify_api "$(t '%s 删除任务%s' "$path" "$result")" "$detail" fi } @@ -110,7 +110,7 @@ add_cron() { fi fi done - notify_api "$path 新增任务" "$detail" + notify_api "$(t '%s 新增任务' "$path")" "$detail" } ## 更新仓库 @@ -199,7 +199,7 @@ update_raw() { cron_line=${cron_line//$'\r'/} result=$(add_cron_api "$cron_line" "${cmd_task} ${filename}" "$cron_name" "$SUB_ID") echo -e "$result\n" - notify_api "新增任务通知" "\n$result" + notify_api "$(t '新增任务通知')" "\n$result" # update_cron_api "$cron_line:$cmd_task $filename:$cron_name:$cron_id" fi else @@ -276,19 +276,19 @@ show_service_logs() { ## 脚本用法 usage() { - t "$cmd_update 命令使用方法:" - echo -e "1. $cmd_update update # 更新并重启青龙" - echo -e "2. $cmd_update extra # 运行自定义脚本" - echo -e "3. $cmd_update raw # 更新单个脚本文件" - echo -e "4. $cmd_update repo # 更新单个仓库的脚本" - echo -e "5. $cmd_update rmlog # 删除旧日志" - echo -e "6. $cmd_update bot # 启动tg-bot" - echo -e "7. $cmd_update check # 检测青龙环境并修复" - echo -e "8. $cmd_update log [--lines ] [--nostream] # 查看青龙运行日志" - echo -e "9. $cmd_update resetlet # 重置登录错误次数" - echo -e "10. $cmd_update resettfa # 禁用两步登录" - echo -e "11. $cmd_update resetpwd # 修改登录密码" - echo -e "12. $cmd_update resetname # 修改登录用户名" + t '%s 命令使用方法:' "$cmd_update" + echo -e "1. $cmd_update update # $(t '更新并重启青龙')" + echo -e "2. $cmd_update extra # $(t '运行自定义脚本')" + echo -e "3. $cmd_update raw # $(t '更新单个脚本文件')" + echo -e "4. $cmd_update repo # $(t '更新单个仓库的脚本')" + echo -e "5. $cmd_update rmlog # $(t '删除旧日志')" + echo -e "6. $cmd_update bot # $(t '启动tg-bot')" + echo -e "7. $cmd_update check # $(t '检测青龙环境并修复')" + echo -e "8. $cmd_update log [--lines ] [--nostream] # $(t '查看青龙运行日志')" + echo -e "9. $cmd_update resetlet # $(t '重置登录错误次数')" + echo -e "10. $cmd_update resettfa # $(t '禁用两步登录')" + echo -e "11. $cmd_update resetpwd # $(t '修改登录密码')" + echo -e "12. $cmd_update resetname # $(t '修改登录用户名')" } reload_qinglong() { @@ -504,7 +504,9 @@ main() { local begin_time=$(format_time "$time_format" "$time") if [[ "$p1" != "repo" ]] && [[ "$p1" != "raw" ]]; then - eval echo -e "\#\# 开始执行... $begin_time\\\n" $cmd + local start_message + printf -v start_message '%q' "$(t '## 开始执行... %s\n' "$begin_time")" + eval "printf '%s\n' $start_message $cmd" fi [[ $ID ]] && update_cron "\"$ID\"" "0" "$$" "$log_path" "$begin_timestamp" @@ -577,7 +579,9 @@ main() { [[ $ID ]] && update_cron "\"$ID\"" "1" "$$" "$log_path" "$begin_timestamp" "$diff_time" if [[ "$p1" != "repo" ]] && [[ "$p1" != "raw" ]]; then - eval echo -e "\\\n\#\# 执行结束... $end_time 耗时 $diff_time 秒     " $cmd + local end_message + printf -v end_message '%q' "$(t '\n## 执行结束... %s 耗时 %s 秒' "$end_time" "$diff_time")     " + eval "printf '%s\n' $end_message $cmd" fi return "$command_status" } diff --git a/shell/upgrade.cjs b/shell/upgrade.cjs index 811e8583..3ede09ad 100644 --- a/shell/upgrade.cjs +++ b/shell/upgrade.cjs @@ -3,6 +3,6 @@ const upgrade = require('../cli/src/internal/maintenance/upgradeArtifacts.cjs'); module.exports = upgrade; if (require.main === module) upgrade.main().catch((error) => { - console.error(`更新失败: ${error.message}`); + console.error(upgrade.upgradeMessage('更新失败: %s', process.env, error.message)); process.exitCode = 1; }); diff --git a/src/layouts/index.tsx b/src/layouts/index.tsx index 7c94f91a..25ebe9ed 100644 --- a/src/layouts/index.tsx +++ b/src/layouts/index.tsx @@ -48,7 +48,8 @@ export default function () { const [loading, setLoading] = useState(true); const [systemInfo, setSystemInfo] = useState(); const [siteTitle, setSiteTitle] = useState( - () => localStorage.getItem('qinglong_panel_title')?.trim() || intl.get('青龙'), + () => + localStorage.getItem('qinglong_panel_title')?.trim() || intl.get('青龙'), ); const [collapsed, setCollapsed] = useState(false); const [initLoading, setInitLoading] = useState(true); @@ -221,15 +222,25 @@ export default function () { useEffect(() => { window.onload = () => { const timing = performance.timing; - console.log(`白屏时间: ${timing.responseStart - timing.navigationStart}`); console.log( - `请求完毕至DOM加载: ${timing.domInteractive - timing.responseEnd}`, + intl.get('白屏时间', { + duration: timing.responseStart - timing.navigationStart, + }), ); console.log( - `解释dom树耗时: ${timing.domComplete - timing.domInteractive}`, + intl.get('请求完毕至DOM加载', { + duration: timing.domInteractive - timing.responseEnd, + }), ); console.log( - `从开始至load总耗时: ${timing.loadEventEnd - timing.navigationStart}`, + intl.get('解释DOM树耗时', { + duration: timing.domComplete - timing.domInteractive, + }), + ); + console.log( + intl.get('从开始至load总耗时', { + duration: timing.loadEventEnd - timing.navigationStart, + }), ); }; }, []); diff --git a/src/locales/en-US.json b/src/locales/en-US.json index b00789fe..63fd3b86 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -1,4 +1,19 @@ { + "选中条数": "Selected {count} items", + "登录失败次数": "Failed {count} time(s)", + "间隔定时规则": "Every {value} {unit}", + "SOCK5代理,例如 IP:PORT": "SOCKS5 proxy, e.g. IP:PORT", + "HTTP/SOCK5代理,例如 http://127.0.0.1:1080": "HTTP/SOCKS5 proxy, e.g. http://127.0.0.1:1080", + "确认操作对象": "Confirm {operation} {object}", + "确认批量操作": "Confirm {operation} selected {object}?", + "检查更新失败,请检查网络后重试": "Failed to check for updates. Check your network and try again.", + "确认操作": "Confirm {operation}", + "分页总数": "{start}-{end} of {total} items", + "总条数": "{total} items", + "内网IP": "Private IP", + "容器更新修复命令": "1. Run on the host: docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower -cR ", + "WxPusher(极简推送SPT-推荐)": "WxPusher (Simple Push SPT, recommended)", + "新版本可用,当前版本": "Version {version} is available. You are using {current}.", "1. 宿主机执行 docker run --rm -v\n /var/run/docker.sock:/var/run/docker.sock\n containrrr/watchtower -cR <容器名>": "1. Execute 'docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower -cR ' on the host machine", "2. 容器内执行 ql check、ql update": "2. Execute 'ql check' and 'ql update' inside the container", "24小时未运行": "Idle 24h+", @@ -709,5 +724,12 @@ "脚本通知": "Script notification", "系统通知": "System notification", "Python支持多种安装方式:\n1. 包名(如:requests)\n2. GitHub链接(如:git+https://github.com/user/repo.git)\n3. requirements文件路径(如:path/to/requirements.txt)\n4. pyproject.toml文件路径": "Python supports package names, Git URLs, and existing requirements.txt or pyproject.toml files under the scripts or repository directory.", - "支持包名、GitHub链接、requirements.txt或pyproject.toml路径": "Package name, Git URL, requirements.txt or pyproject.toml path" + "支持包名、GitHub链接、requirements.txt或pyproject.toml路径": "Package name, Git URL, requirements.txt or pyproject.toml path", + "控制台诗句": "A rain hat and cloak, a song among the hills. The moon, the breeze and I spend our idle days together.", + "运行环境信息": "QingLong runtime:\n\nSystem: {system}\nBrowser: {browser}\nEngine: {engine}", + "WebSocket错误": "WebSocket error:", + "白屏时间": "Time to first response: {duration} ms", + "请求完毕至DOM加载": "Response end to DOM interactive: {duration} ms", + "解释DOM树耗时": "DOM completion time: {duration} ms", + "从开始至load总耗时": "Total load time: {duration} ms" } diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 69e4bcb5..9863ec57 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -1,4 +1,19 @@ { + "选中条数": "已选择 {count} 项", + "登录失败次数": "失败{count}次", + "间隔定时规则": "每{value}{unit}", + "SOCK5代理,例如 IP:PORT": "SOCK5代理,例如 IP:PORT", + "HTTP/SOCK5代理,例如 http://127.0.0.1:1080": "HTTP/SOCK5代理,例如 http://127.0.0.1:1080", + "确认操作对象": "确认{operation}{object}", + "确认批量操作": "确认{operation}选中的{object}吗", + "检查更新失败,请检查网络后重试": "检查更新失败,请检查网络后重试", + "确认操作": "确认{operation}", + "分页总数": "第 {start}-{end} 条/总共 {total} 条", + "总条数": "共 {total} 个", + "内网IP": "内网IP", + "容器更新修复命令": "1. 宿主机执行 docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower -cR <容器名>", + "WxPusher(极简推送SPT-推荐)": "WxPusher(极简推送SPT-推荐)", + "新版本可用,当前版本": "新版本 {version} 可用,你使用的版本为 {current}。", "1. 宿主机执行 docker run --rm -v\n /var/run/docker.sock:/var/run/docker.sock\n containrrr/watchtower -cR <容器名>": "1. 宿主机执行 docker run --rm -v\n /var/run/docker.sock:/var/run/docker.sock\n containrrr/watchtower -cR <容器名>", "2. 容器内执行 ql check、ql update": "2. 容器内执行 ql check、ql update", "24小时未运行": "24小时未运行", @@ -709,5 +724,12 @@ "脚本通知": "脚本通知", "系统通知": "系统通知", "Python支持多种安装方式:\n1. 包名(如:requests)\n2. GitHub链接(如:git+https://github.com/user/repo.git)\n3. requirements文件路径(如:path/to/requirements.txt)\n4. pyproject.toml文件路径": "Python支持多种安装方式:\n1. 包名(如:requests)\n2. GitHub链接(如:git+https://github.com/user/repo.git)\n3. requirements文件路径(如:path/to/requirements.txt)\n4. pyproject.toml文件路径", - "支持包名、GitHub链接、requirements.txt或pyproject.toml路径": "支持包名、GitHub链接、requirements.txt或pyproject.toml路径" + "支持包名、GitHub链接、requirements.txt或pyproject.toml路径": "支持包名、GitHub链接、requirements.txt或pyproject.toml路径", + "控制台诗句": "忘形雨笠烟蓑,知心牧唱樵歌。明月清风共我,闲人三个,从他今古消磨。", + "运行环境信息": "青龙运行环境:\n\n系统:{system}\n浏览器:{browser}\n内核:{engine}", + "WebSocket错误": "WebSocket错误:", + "白屏时间": "白屏时间: {duration} ms", + "请求完毕至DOM加载": "请求完毕至DOM加载: {duration} ms", + "解释DOM树耗时": "解释 DOM 树耗时: {duration} ms", + "从开始至load总耗时": "从开始至 load 总耗时: {duration} ms" } diff --git a/src/pages/crontab/detail.tsx b/src/pages/crontab/detail.tsx index 1baf32ef..0acd265a 100644 --- a/src/pages/crontab/detail.tsx +++ b/src/pages/crontab/detail.tsx @@ -364,13 +364,10 @@ const CronDetailModal = ({ const enabledOrDisabledCron = () => { Modal.confirm({ - title: `确认${currentCron.isDisabled === 1 ? intl.get('启用') : intl.get('禁用') - }`, + title: intl.get('确认操作', { operation: currentCron.isDisabled === 1 ? intl.get('启用') : intl.get('禁用') }), content: ( <> - {intl.get('确认')} - {currentCron.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')} - {intl.get('定时任务')}{' '} + {intl.get('确认操作对象', { operation: currentCron.isDisabled === 1 ? intl.get('启用') : intl.get('禁用'), object: intl.get('定时任务') })}{' '} {currentCron.name} {' '} @@ -398,13 +395,10 @@ const CronDetailModal = ({ const pinOrUnPinCron = () => { Modal.confirm({ - title: `确认${currentCron.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶') - }`, + title: intl.get('确认操作', { operation: currentCron.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶') }), content: ( <> - {intl.get('确认')} - {currentCron.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')} - {intl.get('定时任务')}{' '} + {intl.get('确认操作对象', { operation: currentCron.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶'), object: intl.get('定时任务') })}{' '} {currentCron.name} {' '} diff --git a/src/pages/crontab/index.tsx b/src/pages/crontab/index.tsx index 2c933492..8e6ef316 100644 --- a/src/pages/crontab/index.tsx +++ b/src/pages/crontab/index.tsx @@ -544,13 +544,13 @@ const Crontab = () => { const enabledOrDisabledCron = (record: any, index: number) => { Modal.confirm({ - title: `确认${record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用') - }`, + title: intl.get('确认操作', { + operation: + record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用'), + }), content: ( <> - {intl.get('确认')} - {record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')} - {intl.get('定时任务')}{' '} + {intl.get('确认操作对象', { operation: record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用'), object: intl.get('定时任务') })}{' '} {record.name} {' '} @@ -584,13 +584,13 @@ const Crontab = () => { const pinOrUnPinCron = (record: any, index: number) => { Modal.confirm({ - title: `确认${record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶') - }`, + title: intl.get('确认操作', { + operation: + record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶'), + }), content: ( <> - {intl.get('确认')} - {record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')} - {intl.get('定时任务')}{' '} + {intl.get('确认操作对象', { operation: record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶'), object: intl.get('定时任务') })}{' '} {record.name} {' '} @@ -745,12 +745,12 @@ const Crontab = () => { const operateCrons = (operationStatus: number) => { Modal.confirm({ - title: `确认${OperationName[operationStatus]}`, + title: intl.get('确认操作', { + operation: intl.get(OperationName[operationStatus]), + }), content: ( <> - {intl.get('确认')} - {OperationName[operationStatus]} - {intl.get('选中的定时任务吗')} + {intl.get('确认批量操作', { operation: intl.get(OperationName[operationStatus]), object: intl.get('定时任务') })} ), onOk() { @@ -1035,9 +1035,7 @@ const Crontab = () => { {intl.get('批量修改标签')} - {intl.get('已选择')} - {selectedRowIds?.length} - {intl.get('项')} + {intl.get('选中条数', { count: selectedRowIds.length })} )} @@ -1051,7 +1049,7 @@ const Crontab = () => { simple: isPhone, total, showTotal: (total: number, range: number[]) => - `第 ${range[0]}-${range[1]} 条/总共 ${total} 条`, + intl.get('分页总数', { start: range[0], end: range[1], total }), pageSizeOptions: [10, 20, 50, 100, 200, 500, total || 10000].sort( (a, b) => a - b, ), diff --git a/src/pages/dashboard/index.tsx b/src/pages/dashboard/index.tsx index c7b1229e..5abcd4ce 100644 --- a/src/pages/dashboard/index.tsx +++ b/src/pages/dashboard/index.tsx @@ -167,7 +167,7 @@ const Dashboard = () => { const runtimePagination = { pageSize: 5, showSizeChanger: false, - showTotal: (total: number) => `共 ${total} 个`, + showTotal: (total: number) => intl.get('总条数', { total }), }; if (loading) return ; @@ -348,7 +348,7 @@ const Dashboard = () => { data={{ target: Number(system.memUsagePercent), total: 100, - name: '内存使用率', + name: intl.get('内存使用'), }} style={{ textY: '70%', diff --git a/src/pages/dependence/index.tsx b/src/pages/dependence/index.tsx index 1538af4d..b037d866 100644 --- a/src/pages/dependence/index.tsx +++ b/src/pages/dependence/index.tsx @@ -536,9 +536,7 @@ const Dependence = () => { {intl.get('批量强制删除')} - {intl.get('已选择')} - {selectedRowIds?.length} - {intl.get('项')} + {intl.get('选中条数', { count: selectedRowIds.length })} )} diff --git a/src/pages/env/index.tsx b/src/pages/env/index.tsx index 7659abc1..8968786c 100644 --- a/src/pages/env/index.tsx +++ b/src/pages/env/index.tsx @@ -279,16 +279,13 @@ const Env = () => { const enabledOrDisabledEnv = (record: any, index: number) => { Modal.confirm({ - title: `确认${ - record.status === Status.已禁用 ? intl.get('启用') : intl.get('禁用') - }`, + title: intl.get('确认操作', { + operation: + record.status === Status.已禁用 ? intl.get('启用') : intl.get('禁用'), + }), content: ( <> - {intl.get('确认')} - {record.status === Status.已禁用 - ? intl.get('启用') - : intl.get('禁用')} - Env{' '} + {intl.get('确认操作对象', { operation: record.status === Status.已禁用 ? intl.get('启用') : intl.get('禁用'), object: 'Env' })}{' '} { const pinOrUnpinEnv = (record: any, index: number) => { Modal.confirm({ - title: `确认${ - record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶') - }`, + title: intl.get('确认操作', { + operation: + record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶'), + }), content: ( <> - {intl.get('确认')} - {record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')} - Env{' '} + {intl.get('确认操作对象', { operation: record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶'), object: 'Env' })}{' '} { const operateEnvs = (operationStatus: number) => { Modal.confirm({ - title: `确认${OperationName[operationStatus]}`, + title: intl.get('确认操作', { + operation: intl.get(OperationName[operationStatus]), + }), content: ( <> - {intl.get('确认')} - {OperationName[operationStatus]} - {intl.get('选中的变量吗')} + {intl.get('确认批量操作', { operation: intl.get(OperationName[operationStatus]), object: intl.get('环境变量') })} ), onOk() { @@ -603,7 +599,9 @@ const Env = () => { ); if (code === 200) { - message.success(`${intl.get('成功上传')}${data.length}${intl.get('个环境变量')}`); + message.success( + `${intl.get('成功上传')}${data.length}${intl.get('个环境变量')}`, + ); getEnvs(); } setImportLoading(false); @@ -708,9 +706,7 @@ const Env = () => { {intl.get('批量取消置顶')} - {intl.get('已选择')} - {selectedRowIds?.length} - {intl.get('项')} + {intl.get('选中条数', { count: selectedRowIds.length })} )} diff --git a/src/pages/error/index.tsx b/src/pages/error/index.tsx index 6389ea89..0dfcd564 100644 --- a/src/pages/error/index.tsx +++ b/src/pages/error/index.tsx @@ -79,16 +79,10 @@ const Error = () => { description={
{intl.get('请先按如下方式修复:')}
-
- 1. 宿主机执行 docker run --rm -v - /var/run/docker.sock:/var/run/docker.sock - containrrr/watchtower -cR <容器名> -
+
{intl.get('容器更新修复命令')}
{intl.get('2. 容器内执行 ql check、ql update')}
- {intl.get( - '3. 如果无法解决,容器内执行 ql log,拷贝执行结果', - )} + {intl.get('3. 如果无法解决,容器内执行 ql log,拷贝执行结果')} {intl.get('提交 issue')} diff --git a/src/pages/initialization/index.tsx b/src/pages/initialization/index.tsx index e563249c..1fd9e64d 100644 --- a/src/pages/initialization/index.tsx +++ b/src/pages/initialization/index.tsx @@ -129,7 +129,7 @@ const Initialization = () => { > ))} diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx index 8b0a0d50..24ab46a8 100644 --- a/src/pages/login/index.tsx +++ b/src/pages/login/index.tsx @@ -91,7 +91,7 @@ const Login = () => {
{intl.get('上次登录地点:')} - {lastaddr || '-'} + {lastaddr === '内网IP' ? intl.get('内网IP') : lastaddr || '-'}
{intl.get('上次登录IP:')} @@ -103,7 +103,7 @@ const Login = () => {
{intl.get('上次登录状态:')} - {retries > 0 ? `失败${retries}次` : intl.get('成功')} + {retries > 0 ? intl.get('登录失败次数', { count: retries }) : intl.get('成功')}
), diff --git a/src/pages/setting/checkUpdate.tsx b/src/pages/setting/checkUpdate.tsx index 228148e7..47e48aa4 100644 --- a/src/pages/setting/checkUpdate.tsx +++ b/src/pages/setting/checkUpdate.tsx @@ -1,24 +1,25 @@ -import { disableBody } from "@/utils"; -import config from "@/utils/config"; -import { request } from "@/utils/http"; -import WebSocketManager from "@/utils/websocket"; -import Ansi from "ansi-to-react"; -import { Button, Modal, Statistic, message } from "antd"; -import { useCallback, useEffect, useRef, useState } from "react"; -import intl from "react-intl-universal"; +import { disableBody } from '@/utils'; +import config from '@/utils/config'; +import { request } from '@/utils/http'; +import WebSocketManager from '@/utils/websocket'; +import Ansi from 'ansi-to-react'; +import { Button, Modal, Statistic, message } from 'antd'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import intl from 'react-intl-universal'; const { Countdown } = Statistic; const CheckUpdate = ({ systemInfo }: any) => { const [updateLoading, setUpdateLoading] = useState(false); - const [value, setValue] = useState(""); + const [value, setValue] = useState(''); const modalRef = useRef(); const lastStatusRef = useRef(); + const [updateStatus, setUpdateStatus] = useState(); const checkUpgrade = () => { - if (updateLoading) return; + if (updateLoading || modalRef.current) return; setUpdateLoading(true); - message.loading(intl.get("检查更新中..."), 0); + message.loading(intl.get('检查更新中...'), 0); request .put(`${config.apiPrefix}system/update-check`) .then(({ code, data }) => { @@ -33,7 +34,7 @@ const CheckUpdate = ({ systemInfo }: any) => { }) .catch((error: any) => { message.destroy(); - console.log(error); + message.error(intl.get('检查更新失败,请检查网络后重试')); }) .finally(() => { setUpdateLoading(false); @@ -43,25 +44,19 @@ const CheckUpdate = ({ systemInfo }: any) => { const showForceUpdateModal = (data: any) => { Modal.confirm({ width: 500, - title: intl.get("更新"), + title: intl.get('更新'), content: ( <> -
{intl.get("已经是最新版了!")}
+
{intl.get('已经是最新版了!')}
- {intl.get("青龙")} {data.lastVersion}{" "} - {intl.get("是目前检测到的最新可用版本了。")} + {intl.get('青龙')} {data.lastVersion}{' '} + {intl.get('是目前检测到的最新可用版本了。')}
), - okText: intl.get("重新下载"), + okText: intl.get('重新下载'), onOk() { - showUpdatingModal(); - request - .put(`${config.apiPrefix}system/update`) - .then((_data: any) => { }) - .catch((error: any) => { - console.log(error); - }); + startUpdate(); }, }); }; @@ -72,10 +67,12 @@ const CheckUpdate = ({ systemInfo }: any) => { width: 500, title: ( <> -
{intl.get("更新可用")}
+
{intl.get('更新可用')}
- {intl.get("新版本")} {lastVersion}{" "} - {intl.get("可用,你使用的版本为")} {systemInfo.version}。 + {intl.get('新版本可用,当前版本', { + version: lastVersion, + current: systemInfo.version, + })}
), @@ -84,52 +81,60 @@ const CheckUpdate = ({ systemInfo }: any) => { {lastLog} ), - okText: intl.get("下载更新"), - cancelText: intl.get("以后再说"), + okText: intl.get('下载更新'), + cancelText: intl.get('以后再说'), onOk() { - showUpdatingModal(); - request - .put(`${config.apiPrefix}system/update`) - .then((_data: any) => { }) - .catch((error: any) => { - console.log(error); - }); + startUpdate(); }, }); }; const showUpdatingModal = () => { - setValue(""); + setValue(''); + lastStatusRef.current = 'running'; + setUpdateStatus('running'); modalRef.current = Modal.info({ width: 600, maskClosable: false, closable: false, keyboard: false, okButtonProps: { disabled: true }, - title: intl.get("下载更新中..."), + title: intl.get('下载更新中...'), centered: true, - content: ( -
-          {value}
-        
- ), + afterClose: () => { + modalRef.current = undefined; + }, + content:
,
     });
   };
 
+  const startUpdate = async () => {
+    showUpdatingModal();
+    try {
+      const response = await request.put(`${config.apiPrefix}system/update`);
+      if (response.code !== 200) throw new Error(response.message);
+    } catch {
+      lastStatusRef.current = 'failed';
+      setUpdateStatus('failed');
+      message.error(intl.get('更新失败,请检查网络及日志或稍后再试'));
+    }
+  };
+
   const reloadSystem = (type?: string) => {
     request
       .put(`${config.apiPrefix}update/${type}`)
-      .then((_data: any) => {
+      .then(({ code }: any) => {
+        if (code !== 200) return;
         message.success({
           content: (
             
-              {intl.get("系统将在")}
+              {intl.get('系统将在')}
               
-              {intl.get("秒后自动刷新")}
+              {intl.get('秒后自动刷新')}
             
           ),
           duration: 30,
@@ -148,15 +153,15 @@ const CheckUpdate = ({ systemInfo }: any) => {
     Modal.confirm({
       width: 600,
       maskClosable: false,
-      title: intl.get("确认重启"),
+      title: intl.get('确认重启'),
       centered: true,
-      content: intl.get("系统安装包下载成功,确认重启"),
-      okText: intl.get("重启"),
+      content: intl.get('系统安装包下载成功,确认重启'),
+      okText: intl.get('重启'),
       onOk() {
-        reloadSystem("system");
+        reloadSystem('system');
       },
       onCancel() {
-        modalRef.current.update({
+        modalRef.current?.update({
           maskClosable: true,
           closable: true,
           okButtonProps: { disabled: false },
@@ -166,8 +171,8 @@ const CheckUpdate = ({ systemInfo }: any) => {
   };
 
   useEffect(() => {
-    if (!value) return;
-    const updateFailed = lastStatusRef.current === 'failed';
+    if (!modalRef.current) return;
+    const updateFailed = updateStatus === 'failed';
 
     modalRef.current.update({
       maskClosable: updateFailed,
@@ -182,27 +187,32 @@ const CheckUpdate = ({ systemInfo }: any) => {
         
       ),
     });
-  }, [value]);
+  }, [value, updateStatus]);
 
   const handleMessage = useCallback((payload: any) => {
+    if (!modalRef.current || lastStatusRef.current !== 'running') return;
     const { message: _message, status } = payload;
 
     if (status === 'failed') {
       lastStatusRef.current = 'failed';
-      message.error(intl.get("更新失败,请检查网络及日志或稍后再试"));
+      setUpdateStatus('failed');
+      message.error(intl.get('更新失败,请检查网络及日志或稍后再试'));
     }
 
     if (status === 'success') {
       lastStatusRef.current = 'success';
+      setUpdateStatus('success');
       setTimeout(() => {
-        showReloadModal();
+        if (modalRef.current && lastStatusRef.current === 'success') {
+          showReloadModal();
+        }
       }, 1000);
     }
 
     setTimeout(() => {
       document
-        .querySelector("#log-identifier")
-        ?.scrollIntoView({ behavior: "smooth" });
+        .querySelector('#log-identifier')
+        ?.scrollIntoView({ behavior: 'smooth' });
     }, 600);
 
     if (_message) {
@@ -212,24 +222,26 @@ const CheckUpdate = ({ systemInfo }: any) => {
 
   useEffect(() => {
     const ws = WebSocketManager.getInstance();
-    ws.subscribe("updateSystemVersion", handleMessage);
+    ws.subscribe('updateSystemVersion', handleMessage);
 
     return () => {
-      ws.unsubscribe("updateSystemVersion", handleMessage);
+      ws.unsubscribe('updateSystemVersion', handleMessage);
+      modalRef.current?.destroy();
+      modalRef.current = undefined;
     };
   }, []);
 
   return (
     <>
       
       
     
   );
diff --git a/src/pages/setting/index.less b/src/pages/setting/index.less
index f25d9ce3..600f345e 100644
--- a/src/pages/setting/index.less
+++ b/src/pages/setting/index.less
@@ -33,6 +33,11 @@
   gap: 40px;
 }
 
+.notify-log-table .ant-table-thead > tr > th,
+.login-log-table .ant-table-thead > tr > th {
+  white-space: nowrap;
+}
+
 .ql-container-wrapper.ql-setting-container {
   .ant-tabs-tabpane > div {
     padding-left: 2px;
diff --git a/src/pages/setting/loginLog.tsx b/src/pages/setting/loginLog.tsx
index b6534938..41834951 100644
--- a/src/pages/setting/loginLog.tsx
+++ b/src/pages/setting/loginLog.tsx
@@ -47,7 +47,7 @@ const LoginLog = ({ data, height }: { data: Array; height: number }) => {
   const columns = [
     {
       title: intl.get('序号'),
-      width: 50,
+      width: 90,
       render: (text: string, record: any, index: number) => index + 1,
     },
     {
@@ -63,6 +63,7 @@ const LoginLog = ({ data, height }: { data: Array; height: number }) => {
       dataIndex: 'address',
       width: 120,
       key: 'address',
+      render: (text: string) => (text === '内网IP' ? intl.get('内网IP') : text),
     },
     {
       title: intl.get('登录IP'),
@@ -74,13 +75,13 @@ const LoginLog = ({ data, height }: { data: Array; height: number }) => {
       title: intl.get('登录设备'),
       dataIndex: 'platform',
       key: 'platform',
-      width: 80,
+      width: 140,
     },
     {
       title: intl.get('登录状态'),
       dataIndex: 'status',
       key: 'status',
-      width: 80,
+      width: 140,
       render: (text: string, record: any) => (
         
           {intl.get(LoginStatus[record.status])}
@@ -116,12 +117,13 @@ const LoginLog = ({ data, height }: { data: Array; height: number }) => {
   return (
     <>
        `${record.ip}-${record.timestamp}`}
         size="middle"
-        scroll={{ x: 1000, y: height }}
+        scroll={{ x: 1120, y: height }}
       />
     
   );
diff --git a/src/pages/setting/notifyLog.tsx b/src/pages/setting/notifyLog.tsx
index 491fe2ba..6c9f0131 100644
--- a/src/pages/setting/notifyLog.tsx
+++ b/src/pages/setting/notifyLog.tsx
@@ -2,6 +2,7 @@ import intl from 'react-intl-universal';
 import React from 'react';
 import { Table, Tag } from 'antd';
 import dayjs from 'dayjs';
+import './index.less';
 
 interface NotifyLogItem {
   id?: number;
@@ -40,7 +41,7 @@ const columns = [
   },
   {
     title: intl.get('序号'),
-    width: 50,
+    width: 90,
     render: (text: string, record: any, index: number) => {
       return index + 1;
     },
@@ -79,7 +80,7 @@ const columns = [
     title: intl.get('发送状态'),
     dataIndex: 'status',
     key: 'status',
-    width: 90,
+    width: 140,
     render: (text: string, record: NotifyLogItem) => {
       const statusKey = record.status ?? 1;
       return (
@@ -101,12 +102,13 @@ const NotifyLog = ({
   return (
     <>
       
); diff --git a/src/pages/subscription/index.tsx b/src/pages/subscription/index.tsx index fdde7c53..7edf40c6 100644 --- a/src/pages/subscription/index.tsx +++ b/src/pages/subscription/index.tsx @@ -103,7 +103,7 @@ const Subscription = () => { key: 'type', width: 130, render: (text: string, record: any) => { - return (SubscriptionType as any)[record.type]; + return intl.get((SubscriptionType as any)[record.type]); }, }, { @@ -121,7 +121,7 @@ const Subscription = () => { render: (text: string, record: any) => { if (record.schedule_type === 'interval') { const { type, value } = record.interval_schedule; - return `每${value}${(IntervalSchedule as any)[type]}`; + return intl.get('间隔定时规则', { value, unit: intl.get((IntervalSchedule as any)[type]) }); } return record.schedule; }, @@ -369,14 +369,13 @@ const Subscription = () => { const enabledOrDisabledSubscription = (record: any, index: number) => { Modal.confirm({ - title: `确认${ - record.is_disabled === 1 ? intl.get('启用') : intl.get('禁用') - }`, + title: intl.get('确认操作', { + operation: + record.is_disabled === 1 ? intl.get('启用') : intl.get('禁用'), + }), content: ( <> - {intl.get('确认')} - {record.is_disabled === 1 ? intl.get('启用') : intl.get('禁用')} - {intl.get('定时订阅')}{' '} + {intl.get('确认操作对象', { operation: record.is_disabled === 1 ? intl.get('启用') : intl.get('禁用'), object: intl.get('定时订阅') })}{' '} {record.name} {' '} @@ -569,7 +568,7 @@ const Subscription = () => { simple: isPhone, defaultPageSize: 20, showTotal: (total: number, range: number[]) => - `第 ${range[0]}-${range[1]} 条/总共 ${total} 条`, + intl.get('分页总数', { start: range[0], end: range[1], total }), pageSizeOptions: [20, 100, 500, 1000] as any, }} dataSource={value} diff --git a/src/pages/subscription/modal.tsx b/src/pages/subscription/modal.tsx index a727e590..cf4d8745 100644 --- a/src/pages/subscription/modal.tsx +++ b/src/pages/subscription/modal.tsx @@ -150,10 +150,10 @@ const SubscriptionModal = ({ precision={0} min={1} value={intervalNumber} - style={{ width: 'calc(100% - 58px)' }} + style={{ width: 'calc(100% - 100px)' }} onChange={numberChange} /> - @@ -527,8 +527,8 @@ const SubscriptionModal = ({ onPaste={onNamePaste} placeholder={ type === 'private-repo' - ? 'SOCK5代理,例如 IP:PORT' - : 'HTTP/SOCK5代理,例如 http://127.0.0.1:1080' + ? intl.get('SOCK5代理,例如 IP:PORT') + : intl.get('HTTP/SOCK5代理,例如 http://127.0.0.1:1080') } /> diff --git a/src/utils/config.ts b/src/utils/config.ts index d3572cd2..11fb25a7 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -80,7 +80,7 @@ export default { { value: 'pushPlus', label: 'PushPlus' }, { value: 'wePlusBot', label: intl.get('微加机器人') }, { value: 'wxPusherBot', label: 'wxPusher' }, - { value: 'wxPusherSpt', label: 'WxPusher(极简推送SPT-推荐)' }, + { value: 'wxPusherSpt', label: intl.get('WxPusher(极简推送SPT-推荐)') }, { value: 'openiLink', label: 'OpeniLink' }, { value: 'wpush', label: 'WPUSH' }, { value: 'chat', label: intl.get('群晖chat') }, @@ -431,9 +431,7 @@ export default { }, { label: 'larkSecret', - tip: intl.get( - '飞书群组机器人加签密钥,安全设置中开启签名校验后获得', - ), + tip: intl.get('飞书群组机器人加签密钥,安全设置中开启签名校验后获得'), }, ], email: [ diff --git a/src/utils/index.ts b/src/utils/index.ts index d393df5c..f5eda7f1 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -155,9 +155,9 @@ export default function browserType() { shell === 'none' ? {} : { - shell, // wechat qq uc 360 2345 sougou liebao maxthon - shellVs, - }, + shell, // wechat qq uc 360 2345 sougou liebao maxthon + shellVs, + }, ); console.log( @@ -165,11 +165,15 @@ export default function browserType() { 'color: blue;font-size: 14px;', ); console.log( - '%c忘形雨笠烟蓑,知心牧唱樵歌。明月清风共我,闲人三个,从他今古消磨。\n', + `%c${intl.get('控制台诗句')}\n`, 'color: yellow;font-size: 18px;', ); console.log( - `%c青龙运行环境:\n\n系统:${result.system}/${result.systemVs}\n浏览器:${result.supporter}/${result.supporterVs}\n内核:${result.engine}/${result.engineVs}`, + `%c${intl.get('运行环境信息', { + system: `${result.system}/${result.systemVs}`, + browser: `${result.supporter}/${result.supporterVs}`, + engine: `${result.engine}/${result.engineVs}`, + })}`, 'color: green;font-size: 14px;font-weight: bold;', ); @@ -337,7 +341,7 @@ export function parseCrontab(schedule: string): Date | null { if (time) { return time.next().toDate(); } - } catch (error) { } + } catch (error) {} return null; } diff --git a/src/utils/init.ts b/src/utils/init.ts index 940a318f..d0170502 100644 --- a/src/utils/init.ts +++ b/src/utils/init.ts @@ -1,5 +1,6 @@ import { loader } from '@monaco-editor/react'; import config from './config'; +import intl from 'react-intl-universal'; export function init(version: string) { // monaco 编辑器配置cdn和locale @@ -9,7 +10,7 @@ export function init(version: string) { }, 'vs/nls': { availableLanguages: { - '*': 'zh-cn', + '*': intl.getInitOptions().currentLocale === 'en' ? 'i-default' : 'zh-cn', }, }, }); diff --git a/src/utils/websocket.ts b/src/utils/websocket.ts index d894ad30..1db8e4f3 100644 --- a/src/utils/websocket.ts +++ b/src/utils/websocket.ts @@ -1,11 +1,13 @@ import SockJS from 'sockjs-client'; +import intl from 'react-intl-universal'; import { SockMessageType } from './type'; class WebSocketManager { private static instance: WebSocketManager | null = null; private url: string; private socket: WebSocket | null = null; - private subscriptions: Map void>> = new Map(); + private subscriptions: Map void>> = + new Map(); private options: { maxReconnectAttempts: number; reconnectInterval: number; @@ -15,7 +17,10 @@ class WebSocketManager { private heartbeatTimeout: NodeJS.Timeout | null = null; private state: 'closed' | 'connecting' | 'open' = 'closed'; - constructor(url: string, options: Partial = {}) { + constructor( + url: string, + options: Partial = {}, + ) { this.url = url; this.options = { maxReconnectAttempts: options.maxReconnectAttempts || 5, @@ -26,7 +31,10 @@ class WebSocketManager { this.init(); } - public static getInstance(url: string = '', options?: Partial): WebSocketManager { + public static getInstance( + url: string = '', + options?: Partial, + ): WebSocketManager { if (!WebSocketManager.instance) { WebSocketManager.instance = new WebSocketManager(url, options); } @@ -47,7 +55,9 @@ class WebSocketManager { this.socket = null; this.reconnectAttempts++; - await new Promise((resolve) => setTimeout(resolve, this.options.reconnectInterval)); + await new Promise((resolve) => + setTimeout(resolve, this.options.reconnectInterval), + ); } } catch (error) { this.handleError(error); @@ -138,7 +148,7 @@ class WebSocketManager { } private handleError(error: any) { - console.error('WebSocket错误:', error); + console.error(intl.get('WebSocket错误'), error); this.emit('error', error); } diff --git a/test/back/dynamic-locales.test.cjs b/test/back/dynamic-locales.test.cjs new file mode 100644 index 00000000..0769b419 --- /dev/null +++ b/test/back/dynamic-locales.test.cjs @@ -0,0 +1,175 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const { Joi, CelebrateError } = require('celebrate'); + +const storePath = require.resolve('../../back/shared/store'); +require.cache[storePath] = { + id: storePath, + filename: storePath, + loaded: true, + exports: { shareStore: { setLang: async () => {} } }, +}; +const { setLang, t } = require('../../back/shared/i18n'); +const validationErrors = + require('../../back/middlewares/validationErrors').default; +const { + scheduleSchema, + commonCronSchema, +} = require('../../back/validation/schedule'); +const upgrade = require('../../cli/src/internal/maintenance/upgradeArtifacts.cjs'); + +function responseFor(schema, value) { + const validation = schema.validate(value).error; + assert.ok(validation); + const error = new CelebrateError('Validation failed', { celebrated: true }); + error.details.set('body', validation); + let response; + const res = { + status(code) { + assert.equal(code, 400); + return this; + }, + send(body) { + response = body; + return this; + }, + }; + validationErrors(error, {}, res, (err) => { + throw err; + }); + return response.validation.body.message; +} + +test('compiled validation schemas follow live language changes without altering field paths', () => { + const schema = Joi.object({ + schedule: scheduleSchema, + log_name: commonCronSchema.log_name, + }); + for (const [lang, schedule, empty, logName] of [ + [ + 'en', + 'Invalid schedule rule', + 'Schedule rule cannot be empty', + 'Log name cannot exceed 100 characters', + ], + ['zh', '无效的定时规则', '定时规则不能为空', '日志名称不能超过100个字符'], + [ + 'en', + 'Invalid schedule rule', + 'Schedule rule cannot be empty', + 'Log name cannot exceed 100 characters', + ], + ]) { + setLang(lang); + assert.equal(responseFor(schema, { schedule: 'invalid' }), schedule); + assert.equal(responseFor(schema, { schedule: '' }), empty); + assert.equal( + responseFor(schema, { schedule: '* * * * *', log_name: 'x'.repeat(101) }), + logName, + ); + } + setLang('zh'); +}); + +test('history, upload, initialization and proxy messages resolve dynamically and preserve user text', () => { + const keys = [ + '请先初始化', + '请先关闭两步验证', + '仅支持 PNG、JPEG、GIF、WebP、AVIF 图片', + '历史版本容量不足,无法安全保存', + '文件已变化,请刷新历史版本后重试', + 'trust proxy 配置格式无效', + ]; + setLang('en'); + for (const key of keys) assert.doesNotMatch(t(key), /[\u4e00-\u9fff]/); + assert.equal(t('用户脚本自己的提示'), '用户脚本自己的提示'); + setLang('zh'); + for (const key of keys) assert.equal(t(key), key); +}); + +test('upgrade verification uses the supplied language and retains artifact details', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-locale-upgrade-')); + try { + await fs.writeFile( + path.join(root, 'build-info.json'), + JSON.stringify({ version: 1, dirty: true }), + ); + await assert.rejects( + upgrade.verifyUpgrade(root, root, { QL_LANG: 'en' }), + /Invalid or dirty build manifest/, + ); + await assert.rejects( + upgrade.verifyUpgrade(root, root, { QL_LANG: 'zh' }), + /构建清单无效/, + ); + await fs.writeFile( + path.join(root, 'build-info.json'), + JSON.stringify({ + version: 1, + dirty: false, + sourceCommit: 'a'.repeat(40), + lockfileSha256: 'b'.repeat(64), + files: {}, + }), + ); + await assert.rejects( + upgrade.verifyUpgrade(root, root, { QL_LANG: 'en' }), + /Missing build output: build\/app.js/, + ); + assert.equal( + upgrade.upgradeMessage('更新失败: %s', { QL_LANG: 'en' }, '文件名.txt'), + 'Update failed: 文件名.txt', + ); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test('Shell help switches language and preserves percent characters in script paths', async () => { + const bash = fs.access('/opt/homebrew/bin/bash').then( + () => '/opt/homebrew/bin/bash', + () => 'bash', + ); + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-help-%-')); + try { + const source = await fs.readFile( + path.resolve(__dirname, '../../shell/otask.sh'), + 'utf8', + ); + const functions = path.join(root, 'functions.sh'); + await fs.writeFile( + functions, + source.slice( + 0, + source.indexOf('\nhandle_task_start "${task_shell_params[@]}"'), + ), + ); + for (const lang of ['en', 'zh']) { + const result = spawnSync( + await bash, + [ + '-c', + '. shell/share.sh; . "shell/lang/$QL_LANG.sh"; . "$2"; dir_scripts="$1"; cmd_task=task; array_scripts=(); array_scripts_name=(); usage', + '--', + root, + functions, + ], + { + cwd: path.resolve(__dirname, '../..'), + env: { ...process.env, QL_LANG: lang }, + encoding: 'utf8', + }, + ); + assert.equal(result.status, 0, result.stderr); + assert.ok(result.stdout.includes(root)); + if (lang === 'en') assert.doesNotMatch(result.stdout, /[\u4e00-\u9fff]/); + else assert.match(result.stdout, /暂无脚本可以执行/); + } + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); diff --git a/test/back/language-sync.test.cjs b/test/back/language-sync.test.cjs new file mode 100644 index 00000000..8d85db4f --- /dev/null +++ b/test/back/language-sync.test.cjs @@ -0,0 +1,88 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const { execFile } = require('node:child_process'); +const { promisify } = require('node:util'); + +test('language changes reach live HTTP/scheduler workers and replacement workers restore the saved language', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-language-cluster-')); + const root = path.resolve(__dirname, '../..'); + try { + const state = path.join(dir, 'language.json'); + await fs.writeFile(state, '"en"'); + const fixture = path.join(dir, 'cluster.cjs'); + await fs.writeFile( + fixture, + ` +require(${JSON.stringify(require.resolve('ts-node/register/transpile-only'))}); +const cluster = require('node:cluster'); +const fs = require('node:fs/promises'); +const storePath = require.resolve(${JSON.stringify( + path.join(root, 'back/shared/store.ts'), + )}); +require.cache[storePath] = { id: storePath, filename: storePath, loaded: true, exports: { shareStore: { + getLang: async () => JSON.parse(await fs.readFile(process.env.LANGUAGE_STATE, 'utf8')), + setLang: (lang) => fs.writeFile(process.env.LANGUAGE_STATE, JSON.stringify(lang)), +} } }; +const { initializeLanguageSync } = require(${JSON.stringify( + path.join(root, 'back/shared/languageSync.ts'), + )}); +const { setLang, t } = require(${JSON.stringify( + path.join(root, 'back/shared/i18n.ts'), + )}); +(async () => { + await initializeLanguageSync(); + if (cluster.isPrimary) { + const ready = new Map(), observed = new Set(); let phase = 'initial'; + cluster.on('message', (worker, message) => { + if (message.kind === 'ready') { + ready.set(worker.id, message.value); + if (phase === 'initial' && ready.size === 2) { + if ([...ready.values()].some(value => value !== 'Update successful')) throw Error('Initial language was not restored'); + phase = 'zh'; cluster.workers[1].send({ command: 'switch', lang: 'zh' }); + } else if (phase === 'replacement' && ready.size === 3) { + if (message.value !== '更新成功') throw Error('Replacement lost saved language'); + phase = 'en'; observed.clear(); cluster.workers[1].send({ command: 'switch', lang: 'en' }); + } + } + if (message.kind === 'observed') { + if (message.value !== (phase === 'zh' ? '更新成功' : 'Update successful')) throw Error('Worker language did not change'); + observed.add(worker.id); + if (phase === 'zh' && observed.size === 2) { phase = 'replacement'; cluster.fork(); } + else if (phase === 'en' && observed.size === 3) { + console.log(JSON.stringify({ initialWorkers: 2, updatedWorkers: 3, replacementRestored: true })); + for (const w of Object.values(cluster.workers)) w.kill(); + } + } + }); + cluster.fork(); cluster.fork(); + } else { + process.on('message', async message => { + if (message.command === 'switch') await setLang(message.lang); + if (message.type === 'language-changed') process.send({ kind: 'observed', value: t('更新成功') }); + }); + process.send({ kind: 'ready', value: t('更新成功') }); + } +})().catch(error => { console.error(error); process.exit(1); }); +`, + ); + const result = await promisify(execFile)(process.execPath, [fixture], { + cwd: root, + timeout: 15000, + env: { + ...process.env, + TS_NODE_PROJECT: path.join(root, 'back/tsconfig.json'), + LANGUAGE_STATE: state, + }, + }); + assert.deepEqual(JSON.parse(result.stdout), { + initialWorkers: 2, + updatedWorkers: 3, + replacementRestored: true, + }); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); diff --git a/test/back/legacy-cron-recovery.test.cjs b/test/back/legacy-cron-recovery.test.cjs index 3a50a106..8f862b94 100644 --- a/test/back/legacy-cron-recovery.test.cjs +++ b/test/back/legacy-cron-recovery.test.cjs @@ -38,7 +38,7 @@ test('legacy main and extra schedules restore real jobs and healthy readiness', './data': { scheduleStacks: stacks }, '../shared/runCron': { runCron() {} }, '../loaders/logger': { info() {}, warn() {} }, - '../shared/i18n': { tf: format }, + '../shared/i18n': { t: (s) => s, tf: format }, }); const crons = legacySchedules.map((schedule, index) => ({ id: `legacy-${index}`, name: 'legacy', schedule, command: 'true', diff --git a/test/back/queued-cron-invalidation.test.cjs b/test/back/queued-cron-invalidation.test.cjs index a08417e4..026e1c63 100644 --- a/test/back/queued-cron-invalidation.test.cjs +++ b/test/back/queued-cron-invalidation.test.cjs @@ -31,7 +31,7 @@ async function fixture() { '../shared/cronScheduler': { createCronJob: (schedule, callback) => ({ start() {}, cancel() {}, fire: callback }) }, '../shared/cronSchedule': { isValidCronSchedule: x => x !== 'invalid' }, './data': { scheduleStacks: jobs }, '../shared/runCron': { runCron }, '../loaders/logger': logger, - '../shared/i18n': { tf: s => s }, + '../shared/i18n': { t: (s) => s, tf: s => s }, }); const { delCron } = load('back/schedule/delCron.ts', { './data': { scheduleStacks: jobs }, '../loaders/logger': logger }); function add(id = '1', command = 'old', replace = false) { diff --git a/test/back/scheduler-reconciliation.test.cjs b/test/back/scheduler-reconciliation.test.cjs index 566ff1ab..aad1d98d 100644 --- a/test/back/scheduler-reconciliation.test.cjs +++ b/test/back/scheduler-reconciliation.test.cjs @@ -48,7 +48,7 @@ test('recovery reconciles a surviving scheduler after missed deletes and disable }, '../shared/runCron': {}, '../loaders/logger': { info() {}, warn() {} }, - '../shared/i18n': { tf: (s) => s }, + '../shared/i18n': { t: (s) => s, tf: (s) => s }, }); const client = { addCron: async (crons, replace) => { @@ -97,7 +97,7 @@ test('invalid replacement leaves the previous schedule intact', async () => { './data': { scheduleStacks: stacks }, '../shared/runCron': {}, '../loaders/logger': {}, - '../shared/i18n': { tf: (s) => s }, + '../shared/i18n': { t: (s) => s, tf: (s) => s }, }); await assert.rejects( new Promise((resolve, reject) => @@ -122,7 +122,7 @@ test('invalid persisted schedules cannot block recovery, valid jobs or HTTP heal './data': { scheduleStacks: stacks }, '../shared/runCron': {}, '../loaders/logger': { info() {}, warn() {} }, - '../shared/i18n': { tf: require('node:util').format }, + '../shared/i18n': { t: (s) => s, tf: require('node:util').format }, }); const client = { addCron: (crons, replace) => new Promise((resolve, reject) => { addCron({ request: { crons, replace } }, (error) => error ? reject(error) : resolve()); diff --git a/test/back/shell-upgrade.test.cjs b/test/back/shell-upgrade.test.cjs index cc4f75f8..b3db4511 100644 --- a/test/back/shell-upgrade.test.cjs +++ b/test/back/shell-upgrade.test.cjs @@ -225,6 +225,7 @@ for (const branch of ['master', 'develop', 'debian', 'debian-dev']) const f = await fixture(t, { branch, lockChange: true, tee: true }); let result = await f.execute('update', 'false'); assert.equal(result.code, 0, result.stdout + result.stderr); + assert.ok(result.stdout.includes('     '), 'update logs retain the completion marker'); assert.equal(f.read('version.yaml'), 'version: 2.21.0'); assert.equal(fs.existsSync(path.join(f.root, 'stops')), false); assert.match( @@ -295,7 +296,7 @@ test('failed health checks and concurrent upgrades are rejected', async (t) => { await withUpgradeLock(tmp, async () => { await assert.rejects( withUpgradeLock(tmp, async () => {}), - /Another upgrade/, + /Another upgrade|已有更新或重载/, ); }); assert.deepEqual(fs.readdirSync(tmp), []); @@ -307,6 +308,6 @@ test('failed health checks and concurrent upgrades are rejected', async (t) => { t.after(() => new Promise((resolve) => server.close(resolve))); await assert.rejects( waitForHealth({ QlPort: String(server.address().port) }, 20), - /health check/, + /health check|后端启动检查失败/, ); }); diff --git a/test/back/update-check.test.cjs b/test/back/update-check.test.cjs new file mode 100644 index 00000000..ec03f6e0 --- /dev/null +++ b/test/back/update-check.test.cjs @@ -0,0 +1,109 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const load = require('../helpers/load-security-module.cjs'); +const yaml = require('js-yaml'); +function service(request) { + const Service = load('back/services/system.ts', { + undici: { request }, + '../config': { + versionFile: 'version.yaml', + lastVersionFile: 'https://version.example/version.yaml', + }, + '../config/const': {}, + '../config/util': { + parseVersion: async () => ({ version: '2.23.0' }), + parseContentVersion: yaml.load, + }, + '../data/dependence': {}, + '../data/notify': {}, + '../data/system': {}, + '../shared/pLimit': {}, + '../schedule/client': {}, + '../shared/i18n': { t: (key) => key }, + './notify': class {}, + './schedule': class {}, + './sock': class {}, + }).default; + return new Service({}, {}, {}); +} +for (const [name, request] of [ + [ + 'unreachable server', + async () => { + throw new Error('network down'); + }, + ], + [ + 'HTTP error with a version-like body', + async () => ({ + statusCode: 503, + body: { text: async () => 'version: 2.23.0' }, + }), + ], + [ + 'HTML response', + async () => ({ + statusCode: 200, + body: { text: async () => 'gateway error' }, + }), + ], + [ + 'invalid version', + async () => ({ + statusCode: 200, + body: { text: async () => 'version: latest' }, + }), + ], + [ + 'unreadable body', + async () => ({ + statusCode: 200, + body: { + text: async () => { + throw new Error('reset'); + }, + }, + }), + ], +]) + test(`update check reports failure for ${name}, without claiming the latest version`, async () => { + assert.deepEqual(await service(request).checkUpdate(), { + code: 400, + message: '检查更新失败,请检查网络后重试', + }); + }); +for (const [version, newer] of [ + ['2.23.0', false], + ['2.23.1', true], + ['2.22.0', false], + ['3.0.0', true], +]) + test(`valid update response ${version}`, async () => { + const result = await service(async (url, options) => { + assert.ok(options.signal instanceof AbortSignal); + assert.equal(options.signal.aborted, false); + return { + statusCode: 200, + body: { + text: async () => `version: ${version}\nchangeLog: release notes`, + }, + }; + }).checkUpdate(); + assert.equal(result.code, 200); + assert.equal(result.data.hasNewVersion, newer); + assert.equal(result.data.lastVersion, version); + assert.equal(result.data.lastLog, 'release notes'); + }); +test('a stalled version request times out instead of hanging the update check', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const checking = service(async (url, { signal }) => { + return new Promise((resolve, reject) => + signal.addEventListener('abort', () => reject(new Error('aborted')), { + once: true, + }), + ); + }).checkUpdate(); + await Promise.resolve(); + t.mock.timers.tick(15000); + assert.equal((await checking).code, 400); +}); diff --git a/test/back/upgrade-download-fallback.test.cjs b/test/back/upgrade-download-fallback.test.cjs new file mode 100644 index 00000000..8b94f328 --- /dev/null +++ b/test/back/upgrade-download-fallback.test.cjs @@ -0,0 +1,128 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const fsp = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { stageUpgrade } = require('../../shell/upgrade.cjs'); +const { + writeManifest, + sourceCommit, +} = require('../helpers/upgrade-fixture.cjs'); +async function fixture( + t, + fail, + fallbackMirror = 'gitee', + corrupt = false, + signal, +) { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-download-fallback-')); + t.after(() => fsp.rm(base, { recursive: true, force: true })); + const root = path.join(base, 'root'), + tmp = path.join(base, 'tmp'), + payload = path.join(base, 'payload'), + source = path.join(payload, 'qinglong-' + sourceCommit), + staticRoot = path.join(payload, 'qinglong-static-develop'); + for (const dir of [ + root, + tmp, + path.join(root, 'node_modules'), + path.join(source, 'sample'), + path.join(staticRoot, 'build'), + ]) + fs.mkdirSync(dir, { recursive: true }); + for (const dir of [root, source]) { + fs.writeFileSync(path.join(dir, 'package.json'), '{}'); + fs.writeFileSync(path.join(dir, 'pnpm-lock.yaml'), 'lock'); + } + fs.writeFileSync(path.join(source, 'sample/config.sample.sh'), 'sample'); + fs.writeFileSync(path.join(staticRoot, 'build/app.js'), 'backend'); + writeManifest(source, staticRoot); + if (corrupt) + fs.writeFileSync(path.join(staticRoot, 'build/app.js'), 'tampered'); + for (const repo of ['qinglong-static', 'qinglong']) + execFileSync( + 'zip', + [ + '-qr', + path.join(base, repo + '.zip'), + repo === 'qinglong-static' + ? 'qinglong-static-develop' + : 'qinglong-' + sourceCommit, + ], + { cwd: payload }, + ); + const urls = []; + const operation = stageUpgrade({ + root, + tmp, + env: { QL_BRANCH: 'develop' }, + mirror: 'github', + fallbackMirror, + signal, + install: async () => assert.fail('unchanged dependencies'), + run: async (program, args) => { + if (program === 'curl') { + const url = args.at(-1); + urls.push(url); + assert.ok(args.includes('--speed-time')); + if (fail(url)) throw new Error('download failed'); + const repo = url.includes('/qinglong-static/') + ? 'qinglong-static' + : 'qinglong'; + fs.copyFileSync( + path.join(base, repo + '.zip'), + args[args.indexOf('--output') + 1], + ); + return { stdout: '' }; + } + return { stdout: execFileSync(program, args, { encoding: 'utf8' }) }; + }, + }); + return { operation, urls, tmp }; +} +test('automatic source selection falls back after GitHub network failure and pins the matching source commit', async (t) => { + const f = await fixture(t, (url) => url.startsWith('https://codeload.')); + await f.operation; + assert.equal(f.urls.length, 4); + assert.match(f.urls[1], /gitee.com.*qinglong-static.*develop/); + assert.ok(f.urls[3].includes(sourceCommit)); + assert.ok(fs.existsSync(path.join(f.tmp, 'upgrade-ready-develop.json'))); +}); +test('source-only network failure keeps the manifest commit when changing mirrors', async (t) => { + const f = await fixture( + t, + (url) => url.startsWith('https://codeload.') && url.includes('/qinglong/'), + ); + await f.operation; + assert.equal(f.urls.length, 3); + assert.ok(f.urls[2].endsWith(sourceCommit + '.zip')); +}); +test('explicit CLI GitHub selection does not switch providers', async (t) => { + const f = await fixture(t, () => true, null); + await assert.rejects(f.operation, /download failed/); + assert.equal(f.urls.length, 1); + assert.deepEqual(fs.readdirSync(f.tmp), []); +}); +test('checksum validation failure never retries from another mirror', async (t) => { + const f = await fixture(t, () => false, 'gitee', true); + await assert.rejects(f.operation, /checksum mismatch|构建文件缺失、损坏或混入旧文件/); + assert.equal(f.urls.length, 2); + assert.deepEqual(fs.readdirSync(f.tmp), []); +}); +test('cancellation does not trigger mirror failover', async (t) => { + const controller = new AbortController(); + const f = await fixture( + t, + () => { + controller.abort(new Error('cancelled')); + return true; + }, + 'gitee', + false, + controller.signal, + ); + await assert.rejects(f.operation, /cancelled/); + assert.equal(f.urls.length, 1); +}); diff --git a/test/back/upgrade-guards.test.cjs b/test/back/upgrade-guards.test.cjs index 247368c8..741e5bc9 100644 --- a/test/back/upgrade-guards.test.cjs +++ b/test/back/upgrade-guards.test.cjs @@ -88,7 +88,7 @@ for (const legacy of [true, false]) try { await entered; const contender = await Promise.race(attempts); - assert.match(contender.error?.message || '', /Another upgrade/); + assert.match(contender.error?.message || '', /Another upgrade|已有更新或重载/); assert.equal(maximum, 1); assert.equal((await original.readdir(lock)).length, 1); } finally { @@ -106,7 +106,7 @@ test('an empty abandoned lock is acquired atomically and a failed operation rele withUpgradeLock(tmp, async () => { await assert.rejects( withUpgradeLock(tmp, async () => assert.fail('concurrent operation')), - /Another upgrade/, + /Another upgrade|已有更新或重载/, ); throw new Error('operation failed'); }), @@ -169,7 +169,7 @@ withUpgradeLock(process.argv[2], async () => { await withUpgradeLock(tmp, async () => { await assert.rejects( withUpgradeLock(tmp, async () => {}), - /Another upgrade/, + /Another upgrade|已有更新或重载/, ); }); assert.deepEqual(await fs.readdir(tmp), ['worker.cjs']); @@ -235,7 +235,7 @@ test( res.once('close', () => clearInterval(timer)); }); const began = Date.now(); - await assert.rejects(waitForHealth({ QlPort: port }, 80), /health check/); + await assert.rejects(waitForHealth({ QlPort: port }, 80), /health check|后端启动检查失败/); assert.ok( Date.now() - began < 1000, 'deadline must not depend on socket inactivity', @@ -280,6 +280,6 @@ for (const streaming of [false, true]) test('unhealthy, malformed and oversized health responses fail within the deadline', async (t) => { for (const body of ['{}', '{', ' '.repeat(65537)]) { const port = await healthServer(t, (req, res) => res.end(body)); - await assert.rejects(waitForHealth({ QlPort: port }, 20), /health check/); + await assert.rejects(waitForHealth({ QlPort: port }, 20), /health check|后端启动检查失败/); } }); diff --git a/test/front/editor-locale.test.cjs b/test/front/editor-locale.test.cjs new file mode 100644 index 00000000..7605c290 --- /dev/null +++ b/test/front/editor-locale.test.cjs @@ -0,0 +1,34 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const load = require('../helpers/load-security-module.cjs'); +for (const [locale, expected] of [ + ['en', 'i-default'], + ['zh', 'zh-cn'], +]) + test(`Monaco uses the matching bundled locale for ${locale}`, () => { + let options; + const { init } = load('src/utils/init.ts', { + '@monaco-editor/react': { + loader: { + config: (value) => { + options = value; + }, + }, + }, + './config': { baseUrl: '/panel/' }, + 'react-intl-universal': { + getInitOptions: () => ({ currentLocale: locale }), + }, + }); + init('2.23.0'); + assert.equal(options.paths.vs, '/panel/monaco-editor/min/vs'); + assert.equal(options['vs/nls'].availableLanguages['*'], expected); + const fs = require('node:fs'); + assert.ok( + fs.existsSync( + 'node_modules/monaco-editor/min/vs/editor/editor.main.nls' + + (expected === 'i-default' ? '' : '.' + expected) + + '.js', + ), + ); + }); diff --git a/test/front/update-progress.test.cjs b/test/front/update-progress.test.cjs new file mode 100644 index 00000000..1b7e2d2e --- /dev/null +++ b/test/front/update-progress.test.cjs @@ -0,0 +1,217 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); +// Drive the real component's handlers and effects without a browser or network. +function panel() { + let cursor = 0, + tree, + subscriber; + const hooks = [], + effects = [], + cleanups = [], + confirms = [], + infos = [], + timers = [], + responses = []; + const react = { + useState(initial) { + const i = cursor++; + if (!(i in hooks)) hooks[i] = initial; + return [ + hooks[i], + (v) => { + hooks[i] = typeof v === 'function' ? v(hooks[i]) : v; + }, + ]; + }, + useRef(initial) { + const i = cursor++; + return (hooks[i] ||= { current: initial }); + }, + useCallback(fn) { + cursor++; + return fn; + }, + useEffect(fn, deps) { + const i = cursor++; + if (!hooks[i] || deps.some((v, j) => !Object.is(v, hooks[i][j]))) { + hooks[i] = deps; + effects.push(fn); + } + }, + }; + function modal(options, list) { + const result = { + options, + update(patch) { + Object.assign(options, patch); + }, + destroy() { + options.afterClose?.(); + }, + }; + list.push(result); + return result; + } + const mocks = { + '@/utils': { disableBody() {} }, + '@/utils/config': { apiPrefix: '/api/' }, + '@/utils/http': { + request: { + put: async (url) => + responses.length + ? responses.shift()(url) + : { + code: 200, + data: { hasNewVersion: false, lastVersion: '2.23.0' }, + }, + }, + }, + '@/utils/websocket': { + getInstance: () => ({ + subscribe(topic, fn) { + subscriber = fn; + }, + unsubscribe() { + subscriber = undefined; + }, + }), + }, + 'ansi-to-react': () => null, + antd: { + Button: 'button', + Statistic: { Countdown: 'span' }, + message: { loading() {}, destroy() {}, error() {}, success() {} }, + Modal: { + confirm: (o) => modal(o, confirms), + info: (o) => modal(o, infos), + }, + }, + react, + 'react-intl-universal': { get: (key) => key }, + }; + const module = { exports: {} }; + const compiled = ts.transpileModule( + fs.readFileSync( + path.join(__dirname, '../../src/pages/setting/checkUpdate.tsx'), + 'utf8', + ), + { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + jsx: ts.JsxEmit.ReactJSX, + esModuleInterop: true, + }, + }, + ).outputText; + new Function( + 'require', + 'module', + 'exports', + 'setTimeout', + 'document', + compiled, + )( + (name) => mocks[name] || require(name), + module, + module.exports, + (fn) => { + timers.push(fn); + }, + { querySelector: () => null }, + ); + function render() { + cursor = 0; + tree = module.exports.default({ systemInfo: { version: '2.23.0' } }); + while (effects.length) { + const cleanup = effects.shift()(); + if (cleanup) cleanups.push(cleanup); + } + } + async function settle() { + await new Promise(setImmediate); + render(); + } + render(); + return { + confirms, + infos, + responses, + timers, + settle, + async start() { + tree.props.children[0].props.onClick(); + await settle(); + confirms.at(-1).options.onOk(); + await settle(); + return infos.at(-1); + }, + async send(payload) { + subscriber?.(payload); + await settle(); + }, + close() { + infos.at(-1).destroy(); + }, + unmount() { + cleanups.forEach((fn) => fn()); + }, + }; +} +for (const logged of [false, true]) + test(`failed final update event unlocks the modal without an additional log; logged=${logged}`, async () => { + const p = panel(), + m = await p.start(); + if (logged) await p.send({ message: 'download failed\n' }); + assert.equal(m.options.okButtonProps.disabled, true); + await p.send({ message: '', status: 'failed' }); + assert.equal(m.options.okButtonProps.disabled, false); + assert.equal(m.options.closable, true); + }); +for (const rejected of [false, true]) + test(`update start request failure unlocks modal; rejected=${rejected}`, async () => { + const p = panel(); + p.responses.push( + async () => ({ code: 200, data: { hasNewVersion: false } }), + async () => { + if (rejected) throw new Error('offline'); + return { code: 400, message: 'busy' }; + }, + ); + const m = await p.start(); + assert.equal(m.options.okButtonProps.disabled, false); + assert.equal(m.options.closable, true); + }); +test('retry clears the previous failure and ignores notifications without an active modal', async () => { + const p = panel(); + await p.send({ status: 'success' }); + assert.equal(p.timers.length, 0); + await p.start(); + await p.send({ status: 'failed', message: '' }); + p.close(); + const retry = await p.start(); + assert.equal(retry.options.okButtonProps.disabled, true); + assert.equal(retry.options.closable, false); +}); +test('successful completion prompts restart once and cancel leaves the log dismissible', async () => { + const p = panel(), + m = await p.start(); + await p.send({ status: 'success', message: '' }); + await p.send({ status: 'success', message: '' }); + p.timers.splice(0).forEach((fn) => fn()); + assert.equal(p.confirms.length, 2); + p.confirms.at(-1).options.onCancel(); + assert.equal(m.options.okButtonProps.disabled, false); + assert.equal(m.options.closable, true); +}); +test('unmount removes the modal and suppresses a pending restart prompt', async () => { + const p = panel(); + await p.start(); + await p.send({ status: 'success' }); + p.unmount(); + p.timers.splice(0).forEach((fn) => fn()); + assert.equal(p.confirms.length, 1); +});