exflower-license-server 1.0.6 → 1.0.7

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.
Files changed (3) hide show
  1. package/admin/index.html +246 -19
  2. package/package.json +1 -1
  3. package/routes.js +14 -4
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.7",
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)
@@ -358,12 +359,19 @@ function setupRoutes(app) {
358
359
  app.post('/api/admin/templates', requireAdmin, (req, res) => {
359
360
  const { company_id, type, name, description, content_json, content_md, tags, version } = req.body;
360
361
 
361
- if (!type || !name || !content_json) {
362
- return res.status(400).json({ success: false, message: 'type, name, content_json are required' });
362
+ if (!type || !name) {
363
+ return res.status(400).json({ success: false, message: 'type and name are required' });
363
364
  }
364
365
  if (!['agent', 'excard', 'workflow', 'doc'].includes(type)) {
365
366
  return res.status(400).json({ success: false, message: 'type must be agent, excard, workflow, or doc' });
366
367
  }
368
+ // workflow 必须以 content_json 描述步骤结构;其余类型可用 content_md 原生上传
369
+ if (type === 'workflow' && !content_json) {
370
+ return res.status(400).json({ success: false, message: 'workflow templates require content_json' });
371
+ }
372
+ if (!content_json && !content_md) {
373
+ return res.status(400).json({ success: false, message: 'content_json or content_md is required' });
374
+ }
367
375
 
368
376
  // If company_id is provided, verify it exists
369
377
  if (company_id) {
@@ -397,7 +405,9 @@ function setupRoutes(app) {
397
405
  type,
398
406
  name,
399
407
  description || '',
400
- typeof content_json === 'string' ? content_json : JSON.stringify(content_json),
408
+ content_json
409
+ ? (typeof content_json === 'string' ? content_json : JSON.stringify(content_json))
410
+ : '{}',
401
411
  content_md || null,
402
412
  tags ? JSON.stringify(tags) : '[]',
403
413
  version || '1.0',