学习前提
理解稳定ID、数组筛选、select事件和JSON存储。
本课完成后你能做什么
做出可添加目标、切换阶段、筛选和刷新恢复的学习看板,确保进度统计来自完整数据。
先理解机制
第一阶段定义业务规则。每个目标有稳定id、标题与planned/learning/done阶段;阶段是互斥状态,不用三个独立布尔值,避免同时“未开始”和“已完成”。目标上限100、标题最多80字符,是本项目明确的界面约束。记录与筛选状态分开,筛选不改变记录本身。
第二阶段实现视图派生。完成比例从全部goals计算,不能从筛选后的列表计算,否则只看done时会误显示100%。空看板定义为0%,不做0/0。列表是数据的投影,切换筛选后应能恢复所有记录。
第三阶段实现交互。用原生select切换阶段,键盘用户与触屏用户都可操作,无需只支持拖拽。记录移出当前筛选后,原焦点元素会消失,render把焦点移到筛选器;仍可见时则恢复到该记录的select。删除后回到添加输入,确保任务路径连续。
第四阶段保存与恢复。启动时检查数组、id唯一性、标题与阶段允许值;保存异常只影响持久化,不阻断内存操作。示例使用自己的版本键,不清空同源其他数据。它不提供云端同步、账户或团队协作。
可运行示例
运行方式:将HTML示例保存为独立的.html文件并在浏览器打开;含脚本的示例使用现代浏览器。资源文件与多文件模块另按正文要求准备。
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Learning progress board</title><style>
*{box-sizing:border-box}body{max-width:850px;margin:auto;padding:24px;font:17px/1.6 system-ui;color:#20362c;background:#f7faf4}button,input,select{font:inherit;padding:8px;max-width:100%}button{cursor:pointer}button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid #176343;outline-offset:3px}form{display:flex;flex-wrap:wrap;gap:12px;align-items:end}label{display:block}input,select{display:block}li{margin:12px 0;padding:12px;background:white;border:1px solid #d4dfd2;border-radius:8px;overflow-wrap:anywhere}li button{margin:4px}progress{width:100%}.row{display:flex;gap:12px;flex-wrap:wrap}output{font-size:2rem;font-variant-numeric:tabular-nums}small{display:block}table{border-collapse:collapse;width:100%}td,th{padding:10px;text-align:left;border-bottom:1px solid #ccc}.table-wrap{overflow:auto}h1{font-size:clamp(26px,5vw,38px)}
</style></head><body><main><h1>Learning progress board</h1><p>Add a goal and move it through three stages. Data stays in this browser.</p>
<form id="add"><label>Goal<input id="title" required maxlength="80"></label><button>Add goal</button></form>
<p id="message" role="status"></p><label for="filter">Show stage</label><select id="filter"><option value="all">All</option><option value="planned">Planned</option><option value="learning">Learning</option><option value="done">Done</option></select>
<p id="summary"></p><progress id="progress" max="100" value="0" aria-label="Completed goals"></progress><ul id="goals"></ul></main><script>
const KEY='honeysuck-project:board:v1',stages=['planned','learning','done'];
let goals=[];
const message=document.querySelector('#message'),list=document.querySelector('#goals'),filter=document.querySelector('#filter');
try{const raw=JSON.parse(localStorage.getItem(KEY)||'[]');const seen=new Set();if(Array.isArray(raw))goals=raw.filter(x=>{if(!x||typeof x.id!=='string'||seen.has(x.id)||typeof x.title!=='string'||!x.title.trim()||x.title.length>80||!stages.includes(x.stage))return false;seen.add(x.id);return true;}).slice(0,100);}catch{message.textContent='Saved data could not be loaded. Starting with an empty board.';}
function save(){try{localStorage.setItem(KEY,JSON.stringify(goals));}catch{message.textContent='Could not save. Current changes may be lost after reload.';}}
function render(focusId){
const done=goals.filter(x=>x.stage==='done').length,percent=goals.length?Math.round(done/goals.length*100):0;
document.querySelector('#summary').textContent=`${done} of ${goals.length} complete (${percent}%)`;document.querySelector('#progress').value=percent;list.replaceChildren();
for(const goal of goals.filter(x=>filter.value==='all'||x.stage===filter.value)){
const li=document.createElement('li');li.dataset.id=goal.id;const title=document.createElement('strong');title.textContent=goal.title;
const label=document.createElement('label');label.textContent='Stage for '+goal.title;const select=document.createElement('select');
for(const stage of stages){const option=document.createElement('option');option.value=stage;option.textContent=stage;option.selected=stage===goal.stage;select.append(option);}label.append(select);
select.addEventListener('change',()=>{goal.stage=select.value;message.textContent='Stage updated.';save();render(goal.id);});
const remove=document.createElement('button');remove.type='button';remove.textContent='Delete';remove.setAttribute('aria-label','Delete '+goal.title);remove.addEventListener('click',()=>{goals=goals.filter(x=>x.id!==goal.id);message.textContent='Goal removed.';save();render();document.querySelector('#title').focus();});li.append(title,label,remove);list.append(li);
}
if(!list.children.length){const li=document.createElement('li');li.textContent=goals.length?'No goals in this stage.':'No goals yet. Add your first goal.';li.dataset.empty='true';list.append(li);}
if(focusId){const row=[...list.children].find(x=>x.dataset.id===focusId);(row?.querySelector('select')||filter).focus();}
}
document.querySelector('#add').addEventListener('submit',event=>{event.preventDefault();const input=document.querySelector('#title'),title=input.value.trim();if(!title||title.length>80||goals.length>=100){message.textContent='Use 1–80 characters; the board allows 100 goals.';return;}goals.push({id:crypto.randomUUID(),title,stage:'planned'});input.value='';message.textContent='Goal added.';save();render();input.focus();});filter.addEventListener('change',()=>render());render();
</script></body></html>
推演与验证
验收路径:添加两个同名目标,改其中一个为done,总进度50%;筛选done仍显示50%;再把该项改回planned,应出现筛选空状态且焦点回筛选器。清除筛选看到两项,刷新后阶段保留。破损JSON应能恢复到空看板,保存受限时显示提示。
常见错误与边界
crypto.randomUUID需要HTTPS或localhost等安全上下文。演示的存储不具备多标签页合并策略,两个页面同时编辑可能后写覆盖。标题用textContent,不接受HTML。
动手练习
增加“撤销最近一次删除”,只支持一步撤销:删除时保存完整记录及原位置,再显示撤销按钮;新增删除替换旧撤销记录。
参考解法与理由
撤销恢复稳定id和原阶段,并检查当前集合没有同id记录。恢复后保存并重新渲染,统计自然重算。明确刷新后撤销不可用,避免用户把临时内存撤销误认为历史版本。
项目深化:筛选不能改变完成率的分母
筛选“已完成”后,如果用可见行计算总进度,完成率会永远是100%。先区分业务数据和当前视图,再把统计写成独立函数。示例用稳定ID,即使标题相同也不会把两个目标合并。
在现代浏览器Console中独立运行这个实验;它不替换上方主示例。
function summarize(goals, filter) {
const visible = filter === 'all' ? goals : goals.filter(g => g.stage === filter);
const completed = goals.filter(g => g.stage === 'done').length;
return {visible: visible.length, total: goals.length, completed,
percent: goals.length ? Math.round(completed / goals.length * 100) : 0};
}
const goals = [
{id: 'a', title: 'Read', stage: 'done'},
{id: 'b', title: 'Read', stage: 'todo'},
{id: 'c', title: 'Practise', stage: 'doing'}
];
console.log(JSON.stringify(summarize(goals, 'done')));
console.log(JSON.stringify(summarize([], 'all')));
console.assert(summarize(goals, 'done').percent === summarize(goals, 'all').percent);
预期观察
筛选done时visible=1、total=3、completed=1、percent=33;空集合四个值均为0。
原因、取舍与适用边界
完成率是派生值,不必与记录一起独立保存,否则删除或迁移时可能忘记同步。这里规定三个阶段互斥,统计才有清楚含义;若允许暂停和取消,应先决定它们是否进入分母。界面中“当前显示1项”和“全部完成1/3”应明确区分。改变阶段使当前行消失时,还要把键盘焦点移到稳定位置,避免用户失去操作线索。
进阶练习
如果每个目标有不同权重,还能用完成条目数除以总条目数吗?
解法与推理
不能。先校验权重为有限非负数,再用已完成权重之和除以全部权重之和;总权重为0时定义明确空态。权重应表达学习计划而非随意夸大进度,界面要说明统计方式改变。
进一步阅读
完整源码与验收
下载后在独立练习目录另存为.html文件,用浏览器打开。文件包含上方完整主项目,后面的深入实验独立练习;数据保存在对应浏览器来源中,不是服务端账号。