diff --git a/.project-docs/30-worklog/tasks/20260902-per-account-queue-hard-delete-a6d9f2c1.md b/.project-docs/30-worklog/tasks/20260902-per-account-queue-hard-delete-a6d9f2c1.md new file mode 100644 index 0000000..8e42d29 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260902-per-account-queue-hard-delete-a6d9f2c1.md @@ -0,0 +1,69 @@ +# Task: Scope ERP queues per account and restore hard delete + +## Identity + +- Task ID: 20260902-per-account-queue-hard-delete-a6d9f2c1 +- Mode: Feature +- Branch: codex/20260902-per-account-queue-hard-delete-a6d9f2c1-per-account-queue-hard-delete +- Worktree: /Users/inmanx/Documents/lwltAPI-per-account-queue-hard-delete-a6d9f2c1 +- Base commit: 69ea6d25178d75abf8d7fd728bd31c61764caa7d +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Replace organization-wide ERP claim serialization with an account-scoped claim lock and account-scoped active/confirmed FIFO checks. +- Preserve FIFO ordering for tasks assigned to the same platform/ERP account while allowing tasks assigned to different accounts to be claimed and executed independently. +- Keep reversible archive/restore as a separate operation, and restore `DELETE /api/tasks/:taskId` plus bulk-delete as permanent physical task deletion. +- Allow permanent deletion regardless of parse, handoff, queue, or ERP execution state, while retaining organization/account access control. +- Remove task-owned database rows through existing cascades, request post-commit OSS artifact cleanup, and invoke the existing local extension hard-delete bridge on a best-effort basis. +- Update the operator UI and regressions so archive/restore and irreversible force-delete are visibly distinct. +- Separate administrator-wide read visibility from executable routing: task SSE, automatic handoff, plugin result ingestion, and force-delete cleanup commands must be delivered only to the task's immutable `assigned_user_id`. + +## Intent And Constraints + +- The user explicitly superseded AUTH-001's organization-wide ERP FIFO and archive-only removal decisions for this task. Canonical decision and architecture updates remain Promotion Candidates for a later Integration task. +- A single account still has at most one active ERP execution and a deterministic FIFO of confirmed tasks; one account's active or queued task must not affect another account's claim result or queue position. +- Force delete is authoritative physical deletion and must not call the archive state gate. It is not an ERP rollback: already-written ERP data cannot be undone by deleting the platform record. +- A minimal `task.hard_deleted` audit event may remain as evidence of the destructive action; task content, events, attempts, sessions, inputs, artifacts, and delivery rows must be removed with the task. +- OSS cleanup is post-commit and best effort so storage unavailability cannot resurrect or block an already-authorized database deletion. +- Do not change Chrome extension source/version: its existing `DELETE_TASK` / `LTJT_HARD_DELETE_TASK` path already performs local cancellation and cache removal. +- An administrator may still view organization tasks, but viewing authority must never authorize confirmation, claim, result submission, plugin-result handling, or receipt of another account's execution event/command. +- Do not deploy, restart services, mutate production tasks, operate ERP, or inspect secrets in this Feature task. + +## Outcome + +- `claimForBrowser` now uses an advisory transaction lock keyed by organization and executing user instead of locking the organization row. Both active-execution detection and confirmed FIFO selection are filtered by the locked task's account, so same-account work remains serialized while different accounts do not block or affect queue positions. +- Added `hardDeleteTask` and atomic `hardDeleteTasks` service operations. They preserve organization/owner access checks, intentionally omit status/handoff gates, write a minimal `task.hard_deleted` audit marker, remove task outbox entries, physically delete task rows, rely on existing cascades for all task-owned database records, and request post-commit OSS cleanup. +- `DELETE /api/tasks/:taskId` and `POST /api/tasks/bulk-delete` now use physical deletion; `/archive`, `/restore`, and `/bulk-archive` remain reversible and retain their active-task archive guard. +- The task detail and history UI now expose archive/restore separately from red permanent force-delete controls. Force delete works for active, waiting, archived, and terminal tasks, presents an explicit irreversible/ERP-write warning, removes local caches, and uses the existing `DELETE_TASK` extension bridge as best-effort cancellation/cleanup after server deletion. +- History selection and bulk force-delete are available for both active and archived views. Active tasks that cannot be archived can still be permanently deleted. +- `/api/events` is now explicitly an executable wake-up feed. Historical and live task events are unconditionally filtered by the authenticated account's `assigned_user_id`, including for administrators; the administrator's organization-wide task-list visibility no longer enters SSE dispatch routing. The reconnect cursor is likewise derived only from the current account's tasks. +- Plugin `TASK_RESULT_CHANGED` broadcasts, queued result persistence, and direct result polling now fail closed in the page unless the local task is assigned to the current authenticated account. This prevents an administrator page from attempting to persist another user's extension result and surfacing the resulting authorization rejection as a false task error. +- Hard delete emits a post-commit `browser-command` addressed only to each deleted task's assigned account. An online owner page uses the existing `DELETE_TASK` bridge to stop/clean its own plugin state; an administrator deleting an employee task never sends that cleanup command to the administrator's local plugin. Direct best-effort cleanup is also filtered to locally assigned tasks. +- Updated control-plane implementation documentation and regression coverage. No database migration or Chrome extension source/version change was required. + +## Verification + +- `node --check LianSyn-platform/app.js` — passed. +- `node --run check` — passed. +- `node --run test:control-plane` — passed, 162/162. +- `node --run test:legacy` — passed, 268/268. +- `node --run check:repo` — passed, 10/10. +- `node --run build` — passed. +- Focused account/routing/queue/delete regression — passed, 14/14. +- Focused account plus core control-plane regression — passed after the added account-isolation coverage. +- `git diff --check` — passed. + +## Follow-ups + +- Run an Integration Gate to resolve the intentional conflict with AUTH-001 and promote the accepted per-account FIFO plus explicit physical-delete behavior into canonical project memory. +- Merge/integrate this Feature branch before any rollout. Production deployment, service restart, and live task mutation were not performed and still require explicit user authorization. +- After an authorized rollout, smoke-test with an administrator plus two distinct assigned accounts: keep one employee account executing while confirming that the other employee account claims immediately; verify the administrator receives no employee executable SSE/result handoff; then force-delete a waiting and an active disposable employee task and verify database absence plus cleanup only in the owning employee plugin. + +## Promotion Candidates + +- Supersede AUTH-001's organization-wide ERP FIFO clause with account-scoped FIFO: each `assigned_user_id` owns one serialized ERP queue, while different assigned accounts execute independently. +- Supersede AUTH-001's archive-only removal clause: archive/restore remains the routine reversible path, while explicitly confirmed force delete permanently removes a task regardless of state and cannot roll back prior ERP effects. +- Record the invariant that administrator-wide read visibility is never execution authority: executable lists, SSE wake-ups, browser claims, plugin results, and browser cleanup commands are all scoped to immutable task assignment. +- Update `.project-docs/20-architecture/system-overview.md`, `.project-docs/20-architecture/data-flow.md`, `.project-docs/40-domain/business-rules.md`, `.project-docs/40-domain/success-criteria.md`, and `.project-docs/30-worklog/current-state.md` after Integration acceptance. diff --git a/LianSyn-platform/app.js b/LianSyn-platform/app.js index dfbc02d..e90ad98 100644 --- a/LianSyn-platform/app.js +++ b/LianSyn-platform/app.js @@ -80,10 +80,12 @@ let historyStatus = ''; let historyArchive = 'active'; let historyOffset = 0; const lifecycleScrollPositions = new Map(); +const taskArchiveStates = new Map(); const taskDeleteStates = new Map(); const historySelectedTaskIds = new Set(); const locallyDeletedTaskIds = new Set(); let historyBulkDeleteBusy = false; +let historyBulkAction = ''; let historyBatchMessage = ''; let historyBatchMessageKind = ''; const taskReconciliationStates = new Map(); @@ -1847,10 +1849,10 @@ async function syncRemoteTasks() { function startRemoteEventStream() { if (eventStream) eventStream.close(); - const lastEventId = runtimeTasks().reduce((highest, task) => ( + const lastEventId = runtimeTasks().filter(taskAssignedToCurrentAccount).reduce((highest, task) => ( Math.max(highest, Number(task?.last_event_id || 0)) ), 0); - eventStream = new EventSource(`/api/events?since=${encodeURIComponent(lastEventId)}`); + eventStream = new EventSource(`/api/events?since=${encodeURIComponent(lastEventId)}&executable_by=me`); eventStream.addEventListener('open', () => { syncAutomationSettings({ background: true }).catch(() => {}); }); @@ -1859,6 +1861,9 @@ function startRemoteEventStream() { setOutput({ status: 'sync_error', message: error.message }); }); }); + eventStream.addEventListener('browser-command', (event) => { + handleBrowserCommand(event).catch(() => {}); + }); eventStream.onerror = () => { // EventSource reconnects automatically; polling remains the fallback authority refresh. }; @@ -2256,6 +2261,11 @@ function taskAssignedToCurrentAccount(task) { return Boolean(authUser?.id && task?.assignee?.id === authUser.id); } +function extensionTaskBelongsToCurrentAccount(taskId) { + const task = findTaskById(String(taskId || '').trim()); + return Boolean(task && taskAssignedToCurrentAccount(task)); +} + function taskCanonicalStatus(task) { return String(task?.status || task?.result?.status || 'created'); } @@ -2538,7 +2548,7 @@ function isTaskPollable(task) { ); } -function canDeleteTask(task) { +function canArchiveTask(task) { if (!task) return false; if (task.archived_at) return true; return ![ @@ -2547,6 +2557,11 @@ function canDeleteTask(task) { && !['awaiting_handoff', 'accepted', 'running'].includes(String(task.handoff_status || '')); } +function taskMutationInProgress(taskId) { + return taskArchiveStates.get(taskId) === 'updating' + || taskDeleteStates.get(taskId) === 'deleting'; +} + function canStartConfirmedTask(task) { return Boolean( task?.operation @@ -3634,6 +3649,7 @@ function renderTaskDetail() { const task = selectedTask(); const taskState = $('#taskState'); const confirmButton = $('#confirmTaskButton'); + const archiveButton = $('#archiveTaskButton'); const deleteButton = $('#deleteTaskButton'); const status = task?.status || task?.result?.status || 'created'; const importantMessagePanel = renderTaskImportantMessage(task); @@ -3658,12 +3674,19 @@ function renderTaskDetail() { ? RETRY_HANDOFF_LABEL : lifecycleReview ? '审核并授权本次测试写入' : MANUAL_HANDOFF_LABEL; } + if (archiveButton) { + const archiveState = task ? taskArchiveStates.get(task.task_id) : ''; + archiveButton.disabled = !canArchiveTask(task) || taskMutationInProgress(task?.task_id); + if (archiveState === 'updating') archiveButton.textContent = task?.archived_at ? '恢复中…' : '归档中…'; + else if (archiveState === 'failed') archiveButton.textContent = task?.archived_at ? '重试恢复' : '重试归档'; + else archiveButton.textContent = task?.archived_at ? '恢复任务' : '归档任务'; + } if (deleteButton) { const deleteState = task ? taskDeleteStates.get(task.task_id) : ''; - deleteButton.disabled = !canDeleteTask(task) || deleteState === 'deleting'; - if (deleteState === 'deleting') deleteButton.textContent = task?.archived_at ? '恢复中…' : '归档中…'; - else if (deleteState === 'failed') deleteButton.textContent = task?.archived_at ? '重试恢复' : '重试归档'; - else deleteButton.textContent = task?.archived_at ? '恢复任务' : '归档任务'; + deleteButton.disabled = !task || taskMutationInProgress(task?.task_id); + if (deleteState === 'deleting') deleteButton.textContent = '强制删除中…'; + else if (deleteState === 'failed') deleteButton.textContent = '重试强制删除'; + else deleteButton.textContent = '强制删除任务'; } if (taskState) { taskState.textContent = task ? taskStatusLabel(task) : '未创建'; @@ -4058,14 +4081,15 @@ function resetHistoryTaskSelection({ clearMessage = true } = {}) { function renderHistoryBatchActions(visibleTasks = []) { const actions = $('#historyBatchActions'); if (!actions) return; - actions.hidden = !IS_HISTORY_PAGE || historyArchive === 'archived'; - if (!IS_HISTORY_PAGE || historyArchive === 'archived') return; + actions.hidden = !IS_HISTORY_PAGE; + if (!IS_HISTORY_PAGE) return; const visibleTaskIds = new Set(visibleTasks.map((task) => task?.task_id).filter(Boolean)); for (const taskId of [...historySelectedTaskIds]) { if (!visibleTaskIds.has(taskId)) historySelectedTaskIds.delete(taskId); } const selectedCount = historySelectedTaskIds.size; + const selectedTasks = visibleTasks.filter((task) => historySelectedTaskIds.has(task.task_id)); const allVisibleSelected = visibleTaskIds.size > 0 && selectedCount === visibleTaskIds.size; const selectAll = $('#historySelectAll'); if (selectAll) { @@ -4081,14 +4105,26 @@ function renderHistoryBatchActions(visibleTasks = []) { 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 + const archiveButton = $('#historyArchiveSelectedButton'); + if (archiveButton) { + const archiveBlocked = selectedTasks.some((task) => !canArchiveTask(task)); + archiveButton.hidden = historyArchive === 'archived'; + archiveButton.disabled = historyBulkDeleteBusy || selectedCount === 0 || archiveBlocked; + archiveButton.textContent = historyBulkDeleteBusy && historyBulkAction === 'archive' ? `正在归档 ${selectedCount} 个…` : selectedCount > 0 ? `归档所选 (${selectedCount})` : '归档所选'; + archiveButton.title = archiveBlocked ? '所选任务包含正在处理或等待 ERP 执行的任务,不能归档;可改用强制删除。' : ''; + } + const deleteButton = $('#historyDeleteSelectedButton'); + if (deleteButton) { + deleteButton.disabled = historyBulkDeleteBusy || selectedCount === 0; + deleteButton.textContent = historyBulkDeleteBusy && historyBulkAction === 'delete' + ? `正在强制删除 ${selectedCount} 个…` + : selectedCount > 0 + ? `强制删除所选 (${selectedCount})` + : '强制删除所选'; } actions.setAttribute('aria-busy', historyBulkDeleteBusy ? 'true' : 'false'); } @@ -4123,6 +4159,7 @@ function renderTaskCards() { return; } for (const task of visibleTasks) { + const archiveState = taskArchiveStates.get(task.task_id) || ''; const deleteState = taskDeleteStates.get(task.task_id) || ''; const card = document.createElement('article'); card.className = `task-card${task.task_id === currentTaskId ? ' is-selected' : ''}${historySelectedTaskIds.has(task.task_id) ? ' is-checked' : ''}`; @@ -4162,32 +4199,46 @@ function renderTaskCards() { card.querySelector('.task-card-status .task-pill').textContent = taskStatusLabel(task); if (IS_HISTORY_PAGE) { card.classList.add('has-history-actions'); - if (historyArchive !== 'archived') { - const selection = document.createElement('label'); - selection.className = 'task-card-select'; - selection.title = `选择任务 ${task.task_id}`; - const checkbox = document.createElement('input'); - checkbox.type = 'checkbox'; - checkbox.dataset.historyTaskSelect = task.task_id; - checkbox.checked = historySelectedTaskIds.has(task.task_id); - checkbox.disabled = historyBulkDeleteBusy || deleteState === 'deleting'; - checkbox.setAttribute('aria-label', `选择任务 ${task.task_id}`); - selection.append(checkbox); - card.prepend(selection); - } + const selection = document.createElement('label'); + selection.className = 'task-card-select'; + selection.title = `选择任务 ${task.task_id}`; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.dataset.historyTaskSelect = task.task_id; + checkbox.checked = historySelectedTaskIds.has(task.task_id); + checkbox.disabled = historyBulkDeleteBusy || taskMutationInProgress(task.task_id); + checkbox.setAttribute('aria-label', `选择任务 ${task.task_id}`); + selection.append(checkbox); + card.prepend(selection); + + const actions = document.createElement('div'); + actions.className = 'task-card-actions'; + const archiveButton = document.createElement('button'); + archiveButton.type = 'button'; + archiveButton.className = 'secondary-button task-card-archive'; + archiveButton.dataset.historyTaskArchive = task.task_id; + archiveButton.disabled = historyBulkDeleteBusy || taskMutationInProgress(task.task_id) || !canArchiveTask(task); + archiveButton.textContent = archiveState === 'updating' + ? (task.archived_at ? '恢复中…' : '归档中…') + : archiveState === 'failed' + ? (task.archived_at ? '重试恢复' : '重试归档') + : (task.archived_at ? '恢复' : '归档'); + archiveButton.setAttribute('aria-label', `${task.archived_at ? '恢复' : '归档'}任务 ${task.task_id}`); + actions.append(archiveButton); const deleteButton = document.createElement('button'); deleteButton.type = 'button'; deleteButton.className = 'danger-button task-card-delete'; deleteButton.dataset.historyTaskDelete = task.task_id; - deleteButton.disabled = historyBulkDeleteBusy || deleteState === 'deleting'; + deleteButton.disabled = historyBulkDeleteBusy || taskMutationInProgress(task.task_id); deleteButton.textContent = deleteState === 'deleting' - ? (task.archived_at ? '恢复中…' : '归档中…') + ? '删除中…' : deleteState === 'failed' - ? (task.archived_at ? '重试恢复' : '重试归档') - : (task.archived_at ? '恢复' : '归档'); - deleteButton.setAttribute('aria-label', `${task.archived_at ? '恢复' : '归档'}任务 ${task.task_id}`); - card.append(deleteButton); + ? '重试删除' + : '强制删除'; + deleteButton.setAttribute('aria-label', `永久强制删除任务 ${task.task_id}`); + actions.append(deleteButton); + card.append(actions); } container.appendChild(card); } @@ -4525,7 +4576,7 @@ window.addEventListener('message', (event) => { if (message.type === 'TASK_RESULT_CHANGED') { const taskId = String(message.payload?.task_id || '').trim(); const result = message.payload?.result; - if (taskId && result && typeof result === 'object') { + if (taskId && result && typeof result === 'object' && extensionTaskBelongsToCurrentAccount(taskId)) { queueExtensionTaskResult(taskId, result).catch(() => {}); } return; @@ -4759,8 +4810,8 @@ async function handoffTaskToExtension(task) { stage: 'handoff', handoff_status: 'awaiting_handoff', message: queuePosition > 0 - ? `插件当前正在处理其他任务,本任务排队第 ${queuePosition} 位。` - : '插件当前正在处理其他任务,任务已持久化排队。' + ? `当前账号的插件正在处理其他任务,本任务在该账号队列第 ${queuePosition} 位。` + : '当前账号的插件正在处理其他任务,任务已进入该账号的持久化队列。' }); return { ok: true, @@ -4769,7 +4820,7 @@ async function handoffTaskToExtension(task) { queue_status: 'waiting', queue_position: claim.queue_position, dispatched: false, - message: '插件同一时间只处理一个任务,本任务已进入服务端队列。' + message: '同一账号的插件同一时间只处理一个任务;不同账号互不排队。' }; } startPolling(); @@ -5025,8 +5076,20 @@ function confirmTaskArchive(tasks) { ); } -function removeDeletedTaskLocally(taskId) { +function confirmTaskHardDelete(tasks) { + const selectedTasks = Array.isArray(tasks) ? tasks.filter(Boolean) : []; + if (!selectedTasks.length) return false; + const scope = selectedTasks.length === 1 + ? `任务 ${selectedTasks[0].task_id}` + : `所选 ${selectedTasks.length} 个任务`; + return window.confirm( + `确认永久强制删除${scope}?\n\n任务、原始输入、生命周期、执行记录、附件和回执都会被物理删除,无法恢复。此操作不受“正在处理”或“等待 ERP 执行”状态限制。\n\n如果 ERP 已经开始写入,删除平台记录不会撤销 ERP 中已经发生的操作;系统会向任务所属账号的在线插件发送停止与清理指令,不会误发给当前管理员插件。` + ); +} + +function removeTaskLocally(taskId) { const wasVisible = taskStore.some((item) => item.task_id === taskId); + taskArchiveStates.delete(taskId); taskDeleteStates.delete(taskId); historySelectedTaskIds.delete(taskId); taskStore = taskStore.filter((item) => item.task_id !== taskId); @@ -5049,7 +5112,7 @@ function removeDeletedTaskLocally(taskId) { if (wasVisible) taskListMeta.total = Math.max(0, taskListMeta.total - 1); } -function finishLocalTaskDeletions() { +function finishLocalTaskMutations() { if (!taskStore.some((item) => item.task_id === currentTaskId)) { currentTaskId = taskStore[0]?.task_id || ''; } @@ -5060,12 +5123,47 @@ function finishLocalTaskDeletions() { renderTaskCards(); } -async function deleteTask(taskId) { +async function cleanupDeletedTasksInExtension(tasks) { + const selectedTasks = Array.isArray(tasks) ? tasks.filter((task) => task?.task_id) : []; + const executableTasks = selectedTasks.filter(taskAssignedToCurrentAccount); + const results = await Promise.allSettled( + executableTasks.map((task) => sendToExtension('DELETE_TASK', { task_id: task.task_id }, 3_000)) + ); + return { + requested: executableTasks.length, + skipped: selectedTasks.length - executableTasks.length, + failed: results.filter((result) => result.status === 'rejected').length + }; +} + +async function handleBrowserCommand(event) { + let command = null; + try { + command = JSON.parse(String(event?.data || '')); + } catch (error) { + return; + } + if (command?.action !== 'hard_delete' || command?.target_user_id !== authUser?.id) return; + const taskId = String(command.task_id || '').trim(); + if (!taskId) return; + try { + await sendToExtension('DELETE_TASK', { task_id: taskId }, 3_000); + } catch (error) { + // The server-side force delete remains authoritative even if this online + // browser cannot acknowledge cancellation of its own local plugin task. + } + locallyDeletedTaskIds.add(taskId); + removeTaskLocally(taskId); + finishLocalTaskMutations(); + void syncRemoteTasks().catch(() => {}); +} + +async function toggleTaskArchive(taskId) { const task = findTaskById(taskId); if (!task) return; - if (taskDeleteStates.get(taskId) === 'deleting') return; + if (taskMutationInProgress(taskId)) return; - taskDeleteStates.set(taskId, 'deleting'); + taskArchiveStates.set(taskId, 'updating'); renderTaskCards(); try { @@ -5078,15 +5176,15 @@ async function deleteTask(taskId) { body: {}, timeoutMs: TASK_DELETE_API_TIMEOUT_MS }); - removeDeletedTaskLocally(taskId); + removeTaskLocally(taskId); if (IS_HISTORY_PAGE) { historyBatchMessage = `任务 ${taskId} 已${restoring ? '恢复' : '归档'}。`; historyBatchMessageKind = 'success'; } - finishLocalTaskDeletions(); + finishLocalTaskMutations(); void syncRemoteTasks().catch(() => {}); } catch (error) { - taskDeleteStates.set(taskId, 'failed'); + taskArchiveStates.set(taskId, 'failed'); if (IS_HISTORY_PAGE) { historyBatchMessage = `任务 ${taskId} 操作失败:${error.message || String(error)}`; historyBatchMessageKind = 'error'; @@ -5103,16 +5201,58 @@ async function deleteTask(taskId) { } } -async function deleteHistorySelectedTasks() { +async function hardDeleteTask(taskId) { + const task = findTaskById(taskId); + if (!task || taskMutationInProgress(taskId)) return; + + taskDeleteStates.set(taskId, 'deleting'); + renderTaskCards(); + + try { + await apiRequest(`/api/tasks/${encodeURIComponent(taskId)}`, { + method: 'DELETE', + timeoutMs: TASK_DELETE_API_TIMEOUT_MS + }); + const extensionCleanup = await cleanupDeletedTasksInExtension([task]); + locallyDeletedTaskIds.add(taskId); + removeTaskLocally(taskId); + if (IS_HISTORY_PAGE) { + historyBatchMessage = extensionCleanup.failed + ? `任务 ${taskId} 已永久删除;本机插件未确认清理,请确认 ERP 当前状态。` + : `任务 ${taskId} 已永久删除。`; + historyBatchMessageKind = 'success'; + } + finishLocalTaskMutations(); + void syncRemoteTasks().catch(() => {}); + } catch (error) { + taskDeleteStates.set(taskId, 'failed'); + if (IS_HISTORY_PAGE) { + historyBatchMessage = `任务 ${taskId} 强制删除失败:${error.message || String(error)}`; + historyBatchMessageKind = 'error'; + } + if (currentTaskId === taskId) setTaskState('强制删除失败'); + setOutput({ + status: 'hard_delete_error', + task_id: taskId, + message: `任务强制删除失败:${error.message || String(error)}` + }); + renderTaskCards(); + if (currentTaskId === taskId) setTaskState('强制删除失败'); + throw error; + } +} + +async function archiveHistorySelectedTasks() { if (!IS_HISTORY_PAGE || historyBulkDeleteBusy) return; const selectedTasks = taskStore.filter((task) => historySelectedTaskIds.has(task.task_id)); if (!selectedTasks.length || !confirmTaskArchive(selectedTasks)) return; const taskIds = selectedTasks.map((task) => task.task_id); historyBulkDeleteBusy = true; + historyBulkAction = 'archive'; historyBatchMessage = `正在归档 ${taskIds.length} 个任务…`; historyBatchMessageKind = ''; for (const task of selectedTasks) { - taskDeleteStates.set(task.task_id, 'deleting'); + taskArchiveStates.set(task.task_id, 'updating'); } renderTaskCards(); @@ -5122,14 +5262,14 @@ async function deleteHistorySelectedTasks() { body: { task_ids: taskIds }, timeoutMs: TASK_DELETE_API_TIMEOUT_MS }); - for (const taskId of taskIds) removeDeletedTaskLocally(taskId); + for (const taskId of taskIds) removeTaskLocally(taskId); resetHistoryTaskSelection({ clearMessage: false }); historyBatchMessage = `已归档 ${taskIds.length} 个任务。`; historyBatchMessageKind = 'success'; - finishLocalTaskDeletions(); + finishLocalTaskMutations(); void syncRemoteTasks().catch(() => {}); } catch (error) { - for (const taskId of taskIds) taskDeleteStates.set(taskId, 'failed'); + for (const taskId of taskIds) taskArchiveStates.set(taskId, 'failed'); historyBatchMessage = `批量归档失败,所选任务均未变更:${error.message || String(error)}`; historyBatchMessageKind = 'error'; setTaskState('批量归档失败'); @@ -5141,14 +5281,69 @@ async function deleteHistorySelectedTasks() { void syncRemoteTasks().catch(() => {}); } finally { historyBulkDeleteBusy = false; + historyBulkAction = ''; renderTaskCards(); } } +async function deleteHistorySelectedTasks() { + if (!IS_HISTORY_PAGE || historyBulkDeleteBusy) return; + const selectedTasks = taskStore.filter((task) => historySelectedTaskIds.has(task.task_id)); + if (!selectedTasks.length || !confirmTaskHardDelete(selectedTasks)) return; + const taskIds = selectedTasks.map((task) => task.task_id); + historyBulkDeleteBusy = true; + historyBulkAction = 'delete'; + historyBatchMessage = `正在强制删除 ${taskIds.length} 个任务…`; + historyBatchMessageKind = ''; + for (const taskId of taskIds) taskDeleteStates.set(taskId, 'deleting'); + renderTaskCards(); + + try { + await apiRequest('/api/tasks/bulk-delete', { + method: 'POST', + body: { task_ids: taskIds }, + timeoutMs: TASK_DELETE_API_TIMEOUT_MS + }); + const extensionCleanup = await cleanupDeletedTasksInExtension(selectedTasks); + for (const taskId of taskIds) { + locallyDeletedTaskIds.add(taskId); + removeTaskLocally(taskId); + } + resetHistoryTaskSelection({ clearMessage: false }); + historyBatchMessage = extensionCleanup.failed + ? `已永久删除 ${taskIds.length} 个任务;其中 ${extensionCleanup.failed} 个未获得本机插件清理确认,请确认 ERP 当前状态。` + : `已永久删除 ${taskIds.length} 个任务。`; + historyBatchMessageKind = 'success'; + finishLocalTaskMutations(); + void syncRemoteTasks().catch(() => {}); + } catch (error) { + for (const taskId of taskIds) taskDeleteStates.set(taskId, 'failed'); + historyBatchMessage = `批量强制删除失败,所选任务均未删除:${error.message || String(error)}`; + historyBatchMessageKind = 'error'; + setTaskState('批量强制删除失败'); + setOutput({ + status: 'bulk_hard_delete_error', + task_ids: taskIds, + message: historyBatchMessage + }); + void syncRemoteTasks().catch(() => {}); + } finally { + historyBulkDeleteBusy = false; + historyBulkAction = ''; + renderTaskCards(); + } +} + +async function archiveSelectedTask() { + const task = selectedTask(); + if (!task) return; + return toggleTaskArchive(task.task_id); +} + async function deleteSelectedTask() { const task = selectedTask(); if (!task) return; - return deleteTask(task.task_id); + return hardDeleteTask(task.task_id); } function extensionResultVersion(result) { @@ -5161,6 +5356,7 @@ function extensionResultVersion(result) { } async function persistExtensionTaskResult(taskId, result) { + if (!extensionTaskBelongsToCurrentAccount(taskId)) return false; try { const executionId = result.execution_id; if (!executionId) { @@ -5190,7 +5386,8 @@ async function persistExtensionTaskResult(taskId, result) { function queueExtensionTaskResult(taskId, result) { const normalizedTaskId = String(taskId || '').trim(); - if (!normalizedTaskId || !result || typeof result !== 'object') return Promise.resolve(false); + if (!normalizedTaskId || !result || typeof result !== 'object' + || !extensionTaskBelongsToCurrentAccount(normalizedTaskId)) return Promise.resolve(false); const version = extensionResultVersion(result); pendingExtensionResults.set(normalizedTaskId, { result, version }); const existing = extensionResultPersistQueues.get(normalizedTaskId); @@ -5220,6 +5417,7 @@ function queueExtensionTaskResult(taskId, result) { async function pollTaskResult(taskId, { allowReconciliation = false } = {}) { const knownTask = findTaskById(taskId); + if (!knownTask || !taskAssignedToCurrentAccount(knownTask)) return null; if (!allowReconciliation && knownTask) { const localStatuses = [knownTask.status, knownTask.result?.status, knownTask.handoff_status] .map((value) => String(value || '').toLowerCase()); @@ -5522,6 +5720,7 @@ document.addEventListener('DOMContentLoaded', async () => { extensionResultPersistQueues.clear(); pendingExtensionResults.clear(); persistedExtensionResultVersions.clear(); + taskArchiveStates.clear(); taskDeleteStates.clear(); taskReplyStates.clear(); taskReplyDrafts.clear(); @@ -5532,6 +5731,7 @@ document.addEventListener('DOMContentLoaded', async () => { accountAuthorizationDraft = new Set(); historySelectedTaskIds.clear(); locallyDeletedTaskIds.clear(); + historyBulkAction = ''; historyBatchMessage = ''; historyBatchMessageKind = ''; currentTaskId = ''; @@ -5608,6 +5808,9 @@ document.addEventListener('DOMContentLoaded', async () => { $('#historyDeleteSelectedButton').addEventListener('click', () => { void deleteHistorySelectedTasks(); }); + $('#historyArchiveSelectedButton').addEventListener('click', () => { + void archiveHistorySelectedTasks(); + }); $('#automationToggleButton').addEventListener('click', () => { toggleAutomationSetting().catch((error) => { setTaskState('全自动化设置失败'); @@ -5863,16 +6066,25 @@ document.addEventListener('DOMContentLoaded', async () => { message: error.message })); }); - $('#deleteTaskButton').addEventListener('click', () => { + $('#archiveTaskButton').addEventListener('click', () => { const task = selectedTask(); if (!task) return; if (!task.archived_at && !confirmTaskArchive([task])) return; - deleteSelectedTask().catch((error) => setOutput({ + archiveSelectedTask().catch((error) => setOutput({ status: 'archive_error', task_id: task.task_id, message: error.message || String(error) })); }); + $('#deleteTaskButton').addEventListener('click', () => { + const task = selectedTask(); + if (!task || !confirmTaskHardDelete([task])) return; + deleteSelectedTask().catch((error) => setOutput({ + status: 'hard_delete_error', + task_id: task.task_id, + message: error.message || String(error) + })); + }); $('#taskCards').addEventListener('change', (event) => { const checkbox = event.target.closest('[data-history-task-select]'); const taskId = checkbox?.dataset.historyTaskSelect; @@ -5884,12 +6096,21 @@ document.addEventListener('DOMContentLoaded', async () => { renderTaskCards(); }); $('#taskCards').addEventListener('click', (event) => { + const archiveButton = event.target.closest('[data-history-task-archive]'); + if (archiveButton) { + const taskId = archiveButton.dataset.historyTaskArchive; + const task = taskStore.find((item) => item.task_id === taskId); + if (task && (task.archived_at || confirmTaskArchive([task]))) { + void toggleTaskArchive(taskId).catch(() => {}); + } + return; + } const deleteButton = event.target.closest('[data-history-task-delete]'); if (deleteButton) { const taskId = deleteButton.dataset.historyTaskDelete; const task = taskStore.find((item) => item.task_id === taskId); - if (task && (task.archived_at || confirmTaskArchive([task]))) { - void deleteTask(taskId).catch(() => {}); + if (task && confirmTaskHardDelete([task])) { + void hardDeleteTask(taskId).catch(() => {}); } return; } diff --git a/LianSyn-platform/index.html b/LianSyn-platform/index.html index a1297f0..1f1201e 100644 --- a/LianSyn-platform/index.html +++ b/LianSyn-platform/index.html @@ -5,7 +5,7 @@