From 84b90d1e66ba297ce0d4b812f60b3656ba0bf3dc Mon Sep 17 00:00:00 2001
From: 云 <2163098428@qq.com>
Date: 星期五, 23 十月 2026 17:01:18 +0800
Subject: [PATCH] feat(ai): 更新AI功能配置和数据补录界面

---
 src/views/agent/dataEntry/index.vue |  908 ++++++++++++++++++++++++++++++++++++++++++++++++++++---
 1 files changed, 846 insertions(+), 62 deletions(-)

diff --git a/src/views/agent/dataEntry/index.vue b/src/views/agent/dataEntry/index.vue
index 77badf8..cf9dea6 100644
--- a/src/views/agent/dataEntry/index.vue
+++ b/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: '姝e湪杩炴帴',
+    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 ?? '姝e湪澶勭悊'),
+      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銆乆LSX銆丏OCX 涓庡彲澶嶅埗鏂囨湰鐨� PDF锛屽崟涓枃浠朵笉瓒呰繃 8
-              MiB銆傛壂鎻忎欢鏆備笉鏀寔銆�
+              鏀寔涓�娆¢�夋嫨鏈�澶� 20 涓� CSV銆乆LSX銆丏OCX 涓庡彲澶嶅埗鏂囨湰鐨� 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>

--
Gitblit v1.9.3