This commit is contained in:
qsc
2026-09-06 19:22:56 +08:00
parent a42d3146c5
commit 2cf54a5e6c
18 changed files with 2203 additions and 216 deletions
+271
View File
@@ -7,7 +7,15 @@ const state = {
servers: [],
events: {},
session_control: { mode: "all", entries: [] },
legacy_bilei: [],
token_stats: null,
cache: {
defaults: { api: 300, image: 300 },
limits: { api_memory_entries: 256, image_max_mb: 512 },
api: [],
images: [],
stats: {},
},
};
const editing = { bindingSession: null, controlSession: null, aliasServer: null, kungfuPzid: null };
const restoreConfirmationTimers = new WeakMap();
@@ -21,6 +29,14 @@ function formatUsageCount(value) {
: "—";
}
function formatBytes(value) {
const bytes = Number(value) || 0;
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
}
function renderTokenStats() {
const stats = state.token_stats;
byId("token-level").textContent = Number.isSafeInteger(stats?.level)
@@ -392,6 +408,76 @@ function renderBindings() {
}));
}
function renderLegacyBilei() {
const records = state.legacy_bilei || [];
const body = byId("legacy-bilei-body");
byId("legacy-bilei-count").textContent = String(records.length);
if (!records.length) {
body.replaceChildren(emptyRow(7, "没有待迁移的旧避雷数据"));
return;
}
body.replaceChildren(...records.map((item) => {
const row = document.createElement("tr");
const id = document.createElement("td");
const name = document.createElement("td");
const note = document.createElement("td");
const time = document.createElement("td");
const user = document.createElement("td");
const target = document.createElement("td");
const actions = document.createElement("td");
id.dataset.label = "ID";
name.dataset.label = "避雷名称";
note.dataset.label = "避雷备注";
time.dataset.label = "时间";
user.dataset.label = "记录人";
target.dataset.label = "目标会话";
actions.dataset.label = "操作";
id.textContent = String(item.id);
name.textContent = item.name || "—";
note.textContent = item.text || "—";
note.className = "legacy-note-cell";
time.textContent = item.time || "—";
user.textContent = item.user || "—";
target.className = "legacy-session-cell";
actions.className = "actions";
const sessionInput = document.createElement("input");
sessionInput.className = "inline-editor";
sessionInput.maxLength = 512;
sessionInput.required = true;
sessionInput.setAttribute("list", "session-options");
sessionInput.setAttribute("aria-label", `避雷记录 ${item.id} 的目标会话`);
sessionInput.placeholder = "选择已有会话或直接输入";
target.append(sessionInput);
const migrateButton = button("迁移", "", async () => {
if (!sessionInput.reportValidity()) return;
sessionInput.disabled = true;
migrateButton.disabled = true;
const migrated = await mutate(
"bilei/legacy/migrate",
{ id: item.id, session_id: sessionInput.value },
`避雷记录 ${item.id} 已迁移`,
);
if (!migrated) {
sessionInput.disabled = false;
migrateButton.disabled = false;
sessionInput.focus();
}
});
sessionInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
migrateButton.click();
}
});
actions.append(migrateButton);
row.append(id, name, note, time, user, target, actions);
return row;
}));
}
function renderSubscriptions() {
const body = byId("subscriptions-body");
const bindings = bindingMap();
@@ -552,15 +638,134 @@ function renderKungfu() {
}));
}
function cacheSettingRow(cacheType, item) {
const row = document.createElement("tr");
const name = document.createElement("td");
const ttl = document.createElement("td");
const status = document.createElement("td");
const actions = document.createElement("td");
name.dataset.label = cacheType === "api" ? "接口路径" : "图片指令";
ttl.dataset.label = "缓存时间(秒)";
status.dataset.label = "配置状态";
actions.dataset.label = "操作";
name.textContent = item.name;
name.className = "cache-name-cell";
actions.className = "actions";
const input = document.createElement("input");
input.className = "inline-editor inline-editor--ttl";
input.type = "number";
input.min = "0";
input.max = "2592000";
input.step = "1";
input.required = true;
input.value = String(item.ttl_seconds);
input.setAttribute("aria-label", `${item.name}缓存时间(秒)`);
ttl.append(input);
const badge = document.createElement("span");
badge.className = `cache-badge ${item.overridden ? "cache-badge--custom" : ""}`.trim();
badge.textContent = item.overridden
? "独立设置"
: item.safe_default
? "安全默认"
: "继承默认";
status.append(badge);
const saveButton = button("保存", "", async () => {
if (!input.reportValidity()) return;
saveButton.disabled = true;
restoreButton.disabled = true;
const saved = await mutate(
"cache/settings/save",
{ cache_type: cacheType, cache_name: item.name, ttl_seconds: Number(input.value) },
`${item.name}缓存时间已保存`,
);
if (!saved) {
saveButton.disabled = false;
restoreButton.disabled = false;
}
});
const restoreButton = button("恢复默认", "", async () => {
restoreButton.disabled = true;
saveButton.disabled = true;
const saved = await mutate(
"cache/settings/save",
{ cache_type: cacheType, cache_name: item.name, inherit: true },
`${item.name}已恢复默认时间`,
);
if (!saved) {
restoreButton.disabled = false;
saveButton.disabled = false;
}
});
const clearButton = button("清除此项", "link-button--danger", async () => {
clearButton.disabled = true;
const cleared = await mutate(
"cache/item/clear",
{ cache_type: cacheType, cache_name: item.name },
`${item.name}缓存已清除,下次调用将重新生成`,
);
if (!cleared) clearButton.disabled = false;
});
restoreButton.disabled = !item.overridden;
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
saveButton.click();
}
});
actions.append(saveButton, restoreButton, clearButton);
row.append(name, ttl, status, actions);
return row;
}
function renderCacheTable(cacheType) {
const isApi = cacheType === "api";
const items = isApi ? state.cache.api : state.cache.images;
const filter = byId(isApi ? "api-cache-filter" : "image-cache-filter")
.value.trim().toLocaleLowerCase("zh-CN");
const visible = items.filter((item) => item.name.toLocaleLowerCase("zh-CN").includes(filter));
const body = byId(isApi ? "api-cache-settings-body" : "image-cache-settings-body");
body.replaceChildren(...(
visible.length
? visible.map((item) => cacheSettingRow(cacheType, item))
: [emptyRow(4, filter ? "没有匹配的缓存项目" : "暂无缓存项目")]
));
}
function renderCache() {
const cache = state.cache || { defaults: {}, limits: {}, api: [], images: [], stats: {} };
const stats = cache.stats || {};
const apiDefault = cache.defaults?.api ?? 300;
const imageDefault = cache.defaults?.image ?? 600;
const memoryLimit = cache.limits?.api_memory_entries ?? stats.api_memory_limit ?? 256;
const imageLimitMb = cache.limits?.image_max_mb ?? 512;
byId("api-cache-count").textContent = `${stats.api_count || 0}`;
byId("api-cache-size").textContent = formatBytes(stats.api_size_bytes);
byId("image-cache-count").textContent = `${stats.image_count || 0}`;
byId("image-cache-size").textContent = `${formatBytes(stats.image_size_bytes)} / ${formatBytes(stats.image_limit_bytes)}`;
byId("api-default-ttl").value = String(apiDefault);
byId("image-default-ttl").value = String(imageDefault);
byId("api-memory-limit").value = String(memoryLimit);
byId("image-size-limit").value = String(imageLimitMb);
byId("api-memory-summary").textContent = `${stats.api_memory_count || 0} / ${memoryLimit}`;
byId("cache-default-summary").textContent = `${apiDefault} / ${imageDefault}`;
renderCacheTable("api");
renderCacheTable("image");
}
function render() {
renderTokenStats();
renderServerOptions();
renderSessionOptions();
renderSessionControl();
renderLegacyBilei();
renderBindings();
renderSubscriptions();
renderAliases();
renderKungfu();
renderCache();
}
async function loadData() {
@@ -691,6 +896,72 @@ byId("restore-kungfu").addEventListener("click", async (event) => {
);
});
byId("api-cache-filter").addEventListener("input", () => renderCacheTable("api"));
byId("image-cache-filter").addEventListener("input", () => renderCacheTable("image"));
byId("cache-default-form").addEventListener("submit", async (event) => {
event.preventDefault();
const submit = event.currentTarget.querySelector('button[type="submit"]');
submit.disabled = true;
try {
await bridge.apiPost("cache/settings/save", {
cache_type: "api",
cache_name: "*",
ttl_seconds: Number(byId("api-default-ttl").value),
});
await bridge.apiPost("cache/settings/save", {
cache_type: "image",
cache_name: "*",
ttl_seconds: Number(byId("image-default-ttl").value),
});
await loadData();
showToast("默认缓存时间已保存");
} catch (error) {
showToast(error?.message || "默认缓存时间保存失败", true);
} finally {
submit.disabled = false;
}
});
byId("cache-limit-form").addEventListener("submit", async (event) => {
event.preventDefault();
const submit = event.currentTarget.querySelector('button[type="submit"]');
submit.disabled = true;
try {
await bridge.apiPost("cache/limits/save", {
api_memory_entries: Number(byId("api-memory-limit").value),
image_max_mb: Number(byId("image-size-limit").value),
});
await loadData();
showToast("缓存容量限制已保存并立即生效");
} catch (error) {
showToast(error?.message || "缓存容量限制保存失败", true);
} finally {
submit.disabled = false;
}
});
async function clearCache(cacheType, control) {
control.disabled = true;
try {
const result = await bridge.apiPost("cache/clear", { cache_type: cacheType });
await loadData();
const removed = result?.removed?.[cacheType] ?? 0;
showToast(`${cacheType === "api" ? "接口" : "图片"}缓存已清空,共清理 ${removed}`);
} catch (error) {
showToast(error?.message || "缓存清理失败", true);
} finally {
control.disabled = false;
}
}
byId("clear-api-cache").addEventListener("click", (event) => {
clearCache("api", event.currentTarget);
});
byId("clear-image-cache").addEventListener("click", (event) => {
clearCache("image", event.currentTarget);
});
byId("refresh").addEventListener("click", async (event) => {
const control = event.currentTarget;
control.disabled = true;
+90 -1
View File
@@ -12,7 +12,7 @@
<header class="page-header">
<div>
<h1>剑网三插件管理</h1>
<p>管理事件推送、会话访问范围、区服绑定及别名。</p>
<p>管理会话访问范围、查询缓存、避雷迁移、事件推送、区服绑定及别名。</p>
</div>
<button class="button button--secondary" id="refresh" type="button">刷新数据</button>
</header>
@@ -42,6 +42,8 @@
<button class="tab" type="button" role="tab" aria-selected="false" data-tab="bindings">区服绑定</button>
<button class="tab" type="button" role="tab" aria-selected="false" data-tab="aliases">区服别名</button>
<button class="tab" type="button" role="tab" aria-selected="false" data-tab="kungfu">心法别名</button>
<button class="tab" type="button" role="tab" aria-selected="false" data-tab="cache">缓存管理</button>
<button class="tab" type="button" role="tab" aria-selected="false" data-tab="bilei-migration">避雷迁移</button>
</nav>
<section class="panel is-active" id="session-control-panel" role="tabpanel">
@@ -100,6 +102,93 @@
</div>
</section>
<section class="panel" id="cache-panel" role="tabpanel" hidden>
<div class="section-intro section-intro--actions">
<div>
<h2>查询与图片缓存</h2>
<p>接口数据以 JSON 保存到 SQLite,渲染图片保存到插件数据目录。图片命中时会直接发送,不再请求其上游接口;缓存时间使用秒,填写 0 可关闭对应缓存。</p>
</div>
<div class="cache-clear-actions">
<button class="button button--secondary" id="clear-api-cache" type="button">清空接口缓存</button>
<button class="button button--secondary" id="clear-image-cache" type="button">清空图片缓存</button>
</div>
</div>
<div class="cache-summary" aria-label="缓存统计">
<div class="cache-stat"><span>接口缓存(SQLite</span><strong id="api-cache-count">0 条</strong><small id="api-cache-size">0 B</small></div>
<div class="cache-stat"><span>图片缓存</span><strong id="image-cache-count">0 张</strong><small id="image-cache-size">0 B</small></div>
<div class="cache-stat"><span>接口内存缓存</span><strong id="api-memory-summary">0 / 256 条</strong><small>最久未使用优先淘汰</small></div>
<div class="cache-stat"><span>默认缓存时间</span><strong id="cache-default-summary">300 / 600 秒</strong><small>接口 / 图片</small></div>
</div>
<form class="cache-defaults" id="cache-default-form">
<label>
<span>接口数据默认缓存时间(秒)</span>
<input id="api-default-ttl" type="number" min="0" max="2592000" step="1" required />
</label>
<label>
<span>图片默认缓存时间(秒)</span>
<input id="image-default-ttl" type="number" min="0" max="2592000" step="1" required />
</label>
<button class="button button--primary" type="submit">保存默认时间</button>
</form>
<form class="cache-defaults cache-limits" id="cache-limit-form">
<label>
<span>接口内存缓存最大条数</span>
<input id="api-memory-limit" type="number" min="1" max="100000" step="1" required />
</label>
<label>
<span>图片缓存最大容量(MB</span>
<input id="image-size-limit" type="number" min="1" max="10240" step="1" required />
</label>
<button class="button button--primary" type="submit">保存容量限制</button>
</form>
<details class="cache-group" open>
<summary>JX3API 接口数据缓存</summary>
<div class="cache-toolbar">
<input id="api-cache-filter" type="search" placeholder="筛选接口路径" aria-label="筛选接口路径" />
<span>单项设置优先;恢复后通常继承全局时间,“安全默认”项目恢复为 0 秒。</span>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>接口路径</th><th>缓存时间(秒)</th><th>配置状态</th><th class="actions">操作</th></tr></thead>
<tbody id="api-cache-settings-body"></tbody>
</table>
</div>
</details>
<details class="cache-group" open>
<summary>渲染图片缓存</summary>
<div class="cache-toolbar">
<input id="image-cache-filter" type="search" placeholder="筛选图片指令" aria-label="筛选图片指令" />
<span>图片缓存时间决定最终结果的新鲜度;过期后才重新请求数据并渲染。</span>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>图片指令</th><th>缓存时间(秒)</th><th>配置状态</th><th class="actions">操作</th></tr></thead>
<tbody id="image-cache-settings-body"></tbody>
</table>
</div>
</details>
</section>
<section class="panel" id="bilei-migration-panel" role="tabpanel" hidden>
<div class="section-intro">
<div>
<h2>迁移旧避雷数据</h2>
<p>旧版记录暂存在历史公共数据区。请为每条记录选择或输入目标会话,迁移后该记录仅能在目标会话中访问。剩余 <strong id="legacy-bilei-count">0</strong> 条。</p>
</div>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>ID</th><th>避雷名称</th><th>避雷备注</th><th>时间</th><th>记录人</th><th>目标会话</th><th class="actions">操作</th></tr></thead>
<tbody id="legacy-bilei-body"></tbody>
</table>
</div>
</section>
<section class="panel" id="bindings-panel" role="tabpanel" hidden>
<form class="editor" id="binding-form">
<label>
+29 -2
View File
@@ -86,6 +86,23 @@ input:focus, select:focus { border-color: var(--focus); box-shadow: 0 0 0 3px co
.section-intro { padding: 22px; border-bottom: 1px solid var(--border); }
.section-intro--actions { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.section-intro h2 { margin: 0; font-size: 18px; }
.cache-clear-actions { display: flex; flex: 0 0 auto; justify-content: flex-end; gap: 10px; }
.cache-summary { display: grid; grid-template-columns: repeat(4, 1fr); border-bottom: 1px solid var(--border); background: var(--surface-subtle); }
.cache-stat { display: flex; min-height: 100px; flex-direction: column; justify-content: center; padding: 18px 22px; }
.cache-stat + .cache-stat { border-left: 1px solid var(--border); }
.cache-stat span, .cache-stat small { color: var(--muted); font-size: 12px; font-weight: 650; }
.cache-stat strong { margin: 3px 0; font-size: 21px; font-variant-numeric: tabular-nums; }
.cache-defaults { display: grid; grid-template-columns: minmax(240px, 1fr) minmax(240px, 1fr) auto; align-items: end; gap: 16px; padding: 22px; border-bottom: 1px solid var(--border); }
.cache-group { border-bottom: 1px solid var(--border); }
.cache-group:last-child { border-bottom: 0; }
.cache-group summary { padding: 18px 22px; cursor: pointer; font-size: 16px; font-weight: 750; }
.cache-group summary:hover { background: var(--surface-subtle); }
.cache-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 0 22px 16px; color: var(--muted); font-size: 13px; }
.cache-toolbar input { max-width: 360px; }
.cache-name-cell { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; text-align: left; }
.inline-editor--ttl { min-width: 130px; max-width: 180px; }
.cache-badge { display: inline-flex; padding: 3px 9px; border-radius: 999px; background: color-mix(in srgb, var(--muted) 10%, transparent); color: var(--muted); font-size: 12px; font-weight: 700; }
.cache-badge--custom { background: var(--accent-soft); color: var(--accent); }
.table-wrap { overflow-x: auto; }
table { width: 100%; table-layout: auto; border-collapse: collapse; }
th, td { padding: 15px 20px; text-align: center; vertical-align: middle; border-bottom: 1px solid var(--border); }
@@ -94,6 +111,8 @@ tbody tr:last-child td { border-bottom: 0; }
tbody tr:hover { background: color-mix(in srgb, var(--accent-soft) 42%, transparent); }
tbody tr.is-editing { background: color-mix(in srgb, var(--accent-soft) 62%, transparent); }
.alias-cell { min-width: 320px; }
.legacy-note-cell { min-width: 260px; max-width: 420px; text-align: left; white-space: pre-wrap; overflow-wrap: anywhere; }
.legacy-session-cell { min-width: 300px; }
.inline-editor { min-width: 260px; }
.inline-editor--compact { min-width: 130px; }
.actions { white-space: nowrap; }
@@ -120,6 +139,13 @@ tbody tr.is-editing { background: color-mix(in srgb, var(--accent-soft) 62%, tra
.shell { width: min(100% - 24px, 1260px); padding-top: 20px; }
.page-header { align-items: stretch; flex-direction: column; }
.section-intro--actions { align-items: stretch; flex-direction: column; }
.cache-clear-actions { display: flex; justify-content: flex-end; }
.cache-summary { grid-template-columns: repeat(2, 1fr); }
.cache-stat:nth-child(3) { border-top: 1px solid var(--border); border-left: 0; }
.cache-stat:nth-child(4) { border-top: 1px solid var(--border); }
.cache-defaults { grid-template-columns: 1fr; }
.cache-toolbar { align-items: stretch; flex-direction: column; }
.cache-toolbar input { max-width: none; }
.token-summary { grid-template-columns: repeat(2, 1fr); }
.token-stat { min-height: 88px; padding: 15px 18px; }
.token-stat:nth-child(3) { border-top: 1px solid var(--border); border-left: 0; }
@@ -139,8 +165,9 @@ tbody tr.is-editing { background: color-mix(in srgb, var(--accent-soft) 62%, tra
tbody tr:last-child { border-bottom: 0; }
td { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 12px; padding: 8px 16px; border: 0; overflow-wrap: anywhere; }
td::before { color: var(--muted); content: attr(data-label); font-size: 12px; font-weight: 700; }
td.actions { grid-template-columns: max-content auto auto; align-items: center; width: 100%; white-space: normal; }
.alias-cell, .inline-editor { min-width: 0; }
td.actions { grid-template-columns: max-content repeat(3, auto); align-items: center; width: 100%; white-space: normal; }
.alias-cell, .legacy-note-cell, .legacy-session-cell, .inline-editor { min-width: 0; max-width: none; }
.cache-name-cell { text-align: right; }
td.empty { display: block; padding: 36px 16px; }
td.empty::before { content: none; }
}