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