云
2026-10-22 9ad5fd8dd2994748c44295a3b60736f9a441169f
feat(agent): 添加数据补录会话功能

- 实现了与Python代理API的通信客户端
- 添加了能力声明、会话列表、创建和读取会话等功能接口
- 集成了文件上传和绑定补录会话的功能
- 创建了完整的数据补录界面组件
- 实现场景选择、字段映射、校验预览等交互流程
- 添加了补传文件、修订确认和取消等操作功能
- 实现了导入回执下载和状态管理功能
已添加2个文件
1063 ■■■■■ 文件已修改
src/api/agent/conversation.ts 324 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/agent/dataEntry/index.vue 739 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/agent/conversation.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,324 @@
import type { InternalAxiosRequestConfig } from '@vben/request';
import {
  defaultResponseInterceptor,
  RequestClient,
} from '@vben/request';
import { useAccessStore } from '@vben/stores';
import {
  bindImportAttachment,
  createImportSession,
} from '#/api/mdm/item/agent';
import { uploadFile } from '#/api/system/storage';
/**
 * æ¨¡å—数据补录会话服务(Python)不在 /admin-api ä¸‹ï¼Œ
 * å¼€å‘期由 Vite ä»£ç† /agent-api è½¬å‘到 127.0.0.1:8091,且不返回 CommonResult åŒ…装。
 */
const agentClient = new RequestClient({
  baseURL: '/agent-api',
  responseReturn: 'body',
  timeout: 120_000,
});
agentClient.addRequestInterceptor({
  fulfilled: (config: InternalAxiosRequestConfig) => {
    const token = useAccessStore().accessToken;
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
});
// Python æœåŠ¡ç›´æŽ¥è¿”å›žä¸šåŠ¡ JSON,不套 CommonResult;responseReturn: 'body' æ—¶åŽŸæ ·è¿”å›žå“åº”ä½“ã€‚
agentClient.addResponseInterceptor(
  defaultResponseInterceptor({
    codeField: 'code',
    dataField: 'data',
    successCode: 0,
  }),
);
export namespace AgentConversationApi {
  export interface FieldSpec {
    name: string;
    label: string;
    required: boolean;
    kind: string;
  }
  export interface ModuleSpec {
    module_id: string;
    title: string;
    capability: string;
    bridge: string;
    permission: string;
    description: string;
    required_fields: string[];
    fields: FieldSpec[];
  }
  export interface Capabilities {
    version: string;
    formats: string[];
    ocr: boolean;
    cross_file_dedupe: boolean;
    import_modes: string[];
    notes: string[];
    modules: ModuleSpec[];
  }
  export interface Issue {
    code?: string;
    field?: string;
    message: string;
    candidate_id?: string;
  }
  export interface Summary {
    total?: number;
    create?: number;
    skip?: number;
    blocked?: number;
    success?: number;
    failed?: number;
    skipped?: number;
    unknown?: number;
  }
  export interface ReceiptRow {
    candidate_id: string;
    row_no?: number;
    code?: string;
    name?: string;
    action: string;
    status: string;
    target_item_id?: number;
    error_code?: string;
    error_message?: string;
  }
  export interface Receipt {
    command_id: string;
    idempotency_key: string;
    status: string;
    attachment_id: number;
    source_sha256: string;
    preflight_hash: string;
    summary: Summary;
    rows: ReceiptRow[];
    create_time?: string;
    completed_time?: string;
    error_code?: string;
    error_message?: string;
  }
  export interface ModuleCandidate {
    module_id: string;
    title: string;
    capability: string;
    description: string;
  }
  export interface MissingField {
    name: string;
    label: string;
  }
  /** å¡ç‰‡å­—段按 kind å¤ç”¨ï¼Œæœªä½¿ç”¨çš„字段为空。 */
  export interface Card {
    kind: string;
    title?: string;
    body?: string;
    module_id?: string;
    module_title?: string;
    capability?: string;
    revision?: number;
    issues?: Issue[];
    warnings?: Issue[];
    summary?: Summary;
    can_commit?: boolean;
    side_effects?: string[];
    sync_mes?: boolean | null;
    total?: number;
    actions?: string[];
    notice?: string;
    candidates?: ModuleCandidate[];
    missing_fields?: MissingField[];
    columns?: string[];
    command_id?: string;
    status?: string;
    rows?: ReceiptRow[];
  }
  export interface Message {
    message_id: string;
    role: string;
    type: string;
    text: string;
    cards?: Card[];
  }
  export interface Interrupt {
    interrupt_id: string;
    kind: string;
    revision: number;
    validation_revision?: number | null;
    card: Card;
  }
  export interface RowView {
    candidate_id: string;
    values: Record<string, string>;
    issues: unknown[];
    warnings: unknown[];
  }
  export interface Conversation {
    conversation_id: string;
    title: string;
    status: string;
    module_id: string;
    module_title: string;
    capability: string;
    revision: number;
    validation_revision?: number | null;
    sync_mes?: boolean;
    total: number;
    summary: Summary;
    issues: Issue[];
    warnings: Issue[];
    missing_fields: string[];
    columns: string[];
    messages: Message[];
    interrupts: Interrupt[];
    /** æœªæäº¤æ—¶åŽç«¯è¿”回空对象 {},提交后才有完整回执。 */
    receipt?: null | Partial<Receipt>;
    last_error: string;
    rows: RowView[];
    created_at: string;
    updated_at: string;
    finished: boolean;
    commit_enabled: boolean;
    mes_sync_default: boolean;
  }
  export interface ConversationSummary {
    conversation_id: string;
    title: string;
    status: string;
    module_id: string;
    module_title: string;
    revision: number;
    created_at: string;
    updated_at: string;
  }
  export interface PageResult {
    items: ConversationSummary[];
    total: number;
    page: number;
    page_size: number;
  }
  export interface CreateReq {
    title?: string;
    text?: string;
    attachment_id?: string;
    module_id?: string;
  }
  export interface ActionReq {
    interrupt_id: string;
    revision: number;
    action: string;
    payload: Record<string, unknown>;
  }
  export interface MessageReq {
    text?: string;
    attachment_id?: string;
  }
}
/** èƒ½åŠ›å£°æ˜Žï¼šæ”¯æŒçš„æ ¼å¼ã€æ¨¡å—ä¸Žé™åˆ¶ */
export function getAgentCapabilities() {
  return agentClient.get<AgentConversationApi.Capabilities>('/v1/capabilities');
}
/** ä¼šè¯åˆ—表 */
export function listAgentConversations(params?: {
  page?: number;
  page_size?: number;
  status?: string;
}) {
  return agentClient.get<AgentConversationApi.PageResult>(
    '/v1/conversations',
    { params },
  );
}
/** æ–°å»ºè¡¥å½•会话 */
export function createAgentConversation(data: AgentConversationApi.CreateReq) {
  return agentClient.post<AgentConversationApi.Conversation>(
    '/v1/conversations',
    data,
  );
}
/** è¯»å–会话 */
export function getAgentConversation(conversationId: string) {
  return agentClient.get<AgentConversationApi.Conversation>(
    `/v1/conversations/${conversationId}`,
  );
}
/** å‘送文本或附件(用于补充上传文件) */
export function sendAgentMessage(
  conversationId: string,
  data: AgentConversationApi.MessageReq,
) {
  return agentClient.post<AgentConversationApi.Conversation>(
    `/v1/conversations/${conversationId}/messages`,
    data,
  );
}
/** å¡ç‰‡æ“ä½œï¼šé€‰æ‹©åœºæ™¯ã€ç¡®è®¤æ˜ å°„、修订、确认或取消 */
export function sendAgentAction(
  conversationId: string,
  data: AgentConversationApi.ActionReq,
) {
  return agentClient.post<AgentConversationApi.Conversation>(
    `/v1/conversations/${conversationId}/actions`,
    data,
  );
}
/** ä¸‹è½½å¯¼å…¥æŠ¥è¡¨ï¼ˆxlsx) */
export function downloadAgentReport(conversationId: string) {
  return agentClient.download<{
    data: Blob;
    headers: Record<string, string>;
  }>(`/v1/conversations/${conversationId}/report`, {
    responseReturn: 'raw',
  });
}
/**
 * ä¸Šä¼ é™„件到 Java å­˜å‚¨å¹¶ç»‘定补录会话,返回 attachmentId。
 * Python ä¾§é€šè¿‡ Java Bridge æŒ‰ attachmentId è¯»å–文件。
 */
export async function uploadAgentAttachment(file: File) {
  const sessionId = await createImportSession();
  const blobs = await uploadFile([file]);
  const blobId = blobs?.[0]?.id;
  if (!blobId) {
    throw new Error('文件上传失败,未取得 blobId');
  }
  return await bindImportAttachment(sessionId, blobId);
}
export { agentImportErrorMessage } from '#/api/mdm/item/agent';
src/views/agent/dataEntry/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,739 @@
<script lang="ts" setup>
import type { FileType } from 'ant-design-vue/es/upload/interface';
import type { AgentConversationApi } from '#/api/agent/conversation';
import { computed, onMounted, ref, watch } from 'vue';
import { Page } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { message } from 'ant-design-vue';
import {
  agentImportErrorMessage,
  createAgentConversation,
  downloadAgentReport,
  getAgentCapabilities,
  getAgentConversation,
  listAgentConversations,
  sendAgentAction,
  sendAgentMessage,
  uploadAgentAttachment,
} from '#/api/agent/conversation';
defineOptions({ name: 'AgentDataEntry' });
const MAX_BYTES = 8 * 1024 * 1024;
const ACCEPT = '.csv,.xlsx,.docx,.pdf';
const modules = ref<AgentConversationApi.ModuleSpec[]>([]);
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 newText = ref('');
const newFile = ref<File>();
const newFileName = ref('');
const interruptFile = ref<File>();
const mappingDraft = ref<Record<string, string>>({});
const reviseText = ref('');
const syncMes = ref(true);
const interrupt = computed(() => active.value?.interrupts?.at(-1));
const card = computed(() => interrupt.value?.card);
const actions = computed(() => card.value?.actions ?? []);
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 })),
]);
watch(
  () => interrupt.value?.interrupt_id,
  () => {
    reviseText.value = '';
    mappingDraft.value = {};
    interruptFile.value = undefined;
    const value = card.value?.sync_mes;
    syncMes.value =
      typeof value === 'boolean'
        ? value
        : (active.value?.sync_mes ?? active.value?.mes_sync_default ?? true);
  },
);
function statusText(status?: string) {
  const map: Record<string, string> = {
    SUCCEEDED: '导入成功',
    PARTIAL_FAILED: '部分失败',
    FAILED: '失败',
    UNKNOWN: '结果未知',
    RUNNING: '提交中',
    attached: '已上传',
    blocked: '已阻断',
    cancelled: '已取消',
    classified: '已识别场景',
    commit_disabled: '未提交',
    confirmed: '已确认',
    created: '待上传文件',
    error: '出错',
    failed: '失败',
    mapping_applied: '映射已更新',
    needs_review: '待确认',
    parsed: '已解析',
    preview_ready: '待确认',
    revise: '待重新校验',
    stale: '已过期,需重检',
    validated: '待确认',
  };
  return map[status ?? ''] ?? status ?? '进行中';
}
function statusColor(status?: string) {
  if (status === 'SUCCEEDED' || status === 'confirmed') return 'green';
  if (
    status === 'PARTIAL_FAILED' ||
    status === 'revise' ||
    status === 'stale'
  ) {
    return 'orange';
  }
  if (
    status === 'FAILED' ||
    status === 'failed' ||
    status === 'error' ||
    status === 'blocked' ||
    status === 'cancelled'
  ) {
    return 'red';
  }
  return 'blue';
}
function checkFile(raw: File) {
  const name = raw.name.toLowerCase();
  if (!ACCEPT.split(',').some((ext) => name.endsWith(ext))) {
    message.error('仅支持 CSV、XLSX、DOCX æˆ– PDF æ–‡ä»¶');
    return false;
  }
  if (raw.size > MAX_BYTES) {
    message.error('文件不能超过 8 MiB');
    return false;
  }
  return true;
}
function beforeNewUpload(fileInfo: FileType) {
  const raw = fileInfo as unknown as File;
  if (!checkFile(raw)) return false;
  newFile.value = raw;
  newFileName.value = raw.name;
  return false;
}
function beforeInterruptUpload(fileInfo: FileType) {
  const raw = fileInfo as unknown as File;
  if (!checkFile(raw)) return false;
  interruptFile.value = raw;
  return false;
}
async function boot() {
  loading.value = true;
  try {
    const [capabilities, list] = await Promise.all([
      getAgentCapabilities(),
      listAgentConversations({ page_size: 50 }),
    ]);
    modules.value = capabilities.modules ?? [];
    conversations.value = list.items ?? [];
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  } finally {
    loading.value = false;
  }
}
async function reloadConversations() {
  try {
    const list = await listAgentConversations({ page_size: 50 });
    conversations.value = list.items ?? [];
  } catch {
    // åˆ—表刷新失败不阻塞主流程
  }
}
async function openConversation(conversationId: string) {
  if (busy.value) return;
  busy.value = true;
  try {
    active.value = await getAgentConversation(conversationId);
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  } finally {
    busy.value = false;
  }
}
async function createConversation() {
  if (!newFile.value && !newText.value.trim()) {
    message.warning('请上传文件或填写补录说明');
    return;
  }
  creating.value = true;
  try {
    let attachmentId: number | undefined;
    if (newFile.value) {
      attachmentId = await uploadAgentAttachment(newFile.value);
    }
    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,
    });
    newOpen.value = false;
    newFile.value = undefined;
    newFileName.value = '';
    newText.value = '';
    await reloadConversations();
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  } finally {
    creating.value = false;
  }
}
async function apply(result: AgentConversationApi.Conversation, tip?: string) {
  active.value = result;
  if (tip) message.success(tip);
  await reloadConversations();
}
async function act(
  action: string,
  payload: Record<string, unknown> = {},
  tip?: string,
) {
  const current = active.value;
  const pending = interrupt.value;
  if (!current || !pending) return;
  busy.value = true;
  try {
    const result = await sendAgentAction(current.conversation_id, {
      action,
      interrupt_id: pending.interrupt_id,
      payload,
      revision: pending.revision,
    });
    await apply(result, tip);
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  } finally {
    busy.value = false;
  }
}
function chooseModule(moduleId: string) {
  return act('choose_scenario', { module_id: moduleId }, '已选择业务场景');
}
async function confirmMapping() {
  const missing = card.value?.missing_fields ?? [];
  const mapping: Record<string, string> = {};
  for (const field of missing) {
    const column = mappingDraft.value[field.name];
    if (!column) {
      message.warning(`请为「${field.label}」选择对应列`);
      return;
    }
    mapping[field.name] = column;
  }
  await act('confirm_mapping', { mapping }, '映射已提交,重新校验');
}
async function uploadForFileInterrupt() {
  const current = active.value;
  if (!current) return;
  if (!interruptFile.value) {
    message.warning('请先选择文件');
    return;
  }
  busy.value = true;
  try {
    const attachmentId = await uploadAgentAttachment(interruptFile.value);
    const result = await sendAgentMessage(current.conversation_id, {
      attachment_id: String(attachmentId),
      text: '已上传要补录的文件。',
    });
    interruptFile.value = undefined;
    await apply(result);
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  } finally {
    busy.value = false;
  }
}
async function revise() {
  const text = reviseText.value.trim();
  const payload: Record<string, unknown> = {};
  if (text) payload.text = text;
  if (isGatewayModule.value) payload.sync_mes = syncMes.value;
  if (!text && !isGatewayModule.value) {
    message.warning('请输入修订内容,例如“第2行 åç§° æ”¹ä¸º XXX”');
    return;
  }
  await act('revise', payload, '已提交修订,重新校验');
  reviseText.value = '';
}
async function toggleSync(value: boolean) {
  syncMes.value = value;
  await act('revise', { sync_mes: value }, '已更新同步选项,重新校验');
}
function confirmImport() {
  return act('confirm', {}, '已确认,正在提交');
}
function cancelConversation() {
  return act('cancel', {}, '已取消本次补录');
}
async function downloadReport() {
  const current = active.value;
  if (!current) return;
  try {
    const response = await downloadAgentReport(current.conversation_id);
    const disposition = String(
      response.headers?.['content-disposition'] ?? '',
    );
    const match = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(disposition);
    downloadFileFromBlobPart({
      fileName: match?.[1] || `${current.title || '补录回执'}.xlsx`,
      source: response.data,
    });
  } catch (error) {
    message.error(agentImportErrorMessage(error));
  }
}
onMounted(boot);
</script>
<template>
  <Page auto-content-height>
    <div class="flex h-full gap-3 p-3">
      <aside
        class="flex w-64 shrink-0 flex-col overflow-hidden rounded-lg border border-border bg-card"
      >
        <div class="border-b border-border p-3">
          <a-button type="primary" block @click="newOpen = true">
            æ–°å»ºè¡¥å½•
          </a-button>
          <div class="mt-2 text-xs text-gray-500">
            ä¸Šä¼ æ–‡ä»¶åŽè‡ªåŠ¨è¯†åˆ«ä¸šåŠ¡åœºæ™¯ï¼Œç¡®è®¤å‰ä¼šå®Œæ•´æ ¡éªŒã€‚
          </div>
        </div>
        <div class="flex-1 overflow-y-auto p-2">
          <a-spin :spinning="loading">
            <a-empty
              v-if="!conversations.length"
              description="暂无补录会话"
            />
            <button
              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="{
                'bg-blue-50 dark:bg-blue-900/30':
                  item.conversation_id === active?.conversation_id,
              }"
              type="button"
              @click="openConversation(item.conversation_id)"
            >
              <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>
            </button>
          </a-spin>
        </div>
      </aside>
      <section
        class="flex min-w-0 flex-1 flex-col overflow-hidden rounded-lg border border-border bg-card"
      >
        <header
          class="flex items-center justify-between border-b border-border px-4 py-3"
        >
          <div class="min-w-0">
            <div class="truncate text-base font-semibold">
              {{ active?.title || '模块数据补录' }}
            </div>
            <div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
              <a-tag :color="statusColor(active?.status)" class="!mr-0">
                {{ statusText(active?.status) }}
              </a-tag>
              <span v-if="active?.module_title">{{ active.module_title }}</span>
              <span v-if="active?.total">共 {{ active.total }} è¡Œ</span>
            </div>
          </div>
          <a-button
            v-if="active?.receipt?.command_id"
            :disabled="busy"
            @click="downloadReport"
          >
            ä¸‹è½½å›žæ‰§
          </a-button>
        </header>
        <div class="flex-1 overflow-y-auto p-4">
          <a-empty
            v-if="!active"
            description="从左侧选择会话,或点击「新建补录」开始"
          />
          <div v-else class="space-y-3">
            <div
              v-for="item in active.messages"
              :key="item.message_id"
              class="flex"
              :class="item.role === 'user' ? 'justify-end' : 'justify-start'"
            >
              <div
                class="max-w-[80%] rounded-lg px-3 py-2 text-sm"
                :class="
                  item.role === 'user'
                    ? 'bg-blue-500 text-white'
                    : 'bg-gray-100 dark:bg-gray-800'
                "
              >
                <div class="whitespace-pre-wrap">{{ item.text }}</div>
                <div
                  v-for="(msgCard, index) in item.cards || []"
                  :key="`${item.message_id}-${index}`"
                  class="mt-2 rounded-md border border-border bg-card p-2 text-gray-800 dark:text-gray-100"
                >
                  <template v-if="msgCard.kind === 'receipt'">
                    <div class="mb-1 font-semibold">
                      {{ msgCard.title || '导入回执' }}
                    </div>
                    <div class="text-xs text-gray-500">
                      æˆåŠŸ {{ msgCard.summary?.success ?? 0 }},跳过
                      {{ msgCard.summary?.skipped ?? 0 }},失败
                      {{ msgCard.summary?.failed ?? 0 }}
                    </div>
                    <a-table
                      class="mt-2"
                      :columns="[
                        { title: '编码', dataIndex: 'code', width: 160 },
                        { title: '名称', dataIndex: 'name' },
                        { title: '结果', dataIndex: 'status', width: 90 },
                      ]"
                      :data-source="msgCard.rows || []"
                      :pagination="false"
                      row-key="candidate_id"
                      size="small"
                    >
                      <template #bodyCell="{ column, record }">
                        <a-tag
                          v-if="column.dataIndex === 'status'"
                          :color="record.status === 'SUCCESS' ? 'green' : 'red'"
                        >
                          {{ record.status }}
                        </a-tag>
                      </template>
                    </a-table>
                    <a-button
                      class="mt-2"
                      size="small"
                      type="link"
                      @click="downloadReport"
                    >
                      ä¸‹è½½æŠ¥è¡¨
                    </a-button>
                  </template>
                </div>
              </div>
            </div>
          </div>
        </div>
        <footer v-if="active && interrupt" class="border-t border-border p-3">
          <a-spin :spinning="busy">
            <!-- åœºæ™¯é€‰æ‹© -->
            <div v-if="interrupt.kind === 'scenario'">
              <div class="mb-2 font-medium">{{ card?.title }}</div>
              <div class="mb-2 text-xs text-gray-500">{{ card?.body }}</div>
              <div class="flex flex-wrap gap-2">
                <a-button
                  v-for="candidate in card?.candidates || []"
                  :key="candidate.module_id"
                  :disabled="busy"
                  @click="chooseModule(candidate.module_id)"
                >
                  {{ candidate.title }}
                  <a-tag class="ml-1" color="blue">
                    {{ candidate.capability }}
                  </a-tag>
                </a-button>
                <a-button
                  v-if="actions.includes('cancel')"
                  danger
                  :disabled="busy"
                  @click="cancelConversation"
                >
                  å–消
                </a-button>
              </div>
            </div>
            <!-- å­—段映射 -->
            <div v-else-if="interrupt.kind === 'mapping'">
              <div class="mb-2 font-medium">{{ card?.title }}</div>
              <div class="mb-2 text-xs text-gray-500">{{ card?.body }}</div>
              <div class="space-y-2">
                <div
                  v-for="field in card?.missing_fields || []"
                  :key="field.name"
                  class="flex items-center gap-2"
                >
                  <span class="w-28 text-sm">{{ field.label }}</span>
                  <a-select
                    v-model:value="mappingDraft[field.name]"
                    class="min-w-56"
                    placeholder="选择文件中对应的列"
                    :options="
                      (card?.columns || []).map((column) => ({
                        label: column,
                        value: column,
                      }))
                    "
                  />
                </div>
              </div>
              <div class="mt-3 flex gap-2">
                <a-button
                  type="primary"
                  :loading="busy"
                  @click="confirmMapping"
                >
                  ç¡®è®¤æ˜ å°„
                </a-button>
                <a-button danger :disabled="busy" @click="cancelConversation">
                  å–消
                </a-button>
              </div>
            </div>
            <!-- è¡¥ä¼ æ–‡ä»¶ -->
            <div v-else-if="interrupt.kind === 'file'">
              <div class="mb-2 font-medium">{{ card?.title }}</div>
              <div class="mb-2 text-xs text-gray-500">{{ card?.body }}</div>
              <a-upload
                accept=".csv,.xlsx,.docx,.pdf"
                :before-upload="beforeInterruptUpload"
                :max-count="1"
                :show-upload-list="false"
              >
                <a-button>{{ interruptFile ? '重新选择' : '选择文件' }}</a-button>
              </a-upload>
              <span v-if="interruptFile" class="ml-2 text-xs text-gray-500">
                {{ interruptFile.name }}
              </span>
              <div class="mt-3">
                <a-button
                  type="primary"
                  :disabled="!interruptFile"
                  :loading="busy"
                  @click="uploadForFileInterrupt"
                >
                  ä¸Šä¼ å¹¶ç»§ç»­
                </a-button>
              </div>
            </div>
            <!-- æ ¡éªŒ / é¢„览确认 -->
            <div v-else-if="['preview', 'validation'].includes(interrupt.kind)">
              <div class="mb-2 flex items-center justify-between">
                <span class="font-medium">{{ card?.title }}</span>
                <span class="text-xs text-gray-500">
                  {{ card?.notice }}
                </span>
              </div>
              <a-alert
                v-if="card?.issues?.length"
                class="mb-2"
                type="error"
                show-icon
              >
                <template #message>
                  é˜»æ–­é¡¹ {{ card.issues.length }} æ¡ï¼Œéœ€ä¿®è®¢åŽé‡æ–°æ ¡éªŒ
                </template>
                <ul class="mt-1 list-decimal pl-4 text-xs">
                  <li v-for="(issue, index) in card.issues" :key="index">
                    {{ issue.message }}
                    <span v-if="issue.code" class="text-gray-400">
                      ï¼ˆ{{ issue.code }})
                    </span>
                  </li>
                </ul>
              </a-alert>
              <a-alert
                v-if="card?.warnings?.length"
                class="mb-2"
                type="warning"
                show-icon
              >
                <template #message>
                  æç¤º {{ card.warnings.length }} æ¡ï¼Œä¸é˜»æ–­æäº¤
                </template>
                <ul class="mt-1 list-decimal pl-4 text-xs">
                  <li v-for="(item, index) in card.warnings" :key="index">
                    {{ item.message }}
                  </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"
              >
                <span>共 {{ card.summary.total ?? 0 }} è¡Œ</span>
                <span class="text-green-600">
                  æ–°å»º {{ card.summary.create ?? 0 }}
                </span>
                <span class="text-orange-500">
                  è·³è¿‡ {{ card.summary.skip ?? 0 }}
                </span>
                <span class="text-red-500">
                  é˜»æ–­ {{ card.summary.blocked ?? 0 }}
                </span>
              </div>
              <div v-if="card?.side_effects?.length" class="mb-2 text-xs text-gray-500">
                é¢„期影响:{{ card.side_effects.join(';') }}
              </div>
              <div
                v-if="isGatewayModule"
                class="mb-2 flex items-center gap-2 text-sm"
              >
                <span>同步到 MES</span>
                <a-switch
                  :checked="syncMes"
                  :disabled="busy"
                  @change="toggleSync"
                />
                <span class="text-xs text-gray-500">
                  å…³é—­åŽä»…创建 ERP ç‰©æ–™
                </span>
              </div>
              <div class="flex gap-2">
                <a-input
                  v-model:value="reviseText"
                  class="flex-1"
                  placeholder="如需修改,输入修订说明,例如:第2行 åç§° æ”¹ä¸º XXX"
                  :disabled="busy"
                  @press-enter="revise"
                />
                <a-button :disabled="busy" @click="revise">修订并重检</a-button>
                <a-button
                  v-if="actions.includes('confirm')"
                  type="primary"
                  :disabled="busy || card?.can_commit === false"
                  :loading="busy"
                  @click="confirmImport"
                >
                  ç¡®è®¤å¯¼å…¥
                </a-button>
                <a-button
                  v-if="actions.includes('cancel')"
                  danger
                  :disabled="busy"
                  @click="cancelConversation"
                >
                  å–消
                </a-button>
              </div>
            </div>
            <a-alert
              v-else
              type="info"
              show-icon
              :message="card?.title || '当前步骤需要处理'"
              :description="card?.body"
            />
          </a-spin>
        </footer>
      </section>
      <a-modal
        v-model:open="newOpen"
        title="新建补录"
        :confirm-loading="creating"
        ok-text="开始补录"
        cancel-text="取消"
        :width="560"
        @ok="createConversation"
      >
        <div class="space-y-3">
          <div>
            <div class="mb-1 text-sm">业务场景</div>
            <a-select
              v-model:value="newModule"
              class="w-full"
              :options="moduleOptions"
            />
            <div class="mt-1 text-xs text-gray-500">
              ä¸ç¡®å®šæ—¶å¯ä¿æŒè‡ªåŠ¨è¯†åˆ«ï¼Œç³»ç»Ÿä¼šæ ¹æ®è¡¨å¤´å’Œè¯´æ˜ŽæŽ¨èåœºæ™¯ã€‚
            </div>
          </div>
          <div>
            <div class="mb-1 text-sm">补录说明(可选)</div>
            <a-textarea
              v-model:value="newText"
              :rows="2"
              placeholder="例如:导入一批供应商档案"
            />
          </div>
          <div>
            <div class="mb-1 text-sm">数据文件</div>
            <a-upload
              accept=".csv,.xlsx,.docx,.pdf"
              :before-upload="beforeNewUpload"
              :max-count="1"
              :show-upload-list="false"
            >
              <a-button>选择文件</a-button>
            </a-upload>
            <span v-if="newFileName" class="ml-2 text-xs text-gray-500">
              {{ newFileName }}
            </span>
            <div class="mt-1 text-xs text-gray-500">
              æ”¯æŒ CSV、XLSX、DOCX ä¸Žå¯å¤åˆ¶æ–‡æœ¬çš„ PDF,单个文件不超过 8
              MiB。扫描件暂不支持。
            </div>
          </div>
        </div>
      </a-modal>
    </div>
  </Page>
</template>