DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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

No files matched your search

+977
View File
@@ -0,0 +1,977 @@
<!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>