Files
cc-web/public/task-board.js

1704 lines
66 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(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,
};
let searchTimer = null;
let previousFocus = null;
let latestQueryRequestId = '';
let hasRenderedBoard = false;
const columnRecords = new Map();
const cardRecords = new Map();
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 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;
workspace.appendChild(board);
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,
board,
statusLayer,
statusPanel,
statusClose,
statusPanelSummary,
statusBody,
liveRegion,
};
function announce(message) {
refs.liveRegion.textContent = text(message);
}
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} 的任务状态`);
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, item.id === task.statusId));
}
statusSelect.value = task.statusId;
statusSelect.disabled = !adapters.send;
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 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();
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 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);
renderBoard({ animate: true });
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 task = state.tasks.find((item) => item.sessionId === sessionId);
sendRequest({
type: MESSAGE_TYPES.statusSet,
sessionId,
statusId: text(select.value),
...(task?.version === null ? {} : { expectedVersion: task?.version }),
}, 'task-status');
}
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);
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);
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);