Commit 1eae5cb5b60c61198a3af194bb8a20bb20fd8548

Authored by yibo
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)
.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(&#39;/templates&#39;, (req, res) =&gt; {
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(&#39;/:companyId/pipelines/runs/:runId/reject&#39;, (req, res) =&gt; {
725 812 }
726 813 });
727 814  
728   -// ═══════════════════════════════════════
729 815 // Schedule (日程管理)
730 816 // ═══════════════════════════════════════
731 817  
... ... @@ -973,4 +1059,145 @@ router.get(&#39;/:companyId/messages&#39;, (req, res) =&gt; {
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(&#39;/skills/:name/updates&#39;, h((req, res) =&gt; {
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>
... ...