mock数据,AI对话,全部应用
This commit is contained in:
382
urbanLifelineWeb/packages/platform/ROUTE_GUIDE.md
Normal file
382
urbanLifelineWeb/packages/platform/ROUTE_GUIDE.md
Normal file
@@ -0,0 +1,382 @@
|
||||
# Platform 路由集成指南
|
||||
|
||||
## 快速开始
|
||||
|
||||
### TL;DR
|
||||
|
||||
1. **shared 提供**:路由生成工具、菜单处理、设备检测
|
||||
2. **platform 定义**:布局组件映射、视图加载器
|
||||
3. **platform 生成**:调用 `generateRoutes()` 生成自己的路由
|
||||
|
||||
```typescript
|
||||
// 1. 导入工具
|
||||
import { generateRoutes, type RouteGeneratorConfig } from 'shared/utils/route'
|
||||
import type { SysMenu } from 'shared/types'
|
||||
|
||||
// 2. 配置生成器
|
||||
const config: RouteGeneratorConfig = {
|
||||
layoutMap: { /* 布局映射 */ },
|
||||
viewLoader: (path) => { /* 组件加载 */ }
|
||||
}
|
||||
|
||||
// 3. 生成路由
|
||||
const routes = generateRoutes(menus, config)
|
||||
|
||||
// 4. 添加到路由
|
||||
routes.forEach(route => router.addRoute(route))
|
||||
```
|
||||
|
||||
## 架构说明
|
||||
|
||||
Platform 使用 shared 提供的路由生成工具来动态生成路由。架构如下:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ shared │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ utils/route/route-generator.ts │ │
|
||||
│ │ - generateRoutes() 路由生成 │ │
|
||||
│ │ - buildMenuTree() 菜单树构建 │ │
|
||||
│ │ - filterMenusByPermissions() 权限过滤 │ │
|
||||
│ │ - findMenuByPath() 路径查找 │ │
|
||||
│ │ - getMenuPath() 面包屑路径 │ │
|
||||
│ │ - getFirstAccessibleMenuUrl() 首页跳转 │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ utils/device.ts │ │
|
||||
│ │ - getDeviceType() 设备类型检测 │ │
|
||||
│ │ - isMobile() 移动端判断 │ │
|
||||
│ │ - useDevice() 响应式 Hook │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ types/sys/menu.ts & types/enums.ts │ │
|
||||
│ │ - SysMenu 菜单接口 │ │
|
||||
│ │ - MenuType 菜单类型枚举 │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ Module Federation (远程模块) │
|
||||
│ - shared/utils/route │
|
||||
│ - shared/utils/device │
|
||||
│ - shared/types │
|
||||
└─────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ platform │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ src/router/index.ts │ │
|
||||
│ │ 1. 定义布局组件映射 (platformLayoutMap) │ │
|
||||
│ │ 2. 定义视图组件加载器 (viewLoader) │ │
|
||||
│ │ 3. 调用 generateRoutes() 生成动态路由 │ │
|
||||
│ │ 4. 添加到 Vue Router │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ src/layouts/ │ │
|
||||
│ │ - SidebarLayout.vue 侧边栏布局 │ │
|
||||
│ │ - NavigationLayout.vue 导航布局(如需) │ │
|
||||
│ │ - BasicLayout.vue 基础布局(如需) │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ src/views/ │ │
|
||||
│ │ - 各种页面组件 │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 使用步骤
|
||||
|
||||
### 1. 在 router/index.ts 中配置路由生成器
|
||||
|
||||
```typescript
|
||||
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
|
||||
import { generateRoutes, type RouteGeneratorConfig } from 'shared/utils/route'
|
||||
import type { SysMenu } from 'shared/types'
|
||||
import { SidebarLayout } from '../layouts'
|
||||
|
||||
// 1. 定义布局组件映射
|
||||
const platformLayoutMap: Record<string, () => Promise<any>> = {
|
||||
'SidebarLayout': () => Promise.resolve({ default: SidebarLayout }),
|
||||
'NavigationLayout': () => Promise.resolve({ default: SidebarLayout }), // 可复用或自定义
|
||||
'BasicLayout': () => Promise.resolve({ default: SidebarLayout }),
|
||||
}
|
||||
|
||||
// 2. 定义视图组件加载器
|
||||
const VIEW_MODULES = import.meta.glob('../views/**/*.vue')
|
||||
|
||||
function viewLoader(componentPath: string): (() => Promise<any>) | null {
|
||||
// 将后台路径转换为实际路径
|
||||
let path = componentPath
|
||||
if (!path.startsWith('../')) {
|
||||
if (!path.startsWith('/')) {
|
||||
path = '/' + path
|
||||
}
|
||||
path = '../views' + path
|
||||
}
|
||||
if (!path.endsWith('.vue')) {
|
||||
path += '.vue'
|
||||
}
|
||||
|
||||
const loader = VIEW_MODULES[path]
|
||||
return loader ? (loader as () => Promise<any>) : null
|
||||
}
|
||||
|
||||
// 3. 创建路由生成器配置
|
||||
const routeConfig: RouteGeneratorConfig = {
|
||||
layoutMap: platformLayoutMap,
|
||||
viewLoader,
|
||||
staticRoutes: routes, // 可选:静态路由
|
||||
notFoundComponent: () => import('../views/public/404.vue') // 可选
|
||||
}
|
||||
|
||||
// 4. 动态添加路由的函数
|
||||
export function addDynamicRoutes(menus: SysMenu[]) {
|
||||
const dynamicRoutes = generateRoutes(menus, routeConfig)
|
||||
|
||||
dynamicRoutes.forEach(route => {
|
||||
router.addRoute(route)
|
||||
})
|
||||
|
||||
console.log('✅ 动态路由已添加', dynamicRoutes.length, '个')
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 在应用初始化时调用
|
||||
|
||||
```typescript
|
||||
// main.ts 或登录成功后
|
||||
import { addDynamicRoutes } from './router'
|
||||
import { menuAPI } from 'shared/api'
|
||||
|
||||
// 获取用户菜单
|
||||
const response = await menuAPI.getUserMenus()
|
||||
if (response.success && response.data) {
|
||||
// 添加动态路由
|
||||
addDynamicRoutes(response.data)
|
||||
|
||||
// 跳转到首页或指定页面
|
||||
router.push('/home')
|
||||
}
|
||||
```
|
||||
|
||||
## 菜单数据格式
|
||||
|
||||
```typescript
|
||||
interface SysMenu {
|
||||
menuID: string // 菜单ID,作为路由 name
|
||||
parentID?: string // 父菜单ID,'0' 表示根菜单
|
||||
name: string // 菜单名称
|
||||
url?: string // 路由路径,如 '/user/profile'
|
||||
type: MenuType // 菜单类型
|
||||
icon?: string // 图标
|
||||
component?: string // 组件路径,如 'user/profile/ProfileView'
|
||||
layout?: string // 布局名称,如 'SidebarLayout'
|
||||
orderNum?: number // 排序号
|
||||
permission?: string // 权限标识
|
||||
hidden?: boolean // 是否隐藏
|
||||
children?: SysMenu[] // 子菜单
|
||||
}
|
||||
|
||||
enum MenuType {
|
||||
NAVIGATION = 'navigation', // 导航菜单(顶部导航)
|
||||
SIDEBAR = 'sidebar', // 侧边栏菜单
|
||||
MENU = 'menu', // 普通菜单
|
||||
PAGE = 'page', // 页面(独立路由)
|
||||
BUTTON = 'button' // 按钮(不生成路由)
|
||||
}
|
||||
```
|
||||
|
||||
## 示例菜单数据
|
||||
|
||||
```typescript
|
||||
const menus: SysMenu[] = [
|
||||
{
|
||||
menuID: 'user-center',
|
||||
parentID: '0',
|
||||
name: '用户中心',
|
||||
url: '/user',
|
||||
type: MenuType.NAVIGATION,
|
||||
icon: 'User',
|
||||
layout: 'SidebarLayout',
|
||||
orderNum: 1,
|
||||
children: [
|
||||
{
|
||||
menuID: 'user-profile',
|
||||
parentID: 'user-center',
|
||||
name: '个人信息',
|
||||
url: '/user/profile',
|
||||
type: MenuType.MENU,
|
||||
component: 'user/profile/ProfileView',
|
||||
orderNum: 1
|
||||
},
|
||||
{
|
||||
menuID: 'user-settings',
|
||||
parentID: 'user-center',
|
||||
name: '账号设置',
|
||||
url: '/user/settings',
|
||||
type: MenuType.MENU,
|
||||
component: 'user/settings/SettingsView',
|
||||
orderNum: 2
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 布局组件要求
|
||||
|
||||
布局组件必须包含 `<router-view />` 用于渲染子路由:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="sidebar-layout">
|
||||
<aside class="sidebar">
|
||||
<!-- 侧边栏内容 -->
|
||||
</aside>
|
||||
<main class="content">
|
||||
<router-view /> <!-- 重要!用于渲染页面组件 -->
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## 响应式布局(可选)
|
||||
|
||||
如果需要移动端适配,可以使用 shared 的设备检测工具:
|
||||
|
||||
```typescript
|
||||
import { getDeviceType, DeviceType } from 'shared/utils/device'
|
||||
|
||||
const deviceType = getDeviceType()
|
||||
if (deviceType === DeviceType.MOBILE) {
|
||||
// 移动端逻辑
|
||||
}
|
||||
```
|
||||
|
||||
## 工具方法说明
|
||||
|
||||
所有工具方法从 `shared/utils/route` 导入:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
generateRoutes,
|
||||
buildMenuTree,
|
||||
filterMenusByPermissions,
|
||||
findMenuByPath,
|
||||
getMenuPath,
|
||||
getFirstAccessibleMenuUrl,
|
||||
type RouteGeneratorConfig
|
||||
} from 'shared/utils/route'
|
||||
|
||||
import type { SysMenu, MenuType } from 'shared/types'
|
||||
```
|
||||
|
||||
### generateRoutes(menus, config)
|
||||
根据菜单生成路由配置数组
|
||||
|
||||
**参数:**
|
||||
- `menus: SysMenu[]` - 菜单列表
|
||||
- `config: RouteGeneratorConfig` - 路由生成器配置
|
||||
|
||||
**返回:** `RouteRecordRaw[]`
|
||||
|
||||
### buildMenuTree(menus, staticRoutes?)
|
||||
将扁平菜单列表转换为树形结构
|
||||
|
||||
**参数:**
|
||||
- `menus: SysMenu[]` - 菜单列表
|
||||
- `staticRoutes?: RouteRecordRaw[]` - 静态路由(可选)
|
||||
|
||||
**返回:** `SysMenu[]`
|
||||
|
||||
### filterMenusByPermissions(menus, permissions)
|
||||
根据权限过滤菜单
|
||||
|
||||
**参数:**
|
||||
- `menus: SysMenu[]` - 菜单列表
|
||||
- `permissions: string[]` - 权限列表
|
||||
|
||||
**返回:** `SysMenu[]`
|
||||
|
||||
### findMenuByPath(menus, path)
|
||||
根据路径查找菜单项
|
||||
|
||||
**参数:**
|
||||
- `menus: SysMenu[]` - 菜单列表
|
||||
- `path: string` - 路由路径
|
||||
|
||||
**返回:** `SysMenu | null`
|
||||
|
||||
### getMenuPath(menus, targetMenuId)
|
||||
获取菜单路径数组(用于面包屑导航)
|
||||
|
||||
**参数:**
|
||||
- `menus: SysMenu[]` - 菜单列表
|
||||
- `targetMenuId: string` - 目标菜单ID
|
||||
|
||||
**返回:** `SysMenu[]`
|
||||
|
||||
### getFirstAccessibleMenuUrl(menus)
|
||||
获取第一个可访问的菜单URL(用于登录后跳转)
|
||||
|
||||
**参数:**
|
||||
- `menus: SysMenu[]` - 菜单列表
|
||||
|
||||
**返回:** `string | null`
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **shared 服务必须先启动**:因为使用 Module Federation,platform 依赖 shared 的远程模块
|
||||
2. **布局组件必须包含 router-view**:否则子路由无法渲染
|
||||
3. **组件路径映射**:确保 `viewLoader` 能正确加载组件
|
||||
4. **静态路由优先**:如果菜单标记为 `__STATIC_ROUTE__`,不会重复生成路由
|
||||
5. **路由守卫**:记得在 `router.beforeEach` 中添加权限检查
|
||||
|
||||
## 调试技巧
|
||||
|
||||
1. **查看生成的路由**:
|
||||
```typescript
|
||||
console.log('所有路由:', router.getRoutes())
|
||||
console.log('路由数量:', router.getRoutes().length)
|
||||
```
|
||||
|
||||
2. **查看菜单树结构**:
|
||||
```typescript
|
||||
import { buildMenuTree } from 'shared/utils/route'
|
||||
|
||||
const tree = buildMenuTree(menus)
|
||||
console.log('菜单树:', JSON.stringify(tree, null, 2))
|
||||
```
|
||||
|
||||
3. **查看当前路由信息**:
|
||||
```typescript
|
||||
console.log('当前路由:', router.currentRoute.value)
|
||||
console.log('路由路径:', router.currentRoute.value.path)
|
||||
console.log('路由参数:', router.currentRoute.value.params)
|
||||
console.log('路由元数据:', router.currentRoute.value.meta)
|
||||
```
|
||||
|
||||
4. **测试菜单查找**:
|
||||
```typescript
|
||||
import { findMenuByPath, getMenuPath } from 'shared/utils/route'
|
||||
|
||||
// 根据路径查找菜单
|
||||
const menu = findMenuByPath(menus, '/user/profile')
|
||||
console.log('找到的菜单:', menu)
|
||||
|
||||
// 获取面包屑路径
|
||||
const breadcrumb = getMenuPath(menus, 'user-profile')
|
||||
console.log('面包屑:', breadcrumb.map(m => m.name).join(' > '))
|
||||
```
|
||||
|
||||
5. **检查视图组件加载**:
|
||||
```typescript
|
||||
// 在 viewLoader 中添加日志
|
||||
function viewLoader(componentPath: string) {
|
||||
console.log('尝试加载组件:', componentPath)
|
||||
const path = /* 转换逻辑 */
|
||||
const loader = VIEW_MODULES[path]
|
||||
console.log('找到的加载器:', loader ? '✅' : '❌')
|
||||
return loader ? (loader as () => Promise<any>) : null
|
||||
}
|
||||
```
|
||||
56
urbanLifelineWeb/packages/platform/src/api/ai/agent.ts
Normal file
56
urbanLifelineWeb/packages/platform/src/api/ai/agent.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { api } from 'shared/api'
|
||||
import type { ResultDomain } from 'shared/types'
|
||||
|
||||
|
||||
/**
|
||||
* 认证 API
|
||||
* 通过 Gateway (8180) 访问 Auth Service (8181)
|
||||
* 路由规则:/urban-lifeline/auth/** → auth-service/urban-lifeline/auth/**
|
||||
*/
|
||||
export const agentAPI = {
|
||||
baseUrl: "/urban-lifeline/xxx",//随后端更新
|
||||
|
||||
/**
|
||||
* 获取智能体列表
|
||||
* @returns 智能体列表
|
||||
*/
|
||||
async agentList(): Promise<ResultDomain<any>> {
|
||||
// const response = await api.post<null>(`${this.baseUrl}/send-sms-code`, { phone })
|
||||
// return response.data
|
||||
return [];
|
||||
},
|
||||
|
||||
/**
|
||||
* 新增智能体
|
||||
* @param agent 智能体信息
|
||||
* @returns 智能体信息
|
||||
*/
|
||||
async addAgent(agent: any): Promise<ResultDomain<any>> {
|
||||
// const response = await api.post<null>(`${this.baseUrl}/send-sms-code`, { phone })
|
||||
// return response.data
|
||||
return [];
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新智能体
|
||||
* @param agent 智能体信息
|
||||
* @returns 智能体信息
|
||||
*/
|
||||
async updateAgent(agent: any): Promise<ResultDomain<any>> {
|
||||
// const response = await api.post<null>(`${this.baseUrl}/send-sms-code`, { phone })
|
||||
// return response.data
|
||||
return [];
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除智能体
|
||||
* @param agentId 智能体ID
|
||||
* @returns 智能体信息
|
||||
*/
|
||||
async deleteAgent(agentId: string): Promise<ResultDomain<any>> {
|
||||
// const response = await api.post<null>(`${this.baseUrl}/send-sms-code`, { phone })
|
||||
// return response.data
|
||||
return [];
|
||||
},
|
||||
|
||||
}
|
||||
35
urbanLifelineWeb/packages/platform/src/api/ai/chat.ts
Normal file
35
urbanLifelineWeb/packages/platform/src/api/ai/chat.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { api } from 'shared/api'
|
||||
import type { ResultDomain } from 'shared/types'
|
||||
|
||||
|
||||
/**
|
||||
* 认证 API
|
||||
* 通过 Gateway (8180) 访问 Auth Service (8181)
|
||||
* 路由规则:/urban-lifeline/auth/** → auth-service/urban-lifeline/auth/**
|
||||
*/
|
||||
export const chatAPI = {
|
||||
baseUrl: "/urban-lifeline/xxx",//随后端更新
|
||||
|
||||
/**
|
||||
* 根据agentId获取聊天会话id
|
||||
* @param agentId agentId
|
||||
* @returns 发送结果
|
||||
*/
|
||||
async chatHistory(agentId: string): Promise<ResultDomain<string>> {
|
||||
// const response = await api.post<null>(`${this.baseUrl}/send-sms-code`, { phone })
|
||||
// return response.data
|
||||
return [];
|
||||
},
|
||||
|
||||
/**
|
||||
* 根据agentId和conversationId获取聊天记录
|
||||
* @param agentId agentId
|
||||
* @param conversationId conversationId
|
||||
* @returns 发送结果
|
||||
*/
|
||||
async chatConversation(agentId: string, conversationId: string): Promise<ResultDomain<any>> {
|
||||
// const response = await api.post<null>(`${this.baseUrl}/send-sms-code`, { phone })
|
||||
// return response.data
|
||||
return [];
|
||||
},
|
||||
}
|
||||
2
urbanLifelineWeb/packages/platform/src/api/ai/index.ts
Normal file
2
urbanLifelineWeb/packages/platform/src/api/ai/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './agent'
|
||||
export * from './chat'
|
||||
1
urbanLifelineWeb/packages/platform/src/api/index.ts
Normal file
1
urbanLifelineWeb/packages/platform/src/api/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './ai'
|
||||
@@ -113,8 +113,7 @@ interface MenuItem {
|
||||
key: string
|
||||
label: string
|
||||
icon: string
|
||||
path?: string
|
||||
iframeUrl?: string
|
||||
url?: string
|
||||
type: 'route' | 'iframe'
|
||||
}
|
||||
|
||||
@@ -126,57 +125,90 @@ const collapsed = ref(false)
|
||||
const activeMenu = ref('home')
|
||||
const iframeLoading = ref(false)
|
||||
const iframeRef = ref<HTMLIFrameElement>()
|
||||
const userName = ref('管理员')
|
||||
|
||||
// 菜单配置
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
key: 'home',
|
||||
label: '工作台',
|
||||
icon: 'Grid',
|
||||
path: '/home',
|
||||
type: 'route'
|
||||
},
|
||||
{
|
||||
key: 'bidding',
|
||||
label: '招标助手',
|
||||
icon: 'Document',
|
||||
iframeUrl: 'http://localhost:5002',
|
||||
type: 'iframe'
|
||||
},
|
||||
{
|
||||
key: 'service',
|
||||
label: '泰豪小电',
|
||||
icon: 'Service',
|
||||
iframeUrl: 'http://localhost:5003',
|
||||
type: 'iframe'
|
||||
},
|
||||
{
|
||||
key: 'workflow',
|
||||
label: '智能体编排',
|
||||
icon: 'Connection',
|
||||
iframeUrl: 'http://localhost:3000',
|
||||
type: 'iframe'
|
||||
},
|
||||
{
|
||||
key: 'chat',
|
||||
label: 'AI助手',
|
||||
icon: 'ChatDotRound',
|
||||
path: '/chat',
|
||||
type: 'route'
|
||||
// 从 LocalStorage 获取用户名
|
||||
function getUserName(): string {
|
||||
try {
|
||||
const loginDomainStr = localStorage.getItem('loginDomain')
|
||||
if (loginDomainStr) {
|
||||
const loginDomain = JSON.parse(loginDomainStr)
|
||||
return loginDomain.user?.username || loginDomain.userInfo?.username || '管理员'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 获取用户名失败:', error)
|
||||
}
|
||||
]
|
||||
return '管理员'
|
||||
}
|
||||
|
||||
const userName = ref(getUserName())
|
||||
|
||||
/**
|
||||
* 从 LocalStorage 加载菜单
|
||||
*/
|
||||
function loadMenuFromStorage(): MenuItem[] {
|
||||
try {
|
||||
const loginDomainStr = localStorage.getItem('loginDomain')
|
||||
if (!loginDomainStr) {
|
||||
console.warn('⚠️ 未找到 loginDomain')
|
||||
return []
|
||||
}
|
||||
|
||||
const loginDomain = JSON.parse(loginDomainStr)
|
||||
const userViews = loginDomain.userViews || []
|
||||
|
||||
console.log('📋 加载用户视图:', userViews)
|
||||
|
||||
// 过滤出 SidebarLayout 的顶级菜单(没有 parentId)
|
||||
const sidebarViews = userViews.filter((view: any) =>
|
||||
view.layout === 'SidebarLayout' &&
|
||||
!view.parentId &&
|
||||
view.type === 1 // type 1 是侧边栏菜单
|
||||
)
|
||||
|
||||
// 按 orderNum 排序
|
||||
sidebarViews.sort((a: any, b: any) => (a.orderNum || 0) - (b.orderNum || 0))
|
||||
|
||||
// 转换为 MenuItem 格式
|
||||
const menuItems: MenuItem[] = sidebarViews.map((view: any) => {
|
||||
// 根据 viewType 或 iframeUrl 判断是 route 还是 iframe
|
||||
const isIframe = view.viewType === 'iframe' || !!view.iframeUrl
|
||||
|
||||
// 确定菜单的路由路径
|
||||
let menuUrl = view.url
|
||||
if (isIframe && view.url && (view.url.startsWith('http://') || view.url.startsWith('https://'))) {
|
||||
// iframe 类型且 url 是外部链接,使用 viewId 作为路由路径
|
||||
menuUrl = `/${view.viewId}`
|
||||
}
|
||||
|
||||
return {
|
||||
key: view.viewId || view.name,
|
||||
label: view.name,
|
||||
icon: view.icon || 'Grid',
|
||||
url: menuUrl,
|
||||
type: isIframe ? 'iframe' : 'route'
|
||||
}
|
||||
})
|
||||
|
||||
console.log('✅ 侧边栏菜单:', menuItems)
|
||||
return menuItems
|
||||
} catch (error) {
|
||||
console.error('❌ 加载菜单失败:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// 菜单配置(从 LocalStorage 加载)
|
||||
const menuItems = ref<MenuItem[]>(loadMenuFromStorage())
|
||||
|
||||
// 当前菜单项
|
||||
const currentMenuItem = computed(() => {
|
||||
return menuItems.find(item => item.key === activeMenu.value)
|
||||
return menuItems.value.find(item => item.key === activeMenu.value)
|
||||
})
|
||||
|
||||
// 当前 iframe URL
|
||||
// 当前 iframe URL(从路由 meta 读取)
|
||||
const currentIframeUrl = computed(() => {
|
||||
return currentMenuItem.value?.type === 'iframe'
|
||||
? currentMenuItem.value.iframeUrl
|
||||
: null
|
||||
const meta = route.meta as any
|
||||
return meta?.iframeUrl || null
|
||||
})
|
||||
|
||||
// 切换侧边栏
|
||||
@@ -188,11 +220,12 @@ const toggleSidebar = () => {
|
||||
const handleMenuClick = (item: MenuItem) => {
|
||||
activeMenu.value = item.key
|
||||
|
||||
if (item.type === 'route' && item.path) {
|
||||
router.push(item.path)
|
||||
} else if (item.type === 'iframe') {
|
||||
iframeLoading.value = true
|
||||
// iframe 模式不需要路由跳转
|
||||
// 所有菜单都通过路由跳转
|
||||
if (item.url) {
|
||||
router.push(item.url)
|
||||
if (item.type === 'iframe') {
|
||||
iframeLoading.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,6 +257,7 @@ const handleUserCommand = (command: string) => {
|
||||
router.push('/settings')
|
||||
break
|
||||
case 'logout':
|
||||
localStorage.clear()
|
||||
ElMessage.success('退出成功')
|
||||
router.push('/login')
|
||||
break
|
||||
@@ -234,9 +268,16 @@ const handleUserCommand = (command: string) => {
|
||||
watch(
|
||||
() => route.path,
|
||||
(newPath) => {
|
||||
const menuItem = menuItems.find(item => item.path === newPath)
|
||||
// 查找匹配的菜单项(route 或 iframe 类型)
|
||||
const menuItem = menuItems.value.find((item: MenuItem) => item.url === newPath)
|
||||
if (menuItem) {
|
||||
activeMenu.value = menuItem.key
|
||||
} else {
|
||||
// 如果路径不匹配,尝试通过 route.name 匹配 viewId
|
||||
const menuByName = menuItems.value.find((item: MenuItem) => item.key === route.name)
|
||||
if (menuByName) {
|
||||
activeMenu.value = menuByName.key
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
|
||||
147
urbanLifelineWeb/packages/platform/src/router/dynamicRoute.ts
Normal file
147
urbanLifelineWeb/packages/platform/src/router/dynamicRoute.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 动态路由生成模块(Platform 特定)
|
||||
*
|
||||
* 职责:
|
||||
* 1. 提供 Platform 特定的布局和组件配置
|
||||
* 2. 调用 shared 中的通用路由生成方法
|
||||
* 3. 将生成的路由添加到 Platform 的 router 实例
|
||||
*/
|
||||
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import {
|
||||
generateSimpleRoutes,
|
||||
loadViewsFromStorage,
|
||||
type RouteGeneratorConfig,
|
||||
type GenerateSimpleRoutesOptions
|
||||
} from 'shared/utils/route'
|
||||
import type { TbSysViewDTO } from 'shared/types'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import router from './index'
|
||||
import { SidebarLayout } from '../layouts'
|
||||
|
||||
// Platform 布局组件映射
|
||||
const platformLayoutMap: Record<string, () => Promise<any>> = {
|
||||
'SidebarLayout': () => Promise.resolve({ default: SidebarLayout }),
|
||||
'NavigationLayout': () => Promise.resolve({ default: SidebarLayout }),
|
||||
'BasicLayout': () => Promise.resolve({ default: SidebarLayout })
|
||||
}
|
||||
|
||||
// 视图组件加载器
|
||||
const VIEW_MODULES = import.meta.glob<{ default: any }>('../views/**/*.vue')
|
||||
|
||||
/**
|
||||
* 视图组件加载函数
|
||||
* @param componentPath 组件路径
|
||||
*/
|
||||
function viewLoader(componentPath: string): (() => Promise<any>) | null {
|
||||
// 将后台路径转换为实际路径
|
||||
let path = componentPath
|
||||
|
||||
// 如果不是以 ../ 开头,则认为是相对 views 目录的路径
|
||||
if (!path.startsWith('../')) {
|
||||
if (!path.startsWith('/')) {
|
||||
path = '/' + path
|
||||
}
|
||||
path = '../views' + path
|
||||
}
|
||||
|
||||
// 补全 .vue 后缀
|
||||
if (!path.endsWith('.vue')) {
|
||||
path += '.vue'
|
||||
}
|
||||
|
||||
const loader = VIEW_MODULES[path]
|
||||
|
||||
if (!loader) {
|
||||
console.warn(`[路由生成] 未找到组件: ${componentPath},期望路径: ${path}`)
|
||||
return null
|
||||
}
|
||||
|
||||
return loader as () => Promise<any>
|
||||
}
|
||||
|
||||
// Platform 路由生成器配置
|
||||
const routeConfig: RouteGeneratorConfig = {
|
||||
layoutMap: platformLayoutMap,
|
||||
viewLoader,
|
||||
notFoundComponent: () => Promise.resolve({
|
||||
default: {
|
||||
template: '<div style="padding: 20px; text-align: center;"><h2>404 - 页面未找到</h2></div>'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Platform 路由生成选项
|
||||
const routeOptions: GenerateSimpleRoutesOptions = {
|
||||
asRootChildren: true, // 作为 Root 路由的子路由
|
||||
iframePlaceholder: () => Promise.resolve({
|
||||
default: {
|
||||
template: '<div class="iframe-placeholder"></div>'
|
||||
}
|
||||
}),
|
||||
verbose: true // 启用详细日志
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加动态路由(Platform 特定)
|
||||
* @param views 视图列表(用作菜单)
|
||||
*/
|
||||
export function addDynamicRoutes(views: TbSysViewDTO[]) {
|
||||
if (!views || views.length === 0) {
|
||||
console.warn('[Platform 路由] 视图列表为空')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[Platform 路由] 开始生成路由,视图数量:', views.length)
|
||||
|
||||
try {
|
||||
// 使用 shared 中的通用方法生成路由
|
||||
const routes = generateSimpleRoutes(views, routeConfig, routeOptions)
|
||||
|
||||
// 将生成的路由添加到 Platform 的 router
|
||||
routes.forEach(route => {
|
||||
router.addRoute('Root', route)
|
||||
console.log('[Platform 路由] 已添加路由:', {
|
||||
path: route.path,
|
||||
name: route.name,
|
||||
hasComponent: !!route.component,
|
||||
childrenCount: route.children?.length || 0
|
||||
})
|
||||
})
|
||||
|
||||
console.log('✅ Platform 动态路由添加完成')
|
||||
console.log('所有路由:', router.getRoutes().map(r => ({ path: r.path, name: r.name })))
|
||||
} catch (error) {
|
||||
console.error('❌ Platform 动态路由生成失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 以下为 Platform 特有的辅助函数
|
||||
// 通用的路由生成逻辑已迁移到 shared/utils/route
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 从 LocalStorage 获取菜单并生成路由(Platform 特定)
|
||||
*
|
||||
* 使用 shared 中的通用 loadViewsFromStorage 方法
|
||||
*/
|
||||
export function loadRoutesFromStorage(): boolean {
|
||||
try {
|
||||
// 使用 shared 中的通用方法加载视图数据
|
||||
const views = loadViewsFromStorage('loginDomain', 'userViews')
|
||||
|
||||
if (views) {
|
||||
// 使用 Platform 的 addDynamicRoutes 添加路由
|
||||
addDynamicRoutes(views)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error('[Platform 路由] 从 LocalStorage 加载路由失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,23 @@
|
||||
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
|
||||
import { SidebarLayout } from '../layouts'
|
||||
import { TokenManager } from 'shared/api'
|
||||
import { loadRoutesFromStorage } from './dynamicRoute'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/home'
|
||||
name: 'Root',
|
||||
component: SidebarLayout,
|
||||
children: []
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('../views/public/Login.vue'),
|
||||
component: () => import('@/views/public/Login/Login.vue'),
|
||||
meta: {
|
||||
title: '登录',
|
||||
requiresAuth: false // 不需要登录
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/home',
|
||||
name: 'Home',
|
||||
component: SidebarLayout,
|
||||
meta: {
|
||||
title: '首页',
|
||||
requiresAuth: true // 需要登录
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -32,6 +26,9 @@ const router = createRouter({
|
||||
routes
|
||||
})
|
||||
|
||||
// 标记动态路由是否已加载
|
||||
let dynamicRoutesLoaded = false
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach((to, from, next) => {
|
||||
// 设置页面标题
|
||||
@@ -49,12 +46,37 @@ router.beforeEach((to, from, next) => {
|
||||
path: '/login',
|
||||
query: { redirect: to.fullPath } // 保存原始路径
|
||||
})
|
||||
} else if (to.path === '/login' && hasToken) {
|
||||
// 已登录但访问登录页,跳转到首页
|
||||
next('/home')
|
||||
} else {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
if (to.path === '/login' && hasToken) {
|
||||
// 已登录但访问登录页,跳转到首页
|
||||
next('/')
|
||||
return
|
||||
}
|
||||
|
||||
// 如果已登录且动态路由未加载,先加载动态路由
|
||||
if (hasToken && !dynamicRoutesLoaded) {
|
||||
dynamicRoutesLoaded = true
|
||||
const loaded = loadRoutesFromStorage()
|
||||
|
||||
if (loaded && to.path !== '/') {
|
||||
// 动态路由已加载,重新导航到目标路由
|
||||
next({ ...to, replace: true })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
// 导出动态路由生成函数
|
||||
export { addDynamicRoutes, loadRoutesFromStorage } from './dynamicRoute'
|
||||
|
||||
// 重置动态路由加载状态(用于登录后重新加载)
|
||||
export function resetDynamicRoutes() {
|
||||
dynamicRoutesLoaded = false
|
||||
}
|
||||
|
||||
// 导出路由和辅助函数
|
||||
export default router
|
||||
|
||||
@@ -3,7 +3,39 @@
|
||||
* 用于 TypeScript 识别远程模块
|
||||
*/
|
||||
|
||||
// ========== 组件模块 ==========
|
||||
declare module 'shared/components' {
|
||||
export const FileUpload: any
|
||||
export const DynamicFormItem: any
|
||||
}
|
||||
|
||||
declare module 'shared/components/FileUpload' {
|
||||
import { DefineComponent } from 'vue'
|
||||
const FileUpload: DefineComponent<{}, {}, any>
|
||||
export default FileUpload
|
||||
}
|
||||
|
||||
declare module 'shared/components/DynamicFormItem' {
|
||||
import { DefineComponent } from 'vue'
|
||||
const DynamicFormItem: DefineComponent<{}, {}, any>
|
||||
export default DynamicFormItem
|
||||
}
|
||||
|
||||
// ========== API 模块 ==========
|
||||
declare module 'shared/api' {
|
||||
export const api: any
|
||||
export const TokenManager: any
|
||||
}
|
||||
|
||||
declare module 'shared/api/auth' {
|
||||
export const authAPI: any
|
||||
}
|
||||
|
||||
declare module 'shared/api/file' {
|
||||
export const fileAPI: any
|
||||
}
|
||||
|
||||
// 保留旧的导出路径(向后兼容)
|
||||
declare module 'shared/FileUpload' {
|
||||
import { DefineComponent } from 'vue'
|
||||
const FileUpload: DefineComponent<{}, {}, any>
|
||||
@@ -16,11 +48,6 @@ declare module 'shared/DynamicFormItem' {
|
||||
export default DynamicFormItem
|
||||
}
|
||||
|
||||
declare module 'shared/api' {
|
||||
export const api: any
|
||||
export const TokenManager: any
|
||||
}
|
||||
|
||||
declare module 'shared/authAPI' {
|
||||
export const authAPI: any
|
||||
}
|
||||
@@ -40,15 +67,98 @@ declare module 'shared/utils' {
|
||||
}
|
||||
|
||||
declare module 'shared/types' {
|
||||
import { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export type LoginParam = any
|
||||
export type LoginDomain = any
|
||||
export type SysUserVO = any
|
||||
export type TbSysFileDTO = any
|
||||
export type SysConfigVO = any
|
||||
export type ResultDomain<T = any> = any
|
||||
|
||||
// 视图类型(用于路由和菜单)
|
||||
export interface TbSysViewDTO {
|
||||
viewId?: string
|
||||
name?: string
|
||||
parentId?: string
|
||||
url?: string
|
||||
component?: string
|
||||
iframeUrl?: string
|
||||
icon?: string
|
||||
type?: number
|
||||
layout?: string
|
||||
orderNum?: number
|
||||
description?: string
|
||||
children?: TbSysViewDTO[]
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'shared/components' {
|
||||
export const FileUpload: any
|
||||
export const DynamicFormItem: any
|
||||
declare module 'shared/utils/route' {
|
||||
import { RouteRecordRaw } from 'vue-router'
|
||||
import type { TbSysViewDTO } from 'shared/types'
|
||||
|
||||
export interface RouteGeneratorConfig {
|
||||
layoutMap: Record<string, () => Promise<any>>
|
||||
viewLoader: (componentPath: string) => (() => Promise<any>) | null
|
||||
staticRoutes?: RouteRecordRaw[]
|
||||
notFoundComponent?: () => Promise<any>
|
||||
}
|
||||
|
||||
export interface GenerateSimpleRoutesOptions {
|
||||
asRootChildren?: boolean
|
||||
iframePlaceholder?: () => Promise<any>
|
||||
verbose?: boolean
|
||||
}
|
||||
|
||||
export function generateRoutes(
|
||||
views: TbSysViewDTO[],
|
||||
config: RouteGeneratorConfig
|
||||
): RouteRecordRaw[]
|
||||
|
||||
export function generateSimpleRoutes(
|
||||
views: TbSysViewDTO[],
|
||||
config: RouteGeneratorConfig,
|
||||
options?: GenerateSimpleRoutesOptions
|
||||
): RouteRecordRaw[]
|
||||
|
||||
export function buildMenuTree(
|
||||
views: TbSysViewDTO[],
|
||||
staticRoutes?: RouteRecordRaw[]
|
||||
): TbSysViewDTO[]
|
||||
|
||||
export function filterMenusByPermissions(
|
||||
views: TbSysViewDTO[],
|
||||
permissions: string[]
|
||||
): TbSysViewDTO[]
|
||||
|
||||
export function findMenuByPath(
|
||||
views: TbSysViewDTO[],
|
||||
path: string
|
||||
): TbSysViewDTO | null
|
||||
|
||||
export function getMenuPath(
|
||||
views: TbSysViewDTO[],
|
||||
targetViewId: string
|
||||
): TbSysViewDTO[]
|
||||
|
||||
export function getFirstAccessibleMenuUrl(
|
||||
views: TbSysViewDTO[]
|
||||
): string | null
|
||||
|
||||
export function loadViewsFromStorage(
|
||||
storageKey?: string,
|
||||
viewsPath?: string
|
||||
): TbSysViewDTO[] | null
|
||||
}
|
||||
|
||||
declare module 'shared/utils/device' {
|
||||
export enum DeviceType {
|
||||
MOBILE = 'mobile',
|
||||
DESKTOP = 'desktop'
|
||||
}
|
||||
|
||||
export function getDeviceType(): DeviceType
|
||||
export function isMobile(): boolean
|
||||
export function isDesktop(): boolean
|
||||
export function useDevice(): any
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div class="iframe-view">
|
||||
<iframe
|
||||
v-if="iframeUrl"
|
||||
:src="iframeUrl"
|
||||
class="iframe-content"
|
||||
frameborder="0"
|
||||
@load="handleLoad"
|
||||
/>
|
||||
<div v-else class="iframe-error">
|
||||
<el-icon class="error-icon"><WarningFilled /></el-icon>
|
||||
<p>无效的 iframe 地址</p>
|
||||
</div>
|
||||
<div v-if="loading" class="iframe-loading">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Loading, WarningFilled } from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
const loading = ref(true)
|
||||
|
||||
// 从路由 meta 中获取 iframe URL
|
||||
const iframeUrl = computed(() => {
|
||||
return route.meta.iframeUrl as string || ''
|
||||
})
|
||||
|
||||
function handleLoad() {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
console.log('[IframeView] 加载 iframe:', iframeUrl.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.iframe-view {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.iframe-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.iframe-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
.error-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
}
|
||||
|
||||
.iframe-loading {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--el-bg-color);
|
||||
gap: 12px;
|
||||
|
||||
.el-icon {
|
||||
font-size: 32px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
.apps-view {
|
||||
padding: 32px 48px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
color: #1f2937;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.search-input {
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.category-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.tab-item {
|
||||
padding: 8px 20px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #7c3aed;
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #fff;
|
||||
background: #7c3aed;
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.agents-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
<template>
|
||||
<div class="apps-view">
|
||||
<header class="page-header">
|
||||
<h1>全部应用</h1>
|
||||
<p>选择智能体开始对话,助力您的工作效率提升</p>
|
||||
</header>
|
||||
|
||||
<div class="toolbar-section">
|
||||
<el-input
|
||||
v-model="searchText"
|
||||
placeholder="搜索智能体..."
|
||||
size="large"
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
class="search-input"
|
||||
/>
|
||||
<el-button type="primary" size="large" @click="handleCreateClick">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增智能体
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="category-tabs">
|
||||
<span
|
||||
v-for="cat in categories"
|
||||
:key="cat.key"
|
||||
class="tab-item"
|
||||
:class="{ active: activeCategory === cat.key }"
|
||||
@click="activeCategory = cat.key"
|
||||
>
|
||||
{{ cat.label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="agents-grid">
|
||||
<AgentCard
|
||||
v-for="agent in filteredAgents"
|
||||
:key="agent.id"
|
||||
:agent="agent"
|
||||
@click="handleAgentClick(agent)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑智能体对话框 -->
|
||||
<AgentEdit
|
||||
v-model="showEditDialog"
|
||||
:agent="editingAgent"
|
||||
@save="handleSaveAgent"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Search, Plus } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AgentCard from './components/AgentCard/AgentCard.vue'
|
||||
import AgentEdit from './components/AgentEdit/AgentEdit.vue'
|
||||
|
||||
interface Agent {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
icon?: string
|
||||
imageUrl?: string
|
||||
color: string
|
||||
category: string
|
||||
usage: number
|
||||
apiUrl?: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const searchText = ref('')
|
||||
const activeCategory = ref('all')
|
||||
const showEditDialog = ref(false)
|
||||
const editingAgent = ref<Agent | null>(null)
|
||||
|
||||
const categories = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'content', label: '内容创作' },
|
||||
{ key: 'office', label: '办公助手' },
|
||||
{ key: 'business', label: '业务助手' },
|
||||
{ key: 'lifeline', label: '城市生命线' }
|
||||
]
|
||||
|
||||
// Mock 数据 - 实际使用时从 API 加载
|
||||
const agents = ref<Agent[]>([
|
||||
{
|
||||
id: 'default',
|
||||
name: '城市生命线助手',
|
||||
description: '智能城市基础设施安全管理,提供隐患排查、应急预案、数据分析等服务',
|
||||
icon: '🏙️',
|
||||
color: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
category: 'lifeline',
|
||||
usage: 15680
|
||||
},
|
||||
{
|
||||
id: 'emergency',
|
||||
name: '应急处理助手',
|
||||
description: '专注于突发事件处理、应急预案制定和响应流程优化',
|
||||
icon: '🚨',
|
||||
color: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
|
||||
category: 'lifeline',
|
||||
usage: 8320
|
||||
},
|
||||
{
|
||||
id: 'analysis',
|
||||
name: '数据分析助手',
|
||||
description: '城市设施运行数据分析、故障风险预测、分析报告生成',
|
||||
icon: '📊',
|
||||
color: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
|
||||
category: 'lifeline',
|
||||
usage: 11200
|
||||
},
|
||||
{
|
||||
id: 'safety',
|
||||
name: '安全检查助手',
|
||||
description: '协助进行安全隐患排查、标准流程执行、整改计划制定',
|
||||
icon: '🔍',
|
||||
color: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)',
|
||||
category: 'lifeline',
|
||||
usage: 9540
|
||||
},
|
||||
{
|
||||
id: 'xiaohongshu',
|
||||
name: '小红书文案生成',
|
||||
description: '一键生成爆款小红书文案,支持多种风格,自动添加热门话题标签',
|
||||
icon: '📕',
|
||||
color: '#ff2442',
|
||||
category: 'content',
|
||||
usage: 12580
|
||||
},
|
||||
{
|
||||
id: 'contract',
|
||||
name: '泰豪合同助手',
|
||||
description: '智能合同审核、条款分析、风险提示,提高合同处理效率',
|
||||
icon: '📄',
|
||||
color: '#7c3aed',
|
||||
category: 'business',
|
||||
usage: 8320
|
||||
},
|
||||
{
|
||||
id: 'video',
|
||||
name: '泰豪短视频助手',
|
||||
description: '短视频脚本创作、文案优化、热门话题推荐',
|
||||
icon: '🎬',
|
||||
color: '#10b981',
|
||||
category: 'content',
|
||||
usage: 5640
|
||||
},
|
||||
{
|
||||
id: 'email',
|
||||
name: '邮件写作助手',
|
||||
description: '商务邮件、会议邀请、工作汇报等各类邮件智能生成',
|
||||
icon: '✉️',
|
||||
color: '#6366f1',
|
||||
category: 'office',
|
||||
usage: 7230
|
||||
},
|
||||
{
|
||||
id: 'translate',
|
||||
name: '多语言翻译',
|
||||
description: '支持中英日韩等多语言互译,专业术语精准翻译',
|
||||
icon: '🌐',
|
||||
color: '#14b8a6',
|
||||
category: 'office',
|
||||
usage: 11200
|
||||
}
|
||||
])
|
||||
|
||||
// 筛选后的智能体
|
||||
const filteredAgents = computed(() => {
|
||||
let result = agents.value
|
||||
|
||||
if (activeCategory.value !== 'all') {
|
||||
result = result.filter(a => a.category === activeCategory.value)
|
||||
}
|
||||
|
||||
if (searchText.value) {
|
||||
const keyword = searchText.value.toLowerCase()
|
||||
result = result.filter(a =>
|
||||
a.name.toLowerCase().includes(keyword) ||
|
||||
a.description.toLowerCase().includes(keyword)
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// 点击智能体卡片
|
||||
const handleAgentClick = (agent: Agent) => {
|
||||
// TODO: 实际使用时路由跳转到聊天页面
|
||||
router.push({
|
||||
path: '/aichat',
|
||||
query: { agentId: agent.id }
|
||||
})
|
||||
}
|
||||
|
||||
// 点击新增按钮
|
||||
const handleCreateClick = () => {
|
||||
editingAgent.value = null
|
||||
showEditDialog.value = true
|
||||
}
|
||||
|
||||
// 保存智能体
|
||||
const handleSaveAgent = async (agentData: Partial<Agent>) => {
|
||||
try {
|
||||
if (editingAgent.value) {
|
||||
// TODO: 调用更新 API
|
||||
// await agentAPI.update(editingAgent.value.id, agentData)
|
||||
|
||||
const index = agents.value.findIndex(a => a.id === editingAgent.value!.id)
|
||||
if (index > -1) {
|
||||
agents.value[index] = { ...agents.value[index], ...agentData }
|
||||
}
|
||||
ElMessage.success('智能体更新成功')
|
||||
} else {
|
||||
// TODO: 调用创建 API
|
||||
// const newAgent = await agentAPI.create(agentData)
|
||||
|
||||
const newAgent: Agent = {
|
||||
id: 'custom_' + Date.now(),
|
||||
name: agentData.name || '',
|
||||
description: agentData.description || '',
|
||||
icon: agentData.imageUrl ? '' : '🤖',
|
||||
imageUrl: agentData.imageUrl,
|
||||
color: agentData.color || '#7c3aed',
|
||||
category: agentData.category || 'office',
|
||||
apiUrl: agentData.apiUrl,
|
||||
usage: 0
|
||||
}
|
||||
agents.value.unshift(newAgent)
|
||||
ElMessage.success('智能体创建成功')
|
||||
}
|
||||
showEditDialog.value = false
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
ElMessage.error('保存失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: 组件挂载时加载数据
|
||||
// onMounted(async () => {
|
||||
// try {
|
||||
// const data = await agentAPI.getList()
|
||||
// agents.value = data
|
||||
// } catch (error) {
|
||||
// console.error('加载失败:', error)
|
||||
// }
|
||||
// })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import url('./AgentPlatformView.scss');
|
||||
</style>
|
||||
@@ -0,0 +1,259 @@
|
||||
# 智能体平台 AgentPlatformView
|
||||
|
||||
## 功能概览
|
||||
|
||||
完整的智能体管理平台,支持浏览、搜索、分类、新增和编辑智能体。
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
Agents/
|
||||
├── AgentPlatformView.vue # 主视图组件
|
||||
├── AgentPlatformView.scss # 主视图样式
|
||||
├── components/
|
||||
│ ├── AgentCard/ # 智能体卡片组件
|
||||
│ │ ├── AgentCard.vue
|
||||
│ │ └── AgentCard.scss
|
||||
│ └── AgentEdit/ # 智能体编辑对话框组件
|
||||
│ ├── AgentEdit.vue
|
||||
│ └── AgentEdit.scss
|
||||
└── README.md # 本文档
|
||||
```
|
||||
|
||||
## 核心功能
|
||||
|
||||
### 1. 智能体展示
|
||||
- ✅ 网格布局展示智能体卡片
|
||||
- ✅ 显示图标/图片、名称、描述、使用次数
|
||||
- ✅ 支持点击跳转到聊天页面
|
||||
- ✅ 悬停动画效果
|
||||
|
||||
### 2. 搜索和筛选
|
||||
- ✅ 搜索框实时搜索(名称/描述)
|
||||
- ✅ 分类标签筛选(全部/内容创作/办公助手/业务助手/城市生命线)
|
||||
- ✅ 组合搜索和分类筛选
|
||||
|
||||
### 3. 智能体管理
|
||||
- ✅ 新增智能体
|
||||
- ✅ 编辑智能体(预留接口)
|
||||
- ✅ 图片上传(支持预览)
|
||||
- ✅ 表单验证
|
||||
|
||||
### 4. 高级配置
|
||||
- ✅ API 链接配置
|
||||
- ✅ Dify API Key 配置
|
||||
- ✅ 自定义引导词
|
||||
- ✅ 提示卡片配置(最多3个)
|
||||
|
||||
## Mock 数据
|
||||
|
||||
### 智能体列表
|
||||
```typescript
|
||||
[
|
||||
{
|
||||
id: 'default',
|
||||
name: '城市生命线助手',
|
||||
description: '智能城市基础设施安全管理...',
|
||||
icon: '🏙️',
|
||||
color: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
category: 'lifeline',
|
||||
usage: 15680
|
||||
},
|
||||
// ... 其他智能体
|
||||
]
|
||||
```
|
||||
|
||||
### 分类列表
|
||||
- **all**: 全部
|
||||
- **content**: 内容创作
|
||||
- **office**: 办公助手
|
||||
- **business**: 业务助手
|
||||
- **lifeline**: 城市生命线
|
||||
|
||||
## 组件说明
|
||||
|
||||
### AgentPlatformView(主视图)
|
||||
主要功能:
|
||||
- 展示智能体网格
|
||||
- 搜索和分类筛选
|
||||
- 调用子组件
|
||||
|
||||
Props: 无
|
||||
|
||||
Events: 无
|
||||
|
||||
### AgentCard(卡片组件)
|
||||
显示单个智能体信息
|
||||
|
||||
Props:
|
||||
- `agent`: Agent - 智能体数据
|
||||
|
||||
Events:
|
||||
- `click`: () => void - 卡片点击事件
|
||||
|
||||
### AgentEdit(编辑对话框)
|
||||
智能体新增/编辑表单
|
||||
|
||||
Props:
|
||||
- `modelValue`: boolean - 对话框显示状态
|
||||
- `agent?`: Agent | null - 编辑的智能体数据(null 为新增模式)
|
||||
|
||||
Events:
|
||||
- `update:modelValue`: (value: boolean) => void - 更新显示状态
|
||||
- `save`: (data: Partial<Agent>) => void - 保存事件
|
||||
|
||||
## 数据接口
|
||||
|
||||
### Agent 类型定义
|
||||
```typescript
|
||||
interface Agent {
|
||||
id?: string
|
||||
name: string
|
||||
description: string
|
||||
icon?: string
|
||||
imageUrl?: string
|
||||
color?: string
|
||||
category: string
|
||||
usage?: number
|
||||
apiUrl?: string
|
||||
difyApiKey?: string
|
||||
welcomeMessage?: string
|
||||
suggestions?: string[]
|
||||
}
|
||||
```
|
||||
|
||||
## 接入真实 API
|
||||
|
||||
### 1. 加载智能体列表
|
||||
在 `AgentPlatformView.vue` 的 `onMounted` 中:
|
||||
|
||||
```typescript
|
||||
import { onMounted } from 'vue'
|
||||
import { agentAPI } from '@/api/agent'
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await agentAPI.getList()
|
||||
agents.value = data
|
||||
} catch (error) {
|
||||
console.error('加载失败:', error)
|
||||
ElMessage.error('加载智能体列表失败')
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 2. 创建智能体
|
||||
在 `handleSaveAgent` 函数中(新增时):
|
||||
|
||||
```typescript
|
||||
const newAgent = await agentAPI.create(agentData)
|
||||
agents.value.unshift(newAgent)
|
||||
ElMessage.success('智能体创建成功')
|
||||
```
|
||||
|
||||
### 3. 更新智能体
|
||||
在 `handleSaveAgent` 函数中(编辑时):
|
||||
|
||||
```typescript
|
||||
await agentAPI.update(editingAgent.value.id, agentData)
|
||||
const index = agents.value.findIndex(a => a.id === editingAgent.value!.id)
|
||||
if (index > -1) {
|
||||
agents.value[index] = { ...agents.value[index], ...agentData }
|
||||
}
|
||||
ElMessage.success('智能体更新成功')
|
||||
```
|
||||
|
||||
### 4. 图片上传
|
||||
在 `AgentEdit.vue` 的 `handleFileChange` 中:
|
||||
|
||||
```typescript
|
||||
const uploadedUrl = await uploadAPI.upload(file)
|
||||
formData.value.imageUrl = uploadedUrl
|
||||
```
|
||||
|
||||
## 路由配置
|
||||
|
||||
```typescript
|
||||
{
|
||||
path: '/agents',
|
||||
name: 'Agents',
|
||||
component: () => import('@/views/public/Agents/AgentPlatformView.vue'),
|
||||
meta: {
|
||||
title: '智能体平台'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 样式特点
|
||||
|
||||
### 设计风格
|
||||
- 网格响应式布局(auto-fill, minmax(280px, 1fr))
|
||||
- 现代卡片设计
|
||||
- 流畅动画效果
|
||||
- 渐变色支持
|
||||
|
||||
### 主题色
|
||||
- 主色调:紫色 `#7c3aed`
|
||||
- 背景色:浅灰 `#f9fafb`
|
||||
- 边框色:`#e5e7eb`
|
||||
- 文字色:`#1f2937`
|
||||
|
||||
### 动画效果
|
||||
- 卡片悬停:上移4px + 阴影
|
||||
- 分类标签:颜色渐变
|
||||
- 上传区域:边框颜色变化
|
||||
|
||||
## 依赖
|
||||
|
||||
- Vue 3
|
||||
- Element Plus
|
||||
- TypeScript
|
||||
- Vue Router
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 所有字段使用 4 个空格缩进(符合用户规则)
|
||||
2. Mock 数据在组件内,便于测试
|
||||
3. 已预留 API 接口位置,用 TODO 标注
|
||||
4. **图片上传使用 FileUpload 组件的 cover 模式**
|
||||
- 智能体封面:120x120 像素,最大 5MB
|
||||
- 提示词图标:60x60 像素,最大 2MB
|
||||
- 自动上传到服务器,返回 URL
|
||||
5. 表单验证在保存时进行
|
||||
6. 提示词最多3个,动态添加/删除
|
||||
7. **依赖 shared 包的 FileUpload 组件**(需确保正确配置)
|
||||
|
||||
## 待优化功能
|
||||
|
||||
- [ ] 智能体删除功能
|
||||
- [ ] 智能体排序功能
|
||||
- [ ] 批量操作
|
||||
- [ ] 智能体详情页
|
||||
- [ ] 统计数据展示
|
||||
- [ ] 导入/导出功能
|
||||
- [ ] 权限控制
|
||||
|
||||
## API 接口设计建议
|
||||
|
||||
```typescript
|
||||
// agent API
|
||||
export const agentAPI = {
|
||||
// 获取列表
|
||||
async getList(params?: {
|
||||
category?: string
|
||||
keyword?: string
|
||||
}): Promise<Agent[]> {},
|
||||
|
||||
// 创建
|
||||
async create(data: Partial<Agent>): Promise<Agent> {},
|
||||
|
||||
// 更新
|
||||
async update(id: string, data: Partial<Agent>): Promise<Agent> {},
|
||||
|
||||
// 删除
|
||||
async delete(id: string): Promise<void> {},
|
||||
|
||||
// 获取详情
|
||||
async getDetail(id: string): Promise<Agent> {}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,69 @@
|
||||
.agent-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.12);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.agent-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 26px;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.agent-name {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.agent-desc {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 16px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.usage-count {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="agent-card" @click="$emit('click')">
|
||||
<div class="card-header">
|
||||
<div class="agent-icon" :style="{ background: agent.imageUrl ? 'transparent' : agent.color }">
|
||||
<img v-if="agent.imageUrl" :src="agent.imageUrl" :alt="agent.name" />
|
||||
<span v-else>{{ agent.icon }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="agent-name">{{ agent.name }}</h3>
|
||||
<p class="agent-desc">{{ agent.description }}</p>
|
||||
<div class="card-footer">
|
||||
<span class="usage-count">{{ agent.usage }} 次使用</span>
|
||||
<el-button type="primary" size="small" round>开始对话</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
interface Agent {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
icon?: string
|
||||
imageUrl?: string
|
||||
color: string
|
||||
category: string
|
||||
usage: number
|
||||
apiUrl?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
agent: Agent
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import url('./AgentCard.scss');
|
||||
</style>
|
||||
@@ -0,0 +1,88 @@
|
||||
.agent-cover-upload {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
|
||||
:deep(.area.cover) {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
:deep(.image-wrapper) {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
:deep(.image) {
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.suggestions-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.suggestion-card-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.card-icon-upload {
|
||||
flex-shrink: 0;
|
||||
width: 80px !important;
|
||||
height: 32px !important;
|
||||
|
||||
:deep(.file-upload.cover) {
|
||||
width: 80px !important;
|
||||
height: 32px !important;
|
||||
}
|
||||
|
||||
:deep(.area.cover) {
|
||||
width: 80px !important;
|
||||
height: 32px !important;
|
||||
padding: 0 !important;
|
||||
min-height: unset !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
|
||||
.content {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.text,
|
||||
.tip {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin: 0 !important;
|
||||
|
||||
.plus {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
&::before {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
width: 16px;
|
||||
height: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-text-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑智能体' : '新增智能体'"
|
||||
width="520px"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form :model="formData" label-width="100px" label-position="top">
|
||||
<el-form-item label="智能体图片">
|
||||
<FileUpload
|
||||
mode="cover"
|
||||
v-model:coverImg="formData.imageUrl"
|
||||
accept="image/*"
|
||||
:maxSize="5 * 1024 * 1024"
|
||||
class="agent-cover-upload"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="智能体名称" required>
|
||||
<el-input v-model="formData.name" placeholder="请输入智能体名称" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="智能体介绍" required>
|
||||
<el-input
|
||||
v-model="formData.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入智能体功能介绍"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="API链接">
|
||||
<el-input v-model="formData.apiUrl" placeholder="请输入API接口地址" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Dify API Key">
|
||||
<el-input
|
||||
v-model="formData.difyApiKey"
|
||||
placeholder="请输入Dify智能体API Key"
|
||||
type="password"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="引导词">
|
||||
<el-input
|
||||
v-model="formData.welcomeMessage"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="例如:今天需要我帮你做点什么吗?"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="提示卡片(最多3个)">
|
||||
<div class="suggestions-input">
|
||||
<div
|
||||
v-for="(card, index) in (formData.suggestionCards || [])"
|
||||
:key="index"
|
||||
class="suggestion-card-item"
|
||||
>
|
||||
<!-- 图标上传区域 - 使用 FileUpload cover 模式 -->
|
||||
<FileUpload
|
||||
mode="cover"
|
||||
v-model:coverImg="formData.suggestionCards![index].icon"
|
||||
accept="image/*"
|
||||
:maxSize="2 * 1024 * 1024"
|
||||
class="card-icon-upload"
|
||||
/>
|
||||
|
||||
<!-- 提示词文本输入 -->
|
||||
<el-input
|
||||
v-model="formData.suggestionCards![index].text"
|
||||
:placeholder="`提示词 ${index + 1}`"
|
||||
class="card-text-input"
|
||||
/>
|
||||
|
||||
<!-- 删除按钮 -->
|
||||
<el-button
|
||||
v-if="(formData.suggestionCards?.length || 0) > 1"
|
||||
:icon="Delete"
|
||||
@click="removeSuggestion(index)"
|
||||
class="delete-btn"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
v-if="(formData.suggestionCards?.length || 0) < 3"
|
||||
type="primary"
|
||||
text
|
||||
:icon="Plus"
|
||||
@click="addSuggestion"
|
||||
>
|
||||
添加提示词
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="分类" required>
|
||||
<el-select v-model="formData.category" placeholder="请选择分类" style="width: 100%">
|
||||
<el-option label="内容创作" value="content" />
|
||||
<el-option label="办公助手" value="office" />
|
||||
<el-option label="业务助手" value="business" />
|
||||
<el-option label="城市生命线" value="lifeline" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Plus, Delete } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import FileUpload from 'shared/components/FileUpload'
|
||||
|
||||
interface SuggestionCard {
|
||||
text: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
interface Agent {
|
||||
id?: string
|
||||
name: string
|
||||
description: string
|
||||
icon?: string
|
||||
imageUrl?: string
|
||||
color?: string
|
||||
category: string
|
||||
usage?: number
|
||||
apiUrl?: string
|
||||
difyApiKey?: string
|
||||
welcomeMessage?: string
|
||||
suggestions?: string[]
|
||||
suggestionCards?: SuggestionCard[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
agent?: Agent | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'save', data: Partial<Agent>): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const isEdit = computed(() => !!props.agent)
|
||||
|
||||
const formData = ref<Partial<Agent>>({
|
||||
name: '',
|
||||
description: '',
|
||||
category: '',
|
||||
imageUrl: '',
|
||||
apiUrl: '',
|
||||
difyApiKey: '',
|
||||
welcomeMessage: '',
|
||||
suggestionCards: [{ text: '', icon: '' }]
|
||||
})
|
||||
|
||||
// 监听 agent 变化,填充表单
|
||||
watch(() => props.agent, (newAgent) => {
|
||||
if (newAgent) {
|
||||
formData.value = {
|
||||
name: newAgent.name,
|
||||
description: newAgent.description,
|
||||
category: newAgent.category,
|
||||
imageUrl: newAgent.imageUrl || '',
|
||||
apiUrl: newAgent.apiUrl || '',
|
||||
difyApiKey: newAgent.difyApiKey || '',
|
||||
welcomeMessage: newAgent.welcomeMessage || '',
|
||||
suggestionCards: newAgent.suggestionCards && newAgent.suggestionCards.length > 0
|
||||
? [...newAgent.suggestionCards]
|
||||
: [{ text: '', icon: '' }]
|
||||
}
|
||||
} else {
|
||||
// 重置表单
|
||||
formData.value = {
|
||||
name: '',
|
||||
description: '',
|
||||
category: '',
|
||||
imageUrl: '',
|
||||
apiUrl: '',
|
||||
difyApiKey: '',
|
||||
welcomeMessage: '',
|
||||
suggestionCards: [{ text: '', icon: '' }]
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const addSuggestion = () => {
|
||||
if (formData.value.suggestionCards && formData.value.suggestionCards.length < 3) {
|
||||
formData.value.suggestionCards.push({ text: '', icon: '' })
|
||||
}
|
||||
}
|
||||
|
||||
const removeSuggestion = (index: number) => {
|
||||
if (formData.value.suggestionCards) {
|
||||
formData.value.suggestionCards.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
dialogVisible.value = false
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
// 验证
|
||||
if (!formData.value.name?.trim()) {
|
||||
ElMessage.warning('请输入智能体名称')
|
||||
return
|
||||
}
|
||||
if (!formData.value.description?.trim()) {
|
||||
ElMessage.warning('请输入智能体介绍')
|
||||
return
|
||||
}
|
||||
if (!formData.value.category) {
|
||||
ElMessage.warning('请选择分类')
|
||||
return
|
||||
}
|
||||
|
||||
// 过滤空的提示词卡片
|
||||
const suggestionCards = formData.value.suggestionCards?.filter(s => s.text.trim()) || []
|
||||
|
||||
// 生成随机颜色(如果没有上传图片)
|
||||
const colors = ['#7c3aed', '#10b981', '#f59e0b', '#3b82f6', '#ef4444', '#6366f1', '#14b8a6']
|
||||
const randomColor = colors[Math.floor(Math.random() * colors.length)]
|
||||
|
||||
emit('save', {
|
||||
...formData.value,
|
||||
color: formData.value.imageUrl ? undefined : randomColor,
|
||||
suggestionCards
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import url('./AgentEdit.scss');
|
||||
</style>
|
||||
@@ -0,0 +1,386 @@
|
||||
.chat-view {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// 左侧侧边栏样式 - ChatGPT Style
|
||||
.chat-sidebar {
|
||||
width: 260px;
|
||||
background: #f7f7f8;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: width 0.3s ease;
|
||||
|
||||
&.collapsed {
|
||||
width: 60px;
|
||||
|
||||
.new-chat-btn {
|
||||
padding: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.new-chat-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
border-color: #7c3aed;
|
||||
color: #7c3aed;
|
||||
}
|
||||
}
|
||||
|
||||
.collapse-toggle {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
color: #7c3aed;
|
||||
}
|
||||
}
|
||||
|
||||
.conversations-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.list-section {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.section-title {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
color: #374151;
|
||||
|
||||
&:hover {
|
||||
background: #e5e7eb;
|
||||
|
||||
.conv-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.conv-title {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.conv-actions {
|
||||
opacity: 0;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
.action-icon {
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
color: #6b7280;
|
||||
|
||||
&:hover {
|
||||
background: #d1d5db;
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 主聊天区域
|
||||
.chat-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
|
||||
.agent-dropdown {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f3f4f6;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.agent-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-agent-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
:deep(.el-dropdown-menu__item.is-active) {
|
||||
background: rgba(124, 58, 237, 0.1);
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.chat-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 40px 80px;
|
||||
}
|
||||
|
||||
.welcome-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
&.user {
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.message-content {
|
||||
align-items: flex-end;
|
||||
|
||||
.message-text {
|
||||
background: #7c3aed;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
flex-shrink: 0;
|
||||
|
||||
.ai-avatar-small {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
object-fit: contain;
|
||||
background: #f3f4f6;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.user-avatar-small {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.message-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-width: 70%;
|
||||
|
||||
.message-text {
|
||||
padding: 12px 16px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 12px;
|
||||
color: #1f2937;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
padding: 0 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 16px;
|
||||
|
||||
span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #9ca3af;
|
||||
border-radius: 50%;
|
||||
animation: typing 1.4s infinite ease-in-out both;
|
||||
|
||||
&:nth-child(1) { animation-delay: -0.32s; }
|
||||
&:nth-child(2) { animation-delay: -0.16s; }
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes typing {
|
||||
0%, 80%, 100% {
|
||||
transform: scale(0.6);
|
||||
opacity: 0.5;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.input-area {
|
||||
padding: 20px 80px 30px;
|
||||
background: #fff;
|
||||
|
||||
.input-wrapper {
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
resize: none;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
min-height: 24px;
|
||||
max-height: 120px;
|
||||
|
||||
&::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
.input-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
|
||||
.action-buttons, .send-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #6b7280;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #7c3aed;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #5b21b6;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #d1d5db;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
<template>
|
||||
<div class="chat-view">
|
||||
<!-- Left Sidebar - ChatGPT Style -->
|
||||
<aside class="chat-sidebar" :class="{ collapsed: sidebarCollapsed }">
|
||||
<div class="sidebar-header">
|
||||
<button class="collapse-toggle" @click="sidebarCollapsed = !sidebarCollapsed">
|
||||
<el-icon><Fold v-if="!sidebarCollapsed" /><Expand v-else /></el-icon>
|
||||
<span v-if="!sidebarCollapsed">{{ sidebarCollapsed ? '展开' : '收起' }}</span>
|
||||
</button>
|
||||
<button class="new-chat-btn" @click="handleNewChat">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span v-if="!sidebarCollapsed">新建对话</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!sidebarCollapsed" class="conversations-list">
|
||||
<div class="list-section">
|
||||
<div class="section-title">今天</div>
|
||||
<div
|
||||
v-for="conv in todayConversations"
|
||||
:key="conv.id"
|
||||
class="conversation-item"
|
||||
:class="{ active: currentConversationId === conv.id }"
|
||||
@click="selectConversation(conv)"
|
||||
>
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
<span class="conv-title">{{ conv.title }}</span>
|
||||
<div class="conv-actions">
|
||||
<el-icon class="action-icon" @click.stop="deleteConversation(conv.id)"><Delete /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-section" v-if="olderConversations.length > 0">
|
||||
<div class="section-title">历史记录</div>
|
||||
<div
|
||||
v-for="conv in olderConversations"
|
||||
:key="conv.id"
|
||||
class="conversation-item"
|
||||
:class="{ active: currentConversationId === conv.id }"
|
||||
@click="selectConversation(conv)"
|
||||
>
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
<span class="conv-title">{{ conv.title }}</span>
|
||||
<div class="conv-actions">
|
||||
<el-icon class="action-icon" @click.stop="deleteConversation(conv.id)"><Delete /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Chat Area -->
|
||||
<div class="chat-main">
|
||||
<!-- Header -->
|
||||
<header class="chat-header">
|
||||
<el-dropdown trigger="click" @command="handleAgentChange" class="agent-dropdown">
|
||||
<div class="header-title">
|
||||
<span class="agent-icon">{{ currentAgent.icon }}</span>
|
||||
<span>{{ currentAgent.name }}</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="agent in agents"
|
||||
:key="agent.id"
|
||||
:command="agent.id"
|
||||
:class="{ 'is-active': agent.id === currentAgent.id }"
|
||||
>
|
||||
<span class="dropdown-agent-icon">{{ agent.icon }}</span>
|
||||
<span>{{ agent.name }}</span>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</header>
|
||||
|
||||
<!-- Chat Content -->
|
||||
<div class="chat-content" ref="chatContentRef">
|
||||
<!-- Welcome Message -->
|
||||
<div v-if="messages.length === 0" class="welcome-section">
|
||||
<ChatDefault
|
||||
:agent="currentAgent"
|
||||
@suggestion-click="handleSuggestionClick"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Chat Messages -->
|
||||
<div v-else class="messages-container">
|
||||
<div
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
class="message"
|
||||
:class="message.role"
|
||||
>
|
||||
<div class="message-avatar">
|
||||
<img v-if="message.role === 'assistant'" src="/logo.jpg" alt="AI" class="ai-avatar-small" />
|
||||
<div v-else class="user-avatar-small">👤</div>
|
||||
</div>
|
||||
<div class="message-content">
|
||||
<div class="message-text">{{ message.content }}</div>
|
||||
<div class="message-time">{{ formatTime(message.timestamp) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div v-if="isLoading" class="message assistant">
|
||||
<div class="message-avatar">
|
||||
<img src="/logo.jpg" alt="AI" class="ai-avatar-small" />
|
||||
</div>
|
||||
<div class="message-content">
|
||||
<div class="typing-indicator">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input Area -->
|
||||
<div class="input-area">
|
||||
<div class="input-wrapper">
|
||||
<textarea
|
||||
v-model="inputText"
|
||||
placeholder="请输入内容..."
|
||||
@keydown.enter.prevent="handleSend"
|
||||
rows="1"
|
||||
ref="textareaRef"
|
||||
></textarea>
|
||||
<div class="input-actions">
|
||||
<div class="action-buttons">
|
||||
<button class="action-btn" title="附件">
|
||||
<el-icon><Paperclip /></el-icon>
|
||||
</button>
|
||||
<button class="action-btn" title="表情">
|
||||
<el-icon><Star /></el-icon>
|
||||
</button>
|
||||
<button class="action-btn" title="图片">
|
||||
<el-icon><Picture /></el-icon>
|
||||
</button>
|
||||
<button class="action-btn" title="更多">
|
||||
<el-icon><MoreFilled /></el-icon>
|
||||
</button>
|
||||
<button class="action-btn" title="截图">
|
||||
<el-icon><CameraFilled /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div class="send-actions">
|
||||
<button class="action-btn" title="语音">
|
||||
<el-icon><Microphone /></el-icon>
|
||||
</button>
|
||||
<button class="send-btn" @click="handleSend" :disabled="!inputText.trim()">
|
||||
<el-icon><Promotion /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, nextTick, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import {
|
||||
ArrowDown,
|
||||
Paperclip,
|
||||
Star,
|
||||
Picture,
|
||||
MoreFilled,
|
||||
CameraFilled,
|
||||
Microphone,
|
||||
Promotion,
|
||||
Plus,
|
||||
Fold,
|
||||
Expand,
|
||||
ChatDotRound,
|
||||
Delete
|
||||
} from '@element-plus/icons-vue'
|
||||
import ChatDefault from './components/ChatDefault/ChatDefault.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// Mock 数据 - 智能体列表
|
||||
const agents = ref([
|
||||
{
|
||||
id: 'default',
|
||||
name: '城市生命线助手',
|
||||
icon: '🏙️',
|
||||
color: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
description: '我是城市生命线智能助手,专注于城市基础设施安全管理'
|
||||
},
|
||||
{
|
||||
id: 'emergency',
|
||||
name: '应急处理助手',
|
||||
icon: '🚨',
|
||||
color: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
|
||||
description: '专注于应急事件处理和预案制定'
|
||||
},
|
||||
{
|
||||
id: 'analysis',
|
||||
name: '数据分析助手',
|
||||
icon: '📊',
|
||||
color: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
|
||||
description: '提供数据分析和可视化服务'
|
||||
},
|
||||
{
|
||||
id: 'safety',
|
||||
name: '安全检查助手',
|
||||
icon: '🔍',
|
||||
color: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)',
|
||||
description: '协助进行安全隐患排查和整改'
|
||||
}
|
||||
])
|
||||
|
||||
const currentAgent = ref(agents.value[0])
|
||||
|
||||
// 状态管理
|
||||
const sidebarCollapsed = ref(false)
|
||||
const currentConversationId = ref<number | null>(null)
|
||||
const inputText = ref('')
|
||||
const isLoading = ref(false)
|
||||
const chatContentRef = ref<HTMLElement | null>(null)
|
||||
const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
||||
|
||||
// Mock 数据 - 对话历史
|
||||
interface Conversation {
|
||||
id: number
|
||||
title: string
|
||||
date: Date
|
||||
messages: Message[]
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
content: string
|
||||
role: 'user' | 'assistant'
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
const conversations = ref<Conversation[]>([
|
||||
{
|
||||
id: 1,
|
||||
title: '城市生命线关键设施咨询',
|
||||
date: new Date(),
|
||||
messages: []
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '消防安全隐患处理方案',
|
||||
date: new Date(),
|
||||
messages: []
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '排水系统优化建议',
|
||||
date: new Date(Date.now() - 86400000),
|
||||
messages: []
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '应急预案讨论',
|
||||
date: new Date(Date.now() - 172800000),
|
||||
messages: []
|
||||
}
|
||||
])
|
||||
|
||||
const messages = ref<Message[]>([])
|
||||
|
||||
// 今天的对话
|
||||
const todayConversations = computed(() => {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
return conversations.value.filter(conv => new Date(conv.date) >= today)
|
||||
})
|
||||
|
||||
// 历史对话
|
||||
const olderConversations = computed(() => {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
return conversations.value.filter(conv => new Date(conv.date) < today)
|
||||
})
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timestamp: string) => {
|
||||
if (!timestamp) return ''
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
// 滚动到底部
|
||||
const scrollToBottom = async () => {
|
||||
await nextTick()
|
||||
if (chatContentRef.value) {
|
||||
chatContentRef.value.scrollTop = chatContentRef.value.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
// 新建对话
|
||||
const handleNewChat = () => {
|
||||
const newConv: Conversation = {
|
||||
id: Date.now(),
|
||||
title: '新对话',
|
||||
date: new Date(),
|
||||
messages: []
|
||||
}
|
||||
conversations.value.unshift(newConv)
|
||||
currentConversationId.value = newConv.id
|
||||
messages.value = []
|
||||
}
|
||||
|
||||
// 选择对话
|
||||
const selectConversation = (conv: Conversation) => {
|
||||
currentConversationId.value = conv.id
|
||||
messages.value = conv.messages || []
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
// 删除对话
|
||||
const deleteConversation = (id: number) => {
|
||||
const index = conversations.value.findIndex(c => c.id === id)
|
||||
if (index > -1) {
|
||||
conversations.value.splice(index, 1)
|
||||
if (currentConversationId.value === id) {
|
||||
currentConversationId.value = null
|
||||
messages.value = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 切换智能体
|
||||
const handleAgentChange = (agentId: string) => {
|
||||
const agent = agents.value.find(a => a.id === agentId)
|
||||
if (agent) {
|
||||
currentAgent.value = agent
|
||||
// 切换智能体时清空对话
|
||||
messages.value = []
|
||||
currentConversationId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 处理建议点击
|
||||
const handleSuggestionClick = async (suggestion: string) => {
|
||||
inputText.value = suggestion
|
||||
await handleSend()
|
||||
}
|
||||
|
||||
// 发送消息 - Mock 实现
|
||||
const handleSend = async () => {
|
||||
const text = inputText.value.trim()
|
||||
if (!text || isLoading.value) return
|
||||
|
||||
// 添加用户消息
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
content: text,
|
||||
role: 'user',
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
messages.value.push(userMessage)
|
||||
inputText.value = ''
|
||||
|
||||
await scrollToBottom()
|
||||
|
||||
// 显示加载状态
|
||||
isLoading.value = true
|
||||
|
||||
// Mock API 响应
|
||||
setTimeout(async () => {
|
||||
const mockResponses = [
|
||||
'根据城市生命线安全管理的相关规定,我为您分析如下...',
|
||||
'这是一个很好的问题。让我详细为您解答...',
|
||||
'基于您的需求,我建议采取以下措施...',
|
||||
'从专业角度来看,这个问题需要综合考虑多个因素...'
|
||||
]
|
||||
|
||||
const assistantMessage: Message = {
|
||||
id: Date.now().toString() + '_ai',
|
||||
content: mockResponses[Math.floor(Math.random() * mockResponses.length)],
|
||||
role: 'assistant',
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
messages.value.push(assistantMessage)
|
||||
|
||||
// 保存到当前对话
|
||||
if (currentConversationId.value) {
|
||||
const conv = conversations.value.find(c => c.id === currentConversationId.value)
|
||||
if (conv) {
|
||||
conv.messages = messages.value
|
||||
// 更新对话标题(取第一条用户消息)
|
||||
if (conv.title === '新对话' && messages.value.length > 0) {
|
||||
const firstUserMsg = messages.value.find(m => m.role === 'user')
|
||||
if (firstUserMsg) {
|
||||
conv.title = firstUserMsg.content.slice(0, 20) + (firstUserMsg.content.length > 20 ? '...' : '')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isLoading.value = false
|
||||
await scrollToBottom()
|
||||
}, 1000 + Math.random() * 1000) // 随机延迟 1-2 秒
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 从 URL 参数读取 agentId
|
||||
const agentId = route.query.agentId as string
|
||||
if (agentId) {
|
||||
const agent = agents.value.find(a => a.id === agentId)
|
||||
if (agent) {
|
||||
currentAgent.value = agent
|
||||
console.log('[AI Chat] 已切换到智能体:', agent.name)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('AI Chat View mounted')
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import url('./AIChatView.scss');
|
||||
</style>
|
||||
@@ -0,0 +1,176 @@
|
||||
# AI 聊天界面组件
|
||||
|
||||
## 功能概览
|
||||
|
||||
完整的 AI 聊天界面实现,包含侧边栏对话历史、智能体切换、消息交互等功能。
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
Chat/
|
||||
├── AIChatView.vue # 主聊天界面组件
|
||||
├── AIChatView.scss # 主界面样式
|
||||
├── components/
|
||||
│ ├── ChatDefault/ # 欢迎界面组件
|
||||
│ │ ├── ChatDefault.vue
|
||||
│ │ └── ChatDefault.scss
|
||||
│ └── ChatHistory/ # 历史记录组件(预留)
|
||||
│ ├── ChatHistory.vue
|
||||
│ └── ChatHistory.scss
|
||||
└── README.md # 本文档
|
||||
```
|
||||
|
||||
## 核心功能
|
||||
|
||||
### 1. 侧边栏功能
|
||||
- ✅ 对话历史列表(今天/历史记录分组)
|
||||
- ✅ 新建对话
|
||||
- ✅ 删除对话
|
||||
- ✅ 侧边栏收起/展开
|
||||
|
||||
### 2. 智能体系统
|
||||
- ✅ 多智能体支持(城市生命线、应急处理、数据分析、安全检查)
|
||||
- ✅ 智能体切换(下拉选择)
|
||||
- ✅ 每个智能体独立的欢迎信息和建议
|
||||
|
||||
### 3. 消息交互
|
||||
- ✅ 消息发送/接收
|
||||
- ✅ 用户/AI 消息区分显示
|
||||
- ✅ 打字中动画效果
|
||||
- ✅ 消息时间显示
|
||||
- ✅ 自动滚动到底部
|
||||
|
||||
### 4. 输入功能
|
||||
- ✅ 多行文本输入
|
||||
- ✅ Enter 发送(Shift+Enter 换行)
|
||||
- ✅ 附件、表情、图片等功能按钮(UI 已实现)
|
||||
- ✅ 发送按钮禁用状态
|
||||
|
||||
## Mock 数据
|
||||
|
||||
### 智能体列表
|
||||
```typescript
|
||||
[
|
||||
{
|
||||
id: 'default',
|
||||
name: '城市生命线助手',
|
||||
icon: '🏙️',
|
||||
color: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
description: '我是城市生命线智能助手,专注于城市基础设施安全管理'
|
||||
},
|
||||
// ... 其他智能体
|
||||
]
|
||||
```
|
||||
|
||||
### Mock API 响应
|
||||
发送消息后,系统会在 1-2 秒后返回随机的 Mock 响应:
|
||||
- "根据城市生命线安全管理的相关规定,我为您分析如下..."
|
||||
- "这是一个很好的问题。让我详细为您解答..."
|
||||
- "基于您的需求,我建议采取以下措施..."
|
||||
- "从专业角度来看,这个问题需要综合考虑多个因素..."
|
||||
|
||||
## 样式特点
|
||||
|
||||
### 设计风格
|
||||
- ChatGPT 风格的侧边栏布局
|
||||
- 现代简约的配色方案
|
||||
- 流畅的动画效果
|
||||
- 响应式设计
|
||||
|
||||
### 主题色
|
||||
- 主色调:紫色 `#7c3aed`
|
||||
- 背景色:灰白 `#f7f7f8`
|
||||
- 边框色:浅灰 `#e5e7eb`
|
||||
- 文字色:深灰 `#374151`
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 基本使用
|
||||
```vue
|
||||
<template>
|
||||
<AIChatView />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AIChatView from '@/views/public/Chat/AIChatView.vue'
|
||||
</script>
|
||||
```
|
||||
|
||||
### 集成到路由
|
||||
```typescript
|
||||
{
|
||||
path: '/chat',
|
||||
name: 'Chat',
|
||||
component: () => import('@/views/public/Chat/AIChatView.vue')
|
||||
}
|
||||
```
|
||||
|
||||
## 后续接入真实 API
|
||||
|
||||
### 修改发送消息函数
|
||||
在 `AIChatView.vue` 中找到 `handleSend` 函数,将 Mock 实现替换为真实 API 调用:
|
||||
|
||||
```typescript
|
||||
// 替换这部分 Mock 代码
|
||||
setTimeout(async () => {
|
||||
const mockResponses = [...]
|
||||
// ...
|
||||
}, 1000)
|
||||
|
||||
// 改为真实 API 调用
|
||||
try {
|
||||
const response = await chatAPI.sendMessage({
|
||||
message: text,
|
||||
agentId: currentAgent.value.id,
|
||||
conversationId: currentConversationId.value
|
||||
})
|
||||
|
||||
const assistantMessage: Message = {
|
||||
id: response.id,
|
||||
content: response.content,
|
||||
role: 'assistant',
|
||||
timestamp: response.timestamp
|
||||
}
|
||||
messages.value.push(assistantMessage)
|
||||
} catch (error) {
|
||||
console.error('发送失败:', error)
|
||||
}
|
||||
```
|
||||
|
||||
### 加载历史对话
|
||||
在 `onMounted` 中添加历史对话加载:
|
||||
|
||||
```typescript
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const history = await chatAPI.getConversations()
|
||||
conversations.value = history
|
||||
} catch (error) {
|
||||
console.error('加载历史失败:', error)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 依赖
|
||||
|
||||
- Vue 3
|
||||
- Element Plus
|
||||
- TypeScript
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 确保已安装 Element Plus 并正确配置图标
|
||||
2. `/logo.jpg` 需要存在于 public 目录
|
||||
3. 消息滚动使用了 `scrollTop`,需要在有高度的容器中使用
|
||||
4. 缩进使用 4 个空格(符合用户规则)
|
||||
|
||||
## 待优化功能
|
||||
|
||||
- [ ] 消息流式输出
|
||||
- [ ] 代码块语法高亮
|
||||
- [ ] Markdown 渲染
|
||||
- [ ] 消息重新生成
|
||||
- [ ] 消息编辑
|
||||
- [ ] 导出对话记录
|
||||
- [ ] 附件上传功能实现
|
||||
- [ ] 语音输入功能实现
|
||||
@@ -0,0 +1,82 @@
|
||||
.chat-default {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ai-avatar {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.avatar-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 40px;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-text {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.suggestion-cards {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
max-width: 800px;
|
||||
|
||||
.suggestion-card {
|
||||
width: 220px;
|
||||
padding: 20px;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #7c3aed;
|
||||
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-text {
|
||||
color: #374151;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="chat-default">
|
||||
<!-- AI 头像 -->
|
||||
<div class="ai-avatar">
|
||||
<div class="avatar-icon" :style="{ background: agent.color }">
|
||||
{{ agent.icon }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 描述文字 -->
|
||||
<p class="welcome-text">
|
||||
{{ agent.description }}
|
||||
</p>
|
||||
|
||||
<!-- 欢迎标题 -->
|
||||
<h2 class="welcome-title">{{ welcomeTitle }}</h2>
|
||||
|
||||
<!-- 建议卡片 -->
|
||||
<div class="suggestion-cards">
|
||||
<div
|
||||
v-for="(suggestion, index) in currentSuggestions"
|
||||
:key="index"
|
||||
class="suggestion-card"
|
||||
@click="handleSuggestionClick(suggestion)"
|
||||
>
|
||||
<div class="card-icon" :style="{ background: agent.color }">
|
||||
<component :is="cardIcons[index % cardIcons.length]" />
|
||||
</div>
|
||||
<p class="card-text">{{ suggestion }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
import { OfficeBuilding, Warning, Cloudy } from '@element-plus/icons-vue'
|
||||
|
||||
interface Agent {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
color: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
agent: Agent
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
suggestionClick: [suggestion: string]
|
||||
}>()
|
||||
|
||||
// 各智能体的建议内容
|
||||
const agentSuggestions: Record<string, string[]> = {
|
||||
default: [
|
||||
'城市生命线关键设施有哪些?',
|
||||
'消防安全隐患常见问题以及处理措施有哪些?',
|
||||
'如何平衡排水能力和生态环境保护?'
|
||||
],
|
||||
emergency: [
|
||||
'应急预案应该包含哪些关键内容?',
|
||||
'突发事件的处理流程是什么?',
|
||||
'如何建立高效的应急响应机制?'
|
||||
],
|
||||
analysis: [
|
||||
'如何分析城市设施运行数据?',
|
||||
'帮我生成一份数据分析报告',
|
||||
'如何预测设施故障风险?'
|
||||
],
|
||||
safety: [
|
||||
'安全检查的标准流程是什么?',
|
||||
'常见安全隐患有哪些类型?',
|
||||
'如何制定隐患整改计划?'
|
||||
]
|
||||
}
|
||||
|
||||
// 各智能体的欢迎标题
|
||||
const agentWelcomeTitles: Record<string, string> = {
|
||||
default: '今天需要我帮你做点什么吗?',
|
||||
emergency: '需要我帮你处理什么应急事件?',
|
||||
analysis: '需要我帮你分析什么数据?',
|
||||
safety: '需要我帮你检查什么安全隐患?'
|
||||
}
|
||||
|
||||
// 当前智能体的建议
|
||||
const currentSuggestions = computed(() => {
|
||||
return agentSuggestions[props.agent.id] || agentSuggestions.default
|
||||
})
|
||||
|
||||
// 当前欢迎标题
|
||||
const welcomeTitle = computed(() => {
|
||||
return agentWelcomeTitles[props.agent.id] || agentWelcomeTitles.default
|
||||
})
|
||||
|
||||
// 卡片图标
|
||||
const cardIcons = [OfficeBuilding, Warning, Cloudy]
|
||||
|
||||
// 处理建议点击
|
||||
const handleSuggestionClick = (suggestion: string) => {
|
||||
emit('suggestionClick', suggestion)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import url('./ChatDefault.scss');
|
||||
</style>
|
||||
@@ -71,6 +71,7 @@ import type { LoginParam } from 'shared/types'
|
||||
import { authAPI } from 'shared/authAPI'
|
||||
import { getAesInstance } from 'shared/utils'
|
||||
import { TokenManager } from 'shared/api'
|
||||
import { resetDynamicRoutes } from '@/router'
|
||||
|
||||
// 路由
|
||||
const router = useRouter()
|
||||
@@ -130,22 +131,40 @@ async function handleLogin() {
|
||||
const response = await authAPI.login(loginParam)
|
||||
|
||||
// 7. 检查登录结果
|
||||
if (response.data.success && response.data.data) {
|
||||
const loginData = response.data.data
|
||||
if (response.success && response.data) {
|
||||
const loginData = response.data
|
||||
|
||||
// 8. 保存 Token
|
||||
if (loginData.token) {
|
||||
TokenManager.setToken(loginData.token, loginForm.rememberMe)
|
||||
localStorage.setItem('token', loginData.token)
|
||||
}
|
||||
|
||||
// 9. 显示成功消息
|
||||
// 9. 保存 LoginDomain 到 LocalStorage
|
||||
if (loginData) {
|
||||
localStorage.setItem('loginDomain', JSON.stringify(loginData))
|
||||
localStorage.setItem('views', JSON.stringify(loginData.userViews))
|
||||
}
|
||||
|
||||
// 10. 保存用户信息(用于路由守卫检查)
|
||||
if (loginData.user) {
|
||||
localStorage.setItem('userInfo', JSON.stringify(loginData.user))
|
||||
} else if (loginData.userInfo) {
|
||||
localStorage.setItem('userInfo', JSON.stringify(loginData.userInfo))
|
||||
}
|
||||
|
||||
// 11. 重置动态路由加载状态(让路由守卫重新加载)
|
||||
resetDynamicRoutes()
|
||||
|
||||
// 12. 显示成功消息
|
||||
ElMessage.success('登录成功!')
|
||||
|
||||
// 10. 跳转到首页
|
||||
router.push('/home')
|
||||
// 13. 跳转到首页或重定向地址
|
||||
const redirect = router.currentRoute.value.query.redirect as string
|
||||
router.push(redirect || '/')
|
||||
} else {
|
||||
// 登录失败
|
||||
ElMessage.error(response.data.message || '登录失败,请检查用户名和密码')
|
||||
ElMessage.error(response.message || '登录失败,请检查用户名和密码')
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('登录失败:', error)
|
||||
Reference in New Issue
Block a user