Compare commits

...

8 Commits

Author SHA1 Message Date
john f9e105acd0 feat(learn): add standalone /learn pages backed by Page Data
Serve lightweight child/parent learning assistant pages at /learn without
MindSpace injection, with a rebuild script and Portal static route.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 17:17:18 +08:00
John b99ddbacee docs: register feature/news-morning-force-archive disposition
Memind CI / Test, build, and release guards (push) Failing after 2m55s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 08:25:22 +08:00
John b89b74f29d fix(wechat): 强制重生成先归档当日早报,避免沿用旧 HTML
forceGenerateOnce 若看到已有 daily-news-MMDD.html 会立刻当完成,SearXNG 新稿写不进去。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 08:25:12 +08:00
John 0edb3716d5 docs: register feature/news-morning-searxng-prefetch disposition
Memind CI / Test, build, and release guards (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 08:15:35 +08:00
John 34ab59e0af feat(wechat): 早报生成预取 SearXNG 新闻,覆盖国内国际热点
生成任务不再只靠 web_search:先并行检索国内/国际/热门事件等,再要求 tkmind_search 与 web_search 补漏写页。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 08:15:12 +08:00
John 8798bf07fa test(wechat): forceGenerateOnce 用例补上当日 slug 规则
Memind CI / Test, build, and release guards (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 06:13:54 +08:00
John d966713d3f docs: register feature/news-morning-fresh-daily disposition
Memind CI / Test, build, and release guards (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 06:12:07 +08:00
John 4fc488ad33 fix(wechat): 早报必须搜当日新闻,禁止用旧稿充草稿
生成说明强制联网搜索当天内容,pushDraft 拒绝非当日页面;forceGenerateOnce 用于发版后重跑今日早报。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 06:11:36 +08:00
16 changed files with 1109 additions and 18 deletions
+70
View File
@@ -1,5 +1,75 @@
# 历史分支处置登记
## `feature/news-morning-force-archive`
**状态:禁止再次引用。改动已提交并进入本地 `main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
审计日期:2026-09-15
分支 HEAD`b89b74f2`
`main` 对应提交:`b89b74f2`
### 交付内容
- `forceGenerateOnce` 先把当日 `daily-news-MMDD.html` 归档为 `_archived-*`,再跑生成,避免沿用旧稿
### 验证摘要
- `node scripts/run-memind-tests.mjs --mode changed`ok
### 最终处置
- 保留本地分支名仅用于审计追溯。
- 不要从该分支继续开发、merge、cherry-pick 或构建 runtime/artifact。
- 发布依据使用 `main` @ `b89b74f2`
## `feature/news-morning-searxng-prefetch`
**状态:禁止再次引用。改动已提交并进入本地 `main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
审计日期:2026-09-15
分支 HEAD`34ab59e0`
`main` 对应提交:`34ab59e0`
### 交付内容
- 早报生成前并行预取 SearXNG(国内/国际/热门事件/财经科技/天气)
- 任务要求 `tkmind_search(type=news)``web_search` 补漏,禁止只靠 web_search
- SearXNG 适配器支持 `categories` / `language`
### 验证摘要
- `node scripts/run-memind-tests.mjs --mode changed`ok
### 最终处置
- 保留本地分支名仅用于审计追溯。
- 不要从该分支继续开发、merge、cherry-pick 或构建 runtime/artifact。
- 发布依据使用 `main` @ `34ab59e0`
## `feature/news-morning-fresh-daily`
**状态:禁止再次引用。改动已提交并进入本地 `main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
审计日期:2026-09-15
分支 HEAD`4fc488ad`
`main` 对应提交:`4fc488ad`
### 交付内容
- 新闻早报生成必须联网搜索当日内容,禁止复制历史 `daily-news-*.html` 正文
- `pushDraft` 默认拒绝非当日页面
- `forceGenerateOnce` + `scripts/force-wechat-news-morning-generate.mjs` 用于发版后强制重生成
### 验证摘要
- `node scripts/run-memind-tests.mjs --mode changed`ok
### 最终处置
- 保留本地分支名仅用于审计追溯。
- 不要从该分支继续开发、merge、cherry-pick 或构建 runtime/artifact。
- 发布依据使用 `main` @ `4fc488ad`
## `feature/wechat-draft-long-page-errors`
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
+7 -1
View File
@@ -7,10 +7,16 @@ export async function searchSearxng(query, {
fetchImpl = fetch,
timeoutMs = Number(process.env.TKMIND_SEARCH_TIMEOUT_MS ?? 8000),
signal,
categories = '',
language = '',
} = {}) {
if (!endpoint) throw new Error('SearXNG endpoint is not configured');
try { if (!['http:', 'https:'].includes(new URL(endpoint).protocol)) throw new Error('unsupported protocol'); } catch { throw new Error('SearXNG endpoint is not configured safely'); }
const url = new URL(endpoint); url.searchParams.set('q', query); url.searchParams.set('format', 'json');
const url = new URL(endpoint);
url.searchParams.set('q', query);
url.searchParams.set('format', 'json');
if (categories) url.searchParams.set('categories', String(categories));
if (language) url.searchParams.set('language', String(language));
const timeout = AbortSignal.timeout(timeoutMs);
const response = await fetchImpl(url, {
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
+15
View File
@@ -200,6 +200,21 @@ test('SearXNG adapter normalizes provider results without requiring a live netwo
assert.deepEqual(result[0], { title: 'Goose', url: 'https://example.com', snippet: 'snippet', source: 'searxng', rank: 1 });
});
test('SearXNG adapter can request news category and language', async () => {
let requested;
await searchSearxng('今日热点', {
endpoint: 'http://search.local/search',
categories: 'news',
language: 'zh-CN',
fetchImpl: async (url) => {
requested = String(url);
return { ok: true, json: async () => ({ results: [] }) };
},
});
assert.match(requested, /categories=news/);
assert.match(requested, /language=zh-CN/);
});
test('GitHub code adapter sends bounded queries and normalizes results', async () => {
let requested;
const result = await searchGithubCode('repo:openai goose', { limit: 50, fetchImpl: async (url, options) => { requested = { url: String(url), options }; return { ok: true, json: async () => ({ items: [{ repository: { full_name: 'openai/goose', description: 'agent' }, path: 'src/goose.rs', html_url: 'https://github.com/openai/goose/blob/main/src/goose.rs' }] }) }; } });
+114
View File
@@ -0,0 +1,114 @@
<!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>
<meta name="mindspace-page-data-page-id" content="dcb1f3f0-db96-4dab-a009-7a9c393ebe77">
<script>window.__MINDSPACE_PAGE_DATA__={"pageId":"dcb1f3f0-db96-4dab-a009-7a9c393ebe77","accessMode":"public"};</script>
<style>*{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}</style>
<script src="/assets/page-data-client.js?v=20260915j"></script>
<script src="/learn/learning-assistant-data.js?v=20260915j"></script>
<link rel="icon" type="image/png" href="/brand/tkmind-icon.png">
</head>
<body>
<div class="wrap" id="app"><div class="card"><div class="empty">加载中…</div></div></div>
<div class="tabbar" id="tabbar"></div>
<script>
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;')}
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="/learn/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 LearnStore.getDay(state.child,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 cycleTxt='';if(h.cycleStart){cycleTxt='<br>本期:'+h.cycleStart.replace(/-/g,'/')+' 起'+(h.cycleEnd?',至 '+h.cycleEnd.replace(/-/g,'/'):'(长期)')+' · 仅显示本期内有任务的日期'}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">本期任务完成情况'+(cycleTxt||'(请爸爸妈妈先配置本期任务)')+'</div></div>'+hdrActions()+'</div></header>';if(!h.cycleStart)s+='<div class="card"><div class="empty">还没有本期任务<br>请爸爸妈妈先在家长后台配置</div></div>';else 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 LearnStore.getChildren()).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 LearnStore.getToday(state.child);if(state.tab==='hist')state.hist=await LearnStore.getHistory(state.child);if(state.tab==='cal'&&!state.cal)state.cal=await LearnStore.getCalendar(state.child);if(state.tab==='grow')state.grow=await LearnStore.getGrowth(state.child)}
function render(){var app=document.getElementById('app');if(!state.children.length){app.innerHTML='<div class="card"><div class="empty">还没有小朋友的信息<br>请爸爸妈妈先去 <a href="/learn/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 LearnStore.getCalendar(state.child,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 LearnStore.checkin(id,value,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 LearnStore.init();await load();render()}catch(e){document.getElementById('app').innerHTML='<div class="card"><div class="empty">'+esc(e.message)+'</div></div>'}})();
</script>
</body>
</html>
+64
View File
@@ -0,0 +1,64 @@
(function(global){
"use strict";
var client=null,cache={children:[],categories:[],templates:[],tasks:[],completions:[],settings:[]};
var refreshAt=0,taskIndex=null,TOKEN_KEY="learn_pd_token",DATA_VER="20260915j";
function cycleBounds(childId){var tpls=cache.templates.filter(function(t){return String(t.child_id)===String(childId)&&String(t.active)!=="0"});if(!tpls.length)return {cycleStart:"",cycleEnd:""};var starts=[],ends=[];tpls.forEach(function(t){var s=String(t.start_date||"").slice(0,10);if(s)starts.push(s);if(t.end_date){var e=String(t.end_date).slice(0,10);if(e)ends.push(e)}});starts.sort();ends.sort();return {cycleStart:starts[0]||"",cycleEnd:ends.length?ends[ends.length-1]:""}}
function tasksForDateInCycle(childId,ds){var b=cycleBounds(childId);if(!b.cycleStart||ds<b.cycleStart)return [];if(b.cycleEnd&&ds>b.cycleEnd)return [];var ids={};cache.templates.forEach(function(t){if(String(t.child_id)===String(childId)&&String(t.active)!=="0")ids[String(t.id)]=true});return tasksForDate(childId,ds).filter(function(t){return t.template_id&&ids[String(t.template_id)]})}
function pageId(){return global.__MINDSPACE_PAGE_DATA__&&global.__MINDSPACE_PAGE_DATA__.pageId||null}
function parseWeekdays(raw){var s=String(raw||"1,2,3,4,5").trim();if(!s)return ["1","2","3","4","5"];if(s.charAt(0)==="{")return s.replace(/[{}\"]/g,"").split(",").filter(Boolean);return s.split(",").filter(Boolean)}
async function rows(name,q,retry){try{var r=await client.listRows(name,q||{limit:500});return r.rows||[]}catch(e){if((retry||0)<1&&(e.status>=500||e.code==="page_data_failed"||!e.code))return rows(name,q,(retry||0)+1);throw e}}
function liveRows(list){return (list||[]).filter(function(r){return r&&r.deleted_at==null&&String(r.active)!=="0"})}
function todayStr(){return new Date(Date.now()+28800000).toISOString().slice(0,10)}
function pad(n){return n<10?"0"+n:""+n}
function dowUTC(ds){return new Date(Date.UTC(+ds.slice(0,4),+ds.slice(5,7)-1,+ds.slice(8,10))).getUTCDay()}
function wdCN(d){return ["日","一","二","三","四","五","六"][d]}
function addDays(ds,n){var d=new Date(ds+"T00:00:00Z");d.setUTCDate(d.getUTCDate()+n);return d.toISOString().slice(0,10)}
function pageTokenKey(){return TOKEN_KEY+":"+(global.__MINDSPACE_PAGE_DATA__&&global.__MINDSPACE_PAGE_DATA__.pageId||"default")}
function saveToken(t){try{if(t)sessionStorage.setItem(pageTokenKey(),t);else sessionStorage.removeItem(pageTokenKey())}catch(e){}}
function loadToken(){try{return sessionStorage.getItem(pageTokenKey())||""}catch(e){return ""}}
function rebuildTaskIndex(){taskIndex={};cache.tasks.forEach(function(t){var k=String(t.child_id)+"|"+String(t.task_date).slice(0,10);if(!taskIndex[k])taskIndex[k]=[];taskIndex[k].push(t)})}
function invalidateTaskIndex(){taskIndex=null}
async function refresh(force){if(!force&&client&&refreshAt&&Date.now()-refreshAt<15000)return;var packs=await Promise.all([rows("learn_children"),rows("learn_categories"),rows("learn_templates"),rows("learn_tasks"),rows("learn_completions")]);cache.children=liveRows(packs[0]);cache.categories=liveRows(packs[1]);cache.templates=liveRows(packs[2]);cache.tasks=liveRows(packs[3]);cache.completions=liveRows(packs[4]);cache.settings=[];invalidateTaskIndex();refreshAt=Date.now();rows("learn_settings").then(function(r){cache.settings=liveRows(r)}).catch(function(){})}
function catById(id){return cache.categories.find(function(c){return String(c.id)===String(id)})||null}
function childById(id){return cache.children.find(function(c){return String(c.id)===String(id)})||null}
function tplActive(t,ds){if(String(t.active)==="0")return false;var s=String(t.start_date||"").slice(0,10);if(s&&ds<s)return false;var e=t.end_date?String(t.end_date).slice(0,10):"";if(e&&ds>e)return false;var wd=dowUTC(ds);var wds=parseWeekdays(t.weekdays);return wds.indexOf(String(wd===0?7:wd))>=0||wds.indexOf(String(wd))>=0}
function compMap(childId){var m={};cache.completions.forEach(function(c){if(String(c.child_id)===String(childId))m[String(c.task_id)]=c});return m}
function mergeTask(t,c){var x=Object.assign({},t);if(c){x.status="done";x.value_submitted=c.value||"";x.remark=c.remark||"";x.completed_time=c.completed_time||""}return x}
function tasksForDate(childId,ds){if(!taskIndex)rebuildTaskIndex();var cm=compMap(childId);return (taskIndex[String(childId)+"|"+ds]||[]).map(function(t){return mergeTask(t,cm[String(t.id)])})}
async function ensureTasks(childId,ds){var existing=tasksForDate(childId,ds);var hasTpl={};existing.forEach(function(t){if(t.template_id)hasTpl[String(t.template_id)]=true});var pending=[];for(var i=0;i<cache.templates.length;i++){var t=cache.templates[i];if(String(t.child_id)!==String(childId)||!tplActive(t,ds)||hasTpl[String(t.id)])continue;var cat=catById(t.category_id);pending.push({child_id:Number(childId),template_id:Number(t.id),task_date:ds,title:t.title,description:t.description||"",task_type:t.task_type||"check",target_value:t.target_value||"",unit:t.unit||"",category_id:t.category_id||null,category_name:cat?cat.name:"",category_color:cat?cat.color:"#6b7280",points:Number(t.points||10),status:"pending",value_submitted:"",remark:"",completed_time:""})}if(!pending.length)return existing;var created=await Promise.all(pending.map(function(body){return client.insertRow("learn_tasks",body).then(function(r){return r&&r.row?r.row:null}).catch(function(){return null})}));created.forEach(function(row){if(row)cache.tasks.push(row)});invalidateTaskIndex();return tasksForDate(childId,ds)}
function dayStatus(tasks,ds){var today=todayStr();var done=tasks.filter(function(t){return t.status==="done"}).length;if(!tasks.length)return "none";if(done===tasks.length)return "green";if(done>0)return "yellow";return ds<today?"red":"yellow"}
function streakFor(childId){var today=todayStr();var s=0;for(var i=0;i<120;i++){var ds=addDays(today,-i);var ts=tasksForDate(childId,ds);if(!ts.length)break;if(dayStatus(ts,ds)!=="green"){if(i===0&&dayStatus(ts,ds)==="yellow")continue;break}s++}return s}
function energyFor(childId){var c=childById(childId);var base=c?Number(c.energy||0):0;var extra=cache.completions.filter(function(x){return String(x.child_id)===String(childId)}).reduce(function(a,x){return a+Number(x.points||0)},0);return base+extra}
async function init(opts){opts=opts||{};var pid=pageId();if(!pid)throw new Error("页面未配置 pageId");client=MindSpacePageData.createClient({apiBase:"/api",pageId:pid});var saved=loadToken();if(saved){client.setToken(saved);try{await refresh(true);return client}catch(e){saveToken("");client.setToken(null)}}if(opts.password){var auth=await client.authenticate(opts.password);if(auth&&auth.token)saveToken(auth.token);await refresh(true)}else{await refresh(true)}return client}
async function getChildren(){await refresh();return {data:cache.children.map(function(c){return {id:Number(c.id),name:c.name,emoji:c.emoji}})}}
async function getToday(childId){var ds=todayStr();await ensureTasks(childId,ds);var tasks=tasksForDate(childId,ds);var c=childById(childId)||{name:"",emoji:"🙂"};var done=tasks.filter(function(t){return t.status==="done"}).length;return {date:ds,child:{name:c.name,emoji:c.emoji},done:done,total:tasks.length,energy:energyFor(childId),tasks:tasks.map(function(t){return Object.assign({},t,{id:Number(t.id),points:Number(t.points||0)})})}}
async function getDay(childId,ds){await ensureTasks(childId,ds);return {tasks:tasksForDate(childId,ds).map(function(t){return Object.assign({},t,{id:Number(t.id)})})}}
async function getHistory(childId){var today=todayStr();var b=cycleBounds(childId);if(!b.cycleStart)return {cycleStart:"",cycleEnd:"",data:[]};var endBound=b.cycleEnd&&b.cycleEnd<today?b.cycleEnd:today;var out=[];for(var ds=endBound;ds>=b.cycleStart;ds=addDays(ds,-1)){var tasks=tasksForDateInCycle(childId,ds);if(!tasks.length)continue;var done=tasks.filter(function(t){return t.status==="done"}).length;out.push({date:ds,done:done,total:tasks.length,status:dayStatus(tasks,ds),tasks:tasks})}return {cycleStart:b.cycleStart,cycleEnd:b.cycleEnd,data:out}}
async function getCalendar(childId,month){var m=month||todayStr().slice(0,7);var y=+m.slice(0,4),mo=+m.slice(5,7);var daysIn=new Date(Date.UTC(y,mo,0)).getUTCDate();var days=[];for(var d=1;d<=daysIn;d++){var ds=y+"-"+pad(mo)+"-"+pad(d);var tasks=tasksForDate(childId,ds);var done=tasks.filter(function(t){return t.status==="done"}).length;days.push({date:ds,done:done,total:tasks.length,status:dayStatus(tasks,ds)})}return {month:m,days:days}}
async function getGrowth(childId){var today=todayStr();var byCat={};var totalDone=0,best=0,fullDays=0,run=0;for(var i=89;i>=0;i--){var ds=addDays(today,-i);var tasks=tasksForDate(childId,ds);if(!tasks.length){run=0;continue}var st=dayStatus(tasks,ds);if(st==="green"){run++;fullDays++;if(run>best)best=run}else run=0;tasks.forEach(function(t){var k=t.category_name||"其他";if(!byCat[k])byCat[k]={name:k,done:0,total:0};byCat[k].total++;if(t.status==="done"){byCat[k].done++;totalDone++}})}return {energy:energyFor(childId),streak:streakFor(childId),best:best,fullDays:fullDays,totalDone:totalDone,byCategory:Object.keys(byCat).map(function(k){return byCat[k]})}}
async function checkin(id,value,remark){var task=cache.tasks.find(function(t){return Number(t.id)===Number(id)});if(!task||task.status==="done")throw new Error("任务不存在或已完成");var ds=String(task.task_date).slice(0,10);var now=new Date(Date.now()+28800000);var ct=pad(now.getUTCHours())+":"+pad(now.getUTCMinutes());var pts=Number(task.points||10);await client.insertRow("learn_completions",{task_id:Number(id),child_id:Number(task.child_id),task_date:ds,value:value==null?"":String(value),remark:remark||"",completed_time:ct,points:pts});cache.completions.push({task_id:id,child_id:task.child_id,task_date:ds,value:value,remark:remark,completed_time:ct,points:pts});var todayTasks=tasksForDate(task.child_id,ds);var done=todayTasks.filter(function(t){return t.status==="done"}).length+1;var total=todayTasks.length;return {done:done,total:total,allDone:done>=total,points:pts}}
async function parentStats(){await refresh();var today=todayStr();var weekStart=addDays(today,-6);var children=cache.children.map(function(c){return {name:c.name,emoji:c.emoji,streak:streakFor(c.id)}});var allToday={done:0,total:0},allWeek={done:0,total:0};var trend=[];for(var i=6;i>=0;i--){var ds=addDays(today,-i);var dDone=0,dTotal=0;for(var j=0;j<cache.children.length;j++){if(ds===today)await ensureTasks(cache.children[j].id,ds);var ts=tasksForDate(cache.children[j].id,ds);dDone+=ts.filter(function(t){return t.status==="done"}).length;dTotal+=ts.length}if(ds>=weekStart){allWeek.done+=dDone;allWeek.total+=dTotal}if(ds===today){allToday.done=dDone;allToday.total=dTotal}trend.push({d:ds,done:dDone,total:dTotal})}var missedMap={};for(var k=0;k<30;k++){var ds2=addDays(today,-k);for(var ci=0;ci<cache.children.length;ci++){tasksForDate(cache.children[ci].id,ds2).forEach(function(t){if(t.status!=="done"&&ds2<today){missedMap[t.title]=(missedMap[t.title]||0)+1}})}}var missed=Object.keys(missedMap).map(function(t){return {title:t,count:missedMap[t]}}).sort(function(a,b){return b.count-a.count}).slice(0,5);var catRate=[];cache.categories.forEach(function(cat){var done=0,total=0;for(var ci2=0;ci2<cache.children.length;ci2++){for(var k2=0;k2<30;k2++){tasksForDate(cache.children[ci2].id,addDays(today,-k2)).forEach(function(t){if(String(t.category_id)===String(cat.id)){total++;if(t.status==="done")done++}})}}catRate.push({name:cat.name,done:done,total:total})});return {today:allToday,week:allWeek,children:children,trend:trend,missed:missed,categoryRate:catRate,avgTime:[]}}
async function parentList(kind){await refresh();if(kind==="templates"){return {data:cache.templates.map(function(t){var cat=catById(t.category_id);return Object.assign({},t,{id:Number(t.id),child_id:Number(t.child_id),category_id:t.category_id?Number(t.category_id):null,category_name:cat?cat.name:"",points:Number(t.points||0)})})}}if(kind==="categories")return {data:cache.categories.map(function(c){return {id:Number(c.id),name:c.name,color:c.color}})};if(kind==="children")return {data:cache.children.map(function(c){return {id:Number(c.id),name:c.name,emoji:c.emoji}})};return {data:[]}}
async function parentInsert(kind,body){var r;if(kind==="children")r=await client.insertRow("learn_children",{name:body.name,emoji:body.emoji||"🙂",energy:0});else if(kind==="categories")r=await client.insertRow("learn_categories",{name:body.name,color:body.color||"#3b5bfd"});else if(kind==="templates")r=await client.insertRow("learn_templates",{child_id:body.child_id,category_id:body.category_id||null,title:body.title,description:body.description||"",task_type:body.task_type||"check",target_value:body.target_value||"",unit:body.unit||"",weekdays:body.weekdays,start_date:body.start_date,end_date:body.end_date||null,points:body.points||10,active:1});await refresh(true);return r}
async function parentInsertMany(kind,bodies){var out=[];for(var i=0;i<bodies.length;i++){if(kind==="templates")out.push(await client.insertRow("learn_templates",{child_id:bodies[i].child_id,category_id:bodies[i].category_id||null,title:bodies[i].title,description:bodies[i].description||"",task_type:bodies[i].task_type||"check",target_value:bodies[i].target_value||"",unit:bodies[i].unit||"",weekdays:bodies[i].weekdays,start_date:bodies[i].start_date,end_date:bodies[i].end_date||null,points:bodies[i].points||10,active:1}))}await refresh(true);return out}
async function parentUpdate(kind,id,body){var ds=kind==="templates"?"learn_templates":kind==="categories"?"learn_categories":"learn_children";var r=await client.updateRow(ds,id,body);await refresh(true);return r}
async function parentDelete(kind,id){
if(kind==="templates"){await client.updateRow("learn_templates",id,{active:0});await refresh(true);return}
if(kind==="children"){
var cid=String(id);
var ops=[];
cache.completions.filter(function(x){return String(x.child_id)===cid}).forEach(function(x){ops.push(client.deleteRow("learn_completions",x.id).catch(function(){}))});
cache.tasks.filter(function(x){return String(x.child_id)===cid}).forEach(function(x){ops.push(client.deleteRow("learn_tasks",x.id).catch(function(){}))});
cache.templates.filter(function(x){return String(x.child_id)===cid}).forEach(function(x){ops.push(client.deleteRow("learn_templates",x.id).catch(function(){}))});
if(ops.length)await Promise.all(ops);
await client.deleteRow("learn_children",id);
await refresh(true);return
}
await client.deleteRow(kind==="categories"?"learn_categories":"learn_tasks",id);
await refresh(true)
}
async function parentPassword(oldPw,newPw){throw new Error("热修版暂不支持在线改密,默认口令 88888888")}
async function verifyPassword(pw){clearAuth();try{await init({password:pw})}catch(e){if(e&&e.code==="rate_limited")throw e;if(e&&e.code==="password_invalid")throw new Error("密码错误");throw e}}
function clearAuth(){saveToken("");if(client)client.setToken(null);refreshAt=0;invalidateTaskIndex()}
global.LearnStore={init:init,refresh:refresh,clearAuth:clearAuth,getChildren:getChildren,getToday:getToday,getDay:getDay,getHistory:getHistory,getCalendar:getCalendar,getGrowth:getGrowth,checkin:checkin,parentStats:parentStats,parentList:parentList,parentInsert:parentInsert,parentInsertMany:parentInsertMany,parentUpdate:parentUpdate,parentDelete:parentDelete,parentPassword:parentPassword,verifyPassword:verifyPassword,todayStr:todayStr,wdCN:wdCN,dowOf:dowUTC,addDays:addDays,parseWeekdays:parseWeekdays,DATA_VER:DATA_VER};
})(window);
+116
View File
@@ -0,0 +1,116 @@
<!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>
<meta name="mindspace-page-data-page-id" content="f9c80392-9417-46fd-af89-a91a239d3bfc">
<script>window.__MINDSPACE_PAGE_DATA__={"pageId":"f9c80392-9417-46fd-af89-a91a239d3bfc","accessMode":"password"};</script>
<style>*{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}body{padding-bottom:30px}.tabs{position:sticky;top:0;background:#f2f4fa;padding-top:2px;z-index:10}.pl{max-width:720px}</style>
<script src="/assets/page-data-client.js?v=20260915j"></script>
<script src="/learn/learning-assistant-data.js?v=20260915j"></script>
<link rel="icon" type="image/png" href="/brand/tkmind-icon.png">
</head>
<body>
<div class="wrap pl" id="app"><div class="card"><div class="empty">加载中…</div></div></div>
<script>
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;')}
function pad(n){return n<10?'0'+n:''+n}
var WD=['一','二','三','四','五','六','日'];
function weekdaysCN(w){return LearnStore.parseWeekdays(w).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="/learn/" style="color:#3b5bfd">← 返回小朋友页面</a></div></div>'}
async function login(){state.pw=document.getElementById('pw').value;try{await LearnStore.verifyPassword(state.pw);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;if(!s)return '<header class="h"><h1>数据看板 📊</h1></header><div class="card"><div class="empty">加载中…</div></div>';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 childLabel(id){var c=state.children.find(function(x){return x.id===id});return c?esc(c.emoji)+' '+esc(c.name):''}
function renderTemplates(){var html='<header class="h"><h1>任务模板 📋</h1><div class="sub">配置周期规则,系统每天自动生成当日任务</div></header>';if(!state.templates||!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">👦 '+childLabel(t.child_id)+' '+(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;if(t){var cur=state.children.find(function(c){return c.id===t.child_id});kids='<div style="margin-top:2px"><span class="chip on" style="cursor:default">'+esc(cur?cur.emoji+' '+cur.name:'')+'</span><input type="hidden" id="f_child" value="'+t.child_id+'"></div>'}else{kids='<div style="margin-top:2px">'+state.children.map(function(c){return '<span class="chip child on" data-id="'+c.id+'" onclick="this.classList.toggle(\'on\')">'+esc(c.emoji)+' '+esc(c.name)+'</span>'}).join('')+'</div>'}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=LearnStore.parseWeekdays(t?t.weekdays:'1,2,3,4,5');var wchk=WD.map(function(w,i){return '<span class="chip wd'+(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>'+(t?'给谁安排':'给谁安排(可多选)')+'</label>'+kids+'<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;try{await LearnStore.parentDelete('templates',id);await loadAll();render()}catch(e){alert(e.message||'停用失败')}}
async function saveTpl(id){var wds=[];document.querySelectorAll('.overlay .chip.wd.on').forEach(function(c){wds.push(String(WD.indexOf(c.textContent)+1))});var body={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.join(','),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){body.child_id=+document.getElementById('f_child').value;await LearnStore.parentUpdate('templates',id,body)}else{var childIds=[];document.querySelectorAll('.overlay .chip.child.on').forEach(function(c){childIds.push(+c.getAttribute('data-id'))});if(!childIds.length)throw new Error('请至少选择一个小朋友');await LearnStore.parentInsertMany('templates',childIds.map(function(cid){return Object.assign({},body,{child_id:cid})}))}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 LearnStore.parentInsert('categories',{name:n,color:document.getElementById('c_color').value});await loadAll();render()}
async function delCat(id,name){if(!confirm('删除分类「'+name+'」?使用它的模板将变为无分类。'))return;try{await LearnStore.parentDelete('categories',id);await loadAll();render()}catch(e){alert(e.message||'删除失败')}}
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 LearnStore.parentInsert('children',{name:n,emoji:document.getElementById('k_emoji').value||'🙂'});await loadAll();render()}
async function delKid(id,name){if(!confirm('删除「'+name+'」及其所有任务记录?此操作不可恢复!'))return;try{await LearnStore.parentDelete('children',id);await loadAll();render()}catch(e){alert(e.message||'删除失败')}}
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="/learn/" style="color:#3b5bfd">打开</a><br>· 任务每天首次访问时自动生成<br>· 历史任务保存快照,改模板不影响过去记录<br>· 数据存储:Page Data(口令默认 88888888</div></div><button class="danger" style="width:100%;padding:12px" onclick="logout()">退出登录</button>'}
async function chpw(){try{await LearnStore.parentPassword(document.getElementById('p_old').value,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');if(window.LearnStore&&LearnStore.clearAuth)LearnStore.clearAuth();state.pw='';render()}
async function loadAll(){if(state.tab==='dash')state.stats=await LearnStore.parentStats();if(state.tab==='tpl'){state.templates=(await LearnStore.parentList('templates')).data;state.categories=(await LearnStore.parentList('categories')).data;state.children=(await LearnStore.parentList('children')).data}if(state.tab==='cat'){state.categories=(await LearnStore.parentList('categories')).data}if(state.tab==='kid'){state.children=(await LearnStore.parentList('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){state.pw='';localStorage.removeItem('learn_pw');render()}}
(async function(){if(state.pw){try{await LearnStore.init({password:state.pw});await loadAll()}catch(e){state.pw='';localStorage.removeItem('learn_pw')}}try{render()}catch(e){document.getElementById('app').innerHTML='<div class="card"><div class="empty">'+esc(e.message)+'</div></div>'}})();
</script>
</body>
</html>
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""Build lightweight /learn/* pages without MindSpace injection."""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / 'MindSpace/a70ff537-8908-486e-9b6c-042e07cc25db/public'
OUT = ROOT / 'public/learn'
VER = '20260915j'
CHILD_PAGE = 'dcb1f3f0-db96-4dab-a009-7a9c393ebe77'
PARENT_PAGE = 'f9c80392-9417-46fd-af89-a91a239d3bfc'
def extract_style(html: str) -> str:
m = re.search(r'<style>([\s\S]*?)</style>', html)
return m.group(1) if m else ''
def extract_app_script(html: str) -> str:
m = re.search(r'<script>\s*(var state=[\s\S]*?\)\(\);)\s*</script>', html)
if not m:
raise ValueError('app script not found')
return m.group(1)
def shell(title: str, style: str, page_id: str, access: str, body: str, script: str) -> str:
access_json = '{"pageId":"%s","accessMode":"%s"}' % (page_id, access)
return f'''<!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>
<meta name="mindspace-page-data-page-id" content="{page_id}">
<script>window.__MINDSPACE_PAGE_DATA__={access_json};</script>
<style>{style}</style>
<script src="/assets/page-data-client.js?v={VER}"></script>
<script src="/learn/learning-assistant-data.js?v={VER}"></script>
<link rel="icon" type="image/png" href="/brand/tkmind-icon.png">
</head>
{body}
<script>
{script}
</script>
</body>
</html>
'''
def main():
OUT.mkdir(parents=True, exist_ok=True)
child_src = (SRC / 'learning-assistant.html').read_text(encoding='utf-8')
parent_src = (SRC / 'learning-assistant-parent.html').read_text(encoding='utf-8')
child_style = extract_style(child_src)
parent_style = extract_style(parent_src)
child_script = extract_app_script(child_src)
parent_script = extract_app_script(parent_src)
child_script = child_script.replace('learning-assistant-parent.html', '/learn/parent.html')
parent_script = parent_script.replace('learning-assistant.html', '/learn/')
child_body = '''<body>
<div class="wrap" id="app"><div class="card"><div class="empty">加载中…</div></div></div>
<div class="tabbar" id="tabbar"></div>
'''
parent_body = '''<body>
<div class="wrap pl" id="app"><div class="card"><div class="empty">加载中…</div></div></div>
'''
(OUT / 'index.html').write_text(
shell('我的学习小助手', child_style, CHILD_PAGE, 'public', child_body, child_script),
encoding='utf-8',
)
(OUT / 'parent.html').write_text(
shell('家长后台', parent_style, PARENT_PAGE, 'password', parent_body, parent_script),
encoding='utf-8',
)
data_js = (SRC / 'learning-assistant-data.js').read_text(encoding='utf-8')
data_js = re.sub(r'DATA_VER="[^"]+"', f'DATA_VER="{VER}"', data_js)
(OUT / 'learning-assistant-data.js').write_text(data_js, encoding='utf-8')
print('built', OUT)
print(' index.html', (OUT / 'index.html').stat().st_size, 'bytes')
print(' parent.html', (OUT / 'parent.html').stat().st_size, 'bytes')
if __name__ == '__main__':
main()
@@ -0,0 +1,62 @@
#!/usr/bin/env node
/**
* Set news_morning_draft.forceGenerateOnce so the 103 Portal worker
* regenerates today's daily-news page on the next scan (~30s).
* Dry-run by default; pass --apply to persist.
*/
import process from 'node:process';
import mysql from 'mysql2/promise';
import { loadH5Environment } from './load-env.mjs';
loadH5Environment(import.meta.dirname);
const CONFIG_KEY = 'news_morning_draft';
const CONFIG_TABLE = 'h5_wechat_admin_config';
async function main() {
const apply = process.argv.includes('--apply');
if (!process.env.DATABASE_URL) {
console.error('DATABASE_URL missing');
process.exit(1);
}
const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 });
const [rows] = await pool.query(
`SELECT config_json FROM ${CONFIG_TABLE} WHERE config_key = ? LIMIT 1`,
[CONFIG_KEY],
);
const current = rows[0]?.config_json
? (typeof rows[0].config_json === 'string'
? JSON.parse(rows[0].config_json)
: rows[0].config_json)
: {};
const next = { ...current, forceGenerateOnce: true, enabled: current.enabled !== false };
console.log(JSON.stringify({
apply,
currentForceGenerateOnce: Boolean(current.forceGenerateOnce),
sourceUserId: current.sourceUserId ?? null,
enabled: next.enabled,
}, null, 2));
if (apply) {
const now = Date.now();
await pool.query(
`INSERT INTO ${CONFIG_TABLE} (config_key, config_json, updated_by, updated_at)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
config_json = VALUES(config_json),
updated_by = VALUES(updated_by),
updated_at = VALUES(updated_at)`,
[CONFIG_KEY, JSON.stringify(next), 'manual-force-generate', now],
);
console.log('forceGenerateOnce=true persisted');
}
await pool.end();
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
from pathlib import Path
p = Path("/Users/john/Project/Memind/server.mjs")
text = p.read_text()
if '"/learn"' in text and "public/learn" in text:
print("ALREADY_PATCHED")
raise SystemExit(0)
needle = """ app2.use(
"/assets",
staticMiddleware(
pathApi.join(h5Root, "public/assets"),
{ maxAge: "1d" }
)
);
app2.get(
"/dev/wechat-share-preview","""
insert = """ app2.use(
"/assets",
staticMiddleware(
pathApi.join(h5Root, "public/assets"),
{ maxAge: "1d" }
)
);
app2.use(
"/learn",
staticMiddleware(
pathApi.join(h5Root, "public/learn"),
{ maxAge: "5m", index: "index.html" }
)
);
app2.get(
"/dev/wechat-share-preview","""
if needle not in text:
print("NEEDLE_NOT_FOUND")
raise SystemExit(1)
p.write_text(text.replace(needle, insert, 1))
print("PATCHED_OK")
+7
View File
@@ -161,6 +161,13 @@ export function attachPortalStaticDeliveryRoutes({
{ maxAge: '1d' },
),
);
app.use(
'/learn',
staticMiddleware(
pathApi.join(h5Root, 'public/learn'),
{ maxAge: '5m', index: 'index.html' },
),
);
app.get(
'/dev/wechat-share-preview',
+92 -5
View File
@@ -1,3 +1,5 @@
import fs from 'node:fs';
import path from 'node:path';
import { executeScheduledTask } from './scheduled-task-executor.mjs';
import { localDateKey } from './schedule-time.mjs';
import {
@@ -7,6 +9,7 @@ import {
isNewsMorningPageForToday,
isWithinNewsMorningGenerateLeadWindow,
} from './wechat-news-morning-draft.mjs';
import { prefetchNewsMorningSearxngBrief } from './wechat-news-morning-search.mjs';
import {
isWechatNewsMorningDraftWorkerEnabled,
wechatNewsMorningDraftExecutionTimeoutMs,
@@ -28,6 +31,23 @@ function resolveTodayPage(config, h5Root, now = Date.now()) {
return page;
}
function archiveTodayPageForForce(config, h5Root, now, logger) {
const page = resolveTodayPage(config, h5Root, now);
if (!page?.localPath || !fs.existsSync(page.localPath)) return null;
const stamp = new Date(now).toISOString().replace(/[:.]/g, '-');
const archived = path.join(path.dirname(page.localPath), `_archived-${page.slug}-${stamp}.html`);
fs.renameSync(page.localPath, archived);
if (page.thumbPath && fs.existsSync(page.thumbPath)) {
const archivedThumb = archived.replace(/\.html$/i, '.thumbnail.png');
fs.renameSync(page.thumbPath, archivedThumb);
}
logger.log?.('[NewsMorningDraft] archived today page before force generate', {
from: page.relativePath,
to: path.basename(archived),
});
return archived;
}
export function startWechatNewsMorningDraftWorker({
wechatNewsMorningDraftService = null,
mpConfig = null,
@@ -40,6 +60,7 @@ export function startWechatNewsMorningDraftWorker({
h5Root = null,
env = process.env,
executeTask = executeScheduledTask,
prefetchNewsSearch = prefetchNewsMorningSearxngBrief,
logger = console,
intervalMs = wechatNewsMorningDraftWorkerIntervalMs(env),
executionTimeoutMs = wechatNewsMorningDraftExecutionTimeoutMs(env),
@@ -62,11 +83,27 @@ export function startWechatNewsMorningDraftWorker({
let running = false;
let generationInFlightDateKey = null;
const ensureTodayPage = async (config, dateKey) => {
const ensureTodayPage = async (config, dateKey, { now = Date.now(), force = false } = {}) => {
if (generationInFlightDateKey === dateKey) return null;
generationInFlightDateKey = dateKey;
try {
const task = buildNewsMorningAutoGenerationTask(config);
if (force) {
archiveTodayPageForForce(config, h5Root, now, logger);
}
let searchBrief = '';
try {
searchBrief = await prefetchNewsSearch({
now,
timezone: config.timezone,
env,
});
} catch (prefetchError) {
logger.warn?.(
'[NewsMorningDraft] searxng prefetch failed:',
prefetchError instanceof Error ? prefetchError.message : prefetchError,
);
}
const task = buildNewsMorningAutoGenerationTask(config, { now, searchBrief });
await executeTask(task, {
userAuth,
tkmindProxy,
@@ -78,7 +115,7 @@ export function startWechatNewsMorningDraftWorker({
timeoutMs: executionTimeoutMs,
logger,
});
const page = resolveTodayPage(config, h5Root);
const page = resolveTodayPage(config, h5Root, now);
if (!page) {
throw new Error('新闻早报页面生成未完成');
}
@@ -89,6 +126,7 @@ export function startWechatNewsMorningDraftWorker({
logger.log?.('[NewsMorningDraft] auto-generated today page', {
slug: page.slug,
userId: config.sourceUserId,
force,
});
return page;
} catch (error) {
@@ -111,7 +149,7 @@ export function startWechatNewsMorningDraftWorker({
running = true;
try {
const config = await wechatNewsMorningDraftService.getConfig();
if (!config.enabled || !config.autoPushEnabled || !config.sourceUserId) return;
if (!config.enabled || !config.sourceUserId) return;
const now = Date.now();
const timezone = config.timezone;
@@ -119,6 +157,31 @@ export function startWechatNewsMorningDraftWorker({
const toleranceMinutes = Math.max(1, Math.ceil(Number(intervalMs) / 60_000));
let todayPage = resolveTodayPage(config, h5Root, now);
if (config.forceGenerateOnce && generationInFlightDateKey !== dateKey) {
try {
todayPage = await ensureTodayPage(config, dateKey, { now, force: true });
await wechatNewsMorningDraftService.updateConfig(
{ forceGenerateOnce: false },
{ updatedBy: 'auto-generate' },
);
if (config.autoPushEnabled && todayPage) {
const result = await wechatNewsMorningDraftService.pushDraft({
triggeredBy: 'manual-generate',
now,
});
logger.log?.('[NewsMorningDraft] force generate push succeeded', {
draftMediaId: result.draftMediaId,
pageSlug: result.preview?.page?.slug ?? todayPage.slug,
});
}
} catch {
// keep the flag so the next scan retries
}
return;
}
if (!config.autoPushEnabled) return;
const shouldGenerate =
config.autoGenerateEnabled !== false
&& !todayPage
@@ -133,7 +196,7 @@ export function startWechatNewsMorningDraftWorker({
if (shouldGenerate && generationInFlightDateKey !== dateKey) {
try {
todayPage = await ensureTodayPage(config, dateKey);
todayPage = await ensureTodayPage(config, dateKey, { now });
} catch {
// keep trying during generate window on later scans
}
@@ -164,6 +227,7 @@ export function startWechatNewsMorningDraftWorker({
const result = await wechatNewsMorningDraftService.pushDraft({
triggeredBy: 'auto-push',
now,
});
logger.log?.('[NewsMorningDraft] auto push succeeded', {
draftMediaId: result.draftMediaId,
@@ -182,6 +246,29 @@ export function startWechatNewsMorningDraftWorker({
return {
runOnce,
async generateToday({ force = false, now = Date.now(), pushDraft = false } = {}) {
const config = await wechatNewsMorningDraftService.getConfig();
if (!config.enabled || !config.sourceUserId) {
throw new Error('新闻早报未配置来源用户或未启用');
}
const dateKey = localDateKey(now, config.timezone);
const existing = resolveTodayPage(config, h5Root, now);
if (existing && !force) {
return { page: existing, skipped: true, reason: 'today-page-already-exists' };
}
const page = await ensureTodayPage(config, dateKey, { now, force: true });
if (!page) {
throw new Error('新闻早报页面正在生成中');
}
let draft = null;
if (pushDraft) {
draft = await wechatNewsMorningDraftService.pushDraft({
triggeredBy: 'manual-generate',
now,
});
}
return { page, skipped: false, draft };
},
stop() {
stopped = true;
clearInterval(timer);
+140 -1
View File
@@ -92,13 +92,152 @@ test('news morning draft worker auto-pushes at configured time', async () => {
await worker.runOnce();
assert.equal(pushCalls.length, 1);
assert.deepEqual(pushCalls[0], { triggeredBy: 'auto-push' });
assert.equal(pushCalls[0].triggeredBy, 'auto-push');
} finally {
Date.now = originalNow;
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
test('runOnce honors forceGenerateOnce even when today page exists', async () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'news-draft-flag-'));
const userId = 'user-news';
const publicDir = path.join(tmpRoot, PUBLISH_ROOT_DIR, userId, PUBLIC_ZONE_DIR);
fs.mkdirSync(publicDir, { recursive: true });
fs.writeFileSync(path.join(publicDir, 'daily-news-0911.html'), SAMPLE_HTML);
const pool = createPool();
const service = createWechatNewsMorningDraftService(pool, {
mpConfig: { enabled: true, appId: 'app', appSecret: 'secret' },
h5Root: tmpRoot,
env: {
H5_WECHAT_NEWS_MORNING_DRAFT_ENABLED: '1',
H5_WECHAT_NEWS_MORNING_DRAFT_USER_ID: userId,
},
});
let config = {
enabled: true,
sourceUserId: userId,
autoPushEnabled: true,
autoGenerateEnabled: true,
forceGenerateOnce: true,
timezone: 'Asia/Shanghai',
pushHour: 6,
pushMinute: 0,
pageSlugPattern: 'daily-news-*',
};
service.getConfig = async () => config;
service.updateConfig = async (payload) => {
config = { ...config, ...payload };
return config;
};
const pushCalls = [];
service.pushDraft = async (options = {}) => {
pushCalls.push(options);
return {
ok: true,
draftMediaId: 'draft-force',
preview: { page: { slug: 'daily-news-0911' } },
};
};
const executeCalls = [];
const fixedNow = Date.UTC(2026, 8, 10, 22, 30, 0);
const originalNow = Date.now;
Date.now = () => fixedNow;
try {
const worker = startWechatNewsMorningDraftWorker({
wechatNewsMorningDraftService: service,
mpConfig: { enabled: true },
userAuth: { canUseChat: async () => ({ ok: true }) },
tkmindProxy: { id: 'proxy' },
h5Root: tmpRoot,
env: { H5_WECHAT_NEWS_MORNING_DRAFT_WORKER_ENABLED: '1' },
executeTask: async (task) => {
executeCalls.push(task.taskSpec);
fs.writeFileSync(path.join(publicDir, 'daily-news-0911.html'), `${SAMPLE_HTML}<!--forced-->`);
},
intervalMs: 30_000,
runOnStart: false,
setIntervalFn: () => ({ unref() {} }),
});
await worker.runOnce();
assert.equal(executeCalls.length, 1);
assert.match(executeCalls[0], /禁止复制/u);
assert.equal(config.forceGenerateOnce, false);
assert.equal(pushCalls.length, 1);
assert.equal(pushCalls[0].triggeredBy, 'manual-generate');
assert.equal(fs.existsSync(path.join(publicDir, 'daily-news-0911.html')), true);
assert.ok(
fs.readdirSync(publicDir).some((name) => name.startsWith('_archived-daily-news-0911-')),
);
} finally {
Date.now = originalNow;
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
test('generateToday skips existing page unless force is set', async () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'news-draft-force-'));
const userId = 'user-news';
const publicDir = path.join(tmpRoot, PUBLISH_ROOT_DIR, userId, PUBLIC_ZONE_DIR);
fs.mkdirSync(publicDir, { recursive: true });
fs.writeFileSync(path.join(publicDir, 'daily-news-0911.html'), SAMPLE_HTML);
const pool = createPool();
const service = createWechatNewsMorningDraftService(pool, {
mpConfig: { enabled: true, appId: 'app', appSecret: 'secret' },
h5Root: tmpRoot,
env: {
H5_WECHAT_NEWS_MORNING_DRAFT_ENABLED: '1',
H5_WECHAT_NEWS_MORNING_DRAFT_AUTO: '1',
H5_WECHAT_NEWS_MORNING_DRAFT_USER_ID: userId,
},
});
const executeCalls = [];
const fixedNow = Date.UTC(2026, 8, 10, 22, 0, 0);
const worker = startWechatNewsMorningDraftWorker({
wechatNewsMorningDraftService: service,
mpConfig: { enabled: true },
userAuth: { canUseChat: async () => ({ ok: true }) },
tkmindProxy: { id: 'proxy' },
h5Root: tmpRoot,
env: { H5_WECHAT_NEWS_MORNING_DRAFT_WORKER_ENABLED: '1' },
executeTask: async (task) => {
executeCalls.push(task.taskSpec);
fs.writeFileSync(path.join(publicDir, 'daily-news-0911.html'), `${SAMPLE_HTML}<!--forced-->`);
},
prefetchNewsSearch: async () => '【专用联网搜索预取】国内国际热门事件',
intervalMs: 30_000,
runOnStart: false,
setIntervalFn: () => ({ unref() {} }),
});
try {
const skipped = await worker.generateToday({ now: fixedNow, force: false });
assert.equal(skipped.skipped, true);
assert.equal(executeCalls.length, 0);
const forced = await worker.generateToday({ now: fixedNow, force: true });
assert.equal(forced.skipped, false);
assert.equal(forced.page.slug, 'daily-news-0911');
assert.equal(executeCalls.length, 1);
assert.match(executeCalls[0], /禁止复制/u);
assert.match(executeCalls[0], /tkmind_search/u);
assert.match(executeCalls[0], /专用联网搜索预取/u);
assert.match(executeCalls[0], /国内国际热门事件/u);
assert.match(fs.readFileSync(path.join(publicDir, 'daily-news-0911.html'), 'utf8'), /forced/);
assert.ok(
fs.readdirSync(publicDir).some((name) => name.startsWith('_archived-daily-news-0911-')),
);
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
});
test('schedule helpers detect today page and due windows', () => {
const {
isNewsMorningPageForToday,
+41 -9
View File
@@ -22,7 +22,7 @@ const DEFAULT_DRAFT_ADD_URL = 'https://api.weixin.qq.com/cgi-bin/draft/add';
const DEFAULT_MATERIAL_ADD_URL = 'https://api.weixin.qq.com/cgi-bin/material/add_material';
const DEFAULT_UPLOAD_IMG_URL = 'https://api.weixin.qq.com/cgi-bin/media/uploadimg';
const MAX_THUMB_BYTES = 64 * 1024;
export const NEWS_MORNING_TEMPLATE_VERSION = '2026-09-10';
export const NEWS_MORNING_TEMPLATE_VERSION = '2026-09-15';
/** 0910 新闻早报页面结构说明,供定时任务 taskSpec 与草稿转换共用。 */
export const NEWS_MORNING_TEMPLATE_0910_SPEC = [
@@ -36,6 +36,18 @@ export const NEWS_MORNING_TEMPLATE_0910_SPEC = [
'7. 推送微信草稿时转为内联 style HTML(参考公众号排版),禁止整页长图;正文需控制在 2 万字符内。',
].join('\n');
/** 每日正文必须新搜,禁止把历史早报当新闻来源。 */
export const NEWS_MORNING_FRESH_CONTENT_SPEC = [
'内容硬约束(必须遵守,优先于版式参考):',
'1. 新闻广度必须同时走两条检索,禁止只靠 web_search:先 load_skill → web,再同一轮调用 tkmind_searchtype=news,专用联网搜索)和 web_search,合并去重后写页面。',
'2. 覆盖面至少包括:国内热点、国际热点、热门事件/热搜;并补财经、科技、体育与当日天气。不得凭记忆编造。',
'3. 若任务中附有「专用联网搜索预取」结果,必须优先从中选题,再用 tkmind_search / web_search 补漏;禁止只转述 web_search。',
'4. 禁止复制、改写、微调昨日或任何历史 daily-news-*.html 的新闻标题与正文。',
'5. 最近一版 daily-news 只允许当作版式参考(hero / section / card 结构);读完后必须丢弃其中的条目内容。',
'6. 每条卡片必须是当日可核验事件,并写明来源;title、date-badge、导语必须写明当日日期。要闻栏至少 6 条,其中国内、国际、热门事件都要有代表条目,并用 highlight-box 突出头条。',
'7. 必须包含天气区块(section id=weatherweather-today + weather-card)。若搜索失败,明确写出未能获取当日新闻,禁止用旧稿充数。',
].join('\n');
function normalizeBoolean(value, fallback = false) {
if (value == null || value === '') return fallback;
if (typeof value === 'boolean') return value;
@@ -130,18 +142,21 @@ export function isWithinNewsMorningGenerateLeadWindow(
return current >= start && current < push;
}
export function buildNewsMorningAutoGenerationTask(config, { now = Date.now() } = {}) {
export function buildNewsMorningAutoGenerationTask(config, { now = Date.now(), searchBrief = '' } = {}) {
const timezone = String(config?.timezone ?? 'Asia/Shanghai').trim() || 'Asia/Shanghai';
const dateLabel = formatLocalDateParts(now, timezone);
const brief = String(searchBrief ?? '').trim();
return {
id: 'news-morning-auto-generate',
userId: config.sourceUserId,
title: '每日新闻早报页面(自动)',
taskSpec: [
NEWS_MORNING_FRESH_CONTENT_SPEC,
NEWS_MORNING_TEMPLATE_0910_SPEC,
`今日日期:${dateLabel.iso}`,
`输出文件名必须包含今日 MMDDdaily-news-${dateLabel.mmdd}.html。`,
].join('\n'),
`今日日期:${dateLabel.iso}${dateLabel.year}${dateLabel.month}${dateLabel.day}日)。正文必须是这一天的新闻,不能是其它日期的旧稿`,
`输出文件名必须daily-news-${dateLabel.mmdd}.html(可覆盖同名旧文件)`,
brief,
].filter(Boolean).join('\n'),
recurrence: 'once',
timezone,
notifyChannel: 'web',
@@ -687,6 +702,7 @@ function defaultsFromEnv(env = process.env) {
sourceUserId: String(env.H5_WECHAT_NEWS_MORNING_DRAFT_USER_ID ?? '').trim() || null,
pageSlugPattern: String(env.H5_WECHAT_NEWS_MORNING_DRAFT_SLUG ?? 'daily-news-*').trim() || 'daily-news-*',
author: String(env.H5_WECHAT_NEWS_MORNING_DRAFT_AUTHOR ?? 'TKMind').trim() || 'TKMind',
forceGenerateOnce: false,
templateVersion: NEWS_MORNING_TEMPLATE_VERSION,
};
}
@@ -743,6 +759,7 @@ export function createWechatNewsMorningDraftService(
sourceUserId: String(stored.sourceUserId ?? defaults.sourceUserId ?? '').trim() || null,
pageSlugPattern: String(stored.pageSlugPattern ?? defaults.pageSlugPattern).trim() || defaults.pageSlugPattern,
author: String(stored.author ?? defaults.author).trim().slice(0, 8) || defaults.author,
forceGenerateOnce: normalizeBoolean(stored.forceGenerateOnce, false),
templateVersion: NEWS_MORNING_TEMPLATE_VERSION,
};
}
@@ -822,7 +839,7 @@ export function createWechatNewsMorningDraftService(
article,
template: {
version: NEWS_MORNING_TEMPLATE_VERSION,
spec: NEWS_MORNING_TEMPLATE_0910_SPEC,
spec: [NEWS_MORNING_FRESH_CONTENT_SPEC, NEWS_MORNING_TEMPLATE_0910_SPEC].join('\n'),
},
};
}
@@ -879,7 +896,7 @@ export function createWechatNewsMorningDraftService(
getTemplate() {
return {
version: NEWS_MORNING_TEMPLATE_VERSION,
spec: NEWS_MORNING_TEMPLATE_0910_SPEC,
spec: [NEWS_MORNING_FRESH_CONTENT_SPEC, NEWS_MORNING_TEMPLATE_0910_SPEC].join('\n'),
};
},
@@ -924,6 +941,10 @@ export function createWechatNewsMorningDraftService(
payload.author === undefined
? current.author
: String(payload.author ?? current.author).trim().slice(0, 8) || current.author,
forceGenerateOnce:
payload.forceGenerateOnce === undefined
? current.forceGenerateOnce
: normalizeBoolean(payload.forceGenerateOnce, current.forceGenerateOnce),
templateVersion: NEWS_MORNING_TEMPLATE_VERSION,
};
const now = Date.now();
@@ -1007,13 +1028,18 @@ export function createWechatNewsMorningDraftService(
};
},
async pushDraft({ triggeredBy = null, dryRun = false } = {}) {
async pushDraft({
triggeredBy = null,
dryRun = false,
requireToday = true,
now = Date.now(),
} = {}) {
const config = await this.getConfig();
if (!config.enabled) {
throw new Error('新闻早报草稿推送未启用');
}
let preview = null;
preview = await resolvePreview(config);
preview = await resolvePreview(config, { requireToday, now });
if (dryRun) {
return {
dryRun: true,
@@ -1027,6 +1053,7 @@ export function createWechatNewsMorningDraftService(
h5Root,
userId: config.sourceUserId,
slugPattern: config.pageSlugPattern,
date: new Date(now),
timezone: config.timezone,
});
if (page?.thumbPath) {
@@ -1138,6 +1165,10 @@ export function createWechatNewsMorningDraftService(
return rows.length > 0;
},
async requestForceGenerate({ updatedBy = null } = {}) {
return this.updateConfig({ forceGenerateOnce: true }, { updatedBy });
},
async recordAutoGenerationRun({
status,
pageSlug = null,
@@ -1169,6 +1200,7 @@ export const wechatNewsMorningDraftInternals = {
isLocalScheduleDue,
isWithinNewsMorningGenerateLeadWindow,
buildNewsMorningAutoGenerationTask,
NEWS_MORNING_FRESH_CONTENT_SPEC,
formatLocalDateParts,
localDateKey,
};
+55 -2
View File
@@ -161,12 +161,16 @@ test('news morning draft service persists config and records failed push', async
assert.equal(updated.sourceUserId, userId);
assert.equal(updated.templateVersion, NEWS_MORNING_TEMPLATE_VERSION);
const preview = await service.preview();
const preview = await service.preview({ now: Date.parse('2026-09-10T08:00:00+08:00') });
assert.equal(preview.page.slug, 'daily-news-0910');
assert.match(preview.template.spec, /0910|2026-09-10/u);
assert.match(preview.template.spec, /禁止复制/u);
await assert.rejects(
() => service.pushDraft({ triggeredBy: 'admin-1' }),
() => service.pushDraft({
triggeredBy: 'admin-1',
now: Date.parse('2026-09-10T08:00:00+08:00'),
}),
/微信服务号未启用/u,
);
const runs = await service.listRuns();
@@ -199,6 +203,55 @@ test('getTodayStatus and previewToday require today dated page', async () => {
assert.equal(preview.page.slug, 'daily-news-0911');
});
test('buildNewsMorningAutoGenerationTask requires fresh daily search', () => {
const task = wechatNewsMorningDraftInternals.buildNewsMorningAutoGenerationTask(
{ sourceUserId: 'user-1', timezone: 'Asia/Shanghai' },
{ now: Date.parse('2026-09-15T05:00:00+08:00') },
);
assert.match(task.taskSpec, /禁止复制/u);
assert.match(task.taskSpec, /tkmind_search/u);
assert.match(task.taskSpec, /web_search/u);
assert.match(task.taskSpec, /国内热点/u);
assert.match(task.taskSpec, /国际热点/u);
assert.match(task.taskSpec, /热门事件/u);
assert.match(task.taskSpec, /2026-09-15/u);
assert.match(task.taskSpec, /daily-news-0915\.html/u);
assert.doesNotMatch(task.taskSpec, /可以用昨日正文/u);
const withBrief = wechatNewsMorningDraftInternals.buildNewsMorningAutoGenerationTask(
{ sourceUserId: 'user-1', timezone: 'Asia/Shanghai' },
{
now: Date.parse('2026-09-15T05:00:00+08:00'),
searchBrief: '【专用联网搜索预取】国内热点示例',
},
);
assert.match(withBrief.taskSpec, /专用联网搜索预取/u);
assert.match(withBrief.taskSpec, /国内热点示例/u);
});
test('pushDraft refuses yesterday page when requireToday is on', async () => {
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'news-draft-stale-'));
const userId = 'user-stale';
const publicDir = path.join(root, PUBLISH_ROOT_DIR, userId, PUBLIC_ZONE_DIR);
await fs.promises.mkdir(publicDir, { recursive: true });
await fs.promises.writeFile(path.join(publicDir, 'daily-news-0914.html'), SAMPLE_HTML);
const service = createWechatNewsMorningDraftService(createPool(), {
mpConfig: { enabled: true, appId: 'app', appSecret: 'secret' },
h5Root: root,
env: {},
});
await service.updateConfig({ enabled: true, sourceUserId: userId });
await assert.rejects(
() => service.pushDraft({
triggeredBy: 'admin-1',
now: Date.parse('2026-09-15T08:00:00+08:00'),
}),
/今日(2026-09-15)新闻早报尚未生成/u,
);
});
test('internals normalize booleans consistently', () => {
assert.equal(wechatNewsMorningDraftInternals.normalizeBoolean('1', false), true);
assert.equal(wechatNewsMorningDraftInternals.normalizeBoolean('off', true), false);
+131
View File
@@ -0,0 +1,131 @@
import { searchSearxng } from './mindsearch-providers.mjs';
import { resolvePortalSearxngEndpoint } from './mindsearch-prefetch.mjs';
import { getLocalParts } from './schedule-time.mjs';
function envFlag(value, fallback = false) {
const raw = String(value ?? '').trim().toLowerCase();
if (!raw) return fallback;
return ['1', 'true', 'yes', 'on'].includes(raw);
}
function formatLocalDateParts(date = new Date(), timezone = 'Asia/Shanghai') {
const parts = getLocalParts(date, timezone);
const year = String(parts.year);
const month = String(parts.month).padStart(2, '0');
const day = String(parts.day).padStart(2, '0');
return {
iso: `${year}-${month}-${day}`,
label: `${year}${month}${day}`,
};
}
export function shouldPrefetchNewsMorningSearxng(env = process.env) {
return envFlag(env.MEMIND_NEWS_MORNING_SEARXNG_PREFETCH, true);
}
export function buildNewsMorningSearchQueries(dateLabel) {
const day = String(dateLabel?.iso ?? '').trim();
const label = String(dateLabel?.label ?? day).trim() || day;
return [
{ id: 'domestic', title: '国内热点', query: `${label} 国内热点新闻` },
{ id: 'world', title: '国际热点', query: `${label} 国际热点新闻` },
{ id: 'hot', title: '热门事件', query: `${label} 热门事件 热点` },
{ id: 'finance', title: '财经科技', query: `${label} 财经 科技 新闻` },
{ id: 'weather', title: '天气', query: `${label} 全国天气预报` },
].filter((item) => item.query.trim());
}
function truncateSnippet(value, max = 90) {
const text = String(value ?? '').replace(/\s+/g, ' ').trim();
if (!text) return '';
return text.length > max ? `${text.slice(0, max - 1)}` : text;
}
export function formatNewsMorningSearchBrief(groups = [], { dateLabel = null } = {}) {
const lines = [
'【专用联网搜索预取】以下结果来自专用新闻检索(与 web_search 并列,不是替代)。',
'写页面时必须:1)优先从下列结果覆盖国内、国际、热门事件;2)同一轮再调用 tkmind_search(type=news) 与 web_search 补漏并核对来源;3)禁止只使用 web_search。',
];
if (dateLabel?.iso) lines.push(`预取日期:${dateLabel.iso}`);
lines.push('');
let hitCount = 0;
for (const group of Array.isArray(groups) ? groups : []) {
const rows = (Array.isArray(group?.results) ? group.results : [])
.filter((item) => item?.title || item?.url)
.slice(0, 6);
lines.push(`## ${group.title || group.id || '检索'}`);
if (!rows.length) {
lines.push('- (本路预取为空,必须用 tkmind_search / web_search 补上)');
continue;
}
hitCount += rows.length;
for (const item of rows) {
const title = String(item.title || item.url).trim();
const snippet = truncateSnippet(item.snippet);
lines.push(`- ${title}`);
if (item.url) lines.push(` ${item.url}`);
if (snippet) lines.push(` ${snippet}`);
}
}
if (!hitCount) {
lines.push('预取没有返回条目。仍必须调用 tkmind_search(type=news) 与 web_search,覆盖国内、国际、热门事件后再写页面。');
}
return lines.join('\n').trim();
}
async function searchNewsThenGeneral(query, {
endpoint,
searchImpl,
limit,
timeoutMs,
}) {
const newsHits = await searchImpl(query, {
endpoint,
limit,
timeoutMs,
categories: 'news',
language: 'zh-CN',
}).catch(() => []);
if (Array.isArray(newsHits) && newsHits.length > 0) return newsHits;
return searchImpl(query, {
endpoint,
limit,
timeoutMs,
language: 'zh-CN',
}).catch(() => []);
}
export async function prefetchNewsMorningSearxngBrief({
now = Date.now(),
timezone = 'Asia/Shanghai',
env = process.env,
searchImpl = searchSearxng,
limit = 6,
} = {}) {
const dateLabel = formatLocalDateParts(now, timezone);
const queries = buildNewsMorningSearchQueries(dateLabel);
const emptyBrief = formatNewsMorningSearchBrief(
queries.map((item) => ({ ...item, results: [] })),
{ dateLabel },
);
if (!shouldPrefetchNewsMorningSearxng(env)) {
return emptyBrief;
}
const endpoint = resolvePortalSearxngEndpoint(env);
if (!endpoint) {
return emptyBrief;
}
const timeoutMs = Number(env.TKMIND_SEARCH_TIMEOUT_MS ?? 8000) || 8000;
const groups = await Promise.all(queries.map(async (item) => {
const results = await searchNewsThenGeneral(item.query, {
endpoint,
searchImpl,
limit,
timeoutMs,
});
return { ...item, results: Array.isArray(results) ? results : [] };
}));
return formatNewsMorningSearchBrief(groups, { dateLabel });
}
+64
View File
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildNewsMorningSearchQueries,
formatNewsMorningSearchBrief,
prefetchNewsMorningSearxngBrief,
} from './wechat-news-morning-search.mjs';
test('buildNewsMorningSearchQueries covers domestic, world and hot events', () => {
const queries = buildNewsMorningSearchQueries({
iso: '2026-09-15',
label: '2026年09月15日',
});
const ids = queries.map((item) => item.id);
assert.deepEqual(ids, ['domestic', 'world', 'hot', 'finance', 'weather']);
assert.ok(queries.every((item) => item.query.includes('2026年09月15日')));
});
test('formatNewsMorningSearchBrief requires dual search even when empty', () => {
const text = formatNewsMorningSearchBrief([
{ id: 'domestic', title: '国内热点', results: [] },
], { dateLabel: { iso: '2026-09-15' } });
assert.match(text, /专用联网搜索预取/);
assert.match(text, /tkmind_search/);
assert.match(text, /web_search/);
assert.match(text, /禁止只使用 web_search/);
assert.match(text, /国内、国际、热门事件/);
});
test('prefetchNewsMorningSearxngBrief queries news category then falls back', async () => {
const calls = [];
const brief = await prefetchNewsMorningSearxngBrief({
now: Date.parse('2026-09-15T05:00:00+08:00'),
timezone: 'Asia/Shanghai',
env: { TKMIND_SEARCH_SEARXNG_URL: 'http://127.0.0.1:20080/search' },
searchImpl: async (query, options) => {
calls.push({ query, categories: options.categories || '' });
if (options.categories === 'news' && /国内热点/.test(query)) {
return [];
}
if (options.categories === 'news') {
return [{ title: `${query} 新闻源`, url: 'https://news.example/a', snippet: '摘要' }];
}
return [{ title: `${query} 通用源`, url: 'https://web.example/b', snippet: '补搜' }];
},
});
assert.ok(calls.some((item) => item.categories === 'news'));
assert.ok(calls.some((item) => item.categories === '' && /国内热点/.test(item.query)));
assert.match(brief, /国际热点新闻 新闻源/);
assert.match(brief, /国内热点新闻 通用源/);
assert.match(brief, /tkmind_search/);
});
test('prefetchNewsMorningSearxngBrief skips live calls without endpoint', async () => {
const brief = await prefetchNewsMorningSearxngBrief({
now: Date.parse('2026-09-15T05:00:00+08:00'),
env: {},
searchImpl: async () => {
throw new Error('should not run');
},
});
assert.match(brief, /预取没有返回条目|本路预取为空/);
assert.match(brief, /tkmind_search/);
});