mock数据,AI对话,全部应用

This commit is contained in:
2025-12-12 18:17:38 +08:00
parent 8b211fbad6
commit 0a72416365
41 changed files with 5667 additions and 205 deletions

View 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 Federationplatform 依赖 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
}
```

View 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 [];
},
}

View 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 [];
},
}

View File

@@ -0,0 +1,2 @@
export * from './agent'
export * from './chat'

View File

@@ -0,0 +1 @@
export * from './ai'

View File

@@ -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') {
// 所有菜单都通过路由跳转
if (item.url) {
router.push(item.url)
if (item.type === 'iframe') {
iframeLoading.value = true
// iframe 模式不需要路由跳转
}
}
}
@@ -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 }

View 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
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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> {}
}
```

View File

@@ -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;
}
}
}

View File

@@ -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>

View File

@@ -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;
}
}
}

View File

@@ -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>

View File

@@ -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;
}
}
}
}
}

View File

@@ -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>

View File

@@ -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 渲染
- [ ] 消息重新生成
- [ ] 消息编辑
- [ ] 导出对话记录
- [ ] 附件上传功能实现
- [ ] 语音输入功能实现

View File

@@ -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;
}
}
}

View File

@@ -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>

View File

@@ -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)

View File

@@ -0,0 +1,195 @@
# Shared 模块导出规范
## 导出路径组织
为了保持代码的清晰性和可维护性shared 包的模块导出已按照功能分类组织。
### 组件模块 (Components)
```typescript
// 整体导出
import { FileUpload, DynamicFormItem } from 'shared/components'
// 单独导入(推荐)
import FileUpload from 'shared/components/FileUpload'
import DynamicFormItem from 'shared/components/DynamicFormItem'
```
**可用组件:**
- `shared/components/FileUpload` - 文件上传组件(支持 cover/dialog/content 三种模式)
- `shared/components/DynamicFormItem` - 动态表单项组件
---
### API 模块
```typescript
// 整体导出
import { api, TokenManager } from 'shared/api'
// 单独导入(推荐)
import { authAPI } from 'shared/api/auth'
import { fileAPI } from 'shared/api/file'
```
**可用 API**
- `shared/api` - API 基础模块和 TokenManager
- `shared/api/auth` - 认证相关 API
- `shared/api/file` - 文件管理 API
---
### Utils 工具模块
```typescript
// 整体导出
import { formatFileSize, isImageFile } from 'shared/utils'
// 单独导入
import { getDeviceType, isMobile } from 'shared/utils/device'
import {
generateSimpleRoutes,
loadViewsFromStorage,
buildMenuTree
} from 'shared/utils/route'
import { formatFileSize, isImageFile } from 'shared/utils/file'
```
**可用工具:**
- `shared/utils` - 通用工具函数集合
- `shared/utils/device` - 设备检测工具
- `shared/utils/route` - 路由生成和菜单构建工具
- `generateSimpleRoutes()` - 生成简化路由(适合直接添加到 router
- `loadViewsFromStorage()` - 从 localStorage 加载视图数据
- `buildMenuTree()` - 构建菜单树结构
- `generateRoutes()` - 生成完整路由配置
- 更多工具函数...
- `shared/utils/file` - 文件处理工具
---
### Types 类型模块
```typescript
// 整体导出
import type { LoginParam, TbSysViewDTO } from 'shared/types'
// 单独导入
import type { LoginParam, LoginDomain } from 'shared/types/auth'
import type { TbSysFileDTO } from 'shared/types/file'
import type { SysUserVO, SysConfigVO } from 'shared/types/sys'
```
**可用类型:**
- `shared/types` - 所有类型的统一导出
- `shared/types/base` - 基础类型BaseVO, BaseDTO 等)
- `shared/types/auth` - 认证相关类型
- `shared/types/file` - 文件相关类型
- `shared/types/sys` - 系统相关类型
---
## 向后兼容性
为了保持向后兼容,旧的导入路径仍然可用(但不推荐):
```typescript
// ❌ 旧路径(不推荐,但仍可用)
import FileUpload from 'shared/FileUpload'
import { authAPI } from 'shared/authAPI'
import { fileAPI } from 'shared/fileAPI'
// ✅ 新路径(推荐)
import FileUpload from 'shared/components/FileUpload'
import { authAPI } from 'shared/api/auth'
import { fileAPI } from 'shared/api/file'
```
---
## 最佳实践
### 1. 使用明确的路径
```typescript
// ✅ 推荐:路径清晰,便于理解
import FileUpload from 'shared/components/FileUpload'
import { authAPI } from 'shared/api/auth'
// ❌ 不推荐:路径模糊
import FileUpload from 'shared/FileUpload'
```
### 2. 按需导入
```typescript
// ✅ 推荐:只导入需要的模块
import { formatFileSize } from 'shared/utils/file'
// ❌ 不推荐:导入整个模块
import * as utils from 'shared/utils'
```
### 3. 使用类型导入
```typescript
// ✅ 推荐:明确标识类型导入
import type { LoginParam } from 'shared/types/auth'
import { authAPI } from 'shared/api/auth'
// ❌ 不推荐:混合导入
import { LoginParam, authAPI } from 'shared/types'
```
---
## 添加新模块
如果需要添加新的导出模块,请按照以下步骤:
1. **在 vite.config.ts 中添加导出**
```typescript
exposes: {
'./components/YourComponent': './src/components/yourcomponent/YourComponent.vue'
}
```
2. **在 shared.d.ts 中添加类型声明**
```typescript
declare module 'shared/components/YourComponent' {
import { DefineComponent } from 'vue'
const YourComponent: DefineComponent<{}, {}, any>
export default YourComponent
}
```
3. **更新此文档**
---
## 常见问题
### Q: TypeScript 报错找不到模块?
A: 尝试以下方法:
1. 重启 TypeScript 语言服务器VS Code: `Ctrl+Shift+P` → Restart TS Server
2. 确认 `shared.d.ts` 文件已正确更新
3. 检查 shared 包是否已正确构建
### Q: 运行时找不到模块?
A: 确保:
1. shared 包已启动(`npm run dev` 在 shared 目录)
2. `vite.config.ts` 中的 exposes 配置正确
3. Module Federation 配置正确加载
### Q: 如何查看所有可用模块?
A: 查看 `packages/shared/vite.config.ts` 的 `exposes` 配置
---
## 更新日志
### 2025-12-12
- ✅ 重新组织导出路径,使用清晰的分类前缀
- ✅ 添加向后兼容性支持
- ✅ 更新类型声明文件
- ✅ 添加更多常用模块导出

View File

@@ -0,0 +1,287 @@
# 路由生成逻辑重构说明
## 重构目标
将 Platform 中的通用路由生成逻辑提取到 shared 包中,使其他 web 服务也可以复用。
## 架构设计
### 职责划分
**Shared 包shared/utils/route**
- ✅ 提供通用的路由生成方法
- ✅ 提供视图树构建方法
- ✅ 提供 localStorage 数据加载方法
- ✅ 不依赖特定的 router 实例
- ✅ 不依赖特定的组件加载方式
**Platform 包platform/src/router/dynamicRoute.ts**
- ✅ 提供 Platform 特定的布局组件映射
- ✅ 提供 Platform 特定的组件加载器
- ✅ 调用 shared 中的通用方法
- ✅ 将生成的路由添加到 Platform 的 router 实例
## 核心方法
### Shared 包新增方法
#### 1. `generateSimpleRoutes()`
```typescript
export function generateSimpleRoutes(
views: TbSysViewDTO[],
config: RouteGeneratorConfig,
options?: GenerateSimpleRoutesOptions
): RouteRecordRaw[]
```
**功能**:生成简化的路由配置,适合直接添加到 router
**参数**
- `views` - 视图列表
- `config` - 路由生成器配置(布局映射、组件加载器等)
- `options` - 可选配置
- `asRootChildren` - 是否作为根路由的子路由
- `iframePlaceholder` - iframe 类型视图的占位组件
- `verbose` - 是否启用详细日志
**返回**:路由配置数组
#### 2. `loadViewsFromStorage()`
```typescript
export function loadViewsFromStorage(
storageKey?: string,
viewsPath?: string
): TbSysViewDTO[] | null
```
**功能**:从 localStorage 加载视图数据
**参数**
- `storageKey` - localStorage 的 key默认'loginDomain'
- `viewsPath` - 视图数据在对象中的路径(默认:'userViews',支持嵌套如 'user.views'
**返回**:视图列表,如果不存在返回 null
### Platform 包简化后的方法
#### 1. `addDynamicRoutes()`
```typescript
export function addDynamicRoutes(views: TbSysViewDTO[]) {
// 使用 shared 中的通用方法生成路由
const routes = generateSimpleRoutes(views, routeConfig, routeOptions)
// 将生成的路由添加到 Platform 的 router
routes.forEach(route => {
router.addRoute('Root', route)
})
}
```
#### 2. `loadRoutesFromStorage()`
```typescript
export function loadRoutesFromStorage(): boolean {
// 使用 shared 中的通用方法加载视图数据
const views = loadViewsFromStorage('loginDomain', 'userViews')
if (views) {
addDynamicRoutes(views)
return true
}
return false
}
```
## 使用示例
### 在 Platform 中使用
```typescript
import { loadRoutesFromStorage, addDynamicRoutes } from '@/router/dynamicRoute'
// 从 localStorage 加载并添加路由
loadRoutesFromStorage()
// 或者手动传入视图数据
const views = [...] // 从 API 获取
addDynamicRoutes(views)
```
### 在其他 Web 服务中使用
```typescript
import {
generateSimpleRoutes,
loadViewsFromStorage,
type RouteGeneratorConfig
} from 'shared/utils/route'
import router from './router'
// 1. 配置路由生成器
const config: RouteGeneratorConfig = {
layoutMap: {
'MyLayout': () => import('./layouts/MyLayout.vue')
},
viewLoader: (path) => {
// 自定义组件加载逻辑
return () => import(`./views/${path}.vue`)
}
}
// 2. 加载视图数据
const views = loadViewsFromStorage()
// 3. 生成路由
if (views) {
const routes = generateSimpleRoutes(views, config, {
asRootChildren: true,
verbose: true
})
// 4. 添加到 router
routes.forEach(route => {
router.addRoute('Root', route)
})
}
```
## 配置说明
### RouteGeneratorConfig
```typescript
interface RouteGeneratorConfig {
/** 布局组件映射表 */
layoutMap: Record<string, () => Promise<any>>
/** 视图组件加载器 */
viewLoader: (componentPath: string) => (() => Promise<any>) | null
/** 静态路由列表(可选) */
staticRoutes?: RouteRecordRaw[]
/** 404 组件(可选) */
notFoundComponent?: () => Promise<any>
}
```
### GenerateSimpleRoutesOptions
```typescript
interface GenerateSimpleRoutesOptions {
/** 是否作为根路由的子路由(路径去掉前导 / */
asRootChildren?: boolean
/** iframe 类型视图的占位组件 */
iframePlaceholder?: () => Promise<any>
/** 是否启用详细日志 */
verbose?: boolean
}
```
## 优势
### 1. 代码复用
- ✅ 通用逻辑只需维护一份
- ✅ 其他 web 服务可以直接使用
- ✅ 减少重复代码
### 2. 职责清晰
- ✅ Shared 负责通用逻辑
- ✅ 各个服务负责特定配置
- ✅ 易于理解和维护
### 3. 灵活性
- ✅ 通过配置注入实现定制化
- ✅ 支持多种使用方式
- ✅ 易于扩展
### 4. 可测试性
- ✅ 通用方法独立测试
- ✅ 配置化便于 mock
- ✅ 减少耦合
## 迁移指南
如果其他服务想要使用 shared 中的路由生成逻辑:
### 步骤 1准备配置
```typescript
// 1. 准备布局组件映射
const layoutMap = {
'MainLayout': () => import('./layouts/MainLayout.vue'),
'BlankLayout': () => import('./layouts/BlankLayout.vue')
}
// 2. 准备组件加载器
const VIEW_MODULES = import.meta.glob('./views/**/*.vue')
const viewLoader = (path: string) => {
const fullPath = `./views/${path}.vue`
return VIEW_MODULES[fullPath] || null
}
// 3. 组装配置
const config: RouteGeneratorConfig = {
layoutMap,
viewLoader
}
```
### 步骤 2加载视图数据
```typescript
import { loadViewsFromStorage } from 'shared/utils/route'
// 从 localStorage 加载
const views = loadViewsFromStorage('loginDomain', 'userViews')
// 或从 API 加载
// const views = await api.getUserViews()
```
### 步骤 3生成并添加路由
```typescript
import { generateSimpleRoutes } from 'shared/utils/route'
if (views) {
const routes = generateSimpleRoutes(views, config, {
asRootChildren: true,
verbose: process.env.NODE_ENV === 'development'
})
routes.forEach(route => {
router.addRoute('YourRootRouteName', route)
})
}
```
## 注意事项
1. **组件加载器**:每个服务的组件路径可能不同,需要自行实现 `viewLoader`
2. **布局组件**:需要提供服务特定的布局组件映射
3. **路由实例**:需要自行将生成的路由添加到服务的 router 实例
4. **iframe 支持**:如果需要支持 iframe 视图,需要提供占位组件
## 文件变更
### 新增文件
- `shared/src/utils/route/route-generator.ts` - 新增通用方法
### 修改文件
- `shared/src/utils/route/index.ts` - 新增导出
- `shared/vite.config.ts` - 新增导出配置
- `shared/EXPOSES.md` - 更新文档
- `platform/src/router/dynamicRoute.ts` - 简化代码
- `platform/src/types/shared.d.ts` - 新增类型声明
## 更新日志
### 2025-12-12
- ✅ 将路由生成通用逻辑提取到 shared
- ✅ 新增 `generateSimpleRoutes` 方法
- ✅ 新增 `loadViewsFromStorage` 方法
- ✅ 简化 Platform 的 dynamicRoute.ts
- ✅ 更新文档和类型声明

View File

@@ -1,73 +0,0 @@
/**
* Shared 模块静态文件服务器
* 提供构建后的 ES Module 文件供其他应用使用
*
* 使用方式:
* 1. npm run build:esm # 先构建
* 2. node server.js # 启动服务器
*/
import express from 'express'
import cors from 'cors'
import path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const app = express()
const PORT = 5000
// 启用 CORS
app.use(cors())
// 静态文件服务:/shared/* -> dist/esm/*
app.use('/shared', express.static(path.join(__dirname, 'dist/esm'), {
setHeaders: (res, filepath) => {
// 设置正确的 MIME 类型
if (filepath.endsWith('.js')) {
res.setHeader('Content-Type', 'application/javascript; charset=utf-8')
}
// 允许跨域
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
}
}))
// 健康检查
app.get('/health', (req, res) => {
res.json({
status: 'ok',
port: PORT,
modules: ['components', 'utils', 'api', 'composables', 'types']
})
})
// 模块列表
app.get('/modules', (req, res) => {
res.json({
modules: {
components: '/shared/components.js',
utils: '/shared/utils.js',
api: '/shared/api.js',
composables: '/shared/composables.js',
types: '/shared/types.js'
}
})
})
// 启动服务器
app.listen(PORT, () => {
console.log(`\n🚀 Shared 模块服务器已启动!`)
console.log(``)
console.log(`📦 提供以下模块:`)
console.log(` - http://localhost:${PORT}/shared/components.js`)
console.log(` - http://localhost:${PORT}/shared/utils.js`)
console.log(` - http://localhost:${PORT}/shared/api.js`)
console.log(` - http://localhost:${PORT}/shared/composables.js`)
console.log(` - http://localhost:${PORT}/shared/types.js`)
console.log(``)
console.log(`🔍 健康检查http://localhost:${PORT}/health`)
console.log(`📋 模块列表http://localhost:${PORT}/modules`)
console.log(``)
})

View File

@@ -1,5 +1,6 @@
import { api } from '@/api/index'
import type { LoginParam, LoginDomain } from '@/types'
import type { LoginParam, LoginDomain, ResultDomain } from '@/types'
/**
* 认证 API
@@ -14,16 +15,18 @@ export const authAPI = {
* @param loginParam 登录参数
* @returns 登录结果(包含 token 和用户信息)
*/
login(loginParam: LoginParam) {
return api.post<LoginDomain>(`${this.baseUrl}/login`, loginParam)
async login(loginParam: LoginParam): Promise<ResultDomain<LoginDomain>> {
const response = await api.post<LoginDomain>(`${this.baseUrl}/login`, loginParam)
return response.data
},
/**
* 用户登出
* @returns 登出结果
*/
logout() {
return api.post<LoginDomain>(`${this.baseUrl}/logout`)
async logout(): Promise<ResultDomain<null>> {
const response = await api.post<null>(`${this.baseUrl}/logout`)
return response.data
},
/**
@@ -31,16 +34,18 @@ export const authAPI = {
* @param loginParam 登录参数(包含验证码类型)
* @returns 验证码结果
*/
getCaptcha(loginParam: LoginParam) {
return api.post<LoginDomain>(`${this.baseUrl}/captcha`, loginParam)
async getCaptcha(loginParam: LoginParam): Promise<ResultDomain<any>> {
const response = await api.post<any>(`${this.baseUrl}/captcha`, loginParam)
return response.data
},
/**
* 刷新 Token
* @returns 新的登录信息
*/
refreshToken() {
return api.post<LoginDomain>(`${this.baseUrl}/refresh`)
async refreshToken(): Promise<ResultDomain<LoginDomain>> {
const response = await api.post<LoginDomain>(`${this.baseUrl}/refresh`)
return response.data
},
/**
@@ -48,8 +53,9 @@ export const authAPI = {
* @param email 邮箱地址
* @returns 发送结果
*/
sendEmailCode(email: string) {
return api.post<LoginDomain>(`${this.baseUrl}/send-email-code`, { email })
async sendEmailCode(email: string): Promise<ResultDomain<null>> {
const response = await api.post<null>(`${this.baseUrl}/send-email-code`, { email })
return response.data
},
/**
@@ -57,8 +63,9 @@ export const authAPI = {
* @param phone 手机号
* @returns 发送结果
*/
sendSmsCode(phone: string) {
return api.post<LoginDomain>(`${this.baseUrl}/send-sms-code`, { phone })
async sendSmsCode(phone: string): Promise<ResultDomain<null>> {
const response = await api.post<null>(`${this.baseUrl}/send-sms-code`, { phone })
return response.data
},
/**
@@ -66,7 +73,7 @@ export const authAPI = {
* @param registerData 注册数据
* @returns 注册结果(成功后自动登录,返回 token
*/
register(registerData: {
async register(registerData: {
registerType: 'username' | 'phone' | 'email'
username?: string
phone?: string
@@ -78,15 +85,17 @@ export const authAPI = {
smsSessionId?: string
emailSessionId?: string
studentId?: string
}) {
return api.post<LoginDomain>(`${this.baseUrl}/register`, registerData)
}): Promise<ResultDomain<LoginDomain>> {
const response = await api.post<LoginDomain>(`${this.baseUrl}/register`, registerData)
return response.data
},
/**
* 健康检查
* @returns 健康状态
*/
health() {
return api.get<string>(`${this.baseUrl}/health`)
async health(): Promise<ResultDomain<string>> {
const response = await api.get<string>(`${this.baseUrl}/health`)
return response.data
}
}

View File

@@ -17,25 +17,52 @@ export interface LoginParam {
}
// LoginDomain - 登录信息
import type {
TbSysUserDTO,
TbSysUserInfoDTO,
TbSysUserRoleDTO
} from '@/types/sys/user'
import type {
TbSysDeptDTO,
TbSysPermissionDTO,
TbSysViewDTO
} from '@/types/sys/permission'
/**
* 登录返回的领域对象
*/
export interface LoginDomain {
/** 用户ID */
userId?: string
/** 用户名 */
username?: string
/** 邮箱 */
email?: string
/** 手机 */
phone?: string
/** 访问令牌 */
accessToken?: string
/** 刷新令牌 */
refreshToken?: string
/** 令牌类型 */
tokenType?: string
/** 过期时间(秒) */
expiresIn?: number
/** 用户权限列表 */
permissions?: string[]
/** 用户基本信息 */
user?: TbSysUserDTO
/** 用户详细信息 */
userInfo?: TbSysUserInfoDTO
/** 用户角色列表 */
roles?: string[]
userRoles?: TbSysUserRoleDTO[]
/** 用户部门列表 */
userDepts?: TbSysDeptDTO[]
/** 用户权限列表 */
userPermissions?: TbSysPermissionDTO[]
/** 用户视图列表(视图即菜单,用于生成路由和侧边栏) */
userViews?: TbSysViewDTO[]
/** 访问令牌 */
token?: string
/** 令牌过期时间 */
tokenExpireTime?: string | Date
/** 登录时间 */
loginTime?: string
/** IP地址 */
ipAddress?: string
/** 登录类型 */
loginType?: string
}

View File

@@ -0,0 +1,10 @@
/**
* 菜单类型枚举
*/
export enum MenuType {
NAVIGATION = 'navigation', // 导航菜单
SIDEBAR = 'sidebar', // 侧边栏菜单
MENU = 'menu', // 普通菜单
PAGE = 'page', // 页面
BUTTON = 'button' // 按钮
}

View File

@@ -2,6 +2,7 @@ export * from "./response"
export * from "./page"
export * from "./base"
export * from "./sys"
export * from "./enums"
// 服务 types
export * from "./auth"

View File

@@ -120,16 +120,22 @@ export interface TbSysViewDTO extends BaseDTO {
url?: string;
/** 组件 */
component?: string;
/** iframe URL */
iframeUrl?: string;
/** 图标 */
icon?: string;
/** 类型 */
type?: number;
/** 视图类型 route\iframe*/
viewType?: string;
/** 布局 */
layout?: string;
/** 排序 */
orderNum?: number;
/** 描述 */
description?: string;
/** 子视图列表(用于构建树形结构) */
children?: TbSysViewDTO[];
}
// TbSysPermissionDTO - 系统权限DTO

View File

@@ -0,0 +1,109 @@
import { ref, onMounted, onUnmounted } from 'vue'
/**
* 设备类型枚举
*/
export enum DeviceType {
MOBILE = 'mobile', // h5移动端
DESKTOP = 'desktop' // web桌面端
}
/**
* 屏幕尺寸断点
*/
export const BREAKPOINTS = {
mobile: 768, // 小于768px为移动端(h5)
desktop: 768 // 大于等于768px为桌面端(web)
}
/**
* 检测当前设备类型
*/
export function getDeviceType(): DeviceType {
const width = window.innerWidth
if (width < BREAKPOINTS.mobile) {
return DeviceType.MOBILE // h5移动端
} else {
return DeviceType.DESKTOP // web桌面端
}
}
/**
* 检测是否为移动端
*/
export function isMobile(): boolean {
return getDeviceType() === DeviceType.MOBILE
}
/**
* 检测是否为桌面端
*/
export function isDesktop(): boolean {
return getDeviceType() === DeviceType.DESKTOP
}
/**
* 响应式设备类型 Hook
*/
export function useDevice() {
const deviceType = ref<DeviceType>(getDeviceType())
const isMobileDevice = ref(isMobile())
const isDesktopDevice = ref(isDesktop())
const updateDeviceType = () => {
deviceType.value = getDeviceType()
isMobileDevice.value = isMobile()
isDesktopDevice.value = isDesktop()
}
onMounted(() => {
window.addEventListener('resize', updateDeviceType)
})
onUnmounted(() => {
window.removeEventListener('resize', updateDeviceType)
})
return {
deviceType,
isMobileDevice,
isDesktopDevice
}
}
/**
* 根据设备类型获取对应的组件路径
*/
export function getComponentPath(basePath: string, deviceType?: DeviceType): string {
const currentDeviceType = deviceType || getDeviceType()
// 如果是移动端(h5),尝试加载移动端版本
if (currentDeviceType === DeviceType.MOBILE) {
const mobilePath = basePath.replace('.vue', '.mobile.vue')
return mobilePath
}
// 默认返回桌面版本(web)
return basePath
}
/**
* 动态导入组件,支持回退机制
*/
export async function importResponsiveComponent(basePath: string) {
const deviceType = getDeviceType()
// 尝试加载设备特定的组件
if (deviceType === DeviceType.MOBILE) {
try {
const mobilePath = basePath.replace('.vue', '.mobile.vue')
return await import(/* @vite-ignore */ mobilePath)
} catch {
// 移动端组件不存在,回退到默认组件
}
}
// 加载默认组件(桌面端/web)
return await import(/* @vite-ignore */ basePath)
}

View File

@@ -1,6 +1,7 @@
/**
* Utils 统一导出
*/
export * from './file'
export * from './crypto'
export * from './device'
export * from './file'
export * from './route'

View File

@@ -0,0 +1,19 @@
// 导出所有路由生成相关的函数和类型
export * from './route-generator'
// 显式导出新增的函数和类型
export type {
RouteGeneratorConfig,
GenerateSimpleRoutesOptions
} from './route-generator'
export {
generateRoutes,
generateSimpleRoutes,
buildMenuTree,
filterMenusByPermissions,
findMenuByPath,
getMenuPath,
getFirstAccessibleMenuUrl,
loadViewsFromStorage
} from './route-generator'

View File

@@ -0,0 +1,789 @@
/**
* @description 动态路由生成器工具类
* @author yslg
* @since 2025-12-12
*
* 说明:此文件提供路由生成的通用方法,各个 web 服务可以使用这些方法生成自己的路由
*/
import type { RouteRecordRaw } from 'vue-router'
import type { TbSysViewDTO } from '@/types'
// 为了代码可读性,创建类型别名
type SysMenu = TbSysViewDTO
// 视图类型常量(对应后端的 type 字段)
const ViewType = {
MENU: 1, // 菜单
PAGE: 2, // 页面
BUTTON: 3 // 按钮
} as const
/**
* 路由生成器配置
*/
export interface RouteGeneratorConfig {
/**
* 布局组件映射表
* key: 布局名称value: 组件加载函数
*/
layoutMap: Record<string, () => Promise<any>>
/**
* 视图组件加载器
* 用于动态加载视图组件
*/
viewLoader: (componentPath: string) => Promise<any> | null
/**
* 静态路由列表(可选)
* 用于将静态路由转换为菜单项
*/
staticRoutes?: RouteRecordRaw[]
/**
* 404 组件路径(可选)
*/
notFoundComponent?: () => Promise<any>
}
/**
* 根据菜单生成路由配置
* @param menus 用户菜单列表
* @param config 路由生成器配置
* @returns Vue Router路由配置数组
*/
export function generateRoutes(
menus: SysMenu[],
config: RouteGeneratorConfig
): RouteRecordRaw[] {
if (!menus || menus.length === 0) {
return []
}
const routes: RouteRecordRaw[] = []
const pageRoutes: RouteRecordRaw[] = []
// 构建菜单树
const menuTree = buildMenuTree(menus, config.staticRoutes)
// 生成路由
menuTree.forEach(menu => {
const route = generateRouteFromMenu(menu, config, true)
if (route) {
routes.push(route)
// 递归提取所有 PAGE 类型的子菜单
extractPageChildren(route, pageRoutes, config)
}
})
// 将 PAGE 类型的路由添加到路由列表
routes.push(...pageRoutes)
return routes
}
/**
* 递归提取路由中的 PAGE 类型子菜单
*/
function extractPageChildren(
route: any,
pageRoutes: RouteRecordRaw[],
config: RouteGeneratorConfig
) {
// 检查当前路由是否有 PAGE 类型的子菜单
if (route.meta?.pageChildren && Array.isArray(route.meta.pageChildren)) {
route.meta.pageChildren.forEach((pageMenu: SysMenu) => {
const pageRoute = generateRouteFromMenu(pageMenu, config, true)
if (pageRoute) {
pageRoutes.push(pageRoute)
} else {
console.error(`[路由生成] 生成独立PAGE路由失败: ${pageMenu.name}`)
}
})
// 清理临时数据
delete route.meta.pageChildren
}
// 递归检查子路由
if (route.children && Array.isArray(route.children)) {
route.children.forEach((childRoute: any) => {
extractPageChildren(childRoute, pageRoutes, config)
})
}
}
/**
* 根据单个菜单生成路由
* @param menu 菜单对象
* @param config 路由生成器配置
* @param isTopLevel 是否是顶层菜单
* @returns 路由配置
*/
function generateRouteFromMenu(
menu: SysMenu,
config: RouteGeneratorConfig,
isTopLevel = true
): RouteRecordRaw | null {
// 跳过按钮类型
if (menu.type === ViewType.BUTTON) {
return null
}
// 跳过静态路由(已经在 router 中定义,不需要再次添加)
if (menu.component === '__STATIC_ROUTE__') {
return null
}
const route: any = {
path: menu.url || `/${menu.viewId}`,
name: menu.viewId,
meta: {
title: menu.name,
icon: menu.icon,
menuId: menu.viewId,
parentId: menu.parentId,
orderNum: menu.orderNum,
type: menu.type,
hideInMenu: false,
requiresAuth: true,
}
}
// 检查是否指定了布局(只有顶层菜单才使用布局)
const layout = isTopLevel ? (menu as any).layout : null
const hasChildren = menu.children && menu.children.length > 0
// 检查 component 是否是布局组件
const isComponentLayout = menu.component && (
config.layoutMap[menu.component] ||
(typeof menu.component === 'string' && menu.component.includes('Layout'))
)
// 确定路由组件
if (layout && config.layoutMap[layout]) {
// 如果指定了布局,使用指定的布局
route.component = config.layoutMap[layout]
} else if (isComponentLayout && hasChildren && isTopLevel && menu.component) {
// 如果 component 是布局组件且有子菜单,使用该布局组件作为父路由组件
route.component = config.layoutMap[menu.component]
} else if (hasChildren && isTopLevel) {
// 如果有子菜单但没有指定布局,根据菜单类型选择默认布局
if (menu.type === ViewType.MENU && !menu.parentId) {
route.component = config.layoutMap['SidebarLayout']
} else {
route.component = config.layoutMap['BasicLayout']
}
} else {
// 没有子菜单,也没有指定布局,使用具体的页面组件
if (menu.component) {
const component = config.viewLoader(menu.component)
if (component) {
route.component = component
} else {
// 组件加载失败,使用 404
route.component = config.notFoundComponent || (() => Promise.resolve({ default: { template: '<div>404</div>' } }))
}
} else {
// 使用路由占位组件
route.component = () => Promise.resolve({
default: {
template: '<router-view />'
}
})
}
}
// 处理子路由
if (layout && config.layoutMap[layout] && menu.component && isTopLevel) {
// 如果指定了布局,将页面组件作为子路由
const component = config.viewLoader(menu.component)
route.children = [{
path: '',
name: `${menu.viewId}_page`,
component: component || route.component,
meta: route.meta
}]
// 如果还有其他子菜单,继续添加
if (hasChildren) {
const pageChildren: SysMenu[] = []
const normalChildren: SysMenu[] = []
menu.children!.forEach((child: SysMenu) => {
if (child.type === ViewType.PAGE) {
pageChildren.push(child)
} else {
normalChildren.push(child)
}
})
// 添加普通子菜单
normalChildren.forEach(child => {
const childRoute = generateRouteFromMenu(child, config, false)
if (childRoute) {
route.children!.push(childRoute)
}
})
// PAGE 类型的菜单保存到 meta
if (pageChildren.length > 0) {
route.meta.pageChildren = pageChildren
}
}
} else if (hasChildren) {
// 处理有子菜单的情况
route.children = []
// 分离 PAGE 类型的子菜单和普通子菜单
const pageChildren: SysMenu[] = []
const normalChildren: SysMenu[] = []
menu.children!.forEach((child: SysMenu) => {
if (child.type === ViewType.PAGE) {
pageChildren.push(child)
} else {
normalChildren.push(child)
}
})
// 如果当前菜单有组件且有普通子菜单,创建默认子路由
if (menu.component && !isComponentLayout && normalChildren.length > 0) {
const component = config.viewLoader(menu.component)
route.children!.push({
path: '',
name: `${menu.viewId}_page`,
component: component || route.component,
meta: {
...route.meta,
}
})
}
// 只将普通子菜单加入 children
normalChildren.forEach(child => {
const childRoute = generateRouteFromMenu(child, config, false)
if (childRoute) {
route.children!.push(childRoute)
}
})
// PAGE 类型的菜单保存到 meta
if (pageChildren.length > 0) {
route.meta.pageChildren = pageChildren
}
// 自动重定向到第一个有URL的子菜单
if (!route.redirect && route.children.length > 0) {
const firstChildWithUrl = findFirstMenuWithUrl(normalChildren)
if (firstChildWithUrl?.url) {
route.redirect = firstChildWithUrl.url
}
}
}
return route
}
/**
* 查找第一个有URL的菜单
*/
function findFirstMenuWithUrl(menus: SysMenu[]): SysMenu | null {
for (const menu of menus) {
if (menu.type !== ViewType.BUTTON) {
if (menu.url) {
return menu
}
if (menu.children && menu.children.length > 0) {
const found = findFirstMenuWithUrl(menu.children)
if (found) return found
}
}
}
return null
}
/**
* 将静态路由转换为菜单项
*/
function convertRoutesToMenus(routes: RouteRecordRaw[]): SysMenu[] {
const menus: SysMenu[] = []
routes.forEach(route => {
if (route.children && route.children.length > 0) {
route.children.forEach(child => {
if (child.meta?.menuType !== undefined) {
const menu: SysMenu = {
viewId: child.name as string || child.path.replace(/\//g, '-'),
parentId: '0',
name: child.meta.title as string || child.name as string,
url: route.path,
type: child.meta.menuType as number,
orderNum: (child.meta.orderNum as number) || -1,
component: '__STATIC_ROUTE__',
}
menus.push(menu)
}
})
} else if (route.meta?.menuType !== undefined) {
const menu: SysMenu = {
viewId: route.name as string || route.path.replace(/\//g, '-'),
parentId: '0',
name: route.meta.title as string || route.name as string,
url: route.path,
type: route.meta.menuType as number,
orderNum: (route.meta.orderNum as number) || -1,
component: '__STATIC_ROUTE__',
}
menus.push(menu)
}
})
return menus
}
/**
* 构建菜单树结构
* @param menus 菜单列表
* @param staticRoutes 静态路由列表
* @returns 菜单树
*/
export function buildMenuTree(
menus: SysMenu[],
staticRoutes?: RouteRecordRaw[]
): SysMenu[] {
// 将静态路由转换为菜单项
const staticMenus = staticRoutes ? convertRoutesToMenus(staticRoutes) : []
// 合并动态菜单和静态菜单
const allMenus = [...staticMenus, ...menus]
if (allMenus.length === 0) {
return []
}
const menuMap = new Map<string, SysMenu>()
const rootMenus: SysMenu[] = []
const maxDepth = allMenus.length
// 创建菜单映射
allMenus.forEach(menu => {
if (menu.viewId) {
menuMap.set(menu.viewId, { ...menu, children: [] })
}
})
// 循环构建树结构
for (let depth = 0; depth < maxDepth; depth++) {
let hasChanges = false
allMenus.forEach(menu => {
if (!menu.viewId) return
const menuNode = menuMap.get(menu.viewId)
if (!menuNode) return
if (isNodeInTree(menuNode, rootMenus)) {
return
}
if (!menu.parentId || menu.parentId === '0' || menu.parentId === '') {
if (!isNodeInTree(menuNode, rootMenus)) {
rootMenus.push(menuNode)
hasChanges = true
}
} else {
const parent = menuMap.get(menu.parentId)
if (parent && isNodeInTree(parent, rootMenus)) {
if (!parent.children) {
parent.children = []
}
if (!parent.children.includes(menuNode)) {
parent.children.push(menuNode)
hasChanges = true
}
}
}
})
if (!hasChanges) {
break
}
}
// 排序
const sortMenus = (menus: SysMenu[]): SysMenu[] => {
return menus
.sort((a, b) => (a.orderNum || 0) - (b.orderNum || 0))
.map(menu => ({
...menu,
children: menu.children ? sortMenus(menu.children) : []
}))
}
return sortMenus(rootMenus)
}
/**
* 检查节点是否已经在树中
*/
function isNodeInTree(node: SysMenu, tree: SysMenu[]): boolean {
for (const treeNode of tree) {
if (treeNode.viewId === node.viewId) {
return true
}
if (treeNode.children && isNodeInTree(node, treeNode.children)) {
return true
}
}
return false
}
/**
* 根据权限过滤菜单
*/
export function filterMenusByPermissions(
menus: SysMenu[],
permissions: string[]
): SysMenu[] {
if (!menus || menus.length === 0) {
return []
}
return menus
.filter(() => true) // 暂时返回true后续可根据实际需求过滤
.map(menu => {
if (menu.children && menu.children.length > 0) {
return {
...menu,
children: filterMenusByPermissions(menu.children, permissions)
}
}
return menu
})
}
/**
* 查找路由路径对应的菜单
*/
export function findMenuByPath(menus: SysMenu[], path: string): SysMenu | null {
for (const menu of menus) {
if (menu.url === path) {
return menu
}
if (menu.children && menu.children.length > 0) {
const found = findMenuByPath(menu.children, path)
if (found) {
return found
}
}
}
return null
}
/**
* 获取菜单路径数组(面包屑导航用)
*/
export function getMenuPath(menus: SysMenu[], targetMenuId: string): SysMenu[] {
const path: SysMenu[] = []
function findPath(menuList: SysMenu[]): boolean {
for (const menu of menuList) {
path.push(menu)
if (menu.viewId === targetMenuId) {
return true
}
if (menu.children && menu.children.length > 0) {
if (findPath(menu.children)) {
return true
}
}
path.pop()
}
return false
}
findPath(menus)
return path
}
/**
* 获取第一个可访问的菜单URL用于登录后跳转
*/
export function getFirstAccessibleMenuUrl(menus: SysMenu[]): string | null {
if (!menus || menus.length === 0) {
return null
}
const firstMenu = findFirstMenuWithUrl(menus)
return firstMenu?.url || '/home'
}
/**
* 从 LocalStorage 加载用户视图数据
* @param storageKey localStorage 的 key默认为 'loginDomain'
* @param viewsPath 视图数据在对象中的路径,默认为 'userViews'
* @returns 视图列表,如果不存在返回 null
*/
export function loadViewsFromStorage(
storageKey: string = 'loginDomain',
viewsPath: string = 'userViews'
): TbSysViewDTO[] | null {
try {
const dataStr = localStorage.getItem(storageKey)
if (!dataStr) {
console.log(`[路由工具] LocalStorage 中没有 ${storageKey}`)
return null
}
const data = JSON.parse(dataStr)
// 支持嵌套路径,如 'user.views'
const paths = viewsPath.split('.')
let views = data
for (const path of paths) {
views = views?.[path]
}
if (views && Array.isArray(views) && views.length > 0) {
console.log(`[路由工具] 从 LocalStorage 加载视图,数量: ${views.length}`)
return views
}
console.log(`[路由工具] ${storageKey} 中没有 ${viewsPath} 或数据为空`)
return null
} catch (error) {
console.error('[路由工具] 从 LocalStorage 加载视图失败:', error)
return null
}
}
/**
* 生成简化的路由配置(用于直接添加到 router
* 相比 generateRoutes这个方法生成的路由更适合动态添加到现有路由树
*
* @param views 视图列表
* @param config 路由生成器配置
* @param options 额外选项
* @returns 路由配置数组
*/
export interface GenerateSimpleRoutesOptions {
/**
* 是否作为根路由的子路由(路径去掉前导 /
*/
asRootChildren?: boolean
/**
* iframe 类型视图的占位组件
*/
iframePlaceholder?: () => Promise<any>
/**
* 是否启用详细日志
*/
verbose?: boolean
}
export function generateSimpleRoutes(
views: TbSysViewDTO[],
config: RouteGeneratorConfig,
options: GenerateSimpleRoutesOptions = {}
): RouteRecordRaw[] {
const {
asRootChildren = false,
iframePlaceholder,
verbose = false
} = options
if (!views || views.length === 0) {
if (verbose) console.warn('[路由生成] 视图列表为空')
return []
}
if (verbose) {
console.log('[路由生成] 开始生成路由,视图数量:', views.length)
}
// 构建视图树
const viewTree = buildMenuTree(views)
if (verbose) {
console.log('[路由生成] 构建视图树,根节点数量:', viewTree.length)
}
const routes: RouteRecordRaw[] = []
// 遍历根节点,生成路由
viewTree.forEach(view => {
const route = generateSimpleRoute(view, config, {
asRootChild: asRootChildren,
iframePlaceholder,
verbose
})
if (route) {
routes.push(route)
if (verbose) {
console.log('[路由生成] 已生成路由:', {
path: route.path,
name: route.name,
hasComponent: !!route.component,
childrenCount: route.children?.length || 0
})
}
} else if (verbose) {
console.warn('[路由生成] 跳过无效视图:', view.name)
}
})
return routes
}
/**
* 从单个视图生成简化路由
*/
function generateSimpleRoute(
view: TbSysViewDTO,
config: RouteGeneratorConfig,
options: {
asRootChild?: boolean
iframePlaceholder?: () => Promise<any>
verbose?: boolean
} = {}
): RouteRecordRaw | null {
const { asRootChild = false, iframePlaceholder, verbose = false } = options
// 验证必要字段
if (!view.viewId) {
if (verbose) console.error('[路由生成] 视图缺少 viewId:', view)
return null
}
// 判断是否是 iframe 类型
const isIframe = (view as any).viewType === 'iframe' || !!(view as any).iframeUrl
// 处理路径和组件
let routePath = view.url || `/${view.viewId}`
let component: any
if (isIframe) {
// iframe 类型:使用占位组件
component = iframePlaceholder || (() => Promise.resolve({
default: {
template: '<div class="iframe-placeholder"></div>'
}
}))
} else if (view.component) {
// route 类型:加载实际组件
component = config.viewLoader(view.component)
if (!component) {
if (verbose) console.warn('[路由生成] 组件加载失败:', view.component)
}
}
// 根路径的子路由去掉前导斜杠
if (asRootChild && routePath.startsWith('/')) {
routePath = routePath.substring(1)
}
const hasChildren = view.children && view.children.length > 0
if (verbose) {
console.log('[路由生成] 视图信息:', {
viewId: view.viewId,
name: view.name,
url: view.url,
component: view.component,
isIframe,
hasChildren,
childrenCount: view.children?.length || 0
})
}
const route: any = {
path: routePath,
name: view.viewId,
meta: {
title: view.name || view.viewId,
icon: view.icon,
menuId: view.viewId,
orderNum: view.orderNum,
requiresAuth: true,
isIframe,
iframeUrl: (view as any).iframeUrl
}
}
// 根据 component 和 children 的情况处理
if (component && hasChildren) {
// 有组件且有子视图:组件作为空路径子路由
route.component = component
route.children = [
{
path: '',
name: `${view.viewId}_page`,
component: component,
meta: route.meta
}
]
// 添加其他子路由
view.children!.forEach(childView => {
const childRoute = generateSimpleRoute(childView, config, {
asRootChild: false,
iframePlaceholder,
verbose
})
if (childRoute) {
route.children.push(childRoute)
}
})
} else if (component && !hasChildren) {
// 只有组件,没有子视图
route.component = component
} else if (!component && hasChildren) {
// 没有组件,只有子视图(路由容器)
route.component = () => Promise.resolve({
default: {
template: '<router-view />'
}
})
route.children = []
// 添加子路由
view.children!.forEach(childView => {
const childRoute = generateSimpleRoute(childView, config, {
asRootChild: false,
iframePlaceholder,
verbose
})
if (childRoute) {
route.children.push(childRoute)
}
})
// 重定向到第一个子路由
if (route.children.length > 0) {
const firstChild = route.children[0]
route.redirect = firstChild.path
}
} else {
// 既没有组件也没有子视图
if (verbose) {
console.warn('[路由生成] 视图既无组件也无子视图:', view.name)
}
return null
}
return route
}

View File

@@ -32,23 +32,29 @@ export default defineConfig({
filename: 'remoteEntry.js',
// 暴露的模块
exposes: {
// 通用组件
'./FileUpload': './src/components/fileupload/FileUpload.vue',
'./DynamicFormItem': './src/components/dynamicFormItem/DynamicFormItem.vue',
// ========== 组件模块 ==========
'./components': './src/components/index.ts',
'./components/FileUpload': './src/components/fileupload/FileUpload.vue',
'./components/DynamicFormItem': './src/components/dynamicFormItem/DynamicFormItem.vue',
// API 模块
// ========== API 模块 ==========
'./api': './src/api/index.ts',
'./authAPI': './src/api/auth/auth.ts',
'./fileAPI': './src/api/file/file.ts',
'./api/auth': './src/api/auth/auth.ts',
'./api/file': './src/api/file/file.ts',
// Utils 模块
// ========== Utils 工具模块 ==========
'./utils': './src/utils/index.ts',
'./utils/device': './src/utils/device.ts',
'./utils/route': './src/utils/route/index.ts',
'./utils/route/generator': './src/utils/route/route-generator.ts',
'./utils/file': './src/utils/file.ts',
// Types 模块
// ========== Types 类型模块 ==========
'./types': './src/types/index.ts',
// 整体导出
'./components': './src/components/index.ts'
'./types/base': './src/types/base/index.ts',
'./types/auth': './src/types/auth/index.ts',
'./types/file': './src/types/file/index.ts',
'./types/sys': './src/types/sys/index.ts'
},
// 共享依赖(重要:避免重复加载)
shared: {

View File

@@ -0,0 +1,109 @@
import { ref, onMounted, onUnmounted } from 'vue';
/**
* 设备类型枚举
*/
export enum DeviceType {
MOBILE = 'mobile', // h5移动端
DESKTOP = 'desktop' // web桌面端
}
/**
* 屏幕尺寸断点
*/
export const BREAKPOINTS = {
mobile: 768, // 小于768px为移动端(h5)
desktop: 768 // 大于等于768px为桌面端(web)
};
/**
* 检测当前设备类型
*/
export function getDeviceType(): DeviceType {
const width = window.innerWidth;
if (width < BREAKPOINTS.mobile) {
return DeviceType.MOBILE; // h5移动端
} else {
return DeviceType.DESKTOP; // web桌面端
}
}
/**
* 检测是否为移动端
*/
export function isMobile(): boolean {
return getDeviceType() === DeviceType.MOBILE;
}
/**
* 检测是否为桌面端
*/
export function isDesktop(): boolean {
return getDeviceType() === DeviceType.DESKTOP;
}
/**
* 响应式设备类型 Hook
*/
export function useDevice() {
const deviceType = ref<DeviceType>(getDeviceType());
const isMobileDevice = ref(isMobile());
const isDesktopDevice = ref(isDesktop());
const updateDeviceType = () => {
deviceType.value = getDeviceType();
isMobileDevice.value = isMobile();
isDesktopDevice.value = isDesktop();
};
onMounted(() => {
window.addEventListener('resize', updateDeviceType);
});
onUnmounted(() => {
window.removeEventListener('resize', updateDeviceType);
});
return {
deviceType,
isMobileDevice,
isDesktopDevice
};
}
/**
* 根据设备类型获取对应的组件路径
*/
export function getComponentPath(basePath: string, deviceType?: DeviceType): string {
const currentDeviceType = deviceType || getDeviceType();
// 如果是移动端(h5),尝试加载移动端版本
if (currentDeviceType === DeviceType.MOBILE) {
const mobilePath = basePath.replace('.vue', '.mobile.vue');
return mobilePath;
}
// 默认返回桌面版本(web)
return basePath;
}
/**
* 动态导入组件,支持回退机制
*/
export async function importResponsiveComponent(basePath: string) {
const deviceType = getDeviceType();
// 尝试加载设备特定的组件
if (deviceType === DeviceType.MOBILE) {
try {
const mobilePath = basePath.replace('.vue', '.mobile.vue');
return await import(/* @vite-ignore */ mobilePath);
} catch {
// 移动端组件不存在,回退到默认组件
}
}
// 加载默认组件(桌面端/web)
return await import(/* @vite-ignore */ basePath);
}

View File

@@ -0,0 +1,586 @@
/**
* @description 动态路由生成器
* @author yslg
* @since 2025-10-07
*/
import type { RouteRecordRaw } from 'vue-router';
import type { SysMenu } from '@/types';
import { MenuType } from '@/types/enums';
import { routes } from '@/router';
import { getResponsiveLayout, createResponsiveRoute, type RouteAdapter } from './routeAdapter';
// 预注册所有视图组件,构建时由 Vite 解析并生成按需加载的 chunk
const VIEW_MODULES = import.meta.glob('../views/**/*.vue');
/**
* 布局组件映射 - 使用响应式布局适配
*/
const LAYOUT_MAP: Record<string, () => Promise<any>> = {
// 基础布局(旧版,带侧边栏)
'BasicLayout': getResponsiveLayout('BasicLayout'),
// 导航布局(新版,顶部导航+动态侧边栏)
'NavigationLayout': getResponsiveLayout('NavigationLayout'),
// 侧边栏布局管理后台专用顶层SIDEBAR菜单
'SidebarLayout': getResponsiveLayout('SidebarLayout'),
// 空白布局
'BlankLayout': getResponsiveLayout('BlankLayout'),
// 页面布局
'PageLayout': getResponsiveLayout('PageLayout'),
// 路由占位组件(用于没有组件的子路由)
'RoutePlaceholder': () => import('@/layouts/RoutePlaceholder.vue'),
// 用户中心布局(有共用区域,避免重复查询)
'UserCenterLayout': () => import('@/views/user/user-center/UserCenterLayout.vue'),
};
/**
* 根据菜单生成路由配置
* @param menus 用户菜单列表
* @returns Vue Router路由配置数组
*/
export function generateRoutes(menus: SysMenu[]): RouteRecordRaw[] {
if (!menus || menus.length === 0) {
return [];
}
const routes: RouteRecordRaw[] = [];
const pageRoutes: RouteRecordRaw[] = [];
// 构建菜单树
const menuTree = buildMenuTree(menus);
// 生成路由
menuTree.forEach(menu => {
const route = generateRouteFromMenu(menu);
if (route) {
routes.push(route);
// 递归提取所有 PAGE 类型的子菜单
extractPageChildren(route, pageRoutes);
}
});
// 将 PAGE 类型的路由添加到路由列表
routes.push(...pageRoutes);
return routes;
}
/**
* 递归提取路由中的 PAGE 类型子菜单
*/
function extractPageChildren(route: any, pageRoutes: RouteRecordRaw[]) {
// 检查当前路由是否有 PAGE 类型的子菜单
if (route.meta?.pageChildren && Array.isArray(route.meta.pageChildren)) {
route.meta.pageChildren.forEach((pageMenu: SysMenu) => {
const pageRoute = generateRouteFromMenu(pageMenu, true); // 作为顶层路由生成
if (pageRoute) {
pageRoutes.push(pageRoute);
} else {
console.error(`[路由生成] 生成独立PAGE路由失败: ${pageMenu.name}`);
}
});
// 清理临时数据
delete route.meta.pageChildren;
}
// 递归检查子路由
if (route.children && Array.isArray(route.children)) {
route.children.forEach((childRoute: any) => {
extractPageChildren(childRoute, pageRoutes);
});
}
}
/**
* 根据单个菜单生成路由
* @param menu 菜单对象
* @param isTopLevel 是否是顶层菜单
* @returns 路由配置
*/
function generateRouteFromMenu(menu: SysMenu, isTopLevel = true): RouteRecordRaw | null {
// 跳过按钮类型
if (menu.type === MenuType.BUTTON) {
return null;
}
// 跳过静态路由(已经在 router 中定义,不需要再次添加)
if (menu.component === '__STATIC_ROUTE__') {
return null;
}
const route: any = {
path: menu.url || `/${menu.menuID}`,
name: menu.menuID,
meta: {
title: menu.name,
icon: menu.icon,
menuId: menu.menuID,
parentId: menu.parentID,
orderNum: menu.orderNum,
type: menu.type,
hideInMenu: false,
requiresAuth: true,
}
};
// 检查是否指定了布局(只有顶层菜单才使用布局)
const layout = isTopLevel ? (menu as any).layout : null;
const hasChildren = menu.children && menu.children.length > 0;
// 检查 component 是否是布局组件(优先检查 LAYOUT_MAP再检查名称
const isComponentLayout = menu.component && (
LAYOUT_MAP[menu.component] ||
(typeof menu.component === 'string' && menu.component.includes('Layout'))
);
// 确定路由组件
if (layout && LAYOUT_MAP[layout]) {
// 如果指定了布局,使用指定的布局
route.component = getComponent(layout);
} else if (isComponentLayout && hasChildren && isTopLevel && menu.component) {
// 如果 component 是布局组件且有子菜单,使用该布局组件作为父路由组件
// 这样可以确保布局只渲染一次,共用区域的数据也只查询一次
route.component = getComponent(menu.component);
} else if (hasChildren && isTopLevel) {
// 如果有子菜单但没有指定布局,根据菜单类型选择默认布局
if (menu.type === MenuType.NAVIGATION) {
route.component = getComponent('NavigationLayout');
} else if (menu.type === MenuType.SIDEBAR && !menu.parentID) {
// 顶层SIDEBAR菜单管理后台默认使用SidebarLayout
route.component = getComponent('SidebarLayout');
} else {
route.component = getComponent('BasicLayout');
}
} else {
// 没有子菜单,也没有指定布局,使用具体的页面组件
if (menu.component) {
route.component = getComponent(menu.component);
} else {
// 非顶层菜单没有组件时,使用路由占位组件(不影响布局样式)
route.component = getComponent('RoutePlaceholder');
}
}
// 处理子路由
if (layout && LAYOUT_MAP[layout] && menu.component && isTopLevel) {
// 如果指定了布局,将页面组件作为子路由
route.children = [{
path: '',
name: `${menu.menuID}_page`,
component: getComponent(menu.component),
meta: route.meta
}];
// 如果还有其他子菜单,继续添加
if (hasChildren) {
const pageChildren: SysMenu[] = [];
const normalChildren: SysMenu[] = [];
menu.children!.forEach(child => {
if (child.type === MenuType.PAGE) {
pageChildren.push(child);
} else {
normalChildren.push(child);
}
});
// 添加普通子菜单
normalChildren.forEach(child => {
const childRoute = generateRouteFromMenu(child, false);
if (childRoute) {
route.children!.push(childRoute);
}
});
// PAGE 类型的菜单保存到 meta
if (pageChildren.length > 0) {
route.meta.pageChildren = pageChildren;
}
}
} else if (hasChildren) {
// 处理有子菜单的情况
route.children = [];
// 分离 PAGE 类型的子菜单和普通子菜单
const pageChildren: SysMenu[] = [];
const normalChildren: SysMenu[] = [];
menu.children!.forEach(child => {
if (child.type === MenuType.PAGE) {
// PAGE 类型的菜单作为独立路由,不作为子路由
pageChildren.push(child);
} else {
normalChildren.push(child);
}
});
// 当前菜单指定了页面组件时,如果存在“普通子菜单”(非 PAGE 类型)
// 则需要创建一个默认子路由来承载当前菜单的页面组件,
// 这样父级既能作为分组,又能渲染自己的页面。
// 如果只有 PAGE 类型子菜单,则直接使用当前路由组件,而不再包一层 `_page`
// 避免多出一层嵌套导致 matched 结构过深。
if (menu.component && !isComponentLayout && normalChildren.length > 0) {
route.children!.push({
path: '',
name: `${menu.menuID}_page`,
component: getComponent(menu.component),
meta: {
...route.meta,
}
});
}
// 只将普通子菜单加入 children
normalChildren.forEach(child => {
const childRoute = generateRouteFromMenu(child, false);
if (childRoute) {
route.children!.push(childRoute);
}
});
// PAGE 类型的菜单需要在外层单独处理(不管是哪一层的菜单)
if (pageChildren.length > 0) {
// 将 PAGE 类型的子菜单保存到路由的 meta 中,稍后在外层生成
route.meta.pageChildren = pageChildren;
}
// 如果没有设置重定向自动重定向到第一个有URL的子菜单
if (!route.redirect && route.children.length > 0) {
const firstChildWithUrl = findFirstMenuWithUrl(normalChildren);
if (firstChildWithUrl?.url) {
route.redirect = firstChildWithUrl.url;
}
}
}
return route;
}
/**
* 查找第一个有URL的菜单
* @param menus 菜单列表
* @returns 第一个有URL的菜单
*/
function findFirstMenuWithUrl(menus: SysMenu[]): SysMenu | null {
for (const menu of menus) {
if (menu.type !== MenuType.BUTTON) {
if (menu.url) {
return menu;
}
if (menu.children && menu.children.length > 0) {
const found = findFirstMenuWithUrl(menu.children);
if (found) return found;
}
}
}
return null;
}
/**
* 根据组件名称获取组件(支持响应式组件)
* @param componentName 组件名称/路径
* @returns 组件异步加载函数
*/
function getComponent(componentName: string) {
// 1. 若是布局组件,直接返回预定义映射
if (LAYOUT_MAP[componentName]) {
return LAYOUT_MAP[componentName];
}
// 2. 将后台给的 component 字段转换为 ../views/**.vue 形式的 key
let componentPath = componentName;
// 如果不是以 @/ 开头,则认为是相对 views 根目录的路径,例如 "user/home/HomeView"
if (!componentPath.startsWith('@/')) {
if (!componentPath.startsWith('/')) {
componentPath = '/' + componentPath;
}
componentPath = '@/views' + componentPath; // => '@/views/user/home/HomeView'
}
// 将别名 @/ 转为相对于当前文件的路径,必须与 import.meta.glob 中的模式一致
const originalPath = componentPath.replace(/^@\//, '../'); // => '../views/user/home/HomeView'
// 补全 .vue 后缀
if (!originalPath.endsWith('.vue')) {
componentPath = originalPath + '.vue';
} else {
componentPath = originalPath;
}
// 3. 检查是否有移动端版本
const mobileComponentPath = componentPath.replace('.vue', '.mobile.vue');
// 从 VIEW_MODULES 中查找对应的 loader
const originalLoader = VIEW_MODULES[componentPath];
const mobileLoader = VIEW_MODULES[mobileComponentPath];
if (!originalLoader) {
console.error('[路由生成] 未找到组件模块', {
原始组件名: componentName,
期望路径: componentPath,
可用模块: Object.keys(VIEW_MODULES)
});
// 找不到时退回到 404 组件
return () => import('@/views/public/error/404.vue');
}
// 4. 如果有移动端版本,创建响应式路由适配器
if (mobileLoader) {
const adapter: RouteAdapter = {
original: originalLoader as () => Promise<any>,
mobile: mobileLoader as () => Promise<any>
};
return createResponsiveRoute(adapter);
}
// 5. 没有移动端版本,直接返回原始组件
return originalLoader as () => Promise<any>;
}
/**
* 将静态路由转换为菜单项
* @param routes 静态路由数组
* @returns 菜单项数组
*/
function convertRoutesToMenus(routes: RouteRecordRaw[]): SysMenu[] {
const menus: SysMenu[] = [];
routes.forEach(route => {
// 处理有子路由的情况(现在静态路由都有布局组件)
if (route.children && route.children.length > 0) {
route.children.forEach(child => {
// 只处理有 meta.menuType 的子路由
if (child.meta?.menuType !== undefined) {
const menu: SysMenu = {
menuID: child.name as string || child.path.replace(/\//g, '-'),
parentID: '0',
name: child.meta.title as string || child.name as string,
url: route.path, // 使用父路由的路径
type: child.meta.menuType as MenuType,
orderNum: (child.meta.orderNum as number) || -1,
// 标记为静态路由,避免重复生成路由
component: '__STATIC_ROUTE__', // 特殊标记
};
menus.push(menu);
}
});
}
// 处理没有子路由的情况(兼容性保留)
else if (route.meta?.menuType !== undefined) {
const menu: SysMenu = {
menuID: route.name as string || route.path.replace(/\//g, '-'),
parentID: '0',
name: route.meta.title as string || route.name as string,
url: route.path,
type: route.meta.menuType as MenuType,
orderNum: (route.meta.orderNum as number) || -1,
// 标记为静态路由,避免重复生成路由
component: '__STATIC_ROUTE__', // 特殊标记
};
menus.push(menu);
}
});
return menus;
}
/**
* 构建菜单树结构
* @param menus 菜单列表
* @returns 菜单树
*/
export function buildMenuTree(menus: SysMenu[]): SysMenu[] {
// 将静态路由转换为菜单项
const staticMenus = convertRoutesToMenus(routes);
// 合并动态菜单和静态菜单
const allMenus = [...staticMenus, ...menus];
if (allMenus.length === 0) {
return [];
}
const menuMap = new Map<string, SysMenu>();
const rootMenus: SysMenu[] = [];
const maxDepth = allMenus.length; // 最多遍历len层
// 创建菜单映射
allMenus.forEach(menu => {
if (menu.menuID) {
menuMap.set(menu.menuID, { ...menu, children: [] });
}
});
// 循环构建树结构最多遍历maxDepth次
for (let depth = 0; depth < maxDepth; depth++) {
let hasChanges = false;
allMenus.forEach(menu => {
if (!menu.menuID) return;
const menuNode = menuMap.get(menu.menuID);
if (!menuNode) return;
// 如果节点已经在树中,跳过
if (isNodeInTree(menuNode, rootMenus)) {
return;
}
if (!menu.parentID || menu.parentID === '0' || menu.parentID === '') {
// 根菜单
if (!isNodeInTree(menuNode, rootMenus)) {
rootMenus.push(menuNode);
hasChanges = true;
}
} else {
// 子菜单
const parent = menuMap.get(menu.parentID);
if (parent && isNodeInTree(parent, rootMenus)) {
if (!parent.children) {
parent.children = [];
}
if (!parent.children.includes(menuNode)) {
parent.children.push(menuNode);
hasChanges = true;
}
}
}
});
// 如果没有变化,说明树构建完成
if (!hasChanges) {
break;
}
}
// 按orderNum排序
const sortMenus = (menus: SysMenu[]): SysMenu[] => {
return menus
.sort((a, b) => (a.orderNum || 0) - (b.orderNum || 0))
.map(menu => ({
...menu,
children: menu.children ? sortMenus(menu.children) : []
}));
};
return sortMenus(rootMenus);
}
// 检查节点是否已经在树中
function isNodeInTree(node: SysMenu, tree: SysMenu[]): boolean {
for (const treeNode of tree) {
if (treeNode.menuID === node.menuID) {
return true;
}
if (treeNode.children && isNodeInTree(node, treeNode.children)) {
return true;
}
}
return false;
}
/**
* 根据权限过滤菜单
* @param menus 菜单列表
* @param permissions 用户权限列表
* @returns 过滤后的菜单列表
*/
export function filterMenusByPermissions(
menus: SysMenu[],
permissions: string[]
): SysMenu[] {
if (!menus || menus.length === 0) {
return [];
}
return menus
.filter(() => {
// 如果菜单没有设置权限要求,则默认显示
// 这里可以根据实际业务需求调整权限检查逻辑
return true; // 暂时返回true后续可以根据菜单的权限字段进行过滤
})
.map(menu => {
if (menu.children && menu.children.length > 0) {
return {
...menu,
children: filterMenusByPermissions(menu.children, permissions)
};
}
return menu;
});
}
/**
* 查找路由路径对应的菜单
* @param menus 菜单树
* @param path 路由路径
* @returns 匹配的菜单
*/
export function findMenuByPath(menus: SysMenu[], path: string): SysMenu | null {
for (const menu of menus) {
if (menu.url === path) {
return menu;
}
if (menu.children && menu.children.length > 0) {
const found = findMenuByPath(menu.children, path);
if (found) {
return found;
}
}
}
return null;
}
/**
* 获取菜单路径数组(面包屑导航用)
* @param menus 菜单树
* @param targetMenuId 目标菜单ID
* @returns 菜单路径数组
*/
export function getMenuPath(menus: SysMenu[], targetMenuId: string): SysMenu[] {
const path: SysMenu[] = [];
function findPath(menuList: SysMenu[]): boolean {
for (const menu of menuList) {
path.push(menu);
if (menu.menuID === targetMenuId) {
return true;
}
if (menu.children && menu.children.length > 0) {
if (findPath(menu.children)) {
return true;
}
}
path.pop();
}
return false;
}
findPath(menus);
return path;
}
/**
* 获取第一个可访问的菜单URL用于登录后跳转
* @param menus 菜单树
* @returns 第一个可访问的菜单URL如果没有则返回 null
*/
export function getFirstAccessibleMenuUrl(menus: SysMenu[]): string | null {
if (!menus || menus.length === 0) {
return null;
}
return "/home";
}

View File

@@ -0,0 +1,261 @@
import { RouteRecordRaw, RouteLocationNormalized } from 'vue-router';
import { getDeviceType, DeviceType } from './deviceUtils';
/**
* 路由适配器接口
*/
export interface RouteAdapter {
original: () => Promise<any>; // web桌面端
mobile?: () => Promise<any>; // h5移动端
}
/**
* 移动端路由映射表
*/
export const MOBILE_ROUTES_MAP: Record<string, string> = {
// User Views
'/home': '@/views/user/home/HomeView.mobile.vue',
'/resource-center': '@/views/user/resource-center/ResourceCenterView.mobile.vue',
'/resource-hot': '@/views/user/resource-center/HotResourceView.mobile.vue',
'/search': '@/views/user/resource-center/SearchView.mobile.vue',
'/course-center': '@/views/user/study-plan/CourseCenterView.mobile.vue',
'/course-detail': '@/views/user/study-plan/CourseDetailView.mobile.vue',
'/course-study': '@/views/user/study-plan/CourseStudyView.mobile.vue',
'/study-tasks': '@/views/user/study-plan/StudyTasksView.mobile.vue',
'/learning-task-detail': '@/views/user/study-plan/LearningTaskDetailView.mobile.vue',
'/user-center': '@/views/user/user-center/UserCenterLayout.mobile.vue',
'/personal-info': '@/views/user/user-center/profile/PersonalInfoView.mobile.vue',
'/account-settings': '@/views/user/user-center/profile/AccountSettingsView.mobile.vue',
'/my-achievements': '@/views/user/user-center/MyAchievementsView.mobile.vue',
'/my-favorites': '@/views/user/user-center/MyFavoritesView.mobile.vue',
'/learning-records': '@/views/user/user-center/LearningRecordsView.mobile.vue',
'/my-messages': '@/views/user/message/MyMessageListView.mobile.vue',
'/message-detail': '@/views/user/message/MyMessageDetailView.mobile.vue',
// Public Views
'/login': '@/views/public/login/Login.mobile.vue',
'/register': '@/views/public/login/Register.mobile.vue',
'/forgot-password': '@/views/public/login/ForgotPassword.mobile.vue',
'/article-show': '@/views/public/article/ArticleShowView.mobile.vue',
'/article-add': '@/views/public/article/ArticleAddView.mobile.vue'
};
/**
* Layout映射表
*/
export const LAYOUT_MAP: Record<string, Record<DeviceType, string>> = {
'NavigationLayout': {
[DeviceType.MOBILE]: '@/layouts/MobileLayout.vue', // h5移动端
[DeviceType.DESKTOP]: '@/layouts/NavigationLayout.vue' // web桌面端
},
'SidebarLayout': {
[DeviceType.MOBILE]: '@/layouts/MobileLayout.vue', // h5移动端
[DeviceType.DESKTOP]: '@/layouts/SidebarLayout.vue' // web桌面端
},
'BasicLayout': {
[DeviceType.MOBILE]: '@/layouts/MobileLayout.vue', // h5移动端
[DeviceType.DESKTOP]: '@/layouts/BasicLayout.vue' // web桌面端
},
'BlankLayout': {
[DeviceType.MOBILE]: '@/layouts/BlankLayout.vue', // h5移动端
[DeviceType.DESKTOP]: '@/layouts/BlankLayout.vue' // web桌面端
},
'PageLayout': {
[DeviceType.MOBILE]: '@/layouts/MobileLayout.vue', // h5移动端
[DeviceType.DESKTOP]: '@/layouts/PageLayout.vue' // web桌面端
}
};
/**
* 创建响应式路由组件
*/
export function createResponsiveRoute(adapter: RouteAdapter): () => Promise<any> {
return async () => {
const deviceType = getDeviceType();
try {
// 尝试加载设备特定的组件
if (deviceType === DeviceType.MOBILE && adapter.mobile) {
return await adapter.mobile();
}
} catch (error) {
console.warn(`Failed to load device-specific component for ${deviceType}, falling back to original:`, error);
}
// 回退到原始组件(桌面端/web)
return await adapter.original();
};
}
/**
* 获取响应式Layout组件
*/
export function getResponsiveLayout(layoutName: string): () => Promise<any> {
const deviceType = getDeviceType();
const layoutMap = LAYOUT_MAP[layoutName];
if (!layoutMap) {
console.warn(`Unknown layout: ${layoutName}, using original`);
// 使用具体的导入路径
switch (layoutName) {
case 'BlankLayout':
return () => import('@/layouts/BlankLayout.vue');
case 'NavigationLayout':
return () => import('@/layouts/NavigationLayout.vue');
case 'SidebarLayout':
return () => import('@/layouts/SidebarLayout.vue');
case 'BasicLayout':
return () => import('@/layouts/BasicLayout.vue');
case 'PageLayout':
return () => import('@/layouts/PageLayout.vue');
default:
throw new Error(`Unknown layout: ${layoutName}`);
}
}
const targetLayout = layoutMap[deviceType];
return async () => {
try {
// 使用具体的导入路径而不是动态路径
switch (targetLayout) {
case '@/layouts/BlankLayout.vue':
return await import('@/layouts/BlankLayout.vue');
case '@/layouts/NavigationLayout.vue':
return await import('@/layouts/NavigationLayout.vue');
case '@/layouts/SidebarLayout.vue':
return await import('@/layouts/SidebarLayout.vue');
case '@/layouts/BasicLayout.vue':
return await import('@/layouts/BasicLayout.vue');
case '@/layouts/MobileLayout.vue':
return await import('@/layouts/MobileLayout.vue');
case '@/layouts/PageLayout.vue':
return await import('@/layouts/PageLayout.vue');
default:
throw new Error(`Unknown layout path: ${targetLayout}`);
}
} catch (error) {
console.warn(`Failed to load responsive layout ${targetLayout}, falling back to original:`, error);
// 回退到原始layout
switch (layoutName) {
case 'BlankLayout':
return await import('@/layouts/BlankLayout.vue');
case 'NavigationLayout':
return await import('@/layouts/NavigationLayout.vue');
case 'SidebarLayout':
return await import('@/layouts/SidebarLayout.vue');
case 'BasicLayout':
return await import('@/layouts/BasicLayout.vue');
case 'PageLayout':
return await import('@/layouts/PageLayout.vue');
default:
throw new Error(`Unknown layout: ${layoutName}`);
}
}
};
}
/**
* 创建自适应路由配置
*/
export function createAdaptiveRoute(
path: string,
originalComponent: string,
layoutName?: string,
meta?: any
): RouteRecordRaw {
// 创建具体的导入函数而不是使用动态路径
const getOriginalComponent = () => {
switch (originalComponent) {
case '@/views/public/login/Login.vue':
return import('@/views/public/login/Login.vue');
case '@/views/public/login/Register.vue':
return import('@/views/public/login/Register.vue');
case '@/views/public/login/ForgotPassword.vue':
return import('@/views/public/login/ForgotPassword.vue');
default:
throw new Error(`Unknown component: ${originalComponent}`);
}
};
const getMobileComponent = (): (() => Promise<any>) | null => {
const mobilePath = MOBILE_ROUTES_MAP[path];
if (!mobilePath) return null;
switch (mobilePath) {
case '@/views/public/login/Login.mobile.vue':
return () => import('@/views/public/login/Login.mobile.vue');
case '@/views/public/login/Register.mobile.vue':
return () => import('@/views/public/login/Register.mobile.vue');
case '@/views/public/login/ForgotPassword.mobile.vue':
return () => import('@/views/public/login/ForgotPassword.mobile.vue');
default:
return null;
}
};
const adapter: RouteAdapter = {
original: getOriginalComponent
};
// 检查是否有移动端版本
const mobileImportFunction = getMobileComponent();
if (mobileImportFunction) {
adapter.mobile = mobileImportFunction;
}
// 如果指定了Layout应用响应式Layout
if (layoutName) {
const route: RouteRecordRaw = {
path,
component: getResponsiveLayout(layoutName),
children: [
{
path: '',
component: createResponsiveRoute(adapter),
meta
}
],
meta
};
return route;
}
const route: RouteRecordRaw = {
path,
component: createResponsiveRoute(adapter),
meta
};
return route;
}
/**
* 监听屏幕尺寸变化,重新加载路由
*/
export function setupRouteWatcher(router: any) {
let currentDeviceType = getDeviceType();
const handleResize = () => {
const newDeviceType = getDeviceType();
if (newDeviceType !== currentDeviceType) {
currentDeviceType = newDeviceType;
// 重新加载当前路由以应用新的组件
const currentRoute = router.currentRoute.value;
router.replace({
...currentRoute,
query: {
...currentRoute.query,
_refresh: Date.now() // 强制重新加载
}
});
}
};
window.addEventListener('resize', handleResize);
// 返回清理函数
return () => {
window.removeEventListener('resize', handleResize);
};
}