dsh-fou 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DSL-Orange
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,176 @@
1
- # Temporary Holding Version
1
+ # dsh-fou · 浮游
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > **浮游是 DSH 之上的一层悬浮容器。**
4
+ > **它不属于任何一个会话 —— 它跟着你。**
5
+
6
+ **包名** `dsh-fou`(旧名 `@local/dsh-fou` / `@local/dsh-courier`)· **显示名** 浮游 · **英文** Fou
7
+
8
+ > **状态:第一阶段完成(0.2.0)。** 核心能力已闭环、协议已定;接口仍可能微调。
9
+
10
+ ---
11
+
12
+ ## 一句话
13
+
14
+ > # **别人做窗口,浮游做位置。**
15
+
16
+ **而位置的极端形态,就是一个能装任何东西的容器。**
17
+
18
+ **用户视角的一句话**:**不离开当前位置,就能碰到别处。**
19
+
20
+ ---
21
+
22
+ ## 它是什么(以及它为什么长这样)
23
+
24
+ 浮游是**贴着 DSH 走的一层界面**。三句话定它的位置:
25
+
26
+ | 原则 | 意思 |
27
+ |---|---|
28
+ | **定位 = 跟人** | 容器跟着**人**走,不跟着会话走。换到哪个会话,它都还在原地。 |
29
+ | **边界 = 辅道** | 不跟 DSH 的主道抢。**官方做容器,浮游也做容器 —— 但只为一件事:把人留在原地。** |
30
+ | **从属 = 借宿** | 它住在 DSH 里。**借宿不是永久,是当下的正确姿态。** |
31
+
32
+ **它不是什么**:
33
+
34
+ - **不做工作台** —— 一个小球就够了。
35
+ - **不做标签页** —— **一次只看一件。**
36
+ - **不替代浏览器** —— 装 URL 是为了"带你去现场",不是为了"替代你上网"。
37
+
38
+ ---
39
+
40
+ ## 它现在能做什么
41
+
42
+ | 能力 | 说明 |
43
+ |---|---|
44
+ | **三形态** | **圆球 / 呼机 / 全窗** —— 可拖动、可记忆、任意非交互区都能拖 |
45
+ | **聊天** | 不离开当前页面,给**当前会话**发一条消息 |
46
+ | **穿越** | 在会话之间移动(左键) |
47
+ | **开间** | 一个**只装一件**的容器:能装**网页**、**文件**、**插件界面**(右键) |
48
+ | **最近打开** | 回到刚才看过的东西(按模式分开记) |
49
+ | **挂靠** | 插件可以把界面**挂进开间**(硬挂载),或给浮游一个**动作**(遥控),或报一条**状态简讯**(`brief`) |
50
+ | **应用槽位** | 胶囊左栏可切到报了 `brief` 的插件 —— 比如地颂:左栏 `♫`、右栏歌名、**点胶囊就是播放/暂停** |
51
+ | **挂板** | **只装「挂表」** —— 验收表 / 批注表 / 回执。**普通文件走浏览。** |
52
+ | **编辑器** | 编辑即保存、发送即落稿(**所写即所发**) |
53
+
54
+ ### 左右键的名字
55
+
56
+ | 键 | 名字 | 管什么 |
57
+ |---|---|---|
58
+ | **左键** | **穿越** | 人怎么动 |
59
+ | **右键** | **开间** | 框怎么开 |
60
+
61
+ ---
62
+
63
+ ## 三化(我们的叫法)
64
+
65
+ 浮游的架构分三层。**这三个词是我们自己的叫法,不是行业标准术语。**
66
+
67
+ | 层 | 中文 | 英文 | 层级 | 判断依据 |
68
+ |---|---|---|---|---|
69
+ | **最抽象** | **模式化** | **Method** | 浮游本体 > 开间界面 > 接口协议 | **换了宿主还成立吗** |
70
+ | **中间** | **模块化** | **Model** | 浮游开间 > 浮游功能 > 浮游挂载 | **能接入能取出吗** |
71
+ | **最具体** | **模具化** | **Mold** | 开间协议 > 挂载协议 > 优化协议 | **长成什么样、还能不能改** |
72
+
73
+ > **模式化是"换了宿主还成立";模块化是"能接入能取出";模具化是"长成什么样、还能不能改"。**
74
+
75
+ **而它们背后有一个被归纳出来的形状**(同一个模式出现了两次):
76
+
77
+ | 第一次(挂表) | 第二次(挂靠) |
78
+ |---|---|
79
+ | ① **挂板协议** | ① **开间** |
80
+ | ② **链接协议** | ② **硬挂载** |
81
+ | ③ **模表协议** | ③ **软优化** |
82
+ | **= ④ 挂表协议** | **= ④ 挂靠** |
83
+
84
+ > **① 往哪挂 · ② 怎么挂上去 · ③ 挂上去怎么长得好看 · ④ 三者合成的契约**
85
+ > **——这个"①②③=④"的形状,就是模具的形状。**
86
+
87
+ ---
88
+
89
+ ## 文档在哪
90
+
91
+ | 文件 | 读者 | 内容 |
92
+ |---|---|---|
93
+ | [`docs/00-总纲.md`](docs/00-总纲.md) | 人 / Agent | 协议是什么、有哪些、谁读它 |
94
+ | [`docs/20-挂板协议.md`](docs/20-挂板协议.md) | 人 / Agent | 挂什么、怎么挂、挂上去怎么活 |
95
+ | [`docs/30-挂靠协议.md`](docs/30-挂靠协议.md) | **Agent** | **插件怎么接进浮游**(两条路:挂载 / 遥控) |
96
+ | [`skill/fou-mount/SKILL.md`](skill/fou-mount/SKILL.md) | **Agent** | **40 行挂载接入模板 + 该不该挂的判据** |
97
+
98
+ > **如果你是 Agent**:从 `skill/fou-mount/SKILL.md` 开始。它自带判断表和可复制的模板。
99
+ > **如果你是人**:从 `docs/30-挂靠协议.md` 的「两条路」开始。
100
+
101
+ ---
102
+
103
+ ## 安装
104
+
105
+ **需要**:DSH 0.2.x
106
+
107
+ **已发布到 npm。**
108
+
109
+ **最简单的装法**:DSH 的「插件页 → 添加插件」里键入
110
+
111
+ ```
112
+ dsh-fou
113
+ ```
114
+
115
+ **它会自己登记 `bundles`,装完重启 DSH。**
116
+
117
+ > 如果 npm 还没同步到镜像、或者你要装**开发版**,用下面三条路:
118
+
119
+ ### 路 A · 压缩包(最不容易出错)
120
+
121
+ 1. 拿到 `dsh-fou-0.2.0.tgz`(或自己 `npm pack` 打一个)
122
+ 2. **插件页 → 添加插件 → 粘这个 tgz 的绝对路径** → 安装
123
+ 3. 重启 DSH
124
+
125
+ ### 路 B · 本地目录
126
+
127
+ 1. 把仓库放到你机器上任意位置
128
+ 2. **插件页 → 添加插件 → 粘那个目录的绝对路径** → 安装
129
+ 3. 重启 DSH
130
+
131
+ ### 路 C · 手动接进 profile(开发用)
132
+
133
+ **⚠ 不推荐** —— 漏登记 `bundles` 就会"页面根本没请求它"。只有改源码时才用这条。
134
+
135
+ 1. 建链接:`node_modules/<包名>` → 源码目录
136
+ 2. 在 profile 的 `package.json` 里登记:
137
+ - `dependencies` 加 `"dsh-fou": "link:<你的路径>"`
138
+ - `dsh.profile.bundles` 数组里加 `"dsh-fou"`
139
+ 3. 重启 DSH
140
+
141
+ > ⚠ **改 `bundles` 会让 DSH 重新加载插件列表 —— 改错可能导致 DSH 起不来。**
142
+ > **改之前先备份 profile 的 `package.json`。**
143
+
144
+ ---
145
+
146
+ ## 目录
147
+
148
+ ```
149
+ client.js 客户端半(浮游的界面与坞口协议)
150
+ index.js 宿主半(同源路由:挂板、稿件、编辑器页面)
151
+ view.html 开间的「查看页」(渲染 md / html / 纯文本)
152
+ editor.html 编辑器页面
153
+ panels.html 挂板页面(只列挂表)
154
+ accept.html 验收表
155
+ docs/ 协议
156
+ skill/ 给 Agent 的接入 skill
157
+ _实验/ 入鞘的实验代码(不会被加载;要用时整段复制回 client.js)
158
+ ```
159
+
160
+ ---
161
+
162
+ ## 许可
163
+
164
+ MIT。见 [LICENSE](LICENSE)。
165
+
166
+ ---
167
+
168
+ ## 一个人做的
169
+
170
+ 这个仓库的东西,是一个人和一个 agent 做出来的。**上线只用了两天,但前期远不止两天。**
171
+
172
+ **它现在能用,但它还很年轻。**
173
+
174
+ **如果它对你没用,请你不要用它。**
175
+
176
+ 做它的时候,我们想的是 —— **带上你的眼睛。**
package/accept.html ADDED
@@ -0,0 +1,404 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>浮游 · 验收表</title>
7
+ <style>
8
+ :root { --fg:#1f2328; --dim:#6b7280; --line:#e5e7eb; --bg:#f7f8fa; --ok:#0d9458; --no:#d54941; }
9
+ * { box-sizing:border-box; }
10
+ body { margin:0; padding:16px 14px 92px; font:14px/1.6 "Segoe UI","Microsoft YaHei",system-ui,sans-serif; color:var(--fg); background:var(--bg); }
11
+ .wrap { max-width:1000px; margin:0 auto; }
12
+ .top { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:8px; }
13
+ h1 { font-size:17px; margin:0; }
14
+ select { padding:5px 8px; border:1px solid var(--line); border-radius:8px; background:#fff; font:inherit; font-size:12px; }
15
+ .note { color:var(--dim); font-size:12px; margin:0 0 12px; }
16
+ h2 { font-size:14px; margin:20px 0 8px; padding-bottom:5px; border-bottom:1px solid var(--line); }
17
+ table { width:100%; border-collapse:collapse; background:#fff; border-radius:10px; overflow:hidden; box-shadow:0 1px 2px #0000000d; }
18
+ th,td { padding:7px 9px; border-bottom:1px solid var(--line); vertical-align:top; text-align:left; }
19
+ th { background:#fafbfc; font-size:12px; color:var(--dim); font-weight:600; }
20
+ tr:last-child td { border-bottom:0; }
21
+ td.verdict { width:120px; white-space:nowrap; }
22
+ .btn { display:inline-block; padding:3px 10px; border:1px solid var(--line); border-radius:6px; background:#fff; cursor:pointer; font-size:12px; margin-right:4px; }
23
+ .btn.on-ok { background:var(--ok); color:#fff; border-color:var(--ok); }
24
+ .btn.on-no { background:var(--no); color:#fff; border-color:var(--no); }
25
+ textarea { width:100%; min-height:84px; padding:8px 10px; border:1px solid var(--line); border-radius:8px; font:inherit; resize:vertical; background:#fff; }
26
+ td textarea { min-height:40px; font-size:12px; }
27
+ .hint { color:var(--dim); font-size:12px; margin:4px 0 8px; }
28
+ .notify { background:#eef6ff; border:1px solid #cfe3ff; border-radius:8px; padding:8px 10px; margin-bottom:10px; font-size:12px; }
29
+ .thumbs { display:flex; flex-wrap:wrap; gap:8px; margin:8px 0 0; }
30
+ .thumb { position:relative; border:1px solid var(--line); border-radius:8px; overflow:hidden; background:#fff; }
31
+ .thumb img { display:block; max-height:86px; max-width:150px; object-fit:cover; }
32
+ .thumb button { position:absolute; top:2px; right:2px; border:0; border-radius:6px; background:#000000aa; color:#fff; font-size:11px; line-height:1; padding:3px 6px; cursor:pointer; }
33
+ .thumb span { display:block; font-size:10px; color:var(--dim); padding:2px 4px; }
34
+ .drop-on { outline:2px dashed #2563eb; outline-offset:2px; }
35
+ .bar { position:fixed; left:0; right:0; bottom:0; background:#fffffff2; backdrop-filter:blur(8px); border-top:1px solid var(--line); padding:0 10px; height:46px; box-sizing:border-box; display:flex; gap:8px; align-items:center; overflow:hidden; white-space:nowrap; }
36
+ /* 底栏:高度锁死、永不换行;窄时状态文字整段隐藏,只留按钮(用户 2026-11-05 定) */
37
+ .bar .grow { flex:1 1 auto; min-width:0; color:var(--dim); font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
38
+ .bar button { flex:none; white-space:nowrap; }
39
+ @media (max-width:460px) { .bar .grow { display:none } }
40
+ .primary { padding:7px 14px; border:0; border-radius:8px; background:#2563eb; color:#fff; font:inherit; font-weight:600; cursor:pointer; }
41
+ .primary:disabled { opacity:.5; cursor:default; }
42
+ .ghost { padding:7px 12px; border:1px solid var(--line); border-radius:8px; background:#fff; font:inherit; cursor:pointer; }
43
+ </style>
44
+ </head>
45
+ <body>
46
+ <div class="wrap">
47
+ <div class="top">
48
+ <h1 id="sheetTitle">验收表</h1>
49
+ <select id="sheetPick" title="切换要验收的对象"></select>
50
+ </div>
51
+ <div class="note" id="sheetNote"></div>
52
+
53
+ <div class="notify" id="notifyLine" style="display:none">
54
+ <label style="cursor:pointer;user-select:none">
55
+ <input type="checkbox" id="notifyOn" checked style="vertical-align:-1px;margin-right:6px" />
56
+ 提交后<b>通知</b> <span id="notifyName">(未指定会话)</span> —— 跟写信一样,会把它叫起来看结果
57
+ </label>
58
+ </div>
59
+
60
+ <h2>一、验收表格区</h2>
61
+ <div class="hint">✓ = 通过;✗ = 不通过(请在备注写现象);留空 = 还没验。</div>
62
+ <table id="grid">
63
+ <thead><tr><th style="width:48px">#</th><th>项目</th><th style="width:120px">结果</th><th style="width:26%">备注</th></tr></thead>
64
+ <tbody></tbody>
65
+ </table>
66
+
67
+ <h2>二、文字交流</h2>
68
+ <div class="hint">三块随便写;图片可以<b>直接粘贴</b>或<b>拖进来</b>(截图 → 存到电脑上 → Agent 能看到)。</div>
69
+ <div class="hint"><b id="secCommentTitle">① 评论区</b></div>
70
+ <textarea id="comment" placeholder="例:整体好用,但某一项我分不清……"></textarea>
71
+ <div class="hint" style="margin-top:14px"><b id="secImproveTitle">② 优化区</b></div>
72
+ <textarea id="improve" placeholder="例:这里应该改成……"></textarea>
73
+ <div class="hint" style="margin-top:14px"><b id="secChatTitle">③ 话痨区</b></div>
74
+ <textarea id="chat" placeholder="想到什么写什么,不用管结构;图也可以往这里贴"></textarea>
75
+
76
+ <h2>三、图片</h2>
77
+ <div class="hint">粘贴或拖进来都行(也可以直接往上面的文字框里贴,会自动收进来)。</div>
78
+ <div id="thumbs" class="thumbs"></div>
79
+ </div>
80
+
81
+ <div class="bar">
82
+ <span class="grow" id="status">改动会自动存在这个浏览器里</span>
83
+ <button class="ghost" id="reset">清空</button>
84
+ <button class="ghost" id="copy">复制</button>
85
+ <button class="primary" id="submit">提交</button>
86
+ </div>
87
+
88
+ <script>
89
+ const ROUTE = '/dsh-fou/accept';
90
+ const HEADER = 'x-dsh-fou';
91
+
92
+ // 浮游打开本页时会带上「它正在对话的会话」:提交后可以自动通知那边(跟写信一样)。
93
+ const QUERY = new URLSearchParams(location.search);
94
+ const NOTIFY = { id: QUERY.get('notify') || '', name: QUERY.get('notifyName') || '' };
95
+ if (NOTIFY.id) {
96
+ document.getElementById('notifyLine').style.display = '';
97
+ document.getElementById('notifyName').textContent = NOTIFY.name || NOTIFY.id;
98
+ }
99
+
100
+ let SHEET = { id: 'default', title: '验收表', note: '', items: [] };
101
+ let SHEETS = [];
102
+ const state = { rows: {}, comment: '', improve: '', chat: '', images: [] };
103
+
104
+ // ---------------------------------------------------------------- 草稿存取
105
+ const draftKey = (id) => 'fou.accept.draft.' + id;
106
+ function loadDraft(id) {
107
+ try {
108
+ return JSON.parse(localStorage.getItem(draftKey(id)) || 'null');
109
+ } catch { return null; }
110
+ }
111
+ function saveDraft() {
112
+ try { localStorage.setItem(draftKey(SHEET.id), JSON.stringify(state)); } catch {}
113
+ paintStatus();
114
+ }
115
+ function paintStatus() {
116
+ const done = Object.values(state.rows).filter((r) => r && r.v).length;
117
+ document.getElementById('status').textContent =
118
+ '已存在本浏览器 · 已判 ' + done + ' / ' + SHEET.items.length + ' · 图 ' + state.images.length +
119
+ '(点「提交」才写进电脑)';
120
+ }
121
+
122
+ // ---------------------------------------------------------------- 渲染表格
123
+ function buildGrid() {
124
+ const tbody = document.querySelector('#grid tbody');
125
+ tbody.innerHTML = '';
126
+ SHEET.items.forEach((item, i) => {
127
+ const tr = document.createElement('tr');
128
+ const cur = state.rows[i] || {};
129
+ tr.innerHTML =
130
+ '<td>' + (i + 1) + '</td>' +
131
+ '<td><b></b><div class="hint" style="margin:2px 0 0"></div></td>' +
132
+ '<td class="verdict"><button class="btn" data-v="ok">✓</button><button class="btn" data-v="no">✗</button></td>' +
133
+ '<td><textarea data-slot="' + String(item.name).replace(/"/g, '&quot;') + '" placeholder="备注"></textarea></td>';
134
+ tr.querySelector('b').textContent = item.name;
135
+ tr.querySelector('.hint').textContent = item.desc || '';
136
+ const note = tr.querySelector('textarea');
137
+ note.value = cur.note || '';
138
+ note.addEventListener('input', () => {
139
+ state.rows[i] = Object.assign({}, state.rows[i] || {}, { note: note.value });
140
+ saveDraft();
141
+ });
142
+ wireImageTarget(note, item.name);
143
+ tr.querySelectorAll('.btn').forEach((b) => {
144
+ if (cur.v === b.dataset.v) b.classList.add(b.dataset.v === 'ok' ? 'on-ok' : 'on-no');
145
+ b.addEventListener('click', () => {
146
+ const v = b.dataset.v;
147
+ const prev = state.rows[i] && state.rows[i].v;
148
+ state.rows[i] = Object.assign({}, state.rows[i] || {}, { v: prev === v ? '' : v });
149
+ tr.querySelectorAll('.btn').forEach((x) => x.classList.remove('on-ok', 'on-no'));
150
+ const now = state.rows[i].v;
151
+ if (now) b.classList.add(now === 'ok' ? 'on-ok' : 'on-no');
152
+ saveDraft();
153
+ });
154
+ });
155
+ tbody.appendChild(tr);
156
+ });
157
+ }
158
+
159
+ // ---------------------------------------------------------------- 切表
160
+ async function loadSheet(id) {
161
+ const res = await fetch('/dsh-fou/sheet?id=' + encodeURIComponent(id));
162
+ if (!res.ok) throw new Error('取表失败 HTTP ' + res.status);
163
+ const data = await res.json();
164
+ SHEET = data.sheet;
165
+ const draft = loadDraft(SHEET.id);
166
+ state.rows = (draft && draft.rows) || {};
167
+ state.comment = (draft && draft.comment) || '';
168
+ state.improve = (draft && draft.improve) || '';
169
+ state.chat = (draft && draft.chat) || '';
170
+ state.images = (draft && draft.images) || [];
171
+ document.getElementById('sheetTitle').textContent = SHEET.title;
172
+ document.getElementById('sheetNote').textContent = SHEET.note || '';
173
+ // 三段标题可以由表单定义覆盖("报告 / 验收 / 询问"就是靠这个换的)
174
+ const sec = SHEET.sections || {};
175
+ const paintSection = (id, fallback) => {
176
+ const el = document.getElementById(id);
177
+ if (el) el.textContent = sec[id.replace('sec', '').replace('Title', '').toLowerCase()] || fallback;
178
+ };
179
+ paintSection('secCommentTitle', '① 评论区');
180
+ paintSection('secImproveTitle', '② 优化区');
181
+ paintSection('secChatTitle', '③ 话痨区');
182
+ document.getElementById('comment').value = state.comment;
183
+ document.getElementById('improve').value = state.improve;
184
+ document.getElementById('chat').value = state.chat;
185
+ document.getElementById('sheetPick').value = SHEET.id;
186
+ // 记住这次选的表:下次打开直接落到同一张(用户 2026-11-05 找不到日常验收)
187
+ try { localStorage.setItem('fou.accept.lastsheet.v1', SHEET.id); } catch {}
188
+ buildGrid();
189
+ paintImages();
190
+ paintStatus();
191
+ }
192
+
193
+ async function boot() {
194
+ const res = await fetch('/dsh-fou/sheets');
195
+ const data = await res.json();
196
+ SHEETS = data.sheets || [];
197
+ const pick = document.getElementById('sheetPick');
198
+ pick.innerHTML = SHEETS.map((s) => '<option value="' + s.id + '">' + s.title + '</option>').join('');
199
+ // 打开时选哪张表:显式 ?sheet= → 上次用的 → 日常验收 → 第一张
200
+ // (用户 2026-11-05 找不到日常验收:原来固定选第一张,下拉不明显)
201
+ let remembered = '';
202
+ try { remembered = localStorage.getItem('fou.accept.lastsheet.v1') || ''; } catch {}
203
+ const hasId = (id) => SHEETS.some((s) => s.id === id);
204
+ const want =
205
+ (QUERY.get('sheet') && hasId(QUERY.get('sheet')) ? QUERY.get('sheet') : '') ||
206
+ (hasId(remembered) ? remembered : '') ||
207
+ (hasId('fou-daily') ? 'fou-daily' : '') ||
208
+ (SHEETS[0] && SHEETS[0].id);
209
+ if (!want) {
210
+ document.getElementById('sheetNote').textContent = '没有找到任何表单定义(_验收\\_表单\\*.json)';
211
+ return;
212
+ }
213
+ await loadSheet(want);
214
+ }
215
+ boot().catch((error) => {
216
+ document.getElementById('sheetNote').textContent = '加载失败:' + error.message;
217
+ });
218
+
219
+ document.getElementById('sheetPick').addEventListener('change', (e) => {
220
+ loadSheet(e.target.value).catch((error) => alert('切换失败:' + error.message));
221
+ });
222
+
223
+ // ---------------------------------------------------------------- 文字区
224
+ ['comment', 'improve', 'chat'].forEach((id) => {
225
+ const el = document.getElementById(id);
226
+ el.addEventListener('input', () => { state[id] = el.value; saveDraft(); });
227
+ wireImageTarget(el, el.dataset.slot);
228
+ });
229
+
230
+ // ---------------------------------------------------------------- 图片
231
+ function addImage(dataUrl, from) {
232
+ state.images.push({ dataUrl, from: from || '' });
233
+ saveDraft();
234
+ paintImages();
235
+ }
236
+
237
+ function fileToDataUrl(file) {
238
+ return new Promise((resolve, reject) => {
239
+ const reader = new FileReader();
240
+ reader.onload = () => resolve(String(reader.result));
241
+ reader.onerror = () => reject(reader.error || new Error('读取失败'));
242
+ reader.readAsDataURL(file);
243
+ });
244
+ }
245
+
246
+ async function takeFiles(files, from) {
247
+ for (const file of files) {
248
+ if (!file.type || !file.type.startsWith('image/')) continue;
249
+ try { addImage(await fileToDataUrl(file), from); } catch {}
250
+ }
251
+ }
252
+
253
+ function wireImageTarget(el, from) {
254
+ if (!el || el.dataset.imgWired === '1') return;
255
+ el.dataset.imgWired = '1';
256
+ el.addEventListener('paste', (event) => {
257
+ const items = (event.clipboardData && event.clipboardData.files) || [];
258
+ if (items.length === 0) return;
259
+ event.preventDefault();
260
+ takeFiles(items, from);
261
+ });
262
+ el.addEventListener('dragover', (event) => { event.preventDefault(); el.classList.add('drop-on'); });
263
+ el.addEventListener('dragleave', () => el.classList.remove('drop-on'));
264
+ el.addEventListener('drop', (event) => {
265
+ event.preventDefault();
266
+ el.classList.remove('drop-on');
267
+ takeFiles(event.dataTransfer.files || [], from);
268
+ });
269
+ }
270
+
271
+ function paintImages() {
272
+ const box = document.getElementById('thumbs');
273
+ box.innerHTML = '';
274
+ state.images.forEach((image, i) => {
275
+ const div = document.createElement('div');
276
+ div.className = 'thumb';
277
+ const img = document.createElement('img');
278
+ img.src = image.dataUrl;
279
+ const del = document.createElement('button');
280
+ del.textContent = '×';
281
+ del.addEventListener('click', () => { state.images.splice(i, 1); saveDraft(); paintImages(); });
282
+ const label = document.createElement('span');
283
+ label.textContent = image.from || '(未注明)';
284
+ div.appendChild(img);
285
+ div.appendChild(del);
286
+ div.appendChild(label);
287
+ box.appendChild(div);
288
+ });
289
+ }
290
+
291
+ // 整页也能接图(拖到空白处)
292
+ document.addEventListener('dragover', (e) => e.preventDefault());
293
+ document.addEventListener('drop', (e) => {
294
+ if (e.target && e.target.closest && e.target.closest('textarea')) return;
295
+ if (!e.dataTransfer || !e.dataTransfer.files || e.dataTransfer.files.length === 0) return;
296
+ e.preventDefault();
297
+ takeFiles(e.dataTransfer.files, '(页面)');
298
+ });
299
+
300
+ // ---------------------------------------------------------------- 提交
301
+ function payload() {
302
+ return {
303
+ sheetId: SHEET.id,
304
+ title: SHEET.title,
305
+ items: SHEET.items.map((item, i) => ({
306
+ name: item.name,
307
+ verdict: (state.rows[i] && state.rows[i].v) || '',
308
+ note: (state.rows[i] && state.rows[i].note) || '',
309
+ })),
310
+ comment: state.comment,
311
+ improve: state.improve,
312
+ chat: state.chat,
313
+ images: state.images.map((image) => ({ dataUrl: image.dataUrl, from: image.from })),
314
+ };
315
+ }
316
+
317
+ function summaryLine() {
318
+ const data = payload();
319
+ // 正向项算通过率,反向项算可信度 —— 两个数分开算(用户 2026-11-05 定)。
320
+ const forward = data.items.filter((i) => i.reverse !== true);
321
+ const reverse = data.items.filter((i) => i.reverse === true);
322
+ const ok = forward.filter((i) => i.verdict === 'ok').length;
323
+ const no = forward.filter((i) => i.verdict === 'no').length;
324
+ const allOk = forward.length > 0 && forward.every((i) => i.verdict === 'ok');
325
+ const anyReverseOk = reverse.some((i) => i.verdict === 'ok');
326
+ const credit = reverse.length === 0 ? '' : allOk && anyReverseOk ? ' · ⚠️答案可能未细读' : ' · 答案可信';
327
+ return '通过 ' + ok + ' / ' + forward.length + ' · 不通过 ' + no +
328
+ (reverse.length ? ' · 反向项 ' + reverse.length + ' 条' : '') +
329
+ credit +
330
+ (data.images.length ? ' · 图 ' + data.images.length : '');
331
+ }
332
+
333
+ async function notifyParent(summary, paths) {
334
+ if (!NOTIFY.id) return { skipped: true };
335
+ if (!document.getElementById('notifyOn').checked) return { skipped: true };
336
+ // 走 postMessage 而不是 HTTP:调度总台可能未激活(实测 405),浮游手里有会话控制器。
337
+ if (window.parent === window) return { skipped: true };
338
+ window.parent.postMessage(
339
+ {
340
+ type: 'fou:accept-saved',
341
+ notify: NOTIFY.id,
342
+ notifyName: NOTIFY.name,
343
+ notifyEnabled: true,
344
+ summary,
345
+ mdPath: paths.mdPath || '',
346
+ },
347
+ window.location.origin,
348
+ );
349
+ return { forwarded: true };
350
+ }
351
+
352
+ document.getElementById('reset').addEventListener('click', () => {
353
+ if (!confirm('清空这张表的勾选、文字与图片?(已提交的存档不受影响)')) return;
354
+ localStorage.removeItem(draftKey(SHEET.id));
355
+ location.reload();
356
+ });
357
+
358
+ document.getElementById('copy').addEventListener('click', async () => {
359
+ const data = payload();
360
+ const mark = (v) => (v === 'ok' ? '✓' : v === 'no' ? '✗' : '(未验)');
361
+ const lines = ['# ' + data.title, ''];
362
+ data.items.forEach((it) => lines.push('- ' + mark(it.verdict) + ' ' + it.name + (it.note ? ' —— ' + it.note : '')));
363
+ [['① 评论区', data.comment], ['② 优化区', data.improve], ['③ 话痨区', data.chat]].forEach(([t, v]) => {
364
+ if ((v || '').trim()) lines.push('', '## ' + t, v.trim());
365
+ });
366
+ if (data.images.length) lines.push('', '## 图片', '(' + data.images.length + ' 张,随提交一起存档)');
367
+ try {
368
+ await navigator.clipboard.writeText(lines.join('\n'));
369
+ document.getElementById('status').textContent = '已复制到剪贴板';
370
+ } catch { document.getElementById('status').textContent = '复制失败,请手动选择'; }
371
+ });
372
+
373
+ document.getElementById('submit').addEventListener('click', async () => {
374
+ const btn = document.getElementById('submit');
375
+ const status = document.getElementById('status');
376
+ btn.disabled = true;
377
+ status.textContent = '提交中…(图片会一起上传)';
378
+ try {
379
+ const res = await fetch(ROUTE, {
380
+ method: 'POST',
381
+ headers: { 'content-type': 'application/json', [HEADER]: '1' },
382
+ body: JSON.stringify(payload()),
383
+ });
384
+ const text = await res.text();
385
+ if (!res.ok) throw new Error('HTTP ' + res.status + ' ' + text.slice(0, 120));
386
+ const data = JSON.parse(text);
387
+ let note = '✅ 已存档:' + (data.mdPath || '') + (data.images ? '(含图 ' + data.images + ' 张)' : '');
388
+ try {
389
+ const sent = await notifyParent(summaryLine(), data);
390
+ if (sent.forwarded) note += ' · 已请浮游通知 ' + (NOTIFY.name || NOTIFY.id);
391
+ else if (sent.skipped && NOTIFY.id) note += '(未通知会话)';
392
+ } catch (error) {
393
+ note += ' · ⚠️ 通知失败:' + error.message;
394
+ }
395
+ status.textContent = note;
396
+ } catch (error) {
397
+ status.textContent = '❌ 提交失败:' + error.message + '(可用「复制」兜底)';
398
+ } finally {
399
+ btn.disabled = false;
400
+ }
401
+ });
402
+ </script>
403
+ </body>
404
+ </html>