'use strict'; const BUSINESS_SOURCE = 'LTJT_MOCK_BUSINESS'; const EXTENSION_SOURCE = 'LTJT_ORDER_ASSISTANT_EXTENSION'; const pending = new Map(); let currentTaskId = sessionStorage.getItem('ltjt_mock_current_task_id') || ''; let pollTimer = null; let eventStream = null; let bridgeConnected = false; let erpAutomationEnabled = true; let extensionCompatible = false; let remoteSyncInProgress = false; let syncRequested = false; let csrfToken = ''; let authUser = null; let taskStore = []; const MAX_TASK_LOG_ENTRIES = 300; const BROWSER_CONNECTION_ID = `administrator-browser:${location.origin}`; const REQUIRED_EXTENSION_VERSION = '0.3.1'; const TERMINAL_TASK_STATUSES = new Set([ 'completed', 'reconciliation_pending', 'blocked', 'failed', 'cancelled', 'dry_run', 'parse_failed' ]); const SAMPLE_RAW = `下单模式:团队-单个下单 订单性质:测试 产品名称:遇见老挝 出发日期:2026-08-11 人数: 成人:2 小孩占床:1 小孩不占床:1 婴儿:0 领队:0 用房: SGL:0 TWN:0 TRP:0 DBL:2 HNM:0 TL:0 单价: 成人:520 小孩占床:220 小孩不占床:120 婴儿:0 领队:0 计调OP:测试 销售人:琳琳 备注:笔记本接管重复拦截测试,确认真实保存 ERP。`; function $(selector) { return document.querySelector(selector); } async function apiRequest(path, options = {}) { const method = String(options.method || 'GET').toUpperCase(); const headers = new Headers(options.headers || {}); if (options.body !== undefined && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json'); if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS' && csrfToken) { headers.set('X-CSRF-Token', csrfToken); } const response = await fetch(path, { ...options, method, headers, credentials: 'same-origin', body: options.body !== undefined && typeof options.body !== 'string' ? JSON.stringify(options.body) : options.body }); let payload = null; try { payload = await response.json(); } catch (error) { payload = null; } if (response.status === 401 && path !== '/api/auth/login') { showLoginPanel('登录已过期,请重新登录。'); } if (!response.ok) { throw new Error(payload?.message || payload?.error || `请求失败:HTTP ${response.status}`); } return payload || {}; } function showLoginPanel(message = '') { authUser = null; csrfToken = ''; if (eventStream) { eventStream.close(); eventStream = null; } const panel = $('#loginPanel'); const workbench = $('#workbench'); const authState = $('#authState'); const logoutButton = $('#logoutButton'); if (panel) panel.hidden = false; if (workbench) workbench.hidden = true; if (authState) authState.hidden = true; if (logoutButton) logoutButton.hidden = true; const error = $('#loginError'); if (error) error.textContent = message; } function showAuthenticatedApp(user) { authUser = user; const panel = $('#loginPanel'); const workbench = $('#workbench'); const authState = $('#authState'); const logoutButton = $('#logoutButton'); if (panel) panel.hidden = true; if (workbench) workbench.hidden = false; if (authState) { authState.hidden = false; authState.textContent = `${user.username} · 管理员`; } if (logoutButton) logoutButton.hidden = false; } async function refreshCsrfToken() { const result = await apiRequest('/api/auth/csrf'); csrfToken = result.csrf_token || ''; if (!csrfToken) throw new Error('服务端未返回 CSRF 会话令牌。'); } async function syncRemoteTasks() { if (!authUser) return; if (remoteSyncInProgress) { syncRequested = true; return; } remoteSyncInProgress = true; try { do { syncRequested = false; const result = await apiRequest('/api/tasks'); // Keep cancelled records durable on the server, but hide them from the // active task-card list even if an older server or an SSE race returns one. taskStore = (Array.isArray(result.tasks) ? result.tasks : []).filter((task) => ( task?.status !== 'cancelled' && task?.result?.status !== 'cancelled' )); if (!taskStore.some((task) => task.task_id === currentTaskId)) { currentTaskId = taskStore[0]?.task_id || ''; } if (currentTaskId) sessionStorage.setItem('ltjt_mock_current_task_id', currentTaskId); else sessionStorage.removeItem('ltjt_mock_current_task_id'); renderTaskCards(); } while (syncRequested && authUser); } finally { remoteSyncInProgress = false; if (syncRequested && authUser) { syncRequested = false; queueMicrotask(() => syncRemoteTasks().catch((error) => { setOutput({ status: 'sync_error', message: error.message }); })); } } } function startRemoteEventStream() { if (eventStream) eventStream.close(); const lastEventId = taskStore.reduce((highest, task) => ( Math.max(highest, Number(task?.last_event_id || 0)) ), 0); eventStream = new EventSource(`/api/events?since=${encodeURIComponent(lastEventId)}`); eventStream.addEventListener('task', () => { syncRemoteTasks().catch((error) => { setOutput({ status: 'sync_error', message: error.message }); }); }); eventStream.onerror = () => { // EventSource reconnects automatically; polling remains the fallback authority refresh. }; } function normalizeText(text) { return String(text || '').replace(/\r/g, '').trim(); } function loadTaskStore() { return []; } function saveTaskStore() { // The backend is authoritative. The in-memory array is a render cache only. } function normalizeLogMessage(value) { return String(value || '').replace(/\s+/g, ' ').trim(); } function taskLogLevel(status, message) { const source = `${status || ''} ${message || ''}`; if (/failed|blocked|error|uncertain|失败|阻断|错误|不可用/i.test(source)) return 'ERROR'; if (/completed|received|accepted|confirmed|success|完成|收到|接单|确认|成功/i.test(source)) return 'OK'; return 'INFO'; } function appendTaskLogEntry(task, message, { status = '', level } = {}) { const normalizedMessage = normalizeLogMessage(message); if (!task || !normalizedMessage) return; if (!Array.isArray(task.logs)) task.logs = []; const entry = { at: new Date().toISOString(), level: level || taskLogLevel(status, normalizedMessage), status: status || '', message: normalizedMessage }; const last = task.logs[task.logs.length - 1]; if (last && last.message === entry.message && last.status === entry.status) return; task.logs.push(entry); if (task.logs.length > MAX_TASK_LOG_ENTRIES) { task.logs.splice(0, task.logs.length - MAX_TASK_LOG_ENTRIES); } } function taskSummary(operation) { const data = operation?.data || {}; const counts = data.passenger_counts || {}; const action = operation?.action || operation?.operation || ''; const labels = { team_order_create: '团队-单个下单', team_order_batch_create: '团队-批量下单', shared_plan_create: '散拼-创建母团计划', shared_child_order_create: '散拼-录入子单', order_update: '已有订单更新', passenger_list_import: '旅客名单导入', confirmation_export: '确认件导出/恢复', create_order: 'ERP 创建订单', update_order: 'ERP 更新订单', export_confirmation: 'ERP 导出恢复' }; return { action, operation_label: labels[action] || action || '输入解析任务', route: operation?.route || '', identifier: operation?.identifier || data.existing_refs?.identifier || data.existing_refs?.order_no || '', product: data.product?.name || '', departure_date: data.departure_dates?.[0] || '', test_marker: data.test_marker || '', passenger_total: ['adult', 'child_bed', 'child_no_bed', 'infant', 'leader'].reduce((sum, key) => sum + toNumber(counts[key]), 0), op_user: data.op_user?.name || '', sales_user: data.sales_user?.name || '' }; } function upsertLocalTask(task) { const index = taskStore.findIndex((item) => item.task_id === task.task_id); if (index >= 0) { const existing = taskStore[index]; const previousMessage = existing.message; const previousStatus = existing.status; taskStore[index] = { ...existing, ...task, updated_at: new Date().toISOString() }; if (task.message && (task.message !== previousMessage || task.status !== previousStatus)) { appendTaskLogEntry(taskStore[index], task.message, { status: task.status }); } } else { const nextTask = { ...task, updated_at: new Date().toISOString() }; appendTaskLogEntry(nextTask, nextTask.message, { status: nextTask.status }); taskStore.unshift(nextTask); } saveTaskStore(); renderTaskCards(); } function updateLocalTask(taskId, patch) { const existing = taskStore.find((item) => item.task_id === taskId); if (!existing) return; const previousMessage = existing.message; const previousStatus = existing.status; Object.assign(existing, patch, { updated_at: new Date().toISOString() }); const messageChanged = typeof patch.message === 'string' && patch.message !== previousMessage; const statusChanged = patch.status && patch.status !== previousStatus; if (messageChanged || (statusChanged && !patch.message)) { appendTaskLogEntry(existing, patch.message || `状态更新:${patch.status}`, { status: patch.status }); } saveTaskStore(); renderTaskCards(); } function selectedTask() { return taskStore.find((item) => item.task_id === currentTaskId) || taskStore[0] || null; } function taskStatusText(task) { if (!task) return '未知'; const status = task.status || task.result?.status || 'created'; if (requiresManualConfirmation(task)) return '待确认'; const stage = task.result?.stage || task.stage || ''; if (status === 'running' && stage === 'parse') return '发送中 / 解析中'; if (status === 'parse_queued') return '排队解析'; if (status === 'parse_running') return '解析中'; if (['agent_parse_blocked', 'parse_failed', 'parse_blocked'].includes(status)) return '外部解析失败'; if (status === 'agent_parse_passed') return '外部解析完成'; if (status === 'parse') return '解析中'; if (status === 'completed') return '已完成'; if (status === 'running') return '执行中'; if (status === 'accepted') return '已接单'; if (status === 'confirmed') return '已确认,提交中'; if (status === 'queued') return '排队中'; if (status === 'paused') return '已暂停'; if (status === 'awaiting_confirmation') return '等待人工确认'; if (status === 'confirmed') return '已确认,待提交'; if (status === 'waiting_extension') return '等待插件连接'; if (status === 'parse_failed') return '拆解失败'; if (status === 'parse_blocked') return '拆解失败'; if (status === 'dry_run') return '已规划,未写入 ERP'; if (status === 'operation_blocked') return '业务未接入'; if (status === 'execution_uncertain') return '保存状态待回查'; if (status === 'reconciliation_pending') return '待回查'; if (status === 'cancelled') return '已取消'; if (status === 'post_save_recovery_required') return '需要恢复导出'; if (status === 'blocked') return '已阻断'; if (status === 'saved_unverified') return '已提交待回查'; return stage || status; } function statusClassName(status) { const normalized = String(status || 'created').replace(/[^a-z0-9_-]/gi, '_'); return isFailureStatus(normalized) ? `${normalized} is-failure` : normalized; } function isFailureStatus(status) { const normalized = String(status || '').toLowerCase(); return /(?:failed|blocked|error|uncertain)/.test(normalized) || normalized === 'saved_unverified'; } function requiresManualConfirmation(task) { if (!task?.operation || task.confirmed_at || task.handoff_status === 'accepted') return false; const status = task.status || task.result?.status || 'created'; return !['parse_failed', 'parse_blocked', 'completed'].includes(status); } function taskCanonicalStatus(task) { return String(task?.status || task?.result?.status || 'created'); } function isTaskPollable(task) { const status = taskCanonicalStatus(task); if (TERMINAL_TASK_STATUSES.has(status)) return false; return Boolean( task?.confirmed_at && ( ['queued', 'accepted', 'running'].includes(status) || ['accepted', 'running'].includes(task?.handoff_status) ) ); } function isExecutionClaimed(task) { return Boolean( task?.confirmed_at && ( ['queued', 'accepted', 'running'].includes(taskCanonicalStatus(task)) || ['accepted', 'running', 'reconciliation_pending'].includes(task?.handoff_status) ) ); } function canStartConfirmedTask(task) { return Boolean( task?.operation && task?.confirmed_at && task?.status === 'confirmed' && task?.handoff_status === 'awaiting_handoff' ); } function taskStateClass(status) { if (['completed', 'parse_response_received', 'task_confirmed'].includes(status)) return 'state-ok'; if (isFailureStatus(status)) return 'state-bad'; return 'state-warn'; } function taskDisplayText(task, output) { const status = output?.status; if (status === 'parse_sending') return '正在发送到外部解析服务'; if (status === 'parse_response_received') return '已收到外部解析结果'; if (status === 'parse_response_blocked') return '外部解析服务已阻断'; if (status === 'agent_parse_blocked') return '外部 Skill 阻断'; if (status === 'agent_parse_passed') return '外部解析完成'; if (status === 'create_error') return '创建失败'; return taskStatusText(task); } function selectedOperation(task) { return task?.operation || task?.result?.operation || null; } function formatBusinessDate(value) { return value ? String(value) : '未填写'; } function formatDateTime(value) { if (!value) return '未记录'; const date = new Date(value); if (Number.isNaN(date.getTime())) return String(value); return date.toLocaleString('zh-CN', { hour12: false }); } function formatMoney(value) { const amount = toNumber(value); return `¥${amount.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}`; } const DISPLAY_REDACT_KEY_PATTERN = /(?:api[_-]?key|authorization|cookie|password|secret|csrf(?:[_-]?token)?)/i; const DISPLAY_MASK_KEY_PATTERN = /(?:session[_-]?id|idempotency[_-]?key)/i; function redactJsonForDisplay(value, key = '') { if (DISPLAY_REDACT_KEY_PATTERN.test(key)) return '[已隐藏]'; if (DISPLAY_MASK_KEY_PATTERN.test(key)) return value ? '[已生成]' : ''; if (Array.isArray(value)) return value.map((item) => redactJsonForDisplay(item, key)); if (value && typeof value === 'object') { return Object.fromEntries( Object.entries(value).map(([entryKey, entryValue]) => [ entryKey, redactJsonForDisplay(entryValue, entryKey) ]) ); } return value; } function taskResponsePayload(task) { if (!task) return null; const parseResponse = task.parse_response || task.parse_result || null; return { parse_response: parseResponse, operation: task.operation || parseResponse?.operation || null, handoff_response: task.handoff_response || null, executor_response: task.result || null }; } function formatTaskResponse(task) { const payload = taskResponsePayload(task); if (!payload || Object.values(payload).every((value) => value === null)) { return '等待返回 JSON……'; } return JSON.stringify(redactJsonForDisplay(payload), null, 2); } function el(tag, className, text) { const node = document.createElement(tag); if (className) node.className = className; if (text !== undefined) node.textContent = text; return node; } function appendField(container, label, value) { const item = el('div', 'detail-field'); item.append(el('span', '', label)); const displayValue = value === undefined || value === null || value === '' ? '未填写' : value; item.append(el('strong', '', displayValue)); container.append(item); } function appendMetric(container, label, value) { const item = el('div', 'metric'); item.append(el('span', '', label)); item.append(el('strong', '', value || '未填写')); container.append(item); } function createDetailBlock(title) { const block = el('section', 'detail-block'); block.append(el('h3', '', title)); return block; } function passengerRows(counts) { return [ ['成人', counts.adult], ['小孩占床', counts.child_bed], ['小孩不占床', counts.child_no_bed], ['婴儿', counts.infant], ['领队', counts.leader] ].map(([label, value]) => [label, toNumber(value)]); } function priceRows(data) { const counts = data.passenger_counts || {}; const prices = data.prices || {}; return [ ['成人', counts.adult, prices.adult], ['小孩占床', counts.child_bed, prices.child_bed], ['小孩不占床', counts.child_no_bed, prices.child_no_bed], ['婴儿', counts.infant, prices.infant], ['领队', counts.leader, prices.leader] ].map(([label, count, price]) => { const normalizedCount = toNumber(count); const normalizedPrice = toNumber(price); return { label, count: normalizedCount, price: normalizedPrice, amount: normalizedCount * normalizedPrice }; }).filter((row) => row.count || row.price); } function renderMiniTable(rows, columns) { const table = el('div', 'mini-table'); const head = el('div', 'mini-table-row mini-table-head'); for (const column of columns) head.append(el('span', '', column.label)); table.append(head); for (const row of rows) { const line = el('div', 'mini-table-row'); for (const column of columns) line.append(el('span', '', column.render(row))); table.append(line); } return table; } function taskStepState(task, step) { const status = task.status || task.result?.status || 'created'; const handoff = task.handoff_status || ''; const hasOperation = Boolean(selectedOperation(task)); if (step === 'parse') { if (['parse_failed', 'parse_blocked'].includes(status)) return 'blocked'; return hasOperation ? 'done' : 'active'; } if (step === 'handoff') { if (status === 'waiting_extension') return 'active'; if (handoff === 'accepted' || ['accepted', 'running', 'dry_run', 'saved_unverified', 'completed', 'blocked'].includes(status)) return 'done'; return hasOperation ? 'active' : 'wait'; } if (step === 'erp') { if (status === 'dry_run') return 'done'; if (status === 'blocked') return 'blocked'; if (['saved_unverified', 'completed'].includes(status)) return 'done'; if (['accepted', 'running', 'queued'].includes(status)) return 'active'; return 'wait'; } if (step === 'verify') { if (status === 'completed') return 'done'; if (status === 'saved_unverified') return 'active'; if (status === 'blocked') return 'blocked'; return 'wait'; } return 'wait'; } function parseErpReceiptText(value) { const raw = String(value || ''); const text = raw .replace(/\r/g, '') .replace(//gi, '\n') .replace(/<\/(p|div|li|tr)>/gi, '\n') .replace(/<[^>]+>/g, '') .replace(/ /gi, ' ') .replace(/&/gi, '&') .replace(/</gi, '<') .replace(/>/gi, '>') .trim(); if (!text) return null; const lines = text.split('\n').map((line) => line.trim()).filter(Boolean); const valueAfterLabel = (label) => { const pattern = new RegExp(`${label}\\s*[::]\\s*([^\\n]+)`, 'i'); const line = lines.find((item) => pattern.test(item)); const source = line || text; const match = source.match(pattern); const value = match ? match[1].trim() : ''; return label === '团号' ? value.replace(/[\s。;;,,].*$/, '').trim() : value; }; const groupNumber = valueAfterLabel('团号'); const product = valueAfterLabel('产品'); const passengerText = valueAfterLabel('人数'); const success = /下单成功|成功|success|ສໍາເລັດ/i.test(text); if (!groupNumber && !product && !passengerText) return null; return { status: success ? 'submit_success_receipt' : 'submit_receipt', success, title: lines[0] || '', group_number: groupNumber, product, passenger_text: passengerText }; } function receiptFromTask(task) { const result = task?.result || {}; const direct = result.erp_receipt || result.report?.erp_receipt; if (direct) return direct; const alerts = [ ...(Array.isArray(result.alerts) ? result.alerts : []), ...(Array.isArray(result.report?.alerts) ? result.report.alerts : []) ]; const alertReceipt = alerts.map((alert) => alert.erp_receipt || parseErpReceiptText(alert.message)).find(Boolean); if (alertReceipt) return alertReceipt; return [ result.message, result.report?.message, task?.message, result.report?.raw_message ].map(parseErpReceiptText).find(Boolean) || null; } function taskStageSnapshot(task) { const emptyStage = (note = '等待任务') => ({ mode: 'pending', current: null, currentLabel: '', note }); if (!task) { return { agent: emptyStage(), business: emptyStage(), receipt: emptyStage() }; } const result = task.result || {}; const status = String(result.status || task.status || 'created'); const stage = String(result.stage || task.stage || ''); const operation = selectedOperation(task); const receipt = receiptFromTask(task); const parseFailed = ['agent_parse_blocked', 'parse_failed', 'parse_blocked'].includes(status) || (stage === 'parse' && /failed|blocked/i.test(status)); const agentSucceeded = Boolean(operation) || ['agent_parse_passed', 'awaiting_confirmation', 'confirmed', 'queued', 'accepted', 'running', 'paused', 'waiting_extension', 'saved_unverified', 'completed', 'blocked'].includes(status); const agent = parseFailed ? { mode: 'failed', current: 'failed', currentLabel: 'AI处理失败', note: '外部解析未通过,未进入业务系统处理。' } : agentSucceeded ? { mode: 'complete', current: 'success', currentLabel: 'AI处理成功', note: '已完成指令拆解,等待或已进入业务系统处理。' } : { mode: 'active', current: 'submitted', currentLabel: 'AI处理中', note: '已提交原始指令,正在等待解析结果。' }; let business = emptyStage('等待 Agent 处理完成'); if (agentSucceeded) { if (status === 'completed' || status === 'batch_fallback_completed') { business = { mode: 'complete', current: 'completed', currentLabel: '业务处理已完成', note: '业务系统已完成提交和回查流程。' }; } else if (status === 'reconciliation_pending' || status === 'execution_uncertain' || status === 'saved_unverified') { business = { mode: 'active', current: 'processing', currentLabel: '业务结果待回查', note: 'ERP 可能已写入,自动执行已停止且不会重试。' }; } else if (['blocked', 'cancelled', 'extension_error', 'batch_fallback_incomplete', 'live_submit_blocked', 'preflight_blocked'].includes(status)) { business = { mode: 'failed', current: 'processing', currentLabel: '业务处理失败', note: '业务系统或插件执行未完成,请查看下方流式日志。' }; } else if (status === 'accepted') { business = { mode: 'active', current: 'accepted', currentLabel: '业务系统已接单', note: '插件已接单,准备执行 ERP 操作。' }; } else if (status === 'running' || ['browser_execution', 'open_order_form', 'wait_order_form', 'preflight', 'live_submit', 'verification', 'batch_fallback'].includes(stage)) { business = { mode: 'active', current: 'processing', currentLabel: '业务系统处理中', note: '正在执行 ERP 操作或回查。' }; } else if (['queued', 'confirmed', 'waiting_extension', 'paused'].includes(status)) { business = { mode: 'active', current: null, currentLabel: status === 'paused' ? '等待恢复执行' : '等待插件接单', note: status === 'paused' ? '插件 ERP 操作已暂停。' : '已确认,等待插件接单。' }; } else { business = { mode: 'pending', current: null, currentLabel: '待提交执行', note: '等待人工确认后交给业务系统执行。' }; } } let receiptStage; if (receipt) { receiptStage = { mode: 'complete', current: 'received', currentLabel: '已获取回执', note: receipt.group_number ? `回执团号:${receipt.group_number}` : '已收到 ERP 返回的保存回执。' }; } else if (status === 'reconciliation_pending' || status === 'execution_uncertain' || status === 'saved_unverified') { receiptStage = { mode: 'active', current: 'pending', currentLabel: '待人工回查', note: '请按 ERP 团号、订单号或测试标记核对,禁止重新执行。' }; } else if (business.mode === 'failed') { receiptStage = { mode: 'failed', current: 'pending', currentLabel: '回执获取失败', note: '当前没有可用的 ERP 回执。' }; } else if (business.mode === 'complete') { receiptStage = { mode: 'active', current: 'pending', currentLabel: '等待回执', note: '业务流程完成,等待 ERP 回执。' }; } else { receiptStage = emptyStage('业务处理完成后获取 ERP 回执'); } return { agent, business, receipt: receiptStage }; } function renderTaskStages(task) { const container = $('#taskStages'); if (!container) return; container.innerHTML = ''; const snapshot = taskStageSnapshot(task); const stages = [ { key: 'agent', index: '01', title: 'AI处理', options: [ { key: 'submitted', label: '已提交' }, { key: 'success', label: '已成功' }, { key: 'failed', label: '已失败' } ] }, { key: 'business', index: '02', title: '业务系统处理', options: [ { key: 'accepted', label: '已接单' }, { key: 'processing', label: '处理中' }, { key: 'completed', label: '已完成' } ] }, { key: 'receipt', index: '03', title: '获取回执', options: [ { key: 'pending', label: '待获取' }, { key: 'received', label: '已获取回执' } ] } ]; const flow = el('div', 'task-stage-flow'); for (const stage of stages) { const state = snapshot[stage.key]; const card = el('article', `task-stage-card is-${state.mode}`); const heading = el('div', 'task-stage-heading'); heading.append(el('span', 'task-stage-index', stage.index)); const titleBlock = el('div', 'task-stage-title-block'); titleBlock.append(el('strong', 'task-stage-title', stage.title)); titleBlock.append(el('span', 'task-stage-current', state.currentLabel || '待开始')); heading.append(titleBlock); card.append(heading); const statuses = el('div', 'task-stage-statuses'); statuses.setAttribute('role', 'list'); const visibleOptions = stage.key === 'agent' ? stage.options.filter((option) => !['success', 'failed'].includes(option.key) || option.key === state.current) : stage.options; for (const option of visibleOptions) { const statusItem = el('span', `task-stage-status${state.current === option.key ? ' is-current' : ''}`, option.label); statusItem.setAttribute('role', 'listitem'); statuses.append(statusItem); } card.append(statuses); card.append(el('p', 'task-stage-note', state.note)); flow.append(card); } container.append(flow); } function renderTaskDetail() { const container = $('#taskDetail'); if (!container) return; container.innerHTML = ''; const task = selectedTask(); const taskState = $('#taskState'); const confirmButton = $('#confirmTaskButton'); const deleteButton = $('#deleteTaskButton'); const status = task?.status || task?.result?.status || 'created'; renderTaskStages(task); if (confirmButton) { const canConfirm = Boolean(task && requiresManualConfirmation(task)); const canStart = Boolean(task && canStartConfirmedTask(task)); confirmButton.hidden = !canConfirm && !canStart; confirmButton.disabled = !canConfirm && !canStart; confirmButton.textContent = canStart ? '开始执行' : '确认执行'; } if (deleteButton) { deleteButton.disabled = !task || isExecutionClaimed(task) || TERMINAL_TASK_STATUSES.has(taskCanonicalStatus(task)); } if (taskState) { taskState.textContent = task ? taskStatusText(task) : '未创建'; taskState.className = `state ${task ? taskStateClass(status) : 'state-warn'}`; } const fallbackEntries = task ? [{ at: task.updated_at || task.created_at || new Date().toISOString(), level: taskLogLevel(status, task.message), status, message: task.message || '任务已创建,等待流程事件。' }] : []; const entries = Array.isArray(task?.logs) && task.logs.length ? task.logs : fallbackEntries; const logPanel = el('section', 'task-output-panel'); logPanel.append(el('div', 'task-output-label', '流程日志')); const log = el('pre', 'task-log', entries.length ? entries.map(formatTaskLogEntry).join('\n') : '暂无任务日志。'); log.id = 'taskLog'; log.setAttribute('role', 'log'); log.setAttribute('aria-live', 'polite'); logPanel.append(log); const responsePanel = el('section', 'task-output-panel'); responsePanel.append(el('div', 'task-output-label', '返回 JSON')); const response = el('pre', 'task-json-output', formatTaskResponse(task)); response.id = 'taskResponseJson'; response.setAttribute('aria-live', 'polite'); responsePanel.append(response); container.append(logPanel, responsePanel); log.scrollTop = log.scrollHeight; } function formatTaskLogEntry(entry) { const date = new Date(entry.at || Date.now()); const time = Number.isNaN(date.getTime()) ? '--:--:--' : date.toLocaleTimeString('zh-CN', { hour12: false }); const level = String(entry.level || 'INFO').toUpperCase().padEnd(5, ' '); const status = entry.status ? `[${entry.status}]` : '[event]'; return `${time} ${level} ${status} ${entry.message || ''}`; } function renderTaskCards() { const container = $('#taskCards'); if (!container) return; $('#taskCount').textContent = `${taskStore.length} 个任务`; container.innerHTML = ''; if (!taskStore.length) { const empty = document.createElement('div'); empty.className = 'task-empty'; empty.textContent = '暂无任务。'; container.appendChild(empty); renderTaskDetail(); return; } for (const task of taskStore) { const card = document.createElement('article'); card.className = `task-card${task.task_id === currentTaskId ? ' is-selected' : ''}`; card.dataset.taskId = task.task_id; const summary = task.summary || taskSummary(task.operation); const status = task.status || task.result?.status || 'created'; card.innerHTML = `
`; card.querySelector('.task-card-title').textContent = summary.operation_label || '输入解析任务'; card.querySelector('.task-id').textContent = task.task_id; card.querySelector('.task-card-status .task-pill').textContent = taskStatusText(task); container.appendChild(card); } renderTaskDetail(); } function toNumber(value) { const normalized = String(value ?? '').replace(/[,,]/g, '').trim(); const num = Number(normalized); return Number.isFinite(num) ? num : 0; } function setBridgeState(text, type) { const el = $('#bridgeState'); if (!el) return; const value = el.querySelector('.status-icon-value'); if (value) value.textContent = text; else el.textContent = text; el.className = `status-icon ${type || 'state-warn'}`; } function setAiState(text, type) { const el = $('#aiState'); if (!el) return; const value = el.querySelector('.status-icon-value'); if (value) value.textContent = text; else el.textContent = text; el.className = `status-icon ${type || 'state-warn'}`; } async function pingAi() { try { const response = await fetch('/api/status', { cache: 'no-store' }); const result = await response.json(); if (!response.ok || result.ai_connected !== true) { setAiState('未连接', 'state-bad'); return false; } setAiState('已连接', 'state-ok'); return true; } catch (error) { setAiState('未连接', 'state-bad'); return false; } } function applyBridgePayload(payload = {}) { bridgeConnected = Boolean(payload.ok); if (!bridgeConnected) { erpAutomationEnabled = false; extensionCompatible = false; setBridgeState('未连接', 'state-bad'); return false; } const parseVersion = (value) => String(value || '').split('.').map((part) => Number(part) || 0); const current = parseVersion(payload.version); const required = parseVersion(REQUIRED_EXTENSION_VERSION); let versionComparison = 0; for (let index = 0; index < 3; index += 1) { const currentPart = current[index] || 0; const requiredPart = required[index] || 0; if (currentPart === requiredPart) continue; versionComparison = currentPart > requiredPart ? 1 : -1; break; } extensionCompatible = versionComparison >= 0; if (!extensionCompatible) { erpAutomationEnabled = false; setBridgeState(`需更新到 ${REQUIRED_EXTENSION_VERSION}`, 'state-bad'); return true; } erpAutomationEnabled = payload.erp_automation_enabled !== false; if (!erpAutomationEnabled) { setBridgeState('未连接', 'state-bad'); return true; } setBridgeState('已连接', 'state-ok'); return true; } function setTaskState(text) { const node = $('#taskState'); if (!node) return; node.textContent = text; const normalized = String(text || ''); const type = /失败|阻断|错误|不可用/.test(normalized) ? 'state-bad' : /完成|收到|确认|成功|已连接/.test(normalized) ? 'state-ok' : 'state-warn'; node.className = `state ${type}`; } function taskLogMessage(output) { if (typeof output === 'string') return output; if (!output || typeof output !== 'object') return ''; const blockers = Array.isArray(output.blockers) ? output.blockers : Array.isArray(output.result?.blockers) ? output.result.blockers : []; if (blockers.length) return `阻断:${blockers.join(';')}`; return output.message || output.next_step || output.error || (output.status ? `状态更新:${output.status}` : ''); } function setOutput(output) { const task = selectedTask(); if (task && output !== undefined) { const status = typeof output === 'object' ? output.status || task.status : task.status; appendTaskLogEntry(task, taskLogMessage(output), { status }); saveTaskStore(); } renderTaskDetail(); } function makeRequestId() { if (crypto.randomUUID) return crypto.randomUUID(); return `REQ-${Date.now()}-${Math.random().toString(16).slice(2)}`; } function sendToExtension(type, payload = {}, timeoutMs = 2500) { const requestId = makeRequestId(); const promise = new Promise((resolve, reject) => { const timer = setTimeout(() => { pending.delete(requestId); reject(new Error('插件桥接未响应,请确认扩展已安装并刷新本页。')); }, timeoutMs); pending.set(requestId, { resolve, reject, timer }); }); window.postMessage({ source: BUSINESS_SOURCE, requestId, type, payload }, window.location.origin); return promise; } window.addEventListener('message', (event) => { if (event.source !== window) return; const message = event.data || {}; if (message.source !== EXTENSION_SOURCE) return; if (message.type === 'BRIDGE_READY') { applyBridgePayload(message.payload || { ok: true }); return; } const waiter = pending.get(message.requestId); if (!waiter) return; clearTimeout(waiter.timer); pending.delete(message.requestId); if (message.type === 'ERROR' || message.payload?.ok === false) { waiter.reject(new Error(message.payload?.message || '插件返回错误。')); return; } waiter.resolve(message.payload); }); async function parseRawInstruction(rawText, taskId) { const receivedAt = new Date().toISOString(); const requestStartedAt = new Date().toISOString(); const pendingRequest = { service: 'external_parse_api', transport: 'sse', stage: 'sending', session_id_present: false, event_count: 0, updated_at: requestStartedAt }; updateLocalTask(taskId, { status: 'running', stage: 'parse', message: '正在发送到外部解析服务……', error: '' }); setTaskState('正在发送到外部解析服务'); setOutput({ status: 'parse_sending', task_id: taskId, message: '业务系统已发起解析请求,正在创建独立 session。', external_request: pendingRequest }); let response; try { response = await fetch('/api/parse', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ raw_text: rawText, task_id: taskId, received_at: receivedAt }) }); } catch (error) { const result = { status: 'parse_blocked', blockers: [`无法连接本地解析服务:${error.message}`], operation: null, external_request: { ...pendingRequest, stage: 'local_request_failed', error_code: 'local_parse_api_unreachable', updated_at: new Date().toISOString() } }; updateLocalTask(taskId, { status: 'parse_failed', stage: 'parse', message: '无法连接本地解析服务。', error: result.blockers.join(';'), result, parse_response: result }); setTaskState('解析服务连接失败'); setOutput(result); return result; } let result; try { result = await response.json(); } catch (error) { result = { status: 'parse_blocked', blockers: [`解析服务返回了无效响应:${error.message}`], operation: null, external_request: { ...pendingRequest, stage: 'invalid_response', error_code: 'local_parse_api_invalid_response', updated_at: new Date().toISOString() } }; } if (!response.ok) { result = { status: 'parse_blocked', blockers: [result.message || result.error || `解析服务调用失败:HTTP ${response.status}`], operation: null, external_request: result.external_request || { ...pendingRequest, stage: 'local_api_error', http_status: response.status, updated_at: new Date().toISOString() } }; } const hasBlockers = Array.isArray(result.blockers) && result.blockers.length > 0; updateLocalTask(taskId, { status: hasBlockers ? 'parse_failed' : 'running', stage: 'parse', message: hasBlockers ? '外部解析服务已返回阻断结果。' : '外部解析服务已返回结果,正在整理业务字段。', error: hasBlockers ? result.blockers.join(';') : '', parse_response: result }); setTaskState(hasBlockers ? '外部解析服务已返回阻断' : '已收到外部解析结果'); setOutput({ status: hasBlockers ? 'parse_response_blocked' : 'parse_response_received', task_id: taskId, external_request: result.external_request || { ...pendingRequest, stage: 'response_received', updated_at: new Date().toISOString() }, result }); return result; } async function handoffTaskToExtension(task) { if (!task?.confirmed_at) { throw new Error('任务尚未人工确认,不能提交执行。'); } if (task.status !== 'confirmed' || task.handoff_status !== 'awaiting_handoff') { throw new Error('任务已领取或已进入执行流程,系统不会再次下发。'); } if (!bridgeConnected || !extensionCompatible) { updateLocalTask(task.task_id, { status: 'waiting_extension', stage: 'bridge', message: `任务已确认,但插件未连接或版本低于 ${REQUIRED_EXTENSION_VERSION};不会自动提交。`, error: '插件不可用。', handoff_status: 'waiting_extension' }); throw new Error('插件未连接或版本过低,任务已保留且不会自动补交。'); } if (!erpAutomationEnabled) { updateLocalTask(task.task_id, { status: 'paused', stage: 'automation_disabled', message: '任务已确认,但插件 ERP 操作开关已关闭;不会自动提交。', handoff_status: 'paused', error: '' }); return { ok: true, task_id: task.task_id, status: 'paused', erp_automation_enabled: false, message: '插件 ERP 操作开关已关闭,未交给 ERP 自动执行器,也不会自动补交。' }; } updateLocalTask(task.task_id, { status: 'queued', stage: 'handoff', message: '正在向服务端申请唯一 ERP 执行权。', handoff_status: 'syncing' }); let claim; try { claim = await apiRequest(`/api/tasks/${encodeURIComponent(task.task_id)}/claim`, { method: 'POST', body: { connection_id: BROWSER_CONNECTION_ID } }); if (claim.task) upsertLocalTask(claim.task); if (!claim.claimed) { startPolling(); return { ok: true, task_id: task.task_id, status: 'already_claimed', dispatched: false, message: '服务端已存在执行记录,本次没有再次发送给插件。' }; } const executionId = claim.execution_id; if (!executionId) throw new Error('服务端没有返回唯一执行编号,已停止下发。'); const result = await sendToExtension('CREATE_TASK', { task: claim.task || task, execution_id: executionId }, 4000); if (result?.accepted === false || result?.status === 'duplicate_blocked') { throw new Error(result.message || '插件检测到重复任务,已阻止再次执行。'); } currentTaskId = result.task_id || task.task_id; sessionStorage.setItem('ltjt_mock_current_task_id', currentTaskId); updateLocalTask(task.task_id, { status: 'running', stage: 'auto_executor', message: '插件已接单,后台会自动打开 ERP 表单、预检、保存并回查。', handoff_status: 'running', handoff_response: { ...result, execution_id: executionId } }); startPolling(); return { ...result, execution_id: executionId }; } catch (error) { if (claim?.claimed && claim.execution_id) { const uncertainResult = { task_id: task.task_id, execution_id: claim.execution_id, status: 'execution_uncertain', stage: 'dispatch', execution_phase: 'dispatch_uncertain', write_attempted: true, message: `任务领取后插件响应不确定:${error.message}。已停止自动重试,请回查 ERP。` }; try { const persisted = await apiRequest(`/api/tasks/${encodeURIComponent(task.task_id)}/result`, { method: 'POST', body: { connection_id: BROWSER_CONNECTION_ID, execution_id: claim.execution_id, result: uncertainResult } }); if (persisted.task) upsertLocalTask(persisted.task); } catch (persistError) { updateLocalTask(task.task_id, { status: 'reconciliation_pending', stage: 'reconciliation', handoff_status: 'reconciliation_pending', message: uncertainResult.message, error: persistError.message }); } } throw error; } } async function confirmTask(task) { if (!requiresManualConfirmation(task)) return; const bridgeReady = await pingBridge(); if (!bridgeReady || !extensionCompatible || !erpAutomationEnabled) { throw new Error(`插件未连接、版本低于 ${REQUIRED_EXTENSION_VERSION} 或 ERP 操作开关未开启;任务尚未确认,也不会自动补交。`); } const response = await apiRequest(`/api/tasks/${encodeURIComponent(task.task_id)}/confirm`, { method: 'POST', body: {} }); const confirmedTask = response.task; upsertLocalTask(confirmedTask); try { const handoff = await handoffTaskToExtension(confirmedTask); if (handoff?.status === 'paused') { setTaskState('ERP 操作已关闭'); setOutput({ status: 'task_confirmed_paused', task_id: task.task_id, message: handoff.message }); } else { setOutput({ status: 'task_confirmed', task_id: task.task_id, result: handoff }); } } catch (error) { setTaskState('已确认,等待插件连接'); setOutput({ status: 'task_confirmed_waiting_extension', task_id: task.task_id, message: error.message }); } } async function pingBridge() { try { const result = await sendToExtension('PING', {}, 1200); applyBridgePayload(result); if (authUser && result?.ok) { await apiRequest('/api/connections/heartbeat', { method: 'POST', body: { connection_id: `administrator-browser:${location.origin}`, extension_version: result.version || '', metadata: { bridge_installed_at: result.bridge_installed_at || '' } } }); } return bridgeConnected; } catch (error) { bridgeConnected = false; erpAutomationEnabled = false; setBridgeState('未连接', 'state-bad'); return false; } } async function createTask() { if (!authUser) { showLoginPanel('请先登录。'); return; } const rawText = $('#rawInstruction').value; const task = await apiRequest('/api/tasks', { method: 'POST', body: { raw_text: rawText, idempotency_key: `create:${makeRequestId()}` } }); const remoteTask = task.task; currentTaskId = remoteTask.task_id; sessionStorage.setItem('ltjt_mock_current_task_id', currentTaskId); upsertLocalTask(remoteTask); setTaskState('任务已入队'); setOutput({ status: 'task_created', task_id: currentTaskId, message: '任务已写入平台数据库,等待服务端解析。' }); startRemoteEventStream(); } async function pollResult() { if (!currentTaskId) { setOutput('还没有任务。'); return; } const result = await pollTaskResult(currentTaskId); setTaskState(result ? `${result.stage} / ${result.status}` : `等待插件反馈:${currentTaskId}`); setOutput(result || { status: 'waiting', task_id: currentTaskId }); } async function deleteSelectedTask() { const task = selectedTask(); if (!task) return; const taskId = task.task_id; const deleteButton = $('#deleteTaskButton'); if (deleteButton) deleteButton.disabled = true; appendTaskLogEntry(task, '正在请求取消任务并清理本地记录。', { status: 'cancelling', level: 'INFO' }); saveTaskStore(); renderTaskDetail(); if (deleteButton) deleteButton.disabled = true; try { const needsPluginCancellation = Boolean( task.confirmed_at || ['accepted', 'syncing', 'waiting_extension', 'paused'].includes(task.handoff_status) ); if (needsPluginCancellation) { const cancellation = await sendToExtension('DELETE_TASK', { task_id: taskId }, 4000); if (cancellation?.ok === false) { throw new Error(cancellation.message || '插件未确认任务已取消。'); } } const cancelled = await apiRequest(`/api/tasks/${encodeURIComponent(taskId)}/cancel`, { method: 'POST', body: {} }); if (cancelled.task) updateLocalTask(taskId, cancelled.task); taskStore = taskStore.filter((item) => item.task_id !== taskId); const nextTask = taskStore[0] || null; currentTaskId = nextTask?.task_id || ''; if (currentTaskId) sessionStorage.setItem('ltjt_mock_current_task_id', currentTaskId); else sessionStorage.removeItem('ltjt_mock_current_task_id'); saveTaskStore(); if (!taskStore.some(isTaskPollable)) { if (pollTimer) clearInterval(pollTimer); pollTimer = null; } renderTaskCards(); } catch (error) { setTaskState('删除失败'); setOutput({ status: 'delete_error', task_id: taskId, message: `未删除任务:${error.message || String(error)}` }); if (deleteButton) deleteButton.disabled = false; } } async function pollTaskResult(taskId) { const result = await sendToExtension('GET_TASK_RESULT', { task_id: taskId }, 3000); if (result.result) { try { const executionId = result.result.execution_id; if (!executionId) { throw new Error(`插件版本过低或执行记录缺少 execution_id,请更新到 ${REQUIRED_EXTENSION_VERSION};系统不会重复下发。`); } const persisted = await apiRequest(`/api/tasks/${encodeURIComponent(taskId)}/result`, { method: 'POST', body: { connection_id: BROWSER_CONNECTION_ID, execution_id: executionId, result: result.result } }); if (persisted.task) upsertLocalTask(persisted.task); } catch (error) { updateLocalTask(taskId, { result: result.result, status: result.result.status || 'running', stage: result.result.stage || '', message: `插件已返回结果,但服务端持久化失败:${error.message}`, error: error.message }); } } return result.result || null; } async function pollAllTaskResults() { if (!bridgeConnected) return; const activeTasks = taskStore.filter(isTaskPollable); for (const task of activeTasks) { try { await pollTaskResult(task.task_id); } catch (error) { updateLocalTask(task.task_id, { message: `查询插件反馈失败:${error.message}`, error: error.message }); } } if (!taskStore.some(isTaskPollable) && pollTimer) { clearInterval(pollTimer); pollTimer = null; } } function startPolling() { if (pollTimer) clearInterval(pollTimer); pollAllTaskResults().catch((error) => { setTaskState('轮询失败'); setOutput({ status: 'poll_error', message: error.message }); }); pollTimer = setInterval(() => { pollAllTaskResults().catch((error) => { setTaskState('轮询失败'); setOutput({ status: 'poll_error', message: error.message }); }); }, 2500); } async function clearTasks() { try { await sendToExtension('CLEAR_TASKS', {}, 3000); } catch (error) { // Local business-system cards can still be cleared when the extension is disconnected. } currentTaskId = ''; taskStore = []; saveTaskStore(); sessionStorage.removeItem('ltjt_mock_current_task_id'); if (pollTimer) clearInterval(pollTimer); setTaskState('已清空'); setOutput('暂无任务。'); renderTaskCards(); } async function initializeSession() { try { const me = await apiRequest('/api/auth/me'); await refreshCsrfToken(); showAuthenticatedApp(me.user); await syncRemoteTasks(); startRemoteEventStream(); return true; } catch (error) { showLoginPanel('请登录后继续。'); return false; } } document.addEventListener('DOMContentLoaded', async () => { // Remove credentials/configuration left by the retired browser-side parser. localStorage.removeItem('ltjt_mock_agent_config'); sessionStorage.removeItem('ltjt_mock_agent_api_key'); $('#rawInstruction').value = sessionStorage.getItem('ltjt_mock_raw_instruction') || SAMPLE_RAW; $('#rawInstruction').addEventListener('input', () => { sessionStorage.setItem('ltjt_mock_raw_instruction', $('#rawInstruction').value); }); showLoginPanel(); $('#loginForm').addEventListener('submit', async (event) => { event.preventDefault(); const errorNode = $('#loginError'); if (errorNode) errorNode.textContent = ''; const submitButton = $('#loginForm button[type="submit"]'); if (submitButton) submitButton.disabled = true; try { const response = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ username: $('#loginUsername').value, password: $('#loginPassword').value }) }); const result = await response.json(); if (!response.ok) throw new Error(result.message || '登录失败。'); csrfToken = result.csrf_token || ''; showAuthenticatedApp(result.user); $('#loginPassword').value = ''; await syncRemoteTasks(); startRemoteEventStream(); await pingAi(); await pingBridge(); } catch (error) { if (errorNode) errorNode.textContent = error.message || String(error); } finally { if (submitButton) submitButton.disabled = false; } }); $('#logoutButton').addEventListener('click', async () => { try { await apiRequest('/api/auth/logout', { method: 'POST', body: {} }); } catch (error) { // The server may already have expired the session; clear the local view regardless. } taskStore = []; currentTaskId = ''; sessionStorage.removeItem('ltjt_mock_current_task_id'); showLoginPanel('已退出登录。'); renderTaskCards(); }); $('#createTaskButton').addEventListener('click', () => { createTask().catch((error) => { const result = { status: 'create_error', task_id: currentTaskId, message: error.message }; updateLocalTask(currentTaskId, { status: 'parse_failed', stage: 'parse', message: '创建任务时发生未处理错误。', error: error.message, result }); setTaskState('创建失败'); setOutput(result); }); }); $('#aiState').addEventListener('click', () => { pingAi().catch(() => {}); }); $('#bridgeState').addEventListener('click', () => { pingBridge().catch(() => {}); }); $('#confirmTaskButton').addEventListener('click', () => { const task = selectedTask(); if (!task) return; const action = requiresManualConfirmation(task) ? confirmTask(task) : canStartConfirmedTask(task) ? handoffTaskToExtension(task) : null; if (!action) return; action.catch((error) => setOutput({ status: 'confirmation_error', task_id: task.task_id, message: error.message })); }); $('#deleteTaskButton').addEventListener('click', () => { const task = selectedTask(); if (!task) return; if (!window.confirm(`确认删除任务 ${task.task_id}?已提交的插件任务也会请求取消。`)) return; deleteSelectedTask().catch((error) => setOutput({ status: 'delete_error', task_id: task.task_id, message: error.message || String(error) })); }); $('#taskCards').addEventListener('click', (event) => { const button = event.target.closest('button[data-task-action]'); const card = event.target.closest('.task-card[data-task-id]'); const taskId = button?.dataset.taskId || card?.dataset.taskId; if (!taskId) return; const task = taskStore.find((item) => item.task_id === taskId); if (!task) return; currentTaskId = taskId; sessionStorage.setItem('ltjt_mock_current_task_id', currentTaskId); renderTaskCards(); }); if (await initializeSession()) { renderTaskCards(); pingAi().catch(() => {}); pingBridge().catch(() => {}); // The control plane caches this health probe; keep the browser refresh // interval conservative so status checks cannot pressure the Agent API. setInterval(() => { pingAi().catch(() => {}); pingBridge().catch(() => {}); syncRemoteTasks().catch(() => {}); }, 30_000); if (currentTaskId) renderTaskDetail(); if (taskStore.some(isTaskPollable)) startPolling(); } });