Commit 1eae5cb5b60c61198a3af194bb8a20bb20fd8548
1 parent
3944a08d
feat: OKR tree view upgrade + effort thinking control + gitignore cleanup
- CompanyDashboard Goals Tab: upgraded from flat list to full OKR tree view with progress bars, node hierarchy, status animations, owner/priority badges - ClaudeCliProtocol: added --effort flag support (low=disable thinking) - agent-chat.js: per-agent effort config from config.json cli.effort - .gitignore: exclude data/companies/, apiconversation/, agent memory/tasks - New views: TemplateAgents, JobsPanel, PipelineEditor, SkillsMarket - docs/architecture-v5.md: added decision #13 (thinking control)
Showing
16 changed files
with
3208 additions
and
35 deletions
.gitignore
| ... | ... | @@ -14,13 +14,18 @@ bot.log |
| 14 | 14 | .logs/ |
| 15 | 15 | .data/ |
| 16 | 16 | |
| 17 | -# Data (runtime generated, per-agent) | |
| 18 | -data/agents/*/conversations/ | |
| 19 | -data/agents/*/logs/ | |
| 20 | -data/agents/*/memory/short-term/ | |
| 21 | -data/agents/*/workspace/ | |
| 17 | +# Data (runtime generated, per-agent — covers both data/agents/ and data/system/agents/) | |
| 18 | +data/**/agents/*/conversations/ | |
| 19 | +data/**/agents/*/logs/ | |
| 20 | +data/**/agents/*/memory/ | |
| 21 | +data/**/agents/*/workspace/ | |
| 22 | +data/**/agents/.tasks/ | |
| 22 | 23 | data/agents/job-*/ |
| 23 | 24 | data/trained_skills/ |
| 25 | +data/companies/ | |
| 26 | + | |
| 27 | +# API access logs (runtime generated by api-access-logger middleware) | |
| 28 | +apiconversation/ | |
| 24 | 29 | |
| 25 | 30 | # Legacy directories (removed from architecture) |
| 26 | 31 | conversations/ | ... | ... |
data/system/agents/master/plan.md
| 1 | 1 | # 工作计划 |
| 2 | 2 | |
| 3 | -_(清空,等待新的战略规划)_ | |
| 3 | +## 活跃公司 | |
| 4 | + | |
| 5 | +### ABC Inc. (创建于 2026-03-27) | |
| 6 | +- **状态**: ✅ 运营中 | |
| 7 | +- **CEO**: abc:ceo - ✅ 已完成五步战略工作流 | |
| 8 | +- **CTO**: abc:cto - ✅ 已完成技术架构设计 (T2.1),已完成 MVP 开发 Phase 1 | |
| 9 | +- **COO**: abc:coo - ✅ 已完成运营接管 | |
| 10 | +- **HR**: abc:hr - ✅ 已完成招聘及培训 (T4.1-T4.3 完成,S4 3/3) | |
| 11 | +- **执行层**: developer (核心 API 开发中), qa (training), qa-judge (active), writer (training) | |
| 12 | +- **Pipeline**: S1 完成 ✅, S2 完成 ✅ (92/100 A级), S3 进行中 (T3.2 监控指标), S4 完成 ✅ | |
| 13 | + | |
| 14 | +--- | |
| 15 | + | |
| 16 | +## 🔄 系统巡检日志 | |
| 17 | +- 2026-03-27 10:45: ABC Inc. Phase 1 完成 → 已分发 Phase 2 任务 T-mn8rtmpl 给 abc:developer | |
| 18 | +- 2026-03-27 10:51: COO (abc:coo) 启动 Phase 2 完成 → T-mn8rrrlv ✅ | |
| 19 | +- 2026-03-27 10:55: 定时巡检 - MVP 测试完成 92/100 A级, S2 全部完成, S3 进行中 | |
| 20 | +- 2026-03-27 11:00: 定时巡检 - developer 活跃中 (10:42),Phase 2 进行中 | |
| 21 | +- **产出**: strategy.md, okr.md, pipeline.json, operations_sop.md, metrics_dashboard.md, tech_architecture.md | |
| 22 | +- 2026-03-27 11:05: 定时巡检 - 全部 Agent 活跃 (master/abc:cto/abc:coo/abc:hr/abc:qa-judge), developer/qa/writer training中,系统运行正常 | |
| 23 | +- 2026-03-27 11:10: 定时巡检 - 全部 Agent 状态正常,无卡住/错误 | |
| 24 | +- 2026-03-27 11:15: 定时巡检 - cto/coo 活跃,系统正常 | |
| 25 | +- 2026-03-27 11:20: 定时巡检 - 全部 Agent 正常,qa-judge/cto/coo 活跃 | |
| 26 | +- 2026-03-27 14:40: 定时巡检 - qa-judge/cto/coo 14:36 活跃,hr 10:51,training 状态正常 | |
| 27 | +- 2026-03-27 15:00: 定时巡检 - cto/coo 活跃,系统正常 | |
| 28 | +- 2026-03-27 15:10: CTO (abc:cto) 完成热点新闻爬虫架构设计 → T-mn913wpv ✅ | |
| 29 | +- 2026-03-27 15:05: 定时巡检 - 系统正常,13 Agent 运行中,5 名爬虫 Agent (training),cto/coo/hr/qa-judge (active) | |
| 30 | +- 2026-03-27 15:10: CTO (abc:cto) 完成热点新闻爬虫架构设计 → T-mn913wpv ✅ | |
| 31 | +- 2026-03-27 15:10: 定时巡检 - hr 15:04/cto 15:04/coo 15:04/qa-judge 15:03 活跃,系统正常 | |
| 32 | +- 2026-03-27 15:15: 定时巡检 - cto/coo/hr/qa-judge 活跃,系统正常 | |
| 33 | +- 2026-03-27 15:16: FDE 训练任务已分发给 HR (abc:hr) → T-mn91ic7i | |
| 34 | +- 2026-03-27 15:16: [TASK_CALLBACK] CTO 任务完成 → FDE 训练分发成功,HR 执行中 | |
| 35 | +- 2026-03-27 15:20: 定时巡检 - hr/cto/coo/qa-judge 活跃,FDE 训练进行中,系统正常 | |
| 36 | +- 2026-03-27 15:25: 定时巡检 - hr 15:14/cto 15:14/coo 15:13/qa-judge 15:13 活跃,系统正常 | |
| 37 | +- 2026-03-27 15:30: 定时巡检 - master 15:17活跃,hr/cto/coo/qa-judge 活跃,5名爬虫 Agent (training),系统正常 | |
| 38 | +- 2026-03-27 15:32: [TASK_CALLBACK] HR (abc:hr) FDE 训练完成 → 5名爬虫 Agent 技能装配完成,bilibili-hunter 已执行首轮采集 | |
| 39 | +- 2026-03-27 15:35: 定时巡检 - master 15:18/cto 15:18/coo 15:18/hr 15:18 活跃,qa-judge 15:13,所有 Agent 正常 | |
| 40 | +- 2026-03-27 15:40: 定时巡检 - qa-judge 15:23/cto 15:23/coo 15:23 活跃,hr 15:18,training 状态正常,系统正常 | |
| 41 | +- 2026-03-27 15:45: 定时巡检 - cto/coo/qa-judge/hr 活跃,系统正常 | |
| 42 | +- 2026-03-27 15:50: 定时巡检 - cto 15:34/coo 15:33/qa-judge 15:33 活跃,hr 15:18,系统正常 | |
| 43 | +- 2026-03-27 15:55: 定时巡检 - cto 15:37/coo 15:39 活跃,qa-judge 15:33,hr 15:18,系统正常 | |
| 44 | +- 2026-03-27 16:00: 定时巡检 - cto 15:43/coo 15:43/qa-judge 15:43 活跃,hr 15:18,系统正常 | |
| 45 | + | |
| 46 | +--- | |
| 47 | + | |
| 48 | +_(等待新的战略指令)_ | ... | ... |
data/system/agents/master/todo.md
| 1 | 1 | # 待办事项 |
| 2 | 2 | |
| 3 | 3 | _(清空,等待新的指令)_ |
| 4 | +| T-mn916ucd | 请协调为 ABC 公司新招聘的 5 名热点新闻爬虫系统执行层员工(bilibili-hunter、google-trends-hunter、reddit-hunter、douban-hunter、hot-aggregator)启动 FDE 训练。每个员工需要掌握的技能:scrapling_crawler + 各自平台 API (bilibili-api, google-trends-api, praw, douban-api)。数据存储路径:data/companies/abc/agents/<agentId>/workspace/data/热点库/。目标是优化到 90 分以上。 | ✅ 完成 | abc:hr | P1 | 2026-03-27 15:03 | - | | |
| 5 | +| T-mn91hik9 | 【FDE 训练任务】请为 ABC 公司的 5 名热点新闻爬虫系统执行层员工启动 FDE 训练。<br><br>## 目标 Agent 列表<br>1. bilibili-hunter (abc:bilibili-hunter)<br>2. google-trends-hunter (abc:google-trends-hunter)<br>3. reddit-hunter (abc:reddit-hunter)<br>4. douban-hunter (abc:douban-hunter)<br>5. hot-aggregator (abc:hot-aggregator)<br><br>## 每个 Agent 需掌握的技能<br>- bilibili-hunter: scrapling_crawler + bilibili-api<br>- google-trends-hunter: scrapling_crawler + google-trends-api<br>- reddit-hunter: scrapling_crawler + praw (Reddit API)<br>- douban-hunter: scrapling_crawler + douban-api<br>- hot-aggregator: scrapling_crawler + 聚合能力<br><br>## 数据存储路径<br>data/companies/abc/agents/<agentId>/workspace/data/热点库/<br><br>## 优化目标<br>各 Agent 技能评分优化到 90 分以上<br><br>请执行 FDE 训练流程 (Hunt → Digest → Evolve),完成后汇报结果。 | ✅ 完成 | abc:cto | P1 | 2026-03-27 15:11 | - | | |
| 6 | +| T-mn91kqqo | 【FDE 训练任务】请为以下 5 名热点新闻爬虫系统执行层员工启动 FDE 训练优化:\n\n1. abc:bilibili-hunter - 技能: scrapling + bilibili-api\n2. abc:google-trends-hunter - 技能: scrapling + google-trends-api\n3. abc:reddit-hunter - 技能: scrapling + reddit-api\n4. abc:douban-hunter - 技能: scrapling + douban-api\n5. abc:hot-aggregator - 技能: scrapling + aggregator\n\n目标:各 Agent 技能评分优化到 90 分以上。请执行 FDE 流程(Hunt → Digest → Evolve)。 | ✅ 完成 | abc:hr | P1 | 2026-03-27 15:14 | - | | ... | ... |
data/system/templates/_registry.json
| ... | ... | @@ -6,7 +6,7 @@ |
| 6 | 6 | "description": "公司战略规划、团队管理、OKR 制定、任务委派", |
| 7 | 7 | "benchmarkScore": 97, |
| 8 | 8 | "trainedAt": "2026-03-20", |
| 9 | - "usedCount": 10 | |
| 9 | + "usedCount": 11 | |
| 10 | 10 | }, |
| 11 | 11 | { |
| 12 | 12 | "id": "qa-judge", |
| ... | ... | @@ -15,7 +15,7 @@ |
| 15 | 15 | "description": "质量验收、OKR 进度审查、标准化仲裁", |
| 16 | 16 | "benchmarkScore": 95, |
| 17 | 17 | "trainedAt": "2026-03-25", |
| 18 | - "usedCount": 6 | |
| 18 | + "usedCount": 7 | |
| 19 | 19 | }, |
| 20 | 20 | { |
| 21 | 21 | "id": "base", | ... | ... |
docs/architecture-v5.md
| ... | ... | @@ -477,6 +477,8 @@ Task 进入 → 海马体检索 → 命中? → 小脑快速执行 |
| 477 | 477 | - [x] MasterChatDrawer.vue (侧边抽屉对话) |
| 478 | 478 | - [x] Agent Action Controls (chat, skills panel, train trigger) |
| 479 | 479 | |
| 480 | +- [x] CompanyDashboard Goals Tab 升级为完整 OKR 树视图 (2026-03-27) | |
| 481 | + | |
| 480 | 482 | ### ⬜ Phase 7: 认知架构 (待开始) |
| 481 | 483 | |
| 482 | 484 | - [ ] 海马体检索 (关键词匹配 MVP) |
| ... | ... | @@ -502,6 +504,7 @@ Task 进入 → 海马体检索 → 命中? → 小脑快速执行 |
| 502 | 504 | | 10 | FDE 双路径产出 | workspace/(共享) + agents/(资产) 同时写入 | 03-24 | |
| 503 | 505 | | 11 | 元 Agent 依赖技能加载 | buildSystemPrompt 扫描所有 .claude/skills/ 子目录 | 03-24 | |
| 504 | 506 | | 12 | retrain-pipeline 对齐 | l3-hunt→hunter, l2-digest→digester, innovator-evolver→evolver | 03-25 | |
| 507 | +| 13 | Thinking 控制 | `--effort low` 禁用 extended thinking,config.json `cli.effort` 按 Agent 配置 | 03-27 | | |
| 505 | 508 | |
| 506 | 509 | --- |
| 507 | 510 | ... | ... |
packages/acp-core/protocol/ClaudeCliProtocol.js
| ... | ... | @@ -66,6 +66,14 @@ class ClaudeCliProtocol extends EventEmitter { |
| 66 | 66 | } |
| 67 | 67 | } |
| 68 | 68 | |
| 69 | + // 支持 effort 级别控制 (low/medium/high/max) | |
| 70 | + // low = 禁用 extended thinking,大幅节省 token | |
| 71 | + const effort = this.options.effort || process.env.CLAUDE_EFFORT; | |
| 72 | + if (effort) { | |
| 73 | + args.push('--effort', effort); | |
| 74 | + console.log(`[ClaudeCliProtocol] 💡 effort=${effort}`); | |
| 75 | + } | |
| 76 | + | |
| 69 | 77 | const processEnv = { ...process.env }; |
| 70 | 78 | delete processEnv.NODE_OPTIONS; |
| 71 | 79 | delete processEnv.ELECTRON_RUN_AS_NODE; | ... | ... |
server/routes/agent-chat.js
| ... | ... | @@ -574,6 +574,13 @@ async function getOrCreateProtocol(agentId, convId) { |
| 574 | 574 | console.log(`[AgentChat] 🔒 ${identity.name} 启用编排模式 (legacy fallback): 原生工具已全部禁用`); |
| 575 | 575 | } |
| 576 | 576 | |
| 577 | + // effort 级别控制 (low=禁用thinking | medium | high | max) | |
| 578 | + // 优先从 config.json 的 cli.effort 读取,其次环境变量 CLAUDE_EFFORT | |
| 579 | + if (agentConfig?.cli?.effort) { | |
| 580 | + cliOptions.effort = agentConfig.cli.effort; | |
| 581 | + console.log(`[AgentChat] 💡 ${identity.name} effort=${cliOptions.effort}`); | |
| 582 | + } | |
| 583 | + | |
| 577 | 584 | const protocol = new ClaudeCliProtocol({ name: identity.name }, cliOptions); |
| 578 | 585 | await protocol.connect(null, agentDir); |
| 579 | 586 | await protocol.initialize(); | ... | ... |
server/routes/company.js
| ... | ... | @@ -102,6 +102,93 @@ router.get('/templates', (req, res) => { |
| 102 | 102 | res.json(companyService.listTemplates()); |
| 103 | 103 | }); |
| 104 | 104 | |
| 105 | +// GET /api/company/templates/:templateId/files | |
| 106 | +router.get('/templates/:templateId/files', (req, res) => { | |
| 107 | + try { | |
| 108 | + const path = require('path'); | |
| 109 | + const fs = require('fs'); | |
| 110 | + const DATA_DIR = path.resolve(__dirname, '../../data/system/templates'); | |
| 111 | + const templateDir = path.join(DATA_DIR, req.params.templateId); | |
| 112 | + | |
| 113 | + if (!fs.existsSync(templateDir)) { | |
| 114 | + return res.json({ tree: [], totalFiles: 0 }); | |
| 115 | + } | |
| 116 | + | |
| 117 | + function scanDir(dirPath, relativePath = '') { | |
| 118 | + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); | |
| 119 | + const nodes = []; | |
| 120 | + for (const entry of entries) { | |
| 121 | + const fullPath = path.join(dirPath, entry.name); | |
| 122 | + const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name; | |
| 123 | + | |
| 124 | + if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; | |
| 125 | + | |
| 126 | + if (entry.isDirectory()) { | |
| 127 | + const children = scanDir(fullPath, relPath); | |
| 128 | + nodes.push({ | |
| 129 | + name: entry.name, | |
| 130 | + path: relPath, | |
| 131 | + type: 'directory', | |
| 132 | + children, | |
| 133 | + fileCount: children.reduce((sum, c) => sum + (c.type === 'file' ? 1 : (c.fileCount || 0)), 0), | |
| 134 | + }); | |
| 135 | + } else { | |
| 136 | + const stat = fs.statSync(fullPath); | |
| 137 | + nodes.push({ | |
| 138 | + name: entry.name, | |
| 139 | + path: relPath, | |
| 140 | + type: 'file', | |
| 141 | + size: stat.size, | |
| 142 | + modifiedAt: stat.mtime.toISOString(), | |
| 143 | + }); | |
| 144 | + } | |
| 145 | + } | |
| 146 | + return nodes.sort((a, b) => { | |
| 147 | + if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; | |
| 148 | + return a.name.localeCompare(b.name); | |
| 149 | + }); | |
| 150 | + } | |
| 151 | + | |
| 152 | + const tree = scanDir(templateDir); | |
| 153 | + const countFiles = (nodes) => nodes.reduce((sum, n) => sum + (n.type === 'file' ? 1 : countFiles(n.children || [])), 0); | |
| 154 | + | |
| 155 | + res.json({ tree, totalFiles: countFiles(tree) }); | |
| 156 | + } catch (err) { | |
| 157 | + res.status(500).json({ error: err.message }); | |
| 158 | + } | |
| 159 | +}); | |
| 160 | + | |
| 161 | +// GET /api/company/templates/:templateId/files/content | |
| 162 | +router.get('/templates/:templateId/files/content', (req, res) => { | |
| 163 | + try { | |
| 164 | + const path = require('path'); | |
| 165 | + const fs = require('fs'); | |
| 166 | + const DATA_DIR = path.resolve(__dirname, '../../data/system/templates'); | |
| 167 | + const filePath = req.query.path; | |
| 168 | + | |
| 169 | + if (!filePath) return res.status(400).json({ error: 'path 参数为必填' }); | |
| 170 | + | |
| 171 | + const absPath = path.resolve(DATA_DIR, req.params.templateId, filePath); | |
| 172 | + const safeBoundary = path.resolve(DATA_DIR, req.params.templateId); | |
| 173 | + if (!absPath.startsWith(safeBoundary)) { | |
| 174 | + return res.status(403).json({ error: '路径越界' }); | |
| 175 | + } | |
| 176 | + | |
| 177 | + if (!fs.existsSync(absPath)) { | |
| 178 | + return res.status(404).json({ error: '文件不存在' }); | |
| 179 | + } | |
| 180 | + | |
| 181 | + const stat = fs.statSync(absPath); | |
| 182 | + if (stat.size > 512 * 1024) { | |
| 183 | + return res.json({ content: '(文件过大,跳过预览)', truncated: true, size: stat.size }); | |
| 184 | + } | |
| 185 | + | |
| 186 | + const content = fs.readFileSync(absPath, 'utf-8'); | |
| 187 | + res.json({ content, size: stat.size, path: filePath }); | |
| 188 | + } catch (err) { | |
| 189 | + res.status(500).json({ error: err.message }); | |
| 190 | + } | |
| 191 | +}); | |
| 105 | 192 | // GET /api/company/:companyId |
| 106 | 193 | router.get('/:companyId', (req, res) => { |
| 107 | 194 | const company = companyService.get(req.params.companyId); |
| ... | ... | @@ -725,7 +812,6 @@ router.post('/:companyId/pipelines/runs/:runId/reject', (req, res) => { |
| 725 | 812 | } |
| 726 | 813 | }); |
| 727 | 814 | |
| 728 | -// ═══════════════════════════════════════ | |
| 729 | 815 | // Schedule (日程管理) |
| 730 | 816 | // ═══════════════════════════════════════ |
| 731 | 817 | |
| ... | ... | @@ -973,4 +1059,145 @@ router.get('/:companyId/messages', (req, res) => { |
| 973 | 1059 | } |
| 974 | 1060 | }); |
| 975 | 1061 | |
| 1062 | +// ═══════════════════════════════════════ | |
| 1063 | +// Company Stats — 统计 API (skills count, jobs status) | |
| 1064 | +// ═══════════════════════════════════════ | |
| 1065 | + | |
| 1066 | +// GET /api/company/:companyId/stats — 获取公司统计信息 | |
| 1067 | +router.get('/:companyId/stats', (req, res) => { | |
| 1068 | + try { | |
| 1069 | + const path = require('path'); | |
| 1070 | + const fs = require('fs'); | |
| 1071 | + const DATA_DIR = path.resolve(__dirname, '../../data'); | |
| 1072 | + const companyId = req.params.companyId; | |
| 1073 | + | |
| 1074 | + // 1. Skills 统计 - 统计所有 Agent 的 Skill 数量 | |
| 1075 | + let totalSkills = 0; | |
| 1076 | + const agentSkills = []; | |
| 1077 | + const agentsDir = path.join(DATA_DIR, 'companies', companyId, 'agents'); | |
| 1078 | + | |
| 1079 | + if (fs.existsSync(agentsDir)) { | |
| 1080 | + const agentDirs = fs.readdirSync(agentsDir, { withFileTypes: true }).filter(d => d.isDirectory()); | |
| 1081 | + for (const agentDir of agentDirs) { | |
| 1082 | + const skillsDir = path.join(agentsDir, agentDir.name, 'skills'); | |
| 1083 | + if (fs.existsSync(skillsDir)) { | |
| 1084 | + const skillCount = fs.readdirSync(skillsDir, { withFileTypes: true }).filter(d => d.isDirectory()).length; | |
| 1085 | + totalSkills += skillCount; | |
| 1086 | + agentSkills.push({ agentId: agentDir.name, skillCount }); | |
| 1087 | + } | |
| 1088 | + } | |
| 1089 | + } | |
| 1090 | + | |
| 1091 | + // 2. Agents 统计 | |
| 1092 | + const agents = companyService.listAgents(companyId); | |
| 1093 | + const agentCount = agents.length; | |
| 1094 | + | |
| 1095 | + // 3. Jobs 统计 - 从 jobs 持久化文件读取 | |
| 1096 | + let jobsStats = { total: 0, queued: 0, running: 0, completed: 0, failed: 0 }; | |
| 1097 | + const jobsFilePath = path.join(DATA_DIR, '.data', 'jobs-openapi.json'); | |
| 1098 | + if (fs.existsSync(jobsFilePath)) { | |
| 1099 | + try { | |
| 1100 | + const jobsData = JSON.parse(fs.readFileSync(jobsFilePath, 'utf-8')); | |
| 1101 | + if (Array.isArray(jobsData)) { | |
| 1102 | + jobsStats.total = jobsData.length; | |
| 1103 | + for (const job of jobsData) { | |
| 1104 | + if (jobsStats[job.status] !== undefined) { | |
| 1105 | + jobsStats[job.status]++; | |
| 1106 | + } | |
| 1107 | + } | |
| 1108 | + } | |
| 1109 | + } catch (e) { | |
| 1110 | + console.warn('[Stats] 读取 Jobs 文件失败:', e.message); | |
| 1111 | + } | |
| 1112 | + } | |
| 1113 | + | |
| 1114 | + // 4. Pipelines 统计 | |
| 1115 | + const pSvc = new PipelineService(companyId); | |
| 1116 | + const pipelines = pSvc.listPipelines(); | |
| 1117 | + const runs = pSvc.listRuns(); | |
| 1118 | + const pipelinesStats = { | |
| 1119 | + total: pipelines.length, | |
| 1120 | + active: pipelines.filter(p => p.status === 'active').length, | |
| 1121 | + runs: { | |
| 1122 | + total: runs.length, | |
| 1123 | + running: runs.filter(r => r.status === 'running').length, | |
| 1124 | + completed: runs.filter(r => r.status === 'completed').length, | |
| 1125 | + failed: runs.filter(r => r.status === 'failed').length, | |
| 1126 | + } | |
| 1127 | + }; | |
| 1128 | + | |
| 1129 | + res.json({ | |
| 1130 | + companyId, | |
| 1131 | + timestamp: new Date().toISOString(), | |
| 1132 | + skills: { total: totalSkills, byAgent: agentSkills }, | |
| 1133 | + agents: { total: agentCount }, | |
| 1134 | + jobs: jobsStats, | |
| 1135 | + pipelines: pipelinesStats, | |
| 1136 | + }); | |
| 1137 | + } catch (err) { | |
| 1138 | + res.status(500).json({ error: err.message }); | |
| 1139 | + } | |
| 1140 | +}); | |
| 1141 | + | |
| 1142 | +// GET /api/company/:companyId/jobs — 获取公司相关的 Job 列表 | |
| 1143 | +router.get('/:companyId/jobs', (req, res) => { | |
| 1144 | + try { | |
| 1145 | + const path = require('path'); | |
| 1146 | + const fs = require('fs'); | |
| 1147 | + const DATA_DIR = path.resolve(__dirname, '../../data'); | |
| 1148 | + const jobsFilePath = path.join(DATA_DIR, '.data', 'jobs-openapi.json'); | |
| 1149 | + | |
| 1150 | + if (!fs.existsSync(jobsFilePath)) { | |
| 1151 | + return res.json([]); | |
| 1152 | + } | |
| 1153 | + | |
| 1154 | + const jobsData = JSON.parse(fs.readFileSync(jobsFilePath, 'utf-8')); | |
| 1155 | + if (!Array.isArray(jobsData)) { | |
| 1156 | + return res.json([]); | |
| 1157 | + } | |
| 1158 | + | |
| 1159 | + // 过滤与公司相关的 Jobs (通过 domain 或 conversationId 包含 companyId) | |
| 1160 | + const limit = Math.min(parseInt(req.query.limit) || 50, 100); | |
| 1161 | + const companyId = req.params.companyId; | |
| 1162 | + const filtered = jobsData | |
| 1163 | + .filter(job => !job.domain || job.domain.includes(companyId)) | |
| 1164 | + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) | |
| 1165 | + .slice(0, limit); | |
| 1166 | + | |
| 1167 | + res.json(filtered); | |
| 1168 | + } catch (err) { | |
| 1169 | + res.status(500).json({ error: err.message }); | |
| 1170 | + } | |
| 1171 | +}); | |
| 1172 | + | |
| 1173 | +// GET /api/company/:companyId/jobs/:jobId — 获取 Job 详情 | |
| 1174 | +router.get('/:companyId/jobs/:jobId', (req, res) => { | |
| 1175 | + try { | |
| 1176 | + const path = require('path'); | |
| 1177 | + const fs = require('fs'); | |
| 1178 | + const DATA_DIR = path.resolve(__dirname, '../../data'); | |
| 1179 | + const jobsFilePath = path.join(DATA_DIR, '.data', 'jobs-openapi.json'); | |
| 1180 | + | |
| 1181 | + if (!fs.existsSync(jobsFilePath)) { | |
| 1182 | + return res.status(404).json({ error: 'Job 不存在' }); | |
| 1183 | + } | |
| 1184 | + | |
| 1185 | + const jobsData = JSON.parse(fs.readFileSync(jobsFilePath, 'utf-8')); | |
| 1186 | + if (!Array.isArray(jobsData)) { | |
| 1187 | + return res.status(404).json({ error: 'Job 不存在' }); | |
| 1188 | + } | |
| 1189 | + | |
| 1190 | + const job = jobsData.find(j => j.jobId === req.params.jobId); | |
| 1191 | + if (!job) { | |
| 1192 | + return res.status(404).json({ error: 'Job 不存在' }); | |
| 1193 | + } | |
| 1194 | + | |
| 1195 | + // 返回安全版本 (隐藏 apiKey, webhookUrl) | |
| 1196 | + const { apiKey, webhookUrl, ...safeJob } = job; | |
| 1197 | + res.json(safeJob); | |
| 1198 | + } catch (err) { | |
| 1199 | + res.status(500).json({ error: err.message }); | |
| 1200 | + } | |
| 1201 | +}); | |
| 1202 | + | |
| 976 | 1203 | module.exports = router; | ... | ... |
server/routes/open-api.js
| ... | ... | @@ -278,6 +278,57 @@ router.get('/skills/:name/updates', h((req, res) => { |
| 278 | 278 | res.json({ skillName: req.params.name, latestVersion: skill.version, currentScore: skill.currentScore, hasUpdate: !!hasUpdate, changelog: hasUpdate ? changelog : undefined, downloadCount: skill.downloadCount || 0, updateCount: skill.updateCount || 0 }); |
| 279 | 279 | })); |
| 280 | 280 | |
| 281 | +// ── Pipelines (简化版) ── | |
| 282 | +// 读取 .data/pipelines.json | |
| 283 | +let pipelinesData = { pipelines: [] }; | |
| 284 | +const pipelinesPath = path.resolve(__dirname, '../../.data/pipelines.json'); | |
| 285 | +try { pipelinesData = JSON.parse(fs.readFileSync(pipelinesPath, 'utf-8')); } catch {} | |
| 286 | + | |
| 287 | +router.get('/pipelines', h((req, res) => { | |
| 288 | + const limit = parseInt(req.query.limit) || 20; | |
| 289 | + res.json({ results: pipelinesData.pipelines.slice(0, limit), total: pipelinesData.pipelines.length }); | |
| 290 | +})); | |
| 291 | + | |
| 292 | +router.post('/pipelines', h((req, res) => { | |
| 293 | + const { name, description, config } = req.body; | |
| 294 | + const newPipeline = { id: `p-${Date.now()}`, name, description, config, status: 'idle', createdAt: new Date().toISOString() }; | |
| 295 | + pipelinesData.pipelines.push(newPipeline); | |
| 296 | + fs.writeFileSync(pipelinesPath, JSON.stringify(pipelinesData, null, 2)); | |
| 297 | + res.status(201).json(newPipeline); | |
| 298 | +})); | |
| 299 | + | |
| 300 | +router.get('/pipelines/:pipelineId', h((req, res) => { | |
| 301 | + const pipeline = pipelinesData.pipelines.find(p => p.id === req.params.pipelineId); | |
| 302 | + pipeline ? res.json(pipeline) : res.status(404).json({ error: 'Pipeline not found' }); | |
| 303 | +})); | |
| 304 | + | |
| 305 | +router.put('/pipelines/:pipelineId', h((req, res) => { | |
| 306 | + const idx = pipelinesData.pipelines.findIndex(p => p.id === req.params.pipelineId); | |
| 307 | + if (idx === -1) return res.status(404).json({ error: 'Pipeline not found' }); | |
| 308 | + const { name, description, config } = req.body; | |
| 309 | + pipelinesData.pipelines[idx] = { ...pipelinesData.pipelines[idx], name, description, config }; | |
| 310 | + fs.writeFileSync(pipelinesPath, JSON.stringify(pipelinesData, null, 2)); | |
| 311 | + res.json(pipelinesData.pipelines[idx]); | |
| 312 | +})); | |
| 313 | + | |
| 314 | +router.delete('/pipelines/:pipelineId', h((req, res) => { | |
| 315 | + const idx = pipelinesData.pipelines.findIndex(p => p.id === req.params.pipelineId); | |
| 316 | + if (idx === -1) return res.status(404).json({ error: 'Pipeline not found' }); | |
| 317 | + pipelinesData.pipelines.splice(idx, 1); | |
| 318 | + fs.writeFileSync(pipelinesPath, JSON.stringify(pipelinesData, null, 2)); | |
| 319 | + res.status(204).send(); | |
| 320 | +})); | |
| 321 | + | |
| 322 | +router.post('/pipelines/:pipelineId/run', h((req, res) => { | |
| 323 | + const pipeline = pipelinesData.pipelines.find(p => p.id === req.params.pipelineId); | |
| 324 | + if (!pipeline) return res.status(404).json({ error: 'Pipeline not found' }); | |
| 325 | + pipeline.status = 'running'; | |
| 326 | + pipeline.lastRun = new Date().toISOString(); | |
| 327 | + fs.writeFileSync(pipelinesPath, JSON.stringify(pipelinesData, null, 2)); | |
| 328 | + // TODO: 实际执行 Pipeline 逻辑 | |
| 329 | + res.json({ message: 'Pipeline started', pipeline }); | |
| 330 | +})); | |
| 331 | + | |
| 281 | 332 | // ══════════════════════════════════════ |
| 282 | 333 | // Internal Pipelines |
| 283 | 334 | // ══════════════════════════════════════ | ... | ... |
web/src/components/AppNav.vue
| ... | ... | @@ -9,6 +9,10 @@ |
| 9 | 9 | <span>🤖</span> MasterBot |
| 10 | 10 | </router-link> |
| 11 | 11 | |
| 12 | + <router-link to="/templates" class="nav-link" :class="{ active: $route.path.startsWith('/templates') }"> | |
| 13 | + <span>📦</span> Templates | |
| 14 | + </router-link> | |
| 15 | + | |
| 12 | 16 | <div class="nav-divider"></div> |
| 13 | 17 | |
| 14 | 18 | <!-- Company Switcher --> |
| ... | ... | @@ -55,6 +59,27 @@ |
| 55 | 59 | <router-link :to="`/${currentCompany.id}/messages`" class="nav-link" :class="{ active: $route.params.tab === 'messages' }"> |
| 56 | 60 | 💬 Messages |
| 57 | 61 | </router-link> |
| 62 | + <router-link :to="`/${currentCompany.id}/skills`" class="nav-link" :class="{ active: $route.params.tab === 'skills' }"> | |
| 63 | + 🧪 Skills | |
| 64 | + </router-link> | |
| 65 | + <router-link :to="`/${currentCompany.id}/jobs`" class="nav-link" :class="{ active: $route.params.tab === 'jobs' }"> | |
| 66 | + 📋 Jobs | |
| 67 | + </router-link> | |
| 68 | + <router-link to="/pipelines" class="nav-link" :class="{ active: $route.path === '/pipelines' }"> | |
| 69 | + 🔗 Pipeline | |
| 70 | + </router-link> | |
| 71 | + </template> | |
| 72 | + <!-- Global Links (no company) --> | |
| 73 | + <template v-else> | |
| 74 | + <router-link to="/skills" class="nav-link" :class="{ active: $route.path === '/skills' }"> | |
| 75 | + 🛒 Skills 市场 | |
| 76 | + </router-link> | |
| 77 | + <router-link to="/jobs" class="nav-link" :class="{ active: $route.path === '/jobs' }"> | |
| 78 | + 📋 任务面板 | |
| 79 | + </router-link> | |
| 80 | + <router-link to="/pipelines" class="nav-link" :class="{ active: $route.path === '/pipelines' }"> | |
| 81 | + 🔗 Pipeline 编辑器 | |
| 82 | + </router-link> | |
| 58 | 83 | </template> |
| 59 | 84 | </div> |
| 60 | 85 | ... | ... |
web/src/router/index.js
| ... | ... | @@ -22,12 +22,18 @@ const routes = [ |
| 22 | 22 | props: true, |
| 23 | 23 | }, |
| 24 | 24 | { |
| 25 | + path: '/templates/:agentId?', | |
| 26 | + name: 'TemplateAgents', | |
| 27 | + component: () => import('@/views/TemplateAgents.vue'), | |
| 28 | + props: true, | |
| 29 | + }, | |
| 30 | + { | |
| 25 | 31 | path: '/company', |
| 26 | 32 | name: 'CompanyList', |
| 27 | 33 | component: () => import('@/views/CompanyDashboard.vue'), |
| 28 | 34 | }, |
| 29 | 35 | { |
| 30 | - path: '/:id/:tab(overview|agents|pipelines|goals|sop|okr|team|files|messages)', | |
| 36 | + path: '/:id/:tab(overview|agents|pipelines|goals|sop|okr|team|files|messages|skills|jobs)', | |
| 31 | 37 | name: 'CompanyDetail', |
| 32 | 38 | component: () => import('@/views/CompanyDashboard.vue'), |
| 33 | 39 | props: true |
| ... | ... | @@ -37,6 +43,22 @@ const routes = [ |
| 37 | 43 | name: 'OkrDashboard', |
| 38 | 44 | component: () => import('@/views/OkrDashboard.vue'), |
| 39 | 45 | }, |
| 46 | + // 新增独立页面 | |
| 47 | + { | |
| 48 | + path: '/skills', | |
| 49 | + name: 'SkillsMarket', | |
| 50 | + component: () => import('@/views/SkillsMarket.vue'), | |
| 51 | + }, | |
| 52 | + { | |
| 53 | + path: '/jobs', | |
| 54 | + name: 'JobsPanel', | |
| 55 | + component: () => import('@/views/JobsPanel.vue'), | |
| 56 | + }, | |
| 57 | + { | |
| 58 | + path: '/pipelines', | |
| 59 | + name: 'PipelineEditor', | |
| 60 | + component: () => import('@/views/PipelineEditor.vue'), | |
| 61 | + }, | |
| 40 | 62 | ] |
| 41 | 63 | |
| 42 | 64 | const router = createRouter({ | ... | ... |
web/src/views/CompanyDashboard.vue
| ... | ... | @@ -212,6 +212,7 @@ |
| 212 | 212 | <div v-if="activeTab === 'pipelines'" class="tab-content"> |
| 213 | 213 | <div class="section-header"> |
| 214 | 214 | <h3>SOP 流水线</h3> |
| 215 | + <button class="btn-sm" @click="loadPipelines(selectedCompany.id)">🔄 刷新</button> | |
| 215 | 216 | </div> |
| 216 | 217 | <div class="pipeline-list"> |
| 217 | 218 | <div v-for="p in pipelines" :key="p.id" class="pipeline-card" @click="loadPipelineDetail(p.id)"> |
| ... | ... | @@ -219,7 +220,10 @@ |
| 219 | 220 | <span class="pipeline-name">⚙️ {{ p.name }}</span> |
| 220 | 221 | <span class="pipeline-steps">{{ p.stepCount }} 步</span> |
| 221 | 222 | </div> |
| 222 | - <span class="card-status" :class="p.status">{{ p.status }}</span> | |
| 223 | + <div class="pipeline-actions-row"> | |
| 224 | + <span class="card-status" :class="p.status">{{ p.status }}</span> | |
| 225 | + <button class="btn-pipeline-run" @click.stop="runPipeline(p.id)">▶️ 运行</button> | |
| 226 | + </div> | |
| 223 | 227 | </div> |
| 224 | 228 | <div v-if="pipelines.length === 0" class="empty-mini">暂无流水线</div> |
| 225 | 229 | </div> |
| ... | ... | @@ -232,6 +236,7 @@ |
| 232 | 236 | v-for="(step, idx) in selectedPipeline.steps" |
| 233 | 237 | :key="idx" |
| 234 | 238 | class="step-node" |
| 239 | + :class="{ 'step-current': selectedPipeline.currentStep === idx, 'step-completed': selectedPipeline.currentStep > idx }" | |
| 235 | 240 | > |
| 236 | 241 | <div class="step-index">{{ idx + 1 }}</div> |
| 237 | 242 | <div class="step-info"> |
| ... | ... | @@ -259,27 +264,107 @@ |
| 259 | 264 | <span>步骤 {{ run.currentStep + 1 }}/{{ run.totalSteps }}</span> |
| 260 | 265 | <span>🚀 {{ run.trigger }}</span> |
| 261 | 266 | </div> |
| 267 | + <div class="run-actions"> | |
| 268 | + <button v-if="run.status === 'failed'" class="btn-sm" @click="retryPipelineRun(run.runId)">🔁 重试</button> | |
| 269 | + </div> | |
| 262 | 270 | </div> |
| 263 | 271 | </div> |
| 264 | 272 | </div> |
| 265 | 273 | |
| 266 | - <!-- Tab: Goals --> | |
| 274 | + <!-- Tab: Goals (Full OKR Tree) --> | |
| 267 | 275 | <div v-if="activeTab === 'goals'" class="tab-content"> |
| 268 | - <div class="section-header"> | |
| 269 | - <h3>OKR 目标</h3> | |
| 270 | - </div> | |
| 271 | - <div class="goals-list"> | |
| 272 | - <div v-for="g in goals" :key="g.id" class="goal-card"> | |
| 273 | - <div class="goal-header"> | |
| 274 | - <span class="goal-id">{{ g.id }}</span> | |
| 275 | - <span class="card-status" :class="g.status">{{ g.status }}</span> | |
| 276 | + <div class="okr-layout"> | |
| 277 | + <!-- Left: Goal List --> | |
| 278 | + <div class="okr-sidebar"> | |
| 279 | + <div class="okr-sidebar-header"> | |
| 280 | + <span>🎯 OKR 目标</span> | |
| 281 | + <span class="okr-count" v-if="goals.length">{{ goals.length }}</span> | |
| 282 | + </div> | |
| 283 | + <div class="okr-goal-list"> | |
| 284 | + <div | |
| 285 | + v-for="g in goals" :key="g.id" | |
| 286 | + class="okr-goal-card" :class="{ active: selectedGoalId === g.id }" | |
| 287 | + @click="selectGoalDetail(g.id)" | |
| 288 | + > | |
| 289 | + <div class="okr-goal-top"> | |
| 290 | + <span class="okr-gid">{{ g.id }}</span> | |
| 291 | + <span class="okr-gstatus" :class="g.status">{{ okrStatusLabel(g.status) }}</span> | |
| 292 | + </div> | |
| 293 | + <div class="okr-gtitle">{{ g.title }}</div> | |
| 294 | + <div class="okr-gprogress"> | |
| 295 | + <div class="okr-pbar"><div class="okr-pfill" :style="{ width: (g.progress * 100) + '%' }"></div></div> | |
| 296 | + <span class="okr-ppct">{{ Math.round(g.progress * 100) }}%</span> | |
| 297 | + </div> | |
| 298 | + <div class="okr-gmeta"> | |
| 299 | + <span>👤 {{ g.owner }}</span> | |
| 300 | + <span>{{ g.doneCount || 0 }}/{{ g.totalCount || 0 }}</span> | |
| 301 | + </div> | |
| 302 | + </div> | |
| 303 | + <div v-if="goals.length === 0" class="empty-mini">暂无目标</div> | |
| 276 | 304 | </div> |
| 277 | - <h4 class="goal-title">{{ g.title }}</h4> | |
| 278 | - <div class="goal-meta"> | |
| 279 | - <span>👤 {{ g.owner }}</span> | |
| 305 | + </div> | |
| 306 | + | |
| 307 | + <!-- Right: Goal Detail + Node Tree --> | |
| 308 | + <div class="okr-detail" v-if="selectedGoalDetail"> | |
| 309 | + <div class="okr-hero"> | |
| 310 | + <div class="okr-hero-row"> | |
| 311 | + <span class="okr-hero-id">{{ selectedGoalDetail.id }}</span> | |
| 312 | + <span class="okr-hero-status" :class="selectedGoalDetail.status">{{ okrStatusLabel(selectedGoalDetail.status) }}</span> | |
| 313 | + <span class="okr-hero-owner">👤 {{ selectedGoalDetail.owner }}</span> | |
| 314 | + </div> | |
| 315 | + <h2 class="okr-hero-title">{{ selectedGoalDetail.title }}</h2> | |
| 316 | + <p class="okr-hero-desc">{{ selectedGoalDetail.description || '暂无详细描述' }}</p> | |
| 317 | + <div class="okr-hero-progress" v-if="selectedGoalProgress"> | |
| 318 | + <div class="okr-hp-top"> | |
| 319 | + <span>目标总体进度</span> | |
| 320 | + <span class="okr-hp-pct">{{ Math.round(selectedGoalProgress.progress * 100) }}%</span> | |
| 321 | + </div> | |
| 322 | + <div class="okr-hp-bar"><div class="okr-hp-fill" :style="{ width: (selectedGoalProgress.progress * 100) + '%' }"></div></div> | |
| 323 | + <div class="okr-hp-stats"> | |
| 324 | + <span>总: {{ selectedGoalProgress.total }}</span> | |
| 325 | + <span class="stat-d">✅ {{ selectedGoalProgress.done }}</span> | |
| 326 | + <span class="stat-p">🔄 {{ selectedGoalProgress.inProgress }}</span> | |
| 327 | + <span class="stat-w">⏳ {{ selectedGoalProgress.pending }}</span> | |
| 328 | + </div> | |
| 329 | + </div> | |
| 330 | + </div> | |
| 331 | + <div class="okr-tree-area"> | |
| 332 | + <h3 class="okr-tree-title">OKR 拆解树</h3> | |
| 333 | + <div class="okr-tree"> | |
| 334 | + <div | |
| 335 | + v-for="node in okrSortedNodes" :key="node.id" | |
| 336 | + class="okr-tnode" | |
| 337 | + > | |
| 338 | + <div class="okr-tnode-line"> | |
| 339 | + <div class="okr-tnode-indent" :style="{ width: (node.level - 2) * 24 + 'px' }"></div> | |
| 340 | + <div class="okr-tnode-card" :class="[node.status, 'okr-lv-' + node.level]"> | |
| 341 | + <div class="okr-tnode-hdr"> | |
| 342 | + <span class="okr-nid">{{ node.id }}</span> | |
| 343 | + <span class="okr-ntype">{{ okrLevelName(node.level) }}</span> | |
| 344 | + <span class="okr-nstatus" :class="node.status">{{ okrStatusLabel(node.status) }}</span> | |
| 345 | + </div> | |
| 346 | + <div class="okr-ntitle">{{ node.title }}</div> | |
| 347 | + <div class="okr-nfooter"> | |
| 348 | + <span class="okr-nowner" v-if="node.owner">👤 {{ node.owner }}</span> | |
| 349 | + <span class="okr-nprio" v-if="node.priority" :class="node.priority">{{ node.priority }}</span> | |
| 350 | + <span class="okr-ntask" v-if="node.taskId">🔗 {{ node.taskId }}</span> | |
| 351 | + </div> | |
| 352 | + <div class="okr-nsummary" v-if="node.summary"><strong>摘要:</strong> {{ node.summary }}</div> | |
| 353 | + <div class="okr-nsummary" v-if="node.output"><strong>产出:</strong> {{ node.output }}</div> | |
| 354 | + </div> | |
| 355 | + </div> | |
| 356 | + </div> | |
| 357 | + <div v-if="okrSortedNodes.length === 0" class="empty-mini">暂无 OKR 节点</div> | |
| 358 | + </div> | |
| 359 | + </div> | |
| 360 | + </div> | |
| 361 | + <div class="okr-detail okr-empty" v-else> | |
| 362 | + <div class="empty-state-large"> | |
| 363 | + <div style="font-size:48px; margin-bottom:16px; opacity:0.8">🎯</div> | |
| 364 | + <h3>OKR 目标看板</h3> | |
| 365 | + <p>从左侧选择一个目标查看拆解树与进度</p> | |
| 280 | 366 | </div> |
| 281 | 367 | </div> |
| 282 | - <div v-if="goals.length === 0" class="empty-mini">暂无目标</div> | |
| 283 | 368 | </div> |
| 284 | 369 | </div> |
| 285 | 370 | |
| ... | ... | @@ -355,6 +440,80 @@ |
| 355 | 440 | <div v-if="filteredMessages.length === 0" class="empty-mini">暂无消息记录</div> |
| 356 | 441 | </div> |
| 357 | 442 | </div> |
| 443 | + | |
| 444 | + <!-- Tab: Skills --> | |
| 445 | + <div v-if="activeTab === 'skills'" class="tab-content"> | |
| 446 | + <div class="section-header"> | |
| 447 | + <h3>🧪 Skills 市场</h3> | |
| 448 | + <div class="search-bar-inline"> | |
| 449 | + <input v-model="skillsSearchQuery" type="text" placeholder="搜索 Skills..." @input="loadSkillsList(selectedCompany.id)" /> | |
| 450 | + <button class="btn-sm" @click="loadSkillsList(selectedCompany.id)">🔍</button> | |
| 451 | + </div> | |
| 452 | + </div> | |
| 453 | + <div v-if="companySkills.length === 0" class="empty-mini">暂无 Skills</div> | |
| 454 | + <div v-else class="skills-grid"> | |
| 455 | + <div v-for="skill in companySkills" :key="skill.id" class="skill-card-mini" @click="selectedSkillDetail = skill"> | |
| 456 | + <div class="skill-icon">{{ skill.icon || '⚡' }}</div> | |
| 457 | + <div class="skill-info"> | |
| 458 | + <div class="skill-name">{{ skill.name }}</div> | |
| 459 | + <div class="skill-desc">{{ skill.description || '暂无描述' }}</div> | |
| 460 | + </div> | |
| 461 | + <div class="skill-meta-mini"> | |
| 462 | + <span class="version-badge">{{ skill.version || 'v1.0' }}</span> | |
| 463 | + </div> | |
| 464 | + </div> | |
| 465 | + </div> | |
| 466 | + <!-- Skill Detail Modal --> | |
| 467 | + <div v-if="selectedSkillDetail" class="modal-overlay" @click="selectedSkillDetail = null"> | |
| 468 | + <div class="modal-content" @click.stop> | |
| 469 | + <div class="modal-header"> | |
| 470 | + <h3>{{ selectedSkillDetail.name }}</h3> | |
| 471 | + <button class="btn-close" @click="selectedSkillDetail = null">✕</button> | |
| 472 | + </div> | |
| 473 | + <div class="modal-body"> | |
| 474 | + <p>{{ selectedSkillDetail.description || '暂无描述' }}</p> | |
| 475 | + <div v-if="selectedSkillDetail.taxonomy" class="taxonomy-tags"> | |
| 476 | + <span v-for="(v, k) in selectedSkillDetail.taxonomy" :key="k" class="taxonomy-tag">{{ k }}: {{ v }}</span> | |
| 477 | + </div> | |
| 478 | + </div> | |
| 479 | + <div class="modal-footer"> | |
| 480 | + <button class="btn-install-large" @click="installSkill(selectedSkillDetail)">📥 安装</button> | |
| 481 | + </div> | |
| 482 | + </div> | |
| 483 | + </div> | |
| 484 | + </div> | |
| 485 | + | |
| 486 | + <!-- Tab: Jobs --> | |
| 487 | + <div v-if="activeTab === 'jobs'" class="tab-content"> | |
| 488 | + <div class="section-header"> | |
| 489 | + <h3>📋 任务面板</h3> | |
| 490 | + <div class="filter-inline"> | |
| 491 | + <select v-model="jobStatusFilter" @change="loadCompanyJobs(selectedCompany.id)"> | |
| 492 | + <option value="">全部状态</option> | |
| 493 | + <option value="pending">⏳ 待处理</option> | |
| 494 | + <option value="running">🔄 运行中</option> | |
| 495 | + <option value="completed">✅ 完成</option> | |
| 496 | + <option value="failed">❌ 失败</option> | |
| 497 | + </select> | |
| 498 | + <button class="btn-sm" @click="loadCompanyJobs(selectedCompany.id)">🔄 刷新</button> | |
| 499 | + </div> | |
| 500 | + </div> | |
| 501 | + <div v-if="companyJobs.length === 0" class="empty-mini">暂无任务</div> | |
| 502 | + <div v-else class="jobs-table-mini"> | |
| 503 | + <div class="table-header-mini"> | |
| 504 | + <span>ID</span> | |
| 505 | + <span>状态</span> | |
| 506 | + <span>创建时间</span> | |
| 507 | + <span>操作</span> | |
| 508 | + </div> | |
| 509 | + <div v-for="job in companyJobs" :key="job.id" class="table-row-mini"> | |
| 510 | + <span class="job-id">{{ job.id?.substring(0, 8) || '-' }}</span> | |
| 511 | + <span class="status-badge-mini" :class="job.status">{{ job.status }}</span> | |
| 512 | + <span>{{ formatDate(job.createdAt) }}</span> | |
| 513 | + <button class="btn-action-mini" @click="viewJobDetail(job)">👁 查看</button> | |
| 514 | + </div> | |
| 515 | + </div> | |
| 516 | + </div> | |
| 358 | 517 | </section> |
| 359 | 518 | |
| 360 | 519 | |
| ... | ... | @@ -400,6 +559,9 @@ const pipelines = ref([]) |
| 400 | 559 | const pipelineRuns = ref([]) |
| 401 | 560 | const selectedPipeline = ref(null) |
| 402 | 561 | const goals = ref([]) |
| 562 | +const selectedGoalId = ref(null) | |
| 563 | +const selectedGoalDetail = ref(null) | |
| 564 | +const selectedGoalProgress = ref(null) | |
| 403 | 565 | const templates = ref([]) |
| 404 | 566 | const activeTab = ref('overview') |
| 405 | 567 | const showTemplates = ref(false) |
| ... | ... | @@ -418,6 +580,15 @@ const companyMessages = ref([]) |
| 418 | 580 | const msgFilterAgent = ref('') |
| 419 | 581 | const msgFilterRole = ref('') |
| 420 | 582 | |
| 583 | +// Skills tab | |
| 584 | +const companySkills = ref([]) | |
| 585 | +const skillsSearchQuery = ref('') | |
| 586 | +const selectedSkillDetail = ref(null) | |
| 587 | + | |
| 588 | +// Jobs tab | |
| 589 | +const companyJobs = ref([]) | |
| 590 | +const jobStatusFilter = ref('') | |
| 591 | + | |
| 421 | 592 | function statusLabel(status) { |
| 422 | 593 | const map = { creating: '创建中', running: '运行中', paused: '已暂停', shutdown: '已关停' } |
| 423 | 594 | return map[status] || status |
| ... | ... | @@ -457,6 +628,8 @@ async function loadCompanyData(id, tab) { |
| 457 | 628 | if (activeTab.value === 'goals') await loadGoals(id) |
| 458 | 629 | if (activeTab.value === 'files') await loadCompanyFiles(id) |
| 459 | 630 | if (activeTab.value === 'messages') await loadMessages(id) |
| 631 | + if (activeTab.value === 'skills') await loadSkillsList(id) | |
| 632 | + if (activeTab.value === 'jobs') await loadCompanyJobs(id) | |
| 460 | 633 | } catch (e) { console.error(e) } |
| 461 | 634 | } |
| 462 | 635 | |
| ... | ... | @@ -510,13 +683,70 @@ async function loadPipelineDetail(pipelineId) { |
| 510 | 683 | } catch (e) { console.error(e) } |
| 511 | 684 | } |
| 512 | 685 | |
| 686 | +async function runPipeline(pipelineId) { | |
| 687 | + try { | |
| 688 | + await axios.post(`/api/company/${selectedCompany.value.id}/pipelines/${pipelineId}/run`) | |
| 689 | + alert('✅ Pipeline 已启动') | |
| 690 | + loadPipelines(selectedCompany.value.id) | |
| 691 | + } catch (e) { alert(`❌ 启动失败: ${e.message}`) } | |
| 692 | +} | |
| 693 | + | |
| 694 | +async function retryPipelineRun(runId) { | |
| 695 | + try { | |
| 696 | + await axios.post(`/api/company/${selectedCompany.value.id}/pipelines/runs/${runId}/advance`) | |
| 697 | + alert('✅ 已重新尝试') | |
| 698 | + loadPipelines(selectedCompany.value.id) | |
| 699 | + } catch (e) { alert(`❌ 重试失败: ${e.message}`) } | |
| 700 | +} | |
| 701 | + | |
| 513 | 702 | async function loadGoals(id) { |
| 514 | 703 | try { |
| 515 | 704 | const { data } = await axios.get(`/api/company/${id}/goals`) |
| 516 | 705 | goals.value = data |
| 706 | + // Auto-select first goal | |
| 707 | + if (data.length > 0 && !selectedGoalId.value) { | |
| 708 | + selectGoalDetail(data[0].id) | |
| 709 | + } | |
| 517 | 710 | } catch (e) { console.error(e) } |
| 518 | 711 | } |
| 519 | 712 | |
| 713 | +async function selectGoalDetail(goalId) { | |
| 714 | + if (!selectedCompany.value) return | |
| 715 | + selectedGoalId.value = goalId | |
| 716 | + try { | |
| 717 | + const [goalRes, progRes] = await Promise.all([ | |
| 718 | + axios.get(`/api/company/${selectedCompany.value.id}/goals/${goalId}`), | |
| 719 | + axios.get(`/api/company/${selectedCompany.value.id}/goals/${goalId}/progress`) | |
| 720 | + ]) | |
| 721 | + selectedGoalDetail.value = goalRes.data | |
| 722 | + selectedGoalProgress.value = progRes.data | |
| 723 | + } catch (e) { console.error(e) } | |
| 724 | +} | |
| 725 | + | |
| 726 | +function okrStatusLabel(status) { | |
| 727 | + const map = { active: '运转中', in_progress: '进行中', pending: '待开始', done: '已完成', failed: '失败', blocked: '已阻塞' } | |
| 728 | + return map[status] || status | |
| 729 | +} | |
| 730 | + | |
| 731 | +function okrLevelName(level) { | |
| 732 | + const names = ['Goal', 'Objective', 'Key Result', 'Sub-Task', 'Micro-Task'] | |
| 733 | + return names[Math.min(level - 1, 4)] | |
| 734 | +} | |
| 735 | + | |
| 736 | +const okrSortedNodes = computed(() => { | |
| 737 | + if (!selectedGoalDetail.value?.nodes) return [] | |
| 738 | + const nodes = selectedGoalDetail.value.nodes | |
| 739 | + const keys = Object.keys(nodes).sort((a, b) => { | |
| 740 | + const partsA = a.replace('G-','').split('.').map(Number) | |
| 741 | + const partsB = b.replace('G-','').split('.').map(Number) | |
| 742 | + for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) { | |
| 743 | + if ((partsA[i] || 0) !== (partsB[i] || 0)) return (partsA[i] || 0) - (partsB[i] || 0) | |
| 744 | + } | |
| 745 | + return 0 | |
| 746 | + }) | |
| 747 | + return keys.map(id => ({ id, ...nodes[id], level: id.split('.').length })) | |
| 748 | +}) | |
| 749 | + | |
| 520 | 750 | async function loadCompanyStatus(id) { |
| 521 | 751 | try { |
| 522 | 752 | const { data } = await axios.get(`/api/company/${id}/status`) |
| ... | ... | @@ -624,6 +854,20 @@ async function triggerTrain(agentId) { |
| 624 | 854 | } |
| 625 | 855 | } |
| 626 | 856 | |
| 857 | +async function installSkill(skill) { | |
| 858 | + try { | |
| 859 | + await axios.post(`/api/open/skills/${skill.name}/install`) | |
| 860 | + alert(`✅ ${skill.name} 安装成功`) | |
| 861 | + selectedSkillDetail.value = null | |
| 862 | + } catch (e) { | |
| 863 | + alert(`❌ 安装失败: ${e.message}`) | |
| 864 | + } | |
| 865 | +} | |
| 866 | + | |
| 867 | +function viewJobDetail(job) { | |
| 868 | + alert(`任务详情:\nID: ${job.id}\n状态: ${job.status}\n创建: ${formatDate(job.createdAt)}`) | |
| 869 | +} | |
| 870 | + | |
| 627 | 871 | // switchTab removed, handled by goToTab and router watcher |
| 628 | 872 | |
| 629 | 873 | // ═══ Files Tab Functions ═══ |
| ... | ... | @@ -659,6 +903,22 @@ async function loadMessages(id) { |
| 659 | 903 | } catch (e) { console.error(e) } |
| 660 | 904 | } |
| 661 | 905 | |
| 906 | +async function loadSkillsList(id) { | |
| 907 | + try { | |
| 908 | + const { data } = await axios.get('/api/open/skills/search', { params: { q: skillsSearchQuery.value, limit: 50 } }) | |
| 909 | + companySkills.value = data.results || [] | |
| 910 | + } catch (e) { console.error(e) } | |
| 911 | +} | |
| 912 | + | |
| 913 | +async function loadCompanyJobs(id) { | |
| 914 | + try { | |
| 915 | + const params = {} | |
| 916 | + if (jobStatusFilter.value) params.status = jobStatusFilter.value | |
| 917 | + const { data } = await axios.get(`/api/company/${id}/jobs`, { params }) | |
| 918 | + companyJobs.value = data | |
| 919 | + } catch (e) { console.error(e) } | |
| 920 | +} | |
| 921 | + | |
| 662 | 922 | |
| 663 | 923 | const messageAgentList = computed(() => { |
| 664 | 924 | const names = new Set(companyMessages.value.map(m => m.agentName)) |
| ... | ... | @@ -849,6 +1109,9 @@ function closeDropdown() { |
| 849 | 1109 | .pipeline-header { display: flex; align-items: center; gap: 12px; } |
| 850 | 1110 | .pipeline-name { font-weight: 600; font-size: 14px; } |
| 851 | 1111 | .pipeline-steps { font-size: 12px; color: var(--text-muted); } |
| 1112 | +.pipeline-actions-row { display: flex; align-items: center; gap: 12px; } | |
| 1113 | +.btn-pipeline-run { padding: 6px 12px; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 12px; } | |
| 1114 | +.btn-pipeline-run:hover { opacity: 0.9; } | |
| 852 | 1115 | |
| 853 | 1116 | .pipeline-detail { |
| 854 | 1117 | background: var(--bg-surface); border: 1px solid var(--border-primary); |
| ... | ... | @@ -858,6 +1121,9 @@ function closeDropdown() { |
| 858 | 1121 | |
| 859 | 1122 | .step-flow { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; } |
| 860 | 1123 | .step-node { display: flex; align-items: center; gap: 8px; } |
| 1124 | +.step-node.step-current .step-index { background: var(--accent); color: white; } | |
| 1125 | +.step-node.step-completed .step-index { background: #3fb950; color: white; } | |
| 1126 | +.step-node.step-completed { opacity: 0.7; } | |
| 861 | 1127 | .step-index { |
| 862 | 1128 | width: 28px; height: 28px; border-radius: 50%; |
| 863 | 1129 | background: var(--accent-bg); color: var(--accent); |
| ... | ... | @@ -885,17 +1151,86 @@ function closeDropdown() { |
| 885 | 1151 | .run-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; } |
| 886 | 1152 | .run-id { font-size: 12px; font-weight: 600; color: var(--text-secondary); font-family: monospace; } |
| 887 | 1153 | .run-meta { display: flex; gap: 16px; font-size: 12px; color: var(--text-muted); } |
| 888 | - | |
| 889 | -/* ═══ Goals ═══ */ | |
| 890 | -.goals-list { display: flex; flex-direction: column; gap: 8px; } | |
| 891 | -.goal-card { | |
| 892 | - padding: 16px 20px; background: var(--bg-surface); | |
| 893 | - border: 1px solid var(--border-primary); border-radius: 12px; | |
| 894 | -} | |
| 895 | -.goal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } | |
| 896 | -.goal-id { font-size: 12px; font-weight: 700; color: var(--accent); font-family: monospace; } | |
| 897 | -.goal-title { font-size: 15px; font-weight: 600; margin-bottom: 6px; } | |
| 898 | -.goal-meta { font-size: 12px; color: var(--text-muted); } | |
| 1154 | +.run-actions { margin-top: 8px; } | |
| 1155 | + | |
| 1156 | +/* ═══ OKR Full Layout ═══ */ | |
| 1157 | +.okr-layout { display: flex; height: calc(100vh - 140px); overflow: hidden; } | |
| 1158 | +.okr-sidebar { width: 280px; min-width: 260px; border-right: 1px solid var(--border-primary); background: var(--bg-overlay); display: flex; flex-direction: column; } | |
| 1159 | +.okr-sidebar-header { padding: 12px 16px; font-size: 13px; font-weight: 700; color: var(--text-secondary); border-bottom: 1px solid var(--border-primary); display: flex; align-items: center; justify-content: space-between; background: var(--bg-secondary); } | |
| 1160 | +.okr-count { background: var(--bg-surface-active); color: var(--accent); padding: 2px 8px; border-radius: 12px; font-size: 11px; } | |
| 1161 | +.okr-goal-list { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; } | |
| 1162 | +.okr-goal-card { padding: 12px 14px; border-radius: 10px; cursor: pointer; background: var(--bg-surface); border: 1px solid var(--border-primary); transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); } | |
| 1163 | +.okr-goal-card:hover { transform: translateY(-2px); border-color: var(--accent-light); box-shadow: 0 4px 12px rgba(16,185,129,0.08); } | |
| 1164 | +.okr-goal-card.active { background: var(--accent-bg); border-color: var(--border-active); box-shadow: 0 0 0 1px var(--accent) inset; } | |
| 1165 | +.okr-goal-top { display: flex; justify-content: space-between; margin-bottom: 6px; } | |
| 1166 | +.okr-gid { font-size: 11px; font-family: 'JetBrains Mono', monospace; font-weight: 600; color: var(--text-tertiary); } | |
| 1167 | +.okr-gstatus { font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 4px; } | |
| 1168 | +.okr-gstatus.active { background: rgba(59,130,246,0.15); color: #60a5fa; } | |
| 1169 | +.okr-gstatus.done { background: rgba(16,185,129,0.15); color: #34d399; } | |
| 1170 | +.okr-gtitle { font-size: 13px; font-weight: 600; margin-bottom: 10px; color: var(--text-primary); line-height: 1.4; } | |
| 1171 | +.okr-gprogress { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; } | |
| 1172 | +.okr-pbar { flex: 1; height: 6px; background: var(--bg-input-focus); border-radius: 3px; overflow: hidden; } | |
| 1173 | +.okr-pfill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent-light)); border-radius: 3px; transition: width 0.5s ease; } | |
| 1174 | +.okr-ppct { font-size: 11px; font-weight: 600; color: var(--text-secondary); width: 32px; text-align: right; } | |
| 1175 | +.okr-gmeta { display: flex; justify-content: space-between; font-size: 10px; color: var(--text-faint); } | |
| 1176 | + | |
| 1177 | +/* OKR Detail Panel */ | |
| 1178 | +.okr-detail { flex: 1; overflow-y: auto; background: var(--bg-primary); display: flex; flex-direction: column; } | |
| 1179 | +.okr-detail.okr-empty { align-items: center; justify-content: center; background: var(--bg-overlay); } | |
| 1180 | +.okr-detail.okr-empty h3 { font-size: 20px; font-weight: 600; margin-bottom: 8px; color: var(--text-secondary); } | |
| 1181 | +.okr-detail.okr-empty p { color: var(--text-muted); font-size: 13px; } | |
| 1182 | + | |
| 1183 | +.okr-hero { padding: 30px 40px; border-bottom: 1px solid var(--border-primary); background: linear-gradient(180deg, var(--accent-bg) 0%, transparent 100%); } | |
| 1184 | +.okr-hero-row { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; } | |
| 1185 | +.okr-hero-id { font-size: 14px; font-family: 'JetBrains Mono', monospace; font-weight: 700; color: var(--accent-light); } | |
| 1186 | +.okr-hero-status { font-size: 12px; font-weight: 600; padding: 3px 8px; border-radius: 6px; } | |
| 1187 | +.okr-hero-status.active { background: rgba(59,130,246,0.2); color: #60a5fa; border: 1px solid rgba(59,130,246,0.3); } | |
| 1188 | +.okr-hero-status.done { background: rgba(16,185,129,0.2); color: #34d399; border: 1px solid rgba(16,185,129,0.3); } | |
| 1189 | +.okr-hero-owner { font-size: 12px; font-weight: 500; color: var(--text-secondary); margin-left: auto; background: var(--bg-surface); padding: 4px 10px; border-radius: 12px; border: 1px solid var(--border-primary); } | |
| 1190 | +.okr-hero-title { font-size: 26px; font-weight: 800; margin: 0 0 10px; letter-spacing: -0.5px; } | |
| 1191 | +.okr-hero-desc { font-size: 14px; color: var(--text-tertiary); line-height: 1.6; max-width: 800px; margin: 0; } | |
| 1192 | + | |
| 1193 | +.okr-hero-progress { margin-top: 24px; max-width: 600px; background: var(--bg-surface); padding: 16px 20px; border-radius: 12px; border: 1px solid var(--border-primary); } | |
| 1194 | +.okr-hp-top { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 10px; font-size: 13px; font-weight: 600; color: var(--text-secondary); } | |
| 1195 | +.okr-hp-pct { font-size: 24px; font-weight: 800; color: var(--accent-light); line-height: 1; } | |
| 1196 | +.okr-hp-bar { height: 8px; background: var(--bg-input-focus); border-radius: 4px; overflow: hidden; margin-bottom: 12px; } | |
| 1197 | +.okr-hp-fill { height: 100%; background: linear-gradient(90deg, #10b981, #34d399); border-radius: 4px; transition: width 0.8s cubic-bezier(0.16, 1, 0.3, 1); } | |
| 1198 | +.okr-hp-stats { display: flex; gap: 16px; font-size: 11px; font-weight: 500; color: var(--text-tertiary); } | |
| 1199 | +.okr-hp-stats .stat-d { color: #34d399; } | |
| 1200 | +.okr-hp-stats .stat-p { color: #60a5fa; } | |
| 1201 | +.okr-hp-stats .stat-w { color: var(--text-muted); } | |
| 1202 | + | |
| 1203 | +/* OKR Tree */ | |
| 1204 | +.okr-tree-area { padding: 30px 40px; max-width: 1000px; } | |
| 1205 | +.okr-tree-title { font-size: 18px; font-weight: 600; margin: 0 0 20px; color: var(--text-primary); } | |
| 1206 | +.okr-tree { display: flex; flex-direction: column; gap: 6px; } | |
| 1207 | +.okr-tnode-line { display: flex; width: 100%; } | |
| 1208 | +.okr-tnode-indent { position: relative; } | |
| 1209 | +.okr-tnode-indent::before { content: ''; position: absolute; top: -16px; bottom: 50%; left: -14px; width: 14px; border-left: 1.5px solid var(--border-active); border-bottom: 1.5px solid var(--border-active); border-bottom-left-radius: 6px; z-index: 1; opacity: 0.3; } | |
| 1210 | +.okr-tnode-card { flex: 1; background: var(--bg-surface); border: 1px solid var(--border-primary); border-radius: 8px; padding: 12px 16px; transition: all 0.2s; } | |
| 1211 | +.okr-tnode-card:hover { border-color: rgba(255,255,255,0.1); background: var(--bg-surface-hover); } | |
| 1212 | +.okr-tnode-card.okr-lv-2 { background: rgba(59,130,246,0.03); border-color: rgba(59,130,246,0.1); } | |
| 1213 | +.okr-tnode-card.okr-lv-3 { background: rgba(16,185,129,0.03); border-color: rgba(16,185,129,0.1); } | |
| 1214 | +.okr-tnode-hdr { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } | |
| 1215 | +.okr-nid { font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 700; color: var(--text-tertiary); } | |
| 1216 | +.okr-ntype { font-size: 9px; font-weight: 600; padding: 2px 6px; border-radius: 10px; background: rgba(255,255,255,0.05); color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; } | |
| 1217 | +.okr-nstatus { font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 4px; border: 1px solid transparent; } | |
| 1218 | +.okr-nstatus.done { background: rgba(16,185,129,0.1); color: #34d399; border-color: rgba(16,185,129,0.2); } | |
| 1219 | +.okr-nstatus.in_progress { background: rgba(59,130,246,0.1); color: #60a5fa; border-color: rgba(59,130,246,0.2); animation: okr-pulse 2s infinite; } | |
| 1220 | +.okr-nstatus.pending { background: rgba(255,255,255,0.05); color: var(--text-muted); border-color: var(--border-primary); } | |
| 1221 | +@keyframes okr-pulse { 0% { box-shadow: 0 0 0 0 rgba(96,165,250,0.2); } 70% { box-shadow: 0 0 0 4px rgba(96,165,250,0); } 100% { box-shadow: 0 0 0 0 rgba(96,165,250,0); } } | |
| 1222 | +.okr-ntitle { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; line-height: 1.4; } | |
| 1223 | +.okr-nfooter { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 4px; } | |
| 1224 | +.okr-nowner { font-size: 11px; font-weight: 500; color: var(--accent-light); display: flex; align-items: center; gap: 4px; background: var(--accent-bg); padding: 2px 8px; border-radius: 10px; } | |
| 1225 | +.okr-nprio { font-size: 10px; font-weight: 700; font-family: 'JetBrains Mono', monospace; } | |
| 1226 | +.okr-nprio.P0 { color: #ef4444; } | |
| 1227 | +.okr-nprio.P1 { color: #f59e0b; } | |
| 1228 | +.okr-nprio.P2 { color: var(--text-muted); } | |
| 1229 | +.okr-ntask { font-size: 10px; font-family: 'JetBrains Mono', monospace; color: var(--blue-text); background: rgba(59,130,246,0.1); padding: 2px 6px; border-radius: 4px; } | |
| 1230 | +.okr-nsummary { margin-top: 8px; padding-top: 8px; border-top: 1px dashed var(--border-primary); font-size: 11px; color: var(--text-tertiary); line-height: 1.5; } | |
| 1231 | +.okr-nsummary strong { color: var(--text-secondary); } | |
| 1232 | + | |
| 1233 | +.empty-state-large { text-align: center; color: var(--text-muted); } | |
| 899 | 1234 | |
| 900 | 1235 | /* ═══ Modal ═══ */ |
| 901 | 1236 | .modal-overlay { |
| ... | ... | @@ -1321,4 +1656,39 @@ function closeDropdown() { |
| 1321 | 1656 | white-space: pre-wrap; word-break: break-word; |
| 1322 | 1657 | max-height: 120px; overflow-y: auto; |
| 1323 | 1658 | } |
| 1659 | + | |
| 1660 | +/* Skills Tab */ | |
| 1661 | +.search-bar-inline { display: flex; gap: 8px; align-items: center; } | |
| 1662 | +.search-bar-inline input { padding: 8px 12px; border: 1px solid var(--border-primary); border-radius: 6px; background: var(--bg-surface); color: var(--text-primary); width: 200px; } | |
| 1663 | +.skills-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; margin-top: 16px; } | |
| 1664 | +.skill-card-mini { display: flex; align-items: center; gap: 12px; padding: 16px; background: var(--bg-surface); border: 1px solid var(--border-primary); border-radius: 10px; cursor: pointer; transition: all 0.2s; } | |
| 1665 | +.skill-card-mini:hover { border-color: var(--accent); transform: translateY(-2px); } | |
| 1666 | +.skill-icon { font-size: 28px; } | |
| 1667 | +.skill-info { flex: 1; } | |
| 1668 | +.skill-name { font-weight: 600; font-size: 14px; margin-bottom: 4px; } | |
| 1669 | +.skill-desc { font-size: 12px; color: var(--text-muted); } | |
| 1670 | +.skill-meta-mini { display: flex; align-items: center; } | |
| 1671 | +.version-badge { font-size: 11px; padding: 3px 8px; background: var(--bg-primary); border-radius: 4px; color: var(--text-muted); } | |
| 1672 | +.taxonomy-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; } | |
| 1673 | +.taxonomy-tag { font-size: 11px; padding: 4px 8px; background: var(--bg-surface); border-radius: 4px; } | |
| 1674 | + | |
| 1675 | +/* Jobs Tab */ | |
| 1676 | +.filter-inline { display: flex; gap: 8px; align-items: center; } | |
| 1677 | +.filter-inline select { padding: 8px 12px; border: 1px solid var(--border-primary); border-radius: 6px; background: var(--bg-surface); color: var(--text-primary); } | |
| 1678 | +.jobs-table-mini { display: flex; flex-direction: column; gap: 1px; background: var(--border-primary); border-radius: 10px; overflow: hidden; margin-top: 16px; } | |
| 1679 | +.table-header-mini { display: grid; grid-template-columns: 120px 100px 180px 100px; gap: 16px; padding: 12px 16px; background: var(--bg-primary); font-weight: 600; font-size: 13px; color: var(--text-muted); } | |
| 1680 | +.table-row-mini { display: grid; grid-template-columns: 120px 100px 180px 100px; gap: 16px; padding: 12px 16px; background: var(--bg-surface); align-items: center; font-size: 13px; } | |
| 1681 | +.job-id { font-family: monospace; color: var(--text-muted); } | |
| 1682 | +.status-badge-mini { font-size: 11px; padding: 4px 8px; border-radius: 4px; display: inline-block; } | |
| 1683 | +.status-badge-mini.pending { background: #ffd33d22; color: #ffd33d; } | |
| 1684 | +.status-badge-mini.running { background: #58a6ff22; color: #58a6ff; } | |
| 1685 | +.status-badge-mini.completed { background: #3fb95022; color: #3fb950; } | |
| 1686 | +.status-badge-mini.failed { background: #f8514922; color: #f85149; } | |
| 1687 | +.btn-action-mini { padding: 6px 12px; background: var(--bg-primary); border: 1px solid var(--border-primary); border-radius: 4px; color: var(--text-secondary); cursor: pointer; font-size: 12px; } | |
| 1688 | +.btn-action-mini:hover { border-color: var(--accent); } | |
| 1689 | +.btn-sm { padding: 8px 12px; background: var(--bg-surface); border: 1px solid var(--border-primary); border-radius: 6px; cursor: pointer; font-size: 13px; } | |
| 1690 | +.btn-sm:hover { border-color: var(--accent); } | |
| 1691 | +.btn-install-large { padding: 10px 20px; background: var(--accent); color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; } | |
| 1692 | +.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 1000; } | |
| 1693 | +.modal-content { background: var(--bg-secondary); border: 1px solid var(--border-primary); border-radius: 12px; max-width: 500px; width: 90%; max-height: 80vh; overflow-y: auto; } | |
| 1324 | 1694 | </style> | ... | ... |
web/src/views/JobsPanel.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="jobs-panel"> | |
| 3 | + <AppNav /> | |
| 4 | + <div class="panel-container"> | |
| 5 | + <!-- 头部 --> | |
| 6 | + <div class="panel-header"> | |
| 7 | + <h1>📋 任务面板</h1> | |
| 8 | + <div class="header-actions"> | |
| 9 | + <button class="btn-create-job" @click="showCreateModal = true"> | |
| 10 | + ➕ 创建任务 | |
| 11 | + </button> | |
| 12 | + <button class="btn-refresh" @click="loadJobs"> | |
| 13 | + 🔄 刷新 | |
| 14 | + </button> | |
| 15 | + </div> | |
| 16 | + </div> | |
| 17 | + | |
| 18 | + <!-- 筛选栏 --> | |
| 19 | + <div class="filter-bar"> | |
| 20 | + <div class="filter-group"> | |
| 21 | + <label>状态:</label> | |
| 22 | + <select v-model="statusFilter" @change="loadJobs"> | |
| 23 | + <option value="">全部</option> | |
| 24 | + <option value="pending">⏳ 待处理</option> | |
| 25 | + <option value="running">🔄 运行中</option> | |
| 26 | + <option value="completed">✅ 完成</option> | |
| 27 | + <option value="failed">❌ 失败</option> | |
| 28 | + </select> | |
| 29 | + </div> | |
| 30 | + <div class="filter-group"> | |
| 31 | + <label>技能:</label> | |
| 32 | + <select v-model="skillFilter" @change="loadJobs"> | |
| 33 | + <option value="">全部</option> | |
| 34 | + <option v-for="skill in availableSkills" :key="skill" :value="skill"> | |
| 35 | + {{ skill }} | |
| 36 | + </option> | |
| 37 | + </select> | |
| 38 | + </div> | |
| 39 | + </div> | |
| 40 | + | |
| 41 | + <!-- 任务列表 --> | |
| 42 | + <div v-if="loading" class="loading-state"> | |
| 43 | + <span class="loading-spinner">⏳ 加载中...</span> | |
| 44 | + </div> | |
| 45 | + | |
| 46 | + <div v-else-if="jobs.length === 0" class="empty-state"> | |
| 47 | + <span class="empty-icon">📋</span> | |
| 48 | + <p>暂无任务,请创建新任务</p> | |
| 49 | + </div> | |
| 50 | + | |
| 51 | + <div v-else class="jobs-table"> | |
| 52 | + <div class="table-header"> | |
| 53 | + <span class="col-id">ID</span> | |
| 54 | + <span class="col-skill">技能</span> | |
| 55 | + <span class="col-status">状态</span> | |
| 56 | + <span class="col-input">输入</span> | |
| 57 | + <span class="col-time">创建时间</span> | |
| 58 | + <span class="col-actions">操作</span> | |
| 59 | + </div> | |
| 60 | + <div | |
| 61 | + v-for="job in jobs" | |
| 62 | + :key="job.id" | |
| 63 | + class="table-row" | |
| 64 | + @click="selectJob(job)" | |
| 65 | + > | |
| 66 | + <span class="col-id">{{ job.id?.substring(0, 8) || '-' }}</span> | |
| 67 | + <span class="col-skill"> | |
| 68 | + <span class="skill-badge">{{ job.skillId || job.skill || '-' }}</span> | |
| 69 | + </span> | |
| 70 | + <span class="col-status"> | |
| 71 | + <span class="status-badge" :class="job.status"> | |
| 72 | + {{ statusLabel(job.status) }} | |
| 73 | + </span> | |
| 74 | + </span> | |
| 75 | + <span class="col-input"> | |
| 76 | + <span class="input-preview">{{ formatInput(job.input) }}</span> | |
| 77 | + </span> | |
| 78 | + <span class="col-time">{{ formatDate(job.startedAt || job.createdAt) }}</span> | |
| 79 | + <span class="col-actions"> | |
| 80 | + <button class="btn-action" @click.stop="viewJobDetail(job)"> | |
| 81 | + 👁 查看 | |
| 82 | + </button> | |
| 83 | + <button | |
| 84 | + v-if="job.status === 'failed'" | |
| 85 | + class="btn-action retry" | |
| 86 | + @click.stop="retryJob(job.id)" | |
| 87 | + > | |
| 88 | + 🔁 重试 | |
| 89 | + </button> | |
| 90 | + </span> | |
| 91 | + </div> | |
| 92 | + </div> | |
| 93 | + | |
| 94 | + <!-- 分页 --> | |
| 95 | + <div v-if="totalPages > 1" class="pagination"> | |
| 96 | + <button | |
| 97 | + :disabled="currentPage === 1" | |
| 98 | + @click="goToPage(currentPage - 1)" | |
| 99 | + > | |
| 100 | + ‹ 上一页 | |
| 101 | + </button> | |
| 102 | + <span class="page-info">{{ currentPage }} / {{ totalPages }}</span> | |
| 103 | + <button | |
| 104 | + :disabled="currentPage === totalPages" | |
| 105 | + @click="goToPage(currentPage + 1)" | |
| 106 | + > | |
| 107 | + 下一页 › | |
| 108 | + </button> | |
| 109 | + </div> | |
| 110 | + </div> | |
| 111 | + | |
| 112 | + <!-- 创建任务弹窗 --> | |
| 113 | + <div v-if="showCreateModal" class="modal-overlay" @click="showCreateModal = false"> | |
| 114 | + <div class="modal-content create-job-modal" @click.stop> | |
| 115 | + <div class="modal-header"> | |
| 116 | + <h2>创建新任务</h2> | |
| 117 | + <button class="btn-close" @click="showCreateModal = false">✕</button> | |
| 118 | + </div> | |
| 119 | + <div class="modal-body"> | |
| 120 | + <div class="form-group"> | |
| 121 | + <label>选择技能</label> | |
| 122 | + <select v-model="newJob.skillId" required> | |
| 123 | + <option value="">请选择技能...</option> | |
| 124 | + <option v-for="skill in availableSkills" :key="skill" :value="skill"> | |
| 125 | + {{ skill }} | |
| 126 | + </option> | |
| 127 | + </select> | |
| 128 | + </div> | |
| 129 | + <div class="form-group"> | |
| 130 | + <label>输入参数 (JSON)</label> | |
| 131 | + <textarea | |
| 132 | + v-model="newJob.input" | |
| 133 | + placeholder='{"query": "..."}' | |
| 134 | + rows="6" | |
| 135 | + ></textarea> | |
| 136 | + </div> | |
| 137 | + </div> | |
| 138 | + <div class="modal-footer"> | |
| 139 | + <button class="btn-cancel" @click="showCreateModal = false">取消</button> | |
| 140 | + <button class="btn-submit" @click="createJob" :disabled="!newJob.skillId"> | |
| 141 | + ➕ 创建 | |
| 142 | + </button> | |
| 143 | + </div> | |
| 144 | + </div> | |
| 145 | + </div> | |
| 146 | + | |
| 147 | + <!-- 任务详情弹窗 --> | |
| 148 | + <div v-if="selectedJobDetail" class="modal-overlay" @click="selectedJobDetail = null"> | |
| 149 | + <div class="modal-content job-detail-modal" @click.stop> | |
| 150 | + <div class="modal-header"> | |
| 151 | + <h2>任务详情</h2> | |
| 152 | + <button class="btn-close" @click="selectedJobDetail = null">✕</button> | |
| 153 | + </div> | |
| 154 | + <div class="modal-body"> | |
| 155 | + <div class="detail-row"> | |
| 156 | + <span class="detail-label">ID:</span> | |
| 157 | + <span class="detail-value">{{ selectedJobDetail.id }}</span> | |
| 158 | + </div> | |
| 159 | + <div class="detail-row"> | |
| 160 | + <span class="detail-label">状态:</span> | |
| 161 | + <span class="status-badge" :class="selectedJobDetail.status"> | |
| 162 | + {{ statusLabel(selectedJobDetail.status) }} | |
| 163 | + </span> | |
| 164 | + </div> | |
| 165 | + <div class="detail-row"> | |
| 166 | + <span class="detail-label">技能:</span> | |
| 167 | + <span class="detail-value">{{ selectedJobDetail.skillId || selectedJobDetail.skill }}</span> | |
| 168 | + </div> | |
| 169 | + <div class="detail-section"> | |
| 170 | + <h4>输入</h4> | |
| 171 | + <pre>{{ JSON.stringify(selectedJobDetail.input, null, 2) }}</pre> | |
| 172 | + </div> | |
| 173 | + <div v-if="selectedJobDetail.output" class="detail-section"> | |
| 174 | + <h4>输出</h4> | |
| 175 | + <pre>{{ JSON.stringify(selectedJobDetail.output, null, 2) }}</pre> | |
| 176 | + </div> | |
| 177 | + <div v-if="selectedJobDetail.error" class="detail-section"> | |
| 178 | + <h4>错误</h4> | |
| 179 | + <pre class="error-output">{{ selectedJobDetail.error }}</pre> | |
| 180 | + </div> | |
| 181 | + </div> | |
| 182 | + <div class="modal-footer"> | |
| 183 | + <button | |
| 184 | + v-if="selectedJobDetail.status === 'failed'" | |
| 185 | + class="btn-retry" | |
| 186 | + @click="retryJob(selectedJobDetail.id); selectedJobDetail = null" | |
| 187 | + > | |
| 188 | + 🔁 重试 | |
| 189 | + </button> | |
| 190 | + <button class="btn-close-footer" @click="selectedJobDetail = null"> | |
| 191 | + 关闭 | |
| 192 | + </button> | |
| 193 | + </div> | |
| 194 | + </div> | |
| 195 | + </div> | |
| 196 | + </div> | |
| 197 | +</template> | |
| 198 | + | |
| 199 | +<script setup> | |
| 200 | +import { ref, onMounted } from 'vue' | |
| 201 | +import AppNav from '@/components/AppNav.vue' | |
| 202 | + | |
| 203 | +// 状态 | |
| 204 | +const jobs = ref([]) | |
| 205 | +const loading = ref(false) | |
| 206 | +const currentPage = ref(1) | |
| 207 | +const totalPages = ref(1) | |
| 208 | +const total = ref(0) | |
| 209 | +const statusFilter = ref('') | |
| 210 | +const skillFilter = ref('') | |
| 211 | +const availableSkills = ref([]) | |
| 212 | +const showCreateModal = ref(false) | |
| 213 | +const selectedJobDetail = ref(null) | |
| 214 | + | |
| 215 | +const newJob = ref({ | |
| 216 | + skillId: '', | |
| 217 | + input: '{}' | |
| 218 | +}) | |
| 219 | + | |
| 220 | +// API 基础 | |
| 221 | +const API_BASE = '/api/open' | |
| 222 | + | |
| 223 | +// 加载任务列表 | |
| 224 | +const loadJobs = async () => { | |
| 225 | + loading.value = true | |
| 226 | + try { | |
| 227 | + const params = new URLSearchParams() | |
| 228 | + if (statusFilter.value) params.append('status', statusFilter.value) | |
| 229 | + if (skillFilter.value) params.append('skill', skillFilter.value) | |
| 230 | + params.append('page', currentPage.value) | |
| 231 | + params.append('limit', 20) | |
| 232 | + | |
| 233 | + const response = await fetch(`${API_BASE}/jobs?${params}`) | |
| 234 | + const data = await response.json() | |
| 235 | + jobs.value = data.jobs || data.results || [] | |
| 236 | + total.value = data.total || 0 | |
| 237 | + totalPages.value = Math.ceil(total.value / 20) || 1 | |
| 238 | + } catch (error) { | |
| 239 | + console.error('加载任务失败:', error) | |
| 240 | + } finally { | |
| 241 | + loading.value = false | |
| 242 | + } | |
| 243 | +} | |
| 244 | + | |
| 245 | +// 加载可用技能 | |
| 246 | +const loadAvailableSkills = async () => { | |
| 247 | + try { | |
| 248 | + const response = await fetch(`${API_BASE}/skills/search?q=&limit=100`) | |
| 249 | + const data = await response.json() | |
| 250 | + availableSkills.value = (data.results || []).map(s => s.name) | |
| 251 | + } catch (error) { | |
| 252 | + console.error('加载技能列表失败:', error) | |
| 253 | + } | |
| 254 | +} | |
| 255 | + | |
| 256 | +// 创建任务 | |
| 257 | +const createJob = async () => { | |
| 258 | + try { | |
| 259 | + let inputObj = {} | |
| 260 | + try { | |
| 261 | + inputObj = JSON.parse(newJob.value.input || '{}') | |
| 262 | + } catch (e) { | |
| 263 | + alert('输入参数必须是有效的 JSON 格式') | |
| 264 | + return | |
| 265 | + } | |
| 266 | + | |
| 267 | + const response = await fetch(`${API_BASE}/jobs`, { | |
| 268 | + method: 'POST', | |
| 269 | + headers: { 'Content-Type': 'application/json' }, | |
| 270 | + body: JSON.stringify({ | |
| 271 | + skillId: newJob.value.skillId, | |
| 272 | + input: inputObj | |
| 273 | + }) | |
| 274 | + }) | |
| 275 | + | |
| 276 | + if (response.ok) { | |
| 277 | + alert('✅ 任务创建成功!') | |
| 278 | + showCreateModal.value = false | |
| 279 | + newJob.value = { skillId: '', input: '{}' } | |
| 280 | + loadJobs() | |
| 281 | + } else { | |
| 282 | + alert('❌ 创建失败') | |
| 283 | + } | |
| 284 | + } catch (error) { | |
| 285 | + console.error('创建任务失败:', error) | |
| 286 | + alert('❌ 创建失败') | |
| 287 | + } | |
| 288 | +} | |
| 289 | + | |
| 290 | +// 查看任务详情 | |
| 291 | +const viewJobDetail = async (job) => { | |
| 292 | + try { | |
| 293 | + const response = await fetch(`${API_BASE}/jobs/${job.id}`) | |
| 294 | + const data = await response.json() | |
| 295 | + selectedJobDetail.value = data.job || data | |
| 296 | + } catch (error) { | |
| 297 | + console.error('获取任务详情失败:', error) | |
| 298 | + selectedJobDetail.value = job | |
| 299 | + } | |
| 300 | +} | |
| 301 | + | |
| 302 | +// 重试任务 | |
| 303 | +const retryJob = async (jobId) => { | |
| 304 | + try { | |
| 305 | + const response = await fetch(`${API_BASE}/jobs/${jobId}/retry`, { | |
| 306 | + method: 'POST' | |
| 307 | + }) | |
| 308 | + if (response.ok) { | |
| 309 | + alert('✅ 任务已重新执行') | |
| 310 | + loadJobs() | |
| 311 | + } else { | |
| 312 | + alert('❌ 重试失败') | |
| 313 | + } | |
| 314 | + } catch (error) { | |
| 315 | + console.error('重试失败:', error) | |
| 316 | + alert('❌ 重试失败') | |
| 317 | + } | |
| 318 | +} | |
| 319 | + | |
| 320 | +// 选择任务 | |
| 321 | +const selectJob = (job) => { | |
| 322 | + viewJobDetail(job) | |
| 323 | +} | |
| 324 | + | |
| 325 | +// 分页 | |
| 326 | +const goToPage = (page) => { | |
| 327 | + currentPage.value = page | |
| 328 | + loadJobs() | |
| 329 | +} | |
| 330 | + | |
| 331 | +// 格式化 | |
| 332 | +const statusLabel = (status) => { | |
| 333 | + const labels = { | |
| 334 | + pending: '待处理', | |
| 335 | + running: '运行中', | |
| 336 | + completed: '完成', | |
| 337 | + failed: '失败' | |
| 338 | + } | |
| 339 | + return labels[status] || status | |
| 340 | +} | |
| 341 | + | |
| 342 | +const formatDate = (dateStr) => { | |
| 343 | + if (!dateStr) return '-' | |
| 344 | + const date = new Date(dateStr) | |
| 345 | + return date.toLocaleString('zh-CN') | |
| 346 | +} | |
| 347 | + | |
| 348 | +const formatInput = (input) => { | |
| 349 | + if (!input) return '-' | |
| 350 | + const str = typeof input === 'string' ? input : JSON.stringify(input) | |
| 351 | + return str.length > 50 ? str.substring(0, 50) + '...' : str | |
| 352 | +} | |
| 353 | + | |
| 354 | +// 初始化 | |
| 355 | +onMounted(() => { | |
| 356 | + loadJobs() | |
| 357 | + loadAvailableSkills() | |
| 358 | +}) | |
| 359 | +</script> | |
| 360 | + | |
| 361 | +<style scoped> | |
| 362 | +.jobs-panel { | |
| 363 | + min-height: 100vh; | |
| 364 | + background: var(--bg-primary, #0d1117); | |
| 365 | + color: var(--text-primary, #e6edf3); | |
| 366 | +} | |
| 367 | + | |
| 368 | +.panel-container { | |
| 369 | + max-width: 1200px; | |
| 370 | + margin: 0 auto; | |
| 371 | + padding: 24px; | |
| 372 | +} | |
| 373 | + | |
| 374 | +.panel-header { | |
| 375 | + display: flex; | |
| 376 | + justify-content: space-between; | |
| 377 | + align-items: center; | |
| 378 | + margin-bottom: 24px; | |
| 379 | +} | |
| 380 | + | |
| 381 | +.panel-header h1 { | |
| 382 | + font-size: 28px; | |
| 383 | + font-weight: 600; | |
| 384 | +} | |
| 385 | + | |
| 386 | +.header-actions { | |
| 387 | + display: flex; | |
| 388 | + gap: 12px; | |
| 389 | +} | |
| 390 | + | |
| 391 | +.btn-create-job, | |
| 392 | +.btn-refresh { | |
| 393 | + padding: 10px 16px; | |
| 394 | + border-radius: 8px; | |
| 395 | + cursor: pointer; | |
| 396 | + font-size: 14px; | |
| 397 | + border: none; | |
| 398 | +} | |
| 399 | + | |
| 400 | +.btn-create-job { | |
| 401 | + background: var(--accent, #58a6ff); | |
| 402 | + color: white; | |
| 403 | +} | |
| 404 | + | |
| 405 | +.btn-refresh { | |
| 406 | + background: var(--bg-secondary, #161b22); | |
| 407 | + border: 1px solid var(--border-color, #30363d); | |
| 408 | + color: var(--text-secondary, #8b949e); | |
| 409 | +} | |
| 410 | + | |
| 411 | +.filter-bar { | |
| 412 | + display: flex; | |
| 413 | + gap: 24px; | |
| 414 | + margin-bottom: 24px; | |
| 415 | + padding: 16px; | |
| 416 | + background: var(--bg-secondary, #161b22); | |
| 417 | + border-radius: 8px; | |
| 418 | +} | |
| 419 | + | |
| 420 | +.filter-group { | |
| 421 | + display: flex; | |
| 422 | + align-items: center; | |
| 423 | + gap: 8px; | |
| 424 | +} | |
| 425 | + | |
| 426 | +.filter-group label { | |
| 427 | + color: var(--text-secondary, #8b949e); | |
| 428 | + font-size: 14px; | |
| 429 | +} | |
| 430 | + | |
| 431 | +.filter-group select { | |
| 432 | + padding: 8px 12px; | |
| 433 | + border: 1px solid var(--border-color, #30363d); | |
| 434 | + border-radius: 6px; | |
| 435 | + background: var(--bg-primary, #0d1117); | |
| 436 | + color: var(--text-primary, #e6edf3); | |
| 437 | + font-size: 14px; | |
| 438 | +} | |
| 439 | + | |
| 440 | +.loading-state, | |
| 441 | +.empty-state { | |
| 442 | + text-align: center; | |
| 443 | + padding: 60px 20px; | |
| 444 | + color: var(--text-secondary, #8b949e); | |
| 445 | +} | |
| 446 | + | |
| 447 | +.empty-icon { | |
| 448 | + font-size: 48px; | |
| 449 | + display: block; | |
| 450 | + margin-bottom: 16px; | |
| 451 | +} | |
| 452 | + | |
| 453 | +.jobs-table { | |
| 454 | + background: var(--bg-secondary, #161b22); | |
| 455 | + border: 1px solid var(--border-color, #30363d); | |
| 456 | + border-radius: 12px; | |
| 457 | + overflow: hidden; | |
| 458 | +} | |
| 459 | + | |
| 460 | +.table-header, | |
| 461 | +.table-row { | |
| 462 | + display: grid; | |
| 463 | + grid-template-columns: 100px 150px 100px 1fr 180px 140px; | |
| 464 | + gap: 16px; | |
| 465 | + padding: 12px 16px; | |
| 466 | + align-items: center; | |
| 467 | +} | |
| 468 | + | |
| 469 | +.table-header { | |
| 470 | + background: var(--bg-primary, #0d1117); | |
| 471 | + font-weight: 600; | |
| 472 | + font-size: 13px; | |
| 473 | + color: var(--text-secondary, #8b949e); | |
| 474 | + border-bottom: 1px solid var(--border-color, #30363d); | |
| 475 | +} | |
| 476 | + | |
| 477 | +.table-row { | |
| 478 | + border-bottom: 1px solid var(--border-color, #30363d); | |
| 479 | + cursor: pointer; | |
| 480 | + transition: background 0.2s; | |
| 481 | +} | |
| 482 | + | |
| 483 | +.table-row:hover { | |
| 484 | + background: rgba(88, 166, 255, 0.1); | |
| 485 | +} | |
| 486 | + | |
| 487 | +.table-row:last-child { | |
| 488 | + border-bottom: none; | |
| 489 | +} | |
| 490 | + | |
| 491 | +.skill-badge { | |
| 492 | + font-size: 12px; | |
| 493 | + padding: 4px 8px; | |
| 494 | + background: var(--bg-primary, #0d1117); | |
| 495 | + border-radius: 4px; | |
| 496 | +} | |
| 497 | + | |
| 498 | +.status-badge { | |
| 499 | + font-size: 12px; | |
| 500 | + padding: 4px 8px; | |
| 501 | + border-radius: 4px; | |
| 502 | +} | |
| 503 | + | |
| 504 | +.status-badge.pending { background: #ffd33d22; color: #ffd33d; } | |
| 505 | +.status-badge.running { background: #58a6ff22; color: #58a6ff; } | |
| 506 | +.status-badge.completed { background: #3fb95022; color: #3fb950; } | |
| 507 | +.status-badge.failed { background: #f8514922; color: #f85149; } | |
| 508 | + | |
| 509 | +.input-preview { | |
| 510 | + font-size: 13px; | |
| 511 | + color: var(--text-secondary, #8b949e); | |
| 512 | +} | |
| 513 | + | |
| 514 | +.btn-action { | |
| 515 | + padding: 6px 12px; | |
| 516 | + background: var(--bg-primary, #0d1117); | |
| 517 | + border: 1px solid var(--border-color, #30363d); | |
| 518 | + border-radius: 4px; | |
| 519 | + color: var(--text-primary, #e6edf3); | |
| 520 | + cursor: pointer; | |
| 521 | + font-size: 12px; | |
| 522 | +} | |
| 523 | + | |
| 524 | +.btn-action.retry { | |
| 525 | + color: #58a6ff; | |
| 526 | + border-color: #58a6ff; | |
| 527 | +} | |
| 528 | + | |
| 529 | +.pagination { | |
| 530 | + display: flex; | |
| 531 | + justify-content: center; | |
| 532 | + align-items: center; | |
| 533 | + gap: 16px; | |
| 534 | + margin-top: 24px; | |
| 535 | +} | |
| 536 | + | |
| 537 | +.pagination button { | |
| 538 | + padding: 8px 16px; | |
| 539 | + background: var(--bg-secondary, #161b22); | |
| 540 | + border: 1px solid var(--border-color, #30363d); | |
| 541 | + border-radius: 6px; | |
| 542 | + color: var(--text-primary, #e6edf3); | |
| 543 | + cursor: pointer; | |
| 544 | +} | |
| 545 | + | |
| 546 | +.pagination button:disabled { | |
| 547 | + opacity: 0.5; | |
| 548 | + cursor: not-allowed; | |
| 549 | +} | |
| 550 | + | |
| 551 | +/* Modal */ | |
| 552 | +.modal-overlay { | |
| 553 | + position: fixed; | |
| 554 | + top: 0; | |
| 555 | + left: 0; | |
| 556 | + right: 0; | |
| 557 | + bottom: 0; | |
| 558 | + background: rgba(0, 0, 0, 0.7); | |
| 559 | + display: flex; | |
| 560 | + align-items: center; | |
| 561 | + justify-content: center; | |
| 562 | + z-index: 1000; | |
| 563 | +} | |
| 564 | + | |
| 565 | +.modal-content { | |
| 566 | + background: var(--bg-secondary, #161b22); | |
| 567 | + border: 1px solid var(--border-color, #30363d); | |
| 568 | + border-radius: 12px; | |
| 569 | + max-width: 600px; | |
| 570 | + width: 90%; | |
| 571 | + max-height: 80vh; | |
| 572 | + overflow-y: auto; | |
| 573 | +} | |
| 574 | + | |
| 575 | +.modal-header { | |
| 576 | + display: flex; | |
| 577 | + justify-content: space-between; | |
| 578 | + align-items: center; | |
| 579 | + padding: 20px; | |
| 580 | + border-bottom: 1px solid var(--border-color, #30363d); | |
| 581 | +} | |
| 582 | + | |
| 583 | +.modal-header h2 { | |
| 584 | + margin: 0; | |
| 585 | + font-size: 20px; | |
| 586 | +} | |
| 587 | + | |
| 588 | +.btn-close { | |
| 589 | + background: none; | |
| 590 | + border: none; | |
| 591 | + font-size: 20px; | |
| 592 | + color: var(--text-secondary, #8b949e); | |
| 593 | + cursor: pointer; | |
| 594 | +} | |
| 595 | + | |
| 596 | +.modal-body { | |
| 597 | + padding: 20px; | |
| 598 | +} | |
| 599 | + | |
| 600 | +.form-group { | |
| 601 | + margin-bottom: 16px; | |
| 602 | +} | |
| 603 | + | |
| 604 | +.form-group label { | |
| 605 | + display: block; | |
| 606 | + margin-bottom: 8px; | |
| 607 | + font-size: 14px; | |
| 608 | + color: var(--text-secondary, #8b949e); | |
| 609 | +} | |
| 610 | + | |
| 611 | +.form-group select, | |
| 612 | +.form-group textarea { | |
| 613 | + width: 100%; | |
| 614 | + padding: 12px; | |
| 615 | + border: 1px solid var(--border-color, #30363d); | |
| 616 | + border-radius: 6px; | |
| 617 | + background: var(--bg-primary, #0d1117); | |
| 618 | + color: var(--text-primary, #e6edf3); | |
| 619 | + font-size: 14px; | |
| 620 | + font-family: monospace; | |
| 621 | +} | |
| 622 | + | |
| 623 | +.form-group textarea { | |
| 624 | + resize: vertical; | |
| 625 | + min-height: 120px; | |
| 626 | +} | |
| 627 | + | |
| 628 | +.modal-footer { | |
| 629 | + display: flex; | |
| 630 | + justify-content: flex-end; | |
| 631 | + gap: 12px; | |
| 632 | + padding: 20px; | |
| 633 | + border-top: 1px solid var(--border-color, #30363d); | |
| 634 | +} | |
| 635 | + | |
| 636 | +.btn-cancel { | |
| 637 | + padding: 10px 16px; | |
| 638 | + background: var(--bg-primary, #0d1117); | |
| 639 | + border: 1px solid var(--border-color, #30363d); | |
| 640 | + border-radius: 6px; | |
| 641 | + color: var(--text-secondary, #8b949e); | |
| 642 | + cursor: pointer; | |
| 643 | +} | |
| 644 | + | |
| 645 | +.btn-submit { | |
| 646 | + padding: 10px 16px; | |
| 647 | + background: var(--accent, #58a6ff); | |
| 648 | + border: none; | |
| 649 | + border-radius: 6px; | |
| 650 | + color: white; | |
| 651 | + cursor: pointer; | |
| 652 | +} | |
| 653 | + | |
| 654 | +.btn-submit:disabled { | |
| 655 | + opacity: 0.5; | |
| 656 | + cursor: not-allowed; | |
| 657 | +} | |
| 658 | + | |
| 659 | +.detail-row { | |
| 660 | + display: flex; | |
| 661 | + gap: 12px; | |
| 662 | + margin-bottom: 12px; | |
| 663 | +} | |
| 664 | + | |
| 665 | +.detail-label { | |
| 666 | + color: var(--text-secondary, #8b949e); | |
| 667 | + min-width: 60px; | |
| 668 | +} | |
| 669 | + | |
| 670 | +.detail-section { | |
| 671 | + margin-top: 16px; | |
| 672 | +} | |
| 673 | + | |
| 674 | +.detail-section h4 { | |
| 675 | + font-size: 14px; | |
| 676 | + color: var(--text-secondary, #8b949e); | |
| 677 | + margin-bottom: 8px; | |
| 678 | +} | |
| 679 | + | |
| 680 | +.detail-section pre { | |
| 681 | + background: var(--bg-primary, #0d1117); | |
| 682 | + padding: 12px; | |
| 683 | + border-radius: 6px; | |
| 684 | + font-size: 12px; | |
| 685 | + overflow-x: auto; | |
| 686 | + max-height: 200px; | |
| 687 | + overflow-y: auto; | |
| 688 | +} | |
| 689 | + | |
| 690 | +.error-output { | |
| 691 | + color: #f85149; | |
| 692 | +} | |
| 693 | + | |
| 694 | +.btn-retry { | |
| 695 | + padding: 10px 16px; | |
| 696 | + background: #58a6ff; | |
| 697 | + border: none; | |
| 698 | + border-radius: 6px; | |
| 699 | + color: white; | |
| 700 | + cursor: pointer; | |
| 701 | +} | |
| 702 | + | |
| 703 | +.btn-close-footer { | |
| 704 | + padding: 10px 16px; | |
| 705 | + background: var(--bg-primary, #0d1117); | |
| 706 | + border: 1px solid var(--border-color, #30363d); | |
| 707 | + border-radius: 6px; | |
| 708 | + color: var(--text-secondary, #8b949e); | |
| 709 | + cursor: pointer; | |
| 710 | +} | |
| 711 | +</style> | |
| 0 | 712 | \ No newline at end of file | ... | ... |
web/src/views/PipelineEditor.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="pipeline-editor"> | |
| 3 | + <AppNav /> | |
| 4 | + <div class="editor-container"> | |
| 5 | + <!-- 头部 --> | |
| 6 | + <div class="editor-header"> | |
| 7 | + <h1>⚙️ Pipeline 编辑器</h1> | |
| 8 | + <div class="header-actions"> | |
| 9 | + <button class="btn-create" @click="showCreateModal = true"> | |
| 10 | + ➕ 新建 Pipeline | |
| 11 | + </button> | |
| 12 | + <button class="btn-refresh" @click="loadPipelines"> | |
| 13 | + 🔄 刷新 | |
| 14 | + </button> | |
| 15 | + </div> | |
| 16 | + </div> | |
| 17 | + | |
| 18 | + <!-- Pipeline 列表 --> | |
| 19 | + <div v-if="loading" class="loading-state"> | |
| 20 | + <span class="loading-spinner">⏳ 加载中...</span> | |
| 21 | + </div> | |
| 22 | + | |
| 23 | + <div v-else-if="pipelines.length === 0" class="empty-state"> | |
| 24 | + <span class="empty-icon">🔗</span> | |
| 25 | + <p>暂无 Pipeline,请创建新的任务流水线</p> | |
| 26 | + <button class="btn-create-first" @click="showCreateModal = true"> | |
| 27 | + 创建第一个 Pipeline | |
| 28 | + </button> | |
| 29 | + </div> | |
| 30 | + | |
| 31 | + <div v-else class="pipelines-grid"> | |
| 32 | + <div | |
| 33 | + v-for="pipeline in pipelines" | |
| 34 | + :key="pipeline.id" | |
| 35 | + class="pipeline-card" | |
| 36 | + > | |
| 37 | + <div class="pipeline-header"> | |
| 38 | + <h3>{{ pipeline.name || pipeline.id }}</h3> | |
| 39 | + <span class="status-badge" :class="pipeline.status"> | |
| 40 | + {{ statusLabel(pipeline.status) }} | |
| 41 | + </span> | |
| 42 | + </div> | |
| 43 | + <div class="pipeline-meta"> | |
| 44 | + <span class="meta-item"> | |
| 45 | + 📅 创建: {{ formatDate(pipeline.createdAt) }} | |
| 46 | + </span> | |
| 47 | + <span v-if="pipeline.lastRun" class="meta-item"> | |
| 48 | + 🏃 最近: {{ formatDate(pipeline.lastRun) }} | |
| 49 | + </span> | |
| 50 | + </div> | |
| 51 | + <div v-if="pipeline.config" class="pipeline-stages"> | |
| 52 | + <div | |
| 53 | + v-for="(stage, idx) in pipeline.config.stages || []" | |
| 54 | + :key="idx" | |
| 55 | + class="stage-item" | |
| 56 | + > | |
| 57 | + <span class="stage-num">{{ idx + 1 }}</span> | |
| 58 | + <span class="stage-name">{{ stage.name || stage.skill || 'Stage ' + (idx + 1) }}</span> | |
| 59 | + </div> | |
| 60 | + </div> | |
| 61 | + <div class="pipeline-actions"> | |
| 62 | + <button class="btn-action" @click="editPipeline(pipeline)"> | |
| 63 | + ✏️ 编辑 | |
| 64 | + </button> | |
| 65 | + <button class="btn-action" @click="runPipeline(pipeline.id)"> | |
| 66 | + ▶️ 运行 | |
| 67 | + </button> | |
| 68 | + <button class="btn-action delete" @click="deletePipeline(pipeline.id)"> | |
| 69 | + 🗑️ 删除 | |
| 70 | + </button> | |
| 71 | + </div> | |
| 72 | + </div> | |
| 73 | + </div> | |
| 74 | + | |
| 75 | + <!-- 详细视图弹窗 --> | |
| 76 | + <div v-if="selectedPipeline" class="modal-overlay" @click="selectedPipeline = null"> | |
| 77 | + <div class="modal-content pipeline-detail-modal" @click.stop> | |
| 78 | + <div class="modal-header"> | |
| 79 | + <h2>{{ selectedPipeline.name || selectedPipeline.id }}</h2> | |
| 80 | + <button class="btn-close" @click="selectedPipeline = null">✕</button> | |
| 81 | + </div> | |
| 82 | + <div class="modal-body"> | |
| 83 | + <div class="detail-section"> | |
| 84 | + <h4>状态</h4> | |
| 85 | + <span class="status-badge large" :class="selectedPipeline.status"> | |
| 86 | + {{ statusLabel(selectedPipeline.status) }} | |
| 87 | + </span> | |
| 88 | + </div> | |
| 89 | + <div class="detail-section"> | |
| 90 | + <h4>配置</h4> | |
| 91 | + <pre>{{ JSON.stringify(selectedPipeline.config, null, 2) }}</pre> | |
| 92 | + </div> | |
| 93 | + <div v-if="selectedPipeline.history" class="detail-section"> | |
| 94 | + <h4>运行历史</h4> | |
| 95 | + <div class="history-list"> | |
| 96 | + <div | |
| 97 | + v-for="(run, idx) in selectedPipeline.history" | |
| 98 | + :key="idx" | |
| 99 | + class="history-item" | |
| 100 | + > | |
| 101 | + <span class="run-status" :class="run.status"></span> | |
| 102 | + <span class="run-time">{{ formatDate(run.startedAt) }}</span> | |
| 103 | + <span class="run-duration">{{ run.duration || '-' }}</span> | |
| 104 | + </div> | |
| 105 | + </div> | |
| 106 | + </div> | |
| 107 | + </div> | |
| 108 | + <div class="modal-footer"> | |
| 109 | + <button class="btn-run" @click="runPipeline(selectedPipeline.id); selectedPipeline = null"> | |
| 110 | + ▶️ 运行 Pipeline | |
| 111 | + </button> | |
| 112 | + <button class="btn-close-footer" @click="selectedPipeline = null"> | |
| 113 | + 关闭 | |
| 114 | + </button> | |
| 115 | + </div> | |
| 116 | + </div> | |
| 117 | + </div> | |
| 118 | + | |
| 119 | + <!-- 创建/编辑弹窗 --> | |
| 120 | + <div v-if="showCreateModal || editingPipeline" class="modal-overlay" @click="closeModal"> | |
| 121 | + <div class="modal-content create-pipeline-modal" @click.stop> | |
| 122 | + <div class="modal-header"> | |
| 123 | + <h2>{{ editingPipeline ? '编辑 Pipeline' : '创建 Pipeline' }}</h2> | |
| 124 | + <button class="btn-close" @click="closeModal">✕</button> | |
| 125 | + </div> | |
| 126 | + <div class="modal-body"> | |
| 127 | + <div class="form-group"> | |
| 128 | + <label>Pipeline 名称</label> | |
| 129 | + <input | |
| 130 | + v-model="pipelineForm.name" | |
| 131 | + type="text" | |
| 132 | + placeholder="输入 Pipeline 名称" | |
| 133 | + /> | |
| 134 | + </div> | |
| 135 | + <div class="form-group"> | |
| 136 | + <label>描述</label> | |
| 137 | + <textarea | |
| 138 | + v-model="pipelineForm.description" | |
| 139 | + placeholder="描述这个 Pipeline 的用途" | |
| 140 | + rows="2" | |
| 141 | + ></textarea> | |
| 142 | + </div> | |
| 143 | + <div class="form-group"> | |
| 144 | + <label>阶段配置</label> | |
| 145 | + <div class="stages-editor"> | |
| 146 | + <div | |
| 147 | + v-for="(stage, idx) in pipelineForm.stages" | |
| 148 | + :key="idx" | |
| 149 | + class="stage-editor" | |
| 150 | + > | |
| 151 | + <span class="stage-number">{{ idx + 1 }}</span> | |
| 152 | + <input | |
| 153 | + v-model="stage.name" | |
| 154 | + type="text" | |
| 155 | + placeholder="阶段名称" | |
| 156 | + class="stage-name-input" | |
| 157 | + /> | |
| 158 | + <select v-model="stage.skill" class="stage-skill-select"> | |
| 159 | + <option value="">选择技能...</option> | |
| 160 | + <option v-for="skill in availableSkills" :key="skill" :value="skill"> | |
| 161 | + {{ skill }} | |
| 162 | + </option> | |
| 163 | + </select> | |
| 164 | + <input | |
| 165 | + v-model="stage.config" | |
| 166 | + type="text" | |
| 167 | + placeholder='{"key": "value"}' | |
| 168 | + class="stage-config-input" | |
| 169 | + /> | |
| 170 | + <button class="btn-remove-stage" @click="removeStage(idx)">✕</button> | |
| 171 | + </div> | |
| 172 | + <button class="btn-add-stage" @click="addStage"> | |
| 173 | + ➕ 添加阶段 | |
| 174 | + </button> | |
| 175 | + </div> | |
| 176 | + </div> | |
| 177 | + <div class="form-group"> | |
| 178 | + <label> | |
| 179 | + <input type="checkbox" v-model="pipelineForm.autoRun" /> | |
| 180 | + 自动运行 | |
| 181 | + </label> | |
| 182 | + </div> | |
| 183 | + </div> | |
| 184 | + <div class="modal-footer"> | |
| 185 | + <button class="btn-cancel" @click="closeModal">取消</button> | |
| 186 | + <button class="btn-submit" @click="savePipeline"> | |
| 187 | + 💾 保存 | |
| 188 | + </button> | |
| 189 | + </div> | |
| 190 | + </div> | |
| 191 | + </div> | |
| 192 | + </div> | |
| 193 | + </div> | |
| 194 | +</template> | |
| 195 | + | |
| 196 | +<script setup> | |
| 197 | +import { ref, onMounted } from 'vue' | |
| 198 | +import AppNav from '@/components/AppNav.vue' | |
| 199 | + | |
| 200 | +// 状态 | |
| 201 | +const pipelines = ref([]) | |
| 202 | +const loading = ref(false) | |
| 203 | +const selectedPipeline = ref(null) | |
| 204 | +const showCreateModal = ref(false) | |
| 205 | +const editingPipeline = ref(null) | |
| 206 | +const availableSkills = ref([]) | |
| 207 | + | |
| 208 | +const pipelineForm = ref({ | |
| 209 | + name: '', | |
| 210 | + description: '', | |
| 211 | + stages: [], | |
| 212 | + autoRun: false | |
| 213 | +}) | |
| 214 | + | |
| 215 | +// API | |
| 216 | +const API_BASE = '/api/open' | |
| 217 | + | |
| 218 | +// 加载 Pipelines | |
| 219 | +const loadPipelines = async () => { | |
| 220 | + loading.value = true | |
| 221 | + try { | |
| 222 | + const response = await fetch(`${API_BASE}/pipelines`) | |
| 223 | + const data = await response.json() | |
| 224 | + pipelines.value = data.pipelines || data.results || [] | |
| 225 | + } catch (error) { | |
| 226 | + console.error('加载 Pipelines 失败:', error) | |
| 227 | + } finally { | |
| 228 | + loading.value = false | |
| 229 | + } | |
| 230 | +} | |
| 231 | + | |
| 232 | +// 加载可用技能 | |
| 233 | +const loadAvailableSkills = async () => { | |
| 234 | + try { | |
| 235 | + const response = await fetch(`${API_BASE}/skills/search?q=&limit=100`) | |
| 236 | + const data = await response.json() | |
| 237 | + availableSkills.value = (data.results || []).map(s => s.name) | |
| 238 | + } catch (error) { | |
| 239 | + console.error('加载技能列表失败:', error) | |
| 240 | + } | |
| 241 | +} | |
| 242 | + | |
| 243 | +// 保存 Pipeline | |
| 244 | +const savePipeline = async () => { | |
| 245 | + try { | |
| 246 | + const payload = { | |
| 247 | + name: pipelineForm.value.name, | |
| 248 | + description: pipelineForm.value.description, | |
| 249 | + config: { | |
| 250 | + stages: pipelineForm.value.stages.map(s => ({ | |
| 251 | + name: s.name, | |
| 252 | + skill: s.skill, | |
| 253 | + config: s.config ? JSON.parse(s.config) : {} | |
| 254 | + })), | |
| 255 | + autoRun: pipelineForm.value.autoRun | |
| 256 | + } | |
| 257 | + } | |
| 258 | + | |
| 259 | + const url = editingPipeline.value | |
| 260 | + ? `${API_BASE}/pipelines/${editingPipeline.value.id}` | |
| 261 | + : `${API_BASE}/pipelines` | |
| 262 | + const method = editingPipeline.value ? 'PUT' : 'POST' | |
| 263 | + | |
| 264 | + const response = await fetch(url, { | |
| 265 | + method, | |
| 266 | + headers: { 'Content-Type': 'application/json' }, | |
| 267 | + body: JSON.stringify(payload) | |
| 268 | + }) | |
| 269 | + | |
| 270 | + if (response.ok) { | |
| 271 | + alert('✅ Pipeline 保存成功!') | |
| 272 | + closeModal() | |
| 273 | + loadPipelines() | |
| 274 | + } else { | |
| 275 | + alert('❌ 保存失败') | |
| 276 | + } | |
| 277 | + } catch (error) { | |
| 278 | + console.error('保存失败:', error) | |
| 279 | + alert('❌ 保存失败') | |
| 280 | + } | |
| 281 | +} | |
| 282 | + | |
| 283 | +// 运行 Pipeline | |
| 284 | +const runPipeline = async (pipelineId) => { | |
| 285 | + try { | |
| 286 | + const response = await fetch(`${API_BASE}/pipelines/${pipelineId}/run`, { | |
| 287 | + method: 'POST' | |
| 288 | + }) | |
| 289 | + if (response.ok) { | |
| 290 | + alert('✅ Pipeline 已开始运行!') | |
| 291 | + loadPipelines() | |
| 292 | + } else { | |
| 293 | + alert('❌ 运行失败') | |
| 294 | + } | |
| 295 | + } catch (error) { | |
| 296 | + console.error('运行失败:', error) | |
| 297 | + alert('❌ 运行失败') | |
| 298 | + } | |
| 299 | +} | |
| 300 | + | |
| 301 | +// 删除 Pipeline | |
| 302 | +const deletePipeline = async (pipelineId) => { | |
| 303 | + if (!confirm('确定要删除这个 Pipeline 吗?')) return | |
| 304 | + | |
| 305 | + try { | |
| 306 | + const response = await fetch(`${API_BASE}/pipelines/${pipelineId}`, { | |
| 307 | + method: 'DELETE' | |
| 308 | + }) | |
| 309 | + if (response.ok) { | |
| 310 | + alert('✅ Pipeline 已删除') | |
| 311 | + loadPipelines() | |
| 312 | + } else { | |
| 313 | + alert('❌ 删除失败') | |
| 314 | + } | |
| 315 | + } catch (error) { | |
| 316 | + console.error('删除失败:', error) | |
| 317 | + alert('❌ 删除失败') | |
| 318 | + } | |
| 319 | +} | |
| 320 | + | |
| 321 | +// 编辑 Pipeline | |
| 322 | +const editPipeline = (pipeline) => { | |
| 323 | + editingPipeline.value = pipeline | |
| 324 | + pipelineForm.value = { | |
| 325 | + name: pipeline.name || '', | |
| 326 | + description: pipeline.description || '', | |
| 327 | + stages: (pipeline.config?.stages || []).map(s => ({ | |
| 328 | + name: s.name || '', | |
| 329 | + skill: s.skill || '', | |
| 330 | + config: s.config ? JSON.stringify(s.config) : '' | |
| 331 | + })), | |
| 332 | + autoRun: pipeline.config?.autoRun || false | |
| 333 | + } | |
| 334 | +} | |
| 335 | + | |
| 336 | +// 添加阶段 | |
| 337 | +const addStage = () => { | |
| 338 | + pipelineForm.value.stages.push({ | |
| 339 | + name: '', | |
| 340 | + skill: '', | |
| 341 | + config: '' | |
| 342 | + }) | |
| 343 | +} | |
| 344 | + | |
| 345 | +// 移除阶段 | |
| 346 | +const removeStage = (idx) => { | |
| 347 | + pipelineForm.value.stages.splice(idx, 1) | |
| 348 | +} | |
| 349 | + | |
| 350 | +// 关闭弹窗 | |
| 351 | +const closeModal = () => { | |
| 352 | + showCreateModal.value = false | |
| 353 | + editingPipeline.value = null | |
| 354 | + pipelineForm.value = { | |
| 355 | + name: '', | |
| 356 | + description: '', | |
| 357 | + stages: [], | |
| 358 | + autoRun: false | |
| 359 | + } | |
| 360 | +} | |
| 361 | + | |
| 362 | +// 格式化 | |
| 363 | +const statusLabel = (status) => { | |
| 364 | + const labels = { | |
| 365 | + idle: '空闲', | |
| 366 | + running: '运行中', | |
| 367 | + completed: '已完成', | |
| 368 | + failed: '失败' | |
| 369 | + } | |
| 370 | + return labels[status] || status || '未知' | |
| 371 | +} | |
| 372 | + | |
| 373 | +const formatDate = (dateStr) => { | |
| 374 | + if (!dateStr) return '-' | |
| 375 | + const date = new Date(dateStr) | |
| 376 | + return date.toLocaleString('zh-CN') | |
| 377 | +} | |
| 378 | + | |
| 379 | +// 初始化 | |
| 380 | +onMounted(() => { | |
| 381 | + loadPipelines() | |
| 382 | + loadAvailableSkills() | |
| 383 | +}) | |
| 384 | +</script> | |
| 385 | + | |
| 386 | +<style scoped> | |
| 387 | +.pipeline-editor { | |
| 388 | + min-height: 100vh; | |
| 389 | + background: var(--bg-primary, #0d1117); | |
| 390 | + color: var(--text-primary, #e6edf3); | |
| 391 | +} | |
| 392 | + | |
| 393 | +.editor-container { | |
| 394 | + max-width: 1200px; | |
| 395 | + margin: 0 auto; | |
| 396 | + padding: 24px; | |
| 397 | +} | |
| 398 | + | |
| 399 | +.editor-header { | |
| 400 | + display: flex; | |
| 401 | + justify-content: space-between; | |
| 402 | + align-items: center; | |
| 403 | + margin-bottom: 24px; | |
| 404 | +} | |
| 405 | + | |
| 406 | +.editor-header h1 { | |
| 407 | + font-size: 28px; | |
| 408 | + font-weight: 600; | |
| 409 | +} | |
| 410 | + | |
| 411 | +.header-actions { | |
| 412 | + display: flex; | |
| 413 | + gap: 12px; | |
| 414 | +} | |
| 415 | + | |
| 416 | +.btn-create, | |
| 417 | +.btn-refresh { | |
| 418 | + padding: 10px 16px; | |
| 419 | + border-radius: 8px; | |
| 420 | + cursor: pointer; | |
| 421 | + font-size: 14px; | |
| 422 | + border: none; | |
| 423 | +} | |
| 424 | + | |
| 425 | +.btn-create { | |
| 426 | + background: var(--accent, #58a6ff); | |
| 427 | + color: white; | |
| 428 | +} | |
| 429 | + | |
| 430 | +.btn-refresh { | |
| 431 | + background: var(--bg-secondary, #161b22); | |
| 432 | + border: 1px solid var(--border-color, #30363d); | |
| 433 | + color: var(--text-secondary, #8b949e); | |
| 434 | +} | |
| 435 | + | |
| 436 | +.loading-state, | |
| 437 | +.empty-state { | |
| 438 | + text-align: center; | |
| 439 | + padding: 60px 20px; | |
| 440 | + color: var(--text-secondary, #8b949e); | |
| 441 | +} | |
| 442 | + | |
| 443 | +.empty-icon { | |
| 444 | + font-size: 48px; | |
| 445 | + display: block; | |
| 446 | + margin-bottom: 16px; | |
| 447 | +} | |
| 448 | + | |
| 449 | +.btn-create-first { | |
| 450 | + margin-top: 16px; | |
| 451 | + padding: 12px 24px; | |
| 452 | + background: var(--accent, #58a6ff); | |
| 453 | + color: white; | |
| 454 | + border: none; | |
| 455 | + border-radius: 8px; | |
| 456 | + cursor: pointer; | |
| 457 | +} | |
| 458 | + | |
| 459 | +.pipelines-grid { | |
| 460 | + display: grid; | |
| 461 | + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); | |
| 462 | + gap: 20px; | |
| 463 | +} | |
| 464 | + | |
| 465 | +.pipeline-card { | |
| 466 | + background: var(--bg-secondary, #161b22); | |
| 467 | + border: 1px solid var(--border-color, #30363d); | |
| 468 | + border-radius: 12px; | |
| 469 | + padding: 20px; | |
| 470 | +} | |
| 471 | + | |
| 472 | +.pipeline-header { | |
| 473 | + display: flex; | |
| 474 | + justify-content: space-between; | |
| 475 | + align-items: center; | |
| 476 | + margin-bottom: 12px; | |
| 477 | +} | |
| 478 | + | |
| 479 | +.pipeline-header h3 { | |
| 480 | + font-size: 18px; | |
| 481 | + font-weight: 600; | |
| 482 | + margin: 0; | |
| 483 | +} | |
| 484 | + | |
| 485 | +.status-badge { | |
| 486 | + font-size: 12px; | |
| 487 | + padding: 4px 8px; | |
| 488 | + border-radius: 4px; | |
| 489 | +} | |
| 490 | + | |
| 491 | +.status-badge.idle { background: #8b949e22; color: #8b949e; } | |
| 492 | +.status-badge.running { background: #58a6ff22; color: #58a6ff; } | |
| 493 | +.status-badge.completed { background: #3fb95022; color: #3fb950; } | |
| 494 | +.status-badge.failed { background: #f8514922; color: #f85149; } | |
| 495 | + | |
| 496 | +.status-badge.large { | |
| 497 | + font-size: 14px; | |
| 498 | + padding: 8px 16px; | |
| 499 | +} | |
| 500 | + | |
| 501 | +.pipeline-meta { | |
| 502 | + display: flex; | |
| 503 | + gap: 16px; | |
| 504 | + font-size: 13px; | |
| 505 | + color: var(--text-secondary, #8b949e); | |
| 506 | + margin-bottom: 16px; | |
| 507 | +} | |
| 508 | + | |
| 509 | +.pipeline-stages { | |
| 510 | + display: flex; | |
| 511 | + flex-wrap: wrap; | |
| 512 | + gap: 8px; | |
| 513 | + margin-bottom: 16px; | |
| 514 | +} | |
| 515 | + | |
| 516 | +.stage-item { | |
| 517 | + display: flex; | |
| 518 | + align-items: center; | |
| 519 | + gap: 6px; | |
| 520 | + padding: 6px 12px; | |
| 521 | + background: var(--bg-primary, #0d1117); | |
| 522 | + border-radius: 16px; | |
| 523 | + font-size: 13px; | |
| 524 | +} | |
| 525 | + | |
| 526 | +.stage-num { | |
| 527 | + width: 20px; | |
| 528 | + height: 20px; | |
| 529 | + display: flex; | |
| 530 | + align-items: center; | |
| 531 | + justify-content: center; | |
| 532 | + background: var(--accent, #58a6ff); | |
| 533 | + border-radius: 50%; | |
| 534 | + font-size: 11px; | |
| 535 | + color: white; | |
| 536 | +} | |
| 537 | + | |
| 538 | +.pipeline-actions { | |
| 539 | + display: flex; | |
| 540 | + gap: 8px; | |
| 541 | +} | |
| 542 | + | |
| 543 | +.btn-action { | |
| 544 | + flex: 1; | |
| 545 | + padding: 8px 12px; | |
| 546 | + background: var(--bg-primary, #0d1117); | |
| 547 | + border: 1px solid var(--border-color, #30363d); | |
| 548 | + border-radius: 6px; | |
| 549 | + color: var(--text-primary, #e6edf3); | |
| 550 | + cursor: pointer; | |
| 551 | + font-size: 13px; | |
| 552 | +} | |
| 553 | + | |
| 554 | +.btn-action:hover { | |
| 555 | + border-color: var(--accent, #58a6ff); | |
| 556 | +} | |
| 557 | + | |
| 558 | +.btn-action.delete:hover { | |
| 559 | + border-color: #f85149; | |
| 560 | +} | |
| 561 | + | |
| 562 | +/* Modal */ | |
| 563 | +.modal-overlay { | |
| 564 | + position: fixed; | |
| 565 | + top: 0; | |
| 566 | + left: 0; | |
| 567 | + right: 0; | |
| 568 | + bottom: 0; | |
| 569 | + background: rgba(0, 0, 0, 0.7); | |
| 570 | + display: flex; | |
| 571 | + align-items: center; | |
| 572 | + justify-content: center; | |
| 573 | + z-index: 1000; | |
| 574 | +} | |
| 575 | + | |
| 576 | +.modal-content { | |
| 577 | + background: var(--bg-secondary, #161b22); | |
| 578 | + border: 1px solid var(--border-color, #30363d); | |
| 579 | + border-radius: 12px; | |
| 580 | + max-width: 700px; | |
| 581 | + width: 90%; | |
| 582 | + max-height: 85vh; | |
| 583 | + overflow-y: auto; | |
| 584 | +} | |
| 585 | + | |
| 586 | +.modal-header { | |
| 587 | + display: flex; | |
| 588 | + justify-content: space-between; | |
| 589 | + align-items: center; | |
| 590 | + padding: 20px; | |
| 591 | + border-bottom: 1px solid var(--border-color, #30363d); | |
| 592 | +} | |
| 593 | + | |
| 594 | +.modal-header h2 { | |
| 595 | + margin: 0; | |
| 596 | + font-size: 20px; | |
| 597 | +} | |
| 598 | + | |
| 599 | +.btn-close { | |
| 600 | + background: none; | |
| 601 | + border: none; | |
| 602 | + font-size: 20px; | |
| 603 | + color: var(--text-secondary, #8b949e); | |
| 604 | + cursor: pointer; | |
| 605 | +} | |
| 606 | + | |
| 607 | +.modal-body { | |
| 608 | + padding: 20px; | |
| 609 | +} | |
| 610 | + | |
| 611 | +.form-group { | |
| 612 | + margin-bottom: 20px; | |
| 613 | +} | |
| 614 | + | |
| 615 | +.form-group label { | |
| 616 | + display: block; | |
| 617 | + margin-bottom: 8px; | |
| 618 | + font-size: 14px; | |
| 619 | + color: var(--text-secondary, #8b949e); | |
| 620 | +} | |
| 621 | + | |
| 622 | +.form-group input[type="text"], | |
| 623 | +.form-group textarea { | |
| 624 | + width: 100%; | |
| 625 | + padding: 10px 12px; | |
| 626 | + border: 1px solid var(--border-color, #30363d); | |
| 627 | + border-radius: 6px; | |
| 628 | + background: var(--bg-primary, #0d1117); | |
| 629 | + color: var(--text-primary, #e6edf3); | |
| 630 | + font-size: 14px; | |
| 631 | +} | |
| 632 | + | |
| 633 | +.form-group input[type="checkbox"] { | |
| 634 | + margin-right: 8px; | |
| 635 | +} | |
| 636 | + | |
| 637 | +.stages-editor { | |
| 638 | + display: flex; | |
| 639 | + flex-direction: column; | |
| 640 | + gap: 12px; | |
| 641 | +} | |
| 642 | + | |
| 643 | +.stage-editor { | |
| 644 | + display: flex; | |
| 645 | + gap: 8px; | |
| 646 | + align-items: center; | |
| 647 | +} | |
| 648 | + | |
| 649 | +.stage-number { | |
| 650 | + width: 24px; | |
| 651 | + height: 24px; | |
| 652 | + display: flex; | |
| 653 | + align-items: center; | |
| 654 | + justify-content: center; | |
| 655 | + background: var(--accent, #58a6ff); | |
| 656 | + border-radius: 50%; | |
| 657 | + font-size: 12px; | |
| 658 | + color: white; | |
| 659 | + flex-shrink: 0; | |
| 660 | +} | |
| 661 | + | |
| 662 | +.stage-name-input { | |
| 663 | + width: 120px; | |
| 664 | +} | |
| 665 | + | |
| 666 | +.stage-skill-select { | |
| 667 | + width: 150px; | |
| 668 | + padding: 8px; | |
| 669 | + border: 1px solid var(--border-color, #30363d); | |
| 670 | + border-radius: 6px; | |
| 671 | + background: var(--bg-primary, #0d1117); | |
| 672 | + color: var(--text-primary, #e6edf3); | |
| 673 | +} | |
| 674 | + | |
| 675 | +.stage-config-input { | |
| 676 | + flex: 1; | |
| 677 | + font-size: 12px; | |
| 678 | +} | |
| 679 | + | |
| 680 | +.btn-remove-stage { | |
| 681 | + width: 28px; | |
| 682 | + height: 28px; | |
| 683 | + background: none; | |
| 684 | + border: 1px solid var(--border-color, #30363d); | |
| 685 | + border-radius: 4px; | |
| 686 | + color: #f85149; | |
| 687 | + cursor: pointer; | |
| 688 | +} | |
| 689 | + | |
| 690 | +.btn-add-stage { | |
| 691 | + padding: 10px; | |
| 692 | + background: var(--bg-primary, #0d1117); | |
| 693 | + border: 1px dashed var(--border-color, #30363d); | |
| 694 | + border-radius: 6px; | |
| 695 | + color: var(--text-secondary, #8b949e); | |
| 696 | + cursor: pointer; | |
| 697 | +} | |
| 698 | + | |
| 699 | +.btn-add-stage:hover { | |
| 700 | + border-color: var(--accent, #58a6ff); | |
| 701 | + color: var(--accent, #58a6ff); | |
| 702 | +} | |
| 703 | + | |
| 704 | +.detail-section { | |
| 705 | + margin-bottom: 20px; | |
| 706 | +} | |
| 707 | + | |
| 708 | +.detail-section h4 { | |
| 709 | + font-size: 14px; | |
| 710 | + color: var(--text-secondary, #8b949e); | |
| 711 | + margin-bottom: 8px; | |
| 712 | +} | |
| 713 | + | |
| 714 | +.detail-section pre { | |
| 715 | + background: var(--bg-primary, #0d1117); | |
| 716 | + padding: 12px; | |
| 717 | + border-radius: 6px; | |
| 718 | + font-size: 12px; | |
| 719 | + overflow-x: auto; | |
| 720 | +} | |
| 721 | + | |
| 722 | +.history-list { | |
| 723 | + display: flex; | |
| 724 | + flex-direction: column; | |
| 725 | + gap: 8px; | |
| 726 | +} | |
| 727 | + | |
| 728 | +.history-item { | |
| 729 | + display: flex; | |
| 730 | + align-items: center; | |
| 731 | + gap: 12px; | |
| 732 | + padding: 8px 12px; | |
| 733 | + background: var(--bg-primary, #0d1117); | |
| 734 | + border-radius: 6px; | |
| 735 | +} | |
| 736 | + | |
| 737 | +.run-status { | |
| 738 | + width: 8px; | |
| 739 | + height: 8px; | |
| 740 | + border-radius: 50%; | |
| 741 | +} | |
| 742 | + | |
| 743 | +.run-status.completed { background: #3fb950; } | |
| 744 | +.run-status.failed { background: #f85149; } | |
| 745 | +.run-status.running { background: #58a6ff; } | |
| 746 | + | |
| 747 | +.run-time { | |
| 748 | + font-size: 13px; | |
| 749 | + color: var(--text-secondary, #8b949e); | |
| 750 | +} | |
| 751 | + | |
| 752 | +.modal-footer { | |
| 753 | + display: flex; | |
| 754 | + justify-content: flex-end; | |
| 755 | + gap: 12px; | |
| 756 | + padding: 20px; | |
| 757 | + border-top: 1px solid var(--border-color, #30363d); | |
| 758 | +} | |
| 759 | + | |
| 760 | +.btn-cancel, | |
| 761 | +.btn-submit, | |
| 762 | +.btn-run, | |
| 763 | +.btn-close-footer { | |
| 764 | + padding: 10px 16px; | |
| 765 | + border-radius: 6px; | |
| 766 | + cursor: pointer; | |
| 767 | + font-size: 14px; | |
| 768 | + border: none; | |
| 769 | +} | |
| 770 | + | |
| 771 | +.btn-cancel { | |
| 772 | + background: var(--bg-primary, #0d1117); | |
| 773 | + border: 1px solid var(--border-color, #30363d); | |
| 774 | + color: var(--text-secondary, #8b949e); | |
| 775 | +} | |
| 776 | + | |
| 777 | +.btn-submit { | |
| 778 | + background: var(--accent, #58a6ff); | |
| 779 | + color: white; | |
| 780 | +} | |
| 781 | + | |
| 782 | +.btn-run { | |
| 783 | + background: #3fb950; | |
| 784 | + color: white; | |
| 785 | +} | |
| 786 | + | |
| 787 | +.btn-close-footer { | |
| 788 | + background: var(--bg-primary, #0d1117); | |
| 789 | + border: 1px solid var(--border-color, #30363d); | |
| 790 | + color: var(--text-secondary, #8b949e); | |
| 791 | +} | |
| 792 | +</style> | |
| 0 | 793 | \ No newline at end of file | ... | ... |
web/src/views/SkillsMarket.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="skills-market"> | |
| 3 | + <AppNav /> | |
| 4 | + <div class="market-container"> | |
| 5 | + <!-- 搜索和筛选 --> | |
| 6 | + <div class="market-header"> | |
| 7 | + <h1>🛒 Skills 市场</h1> | |
| 8 | + <div class="search-bar"> | |
| 9 | + <input | |
| 10 | + v-model="searchQuery" | |
| 11 | + type="text" | |
| 12 | + placeholder="搜索 Skills..." | |
| 13 | + @input="handleSearch" | |
| 14 | + /> | |
| 15 | + <button class="btn-search" @click="searchSkills">🔍 搜索</button> | |
| 16 | + </div> | |
| 17 | + </div> | |
| 18 | + | |
| 19 | + <!-- 分类标签 --> | |
| 20 | + <div class="category-tabs"> | |
| 21 | + <button | |
| 22 | + v-for="cat in categories" | |
| 23 | + :key="cat.id" | |
| 24 | + class="tab-btn" | |
| 25 | + :class="{ active: activeCategory === cat.id }" | |
| 26 | + @click="filterByCategory(cat.id)" | |
| 27 | + > | |
| 28 | + {{ cat.icon }} {{ cat.name }} | |
| 29 | + </button> | |
| 30 | + </div> | |
| 31 | + | |
| 32 | + <!-- Skills 列表 --> | |
| 33 | + <div v-if="loading" class="loading-state"> | |
| 34 | + <span class="loading-spinner">⏳ 加载中...</span> | |
| 35 | + </div> | |
| 36 | + | |
| 37 | + <div v-else-if="skills.length === 0" class="empty-state"> | |
| 38 | + <span class="empty-icon">📦</span> | |
| 39 | + <p>暂无 Skills,请尝试其他搜索条件</p> | |
| 40 | + </div> | |
| 41 | + | |
| 42 | + <div v-else class="skills-grid"> | |
| 43 | + <div | |
| 44 | + v-for="skill in skills" | |
| 45 | + :key="skill.id" | |
| 46 | + class="skill-card" | |
| 47 | + @click="showSkillDetail(skill)" | |
| 48 | + > | |
| 49 | + <div class="skill-header"> | |
| 50 | + <span class="skill-icon">{{ skill.icon || '⚡' }}</span> | |
| 51 | + <h3>{{ skill.name }}</h3> | |
| 52 | + </div> | |
| 53 | + <p class="skill-desc">{{ skill.description || '暂无描述' }}</p> | |
| 54 | + <div class="skill-meta"> | |
| 55 | + <span class="meta-tag">{{ skill.version || 'v1.0' }}</span> | |
| 56 | + <span class="meta-tag">{{ formatDate(skill.createdAt) }}</span> | |
| 57 | + </div> | |
| 58 | + <div class="skill-actions"> | |
| 59 | + <button class="btn-install" @click.stop="installSkill(skill)"> | |
| 60 | + 📥 安装 | |
| 61 | + </button> | |
| 62 | + <button class="btn-preview" @click.stop="previewSkill(skill)"> | |
| 63 | + 👁 预览 | |
| 64 | + </button> | |
| 65 | + </div> | |
| 66 | + </div> | |
| 67 | + </div> | |
| 68 | + | |
| 69 | + <!-- 分页 --> | |
| 70 | + <div v-if="totalPages > 1" class="pagination"> | |
| 71 | + <button | |
| 72 | + :disabled="currentPage === 1" | |
| 73 | + @click="goToPage(currentPage - 1)" | |
| 74 | + > | |
| 75 | + ‹ 上一页 | |
| 76 | + </button> | |
| 77 | + <span class="page-info">{{ currentPage }} / {{ totalPages }}</span> | |
| 78 | + <button | |
| 79 | + :disabled="currentPage === totalPages" | |
| 80 | + @click="goToPage(currentPage + 1)" | |
| 81 | + > | |
| 82 | + 下一页 › | |
| 83 | + </button> | |
| 84 | + </div> | |
| 85 | + </div> | |
| 86 | + | |
| 87 | + <!-- Skill 详情弹窗 --> | |
| 88 | + <div v-if="selectedSkill" class="modal-overlay" @click="closeModal"> | |
| 89 | + <div class="modal-content skill-detail-modal" @click.stop> | |
| 90 | + <div class="modal-header"> | |
| 91 | + <h2>{{ selectedSkill.name }}</h2> | |
| 92 | + <button class="btn-close" @click="closeModal">✕</button> | |
| 93 | + </div> | |
| 94 | + <div class="modal-body"> | |
| 95 | + <div class="detail-section"> | |
| 96 | + <h4>描述</h4> | |
| 97 | + <p>{{ selectedSkill.description || '暂无描述' }}</p> | |
| 98 | + </div> | |
| 99 | + <div class="detail-section"> | |
| 100 | + <h4>版本</h4> | |
| 101 | + <span class="version-badge">{{ selectedSkill.version || 'v1.0' }}</span> | |
| 102 | + </div> | |
| 103 | + <div v-if="selectedSkill.manifest" class="detail-section"> | |
| 104 | + <h4>Manifest</h4> | |
| 105 | + <pre>{{ JSON.stringify(selectedSkill.manifest, null, 2) }}</pre> | |
| 106 | + </div> | |
| 107 | + <div v-if="selectedSkill.taxonomy" class="detail-section"> | |
| 108 | + <h4>分类</h4> | |
| 109 | + <div class="taxonomy-tags"> | |
| 110 | + <span | |
| 111 | + v-for="(value, key) in selectedSkill.taxonomy" | |
| 112 | + :key="key" | |
| 113 | + class="taxonomy-tag" | |
| 114 | + > | |
| 115 | + {{ key }}: {{ value }} | |
| 116 | + </span> | |
| 117 | + </div> | |
| 118 | + </div> | |
| 119 | + </div> | |
| 120 | + <div class="modal-footer"> | |
| 121 | + <button class="btn-install-large" @click="installSkill(selectedSkill)"> | |
| 122 | + 📥 安装到我的公司 | |
| 123 | + </button> | |
| 124 | + </div> | |
| 125 | + </div> | |
| 126 | + </div> | |
| 127 | + </div> | |
| 128 | +</template> | |
| 129 | + | |
| 130 | +<script setup> | |
| 131 | +import { ref, onMounted } from 'vue' | |
| 132 | +import { useRouter } from 'vue-router' | |
| 133 | +import AppNav from '@/components/AppNav.vue' | |
| 134 | + | |
| 135 | +const router = useRouter() | |
| 136 | + | |
| 137 | +// 状态 | |
| 138 | +const searchQuery = ref('') | |
| 139 | +const skills = ref([]) | |
| 140 | +const loading = ref(false) | |
| 141 | +const currentPage = ref(1) | |
| 142 | +const totalPages = ref(1) | |
| 143 | +const total = ref(0) | |
| 144 | +const activeCategory = ref('all') | |
| 145 | +const selectedSkill = ref(null) | |
| 146 | + | |
| 147 | +// 分类 | |
| 148 | +const categories = ref([ | |
| 149 | + { id: 'all', name: '全部', icon: '📦' }, | |
| 150 | + { id: 'developer', name: '开发', icon: '💻' }, | |
| 151 | + { id: 'designer', name: '设计', icon: '🎨' }, | |
| 152 | + { id: 'writer', name: '写作', icon: '✍️' }, | |
| 153 | + { id: 'analyst', name: '分析', icon: '📊' }, | |
| 154 | + { id: 'manager', name: '管理', icon: '📋' }, | |
| 155 | +]) | |
| 156 | + | |
| 157 | +// API | |
| 158 | +const API_BASE = '/api/open' | |
| 159 | + | |
| 160 | +// 加载 Skills | |
| 161 | +const loadSkills = async () => { | |
| 162 | + loading.value = true | |
| 163 | + try { | |
| 164 | + const url = `${API_BASE}/skills/search?q=${encodeURIComponent(searchQuery.value)}&page=${currentPage.value}&limit=12` | |
| 165 | + const response = await fetch(url) | |
| 166 | + const data = await response.json() | |
| 167 | + skills.value = data.results || [] | |
| 168 | + total.value = data.total || 0 | |
| 169 | + totalPages.value = Math.ceil(total.value / 12) || 1 | |
| 170 | + } catch (error) { | |
| 171 | + console.error('加载 Skills 失败:', error) | |
| 172 | + } finally { | |
| 173 | + loading.value = false | |
| 174 | + } | |
| 175 | +} | |
| 176 | + | |
| 177 | +// 搜索 | |
| 178 | +const handleSearch = () => { | |
| 179 | + currentPage.value = 1 | |
| 180 | + loadSkills() | |
| 181 | +} | |
| 182 | + | |
| 183 | +const searchSkills = () => { | |
| 184 | + handleSearch() | |
| 185 | +} | |
| 186 | + | |
| 187 | +// 分类筛选 | |
| 188 | +const filterByCategory = (category) => { | |
| 189 | + activeCategory.value = category | |
| 190 | + currentPage.value = 1 | |
| 191 | + loadSkills() | |
| 192 | +} | |
| 193 | + | |
| 194 | +// 分页 | |
| 195 | +const goToPage = (page) => { | |
| 196 | + currentPage.value = page | |
| 197 | + loadSkills() | |
| 198 | +} | |
| 199 | + | |
| 200 | +// 格式化日期 | |
| 201 | +const formatDate = (dateStr) => { | |
| 202 | + if (!dateStr) return '-' | |
| 203 | + const date = new Date(dateStr) | |
| 204 | + return date.toLocaleDateString('zh-CN') | |
| 205 | +} | |
| 206 | + | |
| 207 | +// 显示详情 | |
| 208 | +const showSkillDetail = (skill) => { | |
| 209 | + selectedSkill.value = skill | |
| 210 | +} | |
| 211 | + | |
| 212 | +// 关闭弹窗 | |
| 213 | +const closeModal = () => { | |
| 214 | + selectedSkill.value = null | |
| 215 | +} | |
| 216 | + | |
| 217 | +// 安装 Skill | |
| 218 | +const installSkill = async (skill) => { | |
| 219 | + try { | |
| 220 | + const response = await fetch(`${API_BASE}/skills/${skill.name}/install`, { | |
| 221 | + method: 'POST', | |
| 222 | + }) | |
| 223 | + if (response.ok) { | |
| 224 | + alert(`✅ ${skill.name} 安装成功!`) | |
| 225 | + closeModal() | |
| 226 | + } else { | |
| 227 | + alert('❌ 安装失败') | |
| 228 | + } | |
| 229 | + } catch (error) { | |
| 230 | + console.error('安装失败:', error) | |
| 231 | + alert('❌ 安装失败') | |
| 232 | + } | |
| 233 | +} | |
| 234 | + | |
| 235 | +// 预览 Skill | |
| 236 | +const previewSkill = (skill) => { | |
| 237 | + window.open(`${API_BASE}/skills/${skill.name}/SKILL.md`, '_blank') | |
| 238 | +} | |
| 239 | + | |
| 240 | +// 初始化 | |
| 241 | +onMounted(() => { | |
| 242 | + loadSkills() | |
| 243 | +}) | |
| 244 | +</script> | |
| 245 | + | |
| 246 | +<style scoped> | |
| 247 | +.skills-market { | |
| 248 | + min-height: 100vh; | |
| 249 | + background: var(--bg-primary, #0d1117); | |
| 250 | + color: var(--text-primary, #e6edf3); | |
| 251 | +} | |
| 252 | + | |
| 253 | +.market-container { | |
| 254 | + max-width: 1200px; | |
| 255 | + margin: 0 auto; | |
| 256 | + padding: 24px; | |
| 257 | +} | |
| 258 | + | |
| 259 | +.market-header { | |
| 260 | + display: flex; | |
| 261 | + justify-content: space-between; | |
| 262 | + align-items: center; | |
| 263 | + margin-bottom: 24px; | |
| 264 | +} | |
| 265 | + | |
| 266 | +.market-header h1 { | |
| 267 | + font-size: 28px; | |
| 268 | + font-weight: 600; | |
| 269 | +} | |
| 270 | + | |
| 271 | +.search-bar { | |
| 272 | + display: flex; | |
| 273 | + gap: 12px; | |
| 274 | +} | |
| 275 | + | |
| 276 | +.search-bar input { | |
| 277 | + width: 300px; | |
| 278 | + padding: 10px 16px; | |
| 279 | + border: 1px solid var(--border-color, #30363d); | |
| 280 | + border-radius: 8px; | |
| 281 | + background: var(--bg-secondary, #161b22); | |
| 282 | + color: var(--text-primary, #e6edf3); | |
| 283 | + font-size: 14px; | |
| 284 | +} | |
| 285 | + | |
| 286 | +.search-bar input:focus { | |
| 287 | + outline: none; | |
| 288 | + border-color: var(--accent, #58a6ff); | |
| 289 | +} | |
| 290 | + | |
| 291 | +.btn-search { | |
| 292 | + padding: 10px 20px; | |
| 293 | + background: var(--accent, #58a6ff); | |
| 294 | + color: white; | |
| 295 | + border: none; | |
| 296 | + border-radius: 8px; | |
| 297 | + cursor: pointer; | |
| 298 | + font-weight: 500; | |
| 299 | +} | |
| 300 | + | |
| 301 | +.btn-search:hover { | |
| 302 | + opacity: 0.9; | |
| 303 | +} | |
| 304 | + | |
| 305 | +.category-tabs { | |
| 306 | + display: flex; | |
| 307 | + gap: 8px; | |
| 308 | + margin-bottom: 24px; | |
| 309 | + flex-wrap: wrap; | |
| 310 | +} | |
| 311 | + | |
| 312 | +.tab-btn { | |
| 313 | + padding: 8px 16px; | |
| 314 | + background: var(--bg-secondary, #161b22); | |
| 315 | + border: 1px solid var(--border-color, #30363d); | |
| 316 | + border-radius: 20px; | |
| 317 | + color: var(--text-secondary, #8b949e); | |
| 318 | + cursor: pointer; | |
| 319 | + transition: all 0.2s; | |
| 320 | +} | |
| 321 | + | |
| 322 | +.tab-btn:hover { | |
| 323 | + border-color: var(--accent, #58a6ff); | |
| 324 | +} | |
| 325 | + | |
| 326 | +.tab-btn.active { | |
| 327 | + background: var(--accent, #58a6ff); | |
| 328 | + color: white; | |
| 329 | + border-color: var(--accent, #58a6ff); | |
| 330 | +} | |
| 331 | + | |
| 332 | +.loading-state, | |
| 333 | +.empty-state { | |
| 334 | + text-align: center; | |
| 335 | + padding: 60px 20px; | |
| 336 | + color: var(--text-secondary, #8b949e); | |
| 337 | +} | |
| 338 | + | |
| 339 | +.empty-icon { | |
| 340 | + font-size: 48px; | |
| 341 | + display: block; | |
| 342 | + margin-bottom: 16px; | |
| 343 | +} | |
| 344 | + | |
| 345 | +.skills-grid { | |
| 346 | + display: grid; | |
| 347 | + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); | |
| 348 | + gap: 20px; | |
| 349 | + margin-bottom: 24px; | |
| 350 | +} | |
| 351 | + | |
| 352 | +.skill-card { | |
| 353 | + background: var(--bg-secondary, #161b22); | |
| 354 | + border: 1px solid var(--border-color, #30363d); | |
| 355 | + border-radius: 12px; | |
| 356 | + padding: 20px; | |
| 357 | + cursor: pointer; | |
| 358 | + transition: all 0.2s; | |
| 359 | +} | |
| 360 | + | |
| 361 | +.skill-card:hover { | |
| 362 | + border-color: var(--accent, #58a6ff); | |
| 363 | + transform: translateY(-2px); | |
| 364 | + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); | |
| 365 | +} | |
| 366 | + | |
| 367 | +.skill-header { | |
| 368 | + display: flex; | |
| 369 | + align-items: center; | |
| 370 | + gap: 12px; | |
| 371 | + margin-bottom: 12px; | |
| 372 | +} | |
| 373 | + | |
| 374 | +.skill-icon { | |
| 375 | + font-size: 24px; | |
| 376 | +} | |
| 377 | + | |
| 378 | +.skill-header h3 { | |
| 379 | + font-size: 18px; | |
| 380 | + font-weight: 600; | |
| 381 | + margin: 0; | |
| 382 | +} | |
| 383 | + | |
| 384 | +.skill-desc { | |
| 385 | + color: var(--text-secondary, #8b949e); | |
| 386 | + font-size: 14px; | |
| 387 | + margin-bottom: 12px; | |
| 388 | + line-height: 1.5; | |
| 389 | +} | |
| 390 | + | |
| 391 | +.skill-meta { | |
| 392 | + display: flex; | |
| 393 | + gap: 8px; | |
| 394 | + margin-bottom: 16px; | |
| 395 | +} | |
| 396 | + | |
| 397 | +.meta-tag { | |
| 398 | + font-size: 12px; | |
| 399 | + padding: 4px 8px; | |
| 400 | + background: var(--bg-primary, #0d1117); | |
| 401 | + border-radius: 4px; | |
| 402 | + color: var(--text-secondary, #8b949e); | |
| 403 | +} | |
| 404 | + | |
| 405 | +.skill-actions { | |
| 406 | + display: flex; | |
| 407 | + gap: 8px; | |
| 408 | +} | |
| 409 | + | |
| 410 | +.btn-install, | |
| 411 | +.btn-preview { | |
| 412 | + flex: 1; | |
| 413 | + padding: 8px 12px; | |
| 414 | + border-radius: 6px; | |
| 415 | + cursor: pointer; | |
| 416 | + font-size: 13px; | |
| 417 | + border: none; | |
| 418 | +} | |
| 419 | + | |
| 420 | +.btn-install { | |
| 421 | + background: var(--accent, #58a6ff); | |
| 422 | + color: white; | |
| 423 | +} | |
| 424 | + | |
| 425 | +.btn-install:hover { | |
| 426 | + opacity: 0.9; | |
| 427 | +} | |
| 428 | + | |
| 429 | +.btn-preview { | |
| 430 | + background: var(--bg-primary, #0d1117); | |
| 431 | + color: var(--text-secondary, #8b949e); | |
| 432 | + border: 1px solid var(--border-color, #30363d); | |
| 433 | +} | |
| 434 | + | |
| 435 | +.btn-preview:hover { | |
| 436 | + border-color: var(--accent, #58a6ff); | |
| 437 | +} | |
| 438 | + | |
| 439 | +.pagination { | |
| 440 | + display: flex; | |
| 441 | + justify-content: center; | |
| 442 | + align-items: center; | |
| 443 | + gap: 16px; | |
| 444 | +} | |
| 445 | + | |
| 446 | +.pagination button { | |
| 447 | + padding: 8px 16px; | |
| 448 | + background: var(--bg-secondary, #161b22); | |
| 449 | + border: 1px solid var(--border-color, #30363d); | |
| 450 | + border-radius: 6px; | |
| 451 | + color: var(--text-primary, #e6edf3); | |
| 452 | + cursor: pointer; | |
| 453 | +} | |
| 454 | + | |
| 455 | +.pagination button:disabled { | |
| 456 | + opacity: 0.5; | |
| 457 | + cursor: not-allowed; | |
| 458 | +} | |
| 459 | + | |
| 460 | +.page-info { | |
| 461 | + color: var(--text-secondary, #8b949e); | |
| 462 | +} | |
| 463 | + | |
| 464 | +/* Modal */ | |
| 465 | +.modal-overlay { | |
| 466 | + position: fixed; | |
| 467 | + top: 0; | |
| 468 | + left: 0; | |
| 469 | + right: 0; | |
| 470 | + bottom: 0; | |
| 471 | + background: rgba(0, 0, 0, 0.7); | |
| 472 | + display: flex; | |
| 473 | + align-items: center; | |
| 474 | + justify-content: center; | |
| 475 | + z-index: 1000; | |
| 476 | +} | |
| 477 | + | |
| 478 | +.modal-content { | |
| 479 | + background: var(--bg-secondary, #161b22); | |
| 480 | + border: 1px solid var(--border-color, #30363d); | |
| 481 | + border-radius: 12px; | |
| 482 | + max-width: 600px; | |
| 483 | + width: 90%; | |
| 484 | + max-height: 80vh; | |
| 485 | + overflow-y: auto; | |
| 486 | +} | |
| 487 | + | |
| 488 | +.modal-header { | |
| 489 | + display: flex; | |
| 490 | + justify-content: space-between; | |
| 491 | + align-items: center; | |
| 492 | + padding: 20px; | |
| 493 | + border-bottom: 1px solid var(--border-color, #30363d); | |
| 494 | +} | |
| 495 | + | |
| 496 | +.modal-header h2 { | |
| 497 | + margin: 0; | |
| 498 | + font-size: 20px; | |
| 499 | +} | |
| 500 | + | |
| 501 | +.btn-close { | |
| 502 | + background: none; | |
| 503 | + border: none; | |
| 504 | + font-size: 20px; | |
| 505 | + color: var(--text-secondary, #8b949e); | |
| 506 | + cursor: pointer; | |
| 507 | +} | |
| 508 | + | |
| 509 | +.modal-body { | |
| 510 | + padding: 20px; | |
| 511 | +} | |
| 512 | + | |
| 513 | +.detail-section { | |
| 514 | + margin-bottom: 20px; | |
| 515 | +} | |
| 516 | + | |
| 517 | +.detail-section h4 { | |
| 518 | + font-size: 14px; | |
| 519 | + color: var(--text-secondary, #8b949e); | |
| 520 | + margin-bottom: 8px; | |
| 521 | +} | |
| 522 | + | |
| 523 | +.detail-section pre { | |
| 524 | + background: var(--bg-primary, #0d1117); | |
| 525 | + padding: 12px; | |
| 526 | + border-radius: 6px; | |
| 527 | + font-size: 12px; | |
| 528 | + overflow-x: auto; | |
| 529 | +} | |
| 530 | + | |
| 531 | +.version-badge { | |
| 532 | + display: inline-block; | |
| 533 | + padding: 4px 12px; | |
| 534 | + background: var(--accent, #58a6ff); | |
| 535 | + border-radius: 4px; | |
| 536 | + font-size: 12px; | |
| 537 | + color: white; | |
| 538 | +} | |
| 539 | + | |
| 540 | +.taxonomy-tags { | |
| 541 | + display: flex; | |
| 542 | + flex-wrap: wrap; | |
| 543 | + gap: 8px; | |
| 544 | +} | |
| 545 | + | |
| 546 | +.taxonomy-tag { | |
| 547 | + font-size: 12px; | |
| 548 | + padding: 4px 8px; | |
| 549 | + background: var(--bg-primary, #0d1117); | |
| 550 | + border-radius: 4px; | |
| 551 | +} | |
| 552 | + | |
| 553 | +.modal-footer { | |
| 554 | + padding: 20px; | |
| 555 | + border-top: 1px solid var(--border-color, #30363d); | |
| 556 | + text-align: center; | |
| 557 | +} | |
| 558 | + | |
| 559 | +.btn-install-large { | |
| 560 | + padding: 12px 24px; | |
| 561 | + background: var(--accent, #58a6ff); | |
| 562 | + color: white; | |
| 563 | + border: none; | |
| 564 | + border-radius: 8px; | |
| 565 | + font-size: 16px; | |
| 566 | + cursor: pointer; | |
| 567 | +} | |
| 568 | + | |
| 569 | +.btn-install-large:hover { | |
| 570 | + opacity: 0.9; | |
| 571 | +} | |
| 572 | +</style> | |
| 0 | 573 | \ No newline at end of file | ... | ... |
web/src/views/TemplateAgents.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <div class="template-agents-page"> | |
| 3 | + <AppNav /> | |
| 4 | + | |
| 5 | + <div class="main-layout"> | |
| 6 | + <!-- ═══ Left Sidebar (Template List) ═══ --> | |
| 7 | + <aside class="sidebar-panel"> | |
| 8 | + <div class="sidebar-header"> | |
| 9 | + <h3>📦 模板库库 ({{ templates.length }})</h3> | |
| 10 | + </div> | |
| 11 | + <div class="sidebar-search"> | |
| 12 | + <input type="text" v-model="searchQuery" placeholder="搜索模板..." /> | |
| 13 | + </div> | |
| 14 | + <div class="template-list-scroll"> | |
| 15 | + <div | |
| 16 | + v-for="tpl in filteredTemplates" :key="tpl.id" | |
| 17 | + class="template-item" | |
| 18 | + :class="{ active: selectedTemplateId === tpl.id }" | |
| 19 | + @click="selectTemplate(tpl.id)" | |
| 20 | + > | |
| 21 | + <div class="tpl-icon">{{ agentEmoji(tpl.type) }}</div> | |
| 22 | + <div class="tpl-info"> | |
| 23 | + <div class="tpl-name">{{ tpl.name }}</div> | |
| 24 | + <div class="tpl-type">{{ tpl.type }}</div> | |
| 25 | + </div> | |
| 26 | + <div v-if="tpl.benchmarkScore" class="tpl-score">⭐ {{ tpl.benchmarkScore }}</div> | |
| 27 | + </div> | |
| 28 | + <div v-if="filteredTemplates.length === 0" class="empty-mini">未找到匹配模板</div> | |
| 29 | + </div> | |
| 30 | + </aside> | |
| 31 | + | |
| 32 | + <!-- ═══ Right Content (Detail & Files) ═══ --> | |
| 33 | + <main class="content-panel"> | |
| 34 | + <div v-if="selectedTemplate" class="template-detail"> | |
| 35 | + | |
| 36 | + <div class="detail-header"> | |
| 37 | + <div class="header-main"> | |
| 38 | + <span class="header-icon">{{ agentEmoji(selectedTemplate.type) }}</span> | |
| 39 | + <div> | |
| 40 | + <h2>{{ selectedTemplate.name }}</h2> | |
| 41 | + <div class="meta-row"> | |
| 42 | + <span class="badge badge-type">{{ selectedTemplate.type }}</span> | |
| 43 | + <span class="badge badge-used">被使用: {{ selectedTemplate.usedCount }} 次</span> | |
| 44 | + <span v-if="selectedTemplate.benchmarkScore" class="badge badge-score">评分: {{ selectedTemplate.benchmarkScore }}</span> | |
| 45 | + </div> | |
| 46 | + </div> | |
| 47 | + </div> | |
| 48 | + </div> | |
| 49 | + | |
| 50 | + <!-- File Explorer --> | |
| 51 | + <div class="files-layout"> | |
| 52 | + <div class="files-tree-panel"> | |
| 53 | + <div class="panel-title"> | |
| 54 | + 📁 模板文件结构 | |
| 55 | + <span class="file-count-badge">{{ templateFiles?.totalFiles || 0 }} 文件</span> | |
| 56 | + </div> | |
| 57 | + <div class="files-tree-scroll"> | |
| 58 | + <div v-if="templateFiles?.tree?.length > 0"> | |
| 59 | + <template v-for="node in templateFiles.tree" :key="node.path"> | |
| 60 | + <CompanyFileNode :node="node" :depth="0" :selected="selectedFilePath" @select="previewTemplateFile" /> | |
| 61 | + </template> | |
| 62 | + </div> | |
| 63 | + <div v-else class="empty-mini">暂无文件或系统隐藏</div> | |
| 64 | + </div> | |
| 65 | + </div> | |
| 66 | + | |
| 67 | + <div class="files-preview-panel"> | |
| 68 | + <div class="panel-title"> | |
| 69 | + 👁️ {{ selectedFilePath || '文件预览' }} | |
| 70 | + <span v-if="filePreviewSize" class="file-size-badge">{{ formatFileSize(filePreviewSize) }}</span> | |
| 71 | + </div> | |
| 72 | + <div class="files-preview-scroll"> | |
| 73 | + <pre v-if="filePreviewContent !== null" class="file-preview-code">{{ filePreviewContent }}</pre> | |
| 74 | + <div v-else class="empty-mini">← 点击左侧文件树以预览内容</div> | |
| 75 | + </div> | |
| 76 | + </div> | |
| 77 | + </div> | |
| 78 | + | |
| 79 | + </div> | |
| 80 | + <div v-else class="empty-state"> | |
| 81 | + <div class="empty-icon">📦</div> | |
| 82 | + <h3>请在左侧选择一个 Agent 模板</h3> | |
| 83 | + </div> | |
| 84 | + </main> | |
| 85 | + </div> | |
| 86 | + </div> | |
| 87 | +</template> | |
| 88 | + | |
| 89 | +<script setup> | |
| 90 | +import { ref, computed, onMounted, watch } from 'vue' | |
| 91 | +import { useRouter, useRoute } from 'vue-router' | |
| 92 | +import axios from 'axios' | |
| 93 | +import AppNav from '@/components/AppNav.vue' | |
| 94 | +import CompanyFileNode from '@/components/CompanyFileNode.vue' | |
| 95 | + | |
| 96 | +const router = useRouter() | |
| 97 | +const route = useRoute() | |
| 98 | + | |
| 99 | +const templates = ref([]) | |
| 100 | +const searchQuery = ref('') | |
| 101 | +const selectedTemplateId = ref(null) | |
| 102 | + | |
| 103 | +const templateFiles = ref(null) | |
| 104 | +const selectedFilePath = ref(null) | |
| 105 | +const filePreviewContent = ref(null) | |
| 106 | +const filePreviewSize = ref(0) | |
| 107 | + | |
| 108 | +const filteredTemplates = computed(() => { | |
| 109 | + if (!searchQuery.value) return templates.value; | |
| 110 | + const q = searchQuery.value.toLowerCase(); | |
| 111 | + return templates.value.filter(t => t.id.toLowerCase().includes(q) || (t.name && t.name.toLowerCase().includes(q))); | |
| 112 | +}) | |
| 113 | + | |
| 114 | +const selectedTemplate = computed(() => { | |
| 115 | + return templates.value.find(t => t.id === selectedTemplateId.value) || null; | |
| 116 | +}) | |
| 117 | + | |
| 118 | +function agentEmoji(type) { | |
| 119 | + const map = { cxo: '👔', role: '🏢', skill: '⚙️', specialist: '🔬', writer: '✍️', 'data-analyst': '📈' } | |
| 120 | + return map[type] || '🤖' | |
| 121 | +} | |
| 122 | + | |
| 123 | +function formatFileSize(bytes) { | |
| 124 | + if (!bytes) return '' | |
| 125 | + if (bytes < 1024) return `${bytes} B` | |
| 126 | + if (bytes < 1048576) return `${(bytes/1024).toFixed(1)} KB` | |
| 127 | + return `${(bytes/1048576).toFixed(1)} MB` | |
| 128 | +} | |
| 129 | + | |
| 130 | +async function loadTemplates() { | |
| 131 | + try { | |
| 132 | + const { data } = await axios.get('/api/company/templates') | |
| 133 | + templates.value = data | |
| 134 | + | |
| 135 | + // Auto select from route if valid | |
| 136 | + if (route.params.agentId && templates.value.some(t => t.id === route.params.agentId)) { | |
| 137 | + selectedTemplateId.value = route.params.agentId; | |
| 138 | + await loadTemplateFiles(route.params.agentId); | |
| 139 | + } else if (templates.value.length > 0) { | |
| 140 | + // Default auto-select the first one and replace route | |
| 141 | + selectTemplate(templates.value[0].id) | |
| 142 | + } | |
| 143 | + } catch (e) { | |
| 144 | + console.error(e) | |
| 145 | + } | |
| 146 | +} | |
| 147 | + | |
| 148 | +function selectTemplate(id) { | |
| 149 | + selectedTemplateId.value = id; | |
| 150 | + // Clear file preview selection | |
| 151 | + selectedFilePath.value = null; | |
| 152 | + filePreviewContent.value = null; | |
| 153 | + filePreviewSize.value = 0; | |
| 154 | + | |
| 155 | + router.push(`/templates/${id}`); | |
| 156 | +} | |
| 157 | + | |
| 158 | +async function loadTemplateFiles(id) { | |
| 159 | + try { | |
| 160 | + const { data } = await axios.get(`/api/company/templates/${id}/files`); | |
| 161 | + templateFiles.value = data; | |
| 162 | + } catch (e) { | |
| 163 | + console.error(e); | |
| 164 | + } | |
| 165 | +} | |
| 166 | + | |
| 167 | +async function previewTemplateFile(filePath) { | |
| 168 | + if (!selectedTemplateId.value) return; | |
| 169 | + selectedFilePath.value = filePath; | |
| 170 | + try { | |
| 171 | + const { data } = await axios.get(`/api/company/templates/${selectedTemplateId.value}/files/content`, { | |
| 172 | + params: { path: filePath } | |
| 173 | + }); | |
| 174 | + filePreviewContent.value = data.content; | |
| 175 | + filePreviewSize.value = data.size || 0; | |
| 176 | + } catch (e) { | |
| 177 | + filePreviewContent.value = '(加载失败)'; | |
| 178 | + filePreviewSize.value = 0; | |
| 179 | + } | |
| 180 | +} | |
| 181 | + | |
| 182 | +watch(() => route.params.agentId, (newId) => { | |
| 183 | + if (newId && newId !== selectedTemplateId.value) { | |
| 184 | + selectedTemplateId.value = newId; | |
| 185 | + loadTemplateFiles(newId); | |
| 186 | + } | |
| 187 | +}) | |
| 188 | + | |
| 189 | +watch(selectedTemplateId, (newId) => { | |
| 190 | + if (newId) { | |
| 191 | + loadTemplateFiles(newId); | |
| 192 | + } | |
| 193 | +}) | |
| 194 | + | |
| 195 | +onMounted(() => { | |
| 196 | + loadTemplates() | |
| 197 | +}) | |
| 198 | +</script> | |
| 199 | + | |
| 200 | +<style scoped> | |
| 201 | +.template-agents-page { | |
| 202 | + height: 100vh; | |
| 203 | + display: flex; | |
| 204 | + flex-direction: column; | |
| 205 | + background: var(--bg-primary); | |
| 206 | +} | |
| 207 | + | |
| 208 | +.main-layout { | |
| 209 | + flex: 1; | |
| 210 | + display: flex; | |
| 211 | + overflow: hidden; | |
| 212 | +} | |
| 213 | + | |
| 214 | +/* ═══ Sidebar ═══ */ | |
| 215 | +.sidebar-panel { | |
| 216 | + width: 280px; | |
| 217 | + background: var(--bg-secondary); | |
| 218 | + border-right: 1px solid var(--border-primary); | |
| 219 | + display: flex; | |
| 220 | + flex-direction: column; | |
| 221 | +} | |
| 222 | +.sidebar-header { | |
| 223 | + padding: 16px 20px; | |
| 224 | + border-bottom: 1px solid var(--border-primary); | |
| 225 | +} | |
| 226 | +.sidebar-header h3 { | |
| 227 | + font-size: 16px; font-weight: 700; margin: 0; color: var(--text-primary); | |
| 228 | +} | |
| 229 | +.sidebar-search { | |
| 230 | + padding: 12px; | |
| 231 | + border-bottom: 1px solid var(--border-primary); | |
| 232 | +} | |
| 233 | +.sidebar-search input { | |
| 234 | + width: 100%; padding: 8px 12px; | |
| 235 | + border: 1px solid var(--border-primary); | |
| 236 | + border-radius: 8px; background: var(--bg-input); | |
| 237 | + color: var(--text-primary); font-size: 13px; | |
| 238 | + transition: all 0.2s; | |
| 239 | +} | |
| 240 | +.sidebar-search input:focus { | |
| 241 | + outline: none; border-color: var(--accent); background: var(--bg-input-focus); | |
| 242 | +} | |
| 243 | +.template-list-scroll { | |
| 244 | + flex: 1; overflow-y: auto; padding: 12px; | |
| 245 | + display: flex; flex-direction: column; gap: 8px; | |
| 246 | +} | |
| 247 | +.template-item { | |
| 248 | + display: flex; align-items: center; gap: 12px; | |
| 249 | + padding: 12px; border-radius: 10px; cursor: pointer; | |
| 250 | + border: 1px solid transparent; transition: all 0.2s; | |
| 251 | +} | |
| 252 | +.template-item:hover { | |
| 253 | + background: var(--bg-surface-hover); | |
| 254 | +} | |
| 255 | +.template-item.active { | |
| 256 | + background: var(--bg-surface); | |
| 257 | + border-color: var(--border-active); | |
| 258 | + box-shadow: 0 4px 12px rgba(0,0,0,0.05); | |
| 259 | +} | |
| 260 | +.tpl-icon { font-size: 24px; } | |
| 261 | +.tpl-info { flex: 1; min-width: 0; } | |
| 262 | +.tpl-name { | |
| 263 | + font-size: 14px; font-weight: 700; color: var(--text-primary); | |
| 264 | + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; | |
| 265 | +} | |
| 266 | +.tpl-type { font-size: 11px; color: var(--text-muted); text-transform: uppercase; margin-top: 2px; } | |
| 267 | +.tpl-score { font-size: 11px; color: var(--yellow-text); font-weight: 700; } | |
| 268 | + | |
| 269 | +.empty-mini { padding: 24px; text-align: center; color: var(--text-muted); font-size: 13px; } | |
| 270 | + | |
| 271 | +/* ═══ Main Content ═══ */ | |
| 272 | +.content-panel { | |
| 273 | + flex: 1; display: flex; flex-direction: column; | |
| 274 | + background: var(--bg-primary); padding: 24px; | |
| 275 | +} | |
| 276 | +.empty-state { | |
| 277 | + display: flex; flex-direction: column; align-items: center; justify-content: center; | |
| 278 | + height: 100%; color: var(--text-muted); | |
| 279 | +} | |
| 280 | +.empty-icon { font-size: 48px; margin-bottom: 16px; opacity: 0.5; } | |
| 281 | +.template-detail { | |
| 282 | + display: flex; flex-direction: column; height: 100%; gap: 24px; | |
| 283 | +} | |
| 284 | +.detail-header { | |
| 285 | + background: var(--bg-secondary); border: 1px solid var(--border-primary); | |
| 286 | + border-radius: 16px; padding: 24px; display: flex; justify-content: space-between; align-items: center; | |
| 287 | +} | |
| 288 | +.header-main { display: flex; align-items: center; gap: 20px; } | |
| 289 | +.header-icon { font-size: 48px; } | |
| 290 | +.header-main h2 { font-size: 24px; font-weight: 800; margin: 0 0 8px 0; color: var(--text-primary); } | |
| 291 | +.meta-row { display: flex; gap: 12px; } | |
| 292 | +.badge { padding: 4px 10px; border-radius: 8px; font-size: 12px; font-weight: 600; } | |
| 293 | +.badge-type { background: var(--bg-surface-hover); color: var(--text-secondary); border: 1px solid var(--border-primary); text-transform: uppercase; } | |
| 294 | +.badge-used { background: rgba(59,130,246,0.1); color: var(--blue-text); } | |
| 295 | +.badge-score { background: rgba(251,191,36,0.1); color: var(--yellow-text); } | |
| 296 | + | |
| 297 | +/* ═══ Files Layout ═══ */ | |
| 298 | +.files-layout { | |
| 299 | + display: flex; gap: 0; flex: 1; min-height: 0; | |
| 300 | + border: 1px solid var(--border-primary); border-radius: 12px; overflow: hidden; | |
| 301 | + background: var(--bg-surface); | |
| 302 | +} | |
| 303 | +.files-tree-panel { | |
| 304 | + width: 300px; min-width: 240px; border-right: 1px solid var(--border-primary); | |
| 305 | + display: flex; flex-direction: column; background: var(--bg-overlay); | |
| 306 | +} | |
| 307 | +.files-preview-panel { | |
| 308 | + flex: 1; display: flex; flex-direction: column; min-width: 0; | |
| 309 | +} | |
| 310 | +.panel-title { | |
| 311 | + padding: 12px 16px; font-size: 13px; font-weight: 700; color: var(--text-secondary); | |
| 312 | + border-bottom: 1px solid var(--border-primary); | |
| 313 | + display: flex; align-items: center; justify-content: space-between; gap: 8px; | |
| 314 | + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; | |
| 315 | +} | |
| 316 | +.files-tree-scroll { flex: 1; overflow-y: auto; padding: 8px; } | |
| 317 | +.files-preview-scroll { flex: 1; overflow: auto; padding: 12px; background: var(--bg-input); } | |
| 318 | +.file-preview-code { | |
| 319 | + font-family: 'JetBrains Mono', 'Menlo', monospace; font-size: 13px; | |
| 320 | + line-height: 1.6; color: var(--text-secondary); white-space: pre-wrap; | |
| 321 | + word-break: break-all; margin: 0; padding: 4px; | |
| 322 | +} | |
| 323 | +.file-count-badge { | |
| 324 | + padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; | |
| 325 | + background: var(--accent-bg); color: var(--accent); flex-shrink: 0; | |
| 326 | +} | |
| 327 | +.file-size-badge { | |
| 328 | + padding: 2px 8px; border-radius: 6px; font-size: 11px; | |
| 329 | + background: var(--purple-bg, rgba(139,92,246,0.1)); color: var(--purple-text, #a78bfa); | |
| 330 | + flex-shrink: 0; font-weight: 600; | |
| 331 | +} | |
| 332 | +</style> | ... | ... |