# 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` 复用现有转换逻辑。
