Files
codex-market-app/app.js
T
2026-07-15 18:01:56 +08:00

1439 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// app.js —— 前端逻辑,只经 window.apic.* 与主进程通信。
// 绝不出现市场地址 / 密钥(那些只在主进程)。
"use strict";
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
// 注意:preload 已在 window 上定义了不可配置的全局属性 `api`,
// 顶层再用 const/let 声明同名会抛 "already been declared",故用 window.api 起个别名。
const apic = window.api;
// —— 工具 ——
function humanSize(n) {
const u = ["B", "K", "M", "G"];
let x = n;
for (const unit of u) {
if (x < 1024) return unit === "B" ? `${Math.round(x)}${unit}` : `${x.toFixed(1)}${unit}`;
x /= 1024;
}
return `${x.toFixed(1)}T`;
}
function esc(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
let toastTimer = null;
function toast(msg, kind = "") {
const t = $("#toast");
t.textContent = msg;
t.className = `toast ${kind}`;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.add("hidden"), 4200);
}
function busy(btn, on, textWhenBusy) {
if (!btn) return;
if (on) {
btn.dataset.label = btn.textContent;
btn.textContent = textWhenBusy || "处理中…";
btn.disabled = true;
} else {
btn.textContent = btn.dataset.label || btn.textContent;
btn.disabled = false;
}
}
// —— 顶部广告:点击用系统浏览器打开官方推荐中转 ——
const promoEl = document.querySelector(".promo");
if (promoEl) {
promoEl.addEventListener("click", (e) => {
e.preventDefault();
apic.openExternal(promoEl.dataset.open);
});
}
// —— 标签切换 ——
$$(".tab").forEach((tab) => {
tab.addEventListener("click", () => {
$$(".tab").forEach((t) => t.classList.remove("active"));
$$(".panel").forEach((p) => p.classList.remove("active"));
tab.classList.add("active");
$(`#tab-${tab.dataset.tab}`).classList.add("active");
});
});
$$(".subtab").forEach((st) => {
st.addEventListener("click", () => {
$$(".subtab").forEach((s) => s.classList.remove("active"));
$$(".subpanel").forEach((s) => s.classList.remove("active"));
st.classList.add("active");
$(`#sub-${st.dataset.sub}`).classList.add("active");
});
});
// —— 日志抽屉 ——
$("#log-toggle").addEventListener("click", () => {
const p = $("#log-panel");
p.classList.toggle("hidden");
$("#log-caret").textContent = p.classList.contains("hidden") ? "▲" : "▼";
});
apic.onLog((msg) => {
const out = $("#log-out");
out.textContent += msg + "\n";
$("#log-panel").scrollTop = $("#log-panel").scrollHeight;
});
// ===========================================================================
// ① 插件市场
// ===========================================================================
let skillCat = null; // 完整 catalog {total, categories, skills}
let storeCat = "__all__"; // 当前分类(__all__ / __hot__ / 中文分类名)
let storeSort = "hot"; // hot / downloads / stars
let storeShown = 0; // 已渲染数量(分页)
const STORE_PAGE = 60;
const installed = new Set(JSON.parse(localStorage.getItem("installedSkills") || "[]"));
// 分类图标(与网页语义对应)
const CAT_ICON = {
"全部": "🗂️", "热门": "🔥", "API": "🔌", "办公": "📊", "设计": "🎨",
"效率": "⚡", "内容媒体": "📝", "文档处理": "📄", "开发": "🛠️",
"教育科研": "🎓", "图表": "📈", "客服": "🎧", "后端": "🗄️",
"数据分析": "📊", "数据库": "🗃️", "产品管理": "📋", "运维": "⚙️",
"财务": "💰", "健康": "🩺", "前端": "🖥️", "Git": "🔀", "人力资源": "👥",
"图片生成": "🖼️", "法律": "⚖️", "市场": "📣", "移动端": "📱", "运营": "🚀",
"支付": "💳", "项目管理": "🗓️", "React": "⚛️", "房产": "🏠", "销售": "🤝",
"测试": "🧪", "AI增强": "🤖", "其它": "🧩",
};
const AGENT_CLASS = { Claude: "claude", Codex: "codex", OpenClaw: "openclaw" };
function numify(s) {
if (!s) return 0;
const n = parseFloat(String(s).replace(/,/g, ""));
if (isNaN(n)) return 0;
if (/k/i.test(s)) return n * 1e3;
if (/m/i.test(s)) return n * 1e6;
return n;
}
// 某个技能在「我的 gitea 仓库」里的文件页地址
function skillGitUrl(s) {
// 浏览走 update 域名(管理登录会话在此),clone 用 up 域名(git 直连)
const base = (skillCat.repo || "")
.replace(/\.git$/, "")
.replace("up.tokendun.cc", "update.tokendun.cc"); // https://update.tokendun.cc/mhgmh/skills
if (!base) return "https://hub.cocoloop.cn/popular";
if (!s.path) return base; // 无本地路径时退回仓库首页
// 中文分类目录需 URL 编码,路径分隔符保留
const enc = s.path.split("/").map(encodeURIComponent).join("/");
return `${base}/src/branch/main/${enc}`;
}
// 当前筛选后的技能列表
function storeFiltered() {
const q = $("#market-search").value.trim().toLowerCase();
let list = skillCat.skills;
if (storeCat === "__hot__") {
list = list.filter((s) => numify(s.dl) >= 50000 || numify(s.fav) >= 200000);
} else if (storeCat !== "__all__") {
list = list.filter((s) => s.category === storeCat || (s.cnTags || []).includes(storeCat));
}
if (q) {
list = list.filter((s) =>
(s.name || "").toLowerCase().includes(q) ||
(s.author || "").toLowerCase().includes(q) ||
(s.subtitle || "").toLowerCase().includes(q) ||
(s.brief || "").toLowerCase().includes(q)
);
}
if (storeSort === "downloads") list = [...list].sort((a, b) => numify(b.dl) - numify(a.dl));
else if (storeSort === "stars") list = [...list].sort((a, b) => numify(b.stars) - numify(a.stars));
// hot:保持 catalog 原序(已按下载量排)
return list;
}
function agentBadges(s) {
const a = (s.agents || []).map(
(x) => `<span class="agent-badge ${AGENT_CLASS[x] || ""}">${esc(x)}</span>`
).join("");
const mm = s.mm ? '<span class="agent-badge mm">🎨 多模态</span>' : "";
return `<div class="sk-agents"><span class="lab">适配</span>${a}${mm}</div>`;
}
function skillCardHTML(s) {
const sec = (s.sec || "").toUpperCase();
const secBadge = /^[SAB]$/.test(sec) ? `<div class="sk-sec ${sec}" title="安全评级 ${sec}">${sec}</div>` : "";
const isIn = installed.has(String(s.id));
const meta = [
`<span>⬇ ${esc(s.dl || "0")}</span>`,
s.stars ? `<span>★ ${esc(s.stars)}</span>` : "",
s.fav ? `<span>♥ ${esc(s.fav)}</span>` : "",
s.cred ? `<span class="cred">${esc(s.cred)}</span>` : "",
].join("");
return `
<div class="skill-card" data-id="${esc(String(s.id))}">
<div class="sk-top">
<div class="sk-ico">${esc(s.icon || "🧩")}</div>
<div class="sk-headings">
<div class="sk-name">${esc(s.name)}</div>
<div class="sk-author">by ${esc(s.author || "未知")}</div>
</div>
${secBadge}
</div>
<div class="sk-subtitle">${esc(s.subtitle || s.name)}</div>
<div class="sk-brief">${esc(s.brief || "")}</div>
${agentBadges(s)}
<div class="sk-meta">${meta}</div>
<div class="sk-foot">
<button class="btn primary small" data-install="${esc(String(s.id))}">${isIn ? "重新安装" : "安装"}</button>
<button class="btn ghost small" data-detail="${esc(String(s.id))}">详情</button>
</div>
</div>`;
}
function renderStore(reset = true) {
const grid = $("#market-grid");
const empty = $("#market-empty");
const more = $("#store-more");
const list = storeFiltered();
if (reset) { storeShown = 0; grid.innerHTML = ""; }
if (!list.length) {
grid.innerHTML = "";
empty.classList.remove("hidden");
empty.textContent = "没有匹配的技能,换个关键词或分类试试。";
more.classList.add("hidden");
return;
}
empty.classList.add("hidden");
const next = list.slice(storeShown, storeShown + STORE_PAGE);
grid.insertAdjacentHTML("beforeend", next.map(skillCardHTML).join(""));
storeShown += next.length;
// 副标题
$("#store-cat-title").textContent =
storeCat === "__all__" ? "全部技能" : storeCat === "__hot__" ? "热门技能" : storeCat;
$("#store-cat-sub").textContent =
`${list.length} 个技能 · 通用 SKILL.md 标准,适配 Claude · Codex · OpenClaw`;
// 加载更多
if (storeShown < list.length) {
more.classList.remove("hidden");
more.innerHTML = `已显示 ${storeShown} / ${list.length} · <button class="btn ghost small" id="store-more-btn">加载更多</button>`;
$("#store-more-btn").addEventListener("click", () => renderStore(false));
} else {
more.classList.remove("hidden");
more.innerHTML = `<span class="muted small">已全部显示 · 共 ${list.length} 个</span>`;
}
bindStoreCards();
}
function bindStoreCards() {
const grid = $("#market-grid");
grid.querySelectorAll("[data-install]").forEach((btn) => {
if (btn._bound) return; btn._bound = true;
btn.addEventListener("click", () => installSkill(btn.dataset.install, btn));
});
grid.querySelectorAll("[data-detail]").forEach((btn) => {
if (btn._bound) return; btn._bound = true;
btn.addEventListener("click", () => openSkillDetail(btn.dataset.detail));
});
}
function renderStoreNav() {
const nav = $("#store-nav");
$("#store-total").textContent = skillCat.total.toLocaleString();
const item = (key, name, count) => {
const active = storeCat === key ? " active" : "";
const c = count != null ? `<span class="nav-count">${count.toLocaleString()}</span>` : "";
return `<div class="nav-item${active}" data-cat="${esc(key)}">
<span class="nav-ico">${CAT_ICON[name] || "•"}</span>
<span class="nav-name">${esc(name)}</span>${c}</div>`;
};
let html = "";
html += `<div class="store-nav-group">精选</div>`;
html += item("__all__", "全部", skillCat.total);
html += item("__hot__", "热门", null);
html += `<div class="store-nav-group">全部分类</div>`;
for (const c of skillCat.categories) html += item(c.name, c.name, c.count);
nav.innerHTML = html;
nav.querySelectorAll(".nav-item").forEach((el) =>
el.addEventListener("click", () => {
storeCat = el.dataset.cat;
nav.querySelectorAll(".nav-item").forEach((x) => x.classList.remove("active"));
el.classList.add("active");
$("#market-grid").scrollTop = 0;
renderStore(true);
})
);
}
async function installSkill(id, btn) {
const s = skillCat.skills.find((x) => String(x.id) === String(id));
if (!s) return;
busy(btn, true, "安装中…");
btn.classList.add("installing");
try {
const r = await apic.skillInstall(s);
installed.add(String(s.id));
localStorage.setItem("installedSkills", JSON.stringify([...installed]));
toast(`已安装 ${s.name} ✅(${r.files} 个文件)重启 Codex 生效`, "ok");
btn.textContent = "已安装 ✓";
btn.dataset.label = "已安装 ✓";
} catch (e) {
toast(`安装失败:${e.message || e}`, "err");
busy(btn, false);
} finally {
btn.classList.remove("installing");
}
}
function openSkillDetail(id) {
const s = skillCat.skills.find((x) => String(x.id) === String(id));
if (!s) return;
$("#modal-title").textContent = "技能详情";
const tags = (s.cnTags || []).map((t) => `<span class="sd-tag">${esc(t)}</span>`).join("");
$("#modal-body").innerHTML = `
<div class="sd-hero">
<div class="sk-ico">${esc(s.icon || "🧩")}</div>
<div>
<h3>${esc(s.name)}</h3>
<p class="muted small" style="margin:3px 0 0">by ${esc(s.author || "未知")} · 分类 ${esc(s.category)}</p>
</div>
</div>
<p style="font-weight:600;font-size:14px">${esc(s.subtitle || "")}</p>
<p>${esc(s.brief || "(暂无简介)")}</p>
${tags ? `<div class="sd-tags">${tags}</div>` : ""}
<div class="sd-stats">
<span>⬇ 下载 ${esc(s.dl || "0")}</span>
${s.stars ? `<span>★ 星标 ${esc(s.stars)}</span>` : ""}
${s.fav ? `<span>♥ 收藏 ${esc(s.fav)}</span>` : ""}
${s.sec ? `<span>🛡 安全级 ${esc(s.sec)}</span>` : ""}
${s.cred ? `<span>可信度 ${esc(s.cred)}</span>` : ""}
</div>
<h4>适配 AI 模型 / Agent</h4>
${agentBadges(s)}
<p class="muted small" style="margin-top:8px">${esc(skillCat.model_note || "SKILL.md 标准技能")}${s.mm ? ";本技能涉及图片/视觉,建议搭配多模态强模型。" : "。"}</p>
<div class="sd-install-row">
<button class="btn primary" id="sd-install">安装到 ~/.codex/skills</button>
<button class="btn ghost" id="sd-source">在我的 Git 查看 ↗</button>
</div>`;
modalOverlay.classList.remove("hidden");
$("#sd-install").addEventListener("click", (e) => installSkill(s.id, e.currentTarget));
$("#sd-source").addEventListener("click", () => apic.openExternal(skillGitUrl(s)));
}
async function loadMarket() {
const grid = $("#market-grid");
const empty = $("#market-empty");
empty.classList.add("hidden");
grid.innerHTML = '<div class="empty"><div class="spinner"></div>正在加载技能商店…</div>';
try {
skillCat = await apic.skillCatalog();
renderStoreNav();
renderStore(true);
} catch (e) {
grid.innerHTML = "";
empty.classList.remove("hidden");
empty.textContent = `加载失败:${e.message || e}`;
}
}
let storeSearchTimer = null;
$("#market-search").addEventListener("input", () => {
clearTimeout(storeSearchTimer);
storeSearchTimer = setTimeout(() => renderStore(true), 180);
});
$("#store-sort").querySelectorAll(".seg").forEach((s) => {
s.addEventListener("click", () => {
$("#store-sort").querySelectorAll(".seg").forEach((x) => x.classList.remove("active"));
s.classList.add("active");
storeSort = s.dataset.sort;
renderStore(true);
});
});
// ===========================================================================
// 通用勾选列表渲染(本机导出 / 拉取还原共用)
// ===========================================================================
function renderChecklist(container, items, emptyEl, emptyMsg) {
if (!items.length) {
container.innerHTML = "";
emptyEl.classList.remove("hidden");
emptyEl.textContent = emptyMsg;
return;
}
emptyEl.classList.add("hidden");
const groups = {};
for (const it of items) (groups[it.category] ||= []).push(it);
let html = "";
for (const cat of Object.keys(groups)) {
html += `<div class="cl-group">${esc(cat)}</div>`;
for (const it of groups[cat]) {
html += `
<label class="cl-item">
<input type="checkbox" value="${esc(it.rel)}" />
<span class="cl-name">${esc(it.name)}</span>
<span class="cl-src">${esc(it.source)}</span>
<span class="cl-size">${humanSize(it.size || 0)}</span>
</label>`;
}
}
container.innerHTML = html;
}
function checkedRels(container) {
return $$(`#${container.id} input[type=checkbox]:checked`).map((c) => c.value);
}
// ===========================================================================
// ② 备份 —— 从本机导出 / 上传
// ===========================================================================
let localItems = [];
async function scanLocal() {
const list = $("#local-list");
const empty = $("#local-empty");
empty.classList.add("hidden");
list.innerHTML = '<div class="empty"><div class="spinner"></div>扫描本机 ~/.codex…</div>';
try {
localItems = await apic.scanLocal();
renderChecklist(list, localItems, empty, "本机 ~/.codex 下暂时没有插件或技能。");
} catch (e) {
list.innerHTML = "";
empty.classList.remove("hidden");
empty.textContent = `扫描失败:${e.message || e}`;
}
}
$("#scan-refresh").addEventListener("click", scanLocal);
$("#btn-export").addEventListener("click", async (ev) => {
const rels = checkedRels($("#local-list"));
if (!rels.length) return toast("请先勾选要导出的项", "err");
const dir = await apic.pickDir();
if (!dir) return;
const btn = ev.currentTarget;
busy(btn, true, "导出中…");
try {
const r = await apic.packLocal(rels, dir);
toast(`已导出到:${r.path}`, "ok");
} catch (e) {
toast(`导出失败:${e.message || e}`, "err");
} finally {
busy(btn, false);
}
});
$("#btn-upload").addEventListener("click", async (ev) => {
const rels = checkedRels($("#local-list"));
if (!rels.length) return toast("请先勾选要上传的项", "err");
const repo = $("#repo-name").value.trim();
const token = $("#gh-token").value.trim();
const isPrivate = $("#repo-private").checked;
if (!repo) return toast("请填写仓库名", "err");
if (!token) return toast("请填写 GitHub token", "err");
const btn = ev.currentTarget;
busy(btn, true, "上传中…");
$("#log-panel").classList.remove("hidden");
$("#log-caret").textContent = "▼";
try {
const r = await apic.upload(rels, repo, token, isPrivate);
if (r.marketSkipped) {
toast(`已备份到 ${r.owner} 的仓库 ✅(未设管理员密钥,未上架市场)`, "ok");
} else {
toast(`上传成功:${r.owner},已登记 ${r.published} 个插件到市场 ✅`, "ok");
}
loadMarket();
} catch (e) {
toast(`上传失败:${e.message || e}`, "err");
} finally {
busy(btn, false);
}
});
// ===========================================================================
// ② 还原 —— 从 GitHub 拉取
// ===========================================================================
let pullItems = [];
$("#btn-pull").addEventListener("click", async (ev) => {
const repo = $("#pull-repo").value.trim();
const token = $("#pull-token").value.trim();
if (!repo) return toast("请填写仓库地址", "err");
const btn = ev.currentTarget;
const list = $("#pull-list");
const empty = $("#pull-empty");
busy(btn, true, "拉取中…");
empty.classList.add("hidden");
list.innerHTML = '<div class="empty"><div class="spinner"></div>克隆并扫描仓库…</div>';
$("#pull-actions").classList.add("hidden");
try {
pullItems = await apic.ghPull(repo, token);
renderChecklist(list, pullItems, empty, "该仓库里没找到可还原的插件或技能。");
if (pullItems.length) $("#pull-actions").classList.remove("hidden");
} catch (e) {
list.innerHTML = "";
empty.classList.remove("hidden");
empty.textContent = `拉取失败:${e.message || e}`;
} finally {
busy(btn, false);
}
});
$("#btn-restore").addEventListener("click", async (ev) => {
const rels = checkedRels($("#pull-list"));
if (!rels.length) return toast("请先勾选要还原的项", "err");
const enable = $("#pull-enable").checked;
const btn = ev.currentTarget;
busy(btn, true, "还原中…");
try {
await apic.ghRestore(rels, enable);
toast("还原完成 ✅ 重启 codex 生效", "ok");
} catch (e) {
toast(`还原失败:${e.message || e}`, "err");
} finally {
busy(btn, false);
}
});
// ===========================================================================
// 设置 —— 密钥
// ===========================================================================
async function loadSecrets() {
try {
const gh = await apic.getSecret("github");
if (gh) {
$("#set-github").value = gh;
$("#gh-token").value = gh;
$("#pull-token").value = gh;
}
const ak = await apic.getSecret("adminKey");
if (ak) $("#set-admin").value = ak;
} catch { /* 忽略 */ }
}
$("#btn-save-admin").addEventListener("click", async (ev) => {
const btn = ev.currentTarget;
busy(btn, true, "保存中…");
try {
await apic.setSecret("adminKey", $("#set-admin").value.trim());
toast("管理员密钥已保存 ✅", "ok");
} catch (e) {
toast(`保存失败:${e.message || e}`, "err");
} finally {
busy(btn, false);
}
});
$("#btn-save-secret").addEventListener("click", async (ev) => {
const val = $("#set-github").value.trim();
const btn = ev.currentTarget;
busy(btn, true, "保存中…");
try {
await apic.setSecret("github", val);
if (val) { $("#gh-token").value = val; $("#pull-token").value = val; }
toast("已保存 ✅", "ok");
} catch (e) {
toast(`保存失败:${e.message || e}`, "err");
} finally {
busy(btn, false);
}
});
// ===========================================================================
// 强制清除本机 Codex —— 检测安装位置 + 一键清干净
// ===========================================================================
const KIND_LABEL = {
data: "数据目录",
bin: "可执行文件",
"npm-package": "npm 全局包",
};
function renderPurgeList(items) {
const box = $("#purge-list");
const actions = $("#purge-actions");
if (!items || !items.length) {
box.innerHTML =
'<p class="muted small" style="margin:6px 2px">未检测到本机安装的 Codex(可能未安装,或不在常见位置)。</p>';
actions.style.display = "none";
return;
}
box.innerHTML = items
.map((it, i) => {
const tag = KIND_LABEL[it.kind] || it.kind;
return `<label class="cl-item">
<input type="checkbox" data-purge="${i}" checked />
<div style="min-width:0">
<div class="cl-name">${esc(it.label)}</div>
<div class="cl-src" style="word-break:break-all">${esc(it.path)}</div>
</div>
<span class="cl-size">${esc(tag)}${it.isDir ? " · " + esc(it.sizeText) : ""}</span>
</label>`;
})
.join("");
box._items = items;
actions.style.display = "flex";
}
async function purgeScan(btn) {
busy(btn, true, "检测中…");
try {
const items = await apic.codexDetect();
renderPurgeList(items);
toast(items.length ? `检测到 ${items.length} 个位置` : "未检测到 Codex", items.length ? "ok" : "");
} catch (e) {
toast("检测失败:" + (e.message || e), "err");
} finally {
busy(btn, false);
}
}
$("#btn-purge-scan").addEventListener("click", (ev) => purgeScan(ev.currentTarget));
$("#btn-purge-do").addEventListener("click", async (ev) => {
const box = $("#purge-list");
const items = box._items || [];
const paths = $$('#purge-list input[data-purge]')
.filter((el) => el.checked)
.map((el) => items[+el.dataset.purge]?.path)
.filter(Boolean);
if (!paths.length) {
toast("请先勾选要清除的位置", "err");
return;
}
const btn = ev.currentTarget;
busy(btn, true, "清除中…");
try {
const r = await apic.codexPurge(paths);
if (r.canceled) {
toast("已取消", "");
return;
}
const ok = r.results.filter((x) => x.ok).length;
const fail = r.results.length - ok;
toast(fail ? `清除完成:成功 ${ok},失败 ${fail}(详见运行日志)` : `已彻底清除 ${ok} 个位置 ✅ 可重新安装 Codex 了`, fail ? "err" : "ok");
await purgeScan(btn); // 刷新列表,确认已清空
} catch (e) {
toast("清除失败:" + (e.message || e), "err");
} finally {
busy(btn, false);
}
});
// ===========================================================================
// 会话记录 —— 浏览 / 搜索 / 阅读 / 导出 / 备份 / 导入
// ===========================================================================
let sessData = [];
let sessCurrent = null;
let sessLoaded = false;
function sessRender() {
const q = $("#sess-search").value.trim().toLowerCase();
const box = $("#sess-list");
const list = sessData.filter(
(s) => !q || (s.title || "").toLowerCase().includes(q) || (s.project || "").toLowerCase().includes(q)
);
if (!list.length) {
box.innerHTML = `<div class="empty">${sessData.length ? "没有匹配的对话" : "没有找到会话记录"}</div>`;
return;
}
box.innerHTML = list
.map(
(s) => `
<div class="sess-item" data-idx="${sessData.indexOf(s)}">
<div class="si-title">${esc(s.title)}</div>
<div class="si-meta"><span>${esc(s.project || "—")}</span><span>${(s.ts || "").slice(0, 10)}</span></div>
</div>`
)
.join("");
box.querySelectorAll(".sess-item").forEach((el) =>
el.addEventListener("click", () => sessOpen(+el.dataset.idx, el))
);
}
async function sessOpen(idx, el) {
const item = sessData[idx];
sessCurrent = item;
$$(".sess-item").forEach((x) => x.classList.remove("active"));
if (el) el.classList.add("active");
$("#sess-empty").classList.add("hidden");
$("#sess-conv").classList.remove("hidden");
$("#sess-conv-title").textContent = item.title;
$("#sess-messages").innerHTML = `<div class="empty"><div class="spinner"></div>读取中…</div>`;
try {
const conv = await apic.sessionRead(item.path);
const html = conv.messages
.map((m) => {
const who = m.role === "user" ? "👤 用户" : m.role === "assistant" ? "🤖 Codex" : "🔧 工具";
return `<div class="msg ${m.role}"><span class="who">${who}</span><div class="bubble">${esc(m.text)}</div></div>`;
})
.join("");
$("#sess-messages").innerHTML = html || `<div class="empty">这条对话没有可显示的消息</div>`;
} catch (e) {
$("#sess-messages").innerHTML = `<div class="empty">读取失败:${esc(e.message || e)}</div>`;
}
}
async function sessLoad() {
const box = $("#sess-list");
box.innerHTML = `<div class="empty"><div class="spinner"></div>加载中…</div>`;
try {
sessData = await apic.sessionsList(false);
sessLoaded = true;
sessRender();
} catch (e) {
box.innerHTML = `<div class="empty">加载失败:${esc(e.message || e)}</div>`;
}
}
$("#sess-search").addEventListener("input", sessRender);
$("#sess-refresh").addEventListener("click", sessLoad);
document.querySelector('.tab[data-tab="sessions"]').addEventListener("click", () => {
if (!sessLoaded) sessLoad();
});
$("#sess-export").addEventListener("click", async (ev) => {
if (!sessCurrent) return;
const btn = ev.currentTarget;
busy(btn, true, "导出中…");
try {
const r = await apic.sessionExport(sessCurrent);
if (!r.canceled) toast("已导出 ✅", "ok");
} catch (e) {
toast(`导出失败:${e.message || e}`, "err");
} finally {
busy(btn, false);
}
});
$("#sess-backup").addEventListener("click", async (ev) => {
const btn = ev.currentTarget;
busy(btn, true, "备份中…");
try {
const r = await apic.sessionsBackup();
if (!r.canceled) toast(`已备份 ${r.count} 条会话 ✅`, "ok");
} catch (e) {
toast(`备份失败:${e.message || e}`, "err");
} finally {
busy(btn, false);
}
});
$("#sess-import").addEventListener("click", async (ev) => {
const btn = ev.currentTarget;
busy(btn, true, "导入中…");
try {
const r = await apic.sessionsImport();
if (!r.canceled) {
toast(`导入完成:新增 ${r.added},跳过 ${r.skipped}`, "ok");
sessLoad();
}
} catch (e) {
toast(`导入失败:${e.message || e}`, "err");
} finally {
busy(btn, false);
}
});
apic.onProviderSyncProgress((p) => {
const box = $("#sync-progress");
const fill = $("#sync-progress-fill");
const label = $("#sync-progress-label");
box.classList.remove("hidden");
if (p.phase === "完成") {
fill.style.width = "100%";
label.textContent = "完成 ✅";
} else if (p.total > 0) {
const pct = Math.round((p.done / p.total) * 100);
fill.style.width = pct + "%";
label.textContent = `${p.phase} ${p.done}/${p.total}(${pct}%)`;
} else {
fill.style.width = "8%";
label.textContent = p.phase + "…";
}
});
$("#sync-btn").addEventListener("click", async (ev) => {
const st = await apic.providerSyncStatus();
if (st.running) {
toast("请先完全退出 Codex(Cmd+Q)再同步", "err");
return;
}
if (!confirm(`将把全部历史对话同步到当前账号(provider:${st.target}),使其在 Codex 侧栏重新显示。\n\n开始前会自动备份,可还原。确认继续?`)) return;
const btn = ev.currentTarget;
busy(btn, true, "同步中…");
$("#sync-progress").classList.remove("hidden");
$("#sync-progress-fill").style.width = "0%";
try {
const r = await apic.providerSync();
$("#sync-progress-fill").style.width = "100%";
const changedMsg =
r.changedFiles > 0
? `本次同步 ${r.changedFiles} 条`
: `全部已是最新,无需改动`;
$("#sync-progress-label").textContent = `完成 ✅ 共 ${r.totalConversations} 条会话,${changedMsg}`;
const tip =
r.changedFiles > 0
? `同步完成 ✅ 本次改写 ${r.changedFiles} 条会话。重新打开 Codex 即可看到`
: `全部 ${r.totalConversations} 条会话之前已同步过,本次无需改动 ✅`;
toast(tip, "ok");
setTimeout(() => $("#sync-progress").classList.add("hidden"), 4000);
} catch (e) {
$("#sync-progress").classList.add("hidden");
toast(`同步失败:${e.message || e}`, "err");
} finally {
busy(btn, false);
}
});
// ===========================================================================
// 会话记忆 —— 状态 / 项目开关 / 历史摘要检索
// ===========================================================================
let memStatus = null;
let memLoaded = false;
function memRenderStatus(st) {
memStatus = st;
const badge = $("#mem-state");
badge.textContent = st.enabled ? "已开启" : "已关闭";
badge.className = "pc-badge " + (st.enabled ? "skill" : "");
$("#mem-engine").textContent = st.engine + (st.pending ? ` · 待处理 ${st.pending}` : "");
$("#mem-count").textContent = `记忆库共 ${st.total} 条摘要`;
const tg = $("#mem-toggle");
tg.textContent = st.enabled ? "关闭记忆" : "开启记忆";
tg.className = "btn " + (st.enabled ? "ghost" : "primary");
// 项目列表(开关 + 打开 HANDOFF)
const box = $("#mem-projects");
if (!st.projects.length) {
box.innerHTML = `<div class="empty">还没有任何项目的会话被摘要。去 Codex 里聊一会儿,静默 ${st.idleMinutes} 分钟后这里就有了。</div>`;
} else {
box.innerHTML = st.projects
.map(
(p) => `
<div class="cl-item">
<input type="checkbox" ${p.enabled ? "checked" : ""} data-mem-proj="${esc(p.cwd)}" title="开/关该项目的 HANDOFF 写入" />
<div>
<div class="cl-name">${esc(p.project || "(未知)")}</div>
<div class="cl-src">${esc(p.cwd)}</div>
</div>
<div class="btn-group" style="margin-left:auto">
<span class="muted small">${p.count} 条 · ${(p.last || "").slice(0, 10)}</span>
${p.hasHandoff ? `<button class="btn small ghost" data-mem-open="${esc(p.cwd)}">HANDOFF ↗</button>` : ""}
</div>
</div>`
)
.join("");
box.querySelectorAll("[data-mem-proj]").forEach((el) =>
el.addEventListener("change", async () => {
try {
memRenderStatus(await apic.memoryToggleProject(el.dataset.memProj, el.checked));
toast(el.checked ? "已开启该项目的记忆写入 ✅" : "已关闭该项目的记忆写入", "ok");
} catch (e) {
toast("操作失败:" + (e.message || e), "err");
}
})
);
box.querySelectorAll("[data-mem-open]").forEach((el) =>
el.addEventListener("click", async () => {
try {
await apic.memoryOpenHandoff(el.dataset.memOpen);
} catch (e) {
toast(e.message || String(e), "err");
}
})
);
}
// 项目筛选下拉(保留当前选中)
const sel = $("#mem-project-filter");
const cur = sel.value;
sel.innerHTML =
'<option value="">全部项目</option>' +
st.projects.map((p) => `<option value="${esc(p.cwd)}">${esc(p.project || p.cwd)}</option>`).join("");
sel.value = cur;
}
async function memRenderList() {
const box = $("#mem-list");
const empty = $("#mem-empty");
empty.classList.add("hidden");
try {
const rows = await apic.memoryList({
project: $("#mem-project-filter").value,
q: $("#mem-search").value.trim(),
limit: 100,
});
if (!rows.length) {
box.innerHTML = "";
empty.classList.remove("hidden");
empty.textContent = "没有匹配的摘要。";
return;
}
box.innerHTML = rows
.map((r) => {
const when = (r.ts || "").slice(0, 16).replace("T", " ");
const dec = r.decisions.length
? `<div class="mem-line"><b>决策</b>${r.decisions.map((d) => esc(d)).join(" · ")}</div>`
: "";
const next = r.next_steps.length
? `<div class="mem-line"><b>下一步</b>${r.next_steps.map((d) => esc(d)).join(" · ")}</div>`
: "";
const files = r.files.length ? `<div class="mem-line"><b>文件</b>${esc(r.files.join("、"))}</div>` : "";
return `
<div class="mem-item">
<div class="mem-top">
<span class="mem-topic">${esc(r.topic)}</span>
<span class="pc-badge">${esc(r.project || "—")}</span>
<span class="muted small">${when}</span>
<span class="muted small">${r.engine === "local" ? "本地提取" : "LLM"}</span>
<div class="mem-actions">
<button class="btn small ghost" data-mem-redo="${esc(r.rollout_path)}">重新摘要</button>
<button class="btn small ghost" data-mem-del="${esc(r.id)}">删除</button>
</div>
</div>
<div class="mem-sum">${esc(r.summary)}</div>
${dec}${next}${files}
</div>`;
})
.join("");
box.querySelectorAll("[data-mem-redo]").forEach((el) =>
el.addEventListener("click", async (ev) => {
const btn = ev.currentTarget;
busy(btn, true, "摘要中…");
try {
await apic.memoryResummarize(el.dataset.memRedo);
toast("已重新摘要 ✅", "ok");
} catch (e) {
toast("摘要失败:" + (e.message || e), "err");
} finally {
busy(btn, false);
}
})
);
box.querySelectorAll("[data-mem-del]").forEach((el) =>
el.addEventListener("click", async () => {
if (!confirm("删除这条摘要?(不影响原始会话记录)")) return;
try {
await apic.memoryDelete(el.dataset.memDel);
} catch (e) {
toast("删除失败:" + (e.message || e), "err");
}
})
);
} catch (e) {
box.innerHTML = "";
empty.classList.remove("hidden");
empty.textContent = "加载失败:" + (e.message || e);
}
}
async function memLoad() {
memLoaded = true;
try {
memRenderStatus(await apic.memoryStatus());
await memRenderList();
} catch (e) {
toast("会话记忆读取失败:" + (e.message || e), "err");
}
}
document.querySelector('.tab[data-tab="memory"]').addEventListener("click", () => {
if (!memLoaded) memLoad();
});
$("#mem-refresh").addEventListener("click", memLoad);
$("#mem-search").addEventListener("input", memRenderList);
$("#mem-project-filter").addEventListener("change", memRenderList);
$("#mem-toggle").addEventListener("click", async (ev) => {
const btn = ev.currentTarget;
busy(btn, true, "处理中…");
try {
memRenderStatus(await apic.memorySetEnabled(!(memStatus && memStatus.enabled)));
} catch (e) {
toast("操作失败:" + (e.message || e), "err");
} finally {
busy(btn, false);
}
});
apic.onMemoryChanged(() => {
if (memLoaded) memLoad();
});
// ===========================================================================
// 说明弹窗 —— 使用说明 / 版权声明
// ===========================================================================
const MODAL_CONTENT = {
usage: {
title: "📖 使用说明",
html: `
<h4>插件是「命令包」,不是单个命令</h4>
<p>一个插件(如 <strong>creative-production</strong>)里通常包含多个技能命令。安装后不是输入插件名,而是输入里面的具体命令,比如 <code>/explore</code>、<code>/ads-explorer</code>。</p>
<h4>三步开始使用</h4>
<div class="step"><span class="n">1</span><span>在「插件市场」搜索并点 <strong>安装</strong>,或在「备份与还原 → 从 GitHub 拉取还原」里拉取仓库后还原到本机。</span></div>
<div class="step"><span class="n">2</span><span><strong>重启 Codex</strong>,让新装的插件生效。</span></div>
<div class="step"><span class="n">3</span><span>在 Codex 输入框打 <code>/</code>,再输入命令关键词(如 <code>ads</code>、<code>scan</code>)即可定位并运行对应功能。</span></div>
<h4>备份 / 分享给他人</h4>
<ul>
<li>「从本机导出 / 上传」可把你本机的插件打包推送到 GitHub 仓库(<strong>凭证文件已自动排除</strong>)。</li>
<li>把生成的仓库链接发给对方,对方在本 app 用「从 GitHub 拉取还原」即可一键装好。</li>
</ul>
<h4>每个插件具体能干嘛?</h4>
<p>点插件卡片可查看它的功能说明;各插件仓库首页的 README 里也有完整的命令清单与示例。</p>
`,
},
notice: {
title: "📄 版权声明 / NOTICE",
html: `
<h4>内容归属</h4>
<p>本市场收录并可分发的插件为 <strong>各自作者(含 OpenAI Codex 官方插件)</strong> 的作品,其源代码及相关知识产权归 <strong>原始著作权人</strong> 所有。本工具仅提供备份、分享与在已获授权环境中的还原,<strong>不修改插件源代码</strong>。</p>
<h4>使用范围</h4>
<ul>
<li>请在<strong>已获得相应使用授权</strong>的前提下备份与还原插件。</li>
<li>使用时请遵守 OpenAI 服务条款及各插件的原始许可条款。</li>
<li>如不确定是否有权使用某插件,请以其官方渠道说明为准。</li>
</ul>
<h4>凭证安全</h4>
<p>导出 / 上传时,<strong>凭证文件(auth.json、config.toml、history 等)已自动排除</strong>;GitHub Token 仅经系统安全存储(safeStorage)加密后保存在本机。</p>
<h4>免责声明</h4>
<p>本工具及其分发的备份按「<strong>现状(AS IS)</strong>」提供,不附带任何明示或默示担保。作者不对因使用而产生的任何后果负责。本声明不构成对第三方代码的任何再授权。</p>
`,
},
};
const modalOverlay = $("#modal-overlay");
function openModal(key) {
const c = MODAL_CONTENT[key];
if (!c) return;
$("#modal-title").textContent = c.title;
$("#modal-body").innerHTML = c.html;
modalOverlay.classList.remove("hidden");
}
function closeModal() { modalOverlay.classList.add("hidden"); }
$("#open-usage").addEventListener("click", () => openModal("usage"));
$("#open-notice").addEventListener("click", () => openModal("notice"));
$("#modal-close").addEventListener("click", closeModal);
modalOverlay.addEventListener("click", (e) => { if (e.target === modalOverlay) closeModal(); });
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !modalOverlay.classList.contains("hidden")) closeModal();
});
// ===========================================================================
// 路由中转
// ===========================================================================
let routerState = null;
let grpEditing = null;
function renderRouter(st) {
routerState = st;
$("#router-addr").textContent = st.address;
const badge = $("#router-state");
badge.textContent = st.running ? "运行中" : "未启动";
badge.className = "pc-badge " + (st.running ? "skill" : "");
$("#router-toggle").textContent = st.running ? "停止" : "启动";
$("#router-key").value = st.routerKey;
if (document.activeElement !== $("#router-port")) $("#router-port").value = st.port;
const claudeBtn = $("#router-apply-claude");
if (claudeBtn) {
claudeBtn.textContent = st.claudeApplied ? "取消接入 Claude Code" : "接入 Claude Code";
claudeBtn.classList.toggle("ghost", !!st.claudeApplied);
}
const box = $("#router-groups");
if (!st.groups.length) {
box.innerHTML = `<div class="empty">还没有分组,点右上「+ 新增分组」添加一个中转 API</div>`;
return;
}
box.innerHTML = st.groups
.map((g) => {
const badges =
(g.id === st.activeId ? '<span class="pc-badge skill">当前</span>' : "") +
(g.isDefault ? ' <span class="pc-badge">默认·不可删</span>' : "");
const actions = g.isDefault
? '<span class="muted small">切回这里 = 直连你的账号</span>'
: `<button class="btn small ghost" data-edit="${g.id}">编辑</button><button class="btn small ghost" data-del="${g.id}">删除</button>`;
return `
<div class="cl-item">
<input type="radio" name="active-grp" ${g.id === st.activeId ? "checked" : ""} data-active="${g.id}" />
<div>
<div class="cl-name">${esc(g.name)} ${badges}</div>
<div class="cl-src">${esc(g.baseUrl)}${g.model ? " · 模型 " + esc(g.model) : ""}${!g.isDefault && g.mode === "chat" ? " · 🔄转换" : ""}${g.hasKey ? "" : " · ⚠️无密钥"}</div>
</div>
<div class="btn-group" style="margin-left:auto">${actions}</div>
</div>`;
})
.join("");
box.querySelectorAll("[data-active]").forEach((el) =>
el.addEventListener("change", async () => {
const r = await apic.routerSetActive(el.dataset.active);
renderRouter(r);
if (r._needsRestart) toast("已切换,重启 Codex 后生效(配置有变)", "ok");
else if (r._mode === "direct") toast("已切回默认(直连你的账号)✅", "ok");
else toast("已秒切分组 ✅ 无需重启 Codex", "ok");
})
);
box.querySelectorAll("[data-edit]").forEach((el) =>
el.addEventListener("click", () => openGrpModal(st.groups.find((g) => g.id === el.dataset.edit)))
);
box.querySelectorAll("[data-del]").forEach((el) =>
el.addEventListener("click", async () => {
if (confirm("删除这个分组?")) renderRouter(await apic.routerDeleteGroup(el.dataset.del));
})
);
}
async function loadRouter() {
try {
renderRouter(await apic.routerStatus());
} catch (e) {
toast("路由状态读取失败:" + (e.message || e), "err");
}
}
function openGrpModal(g) {
grpEditing = g || null;
$("#grp-modal-title").textContent = g ? "编辑分组" : "新增分组";
$("#grp-name").value = g ? g.name : "";
$("#grp-mode").value = g && g.mode ? g.mode : "responses";
$("#grp-url").value = g ? g.baseUrl : "";
$("#grp-key").value = ""; // 密钥不回显,留空则不改
$("#grp-key").placeholder = g && g.hasKey ? "(已保存,留空则不修改)" : "sk-…";
$("#grp-model").value = g ? g.model || "" : "";
$("#grp-ctx").value = g && g.contextWindow ? g.contextWindow : "";
const sel = $("#grp-model-list");
sel.classList.add("hidden");
sel.innerHTML = '<option value="">— 从下拉选择模型 —</option>';
$("#grp-model-hint").textContent = "";
$("#grp-modal").classList.remove("hidden");
}
function closeGrpModal() {
$("#grp-modal").classList.add("hidden");
}
document.querySelectorAll("[data-ctx]").forEach((b) =>
b.addEventListener("click", () => {
$("#grp-ctx").value = b.dataset.ctx;
})
);
$("#grp-add").addEventListener("click", () => openGrpModal(null));
$("#grp-modal-close").addEventListener("click", closeGrpModal);
$("#grp-modal").addEventListener("click", (e) => {
if (e.target === $("#grp-modal")) closeGrpModal();
});
$("#grp-save").addEventListener("click", async () => {
const name = $("#grp-name").value.trim();
const baseUrl = $("#grp-url").value.trim();
if (!name || !baseUrl) {
toast("名称和 API 地址必填", "err");
return;
}
const group = {
name,
baseUrl,
model: $("#grp-model").value.trim(),
mode: $("#grp-mode").value,
contextWindow: $("#grp-ctx").value.trim(),
};
if (grpEditing) group.id = grpEditing.id;
const key = $("#grp-key").value;
if (key) group.key = key;
else if (grpEditing) group.key = undefined; // 留空且是编辑 → 保留原密钥(主进程 merge 时不覆盖)
// 编辑留空时不传 key 字段,避免覆盖
if (grpEditing && !key) delete group.key;
try {
renderRouter(await apic.routerUpsertGroup(group));
closeGrpModal();
toast("已保存分组 ✅", "ok");
} catch (e) {
toast("保存失败:" + (e.message || e), "err");
}
});
$("#grp-fetch-models").addEventListener("click", async (ev) => {
const btn = ev.currentTarget;
const baseUrl = $("#grp-url").value.trim();
const key = $("#grp-key").value; // 留空且是编辑 → 用已保存的密钥(传 groupId)
const hint = $("#grp-model-hint");
if (!baseUrl) {
hint.textContent = "请先填 API 地址";
return;
}
busy(btn, true, "获取中…");
hint.textContent = "";
try {
const models = await apic.routerFetchModels(baseUrl, key, grpEditing ? grpEditing.id : null);
const sel = $("#grp-model-list");
sel.innerHTML =
'<option value="">— 从下拉选择模型 —</option>' +
models.map((m) => `<option value="${esc(m)}">${esc(m)}</option>`).join("");
// 若当前已有模型值,选中它
if ($("#grp-model").value) sel.value = $("#grp-model").value;
sel.classList.remove("hidden");
hint.textContent = `共 ${models.length} 个模型,选一个或保持手填`;
} catch (e) {
hint.textContent = "获取失败:" + (e.message || e) + "(可手动填模型)";
} finally {
busy(btn, false);
}
});
$("#grp-model-list").addEventListener("change", (e) => {
if (e.target.value) $("#grp-model").value = e.target.value;
});
$("#router-toggle").addEventListener("click", async (ev) => {
const btn = ev.currentTarget;
busy(btn, true, "处理中…");
try {
renderRouter(routerState.running ? await apic.routerStop() : await apic.routerStart());
} catch (e) {
toast("操作失败:" + (e.message || e), "err");
} finally {
busy(btn, false);
}
});
$("#router-save-port").addEventListener("click", async () => {
try {
renderRouter(await apic.routerSetPort($("#router-port").value));
toast("端口已更新", "ok");
} catch (e) {
toast("改端口失败:" + (e.message || e), "err");
}
});
$("#router-apply").addEventListener("click", async (ev) => {
if (!confirm("将修改 Codex 的 config.toml,指向本地路由(会自动备份)。\n注意:启用后账号切换请统一在本软件切,不要再用 cc-switch,以免冲突。\n\n确认?")) return;
const btn = ev.currentTarget;
busy(btn, true, "配置中…");
try {
const r = await apic.routerApplyCodex();
renderRouter(r.status);
toast("已配置到 Codex ✅ 重启 Codex 后即可通过本地路由,之后切分组无需重启", "ok");
} catch (e) {
toast("配置失败:" + (e.message || e), "err");
} finally {
busy(btn, false);
}
});
$("#router-apply-claude").addEventListener("click", async (ev) => {
const applied = routerState && routerState.claudeApplied;
if (applied) {
if (!confirm("取消接入:将从 ~/.claude/settings.json 移除 ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN(还原成你原来的值)。\n\n确认?")) return;
const btn = ev.currentTarget;
busy(btn, true, "还原中…");
try {
const r = await apic.routerRestoreClaude();
renderRouter(r.status);
toast("已取消接入 Claude Code ✅", "ok");
} catch (e) {
toast("还原失败:" + (e.message || e), "err");
} finally {
busy(btn, false);
}
return;
}
const hasAnthropicGroup = routerState && routerState.groups.some((g) => !g.isDefault && g.hasKey);
if (!hasAnthropicGroup && !confirm("还没有可用的中转分组。先去下面新增一个 Anthropic 格式的中转(base_url 填站点根、模式选 Responses 直连、填密钥),再回来接入。\n\n仍要继续?")) return;
if (!confirm("将修改 ~/.claude/settings.json,把 ANTHROPIC_BASE_URL 指向本地路由(会自动备份)。\n须搭配 Anthropic 格式的中转分组使用。\n\n确认?")) return;
const btn = ev.currentTarget;
busy(btn, true, "接入中…");
try {
const r = await apic.routerApplyClaude();
renderRouter(r.status);
toast("已接入 Claude Code ✅ 新开 claude 会话即走本地路由,之后切分组无需重启", "ok");
} catch (e) {
toast("接入失败:" + (e.message || e), "err");
} finally {
busy(btn, false);
}
});
document.querySelector('.tab[data-tab="router"]').addEventListener("click", loadRouter);
apic.onRouterChanged((st) => renderRouter(st));
// ===========================================================================
// 在线更新
// ===========================================================================
async function loadVersion() {
try {
$("#app-version").textContent = "v" + (await apic.appVersion());
} catch {}
}
// 显示"发现新版本 + 前往下载"链接(Mac 半自动更新)
const RELEASE_FALLBACK = "https://update.tokendun.cc/mhgmh/codex-market-app/releases";
const updateModal = $("#update-modal");
let updateMandatory = false;
// 更新弹窗:显示版本 + 更新内容,区分强制 / 一般
function showUpdateModal({ version, notes, mandatory, url }) {
const link = url || RELEASE_FALLBACK;
updateMandatory = !!mandatory;
$("#update-title").textContent = mandatory ? "重要更新(必须升级)" : "发现新版本";
$("#update-ver").textContent = `新版本 v${version} · 当前 v${$("#app-version").textContent.replace(/^v/, "") || "?"}`;
const badge = $("#update-badge");
badge.textContent = mandatory ? "强制更新" : "推荐更新";
badge.className = "update-badge " + (mandatory ? "force" : "normal");
$("#update-notes").textContent = notes || "";
// 按钮
const foot = $("#update-foot");
foot.innerHTML = mandatory
? `<button class="btn danger" id="upd-quit">退出软件</button>
<button class="btn primary" id="upd-go">立即更新</button>`
: `<button class="btn ghost" id="upd-later">稍后再说</button>
<button class="btn primary" id="upd-go">立即更新</button>`;
$("#upd-go").addEventListener("click", () => apic.openExternal(link));
if (mandatory) {
$("#upd-quit").addEventListener("click", () => apic.quitApp());
} else {
$("#upd-later").addEventListener("click", closeUpdateModal);
}
updateModal.classList.remove("hidden");
}
function closeUpdateModal() {
if (updateMandatory) return; // 强制更新不允许关闭
updateModal.classList.add("hidden");
}
// 一般更新:点背景关闭;强制更新:点背景无效
updateModal.addEventListener("click", (e) => {
if (e.target === updateModal) closeUpdateModal();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !updateModal.classList.contains("hidden")) closeUpdateModal();
});
$("#btn-check-update").addEventListener("click", async (ev) => {
const btn = ev.currentTarget;
const info = $("#update-info");
busy(btn, true, "检查中…");
info.textContent = "";
try {
const r = await apic.checkUpdate();
if (r.dev) info.textContent = "(开发版不检查更新)";
else if (r.network) {
info.innerHTML = `⚠️ ${esc(r.error)} <a class="link" id="dl-manual">前往下载页 ↗</a>`;
const a = $("#dl-manual");
if (a) a.addEventListener("click", () => apic.openExternal(r.url || RELEASE_FALLBACK));
}
else if (r.error) info.textContent = "检查失败:" + r.error;
else if (r.hasNew) {
info.textContent = `发现新版本 v${r.version}`;
showUpdateModal({ version: r.version, notes: r.notes, mandatory: r.mandatory, url: r.url });
}
else info.textContent = "已是最新版本 ✅";
} catch (e) {
info.textContent = "检查失败:" + (e.message || e);
} finally {
busy(btn, false);
}
});
apic.onUpdateStatus((s) => {
const info = $("#update-info");
if (s.state === "available-manual") {
info.textContent = `发现新版本 v${s.version}`;
showUpdateModal({ version: s.version, notes: s.notes, mandatory: s.mandatory, url: s.url });
}
else if (s.state === "available") info.textContent = `发现新版本 ${s.version},下载中…`;
else if (s.state === "downloading") info.textContent = `下载新版本 ${s.percent}%…`;
else if (s.state === "downloaded") info.textContent = `新版本 ${s.version} 已就绪,退出软件即自动更新`;
else if (s.state === "none") info.textContent = "已是最新版本 ✅";
else if (s.state === "error") info.textContent = "更新检查失败:" + (s.message || "");
});
// ===========================================================================
// 全局 / 系统代理
// ===========================================================================
function renderProxy(st) {
const badge = $("#proxy-state");
if (!badge) return;
const detail = $("#proxy-state-detail");
const stopBtn = $("#proxy-stop");
const gBtn = $("#proxy-start-global");
const sBtn = $("#proxy-start-system");
if (st.mode === "global") {
badge.textContent = "全局代理运行中";
badge.className = "pc-badge on";
detail.textContent = "整机 TUN · 所有程序流量走代理";
} else if (st.mode === "system") {
badge.textContent = "系统代理运行中";
badge.className = "pc-badge on";
detail.textContent = `本地中转 127.0.0.1:${st.localPort}`;
} else {
badge.textContent = "未开启";
badge.className = "pc-badge";
detail.textContent = st.proxy
? `已配置 ${st.proxy.host}:${st.proxy.port}${st.proxy.hasAuth ? " · 需密码" : ""}`
: "未配置代理";
}
const running = st.mode !== "off";
stopBtn.classList.toggle("hidden", !running);
const disabled = !st.hasConfig || !st.binOk;
gBtn.disabled = disabled;
sBtn.disabled = disabled;
gBtn.classList.toggle("active", st.mode === "global");
sBtn.classList.toggle("active", st.mode === "system");
if (!st.binOk) detail.textContent = "⚠️ 缺少 sing-box 引擎文件,请重装软件";
}
async function loadProxy() {
try {
const cfg = await apic.proxyGetConfig();
if (cfg && cfg.proxyStr && !$("#proxy-str").value) $("#proxy-str").value = cfg.proxyStr;
renderProxy(await apic.proxyStatus());
} catch (e) {
toast("代理状态读取失败:" + (e.message || e), "err");
}
}
$("#proxy-save").addEventListener("click", async (ev) => {
const btn = ev.currentTarget;
busy(btn, true, "保存中…");
try {
await apic.proxySetConfig({ proxyStr: $("#proxy-str").value.trim() });
renderProxy(await apic.proxyStatus());
toast("已保存 ✅", "ok");
} catch (e) {
toast("保存失败:" + (e.message || e), "err");
} finally {
busy(btn, false);
}
});
$("#proxy-test").addEventListener("click", async (ev) => {
const btn = ev.currentTarget;
const out = $("#proxy-test-out");
busy(btn, true, "测试中…");
out.textContent = "正在通过代理请求出口 IP…";
try {
await apic.proxySetConfig({ proxyStr: $("#proxy-str").value.trim() });
const r = await apic.proxyTest();
out.innerHTML = `✅ 代理可用 · 出口 IP:<b>${esc(r.ip)}</b>`;
} catch (e) {
out.textContent = "❌ 测试失败:" + (e.message || e);
} finally {
busy(btn, false);
}
});
async function startProxy(mode, btn) {
busy(btn, true, mode === "global" ? "启动全局中…" : "启动系统代理中…");
try {
await apic.proxySetConfig({ proxyStr: $("#proxy-str").value.trim() });
renderProxy(await apic.proxyStart(mode));
toast(mode === "global" ? "全局代理已开启(整机)✅" : "系统代理已开启 ✅", "ok");
} catch (e) {
toast("启动失败:" + (e.message || e), "err");
try {
renderProxy(await apic.proxyStatus());
} catch {}
} finally {
busy(btn, false);
}
}
$("#proxy-start-global").addEventListener("click", (ev) => startProxy("global", ev.currentTarget));
$("#proxy-start-system").addEventListener("click", (ev) => startProxy("system", ev.currentTarget));
$("#proxy-stop").addEventListener("click", async (ev) => {
const btn = ev.currentTarget;
busy(btn, true, "关闭中…");
try {
renderProxy(await apic.proxyStop());
toast("代理已关闭,网络已还原", "ok");
} catch (e) {
toast("关闭失败:" + (e.message || e), "err");
} finally {
busy(btn, false);
}
});
document.querySelector('.tab[data-tab="proxy"]').addEventListener("click", loadProxy);
apic.onProxyChanged((st) => renderProxy(st));
// —— 启动 ——
loadMarket();
scanLocal();
loadSecrets();
loadVersion();
loadProxy();