feat: isolate administrators from task data plane

This commit is contained in:
inman committed 2026-09-07 20:25:24 +08:00
1 parent 03d01315a9
commit fd39347603
13 files changed
+644 -112

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@
操作台首页只展示按创建时间倒序排列的最近 10 条任务;任务数量超过 10 条时,点击“查看更多”进入 `/history` 历史任务目录。历史目录仍使用同一登录会话和任务详情面板,支持按任务编号/摘要搜索、按状态筛选、分页,以及补充信息、确认提交、插件回查和彻底删除等现有操作。历史目录不新增归档状态,服务端持久化任务仍是唯一事实源。
操作台不会在页面刷新、插件重连或状态轮询时自动重交 ERP 任务。只有管理员的首次明确确认会申请服务端唯一执行权;领取成功后才向插件下发。领取后的投递超时或 ERP 回执不确定都会进入“待回查”,禁止自动重试。
操作台不会在页面刷新、插件重连或状态轮询时自动重交 ERP 任务。只有任务所属员工账号的首次明确确认会申请服务端唯一执行权;领取成功后才向该账号绑定的插件下发。领取后的投递超时或 ERP 回执不确定都会进入“待回查”,禁止自动重试。管理员只使用账号、渠道、解析策略和审计等管理功能,不进入任务数据面。
生产控制平面说明见 [../control-plane/README.md](../control-plane/README.md)。
+129 -36
View File
@@ -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();
}
});
+6 -6
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#edf2f4">
<title>AI操作台 · LianSyn-platform</title>
<link rel="stylesheet" href="styles.css?v=20260907-leader-summary-2">
<link rel="stylesheet" href="styles.css?v=20260907-admin-task-isolation-1">
</head>
<body>
<main class="app-shell">
@@ -14,8 +14,8 @@
<img class="brand-logo" src="assets/ltjt-platform-logo.png" alt="LianSyn-platform">
</div>
<nav class="top-nav" aria-label="主菜单">
<a id="workbenchNav" class="top-nav-item is-active" aria-current="page" href="/">AI操作台</a>
<a id="historyNav" class="top-nav-item" href="/history">历史任务</a>
<a id="workbenchNav" class="top-nav-item is-active" aria-current="page" href="/" hidden>AI操作台</a>
<a id="historyNav" class="top-nav-item" href="/history" hidden>历史任务</a>
<a id="operationsDashboardNav" class="top-nav-item" href="/operations-dashboard" hidden>平台运行看板</a>
<a id="channelsNav" class="top-nav-item" href="/channels" hidden>AgentBus 渠道</a>
<a id="parserRoutingNav" class="top-nav-item" href="/parser-routing" hidden>解析策略</a>
@@ -32,7 +32,7 @@
<span class="status-icon-label">AI状态</span>
<span class="status-icon-value">未连接</span>
</button>
<button id="bridgeState" type="button" class="status-icon state-bad" title="点击查看插件状态详情" aria-expanded="false" aria-controls="statusDetailsPopover">
<button id="bridgeState" type="button" class="status-icon state-bad" title="点击查看插件状态详情" aria-expanded="false" aria-controls="statusDetailsPopover" hidden>
<span class="status-icon-glyph" aria-hidden="true">⌁</span>
<span class="status-icon-label">插件状态</span>
<span class="status-icon-value">未连接</span>
@@ -91,7 +91,7 @@
<div>
<p class="eyebrow">ACCOUNT DIRECTORY</p>
<h2>平台账号管理</h2>
<p class="muted">员工账号必须绑定唯一 ERP 账号;管理员只负责管理和查看,不绑定员工 ERP 执行身份。</p>
<p class="muted">员工账号必须绑定唯一 ERP 账号;管理员仅负责平台配置,不进入任务控制台、不查看或执行业务任务。</p>
</div>
</div>
<form id="accountForm" class="channel-form" novalidate>
@@ -423,6 +423,6 @@
</section>
</main>
<script src="app.js?v=20260907-leader-summary-2"></script>
<script src="app.js?v=20260907-admin-task-isolation-1"></script>
</body>
</html>