diff --git a/server/routes/company.js b/server/routes/company.js index 068ab20..e33ba60 100644 --- a/server/routes/company.js +++ b/server/routes/company.js @@ -1431,6 +1431,138 @@ router.get('/:companyId/files/content', (req, res) => { } }); +// PUT /api/company/:companyId/files/content — 保存文件内容 +router.put('/:companyId/files/content', (req, res) => { + try { + const DATA_DIR = path.resolve(__dirname, '../../data/companies'); + const filePath = req.body.path; + if (!filePath) return res.status(400).json({ error: 'path 参数为必填' }); + + const absPath = path.resolve(DATA_DIR, req.params.companyId, filePath); + const safeBoundary = path.resolve(DATA_DIR, req.params.companyId); + if (!absPath.startsWith(safeBoundary)) { + return res.status(403).json({ error: '路径越界' }); + } + + const dir = path.dirname(absPath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + fs.writeFileSync(absPath, req.body.content || '', 'utf-8'); + console.log(`[Company] 💾 已保存文件 ${req.params.companyId}/${filePath}`); + res.json({ success: true, path: filePath }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ═══════════════════════════════════════ +// Company Workspace — 工作区文件管理 +// ═══════════════════════════════════════ + +// GET /api/company/:companyId/workspace/files — 列出 workspace 文件树 +router.get('/:companyId/workspace/files', (req, res) => { + try { + const DATA_DIR = path.resolve(__dirname, '../../data/companies'); + const wsDir = path.join(DATA_DIR, req.params.companyId, 'workspace'); + + if (!fs.existsSync(wsDir)) { + return res.json({ tree: [], totalFiles: 0 }); + } + + function scanDir(dirPath, relativePath = '') { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + const nodes = []; + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name; + if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; + + if (entry.isDirectory()) { + const children = scanDir(fullPath, relPath); + nodes.push({ + name: entry.name, path: relPath, type: 'directory', children, + fileCount: children.reduce((sum, c) => sum + (c.type === 'file' ? 1 : (c.fileCount || 0)), 0), + }); + } else { + const stat = fs.statSync(fullPath); + nodes.push({ + name: entry.name, path: relPath, type: 'file', + size: stat.size, + createdAt: stat.birthtime.toISOString(), + modifiedAt: stat.mtime.toISOString(), + }); + } + } + return nodes.sort((a, b) => { + if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; + return a.name.localeCompare(b.name); + }); + } + + const tree = scanDir(wsDir); + const countFiles = (nodes) => nodes.reduce((sum, n) => sum + (n.type === 'file' ? 1 : countFiles(n.children || [])), 0); + res.json({ tree, totalFiles: countFiles(tree) }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// GET /api/company/:companyId/workspace/files/content?path=xxx — 读取 workspace 文件内容 +router.get('/:companyId/workspace/files/content', (req, res) => { + try { + const DATA_DIR = path.resolve(__dirname, '../../data/companies'); + const filePath = String(req.query.path || ''); + if (!filePath) return res.status(400).json({ error: 'path 参数为必填' }); + + const absPath = path.resolve(DATA_DIR, req.params.companyId, 'workspace', filePath); + const safeBoundary = path.resolve(DATA_DIR, req.params.companyId, 'workspace'); + if (!absPath.startsWith(safeBoundary)) { + return res.status(403).json({ error: '路径越界' }); + } + if (!fs.existsSync(absPath)) { + return res.status(404).json({ error: '文件不存在' }); + } + + const stat = fs.statSync(absPath); + if (stat.size > 512 * 1024) { + return res.json({ content: '(文件过大,跳过预览)', truncated: true, size: stat.size }); + } + + const content = fs.readFileSync(absPath, 'utf-8'); + res.json({ + content, size: stat.size, path: filePath, + createdAt: stat.birthtime.toISOString(), + modifiedAt: stat.mtime.toISOString(), + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// PUT /api/company/:companyId/workspace/files/content — 保存 workspace 文件 +router.put('/:companyId/workspace/files/content', (req, res) => { + try { + const DATA_DIR = path.resolve(__dirname, '../../data/companies'); + const filePath = req.body.path; + if (!filePath) return res.status(400).json({ error: 'path 参数为必填' }); + + const absPath = path.resolve(DATA_DIR, req.params.companyId, 'workspace', filePath); + const safeBoundary = path.resolve(DATA_DIR, req.params.companyId, 'workspace'); + if (!absPath.startsWith(safeBoundary)) { + return res.status(403).json({ error: '路径越界' }); + } + + const dir = path.dirname(absPath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + fs.writeFileSync(absPath, req.body.content || '', 'utf-8'); + console.log(`[Workspace] 💾 已保存 ${req.params.companyId}/workspace/${filePath}`); + res.json({ success: true, path: filePath }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + // ═══════════════════════════════════════ // Company Messages — 全局消息时间线 // ═══════════════════════════════════════ diff --git a/web/src/components/AppNav.vue b/web/src/components/AppNav.vue index adb7529..8bf3962 100644 --- a/web/src/components/AppNav.vue +++ b/web/src/components/AppNav.vue @@ -5,21 +5,21 @@ LemonBoss diff --git a/web/src/router/index.js b/web/src/router/index.js index 7e00324..5d8e8ab 100644 --- a/web/src/router/index.js +++ b/web/src/router/index.js @@ -33,7 +33,7 @@ const routes = [ component: () => import('@/views/CompanyDashboard.vue'), }, { - path: '/:id/:tab(overview|agents|pipelines|goals|sop|okr|team|files|messages|skills|jobs)', + path: '/:id/:tab(overview|agents|pipelines|goals|sop|okr|team|files|messages|skills|jobs|workspace)', name: 'CompanyDetail', component: () => import('@/views/CompanyDashboard.vue'), props: true diff --git a/web/src/views/CompanyDashboard.vue b/web/src/views/CompanyDashboard.vue index 8e6881d..dfe37e8 100644 --- a/web/src/views/CompanyDashboard.vue +++ b/web/src/views/CompanyDashboard.vue @@ -445,6 +445,95 @@ + +
+
+ +
+
+ 💼 Workspace + {{ wsFiles?.totalFiles || 0 }} 文件 + +
+
+
+ +
+
+
+
📂
+
Workspace 为空
+
Agent 工作产出将出现在这里
+
+
+
+
+ +
+ +
+
💼
+

Workspace 文件浏览器

+

从左侧选择一个文件查看详情与内容

+
+
+
+
+
@@ -667,6 +756,19 @@ const selectedSkillDetail = ref(null) const companyJobs = ref([]) const jobStatusFilter = ref('') +// Workspace tab +const wsFiles = ref(null) +const wsSelectedPath = ref(null) +const wsSelectedFile = ref(null) +const wsContent = ref('') +const wsViewMode = ref('code') // 'code' | 'edit' | 'preview' +const wsSaving = ref(false) + +const wsIsHtml = computed(() => { + if (!wsSelectedFile.value?.name) return false + return /\.(html?|htm)$/i.test(wsSelectedFile.value.name) +}) + function statusLabel(status) { const map = { creating: '创建中', running: '运行中', paused: '已暂停', shutdown: '已关停' } return map[status] || status @@ -708,6 +810,7 @@ async function loadCompanyData(id, tab) { if (activeTab.value === 'messages') await loadMessages(id) if (activeTab.value === 'skills') await loadSkillsList(id) if (activeTab.value === 'jobs') await loadCompanyJobs(id) + if (activeTab.value === 'workspace') await loadWorkspaceFiles(id) } catch (e) { console.error(e) } } @@ -946,6 +1049,68 @@ function viewJobDetail(job) { alert(`任务详情:\nID: ${job.id}\n状态: ${job.status}\n创建: ${formatDate(job.createdAt)}`) } +// ═══ Workspace Tab Functions ═══ +async function loadWorkspaceFiles(id) { + try { + const { data } = await axios.get(`/api/company/${id}/workspace/files`) + wsFiles.value = data + } catch (e) { console.error(e) } +} + +async function selectWorkspaceFile(filePath) { + if (!selectedCompany.value) return + wsSelectedPath.value = filePath + wsViewMode.value = 'code' + try { + const { data } = await axios.get(`/api/company/${selectedCompany.value.id}/workspace/files/content`, { params: { path: filePath } }) + wsContent.value = data.content + wsSelectedFile.value = { + name: filePath.split('/').pop(), + path: filePath, + size: data.size, + createdAt: data.createdAt, + modifiedAt: data.modifiedAt, + } + // HTML 文件自动切到预览 + if (/\.(html?|htm)$/i.test(filePath)) { + wsViewMode.value = 'preview' + } + } catch { + wsContent.value = '(加载失败)' + wsSelectedFile.value = null + } +} + +async function saveWorkspaceFile() { + if (!selectedCompany.value || !wsSelectedPath.value) return + wsSaving.value = true + try { + await axios.put(`/api/company/${selectedCompany.value.id}/workspace/files/content`, { + path: wsSelectedPath.value, + content: wsContent.value, + }) + // Refresh file info + await selectWorkspaceFile(wsSelectedPath.value) + wsViewMode.value = 'code' + } catch (e) { + alert(`❌ 保存失败: ${e.message}`) + } + wsSaving.value = false +} + +function wsFileIcon(name) { + if (!name) return '📄' + const ext = name.split('.').pop()?.toLowerCase() + const icons = { + json: '📋', md: '📝', js: '⚡', ts: '🔷', py: '🐍', + html: '🌐', css: '🎨', sh: '⚙️', yml: '📐', yaml: '📐', + txt: '📄', log: '📜', csv: '📊', xml: '📰', + jpg: '🖼️', jpeg: '🖼️', png: '🖼️', gif: '🖼️', svg: '🖼️', + mp4: '🎬', mp3: '🎵', pdf: '📕', zip: '📦', + } + return icons[ext] || '📄' +} + // switchTab removed, handled by goToTab and router watcher // ═══ Files Tab Functions ═══ @@ -1678,6 +1843,103 @@ function closeDropdown() { flex-shrink: 0; } +/* ═══ Workspace Tab ═══ */ +.ws-layout { + display: flex; gap: 0; height: calc(100vh - 200px); + border: 1px solid var(--border-primary); border-radius: 12px; overflow: hidden; + background: var(--bg-surface); +} +.ws-tree-panel { + width: 300px; min-width: 240px; border-right: 1px solid var(--border-primary); + display: flex; flex-direction: column; background: var(--bg-overlay); +} +.ws-tree-scroll { flex: 1; overflow-y: auto; padding: 6px; } +.ws-detail-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; } +.ws-file-info { + padding: 12px 16px; border-bottom: 1px solid var(--border-primary); + background: var(--bg-overlay); +} +.ws-file-info-header { + display: flex; align-items: center; gap: 10px; +} +.ws-file-icon { font-size: 28px; flex-shrink: 0; } +.ws-file-name-area { flex: 1; min-width: 0; } +.ws-file-name { + font-size: 15px; font-weight: 700; color: var(--text-primary); + display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.ws-file-path { + font-size: 11px; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; +} +.ws-file-actions { display: flex; gap: 6px; flex-shrink: 0; } +.btn-ws-save { + padding: 5px 14px; border-radius: 8px; font-size: 12px; font-weight: 700; + border: 1px solid rgba(16,185,129,0.3); background: rgba(16,185,129,0.1); + color: #34d399; cursor: pointer; transition: all 0.2s; +} +.btn-ws-save:hover:not(:disabled) { background: rgba(16,185,129,0.2); box-shadow: 0 2px 8px rgba(16,185,129,0.15); } +.btn-ws-edit { + padding: 5px 14px; border-radius: 8px; font-size: 12px; font-weight: 700; + border: 1px solid var(--border-primary); background: var(--bg-surface); + color: var(--text-secondary); cursor: pointer; transition: all 0.2s; +} +.btn-ws-edit:hover { background: var(--bg-surface-hover); border-color: var(--border-active); } +.ws-file-meta { + display: flex; gap: 16px; margin-top: 8px; flex-wrap: wrap; +} +.ws-meta-item { + font-size: 11px; color: var(--text-muted); font-weight: 600; + padding: 2px 8px; background: var(--bg-surface); border-radius: 6px; + border: 1px solid var(--border-primary); +} +.ws-content-area { flex: 1; overflow: auto; position: relative; } +.ws-editor { + width: 100%; height: 100%; border: none; resize: none; padding: 12px 16px; + font-family: 'JetBrains Mono', 'Menlo', monospace; font-size: 12px; + line-height: 1.7; color: var(--text-primary); background: var(--bg-primary); + outline: none; tab-size: 2; +} +.ws-preview-code { + font-family: 'JetBrains Mono', 'Menlo', monospace; font-size: 12px; + line-height: 1.7; color: var(--text-secondary); white-space: pre-wrap; + word-break: break-all; margin: 0; padding: 12px 16px; +} +.ws-empty-state { + flex: 1; display: flex; flex-direction: column; align-items: center; + justify-content: center; color: var(--text-muted); text-align: center; padding: 40px; +} +.ws-empty-state h3 { font-size: 16px; margin: 0 0 4px; color: var(--text-secondary); } +.ws-empty-state p { font-size: 13px; margin: 0; opacity: 0.7; } + +/* View Mode Tabs */ +.ws-view-tabs { + display: flex; gap: 2px; margin-top: 10px; + background: var(--bg-surface); border-radius: 8px; padding: 3px; + border: 1px solid var(--border-primary); +} +.ws-view-tab { + padding: 5px 14px; border-radius: 6px; font-size: 12px; font-weight: 700; + border: none; background: transparent; color: var(--text-muted); + cursor: pointer; transition: all 0.2s; flex: 1; text-align: center; +} +.ws-view-tab:hover { color: var(--text-secondary); background: var(--bg-surface-hover); } +.ws-view-tab.active { + background: var(--accent-bg, rgba(59,130,246,0.12)); color: var(--accent); + box-shadow: 0 1px 4px rgba(0,0,0,0.08); +} + +/* HTML Preview iframe */ +.ws-html-preview { + width: 100%; height: 100%; border: none; + background: #fff; border-radius: 0; +} + +.refresh-btn { + background: none; border: none; cursor: pointer; font-size: 13px; + padding: 2px 4px; border-radius: 4px; transition: all 0.2s; +} +.refresh-btn:hover { background: var(--bg-surface-hover); transform: scale(1.1); } + /* ═══ Messages Tab ═══ */ .msg-controls { display: flex; gap: 8px; align-items: center;