Browse Source

功能开始正常化

master
MSI-7950X\刘泽明 7 days ago
parent
commit
cef7ecd931
  1. 455
      PLAN3.md
  2. 2
      backend/Dockerfile
  3. 1
      backend/agentscope_integration/tools/wecom_tools.py
  4. 2
      backend/modules/audit/router.py
  5. 16
      backend/modules/auth/router.py
  6. 53
      backend/modules/flow_engine/engine.py
  7. 2
      backend/modules/task/router.py
  8. 62
      backend/modules/wecom/router.py
  9. 8
      frontend/nginx.conf
  10. 56
      frontend/src/components/common/PortalSwitcher.vue
  11. 8
      frontend/src/components/layout/AdminLayout.vue
  12. 7
      frontend/src/components/layout/MainLayout.vue
  13. 9
      frontend/src/main.ts
  14. 30
      frontend/src/views/flow/FlowEditor.vue
  15. 3
      frontend/src/views/flow/FlowList.vue

455
PLAN3.md

@ -0,0 +1,455 @@
# Enterprise AI Platform - 问题修复与优化计划 v3
> 基于 log/3.log 接口报错分析 + 流编排页面问题排查 + PLAN2.md 剩余优化项
> 日期: 2026-05-12
> 状态: **待实施**
---
## 一、log/3.log 接口报错分析
### 1.1 错误汇总
| # | 请求 | 状态码 | 错误类型 | 原因 |
|---|------|--------|---------|------|
| 1 | `GET /api/flow/definitions/%3Aid` | **422** | URL编码错误 | `:id` 被URL编码为 `%3Aid`,未替换为实际ID,与问题3同源 |
| 2 | `GET /api/wecom/config` | **500** | `NameError: name 'settings' is not defined` | `backend/modules/wecom/router.py` 第162行缺少 `from config import settings` |
| 3 | `ERROR modules/rag/knowledge.py:112` | (内部) | `Qdrant client is not installed` | 生产环境缺少 qdrant-client 依赖,知识检索功能不可用(非阻塞) |
### 1.2 错误2详细分析(企微配置500)
**文件**: `backend/modules/wecom/router.py`
- 第1-8行导入列表: `import uuid`, `import httpx`, `from fastapi import ...`, `from database import get_db`, `from models import User, ChatSession, ChatMessage`
- **缺失**: `from config import settings` ← 未导入
- 第162行: `"status": "active" if settings.WECOM_CORP_ID else "unconfigured"``settings` 未定义
- 第164行: `getattr(settings, 'WECOM_AGENT_ID', 0)` → 同样报错
- 第201-202行 `update_wecom_config` 中: `hasattr(settings, key)` / `setattr(settings, key, value)` → 也用到 `settings`
---
## 二、四个问题的根因分析与解决方案
---
### 问题1: `Unchecked runtime.lastError: The message port closed before a response was received.`
#### 根因分析
这不是应用代码的bug,而是 **Chrome浏览器扩展(Extension)** 产生的警告。当浏览器扩展(如 Vue DevTools、广告拦截器、翻译插件等)尝试通过消息端口与页面通信时,如果端口在收到响应前被关闭,就会触发此警告。
具体触发路径:
1. 页面URL: `/#/admin/flow/editor`
2. 页面包含 iframe 或使用 `chrome.runtime` API 的扩展
3. 扩展的消息端口在接收方响应前被关闭
#### 解决方案
**方案A(推荐)**: 在前端全局添加运行时错误过滤
文件: `frontend/src/main.ts`
```typescript
// 过滤 Chrome 扩展产生的非关键错误
if (typeof chrome !== 'undefined' && chrome.runtime) {
const originalError = window.onerror
window.onerror = function(message, source, lineno, colno, error) {
if (typeof message === 'string' && message.includes('message port closed')) {
return true // 静默忽略扩展通信错误
}
if (originalError) {
return originalError(message, source, lineno, colno, error)
}
return false
}
}
```
**方案B**: 在 `index.html` 中添加:
```html
<script>
window.addEventListener('error', function(e) {
if (e.message && e.message.includes('message port closed')) {
e.stopPropagation()
e.preventDefault()
}
}, true)
</script>
```
**影响范围**: 全局,不影响任何业务功能
---
### 问题2: 节点面板的节点无法拖到画布上(无任何报错)
#### 根因分析
**文件**: `frontend/src/views/flow/FlowEditor.vue`
问题出在**拖拽事件使用了错误的DOM事件类型**,导致 `dataTransfer` 始终为 `null`:
| 位置 | 当前代码 | 问题 |
|------|---------|------|
| 第25行 | `@mousedown="onDragStart($event, node)"` | ❌ 使用了 `mousedown` 事件,该事件对象上 **没有** `dataTransfer` 属性 |
| 第253行 | `function onDragStart(event: MouseEvent, ...)` | ❌ `MouseEvent` 类型上不存在 `dataTransfer` |
| 第255行 | `const dt = (event as any).dataTransfer` | ❌ 强行转类型,`mousedown` 事件的 `dataTransfer` 永远是 `undefined`,导致 `if (dt)` 条件永不成立,节点数据从未被设置 |
| 第29行 | `<div class="node-item">` | ❌ 缺少 `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
<div
v-for="node in nodeTypes"
:key="node.type"
class="node-item"
draggable="true"
@dragstart="onDragStart($event, node)"
>
```
**修改点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
<el-menu-item index="/admin/flow/market/:id">流详情</el-menu-item>
```
这段代码存在两个严重问题:
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
- <el-menu-item index="/admin/flow/market/:id">流详情</el-menu-item>
```
同时检查 `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
<template>
<div class="portal-switcher">
<el-radio-group
v-model="currentPortal"
size="small"
@change="handleSwitch"
fill="#409EFF"
>
<el-radio-button value="user">
<el-icon><Monitor /></el-icon>
<span class="portal-label">企业AI</span>
</el-radio-button>
<el-radio-button value="admin" v-if="canAccessAdmin">
<el-icon><Setting /></el-icon>
<span class="portal-label">管理后台</span>
</el-radio-button>
</el-radio-group>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { Monitor, Setting } from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const canAccessAdmin = computed(() => userStore.isAdmin)
const currentPortal = computed(() => {
return route.path.startsWith('/admin') ? 'admin' : 'user'
})
function handleSwitch(portal: string) {
if (portal === 'user' && route.path.startsWith('/admin')) {
router.push('/user/dashboard')
} else if (portal === 'admin' && !route.path.startsWith('/admin')) {
router.push('/admin')
}
}
</script>
<style scoped>
.portal-switcher {
margin-right: 16px;
}
.portal-label {
margin-left: 4px;
font-size: 12px;
}
:deep(.el-radio-button__inner) {
padding: 5px 12px;
}
</style>
```
##### 4.2 修改 MainLayout.vue
第88-102行区域的 `header-right`,在用户下拉菜单前插入 PortalSwitcher:
```diff
<div class="header-right">
+ <PortalSwitcher />
<el-dropdown @command="handleCommand">
...
</el-dropdown>
</div>
```
同时**移除**第72-75行的侧边栏管理后台入口:
```diff
- <el-menu-item v-if="userStore.isAdmin" index="/admin" style="margin-top: 20px; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 10px">
- <el-icon><Setting /></el-icon>
- <span>管理后台</span>
- </el-menu-item>
```
##### 4.3 修改 AdminLayout.vue
同样在 header-right 区域插入 PortalSwitcher:
```diff
<div class="header-right">
+ <PortalSwitcher />
<el-dropdown @command="handleCommand">
...
</el-dropdown>
</div>
```
同时**移除**第86-89行的侧边栏返回用户端入口:
```diff
- <el-menu-item index="/user/dashboard" style="margin-top: 20px; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 10px">
- <el-icon><User /></el-icon>
- <span>返回用户端</span>
- </el-menu-item>
```
---
## 三、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 警告
```

2
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"]
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

1
backend/agentscope_integration/tools/wecom_tools.py

@ -1,6 +1,5 @@
import httpx
import logging
import uuid
logger = logging.getLogger(__name__)

2
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,

16
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')
return {"code": 200, "message": "密码已修改"}

53
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

2
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

62
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 {

8
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";
}
}

56
frontend/src/components/common/PortalSwitcher.vue

@ -0,0 +1,56 @@
<template>
<div class="portal-switcher">
<el-radio-group
:model-value="currentPortal"
size="small"
@change="handleSwitch"
>
<el-radio-button value="user">
<el-icon><Monitor /></el-icon>
<span class="portal-label">企业AI</span>
</el-radio-button>
<el-radio-button value="admin" v-if="canAccessAdmin">
<el-icon><Setting /></el-icon>
<span class="portal-label">管理后台</span>
</el-radio-button>
</el-radio-group>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { Monitor, Setting } from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const canAccessAdmin = computed(() => userStore.isAdmin)
const currentPortal = computed(() => {
return route.path.startsWith('/admin') ? 'admin' : 'user'
})
function handleSwitch(portal: string) {
if (portal === 'user' && route.path.startsWith('/admin')) {
router.push('/user/dashboard')
} else if (portal === 'admin' && !route.path.startsWith('/admin')) {
router.push('/admin')
}
}
</script>
<style scoped>
.portal-switcher {
margin-right: 16px;
}
.portal-label {
margin-left: 4px;
font-size: 12px;
}
:deep(.el-radio-button__inner) {
padding: 5px 12px;
}
</style>

8
frontend/src/components/layout/AdminLayout.vue

@ -43,7 +43,6 @@
<el-menu-item index="/admin/flow/list">流列表</el-menu-item>
<el-menu-item index="/admin/flow/editor" v-if="can('flow:create')">流编辑器</el-menu-item>
<el-menu-item index="/admin/flow/market">流市场</el-menu-item>
<el-menu-item index="/admin/flow/market/:id">流详情</el-menu-item>
</el-sub-menu>
<el-sub-menu index="mcp" v-if="can('flow:create')">
@ -82,11 +81,6 @@
</template>
<el-menu-item index="/admin/system/monitor">系统监控</el-menu-item>
</el-sub-menu>
<el-menu-item index="/user/dashboard" style="margin-top: 20px; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 10px">
<el-icon><User /></el-icon>
<span>返回用户端</span>
</el-menu-item>
</el-menu>
</el-aside>
@ -100,6 +94,7 @@
</el-breadcrumb>
</div>
<div class="header-right">
<PortalSwitcher />
<el-dropdown @command="handleCommand">
<span class="user-info">
<el-icon><User /></el-icon>
@ -127,6 +122,7 @@ import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { Fold, User, ArrowDown, List, Connection, Search } from '@element-plus/icons-vue'
import PortalSwitcher from '@/components/common/PortalSwitcher.vue'
const route = useRoute()
const router = useRouter()

7
frontend/src/components/layout/MainLayout.vue

@ -68,11 +68,6 @@
<el-icon><Tools /></el-icon>
<span>系统配置</span>
</el-menu-item>
<el-menu-item v-if="userStore.isAdmin" index="/admin" style="margin-top: 20px; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 10px">
<el-icon><Setting /></el-icon>
<span>管理后台</span>
</el-menu-item>
</el-menu>
</el-aside>
@ -86,6 +81,7 @@
</el-breadcrumb>
</div>
<div class="header-right">
<PortalSwitcher />
<el-dropdown @command="handleCommand">
<span class="user-info">
<el-icon><User /></el-icon>
@ -113,6 +109,7 @@ import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { Fold, User, ArrowDown, Tools, Search } from '@element-plus/icons-vue'
import PortalSwitcher from '@/components/common/PortalSwitcher.vue'
const route = useRoute()
const router = useRouter()

9
frontend/src/main.ts

@ -15,4 +15,11 @@ for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: undefined })
app.mount('#app')
app.mount('#app')
window.addEventListener('error', function(e) {
if (e.message && e.message.includes('message port closed')) {
e.stopPropagation()
e.preventDefault()
}
}, true)

30
frontend/src/views/flow/FlowEditor.vue

@ -22,7 +22,8 @@
v-for="node in nodeTypes"
:key="node.type"
class="node-item"
@mousedown="onDragStart($event, node)"
draggable="true"
@dragstart="onDragStart($event, node)"
>
<el-icon :size="18"><component :is="node.icon" /></el-icon>
<span>{{ node.label }}</span>
@ -250,11 +251,10 @@ const selectedNode = computed(() => {
return elements.value.find((el: any) => el.id === selectedNodeId.value && el.type !== 'edge') || null
})
function onDragStart(event: MouseEvent, node: (typeof nodeTypes)[0]) {
const dt = (event as any).dataTransfer as DataTransfer | undefined
if (vueFlowRef.value && dt) {
dt.setData('application/vueflow', JSON.stringify(node))
dt.effectAllowed = 'move'
function onDragStart(event: DragEvent, node: (typeof nodeTypes)[0]) {
if (event.dataTransfer) {
event.dataTransfer.setData('application/vueflow', JSON.stringify(node))
event.dataTransfer.effectAllowed = 'move'
}
}
@ -263,10 +263,17 @@ function onDrop(event: DragEvent) {
if (!dataStr) return
const nodeData = JSON.parse(dataStr)
const position = (vueFlowRef.value as any)?.screenToFlowCoordinate?.({
x: event.clientX,
y: event.clientY,
}) || { x: event.clientX - 300, y: event.clientY - 200 }
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 = {
@ -277,7 +284,6 @@ function onDrop(event: DragEvent) {
label: nodeData.label,
type: nodeData.type,
typeDesc: nodeData.typeDesc,
icon: nodeData.type,
color: colorMap[nodeData.type] || '#409EFF',
config: nodeData.type === 'llm'
? { system_prompt: '', model: 'gpt-4o-mini', temperature: 0.7 }
@ -287,7 +293,7 @@ function onDrop(event: DragEvent) {
connectable: true,
}
elements.value.push(newNode)
elements.value = [...elements.value, newNode]
}
function onConnect(connection: any) {

3
frontend/src/views/flow/FlowList.vue

@ -19,8 +19,9 @@
<el-tag :type="row.status === 'published' ? 'success' : 'info'">{{ row.status === 'published' ? '已上架' : '草稿' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="280">
<el-table-column label="操作" width="340">
<template #default="{ row }">
<el-button size="small" @click="$router.push(`/admin/flow/market/${row.id}`)">详情</el-button>
<el-button size="small" @click="$router.push(`/admin/flow/editor/${row.id}`)">编辑</el-button>
<el-button size="small" @click="handleTest(row)">测试</el-button>
<el-button v-if="row.status === 'draft'" size="small" type="success" @click="handlePublish(row)">上架</el-button>

Loading…
Cancel
Save