(function installCcwebTaskBoard(global) { 'use strict'; const VERSION = 1; const MESSAGE_TYPES = Object.freeze({ query: 'task_board_query', trackingSet: 'task_tracking_set', statusSet: 'task_status_set', definitionUpsert: 'task_status_definition_upsert', definitionRemove: 'task_status_definition_remove', archiveSet: 'task_archive_set', boardResult: 'task_board_result', boardEvent: 'task_board_event', trackingResult: 'task_tracking_result', statusResult: 'task_status_result', definitionsResult: 'task_status_definitions_result', archiveResult: 'task_archive_result', }); const RESPONSE_TYPES = new Set([ MESSAGE_TYPES.boardResult, MESSAGE_TYPES.boardEvent, MESSAGE_TYPES.trackingResult, MESSAGE_TYPES.statusResult, MESSAGE_TYPES.definitionsResult, MESSAGE_TYPES.archiveResult, ]); let requestSequence = 0; function isObject(value) { return !!value && typeof value === 'object' && !Array.isArray(value); } function hasOwn(value, key) { return isObject(value) && Object.prototype.hasOwnProperty.call(value, key); } function text(value, fallback = '') { if (value === null || value === undefined) return fallback; const normalized = String(value).trim(); return normalized || fallback; } function finiteNumber(value, fallback = null) { const normalized = Number(value); return Number.isFinite(normalized) ? normalized : fallback; } function labelFromValue(value, fallback = '') { if (isObject(value)) { return text(value.label || value.name || value.title || value.id, fallback); } return text(value, fallback); } function createRequestId(prefix = 'task') { const cryptoApi = global.crypto; if (cryptoApi && typeof cryptoApi.randomUUID === 'function') { return `${prefix}-${cryptoApi.randomUUID()}`; } requestSequence += 1; return `${prefix}-${Date.now().toString(36)}-${requestSequence.toString(36)}`; } function normalizeColor(value) { const candidate = text(value); if (!candidate) return ''; if (global.CSS && typeof global.CSS.supports === 'function' && !global.CSS.supports('color', candidate)) { return ''; } return candidate; } function normalizeDefinition(raw, index = 0) { const input = isObject(raw) ? raw : {}; const id = text(input.id || input.statusId, `status-${index + 1}`); return { id, label: text(input.label || input.name, id), prompt: text(input.prompt), color: normalizeColor(input.color), order: finiteNumber(input.order, index * 10), system: input.system === true, enabled: input.enabled !== false, missingDefinition: input.missingDefinition === true, }; } function normalizeTask(raw, index = 0) { const input = isObject(raw) ? raw : {}; const tracking = isObject(input.taskTracking) ? input.taskTracking : {}; const session = isObject(input.session) ? input.session : {}; const runtime = isObject(input.runtime) ? input.runtime : {}; const sessionId = text(input.sessionId || session.id || input.id, `task-${index + 1}`); const hasTrackingFlag = hasOwn(tracking, 'enabled') || hasOwn(input, 'trackingEnabled'); const explicitRuntimeState = labelFromValue( input.runtimeState || input.runtimeStatus || input.runState || input.sessionStatus || runtime.status || session.runtimeStatus, ); const runtimeState = explicitRuntimeState || (input.waitingOnChildren === true ? '等待子任务' : '') || (input.isRunning === true ? '运行中' : ''); const archivedAt = hasOwn(tracking, 'archivedAt') ? tracking.archivedAt : input.archivedAt; const archivedBy = hasOwn(tracking, 'archivedBy') ? tracking.archivedBy : input.archivedBy; return { sessionId, title: text(input.title || session.title || input.name, '未命名任务'), statusId: text(tracking.statusId || input.statusId), summary: text(tracking.summary || input.summary), reason: text(tracking.reason || input.reason), source: text(tracking.source || input.source), isRunning: input.isRunning === true, runtimeState, runtimeDetail: text(input.runtimeDetail || runtime.detail || input.lastRuntimeMessage), agent: labelFromValue(input.agent || input.agentName || session.agent || runtime.agent), project: labelFromValue(input.project || input.projectName || session.project), cwd: text(input.cwd || session.cwd), archivedAt: archivedAt || null, archivedBy: text(archivedBy), updatedAt: tracking.statusUpdatedAt || input.updatedAt || input.updated || session.updatedAt || null, version: finiteNumber(tracking.version, finiteNumber(input.version, null)), order: finiteNumber(input.order, index), trackingEnabled: hasTrackingFlag ? (hasOwn(tracking, 'enabled') ? tracking.enabled === true : input.trackingEnabled === true) : true, }; } function sortDefinitions(definitions) { return definitions.slice().sort((left, right) => ( left.order - right.order || left.label.localeCompare(right.label, 'zh-CN') || left.id.localeCompare(right.id) )); } function sortTasks(tasks) { return tasks.slice().sort((left, right) => { if (left.order !== right.order) return left.order - right.order; const leftTime = Date.parse(left.updatedAt || '') || 0; const rightTime = Date.parse(right.updatedAt || '') || 0; if (leftTime !== rightTime) return rightTime - leftTime; return left.sessionId.localeCompare(right.sessionId); }); } function findArray(source, names) { if (!isObject(source)) return null; for (const name of names) { if (Array.isArray(source[name])) return source[name]; } return null; } function collectEnvelope(message) { const sources = []; if (isObject(message)) sources.push(message); for (const key of ['payload', 'result', 'data', 'event']) { if (isObject(message?.[key])) sources.push(message[key]); } const nestedEvent = sources.find((source) => isObject(source.event)); if (nestedEvent) sources.push(nestedEvent.event); return Object.assign({}, ...sources); } function getProtocolError(message) { const envelope = collectEnvelope(message); const rawError = envelope.error; if (isObject(rawError)) { return { code: text(rawError.code || envelope.code, 'task_board_error'), message: text(rawError.message || envelope.message, '任务看板操作失败'), }; } if (rawError || envelope.ok === false || envelope.success === false) { return { code: text(envelope.code || rawError, 'task_board_error'), message: text(envelope.message || rawError, '任务看板操作失败'), }; } return null; } function createNode(doc, tagName, className, content) { const node = doc.createElement(tagName); if (className) node.className = className; if (content !== undefined && content !== null) node.textContent = String(content); return node; } function setStatusColor(node, color) { const normalized = normalizeColor(color); if (!node?.style || typeof node.style.setProperty !== 'function') return; node.style.setProperty('--task-board-status-color', normalized); } function optionNode(doc, value, label, selected = false) { const option = createNode(doc, 'option', '', label); option.value = value; option.selected = selected; return option; } function shortSessionId(sessionId) { const normalized = text(sessionId, '—'); return normalized.length > 14 ? `${normalized.slice(0, 6)}…${normalized.slice(-5)}` : normalized; } function domToken(value, suffix = '') { const normalized = text(value, 'status').replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'status'; return `${normalized}${suffix ? `-${suffix}` : ''}`; } function formatTime(value) { const timestamp = Date.parse(value || ''); if (!Number.isFinite(timestamp)) return ''; try { return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }).format(new Date(timestamp)); } catch (_) { return new Date(timestamp).toISOString(); } } function safeFocus(node) { if (node && typeof node.focus === 'function') node.focus(); } function mount(target, options = {}) { const doc = options.document || target?.ownerDocument || global.document; if (!doc || typeof doc.createElement !== 'function') { throw new TypeError('CcwebTaskBoard.mount 需要可用的 document'); } const root = typeof target === 'string' ? doc.querySelector(target) : target; if (!root || typeof root.replaceChildren !== 'function') { throw new TypeError('CcwebTaskBoard.mount 需要有效的挂载节点'); } const adapters = { send: typeof options.send === 'function' ? options.send : null, openSession: typeof options.openSession === 'function' ? options.openSession : null, onError: typeof options.onError === 'function' ? options.onError : null, onStateChange: typeof options.onStateChange === 'function' ? options.onStateChange : null, requestId: typeof options.createRequestId === 'function' ? options.createRequestId : (typeof options.requestId === 'function' ? options.requestId : createRequestId), }; const state = { tasks: [], statusDefinitions: [], definitionsVersion: null, filters: { search: '', archived: false, }, statusManagerOpen: false, pending: 0, error: null, destroyed: false, }; const fitColumnLimit = Math.min(6, Math.max(1, Math.floor(finiteNumber(options.fitColumnLimit, 6)))); const wallRotationMs = Math.max(20, finiteNumber(options.wallRotationMs, 12000)); const wallUpdateHoldMs = Math.max(wallRotationMs, finiteNumber(options.wallUpdateHoldMs, 20000)); const wallCardsPerPage = Math.min(4, Math.max(1, Math.floor(finiteNumber(options.wallCardsPerPage, 2)))); let searchTimer = null; let clockTimer = null; let wallRotationTimer = null; let wallHasRendered = false; let wallRotationCursor = 0; let previousFocus = null; let latestQueryRequestId = ''; let hasRenderedBoard = false; const columnRecords = new Map(); const cardRecords = new Map(); const wallColumnRecords = new Map(); const wallCardRecords = new Map(); let wallVisibleSessionIds = new Set(); let boardEmpty = null; const shell = createNode(doc, 'section', 'ccweb-task-board'); shell.setAttribute('aria-label', text(options.ariaLabel, '独立任务看板')); shell.dataset.taskBoardVersion = String(VERSION); const header = createNode(doc, 'header', 'ccweb-task-board__toolbar'); const headingGroup = createNode(doc, 'div', 'ccweb-task-board__heading'); const title = createNode(doc, 'h2', 'ccweb-task-board__title', text(options.title, '任务看板')); const summary = createNode(doc, 'p', 'ccweb-task-board__summary', '等待任务数据'); summary.setAttribute('aria-live', 'polite'); headingGroup.append(title, summary); const clock = createNode(doc, 'aside', 'ccweb-task-board__clock'); clock.setAttribute('aria-label', '当前时间'); const clockTime = createNode(doc, 'time', 'ccweb-task-board__clock-time'); const clockDate = createNode(doc, 'span', 'ccweb-task-board__clock-date'); clock.append(clockTime, clockDate); const controls = createNode(doc, 'div', 'ccweb-task-board__controls'); controls.setAttribute('role', 'group'); controls.setAttribute('aria-label', '任务看板搜索与操作'); const searchLabel = createNode(doc, 'label', 'ccweb-task-board__search'); const searchCaption = createNode(doc, 'span', 'ccweb-task-board__sr-only', '搜索任务'); const searchGlyph = createNode(doc, 'span', 'ccweb-task-board__search-glyph', '⌕'); searchGlyph.setAttribute('aria-hidden', 'true'); const searchInput = createNode(doc, 'input', 'ccweb-task-board__input'); searchInput.type = 'search'; searchInput.placeholder = '搜索任务、会话或摘要'; searchInput.autocomplete = 'off'; searchLabel.append(searchCaption, searchGlyph, searchInput); const archiveButton = createNode(doc, 'button', 'ccweb-task-board__button', '查看归档'); archiveButton.type = 'button'; archiveButton.dataset.action = 'toggle-archive'; archiveButton.setAttribute('aria-pressed', 'false'); const statusButton = createNode(doc, 'button', 'ccweb-task-board__button', '列管理'); statusButton.type = 'button'; statusButton.dataset.action = 'manage-statuses'; statusButton.setAttribute('aria-haspopup', 'dialog'); statusButton.setAttribute('aria-expanded', 'false'); controls.append( searchLabel, archiveButton, statusButton, ); header.append(headingGroup, controls); const workspace = createNode(doc, 'div', 'ccweb-task-board__workspace'); const board = createNode(doc, 'div', 'ccweb-task-board__lanes'); board.setAttribute('aria-label', '任务状态列'); board.tabIndex = 0; const wall = createNode(doc, 'section', 'ccweb-task-board__wall'); wall.setAttribute('aria-label', '横屏任务看板'); const wallGrid = createNode(doc, 'div', 'ccweb-task-board__wall-grid'); wallGrid.setAttribute('role', 'list'); const wallEmpty = createNode(doc, 'section', 'ccweb-task-board__wall-empty'); wallEmpty.setAttribute('role', 'status'); wallEmpty.append( createNode(doc, 'span', 'ccweb-task-board__wall-empty-mark', '◇'), createNode(doc, 'span', '', '等待状态定义'), ); wallGrid.append(clock, wallEmpty); wall.appendChild(wallGrid); workspace.append(board, wall); const statusLayer = createNode(doc, 'div', 'ccweb-task-board__status-layer'); statusLayer.hidden = true; const statusScrim = createNode(doc, 'button', 'ccweb-task-board__status-scrim'); statusScrim.type = 'button'; statusScrim.dataset.action = 'close-statuses'; statusScrim.setAttribute('aria-label', '关闭列管理'); const statusPanel = createNode(doc, 'aside', 'ccweb-task-board__status-panel'); statusPanel.setAttribute('role', 'dialog'); statusPanel.setAttribute('aria-modal', 'true'); statusPanel.setAttribute('aria-labelledby', 'ccweb-task-board-status-title'); const statusHeader = createNode(doc, 'header', 'ccweb-task-board__status-header'); const statusHeaderCopy = createNode(doc, 'div', 'ccweb-task-board__status-header-copy'); const statusPanelTitle = createNode(doc, 'h3', 'ccweb-task-board__status-title', '列管理'); statusPanelTitle.id = 'ccweb-task-board-status-title'; const statusPanelSummary = createNode(doc, 'p', 'ccweb-task-board__status-summary', '看板列及分类提示词由服务端数据驱动'); statusHeaderCopy.append(statusPanelTitle, statusPanelSummary); const statusClose = createNode(doc, 'button', 'ccweb-task-board__icon-button', '×'); statusClose.type = 'button'; statusClose.dataset.action = 'close-statuses'; statusClose.setAttribute('aria-label', '关闭列管理'); statusHeader.append(statusHeaderCopy, statusClose); const statusBody = createNode(doc, 'div', 'ccweb-task-board__status-body'); statusPanel.append(statusHeader, statusBody); statusLayer.append(statusScrim, statusPanel); const liveRegion = createNode(doc, 'div', 'ccweb-task-board__sr-only'); liveRegion.setAttribute('role', 'status'); liveRegion.setAttribute('aria-live', 'polite'); shell.append(header, workspace, statusLayer, liveRegion); root.replaceChildren(shell); const refs = { shell, summary, searchInput, archiveButton, statusButton, clock, clockTime, clockDate, board, wall, wallGrid, wallEmpty, statusLayer, statusPanel, statusClose, statusPanelSummary, statusBody, liveRegion, }; function announce(message) { refs.liveRegion.textContent = text(message); } function renderClock() { const now = new Date(); const hours = String(now.getHours()).padStart(2, '0'); const minutes = String(now.getMinutes()).padStart(2, '0'); const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']; refs.clockTime.textContent = `${hours}:${minutes}`; refs.clockTime.setAttribute('datetime', now.toISOString()); refs.clockDate.textContent = `${now.getMonth() + 1}月${now.getDate()}日 · ${weekdays[now.getDay()]}`; } function scheduleClock() { renderClock(); if (typeof global.setTimeout !== 'function') return; const now = new Date(); const delay = Math.max(1000, (60 - now.getSeconds()) * 1000 - now.getMilliseconds() + 20); clockTimer = global.setTimeout(scheduleClock, delay); if (clockTimer && typeof clockTimer.unref === 'function') clockTimer.unref(); } function wallTaskTimestamp(task) { return Date.parse(task?.updatedAt || '') || 0; } function compareWallTasks(left, right) { if (left.isRunning !== right.isRunning) return left.isRunning ? -1 : 1; const timeDifference = wallTaskTimestamp(right) - wallTaskTimestamp(left); if (timeDifference) return timeDifference; if (left.order !== right.order) return left.order - right.order; return left.sessionId.localeCompare(right.sessionId); } function getWallTasks() { return state.tasks .filter((task) => task.trackingEnabled && !task.archivedAt) .slice() .sort(compareWallTasks); } function wallTaskSignature(task) { if (!task) return ''; return JSON.stringify([ task.sessionId, task.title, task.statusId, task.summary, task.reason, task.isRunning, task.runtimeState, task.runtimeDetail, task.updatedAt, task.version, ]); } function clearWallRotation() { if (wallRotationTimer === null) return; global.clearTimeout(wallRotationTimer); wallRotationTimer = null; } function wallLayoutSize(statusCount) { const cellCount = Math.max(1, statusCount + 1); const preferredColumns = cellCount <= 4 ? Math.min(cellCount, 2) : Math.round(Math.sqrt(cellCount * (16 / 9))); const columns = Math.min(cellCount, Math.max(1, preferredColumns)); return { cellCount, columns, rows: Math.max(1, Math.ceil(cellCount / columns)) }; } function wallTasksByStatus(definitions, tasks) { const grouped = new Map(definitions.map((definition) => [definition.id, []])); for (const task of tasks) grouped.get(task.statusId)?.push(task); return grouped; } function overflowingWallStatusIds() { const definitions = getBoardDefinitions(); const grouped = wallTasksByStatus(definitions, getWallTasks()); const pageSize = wallLayoutSize(definitions.length || 1).rows >= 3 ? 1 : wallCardsPerPage; return definitions .filter((definition) => (grouped.get(definition.id)?.length || 0) > pageSize) .map((definition) => definition.id); } function scheduleWallRotation(delay = wallRotationMs) { clearWallRotation(); if (state.destroyed || prefersReducedBoardMotion() || !overflowingWallStatusIds().length) return; wallRotationTimer = global.setTimeout(() => { wallRotationTimer = null; advanceWallPage(); }, delay); if (wallRotationTimer && typeof wallRotationTimer.unref === 'function') wallRotationTimer.unref(); } function readWallCardRect(card) { if (!card || typeof card.getBoundingClientRect !== 'function' || !refs.wall.contains(card)) return null; const rect = card.getBoundingClientRect(); if (!rect || !Number.isFinite(rect.left) || !Number.isFinite(rect.top)) return null; return { left: rect.left, top: rect.top }; } function cancelWallCardAnimation(record, kind = '') { if (!record?.animations) return; const entries = kind ? [[kind, record.animations.get(kind)]] : Array.from(record.animations.entries()); for (const [animationKind, animation] of entries) { if (!animation) continue; animation.onfinish = null; animation.oncancel = null; record.animations.delete(animationKind); if (typeof animation.cancel !== 'function') continue; try { animation.cancel(); } catch (_) { // 动画对象可能已由浏览器回收;卡片节点仍继续复用。 } } if (!record.animations.size) delete record.element.dataset.motion; } function runWallCardAnimation(record, kind, keyframes, options, onFinish) { cancelWallCardAnimation(record, kind); const card = record.element; card.dataset.motion = kind; const complete = () => { record.animations.delete(kind); if (!record.animations.size) delete card.dataset.motion; if (typeof onFinish === 'function') onFinish(); }; if (prefersReducedBoardMotion() || typeof card.animate !== 'function') { complete(); return; } let animation; try { animation = card.animate(keyframes, { fill: 'both', ...options }); } catch (_) { complete(); return; } record.animations.set(kind, animation); animation.onfinish = () => { if (record.animations.get(kind) !== animation) return; animation.onfinish = null; animation.oncancel = null; try { animation.cancel(); } catch (_) { // 基础样式接管完成帧;旧浏览器回收动画时无需阻断渲染。 } complete(); }; animation.oncancel = () => { if (record.animations.get(kind) !== animation) return; animation.onfinish = null; animation.oncancel = null; record.animations.delete(kind); if (!record.animations.size) delete card.dataset.motion; }; } function animateWallCardPageLeave(sessionId, record) { runWallCardAnimation(record, 'page-leaving', [ { opacity: 1, transform: 'translate3d(0, 0, 0) scaleY(1)', clipPath: 'inset(0 0 0 0)' }, { opacity: 0, transform: 'translate3d(-0.7rem, 0, 0) scaleY(0.72)', clipPath: 'inset(0 88% 0 0)' }, ], { id: 'ccweb-task-board-wall-card-page-leave', duration: 300, easing: 'cubic-bezier(0.55, 0.05, 0.72, 0.35)', }, () => { if (!wallVisibleSessionIds.has(sessionId)) record.element.remove(); }); } function animateWallCardPageEnter(record, index) { runWallCardAnimation(record, 'page-entering', [ { opacity: 0, transform: 'translate3d(0.7rem, 0, 0) scaleY(0.76)', clipPath: 'inset(0 0 100% 0)' }, { opacity: 1, transform: 'translate3d(0, 0, 0) scaleY(1)', clipPath: 'inset(0 0 0 0)' }, ], { id: 'ccweb-task-board-wall-card-page-enter', duration: 430, delay: Math.min(index, 3) * 55, easing: 'cubic-bezier(0.16, 1, 0.3, 1)', }); } function animateWallCardMove(record, before, after) { if (!before || !after) return; const deltaX = before.left - after.left; const deltaY = before.top - after.top; if (Math.abs(deltaX) < 0.5 && Math.abs(deltaY) < 0.5) return; runWallCardAnimation(record, 'moving', [ { transform: `translate3d(${deltaX}px, ${deltaY}px, 0)`, zIndex: 4 }, { transform: 'translate3d(0, 0, 0)', zIndex: 1 }, ], { id: 'ccweb-task-board-wall-card-move', duration: 520, easing: 'cubic-bezier(0.22, 1, 0.36, 1)', }); } function animateWallCardUpdate(record) { runWallCardAnimation(record, 'updating', [ { boxShadow: 'inset 0.16rem 0 0 var(--task-board-status-color)', backgroundColor: 'var(--bg-secondary)' }, { boxShadow: 'inset 0.3rem 0 0 var(--task-board-status-color), 0 0 0 0.16rem color-mix(in srgb, var(--task-board-status-color) 28%, transparent)', backgroundColor: 'color-mix(in srgb, var(--task-board-status-color) 10%, var(--bg-secondary))', offset: 0.36 }, { boxShadow: 'inset 0.16rem 0 0 var(--task-board-status-color)', backgroundColor: 'var(--bg-secondary)' }, ], { id: 'ccweb-task-board-wall-card-update', duration: 1050, easing: 'cubic-bezier(0.22, 1, 0.36, 1)', }); } function createWallColumnRecord(definition, definitionIndex) { const element = createNode(doc, 'section', 'ccweb-task-board__wall-column'); element.setAttribute('role', 'listitem'); const header = createNode(doc, 'header', 'ccweb-task-board__wall-column-header'); const identity = createNode(doc, 'div', 'ccweb-task-board__wall-column-identity'); const dot = createNode(doc, 'span', 'ccweb-task-board__wall-column-dot'); dot.setAttribute('aria-hidden', 'true'); const title = createNode(doc, 'h2', 'ccweb-task-board__wall-column-title'); const count = createNode(doc, 'span', 'ccweb-task-board__wall-column-count'); identity.append(dot, title, count); const page = createNode(doc, 'span', 'ccweb-task-board__wall-column-page'); header.append(identity, page); const body = createNode(doc, 'div', 'ccweb-task-board__wall-column-body'); body.setAttribute('role', 'list'); element.append(header, body); return { element, header, title, count, page, body, empty: null, pageIndex: 0, totalPages: 1, definitionIndex, }; } function updateWallColumnRecord(record, definition, definitionIndex, taskCount, pageSize) { const titleId = `task-board-wall-column-${domToken(definition.id, definitionIndex)}`; record.element.dataset.statusId = definition.id; record.element.setAttribute('aria-labelledby', titleId); setStatusColor(record.element, definition.color); record.title.id = titleId; record.title.textContent = definition.label; record.count.textContent = String(taskCount); record.count.setAttribute('aria-label', `${taskCount} 个任务`); record.header.title = definition.prompt || ''; record.body.setAttribute('aria-label', `${definition.label}任务`); record.totalPages = Math.max(1, Math.ceil(taskCount / pageSize)); record.pageIndex = ((record.pageIndex % record.totalPages) + record.totalPages) % record.totalPages; record.page.textContent = record.totalPages > 1 ? `${record.pageIndex + 1} / ${record.totalPages}` : ''; record.page.hidden = record.totalPages <= 1; record.definitionIndex = definitionIndex; } function wallCardSignature(task, definition) { return JSON.stringify([wallTaskSignature(task), definition.label, definition.color]); } function renderWallCard(task, definition, existingCard = null) { const card = existingCard || createNode(doc, 'article', 'ccweb-task-board__wall-card'); card.replaceChildren(); card.dataset.sessionId = task.sessionId; card.dataset.statusId = task.statusId; card.dataset.runtimeState = task.isRunning ? 'running' : 'idle'; card.setAttribute('role', 'listitem'); setStatusColor(card, definition.color); card.appendChild(createNode(doc, 'h3', 'ccweb-task-board__wall-card-title', task.title)); const supportingText = task.summary || task.reason; if (supportingText) card.appendChild(createNode(doc, 'p', 'ccweb-task-board__wall-card-summary', supportingText)); const meta = createNode(doc, 'div', 'ccweb-task-board__wall-card-meta'); const runtimeLabel = task.runtimeState || (task.isRunning ? '运行中' : ''); if (runtimeLabel) { const runtime = createNode(doc, 'span', 'ccweb-task-board__wall-card-runtime', runtimeLabel); runtime.dataset.running = task.isRunning ? 'true' : 'false'; runtime.prepend(createNode(doc, 'span', 'ccweb-task-board__wall-card-runtime-dot')); meta.appendChild(runtime); } const updatedLabel = formatTime(task.updatedAt); if (updatedLabel) { const updated = createNode(doc, 'time', 'ccweb-task-board__wall-card-updated', updatedLabel); updated.setAttribute('datetime', String(task.updatedAt)); meta.appendChild(updated); } if (meta.children.length) card.appendChild(meta); return card; } function ensureWallColumnEmpty(record) { if (!record.empty) { record.empty = createNode(doc, 'div', 'ccweb-task-board__wall-column-empty'); record.empty.append( createNode(doc, 'span', 'ccweb-task-board__wall-column-empty-mark', '·'), createNode(doc, 'span', '', '暂无任务'), ); } if (record.empty.parentNode !== record.body) record.body.appendChild(record.empty); } function renderWall(options = {}) { if (state.destroyed) return; const definitions = getBoardDefinitions(); const tasks = getWallTasks(); const grouped = wallTasksByStatus(definitions, tasks); const layout = wallLayoutSize(definitions.length || 1); const pageSize = layout.rows >= 3 ? 1 : wallCardsPerPage; refs.wallGrid.style.setProperty('--task-board-wall-columns', String(layout.columns)); refs.wallGrid.style.setProperty('--task-board-wall-rows', String(layout.rows)); refs.wallGrid.dataset.cellCount = String(definitions.length + 1); refs.wallGrid.dataset.rowCount = String(layout.rows); const updatedSessionId = text(options.updatedSessionId); const updatedTask = updatedSessionId ? tasks.find((task) => task.sessionId === updatedSessionId) : null; const oldVisibleSessionIds = new Set(wallVisibleSessionIds); const oldRects = new Map(); for (const [sessionId, record] of wallCardRecords) { const rect = readWallCardRect(record.element); if (rect) oldRects.set(sessionId, rect); cancelWallCardAnimation(record); } if (!definitions.length) { refs.wallEmpty.hidden = false; syncNodeOrder(refs.wallGrid, [refs.clock, refs.wallEmpty]); wallVisibleSessionIds = new Set(); for (const [sessionId, record] of wallCardRecords) { record.element.remove(); wallCardRecords.delete(sessionId); } for (const [statusId, record] of wallColumnRecords) { record.element.remove(); wallColumnRecords.delete(statusId); } clearWallRotation(); wallHasRendered = true; return; } refs.wallEmpty.hidden = true; refs.wallEmpty.remove(); const desiredStatusIds = new Set(); const desiredColumnNodes = [refs.clock]; definitions.forEach((definition, definitionIndex) => { desiredStatusIds.add(definition.id); let record = wallColumnRecords.get(definition.id); if (!record) { record = createWallColumnRecord(definition, definitionIndex); wallColumnRecords.set(definition.id, record); } const columnTasks = grouped.get(definition.id) || []; if (updatedTask?.statusId === definition.id) { const updatedIndex = columnTasks.findIndex((task) => task.sessionId === updatedTask.sessionId); if (updatedIndex >= 0) record.pageIndex = Math.floor(updatedIndex / pageSize); } updateWallColumnRecord(record, definition, definitionIndex, columnTasks.length, pageSize); desiredColumnNodes.push(record.element); }); syncNodeOrder(refs.wallGrid, desiredColumnNodes); const desiredVisibleSessionIds = new Set(); const taskSessionIds = new Set(tasks.map((task) => task.sessionId)); const desiredCardsByStatus = new Map(definitions.map((definition) => [definition.id, []])); const visibleTransitions = []; definitions.forEach((definition) => { const column = wallColumnRecords.get(definition.id); const columnTasks = grouped.get(definition.id) || []; const start = column.pageIndex * pageSize; const visibleTasks = columnTasks.slice(start, start + pageSize); for (const task of visibleTasks) { desiredVisibleSessionIds.add(task.sessionId); let record = wallCardRecords.get(task.sessionId); if (!record) { record = { element: createNode(doc, 'article', 'ccweb-task-board__wall-card'), signature: '', statusId: '', animations: new Map(), }; wallCardRecords.set(task.sessionId, record); } const signature = wallCardSignature(task, definition); if (record.signature !== signature) { renderWallCard(task, definition, record.element); record.signature = signature; } record.statusId = task.statusId; desiredCardsByStatus.get(definition.id).push(record.element); visibleTransitions.push({ sessionId: task.sessionId, record }); } }); wallVisibleSessionIds = desiredVisibleSessionIds; definitions.forEach((definition) => { const column = wallColumnRecords.get(definition.id); const desiredCards = desiredCardsByStatus.get(definition.id); if (desiredCards.length) { column.empty?.remove(); syncNodeOrder(column.body, desiredCards); } else { ensureWallColumnEmpty(column); } }); for (const [sessionId, record] of Array.from(wallCardRecords.entries())) { if (desiredVisibleSessionIds.has(sessionId)) continue; if (!taskSessionIds.has(sessionId)) { cancelWallCardAnimation(record); record.element.remove(); wallCardRecords.delete(sessionId); continue; } if (record.element.parentNode && wallHasRendered) animateWallCardPageLeave(sessionId, record); else record.element.remove(); } for (const transition of visibleTransitions) { const after = readWallCardRect(transition.record.element); const before = oldRects.get(transition.sessionId); if (wallHasRendered && before) animateWallCardMove(transition.record, before, after); else if (wallHasRendered && !oldVisibleSessionIds.has(transition.sessionId)) { const siblings = desiredCardsByStatus.get(transition.record.statusId) || []; animateWallCardPageEnter(transition.record, siblings.indexOf(transition.record.element)); } if (transition.sessionId === updatedSessionId) animateWallCardUpdate(transition.record); } for (const [statusId, record] of Array.from(wallColumnRecords.entries())) { if (desiredStatusIds.has(statusId)) continue; record.element.remove(); wallColumnRecords.delete(statusId); } wallHasRendered = true; if (updatedTask) scheduleWallRotation(wallUpdateHoldMs); else if (wallRotationTimer === null) scheduleWallRotation(); } function advanceWallPage() { const statusIds = overflowingWallStatusIds(); if (!statusIds.length) { clearWallRotation(); return; } const statusId = statusIds[wallRotationCursor % statusIds.length]; wallRotationCursor = (wallRotationCursor + 1) % Math.max(statusIds.length, 1); const record = wallColumnRecords.get(statusId); if (record) record.pageIndex = (record.pageIndex + 1) % Math.max(record.totalPages, 1); renderWall({ rotatedStatusId: statusId }); } function emitStateChange(reason) { if (!adapters.onStateChange) return; adapters.onStateChange(getState(), reason); } function reportError(error) { const normalized = isObject(error) ? { code: text(error.code, 'task_board_error'), message: text(error.message, '任务看板操作失败') } : { code: 'task_board_error', message: text(error, '任务看板操作失败') }; state.error = normalized; refs.shell.dataset.error = normalized.code; refs.summary.textContent = normalized.message; announce(normalized.message); if (adapters.onError) adapters.onError(normalized); } function clearError() { state.error = null; delete refs.shell.dataset.error; } function syncBusy() { const busy = state.pending > 0; refs.shell.setAttribute('aria-busy', busy ? 'true' : 'false'); } function handleAdapterResult(result) { if (isObject(result) && RESPONSE_TYPES.has(result.type)) handleMessage(result); const protocolError = getProtocolError(result); if (protocolError) reportError(protocolError); return result; } function sendRequest(message, requestPrefix = 'task') { if (!adapters.send) { reportError({ code: 'task_board_transport_missing', message: '任务看板尚未连接消息适配器' }); return null; } const suppliedRequestId = text(message.requestId); const request = { ...message, requestId: suppliedRequestId || adapters.requestId(requestPrefix), }; clearError(); let output; try { output = adapters.send(request); } catch (error) { reportError(error); return null; } if (output && typeof output.then === 'function') { state.pending += 1; syncBusy(); return Promise.resolve(output) .then(handleAdapterResult) .catch((error) => { reportError(error); return null; }) .finally(() => { state.pending = Math.max(0, state.pending - 1); syncBusy(); }); } return handleAdapterResult(output); } function statusById(statusId) { return state.statusDefinitions.find((definition) => definition.id === statusId) || null; } function getBoardDefinitions() { const referencedIds = new Set(state.tasks.map((task) => task.statusId).filter(Boolean)); const definitions = state.statusDefinitions.filter((definition) => definition.enabled || referencedIds.has(definition.id)); const knownIds = new Set(definitions.map((definition) => definition.id)); for (const statusId of referencedIds) { if (knownIds.has(statusId)) continue; definitions.push(normalizeDefinition({ id: statusId, label: statusId, prompt: '该列由任务数据引用,但状态定义暂不可用。', enabled: true, missingDefinition: true, }, definitions.length)); } return sortDefinitions(definitions); } function matchesFilters(task) { if (Boolean(task.archivedAt) !== state.filters.archived) return false; const query = state.filters.search.toLocaleLowerCase('zh-CN').trim(); if (!query) return true; return [ task.title, task.sessionId, task.summary, task.reason, task.agent, task.project, task.cwd, ].some((value) => text(value).toLocaleLowerCase('zh-CN').includes(query)); } function createEmptyColumn() { const empty = createNode(doc, 'div', 'ccweb-task-board__column-empty'); const mark = createNode(doc, 'span', 'ccweb-task-board__column-empty-mark', '·'); mark.setAttribute('aria-hidden', 'true'); empty.append(mark, createNode(doc, 'span', '', '暂无任务')); return empty; } function prefersReducedBoardMotion() { if (typeof global.matchMedia !== 'function') return false; try { return global.matchMedia('(prefers-reduced-motion: reduce)').matches === true; } catch (_) { return false; } } function readCardRect(card) { if (!card || typeof card.getBoundingClientRect !== 'function' || !refs.board.contains(card)) return null; const rect = card.getBoundingClientRect(); if (!rect || !Number.isFinite(rect.left) || !Number.isFinite(rect.top)) return null; return { left: rect.left, top: rect.top, width: Number.isFinite(rect.width) ? rect.width : 0, height: Number.isFinite(rect.height) ? rect.height : 0, }; } function readCardBox(card, rect) { let computed = null; if (typeof global.getComputedStyle === 'function') { try { computed = global.getComputedStyle(card); } catch (_) { computed = null; } } return { height: `${Math.max(1, rect?.height || 1)}px`, paddingTop: text(computed?.paddingTop, '0.78rem'), paddingBottom: text(computed?.paddingBottom, '0.78rem'), borderTopWidth: text(computed?.borderTopWidth, '1px'), borderBottomWidth: text(computed?.borderBottomWidth, '1px'), }; } function releaseMotionColumn(record) { const column = record.motionColumn; record.motionColumn = null; if (!column || column.querySelector('[data-motion="moving"]')) return; column.classList.remove('ccweb-task-board__column--motion'); } function cancelCardMotion(record) { const animation = record.animation; record.animation = null; delete record.element.dataset.motion; releaseMotionColumn(record); if (!animation || typeof animation.cancel !== 'function') return; try { animation.cancel(); } catch (_) { // 动画可能已由浏览器回收;节点复用不应因此中断。 } } function runCardMotion(record, kind, keyframes, options, onFinish) { cancelCardMotion(record); const card = record.element; card.dataset.motion = kind; if (kind === 'moving') { record.motionColumn = card.closest('.ccweb-task-board__column'); record.motionColumn?.classList.add('ccweb-task-board__column--motion'); } const completeWithoutAnimation = () => { delete card.dataset.motion; releaseMotionColumn(record); if (typeof onFinish === 'function') onFinish(); }; if (prefersReducedBoardMotion() || typeof card.animate !== 'function') { completeWithoutAnimation(); return; } let animation; try { animation = card.animate(keyframes, { fill: 'both', ...options }); } catch (_) { completeWithoutAnimation(); return; } record.animation = animation; animation.onfinish = () => { if (record.animation !== animation) return; record.animation = null; try { animation.cancel(); } catch (_) { // 清除 fill 效果失败时,基础样式仍会在后续渲染中接管。 } delete card.dataset.motion; releaseMotionColumn(record); if (typeof onFinish === 'function') onFinish(); }; animation.oncancel = () => { if (record.animation !== animation) return; record.animation = null; delete card.dataset.motion; releaseMotionColumn(record); }; } function animateCardMove(record, before, after) { if (!before || !after) return; const deltaX = before.left - after.left; const deltaY = before.top - after.top; if (Math.abs(deltaX) < 0.5 && Math.abs(deltaY) < 0.5) return; runCardMotion(record, 'moving', [ { transform: `translate3d(${deltaX}px, ${deltaY}px, 0)` }, { transform: 'translate3d(0, 0, 0)' }, ], { id: 'ccweb-task-board-card-move', duration: 240, easing: 'cubic-bezier(0.22, 1, 0.36, 1)', }); } function animateCardEnter(record) { const rect = readCardRect(record.element); const box = readCardBox(record.element, rect); runCardMotion(record, 'entering', [ { opacity: 0, height: '0px', paddingTop: '0px', paddingBottom: '0px', borderTopWidth: '0px', borderBottomWidth: '0px', overflow: 'hidden', transform: 'translateY(-0.3rem) scale(0.985)', }, { opacity: 1, height: box.height, paddingTop: box.paddingTop, paddingBottom: box.paddingBottom, borderTopWidth: box.borderTopWidth, borderBottomWidth: box.borderBottomWidth, overflow: 'hidden', transform: 'translateY(0) scale(1)', }, ], { id: 'ccweb-task-board-card-enter', duration: 190, easing: 'cubic-bezier(0.22, 1, 0.36, 1)', }); } function discardCardRecord(sessionId, record) { record.leaving = false; cancelCardMotion(record); record.element.remove(); if (cardRecords.get(sessionId) === record) cardRecords.delete(sessionId); } function animateCardLeave(sessionId, record) { const rect = readCardRect(record.element); const box = readCardBox(record.element, rect); record.leaving = true; runCardMotion(record, 'leaving', [ { opacity: 1, height: box.height, paddingTop: box.paddingTop, paddingBottom: box.paddingBottom, borderTopWidth: box.borderTopWidth, borderBottomWidth: box.borderBottomWidth, overflow: 'hidden', transform: 'scale(1)', }, { opacity: 0, height: '0px', paddingTop: '0px', paddingBottom: '0px', borderTopWidth: '0px', borderBottomWidth: '0px', overflow: 'hidden', transform: 'scale(0.985)', }, ], { id: 'ccweb-task-board-card-leave', duration: 170, easing: 'cubic-bezier(0.4, 0, 1, 1)', }, () => { if (!record.leaving || cardRecords.get(sessionId) !== record) return; record.leaving = false; record.element.remove(); cardRecords.delete(sessionId); }); } function taskCardSignature(task, definition) { const selectableDefinitions = sortDefinitions( state.statusDefinitions.filter((item) => item.enabled && item.id !== task.statusId), ).map((item) => [item.id, item.label, item.enabled, item.order]); return JSON.stringify([ task.sessionId, task.title, task.statusId, task.summary, task.reason, task.isRunning, task.runtimeState, task.runtimeDetail, task.agent, task.updatedAt, task.archivedAt, definition.label, definition.color, Boolean(adapters.send), selectableDefinitions, ]); } function renderTaskCard(task, definition, existingCard = null) { const card = existingCard || createNode(doc, 'article', 'ccweb-task-board__card'); card.replaceChildren(); card.dataset.sessionId = task.sessionId; card.dataset.statusId = task.statusId; card.dataset.runtimeState = task.isRunning ? 'running' : 'idle'; card.setAttribute('role', 'listitem'); setStatusColor(card, definition.color); const cardTop = createNode(doc, 'div', 'ccweb-task-board__card-top'); const sessionCode = createNode(doc, 'span', 'ccweb-task-board__session-id', shortSessionId(task.sessionId)); sessionCode.title = task.sessionId; cardTop.appendChild(sessionCode); const openButton = createNode(doc, 'button', 'ccweb-task-board__card-open'); openButton.type = 'button'; openButton.dataset.action = 'open-session'; openButton.dataset.sessionId = task.sessionId; openButton.setAttribute('aria-label', `打开会话:${task.title}`); openButton.appendChild(createNode(doc, 'span', 'ccweb-task-board__card-title', task.title)); if (task.summary || task.reason) { openButton.appendChild(createNode(doc, 'span', 'ccweb-task-board__card-summary', task.summary || task.reason)); } const stateRow = createNode(doc, 'div', 'ccweb-task-board__state-row'); const runtimeLabel = task.runtimeState || '未运行'; const runtimeState = createNode(doc, 'span', 'ccweb-task-board__runtime-state', `运行:${runtimeLabel}`); if (task.runtimeDetail) runtimeState.title = task.runtimeDetail; stateRow.appendChild(runtimeState); card.append(cardTop, openButton, stateRow); const cardFooter = createNode(doc, 'div', 'ccweb-task-board__card-footer'); const metadata = createNode(doc, 'div', 'ccweb-task-board__metadata'); const updatedLabel = formatTime(task.updatedAt); if (updatedLabel) metadata.appendChild(createNode(doc, 'time', '', updatedLabel)); if (!updatedLabel) metadata.appendChild(createNode(doc, 'span', '', '暂无补充信息')); const cardActions = createNode(doc, 'div', 'ccweb-task-board__card-actions'); const statusLabel = createNode(doc, 'label', 'ccweb-task-board__status-select-wrap'); statusLabel.appendChild(createNode(doc, 'span', 'ccweb-task-board__sr-only', `调整 ${task.title} 的任务状态`)); const statusSelect = createNode(doc, 'select', 'ccweb-task-board__card-select'); statusSelect.dataset.action = 'set-status'; statusSelect.dataset.sessionId = task.sessionId; statusSelect.setAttribute('aria-label', `移动 ${task.title} 到其他列`); statusSelect.appendChild(optionNode(doc, '', '移动到…', true)); const selectableDefinitions = state.statusDefinitions.filter((item) => item.enabled && item.id !== task.statusId); for (const item of sortDefinitions(selectableDefinitions)) { statusSelect.appendChild(optionNode(doc, item.id, item.label)); } statusSelect.value = ''; statusSelect.disabled = !adapters.send || !selectableDefinitions.length; statusLabel.appendChild(statusSelect); const archive = createNode( doc, 'button', 'ccweb-task-board__card-action', task.archivedAt ? '恢复' : '归档', ); archive.type = 'button'; archive.dataset.action = 'set-archive'; archive.dataset.sessionId = task.sessionId; archive.dataset.archived = task.archivedAt ? 'false' : 'true'; archive.disabled = !adapters.send; archive.setAttribute('aria-label', `${task.archivedAt ? '取消归档' : '归档'}:${task.title}`); cardActions.append(statusLabel, archive); cardFooter.append(metadata, cardActions); card.appendChild(cardFooter); return card; } function createColumnRecord(definition, definitionIndex) { const column = createNode(doc, 'section', 'ccweb-task-board__column'); column.dataset.statusId = definition.id; const header = createNode(doc, 'header', 'ccweb-task-board__column-header'); const titleWrap = createNode(doc, 'div', 'ccweb-task-board__column-title-wrap'); const dot = createNode(doc, 'span', 'ccweb-task-board__column-dot'); dot.setAttribute('aria-hidden', 'true'); const title = createNode(doc, 'h3', 'ccweb-task-board__column-title'); const count = createNode(doc, 'span', 'ccweb-task-board__column-count'); titleWrap.append(dot, title, count); header.appendChild(titleWrap); const body = createNode(doc, 'div', 'ccweb-task-board__column-body'); body.setAttribute('role', 'list'); column.append(header, body); return { element: column, header, title, count, body, empty: null, disabled: null, definitionIndex, }; } function updateColumnRecord(record, definition, definitionIndex, taskCount) { const columnTitleId = `task-board-column-${domToken(definition.id, definitionIndex)}`; record.element.dataset.statusId = definition.id; record.element.setAttribute('aria-labelledby', columnTitleId); setStatusColor(record.element, definition.color); record.title.id = columnTitleId; record.title.textContent = definition.label; record.count.textContent = String(taskCount); record.count.setAttribute('aria-label', `${taskCount} 个任务`); record.header.title = definition.prompt || ''; record.body.setAttribute('aria-label', `${definition.label}任务`); record.definitionIndex = definitionIndex; if (!definition.enabled && !record.disabled) { record.disabled = createNode(doc, 'span', 'ccweb-task-board__column-disabled', '已停用'); record.header.appendChild(record.disabled); } else if (definition.enabled && record.disabled) { record.disabled.remove(); record.disabled = null; } } function syncColumnEmpty(record, taskCount) { if (taskCount > 0) { record.empty?.remove(); return; } if (!record.empty) record.empty = createEmptyColumn(); if (record.empty.parentNode !== record.body) record.body.appendChild(record.empty); } function syncNodeOrder(parent, desiredNodes) { const desiredSet = new Set(desiredNodes); const currentNodes = Array.from(parent.children).filter((node) => desiredSet.has(node)); const alreadyOrdered = currentNodes.length === desiredNodes.length && desiredNodes.every((node, index) => node.parentNode === parent && currentNodes[index] === node); if (alreadyOrdered) return; for (const node of desiredNodes) parent.appendChild(node); } function ensureBoardEmpty() { if (!boardEmpty) { boardEmpty = createNode(doc, 'section', 'ccweb-task-board__empty'); boardEmpty.setAttribute('role', 'status'); boardEmpty.append( createNode(doc, 'span', 'ccweb-task-board__empty-mark', '◇'), createNode(doc, 'h3', '', '暂无状态定义'), createNode(doc, 'p', '', '等待服务端提供状态定义,或打开状态管理进行检查。'), ); } if (boardEmpty.parentNode !== refs.board) refs.board.appendChild(boardEmpty); } function clearBoardRecords() { for (const [sessionId, record] of cardRecords) discardCardRecord(sessionId, record); for (const record of columnRecords.values()) record.element.remove(); columnRecords.clear(); } function renderBoard(options = {}) { if (state.destroyed) return; const visibleTasks = sortTasks(state.tasks.filter(matchesFilters)); const definitions = getBoardDefinitions(); const columnCount = definitions.length; refs.board.style.setProperty('--task-board-column-count', String(Math.max(columnCount, 1))); refs.board.dataset.columnCount = String(columnCount); refs.board.dataset.columnLayout = columnCount <= fitColumnLimit ? 'fit' : 'scroll'; refs.shell.dataset.searchActive = state.filters.search ? 'true' : 'false'; const animateChanges = options.animate === true && hasRenderedBoard; const oldRects = new Map(); // 数据事件只移动 keyed 节点;更新前后的位置差用于 FLIP,避免整板重绘闪烁。 if (animateChanges) { for (const [sessionId, record] of cardRecords) { const rect = readCardRect(record.element); if (rect) oldRects.set(sessionId, rect); } } refs.board.dataset.empty = visibleTasks.length ? 'false' : 'true'; refs.archiveButton.textContent = state.filters.archived ? '返回任务' : '查看归档'; refs.archiveButton.setAttribute('aria-pressed', state.filters.archived ? 'true' : 'false'); refs.shell.dataset.view = state.filters.archived ? 'archived' : 'active'; if (!definitions.length) { clearBoardRecords(); ensureBoardEmpty(); } else { boardEmpty?.remove(); const tasksByStatus = new Map(definitions.map((definition) => [definition.id, []])); for (const task of visibleTasks) tasksByStatus.get(task.statusId)?.push(task); const desiredStatusIds = new Set(definitions.map((definition) => definition.id)); const desiredColumnNodes = []; definitions.forEach((definition, definitionIndex) => { const tasks = tasksByStatus.get(definition.id) || []; let record = columnRecords.get(definition.id); if (!record) { record = createColumnRecord(definition, definitionIndex); columnRecords.set(definition.id, record); } updateColumnRecord(record, definition, definitionIndex, tasks.length); desiredColumnNodes.push(record.element); }); syncNodeOrder(refs.board, desiredColumnNodes); const desiredSessionIds = new Set(); const desiredCardsByStatus = new Map(definitions.map((definition) => [definition.id, []])); const transitions = []; definitions.forEach((definition) => { const tasks = tasksByStatus.get(definition.id) || []; for (const task of tasks) { desiredSessionIds.add(task.sessionId); let record = cardRecords.get(task.sessionId); const isNew = !record; if (!record) { record = { element: createNode(doc, 'article', 'ccweb-task-board__card'), signature: '', statusId: '', animation: null, motionColumn: null, leaving: false, }; cardRecords.set(task.sessionId, record); } else { record.leaving = false; cancelCardMotion(record); } const focusedAction = record.element.contains(doc.activeElement) ? text(doc.activeElement?.dataset?.action) : ''; const restoreFocusAction = ['open-session', 'set-status', 'set-archive'].includes(focusedAction) ? focusedAction : ''; const signature = taskCardSignature(task, definition); if (record.signature !== signature) { renderTaskCard(task, definition, record.element); record.signature = signature; } record.statusId = task.statusId; desiredCardsByStatus.get(definition.id).push(record.element); transitions.push({ record, sessionId: task.sessionId, isNew, restoreFocusAction }); } }); definitions.forEach((definition) => { const column = columnRecords.get(definition.id); syncNodeOrder(column.body, desiredCardsByStatus.get(definition.id)); }); for (const transition of transitions) { if (!transition.restoreFocusAction) continue; safeFocus(transition.record.element.querySelector(`[data-action="${transition.restoreFocusAction}"]`)); } for (const [sessionId, record] of Array.from(cardRecords.entries())) { if (desiredSessionIds.has(sessionId)) continue; if (animateChanges && readCardRect(record.element)) animateCardLeave(sessionId, record); else discardCardRecord(sessionId, record); } definitions.forEach((definition) => { const record = columnRecords.get(definition.id); syncColumnEmpty(record, (tasksByStatus.get(definition.id) || []).length); }); for (const [statusId, record] of Array.from(columnRecords.entries())) { if (desiredStatusIds.has(statusId)) continue; record.element.remove(); columnRecords.delete(statusId); } if (animateChanges) { for (const transition of transitions) { if (transition.isNew) { animateCardEnter(transition.record); continue; } animateCardMove( transition.record, oldRects.get(transition.sessionId), readCardRect(transition.record.element), ); } } } const archiveLabel = state.filters.archived ? '归档任务' : '当前任务'; refs.summary.textContent = `${visibleTasks.length} 个${archiveLabel} · ${definitions.length} 个状态`; if (state.statusManagerOpen) renderStatusManager(); renderWall({ updatedSessionId: options.wallUpdatedSessionId }); hasRenderedBoard = true; } function createField(labelText, control, className = '') { const label = createNode(doc, 'label', `ccweb-task-board__field${className ? ` ${className}` : ''}`); label.append(createNode(doc, 'span', 'ccweb-task-board__field-label', labelText), control); return label; } function createDefinitionForm(definition, isNew = false) { const form = createNode(doc, 'form', 'ccweb-task-board__definition'); form.dataset.formAction = 'upsert-definition'; form.dataset.definitionId = isNew ? '' : definition.id; if (definition.system) form.dataset.system = 'true'; setStatusColor(form, definition.color); const formHeader = createNode(doc, 'div', 'ccweb-task-board__definition-header'); const identity = createNode(doc, 'div', 'ccweb-task-board__definition-identity'); identity.append( createNode(doc, 'span', 'ccweb-task-board__definition-dot'), createNode(doc, 'strong', '', isNew ? '新增看板列' : definition.label), ); const kind = createNode( doc, 'span', 'ccweb-task-board__definition-kind', isNew ? 'CUSTOM' : (definition.system ? 'SYSTEM' : 'CUSTOM'), ); formHeader.append(identity, kind); const grid = createNode(doc, 'div', 'ccweb-task-board__definition-grid'); const idInput = createNode(doc, 'input', 'ccweb-task-board__field-control'); idInput.name = 'id'; idInput.value = isNew ? '' : definition.id; idInput.required = true; idInput.maxLength = 80; idInput.pattern = '[A-Za-z0-9][A-Za-z0-9_-]*'; idInput.disabled = !isNew; idInput.placeholder = '例如 waiting-release'; const labelInput = createNode(doc, 'input', 'ccweb-task-board__field-control'); labelInput.name = 'label'; labelInput.value = isNew ? '' : definition.label; labelInput.required = true; labelInput.maxLength = 120; const colorInput = createNode(doc, 'input', 'ccweb-task-board__field-control ccweb-task-board__color-input'); colorInput.name = 'color'; colorInput.type = 'color'; colorInput.value = /^#[0-9a-f]{6}$/i.test(definition.color) ? definition.color : '#6f8d9a'; const orderInput = createNode(doc, 'input', 'ccweb-task-board__field-control'); orderInput.name = 'order'; orderInput.type = 'number'; orderInput.step = '1'; orderInput.value = String(definition.order); const enabledInput = createNode(doc, 'input', 'ccweb-task-board__definition-enabled'); enabledInput.name = 'enabled'; enabledInput.type = 'checkbox'; enabledInput.checked = definition.system || definition.enabled; enabledInput.disabled = definition.system; const enabledField = createField('启用', enabledInput, 'ccweb-task-board__field--checkbox'); grid.append( createField('列 ID', idInput), createField('列名称', labelInput), createField('颜色', colorInput), createField('排序', orderInput), enabledField, ); const promptControl = createNode(doc, 'textarea', 'ccweb-task-board__field-control ccweb-task-board__prompt'); promptControl.name = 'prompt'; promptControl.rows = 4; promptControl.maxLength = 4000; promptControl.required = true; promptControl.placeholder = '填写任务应归入此列的明确条件'; promptControl.value = isNew ? '' : definition.prompt; const promptField = createField('分类提示词', promptControl, 'ccweb-task-board__field--wide'); const actions = createNode(doc, 'div', 'ccweb-task-board__definition-actions'); const save = createNode(doc, 'button', 'ccweb-task-board__button ccweb-task-board__button--primary', isNew ? '新增列' : '保存'); save.type = 'submit'; save.disabled = !adapters.send; actions.appendChild(save); if (!isNew && !definition.system) { const migrateLabel = createNode(doc, 'label', 'ccweb-task-board__migration'); migrateLabel.appendChild(createNode(doc, 'span', '', '删除后迁移到')); const migration = createNode(doc, 'select', 'ccweb-task-board__field-control'); migration.dataset.migrationFor = definition.id; const candidates = sortDefinitions(state.statusDefinitions.filter((item) => item.enabled && item.id !== definition.id)); migration.appendChild(optionNode(doc, '', '由服务端判断', true)); for (const candidate of candidates) { migration.appendChild(optionNode(doc, candidate.id, candidate.label)); } migrateLabel.appendChild(migration); const remove = createNode(doc, 'button', 'ccweb-task-board__button ccweb-task-board__button--danger', '删除'); remove.type = 'button'; remove.dataset.action = 'remove-definition'; remove.dataset.definitionId = definition.id; remove.disabled = !adapters.send; actions.prepend(migrateLabel); actions.appendChild(remove); } form.append(formHeader, grid, promptField, actions); return form; } function renderStatusManager() { if (!state.statusManagerOpen) return; const definitions = sortDefinitions(state.statusDefinitions); const systemCount = definitions.filter((definition) => definition.system).length; refs.statusPanelSummary.textContent = `${definitions.length} 个看板列 · ${systemCount} 个内置列 · ${definitions.length - systemCount} 个自定义列`; refs.statusBody.replaceChildren(); const addDetails = createNode(doc, 'details', 'ccweb-task-board__add-status'); const addSummary = createNode(doc, 'summary', 'ccweb-task-board__add-status-summary', '新增看板列'); addDetails.append( addSummary, createDefinitionForm(normalizeDefinition({ id: '', label: '', prompt: '', color: '#6f8d9a', order: definitions.length * 10, enabled: true, system: false, }), true), ); refs.statusBody.appendChild(addDetails); const list = createNode(doc, 'div', 'ccweb-task-board__definition-list'); if (!definitions.length) { list.appendChild(createNode(doc, 'p', 'ccweb-task-board__status-empty', '尚未收到任何状态定义。')); } else { for (const definition of definitions) list.appendChild(createDefinitionForm(definition)); } refs.statusBody.appendChild(list); } function readNamedControl(form, name) { return form.querySelector(`[name="${name}"]`); } function submitDefinition(form) { const existingId = text(form.dataset.definitionId); const idControl = readNamedControl(form, 'id'); const labelControl = readNamedControl(form, 'label'); const colorControl = readNamedControl(form, 'color'); const orderControl = readNamedControl(form, 'order'); const enabledControl = readNamedControl(form, 'enabled'); const promptControl = readNamedControl(form, 'prompt'); const system = form.dataset.system === 'true'; const definition = { id: existingId || text(idControl?.value), label: text(labelControl?.value), color: text(colorControl?.value, '#6f8d9a'), order: finiteNumber(orderControl?.value, 0), enabled: system ? true : !!enabledControl?.checked, system, prompt: text(promptControl?.value), }; if (!definition.id || !definition.label || !definition.prompt) { reportError({ code: 'task_status_invalid', message: '请完整填写列 ID、列名称和分类提示词' }); return; } sendRequest({ type: MESSAGE_TYPES.definitionUpsert, definition, ...(state.definitionsVersion === null ? {} : { expectedVersion: state.definitionsVersion }), }, 'task-status-definition'); } function mergeTask(rawTask) { if (!isObject(rawTask)) return; const candidateId = text(rawTask.sessionId || rawTask.id || rawTask.session?.id); if (!candidateId) return; const existingIndex = state.tasks.findIndex((task) => task.sessionId === candidateId); const existing = existingIndex >= 0 ? state.tasks[existingIndex] : null; const mergedTracking = { ...(existing ? { enabled: existing.trackingEnabled, statusId: existing.statusId, summary: existing.summary, reason: existing.reason, source: existing.source, archivedAt: existing.archivedAt, archivedBy: existing.archivedBy, statusUpdatedAt: existing.updatedAt, version: existing.version, } : {}), ...(isObject(rawTask.taskTracking) ? rawTask.taskTracking : {}), }; for (const key of [ 'statusId', 'summary', 'reason', 'source', 'archivedAt', 'archivedBy', 'version', ]) { if (hasOwn(rawTask, key) && !hasOwn(rawTask.taskTracking, key)) mergedTracking[key] = rawTask[key]; } if (hasOwn(rawTask, 'trackingEnabled') && !hasOwn(rawTask.taskTracking, 'enabled')) { mergedTracking.enabled = rawTask.trackingEnabled; } if (hasOwn(rawTask, 'updatedAt') && !hasOwn(rawTask.taskTracking, 'statusUpdatedAt')) { mergedTracking.statusUpdatedAt = rawTask.updatedAt; } const normalized = normalizeTask({ ...(existing || {}), ...rawTask, taskTracking: mergedTracking }); if (existing && existing.version !== null && normalized.version !== null && normalized.version < existing.version) { return; } if (!normalized.trackingEnabled) { if (existingIndex >= 0) state.tasks.splice(existingIndex, 1); return; } if (existingIndex >= 0) state.tasks.splice(existingIndex, 1, normalized); else state.tasks.push(normalized); } function applyData(data, options = {}) { const previousWallSignatures = new Map( state.tasks.map((task) => [task.sessionId, wallTaskSignature(task)]), ); const envelope = collectEnvelope(data); const definitions = findArray(envelope, ['statusDefinitions', 'definitions', 'statuses']); const tasks = findArray(envelope, ['tasks', 'items']); const incomingDefinitionsVersion = finiteNumber(envelope.definitionsVersion, null); const definitionsAreCurrent = state.definitionsVersion === null || incomingDefinitionsVersion === null || incomingDefinitionsVersion >= state.definitionsVersion; if (definitions && definitionsAreCurrent) { state.statusDefinitions = sortDefinitions(definitions.map(normalizeDefinition)); if (incomingDefinitionsVersion !== null) state.definitionsVersion = incomingDefinitionsVersion; } if (tasks) { const normalizedTasks = tasks.map(normalizeTask).filter((task) => task.trackingEnabled); if (options.replaceTasks !== false) state.tasks = normalizedTasks; else normalizedTasks.forEach(mergeTask); } const task = isObject(envelope.task) ? envelope.task : (envelope.sessionId && (envelope.taskTracking || envelope.statusId || hasOwn(envelope, 'archivedAt')) ? envelope : null); if (task) mergeTask(task); const removedSessionId = text(envelope.removedSessionId || envelope.deletedSessionId); if (removedSessionId) state.tasks = state.tasks.filter((item) => item.sessionId !== removedSessionId); const changedWallTask = wallHasRendered ? state.tasks .filter((item) => previousWallSignatures.get(item.sessionId) !== wallTaskSignature(item)) .filter((item) => !item.archivedAt) .sort(compareWallTasks)[0] : null; renderBoard({ animate: true, wallUpdatedSessionId: changedWallTask?.sessionId || '' }); emitStateChange(options.reason || 'data'); } function setData(data) { applyData(data, { replaceTasks: true, reason: 'setData' }); return controller; } function handleMessage(message) { if (!isObject(message) || !RESPONSE_TYPES.has(message.type)) return false; if ( message.type === MESSAGE_TYPES.boardResult && latestQueryRequestId && text(message.requestId) && text(message.requestId) !== latestQueryRequestId ) { return true; } const protocolError = getProtocolError(message); if (protocolError) { reportError(protocolError); return true; } clearError(); applyData(message, { replaceTasks: message.type === MESSAGE_TYPES.boardResult, reason: message.type, }); announce('任务看板已更新'); return true; } function queryFilters() { return { search: state.filters.search, archived: state.filters.archived, }; } function refresh(nextFilters) { if (isObject(nextFilters)) { if (hasOwn(nextFilters, 'search')) state.filters.search = text(nextFilters.search); if (hasOwn(nextFilters, 'query')) state.filters.search = text(nextFilters.query); if (hasOwn(nextFilters, 'archived')) state.filters.archived = nextFilters.archived === true; refs.searchInput.value = state.filters.search; renderBoard(); } announce('正在刷新任务看板'); const filters = queryFilters(); latestQueryRequestId = adapters.requestId('task-board-query'); return sendRequest({ type: MESSAGE_TYPES.query, requestId: latestQueryRequestId, query: filters.search, archived: filters.archived, filters, }, 'task-board-query'); } function setFilters(nextFilters = {}, options = {}) { if (hasOwn(nextFilters, 'search')) state.filters.search = text(nextFilters.search); if (hasOwn(nextFilters, 'query')) state.filters.search = text(nextFilters.query); if (hasOwn(nextFilters, 'archived')) state.filters.archived = nextFilters.archived === true; refs.searchInput.value = state.filters.search; renderBoard(); emitStateChange('filters'); if (options.refresh === true) refresh(); return controller; } function getState() { return { tasks: state.tasks.map((task) => ({ ...task })), statusDefinitions: state.statusDefinitions.map((definition) => ({ ...definition })), definitionsVersion: state.definitionsVersion, filters: { ...state.filters }, statusManagerOpen: state.statusManagerOpen, pending: state.pending, error: state.error ? { ...state.error } : null, }; } function openStatusManager() { if (state.statusManagerOpen) return; previousFocus = doc.activeElement; state.statusManagerOpen = true; refs.statusLayer.hidden = false; header.setAttribute('inert', ''); workspace.setAttribute('inert', ''); refs.statusButton.setAttribute('aria-expanded', 'true'); renderStatusManager(); global.setTimeout(() => safeFocus(refs.statusClose), 0); emitStateChange('statusManagerOpen'); } function closeStatusManager() { if (!state.statusManagerOpen) return; state.statusManagerOpen = false; refs.statusLayer.hidden = true; header.removeAttribute('inert'); workspace.removeAttribute('inert'); refs.statusButton.setAttribute('aria-expanded', 'false'); safeFocus(previousFocus || refs.statusButton); previousFocus = null; emitStateChange('statusManagerClose'); } function focusablePanelNodes() { return Array.from(refs.statusPanel.querySelectorAll( 'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), summary, [tabindex]:not([tabindex="-1"])', )).filter((node) => !node.hidden); } function onDocumentKeydown(event) { if (!state.statusManagerOpen) return; if (event.key === 'Escape') { event.preventDefault(); closeStatusManager(); return; } if (event.key !== 'Tab') return; const nodes = focusablePanelNodes(); if (!nodes.length) return; const first = nodes[0]; const last = nodes[nodes.length - 1]; if (event.shiftKey && doc.activeElement === first) { event.preventDefault(); safeFocus(last); } else if (!event.shiftKey && doc.activeElement === last) { event.preventDefault(); safeFocus(first); } } function onSearchInput() { state.filters.search = text(refs.searchInput.value); renderBoard(); if (searchTimer !== null) global.clearTimeout(searchTimer); searchTimer = global.setTimeout(() => { searchTimer = null; if (adapters.send) refresh(); }, 180); } function onBoardChange(event) { const select = event.target?.closest?.('[data-action="set-status"]'); if (!select) return; const sessionId = text(select.dataset.sessionId); const statusId = text(select.value); if (!sessionId || !statusId) return; const task = state.tasks.find((item) => item.sessionId === sessionId); sendRequest({ type: MESSAGE_TYPES.statusSet, sessionId, statusId, ...(task?.version === null ? {} : { expectedVersion: task?.version }), }, 'task-status'); select.value = ''; } function onBoardClick(event) { const actionNode = event.target?.closest?.('[data-action]'); if (!actionNode) return; const action = actionNode.dataset.action; const sessionId = text(actionNode.dataset.sessionId); if (action === 'open-session' && sessionId && adapters.openSession) { adapters.openSession(sessionId); return; } if (action === 'set-archive' && sessionId) { const task = state.tasks.find((item) => item.sessionId === sessionId); sendRequest({ type: MESSAGE_TYPES.archiveSet, sessionId, archived: actionNode.dataset.archived === 'true', ...(task?.version === null ? {} : { expectedVersion: task?.version }), }, 'task-archive'); } } function onShellClick(event) { const actionNode = event.target?.closest?.('[data-action]'); if (!actionNode) return; switch (actionNode.dataset.action) { case 'toggle-archive': state.filters.archived = !state.filters.archived; renderBoard(); if (adapters.send) refresh(); break; case 'manage-statuses': openStatusManager(); break; case 'close-statuses': closeStatusManager(); break; case 'remove-definition': { const id = text(actionNode.dataset.definitionId); const form = actionNode.closest('form'); const migration = form?.querySelector('[data-migration-for]'); sendRequest({ type: MESSAGE_TYPES.definitionRemove, statusId: id, migrateTo: text(migration?.value) || null, ...(state.definitionsVersion === null ? {} : { expectedVersion: state.definitionsVersion }), }, 'task-status-definition'); break; } default: break; } } function onStatusSubmit(event) { const form = event.target?.closest?.('[data-form-action="upsert-definition"]'); if (!form) return; event.preventDefault(); submitDefinition(form); } function destroy() { if (state.destroyed) return; state.destroyed = true; if (searchTimer !== null) global.clearTimeout(searchTimer); if (clockTimer !== null) global.clearTimeout(clockTimer); clearWallRotation(); for (const record of wallCardRecords.values()) cancelWallCardAnimation(record); wallCardRecords.clear(); wallColumnRecords.clear(); wallVisibleSessionIds = new Set(); clearBoardRecords(); boardEmpty?.remove(); refs.searchInput.removeEventListener('input', onSearchInput); refs.board.removeEventListener('change', onBoardChange); refs.board.removeEventListener('click', onBoardClick); refs.shell.removeEventListener('click', onShellClick); refs.statusBody.removeEventListener('submit', onStatusSubmit); doc.removeEventListener('keydown', onDocumentKeydown); if (shell.parentNode === root) root.replaceChildren(); } const controller = Object.freeze({ element: shell, setData, handleMessage, refresh, setFilters, getState, openStatusManager, closeStatusManager, destroy, }); refs.searchInput.addEventListener('input', onSearchInput); refs.board.addEventListener('change', onBoardChange); refs.board.addEventListener('click', onBoardClick); refs.shell.addEventListener('click', onShellClick); refs.statusBody.addEventListener('submit', onStatusSubmit); doc.addEventListener('keydown', onDocumentKeydown); scheduleClock(); if (isObject(options.filters)) { state.filters.search = text(options.filters.search || options.filters.query); state.filters.archived = options.filters.archived === true; refs.searchInput.value = text(state.filters.search); } if (isObject(options.initialData)) setData(options.initialData); else renderBoard(); if (options.autoLoad !== false && adapters.send) refresh(); return controller; } function createTrackingControl(options = {}) { const doc = options.document || global.document; if (!doc || typeof doc.createElement !== 'function') { throw new TypeError('CcwebTaskBoard.createTrackingControl 需要可用的 document'); } const send = typeof options.send === 'function' ? options.send : null; const setTracking = typeof options.setTracking === 'function' ? options.setTracking : null; const onChange = typeof options.onChanged === 'function' ? options.onChanged : (typeof options.onChange === 'function' ? options.onChange : null); const onError = typeof options.onError === 'function' ? options.onError : null; const requestId = typeof options.createRequestId === 'function' ? options.createRequestId : (typeof options.requestId === 'function' ? options.requestId : createRequestId); const state = { sessionId: text(options.sessionId), enabled: options.enabled === true, disabled: options.disabled === true, pending: false, version: finiteNumber(options.version, null), statusMessage: text(options.statusLabel, options.enabled === true ? '已加入' : '未加入'), destroyed: false, }; let pendingRequest = null; const element = createNode(doc, 'label', 'ccweb-task-tracking'); const input = createNode(doc, 'input', 'ccweb-task-tracking__input'); input.type = 'checkbox'; input.checked = state.enabled; input.setAttribute('role', 'switch'); const dot = createNode(doc, 'span', 'ccweb-task-tracking__dot'); dot.setAttribute('aria-hidden', 'true'); const title = createNode(doc, 'span', 'ccweb-task-tracking__title', text(options.label, '看板')); const status = createNode(doc, 'span', 'ccweb-task-tracking__status'); status.setAttribute('aria-live', 'polite'); element.append(input, dot, title, status); function sync() { input.checked = state.enabled; input.disabled = state.disabled || state.pending || (!send && !setTracking) || !state.sessionId; element.dataset.enabled = state.enabled ? 'true' : 'false'; element.dataset.pending = state.pending ? 'true' : 'false'; element.setAttribute('aria-busy', state.pending ? 'true' : 'false'); const statusText = state.pending ? '保存中' : state.statusMessage; status.textContent = statusText; input.setAttribute('aria-label', state.enabled ? '移出任务看板' : '加入任务看板'); } function completeFailure(error, request) { if (pendingRequest && request.requestId !== pendingRequest.requestId) return; state.enabled = request.previousEnabled; state.pending = false; state.statusMessage = text(error?.message, '保存失败'); pendingRequest = null; sync(); if (onError) onError(error, request.message); } function completeSuccess(result, request) { if (pendingRequest && request.requestId !== pendingRequest.requestId) return; const protocolError = getProtocolError(result); if (protocolError) { completeFailure(Object.assign(new Error(protocolError.message), protocolError), request); return; } const envelope = collectEnvelope(result); const task = isObject(envelope.task) ? envelope.task : {}; const tracking = isObject(task.taskTracking) ? task.taskTracking : (isObject(envelope.taskTracking) ? envelope.taskTracking : envelope); const nextVersion = finiteNumber(tracking.version, finiteNumber(envelope.version, null)); if (nextVersion !== null) state.version = nextVersion; state.enabled = hasOwn(tracking, 'enabled') ? tracking.enabled === true : request.nextEnabled; state.pending = false; state.statusMessage = state.enabled ? '已加入' : '未加入'; pendingRequest = null; sync(); if (onChange) onChange(state.enabled, result, request.message); } function persist(nextEnabled) { const previous = state.enabled; state.enabled = nextEnabled; state.pending = true; sync(); const message = { type: MESSAGE_TYPES.trackingSet, requestId: requestId('task-tracking'), sessionId: state.sessionId, enabled: nextEnabled, ...(state.version === null ? {} : { expectedVersion: state.version }), }; const request = { requestId: message.requestId, previousEnabled: previous, nextEnabled, message, }; pendingRequest = request; let result; try { result = setTracking ? setTracking({ ...message }) : send({ ...message }); } catch (error) { completeFailure(error, request); return null; } if (result && typeof result.then === 'function') { return Promise.resolve(result) .then((response) => completeSuccess(response, request)) .catch((error) => completeFailure(error, request)); } if (isObject(result) || setTracking) { completeSuccess(result, request); } return result; } function onInputChange() { persist(input.checked); } function setState(next = {}) { if (hasOwn(next, 'sessionId')) state.sessionId = text(next.sessionId); if (hasOwn(next, 'enabled')) { state.enabled = next.enabled === true; state.statusMessage = state.enabled ? '已加入' : '未加入'; } if (hasOwn(next, 'disabled')) state.disabled = next.disabled === true; if (hasOwn(next, 'version')) state.version = finiteNumber(next.version, null); if (hasOwn(next, 'pending')) state.pending = next.pending === true; if (hasOwn(next, 'statusMessage')) state.statusMessage = text(next.statusMessage); if (hasOwn(next, 'statusLabel')) state.statusMessage = text(next.statusLabel); if (hasOwn(next, 'label')) title.textContent = text(next.label, '看板'); sync(); return controller; } function handleMessage(message) { if (!isObject(message) || message.type !== MESSAGE_TYPES.trackingResult) return false; const envelope = collectEnvelope(message); const responseSessionId = text(envelope.sessionId || envelope.task?.sessionId || envelope.task?.id); if (responseSessionId && responseSessionId !== state.sessionId) return false; if (pendingRequest && text(message.requestId) && text(message.requestId) !== pendingRequest.requestId) return false; const request = pendingRequest || { requestId: text(message.requestId), previousEnabled: state.enabled, nextEnabled: hasOwn(envelope, 'enabled') ? envelope.enabled === true : state.enabled, message: null, }; const protocolError = getProtocolError(message); if (protocolError) completeFailure(Object.assign(new Error(protocolError.message), protocolError), request); else completeSuccess(message, request); return true; } function getState() { return { ...state }; } function destroy() { if (state.destroyed) return; state.destroyed = true; input.removeEventListener('change', onInputChange); if (typeof element.remove === 'function') element.remove(); } const controller = Object.freeze({ element, input, setState, update: setState, handleMessage, getState, destroy, }); input.addEventListener('change', onInputChange); sync(); return controller; } function renderTrackingControl(container, options = {}) { if (!container || typeof container.replaceChildren !== 'function') { throw new TypeError('CcwebTaskBoard.renderTrackingControl 需要有效的容器节点'); } const control = createTrackingControl({ ...options, document: options.document || container.ownerDocument }); container.replaceChildren(control.element); return control; } const api = Object.freeze({ version: VERSION, messageTypes: MESSAGE_TYPES, mount, createTrackingControl, createTrackingToggle: createTrackingControl, renderTrackingControl, mountTrackingControl: renderTrackingControl, mountTrackingToggle: renderTrackingControl, }); global.CcwebTaskBoard = api; })(typeof window !== 'undefined' ? window : globalThis);