promptfigure 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -0
- package/adapters/claude-code/SKILL.md +381 -0
- package/adapters/claude-code/install.mjs +9 -0
- package/adapters/codex/marketplace.json +14 -0
- package/adapters/codex/promptfigure/.codex-plugin/plugin.json +6 -0
- package/adapters/codex/promptfigure/skills/promptfigure-local/SKILL.md +381 -0
- package/bin/pf.mjs +1953 -0
- package/package.json +44 -0
- package/scripts/build-adapters.mjs +80 -0
- package/scripts/test-e2e.mjs +117 -0
- package/skill/promptfigure-local/SKILL.md +381 -0
- package/src/anchor.mjs +63 -0
- package/src/config.mjs +75 -0
- package/src/craft-rules.mjs +158 -0
- package/src/craft.mjs +600 -0
- package/src/doc/docx.mjs +69 -0
- package/src/doc/index.mjs +35 -0
- package/src/doc/para.mjs +54 -0
- package/src/doc/tex.mjs +161 -0
- package/src/doc/texbuild.mjs +158 -0
- package/src/docsearch.mjs +96 -0
- package/src/entity-pair.mjs +17 -0
- package/src/events.mjs +52 -0
- package/src/extract.mjs +124 -0
- package/src/figure-catalog.mjs +326 -0
- package/src/journal.mjs +67 -0
- package/src/ledger.mjs +43 -0
- package/src/next.mjs +125 -0
- package/src/plan.mjs +112 -0
- package/src/png-trim.mjs +217 -0
- package/src/quality.mjs +325 -0
- package/src/ratio.mjs +87 -0
- package/src/render.mjs +247 -0
- package/src/review.mjs +48 -0
- package/src/server.mjs +218 -0
- package/src/store.mjs +91 -0
- package/src/vectorize.mjs +54 -0
- package/tray/pf-tray.py +265 -0
- package/web/app.js +613 -0
- package/web/index.html +73 -0
- package/web/probe.html +36 -0
- package/web/style.css +208 -0
package/web/app.js
ADDED
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
// app.js — GUI 逻辑(只读 + 唯一可操作:审批)
|
|
2
|
+
// GUI 不持有独立可变状态:一切以 /api/state 为准,SSE 推送后重取
|
|
3
|
+
const qs = new URLSearchParams(location.search);
|
|
4
|
+
const DOC = qs.get("doc");
|
|
5
|
+
const TOKEN = qs.get("token");
|
|
6
|
+
|
|
7
|
+
let state = null; // /api/state 结果
|
|
8
|
+
let rendered = false; // 文档是否已渲染(SSE 只重画高亮与侧栏)
|
|
9
|
+
let currentVer = null; // 浮层里当前看的大图
|
|
10
|
+
|
|
11
|
+
const $ = (s) => document.querySelector(s);
|
|
12
|
+
const docView = $("#doc-view");
|
|
13
|
+
|
|
14
|
+
// ---------- 初始化 ----------
|
|
15
|
+
init();
|
|
16
|
+
|
|
17
|
+
async function init() {
|
|
18
|
+
if (!DOC) { docView.textContent = "缺少 doc 参数(请从 pf open 打开的窗口进入)"; return; }
|
|
19
|
+
await loadState();
|
|
20
|
+
await renderDocument();
|
|
21
|
+
rendered = true;
|
|
22
|
+
paintAll();
|
|
23
|
+
|
|
24
|
+
const es = new EventSource(`/api/events?doc=${DOC}`);
|
|
25
|
+
es.onopen = () => $("#conn").classList.add("on");
|
|
26
|
+
es.onerror = () => $("#conn").classList.remove("on");
|
|
27
|
+
es.onmessage = async (e) => {
|
|
28
|
+
const evt = JSON.parse(e.data);
|
|
29
|
+
if (evt.type === "hello") return;
|
|
30
|
+
await loadState();
|
|
31
|
+
paintAll(); // AI 一操作,界面自动变 —— 因为状态源只有一个
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function loadState() {
|
|
36
|
+
const resp = await fetch(`/api/state?doc=${DOC}`);
|
|
37
|
+
if (!resp.ok) { docView.textContent = "加载状态失败(doc 不存在?重新 pf open 一次)"; throw new Error("state"); }
|
|
38
|
+
state = await resp.json();
|
|
39
|
+
// 字段兜底:老 meta / 异常状态不让 GUI 崩
|
|
40
|
+
state.anchors = state.anchors || [];
|
|
41
|
+
state.figures = state.figures || {};
|
|
42
|
+
state.review = state.review || {};
|
|
43
|
+
state.events = state.events || [];
|
|
44
|
+
state.meta.blocks = state.meta.blocks || [];
|
|
45
|
+
$("#doc-title").textContent = `${state.meta.fileName} · ${DOC}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------- 文档渲染(只读) ----------
|
|
49
|
+
async function renderDocument() {
|
|
50
|
+
const kind = state.meta.kind;
|
|
51
|
+
try {
|
|
52
|
+
if (kind === "docx") {
|
|
53
|
+
const buf = await fetch(`/api/file?doc=${DOC}`).then((r) => r.arrayBuffer());
|
|
54
|
+
docView.classList.remove("loading");
|
|
55
|
+
docView.innerHTML = "";
|
|
56
|
+
await window.docx.renderAsync(buf, docView, undefined, { inWrapper: true });
|
|
57
|
+
} else if (kind === "pdf" || (kind === "tex" && state.meta.pdfReady)) {
|
|
58
|
+
docView.classList.remove("loading");
|
|
59
|
+
docView.innerHTML = "";
|
|
60
|
+
await renderPdf(`/api/file?doc=${DOC}`);
|
|
61
|
+
} else if (kind === "tex") {
|
|
62
|
+
// 没编译成功(缺引擎/编译错)→ 文本模式兜底
|
|
63
|
+
docView.classList.remove("loading");
|
|
64
|
+
docView.innerHTML = "";
|
|
65
|
+
if (state.meta.pdfFailReason === "no-engine") {
|
|
66
|
+
const tip = document.createElement("div");
|
|
67
|
+
tip.className = "tex-tip";
|
|
68
|
+
tip.textContent = "⚠️ 本机没有 TeX 环境,当前显示文本预览。让 AI 执行 pf setup-tex(自动装便携编译器)或自装 TeX Live/MiKTeX 后,执行 pf doc compile 即可看到排版后的 PDF。";
|
|
69
|
+
docView.appendChild(tip);
|
|
70
|
+
}
|
|
71
|
+
for (const b of state.meta.blocks || []) {
|
|
72
|
+
const el = document.createElement(b.type === "heading" ? "div" : "p");
|
|
73
|
+
el.className = b.type === "heading" ? `tex-h${Math.min(b.level, 3)}` : b.type === "caption" ? "tex-cap tex-p" : "tex-p";
|
|
74
|
+
el.dataset.blockIdx = b.i;
|
|
75
|
+
el.textContent = b.text;
|
|
76
|
+
docView.appendChild(el);
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
docView.textContent = `暂不支持在线预览 .${kind}`;
|
|
80
|
+
}
|
|
81
|
+
} catch (err) {
|
|
82
|
+
docView.textContent = "文档渲染失败:" + (err.message || err);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------- PDF 渲染(canvas + textLayer:textLayer 里的文本参与高亮定位) ----------
|
|
87
|
+
async function renderPdf(url) {
|
|
88
|
+
const pdfjs = window.pdfjsLib;
|
|
89
|
+
pdfjs.GlobalWorkerOptions.workerSrc = "/vendor/pdf.worker.min.js";
|
|
90
|
+
const pdf = await pdfjs.getDocument(url).promise;
|
|
91
|
+
const scale = 1.5;
|
|
92
|
+
for (let i = 1; i <= pdf.numPages; i++) {
|
|
93
|
+
const page = await pdf.getPage(i);
|
|
94
|
+
const vp = page.getViewport({ scale });
|
|
95
|
+
const wrap = document.createElement("div");
|
|
96
|
+
wrap.className = "pdf-page";
|
|
97
|
+
wrap.style.width = vp.width + "px";
|
|
98
|
+
wrap.style.height = vp.height + "px";
|
|
99
|
+
wrap.style.setProperty("--scale-factor", scale);
|
|
100
|
+
const canvas = document.createElement("canvas");
|
|
101
|
+
canvas.width = vp.width; canvas.height = vp.height;
|
|
102
|
+
wrap.appendChild(canvas);
|
|
103
|
+
const textLayer = document.createElement("div");
|
|
104
|
+
textLayer.className = "textLayer";
|
|
105
|
+
wrap.appendChild(textLayer);
|
|
106
|
+
docView.appendChild(wrap);
|
|
107
|
+
await page.render({ canvasContext: canvas.getContext("2d"), viewport: vp }).promise;
|
|
108
|
+
// 文本层失败不致命:只是这页没法高亮
|
|
109
|
+
try {
|
|
110
|
+
const src = page.streamTextContent ? page.streamTextContent() : await page.getTextContent();
|
|
111
|
+
await pdfjs.renderTextLayer({ textContentSource: src, container: textLayer, viewport: vp }).promise;
|
|
112
|
+
} catch (e) { console.warn("[pf] 第" + i + "页文本层失败", e); }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------- 高亮(用户要求的核心体验) ----------
|
|
117
|
+
// 设计:
|
|
118
|
+
// · 荧光笔色号(5 色循环)= 「这是哪张图的上下文」
|
|
119
|
+
// · 同一段被多张图的上下文覆盖(重叠)→ 背景色加深纹理 + 底部每图一条色带 + title 列出全部
|
|
120
|
+
// · 审批状态(待审/通过/驳回/原文改动/丢失)→ 顶部细色条,与色号解耦
|
|
121
|
+
// · 文本模式下(tex 未编译成 PDF),锚点 --at 指向的整段也打淡荧光(段落级上下文)
|
|
122
|
+
const HL_COLORS = [
|
|
123
|
+
{ bg: "#fff3c2", line: "#e6b800" }, // 0 黄
|
|
124
|
+
{ bg: "#d9f2e0", line: "#2e9e5b" }, // 1 绿
|
|
125
|
+
{ bg: "#d8e7ff", line: "#3b74d1" }, // 2 蓝
|
|
126
|
+
{ bg: "#ecdfff", line: "#7a5bc9" }, // 3 紫
|
|
127
|
+
{ bg: "#ffe7d1", line: "#e07b1f" }, // 4 橙
|
|
128
|
+
];
|
|
129
|
+
const STATE_BAR = { ok: "#e6b800", approved: "#2e9e5b", rejected: "#d64545", changed: "#e07b1f", lost: "#b3b3b3" };
|
|
130
|
+
const STATE_LABEL = { ok: "待审批", approved: "已通过", rejected: "已驳回", changed: "原文已改动", lost: "位置丢失" };
|
|
131
|
+
|
|
132
|
+
function norm(s) { return (s || "").replace(/\s+/g, " "); }
|
|
133
|
+
|
|
134
|
+
function unwrapHighlights() {
|
|
135
|
+
docView.querySelectorAll("span.pf-hl").forEach((sp) => {
|
|
136
|
+
const parent = sp.parentNode;
|
|
137
|
+
while (sp.firstChild) parent.insertBefore(sp.firstChild, sp);
|
|
138
|
+
parent.removeChild(sp);
|
|
139
|
+
parent.normalize();
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function buildTextIndex() {
|
|
144
|
+
const walker = document.createTreeWalker(docView, NodeFilter.SHOW_TEXT, {
|
|
145
|
+
acceptNode: (n) => (n.data.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT),
|
|
146
|
+
});
|
|
147
|
+
const nodes = [];
|
|
148
|
+
let combined = "";
|
|
149
|
+
let n;
|
|
150
|
+
while ((n = walker.nextNode())) {
|
|
151
|
+
const t = norm(n.data);
|
|
152
|
+
nodes.push({ node: n, start: combined.length, end: combined.length + t.length });
|
|
153
|
+
combined += t;
|
|
154
|
+
}
|
|
155
|
+
return { nodes, combined };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 解析 "§3.2 ¶2" → { sec: "3.2", para: 2 };解析失败返回 null
|
|
159
|
+
function parseAt(at) {
|
|
160
|
+
const m = /§?\s*([\d.]+)\s*¶\s*(\d+)/.exec(at || "");
|
|
161
|
+
return m ? { sec: m[1].replace(/\.$/, ""), para: Number(m[2]) } : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 文本模式:--at 指向的整段打淡荧光(段落级上下文,与 quote 精确高亮叠加)
|
|
165
|
+
function paintCtxParagraphs() {
|
|
166
|
+
docView.querySelectorAll(".pf-ctx").forEach((el) => {
|
|
167
|
+
el.classList.remove("pf-ctx");
|
|
168
|
+
el.style.removeProperty("--pf-ctx-bg");
|
|
169
|
+
el.style.removeProperty("--pf-ctx-line");
|
|
170
|
+
el.removeAttribute("data-ctx-figs");
|
|
171
|
+
el.removeAttribute("title");
|
|
172
|
+
});
|
|
173
|
+
if (!(state.meta.kind === "tex" && !state.meta.pdfReady)) return; // PDF/docx 模式定位不到段落元素
|
|
174
|
+
for (const a of state.anchors) {
|
|
175
|
+
const ref = parseAt(a.placement.at);
|
|
176
|
+
if (!ref) continue;
|
|
177
|
+
const b = (state.meta.blocks || []).find((x) => x.type !== "heading" && x.secPath === ref.sec && x.para === ref.para);
|
|
178
|
+
if (!b) continue;
|
|
179
|
+
const el = docView.querySelector(`[data-block-idx="${b.i}"]`);
|
|
180
|
+
if (!el) continue;
|
|
181
|
+
const ci = state.anchors.indexOf(a) % HL_COLORS.length;
|
|
182
|
+
el.classList.add("pf-ctx");
|
|
183
|
+
el.style.setProperty("--pf-ctx-bg", HL_COLORS[ci].bg);
|
|
184
|
+
el.style.setProperty("--pf-ctx-line", HL_COLORS[ci].line);
|
|
185
|
+
const fid = a.figure || a.id;
|
|
186
|
+
el.dataset.ctxFigs = (el.dataset.ctxFigs ? el.dataset.ctxFigs + " " : "") + fid;
|
|
187
|
+
// 记录锚点 id:点图看源文段 → 跳原文时精确/兜底定位都要用
|
|
188
|
+
el.dataset.ctxAnchors = (el.dataset.ctxAnchors ? el.dataset.ctxAnchors + " " : "") + a.id;
|
|
189
|
+
const label = a.figure ? `图的上下文:${a.figure}` : `锚点 ${a.id} 的上下文`;
|
|
190
|
+
el.title = el.dataset.ctxFigs.split(" ").length > 1 ? `多张图的上下文重叠:${el.dataset.ctxFigs.split(" ").join(" + ")}` : label;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// quote 在 PDF 文本层的降级匹配:quote 存的是 LaTeX 源文本(含 \cite/\ref 等命令),
|
|
195
|
+
// 编译后的 PDF 文本层里这些命令消失了,整句必然匹配不上 —— 逐级缩短前缀直到命中。
|
|
196
|
+
// 部分命中也远好于不亮(用户要的是"上下文有荧光")。
|
|
197
|
+
function findQuoteRange(combined, q) {
|
|
198
|
+
let idx = combined.indexOf(q);
|
|
199
|
+
if (idx > -1) return { start: idx, end: idx + q.length, partial: false };
|
|
200
|
+
for (const L of [96, 64, 40, 24]) {
|
|
201
|
+
if (q.length <= L) continue; // 完整 quote 已试过,跳过更长的前缀;更短的继续试
|
|
202
|
+
let prefix = q.slice(0, L);
|
|
203
|
+
const cut = prefix.lastIndexOf(" ");
|
|
204
|
+
if (cut > 12) prefix = prefix.slice(0, cut); // 别切半个词
|
|
205
|
+
prefix = prefix.trim();
|
|
206
|
+
if (prefix.length < 12) continue;
|
|
207
|
+
idx = combined.indexOf(prefix);
|
|
208
|
+
if (idx > -1) return { start: idx, end: idx + prefix.length, partial: true };
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 区间切分渲染:所有 quote 匹配区间求交,每个原子区间一次渲染(重叠天然合并)
|
|
214
|
+
function paintHighlights() {
|
|
215
|
+
if (!rendered) return;
|
|
216
|
+
unwrapHighlights();
|
|
217
|
+
paintCtxParagraphs();
|
|
218
|
+
const { nodes, combined } = buildTextIndex();
|
|
219
|
+
const figState = (figureId) => {
|
|
220
|
+
const r = state.review[figureId];
|
|
221
|
+
if (!r) return "ok";
|
|
222
|
+
return r.status === "approved" ? "approved" : r.status === "rejected" ? "rejected" : "ok";
|
|
223
|
+
};
|
|
224
|
+
const spans = [];
|
|
225
|
+
state.anchors.forEach((a, i) => {
|
|
226
|
+
const q = norm(a.placement.quote || "").trim();
|
|
227
|
+
if (q.length < 4) return;
|
|
228
|
+
const hit = findQuoteRange(combined, q);
|
|
229
|
+
if (!hit) return; // 找不到就如实不高亮(changed 的由 CLI 侧标记,等 AI 重新声明)
|
|
230
|
+
spans.push({ start: hit.start, end: hit.end, a, ci: i % HL_COLORS.length, partial: hit.partial });
|
|
231
|
+
});
|
|
232
|
+
if (!spans.length) {
|
|
233
|
+
if (state.anchors.length) console.warn("[pf] 有锚点但未能定位原文(可能文档已改动)");
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const cuts = [...new Set(spans.flatMap((s) => [s.start, s.end]))].sort((x, y) => x - y);
|
|
237
|
+
for (let k = 0; k < cuts.length - 1; k++) {
|
|
238
|
+
const s = cuts[k], e = cuts[k + 1];
|
|
239
|
+
const cover = spans.filter((sp) => sp.start <= s && sp.end >= e);
|
|
240
|
+
if (!cover.length) continue;
|
|
241
|
+
wrapRange(s, e, cover, figState); // 🔴 每个区间内部重建文本索引:前面的 splitText 会改变节点结构
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function wrapRange(idx, end, cover, figState) {
|
|
246
|
+
// combined 的文本内容不受 wrap 影响(splitText 只切节点不改文本),cut 索引仍然有效
|
|
247
|
+
const { nodes } = buildTextIndex();
|
|
248
|
+
for (const { node, start, end: nodeEnd } of nodes) {
|
|
249
|
+
if (nodeEnd <= idx || start >= end) continue;
|
|
250
|
+
const s = Math.max(0, idx - start);
|
|
251
|
+
const e = Math.min(node.data.length, end - start);
|
|
252
|
+
let target = node;
|
|
253
|
+
if (s > 0) target = target.splitText(s);
|
|
254
|
+
if (e - s < target.data.length) target.splitText(e - s);
|
|
255
|
+
const span = document.createElement("span");
|
|
256
|
+
const main = cover[0];
|
|
257
|
+
// 重叠区间的状态 = 覆盖锚点里最需要关注的状态(驳回 > 原文改动 > 丢失 > 待审 > 通过)
|
|
258
|
+
const PRIO = { rejected: 4, changed: 3, lost: 2, ok: 1, approved: 0 };
|
|
259
|
+
const effState = (c) => (c.a.state === "ok" && c.a.figure ? figState(c.a.figure) : c.a.state);
|
|
260
|
+
const stateName = cover.map(effState).sort((x, y) => (PRIO[y] || 0) - (PRIO[x] || 0))[0] || "ok";
|
|
261
|
+
span.className = "pf-hl";
|
|
262
|
+
// 🔴 记录全部覆盖锚点(不只第一个):两条锚点 quote 相同时会合并成同一区间,
|
|
263
|
+
// 只记 main 的话"点图跳原文"对第二个锚点就落空(2026-09-21 浏览器实测抓到)
|
|
264
|
+
span.dataset.anchor = main.a.id;
|
|
265
|
+
span.dataset.anchors = cover.map((c) => c.a.id).join(" ");
|
|
266
|
+
span.dataset.state = stateName;
|
|
267
|
+
span.dataset.figure = main.a.figure || "";
|
|
268
|
+
span.dataset.figures = cover.map((c) => c.a.figure || c.a.id).join(" ");
|
|
269
|
+
span.style.background = HL_COLORS[main.ci].bg;
|
|
270
|
+
if (cover.length > 1) span.classList.add("depth" + Math.min(cover.length, 4)); // 重叠加深纹理
|
|
271
|
+
// 顶部细条 = 审批状态;底部色带 = 重叠的每张图(深浅/多色区分)
|
|
272
|
+
const shadows = [`inset 0 2px 0 ${STATE_BAR[stateName] || STATE_BAR.ok}`];
|
|
273
|
+
cover.slice(1).forEach((c, i) => {
|
|
274
|
+
shadows.push(`inset 0 -${4 * (i + 1)}px 0 ${HL_COLORS[c.ci].line}`);
|
|
275
|
+
});
|
|
276
|
+
span.style.boxShadow = shadows.join(", ");
|
|
277
|
+
const parts = cover.map((c) => `${c.a.figure || "锚点" + c.a.id}(${STATE_LABEL[effState(c)] || effState(c)})`);
|
|
278
|
+
span.title = (cover.length > 1 ? "多图上下文重叠:" : "图的上下文:") + parts.join(" + ")
|
|
279
|
+
+ (cover.some((c) => c.partial) ? " · 部分匹配(原文可能已改动)" : "") + " · 点击审批";
|
|
280
|
+
target.parentNode.insertBefore(span, target);
|
|
281
|
+
span.appendChild(target);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------- 侧栏 ----------
|
|
286
|
+
function paintAll() {
|
|
287
|
+
paintTabs();
|
|
288
|
+
paintReview();
|
|
289
|
+
paintFigures();
|
|
290
|
+
paintTimeline();
|
|
291
|
+
paintHighlights();
|
|
292
|
+
paintInlineFigs(); // 图卡片内联到原文锚点位置(用户要求:图要定位到原文的位置)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---------- 内联图(图挂在锚点指向的原文位置,不再只躺在侧栏) ----------
|
|
296
|
+
// 定位规则:优先精确 quote 高亮块,退段落上下文;找到含它的 docView 直接子块
|
|
297
|
+
// (PDF=页块 / 文本模式=段落 / docx=渲染块),图卡片插在子块之后 = "这一段的下面"。
|
|
298
|
+
// 🔴 展示逻辑按审批状态区分(用户反馈:被驳回的图还挂在原文里很乱):
|
|
299
|
+
// 待审/通过 → 内联最新版大图 + 状态徽标;已驳回 → 不再挂图,只留一条占位条
|
|
300
|
+
// (驳回意见 + 点击查看历史版本)—— 文章保持干净,历史在浮层里永远可查。
|
|
301
|
+
function paintInlineFigs() {
|
|
302
|
+
docView.querySelectorAll(".pf-inline-fig").forEach((el) => el.remove());
|
|
303
|
+
if (!rendered) return;
|
|
304
|
+
const done = new Set();
|
|
305
|
+
for (const a of state.anchors || []) {
|
|
306
|
+
const fid = a.figure;
|
|
307
|
+
if (!fid || done.has(fid) || !state.figures[fid]) continue;
|
|
308
|
+
let host = docView.querySelector(`.pf-hl[data-figures~="${fid}"]`)
|
|
309
|
+
|| docView.querySelector(`.pf-ctx[data-ctx-figs~="${fid}"]`);
|
|
310
|
+
if (!host) continue; // 原文里定位不到就不硬插(如实缺失,别乱放)
|
|
311
|
+
done.add(fid);
|
|
312
|
+
while (host.parentElement && host.parentElement !== docView) host = host.parentElement;
|
|
313
|
+
const f = state.figures[fid];
|
|
314
|
+
const rev = state.review[fid] || { status: "pending" };
|
|
315
|
+
const card = document.createElement("div");
|
|
316
|
+
card.className = "pf-inline-fig";
|
|
317
|
+
if (rev.status === "rejected") {
|
|
318
|
+
// 已驳回:不挂图(文章里不放被判不合格的图),占位条保留入口
|
|
319
|
+
card.classList.add("rejected-strip");
|
|
320
|
+
card.innerHTML = `<div class="cap">🚫 <b>${escapeHtml(fid)}</b> 最新版已被驳回${rev.note ? ` · 意见:${escapeHtml(rev.note)}` : ""}
|
|
321
|
+
· <span class="link">点击查看历史版本 / 等 AI 按意见重出</span></div>`;
|
|
322
|
+
card.querySelector(".link").onclick = () => openFigure(fid);
|
|
323
|
+
card.onclick = (e) => { if (e.target === card) openFigure(fid); };
|
|
324
|
+
} else {
|
|
325
|
+
const v = displayVer(f, rev);
|
|
326
|
+
card.innerHTML = `<img src="${escapeHtml(figSrc(v.file))}" alt="${escapeHtml(fid)}">
|
|
327
|
+
<div class="cap">${badgeOf(rev.status)} <b>${escapeHtml(fid)}</b> · v${f.versions.indexOf(v) + 1}/${f.versions.length} · 上下文 @${escapeHtml(a.placement?.at || "")} · 点击看大图与源文段</div>`;
|
|
328
|
+
card.querySelector("img").onclick = () => openFigure(fid);
|
|
329
|
+
}
|
|
330
|
+
host.after(card);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ---------- 点图看源文段(浮层里列出这张图锚定/引用了哪些文段) ----------
|
|
335
|
+
function anchorsOfFigure(fid) {
|
|
336
|
+
return (state.anchors || []).filter((a) => (a.figure || "") === fid);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function contextListOfFigure(fid) {
|
|
340
|
+
const list = anchorsOfFigure(fid);
|
|
341
|
+
if (!list.length) return null;
|
|
342
|
+
const box = document.createElement("div");
|
|
343
|
+
box.className = "fig-ctx";
|
|
344
|
+
box.innerHTML = `<div class="fig-ctx-title">锚定原文 — 这张图取材的文段</div>`;
|
|
345
|
+
for (const a of list) {
|
|
346
|
+
const q = String(a.placement?.quote || "").trim();
|
|
347
|
+
const st = a.state || "ok";
|
|
348
|
+
const item = document.createElement("div");
|
|
349
|
+
item.className = "fig-ctx-item";
|
|
350
|
+
item.innerHTML = `<div class="meta">@${escapeHtml(a.placement?.at || "?")} · ${STATE_LABEL[st] || st}</div>
|
|
351
|
+
<div class="quote">${q ? "「" + escapeHtml(q) + "」" : "<i>(该锚点没给引用原句——建议锚点声明时带上 quote)</i>"}</div>
|
|
352
|
+
<div class="btn-row"><button class="btn ghost">跳到原文位置</button></div>`;
|
|
353
|
+
item.querySelector("button").onclick = () => locateAnchor(a.id);
|
|
354
|
+
box.appendChild(item);
|
|
355
|
+
}
|
|
356
|
+
return box;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// 跳到某个锚点在原文里的位置:优先精确 quote 高亮,退段落上下文
|
|
360
|
+
function locateAnchor(aid) {
|
|
361
|
+
$("#overlay").classList.add("hidden");
|
|
362
|
+
let el = docView.querySelector(`.pf-hl[data-anchor="${aid}"]`)
|
|
363
|
+
|| docView.querySelector(`.pf-hl[data-anchors~="${aid}"]`) // 重叠合并区间记的是全部锚点
|
|
364
|
+
|| docView.querySelector(`.pf-ctx[data-ctx-anchors~="${aid}"]`);
|
|
365
|
+
if (!el) { toast("原文里没定位到这条锚点(可能文档已改动,pf anchor list --changed 看)"); return; }
|
|
366
|
+
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
367
|
+
el.classList.add("flash");
|
|
368
|
+
setTimeout(() => el.classList.remove("flash"), 1800);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function paintTabs() {
|
|
372
|
+
const pend = Object.values(state.figures).filter((f) => (state.review[f.figureId]?.status || "pending") === "pending").length;
|
|
373
|
+
$("#cnt-review").textContent = pend || "";
|
|
374
|
+
$("#cnt-figures").textContent = Object.keys(state.figures).length || "";
|
|
375
|
+
$("#cnt-queue").textContent = pend || "";
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const badgeOf = (s) => `<span class="badge ${s}">${{ pending: "待审批", approved: "已通过", rejected: "已驳回" }[s] || s}</span>`;
|
|
379
|
+
|
|
380
|
+
function latestVer(f) { return f.versions[f.versions.length - 1]; }
|
|
381
|
+
|
|
382
|
+
// 内联/卡片该展示哪一版:通过 → 最新版(=获批准的定稿);待审 → 最新版;
|
|
383
|
+
// 已驳回 → 最新版也不该当门面(它就是被判不合格的那张),上层用占位条代替。
|
|
384
|
+
function displayVer(f, rev) { return latestVer(f); }
|
|
385
|
+
|
|
386
|
+
// v.file 形如 "figures/<fid>/v1.png",服务端静态路由是 /fig/<fid>/<file>
|
|
387
|
+
// 🔴 必须带 ?doc=:服务端按 doc 定位项目目录(缺省回退"最近项目",多文档时会 404)
|
|
388
|
+
const figSrc = (file) => "/" + String(file).replace(/^figures\//, "fig/") + `?doc=${DOC}`;
|
|
389
|
+
|
|
390
|
+
function paintReview() {
|
|
391
|
+
const box = $("#tab-review");
|
|
392
|
+
const pend = Object.values(state.figures)
|
|
393
|
+
.filter((f) => (state.review[f.figureId]?.status || "pending") === "pending");
|
|
394
|
+
if (!pend.length) { box.innerHTML = '<p class="empty">暂无待审批的图</p>'; return; }
|
|
395
|
+
box.innerHTML = "";
|
|
396
|
+
for (const f of pend) box.appendChild(figCard(f, true));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function paintFigures() {
|
|
400
|
+
const box = $("#tab-figures");
|
|
401
|
+
const figs = Object.values(state.figures);
|
|
402
|
+
if (!figs.length) { box.innerHTML = '<p class="empty">还没有图</p>'; return; }
|
|
403
|
+
box.innerHTML = "";
|
|
404
|
+
for (const f of figs.slice().reverse()) box.appendChild(figCard(f, false));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ---------- 审批控件(侧栏卡片与浮层共用 —— 审批永远针对具体的图) ----------
|
|
408
|
+
function reviewControls(f) {
|
|
409
|
+
const rev = state.review[f.figureId] || { status: "pending", note: "" };
|
|
410
|
+
const box = document.createElement("div");
|
|
411
|
+
box.className = "review-ctl";
|
|
412
|
+
box.innerHTML = `
|
|
413
|
+
<div class="meta">${badgeOf(rev.status)} <b>${escapeHtml(f.figureId)}</b> @${escapeHtml(f.at)} · ${escapeHtml(latestVer(f).model)}${f.versions.length > 1 ? ` · ${f.versions.length} 版` : ""}</div>
|
|
414
|
+
${rev.note ? `<div class="meta">意见:${escapeHtml(rev.note)}</div>` : ""}
|
|
415
|
+
<textarea class="note-box" placeholder="哪里不对?(可选,AI 会读到)"></textarea>
|
|
416
|
+
<div class="btn-row">
|
|
417
|
+
<button class="btn ok" data-act="approve">通过</button>
|
|
418
|
+
<button class="btn no" data-act="reject">驳回</button>
|
|
419
|
+
</div>
|
|
420
|
+
<div class="btn-row"><button class="btn ghost" data-act="feedback">提交反馈给 AI</button></div>`;
|
|
421
|
+
box.querySelectorAll("[data-act]").forEach((btn) => {
|
|
422
|
+
btn.onclick = async () => {
|
|
423
|
+
const note = box.querySelector(".note-box").value.trim();
|
|
424
|
+
const act = btn.dataset.act;
|
|
425
|
+
const status = act === "approve" ? "approved" : act === "feedback" ? "pending" : "rejected";
|
|
426
|
+
const resp = await fetch(`/api/review?doc=${DOC}`, {
|
|
427
|
+
method: "POST",
|
|
428
|
+
headers: { "Content-Type": "application/json" },
|
|
429
|
+
body: JSON.stringify({ token: TOKEN, figureId: f.figureId, status, note }),
|
|
430
|
+
});
|
|
431
|
+
const data = await resp.json();
|
|
432
|
+
if (!resp.ok) return toast("提交失败:" + (data.error || resp.status));
|
|
433
|
+
toast(act === "approve" ? "已通过,AI 可以出定稿了" : act === "feedback" ? "反馈已提交,AI 会读到" : "已驳回");
|
|
434
|
+
await loadState(); paintAll();
|
|
435
|
+
// 审批队列开着 → 这张已离开队列,原位自动落到下一张
|
|
436
|
+
if (!$("#queue").classList.contains("hidden")) renderQueue();
|
|
437
|
+
if (!$("#overlay").classList.contains("hidden") && currentFigureId === f.figureId) openFigure(f.figureId);
|
|
438
|
+
};
|
|
439
|
+
});
|
|
440
|
+
return box;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function figCard(f, actionable) {
|
|
444
|
+
const v = latestVer(f);
|
|
445
|
+
const card = document.createElement("div");
|
|
446
|
+
card.className = "card";
|
|
447
|
+
card.innerHTML = `<img class="thumb" src="${escapeHtml(figSrc(v.file))}" data-figure="${escapeHtml(f.figureId)}" alt="${escapeHtml(f.figureId)}">`;
|
|
448
|
+
const body = document.createElement("div");
|
|
449
|
+
body.className = "body";
|
|
450
|
+
body.appendChild(reviewControls(f)); // 审批控件(含状态与意见)
|
|
451
|
+
card.appendChild(body);
|
|
452
|
+
card.querySelector(".thumb").onclick = () => openFigure(f.figureId);
|
|
453
|
+
return card;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function paintTimeline() {
|
|
457
|
+
const box = $("#tab-timeline");
|
|
458
|
+
const evts = (state.events || []).slice().reverse();
|
|
459
|
+
if (!evts.length) { box.innerHTML = '<p class="empty">暂无事件</p>'; return; }
|
|
460
|
+
box.innerHTML = evts.map((e) => {
|
|
461
|
+
const t = (e.t || "").slice(5, 19).replace("T", " ");
|
|
462
|
+
const label = {
|
|
463
|
+
"figure.created": "🖼 出图", "anchor.set": "📍 建锚", "billing.fallback": "💰 额度→余额",
|
|
464
|
+
"review.approved": "✅ 通过", "review.rejected": "❌ 驳回", "review.reset": "↩ 重置",
|
|
465
|
+
}[e.type] || e.type;
|
|
466
|
+
return `<div class="evt"><b>${t}</b> ${escapeHtml(label)}${e.figure ? " " + escapeHtml(e.figure) : ""}${e.model ? ` · ${escapeHtml(e.model)}` : ""}${e.note ? ` — ${escapeHtml(e.note)}` : ""}</div>`;
|
|
467
|
+
}).join("");
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ---------- 浮层(版本对比 / 全屏 / 就地审批) ----------
|
|
471
|
+
let currentFigureId = null;
|
|
472
|
+
|
|
473
|
+
function openFigure(figureId) {
|
|
474
|
+
const f = state.figures[figureId];
|
|
475
|
+
if (!f) return;
|
|
476
|
+
currentFigureId = figureId;
|
|
477
|
+
const rev = state.review[figureId] || { status: "pending" };
|
|
478
|
+
$("#overlay-title").textContent = `${figureId} @${f.at}`;
|
|
479
|
+
const body = $("#overlay-body");
|
|
480
|
+
body.classList.remove("full");
|
|
481
|
+
body.innerHTML = "";
|
|
482
|
+
for (const v of f.versions) {
|
|
483
|
+
const ver = document.createElement("div");
|
|
484
|
+
ver.className = "ver";
|
|
485
|
+
ver.innerHTML = `<img src="${escapeHtml(figSrc(v.file))}" data-figure="${escapeHtml(figureId)}" alt="${escapeHtml(v.file)}">
|
|
486
|
+
<div class="meta">v${f.versions.indexOf(v) + 1} · ${escapeHtml(v.model)} · ${v.status === "final" ? "定稿" : "草稿"} · 计费 ${escapeHtml(v.channel)}</div>`;
|
|
487
|
+
ver.querySelector("img").onclick = () => fullscreen(ver.querySelector("img").src);
|
|
488
|
+
body.appendChild(ver);
|
|
489
|
+
}
|
|
490
|
+
// 右栏 = 锚定原文(这张图取材的文段,可跳回原文)+ 审批面板
|
|
491
|
+
$("#overlay-review").innerHTML = "";
|
|
492
|
+
const ctxBox = contextListOfFigure(figureId);
|
|
493
|
+
if (ctxBox) $("#overlay-review").appendChild(ctxBox);
|
|
494
|
+
$("#overlay-review").appendChild(reviewControls(f));
|
|
495
|
+
$("#overlay").classList.remove("hidden");
|
|
496
|
+
$("#btn-fs").onclick = () => fullscreen(figSrc(latestVer(f).file));
|
|
497
|
+
$("#btn-locate").onclick = () => locateFigure(figureId);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// 在文档里定位这张图的上下文高亮(多图重叠时优先精确 quote 高亮,再退段落上下文)
|
|
501
|
+
function locateFigure(figureId) {
|
|
502
|
+
$("#overlay").classList.add("hidden");
|
|
503
|
+
let el = docView.querySelector(`.pf-hl[data-figures~="${figureId}"]`);
|
|
504
|
+
if (!el) el = docView.querySelector(`.pf-ctx[data-ctx-figs~="${figureId}"]`);
|
|
505
|
+
if (!el) { toast("文档里没有这张图的上下文高亮"); return; }
|
|
506
|
+
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
507
|
+
el.classList.add("flash");
|
|
508
|
+
setTimeout(() => el.classList.remove("flash"), 1800);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function fullscreen(src) {
|
|
512
|
+
const body = $("#overlay-body");
|
|
513
|
+
body.classList.add("full");
|
|
514
|
+
body.innerHTML = "";
|
|
515
|
+
// 🔴 不用 innerHTML 拼 <img onclick="...">:内联 handler 里再拼 JS 字符串
|
|
516
|
+
// 转义链条太长,改用 DOM API 彻底消掉这个注入面(Mimosa 高危根因)
|
|
517
|
+
const img = document.createElement("img");
|
|
518
|
+
img.src = src;
|
|
519
|
+
img.onclick = () => { body.classList.remove("full"); openFigure(currentFigureIdOf(src)); };
|
|
520
|
+
body.appendChild(img);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function currentFigureIdOf(src) {
|
|
524
|
+
const m = /figures\/([\w-]+)\//.exec(src);
|
|
525
|
+
return m ? m[1] : "";
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
$("#btn-close").onclick = () => $("#overlay").classList.add("hidden");
|
|
529
|
+
$("#overlay").addEventListener("click", (e) => { if (e.target.id === "overlay") $("#overlay").classList.add("hidden"); });
|
|
530
|
+
document.addEventListener("keydown", (e) => { if (e.key === "Escape") $("#overlay").classList.add("hidden"); });
|
|
531
|
+
|
|
532
|
+
// 点击高亮 / 段落上下文 → 打开对应图的审批面板(重叠区间优先打开有图的那个)
|
|
533
|
+
docView.addEventListener("click", (e) => {
|
|
534
|
+
const hl = e.target.closest(".pf-hl");
|
|
535
|
+
const fids = hl
|
|
536
|
+
? [hl.dataset.figure, ...(hl.dataset.figures || "").split(" ")]
|
|
537
|
+
: [...((e.target.closest(".pf-ctx")?.dataset.ctxFigs) || "").split(" ")];
|
|
538
|
+
const fid = fids.map((x) => x.trim()).filter(Boolean).find((x) => state.figures[x]);
|
|
539
|
+
if (fid) return openFigure(fid);
|
|
540
|
+
toast("这个锚点还没有图(等 AI 出图后再点)");
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
// ---------- 审批队列(看图直批:大图逐张过,不用回原文) ----------
|
|
544
|
+
// 用户要求:审批要有一个列表,看图就能直接通过/驳回,不必读原文。
|
|
545
|
+
// 队列只收 pending 的图;批准/驳回后自动落到下一张,清空时收尾。
|
|
546
|
+
let queueIdx = 0;
|
|
547
|
+
const pendingFigures = () => Object.values(state.figures)
|
|
548
|
+
.filter((f) => (state.review[f.figureId]?.status || "pending") === "pending")
|
|
549
|
+
.sort((a, b) => (a.at || "").localeCompare(b.at || "")); // 按出图顺序过,老的先批
|
|
550
|
+
|
|
551
|
+
function openQueue() {
|
|
552
|
+
if (!pendingFigures().length) { toast("暂无待审批的图 —— 队列是空的"); return; }
|
|
553
|
+
queueIdx = 0;
|
|
554
|
+
$("#queue").classList.remove("hidden");
|
|
555
|
+
renderQueue();
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function renderQueue() {
|
|
559
|
+
const list = pendingFigures();
|
|
560
|
+
if (!list.length) {
|
|
561
|
+
$("#queue").classList.add("hidden");
|
|
562
|
+
toast("审批队列已清空 ✅");
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
if (queueIdx >= list.length) queueIdx = list.length - 1;
|
|
566
|
+
if (queueIdx < 0) queueIdx = 0;
|
|
567
|
+
const f = list[queueIdx];
|
|
568
|
+
$("#queue-pos").textContent = `第 ${queueIdx + 1} / ${list.length} 张 · 全文共 ${Object.keys(state.figures).length} 张图`;
|
|
569
|
+
const img = $("#queue-img-el");
|
|
570
|
+
img.src = figSrc(latestVer(f).file);
|
|
571
|
+
img.onclick = () => fullscreen(figSrc(latestVer(f).file));
|
|
572
|
+
const side = $("#queue-side");
|
|
573
|
+
side.innerHTML = "";
|
|
574
|
+
side.appendChild(reviewControls(f)); // 自带状态徽标/意见框/通过驳回/反馈按钮
|
|
575
|
+
const hint = document.createElement("p");
|
|
576
|
+
hint.className = "meta";
|
|
577
|
+
hint.style.marginTop = "10px";
|
|
578
|
+
hint.textContent = "快捷键:← 上一张 · → 下一张。批完自动跳下一张;点大图看细节,浮层里可「定位上下文」回原文。";
|
|
579
|
+
side.appendChild(hint);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
$("#btn-queue").onclick = openQueue;
|
|
583
|
+
$("#queue-close").onclick = () => $("#queue").classList.add("hidden");
|
|
584
|
+
$("#queue-prev").onclick = () => { queueIdx -= 1; renderQueue(); };
|
|
585
|
+
$("#queue-next").onclick = () => { queueIdx += 1; renderQueue(); };
|
|
586
|
+
document.addEventListener("keydown", (e) => {
|
|
587
|
+
if ($("#queue").classList.contains("hidden")) return;
|
|
588
|
+
if (e.target.tagName === "TEXTAREA") return; // 意见框里打字不抢按键
|
|
589
|
+
if (e.key === "ArrowLeft") $("#queue-prev").click();
|
|
590
|
+
if (e.key === "ArrowRight") $("#queue-next").click();
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
// ---------- 杂项 ----------
|
|
594
|
+
function toast(msg) {
|
|
595
|
+
const t = $("#toast");
|
|
596
|
+
t.textContent = msg;
|
|
597
|
+
t.classList.remove("hidden");
|
|
598
|
+
clearTimeout(t._h);
|
|
599
|
+
t._h = setTimeout(() => t.classList.add("hidden"), 2600);
|
|
600
|
+
}
|
|
601
|
+
function escapeHtml(s) {
|
|
602
|
+
return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// tab 切换
|
|
606
|
+
document.querySelectorAll("#tabs button").forEach((b) => {
|
|
607
|
+
b.onclick = () => {
|
|
608
|
+
document.querySelectorAll("#tabs button").forEach((x) => x.classList.remove("on"));
|
|
609
|
+
document.querySelectorAll(".tab").forEach((x) => x.classList.remove("on"));
|
|
610
|
+
b.classList.add("on");
|
|
611
|
+
$("#tab-" + b.dataset.tab).classList.add("on");
|
|
612
|
+
};
|
|
613
|
+
});
|