2373 lines
75 KiB
Markdown
2373 lines
75 KiB
Markdown
# admin-web 补全与登录/租户管理实施计划
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 为 sino-mci 补齐登录鉴权、多租户管理、管理后台列表与绑定、入站流程图、消息测试模块,使管理后台达到可交付状态。
|
||
|
||
**Architecture:** 后端使用 JWT + Spring Interceptor 实现无状态鉴权,前端通过 localStorage 存储 token 并做路由守卫;管理后台各页面通过新增列表接口加载真实数据;入站流程使用 Vue Flow 可视化编排并记录执行轨迹;消息测试模块统一处理出站测试与入站模拟。
|
||
|
||
**Tech Stack:** Java 21, Spring Boot 4.1, JJWT, Vue 3, Element Plus, Vue Flow, MyBatis-Plus, MySQL
|
||
|
||
---
|
||
|
||
## 功能点清单
|
||
|
||
### 后端功能点
|
||
|
||
1. JWT Token 生成与解析
|
||
2. 鉴权拦截器与登录上下文
|
||
3. 登录接口改造(返回 token)
|
||
4. 当前登录用户信息接口
|
||
5. 租户列表接口
|
||
6. 用户列表接口
|
||
7. 租户隔离校验
|
||
8. 渠道主体列表接口
|
||
9. 渠道账号列表接口
|
||
10. 会话列表接口
|
||
11. 联系人列表接口
|
||
12. 发送记录列表接口
|
||
13. Dashboard 统计接口
|
||
14. 入站流程执行轨迹记录与查询
|
||
15. Webhook 测试接收端点
|
||
|
||
### 前端功能点
|
||
|
||
1. 登录页
|
||
2. 前端路由守卫
|
||
3. API Token 注入
|
||
4. 租户管理页
|
||
5. 用户管理页
|
||
6. Dashboard 真实统计
|
||
7. 渠道主体页补充字段与列表
|
||
8. 渠道账号页补充字段与列表
|
||
9. 会话/群聊页绑定账号
|
||
10. 联系人页列表
|
||
11. 发送记录页 UX 优化
|
||
12. 标签层级展示
|
||
13. 入站流程可视化编辑器
|
||
14. 入站流程执行追踪
|
||
15. 消息测试模块(出站 + 入站)
|
||
|
||
---
|
||
|
||
## 文件结构
|
||
|
||
### 后端新增/修改文件
|
||
|
||
| 文件 | 职责 |
|
||
|---|---|
|
||
| `backend/mci-server/pom.xml` | 添加 jjwt 依赖 |
|
||
| `backend/mci-server/src/main/java/com/sino/mci/admin/security/JwtTokenProvider.java` | JWT 生成与解析 |
|
||
| `backend/mci-server/src/main/java/com/sino/mci/admin/security/AuthContext.java` | 当前登录用户 ThreadLocal |
|
||
| `backend/mci-server/src/main/java/com/sino/mci/admin/security/AuthInterceptor.java` | 鉴权拦截器 |
|
||
| `backend/mci-server/src/main/java/com/sino/mci/config/WebConfig.java` | 注册拦截器 |
|
||
| `backend/mci-server/src/main/java/com/sino/mci/admin/dto/LoginResponse.java` | 改造为包含 token |
|
||
| `backend/mci-server/src/main/java/com/sino/mci/admin/controller/AccountController.java` | 新增 me/tenant list/user list 接口,改造 login |
|
||
| `backend/mci-server/src/main/java/com/sino/mci/admin/service/AccountService.java` | 新增 list 方法 |
|
||
| `backend/mci-server/src/main/java/com/sino/mci/channel/controller/ChannelSubjectController.java` | 新增 list 接口 |
|
||
| `backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelSubjectService.java` | 新增 list 方法 |
|
||
| `backend/mci-server/src/main/java/com/sino/mci/channel/controller/ChannelAccountController.java` | 新增 list 接口 |
|
||
| `backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelAccountService.java` | 新增 list 方法 |
|
||
| `backend/mci-server/src/main/java/com/sino/mci/channel/controller/ConversationController.java` | 新增 list 接口 |
|
||
| `backend/mci-server/src/main/java/com/sino/mci/channel/service/ConversationService.java` | 新增 list 方法 |
|
||
| `backend/mci-server/src/main/java/com/sino/mci/crm/controller/PersonController.java` | 新增 list 接口 |
|
||
| `backend/mci-server/src/main/java/com/sino/mci/crm/service/PersonService.java` | 新增 list 方法 |
|
||
| `backend/mci-server/src/main/java/com/sino/mci/send/controller/SendController.java` | 新增 list 接口 |
|
||
| `backend/mci-server/src/main/java/com/sino/mci/send/service/SendService.java` | 新增 list 方法 |
|
||
| `backend/mci-server/src/main/java/com/sino/mci/admin/controller/DashboardController.java` | Dashboard 统计 |
|
||
| `backend/mci-server/src/main/java/com/sino/mci/inbound/entity/FlowExecutionTrace.java` | 执行轨迹实体 |
|
||
| `backend/mci-server/src/main/java/com/sino/mci/inbound/repository/FlowExecutionTraceMapper.java` | 轨迹 Mapper |
|
||
| `backend/mci-server/src/main/java/com/sino/mci/inbound/controller/InboundMessageController.java` | 新增 trace 接口 |
|
||
| `backend/mci-server/src/main/java/com/sino/mci/inbound/engine/FlowEngine.java` | 记录执行轨迹 |
|
||
| `backend/mci-server/src/main/resources/db/migration/V8__add_flow_trace.sql` | 轨迹表迁移 |
|
||
| `backend/mci-server/src/main/resources/db/migration/V9__add_user_role_platform_admin.sql` | 平台管理员角色 |
|
||
|
||
### 前端新增/修改文件
|
||
|
||
| 文件 | 职责 |
|
||
|---|---|
|
||
| `admin-web/src/api/client.ts` | 注入 Authorization token |
|
||
| `admin-web/src/api/account.ts` | 登录/租户/用户 API |
|
||
| `admin-web/src/api/channel.ts` | 列表 API |
|
||
| `admin-web/src/api/send.ts` | 发送列表 API |
|
||
| `admin-web/src/api/dashboard.ts` | 统计 API |
|
||
| `admin-web/src/api/inbound.ts` | 入站/轨迹 API |
|
||
| `admin-web/src/stores/auth.ts` | Pinia 登录状态 |
|
||
| `admin-web/src/views/Login.vue` | 登录页 |
|
||
| `admin-web/src/views/TenantManagement.vue` | 租户管理 |
|
||
| `admin-web/src/views/UserManagement.vue` | 用户管理 |
|
||
| `admin-web/src/views/Dashboard.vue` | 真实统计 |
|
||
| `admin-web/src/views/ChannelSubject.vue` | 列表 + 补充字段 |
|
||
| `admin-web/src/views/ChannelAccount.vue` | 列表 + 补充字段 |
|
||
| `admin-web/src/views/Conversation.vue` | 列表 + 绑定账号 |
|
||
| `admin-web/src/views/Person.vue` | 列表 |
|
||
| `admin-web/src/views/SendRecord.vue` | UX 优化 |
|
||
| `admin-web/src/views/Tag.vue` | 层级展示 |
|
||
| `admin-web/src/views/InboundFlow.vue` | Vue Flow 编辑器 |
|
||
| `admin-web/src/views/FlowTrace.vue` | 流程追踪 |
|
||
| `admin-web/src/views/MessageTest.vue` | 消息测试 |
|
||
| `admin-web/src/router/index.ts` | 登录路由 + 守卫 |
|
||
| `admin-web/src/components/Layout.vue` | 顶部显示当前用户/退出 |
|
||
| `admin-web/package.json` | 添加 @vue-flow/core、pinia |
|
||
|
||
---
|
||
|
||
## Task 1: 后端 Sa-Token 与鉴权基础设施
|
||
|
||
**Files:**
|
||
- Modify: `backend/mci-server/pom.xml`
|
||
- Create: `backend/mci-server/src/main/java/com/sino/mci/admin/security/AuthContext.java`
|
||
- Create: `backend/mci-server/src/main/java/com/sino/mci/admin/security/AuthInterceptor.java`
|
||
- Create: `backend/mci-server/src/main/java/com/sino/mci/config/WebConfig.java`
|
||
- Modify: `backend/mci-server/src/main/resources/application.yml`
|
||
|
||
- [ ] **Step 1: 添加 Sa-Token 依赖**
|
||
|
||
修改 `backend/mci-server/pom.xml` 的 `<dependencies>` 节点,添加:
|
||
|
||
```xml
|
||
<dependency>
|
||
<groupId>cn.dev33</groupId>
|
||
<artifactId>sa-token-spring-boot3-starter</artifactId>
|
||
<version>1.39.0</version>
|
||
</dependency>
|
||
```
|
||
|
||
- [ ] **Step 2: 创建 AuthContext**
|
||
|
||
基于 Sa-Token `StpUtil` 创建 `backend/mci-server/src/main/java/com/sino/mci/admin/security/AuthContext.java`:
|
||
|
||
```java
|
||
package com.sino.mci.admin.security;
|
||
|
||
import cn.dev33.satoken.stp.StpUtil;
|
||
import lombok.Data;
|
||
|
||
import java.util.List;
|
||
|
||
public class AuthContext {
|
||
|
||
@Data
|
||
public static class AuthUser {
|
||
private Long userId;
|
||
private String tenantCode;
|
||
private String username;
|
||
private List<String> roleCodes;
|
||
}
|
||
|
||
public static AuthUser get() {
|
||
if (!StpUtil.isLogin()) {
|
||
return null;
|
||
}
|
||
AuthUser user = new AuthUser();
|
||
user.setUserId(StpUtil.getLoginIdAsLong());
|
||
user.setTenantCode((String) StpUtil.getSession().get("tenantCode"));
|
||
user.setUsername((String) StpUtil.getSession().get("username"));
|
||
user.setRoleCodes((List<String>) StpUtil.getSession().get("roleCodes"));
|
||
return user;
|
||
}
|
||
|
||
public static String currentTenantCode() {
|
||
AuthUser user = get();
|
||
return user == null ? null : user.getTenantCode();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 创建 AuthInterceptor**
|
||
|
||
创建 `backend/mci-server/src/main/java/com/sino/mci/admin/security/AuthInterceptor.java`,使用 Sa-Token 校验登录:
|
||
|
||
```java
|
||
package com.sino.mci.admin.security;
|
||
|
||
import cn.dev33.satoken.exception.SaTokenException;
|
||
import cn.dev33.satoken.stp.StpUtil;
|
||
import jakarta.servlet.http.HttpServletRequest;
|
||
import jakarta.servlet.http.HttpServletResponse;
|
||
import org.springframework.stereotype.Component;
|
||
import org.springframework.web.servlet.HandlerInterceptor;
|
||
|
||
@Component
|
||
public class AuthInterceptor implements HandlerInterceptor {
|
||
|
||
@Override
|
||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||
String path = request.getRequestURI();
|
||
if (path.contains("/api/v1/account/login")) {
|
||
return true;
|
||
}
|
||
|
||
try {
|
||
StpUtil.checkLogin();
|
||
} catch (SaTokenException e) {
|
||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 注册拦截器并配置 Sa-Token**
|
||
|
||
创建 `backend/mci-server/src/main/java/com/sino/mci/config/WebConfig.java`:
|
||
|
||
```java
|
||
package com.sino.mci.config;
|
||
|
||
import com.sino.mci.admin.security.AuthInterceptor;
|
||
import lombok.RequiredArgsConstructor;
|
||
import org.springframework.context.annotation.Configuration;
|
||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||
|
||
@Configuration
|
||
@RequiredArgsConstructor
|
||
public class WebConfig implements WebMvcConfigurer {
|
||
|
||
private final AuthInterceptor authInterceptor;
|
||
|
||
@Override
|
||
public void addInterceptors(InterceptorRegistry registry) {
|
||
registry.addInterceptor(authInterceptor)
|
||
.addPathPatterns("/msg-platform/api/v1/**", "/api/v1/**")
|
||
.excludePathPatterns("/msg-platform/api/v1/account/login", "/api/v1/account/login");
|
||
}
|
||
}
|
||
```
|
||
|
||
在 `backend/mci-server/src/main/resources/application.yml` 中添加 Sa-Token 基础配置:
|
||
|
||
```yaml
|
||
sa-token:
|
||
token-name: Authorization
|
||
token-prefix: Bearer
|
||
timeout: 86400
|
||
active-timeout: -1
|
||
is-concurrent: true
|
||
is-share: true
|
||
token-style: uuid
|
||
is-log: false
|
||
```
|
||
|
||
- [ ] **Step 5: 编译验证**
|
||
|
||
Run:
|
||
```bash
|
||
cd /Users/marsal/Projects/sino-project/sino-mci/backend
|
||
JAVA_HOME=/opt/homebrew/opt/openjdk@21 mvn -f mci-server/pom.xml compile -DskipTests
|
||
```
|
||
|
||
Expected: BUILD SUCCESS
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
cd /Users/marsal/Projects/sino-project/sino-mci
|
||
git add backend/mci-server/pom.xml backend/mci-server/src/main/java/com/sino/mci/admin/security/ backend/mci-server/src/main/java/com/sino/mci/config/WebConfig.java backend/mci-server/src/main/resources/application.yml
|
||
git commit -m "feat(auth): add Sa-Token auth context and interceptor"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: 后端登录与账号管理 API 改造
|
||
|
||
**Files:**
|
||
- Modify: `backend/mci-server/src/main/java/com/sino/mci/admin/dto/LoginResponse.java`
|
||
- Create: `backend/mci-server/src/main/java/com/sino/mci/admin/dto/TokenResponse.java`
|
||
- Modify: `backend/mci-server/src/main/java/com/sino/mci/admin/service/AccountService.java`
|
||
- Modify: `backend/mci-server/src/main/java/com/sino/mci/admin/controller/AccountController.java`
|
||
- Create: `backend/mci-server/src/main/resources/db/migration/V9__add_user_role_platform_admin.sql`
|
||
|
||
- [ ] **Step 1: 创建 TokenResponse**
|
||
|
||
创建 `backend/mci-server/src/main/java/com/sino/mci/admin/dto/TokenResponse.java`:
|
||
|
||
```java
|
||
package com.sino.mci.admin.dto;
|
||
|
||
import lombok.AllArgsConstructor;
|
||
import lombok.Data;
|
||
import lombok.NoArgsConstructor;
|
||
|
||
@Data
|
||
@NoArgsConstructor
|
||
@AllArgsConstructor
|
||
public class TokenResponse {
|
||
private String token;
|
||
private LoginResponse userInfo;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 改造 AccountService.login 返回 TokenResponse**
|
||
|
||
修改 `backend/mci-server/src/main/java/com/sino/mci/admin/service/AccountService.java`:
|
||
|
||
引入 Sa-Token:
|
||
|
||
```java
|
||
import cn.dev33.satoken.stp.StpUtil;
|
||
```
|
||
|
||
改造 `login` 方法:
|
||
|
||
```java
|
||
public TokenResponse login(String tenantCode, String username, String password) {
|
||
PlatformUser user = userMapper.selectOne(
|
||
new LambdaQueryWrapper<PlatformUser>()
|
||
.eq(PlatformUser::getTenantCode, tenantCode)
|
||
.eq(PlatformUser::getUsername, username)
|
||
.eq(PlatformUser::getStatus, 1));
|
||
if (user == null || !BCrypt.checkpw(password, user.getPassword())) {
|
||
throw new UnauthorizedException("用户名或密码错误");
|
||
}
|
||
|
||
LoginResponse userInfo = new LoginResponse();
|
||
userInfo.setId(user.getId());
|
||
userInfo.setTenantCode(user.getTenantCode());
|
||
userInfo.setUsername(user.getUsername());
|
||
userInfo.setRealName(user.getRealName());
|
||
userInfo.setPhone(user.getPhone());
|
||
userInfo.setRoleCodes(loadRoleCodes(user.getId()));
|
||
|
||
StpUtil.login(user.getId());
|
||
StpUtil.getSession().set("tenantCode", user.getTenantCode());
|
||
StpUtil.getSession().set("username", user.getUsername());
|
||
StpUtil.getSession().set("roleCodes", userInfo.getRoleCodes());
|
||
|
||
String token = StpUtil.getTokenValue();
|
||
return new TokenResponse(token, userInfo);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: 新增 list 方法到 AccountService**
|
||
|
||
在 `AccountService.java` 末尾添加:
|
||
|
||
```java
|
||
public List<Tenant> listTenants() {
|
||
return tenantMapper.selectList(new LambdaQueryWrapper<Tenant>().eq(Tenant::getStatus, 1));
|
||
}
|
||
|
||
public List<PlatformUser> listUsers(String tenantCode) {
|
||
return userMapper.selectList(
|
||
new LambdaQueryWrapper<PlatformUser>()
|
||
.eq(PlatformUser::getTenantCode, tenantCode)
|
||
.eq(PlatformUser::getStatus, 1));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 改造 AccountController**
|
||
|
||
修改 `backend/mci-server/src/main/java/com/sino/mci/admin/controller/AccountController.java`:
|
||
|
||
```java
|
||
package com.sino.mci.admin.controller;
|
||
|
||
import com.sino.mci.admin.dto.*;
|
||
import com.sino.mci.admin.entity.Tenant;
|
||
import com.sino.mci.admin.security.AuthContext;
|
||
import com.sino.mci.admin.service.AccountService;
|
||
import com.sino.mci.shared.web.ApiResponse;
|
||
import jakarta.validation.Valid;
|
||
import lombok.RequiredArgsConstructor;
|
||
import org.springframework.web.bind.annotation.*;
|
||
|
||
import java.util.List;
|
||
|
||
@RestController
|
||
@RequestMapping("/api/v1/account")
|
||
@RequiredArgsConstructor
|
||
public class AccountController {
|
||
|
||
private final AccountService accountService;
|
||
|
||
@PostMapping("/tenant")
|
||
public ApiResponse<Tenant> createTenant(@Valid @RequestBody CreateTenantRequest request) {
|
||
return ApiResponse.ok(accountService.createTenant(request));
|
||
}
|
||
|
||
@GetMapping("/tenant/list")
|
||
public ApiResponse<List<Tenant>> listTenants() {
|
||
return ApiResponse.ok(accountService.listTenants());
|
||
}
|
||
|
||
@PostMapping("/user")
|
||
public ApiResponse<UserResponse> createUser(
|
||
@RequestHeader("X-Tenant-Code") String tenantCode,
|
||
@Valid @RequestBody CreateUserRequest request) {
|
||
return ApiResponse.ok(accountService.createUser(tenantCode, request));
|
||
}
|
||
|
||
@GetMapping("/user/list")
|
||
public ApiResponse<List<PlatformUserResponse>> listUsers() {
|
||
return ApiResponse.ok(accountService.listUsers(AuthContext.currentTenantCode()));
|
||
}
|
||
|
||
@PostMapping("/login")
|
||
public ApiResponse<TokenResponse> login(
|
||
@RequestHeader("X-Tenant-Code") String tenantCode,
|
||
@Valid @RequestBody LoginRequest request) {
|
||
return ApiResponse.ok(accountService.login(tenantCode, request.getUsername(), request.getPassword()));
|
||
}
|
||
|
||
@GetMapping("/me")
|
||
public ApiResponse<AuthContext.AuthUser> me() {
|
||
return ApiResponse.ok(AuthContext.get());
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: 新增 PlatformUserResponse**
|
||
|
||
创建 `backend/mci-server/src/main/java/com/sino/mci/admin/dto/PlatformUserResponse.java`:
|
||
|
||
```java
|
||
package com.sino.mci.admin.dto;
|
||
|
||
import lombok.Data;
|
||
|
||
import java.time.LocalDateTime;
|
||
import java.util.List;
|
||
|
||
@Data
|
||
public class PlatformUserResponse {
|
||
private Long id;
|
||
private String tenantCode;
|
||
private String username;
|
||
private String realName;
|
||
private String phone;
|
||
private Integer status;
|
||
private List<String> roleCodes;
|
||
private LocalDateTime createTime;
|
||
}
|
||
```
|
||
|
||
并调整 `AccountService.listUsers` 返回 `List<PlatformUserResponse>`,使用 `toUserResponse` 转换。
|
||
|
||
- [ ] **Step 6: 添加平台管理员角色种子数据**
|
||
|
||
创建 `backend/mci-server/src/main/resources/db/migration/V9__add_user_role_platform_admin.sql`:
|
||
|
||
```sql
|
||
INSERT IGNORE INTO mci_platform_role (role_code, role_name, permissions) VALUES
|
||
('platform_admin', '平台管理员', '["tenant:manage", "user:manage", "*"]');
|
||
```
|
||
|
||
- [ ] **Step 7: 运行测试**
|
||
|
||
Run:
|
||
```bash
|
||
cd /Users/marsal/Projects/sino-project/sino-mci/backend/mci-server
|
||
JAVA_HOME=/opt/homebrew/opt/openjdk@21 mvn test
|
||
```
|
||
|
||
Expected: 7 tests pass
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
cd /Users/marsal/Projects/sino-project/sino-mci
|
||
git add backend/mci-server/src/main/java/com/sino/mci/admin/ backend/mci-server/src/main/resources/db/migration/V9__add_user_role_platform_admin.sql
|
||
git commit -m "feat(auth): return JWT on login and add tenant/user list APIs"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: 后端列表接口与 Dashboard 统计
|
||
|
||
**Files:**
|
||
- Modify: `backend/mci-server/src/main/java/com/sino/mci/channel/controller/ChannelSubjectController.java`
|
||
- Modify: `backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelSubjectService.java`
|
||
- Modify: `backend/mci-server/src/main/java/com/sino/mci/channel/controller/ChannelAccountController.java`
|
||
- Modify: `backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelAccountService.java`
|
||
- Modify: `backend/mci-server/src/main/java/com/sino/mci/channel/controller/ConversationController.java`
|
||
- Modify: `backend/mci-server/src/main/java/com/sino/mci/channel/service/ConversationService.java`
|
||
- Modify: `backend/mci-server/src/main/java/com/sino/mci/crm/controller/PersonController.java`
|
||
- Modify: `backend/mci-server/src/main/java/com/sino/mci/crm/service/PersonService.java`
|
||
- Modify: `backend/mci-server/src/main/java/com/sino/mci/send/controller/SendController.java`
|
||
- Modify: `backend/mci-server/src/main/java/com/sino/mci/send/service/SendService.java`
|
||
- Create: `backend/mci-server/src/main/java/com/sino/mci/admin/controller/DashboardController.java`
|
||
- Create: `backend/mci-server/src/main/java/com/sino/mci/admin/dto/DashboardStatsResponse.java`
|
||
|
||
- [ ] **Step 1: ChannelSubject 列表**
|
||
|
||
修改 `ChannelSubjectController.java`:
|
||
|
||
```java
|
||
@GetMapping("/list")
|
||
public ApiResponse<List<ChannelSubject>> listSubjects(
|
||
@RequestHeader("X-Tenant-Code") String tenantCode) {
|
||
return ApiResponse.ok(subjectService.listSubjects(tenantCode));
|
||
}
|
||
```
|
||
|
||
修改 `ChannelSubjectService.java`:
|
||
|
||
```java
|
||
public List<ChannelSubject> listSubjects(String tenantCode) {
|
||
return subjectMapper.selectList(
|
||
new LambdaQueryWrapper<ChannelSubject>()
|
||
.eq(ChannelSubject::getTenantCode, tenantCode)
|
||
.eq(ChannelSubject::getStatus, 1)
|
||
.orderByDesc(ChannelSubject::getId));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: ChannelAccount 列表**
|
||
|
||
修改 `ChannelAccountController.java`:
|
||
|
||
```java
|
||
@GetMapping("/list")
|
||
public ApiResponse<List<ChannelAccount>> listAccounts(
|
||
@RequestHeader("X-Tenant-Code") String tenantCode) {
|
||
return ApiResponse.ok(accountService.listAccounts(tenantCode));
|
||
}
|
||
```
|
||
|
||
修改 `ChannelAccountService.java`:
|
||
|
||
```java
|
||
public List<ChannelAccount> listAccounts(String tenantCode) {
|
||
return accountMapper.selectList(
|
||
new LambdaQueryWrapper<ChannelAccount>()
|
||
.eq(ChannelAccount::getTenantCode, tenantCode)
|
||
.eq(ChannelAccount::getStatus, 1)
|
||
.orderByDesc(ChannelAccount::getId));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Conversation 列表**
|
||
|
||
修改 `ConversationController.java`:
|
||
|
||
```java
|
||
@GetMapping("/list")
|
||
public ApiResponse<List<Conversation>> listConversations(
|
||
@RequestHeader("X-Tenant-Code") String tenantCode) {
|
||
return ApiResponse.ok(conversationService.listConversations(tenantCode));
|
||
}
|
||
```
|
||
|
||
修改 `ConversationService.java`:
|
||
|
||
```java
|
||
public List<Conversation> listConversations(String tenantCode) {
|
||
return conversationMapper.selectList(
|
||
new LambdaQueryWrapper<Conversation>()
|
||
.eq(Conversation::getTenantCode, tenantCode)
|
||
.eq(Conversation::getStatus, 1)
|
||
.orderByDesc(Conversation::getId));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Person 列表**
|
||
|
||
修改 `PersonController.java`:
|
||
|
||
```java
|
||
@GetMapping("/list")
|
||
public ApiResponse<List<Person>> listPersons(
|
||
@RequestHeader("X-Tenant-Code") String tenantCode) {
|
||
return ApiResponse.ok(personService.listPersons(tenantCode));
|
||
}
|
||
```
|
||
|
||
修改 `PersonService.java`:
|
||
|
||
```java
|
||
public List<Person> listPersons(String tenantCode) {
|
||
return personMapper.selectList(
|
||
new LambdaQueryWrapper<Person>()
|
||
.eq(Person::getTenantCode, tenantCode)
|
||
.eq(Person::getStatus, 1)
|
||
.orderByDesc(Person::getId));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: SendRecord 列表**
|
||
|
||
修改 `SendController.java`:
|
||
|
||
```java
|
||
@GetMapping("/list")
|
||
public ApiResponse<List<SendRecordResponse>> listSendRecords(
|
||
@RequestHeader("X-Tenant-Code") String tenantCode) {
|
||
return ApiResponse.ok(sendService.listRecentRecords(tenantCode));
|
||
}
|
||
```
|
||
|
||
修改 `SendService.java`:
|
||
|
||
```java
|
||
public List<SendRecordResponse> listRecentRecords(String tenantCode) {
|
||
List<SendRecord> records = sendRecordMapper.selectList(
|
||
new LambdaQueryWrapper<SendRecord>()
|
||
.eq(SendRecord::getTenantCode, tenantCode)
|
||
.orderByDesc(SendRecord::getId)
|
||
.last("LIMIT 50"));
|
||
return records.stream().map(this::toResponse).collect(Collectors.toList());
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Dashboard 统计**
|
||
|
||
创建 `backend/mci-server/src/main/java/com/sino/mci/admin/dto/DashboardStatsResponse.java`:
|
||
|
||
```java
|
||
package com.sino.mci.admin.dto;
|
||
|
||
import lombok.AllArgsConstructor;
|
||
import lombok.Data;
|
||
import lombok.NoArgsConstructor;
|
||
|
||
@Data
|
||
@NoArgsConstructor
|
||
@AllArgsConstructor
|
||
public class DashboardStatsResponse {
|
||
private long subjectCount;
|
||
private long accountCount;
|
||
private long conversationCount;
|
||
private long sentToday;
|
||
}
|
||
```
|
||
|
||
创建 `backend/mci-server/src/main/java/com/sino/mci/admin/controller/DashboardController.java`:
|
||
|
||
```java
|
||
package com.sino.mci.admin.controller;
|
||
|
||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||
import com.sino.mci.admin.dto.DashboardStatsResponse;
|
||
import com.sino.mci.admin.security.AuthContext;
|
||
import com.sino.mci.channel.entity.ChannelAccount;
|
||
import com.sino.mci.channel.entity.ChannelSubject;
|
||
import com.sino.mci.channel.entity.Conversation;
|
||
import com.sino.mci.channel.repository.ChannelAccountMapper;
|
||
import com.sino.mci.channel.repository.ChannelSubjectMapper;
|
||
import com.sino.mci.channel.repository.ConversationMapper;
|
||
import com.sino.mci.send.entity.SendRecord;
|
||
import com.sino.mci.send.repository.SendRecordMapper;
|
||
import com.sino.mci.shared.web.ApiResponse;
|
||
import lombok.RequiredArgsConstructor;
|
||
import org.springframework.web.bind.annotation.GetMapping;
|
||
import org.springframework.web.bind.annotation.RequestMapping;
|
||
import org.springframework.web.bind.annotation.RestController;
|
||
|
||
import java.time.LocalDate;
|
||
import java.time.LocalDateTime;
|
||
import java.time.LocalTime;
|
||
|
||
@RestController
|
||
@RequestMapping("/api/v1/dashboard")
|
||
@RequiredArgsConstructor
|
||
public class DashboardController {
|
||
|
||
private final ChannelSubjectMapper subjectMapper;
|
||
private final ChannelAccountMapper accountMapper;
|
||
private final ConversationMapper conversationMapper;
|
||
private final SendRecordMapper sendRecordMapper;
|
||
|
||
@GetMapping("/stats")
|
||
public ApiResponse<DashboardStatsResponse> stats() {
|
||
String tenantCode = AuthContext.currentTenantCode();
|
||
LocalDateTime startOfDay = LocalDateTime.of(LocalDate.now(), LocalTime.MIN);
|
||
LocalDateTime endOfDay = LocalDateTime.of(LocalDate.now(), LocalTime.MAX);
|
||
|
||
long subjects = subjectMapper.selectCount(
|
||
new LambdaQueryWrapper<ChannelSubject>()
|
||
.eq(ChannelSubject::getTenantCode, tenantCode)
|
||
.eq(ChannelSubject::getStatus, 1));
|
||
long accounts = accountMapper.selectCount(
|
||
new LambdaQueryWrapper<ChannelAccount>()
|
||
.eq(ChannelAccount::getTenantCode, tenantCode)
|
||
.eq(ChannelAccount::getStatus, 1));
|
||
long conversations = conversationMapper.selectCount(
|
||
new LambdaQueryWrapper<Conversation>()
|
||
.eq(Conversation::getTenantCode, tenantCode)
|
||
.eq(Conversation::getStatus, 1));
|
||
long sentToday = sendRecordMapper.selectCount(
|
||
new LambdaQueryWrapper<SendRecord>()
|
||
.eq(SendRecord::getTenantCode, tenantCode)
|
||
.between(SendRecord::getSendTime, startOfDay, endOfDay));
|
||
|
||
return ApiResponse.ok(new DashboardStatsResponse(subjects, accounts, conversations, sentToday));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: 运行测试**
|
||
|
||
Run:
|
||
```bash
|
||
cd /Users/marsal/Projects/sino-project/sino-mci/backend/mci-server
|
||
JAVA_HOME=/opt/homebrew/opt/openjdk@21 mvn test
|
||
```
|
||
|
||
Expected: 7 tests pass
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
cd /Users/marsal/Projects/sino-project/sino-mci
|
||
git add backend/mci-server/src/main/java/com/sino/mci/
|
||
git commit -m "feat(api): add list endpoints and dashboard stats"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: 入站流程执行轨迹
|
||
|
||
**Files:**
|
||
- Create: `backend/mci-server/src/main/resources/db/migration/V8__add_flow_trace.sql`
|
||
- Create: `backend/mci-server/src/main/java/com/sino/mci/inbound/entity/FlowExecutionTrace.java`
|
||
- Create: `backend/mci-server/src/main/java/com/sino/mci/inbound/repository/FlowExecutionTraceMapper.java`
|
||
- Modify: `backend/mci-server/src/main/java/com/sino/mci/inbound/engine/FlowEngine.java`
|
||
- Modify: `backend/mci-server/src/main/java/com/sino/mci/inbound/controller/InboundMessageController.java`
|
||
|
||
- [ ] **Step 1: 创建轨迹表迁移**
|
||
|
||
创建 `backend/mci-server/src/main/resources/db/migration/V8__add_flow_trace.sql`:
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS mci_flow_execution_trace (
|
||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||
event_id VARCHAR(128) NOT NULL COMMENT '入站事件ID',
|
||
flow_definition_id BIGINT COMMENT '流程定义ID',
|
||
node_type VARCHAR(64) NOT NULL COMMENT '节点类型',
|
||
node_config JSON COMMENT '节点配置快照',
|
||
input TEXT COMMENT '节点输入',
|
||
output TEXT COMMENT '节点输出',
|
||
status TINYINT NOT NULL DEFAULT 1 COMMENT '1成功 2失败',
|
||
duration_ms INT COMMENT '执行耗时毫秒',
|
||
error_msg VARCHAR(512) COMMENT '错误信息',
|
||
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
KEY idx_event_id (event_id)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='流程执行轨迹表';
|
||
```
|
||
|
||
- [ ] **Step 2: 创建实体与 Mapper**
|
||
|
||
创建 `FlowExecutionTrace.java`:
|
||
|
||
```java
|
||
package com.sino.mci.inbound.entity;
|
||
|
||
import com.baomidou.mybatisplus.annotation.TableName;
|
||
import lombok.Data;
|
||
|
||
import java.time.LocalDateTime;
|
||
|
||
@Data
|
||
@TableName("mci_flow_execution_trace")
|
||
public class FlowExecutionTrace {
|
||
private Long id;
|
||
private String eventId;
|
||
private Long flowDefinitionId;
|
||
private String nodeType;
|
||
private String nodeConfig;
|
||
private String input;
|
||
private String output;
|
||
private Integer status;
|
||
private Integer durationMs;
|
||
private String errorMsg;
|
||
private LocalDateTime createTime;
|
||
}
|
||
```
|
||
|
||
创建 `FlowExecutionTraceMapper.java`:
|
||
|
||
```java
|
||
package com.sino.mci.inbound.repository;
|
||
|
||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||
import com.sino.mci.inbound.entity.FlowExecutionTrace;
|
||
import org.apache.ibatis.annotations.Mapper;
|
||
|
||
@Mapper
|
||
public interface FlowExecutionTraceMapper extends BaseMapper<FlowExecutionTrace> {
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: FlowEngine 记录轨迹**
|
||
|
||
修改 `FlowEngine.java`,注入 `FlowExecutionTraceMapper`。
|
||
|
||
当前 `FlowEngine.executeInboundFlow` 实际执行固定三步:意图识别、Webhook 推送、标记已处理。轨迹按以下固定节点记录:
|
||
|
||
```java
|
||
private final FlowExecutionTraceMapper traceMapper;
|
||
|
||
private void trace(String eventId, Long flowDefinitionId, String nodeType,
|
||
String input, String output, int status, long durationMs, String errorMsg) {
|
||
FlowExecutionTrace trace = new FlowExecutionTrace();
|
||
trace.setEventId(eventId);
|
||
trace.setFlowDefinitionId(flowDefinitionId);
|
||
trace.setNodeType(nodeType);
|
||
trace.setInput(input);
|
||
trace.setOutput(output);
|
||
trace.setStatus(status);
|
||
trace.setDurationMs((int) durationMs);
|
||
trace.setErrorMsg(errorMsg);
|
||
traceMapper.insert(trace);
|
||
}
|
||
```
|
||
|
||
改造 `executeInboundFlow`:
|
||
|
||
```java
|
||
public void executeInboundFlow(Long eventId) {
|
||
MessageEvent event = messageEventMapper.selectById(eventId);
|
||
if (event == null) {
|
||
log.warn("Message event not found: {}", eventId);
|
||
return;
|
||
}
|
||
|
||
FlowDefinition flow = findPublishedFlow(event.getTenantCode());
|
||
Long flowDefinitionId = flow == null ? null : flow.getId();
|
||
long flowStart = System.currentTimeMillis();
|
||
trace(event.getEventId(), flowDefinitionId, "flow_start", null,
|
||
"tenant=" + event.getTenantCode(), 1, 0, null);
|
||
|
||
if (flow == null) {
|
||
log.info("No published inbound flow for tenant {}, skipping flow execution", event.getTenantCode());
|
||
markProcessed(event, flowDefinitionId);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
long intentStart = System.currentTimeMillis();
|
||
IntentRecognitionService.IntentResult intent = intentRecognitionService.recognize(event);
|
||
trace(event.getEventId(), flowDefinitionId, "intent_recognition",
|
||
event.getContent(), JsonUtils.toJson(intent), 1,
|
||
System.currentTimeMillis() - intentStart, null);
|
||
|
||
long webhookStart = System.currentTimeMillis();
|
||
webhookPushService.pushToBusiness(event.getTenantCode(), event, intent);
|
||
trace(event.getEventId(), flowDefinitionId, "webhook_push",
|
||
JsonUtils.toJson(intent), "pushed", 1,
|
||
System.currentTimeMillis() - webhookStart, null);
|
||
|
||
markProcessed(event, flowDefinitionId);
|
||
} catch (Exception e) {
|
||
log.error("Flow execution failed for event {}", eventId, e);
|
||
trace(event.getEventId(), flowDefinitionId, "webhook_push",
|
||
event.getContent(), null, 2, System.currentTimeMillis() - flowStart,
|
||
e.getMessage());
|
||
}
|
||
}
|
||
|
||
private void markProcessed(MessageEvent event, Long flowDefinitionId) {
|
||
event.setIsProcessed(1);
|
||
messageEventMapper.updateById(event);
|
||
trace(event.getEventId(), flowDefinitionId, "mark_processed", null, "done", 1, 0, null);
|
||
}
|
||
```
|
||
|
||
同时在 `InboundMessageService.receiveMessage` 保存消息后、调用 `FlowEngine` 前记录 `receive` 节点:
|
||
|
||
```java
|
||
private final FlowExecutionTraceMapper traceMapper;
|
||
|
||
// 在 messageEventMapper.insert(event); 之后
|
||
FlowExecutionTrace receiveTrace = new FlowExecutionTrace();
|
||
receiveTrace.setEventId(event.getEventId());
|
||
receiveTrace.setNodeType("receive");
|
||
receiveTrace.setInput(JsonUtils.toJson(request));
|
||
receiveTrace.setOutput("event_id=" + event.getId());
|
||
receiveTrace.setStatus(1);
|
||
traceMapper.insert(receiveTrace);
|
||
```
|
||
|
||
这样单条事件轨迹为:`receive -> flow_start -> intent_recognition -> webhook_push -> mark_processed`。
|
||
|
||
- [ ] **Step 4: 新增 trace 查询接口**
|
||
|
||
修改 `InboundMessageController.java`:
|
||
|
||
```java
|
||
@GetMapping("/{eventId}/trace")
|
||
public ApiResponse<List<FlowExecutionTrace>> trace(@PathVariable String eventId) {
|
||
return ApiResponse.ok(inboundMessageService.listTraces(eventId));
|
||
}
|
||
```
|
||
|
||
在 `InboundMessageService.java` 新增:
|
||
|
||
```java
|
||
public List<FlowExecutionTrace> listTraces(String eventId) {
|
||
return traceMapper.selectList(
|
||
new LambdaQueryWrapper<FlowExecutionTrace>()
|
||
.eq(FlowExecutionTrace::getEventId, eventId)
|
||
.orderByAsc(FlowExecutionTrace::getId));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: 运行测试**
|
||
|
||
Run:
|
||
```bash
|
||
cd /Users/marsal/Projects/sino-project/sino-mci/backend/mci-server
|
||
JAVA_HOME=/opt/homebrew/opt/openjdk@21 mvn test
|
||
```
|
||
|
||
Expected: 7 tests pass
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
cd /Users/marsal/Projects/sino-project/sino-mci
|
||
git add backend/mci-server/src/main/java/com/sino/mci/inbound/ backend/mci-server/src/main/resources/db/migration/V8__add_flow_trace.sql
|
||
git commit -m "feat(inbound): add flow execution trace recording and query"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: 前端依赖与 API 层改造
|
||
|
||
**Files:**
|
||
- Modify: `admin-web/package.json`
|
||
- Modify: `admin-web/src/api/client.ts`
|
||
- Modify: `admin-web/src/api/account.ts`
|
||
- Modify: `admin-web/src/api/channel.ts`
|
||
- Modify: `admin-web/src/api/crm.ts`
|
||
- Modify: `admin-web/src/api/send.ts`
|
||
- Create: `admin-web/src/api/dashboard.ts`
|
||
- Create: `admin-web/src/api/inbound.ts`
|
||
|
||
- [ ] **Step 1: 添加依赖**
|
||
|
||
修改 `admin-web/package.json`:
|
||
|
||
```json
|
||
"dependencies": {
|
||
"axios": "^1.18.1",
|
||
"element-plus": "^2.14.2",
|
||
"pinia": "^3.0.1",
|
||
"vue": "^3.5.39",
|
||
"vue-router": "^4.6.4",
|
||
"@vue-flow/core": "^1.42.1",
|
||
"@vue-flow/background": "^1.3.2",
|
||
"@vue-flow/controls": "^1.1.2"
|
||
}
|
||
```
|
||
|
||
Run:
|
||
```bash
|
||
cd /Users/marsal/Projects/sino-project/sino-mci/admin-web
|
||
npm install
|
||
```
|
||
|
||
- [ ] **Step 2: 改造 client.ts**
|
||
|
||
修改 `admin-web/src/api/client.ts`:
|
||
|
||
```typescript
|
||
import axios from 'axios'
|
||
|
||
const client = axios.create({
|
||
baseURL: import.meta.env.VITE_API_BASE_URL || '/msg-platform',
|
||
timeout: 30000,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
})
|
||
|
||
client.interceptors.request.use((config) => {
|
||
const token = localStorage.getItem('mci-token')
|
||
if (token) {
|
||
config.headers['Authorization'] = `Bearer ${token}`
|
||
}
|
||
const tenantCode = localStorage.getItem('mci-tenant-code')
|
||
if (tenantCode) {
|
||
config.headers['X-Tenant-Code'] = tenantCode
|
||
}
|
||
return config
|
||
})
|
||
|
||
client.interceptors.response.use(
|
||
(response) => response.data,
|
||
(error) => {
|
||
if (error.response?.status === 401) {
|
||
localStorage.removeItem('mci-token')
|
||
localStorage.removeItem('mci-tenant-code')
|
||
window.location.href = '/login'
|
||
}
|
||
const message = error.response?.data?.message || error.message || '请求失败'
|
||
return Promise.reject(new Error(message))
|
||
}
|
||
)
|
||
|
||
export default client
|
||
```
|
||
|
||
- [ ] **Step 3: 改造 account.ts**
|
||
|
||
```typescript
|
||
import client from './client'
|
||
|
||
export function login(data: { tenantCode: string; username: string; password: string }) {
|
||
return client.post('/api/v1/account/login', data)
|
||
}
|
||
|
||
export function createTenant(data: { tenantCode: string; tenantName: string }) {
|
||
return client.post('/api/v1/account/tenant', data)
|
||
}
|
||
|
||
export function listTenants() {
|
||
return client.get('/api/v1/account/tenant/list')
|
||
}
|
||
|
||
export function createUser(data: { username: string; password: string; realName?: string; roleCodes?: string[] }) {
|
||
return client.post('/api/v1/account/user', data)
|
||
}
|
||
|
||
export function listUsers() {
|
||
return client.get('/api/v1/account/user/list')
|
||
}
|
||
|
||
export function me() {
|
||
return client.get('/api/v1/account/me')
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 改造 channel.ts**
|
||
|
||
```typescript
|
||
import client from './client'
|
||
|
||
export function createChannelSubject(data: any) {
|
||
return client.post('/api/v1/channel-subject', data)
|
||
}
|
||
|
||
export function listChannelSubjects() {
|
||
return client.get('/api/v1/channel-subject/list')
|
||
}
|
||
|
||
export function createChannelAccount(data: any) {
|
||
return client.post('/api/v1/channel-account', data)
|
||
}
|
||
|
||
export function listChannelAccounts() {
|
||
return client.get('/api/v1/channel-account/list')
|
||
}
|
||
|
||
export function initChannelAccount(data: any) {
|
||
return client.post('/api/v1/channel-account/init', data)
|
||
}
|
||
|
||
export function createConversation(data: any) {
|
||
return client.post('/api/v1/conversation', data)
|
||
}
|
||
|
||
export function listConversations() {
|
||
return client.get('/api/v1/conversation/list')
|
||
}
|
||
|
||
export function bindConversationAccount(conversationId: number, data: any) {
|
||
return client.post(`/api/v1/conversation/${conversationId}/bind-account`, data)
|
||
}
|
||
|
||
export function bindConversationTags(conversationId: number, data: any) {
|
||
return client.post(`/api/v1/conversation/${conversationId}/bind-tags`, data)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: 改造 crm.ts**
|
||
|
||
```typescript
|
||
import client from './client'
|
||
|
||
export function createPerson(data: any) {
|
||
return client.post('/api/v1/person', data)
|
||
}
|
||
|
||
export function listPersons() {
|
||
return client.get('/api/v1/person/list')
|
||
}
|
||
|
||
export function listTags() {
|
||
return client.get('/api/v1/tag/list')
|
||
}
|
||
|
||
export function createTag(data: any) {
|
||
return client.post('/api/v1/tag', data)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: 改造 send.ts**
|
||
|
||
```typescript
|
||
import client from './client'
|
||
|
||
export function sendMessage(data: any) {
|
||
return client.post('/api/v1/send', data)
|
||
}
|
||
|
||
export function testSendMessage(data: any) {
|
||
return client.post('/api/v1/send/test', data)
|
||
}
|
||
|
||
export function listSendRecords(taskId?: string) {
|
||
if (taskId) {
|
||
return client.get(`/api/v1/send/${taskId}/records`)
|
||
}
|
||
return client.get('/api/v1/send/list')
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: 创建 dashboard.ts 和 inbound.ts**
|
||
|
||
`admin-web/src/api/dashboard.ts`:
|
||
|
||
```typescript
|
||
import client from './client'
|
||
|
||
export function getDashboardStats() {
|
||
return client.get('/api/v1/dashboard/stats')
|
||
}
|
||
```
|
||
|
||
`admin-web/src/api/inbound.ts`:
|
||
|
||
```typescript
|
||
import client from './client'
|
||
|
||
export function receiveMessage(data: any) {
|
||
return client.post('/api/v1/inbound/receive', data)
|
||
}
|
||
|
||
export function getFlowTraces(eventId: string) {
|
||
return client.get(`/api/v1/inbound/${eventId}/trace`)
|
||
}
|
||
```
|
||
|
||
`admin-web/src/api/flow.ts`(供 Vue Flow 编辑器调用):
|
||
|
||
```typescript
|
||
import client from './client'
|
||
|
||
export function createFlowDefinition(data: any) {
|
||
return client.post('/api/v1/inbound/flow', data)
|
||
}
|
||
|
||
export function listFlowDefinitions() {
|
||
return client.get('/api/v1/inbound/flow/list')
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
cd /Users/marsal/Projects/sino-project/sino-mci
|
||
git add admin-web/package.json admin-web/package-lock.json admin-web/src/api/
|
||
git commit -m "feat(web): add pinia/vue-flow deps and update API layer"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: 前端登录与路由守卫
|
||
|
||
**Files:**
|
||
- Create: `admin-web/src/stores/auth.ts`
|
||
- Create: `admin-web/src/views/Login.vue`
|
||
- Modify: `admin-web/src/router/index.ts`
|
||
- Modify: `admin-web/src/components/Layout.vue`
|
||
|
||
- [ ] **Step 1: 创建 Pinia auth store**
|
||
|
||
创建 `admin-web/src/stores/auth.ts`:
|
||
|
||
```typescript
|
||
import { defineStore } from 'pinia'
|
||
import { ref, computed } from 'vue'
|
||
|
||
export const useAuthStore = defineStore('auth', () => {
|
||
const token = ref(localStorage.getItem('mci-token') || '')
|
||
const tenantCode = ref(localStorage.getItem('mci-tenant-code') || '')
|
||
const username = ref('')
|
||
const roleCodes = ref<string[]>([])
|
||
|
||
const isLoggedIn = computed(() => !!token.value)
|
||
|
||
function setAuth(t: string, tc: string, user: string, roles: string[]) {
|
||
token.value = t
|
||
tenantCode.value = tc
|
||
username.value = user
|
||
roleCodes.value = roles
|
||
localStorage.setItem('mci-token', t)
|
||
localStorage.setItem('mci-tenant-code', tc)
|
||
}
|
||
|
||
function clearAuth() {
|
||
token.value = ''
|
||
tenantCode.value = ''
|
||
username.value = ''
|
||
roleCodes.value = []
|
||
localStorage.removeItem('mci-token')
|
||
localStorage.removeItem('mci-tenant-code')
|
||
}
|
||
|
||
return { token, tenantCode, username, roleCodes, isLoggedIn, setAuth, clearAuth }
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: 注册 Pinia**
|
||
|
||
修改 `admin-web/src/main.ts`,在现有 Element Plus 注册基础上加入 Pinia:
|
||
|
||
```typescript
|
||
import { createApp } from 'vue'
|
||
import { createPinia } from 'pinia'
|
||
import ElementPlus from 'element-plus'
|
||
import 'element-plus/dist/index.css'
|
||
import './style.css'
|
||
import App from './App.vue'
|
||
import router from './router'
|
||
|
||
const app = createApp(App)
|
||
app.use(createPinia())
|
||
app.use(ElementPlus)
|
||
app.use(router)
|
||
app.mount('#app')
|
||
```
|
||
|
||
- [ ] **Step 3: 创建 Login.vue**
|
||
|
||
创建 `admin-web/src/views/Login.vue`:
|
||
|
||
```vue
|
||
<template>
|
||
<div class="login-container">
|
||
<el-card title="Sino MCI 登录" style="width: 400px;">
|
||
<el-form :model="form" label-width="80px">
|
||
<el-form-item label="租户编码">
|
||
<el-input v-model="form.tenantCode" placeholder="tenant" />
|
||
</el-form-item>
|
||
<el-form-item label="用户名">
|
||
<el-input v-model="form.username" placeholder="username" />
|
||
</el-form-item>
|
||
<el-form-item label="密码">
|
||
<el-input v-model="form.password" type="password" placeholder="password" />
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" @click="handleLogin" style="width: 100%;">登录</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
</el-card>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { reactive } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { ElMessage } from 'element-plus'
|
||
import { login } from '@/api/account'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
|
||
const router = useRouter()
|
||
const authStore = useAuthStore()
|
||
|
||
const form = reactive({
|
||
tenantCode: '',
|
||
username: '',
|
||
password: '',
|
||
})
|
||
|
||
async function handleLogin() {
|
||
try {
|
||
const res: any = await login(form)
|
||
authStore.setAuth(res.data.token, res.data.userInfo.tenantCode, res.data.userInfo.username, res.data.userInfo.roleCodes || [])
|
||
ElMessage.success('登录成功')
|
||
router.push('/dashboard')
|
||
} catch (e: any) {
|
||
ElMessage.error(e.message)
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.login-container {
|
||
height: 100vh;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
background: #f5f7fa;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 4: 改造路由**
|
||
|
||
修改 `admin-web/src/router/index.ts`:
|
||
|
||
```typescript
|
||
import { createRouter, createWebHistory } from 'vue-router'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
import Layout from '../components/Layout.vue'
|
||
import Dashboard from '../views/Dashboard.vue'
|
||
import ChannelSubject from '../views/ChannelSubject.vue'
|
||
import ChannelAccount from '../views/ChannelAccount.vue'
|
||
import Conversation from '../views/Conversation.vue'
|
||
import Person from '../views/Person.vue'
|
||
import Tag from '../views/Tag.vue'
|
||
import SendRecord from '../views/SendRecord.vue'
|
||
import InboundFlow from '../views/InboundFlow.vue'
|
||
import FlowTrace from '../views/FlowTrace.vue'
|
||
import MessageTest from '../views/MessageTest.vue'
|
||
import TenantManagement from '../views/TenantManagement.vue'
|
||
import UserManagement from '../views/UserManagement.vue'
|
||
import Login from '../views/Login.vue'
|
||
|
||
const routes = [
|
||
{ path: '/login', component: Login, meta: { public: true } },
|
||
{
|
||
path: '/',
|
||
component: Layout,
|
||
redirect: '/dashboard',
|
||
children: [
|
||
{ path: 'dashboard', component: Dashboard, meta: { title: '概览' } },
|
||
{ path: 'channel-subject', component: ChannelSubject, meta: { title: '渠道主体' } },
|
||
{ path: 'channel-account', component: ChannelAccount, meta: { title: '渠道账号' } },
|
||
{ path: 'conversation', component: Conversation, meta: { title: '会话/群聊' } },
|
||
{ path: 'person', component: Person, meta: { title: '联系人' } },
|
||
{ path: 'tag', component: Tag, meta: { title: '标签' } },
|
||
{ path: 'send-record', component: SendRecord, meta: { title: '发送记录' } },
|
||
{ path: 'inbound-flow', component: InboundFlow, meta: { title: '入站流程' } },
|
||
{ path: 'flow-trace', component: FlowTrace, meta: { title: '流程追踪' } },
|
||
{ path: 'message-test', component: MessageTest, meta: { title: '消息测试' } },
|
||
{ path: 'tenant-management', component: TenantManagement, meta: { title: '租户管理' } },
|
||
{ path: 'user-management', component: UserManagement, meta: { title: '用户管理' } },
|
||
],
|
||
},
|
||
]
|
||
|
||
const router = createRouter({
|
||
history: createWebHistory(),
|
||
routes,
|
||
})
|
||
|
||
router.beforeEach((to, from, next) => {
|
||
const authStore = useAuthStore()
|
||
if (!to.meta.public && !authStore.isLoggedIn) {
|
||
next('/login')
|
||
} else if (to.path === '/login' && authStore.isLoggedIn) {
|
||
next('/dashboard')
|
||
} else {
|
||
next()
|
||
}
|
||
})
|
||
|
||
export default router
|
||
```
|
||
|
||
- [ ] **Step 5: 改造 Layout.vue 显示用户和退出**
|
||
|
||
修改 `admin-web/src/components/Layout.vue`(若不存在则创建):
|
||
|
||
```vue
|
||
<template>
|
||
<el-container style="height: 100vh;">
|
||
<el-aside width="200px">
|
||
<div class="logo">Sino MCI</div>
|
||
<el-menu :default-active="$route.path" router>
|
||
<el-menu-item index="/dashboard">概览</el-menu-item>
|
||
<el-menu-item index="/channel-subject">渠道主体</el-menu-item>
|
||
<el-menu-item index="/channel-account">渠道账号</el-menu-item>
|
||
<el-menu-item index="/conversation">会话/群聊</el-menu-item>
|
||
<el-menu-item index="/person">联系人</el-menu-item>
|
||
<el-menu-item index="/tag">标签</el-menu-item>
|
||
<el-menu-item index="/send-record">发送记录</el-menu-item>
|
||
<el-menu-item index="/inbound-flow">入站流程</el-menu-item>
|
||
<el-menu-item index="/flow-trace">流程追踪</el-menu-item>
|
||
<el-menu-item index="/message-test">消息测试</el-menu-item>
|
||
<el-menu-item index="/tenant-management" v-if="isPlatformAdmin">租户管理</el-menu-item>
|
||
<el-menu-item index="/user-management">用户管理</el-menu-item>
|
||
</el-menu>
|
||
</el-aside>
|
||
<el-container>
|
||
<el-header style="display: flex; justify-content: space-between; align-items: center;">
|
||
<span>{{ $route.meta.title }}</span>
|
||
<div>
|
||
<span>{{ authStore.username }} ({{ authStore.tenantCode }})</span>
|
||
<el-button type="text" @click="logout" style="margin-left: 16px;">退出</el-button>
|
||
</div>
|
||
</el-header>
|
||
<el-main>
|
||
<router-view />
|
||
</el-main>
|
||
</el-container>
|
||
</el-container>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const authStore = useAuthStore()
|
||
|
||
const isPlatformAdmin = computed(() => authStore.roleCodes.includes('platform_admin'))
|
||
|
||
function logout() {
|
||
authStore.clearAuth()
|
||
router.push('/login')
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.logo {
|
||
height: 60px;
|
||
line-height: 60px;
|
||
text-align: center;
|
||
font-weight: bold;
|
||
font-size: 18px;
|
||
border-bottom: 1px solid #e4e7ed;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 6: 构建验证**
|
||
|
||
Run:
|
||
```bash
|
||
cd /Users/marsal/Projects/sino-project/sino-mci/admin-web
|
||
npm test
|
||
```
|
||
|
||
Expected: BUILD SUCCESS
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
cd /Users/marsal/Projects/sino-project/sino-mci
|
||
git add admin-web/src/stores/ admin-web/src/views/Login.vue admin-web/src/router/index.ts admin-web/src/components/Layout.vue admin-web/src/main.ts
|
||
git commit -m "feat(web): add login, auth store and router guard"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: 前端租户/用户管理页
|
||
|
||
**Files:**
|
||
- Create: `admin-web/src/views/TenantManagement.vue`
|
||
- Create: `admin-web/src/views/UserManagement.vue`
|
||
|
||
- [ ] **Step 1: 创建 TenantManagement.vue**
|
||
|
||
```vue
|
||
<template>
|
||
<div>
|
||
<el-form :model="form" inline>
|
||
<el-form-item label="租户编码">
|
||
<el-input v-model="form.tenantCode" placeholder="tenant" />
|
||
</el-form-item>
|
||
<el-form-item label="租户名称">
|
||
<el-input v-model="form.tenantName" placeholder="租户名称" />
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" @click="handleCreate">创建</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
|
||
<el-table :data="tenants" border>
|
||
<el-table-column prop="id" label="ID" width="80" />
|
||
<el-table-column prop="tenantCode" label="编码" />
|
||
<el-table-column prop="tenantName" label="名称" />
|
||
<el-table-column prop="status" label="状态" />
|
||
</el-table>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { onMounted, reactive, ref } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { createTenant, listTenants } from '@/api/account'
|
||
|
||
const form = reactive({ tenantCode: '', tenantName: '' })
|
||
const tenants = ref<any[]>([])
|
||
|
||
async function loadTenants() {
|
||
try {
|
||
const res: any = await listTenants()
|
||
tenants.value = res.data || []
|
||
} catch (e: any) {
|
||
ElMessage.error(e.message)
|
||
}
|
||
}
|
||
|
||
async function handleCreate() {
|
||
try {
|
||
await createTenant(form)
|
||
ElMessage.success('创建成功')
|
||
await loadTenants()
|
||
} catch (e: any) {
|
||
ElMessage.error(e.message)
|
||
}
|
||
}
|
||
|
||
onMounted(loadTenants)
|
||
</script>
|
||
```
|
||
|
||
- [ ] **Step 2: 创建 UserManagement.vue**
|
||
|
||
```vue
|
||
<template>
|
||
<div>
|
||
<el-form :model="form" inline>
|
||
<el-form-item label="用户名">
|
||
<el-input v-model="form.username" placeholder="username" />
|
||
</el-form-item>
|
||
<el-form-item label="密码">
|
||
<el-input v-model="form.password" type="password" placeholder="password" />
|
||
</el-form-item>
|
||
<el-form-item label="姓名">
|
||
<el-input v-model="form.realName" placeholder="姓名" />
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" @click="handleCreate">创建</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
|
||
<el-table :data="users" border>
|
||
<el-table-column prop="id" label="ID" width="80" />
|
||
<el-table-column prop="username" label="用户名" />
|
||
<el-table-column prop="realName" label="姓名" />
|
||
<el-table-column prop="roleCodes" label="角色" />
|
||
<el-table-column prop="status" label="状态" />
|
||
</el-table>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { onMounted, reactive, ref } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { createUser, listUsers } from '@/api/account'
|
||
|
||
const form = reactive({ username: '', password: '', realName: '' })
|
||
const users = ref<any[]>([])
|
||
|
||
async function loadUsers() {
|
||
try {
|
||
const res: any = await listUsers()
|
||
users.value = res.data || []
|
||
} catch (e: any) {
|
||
ElMessage.error(e.message)
|
||
}
|
||
}
|
||
|
||
async function handleCreate() {
|
||
try {
|
||
await createUser({ ...form, roleCodes: ['tenant_admin'] })
|
||
ElMessage.success('创建成功')
|
||
await loadUsers()
|
||
} catch (e: any) {
|
||
ElMessage.error(e.message)
|
||
}
|
||
}
|
||
|
||
onMounted(loadUsers)
|
||
</script>
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd /Users/marsal/Projects/sino-project/sino-mci
|
||
git add admin-web/src/views/TenantManagement.vue admin-web/src/views/UserManagement.vue
|
||
git commit -m "feat(web): add tenant and user management pages"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: 前端 Dashboard 与各列表页
|
||
|
||
**Files:**
|
||
- Modify: `admin-web/src/views/Dashboard.vue`
|
||
- Modify: `admin-web/src/views/ChannelSubject.vue`
|
||
- Modify: `admin-web/src/views/ChannelAccount.vue`
|
||
- Modify: `admin-web/src/views/Conversation.vue`
|
||
- Modify: `admin-web/src/views/Person.vue`
|
||
- Modify: `admin-web/src/views/Tag.vue`
|
||
|
||
- [ ] **Step 1: Dashboard.vue**
|
||
|
||
```vue
|
||
<template>
|
||
<div>
|
||
<el-row :gutter="20">
|
||
<el-col :span="6"><el-statistic title="渠道主体" :value="stats.subjectCount" /></el-col>
|
||
<el-col :span="6"><el-statistic title="渠道账号" :value="stats.accountCount" /></el-col>
|
||
<el-col :span="6"><el-statistic title="会话/群聊" :value="stats.conversationCount" /></el-col>
|
||
<el-col :span="6"><el-statistic title="今日发送" :value="stats.sentToday" /></el-col>
|
||
</el-row>
|
||
<el-card class="mt-4">
|
||
<template #header>使用说明</template>
|
||
<p>1. 先在「渠道主体」录入企微主体信息。</p>
|
||
<p>2. 在「渠道账号」添加发送/接收账号。</p>
|
||
<p>3. 在「会话/群聊」维护业务群并绑定发送账号。</p>
|
||
<p>4. 在「发送记录」查看业务系统调用发送 API 的结果。</p>
|
||
</el-card>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { onMounted, reactive } from 'vue'
|
||
import { getDashboardStats } from '@/api/dashboard'
|
||
|
||
const stats = reactive({ subjectCount: 0, accountCount: 0, conversationCount: 0, sentToday: 0 })
|
||
|
||
onMounted(async () => {
|
||
const res: any = await getDashboardStats()
|
||
Object.assign(stats, res.data)
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.mt-4 { margin-top: 20px; }
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 2: ChannelSubject.vue 改造**
|
||
|
||
补充表单字段并加载列表:
|
||
|
||
```vue
|
||
<template>
|
||
<div>
|
||
<el-form :model="form" inline>
|
||
<el-form-item label="渠道">
|
||
<el-select v-model="form.channelType">
|
||
<el-option label="企微" value="wecom" />
|
||
<el-option label="钉钉" value="dingtalk" />
|
||
<el-option label="飞书" value="feishu" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="主体编码"><el-input v-model="form.subjectCode" /></el-form-item>
|
||
<el-form-item label="主体名称"><el-input v-model="form.subjectName" /></el-form-item>
|
||
<el-form-item label="CorpID"><el-input v-model="form.corpId" /></el-form-item>
|
||
<el-form-item label="AgentID"><el-input v-model="form.agentId" /></el-form-item>
|
||
<el-form-item label="AgentSecret"><el-input v-model="form.agentSecret" /></el-form-item>
|
||
<el-form-item><el-button type="primary" @click="handleCreate">创建</el-button></el-form-item>
|
||
</el-form>
|
||
<el-table :data="subjects" border>
|
||
<el-table-column prop="id" label="ID" width="80" />
|
||
<el-table-column prop="channelType" label="渠道" />
|
||
<el-table-column prop="subjectCode" label="编码" />
|
||
<el-table-column prop="subjectName" label="名称" />
|
||
<el-table-column prop="corpId" label="CorpID" />
|
||
<el-table-column prop="status" label="状态" />
|
||
</el-table>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { onMounted, reactive, ref } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { createChannelSubject, listChannelSubjects } from '@/api/channel'
|
||
|
||
const form = reactive({ channelType: 'wecom', subjectCode: '', subjectName: '', corpId: '', agentId: '', agentSecret: '' })
|
||
const subjects = ref<any[]>([])
|
||
|
||
async function loadSubjects() {
|
||
const res: any = await listChannelSubjects()
|
||
subjects.value = res.data || []
|
||
}
|
||
|
||
async function handleCreate() {
|
||
await createChannelSubject(form)
|
||
ElMessage.success('创建成功')
|
||
await loadSubjects()
|
||
}
|
||
|
||
onMounted(loadSubjects)
|
||
</script>
|
||
```
|
||
|
||
- [ ] **Step 3: ChannelAccount.vue 改造**
|
||
|
||
补充 subjectId、sessionArchiveEnabled 并加载列表:
|
||
|
||
```vue
|
||
<template>
|
||
<div>
|
||
<el-form :model="form" inline>
|
||
<el-form-item label="账号类型">
|
||
<el-select v-model="form.accountType">
|
||
<el-option label="企微内部账号" :value="1" />
|
||
<el-option label="企微应用" :value="2" />
|
||
<el-option label="pywechat 个人号" :value="3" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="渠道"><el-input v-model="form.channelType" /></el-form-item>
|
||
<el-form-item label="所属主体">
|
||
<el-select v-model="form.subjectId" placeholder="选择主体">
|
||
<el-option v-for="s in subjects" :key="s.id" :label="s.subjectName" :value="s.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="账号ID"><el-input v-model="form.accountId" /></el-form-item>
|
||
<el-form-item label="名称"><el-input v-model="form.accountName" /></el-form-item>
|
||
<el-form-item label="会话存档">
|
||
<el-switch v-model="form.sessionArchiveEnabled" :active-value="1" :inactive-value="0" />
|
||
</el-form-item>
|
||
<el-form-item><el-button type="primary" @click="handleCreate">创建</el-button></el-form-item>
|
||
</el-form>
|
||
<el-table :data="accounts" border>
|
||
<el-table-column prop="id" label="ID" width="80" />
|
||
<el-table-column prop="accountType" label="类型" />
|
||
<el-table-column prop="channelType" label="渠道" />
|
||
<el-table-column prop="accountId" label="账号ID" />
|
||
<el-table-column prop="accountName" label="名称" />
|
||
<el-table-column prop="loginStatus" label="登录状态" />
|
||
<el-table-column prop="onlineStatus" label="在线状态" />
|
||
</el-table>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { onMounted, reactive, ref } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { createChannelAccount, listChannelAccounts, listChannelSubjects } from '@/api/channel'
|
||
|
||
const form = reactive({ accountType: 1, channelType: 'wecom', subjectId: null, accountId: '', accountName: '', sessionArchiveEnabled: 0 })
|
||
const accounts = ref<any[]>([])
|
||
const subjects = ref<any[]>([])
|
||
|
||
async function loadData() {
|
||
const [a, s] = await Promise.all([listChannelAccounts(), listChannelSubjects()])
|
||
accounts.value = a.data || []
|
||
subjects.value = s.data || []
|
||
}
|
||
|
||
async function handleCreate() {
|
||
await createChannelAccount(form)
|
||
ElMessage.success('创建成功')
|
||
await loadData()
|
||
}
|
||
|
||
onMounted(loadData)
|
||
</script>
|
||
```
|
||
|
||
- [ ] **Step 4: Conversation.vue 改造**
|
||
|
||
补充 ownerAccountId 和绑定账号弹窗:
|
||
|
||
```vue
|
||
<template>
|
||
<div>
|
||
<el-form :model="form" inline>
|
||
<el-form-item label="会话类型">
|
||
<el-select v-model="form.conversationType"><el-option label="单聊" :value="1" /><el-option label="群聊" :value="2" /></el-select>
|
||
</el-form-item>
|
||
<el-form-item label="渠道"><el-input v-model="form.channelType" /></el-form-item>
|
||
<el-form-item label="渠道会话ID"><el-input v-model="form.channelConversationId" /></el-form-item>
|
||
<el-form-item label="名称"><el-input v-model="form.conversationName" /></el-form-item>
|
||
<el-form-item label="群主">
|
||
<el-select v-model="form.ownerAccountId" placeholder="选择账号">
|
||
<el-option v-for="a in accounts" :key="a.id" :label="a.accountName" :value="a.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item><el-button type="primary" @click="handleCreate">创建</el-button></el-form-item>
|
||
</el-form>
|
||
|
||
<el-table :data="conversations" border>
|
||
<el-table-column prop="id" label="ID" width="80" />
|
||
<el-table-column prop="conversationType" label="类型" />
|
||
<el-table-column prop="channelType" label="渠道" />
|
||
<el-table-column prop="channelConversationId" label="渠道会话ID" />
|
||
<el-table-column prop="conversationName" label="名称" />
|
||
<el-table-column label="操作">
|
||
<template #default="scope">
|
||
<el-button size="small" @click="openBind(scope.row)">绑定账号</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<el-dialog v-model="bindVisible" title="绑定账号" width="400px">
|
||
<el-form :model="bindForm" label-width="100px">
|
||
<el-form-item label="账号">
|
||
<el-select v-model="bindForm.accountId">
|
||
<el-option v-for="a in accounts" :key="a.id" :label="a.accountName" :value="a.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="绑定类型">
|
||
<el-select v-model="bindForm.bindingType">
|
||
<el-option label="发送主号" :value="1" />
|
||
<el-option label="发送备用号" :value="2" />
|
||
<el-option label="接收主号" :value="3" />
|
||
<el-option label="接收备用号" :value="4" />
|
||
</el-select>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="bindVisible = false">取消</el-button>
|
||
<el-button type="primary" @click="handleBind">确定</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { onMounted, reactive, ref } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { createConversation, listConversations, bindConversationAccount, listChannelAccounts } from '@/api/channel'
|
||
|
||
const form = reactive({ conversationType: 2, channelType: 'wecom', channelConversationId: '', conversationName: '', ownerAccountId: null })
|
||
const conversations = ref<any[]>([])
|
||
const accounts = ref<any[]>([])
|
||
const bindVisible = ref(false)
|
||
const currentConversation = ref<any>(null)
|
||
const bindForm = reactive({ accountId: null, bindingType: 1, sortOrder: 0 })
|
||
|
||
async function loadData() {
|
||
const [c, a] = await Promise.all([listConversations(), listChannelAccounts()])
|
||
conversations.value = c.data || []
|
||
accounts.value = a.data || []
|
||
}
|
||
|
||
async function handleCreate() {
|
||
await createConversation(form)
|
||
ElMessage.success('创建成功')
|
||
await loadData()
|
||
}
|
||
|
||
function openBind(row: any) {
|
||
currentConversation.value = row
|
||
bindForm.accountId = null
|
||
bindVisible.value = true
|
||
}
|
||
|
||
async function handleBind() {
|
||
if (!currentConversation.value || !bindForm.accountId) return
|
||
await bindConversationAccount(currentConversation.value.id, bindForm)
|
||
ElMessage.success('绑定成功')
|
||
bindVisible.value = false
|
||
}
|
||
|
||
onMounted(loadData)
|
||
</script>
|
||
```
|
||
|
||
- [ ] **Step 5: Person.vue 改造**
|
||
|
||
加载人员列表:
|
||
|
||
```vue
|
||
<template>
|
||
<div>
|
||
<el-form :model="form" inline>
|
||
<el-form-item label="编码"><el-input v-model="form.personCode" /></el-form-item>
|
||
<el-form-item label="姓名"><el-input v-model="form.personName" /></el-form-item>
|
||
<el-form-item label="手机"><el-input v-model="form.phone" /></el-form-item>
|
||
<el-form-item label="类型">
|
||
<el-select v-model="form.personType">
|
||
<el-option label="内部人员" :value="1" />
|
||
<el-option label="供应商人员" :value="2" />
|
||
<el-option label="客户" :value="3" />
|
||
<el-option label="司机" :value="4" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item><el-button type="primary" @click="handleCreate">创建</el-button></el-form-item>
|
||
</el-form>
|
||
<el-table :data="persons" border>
|
||
<el-table-column prop="id" label="ID" width="80" />
|
||
<el-table-column prop="personCode" label="编码" />
|
||
<el-table-column prop="personName" label="姓名" />
|
||
<el-table-column prop="phone" label="手机" />
|
||
<el-table-column prop="personType" label="类型" />
|
||
<el-table-column prop="status" label="状态" />
|
||
</el-table>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { onMounted, reactive, ref } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { createPerson, listPersons } from '@/api/crm'
|
||
|
||
const form = reactive({ personCode: '', personName: '', phone: '', personType: 3 })
|
||
const persons = ref<any[]>([])
|
||
|
||
async function loadPersons() {
|
||
const res: any = await listPersons()
|
||
persons.value = res.data || []
|
||
}
|
||
|
||
async function handleCreate() {
|
||
await createPerson(form)
|
||
ElMessage.success('创建成功')
|
||
await loadPersons()
|
||
}
|
||
|
||
onMounted(loadPersons)
|
||
</script>
|
||
```
|
||
|
||
- [ ] **Step 6: Tag.vue 改造**
|
||
|
||
树形展示层级:
|
||
|
||
```vue
|
||
<template>
|
||
<div>
|
||
<el-form :model="form" inline>
|
||
<el-form-item label="标签名"><el-input v-model="form.tagName" /></el-form-item>
|
||
<el-form-item label="父标签">
|
||
<el-select v-model="form.parentId" clearable>
|
||
<el-option label="无" :value="0" />
|
||
<el-option v-for="t in tags" :key="t.id" :label="t.tagName" :value="t.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item><el-button type="primary" @click="handleCreate">创建</el-button></el-form-item>
|
||
</el-form>
|
||
<el-table :data="tags" border>
|
||
<el-table-column prop="id" label="ID" width="80" />
|
||
<el-table-column prop="tagName" label="名称" />
|
||
<el-table-column prop="tagPath" label="路径" />
|
||
<el-table-column prop="level" label="层级" />
|
||
<el-table-column prop="status" label="状态" />
|
||
</el-table>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { onMounted, reactive, ref } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { createTag, listTags } from '@/api/crm'
|
||
|
||
const form = reactive({ tagName: '', parentId: 0 })
|
||
const tags = ref<any[]>([])
|
||
|
||
async function loadTags() {
|
||
const res: any = await listTags()
|
||
tags.value = res.data || []
|
||
}
|
||
|
||
async function handleCreate() {
|
||
await createTag(form)
|
||
ElMessage.success('创建成功')
|
||
await loadTags()
|
||
}
|
||
|
||
onMounted(loadTags)
|
||
</script>
|
||
```
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
cd /Users/marsal/Projects/sino-project/sino-mci
|
||
git add admin-web/src/views/
|
||
git commit -m "feat(web): load real data in dashboard and list pages"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: 发送记录页 UX 优化
|
||
|
||
**Files:**
|
||
- Modify: `admin-web/src/views/SendRecord.vue`
|
||
|
||
- [ ] **Step 1: 优化 SendRecord.vue**
|
||
|
||
```vue
|
||
<template>
|
||
<div>
|
||
<el-form :model="form" label-width="80px">
|
||
<el-form-item label="目标类型">
|
||
<el-select v-model="form.targetType">
|
||
<el-option label="会话" value="conversation" />
|
||
<el-option label="人员" value="person" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="目标" v-if="form.targetType === 'conversation'">
|
||
<el-select v-model="selectedConversationId" placeholder="选择会话">
|
||
<el-option v-for="c in conversations" :key="c.id" :label="c.conversationName" :value="c.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="内容类型"><el-input v-model="form.contentType" disabled /></el-form-item>
|
||
<el-form-item label="内容">
|
||
<el-input v-model="textContent" type="textarea" :rows="3" placeholder="输入文本消息" />
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" @click="handleTestSend">测试发送</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
|
||
<el-divider />
|
||
|
||
<el-table :data="records" border>
|
||
<el-table-column prop="id" label="ID" width="80" />
|
||
<el-table-column prop="taskId" label="任务ID" />
|
||
<el-table-column prop="targetType" label="目标类型" />
|
||
<el-table-column prop="channelType" label="渠道" />
|
||
<el-table-column prop="contentType" label="内容类型" />
|
||
<el-table-column prop="sendStatus" label="状态">
|
||
<template #default="scope">
|
||
<el-tag v-if="scope.row.sendStatus === 2" type="success">成功</el-tag>
|
||
<el-tag v-else-if="scope.row.sendStatus === 3" type="danger">失败</el-tag>
|
||
<el-tag v-else type="info">待发送</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="createTime" label="创建时间" />
|
||
</el-table>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { onMounted, reactive, ref, computed } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { testSendMessage, listSendRecords } from '@/api/send'
|
||
import { listConversations } from '@/api/channel'
|
||
|
||
const form = reactive({ targetType: 'conversation', contentType: 'text', channelStrategy: { channel_type: 'wecom', strategy: 'primary' } })
|
||
const selectedConversationId = ref<number | null>(null)
|
||
const textContent = ref('')
|
||
const records = ref<any[]>([])
|
||
const conversations = ref<any[]>([])
|
||
|
||
const targetValue = computed(() => {
|
||
if (form.targetType === 'conversation' && selectedConversationId.value) {
|
||
return { conversation_id: selectedConversationId.value }
|
||
}
|
||
return {}
|
||
})
|
||
|
||
async function loadRecords() {
|
||
const res: any = await listSendRecords()
|
||
records.value = res.data || []
|
||
}
|
||
|
||
async function loadConversations() {
|
||
const res: any = await listConversations()
|
||
conversations.value = res.data || []
|
||
}
|
||
|
||
async function handleTestSend() {
|
||
try {
|
||
await testSendMessage({
|
||
targetType: form.targetType,
|
||
targetValue: targetValue.value,
|
||
contentType: 'text',
|
||
content: { text: textContent.value },
|
||
channelStrategy: form.channelStrategy,
|
||
})
|
||
ElMessage.success('测试发送已提交')
|
||
await loadRecords()
|
||
} catch (e: any) {
|
||
ElMessage.error(e.message)
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadRecords()
|
||
loadConversations()
|
||
})
|
||
</script>
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
cd /Users/marsal/Projects/sino-project/sino-mci
|
||
git add admin-web/src/views/SendRecord.vue
|
||
git commit -m "feat(web): improve send record UX with dropdowns"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: 入站流程可视化编辑器与追踪
|
||
|
||
**Files:**
|
||
- Modify: `admin-web/src/views/InboundFlow.vue`
|
||
- Create: `admin-web/src/views/FlowTrace.vue`
|
||
|
||
- [ ] **Step 1: 安装 Vue Flow**
|
||
|
||
已在 Task 5 添加依赖,无需重复。
|
||
|
||
- [ ] **Step 2: 改造 InboundFlow.vue**
|
||
|
||
使用 Vue Flow 实现流程图编辑:
|
||
|
||
```vue
|
||
<template>
|
||
<div style="height: 600px;">
|
||
<div style="margin-bottom: 12px;">
|
||
<el-input v-model="form.flowCode" placeholder="流程编码" style="width: 150px;" />
|
||
<el-input v-model="form.flowName" placeholder="流程名称" style="width: 150px; margin-left: 8px;" />
|
||
<el-button type="primary" @click="handleSave" style="margin-left: 8px;">保存流程</el-button>
|
||
</div>
|
||
<VueFlow v-model="elements" fit-view-on-init>
|
||
<Background />
|
||
<Controls />
|
||
</VueFlow>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { reactive, ref } from 'vue'
|
||
import { VueFlow } from '@vue-flow/core'
|
||
import { Background } from '@vue-flow/background'
|
||
import { Controls } from '@vue-flow/controls'
|
||
import '@vue-flow/core/dist/style.css'
|
||
import '@vue-flow/core/dist/theme-default.css'
|
||
import { ElMessage } from 'element-plus'
|
||
import { createFlowDefinition } from '@/api/flow'
|
||
|
||
const form = reactive({ flowCode: '', flowName: '', nodes: [] as any[], edges: [] as any[] })
|
||
const elements = ref([
|
||
{ id: '1', type: 'input', label: '接收消息', position: { x: 250, y: 5 }, data: { nodeType: 'receive' } },
|
||
{ id: '2', label: '意图识别', position: { x: 250, y: 120 }, data: { nodeType: 'intent', intent: 'rescue_request' } },
|
||
{ id: '3', label: 'Webhook 推送', position: { x: 250, y: 240 }, data: { nodeType: 'webhook', webhookUrl: '' } },
|
||
{ id: 'e1-2', source: '1', target: '2', animated: true },
|
||
{ id: 'e2-3', source: '2', target: '3', animated: true },
|
||
])
|
||
|
||
async function handleSave() {
|
||
const nodes = elements.value.filter(e => !e.source).map(n => ({
|
||
nodeType: n.data?.nodeType,
|
||
config: n.data,
|
||
position: n.position,
|
||
}))
|
||
const edges = elements.value.filter(e => e.source).map(e => ({
|
||
source: e.source,
|
||
target: e.target,
|
||
}))
|
||
await createFlowDefinition({
|
||
flowCode: form.flowCode,
|
||
flowName: form.flowName,
|
||
flowType: 'inbound',
|
||
definitionJson: { nodes, edges },
|
||
})
|
||
ElMessage.success('保存成功')
|
||
}
|
||
</script>
|
||
```
|
||
|
||
说明:后端 `FlowDefinition.definitionJson` 为字符串字段,但在 `CreateFlowRequest` 中对应 `Map<String, Object>`,前端按 `{ nodes, edges }` 结构提交,由后端序列化为 JSON 字符串持久化。
|
||
|
||
- [ ] **Step 3: 创建 FlowTrace.vue**
|
||
|
||
```vue
|
||
<template>
|
||
<div>
|
||
<el-form inline>
|
||
<el-form-item label="事件ID"><el-input v-model="eventId" /></el-form-item>
|
||
<el-form-item><el-button type="primary" @click="loadTrace">查询</el-button></el-form-item>
|
||
</el-form>
|
||
<el-timeline>
|
||
<el-timeline-item v-for="t in traces" :key="t.id" :type="t.status === 1 ? 'success' : 'danger'">
|
||
<p>{{ t.nodeType }} - {{ t.status === 1 ? '成功' : '失败' }} - {{ t.durationMs }}ms</p>
|
||
<p v-if="t.errorMsg" style="color: red;">{{ t.errorMsg }}</p>
|
||
</el-timeline-item>
|
||
</el-timeline>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { getFlowTraces } from '@/api/inbound'
|
||
|
||
const eventId = ref('')
|
||
const traces = ref<any[]>([])
|
||
|
||
async function loadTrace() {
|
||
try {
|
||
const res: any = await getFlowTraces(eventId.value)
|
||
traces.value = res.data || []
|
||
} catch (e: any) {
|
||
ElMessage.error(e.message)
|
||
}
|
||
}
|
||
</script>
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
cd /Users/marsal/Projects/sino-project/sino-mci
|
||
git add admin-web/src/views/InboundFlow.vue admin-web/src/views/FlowTrace.vue
|
||
git commit -m "feat(web): add Vue Flow editor and flow trace viewer"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: 消息测试模块
|
||
|
||
**Files:**
|
||
- Create: `admin-web/src/views/MessageTest.vue`
|
||
|
||
- [ ] **Step 1: 创建 MessageTest.vue**
|
||
|
||
```vue
|
||
<template>
|
||
<div>
|
||
<el-tabs v-model="activeTab">
|
||
<el-tab-pane label="出站测试" name="outbound">
|
||
<el-form :model="outbound" label-width="80px">
|
||
<el-form-item label="目标类型">
|
||
<el-select v-model="outbound.targetType">
|
||
<el-option label="会话" value="conversation" />
|
||
<el-option label="人员" value="person" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="目标会话" v-if="outbound.targetType === 'conversation'">
|
||
<el-select v-model="outbound.conversationId">
|
||
<el-option v-for="c in conversations" :key="c.id" :label="c.conversationName" :value="c.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="文本内容"><el-input v-model="outbound.text" type="textarea" /></el-form-item>
|
||
<el-form-item><el-button type="primary" @click="handleOutbound">测试发送</el-button></el-form-item>
|
||
</el-form>
|
||
</el-tab-pane>
|
||
<el-tab-pane label="入站模拟" name="inbound">
|
||
<el-form :model="inbound" label-width="100px">
|
||
<el-form-item label="事件ID"><el-input v-model="inbound.eventId" /></el-form-item>
|
||
<el-form-item label="渠道"><el-input v-model="inbound.channelType" /></el-form-item>
|
||
<el-form-item label="事件类型"><el-input v-model="inbound.eventType" /></el-form-item>
|
||
<el-form-item label="消息来源"><el-input v-model="inbound.messageSource" /></el-form-item>
|
||
<el-form-item label="会话ID"><el-input v-model="inbound.conversationId" /></el-form-item>
|
||
<el-form-item label="文本内容"><el-input v-model="inbound.text" type="textarea" /></el-form-item>
|
||
<el-form-item><el-button type="primary" @click="handleInbound">模拟入站</el-button></el-form-item>
|
||
</el-form>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { onMounted, reactive, ref } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { testSendMessage } from '@/api/send'
|
||
import { receiveMessage } from '@/api/inbound'
|
||
import { listConversations } from '@/api/channel'
|
||
|
||
const activeTab = ref('outbound')
|
||
const conversations = ref<any[]>([])
|
||
|
||
const outbound = reactive({ targetType: 'conversation', conversationId: null, text: '' })
|
||
const inbound = reactive({ eventId: '', channelType: 'wecom', eventType: 'message', messageSource: 'realtime', conversationId: '', text: '' })
|
||
|
||
async function loadConversations() {
|
||
const res: any = await listConversations()
|
||
conversations.value = res.data || []
|
||
}
|
||
|
||
async function handleOutbound() {
|
||
try {
|
||
await testSendMessage({
|
||
targetType: outbound.targetType,
|
||
targetValue: { conversation_id: outbound.conversationId },
|
||
contentType: 'text',
|
||
content: { text: outbound.text },
|
||
channelStrategy: { channel_type: 'wecom', strategy: 'primary' },
|
||
})
|
||
ElMessage.success('发送成功')
|
||
} catch (e: any) {
|
||
ElMessage.error(e.message)
|
||
}
|
||
}
|
||
|
||
async function handleInbound() {
|
||
try {
|
||
await receiveMessage({
|
||
eventId: inbound.eventId,
|
||
channelType: inbound.channelType,
|
||
eventType: inbound.eventType,
|
||
messageSource: inbound.messageSource,
|
||
conversationId: Number(inbound.conversationId),
|
||
content: { text: inbound.text },
|
||
rawPayload: {},
|
||
businessContext: {},
|
||
})
|
||
ElMessage.success('入站模拟成功')
|
||
} catch (e: any) {
|
||
ElMessage.error(e.message)
|
||
}
|
||
}
|
||
|
||
onMounted(loadConversations)
|
||
</script>
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
cd /Users/marsal/Projects/sino-project/sino-mci
|
||
git add admin-web/src/views/MessageTest.vue
|
||
git commit -m "feat(web): add message test module"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: 联调与验证
|
||
|
||
- [ ] **Step 1: 启动基础设施**
|
||
|
||
```bash
|
||
cd /Users/marsal/Projects/sino-project/sino-mci
|
||
./scripts/local-up.sh --with-apps
|
||
```
|
||
|
||
- [ ] **Step 2: 创建初始租户和管理员**
|
||
|
||
```bash
|
||
curl -X POST http://localhost/msg-platform/api/v1/account/tenant \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"tenantCode":"platform","tenantName":"平台管理"}'
|
||
|
||
curl -X POST http://localhost/msg-platform/api/v1/account/user \
|
||
-H "Content-Type: application/json" \
|
||
-H "X-Tenant-Code: platform" \
|
||
-d '{"username":"admin","password":"admin123","realName":"平台管理员","roleCodes":["platform_admin"]}'
|
||
```
|
||
|
||
- [ ] **Step 3: 登录验证**
|
||
|
||
打开 `http://localhost/login`,使用 `platform / admin / admin123` 登录,确认进入 Dashboard。
|
||
|
||
- [ ] **Step 4: 运行测试**
|
||
|
||
```bash
|
||
cd /Users/marsal/Projects/sino-project/sino-mci/backend/mci-server && JAVA_HOME=/opt/homebrew/opt/openjdk@21 mvn test
|
||
cd /Users/marsal/Projects/sino-project/sino-mci/channel-service && source .venv/bin/activate && pytest
|
||
cd /Users/marsal/Projects/sino-project/sino-mci/admin-web && npm test
|
||
bash /Users/marsal/Projects/sino-project/sino-mci/scripts/test-external-api.sh
|
||
```
|
||
|
||
Expected: 全部通过
|
||
|
||
- [ ] **Step 5: Commit any fixes**
|
||
|
||
```bash
|
||
cd /Users/marsal/Projects/sino-project/sino-mci
|
||
git add .
|
||
git commit -m "fix: integration fixes for auth and admin-web"
|
||
```
|
||
|
||
---
|
||
|
||
## 计划自检
|
||
|
||
**Spec coverage:**
|
||
- 登录鉴权:Task 1-2
|
||
- 多租户管理:Task 2、Task 7
|
||
- 管理后台列表:Task 3、Task 8
|
||
- 会话绑定:Task 8
|
||
- Dashboard 统计:Task 3、Task 8
|
||
- 入站流程图:Task 4、Task 10
|
||
- 消息测试模块:Task 11
|
||
- 前端路由守卫:Task 6
|
||
|
||
**Placeholder scan:** 无 TBD/TODO/实现 later。
|
||
|
||
**Type consistency:** `LoginResponse` 复用现有字段;`AuthContext.AuthUser` 与 Controller 返回一致;`SendRecordResponse` 复用现有转换逻辑。
|