nx-sk 0.7.1 → 0.8.1
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 +15 -0
- package/README.md +7 -5
- package/assets/nx-sk/SKILL.md +16 -10
- package/assets/nx-sk/references/10-sections-entries.md +9 -5
- package/assets/nx-sk/references/40-files.md +17 -11
- package/package.json +1 -1
- package/src/core/attachments.js +26 -5
- package/src/core/fields.js +5 -5
- package/src/modules/entries/service.js +57 -22
- package/src/web/frontend/components/fieldEditor.jsx +93 -28
- package/src/web/frontend/styles.css +2 -0
- package/src/web/public/assets/{files-2kd_BT_X.js → files-DMIKmyJN.js} +1 -1
- package/src/web/public/assets/{index-Dzp1e1hp.js → index-DpX9p_OX.js} +2 -2
- package/src/web/public/assets/{index-DLtjhflt.css → index-lfnBM8pt.css} +1 -1
- package/src/web/public/assets/{view-CmCEE0EV.js → view-Bh_6V4-F.js} +1 -1
- package/src/web/public/assets/{view-C4H5NgGI.js → view-CDaAGMaO.js} +1 -1
- package/src/web/public/assets/{view-CVOS0EBs.js → view-DCR3mXQP.js} +1 -1
- package/src/web/public/assets/{view-DRj3unSu.js → view-DTFmAEtL.js} +1 -1
- package/src/web/public/assets/view-qNmLIhoy.js +7 -0
- package/src/web/public/assets/{view-D7cRKEwG.js → view-uH2FjbJC.js} +1 -1
- package/src/web/public/index.html +2 -2
- package/src/web/public/assets/view-qZDNlGNu.js +0 -7
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
本文件格式参考 [Keep a Changelog](https://keepachangelog.com/),中文正文。
|
|
4
4
|
|
|
5
|
+
## [0.8.1] - 2026-10-15
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **条目里的附件字段框支持拖拽上传**:把文件(可多个)直接拖进 `file` 字段框即上传并追加到列表,悬停时虚线描边提示(与「附件」中心同一套视觉语言);超过 `maxItems` 上限时拖入会明确提示。之前只有「附件」中心页支持拖拽。
|
|
10
|
+
|
|
11
|
+
## [0.8.0] - 2026-10-13
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`file` 字段支持多附件:值 = 附件 id 的列表**。一个字段可以挂**多个**附件(简历多版本、头像多张、证书多份)——CLI 逗号分隔(`--set 简历附件=f_aaa,f_bbb`)或 `--data` 传 JSON 数组;面板字段控件改成**列表**:一次**多选**上传、从附件库逐个追加、逐行移除(下载 / 复制路径照旧)。
|
|
16
|
+
- **新写入统一存数组**(哪怕只有一个)——`entry get --json` 与导出里 `file` 字段的值形状一致;**历史单值字符串原样兼容**(读、反查、搜索、面板编辑都按「单元素列表」处理),不做数据迁移,下次在面板编辑保存后统一成数组。
|
|
17
|
+
- 反查 / 搜索 / 渲染全部按列表走:列表里的**任一**附件都能被 `file get` 反查到「被引用」;按**任一**附件名都能搜到条目;`entry get` 里多个附件各占一行(附件名 + 物理路径)。
|
|
18
|
+
- 数量上限复用 `maxItems`(与 `tags` 同一套属性与报错口径,默认不限);未登记的键写入 `f_` 形态的值会自动登记成**附件字段**(而不是文本 / tags)。
|
|
19
|
+
|
|
5
20
|
## [0.7.1] - 2026-10-13
|
|
6
21
|
|
|
7
22
|
### Changed
|
package/README.md
CHANGED
|
@@ -173,15 +173,17 @@ nx-sk file restore f_xxxx # 可逆:移回原始位置
|
|
|
173
173
|
nx-sk file restore f_xxxx --to "D:\资料" --copy # 或导出副本到指定目录
|
|
174
174
|
```
|
|
175
175
|
|
|
176
|
-
**附件怎么被条目引用**:条目的 `file`
|
|
177
|
-
|
|
176
|
+
**附件怎么被条目引用**:条目的 `file` 字段存的是**附件 id 的列表**——一个字段可以挂**多个**
|
|
177
|
+
附件(简历多版本、头像多张)。id 全局唯一,所以每条条目各有各的附件、互不串台;
|
|
178
|
+
同一份原件也能被多条条目引用(sha256 去重后是同一条)。
|
|
178
179
|
**上传 / 选择发生在栏目条目的字段里**(求职 → 张三 → 「简历附件」直接上传或从库里选),
|
|
179
180
|
不在附件中心做指派:
|
|
180
181
|
|
|
181
182
|
```bash
|
|
182
183
|
FID=$(nx-sk file add --path "D:\简历\张三-简历.pdf" --json | 取 id)
|
|
183
|
-
nx-sk entry update 张三 --set resumeFile=$FID
|
|
184
|
-
nx-sk entry
|
|
184
|
+
nx-sk entry update 张三 --set resumeFile=$FID # 挂一个(存成单元素数组)
|
|
185
|
+
nx-sk entry update 张三 --set resumeFile=$FID,f_xxx # 逗号分隔挂多个
|
|
186
|
+
nx-sk entry get 张三 --section job # 每个附件各占一行:附件名 + 物理路径
|
|
185
187
|
nx-sk file get $FID # 反查「被引用:求职 / 张三 / 简历附件」
|
|
186
188
|
```
|
|
187
189
|
|
|
@@ -265,7 +267,7 @@ nx-sk skill get nx-sk 10-sections-entries # 裸名 → references/10-sections-
|
|
|
265
267
|
## 开发
|
|
266
268
|
|
|
267
269
|
```bash
|
|
268
|
-
pnpm test # = lint + build + 冒烟(
|
|
270
|
+
pnpm test # = lint + build + 冒烟(104 项断言) + 单测(132 项)
|
|
269
271
|
```
|
|
270
272
|
|
|
271
273
|
| 目录 | 职责 |
|
package/assets/nx-sk/SKILL.md
CHANGED
|
@@ -16,12 +16,16 @@ description: 本机个人资源管理器——栏目化存储个人信息(求
|
|
|
16
16
|
|
|
17
17
|
## 核心不变量
|
|
18
18
|
|
|
19
|
-
1.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
1. **结构归栏目,值归条目:栏目持有「固定的结构」,条目只装「值」。**
|
|
20
|
+
每个栏目持有一份**字段台账**(分组 + 字段:key / 中文标签 / 类型 / 是否密文 / 填写策略),
|
|
21
|
+
它是**数据不是代码**、随栏目存在 store 里,所以 agent 与面板看到的字段永远一致——
|
|
22
|
+
「这个栏目有哪些字段」永远只有一个答案,在栏目里;条目里的每一把键都能在那儿找到。
|
|
23
|
+
**台账不拦写入**:`--set 任意键=值` 一律能存,没见过的键会**自动登记**进台账
|
|
24
|
+
(键名就是它本身,中文也可以)——写新字段不必先改 schema,但**必须登记**:
|
|
25
|
+
没进台账的值,面板按台账渲染时**看不见**(像「存了却没生效」)。
|
|
26
|
+
台账的用处是「模板建议 + 你实际写过什么」,不是门禁。
|
|
27
|
+
*违反会怎样*:反过来把它做成门禁,用户每换一个表单就要先改一次 schema ——
|
|
28
|
+
而那正是「只想存个 kv」时最不想付的成本;而不登记就往条目里塞值,存了也看不见。
|
|
25
29
|
|
|
26
30
|
2. **一条 action 同时声明 CLI 与 HTTP。** 面板上能做的,CLI 都能做;两边参数名、错误码一致。
|
|
27
31
|
*违反会怎样*:只能靠字符串匹配猜错误,无法可靠分支。
|
|
@@ -77,12 +81,14 @@ nx-sk file open f_xxxx # 系统默认程序打开原件
|
|
|
77
81
|
```
|
|
78
82
|
|
|
79
83
|
求职条目里的「简历附件 / 头像图片 / 成绩单 / 证书扫描件」是 `file` 字段,
|
|
80
|
-
|
|
84
|
+
它的值是**附件 id 的列表**——一个字段可以挂**多个**附件(简历多版本、头像多张)
|
|
85
|
+
(id 全局唯一 → 各条目互不串台;同内容去重后是同一条,可被多条条目共享):
|
|
81
86
|
|
|
82
87
|
```bash
|
|
83
|
-
nx-sk entry update 张三 --set resumeFile=
|
|
84
|
-
nx-sk entry
|
|
85
|
-
nx-sk
|
|
88
|
+
nx-sk entry update 张三 --set resumeFile=f_aaa,f_bbb # 逗号分隔挂多个(也接受 --data 传数组)
|
|
89
|
+
nx-sk entry update 张三 --set resumeFile=f_aaa # 只挂一个(存下来也是单元素数组)
|
|
90
|
+
nx-sk entry get 张三 --section job # 每个附件各占一行:附件名 + 物理路径
|
|
91
|
+
nx-sk file get f_bbb # 反查「被引用:求职 / 张三 / 简历附件」
|
|
86
92
|
```
|
|
87
93
|
|
|
88
94
|
**上传 / 选择发生在栏目条目里**(Web 的表单字段可直接「上传文件」或从库里选);
|
|
@@ -11,8 +11,12 @@ store.json(~/nx-sk/store.json 或 $NX_SK_STORE)
|
|
|
11
11
|
└─ entries[] 条目:id(e_xxx) / section / title / tags[] / values{字段key: 值} / 时间戳
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
-
|
|
15
|
-
|
|
14
|
+
- **结构与值分家**:栏目持有**结构**(`groups[]` 分组 + `fields[]` 字段定义),条目只持有**值**
|
|
15
|
+
(`values{字段 key: 值}`)。所以「这个栏目有哪些字段」永远只有一个答案,在栏目里;
|
|
16
|
+
条目里的每一把键都能在那儿找到。
|
|
17
|
+
- 一个**栏目** = 一组同构条目 + 一份字段字典(**固定的结构**)。
|
|
18
|
+
- 一个**条目** = 一行数据,值放在 `values` 里,键是字段 `key`;写台账里没有的键会**自动登记**
|
|
19
|
+
(见第五节)——**登记过的键面板才渲染得出来**,没登记的值在界面上看不见。
|
|
16
20
|
- 定位方式都放宽:栏目与条目**都接受 id 或名字**。条目名重名时报 `CONFLICT` 让你改用 id。
|
|
17
21
|
|
|
18
22
|
## 二、栏目命令(集合资源,五操作齐备)
|
|
@@ -116,11 +120,11 @@ nx-sk section update job --fields @fields.json
|
|
|
116
120
|
```
|
|
117
121
|
|
|
118
122
|
字段定义支持:`key`(字母开头)、`label`、`type`(`text|textarea|number|date|month|bool|select|tags|secret|file`)、
|
|
119
|
-
`group`、`hint`、`options`(候选项)、`sensitive`(true 则密文落盘)、`maxItems`(tags 上限)、
|
|
123
|
+
`group`、`hint`、`options`(候选项)、`sensitive`(true 则密文落盘)、`maxItems`(tags / file 上限)、
|
|
120
124
|
`fill`(填写策略,见下)。
|
|
121
125
|
|
|
122
|
-
> `type: 'file'`
|
|
123
|
-
>
|
|
126
|
+
> `type: 'file'` 是**附件字段**:值 = 附件 id 的**列表**(一个字段可挂多个;不是路径),
|
|
127
|
+
> 导入原件、附件库台账、以及「文件统一放 `~/nx-sk/files/`」的完整口径见 `40-files`。
|
|
124
128
|
|
|
125
129
|
> `select` 的 `options` 是**候选项不是白名单**:真实值(如某个少数民族)不在候选里也允许写入。
|
|
126
130
|
|
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
```
|
|
10
10
|
~/nx-sk/files/<分类>/ 二进制原件(真实文件,移动/复制进来的)
|
|
11
11
|
~/nx-sk/store.json 的 files[] 台账:id / name / rel / ext / bytes / sha256 / 标签 / 备注
|
|
12
|
-
~/nx-sk/store.json 的 entries[].values[<file字段>] = f_xxxx ←
|
|
12
|
+
~/nx-sk/store.json 的 entries[].values[<file字段>] = [f_xxxx, …] ← 链接:字段值 = 附件 id 列表(可挂多个)
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
- 一个**附件** = 一次导入的原件 + 一条台账记录。物理文件永远以台账 `rel` 为准。
|
|
16
|
-
- 条目里的 `file` 字段(如 `resumeFile` / `avatar`),**值就是附件 id
|
|
17
|
-
id 全局唯一 → 各条目的附件空间天然隔离;同一个原件(sha256 相同)**去重**后是同一条,
|
|
16
|
+
- 条目里的 `file` 字段(如 `resumeFile` / `avatar`),**值就是附件 id 的列表**(形如 `[f_xxxx, …]`;
|
|
17
|
+
一个字段可挂多个)。id 全局唯一 → 各条目的附件空间天然隔离;同一个原件(sha256 相同)**去重**后是同一条,
|
|
18
18
|
可被多条条目共享,不重复占空间。
|
|
19
19
|
- **上传 / 选择发生在栏目条目里**;「附件」中心只负责统一展示与库存管理,不做指派。
|
|
20
20
|
|
|
@@ -49,24 +49,29 @@ nx-sk file add --path C:\扫描\成绩单.pdf --tags 成绩单 --note "教务处
|
|
|
49
49
|
- 同 sha256 的文件直接复用已有记录(返回 `duplicated`),不会重复占空间。
|
|
50
50
|
- `file add --dry-run` 只计算落点与校验,不移动、不写台账。
|
|
51
51
|
|
|
52
|
-
## 四、链接:`file` 字段值 = 附件 id
|
|
52
|
+
## 四、链接:`file` 字段值 = 附件 id 的**列表**(可挂多个)
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
条目**不复制文件**,只存附件 id。把 id 写进栏目里 `file` 类型的字段,链接就建立了;
|
|
55
|
+
**一个字段可以挂多个附件**(简历多个版本、头像多张、证书多份),值是 id 的数组:
|
|
55
56
|
|
|
56
57
|
```bash
|
|
57
58
|
# 先拿到附件的 id(file add 的输出 / file list --json 里的 files[].id)
|
|
58
59
|
nx-sk file add --path "D:\简历\张三-简历.pdf" --category resume
|
|
59
60
|
|
|
60
|
-
nx-sk entry update 张三 --set resumeFile=
|
|
61
|
-
nx-sk entry
|
|
61
|
+
nx-sk entry update 张三 --set resumeFile=f_aaa,f_bbb # 逗号分隔 = 挂两个(也可用 --data 传 JSON 数组)
|
|
62
|
+
nx-sk entry update 张三 --set resumeFile=f_aaa # 只挂一个(存下来也是单元素数组)
|
|
63
|
+
nx-sk entry get 张三 --section job # 每个附件各占一行:附件名 + 物理路径
|
|
62
64
|
|
|
63
65
|
# 反查:这个附件被哪些栏目 / 条目 / 字段引用
|
|
64
|
-
nx-sk file get
|
|
66
|
+
nx-sk file get f_bbb
|
|
65
67
|
# 被引用: 求职 / 张三 / 简历附件
|
|
66
68
|
```
|
|
67
69
|
|
|
68
|
-
求职条目里的「简历附件 / 头像图片 / 成绩单 / 证书扫描件」就是 `file` 字段。
|
|
69
|
-
**别用文件名或路径去填** —— 值只认附件 id;填了别的东西条目侧会显示成「未解析」。
|
|
70
|
+
- 求职条目里的「简历附件 / 头像图片 / 成绩单 / 证书扫描件」就是 `file` 字段。
|
|
71
|
+
- **别用文件名或路径去填** —— 值只认附件 id;填了别的东西条目侧会显示成「未解析」。
|
|
72
|
+
- 需要限制个数时给字段配 `maxItems`(与 `tags` 同一套;不配 = 不限)。
|
|
73
|
+
- 老数据里存的**单个 id 字符串**原样兼容:读、反查、搜索、面板编辑都按「单元素列表」处理,
|
|
74
|
+
不需要迁移;下一次在面板里编辑该字段保存后,会统一成数组。
|
|
70
75
|
|
|
71
76
|
## 五、还原(可逆)
|
|
72
77
|
|
|
@@ -92,7 +97,8 @@ nx-sk file restore f_xxxx --to "D:\资料" # Web 上传件没有原始
|
|
|
92
97
|
- **组织方式由栏目驱动、不预定义分类**:筛选片按「哪些栏目在引用附件」动态生成(外加「未引用」),
|
|
93
98
|
每张卡片显示被哪个栏目 / 条目 / 字段引用;
|
|
94
99
|
- 编辑弹窗可改名称 / 分类 / 标签 / 备注(改分类或文件名时物理原件随之移动);
|
|
95
|
-
- 条目表单里的 `file`
|
|
100
|
+
- 条目表单里的 `file` 字段是**列表**:文件可直接**拖进字段框**(可多选)、点「上传文件」多选、
|
|
101
|
+
从库里逐个追加、逐行移除;保存即建立链接。
|
|
96
102
|
|
|
97
103
|
## 七、看不到 `file` 上传字段 / 条目里解析不到附件
|
|
98
104
|
|
package/package.json
CHANGED
package/src/core/attachments.js
CHANGED
|
@@ -127,6 +127,26 @@ export function normalizeFileRecord(raw) {
|
|
|
127
127
|
};
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/**
|
|
131
|
+
* 一个 `file` 字段的值 → 附件 id 数组(**全仓库唯一的归一化入口**)。
|
|
132
|
+
*
|
|
133
|
+
* 字段可以挂**多个**附件:新写入统一存数组(哪怕只有一个);历史数据里的单值是
|
|
134
|
+
* 字符串,读的时候按「单元素列表」看待——所以这里两种情况都要吃。
|
|
135
|
+
* 去空、去重、保序:同一个附件在一个字段里出现两次没有意义。
|
|
136
|
+
*/
|
|
137
|
+
export function fileIdsOf(value) {
|
|
138
|
+
const raw = value === null || value === undefined
|
|
139
|
+
? []
|
|
140
|
+
: (Array.isArray(value) ? value : [value]);
|
|
141
|
+
const out = [];
|
|
142
|
+
for (const x of raw) {
|
|
143
|
+
const s = String(x ?? '').trim();
|
|
144
|
+
if (!s || out.includes(s)) continue;
|
|
145
|
+
out.push(s);
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
130
150
|
/** 附件定位:id 优先,其次唯一文件名。 */
|
|
131
151
|
export function findFileRecord(store, ref) {
|
|
132
152
|
const key = String(ref ?? '').trim();
|
|
@@ -140,8 +160,9 @@ export function findFileRecord(store, ref) {
|
|
|
140
160
|
|
|
141
161
|
/**
|
|
142
162
|
* 反向链接:哪些条目的 `file` 字段引用了这个附件。
|
|
143
|
-
* 条目里 `file` 字段的值 = 附件 id
|
|
144
|
-
*
|
|
163
|
+
* 条目里 `file` 字段的值 = 附件 id(或唯一文件名);一个字段可挂多个,
|
|
164
|
+
* 所以这里按 `fileIdsOf` 逐个成员判断——列表里的**任一** id 都能反查到,
|
|
165
|
+
* 同一个(条目, 字段)只出一条。附件中心据此显示「被哪些栏目 / 条目 / 字段用到」。
|
|
145
166
|
*/
|
|
146
167
|
export function fileReferences(store, rec) {
|
|
147
168
|
if (!rec) return [];
|
|
@@ -152,9 +173,9 @@ export function fileReferences(store, rec) {
|
|
|
152
173
|
for (const e of store.entries || []) {
|
|
153
174
|
if (e.section !== sec.id) continue;
|
|
154
175
|
for (const f of fileFields) {
|
|
155
|
-
const
|
|
156
|
-
if (!
|
|
157
|
-
if (findFileRecord(store,
|
|
176
|
+
const ids = fileIdsOf(e.values?.[f.key]);
|
|
177
|
+
if (!ids.length) continue;
|
|
178
|
+
if (!ids.some((id) => findFileRecord(store, id) === rec)) continue;
|
|
158
179
|
out.push({
|
|
159
180
|
sectionId: sec.id, sectionTitle: sec.title,
|
|
160
181
|
entryId: e.id, entryTitle: e.title,
|
package/src/core/fields.js
CHANGED
|
@@ -128,11 +128,11 @@ const JOB_FIELDS = [
|
|
|
128
128
|
f('acceptAdjustment', '是否接受调剂', 'bool', 'job', { hint: '是否接受岗位或城市调剂' }),
|
|
129
129
|
f('jobNote', '求职备注', 'textarea', 'job', { hint: '补充说明' }),
|
|
130
130
|
|
|
131
|
-
// —— 附件资料(统一附件库;值 = 附件 id
|
|
132
|
-
f('resumeFile', '简历附件', 'file', 'files', { kind: 'doc', hint: '简历 PDF/Word
|
|
133
|
-
f('avatar', '头像图片', 'file', 'files', { kind: 'image', hint: '
|
|
134
|
-
f('transcript', '成绩单', 'file', 'files', { kind: 'doc', hint: '
|
|
135
|
-
f('certificateScan', '证书扫描件', 'file', 'files', { kind: 'doc', fill: 'optional', hint: '
|
|
131
|
+
// —— 附件资料(统一附件库;值 = 附件 id 的**列表**,一个字段可挂多个,直接在字段里上传或从附件库选) ——
|
|
132
|
+
f('resumeFile', '简历附件', 'file', 'files', { kind: 'doc', hint: '简历 PDF/Word 原件;可传多个(不同版本/岗位),在本字段直接上传或从附件库选' }),
|
|
133
|
+
f('avatar', '头像图片', 'file', 'files', { kind: 'image', hint: '证件照/头像图片原件;可传多张' }),
|
|
134
|
+
f('transcript', '成绩单', 'file', 'files', { kind: 'doc', hint: '在校成绩单原件;可传多个学期' }),
|
|
135
|
+
f('certificateScan', '证书扫描件', 'file', 'files', { kind: 'doc', fill: 'optional', hint: '获奖/资格证书扫描件;可传多份(不必填)' }),
|
|
136
136
|
|
|
137
137
|
// —— 其他 ——
|
|
138
138
|
f('religion', '宗教信仰', 'text', 'extra', { hint: '没有就写「无」' }),
|
|
@@ -103,11 +103,17 @@ function normalizeValue(def, raw) {
|
|
|
103
103
|
return list;
|
|
104
104
|
}
|
|
105
105
|
if (type === 'file') {
|
|
106
|
-
// 值 = 附件 id
|
|
107
|
-
//
|
|
106
|
+
// 值 = 附件 id(或唯一文件名)的**列表**:一个字段可以挂多个附件(简历传多个版本、
|
|
107
|
+
// 头像多张)。id 全局唯一,天然做到「各条目附件互不串台」;同一份原件被多条条目引用
|
|
108
|
+
// 也没问题(sha256 去重后是同一个 id)。
|
|
109
|
+
// **新写入统一存数组**(哪怕只有一个)——形状一致,下游不必到处判「是串还是表」;
|
|
110
|
+
// 历史数据里的字符串单值读时由 fileIdsOf 按单元素处理,不做迁移。
|
|
108
111
|
// 不校验存在性——附件可能被还原/删掉,前端按「引用的附件不存在」展示。
|
|
109
|
-
const
|
|
110
|
-
|
|
112
|
+
const list = att.fileIdsOf(Array.isArray(raw) ? raw : String(raw).split(/[,,、;;]+/));
|
|
113
|
+
if (def.maxItems && list.length > def.maxItems) {
|
|
114
|
+
throw badInput(`字段「${def.label}」最多 ${def.maxItems} 项,收到 ${list.length} 项`);
|
|
115
|
+
}
|
|
116
|
+
return list.length ? list : null;
|
|
111
117
|
}
|
|
112
118
|
const s = String(raw);
|
|
113
119
|
return s.trim() === '' ? null : s;
|
|
@@ -136,8 +142,21 @@ async function prepareValue(section, def, raw, current) {
|
|
|
136
142
|
* 18 位的身份证号 / 长数字 ID 超过 2^53,推成数字会**静默丢精度**。
|
|
137
143
|
* 值仍是字符串,所以「直接存 kv」不会因为类型推断损坏数据。
|
|
138
144
|
*/
|
|
145
|
+
// 值像不像附件 id(`f_xxxx`)。用来推断「写了一个没登记过的键」该登记成什么类型:
|
|
146
|
+
// `--set 简历附件=f_abc` / `--set 简历附件=f_a,f_b` 应当登记成**附件字段**,
|
|
147
|
+
// 而不是一个普通文本字段或 tags。
|
|
148
|
+
const looksLikeFileId = (x) => typeof x === 'string' && /^f_[0-9a-z]+$/i.test(x.trim());
|
|
149
|
+
|
|
139
150
|
function inferFieldType(raw) {
|
|
140
|
-
if (Array.isArray(raw))
|
|
151
|
+
if (Array.isArray(raw)) {
|
|
152
|
+
if (raw.length && raw.every(looksLikeFileId)) return 'file';
|
|
153
|
+
return 'tags';
|
|
154
|
+
}
|
|
155
|
+
if (looksLikeFileId(raw)) return 'file';
|
|
156
|
+
if (typeof raw === 'string') {
|
|
157
|
+
const parts = raw.split(/[,,、;;]+/).map((x) => x.trim()).filter(Boolean);
|
|
158
|
+
if (parts.length && parts.every(looksLikeFileId)) return 'file';
|
|
159
|
+
}
|
|
141
160
|
if (typeof raw === 'boolean') return 'bool';
|
|
142
161
|
const s = String(raw).trim().toLowerCase();
|
|
143
162
|
if (BOOL_TRUE.includes(s) || BOOL_FALSE.includes(s)) return 'bool';
|
|
@@ -183,10 +202,12 @@ export async function listEntries({ section, q } = {}) {
|
|
|
183
202
|
if (!viewers.has(sec.id)) viewers.set(sec.id, await sensitiveViewer(sec, list.filter((x) => x.section === sec.id)));
|
|
184
203
|
const item = serializeEntry(sec, e, { decrypt: viewers.get(sec.id) });
|
|
185
204
|
if (q) {
|
|
186
|
-
// file 字段值是附件 id
|
|
205
|
+
// file 字段值是附件 id(可能挂多个),把解析出的附件名都加进搜索文本,
|
|
206
|
+
// 才能按「简历/成绩单」搜到条目
|
|
187
207
|
const fileNames = sec.fields
|
|
188
208
|
.filter((f) => f.type === 'file')
|
|
189
|
-
.
|
|
209
|
+
.flatMap((f) => att.fileIdsOf(e.values?.[f.key])
|
|
210
|
+
.map((id) => att.findFileRecord(store, id)?.name || ''));
|
|
190
211
|
const hay = [item.title, sec.title, ...fileNames,
|
|
191
212
|
...Object.values(item.values).map((v) => (v === null ? '' : Array.isArray(v) ? v.join(',') : String(v)))]
|
|
192
213
|
.join('\n').toLowerCase();
|
|
@@ -204,18 +225,19 @@ export async function getEntry(ref, { mask, section } = {}) {
|
|
|
204
225
|
if (!sec) throw notFound(`条目 ${entry.id} 指向的栏目已不存在: ${entry.section}`);
|
|
205
226
|
const decrypt = await sensitiveViewer(sec, [entry]);
|
|
206
227
|
const serialized = serializeEntry(sec, entry, { mask: !!mask, decrypt });
|
|
207
|
-
// 附带 file 字段引用的附件信息:键 =
|
|
228
|
+
// 附带 file 字段引用的附件信息:键 = 字段值(附件 id / 文件名),值为名称 / 路径 / 链接。
|
|
229
|
+
// 一个字段可挂多个附件,所以逐个 id 建表。
|
|
208
230
|
const files = {};
|
|
209
231
|
for (const f of sec.fields.filter((x) => x.type === 'file')) {
|
|
210
|
-
const ref
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
232
|
+
for (const ref of att.fileIdsOf(serialized.values[f.key])) {
|
|
233
|
+
const rec = att.findFileRecord(store, ref);
|
|
234
|
+
if (!rec) continue;
|
|
235
|
+
const links = att.fileLinks(rec);
|
|
236
|
+
files[ref] = {
|
|
237
|
+
id: rec.id, name: rec.name, ext: rec.ext, isImage: att.isImageExt(rec.ext),
|
|
238
|
+
path: displayPath(att.fileRecordAbs(rec)), url: links.url, downloadUrl: links.downloadUrl,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
219
241
|
}
|
|
220
242
|
return { ...serialized, sectionTitle: sec.title, mask: !!mask, files };
|
|
221
243
|
}
|
|
@@ -518,6 +540,21 @@ export function renderEntryList(d) {
|
|
|
518
540
|
return lines.join('\n');
|
|
519
541
|
}
|
|
520
542
|
|
|
543
|
+
/**
|
|
544
|
+
* 一个字段值渲染成几行文本。
|
|
545
|
+
* - 附件(`file` 字段,值可能是 id 列表):解析成「附件名 物理路径」,**几个占几行**
|
|
546
|
+
* - 其它数组(tags 之类):仍旧一行、顿号连接——不要顺手把它们也拆成多行
|
|
547
|
+
*/
|
|
548
|
+
function valueLines(d, v) {
|
|
549
|
+
const asFile = (x) => (d.files && d.files[x] ? `${d.files[x].name} ${d.files[x].path}` : null);
|
|
550
|
+
if (Array.isArray(v) && v.length && v.every((x) => asFile(x))) return v.map(asFile);
|
|
551
|
+
const one = asFile(v);
|
|
552
|
+
if (one) return [one];
|
|
553
|
+
if (Array.isArray(v)) return [v.join('、')];
|
|
554
|
+
if (typeof v === 'boolean') return [v ? '是' : '否'];
|
|
555
|
+
return [String(v)];
|
|
556
|
+
}
|
|
557
|
+
|
|
521
558
|
export function renderEntryGet(d) {
|
|
522
559
|
const c = d.completeness || {};
|
|
523
560
|
const lines = [
|
|
@@ -527,11 +564,9 @@ export function renderEntryGet(d) {
|
|
|
527
564
|
];
|
|
528
565
|
for (const [k, v] of Object.entries(d.values)) {
|
|
529
566
|
if (v === null) continue;
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
}
|
|
534
|
-
lines.push(` ${k.padEnd(24)} ${Array.isArray(v) ? v.join('、') : (typeof v === 'boolean' ? (v ? '是' : '否') : String(v))}`);
|
|
567
|
+
const rendered = valueLines(d, v);
|
|
568
|
+
lines.push(` ${k.padEnd(24)} ${rendered[0]}`);
|
|
569
|
+
for (const extra of rendered.slice(1)) lines.push(` ${' '.repeat(24)} ${extra}`);
|
|
535
570
|
}
|
|
536
571
|
if (d.missing.length) {
|
|
537
572
|
lines.push('', `未填(${d.missing.length}): ${d.missing.map((m) => `${m.label}(${m.key})`).join('、')}`);
|
|
@@ -36,61 +36,126 @@ function AutoTextarea({ value, onChange, minRows = 2, placeholder, disabled, max
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
39
|
+
* 字段值 → 附件 id 数组。
|
|
40
|
+
* 一个 `file` 字段可挂**多个**附件:新写入统一存数组,历史数据里的单值是字符串
|
|
41
|
+
* (读时按单元素处理)。前端不能 import core,所以这里自带一份同口径的归一化。
|
|
42
|
+
*/
|
|
43
|
+
function toIds(value) {
|
|
44
|
+
const raw = value === null || value === undefined ? [] : (Array.isArray(value) ? value : [value]);
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const x of raw) {
|
|
47
|
+
const s = String(x ?? '').trim();
|
|
48
|
+
if (s && !out.includes(s)) out.push(s);
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* file 字段控件(**多值**):值 = 附件 id 的列表,一个字段能挂多个附件
|
|
55
|
+
* (简历多版本、头像多张……)。
|
|
56
|
+
*
|
|
57
|
+
* **上传 / 选择就发生在这里**(「文件在栏目中链接」);「附件」中心只负责统一展示与
|
|
58
|
+
* 库存管理,不在这里做指派,也不预定义任何槽位。
|
|
59
|
+
* 列表逐行显示:缩略图/扩展名徽标 + 名称(内联预览)+ 下载 + 复制物理路径 + 移除;
|
|
60
|
+
* 底部是添加入口(可**多选**上传、或从库里选一个追加)。
|
|
42
61
|
*/
|
|
43
62
|
export function FileFieldControl({ def, value, onChange }) {
|
|
44
63
|
const { toast, guard, patchUi } = useStore();
|
|
45
64
|
const [data, setData] = useState(null);
|
|
65
|
+
const [dragOver, setDragOver] = useState(false);
|
|
46
66
|
const uploadRef = useRef(null);
|
|
47
|
-
|
|
67
|
+
// dragenter/dragleave 在子元素间穿梭时会成对触发,用深度计数避免「还没离开就熄掉高亮」
|
|
68
|
+
const dragDepth = useRef(0);
|
|
69
|
+
const ids = toIds(value);
|
|
70
|
+
const max = Number(def.maxItems) || 0;
|
|
71
|
+
const full = max > 0 && ids.length >= max;
|
|
48
72
|
|
|
49
73
|
const load = useCallback(async () => {
|
|
50
74
|
try { setData(await listAttachments()); } catch { /* 附件库读不到时仍可上传 */ }
|
|
51
75
|
}, []);
|
|
52
76
|
useEffect(() => { load(); }, [load]);
|
|
53
77
|
|
|
54
|
-
const
|
|
78
|
+
const all = data?.files || [];
|
|
79
|
+
const byId = (id) => all.find((f) => f.id === id) || null;
|
|
80
|
+
const available = all.filter((f) => !ids.includes(f.id));
|
|
55
81
|
|
|
56
82
|
const upload = guard(async (fileList) => {
|
|
57
|
-
const
|
|
58
|
-
if (!
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
83
|
+
const picked = [...(fileList || [])];
|
|
84
|
+
if (!picked.length) return;
|
|
85
|
+
if (full) { toast(`「${def.label}」最多 ${max} 个附件,先移除一个`, 'bad'); return; }
|
|
86
|
+
const next = [...ids];
|
|
87
|
+
let duplicated = 0;
|
|
88
|
+
for (const file of picked) {
|
|
89
|
+
const content = await fileToDataUrl(file);
|
|
90
|
+
const r = await api('/api/files', { method: 'POST', body: { content, name: file.name } });
|
|
91
|
+
if (next.includes(r.id)) { duplicated += 1; continue; }
|
|
92
|
+
next.push(r.id);
|
|
93
|
+
}
|
|
94
|
+
onChange(next);
|
|
95
|
+
const added = picked.length - duplicated;
|
|
96
|
+
toast(duplicated
|
|
97
|
+
? `已上传并关联 ${added} 个;${duplicated} 个库里已有,直接引用`
|
|
98
|
+
: `已上传并关联 ${picked.length} 个`);
|
|
63
99
|
setData(await refreshAttachments());
|
|
64
100
|
});
|
|
65
101
|
|
|
102
|
+
const add = (id) => { if (id && !ids.includes(id)) onChange([...ids, id]); };
|
|
103
|
+
const removeAt = (id) => onChange(ids.filter((x) => x !== id));
|
|
104
|
+
|
|
105
|
+
// 拖拽文件进字段框 = 直接上传并追加(与「附件」中心同一套手感)
|
|
106
|
+
const onDrop = guard(async (e) => {
|
|
107
|
+
dragDepth.current = 0;
|
|
108
|
+
setDragOver(false);
|
|
109
|
+
await upload(e.dataTransfer?.files);
|
|
110
|
+
});
|
|
111
|
+
|
|
66
112
|
return (
|
|
67
|
-
<div
|
|
68
|
-
{
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
113
|
+
<div
|
|
114
|
+
className={`file-picker ${dragOver ? 'drag' : ''}`}
|
|
115
|
+
onDragEnter={(e) => { e.preventDefault(); dragDepth.current += 1; setDragOver(true); }}
|
|
116
|
+
onDragOver={(e) => e.preventDefault()}
|
|
117
|
+
onDragLeave={() => { dragDepth.current -= 1; if (dragDepth.current <= 0) { dragDepth.current = 0; setDragOver(false); } }}
|
|
118
|
+
onDrop={onDrop}
|
|
119
|
+
>
|
|
120
|
+
{ids.map((id) => {
|
|
121
|
+
const rec = byId(id);
|
|
122
|
+
if (!rec) {
|
|
123
|
+
return (
|
|
124
|
+
<div className="file-preview" key={id}>
|
|
125
|
+
<span className="hint bad grow">引用的附件已不存在({id})—— 重新上传,或移除。</span>
|
|
126
|
+
<button type="button" className="btn small ghost" onClick={() => removeAt(id)}>移除</button>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return (
|
|
131
|
+
<div className="file-preview" key={id}>
|
|
132
|
+
{rec.isImage
|
|
133
|
+
? <img src={rec.url} alt={rec.name} />
|
|
134
|
+
: <span className="ext-badge" style={{ fontSize: 13, width: 40, textAlign: 'center' }}>{rec.ext ? rec.ext.slice(1).toUpperCase() : 'FILE'}</span>}
|
|
135
|
+
<a className="file-preview-name grow" href={rec.url} target="_blank" rel="noreferrer" title={rec.displayPath}>{rec.name}</a>
|
|
136
|
+
<a className="btn small ghost" href={rec.downloadUrl}>下载</a>
|
|
137
|
+
<Copyable className="hint" text={rec.path} title="点击复制物理路径">路径</Copyable>
|
|
138
|
+
<button type="button" className="btn small ghost" onClick={() => removeAt(id)}>移除</button>
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
})}
|
|
142
|
+
{!ids.length ? <span className="hint">还没有附件。</span> : null}
|
|
79
143
|
<div className="file-pick-row">
|
|
80
|
-
<select value={
|
|
81
|
-
<option value=""
|
|
82
|
-
{
|
|
144
|
+
<select value="" disabled={full || !available.length} onChange={(e) => add(e.target.value)}>
|
|
145
|
+
<option value="">{available.length ? '(从附件库选一个,选中即追加)' : '(库里没有可再选的附件)'}</option>
|
|
146
|
+
{available.map((f) => <option key={f.id} value={f.id}>{f.name}</option>)}
|
|
83
147
|
</select>
|
|
84
|
-
<button type="button" className="btn small" onClick={() => uploadRef.current?.click()}>上传文件</button>
|
|
148
|
+
<button type="button" className="btn small" disabled={full} onClick={() => uploadRef.current?.click()}>上传文件</button>
|
|
85
149
|
<button type="button" className="btn small ghost" onClick={() => patchUi({ view: 'files' })}>附件中心</button>
|
|
86
150
|
<input
|
|
87
151
|
ref={uploadRef}
|
|
88
152
|
type="file"
|
|
153
|
+
multiple
|
|
89
154
|
style={{ display: 'none' }}
|
|
90
155
|
onChange={(e) => { upload(e.target.files); e.target.value = ''; }}
|
|
91
156
|
/>
|
|
92
157
|
</div>
|
|
93
|
-
{
|
|
158
|
+
{full ? <span className="hint">已达上限({max} 个):先移除一个,或把字段的 maxItems 调大。</span> : null}
|
|
94
159
|
{def.hint ? <span className="hint">{def.hint}</span> : null}
|
|
95
160
|
</div>
|
|
96
161
|
);
|
|
@@ -384,6 +384,8 @@ a.file-name:hover { text-decoration: underline; }
|
|
|
384
384
|
|
|
385
385
|
/* ── 字段里的附件选择器 ─────────────────────────────── */
|
|
386
386
|
.file-picker { display: flex; flex-direction: column; gap: 4px; }
|
|
387
|
+
/* 拖文件悬停在字段框上:与「附件」中心同一套视觉语言(虚线描边 + 浅底) */
|
|
388
|
+
.file-picker.drag { outline: 2px dashed var(--ink); outline-offset: -4px; background: var(--soft); }
|
|
387
389
|
.file-picker .file-pick-row { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
|
388
390
|
.file-picker select { flex: 1; min-width: 120px; }
|
|
389
391
|
.file-preview {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{a as l}from"./index-
|
|
1
|
+
import{a as l}from"./index-DpX9p_OX.js";let r=null;function o(n=!1){return!n&&r||(r=l("/api/files").then(e=>e).catch(e=>{throw r=null,e})),r}function i(){return r=null,o(!0)}function u(n){return new Promise((e,a)=>{const t=new FileReader;t.onload=()=>e(t.result),t.onerror=()=>a(new Error("读取文件失败")),t.readAsDataURL(n)})}export{u as f,o as l,i as r};
|