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:
@@ -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"
|
||||
|
||||
@@ -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)}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -16,6 +16,14 @@
|
||||
|
||||
所有服务通过 Docker Compose 编排,统一在 `aiagent-net` 桥接网络中通信。
|
||||
|
||||
> **重要:本项目在 101.43.95.130 的实际生产部署与本指南默认假设有三处差异,升级/运维前务必先看:**
|
||||
>
|
||||
> 1. **数据库是外部腾讯云 CloudDB(TDSQL-C),不是容器 MySQL。** `docker-compose.prod.yml` 里没有 mysql 服务,也就没有 `aiagent-mysql` 容器。凡是 `docker exec aiagent-mysql ...` 的命令都要改成直连外部实例(见第六、七、九节的「外部数据库」写法)。
|
||||
> 2. **服务器在国内,`docker compose build` 常卡在 apt-get / PyPI(files.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` 已正确配置,密钥未过期。
|
||||
|
||||
@@ -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