学习前提
理解数值类型、输入校验、数组统计和本地存储;金额以固定币种练习。
本课完成后你能做什么
用整数最小货币单位保存金额,完成分类筛选、合计与本地恢复。
先理解机制
第一阶段定义范围:这是固定币种的支出记录练习,不是银行连接或财务建议。每条记录包含id、描述、分类和整数cents。示例用USD展示;若改币种,必须同时确认该币种小数单位规则,不能只换一个符号就认为完成多币种支持。
第二阶段解析输入。先验证非负数字文本和最多两位小数,再按整数部分×100加补齐的小数部分得到分。拒绝0、负数、科学计数法、超过两位小数和过大金额,不在没有告知的情况下静默四舍五入。输入规则应在错误提示中可见。
第三阶段派生汇总。先按分类筛选,再累加整数cents,最终只在显示时除以100交给Intl格式器。界面明确写“当前可见合计”,与进度看板的全量统计不同;统计口径必须跟文案一致。删除用稳定id,两个同名支出仍是两条记录。
第四阶段保存与恢复。启动验证数据形状、分类允许值和金额边界,避免损坏数据让合计变NaN。保存失败保留内存操作并提示。只有浏览器本地存储,不跨设备,也没有审计历史;练习时使用虚构记录即可。
可运行示例
运行方式:将HTML示例保存为独立的.html文件并在浏览器打开;含脚本的示例使用现代浏览器。资源文件与多文件模块另按正文要求准备。
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Expense notebook</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>Expense notebook</h1><p>Practice data only. This is a local record-keeping demo.</p>
<form id="entry"><label>Description<input id="description" required maxlength="60"></label><label>Amount<input id="amount" inputmode="decimal" placeholder="12.50" required></label><label>Category<select id="category"><option value="learning">Learning</option><option value="daily">Daily</option><option value="other">Other</option></select></label><button>Add expense</button></form>
<p id="message" role="status"></p><label for="filter">Filter category</label><select id="filter"><option value="all">All</option><option value="learning">Learning</option><option value="daily">Daily</option><option value="other">Other</option></select><p id="total"></p><ul id="entries"></ul></main><script>
const KEY='honeysuck-project:expenses:v1',categories=['learning','daily','other'];let entries=[];
const message=document.querySelector('#message'),list=document.querySelector('#entries'),filter=document.querySelector('#filter');
function cents(raw){const text=raw.trim();if(!/^(0|[1-9][0-9]{0,5})(\.[0-9]{1,2})?$/.test(text))return null;const [whole,fraction='']=text.split('.');const value=Number(whole)*100+Number(fraction.padEnd(2,'0'));return value>0&&Number.isSafeInteger(value)?value:null;}
const money=new Intl.NumberFormat('en-US',{style:'currency',currency:'USD'});
try{const value=JSON.parse(localStorage.getItem(KEY)||'[]');const seen=new Set();if(Array.isArray(value))entries=value.filter(x=>{if(!x||typeof x.id!=='string'||seen.has(x.id)||typeof x.description!=='string'||!x.description.trim()||x.description.length>60||!Number.isSafeInteger(x.cents)||x.cents<=0||x.cents>99999999||!categories.includes(x.category))return false;seen.add(x.id);return true;}).slice(0,500);}catch{message.textContent='Saved entries could not be read. Starting empty.';}
function save(){try{localStorage.setItem(KEY,JSON.stringify(entries));}catch{message.textContent='Could not save. Keep a separate copy before leaving.';}}
function render(){const visible=entries.filter(x=>filter.value==='all'||x.category===filter.value);const sum=visible.reduce((n,x)=>n+x.cents,0);document.querySelector('#total').textContent=`${visible.length} entries · Visible total: ${money.format(sum/100)}`;list.replaceChildren();for(const entry of visible){const li=document.createElement('li'),text=document.createElement('span'),button=document.createElement('button');text.textContent=`${entry.description} · ${entry.category} · ${money.format(entry.cents/100)}`;button.type='button';button.textContent='Delete';button.setAttribute('aria-label','Delete '+entry.description);button.addEventListener('click',()=>{entries=entries.filter(x=>x.id!==entry.id);message.textContent='Entry removed.';save();render();filter.focus();});li.append(text,button);list.append(li);}if(!visible.length){const li=document.createElement('li');li.dataset.empty='true';li.textContent='No entries in this view.';list.append(li);}}
document.querySelector('#entry').addEventListener('submit',event=>{event.preventDefault();const description=document.querySelector('#description').value.trim(),amount=cents(document.querySelector('#amount').value),category=document.querySelector('#category').value;if(!description||description.length>60||amount===null||!categories.includes(category)||entries.length>=500){message.textContent='Use a description and a positive amount up to 999999.99 with at most two decimals; maximum 500 entries.';return;}entries.push({id:crypto.randomUUID(),description,cents:amount,category});document.querySelector('#description').value='';document.querySelector('#amount').value='';message.textContent='Expense added.';save();render();document.querySelector('#description').focus();});filter.addEventListener('change',render);render();
</script></body></html>
推演与验证
添加0.10与0.20,同一分类合计应显示$0.30。切换分类后合计仅包括可见项。输入1.234、-1、1e3、空格和0都被拒绝;输入12.5保存为1250分。重复描述可分别删除。刷新后恢复,损坏金额字段不会进入合计。
常见错误与边界
二进制浮点展示仍由格式器处理,但累计阶段不直接加0.1与0.2。示例限制500条和单笔上限,当前总和在安全整数范围内。不可用此简化模型声称已满足真实账务的审计和一致性要求。
动手练习
增加按分类汇总列表,并实现只清除当前筛选条件、不删除数据的重置按钮。给金额解析函数写正常和边界测试。
参考解法与理由
用固定分类表计算每组整数合计,展示时统一格式化。重置只把filter设回all并render。测试0.01、999999.99、1000000、00.10与尾随换行,确保规则与产品文案一致。
项目深化:金额解析与整数分的边界
0.1+0.2的浮点误差不是唯一风险:parseFloat会接受12abc的前缀,科学计数法和多余小数也可能绕过预期格式。这里为固定两位小数币种定义严格输入格式,并限制整数部分最多9位,最后转换为整数分。
在现代浏览器Console中独立运行这个实验;它不替换上方主示例。
function parseCents(text) {
if (typeof text !== 'string' || text.length > 12) throw new Error('Invalid amount');
const match = /^(0|[1-9][0-9]{0,8})(?:\.([0-9]{1,2}))?(?![\s\S])/.exec(text);
if (!match) throw new Error('Invalid amount');
return Number(match[1]) * 100 + Number((match[2] || '').padEnd(2, '0'));
}
console.log(parseCents('0.10') + parseCents('0.20'));
console.log(parseCents('12.3'));
for (const input of ['12abc', '1e3', '1.234', '-1', '01', '1\n']) {
try { parseCents(input); console.log('accepted'); }
catch { console.log('rejected'); }
}
预期观察
输出30、1230,然后六行rejected。30表示30分,不是30元;显示时才换算为货币格式。
原因、取舍与适用边界
输入约定不接受负数、空格、千位逗号或全角数字,这是有意的格式选择,界面应提前说明。整数分解决本练习的加总精度,但无限条记录的总和仍可能超出安全整数范围,需要限制记录数量并检查Number.isSafeInteger。不同币种小数位、汇率、税率和舍入政策不能由这段固定两位解析器解决;真实财务系统必须单独设计。
进阶练习
产品要求允许用户输入首尾空格,但仍拒绝中间空格,应在哪里处理?
解法与推理
在输入适配层明确trim后调用解析器,保存规范化结果,并给" 12.30 "和"12 .30"分别写接受与拒绝案例。不要在正则里随处插入\s*,那会意外接受更多非预期格式。
进一步阅读
完整源码与验收
下载后在独立练习目录另存为.html文件,用浏览器打开。文件包含上方完整主项目,后面的深入实验独立练习;数据保存在对应浏览器来源中,不是服务端账号。