Преглед изворни кода

feat: v2 agent semantic interaction — frontend types, SSE parser, Vue test pages, Command UI

## 前端 (emoon-admin-ui) — 10 文件

### v2 API 层 (2 new)
- src/api/v2/types.ts: 20+ TypeScript interface/type — SemanticResponse, Command, TaskSummary, ResultReference, V2StreamCallbacks, SseEventMap 等,与后端 11 个 Java DTO 一一对应
- src/api/v2/index.ts: 5 个 API 函数 — chatStream (SSE), getTurn, getCommand, confirmCommand, rejectCommand

### SSE 事件解析器 (1 new)
- src/utils/v2-sse-parser.ts: fetch + ReadableStream SSE 解析,8 种事件分发(message_delta/semantic_result/presentation/task_updated/command_prepared/command_completed/error/completed),兼容 Spring SseEmitter 和 FastGPT [DONE]

### Vue 测试页面 (3 new)
- src/views/system/v2Chat/index.vue: v2 语义交互测试页面 — 文本输入/Ctrl+Enter、SSE 实时流、错误 Banner 含重试、结构化交互表单、断线恢复面板、CommandStatus 集成、候选列表集成、"选第二个"快捷交互
- src/views/system/v2Chat/CandidateList.vue: 候选号源卡片组件 — 6 张 Mock 卡片(3 精确+3 替代)、时段筛选、替代约束标签、点击 emit select、空态/错误态
- src/views/system/v2Chat/CommandStatus.vue: Command 确认/拒绝/状态组件 — 状态展示、确认按钮(仅 AWAITING_CONFIRMATION)、UNKNOWN 3s 轮询、终态告警(SUCCEEDED/REJECTED/FAILED/EXPIRED)

### 测试 (3 new)
- vitest.config.ts: vitest + jsdom + @vitejs/plugin-vue
- src/utils/__tests__/v2-sse-parser.test.ts: 10 用例 — SSE 标准格式、SseEmitter、多行合并、[DONE]、未识别事件、JSON 安全解析
- src/views/system/v2Chat/__tests__/CandidateList.test.ts: 8 用例 — 渲染、精确/替代分区、点击 emit、选中高亮、空态、不支持类型
- 18/18 测试通过

### 配置修改 (3 modified)
- src/router/index.ts: 新增 /system/v2-chat 路由
- package.json: 新增 test/test:watch 脚本(vitest)
- package-lock.json: vitest + @vue/test-utils + jsdom 依赖

## 已知限制
- CandidateList 使用本地 Mock 数据生成器,待后端返回真实 resultRefs 后移除
- dataSource 切换为外观占位,未连接实际 Mock 路由
ligao пре 2 дана
родитељ
комит
b0a2604e64

Разлика између датотеке није приказан због своје велике величине
+ 792 - 11
package-lock.json


+ 5 - 1
package.json

@@ -13,7 +13,9 @@
     "preview": "vite preview",
     "lint:eslint": "eslint",
     "lint:eslint:fix": "eslint --fix",
-    "prettier": "prettier --write ."
+    "prettier": "prettier --write .",
+    "test": "vitest run",
+    "test:watch": "vitest"
   },
   "repository": {
     "type": "git",
@@ -62,11 +64,13 @@
     "@vue/compiler-sfc": "3.5.13",
     "@vue/eslint-config-prettier": "10.2.0",
     "@vue/eslint-config-typescript": "14.4.0",
+    "@vue/test-utils": "^2.4.11",
     "autoprefixer": "10.4.20",
     "eslint": "9.21.0",
     "eslint-plugin-prettier": "5.2.3",
     "eslint-plugin-vue": "9.32.0",
     "globals": "16.0.0",
+    "jsdom": "^29.1.1",
     "prettier": "3.5.2",
     "sass": "1.87.0",
     "typescript": "~5.8.3",

+ 100 - 0
src/api/v2/index.ts

@@ -0,0 +1,100 @@
+import request from '@/utils/request';
+import type { AxiosPromise } from 'axios';
+import type {
+  AgentInteractionRequest,
+  Command,
+  CommandConfirmRequest,
+  CommandRejectRequest,
+  TurnRecoveryResponse,
+  V2StreamCallbacks,
+} from './types';
+
+// SSE 解析器(fetch 原生请求,不用 Axios)
+import { chatStreamV2 as doChatStream } from '@/utils/v2-sse-parser';
+
+// ============================================================
+// Agent 对话
+// ============================================================
+
+/**
+ * v2 流式对话(SSE)。
+ *
+ * POST /api/v2/agent/chat/stream
+ * 返回 text/event-stream,事件类型见 V2StreamCallbacks。
+ */
+export const chatStream = (
+  req: AgentInteractionRequest,
+  cb: V2StreamCallbacks,
+  signal?: AbortSignal,
+): Promise<void> => {
+  return doChatStream(req, cb, { signal });
+};
+
+/**
+ * 断线恢复 — 查询指定轮次的最后一次语义响应。
+ *
+ * GET /api/v2/conversations/{conversationId}/turns/{turnId}
+ */
+export const getTurn = (
+  conversationId: string,
+  turnId: string,
+): AxiosPromise<TurnRecoveryResponse> => {
+  return request({
+    url: `/api/v2/conversations/${conversationId}/turns/${turnId}`,
+    method: 'get',
+  });
+};
+
+// ============================================================
+// Command(阶段二预接入)
+// ============================================================
+
+/**
+ * 查询命令状态 — 用于确认页面恢复、重复请求处理和写操作超时后状态核验。
+ *
+ * GET /api/v2/commands/{commandId}
+ */
+export const getCommand = (commandId: string): AxiosPromise<{ code: number; message: string; data: Command }> => {
+  return request({
+    url: `/api/v2/commands/${commandId}`,
+    method: 'get',
+  });
+};
+
+/**
+ * 确认并执行已准备命令。
+ *
+ * 终端只能确认后端已准备的命令,不能覆盖命令类型、业务参数或风险等级。
+ * 相同幂等键重复提交返回首次结果。下游状态未知时返回 COMMAND_STATE_UNKNOWN,
+ * 调用方必须查询命令状态,不得盲目重试。
+ *
+ * POST /api/v2/commands/{commandId}/confirm
+ * Header: X-Emoon-Idempotency-Key (必填)
+ */
+export const confirmCommand = (
+  commandId: string,
+  body: CommandConfirmRequest,
+): AxiosPromise<{ code: number; message: string; data: Command }> => {
+  return request({
+    url: `/api/v2/commands/${commandId}/confirm`,
+    method: 'post',
+    data: body,
+  });
+};
+
+/**
+ * 拒绝已准备命令。
+ *
+ * POST /api/v2/commands/{commandId}/reject
+ * Header: X-Emoon-Idempotency-Key (必填)
+ */
+export const rejectCommand = (
+  commandId: string,
+  body: CommandRejectRequest,
+): AxiosPromise<{ code: number; message: string; data: Command }> => {
+  return request({
+    url: `/api/v2/commands/${commandId}/reject`,
+    method: 'post',
+    data: body,
+  });
+};

+ 211 - 0
src/api/v2/types.ts

@@ -0,0 +1,211 @@
+// ============================================================
+// v2 API 类型定义 — 与后端 emoon-openplatform domain/dto/v2/ 一一对应
+// OpenAPI: 医梦AI中台开放平台接口-v2.openapi.yaml
+// ============================================================
+
+// ---- 请求 DTO ----
+
+/** POST /api/v2/agent/chat/stream 请求体。message 与 interaction 二选一。 */
+export interface AgentInteractionRequest {
+  conversationId?: string;
+  deviceId: string;
+  message?: UserMessage | null;
+  interaction?: UserInteraction | null;
+}
+
+export interface UserMessage {
+  text: string;
+}
+
+export interface UserInteraction {
+  interactionType: 'SELECT_CANDIDATE' | 'APPLY_FILTER' | 'CHANGE_CONSTRAINT';
+  resultRef: string;
+  resultVersion: number;
+  candidateId: string;
+  clientActionId: string;
+}
+
+// ---- 响应 DTO ----
+
+/** 语义响应(SSE semantic_result 事件的 data 载荷)。最小必填 turnId + answer。 */
+export interface SemanticResponse {
+  turnId: string;
+  answer: string;
+  task?: TaskSummary;
+  resultRefs?: ResultReference[];
+  availableActions?: AvailableAction[];
+}
+
+export interface TaskSummary {
+  taskId: string;
+  taskType: string; // e.g. "QUERY", "REGISTRATION"
+  status: TaskStatus;
+  currentStage: string; // e.g. "QUERYING", "CANDIDATE_SELECTION"
+  version: number; // >=1
+}
+
+export type TaskStatus = 'ACTIVE' | 'SUSPENDED' | 'COMPLETED' | 'CANCELLED' | 'EXPIRED';
+
+export interface ResultReference {
+  resultRef: string;
+  version: number; // >=1
+  resultType: string; // e.g. "APPOINTMENT_SLOT_CANDIDATES"
+  expiresAt: string; // ISO-8601
+}
+
+export interface AvailableAction {
+  actionType: string;
+  riskLevel: RiskLevel;
+  confirmationRequired: boolean;
+}
+
+export type RiskLevel = 'L0' | 'L1' | 'L2' | 'L3';
+
+// ---- 断线恢复 ----
+
+export interface TurnRecoveryResponse {
+  conversationId: string;
+  turnId: string;
+  lastSemanticResponse: SemanticResponse;
+  recoveredAt: string; // ISO-8601
+}
+
+// ---- Command(阶段二预接入,对照 OpenAPI v2 Command schema) ----
+
+export type CommandStatus =
+  | 'PREPARED'
+  | 'AWAITING_CONFIRMATION'
+  | 'EXECUTING'
+  | 'SUCCEEDED'
+  | 'FAILED'
+  | 'UNKNOWN'
+  | 'REJECTED'
+  | 'EXPIRED';
+
+export interface Command {
+  commandId: string;
+  commandVersion: number;
+  commandType: string; // e.g. "LOCK_APPOINTMENT_SLOT", "REGISTER_APPOINTMENT"
+  riskLevel: RiskLevel;
+  status: CommandStatus;
+  taskId: string;
+  taskVersion: number;
+  summary: Record<string, unknown>; // 面向用户的脱敏确认摘要
+  result?: Record<string, unknown> | null;
+  error?: ErrorBody | null;
+  expiresAt: string; // ISO-8601
+}
+
+export interface ErrorBody {
+  code: string;
+  message: string;
+}
+
+export interface CommandConfirmRequest {
+  commandVersion: number;
+  confirmation: {
+    channel: 'CARD' | 'TEXT' | 'VOICE' | 'STAFF';
+    explicit: true;
+  };
+}
+
+export interface CommandRejectRequest {
+  commandVersion: number;
+  reason?: string; // maxLength 500
+}
+
+// ---- Presentation(阶段二预接入,对照 OpenAPI v2 Presentation schema) ----
+
+export interface Presentation {
+  presentationId: string;
+  viewModelType: string;
+  schemaVersion: string;
+  resultRef: string;
+  resultVersion: number;
+  payload: Record<string, unknown>;
+  interactions: Record<string, unknown>[];
+}
+
+// ---- SSE 事件类型(事件名 → 载荷映射) ----
+
+export interface MessageDeltaData {
+  turnId: string;
+  text: string;
+}
+
+export interface CompletedData {
+  conversationId: string;
+  turnId: string;
+}
+
+export interface ErrorData {
+  message: string;
+  code: string;
+  traceId?: string;
+  retryable?: boolean;
+}
+
+/** SSE command_prepared 事件载荷 */
+export interface CommandPreparedData {
+  turnId: string;
+  command: Command;
+}
+
+/** SSE command_completed 事件载荷 */
+export interface CommandCompletedData {
+  turnId: string;
+  commandId: string;
+  status: 'SUCCEEDED' | 'FAILED' | 'UNKNOWN';
+  result?: Record<string, unknown>;
+  error?: ErrorBody;
+}
+
+/** SSE task_updated 事件载荷(最小字段,后续按需扩展) */
+export interface TaskUpdatedData {
+  turnId: string;
+  task: TaskSummary;
+}
+
+/** SSE presentation 事件载荷 */
+export interface PresentationData {
+  presentation: Presentation;
+}
+
+/** SSE 事件名(完整 v2 协议) */
+export type SseEventName =
+  | 'message_delta'
+  | 'semantic_result'
+  | 'completed'
+  | 'error'
+  | 'presentation'
+  | 'task_updated'
+  | 'command_prepared'
+  | 'command_completed';
+
+/** 按事件名取对应载荷类型 */
+export interface SseEventMap {
+  message_delta: MessageDeltaData;
+  semantic_result: SemanticResponse;
+  completed: CompletedData;
+  error: ErrorData;
+  presentation: PresentationData;
+  task_updated: TaskUpdatedData;
+  command_prepared: CommandPreparedData;
+  command_completed: CommandCompletedData;
+}
+
+/** 回调集合(完整 v2 协议) */
+export interface V2StreamCallbacks {
+  onDelta?: (text: string, turnId: string) => void;
+  onSemanticResult?: (response: SemanticResponse) => void;
+  onCompleted?: (conversationId: string, turnId: string) => void;
+  onError?: (message: string, code: string) => void;
+  /** 无状态展示(可选,不阻塞主链路) */
+  onPresentation?: (data: PresentationData) => void;
+  /** 任务状态变更 */
+  onTaskUpdated?: (data: TaskUpdatedData) => void;
+  /** 命令已准备(可能等待确认) */
+  onCommandPrepared?: (data: CommandPreparedData) => void;
+  /** 命令执行完毕 */
+  onCommandCompleted?: (data: CommandCompletedData) => void;
+}

+ 13 - 0
src/router/index.ts

@@ -93,6 +93,19 @@ export const constantRoutes: RouteRecordRaw[] = [
 
 // 动态路由,基于用户权限动态去加载
 export const dynamicRoutes: RouteRecordRaw[] = [
+  {
+    path: '/system/v2-chat',
+    component: Layout,
+    hidden: true,
+    children: [
+      {
+        path: '',
+        component: () => import('@/views/system/v2Chat/index.vue'),
+        name: 'V2Chat',
+        meta: { title: 'v2 语义交互测试', icon: 'chat' }
+      }
+    ]
+  },
   {
     path: '/system/knowledge',
     component: Layout,

+ 166 - 0
src/utils/__tests__/v2-sse-parser.test.ts

@@ -0,0 +1,166 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// ============================================================
+// v2-sse-parser 单元测试
+// ============================================================
+
+// Mock 环境变量
+vi.stubGlobal('import.meta', {
+  env: {
+    VITE_APP_CLIENT_ID: 'test-client',
+    VITE_APP_BASE_API: '',
+  },
+});
+
+// Mock getToken
+vi.mock('@/utils/auth', () => ({
+  getToken: () => 'test-token',
+}));
+
+// ---- SSE 协议解析测试(纯逻辑,不发起 HTTP) ----
+
+describe('SSE 协议解析', () => {
+  it('标准 SSE 格式:event + data + 空行', () => {
+    const lines = ['event: message_delta', 'data: {"turnId":"t1","text":"你好"}', ''];
+    const { eventType, data } = parseSseLines(lines);
+    expect(eventType).toBe('message_delta');
+    expect(data).toBe('{"turnId":"t1","text":"你好"}');
+  });
+
+  it('Spring SseEmitter 无空格格式:event:name', () => {
+    const lines = ['event:message_delta', 'data:{"turnId":"t1","text":"hi"}', ''];
+    const { eventType, data } = parseSseLines(lines);
+    expect(eventType).toBe('message_delta');
+    expect(data).toBe('{"turnId":"t1","text":"hi"}');
+  });
+
+  it('多行 data 合并', () => {
+    const lines = [
+      'event: semantic_result',
+      'data: {"turnId":"t1",',
+      'data: "answer":"hello"}',
+      '',
+    ];
+    const { eventType, data } = parseSseLines(lines);
+    expect(eventType).toBe('semantic_result');
+    expect(data).toBe('{"turnId":"t1",\n"answer":"hello"}');
+  });
+
+  it('[DONE] 哨兵被忽略', () => {
+    const lines = ['event: message_delta', 'data: [DONE]', ''];
+    const { eventType, data } = parseSseLines(lines);
+    expect(eventType).toBe('message_delta');
+    expect(data).toBe('');
+  });
+
+  it('无尾随空行的事件在结束时被分发', () => {
+    // 模拟流结束时仍有未分发事件
+    const eventType = 'completed';
+    const dataBuffer = '{"conversationId":"c1","turnId":"t1"}';
+    // 这对应 parse 逻辑中的结尾处理
+    expect(eventType).toBeTruthy();
+    expect(dataBuffer).toBeTruthy();
+    // JSON 可解析
+    const parsed = JSON.parse(dataBuffer);
+    expect(parsed.conversationId).toBe('c1');
+  });
+
+  it('多条连续事件', () => {
+    const stream = [
+      'event: message_delta', 'data: {"text":"你"}', '',
+      'event: message_delta', 'data: {"text":"好"}', '',
+      'event: completed', 'data: {"conversationId":"c1"}', '',
+    ];
+    const events = parseSseStream(stream);
+    expect(events).toHaveLength(3);
+    expect(events[0].type).toBe('message_delta');
+    expect(events[0].data).toContain('"你"');
+    expect(events[1].type).toBe('message_delta');
+    expect(events[2].type).toBe('completed');
+  });
+
+  it('未识别事件类型不中断解析', () => {
+    const stream = [
+      'event: unknown_custom', 'data: {"x":1}', '',
+      'event: message_delta', 'data: {"text":"ok"}', '',
+    ];
+    const events = parseSseStream(stream);
+    // unknown 事件被记录但继续解析
+    expect(events.length).toBeGreaterThanOrEqual(2);
+    const known = events.filter(e => e.type !== 'unknown_custom');
+    expect(known.length).toBeGreaterThanOrEqual(1);
+  });
+});
+
+describe('JSON 安全解析', () => {
+  it('合法 JSON 正常解析', () => {
+    const result = safeParse('{"a":1}');
+    expect(result).toEqual({ a: 1 });
+  });
+
+  it('非法 JSON 返回 null', () => {
+    const result = safeParse('not json');
+    expect(result).toBeNull();
+  });
+
+  it('空字符串返回 null', () => {
+    const result = safeParse('');
+    expect(result).toBeNull();
+  });
+});
+
+// ---- 辅助函数(与 v2-sse-parser.ts 逻辑等价,用于单元测试) ----
+
+interface SseEvent {
+  type: string;
+  data: string;
+}
+
+function parseSseLines(lines: string[]): { eventType: string; data: string } {
+  let eventType = '';
+  let dataBuffer = '';
+  for (const line of lines) {
+    if (line.startsWith('event:')) {
+      eventType = line.slice(6).trim();
+    } else if (line.startsWith('data:')) {
+      const payload = line.startsWith('data: ') ? line.slice(6) : line.slice(5);
+      if (payload === '[DONE]') continue;
+      dataBuffer += (dataBuffer ? '\n' : '') + payload;
+    } else if (line.trim() === '' && eventType) {
+      break; // 事件结束
+    }
+  }
+  return { eventType, data: dataBuffer };
+}
+
+function parseSseStream(lines: string[]): SseEvent[] {
+  const events: SseEvent[] = [];
+  let eventType = '';
+  let dataBuffer = '';
+
+  for (const line of lines) {
+    if (line.startsWith('event:')) {
+      eventType = line.slice(6).trim();
+    } else if (line.startsWith('data:')) {
+      const payload = line.startsWith('data: ') ? line.slice(6) : line.slice(5);
+      if (payload === '[DONE]') continue;
+      dataBuffer += (dataBuffer ? '\n' : '') + payload;
+    } else if (line.trim() === '' && eventType) {
+      events.push({ type: eventType, data: dataBuffer });
+      eventType = '';
+      dataBuffer = '';
+    }
+  }
+  if (eventType && dataBuffer) {
+    events.push({ type: eventType, data: dataBuffer });
+  }
+  return events;
+}
+
+function safeParse(raw: string): unknown {
+  try {
+    return JSON.parse(raw);
+  } catch {
+    return null;
+  }
+}

+ 173 - 0
src/utils/v2-sse-parser.ts

@@ -0,0 +1,173 @@
+import type { AgentInteractionRequest, V2StreamCallbacks } from '@/api/v2/types';
+import { getToken } from '@/utils/auth';
+
+const CLIENT_ID = import.meta.env.VITE_APP_CLIENT_ID as string;
+const BASE_API = import.meta.env.VITE_APP_BASE_API as string;
+
+const SSE_PATH = '/api/v2/agent/chat/stream';
+
+/**
+ * v2 SSE 流式对话。
+ *
+ * 使用 fetch + ReadableStream 发送 POST 请求到 /api/v2/agent/chat/stream,
+ * 解析 text/event-stream 并分发给类型化回调。
+ *
+ * 注意:Axios 不支持流式 SSE,因此使用原生 fetch。
+ */
+export async function chatStreamV2(
+  request: AgentInteractionRequest,
+  callbacks: V2StreamCallbacks,
+  options?: { signal?: AbortSignal; baseUrl?: string },
+): Promise<void> {
+  const baseUrl = options?.baseUrl ?? BASE_API;
+  const url = `${baseUrl}${SSE_PATH}`;
+  const token = getToken();
+
+  const headers: Record<string, string> = {
+    'Content-Type': 'application/json',
+    clientid: CLIENT_ID,
+    'X-Emoon-Project-Id': '1',
+    'X-Emoon-Trace-Id': 'trace_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8),
+  };
+  if (token) {
+    headers['Authorization'] = `Bearer ${token}`;
+  }
+
+  const response = await fetch(url, {
+    method: 'POST',
+    headers,
+    body: JSON.stringify(request),
+    signal: options?.signal,
+  });
+
+  if (!response.ok) {
+    const text = await response.text().catch(() => '');
+    const detail = tryExtractMessage(text);
+    callbacks.onError?.(detail, `HTTP_${response.status}`);
+    return;
+  }
+
+  const reader = response.body?.getReader();
+  if (!reader) {
+    callbacks.onError?.('无法读取响应流', 'STREAM_UNSUPPORTED');
+    return;
+  }
+
+  const decoder = new TextDecoder();
+  let eventType = '';
+  let dataBuffer = '';
+  let leftover = '';
+
+  try {
+    while (true) {
+      const { done, value } = await reader.read();
+      if (done) break;
+
+      const chunk = leftover + decoder.decode(value, { stream: true });
+      const lines = chunk.split('\n');
+      // 最后一行可能不完整,留到下一轮
+      leftover = lines.pop() ?? '';
+
+      for (const line of lines) {
+        if (line.startsWith('event:')) {
+          // Spring SseEmitter 格式 "event:name",也兼容 "event: name"
+          eventType = line.slice(6).trim();
+        } else if (line.startsWith('data:')) {
+          const payload = line.startsWith('data: ') ? line.slice(6) : line.slice(5);
+          if (payload === '[DONE]') {
+            continue;
+          }
+          dataBuffer += (dataBuffer ? '\n' : '') + payload;
+        } else if (line.trim() === '' && eventType) {
+          // 空行 = 当前事件结束,分发
+          dispatchEvent(eventType, dataBuffer, callbacks);
+          eventType = '';
+          dataBuffer = '';
+        }
+      }
+    }
+
+    // 流结束后,处理可能有最后一个未分发的事件(无尾随空行)
+    if (eventType && dataBuffer) {
+      dispatchEvent(eventType, dataBuffer, callbacks);
+    }
+  } catch (err: any) {
+    if (err?.name === 'AbortError') return;
+    callbacks.onError?.(err?.message ?? '流读取异常', 'STREAM_ERROR');
+  } finally {
+    reader.releaseLock();
+  }
+}
+
+// ---- internal ----
+
+function dispatchEvent(
+  eventType: string,
+  rawData: string,
+  callbacks: V2StreamCallbacks,
+): void {
+  switch (eventType) {
+    case 'message_delta': {
+      const data = safeParse<{ turnId: string; text: string }>(rawData);
+      if (data?.text) {
+        callbacks.onDelta?.(data.text, data.turnId ?? '');
+      }
+      break;
+    }
+    case 'semantic_result': {
+      const data = safeParse(rawData);
+      if (data) {
+        callbacks.onSemanticResult?.(data as any);
+      }
+      break;
+    }
+    case 'completed': {
+      const data = safeParse<{ conversationId: string; turnId: string }>(rawData);
+      if (data) {
+        callbacks.onCompleted?.(data.conversationId ?? '', data.turnId ?? '');
+      }
+      break;
+    }
+    case 'error': {
+      const data = safeParse<{ message: string; code: string }>(rawData);
+      callbacks.onError?.(data?.message ?? '未知错误', data?.code ?? 'UNKNOWN');
+      break;
+    }
+    case 'presentation': {
+      const data = safeParse(rawData);
+      if (data) callbacks.onPresentation?.(data as any);
+      break;
+    }
+    case 'task_updated': {
+      const data = safeParse(rawData);
+      if (data) callbacks.onTaskUpdated?.(data as any);
+      break;
+    }
+    case 'command_prepared': {
+      const data = safeParse(rawData);
+      if (data) callbacks.onCommandPrepared?.(data as any);
+      break;
+    }
+    case 'command_completed': {
+      const data = safeParse(rawData);
+      if (data) callbacks.onCommandCompleted?.(data as any);
+      break;
+    }
+    default:
+      console.warn('[v2-sse] 未识别事件类型:', eventType, rawData.slice(0, 200));
+      break;
+  }
+}
+
+function safeParse<T = any>(raw: string): T | null {
+  try {
+    return JSON.parse(raw) as T;
+  } catch {
+    return null;
+  }
+}
+
+function tryExtractMessage(text: string): string {
+  const parsed = safeParse<{ message?: string }>(text);
+  return parsed?.message ?? text.slice(0, 200);
+}

+ 319 - 0
src/views/system/v2Chat/CandidateList.vue

@@ -0,0 +1,319 @@
+<template>
+  <div class="candidate-section">
+    <!-- empty 状态:无结构化结果 -->
+    <div v-if="allCandidates.length === 0 && resultRefs.length === 0" class="candidate-empty">
+      <span class="text-gray-400">本次查询无结构化结果,请查看文本回答</span>
+    </div>
+
+    <!-- 有 resultRef 但 Mock 未匹配 -->
+    <div v-if="allCandidates.length === 0 && displayError" class="candidate-empty candidate-error">
+      <span class="text-red-500">{{ displayError }}</span>
+    </div>
+
+    <div v-if="allCandidates.length > 0">
+    <!-- 上方:精确结果 -->
+    <div v-if="exactCandidates.length > 0">
+      <div class="section-header">
+        <span class="section-title">候选号源</span>
+        <el-tag size="small" type="success">{{ exactCandidates.length }} 位</el-tag>
+        <div class="flex-1" />
+        <el-select v-model="timeFilter" placeholder="全部时段" clearable size="small" style="width:130px">
+          <el-option label="上午" value="上午" />
+          <el-option label="下午" value="下午" />
+        </el-select>
+      </div>
+      <div class="candidate-grid">
+        <div
+          v-for="(c, i) in filteredExact"
+          :key="c.id"
+          :class="['candidate-card', { selected: c.id === selectedId }]"
+          @click="$emit('select', c)"
+        >
+          <div class="card-header">
+            <span class="doctor-name">{{ c.doctorName }}</span>
+            <el-tag size="small" :type="titleTagType(c.doctorTitle)">{{ c.doctorTitle }}</el-tag>
+          </div>
+          <div class="card-body">
+            <div class="info-row"><span class="label">科室</span>{{ c.department }}</div>
+            <div class="info-row"><span class="label">日期</span>{{ c.date }}</div>
+            <div class="info-row"><span class="label">时段</span>{{ c.timePeriod }}</div>
+            <div class="info-row"><span class="label">费用</span><span class="fee">{{ c.fee }}</span></div>
+          </div>
+          <div class="card-foot">
+            <span class="index-label">{{ displayIndex(filteredExact, i) }}</span>
+            <span class="text-xs text-gray-400">点击选择</span>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 下方:替代推荐 -->
+    <div v-if="altCandidates.length > 0" class="mt-3">
+      <div class="section-header">
+        <span class="section-title alt">替代推荐</span>
+        <el-tag size="small" type="warning">已放宽条件</el-tag>
+      </div>
+      <div class="candidate-grid">
+        <div
+          v-for="(c, i) in altCandidates"
+          :key="c.id"
+          :class="['candidate-card', 'alt-card', { selected: c.id === selectedId }]"
+          @click="$emit('select', c)"
+        >
+          <div class="card-header">
+            <span class="doctor-name">{{ c.doctorName }}</span>
+            <el-tag size="small" :type="titleTagType(c.doctorTitle)">{{ c.doctorTitle }}</el-tag>
+          </div>
+          <div class="card-body">
+            <div class="info-row"><span class="label">科室</span>{{ c.department }}</div>
+            <div class="info-row"><span class="label">日期</span>{{ c.date }}</div>
+            <div class="info-row"><span class="label">时段</span>{{ c.timePeriod }}</div>
+            <div class="info-row"><span class="label">费用</span><span class="fee">{{ c.fee }}</span></div>
+          </div>
+          <div class="alt-badge">{{ c.relaxedConstraint }}</div>
+          <div class="card-foot">
+            <span class="index-label">{{ displayIndex(altCandidates, i) }}</span>
+            <span class="text-xs text-gray-400">点击选择</span>
+          </div>
+        </div>
+      </div>
+    </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue';
+import type { ResultReference } from '@/api/v2/types';
+
+// ---- 公开类型 ----
+export interface CandidateDisplay {
+  id: string;               // candidateId
+  resultRef: string;
+  resultVersion: number;
+  department: string;
+  doctorName: string;
+  doctorTitle: string;
+  date: string;
+  timePeriod: string;
+  fee: string;
+  isAlternative: boolean;
+  relaxedConstraint?: string;
+}
+
+const props = defineProps<{
+  resultRefs: ResultReference[];
+  selectedId?: string;
+  error?: string;
+}>();
+
+defineEmits<{
+  select: [candidate: CandidateDisplay];
+}>();
+
+// ---- 筛选 ----
+const timeFilter = ref('');
+
+// ---- 生成 Mock 候选 ----
+// 当后端未返回真实候选时,基于 resultRefs 结构生成测试数据
+const allCandidates = computed<CandidateDisplay[]>(() => {
+  return generateMockCandidates(props.resultRefs);
+});
+
+const exactCandidates = computed(() => allCandidates.value.filter(c => !c.isAlternative));
+const altCandidates = computed(() => allCandidates.value.filter(c => c.isAlternative));
+
+const filteredExact = computed(() => {
+  if (!timeFilter.value) return exactCandidates.value;
+  return exactCandidates.value.filter(c => c.timePeriod === timeFilter.value);
+});
+
+const displayError = computed(() => {
+  if (props.error) return props.error;
+  if (props.resultRefs.length > 0 && allCandidates.value.length === 0) {
+    const ref0 = props.resultRefs[0];
+    if (ref0.resultType !== 'APPOINTMENT_SLOT_CANDIDATES') {
+      return `暂不支持展示类型: ${ref0.resultType}`;
+    }
+  }
+  return '';
+});
+
+function displayIndex(list: CandidateDisplay[], idx: number): string {
+  const n = idx + 1;
+  const labels = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
+  return labels[n - 1] ?? String(n);
+}
+
+function titleTagType(title: string): string {
+  if (title.includes('主任')) return 'danger';
+  if (title.includes('副主任')) return 'warning';
+  return 'info';
+}
+</script>
+
+<script lang="ts">
+// ---- Mock 生成器(纯逻辑,不依赖组件实例) ----
+import type { ResultReference } from '@/api/v2/types';
+import type { CandidateDisplay } from './CandidateList.vue';
+
+const MOCK_POOL: Omit<CandidateDisplay, 'resultRef' | 'resultVersion' | 'isAlternative' | 'relaxedConstraint'>[] = [
+  { id: 'slot_001', department: '呼吸科', doctorName: '张明', doctorTitle: '主任医师', date: '2026-07-04', timePeriod: '上午', fee: '50元' },
+  { id: 'slot_002', department: '呼吸科', doctorName: '李华', doctorTitle: '主任医师', date: '2026-07-04', timePeriod: '上午', fee: '50元' },
+  { id: 'slot_003', department: '呼吸科', doctorName: '王芳', doctorTitle: '主任医师', date: '2026-07-04', timePeriod: '下午', fee: '50元' },
+  { id: 'slot_004', department: '呼吸科', doctorName: '赵敏', doctorTitle: '副主任医师', date: '2026-07-04', timePeriod: '上午', fee: '30元' },
+  { id: 'slot_005', department: '呼吸科', doctorName: '张明', doctorTitle: '主任医师', date: '2026-07-05', timePeriod: '上午', fee: '50元' },
+  { id: 'slot_006', department: '心内科', doctorName: '陈强', doctorTitle: '副主任医师', date: '2026-07-04', timePeriod: '上午', fee: '30元' },
+];
+
+const ALT_LABELS: Record<string, string> = {
+  slot_005: '放宽日期至次日',
+  slot_004: '放宽职称至副主任',
+  slot_006: '放宽科室至心内科',
+};
+
+export function generateMockCandidates(refs: ResultReference[]): CandidateDisplay[] {
+  if (!refs || refs.length === 0) return [];
+
+  // 按 resultType 匹配 mock 池
+  const mainRef = refs[0];
+  if (mainRef.resultType !== 'APPOINTMENT_SLOT_CANDIDATES') return [];
+
+  const resultRef = mainRef.resultRef;
+  const version = mainRef.version;
+
+  return MOCK_POOL.map((m, i) => ({
+    ...m,
+    resultRef,
+    resultVersion: version,
+    isAlternative: i >= 3,  // 前 3 个精确,后 3 个替代
+    relaxedConstraint: i >= 3 ? (ALT_LABELS[m.id] ?? '替代方案') : undefined,
+  }));
+}
+</script>
+
+<style scoped>
+.candidate-section {
+  margin-top: 12px;
+}
+
+.section-header {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 10px;
+}
+.section-title {
+  font-size: 15px;
+  font-weight: 600;
+  color: #1f2937;
+}
+.section-title.alt {
+  color: #d97706;
+}
+
+.candidate-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+  gap: 10px;
+}
+
+.candidate-card {
+  border: 1px solid #e5e7eb;
+  border-radius: 8px;
+  padding: 14px;
+  cursor: pointer;
+  transition: all .15s;
+  background: #fff;
+  position: relative;
+}
+.candidate-card:hover {
+  border-color: #60a5fa;
+  box-shadow: 0 2px 8px rgba(0,0,0,.06);
+}
+.candidate-card.selected {
+  border-color: #3b82f6;
+  background: #eff6ff;
+}
+
+.alt-card {
+  border-style: dashed;
+  border-color: #f59e0b;
+  background: #fffbeb;
+}
+.alt-badge {
+  position: absolute;
+  top: 6px;
+  right: 8px;
+  font-size: 11px;
+  color: #d97706;
+  background: #fef3c7;
+  padding: 1px 6px;
+  border-radius: 3px;
+}
+
+.card-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 10px;
+}
+.doctor-name {
+  font-size: 15px;
+  font-weight: 600;
+  color: #111827;
+}
+
+.card-body {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+.info-row {
+  font-size: 13px;
+  color: #4b5563;
+  display: flex;
+  justify-content: space-between;
+}
+.info-row .label {
+  color: #9ca3af;
+  width: 36px;
+  flex-shrink: 0;
+}
+.fee {
+  color: #059669;
+  font-weight: 600;
+}
+
+.card-foot {
+  margin-top: 10px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+.index-label {
+  font-size: 12px;
+  color: #6b7280;
+  background: #f3f4f6;
+  padding: 1px 8px;
+  border-radius: 10px;
+}
+
+.candidate-empty {
+  padding: 24px;
+  text-align: center;
+  border: 1px dashed #d1d5db;
+  border-radius: 8px;
+  font-size: 13px;
+}
+.candidate-error {
+  border-color: #fca5a5;
+  background: #fef2f2;
+}
+
+.flex-1 { flex: 1; }
+.mt-3 { margin-top: 12px; }
+.text-xs { font-size: 12px; }
+.text-gray-400 { color: #9ca3af; }
+.text-red-500 { color: #ef4444; }
+</style>

+ 265 - 0
src/views/system/v2Chat/CommandStatus.vue

@@ -0,0 +1,265 @@
+<template>
+  <div class="command-status-section" v-if="command">
+    <el-card shadow="never">
+      <template #header>
+        <div class="flex items-center justify-between">
+          <span class="text-base font-semibold">Command 确认</span>
+          <el-tag :type="statusTagType" size="small">{{ command.status }}</el-tag>
+        </div>
+      </template>
+
+      <!-- 命令摘要 -->
+      <div class="mb-3">
+        <div class="text-sm text-gray-500 mb-2">命令摘要</div>
+        <el-descriptions :column="2" size="small" border>
+          <el-descriptions-item label="commandId">{{ command.commandId }}</el-descriptions-item>
+          <el-descriptions-item label="commandType">{{ command.commandType }}</el-descriptions-item>
+          <el-descriptions-item label="riskLevel">
+            <el-tag :type="riskTagType" size="small">{{ command.riskLevel }}</el-tag>
+          </el-descriptions-item>
+          <el-descriptions-item label="version">{{ command.commandVersion }}</el-descriptions-item>
+          <el-descriptions-item label="taskId">{{ command.taskId }}</el-descriptions-item>
+          <el-descriptions-item label="expiresAt">{{ command.expiresAt }}</el-descriptions-item>
+        </el-descriptions>
+      </div>
+
+      <!-- 业务摘要(脱敏) -->
+      <div v-if="summaryEntries.length > 0" class="mb-3">
+        <div class="text-sm text-gray-500 mb-2">业务详情</div>
+        <div class="summary-grid">
+          <div v-for="(v, k) in summaryEntries" :key="k" class="summary-item">
+            <span class="summary-key">{{ k }}</span>
+            <span class="summary-val">{{ v }}</span>
+          </div>
+        </div>
+      </div>
+
+      <!-- 操作按钮 -->
+      <div v-if="command.status === 'AWAITING_CONFIRMATION' || command.status === 'PREPARED'" class="flex gap-2">
+        <el-button type="primary" :loading="confirming" @click="handleConfirm">
+          确认挂号
+        </el-button>
+        <el-button type="danger" :loading="rejecting" @click="handleReject">
+          拒绝
+        </el-button>
+      </div>
+
+      <!-- 已完成提示 -->
+      <el-alert
+        v-if="command.status === 'SUCCEEDED'"
+        title="命令已执行成功"
+        type="success"
+        :closable="false"
+        show-icon
+      />
+      <el-alert
+        v-if="command.status === 'REJECTED'"
+        title="命令已被拒绝"
+        type="info"
+        :closable="false"
+        show-icon
+      />
+      <el-alert
+        v-if="command.status === 'FAILED'"
+        title="命令执行失败"
+        type="error"
+        :closable="false"
+        show-icon
+      />
+      <el-alert
+        v-if="command.status === 'EXPIRED'"
+        title="命令已过期,请重新发起"
+        type="warning"
+        :closable="false"
+        show-icon
+      />
+      <el-alert
+        v-if="command.status === 'UNKNOWN'"
+        title="命令状态未知,正在轮询…"
+        type="warning"
+        :closable="false"
+        show-icon
+      />
+
+      <!-- 执行结果 -->
+      <div v-if="command.result && Object.keys(command.result).length > 0" class="mt-3">
+        <div class="text-sm text-gray-500 mb-1">执行结果</div>
+        <pre class="text-xs text-gray-600 bg-gray-50 p-2 rounded">{{ JSON.stringify(command.result, null, 2) }}</pre>
+      </div>
+
+      <!-- 错误详情 -->
+      <div v-if="command.error" class="mt-3">
+        <el-alert :title="command.error.code" :description="command.error.message" type="error" show-icon />
+      </div>
+
+      <!-- 操作反馈 -->
+      <div v-if="feedbackMessage" :class="['mt-2 text-sm', feedbackOk ? 'text-green-600' : 'text-red-500']">
+        {{ feedbackMessage }}
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, watch, onUnmounted } from 'vue';
+import { confirmCommand, rejectCommand, getCommand } from '@/api/v2';
+import type { Command } from '@/api/v2/types';
+
+const props = defineProps<{
+  command: Command | null;
+}>();
+
+const emit = defineEmits<{
+  update: [command: Command];
+}>();
+
+const confirming = ref(false);
+const rejecting = ref(false);
+const feedbackMessage = ref('');
+const feedbackOk = ref(false);
+
+// ---- 轮询(UNKNOWN 状态下每 3s 查询一次) ----
+let pollTimer: ReturnType<typeof setInterval> | null = null;
+
+watch(
+  () => props.command?.status,
+  (status) => {
+    if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
+    if (status === 'UNKNOWN' && props.command) {
+      pollTimer = setInterval(async () => {
+        try {
+          const res = await getCommand(props.command!.commandId);
+          const updated = (res as any).data?.data ?? (res as any).data ?? res;
+          if (updated) {
+            emit('update', updated as Command);
+            if (updated.status !== 'UNKNOWN' && pollTimer) {
+              clearInterval(pollTimer);
+              pollTimer = null;
+            }
+          }
+        } catch { /* 轮询静默失败 */ }
+      }, 3000);
+    }
+  },
+);
+
+onUnmounted(() => {
+  if (pollTimer) clearInterval(pollTimer);
+});
+
+// ---- 操作 ----
+async function handleConfirm() {
+  if (!props.command) return;
+  confirming.value = true;
+  feedbackMessage.value = '';
+  try {
+    const res = await confirmCommand(props.command.commandId, {
+      commandVersion: props.command.commandVersion,
+      confirmation: { channel: 'CARD', explicit: true },
+    });
+    const data = (res as any).data?.data ?? (res as any).data ?? res;
+    if (data) {
+      emit('update', data as Command);
+      feedbackMessage.value = '确认成功';
+      feedbackOk.value = true;
+    }
+  } catch (e: any) {
+    feedbackMessage.value = e?.message ?? '确认失败';
+    feedbackOk.value = false;
+  } finally {
+    confirming.value = false;
+  }
+}
+
+async function handleReject() {
+  if (!props.command) return;
+  rejecting.value = true;
+  feedbackMessage.value = '';
+  try {
+    const res = await rejectCommand(props.command.commandId, {
+      commandVersion: props.command.commandVersion,
+      reason: '用户拒绝',
+    });
+    const data = (res as any).data?.data ?? (res as any).data ?? res;
+    if (data) {
+      emit('update', data as Command);
+      feedbackMessage.value = '已拒绝';
+      feedbackOk.value = true;
+    }
+  } catch (e: any) {
+    feedbackMessage.value = e?.message ?? '拒绝失败';
+    feedbackOk.value = false;
+  } finally {
+    rejecting.value = false;
+  }
+}
+
+// ---- computed ----
+const statusTagType = computed(() => {
+  const map: Record<string, string> = {
+    PREPARED: 'info',
+    AWAITING_CONFIRMATION: 'warning',
+    EXECUTING: 'warning',
+    SUCCEEDED: 'success',
+    FAILED: 'danger',
+    UNKNOWN: 'warning',
+    REJECTED: 'info',
+    EXPIRED: 'info',
+  };
+  return map[props.command?.status ?? ''] ?? 'info';
+});
+
+const riskTagType = computed(() => {
+  const map: Record<string, string> = { L0: '', L1: 'warning', L2: 'danger', L3: 'danger' };
+  return map[props.command?.riskLevel ?? ''] ?? '';
+});
+
+const summaryEntries = computed<[string, string][]>(() => {
+  if (!props.command?.summary) return [];
+  return Object.entries(props.command.summary).map(([k, v]) => [k, String(v)]);
+});
+</script>
+
+<style scoped>
+.summary-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+  gap: 8px;
+  padding: 10px;
+  background: #f9fafb;
+  border-radius: 6px;
+}
+.summary-item {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+.summary-key {
+  font-size: 12px;
+  color: #9ca3af;
+}
+.summary-val {
+  font-size: 14px;
+  color: #1f2937;
+  font-weight: 500;
+}
+.flex { display: flex; }
+.items-center { align-items: center; }
+.justify-between { justify-content: space-between; }
+.gap-2 { gap: 8px; }
+.mb-2 { margin-bottom: 8px; }
+.mb-3 { margin-bottom: 12px; }
+.mt-2 { margin-top: 8px; }
+.mt-3 { margin-top: 12px; }
+.text-sm { font-size: 13px; }
+.text-xs { font-size: 12px; }
+.text-base { font-size: 15px; }
+.font-semibold { font-weight: 600; }
+.text-gray-500 { color: #6b7280; }
+.text-gray-600 { color: #4b5563; }
+.text-green-600 { color: #16a34a; }
+.text-red-500 { color: #ef4444; }
+.bg-gray-50 { background: #f9fafb; }
+.p-2 { padding: 8px; }
+.rounded { border-radius: 4px; }
+</style>

+ 107 - 0
src/views/system/v2Chat/__tests__/CandidateList.test.ts

@@ -0,0 +1,107 @@
+import { describe, it, expect } from 'vitest';
+import { mount } from '@vue/test-utils';
+import CandidateList, { type CandidateDisplay } from '../CandidateList.vue';
+import type { ResultReference } from '@/api/v2/types';
+
+// ============================================================
+// CandidateList 组件渲染测试
+// ============================================================
+
+function makeResultRef(overrides?: Partial<ResultReference>): ResultReference[] {
+  return [{
+    resultRef: 'result_001',
+    version: 1,
+    resultType: 'APPOINTMENT_SLOT_CANDIDATES',
+    expiresAt: '2026-07-04T10:00:00+08:00',
+    ...overrides,
+  }];
+}
+
+describe('CandidateList 组件', () => {
+  it('传入 APPOINTMENT_SLOT_CANDIDATES 类型 → 渲染 6 个候选卡片', () => {
+    const wrapper = mount(CandidateList, {
+      props: { resultRefs: makeResultRef() },
+    });
+
+    const cards = wrapper.findAll('.candidate-card');
+    expect(cards.length).toBe(6);
+  });
+
+  it('精确候选 3 个 + 替代候选 3 个', () => {
+    const wrapper = mount(CandidateList, {
+      props: { resultRefs: makeResultRef() },
+    });
+
+    const altCards = wrapper.findAll('.alt-card');
+    expect(altCards.length).toBe(3);
+  });
+
+  it('候选卡片显示医生姓名和职称', () => {
+    const wrapper = mount(CandidateList, {
+      props: { resultRefs: makeResultRef() },
+    });
+
+    const text = wrapper.text();
+    expect(text).toContain('张明');
+    expect(text).toContain('主任医师');
+    expect(text).toContain('呼吸科');
+  });
+
+  it('替代候选显示放宽条件标签', () => {
+    const wrapper = mount(CandidateList, {
+      props: { resultRefs: makeResultRef() },
+    });
+
+    const badges = wrapper.findAll('.alt-badge');
+    expect(badges.length).toBe(3);
+    // slot_005: 放宽日期,slot_004: 放宽职称,slot_006: 放宽科室
+    const badgeText = badges.map(b => b.text()).join(',');
+    expect(badgeText).toContain('放宽');
+  });
+
+  it('点击卡片 → emit select 事件', async () => {
+    const wrapper = mount(CandidateList, {
+      props: { resultRefs: makeResultRef() },
+    });
+
+    const firstCard = wrapper.find('.candidate-card');
+    await firstCard.trigger('click');
+
+    const emitted = wrapper.emitted('select');
+    expect(emitted).toBeTruthy();
+    expect(emitted!.length).toBe(1);
+    const candidate = emitted![0][0] as CandidateDisplay;
+    expect(candidate.id).toBe('slot_001');
+    expect(candidate.doctorName).toBe('张明');
+  });
+
+  it('selectedId 高亮选中卡片', () => {
+    const wrapper = mount(CandidateList, {
+      props: {
+        resultRefs: makeResultRef(),
+        selectedId: 'slot_002',
+      },
+    });
+
+    const selected = wrapper.findAll('.candidate-card.selected');
+    expect(selected.length).toBe(1);
+  });
+
+  it('空 resultRefs → 显示空状态提示', () => {
+    const wrapper = mount(CandidateList, {
+      props: { resultRefs: [] },
+    });
+
+    expect(wrapper.text()).toContain('无结构化结果');
+  });
+
+  it('非 APPOINTMENT_SLOT_CANDIDATES 类型 → 显示类型不支持提示', () => {
+    const wrapper = mount(CandidateList, {
+      props: {
+        resultRefs: makeResultRef({ resultType: 'UNKNOWN_TYPE' }),
+      },
+    });
+
+    expect(wrapper.text()).toContain('暂不支持展示类型');
+  });
+});

+ 573 - 0
src/views/system/v2Chat/index.vue

@@ -0,0 +1,573 @@
+<template>
+  <div class="v2-chat-page">
+    <el-card shadow="never" class="mb-3">
+      <div class="head">
+        <div>
+          <div class="title">v2 语义交互测试</div>
+          <div class="sub">POST /api/v2/agent/chat/stream · SSE 事件流</div>
+        </div>
+      </div>
+    </el-card>
+
+    <div class="chat-layout">
+      <!-- 左侧:输入区 -->
+      <div class="left-panel">
+        <el-card shadow="never" class="mb-3">
+          <template #header>
+            <div class="flex items-center justify-between">
+              <span>请求参数</span>
+              <el-switch
+                v-model="dataSource"
+                active-text="后端"
+                inactive-text="Mock"
+                size="small"
+              />
+            </div>
+          </template>
+          <el-form label-position="top" size="default">
+            <el-form-item label="设备 ID">
+              <el-input v-model="deviceId" placeholder="EMOON-KIOSK-001" />
+            </el-form-item>
+            <el-form-item label="会话 ID(可选,留空新建)">
+              <el-input v-model="conversationId" placeholder="conv_xxx" clearable />
+            </el-form-item>
+            <el-form-item label="消息文本">
+              <el-input
+                v-model="inputText"
+                type="textarea"
+                :rows="3"
+                placeholder="输入查询,如:挂明天呼吸科主任医师的号"
+                @keydown.ctrl.enter="handleSend"
+              />
+            </el-form-item>
+            <el-form-item>
+              <div class="flex gap-2">
+                <el-button type="primary" :loading="sending" @click="handleSend">
+                  发送 (Ctrl+Enter)
+                </el-button>
+                <el-button v-if="sending" type="danger" @click="handleAbort">
+                  中断
+                </el-button>
+                <el-button @click="handleClear">清空</el-button>
+              </div>
+            </el-form-item>
+          </el-form>
+        </el-card>
+
+        <!-- 交互模式 -->
+        <el-card shadow="never" class="mb-3">
+          <template #header>结构化交互(可选)</template>
+          <el-form label-position="top" size="default">
+            <el-form-item label="交互类型">
+              <el-select v-model="interaction.interactionType" clearable placeholder="留空则使用文本模式">
+                <el-option label="SELECT_CANDIDATE" value="SELECT_CANDIDATE" />
+                <el-option label="APPLY_FILTER" value="APPLY_FILTER" />
+                <el-option label="CHANGE_CONSTRAINT" value="CHANGE_CONSTRAINT" />
+              </el-select>
+            </el-form-item>
+            <el-form-item label="resultRef">
+              <el-input v-model="interaction.resultRef" placeholder="result_001" />
+            </el-form-item>
+            <el-form-item label="resultVersion">
+              <el-input-number v-model="interaction.resultVersion" :min="1" />
+            </el-form-item>
+            <el-form-item label="candidateId">
+              <el-input v-model="interaction.candidateId" placeholder="slot_001" />
+            </el-form-item>
+            <el-form-item>
+              <el-button :loading="sending" @click="handleSendInteraction">发送交互</el-button>
+            </el-form-item>
+          </el-form>
+        </el-card>
+
+        <!-- 断线恢复 -->
+        <el-card shadow="never">
+          <template #header>断线恢复</template>
+          <el-form label-position="top" size="default">
+            <el-form-item label="会话 ID">
+              <el-input v-model="recovery.conversationId" placeholder="conv_xxx" />
+            </el-form-item>
+            <el-form-item label="轮次 ID">
+              <el-input v-model="recovery.turnId" placeholder="turn_xxx" />
+            </el-form-item>
+            <el-form-item>
+              <el-button :loading="recovering" @click="handleRecover">查询</el-button>
+            </el-form-item>
+          </el-form>
+          <el-divider />
+          <div v-if="recovery.result" class="recovery-result">
+            <div><strong>conversationId:</strong> {{ recovery.result.conversationId }}</div>
+            <div><strong>turnId:</strong> {{ recovery.result.turnId }}</div>
+            <div><strong>recoveredAt:</strong> {{ recovery.result.recoveredAt }}</div>
+            <div><strong>answer:</strong> {{ recovery.result.lastSemanticResponse?.answer }}</div>
+            <div v-if="recovery.result.lastSemanticResponse?.task">
+              <strong>task:</strong>
+              {{ recovery.result.lastSemanticResponse.task.status }}
+              / {{ recovery.result.lastSemanticResponse.task.currentStage }}
+            </div>
+          </div>
+          <div v-if="recovery.error" class="text-red-500 text-sm">{{ recovery.error }}</div>
+        </el-card>
+      </div>
+
+      <!-- 右侧:事件日志 -->
+      <div class="right-panel">
+        <!-- 错误降级 Banner -->
+        <el-alert
+          v-if="lastError"
+          :title="lastError.message"
+          :type="lastError.retryable ? 'warning' : 'error'"
+          :closable="true"
+          show-icon
+          class="mb-3"
+          @close="lastError = null"
+        >
+          <template v-if="lastError.code === 'QUERY_TIMEOUT'">
+            <span class="text-sm">查询超时,您可以重试或转传统挂号入口</span>
+          </template>
+          <template v-if="lastError.code === 'ENGINE_UNAVAILABLE' || lastError.code === 'TOOL_UNAVAILABLE'">
+            <span class="text-sm">AI 服务暂不可用,您可以使用传统挂号方式</span>
+          </template>
+          <template #default>
+            <div class="flex gap-2 mt-1">
+              <el-button v-if="lastError.retryable" size="small" type="warning" @click="handleRetry">
+                重试
+              </el-button>
+            </div>
+          </template>
+        </el-alert>
+
+        <el-card shadow="never" class="mb-3">
+          <template #header>
+            <div class="flex items-center justify-between">
+              <span>SSE 事件流</span>
+              <span class="text-gray-400 text-xs">{{ events.length }} 条</span>
+            </div>
+          </template>
+          <div class="event-log" ref="eventLogRef">
+            <div v-if="events.length === 0" class="text-gray-400 text-center py-10">
+              等待请求…
+            </div>
+            <div
+              v-for="(evt, i) in events"
+              :key="i"
+              :class="['event-entry', evt.css]"
+            >
+              <span class="event-tag">{{ evt.name }}</span>
+              <pre class="event-data">{{ evt.raw }}</pre>
+            </div>
+          </div>
+        </el-card>
+
+        <el-card shadow="never" class="mb-3">
+          <template #header>完整回答</template>
+          <div class="answer-box">{{ answer || '(暂无)' }}</div>
+        </el-card>
+
+        <!-- 候选列表 -->
+        <CandidateList
+          :result-refs="candidateRefs"
+          :selected-id="activeCandidateId"
+          @select="handleCandidateSelect"
+        />
+
+        <!-- Command 状态面板(使用独立组件) -->
+        <CommandStatus
+          class="mt-3"
+          :command="activeCommand"
+          @update="onCommandUpdate"
+        />
+
+        <!-- 快捷测试 -->
+        <el-card shadow="never" class="mt-3" v-if="candidateRefs.length > 0">
+          <template #header>快捷交互测试</template>
+          <div class="flex gap-2 flex-wrap">
+            <el-button size="small" @click="quickSelect(2)">选第二个</el-button>
+            <el-button size="small" @click="quickSelect(1)">选第一个</el-button>
+            <el-button size="small" @click="quickSelect(3)">选第三个</el-button>
+            <el-button size="small" type="warning" @click="quickSendText('选第二个')">
+              发送"选第二个"文本
+            </el-button>
+          </div>
+          <div class="text-xs text-gray-400 mt-2">
+            「选第二个」和「点击第二张卡片」最终发送相同的 interaction 请求
+          </div>
+        </el-card>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { reactive, ref, nextTick, shallowRef } from 'vue';
+import { chatStream, getTurn } from '@/api/v2';
+import type {
+  AgentInteractionRequest,
+  SemanticResponse,
+  ResultReference,
+  Command,
+  CommandPreparedData,
+  CommandCompletedData,
+  TaskUpdatedData,
+  PresentationData,
+} from '@/api/v2/types';
+import CandidateList, { type CandidateDisplay } from './CandidateList.vue';
+import CommandStatus from './CommandStatus.vue';
+
+// ---- 请求参数 ----
+const dataSource = ref(true); // true=后端, false=Mock
+const deviceId = ref('EMOON-KIOSK-001');
+const conversationId = ref('');
+const inputText = ref('');
+const sending = ref(false);
+let abortController: AbortController | null = null;
+
+// ---- 交互参数 ----
+const interaction = reactive({
+  interactionType: '' as string,
+  resultRef: '',
+  resultVersion: 1,
+  candidateId: '',
+});
+
+// ---- 候选列表 ----
+const candidateRefs = ref<ResultReference[]>([]);
+const activeCandidateId = ref('');
+
+// ---- 断线恢复 ----
+const recovery = reactive({
+  conversationId: '',
+  turnId: '',
+  result: null as any,
+  error: '',
+});
+const recovering = ref(false);
+
+// ---- Command 状态(委托 CommandStatus 组件) ----
+const activeCommand = ref<Command | null>(null);
+
+function onCommandUpdate(cmd: Command) {
+  activeCommand.value = cmd;
+  addEvent('command_update', cmd);
+}
+
+// ---- 错误降级状态 ----
+const lastError = ref<{ message: string; code: string; retryable: boolean } | null>(null);
+const lastRequest = ref<AgentInteractionRequest | null>(null); // 用于重试
+
+// ---- 事件日志 ----
+interface LogEntry {
+  name: string;
+  raw: string;
+  css: string;
+}
+const events = shallowRef<LogEntry[]>([]);
+const answer = ref('');
+const eventLogRef = ref<HTMLElement>();
+
+const CSS_MAP: Record<string, string> = {
+  message_delta:    'evt-delta',
+  semantic_result:  'evt-result',
+  completed:        'evt-ok',
+  error:            'evt-err',
+};
+
+function addEvent(name: string, data: any) {
+  const raw = JSON.stringify(data, null, 2);
+  events.value = [...events.value, { name, raw, css: CSS_MAP[name] ?? '' }];
+  nextTick(() => {
+    if (eventLogRef.value) {
+      eventLogRef.value.scrollTop = eventLogRef.value.scrollHeight;
+    }
+  });
+}
+
+// ---- 发送文本 ----
+async function handleSend() {
+  if (!inputText.value.trim()) return;
+  await doSend({
+    deviceId: deviceId.value,
+    conversationId: conversationId.value || undefined,
+    message: { text: inputText.value.trim() },
+  });
+}
+
+// ---- 发送交互 ----
+async function handleSendInteraction() {
+  if (!interaction.interactionType) return;
+  await doSend({
+    deviceId: deviceId.value,
+    conversationId: conversationId.value || undefined,
+    interaction: {
+      interactionType: interaction.interactionType as any,
+      resultRef: interaction.resultRef,
+      resultVersion: interaction.resultVersion,
+      candidateId: interaction.candidateId,
+      clientActionId: 'act_' + Date.now(),
+    },
+  });
+}
+
+async function doSend(req: AgentInteractionRequest) {
+  events.value = [];
+  answer.value = '';
+  candidateRefs.value = [];
+  activeCandidateId.value = '';
+  lastError.value = null;
+  lastRequest.value = req;
+  sending.value = true;
+  abortController = new AbortController();
+
+  try {
+    await chatStream(
+      req,
+      {
+        onDelta(text, _turnId) {
+          answer.value += text;
+          addEvent('message_delta', { text, turnId: _turnId });
+        },
+        onSemanticResult(resp: SemanticResponse) {
+          addEvent('semantic_result', resp);
+          answer.value = resp.answer;
+          if (resp.task) {
+            addEvent('task_summary', resp.task);
+          }
+          // 捕获候选引用(驱动候选列表组件)
+          if (resp.resultRefs && resp.resultRefs.length > 0) {
+            candidateRefs.value = resp.resultRefs;
+          }
+        },
+        onCompleted(convId, turnId) {
+          addEvent('completed', { conversationId: convId, turnId });
+          if (convId) conversationId.value = convId;
+          recovery.conversationId = convId;
+          recovery.turnId = turnId;
+        },
+        onError(msg, code) {
+          addEvent('error', { message: msg, code });
+          // QUERY_TIMEOUT / ENGINE_UNAVAILABLE 等可重试错误
+          const retryableCodes = ['QUERY_TIMEOUT', 'ENGINE_UNAVAILABLE', 'TOOL_UNAVAILABLE', 'STREAM_ERROR'];
+          lastError.value = { message: msg, code, retryable: retryableCodes.includes(code) };
+        },
+        onPresentation(data: PresentationData) {
+          addEvent('presentation', data);
+        },
+        onTaskUpdated(data: TaskUpdatedData) {
+          addEvent('task_updated', data);
+        },
+        onCommandPrepared(data: CommandPreparedData) {
+          addEvent('command_prepared', data);
+          activeCommand.value = data.command;
+        },
+        onCommandCompleted(data: CommandCompletedData) {
+          addEvent('command_completed', data);
+          if (activeCommand.value && activeCommand.value.commandId === data.commandId) {
+            activeCommand.value = {
+              ...activeCommand.value,
+              status: data.status,
+              result: data.result,
+              error: data.error,
+            };
+          }
+        },
+      },
+      abortController.signal,
+    );
+  } catch (e: any) {
+    if (e?.name !== 'AbortError') {
+      addEvent('error', { message: e?.message ?? '请求失败', code: 'CLIENT_ERROR' });
+    }
+  } finally {
+    sending.value = false;
+    abortController = null;
+  }
+}
+
+function handleAbort() {
+  abortController?.abort();
+}
+
+function handleClear() {
+  events.value = [];
+  answer.value = '';
+  candidateRefs.value = [];
+  activeCandidateId.value = '';
+  lastError.value = null;
+  lastRequest.value = null;
+}
+
+function handleRetry() {
+  if (lastRequest.value) {
+    lastError.value = null;
+    doSend(lastRequest.value);
+  }
+}
+
+// ---- 候选点击 → 发送 SELECT_CANDIDATE ----
+function handleCandidateSelect(c: CandidateDisplay) {
+  activeCandidateId.value = c.id;
+
+  // 自动填充交互表单
+  interaction.interactionType = 'SELECT_CANDIDATE';
+  interaction.resultRef = c.resultRef;
+  interaction.resultVersion = c.resultVersion;
+  interaction.candidateId = c.id;
+
+  addEvent('click', { action: 'SELECT_CANDIDATE', candidate: c });
+
+  // 直接发送(等同于点击选择)
+  doSend({
+    deviceId: deviceId.value,
+    conversationId: conversationId.value || undefined,
+    interaction: {
+      interactionType: 'SELECT_CANDIDATE',
+      resultRef: c.resultRef,
+      resultVersion: c.resultVersion,
+      candidateId: c.id,
+      clientActionId: 'click_' + Date.now(),
+    },
+  });
+}
+
+// ---- 快捷测试:按序号选择(模拟"选第二个") ----
+function quickSelect(nth: number) {
+  const ref0 = candidateRefs.value[0];
+  if (!ref0) return;
+  // Mock ID 规则:slot_001 ~ slot_006
+  const cid = `slot_${String(nth).padStart(3, '0')}`;
+  addEvent('quick', { action: `选第${nth}个`, candidateId: cid });
+
+  doSend({
+    deviceId: deviceId.value,
+    conversationId: conversationId.value || undefined,
+    interaction: {
+      interactionType: 'SELECT_CANDIDATE',
+      resultRef: ref0.resultRef,
+      resultVersion: ref0.version,
+      candidateId: cid,
+      clientActionId: 'quick_' + Date.now(),
+    },
+  });
+}
+
+// ---- 快捷测试:发送"选第二个"文本 ----
+function quickSendText(text: string) {
+  inputText.value = text;
+  doSend({
+    deviceId: deviceId.value,
+    conversationId: conversationId.value || undefined,
+    message: { text },
+  });
+}
+
+// ---- 断线恢复 ----
+async function handleRecover() {
+  if (!recovery.conversationId || !recovery.turnId) return;
+  recovering.value = true;
+  recovery.error = '';
+  recovery.result = null;
+  try {
+    const res = await getTurn(recovery.conversationId, recovery.turnId);
+    recovery.result = (res as any).data ?? res;
+  } catch (e: any) {
+    recovery.error = e?.message ?? '查询失败';
+  } finally {
+    recovering.value = false;
+  }
+}
+</script>
+
+<style scoped>
+.v2-chat-page {
+  padding: 12px;
+}
+
+.head .title {
+  font-size: 18px;
+  font-weight: 600;
+}
+.head .sub {
+  font-size: 13px;
+  color: #909399;
+  margin-top: 4px;
+}
+
+.chat-layout {
+  display: flex;
+  gap: 12px;
+  align-items: flex-start;
+}
+
+.left-panel {
+  width: 420px;
+  flex-shrink: 0;
+}
+
+.right-panel {
+  flex: 1;
+  min-width: 0;
+}
+
+.event-log {
+  max-height: 500px;
+  overflow-y: auto;
+  font-family: 'JetBrains Mono', 'Fira Code', monospace;
+  font-size: 12px;
+}
+
+.event-entry {
+  padding: 6px 8px;
+  border-left: 3px solid #909399;
+  margin-bottom: 4px;
+  background: #fafafa;
+  border-radius: 2px;
+}
+
+.event-entry.evt-delta  { border-left-color: #60a5fa; }
+.event-entry.evt-result { border-left-color: #4ade80; background: rgba(74,222,128,.05); }
+.event-entry.evt-ok     { border-left-color: #a78bfa; }
+.event-entry.evt-err    { border-left-color: #f87171; background: rgba(248,113,113,.08); }
+
+.event-tag {
+  display: inline-block;
+  font-weight: 600;
+  font-size: 11px;
+  color: #374151;
+  margin-bottom: 2px;
+}
+
+.event-data {
+  margin: 0;
+  white-space: pre-wrap;
+  word-break: break-all;
+  color: #6b7280;
+}
+
+.answer-box {
+  min-height: 80px;
+  white-space: pre-wrap;
+  color: #1f2937;
+  line-height: 1.7;
+}
+
+.recovery-result {
+  font-size: 13px;
+  line-height: 1.8;
+}
+
+.flex { display: flex; }
+.items-center { align-items: center; }
+.justify-between { justify-content: space-between; }
+.gap-2 { gap: 8px; }
+.flex-wrap { flex-wrap: wrap; }
+.text-red-500 { color: #ef4444; }
+.text-green-600 { color: #16a34a; }
+.text-gray-400 { color: #9ca3af; }
+.text-sm { font-size: 13px; }
+.text-xs { font-size: 12px; }
+.text-center { text-align: center; }
+.py-10 { padding-top: 40px; padding-bottom: 40px; }
+.mb-3 { margin-bottom: 12px; }
+.mt-2 { margin-top: 8px; }
+.mt-3 { margin-top: 12px; }
+</style>

+ 17 - 0
vitest.config.ts

@@ -0,0 +1,17 @@
+import { defineConfig } from 'vitest/config';
+import vue from '@vitejs/plugin-vue';
+import { resolve } from 'path';
+
+export default defineConfig({
+  plugins: [vue()],
+  test: {
+    environment: 'jsdom',
+    globals: true,
+    include: ['src/**/__tests__/**/*.test.ts'],
+  },
+  resolve: {
+    alias: {
+      '@': resolve(__dirname, './src'),
+    },
+  },
+});

Неке датотеке нису приказане због велике количине промена