编写第一个任务组件
完成支持进度与取消的资料统计任务。
本页目录
目标与最终效果#
这一章完成 资料统计自动化任务:接收文本、分批处理、显示实际进度、支持取消,完成后显示结果。它展示一个任务组件的最小闭环,后续可替换为自己的业务阶段。
示例不调用模型或外部进程,不持久化项目。取消通过运行身份停止后续批次;接入实际进程后,必须额外连接该进程真实的停止机制。
目录和模块顺序#
my-example-task/
manifest.json
main.js
modules/state.js
modules/actions.js
modules/view.js
styles/workspace.css
.tests/actions.test.cjs
README.txt先加载状态、再加载操作、再加载视图,最后注册入口。按环境准备放进开发版的任务扩展目录,重新扫描、启用并打开。
第一步:声明任务类别#
{
"id": "my-example-task",
"name": "资料统计示例",
"version": "1.0.0",
"apiVersion": 1,
"minAppVersion": "2.7.5",
"type": "thirdparty",
"category": "task",
"slot": "standalone",
"description": "分批统计文本材料,演示进度、取消与清理",
"main": "main.js",
"scripts": [
"modules/state.js",
"modules/actions.js",
"modules/view.js"
],
"styles": [
"styles/workspace.css"
],
"permissions": [],
"button": {
"label": "资料统计",
"order": 100
}
}这里明确使用 category: task。不要复制一个内置组件 ID,也不要添加未经宿主支持的 backend 字段。
第二步:把运行状态独立出来#
(function () {
window.MyExampleTask = window.MyExampleTask || {};
MyExampleTask.createState = function () {
return { runId: 0, status: 'idle', stage: '等待输入', processed: 0,
total: 0, result: null, error: '', disposed: false };
};
})();runId 用于识别一次运行;status 描述任务状态;stage 面向用户解释当前步骤。disposed 用于阻止关闭后的回调继续更新界面。
第三步:实现分批执行与取消#
(function () {
var task = window.MyExampleTask;
var pause = function () { return new Promise(function (r) { setTimeout(r, 0); }); };
task.cancel = function (state, render) {
if (state.status !== 'running') return;
state.runId += 1;
state.status = 'cancelled';
state.stage = '已取消,已有输入保持不变';
render();
};
task.run = async function (state, input, render) {
if (state.disposed || state.status === 'running') return;
var text = input.trim();
if (!text) {
state.status = 'failed'; state.error = '请先粘贴需要统计的材料';
state.stage = '检查输入'; render(); return;
}
var id = ++state.runId;
state.status = 'running'; state.stage = '分批统计材料';
state.error = ''; state.result = null; state.processed = 0;
state.total = text.length;
var lines = 1, characters = 0;
var current = function () { return !state.disposed && state.runId === id; };
try {
for (var offset = 0; offset < text.length; offset += 4096) {
if (!current()) return;
var chunk = text.slice(offset, offset + 4096);
for (var i = 0; i < chunk.length; i++) {
if (chunk[i] === '\n') lines++;
if (!/\s/.test(chunk[i])) characters++;
}
state.processed = Math.min(text.length, offset + 4096);
render();
await pause(); // Give the renderer a chance to paint and receive cancel.
}
if (!current()) return;
state.result = { lines: lines, characters: characters };
state.status = 'completed'; state.stage = '统计完成';
} catch (error) {
if (!current()) return;
state.status = 'failed';
state.error = state.stage + '失败:' + String(error.message || error);
} finally {
if (current()) render();
}
};
})();每批最多处理 4096 个 UTF-16 单元,然后把执行权交回事件循环,使界面能重绘和响应取消。统计的 characters 是非空白 UTF-16 单元数,不是词数或字形数;文本首尾空白先被移除。
不要把这里的分批文本处理当作通用后台线程。更重的计算应使用目标宿主支持的后台执行路径,而不是持续占用渲染线程。
第四步:让界面只反映当前状态#
(function () {
var task = window.MyExampleTask;
var cleanup = null;
task.dispose = function () { if (cleanup) cleanup(); cleanup = null; };
task.mount = function (host) {
task.dispose();
if (!host) throw new Error('任务窗口容器不可用');
var state = task.createState();
var root = document.createElement('section'); root.className = 'my-example-task';
var title = document.createElement('h2'); title.textContent = '资料统计';
var input = document.createElement('textarea');
input.setAttribute('aria-label', '待统计材料'); input.placeholder = '粘贴文本材料';
var start = document.createElement('button'); start.textContent = '开始统计';
var stop = document.createElement('button'); stop.textContent = '取消';
start.type = stop.type = 'button';
var status = document.createElement('p'); status.setAttribute('role', 'status');
var progress = document.createElement('progress'); progress.max = 1;
var output = document.createElement('pre');
function render() {
if (state.disposed) return;
var running = state.status === 'running';
start.disabled = running; stop.disabled = !running; input.disabled = running;
status.textContent = state.error || state.stage;
progress.value = state.total ? state.processed / state.total : 0;
output.textContent = state.result ? JSON.stringify(state.result, null, 2) : '';
}
function run() { task.run(state, input.value, render); }
function cancel() { task.cancel(state, render); }
start.addEventListener('click', run); stop.addEventListener('click', cancel);
root.append(title, input, start, stop, status, progress, output);
host.append(root); render();
cleanup = function () {
state.disposed = true; state.runId++;
start.removeEventListener('click', run); stop.removeEventListener('click', cancel);
root.remove();
};
};
})();开始、取消、进度和结果都由同一份状态驱动。旧运行失效后不能把新任务改成完成;清理时移除事件和根节点,不碰其他组件的监听。
第五步:注册与样式#
(function () {
Mrite.plugins.register({
id: 'my-example-task',
activate: function (api, hostElement) { MyExampleTask.mount(hostElement); },
deactivate: function () { MyExampleTask.dispose(); }
});
})();.my-example-task { padding:24px; color:#152139; font:14px/1.7 system-ui; }
.my-example-task textarea { display:block; box-sizing:border-box; width:100%; min-height:180px; padding:12px; font:inherit; }
.my-example-task button { margin:12px 8px 0 0; padding:8px 18px; border:0; border-radius:8px; background:#245dba; color:white; }
.my-example-task button:disabled { opacity:.5; }
.my-example-task progress { width:100%; }
.my-example-task pre { white-space:pre-wrap; overflow-wrap:anywhere; }第六步:按操作验收#
| 操作 | 预期结果 |
|---|---|
| 输入“甲乙”,换行“丙” | lines: 2,characters: 3 |
| 空输入直接开始 | 提示需要材料,可以继续编辑 |
| 长文本运行时取消 | 显示已取消,输入恢复可编辑 |
| 取消后换新文本运行 | 只显示新运行的结果 |
| 运行时关闭或停用 | 不再回写旧界面,不留下重复监听 |
在示例目录运行:
node --test .tests/actions.test.cjs扩展为真实任务#
将 actions.js 中的本地统计替换为实际步骤时,继续保持运行身份、可解释阶段和清理链。输入文件先登记引用,确实需要时再读取;输出写入成功后才登记成果;外部请求、进程和任务取消分别接入自己的真实机制。