` | ❌ 缺少 `draggable="true"` 属性,即使改用 `dragstart` 事件也无法触发拖拽 |
+
+**核心问题链**:
+1. `mousedown` 事件没有 `dataTransfer` → `dt` 为 `undefined`
+2. `if (vueFlowRef.value && dt)` 条件永远为 `false`
+3. `dt.setData('application/vueflow', ...)` 永远不执行
+4. `onDrop` 中 `event.dataTransfer?.getData('application/vueflow')` 返回 `null`
+5. 函数直接 `return`,不创建任何节点
+
+**为什么"没有任何报错"**: 因为代码流程中每个 `if` 条件都静默返回了 `null/undefined`,没有走到会抛异常的逻辑路径。
+
+#### 解决方案
+
+**修改文件**: `frontend/src/views/flow/FlowEditor.vue`
+
+**修改点1** — 模板: 将 `@mousedown` 改为 `@dragstart`,添加 `draggable="true"`
+
+第21-29行,改为:
+```html
+
+```
+
+**修改点2** — 脚本: 修复 `onDragStart` 函数
+
+第253-259行,改为:
+```typescript
+function onDragStart(event: DragEvent, node: (typeof nodeTypes)[0]) {
+ if (event.dataTransfer) {
+ event.dataTransfer.setData('application/vueflow', JSON.stringify(node))
+ event.dataTransfer.effectAllowed = 'move'
+ }
+}
+```
+
+**修改点3** — 脚本: 增强 `onDrop` 函数的健壮性
+
+第261-291行,改为:
+```typescript
+function onDrop(event: DragEvent) {
+ const dataStr = event.dataTransfer?.getData('application/vueflow')
+ if (!dataStr) return
+
+ const nodeData = JSON.parse(dataStr)
+
+ // 使用 Vue Flow 内置方法计算画布坐标,回退到手动估算
+ let position = { x: 100, y: 100 }
+ try {
+ const bounds = (event.currentTarget as HTMLElement)?.getBoundingClientRect()
+ if (bounds) {
+ position = {
+ x: event.clientX - bounds.left - 80,
+ y: event.clientY - bounds.top - 20,
+ }
+ }
+ } catch { /* fallback to default position */ }
+
+ const id = `node_${nodeCounter++}`
+ const newNode: any = {
+ id,
+ type: 'custom',
+ position,
+ data: {
+ label: nodeData.label,
+ type: nodeData.type,
+ typeDesc: nodeData.typeDesc,
+ color: colorMap[nodeData.type] || '#409EFF',
+ config: nodeData.type === 'llm'
+ ? { system_prompt: '', model: 'gpt-4o-mini', temperature: 0.7 }
+ : {},
+ },
+ draggable: true,
+ connectable: true,
+ }
+
+ elements.value = [...elements.value, newNode]
+}
+```
+
+---
+
+### 问题3: 流详情 `/api/flow/definitions/:id` 直接在菜单中出现,未传ID导致422错误
+
+#### 根因分析
+
+**问题源**: `frontend/src/components/layout/AdminLayout.vue` 第46行:
+
+```html
+
流详情
+```
+
+这段代码存在两个严重问题:
+
+1. **`:id` 是路由参数占位符,不能直接作为菜单导航路径**:
+ - 点击菜单 → `router.push('/admin/flow/market/:id')`
+ - 前端路由匹配路径 `/admin/flow/market/:id` → `route.params.id` = `':id'` (字面量)
+ - 组件 `FlowDetail.vue` 调用 API `flowApi.getFlow(':id')`
+ - 发送请求 `GET /api/flow/definitions/%3Aid` → 422 Unprocessable Entity
+
+2. **业务逻辑错误**: 流详情是**列表项的操作入口**(点击某条流记录 → 查看详情),而不是一个独立的导航菜单项。用户必须先选择一个具体的流,才能查看其详情。
+
+**对比已有的正确设计**:
+- `TaskDetail` 路由: `/user/task/:id` — **不在菜单中**,只能通过任务列表点击跳转
+- `FlowEditor` 编辑路由: `/admin/flow/editor/:id` — **不在菜单中**,通过流列表点击跳转
+- `FlowDetail` 流详情路由: `/admin/flow/market/:id` — **错误地放在菜单中**
+
+#### 解决方案
+
+**修改文件1**: `frontend/src/components/layout/AdminLayout.vue`
+
+删除第46行:
+```diff
+-
流详情
+```
+
+同时检查 `FlowList.vue` 和 `FlowMarket.vue` 是否已有正确的详情跳转入口(Card点击 → `router.push('/admin/flow/market/:id')`),如果没有则需要补充。
+
+**修改文件2**: `frontend/src/views/flow/FlowList.vue` — 确保每条流记录点击能跳转到详情
+
+检查并确保 FlowList 每行有跳转逻辑:
+```typescript
+function viewFlow(id: string) {
+ router.push(`/admin/flow/market/${id}`)
+}
+```
+
+---
+
+### 问题4: 管理后台和企业AI后台 —— 做成固定在右上角账号隔壁的选择项
+
+#### 根因分析
+
+**当前实现位置**:
+- `MainLayout.vue` 第72-75行: "管理后台" 入口放在 **左侧边栏底部**(仅管理员可见)
+- `AdminLayout.vue` 第86-89行: "返回用户端" 入口放在 **左侧边栏底部**
+
+**存在的问题**:
+1. 入口位置隐蔽,用户不易发现
+2. "管理后台"入口被混在功能菜单中,不够突出
+3. 两个入口位于不同布局,缺少统一的切换组件
+4. 没有明确的视觉状态指示当前所在端
+
+**需求**: 做成固定在右上角、账号头像旁边的**明确可切换的选择项**
+
+#### 解决方案
+
+**方案**: 在两个 Layout 的 `header-right` 区域统一添加"端切换"组件
+
+##### 4.1 创建公共切换组件
+
+文件: `frontend/src/components/common/PortalSwitcher.vue`
+
+```vue
+
+
+
+
+
+ 企业AI
+
+
+
+ 管理后台
+
+
+
+
+
+
+
+
+```
+
+##### 4.2 修改 MainLayout.vue
+
+第88-102行区域的 `header-right`,在用户下拉菜单前插入 PortalSwitcher:
+
+```diff
+
+```
+
+同时**移除**第72-75行的侧边栏管理后台入口:
+```diff
+-
+-
+- 管理后台
+-
+```
+
+##### 4.3 修改 AdminLayout.vue
+
+同样在 header-right 区域插入 PortalSwitcher:
+
+```diff
+
+```
+
+同时**移除**第86-89行的侧边栏返回用户端入口:
+```diff
+-
+-
+- 返回用户端
+-
+```
+
+---
+
+## 三、P3 级体验优化(下期规划)
+
+> 来源: PLAN2.md "六、剩余可优化项"
+
+### 3.1 全局搜索
+
+| 项目 | 详情 |
+|------|------|
+| **入口位置** | 顶部导航栏中央,全局搜索框(Cmd/Ctrl + K 快捷键唤起) |
+| **搜索范围** | 任务名称/ID、流名称/ID、员工姓名、部门名称、智能体类型 |
+| **交互设计** | 输入关键词 → 实时下拉联想 → 选中 → 跳转到对应详情页 |
+| **技术方案** | 前端: `el-autocomplete` + 防抖;后端: 新增 `GET /api/search?q=keyword` 聚合搜索接口 |
+| **前端文件** | 新增 `components/common/GlobalSearch.vue` |
+| **后端文件** | 新增 `modules/search/router.py` |
+
+### 3.2 主题切换(深色/浅色模式)
+
+| 项目 | 详情 |
+|------|------|
+| **入口位置** | 右上角 PortalSwitcher 旁边,添加主题切换按钮(太阳/月亮图标) |
+| **切换内容** | Element Plus CSS 变量覆盖 + 自定义页面背景色 |
+| **持久化** | localStorage 存 `theme` 字段,刷新后保持 |
+| **技术方案** | 使用 `el-config-provider` 的 `namespace` + CSS 变量覆盖。Element Plus 2.x 原生支持暗黑模式(添加 `dark` class 到 `html` 元素) |
+| **前端文件** | 新增 `stores/theme.ts` + 修改 `main.ts` |
+| **CSS文件** | 新增 `styles/dark.css` |
+
+### 3.3 国际化(i18n 多语言)
+
+| 项目 | 详情 |
+|------|------|
+| **支持语言** | 简体中文(zh-CN)、英文(en-US) |
+| **实现方案** | vue-i18n ^9.x,语言包按模块拆分 |
+| **入口位置** | 右上角 PortalSwitcher 旁边,语言切换下拉或图标 |
+| **持久化** | localStorage 存 `locale` 字段 |
+| **翻译范围** | 菜单、按钮、表单标签、提示信息、表格列头、错误信息 |
+| **前端文件** | 新增 `locales/zh-CN.ts`、`locales/en-US.ts`、`locales/index.ts` |
+
+### 3.4 新手引导(首次登录操作指引)
+
+| 项目 | 详情 |
+|------|------|
+| **触发条件** | 用户表中 `onboarding_completed` 字段为 false |
+| **引导步骤** | ① 欢迎 → ② 查看工作台 → ③ 创建任务 → ④ 查看智能体 → ⑤ 进入管理后台(仅管理员) |
+| **实现方案** | driver.js 或 intro.js 库,高亮目标元素 + 文字提示泡泡 |
+| **跳过机制** | 引导面板有"跳过"和"不再显示"按钮 |
+| **前端文件** | 新增 `components/common/OnboardingGuide.vue` |
+| **后端文件** | `PUT /api/auth/me` 增加 `onboarding_completed` 字段支持 |
+
+### 3.5 移动端适配(响应式布局)
+
+| 项目 | 详情 |
+|------|------|
+| **适配范围** | 工作台、任务列表、智能体对话、通知中心(核心用户端功能) |
+| **布局策略** | 768px 以下: 侧边栏隐藏(汉堡菜单),内容区全宽,表格横向滚动 |
+| **组件调整** | el-card → 边距缩小,el-table → 固定列+横向滚动,el-form → 标签在上 |
+| **CSS方案** | 全局 `@media (max-width: 768px)` + Element Plus 响应式断点 |
+| **管理后台** | 仅做基本适配(可左右滚动),不做深度移动端优化 |
+| **前端文件** | 新增 `styles/mobile.css` + 各页面添加响应式 scoped style |
+
+---
+
+## 四、实施优先级与排期
+
+### 4.1 本期(P0 紧急修复)
+
+| # | 任务 | 文件 | 工作量 |
+|---|------|------|--------|
+| 1 | 修复企微配置500错误(缺少settings导入) | `backend/modules/wecom/router.py` | 5分钟 |
+| 2 | 修复节点拖拽(mousedown→dragstart + draggable) | `frontend/src/views/flow/FlowEditor.vue` | 15分钟 |
+| 3 | 移除菜单中的"流详情"(`:id`占位符) | `frontend/src/components/layout/AdminLayout.vue` | 5分钟 |
+| 4 | PortalSwitcher 创建并集成到两个Layout | `frontend/src/components/common/PortalSwitcher.vue` + `MainLayout.vue` + `AdminLayout.vue` | 30分钟 |
+| 5 | 过滤 Chrome 扩展 runtime.lastError | `frontend/src/main.ts` 或 `index.html` | 5分钟 |
+
+### 4.2 下期(P3 体验优化)
+
+| # | 任务 | 工作量 | 建议排期 |
+|---|------|--------|---------|
+| 6 | 全局搜索 | 2天 | Week 1 |
+| 7 | 主题切换(深色/浅色) | 1天 | Week 1 |
+| 8 | 国际化 i18n | 2天 | Week 2 |
+| 9 | 新手引导 | 1天 | Week 2 |
+| 10 | 移动端适配 | 2天 | Week 2-3 |
+
+---
+
+## 五、验证检查清单
+
+实施完成后,使用 **sroot** 账号验证:
+
+```
+□ 企微配置页面正常加载(不再500报错)
+□ 节点面板拖拽到画布: 拖拽正常 → 节点出现在画布上
+□ 菜单中不再出现"流详情"菜单项
+□ 从流列表/流市场点击具体流 → 正确跳转流详情
+□ 右上角显示 PortalSwitcher 切换按钮
+□ 在企业AI端 → 点击"管理后台" → 切换到管理后台
+□ 在管理后台 → 点击"企业AI" → 切换回企业AI端
+□ 流编辑器页面加载无 Chrome runtime.lastError 警告
+```
\ No newline at end of file
diff --git a/backend/Dockerfile b/backend/Dockerfile
index 55ee90e..8748f0f 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -12,4 +12,4 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
-CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
\ No newline at end of file
+CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
\ No newline at end of file
diff --git a/backend/agentscope_integration/tools/wecom_tools.py b/backend/agentscope_integration/tools/wecom_tools.py
index 6cdd22c..17838eb 100644
--- a/backend/agentscope_integration/tools/wecom_tools.py
+++ b/backend/agentscope_integration/tools/wecom_tools.py
@@ -1,6 +1,5 @@
import httpx
import logging
-import uuid
logger = logging.getLogger(__name__)
diff --git a/backend/modules/audit/router.py b/backend/modules/audit/router.py
index b03ec1a..f862340 100644
--- a/backend/modules/audit/router.py
+++ b/backend/modules/audit/router.py
@@ -54,7 +54,7 @@ async def list_logs(
logs = result.scalars().all()
return AuditLogPage(
- items=[AuditLogOut.model_validate(log) for log in logs],
+ items=[AuditLogOut.model_validate(log, from_attributes=True) for log in logs],
total=total,
page=page,
page_size=page_size,
diff --git a/backend/modules/auth/router.py b/backend/modules/auth/router.py
index 8788592..7b60497 100644
--- a/backend/modules/auth/router.py
+++ b/backend/modules/auth/router.py
@@ -11,6 +11,11 @@ from models import User, UserRole, Role, RolePermission, Permission
from schemas import LoginRequest, TokenResponse, UserOut, RoleOut
from config import settings
+
+def hash_password(password: str) -> str:
+ return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
+
+
router = APIRouter(prefix="/api/auth", tags=["auth"])
async def get_permission_codes(db: AsyncSession, role_ids: list[uuid.UUID]) -> list[str]:
@@ -95,11 +100,12 @@ async def get_me(request: Request, db: AsyncSession = Depends(get_db)):
@router.get("/wecom/oauth-url")
-async def get_wecom_oauth_url():
+async def get_wecom_oauth_url(request: Request):
corp_id = settings.WECOM_CORP_ID or ""
if not corp_id:
return {"code": 400, "message": "请先配置 WECOM_CORP_ID"}
- redirect_uri = f"{settings.WECOM_CORP_ID}/api/auth/wecom/callback"
+ base_url = str(request.base_url).rstrip("/")
+ redirect_uri = f"{base_url}/api/auth/wecom/callback"
url = f"https://open.weixin.qq.com/connect/oauth2/authorize?appid={corp_id}&redirect_uri={redirect_uri}&response_type=code&scope=snsapi_base&state=STATE#wechat_redirect"
return {"code": 200, "data": {"url": url}}
@@ -155,8 +161,4 @@ async def change_password(
user.password_hash = hash_password(new_pw)
await db.commit()
- return {"code": 200, "message": "密码已修改"}
-
-
-def hash_password(password: str) -> str:
- return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
\ No newline at end of file
+ return {"code": 200, "message": "密码已修改"}
\ No newline at end of file
diff --git a/backend/modules/flow_engine/engine.py b/backend/modules/flow_engine/engine.py
index f0f9073..5c02299 100644
--- a/backend/modules/flow_engine/engine.py
+++ b/backend/modules/flow_engine/engine.py
@@ -284,10 +284,57 @@ class ConditionNodeAgent(AgentBase):
user_text = msg.get_text_content() if hasattr(msg, 'get_text_content') else str(msg)
if not self.condition:
- return msg if isinstance(msg, Msg) else Msg(self.name, str(msg), "assistant")
+ return Msg(self.name, "condition:true|条件为空,默认通过", "assistant")
- result_text = f"[条件判断: {self.condition[:80]}]\n输入: {user_text[:300]}\n结果: 条件满足,继续执行。"
- return Msg(self.name, result_text, "assistant")
+ try:
+ from agentscope.model import OpenAIChatModel
+ from agentscope.formatter import OpenAIChatFormatter
+
+ model = OpenAIChatModel(
+ config_name=f"condition_{self.name}",
+ model_name=settings.LLM_MODEL,
+ api_key=settings.LLM_API_KEY,
+ api_base=settings.LLM_API_BASE,
+ )
+
+ formatter = OpenAIChatFormatter()
+ condition_prompt = f"""你是一个条件判断专家。请判断以下条件表达式是否基于输入内容满足。
+
+条件表达式: {self.condition}
+
+输入内容:
+{user_text[:2000]}
+
+请严格只输出一行 JSON:
+{{"result": true/false, "reason": "简要原因"}}"""
+
+ prompt = await formatter.format([
+ Msg("system", condition_prompt, "system"),
+ Msg("user", user_text[:2000], "user"),
+ ])
+ res = await model(prompt)
+
+ import json
+ import re
+ res_text = ""
+ if isinstance(res, list):
+ res_text = res[0].get_text_content() if hasattr(res[0], 'get_text_content') else str(res[0])
+ elif hasattr(res, 'get_text_content'):
+ res_text = res.get_text_content()
+ else:
+ res_text = str(res)
+
+ json_match = re.search(r'\{[^}]+\}', res_text)
+ if json_match:
+ parsed = json.loads(json_match.group())
+ matched = parsed.get("result", False)
+ reason = parsed.get("reason", "")
+ result_flag = "true" if matched else "false"
+ return Msg(self.name, f"condition:{result_flag}|{reason}", "assistant")
+ except Exception as e:
+ logger.warning(f"条件判断LLM调用失败: {e}")
+
+ return Msg(self.name, f"condition:true|条件判断失败,默认通过: {self.condition[:80]}", "assistant")
async def observe(self, msg) -> None:
pass
diff --git a/backend/modules/task/router.py b/backend/modules/task/router.py
index b83b316..0969cc4 100644
--- a/backend/modules/task/router.py
+++ b/backend/modules/task/router.py
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
-from models import Task
+from models import Task, User
from schemas import TaskCreate, TaskUpdate, TaskOut
from modules.org.router import _get_subordinate_ids
diff --git a/backend/modules/wecom/router.py b/backend/modules/wecom/router.py
index a2c7d5d..ddd5c0e 100644
--- a/backend/modules/wecom/router.py
+++ b/backend/modules/wecom/router.py
@@ -1,9 +1,9 @@
import uuid
-import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
+from config import settings
from models import User, ChatSession, ChatMessage
router = APIRouter(prefix="/api/wecom", tags=["wecom"])
@@ -93,66 +93,6 @@ async def wecom_callback(request: Request, db: AsyncSession = Depends(get_db)):
}
-@router.post("/send")
-async def send_wecom_message(
- request: Request,
- payload: dict,
- db: AsyncSession = Depends(get_db),
-):
- """
- 向企业微信用户推送消息。
- 生产环境中需配置真实的企微API凭据。
- """
- to_user = payload.get("to_user", "")
- msg_content = payload.get("content", "")
- msg_type = payload.get("msg_type", "text")
-
- if not to_user:
- raise HTTPException(400, "缺少目标用户")
-
- corp_id = ""
- corp_secret = ""
- wecom_message_id = f"msg_{uuid.uuid4().hex[:12]}"
-
- if corp_id and corp_secret:
- try:
- async with httpx.AsyncClient() as client:
- token_resp = await client.get(
- "https://qyapi.weixin.qq.com/cgi-bin/gettoken",
- params={"corpid": corp_id, "corpsecret": corp_secret},
- )
- token_data = token_resp.json()
- access_token = token_data.get("access_token")
-
- if access_token:
- msg_body = {
- "touser": to_user,
- "msgtype": msg_type,
- "agentid": 0,
- }
- if msg_type == "text":
- msg_body["text"] = {"content": msg_content}
- elif msg_type == "textcard":
- msg_body["textcard"] = payload.get("card", {})
-
- msg_resp = await client.post(
- f"https://qyapi.weixin.qq.com/cgi-bin/message/send",
- params={"access_token": access_token},
- json=msg_body,
- )
- resp_data = msg_resp.json()
- if resp_data.get("errcode") == 0:
- wecom_message_id = resp_data.get("msgid", wecom_message_id)
- except Exception:
- pass
-
- return {
- "code": 200,
- "message": "消息已发送",
- "data": {"wecom_message_id": wecom_message_id},
- }
-
-
@router.get("/config")
async def get_wecom_config(request: Request):
return {
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
index 8dd82b2..1853911 100644
--- a/frontend/nginx.conf
+++ b/frontend/nginx.conf
@@ -6,6 +6,9 @@ server {
location / {
try_files $uri $uri/ /index.html;
+ add_header Cache-Control "no-cache, no-store, must-revalidate";
+ add_header Pragma "no-cache";
+ add_header Expires 0;
}
location /api/ {
@@ -25,4 +28,9 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
+
+ location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
+ expires 0;
+ add_header Cache-Control "no-cache, no-store, must-revalidate";
+ }
}
\ No newline at end of file
diff --git a/frontend/src/components/common/PortalSwitcher.vue b/frontend/src/components/common/PortalSwitcher.vue
new file mode 100644
index 0000000..db5149b
--- /dev/null
+++ b/frontend/src/components/common/PortalSwitcher.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
+ 企业AI
+
+
+
+ 管理后台
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/layout/AdminLayout.vue b/frontend/src/components/layout/AdminLayout.vue
index ebf0969..43f6171 100644
--- a/frontend/src/components/layout/AdminLayout.vue
+++ b/frontend/src/components/layout/AdminLayout.vue
@@ -43,7 +43,6 @@
流列表
流编辑器
流市场
-
流详情
@@ -82,11 +81,6 @@
系统监控
-
-
-
- 返回用户端
-
@@ -100,6 +94,7 @@