diff --git a/LianSyn-platform/README.md b/LianSyn-platform/README.md index 2b78ecf..b826a6f 100644 --- a/LianSyn-platform/README.md +++ b/LianSyn-platform/README.md @@ -4,7 +4,7 @@ 操作台首页只展示按创建时间倒序排列的最近 10 条任务;任务数量超过 10 条时,点击“查看更多”进入 `/history` 历史任务目录。历史目录仍使用同一登录会话和任务详情面板,支持按任务编号/摘要搜索、按状态筛选、分页,以及补充信息、确认提交、插件回查和彻底删除等现有操作。历史目录不新增归档状态,服务端持久化任务仍是唯一事实源。 -操作台不会在页面刷新、插件重连或状态轮询时自动重交 ERP 任务。只有管理员的首次明确确认会申请服务端唯一执行权;领取成功后才向插件下发。领取后的投递超时或 ERP 回执不确定都会进入“待回查”,禁止自动重试。 +操作台不会在页面刷新、插件重连或状态轮询时自动重交 ERP 任务。只有任务所属员工账号的首次明确确认会申请服务端唯一执行权;领取成功后才向该账号绑定的插件下发。领取后的投递超时或 ERP 回执不确定都会进入“待回查”,禁止自动重试。管理员只使用账号、渠道、解析策略和审计等管理功能,不进入任务数据面。 生产控制平面说明见 [../control-plane/README.md](../control-plane/README.md)。 diff --git a/LianSyn-platform/app.js b/LianSyn-platform/app.js index 6b2e4c9..ff2734b 100644 --- a/LianSyn-platform/app.js +++ b/LianSyn-platform/app.js @@ -163,12 +163,16 @@ function isTeamLeader() { return authUser?.role === 'team_lead'; } +function canUseTaskDataPlane(user = authUser) { + return user?.role === 'team_lead' || user?.role === 'user'; +} + function canViewOperationsDashboard() { - return isAdministrator() || isTeamLeader(); + return isTeamLeader(); } function connectionIdForUser(user) { - if (!user?.id) return ''; + if (!user?.id || !canUseTaskDataPlane(user)) return ''; const storageKey = `liansyn_platform_browser_connection_id:${user.id}`; let connectionId = localStorage.getItem(storageKey) || ''; if (!/^platform-browser:[a-f0-9-]{16,80}$/i.test(connectionId)) { @@ -237,6 +241,11 @@ async function encodeRosterAttachment(file) { } async function apiRequest(path, options = {}) { + if (authUser && !canUseTaskDataPlane() && isTaskDataPlaneApiPath(path)) { + const accessError = new Error('管理员账号仅用于平台管理,不能访问业务任务。'); + accessError.code = 'task_access_forbidden'; + throw accessError; + } const method = String(options.method || 'GET').toUpperCase(); const headers = new Headers(options.headers || {}); const timeoutMs = Number(options.timeoutMs) || requestTimeoutForPath(path); @@ -273,6 +282,60 @@ async function apiRequest(path, options = {}) { return payload || {}; } +function isTaskDataPlaneApiPath(path) { + const pathname = String(path || '').split(/[?#]/, 1)[0]; + return pathname === '/api/messages' + || pathname === '/api/connections/heartbeat' + || pathname === '/api/events' + || pathname === '/api/tasks' + || pathname.startsWith('/api/tasks/') + || pathname.startsWith('/api/parser-decisions/') + || pathname === '/api/operations-dashboard' + || pathname.startsWith('/api/operations-dashboard/'); +} + +function hideAuthenticatedNavigation() { + for (const selector of [ + '#workbenchNav', + '#historyNav', + '#operationsDashboardNav', + '#channelsNav', + '#parserRoutingNav', + '#accountsNav', + '#auditNav', + '#bridgeState' + ]) { + const element = $(selector); + if (element) element.hidden = true; + } +} + +function clearTaskDataPlaneClientState() { + currentTaskId = ''; + taskStore = []; + runtimeTaskStore.clear(); + remoteTaskStore.clear(); + taskDetailStore.clear(); + taskDetailRequests.clear(); + taskInputHistoryStore.clear(); + taskInputHistoryRequests.clear(); + localTaskOverlayStore.clear(); + extensionResultPersistQueues.clear(); + pendingExtensionResults.clear(); + persistedExtensionResultVersions.clear(); + taskArchiveStates.clear(); + taskDeleteStates.clear(); + taskReplyStates.clear(); + taskReplyDrafts.clear(); + taskAttachmentDrafts.clear(); + historySelectedTaskIds.clear(); + locallyDeletedTaskIds.clear(); + sessionStorage.removeItem('liansyn_platform_current_task_id'); + stopPolling(); + if (eventStream) eventStream.close(); + eventStream = null; +} + function showLoginPanel(message = '') { authUser = null; browserConnectionId = ''; @@ -314,6 +377,7 @@ function showLoginPanel(message = '') { if (automationButton) automationButton.hidden = true; if (logoutButton) logoutButton.hidden = true; if (changePasswordButton) changePasswordButton.hidden = true; + hideAuthenticatedNavigation(); const submitButton = $('#loginForm button[type="submit"]'); if (submitButton) submitButton.disabled = false; const error = $('#loginError'); @@ -354,6 +418,7 @@ function showAuthChecking() { if (automationButton) automationButton.hidden = true; if (logoutButton) logoutButton.hidden = true; if (changePasswordButton) changePasswordButton.hidden = true; + hideAuthenticatedNavigation(); const submitButton = $('#loginForm button[type="submit"]'); if (submitButton) submitButton.disabled = true; const error = $('#loginError'); @@ -362,14 +427,22 @@ function showAuthChecking() { function showAuthenticatedApp(user) { authUser = user; - browserConnectionId = connectionIdForUser(user); + browserConnectionId = canUseTaskDataPlane() ? connectionIdForUser(user) : ''; + if (isAdministrator()) { + clearTaskDataPlaneClientState(); + if (user?.id) localStorage.removeItem(`liansyn_platform_browser_connection_id:${user.id}`); + } + if (isAdministrator() && (IS_TASK_PAGE || IS_OPERATIONS_DASHBOARD_PAGE)) { + window.location.replace('/accounts'); + return false; + } if (!isAdministrator() && IS_ADMIN_PAGE) { window.location.replace('/'); - return; + return false; } if (IS_OPERATIONS_DASHBOARD_PAGE && !canViewOperationsDashboard()) { window.location.replace('/'); - return; + return false; } const panel = $('#loginPanel'); const workbench = $('#workbench'); @@ -381,11 +454,12 @@ function showAuthenticatedApp(user) { const passwordPanel = $('#passwordChangePanel'); const authState = $('#authState'); const automationButton = $('#automationToggleButton'); + const bridgeState = $('#bridgeState'); const logoutButton = $('#logoutButton'); const changePasswordButton = $('#changePasswordButton'); if (panel) panel.hidden = true; if (passwordPanel) passwordPanel.hidden = true; - if (workbench) workbench.hidden = IS_MANAGEMENT_PAGE; + if (workbench) workbench.hidden = IS_MANAGEMENT_PAGE || !canUseTaskDataPlane(); if (channelsPage) channelsPage.hidden = !IS_CHANNELS_PAGE || !isAdministrator(); if (parserRoutingPage) parserRoutingPage.hidden = !IS_PARSER_ROUTING_PAGE || !isAdministrator(); if (accountsPage) accountsPage.hidden = !IS_ACCOUNTS_PAGE || !isAdministrator(); @@ -405,12 +479,18 @@ function showAuthenticatedApp(user) { } const operationsDashboardNav = $('#operationsDashboardNav'); if (operationsDashboardNav) operationsDashboardNav.hidden = !canViewOperationsDashboard(); + for (const selector of ['#workbenchNav', '#historyNav']) { + const nav = $(selector); + if (nav) nav.hidden = !canUseTaskDataPlane(); + } + if (bridgeState) bridgeState.hidden = !canUseTaskDataPlane(); if (automationButton) { automationButton.hidden = !isAdministrator() || IS_MANAGEMENT_PAGE; renderAutomationToggle(); } const submitButton = $('#loginForm button[type="submit"]'); if (submitButton) submitButton.disabled = false; + return true; } function showPasswordChangePanel() { @@ -1140,7 +1220,7 @@ function renderAccounts() { revoke.dataset.accountAction = 'revoke-sessions'; revoke.dataset.accountId = account.id; revoke.disabled = accountSettingsBusy; - const authorizations = el('button', 'secondary-button', account.role === 'admin' ? '全部任务' : '任务权限'); + const authorizations = el('button', 'secondary-button', account.role === 'admin' ? '不参与任务' : '任务权限'); authorizations.type = 'button'; authorizations.dataset.accountAction = 'business-authorizations'; authorizations.dataset.accountId = account.id; @@ -1280,7 +1360,7 @@ async function createAccountFromForm() { $('#accountErpAccount').value = ''; if (message) { message.textContent = result.account?.role === 'admin' - ? '管理员账号已创建;该角色固定拥有全部任务权限,初始密码不会再次显示。' + ? '管理员账号已创建;该角色仅能使用平台管理功能,不具备任何任务权限。' : '账号已创建,当前默认不能执行任何业务;请点击“任务权限”完成授权。'; } } finally { @@ -1859,7 +1939,7 @@ async function refreshCsrfToken() { } async function syncRemoteTasks() { - if (!authUser) return; + if (!canUseTaskDataPlane()) return; if (remoteSyncInProgress) { syncRequested = true; return; @@ -1907,12 +1987,12 @@ async function syncRemoteTasks() { if (currentTaskId) sessionStorage.setItem('liansyn_platform_current_task_id', currentTaskId); else sessionStorage.removeItem('liansyn_platform_current_task_id'); renderTaskCards(); - } while (syncRequested && authUser); + } while (syncRequested && canUseTaskDataPlane()); await syncRuntimeTasks(); await autoDispatchReadyTasks(); } finally { remoteSyncInProgress = false; - if (syncRequested && authUser) { + if (syncRequested && canUseTaskDataPlane()) { syncRequested = false; queueMicrotask(() => syncRemoteTasks().catch((error) => { setOutput({ status: 'sync_error', message: error.message }); @@ -1923,6 +2003,8 @@ async function syncRemoteTasks() { function startRemoteEventStream() { if (eventStream) eventStream.close(); + eventStream = null; + if (!canUseTaskDataPlane()) return; const lastEventId = runtimeTasks().filter(taskAssignedToCurrentAccount).reduce((highest, task) => ( Math.max(highest, Number(task?.last_event_id || 0)) ), 0); @@ -1980,7 +2062,7 @@ function hasPollableRuntimeTasks() { } async function syncRuntimeTasks() { - if (!authUser) return; + if (!canUseTaskDataPlane()) return; const result = await apiRequest('/api/tasks?status=active&limit=200&include_total=false&executable_by=me'); const activeTasks = (Array.isArray(result.tasks) ? result.tasks : []) .map(mergeRemoteTask) @@ -2269,12 +2351,12 @@ function taskStatusText(task) { 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 === '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 '待管理员核验,请勿重复提交'; + if (status === 'saved_unverified') return '待当前账号只读核验,请勿重复提交'; return stage || status; } @@ -2306,9 +2388,9 @@ function taskStatusLabel(task) { waiting_extension: '等待插件', dry_run: '已规划', operation_blocked: '已阻断', - execution_uncertain: '待管理员核验', - reconciliation_pending: '待管理员核验', - saved_unverified: '待管理员核验', + execution_uncertain: '待人工核验', + reconciliation_pending: '待人工核验', + saved_unverified: '待人工核验', cancelled: '已取消', post_save_recovery_required: '待恢复' }; @@ -2332,7 +2414,7 @@ function requiresManualConfirmation(task) { } function taskAssignedToCurrentAccount(task) { - return Boolean(authUser?.id && task?.assignee?.id === authUser.id); + return Boolean(canUseTaskDataPlane() && authUser?.id && task?.assignee?.id === authUser.id); } function extensionTaskBelongsToCurrentAccount(taskId) { @@ -2676,7 +2758,7 @@ function isAutomaticTask(task) { } async function autoDispatchReadyTasks({ force = false } = {}) { - if (!authUser || autoHandoffInProgress) return; + if (!canUseTaskDataPlane() || autoHandoffInProgress) return; autoHandoffInProgress = true; try { const result = await apiRequest('/api/tasks?status=confirmed&limit=200&include_total=false&executable_by=me'); @@ -3389,7 +3471,7 @@ function taskStageSnapshot(task) { business = { mode: 'needs', current: 'processing', - currentLabel: '待管理员核验' + currentLabel: '待人工核验' }; } else if (['blocked', 'failed', 'cancelled', 'extension_error', 'batch_fallback_incomplete', 'live_submit_blocked', 'preflight_blocked'].includes(status)) { business = { @@ -3517,7 +3599,7 @@ function renderTaskStages(task) { function parserEngineLabel(parser = {}) { if (parser.fallback_reason && parser.fallback_reason !== 'manual_ai_reparse') return 'AI 兜底'; - if (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 解析' : '尚未确定'; } @@ -3689,7 +3771,7 @@ function renderTaskInputAudit(task) { const item = el('article', 'detail-block'); const sourceLabel = message.source === 'manual' ? '人工输入' : message.source === 'agentbus' ? 'AgentBus' - : message.source === 'reparse' ? '管理员重解析' : '历史/系统'; + : message.source === 'reparse' ? '人工重解析' : '历史/系统'; item.append(el( 'p', 'muted', @@ -4622,6 +4704,9 @@ function makeRequestId() { } function sendToExtension(type, payload = {}, timeoutMs = 2500) { + if (!canUseTaskDataPlane()) { + return Promise.reject(new Error('管理员账号不能连接或调用任务执行插件。')); + } const requestId = makeRequestId(); const promise = new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -4643,6 +4728,7 @@ window.addEventListener('message', (event) => { if (event.source !== window) return; const message = event.data || {}; if (message.source !== EXTENSION_SOURCE) return; + if (!canUseTaskDataPlane()) return; if (message.type === 'BRIDGE_READY') { applyBridgePayload(message.payload || { ok: true }); return; @@ -4999,6 +5085,7 @@ async function confirmAndSubmitToErpPlugin(task) { } async function pingBridge() { + if (!canUseTaskDataPlane()) return false; try { const result = await sendToExtension('PING', { expected_erp_account: authUser?.erp_account || '' @@ -5067,6 +5154,10 @@ async function createTask() { showLoginPanel('请先登录。'); return; } + if (!canUseTaskDataPlane()) { + window.location.replace('/accounts'); + return; + } if (taskCreateInProgress) return; const rawText = $('#rawInstruction').value; const fileInput = $('#rosterAttachment'); @@ -5117,12 +5208,13 @@ async function refreshBackgroundState() { if (!authUser || backgroundRefreshInProgress) return; backgroundRefreshInProgress = true; try { - const operations = [pingAi(), pingBridge()]; + const operations = [pingAi()]; + if (canUseTaskDataPlane()) operations.push(pingBridge()); if (isAdministrator()) operations.push(syncAutomationSettings({ background: true })); if (IS_CHANNELS_PAGE && isAdministrator()) { operations.push(syncChannels(), syncLeaderSummarySubscriptions()); } - if (IS_TASK_PAGE) operations.push(syncRemoteTasks()); + if (IS_TASK_PAGE && canUseTaskDataPlane()) operations.push(syncRemoteTasks()); await Promise.allSettled(operations); } finally { backgroundRefreshInProgress = false; @@ -5160,7 +5252,7 @@ function confirmTaskHardDelete(tasks) { ? `任务 ${selectedTasks[0].task_id}` : `所选 ${selectedTasks.length} 个任务`; return window.confirm( - `确认永久强制删除${scope}?\n\n任务、原始输入、生命周期、执行记录、附件和回执都会被物理删除,无法恢复。此操作不受“正在处理”或“等待 ERP 执行”状态限制。\n\n如果 ERP 已经开始写入,删除平台记录不会撤销 ERP 中已经发生的操作;系统会向任务所属账号的在线插件发送停止与清理指令,不会误发给当前管理员插件。已经交给 AgentBus 并到达组长微信的任务摘要也无法撤回。` + `确认永久强制删除${scope}?\n\n任务、原始输入、生命周期、执行记录、附件和回执都会被物理删除,无法恢复。此操作不受“正在处理”或“等待 ERP 执行”状态限制。\n\n如果 ERP 已经开始写入,删除平台记录不会撤销 ERP 中已经发生的操作;系统只会向任务所属账号的在线插件发送停止与清理指令。已经交给 AgentBus 并到达组长微信的任务摘要也无法撤回。` ); } @@ -5540,7 +5632,7 @@ async function resumePrewriteTaskExecution(task) { } async function pollAllTaskResults() { - if (!bridgeConnected || pollInProgress) return; + if (!canUseTaskDataPlane() || !bridgeConnected || pollInProgress) return; pollInProgress = true; try { const activeTasks = runtimeTasks().filter((task) => taskAssignedToCurrentAccount(task) && isTaskPollable(task)); @@ -5567,6 +5659,7 @@ function stopPolling() { function startPolling() { stopPolling(); + if (!canUseTaskDataPlane()) return; pollAllTaskResults().catch((error) => { setTaskState('轮询失败'); setOutput({ status: 'poll_error', message: error.message }); @@ -5616,7 +5709,7 @@ async function initializeSession() { return false; } - showAuthenticatedApp(me.user); + if (!showAuthenticatedApp(me.user)) return false; if (isAdministrator()) { try { await syncAutomationSettings(); @@ -5656,7 +5749,7 @@ async function initializeSession() { if (message) message.textContent = operationsDashboardSafeError(error); }); } - if (IS_TASK_PAGE) { + if (IS_TASK_PAGE && canUseTaskDataPlane()) { try { await syncRemoteTasks(); } catch (error) { @@ -5720,7 +5813,7 @@ document.addEventListener('DOMContentLoaded', async () => { const result = await response.json(); if (!response.ok) throw new Error(result.message || '登录失败。'); csrfToken = result.csrf_token || ''; - showAuthenticatedApp(result.user); + if (!showAuthenticatedApp(result.user)) return; $('#loginPassword').value = ''; if (isAdministrator()) { await syncAutomationSettings().catch(() => setTaskState('全自动化设置读取失败')); @@ -5735,12 +5828,12 @@ document.addEventListener('DOMContentLoaded', async () => { } if (IS_ACCOUNTS_PAGE && isAdministrator()) await syncAccounts(); if (IS_AUDIT_PAGE && isAdministrator()) await syncAuditEvents(); - if (IS_TASK_PAGE) { + if (IS_TASK_PAGE && canUseTaskDataPlane()) { await syncRemoteTasks(); startRemoteEventStream(); } await pingAi(); - await pingBridge(); + if (canUseTaskDataPlane()) await pingBridge(); } catch (error) { if (errorNode) errorNode.textContent = error.message || String(error); } finally { @@ -6227,9 +6320,9 @@ document.addEventListener('DOMContentLoaded', async () => { void copyTaskLifecycle(button); }); if (await initializeSession()) { - if (IS_TASK_PAGE) renderTaskCards(); + if (IS_TASK_PAGE && canUseTaskDataPlane()) renderTaskCards(); pingAi().catch(() => {}); - pingBridge().catch(() => {}); + if (canUseTaskDataPlane()) pingBridge().catch(() => {}); // The control plane caches this health probe; keep the browser refresh // interval conservative so status checks cannot pressure the Agent API. setInterval(() => { @@ -6246,7 +6339,7 @@ document.addEventListener('DOMContentLoaded', async () => { window.addEventListener('focus', () => { refreshBackgroundState().catch(() => {}); }); - if (IS_TASK_PAGE && currentTaskId) renderTaskDetail(); - if (IS_TASK_PAGE && hasPollableRuntimeTasks()) startPolling(); + if (IS_TASK_PAGE && canUseTaskDataPlane() && currentTaskId) renderTaskDetail(); + if (IS_TASK_PAGE && canUseTaskDataPlane() && hasPollableRuntimeTasks()) startPolling(); } }); diff --git a/LianSyn-platform/index.html b/LianSyn-platform/index.html index 530d569..dfd3ac3 100644 --- a/LianSyn-platform/index.html +++ b/LianSyn-platform/index.html @@ -5,7 +5,7 @@