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:
2026-07-12 00:40:22 +08:00
parent f2e65a8fbb
commit eef48ce831
8 changed files with 875 additions and 88 deletions

View File

@@ -60,6 +60,9 @@ class DepartmentAdd(BaseModel):
class ExecuteRequest(BaseModel):
project_description: str = Field(..., min_length=1, description="公司级项目目标")
ceo_plan: Optional[Dict[str, Any]] = Field(
None, description="已确认的 CEO 计划;提供时跳过二次规划,直接据此执行"
)
# ─── Endpoints ───
@@ -365,6 +368,24 @@ def remove_department(
# ─── 项目执行 ───
@router.post("/{company_id}/plan")
async def plan_company(
company_id: str,
body: ExecuteRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""仅生成 CEO 计划供预览/确认,不派发部门、不落库。"""
company = db.query(Company).filter(Company.id == company_id).first()
if not company:
raise HTTPException(status_code=404, detail="公司不存在")
orchestrator = CompanyOrchestrator(db, company_id, current_user.id)
try:
return await orchestrator.preview_plan(body.project_description)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/{company_id}/execute")
async def execute_company(
company_id: str,
@@ -378,7 +399,7 @@ async def execute_company(
raise HTTPException(status_code=404, detail="公司不存在")
orchestrator = CompanyOrchestrator(db, company_id, current_user.id)
try:
result = await orchestrator.execute(body.project_description)
result = await orchestrator.execute(body.project_description, ceo_plan=body.ceo_plan)
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -400,7 +421,7 @@ async def execute_company_stream(
async def event_stream():
try:
async for event in orchestrator.execute_stream(body.project_description):
async for event in orchestrator.execute_stream(body.project_description, ceo_plan=body.ceo_plan):
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
except Exception as e:
yield f"data: {json.dumps({'type': 'error', 'message': str(e)}, ensure_ascii=False)}\n\n"

View File

@@ -383,10 +383,27 @@ class CompanyOrchestrator:
"_ts": __import__("time").time(),
})
async def preview_plan(self, project_description: str) -> Dict[str, Any]:
"""仅运行 CEO 规划并返回计划,不派发部门、不落库(无状态预览)。
供「执行前预览+确认」使用:前端拿到计划后可微调,再携带该计划正式执行。
"""
company = self._load_company()
departments = self._load_departments()
if not departments:
raise ValueError("公司没有部门,请先添加部门")
ceo_plan = await self._run_ceo_planning(company, departments, project_description)
return {
"company_name": company.name,
"department_names": [d.name for d in departments],
"ceo_plan": ceo_plan,
}
async def execute(
self,
project_description: str,
auto_approve_files: bool = True,
ceo_plan: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""执行公司级项目(非流式)。
@@ -418,9 +435,12 @@ class CompanyOrchestrator:
self.db.add(project)
self.db.commit()
# Phase 1: CEO Planning
logger.info("公司编排 [%s]: Phase 1 — CEO 规划开始", self.company_id)
ceo_plan = await self._run_ceo_planning(company, departments, project_description)
# Phase 1: CEO Planning(若已传入已确认的计划则跳过,直接采用)
if ceo_plan is None:
logger.info("公司编排 [%s]: Phase 1 — CEO 规划开始", self.company_id)
ceo_plan = await self._run_ceo_planning(company, departments, project_description)
else:
logger.info("公司编排 [%s]: Phase 1 — 采用已确认的 CEO 计划(跳过规划)", self.company_id)
project.ceo_plan = ceo_plan
project.status = "in_progress"
self.db.commit()
@@ -515,6 +535,7 @@ class CompanyOrchestrator:
project_description: str,
auto_approve_files: bool = True,
max_rounds: int = 3,
ceo_plan: Optional[Dict[str, Any]] = None,
) -> AsyncGenerator[Dict[str, Any], None]:
"""流式执行公司级项目SSE 事件实时推送。支持多轮迭代+打分+预算。
@@ -556,14 +577,17 @@ class CompanyOrchestrator:
"_ts": _time.time(),
}
# Phase 1: CEO Planning
yield {"type": "ceo_plan_start", "message": "CEO is analyzing the objective...", "_ts": _time.time()}
# Phase 1: CEO Planning(若已传入已确认的计划则跳过,直接采用)
t_ceo_start = _time.time()
try:
ceo_plan = await self._run_ceo_planning(company, departments, project_description)
except Exception as e:
yield {"type": "error", "message": f"CEO planning failed: {e}", "phase": "ceo_planning", "_ts": _time.time()}
return
if ceo_plan is None:
yield {"type": "ceo_plan_start", "message": "CEO is analyzing the objective...", "_ts": _time.time()}
try:
ceo_plan = await self._run_ceo_planning(company, departments, project_description)
except Exception as e:
yield {"type": "error", "message": f"CEO planning failed: {e}", "phase": "ceo_planning", "_ts": _time.time()}
return
else:
yield {"type": "ceo_plan_start", "message": "Using approved plan...", "approved": True, "_ts": _time.time()}
yield {"type": "ceo_plan_done", "plan": ceo_plan, "_ts": _time.time(), "duration_ms": int((_time.time() - t_ceo_start) * 1000)}

View File

@@ -922,7 +922,7 @@ COMPANY_PRESETS: Dict[str, Dict[str, Any]] = {
"group": "战略层",
"description": "商业分析、融资BP、合作评估、赛道研判",
"system_prompt": CEO_TECH_PROMPT,
"tools": ["web_search", "text_analyze", "task_plan", "http_request", "file_read"],
"tools": ["web_search", "text_analyze", "task_plan", "http_request", "file_read", "file_write"],
"temperature": 0.5,
"model": "deepseek-v4-pro",
"max_iterations": 15,
@@ -933,7 +933,7 @@ COMPANY_PRESETS: Dict[str, Dict[str, Any]] = {
"group": "战略层",
"description": "架构评审、技术选型、技术债评估、专利分析",
"system_prompt": CTO_TECH_PROMPT,
"tools": ["web_search", "file_read", "grep_search", "text_analyze", "task_plan"],
"tools": ["web_search", "file_read", "grep_search", "text_analyze", "task_plan", "file_write"],
"temperature": 0.3,
"model": "deepseek-v4-pro",
"max_iterations": 12,

View File

@@ -16,6 +16,14 @@
所有服务通过 Docker Compose 编排,统一在 `aiagent-net` 桥接网络中通信。
> **重要:本项目在 101.43.95.130 的实际生产部署与本指南默认假设有三处差异,升级/运维前务必先看:**
>
> 1. **数据库是外部腾讯云 CloudDBTDSQL-C不是容器 MySQL。** `docker-compose.prod.yml` 里没有 mysql 服务,也就没有 `aiagent-mysql` 容器。凡是 `docker exec aiagent-mysql ...` 的命令都要改成直连外部实例(见第六、七、九节的「外部数据库」写法)。
> 2. **服务器在国内,`docker compose build` 常卡在 apt-get / PyPIfiles.pythonhosted.org超时。** 构建或装包需走国内镜像源(见第四节)。
> 3. **升级不要盲目 `alembic upgrade`。** 公司模块迁移 `d684c9b30c1c` 是 autogenerate 的破坏性迁移(会 drop 掉 tools/api_keys 等已有数据的表),且线上库对应表已存在。正确做法见第六节「增量升级」。
>
> 实际部署目录为 `/home/renjianbo/aiagent`(本指南示例用 `/opt/aiagent`,按需替换)。
---
## 二、服务器要求
@@ -173,6 +181,21 @@ docker compose -f docker-compose.prod.yml build --pull
docker compose -f docker-compose.prod.yml up -d
```
> **国内服务器注意(重要)**:在腾讯云等国内服务器上,`docker compose build` 经常卡在 `apt-get update` 或 `files.pythonhosted.org` 超时。处理办法:
>
> - **给 Docker 配镜像加速**(构建时拉基础镜像):编辑 `/etc/docker/daemon.json` 加入
> ```json
> { "registry-mirrors": ["https://mirror.ccs.tencentyun.com"] }
> ```
> 然后 `sudo systemctl restart docker``mirror.ccs.tencentyun.com` 为腾讯云内网加速地址)。
> - **给 pip 换国内源**:在 `backend/Dockerfile` 的 pip 安装步骤加
> `-i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com`。
> - **临时给已运行容器补一个 Python 包**(不想重建镜像时):
> ```bash
> docker exec <容器名> pip install -i https://mirrors.aliyun.com/pypi/simple/ \
> --trusted-host mirrors.aliyun.com <包名>==<版本>
> ```
### 4. 验证部署
```bash
@@ -206,6 +229,8 @@ aiagent-kibana Up
aiagent-filebeat Up
```
> **使用外部腾讯云数据库时**:没有 `aiagent-mysql` 容器(少一个)。核心服务确认 `aiagent-backend`、`aiagent-redis`、`aiagent-frontend`、两个 celery 均为 `Up`,且 `curl -s -o /dev/null -w '%{http_code}' http://localhost:8037/docs` 返回 `200` 即为正常。
---
## 五、访问地址
@@ -260,6 +285,14 @@ docker compose -f docker-compose.prod.yml up -d
### 增量升级
> **升级前必读(本项目实测坑)**
> 1. **构建可能失败**`docker compose build --pull` 在国内服务器常因 apt/PyPI 超时卡死先按第四节配好国内镜像急用时可用「docker cp 热更新 + 补依赖」绕过(见下方备用步骤 2B/3
> 2. **迁移要小心**:公司模块迁移 `d684c9b30c1c_add_company_tables` 是破坏性的(会 `drop_table` tools/api_keys/fcm_tokens 等)。若线上库里 companies/company_projects 等表已存在,**不要执行 `alembic upgrade`**,而是标记版本:
> ```bash
> docker exec aiagent-backend sh -c "cd /app && alembic stamp d684c9b30c1c"
> ```
> 3. **新依赖**:新代码引入了 `APScheduler==3.10.4`公司调度用。旧镜像若没有backend 会 `ModuleNotFoundError: No module named 'apscheduler'` 崩溃需按第四节临时装包backend + 两个 celery 都要装)。
项目提供了 `upgrade.sh` 脚本实现零停机升级:
```bash
@@ -274,31 +307,66 @@ bash upgrade.sh
5. 清理旧镜像,释放磁盘空间
6. 验证后端健康状态
手动升级步骤(不使用脚本
手动升级步骤(国内服务器 / 外部数据库实测流程
```bash
# 拉取最新代码
cd /home/renjianbo/aiagent # 或 /opt/aiagent
# 1. 拉取最新代码
git pull
# 重新构建并启动
# 2A. 常规:重建镜像并重启(网络好、已配国内镜像时)
docker compose -f docker-compose.prod.yml build --pull
docker compose -f docker-compose.prod.yml up -d
# 2B. 备用build 卡死时,热更新已运行容器的代码(不重建镜像)
docker cp backend/app aiagent-backend:/app/
docker cp backend/alembic aiagent-backend:/app/
docker cp backend/app aiagent-celery-worker:/app/
docker cp backend/app aiagent-celery-beat:/app/
# 3. 有新 Python 依赖时,用国内源补装(三个容器都装)
for c in aiagent-backend aiagent-celery-worker aiagent-celery-beat; do
docker exec $c pip install -i https://mirrors.aliyun.com/pypi/simple/ \
--trusted-host mirrors.aliyun.com -r /app/requirements.txt
done
# 4. 迁移:先看当前版本,破坏性迁移用 stamp勿盲目 upgrade见上方警告
docker exec aiagent-backend sh -c "cd /app && alembic current"
# 5. 重启后端使代码生效并验证
docker compose -f docker-compose.prod.yml restart backend
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8037/docs # 期望 200
```
> 走 2B 热更新的改动只在容器可写层,`docker compose up`/重建容器会丢失;条件允许时仍应配好国内镜像走 2A 正式重建。
### 数据库备份
**A. 容器内 MySQL本指南默认**
```bash
# 备份到本地文件
docker exec aiagent-mysql mysqldump -u root -p<密码> agent_db > agent_db_backup.sql
```
# 定时备份(加入 crontab每天凌晨 2 点)
# 0 2 * * * cd /opt/aiagent && docker exec aiagent-mysql mysqldump -u root -p<密码> agent_db > backups/agent_db_$(date +\%Y\%m\%d).sql
**B. 外部腾讯云 CloudDB本项目实际用法无 aiagent-mysql 容器):** 直连外部实例:
```bash
mysqldump -h gz-cynosdbmysql-grp-xxx.sql.tencentcdb.com -P 24936 \
-u root -p<密码> agent_db > agent_db_backup.sql
# 若本机没装 mysql 客户端,可借用 mysql 镜像执行:
# docker run --rm mysql:8.0 mysqldump -h <外部地址> -P 24936 -uroot -p<密码> agent_db > agent_db_backup.sql
# 定时备份crontab每天凌晨 2 点)
# 0 2 * * * mysqldump -h <外部地址> -P 24936 -uroot -p<密码> agent_db > /home/renjianbo/aiagent/backups/agent_db_$(date +\%Y\%m\%d).sql
```
### 数据库恢复
```bash
# 容器内 MySQL
docker exec -i aiagent-mysql mysql -u root -p<密码> agent_db < agent_db_backup.sql
# 外部腾讯云 CloudDB
mysql -h gz-cynosdbmysql-grp-xxx.sql.tencentcdb.com -P 24936 -u root -p<密码> agent_db < agent_db_backup.sql
```
---
@@ -392,17 +460,21 @@ free -h
### 数据库连接失败
**容器内 MySQL**
```bash
# 确认 MySQL 容器是否健康
docker exec aiagent-mysql mysqladmin ping -h localhost
# 检查后端能否连接 MySQL
docker exec aiagent-backend sh -c "curl -s mysql:3306" || echo "无法连接"
# 查看 MySQL 日志
docker logs aiagent-mysql --tail 30
```
**外部腾讯云 CloudDB本项目实际用法** 从 backend 容器内验证能否连通外部库:
```bash
docker exec aiagent-backend python -c "import os,re,pymysql; \
u=os.environ['DATABASE_URL']; m=re.match(r'.*://([^:]+):([^@]+)@([^:]+):(\d+)/([^?]+)',u); \
pymysql.connect(host=m.group(3),user=m.group(1),password=m.group(2),port=int(m.group(4)),database=m.group(5)); \
print('DB OK')"
```
排查点:`.env``DATABASE_URL` 是否正确、腾讯云 CloudDB 安全组是否放行了本服务器的出口 IP、账号密码是否有效、库 `agent_db` 是否存在。
### DeepSeek API 返回 401
确认 `.env``DEEPSEEK_API_KEY` 已正确配置,密钥未过期。

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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 正在规划,请稍候(约需 3060 秒)…', 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>