exflower-license-server 1.0.6 → 1.0.8
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/README.md +33 -34
- package/admin/index.html +246 -19
- package/package.json +1 -1
- package/routes.js +36 -14
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.0.
|
|
15
|
+
# 输出: 1.0.7
|
|
16
16
|
|
|
17
17
|
# 运行
|
|
18
18
|
exflower-license-server
|
|
@@ -145,9 +145,9 @@ sudo systemctl enable --now exflower-license-server
|
|
|
145
145
|
启动后访问 `http://localhost:4001/admin`,使用 `ADMIN_TOKEN` 登录。
|
|
146
146
|
|
|
147
147
|
管理后台功能:
|
|
148
|
-
-
|
|
148
|
+
- **企业管理**:创建公司时可选择有效期(1/3/6 个月、1 年、2 年、永久);列表显示剩余天数(<30 天标黄、过期标红);支持编辑(改有效期/配额)、暂停/恢复、删除
|
|
149
149
|
- **实例管理**:查看已激活的实例列表
|
|
150
|
-
-
|
|
150
|
+
- **模板管理**:按类型选择上传方式(见下方"模板系统"),支持按公司分发,查看导入统计(次数/公司/实例)
|
|
151
151
|
|
|
152
152
|
## API 端点
|
|
153
153
|
|
|
@@ -177,17 +177,26 @@ sudo systemctl enable --now exflower-license-server
|
|
|
177
177
|
|
|
178
178
|
## 模板系统
|
|
179
179
|
|
|
180
|
-
模板用于向客户端分发预设配置。管理后台支持创建 `agent`、`
|
|
180
|
+
模板用于向客户端分发预设配置。管理后台支持创建 `agent`、`excard`、`workflow`、`doc` 四种类型的模板。
|
|
181
|
+
|
|
182
|
+
### 上传方式(v1.0.7 起)
|
|
183
|
+
|
|
184
|
+
管理后台根据类型提供不同的上传方式:
|
|
185
|
+
|
|
186
|
+
- **agent / excard / doc**:**Markdown 原生上传**。直接上传客户端导出的 `.md` 文件(或粘贴 Markdown 文本),后台自动解析 frontmatter 预填模板名称并生成 `content_json`,无需手写 JSON。
|
|
187
|
+
- **workflow**:仍以 JSON 描述步骤结构(步骤间的 excardId 引用和分支跳转无法由 Markdown 表达),上传框预填骨架示例。
|
|
188
|
+
|
|
189
|
+
通过 API 创建模板时,`content_json` 与 `content_md` 至少提供一个(`workflow` 类型必须提供 `content_json`)。
|
|
181
190
|
|
|
182
191
|
### 通用字段
|
|
183
192
|
|
|
184
193
|
| 字段 | 必填 | 说明 |
|
|
185
194
|
|------|------|------|
|
|
186
|
-
| `type` | 是 | 模板类型:`agent` / `
|
|
195
|
+
| `type` | 是 | 模板类型:`agent` / `excard` / `workflow` / `doc` |
|
|
187
196
|
| `name` | 是 | 模板名称 |
|
|
188
197
|
| `description` | 否 | 模板简介(一句话描述) |
|
|
189
|
-
| `content_json` |
|
|
190
|
-
| `content_md` |
|
|
198
|
+
| `content_json` | 视类型 | 结构化数据(JSON 对象);`workflow` 必填,其余类型可由 `content_md` 代替 |
|
|
199
|
+
| `content_md` | 视类型 | Markdown 内容;`agent` / `excard` / `doc` 推荐以此为主 |
|
|
191
200
|
| `company_id` | 否 | `NULL` = 全局模板(所有公司可见);指定公司 ID = 仅该公司可见 |
|
|
192
201
|
| `tags` | 否 | 标签数组 JSON,如 `["营销", "运营"]` |
|
|
193
202
|
| `version` | 否 | 版本号,默认 `1.0` |
|
|
@@ -196,9 +205,9 @@ sudo systemctl enable --now exflower-license-server
|
|
|
196
205
|
|
|
197
206
|
#### `agent` — Agent 成员模板
|
|
198
207
|
|
|
199
|
-
用于创建 CrewAI Agent
|
|
208
|
+
用于创建 CrewAI Agent。**推荐直接上传客户端 agent 的 `.md` 文件**(YAML frontmatter + Backstory/Experience 正文),frontmatter 字段即 `content_json`。
|
|
200
209
|
|
|
201
|
-
**content_json 字段:**
|
|
210
|
+
**frontmatter / content_json 字段:**
|
|
202
211
|
|
|
203
212
|
| 字段 | 必填 | 类型 | 说明 |
|
|
204
213
|
|------|------|------|------|
|
|
@@ -217,6 +226,18 @@ sudo systemctl enable --now exflower-license-server
|
|
|
217
226
|
**content_md 格式(推荐提供):**
|
|
218
227
|
|
|
219
228
|
```markdown
|
|
229
|
+
---
|
|
230
|
+
id: agent-xxx
|
|
231
|
+
name: 小红书运营专家
|
|
232
|
+
role: 小红书内容运营与增长策略师
|
|
233
|
+
goal: 帮助用户制定并执行高效的小红书内容策略
|
|
234
|
+
tools: []
|
|
235
|
+
allowDelegation: false
|
|
236
|
+
maxIter: 25
|
|
237
|
+
memory: true
|
|
238
|
+
allowScriptExecution: disabled
|
|
239
|
+
---
|
|
240
|
+
|
|
220
241
|
# Backstory
|
|
221
242
|
|
|
222
243
|
Agent 的完整背景故事,支持 Markdown 格式...
|
|
@@ -231,33 +252,11 @@ Agent 的完整背景故事,支持 Markdown 格式...
|
|
|
231
252
|
|
|
232
253
|
---
|
|
233
254
|
|
|
234
|
-
#### `crew` — 项目组模板
|
|
235
|
-
|
|
236
|
-
用于创建 Crew(Agent 团队)。
|
|
237
|
-
|
|
238
|
-
**content_json 字段:**
|
|
239
|
-
|
|
240
|
-
| 字段 | 必填 | 类型 | 说明 |
|
|
241
|
-
|------|------|------|------|
|
|
242
|
-
| `name` | 是 | string | 项目组名称 |
|
|
243
|
-
| `description` | 否 | string | 项目组描述 |
|
|
244
|
-
| `agentIds` | 否 | string[] | 成员 Agent ID 列表 |
|
|
245
|
-
| `tasks` | 否 | object[] | 任务列表,每个任务包含: |
|
|
246
|
-
| `tasks[].id` | 是 | string | 任务 ID |
|
|
247
|
-
| `tasks[].agentId` | 是 | string | 执行任务的 Agent ID |
|
|
248
|
-
| `tasks[].description` | 是 | string | 任务描述 |
|
|
249
|
-
| `tasks[].order` | 否 | number | 执行顺序 |
|
|
250
|
-
| `tasks[].expectedOutput` | 否 | string | 期望输出 |
|
|
251
|
-
| `process` | 否 | string | 执行流程:`sequential`(顺序)/ `hierarchical`(层级),默认 `sequential` |
|
|
252
|
-
| `managerAgentId` | 否 | string | Manager Agent ID(层级流程时必填) |
|
|
253
|
-
|
|
254
|
-
---
|
|
255
|
-
|
|
256
255
|
#### `excard` — ExCard 执行模板
|
|
257
256
|
|
|
258
|
-
用于创建工作流执行的 ExCard
|
|
257
|
+
用于创建工作流执行的 ExCard 模板。**推荐直接上传客户端导出的标准 ExCard `.md` 文件**(与客户端"导出 ExCard"产物格式一致):模板只需存 `content_md`,客户端导入时走标准 MD 导入链路(`POST /api/excards/import`),由客户端解析为结构化数据。frontmatter 会被后台自动提取为 `content_json` 用于列表展示。
|
|
259
258
|
|
|
260
|
-
**content_json 字段:**
|
|
259
|
+
**frontmatter / content_json 字段:**
|
|
261
260
|
|
|
262
261
|
| 字段 | 必填 | 类型 | 说明 |
|
|
263
262
|
|------|------|------|------|
|
|
@@ -271,7 +270,7 @@ Agent 的完整背景故事,支持 Markdown 格式...
|
|
|
271
270
|
|
|
272
271
|
**content_md 格式(必须提供):**
|
|
273
272
|
|
|
274
|
-
ExCard 的完整 Markdown
|
|
273
|
+
ExCard 的完整 Markdown 文档(frontmatter + 资源依赖 + 执行步骤 + 执行约定),与客户端 `~/.exflower/excards-md/` 下的文件格式一致。
|
|
275
274
|
|
|
276
275
|
---
|
|
277
276
|
|
package/admin/index.html
CHANGED
|
@@ -156,6 +156,7 @@
|
|
|
156
156
|
.template-type-crew { background: #f3e5f5; color: #6a1b9a; }
|
|
157
157
|
.template-type-excard { background: #e8f5e9; color: #2e7d32; }
|
|
158
158
|
.template-type-workflow { background: #fff3e0; color: #ef6c00; }
|
|
159
|
+
.template-type-doc { background: #e0f7fa; color: #00838f; }
|
|
159
160
|
textarea {
|
|
160
161
|
width: 100%;
|
|
161
162
|
padding: 8px 12px;
|
|
@@ -234,6 +235,14 @@
|
|
|
234
235
|
<input type="number" name="max_instances" placeholder="实例数" value="1" id="new-instances">
|
|
235
236
|
<input type="number" name="max_agents" placeholder="Agent数" value="5" id="new-agents">
|
|
236
237
|
<input type="number" name="max_crews" placeholder="Crew数" value="3" id="new-crews">
|
|
238
|
+
<select id="new-validity">
|
|
239
|
+
<option value="1">1 个月</option>
|
|
240
|
+
<option value="3">3 个月</option>
|
|
241
|
+
<option value="6">6 个月</option>
|
|
242
|
+
<option value="12" selected>1 年</option>
|
|
243
|
+
<option value="24">2 年</option>
|
|
244
|
+
<option value="0">永久</option>
|
|
245
|
+
</select>
|
|
237
246
|
<button class="btn btn-primary" onclick="createCompany()">创建</button>
|
|
238
247
|
</div>
|
|
239
248
|
|
|
@@ -294,6 +303,50 @@
|
|
|
294
303
|
</div>
|
|
295
304
|
</div>
|
|
296
305
|
|
|
306
|
+
<div class="modal-overlay" id="company-edit-modal">
|
|
307
|
+
<div class="modal">
|
|
308
|
+
<h3>编辑公司授权</h3>
|
|
309
|
+
<input type="hidden" id="ce-id">
|
|
310
|
+
<div class="form-group">
|
|
311
|
+
<label>公司</label>
|
|
312
|
+
<input type="text" id="ce-name" disabled>
|
|
313
|
+
</div>
|
|
314
|
+
<div class="form-group">
|
|
315
|
+
<label>有效期</label>
|
|
316
|
+
<select id="ce-validity" onchange="document.getElementById('ce-expires').disabled = (this.value !== 'custom')">
|
|
317
|
+
<option value="1">顺延 1 个月</option>
|
|
318
|
+
<option value="3">顺延 3 个月</option>
|
|
319
|
+
<option value="6">顺延 6 个月</option>
|
|
320
|
+
<option value="12">顺延 1 年</option>
|
|
321
|
+
<option value="24">顺延 2 年</option>
|
|
322
|
+
<option value="permanent">永久</option>
|
|
323
|
+
<option value="custom">指定日期</option>
|
|
324
|
+
<option value="keep">不修改</option>
|
|
325
|
+
</select>
|
|
326
|
+
</div>
|
|
327
|
+
<div class="form-group">
|
|
328
|
+
<label>到期日期(仅"指定日期"时可用)</label>
|
|
329
|
+
<input type="date" id="ce-expires" disabled>
|
|
330
|
+
</div>
|
|
331
|
+
<div class="form-group">
|
|
332
|
+
<label>最大实例数</label>
|
|
333
|
+
<input type="number" id="ce-instances" min="1">
|
|
334
|
+
</div>
|
|
335
|
+
<div class="form-group">
|
|
336
|
+
<label>最大 Agent 数</label>
|
|
337
|
+
<input type="number" id="ce-agents" min="1">
|
|
338
|
+
</div>
|
|
339
|
+
<div class="form-group">
|
|
340
|
+
<label>最大 Crew 数</label>
|
|
341
|
+
<input type="number" id="ce-crews" min="1">
|
|
342
|
+
</div>
|
|
343
|
+
<div class="modal-actions">
|
|
344
|
+
<button class="btn" onclick="document.getElementById('company-edit-modal').classList.remove('show')">取消</button>
|
|
345
|
+
<button class="btn btn-primary" onclick="saveCompanyEdit()">保存</button>
|
|
346
|
+
</div>
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
349
|
+
|
|
297
350
|
<div class="modal-overlay" id="import-detail-modal">
|
|
298
351
|
<div class="modal">
|
|
299
352
|
<h3 id="import-detail-title">导入记录</h3>
|
|
@@ -320,9 +373,10 @@
|
|
|
320
373
|
<input type="hidden" id="tmpl-id">
|
|
321
374
|
<div class="form-group">
|
|
322
375
|
<label>类型</label>
|
|
323
|
-
<select id="tmpl-type">
|
|
376
|
+
<select id="tmpl-type" onchange="onTemplateTypeChange()">
|
|
324
377
|
<option value="agent">Agent</option>
|
|
325
378
|
<option value="excard">ExCard</option>
|
|
379
|
+
<option value="doc">文档</option>
|
|
326
380
|
<option value="workflow">Workflow</option>
|
|
327
381
|
</select>
|
|
328
382
|
</div>
|
|
@@ -344,13 +398,17 @@
|
|
|
344
398
|
<option value="">全局模板</option>
|
|
345
399
|
</select>
|
|
346
400
|
</div>
|
|
347
|
-
<div class="form-group">
|
|
348
|
-
<label
|
|
349
|
-
<
|
|
401
|
+
<div class="form-group" id="tmpl-md-file-group">
|
|
402
|
+
<label>Markdown 文件(Agent / ExCard / 文档直接上传客户端导出的 .md)</label>
|
|
403
|
+
<input type="file" id="tmpl-md-file" accept=".md,.markdown,text/markdown,text/plain" onchange="onTemplateFileSelected(event)">
|
|
350
404
|
</div>
|
|
351
|
-
<div class="form-group">
|
|
352
|
-
<label
|
|
353
|
-
<textarea id="tmpl-
|
|
405
|
+
<div class="form-group" id="tmpl-json-group">
|
|
406
|
+
<label>内容 (JSON) <span style="color:#86868b" id="tmpl-json-hint"></span></label>
|
|
407
|
+
<textarea id="tmpl-json" rows="8"></textarea>
|
|
408
|
+
</div>
|
|
409
|
+
<div class="form-group" id="tmpl-md-group">
|
|
410
|
+
<label id="tmpl-md-label">Markdown 内容</label>
|
|
411
|
+
<textarea id="tmpl-md" rows="10" oninput="onTemplateMdInput()"></textarea>
|
|
354
412
|
</div>
|
|
355
413
|
<div class="modal-actions">
|
|
356
414
|
<button class="btn" onclick="closeTemplateModal()">取消</button>
|
|
@@ -391,26 +449,43 @@
|
|
|
391
449
|
document.getElementById('stat-instances').textContent = res.active_instances || 0;
|
|
392
450
|
}
|
|
393
451
|
|
|
452
|
+
let companiesCache = [];
|
|
453
|
+
|
|
394
454
|
async function loadCompanies() {
|
|
395
455
|
const res = await api('GET', '/api/admin/companies');
|
|
456
|
+
companiesCache = res.companies || [];
|
|
396
457
|
const tbody = document.getElementById('companies-body');
|
|
397
458
|
tbody.innerHTML = '';
|
|
398
459
|
|
|
399
|
-
for (const c of
|
|
460
|
+
for (const c of companiesCache) {
|
|
400
461
|
const tr = document.createElement('tr');
|
|
401
462
|
const statusClass = c.status === 'active' ? 'badge-active' : c.status === 'suspended' ? 'badge-suspended' : 'badge-expired';
|
|
402
463
|
const expired = c.expires_at && new Date(c.expires_at) < new Date();
|
|
403
464
|
const finalStatus = expired ? 'expired' : c.status;
|
|
404
465
|
const finalClass = expired ? 'badge-expired' : statusClass;
|
|
405
466
|
|
|
467
|
+
let expiresHtml = '永久';
|
|
468
|
+
if (c.expires_at) {
|
|
469
|
+
const dateStr = c.expires_at.split('T')[0];
|
|
470
|
+
const daysLeft = Math.ceil((new Date(c.expires_at) - Date.now()) / 86400000);
|
|
471
|
+
if (daysLeft < 0) {
|
|
472
|
+
expiresHtml = `${dateStr} <span style="color:#ff3b30">已过期</span>`;
|
|
473
|
+
} else if (daysLeft < 30) {
|
|
474
|
+
expiresHtml = `${dateStr} <span style="color:#ff9500">剩 ${daysLeft} 天</span>`;
|
|
475
|
+
} else {
|
|
476
|
+
expiresHtml = `${dateStr} <span style="color:#86868b">剩 ${daysLeft} 天</span>`;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
406
480
|
tr.innerHTML = `
|
|
407
481
|
<td><strong>${c.name}</strong></td>
|
|
408
482
|
<td><span class="license-key" onclick="navigator.clipboard.writeText('${c.license_key}');toast('已复制')">${c.license_key}</span></td>
|
|
409
483
|
<td>${c.max_instances}实例 / ${c.max_agents}Agent / ${c.max_crews}Crew</td>
|
|
410
484
|
<td><span class="badge ${finalClass}">${finalStatus}</span></td>
|
|
411
|
-
<td>${
|
|
485
|
+
<td>${expiresHtml}</td>
|
|
412
486
|
<td>${c.active_instances || 0}</td>
|
|
413
487
|
<td>
|
|
488
|
+
<button class="btn btn-small btn-primary" onclick="openCompanyEdit('${c.id}')">编辑</button>
|
|
414
489
|
<button class="btn btn-small ${c.status === 'active' ? 'btn-danger' : 'btn-primary'}" onclick="toggleStatus('${c.id}', '${c.status}')">
|
|
415
490
|
${c.status === 'active' ? '暂停' : '恢复'}
|
|
416
491
|
</button>
|
|
@@ -448,12 +523,20 @@
|
|
|
448
523
|
const name = document.getElementById('new-name').value.trim();
|
|
449
524
|
if (!name) return toast('请输入公司名称');
|
|
450
525
|
|
|
526
|
+
const months = parseInt(document.getElementById('new-validity').value);
|
|
527
|
+
let expiresAt = null; // 永久
|
|
528
|
+
if (months > 0) {
|
|
529
|
+
const d = new Date();
|
|
530
|
+
d.setMonth(d.getMonth() + months);
|
|
531
|
+
expiresAt = d.toISOString().slice(0, 10);
|
|
532
|
+
}
|
|
533
|
+
|
|
451
534
|
await api('POST', '/api/admin/companies', {
|
|
452
535
|
name,
|
|
453
536
|
max_instances: parseInt(document.getElementById('new-instances').value) || 1,
|
|
454
537
|
max_agents: parseInt(document.getElementById('new-agents').value) || 5,
|
|
455
538
|
max_crews: parseInt(document.getElementById('new-crews').value) || 3,
|
|
456
|
-
expires_at:
|
|
539
|
+
expires_at: expiresAt
|
|
457
540
|
});
|
|
458
541
|
|
|
459
542
|
document.getElementById('new-name').value = '';
|
|
@@ -462,6 +545,56 @@
|
|
|
462
545
|
loadStats();
|
|
463
546
|
}
|
|
464
547
|
|
|
548
|
+
function openCompanyEdit(id) {
|
|
549
|
+
const c = companiesCache.find(x => x.id === id);
|
|
550
|
+
if (!c) return toast('公司未找到');
|
|
551
|
+
document.getElementById('ce-id').value = c.id;
|
|
552
|
+
document.getElementById('ce-name').value = c.name;
|
|
553
|
+
document.getElementById('ce-validity').value = 'keep';
|
|
554
|
+
document.getElementById('ce-expires').value = c.expires_at ? c.expires_at.split('T')[0] : '';
|
|
555
|
+
document.getElementById('ce-expires').disabled = true;
|
|
556
|
+
document.getElementById('ce-instances').value = c.max_instances;
|
|
557
|
+
document.getElementById('ce-agents').value = c.max_agents;
|
|
558
|
+
document.getElementById('ce-crews').value = c.max_crews;
|
|
559
|
+
document.getElementById('company-edit-modal').classList.add('show');
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function saveCompanyEdit() {
|
|
563
|
+
const id = document.getElementById('ce-id').value;
|
|
564
|
+
const c = companiesCache.find(x => x.id === id);
|
|
565
|
+
const validity = document.getElementById('ce-validity').value;
|
|
566
|
+
|
|
567
|
+
const payload = {
|
|
568
|
+
max_instances: parseInt(document.getElementById('ce-instances').value) || 1,
|
|
569
|
+
max_agents: parseInt(document.getElementById('ce-agents').value) || 1,
|
|
570
|
+
max_crews: parseInt(document.getElementById('ce-crews').value) || 1,
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
if (validity === 'permanent') {
|
|
574
|
+
payload.expires_at = null;
|
|
575
|
+
} else if (validity === 'custom') {
|
|
576
|
+
const d = document.getElementById('ce-expires').value;
|
|
577
|
+
if (!d) return toast('请选择到期日期');
|
|
578
|
+
payload.expires_at = d;
|
|
579
|
+
} else if (validity !== 'keep') {
|
|
580
|
+
// 顺延:从当前到期日(未过期)或今天(已过期/永久)起算
|
|
581
|
+
const months = parseInt(validity);
|
|
582
|
+
const now = new Date();
|
|
583
|
+
let base = now;
|
|
584
|
+
if (c && c.expires_at) {
|
|
585
|
+
const cur = new Date(c.expires_at);
|
|
586
|
+
if (cur > now) base = cur;
|
|
587
|
+
}
|
|
588
|
+
base.setMonth(base.getMonth() + months);
|
|
589
|
+
payload.expires_at = base.toISOString().slice(0, 10);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
await api('PATCH', `/api/admin/companies/${id}`, payload);
|
|
593
|
+
document.getElementById('company-edit-modal').classList.remove('show');
|
|
594
|
+
toast('已保存');
|
|
595
|
+
loadCompanies();
|
|
596
|
+
}
|
|
597
|
+
|
|
465
598
|
async function toggleStatus(id, current) {
|
|
466
599
|
const next = current === 'active' ? 'suspended' : 'active';
|
|
467
600
|
await api('PATCH', `/api/admin/companies/${id}`, { status: next });
|
|
@@ -537,6 +670,83 @@
|
|
|
537
670
|
}
|
|
538
671
|
}
|
|
539
672
|
|
|
673
|
+
// ── Template type-driven upload ──
|
|
674
|
+
const MD_TYPES = ['agent', 'excard', 'doc'];
|
|
675
|
+
const WORKFLOW_SKELETON = `{
|
|
676
|
+
"title": "工作流程名称",
|
|
677
|
+
"description": "",
|
|
678
|
+
"type": "one-time",
|
|
679
|
+
"agent": "",
|
|
680
|
+
"steps": [
|
|
681
|
+
{
|
|
682
|
+
"stepType": "task",
|
|
683
|
+
"title": "步骤1",
|
|
684
|
+
"description": "",
|
|
685
|
+
"agent": "",
|
|
686
|
+
"excardId": "<excard模板ID>",
|
|
687
|
+
"transitions": { "pass": "next", "fail": "end" }
|
|
688
|
+
}
|
|
689
|
+
]
|
|
690
|
+
}`;
|
|
691
|
+
|
|
692
|
+
function onTemplateTypeChange() {
|
|
693
|
+
const type = document.getElementById('tmpl-type').value;
|
|
694
|
+
const isMd = MD_TYPES.includes(type);
|
|
695
|
+
document.getElementById('tmpl-json-group').classList.toggle('hidden', isMd);
|
|
696
|
+
document.getElementById('tmpl-md-file-group').classList.toggle('hidden', !isMd);
|
|
697
|
+
document.getElementById('tmpl-md-label').textContent = isMd
|
|
698
|
+
? 'Markdown 内容(上传文件后可编辑)'
|
|
699
|
+
: 'Markdown 内容 (可选)';
|
|
700
|
+
if (type === 'workflow') {
|
|
701
|
+
const jsonEl = document.getElementById('tmpl-json');
|
|
702
|
+
jsonEl.placeholder = WORKFLOW_SKELETON;
|
|
703
|
+
document.getElementById('tmpl-json-hint').textContent = '步骤结构,excardId 引用模板库中的 ExCard 模板 ID';
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function parseFrontmatter(md) {
|
|
708
|
+
if (!md || !md.startsWith('---')) return { frontmatter: {}, body: md || '' };
|
|
709
|
+
const end = md.indexOf('\n---', 3);
|
|
710
|
+
if (end === -1) return { frontmatter: {}, body: md };
|
|
711
|
+
const fmText = md.slice(3, end).trim();
|
|
712
|
+
const body = md.slice(end + 4).trim();
|
|
713
|
+
const frontmatter = {};
|
|
714
|
+
for (const line of fmText.split('\n')) {
|
|
715
|
+
const m = line.match(/^([A-Za-z_][\w]*)\s*:\s*(.*)$/);
|
|
716
|
+
if (!m) continue;
|
|
717
|
+
let val = m[2].trim();
|
|
718
|
+
if (/^[[{]/.test(val) || /^(true|false|null|-?\d+(\.\d+)?)$/.test(val)) {
|
|
719
|
+
try { val = JSON.parse(val); } catch { /* keep as string */ }
|
|
720
|
+
}
|
|
721
|
+
frontmatter[m[1]] = val;
|
|
722
|
+
}
|
|
723
|
+
return { frontmatter, body };
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function onTemplateFileSelected(event) {
|
|
727
|
+
const file = event.target.files?.[0];
|
|
728
|
+
if (!file) return;
|
|
729
|
+
file.text().then(text => {
|
|
730
|
+
document.getElementById('tmpl-md').value = text;
|
|
731
|
+
onTemplateMdInput();
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function onTemplateMdInput() {
|
|
736
|
+
const type = document.getElementById('tmpl-type').value;
|
|
737
|
+
if (!MD_TYPES.includes(type)) return;
|
|
738
|
+
const md = document.getElementById('tmpl-md').value;
|
|
739
|
+
const nameEl = document.getElementById('tmpl-name');
|
|
740
|
+
if (nameEl.value.trim()) return; // 不覆盖管理员手填的名称
|
|
741
|
+
const { frontmatter, body } = parseFrontmatter(md);
|
|
742
|
+
let auto = typeof frontmatter.name === 'string' ? frontmatter.name : '';
|
|
743
|
+
if (!auto) {
|
|
744
|
+
const h1 = body.match(/^#\s+(.+)$/m);
|
|
745
|
+
if (h1) auto = h1[1].trim();
|
|
746
|
+
}
|
|
747
|
+
if (auto) nameEl.value = auto;
|
|
748
|
+
}
|
|
749
|
+
|
|
540
750
|
function openTemplateModal() {
|
|
541
751
|
document.getElementById('modal-title').textContent = '上传模板';
|
|
542
752
|
document.getElementById('tmpl-id').value = '';
|
|
@@ -546,7 +756,9 @@
|
|
|
546
756
|
document.getElementById('tmpl-version').value = '';
|
|
547
757
|
document.getElementById('tmpl-json').value = '';
|
|
548
758
|
document.getElementById('tmpl-md').value = '';
|
|
759
|
+
document.getElementById('tmpl-md-file').value = '';
|
|
549
760
|
populateCompanySelect();
|
|
761
|
+
onTemplateTypeChange();
|
|
550
762
|
document.getElementById('template-modal').classList.add('show');
|
|
551
763
|
}
|
|
552
764
|
|
|
@@ -575,6 +787,8 @@
|
|
|
575
787
|
}
|
|
576
788
|
document.getElementById('tmpl-json').value = jsonStr;
|
|
577
789
|
document.getElementById('tmpl-md').value = t.content_md || '';
|
|
790
|
+
document.getElementById('tmpl-md-file').value = '';
|
|
791
|
+
onTemplateTypeChange();
|
|
578
792
|
document.getElementById('template-modal').classList.add('show');
|
|
579
793
|
}
|
|
580
794
|
|
|
@@ -591,16 +805,29 @@
|
|
|
591
805
|
const version = document.getElementById('tmpl-version').value.trim() || undefined;
|
|
592
806
|
const companyId = document.getElementById('tmpl-company').value || undefined;
|
|
593
807
|
const jsonRaw = document.getElementById('tmpl-json').value.trim();
|
|
594
|
-
const md = document.getElementById('tmpl-md').value.trim()
|
|
808
|
+
const md = document.getElementById('tmpl-md').value.trim();
|
|
595
809
|
|
|
596
810
|
if (!name) return toast('请输入模板名称');
|
|
597
|
-
if (!jsonRaw) return toast('请输入 JSON 内容');
|
|
598
811
|
|
|
599
|
-
let contentJson;
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
812
|
+
let contentJson, contentMd;
|
|
813
|
+
if (type === 'workflow') {
|
|
814
|
+
if (!jsonRaw) return toast('请输入 JSON 内容');
|
|
815
|
+
try {
|
|
816
|
+
contentJson = JSON.parse(jsonRaw);
|
|
817
|
+
} catch (e) {
|
|
818
|
+
return toast('JSON 格式错误: ' + e.message);
|
|
819
|
+
}
|
|
820
|
+
contentMd = md || undefined;
|
|
821
|
+
} else {
|
|
822
|
+
// agent / excard / doc:Markdown 原生上传
|
|
823
|
+
if (!md) return toast('请上传或粘贴 Markdown 内容');
|
|
824
|
+
const { frontmatter } = parseFrontmatter(md);
|
|
825
|
+
if (type === 'agent' || type === 'excard') {
|
|
826
|
+
contentJson = frontmatter; // 客户端导入时从 frontmatter 读取字段
|
|
827
|
+
} else {
|
|
828
|
+
contentJson = { assets: [] };
|
|
829
|
+
}
|
|
830
|
+
contentMd = md;
|
|
604
831
|
}
|
|
605
832
|
|
|
606
833
|
if (id) {
|
|
@@ -610,7 +837,7 @@
|
|
|
610
837
|
};
|
|
611
838
|
if (version) payload.version = version;
|
|
612
839
|
if (companyId !== undefined) payload.company_id = companyId;
|
|
613
|
-
if (
|
|
840
|
+
if (contentMd !== undefined) payload.content_md = contentMd;
|
|
614
841
|
await api('PATCH', `/api/admin/templates/${id}`, payload);
|
|
615
842
|
closeTemplateModal();
|
|
616
843
|
toast('模板更新成功');
|
|
@@ -623,7 +850,7 @@
|
|
|
623
850
|
description: desc,
|
|
624
851
|
version,
|
|
625
852
|
content_json: contentJson,
|
|
626
|
-
content_md:
|
|
853
|
+
content_md: contentMd,
|
|
627
854
|
});
|
|
628
855
|
closeTemplateModal();
|
|
629
856
|
toast('模板上传成功');
|
package/package.json
CHANGED
package/routes.js
CHANGED
|
@@ -170,9 +170,10 @@ function setupRoutes(app) {
|
|
|
170
170
|
const key = generateLicenseKey();
|
|
171
171
|
const id = generateId();
|
|
172
172
|
|
|
173
|
+
// expires_at 显式传 null = 永久授权;不传则默认 1 年
|
|
173
174
|
const defaultExpires = new Date();
|
|
174
175
|
defaultExpires.setFullYear(defaultExpires.getFullYear() + 1);
|
|
175
|
-
const expires = expires_at || defaultExpires.toISOString().slice(0, 10);
|
|
176
|
+
const expires = expires_at === null ? null : (expires_at || defaultExpires.toISOString().slice(0, 10));
|
|
176
177
|
|
|
177
178
|
queryRun(
|
|
178
179
|
`INSERT INTO companies (id, name, license_key, max_instances, max_agents, max_crews, expires_at, status)
|
|
@@ -266,11 +267,18 @@ function setupRoutes(app) {
|
|
|
266
267
|
return res.status(404).json({ success: false, message: 'Invalid license key' });
|
|
267
268
|
}
|
|
268
269
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
270
|
+
// 按设备过滤:导入状态跟着设备(instance_id)走,而非激活码;
|
|
271
|
+
// 未传 instance_id 的旧版客户端保持 company 级聚合(向后兼容)
|
|
272
|
+
const instanceId = req.query.instance_id;
|
|
273
|
+
let sql = `SELECT template_id, COUNT(*) as count, MIN(imported_at) as first_import, MAX(imported_at) as last_import
|
|
274
|
+
FROM template_imports WHERE company_id = ?`;
|
|
275
|
+
const params = [company.id];
|
|
276
|
+
if (instanceId) {
|
|
277
|
+
sql += ' AND instance_id = ?';
|
|
278
|
+
params.push(instanceId);
|
|
279
|
+
}
|
|
280
|
+
sql += ' GROUP BY template_id';
|
|
281
|
+
const rows = queryAll(sql, params);
|
|
274
282
|
const map = {};
|
|
275
283
|
for (const r of rows) {
|
|
276
284
|
map[r.template_id] = { count: r.count, first_import: r.first_import, last_import: r.last_import };
|
|
@@ -358,12 +366,19 @@ function setupRoutes(app) {
|
|
|
358
366
|
app.post('/api/admin/templates', requireAdmin, (req, res) => {
|
|
359
367
|
const { company_id, type, name, description, content_json, content_md, tags, version } = req.body;
|
|
360
368
|
|
|
361
|
-
if (!type || !name
|
|
362
|
-
return res.status(400).json({ success: false, message: 'type
|
|
369
|
+
if (!type || !name) {
|
|
370
|
+
return res.status(400).json({ success: false, message: 'type and name are required' });
|
|
363
371
|
}
|
|
364
372
|
if (!['agent', 'excard', 'workflow', 'doc'].includes(type)) {
|
|
365
373
|
return res.status(400).json({ success: false, message: 'type must be agent, excard, workflow, or doc' });
|
|
366
374
|
}
|
|
375
|
+
// workflow 必须以 content_json 描述步骤结构;其余类型可用 content_md 原生上传
|
|
376
|
+
if (type === 'workflow' && !content_json) {
|
|
377
|
+
return res.status(400).json({ success: false, message: 'workflow templates require content_json' });
|
|
378
|
+
}
|
|
379
|
+
if (!content_json && !content_md) {
|
|
380
|
+
return res.status(400).json({ success: false, message: 'content_json or content_md is required' });
|
|
381
|
+
}
|
|
367
382
|
|
|
368
383
|
// If company_id is provided, verify it exists
|
|
369
384
|
if (company_id) {
|
|
@@ -397,7 +412,9 @@ function setupRoutes(app) {
|
|
|
397
412
|
type,
|
|
398
413
|
name,
|
|
399
414
|
description || '',
|
|
400
|
-
|
|
415
|
+
content_json
|
|
416
|
+
? (typeof content_json === 'string' ? content_json : JSON.stringify(content_json))
|
|
417
|
+
: '{}',
|
|
401
418
|
content_md || null,
|
|
402
419
|
tags ? JSON.stringify(tags) : '[]',
|
|
403
420
|
version || '1.0',
|
|
@@ -501,11 +518,16 @@ function setupRoutes(app) {
|
|
|
501
518
|
return res.status(404).json({ success: false, message: 'Invalid license key' });
|
|
502
519
|
}
|
|
503
520
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
521
|
+
// 按设备过滤(见 import-summary 注释)
|
|
522
|
+
const instanceId = req.query.instance_id;
|
|
523
|
+
let sql = `SELECT COUNT(*) as count, MIN(imported_at) as first_import, MAX(imported_at) as last_import
|
|
524
|
+
FROM template_imports WHERE template_id = ? AND company_id = ?`;
|
|
525
|
+
const params = [req.params.id, company.id];
|
|
526
|
+
if (instanceId) {
|
|
527
|
+
sql += ' AND instance_id = ?';
|
|
528
|
+
params.push(instanceId);
|
|
529
|
+
}
|
|
530
|
+
const stats = queryGet(sql, params);
|
|
509
531
|
res.json({ success: true, imported: stats.count > 0, count: stats.count, first_import: stats.first_import, last_import: stats.last_import });
|
|
510
532
|
});
|
|
511
533
|
|