云
2026-10-23 84b90d1e66ba297ce0d4b812f60b3656ba0bf3dc
feat(ai): 更新AI功能配置和数据补录界面

- 将VITE_APP_AI_ENABLE默认值从true改为false以禁用AI功能
- 在开发环境中启用AI功能开关并在.env.development中添加配置说明
- 添加AiModelCapabilityEnum枚举定义AI模型能力(文本和视觉)
- 扩展AgentConversationApi接口增加durable_tasks、AuthorizedCapability等新字段
- 在Conversation接口中添加repair_guidance、attachments和data_scope_summary等属性
- 新增ConversationEvent、ConversationConstraints和Task相关接口定义
- 添加getAuthorizedAgentCapabilities、getActiveBusinessModel等新的API方法
- 实现streamAgentTaskEvents和streamAgentConversationEvents流式事件处理函数
- 更新模型管理界面增加capability字段和对应的数据验证规则
- 添加模型能力字典类型AI_MODEL_CAPABILITY到常量定义
- 在数据补录页面重构文件上传逻辑支持多文件处理
- 实现后台任务分析功能通过createDocumentTask创建持久任务
- 添加SSE事件流处理实时更新任务进度和状态
- 实现附件预览和下载功能支持PDF和图片格式
- 优化会话管理增加删除会话和附件下载API调用
已修改9个文件
已添加1个文件
1696 ■■■■■ 文件已修改
.env 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.env.development 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/agent/conversation.ts 317 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/ai/model/model/index.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/constants/src/biz-ai-enum.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/constants/src/dict-enum.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/agent/dataEntry/components/TaskCenterDrawer.vue 298 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/agent/dataEntry/index.vue 908 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ai/model/model/data.ts 88 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ai/model/model/index.vue 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.env
@@ -20,7 +20,7 @@
VITE_APP_DOCALERT_ENABLE=true
# AI åŠŸèƒ½æ€»å¼€å…³ï¼šfalse æ—¶éšè—æ‰€æœ‰ AI å…¥å£ï¼ˆAI æ¨¡å—菜单、OCR、预测、智能判定、智能体导入)
VITE_APP_AI_ENABLE=true
VITE_APP_AI_ENABLE=false
# ç™¾åº¦ç»Ÿè®¡
VITE_APP_BAIDU_CODE = e98f2eab6ceb8688bc6d8fc5332ff093
.env.development
@@ -11,6 +11,9 @@
VITE_UPLOAD_TYPE=server
# æ˜¯å¦æ‰“å¼€ devtools,true ä¸ºæ‰“开,false ä¸ºå…³é—­
VITE_DEVTOOLS=false
# AI åŠŸèƒ½æ€»å¼€å…³ï¼šfalse æ—¶éšè—æ‰€æœ‰ AI å…¥å£ï¼ˆAI æ¨¡å—菜单、OCR、预测、智能判定、智能体导入)
VITE_APP_AI_ENABLE=true
# æ˜¯å¦æ³¨å…¥å…¨å±€loading
VITE_INJECT_APP_LOADING=true
src/api/agent/conversation.ts
@@ -68,6 +68,38 @@
    import_modes: string[];
    notes: string[];
    modules: ModuleSpec[];
    durable_tasks?: {
      enabled: boolean;
      events: string;
      fact_source: string;
      transport: string;
    };
  }
  export interface AuthorizedCapability {
    tool: string;
    module: string;
    operation: string;
    allowed_fields: string[];
    scope_summary: string;
    can_execute: boolean;
  }
  export interface AuthorizedCapabilityCatalog {
    policy_revision: string;
    authorization_scope: string;
    scope_summary: string;
    capabilities: AuthorizedCapability[];
  }
  export interface BusinessModelInfo {
    id: number;
    name: string;
    model: string;
    platform: string;
    temperature?: number | null;
    max_tokens?: number | null;
    max_contexts?: number | null;
  }
  export interface Issue {
@@ -138,6 +170,7 @@
    revision?: number;
    issues?: Issue[];
    warnings?: Issue[];
    repair_guidance?: Array<{ field?: string; guidance: string }>;
    summary?: Summary;
    can_commit?: boolean;
    side_effects?: string[];
@@ -176,6 +209,12 @@
    warnings: unknown[];
  }
  export interface ConversationAttachment {
    attachment_id: string;
    /** ä»…最新附件解析出文件名;历史附件可能为空。 */
    filename: string;
  }
  export interface Conversation {
    conversation_id: string;
    title: string;
@@ -183,6 +222,9 @@
    module_id: string;
    module_title: string;
    capability: string;
    data_scope_summary: string;
    model_id?: number | null;
    policy_revision: string;
    revision: number;
    validation_revision?: number | null;
    sync_mes?: boolean;
@@ -192,6 +234,9 @@
    warnings: Issue[];
    missing_fields: string[];
    columns: string[];
    /** å€™é€‰è¡Œå®žé™…有值的字段,用于渲染明细表。 */
    row_fields: Array<{ name: string; label: string }>;
    attachments: ConversationAttachment[];
    messages: Message[];
    interrupts: Interrupt[];
    /** æœªæäº¤æ—¶åŽç«¯è¿”回空对象 {},提交后才有完整回执。 */
@@ -223,11 +268,40 @@
    page_size: number;
  }
  export interface ConversationEvent {
    conversation_id: string;
    payload: Record<string, unknown>;
    seq: number;
    type:
      | 'card'
      | 'error'
      | 'message'
      | 'progress'
      | 'receipt'
      | 'resync'
      | 'status';
  }
  export interface CreateReq {
    title?: string;
    text?: string;
    attachment_id?: string;
    module_id?: string;
    model_id?: number;
    constraints?: ConversationConstraints;
  }
  export interface ConversationConstraints {
    scenario_paths: string[];
    time_start?: string;
    time_end?: string;
    record_limit?: number;
    industry?: string;
    skip_rest_days?: boolean;
    rest_day_description?: string;
    region?: string;
    instruction?: string;
    priority_fields: string[];
  }
  export interface ActionReq {
@@ -235,17 +309,177 @@
    revision: number;
    action: string;
    payload: Record<string, unknown>;
    model_id?: number;
  }
  export interface MessageReq {
    text?: string;
    attachment_id?: string;
    model_id?: number;
  }
}
export namespace AgentTaskApi {
  export interface Progress {
    mode: 'determinate' | 'indeterminate';
    completed: number;
    total: null | number;
    percent: null | number;
    unit?: string;
  }
  export interface Result {
    attachment_id?: number;
    attachment_ids?: number[];
    candidate_count?: number;
    continuation_supported?: boolean;
    evidence_ref?: {
      backend: string;
      candidate_count?: number;
      revision: string;
      task_id: string;
    } | null;
    filename?: string;
    filenames?: string[];
    module_id?: string;
    module_title?: string;
    result_kind?: string;
    source_sha256?: string;
    source_sha256s?: string[];
    review_summary?: {
      conflict_groups: number;
      exact_duplicates_merged: number;
      incomplete_keys: number;
      requires_review: boolean;
    };
    warnings?: string[];
  }
  export interface Task {
    task_id: string;
    owner_id: string;
    kind: string;
    status: string;
    stage: string;
    attempt: number;
    sequence: number;
    progress: Progress;
    message: string;
    result: null | Result;
    error: null | { code?: string; message?: string };
    allowed_actions: string[];
    created_at: null | string;
    updated_at: null | string;
    started_at: null | string;
    finished_at: null | string;
  }
  export interface PageResult {
    items: Task[];
    total: number;
    page: number;
    page_size: number;
  }
  export interface CreateReq {
    attachment_id?: number;
    attachment_ids?: number[];
    module_id?: string;
    constraints?: AgentConversationApi.ConversationConstraints;
  }
}
/** èƒ½åŠ›å£°æ˜Žï¼šæ”¯æŒçš„æ ¼å¼ã€æ¨¡å—ä¸Žé™åˆ¶ */
export function getAgentCapabilities() {
  return agentClient.get<AgentConversationApi.Capabilities>('/v1/capabilities');
}
/** è¯»å–当前登录用户经 Java åŽç«¯è£å‰ªåŽçš„可查询、可操作能力。 */
export function getAuthorizedAgentCapabilities() {
  return agentClient.get<AgentConversationApi.AuthorizedCapabilityCatalog>(
    '/v1/authorization/capabilities',
  );
}
/** å½“前数据补录链路实际使用的业务模型;不返回 API å¯†é’¥æˆ–服务地址。 */
export function getActiveBusinessModel() {
  return agentClient.get<AgentConversationApi.BusinessModelInfo>('/v1/model');
}
/** å¯åˆ‡æ¢çš„业务模型列表,按后端排序返回,默认使用第一个。 */
export function getAgentModels() {
  return agentClient.get<AgentConversationApi.BusinessModelInfo[]>('/v1/models');
}
/** åˆ†é¡µè¯»å–当前用户的持久后台任务。 */
export function listAgentTasks(params?: {
  page?: number;
  page_size?: number;
  status?: string;
}) {
  return agentClient.get<AgentTaskApi.PageResult>('/v1/tasks', { params });
}
/** åˆ›å»ºæ–‡æ¡£åŽå°åˆ†æžä»»åŠ¡ï¼ŒæœåŠ¡ç«¯ç«‹å³è¿”å›ž HTTP 202 å¿«ç…§ã€‚ */
export function createDocumentTask(data: AgentTaskApi.CreateReq) {
  return agentClient.post<AgentTaskApi.Task>('/v1/tasks/document-onboarding', data);
}
/** è¯»å–任务事实快照。 */
export function getAgentTask(taskId: string) {
  return agentClient.get<AgentTaskApi.Task>(`/v1/tasks/${taskId}`);
}
/** å–消任务并撤销短期委托票据。 */
export function cancelAgentTask(taskId: string) {
  return agentClient.delete<AgentTaskApi.Task>(`/v1/tasks/${taskId}`);
}
/** ä½¿ç”¨è®¤è¯è¯·æ±‚读取任务 SSE;游标用于断线续传。 */
export async function streamAgentTaskEvents(
  taskId: string,
  since: number,
  signal: AbortSignal,
  onEvent: (event: AgentTaskApi.Task) => void,
) {
  const token = useAccessStore().accessToken;
  const response = await fetch(
    `/agent-api/v1/tasks/${encodeURIComponent(taskId)}/events?since=${since}`,
    {
      credentials: 'same-origin',
      headers: {
        Accept: 'text/event-stream',
        ...(token ? { Authorization: `Bearer ${token}` } : {}),
      },
      signal,
    },
  );
  if (!response.ok) throw new Error(`任务事件连接失败(HTTP ${response.status})`);
  if (!response.body) throw new Error('当前浏览器不支持流式事件');
  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';
  let cursor = since;
  while (true) {
    const { done, value } = await reader.read();
    buffer += decoder.decode(value, { stream: !done }).replaceAll('\r\n', '\n');
    const frames = buffer.split('\n\n');
    buffer = frames.pop() ?? '';
    for (const frame of frames) {
      const data = frame
        .split('\n')
        .filter((line) => line.startsWith('data:'))
        .map((line) => line.slice(5).trimStart())
        .join('\n');
      if (!data) continue;
      const event = JSON.parse(data) as AgentTaskApi.Task;
      if (!Number.isSafeInteger(event.sequence) || event.sequence <= cursor) continue;
      cursor = event.sequence;
      onEvent(event);
    }
    if (done) break;
  }
  return cursor;
}
/** ä¼šè¯åˆ—表 */
@@ -275,6 +509,89 @@
  );
}
/** åˆ é™¤ä¼šè¯ï¼šåŒæ—¶æ¸…理检查点、事件与 Java ä¾§è¡¥å½•附件 */
export function deleteAgentConversation(conversationId: string) {
  return agentClient.delete<{
    attachments_deleted: number;
    conversation_id: string;
    deleted: boolean;
  }>(`/v1/conversations/${conversationId}`);
}
/**
 * è¯»å–会话附件。mode ä¸º inline æ—¶ PDF/图片由浏览器直接预览,
 * å…¶ä½™ç±»åž‹æœåŠ¡ç«¯ä¼šè‡ªåŠ¨é™çº§ä¸ºä¸‹è½½ã€‚
 */
export function downloadAgentConversationAttachment(
  conversationId: string,
  attachmentId: string,
  mode: 'download' | 'inline',
) {
  return agentClient.download<{
    data: Blob;
    headers: Record<string, string>;
  }>(`/v1/conversations/${conversationId}/attachment`, {
    params: { attachment_id: attachmentId, mode },
    responseReturn: 'raw',
  });
}
/**
 * ä½¿ç”¨ fetch è¯»å–带认证头的 SSE。EventSource ä¸èƒ½è®¾ç½® Authorization,不能用于本接口。
 * æœåŠ¡ç«¯ä¼šä¸»åŠ¨ç»“æŸçŸ­è¿žæŽ¥ï¼›è°ƒç”¨æ–¹è´Ÿè´£æºå¸¦è¿”å›žæ¸¸æ ‡é‡è¿žã€‚
 */
export async function streamAgentConversationEvents(
  conversationId: string,
  since: number,
  signal: AbortSignal,
  onEvent: (event: AgentConversationApi.ConversationEvent) => void,
  onOpen?: () => void,
) {
  const token = useAccessStore().accessToken;
  const response = await fetch(
    `/agent-api/v1/conversations/${encodeURIComponent(conversationId)}/events?since=${since}`,
    {
      credentials: 'same-origin',
      headers: {
        Accept: 'text/event-stream',
        ...(token ? { Authorization: `Bearer ${token}` } : {}),
      },
      signal,
    },
  );
  if (!response.ok) {
    throw new Error(`事件连接失败(HTTP ${response.status})`);
  }
  if (!response.body) {
    throw new Error('当前浏览器不支持流式事件');
  }
  onOpen?.();
  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';
  let cursor = since;
  while (true) {
    const { done, value } = await reader.read();
    buffer += decoder.decode(value, { stream: !done }).replaceAll('\r\n', '\n');
    const frames = buffer.split('\n\n');
    buffer = frames.pop() ?? '';
    for (const frame of frames) {
      const data = frame
        .split('\n')
        .filter((line) => line.startsWith('data:'))
        .map((line) => line.slice(5).trimStart())
        .join('\n');
      if (!data) continue;
      const event = JSON.parse(data) as AgentConversationApi.ConversationEvent;
      if (!Number.isSafeInteger(event.seq) || event.seq <= cursor) continue;
      cursor = event.seq;
      onEvent(event);
    }
    if (done) break;
  }
  return cursor;
}
/** å‘送文本或附件(用于补充上传文件) */
export function sendAgentMessage(
  conversationId: string,
src/api/ai/model/model/index.ts
@@ -10,6 +10,7 @@
    model: string; // æ¨¡åž‹æ ‡è¯†
    platform: string; // æ¨¡åž‹å¹³å°
    type: number; // æ¨¡åž‹ç±»åž‹
    capability?: string; // æ¨¡åž‹èƒ½åŠ›ï¼štext-文本,vision-视觉
    sort: number; // æŽ’序
    status: number; // çŠ¶æ€
    temperature?: number; // æ¸©åº¦å‚æ•°
@@ -27,10 +28,11 @@
}
/** èŽ·å¾—æ¨¡åž‹åˆ—è¡¨ */
export function getModelSimpleList(type?: number) {
export function getModelSimpleList(type?: number, capability?: string) {
  return requestClient.get<AiModelModelApi.Model[]>('/ai/model/simple-list', {
    params: {
      type,
      capability,
    },
  });
}
@@ -50,6 +52,11 @@
  return requestClient.put('/ai/model/update', data);
}
/** ä¿®æ”¹æ¨¡åž‹çŠ¶æ€ï¼šå¯ç”¨æ—¶ä¼šè‡ªåŠ¨å…³é—­åŒç±»åž‹åŒèƒ½åŠ›çš„å…¶ä»–æ¨¡åž‹ */
export function updateModelStatus(id: number, status: number) {
  return requestClient.put('/ai/model/update-status', { id, status });
}
/** åˆ é™¤æ¨¡åž‹ */
export function deleteModel(id: number) {
  return requestClient.delete(`/ai/model/delete?id=${id}`);
src/packages/constants/src/biz-ai-enum.ts
@@ -24,6 +24,12 @@
  RERANK: 6, // é‡æŽ’
};
/** AI æ¨¡åž‹èƒ½åŠ›ï¼šä¸Žæ¨¡åž‹ç±»åž‹ä¸€èµ·å†³å®šã€Œå¯ç”¨å”¯ä¸€ã€åˆ†ç»„ */
export const AiModelCapabilityEnum = {
  TEXT: 'text', // æ–‡æœ¬
  VISION: 'vision', // è§†è§‰
};
export interface ImageModel {
  image?: string;
  key: string;
src/packages/constants/src/dict-enum.ts
@@ -148,6 +148,7 @@
const AI_DICT = {
  AI_GENERATE_MODE: 'ai_generate_mode', // AI ç”Ÿæˆæ¨¡å¼
  AI_IMAGE_STATUS: 'ai_image_status', // AI å›¾ç‰‡çŠ¶æ€
  AI_MODEL_CAPABILITY: 'ai_model_capability', // AI æ¨¡åž‹èƒ½åŠ›
  AI_MODEL_TYPE: 'ai_model_type', // AI æ¨¡åž‹ç±»åž‹
  AI_MUSIC_STATUS: 'ai_music_status', // AI éŸ³ä¹çŠ¶æ€
  AI_PLATFORM: 'ai_platform', // AI å¹³å°
src/views/agent/dataEntry/components/TaskCenterDrawer.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,298 @@
<script lang="ts" setup>
import type { AgentTaskApi } from '#/api/agent/conversation';
import { computed, onBeforeUnmount, ref } from 'vue';
import { message } from 'ant-design-vue';
import {
  agentImportErrorMessage,
  cancelAgentTask,
  listAgentTasks,
  streamAgentTaskEvents,
} from '#/api/agent/conversation';
defineOptions({ name: 'AgentTaskCenterDrawer' });
interface Emits {
  (event: 'continue', task: AgentTaskApi.Task): void;
}
const props = defineProps<{ enabled: boolean }>();
const emit = defineEmits<Emits>();
const visible = ref(false);
const loading = ref(false);
const tasks = ref<AgentTaskApi.Task[]>([]);
let pollTimer: ReturnType<typeof setTimeout> | undefined;
let eventAbort: AbortController | undefined;
const activeCount = computed(
  () => tasks.value.filter((task) => !isTerminal(task.status)).length,
);
function isTerminal(status: string) {
  return ['cancelled', 'failed', 'partially_succeeded', 'succeeded'].includes(
    status,
  );
}
function statusText(status: string) {
  const values: Record<string, string> = {
    cancelled: '已取消',
    failed: '失败',
    partially_succeeded: '部分完成',
    queued: '排队中',
    retry_wait: '等待重试',
    running: '执行中',
    succeeded: '已完成',
    waiting_approval: '待审批',
    waiting_input: '待补充',
  };
  return values[status] ?? status;
}
function statusColor(status: string) {
  if (status === 'succeeded') return 'green';
  if (status === 'failed' || status === 'cancelled') return 'red';
  if (status.startsWith('waiting_') || status === 'retry_wait') return 'orange';
  return 'blue';
}
function stageText(stage: string) {
  const values: Record<string, string> = {
    attachment_download: '读取企业附件',
    cancelled: '任务已取消',
    document_invalid: '文档校验失败',
    document_parse: '解析与结构识别',
    evidence_persist: '保存解析证据',
    failed: '任务失败',
    queued: '等待 Worker',
    reauth_required: '需要重新授权',
    result_ready: '分析结果已生成',
    retry_wait: '等待自动重试',
    starting: '准备执行',
    worker_lost: 'Worker å¤šæ¬¡å¤±è”',
  };
  return values[stage] ?? stage;
}
function stopPolling() {
  if (pollTimer) clearTimeout(pollTimer);
  pollTimer = undefined;
  eventAbort?.abort();
  eventAbort = undefined;
}
async function loadTasks(silent = false) {
  if (!props.enabled) return;
  if (!silent) loading.value = true;
  try {
    const result = await listAgentTasks({ page_size: 50 });
    tasks.value = result.items ?? [];
  } catch (error) {
    if (!silent) message.error(agentImportErrorMessage(error));
  } finally {
    if (!silent) loading.value = false;
  }
}
function schedulePolling() {
  if (!visible.value || !props.enabled) return;
  pollTimer = setTimeout(async () => {
    await loadTasks(true);
    schedulePolling();
  }, 3000);
}
async function watchTask(taskId: string, since = 0) {
  eventAbort?.abort();
  const controller = new AbortController();
  eventAbort = controller;
  let cursor = since;
  let retryDelay = 1000;
  while (visible.value && !controller.signal.aborted) {
    try {
      cursor = await streamAgentTaskEvents(
        taskId,
        cursor,
        controller.signal,
        (event) => {
          const index = tasks.value.findIndex((task) => task.task_id === taskId);
          if (index >= 0) tasks.value[index] = { ...tasks.value[index], ...event };
        },
      );
      retryDelay = 1000;
    } catch {
      if (controller.signal.aborted) return;
    }
    await loadTasks(true);
    const latest = tasks.value.find((task) => task.task_id === taskId);
    if (!latest || isTerminal(latest.status) || controller.signal.aborted) return;
    cursor = Math.max(cursor, latest.sequence);
    await new Promise<void>((resolve) => {
      const timer = setTimeout(resolve, retryDelay);
      controller.signal.addEventListener(
        'abort',
        () => {
          clearTimeout(timer);
          resolve();
        },
        { once: true },
      );
    });
    retryDelay = Math.min(retryDelay * 2, 30_000);
  }
}
async function open(taskId?: string) {
  stopPolling();
  visible.value = true;
  await loadTasks();
  schedulePolling();
  const target = taskId
    ? tasks.value.find((task) => task.task_id === taskId)
    : tasks.value.find((task) => !isTerminal(task.status));
  if (target && !isTerminal(target.status)) void watchTask(target.task_id, target.sequence);
}
function close() {
  visible.value = false;
  stopPolling();
}
async function cancel(task: AgentTaskApi.Task) {
  try {
    await cancelAgentTask(task.task_id);
    message.success('任务已取消');
    await loadTasks(true);
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  }
}
function continueTask(task: AgentTaskApi.Task) {
  close();
  emit('continue', task);
}
defineExpose({ open, activeCount });
onBeforeUnmount(stopPolling);
</script>
<template>
  <a-drawer
    :open="visible"
    title="后台任务中心"
    width="min(560px, 100vw)"
    :body-style="{ padding: '16px' }"
    @close="close"
  >
    <a-alert
      v-if="!enabled"
      type="info"
      show-icon
      message="持久任务服务尚未启用"
      description="配置 PostgreSQL、Redis å¹¶å¯ç”¨ MOM_TASK_BACKEND åŽï¼Œå¯åœ¨è¿™é‡ŒæŸ¥çœ‹è·¨é¡µé¢æ‰§è¡Œçš„æ–‡æ¡£ä»»åŠ¡ã€‚"
    />
    <a-spin v-else :spinning="loading">
      <a-empty v-if="!tasks.length" description="暂无后台任务" />
      <div v-else class="space-y-3">
        <article
          v-for="task in tasks"
          :key="task.task_id"
          class="rounded-lg border border-border bg-card p-4 transition-colors duration-200 hover:border-blue-300"
        >
          <div class="flex items-start justify-between gap-3">
            <div class="min-w-0">
              <div class="font-medium text-gray-900 dark:text-gray-100">
                {{ task.result?.filename || '企业文档分析' }}
              </div>
              <div class="mt-1 text-xs text-gray-500">
                {{ stageText(task.stage) }} Â· ç¬¬ {{ task.attempt }} æ¬¡æ‰§è¡Œ
              </div>
            </div>
            <a-tag :color="statusColor(task.status)" class="!mr-0 shrink-0">
              {{ statusText(task.status) }}
            </a-tag>
          </div>
          <div class="mt-3" aria-live="polite">
            <a-progress
              v-if="task.progress.mode === 'determinate'"
              :percent="task.progress.percent ?? 0"
              :status="task.status === 'failed' ? 'exception' : undefined"
            />
            <div
              v-else
              class="rounded-md border border-dashed border-border bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-gray-800 dark:text-gray-300"
              role="status"
            >
              å½“前阶段工作量待确定
            </div>
            <div class="mt-1 flex justify-between gap-3 text-xs text-gray-500">
              <span>{{ task.message }}</span>
              <span v-if="task.progress.total !== null" class="shrink-0">
                {{ task.progress.completed }} / {{ task.progress.total }}
                {{ task.progress.unit === 'row' ? '行' : '' }}
              </span>
            </div>
          </div>
          <a-alert
            v-if="task.error?.message"
            class="mt-3"
            type="error"
            :message="task.error.message"
          />
          <div
            v-if="task.result?.candidate_count !== undefined"
            class="mt-3 rounded-md bg-gray-50 px-3 py-2 text-sm dark:bg-gray-800"
          >
            å·²è¯†åˆ« {{ task.result.candidate_count }} æ¡å€™é€‰æ•°æ®
            <span v-if="task.result.warnings?.length" class="ml-2 text-orange-600">
              {{ task.result.warnings.length }} æ¡æç¤º
            </span>
          </div>
          <a-alert
            v-if="task.result?.review_summary?.requires_review"
            class="mt-2"
            type="warning"
            show-icon
            :message="`需要人工复核:${task.result.review_summary.conflict_groups} ç»„冲突,${task.result.review_summary.incomplete_keys} æ¡ç¼ºå°‘å½’å¹¶é”®`"
          />
          <div
            v-else-if="task.result?.review_summary?.exact_duplicates_merged"
            class="mt-2 text-xs text-gray-500"
          >
            å·²è‡ªåŠ¨åˆå¹¶
            {{ task.result.review_summary.exact_duplicates_merged }} æ¡å®Œå…¨é‡å¤è®°å½•
          </div>
          <div class="mt-3 flex flex-wrap justify-end gap-2">
            <a-button
              v-if="task.allowed_actions.includes('cancel')"
              danger
              @click="cancel(task)"
            >
              å–消任务
            </a-button>
            <a-button
              v-if="
                task.status === 'succeeded' &&
                task.result?.attachment_id &&
                task.result?.continuation_supported !== false
              "
              type="primary"
              @click="continueTask(task)"
            >
              è¿›å…¥è¡¥å½•确认
            </a-button>
          </div>
        </article>
      </div>
    </a-spin>
  </a-drawer>
</template>
src/views/agent/dataEntry/index.vue
@@ -1,57 +1,266 @@
<script lang="ts" setup>
import type { FileType } from 'ant-design-vue/es/upload/interface';
import type { MenuRecordRaw } from '@vben/types';
import type { AgentConversationApi } from '#/api/agent/conversation';
import type { AgentTaskApi } from '#/api/agent/conversation';
import { computed, onMounted, ref, watch } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { Page } from '@vben/common-ui';
import { useAccessStore } from '@vben/stores';
import { downloadFileFromBlobPart } from '@vben/utils';
import { message } from 'ant-design-vue';
import {
  agentImportErrorMessage,
  createDocumentTask,
  createAgentConversation,
  deleteAgentConversation,
  downloadAgentConversationAttachment,
  downloadAgentReport,
  getAgentCapabilities,
  getAgentModels,
  getAuthorizedAgentCapabilities,
  getAgentConversation,
  listAgentConversations,
  sendAgentAction,
  sendAgentMessage,
  streamAgentConversationEvents,
  uploadAgentAttachment,
} from '#/api/agent/conversation';
import TaskCenterDrawer from './components/TaskCenterDrawer.vue';
defineOptions({ name: 'AgentDataEntry' });
const accessStore = useAccessStore();
const MAX_BYTES = 8 * 1024 * 1024;
const ACCEPT = '.csv,.xlsx,.docx,.pdf';
const modules = ref<AgentConversationApi.ModuleSpec[]>([]);
const authorizationScope = ref('');
const models = ref<AgentConversationApi.BusinessModelInfo[]>([]);
const selectedModelId = ref<number>();
const conversations = ref<AgentConversationApi.ConversationSummary[]>([]);
const active = ref<AgentConversationApi.Conversation>();
const loading = ref(false);
const busy = ref(false);
const creating = ref(false);
const newOpen = ref(false);
const newModule = ref('');
const newScenarios = ref<string[]>([]);
const newText = ref('');
const newFile = ref<File>();
const newFileName = ref('');
const newTimeRange = ref<string[]>([]);
const newRecordLimit = ref<number>();
const newIndustry = ref('');
const newSkipRestDays = ref(false);
const newRestDayDescription = ref('');
const newRegion = ref('');
type ConstraintKey =
  | 'industry'
  | 'instruction'
  | 'record_limit'
  | 'region'
  | 'rest_day_description'
  | 'scenario_paths'
  | 'skip_rest_days'
  | 'time_range';
const newPriorities = ref<Record<ConstraintKey, boolean>>({
  industry: false,
  instruction: false,
  record_limit: false,
  region: false,
  rest_day_description: false,
  scenario_paths: false,
  skip_rest_days: false,
  time_range: false,
});
const newFiles = ref<File[]>([]);
const newAttachmentIds = ref<number[]>([]);
const durableTasksEnabled = ref(false);
const taskCenter = ref<{ open: (taskId?: string) => Promise<void> }>();
const interruptFile = ref<File>();
const mappingDraft = ref<Record<string, string>>({});
const reviseText = ref('');
const syncMes = ref(true);
const eventState = ref<'connected' | 'connecting' | 'idle' | 'retrying'>('idle');
interface ExecutionProgress {
  candidateCount: number;
  issueCount: number;
  label: string;
  modelId?: number;
  percent: number;
  stage: string;
}
const executionProgress = ref<ExecutionProgress>();
let eventAbort: AbortController | undefined;
let eventGeneration = 0;
let eventRefreshTimer: ReturnType<typeof setTimeout> | undefined;
const attachmentPreview = ref<{
  filename: string;
  kind: 'image' | 'pdf';
  open: boolean;
  url: string;
}>({ filename: '', kind: 'image', open: false, url: '' });
const modelOptions = computed(() =>
  models.value.map((model) => ({
    label: `${model.name || model.model} Â· ${model.platform}`,
    value: model.id,
  })),
);
const interrupt = computed(() => active.value?.interrupts?.at(-1));
const card = computed(() => interrupt.value?.card);
const actions = computed(() => card.value?.actions ?? []);
const actions = computed(() => {
  const list = [...(card.value?.actions ?? [])];
  // å¡ç‰‡å¯èƒ½åœ¨æäº¤å¼€å…³å…³é—­æ—¶ç”Ÿæˆï¼Œä»¥æœåŠ¡ç«¯å½“å‰å¼€å…³ä¸ºå‡†è¡¥å›žç¡®è®¤åŠ¨ä½œ
  if (
    card.value?.kind === 'preview' &&
    active.value?.commit_enabled &&
    !list.includes('confirm')
  ) {
    list.push('confirm');
  }
  return list;
});
const isGatewayModule = computed(() => active.value?.module_id === 'mdm.item');
const moduleOptions = computed(() => [
  { label: '自动识别', value: '' },
  ...modules.value.map((item) => ({ label: item.title, value: item.module_id })),
const previewValueLabels: Record<string, Record<string, string>> = {
  status: { '0': '启用', '1': '停用' },
  types: {
    '1': 'IQC(来料检验)',
    '2': 'IPQC(过程检验)',
    '3': 'OQC(出货检验)',
    '4': 'RQC(退料检验)',
  },
};
const previewColumns = computed(() => [
  { title: '行号', dataIndex: 'row_index', key: 'row_index', width: 60 },
  ...(active.value?.row_fields ?? []).map((field) => ({
    title: field.label,
    dataIndex: field.name,
    key: field.name,
    width: 140,
    customRender: ({ text }: { text: unknown }) =>
      previewValueLabels[field.name]?.[String(text ?? '')] ?? text,
  })),
]);
const previewRows = computed(() =>
  (active.value?.rows ?? []).map((row, index) => ({
    candidate_id: row.candidate_id,
    row_index: index + 1,
    ...row.values,
  })),
);
const eventStateText = computed(() => {
  if (active.value?.finished) return '已同步';
  return {
    connected: '实时更新',
    connecting: '正在连接',
    idle: '未连接',
    retrying: '断线重连',
  }[eventState.value];
});
const eventStateColor = computed(() => {
  if (active.value?.finished || eventState.value === 'connected') return 'green';
  if (eventState.value === 'retrying') return 'orange';
  return 'blue';
});
interface ScenarioOption {
  description: string;
  label: string;
  name: string;
  value: string;
}
function flattenMenuScenarios(
  menus: MenuRecordRaw[],
  parentNames: string[] = [],
): ScenarioOption[] {
  return menus.flatMap((menu) => {
    const names = [...parentNames, menu.name].filter(Boolean);
    const current = menu.show === false
      ? []
      : [
          {
            description: menu.path,
            label: names.join(' / '),
            name: menu.name,
            value: menu.path,
          },
        ];
    return [
      ...current,
      ...flattenMenuScenarios(menu.children ?? [], names),
    ];
  });
}
const scenarioOptions = computed(() => {
  const seen = new Set<string>();
  return flattenMenuScenarios(accessStore.accessMenus).filter((item) => {
    if (!item.value || seen.has(item.value)) return false;
    seen.add(item.value);
    return true;
  });
});
function togglePriority(key: ConstraintKey) {
  newPriorities.value[key] = !newPriorities.value[key];
}
function buildConstraints(): AgentConversationApi.ConversationConstraints {
  const scenarioByPath = new Map(
    scenarioOptions.value.map((item) => [item.value, item.label]),
  );
  return {
    industry: newIndustry.value.trim() || undefined,
    instruction: newText.value.trim() || undefined,
    priority_fields: Object.entries(newPriorities.value)
      .filter(([, enabled]) => enabled)
      .map(([key]) => key),
    record_limit: newRecordLimit.value,
    region: newRegion.value.trim() || undefined,
    rest_day_description: newRestDayDescription.value.trim() || undefined,
    scenario_paths: newScenarios.value.map((path) =>
      `${scenarioByPath.get(path) ?? path} (${path})`,
    ),
    skip_rest_days:
      newSkipRestDays.value || newPriorities.value.skip_rest_days
        ? newSkipRestDays.value
        : undefined,
    time_end: newTimeRange.value[1],
    time_start: newTimeRange.value[0],
  };
}
function hasNewConditions() {
  return Boolean(
    newScenarios.value.length ||
      newTimeRange.value.length ||
      newRecordLimit.value ||
      newIndustry.value.trim() ||
      newSkipRestDays.value ||
      newRestDayDescription.value.trim() ||
      newRegion.value.trim(),
  );
}
function resetNewConditions() {
  newScenarios.value = [];
  newText.value = '';
  newTimeRange.value = [];
  newRecordLimit.value = undefined;
  newIndustry.value = '';
  newSkipRestDays.value = false;
  newRestDayDescription.value = '';
  newRegion.value = '';
  for (const key of Object.keys(newPriorities.value) as ConstraintKey[]) {
    newPriorities.value[key] = false;
  }
}
watch(
  () => interrupt.value?.interrupt_id,
@@ -81,6 +290,7 @@
    commit_disabled: '未提交',
    confirmed: '已确认',
    created: '待上传文件',
    queued: '排队处理中',
    error: '出错',
    failed: '失败',
    mapping_applied: '映射已更新',
@@ -131,9 +341,18 @@
function beforeNewUpload(fileInfo: FileType) {
  const raw = fileInfo as unknown as File;
  if (!checkFile(raw)) return false;
  newFile.value = raw;
  newFileName.value = raw.name;
  const duplicate = newFiles.value.some(
    (item) =>
      item.name === raw.name && item.size === raw.size && item.lastModified === raw.lastModified,
  );
  if (!duplicate) newFiles.value = [...newFiles.value, raw].slice(0, 20);
  newAttachmentIds.value = [];
  return false;
}
function clearNewFiles() {
  newFiles.value = [];
  newAttachmentIds.value = [];
}
function beforeInterruptUpload(fileInfo: FileType) {
@@ -146,17 +365,39 @@
async function boot() {
  loading.value = true;
  try {
    const [capabilities, list] = await Promise.all([
    const [capabilities, authorization, list, modelList] = await Promise.all([
      getAgentCapabilities(),
      getAuthorizedAgentCapabilities(),
      listAgentConversations({ page_size: 50 }),
      getAgentModels().catch(() => []),
    ]);
    modules.value = capabilities.modules ?? [];
    authorizationScope.value = authorization.scope_summary;
    models.value = modelList;
    if (!selectedModelId.value) {
      selectedModelId.value = active.value?.model_id ?? modelList[0]?.id;
    }
    const visibleScenarioPaths = new Set(scenarioOptions.value.map((item) => item.value));
    newScenarios.value = newScenarios.value.filter((path) =>
      visibleScenarioPaths.has(path),
    );
    durableTasksEnabled.value = capabilities.durable_tasks?.enabled ?? false;
    conversations.value = list.items ?? [];
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  } finally {
    loading.value = false;
  }
}
async function ensureNewAttachments() {
  if (newAttachmentIds.value.length === newFiles.value.length) {
    return newAttachmentIds.value;
  }
  newAttachmentIds.value = [];
  for (const file of newFiles.value) {
    newAttachmentIds.value.push(await uploadAgentAttachment(file));
  }
  return newAttachmentIds.value;
}
async function reloadConversations() {
@@ -168,11 +409,112 @@
  }
}
function abortEventStream() {
  eventGeneration += 1;
  eventAbort?.abort();
  eventAbort = undefined;
  if (eventRefreshTimer) clearTimeout(eventRefreshTimer);
  eventRefreshTimer = undefined;
  eventState.value = 'idle';
}
async function refreshActive(conversationId: string) {
  const snapshot = await getAgentConversation(conversationId);
  if (active.value?.conversation_id !== conversationId) return;
  active.value = snapshot;
  await reloadConversations();
  if (snapshot.finished) abortEventStream();
}
function queueEventRefresh(conversationId: string) {
  if (eventRefreshTimer) clearTimeout(eventRefreshTimer);
  eventRefreshTimer = setTimeout(() => {
    eventRefreshTimer = undefined;
    void refreshActive(conversationId).catch(() => {
      // SSE ä¼šç»§ç»­é‡è¿žï¼Œå¿«ç…§è¯»å–失败不重复弹窗打扰操作。
    });
  }, 120);
}
function handleConversationEvent(
  conversationId: string,
  event: AgentConversationApi.ConversationEvent,
) {
  if (active.value?.conversation_id !== conversationId) return;
  if (event.type === 'progress') {
    const payload = event.payload;
    executionProgress.value = {
      candidateCount: Number(payload.candidate_count ?? 0),
      issueCount: Number(payload.issue_count ?? 0),
      label: String(payload.label ?? '正在处理'),
      modelId:
        typeof payload.model_id === 'number' ? payload.model_id : undefined,
      percent: Math.min(100, Math.max(0, Number(payload.percent ?? 0))),
      stage: String(payload.stage ?? 'processing'),
    };
    return;
  }
  queueEventRefresh(conversationId);
}
function waitForRetry(delay: number, signal: AbortSignal) {
  return new Promise<void>((resolve) => {
    const timer = setTimeout(resolve, delay);
    signal.addEventListener(
      'abort',
      () => {
        clearTimeout(timer);
        resolve();
      },
      { once: true },
    );
  });
}
function startEventStream(conversationId: string) {
  abortEventStream();
  const generation = eventGeneration;
  const controller = new AbortController();
  eventAbort = controller;
  eventState.value = 'connecting';
  void (async () => {
    let cursor = 0;
    let retryDelay = 1000;
    while (!controller.signal.aborted && generation === eventGeneration) {
      try {
        cursor = await streamAgentConversationEvents(
          conversationId,
          cursor,
          controller.signal,
          (event) => handleConversationEvent(conversationId, event),
          () => {
            eventState.value = 'connected';
            retryDelay = 1000;
          },
        );
        if (!controller.signal.aborted) eventState.value = 'connecting';
      } catch {
        if (controller.signal.aborted) return;
        eventState.value = 'retrying';
        try {
          await refreshActive(conversationId);
        } catch {
          // è½®è¯¢å…œåº•失败后继续指数退避。
        }
        await waitForRetry(retryDelay, controller.signal);
        retryDelay = Math.min(retryDelay * 2, 30_000);
      }
    }
  })();
}
async function openConversation(conversationId: string) {
  if (busy.value) return;
  busy.value = true;
  try {
    active.value = await getAgentConversation(conversationId);
    const conversation = await getAgentConversation(conversationId);
    active.value = conversation;
    selectedModelId.value = conversation.model_id ?? models.value[0]?.id;
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  } finally {
@@ -180,27 +522,35 @@
  }
}
function changeModel(value: number) {
  selectedModelId.value = value;
}
async function createConversation() {
  if (!newFile.value && !newText.value.trim()) {
    message.warning('请上传文件或填写补录说明');
  if (!newFiles.value.length && !newText.value.trim() && !hasNewConditions()) {
    message.warning('请上传文件、填写补录说明或设置补录条件');
    return;
  }
  creating.value = true;
  try {
    let attachmentId: number | undefined;
    if (newFile.value) {
      attachmentId = await uploadAgentAttachment(newFile.value);
    if (newFiles.value.length > 1) {
      message.warning('多文件请使用后台分析,归并检查完成后再进入补录');
      return;
    }
    const [attachmentId] = await ensureNewAttachments();
    const description = newText.value.trim();
    const baseTitle =
      newFiles.value[0]?.name || description.slice(0, 60) || '新建补录';
    active.value = await createAgentConversation({
      attachment_id: attachmentId ? String(attachmentId) : undefined,
      module_id: newModule.value || undefined,
      text: newText.value.trim() || undefined,
      title: newFile.value?.name || newText.value.trim().slice(0, 60) || undefined,
      constraints: buildConstraints(),
      model_id: selectedModelId.value,
      text: description || undefined,
      title: baseTitle,
    });
    newOpen.value = false;
    newFile.value = undefined;
    newFileName.value = '';
    newText.value = '';
    clearNewFiles();
    resetNewConditions();
    await reloadConversations();
  } catch (error) {
    message.error(agentImportErrorMessage(error));
@@ -209,8 +559,65 @@
  }
}
async function startBackgroundAnalysis() {
  if (!durableTasksEnabled.value) {
    message.info('持久任务服务尚未启用');
    return;
  }
  if (!newFiles.value.length) {
    message.warning('后台分析需要先选择数据文件');
    return;
  }
  creating.value = true;
  try {
    const attachmentIds = await ensureNewAttachments();
    if (!attachmentIds.length) return;
    const task = await createDocumentTask({
      attachment_ids: attachmentIds,
      constraints: buildConstraints(),
    });
    newOpen.value = false;
    resetNewConditions();
    message.success('后台分析任务已创建,可关闭页面后继续执行');
    await taskCenter.value?.open(task.task_id);
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  } finally {
    creating.value = false;
  }
}
async function continueFromTask(task: AgentTaskApi.Task) {
  if (task.result?.continuation_supported === false) {
    message.info('多文件结果需先在证据明细中处理冲突,当前暂不进入单文件提交');
    return;
  }
  const attachmentId = task.result?.attachment_id;
  if (!attachmentId) {
    message.error('任务结果缺少附件引用,无法进入补录');
    return;
  }
  busy.value = true;
  try {
    active.value = await createAgentConversation({
      attachment_id: String(attachmentId),
      model_id: selectedModelId.value,
      module_id: task.result?.module_id || undefined,
      text: '后台文档分析已完成,请继续进行字段核验和补录确认。',
      title: task.result?.filename || '后台分析补录',
    });
    await reloadConversations();
    message.success('已进入补录确认流程');
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  } finally {
    busy.value = false;
  }
}
async function apply(result: AgentConversationApi.Conversation, tip?: string) {
  active.value = result;
  selectedModelId.value = result.model_id ?? selectedModelId.value;
  if (tip) message.success(tip);
  await reloadConversations();
}
@@ -228,6 +635,7 @@
    const result = await sendAgentAction(current.conversation_id, {
      action,
      interrupt_id: pending.interrupt_id,
      model_id: selectedModelId.value,
      payload,
      revision: pending.revision,
    });
@@ -269,6 +677,7 @@
    const attachmentId = await uploadAgentAttachment(interruptFile.value);
    const result = await sendAgentMessage(current.conversation_id, {
      attachment_id: String(attachmentId),
      model_id: selectedModelId.value,
      text: '已上传要补录的文件。',
    });
    interruptFile.value = undefined;
@@ -324,7 +733,110 @@
  }
}
function decodeAttachmentFilename(
  headers: Record<string, string> | undefined,
  fallback: string,
) {
  const encoded = headers?.['x-agent-filename-b64'];
  if (!encoded) return fallback;
  try {
    const binary = atob(encoded);
    const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
    return new TextDecoder().decode(bytes);
  } catch {
    return fallback;
  }
}
function canPreview(filename?: string) {
  return /\.(pdf|png|jpe?g|gif|webp|bmp)$/i.test(filename || '');
}
async function previewAttachment(
  attachment: AgentConversationApi.ConversationAttachment,
) {
  const current = active.value;
  if (!current) return;
  try {
    const response = await downloadAgentConversationAttachment(
      current.conversation_id,
      attachment.attachment_id,
      'inline',
    );
    const filename = decodeAttachmentFilename(
      response.headers,
      attachment.filename || `附件-${attachment.attachment_id}`,
    );
    closeAttachmentPreview();
    attachmentPreview.value = {
      filename,
      kind: /\.pdf$/i.test(filename) ? 'pdf' : 'image',
      open: true,
      url: URL.createObjectURL(response.data),
    };
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  }
}
function closeAttachmentPreview() {
  if (attachmentPreview.value.url) URL.revokeObjectURL(attachmentPreview.value.url);
  attachmentPreview.value = { filename: '', kind: 'image', open: false, url: '' };
}
async function downloadAttachment(
  attachment: AgentConversationApi.ConversationAttachment,
) {
  const current = active.value;
  if (!current) return;
  try {
    const response = await downloadAgentConversationAttachment(
      current.conversation_id,
      attachment.attachment_id,
      'download',
    );
    downloadFileFromBlobPart({
      fileName: decodeAttachmentFilename(
        response.headers,
        attachment.filename || `附件-${attachment.attachment_id}`,
      ),
      source: response.data,
    });
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  }
}
async function removeConversation(
  item: AgentConversationApi.ConversationSummary,
) {
  try {
    await deleteAgentConversation(item.conversation_id);
    if (active.value?.conversation_id === item.conversation_id) {
      abortEventStream();
      active.value = undefined;
    }
    await reloadConversations();
    message.success('会话及附件已删除');
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  }
}
onMounted(boot);
onBeforeUnmount(() => {
  abortEventStream();
  closeAttachmentPreview();
});
watch(
  () => active.value?.conversation_id,
  (conversationId) => {
    executionProgress.value = undefined;
    if (conversationId && !active.value?.finished) startEventStream(conversationId);
    else abortEventStream();
  },
);
</script>
<template>
@@ -347,29 +859,49 @@
              v-if="!conversations.length"
              description="暂无补录会话"
            />
            <button
            <div
              v-for="item in conversations"
              :key="item.conversation_id"
              class="mb-1 w-full rounded-md p-2 text-left transition-colors hover:bg-gray-100 dark:hover:bg-gray-800"
              class="mb-1 flex w-full cursor-pointer items-start gap-1 rounded-md p-2 text-left transition-colors hover:bg-gray-100 dark:hover:bg-gray-800"
              :class="{
                'bg-blue-50 dark:bg-blue-900/30':
                  item.conversation_id === active?.conversation_id,
              }"
              type="button"
              role="button"
              tabindex="0"
              @click="openConversation(item.conversation_id)"
              @keydown.enter="openConversation(item.conversation_id)"
            >
              <div class="truncate text-sm font-medium">
                {{ item.title || '未命名补录' }}
              <div class="min-w-0 flex-1">
                <div class="truncate text-sm font-medium">
                  {{ item.title || '未命名补录' }}
                </div>
                <div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
                  <a-tag :color="statusColor(item.status)" class="!mr-0">
                    {{ statusText(item.status) }}
                  </a-tag>
                  <span class="truncate">
                    {{ item.module_title || '待识别' }}
                  </span>
                </div>
              </div>
              <div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
                <a-tag :color="statusColor(item.status)" class="!mr-0">
                  {{ statusText(item.status) }}
                </a-tag>
                <span class="truncate">
                  {{ item.module_title || '待识别' }}
                </span>
              </div>
            </button>
              <a-popconfirm
                cancel-text="取消"
                ok-text="删除"
                title="删除会话及已上传附件?"
                @confirm="removeConversation(item)"
              >
                <a-button
                  class="shrink-0"
                  danger
                  size="small"
                  type="text"
                  @click.stop
                >
                  åˆ é™¤
                </a-button>
              </a-popconfirm>
            </div>
          </a-spin>
        </div>
      </aside>
@@ -390,16 +922,108 @@
              </a-tag>
              <span v-if="active?.module_title">{{ active.module_title }}</span>
              <span v-if="active?.total">共 {{ active.total }} è¡Œ</span>
              <span v-if="active?.data_scope_summary || authorizationScope">
                æ•°æ®èŒƒå›´ï¼š{{ active?.data_scope_summary || authorizationScope }}
              </span>
              <span
                v-if="modelOptions.length"
                class="flex items-center gap-1"
                title="切换后从下一次分析开始生效"
              >
                æ¨¡åž‹ï¼š
                <a-select
                  :disabled="busy"
                  :options="modelOptions"
                  :value="selectedModelId"
                  class="w-56"
                  size="small"
                  @change="changeModel"
                />
              </span>
              <a-tag
                v-if="active"
                :color="eventStateColor"
                class="!mr-0"
                aria-live="polite"
              >
                {{ eventStateText }}
              </a-tag>
            </div>
            <div
              v-if="active?.attachments?.length"
              class="mt-1 flex flex-wrap items-center gap-2 text-xs text-gray-500"
            >
              <span>附件:</span>
              <span
                v-for="attachment in active.attachments"
                :key="attachment.attachment_id"
                class="flex items-center gap-1 rounded border border-border px-2 py-0.5"
              >
                <span class="max-w-60 truncate">
                  {{ attachment.filename || `附件 ${attachment.attachment_id}` }}
                </span>
                <a-button
                  v-if="canPreview(attachment.filename)"
                  class="!h-5 !px-1"
                  size="small"
                  type="link"
                  @click="previewAttachment(attachment)"
                >
                  é¢„览
                </a-button>
                <a-button
                  class="!h-5 !px-1"
                  size="small"
                  type="link"
                  @click="downloadAttachment(attachment)"
                >
                  ä¸‹è½½
                </a-button>
              </span>
            </div>
          </div>
          <a-button
            v-if="active?.receipt?.command_id"
            :disabled="busy"
            @click="downloadReport"
          >
            ä¸‹è½½å›žæ‰§
          </a-button>
          <div class="flex shrink-0 items-center gap-2">
            <a-tooltip
              :title="
                durableTasksEnabled
                  ? '查看跨页面执行的文档任务'
                  : '持久任务服务尚未启用'
              "
            >
              <a-button @click="taskCenter?.open()">后台任务</a-button>
            </a-tooltip>
            <a-button
              v-if="active?.receipt?.command_id"
              :disabled="busy"
              @click="downloadReport"
            >
              ä¸‹è½½å›žæ‰§
            </a-button>
          </div>
        </header>
        <div
          v-if="executionProgress && active"
          class="border-b border-border bg-blue-50/70 px-4 py-2 dark:bg-blue-950/20"
          aria-live="polite"
        >
          <div class="mb-1 flex items-center justify-between gap-4 text-xs">
            <span class="font-medium text-blue-700 dark:text-blue-300">
              {{ executionProgress.label }}
            </span>
            <span class="text-gray-500">
              å·²å‘现 {{ executionProgress.candidateCount }} æ¡å€™é€‰
              <template v-if="executionProgress.issueCount">
                Â· {{ executionProgress.issueCount }} é¡¹å¾…核对
              </template>
            </span>
          </div>
          <a-progress
            :percent="executionProgress.percent"
            :status="executionProgress.stage === 'error' ? 'exception' : 'active'"
            size="small"
          />
        </div>
        <div class="flex-1 overflow-y-auto p-4">
          <a-empty
@@ -610,6 +1234,21 @@
                </ul>
              </a-alert>
              <a-alert
                v-if="card?.repair_guidance?.length"
                class="mb-2"
                type="info"
                show-icon
              >
                <template #message>业务大模型核对建议</template>
                <ul class="mt-1 list-disc pl-4 text-xs">
                  <li v-for="(item, index) in card.repair_guidance" :key="index">
                    <span v-if="item.field" class="font-medium">{{ item.field }}:</span>
                    {{ item.guidance }}
                  </li>
                </ul>
              </a-alert>
              <div
                v-if="card?.summary"
                class="mb-2 flex flex-wrap gap-3 text-xs text-gray-600 dark:text-gray-300"
@@ -644,6 +1283,18 @@
                  å…³é—­åŽä»…创建 ERP ç‰©æ–™
                </span>
              </div>
              <a-table
                v-if="previewRows.length"
                class="mb-2"
                :columns="previewColumns"
                :data-source="previewRows"
                :pagination="false"
                :scroll="{ x: 'max-content', y: 320 }"
                row-key="candidate_id"
                size="small"
                bordered
              />
              <div class="flex gap-2">
                <a-input
@@ -685,29 +1336,124 @@
        </footer>
      </section>
      <TaskCenterDrawer
        ref="taskCenter"
        :enabled="durableTasksEnabled"
        @continue="continueFromTask"
      />
      <a-modal
        v-model:open="attachmentPreview.open"
        :footer="null"
        :title="attachmentPreview.filename"
        width="80%"
        @cancel="closeAttachmentPreview"
      >
        <iframe
          v-if="attachmentPreview.kind === 'pdf'"
          :src="attachmentPreview.url"
          class="h-[70vh] w-full border-0"
          title="附件预览"
        />
        <img
          v-else
          :src="attachmentPreview.url"
          alt="附件预览"
          class="max-h-[70vh] w-full object-contain"
        />
      </a-modal>
      <a-modal
        v-model:open="newOpen"
        title="新建补录"
        :confirm-loading="creating"
        ok-text="开始补录"
        cancel-text="取消"
        :width="560"
        @ok="createConversation"
        :width="760"
      >
        <div class="space-y-3">
          <div>
            <div class="mb-1 text-sm">业务场景</div>
            <div class="mb-1 flex items-center justify-between text-sm">
              <span>业务场景(可多选)</span>
              <a-button
                size="small"
                :type="newPriorities.scenario_paths ? 'primary' : 'default'"
                @click="togglePriority('scenario_paths')"
              >优先</a-button>
            </div>
            <a-select
              v-model:value="newModule"
              v-model:value="newScenarios"
              class="w-full"
              :options="moduleOptions"
            />
              mode="multiple"
              :max-tag-count="3"
              :options="scenarioOptions"
              option-filter-prop="label"
              placeholder="请选择当前补录涉及的菜单模块"
              show-search
            >
              <template #option="option">
                <div class="py-1">
                  <div class="font-medium">{{ option.label }}</div>
                  <div v-if="option.description" class="mt-0.5 text-xs text-gray-500">
                    {{ option.description }}
                  </div>
                </div>
              </template>
            </a-select>
            <div class="mt-1 text-xs text-gray-500">
              ä¸ç¡®å®šæ—¶å¯ä¿æŒè‡ªåŠ¨è¯†åˆ«ï¼Œç³»ç»Ÿä¼šæ ¹æ®è¡¨å¤´å’Œè¯´æ˜ŽæŽ¨èåœºæ™¯ã€‚
              å¼€å¯â€œä¼˜å…ˆâ€åŽæŒ‰ç¡¬çº¦æŸå¤„理;最终模块仍必须通过能力与数据权限校验。
            </div>
          </div>
          <div class="grid grid-cols-1 gap-3 md:grid-cols-2">
            <div>
              <div class="mb-1 flex items-center justify-between text-sm">
                <span>时间范围</span>
                <a-button size="small" :type="newPriorities.time_range ? 'primary' : 'default'" @click="togglePriority('time_range')">优先</a-button>
              </div>
              <a-range-picker v-model:value="newTimeRange" class="w-full" value-format="YYYY-MM-DD" />
            </div>
            <div>
              <div class="mb-1 flex items-center justify-between text-sm">
                <span>数据条数</span>
                <a-button size="small" :type="newPriorities.record_limit ? 'primary' : 'default'" @click="togglePriority('record_limit')">优先</a-button>
              </div>
              <a-input-number v-model:value="newRecordLimit" class="w-full" :min="1" :max="100000" placeholder="例如:500" />
            </div>
            <div>
              <div class="mb-1 flex items-center justify-between text-sm">
                <span>行业</span>
                <a-button size="small" :type="newPriorities.industry ? 'primary' : 'default'" @click="togglePriority('industry')">优先</a-button>
              </div>
              <a-input v-model:value="newIndustry" maxlength="80" placeholder="例如:汽车零部件" />
            </div>
            <div>
              <div class="mb-1 flex items-center justify-between text-sm">
                <span>区域</span>
                <a-button size="small" :type="newPriorities.region ? 'primary' : 'default'" @click="togglePriority('region')">优先</a-button>
              </div>
              <a-input v-model:value="newRegion" maxlength="120" placeholder="例如:华东 / æ±Ÿè‹å—通" />
            </div>
            <div>
              <div class="mb-1 flex items-center justify-between text-sm">
                <span>跳过休息日</span>
                <a-button size="small" :type="newPriorities.skip_rest_days ? 'primary' : 'default'" @click="togglePriority('skip_rest_days')">优先</a-button>
              </div>
              <div class="flex h-8 items-center gap-2">
                <a-switch v-model:checked="newSkipRestDays" />
                <span class="text-xs text-gray-500">{{ newSkipRestDays ? '跳过' : '不跳过' }}</span>
              </div>
            </div>
            <div>
              <div class="mb-1 flex items-center justify-between text-sm">
                <span>休息日描述</span>
                <a-button size="small" :type="newPriorities.rest_day_description ? 'primary' : 'default'" @click="togglePriority('rest_day_description')">优先</a-button>
              </div>
              <a-input v-model:value="newRestDayDescription" maxlength="300" placeholder="例如:周日及法定节假日" />
            </div>
          </div>
          <div>
            <div class="mb-1 text-sm">补录说明(可选)</div>
            <div class="mb-1 flex items-center justify-between text-sm">
              <span>补录说明(可选)</span>
              <a-button size="small" :type="newPriorities.instruction ? 'primary' : 'default'" @click="togglePriority('instruction')">优先</a-button>
            </div>
            <a-textarea
              v-model:value="newText"
              :rows="2"
@@ -719,20 +1465,58 @@
            <a-upload
              accept=".csv,.xlsx,.docx,.pdf"
              :before-upload="beforeNewUpload"
              :max-count="1"
              :max-count="20"
              multiple
              :show-upload-list="false"
            >
              <a-button>选择文件</a-button>
            </a-upload>
            <span v-if="newFileName" class="ml-2 text-xs text-gray-500">
              {{ newFileName }}
            <span v-if="newFiles.length" class="ml-2 text-xs text-gray-500">
              å·²é€‰æ‹© {{ newFiles.length }} ä¸ªæ–‡ä»¶
            </span>
            <a-button v-if="newFiles.length" class="ml-2" size="small" type="link" @click="clearNewFiles">
              æ¸…空
            </a-button>
            <div v-if="newFiles.length" class="mt-2 max-h-20 overflow-auto text-xs text-gray-500">
              <div v-for="file in newFiles" :key="`${file.name}:${file.size}:${file.lastModified}`">
                {{ file.name }}
              </div>
            </div>
            <div class="mt-1 text-xs text-gray-500">
              æ”¯æŒ CSV、XLSX、DOCX ä¸Žå¯å¤åˆ¶æ–‡æœ¬çš„ PDF,单个文件不超过 8
              MiB。扫描件暂不支持。
              æ”¯æŒä¸€æ¬¡é€‰æ‹©æœ€å¤š 20 ä¸ª CSV、XLSX、DOCX ä¸Žå¯å¤åˆ¶æ–‡æœ¬çš„ PDF,单个文件不超过 8
              MiB。多文件会在后台做跨文件重复和冲突检查;扫描件暂不支持。
            </div>
          </div>
        </div>
        <template #footer>
          <div class="flex flex-wrap justify-end gap-2">
            <a-button :disabled="creating" @click="newOpen = false">
              å–消
            </a-button>
            <a-tooltip
              :title="
                !durableTasksEnabled
                  ? '需先启用 PostgreSQL ä¸Ž Redis æŒä¹…任务服务'
                  : '任务会根据文件内容自动识别,关闭页面后仍可从任务中心恢复'
              "
            >
              <a-button
                :disabled="!durableTasksEnabled || !newFiles.length"
                :loading="creating"
                @click="startBackgroundAnalysis"
              >
                åŽå°åˆ†æž
              </a-button>
            </a-tooltip>
            <a-button
              type="primary"
              :loading="creating"
              @click="createConversation"
            >
              ç›´æŽ¥å¼€å§‹è¡¥å½•
            </a-button>
          </div>
        </template>
      </a-modal>
    </div>
  </Page>
src/views/ai/model/model/data.ts
@@ -1,7 +1,9 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { AiModelTypeEnum, CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import type { AiModelModelApi } from '#/api/ai/model/model';
import { AiModelCapabilityEnum, AiModelTypeEnum, CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form';
@@ -41,6 +43,26 @@
        };
      },
      rules: 'required',
    },
    {
      fieldName: 'capability',
      label: '模型能力',
      component: 'Select',
      componentProps: (values) => {
        return {
          placeholder: '请选择模型能力',
          // åªæœ‰å¯¹è¯ç±»åž‹åŒºåˆ†æ–‡æœ¬/视觉,其余类型统一按文本能力处理
          disabled: !!values.id || values.type !== AiModelTypeEnum.CHAT,
          options: getDictOptions(DICT_TYPE.AI_MODEL_CAPABILITY, 'string'),
          allowClear: true,
        };
      },
      help: '同一类型同一能力只能启用一个模型;视觉能力用于图片识别',
      dependencies: {
        triggerFields: ['type'],
        show: (values) => values.type === AiModelTypeEnum.CHAT,
      },
      rules: z.string().default(AiModelCapabilityEnum.TEXT),
    },
    {
      fieldName: 'keyId',
@@ -162,11 +184,31 @@
      },
    },
    {
      fieldName: 'type',
      label: '模型类型',
      component: 'Select',
      componentProps: {
        placeholder: '请选择模型类型',
        options: getDictOptions(DICT_TYPE.AI_MODEL_TYPE, 'number'),
        allowClear: true,
      },
    },
    {
      fieldName: 'model',
      label: '模型标识',
      component: 'Input',
      componentProps: {
        placeholder: '请输入模型标识',
        allowClear: true,
      },
    },
    {
      fieldName: 'capability',
      label: '模型能力',
      component: 'Select',
      componentProps: {
        placeholder: '请选择模型能力',
        options: getDictOptions(DICT_TYPE.AI_MODEL_CAPABILITY, 'string'),
        allowClear: true,
      },
    },
@@ -183,23 +225,34 @@
}
/** åˆ—表的字段 */
export function useGridColumns(): VxeTableGridOptions['columns'] {
export function useGridColumns(
  onStatusChange?: (newStatus: number, row: AiModelModelApi.Model) => Promise<boolean | undefined>,
): VxeTableGridOptions['columns'] {
  return [
    {
      field: 'platform',
      title: '所属平台',
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.AI_PLATFORM },
      },
      minWidth: 100,
    },
    {
      field: 'type',
      title: '模型类型',
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.AI_MODEL_TYPE },
      },
      minWidth: 100,
    },
    {
      field: 'capability',
      title: '模型能力',
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.AI_MODEL_CAPABILITY },
      },
      minWidth: 100,
    },
    {
      field: 'platform',
      title: '所属平台',
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.AI_PLATFORM },
      },
      minWidth: 100,
    },
@@ -225,12 +278,17 @@
    },
    {
      field: 'status',
      title: '状态',
      title: '启用',
      minWidth: 100,
      align: 'center',
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.COMMON_STATUS },
        attrs: { beforeChange: onStatusChange },
        name: 'CellSwitch',
        props: {
          checkedValue: CommonStatusEnum.ENABLE,
          unCheckedValue: CommonStatusEnum.DISABLE,
        },
      },
      minWidth: 80,
    },
    {
      field: 'temperature',
src/views/ai/model/model/index.vue
@@ -2,12 +2,15 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AiModelModelApi } from '#/api/ai/model/model';
import { Page, useVbenModal } from '@vben/common-ui';
import { ref } from 'vue';
import { confirm, Page, useVbenModal } from '@vben/common-ui';
import { CommonStatusEnum } from '@vben/constants';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteModel, getModelPage } from '#/api/ai/model/model';
import { deleteModel, getModelPage, updateModelStatus } from '#/api/ai/model/model';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
@@ -17,6 +20,9 @@
  connectedComponent: Form,
  destroyOnClose: true,
});
/** å½“前页数据,spanMethod ä¾èµ–它计算类型列合并行数 */
const currentRows = ref<AiModelModelApi.Model[]>([]);
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
@@ -48,22 +54,71 @@
  }
}
/** æ›´æ–°æ¨¡åž‹å¯ç”¨çŠ¶æ€ï¼šå¯ç”¨ä¼šè‡ªåŠ¨å…³é—­åŒç±»åž‹åŒèƒ½åŠ›çš„å…¶ä»–æ¨¡åž‹ */
async function handleStatusChange(
  newStatus: number,
  row: AiModelModelApi.Model,
): Promise<boolean | undefined> {
  try {
    await confirm(
      newStatus === CommonStatusEnum.ENABLE
        ? `启用「${row.name}」后,同类型同能力下已启用的其他模型会自动关闭,确定继续吗?`
        : `你要停用「${row.name}」吗?停用后该类型该能力将没有可用模型。`,
    );
  } catch {
    return false;
  }
  await updateModelStatus(row.id!, newStatus);
  message.success($t('ui.actionMessage.operationSuccess'));
  // åŒç»„其他模型的启用状态已被后端改写,必须整页刷新
  handleRefresh();
  return true;
}
/** æ¨¡åž‹ç±»åž‹åˆ—按类型纵向合并,形成分类展示 */
function handleSpanMethod({
  column,
  rowIndex,
}: {
  column: { field?: string; property?: string };
  rowIndex: number;
}) {
  const field = column.field || column.property;
  if (field !== 'type') {
    return { colspan: 1, rowspan: 1 };
  }
  const row = currentRows.value[rowIndex];
  if (rowIndex > 0 && currentRows.value[rowIndex - 1]?.type === row?.type) {
    return { colspan: 0, rowspan: 0 };
  }
  let rowspan = 1;
  for (let index = rowIndex + 1; index < currentRows.value.length; index += 1) {
    if (currentRows.value[index]?.type !== row?.type) {
      break;
    }
    rowspan += 1;
  }
  return { colspan: 1, rowspan };
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    columns: useGridColumns(handleStatusChange),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) => {
          return await getModelPage({
          const data = await getModelPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          });
          currentRows.value = data.list || [];
          return data;
        },
      },
    },
@@ -71,6 +126,7 @@
      keyField: 'id',
      isHover: true,
    },
    spanMethod: handleSpanMethod,
    toolbarConfig: {
      refresh: true,
      search: true,