Files
memind/scripts/build-tang-learning-assistant-pages.mjs
T
john ee8677cc6d fix(learn): skip plaza share widget on learning assistant pages
Wire delivery path detection through portal static routes and strip baked share chrome from learn parent/child HTML.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 18:50:01 +08:00

225 lines
40 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
#!/usr/bin/env node
/**
* 生成唐用户学习小助手 HTML(儿童端 + 家长端)
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createLearnAssistantService, learnAssistantInternals } from '../learn-assistant-service.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const TANG = learnAssistantInternals.TANG_OWNER_ID;
const API_BASE = '/api/learn/tang';
const outDir = path.join(root, 'MindSpace', TANG, 'public');
const sharedCss = `*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;background:#f2f4fa;color:#1c2333;min-height:100vh;padding-bottom:70px;padding-bottom:calc(70px + constant(safe-area-inset-bottom));padding-bottom:calc(70px + env(safe-area-inset-bottom))}
.wrap{max-width:560px;margin:0 auto;padding:16px 14px}
header.h{padding:16px 2px 4px}
h1{font-size:22px;font-weight:700}
.sub{font-size:13px;color:#6b7280;margin-top:3px}
.card{background:#fff;border-radius:16px;padding:16px;margin-top:12px;box-shadow:0 1px 3px rgba(28,35,51,.06)}
.big{font-size:26px;font-weight:700}
.bar{height:10px;background:#e8ebf2;border-radius:5px;overflow:hidden;margin-top:8px}
.bar i{display:block;height:100%;background:linear-gradient(90deg,#22c55e,#16a34a);border-radius:5px;transition:width .4s}
.cat{margin-top:14px}
.cat-title{font-size:13px;font-weight:700;padding:3px 10px;border-radius:999px;display:inline-flex;align-items:center;gap:5px}
.task{display:flex;align-items:center;gap:12px;padding:12px 2px;border-bottom:1px solid #f0f2f7;cursor:pointer}
.task:last-child{border-bottom:none}
.task .ck{width:26px;height:26px;border-radius:50%;border:2px solid #cbd5e1;flex:none;display:flex;align-items:center;justify-content:center;font-size:15px;color:#fff}
.task.done .ck{background:#16a34a;border-color:#16a34a}
.task .info{flex:1;min-width:0}
.task .t{font-size:15px;font-weight:600}
.task.done .t{color:#9ca3af;text-decoration:line-through}
.task .d{font-size:12px;color:#6b7280;margin-top:2px}
.task .pt{font-size:12px;color:#f59e0b;font-weight:700;flex:none}
.tabbar{position:fixed;left:0;right:0;bottom:0 !important;top:auto !important;background:#fff;border-top:1px solid #e5e8f0;display:flex;z-index:20;padding:6px 0 8px;padding:6px 0 calc(8px + constant(safe-area-inset-bottom));padding:6px 0 calc(8px + env(safe-area-inset-bottom))}
.overlay{top:0 !important;left:0 !important;right:0 !important;bottom:0 !important}
.tabbar a{flex:1;text-align:center;text-decoration:none;color:#9ca3af;font-size:11px;padding:4px 0}
.tabbar a.on{color:#3b5bfd;font-weight:700}
.tabbar .ic{font-size:20px;display:block}
button{font-family:inherit;border:none;cursor:pointer;border-radius:12px;font-size:14px;font-weight:600;padding:10px 16px;background:#3b5bfd;color:#fff}
button:active{transform:scale(.97)}
button.ghost{background:#e8edff;color:#3b5bfd}
button.danger{background:#ffe9ee;color:#e11d48}
button:disabled{opacity:.4;cursor:not-allowed}
input,select,textarea{font-family:inherit;width:100%;border:2px solid #e5e8f0;border-radius:10px;padding:10px 12px;font-size:15px;outline:none;background:#fff;color:#1c2333}
input:focus,select:focus,textarea:focus{border-color:#3b5bfd}
label{display:block;font-size:13px;font-weight:600;margin:12px 0 5px}
.overlay{position:fixed;inset:0;background:rgba(20,24,38,.5);z-index:40;display:flex;align-items:center;justify-content:center;padding:20px}
.modal{background:#fff;border-radius:18px;padding:24px;width:100%;max-width:360px;max-height:85vh;overflow:auto}
.statcheck{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:12px}
.sc{background:#fff;border-radius:14px;padding:13px 8px;text-align:center;box-shadow:0 1px 3px rgba(28,35,51,.06)}
.sc .n{font-size:20px;font-weight:700}
.sc .l{font-size:11px;color:#6b7280;margin-top:2px}
.cal{display:grid;grid-template-columns:repeat(7,1fr);gap:6px;margin-top:10px}
.cal .w{text-align:center;font-size:11px;color:#9ca3af;padding:4px 0}
.cal .cell{text-align:center;padding:7px 0;border-radius:10px;font-size:14px;position:relative}
.cal .cell.has{font-weight:700}
.cal .cell.g{background:#e7f8ee;color:#16a34a}.cal .cell.y{background:#fff4dd;color:#d97706}.cal .cell.r{background:#ffe9ee;color:#e11d48}.cal .cell.n{color:#c3c9d6}
.cal .today{outline:2px solid #3b5bfd}
.legend{display:flex;gap:12px;font-size:11px;color:#6b7280;margin-top:10px;flex-wrap:wrap}
.dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:4px;vertical-align:-1px}
.feed{position:fixed;left:50%;top:38%;transform:translate(-50%,-50%);background:#fff;border-radius:20px;padding:28px 30px;text-align:center;z-index:50;box-shadow:0 10px 40px rgba(28,35,51,.25);animation:pop .3s}
@keyframes pop{from{transform:translate(-50%,-50%) scale(.8);opacity:0}to{transform:translate(-50%,-50%) scale(1);opacity:1}}
.feed .em{font-size:44px}.feed .t1{font-size:18px;font-weight:700;margin-top:8px}.feed .t2{font-size:13px;color:#6b7280;margin-top:6px;line-height:1.6}
.feed .pt{color:#f59e0b;font-weight:700;margin-top:8px;font-size:15px}
table{width:100%;border-collapse:collapse;font-size:13px}
th,td{padding:8px 6px;border-bottom:1px solid #f0f2f7;text-align:left}
th{font-size:11px;color:#6b7280}
.row{display:flex;gap:10px;align-items:center}
.row>*{flex:1}
.pw{height:8px;background:#e8ebf2;border-radius:4px;overflow:hidden;min-width:60px}
.pw i{display:block;height:100%;background:#3b5bfd}
.chip{display:inline-flex;align-items:center;gap:6px;padding:6px 14px;border-radius:999px;border:2px solid #e5e8f0;font-size:13px;cursor:pointer;margin:0 8px 8px 0;background:#fff}
.chip.on{border-color:#3b5bfd;background:#e8edff;color:#3b5bfd;font-weight:700}
.hbtn{display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:600;color:#3b5bfd;background:#e8edff;padding:6px 13px;border-radius:999px;text-decoration:none;cursor:pointer;flex:none;white-space:nowrap}
.hbtn:active{transform:scale(.95)}
.err{background:#ffe9ee;color:#e11d48;padding:10px;border-radius:10px;font-size:13px;margin-top:10px;display:none}
.tabs{display:flex;gap:8px;margin-top:14px}
.tabs button{flex:1;background:#e8ebf2;color:#6b7280}
.tabs button.on{background:#3b5bfd;color:#fff}
.empty{text-align:center;color:#9ca3af;font-size:13px;padding:18px 0;line-height:1.7}
[data-mindspace-public-share],[data-mindspace-wechat-dialog],.publication-share-fab,.publication-share-sheet{display:none!important;visibility:hidden!important}`;
function childScript() {
return `
var API_BASE='${API_BASE}';
var state={tab:'today',child:null,children:[],data:null,cal:null,grow:null};
function esc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
async function api(p,opt){var r=await fetch(API_BASE+p,opt);var j=await r.json().catch(function(){return{}});if(!r.ok)throw new Error(j.error||'请求失败');return j}
function pickChild(){if(state.children.length<=1)return state.children[0]?state.children[0].id:1;var saved=localStorage.getItem('learn_child');var c=state.children.find(function(x){return String(x.id)===saved});if(!c){var s='<div class="card"><h1>我是谁呀?</h1>';state.children.forEach(function(x){s+='<div class="chip" style="margin-top:12px;font-size:16px;padding:12px 20px" onclick="chooseChild('+x.id+')">'+esc(x.emoji)+' '+esc(x.name)+'</div>'});s+='</div>';return s}return c.id}
function chooseChild(id){localStorage.setItem('learn_child',String(id));location.reload()}
function showSwitcher(){var s='<div class="overlay" onclick="if(event.target===this)this.remove()"><div class="modal"><h3 style="font-size:17px">👤 切换学员</h3><div style="margin-top:8px">';state.children.forEach(function(x){s+='<div class="chip" style="width:100%;justify-content:center;margin:8px 0 0;font-size:16px;padding:12px 20px'+(x.id==state.child?';border-color:#3b5bfd;background:#e8edff;font-weight:700':'')+'" onclick="chooseChild('+x.id+')">'+esc(x.emoji)+' '+esc(x.name)+(x.id==state.child?' ✓':'')+'</div>'});s+='</div><button class="ghost" style="margin-top:16px;width:100%" onclick="this.closest(\\'.overlay\\').remove()">取消</button></div></div>';document.body.insertAdjacentHTML('beforeend',s)}
function hdrActions(){var cur=state.children.find(function(x){return x.id==state.child});return '<div style="display:flex;gap:8px;flex:none;padding-top:6px"><span class="hbtn" onclick="showSwitcher()">⇄ '+(cur?'切换学员':'学员')+'</span><a class="hbtn" href="learning-assistant-parent.html">家长端</a></div>'}
function greet(){var h=new Date().getHours();return h<11?'早上好':h<14?'中午好':h<18?'下午好':'晚上好'}
function pad(n){return n<10?'0'+n:''+n}
function wdCN(d){return ['日','一','二','三','四','五','六'][d]}
function dowOf(ds){return new Date(Date.UTC(+ds.slice(0,4),+ds.slice(5,7)-1,+ds.slice(8,10))).getUTCDay()}
function renderToday(){var d=state.data;var p=d.total?Math.round(d.done/d.total*100):0;var s='<header class="h"><div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px"><div><h1>'+greet()+''+esc(d.child.name)+' '+esc(d.child.emoji)+'</h1><div class="sub">'+d.date.replace(/-/g,'/')+' 周'+wdCN(dowOf(d.date))+'</div></div>'+hdrActions()+'</div></header><div class="card"><div class="row" style="align-items:baseline"><div><div class="big">'+d.done+' / '+d.total+'</div><div class="sub">今天已完成</div></div><div style="text-align:right"><div class="big" style="color:#f59e0b">⭐ '+d.energy+'</div><div class="sub">我的能量</div></div></div><div class="bar"><i style="width:'+p+'%"></i></div>'+(d.total&&d.done===d.total?'<div style="text-align:center;margin-top:10px;font-size:15px;font-weight:700;color:#16a34a">⭐ 今天全部完成,太棒啦!</div>':'')+'</div>';var byCat={},order=[];d.tasks.forEach(function(t){var k=t.category_name||'其他';if(!byCat[k]){byCat[k]={color:t.category_color||'#6b7280',list:[]};order.push(k)}byCat[k].list.push(t)});if(!d.tasks.length)s+='<div class="card"><div class="empty">今天没有任务<br>好好休息,或找爸爸妈妈安排~</div></div>';order.forEach(function(k){s+='<div class="cat"><span class="cat-title" style="background:'+byCat[k].color+'1a;color:'+byCat[k].color+'">'+esc(k)+'</span><div class="card">';byCat[k].list.forEach(function(t){var needVal=(t.task_type!=='check');var sub=[];if(t.description)sub.push(esc(t.description));if(needVal)sub.push('目标:'+esc(t.target_value||'')+(t.unit?esc(t.unit):''));if(t.start_date)sub.push('📅 '+t.start_date.replace(/-/g,'/')+' 开始');if(t.status==='done'){sub.unshift('✓ '+(t.completed_time||'')+' 完成'+(needVal&&t.value_submitted?''+esc(t.value_submitted)+(t.unit?esc(t.unit):''):''));}s+='<div class="task'+(t.status==='done'?' done':'')+'" onclick="doTask('+t.id+')"><div class="ck"'+(t.status==='pending'?' style="border-color:#e11d48"':'')+'>'+(t.status==='done'?'✓':'')+'</div><div class="info"><div class="t">'+esc(t.title)+'</div>'+(sub.length?'<div class="d">'+sub.join('<br>')+'</div>':'')+'</div><div class="pt">+'+t.points+'</div></div>'});s+='</div></div>'});if(d.streak>0)s+='<div class="card" style="text-align:center"><span style="font-size:15px">🔥 连续打卡 <b style="color:#f59e0b">'+d.streak+'</b> 天,继续保持!</span></div>';return s}
function renderCal(){var m=state.cal.month;var first=m+'-01';var dim=new Date(Date.UTC(+m.slice(0,4),+m.slice(5,7),0)).getUTCDate();var lead=dowOf(first);var map={};state.cal.days.forEach(function(x){map[x.date]=x});var s='<header class="h"><div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px"><div><h1>我的日历 📅</h1><div class="sub">'+m.replace('-',' 年 ')+' 月</div></div>'+hdrActions()+'</div></header><div class="card"><div style="display:flex;justify-content:space-between;align-items:center"><button class="ghost" style="padding:6px 14px" onclick="calShift(-1)">← 上月</button><button class="ghost" style="padding:6px 14px" onclick="calShift(1)">下月 →</button></div><div class="cal">';['日','一','二','三','四','五','六'].forEach(function(w){s+='<div class="w">'+w+'</div>'});for(var i=0;i<lead;i++)s+='<div></div>';var today=new Date(Date.now()+8*3600e3).toISOString().slice(0,10);for(var d=1;d<=dim;d++){var ds=m+'-'+pad(d);var st=map[ds];var cls='cell ';var mark='';if(st){cls+=st.status==='green'?'g':st.status==='yellow'?'y':st.status==='red'?'r':'n';if(st.total){cls+=' has';if(st.status==='red')mark='<i style="position:absolute;top:4px;right:5px;width:7px;height:7px;border-radius:50%;background:#e11d48;box-shadow:0 0 4px #e11d48"></i>';else if(st.status==='yellow')mark='<i style="position:absolute;top:4px;right:5px;width:7px;height:7px;border-radius:50%;background:#f59e0b"></i>';}}if(ds===today)cls+=' today';s+='<div class="'+cls+'" style="position:relative;'+(st&&st.status==='red'?'box-shadow:0 0 0 2px #e11d48 inset':'')+'" onclick="showDay(\\''+ds+'\\')">'+d+mark+'</div>'}s+='</div><div class="legend"><span><i class="dot" style="background:#16a34a"></i>全部完成</span><span><i class="dot" style="background:#f59e0b"></i>部分完成</span><span><i class="dot" style="background:#e11d48"></i>有遗漏(红点)</span><span><i class="dot" style="background:#c3c9d6"></i>无任务</span><div style="width:100%;text-align:center;margin-top:4px;color:#3b5bfd">点任意日期查看当天详情</div></div></div><div id="dayDetail"></div>';return s}
async function showDay(ds){var j=await api('/day?child='+state.child+'&date='+ds);var el=document.getElementById('dayDetail');var isToday=ds===new Date(Date.now()+8*3600e3).toISOString().slice(0,10);var stTxt='';var stColor='';var done=j.tasks.filter(function(t){return t.status==='done'}).length;if(j.tasks.length){if(done===j.tasks.length){stTxt='全部完成 ✓';stColor='#16a34a'}else if(done>0){stTxt='完成 '+done+'/'+j.tasks.length+(ds<new Date(Date.now()+8*3600e3).toISOString().slice(0,10)?',有遗漏':'');stColor=ds<new Date(Date.now()+8*3600e3).toISOString().slice(0,10)?'#e11d48':'#d97706'}else{stTxt=ds<new Date(Date.now()+8*3600e3).toISOString().slice(0,10)?'全部未完成 ✕':'待完成';stColor=ds<new Date(Date.now()+8*3600e3).toISOString().slice(0,10)?'#e11d48':'#d97706'}}var s='<div class="card"><div class="row" style="align-items:center;margin-bottom:6px"><b style="font-size:15px;flex:1">'+ds.replace(/-/g,'/')+' 周'+wdCN(dowOf(ds))+(isToday?'(今天)':'')+'</b>'+(stTxt?'<span style="font-size:12px;font-weight:700;color:'+stColor+'">'+stTxt+'</span>':'')+'</div>';if(!j.tasks.length)s+='<div class="empty">那天没有任务</div>';j.tasks.forEach(function(t){var overdue=t.status==='pending'&&ds<new Date(Date.now()+8*3600e3).toISOString().slice(0,10);s+='<div class="task'+(t.status==='done'?' done':'')+'"><div class="ck"'+(overdue?' style="border-color:#e11d48;color:#e11d48;font-size:12px"':'')+'>'+(t.status==='done'?'✓':(overdue?'✕':''))+'</div><div class="info"><div class="t">'+esc(t.title)+'</div><div class="d"'+((overdue)?' style="color:#e11d48"':'')+'>'+esc(t.category_name||'')+(t.status==='done'?' · '+(t.completed_time||'')+' 完成':(overdue?' · 未完成':' · 待完成')+(t.value_submitted?' · '+esc(t.value_submitted)+(t.unit||''):''))+'</div></div></div>'});if(j.tasks.length)s+='<div class="sub" style="margin-top:10px">完成率:'+Math.round(done/j.tasks.length*100)+'%</div>';s+='</div>';el.innerHTML=s}
function renderGrow(){var g=state.grow;var s='<header class="h"><div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px"><div><h1>我的成长 🌱</h1></div>'+hdrActions()+'</div></header><div class="statcheck"><div class="sc"><div class="n" style="color:#f59e0b">⭐ '+g.energy+'</div><div class="l">我的能量</div></div><div class="sc"><div class="n" style="color:#ef4444">🔥 '+g.streak+'</div><div class="l">连续打卡</div></div><div class="sc"><div class="n">'+g.totalDone+'</div><div class="l">累计完成</div></div></div><div class="card"><div class="sub" style="margin-bottom:6px">💪 历史最长连续:<b>'+g.best+'</b> 天 全勤天数:<b>'+g.fullDays+'</b> 天</div></div><div class="card"><div class="t" style="font-weight:700;font-size:15px;margin-bottom:8px">近 30 天分类表现</div>';if(!g.byCategory.length)s+='<div class="empty">还没有记录,快去完成任务吧!</div>';g.byCategory.forEach(function(c){var p=c.total?Math.round(c.done/c.total*100):0;s+='<div style="margin:10px 0"><div class="row" style="align-items:center"><span style="flex:0 0 64px;font-size:13px;font-weight:600">'+esc(c.name)+'</span><div class="pw" style="flex:1"><i style="width:'+p+'%"></i></div><span style="flex:0 0 40px;text-align:right;font-size:12px;color:#6b7280">'+p+'%</span></div></div>'});s+='</div>';return s}
function renderHist(){var h=state.hist;var today=new Date(Date.now()+8*3600e3).toISOString().slice(0,10);var s='<header class="h"><div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px"><div><h1>我的记录 📖</h1><div class="sub">每天的任务和完成情况,都记在这里'+(h.cycleStart?'<br>本期任务从 <b style="color:#3b5bfd">'+h.cycleStart.replace(/-/g,'/')+'</b> 开始':'')+'</div></div>'+hdrActions()+'</div></header>';if(!h.data.length)s+='<div class="card"><div class="empty">还没有记录<br>完成今天的任务,就会出现在这里哦</div></div>';var daysAsc=h.data.slice().reverse();daysAsc.forEach(function(day,idx){var label=day.date===today?'今天':(day.date===addDaysLocal(today,-1)?'昨天':(parseInt(day.date.slice(5),10))+'月'+parseInt(day.date.slice(8),10)+'日');var w='周'+wdCN(dowOf(day.date));var stTxt=day.status==='green'?'全部完成':(day.done>0?'完成 '+day.done+'/'+day.total:(day.date<today?'有遗漏':'待完成'));var stColor=day.status==='green'?'#16a34a':day.status==='red'?'#e11d48':'#d97706';var stBg=day.status==='green'?'#e7f8ee':day.status==='red'?'#ffe9ee':'#fff4dd';var open=day.date===today;var bodyId='hb'+idx;var dot=day.status==='green'?'🟢':(day.status==='red'?'🔴':'🟡');s+='<div class="card"><div class="row" style="align-items:center;cursor:pointer" onclick="toggleDay(\\''+bodyId+'\\',this)"><div style="flex:1;cursor:pointer"><span style="font-size:16px;font-weight:700;cursor:pointer">'+label+'</span> <span class="sub">'+w+' '+dot+'</span></div><span style="font-size:12px;font-weight:700;color:'+stColor+';background:'+stBg+';padding:3px 10px;border-radius:999px;cursor:pointer">'+stTxt+'</span><span style="flex:0 0 16px;text-align:right;color:#9ca3af;font-size:12px;cursor:pointer" class="arr">'+(open?'▾':'▸')+'</span></div><div id="'+bodyId+'" style="'+(open?'':'display:none')+';margin-top:4px">';day.tasks.forEach(function(t){var sub=[];if(t.status==='done')sub.push('✓ '+(t.completed_time||'')+' 完成'+(t.task_type!=='check'&&t.value_submitted?''+esc(t.value_submitted)+(t.unit?esc(t.unit):''):''));else if(t.status==='pending'&&day.date<today)sub.push('✕ 未完成');else sub.push('待完成');if(t.remark)sub.push('📝 '+esc(t.remark));s+='<div class="task'+(t.status==='done'?' done':'')+'"><div class="ck">'+(t.status==='done'?'✓':(t.status==='pending'&&day.date<today?'✕':''))+'</div><div class="info"><div class="t">'+esc(t.title)+'</div><div class="d">'+esc(t.category_name||'')+' · '+sub.join(' · ')+'</div></div></div>'});s+='</div></div>'});return s}
function toggleDay(id,el){var b=document.getElementById(id);var arr=el.querySelector('.arr')||el.parentElement.querySelector('.arr');if(b.style.display==='none'){b.style.display='block';if(arr)arr.textContent='▾'}else{b.style.display='none';if(arr)arr.textContent='▸'}}
function addDaysLocal(ds,n){var d=new Date(ds+'T00:00:00Z');d.setUTCDate(d.getUTCDate()+n);return d.toISOString().slice(0,10)}
function renderTabbar(){var t=[['today','☀️','今日'],['hist','📖','记录'],['cal','📅','日历'],['grow','🌱','成长']];return t.map(function(x){return '<a href="javascript:void(0)" class="'+(state.tab===x[0]?'on':'')+'" onclick="go(\\''+x[0]+'\\')"><span class="ic">'+x[1]+'</span>'+x[2]+'</a>'}).join('')}
async function go(tab){state.tab=tab;await load();render()}
async function load(){if(!state.children.length){state.children=(await api('/children')).data}if(state.child==null){var c=pickChild();if(typeof c!=='number')return;state.child=c}if(state.child==null)return;if(state.tab==='today')state.data=await api('/today?child='+state.child);if(state.tab==='hist')state.hist=await api('/history?child='+state.child);if(state.tab==='cal'&&!state.cal)state.cal=await api('/calendar?child='+state.child);if(state.tab==='grow')state.grow=await api('/growth?child='+state.child)}
function render(){var app=document.getElementById('app');if(!state.children.length){app.innerHTML='<div class="card"><div class="empty">还没有小朋友的信息<br>请爸爸妈妈先去 <a href="learning-assistant-parent.html" style="color:#3b5bfd">家长后台</a> 添加</div></div>';document.getElementById('tabbar').innerHTML='';return}if(state.child==null){app.innerHTML=pickChild();document.getElementById('tabbar').innerHTML='';return}var s=state.tab==='today'?renderToday():state.tab==='hist'?renderHist():state.tab==='cal'?renderCal():renderGrow();app.innerHTML=s;document.getElementById('tabbar').innerHTML=renderTabbar()}
async function calShift(n){var m=state.cal.month.split('-');var y=+m[0],mo=+m[1]+n;if(mo<1){mo=12;y--}if(mo>12){mo=1;y++}state.cal=await api('/calendar?child='+state.child+'&month='+y+'-'+pad(mo));render()}
function doTask(id){var t=null;state.data.tasks.forEach(function(x){if(x.id===id)t=x});if(!t)(state.data.pastPending||[]).forEach(function(x){if(x.id===id)t=x});if(!t)return;if(t.status==='done')return;if(t.task_type==='check'){submit(id)}else{var unit=t.unit||'';var label=t.task_type==='count'?'完成了多少'+(unit?esc(unit):''):(t.task_type==='duration'?'坚持了多少'+(unit||'分钟'):'得了多少分');var def=t.value_submitted||(t.task_type==='duration'?(t.target_value||''):(t.target_value||''));document.getElementById('app').insertAdjacentHTML('beforeend','<div class="overlay" onclick="if(event.target===this)this.remove()"><div class="modal"><h3 style="font-size:17px">✏️ '+esc(t.title)+'</h3><label>'+label+'</label><input id="vInput" inputmode="numeric" value="'+esc(def)+'" placeholder="填一个数字吧"><label>想说的悄悄话(选填)</label><textarea id="vRemark" rows="2" style="font-size:14px"></textarea><div class="row" style="margin-top:16px"><button class="ghost" onclick="this.closest(\\'.overlay\\').remove()">再等等</button><button onclick="submitVal('+id+')">✓ 完成打卡</button></div></div></div>');var inp=document.getElementById('vInput');if(inp)inp.focus()}}
function submitVal(id){var v=document.getElementById('vInput').value;var r=document.getElementById('vRemark').value;submit(id,v,r)}
async function submit(id,value,remark){try{var j=await api('/checkin',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:id,value:value,remark:remark})});var s='<div class="feed" onclick="this.remove()"><div class="em">🎉</div><div class="t1">完成啦!</div><div class="t2">今天已经完成 <b>'+j.done+'</b> / '+j.total+' 项<br>'+(j.allDone?'⭐ 太棒了,今天全部完成!':'再完成 '+(j.total-j.done)+' 项,今天就是全绿!')+'</div><div class="pt">+'+j.points+' 能量</div><div class="t2" style="margin-top:12px;font-size:11px;color:#c3c9d6">点一下继续</div></div>';document.body.insertAdjacentHTML('beforeend',s);await load();render()}catch(e){alert(e.message)}}
(async function(){try{await load();render()}catch(e){document.getElementById('app').innerHTML='<div class="card"><div class="empty">'+esc(e.message)+'</div></div>'}})();`;
}
function parentScript() {
return `
var API_BASE='${API_BASE}';
var state={pw:localStorage.getItem('learn_pw')||'',tab:'dash',stats:null,templates:null,categories:null,children:null,editId:null};
function esc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
async function api(p,opt){opt=opt||{};opt.headers=Object.assign({'X-Parent-Pw':state.pw,'X-Admin-Pw':state.pw,'Content-Type':'application/json'},opt.headers||{});var r=await fetch(API_BASE+p,opt);var j=await r.json().catch(function(){return{}});if(!r.ok)throw new Error(j.error||'请求失败');return j}
function pad(n){return n<10?'0'+n:''+n}
var WD=['一','二','三','四','五','六','日'];
function weekdaysCN(w){return w.split(',').map(function(x){return WD[+x-1]}).join('、')}
var TYPE={check:'完成型',count:'数量型',duration:'时长型',score:'成绩型'};
function renderLogin(){return '<header class="h"><h1>家长后台 🔐</h1><div class="sub">任务配置 · 数据统计 · 习惯分析</div></header><div class="card"><label>管理密码</label><input type="password" id="pw" placeholder="请输入密码" onkeydown="if(event.key===\\'Enter\\')login()"><div class="err" id="err"></div><button style="width:100%;margin-top:16px" onclick="login()">进入后台</button><div class="sub" style="text-align:center;margin-top:14px"><a href="learning-assistant.html" style="color:#3b5bfd">← 返回小朋友页面</a></div></div>'}
async function login(){state.pw=document.getElementById('pw').value;try{await api('/parent/stats');localStorage.setItem('learn_pw',state.pw);state.tab='dash';await loadAll();render()}catch(e){var el=document.getElementById('err');el.style.display='block';el.textContent=e.message}}
function renderDash(){var s=state.stats;var tp=s.today.total?Math.round(s.today.done/s.today.total*100):0;var wp=s.week.total?Math.round(s.week.done/s.week.total*100):0;var st=s.children.length?s.children[0].streak:0;var html='<header class="h"><h1>数据看板 📊</h1><div class="sub">'+s.children.map(function(c){return esc(c.emoji)+' '+esc(c.name)+' 连续 '+c.streak+' 天🔥'}).join(' ')+'</div></header><div class="statcheck"><div class="sc"><div class="n" style="color:'+(tp===100?'#16a34a':'#3b5bfd')+'">'+tp+'%</div><div class="l">今日完成率 '+(s.today.done+'/'+s.today.total)+'</div></div><div class="sc"><div class="n">'+wp+'%</div><div class="l">本周完成率</div></div><div class="sc"><div class="n" style="color:#f59e0b">'+st+'</div><div class="l">连续全勤(天)</div></div></div><div class="card"><div class="t" style="font-weight:700;font-size:15px;margin-bottom:8px">近 7 天趋势</div>';s.trend.forEach(function(d){var p=d.total?Math.round(d.done/d.total*100):0;html+='<div style="margin:8px 0"><div class="row" style="align-items:center"><span style="flex:0 0 58px;font-size:12px;color:#6b7280">'+d.d.slice(5)+'</span><div class="pw" style="flex:1"><i style="width:'+p+'%;background:'+(p===100?'#16a34a':'#3b5bfd')+'"></i></div><span style="flex:0 0 40px;text-align:right;font-size:12px;color:#6b7280">'+p+'%</span></div></div>'});html+='</div>';if(s.missed.length){html+='<div class="card"><div class="t" style="font-weight:700;font-size:15px;margin-bottom:8px">⚠️ 近 30 天容易遗漏</div><table>';s.missed.forEach(function(m){html+='<tr><td>'+esc(m.title)+'</td><td style="color:#e11d48;font-weight:700;text-align:right">漏 '+m.count+' 次</td></tr>'});html+='</table></div>'}if(s.categoryRate.length){html+='<div class="card"><div class="t" style="font-weight:700;font-size:15px;margin-bottom:8px">📚 分类完成率(近 30 天)</div>';s.categoryRate.forEach(function(c){var p=c.total?Math.round(c.done/c.total*100):0;html+='<div style="margin:8px 0"><div class="row" style="align-items:center"><span style="flex:0 0 64px;font-size:13px;font-weight:600">'+esc(c.name)+'</span><div class="pw" style="flex:1"><i style="width:'+p+'%;background:'+(p>=90?'#16a34a':p>=70?'#f59e0b':'#e11d48')+'"></i></div><span style="flex:0 0 40px;text-align:right;font-size:12px;color:#6b7280">'+p+'%</span></div></div>'});html+='</div>'}if(s.avgTime.length){html+='<div class="card"><div class="t" style="font-weight:700;font-size:15px;margin-bottom:8px">⏰ 平均完成时间(近 30 天)</div><table>';s.avgTime.forEach(function(a){html+='<tr><td>'+esc(a.name)+'</td><td style="text-align:right;font-weight:600">'+a.time+'</td></tr>'});html+='</table><div class="sub" style="margin-top:8px">提示:越晚完成的科目,遗漏概率往往越高</div></div>'}return html}
function renderTemplates(){var html='<header class="h"><h1>任务模板 📋</h1><div class="sub">配置周期规则,系统每天自动生成当日任务</div></header>';if(!state.templates.length)html+='<div class="card"><div class="empty">还没有任务模板<br>点下方按钮创建第一个</div></div>';state.templates.forEach(function(t){html+='<div class="card"><div class="row" style="align-items:flex-start"><div style="flex:1"><div style="font-size:16px;font-weight:700">'+esc(t.title)+'</div><div class="sub" style="margin-top:4px;line-height:1.7">'+(t.category_name?'【'+esc(t.category_name)+'】 ':'')+TYPE[t.task_type]+(t.target_value?' · '+esc(t.target_value)+(t.unit?esc(t.unit):''):'')+'<br>每周:'+weekdaysCN(t.weekdays)+' 从 '+String(t.start_date).slice(0,10)+' 起'+(t.end_date?' 至 '+String(t.end_date).slice(0,10):'(长期)')+'<br>+'+t.points+' 能量'+(t.description?'<br>要求:'+esc(t.description):'')+'</div></div><div style="display:flex;flex-direction:column;gap:8px;flex:0 0 66px"><button class="ghost" style="padding:7px 10px;font-size:12px" onclick="editTpl('+t.id+')">编辑</button><button class="danger" style="padding:7px 10px;font-size:12px" onclick="delTpl('+t.id+',\\''+esc(t.title).replace(/'/g,'')+'\\')">停用</button></div></div></div>'});html+='<button style="width:100%;padding:14px;margin-top:14px" onclick="newTpl()"> 新建任务模板</button>';return html}
function tplForm(t){var kids=state.children.map(function(c){return '<option value="'+c.id+'"'+(t&&t.child_id===c.id?' selected':'')+'>'+esc(c.name)+'</option>'}).join('');var cats=state.categories.map(function(c){return '<option value="'+c.id+'"'+(t&&t.category_id===c.id?' selected':'')+'>'+esc(c.name)+'</option>'}).join('');var wds=(t?t.weekdays:'1,2,3,4,5').split(',');var wchk=WD.map(function(w,i){return '<span class="chip'+(wds.indexOf(String(i+1))>=0?' on':'')+'" onclick="this.classList.toggle(\\'on\\')">'+w+'</span>'}).join('');return '<div class="overlay" onclick="if(event.target===this)this.remove()"><div class="modal"><h3 style="font-size:17px;margin-bottom:4px">'+(t?'编辑任务模板':'新建任务模板')+'</h3><label>给谁安排</label><select id="f_child">'+kids+'</select><label>分类</label><select id="f_cat"><option value="">(无分类)</option>'+cats+'</select><label>任务名称 *</label><input id="f_title" value="'+(t?esc(t.title):'')+'" placeholder="如:英语单词复习" maxlength="60"><label>任务要求(孩子可见)</label><textarea id="f_desc" rows="2" maxlength="300" style="font-size:14px">'+(t?esc(t.description||''):'')+'</textarea><label>任务类型</label><select id="f_type">'+['check','count','duration','score'].map(function(k){return '<option value="'+k+'"'+(t&&t.task_type===k?' selected':'')+'>'+TYPE[k]+'</option>'}).join('')+'</select><div class="row"><div><label>目标值</label><input id="f_target" value="'+(t?esc(t.target_value||''):'')+'" placeholder="如 20" inputmode="numeric"></div><div><label>单位</label><input id="f_unit" value="'+(t?esc(t.unit||''):'')+'" placeholder="如 题/分钟/个"></div></div><label>重复的星期(点选)</label><div style="margin-top:2px">'+wchk+'</div><div class="row"><div><label>开始日期</label><input id="f_start" type="date" value="'+(t?String(t.start_date).slice(0,10):new Date().toISOString().slice(0,10))+'"></div><div><label>结束日期(可空)</label><input id="f_end" type="date" value="'+(t&&t.end_date?String(t.end_date).slice(0,10):'')+'"></div></div><label>能量值(完成奖励)</label><input id="f_points" type="number" value="'+(t?t.points:10)+'" min="0" max="100"><div class="err" id="f_err"></div><div class="row" style="margin-top:18px"><button class="ghost" onclick="this.closest(\\'.overlay\\').remove()">取消</button><button onclick="saveTpl('+(t?t.id:0)+')">保存</button></div></div></div>'}
function newTpl(){document.getElementById('app').insertAdjacentHTML('beforeend',tplForm(null))}
function editTpl(id){var t=state.templates.find(function(x){return x.id===id});if(t)document.getElementById('app').insertAdjacentHTML('beforeend',tplForm(t))}
async function delTpl(id,name){if(!confirm('停用「'+name+'」?已生成的历史任务保留,今后不再生成。'))return;await api('/parent/templates/'+id,{method:'DELETE'});await loadAll();render()}
async function saveTpl(id){var wds=[];document.querySelectorAll('.overlay .chip.on').forEach(function(c){wds.push(String(WD.indexOf(c.textContent)+1))});var body={child_id:+document.getElementById('f_child').value,category_id:+document.getElementById('f_cat').value||null,title:document.getElementById('f_title').value,description:document.getElementById('f_desc').value,task_type:document.getElementById('f_type').value,target_value:document.getElementById('f_target').value,unit:document.getElementById('f_unit').value,weekdays:wds,start_date:document.getElementById('f_start').value,end_date:document.getElementById('f_end').value,points:+document.getElementById('f_points').value};try{if(!body.title)throw new Error('任务名称必填');if(!wds.length)throw new Error('请至少选择一个星期');if(id)await api('/parent/templates/'+id,{method:'PUT',body:JSON.stringify(body)});else await api('/parent/templates',{method:'POST',body:JSON.stringify(body)});document.querySelector('.overlay').remove();await loadAll();render()}catch(e){var el=document.getElementById('f_err');el.style.display='block';el.textContent=e.message}}
function renderCats(){var html='<header class="h"><h1>分类管理 🏷️</h1><div class="sub">学习 + 习惯,不限语数外</div></header><div class="card"><table>';state.categories.forEach(function(c){html+='<tr><td><span class="dot" style="background:'+esc(c.color)+'"></span>'+esc(c.name)+'</td><td style="text-align:right"><button class="danger" style="padding:5px 10px;font-size:12px" onclick="delCat('+c.id+',\\''+esc(c.name)+'\\')">删除</button></td></tr>'});html+='</table></div><div class="card"><label>新增分类</label><div class="row"><input id="c_name" placeholder="如:练琴" maxlength="10"><input id="c_color" type="color" style="flex:0 0 56px;padding:4px" value="#3b5bfd"><button style="flex:0 0 70px" onclick="addCat()">添加</button></div></div>';return html}
async function addCat(){var n=document.getElementById('c_name').value.trim();if(!n)return;await api('/parent/categories',{method:'POST',body:JSON.stringify({name:n,color:document.getElementById('c_color').value})});await loadAll();render()}
async function delCat(id,name){if(!confirm('删除分类「'+name+'」?使用它的模板将变为无分类。'))return;await api('/parent/categories/'+id,{method:'DELETE'});await loadAll();render()}
function renderKids(){var html='<header class="h"><h1>孩子管理 👦</h1><div class="sub">儿童端选择孩子后进入</div></header>';state.children.forEach(function(c){html+='<div class="card"><div class="row"><div style="flex:1;font-size:16px;font-weight:700">'+esc(c.emoji)+' '+esc(c.name)+'</div><button class="danger" style="flex:0 0 66px;padding:7px 0;font-size:12px" onclick="delKid('+c.id+',\\''+esc(c.name)+'\\')">删除</button></div></div>'});html+='<div class="card"><label>添加孩子</label><div class="row"><input id="k_emoji" placeholder="😀" style="flex:0 0 64px" maxlength="2"><input id="k_name" placeholder="昵称,如:小宇" maxlength="10"><button style="flex:0 0 70px" onclick="addKid()">添加</button></div></div>';return html}
async function addKid(){var n=document.getElementById('k_name').value.trim();if(!n)return;await api('/parent/children',{method:'POST',body:JSON.stringify({name:n,emoji:document.getElementById('k_emoji').value||'🙂'})});await loadAll();render()}
async function delKid(id,name){if(!confirm('删除「'+name+'」及其所有任务记录?此操作不可恢复!'))return;await api('/parent/children/'+id,{method:'DELETE'});await loadAll();render()}
function renderSet(){return '<header class="h"><h1>设置 ⚙️</h1></header><div class="card"><label>修改管理密码</label><input type="password" id="p_old" placeholder="当前密码"><input type="password" id="p_new" placeholder="新密码(至少3位)" style="margin-top:8px"><div class="err" id="p_err"></div><button style="width:100%;margin-top:14px" onclick="chpw()">保存新密码</button></div><div class="card"><div class="sub" style="line-height:1.8">· 儿童端地址:<a href="learning-assistant.html" style="color:#3b5bfd">打开</a><br>· 任务每天首次访问时自动生成<br>· 历史任务保存快照,改模板不影响过去记录<br>· 数据存储:Memind Portal</div></div><button class="danger" style="width:100%;padding:12px" onclick="logout()">退出登录</button>'}
async function chpw(){try{await api('/parent/password',{method:'POST',body:JSON.stringify({old:document.getElementById('p_old').value,new:document.getElementById('p_new').value})});alert('密码已更新');state.pw=document.getElementById('p_new').value;localStorage.setItem('learn_pw',state.pw)}catch(e){var el=document.getElementById('p_err');el.style.display='block';el.textContent=e.message}}
function logout(){localStorage.removeItem('learn_pw');state.pw='';render()}
async function loadAll(){if(state.tab==='dash')state.stats=await api('/parent/stats');if(state.tab==='tpl'){state.templates=(await api('/parent/templates')).data;state.categories=(await api('/parent/categories')).data;state.children=(await api('/parent/children')).data}if(state.tab==='cat'){state.categories=(await api('/parent/categories')).data}if(state.tab==='kid'){state.children=(await api('/parent/children')).data}}
function render(){var app=document.getElementById('app');if(!state.pw){app.innerHTML=renderLogin();return}var tabs=[['dash','📊','看板'],['tpl','📋','模板'],['cat','🏷️','分类'],['kid','👦','孩子'],['set','⚙️','设置']];var body=state.tab==='dash'?renderDash():state.tab==='tpl'?renderTemplates():state.tab==='cat'?renderCats():state.tab==='kid'?renderKids():renderSet();app.innerHTML=body+'<div class="tabs">'+tabs.map(function(t){return '<button class="'+(state.tab===t[0]?'on':'')+'" onclick="go(\\''+t[0]+'\\')">'+t[1]+' '+t[2]+'</button>'}).join('')+'</div>'}
async function go(tab){state.tab=tab;try{await loadAll();render()}catch(e){if(e.message.indexOf('密码')>=0){state.pw='';localStorage.removeItem('learn_pw')}render()}}
(async function(){if(state.pw){try{await loadAll()}catch(e){if(e.message.indexOf('密码')>=0){state.pw='';localStorage.removeItem('learn_pw')}}}render()})();`;
}
function wrapHtml(title, bodyExtra, script) {
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>${title}</title>
<style>${sharedCss}${bodyExtra || ''}</style>
</head>
<body>
<div class="wrap" id="app"></div>
${title.includes('小助手') ? '<div class="tabbar" id="tabbar"></div><div style="text-align:center;font-size:10px;color:#c3c9d6;padding:6px 0 76px">v1</div>' : '<div class="wrap pl" id="app"></div>'.replace('id="app"', '')}
<script>${script}<\/script>
</body>
</html>`;
}
async function main() {
const service = createLearnAssistantService({ dataRoot: path.join(root, 'data', 'learn-assistant') });
const seed = await service.seedTangDefaults();
fs.mkdirSync(outDir, { recursive: true });
const childHtml = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>我的学习小助手</title>
<style>${sharedCss}</style>
</head>
<body>
<div class="wrap" id="app"></div>
<div class="tabbar" id="tabbar"></div>
<div style="text-align:center;font-size:10px;color:#c3c9d6;padding:6px 0 76px">v1</div>
<script>${childScript()}<\/script>
</body>
</html>`;
const parentHtml = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>家长后台</title>
<style>${sharedCss}body{padding-bottom:30px}.tabs{position:sticky;top:0;background:#f2f4fa;padding-top:2px;z-index:10}.pl{max-width:720px}</style>
</head>
<body>
<div class="wrap pl" id="app"></div>
<script>${parentScript()}<\/script>
</body>
</html>`;
fs.writeFileSync(path.join(outDir, 'learning-assistant.html'), childHtml);
fs.writeFileSync(path.join(outDir, 'learning-assistant-parent.html'), parentHtml);
console.log(JSON.stringify({
ok: true,
seed,
files: [
path.join(outDir, 'learning-assistant.html'),
path.join(outDir, 'learning-assistant-parent.html'),
],
publicUrls: [
`https://m.tkmind.cn/MindSpace/${TANG}/public/learning-assistant.html`,
`https://m.tkmind.cn/MindSpace/${TANG}/public/learning-assistant-parent.html`,
],
defaultParentPassword: '123456',
}, null, 2));
}
main().catch((err) => {
console.error(err);
process.exit(1);
});