exflower-license-server 1.3.1 → 1.3.2
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/Dockerfile +4 -1
- package/README.md +33 -7
- package/admin/index.html +48 -1
- package/db.js +3 -0
- package/package.json +1 -1
- package/routes.js +101 -3
package/Dockerfile
CHANGED
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
FROM node:18-alpine
|
|
14
14
|
|
|
15
15
|
# better-sqlite3 原生模块编译工具(alpine/musl 无预编译二进制)
|
|
16
|
-
|
|
16
|
+
# apk 源切腾讯云镜像(默认 dl-cdn 国内不可达);海外机构建可 --build-arg APK_MIRROR=dl-cdn.alpinelinux.org
|
|
17
|
+
ARG APK_MIRROR=mirrors.cloud.tencent.com
|
|
18
|
+
RUN sed -i "s|dl-cdn.alpinelinux.org|${APK_MIRROR}|g" /etc/apk/repositories \
|
|
19
|
+
&& apk add --no-cache python3 make g++
|
|
17
20
|
|
|
18
21
|
# 从 npm registry 全局安装已发布版本(国内机构建机可换 npmmirror)
|
|
19
22
|
ARG VERSION=latest
|
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ npm install -g exflower-license-server
|
|
|
12
12
|
|
|
13
13
|
# 查看版本
|
|
14
14
|
exflower-license-server --version
|
|
15
|
-
# 输出: 1.
|
|
15
|
+
# 输出: 1.3.1
|
|
16
16
|
|
|
17
17
|
# 运行
|
|
18
18
|
exflower-license-server
|
|
@@ -23,7 +23,19 @@ ADMIN_TOKEN=your-secret-token PORT=4001 exflower-license-server
|
|
|
23
23
|
|
|
24
24
|
### 方式二:Docker
|
|
25
25
|
|
|
26
|
+
镜像构建直接从 npm registry 拉取已发布版本(构建机无需源码):
|
|
27
|
+
|
|
26
28
|
```bash
|
|
29
|
+
# 构建(默认 latest;指定版本用 --build-arg VERSION=x.y.z)
|
|
30
|
+
docker build -t exflower/license-server:latest .
|
|
31
|
+
|
|
32
|
+
# 国内构建机走镜像源(npm registry 与 alpine apk 源都可换)
|
|
33
|
+
docker build \
|
|
34
|
+
--build-arg VERSION=1.3.1 \
|
|
35
|
+
--build-arg NPM_REGISTRY=https://registry.npmmirror.com \
|
|
36
|
+
--build-arg APK_MIRROR=mirrors.cloud.tencent.com \
|
|
37
|
+
-t exflower/license-server:1.3.1 .
|
|
38
|
+
|
|
27
39
|
# 使用 docker-compose(推荐)
|
|
28
40
|
docker-compose up -d
|
|
29
41
|
|
|
@@ -174,7 +186,7 @@ sudo systemctl enable --now exflower-license-server
|
|
|
174
186
|
- **License 管理**:创建 License 时填写备注(如"市场部")并选择有效期(1/3/6 个月、1 年、2 年、永久);列表显示剩余天数(<30 天标黄、过期标红);支持编辑(改有效期/配额)、暂停/恢复、删除、**重置 Key**
|
|
175
187
|
- **品牌(白标)**:全局工作台名称设置(见下方"白标品牌名")
|
|
176
188
|
- **实例监控**:查看已激活的实例列表
|
|
177
|
-
-
|
|
189
|
+
- **模板管理**:按类型页签组织(agent/excard/doc/资料图谱),卡片式列表,支持分类管理与分类过滤、按 License 定向分发(多选),查看导入统计
|
|
178
190
|
- **轻应用分发**:上传 `.exfapp` 包并按 License 隔离分发;客户端「轻应用」页可浏览/下载/导入(上限 5GB,含图谱的包注意部署机磁盘)
|
|
179
191
|
- **客户端更新**:上传各平台更新包(mac 传 **zip**、win 传 NSIS **exe**),客户端启动自动检查 + 帮助菜单手动检查,electron-updater 静默下载安装(每个包 ~700MB+,注意部署机磁盘余量)
|
|
180
192
|
|
|
@@ -206,6 +218,7 @@ sudo systemctl enable --now exflower-license-server
|
|
|
206
218
|
| `/api/license/heartbeat` | POST | 心跳上报(响应含 `brand_name`) |
|
|
207
219
|
| `/api/templates` | GET | 获取模板列表(需 `x-license-key`) |
|
|
208
220
|
| `/api/templates/:id` | GET | 获取模板详情(需 `x-license-key`) |
|
|
221
|
+
| `/api/templates/:id/download` | GET | 下载资料图谱模板 `.exflib`(需 `x-license-key`) |
|
|
209
222
|
| `/api/packages` | GET | 轻应用包列表,按公司隔离(需 `x-license-key`) |
|
|
210
223
|
| `/api/packages/:id/download` | GET | 下载轻应用包(需 `x-license-key`) |
|
|
211
224
|
| `/api/updates/latest.yml` | GET | Windows 更新清单(electron-updater,需 `x-license-key`) |
|
|
@@ -224,6 +237,9 @@ sudo systemctl enable --now exflower-license-server
|
|
|
224
237
|
| `/api/admin/instances/:id` | DELETE | 删除实例 |
|
|
225
238
|
| `/api/admin/templates` | GET/POST | 模板列表/创建 |
|
|
226
239
|
| `/api/admin/templates/:id` | PATCH/DELETE | 更新/删除模板 |
|
|
240
|
+
| `/api/admin/templates/library` | POST | 资料图谱模板上传(multipart,字段 `file`,`.exflib`) |
|
|
241
|
+
| `/api/admin/template-categories` | GET/POST | 模板分类列表/创建(按类型分组) |
|
|
242
|
+
| `/api/admin/template-categories/:id` | PATCH/DELETE | 分类改名/删除 |
|
|
227
243
|
| `/api/admin/imports` | GET | 导入统计(支持 `?template_id=` / `?company_id=` 过滤) |
|
|
228
244
|
| `/api/admin/packages` | GET/POST | 轻应用包列表/上传(multipart,字段 `file`+`company_id`) |
|
|
229
245
|
| `/api/admin/packages/:id` | DELETE | 删除轻应用包 |
|
|
@@ -234,16 +250,19 @@ sudo systemctl enable --now exflower-license-server
|
|
|
234
250
|
|
|
235
251
|
## 模板系统
|
|
236
252
|
|
|
237
|
-
|
|
253
|
+
模板用于向客户端分发预设配置。管理后台支持 `agent`、`excard`、`doc`、`library`(资料图谱)四种类型的模板。
|
|
254
|
+
|
|
255
|
+
> **workflow 类型暂不开放**:上传/下载形态待重新设计,管理台与客户端均已隐藏入口;API 与存量数据保留,重新开放前不要清理 workflow 数据。
|
|
238
256
|
|
|
239
257
|
### 上传方式(v1.0.7 起)
|
|
240
258
|
|
|
241
259
|
管理后台根据类型提供不同的上传方式:
|
|
242
260
|
|
|
243
261
|
- **agent / excard / doc**:**Markdown 原生上传**。直接上传客户端导出的 `.md` 文件(或粘贴 Markdown 文本),后台自动解析 frontmatter 预填模板名称并生成 `content_json`,无需手写 JSON。
|
|
244
|
-
- **
|
|
262
|
+
- **library**:上传客户端导出的 `.exflib` 包(multipart `POST /api/admin/templates/library`,字段 `file`),服务端解包校验 manifest(`format: exflower-library`)并记录文件大小与 sha256;客户端经 `GET /api/templates/:id/download` 下载导入。
|
|
263
|
+
- **workflow**(暂不开放):以 JSON 描述步骤结构,上传框预填骨架示例。
|
|
245
264
|
|
|
246
|
-
通过 API 创建模板时,`content_json` 与 `content_md` 至少提供一个(`workflow` 类型必须提供 `content_json
|
|
265
|
+
通过 API 创建模板时,`content_json` 与 `content_md` 至少提供一个(`workflow` 类型必须提供 `content_json`;`library` 类型走专用上传端点)。
|
|
247
266
|
|
|
248
267
|
### 通用字段
|
|
249
268
|
|
|
@@ -412,9 +431,16 @@ node scripts/seed-templates.js \
|
|
|
412
431
|
- 导入的模板均为**全局模板**(所有 License 可见)。
|
|
413
432
|
- 脚本遵守 admin 接口限速(60 次/分钟),遇到 429 自动退避重试。
|
|
414
433
|
|
|
415
|
-
##
|
|
434
|
+
## 发布新版本(厂商侧)
|
|
416
435
|
|
|
417
436
|
```bash
|
|
418
|
-
|
|
437
|
+
# 1. 提升 package.json 版本号
|
|
438
|
+
# 2. 确认 files 白名单覆盖全部运行时文件(activation.js / seed-templates.js /
|
|
439
|
+
# central-public.pem / seeds/ 缺一个都会发出启动即崩的包)
|
|
440
|
+
npm pack --dry-run # 检查 Tarball Contents
|
|
441
|
+
# 3. 发布(npm 已下线 Classic Token,用勾了 bypass 2FA 的 Granular Token)
|
|
442
|
+
npm publish
|
|
419
443
|
```
|
|
420
444
|
|
|
445
|
+
Docker 镜像构建参数见上方「方式二:Docker」。
|
|
446
|
+
|
package/admin/index.html
CHANGED
|
@@ -415,6 +415,10 @@
|
|
|
415
415
|
<label>Markdown 文件(直接上传客户端导出的 .md)</label>
|
|
416
416
|
<input type="file" id="tmpl-md-file" accept=".md,.markdown,text/markdown,text/plain" onchange="onTemplateFileSelected(event)">
|
|
417
417
|
</div>
|
|
418
|
+
<div id="tmpl-doc-zip-group" class="hidden">
|
|
419
|
+
<label>文档 zip 包(含 README.md + assets/;选择了则以包为准,下方 Markdown 忽略)</label>
|
|
420
|
+
<input type="file" id="tmpl-doc-zip" accept=".zip,application/zip">
|
|
421
|
+
</div>
|
|
418
422
|
<div id="tmpl-json-group">
|
|
419
423
|
<label>内容 (JSON) <span class="muted" id="tmpl-json-hint"></span></label>
|
|
420
424
|
<textarea id="tmpl-json" rows="8"></textarea>
|
|
@@ -967,12 +971,15 @@
|
|
|
967
971
|
tbody.innerHTML = '<tr><td colspan="3" class="muted" style="text-align:center;padding:16px">暂无分类</td></tr>';
|
|
968
972
|
return;
|
|
969
973
|
}
|
|
970
|
-
for (
|
|
974
|
+
for (let i = 0; i < categoriesCache.length; i++) {
|
|
975
|
+
const c = categoriesCache[i];
|
|
971
976
|
const tr = document.createElement('tr');
|
|
972
977
|
tr.innerHTML = `
|
|
973
978
|
<td><strong>${c.name}</strong></td>
|
|
974
979
|
<td>${c.template_count}</td>
|
|
975
980
|
<td>
|
|
981
|
+
<button class="btn-sm btn-plain" ${i === 0 ? 'disabled' : ''} onclick="moveCategory('${c.id}', -1)" title="上移">↑</button>
|
|
982
|
+
<button class="btn-sm btn-plain" ${i === categoriesCache.length - 1 ? 'disabled' : ''} onclick="moveCategory('${c.id}', 1)" title="下移">↓</button>
|
|
976
983
|
<button class="btn-sm btn-plain" onclick="renameCategory('${c.id}')">改名</button>
|
|
977
984
|
<button class="btn-sm btn-danger" onclick="deleteCategory('${c.id}', '${c.name.replace(/'/g, "\\'")}')">删除</button>
|
|
978
985
|
</td>
|
|
@@ -981,6 +988,18 @@
|
|
|
981
988
|
}
|
|
982
989
|
}
|
|
983
990
|
|
|
991
|
+
async function moveCategory(id, dir) {
|
|
992
|
+
const idx = categoriesCache.findIndex(c => c.id === id);
|
|
993
|
+
const swap = idx + dir;
|
|
994
|
+
if (idx < 0 || swap < 0 || swap >= categoriesCache.length) return;
|
|
995
|
+
const ids = categoriesCache.map(c => c.id);
|
|
996
|
+
[ids[idx], ids[swap]] = [ids[swap], ids[idx]];
|
|
997
|
+
const res = await api('PUT', '/api/admin/template-categories/order', { type: currentAssetType, ids });
|
|
998
|
+
if (!res.success) return toast(res.message || '排序失败');
|
|
999
|
+
await loadCategories();
|
|
1000
|
+
renderCategoryList();
|
|
1001
|
+
}
|
|
1002
|
+
|
|
984
1003
|
async function renameCategory(id) {
|
|
985
1004
|
const cat = categoriesCache.find(c => c.id === id);
|
|
986
1005
|
if (!cat) return;
|
|
@@ -1144,6 +1163,9 @@
|
|
|
1144
1163
|
const isMd = MD_TYPES.includes(type);
|
|
1145
1164
|
document.getElementById('tmpl-json-group').classList.toggle('hidden', isMd);
|
|
1146
1165
|
document.getElementById('tmpl-md-file-group').classList.toggle('hidden', !isMd);
|
|
1166
|
+
// doc zip 上传只在新建时提供(编辑模式只改元信息与 md)
|
|
1167
|
+
const isDocCreate = type === 'doc' && !document.getElementById('tmpl-id').value;
|
|
1168
|
+
document.getElementById('tmpl-doc-zip-group').classList.toggle('hidden', !isDocCreate);
|
|
1147
1169
|
document.getElementById('tmpl-md-label').textContent = isMd
|
|
1148
1170
|
? 'Markdown 内容(上传文件后可编辑)'
|
|
1149
1171
|
: 'Markdown 内容 (可选)';
|
|
@@ -1220,6 +1242,7 @@
|
|
|
1220
1242
|
document.getElementById('tmpl-json').value = '';
|
|
1221
1243
|
document.getElementById('tmpl-md').value = '';
|
|
1222
1244
|
document.getElementById('tmpl-md-file').value = '';
|
|
1245
|
+
document.getElementById('tmpl-doc-zip').value = '';
|
|
1223
1246
|
populateCompanySelect();
|
|
1224
1247
|
onTemplateTypeChange();
|
|
1225
1248
|
document.getElementById('template-modal').classList.add('show');
|
|
@@ -1267,6 +1290,7 @@
|
|
|
1267
1290
|
document.getElementById('tmpl-json').value = jsonStr;
|
|
1268
1291
|
document.getElementById('tmpl-md').value = t.content_md || '';
|
|
1269
1292
|
document.getElementById('tmpl-md-file').value = '';
|
|
1293
|
+
document.getElementById('tmpl-doc-zip').value = '';
|
|
1270
1294
|
onTemplateTypeChange();
|
|
1271
1295
|
document.getElementById('template-modal').classList.add('show');
|
|
1272
1296
|
}
|
|
@@ -1289,6 +1313,29 @@
|
|
|
1289
1313
|
|
|
1290
1314
|
if (!name) return toast('请输入模板名称');
|
|
1291
1315
|
|
|
1316
|
+
// 知识库文档 + 选了 zip 包:走 zip 上传通道(携带 assets),以包内 README.md 为内容
|
|
1317
|
+
const docZip = type === 'doc' && !id ? document.getElementById('tmpl-doc-zip').files[0] : null;
|
|
1318
|
+
if (docZip) {
|
|
1319
|
+
const fd = new FormData();
|
|
1320
|
+
fd.append('file', docZip);
|
|
1321
|
+
fd.append('name', name);
|
|
1322
|
+
fd.append('description', desc);
|
|
1323
|
+
if (version) fd.append('version', version);
|
|
1324
|
+
if (category !== undefined) fd.append('category', category);
|
|
1325
|
+
fd.append('company_ids', JSON.stringify(companyIds));
|
|
1326
|
+
const res = await fetch('/api/admin/templates/doc-upload', {
|
|
1327
|
+
method: 'POST',
|
|
1328
|
+
headers: { 'x-admin-token': ADMIN_TOKEN },
|
|
1329
|
+
body: fd
|
|
1330
|
+
});
|
|
1331
|
+
const data = await res.json();
|
|
1332
|
+
if (!data.success) return toast(data.message || '上传失败');
|
|
1333
|
+
closeTemplateModal();
|
|
1334
|
+
toast('文档包上传成功');
|
|
1335
|
+
loadTemplates();
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1292
1339
|
let contentJson, contentMd;
|
|
1293
1340
|
if (type === 'workflow') {
|
|
1294
1341
|
if (!jsonRaw) return toast('请输入 JSON 内容');
|
package/db.js
CHANGED
|
@@ -205,6 +205,9 @@ db.exec(`
|
|
|
205
205
|
// 幂等列迁移:轻应用分类(受管分类 template_categories type='app')
|
|
206
206
|
try { db.exec('ALTER TABLE app_packages ADD COLUMN category TEXT'); } catch { /* 已存在 */ };
|
|
207
207
|
|
|
208
|
+
// 幂等列迁移:分类手动排序(0 序并列时按 created_at,保持存量行为)
|
|
209
|
+
try { db.exec('ALTER TABLE template_categories ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0'); } catch { /* 已存在 */ };
|
|
210
|
+
|
|
208
211
|
// ── 轻应用定向分享(多 License 可见;语义同 template_companies) ──
|
|
209
212
|
db.exec(`
|
|
210
213
|
CREATE TABLE IF NOT EXISTS package_companies (
|
package/package.json
CHANGED
package/routes.js
CHANGED
|
@@ -574,8 +574,12 @@ function setupRoutes(app) {
|
|
|
574
574
|
sql += ' ORDER BY type, name';
|
|
575
575
|
|
|
576
576
|
const templates = queryAll(sql, params);
|
|
577
|
+
// 分类手动排序下发:客户端按服务端顺序展示分类筛选(各类型全量,体积小)
|
|
578
|
+
const orderRows = queryAll('SELECT type, name FROM template_categories ORDER BY sort_order ASC, created_at ASC');
|
|
579
|
+
const categoryOrder = {};
|
|
580
|
+
for (const r of orderRows) (categoryOrder[r.type] = categoryOrder[r.type] || []).push(r.name);
|
|
577
581
|
const hint = upgradeHint(req);
|
|
578
|
-
res.json({ success: true, templates, ...(hint ? { upgrade_hint: hint } : {}) });
|
|
582
|
+
res.json({ success: true, templates, category_order: categoryOrder, ...(hint ? { upgrade_hint: hint } : {}) });
|
|
579
583
|
});
|
|
580
584
|
|
|
581
585
|
// Import summary (must come before /:id to avoid matching 'import-summary' as an id)
|
|
@@ -697,7 +701,7 @@ function setupRoutes(app) {
|
|
|
697
701
|
`;
|
|
698
702
|
const params = [];
|
|
699
703
|
if (type) { sql += ' WHERE c.type = ?'; params.push(type); }
|
|
700
|
-
sql += ' ORDER BY c.created_at ASC';
|
|
704
|
+
sql += ' ORDER BY c.sort_order ASC, c.created_at ASC';
|
|
701
705
|
const cats = queryAll(sql, params);
|
|
702
706
|
// 轻应用分类的计数走 app_packages(模板计数查 templates 永远是 0)
|
|
703
707
|
if (type === 'app') {
|
|
@@ -718,10 +722,25 @@ function setupRoutes(app) {
|
|
|
718
722
|
const dup = queryGet('SELECT id FROM template_categories WHERE type = ? AND name = ?', [type, name]);
|
|
719
723
|
if (dup) return res.status(409).json({ success: false, message: '该类型下分类已存在' });
|
|
720
724
|
const id = generateId();
|
|
721
|
-
|
|
725
|
+
const maxOrder = queryGet('SELECT MAX(sort_order) AS m FROM template_categories WHERE type = ?', [type])?.m ?? -1;
|
|
726
|
+
queryRun('INSERT INTO template_categories (id, type, name, sort_order) VALUES (?, ?, ?, ?)', [id, type, name, maxOrder + 1]);
|
|
722
727
|
res.json({ success: true, category: { id, type, name } });
|
|
723
728
|
});
|
|
724
729
|
|
|
730
|
+
// 手动排序:按 ids 数组顺序重排该类型下分类(未列入的保持相对顺序排在末尾)
|
|
731
|
+
app.put('/api/admin/template-categories/order', requireAdmin, (req, res) => {
|
|
732
|
+
const { type, ids } = req.body || {};
|
|
733
|
+
if (!type || !Array.isArray(ids)) {
|
|
734
|
+
return res.status(400).json({ success: false, message: 'type 与 ids 必填' });
|
|
735
|
+
}
|
|
736
|
+
const rows = queryAll('SELECT id FROM template_categories WHERE type = ? ORDER BY sort_order ASC, created_at ASC', [type]);
|
|
737
|
+
const rest = rows.map(r => r.id).filter(id => !ids.includes(id));
|
|
738
|
+
[...ids, ...rest].forEach((id, i) => {
|
|
739
|
+
queryRun('UPDATE template_categories SET sort_order = ? WHERE id = ? AND type = ?', [i + 1, id, type]);
|
|
740
|
+
});
|
|
741
|
+
res.json({ success: true });
|
|
742
|
+
});
|
|
743
|
+
|
|
725
744
|
app.patch('/api/admin/template-categories/:id', requireAdmin, (req, res) => {
|
|
726
745
|
const cat = queryGet('SELECT * FROM template_categories WHERE id = ?', [req.params.id]);
|
|
727
746
|
if (!cat) return res.status(404).json({ success: false, message: 'Category not found' });
|
|
@@ -984,6 +1003,85 @@ function setupRoutes(app) {
|
|
|
984
1003
|
}
|
|
985
1004
|
});
|
|
986
1005
|
|
|
1006
|
+
// ── Admin: 知识库文档 zip 上传(README.md + assets/,与客户端发布产物的存储契约一致) ──
|
|
1007
|
+
const docZipUpload = multer({
|
|
1008
|
+
storage: multer.memoryStorage(),
|
|
1009
|
+
limits: { fileSize: 200 * 1024 * 1024 }
|
|
1010
|
+
});
|
|
1011
|
+
const DOC_ASSET_MIMES = {
|
|
1012
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
1013
|
+
'.gif': 'image/gif', '.svg': 'image/svg+xml', '.webp': 'image/webp',
|
|
1014
|
+
'.pdf': 'application/pdf', '.md': 'text/markdown', '.txt': 'text/plain',
|
|
1015
|
+
};
|
|
1016
|
+
app.post('/api/admin/templates/doc-upload', requireAdmin, docZipUpload.single('file'), async (req, res) => {
|
|
1017
|
+
try {
|
|
1018
|
+
if (!req.file) return res.status(400).json({ success: false, message: 'file is required' });
|
|
1019
|
+
|
|
1020
|
+
let zip;
|
|
1021
|
+
try {
|
|
1022
|
+
zip = await unzipper.Open.buffer(req.file.buffer);
|
|
1023
|
+
} catch {
|
|
1024
|
+
return res.status(400).json({ success: false, message: '无效的 zip 包' });
|
|
1025
|
+
}
|
|
1026
|
+
const readme = zip.files.find(f => f.type === 'File' && f.path.toLowerCase() === 'readme.md');
|
|
1027
|
+
if (!readme) {
|
|
1028
|
+
return res.status(400).json({ success: false, message: '无效的文档包:根目录缺少 README.md' });
|
|
1029
|
+
}
|
|
1030
|
+
const contentMd = (await readme.buffer()).toString('utf8');
|
|
1031
|
+
|
|
1032
|
+
const assets = [];
|
|
1033
|
+
for (const f of zip.files) {
|
|
1034
|
+
if (f.type !== 'File' || !f.path.startsWith('assets/')) continue;
|
|
1035
|
+
const filename = path.basename(f.path);
|
|
1036
|
+
if (!filename) continue;
|
|
1037
|
+
const buf = await f.buffer();
|
|
1038
|
+
assets.push({
|
|
1039
|
+
filename,
|
|
1040
|
+
mimeType: DOC_ASSET_MIMES[path.extname(filename).toLowerCase()] || 'application/octet-stream',
|
|
1041
|
+
data: buf.toString('base64'),
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
const { company_id, category, version } = req.body || {};
|
|
1046
|
+
let shareIds = null;
|
|
1047
|
+
if (req.body?.company_ids) {
|
|
1048
|
+
try { shareIds = JSON.parse(req.body.company_ids).filter(Boolean); } catch { shareIds = null; }
|
|
1049
|
+
}
|
|
1050
|
+
if (company_id && !shareIds) {
|
|
1051
|
+
const company = queryGet('SELECT id FROM companies WHERE id = ?', [company_id]);
|
|
1052
|
+
if (!company) return res.status(404).json({ success: false, message: 'Company not found' });
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
const h1 = contentMd.match(/^#\s+(.+)$/m)?.[1]?.trim();
|
|
1056
|
+
const name = (req.body?.name || '').trim() || h1 || '未命名文档';
|
|
1057
|
+
const id = generateId();
|
|
1058
|
+
queryRun(
|
|
1059
|
+
`INSERT INTO templates (id, company_id, type, name, description, content_json, content_md, tags, category, version, created_at, updated_at)
|
|
1060
|
+
VALUES (?, ?, 'doc', ?, ?, ?, ?, '[]', ?, ?, datetime('now'), datetime('now'))`,
|
|
1061
|
+
[
|
|
1062
|
+
id,
|
|
1063
|
+
shareIds ? null : (company_id || null),
|
|
1064
|
+
name,
|
|
1065
|
+
(req.body?.description || '').trim(),
|
|
1066
|
+
JSON.stringify({ assets }),
|
|
1067
|
+
contentMd,
|
|
1068
|
+
category || null,
|
|
1069
|
+
(version || '').trim() || '1.0',
|
|
1070
|
+
]
|
|
1071
|
+
);
|
|
1072
|
+
if (shareIds && shareIds.length) {
|
|
1073
|
+
const err = setTemplateCompanies(id, shareIds);
|
|
1074
|
+
if (err) {
|
|
1075
|
+
queryRun('DELETE FROM templates WHERE id = ?', [id]);
|
|
1076
|
+
return res.status(404).json({ success: false, message: err });
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
res.json({ success: true, template: { id, name, assets: assets.length } });
|
|
1080
|
+
} catch (err) {
|
|
1081
|
+
res.status(500).json({ success: false, message: err.message });
|
|
1082
|
+
}
|
|
1083
|
+
});
|
|
1084
|
+
|
|
987
1085
|
// ── Template Import Log (client-facing) ──
|
|
988
1086
|
app.post('/api/templates/:id/import-log', (req, res) => {
|
|
989
1087
|
const licenseKey = req.headers['x-license-key'];
|