nx-sk 0.16.0 → 0.17.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/CHANGELOG.md +32 -0
- package/README.md +14 -1
- package/assets/nx-sk/SKILL.md +11 -1
- package/assets/nx-sk/references/40-files.md +36 -5
- package/package.json +1 -1
- package/src/modules/entries/view.jsx +22 -1
- package/src/modules/files/index.js +9 -0
- package/src/modules/files/service.js +47 -0
- package/src/modules/files/view.jsx +32 -0
- package/src/web/frontend/components/fieldEditor.jsx +73 -6
- package/src/web/frontend/styles.css +48 -1
- package/src/web/public/assets/{files-Cc9BanTT.js → files-CYxBsvjL.js} +1 -1
- package/src/web/public/assets/{index-B1tuW-Bw.js → index-BhVJGdcz.js} +2 -2
- package/src/web/public/assets/index-bNZkkcRr.css +1 -0
- package/src/web/public/assets/{view-DtLbyce-.js → view-C_FrPQ-S.js} +1 -1
- package/src/web/public/assets/{view-C_8pbtZX.js → view-CssUSAY2.js} +1 -1
- package/src/web/public/assets/view-D9cY7JwZ.js +2 -0
- package/src/web/public/assets/view-dM2GLJ6q.js +36 -0
- package/src/web/public/assets/{view-U7H8BYBr.js → view-whlEu4sk.js} +1 -1
- package/src/web/public/index.html +2 -2
- package/src/web/public/assets/index-BotRwCy_.css +0 -1
- package/src/web/public/assets/view-C1Ux-1yp.js +0 -1
- package/src/web/public/assets/view-CLufVxNB.js +0 -35
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,38 @@
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## 0.17.0 (2026-10-25)
|
|
8
|
+
|
|
9
|
+
**附件不再靠下拉框挑,孤儿附件能一键清,改动不用翻到页底才保存。**
|
|
10
|
+
|
|
11
|
+
三处都是「长栏目 / 附件多了以后,原来的做法开始难受」逼出来的:
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **附件库选择面板**(字段里的 `file` 字段):点「从附件库选…」打开的是
|
|
16
|
+
**可搜索的面板**,不是原生下拉框——库里几十个附件时,下拉框根本没法挑。
|
|
17
|
+
- **关键字搜索**:按名称 / 备注 / 标签过滤(Esc 关闭);
|
|
18
|
+
- **未引用优先**:没有任何条目引用的原件排在前面(刚导入还没挂上去的就在最上面),
|
|
19
|
+
已被引用的排在后面并标注「已被 N 处引用」——仍然可选(同一原件可被多条条目共享);
|
|
20
|
+
- 点一行即**追加**到本字段,可连点多个;已挂上的会从列表里消失。
|
|
21
|
+
- **`nx-sk file cleanup`**:一键清理**未被任何条目引用**的附件(孤儿附件),
|
|
22
|
+
物理原件一并删除、不可撤销。判据与附件中心的「未引用」筛选片**完全同一套**
|
|
23
|
+
(`fileReferences` 为空)——两套判据会出现「筛选片里看不到、清理却删了」这种吓人的不一致。
|
|
24
|
+
有 `--dry-run`(先列清单);没有可清的返回 `skipped`(不是失败)。
|
|
25
|
+
面板上:附件中心工具条「清理未引用(N)」,点了弹确认并报清数量。
|
|
26
|
+
- **条目表单的保存按钮固定在右上角**:有改动时表单顶栏出现「N 处改动未保存 · 保存」,
|
|
27
|
+
且顶栏**吸顶**(`position: sticky`)——长栏目滚到一半也看得见保存,不必翻到表单最底。
|
|
28
|
+
底部的保存按钮保留;关标签页前会拦一下(`beforeunload`),改到一半不留神关掉不丢改动。
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- 字段里的 `file` 附件选择从原生 `<select>` 换成选择面板(原来只能在一堆选项里肉眼找)。
|
|
33
|
+
|
|
34
|
+
### 测试
|
|
35
|
+
|
|
36
|
+
- 新增用例:`file cleanup` 只删未被引用的附件,被引用的原样留下(物理文件也在),
|
|
37
|
+
试运行不动台账,反复清理不伤被引用的附件。
|
|
38
|
+
|
|
7
39
|
## 0.16.0 (2026-10-23)
|
|
8
40
|
|
|
9
41
|
**凭据自动化:账号密码跟着场景走,直接渲染成配置文件。**
|
package/README.md
CHANGED
|
@@ -150,13 +150,14 @@ CLI 与 API 同源,skill 驱动 agent**。落到具体功能上:
|
|
|
150
150
|
| `nx-sk routes [--module M] [--http "POST /api/entries"]` | 命令 ↔ 路由双向对照 |
|
|
151
151
|
| `nx-sk section list \| get \| add \| update \| remove` | 栏目 CRUD |
|
|
152
152
|
| `nx-sk section dump <id> [--mask]` | **一个栏目的全部信息**:字段台账 + 全部条目 + 每个条目的未填清单 |
|
|
153
|
-
| `nx-sk section templates` | 内置模板(job / secret / projects / internships / campus / deploy) |
|
|
153
|
+
| `nx-sk section templates` | 内置模板(job / secret / projects / internships / campus / deploy / platform / thesis) |
|
|
154
154
|
| `nx-sk section sync <id> [--dry-run]` | 按模板**补齐**本栏目缺的分组与字段(只增不改不删)——模板演进后让老栏目追上 |
|
|
155
155
|
| `nx-sk entry list \| get \| add \| update \| remove` | 条目 CRUD(PATCH 语义) |
|
|
156
156
|
| `nx-sk entry fields [--section job]` | 字段字典 —— 填之前先看这个,别猜字段名 |
|
|
157
157
|
| `nx-sk file list \| get \| add \| update \| remove` | 附件 CRUD(原件在 `~/nx-sk/files/`,同内容去重) |
|
|
158
158
|
| `nx-sk file add --path <文件> [--copy] [--category C]` | 导入附件:**默认移动原件**,`--copy` 仅复制 |
|
|
159
159
|
| `nx-sk file list [--section <栏目\|none>] [--q 关键字]` | 列附件:按「哪些栏目在引用」筛选(`none` = 未引用);返回栏目汇总与存储目录 |
|
|
160
|
+
| `nx-sk file cleanup [--dry-run]` | **一键清理未被任何条目引用的附件**(孤儿附件,物理原件一并删);`--dry-run` 先看清单 |
|
|
160
161
|
| `nx-sk file restore <id> [--to DIR] [--copy]` | **可逆**:移回原始位置并撤销台账;`--copy` 只导出副本 |
|
|
161
162
|
| `nx-sk file content <id>` | 浏览器链接(内联预览 / `?download=1` 下载);CLI 打印物理路径 |
|
|
162
163
|
| `nx-sk file open <id>` | 用系统默认程序打开原件 |
|
|
@@ -238,6 +239,8 @@ node assets/nx-sk/scripts/render-config.js /tmp/e.json ./docker-compose.local.ym
|
|
|
238
239
|
nx-sk file add --path "D:\简历\张三-简历.pdf" # 默认移动原件进库
|
|
239
240
|
nx-sk file list # 台账:按栏目汇总 + 被引用情况
|
|
240
241
|
nx-sk file list --section none # 只看还没有条目引用的
|
|
242
|
+
nx-sk file cleanup --dry-run # 一键清理未引用附件:先看会删哪些
|
|
243
|
+
nx-sk file cleanup # 真删(物理原件一起删,不可撤销)
|
|
241
244
|
nx-sk file restore f_xxxx # 可逆:移回原始位置、撤销台账
|
|
242
245
|
nx-sk file restore f_xxxx --to "D:\资料" --copy # 或导出副本到指定目录
|
|
243
246
|
```
|
|
@@ -245,6 +248,16 @@ nx-sk file restore f_xxxx --to "D:\资料" --copy # 或导出副本到指定目
|
|
|
245
248
|
**附件怎么被条目引用**:条目的 `file` 字段存的是**附件 id 的列表**——一个字段可以挂**多个**
|
|
246
249
|
附件(简历多版本、头像多张)。id 全局唯一,所以每条条目各有各的附件、互不串台;
|
|
247
250
|
同一份原件也能被多条条目引用(sha256 去重后是同一条)。
|
|
251
|
+
|
|
252
|
+
面板上的三处配合:
|
|
253
|
+
|
|
254
|
+
- **字段里挑附件**:点「从附件库选…」打开的是**可搜索的选择面板**(不是下拉框)——
|
|
255
|
+
按名称 / 备注 / 标签过滤,**未引用的排在前面**(刚导入还没挂上去的原件就在最上面),
|
|
256
|
+
点一行即追加,可连点多个。
|
|
257
|
+
- **附件中心一键清理**:「清理未引用(N)」删掉所有没有任何条目引用的附件
|
|
258
|
+
(判据与「未引用」筛选片同一套),物理原件一并删除,操作前弹确认。
|
|
259
|
+
- **条目表单的保存按钮固定在右上角**:有改动时顶栏出现「N 处改动未保存 · 保存」,
|
|
260
|
+
长栏目滚到一半也看得见,不必翻到表单最底;关标签页前还会拦一下。
|
|
248
261
|
**上传 / 选择发生在栏目条目的字段里**(求职 → 张三 → 「简历附件」直接上传或从库里选),
|
|
249
262
|
不在附件中心做指派:
|
|
250
263
|
|
package/assets/nx-sk/SKILL.md
CHANGED
|
@@ -174,11 +174,21 @@ nx-sk key remove OPENAI_KEY # 删除,不可撤销 → 先 nx-sk
|
|
|
174
174
|
nx-sk file add --path "D:\简历\张三-简历.pdf" # 默认把原件移动进库(--copy 仅复制)
|
|
175
175
|
nx-sk file list # 台账:按栏目汇总 + 被引用情况
|
|
176
176
|
nx-sk file list --section none # 只看还没有条目引用的
|
|
177
|
+
nx-sk file cleanup --dry-run # 一键清理未引用附件:先看会删哪些
|
|
178
|
+
nx-sk file cleanup # 真删(物理原件一起删,不可撤销)
|
|
177
179
|
nx-sk file restore f_xxxx # 可逆:移回原始位置并撤销台账
|
|
178
180
|
nx-sk file restore f_xxxx --to DIR --copy # 或导出副本、库中保留
|
|
179
181
|
nx-sk file open f_xxxx # 系统默认程序打开原件
|
|
180
182
|
```
|
|
181
183
|
|
|
184
|
+
**面板上对应三处**(用户在点,不必 agent 教,但知道行为更省事):
|
|
185
|
+
|
|
186
|
+
- 字段里挑附件:点「从附件库选…」是**可搜索的选择面板**,**未引用的排在前面**,
|
|
187
|
+
点一行即追加到本字段;
|
|
188
|
+
- 附件中心:**「清理未引用(N)」**一键删掉所有没人引用的附件(判据与「未引用」筛选片同一套);
|
|
189
|
+
- 条目表单:**保存按钮固定在右上角**,有改动时顶栏出现「N 处改动未保存 · 保存」,
|
|
190
|
+
长栏目滚到一半也看得见(底部也还有一个)。
|
|
191
|
+
|
|
182
192
|
求职条目里的「简历附件 / 头像图片 / 成绩单 / 证书扫描件」是 `file` 字段,
|
|
183
193
|
它的值是**附件 id 的列表**——一个字段可以挂**多个**附件(简历多版本、头像多张)
|
|
184
194
|
(id 全局唯一 → 各条目互不串台;同内容去重后是同一条,可被多条条目共享):
|
|
@@ -286,7 +296,7 @@ nx-sk setting set --set 'navGroups=[]' # 清空 = 全部栏目恢复平铺
|
|
|
286
296
|
|
|
287
297
|
| ref | 何时读取 |
|
|
288
298
|
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
289
|
-
| `00-design` | 想搞懂**心智模型**时——栏目=结构、条目=数据、内置字段是什么、动态栏目 + 动态条目怎么用、四个聚合点在哪儿、密文边界,以及**东西该往哪儿放**
|
|
299
|
+
| `00-design` | 想搞懂**心智模型**时——栏目=结构、条目=数据、内置字段是什么、动态栏目 + 动态条目怎么用、四个聚合点在哪儿、密文边界,以及**东西该往哪儿放** |
|
|
290
300
|
| `10-sections-entries` | **动手填/改信息时必读**——栏目与条目的完整命令、AI 补全个人信息的推荐流程、字段字典怎么扩、读命令与写命令的参数陷阱 |
|
|
291
301
|
| `20-secrets` | 密文机制、密钥从哪来(环境变量 vs 本机密钥文件)、KV 表的语义、什么时候会解不开 |
|
|
292
302
|
| `30-credentials` | **做凭据自动化时读**——凭据归属哪个栏目、多组账号怎么建清单、跨栏目台账、把凭据渲染成 compose / k8s / .env 的完整流程与安全边界 |
|
|
@@ -30,13 +30,30 @@
|
|
|
30
30
|
| 打开 | `nx-sk file open <id>`(本机默认程序) |
|
|
31
31
|
| 还原 | `nx-sk file restore <id> [--to DIR] [--copy]` |
|
|
32
32
|
| 删除 | `nx-sk file remove <id>` |
|
|
33
|
+
| **清理未引用** | `nx-sk file cleanup [--dry-run]` |
|
|
33
34
|
|
|
34
35
|
- `file list --section <栏目>` 只看「被某栏目引用」的附件;`--section none` 只看**还没有任何条目引用**的。
|
|
35
36
|
- `file content` 是浏览器链接:内联预览,加 `--download` 强制下载;CLI 侧打印物理路径与链接。
|
|
36
37
|
- 所有写命令都支持 `--dry-run`;`file remove` 连物理原件一起删**不可撤销**——
|
|
37
38
|
只想「拿出原件、库里留着」应该用 `file restore --copy`。
|
|
38
39
|
|
|
39
|
-
##
|
|
40
|
+
## 三、一键清理未引用的附件(孤儿附件)
|
|
41
|
+
|
|
42
|
+
附件库是「导入即进库」:批量导入 SOP 截图、临时传一份再删,都会留下**没人引用的原件**——
|
|
43
|
+
既占空间,又在附件中心混在真资料里。逐个删是折磨。
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
nx-sk file cleanup --dry-run # 先看会删哪些(名字 / 体积 / 物理路径)
|
|
47
|
+
nx-sk file cleanup # 真删:物理原件一并删除,不可撤销
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
- 判据与「未引用」筛选片**完全同一套**:没有任何条目的 `file` 字段引用它、正文里也没有引用它的图。
|
|
51
|
+
两套判据会出现「筛选片里看不到、清理却删了」这种吓人的不一致。
|
|
52
|
+
- 被引用的附件**一律不动**;同一个原件可以被多条条目共享,所以「已被引用」不是删除理由。
|
|
53
|
+
- 不可撤销:要留底就先 `nx-sk transfer export`。
|
|
54
|
+
- 面板上:附件中心工具条有「清理未引用(N)」,点了弹确认,报清数量。
|
|
55
|
+
|
|
56
|
+
## 四、导入:默认移动,`--copy` 才复制
|
|
40
57
|
|
|
41
58
|
```bash
|
|
42
59
|
nx-sk file add --path "D:\简历\张三-简历.pdf" # 默认把原件**移动**进库(原件不再留在原地)
|
|
@@ -49,7 +66,7 @@ nx-sk file add --path C:\扫描\成绩单.pdf --tags 成绩单 --note "教务处
|
|
|
49
66
|
- 同 sha256 的文件直接复用已有记录(返回 `duplicated`),不会重复占空间。
|
|
50
67
|
- `file add --dry-run` 只计算落点与校验,不移动、不写台账。
|
|
51
68
|
|
|
52
|
-
##
|
|
69
|
+
## 五、链接:`file` 字段值 = 附件 id 的**列表**(可挂多个)
|
|
53
70
|
|
|
54
71
|
条目**不复制文件**,只存附件 id。把 id 写进栏目里 `file` 类型的字段,链接就建立了;
|
|
55
72
|
**一个字段可以挂多个附件**(简历多个版本、头像多张、证书多份),值是 id 的数组:
|
|
@@ -73,7 +90,7 @@ nx-sk file get f_bbb
|
|
|
73
90
|
- 老数据里存的**单个 id 字符串**原样兼容:读、反查、搜索、面板编辑都按「单元素列表」处理,
|
|
74
91
|
不需要迁移;下一次在面板里编辑该字段保存后,会统一成数组。
|
|
75
92
|
|
|
76
|
-
##
|
|
93
|
+
## 六、还原(可逆)
|
|
77
94
|
|
|
78
95
|
```bash
|
|
79
96
|
nx-sk file restore f_xxxx # 移回**原始位置**并撤销台账(默认)
|
|
@@ -89,7 +106,7 @@ nx-sk file restore f_xxxx --to "D:\资料" # Web 上传件没有原始
|
|
|
89
106
|
- Web 上传进来的附件没有「原始位置」,还原时必须给 `--to`。
|
|
90
107
|
- 还原/删除都不留本地备份;删之前先 `transfer export` 导一份——见 `60-transfer`。
|
|
91
108
|
|
|
92
|
-
##
|
|
109
|
+
## 七、Web 面板
|
|
93
110
|
|
|
94
111
|
`nx-sk serve` → 顶部「附件」页:
|
|
95
112
|
|
|
@@ -99,10 +116,24 @@ nx-sk file restore f_xxxx --to "D:\资料" # Web 上传件没有原始
|
|
|
99
116
|
- **两种引用都算已引用**:`file` 字段里选了本附件,或 `md` 字段正文里写了
|
|
100
117
|
``(外链图片 `https://…` 不算,它本来就不在附件库里)。
|
|
101
118
|
- 编辑弹窗可改名称 / 分类 / 标签 / 备注(改分类或文件名时物理原件随之移动);
|
|
119
|
+
- **「清理未引用(N)」**:一键删掉所有没有任何条目引用的附件(判据与「未引用」筛选片同一套),
|
|
120
|
+
物理原件一并删除,操作前弹确认;
|
|
102
121
|
- 条目表单里的 `file` 字段是**列表**:文件可直接**拖进字段框**(可多选)、点「上传文件」多选、
|
|
103
122
|
从库里逐个追加、逐行移除;保存即建立链接。
|
|
104
123
|
|
|
105
|
-
|
|
124
|
+
### 字段里的附件选择器(可搜索 + 未引用优先)
|
|
125
|
+
|
|
126
|
+
点「从附件库选…」打开的是**选择面板**,不是下拉框:
|
|
127
|
+
|
|
128
|
+
- **可搜索**:按名称 / 备注 / 标签过滤(库里几十个附件时,下拉框根本没法挑);
|
|
129
|
+
- **未引用优先**:没有任何条目引用的原件排在前面(刚导入还没挂上去的就在最上面),
|
|
130
|
+
已被引用的排在后面并标注「已被 N 处引用」——仍然可选(同一原件可被多条条目共享);
|
|
131
|
+
- 点一行即**追加**到本字段,可以连点多个;已挂上的会从列表里消失(它已经在这个字段里了)。
|
|
132
|
+
|
|
133
|
+
> 附件 id 是**全局唯一**的:同一个原件被两个条目挂上,两边指的是同一条记录。
|
|
134
|
+
> 「已被 N 处引用」说的就是这件事,不是「不能选」。
|
|
135
|
+
|
|
136
|
+
## 八、看不到 `file` 上传字段 / 条目里解析不到附件
|
|
106
137
|
|
|
107
138
|
1. **栏目里没有这类字段** —— 栏目字段台账是**持久化数据**:栏目实例化时深拷贝模板字段,
|
|
108
139
|
此后独立演化,模板**后来**新增的字段不会自动回溯到已建栏目。用
|
package/package.json
CHANGED
|
@@ -162,6 +162,19 @@ export default function SectionView({ sectionId }) {
|
|
|
162
162
|
await reload();
|
|
163
163
|
});
|
|
164
164
|
|
|
165
|
+
// 改到一半关标签页 = 改动全丢。浏览器只允许弹一句「确定要离开吗」,
|
|
166
|
+
// 拦不住也改不了文案,但至少别让未保存的改动无声消失。
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
if (!dirty.count) return undefined;
|
|
169
|
+
const onBeforeUnload = (e) => {
|
|
170
|
+
e.preventDefault();
|
|
171
|
+
e.returnValue = '';
|
|
172
|
+
return '';
|
|
173
|
+
};
|
|
174
|
+
window.addEventListener('beforeunload', onBeforeUnload);
|
|
175
|
+
return () => window.removeEventListener('beforeunload', onBeforeUnload);
|
|
176
|
+
}, [dirty.count]);
|
|
177
|
+
|
|
165
178
|
// 「直接存 kv」的入口:敲一行 字段=值 回车即写入,字典里没有的键会自动登记
|
|
166
179
|
const quickAdd = guard(async () => {
|
|
167
180
|
if (!draft) { toast('先新建一条条目', 'bad'); return; }
|
|
@@ -443,7 +456,9 @@ export default function SectionView({ sectionId }) {
|
|
|
443
456
|
<div className="card"><Empty>左边选一条,或新建一条</Empty></div>
|
|
444
457
|
) : (
|
|
445
458
|
<div className="card">
|
|
446
|
-
|
|
459
|
+
{/* 顶栏固定在窗口右上角:长栏目滚到一半也能看见「保存」——
|
|
460
|
+
否则改完一个字段要翻到表单最底点保存,翻页越多越容易漏保存。 */}
|
|
461
|
+
<div className="colhead sticky-head">
|
|
447
462
|
<span
|
|
448
463
|
className="crumb grow nowrap"
|
|
449
464
|
title="点击复制条目定位(栏目›条目 + id + CLI 命令,贴给 agent 即可修改)"
|
|
@@ -456,6 +471,12 @@ export default function SectionView({ sectionId }) {
|
|
|
456
471
|
</span>
|
|
457
472
|
<span className="muted mono">{draft.id}</span>
|
|
458
473
|
{current ? <Tag kind={current.missing.length ? '' : 'strong'}>完整度 {current.completeness.ratio}%</Tag> : null}
|
|
474
|
+
{dirty.count ? (
|
|
475
|
+
<>
|
|
476
|
+
<span className="hint nowrap">{dirty.count} 处改动未保存</span>
|
|
477
|
+
<button type="button" className="btn small" onClick={save} title="保存当前条目的改动">保存</button>
|
|
478
|
+
</>
|
|
479
|
+
) : null}
|
|
459
480
|
</div>
|
|
460
481
|
|
|
461
482
|
<div className="field wide pad" style={{ borderBottom: '1px solid var(--soft-2)' }}>
|
|
@@ -76,6 +76,15 @@ export default {
|
|
|
76
76
|
run: (ctx) => service.removeFile(ctx.ref, ctx),
|
|
77
77
|
render: (d) => service.renderFileChange(d),
|
|
78
78
|
},
|
|
79
|
+
{
|
|
80
|
+
id: 'file.cleanup',
|
|
81
|
+
cli: ['file', 'cleanup'],
|
|
82
|
+
http: ['POST', '/api/files/cleanup'],
|
|
83
|
+
summary: '一键清理**未被任何条目引用**的附件(孤儿附件,物理原件一并删除,不可撤销)',
|
|
84
|
+
flags: { 'dry-run': { type: 'boolean', hint: '只列出会删哪些,不真删' } },
|
|
85
|
+
run: (ctx) => service.cleanupUnreferenced(ctx),
|
|
86
|
+
render: (d) => service.renderFileCleanup(d),
|
|
87
|
+
},
|
|
79
88
|
{
|
|
80
89
|
id: 'file.content',
|
|
81
90
|
cli: ['file', 'content'],
|
|
@@ -177,6 +177,40 @@ export async function removeFile(ref, patch = {}) {
|
|
|
177
177
|
return { status: 'ok', removed: { id: target.id, name: target.name, category: target.category } };
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
/**
|
|
181
|
+
* 一键清理**未被任何条目引用**的附件(孤儿附件)。
|
|
182
|
+
*
|
|
183
|
+
* 判据与「附件中心」的「未引用」筛选片**完全同一套**(`fileReferences` 为空):
|
|
184
|
+
* 没有任何条目的 file 字段引用它、正文里也没有引用它的图。
|
|
185
|
+
*
|
|
186
|
+
* 为什么值得一键做:附件库是「导入即进库」,批量导入 SOP 截图、临时传一份再删
|
|
187
|
+
* 都会留下孤儿——它们既占空间,又在附件中心混在真资料里。逐个删是折磨。
|
|
188
|
+
*
|
|
189
|
+
* 不可撤销(物理原件一起删),所以:
|
|
190
|
+
* - 面板侧先弹确认,列出将删的数量与体积;
|
|
191
|
+
* - CLI 侧有 --dry-run,先看清单再动手。
|
|
192
|
+
*/
|
|
193
|
+
export async function cleanupUnreferenced(patch = {}) {
|
|
194
|
+
const dryRun = patch['dry-run'];
|
|
195
|
+
const store = await loadStore();
|
|
196
|
+
const orphans = store.files.filter((rec) => !fileReferences(store, rec).length);
|
|
197
|
+
if (!orphans.length) {
|
|
198
|
+
// 已经是期望状态(没有孤儿)→ skipped,不是失败
|
|
199
|
+
return { status: 'skipped', count: 0, removed: [], reason: '没有未引用的附件' };
|
|
200
|
+
}
|
|
201
|
+
const brief = orphans.map((r) => ({
|
|
202
|
+
id: r.id, name: r.name, category: r.category, bytes: r.bytes, path: displayPath(fileRecordAbs(r)),
|
|
203
|
+
}));
|
|
204
|
+
if (dryRun) {
|
|
205
|
+
return { status: 'skipped', dryRun: true, count: orphans.length, wouldRemove: brief };
|
|
206
|
+
}
|
|
207
|
+
// 先删物理文件(失败即抛,台账不动)——不能出现「台账没了、文件还在」的中间态
|
|
208
|
+
for (const rec of orphans) await deletePhysical(rec);
|
|
209
|
+
const ids = new Set(orphans.map((r) => r.id));
|
|
210
|
+
await mutateStore((s) => { s.files = s.files.filter((x) => !ids.has(x.id)); });
|
|
211
|
+
return { status: 'ok', count: orphans.length, removed: brief };
|
|
212
|
+
}
|
|
213
|
+
|
|
180
214
|
/** CLI 的 file content:给出物理路径与链接(不直接吐二进制,终端里没法看)。 */
|
|
181
215
|
export async function contentInfo(ref, { download } = {}) {
|
|
182
216
|
const store = await loadStore();
|
|
@@ -271,6 +305,19 @@ export function renderFileChange(d) {
|
|
|
271
305
|
return `已更新附件 ${d.name}(${d.id})`;
|
|
272
306
|
}
|
|
273
307
|
|
|
308
|
+
export function renderFileCleanup(d) {
|
|
309
|
+
if (d.dryRun) {
|
|
310
|
+
const lines = [`试运行(未落盘):将清理 ${d.count} 个未引用的附件`];
|
|
311
|
+
for (const r of d.wouldRemove || []) lines.push(` ${r.id} ${r.name} ${fmtBytes(r.bytes)}`);
|
|
312
|
+
lines.push('确认无误后去掉 --dry-run 即可执行。');
|
|
313
|
+
return lines.join('\n');
|
|
314
|
+
}
|
|
315
|
+
if (d.status === 'skipped') return `没有未引用的附件,无需清理`;
|
|
316
|
+
const lines = [`已清理 ${d.count} 个未引用的附件(物理原件一并删除,不可撤销)`];
|
|
317
|
+
for (const r of d.removed || []) lines.push(` ${r.id} ${r.name} ${fmtBytes(r.bytes)}`);
|
|
318
|
+
return lines.join('\n');
|
|
319
|
+
}
|
|
320
|
+
|
|
274
321
|
export function renderContent(d) {
|
|
275
322
|
return `${d.name}(${d.id})\n路径: ${d.displayPath}\n浏览器链接: ${d.url}${d.download ? d.downloadUrl : ''}`;
|
|
276
323
|
}
|
|
@@ -174,6 +174,30 @@ export default function FilesView() {
|
|
|
174
174
|
await reload();
|
|
175
175
|
});
|
|
176
176
|
|
|
177
|
+
/**
|
|
178
|
+
* 一键清理**未引用**的附件(孤儿附件)。
|
|
179
|
+
*
|
|
180
|
+
* 判据与「未引用」筛选片同一套:没有任何条目的 file 字段引用它、正文里也没有引用它的图。
|
|
181
|
+
* 逐个删是折磨 —— 批量导入 SOP 截图、临时传一份再删,都会留下这种孤儿。
|
|
182
|
+
* 物理原件一起删、不可撤销,所以先弹确认并说清数量。
|
|
183
|
+
*/
|
|
184
|
+
const cleanup = guard(async () => {
|
|
185
|
+
const n = data?.unlinked || 0;
|
|
186
|
+
if (!n) return;
|
|
187
|
+
const ok = await dialog({
|
|
188
|
+
message: `清理 ${n} 个未被任何条目引用的附件?`,
|
|
189
|
+
detail: '物理原件会一起删除,不可撤销。\n想留底就先在「导出导入」页导一份。',
|
|
190
|
+
danger: true,
|
|
191
|
+
confirmText: '清理',
|
|
192
|
+
});
|
|
193
|
+
if (!ok) return;
|
|
194
|
+
const r = await api('/api/files/cleanup', { method: 'POST', body: {} });
|
|
195
|
+
toast(r.count ? `已清理 ${r.count} 个未引用的附件` : '没有未引用的附件');
|
|
196
|
+
await refreshAttachments();
|
|
197
|
+
await load();
|
|
198
|
+
await reload();
|
|
199
|
+
});
|
|
200
|
+
|
|
177
201
|
if (err) {
|
|
178
202
|
return <div className="pad stack"><div className="bad"><strong>附件库读不到</strong></div><pre className="block">{err}</pre></div>;
|
|
179
203
|
}
|
|
@@ -206,6 +230,14 @@ export default function FilesView() {
|
|
|
206
230
|
onChange={(e) => setQ(e.target.value)}
|
|
207
231
|
/>
|
|
208
232
|
<button className="btn small ghost" onClick={load}>刷新</button>
|
|
233
|
+
<button
|
|
234
|
+
className="btn small ghost danger-ghost"
|
|
235
|
+
disabled={!data.unlinked}
|
|
236
|
+
onClick={cleanup}
|
|
237
|
+
title="删除所有没有任何条目引用的附件(物理原件一并删除,不可撤销)"
|
|
238
|
+
>
|
|
239
|
+
清理未引用{data.unlinked ? `(${data.unlinked})` : ''}
|
|
240
|
+
</button>
|
|
209
241
|
<button className="btn small ghost" onClick={openPathImport}>本机路径导入</button>
|
|
210
242
|
<button className="btn small" onClick={() => pickerRef.current?.click()}>上传文件</button>
|
|
211
243
|
<input
|
|
@@ -84,9 +84,13 @@ export function FileFieldControl({ def, value, onChange }) {
|
|
|
84
84
|
// 附件多的时候不把表单撑长:默认只露 5 行,窗口内上下滚动;需要看全时一键展开
|
|
85
85
|
const [expanded, setExpanded] = useState(false);
|
|
86
86
|
const uploadRef = useRef(null);
|
|
87
|
+
// 附件库选择器:默认收起,点开才搜索 / 挑选(表单是给人填的,不是给人翻附件库的)
|
|
88
|
+
const [pickerOpen, setPickerOpen] = useState(false);
|
|
89
|
+
const [pickQuery, setPickQuery] = useState('');
|
|
87
90
|
// dragenter/dragleave 在子元素间穿梭时会成对触发,用深度计数避免「还没离开就熄掉高亮」
|
|
88
91
|
const dragDepth = useRef(0);
|
|
89
92
|
const ids = toIds(value);
|
|
93
|
+
const idsKey = ids.join(',');
|
|
90
94
|
const max = Number(def.maxItems) || 0;
|
|
91
95
|
const full = max > 0 && ids.length >= max;
|
|
92
96
|
|
|
@@ -97,9 +101,37 @@ export function FileFieldControl({ def, value, onChange }) {
|
|
|
97
101
|
}, []);
|
|
98
102
|
useEffect(() => { load(true); }, [load]);
|
|
99
103
|
|
|
100
|
-
const all = data?.files || [];
|
|
104
|
+
const all = useMemo(() => data?.files || [], [data]);
|
|
101
105
|
const byId = (id) => all.find((f) => f.id === id) || null;
|
|
102
|
-
|
|
106
|
+
// 本字段还没挂的附件
|
|
107
|
+
const available = useMemo(
|
|
108
|
+
() => all.filter((f) => !idsKey.split(',').includes(f.id)),
|
|
109
|
+
[all, idsKey],
|
|
110
|
+
);
|
|
111
|
+
// **未引用优先**:附件中心刚导入、还没挂到任何条目的原件排在前面。
|
|
112
|
+
// 理由:选附件时最常要的正是「刚传的那几个」;已被别处引用的往后排,
|
|
113
|
+
// 仍然可选(同一个原件可以被多条条目共享)。
|
|
114
|
+
const ordered = useMemo(() => {
|
|
115
|
+
const free = [];
|
|
116
|
+
const used = [];
|
|
117
|
+
for (const f of available) (f.usedBy?.length ? used : free).push(f);
|
|
118
|
+
return [...free, ...used];
|
|
119
|
+
}, [available]);
|
|
120
|
+
// 关键字搜索:名称 / 备注 / 标签
|
|
121
|
+
const matched = useMemo(() => {
|
|
122
|
+
const q = pickQuery.trim().toLowerCase();
|
|
123
|
+
if (!q) return ordered;
|
|
124
|
+
return ordered.filter((f) => [f.name, f.note, ...(f.tags || [])]
|
|
125
|
+
.join('\n').toLowerCase().includes(q));
|
|
126
|
+
}, [ordered, pickQuery]);
|
|
127
|
+
|
|
128
|
+
// Esc 关掉选择器
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (!pickerOpen) return undefined;
|
|
131
|
+
const onKey = (e) => { if (e.key === 'Escape') setPickerOpen(false); };
|
|
132
|
+
window.addEventListener('keydown', onKey);
|
|
133
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
134
|
+
}, [pickerOpen]);
|
|
103
135
|
|
|
104
136
|
const upload = guard(async (fileList) => {
|
|
105
137
|
const picked = [...(fileList || [])];
|
|
@@ -175,10 +207,15 @@ export function FileFieldControl({ def, value, onChange }) {
|
|
|
175
207
|
</div>
|
|
176
208
|
) : null}
|
|
177
209
|
<div className="file-pick-row">
|
|
178
|
-
<
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
210
|
+
<button
|
|
211
|
+
type="button"
|
|
212
|
+
className="btn small"
|
|
213
|
+
disabled={full || !available.length}
|
|
214
|
+
onClick={() => setPickerOpen((v) => !v)}
|
|
215
|
+
title="从附件库挑附件(可搜索;未引用的排在前面)"
|
|
216
|
+
>
|
|
217
|
+
从附件库选…
|
|
218
|
+
</button>
|
|
182
219
|
<button type="button" className="btn small" disabled={full} onClick={() => uploadRef.current?.click()}>上传文件</button>
|
|
183
220
|
<button type="button" className="btn small ghost" onClick={() => { patchUi({ view: 'files' }); window.location.hash = '#/files'; }}>附件中心</button>
|
|
184
221
|
<input
|
|
@@ -189,6 +226,36 @@ export function FileFieldControl({ def, value, onChange }) {
|
|
|
189
226
|
onChange={(e) => { upload(e.target.files); e.target.value = ''; }}
|
|
190
227
|
/>
|
|
191
228
|
</div>
|
|
229
|
+
{pickerOpen ? (
|
|
230
|
+
<div className="file-pick-panel">
|
|
231
|
+
<div className="file-pick-search">
|
|
232
|
+
<input
|
|
233
|
+
autoFocus
|
|
234
|
+
value={pickQuery}
|
|
235
|
+
placeholder="按名称 / 备注 / 标签搜索附件"
|
|
236
|
+
onChange={(e) => setPickQuery(e.target.value)}
|
|
237
|
+
/>
|
|
238
|
+
<span className="hint nowrap">{matched.length} / {available.length}</span>
|
|
239
|
+
<button type="button" className="btn small ghost" onClick={() => setPickerOpen(false)}>关闭</button>
|
|
240
|
+
</div>
|
|
241
|
+
{matched.length ? (
|
|
242
|
+
<div className="file-pick-list">
|
|
243
|
+
{matched.map((f) => (
|
|
244
|
+
<button type="button" key={f.id} className="file-pick-item" onClick={() => add(f.id)} title="点击追加到本字段">
|
|
245
|
+
{f.isImage
|
|
246
|
+
? <img src={f.url} alt="" />
|
|
247
|
+
: <span className="ext-badge" style={{ fontSize: 12, width: 34, textAlign: 'center' }}>{f.ext ? f.ext.slice(1).toUpperCase() : 'FILE'}</span>}
|
|
248
|
+
<span className="file-pick-name grow nowrap">{f.name}</span>
|
|
249
|
+
<span className="hint nowrap">{f.usedBy?.length ? `已被 ${f.usedBy.length} 处引用` : '未引用'}</span>
|
|
250
|
+
</button>
|
|
251
|
+
))}
|
|
252
|
+
</div>
|
|
253
|
+
) : (
|
|
254
|
+
<div className="hint pad">没有匹配的附件。换个关键字,或先「上传文件」。</div>
|
|
255
|
+
)}
|
|
256
|
+
<div className="hint pad">未引用的排在前面;点一行即追加到本字段(可连续点多个)。</div>
|
|
257
|
+
</div>
|
|
258
|
+
) : null}
|
|
192
259
|
{full ? <span className="hint">已达上限({max} 个):先移除一个,或把字段的 maxItems 调大。</span> : null}
|
|
193
260
|
{def.hint ? <span className="hint">{def.hint}</span> : null}
|
|
194
261
|
</div>
|
|
@@ -423,7 +423,6 @@ a.file-name:hover { text-decoration: underline; }
|
|
|
423
423
|
/* 拖文件悬停在字段框上:与「附件」中心同一套视觉语言(虚线描边 + 浅底) */
|
|
424
424
|
.file-picker.drag { outline: 2px dashed var(--ink); outline-offset: -4px; background: var(--soft); }
|
|
425
425
|
.file-picker .file-pick-row { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
|
426
|
-
.file-picker select { flex: 1; min-width: 120px; }
|
|
427
426
|
.file-preview {
|
|
428
427
|
display: flex; align-items: center; gap: 8px;
|
|
429
428
|
border: var(--border); border-radius: 6px; padding: 4px 8px;
|
|
@@ -436,6 +435,54 @@ a.file-name:hover { text-decoration: underline; }
|
|
|
436
435
|
}
|
|
437
436
|
.file-preview .file-preview-name:hover { text-decoration: underline; }
|
|
438
437
|
|
|
438
|
+
/* 附件库选择器:可搜索 + 未引用优先(不是原生下拉——库里几十个附件时没法挑) */
|
|
439
|
+
.file-pick-panel {
|
|
440
|
+
border: var(--border);
|
|
441
|
+
border-radius: 8px;
|
|
442
|
+
background: var(--paper);
|
|
443
|
+
overflow: hidden;
|
|
444
|
+
}
|
|
445
|
+
.file-pick-search {
|
|
446
|
+
display: flex; align-items: center; gap: 6px;
|
|
447
|
+
padding: 6px 8px;
|
|
448
|
+
border-bottom: var(--border);
|
|
449
|
+
background: var(--soft);
|
|
450
|
+
}
|
|
451
|
+
.file-pick-search input { flex: 1; min-width: 0; }
|
|
452
|
+
.file-pick-list { max-height: 260px; overflow: auto; }
|
|
453
|
+
.file-pick-item {
|
|
454
|
+
display: flex; align-items: center; gap: 8px;
|
|
455
|
+
width: 100%;
|
|
456
|
+
padding: 4px 8px;
|
|
457
|
+
border: 0;
|
|
458
|
+
border-bottom: var(--border);
|
|
459
|
+
background: var(--paper);
|
|
460
|
+
color: var(--ink);
|
|
461
|
+
font: inherit;
|
|
462
|
+
font-size: var(--fs-core);
|
|
463
|
+
text-align: left;
|
|
464
|
+
cursor: pointer;
|
|
465
|
+
}
|
|
466
|
+
.file-pick-item:last-child { border-bottom: 0; }
|
|
467
|
+
.file-pick-item:hover { background: var(--soft); }
|
|
468
|
+
.file-pick-item img {
|
|
469
|
+
width: 28px; height: 28px; object-fit: cover;
|
|
470
|
+
border-radius: 4px; border: var(--border); background: #fff;
|
|
471
|
+
}
|
|
472
|
+
.file-pick-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
473
|
+
|
|
474
|
+
/* 条目表单顶栏固定:长栏目滚到一半也看得见「保存」 */
|
|
475
|
+
.card > .colhead.sticky-head {
|
|
476
|
+
position: sticky;
|
|
477
|
+
top: 0;
|
|
478
|
+
z-index: 5;
|
|
479
|
+
height: auto;
|
|
480
|
+
min-height: var(--row-h);
|
|
481
|
+
padding: 4px 14px;
|
|
482
|
+
background: var(--paper);
|
|
483
|
+
box-shadow: 0 1px 0 var(--soft-2);
|
|
484
|
+
}
|
|
485
|
+
|
|
439
486
|
/* ── 栏目管理(设置页):拖拽编排 + 显隐 ─────────────────────
|
|
440
487
|
结构用版面表达、改结构用拖拽表达——所以这里只有把手、输入框与两个动作,
|
|
441
488
|
没有一排 上移/下移/移除 按钮(按钮越多越看不出「现在是什么结构」)。 */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{a as l}from"./index-
|
|
1
|
+
import{a as l}from"./index-BhVJGdcz.js";let n=null,o=0;const u=1500;function c(t=!1){return!t&&n&&Date.now()-o<u||(o=Date.now(),n=l("/api/files").then(e=>e).catch(e=>{throw n=null,e})),n}function d(){return n=null,c(!0)}function h(t,e){const a=Array.isArray(t)?t:[],r=(Array.isArray(e)?e:[]).filter(Boolean);if(!r.length)return a;const s=new Set(r.map(i=>i.id));return[...r,...a.filter(i=>!s.has(i.id))]}function m(t){return new Promise((e,a)=>{const r=new FileReader;r.onload=()=>e(r.result),r.onerror=()=>a(new Error("读取文件失败")),r.readAsDataURL(t)})}export{m as f,c as l,h as m,d as r};
|