first commit
This commit is contained in:
1564
apps/miniprogram/utils/aboutContent.js
Normal file
1564
apps/miniprogram/utils/aboutContent.js
Normal file
File diff suppressed because it is too large
Load Diff
70
apps/miniprogram/utils/aboutContent.test.ts
Normal file
70
apps/miniprogram/utils/aboutContent.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const { getAboutBrand, getAboutDetailContent, getAboutHomeItems } = require("./aboutContent.js");
|
||||
|
||||
describe("about content i18n", () => {
|
||||
it("关于页产品信息在繁中和英文下仍保留明细内容", () => {
|
||||
const zhHantApp = getAboutDetailContent("app", "zh-Hant");
|
||||
const enApp = getAboutDetailContent("app", "en");
|
||||
|
||||
expect(zhHantApp.sections[0].title).toBe("產品資訊");
|
||||
expect(zhHantApp.sections[0].bullets.length).toBeGreaterThan(0);
|
||||
expect(enApp.sections[0].title).toBe("Product Info");
|
||||
expect(enApp.sections[0].bullets.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("使用手册和隐私政策支持繁中和英文标题", () => {
|
||||
const zhHantManual = getAboutDetailContent("manual", "zh-Hant");
|
||||
const enPrivacy = getAboutDetailContent("privacy", "en");
|
||||
|
||||
expect(zhHantManual.title).toBe("使用手冊");
|
||||
expect(enPrivacy.title).toBe("Privacy");
|
||||
expect(enPrivacy.sections[0].bullets[3]).toContain("microphone audio chunks");
|
||||
});
|
||||
|
||||
it("变更记录支持繁中和英文正文切换", () => {
|
||||
const zhHansChangelog = getAboutDetailContent("changelog", "zh-Hans");
|
||||
const zhHantChangelog = getAboutDetailContent("changelog", "zh-Hant");
|
||||
const enChangelog = getAboutDetailContent("changelog", "en");
|
||||
const zhHansV300 = zhHansChangelog.sections.find((section) => section.title === "v3.0.0(2026-03-18)");
|
||||
const zhHantV300 = zhHantChangelog.sections.find((section) => section.title === "v3.0.0(2026-03-18)");
|
||||
const enV300 = enChangelog.sections.find((section) => section.title === "v3.0.0 (2026-03-18)");
|
||||
|
||||
expect(zhHansV300?.bullets?.[0]).toContain("终端交互稳定性修复");
|
||||
expect(zhHantChangelog.sections[0].title).toBe("索引說明");
|
||||
expect(enChangelog.sections[0].title).toBe("Index Notes");
|
||||
expect(zhHantV300?.bullets?.[1]).toContain("多語言文案");
|
||||
expect(enV300?.bullets?.[2]).toContain("v2.9.6");
|
||||
});
|
||||
|
||||
it("首页入口和品牌信息会按语言切换", () => {
|
||||
expect(getAboutHomeItems("en")[0].title).toBe("Manual");
|
||||
expect(getAboutBrand("zh-Hant").chineseName).toBe("AI矩連");
|
||||
});
|
||||
|
||||
it("日文和韩文关于首页入口、反馈与关于详情页都支持本地化", () => {
|
||||
expect(getAboutHomeItems("ja")[0].title).toBe("使い方");
|
||||
expect(getAboutHomeItems("ko")[0].title).toBe("사용 안내");
|
||||
expect(getAboutDetailContent("feedback", "ja").title).toBe("フィードバック");
|
||||
expect(getAboutDetailContent("app", "ko").sections[0].title).toBe("제품 정보");
|
||||
});
|
||||
|
||||
it("使用手册中的闪念页说明同步包含新增入口", () => {
|
||||
const zhHansManual = getAboutDetailContent("manual", "zh-Hans");
|
||||
const enManual = getAboutDetailContent("manual", "en");
|
||||
|
||||
expect(zhHansManual.sections[6].bullets[3]).toContain("新增");
|
||||
expect(enManual.sections[6].bullets[3]).toContain("Add");
|
||||
});
|
||||
|
||||
it("使用手册会在正文前插入项目背景三点并挂载配图", () => {
|
||||
const zhHansManual = getAboutDetailContent("manual", "zh-Hans");
|
||||
const enManual = getAboutDetailContent("manual", "en");
|
||||
|
||||
expect(zhHansManual.sections[0].title).toBe("为什么需要这个APP?");
|
||||
expect(zhHansManual.sections[0].bullets).toHaveLength(3);
|
||||
expect(zhHansManual.sections[1].mediaItems[0].src).toBe("/assets/guide/guide-mobile-01-server-list.jpg");
|
||||
expect(zhHansManual.sections[7].mediaItems).toHaveLength(4);
|
||||
expect(enManual.sections[9].mediaItems[0].src).toBe("/assets/guide/guide-mobile-10-about.jpg");
|
||||
});
|
||||
});
|
||||
167
apps/miniprogram/utils/aboutContentLocaleOverlays.js
Normal file
167
apps/miniprogram/utils/aboutContentLocaleOverlays.js
Normal file
@@ -0,0 +1,167 @@
|
||||
/* global module */
|
||||
|
||||
/**
|
||||
* About 页面的 JA / KO 覆盖层:
|
||||
* 1. 仅覆盖本轮明确要求本地化的“关于首页”“问题反馈”和“关于详情页”;
|
||||
* 2. 其余 about 页面继续沿用英文基线,避免过早扩散维护范围;
|
||||
* 3. 共享的品牌摘要、分享按钮和复制邮箱提示一并本地化。
|
||||
*/
|
||||
|
||||
const ABOUT_JA_OVERLAY = {
|
||||
brand: {
|
||||
chineseName: "AI JuLian",
|
||||
platformLabel: "RemoteConn ミニプログラム",
|
||||
summary: "サーバーを管理し、モバイル端末で AI ターミナル操作を実行します。"
|
||||
},
|
||||
homeItems: {
|
||||
manual: {
|
||||
title: "使い方",
|
||||
subtitle: "各モジュールの実際の操作手順と推奨利用順を確認します"
|
||||
},
|
||||
feedback: {
|
||||
title: "フィードバック",
|
||||
subtitle: "連絡方法と添付をおすすめする情報を確認します"
|
||||
},
|
||||
privacy: {
|
||||
title: "プライバシー",
|
||||
subtitle: "情報の収集、利用、保存に関する説明を確認します"
|
||||
},
|
||||
changelog: {
|
||||
title: "変更履歴",
|
||||
subtitle: "完全なバージョン履歴と現在の既知課題を確認します"
|
||||
},
|
||||
app: {
|
||||
title: "このアプリについて",
|
||||
subtitle: "製品概要、バージョン情報、連絡先を確認します"
|
||||
}
|
||||
},
|
||||
details: {
|
||||
feedback: {
|
||||
title: "フィードバック",
|
||||
lead: "問題や改善提案があれば、メールでご連絡ください。",
|
||||
sections: [
|
||||
{
|
||||
title: "メール",
|
||||
paragraphs: ["douboer@gmail.com"],
|
||||
actionLabel: "メールをコピー"
|
||||
},
|
||||
{
|
||||
title: "添付をおすすめする情報",
|
||||
bullets: [
|
||||
"端末モデルと OS バージョン",
|
||||
"ミニプログラムのバージョン",
|
||||
"再現手順",
|
||||
"スクリーンショットまたは秘匿化済みログ"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "受付対象",
|
||||
bullets: ["接続失敗", "端末描画の問題", "メモや設定の問題", "要望・UX フィードバック"]
|
||||
}
|
||||
]
|
||||
},
|
||||
app: {
|
||||
title: "このアプリについて",
|
||||
lead: "このページでは製品概要、バージョン、連絡先を確認できます。",
|
||||
sections: [
|
||||
{
|
||||
title: "製品情報",
|
||||
bullets: [
|
||||
"製品名:RemoteConn",
|
||||
"中文名:AI JuLian",
|
||||
"プラットフォーム:RemoteConn ミニプログラム",
|
||||
"バージョン:v3.0.0",
|
||||
"ビルド日:20260318",
|
||||
"データ範囲:設定、サーバープロファイル、メモはデバイス間同期に対応し、機密資格情報はサーバー側で暗号化されています",
|
||||
"連絡先:douboer@gmail.com",
|
||||
"更新日:2026-03-18"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
shareButton: "友だちに共有",
|
||||
copiedEmail: "メールアドレスをコピーしました"
|
||||
};
|
||||
|
||||
const ABOUT_KO_OVERLAY = {
|
||||
brand: {
|
||||
chineseName: "AI JuLian",
|
||||
platformLabel: "RemoteConn 미니프로그램",
|
||||
summary: "서버를 관리하고 모바일 단말에서 AI 터미널 작업을 수행합니다."
|
||||
},
|
||||
homeItems: {
|
||||
manual: {
|
||||
title: "사용 안내",
|
||||
subtitle: "각 모듈의 실제 사용 방법과 권장 순서를 확인합니다"
|
||||
},
|
||||
feedback: {
|
||||
title: "피드백",
|
||||
subtitle: "문의 방법과 함께 보내면 좋은 정보를 확인합니다"
|
||||
},
|
||||
privacy: {
|
||||
title: "개인정보",
|
||||
subtitle: "정보 수집, 이용, 저장 방침을 확인합니다"
|
||||
},
|
||||
changelog: {
|
||||
title: "변경 기록",
|
||||
subtitle: "전체 버전 이력과 현재 남은 이슈를 확인합니다"
|
||||
},
|
||||
app: {
|
||||
title: "앱 정보",
|
||||
subtitle: "제품 개요, 버전 정보, 연락처를 확인합니다"
|
||||
}
|
||||
},
|
||||
details: {
|
||||
feedback: {
|
||||
title: "피드백",
|
||||
lead: "문제나 개선 제안이 있으면 이메일로 알려 주세요.",
|
||||
sections: [
|
||||
{
|
||||
title: "이메일",
|
||||
paragraphs: ["douboer@gmail.com"],
|
||||
actionLabel: "이메일 복사"
|
||||
},
|
||||
{
|
||||
title: "함께 보내면 좋은 정보",
|
||||
bullets: [
|
||||
"기기 모델과 OS 버전",
|
||||
"미니프로그램 버전",
|
||||
"재현 절차",
|
||||
"스크린샷 또는 비식별 처리된 로그"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "접수 범위",
|
||||
bullets: ["연결 실패", "터미널 렌더링 문제", "메모 또는 설정 문제", "제안 및 UX 피드백"]
|
||||
}
|
||||
]
|
||||
},
|
||||
app: {
|
||||
title: "앱 정보",
|
||||
lead: "이 페이지에서 제품 개요, 버전, 연락처를 확인할 수 있습니다.",
|
||||
sections: [
|
||||
{
|
||||
title: "제품 정보",
|
||||
bullets: [
|
||||
"제품명:RemoteConn",
|
||||
"중문명:AI JuLian",
|
||||
"플랫폼:RemoteConn 미니프로그램",
|
||||
"버전:v3.0.0",
|
||||
"빌드 날짜:20260318",
|
||||
"데이터 범위:설정, 서버 프로필, 메모는 기기 간 동기화를 지원하며 민감한 자격 증명은 서버 측에서 암호화됩니다",
|
||||
"문의:douboer@gmail.com",
|
||||
"업데이트:2026-03-18"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
shareButton: "친구에게 공유",
|
||||
copiedEmail: "이메일 주소를 복사했습니다"
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
ABOUT_JA_OVERLAY,
|
||||
ABOUT_KO_OVERLAY
|
||||
};
|
||||
55
apps/miniprogram/utils/aboutPageFactory.js
Normal file
55
apps/miniprogram/utils/aboutPageFactory.js
Normal file
@@ -0,0 +1,55 @@
|
||||
/* global module, wx, require */
|
||||
|
||||
const { getSettings } = require("./storage");
|
||||
const { buildThemeStyle, applyNavigationBarTheme } = require("./themeStyle");
|
||||
const { getAboutBrand, getAboutDetailContent, getAboutUiCopy } = require("./aboutContent");
|
||||
const { normalizeUiLanguage } = require("./i18n");
|
||||
|
||||
/**
|
||||
* 统一生成关于详情页,保证 5 个详情页结构一致:
|
||||
* 1. 共享品牌信息与复制邮箱逻辑;
|
||||
* 2. 页面仅通过 key 切换内容,避免后续文案更新时多处同步。
|
||||
*/
|
||||
function createAboutDetailPage(key) {
|
||||
return {
|
||||
data: {
|
||||
brand: getAboutBrand("zh-Hans"),
|
||||
pageContent: getAboutDetailContent(key, "zh-Hans"),
|
||||
uiCopy: getAboutUiCopy("zh-Hans"),
|
||||
themeStyle: ""
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.applyThemeStyle();
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.applyThemeStyle();
|
||||
},
|
||||
|
||||
applyThemeStyle() {
|
||||
const settings = getSettings();
|
||||
const language = normalizeUiLanguage(settings.uiLanguage);
|
||||
const brand = getAboutBrand(language);
|
||||
const pageContent = getAboutDetailContent(key, language);
|
||||
const uiCopy = getAboutUiCopy(language);
|
||||
applyNavigationBarTheme(settings);
|
||||
wx.setNavigationBarTitle({ title: pageContent.title });
|
||||
this.setData({ brand, pageContent, uiCopy, themeStyle: buildThemeStyle(settings) });
|
||||
},
|
||||
|
||||
onCopyFeedbackEmail() {
|
||||
const uiCopy = this.data.uiCopy || getAboutUiCopy("zh-Hans");
|
||||
wx.setClipboardData({
|
||||
data: (this.data.brand && this.data.brand.feedbackEmail) || getAboutBrand("zh-Hans").feedbackEmail,
|
||||
success: () => {
|
||||
wx.showToast({ title: uiCopy.copiedEmail, icon: "none" });
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createAboutDetailPage
|
||||
};
|
||||
256
apps/miniprogram/utils/aiLaunch.js
Normal file
256
apps/miniprogram/utils/aiLaunch.js
Normal file
@@ -0,0 +1,256 @@
|
||||
/* global module */
|
||||
|
||||
const CODEX_BOOTSTRAP_TOKEN_DIR_MISSING = "__RC_CODEX_DIR_MISSING__";
|
||||
const CODEX_BOOTSTRAP_TOKEN_CODEX_MISSING = "__RC_CODEX_BIN_MISSING__";
|
||||
const CODEX_BOOTSTRAP_TOKEN_READY = "__RC_CODEX_READY__";
|
||||
const AI_RUNTIME_EXIT_OSC_IDENT = 633;
|
||||
const DEFAULT_AI_PROVIDER = "codex";
|
||||
const DEFAULT_CODEX_SANDBOX_MODE = "workspace-write";
|
||||
const DEFAULT_COPILOT_PERMISSION_MODE = "default";
|
||||
|
||||
const AI_PROVIDER_VALUES = new Set(["codex", "copilot"]);
|
||||
const CODEX_SANDBOX_MODE_VALUES = new Set(["read-only", "workspace-write", "danger-full-access"]);
|
||||
const COPILOT_PERMISSION_MODE_VALUES = new Set(["default", "experimental", "allow-all"]);
|
||||
const AI_RUNTIME_EXIT_MARKERS = {
|
||||
codex: `\u001b]${AI_RUNTIME_EXIT_OSC_IDENT};RemoteConn;ai-exit=codex\u0007`,
|
||||
copilot: `\u001b]${AI_RUNTIME_EXIT_OSC_IDENT};RemoteConn;ai-exit=copilot\u0007`
|
||||
};
|
||||
|
||||
/**
|
||||
* AI 默认入口只允许 Codex / Copilot 两档:
|
||||
* 1. 新装默认走 Codex;
|
||||
* 2. 历史空值和脏值统一回退到 Codex。
|
||||
*/
|
||||
function normalizeAiProvider(value) {
|
||||
const normalized = String(value || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return AI_PROVIDER_VALUES.has(normalized) ? normalized : DEFAULT_AI_PROVIDER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex sandbox 模式只接受三档预设值:
|
||||
* 1. 旧版本/脏数据回落到项目目录读写;
|
||||
* 2. 保持与当前弹窗默认选项一致,避免升级后行为突变。
|
||||
*/
|
||||
function normalizeCodexSandboxMode(value) {
|
||||
const normalized = String(value || "").trim();
|
||||
return CODEX_SANDBOX_MODE_VALUES.has(normalized) ? normalized : DEFAULT_CODEX_SANDBOX_MODE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copilot 权限模式同样收口为固定三档:
|
||||
* 1. 默认;
|
||||
* 2. experimental;
|
||||
* 3. allow-all。
|
||||
*/
|
||||
function normalizeCopilotPermissionMode(value) {
|
||||
const normalized = String(value || "").trim();
|
||||
return COPILOT_PERMISSION_MODE_VALUES.has(normalized) ? normalized : DEFAULT_COPILOT_PERMISSION_MODE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对 shell 参数做最小安全转义,避免单引号截断。
|
||||
*/
|
||||
function shellQuote(value) {
|
||||
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造项目目录切换命令:
|
||||
* 1. `~` 和 `~/...` 需要保留 HOME 展开语义;
|
||||
* 2. 其他路径统一做单引号转义,避免空格或特殊字符破坏命令。
|
||||
*/
|
||||
function buildCdCommand(projectPath) {
|
||||
const normalized = String(projectPath || "~").trim() || "~";
|
||||
|
||||
if (normalized === "~") {
|
||||
return 'cd "$HOME"';
|
||||
}
|
||||
|
||||
if (normalized.startsWith("~/")) {
|
||||
const relative = normalized.slice(2);
|
||||
return relative ? `cd "$HOME"/${shellQuote(relative)}` : 'cd "$HOME"';
|
||||
}
|
||||
|
||||
return `cd ${shellQuote(normalized)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 由全局配置推导 Copilot 启动命令:
|
||||
* 1. 默认模式直接执行 `copilot`;
|
||||
* 2. 其它模式只追加稳定 flag,不在页面层散落命令字符串。
|
||||
*/
|
||||
function buildCopilotCommand(mode) {
|
||||
const normalized = normalizeCopilotPermissionMode(mode);
|
||||
if (normalized === "experimental") return "copilot --experimental";
|
||||
if (normalized === "allow-all") return "copilot --allow-all";
|
||||
return "copilot";
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 前台态退出标记:
|
||||
* 1. 使用未知 OSC,既能被前端原始流识别,也不会污染终端可见内容;
|
||||
* 2. 远端 AI 进程退出回到 shell 时打印一次,用于解除“当前已有 AI 在前台”的保护。
|
||||
*/
|
||||
function buildAiRuntimeExitMarker(provider) {
|
||||
const normalized = normalizeAiProvider(provider);
|
||||
return AI_RUNTIME_EXIT_MARKERS[normalized] || AI_RUNTIME_EXIT_MARKERS[DEFAULT_AI_PROVIDER];
|
||||
}
|
||||
|
||||
function buildAiRuntimeExitPrintfCommand(provider) {
|
||||
const normalized = normalizeAiProvider(provider);
|
||||
return `printf '\\033]${AI_RUNTIME_EXIT_OSC_IDENT};RemoteConn;ai-exit=${normalized}\\a'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 启动前的“会话可发命令”判定:
|
||||
* 1. 不能只看页面状态是否显示为 connected,因为首段 banner/stdout 可能早于 shell 真正 ready;
|
||||
* 2. 小程序自动建链后立刻点 AI 的场景里,必须等待网关显式 `control.connected`;
|
||||
* 3. 这样可以避免把 Codex/Copilot 启动命令打进尚未就绪的 shell,导致后续 bootstrap token 永远等不到。
|
||||
*/
|
||||
function isAiSessionReady(status, hasClient, shellReady) {
|
||||
return String(status || "").trim() === "connected" && !!hasClient && shellReady === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将脚本文本安全嵌入到 `sh -lc "..."` 的双引号参数中。
|
||||
* 说明:远端默认 shell 可能不是 POSIX shell,因此需要显式把 bootstrap
|
||||
* 转交给 `sh -lc` 解释,避免 csh/tcsh 在重定向语法上报错。
|
||||
*/
|
||||
function escapeForDoubleQuotedShellArg(script) {
|
||||
return String(script || "")
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\$/g, "\\$")
|
||||
.replace(/`/g, "\\`")
|
||||
.replace(/!/g, "\\!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 从原始 stdout/stderr 中消费 AI 退出标记:
|
||||
* 1. 已完成的 OSC 标记直接剥离,并返回退出的 provider;
|
||||
* 2. 若 chunk 末尾只收到半段标记,则仅缓存那段 `ESC ] ...` 尾巴;
|
||||
* 3. 不延迟普通文本输出,避免为等标记而卡住终端刷新。
|
||||
*/
|
||||
function consumeAiRuntimeExitMarkers(input, previousCarry) {
|
||||
let working = `${String(previousCarry || "")}${String(input || "")}`;
|
||||
const exitedProviders = [];
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const provider of Object.keys(AI_RUNTIME_EXIT_MARKERS)) {
|
||||
const marker = AI_RUNTIME_EXIT_MARKERS[provider];
|
||||
const index = working.indexOf(marker);
|
||||
if (index < 0) {
|
||||
continue;
|
||||
}
|
||||
exitedProviders.push(provider);
|
||||
working = `${working.slice(0, index)}${working.slice(index + marker.length)}`;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
let carry = "";
|
||||
const lastEscIndex = working.lastIndexOf("\u001b");
|
||||
if (lastEscIndex >= 0) {
|
||||
const suffix = working.slice(lastEscIndex);
|
||||
const mayBeMarkerTail = Object.values(AI_RUNTIME_EXIT_MARKERS).some((marker) =>
|
||||
marker.startsWith(suffix)
|
||||
);
|
||||
if (mayBeMarkerTail) {
|
||||
carry = suffix;
|
||||
working = working.slice(0, lastEscIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: working,
|
||||
carry,
|
||||
exitedProviders
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成小程序端 Codex 启动预检命令。
|
||||
* 目标与 Web 端保持一致:
|
||||
* 1. 先验证项目目录是否存在;
|
||||
* 2. 再验证服务器是否安装 `codex`;
|
||||
* 3. 仅在两项都通过时,进入目标目录并启动 Codex。
|
||||
*/
|
||||
function buildCodexBootstrapCommand(projectPath, sandbox) {
|
||||
const normalizedPath = String(projectPath || "~").trim() || "~";
|
||||
const cdCommand = buildCdCommand(normalizedPath);
|
||||
const runCommand = `codex --sandbox ${normalizeCodexSandboxMode(sandbox)}`;
|
||||
const exitPrintfCommand = buildAiRuntimeExitPrintfCommand("codex");
|
||||
const bootstrapScript =
|
||||
`__rc_codex_path_ok=1; __rc_codex_bin_ok=1; ${cdCommand} >/dev/null 2>&1 || __rc_codex_path_ok=0; ` +
|
||||
`command -v codex >/dev/null 2>&1 || __rc_codex_bin_ok=0; ` +
|
||||
`[ "$__rc_codex_path_ok" -eq 1 ] || printf '${CODEX_BOOTSTRAP_TOKEN_DIR_MISSING}\\n'; ` +
|
||||
`[ "$__rc_codex_bin_ok" -eq 1 ] || printf '${CODEX_BOOTSTRAP_TOKEN_CODEX_MISSING}\\n'; ` +
|
||||
`if [ "$__rc_codex_path_ok" -eq 1 ] && [ "$__rc_codex_bin_ok" -eq 1 ]; then printf '${CODEX_BOOTSTRAP_TOKEN_READY}\\n'; ${runCommand}; __rc_ai_exit_code=$?; ${exitPrintfCommand}; exit "$__rc_ai_exit_code"; fi`;
|
||||
|
||||
return {
|
||||
projectPath: normalizedPath,
|
||||
command: `sh -lc "${escapeForDoubleQuotedShellArg(bootstrapScript)}"`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成小程序端 Codex 恢复命令:
|
||||
* 1. 仅用于“旧 SSH 未续上,但本地记得上一轮前台是 Codex”的场景;
|
||||
* 2. 走 `codex resume --last --sandbox ...`,显式保持上次会话权限;
|
||||
* 3. 无论恢复成功还是快速失败,都会打印退出标记,便于页面解除前台锁。
|
||||
*/
|
||||
function buildCodexResumeCommand(projectPath, sandbox) {
|
||||
const normalizedPath = String(projectPath || "~").trim() || "~";
|
||||
const exitPrintfCommand = buildAiRuntimeExitPrintfCommand("codex");
|
||||
const script =
|
||||
`${buildCdCommand(normalizedPath)} && codex resume --last --sandbox ${normalizeCodexSandboxMode(sandbox)}; ` +
|
||||
`__rc_ai_exit_code=$?; ${exitPrintfCommand}; exit "$__rc_ai_exit_code"`;
|
||||
|
||||
return {
|
||||
projectPath: normalizedPath,
|
||||
command: `sh -lc "${escapeForDoubleQuotedShellArg(script)}"`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Copilot 没有 Codex 那套独立的 bootstrap token,但仍需要:
|
||||
* 1. 先切到项目目录;
|
||||
* 2. 退出时打印同源 OSC 标记,方便前端解除互斥保护。
|
||||
*/
|
||||
function buildCopilotLaunchCommand(projectPath, mode) {
|
||||
const normalizedPath = String(projectPath || "~").trim() || "~";
|
||||
const runCommand = buildCopilotCommand(mode);
|
||||
const script = `${buildCdCommand(normalizedPath)} && ${runCommand}; __rc_ai_exit_code=$?; ${buildAiRuntimeExitPrintfCommand(
|
||||
"copilot"
|
||||
)}; exit "$__rc_ai_exit_code"`;
|
||||
return {
|
||||
projectPath: normalizedPath,
|
||||
command: `sh -lc "${escapeForDoubleQuotedShellArg(script)}"`
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CODEX_BOOTSTRAP_TOKEN_DIR_MISSING,
|
||||
CODEX_BOOTSTRAP_TOKEN_CODEX_MISSING,
|
||||
CODEX_BOOTSTRAP_TOKEN_READY,
|
||||
AI_RUNTIME_EXIT_OSC_IDENT,
|
||||
DEFAULT_AI_PROVIDER,
|
||||
DEFAULT_CODEX_SANDBOX_MODE,
|
||||
DEFAULT_COPILOT_PERMISSION_MODE,
|
||||
shellQuote,
|
||||
normalizeAiProvider,
|
||||
normalizeCodexSandboxMode,
|
||||
normalizeCopilotPermissionMode,
|
||||
buildCdCommand,
|
||||
buildAiRuntimeExitMarker,
|
||||
consumeAiRuntimeExitMarkers,
|
||||
isAiSessionReady,
|
||||
buildCopilotCommand,
|
||||
buildCopilotLaunchCommand,
|
||||
buildCodexResumeCommand,
|
||||
escapeForDoubleQuotedShellArg,
|
||||
buildCodexBootstrapCommand
|
||||
};
|
||||
117
apps/miniprogram/utils/aiLaunch.test.ts
Normal file
117
apps/miniprogram/utils/aiLaunch.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const {
|
||||
DEFAULT_AI_PROVIDER,
|
||||
AI_RUNTIME_EXIT_OSC_IDENT,
|
||||
CODEX_BOOTSTRAP_TOKEN_CODEX_MISSING,
|
||||
CODEX_BOOTSTRAP_TOKEN_DIR_MISSING,
|
||||
CODEX_BOOTSTRAP_TOKEN_READY,
|
||||
DEFAULT_CODEX_SANDBOX_MODE,
|
||||
DEFAULT_COPILOT_PERMISSION_MODE,
|
||||
buildAiRuntimeExitMarker,
|
||||
buildCopilotLaunchCommand,
|
||||
buildCopilotCommand,
|
||||
buildCdCommand,
|
||||
buildCodexBootstrapCommand,
|
||||
buildCodexResumeCommand,
|
||||
consumeAiRuntimeExitMarkers,
|
||||
isAiSessionReady,
|
||||
normalizeAiProvider,
|
||||
normalizeCodexSandboxMode,
|
||||
normalizeCopilotPermissionMode
|
||||
} = require("./aiLaunch.js");
|
||||
|
||||
describe("miniprogram aiLaunch", () => {
|
||||
it("保留 HOME 展开语义", () => {
|
||||
expect(buildCdCommand("~")).toBe('cd "$HOME"');
|
||||
expect(buildCdCommand("~/workspace/remoteconn")).toBe("cd \"$HOME\"/'workspace/remoteconn'");
|
||||
});
|
||||
|
||||
it("对普通路径做单引号转义", () => {
|
||||
expect(buildCdCommand("/var/www/my app")).toBe("cd '/var/www/my app'");
|
||||
expect(buildCdCommand("/tmp/it's here")).toBe("cd '/tmp/it'\\''s here'");
|
||||
});
|
||||
|
||||
it("生成与 Web 对齐的 Codex bootstrap 命令", () => {
|
||||
const plan = buildCodexBootstrapCommand("~/workspace/demo", "danger-full-access");
|
||||
|
||||
expect(plan.projectPath).toBe("~/workspace/demo");
|
||||
expect(plan.command.startsWith('sh -lc "')).toBe(true);
|
||||
expect(plan.command).toContain("codex --sandbox danger-full-access");
|
||||
expect(plan.command).toContain(CODEX_BOOTSTRAP_TOKEN_DIR_MISSING);
|
||||
expect(plan.command).toContain(CODEX_BOOTSTRAP_TOKEN_CODEX_MISSING);
|
||||
expect(plan.command).toContain(CODEX_BOOTSTRAP_TOKEN_READY);
|
||||
expect(plan.command).toContain(
|
||||
`printf '\\\\033]${AI_RUNTIME_EXIT_OSC_IDENT};RemoteConn;ai-exit=codex\\\\a'`
|
||||
);
|
||||
});
|
||||
|
||||
it("生成 Codex 恢复命令时应使用 resume --last 并保留退出标记", () => {
|
||||
const plan = buildCodexResumeCommand("~/workspace/demo", "danger-full-access");
|
||||
|
||||
expect(plan.projectPath).toBe("~/workspace/demo");
|
||||
expect(plan.command.startsWith('sh -lc "')).toBe(true);
|
||||
expect(plan.command).toContain("workspace/demo");
|
||||
expect(plan.command).toContain("codex resume --last --sandbox danger-full-access;");
|
||||
expect(plan.command).toContain(
|
||||
`printf '\\\\033]${AI_RUNTIME_EXIT_OSC_IDENT};RemoteConn;ai-exit=codex\\\\a'`
|
||||
);
|
||||
});
|
||||
|
||||
it("AI 模式脏值会回退到稳定默认值", () => {
|
||||
expect(normalizeAiProvider("")).toBe(DEFAULT_AI_PROVIDER);
|
||||
expect(normalizeAiProvider("invalid")).toBe(DEFAULT_AI_PROVIDER);
|
||||
expect(normalizeCodexSandboxMode("")).toBe(DEFAULT_CODEX_SANDBOX_MODE);
|
||||
expect(normalizeCodexSandboxMode("invalid")).toBe(DEFAULT_CODEX_SANDBOX_MODE);
|
||||
expect(normalizeCopilotPermissionMode("")).toBe(DEFAULT_COPILOT_PERMISSION_MODE);
|
||||
expect(normalizeCopilotPermissionMode("invalid")).toBe(DEFAULT_COPILOT_PERMISSION_MODE);
|
||||
});
|
||||
|
||||
it("会按 Copilot 权限模式构造启动命令", () => {
|
||||
expect(buildCopilotCommand("default")).toBe("copilot");
|
||||
expect(buildCopilotCommand("experimental")).toBe("copilot --experimental");
|
||||
expect(buildCopilotCommand("allow-all")).toBe("copilot --allow-all");
|
||||
expect(buildCopilotCommand("invalid")).toBe("copilot");
|
||||
});
|
||||
|
||||
it("Copilot 启动命令也会携带退出标记,供前端解除前台 AI 保护", () => {
|
||||
const plan = buildCopilotLaunchCommand("~/workspace/demo", "allow-all");
|
||||
|
||||
expect(plan.projectPath).toBe("~/workspace/demo");
|
||||
expect(plan.command.startsWith('sh -lc "')).toBe(true);
|
||||
expect(plan.command).toContain("copilot --allow-all");
|
||||
expect(plan.command).toContain(
|
||||
`printf '\\\\033]${AI_RUNTIME_EXIT_OSC_IDENT};RemoteConn;ai-exit=copilot\\\\a'`
|
||||
);
|
||||
});
|
||||
|
||||
it("能消费完整或跨 chunk 的 AI 退出标记,而不污染可见文本", () => {
|
||||
const codexExitMarker = buildAiRuntimeExitMarker("codex");
|
||||
|
||||
expect(consumeAiRuntimeExitMarkers(`prefix${codexExitMarker}suffix`, "")).toEqual({
|
||||
text: "prefixsuffix",
|
||||
carry: "",
|
||||
exitedProviders: ["codex"]
|
||||
});
|
||||
|
||||
const first = consumeAiRuntimeExitMarkers(`before${codexExitMarker.slice(0, 8)}`, "");
|
||||
expect(first).toEqual({
|
||||
text: "before",
|
||||
carry: codexExitMarker.slice(0, 8),
|
||||
exitedProviders: []
|
||||
});
|
||||
|
||||
expect(consumeAiRuntimeExitMarkers(`${codexExitMarker.slice(8)}after`, first.carry)).toEqual({
|
||||
text: "after",
|
||||
carry: "",
|
||||
exitedProviders: ["codex"]
|
||||
});
|
||||
});
|
||||
|
||||
it("只有收到显式 shell-ready 信号后,才认为 AI 会话可发命令", () => {
|
||||
expect(isAiSessionReady("connected", true, false)).toBe(false);
|
||||
expect(isAiSessionReady("connected", true, true)).toBe(true);
|
||||
expect(isAiSessionReady("connecting", true, true)).toBe(false);
|
||||
expect(isAiSessionReady("connected", false, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
277
apps/miniprogram/utils/asrGateway.js
Normal file
277
apps/miniprogram/utils/asrGateway.js
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* 小程序语音网关客户端:
|
||||
* 1. 对齐 Web 端 /ws/asr 协议(ready/result/round_end/error);
|
||||
* 2. 控制帧走 JSON,音频分片走二进制帧。
|
||||
*/
|
||||
const READY_TIMEOUT_MS = 7000;
|
||||
const CLOSE_TIMEOUT_MS = 2600;
|
||||
|
||||
function buildAsrWsUrl(rawGatewayUrl, token) {
|
||||
const input = String(rawGatewayUrl || "").trim();
|
||||
if (!input) {
|
||||
throw new Error("网关地址为空");
|
||||
}
|
||||
let base = input;
|
||||
if (base.startsWith("http://")) base = `ws://${base.slice(7)}`;
|
||||
if (base.startsWith("https://")) base = `wss://${base.slice(8)}`;
|
||||
if (!base.startsWith("ws://") && !base.startsWith("wss://")) {
|
||||
base = `wss://${base}`;
|
||||
}
|
||||
base = base.replace(/\/+$/, "");
|
||||
const safeToken = encodeURIComponent(String(token || ""));
|
||||
return `${base}/ws/asr?token=${safeToken}`;
|
||||
}
|
||||
|
||||
function createAsrGatewayClient(options) {
|
||||
const config = options || {};
|
||||
const endpoint = buildAsrWsUrl(config.gatewayUrl, config.gatewayToken);
|
||||
let socketTask = null;
|
||||
let ready = false;
|
||||
let closed = false;
|
||||
let stopCloseTimer = null;
|
||||
let readyTimer = null;
|
||||
let readyResolve = null;
|
||||
let readyReject = null;
|
||||
|
||||
function clearReadyTimer() {
|
||||
if (readyTimer) {
|
||||
clearTimeout(readyTimer);
|
||||
readyTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearStopCloseTimer() {
|
||||
if (stopCloseTimer) {
|
||||
clearTimeout(stopCloseTimer);
|
||||
stopCloseTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function rejectPendingReady(error) {
|
||||
if (typeof readyReject === "function") {
|
||||
readyReject(error);
|
||||
}
|
||||
readyResolve = null;
|
||||
readyReject = null;
|
||||
}
|
||||
|
||||
function resolvePendingReady() {
|
||||
if (typeof readyResolve === "function") {
|
||||
readyResolve();
|
||||
}
|
||||
readyResolve = null;
|
||||
readyReject = null;
|
||||
}
|
||||
|
||||
function sendJson(frame) {
|
||||
if (!socketTask || closed) return;
|
||||
socketTask.send({ data: JSON.stringify(frame) });
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (socketTask && !closed) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
closed = false;
|
||||
ready = false;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
readyResolve = resolve;
|
||||
readyReject = reject;
|
||||
socketTask = wx.connectSocket({ url: endpoint, timeout: READY_TIMEOUT_MS });
|
||||
clearReadyTimer();
|
||||
readyTimer = setTimeout(() => {
|
||||
if (ready) return;
|
||||
close("ready_timeout");
|
||||
rejectPendingReady(new Error("语音网关连接超时"));
|
||||
}, READY_TIMEOUT_MS);
|
||||
|
||||
socketTask.onOpen(() => {
|
||||
if (typeof config.onOpen === "function") {
|
||||
config.onOpen();
|
||||
}
|
||||
});
|
||||
|
||||
socketTask.onMessage((event) => {
|
||||
const raw = event && event.data;
|
||||
if (typeof raw !== "string") {
|
||||
return;
|
||||
}
|
||||
let frame = null;
|
||||
try {
|
||||
frame = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === "ready") {
|
||||
ready = true;
|
||||
clearReadyTimer();
|
||||
resolvePendingReady();
|
||||
if (typeof config.onReady === "function") {
|
||||
config.onReady(frame.payload || {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === "result") {
|
||||
if (typeof config.onResult === "function") {
|
||||
config.onResult(frame.payload || {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === "round_end") {
|
||||
clearStopCloseTimer();
|
||||
if (typeof config.onRoundEnd === "function") {
|
||||
config.onRoundEnd(frame.payload || {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === "pong") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === "error") {
|
||||
const message = String((frame.payload && frame.payload.message) || "语音网关异常");
|
||||
const error = new Error(message);
|
||||
if (!ready) {
|
||||
clearReadyTimer();
|
||||
rejectPendingReady(error);
|
||||
}
|
||||
if (typeof config.onError === "function") {
|
||||
config.onError(error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
socketTask.onError((event) => {
|
||||
const detail = event && event.errMsg ? String(event.errMsg) : "";
|
||||
const error = new Error(detail ? `语音网关连接失败: ${detail}` : "语音网关连接失败");
|
||||
if (!ready) {
|
||||
clearReadyTimer();
|
||||
rejectPendingReady(error);
|
||||
}
|
||||
if (typeof config.onError === "function") {
|
||||
config.onError(error);
|
||||
}
|
||||
});
|
||||
|
||||
socketTask.onClose((event) => {
|
||||
const wasReady = ready;
|
||||
ready = false;
|
||||
closed = true;
|
||||
clearReadyTimer();
|
||||
clearStopCloseTimer();
|
||||
socketTask = null;
|
||||
if (!wasReady) {
|
||||
const code = event && typeof event.code === "number" ? event.code : "";
|
||||
const reason = event && event.reason ? String(event.reason) : "";
|
||||
rejectPendingReady(
|
||||
new Error(`语音连接已关闭${code ? `(${code})` : ""}${reason ? `: ${reason}` : ""}`)
|
||||
);
|
||||
}
|
||||
if (typeof config.onClose === "function") {
|
||||
config.onClose(event || {});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function startRound(payload) {
|
||||
if (!ready) {
|
||||
throw new Error("语音网关未就绪");
|
||||
}
|
||||
sendJson({
|
||||
type: "start",
|
||||
payload:
|
||||
payload && typeof payload === "object"
|
||||
? payload
|
||||
: {
|
||||
audio: {
|
||||
format: "pcm",
|
||||
codec: "raw",
|
||||
rate: 16000,
|
||||
bits: 16,
|
||||
channel: 1
|
||||
},
|
||||
request: {
|
||||
model_name: "bigmodel",
|
||||
enable_itn: true,
|
||||
enable_punc: true,
|
||||
result_type: "full"
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function sendAudio(buffer) {
|
||||
if (!ready || !socketTask || closed) {
|
||||
return;
|
||||
}
|
||||
if (!(buffer instanceof ArrayBuffer) || buffer.byteLength <= 0) {
|
||||
return;
|
||||
}
|
||||
socketTask.send({ data: buffer });
|
||||
}
|
||||
|
||||
function stopRound() {
|
||||
if (!ready) return;
|
||||
sendJson({ type: "stop" });
|
||||
clearStopCloseTimer();
|
||||
stopCloseTimer = setTimeout(() => {
|
||||
close("round_timeout");
|
||||
}, CLOSE_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
function cancelRound() {
|
||||
if (!ready) {
|
||||
close("cancel_before_ready");
|
||||
return;
|
||||
}
|
||||
sendJson({ type: "cancel" });
|
||||
close("cancel");
|
||||
}
|
||||
|
||||
function ping() {
|
||||
if (!ready) return;
|
||||
sendJson({ type: "ping" });
|
||||
}
|
||||
|
||||
function close(reason) {
|
||||
clearReadyTimer();
|
||||
clearStopCloseTimer();
|
||||
closed = true;
|
||||
ready = false;
|
||||
if (!socketTask) return;
|
||||
try {
|
||||
socketTask.close({ code: 1000, reason: reason || "client_close" });
|
||||
} catch (error) {
|
||||
console.warn("[asrGateway.close]", error);
|
||||
}
|
||||
socketTask = null;
|
||||
}
|
||||
|
||||
return {
|
||||
connect,
|
||||
startRound,
|
||||
sendAudio,
|
||||
stopRound,
|
||||
cancelRound,
|
||||
ping,
|
||||
close,
|
||||
isReady() {
|
||||
return ready;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
READY_TIMEOUT_MS,
|
||||
CLOSE_TIMEOUT_MS,
|
||||
buildAsrWsUrl,
|
||||
createAsrGatewayClient
|
||||
};
|
||||
461
apps/miniprogram/utils/gateway.js
Normal file
461
apps/miniprogram/utils/gateway.js
Normal file
@@ -0,0 +1,461 @@
|
||||
/* global wx, console, module, clearInterval, setInterval, setTimeout */
|
||||
|
||||
const DEFAULT_LATENCY_SAMPLE_INTERVAL_MS = 10000;
|
||||
const DEFAULT_CONNECT_RETRY_COUNT = 1;
|
||||
const DEFAULT_CONNECT_RETRY_DELAY_MS = 400;
|
||||
|
||||
function normalizeLatencySampleIntervalMs(value) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric < 1000) {
|
||||
return DEFAULT_LATENCY_SAMPLE_INTERVAL_MS;
|
||||
}
|
||||
return Math.round(numeric);
|
||||
}
|
||||
|
||||
function normalizeConnectRetryCount(value) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric < 0) {
|
||||
return DEFAULT_CONNECT_RETRY_COUNT;
|
||||
}
|
||||
return Math.min(2, Math.round(numeric));
|
||||
}
|
||||
|
||||
function normalizeConnectRetryDelayMs(value) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric < 100) {
|
||||
return DEFAULT_CONNECT_RETRY_DELAY_MS;
|
||||
}
|
||||
return Math.min(2000, Math.round(numeric));
|
||||
}
|
||||
|
||||
function wait(ms) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, Math.max(0, Math.round(Number(ms) || 0)));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅对“明显属于临时网络态”的首连失败做一次短退避重试:
|
||||
* 1. `connection refused / timeout / reset` 一类通常是客户端网络栈或链路瞬时抖动;
|
||||
* 2. 域名白名单、证书、URL 非法等配置问题不会被重试,避免掩盖真实错误;
|
||||
* 3. 这里只处理 `onOpen` 之前的失败,已建立的会话仍按原有断线逻辑处理。
|
||||
*/
|
||||
function isRetryableConnectFailure(detail) {
|
||||
const text = String(detail || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!text) return false;
|
||||
|
||||
const blockedHints = [
|
||||
"url not in domain list",
|
||||
"socket 域名",
|
||||
"invalid url",
|
||||
"ssl handshake failed",
|
||||
"certificate",
|
||||
"cert",
|
||||
"tls",
|
||||
"token 无效",
|
||||
"auth_failed"
|
||||
];
|
||||
if (blockedHints.some((hint) => text.includes(hint))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const retryableHints = [
|
||||
"connection refused",
|
||||
"econnrefused",
|
||||
"software caused connection abort",
|
||||
"connection abort",
|
||||
"connection reset",
|
||||
"econnreset",
|
||||
"network is unreachable",
|
||||
"host is unreachable",
|
||||
"timed out",
|
||||
"timeout",
|
||||
"temporarily unavailable",
|
||||
"socket hang up",
|
||||
"failed to connect"
|
||||
];
|
||||
return retryableHints.some((hint) => text.includes(hint));
|
||||
}
|
||||
|
||||
function isRetryablePreOpenClose(event) {
|
||||
const code = event && typeof event.code === "number" ? event.code : 0;
|
||||
if (!code) return true;
|
||||
return code === 1001 || code === 1006 || code === 1011;
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信小程序网关传输层(最小可用版)。
|
||||
* 协议与 Web 端保持一致:
|
||||
* - init / stdin / resize / control(ping-pong-disconnect)
|
||||
*/
|
||||
function buildWsUrl(rawGatewayUrl, token) {
|
||||
const input = String(rawGatewayUrl || "").trim();
|
||||
if (!input) {
|
||||
throw new Error("网关地址为空");
|
||||
}
|
||||
|
||||
let base = input;
|
||||
if (base.startsWith("http://")) base = `ws://${base.slice(7)}`;
|
||||
if (base.startsWith("https://")) base = `wss://${base.slice(8)}`;
|
||||
if (!base.startsWith("ws://") && !base.startsWith("wss://")) {
|
||||
base = `wss://${base}`;
|
||||
}
|
||||
base = base.replace(/\/+$/, "");
|
||||
const safeToken = encodeURIComponent(String(token || ""));
|
||||
return `${base}/ws/terminal?token=${safeToken}`;
|
||||
}
|
||||
|
||||
function createGatewayClient(options) {
|
||||
const config = options || {};
|
||||
let socketTask = null;
|
||||
let socketReady = false;
|
||||
let heartbeatTimer = null;
|
||||
let heartbeatIntervalMs = normalizeLatencySampleIntervalMs(config.heartbeatIntervalMs);
|
||||
const connectRetryCount = normalizeConnectRetryCount(config.connectRetryCount);
|
||||
const connectRetryDelayMs = normalizeConnectRetryDelayMs(config.connectRetryDelayMs);
|
||||
let pingAt = 0;
|
||||
let connectPromise = null;
|
||||
let activeSeq = 0;
|
||||
let activeConnectRunSeq = 0;
|
||||
const debugLog = typeof config.debugLog === "function" ? config.debugLog : null;
|
||||
|
||||
function log(event, payload) {
|
||||
if (!debugLog) return;
|
||||
try {
|
||||
debugLog(event, payload || {});
|
||||
} catch (error) {
|
||||
console.warn("[gateway.debug]", error);
|
||||
}
|
||||
}
|
||||
|
||||
function clearHeartbeat() {
|
||||
if (heartbeatTimer) {
|
||||
clearInterval(heartbeatTimer);
|
||||
heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function releaseSocketTask(task) {
|
||||
if (task && task === socketTask) {
|
||||
socketTask = null;
|
||||
}
|
||||
socketReady = false;
|
||||
clearHeartbeat();
|
||||
}
|
||||
|
||||
function safeCloseSocketTask(task, reason) {
|
||||
if (!task || typeof task.close !== "function") return;
|
||||
try {
|
||||
task.close({ code: 1000, reason: reason || "cleanup" });
|
||||
} catch (error) {
|
||||
console.warn("[gateway.cleanup]", error);
|
||||
}
|
||||
}
|
||||
|
||||
function sendFrame(frame) {
|
||||
if (!socketTask) return;
|
||||
socketTask.send({ data: JSON.stringify(frame) });
|
||||
}
|
||||
|
||||
function sendLatencyPing() {
|
||||
if (!socketTask) return;
|
||||
pingAt = Date.now();
|
||||
sendFrame({ type: "control", payload: { action: "ping" } });
|
||||
}
|
||||
|
||||
function startHeartbeat() {
|
||||
clearHeartbeat();
|
||||
if (!socketReady) return;
|
||||
heartbeatTimer = setInterval(() => {
|
||||
sendLatencyPing();
|
||||
}, heartbeatIntervalMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接真正进入可用态后,允许上层主动补一拍时延采样,
|
||||
* 避免首个 ping 早于 shell ready,导致 UI 要等下一轮 10 秒心跳。
|
||||
*/
|
||||
function sampleLatency() {
|
||||
sendLatencyPing();
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊断面板展开时,小程序会把采样频率提升到 3 秒;
|
||||
* 收起后再恢复默认节奏,这里只负责切换 WebSocket ping 心跳本身。
|
||||
*/
|
||||
function setLatencySampleInterval(intervalMs) {
|
||||
const nextIntervalMs = normalizeLatencySampleIntervalMs(intervalMs);
|
||||
if (nextIntervalMs === heartbeatIntervalMs) {
|
||||
return;
|
||||
}
|
||||
heartbeatIntervalMs = nextIntervalMs;
|
||||
if (socketReady) {
|
||||
startHeartbeat();
|
||||
}
|
||||
}
|
||||
|
||||
function connect(params) {
|
||||
const payload = params || {};
|
||||
const url = buildWsUrl(config.gatewayUrl, config.gatewayToken);
|
||||
const connectTimeoutMs = Number(config.connectTimeoutMs);
|
||||
const timeout = Number.isFinite(connectTimeoutMs) && connectTimeoutMs >= 1000 ? connectTimeoutMs : 12000;
|
||||
const maxAttempts = 1 + connectRetryCount;
|
||||
|
||||
if (connectPromise) return connectPromise;
|
||||
|
||||
const connectRunSeq = ++activeConnectRunSeq;
|
||||
|
||||
function connectOnce(attempt) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let opened = false;
|
||||
const seq = ++activeSeq;
|
||||
log("gateway.socket.connecting", {
|
||||
attempt,
|
||||
maxAttempts,
|
||||
timeoutMs: timeout,
|
||||
host: payload.host,
|
||||
port: Number(payload.port) || 22,
|
||||
cols: Number(payload.cols) || 80,
|
||||
rows: Number(payload.rows) || 24,
|
||||
hasJumpHost: !!payload.jumpHost
|
||||
});
|
||||
|
||||
const previousSocket = socketTask;
|
||||
if (previousSocket) {
|
||||
releaseSocketTask(previousSocket);
|
||||
safeCloseSocketTask(previousSocket, "replace_before_connect");
|
||||
}
|
||||
|
||||
const rejectOnce = (message, retryable) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject({ message, retryable: !!retryable });
|
||||
};
|
||||
const resolveOnce = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve();
|
||||
};
|
||||
|
||||
const task = wx.connectSocket({ url, timeout });
|
||||
socketTask = task;
|
||||
|
||||
task.onOpen(() => {
|
||||
if (seq !== activeSeq || task !== socketTask) return;
|
||||
opened = true;
|
||||
socketReady = true;
|
||||
log("gateway.socket.open", { seq, attempt, maxAttempts });
|
||||
sendFrame({
|
||||
type: "init",
|
||||
payload: {
|
||||
host: payload.host,
|
||||
port: Number(payload.port) || 22,
|
||||
username: payload.username,
|
||||
...(payload.clientSessionKey ? { clientSessionKey: String(payload.clientSessionKey) } : {}),
|
||||
...(Number.isFinite(Number(payload.resumeGraceMs))
|
||||
? { resumeGraceMs: Math.round(Number(payload.resumeGraceMs)) }
|
||||
: {}),
|
||||
credential: payload.credential || { type: "password", password: "" },
|
||||
...(payload.jumpHost ? { jumpHost: payload.jumpHost } : {}),
|
||||
pty: {
|
||||
cols: Number(payload.cols) || 80,
|
||||
rows: Number(payload.rows) || 24
|
||||
}
|
||||
}
|
||||
});
|
||||
log("gateway.init.sent", {
|
||||
seq,
|
||||
attempt,
|
||||
cols: Number(payload.cols) || 80,
|
||||
rows: Number(payload.rows) || 24,
|
||||
hasJumpHost: !!payload.jumpHost,
|
||||
hasClientSessionKey: !!payload.clientSessionKey
|
||||
});
|
||||
startHeartbeat();
|
||||
resolveOnce();
|
||||
});
|
||||
|
||||
task.onMessage((event) => {
|
||||
if (seq !== activeSeq || task !== socketTask) return;
|
||||
try {
|
||||
const frame = JSON.parse(event.data || "{}");
|
||||
if (frame.type === "control" && frame.payload?.action === "ping") {
|
||||
sendFrame({ type: "control", payload: { action: "pong" } });
|
||||
return;
|
||||
}
|
||||
if (frame.type === "control" && frame.payload?.action === "pong") {
|
||||
if (typeof config.onLatency === "function" && pingAt > 0) {
|
||||
config.onLatency(Date.now() - pingAt);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof config.onFrame === "function") {
|
||||
config.onFrame(frame);
|
||||
}
|
||||
} catch (error) {
|
||||
if (typeof config.onError === "function") {
|
||||
config.onError(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
task.onClose((event) => {
|
||||
if (seq !== activeSeq || task !== socketTask) return;
|
||||
const code = event && typeof event.code === "number" ? event.code : "";
|
||||
const reason = event && event.reason ? String(event.reason) : "";
|
||||
releaseSocketTask(task);
|
||||
log("gateway.socket.close", {
|
||||
seq,
|
||||
attempt,
|
||||
opened,
|
||||
code,
|
||||
reason
|
||||
});
|
||||
if (!opened) {
|
||||
rejectOnce(
|
||||
`网关连接失败${code ? `(${code})` : ""}${reason ? `: ${reason}` : ""}`,
|
||||
isRetryablePreOpenClose(event)
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (typeof config.onClose === "function") {
|
||||
config.onClose(event);
|
||||
}
|
||||
});
|
||||
|
||||
task.onError((error) => {
|
||||
if (seq !== activeSeq || task !== socketTask) return;
|
||||
const detail = error && error.errMsg ? String(error.errMsg) : "";
|
||||
const message = detail ? `网关连接失败: ${detail}` : "网关连接失败";
|
||||
log("gateway.socket.error", {
|
||||
seq,
|
||||
attempt,
|
||||
errMsg: detail
|
||||
});
|
||||
if (!opened) {
|
||||
const retryable = isRetryableConnectFailure(detail);
|
||||
releaseSocketTask(task);
|
||||
safeCloseSocketTask(task, "connect_error");
|
||||
rejectOnce(message, retryable);
|
||||
return;
|
||||
}
|
||||
if (typeof config.onError === "function") {
|
||||
config.onError(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
connectPromise = (async () => {
|
||||
let lastMessage = "网关连接失败";
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
if (connectRunSeq !== activeConnectRunSeq) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await connectOnce(attempt);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (connectRunSeq !== activeConnectRunSeq) {
|
||||
return;
|
||||
}
|
||||
const detail = error && typeof error === "object" ? error : {};
|
||||
const message = detail.message ? String(detail.message) : "网关连接失败";
|
||||
lastMessage = message;
|
||||
const canRetry = attempt < maxAttempts && !!detail.retryable;
|
||||
log("gateway.socket.attempt_failed", {
|
||||
attempt,
|
||||
maxAttempts,
|
||||
retryable: !!detail.retryable,
|
||||
message
|
||||
});
|
||||
if (!canRetry) {
|
||||
throw new Error(message);
|
||||
}
|
||||
log("gateway.socket.retry_scheduled", {
|
||||
attempt,
|
||||
nextAttempt: attempt + 1,
|
||||
delayMs: connectRetryDelayMs,
|
||||
message
|
||||
});
|
||||
await wait(connectRetryDelayMs);
|
||||
}
|
||||
}
|
||||
throw new Error(lastMessage);
|
||||
})().finally(() => {
|
||||
connectPromise = null;
|
||||
});
|
||||
|
||||
return connectPromise;
|
||||
}
|
||||
|
||||
function sendStdin(text, meta) {
|
||||
const inputMeta = meta && typeof meta === "object" ? meta : {};
|
||||
sendFrame({
|
||||
type: "stdin",
|
||||
payload: {
|
||||
data: String(text || ""),
|
||||
meta: {
|
||||
source: inputMeta.source === "assist" ? "assist" : "keyboard",
|
||||
...(inputMeta.txnId ? { txnId: String(inputMeta.txnId) } : {})
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function resize(cols, rows) {
|
||||
sendFrame({ type: "resize", payload: { cols: Number(cols) || 80, rows: Number(rows) || 24 } });
|
||||
}
|
||||
|
||||
function disconnect(reason) {
|
||||
activeConnectRunSeq += 1;
|
||||
activeSeq += 1;
|
||||
sendFrame({ type: "control", payload: { action: "disconnect", reason: reason || "manual" } });
|
||||
socketReady = false;
|
||||
clearHeartbeat();
|
||||
if (socketTask) {
|
||||
socketTask.close({ code: 1000, reason: "manual" });
|
||||
socketTask = null;
|
||||
}
|
||||
connectPromise = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅关闭 WebSocket,不向服务端发送 disconnect 控制帧:
|
||||
* - 供终端页切后台/离开页面时使用;
|
||||
* - 网关会把 SSH 会话转入“驻留等待续接”窗口。
|
||||
*/
|
||||
function suspend(reason) {
|
||||
activeConnectRunSeq += 1;
|
||||
activeSeq += 1;
|
||||
socketReady = false;
|
||||
clearHeartbeat();
|
||||
if (socketTask) {
|
||||
try {
|
||||
socketTask.close({ code: 1000, reason: reason || "suspend" });
|
||||
} catch (error) {
|
||||
console.warn("[gateway.suspend]", error);
|
||||
}
|
||||
socketTask = null;
|
||||
}
|
||||
connectPromise = null;
|
||||
}
|
||||
|
||||
return {
|
||||
connect,
|
||||
sendStdin,
|
||||
resize,
|
||||
sampleLatency,
|
||||
setLatencySampleInterval,
|
||||
disconnect,
|
||||
suspend
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createGatewayClient,
|
||||
buildWsUrl
|
||||
};
|
||||
214
apps/miniprogram/utils/gateway.test.ts
Normal file
214
apps/miniprogram/utils/gateway.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { createGatewayClient } = require("./gateway.js");
|
||||
|
||||
function createMockSocketTask() {
|
||||
const handlers = {
|
||||
open: null,
|
||||
message: null,
|
||||
close: null,
|
||||
error: null
|
||||
} as {
|
||||
open: null | (() => void);
|
||||
message: null | ((event: { data: string }) => void);
|
||||
close: null | ((event?: { code?: number; reason?: string }) => void);
|
||||
error: null | ((error?: { errMsg?: string }) => void);
|
||||
};
|
||||
|
||||
const task = {
|
||||
send: vi.fn(),
|
||||
close: vi.fn(),
|
||||
onOpen(callback: () => void) {
|
||||
handlers.open = callback;
|
||||
},
|
||||
onMessage(callback: (event: { data: string }) => void) {
|
||||
handlers.message = callback;
|
||||
},
|
||||
onClose(callback: (event?: { code?: number; reason?: string }) => void) {
|
||||
handlers.close = callback;
|
||||
},
|
||||
onError(callback: (error?: { errMsg?: string }) => void) {
|
||||
handlers.error = callback;
|
||||
}
|
||||
};
|
||||
|
||||
return { handlers, task };
|
||||
}
|
||||
|
||||
describe("gateway", () => {
|
||||
afterEach(() => {
|
||||
const runtime = globalThis as typeof globalThis & { wx?: unknown };
|
||||
delete runtime.wx;
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("收到 connected 后可立刻补发一拍时延采样", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { handlers, task } = createMockSocketTask();
|
||||
const runtime = globalThis as typeof globalThis & { wx?: { connectSocket: ReturnType<typeof vi.fn> } };
|
||||
runtime.wx = {
|
||||
connectSocket: vi.fn(() => task)
|
||||
};
|
||||
const onLatency = vi.fn();
|
||||
const client = createGatewayClient({
|
||||
gatewayUrl: "wss://conn.biboer.cn",
|
||||
gatewayToken: "demo-token",
|
||||
onLatency
|
||||
});
|
||||
|
||||
const connectPromise = client.connect({
|
||||
host: "example.com",
|
||||
port: 22,
|
||||
username: "root",
|
||||
credential: { type: "password", password: "secret" }
|
||||
});
|
||||
|
||||
handlers.open?.();
|
||||
await connectPromise;
|
||||
|
||||
expect(task.send).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(task.send.mock.calls[0][0].data)).toMatchObject({
|
||||
type: "init"
|
||||
});
|
||||
|
||||
client.sampleLatency();
|
||||
|
||||
expect(task.send).toHaveBeenCalledTimes(2);
|
||||
expect(JSON.parse(task.send.mock.calls[1][0].data)).toEqual({
|
||||
type: "control",
|
||||
payload: { action: "ping" }
|
||||
});
|
||||
|
||||
handlers.message?.({
|
||||
data: JSON.stringify({
|
||||
type: "control",
|
||||
payload: { action: "pong" }
|
||||
})
|
||||
});
|
||||
|
||||
expect(onLatency).toHaveBeenCalledTimes(1);
|
||||
expect(onLatency.mock.calls[0][0]).toBeGreaterThanOrEqual(0);
|
||||
|
||||
client.disconnect("test");
|
||||
});
|
||||
|
||||
it("支持按需切换时延采样心跳间隔", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { handlers, task } = createMockSocketTask();
|
||||
const runtime = globalThis as typeof globalThis & { wx?: { connectSocket: ReturnType<typeof vi.fn> } };
|
||||
runtime.wx = {
|
||||
connectSocket: vi.fn(() => task)
|
||||
};
|
||||
const client = createGatewayClient({
|
||||
gatewayUrl: "wss://conn.biboer.cn",
|
||||
gatewayToken: "demo-token"
|
||||
});
|
||||
|
||||
const connectPromise = client.connect({
|
||||
host: "example.com",
|
||||
port: 22,
|
||||
username: "root",
|
||||
credential: { type: "password", password: "secret" }
|
||||
});
|
||||
|
||||
handlers.open?.();
|
||||
await connectPromise;
|
||||
|
||||
expect(task.send).toHaveBeenCalledTimes(1);
|
||||
vi.advanceTimersByTime(9999);
|
||||
expect(task.send).toHaveBeenCalledTimes(1);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(JSON.parse(task.send.mock.calls[1][0].data)).toEqual({
|
||||
type: "control",
|
||||
payload: { action: "ping" }
|
||||
});
|
||||
|
||||
client.setLatencySampleInterval(3000);
|
||||
vi.advanceTimersByTime(2999);
|
||||
expect(task.send).toHaveBeenCalledTimes(2);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(JSON.parse(task.send.mock.calls[2][0].data)).toEqual({
|
||||
type: "control",
|
||||
payload: { action: "ping" }
|
||||
});
|
||||
|
||||
client.setLatencySampleInterval(10000);
|
||||
vi.advanceTimersByTime(3000);
|
||||
expect(task.send).toHaveBeenCalledTimes(3);
|
||||
vi.advanceTimersByTime(7000);
|
||||
expect(JSON.parse(task.send.mock.calls[3][0].data)).toEqual({
|
||||
type: "control",
|
||||
payload: { action: "ping" }
|
||||
});
|
||||
|
||||
client.disconnect("test");
|
||||
});
|
||||
|
||||
it("首连遇到 connection refused 时会自动重试一次", async () => {
|
||||
vi.useFakeTimers();
|
||||
const first = createMockSocketTask();
|
||||
const second = createMockSocketTask();
|
||||
const runtime = globalThis as typeof globalThis & { wx?: { connectSocket: ReturnType<typeof vi.fn> } };
|
||||
runtime.wx = {
|
||||
connectSocket: vi.fn().mockReturnValueOnce(first.task).mockReturnValueOnce(second.task)
|
||||
};
|
||||
const onError = vi.fn();
|
||||
const client = createGatewayClient({
|
||||
gatewayUrl: "wss://conn.biboer.cn",
|
||||
gatewayToken: "demo-token",
|
||||
onError
|
||||
});
|
||||
|
||||
const connectPromise = client.connect({
|
||||
host: "example.com",
|
||||
port: 22,
|
||||
username: "root",
|
||||
credential: { type: "password", password: "secret" }
|
||||
});
|
||||
|
||||
first.handlers.error?.({
|
||||
errMsg: "connectSocket:fail createWebSocketTask:fail connection refused"
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
second.handlers.open?.();
|
||||
|
||||
await expect(connectPromise).resolves.toBeUndefined();
|
||||
expect(runtime.wx.connectSocket).toHaveBeenCalledTimes(2);
|
||||
expect(first.task.close).toHaveBeenCalledTimes(1);
|
||||
expect(second.task.send).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(second.task.send.mock.calls[0][0].data)).toMatchObject({
|
||||
type: "init"
|
||||
});
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
|
||||
client.disconnect("test");
|
||||
});
|
||||
|
||||
it("域名白名单错误不会进入自动重试", async () => {
|
||||
vi.useFakeTimers();
|
||||
const first = createMockSocketTask();
|
||||
const runtime = globalThis as typeof globalThis & { wx?: { connectSocket: ReturnType<typeof vi.fn> } };
|
||||
runtime.wx = {
|
||||
connectSocket: vi.fn(() => first.task)
|
||||
};
|
||||
const client = createGatewayClient({
|
||||
gatewayUrl: "wss://conn.biboer.cn",
|
||||
gatewayToken: "demo-token"
|
||||
});
|
||||
|
||||
const connectPromise = client.connect({
|
||||
host: "example.com",
|
||||
port: 22,
|
||||
username: "root",
|
||||
credential: { type: "password", password: "secret" }
|
||||
});
|
||||
|
||||
first.handlers.error?.({
|
||||
errMsg: "connectSocket:fail Error: url not in domain list"
|
||||
});
|
||||
|
||||
await expect(connectPromise).rejects.toThrow("url not in domain list");
|
||||
expect(runtime.wx.connectSocket).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
440
apps/miniprogram/utils/i18n.js
Normal file
440
apps/miniprogram/utils/i18n.js
Normal file
@@ -0,0 +1,440 @@
|
||||
/* global module, require */
|
||||
|
||||
const { I18N_CATALOG } = require("./i18nCatalog");
|
||||
|
||||
const DEFAULT_UI_LANGUAGE = "zh-Hans";
|
||||
const UI_LANGUAGE_VALUES = Object.freeze(["zh-Hans", "zh-Hant", "en", "ja", "ko"]);
|
||||
const UI_LANGUAGE_VALUE_SET = new Set(UI_LANGUAGE_VALUES);
|
||||
const UI_LANGUAGE_FALLBACKS = Object.freeze({
|
||||
ja: "en",
|
||||
ko: "en"
|
||||
});
|
||||
/**
|
||||
* 主题内部值顺序与 shared/Web 保持一致:
|
||||
* 1. 设置页下拉顺序直接复用这份稳定数组;
|
||||
* 2. 已重命名的老主题继续保留多语言展示名;
|
||||
* 3. 新增主题若未提供本地化名,则回退内部值显示,和 Web 保持同口径。
|
||||
*/
|
||||
const THEME_PRESET_VALUES = Object.freeze([
|
||||
"tide",
|
||||
"暮砂",
|
||||
"霓潮",
|
||||
"苔暮",
|
||||
"焰岩",
|
||||
"岩陶",
|
||||
"靛雾",
|
||||
"绛霓",
|
||||
"玫蓝",
|
||||
"珊湾",
|
||||
"苔荧",
|
||||
"铜暮",
|
||||
"炽潮",
|
||||
"藕夜",
|
||||
"沙海",
|
||||
"珀岚",
|
||||
"炫虹",
|
||||
"鎏霓",
|
||||
"珊汐",
|
||||
"黛苔",
|
||||
"霜绯"
|
||||
]);
|
||||
const THEME_PRESET_LABELS = Object.freeze({
|
||||
"zh-Hans": Object.freeze({
|
||||
tide: "潮汐",
|
||||
暮砂: "沙丘",
|
||||
霓潮: "棱光",
|
||||
苔暮: "苔影",
|
||||
焰岩: "余烬",
|
||||
岩陶: "陶土",
|
||||
靛雾: "岚雾",
|
||||
绛霓: "绛霓",
|
||||
玫蓝: "玫蓝",
|
||||
珊湾: "珊湾",
|
||||
苔荧: "苔荧",
|
||||
铜暮: "铜暮",
|
||||
炽潮: "炽潮",
|
||||
藕夜: "藕夜",
|
||||
沙海: "沙海",
|
||||
珀岚: "珀岚",
|
||||
炫虹: "炫虹",
|
||||
鎏霓: "鎏霓",
|
||||
珊汐: "珊汐",
|
||||
黛苔: "黛苔",
|
||||
霜绯: "霜绯"
|
||||
}),
|
||||
"zh-Hant": Object.freeze({
|
||||
tide: "潮汐",
|
||||
暮砂: "沙丘",
|
||||
霓潮: "稜光",
|
||||
苔暮: "苔影",
|
||||
焰岩: "餘燼",
|
||||
岩陶: "陶土",
|
||||
靛雾: "嵐霧",
|
||||
绛霓: "絳霓",
|
||||
玫蓝: "玫藍",
|
||||
珊湾: "珊灣",
|
||||
苔荧: "苔螢",
|
||||
铜暮: "銅暮",
|
||||
炽潮: "熾潮",
|
||||
藕夜: "藕夜",
|
||||
沙海: "沙海",
|
||||
珀岚: "珀嵐",
|
||||
炫虹: "炫虹",
|
||||
鎏霓: "鎏霓",
|
||||
珊汐: "珊汐",
|
||||
黛苔: "黛苔",
|
||||
霜绯: "霜緋"
|
||||
}),
|
||||
en: Object.freeze({
|
||||
tide: "Tide",
|
||||
暮砂: "Dune",
|
||||
霓潮: "Prism",
|
||||
苔暮: "Moss",
|
||||
焰岩: "Ember",
|
||||
岩陶: "Clay",
|
||||
靛雾: "Haze",
|
||||
绛霓: "Crimson",
|
||||
玫蓝: "Rose",
|
||||
珊湾: "Bay",
|
||||
苔荧: "Glow",
|
||||
铜暮: "Copper",
|
||||
炽潮: "Blaze",
|
||||
藕夜: "Lotus",
|
||||
沙海: "Sandsea",
|
||||
珀岚: "Amber",
|
||||
炫虹: "Neon",
|
||||
鎏霓: "Gilded",
|
||||
珊汐: "Coral",
|
||||
黛苔: "Slate",
|
||||
霜绯: "Frost"
|
||||
}),
|
||||
ja: Object.freeze({
|
||||
tide: "潮汐",
|
||||
暮砂: "砂丘",
|
||||
霓潮: "プリズム",
|
||||
苔暮: "苔影",
|
||||
焰岩: "残り火",
|
||||
岩陶: "陶土",
|
||||
靛雾: "藍霧",
|
||||
绛霓: "深紅",
|
||||
玫蓝: "薔薇",
|
||||
珊湾: "珊湾",
|
||||
苔荧: "苔光",
|
||||
铜暮: "銅暮",
|
||||
炽潮: "炎潮",
|
||||
藕夜: "蓮夜",
|
||||
沙海: "砂海",
|
||||
珀岚: "琥珀",
|
||||
炫虹: "ネオン",
|
||||
鎏霓: "金彩",
|
||||
珊汐: "珊汐",
|
||||
黛苔: "黛苔",
|
||||
霜绯: "霜緋"
|
||||
}),
|
||||
ko: Object.freeze({
|
||||
tide: "조류",
|
||||
暮砂: "사구",
|
||||
霓潮: "프리즘",
|
||||
苔暮: "이끼 그림자",
|
||||
焰岩: "잿불",
|
||||
岩陶: "도토",
|
||||
靛雾: "남빛 안개",
|
||||
绛霓: "크림슨",
|
||||
玫蓝: "로즈",
|
||||
珊湾: "코럴",
|
||||
苔荧: "글로우",
|
||||
铜暮: "코퍼",
|
||||
炽潮: "블레이즈",
|
||||
藕夜: "로터스",
|
||||
沙海: "샌드씨",
|
||||
珀岚: "앰버",
|
||||
炫虹: "네온",
|
||||
鎏霓: "길디드",
|
||||
珊汐: "산호",
|
||||
黛苔: "슬레이트",
|
||||
霜绯: "프로스트"
|
||||
})
|
||||
});
|
||||
|
||||
function normalizeUiLanguage(value) {
|
||||
const normalized = String(value || "").trim();
|
||||
return UI_LANGUAGE_VALUE_SET.has(normalized) ? normalized : DEFAULT_UI_LANGUAGE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增语种先走英文基线兜底:
|
||||
* 1. 保证所有现有页面能稳定显示,不会回落成简体中文;
|
||||
* 2. 后续补专门词典时,只需在 catalog 中加同名语言节点即可覆盖。
|
||||
*/
|
||||
function resolveCatalogLanguage(language) {
|
||||
const normalized = normalizeUiLanguage(language);
|
||||
return I18N_CATALOG[normalized] ? normalized : UI_LANGUAGE_FALLBACKS[normalized] || DEFAULT_UI_LANGUAGE;
|
||||
}
|
||||
|
||||
function resolveCatalog(language) {
|
||||
return I18N_CATALOG[resolveCatalogLanguage(language)] || I18N_CATALOG[DEFAULT_UI_LANGUAGE];
|
||||
}
|
||||
|
||||
function deepClone(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => deepClone(item));
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const next = {};
|
||||
Object.keys(value).forEach((key) => {
|
||||
next[key] = deepClone(value[key]);
|
||||
});
|
||||
return next;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readByPath(source, path) {
|
||||
if (!source || typeof source !== "object") return undefined;
|
||||
const segments = String(path || "")
|
||||
.split(".")
|
||||
.filter((item) => !!item);
|
||||
let current = source;
|
||||
for (let i = 0; i < segments.length; i += 1) {
|
||||
const key = segments[i];
|
||||
if (!current || typeof current !== "object" || !(key in current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function formatTemplate(template, params) {
|
||||
const source = params && typeof params === "object" ? params : {};
|
||||
return String(template || "").replace(/\{([a-zA-Z0-9_]+)\}/g, (_match, key) => {
|
||||
if (!(key in source)) return "";
|
||||
return String(source[key] == null ? "" : source[key]);
|
||||
});
|
||||
}
|
||||
|
||||
function t(language, key, params) {
|
||||
const value = readByPath(resolveCatalog(language), key);
|
||||
if (typeof value !== "string") {
|
||||
return key ? String(key) : "";
|
||||
}
|
||||
return params ? formatTemplate(value, params) : value;
|
||||
}
|
||||
|
||||
function buildPageCopy(language, namespace) {
|
||||
const value = readByPath(resolveCatalog(language), namespace);
|
||||
return value && typeof value === "object" ? deepClone(value) : {};
|
||||
}
|
||||
|
||||
function getStatusLabel(language, status) {
|
||||
const normalized = String(status || "").trim();
|
||||
return t(language, `common.statusLabels.${normalized}`) || normalized;
|
||||
}
|
||||
|
||||
function getRuntimeStateLabel(language, status) {
|
||||
const normalized = String(status || "").trim();
|
||||
return t(language, `common.runtimeStateLabels.${normalized}`) || normalized;
|
||||
}
|
||||
|
||||
function getUiLanguageOptions() {
|
||||
return [
|
||||
{ label: "简体中文", value: "zh-Hans" },
|
||||
{ label: "繁體中文", value: "zh-Hant" },
|
||||
{ label: "English", value: "en" },
|
||||
{ label: "日本語", value: "ja" },
|
||||
{ label: "한국어", value: "ko" }
|
||||
];
|
||||
}
|
||||
|
||||
function buildThemeModeOptions(language) {
|
||||
return [
|
||||
{ label: t(language, "settings.options.themeMode.dark"), value: "dark" },
|
||||
{ label: t(language, "settings.options.themeMode.light"), value: "light" }
|
||||
];
|
||||
}
|
||||
|
||||
function buildSettingsAuthTypeOptions(language) {
|
||||
return [
|
||||
{ label: t(language, "settings.options.authType.password"), value: "password" },
|
||||
{ label: t(language, "settings.options.authType.key"), value: "key" }
|
||||
];
|
||||
}
|
||||
|
||||
function buildAiCodexSandboxOptions(language) {
|
||||
return [
|
||||
{ label: t(language, "settings.options.aiCodexSandboxMode.readOnly"), value: "read-only" },
|
||||
{ label: t(language, "settings.options.aiCodexSandboxMode.workspaceWrite"), value: "workspace-write" },
|
||||
{
|
||||
label: t(language, "settings.options.aiCodexSandboxMode.dangerFullAccess"),
|
||||
value: "danger-full-access"
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function buildAiProviderOptions(language) {
|
||||
return [
|
||||
{ label: t(language, "settings.options.aiDefaultProvider.codex"), value: "codex" },
|
||||
{ label: t(language, "settings.options.aiDefaultProvider.copilot"), value: "copilot" }
|
||||
];
|
||||
}
|
||||
|
||||
function buildAiCopilotPermissionOptions(language) {
|
||||
return [
|
||||
{ label: t(language, "settings.options.aiCopilotPermissionMode.default"), value: "default" },
|
||||
{
|
||||
label: t(language, "settings.options.aiCopilotPermissionMode.experimental"),
|
||||
value: "experimental"
|
||||
},
|
||||
{ label: t(language, "settings.options.aiCopilotPermissionMode.allowAll"), value: "allow-all" }
|
||||
];
|
||||
}
|
||||
|
||||
function buildServerAuthTypeOptions(language) {
|
||||
return [
|
||||
{ label: t(language, "serverSettings.options.authType.password"), value: "password" },
|
||||
{ label: t(language, "serverSettings.options.authType.privateKey"), value: "privateKey" },
|
||||
{ label: t(language, "serverSettings.options.authType.certificate"), value: "certificate" }
|
||||
];
|
||||
}
|
||||
|
||||
function buildSettingsTabs(language) {
|
||||
return [
|
||||
{ id: "ui", label: t(language, "settings.tabs.ui") },
|
||||
{ id: "shell", label: t(language, "settings.tabs.shell") },
|
||||
{ id: "connection", label: t(language, "settings.tabs.connection") },
|
||||
{ id: "log", label: t(language, "settings.tabs.log") }
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 主题预设内部值保持稳定,界面层再按语言映射为用户可读展示名。
|
||||
*/
|
||||
function getThemePresetLabel(language, value) {
|
||||
const normalizedLanguage = normalizeUiLanguage(language);
|
||||
return THEME_PRESET_LABELS[normalizedLanguage]?.[value] || String(value || "");
|
||||
}
|
||||
|
||||
function buildThemePresetOptions(language) {
|
||||
return THEME_PRESET_VALUES.map((value) => ({
|
||||
value,
|
||||
label: getThemePresetLabel(language, value)
|
||||
}));
|
||||
}
|
||||
|
||||
function localizeServerValidationMessage(language, message) {
|
||||
const raw = String(message || "").trim();
|
||||
const localizedLanguage = resolveCatalogLanguage(language);
|
||||
if (!raw) return raw;
|
||||
const map = {
|
||||
主机不能为空: {
|
||||
"zh-Hant": "主機不能為空",
|
||||
en: "Host is required",
|
||||
ja: "ホストは必須です",
|
||||
ko: "호스트는 필수입니다"
|
||||
},
|
||||
用户名不能为空: {
|
||||
"zh-Hant": "使用者名稱不能為空",
|
||||
en: "Username is required",
|
||||
ja: "ユーザー名は必須です",
|
||||
ko: "사용자명은 필수입니다"
|
||||
},
|
||||
密码不能为空: {
|
||||
"zh-Hant": "密碼不能為空",
|
||||
en: "Password is required",
|
||||
ja: "パスワードは必須です",
|
||||
ko: "비밀번호는 필수입니다"
|
||||
},
|
||||
私钥内容不能为空: {
|
||||
"zh-Hant": "私鑰內容不能為空",
|
||||
en: "Private key is required",
|
||||
ja: "秘密鍵は必須です",
|
||||
ko: "개인 키는 필수입니다"
|
||||
},
|
||||
证书模式下私钥不能为空: {
|
||||
"zh-Hant": "憑證模式下私鑰不能為空",
|
||||
en: "Private key is required for certificate mode",
|
||||
ja: "証明書モードでは秘密鍵が必須です",
|
||||
ko: "인증서 모드에서는 개인 키가 필요합니다"
|
||||
},
|
||||
证书模式下证书不能为空: {
|
||||
"zh-Hant": "憑證模式下憑證不能為空",
|
||||
en: "Certificate is required for certificate mode",
|
||||
ja: "証明書モードでは証明書が必須です",
|
||||
ko: "인증서 모드에서는 인증서가 필요합니다"
|
||||
},
|
||||
跳板机主机不能为空: {
|
||||
"zh-Hant": "跳板機主機不能為空",
|
||||
en: "Jump host is required",
|
||||
ja: "踏み台ホストは必須です",
|
||||
ko: "점프 호스트는 필수입니다"
|
||||
},
|
||||
跳板机用户名不能为空: {
|
||||
"zh-Hant": "跳板機使用者名稱不能為空",
|
||||
en: "Jump host username is required",
|
||||
ja: "踏み台ユーザー名は必須です",
|
||||
ko: "점프 호스트 사용자명은 필수입니다"
|
||||
},
|
||||
跳板机密码不能为空: {
|
||||
"zh-Hant": "跳板機密碼不能為空",
|
||||
en: "Jump host password is required",
|
||||
ja: "踏み台パスワードは必須です",
|
||||
ko: "점프 호스트 비밀번호는 필수입니다"
|
||||
},
|
||||
跳板机私钥不能为空: {
|
||||
"zh-Hant": "跳板機私鑰不能為空",
|
||||
en: "Jump host private key is required",
|
||||
ja: "踏み台秘密鍵は必須です",
|
||||
ko: "점프 호스트 개인 키는 필수입니다"
|
||||
},
|
||||
跳板机证书模式下私钥不能为空: {
|
||||
"zh-Hant": "跳板機憑證模式下私鑰不能為空",
|
||||
en: "Jump host private key is required for certificate mode",
|
||||
ja: "踏み台の証明書モードでは秘密鍵が必須です",
|
||||
ko: "점프 호스트 인증서 모드에서는 개인 키가 필요합니다"
|
||||
},
|
||||
跳板机证书模式下证书不能为空: {
|
||||
"zh-Hant": "跳板機憑證模式下憑證不能為空",
|
||||
en: "Jump host certificate is required for certificate mode",
|
||||
ja: "踏み台の証明書モードでは証明書が必須です",
|
||||
ko: "점프 호스트 인증서 모드에서는 인증서가 필요합니다"
|
||||
}
|
||||
};
|
||||
if (map[raw] && map[raw][localizedLanguage]) {
|
||||
return map[raw][localizedLanguage];
|
||||
}
|
||||
if (/^SSH 端口需为 1-65535 的整数$/.test(raw)) {
|
||||
if (localizedLanguage === "zh-Hant") return "SSH 埠需為 1-65535 的整數";
|
||||
if (localizedLanguage === "en") return "SSH port must be an integer between 1 and 65535";
|
||||
if (localizedLanguage === "ja") return "SSH ポートは 1〜65535 の整数で入力してください";
|
||||
if (localizedLanguage === "ko") return "SSH 포트는 1~65535 사이 정수여야 합니다";
|
||||
}
|
||||
if (/^跳板机端口需为 1-65535 的整数$/.test(raw)) {
|
||||
if (localizedLanguage === "zh-Hant") return "跳板機埠需為 1-65535 的整數";
|
||||
if (localizedLanguage === "en") return "Jump host port must be an integer between 1 and 65535";
|
||||
if (localizedLanguage === "ja") return "踏み台ポートは 1〜65535 の整数で入力してください";
|
||||
if (localizedLanguage === "ko") return "점프 호스트 포트는 1~65535 사이 정수여야 합니다";
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_UI_LANGUAGE,
|
||||
UI_LANGUAGE_VALUES,
|
||||
buildAiCodexSandboxOptions,
|
||||
buildAiCopilotPermissionOptions,
|
||||
buildAiProviderOptions,
|
||||
buildPageCopy,
|
||||
buildServerAuthTypeOptions,
|
||||
buildSettingsAuthTypeOptions,
|
||||
buildSettingsTabs,
|
||||
buildThemeModeOptions,
|
||||
buildThemePresetOptions,
|
||||
formatTemplate,
|
||||
getThemePresetLabel,
|
||||
getRuntimeStateLabel,
|
||||
getStatusLabel,
|
||||
getUiLanguageOptions,
|
||||
localizeServerValidationMessage,
|
||||
normalizeUiLanguage,
|
||||
t
|
||||
};
|
||||
88
apps/miniprogram/utils/i18n.test.ts
Normal file
88
apps/miniprogram/utils/i18n.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const {
|
||||
buildPageCopy,
|
||||
buildThemeModeOptions,
|
||||
buildThemePresetOptions,
|
||||
formatTemplate,
|
||||
getThemePresetLabel,
|
||||
getStatusLabel,
|
||||
getUiLanguageOptions,
|
||||
localizeServerValidationMessage,
|
||||
normalizeUiLanguage,
|
||||
t
|
||||
} = require("./i18n.js");
|
||||
|
||||
describe("miniprogram i18n", () => {
|
||||
it("会把非法界面语言回退到简体中文", () => {
|
||||
expect(normalizeUiLanguage("zh-Hant")).toBe("zh-Hant");
|
||||
expect(normalizeUiLanguage("en")).toBe("en");
|
||||
expect(normalizeUiLanguage("ja")).toBe("ja");
|
||||
expect(normalizeUiLanguage("ko")).toBe("ko");
|
||||
expect(normalizeUiLanguage("unknown")).toBe("zh-Hans");
|
||||
});
|
||||
|
||||
it("可构建设置页主题模式和状态文案", () => {
|
||||
expect(buildThemeModeOptions("en")).toEqual([
|
||||
{ label: "Dark", value: "dark" },
|
||||
{ label: "Light", value: "light" }
|
||||
]);
|
||||
expect(getStatusLabel("zh-Hant", "connected")).toBe("已連線");
|
||||
});
|
||||
|
||||
it("会按界面语言输出主题展示名", () => {
|
||||
expect(getThemePresetLabel("zh-Hans", "tide")).toBe("潮汐");
|
||||
expect(getThemePresetLabel("zh-Hant", "霓潮")).toBe("稜光");
|
||||
expect(getThemePresetLabel("en", "焰岩")).toBe("Ember");
|
||||
expect(buildThemePresetOptions("zh-Hans").slice(0, 7)).toEqual([
|
||||
{ label: "潮汐", value: "tide" },
|
||||
{ label: "沙丘", value: "暮砂" },
|
||||
{ label: "棱光", value: "霓潮" },
|
||||
{ label: "苔影", value: "苔暮" },
|
||||
{ label: "余烬", value: "焰岩" },
|
||||
{ label: "陶土", value: "岩陶" },
|
||||
{ label: "岚雾", value: "靛雾" }
|
||||
]);
|
||||
expect(buildThemePresetOptions("zh-Hans")).toHaveLength(21);
|
||||
expect(buildThemePresetOptions("zh-Hans")[20]).toEqual({ label: "霜绯", value: "霜绯" });
|
||||
expect(getThemePresetLabel("en", "绛霓")).toBe("Crimson");
|
||||
expect(getThemePresetLabel("ja", "珀岚")).toBe("琥珀");
|
||||
expect(getThemePresetLabel("ko", "霜绯")).toBe("프로스트");
|
||||
expect(buildThemePresetOptions("en")[0]).toEqual({ label: "Tide", value: "tide" });
|
||||
});
|
||||
|
||||
it("支持模板替换和页面 copy 读取", () => {
|
||||
expect(formatTemplate("Hello {name}", { name: "RemoteConn" })).toBe("Hello RemoteConn");
|
||||
expect(buildPageCopy("en", "connect").pageTitle).toBe("My Servers");
|
||||
expect(buildPageCopy("ja", "connect").pageTitle).toBe("サーバー一覧");
|
||||
expect(buildPageCopy("ko", "settings").navTitle).toBe("설정");
|
||||
expect(buildPageCopy("zh-Hans", "records").addButton).toBe("新增");
|
||||
expect(buildPageCopy("en", "records").newRecordHint).toBe("Type to autosave as a new note");
|
||||
expect(buildPageCopy("zh-Hans", "settings").hints.shellActivationDebugOutline).toContain("软键盘");
|
||||
expect(buildPageCopy("zh-Hans", "settings").fields.showVoiceInputButton).toBe("语音输入按钮");
|
||||
expect(t("ja", "bottomNav.backText")).toBe("戻る");
|
||||
expect(t("ko", "bottomNav.backText")).toBe("뒤로");
|
||||
expect(t("en", "bottomNav.backText")).toBe("Back");
|
||||
});
|
||||
|
||||
it("会本地化服务器校验消息", () => {
|
||||
expect(localizeServerValidationMessage("en", "主机不能为空")).toBe("Host is required");
|
||||
expect(localizeServerValidationMessage("ja", "主机不能为空")).toBe("ホストは必須です");
|
||||
expect(localizeServerValidationMessage("ko", "跳板机端口需为 1-65535 的整数")).toBe(
|
||||
"점프 호스트 포트는 1~65535 사이 정수여야 합니다"
|
||||
);
|
||||
expect(localizeServerValidationMessage("zh-Hant", "SSH 端口需为 1-65535 的整数")).toBe(
|
||||
"SSH 埠需為 1-65535 的整數"
|
||||
);
|
||||
});
|
||||
|
||||
it("界面语言选项保持稳定顺序", () => {
|
||||
expect(getUiLanguageOptions()).toEqual([
|
||||
{ label: "简体中文", value: "zh-Hans" },
|
||||
{ label: "繁體中文", value: "zh-Hant" },
|
||||
{ label: "English", value: "en" },
|
||||
{ label: "日本語", value: "ja" },
|
||||
{ label: "한국어", value: "ko" }
|
||||
]);
|
||||
});
|
||||
});
|
||||
1540
apps/miniprogram/utils/i18nCatalog.js
Normal file
1540
apps/miniprogram/utils/i18nCatalog.js
Normal file
File diff suppressed because it is too large
Load Diff
913
apps/miniprogram/utils/i18nCatalogLocaleOverlays.js
Normal file
913
apps/miniprogram/utils/i18nCatalogLocaleOverlays.js
Normal file
@@ -0,0 +1,913 @@
|
||||
/* global module */
|
||||
|
||||
/**
|
||||
* 日文、韩文词典覆盖层:
|
||||
* 1. 以英文词典为稳定基线,仅覆盖本轮明确要求翻译的页面;
|
||||
* 2. `logs / records` 保持英文回退,不在这里重复维护;
|
||||
* 3. 底部导航属于全局 UI,因此即使指向日志/闪念页,其标签也一并本地化。
|
||||
*/
|
||||
|
||||
const JA_I18N_OVERLAY = {
|
||||
common: {
|
||||
statusLabels: {
|
||||
idle: "待機中",
|
||||
connecting: "接続中",
|
||||
auth_pending: "認証中",
|
||||
connected: "接続済み",
|
||||
reconnecting: "再接続中",
|
||||
disconnected: "切断済み",
|
||||
error: "エラー",
|
||||
config_required: "設定不足"
|
||||
},
|
||||
runtimeStateLabels: {
|
||||
connected: "接続済み",
|
||||
disconnected: "未接続"
|
||||
},
|
||||
pageIndicator: "{page} / {total} ページ"
|
||||
},
|
||||
bottomNav: {
|
||||
backText: "戻る",
|
||||
pageTextLabels: {
|
||||
"open-terminal-shell": "端末",
|
||||
"/pages/terminal/index": "端末",
|
||||
"/pages/connect/index": "サーバ",
|
||||
"/pages/logs/index": "ログ",
|
||||
"/pages/records/index": "メモ",
|
||||
"/pages/settings/index": "設定",
|
||||
"/pages/about/index": "案内",
|
||||
"/pages/plugins/index": "プラグ",
|
||||
default: "ページ"
|
||||
},
|
||||
modal: {
|
||||
noTerminalTitle: "開ける端末がありません",
|
||||
noTerminalContent: "まずサーバーカードからセッションを開始してから、端末ページを開いてください。"
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
navTitle: "設定",
|
||||
saveStatus: {
|
||||
synced: "ローカル設定を同期しました",
|
||||
saving: "自動保存中...",
|
||||
saved: "自動保存しました"
|
||||
},
|
||||
tabs: {
|
||||
ui: "画面",
|
||||
shell: "端末",
|
||||
connection: "接続",
|
||||
log: "記録"
|
||||
},
|
||||
sections: {
|
||||
languageTitle: "言語",
|
||||
languageDesc: "ミニプログラムの表示言語を切り替えます",
|
||||
uiTitle: "画面設定",
|
||||
uiDesc: "アプリの外観とテーマモード",
|
||||
shellDisplayTitle: "表示設定",
|
||||
shellDisplayDesc: "端末表示と入力体験",
|
||||
ttsTitle: "読み上げ設定",
|
||||
ttsDesc: "単位は文字数です。既定の総量上限は 500、分割長は 80 です。",
|
||||
shellBufferTitle: "端末バッファ",
|
||||
shellBufferDesc: "ライブバッファと再開スナップショットの上限を調整し、メモリ使用量を抑えます",
|
||||
connectionTitle: "接続設定",
|
||||
connectionDesc: "接続挙動とサーバー既定値",
|
||||
aiConnectionTitle: "AI 接続",
|
||||
aiConnectionDesc: "Codex と Copilot の既定起動権限を設定します",
|
||||
syncTitle: "同期設定",
|
||||
syncDesc: "設定データをクラウドに同期するかを制御します",
|
||||
recordTitle: "記録設定",
|
||||
recordDesc: "メモとログの保持期間を制御します",
|
||||
voiceCategoryTitle: "メモ分類",
|
||||
voiceCategoryDesc: "メモ分類を管理します"
|
||||
},
|
||||
fields: {
|
||||
themeMode: "モード",
|
||||
themePreset: "テーマ",
|
||||
uiLanguage: "表示言語",
|
||||
uiAccentColor: "アクセント",
|
||||
uiBgColor: "背景",
|
||||
uiTextColor: "文字",
|
||||
uiBtnColor: "ボタン",
|
||||
shellBgColor: "端末背景",
|
||||
shellTextColor: "端末前景",
|
||||
shellAccentColor: "カーソル強調色",
|
||||
shellFontFamily: "フォント",
|
||||
shellFontSize: "文字サイズ",
|
||||
shellLineHeight: "行間",
|
||||
unicode11: "全角文字サポート",
|
||||
shellActivationDebugOutline: "入力エリア枠線",
|
||||
showVoiceInputButton: "音声入力ボタン",
|
||||
ttsSpeakableMaxChars: "総文字数上限",
|
||||
ttsSegmentMaxChars: "分割長",
|
||||
shellBufferMaxEntries: "最大バッファ行数",
|
||||
shellBufferMaxBytes: "最大バッファバイト数",
|
||||
shellBufferSnapshotMaxLines: "再開スナップショット行数",
|
||||
autoReconnect: "自動再接続",
|
||||
reconnectLimit: "再接続回数上限",
|
||||
backgroundSessionKeepAliveMinutes: "バックグラウンド保持時間(分)",
|
||||
defaultAuthType: "既定の認証方式",
|
||||
aiDefaultProvider: "既定の AI",
|
||||
aiCodexSandboxMode: "Codex",
|
||||
aiCopilotPermissionMode: "Copilot",
|
||||
defaultPort: "既定 SSH ポート",
|
||||
defaultProjectPath: "既定プロジェクトパス",
|
||||
defaultTimeoutSeconds: "既定タイムアウト(秒)",
|
||||
defaultHeartbeatSeconds: "既定ハートビート(秒)",
|
||||
syncConfigEnabled: "設定をクラウド同期",
|
||||
logRetentionDays: "保持日数",
|
||||
voiceCategoryList: "分類一覧"
|
||||
},
|
||||
hints: {
|
||||
shellFontSizeReconnect: "文字サイズ変更後は、見た目を安定させるため再接続を推奨します",
|
||||
shellActivationDebugOutline: "入力エリアをタップするとソフトキーボードを開き、ほかをタップすると閉じます。",
|
||||
showVoiceInputButton: "右下のフローティング音声ボタンと展開パネルを表示するかを制御します。",
|
||||
ttsSpeakableMaxChars:
|
||||
"推奨範囲は 120〜1200 文字です。読み上げ対象はアシスタントの応答本文のみで、入力行とフッターは除外されます。長文は最初の音声までの待ち時間を短くするため自動分割されます。",
|
||||
syncConfigLine1: "オフにすると、設定・サーバープロファイル・メモのクラウド同期を一時停止します。",
|
||||
syncConfigLine2: "ローカル保存、端末接続、現在のセッションには影響しません。",
|
||||
voicePreviewUnicodeOn: "オン",
|
||||
voicePreviewUnicodeOff: "オフ",
|
||||
terminalPreviewLine: "Unicode11: {unicodeState} | 日本語 ABC 123 |_END_"
|
||||
},
|
||||
placeholders: {
|
||||
defaultProjectPath: "例: ~/workspace",
|
||||
newVoiceRecordCategory: "新しい分類"
|
||||
},
|
||||
buttons: {
|
||||
addVoiceRecordCategory: "追加",
|
||||
setDefaultCategory: "既定に設定",
|
||||
removeSelectedCategory: "選択を削除"
|
||||
},
|
||||
labels: {
|
||||
defaultBadge: "既定",
|
||||
ttsSpeakableCharsUnit: " 文字"
|
||||
},
|
||||
options: {
|
||||
authType: {
|
||||
password: "パスワード",
|
||||
key: "鍵"
|
||||
},
|
||||
aiDefaultProvider: {
|
||||
codex: "Codex",
|
||||
copilot: "Copilot"
|
||||
},
|
||||
aiCodexSandboxMode: {
|
||||
readOnly: "読み取り専用",
|
||||
workspaceWrite: "プロジェクト読書き",
|
||||
dangerFullAccess: "フルアクセス"
|
||||
},
|
||||
aiCopilotPermissionMode: {
|
||||
default: "標準",
|
||||
experimental: "実験的",
|
||||
allowAll: "フルアクセス"
|
||||
},
|
||||
themeMode: {
|
||||
dark: "ダーク",
|
||||
light: "ライト"
|
||||
},
|
||||
uiLanguage: {
|
||||
"zh-Hans": "簡体中文",
|
||||
"zh-Hant": "繁體中文",
|
||||
en: "English",
|
||||
ja: "日本語",
|
||||
ko: "한국어"
|
||||
},
|
||||
themePresetDefaultSuffix: "(既定)",
|
||||
fontFallback: "等幅デフォルト"
|
||||
},
|
||||
toast: {
|
||||
enterCategoryName: "分類名を入力してください",
|
||||
categoryExists: "分類はすでに存在します",
|
||||
maxCategories: "分類は最大 10 件までです",
|
||||
fallbackCannotDelete: "既定の分類は削除できません",
|
||||
keepAtLeastOneCategory: "少なくとも 1 つの分類を残してください"
|
||||
}
|
||||
},
|
||||
connect: {
|
||||
navTitle: "サーバー",
|
||||
pageTitle: "サーバー一覧",
|
||||
searchPlaceholder: "サーバーを検索",
|
||||
recentConnectionPrefix: "最近の接続",
|
||||
emptyTip: "該当するサーバーがありません",
|
||||
unnamedServer: "名称未設定サーバー",
|
||||
authTypeLabels: {
|
||||
password: "パスワード",
|
||||
privateKey: "秘密鍵",
|
||||
certificate: "証明書"
|
||||
},
|
||||
textIcons: {
|
||||
create: "新規",
|
||||
remove: "削除",
|
||||
selectAll: "全選",
|
||||
search: "検索",
|
||||
copy: "複製",
|
||||
connect: "SSH"
|
||||
},
|
||||
modal: {
|
||||
removeTitle: "サーバーを削除",
|
||||
removeContent: "選択したサーバー({count} 件)を削除しますか?"
|
||||
},
|
||||
toast: {
|
||||
serverNotFound: "サーバーが見つかりません",
|
||||
serverToCopyNotFound: "複製元のサーバーが見つかりません",
|
||||
serverCopied: "サーバーを複製しました",
|
||||
clearSearchBeforeSort: "並べ替える前に検索条件をクリアしてください"
|
||||
},
|
||||
summary: {
|
||||
connectFromList: "サーバー一覧から接続を開始しました",
|
||||
aiFromList: "サーバー一覧から AI クイック起動を開始しました"
|
||||
},
|
||||
fallback: {
|
||||
noConnection: "接続履歴なし",
|
||||
newServerPrefix: "サーバー"
|
||||
},
|
||||
labels: {
|
||||
copyNameSuffix: " のコピー"
|
||||
},
|
||||
display: {
|
||||
projectPrefix: "作業"
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
navTitle: "プラグイン",
|
||||
runtimeStatePrefix: "実行状態: ",
|
||||
summary: "現在のミニプログラム基線 v3.0.0 に合わせ、ライフサイクル制御、インポート/エクスポート、コマンド実行、実行ログを揃えています。",
|
||||
sections: {
|
||||
pluginList: "プラグイン一覧",
|
||||
importJson: "プラグイン JSON の取り込み",
|
||||
runCommand: "コマンド実行",
|
||||
runtimeLogs: "実行ログ"
|
||||
},
|
||||
buttons: {
|
||||
enable: "有効化",
|
||||
disable: "無効化",
|
||||
reload: "再読み込み",
|
||||
remove: "削除",
|
||||
importJson: "取り込む",
|
||||
exportJson: "すべて出力(コピー)"
|
||||
},
|
||||
empty: {
|
||||
noPlugins: "プラグインはまだありません",
|
||||
noCommands: "使用可能なコマンドがありません。接続中セッションと登録済みプラグインコマンドが必要です。",
|
||||
noLogs: "ログはまだありません"
|
||||
},
|
||||
placeholder: {
|
||||
pluginJson: "[{\"manifest\":...,\"mainJs\":\"...\",\"stylesCss\":\"...\"}]"
|
||||
},
|
||||
modal: {
|
||||
removeTitle: "プラグインを削除",
|
||||
removeContent: "プラグイン {id} を削除しますか?"
|
||||
},
|
||||
toast: {
|
||||
bootstrapFailed: "プラグイン初期化に失敗しました",
|
||||
pastePluginJsonFirst: "先にプラグイン JSON を貼り付けてください",
|
||||
importSuccess: "取り込みました",
|
||||
importFailed: "取り込みに失敗しました",
|
||||
exportSuccess: "プラグイン JSON をコピーしました",
|
||||
exportFailed: "出力に失敗しました",
|
||||
enabled: "有効化しました",
|
||||
enableFailed: "有効化に失敗しました",
|
||||
disabled: "無効化しました",
|
||||
disableFailed: "無効化に失敗しました",
|
||||
reloaded: "再読み込みしました",
|
||||
reloadFailed: "再読み込みに失敗しました",
|
||||
removed: "削除しました",
|
||||
removeFailed: "削除に失敗しました",
|
||||
commandExecuted: "コマンドを実行しました",
|
||||
commandExecuteFailed: "コマンド実行に失敗しました"
|
||||
}
|
||||
},
|
||||
serverSettings: {
|
||||
navTitle: "サーバー設定",
|
||||
sections: {
|
||||
basicTitle: "基本情報",
|
||||
basicDesc: "対象サーバーを識別して指定します",
|
||||
authTitle: "認証",
|
||||
authDesc: "認証方式に応じてパスワードまたは鍵情報を入力します",
|
||||
connectTitle: "接続",
|
||||
connectDesc: "接続経路と作業ディレクトリを定義します",
|
||||
jumpHostTitle: "踏み台ホスト",
|
||||
jumpHostDesc: "設定済みの踏み台経由で対象サーバーに到達します"
|
||||
},
|
||||
fields: {
|
||||
name: "名称",
|
||||
tags: "タグ",
|
||||
host: "ホスト",
|
||||
port: "ポート",
|
||||
username: "ユーザー名",
|
||||
authType: "認証方式",
|
||||
password: "パスワード",
|
||||
privateKey: "秘密鍵",
|
||||
passphrase: "パスフレーズ",
|
||||
certificate: "証明書",
|
||||
transportMode: "転送方式",
|
||||
aiProjectPath: "AI 作業ディレクトリ",
|
||||
jumpHost: "踏み台ホスト",
|
||||
jumpPort: "踏み台ポート",
|
||||
jumpUsername: "踏み台ユーザー名"
|
||||
},
|
||||
options: {
|
||||
authType: {
|
||||
password: "パスワード",
|
||||
privateKey: "秘密鍵",
|
||||
certificate: "証明書"
|
||||
}
|
||||
},
|
||||
placeholders: {
|
||||
tags: "prod,tokyo",
|
||||
aiProjectPath: "~/workspace/project"
|
||||
},
|
||||
directoryPicker: {
|
||||
openButton: "ディレクトリを選択",
|
||||
loading: "読み込み中",
|
||||
cancel: "キャンセル",
|
||||
apply: "適用"
|
||||
},
|
||||
modal: {
|
||||
socketDomainTitle: "Socket ドメイン未許可",
|
||||
socketDomainContent: "現在のゲートウェイ URL はミニプログラムの Socket 許可ドメインに含まれていません: {domainHint}",
|
||||
socketDomainContentNoHint: "現在のゲートウェイ URL はミニプログラムの Socket 許可ドメインに含まれていません",
|
||||
connectFailedTitle: "サーバーに接続できません",
|
||||
connectFailedContent: "{message}\nホスト、ポート、ユーザー名、認証情報が正しいか確認してください。"
|
||||
},
|
||||
toast: {
|
||||
opsConfigMissing: "運用設定がありません。管理者に連絡してください。",
|
||||
saved: "保存しました",
|
||||
connectFromSettings: "サーバー設定から接続を開始しました"
|
||||
}
|
||||
},
|
||||
terminal: {
|
||||
navTitle: "端末",
|
||||
disconnectedHint: "右上の再接続スイッチまたは左上の AI ボタンから再接続してください。",
|
||||
connectionAction: {
|
||||
reconnect: "再接続",
|
||||
disconnect: "切断"
|
||||
},
|
||||
stateLabels: {
|
||||
idle: "待機中",
|
||||
connecting: "接続中",
|
||||
auth_pending: "認証中",
|
||||
connected: "接続済み",
|
||||
reconnecting: "再接続中",
|
||||
disconnected: "切断済み",
|
||||
error: "エラー",
|
||||
config_required: "設定不足"
|
||||
},
|
||||
voice: {
|
||||
recordingHint: "録音中... 離して送信またはメモに保存",
|
||||
inputPlaceholder: "下の音声ボタンを長押しして入力を開始"
|
||||
},
|
||||
aiDialog: {
|
||||
title: "AI クイック起動",
|
||||
hint: "起動オプションは「全体設定 -> 接続 -> AI 接続」に移動しました",
|
||||
currentMode: "現在のモード",
|
||||
launchCodex: "Codex を起動",
|
||||
launchCopilot: "Copilot を起動",
|
||||
close: "閉じる"
|
||||
},
|
||||
toast: {
|
||||
aiAlreadyRunning: "{provider} はこのセッションですでに実行中です。再起動する前に終了してください"
|
||||
},
|
||||
fallback: {
|
||||
noProject: "プロジェクト未設定",
|
||||
unnamedServer: "名称未設定サーバー"
|
||||
},
|
||||
sessionInfo: {
|
||||
title: "セッション情報",
|
||||
nameLabel: "サーバー名",
|
||||
projectLabel: "作業ディレクトリ",
|
||||
addressLabel: "サーバーアドレス",
|
||||
jumpTargetLabel: "接続先サーバー",
|
||||
sshConnectionLabel: "SSH 接続",
|
||||
aiConnectionLabel: "AI 接続",
|
||||
connectedValue: "接続",
|
||||
disconnectedValue: "切断",
|
||||
emptyValue: "-"
|
||||
},
|
||||
modal: {
|
||||
socketDomainTitle: "Socket ドメイン未許可",
|
||||
socketDomainContent: "現在のゲートウェイ URL はミニプログラムの Socket 許可ドメインに含まれていません: {domainHint}",
|
||||
socketDomainContentNoHint: "現在のゲートウェイ URL はミニプログラムの Socket 許可ドメインに含まれていません"
|
||||
},
|
||||
diagnostics: {
|
||||
responseAxisCardLabel: "ゲートウェイ RTT (ms)",
|
||||
networkAxisCardLabel: "ネットワーク RTT (ms)",
|
||||
chartMinShort: "最小",
|
||||
chartMaxShort: "最大",
|
||||
chartAvgShort: "平均",
|
||||
chartStatEmpty: "--"
|
||||
},
|
||||
tts: {
|
||||
enable: "TTS を有効化",
|
||||
disable: "TTS を無効化",
|
||||
settingsTitle: "読み上げ設定",
|
||||
settingsDesc: "端末の音声再生と総読み上げ文字数を制御します",
|
||||
lengthLabel: "総読み上げ文字数",
|
||||
lengthUnit: " 文字",
|
||||
lengthHint: "アシスタントの応答のみを読み上げます。入力行とフッターは除外されます。",
|
||||
segmentHint: "長い内容は最初の音声までの待ち時間を短くするため、自動で小さな断片に分割されます。"
|
||||
},
|
||||
summary: {
|
||||
gatewayConnect: "ミニプログラムがゲートウェイ接続を開始しました",
|
||||
sessionRestored: "セッションを復元しました",
|
||||
sessionConnected: "セッションに接続しました"
|
||||
},
|
||||
errors: {
|
||||
serverNotFound: "サーバーが見つかりません",
|
||||
sessionNotConnected: "セッションは未接続です",
|
||||
opsConfigMissing: "運用設定がありません。管理者に連絡してください。",
|
||||
gatewayError: "ゲートウェイエラー",
|
||||
connectionException: "接続エラー",
|
||||
sessionReadyTimeout: "セッションの準備がタイムアウトしました",
|
||||
serverConfigIncomplete: "サーバー設定が不完全です",
|
||||
connectionFailed: "接続に失敗しました",
|
||||
sessionDisconnected: "セッションが切断されました",
|
||||
waitSessionTimeout: "セッション接続の待機がタイムアウトしました",
|
||||
codexLaunchFailed: "Codex の起動に失敗しました",
|
||||
copilotLaunchFailed: "Copilot の起動に失敗しました",
|
||||
codexNotInstalled: "サーバーに codex がインストールされていません",
|
||||
codexLaunching: "Codex はすでに起動中です",
|
||||
waitCodexTimeout: "Codex 起動結果の待機がタイムアウトしました",
|
||||
codexWorkingDirMissing: "Codex の作業ディレクトリ {projectPath} が存在しません",
|
||||
privacyAuthFailed: "プライバシー認可に失敗しました",
|
||||
privacyApiBanned:
|
||||
"ミニプログラム管理画面でプライバシー申告が未完了のため、録音 API が無効化されています。WeChat 公開プラットフォームで申告を補って再審査・公開してください。",
|
||||
privacyScopeUndeclared:
|
||||
"ミニプログラムのプライバシー指針に録音用途が記載されていません。WeChat 公開プラットフォームの「サービス内容声明 - ユーザープライバシー保護指針」にマイク収集用途を追加してください。",
|
||||
privacyDenied: "プライバシー規約に同意していないため、録音は利用できません",
|
||||
micPermissionDenied: "マイク権限が無効です。設定で録音を許可してください。",
|
||||
micPermissionReadFailed: "マイク権限の取得に失敗しました",
|
||||
recorderBusy: "レコーダーが使用中です。しばらくしてから再試行してください。",
|
||||
recordCaptureFailed: "録音の取得に失敗しました",
|
||||
asrFailed: "音声認識に失敗しました",
|
||||
asrGatewayFailed: "音声ゲートウェイ接続に失敗しました。ミニプログラムの Socket 許可ドメインを確認してください。",
|
||||
asrGatewayConnectFailed: "音声ゲートウェイ接続に失敗しました。ネットワークまたはゲートウェイ設定を確認してください。",
|
||||
asrTimeout: "音声サービス接続がタイムアウトしました。しばらくしてから再試行してください。",
|
||||
clipboardEmpty: "クリップボードが空です",
|
||||
clipboardReadFailed: "クリップボードの読み取りに失敗しました",
|
||||
noRecordContent: "保存できる内容がありません",
|
||||
recordSaved: "メモに保存しました",
|
||||
noSpeakableContent: "読み上げ可能な内容がありません",
|
||||
contentNotSpeakable: "現在の内容は読み上げに適していません",
|
||||
ttsTextTooLong: "読み上げテキストが長すぎます",
|
||||
ttsTimeout: "音声生成がタイムアウトしました。しばらくしてから再試行してください。",
|
||||
ttsUpstreamRejected: "TTS の認証または権限に失敗しました。管理者に TTS 設定の確認を依頼してください。",
|
||||
ttsSynthesizeFailed: "音声生成に失敗しました",
|
||||
ttsPlayFailed: "音声再生に失敗しました。ネットワークを確認してください。",
|
||||
ttsUnavailable: "TTS サービスが設定されていません"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const KO_I18N_OVERLAY = {
|
||||
common: {
|
||||
statusLabels: {
|
||||
idle: "대기 중",
|
||||
connecting: "연결 중",
|
||||
auth_pending: "인증 중",
|
||||
connected: "연결됨",
|
||||
reconnecting: "재연결 중",
|
||||
disconnected: "연결 끊김",
|
||||
error: "오류",
|
||||
config_required: "설정 필요"
|
||||
},
|
||||
runtimeStateLabels: {
|
||||
connected: "연결됨",
|
||||
disconnected: "연결 안 됨"
|
||||
},
|
||||
pageIndicator: "{page} / {total} 페이지"
|
||||
},
|
||||
bottomNav: {
|
||||
backText: "뒤로",
|
||||
pageTextLabels: {
|
||||
"open-terminal-shell": "터미널",
|
||||
"/pages/terminal/index": "터미널",
|
||||
"/pages/connect/index": "서버",
|
||||
"/pages/logs/index": "로그",
|
||||
"/pages/records/index": "메모",
|
||||
"/pages/settings/index": "설정",
|
||||
"/pages/about/index": "소개",
|
||||
"/pages/plugins/index": "플러그",
|
||||
default: "페이지"
|
||||
},
|
||||
modal: {
|
||||
noTerminalTitle: "열 수 있는 터미널이 없습니다",
|
||||
noTerminalContent: "먼저 서버 카드에서 세션을 시작한 뒤 터미널 페이지를 열어 주세요."
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
navTitle: "설정",
|
||||
saveStatus: {
|
||||
synced: "로컬 설정이 동기화되었습니다",
|
||||
saving: "자동 저장 중...",
|
||||
saved: "자동 저장되었습니다"
|
||||
},
|
||||
tabs: {
|
||||
ui: "화면",
|
||||
shell: "터미널",
|
||||
connection: "연결",
|
||||
log: "기록"
|
||||
},
|
||||
sections: {
|
||||
languageTitle: "언어",
|
||||
languageDesc: "미니프로그램 인터페이스 언어를 전환합니다",
|
||||
uiTitle: "화면 설정",
|
||||
uiDesc: "앱 외형과 테마 모드",
|
||||
shellDisplayTitle: "표시 설정",
|
||||
shellDisplayDesc: "터미널 표시와 입력 경험",
|
||||
ttsTitle: "읽어주기 설정",
|
||||
ttsDesc: "단위는 글자 수입니다. 기본 총 길이 제한은 500, 분할 길이는 80입니다.",
|
||||
shellBufferTitle: "터미널 버퍼",
|
||||
shellBufferDesc: "실시간 버퍼와 재개 스냅샷 한도를 조정해 메모리 사용량을 줄입니다",
|
||||
connectionTitle: "연결 설정",
|
||||
connectionDesc: "연결 동작과 서버 기본값",
|
||||
aiConnectionTitle: "AI 연결",
|
||||
aiConnectionDesc: "Codex 와 Copilot 의 기본 실행 권한을 설정합니다",
|
||||
syncTitle: "동기화 설정",
|
||||
syncDesc: "설정 데이터를 클라우드에 동기화할지 제어합니다",
|
||||
recordTitle: "기록 설정",
|
||||
recordDesc: "메모와 로그의 보존 기간을 제어합니다",
|
||||
voiceCategoryTitle: "메모 분류",
|
||||
voiceCategoryDesc: "메모 분류를 관리합니다"
|
||||
},
|
||||
fields: {
|
||||
themeMode: "모드",
|
||||
themePreset: "테마",
|
||||
uiLanguage: "화면 언어",
|
||||
uiAccentColor: "강조색",
|
||||
uiBgColor: "배경색",
|
||||
uiTextColor: "텍스트 색",
|
||||
uiBtnColor: "버튼 색",
|
||||
shellBgColor: "터미널 배경",
|
||||
shellTextColor: "터미널 전경",
|
||||
shellAccentColor: "커서 강조색",
|
||||
shellFontFamily: "글꼴",
|
||||
shellFontSize: "글자 크기",
|
||||
shellLineHeight: "줄 높이",
|
||||
unicode11: "전각 문자 지원",
|
||||
shellActivationDebugOutline: "입력 영역 테두리",
|
||||
showVoiceInputButton: "음성 입력 버튼",
|
||||
ttsSpeakableMaxChars: "총 길이 제한",
|
||||
ttsSegmentMaxChars: "분할 길이",
|
||||
shellBufferMaxEntries: "최대 버퍼 줄 수",
|
||||
shellBufferMaxBytes: "최대 버퍼 바이트",
|
||||
shellBufferSnapshotMaxLines: "재개 스냅샷 줄 수",
|
||||
autoReconnect: "자동 재연결",
|
||||
reconnectLimit: "재연결 횟수 제한",
|
||||
backgroundSessionKeepAliveMinutes: "백그라운드 유지 시간(분)",
|
||||
defaultAuthType: "기본 인증 방식",
|
||||
aiDefaultProvider: "기본 AI",
|
||||
aiCodexSandboxMode: "Codex",
|
||||
aiCopilotPermissionMode: "Copilot",
|
||||
defaultPort: "기본 SSH 포트",
|
||||
defaultProjectPath: "기본 프로젝트 경로",
|
||||
defaultTimeoutSeconds: "기본 타임아웃(초)",
|
||||
defaultHeartbeatSeconds: "기본 하트비트(초)",
|
||||
syncConfigEnabled: "설정을 클라우드에 동기화",
|
||||
logRetentionDays: "보존 일수",
|
||||
voiceCategoryList: "분류 목록"
|
||||
},
|
||||
hints: {
|
||||
shellFontSizeReconnect: "글자 크기를 바꾼 뒤에는 표시 안정성을 위해 재연결을 권장합니다",
|
||||
shellActivationDebugOutline: "입력 영역을 누르면 소프트 키보드가 열리고, 다른 곳을 누르면 닫힙니다.",
|
||||
showVoiceInputButton: "오른쪽 아래의 플로팅 음성 버튼과 펼침 패널 표시 여부를 제어합니다.",
|
||||
ttsSpeakableMaxChars:
|
||||
"권장 범위는 120~1200자입니다. 읽어주는 대상은 어시스턴트 응답 본문만이며 입력 줄과 하단 바는 제외됩니다. 긴 내용은 첫 음성까지의 대기 시간을 줄이기 위해 자동 분할됩니다.",
|
||||
syncConfigLine1: "끄면 설정, 서버 프로필, 메모의 클라우드 동기화가 일시 중지됩니다.",
|
||||
syncConfigLine2: "로컬 저장, 터미널 연결, 현재 세션에는 영향을 주지 않습니다.",
|
||||
voicePreviewUnicodeOn: "켜짐",
|
||||
voicePreviewUnicodeOff: "꺼짐",
|
||||
terminalPreviewLine: "Unicode11: {unicodeState} | 한글 ABC 123 |_END_"
|
||||
},
|
||||
placeholders: {
|
||||
defaultProjectPath: "예: ~/workspace",
|
||||
newVoiceRecordCategory: "새 분류"
|
||||
},
|
||||
buttons: {
|
||||
addVoiceRecordCategory: "추가",
|
||||
setDefaultCategory: "기본값으로",
|
||||
removeSelectedCategory: "선택 삭제"
|
||||
},
|
||||
labels: {
|
||||
defaultBadge: "기본",
|
||||
ttsSpeakableCharsUnit: "자"
|
||||
},
|
||||
options: {
|
||||
authType: {
|
||||
password: "비밀번호",
|
||||
key: "키"
|
||||
},
|
||||
aiDefaultProvider: {
|
||||
codex: "Codex",
|
||||
copilot: "Copilot"
|
||||
},
|
||||
aiCodexSandboxMode: {
|
||||
readOnly: "읽기 전용",
|
||||
workspaceWrite: "프로젝트 읽기/쓰기",
|
||||
dangerFullAccess: "전체 권한"
|
||||
},
|
||||
aiCopilotPermissionMode: {
|
||||
default: "기본",
|
||||
experimental: "실험",
|
||||
allowAll: "전체 권한"
|
||||
},
|
||||
themeMode: {
|
||||
dark: "다크",
|
||||
light: "라이트"
|
||||
},
|
||||
uiLanguage: {
|
||||
"zh-Hans": "중국어 간체",
|
||||
"zh-Hant": "중국어 번체",
|
||||
en: "English",
|
||||
ja: "일본어",
|
||||
ko: "한국어"
|
||||
},
|
||||
themePresetDefaultSuffix: " (기본)",
|
||||
fontFallback: "고정폭 기본"
|
||||
},
|
||||
toast: {
|
||||
enterCategoryName: "분류 이름을 입력해 주세요",
|
||||
categoryExists: "이미 존재하는 분류입니다",
|
||||
maxCategories: "분류는 최대 10개까지 가능합니다",
|
||||
fallbackCannotDelete: "기본 분류는 삭제할 수 없습니다",
|
||||
keepAtLeastOneCategory: "최소 한 개의 분류를 남겨야 합니다"
|
||||
}
|
||||
},
|
||||
connect: {
|
||||
navTitle: "서버",
|
||||
pageTitle: "서버 목록",
|
||||
searchPlaceholder: "서버 검색",
|
||||
recentConnectionPrefix: "최근 연결",
|
||||
emptyTip: "일치하는 서버가 없습니다",
|
||||
unnamedServer: "이름 없는 서버",
|
||||
authTypeLabels: {
|
||||
password: "비밀번호",
|
||||
privateKey: "개인 키",
|
||||
certificate: "인증서"
|
||||
},
|
||||
textIcons: {
|
||||
create: "추가",
|
||||
remove: "삭제",
|
||||
selectAll: "전체",
|
||||
search: "검색",
|
||||
copy: "복제",
|
||||
connect: "SSH"
|
||||
},
|
||||
modal: {
|
||||
removeTitle: "서버 삭제",
|
||||
removeContent: "선택한 서버({count}개)를 삭제할까요?"
|
||||
},
|
||||
toast: {
|
||||
serverNotFound: "서버를 찾을 수 없습니다",
|
||||
serverToCopyNotFound: "복제할 서버를 찾을 수 없습니다",
|
||||
serverCopied: "서버를 복제했습니다",
|
||||
clearSearchBeforeSort: "순서를 바꾸기 전에 검색 조건을 지워 주세요"
|
||||
},
|
||||
summary: {
|
||||
connectFromList: "서버 목록에서 연결을 시작했습니다",
|
||||
aiFromList: "서버 목록에서 AI 빠른 실행을 시작했습니다"
|
||||
},
|
||||
fallback: {
|
||||
noConnection: "연결 기록 없음",
|
||||
newServerPrefix: "서버"
|
||||
},
|
||||
labels: {
|
||||
copyNameSuffix: " 복사본"
|
||||
},
|
||||
display: {
|
||||
projectPrefix: "작업"
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
navTitle: "플러그인",
|
||||
runtimeStatePrefix: "실행 상태: ",
|
||||
summary: "현재 미니프로그램 기준선 v3.0.0 에 맞춰 라이프사이클 제어, 가져오기/내보내기, 명령 실행, 실행 로그를 정렬했습니다.",
|
||||
sections: {
|
||||
pluginList: "플러그인 목록",
|
||||
importJson: "플러그인 JSON 가져오기",
|
||||
runCommand: "명령 실행",
|
||||
runtimeLogs: "실행 로그"
|
||||
},
|
||||
buttons: {
|
||||
enable: "사용",
|
||||
disable: "중지",
|
||||
reload: "새로고침",
|
||||
remove: "삭제",
|
||||
importJson: "가져오기",
|
||||
exportJson: "전체 내보내기(복사)"
|
||||
},
|
||||
empty: {
|
||||
noPlugins: "아직 플러그인이 없습니다",
|
||||
noCommands: "사용 가능한 명령이 없습니다. 연결된 세션과 등록된 플러그인 명령이 필요합니다.",
|
||||
noLogs: "아직 로그가 없습니다"
|
||||
},
|
||||
placeholder: {
|
||||
pluginJson: "[{\"manifest\":...,\"mainJs\":\"...\",\"stylesCss\":\"...\"}]"
|
||||
},
|
||||
modal: {
|
||||
removeTitle: "플러그인 삭제",
|
||||
removeContent: "플러그인 {id} 를 삭제할까요?"
|
||||
},
|
||||
toast: {
|
||||
bootstrapFailed: "플러그인 초기화에 실패했습니다",
|
||||
pastePluginJsonFirst: "먼저 플러그인 JSON 을 붙여 넣어 주세요",
|
||||
importSuccess: "가져왔습니다",
|
||||
importFailed: "가져오기에 실패했습니다",
|
||||
exportSuccess: "플러그인 JSON 을 복사했습니다",
|
||||
exportFailed: "내보내기에 실패했습니다",
|
||||
enabled: "사용으로 전환했습니다",
|
||||
enableFailed: "사용 전환에 실패했습니다",
|
||||
disabled: "중지했습니다",
|
||||
disableFailed: "중지에 실패했습니다",
|
||||
reloaded: "다시 불러왔습니다",
|
||||
reloadFailed: "다시 불러오기에 실패했습니다",
|
||||
removed: "삭제했습니다",
|
||||
removeFailed: "삭제에 실패했습니다",
|
||||
commandExecuted: "명령을 실행했습니다",
|
||||
commandExecuteFailed: "명령 실행에 실패했습니다"
|
||||
}
|
||||
},
|
||||
serverSettings: {
|
||||
navTitle: "서버 설정",
|
||||
sections: {
|
||||
basicTitle: "기본 정보",
|
||||
basicDesc: "대상 서버를 식별하고 지정합니다",
|
||||
authTitle: "인증",
|
||||
authDesc: "인증 방식에 따라 비밀번호 또는 키 정보를 입력합니다",
|
||||
connectTitle: "연결",
|
||||
connectDesc: "연결 경로와 작업 디렉터리를 정의합니다",
|
||||
jumpHostTitle: "점프 호스트",
|
||||
jumpHostDesc: "설정된 점프 호스트를 통해 대상 서버에 도달합니다"
|
||||
},
|
||||
fields: {
|
||||
name: "이름",
|
||||
tags: "태그",
|
||||
host: "호스트",
|
||||
port: "포트",
|
||||
username: "사용자명",
|
||||
authType: "인증 방식",
|
||||
password: "비밀번호",
|
||||
privateKey: "개인 키",
|
||||
passphrase: "패스프레이즈",
|
||||
certificate: "인증서",
|
||||
transportMode: "전송 방식",
|
||||
aiProjectPath: "AI 작업 디렉터리",
|
||||
jumpHost: "점프 호스트",
|
||||
jumpPort: "점프 포트",
|
||||
jumpUsername: "점프 사용자명"
|
||||
},
|
||||
options: {
|
||||
authType: {
|
||||
password: "비밀번호",
|
||||
privateKey: "개인 키",
|
||||
certificate: "인증서"
|
||||
}
|
||||
},
|
||||
placeholders: {
|
||||
tags: "prod,seoul",
|
||||
aiProjectPath: "~/workspace/project"
|
||||
},
|
||||
directoryPicker: {
|
||||
openButton: "디렉터리 선택",
|
||||
loading: "불러오는 중",
|
||||
cancel: "취소",
|
||||
apply: "적용"
|
||||
},
|
||||
modal: {
|
||||
socketDomainTitle: "Socket 도메인 미허용",
|
||||
socketDomainContent: "현재 게이트웨이 URL 이 미니프로그램 Socket 허용 도메인 목록에 없습니다: {domainHint}",
|
||||
socketDomainContentNoHint: "현재 게이트웨이 URL 이 미니프로그램 Socket 허용 도메인 목록에 없습니다",
|
||||
connectFailedTitle: "서버에 연결할 수 없습니다",
|
||||
connectFailedContent: "{message}\n호스트, 포트, 사용자명, 인증 정보가 올바른지 확인해 주세요."
|
||||
},
|
||||
toast: {
|
||||
opsConfigMissing: "운영 설정이 없습니다. 관리자에게 문의해 주세요.",
|
||||
saved: "저장했습니다",
|
||||
connectFromSettings: "서버 설정에서 연결을 시작했습니다"
|
||||
}
|
||||
},
|
||||
terminal: {
|
||||
navTitle: "터미널",
|
||||
disconnectedHint: "오른쪽 위 재연결 스위치 또는 왼쪽 위 AI 버튼으로 다시 연결해 주세요.",
|
||||
connectionAction: {
|
||||
reconnect: "재연결",
|
||||
disconnect: "연결 끊기"
|
||||
},
|
||||
stateLabels: {
|
||||
idle: "대기 중",
|
||||
connecting: "연결 중",
|
||||
auth_pending: "인증 중",
|
||||
connected: "연결됨",
|
||||
reconnecting: "재연결 중",
|
||||
disconnected: "연결 끊김",
|
||||
error: "오류",
|
||||
config_required: "설정 필요"
|
||||
},
|
||||
voice: {
|
||||
recordingHint: "녹음 중... 놓으면 전송하거나 메모에 저장합니다",
|
||||
inputPlaceholder: "아래 음성 버튼을 길게 눌러 입력을 시작하세요"
|
||||
},
|
||||
aiDialog: {
|
||||
title: "AI 빠른 실행",
|
||||
hint: "실행 옵션은 전역 설정 -> 연결 -> AI 연결로 이동했습니다",
|
||||
currentMode: "현재 모드",
|
||||
launchCodex: "Codex 실행",
|
||||
launchCopilot: "Copilot 실행",
|
||||
close: "닫기"
|
||||
},
|
||||
toast: {
|
||||
aiAlreadyRunning: "{provider} 가 이 세션에서 이미 실행 중입니다. 다시 시작하기 전에 종료해 주세요"
|
||||
},
|
||||
fallback: {
|
||||
noProject: "프로젝트 미설정",
|
||||
unnamedServer: "이름 없는 서버"
|
||||
},
|
||||
sessionInfo: {
|
||||
title: "세션 정보",
|
||||
nameLabel: "서버 이름",
|
||||
projectLabel: "작업 디렉터리",
|
||||
addressLabel: "서버 주소",
|
||||
jumpTargetLabel: "대상 서버",
|
||||
sshConnectionLabel: "SSH 연결",
|
||||
aiConnectionLabel: "AI 연결",
|
||||
connectedValue: "연결",
|
||||
disconnectedValue: "끊김",
|
||||
emptyValue: "-"
|
||||
},
|
||||
modal: {
|
||||
socketDomainTitle: "Socket 도메인 미허용",
|
||||
socketDomainContent: "현재 게이트웨이 URL 이 미니프로그램 Socket 허용 도메인 목록에 없습니다: {domainHint}",
|
||||
socketDomainContentNoHint: "현재 게이트웨이 URL 이 미니프로그램 Socket 허용 도메인 목록에 없습니다"
|
||||
},
|
||||
diagnostics: {
|
||||
responseAxisCardLabel: "게이트웨이 RTT (ms)",
|
||||
networkAxisCardLabel: "네트워크 RTT (ms)",
|
||||
chartMinShort: "최소",
|
||||
chartMaxShort: "최대",
|
||||
chartAvgShort: "평균",
|
||||
chartStatEmpty: "--"
|
||||
},
|
||||
tts: {
|
||||
enable: "TTS 켜기",
|
||||
disable: "TTS 끄기",
|
||||
settingsTitle: "읽어주기 설정",
|
||||
settingsDesc: "터미널 음성 재생과 총 읽기 길이를 제어합니다",
|
||||
lengthLabel: "총 읽기 길이",
|
||||
lengthUnit: "자",
|
||||
lengthHint: "어시스턴트 응답 본문만 읽어 주며, 입력 줄과 하단 바는 제외됩니다.",
|
||||
segmentHint: "긴 내용은 첫 음성까지의 대기 시간을 줄이기 위해 자동으로 더 작은 조각으로 나뉩니다."
|
||||
},
|
||||
summary: {
|
||||
gatewayConnect: "미니프로그램이 게이트웨이 연결을 시작했습니다",
|
||||
sessionRestored: "세션을 복원했습니다",
|
||||
sessionConnected: "세션에 연결했습니다"
|
||||
},
|
||||
errors: {
|
||||
serverNotFound: "서버를 찾을 수 없습니다",
|
||||
sessionNotConnected: "세션이 연결되어 있지 않습니다",
|
||||
opsConfigMissing: "운영 설정이 없습니다. 관리자에게 문의해 주세요.",
|
||||
gatewayError: "게이트웨이 오류",
|
||||
connectionException: "연결 오류",
|
||||
sessionReadyTimeout: "세션 준비 시간이 초과되었습니다",
|
||||
serverConfigIncomplete: "서버 설정이 완전하지 않습니다",
|
||||
connectionFailed: "연결에 실패했습니다",
|
||||
sessionDisconnected: "세션 연결이 끊겼습니다",
|
||||
waitSessionTimeout: "세션 연결 대기 시간이 초과되었습니다",
|
||||
codexLaunchFailed: "Codex 실행에 실패했습니다",
|
||||
copilotLaunchFailed: "Copilot 실행에 실패했습니다",
|
||||
codexNotInstalled: "서버에 codex 가 설치되어 있지 않습니다",
|
||||
codexLaunching: "Codex 가 이미 실행 중입니다",
|
||||
waitCodexTimeout: "Codex 실행 결과 대기 시간이 초과되었습니다",
|
||||
codexWorkingDirMissing: "Codex 작업 디렉터리 {projectPath} 가 존재하지 않습니다",
|
||||
privacyAuthFailed: "개인정보 권한 확인에 실패했습니다",
|
||||
privacyApiBanned:
|
||||
"미니프로그램 관리 콘솔에서 개인정보 고지가 완료되지 않아 녹음 API 가 비활성화되었습니다. WeChat 공개 플랫폼에서 고지를 보완한 뒤 다시 심사 및 배포해 주세요.",
|
||||
privacyScopeUndeclared:
|
||||
"미니프로그램 개인정보 가이드에 녹음 용도가 선언되어 있지 않습니다. WeChat 공개 플랫폼의 ‘서비스 내용 선언 - 사용자 개인정보 보호 가이드’에 마이크 수집 용도를 추가해 주세요.",
|
||||
privacyDenied: "개인정보 약관에 동의하지 않아 녹음을 사용할 수 없습니다",
|
||||
micPermissionDenied: "마이크 권한이 꺼져 있습니다. 설정에서 녹음을 허용해 주세요.",
|
||||
micPermissionReadFailed: "마이크 권한을 읽지 못했습니다",
|
||||
recorderBusy: "녹음기가 사용 중입니다. 잠시 후 다시 시도해 주세요.",
|
||||
recordCaptureFailed: "녹음 수집에 실패했습니다",
|
||||
asrFailed: "음성 인식에 실패했습니다",
|
||||
asrGatewayFailed: "음성 게이트웨이 연결에 실패했습니다. 미니프로그램 Socket 허용 도메인을 확인해 주세요.",
|
||||
asrGatewayConnectFailed: "음성 게이트웨이 연결에 실패했습니다. 네트워크 또는 게이트웨이 설정을 확인해 주세요.",
|
||||
asrTimeout: "음성 서비스 연결 시간이 초과되었습니다. 잠시 후 다시 시도해 주세요.",
|
||||
clipboardEmpty: "클립보드가 비어 있습니다",
|
||||
clipboardReadFailed: "클립보드를 읽지 못했습니다",
|
||||
noRecordContent: "저장할 내용이 없습니다",
|
||||
recordSaved: "메모에 저장했습니다",
|
||||
noSpeakableContent: "읽어줄 수 있는 내용이 없습니다",
|
||||
contentNotSpeakable: "현재 내용은 읽어주기에 적합하지 않습니다",
|
||||
ttsTextTooLong: "읽어줄 텍스트가 너무 깁니다",
|
||||
ttsTimeout: "음성 생성 시간이 초과되었습니다. 잠시 후 다시 시도해 주세요.",
|
||||
ttsUpstreamRejected: "TTS 인증 또는 권한에 실패했습니다. 관리자에게 TTS 설정 확인을 요청해 주세요.",
|
||||
ttsSynthesizeFailed: "음성 생성에 실패했습니다",
|
||||
ttsPlayFailed: "오디오 재생에 실패했습니다. 네트워크를 확인해 주세요.",
|
||||
ttsUnavailable: "TTS 서비스가 설정되어 있지 않습니다"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
JA_I18N_OVERLAY,
|
||||
KO_I18N_OVERLAY
|
||||
};
|
||||
99
apps/miniprogram/utils/iconRuntime.test.ts
Normal file
99
apps/miniprogram/utils/iconRuntime.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
function loadModule(modulePath: string) {
|
||||
const resolved = require.resolve(modulePath);
|
||||
delete require.cache[resolved];
|
||||
return require(modulePath);
|
||||
}
|
||||
|
||||
function mockWxPlatform(platform: string) {
|
||||
(
|
||||
global as typeof globalThis & {
|
||||
wx?: { getAppBaseInfo: () => { platform: string } };
|
||||
}
|
||||
).wx = {
|
||||
getAppBaseInfo() {
|
||||
return { platform };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function decodeSvgDataUri(uri: string) {
|
||||
const prefix = "data:image/svg+xml;base64,";
|
||||
if (!uri.startsWith(prefix)) {
|
||||
throw new Error(`unexpected uri: ${uri.slice(0, 32)}`);
|
||||
}
|
||||
return Buffer.from(uri.slice(prefix.length), "base64").toString("utf8");
|
||||
}
|
||||
|
||||
describe("miniprogram icon runtime policy", () => {
|
||||
afterEach(() => {
|
||||
delete (global as typeof globalThis & { wx?: unknown }).wx;
|
||||
});
|
||||
|
||||
it("在 devtools 中也会生成带主题色的按钮图标", () => {
|
||||
mockWxPlatform("devtools");
|
||||
const { buildAccentButtonIconMap, buildActiveButtonIconMap, buildButtonIconMap, resolveButtonIcon } =
|
||||
loadModule("./themedIcons.js");
|
||||
const { buildTerminalToolActiveIconMap, buildTerminalToolIconMap } = loadModule("./terminalIcons.js");
|
||||
|
||||
const buttonIcons = buildButtonIconMap({ uiBtnColor: "#ABCDEF" });
|
||||
const activeButtonIcons = buildActiveButtonIconMap({ uiTextColor: "#102030" });
|
||||
const accentButtonIcons = buildAccentButtonIconMap({ uiAccentColor: "#3366FF" });
|
||||
const terminalIcons = buildTerminalToolIconMap({
|
||||
shellAccentColor: "#123456",
|
||||
uiAccentColor: "#3366FF"
|
||||
});
|
||||
const terminalActiveIcons = buildTerminalToolActiveIconMap({
|
||||
uiBgColor: "#FFFFFF",
|
||||
uiAccentColor: "#AABBCC",
|
||||
shellTextColor: "#102030"
|
||||
});
|
||||
|
||||
expect(buttonIcons.about).toMatch(/^data:image\/svg\+xml;base64,/);
|
||||
expect(resolveButtonIcon("/assets/icons/about.svg", buttonIcons)).toBe(buttonIcons.about);
|
||||
expect(resolveButtonIcon("/assets/icons/add.svg", buttonIcons)).toBe(buttonIcons.add);
|
||||
expect(resolveButtonIcon("/assets/icons/right.svg", buttonIcons)).toBe(buttonIcons.right);
|
||||
expect(resolveButtonIcon("/assets/icons/share.svg", buttonIcons)).toBe("/assets/icons/share.svg");
|
||||
expect(resolveButtonIcon("/assets/icons/shell.svg", buttonIcons)).toBe(buttonIcons.shell);
|
||||
expect(decodeSvgDataUri(buttonIcons.about)).toContain('fill="#ABCDEF"');
|
||||
expect(decodeSvgDataUri(buttonIcons.add)).toContain('fill="#ABCDEF"');
|
||||
expect(decodeSvgDataUri(buttonIcons.right)).toContain('fill="#ABCDEF"');
|
||||
expect(decodeSvgDataUri(buttonIcons.shell)).toContain('fill="#ABCDEF"');
|
||||
expect(decodeSvgDataUri(activeButtonIcons.connect)).toContain('fill="#102030"');
|
||||
expect(decodeSvgDataUri(accentButtonIcons.codex)).toContain('fill="#3366FF"');
|
||||
expect(decodeSvgDataUri(accentButtonIcons.clear)).toContain('fill="#3366FF"');
|
||||
expect(terminalIcons.keyboard).toMatch(/^data:image\/svg\+xml;base64,/);
|
||||
expect(decodeSvgDataUri(terminalIcons.keyboard)).toContain('fill="#123456"');
|
||||
expect(decodeSvgDataUri(terminalIcons.enter)).toContain('stroke="#123456"');
|
||||
expect(decodeSvgDataUri(terminalIcons.home)).toContain('fill="#123456"');
|
||||
expect(decodeSvgDataUri(terminalIcons.reading)).toContain('fill="#123456"');
|
||||
expect(decodeSvgDataUri(terminalIcons.ctrlc)).toContain('fill="#123456"');
|
||||
expect(decodeSvgDataUri(terminalIcons.ctrlc)).toContain('fill="#3366FF"');
|
||||
expect(decodeSvgDataUri(terminalActiveIcons.keyboard)).toContain('fill="#102030"');
|
||||
expect(decodeSvgDataUri(terminalIcons.stopreading)).toContain('fill="#123456"');
|
||||
expect(decodeSvgDataUri(terminalActiveIcons.reading)).toContain('fill="#102030"');
|
||||
expect(decodeSvgDataUri(terminalActiveIcons.ctrlc)).toContain('fill="#102030"');
|
||||
expect(decodeSvgDataUri(terminalActiveIcons.ctrlc)).toContain('fill="#AABBCC"');
|
||||
});
|
||||
|
||||
it("在非 devtools 运行环境中同样使用主题化 svg", () => {
|
||||
mockWxPlatform("ios");
|
||||
const { buildButtonIconMap } = loadModule("./themedIcons.js");
|
||||
const { buildTerminalToolActiveIconMap, buildTerminalToolIconMap } = loadModule("./terminalIcons.js");
|
||||
|
||||
const buttonIcons = buildButtonIconMap({ uiBtnColor: "#445566" });
|
||||
const terminalIcons = buildTerminalToolIconMap({ shellAccentColor: "#778899" });
|
||||
const terminalActiveIcons = buildTerminalToolActiveIconMap({
|
||||
uiBgColor: "#FFFFFF",
|
||||
uiAccentColor: "#FEFEFE",
|
||||
shellTextColor: "#224466"
|
||||
});
|
||||
|
||||
expect(decodeSvgDataUri(buttonIcons.cancel)).toContain('fill="#445566"');
|
||||
expect(decodeSvgDataUri(terminalIcons.voice)).toContain('fill="#778899"');
|
||||
expect(decodeSvgDataUri(terminalIcons.home)).toContain('fill="#778899"');
|
||||
expect(decodeSvgDataUri(terminalIcons.reading)).toContain('fill="#778899"');
|
||||
expect(decodeSvgDataUri(terminalActiveIcons.reading)).toContain('fill="#224466"');
|
||||
});
|
||||
});
|
||||
82
apps/miniprogram/utils/iconSvgSources.js
Normal file
82
apps/miniprogram/utils/iconSvgSources.js
Normal file
@@ -0,0 +1,82 @@
|
||||
/* global module */
|
||||
|
||||
/**
|
||||
* 小程序运行时不能直接读取 `assets/icons/*.svg` 文件内容,
|
||||
* 这里把按钮相关 SVG 源文内联成字符串,供主题着色阶段统一替换 fill/stroke。
|
||||
* 约定:
|
||||
* 1. 仅收录当前按钮链路实际会用到的图标,避免映射表无限膨胀;
|
||||
* 2. 保持单行字符串,减少 data URI 编码前的无意义空白;
|
||||
* 3. 原始路径仍保留在 `assets/icons`,这里不是资源来源,只是运行时副本。
|
||||
*/
|
||||
const ICON_SVG_SOURCES = Object.freeze({
|
||||
about:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22"> <path fill="#FFC16E" d="M11 22c6.075 0 11-4.925 11-11S17.075 0 11 0 0 4.925 0 11s4.925 11 11 11ZM9.281 5.5a1.719 1.719 0 0 1 3.438 0v.687a1.719 1.719 0 0 1-3.438 0V5.5Zm0 5.5a1.719 1.719 0 0 1 3.438 0v5.5a1.719 1.719 0 0 1-3.438 0V11Z"/> </svg>',
|
||||
add: '<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22"> <path fill="#67D1FF" d="M11.005 0C4.93 0 0 4.924 0 11c0 6.074 4.93 11 11.005 11 6.071 0 11-4.926 11-11 0-6.076-4.929-11-11-11Zm5.5 12.373h-4.129V16.5H9.63v-4.127H5.506v-2.75h4.123V5.5h2.747v4.124h4.13v2.75Z"/> </svg>',
|
||||
ai: '<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22"> <path fill="#67D1FF" d="M10.962 0a10.909 10.909 0 0 0-6.083 1.845A10.988 10.988 0 0 0 .84 6.775a11.05 11.05 0 0 0-.633 6.353 11.017 11.017 0 0 0 2.985 5.636 10.93 10.93 0 0 0 5.599 3.02c2.122.429 4.323.216 6.324-.613a10.958 10.958 0 0 0 4.92-4.04 11.038 11.038 0 0 0 1.858-6.107v-.015A11.033 11.033 0 0 0 21.07 6.8a10.988 10.988 0 0 0-2.364-3.57A10.928 10.928 0 0 0 15.16.842 10.885 10.885 0 0 0 10.975 0h-.013Zm.614 14.925-.772-1.833H7.8l-.772 1.833H5.053l3.276-7.96h1.935l3.278 7.96h-1.966Zm4.511 0H14.4v-7.96h1.687v7.96Z"/> <path fill="#67D1FF" d="M8.18 11.668h2.255l-1.127-2.78-1.128 2.78Z"/> </svg>',
|
||||
back: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"> <path fill="#67D1FF" d="M17.053 10.8h-7.24l3.043-3.045a1.216 1.216 0 0 0 .024-1.718 1.216 1.216 0 0 0-1.72.02l-5.064 5.066c-.011.01-.026.013-.037.024a1.185 1.185 0 0 0-.342.866c0 .305.112.611.344.841.01.01.022.013.032.021l5.067 5.067c.482.48 1.251.493 1.72.024a1.216 1.216 0 0 0-.024-1.718L9.811 13.2h7.242c.68 0 1.232-.538 1.232-1.2 0-.662-.552-1.2-1.232-1.2Z"/> <path fill="#67D1FF" d="M12 0A11.998 11.998 0 0 0 0 12c0 6.629 5.371 12 12 12s12-5.371 12-12S18.629 0 12 0Zm0 21.6a9.599 9.599 0 1 1 0-19.198A9.599 9.599 0 0 1 12 21.6Z"/> </svg>',
|
||||
backspace:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="16" fill="none" viewBox="0 0 20 16"> <path fill="#FFC16E" d="m15.82 10.519-2.968-3.01 2.969-3.011-1.172-1.173-2.969 3.01-3.008-3.01-1.171 1.173 3.007 3.01-3.007 3.01 1.171 1.173 3.008-3.01 2.97 3.01 1.17-1.172ZM18.32 0c.444 0 .834.17 1.172.508.339.338.508.73.508 1.173v11.652c0 .443-.17.834-.508 1.173-.338.338-.728.508-1.171.508H5.82c-.521 0-.964-.248-1.329-.744L0 7.507 4.492.743C4.857.248 5.3 0 5.821 0h12.5Z"/> </svg>',
|
||||
cancel:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22"> <path fill="#67D1FF" d="M10.937 0C4.897 0 0 4.896 0 10.937c0 6.04 4.896 10.936 10.937 10.936 6.04 0 10.936-4.896 10.936-10.936S16.977 0 10.937 0Zm5.694 14.507a1.364 1.364 0 0 1 0 1.923l-.481.48a1.364 1.364 0 0 1-1.923 0l-3.43-3.43-3.43 3.43a1.364 1.364 0 0 1-1.924 0l-.48-.48a1.364 1.364 0 0 1 0-1.923l3.43-3.43-3.71-3.71a1.364 1.364 0 0 1 0-1.924l.48-.48a1.364 1.364 0 0 1 1.924 0l3.71 3.71 3.71-3.71a1.364 1.364 0 0 1 1.923 0l.48.48a1.364 1.364 0 0 1 0 1.923l-3.71 3.71 3.43 3.43Z"/> </svg>',
|
||||
clear:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="23" fill="none" viewBox="0 0 24 23"> <path fill="#E6F0FF" d="M22.313 6.835c0-1.14-.91-2.05-2.05-2.05h-4.098V1.94c0-1.14-.89-1.94-2.03-1.94-1.14 0-2.07.8-2.07 1.94v2.846H8.199c-1.14 0-2.05.91-2.05 2.05v2.049h16.274v-2.05h-.11Zm0 4.1H6.039S4.899 23 0 23h5.809c2.28 0 3.298-6.597 3.298-6.597s1.019 5.918.11 6.597h3.528c1.589-.23 1.819-7.506 1.819-7.506s1.589 7.397 1.37 7.397h-3.189 5.009c1.479-.34 1.588-5.688 1.588-5.688s.679 5.688.46 5.688h-2.158 2.729c4.098.109 4.329-5.578 1.94-11.957Z"/> </svg>',
|
||||
"clear-input":
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="22" height="24" fill="none" viewBox="0 0 22 24"> <path fill="#67D1FF" d="M14.071.652a3.268 3.268 0 0 0-1.694-.614c-.444-.03-.89-.042-1.335-.036h-.27c-.43-.008-.862.004-1.292.036a3.266 3.266 0 0 0-1.726.64c-.346.276-.633.62-.841 1.011-.2.35-.398.786-.622 1.28l-.39.85h-4.81a1.091 1.091 0 1 0 0 2.183h.818V19.91A4.09 4.09 0 0 0 6 24h9.818a4.09 4.09 0 0 0 4.09-4.09V6.002h.818a1.092 1.092 0 0 0 0-2.18h-4.71l-.465-.961c-.195-.42-.408-.833-.638-1.235a3.254 3.254 0 0 0-.841-.974Zm-.48 3.17H8.3c.154-.358.323-.708.507-1.051a1.003 1.003 0 0 1 .87-.56c.291-.026.67-.026 1.27-.026.586 0 .955 0 1.237.026a1.004 1.004 0 0 1 .859.539c.144.237.303.56.55 1.071Zm-5.41 14.41a.818.818 0 0 1-.818-.817V10.87a.818.818 0 0 1 1.636 0v6.545a.818.818 0 0 1-.818.818Zm6.273-7.362v6.545a.818.818 0 0 1-1.636 0V10.87a.818.818 0 0 1 1.636 0Z"/> </svg>',
|
||||
codex:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="22" fill="none" viewBox="0 0 24 22"> <path fill="#E6F0FF" d="M9.7 8.708c-.15-.458-.25-.916-.4-1.375-.3 1.146-.65 2.384-1 3.484l-.35 1.1h2.65l-.35-1.1a32.72 32.72 0 0 0-.55-2.109Z"/> <path fill="#E6F0FF" d="M24 6.417v-5.5C24 .412 23.55 0 23 0h-6v1.833H7V0H1C.45 0 0 .412 0 .917v5.5h2v9.166H0v5.5C0 21.588.45 22 1 22h6v-1.833h10V22h6c.55 0 1-.413 1-.917v-5.5h-2V6.417h2Zm-9.3 10.129-.05.046H12.1c-.05 0-.05 0-.05-.046l-.85-2.796H7.45l-.85 2.796c0 .046-.05.046-.05.046H4.1s-.05 0-.05-.046V16.5L7.9 5.454c0-.046.05-.046.05-.046h2.85c.05 0 .05 0 .05.046L14.7 16.5v.046Zm3.85-.046c0 .046-.05.046-.1.046h-2.4c-.05 0-.1-.046-.1-.046V5.454c0-.046.05-.046.1-.046h2.4c.05 0 .1.046.1.046V16.5Z"/> </svg>',
|
||||
config:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"> <path fill="#67D1FF" d="M22.286 9.429h-1.213a9.45 9.45 0 0 0-.857-2.023l.857-.857a1.715 1.715 0 0 0 0-2.422L19.877 2.91a1.714 1.714 0 0 0-2.421 0l-.857.857a9.226 9.226 0 0 0-2.028-.836V1.714A1.713 1.713 0 0 0 12.857 0h-1.714a1.714 1.714 0 0 0-1.714 1.714v1.217a9.227 9.227 0 0 0-2.023.836l-.857-.857a1.714 1.714 0 0 0-2.422 0L2.91 4.123a1.714 1.714 0 0 0 0 2.421l.857.857a9.45 9.45 0 0 0-.857 2.023H1.714A1.714 1.714 0 0 0 0 11.14v1.714a1.714 1.714 0 0 0 1.714 1.714h1.213a9.45 9.45 0 0 0 .857 2.023l-.857.857a1.714 1.714 0 0 0 0 2.422l1.213 1.21a1.714 1.714 0 0 0 2.421 0l.858-.857c.634.36 1.309.644 2.01.845v1.217A1.714 1.714 0 0 0 11.143 24h1.714a1.713 1.713 0 0 0 1.714-1.714v-1.217a9.233 9.233 0 0 0 2.023-.836l.857.857a1.714 1.714 0 0 0 2.422 0l1.213-1.213a1.714 1.714 0 0 0 0-2.421l-.857-.857a9.45 9.45 0 0 0 .857-2.023h1.2A1.714 1.714 0 0 0 24 12.86v-1.718a1.714 1.714 0 0 0-1.714-1.714Zm-7.822 4.954a3.429 3.429 0 1 1-4.93-4.767 3.429 3.429 0 0 1 4.93 4.767Z"/> </svg>',
|
||||
connect:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22"> <path fill="#67D1FF" d="M11.023 0c6.08.04 10.993 4.985 10.964 11.031C21.957 17.103 17 22.035 10.96 22 4.86 21.966-.057 16.988 0 10.908.06 4.868 5.02-.04 11.024 0ZM4.145 14.27c.096 1.317.73 2.444 2.067 3.066 1.308.61 2.626.497 3.711-.45 1.142-.995 2.204-2.094 3.21-3.229.831-.938.926-2.095.55-3.277-.247-.77-.71-1.066-1.318-.872-.575.185-.77.655-.556 1.417.18.643.135 1.24-.344 1.732-.864.89-1.727 1.783-2.63 2.63-.696.65-1.661.612-2.268-.013-.625-.642-.628-1.629.01-2.33.308-.34.655-.646.968-.982.4-.428.398-1.026.018-1.397-.362-.352-.942-.363-1.355.019a17.04 17.04 0 0 0-1.22 1.24c-.574.658-.835 1.444-.843 2.446Zm4.118-3.929c.014.135.027.396.072.65.03.176.093.347.159.514.254.642.763.924 1.302.73.497-.178.725-.72.525-1.343-.244-.767-.076-1.41.488-1.973.793-.79 1.575-1.594 2.374-2.38.687-.676 1.679-.714 2.318-.108.665.629.682 1.635.025 2.37-.293.328-.625.62-.925.942-.426.458-.437.999-.047 1.39.406.407.947.434 1.387.014.462-.442.92-.895 1.312-1.396 1.093-1.394.914-3.464-.372-4.695-1.305-1.25-3.353-1.363-4.696-.181-1.034.91-2.005 1.896-2.966 2.885-.65.671-.95 1.516-.956 2.581Z"/> <path fill="#67D1FF" d="M20.166 5.268c0-.11-.002-.219-.004-.328A11.05 11.05 0 0 0 11.022 0C5.019-.04.059 4.87 0 10.908c-.037 3.828 1.898 7.217 4.86 9.212.148.004.297.006.445.006 8.207.002 14.86-6.65 14.86-14.858Zm-7.033 8.39c-1.007 1.133-2.068 2.233-3.21 3.23-1.085.945-2.404 1.057-3.711.449-1.338-.622-1.97-1.75-2.067-3.067.007-1.002.269-1.788.845-2.444a17.04 17.04 0 0 1 1.219-1.24c.413-.382.993-.373 1.355-.02.38.373.382.97-.018 1.399-.313.336-.66.643-.969.983-.637.7-.634 1.687-.009 2.33.607.624 1.574.663 2.267.011.905-.847 1.766-1.74 2.63-2.629.48-.492.526-1.09.345-1.732-.215-.762-.019-1.232.556-1.417.607-.194 1.072.102 1.317.872.376 1.18.281 2.337-.55 3.275Zm1.421-2.524c-.391-.392-.38-.933.047-1.39.3-.323.632-.615.925-.943.657-.735.64-1.74-.025-2.37-.638-.604-1.631-.566-2.318.108-.8.786-1.58 1.59-2.374 2.38-.564.561-.732 1.206-.488 1.973.199.624-.028 1.164-.525 1.343-.54.194-1.048-.09-1.302-.73a2.59 2.59 0 0 1-.16-.513c-.044-.255-.057-.516-.07-.65.006-1.065.306-1.909.957-2.58.961-.99 1.933-1.974 2.967-2.885 1.343-1.184 3.39-1.07 4.696.18 1.286 1.231 1.465 3.302.372 4.696-.393.5-.852.954-1.312 1.396-.443.418-.984.39-1.39-.015Z"/> <path fill="#67D1FF" d="M11.73 10.517c-.045-.524.17-.86.635-1.008.109-.036.223-.056.337-.058a14.81 14.81 0 0 0 2.111-3.4c-.545-.138-1.16.025-1.629.488-.799.786-1.58 1.59-2.374 2.38-.564.561-.732 1.206-.487 1.973.105.33.09.634-.02.876.503-.387.98-.804 1.427-1.251Z"/> <path fill="#67D1FF" d="M.002 10.908c-.014 1.342.22 2.674.69 3.93 1.169.044 2.339-.05 3.486-.279a4.1 4.1 0 0 1-.031-.291c.006-1.003.268-1.789.845-2.445a17.04 17.04 0 0 1 1.218-1.24c.413-.382.994-.373 1.355-.019.381.373.382.97-.017 1.398-.313.336-.66.643-.97.983-.293.324-.45.708-.473 1.09a14.72 14.72 0 0 0 3.485-1.75c-.46.063-.875-.223-1.095-.781a2.593 2.593 0 0 1-.159-.513c-.044-.255-.058-.516-.071-.651.006-1.065.306-1.908.958-2.58.96-.988 1.932-1.974 2.966-2.885.925-.815 2.183-1.015 3.303-.652.283-.955.474-1.95.559-2.976A11.018 11.018 0 0 0 11.026.002C5.02-.04.06 4.869.002 10.908Z"/> <path fill="#67D1FF" d="M.127 9.362c5.187-.924 9.441-4.54 11.27-9.35A15.24 15.24 0 0 0 11.023 0C5.543-.036.933 4.053.127 9.362Z"/> </svg>',
|
||||
copy: '<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22"> <path fill="#67D1FF" d="M8.25 1.833c0-1.012.821-1.833 1.833-1.833h8.25c1.012 0 1.833.821 1.833 1.833v10.084a1.833 1.833 0 0 1-1.833 1.833H17.05V1.833H8.25Z"/> <path fill="#67D1FF" d="M3.667 4.583c0-1.012.821-1.833 1.833-1.833h8.25c1.012 0 1.833.821 1.833 1.833v13.75A1.833 1.833 0 0 1 13.75 20.167H5.5a1.833 1.833 0 0 1-1.833-1.834V4.583Zm2.75 1.834v1.833H12.833V6.417H6.417Zm0 4.583v1.833H12.833V11H6.417Zm0 4.583v1.834h4.583v-1.834H6.417Z"/> </svg>',
|
||||
create:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22"> <path fill="#67D1FF" d="M11.005 0C4.93 0 0 4.924 0 11c0 6.074 4.93 11 11.005 11 6.071 0 11-4.926 11-11 0-6.076-4.929-11-11-11Zm5.5 12.373h-4.129V16.5H9.63v-4.127H5.506v-2.75h4.123V5.5h2.747v4.124h4.13v2.75Z"/> </svg>',
|
||||
ctrlc:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="19" height="20" fill="none" viewBox="0 0 19 20"> <path fill="#FFC16E" d="M7.154 1.265h1.994c.008 0 .011.006.011.016v2.562h1.703v1.851H5.788c-.008 0-.012-.005-.012-.015V3.858c0-.01.004-.015.012-.015h1.366V1.265Zm0 4.846H9.16v2.083c0 .335.04.625.118.872.09.185.276.278.56.278.25 0 .517-.075.8-.224.169 1.27.253 1.922.253 1.953h-.006c-.392.272-.933.408-1.624.408h-.022c-.982 0-1.604-.416-1.865-1.25-.146-.396-.219-.99-.219-1.782V6.111Zm8.62-2.276c.235 0 .446.026.633.077v.008c0 .025-.077.81-.23 2.353a1.844 1.844 0 0 0-.711-.139c-.68 0-1.225.324-1.636.972h-.005v-2.16c.201-.458.47-.772.806-.941.194-.114.575-.17 1.143-.17Zm-4.324.139h2.09v7.507h-2.09V3.974ZM16.967 0h2.022c.007 0 .011.005.011.015v11.459h-2.033V0ZM2.879 2.984h.084c.672 0 1.234.244 1.686.733.086.098.179.226.28.386.15.262.26.52.33.771.075.273.13.587.168.942l-1.87.254c-.049-.34-.124-.589-.224-.748-.124-.175-.271-.262-.443-.262-.007 0-.011-.006-.011-.016v-2.06Zm-.275.008v2.106c-.09.016-.2.11-.33.286-.198.334-.297.861-.297 1.581v.108c.004.921.163 1.513.476 1.775a.686.686 0 0 0 .387.124c.388 0 .634-.286.739-.857l.022-.224H5.49c-.019.252-.05.502-.095.749a4.103 4.103 0 0 1-.415 1.196c-.467.807-1.182 1.211-2.145 1.211-.866 0-1.548-.324-2.044-.972C.263 9.38 0 8.377 0 7.065c0-1.717.452-2.924 1.355-3.618a2.6 2.6 0 0 1 .622-.324c.18-.062.388-.106.627-.131Z"/> <path fill="#5BD2FF" d="M10.821 11.937h.084c.673 0 1.234.244 1.686.732.086.098.18.227.28.386.15.263.26.52.33.772.075.273.131.586.169.941l-1.87.255c-.05-.34-.124-.589-.225-.748-.123-.175-.27-.263-.443-.263-.007 0-.01-.005-.01-.015v-2.06Zm-.274.007v2.107c-.09.015-.2.11-.33.285-.198.335-.297.862-.297 1.582v.108c.003.92.162 1.512.476 1.775a.685.685 0 0 0 .386.123c.389 0 .635-.285.74-.856l.022-.224h1.887c-.018.252-.05.502-.095.748a4.102 4.102 0 0 1-.414 1.196C12.455 19.596 11.74 20 10.776 20c-.866 0-1.547-.324-2.044-.972-.526-.695-.79-1.698-.79-3.01 0-1.718.452-2.924 1.356-3.619.15-.113.357-.22.622-.324a2.88 2.88 0 0 1 .627-.13Z"/> </svg>',
|
||||
delete:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22"> <path fill="#67D1FF" d="M11.005 0C4.93 0 0 4.924 0 11c0 6.074 4.93 11 11.005 11 6.071 0 11-4.926 11-11 0-6.076-4.929-11-11-11Zm5.5 12.373H5.506v-2.75h11v2.75Z"/> </svg>',
|
||||
down: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 20 20"> <path fill="#FFC16E" d="M10 0c5.523 0 10 4.477 10 10s-4.477 10-10 10S0 15.523 0 10 4.477 0 10 0Zm0 2a8 8 0 0 0-8 8 8 8 0 0 0 8 8 8 8 0 0 0 8-8 8 8 0 0 0-8-8Z"/> <path fill="#FFC16E" d="M10.714 12.66a1.001 1.001 0 0 1-1.457.047L5.72 9.172a1 1 0 0 1 1.414-1.415l2.827 2.828 2.831-2.832a1 1 0 0 1 1.414 1.415l-3.493 3.493Z"/> </svg>',
|
||||
enter:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="21" height="17" fill="none" viewBox="0 0 21 17"> <path fill="#FFC16E" stroke="#FFC16E" stroke-width=".5" d="m14.422.25.312.008a6.495 6.495 0 0 1 4.156 1.75c1.187 1.126 1.858 2.655 1.86 4.254l-.008.3a5.89 5.89 0 0 1-1.852 3.955 6.509 6.509 0 0 1-4.468 1.757H3.134l3.248 3.079H6.38a.783.783 0 0 1 .016 1.166.846.846 0 0 1-.603.231.867.867 0 0 1-.588-.247L.5 12.044a.795.795 0 0 1-.25-.576c0-.219.092-.425.25-.575L5.206 6.43l.007-.006a.857.857 0 0 1 1.154.02.794.794 0 0 1 .25.56.792.792 0 0 1-.23.57l-.005.007H6.38l-3.246 3.077h11.287a4.793 4.793 0 0 0 3.295-1.292 4.278 4.278 0 0 0 1.357-3.104 4.278 4.278 0 0 0-1.357-3.105 4.794 4.794 0 0 0-3.295-1.293H5.793a.855.855 0 0 1-.588-.231.794.794 0 0 1-.25-.576c0-.219.092-.426.25-.577a.855.855 0 0 1 .588-.23h8.629Z"/> </svg>',
|
||||
esc: '<svg xmlns="http://www.w3.org/2000/svg" width="19" height="10" fill="none" viewBox="0 0 19 10"> <path fill="#FFC16E" d="M2.98 0h.132C4.068 0 4.81.496 5.34 1.489c.004.019.044.105.12.257.302.75.453 1.641.453 2.672v1.05H2.258V3.702h1.756a3.7 3.7 0 0 0-.108-.82c-.135-.471-.422-.706-.86-.706-.498 0-.826.32-.985.963a6.815 6.815 0 0 0-.114 1.346c0 .502.028 1.027.084 1.574.075.458.187.77.334.935.156.178.37.268.645.268.474 0 .779-.274.914-.821a.381.381 0 0 1 .024-.105h1.923a7.59 7.59 0 0 1-.179.954c-.243.853-.635 1.447-1.176 1.785-.41.273-.906.41-1.488.41C1.611 9.472.685 8.677.251 7.099.084 6.463 0 5.748 0 4.952V4.82c0-1.304.211-2.376.633-3.216C1.195.534 1.977 0 2.981 0Zm6.398 0v2.118a.83.83 0 0 0-.466.163.648.648 0 0 0-.197.486v.048c0 .286.165.48.495.582.16.051.62.178 1.38.382.434.152.779.318 1.033.496.618.452.926 1.167.926 2.147v.143c0 1.082-.306 1.874-.92 2.376-.473.388-1.134.582-1.983.582h-.012c-1.557 0-2.536-.658-2.938-1.975a4.659 4.659 0 0 1-.185-1.174h2.072c.004 0 .02.067.048.2.032.122.078.233.137.335.172.26.458.39.86.39.518 0 .777-.215.777-.648v-.086c0-.324-.18-.537-.538-.64-.736-.158-1.268-.301-1.594-.429a3.225 3.225 0 0 1-.849-.505c-.521-.484-.782-1.174-.782-2.071 0-1.107.348-1.912 1.045-2.414A2.853 2.853 0 0 1 9.342.01h.006l.03-.01Zm.298.01h.006c.593 0 1.145.168 1.655.505.167.128.336.293.507.497.208.311.325.521.353.63.095.222.175.492.239.81l.041.287-.011.01c-.156.018-.83.114-2.025.286-.048-.287-.132-.506-.251-.659a.607.607 0 0 0-.317-.21c-.012-.013-.077-.028-.197-.048V.01Zm6.541.018h.09c.716 0 1.315.303 1.797.907.092.12.191.28.299.477.159.325.277.643.352.954.08.337.14.726.18 1.164l-1.996.315c-.051-.42-.131-.728-.239-.925-.131-.217-.288-.325-.471-.325-.008 0-.012-.006-.012-.019V.028Zm-.293.01v2.605c-.096.02-.213.137-.352.353-.211.414-.317 1.066-.317 1.956v.134c.004 1.138.173 1.87.508 2.194a.664.664 0 0 0 .412.153c.414 0 .677-.353.788-1.059l.024-.277H19c-.02.312-.054.62-.102.926a5.637 5.637 0 0 1-.442 1.479C17.96 9.5 17.196 10 16.17 10c-.924 0-1.65-.4-2.18-1.202-.562-.86-.843-2.1-.843-3.722 0-2.124.482-3.616 1.446-4.475.16-.14.38-.274.663-.4.191-.077.414-.131.669-.163Z"/> </svg>',
|
||||
home:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 20 20"> <path fill="#FFC16E" d="M19.765 6.768 10.321.102a.556.556 0 0 0-.642 0L.235 6.768A.557.557 0 0 0 0 7.222v12.222A.555.555 0 0 0 .556 20h6.11a.555.555 0 0 0 .556-.556v-6.666h5.556v6.666a.556.556 0 0 0 .555.556h6.111a.556.556 0 0 0 .556-.556V7.222a.557.557 0 0 0-.235-.454Z"/> </svg>',
|
||||
keyboard:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="23" height="22" fill="none" viewBox="0 0 23 22"> <path fill="#E6F0FF" d="M10.82 2.827a3.266 3.266 0 0 1 .457-1.634A1.635 1.635 0 0 1 12.65.425a4.707 4.707 0 0 1 1.487.245c.507.164 1.063.392 1.635.621a20.07 20.07 0 0 0 2.043.752 3.465 3.465 0 0 0 2.14.098 2.565 2.565 0 0 0 1.39-1.863L21.427 0h.278l.555.163h.294l-.081.278a3.58 3.58 0 0 1-2.076 2.648 4.674 4.674 0 0 1-2.942 0l-1.078-.262-1.046-.424a7.649 7.649 0 0 0-2.615-.785.621.621 0 0 0-.54.36 2.32 2.32 0 0 0-.18.85h.31l.377 1.928h-2.534l.376-1.929h.295ZM2.73 4.772h16.425a2.746 2.746 0 0 1 2.73 2.73v11.767a2.746 2.746 0 0 1-2.73 2.73H2.811A2.73 2.73 0 0 1 0 19.269V7.502a2.73 2.73 0 0 1 2.73-2.73Zm13.728 12.389a.458.458 0 0 0-.441.458v1.258a.458.458 0 0 0 .441.458h1.88a.457.457 0 0 0 .441-.458V17.62a.457.457 0 0 0-.441-.458h-1.88Zm-9.3 0a.621.621 0 0 0-.604.605v.964a.62.62 0 0 0 .605.605h7.387a.62.62 0 0 0 .605-.605v-.964a.621.621 0 0 0-.605-.605H7.159Zm6.162-9.038a.458.458 0 0 0-.408.458v1.274a.44.44 0 0 0 .44.442H15.2a.44.44 0 0 0 .441-.442V8.581a.458.458 0 0 0-.441-.458h-1.88Zm-3.677 0a.458.458 0 0 0-.441.458v1.274a.441.441 0 0 0 .44.442h1.913a.441.441 0 0 0 .441-.442V8.581a.458.458 0 0 0-.44-.458H9.642Zm-6.015 0a.458.458 0 0 0-.441.458v1.274a.441.441 0 0 0 .441.442h4.56a.441.441 0 0 0 .442-.442V8.581a.458.458 0 0 0-.442-.458h-4.56Zm6.783 3.04a.458.458 0 0 0-.441.457v1.259a.458.458 0 0 0 .441.458h1.88a.458.458 0 0 0 .44-.458V11.62a.458.458 0 0 0-.44-.458h-1.88Zm-3.465 0a.474.474 0 0 0-.457.457v1.259a.474.474 0 0 0 .457.458h1.88a.458.458 0 0 0 .44-.458V11.62a.458.458 0 0 0-.44-.458h-1.88Zm-3.383 0a.457.457 0 0 0-.458.457v1.259a.458.458 0 0 0 .442.458h1.895a.458.458 0 0 0 .442-.458V11.62a.458.458 0 0 0-.442-.458h-1.88Zm9.349 2.909a.441.441 0 0 0-.442.441v1.275a.44.44 0 0 0 .442.441h1.928a.441.441 0 0 0 .441-.44v-1.276a.441.441 0 0 0-.44-.441h-1.93Zm-3.498 0a.44.44 0 0 0-.441.441v1.275a.441.441 0 0 0 .441.441h1.863a.44.44 0 0 0 .442-.44v-1.276a.441.441 0 0 0-.442-.441H9.414Zm-5.933 0a.441.441 0 0 0-.376.441v1.275a.441.441 0 0 0 .442.441H7.86a.44.44 0 0 0 .442-.44v-1.276a.442.442 0 0 0-.442-.441H3.48Zm0 3.089a.457.457 0 0 0-.441.458v1.258a.458.458 0 0 0 .441.458H5.41a.458.458 0 0 0 .441-.458V17.62a.458.458 0 0 0-.441-.458H3.48Zm10.722-5.998a.458.458 0 0 0-.441.457v1.259a.458.458 0 0 0 .44.458h4.495V8.515a.457.457 0 0 0-.457-.441h-1.26a.457.457 0 0 0-.457.441v2.648h-2.321Zm2.272 2.909a.44.44 0 0 0-.442.441v1.275a.44.44 0 0 0 .442.441h1.88a.458.458 0 0 0 .457-.44v-1.276a.458.458 0 0 0-.458-.441h-1.88Z"/> </svg>',
|
||||
left: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 20 20"> <path fill="#FFC16E" d="M20 10c0 5.523-4.477 10-10 10S0 15.523 0 10 4.477 0 10 0s10 4.477 10 10Zm-2 0a8 8 0 0 0-8-8 8 8 0 0 0-8 8 8 8 0 0 0 8 8 8 8 0 0 0 8-8Z"/> <path fill="#FFC16E" d="M7.34 10.714a1 1 0 0 1-.047-1.457l3.535-3.536a1 1 0 1 1 1.415 1.414L9.415 9.962l2.832 2.831a1 1 0 0 1-1.415 1.414l-3.493-3.493Z"/> </svg>',
|
||||
log: '<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22"> <path fill="#67D1FF" fill-rule="evenodd" d="M11.299 14.804c1.045 0 1.901.316 2.545.961.646.647.956 1.537.956 2.646 0 1.031-.271 1.873-.833 2.506l-.116.124c-.64.645-1.491.959-2.535.959-.987 0-1.803-.275-2.429-.838l-.122-.116c-.64-.642-.95-1.518-.95-2.603 0-.69.105-1.282.327-1.769.164-.356.39-.676.672-.959.196-.2.422-.37.669-.505l.264-.128.016-.005c.45-.184.964-.273 1.536-.273Zm2.181 6.32a2.888 2.888 0 0 1 .119-.101l-.119.101Zm-4.865-.544a2.745 2.745 0 0 0-.017-.024l.017.024Zm2.697-4.265c-.517 0-.92.168-1.227.502-.303.329-.471.844-.471 1.58 0 .723.171 1.237.485 1.576.318.346.718.517 1.213.517.496 0 .893-.17 1.206-.511.308-.335.479-.857.479-1.599 0-.642-.128-1.114-.36-1.439l-.105-.13c-.301-.329-.702-.496-1.22-.496Zm-3.29 1.702c-.003.034-.004.069-.006.104l.01-.162-.004.058Zm5.896-1.886a2.836 2.836 0 0 1-.102-.121l.102.121Zm4.908-1.327c.868 0 1.575.176 2.094.551.515.373.845.888.99 1.535l.042.187-.194.035-1.365.247-.174.032-.046-.166a1.225 1.225 0 0 0-.466-.665l-.09-.058a1.542 1.542 0 0 0-.79-.19c-.557 0-.98.17-1.293.495v-.001c-.31.323-.48.818-.48 1.518 0 .665.134 1.158.38 1.5l.11.138.002.001c.318.349.735.525 1.267.525.263 0 .527-.049.795-.151h.003a2.79 2.79 0 0 0 .62-.323v-.555h-1.318l-.258.188v-1.67H22v2.894l-.058.055c-.313.293-.755.543-1.315.754v-.001A4.786 4.786 0 0 1 18.9 22h-.001c-.74-.001-1.394-.15-1.957-.458a3.006 3.006 0 0 1-1.264-1.308l-.004-.009-.003-.007-.097-.214a4.038 4.038 0 0 1-.316-1.355l-.005-.233v-.038c0-.714.155-1.355.468-1.92a3.177 3.177 0 0 1 1.37-1.299l.007-.003.018-.008c.47-.233 1.043-.344 1.71-.344Zm-3.19 2.321-.01.034a2.55 2.55 0 0 1 .07-.197l-.06.163Z" clip-rule="evenodd"/> <path fill="#67D1FF" d="M4.079 14.97v5.435h3.417v1.483H2.051l.272-.263V14.97h1.756Z"/> <path fill="#67D1FF" fill-rule="evenodd" d="M16.812.004c1.383.048 2.591 1.306 2.591 2.734v11.467h-1.957V2.501a.6.6 0 0 0-.227-.444.777.777 0 0 0-.489-.194H2.562c-.315 0-.596.272-.596.638v19.186l-.002.183H0l.008-.193c.012-.254.013-.867.01-1.422a170.26 170.26 0 0 0-.006-.727c0-.095-.002-.173-.003-.227V2.736C.008 1.294 1.132 0 2.56 0h14.248l.003.004Z" clip-rule="evenodd"/> <path fill="#67D1FF" d="M16.24 11.13H3.177V9.22H16.24v1.91Zm0-4.61H3.177V4.61H16.24v1.91Z"/> </svg>',
|
||||
paste:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="19" height="20" fill="none" viewBox="0 0 19 20"> <path fill="#FFC16E" d="M17 9h-7a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2v-7a2 2 0 0 0-2-2Zm1-2V4a2 2 0 0 0-2-2h-2a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2H2a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4V9a2 2 0 0 1 2-2h10ZM6 4V2h6v2H6Z"/> </svg>',
|
||||
plugins:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22"> <path fill="#67D1FF" d="M9.002 0a3 3 0 0 1 2.828 4.002h4.171c1.106 0 2 .894 2 1.998v4.171a3 3 0 1 1 0 5.658v4.17a2 2 0 0 1-2 2.001h-4.17a3 3 0 1 0-5.658 0H2a2 2 0 0 1-2-2v-4.171a3 3 0 1 0 0-5.658v-4.17a2 2 0 0 1 2-2h4.171a3 3 0 0 1 2.833-4L9.002 0Z"/> </svg>',
|
||||
record:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"> <path fill="#67D1FF" d="M.003 22.986a.433.433 0 0 0 .608.45l5.55-2.423-5.49-4.045c-.207 1.825-.638 5.72-.668 6.018Zm14.992-5.252c0 .464.377.84.84.84h6.72a.84.84 0 0 0 .84-.84.84.84 0 0 0-.84-.837h-6.72a.84.84 0 0 0-.84.837ZM18.51 5.132a1.203 1.203 0 0 0-.255-1.683L13.892.235a1.205 1.205 0 0 0-1.685.255l-.357.485 6.303 4.642.357-.485Zm-7.16-3.479L1.204 15.417l6.303 4.644L17.653 6.296 11.35 1.653ZM8.028 16.91a.73.73 0 1 1-1.182-.852l6.83-9.47a.73.73 0 1 1 1.181.852l-6.829 9.47Zm14.632 4.91H9.23a.838.838 0 1 0 0 1.678h13.43a.84.84 0 1 0 0-1.678Z"/> </svg>',
|
||||
recordmanager:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22"> <path fill="#67D1FF" d="M20.977 9.325a.963.963 0 0 0-.963.965v8.815a.966.966 0 0 1-.963.965H4.538a.966.966 0 0 1-.963-.965v-4.897h.685a.963.963 0 0 0 .963-.965.963.963 0 0 0-.963-.965H.963a.963.963 0 0 0-.963.965c0 .534.43.965.963.965h.685v4.897A2.897 2.897 0 0 0 4.538 22H19.05c1.592 0 2.89-1.3 2.89-2.895V10.29a.963.963 0 0 0-.964-.965ZM8.164 9.61l-1.278 3.419c-.218.583-.066 1.233.396 1.696.323.324.736.496 1.154.496.182 0 .364-.033.54-.1l3.411-1.28c.33-.124.621-.31.867-.557l8.153-8.17c.405-.405.613-.982.568-1.578a2.358 2.358 0 0 0-.694-1.486L19.935.7A2.35 2.35 0 0 0 18.45.007a2.006 2.006 0 0 0-1.575.568L8.72 8.741a2.427 2.427 0 0 0-.556.869Zm1.804.678a.487.487 0 0 1 .114-.18l8.155-8.172a.118.118 0 0 1 .052-.008h.017a.447.447 0 0 1 .265.135l1.347 1.349c.079.08.128.178.134.266.003.043-.006.066-.008.068L11.89 11.92a.505.505 0 0 1-.18.114L8.924 13.08l1.044-2.792ZM.963 6.9H4.26a.963.963 0 0 0 .963-.965.963.963 0 0 0-.963-.965h-.685V2.934c0-.532.432-.966.963-.966h7.256a.963.963 0 0 0 .963-.965.963.963 0 0 0-.963-.965H4.538c-1.593 0-2.89 1.3-2.89 2.896V4.97H.963A.963.963 0 0 0 0 5.936c0 .534.43.965.963.965Zm0 3.653H4.26a.963.963 0 0 0 .963-.965.963.963 0 0 0-.963-.966H.963A.963.963 0 0 0 0 9.59c0 .534.43.965.963.965Z"/> </svg>',
|
||||
reading:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22"> <path fill="#FFC16E" d="M16.074 1.168c.073-.13.167-.242.276-.329a.918.918 0 0 1 .36-.177.84.84 0 0 1 .39 0 .92.92 0 0 1 .36.179c1.397 1.114 2.542 2.622 3.334 4.39A14.112 14.112 0 0 1 22 10.98c0 2.005-.414 3.979-1.206 5.747-.792 1.769-1.937 3.277-3.334 4.391a.93.93 0 0 1-.362.19.841.841 0 0 1-.395.009.913.913 0 0 1-.368-.176 1.113 1.113 0 0 1-.28-.333 1.327 1.327 0 0 1-.152-.436 1.414 1.414 0 0 1 .003-.472c.028-.156.08-.303.156-.434.076-.13.173-.242.286-.327 1.123-.898 2.044-2.111 2.68-3.534a11.36 11.36 0 0 0 .97-4.624c0-1.613-.333-3.201-.97-4.624-.636-1.423-1.557-2.637-2.68-3.534a1.116 1.116 0 0 1-.276-.33 1.33 1.33 0 0 1-.15-.43 1.42 1.42 0 0 1 0-.464c.026-.154.077-.3.15-.43h.002Z"/> <path fill="#FFC16E" d="M14.054 5.567c.093-.11.203-.198.325-.258a.861.861 0 0 1 .764 0c.122.06.232.148.325.258 1.34 1.599 2.092 3.767 2.092 6.027 0 2.261-.753 4.429-2.092 6.028a.921.921 0 0 1-.703.336.926.926 0 0 1-.7-.349 1.316 1.316 0 0 1-.292-.833 1.322 1.322 0 0 1 .281-.84 6.326 6.326 0 0 0 1.117-1.991 7.178 7.178 0 0 0 .393-2.351c0-.807-.134-1.606-.393-2.35a6.327 6.327 0 0 0-1.117-1.993 1.229 1.229 0 0 1-.216-.387 1.395 1.395 0 0 1 0-.912c.05-.145.123-.277.216-.387v.002ZM11.09.016a1.537 1.537 0 0 0-.72.068c-.235.08-.453.215-.642.398L4.656 5.005H2.118c-.512-.052-1.02.137-1.416.528-.396.39-.648.95-.702 1.56v6.705c.053.61.305 1.17.7 1.56.397.392.905.582 1.417.53H4.66l5.069 5.626c.328.309.732.48 1.15.486.07-.003.142-.01.212-.024.116-.016.23-.046.342-.088.258-.104.481-.304.636-.571.155-.267.234-.586.225-.91V1.584c0-.029-.009-.05-.011-.079a1.715 1.715 0 0 0-.242-.859 1.304 1.304 0 0 0-.95-.633v.002Z"/> </svg>',
|
||||
right:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 20 20"> <path fill="#FFC16E" d="M0 10C0 4.477 4.477 0 10 0s10 4.477 10 10-4.477 10-10 10S0 15.523 0 10Zm2 0a8 8 0 0 0 8 8 8 8 0 0 0 8-8 8 8 0 0 0-8-8 8 8 0 0 0-8 8Z"/> <path fill="#FFC16E" d="M12.66 9.286a1 1 0 0 1 .047 1.457L9.172 14.28a1 1 0 0 1-1.415-1.414l2.828-2.827-2.832-2.831a1 1 0 1 1 1.415-1.414l3.493 3.493Z"/> </svg>',
|
||||
save: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 20 20"> <path fill="#67D1FF" d="M1.111 0h14.445l4.118 4.119c.209.208.326.49.326.785V18.89A1.111 1.111 0 0 1 18.889 20H1.11A1.111 1.111 0 0 1 0 18.889V1.11A1.111 1.111 0 0 1 1.111 0ZM10 16.667A3.333 3.333 0 1 0 10 10a3.333 3.333 0 0 0 0 6.667ZM2.222 2.222v4.445h11.111V2.222H2.223Z"/> </svg>',
|
||||
search:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none" viewBox="0 0 15 15"> <path fill="#67D1FF" d="m2.363 14.276 4.084-4.081-2.112-2.112-4.084 4.084a.868.868 0 0 0 0 1.222l.887.887a.872.872 0 0 0 1.225 0Zm2.883-6.101 1.106 1.107 1.26-1.26a4.41 4.41 0 0 0 5.626-.505 4.409 4.409 0 0 0 0-6.228 4.406 4.406 0 0 0-6.228 0 4.41 4.41 0 0 0-.504 5.626l-1.26 1.26Zm2.63-6.032a3.186 3.186 0 0 1 4.508 0 3.186 3.186 0 0 1 0 4.508 3.186 3.186 0 0 1-4.508 0 3.186 3.186 0 0 1 0-4.508Z"/> </svg>',
|
||||
selectall:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22"> <path fill="#67D1FF" fill-rule="evenodd" d="M20.428 16.62c.866 0 1.572.602 1.572 1.346v2.688c0 .742-.703 1.346-1.572 1.346H1.572C.706 22 0 21.398 0 20.654v-2.691c0-.742.703-1.344 1.572-1.344h18.856Zm-4.086 2.61-1.292-1.098-1.05.891L16.326 21 20 17.879 18.966 17l-2.624 2.23ZM5.235 17.963c-.866 0-1.572.601-1.572 1.346 0 .744.703 1.345 1.572 1.345.867 0 1.574-.601 1.574-1.345 0-.745-.704-1.346-1.574-1.346Zm15.193-9.651c.866 0 1.572.601 1.572 1.345v2.689c0 .741-.703 1.345-1.572 1.345H1.572C.706 13.691 0 13.09 0 12.346V9.654c0-.741.703-1.342 1.572-1.342h18.856Zm-4.086 2.918-1.292-1.098-1.05.891L16.326 13 20 9.879 18.966 9l-2.624 2.23ZM5.235 9.653c-.866 0-1.572.602-1.572 1.346 0 .744.703 1.346 1.572 1.346.867 0 1.574-.602 1.574-1.346 0-.744-.704-1.346-1.574-1.346ZM20.428 0C21.294 0 22 .602 22 1.346v2.688c0 .742-.703 1.347-1.572 1.347H1.572C.706 5.38 0 4.778 0 4.034V1.343C0 .6.703 0 1.572 0h18.856Zm-4.086 3.23L15.05 2.131 14 3.023 16.326 5 20 1.879 18.966 1l-2.624 2.23ZM5.235 1.342c-.866 0-1.572.602-1.572 1.345 0 .745.703 1.346 1.572 1.346.867 0 1.574-.601 1.574-1.346 0-.744-.704-1.345-1.574-1.345Z" clip-rule="evenodd"/> </svg>',
|
||||
sent: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"> <path fill="#67D1FF" d="m0 24 2.496-11.112 13.8-.864-13.8-.936L0 0l24 12L0 24Z"/> </svg>',
|
||||
shell:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22"> <path fill="#FFC16E" d="M11 0a11 11 0 1 0 0 22 11 11 0 0 0 0-22ZM5.958 15.732l-1.304-1.35L7.99 11 4.654 7.618l1.304-1.412 4.654 4.654-4.654 4.872Zm11.775-.295H11.28v-1.94h6.47l-.016 1.94Z"/> </svg>',
|
||||
serverlist:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="22" height="20" fill="none" viewBox="0 0 22 20"> <path fill="#67D1FF" d="M2.2 4.4a2.2 2.2 0 1 0 0-4.4 2.2 2.2 0 0 0 0 4.4ZM8.25.55a1.65 1.65 0 0 0 0 3.3h12.1a1.65 1.65 0 1 0 0-3.3H8.25ZM6.6 9.9a1.65 1.65 0 0 1 1.65-1.65h12.1a1.65 1.65 0 1 1 0 3.3H8.25A1.65 1.65 0 0 1 6.6 9.9Zm0 7.7a1.65 1.65 0 0 1 1.65-1.65h12.1a1.65 1.65 0 1 1 0 3.3H8.25A1.65 1.65 0 0 1 6.6 17.6ZM4.4 9.9a2.2 2.2 0 1 1-4.4 0 2.2 2.2 0 0 1 4.4 0Zm-2.2 9.9a2.2 2.2 0 1 0 0-4.4 2.2 2.2 0 0 0 0 4.4Z" opacity=".99"/> </svg>',
|
||||
shift:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="22" height="18" fill="none" viewBox="0 0 22 18"> <path fill="#FFC16E" d="M7.845 18h6.31c1.432 0 2.2-.795 2.2-2.076V11.58h4.162c.828 0 1.483-.485 1.483-1.222 0-.456-.236-.796-.614-1.144L12.263.592C11.874.214 11.455 0 11.005 0c-.46 0-.87.214-1.269.592L.614 9.214C.225 9.582 0 9.902 0 10.358c0 .737.655 1.222 1.493 1.222h4.153v4.344c0 1.28.766 2.076 2.199 2.076Z"/> </svg>',
|
||||
stopreading:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22"> <path fill="#FFC16E" d="M12.034 21.507 6.567 16.62h-3.53a3.011 3.011 0 0 1-2.041-.802 3.106 3.106 0 0 1-.981-1.988L0 13.526V8.47c0-.818.318-1.603.886-2.183a3.017 3.017 0 0 1 2.144-.91l1.394 1.694H3.038a1.37 1.37 0 0 0-.974.41 1.414 1.414 0 0 0-.404.99v5.063c0 .371.146.727.404.99.259.262.61.41.974.41H6.88a.81.81 0 0 1 .551.211l5.708 5.1a.273.273 0 0 0 .2.066.27.27 0 0 0 .187-.097.259.259 0 0 0 .067-.181v-1.84l1.639 1.992c-.026.364-.15.713-.361 1.008a1.938 1.938 0 0 1-.831.663 1.895 1.895 0 0 1-2.002-.349h-.005ZM1.66 1.087 2.934.003 19.643 20.28l-1.274 1.091L1.659 1.086Zm16.982 14.92a.854.854 0 0 1 .06-1.231 5.138 5.138 0 0 0 1.632-3.531 5.154 5.154 0 0 0-1.298-3.672 3.897 3.897 0 0 0-.365-.366.84.84 0 0 1-.303-.579.854.854 0 0 1 .197-.624.83.83 0 0 1 .578-.292.817.817 0 0 1 .608.218l.03.023a6.767 6.767 0 0 1 1.563 2.115 6.862 6.862 0 0 1 .278 5.18 6.801 6.801 0 0 1-1.327 2.276 5.02 5.02 0 0 1-.485.493.803.803 0 0 1-.896.178.844.844 0 0 1-.276-.188h.004Zm-5.049-5.696V1.963a.276.276 0 0 0-.067-.18.284.284 0 0 0-.386-.024l-3.79 3.39-1.073-1.294 3.77-3.36a1.899 1.899 0 0 1 2.072-.323c.34.156.627.407.828.725.202.318.309.688.308 1.067v10.358l-1.662-2.011Z"/> </svg>',
|
||||
tab: '<svg xmlns="http://www.w3.org/2000/svg" width="19" height="14" fill="none" viewBox="0 0 19 14"> <path fill="#FFC16E" d="M1.497 1.583h2.166c.008 0 .012.006.012.018v3.11h1.85v2.247H.011C.004 6.958 0 6.952 0 6.939V4.73c0-.012.004-.019.012-.019h1.485V1.583Zm0 5.88h2.178v2.529c0 .406.042.758.127 1.058.098.225.3.337.609.337.272 0 .562-.09.87-.271.182 1.542.274 2.331.274 2.369h-.006c-.426.33-1.014.496-1.765.496H3.76c-1.067 0-1.742-.505-2.026-1.517-.158-.48-.237-1.202-.237-2.163V7.463Zm7.982-2.762c1.37 0 2.24.612 2.61 1.835.146.512.219 1.071.219 1.677V14H10.25v-1.19c-.385.525-.75.86-1.095 1.003a2.05 2.05 0 0 1-.84.178c-.81 0-1.415-.356-1.812-1.068-.227-.424-.341-.949-.341-1.573v-.019c0-1.423.606-2.329 1.82-2.716.194-.068.498-.13.912-.187.198-.019.531-.044.997-.075v1.714c-.462.025-.768.06-.918.103a1.402 1.402 0 0 0-.408.206c-.19.175-.286.415-.286.721v.028c0 .524.244.787.73.787.515 0 .874-.29 1.077-.871.033-.138.05-.206.055-.206h.006v-.038c.02-.187.03-.33.03-.43.008-1.274.012-2.092.012-2.454 0-.506-.146-.84-.438-1.002a.825.825 0 0 0-.37-.084c-.418 0-.69.2-.816.6a3.907 3.907 0 0 0-.055.27h-.006a97.59 97.59 0 0 1-1.977-.215 4.55 4.55 0 0 1 .158-.796c.183-.668.538-1.183 1.065-1.545.462-.293 1.038-.44 1.728-.44ZM12.916 0h2.044c-.004 2.997-.006 5.076-.006 6.237v1.498c0 1.523.008 2.285.024 2.285.033.375.084.662.153.862.158.43.424.646.797.646.527 0 .87-.422 1.028-1.264a4.96 4.96 0 0 0 .073-.871v-.15c0-.525-.065-.977-.195-1.358-.195-.512-.493-.768-.894-.768-.134 0-.365.09-.694.272v-2.06c.15-.163.416-.328.797-.497.231-.087.426-.131.584-.131h.012c.666 0 1.26.415 1.783 1.245.385.693.578 1.73.578 3.11v.243c0 1.342-.217 2.457-.651 3.343-.454.85-1.059 1.274-1.813 1.274h-.03c-.69 0-1.223-.381-1.6-1.143v1.133c0 .013-.005.02-.013.02h-1.977V0Z"/> </svg>',
|
||||
up: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 20 20"> <path fill="#FFC16E" d="M10 20C4.477 20 0 15.523 0 10S4.477 0 10 0s10 4.477 10 10-4.477 10-10 10Zm0-2a8 8 0 0 0 8-8 8 8 0 0 0-8-8 8 8 0 0 0-8 8 8 8 0 0 0 8 8Z"/> <path fill="#FFC16E" d="M9.286 7.34a1 1 0 0 1 1.457-.047l3.536 3.535a1 1 0 0 1-1.414 1.415l-2.827-2.828-2.831 2.832a1 1 0 0 1-1.414-1.415l3.493-3.493Z"/> </svg>',
|
||||
voice:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="27" height="36" fill="none" viewBox="0 0 27 36"> <g opacity="1"> <path fill="#67D1FF" d="M13.326 26.661a8.254 8.254 0 0 0 8.246-8.246V8.245a8.246 8.246 0 1 0-16.491 0v10.17a8.254 8.254 0 0 0 8.245 8.246Z"/> <path fill="#67D1FF" d="M22.759 26.932a13.487 13.487 0 0 0 3.894-9.47V11.64a1.47 1.47 0 1 0-2.941 0v5.822a10.453 10.453 0 0 1-10.386 10.513A10.458 10.458 0 0 1 2.941 17.462V11.64a1.47 1.47 0 0 0-2.941 0v5.822a13.483 13.483 0 0 0 3.898 9.47 13.242 13.242 0 0 0 7.966 3.882v2.245h-6.16a1.47 1.47 0 0 0 0 2.941h15.254a1.47 1.47 0 1 0 0-2.94h-6.161v-2.246a13.263 13.263 0 0 0 7.962-3.882Z"/> </g> </svg>'
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
ICON_SVG_SOURCES
|
||||
};
|
||||
34
apps/miniprogram/utils/localeBus.js
Normal file
34
apps/miniprogram/utils/localeBus.js
Normal file
@@ -0,0 +1,34 @@
|
||||
/* global module */
|
||||
|
||||
/**
|
||||
* 轻量语言事件总线:
|
||||
* 1. 设置页切换界面语言时,当前页内组件可立即刷新;
|
||||
* 2. 只传递 uiLanguage,不耦合其它设置项;
|
||||
* 3. 订阅方自行决定是否需要重新读取完整 settings。
|
||||
*/
|
||||
const listeners = new Set();
|
||||
|
||||
function emitLocaleChange(language) {
|
||||
listeners.forEach((listener) => {
|
||||
try {
|
||||
listener(language);
|
||||
} catch {
|
||||
// 忽略单个订阅方异常,避免影响其它页面刷新。
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function subscribeLocaleChange(listener) {
|
||||
if (typeof listener !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
emitLocaleChange,
|
||||
subscribeLocaleChange
|
||||
};
|
||||
20
apps/miniprogram/utils/navigationPolicy.js
Normal file
20
apps/miniprogram/utils/navigationPolicy.js
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 小程序页面导航策略:
|
||||
* 1. 底部导航在“页面之间横跳”时优先使用 redirectTo,避免长期堆积页面实例;
|
||||
* 2. 同一路径返回 noop,避免重复触发无意义导航;
|
||||
* 3. 仅当目标不是标准页面路由时,才退回 navigateTo。
|
||||
*/
|
||||
function resolvePageNavigationMethod(currentPath, targetPath) {
|
||||
const current = String(currentPath || "").trim();
|
||||
const target = String(targetPath || "").trim();
|
||||
if (!target) return "noop";
|
||||
if (current && current === target) return "noop";
|
||||
if (/^\/pages\/.+\/index$/.test(target)) {
|
||||
return "redirectTo";
|
||||
}
|
||||
return "navigateTo";
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolvePageNavigationMethod
|
||||
};
|
||||
19
apps/miniprogram/utils/navigationPolicy.test.ts
Normal file
19
apps/miniprogram/utils/navigationPolicy.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const { resolvePageNavigationMethod } = require("./navigationPolicy.js");
|
||||
|
||||
describe("navigationPolicy", () => {
|
||||
it("底部导航在标准页面之间切换时使用 redirectTo 以避免堆积页面实例", () => {
|
||||
expect(resolvePageNavigationMethod("/pages/connect/index", "/pages/logs/index")).toBe("redirectTo");
|
||||
expect(resolvePageNavigationMethod("/pages/terminal/index", "/pages/settings/index")).toBe("redirectTo");
|
||||
});
|
||||
|
||||
it("目标为空或与当前页面相同时不触发导航", () => {
|
||||
expect(resolvePageNavigationMethod("/pages/connect/index", "")).toBe("noop");
|
||||
expect(resolvePageNavigationMethod("/pages/connect/index", "/pages/connect/index")).toBe("noop");
|
||||
});
|
||||
|
||||
it("非标准页面路径仍保留 navigateTo 兜底", () => {
|
||||
expect(resolvePageNavigationMethod("/pages/connect/index", "/packageA/detail")).toBe("navigateTo");
|
||||
});
|
||||
});
|
||||
240
apps/miniprogram/utils/opsConfig.js
Normal file
240
apps/miniprogram/utils/opsConfig.js
Normal file
@@ -0,0 +1,240 @@
|
||||
const DEFAULT_OPS_CONFIG = {
|
||||
gatewayUrl: "",
|
||||
gatewayToken: "",
|
||||
hostKeyPolicy: "strict",
|
||||
credentialMemoryPolicy: "remember",
|
||||
gatewayConnectTimeoutMs: 12000,
|
||||
waitForConnectedTimeoutMs: 15000,
|
||||
terminalBufferMaxEntries: 5000,
|
||||
terminalBufferMaxBytes: 4 * 1024 * 1024,
|
||||
maskSecrets: true
|
||||
};
|
||||
|
||||
const OPS_ENV_PATHS = [
|
||||
"/.env",
|
||||
".env",
|
||||
"./.env",
|
||||
"../.env",
|
||||
"../../.env",
|
||||
"miniprogram/.env",
|
||||
"apps/miniprogram/.env",
|
||||
"/apps/miniprogram/.env"
|
||||
];
|
||||
|
||||
function toSafeNumber(value, fallback, min) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
if (typeof min === "number" && parsed < min) return fallback;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function stripQuotes(raw) {
|
||||
const value = String(raw || "").trim();
|
||||
if (!value) return "";
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseBoolean(value, fallback) {
|
||||
const input = String(value || "").trim().toLowerCase();
|
||||
if (!input) return fallback;
|
||||
if (input === "1" || input === "true" || input === "yes" || input === "on") return true;
|
||||
if (input === "0" || input === "false" || input === "no" || input === "off") return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function readTextByPaths(paths) {
|
||||
if (typeof wx === "undefined" || typeof wx.getFileSystemManager !== "function") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const fs = wx.getFileSystemManager();
|
||||
for (let i = 0; i < paths.length; i += 1) {
|
||||
const path = paths[i];
|
||||
try {
|
||||
const raw = fs.readFileSync(path, "utf8");
|
||||
if (!raw) continue;
|
||||
return String(raw);
|
||||
} catch (error) {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function parseEnvMap(rawText) {
|
||||
const text = String(rawText || "");
|
||||
const lines = text.split(/\r?\n/);
|
||||
const envMap = {};
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i].trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const normalized = line.startsWith("export ") ? line.slice(7).trim() : line;
|
||||
const eqIndex = normalized.indexOf("=");
|
||||
if (eqIndex <= 0) continue;
|
||||
const key = normalized.slice(0, eqIndex).trim();
|
||||
if (!key) continue;
|
||||
const value = stripQuotes(normalized.slice(eqIndex + 1));
|
||||
envMap[key] = value;
|
||||
}
|
||||
return envMap;
|
||||
}
|
||||
|
||||
function mapOpsFields(source) {
|
||||
const envMap = source && typeof source === "object" ? source : {};
|
||||
const next = {};
|
||||
if (Object.prototype.hasOwnProperty.call(envMap, "GATEWAY_URL")) {
|
||||
next.gatewayUrl = envMap.GATEWAY_URL;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(envMap, "gatewayUrl")) {
|
||||
next.gatewayUrl = envMap.gatewayUrl;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(envMap, "GATEWAY_TOKEN")) {
|
||||
next.gatewayToken = envMap.GATEWAY_TOKEN;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(envMap, "gatewayToken")) {
|
||||
next.gatewayToken = envMap.gatewayToken;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(envMap, "HOST_KEY_POLICY")) {
|
||||
next.hostKeyPolicy = envMap.HOST_KEY_POLICY;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(envMap, "hostKeyPolicy")) {
|
||||
next.hostKeyPolicy = envMap.hostKeyPolicy;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(envMap, "CREDENTIAL_MEMORY_POLICY")) {
|
||||
next.credentialMemoryPolicy = envMap.CREDENTIAL_MEMORY_POLICY;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(envMap, "credentialMemoryPolicy")) {
|
||||
next.credentialMemoryPolicy = envMap.credentialMemoryPolicy;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(envMap, "GATEWAY_CONNECT_TIMEOUT_MS")) {
|
||||
next.gatewayConnectTimeoutMs = envMap.GATEWAY_CONNECT_TIMEOUT_MS;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(envMap, "gatewayConnectTimeoutMs")) {
|
||||
next.gatewayConnectTimeoutMs = envMap.gatewayConnectTimeoutMs;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(envMap, "WAIT_FOR_CONNECTED_TIMEOUT_MS")) {
|
||||
next.waitForConnectedTimeoutMs = envMap.WAIT_FOR_CONNECTED_TIMEOUT_MS;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(envMap, "waitForConnectedTimeoutMs")) {
|
||||
next.waitForConnectedTimeoutMs = envMap.waitForConnectedTimeoutMs;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(envMap, "TERMINAL_BUFFER_MAX_ENTRIES")) {
|
||||
next.terminalBufferMaxEntries = envMap.TERMINAL_BUFFER_MAX_ENTRIES;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(envMap, "terminalBufferMaxEntries")) {
|
||||
next.terminalBufferMaxEntries = envMap.terminalBufferMaxEntries;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(envMap, "TERMINAL_BUFFER_MAX_BYTES")) {
|
||||
next.terminalBufferMaxBytes = envMap.TERMINAL_BUFFER_MAX_BYTES;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(envMap, "terminalBufferMaxBytes")) {
|
||||
next.terminalBufferMaxBytes = envMap.terminalBufferMaxBytes;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(envMap, "MASK_SECRETS")) {
|
||||
const parsedMask = parseBoolean(envMap.MASK_SECRETS, undefined);
|
||||
if (parsedMask !== undefined) {
|
||||
next.maskSecrets = parsedMask;
|
||||
}
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(envMap, "maskSecrets")) {
|
||||
next.maskSecrets = parseBoolean(envMap.maskSecrets, undefined);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function readOpsConfigFromBundledModule() {
|
||||
try {
|
||||
const bundled = require("./opsEnv.js");
|
||||
return mapOpsFields(bundled);
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function readOpsConfigFromExtConfig() {
|
||||
if (typeof wx === "undefined" || typeof wx.getExtConfigSync !== "function") {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const ext = wx.getExtConfigSync() || {};
|
||||
const source =
|
||||
ext.remoteconnOps || ext.remoteConnOps || ext.remoteconn || ext.REMOTECONN_OPS || ext;
|
||||
return mapOpsFields(source);
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function readOpsConfigFromGlobal() {
|
||||
if (typeof globalThis === "undefined") return {};
|
||||
const source = globalThis.__REMOTE_CONN_OPS__;
|
||||
if (!source || typeof source !== "object") return {};
|
||||
return mapOpsFields(source);
|
||||
}
|
||||
|
||||
function readOpsConfigFromEnvFile() {
|
||||
const raw = readTextByPaths(OPS_ENV_PATHS);
|
||||
if (!raw) return {};
|
||||
const envMap = parseEnvMap(raw);
|
||||
return mapOpsFields(envMap);
|
||||
}
|
||||
|
||||
function readOpsConfig() {
|
||||
// 优先级:全局注入 > extConfig > 生成模块 > .env 文件读盘
|
||||
return {
|
||||
...readOpsConfigFromEnvFile(),
|
||||
...readOpsConfigFromBundledModule(),
|
||||
...readOpsConfigFromExtConfig(),
|
||||
...readOpsConfigFromGlobal()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 运维配置不暴露给设置页用户编辑。
|
||||
*/
|
||||
function getOpsConfig() {
|
||||
const source = readOpsConfig();
|
||||
return {
|
||||
gatewayUrl: String(source.gatewayUrl || DEFAULT_OPS_CONFIG.gatewayUrl).trim(),
|
||||
gatewayToken: String(source.gatewayToken || DEFAULT_OPS_CONFIG.gatewayToken).trim(),
|
||||
hostKeyPolicy: String(source.hostKeyPolicy || DEFAULT_OPS_CONFIG.hostKeyPolicy),
|
||||
credentialMemoryPolicy: String(
|
||||
source.credentialMemoryPolicy || DEFAULT_OPS_CONFIG.credentialMemoryPolicy
|
||||
),
|
||||
gatewayConnectTimeoutMs: toSafeNumber(
|
||||
source.gatewayConnectTimeoutMs,
|
||||
DEFAULT_OPS_CONFIG.gatewayConnectTimeoutMs,
|
||||
1000
|
||||
),
|
||||
waitForConnectedTimeoutMs: toSafeNumber(
|
||||
source.waitForConnectedTimeoutMs,
|
||||
DEFAULT_OPS_CONFIG.waitForConnectedTimeoutMs,
|
||||
1000
|
||||
),
|
||||
terminalBufferMaxEntries: toSafeNumber(
|
||||
source.terminalBufferMaxEntries,
|
||||
DEFAULT_OPS_CONFIG.terminalBufferMaxEntries,
|
||||
100
|
||||
),
|
||||
terminalBufferMaxBytes: toSafeNumber(
|
||||
source.terminalBufferMaxBytes,
|
||||
DEFAULT_OPS_CONFIG.terminalBufferMaxBytes,
|
||||
1024
|
||||
),
|
||||
maskSecrets: source.maskSecrets === undefined ? DEFAULT_OPS_CONFIG.maskSecrets : !!source.maskSecrets
|
||||
};
|
||||
}
|
||||
|
||||
function isOpsConfigReady(config) {
|
||||
const resolved = config || getOpsConfig();
|
||||
return Boolean(String(resolved.gatewayUrl || "").trim()) && Boolean(String(resolved.gatewayToken || "").trim());
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_OPS_CONFIG,
|
||||
getOpsConfig,
|
||||
isOpsConfigReady
|
||||
};
|
||||
15
apps/miniprogram/utils/pagination.js
Normal file
15
apps/miniprogram/utils/pagination.js
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 简单分页工具:统一日志与闪念的 15 条/页规则。
|
||||
*/
|
||||
function pageOf(list, page, pageSize) {
|
||||
const safeSize = Math.max(1, Number(pageSize) || 15);
|
||||
const total = Array.isArray(list) ? list.length : 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / safeSize));
|
||||
const safePage = Math.min(Math.max(1, Number(page) || 1), totalPages);
|
||||
const start = (safePage - 1) * safeSize;
|
||||
const end = start + safeSize;
|
||||
const rows = Array.isArray(list) ? list.slice(start, end) : [];
|
||||
return { rows, total, totalPages, page: safePage, pageSize: safeSize };
|
||||
}
|
||||
|
||||
module.exports = { pageOf };
|
||||
520
apps/miniprogram/utils/pluginRuntime.js
Normal file
520
apps/miniprogram/utils/pluginRuntime.js
Normal file
@@ -0,0 +1,520 @@
|
||||
/* global require, getApp, wx, console, module */
|
||||
|
||||
const {
|
||||
listPluginPackages,
|
||||
getPluginPackage,
|
||||
upsertPluginPackage,
|
||||
removePluginPackage,
|
||||
listPluginRecords,
|
||||
savePluginRecords,
|
||||
listPluginRuntimeLogs,
|
||||
savePluginRuntimeLogs,
|
||||
readPluginData,
|
||||
writePluginData
|
||||
} = require("./storage");
|
||||
const { onSessionEvent, sendToActiveSession } = require("./sessionBus");
|
||||
|
||||
const MAX_RUNTIME_LOGS = 300;
|
||||
const SUPPORTED_PERMISSIONS = new Set([
|
||||
"commands.register",
|
||||
"session.read",
|
||||
"session.write",
|
||||
"ui.notice",
|
||||
"storage.read",
|
||||
"storage.write",
|
||||
"logs.read"
|
||||
]);
|
||||
const DEFAULT_APP_META = {
|
||||
version: "2.7.1",
|
||||
platform: "miniapp"
|
||||
};
|
||||
|
||||
const state = {
|
||||
initialized: false,
|
||||
records: new Map(),
|
||||
runtime: new Map(),
|
||||
commands: new Map(),
|
||||
runtimeLogs: []
|
||||
};
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function toLogLine(level, pluginId, message) {
|
||||
const time = new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
||||
return `[${time}] [${level}] [${pluginId}] ${message}`;
|
||||
}
|
||||
|
||||
function pushRuntimeLog(level, pluginId, message) {
|
||||
const line = toLogLine(level, pluginId, message);
|
||||
state.runtimeLogs.unshift(line);
|
||||
if (state.runtimeLogs.length > MAX_RUNTIME_LOGS) {
|
||||
state.runtimeLogs.splice(MAX_RUNTIME_LOGS);
|
||||
}
|
||||
savePluginRuntimeLogs(state.runtimeLogs);
|
||||
}
|
||||
|
||||
function createRecord(pluginId) {
|
||||
const now = nowIso();
|
||||
return {
|
||||
id: pluginId,
|
||||
enabled: false,
|
||||
status: "discovered",
|
||||
errorCount: 0,
|
||||
lastError: "",
|
||||
installedAt: now,
|
||||
updatedAt: now,
|
||||
lastLoadedAt: ""
|
||||
};
|
||||
}
|
||||
|
||||
function persistRecords() {
|
||||
savePluginRecords(Array.from(state.records.values()));
|
||||
}
|
||||
|
||||
function normalizeRecord(record) {
|
||||
const base = createRecord(String((record && record.id) || ""));
|
||||
return {
|
||||
...base,
|
||||
...(record || {}),
|
||||
id: String((record && record.id) || base.id),
|
||||
errorCount: Number((record && record.errorCount) || 0),
|
||||
enabled: Boolean(record && record.enabled)
|
||||
};
|
||||
}
|
||||
|
||||
function safeParsePluginJson(raw) {
|
||||
const parsed = JSON.parse(String(raw || ""));
|
||||
const items = Array.isArray(parsed) ? parsed : [parsed];
|
||||
return items.map(validatePluginPackage);
|
||||
}
|
||||
|
||||
function assertPermission(manifest, permission) {
|
||||
if (!manifest.permissions.includes(permission)) {
|
||||
throw new Error(`权限不足: ${permission}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validatePluginPackage(input) {
|
||||
const payload = input && typeof input === "object" ? input : {};
|
||||
const manifest = payload.manifest && typeof payload.manifest === "object" ? payload.manifest : {};
|
||||
const id = String(manifest.id || "").trim();
|
||||
if (!id) throw new Error("插件 manifest.id 不能为空");
|
||||
const name = String(manifest.name || "").trim();
|
||||
if (!name) throw new Error(`插件 ${id} 缺少 manifest.name`);
|
||||
const permissions = Array.isArray(manifest.permissions)
|
||||
? manifest.permissions.map((item) => String(item || "").trim())
|
||||
: [];
|
||||
permissions.forEach((permission) => {
|
||||
if (!SUPPORTED_PERMISSIONS.has(permission)) {
|
||||
throw new Error(`插件 ${id} 使用了未支持权限: ${permission}`);
|
||||
}
|
||||
});
|
||||
const mainJs = String(payload.mainJs || "");
|
||||
if (!mainJs.trim()) {
|
||||
throw new Error(`插件 ${id} 缺少 mainJs`);
|
||||
}
|
||||
return {
|
||||
manifest: {
|
||||
id,
|
||||
name,
|
||||
version: String(manifest.version || "0.0.1"),
|
||||
minAppVersion: String(manifest.minAppVersion || "0.0.1"),
|
||||
description: String(manifest.description || ""),
|
||||
entry: "main.js",
|
||||
style: "styles.css",
|
||||
permissions
|
||||
},
|
||||
mainJs,
|
||||
stylesCss: String(payload.stylesCss || "")
|
||||
};
|
||||
}
|
||||
|
||||
function getAppMeta() {
|
||||
try {
|
||||
const app = getApp();
|
||||
return {
|
||||
version: String((app && app.globalData && app.globalData.appVersion) || DEFAULT_APP_META.version),
|
||||
platform: "miniapp"
|
||||
};
|
||||
} catch {
|
||||
return DEFAULT_APP_META;
|
||||
}
|
||||
}
|
||||
|
||||
function compareVersion(a, b) {
|
||||
const left = String(a || "0.0.0")
|
||||
.split(".")
|
||||
.map((item) => Number(item) || 0);
|
||||
const right = String(b || "0.0.0")
|
||||
.split(".")
|
||||
.map((item) => Number(item) || 0);
|
||||
const max = Math.max(left.length, right.length);
|
||||
for (let i = 0; i < max; i += 1) {
|
||||
const l = left[i] || 0;
|
||||
const r = right[i] || 0;
|
||||
if (l > r) return 1;
|
||||
if (l < r) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function createRuntimeContext(pluginPackage, runtimeSlot) {
|
||||
const manifest = pluginPackage.manifest;
|
||||
return {
|
||||
app: getAppMeta(),
|
||||
commands: {
|
||||
register(command) {
|
||||
assertPermission(manifest, "commands.register");
|
||||
if (!command || typeof command !== "object") {
|
||||
throw new Error("命令定义非法");
|
||||
}
|
||||
const rawId = String(command.id || "").trim();
|
||||
if (!rawId) {
|
||||
throw new Error("命令 id 不能为空");
|
||||
}
|
||||
const fullId = `${manifest.id}:${rawId}`;
|
||||
const item = {
|
||||
id: fullId,
|
||||
title: String(command.title || rawId),
|
||||
when: command.when === "connected" ? "connected" : "always",
|
||||
handler: typeof command.handler === "function" ? command.handler : () => {}
|
||||
};
|
||||
state.commands.set(fullId, item);
|
||||
runtimeSlot.cleanupFns.push(() => state.commands.delete(fullId));
|
||||
}
|
||||
},
|
||||
session: {
|
||||
async send(input) {
|
||||
assertPermission(manifest, "session.write");
|
||||
await sendToActiveSession(String(input || ""), { source: "assist", txnId: createTxnId("plugin") });
|
||||
},
|
||||
on(eventName, handler) {
|
||||
assertPermission(manifest, "session.read");
|
||||
const off = onSessionEvent(eventName, handler);
|
||||
runtimeSlot.cleanupFns.push(off);
|
||||
return off;
|
||||
}
|
||||
},
|
||||
storage: {
|
||||
async get(key) {
|
||||
assertPermission(manifest, "storage.read");
|
||||
const data = readPluginData(manifest.id);
|
||||
return data[String(key || "")];
|
||||
},
|
||||
async set(key, value) {
|
||||
assertPermission(manifest, "storage.write");
|
||||
const data = readPluginData(manifest.id);
|
||||
data[String(key || "")] = value;
|
||||
writePluginData(manifest.id, data);
|
||||
}
|
||||
},
|
||||
ui: {
|
||||
showNotice(message, level = "info") {
|
||||
assertPermission(manifest, "ui.notice");
|
||||
const title = String(message || "").slice(0, 7) || "插件通知";
|
||||
wx.showToast({ title, icon: level === "error" ? "none" : "none" });
|
||||
}
|
||||
},
|
||||
logger: {
|
||||
info(...args) {
|
||||
pushRuntimeLog("info", manifest.id, args.join(" "));
|
||||
},
|
||||
warn(...args) {
|
||||
pushRuntimeLog("warn", manifest.id, args.join(" "));
|
||||
},
|
||||
error(...args) {
|
||||
pushRuntimeLog("error", manifest.id, args.join(" "));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function loadPluginApi(mainJs, ctx) {
|
||||
const moduleRef = { exports: {} };
|
||||
const exportsRef = moduleRef.exports;
|
||||
const fn = new Function(
|
||||
"ctx",
|
||||
"module",
|
||||
"exports",
|
||||
`"use strict";
|
||||
const window = undefined;
|
||||
const document = undefined;
|
||||
const localStorage = undefined;
|
||||
${mainJs}
|
||||
return module.exports;`
|
||||
);
|
||||
return fn(ctx, moduleRef, exportsRef) || moduleRef.exports;
|
||||
}
|
||||
|
||||
function clearRuntimeById(pluginId) {
|
||||
const runtime = state.runtime.get(pluginId);
|
||||
if (!runtime) {
|
||||
return;
|
||||
}
|
||||
if (runtime.api && typeof runtime.api.onunload === "function") {
|
||||
try {
|
||||
runtime.api.onunload();
|
||||
} catch (error) {
|
||||
pushRuntimeLog("warn", pluginId, `onunload 异常: ${String((error && error.message) || error)}`);
|
||||
}
|
||||
}
|
||||
runtime.cleanupFns.forEach((off) => {
|
||||
try {
|
||||
off();
|
||||
} catch (error) {
|
||||
console.warn("[pluginRuntime.cleanup]", pluginId, error);
|
||||
}
|
||||
});
|
||||
state.runtime.delete(pluginId);
|
||||
Array.from(state.commands.keys()).forEach((commandId) => {
|
||||
if (commandId.startsWith(`${pluginId}:`)) {
|
||||
state.commands.delete(commandId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function ensureSamplePlugin() {
|
||||
if (listPluginPackages().length > 0) {
|
||||
return;
|
||||
}
|
||||
upsertPluginPackage({
|
||||
manifest: {
|
||||
id: "codex-shortcuts",
|
||||
name: "Codex Shortcuts",
|
||||
version: "0.1.0",
|
||||
minAppVersion: "0.1.0",
|
||||
description: "提供常用 Codex 快捷命令",
|
||||
entry: "main.js",
|
||||
style: "styles.css",
|
||||
permissions: ["commands.register", "session.write", "ui.notice"]
|
||||
},
|
||||
mainJs: `
|
||||
module.exports = {
|
||||
onload(ctx) {
|
||||
ctx.commands.register({
|
||||
id: "codex-doctor",
|
||||
title: "Codex Doctor",
|
||||
when: "connected",
|
||||
async handler() {
|
||||
await ctx.session.send("codex --doctor\\r");
|
||||
}
|
||||
});
|
||||
ctx.ui.showNotice("codex-shortcuts 已加载", "info");
|
||||
}
|
||||
};
|
||||
`.trim(),
|
||||
stylesCss: ""
|
||||
});
|
||||
}
|
||||
|
||||
function createTxnId(prefix) {
|
||||
return `${prefix || "plugin"}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
async function ensureBootstrapped() {
|
||||
if (state.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.runtimeLogs = listPluginRuntimeLogs();
|
||||
ensureSamplePlugin();
|
||||
|
||||
state.records.clear();
|
||||
const storedRecords = listPluginRecords();
|
||||
storedRecords.forEach((record) => {
|
||||
const normalized = normalizeRecord(record);
|
||||
if (normalized.id) {
|
||||
state.records.set(normalized.id, normalized);
|
||||
}
|
||||
});
|
||||
|
||||
listPluginPackages().forEach((pluginPackage) => {
|
||||
const pluginId = String((pluginPackage && pluginPackage.manifest && pluginPackage.manifest.id) || "");
|
||||
if (!pluginId) return;
|
||||
if (!state.records.has(pluginId)) {
|
||||
state.records.set(pluginId, createRecord(pluginId));
|
||||
}
|
||||
});
|
||||
persistRecords();
|
||||
|
||||
state.initialized = true;
|
||||
|
||||
const enabledIds = Array.from(state.records.values())
|
||||
.filter((record) => record.enabled)
|
||||
.map((record) => record.id);
|
||||
for (const pluginId of enabledIds) {
|
||||
try {
|
||||
await enable(pluginId);
|
||||
} catch (error) {
|
||||
pushRuntimeLog("error", pluginId, `自动启用失败: ${String((error && error.message) || error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function listRecords() {
|
||||
return Array.from(state.records.values());
|
||||
}
|
||||
|
||||
function listRuntimeLogs() {
|
||||
return state.runtimeLogs.slice();
|
||||
}
|
||||
|
||||
function listCommands(sessionState) {
|
||||
return Array.from(state.commands.values()).filter((command) => {
|
||||
if (command.when === "connected") {
|
||||
return sessionState === "connected";
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function importJson(raw) {
|
||||
await ensureBootstrapped();
|
||||
const packages = safeParsePluginJson(raw);
|
||||
packages.forEach((pluginPackage) => {
|
||||
upsertPluginPackage(pluginPackage);
|
||||
const pluginId = pluginPackage.manifest.id;
|
||||
const record = state.records.get(pluginId) || createRecord(pluginId);
|
||||
record.status = "validated";
|
||||
record.lastError = "";
|
||||
record.updatedAt = nowIso();
|
||||
state.records.set(pluginId, record);
|
||||
});
|
||||
persistRecords();
|
||||
}
|
||||
|
||||
async function exportJson() {
|
||||
await ensureBootstrapped();
|
||||
return JSON.stringify(listPluginPackages(), null, 2);
|
||||
}
|
||||
|
||||
async function enable(pluginId) {
|
||||
await ensureBootstrapped();
|
||||
const id = String(pluginId || "").trim();
|
||||
if (!id) {
|
||||
throw new Error("插件 id 不能为空");
|
||||
}
|
||||
|
||||
const pluginPackage = getPluginPackage(id);
|
||||
if (!pluginPackage) {
|
||||
throw new Error("插件不存在");
|
||||
}
|
||||
|
||||
const appMeta = getAppMeta();
|
||||
if (compareVersion(appMeta.version, pluginPackage.manifest.minAppVersion || "0.0.1") < 0) {
|
||||
throw new Error(`当前版本 ${appMeta.version} 低于插件最低要求 ${pluginPackage.manifest.minAppVersion}`);
|
||||
}
|
||||
|
||||
const record = state.records.get(id) || createRecord(id);
|
||||
if (record.errorCount >= 3) {
|
||||
throw new Error("插件已熔断,请先重载后再启用");
|
||||
}
|
||||
|
||||
clearRuntimeById(id);
|
||||
record.status = "loading";
|
||||
record.lastError = "";
|
||||
record.updatedAt = nowIso();
|
||||
state.records.set(id, record);
|
||||
persistRecords();
|
||||
|
||||
const runtimeSlot = {
|
||||
pluginId: id,
|
||||
cleanupFns: [],
|
||||
api: null
|
||||
};
|
||||
|
||||
try {
|
||||
const ctx = createRuntimeContext(pluginPackage, runtimeSlot);
|
||||
runtimeSlot.api = loadPluginApi(pluginPackage.mainJs, ctx);
|
||||
if (runtimeSlot.api && typeof runtimeSlot.api.onload === "function") {
|
||||
await Promise.resolve(runtimeSlot.api.onload(ctx));
|
||||
}
|
||||
state.runtime.set(id, runtimeSlot);
|
||||
|
||||
record.enabled = true;
|
||||
record.status = "active";
|
||||
record.lastError = "";
|
||||
record.lastLoadedAt = nowIso();
|
||||
record.updatedAt = nowIso();
|
||||
state.records.set(id, record);
|
||||
persistRecords();
|
||||
pushRuntimeLog("info", id, "插件已启用");
|
||||
} catch (error) {
|
||||
clearRuntimeById(id);
|
||||
record.enabled = false;
|
||||
record.status = "failed";
|
||||
record.errorCount = Number(record.errorCount || 0) + 1;
|
||||
record.lastError = String((error && error.message) || error);
|
||||
record.updatedAt = nowIso();
|
||||
state.records.set(id, record);
|
||||
persistRecords();
|
||||
pushRuntimeLog("error", id, `启用失败: ${record.lastError}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function disable(pluginId) {
|
||||
await ensureBootstrapped();
|
||||
const id = String(pluginId || "").trim();
|
||||
if (!id) {
|
||||
throw new Error("插件 id 不能为空");
|
||||
}
|
||||
clearRuntimeById(id);
|
||||
const record = state.records.get(id) || createRecord(id);
|
||||
record.enabled = false;
|
||||
if (record.status !== "failed") {
|
||||
record.status = "stopped";
|
||||
}
|
||||
record.updatedAt = nowIso();
|
||||
state.records.set(id, record);
|
||||
persistRecords();
|
||||
pushRuntimeLog("info", id, "插件已禁用");
|
||||
}
|
||||
|
||||
async function reload(pluginId) {
|
||||
await ensureBootstrapped();
|
||||
const id = String(pluginId || "").trim();
|
||||
const record = state.records.get(id) || createRecord(id);
|
||||
record.errorCount = 0;
|
||||
record.lastError = "";
|
||||
record.updatedAt = nowIso();
|
||||
state.records.set(id, record);
|
||||
persistRecords();
|
||||
await disable(id);
|
||||
await enable(id);
|
||||
}
|
||||
|
||||
async function remove(pluginId) {
|
||||
await ensureBootstrapped();
|
||||
const id = String(pluginId || "").trim();
|
||||
await disable(id);
|
||||
removePluginPackage(id);
|
||||
state.records.delete(id);
|
||||
persistRecords();
|
||||
pushRuntimeLog("info", id, "插件已移除");
|
||||
}
|
||||
|
||||
async function runCommand(commandId) {
|
||||
await ensureBootstrapped();
|
||||
const command = state.commands.get(String(commandId || ""));
|
||||
if (!command) {
|
||||
throw new Error("命令不存在");
|
||||
}
|
||||
await Promise.resolve(command.handler());
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ensureBootstrapped,
|
||||
listRecords,
|
||||
listRuntimeLogs,
|
||||
listCommands,
|
||||
importJson,
|
||||
exportJson,
|
||||
enable,
|
||||
disable,
|
||||
reload,
|
||||
remove,
|
||||
runCommand
|
||||
};
|
||||
416
apps/miniprogram/utils/remoteDirectory.js
Normal file
416
apps/miniprogram/utils/remoteDirectory.js
Normal file
@@ -0,0 +1,416 @@
|
||||
/* global console, module, require, clearTimeout, setTimeout */
|
||||
/* eslint-disable no-control-regex */
|
||||
|
||||
const { createGatewayClient } = require("./gateway");
|
||||
const { validateServerForConnect } = require("./serverValidation");
|
||||
const { resolveSocketDomainHint } = require("./socketDomain");
|
||||
|
||||
/**
|
||||
* 认证参数与 terminal 页保持一致,避免多处行为分叉。
|
||||
*/
|
||||
function normalizeAuthType(value) {
|
||||
const authType = String(value || "").trim();
|
||||
if (authType === "privateKey" || authType === "certificate") return authType;
|
||||
return "password";
|
||||
}
|
||||
|
||||
function resolveCredential(server, prefix) {
|
||||
const source = server && typeof server === "object" ? server : {};
|
||||
const fieldPrefix = String(prefix || "");
|
||||
const authType = normalizeAuthType(
|
||||
fieldPrefix ? source.jumpHost && source.jumpHost.authType : source.authType
|
||||
);
|
||||
const passwordKey = fieldPrefix ? `${fieldPrefix}Password` : "password";
|
||||
const privateKeyKey = fieldPrefix ? `${fieldPrefix}PrivateKey` : "privateKey";
|
||||
const passphraseKey = fieldPrefix ? `${fieldPrefix}Passphrase` : "passphrase";
|
||||
const certificateKey = fieldPrefix ? `${fieldPrefix}Certificate` : "certificate";
|
||||
if (authType === "privateKey") {
|
||||
return {
|
||||
type: "privateKey",
|
||||
privateKey: String(source[privateKeyKey] || ""),
|
||||
passphrase: String(source[passphraseKey] || "")
|
||||
};
|
||||
}
|
||||
if (authType === "certificate") {
|
||||
return {
|
||||
type: "certificate",
|
||||
privateKey: String(source[privateKeyKey] || ""),
|
||||
passphrase: String(source[passphraseKey] || ""),
|
||||
certificate: String(source[certificateKey] || "")
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "password",
|
||||
password: String(source[passwordKey] || "")
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHomePath(input) {
|
||||
const value = String(input || "").trim();
|
||||
if (!value || value === "~") return "~";
|
||||
if (!value.startsWith("~/")) return "~";
|
||||
const body = value
|
||||
.slice(2)
|
||||
.split("/")
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part && part !== "." && part !== "..")
|
||||
.join("/");
|
||||
return body ? `~/${body}` : "~";
|
||||
}
|
||||
|
||||
function quoteForShellSingle(value) {
|
||||
return `'${String(value || "").replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function buildListCommand(path, beginMarker, endMarker) {
|
||||
const normalized = normalizeHomePath(path);
|
||||
const relative = normalized === "~" ? "" : normalized.slice(2);
|
||||
const relQuoted = quoteForShellSingle(relative);
|
||||
const beginQuoted = quoteForShellSingle(beginMarker);
|
||||
const endQuoted = quoteForShellSingle(endMarker);
|
||||
// 外层仅执行 `sh -lc '...'`,避免依赖远端默认 shell 对 `A=B cmd` 前缀赋值语法的支持。
|
||||
// 目录列举优先 command ls -1tp(按修改时间从新到旧,且绕过 alias),再回退 find。
|
||||
const script = [
|
||||
`__RC_BEGIN=${beginQuoted}`,
|
||||
`__RC_END=${endQuoted}`,
|
||||
`__RC_REL=${relQuoted}`,
|
||||
'if [ -z "$__RC_REL" ]; then __RC_DIR="$HOME"; else __RC_DIR="$HOME/$__RC_REL"; fi',
|
||||
'printf "%s\\n" "$__RC_BEGIN"',
|
||||
'if ! cd "$__RC_DIR" 2>/dev/null; then printf "__RCERR__CD__\\n"; printf "%s\\n" "$__RC_END"; exit 0; fi',
|
||||
"if command -v ls >/dev/null 2>&1; then command ls -1tp 2>/dev/null | sed -n '/\\/$/s#/$##p' | sed '/^\\./d'; elif command -v find >/dev/null 2>&1; then find . -mindepth 1 -maxdepth 1 -type d ! -name '.*' -print 2>/dev/null | sed 's#^\\./##'; fi",
|
||||
'printf "%s\\n" "$__RC_END"'
|
||||
].join("; ");
|
||||
const scriptQuoted = quoteForShellSingle(script);
|
||||
return `sh -lc ${scriptQuoted}\n`;
|
||||
}
|
||||
|
||||
function createStdoutCollector(beginMarker, endMarker) {
|
||||
let buffer = "";
|
||||
let done = false;
|
||||
let collecting = false;
|
||||
const extractedLines = [];
|
||||
let beginSignalSeen = false;
|
||||
|
||||
function sanitizeChunk(input) {
|
||||
return sanitizeTerminalChunk(input);
|
||||
}
|
||||
|
||||
function consume(rawChunk) {
|
||||
if (done) return;
|
||||
buffer += sanitizeChunk(rawChunk);
|
||||
if (!beginSignalSeen && buffer.includes(beginMarker)) {
|
||||
beginSignalSeen = true;
|
||||
}
|
||||
// 必须按“整行”识别 begin/end 标记,避免命令回显折行时把标记误判成目录项。
|
||||
while (!done) {
|
||||
const newlineAt = buffer.indexOf("\n");
|
||||
if (newlineAt < 0) break;
|
||||
const rawLine = buffer.slice(0, newlineAt);
|
||||
buffer = buffer.slice(newlineAt + 1);
|
||||
const line = rawLine.replace(/\r/g, "");
|
||||
const trimmed = line.trim();
|
||||
if (!collecting) {
|
||||
if (trimmed === beginMarker) {
|
||||
collecting = true;
|
||||
beginSignalSeen = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (trimmed === endMarker) {
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
extractedLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
function isDone() {
|
||||
return done;
|
||||
}
|
||||
|
||||
function hasBeginMarker() {
|
||||
return collecting || done;
|
||||
}
|
||||
|
||||
function hasBeginSignal() {
|
||||
return beginSignalSeen || collecting || done;
|
||||
}
|
||||
|
||||
function getLines() {
|
||||
return extractedLines.map((line) => line.trim()).filter((line) => !!line);
|
||||
}
|
||||
|
||||
return { consume, isDone, hasBeginMarker, hasBeginSignal, getLines };
|
||||
}
|
||||
|
||||
function sanitizeTerminalChunk(input) {
|
||||
return String(input || "")
|
||||
.replace(/\r/g, "")
|
||||
.replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, "")
|
||||
.replace(/\u001bP[\s\S]*?\u001b\\/g, "")
|
||||
.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "")
|
||||
.replace(/\u001b[@-~]/g, "");
|
||||
}
|
||||
|
||||
function toErrorMessage(error, fallback) {
|
||||
if (!error) return fallback;
|
||||
if (typeof error === "string") return error;
|
||||
if (typeof error.message === "string" && error.message.trim()) return error.message.trim();
|
||||
if (typeof error.errMsg === "string" && error.errMsg.trim()) return error.errMsg.trim();
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function isLikelyErrorLine(line) {
|
||||
const text = String(line || "");
|
||||
if (!text) return false;
|
||||
return /^(?:ls|find|sh):|not found|no such file|permission denied/i.test(text);
|
||||
}
|
||||
|
||||
function isInternalMarkerLine(line) {
|
||||
const text = String(line || "");
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/__RC_/i.test(trimmed)) return true;
|
||||
if (/~\/__RC_/i.test(trimmed)) return true;
|
||||
if (/(?:^|\s)gavin\b.*\b(?:%|#|\$)\b.*__RC_/i.test(trimmed)) return true;
|
||||
const normalized = trimmed
|
||||
.replace(/[\u001b\u009b][[()\]#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, "")
|
||||
.replace(/[^a-zA-Z0-9_]/g, "")
|
||||
.toUpperCase();
|
||||
if (!normalized) return false;
|
||||
if (normalized.includes("RCDIRBEGIN")) return true;
|
||||
if (normalized.includes("RCDIREND")) return true;
|
||||
if (normalized.includes("RCBEGIN")) return true;
|
||||
if (normalized.includes("RCEND")) return true;
|
||||
if (normalized.includes("RCREL")) return true;
|
||||
if (normalized.includes("RCERR")) return true;
|
||||
if (normalized.includes("LIST_REMOTEDIRECTORIES_DONE".replace(/[^A-Z0-9_]/g, ""))) return true;
|
||||
if (!text) return false;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过一次短连接从远端目录读取子目录列表。
|
||||
* 返回值仅包含“目录名”,不包含完整路径。
|
||||
*/
|
||||
function listRemoteDirectories(options) {
|
||||
const params = options && typeof options === "object" ? options : {};
|
||||
const server = params.server || {};
|
||||
const opsConfig = params.opsConfig || {};
|
||||
const targetPath = normalizeHomePath(params.path || "~");
|
||||
const requestId = `dir_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
const requestStartedAt = Date.now();
|
||||
const connectTimeoutMs = Number(opsConfig.gatewayConnectTimeoutMs);
|
||||
const timeoutMs = Number(params.timeoutMs);
|
||||
const maxWaitMs =
|
||||
Number.isFinite(timeoutMs) && timeoutMs >= 1000
|
||||
? timeoutMs
|
||||
: Number.isFinite(connectTimeoutMs) && connectTimeoutMs >= 1000
|
||||
? connectTimeoutMs + 10000
|
||||
: 22000;
|
||||
|
||||
const beginMarker = `__RC_DIR_BEGIN_${Date.now()}_${Math.random().toString(36).slice(2, 8)}__`;
|
||||
const endMarker = `__RC_DIR_END_${Date.now()}_${Math.random().toString(36).slice(2, 8)}__`;
|
||||
const collector = createStdoutCollector(beginMarker, endMarker);
|
||||
const command = buildListCommand(targetPath, beginMarker, endMarker);
|
||||
|
||||
console.info(
|
||||
`[目录请求] 开始 id=${requestId} host=${String(server.host || "").trim() || "-"} ` +
|
||||
`port=${Number(server.port) || 22} user=${String(server.username || "").trim() || "-"} path=${targetPath}`
|
||||
);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let connected = false;
|
||||
let timer = null;
|
||||
let commandAttempts = 0;
|
||||
// 目录读取改为单次发送,避免重试导致重复 ls 拉长耗时。
|
||||
const maxCommandAttempts = 1;
|
||||
let sawFirstOutput = false;
|
||||
const diagnosticLines = [];
|
||||
|
||||
function rememberDiagnostics(rawChunk) {
|
||||
const lines = sanitizeTerminalChunk(rawChunk)
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => !!line)
|
||||
.filter((line) => !isInternalMarkerLine(line));
|
||||
lines.forEach((line) => {
|
||||
if (diagnosticLines.includes(line)) return;
|
||||
diagnosticLines.push(line);
|
||||
});
|
||||
if (diagnosticLines.length > 6) {
|
||||
diagnosticLines.splice(0, diagnosticLines.length - 6);
|
||||
}
|
||||
}
|
||||
|
||||
function sendCommandAttempt(trigger) {
|
||||
if (settled) return;
|
||||
if (!connected) return;
|
||||
if (collector.isDone()) return;
|
||||
if (commandAttempts >= maxCommandAttempts) return;
|
||||
commandAttempts += 1;
|
||||
console.info(
|
||||
`[目录请求] 发送命令 id=${requestId} attempt=${commandAttempts}/${maxCommandAttempts} ` +
|
||||
`trigger=${String(trigger || "unknown")} elapsed=${Date.now() - requestStartedAt}ms`
|
||||
);
|
||||
try {
|
||||
client.sendStdin(command);
|
||||
} catch {
|
||||
finishReject(new Error("目录命令发送失败"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const client = createGatewayClient({
|
||||
gatewayUrl: opsConfig.gatewayUrl,
|
||||
gatewayToken: opsConfig.gatewayToken,
|
||||
connectTimeoutMs: opsConfig.gatewayConnectTimeoutMs,
|
||||
onFrame: (frame) => {
|
||||
if (settled) return;
|
||||
if (
|
||||
frame.type === "control" &&
|
||||
frame.payload?.action === "connected" &&
|
||||
!frame.payload?.fingerprint
|
||||
) {
|
||||
connected = true;
|
||||
console.info(`[目录请求] 已连接 id=${requestId} elapsed=${Date.now() - requestStartedAt}ms`);
|
||||
// connected 事件里立即首发,避免定时器抖动导致额外等待。
|
||||
sendCommandAttempt("connected");
|
||||
return;
|
||||
}
|
||||
if (frame.type === "control" && frame.payload?.action === "connected" && frame.payload?.fingerprint) {
|
||||
return;
|
||||
}
|
||||
if (frame.type === "stdout" || frame.type === "stderr") {
|
||||
const chunkText = frame.payload?.data || "";
|
||||
if (!collector.hasBeginSignal()) {
|
||||
rememberDiagnostics(chunkText);
|
||||
}
|
||||
if (!sawFirstOutput && String(chunkText || "").trim()) {
|
||||
sawFirstOutput = true;
|
||||
console.info(`[目录请求] 首包输出 id=${requestId} elapsed=${Date.now() - requestStartedAt}ms`);
|
||||
// 若连接后尚未发出目录命令,首包输出到达时立即触发一次发送。
|
||||
if (connected && commandAttempts === 0 && !collector.isDone()) {
|
||||
sendCommandAttempt("first_output");
|
||||
}
|
||||
}
|
||||
const beforeDone = collector.isDone();
|
||||
collector.consume(chunkText);
|
||||
const afterDone = collector.isDone();
|
||||
if (beforeDone || !afterDone) return;
|
||||
if (!collector.isDone()) return;
|
||||
const rawLines = collector.getLines();
|
||||
const hasCdError = rawLines.some((line) => line.trim() === "__RCERR__CD__");
|
||||
if (hasCdError) {
|
||||
finishReject(new Error(`目录不存在或无权限:${targetPath}`));
|
||||
return;
|
||||
}
|
||||
const names = rawLines
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && line !== "." && line !== "..")
|
||||
.filter((line) => !isInternalMarkerLine(line))
|
||||
.filter((line) => !line.startsWith("."))
|
||||
.filter((line) => !isLikelyErrorLine(line))
|
||||
.filter((line, index, arr) => arr.indexOf(line) === index);
|
||||
console.info(
|
||||
`[目录请求] 完成 id=${requestId} path=${targetPath} count=${names.length} ` +
|
||||
`attempts=${commandAttempts} elapsed=${Date.now() - requestStartedAt}ms`
|
||||
);
|
||||
finishResolve(names);
|
||||
return;
|
||||
}
|
||||
if (frame.type === "error") {
|
||||
finishReject(new Error(frame.payload?.message || "网关错误"));
|
||||
return;
|
||||
}
|
||||
if (frame.type === "control" && frame.payload?.action === "disconnect") {
|
||||
const reason = String(frame.payload?.reason || "").trim();
|
||||
if (!collector.isDone()) {
|
||||
finishReject(new Error(reason ? `连接已断开: ${reason}` : "连接已断开"));
|
||||
}
|
||||
}
|
||||
},
|
||||
onClose: () => {
|
||||
if (settled || collector.isDone()) return;
|
||||
finishReject(new Error(connected ? "连接中断" : "网关连接失败"));
|
||||
},
|
||||
onError: (error) => {
|
||||
if (settled) return;
|
||||
finishReject(new Error(toErrorMessage(error, "网关连接失败")));
|
||||
}
|
||||
});
|
||||
|
||||
function cleanup() {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
try {
|
||||
client.disconnect("list_remote_directories_done");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function finishResolve(payload) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(payload);
|
||||
}
|
||||
|
||||
function finishReject(error) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
console.warn(
|
||||
`[目录请求] 失败 id=${requestId} path=${targetPath} attempts=${commandAttempts} ` +
|
||||
`elapsed=${Date.now() - requestStartedAt}ms reason=${toErrorMessage(error, "unknown")}`
|
||||
);
|
||||
cleanup();
|
||||
reject(error);
|
||||
}
|
||||
|
||||
timer = setTimeout(() => {
|
||||
const diagnostic = diagnosticLines.find((line) => !isLikelyErrorLine(line)) || diagnosticLines[0] || "";
|
||||
if (diagnostic) {
|
||||
finishReject(new Error(`目录读取超时,远端返回:${diagnostic}`));
|
||||
return;
|
||||
}
|
||||
finishReject(new Error("目录读取超时,请稍后重试"));
|
||||
}, maxWaitMs);
|
||||
|
||||
client
|
||||
.connect({
|
||||
host: server.jumpHost && server.jumpHost.enabled ? server.jumpHost.host : server.host,
|
||||
port: server.jumpHost && server.jumpHost.enabled ? Number(server.jumpHost.port) || 22 : server.port,
|
||||
username: server.jumpHost && server.jumpHost.enabled ? server.jumpHost.username : server.username,
|
||||
credential:
|
||||
server.jumpHost && server.jumpHost.enabled
|
||||
? resolveCredential(server, "jump")
|
||||
: resolveCredential(server),
|
||||
...(server.jumpHost && server.jumpHost.enabled
|
||||
? {
|
||||
jumpHost: {
|
||||
host: server.host,
|
||||
port: server.port,
|
||||
username: server.username,
|
||||
credential: resolveCredential(server)
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
.catch((error) => {
|
||||
finishReject(new Error(toErrorMessage(error, "网关连接失败")));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listRemoteDirectories,
|
||||
normalizeHomePath,
|
||||
validateServerForConnect,
|
||||
resolveSocketDomainHint
|
||||
};
|
||||
27
apps/miniprogram/utils/runtimeEnv.js
Normal file
27
apps/miniprogram/utils/runtimeEnv.js
Normal file
@@ -0,0 +1,27 @@
|
||||
const { getRuntimeFingerprint } = require("./systemInfoCompat");
|
||||
|
||||
/**
|
||||
* 判断当前是否运行在微信开发者工具内。
|
||||
* 说明:
|
||||
* 1. `page-frame.html` / `getBase64ImagesInCss` 这类告警主要出现在 devtools 渲染层;
|
||||
* 2. 在 devtools 中尽量避免把超长 `data:` URI 注入到视图模板,降低解析噪声;
|
||||
* 3. 真机与正式运行环境仍保留动态着色能力。
|
||||
*/
|
||||
function isDevtoolsRuntime(wxLike) {
|
||||
const api = wxLike || (typeof wx !== "undefined" ? wx : null);
|
||||
if (!api) return false;
|
||||
try {
|
||||
const info = getRuntimeFingerprint(api);
|
||||
return (
|
||||
String(info.platform || "")
|
||||
.trim()
|
||||
.toLowerCase() === "devtools"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isDevtoolsRuntime
|
||||
};
|
||||
69
apps/miniprogram/utils/serverValidation.js
Normal file
69
apps/miniprogram/utils/serverValidation.js
Normal file
@@ -0,0 +1,69 @@
|
||||
/* global module */
|
||||
|
||||
/**
|
||||
* 服务器连接前校验。
|
||||
* 约束:
|
||||
* 1. 这里只做“用户可立即修正”的前置校验,避免明显配置错误落到网关层才报模糊错误;
|
||||
* 2. 端口允许留空并回退默认 22,保持当前表单行为不变;
|
||||
* 3. 返回值统一为中文错误文案,空字符串表示校验通过。
|
||||
*/
|
||||
|
||||
function normalizeAuthType(value) {
|
||||
const authType = String(value || "").trim();
|
||||
if (authType === "privateKey" || authType === "certificate") return authType;
|
||||
return "password";
|
||||
}
|
||||
|
||||
function validatePortField(value, label) {
|
||||
const raw = String(value == null ? "" : value).trim();
|
||||
if (!raw) return "";
|
||||
if (!/^\d+$/.test(raw)) return `${label}需为 1-65535 的整数`;
|
||||
const port = Number(raw);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
return `${label}需为 1-65535 的整数`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function validateServerForConnect(server) {
|
||||
const source = server && typeof server === "object" ? server : {};
|
||||
const host = String(source.host || "").trim();
|
||||
const username = String(source.username || "").trim();
|
||||
const portError = validatePortField(source.port, "SSH 端口");
|
||||
if (!host) return "主机不能为空";
|
||||
if (portError) return portError;
|
||||
if (!username) return "用户名不能为空";
|
||||
|
||||
const authType = normalizeAuthType(source.authType);
|
||||
if (authType === "password" && !String(source.password || "").trim()) return "密码不能为空";
|
||||
if (authType === "privateKey" && !String(source.privateKey || "").trim()) return "私钥内容不能为空";
|
||||
if (authType === "certificate") {
|
||||
if (!String(source.privateKey || "").trim()) return "证书模式下私钥不能为空";
|
||||
if (!String(source.certificate || "").trim()) return "证书模式下证书不能为空";
|
||||
}
|
||||
|
||||
const jumpHost = source.jumpHost && typeof source.jumpHost === "object" ? source.jumpHost : null;
|
||||
if (!jumpHost || !jumpHost.enabled) return "";
|
||||
|
||||
const jumpHostName = String(jumpHost.host || "").trim();
|
||||
const jumpUsername = String(jumpHost.username || "").trim();
|
||||
const jumpPortError = validatePortField(jumpHost.port, "跳板机端口");
|
||||
if (!jumpHostName) return "跳板机主机不能为空";
|
||||
if (jumpPortError) return jumpPortError;
|
||||
if (!jumpUsername) return "跳板机用户名不能为空";
|
||||
|
||||
const jumpAuthType = normalizeAuthType(jumpHost.authType);
|
||||
if (jumpAuthType === "password" && !String(source.jumpPassword || "").trim()) return "跳板机密码不能为空";
|
||||
if (jumpAuthType === "privateKey" && !String(source.jumpPrivateKey || "").trim()) {
|
||||
return "跳板机私钥不能为空";
|
||||
}
|
||||
if (jumpAuthType === "certificate") {
|
||||
if (!String(source.jumpPrivateKey || "").trim()) return "跳板机证书模式下私钥不能为空";
|
||||
if (!String(source.jumpCertificate || "").trim()) return "跳板机证书模式下证书不能为空";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validateServerForConnect
|
||||
};
|
||||
66
apps/miniprogram/utils/serverValidation.test.ts
Normal file
66
apps/miniprogram/utils/serverValidation.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function loadServerValidationModule() {
|
||||
const modulePath = require.resolve("./serverValidation.js");
|
||||
delete require.cache[modulePath];
|
||||
return require("./serverValidation.js");
|
||||
}
|
||||
|
||||
describe("miniprogram serverValidation", () => {
|
||||
it("端口超出范围时返回准确提示", () => {
|
||||
const { validateServerForConnect } = loadServerValidationModule();
|
||||
|
||||
expect(
|
||||
validateServerForConnect({
|
||||
host: "10.0.0.8",
|
||||
port: 70000,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
password: "secret"
|
||||
})
|
||||
).toBe("SSH 端口需为 1-65535 的整数");
|
||||
});
|
||||
|
||||
it("跳板机端口非法时优先返回跳板机端口提示", () => {
|
||||
const { validateServerForConnect } = loadServerValidationModule();
|
||||
|
||||
expect(
|
||||
validateServerForConnect({
|
||||
host: "10.0.0.8",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
password: "secret",
|
||||
jumpHost: {
|
||||
enabled: true,
|
||||
host: "10.0.0.1",
|
||||
port: "abc",
|
||||
username: "jump",
|
||||
authType: "password"
|
||||
},
|
||||
jumpPassword: "jump-secret"
|
||||
})
|
||||
).toBe("跳板机端口需为 1-65535 的整数");
|
||||
});
|
||||
|
||||
it("缺少主机和用户名时返回更具体的提示", () => {
|
||||
const { validateServerForConnect } = loadServerValidationModule();
|
||||
|
||||
expect(validateServerForConnect({ username: "root" })).toBe("主机不能为空");
|
||||
expect(validateServerForConnect({ host: "10.0.0.8" })).toBe("用户名不能为空");
|
||||
});
|
||||
|
||||
it("端口留空时保留默认端口行为,不额外报错", () => {
|
||||
const { validateServerForConnect } = loadServerValidationModule();
|
||||
|
||||
expect(
|
||||
validateServerForConnect({
|
||||
host: "10.0.0.8",
|
||||
port: "",
|
||||
username: "root",
|
||||
authType: "password",
|
||||
password: "secret"
|
||||
})
|
||||
).toBe("");
|
||||
});
|
||||
});
|
||||
67
apps/miniprogram/utils/sessionBus.js
Normal file
67
apps/miniprogram/utils/sessionBus.js
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 小程序终端会话事件总线:
|
||||
* 1. 插件运行时通过该总线订阅会话事件;
|
||||
* 2. 终端页通过该总线暴露“当前活动会话”的发送能力。
|
||||
*/
|
||||
const EVENT_NAMES = ["connected", "disconnected", "stdout", "stderr", "latency"];
|
||||
|
||||
const listeners = EVENT_NAMES.reduce((acc, key) => {
|
||||
acc[key] = new Set();
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
let activeSessionSender = null;
|
||||
let currentSessionState = "disconnected";
|
||||
|
||||
function onSessionEvent(eventName, handler) {
|
||||
const name = String(eventName || "");
|
||||
if (!listeners[name] || typeof handler !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
listeners[name].add(handler);
|
||||
return () => {
|
||||
listeners[name].delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
function emitSessionEvent(eventName, payload) {
|
||||
const name = String(eventName || "");
|
||||
const bucket = listeners[name];
|
||||
if (name === "connected") {
|
||||
currentSessionState = "connected";
|
||||
} else if (name === "disconnected") {
|
||||
currentSessionState = "disconnected";
|
||||
}
|
||||
if (!bucket) return;
|
||||
bucket.forEach((handler) => {
|
||||
try {
|
||||
handler(payload);
|
||||
} catch (error) {
|
||||
console.error("[sessionBus.emit]", name, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setActiveSessionSender(sender) {
|
||||
activeSessionSender = typeof sender === "function" ? sender : null;
|
||||
if (!activeSessionSender) {
|
||||
currentSessionState = "disconnected";
|
||||
}
|
||||
}
|
||||
|
||||
async function sendToActiveSession(input, meta) {
|
||||
if (!activeSessionSender) {
|
||||
throw new Error("当前无活动终端会话");
|
||||
}
|
||||
await Promise.resolve(activeSessionSender(String(input || ""), meta));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
onSessionEvent,
|
||||
emitSessionEvent,
|
||||
setActiveSessionSender,
|
||||
sendToActiveSession,
|
||||
getSessionState() {
|
||||
return currentSessionState;
|
||||
}
|
||||
};
|
||||
42
apps/miniprogram/utils/socketDomain.js
Normal file
42
apps/miniprogram/utils/socketDomain.js
Normal file
@@ -0,0 +1,42 @@
|
||||
/* global module */
|
||||
|
||||
/**
|
||||
* 统一解析小程序 socket 合法域名提示里的协议与主机部分。
|
||||
* 保持纯函数,供设置页与终端页复用,避免重复维护 URL 归一化逻辑。
|
||||
*/
|
||||
|
||||
function resolveSocketDomainHint(rawGatewayUrl) {
|
||||
const input = String(rawGatewayUrl || "").trim();
|
||||
if (!input) return "";
|
||||
|
||||
let normalized = input;
|
||||
if (!normalized.startsWith("ws://") && !normalized.startsWith("wss://")) {
|
||||
if (normalized.startsWith("http://")) {
|
||||
normalized = `ws://${normalized.slice(7)}`;
|
||||
} else if (normalized.startsWith("https://")) {
|
||||
normalized = `wss://${normalized.slice(8)}`;
|
||||
} else {
|
||||
normalized = `wss://${normalized}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序真机环境不保证存在标准 URL 全局对象,这里改为纯字符串解析,
|
||||
* 只提取协议与 host:port,避免诊断面板在真机里错误显示“暂无”。
|
||||
*/
|
||||
const matched = normalized.match(/^(wss?):\/\/([^/?#]+)/i);
|
||||
if (!matched) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const protocol = String(matched[1] || "").toLowerCase();
|
||||
const host = String(matched[2] || "").replace(/:443$/i, "");
|
||||
if (!protocol || !host) {
|
||||
return "";
|
||||
}
|
||||
return `${protocol}://${host}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveSocketDomainHint
|
||||
};
|
||||
25
apps/miniprogram/utils/socketDomain.test.ts
Normal file
25
apps/miniprogram/utils/socketDomain.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const { resolveSocketDomainHint } = require("./socketDomain.js");
|
||||
|
||||
describe("socketDomain", () => {
|
||||
it("将 https 网关地址归一化为 wss 域名提示", () => {
|
||||
expect(resolveSocketDomainHint("https://conn.biboer.cn")).toBe("wss://conn.biboer.cn");
|
||||
});
|
||||
|
||||
it("保留已带协议与端口的 socket 地址", () => {
|
||||
expect(resolveSocketDomainHint("wss://conn.biboer.cn:443/")).toBe("wss://conn.biboer.cn");
|
||||
});
|
||||
|
||||
it("在缺少 URL 全局时仍能解析真机网关地址", () => {
|
||||
const originalUrl = globalThis.URL;
|
||||
// 模拟小程序真机缺少标准 URL 构造器的运行时。
|
||||
// @ts-expect-error 测试里需要显式移除全局对象。
|
||||
delete globalThis.URL;
|
||||
try {
|
||||
expect(resolveSocketDomainHint("wss://conn.biboer.cn")).toBe("wss://conn.biboer.cn");
|
||||
} finally {
|
||||
globalThis.URL = originalUrl;
|
||||
}
|
||||
});
|
||||
});
|
||||
902
apps/miniprogram/utils/storage.js
Normal file
902
apps/miniprogram/utils/storage.js
Normal file
@@ -0,0 +1,902 @@
|
||||
/* global wx, console, module, require */
|
||||
|
||||
const {
|
||||
DEFAULT_AI_PROVIDER,
|
||||
DEFAULT_CODEX_SANDBOX_MODE,
|
||||
DEFAULT_COPILOT_PERMISSION_MODE,
|
||||
normalizeAiProvider,
|
||||
normalizeCodexSandboxMode,
|
||||
normalizeCopilotPermissionMode
|
||||
} = require("./aiLaunch");
|
||||
const { normalizeTerminalFontFamily, pickBtnColor, pickShellAccentColor } = require("./themeStyle");
|
||||
const { normalizeUiLanguage } = require("./i18n");
|
||||
const {
|
||||
DEFAULT_TERMINAL_RESUME_MINUTES,
|
||||
MIN_TERMINAL_RESUME_MINUTES,
|
||||
MAX_TERMINAL_RESUME_MINUTES
|
||||
} = require("./terminalSessionState");
|
||||
const {
|
||||
DEFAULT_TTS_SPEAKABLE_MAX_CHARS,
|
||||
MIN_TTS_SPEAKABLE_MAX_CHARS,
|
||||
MAX_TTS_SPEAKABLE_MAX_CHARS,
|
||||
DEFAULT_TTS_SEGMENT_MAX_CHARS,
|
||||
MIN_TTS_SEGMENT_MAX_CHARS,
|
||||
MAX_TTS_SEGMENT_MAX_CHARS
|
||||
} = require("./ttsSettings");
|
||||
|
||||
/**
|
||||
* 小程序本地存储封装。
|
||||
* 约束:
|
||||
* 1. 全部走同步 API,保证页面进入时读取行为确定;
|
||||
* 2. 结构保持与 Web 端语义一致,便于后续对齐。
|
||||
*/
|
||||
const KEYS = {
|
||||
servers: "remoteconn.servers.v2",
|
||||
settings: "remoteconn.settings.v2",
|
||||
logs: "remoteconn.logs.v2",
|
||||
records: "remoteconn.records.v2",
|
||||
pluginPackages: "remoteconn.plugins.packages.v2",
|
||||
pluginRecords: "remoteconn.plugins.records.v2",
|
||||
pluginRuntimeLogs: "remoteconn.plugins.runtimeLogs.v2"
|
||||
};
|
||||
|
||||
/**
|
||||
* 稳定备份 key 不随业务版本号变化:
|
||||
* 1. 正式 key 升级时,可直接从备份恢复;
|
||||
* 2. 即使未来继续升 v3/v4,也不需要把备份链条无限拉长。
|
||||
*/
|
||||
const BACKUP_KEYS = {
|
||||
servers: "remoteconn.backup.servers",
|
||||
settings: "remoteconn.backup.settings",
|
||||
logs: "remoteconn.backup.logs",
|
||||
records: "remoteconn.backup.records",
|
||||
pluginPackages: "remoteconn.backup.plugins.packages",
|
||||
pluginRecords: "remoteconn.backup.plugins.records",
|
||||
pluginRuntimeLogs: "remoteconn.backup.plugins.runtimeLogs"
|
||||
};
|
||||
|
||||
/**
|
||||
* 历史 key 候选表:
|
||||
* 1. 只在当前正式 key 缺失时尝试;
|
||||
* 2. 明确列出已知/合理的旧命名,避免运行时“猜 key”。
|
||||
*/
|
||||
const LEGACY_KEYS = {
|
||||
servers: ["remoteconn.servers.v1", "remoteconn.servers"],
|
||||
settings: ["remoteconn.settings.v1", "remoteconn.settings"],
|
||||
logs: ["remoteconn.logs.v1", "remoteconn.logs"],
|
||||
records: ["remoteconn.records.v1", "remoteconn.records"],
|
||||
pluginPackages: ["remoteconn.plugins.packages.v1", "remoteconn.plugins.packages"],
|
||||
pluginRecords: ["remoteconn.plugins.records.v1", "remoteconn.plugins.records"],
|
||||
pluginRuntimeLogs: ["remoteconn.plugins.runtimeLogs.v1", "remoteconn.plugins.runtimeLogs"]
|
||||
};
|
||||
|
||||
let storageBootstrapDone = false;
|
||||
let storageBootstrapInProgress = false;
|
||||
let syncServiceModule = null;
|
||||
|
||||
const DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK = "未分类";
|
||||
const DEFAULT_VOICE_RECORD_CATEGORIES = ["未分类", "优化", "新需求", "问题", "灵感"];
|
||||
const DEFAULT_VOICE_RECORD_DEFAULT_CATEGORY = "优化";
|
||||
const DEFAULT_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES = 200;
|
||||
const MIN_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES = 20;
|
||||
const MAX_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES = 5000;
|
||||
|
||||
const OPS_SETTING_KEYS = new Set([
|
||||
"gatewayUrl",
|
||||
"gatewayToken",
|
||||
"hostKeyPolicy",
|
||||
"credentialMemoryPolicy",
|
||||
"gatewayConnectTimeoutMs",
|
||||
"waitForConnectedTimeoutMs",
|
||||
"terminalBufferMaxEntries",
|
||||
"terminalBufferMaxBytes",
|
||||
"maskSecrets"
|
||||
]);
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
// ── 界面主题(用户可调)────────────────────────────────────────────────────
|
||||
uiLanguage: "zh-Hans",
|
||||
uiThemeMode: "dark",
|
||||
uiThemePreset: "tide",
|
||||
uiAccentColor: "#5bd2ff",
|
||||
uiBgColor: "#192b4d",
|
||||
uiTextColor: "#e6f0ff",
|
||||
uiBtnColor: pickBtnColor("#192b4d", "#e6f0ff"),
|
||||
|
||||
// ── 连接与服务器默认参数(用户可调)────────────────────────────────────────
|
||||
autoReconnect: true,
|
||||
syncConfigEnabled: true,
|
||||
reconnectLimit: 3,
|
||||
backgroundSessionKeepAliveMinutes: DEFAULT_TERMINAL_RESUME_MINUTES,
|
||||
defaultAuthType: "password",
|
||||
aiDefaultProvider: DEFAULT_AI_PROVIDER,
|
||||
aiCodexSandboxMode: DEFAULT_CODEX_SANDBOX_MODE,
|
||||
aiCopilotPermissionMode: DEFAULT_COPILOT_PERMISSION_MODE,
|
||||
defaultPort: 22,
|
||||
defaultProjectPath: "~/workspace",
|
||||
defaultTimeoutSeconds: 20,
|
||||
defaultHeartbeatSeconds: 15,
|
||||
defaultTransportMode: "gateway",
|
||||
|
||||
// ── 终端显示与缓冲 ─────────────────────────────────────────────────────────
|
||||
shellThemeMode: "dark",
|
||||
shellThemePreset: "tide",
|
||||
shellBgColor: "#192b4d",
|
||||
shellTextColor: "#e6f0ff",
|
||||
shellAccentColor: pickShellAccentColor("#192b4d", "#e6f0ff"),
|
||||
shellFontFamily: "JetBrains Mono",
|
||||
shellFontSize: 15,
|
||||
shellLineHeight: 1.4,
|
||||
shellActivationDebugOutline: true,
|
||||
showVoiceInputButton: true,
|
||||
ttsSpeakableMaxChars: DEFAULT_TTS_SPEAKABLE_MAX_CHARS,
|
||||
ttsSegmentMaxChars: DEFAULT_TTS_SEGMENT_MAX_CHARS,
|
||||
shellBufferMaxEntries: 5000,
|
||||
shellBufferMaxBytes: 4 * 1024 * 1024,
|
||||
shellBufferSnapshotMaxLines: DEFAULT_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES,
|
||||
unicode11: true,
|
||||
|
||||
// ── 日志 ───────────────────────────────────────────────────────────────────
|
||||
logRetentionDays: 30,
|
||||
voiceRecordCategories: DEFAULT_VOICE_RECORD_CATEGORIES.slice(),
|
||||
voiceRecordDefaultCategory: DEFAULT_VOICE_RECORD_DEFAULT_CATEGORY
|
||||
};
|
||||
|
||||
const NUMBER_RULES = {
|
||||
reconnectLimit: { fallback: 3, min: 0, max: 10, integer: true },
|
||||
backgroundSessionKeepAliveMinutes: {
|
||||
fallback: DEFAULT_TERMINAL_RESUME_MINUTES,
|
||||
min: MIN_TERMINAL_RESUME_MINUTES,
|
||||
max: MAX_TERMINAL_RESUME_MINUTES,
|
||||
integer: true
|
||||
},
|
||||
defaultPort: { fallback: 22, min: 1, max: 65535, integer: true },
|
||||
defaultTimeoutSeconds: { fallback: 20, min: 5, max: 600, integer: true },
|
||||
defaultHeartbeatSeconds: { fallback: 15, min: 5, max: 600, integer: true },
|
||||
shellFontSize: { fallback: 15, min: 12, max: 22, integer: true },
|
||||
shellLineHeight: { fallback: 1.4, min: 1, max: 2, integer: false },
|
||||
ttsSpeakableMaxChars: {
|
||||
fallback: DEFAULT_TTS_SPEAKABLE_MAX_CHARS,
|
||||
min: MIN_TTS_SPEAKABLE_MAX_CHARS,
|
||||
max: MAX_TTS_SPEAKABLE_MAX_CHARS,
|
||||
integer: true
|
||||
},
|
||||
ttsSegmentMaxChars: {
|
||||
fallback: DEFAULT_TTS_SEGMENT_MAX_CHARS,
|
||||
min: MIN_TTS_SEGMENT_MAX_CHARS,
|
||||
max: MAX_TTS_SEGMENT_MAX_CHARS,
|
||||
integer: true
|
||||
},
|
||||
shellBufferMaxEntries: { fallback: 5000, min: 100, max: 200000, integer: true },
|
||||
shellBufferMaxBytes: { fallback: 4 * 1024 * 1024, min: 1024, max: 50 * 1024 * 1024, integer: true },
|
||||
shellBufferSnapshotMaxLines: {
|
||||
fallback: DEFAULT_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES,
|
||||
min: MIN_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES,
|
||||
max: MAX_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES,
|
||||
integer: true
|
||||
},
|
||||
logRetentionDays: { fallback: 30, min: 1, max: 365, integer: true }
|
||||
};
|
||||
|
||||
const THEME_MODE_VALUES = new Set(["dark", "light"]);
|
||||
const AUTH_TYPE_VALUES = new Set(["password", "key"]);
|
||||
const HEX_COLOR_REGEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
||||
|
||||
function normalizeServerTags(value) {
|
||||
const source = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
|
||||
const seen = new Set();
|
||||
const next = [];
|
||||
source.forEach((entry) => {
|
||||
const normalized = String(entry || "").trim();
|
||||
if (!normalized || seen.has(normalized)) return;
|
||||
seen.add(normalized);
|
||||
next.push(normalized);
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
function normalizeVoiceRecordCategories(value) {
|
||||
const source = Array.isArray(value) ? value : [];
|
||||
const seen = new Set();
|
||||
const next = [];
|
||||
|
||||
source.forEach((entry) => {
|
||||
const normalized = String(entry || "").trim();
|
||||
if (!normalized || seen.has(normalized)) return;
|
||||
seen.add(normalized);
|
||||
next.push(normalized);
|
||||
});
|
||||
|
||||
if (!seen.has(DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK)) {
|
||||
next.unshift(DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK);
|
||||
}
|
||||
|
||||
return next.slice(0, 10);
|
||||
}
|
||||
|
||||
function normalizeVoiceRecordDefaultCategory(value, categories) {
|
||||
const normalized = String(value || "").trim();
|
||||
if (normalized && categories.includes(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
if (categories.includes(DEFAULT_VOICE_RECORD_DEFAULT_CATEGORY)) {
|
||||
return DEFAULT_VOICE_RECORD_DEFAULT_CATEGORY;
|
||||
}
|
||||
return categories[0] || DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK;
|
||||
}
|
||||
|
||||
function normalizeNumber(value, rule) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return rule.fallback;
|
||||
const normalized = rule.integer ? Math.round(parsed) : parsed;
|
||||
if (normalized < rule.min) return rule.min;
|
||||
if (normalized > rule.max) return rule.max;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeSettings(input) {
|
||||
const source = input && typeof input === "object" ? input : {};
|
||||
const next = { ...source };
|
||||
|
||||
Object.keys(NUMBER_RULES).forEach((key) => {
|
||||
next[key] = normalizeNumber(next[key], NUMBER_RULES[key]);
|
||||
});
|
||||
|
||||
next.uiLanguage = normalizeUiLanguage(next.uiLanguage);
|
||||
next.uiThemeMode = THEME_MODE_VALUES.has(next.uiThemeMode)
|
||||
? next.uiThemeMode
|
||||
: DEFAULT_SETTINGS.uiThemeMode;
|
||||
next.shellThemeMode = THEME_MODE_VALUES.has(next.shellThemeMode)
|
||||
? next.shellThemeMode
|
||||
: DEFAULT_SETTINGS.shellThemeMode;
|
||||
next.shellThemePreset = String(next.shellThemePreset || DEFAULT_SETTINGS.shellThemePreset);
|
||||
next.defaultAuthType = AUTH_TYPE_VALUES.has(next.defaultAuthType)
|
||||
? next.defaultAuthType
|
||||
: DEFAULT_SETTINGS.defaultAuthType;
|
||||
next.aiDefaultProvider = normalizeAiProvider(next.aiDefaultProvider);
|
||||
next.aiCodexSandboxMode = normalizeCodexSandboxMode(next.aiCodexSandboxMode);
|
||||
next.aiCopilotPermissionMode = normalizeCopilotPermissionMode(next.aiCopilotPermissionMode);
|
||||
next.defaultTransportMode = String(next.defaultTransportMode || DEFAULT_SETTINGS.defaultTransportMode);
|
||||
next.defaultProjectPath = String(next.defaultProjectPath || DEFAULT_SETTINGS.defaultProjectPath);
|
||||
next.uiThemePreset = String(next.uiThemePreset || DEFAULT_SETTINGS.uiThemePreset);
|
||||
next.uiAccentColor = HEX_COLOR_REGEX.test(String(next.uiAccentColor || ""))
|
||||
? String(next.uiAccentColor)
|
||||
: DEFAULT_SETTINGS.uiAccentColor;
|
||||
next.uiBgColor = HEX_COLOR_REGEX.test(String(next.uiBgColor || ""))
|
||||
? String(next.uiBgColor)
|
||||
: DEFAULT_SETTINGS.uiBgColor;
|
||||
next.uiTextColor = HEX_COLOR_REGEX.test(String(next.uiTextColor || ""))
|
||||
? String(next.uiTextColor)
|
||||
: DEFAULT_SETTINGS.uiTextColor;
|
||||
next.uiBtnColor = HEX_COLOR_REGEX.test(String(next.uiBtnColor || ""))
|
||||
? String(next.uiBtnColor)
|
||||
: pickBtnColor(next.uiBgColor, next.uiTextColor);
|
||||
/**
|
||||
* 终端字体必须先经过“安全字体”归一:
|
||||
* 1. 避免把比例字体直接写入终端运行态;
|
||||
* 2. 老版本已保存的不安全字体在这里自动迁回安全等宽字体。
|
||||
*/
|
||||
next.shellFontFamily = normalizeTerminalFontFamily(
|
||||
String(next.shellFontFamily || DEFAULT_SETTINGS.shellFontFamily)
|
||||
);
|
||||
next.shellBgColor = HEX_COLOR_REGEX.test(String(next.shellBgColor || ""))
|
||||
? String(next.shellBgColor)
|
||||
: DEFAULT_SETTINGS.shellBgColor;
|
||||
next.shellTextColor = HEX_COLOR_REGEX.test(String(next.shellTextColor || ""))
|
||||
? String(next.shellTextColor)
|
||||
: DEFAULT_SETTINGS.shellTextColor;
|
||||
next.shellAccentColor = HEX_COLOR_REGEX.test(String(next.shellAccentColor || ""))
|
||||
? String(next.shellAccentColor)
|
||||
: pickShellAccentColor(next.shellBgColor, next.shellTextColor);
|
||||
next.shellActivationDebugOutline =
|
||||
next.shellActivationDebugOutline === undefined
|
||||
? DEFAULT_SETTINGS.shellActivationDebugOutline
|
||||
: !!next.shellActivationDebugOutline;
|
||||
/**
|
||||
* 语音输入按钮默认显示:
|
||||
* 1. 老版本没有该字段时自动补 true;
|
||||
* 2. 统一收敛为布尔值,避免 storage 里残留字符串/数字脏值。
|
||||
*/
|
||||
next.showVoiceInputButton =
|
||||
next.showVoiceInputButton === undefined ? DEFAULT_SETTINGS.showVoiceInputButton : !!next.showVoiceInputButton;
|
||||
next.unicode11 = !!next.unicode11;
|
||||
next.autoReconnect = !!next.autoReconnect;
|
||||
// 云端同步开关默认开启,兼容现有“本地 + Gateway”双写行为。
|
||||
next.syncConfigEnabled =
|
||||
next.syncConfigEnabled === undefined ? DEFAULT_SETTINGS.syncConfigEnabled : !!next.syncConfigEnabled;
|
||||
next.voiceRecordCategories = normalizeVoiceRecordCategories(next.voiceRecordCategories);
|
||||
next.voiceRecordDefaultCategory = normalizeVoiceRecordDefaultCategory(
|
||||
next.voiceRecordDefaultCategory,
|
||||
next.voiceRecordCategories
|
||||
);
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function sanitizeUserSettings(input) {
|
||||
if (!input || typeof input !== "object") return {};
|
||||
const next = {};
|
||||
Object.keys(input).forEach((key) => {
|
||||
if (OPS_SETTING_KEYS.has(key)) return;
|
||||
next[key] = input[key];
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
function listStorageKeys() {
|
||||
try {
|
||||
const info = wx.getStorageInfoSync();
|
||||
return Array.isArray(info && info.keys) ? info.keys : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function readRaw(key) {
|
||||
try {
|
||||
const value = wx.getStorageSync(key);
|
||||
const keys = listStorageKeys();
|
||||
const found = keys.includes(key) || !(value === undefined || value === null || value === "");
|
||||
return { found, value };
|
||||
} catch (error) {
|
||||
console.warn("[storage.read.raw]", key, error);
|
||||
return { found: false, value: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
function writeRaw(key, value) {
|
||||
try {
|
||||
wx.setStorageSync(key, value);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("[storage.write.raw]", key, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateStorageKey(logicalKey) {
|
||||
const primaryKey = KEYS[logicalKey];
|
||||
const backupKey = BACKUP_KEYS[logicalKey];
|
||||
const primary = readRaw(primaryKey);
|
||||
|
||||
if (primary.found) {
|
||||
if (backupKey) {
|
||||
const backup = readRaw(backupKey);
|
||||
if (!backup.found) {
|
||||
writeRaw(backupKey, primary.value);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (backupKey) {
|
||||
const backup = readRaw(backupKey);
|
||||
if (backup.found) {
|
||||
writeRaw(primaryKey, backup.value);
|
||||
console.info("[storage.restore.backup]", logicalKey, backupKey, "->", primaryKey);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const legacyKeys = Array.isArray(LEGACY_KEYS[logicalKey]) ? LEGACY_KEYS[logicalKey] : [];
|
||||
for (let i = 0; i < legacyKeys.length; i += 1) {
|
||||
const legacyKey = legacyKeys[i];
|
||||
const legacy = readRaw(legacyKey);
|
||||
if (!legacy.found) continue;
|
||||
writeRaw(primaryKey, legacy.value);
|
||||
if (backupKey) {
|
||||
writeRaw(backupKey, legacy.value);
|
||||
}
|
||||
console.info("[storage.restore.legacy]", logicalKey, legacyKey, "->", primaryKey);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureStorageBootstrapped() {
|
||||
if (storageBootstrapDone || storageBootstrapInProgress) return;
|
||||
storageBootstrapInProgress = true;
|
||||
try {
|
||||
Object.keys(KEYS).forEach((logicalKey) => {
|
||||
hydrateStorageKey(logicalKey);
|
||||
});
|
||||
storageBootstrapDone = true;
|
||||
} finally {
|
||||
storageBootstrapInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
function read(key, fallback) {
|
||||
ensureStorageBootstrapped();
|
||||
try {
|
||||
const { found, value } = readRaw(key);
|
||||
if (!found) return fallback;
|
||||
return value === undefined || value === null || value === "" ? fallback : value;
|
||||
} catch (error) {
|
||||
console.warn("[storage.read]", key, error);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function write(key, value) {
|
||||
ensureStorageBootstrapped();
|
||||
writeRaw(key, value);
|
||||
|
||||
const logicalKey = Object.keys(KEYS).find((name) => KEYS[name] === key);
|
||||
if (!logicalKey) return;
|
||||
|
||||
const backupKey = BACKUP_KEYS[logicalKey];
|
||||
if (!backupKey) return;
|
||||
|
||||
if (!writeRaw(backupKey, value)) {
|
||||
console.error("[storage.write.backup]", key, backupKey);
|
||||
}
|
||||
}
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function loadSyncService() {
|
||||
if (syncServiceModule) return syncServiceModule;
|
||||
try {
|
||||
syncServiceModule = require("./syncService");
|
||||
} catch {
|
||||
syncServiceModule = null;
|
||||
}
|
||||
return syncServiceModule;
|
||||
}
|
||||
|
||||
function notifySync(method, ...args) {
|
||||
const service = loadSyncService();
|
||||
if (!service || typeof service[method] !== "function") return;
|
||||
try {
|
||||
service[method](...args);
|
||||
} catch (error) {
|
||||
console.warn(`[storage.sync.${method}]`, error);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeJumpHost(input) {
|
||||
const source = input && typeof input === "object" ? input : {};
|
||||
const authType =
|
||||
source.authType === "privateKey" || source.authType === "certificate" ? source.authType : "password";
|
||||
return {
|
||||
enabled: source.enabled === true,
|
||||
host: String(source.host || "").trim(),
|
||||
port: Number(source.port) || 22,
|
||||
username: String(source.username || "").trim(),
|
||||
authType
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeServerAuthType(value) {
|
||||
return value === "privateKey" || value === "certificate" ? value : "password";
|
||||
}
|
||||
|
||||
function normalizeServerRecord(server) {
|
||||
const source = server && typeof server === "object" ? server : {};
|
||||
// 服务器对象要在本地先归一化成“同步可直接上传”的形状。
|
||||
// 历史数据里可能残留 authType="key"、null 字段或字符串数字,若原样上传会被网关 schema 拒绝。
|
||||
return {
|
||||
...source,
|
||||
id: String(source.id || ""),
|
||||
name: String(source.name || ""),
|
||||
tags: normalizeServerTags(source.tags),
|
||||
host: String(source.host || "").trim(),
|
||||
port: Number(source.port) || 22,
|
||||
username: String(source.username || "").trim(),
|
||||
authType: normalizeServerAuthType(source.authType),
|
||||
password: String(source.password || ""),
|
||||
privateKey: String(source.privateKey || ""),
|
||||
passphrase: String(source.passphrase || ""),
|
||||
certificate: String(source.certificate || ""),
|
||||
projectPath: String(source.projectPath || ""),
|
||||
timeoutSeconds: Number(source.timeoutSeconds) || 15,
|
||||
heartbeatSeconds: Number(source.heartbeatSeconds) || 10,
|
||||
transportMode: String(source.transportMode || "gateway"),
|
||||
jumpHost: normalizeJumpHost(source.jumpHost),
|
||||
jumpPassword: String(source.jumpPassword || ""),
|
||||
jumpPrivateKey: String(source.jumpPrivateKey || ""),
|
||||
jumpPassphrase: String(source.jumpPassphrase || ""),
|
||||
jumpCertificate: String(source.jumpCertificate || ""),
|
||||
sortOrder: Number(source.sortOrder) || 0,
|
||||
lastConnectedAt: String(source.lastConnectedAt || ""),
|
||||
updatedAt: String(source.updatedAt || source.lastConnectedAt || nowIso())
|
||||
};
|
||||
}
|
||||
|
||||
function createServerSeed() {
|
||||
const ts = Date.now();
|
||||
const rand = Math.random().toString(36).slice(2, 8);
|
||||
const settings = getSettings();
|
||||
const defaultAuthType = settings.defaultAuthType === "key" ? "privateKey" : "password";
|
||||
return {
|
||||
id: `srv-${ts}-${rand}`,
|
||||
name: "",
|
||||
tags: [],
|
||||
host: "",
|
||||
port: Number(settings.defaultPort) || 22,
|
||||
username: "",
|
||||
authType: defaultAuthType,
|
||||
password: "",
|
||||
privateKey: "",
|
||||
passphrase: "",
|
||||
certificate: "",
|
||||
projectPath: settings.defaultProjectPath || "",
|
||||
timeoutSeconds: Number(settings.defaultTimeoutSeconds) || 15,
|
||||
heartbeatSeconds: Number(settings.defaultHeartbeatSeconds) || 10,
|
||||
transportMode: settings.defaultTransportMode || "gateway",
|
||||
jumpHost: normalizeJumpHost(),
|
||||
sortOrder: ts,
|
||||
lastConnectedAt: "",
|
||||
updatedAt: nowIso()
|
||||
};
|
||||
}
|
||||
|
||||
function listServers() {
|
||||
const rows = read(KEYS.servers, []);
|
||||
return Array.isArray(rows)
|
||||
? rows
|
||||
.map((item) => normalizeServerRecord({ ...createServerSeed(), ...item }))
|
||||
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
|
||||
: [];
|
||||
}
|
||||
|
||||
function saveServers(next, options) {
|
||||
write(KEYS.servers, Array.isArray(next) ? next : []);
|
||||
const extra = options && typeof options === "object" ? options : {};
|
||||
if (!extra.silentSync) {
|
||||
notifySync("scheduleServersSync");
|
||||
}
|
||||
}
|
||||
|
||||
function upsertServer(server, options) {
|
||||
const extra = options && typeof options === "object" ? options : {};
|
||||
const rows = listServers();
|
||||
const index = rows.findIndex((item) => item.id === server.id);
|
||||
const updatedAt = extra.preserveUpdatedAt ? String(server.updatedAt || nowIso()) : nowIso();
|
||||
if (index >= 0) {
|
||||
rows[index] = normalizeServerRecord({ ...rows[index], ...server, updatedAt });
|
||||
} else {
|
||||
rows.push(normalizeServerRecord({ ...createServerSeed(), ...server, updatedAt }));
|
||||
}
|
||||
saveServers(rows, extra);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function removeServer(serverId, options) {
|
||||
const extra = options && typeof options === "object" ? options : {};
|
||||
const rows = listServers().filter((item) => item.id !== serverId);
|
||||
saveServers(rows, extra);
|
||||
if (!extra.silentSync) {
|
||||
notifySync("markServerDeleted", serverId, nowIso());
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function markServerConnected(serverId) {
|
||||
const rows = listServers();
|
||||
const index = rows.findIndex((item) => item.id === serverId);
|
||||
if (index >= 0) {
|
||||
rows[index].lastConnectedAt = nowIso();
|
||||
rows[index].updatedAt = nowIso();
|
||||
saveServers(rows);
|
||||
}
|
||||
}
|
||||
|
||||
function getSettings() {
|
||||
return normalizeSettings({ ...DEFAULT_SETTINGS, ...sanitizeUserSettings(read(KEYS.settings, {})) });
|
||||
}
|
||||
|
||||
function saveSettings(next, options) {
|
||||
const extra = options && typeof options === "object" ? options : {};
|
||||
const previous = getSettings();
|
||||
const updatedAt = extra.preserveUpdatedAt
|
||||
? String(next && next.updatedAt ? next.updatedAt : nowIso())
|
||||
: nowIso();
|
||||
const normalized = normalizeSettings({ ...previous, ...sanitizeUserSettings(next || {}), updatedAt });
|
||||
write(KEYS.settings, normalized);
|
||||
if (!extra.silentSync) {
|
||||
/**
|
||||
* 重新打开云端同步时,先强制跑一次 bootstrap:
|
||||
* 1. 先把云端最新视图合并回本地;
|
||||
* 2. 再补推当前合并结果,避免直接用“关闭期间的本地快照”覆盖远端。
|
||||
*/
|
||||
if (!previous.syncConfigEnabled && normalized.syncConfigEnabled) {
|
||||
notifySync("resumeSyncConfig");
|
||||
} else {
|
||||
notifySync("scheduleSettingsSync");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRecord(item) {
|
||||
const source = item && typeof item === "object" ? item : {};
|
||||
const createdAt = String(source.createdAt || nowIso());
|
||||
const discarded = source.discarded === true;
|
||||
const processed = !discarded && source.processed === true;
|
||||
return {
|
||||
id: String(source.id || `rec-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`),
|
||||
content: String(source.content || "").trim(),
|
||||
serverId: String(source.serverId || ""),
|
||||
createdAt,
|
||||
updatedAt: String(source.updatedAt || createdAt),
|
||||
category:
|
||||
String(source.category || DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK).trim() ||
|
||||
DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK,
|
||||
contextLabel: String(source.contextLabel || ""),
|
||||
processed,
|
||||
discarded
|
||||
};
|
||||
}
|
||||
|
||||
function dropExpiredLogs(rows) {
|
||||
const list = Array.isArray(rows) ? rows : [];
|
||||
const retentionDays = getSettings().logRetentionDays;
|
||||
const ttlMs = Number(retentionDays) * 24 * 60 * 60 * 1000;
|
||||
if (!Number.isFinite(ttlMs) || ttlMs <= 0) return list;
|
||||
|
||||
const now = Date.now();
|
||||
return list.filter((item) => {
|
||||
const startAt = +new Date(item.startAt || 0);
|
||||
if (!Number.isFinite(startAt) || startAt <= 0) return true;
|
||||
return now - startAt <= ttlMs;
|
||||
});
|
||||
}
|
||||
|
||||
function listLogs() {
|
||||
const rows = read(KEYS.logs, []);
|
||||
const cleaned = dropExpiredLogs(rows);
|
||||
if (Array.isArray(rows) && cleaned.length !== rows.length) {
|
||||
write(KEYS.logs, cleaned);
|
||||
}
|
||||
return Array.isArray(cleaned)
|
||||
? cleaned.slice().sort((a, b) => +new Date(b.startAt) - +new Date(a.startAt))
|
||||
: [];
|
||||
}
|
||||
|
||||
function appendLog(entry) {
|
||||
const rows = listLogs();
|
||||
rows.unshift({
|
||||
id: entry.id || `log-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
startAt: entry.startAt || nowIso(),
|
||||
endAt: entry.endAt || "",
|
||||
serverId: entry.serverId || "",
|
||||
status: entry.status || "connected",
|
||||
summary: entry.summary || ""
|
||||
});
|
||||
write(KEYS.logs, rows);
|
||||
}
|
||||
|
||||
function listRecords() {
|
||||
const rows = read(KEYS.records, []);
|
||||
if (!Array.isArray(rows)) return [];
|
||||
let dirty = false;
|
||||
const normalized = rows
|
||||
.map((item) => {
|
||||
const next = normalizeRecord(item);
|
||||
if (
|
||||
!item ||
|
||||
item.updatedAt !== next.updatedAt ||
|
||||
item.category !== next.category ||
|
||||
item.contextLabel !== next.contextLabel ||
|
||||
item.processed !== next.processed ||
|
||||
item.discarded !== next.discarded ||
|
||||
item.content !== next.content ||
|
||||
item.serverId !== next.serverId
|
||||
) {
|
||||
dirty = true;
|
||||
}
|
||||
return next;
|
||||
})
|
||||
.sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt));
|
||||
if (dirty) {
|
||||
write(KEYS.records, normalized);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function saveRecords(next, options) {
|
||||
write(KEYS.records, Array.isArray(next) ? next.map((item) => normalizeRecord(item)) : []);
|
||||
const extra = options && typeof options === "object" ? options : {};
|
||||
if (!extra.silentSync) {
|
||||
notifySync("scheduleRecordsSync");
|
||||
}
|
||||
}
|
||||
|
||||
function addRecord(content, serverId, options) {
|
||||
const text = String(content || "").trim();
|
||||
if (!text) return null;
|
||||
const rows = listRecords();
|
||||
const extra = options && typeof options === "object" ? options : {};
|
||||
const timestamp = nowIso();
|
||||
const discarded = extra.discarded === true;
|
||||
const processed = !discarded && extra.processed === true;
|
||||
const next = {
|
||||
id: `rec-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
content: text,
|
||||
serverId: serverId || "",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
category:
|
||||
String(extra.category || DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK).trim() ||
|
||||
DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK,
|
||||
contextLabel: String(extra.contextLabel || ""),
|
||||
processed,
|
||||
discarded
|
||||
};
|
||||
rows.unshift(next);
|
||||
saveRecords(rows);
|
||||
return next;
|
||||
}
|
||||
|
||||
function updateRecord(payload) {
|
||||
const source = payload && typeof payload === "object" ? payload : {};
|
||||
const recordId = String(source.id || "").trim();
|
||||
const content = String(source.content || "").trim();
|
||||
if (!recordId || !content) return null;
|
||||
const rows = listRecords();
|
||||
const index = rows.findIndex((item) => item.id === recordId);
|
||||
if (index < 0) return null;
|
||||
const current = rows[index];
|
||||
let nextProcessed = Object.prototype.hasOwnProperty.call(source, "processed")
|
||||
? source.processed === true
|
||||
: current.processed === true;
|
||||
let nextDiscarded = Object.prototype.hasOwnProperty.call(source, "discarded")
|
||||
? source.discarded === true
|
||||
: current.discarded === true;
|
||||
/**
|
||||
* 闪念终态互斥规则与 normalizeRecord 保持一致:
|
||||
* 1. 只要本次结果里 discarded 为 true,就强制关掉 processed;
|
||||
* 2. 否则若 processed 为 true,再回头关掉 discarded;
|
||||
* 3. 这样“已废弃”不会被历史 processed 残值反向吞掉。
|
||||
*/
|
||||
if (nextDiscarded) {
|
||||
nextProcessed = false;
|
||||
} else if (nextProcessed) {
|
||||
nextDiscarded = false;
|
||||
}
|
||||
const next = {
|
||||
...current,
|
||||
content,
|
||||
category:
|
||||
String(source.category || current.category || DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK).trim() ||
|
||||
DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK,
|
||||
processed: nextProcessed,
|
||||
discarded: nextDiscarded,
|
||||
updatedAt: nowIso()
|
||||
};
|
||||
rows[index] = next;
|
||||
saveRecords(rows);
|
||||
return next;
|
||||
}
|
||||
|
||||
function searchRecords(input) {
|
||||
const source = input && typeof input === "object" ? input : {};
|
||||
const keyword = String(source.keyword || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const category = String(source.category || "").trim();
|
||||
return listRecords().filter((item) => {
|
||||
if (category && item.category !== category) return false;
|
||||
if (!keyword) return true;
|
||||
return [item.content, item.category, item.contextLabel, item.createdAt]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(keyword);
|
||||
});
|
||||
}
|
||||
|
||||
function removeRecord(recordId, options) {
|
||||
const extra = options && typeof options === "object" ? options : {};
|
||||
const rows = listRecords().filter((item) => item.id !== recordId);
|
||||
saveRecords(rows, extra);
|
||||
if (!extra.silentSync) {
|
||||
notifySync("markRecordDeleted", recordId, nowIso());
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function listPluginPackages() {
|
||||
const rows = read(KEYS.pluginPackages, []);
|
||||
return Array.isArray(rows) ? rows : [];
|
||||
}
|
||||
|
||||
function getPluginPackage(pluginId) {
|
||||
const id = String(pluginId || "");
|
||||
return listPluginPackages().find((item) => item && item.manifest && item.manifest.id === id) || null;
|
||||
}
|
||||
|
||||
function savePluginPackages(next) {
|
||||
write(KEYS.pluginPackages, Array.isArray(next) ? next : []);
|
||||
}
|
||||
|
||||
function upsertPluginPackage(pluginPackage) {
|
||||
if (!pluginPackage || !pluginPackage.manifest || !pluginPackage.manifest.id) {
|
||||
throw new Error("插件包格式非法");
|
||||
}
|
||||
const rows = listPluginPackages();
|
||||
const index = rows.findIndex(
|
||||
(item) => item && item.manifest && item.manifest.id === pluginPackage.manifest.id
|
||||
);
|
||||
if (index >= 0) {
|
||||
rows[index] = pluginPackage;
|
||||
} else {
|
||||
rows.push(pluginPackage);
|
||||
}
|
||||
savePluginPackages(rows);
|
||||
}
|
||||
|
||||
function removePluginPackage(pluginId) {
|
||||
const id = String(pluginId || "");
|
||||
const rows = listPluginPackages().filter((item) => item && item.manifest && item.manifest.id !== id);
|
||||
savePluginPackages(rows);
|
||||
}
|
||||
|
||||
function listPluginRecords() {
|
||||
const rows = read(KEYS.pluginRecords, []);
|
||||
return Array.isArray(rows) ? rows : [];
|
||||
}
|
||||
|
||||
function savePluginRecords(next) {
|
||||
write(KEYS.pluginRecords, Array.isArray(next) ? next : []);
|
||||
}
|
||||
|
||||
function listPluginRuntimeLogs() {
|
||||
const rows = read(KEYS.pluginRuntimeLogs, []);
|
||||
return Array.isArray(rows) ? rows : [];
|
||||
}
|
||||
|
||||
function savePluginRuntimeLogs(next) {
|
||||
write(KEYS.pluginRuntimeLogs, Array.isArray(next) ? next : []);
|
||||
}
|
||||
|
||||
function readPluginData(pluginId) {
|
||||
const id = String(pluginId || "").trim();
|
||||
if (!id) return {};
|
||||
return read(`remoteconn.plugins.data.${id}.v2`, {});
|
||||
}
|
||||
|
||||
function writePluginData(pluginId, value) {
|
||||
const id = String(pluginId || "").trim();
|
||||
if (!id) return;
|
||||
write(`remoteconn.plugins.data.${id}.v2`, value && typeof value === "object" ? value : {});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createServerSeed,
|
||||
listServers,
|
||||
saveServers,
|
||||
upsertServer,
|
||||
removeServer,
|
||||
markServerConnected,
|
||||
getSettings,
|
||||
saveSettings,
|
||||
listLogs,
|
||||
appendLog,
|
||||
listRecords,
|
||||
addRecord,
|
||||
updateRecord,
|
||||
saveRecords,
|
||||
searchRecords,
|
||||
removeRecord,
|
||||
listPluginPackages,
|
||||
getPluginPackage,
|
||||
savePluginPackages,
|
||||
upsertPluginPackage,
|
||||
removePluginPackage,
|
||||
listPluginRecords,
|
||||
savePluginRecords,
|
||||
listPluginRuntimeLogs,
|
||||
savePluginRuntimeLogs,
|
||||
readPluginData,
|
||||
writePluginData,
|
||||
DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK,
|
||||
DEFAULT_VOICE_RECORD_CATEGORIES,
|
||||
DEFAULT_VOICE_RECORD_DEFAULT_CATEGORY,
|
||||
DEFAULT_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES,
|
||||
MIN_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES,
|
||||
MAX_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES,
|
||||
normalizeVoiceRecordCategories,
|
||||
normalizeVoiceRecordDefaultCategory
|
||||
};
|
||||
367
apps/miniprogram/utils/storage.test.ts
Normal file
367
apps/miniprogram/utils/storage.test.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type StorageValue = string | number | boolean | null | undefined | Record<string, unknown> | unknown[];
|
||||
|
||||
function createWxStorage(initial: Record<string, StorageValue>) {
|
||||
const store = new Map<string, StorageValue>(Object.entries(initial));
|
||||
return {
|
||||
store,
|
||||
getStorageSync(key: string) {
|
||||
return store.get(key);
|
||||
},
|
||||
setStorageSync(key: string, value: StorageValue) {
|
||||
store.set(key, value);
|
||||
},
|
||||
getStorageInfoSync() {
|
||||
return { keys: Array.from(store.keys()) };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function loadStorageModule() {
|
||||
const modulePath = require.resolve("./storage.js");
|
||||
delete require.cache[modulePath];
|
||||
return require("./storage.js");
|
||||
}
|
||||
|
||||
describe("miniprogram storage bootstrap", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("当前正式 key 缺失时可从稳定备份恢复服务器列表", () => {
|
||||
const backupServers = [
|
||||
{
|
||||
id: "srv-backup-1",
|
||||
name: "backup",
|
||||
host: "127.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
projectPath: "~/workspace",
|
||||
timeoutSeconds: 20,
|
||||
heartbeatSeconds: 15,
|
||||
transportMode: "gateway",
|
||||
tags: ["prod"],
|
||||
jumpHost: {},
|
||||
sortOrder: 1,
|
||||
lastConnectedAt: ""
|
||||
}
|
||||
];
|
||||
const wxStorage = createWxStorage({
|
||||
"remoteconn.backup.servers": backupServers
|
||||
});
|
||||
(global as typeof globalThis & { wx: typeof wxStorage }).wx = wxStorage;
|
||||
|
||||
const storage = loadStorageModule();
|
||||
const rows = storage.listServers();
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].id).toBe("srv-backup-1");
|
||||
expect(wxStorage.store.get("remoteconn.servers.v2")).toEqual(backupServers);
|
||||
});
|
||||
|
||||
it("当前正式 key 与备份都缺失时可从历史 key 迁移记录数据", () => {
|
||||
const legacyRecords = [
|
||||
{
|
||||
id: "rec-legacy-1",
|
||||
content: "legacy note",
|
||||
serverId: "srv-legacy-1",
|
||||
createdAt: "2026-03-09T10:00:00.000Z"
|
||||
}
|
||||
];
|
||||
const wxStorage = createWxStorage({
|
||||
"remoteconn.records.v1": legacyRecords
|
||||
});
|
||||
(global as typeof globalThis & { wx: typeof wxStorage }).wx = wxStorage;
|
||||
|
||||
const storage = loadStorageModule();
|
||||
const rows = storage.listRecords();
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
id: "rec-legacy-1",
|
||||
content: "legacy note",
|
||||
serverId: "srv-legacy-1"
|
||||
});
|
||||
expect(wxStorage.store.get("remoteconn.records.v2")).toEqual(rows);
|
||||
expect(wxStorage.store.get("remoteconn.backup.records")).toEqual(rows);
|
||||
});
|
||||
|
||||
it("闪念处理状态会归一化并按互斥规则稳定写回", () => {
|
||||
const wxStorage = createWxStorage({
|
||||
"remoteconn.records.v2": [
|
||||
{
|
||||
id: "rec-1",
|
||||
content: "待处理事项",
|
||||
category: "问题",
|
||||
createdAt: "2026-03-10T10:00:00.000Z",
|
||||
updatedAt: "2026-03-10T10:00:00.000Z"
|
||||
}
|
||||
]
|
||||
});
|
||||
(global as typeof globalThis & { wx: typeof wxStorage }).wx = wxStorage;
|
||||
|
||||
const storage = loadStorageModule();
|
||||
const normalized = storage.listRecords();
|
||||
expect(normalized[0]?.processed).toBe(false);
|
||||
expect(normalized[0]?.discarded).toBe(false);
|
||||
|
||||
const processed = storage.updateRecord({
|
||||
id: "rec-1",
|
||||
content: "待处理事项",
|
||||
category: "问题",
|
||||
processed: true
|
||||
});
|
||||
|
||||
expect(processed?.processed).toBe(true);
|
||||
expect(processed?.discarded).toBe(false);
|
||||
|
||||
const discarded = storage.updateRecord({
|
||||
id: "rec-1",
|
||||
content: "待处理事项",
|
||||
category: "问题",
|
||||
discarded: true
|
||||
});
|
||||
|
||||
expect(discarded?.processed).toBe(false);
|
||||
expect(discarded?.discarded).toBe(true);
|
||||
expect(storage.listRecords()[0]?.processed).toBe(false);
|
||||
expect(storage.listRecords()[0]?.discarded).toBe(true);
|
||||
expect(wxStorage.store.get("remoteconn.records.v2")).toEqual(storage.listRecords());
|
||||
});
|
||||
|
||||
it("写入正式 key 时会同步刷新稳定备份", () => {
|
||||
const wxStorage = createWxStorage({});
|
||||
(global as typeof globalThis & { wx: typeof wxStorage }).wx = wxStorage;
|
||||
|
||||
const storage = loadStorageModule();
|
||||
storage.saveSettings({
|
||||
uiThemeMode: "light",
|
||||
uiBgColor: "#f4f1de",
|
||||
uiTextColor: "#222222",
|
||||
uiBtnColor: "#111111",
|
||||
uiAccentColor: "#123456"
|
||||
});
|
||||
|
||||
expect(wxStorage.store.get("remoteconn.settings.v2")).toEqual(
|
||||
wxStorage.store.get("remoteconn.backup.settings")
|
||||
);
|
||||
});
|
||||
|
||||
it("syncConfigEnabled 关闭后可稳定读回", () => {
|
||||
const wxStorage = createWxStorage({});
|
||||
(global as typeof globalThis & { wx: typeof wxStorage }).wx = wxStorage;
|
||||
|
||||
const storage = loadStorageModule();
|
||||
storage.saveSettings({
|
||||
syncConfigEnabled: false
|
||||
});
|
||||
|
||||
expect(storage.getSettings().syncConfigEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("showVoiceInputButton 关闭后可稳定读回", () => {
|
||||
const wxStorage = createWxStorage({});
|
||||
(global as typeof globalThis & { wx: typeof wxStorage }).wx = wxStorage;
|
||||
|
||||
const storage = loadStorageModule();
|
||||
expect(storage.getSettings().showVoiceInputButton).toBe(true);
|
||||
|
||||
storage.saveSettings({
|
||||
showVoiceInputButton: false
|
||||
});
|
||||
|
||||
expect(storage.getSettings().showVoiceInputButton).toBe(false);
|
||||
});
|
||||
|
||||
it("ttsSpeakableMaxChars 会按边界归一化并稳定读回", () => {
|
||||
const wxStorage = createWxStorage({});
|
||||
(global as typeof globalThis & { wx: typeof wxStorage }).wx = wxStorage;
|
||||
|
||||
const storage = loadStorageModule();
|
||||
expect(storage.getSettings().ttsSpeakableMaxChars).toBe(500);
|
||||
expect(storage.getSettings().ttsSegmentMaxChars).toBe(80);
|
||||
|
||||
storage.saveSettings({
|
||||
ttsSpeakableMaxChars: 50
|
||||
});
|
||||
expect(storage.getSettings().ttsSpeakableMaxChars).toBe(120);
|
||||
|
||||
storage.saveSettings({
|
||||
ttsSpeakableMaxChars: 500
|
||||
});
|
||||
expect(storage.getSettings().ttsSpeakableMaxChars).toBe(500);
|
||||
|
||||
storage.saveSettings({
|
||||
ttsSpeakableMaxChars: 5000
|
||||
});
|
||||
expect(storage.getSettings().ttsSpeakableMaxChars).toBe(1200);
|
||||
|
||||
storage.saveSettings({
|
||||
ttsSegmentMaxChars: 10
|
||||
});
|
||||
expect(storage.getSettings().ttsSegmentMaxChars).toBe(40);
|
||||
|
||||
storage.saveSettings({
|
||||
ttsSegmentMaxChars: 80
|
||||
});
|
||||
expect(storage.getSettings().ttsSegmentMaxChars).toBe(80);
|
||||
|
||||
storage.saveSettings({
|
||||
ttsSegmentMaxChars: 1000
|
||||
});
|
||||
expect(storage.getSettings().ttsSegmentMaxChars).toBe(200);
|
||||
});
|
||||
|
||||
it("AI 连接默认模式会稳定读回,非法值会回退到默认档位", () => {
|
||||
const wxStorage = createWxStorage({});
|
||||
(global as typeof globalThis & { wx: typeof wxStorage }).wx = wxStorage;
|
||||
|
||||
const storage = loadStorageModule();
|
||||
expect(storage.getSettings().aiDefaultProvider).toBe("codex");
|
||||
expect(storage.getSettings().aiCodexSandboxMode).toBe("workspace-write");
|
||||
expect(storage.getSettings().aiCopilotPermissionMode).toBe("default");
|
||||
|
||||
storage.saveSettings({
|
||||
aiDefaultProvider: "copilot",
|
||||
aiCodexSandboxMode: "read-only",
|
||||
aiCopilotPermissionMode: "experimental"
|
||||
});
|
||||
expect(storage.getSettings().aiDefaultProvider).toBe("copilot");
|
||||
expect(storage.getSettings().aiCodexSandboxMode).toBe("read-only");
|
||||
expect(storage.getSettings().aiCopilotPermissionMode).toBe("experimental");
|
||||
|
||||
storage.saveSettings({
|
||||
aiDefaultProvider: "invalid",
|
||||
aiCodexSandboxMode: "invalid",
|
||||
aiCopilotPermissionMode: "invalid"
|
||||
});
|
||||
expect(storage.getSettings().aiDefaultProvider).toBe("codex");
|
||||
expect(storage.getSettings().aiCodexSandboxMode).toBe("workspace-write");
|
||||
expect(storage.getSettings().aiCopilotPermissionMode).toBe("default");
|
||||
});
|
||||
|
||||
it("续接快照行数会按边界归一化后稳定读回", () => {
|
||||
const wxStorage = createWxStorage({});
|
||||
(global as typeof globalThis & { wx: typeof wxStorage }).wx = wxStorage;
|
||||
|
||||
const storage = loadStorageModule();
|
||||
expect(storage.getSettings().shellBufferSnapshotMaxLines).toBe(
|
||||
storage.DEFAULT_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES
|
||||
);
|
||||
|
||||
storage.saveSettings({
|
||||
shellBufferSnapshotMaxLines: storage.MIN_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES - 1
|
||||
});
|
||||
expect(storage.getSettings().shellBufferSnapshotMaxLines).toBe(
|
||||
storage.MIN_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES
|
||||
);
|
||||
|
||||
storage.saveSettings({
|
||||
shellBufferSnapshotMaxLines: storage.MAX_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES + 1
|
||||
});
|
||||
expect(storage.getSettings().shellBufferSnapshotMaxLines).toBe(
|
||||
storage.MAX_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES
|
||||
);
|
||||
});
|
||||
|
||||
it("uiLanguage 会回退到合法值并稳定读回", () => {
|
||||
const wxStorage = createWxStorage({});
|
||||
(global as typeof globalThis & { wx: typeof wxStorage }).wx = wxStorage;
|
||||
|
||||
const storage = loadStorageModule();
|
||||
expect(storage.getSettings().uiLanguage).toBe("zh-Hans");
|
||||
|
||||
storage.saveSettings({
|
||||
uiLanguage: "ja"
|
||||
});
|
||||
expect(storage.getSettings().uiLanguage).toBe("ja");
|
||||
|
||||
storage.saveSettings({
|
||||
uiLanguage: "ko"
|
||||
});
|
||||
expect(storage.getSettings().uiLanguage).toBe("ko");
|
||||
|
||||
storage.saveSettings({
|
||||
uiLanguage: "invalid-language"
|
||||
});
|
||||
expect(storage.getSettings().uiLanguage).toBe("zh-Hans");
|
||||
});
|
||||
|
||||
it("历史服务器脏数据会先归一化,再参与同步上传", () => {
|
||||
const wxStorage = createWxStorage({
|
||||
"remoteconn.servers.v2": [
|
||||
{
|
||||
id: "srv-legacy-1",
|
||||
name: null,
|
||||
tags: ["prod", "", "prod"],
|
||||
host: " 10.0.0.8 ",
|
||||
port: "22",
|
||||
username: null,
|
||||
authType: "key",
|
||||
password: null,
|
||||
privateKey: null,
|
||||
passphrase: null,
|
||||
certificate: null,
|
||||
projectPath: null,
|
||||
timeoutSeconds: "30",
|
||||
heartbeatSeconds: "12",
|
||||
transportMode: null,
|
||||
jumpHost: {
|
||||
enabled: true,
|
||||
host: " jump.local ",
|
||||
port: "2222",
|
||||
username: null,
|
||||
authType: "key"
|
||||
},
|
||||
jumpPassword: null,
|
||||
jumpPrivateKey: null,
|
||||
jumpPassphrase: null,
|
||||
jumpCertificate: null,
|
||||
sortOrder: "9",
|
||||
lastConnectedAt: null,
|
||||
updatedAt: ""
|
||||
}
|
||||
]
|
||||
});
|
||||
(global as typeof globalThis & { wx: typeof wxStorage }).wx = wxStorage;
|
||||
|
||||
const storage = loadStorageModule();
|
||||
const rows = storage.listServers();
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
id: "srv-legacy-1",
|
||||
name: "",
|
||||
tags: ["prod"],
|
||||
host: "10.0.0.8",
|
||||
port: 22,
|
||||
username: "",
|
||||
authType: "password",
|
||||
password: "",
|
||||
privateKey: "",
|
||||
passphrase: "",
|
||||
certificate: "",
|
||||
projectPath: "",
|
||||
timeoutSeconds: 30,
|
||||
heartbeatSeconds: 12,
|
||||
transportMode: "gateway",
|
||||
jumpHost: {
|
||||
enabled: true,
|
||||
host: "jump.local",
|
||||
port: 2222,
|
||||
username: "",
|
||||
authType: "password"
|
||||
},
|
||||
jumpPassword: "",
|
||||
jumpPrivateKey: "",
|
||||
jumpPassphrase: "",
|
||||
jumpCertificate: "",
|
||||
sortOrder: 9,
|
||||
lastConnectedAt: ""
|
||||
});
|
||||
expect(typeof rows[0].updatedAt).toBe("string");
|
||||
expect(rows[0].updatedAt.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
69
apps/miniprogram/utils/svgButtonFeedback.js
Normal file
69
apps/miniprogram/utils/svgButtonFeedback.js
Normal file
@@ -0,0 +1,69 @@
|
||||
/* global module */
|
||||
|
||||
const DEFAULT_DATA_KEY = "pressedSvgButtonKey";
|
||||
const DEFAULT_DATASET_KEY = "pressKey";
|
||||
|
||||
function buildSvgButtonPressData(dataKey = DEFAULT_DATA_KEY) {
|
||||
return {
|
||||
[dataKey]: ""
|
||||
};
|
||||
}
|
||||
|
||||
function extractSvgButtonPressKey(event, datasetKey = DEFAULT_DATASET_KEY) {
|
||||
const source =
|
||||
(event && event.currentTarget && event.currentTarget.dataset) ||
|
||||
(event && event.target && event.target.dataset) ||
|
||||
null;
|
||||
if (!source) return "";
|
||||
return String(source[datasetKey] || "").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序同一时刻只需要记录“当前正在按下的那个 SVG 按钮”:
|
||||
* 1. 用单个字符串足够表达,避免为每个按钮建一堆布尔字段;
|
||||
* 2. 页面和组件都复用同一个数据键,模板判断也保持统一。
|
||||
*/
|
||||
function setSvgButtonPressed(host, key, pressed, dataKey = DEFAULT_DATA_KEY) {
|
||||
if (!host || typeof host.setData !== "function") return;
|
||||
const current = String((host.data && host.data[dataKey]) || "").trim();
|
||||
const normalizedKey = String(key || "").trim();
|
||||
const next = pressed ? normalizedKey : current === normalizedKey ? "" : current;
|
||||
if (current === next) return;
|
||||
host.setData({
|
||||
[dataKey]: next
|
||||
});
|
||||
}
|
||||
|
||||
function createSvgButtonPressMethods(options = {}) {
|
||||
const dataKey = String(options.dataKey || DEFAULT_DATA_KEY).trim() || DEFAULT_DATA_KEY;
|
||||
const datasetKey = String(options.datasetKey || DEFAULT_DATASET_KEY).trim() || DEFAULT_DATASET_KEY;
|
||||
|
||||
return {
|
||||
setSvgButtonPressState(key, pressed) {
|
||||
setSvgButtonPressed(this, key, pressed, dataKey);
|
||||
},
|
||||
|
||||
setSvgButtonPressStateFromEvent(event, pressed) {
|
||||
const key = extractSvgButtonPressKey(event, datasetKey);
|
||||
setSvgButtonPressed(this, key, pressed, dataKey);
|
||||
},
|
||||
|
||||
onSvgButtonTouchStart(event) {
|
||||
const key = extractSvgButtonPressKey(event, datasetKey);
|
||||
setSvgButtonPressed(this, key, true, dataKey);
|
||||
},
|
||||
|
||||
onSvgButtonTouchEnd(event) {
|
||||
const key = extractSvgButtonPressKey(event, datasetKey);
|
||||
setSvgButtonPressed(this, key, false, dataKey);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_SVG_BUTTON_PRESS_DATA_KEY: DEFAULT_DATA_KEY,
|
||||
buildSvgButtonPressData,
|
||||
createSvgButtonPressMethods,
|
||||
extractSvgButtonPressKey,
|
||||
setSvgButtonPressed
|
||||
};
|
||||
74
apps/miniprogram/utils/svgButtonFeedback.test.ts
Normal file
74
apps/miniprogram/utils/svgButtonFeedback.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
function loadModule() {
|
||||
const resolved = require.resolve("./svgButtonFeedback.js");
|
||||
delete require.cache[resolved];
|
||||
return require("./svgButtonFeedback.js");
|
||||
}
|
||||
|
||||
describe("svg button feedback helper", () => {
|
||||
it("生成默认按压态数据键", () => {
|
||||
const { buildSvgButtonPressData } = loadModule();
|
||||
expect(buildSvgButtonPressData()).toEqual({
|
||||
pressedSvgButtonKey: ""
|
||||
});
|
||||
expect(buildSvgButtonPressData("customPressedKey")).toEqual({
|
||||
customPressedKey: ""
|
||||
});
|
||||
});
|
||||
|
||||
it("从事件里提取 press key", () => {
|
||||
const { extractSvgButtonPressKey } = loadModule();
|
||||
expect(
|
||||
extractSvgButtonPressKey({
|
||||
currentTarget: {
|
||||
dataset: {
|
||||
pressKey: "toolbar-ai"
|
||||
}
|
||||
}
|
||||
})
|
||||
).toBe("toolbar-ai");
|
||||
expect(
|
||||
extractSvgButtonPressKey({
|
||||
target: {
|
||||
dataset: {
|
||||
customKey: "voice-main"
|
||||
}
|
||||
}
|
||||
}, "customKey")
|
||||
).toBe("voice-main");
|
||||
});
|
||||
|
||||
it("只在状态变化时写入 setData", () => {
|
||||
const { createSvgButtonPressMethods } = loadModule();
|
||||
const host = {
|
||||
data: {
|
||||
pressedSvgButtonKey: ""
|
||||
},
|
||||
setData: vi.fn(function setData(patch: Record<string, string>) {
|
||||
this.data = {
|
||||
...this.data,
|
||||
...patch
|
||||
};
|
||||
})
|
||||
};
|
||||
const methods = createSvgButtonPressMethods();
|
||||
|
||||
methods.setSvgButtonPressState.call(host, "records-close", true);
|
||||
expect(host.data.pressedSvgButtonKey).toBe("records-close");
|
||||
expect(host.setData).toHaveBeenCalledTimes(1);
|
||||
|
||||
methods.setSvgButtonPressState.call(host, "records-close", true);
|
||||
expect(host.setData).toHaveBeenCalledTimes(1);
|
||||
|
||||
methods.onSvgButtonTouchEnd.call(host, {
|
||||
currentTarget: {
|
||||
dataset: {
|
||||
pressKey: "records-close"
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(host.data.pressedSvgButtonKey).toBe("");
|
||||
expect(host.setData).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
80
apps/miniprogram/utils/svgDataUri.js
Normal file
80
apps/miniprogram/utils/svgDataUri.js
Normal file
@@ -0,0 +1,80 @@
|
||||
const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
/**
|
||||
* 把 SVG 文本编码成 UTF-8 字节。
|
||||
* 说明:
|
||||
* 1. 小程序运行时没有稳定可用的 Buffer,因此这里使用纯 JS 编码;
|
||||
* 2. 需要显式处理代理对,避免非 ASCII 字符在 data URI 中损坏;
|
||||
* 3. 非法代理项统一替换成 U+FFFD,保证输出始终可编码。
|
||||
*/
|
||||
function encodeUtf8Bytes(input) {
|
||||
const source = String(input || "");
|
||||
const bytes = [];
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
const first = source.charCodeAt(index);
|
||||
if (first < 0x80) {
|
||||
bytes.push(first);
|
||||
continue;
|
||||
}
|
||||
if (first < 0x800) {
|
||||
bytes.push(0xc0 | (first >> 6), 0x80 | (first & 0x3f));
|
||||
continue;
|
||||
}
|
||||
|
||||
let codePoint = first;
|
||||
if (first >= 0xd800 && first <= 0xdbff) {
|
||||
const second = source.charCodeAt(index + 1);
|
||||
if (second >= 0xdc00 && second <= 0xdfff) {
|
||||
codePoint = ((first - 0xd800) << 10) + (second - 0xdc00) + 0x10000;
|
||||
index += 1;
|
||||
} else {
|
||||
codePoint = 0xfffd;
|
||||
}
|
||||
} else if (first >= 0xdc00 && first <= 0xdfff) {
|
||||
codePoint = 0xfffd;
|
||||
}
|
||||
|
||||
if (codePoint < 0x10000) {
|
||||
bytes.push(0xe0 | (codePoint >> 12), 0x80 | ((codePoint >> 6) & 0x3f), 0x80 | (codePoint & 0x3f));
|
||||
continue;
|
||||
}
|
||||
|
||||
bytes.push(
|
||||
0xf0 | (codePoint >> 18),
|
||||
0x80 | ((codePoint >> 12) & 0x3f),
|
||||
0x80 | ((codePoint >> 6) & 0x3f),
|
||||
0x80 | (codePoint & 0x3f)
|
||||
);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 UTF-8 字节数组转成 base64。
|
||||
* 这样生成的 data URI 可以避开开发者工具对 `utf8,...` 形式 SVG URI 的兼容问题。
|
||||
*/
|
||||
function encodeBase64FromBytes(bytes) {
|
||||
const output = [];
|
||||
for (let index = 0; index < bytes.length; index += 3) {
|
||||
const first = bytes[index];
|
||||
const second = index + 1 < bytes.length ? bytes[index + 1] : null;
|
||||
const third = index + 2 < bytes.length ? bytes[index + 2] : null;
|
||||
const triplet = (first << 16) | ((second || 0) << 8) | (third || 0);
|
||||
output.push(
|
||||
BASE64_ALPHABET[(triplet >> 18) & 0x3f],
|
||||
BASE64_ALPHABET[(triplet >> 12) & 0x3f],
|
||||
second === null ? "=" : BASE64_ALPHABET[(triplet >> 6) & 0x3f],
|
||||
third === null ? "=" : BASE64_ALPHABET[triplet & 0x3f]
|
||||
);
|
||||
}
|
||||
return output.join("");
|
||||
}
|
||||
|
||||
function toSvgDataUri(svg) {
|
||||
const base64 = encodeBase64FromBytes(encodeUtf8Bytes(svg));
|
||||
return `data:image/svg+xml;base64,${base64}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
toSvgDataUri
|
||||
};
|
||||
23
apps/miniprogram/utils/svgDataUri.test.ts
Normal file
23
apps/miniprogram/utils/svgDataUri.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const { toSvgDataUri } = require("./svgDataUri.js");
|
||||
|
||||
function decodeSvgFromDataUri(uri: string) {
|
||||
const prefix = "data:image/svg+xml;base64,";
|
||||
expect(uri.startsWith(prefix)).toBe(true);
|
||||
return Buffer.from(uri.slice(prefix.length), "base64").toString("utf8");
|
||||
}
|
||||
|
||||
describe("svgDataUri", () => {
|
||||
it("把 SVG 编码为 base64 data URI", () => {
|
||||
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><path fill="#67D1FF" d="M0 0h10v10H0z"/></svg>';
|
||||
const uri = toSvgDataUri(svg);
|
||||
expect(decodeSvgFromDataUri(uri)).toBe(svg);
|
||||
});
|
||||
|
||||
it("对非 ASCII 字符使用 UTF-8 编码,避免 data URI 损坏", () => {
|
||||
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><text>终端</text></svg>';
|
||||
const uri = toSvgDataUri(svg);
|
||||
expect(decodeSvgFromDataUri(uri)).toContain("终端");
|
||||
});
|
||||
});
|
||||
174
apps/miniprogram/utils/syncAuth.js
Normal file
174
apps/miniprogram/utils/syncAuth.js
Normal file
@@ -0,0 +1,174 @@
|
||||
/* global wx, console, require, module */
|
||||
|
||||
const { getOpsConfig } = require("./opsConfig");
|
||||
|
||||
const SYNC_AUTH_STORAGE_KEY = "remoteconn.sync.auth.v1";
|
||||
|
||||
function logSyncAuth(level, message, extra) {
|
||||
if (level === "info") return;
|
||||
const payload = extra && typeof extra === "object" ? extra : {};
|
||||
const writer = level === "warn" ? console.warn : level === "error" ? console.error : console.info;
|
||||
writer("[sync.auth]", message, payload);
|
||||
}
|
||||
|
||||
function readAuthCache() {
|
||||
try {
|
||||
const raw = wx.getStorageSync(SYNC_AUTH_STORAGE_KEY);
|
||||
return raw && typeof raw === "object" ? raw : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeAuthCache(value) {
|
||||
try {
|
||||
wx.setStorageSync(SYNC_AUTH_STORAGE_KEY, value && typeof value === "object" ? value : {});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSyncBaseUrl() {
|
||||
const ops = getOpsConfig();
|
||||
const raw = String(ops.gatewayUrl || "").trim();
|
||||
if (!raw) {
|
||||
logSyncAuth("warn", "未配置 gatewayUrl,无法发起同步请求");
|
||||
return "";
|
||||
}
|
||||
let normalized = raw;
|
||||
if (normalized.startsWith("ws://")) normalized = `http://${normalized.slice(5)}`;
|
||||
if (normalized.startsWith("wss://")) normalized = `https://${normalized.slice(6)}`;
|
||||
if (!normalized.startsWith("http://") && !normalized.startsWith("https://")) {
|
||||
normalized = `https://${normalized}`;
|
||||
}
|
||||
normalized = normalized.replace(/[?#].*$/, "").replace(/\/+$/, "");
|
||||
const matched = normalized.match(/^(https?):\/\/([^/]+)(?:\/.*)?$/i);
|
||||
if (!matched) {
|
||||
logSyncAuth("warn", "gatewayUrl 非法,无法推导同步基地址", { raw });
|
||||
return "";
|
||||
}
|
||||
const baseUrl = `${matched[1].toLowerCase()}://${matched[2]}`;
|
||||
logSyncAuth("info", "同步基地址推导成功", { baseUrl });
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
function requestJson(url, options) {
|
||||
logSyncAuth("info", "发起同步请求", {
|
||||
method: (options && options.method) || "GET",
|
||||
url
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.request({
|
||||
url,
|
||||
method: (options && options.method) || "GET",
|
||||
data: options && options.data,
|
||||
header: (options && options.header) || {},
|
||||
...(Number.isFinite(Number(options && options.timeoutMs)) && Number(options && options.timeoutMs) >= 1000
|
||||
? { timeout: Math.round(Number(options && options.timeoutMs)) }
|
||||
: {}),
|
||||
success(res) {
|
||||
const ok = res && res.statusCode >= 200 && res.statusCode < 300;
|
||||
if (!ok) {
|
||||
const message =
|
||||
res && res.data && typeof res.data === "object" && res.data.message
|
||||
? String(res.data.message)
|
||||
: `sync request failed: ${res.statusCode}`;
|
||||
logSyncAuth("warn", "同步请求返回非 2xx", {
|
||||
method: (options && options.method) || "GET",
|
||||
url,
|
||||
statusCode: res && res.statusCode,
|
||||
message
|
||||
});
|
||||
reject(new Error(message));
|
||||
return;
|
||||
}
|
||||
logSyncAuth("info", "同步请求成功", {
|
||||
method: (options && options.method) || "GET",
|
||||
url,
|
||||
statusCode: res.statusCode
|
||||
});
|
||||
resolve(res.data || {});
|
||||
},
|
||||
fail(error) {
|
||||
logSyncAuth("error", "同步请求失败", {
|
||||
method: (options && options.method) || "GET",
|
||||
url,
|
||||
error: error && error.errMsg ? error.errMsg : String(error || "")
|
||||
});
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loginAndFetchToken() {
|
||||
const baseUrl = resolveSyncBaseUrl();
|
||||
const ops = getOpsConfig();
|
||||
if (!baseUrl || !ops.gatewayToken) {
|
||||
logSyncAuth("warn", "同步鉴权配置不完整", {
|
||||
hasBaseUrl: Boolean(baseUrl),
|
||||
hasGatewayToken: Boolean(ops.gatewayToken)
|
||||
});
|
||||
throw new Error("sync config incomplete");
|
||||
}
|
||||
logSyncAuth("info", "准备执行 wx.login 获取同步令牌", { baseUrl });
|
||||
const loginResult = await new Promise((resolve, reject) => {
|
||||
wx.login({
|
||||
success(res) {
|
||||
if (!res.code) {
|
||||
logSyncAuth("warn", "wx.login 成功但未返回 code");
|
||||
reject(new Error("wx.login missing code"));
|
||||
return;
|
||||
}
|
||||
logSyncAuth("info", "wx.login 成功,已拿到 code");
|
||||
resolve(res);
|
||||
},
|
||||
fail(error) {
|
||||
logSyncAuth("error", "wx.login 失败", {
|
||||
error: error && error.errMsg ? error.errMsg : String(error || "")
|
||||
});
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
const payload = await requestJson(`${baseUrl}/api/miniprogram/auth/login`, {
|
||||
method: "POST",
|
||||
data: { code: loginResult.code },
|
||||
header: {
|
||||
"content-type": "application/json",
|
||||
"x-gateway-token": ops.gatewayToken
|
||||
}
|
||||
});
|
||||
if (!payload || payload.ok !== true || !payload.token) {
|
||||
throw new Error((payload && payload.message) || "sync login failed");
|
||||
}
|
||||
const session = {
|
||||
token: String(payload.token || ""),
|
||||
expiresAt: String(payload.expiresAt || "")
|
||||
};
|
||||
writeAuthCache(session);
|
||||
logSyncAuth("info", "同步令牌获取成功", {
|
||||
expiresAt: session.expiresAt
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
async function ensureSyncAuthToken() {
|
||||
const cached = readAuthCache();
|
||||
const expiresAt = +new Date(cached.expiresAt || 0);
|
||||
if (cached.token && Number.isFinite(expiresAt) && expiresAt - Date.now() > 60 * 1000) {
|
||||
logSyncAuth("info", "复用本地缓存的同步令牌", {
|
||||
expiresAt: cached.expiresAt
|
||||
});
|
||||
return String(cached.token);
|
||||
}
|
||||
logSyncAuth("info", "本地无可用同步令牌,准备重新登录");
|
||||
const session = await loginAndFetchToken();
|
||||
return session.token;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ensureSyncAuthToken,
|
||||
resolveSyncBaseUrl,
|
||||
requestJson
|
||||
};
|
||||
34
apps/miniprogram/utils/syncConfigBus.js
Normal file
34
apps/miniprogram/utils/syncConfigBus.js
Normal file
@@ -0,0 +1,34 @@
|
||||
/* global module */
|
||||
|
||||
/**
|
||||
* 同步配置刷新事件总线:
|
||||
* 1. 启动 bootstrap 完成并合并回本地后,当前已打开页面需要立刻重读 storage;
|
||||
* 2. 这里只广播“配置已落地”这一件事,不承载具体网络细节;
|
||||
* 3. 订阅方自行决定刷新服务器列表、主题或其它展示数据,避免总线和页面逻辑耦死。
|
||||
*/
|
||||
const listeners = new Set();
|
||||
|
||||
function emitSyncConfigApplied(payload) {
|
||||
listeners.forEach((listener) => {
|
||||
try {
|
||||
listener(payload);
|
||||
} catch {
|
||||
// 忽略单个订阅方异常,避免影响其它页面刷新。
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function subscribeSyncConfigApplied(listener) {
|
||||
if (typeof listener !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
emitSyncConfigApplied,
|
||||
subscribeSyncConfigApplied
|
||||
};
|
||||
15
apps/miniprogram/utils/syncMask.js
Normal file
15
apps/miniprogram/utils/syncMask.js
Normal file
@@ -0,0 +1,15 @@
|
||||
/* global module */
|
||||
|
||||
/**
|
||||
* 页面展示层如需回显敏感字段,应统一走遮罩处理,避免误展示完整凭据。
|
||||
*/
|
||||
function maskSecret(value) {
|
||||
const raw = String(value || "");
|
||||
if (!raw) return "";
|
||||
if (raw.length <= 8) return `${raw.slice(0, 1)}***${raw.slice(-1)}`;
|
||||
return `${raw.slice(0, 4)}***${raw.slice(-4)}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
maskSecret
|
||||
};
|
||||
554
apps/miniprogram/utils/syncService.js
Normal file
554
apps/miniprogram/utils/syncService.js
Normal file
@@ -0,0 +1,554 @@
|
||||
/* global wx, console, setTimeout, clearTimeout, require, module, getApp */
|
||||
|
||||
const { ensureSyncAuthToken, resolveSyncBaseUrl, requestJson } = require("./syncAuth");
|
||||
const { emitSyncConfigApplied } = require("./syncConfigBus");
|
||||
const {
|
||||
getSettings,
|
||||
saveSettings,
|
||||
listServers,
|
||||
saveServers,
|
||||
listRecords,
|
||||
saveRecords
|
||||
} = require("./storage");
|
||||
|
||||
const DELETED_SERVERS_KEY = "remoteconn.sync.deleted.servers.v1";
|
||||
const DELETED_RECORDS_KEY = "remoteconn.sync.deleted.records.v1";
|
||||
const TOMBSTONE_LIMIT = 500;
|
||||
|
||||
let bootstrapPromise = null;
|
||||
let settingsTimer = null;
|
||||
let serversTimer = null;
|
||||
let recordsTimer = null;
|
||||
|
||||
function logSync(level, message, extra) {
|
||||
if (level === "info") return;
|
||||
const payload = extra && typeof extra === "object" ? extra : {};
|
||||
const writer = level === "warn" ? console.warn : level === "error" ? console.error : console.info;
|
||||
writer("[sync]", message, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 云端同步总开关:
|
||||
* 1. 默认开启,保持既有“本地 + 云端”双写行为;
|
||||
* 2. 用户关闭后,仅暂停配置类数据上云,不影响本地保存与终端会话;
|
||||
* 3. 读取 settings 时统一走 storage 归一化,避免旧版本缺字段导致误判。
|
||||
*/
|
||||
function isSyncConfigEnabled() {
|
||||
const settings = getSettings();
|
||||
return settings.syncConfigEnabled !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 定向清理排队中的同步定时器。
|
||||
* 说明:
|
||||
* 1. 用户关闭同步后,不应继续执行已经排队但尚未触发的上传;
|
||||
* 2. 这里不做网络取消,仅清理本地 200ms 防抖队列。
|
||||
*/
|
||||
function clearScheduledTimer(timerName) {
|
||||
if (timerName === "settingsTimer" && settingsTimer) {
|
||||
clearTimeout(settingsTimer);
|
||||
settingsTimer = null;
|
||||
}
|
||||
if (timerName === "serversTimer" && serversTimer) {
|
||||
clearTimeout(serversTimer);
|
||||
serversTimer = null;
|
||||
}
|
||||
if (timerName === "recordsTimer" && recordsTimer) {
|
||||
clearTimeout(recordsTimer);
|
||||
recordsTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次性清理全部排队任务。
|
||||
* 说明:
|
||||
* 1. 用于“关闭同步”或“重新打开同步前先清空旧队列”;
|
||||
* 2. 只处理本地防抖队列,不会中断已经发出的网络请求。
|
||||
*/
|
||||
function clearAllScheduledTimers() {
|
||||
clearScheduledTimer("settingsTimer");
|
||||
clearScheduledTimer("serversTimer");
|
||||
clearScheduledTimer("recordsTimer");
|
||||
}
|
||||
|
||||
function readArrayStorage(key) {
|
||||
try {
|
||||
const raw = wx.getStorageSync(key);
|
||||
return Array.isArray(raw) ? raw : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeArrayStorage(key, value) {
|
||||
try {
|
||||
wx.setStorageSync(key, Array.isArray(value) ? value : []);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function upsertTombstone(key, id, deletedAt) {
|
||||
const rows = readArrayStorage(key).filter((item) => item && item.id !== id);
|
||||
rows.unshift({ id, deletedAt: String(deletedAt || new Date().toISOString()) });
|
||||
writeArrayStorage(key, rows.slice(0, TOMBSTONE_LIMIT));
|
||||
}
|
||||
|
||||
function clearTombstones(key, ids) {
|
||||
const removed = new Set(Array.isArray(ids) ? ids : []);
|
||||
if (removed.size === 0) return;
|
||||
const rows = readArrayStorage(key).filter((item) => item && !removed.has(item.id));
|
||||
writeArrayStorage(key, rows);
|
||||
}
|
||||
|
||||
function getServerTombstones() {
|
||||
return readArrayStorage(DELETED_SERVERS_KEY);
|
||||
}
|
||||
|
||||
function getRecordTombstones() {
|
||||
return readArrayStorage(DELETED_RECORDS_KEY);
|
||||
}
|
||||
|
||||
function buildSettingsPayload() {
|
||||
const settings = getSettings();
|
||||
const updatedAt = String(settings.updatedAt || new Date().toISOString());
|
||||
return {
|
||||
updatedAt,
|
||||
data: settings
|
||||
};
|
||||
}
|
||||
|
||||
function buildServersPayload() {
|
||||
const rows = listServers().map((item) => ({ ...item, deletedAt: null }));
|
||||
const tombstones = getServerTombstones().map((item) => ({
|
||||
id: item.id,
|
||||
name: "",
|
||||
tags: [],
|
||||
host: "",
|
||||
port: 22,
|
||||
username: "",
|
||||
authType: "password",
|
||||
password: "",
|
||||
privateKey: "",
|
||||
passphrase: "",
|
||||
certificate: "",
|
||||
projectPath: "",
|
||||
timeoutSeconds: 15,
|
||||
heartbeatSeconds: 10,
|
||||
transportMode: "gateway",
|
||||
jumpHost: {
|
||||
enabled: false,
|
||||
host: "",
|
||||
port: 22,
|
||||
username: "",
|
||||
authType: "password"
|
||||
},
|
||||
jumpPassword: "",
|
||||
jumpPrivateKey: "",
|
||||
jumpPassphrase: "",
|
||||
jumpCertificate: "",
|
||||
sortOrder: 0,
|
||||
lastConnectedAt: "",
|
||||
updatedAt: item.deletedAt,
|
||||
deletedAt: item.deletedAt
|
||||
}));
|
||||
return rows.concat(tombstones);
|
||||
}
|
||||
|
||||
function buildRecordsPayload() {
|
||||
const rows = listRecords().map((item) => ({ ...item, deletedAt: null }));
|
||||
const tombstones = getRecordTombstones().map((item) => ({
|
||||
id: item.id,
|
||||
content: "",
|
||||
serverId: "",
|
||||
category: "未分类",
|
||||
contextLabel: "",
|
||||
processed: false,
|
||||
discarded: false,
|
||||
createdAt: item.deletedAt,
|
||||
updatedAt: item.deletedAt,
|
||||
deletedAt: item.deletedAt
|
||||
}));
|
||||
return rows.concat(tombstones);
|
||||
}
|
||||
|
||||
function indexById(rows) {
|
||||
const map = {};
|
||||
(Array.isArray(rows) ? rows : []).forEach((item) => {
|
||||
if (!item || !item.id) return;
|
||||
map[item.id] = item;
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
function pickLatest(current, candidate) {
|
||||
// bootstrap 合并时,本地存在而远端缺项是正常情况;这里必须先兜底 null。
|
||||
if (!current) return candidate;
|
||||
if (!candidate) return current;
|
||||
const currentUpdated = +new Date(current.updatedAt || current.deletedAt || 0);
|
||||
const candidateUpdated = +new Date(candidate.updatedAt || candidate.deletedAt || 0);
|
||||
return candidateUpdated >= currentUpdated ? candidate : current;
|
||||
}
|
||||
|
||||
/**
|
||||
* 闪念终态(已处理 / 已废弃)当前没有“恢复普通态”的 UI:
|
||||
* 1. 只要一侧已经进入终态,就不应被另一侧的普通态刷回;
|
||||
* 2. 终态之间的切换仍按 updatedAt 取较新值;
|
||||
* 3. 删除 tombstone 继续走通用时间比较,避免破坏删除同步。
|
||||
*/
|
||||
function pickLatestRecord(current, candidate) {
|
||||
if (!current) return candidate;
|
||||
if (!candidate) return current;
|
||||
if (current.deletedAt || candidate.deletedAt) {
|
||||
return pickLatest(current, candidate);
|
||||
}
|
||||
|
||||
const currentTerminal = current.discarded === true || current.processed === true;
|
||||
const candidateTerminal = candidate.discarded === true || candidate.processed === true;
|
||||
if (currentTerminal && !candidateTerminal) return current;
|
||||
if (candidateTerminal && !currentTerminal) return candidate;
|
||||
|
||||
return pickLatest(current, candidate);
|
||||
}
|
||||
|
||||
function mergeServers(remoteRows) {
|
||||
const localRows = listServers();
|
||||
const localMap = indexById(listServers().map((item) => ({ ...item, deletedAt: null })));
|
||||
const tombstones = getServerTombstones();
|
||||
const remoteMap = indexById(remoteRows);
|
||||
tombstones.forEach((item) => {
|
||||
remoteMap[item.id] = pickLatest(remoteMap[item.id], {
|
||||
id: item.id,
|
||||
deletedAt: item.deletedAt,
|
||||
updatedAt: item.deletedAt
|
||||
});
|
||||
});
|
||||
const allIds = new Set([...Object.keys(localMap), ...Object.keys(remoteMap)]);
|
||||
const merged = [];
|
||||
const clearedTombstones = [];
|
||||
allIds.forEach((id) => {
|
||||
const localItem = localMap[id] || null;
|
||||
const remoteItem = remoteMap[id] || null;
|
||||
const winner = pickLatest(localItem, remoteItem);
|
||||
if (!winner) return;
|
||||
if (winner.deletedAt) {
|
||||
clearedTombstones.push(id);
|
||||
return;
|
||||
}
|
||||
merged.push({ ...winner, deletedAt: null });
|
||||
clearedTombstones.push(id);
|
||||
});
|
||||
saveServers(merged, { silentSync: true });
|
||||
clearTombstones(DELETED_SERVERS_KEY, clearedTombstones);
|
||||
logSync("info", "服务器合并完成", {
|
||||
localCount: localRows.length,
|
||||
remoteCount: Array.isArray(remoteRows) ? remoteRows.length : 0,
|
||||
mergedCount: merged.length,
|
||||
tombstoneCount: tombstones.length
|
||||
});
|
||||
}
|
||||
|
||||
function mergeRecords(remoteRows) {
|
||||
const localRows = listRecords();
|
||||
const localMap = indexById(listRecords().map((item) => ({ ...item, deletedAt: null })));
|
||||
const tombstones = getRecordTombstones();
|
||||
const remoteMap = indexById(remoteRows);
|
||||
tombstones.forEach((item) => {
|
||||
remoteMap[item.id] = pickLatest(remoteMap[item.id], {
|
||||
id: item.id,
|
||||
deletedAt: item.deletedAt,
|
||||
updatedAt: item.deletedAt
|
||||
});
|
||||
});
|
||||
const allIds = new Set([...Object.keys(localMap), ...Object.keys(remoteMap)]);
|
||||
const merged = [];
|
||||
const clearedTombstones = [];
|
||||
allIds.forEach((id) => {
|
||||
const localItem = localMap[id] || null;
|
||||
const remoteItem = remoteMap[id] || null;
|
||||
const winner = pickLatestRecord(localItem, remoteItem);
|
||||
if (!winner) return;
|
||||
if (winner.deletedAt) {
|
||||
clearedTombstones.push(id);
|
||||
return;
|
||||
}
|
||||
merged.push({ ...winner, deletedAt: null });
|
||||
clearedTombstones.push(id);
|
||||
});
|
||||
saveRecords(merged, { silentSync: true });
|
||||
clearTombstones(DELETED_RECORDS_KEY, clearedTombstones);
|
||||
logSync("info", "闪念合并完成", {
|
||||
localCount: localRows.length,
|
||||
remoteCount: Array.isArray(remoteRows) ? remoteRows.length : 0,
|
||||
mergedCount: merged.length,
|
||||
tombstoneCount: tombstones.length
|
||||
});
|
||||
}
|
||||
|
||||
function mergeSettings(remotePayload) {
|
||||
if (!remotePayload || !remotePayload.updatedAt || !remotePayload.data) {
|
||||
logSync("info", "服务端未返回设置,跳过本地设置合并");
|
||||
return;
|
||||
}
|
||||
const local = getSettings() || {};
|
||||
const localUpdated = +new Date(local.updatedAt || 0);
|
||||
const remoteUpdated = +new Date(remotePayload.updatedAt || 0);
|
||||
if (Number.isFinite(localUpdated) && localUpdated > remoteUpdated) {
|
||||
logSync("info", "本地设置更新时间更新,保留本地设置", {
|
||||
localUpdatedAt: local.updatedAt || "",
|
||||
remoteUpdatedAt: remotePayload.updatedAt
|
||||
});
|
||||
return;
|
||||
}
|
||||
saveSettings(
|
||||
{ ...remotePayload.data, updatedAt: remotePayload.updatedAt },
|
||||
{ preserveUpdatedAt: true, silentSync: true }
|
||||
);
|
||||
logSync("info", "设置合并完成", {
|
||||
remoteUpdatedAt: remotePayload.updatedAt
|
||||
});
|
||||
}
|
||||
|
||||
async function authedRequest(path, options) {
|
||||
const token = await ensureSyncAuthToken();
|
||||
const baseUrl = resolveSyncBaseUrl();
|
||||
if (!baseUrl) {
|
||||
throw new Error("sync base url missing");
|
||||
}
|
||||
return requestJson(`${baseUrl}${path}`, {
|
||||
method: (options && options.method) || "GET",
|
||||
data: options && options.data,
|
||||
header: {
|
||||
"content-type": "application/json",
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function pushSettingsNow() {
|
||||
const payload = buildSettingsPayload();
|
||||
logSync("info", "开始上传设置", {
|
||||
updatedAt: payload.updatedAt
|
||||
});
|
||||
await authedRequest("/api/miniprogram/sync/settings", {
|
||||
method: "PUT",
|
||||
data: payload
|
||||
});
|
||||
logSync("info", "设置上传完成", {
|
||||
updatedAt: payload.updatedAt
|
||||
});
|
||||
}
|
||||
|
||||
async function pushServersNow() {
|
||||
const payload = buildServersPayload();
|
||||
logSync("info", "开始上传服务器列表", {
|
||||
count: payload.length,
|
||||
tombstoneCount: getServerTombstones().length
|
||||
});
|
||||
const response = await authedRequest("/api/miniprogram/sync/servers", {
|
||||
method: "PUT",
|
||||
data: { servers: payload }
|
||||
});
|
||||
if (response && Array.isArray(response.servers)) {
|
||||
mergeServers(response.servers);
|
||||
}
|
||||
logSync("info", "服务器列表上传完成", {
|
||||
uploadedCount: payload.length,
|
||||
remoteCount: response && Array.isArray(response.servers) ? response.servers.length : 0
|
||||
});
|
||||
}
|
||||
|
||||
async function pushRecordsNow() {
|
||||
const payload = buildRecordsPayload();
|
||||
logSync("info", "开始上传闪念列表", {
|
||||
count: payload.length,
|
||||
tombstoneCount: getRecordTombstones().length
|
||||
});
|
||||
const response = await authedRequest("/api/miniprogram/sync/records", {
|
||||
method: "PUT",
|
||||
data: { records: payload }
|
||||
});
|
||||
if (response && Array.isArray(response.records)) {
|
||||
mergeRecords(response.records);
|
||||
}
|
||||
logSync("info", "闪念列表上传完成", {
|
||||
uploadedCount: payload.length,
|
||||
remoteCount: response && Array.isArray(response.records) ? response.records.length : 0
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleTask(timerName, task) {
|
||||
/**
|
||||
* 关闭同步后,新的排队请求应立刻失效,并顺手清掉同名旧定时器,
|
||||
* 避免用户刚关掉开关,200ms 防抖里的旧任务仍继续上云。
|
||||
*/
|
||||
if (!isSyncConfigEnabled()) {
|
||||
clearScheduledTimer(timerName);
|
||||
logSync("info", "同步总开关关闭,跳过任务入队", { timerName });
|
||||
return;
|
||||
}
|
||||
clearScheduledTimer(timerName);
|
||||
logSync("info", "同步任务已入队", { timerName });
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
try {
|
||||
/**
|
||||
* 定时器真正触发时再次检查开关,确保“先排队、后关闭”的任务不会继续执行。
|
||||
*/
|
||||
if (!isSyncConfigEnabled()) {
|
||||
logSync("info", "同步总开关关闭,跳过任务执行", { timerName });
|
||||
return;
|
||||
}
|
||||
logSync("info", "同步任务开始执行", { timerName });
|
||||
await task();
|
||||
logSync("info", "同步任务执行完成", { timerName });
|
||||
} catch (error) {
|
||||
logSync("warn", "同步任务执行失败", {
|
||||
timerName,
|
||||
error: error instanceof Error ? error.message : String(error || "")
|
||||
});
|
||||
} finally {
|
||||
if (timerName === "settingsTimer") settingsTimer = null;
|
||||
if (timerName === "serversTimer") serversTimer = null;
|
||||
if (timerName === "recordsTimer") recordsTimer = null;
|
||||
}
|
||||
}, 200);
|
||||
|
||||
if (timerName === "settingsTimer") settingsTimer = timeout;
|
||||
if (timerName === "serversTimer") serversTimer = timeout;
|
||||
if (timerName === "recordsTimer") recordsTimer = timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取一次云端 bootstrap,并把结果合并回本地。
|
||||
* options.force=true 时忽略历史缓存,重新发起拉取。
|
||||
*/
|
||||
async function ensureSyncBootstrap(options) {
|
||||
const extra = options && typeof options === "object" ? options : {};
|
||||
/**
|
||||
* 启动 bootstrap 只同步配置类数据,用户明确关闭后应直接跳过。
|
||||
*/
|
||||
if (!isSyncConfigEnabled()) {
|
||||
logSync("info", "同步总开关关闭,跳过启动 bootstrap");
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
if (extra.force) {
|
||||
bootstrapPromise = null;
|
||||
}
|
||||
if (bootstrapPromise) return bootstrapPromise;
|
||||
const opsBaseUrl = resolveSyncBaseUrl();
|
||||
if (!opsBaseUrl) {
|
||||
logSync("warn", "未配置同步基地址,跳过启动 bootstrap");
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
bootstrapPromise = (async () => {
|
||||
try {
|
||||
logSync("info", "开始执行启动 bootstrap", { baseUrl: opsBaseUrl });
|
||||
const payload = await authedRequest("/api/miniprogram/sync/bootstrap", { method: "GET" });
|
||||
if (!payload || payload.ok !== true) return null;
|
||||
mergeSettings(payload.settings || null);
|
||||
mergeServers(Array.isArray(payload.servers) ? payload.servers : []);
|
||||
mergeRecords(Array.isArray(payload.records) ? payload.records : []);
|
||||
logSync("info", "启动 bootstrap 完成", {
|
||||
hasSettings: Boolean(payload.settings),
|
||||
serverCount: Array.isArray(payload.servers) ? payload.servers.length : 0,
|
||||
recordCount: Array.isArray(payload.records) ? payload.records.length : 0
|
||||
});
|
||||
/**
|
||||
* 启动 bootstrap 是“异步合并回本地”的:
|
||||
* 1. 首页/底栏可能已经先渲染了旧本地快照;
|
||||
* 2. 合并完成后必须主动广播一次,让当前可见页立刻重读 storage;
|
||||
* 3. 否则用户会看到“同步已完成,但必须重新进页面才显示”的假象。
|
||||
*/
|
||||
emitSyncConfigApplied({
|
||||
source: extra.force ? "bootstrap.force" : "bootstrap",
|
||||
hasSettings: Boolean(payload.settings),
|
||||
serverCount: Array.isArray(payload.servers) ? payload.servers.length : 0,
|
||||
recordCount: Array.isArray(payload.records) ? payload.records.length : 0,
|
||||
appliedAt: Date.now()
|
||||
});
|
||||
return payload;
|
||||
} catch (error) {
|
||||
logSync("warn", "启动 bootstrap 失败", {
|
||||
error: error instanceof Error ? error.message : String(error || "")
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
const app = typeof getApp === "function" ? getApp() : null;
|
||||
if (app && app.globalData) {
|
||||
app.globalData.syncBootstrappedAt = Date.now();
|
||||
}
|
||||
}
|
||||
})();
|
||||
return bootstrapPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新打开同步后的恢复流程:
|
||||
* 1. 先强制拉取一次最新云端配置,按 updatedAt/逐项 merge 规则合并;
|
||||
* 2. 再把当前合并后的本地结果重新排队上传,避免直接用“关闭期间的本地旧视图”覆盖云端。
|
||||
* 3. 若 bootstrap 失败,则退化为仅恢复本地补推,保证用户不会因为一次拉取失败而一直无法恢复同步。
|
||||
*/
|
||||
async function resumeSyncConfig() {
|
||||
if (!isSyncConfigEnabled()) {
|
||||
logSync("info", "同步总开关关闭,跳过恢复同步");
|
||||
clearAllScheduledTimers();
|
||||
return null;
|
||||
}
|
||||
clearAllScheduledTimers();
|
||||
let bootstrapPayload = null;
|
||||
try {
|
||||
bootstrapPayload = await ensureSyncBootstrap({ force: true });
|
||||
} catch (error) {
|
||||
logSync("warn", "恢复同步前 bootstrap 抛出异常,改为直接补推本地数据", {
|
||||
error: error instanceof Error ? error.message : String(error || "")
|
||||
});
|
||||
}
|
||||
scheduleSettingsSync();
|
||||
scheduleServersSync();
|
||||
scheduleRecordsSync();
|
||||
return bootstrapPayload;
|
||||
}
|
||||
|
||||
function scheduleSettingsSync() {
|
||||
scheduleTask("settingsTimer", pushSettingsNow);
|
||||
}
|
||||
|
||||
function scheduleServersSync() {
|
||||
scheduleTask("serversTimer", pushServersNow);
|
||||
}
|
||||
|
||||
function scheduleRecordsSync() {
|
||||
scheduleTask("recordsTimer", pushRecordsNow);
|
||||
}
|
||||
|
||||
function markServerDeleted(id, deletedAt) {
|
||||
if (!id) return;
|
||||
upsertTombstone(DELETED_SERVERS_KEY, id, deletedAt);
|
||||
logSync("info", "已记录服务器删除 tombstone", { id, deletedAt });
|
||||
scheduleServersSync();
|
||||
}
|
||||
|
||||
function markRecordDeleted(id, deletedAt) {
|
||||
if (!id) return;
|
||||
upsertTombstone(DELETED_RECORDS_KEY, id, deletedAt);
|
||||
logSync("info", "已记录闪念删除 tombstone", { id, deletedAt });
|
||||
scheduleRecordsSync();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ensureSyncBootstrap,
|
||||
resumeSyncConfig,
|
||||
scheduleSettingsSync,
|
||||
scheduleServersSync,
|
||||
scheduleRecordsSync,
|
||||
markServerDeleted,
|
||||
markRecordDeleted,
|
||||
__test__: {
|
||||
clearAllScheduledTimers,
|
||||
clearScheduledTimer,
|
||||
isSyncConfigEnabled,
|
||||
pickLatest,
|
||||
pickLatestRecord
|
||||
}
|
||||
};
|
||||
464
apps/miniprogram/utils/syncService.test.ts
Normal file
464
apps/miniprogram/utils/syncService.test.ts
Normal file
@@ -0,0 +1,464 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type StorageValue = string | number | boolean | null | undefined | Record<string, unknown> | unknown[];
|
||||
|
||||
function createWxRuntime(
|
||||
initial: Record<string, StorageValue>,
|
||||
options?: {
|
||||
onRequest?: (url: string, method: string) => { statusCode: number; data: Record<string, unknown> };
|
||||
}
|
||||
) {
|
||||
const store = new Map<string, StorageValue>(Object.entries(initial));
|
||||
const extra = options && typeof options === "object" ? options : {};
|
||||
return {
|
||||
store,
|
||||
getStorageSync: vi.fn((key: string) => store.get(key)),
|
||||
setStorageSync: vi.fn((key: string, value: StorageValue) => {
|
||||
store.set(key, value);
|
||||
}),
|
||||
getStorageInfoSync: vi.fn(() => ({ keys: Array.from(store.keys()) })),
|
||||
login: vi.fn((options?: { success?: (value: { code: string }) => void }) => {
|
||||
options?.success?.({ code: "mock-login-code" });
|
||||
}),
|
||||
request: vi.fn(
|
||||
(options?: {
|
||||
url?: string;
|
||||
method?: string;
|
||||
success?: (value: { statusCode: number; data: Record<string, unknown> }) => void;
|
||||
}) => {
|
||||
const url = String(options?.url || "");
|
||||
const method = String(options?.method || "GET").toUpperCase();
|
||||
const response = extra.onRequest ? extra.onRequest(url, method) : { statusCode: 200, data: { ok: true } };
|
||||
options?.success?.(response);
|
||||
}
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
function clearModuleCache() {
|
||||
const modulePaths = [
|
||||
"./syncService.js",
|
||||
"./syncConfigBus.js",
|
||||
"./storage.js",
|
||||
"./syncAuth.js",
|
||||
"./opsConfig.js"
|
||||
].map((path) => require.resolve(path));
|
||||
modulePaths.forEach((modulePath) => {
|
||||
delete require.cache[modulePath];
|
||||
});
|
||||
}
|
||||
|
||||
function loadSyncServiceModule() {
|
||||
clearModuleCache();
|
||||
return require("./syncService.js");
|
||||
}
|
||||
|
||||
describe("miniprogram syncService", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useFakeTimers();
|
||||
delete (globalThis as typeof globalThis & { __REMOTE_CONN_OPS__?: unknown }).__REMOTE_CONN_OPS__;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
delete (global as typeof globalThis & { wx?: unknown }).wx;
|
||||
delete (globalThis as typeof globalThis & { __REMOTE_CONN_OPS__?: unknown }).__REMOTE_CONN_OPS__;
|
||||
clearModuleCache();
|
||||
});
|
||||
|
||||
it("pickLatest 在远端缺项时保留本地记录,避免读取 null.updatedAt", () => {
|
||||
const wxRuntime = createWxRuntime({});
|
||||
(global as typeof globalThis & { wx: typeof wxRuntime }).wx = wxRuntime;
|
||||
|
||||
const syncService = loadSyncServiceModule();
|
||||
const localRow = {
|
||||
id: "srv-local-1",
|
||||
name: "local",
|
||||
updatedAt: "2026-03-09T10:00:00.000Z"
|
||||
};
|
||||
|
||||
expect(syncService.__test__.pickLatest(localRow, null)).toEqual(localRow);
|
||||
});
|
||||
|
||||
it("pickLatest 在本地缺项时保留远端记录", () => {
|
||||
const wxRuntime = createWxRuntime({});
|
||||
(global as typeof globalThis & { wx: typeof wxRuntime }).wx = wxRuntime;
|
||||
|
||||
const syncService = loadSyncServiceModule();
|
||||
const remoteRow = {
|
||||
id: "srv-remote-1",
|
||||
name: "remote",
|
||||
updatedAt: "2026-03-09T11:00:00.000Z"
|
||||
};
|
||||
|
||||
expect(syncService.__test__.pickLatest(null, remoteRow)).toEqual(remoteRow);
|
||||
});
|
||||
|
||||
it("pickLatestRecord 在终态与普通态冲突时保留终态记录", () => {
|
||||
const wxRuntime = createWxRuntime({});
|
||||
(global as typeof globalThis & { wx: typeof wxRuntime }).wx = wxRuntime;
|
||||
|
||||
const syncService = loadSyncServiceModule();
|
||||
const localRow = {
|
||||
id: "rec-local-1",
|
||||
content: "local discarded",
|
||||
serverId: "",
|
||||
category: "问题",
|
||||
contextLabel: "",
|
||||
processed: false,
|
||||
discarded: true,
|
||||
createdAt: "2026-03-09T00:00:00.000Z",
|
||||
updatedAt: "2026-03-09T10:00:00.000Z",
|
||||
deletedAt: null
|
||||
};
|
||||
const remoteRow = {
|
||||
id: "rec-local-1",
|
||||
content: "remote normal",
|
||||
serverId: "",
|
||||
category: "问题",
|
||||
contextLabel: "",
|
||||
processed: false,
|
||||
discarded: false,
|
||||
createdAt: "2026-03-09T00:00:00.000Z",
|
||||
updatedAt: "2026-03-09T11:00:00.000Z",
|
||||
deletedAt: null
|
||||
};
|
||||
|
||||
expect(syncService.__test__.pickLatestRecord(localRow, remoteRow)).toEqual(localRow);
|
||||
});
|
||||
|
||||
it("scheduleRecordsSync 不会用服务端普通态覆盖本地已废弃记录", async () => {
|
||||
const wxRuntime = createWxRuntime(
|
||||
{
|
||||
"remoteconn.settings.v2": {
|
||||
syncConfigEnabled: true,
|
||||
updatedAt: "2026-03-10T00:00:00.000Z"
|
||||
},
|
||||
"remoteconn.records.v2": []
|
||||
},
|
||||
{
|
||||
onRequest(url) {
|
||||
if (url.endsWith("/api/miniprogram/auth/login")) {
|
||||
return {
|
||||
statusCode: 200,
|
||||
data: {
|
||||
ok: true,
|
||||
token: "mock-token",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z"
|
||||
}
|
||||
};
|
||||
}
|
||||
if (url.endsWith("/api/miniprogram/sync/records")) {
|
||||
return {
|
||||
statusCode: 200,
|
||||
data: {
|
||||
ok: true,
|
||||
records: [
|
||||
{
|
||||
id: "rec-a",
|
||||
content: "alpha",
|
||||
serverId: "",
|
||||
category: "问题",
|
||||
contextLabel: "",
|
||||
processed: false,
|
||||
discarded: false,
|
||||
createdAt: "2026-03-09T00:00:00.000Z",
|
||||
updatedAt: "2026-03-11T12:00:00.000Z",
|
||||
deletedAt: null
|
||||
},
|
||||
{
|
||||
id: "rec-b",
|
||||
content: "beta",
|
||||
serverId: "",
|
||||
category: "问题",
|
||||
contextLabel: "",
|
||||
processed: true,
|
||||
discarded: false,
|
||||
createdAt: "2026-03-09T00:05:00.000Z",
|
||||
updatedAt: "2026-03-11T10:05:00.000Z",
|
||||
deletedAt: null
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
return { statusCode: 200, data: { ok: true } };
|
||||
}
|
||||
}
|
||||
);
|
||||
(global as typeof globalThis & { wx: typeof wxRuntime }).wx = wxRuntime;
|
||||
(globalThis as typeof globalThis & { __REMOTE_CONN_OPS__?: unknown }).__REMOTE_CONN_OPS__ = {
|
||||
gatewayUrl: "https://gateway.example.com",
|
||||
gatewayToken: "token"
|
||||
};
|
||||
|
||||
clearModuleCache();
|
||||
const storage = require("./storage.js");
|
||||
storage.saveRecords(
|
||||
[
|
||||
{
|
||||
id: "rec-a",
|
||||
content: "alpha",
|
||||
serverId: "",
|
||||
category: "问题",
|
||||
contextLabel: "",
|
||||
processed: false,
|
||||
discarded: true,
|
||||
createdAt: "2026-03-09T00:00:00.000Z",
|
||||
updatedAt: "2026-03-11T10:00:00.000Z"
|
||||
},
|
||||
{
|
||||
id: "rec-b",
|
||||
content: "beta",
|
||||
serverId: "",
|
||||
category: "问题",
|
||||
contextLabel: "",
|
||||
processed: true,
|
||||
discarded: false,
|
||||
createdAt: "2026-03-09T00:05:00.000Z",
|
||||
updatedAt: "2026-03-11T10:05:00.000Z"
|
||||
}
|
||||
],
|
||||
{ silentSync: true }
|
||||
);
|
||||
|
||||
const syncService = require("./syncService.js");
|
||||
syncService.scheduleRecordsSync();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const rows = storage.listRecords();
|
||||
const recordA = rows.find((item: { id: string }) => item.id === "rec-a");
|
||||
const recordB = rows.find((item: { id: string }) => item.id === "rec-b");
|
||||
|
||||
expect(recordA && recordA.discarded).toBe(true);
|
||||
expect(recordA && recordA.processed).toBe(false);
|
||||
expect(recordB && recordB.processed).toBe(true);
|
||||
});
|
||||
|
||||
it("关闭 syncConfigEnabled 后不会再启动同步任务", async () => {
|
||||
const wxRuntime = createWxRuntime({
|
||||
"remoteconn.settings.v2": {
|
||||
syncConfigEnabled: false,
|
||||
updatedAt: "2026-03-10T00:00:00.000Z"
|
||||
}
|
||||
});
|
||||
(global as typeof globalThis & { wx: typeof wxRuntime }).wx = wxRuntime;
|
||||
|
||||
const syncService = loadSyncServiceModule();
|
||||
syncService.scheduleSettingsSync();
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
expect(syncService.__test__.isSyncConfigEnabled()).toBe(false);
|
||||
expect(wxRuntime.login).not.toHaveBeenCalled();
|
||||
expect(wxRuntime.request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("关闭 syncConfigEnabled 后会跳过启动 bootstrap", async () => {
|
||||
const wxRuntime = createWxRuntime({
|
||||
"remoteconn.settings.v2": {
|
||||
syncConfigEnabled: false,
|
||||
updatedAt: "2026-03-10T00:00:00.000Z"
|
||||
}
|
||||
});
|
||||
(global as typeof globalThis & { wx: typeof wxRuntime }).wx = wxRuntime;
|
||||
(globalThis as typeof globalThis & { __REMOTE_CONN_OPS__?: unknown }).__REMOTE_CONN_OPS__ = {
|
||||
gatewayUrl: "https://gateway.example.com",
|
||||
gatewayToken: "token"
|
||||
};
|
||||
|
||||
const syncService = loadSyncServiceModule();
|
||||
await expect(syncService.ensureSyncBootstrap()).resolves.toBeNull();
|
||||
|
||||
expect(wxRuntime.login).not.toHaveBeenCalled();
|
||||
expect(wxRuntime.request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("重新打开 syncConfigEnabled 时会先 bootstrap 再补推本地配置", async () => {
|
||||
const requestUrls: string[] = [];
|
||||
const wxRuntime = createWxRuntime(
|
||||
{
|
||||
"remoteconn.settings.v2": {
|
||||
syncConfigEnabled: false,
|
||||
uiThemeMode: "dark",
|
||||
updatedAt: "2026-03-10T00:00:00.000Z"
|
||||
},
|
||||
"remoteconn.servers.v2": [],
|
||||
"remoteconn.records.v2": []
|
||||
},
|
||||
{
|
||||
onRequest(url, method) {
|
||||
requestUrls.push(`${method} ${url}`);
|
||||
if (url.endsWith("/api/miniprogram/auth/login")) {
|
||||
return {
|
||||
statusCode: 200,
|
||||
data: {
|
||||
ok: true,
|
||||
token: "mock-token",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z"
|
||||
}
|
||||
};
|
||||
}
|
||||
if (url.endsWith("/api/miniprogram/sync/bootstrap")) {
|
||||
return {
|
||||
statusCode: 200,
|
||||
data: {
|
||||
ok: true,
|
||||
settings: {
|
||||
updatedAt: "2026-03-09T00:00:00.000Z",
|
||||
data: {
|
||||
syncConfigEnabled: true,
|
||||
uiThemeMode: "light"
|
||||
}
|
||||
},
|
||||
servers: [],
|
||||
records: []
|
||||
}
|
||||
};
|
||||
}
|
||||
if (url.endsWith("/api/miniprogram/sync/settings")) {
|
||||
return { statusCode: 200, data: { ok: true } };
|
||||
}
|
||||
if (url.endsWith("/api/miniprogram/sync/servers")) {
|
||||
return { statusCode: 200, data: { ok: true, servers: [] } };
|
||||
}
|
||||
if (url.endsWith("/api/miniprogram/sync/records")) {
|
||||
return { statusCode: 200, data: { ok: true, records: [] } };
|
||||
}
|
||||
return { statusCode: 200, data: { ok: true } };
|
||||
}
|
||||
}
|
||||
);
|
||||
(global as typeof globalThis & { wx: typeof wxRuntime }).wx = wxRuntime;
|
||||
(globalThis as typeof globalThis & { __REMOTE_CONN_OPS__?: unknown }).__REMOTE_CONN_OPS__ = {
|
||||
gatewayUrl: "https://gateway.example.com",
|
||||
gatewayToken: "token"
|
||||
};
|
||||
|
||||
clearModuleCache();
|
||||
const storage = require("./storage.js");
|
||||
|
||||
storage.saveSettings({
|
||||
syncConfigEnabled: true,
|
||||
uiThemeMode: "dark"
|
||||
});
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(requestUrls).toEqual([
|
||||
"POST https://gateway.example.com/api/miniprogram/auth/login",
|
||||
"GET https://gateway.example.com/api/miniprogram/sync/bootstrap",
|
||||
"PUT https://gateway.example.com/api/miniprogram/sync/settings",
|
||||
"PUT https://gateway.example.com/api/miniprogram/sync/servers",
|
||||
"PUT https://gateway.example.com/api/miniprogram/sync/records"
|
||||
]);
|
||||
|
||||
expect(storage.getSettings().syncConfigEnabled).toBe(true);
|
||||
expect(storage.getSettings().uiThemeMode).toBe("dark");
|
||||
});
|
||||
|
||||
it("启动 bootstrap 合并完成后会广播刷新事件,供当前页面立刻重读本地配置", async () => {
|
||||
const wxRuntime = createWxRuntime(
|
||||
{
|
||||
"remoteconn.settings.v2": {
|
||||
syncConfigEnabled: true,
|
||||
uiThemeMode: "dark",
|
||||
updatedAt: "2026-03-10T00:00:00.000Z"
|
||||
},
|
||||
"remoteconn.servers.v2": [],
|
||||
"remoteconn.records.v2": []
|
||||
},
|
||||
{
|
||||
onRequest(url) {
|
||||
if (url.endsWith("/api/miniprogram/auth/login")) {
|
||||
return {
|
||||
statusCode: 200,
|
||||
data: {
|
||||
ok: true,
|
||||
token: "mock-token",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z"
|
||||
}
|
||||
};
|
||||
}
|
||||
if (url.endsWith("/api/miniprogram/sync/bootstrap")) {
|
||||
return {
|
||||
statusCode: 200,
|
||||
data: {
|
||||
ok: true,
|
||||
settings: {
|
||||
updatedAt: "2026-03-11T00:00:00.000Z",
|
||||
data: {
|
||||
syncConfigEnabled: true,
|
||||
uiThemeMode: "light"
|
||||
}
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
id: "srv-1",
|
||||
name: "remote-1",
|
||||
tags: [],
|
||||
host: "1.1.1.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
password: "",
|
||||
privateKey: "",
|
||||
passphrase: "",
|
||||
certificate: "",
|
||||
projectPath: "",
|
||||
timeoutSeconds: 15,
|
||||
heartbeatSeconds: 10,
|
||||
transportMode: "gateway",
|
||||
jumpHost: {
|
||||
enabled: false,
|
||||
host: "",
|
||||
port: 22,
|
||||
username: "",
|
||||
authType: "password"
|
||||
},
|
||||
jumpPassword: "",
|
||||
jumpPrivateKey: "",
|
||||
jumpPassphrase: "",
|
||||
jumpCertificate: "",
|
||||
sortOrder: 1,
|
||||
lastConnectedAt: "",
|
||||
updatedAt: "2026-03-11T00:00:00.000Z",
|
||||
deletedAt: null
|
||||
}
|
||||
],
|
||||
records: []
|
||||
}
|
||||
};
|
||||
}
|
||||
return { statusCode: 200, data: { ok: true } };
|
||||
}
|
||||
}
|
||||
);
|
||||
(global as typeof globalThis & { wx: typeof wxRuntime }).wx = wxRuntime;
|
||||
(globalThis as typeof globalThis & { __REMOTE_CONN_OPS__?: unknown }).__REMOTE_CONN_OPS__ = {
|
||||
gatewayUrl: "https://gateway.example.com",
|
||||
gatewayToken: "token"
|
||||
};
|
||||
|
||||
clearModuleCache();
|
||||
const syncConfigBus = require("./syncConfigBus.js");
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = syncConfigBus.subscribeSyncConfigApplied(listener);
|
||||
const syncService = require("./syncService.js");
|
||||
|
||||
await expect(syncService.ensureSyncBootstrap()).resolves.toMatchObject({
|
||||
ok: true
|
||||
});
|
||||
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "bootstrap",
|
||||
hasSettings: true,
|
||||
serverCount: 1,
|
||||
recordCount: 0
|
||||
})
|
||||
);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
68
apps/miniprogram/utils/systemInfoCompat.js
Normal file
68
apps/miniprogram/utils/systemInfoCompat.js
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 小程序系统信息兼容层:
|
||||
* 1. 优先使用微信推荐的新接口,避免 `getSystemInfoSync` 废弃告警;
|
||||
* 2. 老基础库不存在新接口时,回退到旧接口,保证兼容;
|
||||
* 3. 仅暴露当前仓库真正需要的最小字段,避免把整份系统信息继续向外扩散。
|
||||
*/
|
||||
|
||||
function callSyncApi(api, name) {
|
||||
if (!api || typeof api[name] !== "function") return {};
|
||||
try {
|
||||
const result = api[name]();
|
||||
return result && typeof result === "object" ? result : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWxApi(wxLike) {
|
||||
return wxLike || (typeof wx !== "undefined" ? wx : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取窗口尺寸。
|
||||
* 微信已建议从 `getWindowInfo` 获取窗口相关字段;旧环境则回退到 `getSystemInfoSync`。
|
||||
*/
|
||||
function getWindowMetrics(wxLike) {
|
||||
const api = resolveWxApi(wxLike);
|
||||
const modern = callSyncApi(api, "getWindowInfo");
|
||||
if (Object.keys(modern).length > 0) {
|
||||
return {
|
||||
windowWidth: Number(modern.windowWidth),
|
||||
windowHeight: Number(modern.windowHeight)
|
||||
};
|
||||
}
|
||||
const legacy = callSyncApi(api, "getSystemInfoSync");
|
||||
return {
|
||||
windowWidth: Number(legacy.windowWidth),
|
||||
windowHeight: Number(legacy.windowHeight)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 组合“运行环境 + 设备”指纹。
|
||||
* 这里只保留当前代码真正用到的 `platform/system/brand` 三个字段。
|
||||
*/
|
||||
function getRuntimeFingerprint(wxLike) {
|
||||
const api = resolveWxApi(wxLike);
|
||||
const appBase = callSyncApi(api, "getAppBaseInfo");
|
||||
const device = callSyncApi(api, "getDeviceInfo");
|
||||
if (Object.keys(appBase).length > 0 || Object.keys(device).length > 0) {
|
||||
return {
|
||||
platform: String(appBase.platform || device.platform || ""),
|
||||
system: String(device.system || appBase.system || ""),
|
||||
brand: String(device.brand || appBase.brand || "")
|
||||
};
|
||||
}
|
||||
const legacy = callSyncApi(api, "getSystemInfoSync");
|
||||
return {
|
||||
platform: String(legacy.platform || ""),
|
||||
system: String(legacy.system || ""),
|
||||
brand: String(legacy.brand || "")
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getWindowMetrics,
|
||||
getRuntimeFingerprint
|
||||
};
|
||||
90
apps/miniprogram/utils/systemInfoCompat.test.ts
Normal file
90
apps/miniprogram/utils/systemInfoCompat.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
function loadModule(modulePath: string) {
|
||||
const resolved = require.resolve(modulePath);
|
||||
delete require.cache[resolved];
|
||||
return require(modulePath);
|
||||
}
|
||||
|
||||
describe("systemInfoCompat", () => {
|
||||
afterEach(() => {
|
||||
delete (global as typeof globalThis & { wx?: unknown }).wx;
|
||||
});
|
||||
|
||||
it("优先使用新接口读取窗口尺寸", () => {
|
||||
(
|
||||
global as typeof globalThis & {
|
||||
wx?: {
|
||||
getWindowInfo: () => { windowWidth: number; windowHeight: number };
|
||||
getSystemInfoSync: () => { windowWidth: number; windowHeight: number };
|
||||
};
|
||||
}
|
||||
).wx = {
|
||||
getWindowInfo() {
|
||||
return { windowWidth: 430, windowHeight: 932 };
|
||||
},
|
||||
getSystemInfoSync() {
|
||||
return { windowWidth: 320, windowHeight: 568 };
|
||||
}
|
||||
};
|
||||
const wxMock = (global as typeof globalThis & { wx?: unknown }).wx;
|
||||
|
||||
const { getWindowMetrics } = loadModule("./systemInfoCompat.js");
|
||||
expect(getWindowMetrics(wxMock)).toEqual({
|
||||
windowWidth: 430,
|
||||
windowHeight: 932
|
||||
});
|
||||
});
|
||||
|
||||
it("新接口不存在时回退到 getSystemInfoSync", () => {
|
||||
(
|
||||
global as typeof globalThis & {
|
||||
wx?: {
|
||||
getSystemInfoSync: () => { windowWidth: number; windowHeight: number; platform: string };
|
||||
};
|
||||
}
|
||||
).wx = {
|
||||
getSystemInfoSync() {
|
||||
return { windowWidth: 375, windowHeight: 667, platform: "ios" };
|
||||
}
|
||||
};
|
||||
const wxMock = (global as typeof globalThis & { wx?: unknown }).wx;
|
||||
|
||||
const { getWindowMetrics, getRuntimeFingerprint } = loadModule("./systemInfoCompat.js");
|
||||
expect(getWindowMetrics(wxMock)).toEqual({
|
||||
windowWidth: 375,
|
||||
windowHeight: 667
|
||||
});
|
||||
expect(getRuntimeFingerprint(wxMock)).toEqual({
|
||||
platform: "ios",
|
||||
system: "",
|
||||
brand: ""
|
||||
});
|
||||
});
|
||||
|
||||
it("运行环境指纹优先组合 getAppBaseInfo 与 getDeviceInfo", () => {
|
||||
(
|
||||
global as typeof globalThis & {
|
||||
wx?: {
|
||||
getAppBaseInfo: () => { platform: string };
|
||||
getDeviceInfo: () => { brand: string; system: string };
|
||||
};
|
||||
}
|
||||
).wx = {
|
||||
getAppBaseInfo() {
|
||||
return { platform: "devtools" };
|
||||
},
|
||||
getDeviceInfo() {
|
||||
return { brand: "apple", system: "iOS 18.1" };
|
||||
}
|
||||
};
|
||||
const wxMock = (global as typeof globalThis & { wx?: unknown }).wx;
|
||||
|
||||
const { getRuntimeFingerprint } = loadModule("./systemInfoCompat.js");
|
||||
expect(getRuntimeFingerprint(wxMock)).toEqual({
|
||||
platform: "devtools",
|
||||
system: "iOS 18.1",
|
||||
brand: "apple"
|
||||
});
|
||||
});
|
||||
});
|
||||
185
apps/miniprogram/utils/terminalIcons.js
Normal file
185
apps/miniprogram/utils/terminalIcons.js
Normal file
@@ -0,0 +1,185 @@
|
||||
/* global require, module */
|
||||
|
||||
const { toSvgDataUri } = require("./svgDataUri");
|
||||
const { ICON_SVG_SOURCES } = require("./iconSvgSources");
|
||||
const { tintSvgMarkup } = require("./themedIcons");
|
||||
|
||||
const DEFAULT_SHELL_ICON_COLOR = "#9CA9BF";
|
||||
const DEFAULT_SHELL_ACTIVE_ICON_COLOR = "#5BD2FF";
|
||||
const DEFAULT_SHELL_CTRL_C_HIGHLIGHT_COLOR = "#5BD2FF";
|
||||
const CTRL_C_PRIMARY_SOURCE_COLOR = "#FFC16E";
|
||||
const CTRL_C_HIGHLIGHT_SOURCE_COLOR = "#5BD2FF";
|
||||
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
||||
const TERMINAL_ICON_NAMES = Object.freeze([
|
||||
"backspace",
|
||||
"cancel",
|
||||
"clear",
|
||||
"clear-input",
|
||||
"codex",
|
||||
"ctrlc",
|
||||
"down",
|
||||
"enter",
|
||||
"esc",
|
||||
"home",
|
||||
"keyboard",
|
||||
"left",
|
||||
"paste",
|
||||
"reading",
|
||||
"record",
|
||||
"right",
|
||||
"sent",
|
||||
"shift",
|
||||
"stopreading",
|
||||
"tab",
|
||||
"up",
|
||||
"voice"
|
||||
]);
|
||||
const terminalIconCache = Object.create(null);
|
||||
const terminalActiveIconCache = Object.create(null);
|
||||
|
||||
function normalizeShellColor(value, fallback) {
|
||||
const normalized = String(value || "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
return HEX_COLOR_PATTERN.test(normalized) ? normalized : fallback;
|
||||
}
|
||||
|
||||
function hexToRgb(value) {
|
||||
const normalized = normalizeShellColor(value, "#000000");
|
||||
return {
|
||||
r: parseInt(normalized.slice(1, 3), 16),
|
||||
g: parseInt(normalized.slice(3, 5), 16),
|
||||
b: parseInt(normalized.slice(5, 7), 16)
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRelativeLuminance(value) {
|
||||
const rgb = hexToRgb(value);
|
||||
const channelToLinear = (channel) => {
|
||||
const normalized = channel / 255;
|
||||
if (normalized <= 0.03928) {
|
||||
return normalized / 12.92;
|
||||
}
|
||||
return ((normalized + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
return channelToLinear(rgb.r) * 0.2126 + channelToLinear(rgb.g) * 0.7152 + channelToLinear(rgb.b) * 0.0722;
|
||||
}
|
||||
|
||||
function contrastRatio(left, right) {
|
||||
const leftLuminance = resolveRelativeLuminance(left);
|
||||
const rightLuminance = resolveRelativeLuminance(right);
|
||||
const lighter = Math.max(leftLuminance, rightLuminance);
|
||||
const darker = Math.min(leftLuminance, rightLuminance);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
function toCamelIconName(name) {
|
||||
return String(name || "").replace(/-([a-zA-Z0-9])/g, (_, segment) => segment.toUpperCase());
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function replaceSvgHexColor(svg, sourceColor, targetColor) {
|
||||
const source = normalizeShellColor(sourceColor, sourceColor);
|
||||
const target = normalizeShellColor(targetColor, targetColor);
|
||||
return String(svg || "").replace(
|
||||
new RegExp(`\\b(fill|stroke)="${escapeRegExp(source)}"`, "g"),
|
||||
(_match, attribute) => `${attribute}="${target}"`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `Ctrl+C` 图标本身是双段结构:
|
||||
* 1. `Ctrl` 保持终端工具色,继续跟随工具区主题;
|
||||
* 2. `C` 单独保留强调色,避免被统一着色后丢掉原始 SVG 的层次感。
|
||||
*/
|
||||
function tintTerminalToolSvgMarkup(name, svg, primaryColor, highlightColor) {
|
||||
if (name !== "ctrlc") {
|
||||
return tintSvgMarkup(svg, primaryColor);
|
||||
}
|
||||
const primaryTinted = replaceSvgHexColor(svg, CTRL_C_PRIMARY_SOURCE_COLOR, primaryColor);
|
||||
return replaceSvgHexColor(primaryTinted, CTRL_C_HIGHLIGHT_SOURCE_COLOR, highlightColor);
|
||||
}
|
||||
|
||||
/**
|
||||
* 终端工具按钮有两组颜色:
|
||||
* 1. 常态沿用 shell accent,和终端工具区其余图标保持一致;
|
||||
* 2. 激活态切到 shell text,让 reading 图标在无外层底板时仍然足够醒目。
|
||||
*/
|
||||
function buildTerminalToolIconVariantMap(color, highlightColor, cache) {
|
||||
const cacheKey = `${normalizeShellColor(color, DEFAULT_SHELL_ICON_COLOR)}:${normalizeShellColor(
|
||||
highlightColor,
|
||||
DEFAULT_SHELL_CTRL_C_HIGHLIGHT_COLOR
|
||||
)}`;
|
||||
if (cache[cacheKey]) {
|
||||
return cache[cacheKey];
|
||||
}
|
||||
|
||||
const normalizedColor = normalizeShellColor(color, DEFAULT_SHELL_ICON_COLOR);
|
||||
const normalizedHighlightColor = normalizeShellColor(
|
||||
highlightColor,
|
||||
DEFAULT_SHELL_CTRL_C_HIGHLIGHT_COLOR
|
||||
);
|
||||
const iconMap = {};
|
||||
TERMINAL_ICON_NAMES.forEach((name) => {
|
||||
const svg = ICON_SVG_SOURCES[name];
|
||||
if (!svg) return;
|
||||
const dataUri = toSvgDataUri(
|
||||
tintTerminalToolSvgMarkup(name, svg, normalizedColor, normalizedHighlightColor)
|
||||
);
|
||||
iconMap[name] = dataUri;
|
||||
const camelName = toCamelIconName(name);
|
||||
if (camelName !== name) {
|
||||
iconMap[camelName] = dataUri;
|
||||
}
|
||||
});
|
||||
cache[cacheKey] = iconMap;
|
||||
return iconMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* reading 激活态所在位置是终端页工具栏,而不是 shell 输出区。
|
||||
* 因此优先使用更醒目的 UI accent;若当前 accent 与页面背景太接近,
|
||||
* 再退回 shell 文本色,避免浅色模式下“已开启但看不清”。
|
||||
*/
|
||||
function resolveTerminalToolActiveColor(settings) {
|
||||
const source = settings && typeof settings === "object" ? settings : {};
|
||||
const toolbarBg = normalizeShellColor(source.uiBgColor, "#192B4D");
|
||||
const accentColor = normalizeShellColor(source.uiAccentColor, DEFAULT_SHELL_ACTIVE_ICON_COLOR);
|
||||
const shellTextColor = normalizeShellColor(source.shellTextColor, "#E6F0FF");
|
||||
const accentContrast = contrastRatio(toolbarBg, accentColor);
|
||||
const shellTextContrast = contrastRatio(toolbarBg, shellTextColor);
|
||||
if (accentContrast >= 3 || accentContrast >= shellTextContrast * 0.92) {
|
||||
return accentColor;
|
||||
}
|
||||
return shellTextColor;
|
||||
}
|
||||
|
||||
function resolveTerminalToolCtrlCHighlightColor(settings) {
|
||||
const source = settings && typeof settings === "object" ? settings : {};
|
||||
return normalizeShellColor(source.uiAccentColor, DEFAULT_SHELL_CTRL_C_HIGHLIGHT_COLOR);
|
||||
}
|
||||
|
||||
function buildTerminalToolIconMap(settings) {
|
||||
const source = settings && typeof settings === "object" ? settings : {};
|
||||
return buildTerminalToolIconVariantMap(
|
||||
normalizeShellColor(source.shellAccentColor, DEFAULT_SHELL_ICON_COLOR),
|
||||
resolveTerminalToolCtrlCHighlightColor(source),
|
||||
terminalIconCache
|
||||
);
|
||||
}
|
||||
|
||||
function buildTerminalToolActiveIconMap(settings) {
|
||||
return buildTerminalToolIconVariantMap(
|
||||
resolveTerminalToolActiveColor(settings),
|
||||
resolveTerminalToolCtrlCHighlightColor(settings),
|
||||
terminalActiveIconCache
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildTerminalToolActiveIconMap,
|
||||
buildTerminalToolIconMap
|
||||
};
|
||||
43
apps/miniprogram/utils/terminalIntent.js
Normal file
43
apps/miniprogram/utils/terminalIntent.js
Normal file
@@ -0,0 +1,43 @@
|
||||
/* global getApp */
|
||||
|
||||
const TERMINAL_INTENT_KEY = "pendingTerminalIntent";
|
||||
|
||||
/**
|
||||
* 记录一次“进入终端后要做什么”的瞬时意图。
|
||||
* 当前仅用于服务器列表页点击 AI 图标后,通知终端页自动启动默认 AI。
|
||||
*/
|
||||
function setPendingTerminalIntent(intent) {
|
||||
const app = getApp && getApp();
|
||||
if (!app || !app.globalData) {
|
||||
return;
|
||||
}
|
||||
app.globalData[TERMINAL_INTENT_KEY] = intent || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取当前挂起的终端意图,但不清除。
|
||||
*/
|
||||
function getPendingTerminalIntent() {
|
||||
const app = getApp && getApp();
|
||||
if (!app || !app.globalData) {
|
||||
return null;
|
||||
}
|
||||
return app.globalData[TERMINAL_INTENT_KEY] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理已消费的终端意图,避免后续页面误触发。
|
||||
*/
|
||||
function clearPendingTerminalIntent() {
|
||||
const app = getApp && getApp();
|
||||
if (!app || !app.globalData) {
|
||||
return;
|
||||
}
|
||||
app.globalData[TERMINAL_INTENT_KEY] = null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
setPendingTerminalIntent,
|
||||
getPendingTerminalIntent,
|
||||
clearPendingTerminalIntent
|
||||
};
|
||||
101
apps/miniprogram/utils/terminalNavigation.js
Normal file
101
apps/miniprogram/utils/terminalNavigation.js
Normal file
@@ -0,0 +1,101 @@
|
||||
/* global wx, getCurrentPages, module, require */
|
||||
|
||||
const { setPendingTerminalIntent } = require("./terminalIntent");
|
||||
const { normalizeTerminalSessionSnapshot } = require("./terminalSessionState");
|
||||
|
||||
const ACTIVE_TERMINAL_SESSION_STATUS = new Set(["connecting", "auth_pending", "connected", "resumable"]);
|
||||
|
||||
/**
|
||||
* 生成终端页路由:
|
||||
* 1. 终端页必须携带 serverId,否则 onLoad 无法定位服务器;
|
||||
* 2. AI 快捷启动仍沿用历史 query `openCodex=1`,避免再维护第二套入口参数。
|
||||
*/
|
||||
function resolveTerminalPageUrl(serverId, options) {
|
||||
const normalizedServerId = String(serverId || "").trim();
|
||||
if (!normalizedServerId) return "";
|
||||
const nextOptions = options && typeof options === "object" ? options : {};
|
||||
const query = nextOptions.openCodex ? "&openCodex=1" : "";
|
||||
return `/pages/terminal/index?serverId=${normalizedServerId}${query}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 若导航栈上一页已经是同一台服务器的终端页,则优先复用它:
|
||||
* 1. 避免重复压栈多个终端实例;
|
||||
* 2. 保留该终端页内部已有的连接态和缓冲区。
|
||||
*/
|
||||
function shouldReusePreviousTerminalPage(pages, serverId) {
|
||||
const normalizedServerId = String(serverId || "").trim();
|
||||
if (!normalizedServerId || !Array.isArray(pages) || pages.length < 2) {
|
||||
return false;
|
||||
}
|
||||
const previous = pages[pages.length - 2];
|
||||
return !!(
|
||||
previous &&
|
||||
previous.route === "pages/terminal/index" &&
|
||||
previous.data &&
|
||||
previous.data.serverId === normalizedServerId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从终端会话快照中提取“当前可打开的终端服务器”:
|
||||
* 1. 仅接受连接中/已连接/可恢复的会话;
|
||||
* 2. 若服务器列表里已不存在该服务器,则视为不可打开。
|
||||
*/
|
||||
function resolveActiveTerminalServerId(snapshot, servers, now = Date.now()) {
|
||||
const normalizedSnapshot = normalizeTerminalSessionSnapshot(snapshot, now);
|
||||
if (!normalizedSnapshot) return "";
|
||||
if (!ACTIVE_TERMINAL_SESSION_STATUS.has(normalizedSnapshot.status)) {
|
||||
return "";
|
||||
}
|
||||
const rows = Array.isArray(servers) ? servers : [];
|
||||
return rows.some((item) => String(item && item.id ? item.id : "").trim() === normalizedSnapshot.serverId)
|
||||
? normalizedSnapshot.serverId
|
||||
: "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前是否存在“可从 shell 按钮直接打开”的活动终端:
|
||||
* 1. 与 shell 按钮点击逻辑共用同一判定,避免“能点开但不高亮”或反过来的分叉;
|
||||
* 2. 只要会话仍处于连接中 / 已连接 / 可恢复,且服务器仍存在,即视为活动连接。
|
||||
*/
|
||||
function hasActiveTerminalSession(snapshot, servers, now = Date.now()) {
|
||||
return Boolean(resolveActiveTerminalServerId(snapshot, servers, now));
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开终端页:
|
||||
* 1. 连接页与底部 shell 按钮共用同一套终端导航语义;
|
||||
* 2. 若命中上一页复用,则直接返回现有终端页;
|
||||
* 3. 仅在需要时写入一次瞬时终端意图。
|
||||
*/
|
||||
function openTerminalPage(serverId, reuseExisting, options) {
|
||||
const normalizedServerId = String(serverId || "").trim();
|
||||
if (!normalizedServerId) return false;
|
||||
const nextOptions = options && typeof options === "object" ? options : {};
|
||||
const pages = typeof getCurrentPages === "function" ? getCurrentPages() : [];
|
||||
if (reuseExisting && shouldReusePreviousTerminalPage(pages, normalizedServerId)) {
|
||||
if (nextOptions.openCodex) {
|
||||
setPendingTerminalIntent({
|
||||
// `open_ai` 为历史命名,当前语义已是“进入终端后直接启动默认 AI”。
|
||||
action: "open_ai",
|
||||
serverId: normalizedServerId,
|
||||
createdAt: Date.now()
|
||||
});
|
||||
}
|
||||
wx.navigateBack({ delta: 1 });
|
||||
return true;
|
||||
}
|
||||
const url = resolveTerminalPageUrl(normalizedServerId, nextOptions);
|
||||
if (!url) return false;
|
||||
wx.navigateTo({ url });
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveTerminalPageUrl,
|
||||
shouldReusePreviousTerminalPage,
|
||||
resolveActiveTerminalServerId,
|
||||
hasActiveTerminalSession,
|
||||
openTerminalPage
|
||||
};
|
||||
103
apps/miniprogram/utils/terminalNavigation.test.ts
Normal file
103
apps/miniprogram/utils/terminalNavigation.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
function loadTerminalNavigationModule() {
|
||||
const modulePath = require.resolve("./terminalNavigation.js");
|
||||
const intentModulePath = require.resolve("./terminalIntent.js");
|
||||
delete require.cache[modulePath];
|
||||
delete require.cache[intentModulePath];
|
||||
return require("./terminalNavigation.js");
|
||||
}
|
||||
|
||||
describe("terminalNavigation", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete (global as typeof globalThis & { wx?: unknown }).wx;
|
||||
delete (global as typeof globalThis & { getCurrentPages?: unknown }).getCurrentPages;
|
||||
delete (global as typeof globalThis & { getApp?: unknown }).getApp;
|
||||
});
|
||||
|
||||
it("仅对仍有效且服务器仍存在的终端会话返回可打开 serverId", () => {
|
||||
const { hasActiveTerminalSession, resolveActiveTerminalServerId } = loadTerminalNavigationModule();
|
||||
const baseSnapshot = {
|
||||
version: 1,
|
||||
serverId: "srv-1",
|
||||
sessionId: "mini-1",
|
||||
sessionKey: "key-1",
|
||||
status: "connected",
|
||||
resumeGraceMs: 15 * 60 * 1000,
|
||||
resumeExpiresAt: 0,
|
||||
savedAt: 1_700_000_000_000
|
||||
};
|
||||
|
||||
expect(resolveActiveTerminalServerId(baseSnapshot, [{ id: "srv-1" }], 1_700_000_001_000)).toBe("srv-1");
|
||||
expect(hasActiveTerminalSession(baseSnapshot, [{ id: "srv-1" }], 1_700_000_001_000)).toBe(true);
|
||||
expect(
|
||||
resolveActiveTerminalServerId({ ...baseSnapshot, status: "disconnected" }, [{ id: "srv-1" }], 1_700_000_001_000)
|
||||
).toBe("");
|
||||
expect(hasActiveTerminalSession({ ...baseSnapshot, status: "disconnected" }, [{ id: "srv-1" }], 1_700_000_001_000)).toBe(
|
||||
false
|
||||
);
|
||||
expect(resolveActiveTerminalServerId(baseSnapshot, [{ id: "srv-2" }], 1_700_000_001_000)).toBe("");
|
||||
expect(hasActiveTerminalSession(baseSnapshot, [{ id: "srv-2" }], 1_700_000_001_000)).toBe(false);
|
||||
});
|
||||
|
||||
it("复用上一页终端时直接 navigateBack,并保留 AI 直启意图", () => {
|
||||
const navigateBack = vi.fn();
|
||||
const navigateTo = vi.fn();
|
||||
(
|
||||
global as typeof globalThis & {
|
||||
wx?: { navigateBack: typeof navigateBack; navigateTo: typeof navigateTo };
|
||||
getCurrentPages?: () => Array<Record<string, unknown>>;
|
||||
getApp?: () => { globalData: Record<string, unknown> };
|
||||
}
|
||||
).wx = {
|
||||
navigateBack,
|
||||
navigateTo
|
||||
};
|
||||
(global as typeof globalThis & { getCurrentPages?: () => Array<Record<string, unknown>> }).getCurrentPages = () => [
|
||||
{ route: "pages/connect/index", data: {} },
|
||||
{ route: "pages/terminal/index", data: { serverId: "srv-1" } },
|
||||
{ route: "pages/connect/index", data: {} }
|
||||
];
|
||||
const globalData: Record<string, unknown> = {};
|
||||
(global as typeof globalThis & { getApp?: () => { globalData: Record<string, unknown> } }).getApp = () => ({
|
||||
globalData
|
||||
});
|
||||
|
||||
const { openTerminalPage } = loadTerminalNavigationModule();
|
||||
const opened = openTerminalPage("srv-1", true, { openCodex: true });
|
||||
|
||||
expect(opened).toBe(true);
|
||||
expect(navigateBack).toHaveBeenCalledWith({ delta: 1 });
|
||||
expect(navigateTo).not.toHaveBeenCalled();
|
||||
expect(globalData.pendingTerminalIntent).toMatchObject({
|
||||
action: "open_ai",
|
||||
serverId: "srv-1"
|
||||
});
|
||||
});
|
||||
|
||||
it("未命中复用时按 serverId 打开终端页", () => {
|
||||
const navigateBack = vi.fn();
|
||||
const navigateTo = vi.fn();
|
||||
(
|
||||
global as typeof globalThis & {
|
||||
wx?: { navigateBack: typeof navigateBack; navigateTo: typeof navigateTo };
|
||||
getCurrentPages?: () => Array<Record<string, unknown>>;
|
||||
}
|
||||
).wx = {
|
||||
navigateBack,
|
||||
navigateTo
|
||||
};
|
||||
(global as typeof globalThis & { getCurrentPages?: () => Array<Record<string, unknown>> }).getCurrentPages = () => [
|
||||
{ route: "pages/settings/index", data: {} }
|
||||
];
|
||||
|
||||
const { openTerminalPage, resolveTerminalPageUrl } = loadTerminalNavigationModule();
|
||||
const opened = openTerminalPage("srv-9", true);
|
||||
|
||||
expect(opened).toBe(true);
|
||||
expect(resolveTerminalPageUrl("srv-9", { openCodex: true })).toBe("/pages/terminal/index?serverId=srv-9&openCodex=1");
|
||||
expect(navigateTo).toHaveBeenCalledWith({ url: "/pages/terminal/index?serverId=srv-9" });
|
||||
expect(navigateBack).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
201
apps/miniprogram/utils/terminalSession.js
Normal file
201
apps/miniprogram/utils/terminalSession.js
Normal file
@@ -0,0 +1,201 @@
|
||||
/* global wx, console, module, require */
|
||||
|
||||
const { buildTerminalSessionSnapshot, normalizeTerminalSessionSnapshot } = require("./terminalSessionState");
|
||||
|
||||
const TERMINAL_SESSION_STORAGE_KEY = "remoteconn.terminal.session.v1";
|
||||
const TERMINAL_BUFFER_STORAGE_KEY = "remoteconn.terminal.buffer.v1";
|
||||
|
||||
function normalizePositiveInt(value, fallback, min) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
const normalized = Math.round(parsed);
|
||||
if (normalized < min) return fallback;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeTerminalSnapshotStyle(input) {
|
||||
const source = input && typeof input === "object" ? input : null;
|
||||
if (!source) return null;
|
||||
const fg = String(source.fg || "").trim();
|
||||
const bg = String(source.bg || "").trim();
|
||||
const bold = source.bold === true;
|
||||
const underline = source.underline === true;
|
||||
if (!fg && !bg && !bold && !underline) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
fg,
|
||||
bg,
|
||||
bold,
|
||||
underline
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTerminalSnapshotStyleTable(input) {
|
||||
const rows = Array.isArray(input) ? input : [];
|
||||
return rows.map((item) => normalizeTerminalSnapshotStyle(item)).filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeTerminalSnapshotRun(input, styleCount) {
|
||||
const source = input && typeof input === "object" ? input : null;
|
||||
if (!source) return null;
|
||||
const text =
|
||||
typeof source.t === "string" ? source.t : typeof source.text === "string" ? source.text : "";
|
||||
const rawColumns = Number(source.c !== undefined ? source.c : source.columns);
|
||||
const columns = Number.isFinite(rawColumns) ? Math.max(0, Math.round(rawColumns)) : 0;
|
||||
if (!text && columns <= 0) {
|
||||
return null;
|
||||
}
|
||||
const next = {};
|
||||
if (text) next.t = text;
|
||||
if (columns > 0) next.c = columns;
|
||||
if (source.f === 1 || source.fixed === true) {
|
||||
next.f = 1;
|
||||
}
|
||||
const rawStyleIndex = Number(source.s !== undefined ? source.s : source.styleIndex);
|
||||
if (Number.isInteger(rawStyleIndex) && rawStyleIndex >= 0 && rawStyleIndex < styleCount) {
|
||||
next.s = rawStyleIndex;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function normalizeTerminalSnapshotStyledLines(input, styleTable) {
|
||||
const rows = Array.isArray(input) ? input : [];
|
||||
const styleCount = Array.isArray(styleTable) ? styleTable.length : 0;
|
||||
return rows.map((line) => {
|
||||
const runs = Array.isArray(line) ? line : [];
|
||||
return runs.map((run) => normalizeTerminalSnapshotRun(run, styleCount)).filter(Boolean);
|
||||
});
|
||||
}
|
||||
|
||||
function readTerminalSessionRaw() {
|
||||
try {
|
||||
const value = wx.getStorageSync(TERMINAL_SESSION_STORAGE_KEY);
|
||||
return value && typeof value === "object" ? value : null;
|
||||
} catch (error) {
|
||||
console.warn("[terminalSession.read]", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getTerminalSessionSnapshot(now = Date.now()) {
|
||||
const snapshot = normalizeTerminalSessionSnapshot(readTerminalSessionRaw(), now);
|
||||
if (snapshot) {
|
||||
return snapshot;
|
||||
}
|
||||
clearTerminalSessionSnapshot();
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveTerminalSessionSnapshot(snapshot, now = Date.now()) {
|
||||
const normalized = normalizeTerminalSessionSnapshot(snapshot, now);
|
||||
if (!normalized) {
|
||||
clearTerminalSessionSnapshot();
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
wx.setStorageSync(TERMINAL_SESSION_STORAGE_KEY, normalized);
|
||||
return normalized;
|
||||
} catch (error) {
|
||||
console.warn("[terminalSession.write]", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createTerminalSessionSnapshot(input, now = Date.now()) {
|
||||
const snapshot = buildTerminalSessionSnapshot(input, now);
|
||||
if (!snapshot) return null;
|
||||
return saveTerminalSessionSnapshot(snapshot, now);
|
||||
}
|
||||
|
||||
function markTerminalSessionResumable(patch, now = Date.now()) {
|
||||
return createTerminalSessionSnapshot({ ...(patch || {}), status: "resumable" }, now);
|
||||
}
|
||||
|
||||
function normalizeTerminalBufferSnapshot(input, now = Date.now()) {
|
||||
const source = input && typeof input === "object" ? input : null;
|
||||
if (!source) return null;
|
||||
const sessionKey = String(source.sessionKey || "").trim();
|
||||
if (!sessionKey) return null;
|
||||
const lines = Array.isArray(source.lines) ? source.lines.filter((item) => typeof item === "string") : [];
|
||||
const styleTable = normalizeTerminalSnapshotStyleTable(source.styleTable);
|
||||
const styledLines = normalizeTerminalSnapshotStyledLines(source.styledLines, styleTable);
|
||||
const replayText = typeof source.replayText === "string" ? source.replayText : "";
|
||||
const cursorRow = Number(source.cursorRow);
|
||||
const cursorCol = Number(source.cursorCol);
|
||||
const savedAt = Number(source.savedAt);
|
||||
const bufferCols = normalizePositiveInt(source.bufferCols, 80, 1);
|
||||
const bufferRows = normalizePositiveInt(source.bufferRows, 24, 1);
|
||||
return {
|
||||
version: styledLines.length > 0 ? 2 : 1,
|
||||
sessionKey,
|
||||
lines,
|
||||
styleTable,
|
||||
styledLines,
|
||||
replayText,
|
||||
bufferCols,
|
||||
bufferRows,
|
||||
cursorRow: Number.isFinite(cursorRow) ? Math.max(0, Math.round(cursorRow)) : 0,
|
||||
cursorCol: Number.isFinite(cursorCol) ? Math.max(0, Math.round(cursorCol)) : 0,
|
||||
savedAt: Number.isFinite(savedAt) ? Math.round(savedAt) : now
|
||||
};
|
||||
}
|
||||
|
||||
function saveTerminalBufferSnapshot(snapshot, now = Date.now()) {
|
||||
const normalized = normalizeTerminalBufferSnapshot(snapshot, now);
|
||||
if (!normalized) {
|
||||
clearTerminalBufferSnapshot();
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
wx.setStorageSync(TERMINAL_BUFFER_STORAGE_KEY, normalized);
|
||||
return normalized;
|
||||
} catch (error) {
|
||||
console.warn("[terminalBuffer.write]", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getTerminalBufferSnapshot(sessionKey, now = Date.now()) {
|
||||
const targetSessionKey = String(sessionKey || "").trim();
|
||||
if (!targetSessionKey) return null;
|
||||
try {
|
||||
const raw = wx.getStorageSync(TERMINAL_BUFFER_STORAGE_KEY);
|
||||
const normalized = normalizeTerminalBufferSnapshot(raw, now);
|
||||
if (!normalized || normalized.sessionKey !== targetSessionKey) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
} catch (error) {
|
||||
console.warn("[terminalBuffer.read]", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearTerminalBufferSnapshot() {
|
||||
try {
|
||||
wx.removeStorageSync(TERMINAL_BUFFER_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
console.warn("[terminalBuffer.clear]", error);
|
||||
}
|
||||
}
|
||||
|
||||
function clearTerminalSessionSnapshot() {
|
||||
try {
|
||||
wx.removeStorageSync(TERMINAL_SESSION_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
console.warn("[terminalSession.clear]", error);
|
||||
}
|
||||
clearTerminalBufferSnapshot();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getTerminalSessionSnapshot,
|
||||
saveTerminalSessionSnapshot,
|
||||
createTerminalSessionSnapshot,
|
||||
markTerminalSessionResumable,
|
||||
getTerminalBufferSnapshot,
|
||||
saveTerminalBufferSnapshot,
|
||||
clearTerminalBufferSnapshot,
|
||||
clearTerminalSessionSnapshot
|
||||
};
|
||||
79
apps/miniprogram/utils/terminalSession.test.ts
Normal file
79
apps/miniprogram/utils/terminalSession.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
function createWxStorage(initial: Record<string, unknown>) {
|
||||
const store = new Map<string, unknown>(Object.entries(initial));
|
||||
return {
|
||||
getStorageSync(key: string) {
|
||||
return store.get(key);
|
||||
},
|
||||
setStorageSync(key: string, value: unknown) {
|
||||
store.set(key, value);
|
||||
},
|
||||
removeStorageSync(key: string) {
|
||||
store.delete(key);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function loadTerminalSessionModule() {
|
||||
const modulePath = require.resolve("./terminalSession.js");
|
||||
delete require.cache[modulePath];
|
||||
return require("./terminalSession.js");
|
||||
}
|
||||
|
||||
describe("terminalSession", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("终端缓冲快照会保留当时的列数和行数,供恢复时避免误判几何变化", () => {
|
||||
const wxStorage = createWxStorage({});
|
||||
(global as typeof globalThis & { wx: typeof wxStorage }).wx = wxStorage;
|
||||
|
||||
const terminalSession = loadTerminalSessionModule();
|
||||
terminalSession.saveTerminalBufferSnapshot({
|
||||
sessionKey: "mini-key-1",
|
||||
lines: ["prompt"],
|
||||
replayText: "prompt",
|
||||
bufferCols: 53,
|
||||
bufferRows: 18,
|
||||
cursorRow: 0,
|
||||
cursorCol: 6
|
||||
});
|
||||
|
||||
expect(terminalSession.getTerminalBufferSnapshot("mini-key-1")).toEqual(
|
||||
expect.objectContaining({
|
||||
bufferCols: 53,
|
||||
bufferRows: 18,
|
||||
cursorRow: 0,
|
||||
cursorCol: 6
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("终端缓冲快照会保留压缩样式行,供恢复第一页时直接还原 ANSI 颜色", () => {
|
||||
const wxStorage = createWxStorage({});
|
||||
(global as typeof globalThis & { wx: typeof wxStorage }).wx = wxStorage;
|
||||
|
||||
const terminalSession = loadTerminalSessionModule();
|
||||
terminalSession.saveTerminalBufferSnapshot({
|
||||
sessionKey: "mini-key-2",
|
||||
lines: ["ERR"],
|
||||
styleTable: [{ fg: "#ff5f56", bg: "#1f2937", bold: true }],
|
||||
styledLines: [[{ t: "ERR", c: 3, s: 0 }]],
|
||||
replayText: "\u001b[31;1mERR\u001b[0m",
|
||||
bufferCols: 53,
|
||||
bufferRows: 18,
|
||||
cursorRow: 0,
|
||||
cursorCol: 3
|
||||
});
|
||||
|
||||
expect(terminalSession.getTerminalBufferSnapshot("mini-key-2")).toEqual(
|
||||
expect.objectContaining({
|
||||
version: 2,
|
||||
styleTable: [expect.objectContaining({ fg: "#ff5f56", bg: "#1f2937", bold: true })],
|
||||
styledLines: [[{ t: "ERR", c: 3, s: 0 }]]
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
204
apps/miniprogram/utils/terminalSessionState.js
Normal file
204
apps/miniprogram/utils/terminalSessionState.js
Normal file
@@ -0,0 +1,204 @@
|
||||
/* global module */
|
||||
|
||||
/**
|
||||
* 小程序终端续接状态纯函数:
|
||||
* 1. 负责“后台保活分钟数”的收敛;
|
||||
* 2. 负责终端续接快照的规范化与过期判断;
|
||||
* 3. 不依赖 wx,便于单元测试覆盖。
|
||||
*/
|
||||
const TERMINAL_SESSION_SNAPSHOT_VERSION = 1;
|
||||
const DEFAULT_TERMINAL_RESUME_MINUTES = 15;
|
||||
const MIN_TERMINAL_RESUME_MINUTES = 1;
|
||||
const MAX_TERMINAL_RESUME_MINUTES = 60;
|
||||
const MIN_TERMINAL_RESUME_GRACE_MS = MIN_TERMINAL_RESUME_MINUTES * 60 * 1000;
|
||||
const MAX_TERMINAL_RESUME_GRACE_MS = MAX_TERMINAL_RESUME_MINUTES * 60 * 1000;
|
||||
|
||||
const VALID_TERMINAL_SESSION_STATUS = new Set([
|
||||
"connecting",
|
||||
"auth_pending",
|
||||
"connected",
|
||||
"resumable",
|
||||
"disconnected",
|
||||
"error"
|
||||
]);
|
||||
const VALID_ACTIVE_AI_PROVIDER = new Set(["", "codex", "copilot"]);
|
||||
const VALID_CODEX_SANDBOX_MODE = new Set(["read-only", "workspace-write", "danger-full-access"]);
|
||||
|
||||
function normalizeCodexSandboxMode(value) {
|
||||
const normalized = String(value || "").trim();
|
||||
if (!normalized) return "";
|
||||
return VALID_CODEX_SANDBOX_MODE.has(normalized) ? normalized : "workspace-write";
|
||||
}
|
||||
|
||||
function normalizeTerminalResumeMinutes(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return DEFAULT_TERMINAL_RESUME_MINUTES;
|
||||
const normalized = Math.round(parsed);
|
||||
if (normalized < MIN_TERMINAL_RESUME_MINUTES) return MIN_TERMINAL_RESUME_MINUTES;
|
||||
if (normalized > MAX_TERMINAL_RESUME_MINUTES) return MAX_TERMINAL_RESUME_MINUTES;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeTerminalResumeGraceMs(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return DEFAULT_TERMINAL_RESUME_MINUTES * 60 * 1000;
|
||||
}
|
||||
const normalized = Math.round(parsed);
|
||||
if (normalized < MIN_TERMINAL_RESUME_GRACE_MS) return MIN_TERMINAL_RESUME_GRACE_MS;
|
||||
if (normalized > MAX_TERMINAL_RESUME_GRACE_MS) return MAX_TERMINAL_RESUME_GRACE_MS;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolveTerminalResumeGraceMs(settings) {
|
||||
const minutes = normalizeTerminalResumeMinutes(
|
||||
settings && typeof settings === "object" ? settings.backgroundSessionKeepAliveMinutes : undefined
|
||||
);
|
||||
return minutes * 60 * 1000;
|
||||
}
|
||||
|
||||
function normalizeTerminalSessionStatus(value) {
|
||||
const normalized = String(value || "").trim();
|
||||
if (!VALID_TERMINAL_SESSION_STATUS.has(normalized)) {
|
||||
return "disconnected";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isTerminalSessionSnapshotExpired(snapshot, now = Date.now()) {
|
||||
if (!snapshot || typeof snapshot !== "object") return true;
|
||||
if (String(snapshot.status || "") !== "resumable") return false;
|
||||
const expiresAt = Number(snapshot.resumeExpiresAt);
|
||||
if (!Number.isFinite(expiresAt)) return true;
|
||||
return expiresAt <= now;
|
||||
}
|
||||
|
||||
function normalizeTerminalSessionSnapshot(input, now = Date.now()) {
|
||||
const source = input && typeof input === "object" ? input : null;
|
||||
if (!source) return null;
|
||||
|
||||
const version = Number(source.version);
|
||||
if (version !== TERMINAL_SESSION_SNAPSHOT_VERSION) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const serverId = String(source.serverId || "").trim();
|
||||
const sessionId = String(source.sessionId || "").trim();
|
||||
const sessionKey = String(source.sessionKey || "").trim();
|
||||
if (!serverId || !sessionId || !sessionKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const status = normalizeTerminalSessionStatus(source.status);
|
||||
const activeAiProvider = VALID_ACTIVE_AI_PROVIDER.has(String(source.activeAiProvider || "").trim())
|
||||
? String(source.activeAiProvider || "").trim()
|
||||
: "";
|
||||
const codexSandboxMode =
|
||||
activeAiProvider === "codex" ? normalizeCodexSandboxMode(source.codexSandboxMode) : "";
|
||||
const savedAt = Number(source.savedAt);
|
||||
const resumeGraceMs = normalizeTerminalResumeGraceMs(source.resumeGraceMs);
|
||||
const resumeExpiresAt = status === "resumable" ? Number(source.resumeExpiresAt) : 0;
|
||||
|
||||
const snapshot = {
|
||||
version: TERMINAL_SESSION_SNAPSHOT_VERSION,
|
||||
serverId,
|
||||
serverLabel: String(source.serverLabel || "").trim(),
|
||||
sessionId,
|
||||
sessionKey,
|
||||
status,
|
||||
activeAiProvider,
|
||||
codexSandboxMode,
|
||||
resumeGraceMs,
|
||||
resumeExpiresAt: Number.isFinite(resumeExpiresAt) ? Math.round(resumeExpiresAt) : 0,
|
||||
savedAt: Number.isFinite(savedAt) ? Math.round(savedAt) : now
|
||||
};
|
||||
|
||||
if (isTerminalSessionSnapshotExpired(snapshot, now)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function buildTerminalSessionSnapshot(input, now = Date.now()) {
|
||||
const source = input && typeof input === "object" ? input : {};
|
||||
const status = normalizeTerminalSessionStatus(source.status);
|
||||
const resumeGraceMs = normalizeTerminalResumeGraceMs(source.resumeGraceMs);
|
||||
const resumeExpiresAt =
|
||||
status === "resumable"
|
||||
? now + resumeGraceMs
|
||||
: Math.max(0, Math.round(Number(source.resumeExpiresAt) || 0));
|
||||
|
||||
return normalizeTerminalSessionSnapshot(
|
||||
{
|
||||
version: TERMINAL_SESSION_SNAPSHOT_VERSION,
|
||||
serverId: String(source.serverId || "").trim(),
|
||||
serverLabel: String(source.serverLabel || "").trim(),
|
||||
sessionId: String(source.sessionId || "").trim(),
|
||||
sessionKey: String(source.sessionKey || "").trim(),
|
||||
status,
|
||||
activeAiProvider: VALID_ACTIVE_AI_PROVIDER.has(String(source.activeAiProvider || "").trim())
|
||||
? String(source.activeAiProvider || "").trim()
|
||||
: "",
|
||||
codexSandboxMode:
|
||||
String(source.activeAiProvider || "").trim() === "codex"
|
||||
? normalizeCodexSandboxMode(source.codexSandboxMode)
|
||||
: "",
|
||||
resumeGraceMs,
|
||||
resumeExpiresAt,
|
||||
savedAt: now
|
||||
},
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
function isTerminalSessionHighlighted(snapshot, serverId, now = Date.now()) {
|
||||
const normalized = normalizeTerminalSessionSnapshot(snapshot, now);
|
||||
if (!normalized || normalized.serverId !== String(serverId || "").trim()) {
|
||||
return false;
|
||||
}
|
||||
return normalized.status === "connected" || normalized.status === "resumable";
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 高亮态比普通连接态更严格:
|
||||
* 1. 目标服务器必须仍处于“已连接 / 可续接”窗口;
|
||||
* 2. 同一快照里还要明确记录了 AI 前台 provider;
|
||||
* 3. 这样列表页和终端页才能只在“真的有 AI 会话”时点亮 AI 按钮。
|
||||
*/
|
||||
function isTerminalSessionAiHighlighted(snapshot, serverId, now = Date.now()) {
|
||||
const normalized = normalizeTerminalSessionSnapshot(snapshot, now);
|
||||
if (!normalized || normalized.serverId !== String(serverId || "").trim()) {
|
||||
return false;
|
||||
}
|
||||
if (!normalized.activeAiProvider) {
|
||||
return false;
|
||||
}
|
||||
return normalized.status === "connected" || normalized.status === "resumable";
|
||||
}
|
||||
|
||||
function isTerminalSessionConnecting(snapshot, serverId, now = Date.now()) {
|
||||
const normalized = normalizeTerminalSessionSnapshot(snapshot, now);
|
||||
if (!normalized || normalized.serverId !== String(serverId || "").trim()) {
|
||||
return false;
|
||||
}
|
||||
return normalized.status === "connecting" || normalized.status === "auth_pending";
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TERMINAL_SESSION_SNAPSHOT_VERSION,
|
||||
DEFAULT_TERMINAL_RESUME_MINUTES,
|
||||
MIN_TERMINAL_RESUME_MINUTES,
|
||||
MAX_TERMINAL_RESUME_MINUTES,
|
||||
normalizeTerminalResumeMinutes,
|
||||
normalizeTerminalResumeGraceMs,
|
||||
resolveTerminalResumeGraceMs,
|
||||
normalizeCodexSandboxMode,
|
||||
normalizeTerminalSessionStatus,
|
||||
normalizeTerminalSessionSnapshot,
|
||||
buildTerminalSessionSnapshot,
|
||||
isTerminalSessionSnapshotExpired,
|
||||
isTerminalSessionAiHighlighted,
|
||||
isTerminalSessionHighlighted,
|
||||
isTerminalSessionConnecting
|
||||
};
|
||||
149
apps/miniprogram/utils/terminalSessionState.test.ts
Normal file
149
apps/miniprogram/utils/terminalSessionState.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const {
|
||||
DEFAULT_TERMINAL_RESUME_MINUTES,
|
||||
MAX_TERMINAL_RESUME_MINUTES,
|
||||
buildTerminalSessionSnapshot,
|
||||
isTerminalSessionAiHighlighted,
|
||||
isTerminalSessionConnecting,
|
||||
isTerminalSessionHighlighted,
|
||||
normalizeCodexSandboxMode,
|
||||
normalizeTerminalResumeMinutes,
|
||||
normalizeTerminalSessionSnapshot,
|
||||
resolveTerminalResumeGraceMs
|
||||
} = require("./terminalSessionState");
|
||||
|
||||
describe("terminalSessionState", () => {
|
||||
it("收敛后台保活分钟数到合法区间", () => {
|
||||
expect(normalizeTerminalResumeMinutes(undefined)).toBe(DEFAULT_TERMINAL_RESUME_MINUTES);
|
||||
expect(normalizeTerminalResumeMinutes(0)).toBe(1);
|
||||
expect(normalizeTerminalResumeMinutes(MAX_TERMINAL_RESUME_MINUTES + 10)).toBe(
|
||||
MAX_TERMINAL_RESUME_MINUTES
|
||||
);
|
||||
});
|
||||
|
||||
it("根据设置生成毫秒级续接窗口", () => {
|
||||
expect(resolveTerminalResumeGraceMs({ backgroundSessionKeepAliveMinutes: 15 })).toBe(15 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("保留未过期的可续接快照", () => {
|
||||
const now = 1_700_000_000_000;
|
||||
const snapshot = buildTerminalSessionSnapshot(
|
||||
{
|
||||
serverId: "srv-1",
|
||||
serverLabel: "server-1",
|
||||
sessionId: "mini-1",
|
||||
sessionKey: "mini-key-1",
|
||||
status: "resumable",
|
||||
activeAiProvider: "codex",
|
||||
codexSandboxMode: "danger-full-access",
|
||||
resumeGraceMs: 15 * 60 * 1000
|
||||
},
|
||||
now
|
||||
);
|
||||
|
||||
expect(snapshot).toBeTruthy();
|
||||
expect(snapshot && snapshot.codexSandboxMode).toBe("danger-full-access");
|
||||
expect(normalizeTerminalSessionSnapshot(snapshot, now + 1000)).toEqual(snapshot);
|
||||
expect(normalizeTerminalSessionSnapshot(snapshot, now + 15 * 60 * 1000 + 1)).toBeNull();
|
||||
});
|
||||
|
||||
it("仅在 Codex 前台态下保留 sandbox,并收敛脏值", () => {
|
||||
const now = 1_700_000_000_000;
|
||||
|
||||
expect(normalizeCodexSandboxMode("danger-full-access")).toBe("danger-full-access");
|
||||
expect(normalizeCodexSandboxMode("invalid")).toBe("workspace-write");
|
||||
expect(normalizeCodexSandboxMode("")).toBe("");
|
||||
|
||||
const codexSnapshot = buildTerminalSessionSnapshot(
|
||||
{
|
||||
serverId: "srv-codex",
|
||||
sessionId: "mini-codex",
|
||||
sessionKey: "mini-key-codex",
|
||||
status: "connected",
|
||||
activeAiProvider: "codex",
|
||||
codexSandboxMode: "invalid",
|
||||
resumeGraceMs: 15 * 60 * 1000
|
||||
},
|
||||
now
|
||||
);
|
||||
const copilotSnapshot = buildTerminalSessionSnapshot(
|
||||
{
|
||||
serverId: "srv-copilot",
|
||||
sessionId: "mini-copilot",
|
||||
sessionKey: "mini-key-copilot",
|
||||
status: "connected",
|
||||
activeAiProvider: "copilot",
|
||||
codexSandboxMode: "danger-full-access",
|
||||
resumeGraceMs: 15 * 60 * 1000
|
||||
},
|
||||
now
|
||||
);
|
||||
|
||||
expect(codexSnapshot && codexSnapshot.codexSandboxMode).toBe("workspace-write");
|
||||
expect(copilotSnapshot && copilotSnapshot.codexSandboxMode).toBe("");
|
||||
});
|
||||
|
||||
it("区分高亮态与连接中态", () => {
|
||||
const now = 1_700_000_000_000;
|
||||
const resumable = buildTerminalSessionSnapshot(
|
||||
{
|
||||
serverId: "srv-2",
|
||||
sessionId: "mini-2",
|
||||
sessionKey: "mini-key-2",
|
||||
status: "resumable",
|
||||
activeAiProvider: "copilot",
|
||||
resumeGraceMs: 15 * 60 * 1000
|
||||
},
|
||||
now
|
||||
);
|
||||
const connecting = buildTerminalSessionSnapshot(
|
||||
{
|
||||
serverId: "srv-3",
|
||||
sessionId: "mini-3",
|
||||
sessionKey: "mini-key-3",
|
||||
status: "connecting",
|
||||
activeAiProvider: "invalid",
|
||||
resumeGraceMs: 15 * 60 * 1000
|
||||
},
|
||||
now
|
||||
);
|
||||
|
||||
expect(isTerminalSessionHighlighted(resumable, "srv-2", now)).toBe(true);
|
||||
expect(isTerminalSessionConnecting(resumable, "srv-2", now)).toBe(false);
|
||||
expect(isTerminalSessionConnecting(connecting, "srv-3", now)).toBe(true);
|
||||
expect(isTerminalSessionHighlighted(connecting, "srv-3", now)).toBe(false);
|
||||
expect(connecting && connecting.activeAiProvider).toBe("");
|
||||
});
|
||||
|
||||
it("仅在目标服务器存在 AI 前台态时点亮 AI 按钮", () => {
|
||||
const now = 1_700_000_000_000;
|
||||
const aiResumable = buildTerminalSessionSnapshot(
|
||||
{
|
||||
serverId: "srv-ai",
|
||||
sessionId: "mini-ai",
|
||||
sessionKey: "mini-key-ai",
|
||||
status: "resumable",
|
||||
activeAiProvider: "copilot",
|
||||
resumeGraceMs: 15 * 60 * 1000
|
||||
},
|
||||
now
|
||||
);
|
||||
const plainConnected = buildTerminalSessionSnapshot(
|
||||
{
|
||||
serverId: "srv-shell",
|
||||
sessionId: "mini-shell",
|
||||
sessionKey: "mini-key-shell",
|
||||
status: "connected",
|
||||
activeAiProvider: "",
|
||||
resumeGraceMs: 15 * 60 * 1000
|
||||
},
|
||||
now
|
||||
);
|
||||
|
||||
expect(isTerminalSessionAiHighlighted(aiResumable, "srv-ai", now)).toBe(true);
|
||||
expect(isTerminalSessionAiHighlighted(aiResumable, "srv-other", now)).toBe(false);
|
||||
expect(isTerminalSessionAiHighlighted(plainConnected, "srv-shell", now)).toBe(false);
|
||||
expect(isTerminalSessionAiHighlighted(null, "srv-ai", now)).toBe(false);
|
||||
});
|
||||
});
|
||||
534
apps/miniprogram/utils/themeStyle.js
Normal file
534
apps/miniprogram/utils/themeStyle.js
Normal file
@@ -0,0 +1,534 @@
|
||||
/* global module, wx */
|
||||
|
||||
/**
|
||||
* 小程序主题预设与 shared/Web 保持同一套内部值和顺序:
|
||||
* 1. 这里保留运行时可直接消费的扁平对象,避免小程序侧额外依赖 TS 共享模块;
|
||||
* 2. 若 shared 新增主题,这里也要同步补齐,保证多端主题配置可互通;
|
||||
* 3. shell cursor 仍按当前小程序规则实时推导,因此这里只保留 bg/text 基色。
|
||||
*/
|
||||
const THEME_PRESETS = {
|
||||
tide: {
|
||||
dark: { bg: "#192b4d", text: "#e6f0ff", accent: "#5bd2ff" },
|
||||
light: { bg: "#e6f0ff", text: "#192b4d", accent: "#3D86FF" },
|
||||
shellDark: { bg: "#192b4d", text: "#e6f0ff", cursor: "#5bd2ff" },
|
||||
shellLight: { bg: "#e6f0ff", text: "#192b4d", cursor: "#3D86FF" }
|
||||
},
|
||||
暮砂: {
|
||||
dark: { bg: "#3D405B", text: "#F4F1DE", accent: "#81B29A" },
|
||||
light: { bg: "#F4F1DE", text: "#3D405B", accent: "#E07A5F" },
|
||||
shellDark: { bg: "#3D405B", text: "#F4F1DE", cursor: "#81B29A" },
|
||||
shellLight: { bg: "#F4F1DE", text: "#3D405B", cursor: "#E07A5F" }
|
||||
},
|
||||
霓潮: {
|
||||
dark: { bg: "#073B4C", text: "#FFD166", accent: "#06D6A0" },
|
||||
light: { bg: "#FFD166", text: "#073B4C", accent: "#EF476F" },
|
||||
shellDark: { bg: "#073B4C", text: "#FFD166", cursor: "#06D6A0" },
|
||||
shellLight: { bg: "#FFD166", text: "#073B4C", cursor: "#EF476F" }
|
||||
},
|
||||
苔暮: {
|
||||
dark: { bg: "#282C75", text: "#A8B868", accent: "#7A71E4" },
|
||||
light: { bg: "#A8B868", text: "#282C75", accent: "#4E4CC3" },
|
||||
shellDark: { bg: "#282C75", text: "#A8B868", cursor: "#7A71E4" },
|
||||
shellLight: { bg: "#A8B868", text: "#282C75", cursor: "#4E4CC3" }
|
||||
},
|
||||
焰岩: {
|
||||
dark: { bg: "#0F4C5C", text: "#FB8B24", accent: "#E36414" },
|
||||
light: { bg: "#FB8B24", text: "#0F4C5C", accent: "#CB4721" },
|
||||
shellDark: { bg: "#0F4C5C", text: "#FB8B24", cursor: "#E36414" },
|
||||
shellLight: { bg: "#FB8B24", text: "#0F4C5C", cursor: "#CB4721" }
|
||||
},
|
||||
岩陶: {
|
||||
dark: { bg: "#283D3B", text: "#EDDDD4", accent: "#E9B5AF" },
|
||||
light: { bg: "#EDDDD4", text: "#283D3B", accent: "#D99185" },
|
||||
shellDark: { bg: "#283D3B", text: "#EDDDD4", cursor: "#E9B5AF" },
|
||||
shellLight: { bg: "#EDDDD4", text: "#283D3B", cursor: "#D99185" }
|
||||
},
|
||||
靛雾: {
|
||||
dark: { bg: "#292281", text: "#F1F0CD", accent: "#9D96BA" },
|
||||
light: { bg: "#F1F0CD", text: "#292281", accent: "#4A3BA6" },
|
||||
shellDark: { bg: "#292281", text: "#F1F0CD", cursor: "#9D96BA" },
|
||||
shellLight: { bg: "#F1F0CD", text: "#292281", cursor: "#4A3BA6" }
|
||||
},
|
||||
绛霓: {
|
||||
dark: { bg: "#3A0CA3", text: "#4CC9F0", accent: "#4895EF" },
|
||||
light: { bg: "#4CC9F0", text: "#3A0CA3", accent: "#F72585" },
|
||||
shellDark: { bg: "#3A0CA3", text: "#4CC9F0", cursor: "#4895EF" },
|
||||
shellLight: { bg: "#4CC9F0", text: "#3A0CA3", cursor: "#F72585" }
|
||||
},
|
||||
玫蓝: {
|
||||
dark: { bg: "#3D1F94", text: "#629FEB", accent: "#D06A79" },
|
||||
light: { bg: "#629FEB", text: "#3D1F94", accent: "#D06A79" },
|
||||
shellDark: { bg: "#3D1F94", text: "#629FEB", cursor: "#567BE3" },
|
||||
shellLight: { bg: "#629FEB", text: "#3D1F94", cursor: "#D06A79" }
|
||||
},
|
||||
珊湾: {
|
||||
dark: { bg: "#37615B", text: "#52DFDD", accent: "#EC5B57" },
|
||||
light: { bg: "#52DFDD", text: "#37615B", accent: "#EC5B57" },
|
||||
shellDark: { bg: "#37615B", text: "#52DFDD", cursor: "#44B0AB" },
|
||||
shellLight: { bg: "#52DFDD", text: "#37615B", cursor: "#EC5B57" }
|
||||
},
|
||||
苔荧: {
|
||||
dark: { bg: "#252157", text: "#D1D942", accent: "#99A32C" },
|
||||
light: { bg: "#D1D942", text: "#252157", accent: "#909636" },
|
||||
shellDark: { bg: "#252157", text: "#D1D942", cursor: "#C2CB37" },
|
||||
shellLight: { bg: "#D1D942", text: "#252157", cursor: "#909636" }
|
||||
},
|
||||
铜暮: {
|
||||
dark: { bg: "#1A375A", text: "#E6B030", accent: "#B27225" },
|
||||
light: { bg: "#E6B030", text: "#1A375A", accent: "#B27225" },
|
||||
shellDark: { bg: "#1A375A", text: "#E6B030", cursor: "#D99F27" },
|
||||
shellLight: { bg: "#E6B030", text: "#1A375A", cursor: "#B27225" }
|
||||
},
|
||||
炽潮: {
|
||||
dark: { bg: "#125554", text: "#F55054", accent: "#C43133" },
|
||||
light: { bg: "#F55054", text: "#125554", accent: "#AA3E40" },
|
||||
shellDark: { bg: "#125554", text: "#F55054", cursor: "#E94347" },
|
||||
shellLight: { bg: "#F55054", text: "#125554", cursor: "#AA3E40" }
|
||||
},
|
||||
藕夜: {
|
||||
dark: { bg: "#322F4F", text: "#DBDD85", accent: "#554C93" },
|
||||
light: { bg: "#DBDD85", text: "#322F4F", accent: "#D5DB74" },
|
||||
shellDark: { bg: "#322F4F", text: "#DBDD85", cursor: "#C8CF67" },
|
||||
shellLight: { bg: "#DBDD85", text: "#322F4F", cursor: "#554C93" }
|
||||
},
|
||||
沙海: {
|
||||
dark: { bg: "#2B3B51", text: "#E2D075", accent: "#3F7690" },
|
||||
light: { bg: "#E2D075", text: "#2B3B51", accent: "#355971" },
|
||||
shellDark: { bg: "#2B3B51", text: "#E2D075", cursor: "#DBAA5F" },
|
||||
shellLight: { bg: "#E2D075", text: "#2B3B51", cursor: "#3F7690" }
|
||||
},
|
||||
珀岚: {
|
||||
dark: { bg: "#274D4C", text: "#E79094", accent: "#2F9595" },
|
||||
light: { bg: "#E79094", text: "#274D4C", accent: "#2B7171" },
|
||||
shellDark: { bg: "#274D4C", text: "#E79094", cursor: "#EC7578" },
|
||||
shellLight: { bg: "#E79094", text: "#274D4C", cursor: "#2F9595" }
|
||||
},
|
||||
炫虹: {
|
||||
dark: { bg: "#8338EC", text: "#FFBE0B", accent: "#FF006E" },
|
||||
light: { bg: "#FFBE0B", text: "#8338EC", accent: "#FD2B3B" },
|
||||
shellDark: { bg: "#8338EC", text: "#FFBE0B", cursor: "#3A86FF" },
|
||||
shellLight: { bg: "#FFBE0B", text: "#8338EC", cursor: "#FF006E" }
|
||||
},
|
||||
鎏霓: {
|
||||
dark: { bg: "#7550D5", text: "#F3D321", accent: "#A2529A" },
|
||||
light: { bg: "#F3D321", text: "#7550D5", accent: "#D67039" },
|
||||
shellDark: { bg: "#7550D5", text: "#F3D321", cursor: "#476CEF" },
|
||||
shellLight: { bg: "#F3D321", text: "#7550D5", cursor: "#A2529A" }
|
||||
},
|
||||
珊汐: {
|
||||
dark: { bg: "#7F9E96", text: "#FB5860", accent: "#3DCAC5" },
|
||||
light: { bg: "#FB5860", text: "#7F9E96", accent: "#F2292C" },
|
||||
shellDark: { bg: "#7F9E96", text: "#FB5860", cursor: "#3DCAC5" },
|
||||
shellLight: { bg: "#FB5860", text: "#7F9E96", cursor: "#F2292C" }
|
||||
},
|
||||
黛苔: {
|
||||
dark: { bg: "#2F2E3B", text: "#E7E8D6", accent: "#788031" },
|
||||
light: { bg: "#E7E8D6", text: "#2F2E3B", accent: "#788031" },
|
||||
shellDark: { bg: "#2F2E3B", text: "#E7E8D6", cursor: "#949D3A" },
|
||||
shellLight: { bg: "#E7E8D6", text: "#2F2E3B", cursor: "#788031" }
|
||||
},
|
||||
霜绯: {
|
||||
dark: { bg: "#293B3B", text: "#ECD7D8", accent: "#1D7575" },
|
||||
light: { bg: "#ECD7D8", text: "#293B3B", accent: "#993333" },
|
||||
shellDark: { bg: "#293B3B", text: "#ECD7D8", cursor: "#BD3C3D" },
|
||||
shellLight: { bg: "#ECD7D8", text: "#293B3B", cursor: "#993333" }
|
||||
}
|
||||
};
|
||||
|
||||
const DEFAULT_UI = THEME_PRESETS.tide.dark;
|
||||
const SHELL_ACCENT_BLEND_T = 0.64;
|
||||
const DEFAULT_SHELL = {
|
||||
...THEME_PRESETS.tide.shellDark,
|
||||
cursor: pickShellAccentColor(THEME_PRESETS.tide.shellDark.bg, THEME_PRESETS.tide.shellDark.text)
|
||||
};
|
||||
const DEFAULT_SHELL_FONT_FAMILY = 'JetBrains Mono, "SFMono-Regular", Menlo, monospace';
|
||||
const DEFAULT_SHELL_FONT_SIZE = 15;
|
||||
const DEFAULT_SHELL_LINE_HEIGHT = 1.4;
|
||||
const DEFAULT_NAVIGATION_BAR_THEME = {
|
||||
backgroundColor: DEFAULT_UI.bg,
|
||||
frontColor: "#ffffff"
|
||||
};
|
||||
/**
|
||||
* 小程序终端当前采用“固定 cell 网格 + 自绘 caret”模型。
|
||||
* 因此这里只允许已验证度量稳定的终端字体;比例字体或中英文度量不稳定的字体
|
||||
* 会导致字间空隙拉大、光标横向偏移。
|
||||
*/
|
||||
const TERMINAL_SAFE_FONT_OPTIONS = [
|
||||
{
|
||||
label: "等宽默认",
|
||||
value: 'ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace'
|
||||
},
|
||||
{
|
||||
label: "JetBrains Mono",
|
||||
value: '"JetBrains Mono", "SFMono-Regular", Menlo, Consolas, monospace'
|
||||
},
|
||||
{ label: "SF Mono", value: '"SFMono-Regular", Menlo, monospace' },
|
||||
{ label: "Menlo", value: "Menlo, Monaco, monospace" },
|
||||
{ label: "Monaco", value: "Monaco, Menlo, monospace" },
|
||||
{ label: "Consolas", value: 'Consolas, "Courier New", monospace' },
|
||||
{
|
||||
label: "Roboto Mono",
|
||||
value: '"Roboto Mono", "SFMono-Regular", Menlo, Consolas, monospace'
|
||||
}
|
||||
];
|
||||
const TERMINAL_SAFE_FONT_VALUE_SET = new Set(
|
||||
TERMINAL_SAFE_FONT_OPTIONS.map((item) =>
|
||||
String(item.value || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
)
|
||||
);
|
||||
|
||||
function clampChannel(value) {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
if (value < 0) return 0;
|
||||
if (value > 255) return 255;
|
||||
return Math.round(value);
|
||||
}
|
||||
|
||||
function clampNumber(value, min, max, fallback) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
if (parsed < min) return min;
|
||||
if (parsed > max) return max;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function sanitizeCssToken(value, fallback) {
|
||||
const raw = String(value == null ? "" : value)
|
||||
.replace(/[;\n\r]/g, " ")
|
||||
.trim();
|
||||
return raw || fallback;
|
||||
}
|
||||
|
||||
function normalizeTerminalFontFamily(value) {
|
||||
const raw = sanitizeCssToken(value, DEFAULT_SHELL_FONT_FAMILY);
|
||||
return TERMINAL_SAFE_FONT_VALUE_SET.has(raw.toLowerCase()) ? raw : DEFAULT_SHELL_FONT_FAMILY;
|
||||
}
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const raw = String(hex || "").trim();
|
||||
const match = raw.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/);
|
||||
if (!match) return null;
|
||||
const normalized =
|
||||
match[1].length === 3
|
||||
? match[1]
|
||||
.split("")
|
||||
.map((ch) => ch + ch)
|
||||
.join("")
|
||||
: match[1];
|
||||
return {
|
||||
r: parseInt(normalized.slice(0, 2), 16),
|
||||
g: parseInt(normalized.slice(2, 4), 16),
|
||||
b: parseInt(normalized.slice(4, 6), 16)
|
||||
};
|
||||
}
|
||||
|
||||
function rgbToHex(rgb) {
|
||||
const toHex = (value) => clampChannel(value).toString(16).padStart(2, "0");
|
||||
return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`;
|
||||
}
|
||||
|
||||
function hexToRgba(hex, alpha, fallback) {
|
||||
const rgb = hexToRgb(hex);
|
||||
if (!rgb) return fallback;
|
||||
const normalizedAlpha = clampNumber(alpha, 0, 1, 1);
|
||||
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${normalizedAlpha})`;
|
||||
}
|
||||
|
||||
function mixHex(a, b, t) {
|
||||
const left = hexToRgb(a);
|
||||
const right = hexToRgb(b);
|
||||
if (!left || !right) return b;
|
||||
return rgbToHex({
|
||||
r: left.r + (right.r - left.r) * t,
|
||||
g: left.g + (right.g - left.g) * t,
|
||||
b: left.b + (right.b - left.b) * t
|
||||
});
|
||||
}
|
||||
|
||||
function pickBtnColor(bg, text) {
|
||||
return mixHex(bg, text, 0.72);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell 强调色与 Web/shared 规则保持一致:
|
||||
* 1. 不直接复用 UI accent,避免终端层级过亮;
|
||||
* 2. 在 shell 背景和前景之间取“中间偏前景”的颜色;
|
||||
* 3. 这样终端按钮、光标和状态提示可以跟随 shell 主体变化。
|
||||
*/
|
||||
function pickShellAccentColor(bg, text) {
|
||||
return mixHex(bg, text, SHELL_ACCENT_BLEND_T);
|
||||
}
|
||||
|
||||
function resolveRelativeLuminance(hex) {
|
||||
const rgb = hexToRgb(hex);
|
||||
if (!rgb) return 0;
|
||||
const channelToLinear = (value) => {
|
||||
const normalized = clampChannel(value) / 255;
|
||||
if (normalized <= 0.03928) {
|
||||
return normalized / 12.92;
|
||||
}
|
||||
return ((normalized + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
return channelToLinear(rgb.r) * 0.2126 + channelToLinear(rgb.g) * 0.7152 + channelToLinear(rgb.b) * 0.0722;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析原生导航栏配色:
|
||||
* 1. backgroundColor 跟随当前 UI 背景色;
|
||||
* 2. frontColor 仅允许微信支持的 #ffffff / #000000,两者按背景亮度择优。
|
||||
*/
|
||||
function resolveNavigationBarTheme(settings) {
|
||||
const palette = resolveRuntimeTheme(settings);
|
||||
const luminance = resolveRelativeLuminance(palette.bg);
|
||||
return {
|
||||
backgroundColor: palette.bg,
|
||||
frontColor: luminance >= 0.42 ? "#000000" : "#ffffff"
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将当前主题同步到微信原生导航栏,解决“页面工具栏上方颜色锁定”的问题。
|
||||
* 调用失败时静默返回,避免在单测或非微信运行环境中抛错。
|
||||
*/
|
||||
function applyNavigationBarTheme(settings, wxLike) {
|
||||
const theme = resolveNavigationBarTheme(settings);
|
||||
const api = wxLike || (typeof wx !== "undefined" ? wx : null);
|
||||
if (!api || typeof api.setNavigationBarColor !== "function") {
|
||||
return theme;
|
||||
}
|
||||
try {
|
||||
api.setNavigationBarColor({
|
||||
frontColor: theme.frontColor,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
animation: {
|
||||
duration: 0,
|
||||
timingFunc: "linear"
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
return theme;
|
||||
}
|
||||
return theme;
|
||||
}
|
||||
|
||||
function getPreset(preset) {
|
||||
const key = String(preset || "tide");
|
||||
return THEME_PRESETS[key] || THEME_PRESETS.tide;
|
||||
}
|
||||
|
||||
function getUiThemeVariant(preset, mode) {
|
||||
const entry = getPreset(preset);
|
||||
return mode === "light" ? entry.light : entry.dark;
|
||||
}
|
||||
|
||||
function getShellThemeVariant(preset, mode) {
|
||||
const entry = getPreset(preset);
|
||||
const variant = mode === "light" ? entry.shellLight : entry.shellDark;
|
||||
return {
|
||||
...variant,
|
||||
cursor: pickShellAccentColor(variant.bg, variant.text)
|
||||
};
|
||||
}
|
||||
|
||||
function applyUiThemeSelection(form) {
|
||||
const source = { ...(form || {}) };
|
||||
const mode = source.uiThemeMode === "light" ? "light" : "dark";
|
||||
const variant = getUiThemeVariant(source.uiThemePreset, mode);
|
||||
source.uiBgColor = variant.bg;
|
||||
source.uiTextColor = variant.text;
|
||||
source.uiAccentColor = variant.accent;
|
||||
source.uiBtnColor = pickBtnColor(variant.bg, variant.text);
|
||||
return source;
|
||||
}
|
||||
|
||||
function applyShellThemeSelection(form) {
|
||||
const source = { ...(form || {}) };
|
||||
const mode = source.shellThemeMode === "light" ? "light" : "dark";
|
||||
const variant = getShellThemeVariant(source.shellThemePreset, mode);
|
||||
source.shellBgColor = variant.bg;
|
||||
source.shellTextColor = variant.text;
|
||||
source.shellAccentColor = pickShellAccentColor(variant.bg, variant.text);
|
||||
return source;
|
||||
}
|
||||
|
||||
function resolveRuntimeTheme(settings) {
|
||||
const s = settings || {};
|
||||
const uiBg = s.uiBgColor || DEFAULT_UI.bg;
|
||||
const uiText = s.uiTextColor || DEFAULT_UI.text;
|
||||
const shellBg = s.shellBgColor || DEFAULT_SHELL.bg;
|
||||
const shellText = s.shellTextColor || DEFAULT_SHELL.text;
|
||||
return {
|
||||
bg: uiBg,
|
||||
text: uiText,
|
||||
accent: s.uiAccentColor || DEFAULT_UI.accent,
|
||||
btn: s.uiBtnColor || pickBtnColor(uiBg, uiText),
|
||||
shellBg,
|
||||
shellText,
|
||||
shellAccent: s.shellAccentColor || pickShellAccentColor(shellBg, shellText),
|
||||
shellFontFamily: normalizeTerminalFontFamily(s.shellFontFamily),
|
||||
shellFontSize: clampNumber(s.shellFontSize, 12, 22, DEFAULT_SHELL_FONT_SIZE),
|
||||
shellLineHeight: clampNumber(s.shellLineHeight, 1, 2, DEFAULT_SHELL_LINE_HEIGHT)
|
||||
};
|
||||
}
|
||||
|
||||
function resolveButtonTokens(palette) {
|
||||
return {
|
||||
btnBorder: mixHex(palette.bg, palette.btn, 0.68),
|
||||
btnBorderStrong: mixHex(palette.bg, palette.btn, 0.84),
|
||||
btnBg: mixHex(palette.bg, palette.btn, 0.2),
|
||||
btnBgStrong: mixHex(palette.bg, palette.btn, 0.34),
|
||||
btnBgActive: mixHex(palette.bg, palette.btn, 0.26),
|
||||
btnText: mixHex(palette.text, palette.btn, 0.18),
|
||||
btnDangerBorder: mixHex(palette.bg, palette.accent, 0.8),
|
||||
btnDangerBg: mixHex(palette.bg, palette.accent, 0.22),
|
||||
chipBg: mixHex(palette.bg, palette.accent, 0.28),
|
||||
chipText: palette.text,
|
||||
accentDivider: hexToRgba(palette.accent, 0.6, "rgba(91, 210, 255, 0.6)"),
|
||||
switchOnBg: mixHex(palette.bg, palette.btn, 0.58),
|
||||
switchOffBg: mixHex(palette.bg, palette.text, 0.24),
|
||||
switchKnob: mixHex(palette.text, "#ffffff", 0.45),
|
||||
iconBtnBg: mixHex(palette.bg, palette.btn, 0.14),
|
||||
iconBtnBgStrong: mixHex(palette.bg, palette.btn, 0.24),
|
||||
accentBg: mixHex(palette.bg, palette.accent, 0.18),
|
||||
accentBgStrong: mixHex(palette.bg, palette.accent, 0.32),
|
||||
accentBorder: mixHex(palette.bg, palette.accent, 0.72),
|
||||
accentRing: hexToRgba(palette.accent, 0.22, "rgba(91, 210, 255, 0.22)"),
|
||||
accentShadow: hexToRgba(palette.accent, 0.28, "rgba(91, 210, 255, 0.28)"),
|
||||
shellBtnBg: mixHex(palette.shellBg, palette.shellAccent, 0.42),
|
||||
shellBtnText: mixHex(palette.shellText, palette.shellAccent, 0.5),
|
||||
shellAccentBg: mixHex(palette.shellBg, palette.shellAccent, 0.18),
|
||||
shellAccentBgStrong: mixHex(palette.shellBg, palette.shellAccent, 0.32),
|
||||
shellAccentBorder: mixHex(palette.shellBg, palette.shellAccent, 0.74),
|
||||
shellAccentRing: hexToRgba(palette.shellAccent, 0.24, "rgba(156, 169, 191, 0.24)"),
|
||||
shellAccentShadow: hexToRgba(palette.shellAccent, 0.3, "rgba(156, 169, 191, 0.3)"),
|
||||
terminalTouchToolsBg: hexToRgba(palette.shellBg, 0.8, "rgba(25, 43, 77, 0.8)")
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 主界面卡片与 about 卡片共用同一套表面 token:
|
||||
* 1. 浅色模式下边框直接从当前 text 推导,避免继续沿用深色基线;
|
||||
* 2. 面板底色保持轻微前景混合,保证和页面背景拉开层次;
|
||||
* 3. 阴影也跟随当前强调色派生,后续其他页面可直接复用。
|
||||
*/
|
||||
function resolveSurfaceTokens(palette) {
|
||||
return {
|
||||
surface: mixHex(palette.bg, palette.text, 0.05),
|
||||
surfaceBorder: hexToRgba(palette.text, 0.18, "rgba(230, 240, 255, 0.18)"),
|
||||
surfaceShadow: hexToRgba(palette.accent, 0.18, "rgba(91, 210, 255, 0.18)")
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* About 页面虽然布局更自由,但配色仍应跟随“界面配置”:
|
||||
* 1. 面板底色从 bg/text 混合得到,避免再写死一套米白主题;
|
||||
* 2. 强调色继续复用当前 UI accent,保证切换主题后 about 也同步变;
|
||||
* 3. 背景光斑只作为氛围层,仍从当前主题色推导,不脱离主界面。
|
||||
*/
|
||||
function resolveAboutTokens(palette, buttonTokens, surfaceTokens) {
|
||||
return {
|
||||
bg: palette.bg,
|
||||
surface: surfaceTokens.surface,
|
||||
surfaceBorder: surfaceTokens.surfaceBorder,
|
||||
glow: surfaceTokens.surfaceShadow,
|
||||
textStrong: palette.text,
|
||||
text: mixHex(palette.bg, palette.text, 0.84),
|
||||
textMuted: mixHex(palette.bg, palette.text, 0.62),
|
||||
accent: palette.accent,
|
||||
accentSoft: hexToRgba(palette.accent, 0.22, "rgba(91, 210, 255, 0.22)"),
|
||||
accentLine: hexToRgba(palette.accent, 0.42, "rgba(91, 210, 255, 0.42)"),
|
||||
actionBg: buttonTokens.btnBgStrong,
|
||||
actionText: buttonTokens.btnText,
|
||||
actionBorder: buttonTokens.btnBorderStrong,
|
||||
orbLeftStart: palette.accent,
|
||||
orbLeftEnd: mixHex(palette.accent, palette.bg, 0.56),
|
||||
orbRightStart: mixHex(palette.btn, palette.bg, 0.34),
|
||||
orbRightEnd: mixHex(palette.text, palette.bg, 0.16)
|
||||
};
|
||||
}
|
||||
|
||||
function buildThemeStyle(settings) {
|
||||
const palette = resolveRuntimeTheme(settings);
|
||||
const buttonTokens = resolveButtonTokens(palette);
|
||||
const surfaceTokens = resolveSurfaceTokens(palette);
|
||||
const aboutTokens = resolveAboutTokens(palette, buttonTokens, surfaceTokens);
|
||||
const muted = mixHex(palette.bg, palette.text, 0.58);
|
||||
return [
|
||||
`--bg:${palette.bg}`,
|
||||
`--text:${palette.text}`,
|
||||
`--accent:${palette.accent}`,
|
||||
`--btn:${palette.btn}`,
|
||||
`--muted:${muted}`,
|
||||
`--surface:${surfaceTokens.surface}`,
|
||||
`--surface-border:${surfaceTokens.surfaceBorder}`,
|
||||
`--surface-shadow:${surfaceTokens.surfaceShadow}`,
|
||||
`--shell-bg:${palette.shellBg}`,
|
||||
`--shell-text:${palette.shellText}`,
|
||||
`--shell-accent:${palette.shellAccent}`,
|
||||
`--shell-font-family:${palette.shellFontFamily}`,
|
||||
`--shell-font-size:${palette.shellFontSize}px`,
|
||||
`--shell-line-height:${palette.shellLineHeight}`,
|
||||
`--btn-border:${buttonTokens.btnBorder}`,
|
||||
`--btn-border-strong:${buttonTokens.btnBorderStrong}`,
|
||||
`--btn-bg:${buttonTokens.btnBg}`,
|
||||
`--btn-bg-strong:${buttonTokens.btnBgStrong}`,
|
||||
`--btn-bg-active:${buttonTokens.btnBgActive}`,
|
||||
`--btn-text:${buttonTokens.btnText}`,
|
||||
`--btn-danger-border:${buttonTokens.btnDangerBorder}`,
|
||||
`--btn-danger-bg:${buttonTokens.btnDangerBg}`,
|
||||
`--chip-bg:${buttonTokens.chipBg}`,
|
||||
`--chip-text:${buttonTokens.chipText}`,
|
||||
`--accent-divider:${buttonTokens.accentDivider}`,
|
||||
`--switch-on-bg:${buttonTokens.switchOnBg}`,
|
||||
`--switch-off-bg:${buttonTokens.switchOffBg}`,
|
||||
`--switch-knob:${buttonTokens.switchKnob}`,
|
||||
`--icon-btn-bg:${buttonTokens.iconBtnBg}`,
|
||||
`--icon-btn-bg-strong:${buttonTokens.iconBtnBgStrong}`,
|
||||
`--accent-bg:${buttonTokens.accentBg}`,
|
||||
`--accent-bg-strong:${buttonTokens.accentBgStrong}`,
|
||||
`--accent-border:${buttonTokens.accentBorder}`,
|
||||
`--accent-ring:${buttonTokens.accentRing}`,
|
||||
`--accent-shadow:${buttonTokens.accentShadow}`,
|
||||
`--about-bg:${aboutTokens.bg}`,
|
||||
`--about-surface:${aboutTokens.surface}`,
|
||||
`--about-surface-border:${aboutTokens.surfaceBorder}`,
|
||||
`--about-glow:${aboutTokens.glow}`,
|
||||
`--about-text-strong:${aboutTokens.textStrong}`,
|
||||
`--about-text:${aboutTokens.text}`,
|
||||
`--about-text-muted:${aboutTokens.textMuted}`,
|
||||
`--about-accent:${aboutTokens.accent}`,
|
||||
`--about-accent-soft:${aboutTokens.accentSoft}`,
|
||||
`--about-accent-line:${aboutTokens.accentLine}`,
|
||||
`--about-action-bg:${aboutTokens.actionBg}`,
|
||||
`--about-action-text:${aboutTokens.actionText}`,
|
||||
`--about-action-border:${aboutTokens.actionBorder}`,
|
||||
`--about-orb-left-start:${aboutTokens.orbLeftStart}`,
|
||||
`--about-orb-left-end:${aboutTokens.orbLeftEnd}`,
|
||||
`--about-orb-right-start:${aboutTokens.orbRightStart}`,
|
||||
`--about-orb-right-end:${aboutTokens.orbRightEnd}`,
|
||||
`--shell-btn-bg:${buttonTokens.shellBtnBg}`,
|
||||
`--shell-btn-text:${buttonTokens.shellBtnText}`,
|
||||
`--shell-accent-bg:${buttonTokens.shellAccentBg}`,
|
||||
`--shell-accent-bg-strong:${buttonTokens.shellAccentBgStrong}`,
|
||||
`--shell-accent-border:${buttonTokens.shellAccentBorder}`,
|
||||
`--shell-accent-ring:${buttonTokens.shellAccentRing}`,
|
||||
`--shell-accent-shadow:${buttonTokens.shellAccentShadow}`,
|
||||
`--terminal-touch-tools-bg:${buttonTokens.terminalTouchToolsBg}`
|
||||
].join(";");
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildThemeStyle,
|
||||
applyUiThemeSelection,
|
||||
applyShellThemeSelection,
|
||||
pickBtnColor,
|
||||
pickShellAccentColor,
|
||||
resolveNavigationBarTheme,
|
||||
applyNavigationBarTheme,
|
||||
DEFAULT_NAVIGATION_BAR_THEME,
|
||||
DEFAULT_SHELL_FONT_FAMILY,
|
||||
TERMINAL_SAFE_FONT_OPTIONS,
|
||||
normalizeTerminalFontFamily
|
||||
};
|
||||
106
apps/miniprogram/utils/themeStyle.test.ts
Normal file
106
apps/miniprogram/utils/themeStyle.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const {
|
||||
DEFAULT_NAVIGATION_BAR_THEME,
|
||||
DEFAULT_SHELL_FONT_FAMILY,
|
||||
TERMINAL_SAFE_FONT_OPTIONS,
|
||||
buildThemeStyle,
|
||||
normalizeTerminalFontFamily,
|
||||
applyUiThemeSelection,
|
||||
applyShellThemeSelection,
|
||||
resolveNavigationBarTheme
|
||||
} = require("./themeStyle.js");
|
||||
|
||||
describe("themeStyle terminal font safety", () => {
|
||||
it("保留已验证可用于终端 cell 网格的等宽字体", () => {
|
||||
TERMINAL_SAFE_FONT_OPTIONS.forEach((option: { value: string }) => {
|
||||
expect(normalizeTerminalFontFamily(option.value)).toBe(option.value);
|
||||
});
|
||||
});
|
||||
|
||||
it("把比例字体或不安全字体回退到默认等宽字体", () => {
|
||||
expect(normalizeTerminalFontFamily('"PingFang SC", "Hiragino Sans GB", sans-serif')).toBe(
|
||||
DEFAULT_SHELL_FONT_FAMILY
|
||||
);
|
||||
expect(
|
||||
normalizeTerminalFontFamily(
|
||||
'-apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif'
|
||||
)
|
||||
).toBe(DEFAULT_SHELL_FONT_FAMILY);
|
||||
expect(normalizeTerminalFontFamily('"Microsoft YaHei", "PingFang SC", sans-serif')).toBe(
|
||||
DEFAULT_SHELL_FONT_FAMILY
|
||||
);
|
||||
expect(normalizeTerminalFontFamily('"Noto Sans Mono CJK SC", "Noto Sans Mono", monospace')).toBe(
|
||||
DEFAULT_SHELL_FONT_FAMILY
|
||||
);
|
||||
});
|
||||
|
||||
it("深色主题下原生导航栏使用深色背景和白色前景", () => {
|
||||
expect(resolveNavigationBarTheme({ uiBgColor: "#192b4d" })).toEqual({
|
||||
backgroundColor: "#192b4d",
|
||||
frontColor: "#ffffff"
|
||||
});
|
||||
});
|
||||
|
||||
it("浅色主题下原生导航栏自动切换黑色前景", () => {
|
||||
expect(resolveNavigationBarTheme({ uiBgColor: "#f4f1de" })).toEqual({
|
||||
backgroundColor: "#f4f1de",
|
||||
frontColor: "#000000"
|
||||
});
|
||||
});
|
||||
|
||||
it("缺省配置回退默认导航栏主题", () => {
|
||||
expect(resolveNavigationBarTheme({})).toEqual(DEFAULT_NAVIGATION_BAR_THEME);
|
||||
});
|
||||
|
||||
it("UI 主题切换时按钮色按文档规则跟随主体联动", () => {
|
||||
expect(applyUiThemeSelection({ uiThemePreset: "tide", uiThemeMode: "dark" })).toMatchObject({
|
||||
uiBgColor: "#192b4d",
|
||||
uiTextColor: "#e6f0ff",
|
||||
uiAccentColor: "#5bd2ff",
|
||||
uiBtnColor: "#adb9cd"
|
||||
});
|
||||
});
|
||||
|
||||
it("Shell 主题切换时强调色按文档规则从背景和前景推导", () => {
|
||||
expect(applyShellThemeSelection({ shellThemePreset: "tide", shellThemeMode: "dark" })).toMatchObject({
|
||||
shellBgColor: "#192b4d",
|
||||
shellTextColor: "#e6f0ff",
|
||||
shellAccentColor: "#9ca9bf"
|
||||
});
|
||||
});
|
||||
|
||||
it("新增主题预设在小程序端也能输出与 Web 对齐的基色", () => {
|
||||
expect(applyUiThemeSelection({ uiThemePreset: "绛霓", uiThemeMode: "dark" })).toMatchObject({
|
||||
uiBgColor: "#3A0CA3",
|
||||
uiTextColor: "#4CC9F0",
|
||||
uiAccentColor: "#4895EF"
|
||||
});
|
||||
expect(applyShellThemeSelection({ shellThemePreset: "霜绯", shellThemeMode: "light" })).toMatchObject({
|
||||
shellBgColor: "#ECD7D8",
|
||||
shellTextColor: "#293B3B"
|
||||
});
|
||||
});
|
||||
|
||||
it("展开键盘区背景跟随 shell 背景生成 80% 透明 token", () => {
|
||||
expect(buildThemeStyle({ shellBgColor: "#192b4d", shellTextColor: "#e6f0ff" })).toContain(
|
||||
"--terminal-touch-tools-bg:rgba(25, 43, 77, 0.8)"
|
||||
);
|
||||
});
|
||||
|
||||
it("about 页面派生色板跟随当前界面主题输出", () => {
|
||||
const style = buildThemeStyle({
|
||||
uiBgColor: "#102030",
|
||||
uiTextColor: "#E0F0FF",
|
||||
uiAccentColor: "#55CCFF",
|
||||
uiBtnColor: "#88AACC"
|
||||
});
|
||||
|
||||
expect(style).toContain("--about-bg:#102030");
|
||||
expect(style).toContain("--about-text-strong:#E0F0FF");
|
||||
expect(style).toContain("--about-accent:#55CCFF");
|
||||
expect(style).toContain("--surface:");
|
||||
expect(style).toContain("--surface-border:");
|
||||
expect(style).toContain("--about-surface:");
|
||||
});
|
||||
});
|
||||
142
apps/miniprogram/utils/themedIcons.js
Normal file
142
apps/miniprogram/utils/themedIcons.js
Normal file
@@ -0,0 +1,142 @@
|
||||
/* global require, module */
|
||||
|
||||
const { toSvgDataUri } = require("./svgDataUri");
|
||||
const { ICON_SVG_SOURCES } = require("./iconSvgSources");
|
||||
|
||||
const DEFAULT_UI_BUTTON_COLOR = "#ADB9CD";
|
||||
const DEFAULT_UI_ACCENT_COLOR = "#5BD2FF";
|
||||
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
||||
const BUTTON_ICON_NAMES = Object.freeze([
|
||||
"about",
|
||||
"add",
|
||||
"ai",
|
||||
"back",
|
||||
"cancel",
|
||||
"clear",
|
||||
"codex",
|
||||
"config",
|
||||
"connect",
|
||||
"copy",
|
||||
"create",
|
||||
"delete",
|
||||
"log",
|
||||
"plugins",
|
||||
"right",
|
||||
"recordmanager",
|
||||
"save",
|
||||
"search",
|
||||
"selectall",
|
||||
"share",
|
||||
"shell",
|
||||
"serverlist"
|
||||
]);
|
||||
const buttonIconCache = Object.create(null);
|
||||
const activeButtonIconCache = Object.create(null);
|
||||
const accentButtonIconCache = Object.create(null);
|
||||
|
||||
function normalizeThemeColor(value, fallback) {
|
||||
const normalized = String(value || "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
return HEX_COLOR_PATTERN.test(normalized) ? normalized : fallback;
|
||||
}
|
||||
|
||||
function toCamelIconName(name) {
|
||||
return String(name || "").replace(/-([a-zA-Z0-9])/g, (_, segment) => segment.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* 按钮图标在 `image` 节点里渲染时,真正可见的是 SVG 自身的 fill/stroke。
|
||||
* 这里只替换十六进制色值,保留 `fill="none"`、opacity、规则属性等结构不变。
|
||||
*/
|
||||
function tintSvgMarkup(svg, color) {
|
||||
return String(svg || "").replace(
|
||||
/\b(fill|stroke)="#[0-9a-fA-F]{3,8}"/g,
|
||||
(_match, attribute) => `${attribute}="${color}"`
|
||||
);
|
||||
}
|
||||
|
||||
function buildIconVariantMap(names, color, cache) {
|
||||
const cacheKey = normalizeThemeColor(color, DEFAULT_UI_BUTTON_COLOR);
|
||||
if (cache[cacheKey]) {
|
||||
return cache[cacheKey];
|
||||
}
|
||||
|
||||
const iconMap = {};
|
||||
names.forEach((name) => {
|
||||
const source = ICON_SVG_SOURCES[name];
|
||||
if (!source) return;
|
||||
const dataUri = toSvgDataUri(tintSvgMarkup(source, cacheKey));
|
||||
iconMap[name] = dataUri;
|
||||
const camelName = toCamelIconName(name);
|
||||
if (camelName !== name) {
|
||||
iconMap[camelName] = dataUri;
|
||||
}
|
||||
});
|
||||
cache[cacheKey] = iconMap;
|
||||
return iconMap;
|
||||
}
|
||||
|
||||
function buildButtonIconMap(settings) {
|
||||
const source = settings && typeof settings === "object" ? settings : {};
|
||||
return buildIconVariantMap(BUTTON_ICON_NAMES, source.uiBtnColor, buttonIconCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接态按钮需要落在高饱和背景上:
|
||||
* 1. 图标本体改用界面前景色,避免继续沿用按钮灰而发闷;
|
||||
* 2. 目前只给 connect / shell 等“连接态按钮”消费,但实现保持通用,后续可复用。
|
||||
*/
|
||||
function buildActiveButtonIconMap(settings) {
|
||||
const source = settings && typeof settings === "object" ? settings : {};
|
||||
return buildIconVariantMap(BUTTON_ICON_NAMES, source.uiTextColor, activeButtonIconCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* 强调态按钮图标用于触摸反馈:
|
||||
* 1. 直接把 SVG 的 fill/stroke 换成强调色,避免只能靠背景变化;
|
||||
* 2. 优先使用 UI 强调色,没有时退回默认强调蓝。
|
||||
*/
|
||||
function buildAccentButtonIconMap(settings) {
|
||||
const source = settings && typeof settings === "object" ? settings : {};
|
||||
return buildIconVariantMap(
|
||||
BUTTON_ICON_NAMES,
|
||||
source.uiAccentColor || source.shellAccentColor || DEFAULT_UI_ACCENT_COLOR,
|
||||
accentButtonIconCache
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面通常会同时消费常态 / 激活态 / 强调态三组按钮图标:
|
||||
* 1. 统一在这里聚合,避免每个页面各自重复构造;
|
||||
* 2. 返回值字段名直接对齐页面 data,便于 `setData` 复用。
|
||||
*/
|
||||
function buildButtonIconThemeMaps(settings) {
|
||||
return {
|
||||
icons: buildButtonIconMap(settings),
|
||||
activeIcons: buildActiveButtonIconMap(settings),
|
||||
accentIcons: buildAccentButtonIconMap(settings)
|
||||
};
|
||||
}
|
||||
|
||||
function extractIconName(pathLike) {
|
||||
const raw = String(pathLike || "");
|
||||
const match = raw.match(/\/assets\/icons\/([a-zA-Z0-9-]+)\.svg/);
|
||||
return match ? match[1] : "";
|
||||
}
|
||||
|
||||
function resolveButtonIcon(pathLike, iconMap) {
|
||||
const name = extractIconName(pathLike);
|
||||
if (!name) return pathLike;
|
||||
if (!iconMap) return pathLike;
|
||||
return iconMap[name] || iconMap[toCamelIconName(name)] || pathLike;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildActiveButtonIconMap,
|
||||
buildAccentButtonIconMap,
|
||||
buildButtonIconMap,
|
||||
buildButtonIconThemeMaps,
|
||||
resolveButtonIcon,
|
||||
tintSvgMarkup
|
||||
};
|
||||
263
apps/miniprogram/utils/ttsGateway.js
Normal file
263
apps/miniprogram/utils/ttsGateway.js
Normal file
@@ -0,0 +1,263 @@
|
||||
/* global require, module, clearTimeout, setTimeout */
|
||||
|
||||
const { ensureSyncAuthToken, resolveSyncBaseUrl, requestJson } = require("./syncAuth");
|
||||
|
||||
const TTS_REQUEST_TIMEOUT_MS = 15000;
|
||||
const TTS_GENERATION_TIMEOUT_MS = 6 * 60 * 1000;
|
||||
const TTS_STATUS_POLL_INTERVAL_MS = 700;
|
||||
const TTS_LOCAL_CACHE_PREFIX = "tts-cache-";
|
||||
const KNOWN_TTS_MESSAGES = new Set([
|
||||
"当前没有可播报内容",
|
||||
"当前内容不适合播报",
|
||||
"播报文本过长",
|
||||
"语音生成繁忙,请稍后重试",
|
||||
"语音生成超时,请稍后重试",
|
||||
"语音生成失败",
|
||||
"TTS 服务未配置"
|
||||
]);
|
||||
const KNOWN_TTS_MESSAGE_PREFIXES = ["TTS 上游鉴权或权限失败,请检查密钥、地域和账号权限"];
|
||||
|
||||
function isKnownTtsMessage(message) {
|
||||
const normalized = String(message || "").trim();
|
||||
if (!normalized) return false;
|
||||
if (KNOWN_TTS_MESSAGES.has(normalized)) return true;
|
||||
return KNOWN_TTS_MESSAGE_PREFIXES.some((prefix) => normalized.startsWith(prefix));
|
||||
}
|
||||
|
||||
function withTimeout(task, timeoutMs, timeoutMessage) {
|
||||
const waitMs = Math.max(1000, Math.round(Number(timeoutMs) || TTS_REQUEST_TIMEOUT_MS));
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error(timeoutMessage || "语音生成超时,请稍后重试"));
|
||||
}, waitMs);
|
||||
Promise.resolve(task)
|
||||
.then((value) => {
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
const waitMs = Math.max(0, Math.round(Number(ms) || 0));
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, waitMs);
|
||||
});
|
||||
}
|
||||
|
||||
function getMiniProgramFs() {
|
||||
if (typeof wx === "undefined" || typeof wx.getFileSystemManager !== "function") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return wx.getFileSystemManager();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeTtsCacheKey(cacheKey) {
|
||||
return String(cacheKey || "")
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用小程序用户目录做确定性文件缓存:
|
||||
* 1. 以服务端 `cacheKey` 命名,天然与内容一一对应;
|
||||
* 2. 命中后可直接播放本地文件,不再重复下载;
|
||||
* 3. 不依赖 `saveFile` 的全局列表,避免额外维护 saved file 配额。
|
||||
*/
|
||||
function resolveTtsLocalCachePath(cacheKey) {
|
||||
const normalizedKey = sanitizeTtsCacheKey(cacheKey);
|
||||
const userDataPath =
|
||||
typeof wx !== "undefined" && wx && wx.env && typeof wx.env.USER_DATA_PATH === "string"
|
||||
? wx.env.USER_DATA_PATH
|
||||
: "";
|
||||
if (!normalizedKey || !userDataPath) {
|
||||
return "";
|
||||
}
|
||||
return `${userDataPath}/${TTS_LOCAL_CACHE_PREFIX}${normalizedKey}.mp3`;
|
||||
}
|
||||
|
||||
function accessLocalFile(filePath) {
|
||||
const targetPath = String(filePath || "").trim();
|
||||
const fs = getMiniProgramFs();
|
||||
if (!targetPath || !fs || typeof fs.access !== "function") {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
fs.access({
|
||||
path: targetPath,
|
||||
success: () => resolve(true),
|
||||
fail: () => resolve(false)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function downloadTtsAudioToLocal(remoteAudioUrl, cacheKey, timeoutMs) {
|
||||
const targetUrl = String(remoteAudioUrl || "").trim();
|
||||
const filePath = resolveTtsLocalCachePath(cacheKey);
|
||||
if (!targetUrl || !filePath || typeof wx === "undefined" || typeof wx.downloadFile !== "function") {
|
||||
return Promise.resolve("");
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
wx.downloadFile({
|
||||
url: targetUrl,
|
||||
filePath,
|
||||
timeout: Math.max(1000, Math.round(Number(timeoutMs) || TTS_REQUEST_TIMEOUT_MS)),
|
||||
success(res) {
|
||||
const statusCode = Number(res && res.statusCode);
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
resolve(filePath);
|
||||
return;
|
||||
}
|
||||
resolve("");
|
||||
},
|
||||
fail() {
|
||||
resolve("");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function resolvePlayableAudioUrl(remoteAudioUrl, cacheKey, timeoutMs) {
|
||||
const localPath = resolveTtsLocalCachePath(cacheKey);
|
||||
if (!localPath) {
|
||||
return remoteAudioUrl;
|
||||
}
|
||||
if (await accessLocalFile(localPath)) {
|
||||
return localPath;
|
||||
}
|
||||
const downloadedPath = await downloadTtsAudioToLocal(remoteAudioUrl, cacheKey, timeoutMs);
|
||||
return downloadedPath || remoteAudioUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* gateway 生成的 TTS 音频/状态地址本质上应与小程序当前同步基地址同源:
|
||||
* 1. 若反向代理未透传 `x-forwarded-proto/host`,后端可能回出 `http://内网主机/...`;
|
||||
* 2. 小程序 `request` 能成功,不代表 `InnerAudioContext` 也能播放这个错误 origin;
|
||||
* 3. 因此仅对 `/api/miniprogram/tts/` 路径做同源归一化,强制回到当前 `gatewayUrl`。
|
||||
*/
|
||||
function normalizeGatewayTtsUrl(rawUrl, baseUrl) {
|
||||
const candidate = String(rawUrl || "").trim();
|
||||
const base = String(baseUrl || "").trim();
|
||||
if (!candidate || !base) {
|
||||
return candidate;
|
||||
}
|
||||
try {
|
||||
const baseParsed = new URL(base);
|
||||
const resolved = new URL(candidate, `${baseParsed.origin}/`);
|
||||
if (!resolved.pathname.startsWith("/api/miniprogram/tts/")) {
|
||||
return resolved.toString();
|
||||
}
|
||||
return `${baseParsed.origin}${resolved.pathname}${resolved.search}${resolved.hash}`;
|
||||
} catch {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForSynthesisReady(statusUrl, timeoutMs) {
|
||||
const deadline = Date.now() + Math.max(TTS_REQUEST_TIMEOUT_MS, Math.round(Number(timeoutMs) || TTS_GENERATION_TIMEOUT_MS));
|
||||
while (Date.now() < deadline) {
|
||||
const payload = await requestJson(statusUrl, {
|
||||
method: "GET",
|
||||
timeoutMs: TTS_REQUEST_TIMEOUT_MS
|
||||
});
|
||||
const status = payload && payload.status ? String(payload.status) : "";
|
||||
if (payload && payload.ok === true && status === "ready") {
|
||||
return;
|
||||
}
|
||||
const rawMessage = payload && payload.message ? String(payload.message) : "";
|
||||
if (status === "error") {
|
||||
throw new Error(isKnownTtsMessage(rawMessage) ? rawMessage : "语音生成失败");
|
||||
}
|
||||
const remainingMs = deadline - Date.now();
|
||||
if (remainingMs <= 0) {
|
||||
break;
|
||||
}
|
||||
await sleep(Math.min(TTS_STATUS_POLL_INTERVAL_MS, remainingMs));
|
||||
}
|
||||
throw new Error("语音生成超时,请稍后重试");
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序 TTS 请求只做两件事:
|
||||
* 1. 复用同步登录 Bearer token;
|
||||
* 2. 把短文本换成短时可播放音频 URL。
|
||||
*/
|
||||
async function synthesizeTerminalSpeech(options) {
|
||||
const input = options && typeof options === "object" ? options : {};
|
||||
const text = String(input.text || "").trim();
|
||||
if (!text) {
|
||||
throw new Error("当前没有可播报内容");
|
||||
}
|
||||
const baseUrl = resolveSyncBaseUrl();
|
||||
if (!baseUrl) {
|
||||
throw new Error("语音生成失败");
|
||||
}
|
||||
const token = await ensureSyncAuthToken();
|
||||
let response = null;
|
||||
try {
|
||||
response = await withTimeout(
|
||||
requestJson(`${baseUrl}/api/miniprogram/tts/synthesize`, {
|
||||
method: "POST",
|
||||
data: {
|
||||
text,
|
||||
scene: "codex_terminal",
|
||||
...(input.voice ? { voice: String(input.voice) } : {}),
|
||||
...(Number.isFinite(Number(input.speed)) ? { speed: Number(input.speed) } : {})
|
||||
},
|
||||
header: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${token}`
|
||||
},
|
||||
timeoutMs: TTS_REQUEST_TIMEOUT_MS
|
||||
}),
|
||||
TTS_REQUEST_TIMEOUT_MS,
|
||||
"语音生成超时,请稍后重试"
|
||||
);
|
||||
if (response && response.status === "pending") {
|
||||
const statusUrl = normalizeGatewayTtsUrl(response.statusUrl, baseUrl);
|
||||
if (!statusUrl) {
|
||||
throw new Error("语音生成失败");
|
||||
}
|
||||
await waitForSynthesisReady(statusUrl, input.timeoutMs);
|
||||
}
|
||||
} catch (error) {
|
||||
const rawMessage = error instanceof Error && error.message ? error.message : "";
|
||||
if (/timeout/i.test(rawMessage)) {
|
||||
throw new Error("语音生成超时,请稍后重试");
|
||||
}
|
||||
if (isKnownTtsMessage(rawMessage)) {
|
||||
throw new Error(rawMessage);
|
||||
}
|
||||
throw new Error("语音生成失败");
|
||||
}
|
||||
if (!response || response.ok !== true || !response.audioUrl) {
|
||||
const rawMessage = response && response.message ? String(response.message) : "";
|
||||
throw new Error(isKnownTtsMessage(rawMessage) ? rawMessage : "语音生成失败");
|
||||
}
|
||||
const remoteAudioUrl = normalizeGatewayTtsUrl(response.audioUrl, baseUrl);
|
||||
const audioUrl = await resolvePlayableAudioUrl(
|
||||
remoteAudioUrl,
|
||||
response.cacheKey,
|
||||
TTS_REQUEST_TIMEOUT_MS
|
||||
);
|
||||
return {
|
||||
cacheKey: String(response.cacheKey || ""),
|
||||
cached: response.cached === true,
|
||||
audioUrl,
|
||||
remoteAudioUrl,
|
||||
expiresAt: String(response.expiresAt || "")
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TTS_REQUEST_TIMEOUT_MS,
|
||||
synthesizeTerminalSpeech
|
||||
};
|
||||
359
apps/miniprogram/utils/ttsGateway.test.ts
Normal file
359
apps/miniprogram/utils/ttsGateway.test.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
function clearModuleCache() {
|
||||
["./ttsGateway.js", "./syncAuth.js", "./opsConfig.js"].forEach((modulePath) => {
|
||||
delete require.cache[require.resolve(modulePath)];
|
||||
});
|
||||
}
|
||||
|
||||
describe("ttsGateway", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
clearModuleCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearModuleCache();
|
||||
delete (global as typeof globalThis & { wx?: unknown }).wx;
|
||||
delete (globalThis as typeof globalThis & { __REMOTE_CONN_OPS__?: unknown }).__REMOTE_CONN_OPS__;
|
||||
});
|
||||
|
||||
it("应使用同步 token 请求 TTS synthesize 接口", async () => {
|
||||
const downloadFile = vi.fn(
|
||||
(options?: { success?: (value: { statusCode: number }) => void; filePath?: string }) => {
|
||||
options?.success?.({
|
||||
statusCode: 200
|
||||
});
|
||||
}
|
||||
);
|
||||
const wxRuntime = {
|
||||
env: {
|
||||
USER_DATA_PATH: "/userdata"
|
||||
},
|
||||
getStorageSync: vi.fn(),
|
||||
setStorageSync: vi.fn(),
|
||||
getFileSystemManager: vi.fn(() => ({
|
||||
access: vi.fn((options?: { fail?: () => void }) => {
|
||||
options?.fail?.();
|
||||
})
|
||||
})),
|
||||
login: vi.fn((options?: { success?: (value: { code: string }) => void }) => {
|
||||
options?.success?.({ code: "mock-code" });
|
||||
}),
|
||||
downloadFile,
|
||||
request: vi.fn(
|
||||
(options?: {
|
||||
url?: string;
|
||||
method?: string;
|
||||
header?: Record<string, string>;
|
||||
timeout?: number;
|
||||
success?: (value: { statusCode: number; data: Record<string, unknown> }) => void;
|
||||
}) => {
|
||||
if (String(options?.url).endsWith("/api/miniprogram/auth/login")) {
|
||||
options?.success?.({
|
||||
statusCode: 200,
|
||||
data: {
|
||||
ok: true,
|
||||
token: "sync-token",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z"
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
options?.success?.({
|
||||
statusCode: 200,
|
||||
data: {
|
||||
ok: true,
|
||||
cacheKey: "cache-1",
|
||||
cached: true,
|
||||
audioUrl: "https://gateway.example.com/api/miniprogram/tts/audio/cache-1?ticket=demo",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z"
|
||||
}
|
||||
});
|
||||
}
|
||||
)
|
||||
};
|
||||
(global as typeof globalThis & { wx: typeof wxRuntime }).wx = wxRuntime;
|
||||
(globalThis as typeof globalThis & { __REMOTE_CONN_OPS__?: unknown }).__REMOTE_CONN_OPS__ = {
|
||||
gatewayUrl: "https://gateway.example.com",
|
||||
gatewayToken: "gateway-token"
|
||||
};
|
||||
|
||||
const { synthesizeTerminalSpeech } = require("./ttsGateway.js");
|
||||
const result = await synthesizeTerminalSpeech({
|
||||
text: "请先检查配置。"
|
||||
});
|
||||
|
||||
expect(result.audioUrl).toBe("/userdata/tts-cache-cache-1.mp3");
|
||||
expect(result.remoteAudioUrl).toContain("/api/miniprogram/tts/audio/cache-1");
|
||||
expect(downloadFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://gateway.example.com/api/miniprogram/tts/audio/cache-1?ticket=demo",
|
||||
filePath: "/userdata/tts-cache-cache-1.mp3",
|
||||
timeout: 15000
|
||||
})
|
||||
);
|
||||
expect(wxRuntime.request).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://gateway.example.com/api/miniprogram/tts/synthesize",
|
||||
method: "POST",
|
||||
timeout: 15000,
|
||||
header: expect.objectContaining({
|
||||
authorization: "Bearer sync-token"
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("应在后台合成时轮询状态直到音频就绪", async () => {
|
||||
const requestCalls: Array<{ url: string; method: string; timeout?: number }> = [];
|
||||
const downloadFile = vi.fn(
|
||||
(options?: { success?: (value: { statusCode: number }) => void; filePath?: string }) => {
|
||||
options?.success?.({
|
||||
statusCode: 200
|
||||
});
|
||||
}
|
||||
);
|
||||
const wxRuntime = {
|
||||
env: {
|
||||
USER_DATA_PATH: "/userdata"
|
||||
},
|
||||
getStorageSync: vi.fn(() => ({
|
||||
token: "sync-token",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z"
|
||||
})),
|
||||
setStorageSync: vi.fn(),
|
||||
getFileSystemManager: vi.fn(() => ({
|
||||
access: vi.fn((options?: { fail?: () => void }) => {
|
||||
options?.fail?.();
|
||||
})
|
||||
})),
|
||||
login: vi.fn(),
|
||||
downloadFile,
|
||||
request: vi.fn(
|
||||
(options?: {
|
||||
url?: string;
|
||||
method?: string;
|
||||
timeout?: number;
|
||||
success?: (value: { statusCode: number; data: Record<string, unknown> }) => void;
|
||||
}) => {
|
||||
requestCalls.push({
|
||||
url: String(options?.url || ""),
|
||||
method: String(options?.method || "GET").toUpperCase(),
|
||||
timeout: options?.timeout
|
||||
});
|
||||
if (String(options?.url).endsWith("/api/miniprogram/tts/synthesize")) {
|
||||
options?.success?.({
|
||||
statusCode: 200,
|
||||
data: {
|
||||
ok: true,
|
||||
status: "pending",
|
||||
cacheKey: "cache-2",
|
||||
cached: false,
|
||||
audioUrl: "https://gateway.example.com/api/miniprogram/tts/audio/cache-2?ticket=demo",
|
||||
statusUrl: "https://gateway.example.com/api/miniprogram/tts/status/cache-2?ticket=demo",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z"
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
options?.success?.({
|
||||
statusCode: 200,
|
||||
data:
|
||||
requestCalls.filter((item) => item.url.includes("/api/miniprogram/tts/status/")).length >= 2
|
||||
? { ok: true, status: "ready" }
|
||||
: { ok: true, status: "pending" }
|
||||
});
|
||||
}
|
||||
)
|
||||
};
|
||||
(global as typeof globalThis & { wx: typeof wxRuntime }).wx = wxRuntime;
|
||||
(globalThis as typeof globalThis & { __REMOTE_CONN_OPS__?: unknown }).__REMOTE_CONN_OPS__ = {
|
||||
gatewayUrl: "https://gateway.example.com",
|
||||
gatewayToken: "gateway-token"
|
||||
};
|
||||
|
||||
const { synthesizeTerminalSpeech } = require("./ttsGateway.js");
|
||||
const result = await synthesizeTerminalSpeech({
|
||||
text: "请先检查配置。",
|
||||
timeoutMs: 10000
|
||||
});
|
||||
|
||||
expect(result.audioUrl).toBe("/userdata/tts-cache-cache-2.mp3");
|
||||
expect(result.remoteAudioUrl).toContain("/api/miniprogram/tts/audio/cache-2");
|
||||
expect(requestCalls.filter((item) => item.url.includes("/api/miniprogram/tts/status/")).length).toBe(2);
|
||||
expect(downloadFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("应把网关返回的 TTS 地址重写到当前同步基地址同源", async () => {
|
||||
const requestCalls: string[] = [];
|
||||
const downloadFile = vi.fn(
|
||||
(options?: { success?: (value: { statusCode: number }) => void; filePath?: string }) => {
|
||||
options?.success?.({
|
||||
statusCode: 200
|
||||
});
|
||||
}
|
||||
);
|
||||
const wxRuntime = {
|
||||
env: {
|
||||
USER_DATA_PATH: "/userdata"
|
||||
},
|
||||
getStorageSync: vi.fn(() => ({
|
||||
token: "sync-token",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z"
|
||||
})),
|
||||
setStorageSync: vi.fn(),
|
||||
getFileSystemManager: vi.fn(() => ({
|
||||
access: vi.fn((options?: { fail?: () => void }) => {
|
||||
options?.fail?.();
|
||||
})
|
||||
})),
|
||||
login: vi.fn(),
|
||||
downloadFile,
|
||||
request: vi.fn(
|
||||
(options?: {
|
||||
url?: string;
|
||||
success?: (value: { statusCode: number; data: Record<string, unknown> }) => void;
|
||||
}) => {
|
||||
requestCalls.push(String(options?.url || ""));
|
||||
if (String(options?.url).endsWith("/api/miniprogram/tts/synthesize")) {
|
||||
options?.success?.({
|
||||
statusCode: 200,
|
||||
data: {
|
||||
ok: true,
|
||||
status: "pending",
|
||||
cacheKey: "cache-3",
|
||||
cached: false,
|
||||
audioUrl: "http://127.0.0.1:8787/api/miniprogram/tts/audio/cache-3?ticket=demo",
|
||||
statusUrl: "http://127.0.0.1:8787/api/miniprogram/tts/status/cache-3?ticket=demo",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z"
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
options?.success?.({
|
||||
statusCode: 200,
|
||||
data: { ok: true, status: "ready" }
|
||||
});
|
||||
}
|
||||
)
|
||||
};
|
||||
(global as typeof globalThis & { wx: typeof wxRuntime }).wx = wxRuntime;
|
||||
(globalThis as typeof globalThis & { __REMOTE_CONN_OPS__?: unknown }).__REMOTE_CONN_OPS__ = {
|
||||
gatewayUrl: "https://gateway.example.com",
|
||||
gatewayToken: "gateway-token"
|
||||
};
|
||||
|
||||
const { synthesizeTerminalSpeech } = require("./ttsGateway.js");
|
||||
const result = await synthesizeTerminalSpeech({
|
||||
text: "请先检查配置。"
|
||||
});
|
||||
|
||||
expect(result.audioUrl).toBe("/userdata/tts-cache-cache-3.mp3");
|
||||
expect(result.remoteAudioUrl).toBe("https://gateway.example.com/api/miniprogram/tts/audio/cache-3?ticket=demo");
|
||||
expect(requestCalls).toContain("https://gateway.example.com/api/miniprogram/tts/status/cache-3?ticket=demo");
|
||||
expect(downloadFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://gateway.example.com/api/miniprogram/tts/audio/cache-3?ticket=demo",
|
||||
filePath: "/userdata/tts-cache-cache-3.mp3"
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("本地已命中 TTS 缓存时应直接复用,不再重复下载", async () => {
|
||||
const downloadFile = vi.fn();
|
||||
const wxRuntime = {
|
||||
env: {
|
||||
USER_DATA_PATH: "/userdata"
|
||||
},
|
||||
getStorageSync: vi.fn(() => ({
|
||||
token: "sync-token",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z"
|
||||
})),
|
||||
setStorageSync: vi.fn(),
|
||||
getFileSystemManager: vi.fn(() => ({
|
||||
access: vi.fn((options?: { success?: () => void }) => {
|
||||
options?.success?.();
|
||||
})
|
||||
})),
|
||||
login: vi.fn(),
|
||||
downloadFile,
|
||||
request: vi.fn(
|
||||
(options?: {
|
||||
success?: (value: { statusCode: number; data: Record<string, unknown> }) => void;
|
||||
}) => {
|
||||
options?.success?.({
|
||||
statusCode: 200,
|
||||
data: {
|
||||
ok: true,
|
||||
cacheKey: "cache-4",
|
||||
cached: true,
|
||||
audioUrl: "https://gateway.example.com/api/miniprogram/tts/audio/cache-4?ticket=demo",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z"
|
||||
}
|
||||
});
|
||||
}
|
||||
)
|
||||
};
|
||||
(global as typeof globalThis & { wx: typeof wxRuntime }).wx = wxRuntime;
|
||||
(globalThis as typeof globalThis & { __REMOTE_CONN_OPS__?: unknown }).__REMOTE_CONN_OPS__ = {
|
||||
gatewayUrl: "https://gateway.example.com",
|
||||
gatewayToken: "gateway-token"
|
||||
};
|
||||
|
||||
const { synthesizeTerminalSpeech } = require("./ttsGateway.js");
|
||||
const result = await synthesizeTerminalSpeech({
|
||||
text: "请先检查配置。"
|
||||
});
|
||||
|
||||
expect(result.audioUrl).toBe("/userdata/tts-cache-cache-4.mp3");
|
||||
expect(downloadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("应保留上游鉴权或权限失败提示", async () => {
|
||||
const wxRuntime = {
|
||||
env: {
|
||||
USER_DATA_PATH: "/userdata"
|
||||
},
|
||||
getStorageSync: vi.fn(() => ({
|
||||
token: "sync-token",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z"
|
||||
})),
|
||||
setStorageSync: vi.fn(),
|
||||
getFileSystemManager: vi.fn(() => ({
|
||||
access: vi.fn((options?: { fail?: () => void }) => {
|
||||
options?.fail?.();
|
||||
})
|
||||
})),
|
||||
login: vi.fn(),
|
||||
downloadFile: vi.fn(),
|
||||
request: vi.fn(
|
||||
(options?: {
|
||||
success?: (value: { statusCode: number; data: Record<string, unknown> }) => void;
|
||||
}) => {
|
||||
options?.success?.({
|
||||
statusCode: 502,
|
||||
data: {
|
||||
ok: false,
|
||||
message:
|
||||
"TTS 上游鉴权或权限失败,请检查密钥、地域和账号权限(AuthFailure.InvalidSecretId: The SecretId is not found)"
|
||||
}
|
||||
});
|
||||
}
|
||||
)
|
||||
};
|
||||
(global as typeof globalThis & { wx: typeof wxRuntime }).wx = wxRuntime;
|
||||
(globalThis as typeof globalThis & { __REMOTE_CONN_OPS__?: unknown }).__REMOTE_CONN_OPS__ = {
|
||||
gatewayUrl: "https://gateway.example.com",
|
||||
gatewayToken: "gateway-token"
|
||||
};
|
||||
|
||||
const { synthesizeTerminalSpeech } = require("./ttsGateway.js");
|
||||
|
||||
await expect(
|
||||
synthesizeTerminalSpeech({
|
||||
text: "请先检查配置。"
|
||||
})
|
||||
).rejects.toThrow("TTS 上游鉴权或权限失败,请检查密钥、地域和账号权限");
|
||||
});
|
||||
});
|
||||
82
apps/miniprogram/utils/ttsSettings.js
Normal file
82
apps/miniprogram/utils/ttsSettings.js
Normal file
@@ -0,0 +1,82 @@
|
||||
/* global module */
|
||||
|
||||
/**
|
||||
* 终端播报长度配置拆成“总量”和“单段”两层:
|
||||
* 1. 总量决定一轮回答最多取多少正文;
|
||||
* 2. 单段决定每次送给 TTS 的请求大小,用来压低首段等待时延;
|
||||
* 3. 单段限制必须保守落在 gateway 当前长度校验之内;
|
||||
* 4. “分片长度”只控制单段字符窗口,实际仍会再受 UTF-8 byte 上限兜底。
|
||||
*/
|
||||
const DEFAULT_TTS_SPEAKABLE_MAX_CHARS = 500;
|
||||
const MIN_TTS_SPEAKABLE_MAX_CHARS = 120;
|
||||
const MAX_TTS_SPEAKABLE_MAX_CHARS = 1200;
|
||||
const DEFAULT_TTS_SEGMENT_MAX_CHARS = 80;
|
||||
const MIN_TTS_SEGMENT_MAX_CHARS = 40;
|
||||
const MAX_TTS_SEGMENT_MAX_CHARS = 200;
|
||||
const TTS_SEGMENT_MAX_CHARS = DEFAULT_TTS_SEGMENT_MAX_CHARS;
|
||||
const TTS_SEGMENT_MAX_UTF8_BYTES = DEFAULT_TTS_SEGMENT_MAX_CHARS * 3;
|
||||
|
||||
function normalizeTtsSpeakableMaxChars(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return DEFAULT_TTS_SPEAKABLE_MAX_CHARS;
|
||||
}
|
||||
const normalized = Math.round(parsed);
|
||||
if (normalized < MIN_TTS_SPEAKABLE_MAX_CHARS) {
|
||||
return MIN_TTS_SPEAKABLE_MAX_CHARS;
|
||||
}
|
||||
if (normalized > MAX_TTS_SPEAKABLE_MAX_CHARS) {
|
||||
return MAX_TTS_SPEAKABLE_MAX_CHARS;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeTtsSegmentMaxChars(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return DEFAULT_TTS_SEGMENT_MAX_CHARS;
|
||||
}
|
||||
const normalized = Math.round(parsed);
|
||||
if (normalized < MIN_TTS_SEGMENT_MAX_CHARS) {
|
||||
return MIN_TTS_SEGMENT_MAX_CHARS;
|
||||
}
|
||||
if (normalized > MAX_TTS_SEGMENT_MAX_CHARS) {
|
||||
return MAX_TTS_SEGMENT_MAX_CHARS;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 总量截断只负责控制“这一轮最多保留多少正文”,并不直接对应单次网关请求。
|
||||
* 对中文按 3 bytes/字估算,可让 500 字正文完整进入后续分段流程。
|
||||
*/
|
||||
function resolveTtsSpeakableUtf8ByteLimit(maxChars) {
|
||||
const normalized = normalizeTtsSpeakableMaxChars(maxChars);
|
||||
return Math.max(TTS_SEGMENT_MAX_UTF8_BYTES, Math.min(normalized * 3, MAX_TTS_SPEAKABLE_MAX_CHARS * 3));
|
||||
}
|
||||
|
||||
/**
|
||||
* 单段 byte 限制始终卡在安全阈值内:
|
||||
* 1. 默认 80 字对应 240 bytes;
|
||||
* 2. 即使用户把分片长度调大,最终也不会超过 360 bytes;
|
||||
* 3. 这样可避免单段再次撞到网关 450 bytes 的校验。
|
||||
*/
|
||||
function resolveTtsSegmentUtf8ByteLimit(maxChars) {
|
||||
const normalized = normalizeTtsSegmentMaxChars(maxChars);
|
||||
return Math.max(TTS_SEGMENT_MAX_UTF8_BYTES, Math.min(normalized * 3, 360));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_TTS_SPEAKABLE_MAX_CHARS,
|
||||
MIN_TTS_SPEAKABLE_MAX_CHARS,
|
||||
MAX_TTS_SPEAKABLE_MAX_CHARS,
|
||||
DEFAULT_TTS_SEGMENT_MAX_CHARS,
|
||||
MIN_TTS_SEGMENT_MAX_CHARS,
|
||||
MAX_TTS_SEGMENT_MAX_CHARS,
|
||||
TTS_SEGMENT_MAX_CHARS,
|
||||
TTS_SEGMENT_MAX_UTF8_BYTES,
|
||||
normalizeTtsSpeakableMaxChars,
|
||||
normalizeTtsSegmentMaxChars,
|
||||
resolveTtsSpeakableUtf8ByteLimit,
|
||||
resolveTtsSegmentUtf8ByteLimit
|
||||
};
|
||||
Reference in New Issue
Block a user