Files
90CNServer/pvf/tools/annotation-editor.html
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

978 lines
55 KiB
HTML
Raw Permalink 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.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>pvfine 标注规则编辑器</title>
<style>
:root {
color-scheme: dark;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif;
background: #17191e;
color: #e6e8ee;
font-size: 13px;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 1040px; min-height: 100vh; overflow: hidden; }
button, input, select, textarea { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: default; opacity: .45; }
.app { height: 100vh; display: flex; flex-direction: column; }
.toolbar {
height: 48px; display: flex; align-items: center; gap: 8px; padding: 0 14px;
background: #20232a; border-bottom: 1px solid #363a45; flex: 0 0 auto;
}
.brand { font-size: 15px; font-weight: 650; margin-right: 10px; }
.toolbar-spacer { flex: 1; }
.status { max-width: 440px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #9aa1ad; }
.status.ok { color: #66c99a; }
.status.error { color: #ee8585; }
.btn {
height: 30px; padding: 0 10px; border-radius: 5px; border: 1px solid #454b58;
background: #292d36; color: #e6e8ee;
}
.btn:hover { background: #343945; }
.btn.primary { background: #376fc2; border-color: #4f8ddd; }
.btn.danger { color: #ffabab; }
.btn.icon { width: 30px; padding: 0; font-weight: 700; }
.workspace { flex: 1; min-height: 0; display: grid; grid-template-columns: 280px 430px minmax(360px, 1fr); }
.pane { min-width: 0; min-height: 0; overflow: auto; border-right: 1px solid #343842; }
.pane:last-child { border-right: 0; }
.pane-head {
position: sticky; top: 0; z-index: 2; min-height: 42px; padding: 8px 12px;
display: flex; align-items: center; gap: 8px; background: #1d2026; border-bottom: 1px solid #343842;
}
.pane-title { font-weight: 650; flex: 1; }
.tabs { display: flex; gap: 4px; }
.tab { border: 0; background: transparent; color: #9da5b2; padding: 6px 8px; border-bottom: 2px solid transparent; }
.tab.active { color: #fff; border-bottom-color: #5d9cf0; }
.list { padding: 8px; }
.list-item {
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px; align-items: center;
padding: 8px; margin-bottom: 6px; background: #22262e; border: 1px solid #353a45; border-radius: 6px;
}
.list-item.active { border-color: #5b91d8; background: #252c37; }
.list-main { min-width: 0; cursor: pointer; }
.list-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
.list-meta { margin-top: 3px; color: #9098a5; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.item-actions { display: flex; gap: 3px; }
.item-actions button { width: 24px; height: 24px; padding: 0; border: 0; border-radius: 4px; background: transparent; color: #aab1bc; }
.item-actions button:hover { background: #3a404c; color: #fff; }
.rule-group { display: block; width: 100%; padding: 10px; border: 0; border-bottom: 1px solid #3a404c; background: #252a33; color: inherit; text-align: left; overflow-wrap: anywhere; }
.form { padding: 14px; }
.section { margin-bottom: 18px; }
.section-title { margin: 0 0 10px; color: #7fb4f3; font-size: 12px; font-weight: 700; text-transform: uppercase; }
.field { margin-bottom: 11px; }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
label { display: block; margin-bottom: 5px; color: #b7bdc7; }
input, select, textarea {
width: 100%; border: 1px solid #424854; border-radius: 5px; background: #171a20; color: #edf0f5;
padding: 7px 8px; outline: none;
}
input:focus, select:focus, textarea:focus { border-color: #5b96e4; box-shadow: 0 0 0 2px rgba(91,150,228,.14); }
textarea { min-height: 78px; resize: vertical; line-height: 1.45; }
.hint { color: #858d99; font-size: 11px; margin-top: 4px; line-height: 1.4; }
.preview-controls { padding: 12px; border-bottom: 1px solid #343842; background: #1d2026; }
.preview-controls textarea { height: 180px; font-family: "SF Mono", Menlo, Consolas, monospace; }
.preview-wrap { padding: 14px; }
.preview-path-tags { display: flex; min-height: 19px; gap: 6px; margin-bottom: 10px; }
.preview-code {
min-height: 220px; margin: 0; padding: 12px; white-space: pre-wrap; overflow-wrap: anywhere;
border: 1px solid #383d48; border-radius: 6px; background: #14171c;
font-family: "SF Mono", Menlo, Consolas, monospace; line-height: 1.75; user-select: text;
}
.annotation-tag {
display: inline-flex; align-items: center; max-width: 220px; height: 19px; margin-left: 6px; padding: 0 6px;
vertical-align: 1px; border: 1px solid #4e87c7; border-radius: 4px; background: #243d5b;
color: #b8d9ff; font: 11px/17px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; user-select: none;
}
.annotation-tag.enum { border-color: #a77d38; background: #4a3922; color: #ffd99a; }
.annotation-tag.reference { border-color: #438a69; background: #213f34; color: #a9e7c9; }
.checkbox-control { display: flex; align-items: center; gap: 7px; margin: 0; color: #edf0f5; }
.checkbox-control input { width: auto; margin: 0; }
.preview-empty { color: #858d99; }
.error-box { margin: 10px 12px; padding: 9px; border: 1px solid #7a4141; border-radius: 5px; color: #ffb3b3; background: #382323; white-space: pre-wrap; }
.hidden { display: none !important; }
.file-input { display: none; }
</style>
</head>
<body>
<div class="app">
<header class="toolbar">
<div class="brand">pvfine 标注规则编辑器</div>
<button class="btn" id="importButton">导入 JSON</button>
<button class="btn" id="exportButton">导出 JSON</button>
<input class="file-input" id="importFile" type="file" accept="application/json,.json" />
<div class="toolbar-spacer"></div>
<div class="status" id="status">正在载入规则...</div>
</header>
<main class="workspace">
<aside class="pane">
<div class="pane-head">
<div class="tabs">
<button class="tab active" data-tab="rules">规则</button>
<button class="tab" data-tab="fields">共享字段</button>
<button class="tab" data-tab="relations">关联</button>
</div>
<button class="btn primary" id="addButton">新增</button>
</div>
<div class="list" id="itemList"></div>
</aside>
<section class="pane">
<div class="pane-head"><div class="pane-title" id="formTitle">规则详情</div></div>
<div class="error-box hidden" id="errorBox"></div>
<form class="form" id="editorForm"></form>
</section>
<section class="pane">
<div class="pane-head"><div class="pane-title">示例预览</div><button class="btn" id="previewButton">刷新预览</button></div>
<div class="preview-controls">
<div class="field">
<label for="previewPath">文件路径</label>
<input id="previewPath" value="equipment/example.equ" />
</div>
<div class="field">
<label for="previewVersion">PVF 版本</label>
<select id="previewVersion"><option value="90US">90US</option><option value="90CN">90CN</option><option value="110US">110US</option><option value="115US">115US</option><option value="">未知版本</option></select>
</div>
<div class="field">
<label for="previewText">反编译文本</label>
<textarea id="previewText">[rarity]
3
[name]
`示例装备`</textarea>
</div>
</div>
<div class="preview-wrap">
<div class="preview-path-tags" id="previewPathTags"></div>
<pre class="preview-code" id="previewCode"><span class="preview-empty">暂无预览</span></pre>
</div>
</section>
</main>
</div>
<script>
const API_TOKEN = "__EDITOR_TOKEN__";
const state = { document: null, tab: "rules", selected: 0, saveTimer: 0, previewTimer: 0 };
const expandedGroups = new Set([""]);
function ruleGroup(rule) { return (rule.group || "").trim(); }
const $ = (id) => document.getElementById(id);
async function api(path, options = {}) {
const headers = { ...(options.headers || {}), "X-Annotation-Editor-Token": API_TOKEN };
if (options.body) headers["Content-Type"] = "application/json";
const response = await fetch(path, { ...options, headers, cache: "no-store" });
if (!response.ok) {
let message = `请求失败 (${response.status})`;
try { message = (await response.json()).error || message; } catch {}
throw new Error(message);
}
return response.json();
}
function setStatus(message, kind = "") {
$("status").textContent = message;
$("status").className = `status ${kind}`;
}
function showError(message = "") {
$("errorBox").textContent = message;
$("errorBox").classList.toggle("hidden", !message);
}
function rules() { return state.document.rules || (state.document.rules = []); }
function fields() { return state.document.fields || (state.document.fields = []); }
function relationEntries() { return Object.entries(state.document.relations || (state.document.relations = {})); }
function previewProviderText(preview) {
if (!preview) return "";
return preview.providers?.length ? preview.providers.join(", ") : (preview.provider || "");
}
function setPreviewProviders(preview, value) {
const providers = value.split(",").map((item) => item.trim()).filter(Boolean);
if (providers.length <= 1) {
preview.provider = providers[0] || "";
delete preview.providers;
} else {
delete preview.provider;
preview.providers = [...new Set(providers)];
}
}
function currentRule() { return rules()[state.selected] || null; }
function currentField() { return fields()[state.selected] || null; }
function currentRelation() { return relationEntries()[state.selected] || null; }
function renderAll() {
if (state.tab === "rules" && currentRule()) expandedGroups.add(ruleGroup(currentRule()));
document.querySelectorAll(".tab").forEach((tab) => tab.classList.toggle("active", tab.dataset.tab === state.tab));
renderList();
renderForm();
}
function renderList() {
const container = $("itemList");
container.replaceChildren();
const items = state.tab === "rules"
? rules().map((rule, index) => ({ key: rule.id || `未命名规则 ${index + 1}`, meta: `${rule.target?.kind || "?"} · ${rule.annotation?.title || "无标题"}` }))
: state.tab === "fields"
? fields().map((item, index) => ({ key: item.id || `未命名字段 ${index + 1}`, meta: `${previewProviderText(item.preview) || "无预览"} · ${item.annotation?.title || "无标题"}` }))
: relationEntries().map(([name, relation]) => ({ key: name, meta: relation.listPath || "未配置列表文件" }));
const appendItem = (item, index) => {
const row = document.createElement("div");
row.className = `list-item ${index === state.selected ? "active" : ""}`;
const main = document.createElement("div");
main.className = "list-main";
main.innerHTML = `<div class="list-name"></div><div class="list-meta"></div>`;
main.querySelector(".list-name").textContent = item.key;
main.querySelector(".list-meta").textContent = item.meta;
main.onclick = () => { state.selected = index; renderAll(); };
const actions = document.createElement("div");
actions.className = "item-actions";
if (state.tab === "rules" || state.tab === "fields") {
actions.append(actionButton("↑", "上移", () => moveItem(index, -1), index === 0));
actions.append(actionButton("↓", "下移", () => moveItem(index, 1), index === items.length - 1));
}
actions.append(actionButton("×", "删除", () => removeItem(index), false));
if (state.tab === "rules") {
const copyButton = actionButton("⧉", "复制规则", () => copyRule(index), false);
copyButton.setAttribute("aria-label", "复制规则");
actions.append(copyButton);
}
if (state.tab === "fields") {
const copyButton = actionButton("⧉", "复制字段", () => copyField(index), false);
copyButton.setAttribute("aria-label", "复制字段");
actions.append(copyButton);
}
row.append(main, actions);
container.append(row);
};
if (state.tab !== "rules") {
items.forEach(appendItem);
return;
}
const groups = new Map();
rules().forEach((rule, index) => {
const group = ruleGroup(rule);
if (!groups.has(group)) groups.set(group, []);
groups.get(group).push(index);
});
for (const [group, indices] of groups) {
const expanded = expandedGroups.has(group);
const header = document.createElement("button");
header.type = "button";
header.className = "rule-group";
header.textContent = `${expanded ? "▾" : "▸"} ${group || "未分组"} (${indices.length})`;
header.setAttribute("aria-expanded", String(expanded));
header.onclick = () => {
if (expanded) expandedGroups.delete(group);
else expandedGroups.add(group);
renderList();
};
container.append(header);
if (expanded) indices.forEach((index) => appendItem(items[index], index));
}
}
function actionButton(text, title, handler, disabled) {
const button = document.createElement("button");
button.type = "button";
button.textContent = text;
button.title = title;
button.disabled = disabled;
button.onclick = (event) => { event.stopPropagation(); handler(); };
return button;
}
function renderForm() {
showError();
if (state.tab === "rules") renderRuleForm(currentRule());
else if (state.tab === "fields") renderFieldForm(currentField());
else renderRelationForm(currentRelation());
}
function renderRuleForm(rule) {
$("formTitle").textContent = "规则详情";
const form = $("editorForm");
if (!rule) { form.innerHTML = '<div class="preview-empty">点击“新增”创建规则</div>'; return; }
const referencedField = rule.field ? fields().find((item) => item.id === rule.field) : null;
const target = rule.target || referencedField?.target || { kind: "section", section: "name" };
const annotation = rule.annotation || referencedField?.annotation || { title: "", type: "text" };
const match = rule.match || referencedField?.match || {};
form.innerHTML = `
<div class="section"><div class="section-title">基本信息</div>
${field("规则 ID", input("rule-id", rule.id || ""), "规则顺序决定冲突时显示优先级")}
${field("说明", textarea("rule-description", rule.description || ""))}
${field("分组", `<input id="rule-group" list="rule-groups" value="${escapeAttr(rule.group || "")}" /><datalist id="rule-groups">${[...new Set(rules().map(ruleGroup).filter(Boolean))].map((group) => `<option value="${escapeAttr(group)}"></option>`).join("")}</datalist>`, "输入新分组或选择已有分组;留空为未分组,不影响规则优先级")}
${field("共享字段", select("rule-field", rule.field || "", [["","不引用共享字段"], ...fields().map((item) => [item.id, item.id])]), "引用后会继承字段的匹配、目标和标注元数据;下方显式填写的值仍可覆盖")}
</div>
<div class="section"><div class="section-title">文件匹配</div>
${field("文件后缀", input("match-extensions", (match.extensions || []).join(", ")), "逗号分隔,例如 .equ, .stk;留空表示不限")}
${field("路径 Glob", input("match-glob", match.glob || ""), "支持 *、? 和跨目录的 **")}
</div>
<div class="section"><div class="section-title">目标位置</div>
${field("目标类型", select("target-kind", target.kind, [["path","路径"],["section","Section"],["token","Token"]]))}
<div id="sectionFields" class="${target.kind === "path" ? "hidden" : ""}">
${field("Section 名称", input("target-section", target.section || ""), "所有同名 section 都会命中")}
</div>
<div id="tokenFields" class="${target.kind === "token" ? "" : "hidden"}">
${field("Token 选择", select("token-mode", target.range ? "range" : "index", [["index","单个下标"],["range","下标范围"]]))}
<div id="tokenIndexField" class="${target.range ? "hidden" : ""}">${field(annotation.type === "image" ? "图片索引 Token 下标" : "Token 下标", numberInput("target-index", target.index ?? 0), annotation.type === "image" ? "正文图片会显示在这个图片索引旁边" : "")}</div>
<div id="tokenRangeFields" class="${target.range ? "field-row" : "hidden"}">
${field("起始下标", numberInput("range-start", target.range?.start ?? 0))}
${field("结束下标(不含)", numberInput("range-end", target.range?.endExclusive ?? 1))}
</div>
<div id="tokenRecordFields" class="${target.range ? "hidden" : ""}">
${field("起始偏移 offset", numberInput("target-offset", target.offset ?? 0), "只在整个 Section 开头跳过一次,之后才按分隔值分组")}
${field("每组偏移 groupOffset", numberInput("target-group-offset", target.groupOffset ?? 0), "每个分组(包括第一组)都先跳过这么多个 Token,再开始按记录匹配")}
${field("独占行分隔值 standaloneValues", input("target-standalone-values", standaloneValuesText(target.standaloneValues)), "整数列表,用逗号分隔,例如 -1 或 -1, -2;分隔值不参与标注,每次遇到都会重新分组")}
${field("每条记录 Token 数", numberInput("target-record-tokens", target.recordTokens || 0), "配置后会按固定记录重复匹配,例如 skill levelup 为 3")}
${field("动态记录宽度 Token", optionalNumberInput("target-tokens-per-line", target.tokensPerLineIndex), "可选;读取 Section 中指定下标的 Token 作为记录宽度,下标从 0 开始")}
${field("上下文 Token 下标", numberInput("target-context-index", target.contextIndex ?? 0), "可选;留空时使用关联类型中的设置")}
<div id="imagePathField" class="${annotation.type === "image" ? "" : "hidden"}">
${field("IMG 路径 Token 下标", numberInput("target-image-path-token", target.imagePathToken ?? 0), "与图片索引 Token 位于同一条记录,索引从 0 开始")}
</div>
</div>
</div>
</div>
<div class="section"><div class="section-title">标注内容</div>
${field("Tag 标题", input("annotation-title", annotation.title || ""), "可留空;留空时不显示 Tag,路径等跳转仍可通过 Cmd/Ctrl+单击正文使用")}
${field("标注类型", select("annotation-type", annotation.type, [["text","文本"],["image","图片"],["enum","枚举"],["reference","关联 ID"],["path","路径"]]))}
<div id="pathFields" class="${annotation.type === "path" ? "" : "hidden"}">
${field("路径根目录", input("annotation-path-root", annotation.pathRoot || ""), "可选;填写归档内目录,例如 equipment/character。留空时从归档根目录查找")}
</div>
<div id="inlineImageField" class="${annotation.type === "image" ? "" : "hidden"}">
${field("正文显示图片", `<label class="checkbox-control" for="annotation-inline-image"><input id="annotation-inline-image" type="checkbox" ${annotation.inlineImage ? "checked" : ""} />在标注 Tag 中显示图片</label>`, "关闭时仅在悬浮提示中异步加载图片")}
</div>
${field("Tooltip 内容", textarea("annotation-content", annotation.content || ""))}
<div id="enumFields" class="${annotation.type === "enum" ? "" : "hidden"}">
${field("枚举映射", textarea("annotation-values", valuesToText(annotation.values)), "每行一个 value=名称")}
</div>
<div id="referenceFields" class="${annotation.type === "reference" ? "" : "hidden"}">
${field("关联类型", select("annotation-relation", annotation.relation || "", [["","请选择"], ...relationEntries().map(([name]) => [name, name])]))}
</div>
</div>
${versionFields(rule)}`;
bindRuleForm(rule);
}
function versionFields(definition) {
return `<div class="section"><div class="section-title">适用 PVF 版本</div>
${field("版本(可多选)", ["90US", "90CN", "110US", "115US"].map((version) => `<label class="checkbox-control" for="pvf-version-${version}"><input id="pvf-version-${version}" type="checkbox" ${(definition.pvfVersions || []).includes(version) ? "checked" : ""} />${version}</label>`).join(""), "未选择表示全版本适用;引用共享字段时,两处版本限制须同时满足")}
</div>`;
}
function bindVersions(definition) {
const versions = ["90US", "90CN", "110US", "115US"];
for (const version of versions) {
bindChecked(`pvf-version-${version}`, () => {
const selected = versions.filter((value) => $(`pvf-version-${value}`).checked);
if (selected.length) definition.pvfVersions = selected;
else delete definition.pvfVersions;
});
}
}
function bindRuleForm(rule) {
bindVersions(rule);
const referencedField = rule.field ? fields().find((item) => item.id === rule.field) : null;
rule.match ||= structuredClone(referencedField?.match || {});
rule.target ||= structuredClone(referencedField?.target || { kind: "section", section: "name" });
rule.annotation ||= structuredClone(referencedField?.annotation || { title: "", type: "text" });
bind("rule-id", "input", (value) => rule.id = value);
bind("rule-description", "input", (value) => rule.description = value);
bind("rule-field", "change", (value) => {
if (value) rule.field = value;
else delete rule.field;
});
bind("rule-group", "change", (value) => {
rule.group = value.trim();
expandedGroups.add(ruleGroup(rule));
});
bind("match-extensions", "input", (value) => rule.match.extensions = value.split(",").map((item) => item.trim()).filter(Boolean));
bind("match-glob", "input", (value) => rule.match.glob = value);
bind("target-section", "input", (value) => rule.target.section = value);
bind("target-index", "input", (value) => rule.target.index = number(value));
bind("target-record-tokens", "input", (value) => rule.target.recordTokens = number(value));
bind("target-offset", "input", (value) => rule.target.offset = number(value));
bind("target-group-offset", "input", (value) => rule.target.groupOffset = number(value));
bindStandaloneValues("target-standalone-values", rule.target);
bind("target-tokens-per-line", "input", (value) => { if (value.trim()) rule.target.tokensPerLineIndex = number(value); else delete rule.target.tokensPerLineIndex; });
bind("target-context-index", "input", (value) => rule.target.contextIndex = number(value));
bind("target-image-path-token", "input", (value) => rule.target.imagePathToken = number(value));
bind("range-start", "input", (value) => rule.target.range.start = number(value));
bind("range-end", "input", (value) => rule.target.range.endExclusive = number(value));
bind("annotation-title", "input", (value) => rule.annotation.title = value);
bind("annotation-content", "input", (value) => rule.annotation.content = value);
bind("annotation-values", "input", (value) => rule.annotation.values = textToValues(value));
bind("annotation-relation", "change", (value) => rule.annotation.relation = value);
bind("annotation-path-root", "input", (value) => { if (value.trim()) rule.annotation.pathRoot = value; else delete rule.annotation.pathRoot; });
bind("target-kind", "change", (value) => {
rule.target.kind = value;
if (value === "path" || value === "section") {
clearTokenGrouping(rule.target);
delete rule.target.index; delete rule.target.range; delete rule.target.contextIndex;
delete rule.target.imagePathToken; delete rule.annotation.inlineImage;
delete rule.annotation.pathRoot; rule.annotation.type = "text";
}
if (value === "token" && rule.target.index == null && !rule.target.range) rule.target.index = rule.annotation.type === "image" ? 1 : 0;
if (value === "token" && rule.annotation.type === "image") { rule.target.recordTokens ||= 2; rule.target.imagePathToken ??= 0; }
renderAll(); scheduleSave();
});
bind("token-mode", "change", (value) => {
if (value === "range") {
clearTokenGrouping(rule.target);
delete rule.target.index; delete rule.target.contextIndex; delete rule.target.imagePathToken;
delete rule.annotation.inlineImage; delete rule.annotation.pathRoot;
rule.target.range = { start: 0, endExclusive: 1 }; rule.annotation.type = "text";
}
else {
rule.target.index = 0;
delete rule.target.range;
if (rule.annotation.type === "image") { rule.target.recordTokens ||= 2; rule.target.imagePathToken ??= 0; }
}
renderAll(); scheduleSave();
});
bind("annotation-type", "change", (value) => {
rule.annotation.type = value;
if (value === "enum") rule.annotation.values ||= {};
if (value === "reference") rule.annotation.relation ||= "";
// enum/reference/image 只能作用于单个 token。用户通常会先选标注类型,
// 因此这里自动把目标切换到 token,避免兼容性检查把选择静默改回文本。
if (value !== "text" && (rule.target.kind !== "token" || rule.target.range)) {
rule.target.kind = "token";
rule.target.section ||= "name";
rule.target.index = value === "image" ? 1 : 0;
delete rule.target.range;
}
if (value === "image") {
rule.target.index ??= 1;
rule.target.recordTokens ||= 2;
rule.target.imagePathToken ??= rule.target.index === 0 ? 1 : 0;
}
if (rule.annotation.type !== "image") delete rule.target.imagePathToken;
if (rule.annotation.type !== "image") delete rule.annotation.inlineImage;
if (rule.annotation.type !== "path") delete rule.annotation.pathRoot;
if ((rule.target.kind !== "token" || rule.target.range) && rule.annotation.type !== "text") rule.annotation.type = "text";
renderAll(); scheduleSave();
});
bindChecked("annotation-inline-image", (checked) => {
if (checked) rule.annotation.inlineImage = true;
else delete rule.annotation.inlineImage;
});
}
function renderFieldForm(fieldDefinition) {
$("formTitle").textContent = "共享字段详情";
const form = $("editorForm");
if (!fieldDefinition) { form.innerHTML = '<div class="preview-empty">点击“新增”创建共享字段</div>'; return; }
const target = fieldDefinition.target || (fieldDefinition.target = { kind: "token", section: "name", index: 0 });
const annotation = fieldDefinition.annotation || (fieldDefinition.annotation = { title: "", type: "text" });
const match = fieldDefinition.match || (fieldDefinition.match = {});
const preview = fieldDefinition.preview || null;
form.innerHTML = `
<div class="section"><div class="section-title">基本信息</div>
${field("字段 ID", input("field-id", fieldDefinition.id || ""), "供 rules.field 和预览 provider 引用,保存后应保持稳定")}
</div>
<div class="section"><div class="section-title">文件匹配</div>
${field("文件后缀", input("field-match-extensions", (match.extensions || []).join(", ")), "逗号分隔,例如 .equ;留空表示不限")}
${field("路径 Glob", input("field-match-glob", match.glob || ""), "支持 *、? 和跨目录的 **")}
</div>
<div class="section"><div class="section-title">目标位置</div>
${field("目标类型", select("field-target-kind", target.kind, [["path","路径"],["section","Section"],["token","Token"]]))}
<div id="fieldSectionFields" class="${target.kind === "path" ? "hidden" : ""}">
${field("Section 名称", input("field-target-section", target.section || ""), "大小写不敏感,所有同名 section 都会命中")}
</div>
<div id="fieldTokenFields" class="${target.kind === "token" ? "" : "hidden"}">
${field("Token 选择", select("field-token-mode", target.range ? "range" : "index", [["index","单个下标"],["range","下标范围"]]))}
<div id="fieldTokenIndexField" class="${target.range ? "hidden" : ""}">
${field(annotation.type === "image" ? "图片索引 Token 下标" : "Token 下标", numberInput("field-target-index", target.index ?? 0))}
</div>
<div id="fieldTokenRangeFields" class="${target.range ? "field-row" : "hidden"}">
${field("起始下标", numberInput("field-range-start", target.range?.start ?? 0))}
${field("结束下标(不含)", numberInput("field-range-end", target.range?.endExclusive ?? 1))}
</div>
<div id="fieldRecordFields" class="${target.range ? "hidden" : ""}">
${field("起始偏移", numberInput("field-target-offset", target.offset ?? 0), "跳过 section 开头的直接 token")}
${field("每组偏移 groupOffset", numberInput("field-target-group-offset", target.groupOffset ?? 0), "每个分组(包括第一组)先跳过指定数量的 Token;与 Section 起始偏移分别计算")}
${field("独占行分隔值 standaloneValues", input("field-target-standalone-values", standaloneValuesText(target.standaloneValues)), "整数列表,逗号分隔,例如 -1;分隔值自身不标注,后续重新分组")}
${field("每条记录 Token 数", numberInput("field-target-record-tokens", target.recordTokens || 0), "例如 skill levelup 为 3;非重复字段留空")}
${field("动态记录宽度 Token", optionalNumberInput("field-target-tokens-per-line", target.tokensPerLineIndex), "可选;留空表示不启用,下标 0 表示第一个 Token")}
${field("上下文 Token 下标", numberInput("field-target-context-index", target.contextIndex ?? 0), "用于 contextual relation,例如职业上下文")}
<div id="fieldImagePathField" class="${annotation.type === "image" ? "" : "hidden"}">
${field("IMG 路径 Token 下标", numberInput("field-target-image-path-token", target.imagePathToken ?? 0), "与图片索引 Token 位于同一条记录")}
</div>
</div>
</div>
</div>
<div class="section"><div class="section-title">标注元数据</div>
${field("Tag 标题", input("field-annotation-title", annotation.title || ""), "可留空;留空时不显示 Tag")}
${field("标注类型", select("field-annotation-type", annotation.type, [["text","文本"],["image","图片"],["enum","枚举"],["reference","关联 ID"],["path","路径"]]))}
<div id="fieldPathFields" class="${annotation.type === "path" ? "" : "hidden"}">
${field("路径根目录", input("field-annotation-path-root", annotation.pathRoot || ""), "可选;填写归档内目录,例如 equipment/character。留空时从归档根目录查找")}
</div>
<div id="fieldInlineImageField" class="${annotation.type === "image" ? "" : "hidden"}">
${field("正文显示图片", `<label class="checkbox-control" for="field-annotation-inline-image"><input id="field-annotation-inline-image" type="checkbox" ${annotation.inlineImage ? "checked" : ""} />在标注 Tag 中显示图片</label>`)}
</div>
${field("Tooltip 内容", textarea("field-annotation-content", annotation.content || ""))}
${field("枚举映射", textarea("field-annotation-values", valuesToText(annotation.values)), "每行一个 value=名称;文本字段也可以用它提供预览显示名称")}
${field("关联类型", select("field-annotation-relation", annotation.relation || "", [["","不使用关联"], ...relationEntries().map(([name]) => [name, name])]))}
</div>
<div class="section"><div class="section-title">预览元数据</div>
${field("启用预览元数据", `<label class="checkbox-control" for="field-preview-enabled"><input id="field-preview-enabled" type="checkbox" ${preview ? "checked" : ""} />让预览 provider 使用这个字段</label>`, "关闭时字段仍可被 rules.field 引用,但不会出现在结构化预览中")}
<div id="fieldPreviewSettings" class="${preview ? "" : "hidden"}">
${field("Provider", input("field-preview-provider", previewProviderText(preview)), "逗号分隔,例如 equ, stk;也可以只填写一个 provider")}
${field("显示角色", input("field-preview-role", preview?.role || ""), "例如 rarity、base-attribute、skill-levelup")}
${field("显示分组", input("field-preview-group", preview?.group || ""), "例如 summary、base、skills")}
<div class="field-row">
${field("排序", numberInput("field-preview-order", preview?.order ?? 0))}
${field("格式", input("field-preview-format", preview?.format || "text"), "例如 enum、range、signed-number")}
</div>
${field("显示标签", input("field-preview-label", preview?.label || ""), "可选;为空时使用标注标题")}
</div>
</div>
${versionFields(fieldDefinition)}`;
bindFieldForm(fieldDefinition);
}
function ensureFieldPreview(fieldDefinition) {
fieldDefinition.preview ||= { provider: "", role: "", group: "", order: 0, format: "text" };
return fieldDefinition.preview;
}
function bindFieldForm(fieldDefinition) {
bindVersions(fieldDefinition);
const target = () => fieldDefinition.target;
const annotation = () => fieldDefinition.annotation;
bind("field-id", "input", (value) => fieldDefinition.id = value);
bind("field-match-extensions", "input", (value) => fieldDefinition.match.extensions = value.split(",").map((item) => item.trim()).filter(Boolean));
bind("field-match-glob", "input", (value) => fieldDefinition.match.glob = value);
bind("field-target-section", "input", (value) => target().section = value);
bind("field-target-index", "input", (value) => target().index = number(value));
bind("field-range-start", "input", (value) => target().range.start = number(value));
bind("field-range-end", "input", (value) => target().range.endExclusive = number(value));
bind("field-target-offset", "input", (value) => target().offset = number(value));
bind("field-target-group-offset", "input", (value) => target().groupOffset = number(value));
bindStandaloneValues("field-target-standalone-values", target());
bind("field-target-record-tokens", "input", (value) => target().recordTokens = number(value));
bind("field-target-tokens-per-line", "input", (value) => { if (value.trim()) target().tokensPerLineIndex = number(value); else delete target().tokensPerLineIndex; });
bind("field-target-context-index", "input", (value) => target().contextIndex = number(value));
bind("field-target-image-path-token", "input", (value) => target().imagePathToken = number(value));
bind("field-annotation-title", "input", (value) => annotation().title = value);
bind("field-annotation-content", "input", (value) => annotation().content = value);
bind("field-annotation-values", "input", (value) => annotation().values = textToValues(value));
bind("field-annotation-relation", "change", (value) => annotation().relation = value);
bind("field-annotation-path-root", "input", (value) => { if (value.trim()) annotation().pathRoot = value; else delete annotation().pathRoot; });
bind("field-preview-provider", "input", (value) => setPreviewProviders(ensureFieldPreview(fieldDefinition), value));
bind("field-preview-role", "input", (value) => ensureFieldPreview(fieldDefinition).role = value);
bind("field-preview-group", "input", (value) => ensureFieldPreview(fieldDefinition).group = value);
bind("field-preview-order", "input", (value) => ensureFieldPreview(fieldDefinition).order = number(value));
bind("field-preview-format", "input", (value) => ensureFieldPreview(fieldDefinition).format = value);
bind("field-preview-label", "input", (value) => ensureFieldPreview(fieldDefinition).label = value);
bindChecked("field-annotation-inline-image", (checked) => {
if (checked) annotation().inlineImage = true;
else delete annotation().inlineImage;
});
bindChecked("field-preview-enabled", (checked) => {
if (checked) ensureFieldPreview(fieldDefinition);
else delete fieldDefinition.preview;
renderAll();
});
bind("field-target-kind", "change", (value) => {
target().kind = value;
if (value === "path" || value === "section") {
clearTokenGrouping(target());
delete target().index; delete target().range;
delete target().contextIndex; delete target().imagePathToken;
if (annotation().type !== "text") annotation().type = "text";
delete annotation().pathRoot;
}
if (value === "token" && target().index == null && !target().range) target().index = annotation().type === "image" ? 1 : 0;
renderAll();
});
bind("field-token-mode", "change", (value) => {
if (value === "range") {
clearTokenGrouping(target());
delete target().index; delete target().contextIndex; delete target().imagePathToken;
target().range = { start: 0, endExclusive: 1 };
annotation().type = "text";
delete annotation().pathRoot;
} else {
target().index = 0; delete target().range;
if (annotation().type === "image") { target().recordTokens ||= 2; target().imagePathToken ??= 0; }
}
renderAll();
});
bind("field-annotation-type", "change", (value) => {
annotation().type = value;
if (value === "enum") annotation().values ||= {};
if (value === "reference") annotation().relation ||= "";
if (value !== "text" && (target().kind !== "token" || target().range)) {
target().kind = "token"; target().section ||= "name"; target().index = value === "image" ? 1 : 0;
delete target().range;
}
if (value === "image") { target().index ??= 1; target().recordTokens ||= 2; target().imagePathToken ??= target().index === 0 ? 1 : 0; }
if (value !== "image") { delete target().imagePathToken; delete annotation().inlineImage; }
if (value !== "path") delete annotation().pathRoot;
renderAll();
});
}
function renderRelationForm(entry) {
$("formTitle").textContent = "关联类型详情";
const form = $("editorForm");
if (!entry) { form.innerHTML = '<div class="preview-empty">点击“新增”创建关联类型</div>'; return; }
const [name, relation] = entry;
const relationKind = relation.kind || "list";
form.innerHTML = `
<div class="section"><div class="section-title">关联索引</div>
${field("类型名称", input("relation-name", name))}
${field("关联模式", select("relation-kind", relationKind, [["list","固定列表"],["contextual","上下文列表"],["union","联合关联"]]))}
<div id="relationListField" class="${relationKind !== "list" ? "hidden" : ""}">
${field("列表文件", input("relation-list", relation.listPath || ""))}
</div>
<div id="relationContextFields" class="${relationKind === "contextual" ? "" : "hidden"}">
${field("上下文 Token 下标", numberInput("relation-context-token", relation.contextToken ?? 0), "例如 skill levelup 的第一个字符串")}
${field("上下文到列表映射", textarea("relation-context-paths", contextPathsToText(relation.contextPaths)), "每行一个 上下文=列表路径,例如 [fighter]=skill/fighterskill.lst")}
</div>
<div id="relationUnionFields" class="${relationKind === "union" ? "" : "hidden"}">
${field("包含关联类型", input("relation-members", (relation.relations || []).join(", ")), "逗号分隔,例如 装备, 道具")}
</div>
<div class="field-row">
${field("ID Token", numberInput("relation-id-token", relation.idToken ?? 0))}
${field("路径 Token", numberInput("relation-path-token", relation.pathToken ?? 1))}
</div>
${field("每条记录 Token 数", numberInput("relation-record-tokens", relation.recordTokens || 2), "为 0 时按最大 token 下标自动推导")}
${field("名称 Section", input("relation-name-section", relation.nameSection || "name"))}
</div>`;
bind("relation-name", "change", (value) => renameRelation(name, value));
bind("relation-kind", "change", (value) => {
relation.kind = value;
renderAll();
});
bind("relation-list", "input", (value) => relation.listPath = value);
bind("relation-id-token", "input", (value) => relation.idToken = number(value));
bind("relation-path-token", "input", (value) => relation.pathToken = number(value));
bind("relation-record-tokens", "input", (value) => relation.recordTokens = number(value));
bind("relation-name-section", "input", (value) => relation.nameSection = value);
bind("relation-context-token", "input", (value) => relation.contextToken = number(value));
bind("relation-context-paths", "input", (value) => relation.contextPaths = textToContextPaths(value));
bind("relation-members", "input", (value) => relation.relations = value.split(",").map((item) => item.trim()).filter(Boolean));
}
function standaloneValuesText(values) { return (values || []).join(", "); }
function parseStandaloneValues(text) {
if (!text.trim()) return [];
return text.split(/[,,\s]+/).filter(Boolean).map((part) => {
if (!/^[+-]?\d+$/.test(part)) throw new Error("分隔值必须是整数,例如 -1, -2");
const number = Number(part);
if (!Number.isSafeInteger(number) || number < -2147483648 || number > 2147483647) {
throw new Error("分隔值超出 32 位整数范围");
}
return number;
});
}
function clearTokenGrouping(target) {
delete target.offset;
delete target.groupOffset;
delete target.standaloneValues;
delete target.recordTokens;
delete target.tokensPerLineIndex;
}
function bindStandaloneValues(id, target) {
const element = $(id);
if (!element) return;
element.addEventListener("input", () => {
try {
const values = parseStandaloneValues(element.value);
element.setCustomValidity("");
if (values.length) target.standaloneValues = values;
else delete target.standaloneValues;
showError();
renderList();
scheduleSave();
} catch (error) {
clearTimeout(state.saveTimer);
element.setCustomValidity(error.message);
showError(error.message);
setStatus("分隔值格式错误,未保存", "error");
}
});
}
function bind(id, eventName, setter) {
const element = $(id);
if (!element) return;
element.addEventListener(eventName, () => { setter(element.value); renderList(); scheduleSave(); });
}
function bindChecked(id, setter) {
const element = $(id);
if (!element) return;
element.addEventListener("change", () => { setter(element.checked); renderList(); scheduleSave(); });
}
function field(label, control, hint = "") {
return `<div class="field"><label>${escapeHTML(label)}</label>${control}${hint ? `<div class="hint">${escapeHTML(hint)}</div>` : ""}</div>`;
}
function input(id, value) { return `<input id="${id}" value="${escapeAttr(value)}" />`; }
function numberInput(id, value) { return `<input id="${id}" type="number" min="0" step="1" value="${Number(value) || 0}" />`; }
function optionalNumberInput(id, value) { return `<input id="${id}" type="number" min="0" step="1" value="${value == null ? "" : Number(value)}" />`; }
function textarea(id, value) { return `<textarea id="${id}">${escapeHTML(value)}</textarea>`; }
function select(id, value, options) {
return `<select id="${id}">${options.map(([key, label]) => `<option value="${escapeAttr(key)}" ${key === value ? "selected" : ""}>${escapeHTML(label)}</option>`).join("")}</select>`;
}
function addItem() {
if (state.tab === "rules") {
const index = rules().length + 1;
rules().push({ id: `rule.${index}`, description: "", match: {}, target: { kind: "section", section: "name" }, annotation: { title: "新标注", type: "text", content: "" } });
state.selected = rules().length - 1;
} else if (state.tab === "fields") {
const index = fields().length + 1;
fields().push({
id: `field.${index}`,
match: { extensions: [".equ"] },
target: { kind: "token", section: "name", index: 0 },
annotation: { title: "新字段", type: "text" },
preview: { provider: "", role: "", group: "", order: 0, format: "text" },
});
state.selected = fields().length - 1;
} else {
let index = relationEntries().length + 1;
let name = `relation${index}`;
while (state.document.relations[name]) name = `relation${++index}`;
state.document.relations[name] = { listPath: "", idToken: 0, pathToken: 1, recordTokens: 2, nameSection: "name" };
state.selected = relationEntries().length - 1;
}
renderAll(); scheduleSave();
}
function removeItem(index) {
if (state.tab === "rules") rules().splice(index, 1);
else if (state.tab === "fields") fields().splice(index, 1);
else delete state.document.relations[relationEntries()[index][0]];
state.selected = Math.max(0, Math.min(state.selected, itemCount() - 1));
renderAll(); scheduleSave();
}
function itemCount() {
if (state.tab === "rules") return rules().length;
if (state.tab === "fields") return fields().length;
return relationEntries().length;
}
function moveItem(index, delta) {
const items = state.tab === "rules" ? rules() : fields();
const target = index + delta;
if (target < 0 || target >= items.length) return;
[items[index], items[target]] = [items[target], items[index]];
state.selected = target;
renderAll(); scheduleSave();
}
function copyRule(index) {
const source = rules()[index];
if (!source) return;
const copy = structuredClone(source);
const baseID = `${source.id || "rule"}.copy`;
const existingIDs = new Set(rules().map((rule) => rule.id));
copy.id = baseID;
let suffix = 2;
while (existingIDs.has(copy.id)) copy.id = `${baseID}.${suffix++}`;
rules().push(copy);
state.selected = rules().length - 1;
renderAll(); scheduleSave();
}
function copyField(index) {
const source = fields()[index];
if (!source) return;
const copy = structuredClone(source);
const baseID = `${source.id || "field"}.copy`;
const existingIDs = new Set(fields().map((item) => item.id));
copy.id = baseID;
let suffix = 2;
while (existingIDs.has(copy.id)) copy.id = `${baseID}.${suffix++}`;
fields().push(copy);
state.selected = fields().length - 1;
renderAll(); scheduleSave();
}
function renameRelation(oldName, newName) {
newName = newName.trim();
if (!newName || (newName !== oldName && state.document.relations[newName])) {
showError("关联类型名称不能为空或重复");
return;
}
const relation = state.document.relations[oldName];
const entries = relationEntries();
const rebuilt = {};
entries.forEach(([name, value]) => rebuilt[name === oldName ? newName : name] = value);
state.document.relations = rebuilt;
rules().forEach((rule) => {
if (rule.annotation?.relation === oldName) rule.annotation.relation = newName;
});
renderAll(); scheduleSave();
}
function scheduleSave() {
clearTimeout(state.saveTimer);
setStatus("等待校验...");
state.saveTimer = setTimeout(saveRules, 350);
}
async function saveRules() {
try {
const invalidInput = $("editorForm").querySelector(":invalid");
if (invalidInput) { showError(invalidInput.validationMessage); setStatus("字段格式错误,未保存", "error"); return; }
setStatus("正在校验并写入...");
await api("/api/rules", { method: "PUT", body: JSON.stringify(state.document) });
showError(); setStatus("已写入 config/annotations.json,并更新备份", "ok");
await refreshPreview();
} catch (error) {
showError(error.message); setStatus("规则未写入", "error");
}
}
async function refreshPreview() {
try {
const path = $("previewPath").value;
const text = $("previewText").value;
const result = await api("/api/preview", {
method: "POST",
body: JSON.stringify({ path, text, pvfVersion: $("previewVersion").value, document: state.document }),
});
renderPathPreview(result.pathAnnotations || []);
renderPreview(text, result.annotations || []);
} catch (error) {
$("previewPathTags").replaceChildren();
$("previewCode").textContent = error.message;
}
}
function renderPathPreview(annotations) {
const container = $("previewPathTags");
container.replaceChildren();
annotations.forEach((annotation) => {
if (!annotation.title?.trim()) return;
const tag = document.createElement("span");
tag.className = `annotation-tag ${annotation.type || "text"}`;
tag.textContent = annotation.title;
tag.title = annotation.content;
tag.setAttribute("aria-label", annotation.content || annotation.title);
container.append(tag);
});
}
function renderPreview(text, annotations) {
const container = $("previewCode");
container.replaceChildren();
if (!annotations.length) {
const empty = document.createElement("span");
empty.className = "preview-empty";
empty.textContent = text || "当前示例没有命中标注";
container.append(empty);
return;
}
let cursor = 0;
annotations.slice().sort((a, b) => a.end - b.end).forEach((annotation) => {
const end = Math.max(cursor, Math.min(text.length, annotation.end));
container.append(document.createTextNode(text.slice(cursor, end)));
if (annotation.title?.trim()) {
const tag = document.createElement("span");
tag.className = `annotation-tag ${annotation.type || "text"}`;
tag.textContent = annotation.title;
tag.title = annotation.content;
tag.setAttribute("aria-label", annotation.content || annotation.title);
container.append(tag);
}
cursor = end;
});
container.append(document.createTextNode(text.slice(cursor)));
}
function valuesToText(values) {
return Object.entries(values || {}).map(([key, value]) => `${key}=${value}`).join("\n");
}
function textToValues(text) {
const values = {};
text.split(/\r?\n/).forEach((line) => {
const index = line.indexOf("=");
if (index > 0) values[line.slice(0, index).trim()] = line.slice(index + 1).trim();
});
return values;
}
function contextPathsToText(paths) {
return Object.entries(paths || {}).map(([key, value]) => `${key}=${value}`).join("\n");
}
function textToContextPaths(text) {
const paths = {};
text.split(/\r?\n/).forEach((line) => {
const index = line.indexOf("=");
if (index > 0) paths[line.slice(0, index).trim()] = line.slice(index + 1).trim();
});
return paths;
}
function number(value) { return Number.isFinite(Number(value)) ? Math.trunc(Number(value)) : 0; }
function escapeHTML(value) { return String(value).replace(/[&<>]/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[char])); }
function escapeAttr(value) { return escapeHTML(value).replace(/"/g, "&quot;"); }
async function importRules(file) {
try {
const imported = JSON.parse(await file.text());
imported.relations = state.document?.relations || {};
state.document = imported;
state.tab = "rules"; state.selected = 0;
renderAll(); await saveRules();
} catch (error) {
showError(`导入失败: ${error.message}`); setStatus("导入失败", "error");
}
}
function exportRules() {
const exported = structuredClone(state.document);
delete exported.relations;
const blob = new Blob([JSON.stringify(exported, null, 2) + "\n"], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url; link.download = "annotations.json"; link.click();
URL.revokeObjectURL(url);
}
document.querySelectorAll(".tab").forEach((tab) => tab.onclick = () => {
state.tab = tab.dataset.tab; state.selected = 0; renderAll();
});
$("addButton").onclick = addItem;
$("importButton").onclick = () => $("importFile").click();
$("importFile").onchange = () => { const file = $("importFile").files[0]; if (file) importRules(file); };
$("exportButton").onclick = exportRules;
$("previewButton").onclick = refreshPreview;
$("previewVersion").onchange = refreshPreview;
$("previewPath").oninput = () => {
clearTimeout(state.previewTimer);
state.previewTimer = setTimeout(refreshPreview, 250);
};
$("previewText").oninput = () => { clearTimeout(state.previewTimer); state.previewTimer = setTimeout(refreshPreview, 250); };
(async () => {
try {
state.document = await api("/api/rules");
state.document.relations ||= {};
state.document.fields ||= [];
state.document.rules ||= [];
renderAll(); await refreshPreview(); setStatus("规则已载入", "ok");
} catch (error) {
showError(error.message); setStatus("载入失败", "error");
}
})();
</script>
</body>
</html>