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 CHANGED
@@ -12,7 +12,7 @@ npm install -g exflower-license-server
12
12
 
13
13
  # 查看版本
14
14
  exflower-license-server --version
15
- # 输出: 1.0.2
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
- - **模板管理**:上传/编辑/删除模板(agent/crew/excard/workflow),支持按公司分发,查看导入统计(次数/公司/实例)
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`、`crew`、`excard`、`workflow`、`doc` 五种类型的模板。
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` / `crew` / `excard` / `workflow` / `doc` |
195
+ | `type` | 是 | 模板类型:`agent` / `excard` / `workflow` / `doc` |
187
196
  | `name` | 是 | 模板名称 |
188
197
  | `description` | 否 | 模板简介(一句话描述) |
189
- | `content_json` | | 结构化数据(JSON 对象),各类型字段见下方 |
190
- | `content_md` | | Markdown 格式说明文档,部分类型必须 |
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>内容 (JSON)</label>
349
- <textarea id="tmpl-json" rows="8" placeholder='{"id": "...", ...}'></textarea>
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>Markdown 内容 (可选,Agent/ExCard 需要)</label>
353
- <textarea id="tmpl-md" rows="6" placeholder="YAML frontmatter + Markdown body"></textarea>
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 res.companies || []) {
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>${c.expires_at ? c.expires_at.split('T')[0] : '永久'}</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: new Date(Date.now() + 365 * 86400000).toISOString()
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() || undefined;
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
- try {
601
- contentJson = JSON.parse(jsonRaw);
602
- } catch (e) {
603
- return toast('JSON 格式错误: ' + e.message);
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 (md !== undefined) payload.content_md = md;
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: md,
853
+ content_md: contentMd,
627
854
  });
628
855
  closeTemplateModal();
629
856
  toast('模板上传成功');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "exflower-license-server",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "ExFlower License Server - 在线授权验证服务",
5
5
  "main": "index.js",
6
6
  "bin": {
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
- const rows = queryAll(
270
- `SELECT template_id, COUNT(*) as count, MIN(imported_at) as first_import, MAX(imported_at) as last_import
271
- FROM template_imports WHERE company_id = ? GROUP BY template_id`,
272
- [company.id]
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 || !content_json) {
362
- return res.status(400).json({ success: false, message: 'type, name, content_json are required' });
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
- typeof content_json === 'string' ? content_json : JSON.stringify(content_json),
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
- const stats = queryGet(
505
- `SELECT COUNT(*) as count, MIN(imported_at) as first_import, MAX(imported_at) as last_import
506
- FROM template_imports WHERE template_id = ? AND company_id = ?`,
507
- [req.params.id, company.id]
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