@code2rich/jpage 1.5.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/.claude/settings.local.json +68 -0
- package/.dockerignore +8 -0
- package/.env.example +56 -0
- package/.github/workflows/ci.yml +43 -0
- package/CLAUDE.md +280 -0
- package/Dockerfile +44 -0
- package/LICENSE +21 -0
- package/README.md +433 -0
- package/README_EN.md +399 -0
- package/bin/args.js +64 -0
- package/bin/client.js +93 -0
- package/bin/commands/_shared.js +54 -0
- package/bin/commands/cat.js +23 -0
- package/bin/commands/ls.js +44 -0
- package/bin/commands/mv.js +20 -0
- package/bin/commands/rm.js +22 -0
- package/bin/commands/skills.js +70 -0
- package/bin/commands/star.js +23 -0
- package/bin/commands/tags.js +97 -0
- package/bin/commands/upload.js +84 -0
- package/bin/commands/url.js +25 -0
- package/bin/commands/whoami.js +29 -0
- package/bin/config.js +85 -0
- package/bin/jpage.js +168 -0
- package/build.js +112 -0
- package/docker-compose.yml +26 -0
- package/docs/api.md +438 -0
- package/docs/design/005-custom-modal.md +296 -0
- package/docs/design/013-file-version-history.md +324 -0
- package/docs/design/billing-system.md +600 -0
- package/docs/design/db-index-and-healthcheck.md +176 -0
- package/docs/design/loading-states.md +209 -0
- package/docs/virtual-hosting-feasibility.md +453 -0
- package/eslint.config.mjs +172 -0
- package/lib/auth-state.js +15 -0
- package/lib/categories.js +20 -0
- package/lib/crypto.js +85 -0
- package/lib/csp.js +66 -0
- package/lib/db.js +53 -0
- package/lib/dispatch.js +103 -0
- package/lib/fts.js +81 -0
- package/lib/middleware/auth.js +114 -0
- package/lib/middleware/files.js +42 -0
- package/lib/paths.js +9 -0
- package/lib/render-cache.js +48 -0
- package/lib/render.js +157 -0
- package/lib/templates.js +149 -0
- package/lib/util.js +66 -0
- package/lib/view-counts.js +59 -0
- package/lib/zip.js +192 -0
- package/logger.js +16 -0
- package/mailer.js +34 -0
- package/mcp/constants.js +16 -0
- package/mcp/resources.js +74 -0
- package/mcp/server.js +43 -0
- package/mcp/tools-categories.js +56 -0
- package/mcp/tools-content-templates.js +59 -0
- package/mcp/tools-files.js +245 -0
- package/mcp/tools-tags.js +41 -0
- package/mcp/tools-versions.js +57 -0
- package/mcp/transport.js +183 -0
- package/mcp/util.js +63 -0
- package/mcp-server.js +20 -0
- package/migrations/001_init_schema.js +25 -0
- package/migrations/002_add_share_key.js +33 -0
- package/migrations/003_add_roles_and_tokens.js +28 -0
- package/migrations/004_add_version_history.js +32 -0
- package/migrations/005_tags_starred_categories.js +49 -0
- package/migrations/006_zip_bundle.js +17 -0
- package/migrations/007_add_file_type_uploaded_by_indexes.js +7 -0
- package/migrations/008_add_fts5.js +6 -0
- package/migrations/009_add_link_visits.js +20 -0
- package/migrations/010_add_templates_system.js +34 -0
- package/migrations/011_content_templates.js +233 -0
- package/migrations/012_add_email_and_verification.js +35 -0
- package/migrations/013_add_token_encrypted.js +14 -0
- package/migrations.js +65 -0
- package/package.json +63 -0
- package/public/css/style.css +2915 -0
- package/public/index.html +855 -0
- package/public/js/api.js +22 -0
- package/public/js/app.js +94 -0
- package/public/js/components/dialog.js +106 -0
- package/public/js/components/toast.js +13 -0
- package/public/js/pages/content-templates.js +330 -0
- package/public/js/pages/home.js +1903 -0
- package/public/js/pages/landing.js +158 -0
- package/public/js/pages/login.js +175 -0
- package/public/js/pages/preview.js +713 -0
- package/public/js/theme.js +44 -0
- package/public/js/utils.js +67 -0
- package/routes/admin.js +136 -0
- package/routes/auth.js +365 -0
- package/routes/categories.js +90 -0
- package/routes/content-templates.js +215 -0
- package/routes/files/_shared.js +112 -0
- package/routes/files/associations.js +94 -0
- package/routes/files/crud.js +139 -0
- package/routes/files/detail-serve.js +178 -0
- package/routes/files/index.js +38 -0
- package/routes/files/list.js +200 -0
- package/routes/files/overwrite.js +114 -0
- package/routes/files/upload.js +204 -0
- package/routes/files/versions.js +166 -0
- package/routes/files.js +16 -0
- package/routes/skills.js +93 -0
- package/routes/tags.js +65 -0
- package/routes/tokens.js +110 -0
- package/routes/users.js +120 -0
- package/server.js +372 -0
- package/skills/jpage-content-template/SKILL.md +98 -0
- package/skills/jpage-upload/SKILL.md +247 -0
- package/skills-registry.js +135 -0
- package/templates/academic.html +41 -0
- package/templates/dark-pro.html +41 -0
- package/templates/default.html +56 -0
- package/templates/github.html +67 -0
- package/test/browser-harness.js +125 -0
- package/test/dispatch-bench.js +74 -0
- package/test/helpers/setup.js +45 -0
- package/test/integration/admin.test.js +108 -0
- package/test/integration/auth.test.js +93 -0
- package/test/integration/categories.test.js +103 -0
- package/test/integration/cli.test.js +310 -0
- package/test/integration/content-templates.test.js +147 -0
- package/test/integration/files-security.test.js +248 -0
- package/test/integration/files.test.js +139 -0
- package/test/integration/share.test.js +79 -0
- package/test/integration/skills.test.js +104 -0
- package/test/integration/tags.test.js +84 -0
- package/test/integration/tokens.test.js +89 -0
- package/test/integration/users.test.js +138 -0
- package/test/mcp-harness.js +152 -0
- package/test/perf-bench.js +108 -0
- package/test/perf-harness.js +198 -0
- package/test/run-server.sh +15 -0
- package/test/unit/cli-args.test.js +88 -0
- package/test/unit/cli-config.test.js +89 -0
- package/test/unit/crypto.test.js +100 -0
- package/test/unit/fts.test.js +52 -0
- package/test/unit/render-cache.test.js +76 -0
- package/test/unit/util.test.js +81 -0
- package/test/unit/zip.test.js +164 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
# 005 — 自定义 Modal 组件替代原生 prompt/confirm
|
|
2
|
+
|
|
3
|
+
> 状态:设计完成,待实现
|
|
4
|
+
> 关联:分析报告问题 #8(重命名 prompt)、#9(删除 confirm)
|
|
5
|
+
> 优先级:P1
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 一、现状分析
|
|
10
|
+
|
|
11
|
+
### 1.1 当前已实现
|
|
12
|
+
|
|
13
|
+
重命名和删除**已经**使用了自定义 modal(非原生 `prompt`/`confirm`),但实现方式是「每个场景独立编写 HTML + JS」:
|
|
14
|
+
|
|
15
|
+
| Modal | HTML 行数 | JS 逻辑 | 特点 |
|
|
16
|
+
|---|---|---|---|
|
|
17
|
+
| `rename-modal` | index.html:188-206 | app.js `doRename()` ~55 行 | 输入框 + 校验 + Promise 封装 |
|
|
18
|
+
| `delete-modal` | index.html:209-223 | app.js `doDelete()` ~43 行 | 动态文案 + 危险操作样式 |
|
|
19
|
+
| `skill-modal` | index.html:127-145 | app.js ~110 行 | 展示型,无表单 |
|
|
20
|
+
| `mcp-config-modal` | index.html:150-165 | 少量 | 展示型,纯复制 |
|
|
21
|
+
| `skills-list-modal` | index.html:167-184 | 少量 | 列表型 |
|
|
22
|
+
|
|
23
|
+
### 1.2 存在的问题
|
|
24
|
+
|
|
25
|
+
1. **代码重复**:每个 modal 的 open/close/cleanup 逻辑几乎一致(事件绑定、backdrop 点击、Escape 关闭、aria 属性切换),但各写一遍
|
|
26
|
+
2. **无通用组件**:新增 modal 场景(如分享链接、文件上传进度)需要复制整套 boilerplate
|
|
27
|
+
3. **事件泄漏风险**:手动 `addEventListener` / `removeEventListener` 配对,容易遗漏
|
|
28
|
+
4. **样式不统一**:个别 modal 使用 inline style(`style="max-width:440px"`),而非 CSS class
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## 二、设计目标
|
|
33
|
+
|
|
34
|
+
1. **一个通用 Modal 工具函数**,覆盖三种场景:
|
|
35
|
+
- **Alert**:纯信息提示 + 确认按钮(替代未来可能的 `alert`)
|
|
36
|
+
- **Confirm**:确认/取消双按钮(替代 `confirm`,如删除确认)
|
|
37
|
+
- **Prompt**:输入框 + 确认/取消(替代 `prompt`,如重命名)
|
|
38
|
+
2. **Promise 接口**,调用方式简洁:`const name = await modal.prompt({ title, value })`
|
|
39
|
+
3. **复用现有 CSS**,不新增框架依赖
|
|
40
|
+
4. **向后兼容**:保留 skill-modal、mcp-config-modal 等展示型 modal 不变,仅重构交互型 modal
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 三、API 设计
|
|
45
|
+
|
|
46
|
+
### 3.1 `modal.confirm(options)` → `Promise<boolean>`
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
const ok = await modal.confirm({
|
|
50
|
+
title: '确认删除',
|
|
51
|
+
message: '确定要删除 <strong>report.html</strong> 吗?此操作不可撤销。',
|
|
52
|
+
// message 支持 HTML 字符串
|
|
53
|
+
confirmText: '删除', // 默认 '确认'
|
|
54
|
+
danger: true, // 确认按钮使用红色危险样式
|
|
55
|
+
});
|
|
56
|
+
// ok === true → 用户点了确认
|
|
57
|
+
// ok === false → 用户点了取消 / Escape / 点击遮罩
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### 3.2 `modal.prompt(options)` → `Promise<string|null>`
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
const name = await modal.prompt({
|
|
64
|
+
title: '重命名文件',
|
|
65
|
+
label: '文件名', // input 上方的 label 文字
|
|
66
|
+
value: 'old-name.html', // 输入框初始值
|
|
67
|
+
placeholder: '输入新文件名',
|
|
68
|
+
validate: (v) => { // 可选校验函数
|
|
69
|
+
if (!v.trim()) return '文件名不能为空';
|
|
70
|
+
if (/[\/\\]/.test(v)) return '文件名不能包含 / 或 \\';
|
|
71
|
+
return null; // 返回 null 表示通过
|
|
72
|
+
},
|
|
73
|
+
confirmText: '确认', // 默认 '确认'
|
|
74
|
+
});
|
|
75
|
+
// name === string → 用户输入的值(已 trim)
|
|
76
|
+
// name === null → 用户取消
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### 3.3 `modal.alert(options)` → `Promise<void>`
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
await modal.alert({
|
|
83
|
+
title: '提示',
|
|
84
|
+
message: '复制失败,请手动复制以下链接',
|
|
85
|
+
});
|
|
86
|
+
// 用户点确认或关闭后 resolve
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## 四、实现方案
|
|
92
|
+
|
|
93
|
+
### 4.1 HTML 结构
|
|
94
|
+
|
|
95
|
+
在 `index.html` 中新增一个**通用 modal 容器**(替换现有的 `rename-modal` 和 `delete-modal`):
|
|
96
|
+
|
|
97
|
+
```html
|
|
98
|
+
<!-- 通用交互 Modal -->
|
|
99
|
+
<div class="modal-backdrop" id="dialog-modal" hidden aria-hidden="true"
|
|
100
|
+
role="dialog" aria-modal="true" aria-labelledby="dialog-modal-title">
|
|
101
|
+
<div class="modal-panel modal-panel-sm">
|
|
102
|
+
<div class="modal-header">
|
|
103
|
+
<h2 id="dialog-modal-title"></h2>
|
|
104
|
+
<button type="button" class="btn btn-small modal-close"
|
|
105
|
+
id="dialog-modal-close" aria-label="关闭">×</button>
|
|
106
|
+
</div>
|
|
107
|
+
<div class="modal-body">
|
|
108
|
+
<div class="dialog-message" id="dialog-modal-message"></div>
|
|
109
|
+
<label class="login-field dialog-field" id="dialog-modal-field" hidden>
|
|
110
|
+
<span id="dialog-modal-label"></span>
|
|
111
|
+
<input type="text" id="dialog-modal-input" autocomplete="off">
|
|
112
|
+
</label>
|
|
113
|
+
<div class="login-error" id="dialog-modal-error" role="alert" hidden></div>
|
|
114
|
+
</div>
|
|
115
|
+
<div class="modal-footer">
|
|
116
|
+
<button type="button" class="btn btn-small" id="dialog-modal-cancel">取消</button>
|
|
117
|
+
<button type="button" class="btn btn-primary" id="dialog-modal-confirm">确认</button>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
要点:
|
|
124
|
+
- `dialog-message`:confirm/alert 场景显示消息,prompt 场景可隐藏或共存
|
|
125
|
+
- `dialog-field`:仅 prompt 场景显示(通过 `hidden` 切换)
|
|
126
|
+
- `dialog-modal-cancel`:alert 场景隐藏该按钮(只留确认)
|
|
127
|
+
- 现有的 `rename-modal` 和 `delete-modal` 两个 HTML 块将被移除
|
|
128
|
+
|
|
129
|
+
### 4.2 CSS 新增
|
|
130
|
+
|
|
131
|
+
```css
|
|
132
|
+
/* 小尺寸 modal(prompt / confirm / alert) */
|
|
133
|
+
.modal-panel-sm {
|
|
134
|
+
max-width: 440px;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* dialog body 内的消息文字 */
|
|
138
|
+
.dialog-message {
|
|
139
|
+
font-size: 15px;
|
|
140
|
+
line-height: 1.6;
|
|
141
|
+
margin: 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* dialog 输入区域的 label+input 间距 */
|
|
145
|
+
.dialog-field {
|
|
146
|
+
margin-top: 12px;
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
不再使用 inline style,统一用 `modal-panel-sm` class。
|
|
151
|
+
|
|
152
|
+
### 4.3 JS 实现
|
|
153
|
+
|
|
154
|
+
在 `app.js` 中新增 `modal` 对象(约 80 行),核心逻辑:
|
|
155
|
+
|
|
156
|
+
```js
|
|
157
|
+
const dialogModal = {
|
|
158
|
+
el: null, input: null, error: null,
|
|
159
|
+
msg: null, field: null,
|
|
160
|
+
confirmBtn: null, cancelBtn: null, closeBtn: null,
|
|
161
|
+
_resolve: null,
|
|
162
|
+
_mode: null, // 'alert' | 'confirm' | 'prompt'
|
|
163
|
+
_validate: null,
|
|
164
|
+
|
|
165
|
+
init() {
|
|
166
|
+
// 缓存 DOM 引用,绑定一次性事件
|
|
167
|
+
this.el = document.getElementById('dialog-modal');
|
|
168
|
+
// ... 缓存其他元素 ...
|
|
169
|
+
this.closeBtn.onclick = () => this._dismiss();
|
|
170
|
+
this.cancelBtn.onclick = () => this._dismiss();
|
|
171
|
+
this.el.addEventListener('click', e => {
|
|
172
|
+
if (e.target === this.el) this._dismiss();
|
|
173
|
+
});
|
|
174
|
+
this.input.addEventListener('keydown', e => {
|
|
175
|
+
if (e.key === 'Enter') this._accept();
|
|
176
|
+
if (e.key === 'Escape') this._dismiss();
|
|
177
|
+
});
|
|
178
|
+
document.addEventListener('keydown', e => {
|
|
179
|
+
if (e.key === 'Escape' && !this.el.hidden) this._dismiss();
|
|
180
|
+
});
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
_open(mode, opts) {
|
|
184
|
+
this._mode = mode;
|
|
185
|
+
this._resolve = null;
|
|
186
|
+
// 设置 title、message、field 可见性、按钮文案等
|
|
187
|
+
// ...
|
|
188
|
+
this.el.hidden = false;
|
|
189
|
+
this.el.setAttribute('aria-hidden', 'false');
|
|
190
|
+
if (mode === 'prompt') { this.input.focus(); this.input.select(); }
|
|
191
|
+
else { this.confirmBtn.focus(); }
|
|
192
|
+
return new Promise(resolve => { this._resolve = resolve; });
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
_accept() {
|
|
196
|
+
if (this._mode === 'prompt') {
|
|
197
|
+
const val = this.input.value.trim();
|
|
198
|
+
if (this._validate) {
|
|
199
|
+
const err = this._validate(val);
|
|
200
|
+
if (err) { this.error.textContent = err; this.error.hidden = false; return; }
|
|
201
|
+
}
|
|
202
|
+
this._close(val);
|
|
203
|
+
} else {
|
|
204
|
+
this._close(true);
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
_dismiss() {
|
|
209
|
+
this._close(this._mode === 'prompt' ? null : false);
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
_close(result) {
|
|
213
|
+
this.el.hidden = true;
|
|
214
|
+
this.el.setAttribute('aria-hidden', 'true');
|
|
215
|
+
this.error.hidden = true;
|
|
216
|
+
this._resolve(result);
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
confirm(opts) { return this._open('confirm', opts); },
|
|
220
|
+
prompt(opts) { return this._open('prompt', opts); },
|
|
221
|
+
alert(opts) { return this._open('alert', opts); },
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// 在 DOMContentLoaded 中调用 dialogModal.init()
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### 4.4 调用方改造
|
|
228
|
+
|
|
229
|
+
**`doRename` 改造前(~55 行)→ 改造后(~12 行):**
|
|
230
|
+
|
|
231
|
+
```js
|
|
232
|
+
async function doRename(container, id, currentName) {
|
|
233
|
+
const name = await dialogModal.prompt({
|
|
234
|
+
title: '重命名文件',
|
|
235
|
+
label: '文件名',
|
|
236
|
+
value: currentName,
|
|
237
|
+
validate: v => {
|
|
238
|
+
if (!v.trim()) return '文件名不能为空';
|
|
239
|
+
return null;
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
if (name === null || name === currentName) return;
|
|
243
|
+
try {
|
|
244
|
+
await api(`/api/files/${id}`, { method: 'PUT', body: { name } });
|
|
245
|
+
toast('重命名成功');
|
|
246
|
+
loadFiles(container);
|
|
247
|
+
} catch (e) {
|
|
248
|
+
toast(e.message, 'error');
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
**`doDelete` 改造前(~43 行)→ 改造后(~10 行):**
|
|
254
|
+
|
|
255
|
+
```js
|
|
256
|
+
async function doDelete(container, id, fileName) {
|
|
257
|
+
const ok = await dialogModal.confirm({
|
|
258
|
+
title: '确认删除',
|
|
259
|
+
message: `确定要删除 <strong>${escapeHtml(fileName)}</strong> 吗?此操作不可撤销。`,
|
|
260
|
+
confirmText: '删除',
|
|
261
|
+
danger: true,
|
|
262
|
+
});
|
|
263
|
+
if (!ok) return;
|
|
264
|
+
try {
|
|
265
|
+
await api(`/api/files/${id}`, { method: 'DELETE' });
|
|
266
|
+
toast('删除成功');
|
|
267
|
+
loadFiles(container);
|
|
268
|
+
} catch (e) {
|
|
269
|
+
toast(e.message, 'error');
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
276
|
+
## 五、影响范围
|
|
277
|
+
|
|
278
|
+
| 文件 | 变更类型 | 说明 |
|
|
279
|
+
|---|---|---|
|
|
280
|
+
| `public/index.html` | 修改 | 移除 `rename-modal` 和 `delete-modal` 两个 HTML 块,新增通用 `dialog-modal` |
|
|
281
|
+
| `public/css/style.css` | 修改 | 新增 `.modal-panel-sm`、`.dialog-message`、`.dialog-field` 样式 |
|
|
282
|
+
| `public/js/app.js` | 修改 | 新增 `dialogModal` 对象(~80 行),精简 `doRename` / `doDelete`(共减少 ~60 行) |
|
|
283
|
+
|
|
284
|
+
**不影响的文件**:`server.js`、`mcp-server.js`、`skills-registry.js`、展示型 modal(skill / mcp-config / skills-list)
|
|
285
|
+
|
|
286
|
+
---
|
|
287
|
+
|
|
288
|
+
## 六、验收标准
|
|
289
|
+
|
|
290
|
+
1. 重命名流程:点击重命名 → 弹出 modal → 输入新名称 → 校验不通过显示错误 → 确认后调用 API → 成功 toast + 刷新列表
|
|
291
|
+
2. 删除流程:点击删除 → 弹出确认 modal → 确认按钮为红色 → 确认后调用 API → 成功 toast + 刷新列表
|
|
292
|
+
3. Escape 键 / 点击遮罩 / 点取消 → 关闭 modal,不触发任何操作
|
|
293
|
+
4. 回车键(prompt 场景)→ 等同于点确认
|
|
294
|
+
5. 无 `window.prompt` / `window.confirm` / `window.alert` 调用残留
|
|
295
|
+
6. 深色模式下 modal 显示正常
|
|
296
|
+
7. 移动端 modal 居中显示,不被截断
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
# 013 — 文件版本历史
|
|
2
|
+
|
|
3
|
+
> 状态:设计完成,待实现
|
|
4
|
+
> 关联:问题 21(版本历史)
|
|
5
|
+
> 复杂度:★★★★★(DB + API + JS + CSS)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 一、目标
|
|
10
|
+
|
|
11
|
+
为每个文件维护版本链。上传同名文件自动覆盖并保留历史版本;支持查看、恢复、删除历史版本。
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 二、数据库变更
|
|
16
|
+
|
|
17
|
+
### 2.1 `files` 表新增 `updated_at`
|
|
18
|
+
|
|
19
|
+
```sql
|
|
20
|
+
ALTER TABLE files ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP;
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
回填:`UPDATE files SET updated_at = created_at WHERE updated_at IS NULL;`
|
|
24
|
+
|
|
25
|
+
语义:
|
|
26
|
+
- `created_at` = 首次上传时间,创建后不变
|
|
27
|
+
- `updated_at` = 最后一次覆盖上传的时间,每次覆盖刷新
|
|
28
|
+
|
|
29
|
+
文件列表排序改为 `ORDER BY updated_at DESC`。
|
|
30
|
+
|
|
31
|
+
### 2.2 新增 `file_versions` 表
|
|
32
|
+
|
|
33
|
+
```sql
|
|
34
|
+
CREATE TABLE IF NOT EXISTS file_versions (
|
|
35
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
36
|
+
file_id INTEGER NOT NULL,
|
|
37
|
+
version INTEGER NOT NULL,
|
|
38
|
+
stored_name TEXT NOT NULL,
|
|
39
|
+
size INTEGER NOT NULL,
|
|
40
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
41
|
+
uploaded_by INTEGER,
|
|
42
|
+
UNIQUE(file_id, version),
|
|
43
|
+
FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE
|
|
44
|
+
);
|
|
45
|
+
CREATE INDEX IF NOT EXISTS idx_fv_file_ver ON file_versions(file_id, version DESC);
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
规则:
|
|
49
|
+
- `files.stored_name` 始终指向**当前版本**文件
|
|
50
|
+
- 覆盖上传时,先把旧 `stored_name` 写入 `file_versions`,再更新 `files` 主记录
|
|
51
|
+
- `version` 从 1 递增,不设上限
|
|
52
|
+
- 删除文件时 CASCADE 清理版本记录 + 遍历删磁盘文件
|
|
53
|
+
|
|
54
|
+
### 2.3 迁移策略
|
|
55
|
+
|
|
56
|
+
在 `db.serialize()` 块中顺序执行:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
1. PRAGMA table_info(files) → 检查并 ADD COLUMN updated_at
|
|
60
|
+
2. 回填 updated_at
|
|
61
|
+
3. CREATE TABLE IF NOT EXISTS file_versions
|
|
62
|
+
4. CREATE INDEX IF NOT EXISTS idx_fv_file_ver
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
无需数据迁移,对现有文件透明。
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## 三、同名文件自动覆盖逻辑
|
|
70
|
+
|
|
71
|
+
### 3.1 触发条件
|
|
72
|
+
|
|
73
|
+
`POST /api/files/upload` 和 `POST /api/files/upload-json` 中,写入数据库前检查:
|
|
74
|
+
|
|
75
|
+
```js
|
|
76
|
+
const existing = await dbGet(
|
|
77
|
+
'SELECT id, stored_name, size, uploaded_by FROM files WHERE original_name = ?',
|
|
78
|
+
[decodedName]
|
|
79
|
+
);
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
- **存在同名文件** → 执行覆盖流程(§3.2)
|
|
83
|
+
- **不存在** → 保持原有新建逻辑不变
|
|
84
|
+
|
|
85
|
+
### 3.2 覆盖流程
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
1. 计算 version 号:
|
|
89
|
+
SELECT COALESCE(MAX(version), 0) + 1 FROM file_versions WHERE file_id = existing.id
|
|
90
|
+
→ nextVer
|
|
91
|
+
|
|
92
|
+
2. 备份当前版本到 file_versions:
|
|
93
|
+
INSERT INTO file_versions (file_id, version, stored_name, size, uploaded_by)
|
|
94
|
+
VALUES (existing.id, nextVer, existing.stored_name, existing.size, existing.uploaded_by)
|
|
95
|
+
|
|
96
|
+
3. 新文件写入磁盘(新 stored_name)
|
|
97
|
+
|
|
98
|
+
4. 更新 files 主记录:
|
|
99
|
+
UPDATE files SET stored_name = ?, size = ?, updated_at = CURRENT_TIMESTAMP
|
|
100
|
+
WHERE id = existing.id
|
|
101
|
+
|
|
102
|
+
5. 返回覆盖结果(含 version 信息)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**响应变化**:覆盖时返回 `{ overwritten: true, id, version: nextVer + 1, ... }`,前端据此提示「已更新为第 N 版」而非「上传成功」。
|
|
106
|
+
|
|
107
|
+
### 3.3 文件类型校验
|
|
108
|
+
|
|
109
|
+
同名覆盖时仍需校验扩展名一致。如果同名但扩展名不同(如已有 `a.html` 又上传 `a.md`),拒绝覆盖并提示「文件类型不匹配」,走新建流程。
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## 四、API 设计
|
|
114
|
+
|
|
115
|
+
### 4.1 新增端点
|
|
116
|
+
|
|
117
|
+
| 方法 | 路径 | 鉴权 | 说明 |
|
|
118
|
+
|---|---|---|---|
|
|
119
|
+
| `GET` | `/api/files/:id/versions` | 需登录 | 列出版本历史 |
|
|
120
|
+
| `GET` | `/api/files/:id/versions/:ver/content` | 需登录 | 获取历史版本原文 |
|
|
121
|
+
| `GET` | `/api/files/:id/versions/:ver/render` | 需登录 | 渲染历史版本 |
|
|
122
|
+
| `POST` | `/api/files/:id/versions/:ver/restore` | 需登录 | 恢复到指定版本 |
|
|
123
|
+
| `DELETE` | `/api/files/:id/versions/:ver` | 需登录 | 删除指定历史版本 |
|
|
124
|
+
|
|
125
|
+
### 4.2 修改现有端点
|
|
126
|
+
|
|
127
|
+
| 端点 | 变更 |
|
|
128
|
+
|---|---|
|
|
129
|
+
| `POST /api/files/upload` | 上传前查同名 → 覆盖流程 |
|
|
130
|
+
| `POST /api/files/upload-json` | 上传前查同名 → 覆盖流程 |
|
|
131
|
+
| `GET /api/files` | 返回字段增加 `version_count`;排序改为 `updated_at DESC` |
|
|
132
|
+
| `DELETE /api/files/:id` | 删除前清理 `file_versions` 及对应磁盘文件 |
|
|
133
|
+
|
|
134
|
+
### 4.3 响应格式
|
|
135
|
+
|
|
136
|
+
**`GET /api/files/:id/versions`**
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{
|
|
140
|
+
"file_id": 42,
|
|
141
|
+
"current": {
|
|
142
|
+
"stored_name": "1749...html",
|
|
143
|
+
"size": 1234,
|
|
144
|
+
"updated_at": "2026-06-08T14:30:00Z"
|
|
145
|
+
},
|
|
146
|
+
"versions": [
|
|
147
|
+
{
|
|
148
|
+
"id": 7,
|
|
149
|
+
"version": 1,
|
|
150
|
+
"size": 1000,
|
|
151
|
+
"created_at": "2026-06-07T10:00:00Z"
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
"id": 8,
|
|
155
|
+
"version": 2,
|
|
156
|
+
"size": 1100,
|
|
157
|
+
"created_at": "2026-06-08T09:00:00Z"
|
|
158
|
+
}
|
|
159
|
+
]
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
**覆盖上传响应**
|
|
164
|
+
|
|
165
|
+
```json
|
|
166
|
+
{
|
|
167
|
+
"id": 42,
|
|
168
|
+
"overwritten": true,
|
|
169
|
+
"version": 3,
|
|
170
|
+
"original_name": "report.html",
|
|
171
|
+
"file_type": "html",
|
|
172
|
+
"size": 1234,
|
|
173
|
+
"is_public": 1,
|
|
174
|
+
"share_key": "abc12345"
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### 4.4 恢复版本流程
|
|
179
|
+
|
|
180
|
+
`POST /api/files/:id/versions/:ver/restore`
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
1. 从 file_versions 取目标版本的 stored_name
|
|
184
|
+
2. 读目标版本文件内容
|
|
185
|
+
3. 写入新磁盘文件(新 stored_name)
|
|
186
|
+
4. 当前版本备份到 file_versions(nextVer)
|
|
187
|
+
5. 更新 files.stored_name / size / updated_at 指向新文件
|
|
188
|
+
6. 返回成功
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
注意:restore 时复制文件内容到新 stored_name,这样 `file_versions` 中的记录可安全删除不影响当前版本。
|
|
192
|
+
|
|
193
|
+
### 4.5 删除版本流程
|
|
194
|
+
|
|
195
|
+
`DELETE /api/files/:id/versions/:ver`
|
|
196
|
+
|
|
197
|
+
```
|
|
198
|
+
1. 查 file_versions 获取 stored_name
|
|
199
|
+
2. 删除磁盘文件
|
|
200
|
+
3. DELETE FROM file_versions
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## 五、文件列表页变更
|
|
206
|
+
|
|
207
|
+
### 5.1 SQL 查询
|
|
208
|
+
|
|
209
|
+
```sql
|
|
210
|
+
SELECT f.*,
|
|
211
|
+
(SELECT COUNT(*) FROM file_versions WHERE file_id = f.id) AS version_count
|
|
212
|
+
FROM files f
|
|
213
|
+
ORDER BY f.updated_at DESC
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### 5.2 列表项展示
|
|
217
|
+
|
|
218
|
+
`file-subline` 增加版本信息:
|
|
219
|
+
|
|
220
|
+
```
|
|
221
|
+
HTML | 公开 | 12.3 KB · v3 · 2 分钟前更新
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
- `v1` 不显示(无历史版本)
|
|
225
|
+
- `v2+` 显示版本号
|
|
226
|
+
|
|
227
|
+
### 5.3 前端变量
|
|
228
|
+
|
|
229
|
+
`allFiles` 中每个文件对象增加 `version_count` 字段,`renderFileList()` 中根据 `version_count > 0` 决定是否渲染版本 badge。
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
## 六、预览页 — 版本历史面板
|
|
234
|
+
|
|
235
|
+
### 6.1 布局
|
|
236
|
+
|
|
237
|
+
在预览页顶栏扩展行新增两个按钮:
|
|
238
|
+
|
|
239
|
+
```
|
|
240
|
+
[上传新版本] [历史 v3 ▾]
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
点击「历史」按钮,右侧滑出面板:
|
|
244
|
+
|
|
245
|
+
```
|
|
246
|
+
┌──────────────────────────────────────────────────────┐
|
|
247
|
+
│ ← 返回 文件名.html [渲染|源码] [下载] [上传新版本] [历史 v3 ▾] │
|
|
248
|
+
├──────────────────────────────────┬───────────────────┤
|
|
249
|
+
│ │ 版本历史 │
|
|
250
|
+
│ │ │
|
|
251
|
+
│ iframe 预览 │ ● 当前 (v3) │
|
|
252
|
+
│ │ 12.3 KB · 刚刚 │
|
|
253
|
+
│ │ │
|
|
254
|
+
│ │ ○ v2 │
|
|
255
|
+
│ │ 10.1 KB · 2h前 │
|
|
256
|
+
│ │ [查看] [恢复] [删除]│
|
|
257
|
+
│ │ │
|
|
258
|
+
│ │ ○ v1 │
|
|
259
|
+
│ │ 8.2 KB · 昨天 │
|
|
260
|
+
│ │ [查看] [恢复] [删除]│
|
|
261
|
+
└──────────────────────────────────┴───────────────────┘
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### 6.2 面板交互
|
|
265
|
+
|
|
266
|
+
| 操作 | 行为 |
|
|
267
|
+
|---|---|
|
|
268
|
+
| **查看** | iframe 加载 `/api/files/:id/versions/:ver/render`,面板保持打开 |
|
|
269
|
+
| **恢复** | 二次确认 → `POST /versions/:ver/restore` → 刷新版本列表 + iframe |
|
|
270
|
+
| **删除** | 二次确认 → `DELETE /versions/:ver` → 从列表移除该行 |
|
|
271
|
+
| **上传新版本** | 触发文件选择器 → 调用 `POST /api/files/:id/overwrite` → 刷新 |
|
|
272
|
+
| **关闭面板** | 点击遮罩 / Escape / 再次点击「历史」按钮 |
|
|
273
|
+
|
|
274
|
+
### 6.3 预览页「上传新版本」
|
|
275
|
+
|
|
276
|
+
预览页增加隐藏 `<input type="file">`,点击「上传新版本」触发。
|
|
277
|
+
|
|
278
|
+
调用新的 `POST /api/files/:id/overwrite` 端点(multipart),流程与首页上传类似,但目标是覆盖而非新建。
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
## 七、MCP 工具扩展
|
|
283
|
+
|
|
284
|
+
### 7.1 修改现有工具
|
|
285
|
+
|
|
286
|
+
`upload_file` 增加可选参数 `overwriteFileId`:
|
|
287
|
+
|
|
288
|
+
- 提供 `overwriteFileId` → 调用覆盖上传 API
|
|
289
|
+
- 不提供 → 保持原行为(同名自动覆盖逻辑在服务端生效,MCP JSON 上传也会触发)
|
|
290
|
+
|
|
291
|
+
### 7.2 新增工具
|
|
292
|
+
|
|
293
|
+
| 工具 | 参数 | 说明 |
|
|
294
|
+
|---|---|---|
|
|
295
|
+
| `list_file_versions` | `fileId: number` | 列出版本历史 |
|
|
296
|
+
| `restore_file_version` | `fileId: number, version: number` | 恢复到指定版本 |
|
|
297
|
+
|
|
298
|
+
---
|
|
299
|
+
|
|
300
|
+
## 八、实现步骤
|
|
301
|
+
|
|
302
|
+
| 步骤 | 内容 | 涉及文件 |
|
|
303
|
+
|---|---|---|
|
|
304
|
+
| 1 | 数据库迁移(`updated_at` + `file_versions` 表) | `server.js` |
|
|
305
|
+
| 2 | 覆盖上传逻辑(同名自动覆盖 + `/overwrite` 端点) | `server.js` |
|
|
306
|
+
| 3 | 版本 CRUD API(列表/内容/渲染/恢复/删除) | `server.js` |
|
|
307
|
+
| 4 | 修改删除文件端点(清理版本) | `server.js` |
|
|
308
|
+
| 5 | 修改文件列表 API(`version_count` + `updated_at` 排序) | `server.js` |
|
|
309
|
+
| 6 | 前端:文件列表显示版本号 | `app.js` |
|
|
310
|
+
| 7 | 前端:预览页版本面板 UI + 交互 | `app.js`, `index.html`, `style.css` |
|
|
311
|
+
| 8 | MCP 工具扩展 | `mcp-server.js` |
|
|
312
|
+
|
|
313
|
+
---
|
|
314
|
+
|
|
315
|
+
## 九、边界情况
|
|
316
|
+
|
|
317
|
+
| 场景 | 处理 |
|
|
318
|
+
|---|---|
|
|
319
|
+
| 同名文件但扩展名不同 | 拒绝覆盖,提示「文件类型不匹配」 |
|
|
320
|
+
| 版本面板查看历史版本时又上传新版本 | 刷新版本列表,iframe 回到当前版本 |
|
|
321
|
+
| 恢复已被删除的版本 | 404 |
|
|
322
|
+
| 删除所有历史版本 | 面板显示「仅有当前版本」 |
|
|
323
|
+
| 并发覆盖同一文件 | SQLite 写锁串行化,后到者基于最新版本号递增,不会丢版本 |
|
|
324
|
+
| MCP 上传同名文件 | 与 Web 上传走同一套自动覆盖逻辑 |
|