'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 HOME_TASK_LIMIT = 10;
const HISTORY_TASK_PAGE_SIZE = 24;
const DEFAULT_API_TIMEOUT_MS = 15_000;
const AUTH_API_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 organizationAutomationEnabled = false;
let automationSettingsLoaded = false;
let automationSettingsBusy = false;
let automationSettingsError = '';
let automationSettingsSyncInFlight = null;
let channelList = [];
let channelSettingsBusy = false;
let parserRoutingRows = [];
let parserRoutingBusy = false;
let autoHandoffInProgress = false;
let taskStore = [];
const runtimeTaskStore = new Map();
const remoteTaskStore = new Map();
const taskDetailStore = new Map();
const taskDetailRequests = new Map();
const localTaskOverlayStore = new Map();
let pollInProgress = false;
let backgroundRefreshInProgress = false;
let taskListMeta = {
total: 0,
offset: 0,
limit: HOME_TASK_LIMIT,
has_more: false
};
let historySearch = '';
let historyStatus = '';
let historyOffset = 0;
const lifecycleScrollPositions = new Map();
const taskDeleteStates = new Map();
const historySelectedTaskIds = new Set();
const locallyDeletedTaskIds = new Set();
let historyBulkDeleteBusy = false;
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 BROWSER_CONNECTION_ID = `administrator-browser:${location.origin}`;
const REQUIRED_EXTENSION_VERSION = '0.5.163';
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 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 = {}) {
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) {
throw new Error(payload?.message || payload?.error || `请求失败:HTTP ${response.status}`);
}
return payload || {};
}
function showLoginPanel(message = '') {
authUser = null;
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 authState = $('#authState');
const automationButton = $('#automationToggleButton');
const logoutButton = $('#logoutButton');
if (panel) panel.hidden = false;
if (workbench) workbench.hidden = true;
if (channelsPage) channelsPage.hidden = true;
if (parserRoutingPage) parserRoutingPage.hidden = true;
if (authState) authState.hidden = true;
if (automationButton) automationButton.hidden = true;
if (logoutButton) logoutButton.hidden = true;
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 authState = $('#authState');
const automationButton = $('#automationToggleButton');
const logoutButton = $('#logoutButton');
if (panel) panel.hidden = false;
if (workbench) workbench.hidden = true;
if (channelsPage) channelsPage.hidden = true;
if (parserRoutingPage) parserRoutingPage.hidden = true;
if (authState) authState.hidden = true;
if (automationButton) automationButton.hidden = true;
if (logoutButton) logoutButton.hidden = true;
const submitButton = $('#loginForm button[type="submit"]');
if (submitButton) submitButton.disabled = true;
const error = $('#loginError');
if (error) error.textContent = '正在恢复登录状态(最多等待 10 秒)…';
}
function showAuthenticatedApp(user) {
authUser = user;
const panel = $('#loginPanel');
const workbench = $('#workbench');
const channelsPage = $('#channelsPage');
const parserRoutingPage = $('#parserRoutingPage');
const authState = $('#authState');
const automationButton = $('#automationToggleButton');
const logoutButton = $('#logoutButton');
if (panel) panel.hidden = true;
if (workbench) workbench.hidden = IS_CHANNELS_PAGE || IS_PARSER_ROUTING_PAGE;
if (channelsPage) channelsPage.hidden = !IS_CHANNELS_PAGE;
if (parserRoutingPage) parserRoutingPage.hidden = !IS_PARSER_ROUTING_PAGE;
if (authState) {
authState.hidden = false;
authState.textContent = `${user.username} · 管理员`;
}
if (logoutButton) logoutButton.hidden = false;
if (automationButton) {
automationButton.hidden = IS_CHANNELS_PAGE || IS_PARSER_ROUTING_PAGE;
renderAutomationToggle();
}
const submitButton = $('#loginForm button[type="submit"]');
if (submitButton) submitButton.disabled = false;
}
function renderAutomationToggle() {
const button = $('#automationToggleButton');
if (!button) return;
button.hidden = !authUser || IS_CHANNELS_PAGE || IS_PARSER_ROUTING_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 (!authUser) 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 (!authUser) 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 (!authUser) 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-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 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 (!authUser || IS_HISTORY_PAGE) return null;
const result = await apiRequest('/api/channels');
channelList = Array.isArray(result.channels) ? result.channels : [];
renderChannelList();
return channelList;
}
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),
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 = '';
$('#channelExternalUserRef').value = '';
$('#channelBotAddress').value = '';
renderChannelList();
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 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 回执记录(包括未发送回执)会被移除;历史任务不会被删除。此操作不可撤销。'
);
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 || '未命名渠道'}”已删除。`;
}
async function refreshCsrfToken() {
const result = await apiRequest('/api/auth/csrf');
csrfToken = result.csrf_token || '';
if (!csrfToken) throw new Error('服务端未返回 CSRF 会话令牌。');
}
async function syncRemoteTasks() {
if (!authUser) return;
if (remoteSyncInProgress) {
syncRequested = true;
return;
}
remoteSyncInProgress = true;
try {
do {
syncRequested = false;
const 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);
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) => (
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 && authUser);
await syncRuntimeTasks();
await autoDispatchReadyTasks();
} finally {
remoteSyncInProgress = false;
if (syncRequested && authUser) {
syncRequested = false;
queueMicrotask(() => syncRemoteTasks().catch((error) => {
setOutput({ status: 'sync_error', message: error.message });
}));
}
}
}
function startRemoteEventStream() {
if (eventStream) eventStream.close();
const lastEventId = runtimeTasks().reduce((highest, task) => (
Math.max(highest, Number(task?.last_event_id || 0))
), 0);
eventStream = new EventSource(`/api/events?since=${encodeURIComponent(lastEventId)}`);
eventStream.addEventListener('open', () => {
syncAutomationSettings({ background: true }).catch(() => {});
});
eventStream.addEventListener('task', () => {
syncRemoteTasks().catch((error) => {
setOutput({ status: 'sync_error', message: error.message });
});
});
eventStream.onerror = () => {
// EventSource reconnects automatically; polling remains the fallback authority refresh.
};
}
function 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(isTaskPollable);
}
async function syncRuntimeTasks() {
if (!authUser) return;
const result = await apiRequest('/api/tasks?status=active&limit=200&include_total=false');
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: '散拼-录入子单',
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;
}
function loadSelectedTaskDetails(task) {
if (!authUser || !task || isDetailedTask(task)) return;
const lifecycle = $('#taskLifecycle');
if (lifecycle) lifecycle.textContent = '正在加载任务详情…';
void ensureTaskDetails(task.task_id).catch((error) => {
if (lifecycle?.isConnected) lifecycle.textContent = `任务详情加载失败:${error.message || String(error)}`;
});
}
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 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 isTaskPollable(task) {
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 canDeleteTask(task) {
return Boolean(task);
}
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 (!authUser || autoHandoffInProgress) return;
autoHandoffInProgress = true;
try {
let candidates = taskStore;
if (organizationAutomationEnabled) {
const result = await apiRequest('/api/tasks?status=confirmed&limit=200&include_total=false');
const byId = new Map(taskStore.map((task) => [task.task_id, task]));
for (const task of Array.isArray(result.tasks) ? result.tasks : []) {
byId.set(task.task_id, task);
cacheRuntimeTask(task);
}
candidates = [...byId.values()];
}
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'}`));
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 = 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 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 deleteButton = $('#deleteTaskButton');
const status = task?.status || task?.result?.status || 'created';
const importantMessagePanel = renderTaskImportantMessage(task);
renderTaskStages(task);
if (confirmButton) {
const canConfirm = Boolean(task && requiresManualConfirmation(task));
const canStart = Boolean(task && !isAutomaticTask(task) && canStartConfirmedTask(task));
const canResume = Boolean(task && canResumePrewriteTask(task));
const canReconcile = Boolean(task && 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
? RECONCILE_LABEL
: canResume
? '恢复保存前执行'
: canStart
? RETRY_HANDOFF_LABEL
: lifecycleReview ? '审核并授权本次测试写入' : MANUAL_HANDOFF_LABEL;
}
if (deleteButton) {
const deleteState = task ? taskDeleteStates.get(task.task_id) : '';
deleteButton.disabled = !canDeleteTask(task) || deleteState === 'deleting';
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 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);
const instructionPanel = document.querySelector('.instruction-panel');
const historyFilters = $('#historyFilters');
const channelsPage = $('#channelsPage');
const parserRoutingPage = $('#parserRoutingPage');
const title = $('#taskListTitle');
const hint = $('#taskListHint');
const historyLink = $('#historyLink');
const workbenchNav = $('#workbenchNav');
const historyNav = $('#historyNav');
const channelsNav = $('#channelsNav');
const parserRoutingNav = $('#parserRoutingNav');
if (instructionPanel) instructionPanel.hidden = IS_HISTORY_PAGE;
if (channelsPage) channelsPage.hidden = !IS_CHANNELS_PAGE;
if (parserRoutingPage) parserRoutingPage.hidden = !IS_PARSER_ROUTING_PAGE;
if (historyFilters) historyFilters.hidden = !IS_HISTORY_PAGE;
if (title) title.textContent = IS_HISTORY_PAGE ? '历史任务目录' : '任务卡片';
if (hint) hint.textContent = IS_HISTORY_PAGE ? '全部未删除任务' : '最近任务';
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_CHANNELS_PAGE && !IS_PARSER_ROUTING_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 (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');
if (searchInput && searchInput.value !== historySearch) searchInput.value = historySearch;
if (statusInput && statusInput.value !== historyStatus) statusInput.value = historyStatus;
}
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 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 deleteButton = $('#historyDeleteSelectedButton');
if (deleteButton) {
deleteButton.disabled = historyBulkDeleteBusy || selectedCount === 0;
deleteButton.textContent = historyBulkDeleteBusy
? `正在删除 ${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 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 = `