Commit f3c9b91ae25c0e639cb0d3593b3622be852f0ef1
1 parent
45b2c71d
feat: complete template agent management capabilities
- Added inline editing for template description and type fields - Implemented Delete Template functionality (removes both file system and registry entry) - Added confirmation safeguards for the destructive delete action
Showing
2 changed files
with
168 additions
and
10 deletions
server/routes/company.js
| ... | ... | @@ -47,12 +47,12 @@ router.get('/templates', (req, res) => { |
| 47 | 47 | res.json(companyService.listTemplates()); |
| 48 | 48 | }); |
| 49 | 49 | |
| 50 | -// PATCH /api/company/templates/:templateId — 修改模板元信息 (如名称) | |
| 50 | +// PATCH /api/company/templates/:templateId — 修改模板元信息 (名称, 类型, 描述) | |
| 51 | 51 | router.patch('/templates/:templateId', (req, res) => { |
| 52 | 52 | try { |
| 53 | 53 | const { templateId } = req.params; |
| 54 | - const { name } = req.body; | |
| 55 | - if (!name || !name.trim()) return res.status(400).json({ error: 'name 为必填项' }); | |
| 54 | + const { name, type, description } = req.body; | |
| 55 | + if (name !== undefined && !name.trim()) return res.status(400).json({ error: 'name 为必填项' }); | |
| 56 | 56 | |
| 57 | 57 | const path = require('path'); |
| 58 | 58 | const fs = require('fs'); |
| ... | ... | @@ -63,29 +63,66 @@ router.patch('/templates/:templateId', (req, res) => { |
| 63 | 63 | return res.status(404).json({ error: `模板 "${templateId}" 不存在` }); |
| 64 | 64 | } |
| 65 | 65 | |
| 66 | - // 1. 更新 config.json 中的 name | |
| 66 | + // 1. 更新 config.json 中的字段 | |
| 67 | 67 | const configPath = path.join(templateDir, 'config.json'); |
| 68 | 68 | if (fs.existsSync(configPath)) { |
| 69 | 69 | const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); |
| 70 | - config.name = name.trim(); | |
| 70 | + if (name !== undefined) config.name = name.trim(); | |
| 71 | + if (type !== undefined) config.type = type.trim(); | |
| 72 | + if (description !== undefined) config.description = description.trim(); | |
| 71 | 73 | fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); |
| 72 | 74 | } |
| 73 | 75 | |
| 74 | - // 2. 更新 _registry.json 中的 name | |
| 76 | + // 2. 更新 _registry.json 中的字段 | |
| 75 | 77 | const registryPath = path.join(TEMPLATES_DIR, '_registry.json'); |
| 76 | 78 | if (fs.existsSync(registryPath)) { |
| 77 | 79 | try { |
| 78 | 80 | const registry = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); |
| 79 | 81 | const entry = registry.find(t => t.id === templateId); |
| 80 | 82 | if (entry) { |
| 81 | - entry.name = name.trim(); | |
| 83 | + if (name !== undefined) entry.name = name.trim(); | |
| 84 | + if (type !== undefined) entry.type = type.trim(); | |
| 85 | + if (description !== undefined) entry.description = description.trim(); | |
| 82 | 86 | fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2), 'utf-8'); |
| 83 | 87 | } |
| 84 | 88 | } catch {} |
| 85 | 89 | } |
| 86 | 90 | |
| 87 | - console.log(`[Template] ✏️ 模板 ${templateId} 名称已更新为: ${name.trim()}`); | |
| 88 | - res.json({ success: true, id: templateId, name: name.trim() }); | |
| 91 | + console.log(`[Template] ✏️ 模板 ${templateId} 元信息已更新`); | |
| 92 | + res.json({ success: true, id: templateId, name: name?.trim(), type: type?.trim(), description: description?.trim() }); | |
| 93 | + } catch (err) { | |
| 94 | + res.status(500).json({ error: err.message }); | |
| 95 | + } | |
| 96 | +}); | |
| 97 | + | |
| 98 | +// DELETE /api/company/templates/:templateId — 删除模板 | |
| 99 | +router.delete('/templates/:templateId', (req, res) => { | |
| 100 | + try { | |
| 101 | + const { templateId } = req.params; | |
| 102 | + const path = require('path'); | |
| 103 | + const fs = require('fs'); | |
| 104 | + const TEMPLATES_DIR = path.resolve(__dirname, '../../system/templates'); | |
| 105 | + const templateDir = path.join(TEMPLATES_DIR, templateId); | |
| 106 | + | |
| 107 | + if (!fs.existsSync(templateDir)) { | |
| 108 | + return res.status(404).json({ error: `模板 "${templateId}" 不存在` }); | |
| 109 | + } | |
| 110 | + | |
| 111 | + // 1. 删除模版文件夹 | |
| 112 | + fs.rmSync(templateDir, { recursive: true, force: true }); | |
| 113 | + | |
| 114 | + // 2. 更新 registry | |
| 115 | + const registryPath = path.join(TEMPLATES_DIR, '_registry.json'); | |
| 116 | + if (fs.existsSync(registryPath)) { | |
| 117 | + try { | |
| 118 | + let registry = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); | |
| 119 | + registry = registry.filter(t => t.id !== templateId); | |
| 120 | + fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2), 'utf-8'); | |
| 121 | + } catch {} | |
| 122 | + } | |
| 123 | + | |
| 124 | + console.log(`[Template] 🗑️ 模板 ${templateId} 已被删除`); | |
| 125 | + res.json({ success: true, message: `模板 ${templateId} 已删除` }); | |
| 89 | 126 | } catch (err) { |
| 90 | 127 | res.status(500).json({ error: err.message }); |
| 91 | 128 | } | ... | ... |
web/src/views/TemplateAgents.vue
| ... | ... | @@ -96,8 +96,33 @@ |
| 96 | 96 | </template> |
| 97 | 97 | <span class="template-id-tag">#{{ selectedTemplate.id }}</span> |
| 98 | 98 | </h2> |
| 99 | + <div class="template-desc-edit"> | |
| 100 | + <template v-if="editingDesc"> | |
| 101 | + <textarea v-model="editDescValue" class="desc-edit-input form-textarea" @keyup.esc="cancelEditDesc" placeholder="输入模板描述..."></textarea> | |
| 102 | + <div class="desc-edit-actions"> | |
| 103 | + <button class="name-edit-action save" @click.stop="saveTemplateDesc" title="保存">✓</button> | |
| 104 | + <button class="name-edit-action cancel" @click.stop="cancelEditDesc" title="取消">✕</button> | |
| 105 | + </div> | |
| 106 | + </template> | |
| 107 | + <template v-else> | |
| 108 | + <div class="desc-display" @click="startEditDesc" title="点击修改描述"> | |
| 109 | + {{ selectedTemplate.description || '暂无描述' }} <span class="name-edit-hint">✏️</span> | |
| 110 | + </div> | |
| 111 | + </template> | |
| 112 | + </div> | |
| 99 | 113 | <div class="meta-row"> |
| 100 | - <span class="badge badge-type">{{ selectedTemplate.type }}</span> | |
| 114 | + <template v-if="editingType"> | |
| 115 | + <select ref="typeSelectRef" v-model="editTypeValue" class="type-edit-select form-input" @change="saveTemplateType" @blur="cancelEditType" @keyup.esc="cancelEditType"> | |
| 116 | + <option value="skill">🔧 skill</option> | |
| 117 | + <option value="executor">⚡ executor</option> | |
| 118 | + <option value="specialist">🔬 specialist</option> | |
| 119 | + <option value="cxo">👔 cxo</option> | |
| 120 | + <option value="role">🏢 role</option> | |
| 121 | + </select> | |
| 122 | + </template> | |
| 123 | + <template v-else> | |
| 124 | + <span class="badge badge-type editable-badge" @click="startEditType" title="点击修改类型">{{ selectedTemplate.type }} <span class="name-edit-hint">✏️</span></span> | |
| 125 | + </template> | |
| 101 | 126 | <span class="badge badge-used">被使用: {{ selectedTemplate.usedCount }} 次</span> |
| 102 | 127 | <span v-if="selectedTemplate.benchmarkScore" class="badge badge-score">评分: {{ selectedTemplate.benchmarkScore }}</span> |
| 103 | 128 | <span v-if="selectedTemplate.trainedAt" class="badge badge-time">训练于: {{ selectedTemplate.trainedAt }}</span> |
| ... | ... | @@ -111,6 +136,9 @@ |
| 111 | 136 | </div> |
| 112 | 137 | <!-- Action Buttons --> |
| 113 | 138 | <div class="header-actions"> |
| 139 | + <button class="action-btn action-delete" :disabled="actionStatus !== null" @click="deleteTemplate"> | |
| 140 | + 🗑️ 删除骨架 | |
| 141 | + </button> | |
| 114 | 142 | <button class="action-btn action-upload" :disabled="actionStatus === 'uploading'" @click="$refs.skillFileInput.click()"> |
| 115 | 143 | {{ actionStatus === 'uploading' ? '⏳ 导入中...' : '📥 导入 Skill (ZIP/文件)' }} |
| 116 | 144 | </button> |
| ... | ... | @@ -241,6 +269,13 @@ const editingName = ref(false) |
| 241 | 269 | const editNameValue = ref('') |
| 242 | 270 | const nameInputRef = ref(null) |
| 243 | 271 | |
| 272 | +// Inline type/desc editing | |
| 273 | +const editingType = ref(false) | |
| 274 | +const editTypeValue = ref('') | |
| 275 | +const typeSelectRef = ref(null) | |
| 276 | +const editingDesc = ref(false) | |
| 277 | +const editDescValue = ref('') | |
| 278 | + | |
| 244 | 279 | const fileDirty = computed(() => editFileContent.value !== null && filePreviewContent.value !== null && editFileContent.value !== filePreviewContent.value) |
| 245 | 280 | |
| 246 | 281 | function toggleSortDir() { |
| ... | ... | @@ -426,6 +461,79 @@ async function saveTemplateName() { |
| 426 | 461 | } |
| 427 | 462 | } |
| 428 | 463 | |
| 464 | +// ─── Inline Type / Desc Editing ─── | |
| 465 | + | |
| 466 | +function startEditType() { | |
| 467 | + if (!selectedTemplate.value) return; | |
| 468 | + editTypeValue.value = selectedTemplate.value.type; | |
| 469 | + editingType.value = true; | |
| 470 | + nextTick(() => { typeSelectRef.value?.focus() }); | |
| 471 | +} | |
| 472 | + | |
| 473 | +function cancelEditType() { | |
| 474 | + editingType.value = false; | |
| 475 | +} | |
| 476 | + | |
| 477 | +async function saveTemplateType() { | |
| 478 | + if (!editingType.value) return; | |
| 479 | + const newType = editTypeValue.value.trim(); | |
| 480 | + if (!newType || newType === selectedTemplate.value?.type) { | |
| 481 | + cancelEditType(); | |
| 482 | + return; | |
| 483 | + } | |
| 484 | + try { | |
| 485 | + await axios.patch(`/api/company/templates/${selectedTemplateId.value}`, { type: newType }); | |
| 486 | + const tpl = templates.value.find(t => t.id === selectedTemplateId.value); | |
| 487 | + if (tpl) tpl.type = newType; | |
| 488 | + editingType.value = false; | |
| 489 | + } catch (e) { | |
| 490 | + alert(`修改类型失败: ${e.response?.data?.error || e.message}`); | |
| 491 | + } | |
| 492 | +} | |
| 493 | + | |
| 494 | +function startEditDesc() { | |
| 495 | + if (!selectedTemplate.value) return; | |
| 496 | + editDescValue.value = selectedTemplate.value.description || ''; | |
| 497 | + editingDesc.value = true; | |
| 498 | +} | |
| 499 | + | |
| 500 | +function cancelEditDesc() { | |
| 501 | + editingDesc.value = false; | |
| 502 | +} | |
| 503 | + | |
| 504 | +async function saveTemplateDesc() { | |
| 505 | + if (!editingDesc.value) return; | |
| 506 | + const newDesc = editDescValue.value.trim(); | |
| 507 | + if (newDesc === selectedTemplate.value?.description) { | |
| 508 | + cancelEditDesc(); | |
| 509 | + return; | |
| 510 | + } | |
| 511 | + try { | |
| 512 | + await axios.patch(`/api/company/templates/${selectedTemplateId.value}`, { description: newDesc }); | |
| 513 | + const tpl = templates.value.find(t => t.id === selectedTemplateId.value); | |
| 514 | + if (tpl) tpl.description = newDesc; | |
| 515 | + editingDesc.value = false; | |
| 516 | + } catch (e) { | |
| 517 | + alert(`修改描述失败: ${e.response?.data?.error || e.message}`); | |
| 518 | + } | |
| 519 | +} | |
| 520 | + | |
| 521 | +async function deleteTemplate() { | |
| 522 | + if (!selectedTemplate.value) return; | |
| 523 | + if (!confirm(`⚠️ 确定要彻底删除 Agent 骨架 "${selectedTemplate.value.name || selectedTemplate.value.id}" 吗?此操作将删除所有关单文件,并且无法恢复!`)) return; | |
| 524 | + try { | |
| 525 | + actionStatus.value = 'uploading'; // reuse this state to show loading | |
| 526 | + actionMessage.value = '正在删除模板...'; | |
| 527 | + await axios.delete(`/api/company/templates/${selectedTemplateId.value}`); | |
| 528 | + actionStatus.value = null; | |
| 529 | + selectedTemplateId.value = null; | |
| 530 | + await loadTemplates(); | |
| 531 | + } catch (e) { | |
| 532 | + actionStatus.value = 'error'; | |
| 533 | + actionMessage.value = `删除失败: ${e.response?.data?.error || e.message}`; | |
| 534 | + } | |
| 535 | +} | |
| 536 | + | |
| 429 | 537 | // ─── Agent Creator & Innovator ─── |
| 430 | 538 | |
| 431 | 539 | async function triggerGenerate() { |
| ... | ... | @@ -785,6 +893,19 @@ onMounted(() => { |
| 785 | 893 | .action-upload:hover:not(:disabled) { background: rgba(245,158,11,0.2); box-shadow: 0 3px 10px rgba(245,158,11,0.15); } |
| 786 | 894 | .action-provision { background: rgba(20,184,166,0.1); color: #14b8a6; border-color: rgba(20,184,166,0.2); } |
| 787 | 895 | .action-provision:hover:not(:disabled) { background: rgba(20,184,166,0.2); box-shadow: 0 3px 10px rgba(20,184,166,0.15); } |
| 896 | +.action-delete { background: rgba(239, 68, 68, 0.1); color: var(--red-text, #ef4444); border-color: rgba(239, 68, 68, 0.2); } | |
| 897 | +.action-delete:hover:not(:disabled) { background: rgba(239, 68, 68, 0.2); box-shadow: 0 3px 10px rgba(239, 68, 68, 0.15); } | |
| 898 | + | |
| 899 | +/* Field Editing overrides */ | |
| 900 | +.template-desc-edit { margin-bottom: 12px; margin-top: -2px; } | |
| 901 | +.desc-display { font-size: 13px; color: var(--text-secondary); cursor: pointer; display: inline-flex; align-items: flex-start; gap: 6px; } | |
| 902 | +.desc-display:hover .name-edit-hint { opacity: 0.6; } | |
| 903 | +.desc-display:hover { color: var(--text-primary); } | |
| 904 | +.desc-edit-input { width: 100%; max-width: 500px; display: block; margin-bottom: 8px; min-height: 60px; font-size: 13px; } | |
| 905 | +.desc-edit-actions { display: flex; gap: 6px; } | |
| 906 | +.editable-badge { cursor: pointer; transition: all 0.2s; display: inline-flex; align-items: center; gap: 4px; } | |
| 907 | +.editable-badge:hover { background: var(--bg-input-focus); border-color: var(--accent); color: var(--accent); } | |
| 908 | +.type-edit-select { width: 140px; } | |
| 788 | 909 | |
| 789 | 910 | /* Status Banner */ |
| 790 | 911 | .status-banner { | ... | ... |