1704 lines
66 KiB
JavaScript
1704 lines
66 KiB
JavaScript
(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);
|