Browse Source

功能开始正常化

master
MSI-7950X\刘泽明 1 week 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. 14
      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. 7
      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 . . 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 httpx
import logging import logging
import uuid
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

2
backend/modules/audit/router.py

@ -54,7 +54,7 @@ async def list_logs(
logs = result.scalars().all() logs = result.scalars().all()
return AuditLogPage( 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, total=total,
page=page, page=page,
page_size=page_size, page_size=page_size,

14
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 schemas import LoginRequest, TokenResponse, UserOut, RoleOut
from config import settings 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"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
async def get_permission_codes(db: AsyncSession, role_ids: list[uuid.UUID]) -> list[str]: 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") @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 "" corp_id = settings.WECOM_CORP_ID or ""
if not corp_id: if not corp_id:
return {"code": 400, "message": "请先配置 WECOM_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" 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}} return {"code": 200, "data": {"url": url}}
@ -156,7 +162,3 @@ async def change_password(
user.password_hash = hash_password(new_pw) user.password_hash = hash_password(new_pw)
await db.commit() await db.commit()
return {"code": 200, "message": "密码已修改"} return {"code": 200, "message": "密码已修改"}
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')

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) user_text = msg.get_text_content() if hasattr(msg, 'get_text_content') else str(msg)
if not self.condition: 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结果: 条件满足,继续执行。" try:
return Msg(self.name, result_text, "assistant") 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: async def observe(self, msg) -> None:
pass pass

2
backend/modules/task/router.py

@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db from database import get_db
from models import Task from models import Task, User
from schemas import TaskCreate, TaskUpdate, TaskOut from schemas import TaskCreate, TaskUpdate, TaskOut
from modules.org.router import _get_subordinate_ids from modules.org.router import _get_subordinate_ids

62
backend/modules/wecom/router.py

@ -1,9 +1,9 @@
import uuid import uuid
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db from database import get_db
from config import settings
from models import User, ChatSession, ChatMessage from models import User, ChatSession, ChatMessage
router = APIRouter(prefix="/api/wecom", tags=["wecom"]) 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") @router.get("/config")
async def get_wecom_config(request: Request): async def get_wecom_config(request: Request):
return { return {

8
frontend/nginx.conf

@ -6,6 +6,9 @@ server {
location / { location / {
try_files $uri $uri/ /index.html; 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/ { location /api/ {
@ -25,4 +28,9 @@ server {
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; 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/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/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">流市场</el-menu-item>
<el-menu-item index="/admin/flow/market/:id">流详情</el-menu-item>
</el-sub-menu> </el-sub-menu>
<el-sub-menu index="mcp" v-if="can('flow:create')"> <el-sub-menu index="mcp" v-if="can('flow:create')">
@ -82,11 +81,6 @@
</template> </template>
<el-menu-item index="/admin/system/monitor">系统监控</el-menu-item> <el-menu-item index="/admin/system/monitor">系统监控</el-menu-item>
</el-sub-menu> </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-menu>
</el-aside> </el-aside>
@ -100,6 +94,7 @@
</el-breadcrumb> </el-breadcrumb>
</div> </div>
<div class="header-right"> <div class="header-right">
<PortalSwitcher />
<el-dropdown @command="handleCommand"> <el-dropdown @command="handleCommand">
<span class="user-info"> <span class="user-info">
<el-icon><User /></el-icon> <el-icon><User /></el-icon>
@ -127,6 +122,7 @@ import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { Fold, User, ArrowDown, List, Connection, Search } from '@element-plus/icons-vue' import { Fold, User, ArrowDown, List, Connection, Search } from '@element-plus/icons-vue'
import PortalSwitcher from '@/components/common/PortalSwitcher.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()

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

@ -68,11 +68,6 @@
<el-icon><Tools /></el-icon> <el-icon><Tools /></el-icon>
<span>系统配置</span> <span>系统配置</span>
</el-menu-item> </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-menu>
</el-aside> </el-aside>
@ -86,6 +81,7 @@
</el-breadcrumb> </el-breadcrumb>
</div> </div>
<div class="header-right"> <div class="header-right">
<PortalSwitcher />
<el-dropdown @command="handleCommand"> <el-dropdown @command="handleCommand">
<span class="user-info"> <span class="user-info">
<el-icon><User /></el-icon> <el-icon><User /></el-icon>
@ -113,6 +109,7 @@ import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { Fold, User, ArrowDown, Tools, Search } from '@element-plus/icons-vue' import { Fold, User, ArrowDown, Tools, Search } from '@element-plus/icons-vue'
import PortalSwitcher from '@/components/common/PortalSwitcher.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()

7
frontend/src/main.ts

@ -16,3 +16,10 @@ app.use(createPinia())
app.use(router) app.use(router)
app.use(ElementPlus, { locale: undefined }) 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" v-for="node in nodeTypes"
:key="node.type" :key="node.type"
class="node-item" class="node-item"
@mousedown="onDragStart($event, node)" draggable="true"
@dragstart="onDragStart($event, node)"
> >
<el-icon :size="18"><component :is="node.icon" /></el-icon> <el-icon :size="18"><component :is="node.icon" /></el-icon>
<span>{{ node.label }}</span> <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 return elements.value.find((el: any) => el.id === selectedNodeId.value && el.type !== 'edge') || null
}) })
function onDragStart(event: MouseEvent, node: (typeof nodeTypes)[0]) { function onDragStart(event: DragEvent, node: (typeof nodeTypes)[0]) {
const dt = (event as any).dataTransfer as DataTransfer | undefined if (event.dataTransfer) {
if (vueFlowRef.value && dt) { event.dataTransfer.setData('application/vueflow', JSON.stringify(node))
dt.setData('application/vueflow', JSON.stringify(node)) event.dataTransfer.effectAllowed = 'move'
dt.effectAllowed = 'move'
} }
} }
@ -263,10 +263,17 @@ function onDrop(event: DragEvent) {
if (!dataStr) return if (!dataStr) return
const nodeData = JSON.parse(dataStr) const nodeData = JSON.parse(dataStr)
const position = (vueFlowRef.value as any)?.screenToFlowCoordinate?.({
x: event.clientX, let position = { x: 100, y: 100 }
y: event.clientY, try {
}) || { x: event.clientX - 300, y: event.clientY - 200 } 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 id = `node_${nodeCounter++}`
const newNode: any = { const newNode: any = {
@ -277,7 +284,6 @@ function onDrop(event: DragEvent) {
label: nodeData.label, label: nodeData.label,
type: nodeData.type, type: nodeData.type,
typeDesc: nodeData.typeDesc, typeDesc: nodeData.typeDesc,
icon: nodeData.type,
color: colorMap[nodeData.type] || '#409EFF', color: colorMap[nodeData.type] || '#409EFF',
config: nodeData.type === 'llm' config: nodeData.type === 'llm'
? { system_prompt: '', model: 'gpt-4o-mini', temperature: 0.7 } ? { system_prompt: '', model: 'gpt-4o-mini', temperature: 0.7 }
@ -287,7 +293,7 @@ function onDrop(event: DragEvent) {
connectable: true, connectable: true,
} }
elements.value.push(newNode) elements.value = [...elements.value, newNode]
} }
function onConnect(connection: any) { 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> <el-tag :type="row.status === 'published' ? 'success' : 'info'">{{ row.status === 'published' ? '已上架' : '草稿' }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="280"> <el-table-column label="操作" width="340">
<template #default="{ row }"> <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="$router.push(`/admin/flow/editor/${row.id}`)">编辑</el-button>
<el-button size="small" @click="handleTest(row)">测试</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> <el-button v-if="row.status === 'draft'" size="small" type="success" @click="handlePublish(row)">上架</el-button>

Loading…
Cancel
Save