'use strict'; const BUSINESS_SOURCE = 'LIANSYN_PLATFORM'; const EXTENSION_SOURCE = 'LTJT_ORDER_ASSISTANT_EXTENSION'; const CURRENT_PAGE = window.location.pathname.replace(/\/+$/, '') || '/'; const IS_HISTORY_PAGE = CURRENT_PAGE === '/history'; const IS_CHANNELS_PAGE = CURRENT_PAGE === '/channels'; const IS_PARSER_ROUTING_PAGE = CURRENT_PAGE === '/parser-routing'; const IS_ACCOUNTS_PAGE = CURRENT_PAGE === '/accounts'; const IS_AUDIT_PAGE = CURRENT_PAGE === '/audit'; const IS_OPERATIONS_DASHBOARD_PAGE = CURRENT_PAGE === '/operations-dashboard'; const IS_ADMIN_PAGE = IS_CHANNELS_PAGE || IS_PARSER_ROUTING_PAGE || IS_ACCOUNTS_PAGE || IS_AUDIT_PAGE; const IS_MANAGEMENT_PAGE = IS_ADMIN_PAGE || IS_OPERATIONS_DASHBOARD_PAGE; const IS_TASK_PAGE = !IS_MANAGEMENT_PAGE; const HOME_TASK_LIMIT = 10; const HISTORY_TASK_PAGE_SIZE = 24; const OPERATIONS_DASHBOARD_PAGE_SIZE = 20; const DEFAULT_API_TIMEOUT_MS = 15_000; const AUTH_API_TIMEOUT_MS = 10_000; const BRIDGE_PING_TIMEOUT_MS = 10_000; const BRIDGE_TASK_ACCEPT_TIMEOUT_MS = 10_000; const RESULT_API_TIMEOUT_MS = 20_000; const TASK_DELETE_API_TIMEOUT_MS = 60_000; const LOCAL_TASK_OVERLAY_TTL_MS = 10 * 60_000; const pending = new Map(); let currentTaskId = sessionStorage.getItem('liansyn_platform_current_task_id') || ''; let pollTimer = null; let eventStream = null; let bridgeConnected = false; let erpAutomationEnabled = true; let erpHostAccessReady = true; let erpSessionReady = true; let extensionCompatible = false; let latestAiStatus = null; let latestBridgeStatus = null; let activeStatusDetails = ''; let remoteSyncInProgress = false; let syncRequested = false; let csrfToken = ''; let authUser = null; let browserConnectionId = ''; let organizationAutomationEnabled = false; let automationSettingsLoaded = false; let automationSettingsBusy = false; let automationSettingsError = ''; let automationSettingsSyncInFlight = null; let channelList = []; let channelSettingsBusy = false; let leaderSummaryWebhookStatus = null; let parserRoutingRows = []; let parserRoutingBusy = false; let accountList = []; let accountTaskTypes = []; let accountSettingsBusy = false; let accountAuthorizationTargetId = ''; let accountAuthorizationDraft = new Set(); let auditEvents = []; let auditBusy = false; let operationsDashboardData = null; let operationsDashboardBusy = false; let operationsDashboardOffset = 0; let operationsDashboardSelectedTaskId = ''; let operationsDashboardDetail = null; let autoHandoffInProgress = false; let taskStore = []; const runtimeTaskStore = new Map(); const remoteTaskStore = new Map(); const taskDetailStore = new Map(); const taskDetailRequests = new Map(); const taskInputHistoryStore = new Map(); const taskInputHistoryRequests = new Map(); const localTaskOverlayStore = new Map(); let pollInProgress = false; let backgroundRefreshInProgress = false; let bridgePingInFlight = null; let taskListMeta = { total: 0, offset: 0, limit: HOME_TASK_LIMIT, has_more: false }; let historySearch = ''; let historyStatus = ''; let historyArchive = 'active'; let historyOffset = 0; const lifecycleScrollPositions = new Map(); const taskArchiveStates = new Map(); const taskDeleteStates = new Map(); const historySelectedTaskIds = new Set(); const locallyDeletedTaskIds = new Set(); let historyBulkDeleteBusy = false; let historyBulkAction = ''; let historyBatchMessage = ''; let historyBatchMessageKind = ''; const taskReconciliationStates = new Map(); const taskReplyStates = new Map(); const taskReplyDrafts = new Map(); const taskAttachmentDrafts = new Map(); const autoHandoffRetryAt = new Map(); const extensionResultPersistQueues = new Map(); const pendingExtensionResults = new Map(); const persistedExtensionResultVersions = new Map(); let taskCreateInProgress = false; const AUTO_HANDOFF_RETRY_MS = 30_000; const REQUIRED_EXTENSION_VERSION = '0.5.189'; const MANUAL_HANDOFF_LABEL = '确认并提交到 ERP 插件'; const RETRY_HANDOFF_LABEL = '继续提交到 ERP 插件'; const RECONCILE_LABEL = '只读回查 ERP 现有结果'; const TERMINAL_TASK_STATUSES = new Set([ 'completed', 'blocked', 'failed', 'cancelled', 'dry_run', 'parse_failed' ]); const MANUAL_RECONCILIATION_STATUSES = new Set([ 'reconciliation_pending', 'saved_unverified', 'execution_uncertain', 'uncertain' ]); function requestTimeoutForPath(path) { if (/^\/api\/auth\//.test(path)) return AUTH_API_TIMEOUT_MS; if (/\/result$/.test(path)) return RESULT_API_TIMEOUT_MS; return DEFAULT_API_TIMEOUT_MS; } async function fetchWithTimeout(resource, options = {}, timeoutMs = DEFAULT_API_TIMEOUT_MS) { const controller = new AbortController(); const externalSignal = options.signal; let timeoutId = null; let onExternalAbort = null; if (externalSignal) { onExternalAbort = () => controller.abort(externalSignal.reason); if (externalSignal.aborted) onExternalAbort(); else externalSignal.addEventListener('abort', onExternalAbort, { once: true }); } timeoutId = window.setTimeout(() => controller.abort(), timeoutMs); try { return await fetch(resource, { ...options, signal: controller.signal }); } catch (error) { if (controller.signal.aborted && !externalSignal?.aborted) { const timeoutError = new Error(`请求超时(${Math.round(timeoutMs / 1000)} 秒):${String(resource)}`); timeoutError.code = 'request_timeout'; throw timeoutError; } throw error; } finally { if (timeoutId !== null) window.clearTimeout(timeoutId); if (externalSignal && onExternalAbort) externalSignal.removeEventListener('abort', onExternalAbort); } } function $(selector) { return document.querySelector(selector); } function isAdministrator() { return authUser?.role === 'admin'; } function isTeamLeader() { return authUser?.role === 'team_lead'; } function canUseTaskDataPlane(user = authUser) { return user?.role === 'team_lead' || user?.role === 'user'; } function canViewOperationsDashboard() { return isTeamLeader(); } function connectionIdForUser(user) { if (!user?.id || !canUseTaskDataPlane(user)) return ''; const storageKey = `liansyn_platform_browser_connection_id:${user.id}`; let connectionId = localStorage.getItem(storageKey) || ''; if (!/^platform-browser:[a-f0-9-]{16,80}$/i.test(connectionId)) { const identifier = crypto.randomUUID?.() || Array.from(crypto.getRandomValues(new Uint8Array(16)), (value) => value.toString(16).padStart(2, '0')).join(''); connectionId = `platform-browser:${identifier}`; localStorage.setItem(storageKey, connectionId); } return connectionId; } function rosterAttachmentError(code, message) { const error = new Error(message); error.code = code; return error; } function validateRosterAttachmentFile(file) { if (!file) throw rosterAttachmentError('roster_file_required', '请选择一份 .xls 或 .xlsx 名单附件。'); const name = String(file.name || '').trim(); if (!/\.(?:xls|xlsx)$/i.test(name)) { throw rosterAttachmentError('roster_file_type_unsupported', '名单附件必须是 .xls 或 .xlsx 文件。'); } if (!Number.isSafeInteger(file.size) || file.size <= 0) { throw rosterAttachmentError('roster_file_empty', '名单附件为空,请重新选择。'); } return file; } function rosterAttachmentContentType(file) { if (String(file.type || '').trim()) return String(file.type).trim(); return /\.xlsx$/i.test(String(file.name || '')) ? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' : 'application/vnd.ms-excel'; } function rosterAttachmentSizeText(file) { const bytes = Number(file?.size || 0); if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${Math.round(bytes / 102.4) / 10} KB`; return `${Math.round(bytes / 1024 / 102.4) / 10} MB`; } async function encodeRosterAttachment(file) { validateRosterAttachmentFile(file); const dataUrl = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.addEventListener('load', () => resolve(String(reader.result || '')), { once: true }); reader.addEventListener('error', () => reject( rosterAttachmentError('roster_file_read_failed', '无法读取名单附件,请重新选择。') ), { once: true }); reader.addEventListener('abort', () => reject( rosterAttachmentError('roster_file_read_aborted', '名单附件读取已取消。') ), { once: true }); reader.readAsDataURL(file); }); const delimiter = dataUrl.indexOf(','); const contentBase64 = delimiter >= 0 ? dataUrl.slice(delimiter + 1) : ''; if (!contentBase64) throw rosterAttachmentError('roster_file_read_failed', '无法读取名单附件,请重新选择。'); return { name: String(file.name || '').trim(), content_type: rosterAttachmentContentType(file), size: file.size, content_base64: contentBase64 }; } async function apiRequest(path, options = {}) { if (authUser && !canUseTaskDataPlane() && isTaskDataPlaneApiPath(path)) { const accessError = new Error('管理员账号仅用于平台管理,不能访问业务任务。'); accessError.code = 'task_access_forbidden'; throw accessError; } const method = String(options.method || 'GET').toUpperCase(); const headers = new Headers(options.headers || {}); const timeoutMs = Number(options.timeoutMs) || requestTimeoutForPath(path); const requestOptions = { ...options }; delete requestOptions.timeoutMs; 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 fetchWithTimeout(path, { ...requestOptions, method, headers, cache: options.cache || 'no-store', credentials: 'same-origin', body: options.body !== undefined && typeof options.body !== 'string' ? JSON.stringify(options.body) : options.body }, timeoutMs); let payload = null; try { payload = await response.json(); } catch (error) { payload = null; } if (response.status === 401 && path !== '/api/auth/login') { showLoginPanel('登录已过期,请重新登录。'); } if (!response.ok) { const requestError = new Error(payload?.message || payload?.error || `请求失败:HTTP ${response.status}`); requestError.status = response.status; requestError.errorCode = payload?.error_code || ''; requestError.details = payload?.details; throw requestError; } return payload || {}; } function isTaskDataPlaneApiPath(path) { const pathname = String(path || '').split(/[?#]/, 1)[0]; return pathname === '/api/messages' || pathname === '/api/connections/heartbeat' || pathname === '/api/events' || pathname === '/api/tasks' || pathname.startsWith('/api/tasks/') || pathname.startsWith('/api/parser-decisions/') || pathname === '/api/operations-dashboard' || pathname.startsWith('/api/operations-dashboard/'); } function hideAuthenticatedNavigation() { for (const selector of [ '#workbenchNav', '#historyNav', '#operationsDashboardNav', '#channelsNav', '#parserRoutingNav', '#accountsNav', '#auditNav', '#bridgeState' ]) { const element = $(selector); if (element) element.hidden = true; } } function clearTaskDataPlaneClientState() { currentTaskId = ''; taskStore = []; runtimeTaskStore.clear(); remoteTaskStore.clear(); taskDetailStore.clear(); taskDetailRequests.clear(); taskInputHistoryStore.clear(); taskInputHistoryRequests.clear(); localTaskOverlayStore.clear(); extensionResultPersistQueues.clear(); pendingExtensionResults.clear(); persistedExtensionResultVersions.clear(); taskArchiveStates.clear(); taskDeleteStates.clear(); taskReplyStates.clear(); taskReplyDrafts.clear(); taskAttachmentDrafts.clear(); historySelectedTaskIds.clear(); locallyDeletedTaskIds.clear(); sessionStorage.removeItem('liansyn_platform_current_task_id'); stopPolling(); if (eventStream) eventStream.close(); eventStream = null; } function showLoginPanel(message = '') { authUser = null; browserConnectionId = ''; csrfToken = ''; taskAttachmentDrafts.clear(); const rosterAttachmentInput = $('#rosterAttachment'); const rosterAttachmentStatus = $('#rosterAttachmentStatus'); if (rosterAttachmentInput) rosterAttachmentInput.value = ''; if (rosterAttachmentStatus) rosterAttachmentStatus.textContent = ''; stopPolling(); automationSettingsLoaded = false; automationSettingsError = ''; organizationAutomationEnabled = false; if (eventStream) { eventStream.close(); eventStream = null; } const panel = $('#loginPanel'); const workbench = $('#workbench'); const channelsPage = $('#channelsPage'); const parserRoutingPage = $('#parserRoutingPage'); const accountsPage = $('#accountsPage'); const auditPage = $('#auditPage'); const operationsDashboardPage = $('#operationsDashboardPage'); const passwordPanel = $('#passwordChangePanel'); const authState = $('#authState'); const automationButton = $('#automationToggleButton'); const logoutButton = $('#logoutButton'); const changePasswordButton = $('#changePasswordButton'); if (panel) panel.hidden = false; if (workbench) workbench.hidden = true; if (channelsPage) channelsPage.hidden = true; if (parserRoutingPage) parserRoutingPage.hidden = true; if (accountsPage) accountsPage.hidden = true; if (auditPage) auditPage.hidden = true; if (operationsDashboardPage) operationsDashboardPage.hidden = true; if (passwordPanel) passwordPanel.hidden = true; if (authState) authState.hidden = true; if (automationButton) automationButton.hidden = true; if (logoutButton) logoutButton.hidden = true; if (changePasswordButton) changePasswordButton.hidden = true; hideAuthenticatedNavigation(); const submitButton = $('#loginForm button[type="submit"]'); if (submitButton) submitButton.disabled = false; const error = $('#loginError'); if (error) error.textContent = message; } function showAuthChecking() { authUser = null; csrfToken = ''; automationSettingsLoaded = false; automationSettingsError = ''; organizationAutomationEnabled = false; if (eventStream) { eventStream.close(); eventStream = null; } const panel = $('#loginPanel'); const workbench = $('#workbench'); const channelsPage = $('#channelsPage'); const parserRoutingPage = $('#parserRoutingPage'); const accountsPage = $('#accountsPage'); const auditPage = $('#auditPage'); const operationsDashboardPage = $('#operationsDashboardPage'); const passwordPanel = $('#passwordChangePanel'); const authState = $('#authState'); const automationButton = $('#automationToggleButton'); const logoutButton = $('#logoutButton'); const changePasswordButton = $('#changePasswordButton'); if (panel) panel.hidden = false; if (workbench) workbench.hidden = true; if (channelsPage) channelsPage.hidden = true; if (parserRoutingPage) parserRoutingPage.hidden = true; if (accountsPage) accountsPage.hidden = true; if (auditPage) auditPage.hidden = true; if (operationsDashboardPage) operationsDashboardPage.hidden = true; if (passwordPanel) passwordPanel.hidden = true; if (authState) authState.hidden = true; if (automationButton) automationButton.hidden = true; if (logoutButton) logoutButton.hidden = true; if (changePasswordButton) changePasswordButton.hidden = true; hideAuthenticatedNavigation(); const submitButton = $('#loginForm button[type="submit"]'); if (submitButton) submitButton.disabled = true; const error = $('#loginError'); if (error) error.textContent = '正在恢复登录状态(最多等待 10 秒)…'; } function showAuthenticatedApp(user) { authUser = user; browserConnectionId = canUseTaskDataPlane() ? connectionIdForUser(user) : ''; if (isAdministrator()) { clearTaskDataPlaneClientState(); if (user?.id) localStorage.removeItem(`liansyn_platform_browser_connection_id:${user.id}`); } if (isAdministrator() && (IS_TASK_PAGE || IS_OPERATIONS_DASHBOARD_PAGE)) { window.location.replace('/accounts'); return false; } if (!isAdministrator() && IS_ADMIN_PAGE) { window.location.replace('/'); return false; } if (IS_OPERATIONS_DASHBOARD_PAGE && !canViewOperationsDashboard()) { window.location.replace('/'); return false; } const panel = $('#loginPanel'); const workbench = $('#workbench'); const channelsPage = $('#channelsPage'); const parserRoutingPage = $('#parserRoutingPage'); const accountsPage = $('#accountsPage'); const auditPage = $('#auditPage'); const operationsDashboardPage = $('#operationsDashboardPage'); const passwordPanel = $('#passwordChangePanel'); const authState = $('#authState'); const automationButton = $('#automationToggleButton'); const bridgeState = $('#bridgeState'); const logoutButton = $('#logoutButton'); const changePasswordButton = $('#changePasswordButton'); if (panel) panel.hidden = true; if (passwordPanel) passwordPanel.hidden = true; if (workbench) workbench.hidden = IS_MANAGEMENT_PAGE || !canUseTaskDataPlane(); if (channelsPage) channelsPage.hidden = !IS_CHANNELS_PAGE || !isAdministrator(); if (parserRoutingPage) parserRoutingPage.hidden = !IS_PARSER_ROUTING_PAGE || !isAdministrator(); if (accountsPage) accountsPage.hidden = !IS_ACCOUNTS_PAGE || !isAdministrator(); if (auditPage) auditPage.hidden = !IS_AUDIT_PAGE || !isAdministrator(); if (operationsDashboardPage) { operationsDashboardPage.hidden = !IS_OPERATIONS_DASHBOARD_PAGE || !canViewOperationsDashboard(); } if (authState) { authState.hidden = false; authState.textContent = `${user.username} · ${accountRoleLabel(user.role)}`; } if (logoutButton) logoutButton.hidden = false; if (changePasswordButton) changePasswordButton.hidden = false; for (const selector of ['#channelsNav', '#parserRoutingNav', '#accountsNav', '#auditNav']) { const nav = $(selector); if (nav) nav.hidden = !isAdministrator(); } const operationsDashboardNav = $('#operationsDashboardNav'); if (operationsDashboardNav) operationsDashboardNav.hidden = !canViewOperationsDashboard(); for (const selector of ['#workbenchNav', '#historyNav']) { const nav = $(selector); if (nav) nav.hidden = !canUseTaskDataPlane(); } if (bridgeState) bridgeState.hidden = !canUseTaskDataPlane(); if (automationButton) { automationButton.hidden = !isAdministrator() || IS_MANAGEMENT_PAGE; renderAutomationToggle(); } const submitButton = $('#loginForm button[type="submit"]'); if (submitButton) submitButton.disabled = false; return true; } function showPasswordChangePanel() { if (!authUser) return; for (const selector of ['#loginPanel', '#workbench', '#channelsPage', '#parserRoutingPage', '#accountsPage', '#auditPage', '#operationsDashboardPage']) { const page = $(selector); if (page) page.hidden = true; } const panel = $('#passwordChangePanel'); if (panel) panel.hidden = false; $('#passwordChangeTitle').textContent = '修改密码'; $('#passwordChangeHint').textContent = '修改后,其他已登录会话将失效。'; $('#passwordChangeCancel').hidden = false; $('#passwordChangeError').textContent = ''; $('#changePasswordButton').hidden = true; $('#automationToggleButton').hidden = true; } function renderAutomationToggle() { const button = $('#automationToggleButton'); if (!button) return; button.hidden = !isAdministrator() || IS_MANAGEMENT_PAGE; button.disabled = !authUser || automationSettingsBusy || !automationSettingsLoaded || Boolean(automationSettingsError); button.setAttribute('aria-pressed', organizationAutomationEnabled ? 'true' : 'false'); button.classList.toggle('is-enabled', organizationAutomationEnabled); button.classList.toggle('is-error', Boolean(automationSettingsError)); button.textContent = automationSettingsError ? '全自动化:读取失败' : !automationSettingsLoaded ? '全自动化:读取中…' : organizationAutomationEnabled ? '全自动化:已开启' : '全自动化:已关闭'; button.title = automationSettingsError || (organizationAutomationEnabled ? '已开启:所有来源的新任务解析通过后将自动进入 ERP' : '已关闭:所有来源的新任务解析通过后都需要人工确认才进入 ERP'); } async function syncAutomationSettings({ background = false } = {}) { if (!isAdministrator()) return null; if (automationSettingsSyncInFlight) return automationSettingsSyncInFlight; if (automationSettingsBusy) return null; const showBusy = !background || !automationSettingsLoaded; if (showBusy) { automationSettingsBusy = true; automationSettingsError = ''; renderAutomationToggle(); } automationSettingsSyncInFlight = (async () => { try { const result = await apiRequest('/api/settings/automation'); organizationAutomationEnabled = result.settings?.enabled === true; automationSettingsLoaded = true; automationSettingsError = ''; return result.settings || { enabled: organizationAutomationEnabled }; } catch (error) { automationSettingsLoaded = false; automationSettingsError = error.message || String(error); throw error; } finally { if (showBusy) automationSettingsBusy = false; automationSettingsSyncInFlight = null; renderAutomationToggle(); if (IS_PARSER_ROUTING_PAGE) renderParserRouting(); } })(); return automationSettingsSyncInFlight; } async function toggleAutomationSetting() { if (!isAdministrator()) return; if (automationSettingsSyncInFlight) { try { await automationSettingsSyncInFlight; } catch (error) { return; } } if (!authUser || !automationSettingsLoaded || automationSettingsBusy) return; const nextEnabled = !organizationAutomationEnabled; automationSettingsBusy = true; automationSettingsError = ''; renderAutomationToggle(); try { const result = await apiRequest('/api/settings/automation', { method: 'PUT', body: { enabled: nextEnabled } }); organizationAutomationEnabled = result.settings?.enabled === true; automationSettingsLoaded = true; renderAutomationToggle(); await autoDispatchReadyTasks(); } catch (error) { automationSettingsError = error.message || String(error); renderAutomationToggle(); throw error; } finally { automationSettingsBusy = false; renderAutomationToggle(); } } const PARSER_MODE_ORDER = ['ai', 'shadow', 'auto', 'program']; const PARSER_MODE_LABELS = { ai: 'AI', shadow: 'Shadow', auto: 'Auto', program: 'Program' }; const PARSER_GATE_LABELS = { program_implemented: '程序及夹具已实现', single_route_promotion: '没有其他业务处于 Auto 测试', all_differences_reviewed: '差异已全部判定', program_critical_errors: '程序关键错误', program_contract_errors: '程序契约错误', program_runtime_errors: '程序运行错误', downstream_failures: '程序下游失败' }; function parserStatsText(stats = {}) { const count = Number(stats.parse_count || 0); const match = stats.match_rate_percent == null ? '—' : `${stats.match_rate_percent}% 匹配`; const fallback = stats.fallback_rate_percent == null ? '—' : `${stats.fallback_rate_percent}% 兜底`; const duration = stats.average_duration_ms == null ? '—' : `${stats.average_duration_ms}ms`; const p95 = stats.p95_duration_ms == null ? '—' : `P95 ${stats.p95_duration_ms}ms`; return `${count} 次 · ${match} · ${fallback} · ${duration} / ${p95}`; } function parserNextGate(route) { if (route.mode_locked) return null; const rank = PARSER_MODE_ORDER.indexOf(route.mode); if (rank < 0 || rank >= PARSER_MODE_ORDER.length - 1) return null; const nextMode = PARSER_MODE_ORDER[rank + 1]; return { mode: nextMode, gate: route.promotion?.[nextMode] || { eligible: false, requirements: [] } }; } function parserGateNode(route) { if (route.mode_locked) return el('span', 'parser-gate-complete', 'Program-only · 固定策略'); const next = parserNextGate(route); if (!next) return el('span', 'parser-gate-complete', '已在 Program'); const wrapper = el('div', `parser-gate ${next.gate.eligible ? 'is-ready' : 'is-waiting'}`); wrapper.append(el('strong', '', next.gate.eligible ? `可进入 ${PARSER_MODE_LABELS[next.mode]}` : `待满足 ${PARSER_MODE_LABELS[next.mode]}`)); const unmet = (next.gate.requirements || []).filter((item) => !item.met); const detail = unmet.length ? unmet.slice(0, 3).map((item) => `${PARSER_GATE_LABELS[item.code] || item.code} ${item.current ?? 0}/${item.required}`).join(';') : '即时检查已通过'; wrapper.append(el('span', '', detail)); return wrapper; } function renderParserRouting() { const body = $('#parserRoutingRows'); const message = $('#parserRoutingMessage'); const notice = $('#parserAutomationNotice'); const emergency = $('#emergencyAiButton'); if (!body) return; if (notice) { notice.className = `parser-automation-notice ${organizationAutomationEnabled ? 'is-enabled' : ''}`; notice.textContent = organizationAutomationEnabled ? '平台全自动化已开启:新任务切入 Auto / Program 后,解析通过可能直接进入 ERP,不附加人工确认期。' : '平台全自动化已关闭:解析模式变化不会改变当前人工确认设置。'; } if (emergency) emergency.disabled = parserRoutingBusy || !parserRoutingRows.length; body.replaceChildren(); if (!parserRoutingRows.length) { const row = document.createElement('tr'); const cell = document.createElement('td'); cell.colSpan = 7; cell.className = 'parser-routing-empty'; cell.textContent = parserRoutingBusy ? '正在读取解析策略…' : '解析策略尚未加载。'; row.append(cell); body.append(row); return; } for (const route of parserRoutingRows) { const row = document.createElement('tr'); row.dataset.routeId = route.route_id; const business = document.createElement('td'); business.append(el('strong', 'parser-business-name', `${route.display_order || '—'}. ${route.directive}`)); business.append(el('span', 'parser-route-id', route.route_id)); business.append(el('span', 'parser-release-order', `建议测试顺序 ${route.release_order || '—'}`)); const version = document.createElement('td'); version.append(el('span', `parser-implementation ${route.program_implemented ? 'is-ready' : ''}`, route.program_implemented ? '已实现' : '未实现')); version.append(el('code', 'parser-version', route.program_version || '—')); const modeCell = document.createElement('td'); const select = document.createElement('select'); select.className = `parser-mode-select mode-${route.mode}`; select.dataset.parserModeRoute = route.route_id; select.disabled = parserRoutingBusy || route.mode_locked === true; select.title = route.mode_locked ? '该业务固定使用 Program 解析' : '切换新任务的解析模式'; const allowedModes = Array.isArray(route.allowed_modes) && route.allowed_modes.length ? route.allowed_modes.filter((mode) => PARSER_MODE_ORDER.includes(mode)) : PARSER_MODE_ORDER; const currentRank = PARSER_MODE_ORDER.indexOf(route.mode); for (const mode of PARSER_MODE_ORDER) { const option = document.createElement('option'); option.value = mode; option.textContent = route.mode_locked && mode === route.mode ? `${PARSER_MODE_LABELS[mode]}(固定)` : PARSER_MODE_LABELS[mode]; const rank = PARSER_MODE_ORDER.indexOf(mode); const gate = route.promotion?.[mode]; option.disabled = !allowedModes.includes(mode) || (rank > currentRank && (rank !== currentRank + 1 || gate?.eligible !== true)); option.selected = mode === route.mode; select.append(option); } modeCell.append(select, el( 'span', 'parser-revision', route.mode_locked ? `Program-only · revision ${route.revision}` : `revision ${route.revision}` )); const stats7 = document.createElement('td'); stats7.textContent = parserStatsText(route.stats_7d); const stats30 = document.createElement('td'); stats30.textContent = parserStatsText(route.stats_30d); const gate = document.createElement('td'); gate.append(parserGateNode(route)); const updated = document.createElement('td'); updated.append(el('span', '', route.updated_by || '未配置')); updated.append(el('time', 'parser-updated-at', formatDateTime(route.updated_at))); row.append(business, version, modeCell, stats7, stats30, gate, updated); body.append(row); } if (message && !parserRoutingBusy && !message.textContent) message.textContent = `已加载 ${parserRoutingRows.length} 项业务。`; } async function syncParserRouting() { if (!isAdministrator()) return; parserRoutingBusy = true; const message = $('#parserRoutingMessage'); if (message) message.textContent = '正在读取解析策略…'; renderParserRouting(); try { const result = await apiRequest('/api/settings/parser-routing'); parserRoutingRows = Array.isArray(result.routes) ? result.routes : []; if (message) message.textContent = `已加载 ${parserRoutingRows.length} 项业务。`; } catch (error) { if (message) message.textContent = `解析策略读取失败:${error.message || String(error)}`; throw error; } finally { parserRoutingBusy = false; renderParserRouting(); } } async function changeParserRoutingMode(routeId, mode) { const route = parserRoutingRows.find((item) => item.route_id === routeId); const allowedModes = Array.isArray(route?.allowed_modes) && route.allowed_modes.length ? route.allowed_modes : PARSER_MODE_ORDER; if (!route || route.mode_locked || !allowedModes.includes(mode) || parserRoutingBusy || route.mode === mode) return; if (organizationAutomationEnabled && ['auto', 'program'].includes(mode)) { const confirmed = window.confirm(`平台全自动化已开启。将“${route.directive}”切换到 ${PARSER_MODE_LABELS[mode]} 后,新任务解析通过可能直接进入 ERP。确认继续?`); if (!confirmed) { renderParserRouting(); return; } } parserRoutingBusy = true; const message = $('#parserRoutingMessage'); if (message) message.textContent = `正在切换“${route.directive}”…`; renderParserRouting(); try { await apiRequest(`/api/settings/parser-routing/${encodeURIComponent(routeId)}`, { method: 'PUT', body: { mode, expected_revision: route.revision } }); await syncParserRouting(); if (message) message.textContent = `“${route.directive}”已切换到 ${PARSER_MODE_LABELS[mode]};只影响新任务和新会话。`; } catch (error) { parserRoutingBusy = false; await syncParserRouting().catch(() => {}); if (message) message.textContent = `切换失败:${error.message || String(error)}`; } finally { parserRoutingBusy = false; renderParserRouting(); } } async function emergencyAllAi() { if (parserRoutingBusy) return; const programOnlyCount = parserRoutingRows.filter((route) => route.mode_locked).length; const configurableCount = parserRoutingRows.length - programOnlyCount; const reason = window.prompt('请输入可配置业务切回 AI 的原因(将记录审计):'); if (!normalizeText(reason)) return; if (!window.confirm(`确认在一个事务内把 ${configurableCount} 项可配置业务切回 AI?${programOnlyCount} 项名单导入仍保持 Program,运行中任务仍沿用其创建时快照。`)) return; parserRoutingBusy = true; renderParserRouting(); const message = $('#parserRoutingMessage'); if (message) message.textContent = '正在将可配置业务切回 AI…'; try { const result = await apiRequest('/api/settings/parser-routing/emergency-ai', { method: 'POST', body: { reason: normalizeText(reason) } }); parserRoutingRows = Array.isArray(result.routes) ? result.routes : []; if (message) message.textContent = `${configurableCount} 项可配置业务已切回 AI;名单导入仍保持 Program,仅影响此后创建的新任务。`; } catch (error) { if (message) message.textContent = `紧急回退失败:${error.message || String(error)}`; } finally { parserRoutingBusy = false; renderParserRouting(); } } function channelStatusLabel(status) { return { connected: '已连接', connecting: '连接中', error: '连接错误', disabled: '已停用' }[status] || '未知状态'; } function renderChannelList() { const container = $('#channelList'); const message = $('#channelMessage'); if (!container) return; container.innerHTML = ''; if (!channelList.length) { container.append(el('p', 'muted channel-empty', '还没有用户渠道。添加后,每个渠道会使用独立的 AgentBus key。')); return; } for (const channel of channelList) { const row = el('article', 'channel-row'); const main = el('div', 'channel-row-main'); const heading = el('div', 'channel-row-heading'); heading.append(el('strong', '', channel.display_name || '未命名渠道')); heading.append(el('span', `state ${channel.status === 'connected' ? 'state-ok' : channel.status === 'error' ? 'state-bad' : 'state-warn'}`, channelStatusLabel(channel.status))); main.append(heading); main.append(el('p', 'muted', channel.external_user_ref ? `外部用户:${channel.external_user_ref}` : '未设置外部用户标识')); main.append(el( 'p', channel.routing_ready ? 'muted' : 'channel-error', channel.owner_username ? `执行账号:${channel.owner_username} · ERP:${channel.owner_erp_account || '未配置'}` : '未绑定平台员工账号,渠道不会接收任务' )); main.append(el('p', 'channel-key-state', 'AgentBus key:已配置(平台不会回显)')); if (channel.last_error) main.append(el('p', 'channel-error', channel.last_error)); row.append(main); const actions = el('div', 'channel-row-actions'); const owner = document.createElement('select'); owner.dataset.channelAction = 'owner'; owner.dataset.channelId = channel.id; const availableOwners = accountList.filter((account) => ( account.role !== 'admin' && account.is_active && account.erp_account && (account.id === channel.owner_user_id || !channelList.some((item) => item.id !== channel.id && item.owner_user_id === account.id)) )); owner.append(new Option('选择执行账号', '')); for (const account of availableOwners) { owner.append(new Option(`${account.username} · ${account.erp_account}`, account.id)); } owner.value = channel.owner_user_id || ''; owner.disabled = channelSettingsBusy; actions.append(owner); const toggle = el('button', 'secondary-button', channel.enabled ? '停用' : '启用'); toggle.type = 'button'; toggle.dataset.channelAction = 'toggle'; toggle.dataset.channelId = channel.id; actions.append(toggle); const rotate = el('button', 'secondary-button', '轮换 key'); rotate.type = 'button'; rotate.dataset.channelAction = 'rotate'; rotate.dataset.channelId = channel.id; actions.append(rotate); const rename = el('button', 'secondary-button', '维护名称'); rename.type = 'button'; rename.dataset.channelAction = 'rename'; rename.dataset.channelId = channel.id; actions.append(rename); const deleteButton = el('button', 'danger-button', channel.deletable === false ? '环境托管' : '删除'); deleteButton.type = 'button'; deleteButton.dataset.channelAction = 'delete'; deleteButton.dataset.channelId = channel.id; deleteButton.disabled = channelSettingsBusy || channel.deletable === false; if (channel.deletable === false) { deleteButton.title = '该兼容渠道由旧 AgentBus 环境变量托管;请先移除环境配置并重启服务。'; } actions.append(deleteButton); row.append(actions); container.append(row); } if (message && !channelSettingsBusy) message.textContent = ''; } async function syncChannels() { if (!isAdministrator()) return null; const result = await apiRequest('/api/channels'); channelList = Array.isArray(result.channels) ? result.channels : []; renderChannelList(); renderChannelOwnerOptions(); return channelList; } function renderChannelOwnerOptions() { const select = $('#channelOwnerUserId'); if (!select) return; const selected = String(select.value || ''); select.replaceChildren(new Option('请选择员工账号', '')); const boundIds = new Set(channelList.map((channel) => channel.owner_user_id).filter(Boolean)); for (const account of accountList) { if (account.role === 'admin' || !account.is_active || !account.erp_account || boundIds.has(account.id)) continue; select.append(new Option(`${account.username} · ERP ${account.erp_account}`, account.id)); } if ([...select.options].some((option) => option.value === selected)) select.value = selected; } async function createChannelFromForm() { if (!authUser || channelSettingsBusy) return; const message = $('#channelMessage'); channelSettingsBusy = true; if (message) message.textContent = '正在保存渠道…'; try { const result = await apiRequest('/api/channels', { method: 'POST', body: { display_name: normalizeText($('#channelDisplayName')?.value).slice(0, 120), owner_user_id: String($('#channelOwnerUserId')?.value || ''), external_user_ref: normalizeText($('#channelExternalUserRef')?.value).slice(0, 200), agentbus_key: String($('#channelAgentbusKey')?.value || '').trim(), bot_address: normalizeText($('#channelBotAddress')?.value).slice(0, 200), enabled: true } }); if (result.channel) channelList = [...channelList.filter((item) => item.id !== result.channel.id), result.channel]; $('#channelAgentbusKey').value = ''; $('#channelDisplayName').value = ''; $('#channelOwnerUserId').value = ''; $('#channelExternalUserRef').value = ''; $('#channelBotAddress').value = ''; renderChannelList(); renderChannelOwnerOptions(); if (message) message.textContent = '渠道已保存,连接状态会在服务端异步更新。'; } finally { channelSettingsBusy = false; } } async function updateChannelEnabled(channelId, enabled) { if (!authUser || channelSettingsBusy) return; channelSettingsBusy = true; try { await apiRequest(`/api/channels/${encodeURIComponent(channelId)}`, { method: 'PATCH', body: { enabled } }); await syncChannels(); } finally { channelSettingsBusy = false; } } async function updateChannelOwner(channelId, ownerUserId) { if (!authUser || channelSettingsBusy || !ownerUserId) return; channelSettingsBusy = true; try { await apiRequest(`/api/channels/${encodeURIComponent(channelId)}`, { method: 'PATCH', body: { owner_user_id: ownerUserId } }); await syncChannels(); renderChannelOwnerOptions(); } finally { channelSettingsBusy = false; renderChannelList(); renderChannelOwnerOptions(); } } async function renameChannel(channelId) { if (!authUser || channelSettingsBusy) return; const channel = channelList.find((item) => item.id === channelId); const currentName = channel?.display_name || ''; const nextName = window.prompt('请输入新的渠道名称:', currentName); if (nextName === null || !nextName.trim() || nextName.trim() === currentName) return; channelSettingsBusy = true; const message = $('#channelMessage'); if (message) message.textContent = '正在更新渠道名称…'; try { await apiRequest(`/api/channels/${encodeURIComponent(channelId)}`, { method: 'PATCH', body: { display_name: nextName.trim().slice(0, 120) } }); await syncChannels(); if (message) message.textContent = '渠道名称已更新。'; } finally { channelSettingsBusy = false; } } async function rotateChannelKey(channelId) { if (!authUser || channelSettingsBusy) return; const key = window.prompt('请输入新的 AgentBus key。保存后旧 key 将立即失效:', ''); if (!key?.trim()) return; channelSettingsBusy = true; try { await apiRequest(`/api/channels/${encodeURIComponent(channelId)}/rotate-key`, { method: 'POST', body: { agentbus_key: key.trim() } }); await syncChannels(); } finally { channelSettingsBusy = false; } } async function deleteChannel(channelId) { if (!authUser || channelSettingsBusy) return; const channel = channelList.find((item) => item.id === channelId); if (!channel || channel.deletable === false) return; const confirmed = window.confirm( `确认删除 AgentBus 渠道“${channel.display_name || '未命名渠道'}”?\n\n` + '删除后连接会立即停止,服务端保存的 key、持久化 AgentBus 回执记录(包括未发送回执)会被移除;历史任务不会被删除。组长摘要已使用独立外部 Webhook,不受该渠道影响。此操作不可撤销。' ); if (!confirmed) return; const message = $('#channelMessage'); channelSettingsBusy = true; renderChannelList(); if (message) message.textContent = `正在删除“${channel.display_name || '未命名渠道'}”…`; try { await apiRequest(`/api/channels/${encodeURIComponent(channelId)}`, { method: 'DELETE' }); channelList = channelList.filter((item) => item.id !== channelId); } finally { channelSettingsBusy = false; renderChannelList(); } if (message) message.textContent = `“${channel.display_name || '未命名渠道'}”已删除。`; } function renderLeaderSummaryWebhookStatus() { const container = $('#leaderSummaryList'); if (!container) return; container.replaceChildren(); const status = leaderSummaryWebhookStatus; if (!status) { container.append(el('p', 'muted channel-empty', '正在读取外部 Webhook 投递状态…')); return; } const active = status.enabled === true && status.state === 'active'; const invalid = status.state === 'invalid_configuration'; const row = el('article', 'channel-row'); const main = el('div', 'channel-row-main'); const heading = el('div', 'channel-row-heading'); heading.append(el('strong', '', '固定微信群 Webhook')); heading.append(el( 'span', `state ${active ? 'state-ok' : status.configured || invalid ? 'state-warn' : 'state-bad'}`, active ? '自动推送' : invalid ? '配置无效' : status.configured ? '配置同步中' : '尚未配置' )); main.append(heading); main.append(el('p', 'muted', `配置 ID:${status.webhook_config_id || '9999'} · 范围:同组织非管理员员工 · 来源:人工任务、AgentBus 任务`)); main.append(el( 'p', active ? 'channel-key-state' : 'channel-error', status.status_message || '外部 Webhook 状态未知。' )); main.append(el( 'p', 'muted', `当前队列:待发 ${status.pending_count || 0} · 明确拒绝 ${status.failed_count || 0} · 结果不确定 ${status.uncertain_count || 0} · 累计已受理 ${status.accepted_count || 0}` )); if (status.endpoint_fingerprint) { main.append(el('p', 'muted', `接口指纹:${status.endpoint_fingerprint} · token 与完整 URL 均不回显`)); } main.append(el('p', 'muted', `自动生效起点:${formatDateTime(status.starts_at)} · 最近受理:${formatDateTime(status.last_accepted_at)}`)); row.append(main); container.append(row); } async function syncLeaderSummaryWebhookStatus() { if (!isAdministrator()) return null; const result = await apiRequest('/api/settings/leader-summary-webhook'); leaderSummaryWebhookStatus = result.status || null; renderLeaderSummaryWebhookStatus(); return leaderSummaryWebhookStatus; } function accountRoleLabel(role) { if (role === 'admin') return '管理员'; if (role === 'team_lead') return '组长'; return '普通用户'; } function validateAccountCreationValues(values = {}) { const username = String(values.username || '').trim(); const password = String(values.password || ''); const role = String(values.role || ''); const erpAccount = String(values.erpAccount || '').trim(); if (!username || username.length > 160) { return { ok: false, field: 'accountUsername', message: '账号必须为 1—160 个字符。' }; } if (!password) { return { ok: false, field: 'accountPassword', message: '请输入初始密码。' }; } if (!['admin', 'team_lead', 'user'].includes(role)) { return { ok: false, field: 'accountRole', message: '请选择有效的账号角色。' }; } if (role !== 'admin' && (!erpAccount || erpAccount.length > 200)) { return { ok: false, field: 'accountErpAccount', message: '普通用户或组长必须填写 1—200 个字符的 ERP 账号。' }; } return { ok: true, body: { username, password, role, erp_account: role === 'admin' ? '' : erpAccount, business_route_ids: [] } }; } function accountCreationErrorMessage(error) { const details = Array.isArray(error?.details) ? error.details.map((item) => String(item || '')) : []; if (error?.errorCode === 'invalid_request') { if (details.some((path) => path === 'password' || path.startsWith('password.'))) { return '请输入初始密码。'; } if (details.some((path) => path === 'username' || path.startsWith('username.'))) { return '账号必须为 1—160 个字符。'; } if (details.some((path) => path === 'role' || path.startsWith('role.'))) { return '请选择有效的账号角色。'; } if (details.some((path) => path === 'erp_account' || path.startsWith('erp_account.'))) { return '普通用户或组长必须填写 ERP 账号。'; } } return error?.message || String(error); } function showAccountCreationValidationError(validation) { const message = $('#accountMessage'); if (message) message.textContent = validation.message; const field = validation.field ? document.getElementById(validation.field) : null; if (field) { field.setAttribute('aria-invalid', 'true'); field.focus(); } } function clearAccountCreationValidationErrors() { for (const fieldId of ['accountUsername', 'accountPassword', 'accountRole', 'accountErpAccount']) { document.getElementById(fieldId)?.removeAttribute('aria-invalid'); } } function renderAccounts() { const container = $('#accountList'); if (!container) return; container.replaceChildren(); if (!accountList.length) { accountAuthorizationTargetId = ''; accountAuthorizationDraft = new Set(); renderAccountAuthorizationPanel(); container.append(el('p', 'muted channel-empty', '还没有可显示的平台账号。')); return; } for (const account of accountList) { const row = el('article', 'channel-row'); const main = el('div', 'channel-row-main'); const heading = el('div', 'channel-row-heading'); heading.append(el('strong', '', account.username)); heading.append(el('span', `state ${account.is_active ? 'state-ok' : 'state-bad'}`, account.is_active ? '有效' : '已停用')); main.append(heading); main.append(el('p', 'muted', accountRoleLabel(account.role))); main.append(el('p', account.erp_account ? 'muted' : 'channel-error', account.role === 'admin' ? 'ERP 执行身份:不绑定' : `ERP 执行身份:${account.erp_account || '未配置(禁止执行)'}`)); const authorizedCount = Array.isArray(account.authorized_business_route_ids) ? account.authorized_business_route_ids.length : 0; main.append(el( 'p', 'muted account-authorization-summary', account.role === 'admin' ? `任务权限:全部 ${accountTaskTypes.length || 19} 类业务` : `任务权限:已授权 ${authorizedCount}/${accountTaskTypes.length || 19} 类业务` )); main.append(el('p', 'muted', `最近登录:${account.last_login_at ? new Date(account.last_login_at).toLocaleString('zh-CN') : '尚未登录'}`)); const actions = el('div', 'channel-row-actions'); const role = document.createElement('select'); role.dataset.accountAction = 'role'; role.dataset.accountId = account.id; role.innerHTML = ''; role.value = account.role; role.disabled = accountSettingsBusy || account.id === authUser?.id; const toggle = el('button', 'secondary-button', account.is_active ? '停用' : '启用'); toggle.type = 'button'; toggle.dataset.accountAction = 'toggle'; toggle.dataset.accountId = account.id; toggle.disabled = accountSettingsBusy || account.id === authUser?.id; const reset = el('button', 'secondary-button', '重置密码'); reset.type = 'button'; reset.dataset.accountAction = 'reset-password'; reset.dataset.accountId = account.id; reset.disabled = accountSettingsBusy; const revoke = el('button', 'secondary-button', '撤销会话'); revoke.type = 'button'; revoke.dataset.accountAction = 'revoke-sessions'; revoke.dataset.accountId = account.id; revoke.disabled = accountSettingsBusy; const authorizations = el('button', 'secondary-button', account.role === 'admin' ? '不参与任务' : '任务权限'); authorizations.type = 'button'; authorizations.dataset.accountAction = 'business-authorizations'; authorizations.dataset.accountId = account.id; authorizations.disabled = accountSettingsBusy || account.role === 'admin'; const erpAccount = el('button', 'secondary-button', 'ERP 账号'); erpAccount.type = 'button'; erpAccount.dataset.accountAction = 'erp-account'; erpAccount.dataset.accountId = account.id; erpAccount.disabled = accountSettingsBusy || account.role === 'admin'; actions.append(role, authorizations, erpAccount, toggle, reset, revoke); row.append(main, actions); container.append(row); } renderAccountAuthorizationPanel(); } function renderAccountAuthorizationPanel() { const panel = $('#accountAuthorizationPanel'); const container = $('#accountAuthorizationTypes'); if (!panel || !container) return; const account = accountList.find((item) => item.id === accountAuthorizationTargetId); const isOpen = Boolean(account && account.role !== 'admin'); panel.closest('.account-panel')?.classList.toggle('is-authorizing', isOpen); if (!isOpen) { panel.hidden = true; container.replaceChildren(); accountAuthorizationTargetId = ''; accountAuthorizationDraft = new Set(); return; } panel.hidden = false; $('#accountAuthorizationTitle').textContent = `${account.username} · 任务类型授权`; $('#accountAuthorizationHint').textContent = '仅勾选的业务可以接收指令、补充信息和名单附件;取消授权后,后续确认与 ERP 领取也会被阻止。'; container.replaceChildren(); for (const taskType of accountTaskTypes) { const label = el('label', `account-authorization-type${accountAuthorizationDraft.has(taskType.route_id) ? ' is-selected' : ''}`); const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = accountAuthorizationDraft.has(taskType.route_id); checkbox.disabled = accountSettingsBusy; checkbox.dataset.accountAuthorizationRoute = taskType.route_id; const copy = el('span', 'account-authorization-type-copy'); copy.append(el('strong', '', taskType.directive || taskType.route_id)); copy.append(el('small', 'muted', taskType.route_id)); label.append(checkbox, copy); container.append(label); } const count = accountAuthorizationDraft.size; $('#accountAuthorizationCount').textContent = `已选择 ${count}/${accountTaskTypes.length} 类业务${count ? '' : ';该账号将不能提交任何业务指令'}`; $('#accountAuthorizationSelectAll').disabled = accountSettingsBusy || !accountTaskTypes.length; $('#accountAuthorizationClear').disabled = accountSettingsBusy || !accountAuthorizationDraft.size; $('#accountAuthorizationSave').disabled = accountSettingsBusy; $('#accountAuthorizationCancel').disabled = accountSettingsBusy; } function openAccountAuthorizationEditor(accountId) { const account = accountList.find((item) => item.id === accountId); if (!account || account.role === 'admin') return; accountAuthorizationTargetId = account.id; accountAuthorizationDraft = new Set( Array.isArray(account.authorized_business_route_ids) ? account.authorized_business_route_ids : [] ); renderAccountAuthorizationPanel(); $('#accountAuthorizationPanel')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } function closeAccountAuthorizationEditor() { accountAuthorizationTargetId = ''; accountAuthorizationDraft = new Set(); renderAccountAuthorizationPanel(); } async function saveAccountBusinessAuthorizations() { if (!isAdministrator() || accountSettingsBusy || !accountAuthorizationTargetId) return; const account = accountList.find((item) => item.id === accountAuthorizationTargetId); if (!account || account.role === 'admin') return; accountSettingsBusy = true; renderAccounts(); const message = $('#accountMessage'); try { const result = await apiRequest(`/api/accounts/${encodeURIComponent(account.id)}/business-authorizations`, { method: 'PUT', body: { business_route_ids: accountTaskTypes .map((taskType) => taskType.route_id) .filter((routeId) => accountAuthorizationDraft.has(routeId)), expected_revision: Number(account.business_authorization_revision || 0) } }); if (result.account) { accountList = accountList.map((item) => item.id === account.id ? result.account : item); } if (message) message.textContent = `“${account.username}”的任务类型授权已更新,并立即应用于后续指令。`; accountAuthorizationTargetId = ''; accountAuthorizationDraft = new Set(); } finally { accountSettingsBusy = false; renderAccounts(); } } async function syncAccounts() { if (!isAdministrator()) return; const result = await apiRequest('/api/accounts'); accountList = Array.isArray(result.accounts) ? result.accounts : []; accountTaskTypes = Array.isArray(result.task_types) ? result.task_types : []; renderAccounts(); renderChannelOwnerOptions(); } async function createAccountFromForm() { if (!isAdministrator() || accountSettingsBusy) return; const validation = validateAccountCreationValues({ username: $('#accountUsername').value, password: $('#accountPassword').value, role: $('#accountRole').value, erpAccount: $('#accountErpAccount').value }); if (!validation.ok) { showAccountCreationValidationError(validation); return; } clearAccountCreationValidationErrors(); accountSettingsBusy = true; renderAccounts(); const message = $('#accountMessage'); try { const result = await apiRequest('/api/accounts', { method: 'POST', body: validation.body }); if (result.account) accountList = [...accountList.filter((item) => item.id !== result.account.id), result.account] .sort((left, right) => left.username.localeCompare(right.username, 'zh-CN')); $('#accountUsername').value = ''; $('#accountPassword').value = ''; $('#accountErpAccount').value = ''; if (message) { message.textContent = result.account?.role === 'admin' ? '管理员账号已创建;该角色仅能使用平台管理功能,不具备任何任务权限。' : '账号已创建,当前默认不能执行任何业务;请点击“任务权限”完成授权。'; } } finally { accountSettingsBusy = false; renderAccounts(); } } async function updateManagedAccountErpIdentity(accountId) { const account = accountList.find((item) => item.id === accountId); if (!account || account.role === 'admin') return; const value = window.prompt('请输入 ERP 页面显示的登录账号。修改后该员工的现有平台会话和云电脑执行连接会失效:', account.erp_account || ''); if (value === null || !value.trim() || value.trim() === account.erp_account) return; await updateManagedAccount(accountId, { erp_account: value.trim().slice(0, 200) }); } async function updateManagedAccount(accountId, patch) { if (!isAdministrator() || accountSettingsBusy) return; accountSettingsBusy = true; renderAccounts(); try { const result = await apiRequest(`/api/accounts/${encodeURIComponent(accountId)}`, { method: 'PATCH', body: patch }); if (result.account) accountList = accountList.map((item) => item.id === accountId ? result.account : item); } finally { accountSettingsBusy = false; renderAccounts(); } } async function resetManagedAccountPassword(accountId) { if (!isAdministrator() || accountSettingsBusy) return; const password = window.prompt('请输入新密码:'); if (password == null) return; if (!password) { const message = $('#accountMessage'); if (message) message.textContent = '密码不能为空。'; return; } accountSettingsBusy = true; renderAccounts(); try { await apiRequest(`/api/accounts/${encodeURIComponent(accountId)}/reset-password`, { method: 'POST', body: { password } }); const message = $('#accountMessage'); if (message) message.textContent = '密码已重置,该账号的现有会话已全部撤销。'; await syncAccounts(); } finally { accountSettingsBusy = false; renderAccounts(); } } async function revokeManagedAccountSessions(accountId) { if (!isAdministrator() || accountSettingsBusy) return; if (!window.confirm('确认撤销该账号的全部登录会话?')) return; accountSettingsBusy = true; renderAccounts(); try { const result = await apiRequest(`/api/accounts/${encodeURIComponent(accountId)}/revoke-sessions`, { method: 'POST', body: {} }); const message = $('#accountMessage'); if (message) message.textContent = `已撤销 ${Number(result.sessions_revoked || 0)} 个会话。`; } finally { accountSettingsBusy = false; renderAccounts(); } } function renderAuditEvents() { const container = $('#auditList'); if (!container) return; container.replaceChildren(); if (!auditEvents.length) { container.append(el('p', 'muted channel-empty', '暂无符合条件的审计记录。')); return; } for (const event of auditEvents) { const row = el('article', 'channel-row'); const main = el('div', 'channel-row-main'); const heading = el('div', 'channel-row-heading'); heading.append(el('strong', '', event.event_type || 'unknown')); heading.append(el('span', 'state state-ok', event.actor?.username || '系统')); main.append(heading); main.append(el('p', 'muted', `${event.entity_type || 'entity'} · ${event.entity_id || '—'} · ${new Date(event.created_at).toLocaleString('zh-CN')}`)); const metadata = el('pre', 'task-lifecycle-console', JSON.stringify(event.metadata || {}, null, 2)); row.append(main, metadata); container.append(row); } } async function syncAuditEvents() { if (!isAdministrator() || auditBusy) return; auditBusy = true; const params = new URLSearchParams({ limit: '100', offset: '0' }); const eventType = String($('#auditEventType')?.value || '').trim(); const entityType = String($('#auditEntityType')?.value || '').trim(); if (eventType) params.set('event_type', eventType); if (entityType) params.set('entity_type', entityType); try { const result = await apiRequest(`/api/audit?${params.toString()}`); auditEvents = Array.isArray(result.events) ? result.events : []; renderAuditEvents(); const message = $('#auditMessage'); if (message) message.textContent = `共 ${Number(result.total || auditEvents.length)} 条,当前显示 ${auditEvents.length} 条。`; } finally { auditBusy = false; } } function operationsDashboardDateValue(date) { const parts = Object.fromEntries(new Intl.DateTimeFormat('en', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(date).map((part) => [part.type, part.value])); return `${parts.year}-${parts.month}-${parts.day}`; } function setOperationsDashboardRange(days = 30) { const normalizedDays = Math.max(1, Math.min(366, Number(days) || 30)); const today = new Date(); const from = new Date(today.getTime() - (normalizedDays - 1) * 24 * 60 * 60 * 1_000); $('#operationsDashboardFrom').value = operationsDashboardDateValue(from); $('#operationsDashboardTo').value = operationsDashboardDateValue(today); for (const button of document.querySelectorAll('[data-operations-range-days]')) { button.classList.toggle('is-active', Number(button.dataset.operationsRangeDays) === normalizedDays); } } function resetOperationsDashboardFilters() { setOperationsDashboardRange(30); $('#operationsDashboardActor').value = ''; $('#operationsDashboardBusiness').value = ''; $('#operationsDashboardStatus').value = 'all'; $('#operationsDashboardSearch').value = ''; operationsDashboardOffset = 0; } function operationsDashboardRange() { const fromValue = String($('#operationsDashboardFrom')?.value || ''); const toValue = String($('#operationsDashboardTo')?.value || ''); const from = new Date(`${fromValue}T00:00:00+08:00`); const toInclusive = new Date(`${toValue}T00:00:00+08:00`); if (!fromValue || !toValue || Number.isNaN(from.getTime()) || Number.isNaN(toInclusive.getTime())) { throw new Error('请选择有效的开始与结束日期。'); } if (toInclusive.getTime() < from.getTime()) throw new Error('结束日期不能早于开始日期。'); const toExclusive = new Date(toInclusive); toExclusive.setDate(toExclusive.getDate() + 1); return { from: from.toISOString(), to: toExclusive.toISOString() }; } function operationsDashboardStatusAppearance(outcomeKind) { if (outcomeKind === 'failed') return 'state-bad'; if (outcomeKind === 'completed') return 'state-ok'; if (outcomeKind === 'active' || outcomeKind === 'attention') return 'state-process'; return 'state-warn'; } function operationsDashboardOutcomeLabel(outcomeKind, fallback = '') { if (outcomeKind === 'completed') return '成功'; if (outcomeKind === 'attention') return '进行中'; if (outcomeKind === 'failed') return '未成功'; if (outcomeKind === 'cancelled') return '已取消'; if (outcomeKind === 'active') return '进行中'; return String(fallback || '进行中'); } function operationsDashboardReadableResult(value, outcomeKind) { const raw = String(value || '').trim().replace(/^待跟进\s*[::]\s*/u, ''); const technicalLanguage = /(?:\b(?:agent|api|dom|frame|iframe|json|native|operation|parser|payload|plugin|projection|requery|schema|stack|trace|write)\b|[a-z][a-z0-9]*(?:_[a-z0-9]+)+|data\.[a-z]|插件|解析服务|技术上下文|错误码|接口|ERP)/i; if (!raw) { if (outcomeKind === 'completed') return '工作已经完成。'; if (outcomeKind === 'attention') return '正在等待补充信息或确认。'; if (outcomeKind === 'failed') return '本次工作未完成,需要检查后重新处理。'; if (outcomeKind === 'cancelled') return '这项工作已取消。'; return '正在处理中。'; } if (!technicalLanguage.test(raw)) return raw; if (/不确定|核验|请勿重复/.test(raw)) return '当前结果需要人工确认,请勿重复提交。'; if (/未找到|不存在|不匹配|mismatch/i.test(raw)) return '未完成:没有找到符合指令条件的业务记录。'; if (/登录|连接|网络|超时/i.test(raw)) return '未完成:业务系统暂时无法完成处理,请稍后再试。'; if (/补充|缺少|必填|日期|年份/.test(raw)) return '需要补充完整的业务信息。'; if (/不支持|超出.*范围/.test(raw)) return '未完成:这项要求暂不在当前业务处理范围内。'; if (outcomeKind === 'completed') return '工作已经完成,结果已记录。'; if (outcomeKind === 'attention') return '正在等待补充信息或确认。'; if (outcomeKind === 'cancelled') return '这项工作已取消。'; return '未完成:当前业务条件未满足,需要检查后重新处理。'; } function operationsDashboardSafeError(error, fallback = '看板暂时无法更新,请稍后再试。') { const message = String(error?.message || '').trim(); if (error?.errorCode === 'operations_dashboard_query_timeout') { return '看板查询耗时过长,请缩短日期范围或增加筛选条件后重试。'; } if (error?.code === 'request_timeout' || /^请求超时/.test(message)) { return '看板请求超时,请稍后重试;若正在筛选,请缩短日期范围或增加筛选条件。'; } if (/^(请选择有效的开始与结束日期|结束日期不能早于开始日期|单次最多查询|关键词查询范围)/.test(message)) { return message; } return fallback; } function operationsDashboardBusinessLabel(value) { const label = String(value || '').trim(); return !label || label === '未识别业务' ? '其他任务' : label; } function operationsDashboardReadableInput(value) { const input = String(value || '').trim(); if (!input) return '未记录输入内容'; const looksLikeTechnicalPayload = /^[\[{]/.test(input) || /(?:"(?:action|operation|payload|schema|submit_mode)"\s*:|[a-z][a-z0-9]*(?:_[a-z0-9]+){2,}|data\.[a-z])/i.test(input); return looksLikeTechnicalPayload ? '该历史任务没有可展示的业务指令。' : input; } function renderOperationsDashboardActorOptions(actors) { const select = $('#operationsDashboardActor'); if (!select) return; const selected = select.value; select.replaceChildren(new Option('全部员工', '')); for (const actor of actors || []) { const state = actor.is_active ? '' : ' · 已停用'; select.append(new Option(`${actor.username} · ${accountRoleLabel(actor.role)}${state}`, actor.id)); } if ([...select.options].some((option) => option.value === selected)) select.value = selected; } function renderOperationsDashboardBusinessOptions(options) { const select = $('#operationsDashboardBusiness'); if (!select) return; const selected = select.value; select.replaceChildren(new Option('全部类型', '')); for (const option of options || []) { select.append(new Option(operationsDashboardBusinessLabel(option.label || option.key), option.key)); } if ([...select.options].some((option) => option.value === selected)) select.value = selected; } function renderOperationsDashboardSummary() { const container = $('#operationsDashboardSummary'); if (!container) return; container.replaceChildren(); const summary = operationsDashboardData?.summary || {}; const cards = [ { label: '操作次数', value: summary.total || 0, hint: '全部任务操作', tone: 'total' }, { label: '成功次数', value: summary.completed || 0, hint: '已成功完成', tone: 'success' }, { label: '进行中', value: Number(summary.active || 0) + Number(summary.attention || 0), hint: '正在处理', tone: 'active' }, { label: '未成功', value: summary.failed || 0, hint: '需要继续处理', tone: 'failed' }, { label: '操作员工', value: summary.user_count || 0, hint: '参与操作人数', tone: 'people' } ]; for (const item of cards) { const card = el('article', `operations-dashboard-stat operations-dashboard-stat-${item.tone}`); card.append(el('span', '', item.label)); card.append(el('strong', '', String(item.value))); card.append(el('small', '', item.hint)); container.append(card); } } function operationsDashboardRankScale(maximum) { const numericMaximum = Number(maximum || 0); const safeMaximum = Number.isFinite(numericMaximum) ? Math.max(0, numericMaximum) : 0; if (safeMaximum <= 0) return 1; const roughStep = safeMaximum / 5; const magnitude = 10 ** Math.floor(Math.log10(Math.max(1, roughStep))); const normalizedStep = roughStep / magnitude; const niceStep = Math.max( 1, (normalizedStep <= 1 ? 1 : normalizedStep <= 2 ? 2 : normalizedStep <= 5 ? 5 : 10) * magnitude ); const roundedMaximum = Math.ceil(safeMaximum / niceStep) * niceStep; return roundedMaximum <= safeMaximum ? roundedMaximum + niceStep : roundedMaximum; } function operationsDashboardRankBar(total, scaleMaximum) { const safeMaximum = Math.max(1, Number(scaleMaximum || 0)); const safeTotal = Math.max(0, Number(total || 0)); const bar = el('span', 'operations-dashboard-rank-bar'); bar.setAttribute('aria-hidden', 'true'); if (safeTotal > 0) { bar.style.width = `${Math.min(100, (safeTotal / safeMaximum) * 100)}%`; } else { bar.hidden = true; } return bar; } function renderOperationsDashboardBusinesses() { const container = $('#operationsDashboardBusinesses'); if (!container) return; container.replaceChildren(); const businesses = [...(operationsDashboardData?.businesses || [])] .sort((left, right) => Number(right.total || 0) - Number(left.total || 0)); if (!businesses.length) { container.append(el('p', 'operations-dashboard-empty', '当前范围内还没有任务类型数据。')); return; } const scaleMaximum = operationsDashboardRankScale( Math.max(...businesses.map((item) => Number(item.total || 0)), 0) ); for (const [index, item] of businesses.entries()) { const button = el('button', 'operations-dashboard-business-row'); button.type = 'button'; button.dataset.operationsBusiness = item.key; button.setAttribute('aria-label', `${operationsDashboardBusinessLabel(item.label)},操作 ${item.total} 次,成功 ${item.completed} 次`); button.append( el('span', 'operations-dashboard-rank-number', String(index + 1).padStart(2, '0')), el('strong', 'operations-dashboard-rank-name', operationsDashboardBusinessLabel(item.label)) ); button.prepend(operationsDashboardRankBar(item.total, scaleMaximum)); const metrics = el('span', 'operations-dashboard-rank-metrics'); metrics.append( el('span', 'is-total', `操作 ${Number(item.total || 0)}`), el('span', 'is-success', `成功 ${Number(item.completed || 0)}`) ); button.append(metrics); container.append(button); } } function renderOperationsDashboardBreakdowns() { renderOperationsDashboardBusinesses(); } function renderOperationsDashboardUsers() { const container = $('#operationsDashboardUsers'); if (!container) return; container.replaceChildren(); const users = [...(operationsDashboardData?.users || [])] .sort((left, right) => Number(right.total || 0) - Number(left.total || 0)); $('#operationsDashboardUserCount').textContent = `${users.length} 名员工`; if (!users.length) { container.append(el('p', 'operations-dashboard-empty', '当前范围内还没有人员工作数据。')); return; } const scaleMaximum = operationsDashboardRankScale( Math.max(...users.map((item) => Number(item.total || 0)), 0) ); for (const [index, item] of users.entries()) { const button = el('button', 'operations-dashboard-user-row'); button.type = 'button'; button.dataset.operationsActorId = item.actor.id; button.setAttribute('aria-label', `${item.actor.username},操作 ${item.total} 次,成功 ${item.completed} 次`); button.append(el('span', 'operations-dashboard-rank-number', String(index + 1).padStart(2, '0'))); const identity = el('div', 'operations-dashboard-user-identity'); identity.append( el('span', 'operations-dashboard-avatar', String(item.actor.username || '人').slice(0, 1).toUpperCase()), el('span', 'operations-dashboard-user-name') ); identity.lastElementChild.append( el('strong', '', item.actor.username), el('small', '', accountRoleLabel(item.actor.role)) ); const bar = operationsDashboardRankBar(item.total, scaleMaximum); const metrics = el('div', 'operations-dashboard-user-metrics'); metrics.append( el('span', 'is-total', `操作 ${Number(item.total || 0)}`), el('span', 'is-success', `成功 ${Number(item.completed || 0)}`) ); button.prepend(bar); button.append(identity, metrics); container.append(button); } } function renderOperationsDashboardTasks() { const container = $('#operationsDashboardTasks'); if (!container) return; container.replaceChildren(); const tasks = operationsDashboardData?.tasks || []; const total = Number(operationsDashboardData?.total || 0); $('#operationsDashboardTaskCount').textContent = `共 ${total} 条任务操作`; if (!tasks.length) { const emptyRow = el('tr', 'operations-dashboard-table-empty'); const emptyCell = el('td', ''); emptyCell.colSpan = 6; emptyCell.append(el('p', 'operations-dashboard-empty', '当前筛选范围内没有任务操作记录。')); emptyRow.append(emptyCell); container.append(emptyRow); } for (const task of tasks) { const row = el('tr', `operations-dashboard-table-row${operationsDashboardSelectedTaskId === task.task_id ? ' is-selected' : ''}`); row.dataset.operationsTaskId = task.task_id; row.tabIndex = 0; row.setAttribute('role', 'button'); row.setAttribute('aria-label', `查看 ${task.creator.username} 的${operationsDashboardBusinessLabel(task.business_route_label)}详情`); const timeCell = el('td', 'operations-dashboard-table-time'); timeCell.dataset.label = '操作时间'; timeCell.append(el('time', '', formatDateTime(task.created_at))); const actorCell = el('td', 'operations-dashboard-table-actor'); actorCell.dataset.label = '员工名称'; const actor = el('span', 'operations-dashboard-task-person'); actor.append( el('span', 'operations-dashboard-avatar', String(task.creator.username || '人').slice(0, 1).toUpperCase()), el('strong', '', task.creator.username) ); actorCell.append(actor); const typeCell = el('td', 'operations-dashboard-table-type', operationsDashboardBusinessLabel(task.business_route_label)); typeCell.dataset.label = '任务类型'; const inputCell = el('td', 'operations-dashboard-table-copy'); inputCell.dataset.label = '原始输入'; inputCell.append(el('span', '', operationsDashboardReadableInput(task.instruction_preview))); if (Number(task.input_turn_count || 0) > 1) { inputCell.append(el('small', '', `${task.input_turn_count} 次沟通`)); } const outputCell = el('td', 'operations-dashboard-table-copy is-output'); outputCell.dataset.label = '最终输出'; outputCell.append(el('span', '', operationsDashboardReadableResult(task.result_summary, task.outcome_kind))); const statusCell = el('td', 'operations-dashboard-table-status'); statusCell.dataset.label = '任务结果'; statusCell.append(el('span', `state ${operationsDashboardStatusAppearance(task.outcome_kind)}`, operationsDashboardOutcomeLabel(task.outcome_kind, task.outcome_label))); row.append(timeCell, actorCell, typeCell, inputCell, outputCell, statusCell); container.append(row); } const start = total ? operationsDashboardOffset + 1 : 0; const end = operationsDashboardOffset + tasks.length; $('#operationsDashboardPageInfo').textContent = total ? `${start}–${end} / ${total}` : '0 条'; $('#operationsDashboardPrevious').disabled = operationsDashboardBusy || operationsDashboardOffset <= 0; $('#operationsDashboardNext').disabled = operationsDashboardBusy || !operationsDashboardData?.has_more; } function renderOperationsDashboard() { renderOperationsDashboardSummary(); renderOperationsDashboardBreakdowns(); renderOperationsDashboardUsers(); renderOperationsDashboardTasks(); } function appendOperationsDashboardDetailSection(container, title, content) { const section = el('section', 'operations-dashboard-detail-section'); section.append(el('h3', '', title)); section.append(content); container.append(section); } function renderOperationsDashboardDetail() { const panel = $('#operationsDashboardDetail'); const container = $('#operationsDashboardDetailBody'); if (!panel || !container) return; container.replaceChildren(); const detail = operationsDashboardDetail; if (!detail?.task_id) { panel.hidden = true; document.body.classList.remove('operations-dashboard-detail-open'); return; } panel.hidden = false; document.body.classList.add('operations-dashboard-detail-open'); $('#operationsDashboardDetailTitle').textContent = `${detail.creator?.username || '人员'} · ${operationsDashboardBusinessLabel(detail.business_route_label)}`; const facts = el('div', 'operations-dashboard-detail-facts'); appendField(facts, '人员', detail.creator?.username || '未记录'); appendField(facts, '任务类型', operationsDashboardBusinessLabel(detail.business_route_label)); appendField(facts, '开始时间', formatDateTime(detail.created_at)); appendField(facts, '完成情况', operationsDashboardOutcomeLabel(detail.outcome_kind, detail.outcome_label)); appendOperationsDashboardDetailSection(container, '工作概况', facts); const inputs = el('div', 'operations-dashboard-input-history'); for (const message of detail.instructions || []) { const item = el('article', 'operations-dashboard-input-item'); const label = Number(message.turn_no) <= 1 ? '首次输入' : `第 ${message.turn_no} 次补充`; item.append(el('strong', '', `${label} · ${message.actor?.username || detail.creator?.username || '人员'}`)); item.append(el('span', '', formatDateTime(message.created_at))); item.append(el('p', '', operationsDashboardReadableInput(message.content))); inputs.append(item); } if (!inputs.childElementCount) inputs.append(el('p', 'operations-dashboard-empty', '没有可显示的输入内容。')); appendOperationsDashboardDetailSection(container, '原始输入', inputs); const attachments = el('div', 'operations-dashboard-attachments'); for (const attachment of detail.attachments || []) { const item = el('article', 'operations-dashboard-attachment-row'); item.append(el('strong', '', attachment.file_name || '未命名附件')); item.append(el('span', '', `${attachment.actor?.username || detail.creator?.username || '人员'} · ${formatDateTime(attachment.created_at)}${attachment.row_count == null ? '' : ` · ${attachment.row_count} 行数据`}`)); attachments.append(item); } if (attachments.childElementCount) { appendOperationsDashboardDetailSection(container, '随任务提交的资料', attachments); } appendOperationsDashboardDetailSection( container, '最终输出', el('p', 'operations-dashboard-result-detail', operationsDashboardReadableResult(detail.result_summary, detail.outcome_kind)) ); } async function syncOperationsDashboard() { if (!canViewOperationsDashboard() || operationsDashboardBusy) return; operationsDashboardBusy = true; renderOperationsDashboardTasks(); const message = $('#operationsDashboardMessage'); const refreshButton = $('#operationsDashboardRefresh'); const filterForm = $('#operationsDashboardFilters'); const queryButton = filterForm?.querySelector('button[type="submit"]'); if (filterForm) filterForm.setAttribute('aria-busy', 'true'); if (queryButton) queryButton.disabled = true; if (refreshButton) refreshButton.disabled = true; if (message) message.textContent = '正在汇总平台运行数据…'; try { const range = operationsDashboardRange(); const params = new URLSearchParams({ from: range.from, to: range.to, status: String($('#operationsDashboardStatus')?.value || 'all'), limit: String(OPERATIONS_DASHBOARD_PAGE_SIZE), offset: String(operationsDashboardOffset) }); const actorUserId = String($('#operationsDashboardActor')?.value || ''); const businessRouteId = String($('#operationsDashboardBusiness')?.value || ''); const search = String($('#operationsDashboardSearch')?.value || '').trim(); if (actorUserId) params.set('actor_user_id', actorUserId); if (businessRouteId) params.set('business_route_id', businessRouteId); if (search) params.set('search', search); const result = await apiRequest(`/api/operations-dashboard?${params.toString()}`); operationsDashboardData = result; renderOperationsDashboardActorOptions(result.actors || []); renderOperationsDashboardBusinessOptions(result.business_options || []); renderOperationsDashboard(); const freshness = $('#operationsDashboardFreshness'); if (freshness) freshness.textContent = `更新于 ${new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' }).format(new Date())}`; if (message) message.textContent = ''; } finally { operationsDashboardBusy = false; if (filterForm) filterForm.removeAttribute('aria-busy'); if (queryButton) queryButton.disabled = false; if (refreshButton) refreshButton.disabled = false; renderOperationsDashboardTasks(); } } async function loadOperationsDashboardTask(taskId) { if (!canViewOperationsDashboard() || !taskId) return; operationsDashboardSelectedTaskId = taskId; operationsDashboardDetail = null; renderOperationsDashboardTasks(); const panel = $('#operationsDashboardDetail'); const container = $('#operationsDashboardDetailBody'); panel.hidden = false; document.body.classList.add('operations-dashboard-detail-open'); container.replaceChildren(el('p', 'operations-dashboard-empty', '正在打开工作详情…')); $('#operationsDashboardDetailTitle').textContent = '一次工作的完整过程'; const result = await apiRequest(`/api/operations-dashboard/tasks/${encodeURIComponent(taskId)}`); if (operationsDashboardSelectedTaskId !== taskId) return; operationsDashboardDetail = result; renderOperationsDashboardDetail(); } function closeOperationsDashboardDetail() { operationsDashboardSelectedTaskId = ''; operationsDashboardDetail = null; $('#operationsDashboardDetail').hidden = true; document.body.classList.remove('operations-dashboard-detail-open'); renderOperationsDashboardTasks(); } function refreshOperationsDashboardAfterDrilldown() { operationsDashboardOffset = 0; closeOperationsDashboardDetail(); void syncOperationsDashboard().catch((error) => { $('#operationsDashboardMessage').textContent = operationsDashboardSafeError(error); }); } async function refreshCsrfToken() { const result = await apiRequest('/api/auth/csrf'); csrfToken = result.csrf_token || ''; if (!csrfToken) throw new Error('服务端未返回 CSRF 会话令牌。'); } async function syncRemoteTasks() { if (!canUseTaskDataPlane()) return; if (remoteSyncInProgress) { syncRequested = true; return; } remoteSyncInProgress = true; try { do { syncRequested = false; const params = new URLSearchParams({ limit: String(IS_HISTORY_PAGE ? HISTORY_TASK_PAGE_SIZE : HOME_TASK_LIMIT), offset: String(IS_HISTORY_PAGE ? historyOffset : 0) }); if (IS_HISTORY_PAGE && historySearch) params.set('search', historySearch); if (IS_HISTORY_PAGE && historyStatus) params.set('status', historyStatus); if (IS_HISTORY_PAGE) params.set('archive', historyArchive); const result = await apiRequest(`/api/tasks?${params.toString()}`); // Keep the server snapshot authoritative, but reject older event/timestamp // versions and preserve a short-lived local overlay until the result is // durably visible on the server. taskStore = (Array.isArray(result.tasks) ? result.tasks : []) .map(mergeRemoteTask) .filter((task) => ( (historyArchive === 'archived' || ( task?.status !== 'cancelled' && task?.result?.status !== 'cancelled' )) && !locallyDeletedTaskIds.has(task?.task_id) )); const pagination = result.pagination || {}; taskListMeta = { total: Number.isFinite(Number(pagination.total)) ? Number(pagination.total) : taskStore.length, offset: Number.isFinite(Number(pagination.offset)) ? Number(pagination.offset) : (IS_HISTORY_PAGE ? historyOffset : 0), limit: Number.isFinite(Number(pagination.limit)) ? Number(pagination.limit) : (IS_HISTORY_PAGE ? HISTORY_TASK_PAGE_SIZE : HOME_TASK_LIMIT), has_more: pagination.has_more === true }; if (IS_HISTORY_PAGE && !taskStore.length && historyOffset > 0) { historyOffset = Math.max(0, historyOffset - HISTORY_TASK_PAGE_SIZE); syncRequested = true; continue; } for (const task of taskStore) cacheRuntimeTask(task); if (!taskStore.some((task) => task.task_id === currentTaskId) && !(currentTaskId && runtimeTaskStore.has(currentTaskId) && !IS_HISTORY_PAGE)) { currentTaskId = taskStore[0]?.task_id || ''; } if (currentTaskId) sessionStorage.setItem('liansyn_platform_current_task_id', currentTaskId); else sessionStorage.removeItem('liansyn_platform_current_task_id'); renderTaskCards(); } while (syncRequested && canUseTaskDataPlane()); await syncRuntimeTasks(); await autoDispatchReadyTasks(); } finally { remoteSyncInProgress = false; if (syncRequested && canUseTaskDataPlane()) { syncRequested = false; queueMicrotask(() => syncRemoteTasks().catch((error) => { setOutput({ status: 'sync_error', message: error.message }); })); } } } function startRemoteEventStream() { if (eventStream) eventStream.close(); eventStream = null; if (!canUseTaskDataPlane()) return; const lastEventId = runtimeTasks().filter(taskAssignedToCurrentAccount).reduce((highest, task) => ( Math.max(highest, Number(task?.last_event_id || 0)) ), 0); eventStream = new EventSource(`/api/events?since=${encodeURIComponent(lastEventId)}&executable_by=me`); eventStream.addEventListener('open', () => { syncAutomationSettings({ background: true }).catch(() => {}); }); eventStream.addEventListener('task', () => { syncRemoteTasks().catch((error) => { setOutput({ status: 'sync_error', message: error.message }); }); }); eventStream.addEventListener('browser-command', (event) => { handleBrowserCommand(event).catch(() => {}); }); eventStream.onerror = () => { // EventSource reconnects automatically; polling remains the fallback authority refresh. }; } function cacheRuntimeTask(task) { if (!task?.task_id) return; if (isTaskPollable(task) || (isAutomaticTask(task) && canStartConfirmedTask(task))) { runtimeTaskStore.set(task.task_id, task); } else { runtimeTaskStore.delete(task.task_id); } } function runtimeTasks() { const merged = new Map(); for (const task of taskStore) { if (task?.task_id) merged.set(task.task_id, task); } for (const task of runtimeTaskStore.values()) { if (!task?.task_id) continue; const existing = merged.get(task.task_id); if (!existing || compareTaskVersion(task, existing) >= 0) merged.set(task.task_id, task); } for (const [taskId, overlay] of localTaskOverlayStore.entries()) { if (Date.now() - overlay.at > LOCAL_TASK_OVERLAY_TTL_MS) { localTaskOverlayStore.delete(taskId); continue; } const existing = merged.get(taskId); merged.set(taskId, existing ? { ...existing, ...overlay.task, __server_status: existing.__server_status || taskServerStatus(existing) } : overlay.task); } return [...merged.values()]; } function hasPollableRuntimeTasks() { return runtimeTasks().some((task) => taskAssignedToCurrentAccount(task) && isTaskPollable(task)); } async function syncRuntimeTasks() { if (!canUseTaskDataPlane()) return; const result = await apiRequest('/api/tasks?status=active&limit=200&include_total=false&executable_by=me'); const activeTasks = (Array.isArray(result.tasks) ? result.tasks : []) .map(mergeRemoteTask) .filter((task) => !locallyDeletedTaskIds.has(task?.task_id)); const activeIds = new Set(activeTasks.map((task) => task?.task_id).filter(Boolean)); for (const [taskId, task] of runtimeTaskStore.entries()) { if (isTaskPollable(task) && !activeIds.has(taskId)) runtimeTaskStore.delete(taskId); } for (const task of activeTasks) cacheRuntimeTask(task); } 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 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: '散拼-录入子单', shared_child_order_batch_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: '', identifier: data.existing_refs?.identifier || data.existing_refs?.order_no || '', customer: data.customer?.name || '', product: data.product?.name || '', leader: data.leader?.name || '', departure_date: data.departure_dates?.[0] || '', 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 taskServerStatus(task) { return String(task?.__server_status || task?.status || task?.result?.status || 'created'); } function compareTaskVersion(left, right) { const leftEvent = Number(left?.last_event_id || 0); const rightEvent = Number(right?.last_event_id || 0); if (leftEvent !== rightEvent) return leftEvent - rightEvent; const leftTime = Date.parse(left?.updated_at || '') || 0; const rightTime = Date.parse(right?.updated_at || '') || 0; return leftTime - rightTime; } function isDetailedTask(task) { return Boolean(task && Array.isArray(task.events) && Object.prototype.hasOwnProperty.call(task, 'operation')); } function findTaskById(taskId) { return taskStore.find((item) => item.task_id === taskId) || localTaskOverlayStore.get(taskId)?.task || runtimeTaskStore.get(taskId) || null; } function rememberRemoteTask(task) { if (!task?.task_id) return task; const previous = remoteTaskStore.get(task.task_id); if (previous && compareTaskVersion(previous, task) > 0) return previous; const normalized = { ...task, __server_status: task.status }; remoteTaskStore.set(task.task_id, normalized); return normalized; } function mergeRemoteTask(task) { if (!task?.task_id) return task; const remoteTask = rememberRemoteTask(task); const detail = taskDetailStore.get(task.task_id); let merged = remoteTask; if (detail && compareTaskVersion(detail.task, remoteTask) === 0) { merged = { ...remoteTask, ...detail.task, __server_status: remoteTask.status }; } else if (detail && compareTaskVersion(detail.task, remoteTask) < 0) { taskDetailStore.delete(task.task_id); } const overlay = localTaskOverlayStore.get(task.task_id); if (!overlay || Date.now() - overlay.at > LOCAL_TASK_OVERLAY_TTL_MS) { if (overlay) localTaskOverlayStore.delete(task.task_id); return merged; } if (TERMINAL_TASK_STATUSES.has(String(remoteTask.status || ''))) { localTaskOverlayStore.delete(task.task_id); return merged; } return { ...merged, ...overlay.task, __server_status: remoteTask.status }; } function cacheTaskDetails(task) { if (!isDetailedTask(task)) return; taskDetailStore.set(task.task_id, { task }); } async function ensureTaskDetails(taskId, { force = false } = {}) { const current = findTaskById(taskId); const cached = taskDetailStore.get(taskId); if (!force && cached && (!current || compareTaskVersion(cached.task, current) >= 0)) return cached.task; const existingRequest = taskDetailRequests.get(taskId); if (existingRequest) return existingRequest; const request = apiRequest(`/api/tasks/${encodeURIComponent(taskId)}`, { timeoutMs: RESULT_API_TIMEOUT_MS }) .then((result) => { if (!result.task) throw new Error('服务端未返回任务详情。'); const task = { ...result.task, __server_status: result.task.status }; cacheTaskDetails(task); upsertLocalTask(task, { forceVisible: false, source: 'server' }); return task; }) .finally(() => taskDetailRequests.delete(taskId)); taskDetailRequests.set(taskId, request); return request; } async function ensureTaskInputHistory(taskId, { force = false } = {}) { if (!force && taskInputHistoryStore.has(taskId)) return taskInputHistoryStore.get(taskId); const existingRequest = taskInputHistoryRequests.get(taskId); if (existingRequest) return existingRequest; const request = apiRequest(`/api/tasks/${encodeURIComponent(taskId)}/input-history`, { timeoutMs: RESULT_API_TIMEOUT_MS }) .then((result) => { const history = { messages: Array.isArray(result.messages) ? result.messages : [], attachments: Array.isArray(result.attachments) ? result.attachments : [] }; taskInputHistoryStore.set(taskId, history); if (currentTaskId === taskId) renderTaskDetail(); return history; }) .finally(() => taskInputHistoryRequests.delete(taskId)); taskInputHistoryRequests.set(taskId, request); return request; } function loadSelectedTaskDetails(task) { if (!authUser || !task) return; if (!isDetailedTask(task)) { const lifecycle = $('#taskLifecycle'); if (lifecycle) lifecycle.textContent = '正在加载任务详情…'; void ensureTaskDetails(task.task_id).catch((error) => { if (lifecycle?.isConnected) lifecycle.textContent = `任务详情加载失败:${error.message || String(error)}`; }); } void ensureTaskInputHistory(task.task_id).catch(() => {}); } function upsertLocalTask(task, { forceVisible = true, source = 'server' } = {}) { if (!task?.task_id) return; if (locallyDeletedTaskIds.has(task.task_id)) return; if (!isAwaitingAttachment(task)) taskAttachmentDrafts.delete(task.task_id); const normalizedTask = source === 'server' ? rememberRemoteTask(task) : task; if (source === 'server') { cacheTaskDetails(normalizedTask); if (TERMINAL_TASK_STATUSES.has(String(normalizedTask.status || ''))) { localTaskOverlayStore.delete(normalizedTask.task_id); } } else { localTaskOverlayStore.set(normalizedTask.task_id, { task: { ...(localTaskOverlayStore.get(normalizedTask.task_id)?.task || {}), ...normalizedTask }, at: Date.now() }); } const index = taskStore.findIndex((item) => item.task_id === normalizedTask.task_id); let nextTask; if (index >= 0) { const existing = taskStore[index]; nextTask = { ...existing, ...normalizedTask }; taskStore[index] = nextTask; } else if (forceVisible && !runtimeTaskStore.has(normalizedTask.task_id)) { nextTask = { ...normalizedTask }; taskStore.unshift(nextTask); } else { nextTask = { ...(runtimeTaskStore.get(normalizedTask.task_id) || {}), ...normalizedTask }; } cacheRuntimeTask(nextTask); saveTaskStore(); renderTaskCards(); } function updateLocalTask(taskId, patch) { const visibleIndex = taskStore.findIndex((item) => item.task_id === taskId); const existing = visibleIndex >= 0 ? taskStore[visibleIndex] : findTaskById(taskId); if (!existing) return; const updated = { ...existing, ...patch }; localTaskOverlayStore.set(taskId, { task: updated, at: Date.now() }); if (visibleIndex >= 0) taskStore[visibleIndex] = updated; cacheRuntimeTask(updated); saveTaskStore(); renderTaskCards(); } function selectedTask() { return findTaskById(currentTaskId) || (!IS_HISTORY_PAGE && runtimeTaskStore.get(currentTaskId)) || taskStore[0] || null; } function taskFailureInfo(task) { const status = String(task?.status || task?.result?.status || '').toLowerCase(); if (!isFailureStatus(status)) return null; if (task?.failure && typeof task.failure === 'object') return task.failure; const parseResponse = task?.parse_response; if (parseResponse?.failure && typeof parseResponse.failure === 'object') return parseResponse.failure; if (parseResponse?.failure_stage || parseResponse?.failure_message || parseResponse?.failure_source) { return parseResponse; } return null; } function failureStageLabel(stage) { const labels = { configuration: '服务配置', external_session_creation: 'Agent 会话创建', external_parse_request: 'Agent 请求', external_session_recovery: 'Agent 会话恢复', agent_response_parsing: 'Agent 返回解析', agent_result_validation: 'Agent 结果校验', operation_contract_validation: 'operation 契约校验', parse_worker_lease: '解析 Worker 租约', plugin_handoff: '插件交接', handoff: '插件交接', preflight: 'ERP预检', live_submit: 'ERP保存', browser_execution: '插件浏览器执行', erp_execution: 'ERP 写入', receipt_reconciliation: 'ERP 回执回查', erp_resolution: 'ERP 对象检索' }; return labels[String(stage || '')] || String(stage || '任务处理'); } function taskFailureText(task) { const failure = taskFailureInfo(task); if (!failure) return ''; const stage = failureStageLabel(failure.failure_stage || failure.stage); const message = failure.failure_message || failure.message || failure.error_code || '任务失败。'; return `失败:${stage} · ${message}`; } function taskStatusText(task) { if (!task) return '未知'; const status = task.status || task.result?.status || 'created'; if (requiresManualConfirmation(task)) return '系统解析完成,待确认并提交 ERP 插件'; const failureText = taskFailureText(task); if (failureText && isFailureStatus(status)) return failureText; const stage = task.result?.stage || task.stage || ''; if (status === 'running' && stage === 'parse') return '发送中 / 解析中'; if (status === 'parse_queued') return '排队解析'; if (status === 'parse_running') return '解析中'; if (status === 'awaiting_attachment') return '等待名单附件'; if (status === 'awaiting_user_input' || status === 'agent_parse_needs_input') 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' && task.confirmation_mode === 'automatic') return '全自动化已接管,等待插件执行'; if (status === 'confirmed') return '已确认,待提交到 ERP 插件'; if (status === 'queued') return '排队中'; if (status === 'paused') return '已暂停'; if (status === 'awaiting_confirmation') return '等待确认并提交 ERP 插件'; 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 taskStatusLabel(task) { if (!task) return '未知'; const status = taskCanonicalStatus(task); const stage = task.result?.stage || task.stage || ''; if (requiresManualConfirmation(task)) return '待确认'; if (['agent_parse_blocked', 'parse_failed', 'parse_blocked'].includes(status)) return '失败'; if (isFailureStatus(status)) return '失败'; if (status === 'running' && stage === 'parse') return '解析中'; if (status === 'confirmed' && task.confirmation_mode === 'automatic') return '自动执行中'; const labels = { created: '待开始', parse_queued: '排队解析', parse_running: '解析中', awaiting_attachment: '等待名单附件', awaiting_user_input: '等待补充', agent_parse_needs_input: '等待补充', agent_parse_passed: '解析完成', parse: '解析中', completed: '已完成', running: '执行中', accepted: '已接单', confirmed: '已确认', queued: '排队中', paused: '已暂停', awaiting_confirmation: '待确认', waiting_extension: '等待插件', dry_run: '已规划', operation_blocked: '已阻断', execution_uncertain: '待人工核验', reconciliation_pending: '待人工核验', saved_unverified: '待人工核验', cancelled: '已取消', post_save_recovery_required: '待恢复' }; return labels[status] || 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?.task_id || task.confirmed_at || task.handoff_status === 'accepted') return false; const status = task.status || task.result?.status || 'created'; return ['awaiting_confirmation', 'agent_parse_passed'].includes(status); } function taskAssignedToCurrentAccount(task) { return Boolean(canUseTaskDataPlane() && authUser?.id && task?.assignee?.id === authUser.id); } function extensionTaskBelongsToCurrentAccount(taskId) { const task = findTaskById(String(taskId || '').trim()); return Boolean(task && taskAssignedToCurrentAccount(task)); } function taskCanonicalStatus(task) { return String(task?.status || task?.result?.status || 'created'); } function isAwaitingUserInput(task) { return task?.status === 'awaiting_user_input'; } function isAwaitingAttachment(task) { return task?.status === 'awaiting_attachment'; } async function sendTaskReply(task, textarea, button, statusNode) { const taskId = task?.task_id; if (!taskId || !isAwaitingUserInput(task) || taskReplyStates.get(taskId) === 'sending') return; const message = normalizeText(textarea?.value); if (!message) { if (statusNode) statusNode.textContent = '请先填写补充信息。'; textarea?.focus(); return; } taskReplyStates.set(taskId, 'sending'); if (button) button.disabled = true; if (statusNode) statusNode.textContent = '正在提交补充信息…'; try { const result = await apiRequest('/api/messages', { method: 'POST', body: { message, task_id: taskId, conversation_id: task.conversation_id || undefined, idempotency_key: `manual:${taskId}:${makeRequestId()}` } }); taskReplyStates.delete(taskId); taskReplyDrafts.delete(taskId); if (result.task) { currentTaskId = result.task.task_id; sessionStorage.setItem('liansyn_platform_current_task_id', currentTaskId); upsertLocalTask(result.task); } else { await syncRemoteTasks(); } } catch (error) { taskReplyStates.delete(taskId); if (button?.isConnected) button.disabled = false; if (statusNode?.isConnected) statusNode.textContent = error.message || String(error); } } async function sendTaskAttachment(task, fileInput, button, statusNode) { const taskId = task?.task_id; if (!taskId || !isAwaitingAttachment(task) || taskReplyStates.get(taskId) === 'sending') return; const file = fileInput?.files?.[0] || taskAttachmentDrafts.get(taskId); try { validateRosterAttachmentFile(file); } catch (error) { if (statusNode) statusNode.textContent = error.message || String(error); fileInput?.focus(); return; } taskAttachmentDrafts.set(taskId, file); taskReplyStates.set(taskId, 'sending'); if (button) button.disabled = true; if (statusNode) statusNode.textContent = '正在读取并提交名单附件…'; try { const attachment = await encodeRosterAttachment(file); const result = await apiRequest('/api/messages', { method: 'POST', timeoutMs: TASK_DELETE_API_TIMEOUT_MS, body: { message: '', attachments: [attachment], task_id: taskId, conversation_id: task.conversation_id || undefined, idempotency_key: `manual-roster:${taskId}:${makeRequestId()}` } }); taskReplyStates.delete(taskId); taskAttachmentDrafts.delete(taskId); if (fileInput?.isConnected) fileInput.value = ''; if (statusNode?.isConnected) { statusNode.textContent = result.input_attachment?.message || '名单附件已提交,等待程序解析。'; } if (result.task) { currentTaskId = result.task.task_id; sessionStorage.setItem('liansyn_platform_current_task_id', currentTaskId); upsertLocalTask(result.task); } else { await syncRemoteTasks(); } } catch (error) { taskReplyStates.delete(taskId); if (button?.isConnected) button.disabled = false; if (statusNode?.isConnected) statusNode.textContent = error.message || String(error); } } function taskImportantMessage(task) { if (task?.important_message) return task.important_message; const status = String(task?.status || task?.result?.status || '').toLowerCase(); if (status === 'awaiting_attachment') { return { kind: 'awaiting_attachment', text: task?.message || '请发送一份 .xls 或 .xlsx 名单附件。', recorded_at: task?.updated_at || '', input_request: task?.input_request || undefined }; } if (status === 'awaiting_user_input') { const reply = String(task?.parse_response?.reply || task?.result?.reply || '').trim(); return reply ? { kind: 'awaiting_user_input', text: reply, recorded_at: task?.updated_at || '', input_request: task?.input_request || undefined } : null; } if (task?.error_summary && isFailureStatus(status)) { return { kind: 'error', text: task.error_summary.message || '任务处理失败。', recorded_at: task.error_summary.recorded_at || task.updated_at || '', error_summary: task.error_summary }; } if (status === 'completed' && task?.success_receipt) { const receipt = task.success_receipt; return { kind: 'success', text: task.message || '任务已完成。', recorded_at: receipt.recorded_at || task.updated_at || '', success_receipt: receipt }; } return null; } function renderTaskReplyForm(task) { const form = el('form', 'task-input-request-form'); form.noValidate = true; const textarea = el('textarea', 'task-reply-input'); textarea.id = 'taskReplyInput'; textarea.name = 'message'; textarea.rows = 4; textarea.maxLength = 200_000; textarea.placeholder = '请输入补充信息,提交后将沿用当前任务的解析模式和会话。'; textarea.value = taskReplyDrafts.get(task.task_id) || ''; textarea.setAttribute('aria-label', '补充当前任务的信息'); textarea.addEventListener('input', () => { taskReplyDrafts.set(task.task_id, textarea.value); }); form.append(textarea); const actions = el('div', 'task-input-request-actions'); const button = el('button', '', taskReplyStates.get(task.task_id) === 'sending' ? '提交中…' : '提交补充信息'); button.id = 'sendTaskReplyButton'; button.type = 'submit'; button.disabled = taskReplyStates.get(task.task_id) === 'sending'; const statusNode = el('span', 'task-input-request-status'); statusNode.id = 'taskReplyStatus'; statusNode.setAttribute('role', 'status'); actions.append(button, statusNode); form.append(actions); form.addEventListener('submit', (event) => { event.preventDefault(); void sendTaskReply(task, textarea, button, statusNode); }); return form; } function renderTaskAttachmentForm(task) { const form = el('form', 'task-input-request-form'); form.noValidate = true; const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.accept = '.xls,.xlsx,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; fileInput.setAttribute('aria-label', '补充当前名单任务的 Excel 附件'); const statusNode = el('span', 'task-input-request-status'); statusNode.setAttribute('role', 'status'); const selectedFile = taskAttachmentDrafts.get(task.task_id); if (selectedFile) statusNode.textContent = `已选择:${selectedFile.name}(${rosterAttachmentSizeText(selectedFile)})`; fileInput.addEventListener('change', () => { const file = fileInput.files?.[0]; if (!file) { taskAttachmentDrafts.delete(task.task_id); statusNode.textContent = ''; return; } try { validateRosterAttachmentFile(file); taskAttachmentDrafts.set(task.task_id, file); statusNode.textContent = `已选择:${file.name}(${rosterAttachmentSizeText(file)})`; } catch (error) { taskAttachmentDrafts.delete(task.task_id); fileInput.value = ''; statusNode.textContent = error.message || String(error); } }); form.append(fileInput, el('p', 'task-input-request-prompts', '仅接受单个 .xls 或 .xlsx;可以不填写文字。')); const actions = el('div', 'task-input-request-actions'); const button = el('button', '', taskReplyStates.get(task.task_id) === 'sending' ? '提交中…' : '提交名单附件'); button.type = 'submit'; button.disabled = taskReplyStates.get(task.task_id) === 'sending'; actions.append(button, statusNode); form.append(actions); form.addEventListener('submit', (event) => { event.preventDefault(); void sendTaskAttachment(task, fileInput, button, statusNode); }); return form; } function renderTaskImportantMessage(task) { const important = taskImportantMessage(task); if (!important) return null; const panel = el('section', 'task-output-panel task-important-message-panel'); panel.id = 'importantMessagePanel'; panel.append(el('div', 'task-output-label', '需返回/交互用户的重要消息')); const kind = important.kind; const kindMeta = kind === 'awaiting_attachment' ? { label: '等待名单附件', className: 'state-warn' } : kind === 'awaiting_user_input' ? { label: '等待补充', className: 'state-warn' } : kind === 'error' ? { label: '错误摘要', className: 'state-bad' } : { label: '成功回执', className: 'state-ok' }; const card = el('article', `task-important-message-card ${kindMeta.className}`); const heading = el('div', 'task-important-message-heading'); heading.append(el('strong', 'task-outcome-title', kindMeta.label)); heading.append(el('time', 'task-outcome-time', `记录时间:${formatDateTime(important.recorded_at)}`)); card.append(heading); card.append(el('p', 'task-outcome-message', important.text)); if (kind === 'success') { const receipt = important.success_receipt?.receipt || {}; const groups = displayValues(receipt.group_numbers || receipt.group_number); const orders = displayValues(receipt.order_numbers || receipt.order_number); if (groups.length) card.append(el('p', 'task-outcome-line', `团号:${groups.join('、')}`)); if (orders.length) card.append(el('p', 'task-outcome-line', `订单号:${orders.join('、')}`)); } panel.append(card); if (kind === 'awaiting_user_input' || kind === 'awaiting_attachment') { const formPanel = el('section', 'task-input-request'); formPanel.id = 'inputRequestPanel'; const header = el('div', 'task-input-request-head'); header.append(el('div', 'task-output-label', kind === 'awaiting_attachment' ? '补交名单附件' : '补充信息')); header.append(el( 'span', 'task-input-request-state', taskReplyStates.get(task.task_id) === 'sending' ? '提交中…' : '仅补充当前任务' )); formPanel.append( header, kind === 'awaiting_attachment' ? renderTaskAttachmentForm(task) : renderTaskReplyForm(task) ); panel.append(formPanel); } return panel; } function isDispatchResultPending(task) { return ['reconciliation_pending', 'failed'].includes(taskServerStatus(task)) && task?.result?.execution_phase === 'dispatch_uncertain' && ['dispatch', 'reconciliation_timeout'].includes(task.result.stage); } function isTaskPollable(task) { if (isDispatchResultPending(task)) return taskServerStatus(task) === 'reconciliation_pending'; const localStatuses = [task?.status, task?.result?.status, task?.handoff_status] .map((value) => String(value || '').toLowerCase()); if (localStatuses.some((status) => MANUAL_RECONCILIATION_STATUSES.has(status))) return false; const status = taskServerStatus(task); if (TERMINAL_TASK_STATUSES.has(status) || MANUAL_RECONCILIATION_STATUSES.has(status)) return false; return Boolean( task?.confirmed_at && ( ['queued', 'accepted', 'running'].includes(status) || ['accepted', 'running'].includes(task?.handoff_status) ) ); } function canArchiveTask(task) { if (!task) return false; if (task.archived_at) return true; return ![ 'parse_queued', 'parse_running', 'confirmed', 'queued', 'accepted', 'running' ].includes(String(task.status || '')) && !['awaiting_handoff', 'accepted', 'running'].includes(String(task.handoff_status || '')); } function taskMutationInProgress(taskId) { return taskArchiveStates.get(taskId) === 'updating' || taskDeleteStates.get(taskId) === 'deleting'; } function canStartConfirmedTask(task) { return Boolean( task?.operation && task?.confirmed_at && task?.status === 'confirmed' && task?.handoff_status === 'awaiting_handoff' ); } function canReconcileTask(task) { const result = task?.result && typeof task.result === 'object' ? task.result : {}; const failedWithReconciliation = taskCanonicalStatus(task) === 'failed' && result.reconciliation_required === true && ['reconciliation_pending', 'execution_uncertain', 'saved_unverified'].includes(String(result.status || '')); return Boolean( task?.operation && task?.confirmed_at && (taskCanonicalStatus(task) === 'reconciliation_pending' || failedWithReconciliation) && isLifecycleOperationView(selectedOperation(task)) ); } function canResumePrewriteTask(task) { const result = task?.result && typeof task.result === 'object' ? task.result : {}; const stage = String(result.stage || task?.stage || ''); return Boolean( task?.operation && task?.confirmed_at && taskCanonicalStatus(task) === 'running' && ['auto_executor', 'erp_keepalive_degraded'].includes(stage) && result.no_erp_write === true && result.write_attempted !== true ); } function isAutomaticTask(task) { return task?.confirmation_mode === 'automatic'; } async function autoDispatchReadyTasks({ force = false } = {}) { if (!canUseTaskDataPlane() || autoHandoffInProgress) return; autoHandoffInProgress = true; try { const result = await apiRequest('/api/tasks?status=confirmed&limit=200&include_total=false&executable_by=me'); const candidates = Array.isArray(result.tasks) ? result.tasks : []; for (const task of candidates) { cacheRuntimeTask(task); } const orderedCandidates = [...candidates].sort((left, right) => { const leftTime = Date.parse(left?.created_at || '') || 0; const rightTime = Date.parse(right?.created_at || '') || 0; return leftTime - rightTime || String(left?.task_id || '').localeCompare(String(right?.task_id || '')); }); for (const task of orderedCandidates) { if (!isAutomaticTask(task) || !task?.confirmed_at || task?.status !== 'confirmed' || task?.handoff_status !== 'awaiting_handoff') continue; const detailedTask = await ensureTaskDetails(task.task_id); if (!canStartConfirmedTask(detailedTask)) continue; const retryAt = autoHandoffRetryAt.get(task.task_id) || 0; if (!force && retryAt > Date.now()) continue; try { const handoff = await handoffTaskToExtension(detailedTask); if (handoff?.status === 'paused') { autoHandoffRetryAt.set(task.task_id, Date.now() + AUTO_HANDOFF_RETRY_MS); break; } else if (handoff?.status === 'queued') { autoHandoffRetryAt.set(task.task_id, Date.now() + AUTO_HANDOFF_RETRY_MS); break; } else { autoHandoffRetryAt.delete(task.task_id); break; } } catch (error) { autoHandoffRetryAt.set(task.task_id, Date.now() + AUTO_HANDOFF_RETRY_MS); // Keep the server-authoritative confirmed/awaiting_handoff state in // the local snapshot. Replacing it with waiting_extension would make // the task fail canStartConfirmedTask() and prevent a later // BRIDGE_READY event from retrying the pre-claim handoff. updateLocalTask(task.task_id, { stage: 'bridge', message: `全自动化等待插件:${error.message || String(error)}`, error: error.message || String(error) }); break; } } } finally { autoHandoffInProgress = false; } } 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 '解析规则阻断'; if (status === 'agent_parse_needs_input') return '等待用户补充'; if (status === 'agent_parse_passed') return '系统解析完成'; if (status === 'create_error') return '创建失败'; return taskStatusText(task); } function selectedOperation(task) { return task?.operation || task?.result?.operation || null; } const LIFECYCLE_ACTIONS = new Set([ 'passenger_list_import', 'arrangement_guide', 'arrangement_vehicle', 'arrangement_hotel', 'arrangement_transport', 'arrangement_other', 'order_update_independent', 'order_update_shared_plan', 'order_update_shared_child', 'order_cancel', 'order_restore', 'order_delete' ]); function isLifecycleOperationView(operation) { return LIFECYCLE_ACTIONS.has(String(operation?.action || '')); } function hasLifecycleTestContextView(operation) { const context = operation?.source?.test_context; return isLifecycleOperationView(operation) && Boolean(context && typeof context === 'object' && !Array.isArray(context) && Object.keys(context).length); } function lifecycleReviewIdentifier(refs = {}) { return String( refs.identifier || refs.order_no || refs.group_no || refs.child_order_no || refs.parent_group_no || refs.parent_plan_no || refs.plan_no || refs.ddid || refs.tid || '' ).trim(); } function operationReviewDetail(operation = {}) { const data = operation.data || {}; if (data.arrangement) return `${operation.action} / ${data.arrangement.mode || '未声明模式'}`; if (Array.isArray(data.updates?.actions)) { return data.updates.actions.map((item) => `${item.target || '未知字段'}:${item.operation || '未知动作'}`).join(','); } if (data.transition) return `${data.transition.mode || '未知路径'}:${data.transition.from_status || '未知'} → ${data.transition.to_status || '未知'}`; if (Array.isArray(data.export_types)) return `文件类型:${data.export_types.join('、')}(保存为后台附件)`; if (data.passenger_list) return `${data.passenger_list.operation || '名单操作'} / ${data.passenger_list.row_count || 0} 行`; return data.order_number?.suffix ? `显式 suffix:${data.order_number.suffix}` : '按标准 operation 执行'; } function renderOperationReview(task) { const operation = selectedOperation(task); if (!operation || operation.action === 'confirmation_export') return null; const data = operation.data || {}; const refs = data.existing_refs || {}; const context = operation.source?.test_context || {}; const lifecycleTest = hasLifecycleTestContextView(operation); const businessLookup = Boolean(data.customer?.name && Array.isArray(data.departure_dates) && data.departure_dates.length === 1); const panel = el('section', 'task-output-panel task-operation-review-panel'); const heading = el('div', 'task-output-heading'); heading.append(el('div', 'task-output-label', lifecycleTest ? '生命周期测试写入审核' : '标准 operation 审核')); panel.append(heading); const card = el('div', `task-operation-review-card ${lifecycleTest ? 'state-warn' : ''}`); const rows = [ ['action', operation.action || '未识别'], ['目标对象', refs.kind ? `${refs.kind} / ${lifecycleReviewIdentifier(refs) || (businessLookup ? '按业务条件检索' : '缺少编号')}` : businessLookup ? '按业务条件检索已有对象' : '新建对象'], ['目标日期', refs.departure_date || (Array.isArray(data.departure_dates) ? data.departure_dates.join('、') : '未提供')], ['业务选择', [data.customer?.name, data.product?.name, data.leader?.name].filter(Boolean).join(' / ') || '由当前对象确定'], ['操作明细', operationReviewDetail(operation)] ]; if (lifecycleTest) { rows.push( ['测试运行', context.run_id || '缺失'], ['账号 / 标记', `${context.account || '缺失'} / ${context.marker || '缺失'}`], ['创建引用', `${Array.isArray(context.created_refs) ? context.created_refs.length : 0} 个(服务端确认时精确复核当前对象)`], ['写入授权', context.allow_live_write === true ? '控制平面已在人工确认后授权' : '尚未授权;点击确认后由控制平面校验并注入'] ); } for (const [label, value] of rows) { const row = el('div', 'task-operation-review-row'); row.append(el('span', 'task-operation-review-key', label)); row.append(el('span', 'task-operation-review-value', String(value || '未提供'))); card.append(row); } if (lifecycleTest) { card.append(el('p', 'task-operation-review-note', '仅允许当前运行、AI 测试账号、2026 年 9 月且精确命中 created_refs 的对象;开启全自动后,任何来源的任务都不因生命周期类型转为人工确认。')); } panel.append(card); return panel; } function renderTaskArtifacts(task) { const report = task?.result?.report && typeof task.result.report === 'object' ? task.result.report : {}; const reportArtifacts = Array.isArray(report.artifacts) ? report.artifacts : []; const receipt = task?.success_receipt?.receipt && typeof task.success_receipt.receipt === 'object' ? task.success_receipt.receipt : {}; const receiptAttachments = [ ...(Array.isArray(receipt.reply_attachments) ? receipt.reply_attachments : []), ...(Array.isArray(receipt.attachments) ? receipt.attachments : []), ...(Array.isArray(task?.important_message?.attachments) ? task.important_message.attachments : []) ]; const fallbackByName = new Map( receiptAttachments .filter((item) => item && typeof item === 'object') .map((item) => [ String(item.file_name || item.name || item.fileName || '').trim().toLowerCase(), item ]) .filter(([name]) => name) ); const artifacts = reportArtifacts.length ? reportArtifacts.map((artifact) => { const fallback = fallbackByName.get( String(artifact?.file_name || artifact?.name || artifact?.fileName || '').trim().toLowerCase() ) || {}; return { ...fallback, ...artifact, file_name: artifact?.file_name || artifact?.name || artifact?.fileName || fallback.file_name || fallback.name || '团队文件', download_url: artifact?.download_url || artifact?.public_url || artifact?.url || fallback.download_url || fallback.public_url || fallback.url || '' }; }) : receiptAttachments; if (!artifacts.length) return null; const panel = el('section', 'task-output-panel task-artifact-panel'); const heading = el('div', 'task-output-heading'); heading.append(el('div', 'task-output-label', '后台附件')); panel.append(heading); const list = el('div', 'task-artifact-list'); if (reportArtifacts.length && report.backend_stored !== true) { list.append(el('p', 'task-artifact-pending', 'ERP 文件已读取,正在保存到平台后台…')); panel.append(list); return panel; } for (const artifact of artifacts) { const row = el('div', 'task-artifact-row'); const meta = el('div', 'task-artifact-meta'); meta.append(el('strong', 'task-artifact-name', artifact.file_name || '团队文件')); row.append(meta); const downloadUrl = artifact.download_url || artifact.public_url || artifact.url || (artifact.artifact_id && task?.task_id ? `/api/tasks/${encodeURIComponent(task.task_id)}/artifacts/${encodeURIComponent(artifact.artifact_id)}` : ''); if (downloadUrl) { const link = el('a', 'secondary-button task-artifact-download', '下载文件'); link.href = downloadUrl; link.setAttribute('download', artifact.file_name || 'team-file.bin'); link.rel = 'noopener'; row.append(link); } else { row.append(el('span', 'task-artifact-unavailable', '附件链接不可用')); } list.append(row); } panel.append(list); return panel; } const OPERATION_TIMING_SCHEMA_VERSION = 'ltjt-operation-timing-v1'; const OPERATION_STAGE_LABELS = { auto_executor: '插件领取与启动', erp_keepalive_degraded: 'ERP 保活降级', queued: '查找 ERP 页面', open_order_form: '打开业务表单', wait_order_form: '等待表单就绪', preflight: '保存前预检', live_submit: 'ERP 提交', verification: '写后回查', erp_resolution: 'ERP 对象解析', lifecycle_route_preparation: '打开目标业务页', lifecycle_preflight: '生命周期预检', lifecycle_live_submit: '生命周期提交', lifecycle_completed: '生命周期回查完成', lifecycle_reconciliation: '只读结果回查', browser_execution: '浏览器业务执行', capability_check: '能力与契约检查', batch_fallback: '批量逐项执行', execution_gate: '写入前安全门禁', validation: '执行契约校验', automation_disabled: '自动化开关检查', execution_timeout: '执行超时收敛', cancelled: '取消收敛', extension_error: '插件异常收敛' }; const PAGE_ACTION_LABELS = { open_order_form: '打开团队下单页', ping_order_frame: '探测团队下单页', open_team_batch_form: '打开批量下单页', ping_team_batch_frame: '探测批量下单页', preflight_raw_instruction: '团队单保存前预检', live_submit_approved: '团队单提交', verify_order_marker: '团队单回查', return_to_order_list: '返回团队列表', inspect_operation_context: '读取业务上下文', resolve_lifecycle_operation: '解析 ERP 目标', prepare_lifecycle_operation: '打开生命周期目标页', preflight_lifecycle_operation: '生命周期预检', live_submit_lifecycle_operation: '生命周期提交', cleanup_completed_lifecycle_dialog: '关闭已完成业务弹窗', verify_lifecycle_operation: '生命周期回查', create_split_parent_live: '新增散拼母团', create_split_child_live: '新增散拼子单', export_confirmation_sources: '读取团队文件', preflight_team_batch_native: '批量下单预检', live_submit_team_batch_approved: '批量下单提交', verify_team_batch_receipts: '批量下单回查' }; const TIMING_DETAIL_LABELS = { 'executor.erp_keepalive_setup': '建立 ERP 执行保活', 'preflight_lookup.customer': '查询客户资料', 'preflight_lookup.parallel_wall': '四项资料并行查询', 'preflight_lookup.product': '查询产品资料', 'preflight_lookup.route': '查询线路资料', 'preflight_lookup.staff': '查询员工资料', 'frame_discovery.web_navigation': '定位目标 ERP Frame', 'preflight_product_linkage': '等待产品联动完成', 'preflight_customer_restore': '客户回填精确复核', 'preflight_submit_intercept': '提交载荷安全拦截', 'result_publish.prepare': '整理平台回执', 'storage_prune.local_history': '清理可丢弃的本地历史', 'storage_queue.accept_execution': '执行领取存储排队', 'storage_queue.execution_state': '执行状态存储排队', 'storage_queue.result_state': '任务结果存储排队', 'storage_read.accept_execution': '读取执行领取状态', 'storage_read.automation_setting': '读取自动化设置', 'storage_read.execution_record': '读取执行记录', 'storage_read.execution_state': '读取执行状态', 'storage_read.result_state': '读取任务结果状态', 'storage_read.task_payload': '读取任务载荷', 'storage_write.accept_execution': '保存执行领取状态', 'storage_write.execution_state': '保存执行状态', 'storage_write.last_frame': '保存表单 Frame 证据', 'storage_write.last_live_submit': '保存提交证据', 'storage_write.last_preflight': '保存预检证据', 'storage_write.last_return_to_list': '保存返回列表证据', 'storage_write.last_verification': '保存回查证据', 'storage_write.result_state': '保存任务结果状态', 'tab_lookup.cache_hit': '复用任务 ERP 页签', 'tab_lookup.create': '新建 ERP 页签', 'tab_lookup.find_or_open': '定位可用 ERP 页签', 'tab_lookup.host_permission': '检查 ERP 页面权限', 'tab_lookup.query': '扫描 ERP 页签', 'tab_lookup.wait_ready': '等待 ERP 页签就绪' }; const TIMING_COUNTER_LABELS = { cache_hit_count: '缓存命中', call_count: '调用', candidate_count: '候选 Frame', completion_event_count: '完成回调', created_count: '新建', execution_count: '执行记录', exact_check_count: '精确复核', failure_count: '失败', frame_count: 'Frame', matched_field_count: '匹配字段', protected_root_count: '受保护任务', quiet_wait_ms: '静默等待毫秒', removed_execution_count: '清理执行记录', removed_result_count: '清理结果', removed_root_count: '清理任务', removed_task_count: '清理载荷', removed_timing_count: '清理计时状态', request_count: '请求', result_count: '结果记录', retained_root_count: '保留任务', retry_count: '重试', poll_count: '轮询', sample_count: '稳定采样', stable_sample_count: '连续稳定', submit_count: '提交尝试', tab_count: '页签', task_count: '任务载荷', timing_count: '计时状态' }; function formatOperationDuration(value) { const milliseconds = Math.max(0, Number(value) || 0); if (milliseconds < 1_000) return `${Math.round(milliseconds)}ms`; if (milliseconds < 60_000) return `${(milliseconds / 1_000).toFixed(milliseconds < 10_000 ? 2 : 1)}s`; const minutes = Math.floor(milliseconds / 60_000); const seconds = Math.round((milliseconds % 60_000) / 1_000); return `${minutes}m ${seconds}s`; } function timingDetailLabel(step) { if (step === 'script_injection') return '脚本注入'; if (TIMING_DETAIL_LABELS[step]) return TIMING_DETAIL_LABELS[step]; const matched = String(step || '').match(/^(action_roundtrip|action_runtime|network_wait)\.(.+)$/); if (!matched) return String(step || '未知步骤'); const prefix = { action_roundtrip: '浏览器往返', action_runtime: '页面执行', network_wait: '网络等待' }[matched[1]]; return `${prefix} · ${PAGE_ACTION_LABELS[matched[2]] || matched[2]}`; } function timingCountersText(counters) { if (!counters || typeof counters !== 'object') return ''; return Object.entries(counters) .filter(([, value]) => Number.isFinite(Number(value))) .map(([key, value]) => `${TIMING_COUNTER_LABELS[key] || key} ${Number(value)}`) .join(' · '); } function renderOperationTiming(task) { const timing = task?.result?.operation_timing; if (!timing || timing.schema_version !== OPERATION_TIMING_SCHEMA_VERSION || !Array.isArray(timing.stages)) return null; const panel = el('section', 'task-output-panel task-operation-timing-panel'); const heading = el('div', 'task-output-heading'); heading.append(el('div', 'task-output-label', '插件操作耗时')); heading.append(el( 'span', 'task-operation-timing-total', `${formatOperationDuration(timing.total_ms)} · ${Number(timing.stage_count || timing.stages.length)} 阶段` )); panel.append(heading); const list = el('div', 'task-operation-timing-list'); for (const stage of timing.stages) { if (!stage || typeof stage !== 'object') continue; const row = el('article', 'task-operation-timing-stage'); const stageHeading = el('div', 'task-operation-timing-stage-heading'); const stageName = el('div', 'task-operation-timing-stage-name'); stageName.append(el('strong', '', OPERATION_STAGE_LABELS[stage.stage] || stage.stage || '未知阶段')); stageName.append(el('code', '', stage.stage || 'unknown_stage')); stageHeading.append(stageName); const metrics = el('div', 'task-operation-timing-stage-metrics'); metrics.append(el('strong', '', formatOperationDuration(stage.duration_ms))); metrics.append(el('span', '', stage.status || 'unknown')); stageHeading.append(metrics); row.append(stageHeading); const details = Array.isArray(stage.details) ? stage.details : []; if (details.length) { const detailList = el('div', 'task-operation-timing-details'); for (const detail of details) { const detailRow = el('div', 'task-operation-timing-detail'); detailRow.append(el('span', '', timingDetailLabel(detail.step))); const counters = timingCountersText(detail.counters); detailRow.append(el( 'strong', '', `${formatOperationDuration(detail.duration_ms)}${counters ? ` · ${counters}` : ''}` )); detailList.append(detailRow); } row.append(detailList); } list.append(row); } panel.append(list); panel.append(el( 'p', 'task-operation-timing-note', '从插件取得唯一执行权开始计时,不包含人工确认等待;并行及嵌套明细不可直接相加,网络等待为浏览器 Resource Timing 聚合,不记录 URL、表单值或响应正文。' )); return panel; } 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 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 explicit = task?.success_receipt?.receipt; if (explicit) return explicit; 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 displayValues(value) { if (Array.isArray(value)) return value.map((item) => String(item || '').trim()).filter(Boolean); if (value === undefined || value === null || value === '') return []; return [String(value).trim()].filter(Boolean); } function taskStageSnapshot(task) { const emptyStage = () => ({ mode: 'pending', current: null, currentLabel: '' }); 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 parseNeedsInput = ['awaiting_attachment', 'awaiting_user_input', 'agent_parse_needs_input'].includes(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: '已失败' } : parseNeedsInput ? { mode: 'needs', current: 'needs_input', currentLabel: status === 'awaiting_attachment' ? '等待附件' : '等待补充' } : agentSucceeded ? { mode: 'complete', current: 'success', currentLabel: '已成功' } : status === 'parse_running' || (status === 'running' && stage === 'parse') ? { mode: 'processing', current: 'submitted', currentLabel: '处理中' } : { mode: 'needs', current: 'submitted', currentLabel: '已提交' }; let business = emptyStage(); if (agentSucceeded) { if (status === 'completed' || status === 'batch_fallback_completed') { business = { mode: 'complete', current: 'completed', currentLabel: '已完成' }; } else if (status === 'reconciliation_pending' || status === 'execution_uncertain' || status === 'saved_unverified') { business = { mode: 'needs', current: 'processing', currentLabel: '待人工核验' }; } else if (['blocked', 'failed', 'cancelled', 'extension_error', 'batch_fallback_incomplete', 'live_submit_blocked', 'preflight_blocked'].includes(status)) { business = { mode: 'failed', current: 'failed', currentLabel: '已失败' }; } else if (status === 'accepted' || status === 'queued') { business = { mode: 'processing', current: 'accepted', currentLabel: '已接单' }; } else if (status === 'running' || ['browser_execution', 'open_order_form', 'wait_order_form', 'preflight', 'live_submit', 'verification', 'batch_fallback'].includes(stage)) { business = { mode: 'processing', current: 'processing', currentLabel: '处理中' }; } else if (['confirmed', 'waiting_extension', 'paused'].includes(status)) { business = { mode: 'needs', current: null, currentLabel: '需处理' }; } else { business = { mode: 'pending', current: null, currentLabel: '待开始' }; } } let receiptStage; if (receipt) { receiptStage = { mode: 'complete', current: 'received', currentLabel: '已获取回执' }; } else if (status === 'reconciliation_pending' || status === 'execution_uncertain' || status === 'saved_unverified') { receiptStage = { mode: 'needs', current: 'pending', currentLabel: '请勿重复提交' }; } else if (business.mode === 'failed') { receiptStage = { mode: 'pending', current: null, currentLabel: '待获取' }; } else if (business.mode === 'complete') { receiptStage = { mode: 'needs', current: 'pending', currentLabel: '待获取' }; } else { receiptStage = emptyStage(); } return { agent, business, receipt: receiptStage }; } function renderTaskStages(task) { const container = $('#taskStages'); if (!container) return; container.innerHTML = ''; const snapshot = taskStageSnapshot(task); const stages = [ { key: 'agent', title: '系统解析阶段', options: [ { key: 'submitted', label: '已提交' }, { key: 'needs_input', label: '等待补充' }, { key: 'success', label: '已成功' }, { key: 'failed', label: '已失败' } ] }, { key: 'business', title: '系统写入处理', options: [ { key: 'accepted', label: '已接单' }, { key: 'processing', label: '处理中' }, { key: 'completed', label: '已完成' }, { key: 'failed', label: '已失败' } ] }, { key: 'receipt', title: '获取回执', options: [ { key: 'pending', label: '待获取' }, { key: 'received', label: '已获取回执' }, { key: 'failed', 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('strong', 'task-stage-title', stage.title)); card.append(heading); const currentOption = stage.options.find((option) => option.key === state.current); const statusItem = el( 'span', 'task-stage-status is-current', currentOption?.label || state.currentLabel || '待开始' ); statusItem.setAttribute('role', 'status'); card.append(statusItem); flow.append(card); } container.append(flow); } function parserEngineLabel(parser = {}) { if (parser.fallback_reason && parser.fallback_reason !== 'manual_ai_reparse') return 'AI 兜底'; if (parser.fallback_reason === 'manual_ai_reparse') return '人工 AI 重解析'; return parser.authoritative_engine === 'program' ? '程序解析' : parser.authoritative_engine === 'ai' ? 'AI 解析' : '尚未确定'; } function parserDifferenceValue(side = {}) { if (side.present !== true) return '(未提供)'; if (typeof side.value === 'string') return side.value; try { return JSON.stringify(side.value, null, 2); } catch { return String(side.value ?? 'null'); } } function parserDifferenceNode(entry) { const diff = el('div', 'task-parser-diff'); const heading = Number(entry.attempt_no || 0) > 0 ? `第 ${entry.attempt_no} 次解析 · operation 字段差异` : 'operation 字段差异'; diff.append(el('strong', '', heading)); if (Array.isArray(entry.field_differences) && entry.field_differences.length) { const list = el('div', 'task-parser-difference-list'); for (const item of entry.field_differences) { const row = el('article', 'task-parser-difference-row'); row.append(el('code', 'task-parser-difference-path', String(item.path || '').replace(/^\$\.?/, ''))); const values = el('div', 'task-parser-difference-values'); const program = el('div', 'task-parser-difference-value'); program.append(el('span', '', 'Program')); program.append(el('pre', '', parserDifferenceValue(item.program))); const ai = el('div', 'task-parser-difference-value'); ai.append(el('span', '', 'AI')); ai.append(el('pre', '', parserDifferenceValue(item.ai))); values.append(program, ai); row.append(values); list.append(row); } diff.append(list); } else if (Array.isArray(entry.diff_paths) && entry.diff_paths.length) { const list = el('ul', ''); for (const path of entry.diff_paths) list.append(el('li', '', String(path).replace(/^\$\.?/, ''))); diff.append(list); } return diff; } function parserReviewNode(decisionId, reviewStatus) { const review = el('div', 'task-parser-review'); review.append(el('span', 'muted', `人工判定:${reviewStatus || 'pending'}`)); if (!isAdministrator()) return review; const actions = el('div', 'task-parser-review-actions'); const options = [ ['equivalent', '等价'], ['program_correct', '程序正确'], ['ai_correct', 'AI 正确'], ['both_wrong', '两者都错'] ]; for (const [verdict, label] of options) { const button = el('button', verdict === 'ai_correct' || verdict === 'both_wrong' ? 'danger-secondary-button' : 'secondary-button', label); button.type = 'button'; button.dataset.parserDecisionId = decisionId; button.dataset.parserReviewVerdict = verdict; actions.append(button); } review.append(actions); return review; } function renderTaskParserPanel(task) { const parser = task?.parser; if (!parser) return null; const panel = el('section', 'task-output-panel task-parser-panel'); panel.append(el('div', 'task-output-label', '解析决策')); const grid = el('div', 'task-parser-grid'); const values = [ ['业务路由', parser.route_id || 'unresolved → AI'], ['输入契约', parser.input_contract_version || '历史/未识别'], ['任务模式快照', `${PARSER_MODE_LABELS[parser.configured_mode] || parser.configured_mode || 'AI'} / revision ${parser.config_revision || 0}`], ['权威引擎', parserEngineLabel(parser)], ['解析版本', parser.version || '未记录'], ['Fallback 原因', parser.fallback_reason || '无'], ['Shadow 比较', parser.comparison_status || '未比较'], ['待判定差异', String(Number(parser.unreviewed_difference_count || 0))] ]; for (const [label, value] of values) appendField(grid, label, value); panel.append(grid); const shadowDifferences = Array.isArray(parser.shadow_differences) && parser.shadow_differences.length ? parser.shadow_differences : parser.comparison_status === 'different' && parser.decision_id ? [{ decision_id: parser.decision_id, attempt_no: 0, review_status: parser.review_status, diff_paths: parser.diff_paths, field_differences: parser.field_differences }] : []; for (const difference of shadowDifferences) { panel.append(parserDifferenceNode(difference)); panel.append(parserReviewNode(difference.decision_id, difference.review_status)); } const failure = taskFailureInfo(task); const canReparse = isAdministrator() && task.status === 'parse_failed' && Number(parser.reparse_count || 0) < 1 && failure?.no_plugin_dispatch === true && failure?.no_erp_write === true; if (canReparse) { const action = el('div', 'task-parser-reparse'); action.append(el('span', 'muted', '尚未进入插件或 ERP,可授权一次 AI 重解析。')); const button = el('button', 'secondary-button', '使用 AI 重解析一次'); button.type = 'button'; button.dataset.aiReparseTask = task.task_id; action.append(button); panel.append(action); } return panel; } async function reviewTaskParserDecision(decisionId, verdict, button) { if (!decisionId || !verdict) return; const noteInput = window.prompt('可选:填写本次人工判定说明(不填写客户资料):'); if (noteInput === null) return; const note = noteInput || undefined; button.disabled = true; try { await apiRequest(`/api/parser-decisions/${encodeURIComponent(decisionId)}/review`, { method: 'PUT', body: { verdict, note } }); if (currentTaskId) await ensureTaskDetails(currentTaskId, { force: true }); renderTaskCards(); } catch (error) { window.alert(`判定保存失败:${error.message || String(error)}`); } finally { if (button.isConnected) button.disabled = false; } } async function reparseTaskWithAi(taskId, button) { const reason = window.prompt('请输入本次 AI 重解析原因(仅允许一次,将记录审计):'); if (!normalizeText(reason)) return; if (!window.confirm('确认该任务尚未进入插件或 ERP,并使用 AI 重解析一次?')) return; button.disabled = true; try { const result = await apiRequest(`/api/tasks/${encodeURIComponent(taskId)}/reparse`, { method: 'POST', body: { engine: 'ai', reason: normalizeText(reason) } }); if (result.task) upsertLocalTask(result.task); } catch (error) { window.alert(`AI 重解析提交失败:${error.message || String(error)}`); } finally { if (button.isConnected) button.disabled = false; } } function renderTaskInputAudit(task) { if (!task?.task_id) return null; const panel = el('section', 'task-output-panel'); panel.append(el('div', 'task-output-label', '创建人与原始输入审计')); panel.append(el('p', 'muted', `创建人:${task.creator?.username || (task.source === 'agentbus' ? 'AgentBus / 系统' : '历史记录未知')}`)); panel.append(el('p', task.assignee?.username ? 'muted' : 'channel-error', `执行归属:${task.assignee?.username || '未分配(禁止 ERP 执行)'}`)); const history = taskInputHistoryStore.get(task.task_id); if (!history) { panel.append(el('p', 'muted', '正在加载加密输入历史…')); return panel; } for (const message of history.messages) { const item = el('article', 'detail-block'); const sourceLabel = message.source === 'manual' ? '人工输入' : message.source === 'agentbus' ? 'AgentBus' : message.source === 'reparse' ? '人工重解析' : '历史/系统'; item.append(el( 'p', 'muted', `第 ${message.turn_no} 轮 · ${message.actor?.username || '未知操作人'} · ${sourceLabel} · ${new Date(message.created_at).toLocaleString('zh-CN')}` )); item.append(el('pre', 'task-lifecycle-console', message.content || '')); panel.append(item); } for (const attachment of history.attachments) { panel.append(el( 'p', 'muted', `附件:${attachment.file_name || '未命名'} · ${attachment.actor?.username || '未知操作人'} · ${attachment.status} · SHA-256 ${String(attachment.sha256 || '').slice(0, 16)}…` )); } if (!history.messages.length && !history.attachments.length) { panel.append(el('p', 'muted', '没有可显示的输入审计记录。')); } return panel; } function renderTaskDetail() { const container = $('#taskDetail'); if (!container) return; const previousLifecycle = $('#taskLifecycle'); if (previousLifecycle?.dataset.taskId && Number.isFinite(previousLifecycle.scrollTop)) { lifecycleScrollPositions.set(previousLifecycle.dataset.taskId, previousLifecycle.scrollTop); } container.innerHTML = ''; const task = selectedTask(); const taskState = $('#taskState'); const confirmButton = $('#confirmTaskButton'); const archiveButton = $('#archiveTaskButton'); const deleteButton = $('#deleteTaskButton'); const status = task?.status || task?.result?.status || 'created'; const importantMessagePanel = renderTaskImportantMessage(task); renderTaskStages(task); if (confirmButton) { const canOperate = Boolean(task && taskAssignedToCurrentAccount(task)); const canConfirm = Boolean(canOperate && requiresManualConfirmation(task)); const canStart = Boolean(canOperate && !isAutomaticTask(task) && canStartConfirmedTask(task)); const canResume = Boolean(canOperate && canResumePrewriteTask(task)); const canReconcile = Boolean(canOperate && canReconcileTask(task)); const reconciliationBusy = Boolean(task && taskReconciliationStates.get(task.task_id) === 'running'); confirmButton.hidden = !canConfirm && !canStart && !canResume && !canReconcile; confirmButton.disabled = (!canConfirm && !canStart && !canResume && !canReconcile) || reconciliationBusy; const lifecycleReview = hasLifecycleTestContextView(selectedOperation(task)); confirmButton.textContent = reconciliationBusy ? '只读回查中…' : canReconcile ? (isDispatchResultPending(task) ? '读取插件已保存结果' : RECONCILE_LABEL) : canResume ? '恢复保存前执行' : canStart ? RETRY_HANDOFF_LABEL : lifecycleReview ? '审核并授权本次测试写入' : MANUAL_HANDOFF_LABEL; } if (archiveButton) { const archiveState = task ? taskArchiveStates.get(task.task_id) : ''; archiveButton.disabled = !canArchiveTask(task) || taskMutationInProgress(task?.task_id); if (archiveState === 'updating') archiveButton.textContent = task?.archived_at ? '恢复中…' : '归档中…'; else if (archiveState === 'failed') archiveButton.textContent = task?.archived_at ? '重试恢复' : '重试归档'; else archiveButton.textContent = task?.archived_at ? '恢复任务' : '归档任务'; } if (deleteButton) { const deleteState = task ? taskDeleteStates.get(task.task_id) : ''; deleteButton.disabled = !task || taskMutationInProgress(task?.task_id); if (deleteState === 'deleting') deleteButton.textContent = '强制删除中…'; else if (deleteState === 'failed') deleteButton.textContent = '重试强制删除'; else deleteButton.textContent = '强制删除任务'; } if (taskState) { taskState.textContent = task ? taskStatusLabel(task) : '未创建'; taskState.className = `state ${task ? taskStateClass(status) : 'state-warn'}`; } const lifecyclePanel = el('section', 'task-output-panel task-lifecycle-panel'); const lifecycleHeading = el('div', 'task-output-heading'); lifecycleHeading.append(el('div', 'task-output-label', '任务生命周期')); const copyButton = el('button', 'secondary-button task-copy-button', '一键复制'); copyButton.type = 'button'; copyButton.dataset.copyLifecycle = 'true'; copyButton.title = '复制黑框中的全部内容'; copyButton.setAttribute('aria-label', '复制任务生命周期'); lifecycleHeading.append(copyButton); lifecyclePanel.append(lifecycleHeading); const lifecycle = renderTaskLifecycle(task); lifecycle.id = 'taskLifecycle'; lifecycle.dataset.taskId = task?.task_id || ''; lifecycle.setAttribute('role', 'log'); lifecycle.setAttribute('aria-live', 'polite'); lifecyclePanel.append(lifecycle); const panels = []; if (importantMessagePanel) panels.push(importantMessagePanel); const inputAuditPanel = renderTaskInputAudit(task); if (inputAuditPanel) panels.push(inputAuditPanel); const parserPanel = renderTaskParserPanel(task); if (parserPanel) panels.push(parserPanel); const operationReviewPanel = renderOperationReview(task); if (operationReviewPanel) panels.push(operationReviewPanel); const operationTimingPanel = renderOperationTiming(task); if (operationTimingPanel) panels.push(operationTimingPanel); const artifactPanel = renderTaskArtifacts(task); if (artifactPanel) panels.push(artifactPanel); panels.push(lifecyclePanel); container.append(...panels); const rememberedScrollTop = task?.task_id ? lifecycleScrollPositions.get(task.task_id) : undefined; if (Number.isFinite(rememberedScrollTop)) lifecycle.scrollTop = rememberedScrollTop; loadSelectedTaskDetails(task); } const EXTERNAL_TRACE_MESSAGES = { not_started: '解析请求已创建,等待调用外部 Agent。', creating_session: '正在创建外部 Agent 会话。', session_created: '外部 Agent 会话已创建。', stream_connecting: '正在接收外部 Agent 的 SSE 响应。', stream_completed: '外部 Agent SSE 响应已完成。', result_received: '已收到 Agent 返回结果。', contract_validating: '正在校验 Agent 返回的标准 operation 契约。', contract_repairing: '首次结果未通过契约校验,正在发起一次同会话修复。', recovering_session: '外部 Agent 会话异常,正在恢复。', session_recreated: '外部 Agent 会话已重建。', completed_without_json: 'Agent 响应已结束,但未返回可识别的 JSON。', failed: '外部 Agent 请求失败。', not_configured: '外部解析服务未配置。' }; function externalTraceMessage(item) { return EXTERNAL_TRACE_MESSAGES[item?.stage] || `外部解析阶段:${item?.stage || 'unknown'}。`; } function taskLifecycleEntries(task) { const entries = []; let order = 0; for (const event of Array.isArray(task?.events) ? task.events : []) { entries.push({ ...event, source: 'task_event', group: event.stage || 'system', order: order++ }); } const trace = task?.parse_response?.external_request; for (const item of Array.isArray(trace?.timeline) ? trace.timeline : []) { entries.push({ id: `agent-trace-${item.sequence || order}`, status: 'info', stage: item.stage || 'external_parse', message: externalTraceMessage(item), created_at: item.at, payload: item, source: 'agent_trace', group: 'parse', order: order++ }); } const error = task?.error_summary; const failure = taskFailureInfo(task); const status = String(task?.status || task?.result?.status || '').toLowerCase(); const rawErrorText = error?.message || failure?.failure_message || ''; const errorText = taskFailureText(task) || rawErrorText; if (isFailureStatus(status) && errorText) { const hasErrorEntry = entries.some((entry) => { const values = [entry.message, entry.payload?.failure_message, entry.payload?.message] .filter(Boolean) .map((value) => String(value)); return values.some((value) => value.includes(errorText) || (rawErrorText && value.includes(rawErrorText))); }); if (!hasErrorEntry) { const errorStage = error?.stage || error?.failure_stage || failure?.failure_stage || 'task'; entries.push({ id: `task-error-summary-${error?.recorded_at || task?.updated_at || 'latest'}`, status: 'error', stage: errorStage, message: errorText.startsWith('失败:') ? errorText : `失败:${failureStageLabel(errorStage)} · ${errorText}`, created_at: error?.recorded_at || task?.updated_at || '', payload: { error_code: error?.error_code || failure?.error_code || 'task_failed', failure_stage: error?.failure_stage || errorStage, failure_source: error?.failure_source || failure?.failure_source || '', failure_message: rawErrorText || errorText }, source: 'task_error_summary', group: errorStage, order: order++ }); } } return entries.sort((left, right) => { const leftTime = Date.parse(left.created_at || '') || 0; const rightTime = Date.parse(right.created_at || '') || 0; return leftTime - rightTime || left.order - right.order; }); } function lifecycleLevel(entry) { const source = `${entry.status || ''} ${entry.message || ''} ${entry.payload?.failure_stage || ''}`; if (/failed|blocked|error|uncertain|失败|阻断|错误|不可用/i.test(source)) return 'error'; if (/completed|received|accepted|confirmed|success|完成|收到|接单|确认|成功/i.test(source)) return 'ok'; if (/repair|recover|waiting|pending|queued|等待|恢复|修复/i.test(source)) return 'warn'; return 'info'; } function lifecycleGroupLabel(entry) { if (entry.source === 'agent_trace') return 'AI解析阶段'; if (entry.group === 'parse') return '系统解析阶段'; return taskLogStageLabel(entry.group || entry.stage); } function taskLifecycleDetails(entry, task) { const details = { event_id: entry.id, source: entry.source, status: entry.status || '', stage: entry.stage || '', created_at: entry.created_at || '', payload: entry.payload || {} }; if (entry.source === 'task_event' && entry.stage === 'parse' && ['awaiting_confirmation', 'awaiting_attachment', 'awaiting_user_input', 'parse_failed'].includes(entry.status)) { details.parse_response = task?.parse_response || null; details.operation = task?.operation || null; details.failure = taskFailureInfo(task); } if (entry.source === 'task_event' && ['execution', 'browser_execution', 'preflight', 'live_submit', 'receipt', 'verification', 'reconciliation'].includes(entry.stage)) { details.executor_response = task?.result || null; details.failure = taskFailureInfo(task); } return details; } async function writeClipboardText(text) { if (navigator.clipboard?.writeText) { try { await navigator.clipboard.writeText(text); return; } catch (error) { // Fall through to the DOM fallback when clipboard permissions are unavailable. } } const input = el('textarea', 'clipboard-fallback-input', text); input.setAttribute('readonly', ''); input.style.position = 'fixed'; input.style.top = '-9999px'; input.style.left = '-9999px'; input.style.opacity = '0'; document.body.append(input); input.focus(); input.select(); let copied = false; try { copied = document.execCommand('copy'); } finally { input.remove(); } if (!copied) throw new Error('当前浏览器不支持复制。'); } async function copyTaskLifecycle(button) { const lifecycle = $('#taskLifecycle'); const text = lifecycle?.textContent || ''; if (!lifecycle || !text.trim()) return; button.disabled = true; button.classList.remove('is-copied', 'is-error'); try { await writeClipboardText(text); button.textContent = '已复制'; button.classList.add('is-copied'); } catch (error) { button.textContent = '复制失败'; button.classList.add('is-error'); } finally { window.setTimeout(() => { if (!button.isConnected) return; button.disabled = false; button.textContent = '一键复制'; button.classList.remove('is-copied', 'is-error'); }, 1600); } } function renderTaskLifecycle(task) { const entries = taskLifecycleEntries(task); if (!entries.length) return el('pre', 'task-lifecycle-console', '暂无任务生命周期记录。'); const lines = []; for (const entry of entries) { const level = lifecycleLevel(entry); const levelText = { error: 'ERROR', ok: 'OK', warn: 'WARN', info: 'INFO' }[level] || 'INFO'; const time = new Date(entry.created_at || Date.now()); const timeText = Number.isNaN(time.getTime()) ? '--:--:--' : time.toLocaleTimeString('zh-CN', { hour12: false }); const status = entry.status ? `[${entry.status}]` : '[event]'; lines.push(`${timeText} ${levelText.padEnd(5, ' ')} [${lifecycleGroupLabel(entry)}] ${status} ${entry.message || '状态已更新。'}`); const details = taskLifecycleDetails(entry, task); const hasTechnicalDetails = Object.keys(details.payload || {}).length > 0 || details.parse_response !== undefined || details.executor_response !== undefined; if (hasTechnicalDetails) { const technical = JSON.stringify(redactJsonForDisplay(details), null, 2) .split('\n') .map((line) => ` ${line}`) .join('\n'); lines.push(` 技术详情:\n${technical}`); } } return el('pre', 'task-lifecycle-console', lines.join('\n')); } function taskLogStageLabel(stage) { const normalized = String(stage || '').toLowerCase(); if (normalized === 'parse') return '系统解析阶段'; if (['receipt', 'verification', 'reconciliation'].includes(normalized)) return '获取回执'; return '系统写入处理'; } function taskSourceInfo(task) { const source = task?.source === 'agentbus' ? 'agentbus' : 'manual'; return source === 'agentbus' ? { className: 'agentbus', label: 'AgentBus' } : { className: 'manual', label: '人工输入' }; } function configurePageMode() { document.documentElement.classList.toggle('parser-routing-page-active', IS_PARSER_ROUTING_PAGE); document.body.classList.toggle('history-page', IS_HISTORY_PAGE); document.body.classList.toggle('channels-page-active', IS_CHANNELS_PAGE); document.body.classList.toggle('parser-routing-page-active', IS_PARSER_ROUTING_PAGE); document.body.classList.toggle('accounts-page-active', IS_ACCOUNTS_PAGE); document.body.classList.toggle('audit-page-active', IS_AUDIT_PAGE); document.body.classList.toggle('operations-dashboard-page-active', IS_OPERATIONS_DASHBOARD_PAGE); const instructionPanel = document.querySelector('.instruction-panel'); const historyFilters = $('#historyFilters'); const channelsPage = $('#channelsPage'); const parserRoutingPage = $('#parserRoutingPage'); const accountsPage = $('#accountsPage'); const auditPage = $('#auditPage'); const operationsDashboardPage = $('#operationsDashboardPage'); const title = $('#taskListTitle'); const hint = $('#taskListHint'); const historyLink = $('#historyLink'); const workbenchNav = $('#workbenchNav'); const historyNav = $('#historyNav'); const channelsNav = $('#channelsNav'); const parserRoutingNav = $('#parserRoutingNav'); const accountsNav = $('#accountsNav'); const auditNav = $('#auditNav'); const operationsDashboardNav = $('#operationsDashboardNav'); if (instructionPanel) instructionPanel.hidden = IS_HISTORY_PAGE; if (channelsPage) channelsPage.hidden = !IS_CHANNELS_PAGE; if (parserRoutingPage) parserRoutingPage.hidden = !IS_PARSER_ROUTING_PAGE; if (accountsPage) accountsPage.hidden = !IS_ACCOUNTS_PAGE; if (auditPage) auditPage.hidden = !IS_AUDIT_PAGE; if (operationsDashboardPage) operationsDashboardPage.hidden = !IS_OPERATIONS_DASHBOARD_PAGE; if (historyFilters) historyFilters.hidden = !IS_HISTORY_PAGE; if (title) title.textContent = IS_HISTORY_PAGE ? '历史任务目录' : '任务卡片'; if (hint) hint.textContent = IS_HISTORY_PAGE ? (historyArchive === 'archived' ? '已归档任务' : '未归档任务') : '最近任务'; if (historyLink) { historyLink.href = IS_HISTORY_PAGE ? '/' : '/history'; historyLink.textContent = IS_HISTORY_PAGE ? '返回 AI 操作台' : '查看更多'; historyLink.hidden = !IS_HISTORY_PAGE && taskListMeta.total <= HOME_TASK_LIMIT; } if (workbenchNav) { const workbenchActive = !IS_HISTORY_PAGE && !IS_MANAGEMENT_PAGE; workbenchNav.classList.toggle('is-active', workbenchActive); if (!workbenchActive) workbenchNav.removeAttribute('aria-current'); else workbenchNav.setAttribute('aria-current', 'page'); } if (historyNav) { historyNav.classList.toggle('is-active', IS_HISTORY_PAGE); if (IS_HISTORY_PAGE) historyNav.setAttribute('aria-current', 'page'); else historyNav.removeAttribute('aria-current'); } if (channelsNav) { channelsNav.classList.toggle('is-active', IS_CHANNELS_PAGE); if (IS_CHANNELS_PAGE) channelsNav.setAttribute('aria-current', 'page'); else channelsNav.removeAttribute('aria-current'); } if (parserRoutingNav) { parserRoutingNav.classList.toggle('is-active', IS_PARSER_ROUTING_PAGE); if (IS_PARSER_ROUTING_PAGE) parserRoutingNav.setAttribute('aria-current', 'page'); else parserRoutingNav.removeAttribute('aria-current'); } if (accountsNav) { accountsNav.classList.toggle('is-active', IS_ACCOUNTS_PAGE); if (IS_ACCOUNTS_PAGE) accountsNav.setAttribute('aria-current', 'page'); else accountsNav.removeAttribute('aria-current'); } if (auditNav) { auditNav.classList.toggle('is-active', IS_AUDIT_PAGE); if (IS_AUDIT_PAGE) auditNav.setAttribute('aria-current', 'page'); else auditNav.removeAttribute('aria-current'); } if (operationsDashboardNav) { operationsDashboardNav.classList.toggle('is-active', IS_OPERATIONS_DASHBOARD_PAGE); if (IS_OPERATIONS_DASHBOARD_PAGE) operationsDashboardNav.setAttribute('aria-current', 'page'); else operationsDashboardNav.removeAttribute('aria-current'); } if (IS_ACCOUNTS_PAGE) document.title = '账号管理 · LianSyn-platform'; else if (IS_AUDIT_PAGE) document.title = '审计记录 · LianSyn-platform'; else if (IS_OPERATIONS_DASHBOARD_PAGE) document.title = '平台运行看板 · LianSyn-platform'; else if (IS_PARSER_ROUTING_PAGE) document.title = '解析策略 · LianSyn-platform'; else if (IS_CHANNELS_PAGE) document.title = 'AgentBus 渠道 · LianSyn-platform'; else if (IS_HISTORY_PAGE) document.title = '历史任务 · LianSyn-platform'; const searchInput = $('#historySearchInput'); const statusInput = $('#historyStatusInput'); const archiveInput = $('#historyArchiveInput'); if (searchInput && searchInput.value !== historySearch) searchInput.value = historySearch; if (statusInput && statusInput.value !== historyStatus) statusInput.value = historyStatus; if (archiveInput && archiveInput.value !== historyArchive) archiveInput.value = historyArchive; } function renderTaskListHeader() { const total = Math.max(taskListMeta.total, taskStore.length); const count = $('#taskCount'); if (count) { if (IS_HISTORY_PAGE) { count.textContent = `${total} 个任务`; } else if (total > HOME_TASK_LIMIT) { count.textContent = `最近 ${Math.min(taskStore.length, HOME_TASK_LIMIT)} 条 / 共 ${total} 个任务`; } else { count.textContent = `${Math.min(taskStore.length, HOME_TASK_LIMIT)} 个任务`; } } const historyLink = $('#historyLink'); if (historyLink) historyLink.hidden = !IS_HISTORY_PAGE && total <= HOME_TASK_LIMIT; } function renderHistoryPagination() { const pagination = $('#historyPagination'); if (!pagination) return; pagination.hidden = !IS_HISTORY_PAGE; if (!IS_HISTORY_PAGE) return; const total = taskListMeta.total; const start = total ? taskListMeta.offset + 1 : 0; const end = Math.min(taskListMeta.offset + taskStore.length, total); const summary = $('#historyPageSummary'); if (summary) summary.textContent = total ? `显示 ${start}-${end} 条,共 ${total} 条` : '暂无符合条件的任务'; const previous = $('#historyPreviousButton'); const next = $('#historyNextButton'); if (previous) previous.disabled = taskListMeta.offset <= 0; if (next) next.disabled = !taskListMeta.has_more; } function resetHistoryTaskSelection({ clearMessage = true } = {}) { historySelectedTaskIds.clear(); if (clearMessage) { historyBatchMessage = ''; historyBatchMessageKind = ''; } } function renderHistoryBatchActions(visibleTasks = []) { const actions = $('#historyBatchActions'); if (!actions) return; actions.hidden = !IS_HISTORY_PAGE; if (!IS_HISTORY_PAGE) return; const visibleTaskIds = new Set(visibleTasks.map((task) => task?.task_id).filter(Boolean)); for (const taskId of [...historySelectedTaskIds]) { if (!visibleTaskIds.has(taskId)) historySelectedTaskIds.delete(taskId); } const selectedCount = historySelectedTaskIds.size; const selectedTasks = visibleTasks.filter((task) => historySelectedTaskIds.has(task.task_id)); const allVisibleSelected = visibleTaskIds.size > 0 && selectedCount === visibleTaskIds.size; const selectAll = $('#historySelectAll'); if (selectAll) { selectAll.checked = allVisibleSelected; selectAll.indeterminate = selectedCount > 0 && !allVisibleSelected; selectAll.disabled = historyBulkDeleteBusy || visibleTaskIds.size === 0; } const summary = $('#historySelectionSummary'); if (summary) summary.textContent = `已选 ${selectedCount} 个`; const message = $('#historyBatchMessage'); if (message) { message.textContent = historyBatchMessage; message.classList.toggle('is-error', historyBatchMessageKind === 'error'); message.classList.toggle('is-success', historyBatchMessageKind === 'success'); } const archiveButton = $('#historyArchiveSelectedButton'); if (archiveButton) { const archiveBlocked = selectedTasks.some((task) => !canArchiveTask(task)); archiveButton.hidden = historyArchive === 'archived'; archiveButton.disabled = historyBulkDeleteBusy || selectedCount === 0 || archiveBlocked; archiveButton.textContent = historyBulkDeleteBusy && historyBulkAction === 'archive' ? `正在归档 ${selectedCount} 个…` : selectedCount > 0 ? `归档所选 (${selectedCount})` : '归档所选'; archiveButton.title = archiveBlocked ? '所选任务包含正在处理或等待 ERP 执行的任务,不能归档;可改用强制删除。' : ''; } const deleteButton = $('#historyDeleteSelectedButton'); if (deleteButton) { deleteButton.disabled = historyBulkDeleteBusy || selectedCount === 0; deleteButton.textContent = historyBulkDeleteBusy && historyBulkAction === 'delete' ? `正在强制删除 ${selectedCount} 个…` : selectedCount > 0 ? `强制删除所选 (${selectedCount})` : '强制删除所选'; } actions.setAttribute('aria-busy', historyBulkDeleteBusy ? 'true' : 'false'); } function taskParserBadge(task) { const parser = task?.parser || {}; if (Number(parser.unreviewed_difference_count || 0) > 0 || parser.comparison_status === 'different') { return { label: '影子差异', className: 'is-different' }; } if (parser.fallback_reason && parser.fallback_reason !== 'manual_ai_reparse') return { label: 'AI兜底', className: 'is-fallback' }; if (parser.authoritative_engine === 'program') return { label: '程序解析', className: 'is-program' }; if (parser.authoritative_engine === 'ai') return { label: 'AI解析', className: 'is-ai' }; return null; } function renderTaskCards() { const container = $('#taskCards'); if (!container) return; renderTaskListHeader(); renderHistoryPagination(); container.innerHTML = ''; const visibleTasks = IS_HISTORY_PAGE ? taskStore : taskStore.slice(0, HOME_TASK_LIMIT); renderHistoryBatchActions(visibleTasks); if (!visibleTasks.length) { const empty = document.createElement('div'); empty.className = 'task-empty'; empty.textContent = IS_HISTORY_PAGE && (historySearch || historyStatus) ? '暂无符合筛选条件的任务。' : '暂无任务。'; container.appendChild(empty); renderTaskDetail(); return; } for (const task of visibleTasks) { const archiveState = taskArchiveStates.get(task.task_id) || ''; const deleteState = taskDeleteStates.get(task.task_id) || ''; const card = document.createElement('article'); card.className = `task-card${task.task_id === currentTaskId ? ' is-selected' : ''}${historySelectedTaskIds.has(task.task_id) ? ' is-checked' : ''}`; card.dataset.taskId = task.task_id; const summary = task.summary || taskSummary(task.operation); const status = task.status || task.result?.status || 'created'; const source = taskSourceInfo(task); card.innerHTML = `
`; card.querySelector('.task-card-title').textContent = status === 'awaiting_attachment' ? '等待名单附件' : status === 'awaiting_user_input' ? '等待补充资料' : summary.operation_label || '输入解析任务'; card.querySelector('.task-source-tag').textContent = source.label; const parserBadge = taskParserBadge(task); const parserTag = card.querySelector('.task-parser-tag'); if (parserTag && parserBadge) { parserTag.hidden = false; parserTag.className = `task-parser-tag ${parserBadge.className}`; parserTag.textContent = parserBadge.label; } card.querySelector('.task-id').textContent = task.task_id; card.querySelector('.task-card-status .task-pill').textContent = taskStatusLabel(task); if (IS_HISTORY_PAGE) { card.classList.add('has-history-actions'); const selection = document.createElement('label'); selection.className = 'task-card-select'; selection.title = `选择任务 ${task.task_id}`; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.dataset.historyTaskSelect = task.task_id; checkbox.checked = historySelectedTaskIds.has(task.task_id); checkbox.disabled = historyBulkDeleteBusy || taskMutationInProgress(task.task_id); checkbox.setAttribute('aria-label', `选择任务 ${task.task_id}`); selection.append(checkbox); card.prepend(selection); const actions = document.createElement('div'); actions.className = 'task-card-actions'; const archiveButton = document.createElement('button'); archiveButton.type = 'button'; archiveButton.className = 'secondary-button task-card-archive'; archiveButton.dataset.historyTaskArchive = task.task_id; archiveButton.disabled = historyBulkDeleteBusy || taskMutationInProgress(task.task_id) || !canArchiveTask(task); archiveButton.textContent = archiveState === 'updating' ? (task.archived_at ? '恢复中…' : '归档中…') : archiveState === 'failed' ? (task.archived_at ? '重试恢复' : '重试归档') : (task.archived_at ? '恢复' : '归档'); archiveButton.setAttribute('aria-label', `${task.archived_at ? '恢复' : '归档'}任务 ${task.task_id}`); actions.append(archiveButton); const deleteButton = document.createElement('button'); deleteButton.type = 'button'; deleteButton.className = 'danger-button task-card-delete'; deleteButton.dataset.historyTaskDelete = task.task_id; deleteButton.disabled = historyBulkDeleteBusy || taskMutationInProgress(task.task_id); deleteButton.textContent = deleteState === 'deleting' ? '删除中…' : deleteState === 'failed' ? '重试删除' : '强制删除'; deleteButton.setAttribute('aria-label', `永久强制删除任务 ${task.task_id}`); actions.append(deleteButton); card.append(actions); } 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'}`; } function formatStatusTime(value) { if (!value) return '未记录'; const date = new Date(value); if (!Number.isFinite(date.getTime())) return String(value); return date.toLocaleString('zh-CN', { hour12: false }); } function statusBoolean(value, trueText = '正常', falseText = '异常', unknownText = '待检测') { if (value === true) return trueText; if (value === false) return falseText; return unknownText; } function statusDetailRow(label, value) { const row = document.createElement('div'); row.className = 'status-detail-row'; const name = document.createElement('span'); const detail = document.createElement('strong'); name.textContent = label; detail.textContent = value === undefined || value === null || value === '' ? '未提供' : String(value); row.append(name, detail); return row; } function setStatusExpanded(kind, expanded) { const button = kind === 'ai' ? $('#aiState') : $('#bridgeState'); if (button) button.setAttribute('aria-expanded', expanded ? 'true' : 'false'); } function closeStatusDetails() { const popover = $('#statusDetailsPopover'); if (popover) popover.hidden = true; setStatusExpanded('ai', false); setStatusExpanded('plugin', false); activeStatusDetails = ''; } function renderStatusDetails() { const popover = $('#statusDetailsPopover'); const title = $('#statusDetailsTitle'); const body = $('#statusDetailsBody'); const refresh = $('#statusDetailsRefresh'); if (!popover || !title || !body || !refresh || !activeStatusDetails) return; body.replaceChildren(); const summary = document.createElement('div'); summary.className = 'status-detail-summary state-warn'; if (activeStatusDetails === 'ai') { const status = latestAiStatus; const probe = status?.ai_probe || {}; title.textContent = 'AI 状态详情'; refresh.textContent = '重新检测 AI'; if (!status) { summary.textContent = '正在检测服务链路…'; } else if (status.ai_connected === true) { summary.className = 'status-detail-summary state-ok'; summary.textContent = '服务链路已连接'; } else { summary.className = 'status-detail-summary state-bad'; summary.textContent = '服务链路未连接'; } body.append( summary, statusDetailRow('控制平面 / 数据库', statusBoolean(status?.database_ready, '正常', '不可用')), statusDetailRow('AI 配置', statusBoolean(status?.ai_configured, '已配置', '未配置')), statusDetailRow('外部服务', statusBoolean(status?.ai_reachable, '可达', '不可达')), statusDetailRow( '鉴权证据', status?.ai_authenticated === true ? '已由成功任务验证' : status?.ai_authenticated === false ? '历史验证失败(不影响链路状态)' : '待真实任务验证' ), statusDetailRow('上游 HTTP', probe.http_status ? `HTTP ${probe.http_status}` : '未返回'), statusDetailRow('最后成功解析', formatStatusTime(probe.last_success_at)), statusDetailRow('最后鉴权失败', formatStatusTime(probe.last_auth_failure_at)), statusDetailRow('最近错误', status?.diagnostic_error || probe.error_code || probe.last_auth_failure_code || '无'), statusDetailRow('检查时间', formatStatusTime(status?.checked_at)) ); } else { const status = latestBridgeStatus; const recoveryStatus = String(status?.erpLinkRecovery?.status || status?.erpSession?.erp_keepalive?.recovery_status || ''); const recoveryPending = ['scheduled', 'already_running', 'running'].includes(recoveryStatus); const hasWarning = status?.connected && (!status?.compatible || !status?.automationEnabled || !status?.erpHostAccessReady || status?.erpSessionReady === false || recoveryPending); title.textContent = '插件状态详情'; refresh.textContent = '重新检测插件'; if (!status) { summary.textContent = '正在检测页面桥接…'; } else if (!status.connected) { summary.className = 'status-detail-summary state-bad'; summary.textContent = '插件桥接未连接'; } else if (hasWarning) { summary.className = 'status-detail-summary state-warn'; summary.textContent = '插件已连接,但存在告警'; } else { summary.className = 'status-detail-summary state-ok'; summary.textContent = '插件桥接已连接'; } body.append( summary, statusDetailRow('页面桥接', statusBoolean(status?.connected, '已响应', '未响应')), statusDetailRow('插件版本', status?.version || '未知'), statusDetailRow('最低版本', REQUIRED_EXTENSION_VERSION), statusDetailRow('版本兼容', statusBoolean(status?.compatible, '兼容', '需要更新')), statusDetailRow('ERP 自动化', statusBoolean(status?.automationEnabled, '已开启', '已关闭')), statusDetailRow('ERP 页面权限', statusBoolean(status?.erpHostAccessReady, '可访问', '需在扩展设置中允许')), statusDetailRow('ERP 会话', statusBoolean(status?.erpSessionReady, '正常', '异常或待登录')), statusDetailRow('ERP 账号匹配', authUser?.erp_account ? statusBoolean(status?.erpAccountMatched, '与平台绑定一致', `应登录 ${authUser.erp_account}`) : '管理员不绑定员工 ERP 账号'), statusDetailRow('ERP 链路恢复', recoveryStatus || '未触发'), statusDetailRow('安装时间', formatStatusTime(status?.installedAt)), statusDetailRow('最近错误', status?.error || '无'), statusDetailRow('检查时间', formatStatusTime(status?.checkedAt)) ); } } async function toggleStatusDetails(kind) { const popover = $('#statusDetailsPopover'); if (!popover) return; if (!popover.hidden && activeStatusDetails === kind) { closeStatusDetails(); return; } setStatusExpanded('ai', kind === 'ai'); setStatusExpanded('plugin', kind === 'plugin'); activeStatusDetails = kind; popover.hidden = false; renderStatusDetails(); if (kind === 'ai') await pingAi(); else await pingBridge(); } async function pingAi() { try { const response = await fetchWithTimeout('/api/status', { cache: 'no-store' }, DEFAULT_API_TIMEOUT_MS); const result = await response.json(); latestAiStatus = { ...result, checked_at: result.checked_at || new Date().toISOString(), diagnostic_error: response.ok ? '' : result.message || result.error || `HTTP ${response.status}` }; if (!response.ok || result.ai_connected !== true) { setAiState('未连接', 'state-bad'); renderStatusDetails(); return false; } setAiState('已连接', 'state-ok'); renderStatusDetails(); return true; } catch (error) { latestAiStatus = { ai_connected: false, diagnostic_error: error.message || String(error), checked_at: new Date().toISOString() }; setAiState('未连接', 'state-bad'); renderStatusDetails(); return false; } } function applyBridgePayload(payload = {}) { const wasBridgeConnected = bridgeConnected; const previousBridgeInstalledAt = String(latestBridgeStatus?.installedAt || ''); bridgeConnected = Boolean(payload.ok); if (!bridgeConnected) { erpAutomationEnabled = false; erpHostAccessReady = false; erpSessionReady = false; extensionCompatible = false; latestBridgeStatus = { connected: false, compatible: false, automationEnabled: false, version: payload.version || '', installedAt: payload.bridge_installed_at || '', error: payload.message || payload.error || '插件桥接未返回成功状态。', checkedAt: new Date().toISOString() }; setBridgeState('未连接', 'state-bad'); renderStatusDetails(); return false; } const bridgeInstalledAt = String(payload.bridge_installed_at || ''); const erpSession = payload.erp_session || {}; const erpAccessError = new Set([ 'erp_host_permission_missing', 'erp_host_not_allowlisted', 'erp_page_access_denied' ]).has(String(erpSession.error_code || '')); erpHostAccessReady = erpSession.host_permission_granted !== false && !erpAccessError; const erpAccountMatched = !authUser?.erp_account || erpSession.account_matched === true; erpSessionReady = erpSession.session_ready !== false && erpSession.erp_keepalive?.last_status !== 'session_expired' && erpAccountMatched; const recoveryStatus = String(erpSession.erp_link_recovery?.status || erpSession.erp_keepalive?.recovery_status || ''); const erpLinkRecoveryPending = ['scheduled', 'already_running', 'running'].includes(recoveryStatus); 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; erpAutomationEnabled = payload.erp_automation_enabled !== false; latestBridgeStatus = { connected: true, compatible: extensionCompatible, automationEnabled: erpAutomationEnabled, erpHostAccessReady, erpSessionReady, erpAccountMatched, erpSession, erpLinkRecovery: { status: recoveryStatus || 'not_started', pending: erpLinkRecoveryPending }, version: payload.version || '', installedAt: bridgeInstalledAt, error: !extensionCompatible ? `插件版本低于最低要求 ${REQUIRED_EXTENSION_VERSION}。` : !erpAutomationEnabled ? 'ERP 自动化开关已关闭。' : !erpHostAccessReady ? erpSession.message || '扩展暂时没有 ERP 页面访问权限。' : !erpAccountMatched ? `当前 ERP 登录账号与平台绑定的“${authUser?.erp_account || ''}”不一致。` : !erpSessionReady ? erpSession.message || 'ERP 会话异常,插件已尝试刷新现有 ERP 链路;请确认 ERP 已登录。' : erpLinkRecoveryPending ? '已检测到 ERP 链路异常,插件正在刷新并进行只读复测。' : '', checkedAt: new Date().toISOString() }; if (!extensionCompatible || !erpAutomationEnabled || !erpHostAccessReady || !erpSessionReady || erpLinkRecoveryPending) { setBridgeState('已连接,有告警', 'state-warn'); } else { setBridgeState('已连接', 'state-ok'); } renderStatusDetails(); const bridgeReadyForDispatch = !wasBridgeConnected || Boolean(bridgeInstalledAt && bridgeInstalledAt !== previousBridgeInstalledAt); queueMicrotask(() => autoDispatchReadyTasks({ force: bridgeReadyForDispatch }).catch(() => {})); 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 setOutput(output) { 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) { if (!canUseTaskDataPlane()) { return Promise.reject(new Error('管理员账号不能连接或调用任务执行插件。')); } 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 (!canUseTaskDataPlane()) return; if (message.type === 'BRIDGE_READY') { // The announcement has no expected ERP account. Verify this session rather // than overwriting an account-bound result with its unbound snapshot. pingBridge().catch(() => {}); return; } if (message.type === 'TASK_RESULT_CHANGED') { const taskId = String(message.payload?.task_id || '').trim(); const result = message.payload?.result; if (taskId && result && typeof result === 'object' && extensionTaskBelongsToCurrentAccount(taskId)) { queueExtensionTaskResult(taskId, result).catch(() => {}); } return; } const waiter = pending.get(message.requestId); if (!waiter) return; if (message.type === 'ERROR' && /Extension context invalidated/i.test(String(message.payload?.message || ''))) { // A stale content-script handler can answer after an extension reload; // keep the request open for the current bridge response. 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; const needsInput = result.status === 'agent_parse_needs_input'; updateLocalTask(taskId, { status: needsInput ? 'awaiting_user_input' : hasBlockers ? 'parse_failed' : 'running', stage: 'parse', message: needsInput ? '系统需要用户补充信息。' : hasBlockers ? '系统已返回解析阻断。' : '系统已返回解析结果,正在整理业务字段。', error: hasBlockers ? result.blockers.join(';') : '', parse_response: result }); setTaskState(needsInput ? '等待用户补充' : hasBlockers ? '系统解析已阻断' : '已收到解析结果'); setOutput({ status: needsInput ? 'parse_response_needs_input' : 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 (!taskAssignedToCurrentAccount(task)) { throw new Error('该任务分配给其他平台账号,当前云电脑只能查看,不能执行。'); } const automaticTask = isAutomaticTask(task); if (!task?.confirmed_at) { throw new Error(automaticTask ? '自动任务缺少确认时间,已停止下发。' : '任务尚未人工确认,不能提交到 ERP 插件。'); } if (task.status !== 'confirmed' || task.handoff_status !== 'awaiting_handoff') { throw new Error('任务已领取或已进入插件执行流程,系统不会再次提交。'); } if (!bridgeConnected || !extensionCompatible) { if (automaticTask) { updateLocalTask(task.task_id, { message: `全自动化已接管,等待插件连接或版本升级(最低 ${REQUIRED_EXTENSION_VERSION})。`, error: '插件不可用。' }); } else { updateLocalTask(task.task_id, { status: 'waiting_extension', stage: 'bridge', message: `任务已确认,但插件未连接或版本低于 ${REQUIRED_EXTENSION_VERSION};本次未提交,请连接插件后再次点击。`, error: '插件不可用。', handoff_status: 'waiting_extension' }); } throw new Error('插件未连接或版本过低,任务已保留且未提交到插件。'); } const erpLinkRecoveryPending = latestBridgeStatus?.erpLinkRecovery?.pending === true; if (!erpHostAccessReady || !erpSessionReady || erpLinkRecoveryPending) { const message = !erpHostAccessReady ? '扩展当前没有 ERP 页面访问权限,任务已保留且未提交;请在 chrome://extensions 中允许 ERP 网站访问并重载扩展。' : erpLinkRecoveryPending ? '插件正在刷新 ERP 链路并进行只读复测,任务已保留且未提交;请稍后重新检测。' : 'ERP 会话当前异常,插件已尝试刷新现有 ERP 链路;请确认 ERP 已登录后再提交。'; updateLocalTask(task.task_id, automaticTask ? { message, error: message } : { status: 'waiting_extension', stage: 'bridge', message, error: message, handoff_status: 'waiting_extension' }); throw new Error(message); } if (!erpAutomationEnabled) { if (automaticTask) { updateLocalTask(task.task_id, { message: '全自动化已接管,但插件 ERP 操作开关已关闭,等待恢复后继续。', error: '插件 ERP 操作开关已关闭。' }); throw new Error('插件 ERP 操作开关已关闭,自动任务暂未下发。'); } 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: browserConnectionId } }); if (claim.task) upsertLocalTask(claim.task); if (!claim.claimed) { if (claim.queue_status === 'waiting') { const queuePosition = Number(claim.queue_position || 0); updateLocalTask(task.task_id, { status: 'confirmed', stage: 'handoff', handoff_status: 'awaiting_handoff', message: queuePosition > 0 ? `当前账号的插件正在处理其他任务,本任务在该账号队列第 ${queuePosition} 位。` : '当前账号的插件正在处理其他任务,任务已进入该账号的持久化队列。' }); return { ok: true, task_id: task.task_id, status: 'queued', queue_status: 'waiting', queue_position: claim.queue_position, dispatched: false, message: '同一账号的插件同一时间只处理一个任务;不同账号互不排队。' }; } 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 }, BRIDGE_TASK_ACCEPT_TIMEOUT_MS); if (result?.accepted === false || result?.status === 'duplicate_blocked') { throw new Error(result.message || '插件检测到重复任务,已阻止再次执行。'); } currentTaskId = result.task_id || task.task_id; sessionStorage.setItem('liansyn_platform_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: browserConnectionId, execution_id: claim.execution_id, result: uncertainResult } }); if (persisted.task) upsertLocalTask(persisted.task, { forceVisible: false }); } 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 confirmAndSubmitToErpPlugin(task) { task = await ensureTaskDetails(task?.task_id); if (!requiresManualConfirmation(task)) return; if (!taskAssignedToCurrentAccount(task)) { throw new Error('该任务分配给其他平台账号,当前账号只能查看,不能确认或执行。'); } const bridgeReady = await pingBridge(); if (!bridgeReady || !extensionCompatible || !erpAutomationEnabled || !erpHostAccessReady || !erpSessionReady || latestBridgeStatus?.erpLinkRecovery?.pending === true) { throw new Error(`插件未连接、版本低于 ${REQUIRED_EXTENSION_VERSION}、ERP 页面/会话不可用或 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_and_handed_off', 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() { if (!canUseTaskDataPlane()) return false; const user = authUser; const connectionId = browserConnectionId; if (bridgePingInFlight?.user === user && bridgePingInFlight.connectionId === connectionId) { return bridgePingInFlight.promise; } const probe = { user, connectionId, promise: null }; probe.promise = checkBridgeConnection(user, connectionId).finally(() => { if (bridgePingInFlight === probe) bridgePingInFlight = null; }); bridgePingInFlight = probe; return probe.promise; } async function checkBridgeConnection(user, connectionId) { const isCurrentSession = () => authUser === user && browserConnectionId === connectionId && canUseTaskDataPlane(); try { const result = await sendToExtension('PING', { expected_erp_account: user.erp_account || '' }, BRIDGE_PING_TIMEOUT_MS); if (!isCurrentSession()) return false; applyBridgePayload(result); if (result?.ok) { try { const heartbeat = await apiRequest('/api/connections/heartbeat', { method: 'POST', body: { connection_id: connectionId, extension_version: result.version || '', erp_account: user.erp_account || '', erp_account_matched: result.erp_session?.account_matched === true, metadata: { bridge_installed_at: result.bridge_installed_at || '' } } }); if (!isCurrentSession()) return false; if (heartbeat.execution_ready !== true) { erpSessionReady = false; latestBridgeStatus = { ...latestBridgeStatus, erpSessionReady: false, erpAccountMatched: heartbeat.erp_account_matched === true, error: authUser.erp_account ? `当前 ERP 登录账号与平台绑定的“${authUser.erp_account}”不一致。` : '当前云电脑尚未满足执行条件。' }; setBridgeState('已连接,有告警', 'state-warn'); renderStatusDetails(); return false; } } catch (heartbeatError) { if (!isCurrentSession()) return false; erpSessionReady = false; latestBridgeStatus = { ...latestBridgeStatus, erpSessionReady: false, error: heartbeatError.message || String(heartbeatError) }; setBridgeState('已连接,有告警', 'state-warn'); renderStatusDetails(); return false; } } return bridgeConnected && erpSessionReady; } catch (error) { if (!isCurrentSession()) return false; bridgeConnected = false; erpAutomationEnabled = false; extensionCompatible = false; latestBridgeStatus = { connected: false, compatible: false, automationEnabled: false, version: '', installedAt: '', error: error.message || String(error), checkedAt: new Date().toISOString() }; setBridgeState('未连接', 'state-bad'); renderStatusDetails(); return false; } } async function createTask() { if (!authUser) { showLoginPanel('请先登录。'); return; } if (!canUseTaskDataPlane()) { window.location.replace('/accounts'); return; } if (taskCreateInProgress) return; const rawText = $('#rawInstruction').value; const fileInput = $('#rosterAttachment'); const statusNode = $('#rosterAttachmentStatus'); const button = $('#createTaskButton'); const selectedFile = fileInput?.files?.[0]; taskCreateInProgress = true; if (button) button.disabled = true; if (statusNode) statusNode.textContent = selectedFile ? '正在读取并提交名单附件…' : '正在创建任务…'; try { const rosterAttachment = selectedFile ? await encodeRosterAttachment(selectedFile) : null; const task = await apiRequest('/api/tasks', { method: 'POST', timeoutMs: rosterAttachment ? TASK_DELETE_API_TIMEOUT_MS : undefined, body: { raw_text: rawText, attachments: rosterAttachment ? [rosterAttachment] : undefined, idempotency_key: `create:${makeRequestId()}` } }); const remoteTask = task.task; currentTaskId = remoteTask.task_id; sessionStorage.setItem('liansyn_platform_current_task_id', currentTaskId); upsertLocalTask(remoteTask); if (fileInput) fileInput.value = ''; if (statusNode) { statusNode.textContent = remoteTask.status === 'awaiting_attachment' ? '名单任务已创建,请在任务详情中补交 Excel 附件。' : rosterAttachment ? '名单附件已随任务提交。' : ''; } setTaskState(remoteTask.status === 'awaiting_attachment' ? '等待名单附件' : '任务已入队'); setOutput({ status: 'task_created', task_id: currentTaskId, message: remoteTask.message || '任务已写入平台数据库,等待服务端处理。' }); startRemoteEventStream(); } catch (error) { if (statusNode) statusNode.textContent = error.message || String(error); throw error; } finally { taskCreateInProgress = false; if (button?.isConnected) button.disabled = false; } } async function refreshBackgroundState() { if (!authUser) return; // Worker liveness must not wait for task-list/database synchronization. // pingBridge coalesces timer, focus and manual checks for this session. const bridgeRefresh = canUseTaskDataPlane() ? pingBridge() : Promise.resolve(false); if (backgroundRefreshInProgress) { await bridgeRefresh; return; } backgroundRefreshInProgress = true; try { const operations = [pingAi(), bridgeRefresh]; if (isAdministrator()) operations.push(syncAutomationSettings({ background: true })); if (IS_CHANNELS_PAGE && isAdministrator()) { operations.push(syncChannels(), syncLeaderSummaryWebhookStatus()); } if (IS_TASK_PAGE && canUseTaskDataPlane()) operations.push(syncRemoteTasks()); await Promise.allSettled(operations); } finally { backgroundRefreshInProgress = false; } } 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 }); } function confirmTaskArchive(tasks) { const selectedTasks = Array.isArray(tasks) ? tasks.filter(Boolean) : []; if (!selectedTasks.length) return false; const scope = selectedTasks.length === 1 ? `任务 ${selectedTasks[0].task_id}` : `所选 ${selectedTasks.length} 个任务`; return window.confirm( `确认归档${scope}?\n\n归档后任务不会出现在日常列表中,但创建人、原始输入、生命周期、附件和回执都会保留,可从“已归档”范围恢复。` ); } function confirmTaskHardDelete(tasks) { const selectedTasks = Array.isArray(tasks) ? tasks.filter(Boolean) : []; if (!selectedTasks.length) return false; const scope = selectedTasks.length === 1 ? `任务 ${selectedTasks[0].task_id}` : `所选 ${selectedTasks.length} 个任务`; return window.confirm( `确认永久强制删除${scope}?\n\n任务、原始输入、生命周期、执行记录、附件和任务回执都会被物理删除,无法恢复。独立保存的团队办理进度、安排明细、变更历史及成功凭据会保留;导出文件记录保留,文件附件仍会删除。此操作不受“正在处理”或“等待 ERP 执行”状态限制。\n\n如果 ERP 已经开始写入,删除平台记录不会撤销 ERP 中已经发生的操作;系统只会向任务所属账号的在线插件发送停止与清理指令。已经由外部 Webhook 受理并可能到达组长微信群的任务摘要也无法撤回。` ); } function removeTaskLocally(taskId) { const wasVisible = taskStore.some((item) => item.task_id === taskId); taskArchiveStates.delete(taskId); taskDeleteStates.delete(taskId); historySelectedTaskIds.delete(taskId); taskStore = taskStore.filter((item) => item.task_id !== taskId); runtimeTaskStore.delete(taskId); remoteTaskStore.delete(taskId); taskDetailStore.delete(taskId); taskDetailRequests.delete(taskId); taskInputHistoryStore.delete(taskId); taskInputHistoryRequests.delete(taskId); localTaskOverlayStore.delete(taskId); pendingExtensionResults.delete(taskId); persistedExtensionResultVersions.delete(taskId); extensionResultPersistQueues.delete(taskId); autoHandoffRetryAt.delete(taskId); taskReconciliationStates.delete(taskId); taskReplyStates.delete(taskId); taskReplyDrafts.delete(taskId); taskAttachmentDrafts.delete(taskId); lifecycleScrollPositions.delete(taskId); if (wasVisible) taskListMeta.total = Math.max(0, taskListMeta.total - 1); } function finishLocalTaskMutations() { if (!taskStore.some((item) => item.task_id === currentTaskId)) { currentTaskId = taskStore[0]?.task_id || ''; } if (currentTaskId) sessionStorage.setItem('liansyn_platform_current_task_id', currentTaskId); else sessionStorage.removeItem('liansyn_platform_current_task_id'); saveTaskStore(); if (!hasPollableRuntimeTasks()) stopPolling(); renderTaskCards(); } async function cleanupDeletedTasksInExtension(tasks) { const selectedTasks = Array.isArray(tasks) ? tasks.filter((task) => task?.task_id) : []; const executableTasks = selectedTasks.filter(taskAssignedToCurrentAccount); const results = await Promise.allSettled( executableTasks.map((task) => sendToExtension('DELETE_TASK', { task_id: task.task_id }, 3_000)) ); return { requested: executableTasks.length, skipped: selectedTasks.length - executableTasks.length, failed: results.filter((result) => result.status === 'rejected').length }; } async function handleBrowserCommand(event) { let command = null; try { command = JSON.parse(String(event?.data || '')); } catch (error) { return; } if (command?.action !== 'hard_delete' || command?.target_user_id !== authUser?.id) return; const taskId = String(command.task_id || '').trim(); if (!taskId) return; try { await sendToExtension('DELETE_TASK', { task_id: taskId }, 3_000); } catch (error) { // The server-side force delete remains authoritative even if this online // browser cannot acknowledge cancellation of its own local plugin task. } locallyDeletedTaskIds.add(taskId); removeTaskLocally(taskId); finishLocalTaskMutations(); void syncRemoteTasks().catch(() => {}); } async function toggleTaskArchive(taskId) { const task = findTaskById(taskId); if (!task) return; if (taskMutationInProgress(taskId)) return; taskArchiveStates.set(taskId, 'updating'); renderTaskCards(); try { const restoring = Boolean(task.archived_at); await apiRequest( restoring ? `/api/tasks/${encodeURIComponent(taskId)}/restore` : `/api/tasks/${encodeURIComponent(taskId)}/archive`, { method: 'POST', body: {}, timeoutMs: TASK_DELETE_API_TIMEOUT_MS }); removeTaskLocally(taskId); if (IS_HISTORY_PAGE) { historyBatchMessage = `任务 ${taskId} 已${restoring ? '恢复' : '归档'}。`; historyBatchMessageKind = 'success'; } finishLocalTaskMutations(); void syncRemoteTasks().catch(() => {}); } catch (error) { taskArchiveStates.set(taskId, 'failed'); if (IS_HISTORY_PAGE) { historyBatchMessage = `任务 ${taskId} 操作失败:${error.message || String(error)}`; historyBatchMessageKind = 'error'; } if (currentTaskId === taskId) setTaskState('归档/恢复失败'); setOutput({ status: 'archive_error', task_id: taskId, message: `任务归档/恢复失败:${error.message || String(error)}` }); renderTaskCards(); if (currentTaskId === taskId) setTaskState('归档/恢复失败'); throw error; } } async function hardDeleteTask(taskId) { const task = findTaskById(taskId); if (!task || taskMutationInProgress(taskId)) return; taskDeleteStates.set(taskId, 'deleting'); renderTaskCards(); try { await apiRequest(`/api/tasks/${encodeURIComponent(taskId)}`, { method: 'DELETE', timeoutMs: TASK_DELETE_API_TIMEOUT_MS }); const extensionCleanup = await cleanupDeletedTasksInExtension([task]); locallyDeletedTaskIds.add(taskId); removeTaskLocally(taskId); if (IS_HISTORY_PAGE) { historyBatchMessage = extensionCleanup.failed ? `任务 ${taskId} 已永久删除;本机插件未确认清理,请确认 ERP 当前状态。` : `任务 ${taskId} 已永久删除。`; historyBatchMessageKind = 'success'; } finishLocalTaskMutations(); void syncRemoteTasks().catch(() => {}); } catch (error) { taskDeleteStates.set(taskId, 'failed'); if (IS_HISTORY_PAGE) { historyBatchMessage = `任务 ${taskId} 强制删除失败:${error.message || String(error)}`; historyBatchMessageKind = 'error'; } if (currentTaskId === taskId) setTaskState('强制删除失败'); setOutput({ status: 'hard_delete_error', task_id: taskId, message: `任务强制删除失败:${error.message || String(error)}` }); renderTaskCards(); if (currentTaskId === taskId) setTaskState('强制删除失败'); throw error; } } async function archiveHistorySelectedTasks() { if (!IS_HISTORY_PAGE || historyBulkDeleteBusy) return; const selectedTasks = taskStore.filter((task) => historySelectedTaskIds.has(task.task_id)); if (!selectedTasks.length || !confirmTaskArchive(selectedTasks)) return; const taskIds = selectedTasks.map((task) => task.task_id); historyBulkDeleteBusy = true; historyBulkAction = 'archive'; historyBatchMessage = `正在归档 ${taskIds.length} 个任务…`; historyBatchMessageKind = ''; for (const task of selectedTasks) { taskArchiveStates.set(task.task_id, 'updating'); } renderTaskCards(); try { await apiRequest('/api/tasks/bulk-archive', { method: 'POST', body: { task_ids: taskIds }, timeoutMs: TASK_DELETE_API_TIMEOUT_MS }); for (const taskId of taskIds) removeTaskLocally(taskId); resetHistoryTaskSelection({ clearMessage: false }); historyBatchMessage = `已归档 ${taskIds.length} 个任务。`; historyBatchMessageKind = 'success'; finishLocalTaskMutations(); void syncRemoteTasks().catch(() => {}); } catch (error) { for (const taskId of taskIds) taskArchiveStates.set(taskId, 'failed'); historyBatchMessage = `批量归档失败,所选任务均未变更:${error.message || String(error)}`; historyBatchMessageKind = 'error'; setTaskState('批量归档失败'); setOutput({ status: 'bulk_archive_error', task_ids: taskIds, message: historyBatchMessage }); void syncRemoteTasks().catch(() => {}); } finally { historyBulkDeleteBusy = false; historyBulkAction = ''; renderTaskCards(); } } async function deleteHistorySelectedTasks() { if (!IS_HISTORY_PAGE || historyBulkDeleteBusy) return; const selectedTasks = taskStore.filter((task) => historySelectedTaskIds.has(task.task_id)); if (!selectedTasks.length || !confirmTaskHardDelete(selectedTasks)) return; const taskIds = selectedTasks.map((task) => task.task_id); historyBulkDeleteBusy = true; historyBulkAction = 'delete'; historyBatchMessage = `正在强制删除 ${taskIds.length} 个任务…`; historyBatchMessageKind = ''; for (const taskId of taskIds) taskDeleteStates.set(taskId, 'deleting'); renderTaskCards(); try { await apiRequest('/api/tasks/bulk-delete', { method: 'POST', body: { task_ids: taskIds }, timeoutMs: TASK_DELETE_API_TIMEOUT_MS }); const extensionCleanup = await cleanupDeletedTasksInExtension(selectedTasks); for (const taskId of taskIds) { locallyDeletedTaskIds.add(taskId); removeTaskLocally(taskId); } resetHistoryTaskSelection({ clearMessage: false }); historyBatchMessage = extensionCleanup.failed ? `已永久删除 ${taskIds.length} 个任务;其中 ${extensionCleanup.failed} 个未获得本机插件清理确认,请确认 ERP 当前状态。` : `已永久删除 ${taskIds.length} 个任务。`; historyBatchMessageKind = 'success'; finishLocalTaskMutations(); void syncRemoteTasks().catch(() => {}); } catch (error) { for (const taskId of taskIds) taskDeleteStates.set(taskId, 'failed'); historyBatchMessage = `批量强制删除失败,所选任务均未删除:${error.message || String(error)}`; historyBatchMessageKind = 'error'; setTaskState('批量强制删除失败'); setOutput({ status: 'bulk_hard_delete_error', task_ids: taskIds, message: historyBatchMessage }); void syncRemoteTasks().catch(() => {}); } finally { historyBulkDeleteBusy = false; historyBulkAction = ''; renderTaskCards(); } } async function archiveSelectedTask() { const task = selectedTask(); if (!task) return; return toggleTaskArchive(task.task_id); } async function deleteSelectedTask() { const task = selectedTask(); if (!task) return; return hardDeleteTask(task.task_id); } function extensionResultVersion(result) { return [ result?.execution_id || '', result?.updated_at || '', result?.stage || '', result?.status || '' ].join('|'); } async function persistExtensionTaskResult(taskId, result) { if (!extensionTaskBelongsToCurrentAccount(taskId)) return false; const dispatchPending = isDispatchResultPending(findTaskById(taskId)); // Dispatch uncertainty only needs a stored terminal outcome. Ordinary // progress must not turn a waiting task back into an executable task. if (dispatchPending && !['blocked', 'completed'].includes(result.status)) return false; try { const executionId = 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: browserConnectionId, execution_id: executionId, result } }); if (persisted.task) upsertLocalTask(persisted.task, { forceVisible: false }); return true; } catch (error) { updateLocalTask(taskId, { ...(dispatchPending ? {} : { result, status: result.status || 'running', stage: result.stage || '' }), message: `插件已返回结果,但服务端持久化失败:${error.message}`, error: error.message }); return false; } } function queueExtensionTaskResult(taskId, result) { const normalizedTaskId = String(taskId || '').trim(); if (!normalizedTaskId || !result || typeof result !== 'object' || !extensionTaskBelongsToCurrentAccount(normalizedTaskId)) return Promise.resolve(false); const version = extensionResultVersion(result); pendingExtensionResults.set(normalizedTaskId, { result, version }); const existing = extensionResultPersistQueues.get(normalizedTaskId); if (existing) return existing; const queued = (async () => { let latestOutcome = true; while (pendingExtensionResults.has(normalizedTaskId)) { const candidate = pendingExtensionResults.get(normalizedTaskId); pendingExtensionResults.delete(normalizedTaskId); if (persistedExtensionResultVersions.get(normalizedTaskId) === candidate.version) continue; latestOutcome = await persistExtensionTaskResult(normalizedTaskId, candidate.result); if (latestOutcome) persistedExtensionResultVersions.set(normalizedTaskId, candidate.version); else if (!pendingExtensionResults.has(normalizedTaskId)) break; } return latestOutcome; })(); extensionResultPersistQueues.set(normalizedTaskId, queued); queued.finally(() => { if (extensionResultPersistQueues.get(normalizedTaskId) === queued) { extensionResultPersistQueues.delete(normalizedTaskId); } const pendingResult = pendingExtensionResults.get(normalizedTaskId); if (pendingResult) queueExtensionTaskResult(normalizedTaskId, pendingResult.result).catch(() => {}); }).catch(() => {}); return queued; } async function pollTaskResult(taskId, { allowReconciliation = false } = {}) { const knownTask = findTaskById(taskId); if (!knownTask || !taskAssignedToCurrentAccount(knownTask)) return null; if (!allowReconciliation && knownTask) { const localStatuses = [knownTask.status, knownTask.result?.status, knownTask.handoff_status] .map((value) => String(value || '').toLowerCase()); if (localStatuses.some((status) => MANUAL_RECONCILIATION_STATUSES.has(status)) && !isDispatchResultPending(knownTask)) return null; } const response = await sendToExtension('GET_TASK_RESULT', { task_id: taskId }, 3000); if (response.result) await queueExtensionTaskResult(taskId, response.result); return response.result || null; } async function reconcileTaskReceipt(task) { if (!canReconcileTask(task)) throw new Error('当前任务不在可只读回查的生命周期不确定状态。'); if (!taskAssignedToCurrentAccount(task)) throw new Error('该任务分配给其他平台账号,当前云电脑不能执行回查。'); const bridgeReady = await pingBridge(); if (!bridgeReady || !extensionCompatible || !erpHostAccessReady) throw new Error(`插件未连接、版本低于 ${REQUIRED_EXTENSION_VERSION} 或 ERP 页面权限不可用,未执行回查。`); taskReconciliationStates.set(task.task_id, 'running'); renderTaskDetail(); try { if (isDispatchResultPending(task)) { // The plugin may already be blocked before ERP resolution. Read its // existing result instead of invoking the post-write reconciliation API. const result = await pollTaskResult(task.task_id, { allowReconciliation: true }); await syncRemoteTasks(); setTaskState(isDispatchResultPending(findTaskById(task.task_id)) ? '尚未取得可确认的插件结果' : '插件结果已同步'); return result; } const result = await sendToExtension('RECONCILE_TASK', { task_id: task.task_id }, 60_000); await pollTaskResult(task.task_id, { allowReconciliation: true }); await syncRemoteTasks(); setTaskState(result?.reconciliation_resolved ? '回查已收敛' : '仍待回查'); return result; } finally { taskReconciliationStates.delete(task.task_id); renderTaskDetail(); } } async function resumePrewriteTaskExecution(task) { if (!canResumePrewriteTask(task)) throw new Error('当前任务没有可证明的保存前零写状态,禁止恢复。'); if (!taskAssignedToCurrentAccount(task)) throw new Error('该任务分配给其他平台账号,当前云电脑不能恢复执行。'); const response = await sendToExtension('RESUME_PREWRITE_TASK', { task_id: task.task_id }, 3000); updateLocalTask(task.task_id, { status: 'running', stage: 'resume_prewrite', message: response?.message || '已请求恢复保存前任务,等待插件执行结果。', error: '' }); startPolling(); setTaskState('恢复执行中'); return response; } async function pollAllTaskResults() { if (!canUseTaskDataPlane() || !bridgeConnected || pollInProgress) return; pollInProgress = true; try { const activeTasks = runtimeTasks().filter((task) => taskAssignedToCurrentAccount(task) && isTaskPollable(task)); for (const task of activeTasks) { try { await pollTaskResult(task.task_id); } catch (error) { updateLocalTask(task.task_id, { message: `查询插件反馈失败:${error.message}`, error: error.message }); } } } finally { pollInProgress = false; } if (!hasPollableRuntimeTasks()) stopPolling(); } function stopPolling() { if (pollTimer) clearInterval(pollTimer); pollTimer = null; } function startPolling() { stopPolling(); if (!canUseTaskDataPlane()) return; 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 = []; runtimeTaskStore.clear(); remoteTaskStore.clear(); taskDetailStore.clear(); taskDetailRequests.clear(); localTaskOverlayStore.clear(); extensionResultPersistQueues.clear(); pendingExtensionResults.clear(); persistedExtensionResultVersions.clear(); taskAttachmentDrafts.clear(); saveTaskStore(); sessionStorage.removeItem('liansyn_platform_current_task_id'); stopPolling(); setTaskState('已清空'); setOutput('暂无任务。'); renderTaskCards(); } async function initializeSession() { let me; try { me = await apiRequest('/api/auth/me'); await refreshCsrfToken(); } catch (error) { showLoginPanel(error?.code === 'request_timeout' ? '登录状态恢复超时,请刷新页面重试。' : '登录状态已失效,请重新登录。'); return false; } if (!showAuthenticatedApp(me.user)) return false; if (isAdministrator()) { try { await syncAutomationSettings(); } catch (error) { setTaskState('全自动化设置读取失败'); } } if (IS_PARSER_ROUTING_PAGE && isAdministrator()) { await syncParserRouting().catch(() => {}); } if (IS_CHANNELS_PAGE && isAdministrator()) { await syncAccounts().catch(() => {}); await syncChannels().catch((error) => { const message = $('#channelMessage'); if (message) message.textContent = `渠道读取失败:${error.message || String(error)}`; }); await syncLeaderSummaryWebhookStatus().catch((error) => { const message = $('#leaderSummaryMessage'); if (message) message.textContent = `组长摘要状态读取失败:${error.message || String(error)}`; }); } if (IS_ACCOUNTS_PAGE && isAdministrator()) { await syncAccounts().catch((error) => { const message = $('#accountMessage'); if (message) message.textContent = `账号读取失败:${error.message || String(error)}`; }); } if (IS_AUDIT_PAGE && isAdministrator()) { await syncAuditEvents().catch((error) => { const message = $('#auditMessage'); if (message) message.textContent = `审计读取失败:${error.message || String(error)}`; }); } if (IS_OPERATIONS_DASHBOARD_PAGE && canViewOperationsDashboard()) { await syncOperationsDashboard().catch((error) => { const message = $('#operationsDashboardMessage'); if (message) message.textContent = operationsDashboardSafeError(error); }); } if (IS_TASK_PAGE && canUseTaskDataPlane()) { try { await syncRemoteTasks(); } catch (error) { // A task-list/database failure must not erase a valid session. setTaskState('任务同步失败'); setOutput({ status: 'sync_error', message: error.message || String(error) }); } startRemoteEventStream(); } return true; } document.addEventListener('DOMContentLoaded', async () => { configurePageMode(); if (IS_OPERATIONS_DASHBOARD_PAGE) resetOperationsDashboardFilters(); // Remove credentials/configuration left by the retired browser-side parser. localStorage.removeItem('liansyn_platform_agent_config'); sessionStorage.removeItem('liansyn_platform_agent_api_key'); $('#rawInstruction').value = sessionStorage.getItem('liansyn_platform_raw_instruction') || ''; $('#rawInstruction').addEventListener('input', () => { sessionStorage.setItem('liansyn_platform_raw_instruction', $('#rawInstruction').value); }); $('#rosterAttachment').addEventListener('change', () => { const input = $('#rosterAttachment'); const status = $('#rosterAttachmentStatus'); const file = input.files?.[0]; if (!file) { status.textContent = ''; return; } try { validateRosterAttachmentFile(file); status.textContent = `已选择:${file.name}(${rosterAttachmentSizeText(file)})`; } catch (error) { input.value = ''; status.textContent = error.message || String(error); } }); showAuthChecking(); // AI health is a public, read-only control-plane probe. Refresh it even // before login so the header does not confuse "not logged in" with // "external Agent disconnected". pingAi().catch(() => {}); $('#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 fetchWithTimeout('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, cache: 'no-store', credentials: 'same-origin', body: JSON.stringify({ username: $('#loginUsername').value, password: $('#loginPassword').value }) }, AUTH_API_TIMEOUT_MS); const result = await response.json(); if (!response.ok) throw new Error(result.message || '登录失败。'); csrfToken = result.csrf_token || ''; if (!showAuthenticatedApp(result.user)) return; $('#loginPassword').value = ''; if (isAdministrator()) { await syncAutomationSettings().catch(() => setTaskState('全自动化设置读取失败')); } if (IS_PARSER_ROUTING_PAGE && isAdministrator()) await syncParserRouting().catch(() => {}); if (IS_CHANNELS_PAGE && isAdministrator()) { await syncAccounts().catch(() => {}); await syncChannels().catch((error) => { const message = $('#channelMessage'); if (message) message.textContent = `渠道读取失败:${error.message || String(error)}`; }); } if (IS_ACCOUNTS_PAGE && isAdministrator()) await syncAccounts(); if (IS_AUDIT_PAGE && isAdministrator()) await syncAuditEvents(); if (IS_TASK_PAGE && canUseTaskDataPlane()) { await syncRemoteTasks(); startRemoteEventStream(); } await pingAi(); if (canUseTaskDataPlane()) await pingBridge(); } catch (error) { if (errorNode) errorNode.textContent = error.message || String(error); } finally { if (submitButton) submitButton.disabled = false; } }); $('#changePasswordButton').addEventListener('click', showPasswordChangePanel); $('#passwordChangeCancel').addEventListener('click', () => { if (!authUser) return; showAuthenticatedApp(authUser); }); $('#passwordChangeForm').addEventListener('submit', async (event) => { event.preventDefault(); const errorNode = $('#passwordChangeError'); const currentPassword = String($('#currentPassword').value || ''); const newPassword = String($('#newPassword').value || ''); const confirmation = String($('#confirmNewPassword').value || ''); if (!currentPassword || !newPassword) { errorNode.textContent = '当前密码和新密码不能为空。'; return; } if (newPassword !== confirmation) { errorNode.textContent = '两次输入的新密码不一致。'; return; } const submit = $('#passwordChangeForm button[type="submit"]'); submit.disabled = true; errorNode.textContent = ''; try { await apiRequest('/api/auth/password', { method: 'PUT', body: { current_password: currentPassword, new_password: newPassword } }); $('#currentPassword').value = ''; $('#newPassword').value = ''; $('#confirmNewPassword').value = ''; await initializeSession(); } catch (error) { errorNode.textContent = error.message || String(error); } finally { submit.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 = []; runtimeTaskStore.clear(); remoteTaskStore.clear(); taskDetailStore.clear(); taskDetailRequests.clear(); taskInputHistoryStore.clear(); taskInputHistoryRequests.clear(); localTaskOverlayStore.clear(); extensionResultPersistQueues.clear(); pendingExtensionResults.clear(); persistedExtensionResultVersions.clear(); taskArchiveStates.clear(); taskDeleteStates.clear(); taskReplyStates.clear(); taskReplyDrafts.clear(); taskAttachmentDrafts.clear(); accountList = []; accountTaskTypes = []; channelList = []; leaderSummaryWebhookStatus = null; accountAuthorizationTargetId = ''; accountAuthorizationDraft = new Set(); historySelectedTaskIds.clear(); locallyDeletedTaskIds.clear(); historyBulkAction = ''; historyBatchMessage = ''; historyBatchMessageKind = ''; currentTaskId = ''; browserConnectionId = ''; sessionStorage.removeItem('liansyn_platform_current_task_id'); $('#rosterAttachment').value = ''; $('#rosterAttachmentStatus').textContent = ''; stopPolling(); showLoginPanel('已退出登录。'); renderTaskCards(); }); $('#createTaskButton').addEventListener('click', () => { createTask().catch((error) => { const result = { status: 'create_error', task_id: '', message: error.message }; setTaskState('创建失败'); setOutput(result); }); }); $('#historyFilters').addEventListener('submit', (event) => { event.preventDefault(); historySearch = normalizeText($('#historySearchInput').value).slice(0, 200); historyStatus = normalizeText($('#historyStatusInput').value).slice(0, 80); historyArchive = $('#historyArchiveInput').value === 'archived' ? 'archived' : 'active'; historyOffset = 0; currentTaskId = ''; resetHistoryTaskSelection(); configurePageMode(); syncRemoteTasks().catch((error) => { setTaskState('历史任务筛选失败'); setOutput({ status: 'history_sync_error', message: error.message || String(error) }); }); }); $('#historyResetButton').addEventListener('click', () => { historySearch = ''; historyStatus = ''; historyArchive = 'active'; historyOffset = 0; currentTaskId = ''; resetHistoryTaskSelection(); configurePageMode(); syncRemoteTasks().catch((error) => { setTaskState('历史任务刷新失败'); setOutput({ status: 'history_sync_error', message: error.message || String(error) }); }); }); $('#historyPreviousButton').addEventListener('click', () => { if (!IS_HISTORY_PAGE || historyOffset <= 0) return; historyOffset = Math.max(0, historyOffset - HISTORY_TASK_PAGE_SIZE); currentTaskId = ''; resetHistoryTaskSelection(); syncRemoteTasks().catch((error) => setOutput({ status: 'history_sync_error', message: error.message || String(error) })); }); $('#historyNextButton').addEventListener('click', () => { if (!IS_HISTORY_PAGE || !taskListMeta.has_more) return; historyOffset += HISTORY_TASK_PAGE_SIZE; currentTaskId = ''; resetHistoryTaskSelection(); syncRemoteTasks().catch((error) => setOutput({ status: 'history_sync_error', message: error.message || String(error) })); }); $('#historySelectAll').addEventListener('change', (event) => { if (!IS_HISTORY_PAGE || historyBulkDeleteBusy) return; historySelectedTaskIds.clear(); if (event.target.checked) { for (const task of taskStore) historySelectedTaskIds.add(task.task_id); } historyBatchMessage = ''; historyBatchMessageKind = ''; renderTaskCards(); }); $('#historyDeleteSelectedButton').addEventListener('click', () => { void deleteHistorySelectedTasks(); }); $('#historyArchiveSelectedButton').addEventListener('click', () => { void archiveHistorySelectedTasks(); }); $('#automationToggleButton').addEventListener('click', () => { toggleAutomationSetting().catch((error) => { setTaskState('全自动化设置失败'); setOutput({ status: 'automation_settings_error', message: error.message || String(error) }); }); }); $('#accountForm')?.addEventListener('submit', (event) => { event.preventDefault(); createAccountFromForm().catch((error) => { const message = $('#accountMessage'); if (message) message.textContent = accountCreationErrorMessage(error); }); }); $('#accountList')?.addEventListener('change', (event) => { const control = event.target.closest('[data-account-action="role"][data-account-id]'); if (!control) return; const account = accountList.find((item) => item.id === control.dataset.accountId); if (!account) return; const patch = { role: control.value }; if (control.value === 'admin') patch.erp_account = null; if (control.value !== 'admin' && !account.erp_account) { const erpAccount = window.prompt('该员工角色必须绑定 ERP 账号,请输入 ERP 页面显示的登录账号:', ''); if (!erpAccount?.trim()) { control.value = account.role; return; } patch.erp_account = erpAccount.trim().slice(0, 200); } void updateManagedAccount(control.dataset.accountId, patch).catch((error) => { const message = $('#accountMessage'); if (message) message.textContent = error.message || String(error); void syncAccounts(); }); }); $('#accountList')?.addEventListener('click', (event) => { const button = event.target.closest('button[data-account-action][data-account-id]'); if (!button) return; const account = accountList.find((item) => item.id === button.dataset.accountId); if (!account) return; const action = button.dataset.accountAction; let operation; if (action === 'business-authorizations') { openAccountAuthorizationEditor(account.id); return; } if (action === 'erp-account') operation = updateManagedAccountErpIdentity(account.id); else if (action === 'toggle') operation = updateManagedAccount(account.id, { is_active: !account.is_active }); else if (action === 'reset-password') operation = resetManagedAccountPassword(account.id); else if (action === 'revoke-sessions') operation = revokeManagedAccountSessions(account.id); else return; operation.catch((error) => { const message = $('#accountMessage'); if (message) message.textContent = error.message || String(error); }); }); $('#accountAuthorizationTypes')?.addEventListener('change', (event) => { const checkbox = event.target.closest('input[data-account-authorization-route]'); if (!checkbox || accountSettingsBusy) return; const routeId = checkbox.dataset.accountAuthorizationRoute; if (checkbox.checked) accountAuthorizationDraft.add(routeId); else accountAuthorizationDraft.delete(routeId); renderAccountAuthorizationPanel(); }); $('#accountAuthorizationSelectAll')?.addEventListener('click', () => { if (accountSettingsBusy) return; accountAuthorizationDraft = new Set(accountTaskTypes.map((taskType) => taskType.route_id)); renderAccountAuthorizationPanel(); }); $('#accountAuthorizationClear')?.addEventListener('click', () => { if (accountSettingsBusy) return; accountAuthorizationDraft = new Set(); renderAccountAuthorizationPanel(); }); $('#accountAuthorizationSave')?.addEventListener('click', () => { void saveAccountBusinessAuthorizations().catch((error) => { const message = $('#accountMessage'); if (message) message.textContent = error.message || String(error); }); }); $('#accountAuthorizationCancel')?.addEventListener('click', closeAccountAuthorizationEditor); $('#auditFilters')?.addEventListener('submit', (event) => { event.preventDefault(); void syncAuditEvents().catch((error) => { const message = $('#auditMessage'); if (message) message.textContent = error.message || String(error); }); }); $('#auditResetButton')?.addEventListener('click', () => { $('#auditEventType').value = ''; $('#auditEntityType').value = ''; void syncAuditEvents(); }); $('#operationsDashboardFilters')?.addEventListener('submit', (event) => { event.preventDefault(); operationsDashboardOffset = 0; closeOperationsDashboardDetail(); void syncOperationsDashboard().catch((error) => { $('#operationsDashboardMessage').textContent = operationsDashboardSafeError(error); }); }); $('#operationsDashboardRangePresets')?.addEventListener('click', (event) => { const button = event.target.closest('[data-operations-range-days]'); if (!button) return; setOperationsDashboardRange(Number(button.dataset.operationsRangeDays)); operationsDashboardOffset = 0; closeOperationsDashboardDetail(); void syncOperationsDashboard().catch((error) => { $('#operationsDashboardMessage').textContent = operationsDashboardSafeError(error); }); }); for (const input of [$('#operationsDashboardFrom'), $('#operationsDashboardTo')]) { input?.addEventListener('change', () => { for (const button of document.querySelectorAll('[data-operations-range-days]')) { button.classList.remove('is-active'); } }); } $('#operationsDashboardRefresh')?.addEventListener('click', () => { void syncOperationsDashboard().catch((error) => { $('#operationsDashboardMessage').textContent = operationsDashboardSafeError(error); }); }); $('#operationsDashboardUsers')?.addEventListener('click', (event) => { const button = event.target.closest('[data-operations-actor-id]'); if (!button) return; $('#operationsDashboardActor').value = button.dataset.operationsActorId || ''; refreshOperationsDashboardAfterDrilldown(); }); $('#operationsDashboardBusinesses')?.addEventListener('click', (event) => { const button = event.target.closest('[data-operations-business]'); if (!button?.dataset.operationsBusiness) return; $('#operationsDashboardBusiness').value = button.dataset.operationsBusiness; refreshOperationsDashboardAfterDrilldown(); }); $('#operationsDashboardTasks')?.addEventListener('click', (event) => { const row = event.target.closest('[data-operations-task-id]'); if (!row) return; void loadOperationsDashboardTask(row.dataset.operationsTaskId).catch((error) => { $('#operationsDashboardMessage').textContent = operationsDashboardSafeError(error, '工作详情暂时无法打开,请稍后再试。'); closeOperationsDashboardDetail(); }); }); $('#operationsDashboardTasks')?.addEventListener('keydown', (event) => { if (event.key !== 'Enter' && event.key !== ' ') return; const row = event.target.closest('[data-operations-task-id]'); if (!row) return; event.preventDefault(); row.click(); }); $('#operationsDashboardPrevious')?.addEventListener('click', () => { if (operationsDashboardOffset <= 0 || operationsDashboardBusy) return; operationsDashboardOffset = Math.max(0, operationsDashboardOffset - OPERATIONS_DASHBOARD_PAGE_SIZE); closeOperationsDashboardDetail(); void syncOperationsDashboard().catch((error) => { $('#operationsDashboardMessage').textContent = operationsDashboardSafeError(error); }); }); $('#operationsDashboardNext')?.addEventListener('click', () => { if (!operationsDashboardData?.has_more || operationsDashboardBusy) return; operationsDashboardOffset += OPERATIONS_DASHBOARD_PAGE_SIZE; closeOperationsDashboardDetail(); void syncOperationsDashboard().catch((error) => { $('#operationsDashboardMessage').textContent = operationsDashboardSafeError(error); }); }); $('#operationsDashboardDetailClose')?.addEventListener('click', closeOperationsDashboardDetail); $('#parserRoutingRows')?.addEventListener('change', (event) => { const select = event.target.closest('select[data-parser-mode-route]'); if (!select) return; void changeParserRoutingMode(select.dataset.parserModeRoute, select.value); }); $('#emergencyAiButton')?.addEventListener('click', () => { void emergencyAllAi(); }); $('#channelForm')?.addEventListener('submit', (event) => { event.preventDefault(); createChannelFromForm().catch((error) => { const message = $('#channelMessage'); if (message) message.textContent = error.message || String(error); }); }); $('#channelList')?.addEventListener('click', (event) => { const button = event.target.closest('[data-channel-action][data-channel-id]'); if (!button) return; const channelId = button.dataset.channelId; const channel = channelList.find((item) => item.id === channelId); if (!channelId || !channel) return; const action = button.dataset.channelAction; let operation; if (action === 'toggle') operation = updateChannelEnabled(channelId, !channel.enabled); else if (action === 'rename') operation = renameChannel(channelId); else if (action === 'rotate') operation = rotateChannelKey(channelId); else if (action === 'delete') operation = deleteChannel(channelId); else return; operation.catch((error) => { const message = $('#channelMessage'); if (message) message.textContent = error.message || String(error); }); }); $('#channelList')?.addEventListener('change', (event) => { const select = event.target.closest('select[data-channel-action="owner"][data-channel-id]'); if (!select?.value) return; void updateChannelOwner(select.dataset.channelId, select.value).catch((error) => { const message = $('#channelMessage'); if (message) message.textContent = error.message || String(error); void syncChannels(); }); }); $('#accountRole')?.addEventListener('change', (event) => { const erpInput = $('#accountErpAccount'); const admin = event.target.value === 'admin'; erpInput.required = !admin; erpInput.disabled = admin; if (admin) erpInput.value = ''; }); $('#aiState').addEventListener('click', () => { toggleStatusDetails('ai').catch(() => {}); }); $('#bridgeState').addEventListener('click', () => { toggleStatusDetails('plugin').catch(() => {}); }); $('#statusDetailsClose').addEventListener('click', closeStatusDetails); $('#statusDetailsRefresh').addEventListener('click', () => { const refresh = activeStatusDetails === 'ai' ? pingAi() : pingBridge(); refresh.catch(() => {}); }); document.addEventListener('click', (event) => { if (!activeStatusDetails) return; if (event.target.closest('#statusDetailsPopover, #aiState, #bridgeState')) return; closeStatusDetails(); }); document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && activeStatusDetails) { closeStatusDetails(); } }); $('#confirmTaskButton').addEventListener('click', () => { const task = selectedTask(); if (!task) return; const action = requiresManualConfirmation(task) ? confirmAndSubmitToErpPlugin(task) : canResumePrewriteTask(task) ? resumePrewriteTaskExecution(task) : canReconcileTask(task) ? reconcileTaskReceipt(task) : canStartConfirmedTask(task) ? handoffTaskToExtension(task) : null; if (!action) return; action.catch((error) => setOutput({ status: 'confirmation_error', task_id: task.task_id, message: error.message })); }); $('#archiveTaskButton').addEventListener('click', () => { const task = selectedTask(); if (!task) return; if (!task.archived_at && !confirmTaskArchive([task])) return; archiveSelectedTask().catch((error) => setOutput({ status: 'archive_error', task_id: task.task_id, message: error.message || String(error) })); }); $('#deleteTaskButton').addEventListener('click', () => { const task = selectedTask(); if (!task || !confirmTaskHardDelete([task])) return; deleteSelectedTask().catch((error) => setOutput({ status: 'hard_delete_error', task_id: task.task_id, message: error.message || String(error) })); }); $('#taskCards').addEventListener('change', (event) => { const checkbox = event.target.closest('[data-history-task-select]'); const taskId = checkbox?.dataset.historyTaskSelect; if (!IS_HISTORY_PAGE || !taskId || historyBulkDeleteBusy) return; if (checkbox.checked) historySelectedTaskIds.add(taskId); else historySelectedTaskIds.delete(taskId); historyBatchMessage = ''; historyBatchMessageKind = ''; renderTaskCards(); }); $('#taskCards').addEventListener('click', (event) => { const archiveButton = event.target.closest('[data-history-task-archive]'); if (archiveButton) { const taskId = archiveButton.dataset.historyTaskArchive; const task = taskStore.find((item) => item.task_id === taskId); if (task && (task.archived_at || confirmTaskArchive([task]))) { void toggleTaskArchive(taskId).catch(() => {}); } return; } const deleteButton = event.target.closest('[data-history-task-delete]'); if (deleteButton) { const taskId = deleteButton.dataset.historyTaskDelete; const task = taskStore.find((item) => item.task_id === taskId); if (task && confirmTaskHardDelete([task])) { void hardDeleteTask(taskId).catch(() => {}); } return; } if (event.target.closest('[data-history-task-select], .task-card-select')) return; const card = event.target.closest('.task-card[data-task-id]'); const taskId = card?.dataset.taskId; if (!taskId) return; const task = taskStore.find((item) => item.task_id === taskId); if (!task) return; currentTaskId = taskId; sessionStorage.setItem('liansyn_platform_current_task_id', currentTaskId); renderTaskCards(); }); $('#taskDetail').addEventListener('click', (event) => { const reviewButton = event.target.closest('[data-parser-decision-id][data-parser-review-verdict]'); if (reviewButton) { void reviewTaskParserDecision( reviewButton.dataset.parserDecisionId, reviewButton.dataset.parserReviewVerdict, reviewButton ); return; } const reparseButton = event.target.closest('[data-ai-reparse-task]'); if (reparseButton) { void reparseTaskWithAi(reparseButton.dataset.aiReparseTask, reparseButton); return; } const button = event.target.closest('[data-copy-lifecycle]'); if (!button) return; void copyTaskLifecycle(button); }); // Register once per page, even when initial session restoration fails. // refreshBackgroundState remains inert while logged out; later login uses // these same listeners/timer without requiring a full page reload. setInterval(() => { refreshBackgroundState().catch(() => {}); }, 30_000); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { stopPolling(); return; } refreshBackgroundState().catch(() => {}); if (hasPollableRuntimeTasks()) startPolling(); }); window.addEventListener('focus', () => { refreshBackgroundState().catch(() => {}); }); if (await initializeSession()) { if (IS_TASK_PAGE && canUseTaskDataPlane()) renderTaskCards(); pingAi().catch(() => {}); if (canUseTaskDataPlane()) pingBridge().catch(() => {}); if (IS_TASK_PAGE && canUseTaskDataPlane() && currentTaskId) renderTaskDetail(); if (IS_TASK_PAGE && canUseTaskDataPlane() && hasPollableRuntimeTasks()) startPolling(); } });