Files
memind/scripts/build-learning-assistant-public-pages.mjs
T
john 348e192800 feat(learn): add family learning assistant API routes and public pages
Wire /learn static delivery, parent/child portal routes, and Tang page build scripts for 103 deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 17:09:52 +08:00

136 lines
14 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
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const outDir = path.join(root, 'public', 'learn');
const sharedCss = fs.readFileSync(path.join(root, 'scripts/build-tang-learning-assistant-pages.mjs'), 'utf8')
.match(/const sharedCss = `([\s\S]*?)`;/)[1];
function childPage() {
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><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">v2</div>
<script>
var FAMILY_SLUG=new URLSearchParams(location.search).get('f')||'tang';
var API_BASE='/api/learn/public/'+encodeURIComponent(FAMILY_SLUG);
${childScriptBody()}
</script></body></html>`;
}
function childScriptBody() {
// childScript() 在 tang 构建里经 template literal 会把 \\' 变成 \'
// 这里直接读源码,需手动还原为 inline <script> 可用的转义。
return fs.readFileSync(path.join(root, 'scripts/build-tang-learning-assistant-pages.mjs'), 'utf8')
.match(/function childScript\(\) {\s*return `\s*([\s\S]*?)`;\s*}/)[1]
.replace(/var API_BASE='[^']*';\n?/g, '')
.replace(/learning-assistant-parent\.html/g, "' + 'parent.html?f=' + FAMILY_SLUG + '")
.replace(/\\\\'/g, "\\'");
}
function parentPage() {
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><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>
var FAMILY_SLUG=new URLSearchParams(location.search).get('f')||'';
var API_BASE='/api/learn/me';
var state={tab:'dash',stats:null,templates:null,categories:null,children:null,family:null,role:null,invite: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.credentials='include';opt.headers=Object.assign({'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||j.message||'请求失败');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 childUrl(){return '/learn/child.html?f='+(state.family?state.family.slug:FAMILY_SLUG||'tang')}
function memindAuthUrl(){
return '/?return_to='+encodeURIComponent(location.pathname+location.search);
}
function renderAuth(){
return '<header class="h"><h1>家长后台 🔐</h1><div class="sub">登录 Memind 后管理孩子任务;支持邀请爸爸/妈妈共同管理</div></header>'
+'<div class="card"><p class="sub" style="line-height:1.8;margin-bottom:12px">请通过 Memind 首页登录或注册(不要用 /auth/login 直链)。登录成功后会自动回到本页。</p>'
+'<a href="'+memindAuthUrl()+'" style="display:block;text-align:center;padding:12px;border-radius:12px;background:#3b5bfd;color:#fff;text-decoration:none;font-weight:600;margin-bottom:10px">登录 / 注册 Memind</a>'
+'<div class="sub" style="text-align:center;margin-top:14px"><a href="'+childUrl()+'" style="color:#3b5bfd">← 返回小朋友页面</a></div></div>';
}
async function ensureFamily(){
var info=await api('/family');
state.family=info.family;state.role=info.role;return info;
}
function renderInvite(){
if(state.role!=='owner')return '';
var s='<div class="card"><div class="t" style="font-weight:700;font-size:15px;margin-bottom:8px">👨‍👩‍👧 邀请协家长</div><div class="sub" style="line-height:1.7">把链接发给爸爸/妈妈,对方注册 Memind 后打开即可加入同一家庭。</div>';
if(state.invite&&state.invite.joinUrl){s+='<div class="sub" style="margin-top:10px;word-break:break-all;background:#f8fafc;padding:10px;border-radius:10px">'+esc(state.invite.joinUrl)+'</div>'}
s+='<button style="width:100%;margin-top:12px" onclick="createInvite()">生成邀请链接</button></div>';
return s;
}
async function createInvite(){state.invite=await api('/invite',{method:'POST'});render()}
function renderSet(){
return '<header class="h"><h1>设置 ⚙️</h1></header>'
+renderInvite()
+'<div class="card"><div class="sub" style="line-height:1.8">· 儿童端:<a href="'+childUrl()+'" style="color:#3b5bfd">'+esc(childUrl())+'</a><br>· 家庭 slug<b>'+esc(state.family.slug)+'</b><br>· 你的角色:<b>'+(state.role==='owner'?'主家长':'协家长')+'</b><br>· 成员:'+state.family.members.map(function(m){return esc(m.displayName||m.userId.slice(0,8))+'('+(m.role==='owner'?'主':'协')+')'}).join('、')+'</div></div>';
}
${parentDashboardScript()}
(async function(){try{var st=await fetch('/auth/status',{credentials:'include'}).then(function(r){return r.json()});if(!st.authenticated||!st.user){document.getElementById('app').innerHTML=renderAuth();return}await ensureFamily();await loadAll();render()}catch(e){document.getElementById('app').innerHTML='<div class="card"><div class="empty">'+esc(e.message)+'</div></div>'}})();
</script></body></html>`;
}
function parentDashboardScript() {
return `
function renderLogin(){return renderAuth()}
async function login(){await ensureFamily();await loadAll();render()}
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>';s.trend.forEach(function(d){var p=d.total?Math.round(d.done/d.total*100):0;html+='<div class="card"><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>'});return html}
function renderTemplates(){var html='<header class="h"><h1>任务模板 📋</h1></header>';state.templates.forEach(function(t){html+='<div class="card"><b>'+esc(t.title)+'</b><div class="sub">+'+t.points+' 能量 · 每周 '+weekdaysCN(t.weekdays)+'</div></div>'});html+='<button style="width:100%;margin-top:14px" onclick="newTpl()"> 新建任务模板</button>';return html}
function tplForm(t){var kids=state.children.map(function(c){return '<option value="'+c.id+'">'+esc(c.name)+'</option>'}).join('');var cats=state.categories.map(function(c){return '<option value="'+c.id+'">'+esc(c.name)+'</option>'}).join('');var wchk=WD.map(function(w,i){return '<span class="chip" onclick="this.classList.toggle(\\'on\\')">'+w+'</span>'}).join('');return '<div class="overlay" onclick="if(event.target===this)this.remove()"><div class="modal"><h3>'+(t?'编辑':'新建')+'任务模板</h3><select id="f_child">'+kids+'</select><input id="f_title" placeholder="任务名称"><select id="f_type"><option value="check">完成型</option><option value="count">数量型</option></select><div>'+wchk+'</div><button onclick="saveTpl('+(t?t.id:0)+')">保存</button></div></div>'}
function newTpl(){document.getElementById('app').insertAdjacentHTML('beforeend',tplForm(null))}
async function saveTpl(id){var wds=[];document.querySelectorAll('.overlay .chip.on').forEach(function(c,i){wds.push(String(i+1))});var body={child_id:+document.getElementById('f_child').value,title:document.getElementById('f_title').value,task_type:document.getElementById('f_type').value,weekdays:wds,start_date:new Date().toISOString().slice(0,10),points:10};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()}
function renderCats(){var html='<header class="h"><h1>分类管理 🏷️</h1></header><div class="card"><table>';state.categories.forEach(function(c){html+='<tr><td>'+esc(c.name)+'</td></tr>'});html+='</table></div><div class="card"><input id="c_name" placeholder="分类名"><button onclick="addCat()">添加</button></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:'#3b5bfd'})});await loadAll();render()}
function renderKids(){var html='<header class="h"><h1>孩子管理 👦</h1></header>';state.children.forEach(function(c){html+='<div class="card">'+esc(c.emoji)+' '+esc(c.name)+'</div>'});html+='<div class="card"><input id="k_name" placeholder="昵称"><button onclick="addKid()">添加</button></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:'🙂'})});await loadAll();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.family){app.innerHTML=renderAuth();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;await loadAll();render()}
`;
}
function joinPage() {
return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>加入家庭</title>
<style>body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;background:#f2f4fa;padding:24px}.card{max-width:420px;margin:40px auto;background:#fff;border-radius:16px;padding:24px;box-shadow:0 1px 3px rgba(0,0,0,.08)}button,a.btn{display:block;width:100%;text-align:center;padding:12px;border-radius:12px;border:none;font-weight:600;text-decoration:none;margin-top:10px}button{background:#3b5bfd;color:#fff}a.btn{background:#e8edff;color:#3b5bfd}</style></head><body>
<div class="card"><h1>加入学习家庭</h1><p id="msg" class="sub">正在处理邀请…</p><a class="btn" id="login" href="/" style="display:none">登录 / 注册 Memind</a></div>
<script>
var token=new URLSearchParams(location.search).get('token');
(async function(){
var msg=document.getElementById('msg');
if(!token){msg.textContent='邀请链接无效';return}
var st=await fetch('/auth/status',{credentials:'include'}).then(function(r){return r.json()});
if(!st.authenticated||!st.user){msg.textContent='请先登录 Memind,再加入家庭';document.getElementById('login').href='/?return_to='+encodeURIComponent(location.pathname+location.search);document.getElementById('login').style.display='block';return}
try{
var j=await fetch('/api/learn/me/join',{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:token})}).then(function(r){return r.json()});
if(j.error)throw new Error(j.error);
msg.textContent='已加入「'+j.family.name+'」,正在进入家长后台…';
location.href='/learn/parent.html?f='+encodeURIComponent(j.family.slug);
}catch(e){msg.textContent=e.message}
})();
</script></body></html>`;
}
function indexPage() {
return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>学习小助手</title>
<style>body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;background:#f2f4fa;padding:24px;text-align:center}.card{max-width:480px;margin:40px auto;background:#fff;border-radius:16px;padding:28px}a{display:inline-block;margin:8px;padding:12px 18px;border-radius:999px;background:#3b5bfd;color:#fff;text-decoration:none;font-weight:600}</style></head><body>
<div class="card"><h1>学习小助手</h1><p>家长注册 Memind 后配置任务;孩子打开链接打卡。</p>
<a href="/learn/parent.html">家长后台</a><a href="/learn/child.html?f=tang" style="background:#e8edff;color:#3b5bfd">儿童端示例</a>
<a href="/?return_to=%2Flearn%2Fparent.html" style="background:#16a34a">登录 / 注册 Memind</a></div></body></html>`;
}
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(path.join(outDir, 'child.html'), childPage());
fs.writeFileSync(path.join(outDir, 'parent.html'), parentPage());
fs.writeFileSync(path.join(outDir, 'join.html'), joinPage());
fs.writeFileSync(path.join(outDir, 'index.html'), indexPage());
console.log(JSON.stringify({ ok: true, outDir }, null, 2));