Commit 13e9660c331a85d9e45ed07186eb735b86a02ed6
1 parent
6d81a868
feat: company workspace page + HTML preview + navbar cleanup
1. Workspace page: left file tree + right detail/editor panel - File info: name, path, size, created/modified timestamps - Three view modes: Code / Preview / Edit with save - HTML files auto-open in Preview tab (sandboxed iframe) 2. Backend APIs: - GET/PUT /:companyId/workspace/files (list + content + save) - PUT /:companyId/files/content (company file save) 3. Navbar: removed Skills/Jobs/Pipeline, added Workspace 4. All nav links open in new tabs (target=_blank)
Showing
4 changed files
with
418 additions
and
42 deletions
server/routes/company.js
| ... | ... | @@ -1431,6 +1431,138 @@ router.get('/:companyId/files/content', (req, res) => { |
| 1431 | 1431 | } |
| 1432 | 1432 | }); |
| 1433 | 1433 | |
| 1434 | +// PUT /api/company/:companyId/files/content — 保存文件内容 | |
| 1435 | +router.put('/:companyId/files/content', (req, res) => { | |
| 1436 | + try { | |
| 1437 | + const DATA_DIR = path.resolve(__dirname, '../../data/companies'); | |
| 1438 | + const filePath = req.body.path; | |
| 1439 | + if (!filePath) return res.status(400).json({ error: 'path 参数为必填' }); | |
| 1440 | + | |
| 1441 | + const absPath = path.resolve(DATA_DIR, req.params.companyId, filePath); | |
| 1442 | + const safeBoundary = path.resolve(DATA_DIR, req.params.companyId); | |
| 1443 | + if (!absPath.startsWith(safeBoundary)) { | |
| 1444 | + return res.status(403).json({ error: '路径越界' }); | |
| 1445 | + } | |
| 1446 | + | |
| 1447 | + const dir = path.dirname(absPath); | |
| 1448 | + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); | |
| 1449 | + | |
| 1450 | + fs.writeFileSync(absPath, req.body.content || '', 'utf-8'); | |
| 1451 | + console.log(`[Company] 💾 已保存文件 ${req.params.companyId}/${filePath}`); | |
| 1452 | + res.json({ success: true, path: filePath }); | |
| 1453 | + } catch (err) { | |
| 1454 | + res.status(500).json({ error: err.message }); | |
| 1455 | + } | |
| 1456 | +}); | |
| 1457 | + | |
| 1458 | +// ═══════════════════════════════════════ | |
| 1459 | +// Company Workspace — 工作区文件管理 | |
| 1460 | +// ═══════════════════════════════════════ | |
| 1461 | + | |
| 1462 | +// GET /api/company/:companyId/workspace/files — 列出 workspace 文件树 | |
| 1463 | +router.get('/:companyId/workspace/files', (req, res) => { | |
| 1464 | + try { | |
| 1465 | + const DATA_DIR = path.resolve(__dirname, '../../data/companies'); | |
| 1466 | + const wsDir = path.join(DATA_DIR, req.params.companyId, 'workspace'); | |
| 1467 | + | |
| 1468 | + if (!fs.existsSync(wsDir)) { | |
| 1469 | + return res.json({ tree: [], totalFiles: 0 }); | |
| 1470 | + } | |
| 1471 | + | |
| 1472 | + function scanDir(dirPath, relativePath = '') { | |
| 1473 | + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); | |
| 1474 | + const nodes = []; | |
| 1475 | + for (const entry of entries) { | |
| 1476 | + const fullPath = path.join(dirPath, entry.name); | |
| 1477 | + const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name; | |
| 1478 | + if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; | |
| 1479 | + | |
| 1480 | + if (entry.isDirectory()) { | |
| 1481 | + const children = scanDir(fullPath, relPath); | |
| 1482 | + nodes.push({ | |
| 1483 | + name: entry.name, path: relPath, type: 'directory', children, | |
| 1484 | + fileCount: children.reduce((sum, c) => sum + (c.type === 'file' ? 1 : (c.fileCount || 0)), 0), | |
| 1485 | + }); | |
| 1486 | + } else { | |
| 1487 | + const stat = fs.statSync(fullPath); | |
| 1488 | + nodes.push({ | |
| 1489 | + name: entry.name, path: relPath, type: 'file', | |
| 1490 | + size: stat.size, | |
| 1491 | + createdAt: stat.birthtime.toISOString(), | |
| 1492 | + modifiedAt: stat.mtime.toISOString(), | |
| 1493 | + }); | |
| 1494 | + } | |
| 1495 | + } | |
| 1496 | + return nodes.sort((a, b) => { | |
| 1497 | + if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; | |
| 1498 | + return a.name.localeCompare(b.name); | |
| 1499 | + }); | |
| 1500 | + } | |
| 1501 | + | |
| 1502 | + const tree = scanDir(wsDir); | |
| 1503 | + const countFiles = (nodes) => nodes.reduce((sum, n) => sum + (n.type === 'file' ? 1 : countFiles(n.children || [])), 0); | |
| 1504 | + res.json({ tree, totalFiles: countFiles(tree) }); | |
| 1505 | + } catch (err) { | |
| 1506 | + res.status(500).json({ error: err.message }); | |
| 1507 | + } | |
| 1508 | +}); | |
| 1509 | + | |
| 1510 | +// GET /api/company/:companyId/workspace/files/content?path=xxx — 读取 workspace 文件内容 | |
| 1511 | +router.get('/:companyId/workspace/files/content', (req, res) => { | |
| 1512 | + try { | |
| 1513 | + const DATA_DIR = path.resolve(__dirname, '../../data/companies'); | |
| 1514 | + const filePath = String(req.query.path || ''); | |
| 1515 | + if (!filePath) return res.status(400).json({ error: 'path 参数为必填' }); | |
| 1516 | + | |
| 1517 | + const absPath = path.resolve(DATA_DIR, req.params.companyId, 'workspace', filePath); | |
| 1518 | + const safeBoundary = path.resolve(DATA_DIR, req.params.companyId, 'workspace'); | |
| 1519 | + if (!absPath.startsWith(safeBoundary)) { | |
| 1520 | + return res.status(403).json({ error: '路径越界' }); | |
| 1521 | + } | |
| 1522 | + if (!fs.existsSync(absPath)) { | |
| 1523 | + return res.status(404).json({ error: '文件不存在' }); | |
| 1524 | + } | |
| 1525 | + | |
| 1526 | + const stat = fs.statSync(absPath); | |
| 1527 | + if (stat.size > 512 * 1024) { | |
| 1528 | + return res.json({ content: '(文件过大,跳过预览)', truncated: true, size: stat.size }); | |
| 1529 | + } | |
| 1530 | + | |
| 1531 | + const content = fs.readFileSync(absPath, 'utf-8'); | |
| 1532 | + res.json({ | |
| 1533 | + content, size: stat.size, path: filePath, | |
| 1534 | + createdAt: stat.birthtime.toISOString(), | |
| 1535 | + modifiedAt: stat.mtime.toISOString(), | |
| 1536 | + }); | |
| 1537 | + } catch (err) { | |
| 1538 | + res.status(500).json({ error: err.message }); | |
| 1539 | + } | |
| 1540 | +}); | |
| 1541 | + | |
| 1542 | +// PUT /api/company/:companyId/workspace/files/content — 保存 workspace 文件 | |
| 1543 | +router.put('/:companyId/workspace/files/content', (req, res) => { | |
| 1544 | + try { | |
| 1545 | + const DATA_DIR = path.resolve(__dirname, '../../data/companies'); | |
| 1546 | + const filePath = req.body.path; | |
| 1547 | + if (!filePath) return res.status(400).json({ error: 'path 参数为必填' }); | |
| 1548 | + | |
| 1549 | + const absPath = path.resolve(DATA_DIR, req.params.companyId, 'workspace', filePath); | |
| 1550 | + const safeBoundary = path.resolve(DATA_DIR, req.params.companyId, 'workspace'); | |
| 1551 | + if (!absPath.startsWith(safeBoundary)) { | |
| 1552 | + return res.status(403).json({ error: '路径越界' }); | |
| 1553 | + } | |
| 1554 | + | |
| 1555 | + const dir = path.dirname(absPath); | |
| 1556 | + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); | |
| 1557 | + | |
| 1558 | + fs.writeFileSync(absPath, req.body.content || '', 'utf-8'); | |
| 1559 | + console.log(`[Workspace] 💾 已保存 ${req.params.companyId}/workspace/${filePath}`); | |
| 1560 | + res.json({ success: true, path: filePath }); | |
| 1561 | + } catch (err) { | |
| 1562 | + res.status(500).json({ error: err.message }); | |
| 1563 | + } | |
| 1564 | +}); | |
| 1565 | + | |
| 1434 | 1566 | // ═══════════════════════════════════════ |
| 1435 | 1567 | // Company Messages — 全局消息时间线 |
| 1436 | 1568 | // ═══════════════════════════════════════ | ... | ... |
web/src/components/AppNav.vue
| ... | ... | @@ -5,21 +5,21 @@ |
| 5 | 5 | <span class="nav-title">LemonBoss</span> |
| 6 | 6 | </div> |
| 7 | 7 | <div class="nav-links"> |
| 8 | - <router-link to="/agent/master" class="nav-link" :class="{ active: $route.path.startsWith('/agent') }"> | |
| 8 | + <a href="/agent/master" target="_blank" class="nav-link" :class="{ active: $route.path.startsWith('/agent') }"> | |
| 9 | 9 | <span>🤖</span> System Agents |
| 10 | - </router-link> | |
| 10 | + </a> | |
| 11 | 11 | |
| 12 | - <router-link to="/templates" class="nav-link" :class="{ active: $route.path.startsWith('/templates') }"> | |
| 12 | + <a href="/templates" target="_blank" class="nav-link" :class="{ active: $route.path.startsWith('/templates') }"> | |
| 13 | 13 | <span>📦</span> Templates |
| 14 | - </router-link> | |
| 14 | + </a> | |
| 15 | 15 | |
| 16 | - <router-link to="/tasks" class="nav-link" :class="{ active: $route.path.startsWith('/tasks') }"> | |
| 16 | + <a href="/tasks" target="_blank" class="nav-link" :class="{ active: $route.path.startsWith('/tasks') }"> | |
| 17 | 17 | <span>📜</span> Task Logs |
| 18 | - </router-link> | |
| 18 | + </a> | |
| 19 | 19 | |
| 20 | - <router-link to="/quota" class="nav-link" :class="{ active: $route.path === '/quota' }"> | |
| 20 | + <a href="/quota" target="_blank" class="nav-link" :class="{ active: $route.path === '/quota' }"> | |
| 21 | 21 | <span>📊</span> Quota |
| 22 | - </router-link> | |
| 22 | + </a> | |
| 23 | 23 | |
| 24 | 24 | <div class="nav-divider"></div> |
| 25 | 25 | |
| ... | ... | @@ -49,45 +49,27 @@ |
| 49 | 49 | |
| 50 | 50 | <!-- Contextual Company Navigation --> |
| 51 | 51 | <template v-if="currentCompany"> |
| 52 | - <router-link :to="`/${currentCompany.id}/overview`" class="nav-link" :class="{ active: $route.params.tab === 'overview' }"> | |
| 52 | + <a :href="`/${currentCompany.id}/overview`" target="_blank" class="nav-link" :class="{ active: $route.params.tab === 'overview' }"> | |
| 53 | 53 | 📊 Overview |
| 54 | - </router-link> | |
| 55 | - <router-link :to="`/${currentCompany.id}/agents`" class="nav-link" :class="{ active: $route.params.tab === 'agents' }"> | |
| 54 | + </a> | |
| 55 | + <a :href="`/${currentCompany.id}/agents`" target="_blank" class="nav-link" :class="{ active: $route.params.tab === 'agents' }"> | |
| 56 | 56 | 👥 Team |
| 57 | - </router-link> | |
| 58 | - <router-link :to="`/${currentCompany.id}/pipelines`" class="nav-link" :class="{ active: $route.params.tab === 'pipelines' || $route.params.tab === 'sop' }"> | |
| 57 | + </a> | |
| 58 | + <a :href="`/${currentCompany.id}/workspace`" target="_blank" class="nav-link" :class="{ active: $route.params.tab === 'workspace' }"> | |
| 59 | + 💼 Workspace | |
| 60 | + </a> | |
| 61 | + <a :href="`/${currentCompany.id}/pipelines`" target="_blank" class="nav-link" :class="{ active: $route.params.tab === 'pipelines' || $route.params.tab === 'sop' }"> | |
| 59 | 62 | ⚙️ SOP |
| 60 | - </router-link> | |
| 61 | - <router-link :to="`/${currentCompany.id}/goals`" class="nav-link" :class="{ active: $route.params.tab === 'goals' || $route.params.tab === 'okr' }"> | |
| 63 | + </a> | |
| 64 | + <a :href="`/${currentCompany.id}/goals`" target="_blank" class="nav-link" :class="{ active: $route.params.tab === 'goals' || $route.params.tab === 'okr' }"> | |
| 62 | 65 | 🎯 OKR |
| 63 | - </router-link> | |
| 64 | - <router-link :to="`/${currentCompany.id}/files`" class="nav-link" :class="{ active: $route.params.tab === 'files' }"> | |
| 66 | + </a> | |
| 67 | + <a :href="`/${currentCompany.id}/files`" target="_blank" class="nav-link" :class="{ active: $route.params.tab === 'files' }"> | |
| 65 | 68 | 📁 Files |
| 66 | - </router-link> | |
| 67 | - <router-link :to="`/${currentCompany.id}/messages`" class="nav-link" :class="{ active: $route.params.tab === 'messages' }"> | |
| 69 | + </a> | |
| 70 | + <a :href="`/${currentCompany.id}/messages`" target="_blank" class="nav-link" :class="{ active: $route.params.tab === 'messages' }"> | |
| 68 | 71 | 💬 Messages |
| 69 | - </router-link> | |
| 70 | - <router-link :to="`/${currentCompany.id}/skills`" class="nav-link" :class="{ active: $route.params.tab === 'skills' }"> | |
| 71 | - 🧪 Skills | |
| 72 | - </router-link> | |
| 73 | - <router-link :to="`/${currentCompany.id}/jobs`" class="nav-link" :class="{ active: $route.params.tab === 'jobs' }"> | |
| 74 | - 📋 Jobs | |
| 75 | - </router-link> | |
| 76 | - <router-link to="/pipelines" class="nav-link" :class="{ active: $route.path === '/pipelines' }"> | |
| 77 | - 🔗 Pipeline | |
| 78 | - </router-link> | |
| 79 | - </template> | |
| 80 | - <!-- Global Links (no company) --> | |
| 81 | - <template v-else> | |
| 82 | - <router-link to="/skills" class="nav-link" :class="{ active: $route.path === '/skills' }"> | |
| 83 | - 🛒 Skills 市场 | |
| 84 | - </router-link> | |
| 85 | - <router-link to="/jobs" class="nav-link" :class="{ active: $route.path === '/jobs' }"> | |
| 86 | - 📋 任务面板 | |
| 87 | - </router-link> | |
| 88 | - <router-link to="/pipelines" class="nav-link" :class="{ active: $route.path === '/pipelines' }"> | |
| 89 | - 🔗 Pipeline 编辑器 | |
| 90 | - </router-link> | |
| 72 | + </a> | |
| 91 | 73 | </template> |
| 92 | 74 | </div> |
| 93 | 75 | ... | ... |
web/src/router/index.js
| ... | ... | @@ -33,7 +33,7 @@ const routes = [ |
| 33 | 33 | component: () => import('@/views/CompanyDashboard.vue'), |
| 34 | 34 | }, |
| 35 | 35 | { |
| 36 | - path: '/:id/:tab(overview|agents|pipelines|goals|sop|okr|team|files|messages|skills|jobs)', | |
| 36 | + path: '/:id/:tab(overview|agents|pipelines|goals|sop|okr|team|files|messages|skills|jobs|workspace)', | |
| 37 | 37 | name: 'CompanyDetail', |
| 38 | 38 | component: () => import('@/views/CompanyDashboard.vue'), |
| 39 | 39 | props: true | ... | ... |
web/src/views/CompanyDashboard.vue
| ... | ... | @@ -445,6 +445,95 @@ |
| 445 | 445 | </div> |
| 446 | 446 | </div> |
| 447 | 447 | |
| 448 | + <!-- Tab: Workspace --> | |
| 449 | + <div v-if="activeTab === 'workspace'" class="tab-content"> | |
| 450 | + <div class="ws-layout"> | |
| 451 | + <!-- Left: File Tree --> | |
| 452 | + <div class="ws-tree-panel"> | |
| 453 | + <div class="panel-title"> | |
| 454 | + 💼 Workspace | |
| 455 | + <span class="file-count-badge">{{ wsFiles?.totalFiles || 0 }} 文件</span> | |
| 456 | + <button class="refresh-btn" @click="loadWorkspaceFiles(selectedCompany.id)" title="刷新">🔄</button> | |
| 457 | + </div> | |
| 458 | + <div class="ws-tree-scroll"> | |
| 459 | + <div v-if="wsFiles?.tree?.length > 0"> | |
| 460 | + <template v-for="node in wsFiles.tree" :key="node.path"> | |
| 461 | + <CompanyFileNode :node="node" :depth="0" :selected="wsSelectedPath" @select="selectWorkspaceFile" /> | |
| 462 | + </template> | |
| 463 | + </div> | |
| 464 | + <div v-else class="empty-mini"> | |
| 465 | + <div style="padding:24px; text-align:center; opacity:0.6;"> | |
| 466 | + <div style="font-size:32px; margin-bottom:8px;">📂</div> | |
| 467 | + <div>Workspace 为空</div> | |
| 468 | + <div style="font-size:11px; margin-top:4px;">Agent 工作产出将出现在这里</div> | |
| 469 | + </div> | |
| 470 | + </div> | |
| 471 | + </div> | |
| 472 | + </div> | |
| 473 | + <!-- Right: Detail + Editor --> | |
| 474 | + <div class="ws-detail-panel"> | |
| 475 | + <template v-if="wsSelectedFile"> | |
| 476 | + <!-- File Info Bar --> | |
| 477 | + <div class="ws-file-info"> | |
| 478 | + <div class="ws-file-info-header"> | |
| 479 | + <span class="ws-file-icon">{{ wsFileIcon(wsSelectedFile.name) }}</span> | |
| 480 | + <div class="ws-file-name-area"> | |
| 481 | + <span class="ws-file-name">{{ wsSelectedFile.name }}</span> | |
| 482 | + <span class="ws-file-path">workspace/{{ wsSelectedFile.path }}</span> | |
| 483 | + </div> | |
| 484 | + <div class="ws-file-actions"> | |
| 485 | + <button v-if="wsViewMode === 'edit'" class="btn-ws-save" @click="saveWorkspaceFile" :disabled="wsSaving"> | |
| 486 | + {{ wsSaving ? '⏳ 保存中...' : '💾 保存' }} | |
| 487 | + </button> | |
| 488 | + </div> | |
| 489 | + </div> | |
| 490 | + <div class="ws-file-meta"> | |
| 491 | + <span class="ws-meta-item">📏 {{ formatFileSize(wsSelectedFile.size) }}</span> | |
| 492 | + <span class="ws-meta-item">📅 创建: {{ formatDate(wsSelectedFile.createdAt) }}</span> | |
| 493 | + <span class="ws-meta-item">🔄 修改: {{ formatDate(wsSelectedFile.modifiedAt) }}</span> | |
| 494 | + </div> | |
| 495 | + <!-- View Mode Tabs --> | |
| 496 | + <div class="ws-view-tabs"> | |
| 497 | + <button class="ws-view-tab" :class="{ active: wsViewMode === 'code' }" @click="wsViewMode = 'code'"> | |
| 498 | + 📄 代码 | |
| 499 | + </button> | |
| 500 | + <button v-if="wsIsHtml" class="ws-view-tab" :class="{ active: wsViewMode === 'preview' }" @click="wsViewMode = 'preview'"> | |
| 501 | + 🌐 预览 | |
| 502 | + </button> | |
| 503 | + <button class="ws-view-tab" :class="{ active: wsViewMode === 'edit' }" @click="wsViewMode = 'edit'"> | |
| 504 | + ✏️ 编辑 | |
| 505 | + </button> | |
| 506 | + </div> | |
| 507 | + </div> | |
| 508 | + <!-- File Content --> | |
| 509 | + <div class="ws-content-area"> | |
| 510 | + <!-- Code View --> | |
| 511 | + <pre v-if="wsViewMode === 'code'" class="ws-preview-code">{{ wsContent }}</pre> | |
| 512 | + <!-- Edit View --> | |
| 513 | + <textarea | |
| 514 | + v-else-if="wsViewMode === 'edit'" | |
| 515 | + v-model="wsContent" | |
| 516 | + class="ws-editor" | |
| 517 | + spellcheck="false" | |
| 518 | + ></textarea> | |
| 519 | + <!-- HTML Preview --> | |
| 520 | + <iframe | |
| 521 | + v-else-if="wsViewMode === 'preview'" | |
| 522 | + class="ws-html-preview" | |
| 523 | + :srcdoc="wsContent" | |
| 524 | + sandbox="allow-scripts allow-same-origin" | |
| 525 | + ></iframe> | |
| 526 | + </div> | |
| 527 | + </template> | |
| 528 | + <div v-else class="ws-empty-state"> | |
| 529 | + <div style="font-size:48px; margin-bottom:12px; opacity:0.7;">💼</div> | |
| 530 | + <h3>Workspace 文件浏览器</h3> | |
| 531 | + <p>从左侧选择一个文件查看详情与内容</p> | |
| 532 | + </div> | |
| 533 | + </div> | |
| 534 | + </div> | |
| 535 | + </div> | |
| 536 | + | |
| 448 | 537 | <!-- Tab: Skills --> |
| 449 | 538 | <div v-if="activeTab === 'skills'" class="tab-content"> |
| 450 | 539 | <div class="section-header"> |
| ... | ... | @@ -667,6 +756,19 @@ const selectedSkillDetail = ref(null) |
| 667 | 756 | const companyJobs = ref([]) |
| 668 | 757 | const jobStatusFilter = ref('') |
| 669 | 758 | |
| 759 | +// Workspace tab | |
| 760 | +const wsFiles = ref(null) | |
| 761 | +const wsSelectedPath = ref(null) | |
| 762 | +const wsSelectedFile = ref(null) | |
| 763 | +const wsContent = ref('') | |
| 764 | +const wsViewMode = ref('code') // 'code' | 'edit' | 'preview' | |
| 765 | +const wsSaving = ref(false) | |
| 766 | + | |
| 767 | +const wsIsHtml = computed(() => { | |
| 768 | + if (!wsSelectedFile.value?.name) return false | |
| 769 | + return /\.(html?|htm)$/i.test(wsSelectedFile.value.name) | |
| 770 | +}) | |
| 771 | + | |
| 670 | 772 | function statusLabel(status) { |
| 671 | 773 | const map = { creating: '创建中', running: '运行中', paused: '已暂停', shutdown: '已关停' } |
| 672 | 774 | return map[status] || status |
| ... | ... | @@ -708,6 +810,7 @@ async function loadCompanyData(id, tab) { |
| 708 | 810 | if (activeTab.value === 'messages') await loadMessages(id) |
| 709 | 811 | if (activeTab.value === 'skills') await loadSkillsList(id) |
| 710 | 812 | if (activeTab.value === 'jobs') await loadCompanyJobs(id) |
| 813 | + if (activeTab.value === 'workspace') await loadWorkspaceFiles(id) | |
| 711 | 814 | } catch (e) { console.error(e) } |
| 712 | 815 | } |
| 713 | 816 | |
| ... | ... | @@ -946,6 +1049,68 @@ function viewJobDetail(job) { |
| 946 | 1049 | alert(`任务详情:\nID: ${job.id}\n状态: ${job.status}\n创建: ${formatDate(job.createdAt)}`) |
| 947 | 1050 | } |
| 948 | 1051 | |
| 1052 | +// ═══ Workspace Tab Functions ═══ | |
| 1053 | +async function loadWorkspaceFiles(id) { | |
| 1054 | + try { | |
| 1055 | + const { data } = await axios.get(`/api/company/${id}/workspace/files`) | |
| 1056 | + wsFiles.value = data | |
| 1057 | + } catch (e) { console.error(e) } | |
| 1058 | +} | |
| 1059 | + | |
| 1060 | +async function selectWorkspaceFile(filePath) { | |
| 1061 | + if (!selectedCompany.value) return | |
| 1062 | + wsSelectedPath.value = filePath | |
| 1063 | + wsViewMode.value = 'code' | |
| 1064 | + try { | |
| 1065 | + const { data } = await axios.get(`/api/company/${selectedCompany.value.id}/workspace/files/content`, { params: { path: filePath } }) | |
| 1066 | + wsContent.value = data.content | |
| 1067 | + wsSelectedFile.value = { | |
| 1068 | + name: filePath.split('/').pop(), | |
| 1069 | + path: filePath, | |
| 1070 | + size: data.size, | |
| 1071 | + createdAt: data.createdAt, | |
| 1072 | + modifiedAt: data.modifiedAt, | |
| 1073 | + } | |
| 1074 | + // HTML 文件自动切到预览 | |
| 1075 | + if (/\.(html?|htm)$/i.test(filePath)) { | |
| 1076 | + wsViewMode.value = 'preview' | |
| 1077 | + } | |
| 1078 | + } catch { | |
| 1079 | + wsContent.value = '(加载失败)' | |
| 1080 | + wsSelectedFile.value = null | |
| 1081 | + } | |
| 1082 | +} | |
| 1083 | + | |
| 1084 | +async function saveWorkspaceFile() { | |
| 1085 | + if (!selectedCompany.value || !wsSelectedPath.value) return | |
| 1086 | + wsSaving.value = true | |
| 1087 | + try { | |
| 1088 | + await axios.put(`/api/company/${selectedCompany.value.id}/workspace/files/content`, { | |
| 1089 | + path: wsSelectedPath.value, | |
| 1090 | + content: wsContent.value, | |
| 1091 | + }) | |
| 1092 | + // Refresh file info | |
| 1093 | + await selectWorkspaceFile(wsSelectedPath.value) | |
| 1094 | + wsViewMode.value = 'code' | |
| 1095 | + } catch (e) { | |
| 1096 | + alert(`❌ 保存失败: ${e.message}`) | |
| 1097 | + } | |
| 1098 | + wsSaving.value = false | |
| 1099 | +} | |
| 1100 | + | |
| 1101 | +function wsFileIcon(name) { | |
| 1102 | + if (!name) return '📄' | |
| 1103 | + const ext = name.split('.').pop()?.toLowerCase() | |
| 1104 | + const icons = { | |
| 1105 | + json: '📋', md: '📝', js: '⚡', ts: '🔷', py: '🐍', | |
| 1106 | + html: '🌐', css: '🎨', sh: '⚙️', yml: '📐', yaml: '📐', | |
| 1107 | + txt: '📄', log: '📜', csv: '📊', xml: '📰', | |
| 1108 | + jpg: '🖼️', jpeg: '🖼️', png: '🖼️', gif: '🖼️', svg: '🖼️', | |
| 1109 | + mp4: '🎬', mp3: '🎵', pdf: '📕', zip: '📦', | |
| 1110 | + } | |
| 1111 | + return icons[ext] || '📄' | |
| 1112 | +} | |
| 1113 | + | |
| 949 | 1114 | // switchTab removed, handled by goToTab and router watcher |
| 950 | 1115 | |
| 951 | 1116 | // ═══ Files Tab Functions ═══ |
| ... | ... | @@ -1678,6 +1843,103 @@ function closeDropdown() { |
| 1678 | 1843 | flex-shrink: 0; |
| 1679 | 1844 | } |
| 1680 | 1845 | |
| 1846 | +/* ═══ Workspace Tab ═══ */ | |
| 1847 | +.ws-layout { | |
| 1848 | + display: flex; gap: 0; height: calc(100vh - 200px); | |
| 1849 | + border: 1px solid var(--border-primary); border-radius: 12px; overflow: hidden; | |
| 1850 | + background: var(--bg-surface); | |
| 1851 | +} | |
| 1852 | +.ws-tree-panel { | |
| 1853 | + width: 300px; min-width: 240px; border-right: 1px solid var(--border-primary); | |
| 1854 | + display: flex; flex-direction: column; background: var(--bg-overlay); | |
| 1855 | +} | |
| 1856 | +.ws-tree-scroll { flex: 1; overflow-y: auto; padding: 6px; } | |
| 1857 | +.ws-detail-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; } | |
| 1858 | +.ws-file-info { | |
| 1859 | + padding: 12px 16px; border-bottom: 1px solid var(--border-primary); | |
| 1860 | + background: var(--bg-overlay); | |
| 1861 | +} | |
| 1862 | +.ws-file-info-header { | |
| 1863 | + display: flex; align-items: center; gap: 10px; | |
| 1864 | +} | |
| 1865 | +.ws-file-icon { font-size: 28px; flex-shrink: 0; } | |
| 1866 | +.ws-file-name-area { flex: 1; min-width: 0; } | |
| 1867 | +.ws-file-name { | |
| 1868 | + font-size: 15px; font-weight: 700; color: var(--text-primary); | |
| 1869 | + display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; | |
| 1870 | +} | |
| 1871 | +.ws-file-path { | |
| 1872 | + font-size: 11px; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; | |
| 1873 | +} | |
| 1874 | +.ws-file-actions { display: flex; gap: 6px; flex-shrink: 0; } | |
| 1875 | +.btn-ws-save { | |
| 1876 | + padding: 5px 14px; border-radius: 8px; font-size: 12px; font-weight: 700; | |
| 1877 | + border: 1px solid rgba(16,185,129,0.3); background: rgba(16,185,129,0.1); | |
| 1878 | + color: #34d399; cursor: pointer; transition: all 0.2s; | |
| 1879 | +} | |
| 1880 | +.btn-ws-save:hover:not(:disabled) { background: rgba(16,185,129,0.2); box-shadow: 0 2px 8px rgba(16,185,129,0.15); } | |
| 1881 | +.btn-ws-edit { | |
| 1882 | + padding: 5px 14px; border-radius: 8px; font-size: 12px; font-weight: 700; | |
| 1883 | + border: 1px solid var(--border-primary); background: var(--bg-surface); | |
| 1884 | + color: var(--text-secondary); cursor: pointer; transition: all 0.2s; | |
| 1885 | +} | |
| 1886 | +.btn-ws-edit:hover { background: var(--bg-surface-hover); border-color: var(--border-active); } | |
| 1887 | +.ws-file-meta { | |
| 1888 | + display: flex; gap: 16px; margin-top: 8px; flex-wrap: wrap; | |
| 1889 | +} | |
| 1890 | +.ws-meta-item { | |
| 1891 | + font-size: 11px; color: var(--text-muted); font-weight: 600; | |
| 1892 | + padding: 2px 8px; background: var(--bg-surface); border-radius: 6px; | |
| 1893 | + border: 1px solid var(--border-primary); | |
| 1894 | +} | |
| 1895 | +.ws-content-area { flex: 1; overflow: auto; position: relative; } | |
| 1896 | +.ws-editor { | |
| 1897 | + width: 100%; height: 100%; border: none; resize: none; padding: 12px 16px; | |
| 1898 | + font-family: 'JetBrains Mono', 'Menlo', monospace; font-size: 12px; | |
| 1899 | + line-height: 1.7; color: var(--text-primary); background: var(--bg-primary); | |
| 1900 | + outline: none; tab-size: 2; | |
| 1901 | +} | |
| 1902 | +.ws-preview-code { | |
| 1903 | + font-family: 'JetBrains Mono', 'Menlo', monospace; font-size: 12px; | |
| 1904 | + line-height: 1.7; color: var(--text-secondary); white-space: pre-wrap; | |
| 1905 | + word-break: break-all; margin: 0; padding: 12px 16px; | |
| 1906 | +} | |
| 1907 | +.ws-empty-state { | |
| 1908 | + flex: 1; display: flex; flex-direction: column; align-items: center; | |
| 1909 | + justify-content: center; color: var(--text-muted); text-align: center; padding: 40px; | |
| 1910 | +} | |
| 1911 | +.ws-empty-state h3 { font-size: 16px; margin: 0 0 4px; color: var(--text-secondary); } | |
| 1912 | +.ws-empty-state p { font-size: 13px; margin: 0; opacity: 0.7; } | |
| 1913 | + | |
| 1914 | +/* View Mode Tabs */ | |
| 1915 | +.ws-view-tabs { | |
| 1916 | + display: flex; gap: 2px; margin-top: 10px; | |
| 1917 | + background: var(--bg-surface); border-radius: 8px; padding: 3px; | |
| 1918 | + border: 1px solid var(--border-primary); | |
| 1919 | +} | |
| 1920 | +.ws-view-tab { | |
| 1921 | + padding: 5px 14px; border-radius: 6px; font-size: 12px; font-weight: 700; | |
| 1922 | + border: none; background: transparent; color: var(--text-muted); | |
| 1923 | + cursor: pointer; transition: all 0.2s; flex: 1; text-align: center; | |
| 1924 | +} | |
| 1925 | +.ws-view-tab:hover { color: var(--text-secondary); background: var(--bg-surface-hover); } | |
| 1926 | +.ws-view-tab.active { | |
| 1927 | + background: var(--accent-bg, rgba(59,130,246,0.12)); color: var(--accent); | |
| 1928 | + box-shadow: 0 1px 4px rgba(0,0,0,0.08); | |
| 1929 | +} | |
| 1930 | + | |
| 1931 | +/* HTML Preview iframe */ | |
| 1932 | +.ws-html-preview { | |
| 1933 | + width: 100%; height: 100%; border: none; | |
| 1934 | + background: #fff; border-radius: 0; | |
| 1935 | +} | |
| 1936 | + | |
| 1937 | +.refresh-btn { | |
| 1938 | + background: none; border: none; cursor: pointer; font-size: 13px; | |
| 1939 | + padding: 2px 4px; border-radius: 4px; transition: all 0.2s; | |
| 1940 | +} | |
| 1941 | +.refresh-btn:hover { background: var(--bg-surface-hover); transform: scale(1.1); } | |
| 1942 | + | |
| 1681 | 1943 | /* ═══ Messages Tab ═══ */ |
| 1682 | 1944 | .msg-controls { |
| 1683 | 1945 | display: flex; gap: 8px; align-items: center; | ... | ... |