feat: CEO plan preview & confirm before company execution
- Add POST /companies/{id}/plan endpoint for stateless CEO plan preview (no DB write)
- execute()/execute_stream() accept optional confirmed ceo_plan to skip re-planning
- Frontend: plan preview dialog in CompanyBuilder to review/tweak analysis, dept goals & deliverables before running; previewCompanyPlan API with extended timeout
- Grant file_write tool to CEO/CTO presets; add echarts dependency
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
"@vue-flow/core": "^1.30.0",
|
||||
"@vue-flow/minimap": "^1.1.0",
|
||||
"axios": "^1.6.2",
|
||||
"echarts": "^6.1.0",
|
||||
"element-plus": "^2.4.4",
|
||||
"marked": "^18.0.5",
|
||||
"monaco-editor": "^0.44.0",
|
||||
|
||||
23
frontend/pnpm-lock.yaml
generated
23
frontend/pnpm-lock.yaml
generated
@@ -26,6 +26,9 @@ importers:
|
||||
axios:
|
||||
specifier: ^1.6.2
|
||||
version: 1.14.0
|
||||
echarts:
|
||||
specifier: ^6.1.0
|
||||
version: 6.1.0
|
||||
element-plus:
|
||||
specifier: ^2.4.4
|
||||
version: 2.13.6(typescript@5.3.3)(vue@3.5.32(typescript@5.3.3))
|
||||
@@ -838,6 +841,9 @@ packages:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
echarts@6.1.0:
|
||||
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
|
||||
|
||||
element-plus@2.13.6:
|
||||
resolution: {integrity: sha512-XHgwXr8Fjz6i+6BaqFhAbae/dJbG7bBAAlHrY3pWL7dpj+JcqcOyKYt4Oy5KP86FQwS1k4uIZDjCx2FyUR5lDg==}
|
||||
peerDependencies:
|
||||
@@ -1400,6 +1406,9 @@ packages:
|
||||
peerDependencies:
|
||||
typescript: '>=4.2.0'
|
||||
|
||||
tslib@2.3.0:
|
||||
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -1547,6 +1556,9 @@ packages:
|
||||
yup@1.7.1:
|
||||
resolution: {integrity: sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==}
|
||||
|
||||
zrender@6.1.0:
|
||||
resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@babel/helper-string-parser@7.27.1': {}
|
||||
@@ -2233,6 +2245,11 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
echarts@6.1.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
zrender: 6.1.0
|
||||
|
||||
element-plus@2.13.6(typescript@5.3.3)(vue@3.5.32(typescript@5.3.3)):
|
||||
dependencies:
|
||||
'@ctrl/tinycolor': 4.2.0
|
||||
@@ -2841,6 +2858,8 @@ snapshots:
|
||||
dependencies:
|
||||
typescript: 5.3.3
|
||||
|
||||
tslib@2.3.0: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
@@ -2944,3 +2963,7 @@ snapshots:
|
||||
tiny-case: 1.0.3
|
||||
toposort: 2.0.2
|
||||
type-fest: 2.19.0
|
||||
|
||||
zrender@6.1.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
|
||||
@@ -76,6 +76,16 @@ export async function executeCompany(companyId: string, projectDescription: stri
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function previewCompanyPlan(companyId: string, projectDescription: string): Promise<any> {
|
||||
// CEO 规划是一次 LLM 调用,通常几十秒,需覆盖 axios 默认 30s 超时
|
||||
const resp = await api.post(
|
||||
`/api/v1/companies/${companyId}/plan`,
|
||||
{ project_description: projectDescription },
|
||||
{ timeout: 300000 },
|
||||
)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function addDepartment(companyId: string, teamId: string): Promise<any> {
|
||||
const resp = await api.post(`/api/v1/companies/${companyId}/departments`, { team_id: teamId })
|
||||
return resp.data
|
||||
|
||||
@@ -175,33 +175,65 @@
|
||||
<div class="execute-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="executing"
|
||||
:disabled="!projectGoal.trim() || !selectedCompanyDetail?.departments?.length"
|
||||
@click="handleExecute"
|
||||
:loading="planning || streaming"
|
||||
:disabled="executing || !projectGoal.trim() || !selectedCompanyDetail?.departments?.length"
|
||||
@click="openPlanPreview"
|
||||
>
|
||||
<el-icon><VideoPlay /></el-icon> CEO 启动执行
|
||||
<el-icon><VideoPlay /></el-icon> 启动执行(实时白盒)
|
||||
</el-button>
|
||||
<el-button
|
||||
:loading="executing"
|
||||
:disabled="!projectGoal.trim() || !selectedCompanyDetail?.departments?.length"
|
||||
@click="handleExecuteStream"
|
||||
v-if="streaming"
|
||||
type="danger"
|
||||
plain
|
||||
@click="stopExecution"
|
||||
>
|
||||
<el-icon><Connection /></el-icon> 流式执行
|
||||
<el-icon><CircleClose /></el-icon> 停止
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="streaming && !showLiveMonitor"
|
||||
type="primary"
|
||||
plain
|
||||
@click="showLiveMonitor = true"
|
||||
>
|
||||
<el-icon><Monitor /></el-icon> 实时监控
|
||||
</el-button>
|
||||
<el-button
|
||||
:loading="executing && !streaming"
|
||||
:disabled="executing || !projectGoal.trim() || !selectedCompanyDetail?.departments?.length"
|
||||
@click="handleExecute"
|
||||
>
|
||||
<el-icon><Connection /></el-icon> 后台执行
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Round Indicator -->
|
||||
<div v-if="currentRound > 0" class="round-indicator">
|
||||
<el-steps :active="currentRound - 1" align-center finish-status="success">
|
||||
<el-step
|
||||
v-for="r in maxRounds"
|
||||
:key="r"
|
||||
:title="`第 ${r} 轮`"
|
||||
:description="roundHistory[r-1]?.done ? (roundHistory[r-1].rework_depts.length ? `返工: ${roundHistory[r-1].rework_depts.join(',')}` : '全部通过') : (r === currentRound ? '执行中...' : '')"
|
||||
<!-- ========== 实时执行监控(自动弹出)========== -->
|
||||
<el-dialog
|
||||
v-model="showLiveMonitor"
|
||||
:title="'实时执行监控' + (selectedCompany ? ' · ' + selectedCompany.name : '')"
|
||||
width="92vw"
|
||||
top="3vh"
|
||||
:close-on-click-modal="false"
|
||||
append-to-body
|
||||
class="live-monitor-dialog"
|
||||
>
|
||||
<div class="live-monitor-body">
|
||||
<el-empty
|
||||
v-if="timeline.length === 0 && Object.keys(deptPanels).length === 0"
|
||||
description="正在启动执行,等待首个事件…"
|
||||
:image-size="60"
|
||||
/>
|
||||
</el-steps>
|
||||
</div>
|
||||
<!-- Round Indicator -->
|
||||
<div v-if="currentRound > 0" class="round-indicator">
|
||||
<el-steps :active="currentRound - 1" align-center finish-status="success">
|
||||
<el-step
|
||||
v-for="r in maxRounds"
|
||||
:key="r"
|
||||
:title="`第 ${r} 轮`"
|
||||
:description="roundHistory[r-1]?.done ? (roundHistory[r-1].rework_depts.length ? `返工: ${roundHistory[r-1].rework_depts.join(',')}` : '全部通过') : (r === currentRound ? '执行中...' : '')"
|
||||
/>
|
||||
</el-steps>
|
||||
</div>
|
||||
|
||||
<!-- Execution Timeline -->
|
||||
<div v-if="timeline.length > 0" class="exec-timeline">
|
||||
@@ -342,6 +374,14 @@
|
||||
</el-collapse>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button v-if="streaming" type="danger" plain @click="stopExecution">
|
||||
<el-icon><CircleClose /></el-icon> 停止执行
|
||||
</el-button>
|
||||
<el-button @click="showLiveMonitor = false">关闭窗口</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Focus Dialog -->
|
||||
<el-dialog v-model="showFocusDialog" :title="(focusPanel?.name || '') + ' — 详细监控'" width="90vw" top="3vh" @close="closeFocusDialog">
|
||||
@@ -427,7 +467,21 @@
|
||||
:key="d.department_name"
|
||||
:title="`${d.department_name} — ${d.success ? '✓' : '✗'}`"
|
||||
>
|
||||
<pre class="result-json">{{ JSON.stringify(d.result || d, null, 2) }}</pre>
|
||||
<div v-if="deptDeliverable(d)" class="dept-deliverable" v-html="renderMarkdown(deptDeliverable(d))"></div>
|
||||
<div v-if="deptFiles(d).length" class="dept-files">
|
||||
<div class="dept-files-title">📎 产物文件 ({{ deptFiles(d).length }})</div>
|
||||
<div v-for="(f, i) in deptFiles(d)" :key="i" class="dept-file-item">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span class="dept-file-name">{{ fileBaseName(f) }}</span>
|
||||
<span class="dept-file-path">{{ f }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-if="!deptDeliverable(d) && !deptFiles(d).length" description="无交付内容" :image-size="40" />
|
||||
<el-collapse class="dept-raw-collapse">
|
||||
<el-collapse-item title="原始 JSON 详情">
|
||||
<pre class="result-json">{{ JSON.stringify(d.result || d, null, 2) }}</pre>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
@@ -464,7 +518,7 @@
|
||||
|
||||
<!-- Right: Tabs (Projects / Knowledge / Insights / Schedules) -->
|
||||
<div class="right-panel">
|
||||
<el-tabs v-model="detailRightTab" type="border-card" class="right-tabs">
|
||||
<el-tabs v-model="detailRightTab" type="border-card" class="right-tabs" @tab-change="onRightTabChange">
|
||||
<!-- Projects Tab -->
|
||||
<el-tab-pane label="项目历史" name="projects">
|
||||
<el-table
|
||||
@@ -558,16 +612,23 @@
|
||||
<el-statistic title="成功率" :value="insights.success_rate || 0" suffix="%" />
|
||||
<el-statistic title="平均轮次" :value="insights.avg_rounds || 0" :precision="1" />
|
||||
</div>
|
||||
<div class="insight-dept-table" v-if="insights.dept_stats?.length">
|
||||
<h4>部门效率</h4>
|
||||
<el-table :data="insights.dept_stats" size="small" max-height="160">
|
||||
<el-table-column prop="name" label="部门" width="80" />
|
||||
<el-table-column prop="execution_count" label="执行次数" width="70" />
|
||||
<el-table-column prop="avg_score" label="平均分" width="70" />
|
||||
<el-table-column prop="success_rate" label="成功率" width="80">
|
||||
<template #default="{ row }">{{ row.success_rate }}%</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="insight-chart" v-if="insights.dept_stats?.length">
|
||||
<h4>部门表现</h4>
|
||||
<EChart :option="deptChartOption" height="240px" />
|
||||
</div>
|
||||
<div class="insight-chart" v-if="insights.collaboration_pairs?.length">
|
||||
<h4>部门协作关系</h4>
|
||||
<EChart :option="collabChartOption" height="260px" />
|
||||
</div>
|
||||
<div class="insight-failures" v-if="insights.failure_reasons?.length">
|
||||
<h4>失败归因 ({{ insights.failure_reasons.length }})</h4>
|
||||
<el-card v-for="(f, i) in insights.failure_reasons" :key="i" class="failure-card" shadow="never">
|
||||
<div class="failure-head">
|
||||
<span class="failure-proj">{{ f.project }}</span>
|
||||
<el-tag type="danger" size="small">{{ f.department }} · {{ f.score }}分</el-tag>
|
||||
</div>
|
||||
<div class="failure-feedback">{{ f.feedback }}</div>
|
||||
</el-card>
|
||||
</div>
|
||||
<div class="insight-recs" v-if="insights.recommendations?.length">
|
||||
<h4>改进建议</h4>
|
||||
@@ -607,6 +668,55 @@
|
||||
</div>
|
||||
<el-empty v-else description="暂无定时任务" :image-size="48" />
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Supervisor Tab -->
|
||||
<el-tab-pane label="监管" name="supervisor">
|
||||
<div class="supervisor-toolbar">
|
||||
<el-button size="small" type="primary" :loading="loadingSupervisor" @click="handleSupervisorScan"><el-icon><Refresh /></el-icon> 一键扫描</el-button>
|
||||
<el-button size="small" :loading="loadingSupervisor" @click="loadSupervisor">刷新</el-button>
|
||||
<el-checkbox v-model="supervisorAllCompanies" size="small" @change="loadSupervisor">全部公司</el-checkbox>
|
||||
<span v-if="supervisorScanAt" class="supervisor-scanat">扫描于 {{ formatDate(supervisorScanAt) }}</span>
|
||||
</div>
|
||||
<div class="supervisor-hint">提示:长时间流式任务不会刷新"更新时间",可能被误判为风险/僵尸,可扫描后再核对。</div>
|
||||
|
||||
<div class="supervisor-section">
|
||||
<div class="panel-title">进行中 · 风险 ({{ filteredInProgress.length }})</div>
|
||||
<div class="supervisor-list" v-if="filteredInProgress.length">
|
||||
<el-card v-for="p in filteredInProgress" :key="p.project_id" :class="['sv-card', { 'sv-at-risk': p.is_at_risk }]" shadow="never">
|
||||
<div class="sv-row">
|
||||
<span class="sv-name">{{ p.name }}</span>
|
||||
<el-tag :type="p.is_at_risk ? 'danger' : 'warning'" size="small">{{ p.is_at_risk ? '高风险' : '进行中' }}</el-tag>
|
||||
</div>
|
||||
<div class="sv-meta">空闲 {{ p.idle_minutes }} 分钟</div>
|
||||
<div class="sv-countdown">
|
||||
<span class="sv-countdown-label">距判定僵尸</span>
|
||||
<el-progress
|
||||
:percentage="Math.max(0, Math.min(100, Math.round((1 - (p.will_become_zombie_in_minutes || 0) / 15) * 100)))"
|
||||
:status="p.is_at_risk ? 'exception' : 'warning'"
|
||||
:stroke-width="6"
|
||||
:show-text="false"
|
||||
/>
|
||||
<span class="sv-countdown-val">{{ Math.round(p.will_become_zombie_in_minutes || 0) }} 分钟</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
<el-empty v-else description="无进行中项目" :image-size="40" />
|
||||
</div>
|
||||
|
||||
<div class="supervisor-section">
|
||||
<div class="panel-title">僵尸项目 ({{ filteredZombies.length }})</div>
|
||||
<div class="supervisor-list" v-if="filteredZombies.length">
|
||||
<el-card v-for="z in filteredZombies" :key="z.project_id" class="sv-card sv-zombie" shadow="never">
|
||||
<div class="sv-row">
|
||||
<span class="sv-name">{{ z.name }}</span>
|
||||
<el-button size="small" type="danger" text @click="handleRecoverZombie(z)">恢复</el-button>
|
||||
</div>
|
||||
<div class="sv-meta">空闲 {{ z.idle_minutes }} 分钟 · 最后更新 {{ formatDate(z.last_updated) }}</div>
|
||||
</el-card>
|
||||
</div>
|
||||
<el-empty v-else description="无僵尸项目 🎉" :image-size="40" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
@@ -771,22 +881,91 @@
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="planPreviewVisible" title="CEO 执行计划预览" width="70vw" top="5vh">
|
||||
<div v-if="planDraft" class="plan-preview">
|
||||
<div class="plan-meta">
|
||||
<span class="plan-company">{{ planCompanyName }}</span>
|
||||
<el-tag size="small" type="info">{{ (planDraft.departments || []).length }} 个部门</el-tag>
|
||||
<span class="plan-hint">可微调下方「战略分析 / 各部门目标 / 交付物」后再执行;部门与依赖关系保持 CEO 原样。</span>
|
||||
</div>
|
||||
|
||||
<div class="plan-section">
|
||||
<div class="plan-label">战略分析(analysis)</div>
|
||||
<el-input v-model="planDraft.analysis" type="textarea" :autosize="{ minRows: 2, maxRows: 6 }" placeholder="CEO 对目标的总体分析" />
|
||||
</div>
|
||||
|
||||
<div class="plan-section" v-if="planDraft.overall_timeline || (planDraft.key_success_metrics || []).length || (planDraft.risks || []).length">
|
||||
<div class="plan-readonly-grid">
|
||||
<div v-if="planDraft.overall_timeline">
|
||||
<div class="plan-label">总体时间线</div>
|
||||
<div class="plan-readonly-text">{{ planDraft.overall_timeline }}</div>
|
||||
</div>
|
||||
<div v-if="(planDraft.key_success_metrics || []).length">
|
||||
<div class="plan-label">成功指标</div>
|
||||
<ul class="plan-ul">
|
||||
<li v-for="(m, i) in planDraft.key_success_metrics" :key="i">{{ m }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="(planDraft.risks || []).length">
|
||||
<div class="plan-label">风险</div>
|
||||
<ul class="plan-ul">
|
||||
<li v-for="(r, i) in planDraft.risks" :key="i">{{ r }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="plan-label" style="margin-top: 12px;">部门任务拆分</div>
|
||||
<div class="plan-dept-card" v-for="(dept, di) in planDraft.departments" :key="di">
|
||||
<div class="plan-dept-head">
|
||||
<span class="plan-dept-name">{{ dept.department_name || dept.department_type }}</span>
|
||||
<el-tag v-if="dept.department_type" size="small" type="info">{{ dept.department_type }}</el-tag>
|
||||
<el-tag v-if="dept.priority" size="small" :type="dept.priority === 'high' ? 'danger' : (dept.priority === 'low' ? 'info' : 'warning')">优先级 {{ dept.priority }}</el-tag>
|
||||
<span v-if="(dept.dependencies || []).length" class="plan-dep">
|
||||
依赖:<el-tag v-for="(dep, dpi) in dept.dependencies" :key="dpi" size="small" effect="plain" style="margin-right:4px;">{{ dep }}</el-tag>
|
||||
</span>
|
||||
</div>
|
||||
<div class="plan-dept-field">
|
||||
<div class="plan-sub-label">目标(goal)</div>
|
||||
<el-input v-model="dept.goal" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" placeholder="部门目标" />
|
||||
</div>
|
||||
<div class="plan-dept-field">
|
||||
<div class="plan-sub-label">交付物(每行一项)</div>
|
||||
<el-input v-model="dept._deliverablesText" type="textarea" :autosize="{ minRows: 1, maxRows: 6 }" placeholder="每行一个交付物" />
|
||||
</div>
|
||||
<div class="plan-dept-field" v-if="(dept.key_questions || []).length">
|
||||
<div class="plan-sub-label">关键问题</div>
|
||||
<ul class="plan-ul">
|
||||
<li v-for="(q, qi) in dept.key_questions" :key="qi">{{ q }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="planPreviewVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="confirmPlanAndExecute">
|
||||
<el-icon><VideoPlay /></el-icon> 确认并执行
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</MainLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import MainLayout from '@/components/MainLayout.vue'
|
||||
import EChart from '@/components/EChart.vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, ArrowLeft, VideoPlay, Connection, Edit, Delete, Download, Refresh, Loading } from '@element-plus/icons-vue'
|
||||
import { Plus, ArrowLeft, VideoPlay, Connection, Edit, Delete, Download, Refresh, Loading, CircleClose, Document, Monitor } from '@element-plus/icons-vue'
|
||||
import {
|
||||
listCompanies, getCompany, createCompanyFromPreset, createCompany, deleteCompany, updateCompany,
|
||||
executeCompany, listProjects, addDepartment, exportCompany, getCompanyStats,
|
||||
executeCompany, previewCompanyPlan, listProjects, addDepartment, exportCompany, getCompanyStats,
|
||||
listSchedules, createSchedule, updateSchedule, deleteSchedule, triggerSchedule,
|
||||
listKnowledge, generateKnowledge, deleteKnowledge,
|
||||
getCompanyInsights, listTemplates, publishProject, unpublishProject,
|
||||
recoverProject,
|
||||
recoverProject, getZombieProjects, getInProgressProjects, triggerSupervisorScan,
|
||||
} from '@/api/companies'
|
||||
import type { CompanySchedule, CompanyKnowledge, CompanyInsights, ProjectTemplate } from '@/api/companies'
|
||||
import { removeMember, addMember, listTeams } from '@/api/teams'
|
||||
@@ -822,6 +1001,15 @@ const executing = ref(false)
|
||||
const projectGoal = ref('')
|
||||
const executionResult = ref<any>(null)
|
||||
const streamEvents = ref<any[]>([])
|
||||
const streaming = ref(false)
|
||||
const streamController = ref<AbortController | null>(null)
|
||||
// —— 执行前 CEO 计划预览 ——
|
||||
const planning = ref(false)
|
||||
const planPreviewVisible = ref(false)
|
||||
const planCompanyName = ref('')
|
||||
const planDraft = ref<any>(null)
|
||||
// —— 实时执行监控窗口 ——
|
||||
const showLiveMonitor = ref(false)
|
||||
const activeTab = ref('departments')
|
||||
const loadingCompanies = ref(false)
|
||||
const companyStats = ref<Record<string, any>>({})
|
||||
@@ -850,6 +1038,13 @@ const loadingInsights = ref(false)
|
||||
const loadingSchedules = ref(false)
|
||||
const detailRightTab = ref('projects')
|
||||
|
||||
// ─── Supervisor ───
|
||||
const zombies = ref<any[]>([])
|
||||
const inProgress = ref<any[]>([])
|
||||
const loadingSupervisor = ref(false)
|
||||
const supervisorScanAt = ref<string | null>(null)
|
||||
const supervisorAllCompanies = ref(false)
|
||||
|
||||
// ─── Drag-and-drop ───
|
||||
const dragOverDept = ref<string | null>(null)
|
||||
const draggingMemberId = ref<string | null>(null)
|
||||
@@ -1250,45 +1445,127 @@ async function handleExecute() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExecuteStream() {
|
||||
async function openPlanPreview() {
|
||||
if (!selectedCompany.value) return
|
||||
if (!projectGoal.value.trim()) {
|
||||
ElMessage.warning('请先填写项目目标')
|
||||
return
|
||||
}
|
||||
if (!selectedCompanyDetail.value?.departments?.length) {
|
||||
ElMessage.warning('公司没有部门,请先添加部门')
|
||||
return
|
||||
}
|
||||
planning.value = true
|
||||
const loadingMsg = ElMessage({ message: 'CEO 正在规划,请稍候(约需 30–60 秒)…', type: 'info', duration: 0 })
|
||||
try {
|
||||
const resp = await previewCompanyPlan(selectedCompany.value.id, projectGoal.value)
|
||||
const plan = resp?.ceo_plan || {}
|
||||
// 深拷贝,编辑不影响原始返回;deliverables 数组转多行文本便于编辑
|
||||
planDraft.value = JSON.parse(JSON.stringify(plan))
|
||||
if (Array.isArray(planDraft.value.departments)) {
|
||||
planDraft.value.departments.forEach((d: any) => {
|
||||
d._deliverablesText = Array.isArray(d.deliverables) ? d.deliverables.join('\n') : (d.deliverables || '')
|
||||
})
|
||||
}
|
||||
planCompanyName.value = resp?.company_name || selectedCompany.value.name || ''
|
||||
planPreviewVisible.value = true
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.detail || e?.message || '生成计划失败')
|
||||
} finally {
|
||||
loadingMsg.close()
|
||||
planning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmPlanAndExecute() {
|
||||
const plan = planDraft.value ? JSON.parse(JSON.stringify(planDraft.value)) : null
|
||||
if (plan && Array.isArray(plan.departments)) {
|
||||
plan.departments.forEach((d: any) => {
|
||||
// 多行文本回写为 deliverables 数组(去空行)
|
||||
if (typeof d._deliverablesText === 'string') {
|
||||
d.deliverables = d._deliverablesText.split('\n').map((s: string) => s.trim()).filter(Boolean)
|
||||
}
|
||||
delete d._deliverablesText
|
||||
})
|
||||
}
|
||||
planPreviewVisible.value = false
|
||||
handleExecuteStream(plan)
|
||||
}
|
||||
|
||||
async function handleExecuteStream(approvedPlan?: any) {
|
||||
if (!selectedCompany.value) return
|
||||
executing.value = true
|
||||
streaming.value = true
|
||||
showLiveMonitor.value = true
|
||||
streamEvents.value = []
|
||||
deptPanels.value = {}
|
||||
timeline.value = []
|
||||
executionResult.value = null
|
||||
ceoScores.value = []
|
||||
budgetMap.value = {}
|
||||
currentRound.value = 0
|
||||
roundHistory.value = []
|
||||
|
||||
const token = localStorage.getItem('access_token') || ''
|
||||
const response = await fetch(`/api/v1/companies/${selectedCompany.value.id}/execute/stream`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
||||
body: JSON.stringify({ project_description: projectGoal.value }),
|
||||
})
|
||||
const controller = new AbortController()
|
||||
streamController.value = controller
|
||||
try {
|
||||
// 认证:登录只写入 localStorage['token'],access_token 可能为空 → 回退到 token(同 AgentChat 流式做法)
|
||||
const token = localStorage.getItem('access_token') || localStorage.getItem('token') || ''
|
||||
const payload: any = { project_description: projectGoal.value }
|
||||
if (approvedPlan) payload.ceo_plan = approvedPlan
|
||||
const response = await fetch(`/api/v1/companies/${selectedCompany.value.id}/execute/stream`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) { executing.value = false; return }
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue
|
||||
try {
|
||||
const evt = JSON.parse(line.slice(6))
|
||||
evt._time = new Date().toLocaleTimeString()
|
||||
streamEvents.value.push(evt)
|
||||
routeStreamEvent(evt)
|
||||
} catch { /* ignore */ }
|
||||
if (!response.ok) {
|
||||
let detail = `HTTP ${response.status}`
|
||||
try { const j = await response.json(); detail = j?.detail || j?.message || detail } catch { /* keep */ }
|
||||
throw new Error(`流式执行请求失败:${detail}`)
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) return
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue
|
||||
try {
|
||||
const evt = JSON.parse(line.slice(6))
|
||||
evt._time = new Date().toLocaleTimeString()
|
||||
streamEvents.value.push(evt)
|
||||
routeStreamEvent(evt)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'AbortError') {
|
||||
timeline.value.push({ type: 'error', label: '已手动停止' })
|
||||
ElMessage.info('已停止执行')
|
||||
} else {
|
||||
ElMessage.error(e?.message || '流式执行失败')
|
||||
timeline.value.push({ type: 'error', label: '执行失败' })
|
||||
}
|
||||
} finally {
|
||||
executing.value = false
|
||||
streaming.value = false
|
||||
streamController.value = null
|
||||
await loadProjects()
|
||||
}
|
||||
executing.value = false
|
||||
await loadProjects()
|
||||
}
|
||||
|
||||
function stopExecution() {
|
||||
streamController.value?.abort()
|
||||
}
|
||||
|
||||
function routeStreamEvent(evt: any) {
|
||||
@@ -1728,6 +2005,128 @@ async function loadInsights() {
|
||||
finally { loadingInsights.value = false }
|
||||
}
|
||||
|
||||
const deptChartOption = computed(() => {
|
||||
const stats = insights.value?.dept_stats || []
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['成功率(%)', '平均分'], top: 0 },
|
||||
grid: { left: 36, right: 12, top: 30, bottom: 40 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: stats.map((s: any) => s.name),
|
||||
axisLabel: { interval: 0, rotate: stats.length > 4 ? 30 : 0, fontSize: 11 },
|
||||
},
|
||||
yAxis: { type: 'value' },
|
||||
series: [
|
||||
{ name: '成功率(%)', type: 'bar', data: stats.map((s: any) => s.success_rate), itemStyle: { color: '#67c23a' } },
|
||||
{ name: '平均分', type: 'bar', data: stats.map((s: any) => s.avg_score), itemStyle: { color: '#409eff' } },
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const collabChartOption = computed(() => {
|
||||
const pairs = insights.value?.collaboration_pairs || []
|
||||
const nodeSet = new Set<string>()
|
||||
pairs.forEach((p: any) => {
|
||||
const [a, b] = (p.pair || '').split('→')
|
||||
if (a) nodeSet.add(a.trim())
|
||||
if (b) nodeSet.add(b.trim())
|
||||
})
|
||||
const nodes = Array.from(nodeSet).map(n => ({ name: n, symbolSize: 42, itemStyle: { color: '#409eff' } }))
|
||||
const maxCount = Math.max(...pairs.map((p: any) => p.count || 1), 1)
|
||||
const links = pairs.map((p: any) => {
|
||||
const [a, b] = (p.pair || '').split('→')
|
||||
return {
|
||||
source: (a || '').trim(),
|
||||
target: (b || '').trim(),
|
||||
value: p.count,
|
||||
lineStyle: { width: 1 + (p.count / maxCount) * 5, curveness: 0.15 },
|
||||
}
|
||||
})
|
||||
return {
|
||||
tooltip: {},
|
||||
series: [{
|
||||
type: 'graph',
|
||||
layout: 'circular',
|
||||
circular: { rotateLabel: true },
|
||||
roam: true,
|
||||
label: { show: true, position: 'right', fontSize: 11 },
|
||||
edgeSymbol: ['none', 'arrow'],
|
||||
edgeSymbolSize: 8,
|
||||
data: nodes,
|
||||
links,
|
||||
lineStyle: { color: '#c0c4cc', opacity: 0.9 },
|
||||
}],
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Supervisor ───
|
||||
async function loadSupervisor() {
|
||||
loadingSupervisor.value = true
|
||||
try {
|
||||
const [z, ip] = await Promise.all([getZombieProjects(), getInProgressProjects()])
|
||||
zombies.value = z.projects || []
|
||||
inProgress.value = ip.projects || []
|
||||
supervisorScanAt.value = z.scanned_at || null
|
||||
} catch { /* ignore */ }
|
||||
finally { loadingSupervisor.value = false }
|
||||
}
|
||||
|
||||
async function handleSupervisorScan() {
|
||||
loadingSupervisor.value = true
|
||||
try {
|
||||
const r = await triggerSupervisorScan()
|
||||
ElMessage.success(`扫描完成:发现 ${r.zombie_count ?? 0} 个僵尸项目`)
|
||||
await loadSupervisor()
|
||||
await loadProjects()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.detail || '扫描失败')
|
||||
} finally {
|
||||
loadingSupervisor.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRecoverZombie(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认恢复僵尸项目「${row.name}」?恢复后将标记为"已失败",可重新执行。`,
|
||||
'恢复项目',
|
||||
{ type: 'warning', confirmButtonText: '确认恢复' }
|
||||
)
|
||||
await recoverProject(row.company_id, row.project_id)
|
||||
ElMessage.success('已恢复')
|
||||
await loadSupervisor()
|
||||
await loadProjects()
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel') ElMessage.error(e?.response?.data?.detail || '恢复失败')
|
||||
}
|
||||
}
|
||||
|
||||
const filteredZombies = computed(() =>
|
||||
supervisorAllCompanies.value ? zombies.value : zombies.value.filter(z => z.company_id === selectedCompany.value?.id)
|
||||
)
|
||||
const filteredInProgress = computed(() =>
|
||||
supervisorAllCompanies.value ? inProgress.value : inProgress.value.filter(p => p.company_id === selectedCompany.value?.id)
|
||||
)
|
||||
|
||||
function onRightTabChange(name: string) {
|
||||
if (name === 'supervisor') loadSupervisor()
|
||||
}
|
||||
|
||||
// ─── Department result helpers (readable output) ───
|
||||
function deptDeliverable(d: any): string {
|
||||
const r = d?.result
|
||||
return (r?.deliverable || r?.output || d?.deliverable || (typeof r === 'string' ? r : '') || '').toString()
|
||||
}
|
||||
function deptFiles(d: any): string[] {
|
||||
const raw = d?.result?.files || d?.files || []
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.map((f: any) => (typeof f === 'string' ? f : (f?.path || f?.name || JSON.stringify(f))))
|
||||
}
|
||||
function fileBaseName(f: string): string {
|
||||
return (f || '').split(/[\\/]/).pop() || f
|
||||
}
|
||||
|
||||
// ─── Template Marketplace ───
|
||||
async function handlePublishTemplate(project: CompanyProject) {
|
||||
if (!selectedCompany.value) return
|
||||
@@ -2977,3 +3376,240 @@ function formatDuration(ms: number) {
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
/* ── Readable department results ── */
|
||||
.dept-deliverable {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #303133;
|
||||
background: #fafafa;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
}
|
||||
.dept-files {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.dept-files-title {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.dept-file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
padding: 3px 0;
|
||||
}
|
||||
.dept-file-name {
|
||||
font-weight: 600;
|
||||
color: #409eff;
|
||||
}
|
||||
.dept-file-path {
|
||||
color: #c0c4cc;
|
||||
font-size: 11px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dept-raw-collapse {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* ── Insights charts ── */
|
||||
.insight-chart {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.insight-chart h4,
|
||||
.insight-failures h4 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
}
|
||||
.insight-failures {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.failure-card {
|
||||
margin-bottom: 8px;
|
||||
background: #fef0f0;
|
||||
}
|
||||
.failure-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.failure-proj {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
.failure-feedback {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Supervisor tab ── */
|
||||
.supervisor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.supervisor-scanat {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-left: auto;
|
||||
}
|
||||
.supervisor-hint {
|
||||
font-size: 12px;
|
||||
color: #e6a23c;
|
||||
background: #fdf6ec;
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.supervisor-section {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.supervisor-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.sv-card {
|
||||
border-left: 3px solid #e4e7ed;
|
||||
}
|
||||
.sv-card.sv-at-risk {
|
||||
border-left-color: #f56c6c;
|
||||
}
|
||||
.sv-card.sv-zombie {
|
||||
border-left-color: #f56c6c;
|
||||
background: #fef0f0;
|
||||
}
|
||||
.sv-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.sv-name {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
.sv-meta {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.sv-countdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.sv-countdown-label {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sv-countdown :deep(.el-progress) {
|
||||
flex: 1;
|
||||
}
|
||||
.sv-countdown-val {
|
||||
font-size: 12px;
|
||||
color: #f56c6c;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* —— CEO 计划预览对话框 —— */
|
||||
.plan-preview {
|
||||
max-height: 68vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 6px;
|
||||
}
|
||||
.plan-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.plan-company {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
.plan-hint {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
.plan-section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.plan-label {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
margin-bottom: 6px;
|
||||
color: #303133;
|
||||
}
|
||||
.plan-sub-label {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
.plan-readonly-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.plan-readonly-text {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
.plan-ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
.plan-dept-card {
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
background: #fafcff;
|
||||
}
|
||||
.plan-dept-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.plan-dept-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
.plan-dep {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
.plan-dept-field {
|
||||
margin-top: 6px;
|
||||
}
|
||||
/* —— 实时执行监控窗口 —— */
|
||||
.live-monitor-body {
|
||||
min-height: 40vh;
|
||||
max-height: 78vh;
|
||||
overflow-y: auto;
|
||||
padding: 4px 8px 8px;
|
||||
}
|
||||
:deep(.live-monitor-dialog .el-dialog__body) {
|
||||
padding-top: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user