diff --git a/urbanLifelineServ/.bin/database/postgres/sql/initDataPermission.sql b/urbanLifelineServ/.bin/database/postgres/sql/initDataPermission.sql index e25c3e29..5d7ea90e 100644 --- a/urbanLifelineServ/.bin/database/postgres/sql/initDataPermission.sql +++ b/urbanLifelineServ/.bin/database/postgres/sql/initDataPermission.sql @@ -175,68 +175,67 @@ INSERT INTO sys.tb_sys_view ( ('VIEW-P001', 'view_platform_home', '全部应用', NULL, '/agents', 'public/Agents/AgentPlatformView.vue', 'Grid', 1, 'route', NULL, 'platform', 'SidebarLayout', 20, '全部智能体', 'system', now(), false), -- iframe 嵌入菜单 -('VIEW-P005', 'view_platform_workflow', '智能体编排', NULL, NULL, NULL, 'Connection', 1, +-- url: platform中的路由路径(用于sidebar定位和路由跳转) +-- iframe_url: iframe的src地址(实际内容的URL) +('VIEW-P005', 'view_platform_workflow', '智能体编排', NULL, '/app/workflow', NULL, 'Connection', 1, 'iframe', 'http://localhost:3000', 'platform', 'SidebarLayout', 30, 'Dify智能体编排(iframe)', 'system', now(), false), -('VIEW-P003', 'view_platform_bidding', '招标助手', NULL, NULL, NULL, 'Document', 1, - 'iframe', 'http://localhost:5002', 'platform', 'SidebarLayout', 40, '招标应用(iframe)', 'system', now(), false), -('VIEW-P004', 'view_platform_workcase', '泰豪小电', NULL, ‘’, NULL, 'Service', 1, - 'iframe', 'http://localhost:5003', 'platform', 'SidebarLayout', 50, '客服应用(iframe)', 'system', now(), false), +('VIEW-P003', 'view_platform_bidding', '招标助手', NULL, '/app/bidding', NULL, 'Document', 1, + 'iframe', '/bidding/', 'platform', 'SidebarLayout', 40, '招标应用(iframe)', 'system', now(), false), +('VIEW-P004', 'view_platform_workcase', '泰豪小电', NULL, '/app/workcase', NULL, 'Service', 1, + 'iframe', '/workcase/', 'platform', 'SidebarLayout', 50, '客服应用(iframe)', 'system', now(), false), --- 系统管理目录 -('VIEW-P100', 'view_system', '系统管理', NULL, '/system', NULL, 'Settings', 0, - 'route', NULL, 'platform', 'SidebarLayout', 100, '系统管理目录', 'system', now(), false), - --- 系统管理子菜单 -('VIEW-P101', 'view_user', '用户管理', 'view_system', '/system/user', 'system/UserList', 'Users', 1, - 'route', NULL, 'platform', 'SidebarLayout', 10, '用户管理页面', 'system', now(), false), - -('VIEW-P102', 'view_role', '角色管理', 'view_system', '/system/role', 'system/RoleList', 'Shield', 1, - 'route', NULL, 'platform', 'SidebarLayout', 20, '角色管理页面', 'system', now(), false), - -('VIEW-P103', 'view_dept', '部门管理', 'view_system', '/system/dept', 'system/DeptList', 'Building', 1, - 'route', NULL, 'platform', 'SidebarLayout', 30, '部门管理页面', 'system', now(), false), - -('VIEW-P104', 'view_permission', '权限管理', 'view_system', '/system/permission', 'system/PermissionList', 'Lock', 1, - 'route', NULL, 'platform', 'SidebarLayout', 40, '权限管理页面', 'system', now(), false), - -('VIEW-P105', 'view_config', '配置管理', 'view_system', '/system/config', 'system/ConfigList', 'Settings', 1, - 'route', NULL, 'platform', 'SidebarLayout', 50, '配置管理页面', 'system', now(), false), - -('VIEW-P106', 'view_file', '文件管理', 'view_system', '/system/file', 'system/FileList', 'FileText', 1, - 'route', NULL, 'platform', 'SidebarLayout', 60, '文件管理页面', 'system', now(), false), - -('VIEW-P107', 'view_message', '消息管理', 'view_system', '/system/message', 'system/MessageList', 'Mail', 1, - 'route', NULL, 'platform', 'SidebarLayout', 70, '消息管理页面', 'system', now(), false), - --- ========================= --- 招标应用菜单 (bidding) --- ========================= -('VIEW-B001', 'view_bidding_home', '首页', NULL, '/home', 'Home', 'House', 1, - 'route', NULL, 'bidding', 'DefaultLayout', 10, '招标应用首页', 'system', now(), false), - -('VIEW-B002', 'view_bidding_list', '招标列表', NULL, '/bidding/list', 'bidding/List', 'List', 1, - 'route', NULL, 'bidding', 'DefaultLayout', 20, '招标项目列表', 'system', now(), false), - -('VIEW-B003', 'view_bidding_detail', '招标详情', NULL, '/bidding/detail', 'bidding/Detail', 'Document', 1, - 'route', NULL, 'bidding', 'DefaultLayout', 30, '招标项目详情', 'system', now(), false), - -('VIEW-B004', 'view_bidding_offer', '投标管理', NULL, '/bidding/offer', 'bidding/Offer', 'Edit', 1, - 'route', NULL, 'bidding', 'DefaultLayout', 40, '投标管理页面', 'system', now(), false), +-- -- 系统管理目录 +-- ('VIEW-P100', 'view_system', '系统管理', NULL, '/system', NULL, 'Settings', 0, +-- 'route', NULL, 'platform', 'SidebarLayout', 100, '系统管理目录', 'system', now(), false), +-- +-- -- 系统管理子菜单 +-- ('VIEW-P101', 'view_user', '用户管理', 'view_system', '/system/user', 'system/UserList', 'Users', 1, +-- 'route', NULL, 'platform', 'SidebarLayout', 10, '用户管理页面', 'system', now(), false), +-- +-- ('VIEW-P102', 'view_role', '角色管理', 'view_system', '/system/role', 'system/RoleList', 'Shield', 1, +-- 'route', NULL, 'platform', 'SidebarLayout', 20, '角色管理页面', 'system', now(), false), +-- +-- ('VIEW-P103', 'view_dept', '部门管理', 'view_system', '/system/dept', 'system/DeptList', 'Building', 1, +-- 'route', NULL, 'platform', 'SidebarLayout', 30, '部门管理页面', 'system', now(), false), +-- +-- ('VIEW-P104', 'view_permission', '权限管理', 'view_system', '/system/permission', 'system/PermissionList', 'Lock', 1, +-- 'route', NULL, 'platform', 'SidebarLayout', 40, '权限管理页面', 'system', now(), false), +-- +-- ('VIEW-P105', 'view_config', '配置管理', 'view_system', '/system/config', 'system/ConfigList', 'Settings', 1, +-- 'route', NULL, 'platform', 'SidebarLayout', 50, '配置管理页面', 'system', now(), false), +-- +-- ('VIEW-P106', 'view_file', '文件管理', 'view_system', '/system/file', 'system/FileList', 'FileText', 1, +-- 'route', NULL, 'platform', 'SidebarLayout', 60, '文件管理页面', 'system', now(), false), +-- +-- ('VIEW-P107', 'view_message', '消息管理', 'view_system', '/system/message', 'system/MessageList', 'Mail', 1, +-- 'route', NULL, 'platform', 'SidebarLayout', 70, '消息管理页面', 'system', now(), false), +-- +-- -- ========================= +-- -- 招标应用菜单 (bidding) +-- -- ========================= +-- ('VIEW-B001', 'view_bidding_home', '首页', NULL, '/home', 'Home', 'House', 1, +-- 'route', NULL, 'bidding', 'DefaultLayout', 10, '招标应用首页', 'system', now(), false), +-- +-- ('VIEW-B002', 'view_bidding_list', '招标列表', NULL, '/bidding/list', 'bidding/List', 'List', 1, +-- 'route', NULL, 'bidding', 'DefaultLayout', 20, '招标项目列表', 'system', now(), false), +-- +-- ('VIEW-B003', 'view_bidding_detail', '招标详情', NULL, '/bidding/detail', 'bidding/Detail', 'Document', 1, +-- 'route', NULL, 'bidding', 'DefaultLayout', 30, '招标项目详情', 'system', now(), false), +-- +-- ('VIEW-B004', 'view_bidding_offer', '投标管理', NULL, '/bidding/offer', 'bidding/Offer', 'Edit', 1, +-- 'route', NULL, 'bidding', 'DefaultLayout', 40, '投标管理页面', 'system', now(), false), -- ========================= -- 客服应用菜单 (workcase) -- ========================= -('VIEW-W001', 'view_workcase_home', '首页', NULL, '/home', 'Home', 'House', 1, - 'route', NULL, 'workcase', 'DefaultLayout', 10, '客服应用首页', 'system', now(), false), +('VIEW-W001', 'view_workcase_home', '智能客服', NULL, '/', 'public/AIChat/AIChatView.vue', 'House', 3, + 'route', NULL, 'workcase', 'BlankLayout', 10, '智能客服首页', 'system', now(), false), -('VIEW-W002', 'view_workcase_list', '工单列表', NULL, '/workcase/list', 'workcase/List', 'Tickets', 1, - 'route', NULL, 'workcase', 'DefaultLayout', 20, '工单列表页面', 'system', now(), false), +('VIEW-W002', 'view_workcase_list', '工单列表', NULL, '/list', 'workcase/List', 'Tickets', 1, + 'route', NULL, 'workcase', 'SidebarLayout', 20, '工单列表页面', 'system', now(), false), -('VIEW-W003', 'view_workcase_detail', '工单详情', NULL, '/workcase/detail', 'workcase/Detail', 'Document', 1, - 'route', NULL, 'workcase', 'DefaultLayout', 30, '工单详情页面', 'system', now(), false), - -('VIEW-W004', 'view_workcase_chat', '智能客服', NULL, '/workcase/chat', 'workcase/Chat', 'ChatDotRound', 1, - 'route', NULL, 'workcase', 'DefaultLayout', 40, '智能客服聊天', 'system', now(), false); +('VIEW-W003', 'view_workcase_detail', '工单详情', NULL, '/detail', 'workcase/Detail', 'Document', 1, + 'route', NULL, 'workcase', 'SidebarLayout', 30, '工单详情页面', 'system', now(), false); -- ============================= -- 6. 角色权限关联(超级管理员拥有所有权限) @@ -319,45 +318,50 @@ INSERT INTO sys.tb_sys_view_permission ( ('VP-P004', 'view_platform_workcase', 'perm_platform_workcase', 'system', NULL, now(), false), ('VP-P005', 'view_platform_workflow', 'perm_platform_workflow', 'system', NULL, now(), false), --- 用户管理视图关联用户权限 -('VP-0001', 'view_user', 'perm_user_view', 'system', NULL, now(), false), -('VP-0002', 'view_user', 'perm_user_create', 'system', NULL, now(), false), -('VP-0003', 'view_user', 'perm_user_edit', 'system', NULL, now(), false), -('VP-0004', 'view_user', 'perm_user_delete', 'system', NULL, now(), false), -('VP-0005', 'view_user', 'perm_user_export', 'system', NULL, now(), false), +-- Workcase服务内部视图关联(使用同一个workcase访问权限) +('VP-W001', 'view_workcase_home', 'perm_platform_workcase', 'system', NULL, now(), false), +('VP-W002', 'view_workcase_list', 'perm_platform_workcase', 'system', NULL, now(), false), +('VP-W003', 'view_workcase_detail', 'perm_platform_workcase', 'system', NULL, now(), false); --- 角色管理视图关联角色权限 -('VP-0011', 'view_role', 'perm_role_view', 'system', NULL, now(), false), -('VP-0012', 'view_role', 'perm_role_create', 'system', NULL, now(), false), -('VP-0013', 'view_role', 'perm_role_edit', 'system', NULL, now(), false), -('VP-0014', 'view_role', 'perm_role_delete', 'system', NULL, now(), false), -('VP-0015', 'view_role', 'perm_role_export', 'system', NULL, now(), false), - --- 部门管理视图关联部门权限 -('VP-0021', 'view_dept', 'perm_dept_view', 'system', NULL, now(), false), -('VP-0022', 'view_dept', 'perm_dept_create', 'system', NULL, now(), false), -('VP-0023', 'view_dept', 'perm_dept_edit', 'system', NULL, now(), false), -('VP-0024', 'view_dept', 'perm_dept_delete', 'system', NULL, now(), false), -('VP-0025', 'view_dept', 'perm_dept_export', 'system', NULL, now(), false), - --- 权限管理视图关联权限管理权限 -('VP-0031', 'view_permission', 'perm_permission_view', 'system', NULL, now(), false), -('VP-0032', 'view_permission', 'perm_permission_manage', 'system', NULL, now(), false), - --- 配置管理视图关联配置权限 -('VP-0041', 'view_config', 'perm_config_view', 'system', NULL, now(), false), -('VP-0042', 'view_config', 'perm_config_edit', 'system', NULL, now(), false), -('VP-0043', 'view_config', 'perm_config_export', 'system', NULL, now(), false), - --- 文件管理视图关联文件权限 -('VP-0051', 'view_file', 'perm_file_view', 'system', NULL, now(), false), -('VP-0052', 'view_file', 'perm_file_upload', 'system', NULL, now(), false), -('VP-0053', 'view_file', 'perm_file_download', 'system', NULL, now(), false), -('VP-0054', 'view_file', 'perm_file_delete', 'system', NULL, now(), false), -('VP-0055', 'view_file', 'perm_file_export', 'system', NULL, now(), false), - --- 消息管理视图关联消息权限 -('VP-0061', 'view_message', 'perm_message_view', 'system', NULL, now(), false), -('VP-0062', 'view_message', 'perm_message_send', 'system', NULL, now(), false), -('VP-0063', 'view_message', 'perm_message_manage', 'system', NULL, now(), false), -('VP-0064', 'view_message', 'perm_message_export', 'system', NULL, now(), false); +-- -- 用户管理视图关联用户权限(已注释,因为view_user被注释掉了) +-- -- ('VP-0001', 'view_user', 'perm_user_view', 'system', NULL, now(), false), +-- -- ('VP-0002', 'view_user', 'perm_user_create', 'system', NULL, now(), false), +-- -- ('VP-0003', 'view_user', 'perm_user_edit', 'system', NULL, now(), false), +-- -- ('VP-0004', 'view_user', 'perm_user_delete', 'system', NULL, now(), false), +-- -- ('VP-0005', 'view_user', 'perm_user_export', 'system', NULL, now(), false), +-- -- +-- -- -- 角色管理视图关联角色权限 +-- -- ('VP-0011', 'view_role', 'perm_role_view', 'system', NULL, now(), false), +-- -- ('VP-0012', 'view_role', 'perm_role_create', 'system', NULL, now(), false), +-- -- ('VP-0013', 'view_role', 'perm_role_edit', 'system', NULL, now(), false), +-- -- ('VP-0014', 'view_role', 'perm_role_delete', 'system', NULL, now(), false), +-- -- ('VP-0015', 'view_role', 'perm_role_export', 'system', NULL, now(), false), +-- -- +-- -- -- 部门管理视图关联部门权限 +-- -- ('VP-0021', 'view_dept', 'perm_dept_view', 'system', NULL, now(), false), +-- -- ('VP-0022', 'view_dept', 'perm_dept_create', 'system', NULL, now(), false), +-- -- ('VP-0023', 'view_dept', 'perm_dept_edit', 'system', NULL, now(), false), +-- -- ('VP-0024', 'view_dept', 'perm_dept_delete', 'system', NULL, now(), false), +-- -- ('VP-0025', 'view_dept', 'perm_dept_export', 'system', NULL, now(), false), +-- -- +-- -- -- 权限管理视图关联权限管理权限 +-- -- ('VP-0031', 'view_permission', 'perm_permission_view', 'system', NULL, now(), false), +-- -- ('VP-0032', 'view_permission', 'perm_permission_manage', 'system', NULL, now(), false), +-- -- +-- -- -- 配置管理视图关联配置权限 +-- -- ('VP-0041', 'view_config', 'perm_config_view', 'system', NULL, now(), false), +-- -- ('VP-0042', 'view_config', 'perm_config_edit', 'system', NULL, now(), false), +-- -- ('VP-0043', 'view_config', 'perm_config_export', 'system', NULL, now(), false), +-- -- +-- -- -- 文件管理视图关联文件权限 +-- -- ('VP-0051', 'view_file', 'perm_file_view', 'system', NULL, now(), false), +-- -- ('VP-0052', 'view_file', 'perm_file_upload', 'system', NULL, now(), false), +-- -- ('VP-0053', 'view_file', 'perm_file_download', 'system', NULL, now(), false), +-- -- ('VP-0054', 'view_file', 'perm_file_delete', 'system', NULL, now(), false), +-- -- ('VP-0055', 'view_file', 'perm_file_export', 'system', NULL, now(), false), +-- -- +-- -- -- 消息管理视图关联消息权限 +-- -- ('VP-0061', 'view_message', 'perm_message_view', 'system', NULL, now(), false), +-- -- ('VP-0062', 'view_message', 'perm_message_send', 'system', NULL, now(), false), +-- -- ('VP-0063', 'view_message', 'perm_message_manage', 'system', NULL, now(), false), +-- -- ('VP-0064', 'view_message', 'perm_message_export', 'system', NULL, now(), false); diff --git a/urbanLifelineServ/apis/api-system/src/main/java/org/xyzh/api/system/vo/PermissionVO.java b/urbanLifelineServ/apis/api-system/src/main/java/org/xyzh/api/system/vo/PermissionVO.java index a69c5eed..6b2aff31 100644 --- a/urbanLifelineServ/apis/api-system/src/main/java/org/xyzh/api/system/vo/PermissionVO.java +++ b/urbanLifelineServ/apis/api-system/src/main/java/org/xyzh/api/system/vo/PermissionVO.java @@ -107,6 +107,9 @@ public class PermissionVO extends BaseVO { @Schema(description = "iframe URL") private String viewIframeUrl; + @Schema(description = "所属服务:platform=平台应用 bidding=招标应用 workcase=客服应用") + private String viewService; + @Schema(description = "布局") private String viewLayout; @@ -176,6 +179,7 @@ public class PermissionVO extends BaseVO { dto.setType(vo.getViewType()); dto.setViewType(vo.getViewViewType()); dto.setIframeUrl(vo.getViewIframeUrl()); + dto.setService(vo.getViewService()); dto.setLayout(vo.getViewLayout()); dto.setOrderNum(vo.getViewOrderNum()); dto.setDescription(vo.getViewDescription()); @@ -205,6 +209,7 @@ public class PermissionVO extends BaseVO { vo.setViewType(dto.getType()); vo.setViewViewType(dto.getViewType()); vo.setViewIframeUrl(dto.getIframeUrl()); + vo.setViewService(dto.getService()); vo.setViewLayout(dto.getLayout()); vo.setViewOrderNum(dto.getOrderNum()); vo.setViewDescription(dto.getDescription()); diff --git a/urbanLifelineServ/system/src/main/resources/mapper/role/TbSysRolePermissionMapper.xml b/urbanLifelineServ/system/src/main/resources/mapper/role/TbSysRolePermissionMapper.xml index dedb52d2..c7124f05 100644 --- a/urbanLifelineServ/system/src/main/resources/mapper/role/TbSysRolePermissionMapper.xml +++ b/urbanLifelineServ/system/src/main/resources/mapper/role/TbSysRolePermissionMapper.xml @@ -48,6 +48,7 @@ + @@ -267,6 +268,7 @@ v.type AS view_type, v.view_type AS view_view_type, v.iframe_url AS view_iframe_url, + v.service AS view_service, v.layout AS view_layout, v.order_num AS view_order_num, v.description AS view_description, diff --git a/urbanLifelineWeb/NGINX_SSO_CONFIG.md b/urbanLifelineWeb/NGINX_SSO_CONFIG.md new file mode 100644 index 00000000..4f96ff39 --- /dev/null +++ b/urbanLifelineWeb/NGINX_SSO_CONFIG.md @@ -0,0 +1,267 @@ +# Nginx单点登录配置说明 + +## 架构概述 + +本系统使用 **Platform** 作为统一入口和单点登录服务,其他服务(Workcase、Bidding)通过 Nginx 代理访问。 + +## 访问方式 + +### 开发环境(推荐通过Nginx访问) + +1. **启动Nginx** + ```bash + # Windows + cd f:\Environment\Nginx\nginx-1.28.0 + start nginx + ``` + +2. **启动各服务** + ```bash + # Platform (5001端口) + pnpm --filter platform dev + + # Workcase (5003端口) + pnpm --filter workcase dev + + # Bidding (5002端口) + pnpm --filter bidding dev + + # 后端API (8180端口) + # 启动Spring Boot应用 + ``` + +3. **访问地址** + - **统一入口**: http://localhost (Nginx 80端口) + - Platform: http://localhost/ + - Workcase: http://localhost/workcase + - Bidding: http://localhost/bidding + - 后端API: http://localhost/api + +### 生产环境 + +与开发环境配置完全一致,只需将各服务构建后部署即可。 + +```bash +# 构建所有服务 +pnpm build + +# Nginx配置已经就绪,直接启动即可 +``` + +## Nginx配置详解 + +### 核心配置 (nginx.conf) + +```nginx +# Platform 主应用(单点登录入口) +location / { + proxy_pass http://localhost:5001/; +} + +# Workcase 工单服务 +location /workcase/ { + proxy_pass http://localhost:5003/; +} + +# Bidding 招标服务 +location /bidding/ { + proxy_pass http://localhost:5002/; +} + +# 后端 API 统一入口 +location /api/ { + proxy_pass http://localhost:8180/; +} +``` + +## 单点登录流程 + +### 1. 用户访问流程 + +``` +用户访问 http://localhost + ↓ +Nginx代理到 Platform (5001) + ↓ +Platform检查token + ↓ +未登录 → 显示登录页 +已登录 → 显示主页和菜单 +``` + +### 2. 子服务访问流程 + +``` +用户点击"泰豪小电"菜单 + ↓ +Platform iframe加载 http://localhost/workcase + ↓ +Nginx代理到 Workcase (5003) + ↓ +Workcase从LocalStorage读取token + ↓ +有token → 加载workcase路由 +无token → 重定向到 /login (Platform登录页) +``` + +### 3. Token共享机制 + +所有服务共享同一个LocalStorage(因为都在同一个域名下),因此: + +- **Token存储**: `localStorage.setItem('token', ...)` +- **用户信息**: `localStorage.setItem('loginDomain', ...)` +- **视图数据**: `loginDomain.userViews` + +每个服务根据 `service` 字段筛选自己的视图: +```typescript +// Workcase筛选 +const workcaseViews = allViews.filter(view => view.service === 'workcase') + +// Platform筛选 +const platformViews = allViews.filter(view => view.service === 'platform') +``` + +## 配置文件 + +### 1. Shared配置 (`packages/shared/src/config/index.ts`) + +```typescript +sso: { + platformUrl: '/', // Platform地址(相对路径) + workcaseUrl: '/workcase', // Workcase地址 + biddingUrl: '/bidding' // Bidding地址 +} +``` + +### 2. App-config.js(各服务) + +**Platform** (`packages/platform/public/app-config.js`): +```javascript +sso: { + platformUrl: '/', + workcaseUrl: '/workcase', + biddingUrl: '/bidding' +} +``` + +**Workcase** (`packages/workcase/public/app-config.js`): +```javascript +sso: { + platformUrl: '/', + workcaseUrl: '/workcase', + biddingUrl: '/bidding' +} +``` + +## 路由配置 + +### Workcase路由守卫 + +```typescript +// 未登录重定向到Platform登录页 +if (requiresAuth && !hasToken) { + const platformUrl = APP_CONFIG.sso?.platformUrl || '/' + const loginPath = platformUrl.endsWith('/') + ? `${platformUrl}login` + : `${platformUrl}/login` + const platformLoginUrl = `${loginPath}?redirect=${encodeURIComponent(window.location.href)}` + window.location.href = platformLoginUrl +} +``` + +### 动态路由加载 + +```typescript +// 从LocalStorage加载并筛选本服务的视图 +const allViews = loadViewsFromStorage('loginDomain', 'userViews') +const workcaseViews = allViews.filter(view => view.service === 'workcase') +addDynamicRoutes(workcaseViews) +``` + +## 数据库配置 + +### 视图表service字段 + +确保 `tb_sys_view` 表的视图数据正确设置了 `service` 字段: + +```sql +-- Platform视图 +('VIEW-P001', 'view_platform_home', '全部应用', NULL, '/agents', ..., + 'route', NULL, 'platform', 'SidebarLayout', ...) + +-- Workcase视图 +('VIEW-W001', 'view_workcase_home', '工单首页', NULL, '/home', ..., + 'route', NULL, 'workcase', 'SidebarLayout', ...) + +-- Bidding视图 +('VIEW-B001', 'view_bidding_home', '首页', NULL, '/home', ..., + 'route', NULL, 'bidding', 'DefaultLayout', ...) +``` + +## 开发建议 + +### 方式一:通过Nginx访问(推荐) + +**优点**: +- 与生产环境完全一致 +- 测试单点登录功能 +- 避免跨域问题 + +**配置**: +- 所有服务使用相对路径 `/`, `/workcase`, `/bidding` +- 通过 http://localhost 访问 + +### 方式二:直接访问各服务端口 + +**优点**: +- 独立开发调试 +- HMR更快 + +**配置**: +- 修改 `devConfig.sso` 为绝对URL + ```typescript + sso: { + platformUrl: 'http://localhost:5001', + workcaseUrl: 'http://localhost:5003', + biddingUrl: 'http://localhost:5002' + } + ``` +- 直接访问 http://localhost:5001, http://localhost:5003 + +## 常见问题 + +### 1. 登录后跳转到错误的地址 + +**原因**: `platformUrl` 配置不正确 + +**解决**: 检查 `app-config.js` 中的 `sso.platformUrl` 配置 + +### 2. Token无法共享 + +**原因**: 不同端口访问导致LocalStorage隔离 + +**解决**: 统一通过Nginx访问(http://localhost) + +### 3. Nginx无法启动 + +**检查**: +- 80端口是否被占用 +- nginx.conf配置是否正确 + +### 4. 子服务路由404 + +**检查**: +- Vite配置中的 `base` 是否正确设置 +- Workcase: `base: '/workcase'` +- Bidding: `base: '/bidding'` + +## 重启服务 + +```bash +# 重启Nginx(Windows) +nginx -s reload + +# 或完全重启 +nginx -s quit +start nginx +``` diff --git a/urbanLifelineWeb/packages/bidding/public/app-config.js b/urbanLifelineWeb/packages/bidding/public/app-config.js index 90302297..fb0f4f03 100644 --- a/urbanLifelineWeb/packages/bidding/public/app-config.js +++ b/urbanLifelineWeb/packages/bidding/public/app-config.js @@ -51,6 +51,13 @@ window.APP_RUNTIME_CONFIG = { publicImgPath: '/img', publicWebPath: '/', + // 单点登录配置 + sso: { + platformUrl: '/', // platform 平台地址 + workcaseUrl: '/workcase', // workcase 服务地址 + biddingUrl: '/bidding' // bidding 服务地址 + }, + // 功能开关 features: { enableDebug: false, diff --git a/urbanLifelineWeb/packages/bidding/src/layouts/SidebarLayout/SidebarLayout.vue b/urbanLifelineWeb/packages/bidding/src/layouts/SidebarLayout/SidebarLayout.vue new file mode 100644 index 00000000..8c780394 --- /dev/null +++ b/urbanLifelineWeb/packages/bidding/src/layouts/SidebarLayout/SidebarLayout.vue @@ -0,0 +1,293 @@ + + + + + + + + + + + {{ currentMenuItem?.label }} + + 刷新 + + + + + + 加载中... + + + + + + + + + + + + diff --git a/urbanLifelineWeb/packages/bidding/src/layouts/SidebarLayout/index.ts b/urbanLifelineWeb/packages/bidding/src/layouts/SidebarLayout/index.ts new file mode 100644 index 00000000..972caa46 --- /dev/null +++ b/urbanLifelineWeb/packages/bidding/src/layouts/SidebarLayout/index.ts @@ -0,0 +1 @@ +export { default as SidebarLayout } from './SidebarLayout.vue' diff --git a/urbanLifelineWeb/packages/bidding/src/layouts/index.ts b/urbanLifelineWeb/packages/bidding/src/layouts/index.ts new file mode 100644 index 00000000..387ddeff --- /dev/null +++ b/urbanLifelineWeb/packages/bidding/src/layouts/index.ts @@ -0,0 +1,3 @@ +export { default as SidebarLayout } from './SidebarLayout/SidebarLayout.vue' +// BlankLayout从shared导入 +export { BlankLayout } from 'shared/layouts' diff --git a/urbanLifelineWeb/packages/bidding/src/router/dynamicRoute.ts b/urbanLifelineWeb/packages/bidding/src/router/dynamicRoute.ts new file mode 100644 index 00000000..9d5c83db --- /dev/null +++ b/urbanLifelineWeb/packages/bidding/src/router/dynamicRoute.ts @@ -0,0 +1,155 @@ +/** + * 动态路由生成模块(Bidding 特定) + * + * 职责: + * 1. 提供 Bidding 特定的布局和组件配置 + * 2. 调用 shared 中的通用路由生成方法 + * 3. 将生成的路由添加到 Bidding 的 router 实例 + */ + +/// + +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, BlankLayout } from '@/layouts' + +// Bidding 布局组件映射 +const biddingLayoutMap: Record Promise> = { + 'SidebarLayout': () => Promise.resolve({ default: SidebarLayout }), + 'BlankLayout': () => Promise.resolve({ default: BlankLayout }), + 'NavigationLayout': () => Promise.resolve({ default: SidebarLayout }), + 'BasicLayout': () => Promise.resolve({ default: SidebarLayout }) +} + +// 视图组件加载器 +const VIEW_MODULES = import.meta.glob<{ default: any }>('../views/**/*.vue') + +/** + * 视图组件加载函数 + * @param componentPath 组件路径(如 "bidding/Home" 或 "bidding/List") + */ +function viewLoader(componentPath: string): (() => Promise) | null { + // 将后台路径转换为 ../views 格式 + let path = componentPath + + // 移除开头的斜杠(如果有) + if (path.startsWith('/')) { + path = path.substring(1) + } + + // 补全 .vue 后缀(如果没有) + if (!path.endsWith('.vue')) { + path += '.vue' + } + + // 转换为 ../views 格式(匹配 import.meta.glob 的 key) + const fullPath = `../views/${path}` + + console.log('[Bidding viewLoader] 尝试加载组件:', componentPath, '→', fullPath) + + const loader = VIEW_MODULES[fullPath] + + if (!loader) { + console.warn('[Bidding viewLoader] 组件未找到:', fullPath) + console.log('[Bidding viewLoader] 可用的组件:', Object.keys(VIEW_MODULES)) + return null + } + + return loader as () => Promise +} + +// Bidding 路由生成器配置 +const routeConfig: RouteGeneratorConfig = { + layoutMap: biddingLayoutMap, + viewLoader, + notFoundComponent: () => import('vue').then(({ h }) => ({ + default: { + render() { return h('div', { style: { padding: '20px', textAlign: 'center' } }, '404 - 页面未找到') } + } + })) +} + +// Bidding 路由生成选项 +const routeOptions: GenerateSimpleRoutesOptions = { + asRootChildren: false, // 直接作为根级路由,不是某个布局的子路由 + iframePlaceholder: () => import('shared/components').then(m => ({ default: m.IframeView })), + verbose: true // 启用详细日志 +} + +/** + * 添加动态路由(Bidding 特定) + * @param views 视图列表(用作菜单) + */ +export function addDynamicRoutes(views: TbSysViewDTO[]) { + if (!views || views.length === 0) { + console.warn('[Bidding] addDynamicRoutes: 视图列表为空') + return + } + + console.log('[Bidding] addDynamicRoutes: 开始添加动态路由,视图数量:', views.length) + console.log('[Bidding] addDynamicRoutes: 路由配置:', routeConfig) + console.log('[Bidding] addDynamicRoutes: 路由选项:', routeOptions) + + try { + // 使用 shared 中的通用方法生成路由 + const routes = generateSimpleRoutes(views, routeConfig, routeOptions) + + // 直接将路由添加到根级别(不是作为Root的children) + routes.forEach(route => { + console.log('[Bidding] addDynamicRoutes: 添加路由', route.path, '使用布局:', route.component?.name || 'unknown') + router.addRoute(route) + }) + + } catch (error) { + console.error('[Bidding] addDynamicRoutes: 添加路由失败', error) + throw error + } +} + +/** + * 从 LocalStorage 获取菜单并生成路由(Bidding 特定) + * + * 使用 shared 中的通用 loadViewsFromStorage 方法 + * 筛选出 service='bidding' 的视图 + */ +export function loadRoutesFromStorage(): boolean { + try { + console.log('[Bidding] loadRoutesFromStorage: 开始加载动态路由') + + // 使用 shared 中的通用方法加载视图数据 + const allViews = loadViewsFromStorage('loginDomain', 'userViews') + + console.log('[Bidding] loadRoutesFromStorage: 加载的所有视图数量:', allViews?.length || 0) + + if (allViews) { + // 过滤出 bidding 服务的视图 + const biddingViews = allViews.filter((view: TbSysViewDTO) => + view.service === 'bidding' + ) + + console.log('[Bidding] loadRoutesFromStorage: 过滤后的 bidding 视图:', biddingViews) + + if (biddingViews.length === 0) { + console.warn('[Bidding] loadRoutesFromStorage: 没有找到 bidding 服务的视图') + return false + } + + // 使用 Bidding 的 addDynamicRoutes 添加路由 + addDynamicRoutes(biddingViews) + return true + } + + console.warn('[Bidding] loadRoutesFromStorage: 未能加载视图数据') + return false + } catch (error) { + console.error('[Bidding] loadRoutesFromStorage: 加载路由失败', error) + return false + } +} diff --git a/urbanLifelineWeb/packages/bidding/src/router/index.ts b/urbanLifelineWeb/packages/bidding/src/router/index.ts new file mode 100644 index 00000000..120bc4cf --- /dev/null +++ b/urbanLifelineWeb/packages/bidding/src/router/index.ts @@ -0,0 +1,94 @@ +import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router' +// @ts-ignore +import { TokenManager } from 'shared/api' +// @ts-ignore +import { APP_CONFIG } from 'shared/config' +// @ts-ignore +import { loadRoutesFromStorage } from './dynamicRoute' + +// bidding应用的动态路由会根据layout字段自动添加,不需要预定义Root布局 +const routes: RouteRecordRaw[] = [] + +const router = createRouter({ + history: createWebHistory('/bidding'), // 与nginx保持一致,使用/bidding前缀 + routes +}) + +// 标记动态路由是否已加载 +let dynamicRoutesLoaded = false + +// 路由守卫 +router.beforeEach((to, from, next) => { + console.log('[Bidding Router] 路由守卫触发:', { + to: to.path, + from: from.path, + meta: to.meta + }) + + // 设置页面标题 + if (to.meta.title) { + document.title = `${to.meta.title} - 招标管理系统` + } + + // 检查是否需要登录 + const requiresAuth = to.meta.requiresAuth !== false + const hasToken = TokenManager.hasToken() + + console.log('[Bidding Router] 认证检查:', { + requiresAuth, + hasToken, + tokenValue: localStorage.getItem('token') + }) + + // 其他页面:检查是否需要登录 + if (requiresAuth && !hasToken) { + // 需要登录但未登录,重定向到 platform 的登录页 + // 重要:必须使用完整URL(包含origin),避免被bidding的路由拦截造成循环 + const currentUrl = window.location.href + const origin = window.location.origin + + // 构建platform登录页的完整URL + const loginUrl = `${origin}/login?redirect=${encodeURIComponent(currentUrl)}` + + console.log('[Bidding Router] 未登录,重定向到Platform登录页:', loginUrl) + + // 使用完整URL跳转,跳出bidding的路由系统 + window.location.href = loginUrl + return + } + + // 如果已登录且动态路由未加载,先加载动态路由 + if (hasToken && !dynamicRoutesLoaded) { + console.log('[Bidding Router] 开始加载动态路由...') + console.log('[Bidding Router] LocalStorage 内容:', { + loginDomain: localStorage.getItem('loginDomain'), + token: localStorage.getItem('token') + }) + + dynamicRoutesLoaded = true + const loaded = loadRoutesFromStorage?.() + + console.log('[Bidding Router] 动态路由加载结果:', loaded) + console.log('[Bidding Router] 当前路径:', to.path) + console.log('[Bidding Router] 所有路由:', router.getRoutes().map(r => r.path)) + + if (loaded) { + // 动态路由加载成功,重新导航以匹配新添加的路由 + console.log('[Bidding Router] 动态路由加载成功,重新导航到:', to.path) + next({ ...to, replace: true }) + return + } else { + console.warn('[Bidding Router] 动态路由加载失败') + } + } + + console.log('[Bidding Router] 继续正常导航') + next() +}) + +// 重置动态路由加载状态 +export function resetDynamicRoutes() { + dynamicRoutesLoaded = false +} + +export default router diff --git a/urbanLifelineWeb/packages/bidding/src/types/shared.d.ts b/urbanLifelineWeb/packages/bidding/src/types/shared.d.ts new file mode 100644 index 00000000..f352e00e --- /dev/null +++ b/urbanLifelineWeb/packages/bidding/src/types/shared.d.ts @@ -0,0 +1,184 @@ +/** + * Shared Module Federation 类型声明 + * 用于 TypeScript 识别远程模块 + */ + +// ========== 组件模块 ========== +declare module 'shared/components' { + export const FileUpload: any + export const DynamicFormItem: any + export const IframeView: 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 +} + +declare module 'shared/components/iframe/IframeView.vue' { + import { DefineComponent } from 'vue' + const IframeView: DefineComponent<{}, {}, any> + export default IframeView +} + +declare module 'shared/components/iframe/IframeView.vue' { + import { DefineComponent } from 'vue' + const IframeView: DefineComponent<{}, {}, any> + export default IframeView +} + +// ========== 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/api' { + export const authAPI: any + export const fileAPI: any + export const TokenManager: any + export const api: any +} + +// 保留旧的导出路径(向后兼容) +declare module 'shared/FileUpload' { + import { DefineComponent } from 'vue' + const FileUpload: DefineComponent<{}, {}, any> + export default FileUpload +} + +declare module 'shared/DynamicFormItem' { + import { DefineComponent } from 'vue' + const DynamicFormItem: DefineComponent<{}, {}, any> + export default DynamicFormItem +} + +declare module 'shared/utils' { + export const initAesEncrypt: any + export const getAesInstance: any + export const formatFileSize: any + export const isImageFile: any + export const getFileTypeIcon: any + export const isValidFileType: any + export const getFilePreviewUrl: any +} + +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 = any + + // 视图类型(用于路由和菜单) + export interface TbSysViewDTO { + viewId?: string + name?: string + parentId?: string + url?: string + component?: string + service?: string + iframeUrl?: string + icon?: string + type?: number + layout?: string + orderNum?: number + description?: string + children?: TbSysViewDTO[] + } +} + +declare module 'shared/utils/route' { + import { RouteRecordRaw } from 'vue-router' + import type { TbSysViewDTO } from 'shared/types' + + export interface RouteGeneratorConfig { + layoutMap: Record Promise> + viewLoader: (componentPath: string) => (() => Promise) | null + staticRoutes?: RouteRecordRaw[] + notFoundComponent?: () => Promise + } + + export interface GenerateSimpleRoutesOptions { + asRootChildren?: boolean + iframePlaceholder?: () => Promise + 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 +} + +// ========== Layouts 布局模块 ========== +declare module 'shared/layouts' { + import { DefineComponent } from 'vue' + + export const BlankLayout: DefineComponent<{}, {}, any> +} diff --git a/urbanLifelineWeb/packages/bidding/vite.config.ts b/urbanLifelineWeb/packages/bidding/vite.config.ts index 5b1511bd..3c3df7f2 100644 --- a/urbanLifelineWeb/packages/bidding/vite.config.ts +++ b/urbanLifelineWeb/packages/bidding/vite.config.ts @@ -7,7 +7,10 @@ import { fileURLToPath } from 'url' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) -export default defineConfig({ +export default defineConfig(({ mode }) => ({ + // 开发和生产环境都通过nginx代理访问/bidding + base: '/bidding/', + plugins: [ vue({ script: { @@ -34,6 +37,7 @@ export default defineConfig({ port: 5002, host: true, cors: true, + open: '/bidding/', // 开发时自动打开到 /bidding/ 路径 proxy: { '/api': { target: 'http://localhost:8180', @@ -60,4 +64,4 @@ export default defineConfig({ } } } -}) +})) diff --git a/urbanLifelineWeb/packages/platform/index.html b/urbanLifelineWeb/packages/platform/index.html index 7c36ba4d..07ddce13 100644 --- a/urbanLifelineWeb/packages/platform/index.html +++ b/urbanLifelineWeb/packages/platform/index.html @@ -8,9 +8,6 @@ - - - diff --git a/urbanLifelineWeb/packages/platform/public/app-config.js b/urbanLifelineWeb/packages/platform/public/app-config.js index 90302297..fb0f4f03 100644 --- a/urbanLifelineWeb/packages/platform/public/app-config.js +++ b/urbanLifelineWeb/packages/platform/public/app-config.js @@ -51,6 +51,13 @@ window.APP_RUNTIME_CONFIG = { publicImgPath: '/img', publicWebPath: '/', + // 单点登录配置 + sso: { + platformUrl: '/', // platform 平台地址 + workcaseUrl: '/workcase', // workcase 服务地址 + biddingUrl: '/bidding' // bidding 服务地址 + }, + // 功能开关 features: { enableDebug: false, diff --git a/urbanLifelineWeb/packages/platform/src/layouts/SidebarLayout/SidebarLayout.vue b/urbanLifelineWeb/packages/platform/src/layouts/SidebarLayout/SidebarLayout.vue index 61409b69..ce97dbac 100644 --- a/urbanLifelineWeb/packages/platform/src/layouts/SidebarLayout/SidebarLayout.vue +++ b/urbanLifelineWeb/packages/platform/src/layouts/SidebarLayout/SidebarLayout.vue @@ -135,7 +135,6 @@ function getUserName(): string { return loginDomain.user?.username || loginDomain.userInfo?.username || '管理员' } } catch (error) { - console.error('❌ 获取用户名失败:', error) } return '管理员' } @@ -149,20 +148,20 @@ 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) + // 过滤出 SidebarLayout 的顶级菜单(没有 parentId,且属于 platform 服务,且不是admin路由) const sidebarViews = userViews.filter((view: any) => view.layout === 'SidebarLayout' && !view.parentId && - view.type === 1 // type 1 是侧边栏菜单 + view.type === 1 && // type 1 是侧边栏菜单 + view.service === 'platform' && // 只显示 platform 服务的视图 + !view.url?.startsWith('/admin') // 排除 admin 路由(由 AdminSidebar 管理) ) // 按 orderNum 排序 @@ -189,10 +188,8 @@ function loadMenuFromStorage(): MenuItem[] { } }) - console.log('✅ 侧边栏菜单:', menuItems) return menuItems } catch (error) { - console.error('❌ 加载菜单失败:', error) return [] } } diff --git a/urbanLifelineWeb/packages/platform/src/layouts/index.ts b/urbanLifelineWeb/packages/platform/src/layouts/index.ts index 32fbbfc0..8ac1780c 100644 --- a/urbanLifelineWeb/packages/platform/src/layouts/index.ts +++ b/urbanLifelineWeb/packages/platform/src/layouts/index.ts @@ -1 +1,3 @@ -export { default as SidebarLayout } from "./SidebarLayout/SidebarLayout.vue"; \ No newline at end of file +export { default as SidebarLayout } from "./SidebarLayout/SidebarLayout.vue"; +// BlankLayout从shared导入 +export { BlankLayout } from 'shared/layouts'; \ No newline at end of file diff --git a/urbanLifelineWeb/packages/platform/src/router/dynamicRoute.ts b/urbanLifelineWeb/packages/platform/src/router/dynamicRoute.ts index 31334a39..81201cfd 100644 --- a/urbanLifelineWeb/packages/platform/src/router/dynamicRoute.ts +++ b/urbanLifelineWeb/packages/platform/src/router/dynamicRoute.ts @@ -18,43 +18,47 @@ import { import type { TbSysViewDTO } from 'shared/types' import type { RouteRecordRaw } from 'vue-router' import router from './index' -import { SidebarLayout } from '../layouts' +import { SidebarLayout, BlankLayout } from '@/layouts' // Platform 布局组件映射 const platformLayoutMap: Record Promise> = { 'SidebarLayout': () => Promise.resolve({ default: SidebarLayout }), + 'BlankLayout': () => Promise.resolve({ default: BlankLayout }), 'NavigationLayout': () => Promise.resolve({ default: SidebarLayout }), 'BasicLayout': () => Promise.resolve({ default: SidebarLayout }) } // 视图组件加载器 -const VIEW_MODULES = import.meta.glob<{ default: any }>('../views/**/*.vue') +const VIEW_MODULES = import.meta.glob<{ default: any }>('@/views/**/*.vue') /** * 视图组件加载函数 - * @param componentPath 组件路径 + * @param componentPath 组件路径(如 "public/Chat/AIChatView.vue") */ function viewLoader(componentPath: string): (() => Promise) | null { - // 将后台路径转换为实际路径 + // 将后台路径转换为 @/views 格式 let path = componentPath - // 如果不是以 ../ 开头,则认为是相对 views 目录的路径 - if (!path.startsWith('../')) { - if (!path.startsWith('/')) { - path = '/' + path - } - path = '../views' + path + // 移除开头的斜杠(如果有) + if (path.startsWith('/')) { + path = path.substring(1) } - // 补全 .vue 后缀 + // 补全 .vue 后缀(如果没有) if (!path.endsWith('.vue')) { path += '.vue' } - const loader = VIEW_MODULES[path] + // 转换为 /src/views 格式(匹配 import.meta.glob 的 key) + const fullPath = `/src/views/${path}` + + console.log('[Platform viewLoader] 尝试加载组件:', componentPath, '→', fullPath) + + const loader = VIEW_MODULES[fullPath] if (!loader) { - console.warn(`[路由生成] 未找到组件: ${componentPath},期望路径: ${path}`) + console.warn('[Platform viewLoader] 组件未找到:', fullPath) + console.log('[Platform viewLoader] 可用的组件:', Object.keys(VIEW_MODULES)) return null } @@ -74,12 +78,8 @@ const routeConfig: RouteGeneratorConfig = { // Platform 路由生成选项 const routeOptions: GenerateSimpleRoutesOptions = { - asRootChildren: true, // 作为 Root 路由的子路由 - iframePlaceholder: () => Promise.resolve({ - default: { - template: '' - } - }), + asRootChildren: false, // 直接作为根级路由,不是某个布局的子路由 + iframePlaceholder: () => import('shared/components').then(m => ({ default: m.IframeView })), verbose: true // 启用详细日志 } @@ -89,31 +89,34 @@ const routeOptions: GenerateSimpleRoutesOptions = { */ export function addDynamicRoutes(views: TbSysViewDTO[]) { if (!views || views.length === 0) { - console.warn('[Platform 路由] 视图列表为空') + console.warn('[Platform] addDynamicRoutes: 视图列表为空') return } - console.log('[Platform 路由] 开始生成路由,视图数量:', views.length) + console.log('[Platform] addDynamicRoutes: 开始添加动态路由,视图数量:', views.length) try { // 使用 shared 中的通用方法生成路由 const routes = generateSimpleRoutes(views, routeConfig, routeOptions) - // 将生成的路由添加到 Platform 的 router + // 直接将路由添加到根级别(不是作为Root的children) 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] addDynamicRoutes: 添加路由', route.path, '使用布局:', route.component?.name || 'unknown') + router.addRoute(route) }) - console.log('✅ Platform 动态路由添加完成') - console.log('所有路由:', router.getRoutes().map(r => ({ path: r.path, name: r.name }))) + // 动态添加根路径重定向到第一个菜单项 + if (routes.length > 0) { + const firstRoute = routes[0] + router.addRoute({ + path: '/', + redirect: firstRoute.path + }) + console.log('[Platform] addDynamicRoutes: 添加根路径重定向到', firstRoute.path) + } + } catch (error) { - console.error('❌ Platform 动态路由生成失败:', error) + console.error('[Platform] addDynamicRoutes: 添加路由失败', error) throw error } } @@ -130,18 +133,35 @@ export function addDynamicRoutes(views: TbSysViewDTO[]) { */ export function loadRoutesFromStorage(): boolean { try { - // 使用 shared 中的通用方法加载视图数据 - const views = loadViewsFromStorage('loginDomain', 'userViews') + console.log('[Platform] loadRoutesFromStorage: 开始加载动态路由') - if (views) { + // 使用 shared 中的通用方法加载视图数据 + const allViews = loadViewsFromStorage('loginDomain', 'userViews') + + console.log('[Platform] loadRoutesFromStorage: 加载的所有视图数量:', allViews?.length || 0) + + if (allViews) { + // 过滤出 platform 服务的视图 + const platformViews = allViews.filter((view: TbSysViewDTO) => + view.service === 'platform' + ) + + console.log('[Platform] loadRoutesFromStorage: 过滤后的 platform 视图:', platformViews) + + if (platformViews.length === 0) { + console.warn('[Platform] loadRoutesFromStorage: 没有找到 platform 服务的视图') + return false + } + // 使用 Platform 的 addDynamicRoutes 添加路由 - addDynamicRoutes(views) + addDynamicRoutes(platformViews) return true } + console.warn('[Platform] loadRoutesFromStorage: 未能加载视图数据') return false } catch (error) { - console.error('[Platform 路由] 从 LocalStorage 加载路由失败:', error) + console.error('[Platform] loadRoutesFromStorage: 加载路由失败', error) return false } } diff --git a/urbanLifelineWeb/packages/platform/src/router/index.ts b/urbanLifelineWeb/packages/platform/src/router/index.ts index 487af0a9..5d2bceb0 100644 --- a/urbanLifelineWeb/packages/platform/src/router/index.ts +++ b/urbanLifelineWeb/packages/platform/src/router/index.ts @@ -1,15 +1,9 @@ import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router' -import { SidebarLayout } from '../layouts' import { TokenManager } from 'shared/api' import { loadRoutesFromStorage } from './dynamicRoute' +// platform应用的动态路由会根据layout字段自动添加,不需要预定义Root布局 const routes: RouteRecordRaw[] = [ - { - path: '/', - name: 'Root', - component: SidebarLayout, - children: [] - }, { path: '/login', name: 'Login', diff --git a/urbanLifelineWeb/packages/platform/src/types/shared.d.ts b/urbanLifelineWeb/packages/platform/src/types/shared.d.ts index 6bec6970..f352e00e 100644 --- a/urbanLifelineWeb/packages/platform/src/types/shared.d.ts +++ b/urbanLifelineWeb/packages/platform/src/types/shared.d.ts @@ -7,6 +7,7 @@ declare module 'shared/components' { export const FileUpload: any export const DynamicFormItem: any + export const IframeView: any } declare module 'shared/components/FileUpload' { @@ -21,6 +22,18 @@ declare module 'shared/components/DynamicFormItem' { export default DynamicFormItem } +declare module 'shared/components/iframe/IframeView.vue' { + import { DefineComponent } from 'vue' + const IframeView: DefineComponent<{}, {}, any> + export default IframeView +} + +declare module 'shared/components/iframe/IframeView.vue' { + import { DefineComponent } from 'vue' + const IframeView: DefineComponent<{}, {}, any> + export default IframeView +} + // ========== API 模块 ========== declare module 'shared/api' { export const api: any @@ -35,6 +48,13 @@ declare module 'shared/api/file' { export const fileAPI: any } +declare module 'shared/api' { + export const authAPI: any + export const fileAPI: any + export const TokenManager: any + export const api: any +} + // 保留旧的导出路径(向后兼容) declare module 'shared/FileUpload' { import { DefineComponent } from 'vue' @@ -48,14 +68,6 @@ declare module 'shared/DynamicFormItem' { export default DynamicFormItem } -declare module 'shared/authAPI' { - export const authAPI: any -} - -declare module 'shared/fileAPI' { - export const fileAPI: any -} - declare module 'shared/utils' { export const initAesEncrypt: any export const getAesInstance: any @@ -83,6 +95,7 @@ declare module 'shared/types' { parentId?: string url?: string component?: string + service?: string iframeUrl?: string icon?: string type?: number @@ -162,3 +175,10 @@ declare module 'shared/utils/device' { export function isDesktop(): boolean export function useDevice(): any } + +// ========== Layouts 布局模块 ========== +declare module 'shared/layouts' { + import { DefineComponent } from 'vue' + + export const BlankLayout: DefineComponent<{}, {}, any> +} diff --git a/urbanLifelineWeb/packages/platform/src/views/public/Login/Login.vue b/urbanLifelineWeb/packages/platform/src/views/public/Login/Login.vue index 8218820e..5be1119c 100644 --- a/urbanLifelineWeb/packages/platform/src/views/public/Login/Login.vue +++ b/urbanLifelineWeb/packages/platform/src/views/public/Login/Login.vue @@ -68,9 +68,9 @@ import { reactive, ref } from 'vue' import { useRouter } from 'vue-router' import { ElMessage, type FormInstance, type FormRules } from 'element-plus' import type { LoginParam } from 'shared/types' -import { authAPI } from 'shared/authAPI' -import { getAesInstance } from 'shared/utils' +import { authAPI } from 'shared/api/auth' import { TokenManager } from 'shared/api' +import { getAesInstance } from 'shared/utils' import { resetDynamicRoutes } from '@/router' // 路由 diff --git a/urbanLifelineWeb/packages/platform/vite.config.ts b/urbanLifelineWeb/packages/platform/vite.config.ts index 53a417cc..a0282fa1 100644 --- a/urbanLifelineWeb/packages/platform/vite.config.ts +++ b/urbanLifelineWeb/packages/platform/vite.config.ts @@ -7,7 +7,11 @@ import { fileURLToPath } from 'url' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) + export default defineConfig({ + // Platform 是根路径应用 + base: '/', + plugins: [ vue({ script: { @@ -50,6 +54,7 @@ export default defineConfig({ port: 5001, host: true, cors: true, + open: '/', // 开发时自动打开到根路径 proxy: { '/api': { target: 'http://localhost:8180', diff --git a/urbanLifelineWeb/packages/shared/src/components/iframe/IframeView.vue b/urbanLifelineWeb/packages/shared/src/components/iframe/IframeView.vue new file mode 100644 index 00000000..1e35c572 --- /dev/null +++ b/urbanLifelineWeb/packages/shared/src/components/iframe/IframeView.vue @@ -0,0 +1,90 @@ + + + + + + 无效的 iframe 地址 + + + + 加载中... + + + + + + + diff --git a/urbanLifelineWeb/packages/shared/src/components/index.ts b/urbanLifelineWeb/packages/shared/src/components/index.ts index 925909c3..2e8c9f19 100644 --- a/urbanLifelineWeb/packages/shared/src/components/index.ts +++ b/urbanLifelineWeb/packages/shared/src/components/index.ts @@ -1,3 +1,6 @@ export * from './fileupload' export * from './base' -export * from './dynamicFormItem' \ No newline at end of file +export * from './dynamicFormItem' + +// 通用视图组件 +export { default as IframeView } from './iframe/IframeView.vue' \ No newline at end of file diff --git a/urbanLifelineWeb/packages/shared/src/config/index.ts b/urbanLifelineWeb/packages/shared/src/config/index.ts index 30e2ea8f..830d90f9 100644 --- a/urbanLifelineWeb/packages/shared/src/config/index.ts +++ b/urbanLifelineWeb/packages/shared/src/config/index.ts @@ -43,6 +43,12 @@ export interface AppRuntimeConfig { }; publicImgPath: string; publicWebPath: string; + // 单点登录配置 + sso?: { + platformUrl: string; // platform 平台地址 + workcaseUrl: string; // workcase 服务地址 + biddingUrl: string; // bidding 服务地址 + }; features?: { enableDebug?: boolean; enableMockData?: boolean; @@ -92,6 +98,15 @@ const devConfig: AppRuntimeConfig = { publicImgPath: 'http://localhost:5173/img', publicWebPath: 'http://localhost:5173', + // 单点登录配置 + // 推荐:开发环境也通过nginx访问(http://localhost) + // 备选:直接访问各服务端口(platformUrl: 'http://localhost:5001') + sso: { + platformUrl: '/', // 通过nginx访问platform + workcaseUrl: '/workcase', // 通过nginx访问workcase + biddingUrl: '/bidding' // 通过nginx访问bidding + }, + features: { enableDebug: true, enableMockData: false @@ -132,6 +147,13 @@ const prodDefaultConfig: AppRuntimeConfig = { publicImgPath: '/img', publicWebPath: '/', + // 单点登录配置(生产环境通过nginx代理) + sso: { + platformUrl: '/', + workcaseUrl: '/workcase', + biddingUrl: '/bidding' + }, + features: { enableDebug: false, enableMockData: false @@ -218,6 +240,13 @@ export const APP_CONFIG = { publicImgPath: config.publicImgPath, publicWebPath: config.publicWebPath, + // 单点登录配置 + sso: config.sso || { + platformUrl: '/', + workcaseUrl: '/workcase', + biddingUrl: '/bidding' + }, + // 功能开关 features: config.features || {} }; diff --git a/urbanLifelineWeb/packages/shared/src/layouts/BlankLayout/BlankLayout.vue b/urbanLifelineWeb/packages/shared/src/layouts/BlankLayout/BlankLayout.vue new file mode 100644 index 00000000..e57dd5cc --- /dev/null +++ b/urbanLifelineWeb/packages/shared/src/layouts/BlankLayout/BlankLayout.vue @@ -0,0 +1,18 @@ + + + + + + + + + diff --git a/urbanLifelineWeb/packages/shared/src/layouts/index.ts b/urbanLifelineWeb/packages/shared/src/layouts/index.ts new file mode 100644 index 00000000..4e84460d --- /dev/null +++ b/urbanLifelineWeb/packages/shared/src/layouts/index.ts @@ -0,0 +1 @@ +export { default as BlankLayout } from './BlankLayout/BlankLayout.vue' diff --git a/urbanLifelineWeb/packages/shared/src/types/sys/permission.ts b/urbanLifelineWeb/packages/shared/src/types/sys/permission.ts index abf7ee6e..5b753c0b 100644 --- a/urbanLifelineWeb/packages/shared/src/types/sys/permission.ts +++ b/urbanLifelineWeb/packages/shared/src/types/sys/permission.ts @@ -128,6 +128,8 @@ export interface TbSysViewDTO extends BaseDTO { type?: number; /** 视图类型 route\iframe*/ viewType?: string; + /** 所属服务 platform\workcase\bidding */ + service?: string; /** 布局 */ layout?: string; /** 排序 */ diff --git a/urbanLifelineWeb/packages/shared/src/utils/route/route-generator.ts b/urbanLifelineWeb/packages/shared/src/utils/route/route-generator.ts index e3c0836b..7e495429 100644 --- a/urbanLifelineWeb/packages/shared/src/utils/route/route-generator.ts +++ b/urbanLifelineWeb/packages/shared/src/utils/route/route-generator.ts @@ -184,15 +184,22 @@ function generateRouteFromMenu( route.component = component } else { // 组件加载失败,使用 404 - route.component = config.notFoundComponent || (() => Promise.resolve({ default: { template: '404' } })) + route.component = config.notFoundComponent || (() => import('vue').then(({ h }) => ({ + default: { + render() { return h('div', '404') } + } + }))) } } else { // 使用路由占位组件 - route.component = () => Promise.resolve({ + route.component = () => import('vue').then(({ h, resolveComponent }) => ({ default: { - template: '' + render() { + const RouterView = resolveComponent('RouterView') + return h(RouterView) + } } - }) + })) } } @@ -677,17 +684,29 @@ function generateSimpleRoute( let component: any if (isIframe) { - // iframe 类型:使用占位组件 - component = iframePlaceholder || (() => Promise.resolve({ - default: { - template: '' - } - })) + // iframe 类型:使用占位组件(用于显示iframe内容) + // 路由路径使用 url 字段(应该设置为不冲突的路径,如 /app/workcase) + component = iframePlaceholder || (() => import('vue').then(({ h }) => ({ + default: { + render() { return h('div', { class: 'iframe-placeholder' }, 'Loading...') } + } + }))) } else if (view.component) { // route 类型:加载实际组件 component = config.viewLoader(view.component) if (!component) { - if (verbose) console.warn('[路由生成] 组件加载失败:', view.component) + if (verbose) console.warn('[路由生成] 组件加载失败:', view.component, '使用占位组件') + // 使用占位组件,避免路由无效 + const errorMsg = `组件加载失败: ${view.component}` + component = () => import('vue').then(({ h }) => ({ + default: { + render() { + return h('div', { + style: { padding: '20px', color: 'red' } + }, errorMsg) + } + } + })) } } @@ -753,11 +772,14 @@ function generateSimpleRoute( route.component = component } else if (!component && hasChildren) { // 没有组件,只有子视图(路由容器) - route.component = () => Promise.resolve({ + route.component = () => import('vue').then(({ h, resolveComponent }) => ({ default: { - template: '' + render() { + const RouterView = resolveComponent('RouterView') + return h(RouterView) + } } - }) + })) route.children = [] // 添加子路由 @@ -785,5 +807,51 @@ function generateSimpleRoute( return null } + // 处理layout:如果视图指定了layout,且不是作为Root的子路由,且有有效组件,需要包裹layout + const viewLayout = (view as any).layout + if (viewLayout && !asRootChild && route.component && config.layoutMap[viewLayout]) { + if (verbose) { + console.log('[路由生成] 为视图添加布局:', view.name, '布局:', viewLayout, '路径:', routePath) + } + + // 创建layout路由,将原路由的组件作为其子路由 + const layoutRoute: RouteRecordRaw = { + path: routePath, + name: view.viewId, + component: config.layoutMap[viewLayout], + meta: { + ...route.meta, + layout: viewLayout // 标记使用的布局 + }, + children: [ + { + path: '', + name: `${view.viewId}_content`, + component: route.component, + meta: route.meta + } + ] + } + + // 如果原路由有其他children(子视图),也添加到layout路由的children中 + if (route.children && route.children.length > 0) { + // 跳过第一个空路径的子路由(如果存在) + const otherChildren = route.children.filter((child: any) => child.path !== '') + if (otherChildren.length > 0) { + layoutRoute.children!.push(...otherChildren) + } + } + + if (verbose) { + console.log('[路由生成] Layout路由生成完成:', { + path: layoutRoute.path, + name: layoutRoute.name, + childrenCount: layoutRoute.children?.length + }) + } + + return layoutRoute + } + return route } diff --git a/urbanLifelineWeb/packages/shared/vite.config.ts b/urbanLifelineWeb/packages/shared/vite.config.ts index 4ca2321a..8804924c 100644 --- a/urbanLifelineWeb/packages/shared/vite.config.ts +++ b/urbanLifelineWeb/packages/shared/vite.config.ts @@ -36,7 +36,8 @@ export default defineConfig({ './components': './src/components/index.ts', './components/FileUpload': './src/components/fileupload/FileUpload.vue', './components/DynamicFormItem': './src/components/dynamicFormItem/DynamicFormItem.vue', - + './components/iframe/IframeView.vue': './src/components/iframe/IframeView.vue', + // ========== API 模块 ========== './api': './src/api/index.ts', './api/auth': './src/api/auth/auth.ts', @@ -54,7 +55,13 @@ export default defineConfig({ './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' + './types/sys': './src/types/sys/index.ts', + + // ========== Config 配置模块 ========== + './config': './src/config/index.ts', + + // ========== Layouts 布局模块 ========== + './layouts': './src/layouts/index.ts' }, // 共享依赖(重要:避免重复加载) shared: { diff --git a/urbanLifelineWeb/packages/shared/.__mf__temp/shared/localSharedImportMap.js b/urbanLifelineWeb/packages/workcase/.__mf__temp/workcase/localSharedImportMap.js similarity index 55% rename from urbanLifelineWeb/packages/shared/.__mf__temp/shared/localSharedImportMap.js rename to urbanLifelineWeb/packages/workcase/.__mf__temp/workcase/localSharedImportMap.js index 90aed31d..f4045509 100644 --- a/urbanLifelineWeb/packages/shared/.__mf__temp/shared/localSharedImportMap.js +++ b/urbanLifelineWeb/packages/workcase/.__mf__temp/workcase/localSharedImportMap.js @@ -4,100 +4,30 @@ import {loadShare} from "@module-federation/runtime"; const importMap = { - "@element-plus/icons-vue": async () => { - let pkg = await import("__mf__virtual/shared__prebuild___mf_0_element_mf_2_plus_mf_1_icons_mf_2_vue__prebuild__.js"); - return pkg; - } - , - "axios": async () => { - let pkg = await import("__mf__virtual/shared__prebuild__axios__prebuild__.js"); - return pkg; - } - , "element-plus": async () => { - let pkg = await import("__mf__virtual/shared__prebuild__element_mf_2_plus__prebuild__.js"); + let pkg = await import("__mf__virtual/workcase__prebuild__element_mf_2_plus__prebuild__.js"); return pkg; } , "vue": async () => { - let pkg = await import("__mf__virtual/shared__prebuild__vue__prebuild__.js"); + let pkg = await import("__mf__virtual/workcase__prebuild__vue__prebuild__.js"); return pkg; } , "vue-router": async () => { - let pkg = await import("__mf__virtual/shared__prebuild__vue_mf_2_router__prebuild__.js"); + let pkg = await import("__mf__virtual/workcase__prebuild__vue_mf_2_router__prebuild__.js"); return pkg; } } const usedShared = { - "@element-plus/icons-vue": { - name: "@element-plus/icons-vue", - version: "2.3.2", - scope: ["default"], - loaded: false, - from: "shared", - async get () { - if (false) { - throw new Error(`Shared module '${"@element-plus/icons-vue"}' must be provided by host`); - } - usedShared["@element-plus/icons-vue"].loaded = true - const {"@element-plus/icons-vue": pkgDynamicImport} = importMap - const res = await pkgDynamicImport() - const exportModule = {...res} - // All npm packages pre-built by vite will be converted to esm - Object.defineProperty(exportModule, "__esModule", { - value: true, - enumerable: false - }) - return function () { - return exportModule - } - }, - shareConfig: { - singleton: false, - requiredVersion: "^2.3.2", - - } - } - , - "axios": { - name: "axios", - version: "1.13.2", - scope: ["default"], - loaded: false, - from: "shared", - async get () { - if (false) { - throw new Error(`Shared module '${"axios"}' must be provided by host`); - } - usedShared["axios"].loaded = true - const {"axios": pkgDynamicImport} = importMap - const res = await pkgDynamicImport() - const exportModule = {...res} - // All npm packages pre-built by vite will be converted to esm - Object.defineProperty(exportModule, "__esModule", { - value: true, - enumerable: false - }) - return function () { - return exportModule - } - }, - shareConfig: { - singleton: false, - requiredVersion: "^1.13.2", - - } - } - , "element-plus": { name: "element-plus", version: "2.12.0", scope: ["default"], loaded: false, - from: "shared", + from: "workcase", async get () { if (false) { throw new Error(`Shared module '${"element-plus"}' must be provided by host`); @@ -127,7 +57,7 @@ version: "3.5.25", scope: ["default"], loaded: false, - from: "shared", + from: "workcase", async get () { if (false) { throw new Error(`Shared module '${"vue"}' must be provided by host`); @@ -157,7 +87,7 @@ version: "4.6.3", scope: ["default"], loaded: false, - from: "shared", + from: "workcase", async get () { if (false) { throw new Error(`Shared module '${"vue-router"}' must be provided by host`); @@ -184,6 +114,14 @@ } const usedRemotes = [ + { + entryGlobalName: "shared", + name: "shared", + type: "module", + entry: "http://localhost:5000/remoteEntry.js", + shareScope: "default", + } + ] export { usedShared, diff --git a/urbanLifelineWeb/packages/workcase/index.html b/urbanLifelineWeb/packages/workcase/index.html index 00d91c29..7b994e53 100644 --- a/urbanLifelineWeb/packages/workcase/index.html +++ b/urbanLifelineWeb/packages/workcase/index.html @@ -8,23 +8,6 @@ - - - - - - - diff --git a/urbanLifelineWeb/packages/workcase/package.json b/urbanLifelineWeb/packages/workcase/package.json index f0f41a53..a7b89b79 100644 --- a/urbanLifelineWeb/packages/workcase/package.json +++ b/urbanLifelineWeb/packages/workcase/package.json @@ -21,6 +21,7 @@ "@types/node": "^22.0.0", "@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue-jsx": "^4.1.1", + "@module-federation/vite": "^1.9.3", "typescript": "^5.7.2", "vite": "^6.0.3", "vue-tsc": "^2.2.0" diff --git a/urbanLifelineWeb/packages/workcase/pnpm-lock.yaml b/urbanLifelineWeb/packages/workcase/pnpm-lock.yaml index 644bf917..94f5baca 100644 --- a/urbanLifelineWeb/packages/workcase/pnpm-lock.yaml +++ b/urbanLifelineWeb/packages/workcase/pnpm-lock.yaml @@ -28,6 +28,9 @@ dependencies: version: 4.6.3(vue@3.5.25) devDependencies: + '@module-federation/vite': + specifier: ^1.9.3 + version: 1.9.3 '@types/node': specifier: ^22.0.0 version: 22.19.1 @@ -596,10 +599,61 @@ packages: '@jridgewell/sourcemap-codec': 1.5.5 dev: true + /@module-federation/error-codes@0.21.6: + resolution: {integrity: sha512-MLJUCQ05KnoVl8xd6xs9a5g2/8U+eWmVxg7xiBMeR0+7OjdWUbHwcwgVFatRIwSZvFgKHfWEiI7wsU1q1XbTRQ==} + dev: true + + /@module-federation/runtime-core@0.21.6: + resolution: {integrity: sha512-5Hd1Y5qp5lU/aTiK66lidMlM/4ji2gr3EXAtJdreJzkY+bKcI5+21GRcliZ4RAkICmvdxQU5PHPL71XmNc7Lsw==} + dependencies: + '@module-federation/error-codes': 0.21.6 + '@module-federation/sdk': 0.21.6 + dev: true + + /@module-federation/runtime@0.21.6: + resolution: {integrity: sha512-+caXwaQqwTNh+CQqyb4mZmXq7iEemRDrTZQGD+zyeH454JAYnJ3s/3oDFizdH6245pk+NiqDyOOkHzzFQorKhQ==} + dependencies: + '@module-federation/error-codes': 0.21.6 + '@module-federation/runtime-core': 0.21.6 + '@module-federation/sdk': 0.21.6 + dev: true + + /@module-federation/sdk@0.21.6: + resolution: {integrity: sha512-x6hARETb8iqHVhEsQBysuWpznNZViUh84qV2yE7AD+g7uIzHKiYdoWqj10posbo5XKf/147qgWDzKZoKoEP2dw==} + dev: true + + /@module-federation/vite@1.9.3: + resolution: {integrity: sha512-MV6XI3FX6okEMJ7FdmvFmYuu7DygRoLljKT8atrBwFhlttsgBbswpqMj4P4Fs/X+pFmbIi/ntFzVhsrG0qQnGQ==} + dependencies: + '@module-federation/runtime': 0.21.6 + '@module-federation/sdk': 0.21.6 + '@rollup/pluginutils': 5.3.0 + defu: 6.1.4 + estree-walker: 2.0.2 + magic-string: 0.30.21 + pathe: 1.1.2 + transitivePeerDependencies: + - rollup + dev: true + /@rolldown/pluginutils@1.0.0-beta.53: resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} dev: true + /@rollup/pluginutils@5.3.0: + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + dependencies: + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.3 + dev: true + /@rollup/rollup-android-arm-eabi@4.53.3: resolution: {integrity: sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==} cpu: [arm] @@ -1146,6 +1200,10 @@ packages: ms: 2.1.3 dev: true + /defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + dev: true + /delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -1464,6 +1522,10 @@ packages: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} dev: true + /pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + dev: true + /picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} diff --git a/urbanLifelineWeb/packages/workcase/public/app-config.js b/urbanLifelineWeb/packages/workcase/public/app-config.js index 90302297..fb0f4f03 100644 --- a/urbanLifelineWeb/packages/workcase/public/app-config.js +++ b/urbanLifelineWeb/packages/workcase/public/app-config.js @@ -51,6 +51,13 @@ window.APP_RUNTIME_CONFIG = { publicImgPath: '/img', publicWebPath: '/', + // 单点登录配置 + sso: { + platformUrl: '/', // platform 平台地址 + workcaseUrl: '/workcase', // workcase 服务地址 + biddingUrl: '/bidding' // bidding 服务地址 + }, + // 功能开关 features: { enableDebug: false, diff --git a/urbanLifelineWeb/packages/workcase/public/avatar.svg b/urbanLifelineWeb/packages/workcase/public/avatar.svg new file mode 100644 index 00000000..b537ce1d --- /dev/null +++ b/urbanLifelineWeb/packages/workcase/public/avatar.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/urbanLifelineWeb/packages/workcase/public/favicon.svg b/urbanLifelineWeb/packages/workcase/public/favicon.svg new file mode 100644 index 00000000..0064d6fc --- /dev/null +++ b/urbanLifelineWeb/packages/workcase/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/urbanLifelineWeb/packages/workcase/public/logo.jpg b/urbanLifelineWeb/packages/workcase/public/logo.jpg new file mode 100644 index 00000000..18b5da66 Binary files /dev/null and b/urbanLifelineWeb/packages/workcase/public/logo.jpg differ diff --git a/urbanLifelineWeb/packages/workcase/src/App.vue b/urbanLifelineWeb/packages/workcase/src/App.vue new file mode 100644 index 00000000..f1d58b48 --- /dev/null +++ b/urbanLifelineWeb/packages/workcase/src/App.vue @@ -0,0 +1,27 @@ + + + + + + + diff --git a/urbanLifelineWeb/packages/workcase/src/config.ts b/urbanLifelineWeb/packages/workcase/src/config.ts new file mode 100644 index 00000000..9fd99ffb --- /dev/null +++ b/urbanLifelineWeb/packages/workcase/src/config.ts @@ -0,0 +1,34 @@ +/** + * Workcase 应用配置 + */ + +/** + * AES 加密密钥(与后端保持一致) + * 对应后端配置:security.aes.secret-key + * Base64 编码的 32 字节密钥(256 位) + */ +export const AES_SECRET_KEY = 'MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=' // Base64 编码,解码后是 "12345678901234567890123456789012" (32字节) + +/** + * API 基础地址 + * 注意:使用 shared 的 APP_CONFIG 统一管理,这里保留用于特殊场景 + */ +export const API_BASE_URL = (import.meta as any).env?.VITE_API_BASE_URL || '/api' + +/** + * Platform URL(单点登录入口) + * 开发环境: + * - 通过nginx访问时使用 '/'(推荐) + * - 直接访问各服务时使用 'http://localhost:5001' + * 生产环境:统一使用 '/' + */ +export const PLATFORM_URL = (import.meta as any).env?.VITE_PLATFORM_URL || '/' + +/** + * 应用配置 + */ +export const APP_CONFIG = { + name: '泰豪小电', + version: '1.0.0', + copyright: '泰豪电源' +} \ No newline at end of file diff --git a/urbanLifelineWeb/packages/workcase/src/layouts/SidebarLayout/SidebarLayout.scss b/urbanLifelineWeb/packages/workcase/src/layouts/SidebarLayout/SidebarLayout.scss new file mode 100644 index 00000000..c3a40ef7 --- /dev/null +++ b/urbanLifelineWeb/packages/workcase/src/layouts/SidebarLayout/SidebarLayout.scss @@ -0,0 +1,264 @@ +.sidebar-layout { + display: flex; + width: 100%; + height: 100vh; + overflow: hidden; +} + +// ==================== 侧边栏 ==================== +.sidebar { + width: 220px; + height: 100%; + background: #F0EAF4; + display: flex; + flex-direction: column; + color: #333; + flex-shrink: 0; + transition: width 0.3s ease; + border-right: 1px solid rgba(0, 0, 0, 0.08); + + &.collapsed { + width: 64px; + + .sidebar-header { + padding: 16px 12px; + justify-content: center; + + .logo { + justify-content: center; + } + + .collapse-btn { + position: static; + margin-left: 0; + } + } + + .nav-item { + justify-content: center; + padding: 12px; + } + + .user-section { + justify-content: center; + padding: 16px 12px; + } + } +} + +// 侧边栏头部 +.sidebar-header { + padding: 16px 20px; + border-bottom: 1px solid rgba(0, 0, 0, 0.08); + display: flex; + align-items: center; + justify-content: space-between; +} + +.collapse-btn { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + cursor: pointer; + color: #888; + transition: all 0.2s; + + &:hover { + background: rgba(124, 58, 237, 0.1); + color: #7c3aed; + } +} + +.logo { + display: flex; + align-items: center; + gap: 10px; + + .logo-img { + width: 40px; + height: 40px; + border-radius: 6px; + object-fit: contain; + background: #fff; + padding: 2px; + } + + .logo-text { + font-size: 16px; + font-weight: 600; + color: #333; + } +} + +// 导航菜单 +.nav-menu { + flex: 1; + overflow-y: auto; + padding: 12px 0; + + &::-webkit-scrollbar { + width: 4px; + } + + &::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + } +} + +.nav-section { + padding: 8px 0; +} + +.nav-item { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 20px; + margin-bottom: 4px; + cursor: pointer; + transition: all 0.2s ease; + color: #555; + font-size: 14px; + + &:hover { + background: rgba(124, 58, 237, 0.1); + color: #7c3aed; + } + + &.active { + background: rgba(124, 58, 237, 0.15); + color: #7c3aed; + font-weight: 500; + } + + .el-icon { + font-size: 18px; + flex-shrink: 0; + } + + span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} + +// 用户信息 +.user-section { + padding: 16px 20px; + border-top: 1px solid rgba(0, 0, 0, 0.08); + cursor: pointer; + transition: background 0.2s; + + &:hover { + background: rgba(124, 58, 237, 0.05); + } + + .user-info-wrapper { + display: flex; + align-items: center; + gap: 12px; + } + + .user-avatar { + flex-shrink: 0; + } + + .user-name { + font-size: 14px; + font-weight: 500; + color: #333; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} + +// ==================== 主内容区 ==================== +.main-content { + flex: 1; + height: 100%; + overflow: hidden; + background: #fff; + position: relative; +} + +// iframe 容器 +.iframe-container { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + position: relative; +} + +.iframe-header { + height: 56px; + padding: 0 24px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid #e5e7eb; + background: #fafafa; + flex-shrink: 0; +} + +.iframe-title { + font-size: 16px; + font-weight: 600; + color: #333; +} + +.content-iframe { + flex: 1; + width: 100%; + height: 100%; + border: none; + background: #fff; +} + +.iframe-loading { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + color: #7c3aed; + font-size: 14px; + z-index: 10; + + .el-icon { + font-size: 32px; + } +} + +// ==================== 响应式 ==================== +@media (max-width: 768px) { + .sidebar { + width: 64px; + + &:not(.collapsed) { + width: 220px; + position: fixed; + left: 0; + top: 0; + z-index: 1000; + box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1); + } + } + + .iframe-header { + padding: 0 16px; + + .iframe-title { + font-size: 14px; + } + } +} diff --git a/urbanLifelineWeb/packages/workcase/src/layouts/SidebarLayout/SidebarLayout.vue b/urbanLifelineWeb/packages/workcase/src/layouts/SidebarLayout/SidebarLayout.vue new file mode 100644 index 00000000..4cf0bc02 --- /dev/null +++ b/urbanLifelineWeb/packages/workcase/src/layouts/SidebarLayout/SidebarLayout.vue @@ -0,0 +1,293 @@ + + + + + + + + + + + {{ currentMenuItem?.label }} + + 刷新 + + + + + + 加载中... + + + + + + + + + + + + \ No newline at end of file diff --git a/urbanLifelineWeb/packages/workcase/src/layouts/index.ts b/urbanLifelineWeb/packages/workcase/src/layouts/index.ts new file mode 100644 index 00000000..387ddeff --- /dev/null +++ b/urbanLifelineWeb/packages/workcase/src/layouts/index.ts @@ -0,0 +1,3 @@ +export { default as SidebarLayout } from './SidebarLayout/SidebarLayout.vue' +// BlankLayout从shared导入 +export { BlankLayout } from 'shared/layouts' diff --git a/urbanLifelineWeb/packages/workcase/src/main.ts b/urbanLifelineWeb/packages/workcase/src/main.ts new file mode 100644 index 00000000..52ed9f9b --- /dev/null +++ b/urbanLifelineWeb/packages/workcase/src/main.ts @@ -0,0 +1,48 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' +import 'element-plus/dist/index.css' + +import App from './App.vue' +import router from './router/' +import { AES_SECRET_KEY } from './config' +// @ts-ignore +import { initAesEncrypt } from 'shared/utils' + +// 异步初始化应用 +async function initApp() { + // 1. 初始化 AES 加密工具 + try { + await initAesEncrypt(AES_SECRET_KEY) + console.log('✅ AES 加密工具初始化成功') + } catch (error) { + console.error('❌ AES 加密工具初始化失败:', error) + } + + // 2. 创建 Vue 应用 + const app = createApp(App) + + // 3. 注册 Pinia + const pinia = createPinia() + app.use(pinia) + + // 4. 注册 Element Plus + app.use(ElementPlus) + + // 5. 注册所有 Element Plus 图标 + for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component) + } + + // 6. 注册路由 + app.use(router) + + // 7. 挂载应用 + app.mount('#app') + + console.log('✅ Workcase 应用启动成功') +} + +// 启动应用 +initApp() diff --git a/urbanLifelineWeb/packages/workcase/src/router/dynamicRoute.ts b/urbanLifelineWeb/packages/workcase/src/router/dynamicRoute.ts new file mode 100644 index 00000000..66c7e6f5 --- /dev/null +++ b/urbanLifelineWeb/packages/workcase/src/router/dynamicRoute.ts @@ -0,0 +1,154 @@ +/** + * 动态路由生成模块(Workcase 特定) + * + * 职责: + * 1. 提供 Workcase 特定的布局和组件配置 + * 2. 调用 shared 中的通用路由生成方法 + * 3. 将生成的路由添加到 Workcase 的 router 实例 + */ + +/// + +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, BlankLayout } from '@/layouts' + +// Workcase 布局组件映射 +const workcaseLayoutMap: Record Promise> = { + 'SidebarLayout': () => Promise.resolve({ default: SidebarLayout }), + 'BlankLayout': () => Promise.resolve({ default: BlankLayout }), + 'NavigationLayout': () => Promise.resolve({ default: SidebarLayout }), + 'BasicLayout': () => Promise.resolve({ default: SidebarLayout }) +} + +// 视图组件加载器 +const VIEW_MODULES = import.meta.glob<{ default: any }>('../views/**/*.vue') + +/** + * 视图组件加载函数 + * @param componentPath 组件路径(如 "public/AIChat/AIChatView.vue" 或 "workcase/List") + */ +function viewLoader(componentPath: string): (() => Promise) | null { + // 将后台路径转换为 @/views 格式 + let path = componentPath + + // 移除开头的斜杠(如果有) + if (path.startsWith('/')) { + path = path.substring(1) + } + + // 补全 .vue 后缀(如果没有) + if (!path.endsWith('.vue')) { + path += '.vue' + } + + // 转换为 ../views 格式(匹配 import.meta.glob 的 key) + const fullPath = `../views/${path}` + + console.log('[Workcase viewLoader] 尝试加载组件:', componentPath, '→', fullPath) + + const loader = VIEW_MODULES[fullPath] + + if (!loader) { + console.warn('[Workcase viewLoader] 组件未找到:', fullPath) + console.log('[Workcase viewLoader] 可用的组件:', Object.keys(VIEW_MODULES)) + return null + } + + return loader as () => Promise +} + +// Workcase 路由生成器配置 +const routeConfig: RouteGeneratorConfig = { + layoutMap: workcaseLayoutMap, + viewLoader, + notFoundComponent: () => Promise.resolve({ + default: { + template: '404 - 页面未找到' + } + }) +} + +// Workcase 路由生成选项 +const routeOptions: GenerateSimpleRoutesOptions = { + asRootChildren: false, // 直接作为根级路由,不是某个布局的子路由 + iframePlaceholder: () => import('shared/components').then(m => ({ default: m.IframeView })), + verbose: true // 启用详细日志 +} + +/** + * 添加动态路由(Workcase 特定) + * @param views 视图列表(用作菜单) + */ +export function addDynamicRoutes(views: TbSysViewDTO[]) { + if (!views || views.length === 0) { + console.warn('[Workcase] addDynamicRoutes: 视图列表为空') + return + } + + console.log('[Workcase] addDynamicRoutes: 开始添加动态路由,视图数量:', views.length) + console.log('[Workcase] addDynamicRoutes: 路由配置:', routeConfig) + console.log('[Workcase] addDynamicRoutes: 路由选项:', routeOptions) + + try { + // 使用 shared 中的通用方法生成路由 + const routes = generateSimpleRoutes(views, routeConfig, routeOptions) + + // 直接将路由添加到根级别(不是作为Root的children) + routes.forEach(route => { + console.log('[Workcase] addDynamicRoutes: 添加路由', route.path, '使用布局:', route.component?.name || 'unknown') + router.addRoute(route) + }) + + } catch (error) { + throw error + } +} + +/** + * 从 LocalStorage 获取菜单并生成路由(Workcase 特定) + * + * 使用 shared 中的通用 loadViewsFromStorage 方法 + * 筛选出 service='workcase' 的视图 + */ +export function loadRoutesFromStorage(): boolean { + try { + console.log('[Workcase] loadRoutesFromStorage: 开始加载动态路由') + + // 使用 shared 中的通用方法加载视图数据 + const allViews = loadViewsFromStorage('loginDomain', 'userViews') + + console.log('[Workcase] loadRoutesFromStorage: 加载的所有视图数量:', allViews?.length || 0) + + if (allViews) { + // 过滤出 workcase 服务的视图 + const workcaseViews = allViews.filter((view: TbSysViewDTO) => + view.service === 'workcase' + ) + + console.log('[Workcase] loadRoutesFromStorage: 过滤后的 workcase 视图:', workcaseViews) + + if (workcaseViews.length === 0) { + console.warn('[Workcase] loadRoutesFromStorage: 没有找到 workcase 服务的视图') + return false + } + + // 使用 Workcase 的 addDynamicRoutes 添加路由 + addDynamicRoutes(workcaseViews) + return true + } + + console.warn('[Workcase] loadRoutesFromStorage: 未能加载视图数据') + return false + } catch (error) { + console.error('[Workcase] loadRoutesFromStorage: 加载路由失败', error) + return false + } +} diff --git a/urbanLifelineWeb/packages/workcase/src/router/index.ts b/urbanLifelineWeb/packages/workcase/src/router/index.ts new file mode 100644 index 00000000..ace44e6d --- /dev/null +++ b/urbanLifelineWeb/packages/workcase/src/router/index.ts @@ -0,0 +1,94 @@ +import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router' +// @ts-ignore +import { TokenManager } from 'shared/api' +// @ts-ignore +import { APP_CONFIG } from 'shared/config' +// @ts-ignore +import { loadRoutesFromStorage } from './dynamicRoute' + +// workcase应用的动态路由会根据layout字段自动添加,不需要预定义Root布局 +const routes: RouteRecordRaw[] = [] + +const router = createRouter({ + history: createWebHistory('/workcase'), // 与nginx保持一致,使用/workcase前缀 + routes +}) + +// 标记动态路由是否已加载 +let dynamicRoutesLoaded = false + +// 路由守卫 +router.beforeEach((to, from, next) => { + console.log('[Workcase Router] 路由守卫触发:', { + to: to.path, + from: from.path, + meta: to.meta + }) + + // 设置页面标题 + if (to.meta.title) { + document.title = `${to.meta.title} - 工单管理系统` + } + + // 检查是否需要登录 + const requiresAuth = to.meta.requiresAuth !== false + const hasToken = TokenManager.hasToken() + + console.log('[Workcase Router] 认证检查:', { + requiresAuth, + hasToken, + tokenValue: localStorage.getItem('token') + }) + + // 其他页面:检查是否需要登录 + if (requiresAuth && !hasToken) { + // 需要登录但未登录,重定向到 platform 的登录页 + // 重要:必须使用完整URL(包含origin),避免被workcase的路由拦截造成循环 + const currentUrl = window.location.href + const origin = window.location.origin + + // 构建platform登录页的完整URL + const loginUrl = `${origin}/login?redirect=${encodeURIComponent(currentUrl)}` + + console.log('[Workcase Router] 未登录,重定向到Platform登录页:', loginUrl) + + // 使用完整URL跳转,跳出workcase的路由系统 + window.location.href = loginUrl + return + } + + // 如果已登录且动态路由未加载,先加载动态路由 + if (hasToken && !dynamicRoutesLoaded) { + console.log('[Workcase Router] 开始加载动态路由...') + console.log('[Workcase Router] LocalStorage 内容:', { + loginDomain: localStorage.getItem('loginDomain'), + token: localStorage.getItem('token') + }) + + dynamicRoutesLoaded = true + const loaded = loadRoutesFromStorage?.() + + console.log('[Workcase Router] 动态路由加载结果:', loaded) + console.log('[Workcase Router] 当前路径:', to.path) + console.log('[Workcase Router] 所有路由:', router.getRoutes().map(r => r.path)) + + if (loaded) { + // 动态路由加载成功,重新导航以匹配新添加的路由 + console.log('[Workcase Router] 动态路由加载成功,重新导航到:', to.path) + next({ ...to, replace: true }) + return + } else { + console.warn('[Workcase Router] 动态路由加载失败') + } + } + + console.log('[Workcase Router] 继续正常导航') + next() +}) + +// 重置动态路由加载状态 +export function resetDynamicRoutes() { + dynamicRoutesLoaded = false +} + +export default router diff --git a/urbanLifelineWeb/packages/workcase/src/types/shared.d.ts b/urbanLifelineWeb/packages/workcase/src/types/shared.d.ts new file mode 100644 index 00000000..b3770513 --- /dev/null +++ b/urbanLifelineWeb/packages/workcase/src/types/shared.d.ts @@ -0,0 +1,179 @@ +/** + * Shared Module Federation 类型声明 + * 用于 TypeScript 识别远程模块 + */ + +// ========== 组件模块 ========== +declare module 'shared/components' { + export const FileUpload: any + export const DynamicFormItem: any + export const IframeView: 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 +} + +declare module 'shared/components/iframe/IframeView.vue' { + import { DefineComponent } from 'vue' + const IframeView: DefineComponent<{}, {}, any> + export default IframeView +} + +// ========== 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> + export default FileUpload +} + +declare module 'shared/DynamicFormItem' { + import { DefineComponent } from 'vue' + const DynamicFormItem: DefineComponent<{}, {}, any> + export default DynamicFormItem +} + +declare module 'shared/authAPI' { + export const authAPI: any +} + +declare module 'shared/fileAPI' { + export const fileAPI: any +} + +declare module 'shared/utils' { + export const initAesEncrypt: any + export const getAesInstance: any + export const formatFileSize: any + export const isImageFile: any + export const getFileTypeIcon: any + export const isValidFileType: any + export const getFilePreviewUrl: any +} + +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 = any + + // 视图类型(用于路由和菜单) + export interface TbSysViewDTO { + viewId?: string + name?: string + parentId?: string + url?: string + component?: string + iframeUrl?: string + icon?: string + type?: number + service?: string + layout?: string + orderNum?: number + description?: string + children?: TbSysViewDTO[] + } +} + +declare module 'shared/utils/route' { + import { RouteRecordRaw } from 'vue-router' + import type { TbSysViewDTO } from 'shared/types' + + export interface RouteGeneratorConfig { + layoutMap: Record Promise> + viewLoader: (componentPath: string) => (() => Promise) | null + staticRoutes?: RouteRecordRaw[] + notFoundComponent?: () => Promise + } + + export interface GenerateSimpleRoutesOptions { + asRootChildren?: boolean + iframePlaceholder?: () => Promise + 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 +} + +// ========== Layouts 布局模块 ========== +declare module 'shared/layouts' { + import { DefineComponent } from 'vue' + + export const BlankLayout: DefineComponent<{}, {}, any> +} diff --git a/urbanLifelineWeb/packages/workcase/src/views/public/AIChat/AIChatView.scss b/urbanLifelineWeb/packages/workcase/src/views/public/AIChat/AIChatView.scss new file mode 100644 index 00000000..8446df4d --- /dev/null +++ b/urbanLifelineWeb/packages/workcase/src/views/public/AIChat/AIChatView.scss @@ -0,0 +1,247 @@ +.ai-chat-system { + display: flex; + height: 100vh; + background: #f5f7fa; + + // 左侧边栏 + .chat-sidebar { + width: 200px; + background: #fff; + border-right: 1px solid #e4e7ed; + display: flex; + flex-direction: column; + + .sidebar-header { + padding: 20px 16px; + border-bottom: 1px solid #e4e7ed; + + h1 { + font-size: 16px; + margin: 0; + color: #303133; + } + } + + .nav-section { + padding: 12px 8px; + + .nav-item.new-chat { + background: #409eff; + color: #fff; + + &:hover { + background: #66b1ff; + } + } + + .nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + margin-bottom: 2px; + border-radius: 6px; + cursor: pointer; + color: #606266; + transition: all 0.2s; + + &:hover { + background: #f5f7fa; + } + + &.active { + background: #ecf5ff; + color: #409eff; + } + + span { + font-size: 14px; + } + } + } + + .sidebar-footer { + margin-top: auto; + padding: 16px; + text-align: center; + font-size: 12px; + color: #c0c4cc; + border-top: 1px solid #e4e7ed; + } + } + + // 主内容区 + .chat-main { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + background: #f5f7fa; + + .chat-header { + padding: 24px 32px; + background: #fff; + border-bottom: 1px solid #e4e7ed; + + h1 { + font-size: 24px; + margin: 0 0 8px 0; + color: #303133; + } + + .subtitle { + font-size: 14px; + color: #909399; + margin: 0; + } + } + + .chat-area { + flex: 1; + background: #fff; + margin: 24px 32px; + border-radius: 8px; + display: flex; + flex-direction: column; + overflow: hidden; + + .ai-info { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 20px; + border-bottom: 1px solid #e4e7ed; + + .ai-avatar { + width: 48px; + height: 48px; + border-radius: 50%; + background: #f0f2f5; + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; + } + + .ai-details { + flex: 1; + } + + .ai-name { + font-size: 16px; + font-weight: 600; + color: #303133; + margin-bottom: 4px; + } + + .ai-status { + font-size: 12px; + color: #67c23a; + display: flex; + align-items: center; + gap: 6px; + + .status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #67c23a; + animation: pulse 2s infinite; + } + } + } + + .messages { + flex: 1; + padding: 20px; + overflow-y: auto; + background: #f5f7fa; + + .message { + display: flex; + margin-bottom: 16px; + + &.user { + justify-content: flex-end; + + .message-content { + background: #409eff; + color: #fff; + } + } + + .message-content { + max-width: 60%; + padding: 12px 16px; + background: #fff; + border-radius: 8px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + } + + .message-text { + line-height: 1.6; + word-wrap: break-word; + } + + .message-time { + font-size: 11px; + color: #c0c4cc; + margin-top: 6px; + } + } + } + + .quick-actions { + display: flex; + gap: 12px; + padding: 12px 20px; + border-top: 1px solid #e4e7ed; + border-bottom: 1px solid #e4e7ed; + background: #fff; + + .quick-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + background: #f5f7fa; + border: 1px solid #e4e7ed; + border-radius: 16px; + font-size: 13px; + color: #606266; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: #ecf5ff; + border-color: #409eff; + color: #409eff; + } + } + } + + .input-area { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 20px; + background: #fff; + + .attach-icon { + font-size: 20px; + color: #909399; + cursor: pointer; + } + } + } + } +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} diff --git a/urbanLifelineWeb/packages/workcase/src/views/public/AIChat/AIChatView.vue b/urbanLifelineWeb/packages/workcase/src/views/public/AIChat/AIChatView.vue new file mode 100644 index 00000000..259f694e --- /dev/null +++ b/urbanLifelineWeb/packages/workcase/src/views/public/AIChat/AIChatView.vue @@ -0,0 +1,193 @@ + + + + + + + + + + + 泰豪小电-对内 + Real-time Support Agent + + + + + + + 🤖 + + 泰豪智能服务助手 + + + 基于 RAG 知识库 · 24小时在线 + + + + + + + + + {{ msg.text }} + {{ msg.time }} + + + + + + + + + + 设备操作手册 + + + + 故障排查指南 + + + + 维保服务规范 + + + + 技术参数查询 + + + + + + + + + + + + + + + + + diff --git a/urbanLifelineWeb/packages/workcase/src/views/public/AIChat/components/ChatHistory.scss b/urbanLifelineWeb/packages/workcase/src/views/public/AIChat/components/ChatHistory.scss new file mode 100644 index 00000000..6ea279b6 --- /dev/null +++ b/urbanLifelineWeb/packages/workcase/src/views/public/AIChat/components/ChatHistory.scss @@ -0,0 +1,61 @@ +.chat-history { + flex: 1; + overflow-y: auto; + padding: 0 8px; + + .history-title { + font-size: 12px; + color: #909399; + padding: 8px 12px; + margin-bottom: 4px; + } + + .history-list { + display: flex; + flex-direction: column; + gap: 2px; + } + + .history-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: 6px; + cursor: pointer; + color: #606266; + transition: all 0.2s; + + &:hover { + background: #f5f7fa; + } + + &.active { + background: #ecf5ff; + color: #409eff; + } + + .el-icon { + font-size: 16px; + flex-shrink: 0; + } + + .history-info { + flex: 1; + min-width: 0; + + .history-name { + font-size: 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .history-time { + font-size: 12px; + color: #909399; + margin-top: 2px; + } + } + } +} diff --git a/urbanLifelineWeb/packages/workcase/src/views/public/AIChat/components/ChatHistory.vue b/urbanLifelineWeb/packages/workcase/src/views/public/AIChat/components/ChatHistory.vue new file mode 100644 index 00000000..2fd8bbf8 --- /dev/null +++ b/urbanLifelineWeb/packages/workcase/src/views/public/AIChat/components/ChatHistory.vue @@ -0,0 +1,51 @@ + + + 历史对话 + + + + + {{ chat.title }} + {{ chat.time }} + + + + + + + + + diff --git a/urbanLifelineWeb/packages/workcase/src/views/public/Login/Login.vue b/urbanLifelineWeb/packages/workcase/src/views/public/Login/Login.vue new file mode 100644 index 00000000..078c2497 --- /dev/null +++ b/urbanLifelineWeb/packages/workcase/src/views/public/Login/Login.vue @@ -0,0 +1,47 @@ + + + + 工单管理系统 + 请先登录主系统 + + 前往主系统登录 + + + + + + + + diff --git a/urbanLifelineWeb/packages/workcase/vite.config.ts b/urbanLifelineWeb/packages/workcase/vite.config.ts index 57a5601f..87aea47c 100644 --- a/urbanLifelineWeb/packages/workcase/vite.config.ts +++ b/urbanLifelineWeb/packages/workcase/vite.config.ts @@ -1,13 +1,17 @@ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import vueJsx from '@vitejs/plugin-vue-jsx' +import { federation } from '@module-federation/vite' import { resolve, dirname } from 'path' import { fileURLToPath } from 'url' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) -export default defineConfig({ +export default defineConfig(({ mode }) => ({ + // 开发和生产环境都通过nginx代理访问/workcase + base: '/workcase/', + plugins: [ vue({ script: { @@ -15,7 +19,23 @@ export default defineConfig({ propsDestructure: true } }), - vueJsx() + vueJsx(), + federation({ + name: 'workcase', + remotes: { + shared: { + type: 'module', + name: 'shared', + entry: 'http://localhost:5000/remoteEntry.js' + } + }, + shared: { + vue: {}, + 'vue-router': {}, + 'element-plus': {}, + axios: {} + } + }) ], define: { @@ -34,6 +54,7 @@ export default defineConfig({ port: 5003, host: true, cors: true, + open: '/workcase/', // 开发时自动打开到 /workcase/ 路径 proxy: { '/api': { target: 'http://localhost:8180', @@ -60,4 +81,4 @@ export default defineConfig({ } } } -}) +})) diff --git a/江西城市生命线-可交互原型/frontend/vite.config.js b/江西城市生命线-可交互原型/frontend/vite.config.js index 0b474e44..15a286ae 100644 --- a/江西城市生命线-可交互原型/frontend/vite.config.js +++ b/江西城市生命线-可交互原型/frontend/vite.config.js @@ -9,6 +9,12 @@ export default defineConfig({ '@': resolve(__dirname, 'src') } }, + define: { + __VUE_OPTIONS_API__: true, + __VUE_PROD_DEVTOOLS__: true, + __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: true + }, + server: { port: 5173, allowedHosts:['.trycloudflare.com','.ngrok-free.app','.cpolar.top','.cpolar.cn'],
无效的 iframe 地址
Real-time Support Agent
请先登录主系统