348e192800
Wire /learn static delivery, parent/child portal routes, and Tang page build scripts for 103 deploy. Co-authored-by: Cursor <cursoragent@cursor.com>
726 lines
24 KiB
JavaScript
726 lines
24 KiB
JavaScript
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
|
|
const TANG_OWNER_ID = 'a70ff537-8908-486e-9b6c-042e07cc25db';
|
|
|
|
function todayCst() {
|
|
return new Date(Date.now() + 8 * 3600e3).toISOString().slice(0, 10);
|
|
}
|
|
|
|
function normalizeChildId(childId) {
|
|
const id = Number.parseInt(String(childId ?? ''), 10);
|
|
if (!Number.isFinite(id) || id <= 0) {
|
|
throw Object.assign(new Error('孩子不存在'), { statusCode: 404 });
|
|
}
|
|
return id;
|
|
}
|
|
|
|
function nowTimeCst() {
|
|
const d = new Date(Date.now() + 8 * 3600e3);
|
|
return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`;
|
|
}
|
|
|
|
function dowOf(ds) {
|
|
return new Date(Date.UTC(+ds.slice(0, 4), +ds.slice(5, 7) - 1, +ds.slice(8, 10))).getUTCDay();
|
|
}
|
|
|
|
function theirWeekdayFromJs(jsDow) {
|
|
return jsDow === 0 ? 7 : jsDow;
|
|
}
|
|
|
|
function addDays(ds, n) {
|
|
const d = new Date(`${ds}T00:00:00Z`);
|
|
d.setUTCDate(d.getUTCDate() + n);
|
|
return d.toISOString().slice(0, 10);
|
|
}
|
|
|
|
function monthStart(month) {
|
|
return `${month}-01`;
|
|
}
|
|
|
|
function daysInMonth(month) {
|
|
const [y, m] = month.split('-').map(Number);
|
|
return new Date(Date.UTC(y, m, 0)).getUTCDate();
|
|
}
|
|
|
|
async function readJson(filePath, fallback) {
|
|
try {
|
|
const text = await fs.readFile(filePath, 'utf8');
|
|
return JSON.parse(text);
|
|
} catch (error) {
|
|
if (error?.code === 'ENOENT') return fallback;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function writeJson(filePath, value) {
|
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
}
|
|
|
|
function categoryMap(categories) {
|
|
const map = new Map();
|
|
for (const c of categories) map.set(c.id, c);
|
|
return map;
|
|
}
|
|
|
|
function enrichTask(task, categories) {
|
|
const cat = task.category_id ? categories.find((c) => c.id === task.category_id) : null;
|
|
return {
|
|
...task,
|
|
category_name: cat?.name ?? task.category_name ?? null,
|
|
category_color: cat?.color ?? task.category_color ?? '#6b7280',
|
|
};
|
|
}
|
|
|
|
function dayStatus(tasks, date) {
|
|
const today = todayCst();
|
|
if (!tasks.length) return 'n';
|
|
const done = tasks.filter((t) => t.status === 'done').length;
|
|
if (done === tasks.length) return 'green';
|
|
if (date < today) return done > 0 ? 'yellow' : 'red';
|
|
return done > 0 ? 'yellow' : 'n';
|
|
}
|
|
|
|
function computeStreak(taskDatesByDay, childId, endDate) {
|
|
let streak = 0;
|
|
let d = endDate;
|
|
for (let i = 0; i < 400; i += 1) {
|
|
const tasks = (taskDatesByDay.get(`${childId}:${d}`) ?? []).filter((t) => t.status !== 'cancelled');
|
|
if (!tasks.length) break;
|
|
const done = tasks.filter((t) => t.status === 'done').length;
|
|
if (done === tasks.length) streak += 1;
|
|
else break;
|
|
d = addDays(d, -1);
|
|
}
|
|
return streak;
|
|
}
|
|
|
|
function computeBestStreak(allTasks, childId) {
|
|
const byDate = new Map();
|
|
for (const t of allTasks) {
|
|
if (t.child_id !== childId) continue;
|
|
const key = t.task_date;
|
|
if (!byDate.has(key)) byDate.set(key, []);
|
|
byDate.get(key).push(t);
|
|
}
|
|
const dates = [...byDate.keys()].sort();
|
|
let best = 0;
|
|
let cur = 0;
|
|
let prev = null;
|
|
for (const d of dates) {
|
|
const tasks = byDate.get(d);
|
|
const full = tasks.length && tasks.every((t) => t.status === 'done');
|
|
if (full) {
|
|
if (prev && addDays(prev, 1) === d) cur += 1;
|
|
else cur = 1;
|
|
best = Math.max(best, cur);
|
|
} else {
|
|
cur = 0;
|
|
}
|
|
prev = d;
|
|
}
|
|
return best;
|
|
}
|
|
|
|
export function createLearnAssistantService({ dataRoot }) {
|
|
if (!dataRoot) throw new Error('dataRoot is required');
|
|
|
|
function storeDir(familyId) {
|
|
return path.join(dataRoot, 'families', familyId);
|
|
}
|
|
|
|
function legacyStoreDir(familyId) {
|
|
return path.join(dataRoot, familyId);
|
|
}
|
|
|
|
function paths(familyId) {
|
|
const dir = storeDir(familyId);
|
|
return {
|
|
dir,
|
|
settings: path.join(dir, 'settings.json'),
|
|
children: path.join(dir, 'children.json'),
|
|
categories: path.join(dir, 'categories.json'),
|
|
templates: path.join(dir, 'templates.json'),
|
|
tasks: path.join(dir, 'tasks.json'),
|
|
meta: path.join(dir, 'meta.json'),
|
|
};
|
|
}
|
|
|
|
async function loadStore(familyId) {
|
|
const p = paths(familyId);
|
|
const [settings, children, categories, templates, tasks, meta] = await Promise.all([
|
|
readJson(p.settings, { password: '123456' }),
|
|
readJson(p.children, []),
|
|
readJson(p.categories, []),
|
|
readJson(p.templates, []),
|
|
readJson(p.tasks, []),
|
|
readJson(p.meta, { nextChildId: 1, nextCategoryId: 1, nextTemplateId: 1, nextTaskId: 1 }),
|
|
]);
|
|
return { settings, children, categories, templates, tasks, meta, paths: p };
|
|
}
|
|
|
|
async function saveStore(store) {
|
|
const { paths: p, settings, children, categories, templates, tasks, meta } = store;
|
|
await Promise.all([
|
|
writeJson(p.settings, settings),
|
|
writeJson(p.children, children),
|
|
writeJson(p.categories, categories),
|
|
writeJson(p.templates, templates),
|
|
writeJson(p.tasks, tasks),
|
|
writeJson(p.meta, meta),
|
|
]);
|
|
}
|
|
|
|
function childEnergy(tasks, childId) {
|
|
return tasks
|
|
.filter((t) => t.child_id === childId && t.status === 'done')
|
|
.reduce((sum, t) => sum + Number(t.points ?? 0), 0);
|
|
}
|
|
|
|
async function ensureDailyTasks(store, childId, date) {
|
|
const activeTemplates = store.templates.filter((t) => t.active !== false && t.child_id === childId);
|
|
const jsDow = dowOf(date);
|
|
const theirDow = theirWeekdayFromJs(jsDow);
|
|
let changed = false;
|
|
for (const tpl of activeTemplates) {
|
|
const weekdays = String(tpl.weekdays ?? '').split(',').map((x) => +x).filter(Boolean);
|
|
if (!weekdays.includes(theirDow)) continue;
|
|
const start = String(tpl.start_date ?? '').slice(0, 10);
|
|
const end = tpl.end_date ? String(tpl.end_date).slice(0, 10) : null;
|
|
if (start && date < start) continue;
|
|
if (end && date > end) continue;
|
|
const exists = store.tasks.some(
|
|
(t) => t.child_id === childId && t.task_date === date && t.template_id === tpl.id,
|
|
);
|
|
if (exists) continue;
|
|
const cat = store.categories.find((c) => c.id === tpl.category_id);
|
|
store.tasks.push({
|
|
id: store.meta.nextTaskId++,
|
|
child_id: childId,
|
|
template_id: tpl.id,
|
|
category_id: tpl.category_id ?? null,
|
|
category_name: cat?.name ?? null,
|
|
category_color: cat?.color ?? '#6b7280',
|
|
title: tpl.title,
|
|
description: tpl.description ?? '',
|
|
task_type: tpl.task_type ?? 'check',
|
|
target_value: tpl.target_value ?? '',
|
|
unit: tpl.unit ?? '',
|
|
points: tpl.points ?? 10,
|
|
task_date: date,
|
|
start_date: start,
|
|
status: 'pending',
|
|
value_submitted: null,
|
|
remark: null,
|
|
completed_time: null,
|
|
});
|
|
changed = true;
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
async function getToday(familyId, childId, date = todayCst()) {
|
|
childId = normalizeChildId(childId);
|
|
const store = await loadStore(familyId);
|
|
const child = store.children.find((c) => c.id === childId);
|
|
if (!child) throw Object.assign(new Error('孩子不存在'), { statusCode: 404 });
|
|
if (await ensureDailyTasks(store, childId, date)) await saveStore(store);
|
|
const tasks = store.tasks
|
|
.filter((t) => t.child_id === childId && t.task_date === date)
|
|
.map((t) => enrichTask(t, store.categories));
|
|
const today = todayCst();
|
|
const pastPending = store.tasks
|
|
.filter((t) => t.child_id === childId && t.status === 'pending' && t.task_date < today)
|
|
.sort((a, b) => b.task_date.localeCompare(a.task_date))
|
|
.map((t) => enrichTask({ ...t, task_date: t.task_date }, store.categories));
|
|
const done = tasks.filter((t) => t.status === 'done').length;
|
|
const byDay = new Map();
|
|
for (const t of store.tasks) {
|
|
const key = `${t.child_id}:${t.task_date}`;
|
|
if (!byDay.has(key)) byDay.set(key, []);
|
|
byDay.get(key).push(t);
|
|
}
|
|
return {
|
|
child: { id: child.id, name: child.name, emoji: child.emoji },
|
|
date,
|
|
tasks,
|
|
pastPending,
|
|
total: tasks.length,
|
|
done,
|
|
streak: computeStreak(byDay, childId, date),
|
|
best: computeBestStreak(store.tasks, childId),
|
|
energy: childEnergy(store.tasks, childId),
|
|
};
|
|
}
|
|
|
|
async function getDay(familyId, childId, date) {
|
|
childId = normalizeChildId(childId);
|
|
const store = await loadStore(familyId);
|
|
if (await ensureDailyTasks(store, childId, date)) await saveStore(store);
|
|
const tasks = store.tasks
|
|
.filter((t) => t.child_id === childId && t.task_date === date)
|
|
.map((t) => enrichTask(t, store.categories));
|
|
return { tasks };
|
|
}
|
|
|
|
async function checkin(familyId, taskId, { value = null, remark = null } = {}) {
|
|
const store = await loadStore(familyId);
|
|
const task = store.tasks.find((t) => t.id === taskId);
|
|
if (!task) throw Object.assign(new Error('任务不存在'), { statusCode: 404 });
|
|
if (task.status === 'done') throw Object.assign(new Error('任务已完成'), { statusCode: 400 });
|
|
task.status = 'done';
|
|
task.value_submitted = value == null || value === '' ? null : String(value);
|
|
task.remark = remark ? String(remark) : null;
|
|
task.completed_time = nowTimeCst();
|
|
await saveStore(store);
|
|
const todayTasks = store.tasks.filter(
|
|
(t) => t.child_id === task.child_id && t.task_date === task.task_date,
|
|
);
|
|
const done = todayTasks.filter((t) => t.status === 'done').length;
|
|
return {
|
|
done,
|
|
total: todayTasks.length,
|
|
allDone: done === todayTasks.length,
|
|
points: task.points ?? 0,
|
|
};
|
|
}
|
|
|
|
async function getCalendar(familyId, childId, month) {
|
|
childId = normalizeChildId(childId);
|
|
const store = await loadStore(familyId);
|
|
const m = month || todayCst().slice(0, 7);
|
|
const dim = daysInMonth(m);
|
|
for (let d = 1; d <= dim; d += 1) {
|
|
const ds = `${m}-${String(d).padStart(2, '0')}`;
|
|
if (await ensureDailyTasks(store, childId, ds)) await saveStore(store);
|
|
}
|
|
const days = [];
|
|
for (let d = 1; d <= dim; d += 1) {
|
|
const ds = `${m}-${String(d).padStart(2, '0')}`;
|
|
const tasks = store.tasks.filter((t) => t.child_id === childId && t.task_date === ds);
|
|
if (!tasks.length) continue;
|
|
const done = tasks.filter((t) => t.status === 'done').length;
|
|
days.push({
|
|
date: ds,
|
|
total: tasks.length,
|
|
done,
|
|
status: dayStatus(tasks, ds),
|
|
});
|
|
}
|
|
return { month: m, days };
|
|
}
|
|
|
|
async function getGrowth(familyId, childId) {
|
|
childId = normalizeChildId(childId);
|
|
const store = await loadStore(familyId);
|
|
const childTasks = store.tasks.filter((t) => t.child_id === childId);
|
|
const totalDone = childTasks.filter((t) => t.status === 'done').length;
|
|
const byDay = new Map();
|
|
for (const t of childTasks) {
|
|
if (!byDay.has(t.task_date)) byDay.set(t.task_date, []);
|
|
byDay.get(t.task_date).push(t);
|
|
}
|
|
let fullDays = 0;
|
|
for (const tasks of byDay.values()) {
|
|
if (tasks.length && tasks.every((t) => t.status === 'done')) fullDays += 1;
|
|
}
|
|
const since = addDays(todayCst(), -30);
|
|
const recent = childTasks.filter((t) => t.task_date >= since);
|
|
const byCategory = new Map();
|
|
for (const t of recent) {
|
|
const name = t.category_name || store.categories.find((c) => c.id === t.category_id)?.name || '其他';
|
|
if (!byCategory.has(name)) byCategory.set(name, { name, total: 0, done: 0 });
|
|
const row = byCategory.get(name);
|
|
row.total += 1;
|
|
if (t.status === 'done') row.done += 1;
|
|
}
|
|
const byDayStreak = new Map();
|
|
for (const t of store.tasks) {
|
|
const key = `${t.child_id}:${t.task_date}`;
|
|
if (!byDayStreak.has(key)) byDayStreak.set(key, []);
|
|
byDayStreak.get(key).push(t);
|
|
}
|
|
return {
|
|
taskPoints: childTasks.filter((t) => t.status === 'done').reduce((s, t) => s + Number(t.points ?? 0), 0),
|
|
fullDays,
|
|
energy: childEnergy(store.tasks, childId),
|
|
streak: computeStreak(byDayStreak, childId, todayCst()),
|
|
best: computeBestStreak(store.tasks, childId),
|
|
totalDone,
|
|
byCategory: [...byCategory.values()],
|
|
};
|
|
}
|
|
|
|
async function getHistory(familyId, childId) {
|
|
childId = normalizeChildId(childId);
|
|
const store = await loadStore(familyId);
|
|
const childTasks = store.tasks.filter((t) => t.child_id === childId);
|
|
const byDate = new Map();
|
|
for (const t of childTasks) {
|
|
if (!byDate.has(t.task_date)) byDate.set(t.task_date, []);
|
|
byDate.get(t.task_date).push(enrichTask(t, store.categories));
|
|
}
|
|
const data = [...byDate.entries()]
|
|
.sort((a, b) => b[0].localeCompare(a[0]))
|
|
.map(([date, tasks]) => {
|
|
const done = tasks.filter((t) => t.status === 'done').length;
|
|
return {
|
|
date,
|
|
total: tasks.length,
|
|
done,
|
|
status: dayStatus(tasks, date),
|
|
tasks: tasks.map((t) => ({
|
|
id: t.id,
|
|
d: date,
|
|
title: t.title,
|
|
description: t.description,
|
|
category_name: t.category_name,
|
|
category_color: t.category_color,
|
|
task_type: t.task_type,
|
|
target_value: t.target_value,
|
|
unit: t.unit,
|
|
status: t.status,
|
|
value_submitted: t.value_submitted,
|
|
completed_time: t.completed_time,
|
|
remark: t.remark,
|
|
points: t.points,
|
|
})),
|
|
};
|
|
});
|
|
const tplStarts = store.templates
|
|
.filter((t) => t.child_id === childId && t.active !== false)
|
|
.map((t) => String(t.start_date ?? '').slice(0, 10))
|
|
.filter(Boolean)
|
|
.sort();
|
|
return { data, cycleStart: tplStarts[0] ?? null };
|
|
}
|
|
|
|
async function listChildren(familyId) {
|
|
const store = await loadStore(familyId);
|
|
return { data: store.children.map(({ id, name, emoji }) => ({ id, name, emoji })) };
|
|
}
|
|
|
|
async function parentStats(familyId) {
|
|
const store = await loadStore(familyId);
|
|
const today = todayCst();
|
|
const weekStart = addDays(today, -6);
|
|
const monthStartDate = addDays(today, -29);
|
|
const children = store.children.map((child) => {
|
|
const byDay = new Map();
|
|
for (const t of store.tasks.filter((x) => x.child_id === child.id)) {
|
|
if (!byDay.has(t.task_date)) byDay.set(t.task_date, []);
|
|
byDay.get(t.task_date).push(t);
|
|
}
|
|
return {
|
|
...child,
|
|
streak: computeStreak(
|
|
new Map([...byDay.entries()].map(([d, tasks]) => [`${child.id}:${d}`, tasks])),
|
|
child.id,
|
|
today,
|
|
),
|
|
};
|
|
});
|
|
const allToday = store.tasks.filter((t) => t.task_date === today);
|
|
const allWeek = store.tasks.filter((t) => t.task_date >= weekStart && t.task_date <= today);
|
|
const trend = [];
|
|
for (let i = 6; i >= 0; i -= 1) {
|
|
const d = addDays(today, -i);
|
|
const tasks = store.tasks.filter((t) => t.task_date === d);
|
|
trend.push({
|
|
d,
|
|
total: tasks.length,
|
|
done: tasks.filter((t) => t.status === 'done').length,
|
|
});
|
|
}
|
|
const missedMap = new Map();
|
|
for (const t of store.tasks.filter((t) => t.task_date >= monthStartDate && t.task_date < today)) {
|
|
if (t.status !== 'pending') continue;
|
|
missedMap.set(t.title, (missedMap.get(t.title) ?? 0) + 1);
|
|
}
|
|
const missed = [...missedMap.entries()]
|
|
.map(([title, count]) => ({ title, count }))
|
|
.sort((a, b) => b.count - a.count)
|
|
.slice(0, 8);
|
|
const catStats = new Map();
|
|
for (const t of store.tasks.filter((t) => t.task_date >= monthStartDate)) {
|
|
const name = t.category_name || store.categories.find((c) => c.id === t.category_id)?.name || '其他';
|
|
if (!catStats.has(name)) catStats.set(name, { name, total: 0, done: 0 });
|
|
const row = catStats.get(name);
|
|
row.total += 1;
|
|
if (t.status === 'done') row.done += 1;
|
|
}
|
|
const avgBuckets = new Map();
|
|
for (const t of store.tasks.filter((t) => t.status === 'done' && t.completed_time && t.task_date >= monthStartDate)) {
|
|
const name = t.category_name || store.categories.find((c) => c.id === t.category_id)?.name || '其他';
|
|
const [hh, mm] = String(t.completed_time).split(':').map(Number);
|
|
const mins = hh * 60 + mm;
|
|
if (!avgBuckets.has(name)) avgBuckets.set(name, []);
|
|
avgBuckets.get(name).push(mins);
|
|
}
|
|
const avgTime = [...avgBuckets.entries()].map(([name, mins]) => {
|
|
const avg = Math.round(mins.reduce((a, b) => a + b, 0) / mins.length);
|
|
return { name, time: `${String(Math.floor(avg / 60)).padStart(2, '0')}:${String(avg % 60).padStart(2, '0')}` };
|
|
});
|
|
return {
|
|
children,
|
|
today: {
|
|
total: allToday.length,
|
|
done: allToday.filter((t) => t.status === 'done').length,
|
|
},
|
|
week: {
|
|
total: allWeek.length,
|
|
done: allWeek.filter((t) => t.status === 'done').length,
|
|
},
|
|
trend,
|
|
missed,
|
|
categoryRate: [...catStats.values()],
|
|
avgTime,
|
|
};
|
|
}
|
|
|
|
async function parentListTemplates(familyId) {
|
|
const store = await loadStore(familyId);
|
|
const cats = categoryMap(store.categories);
|
|
return {
|
|
data: store.templates
|
|
.filter((t) => t.active !== false)
|
|
.map((t) => ({
|
|
...t,
|
|
category_name: t.category_id ? cats.get(t.category_id)?.name ?? null : null,
|
|
})),
|
|
};
|
|
}
|
|
|
|
async function parentSaveTemplate(familyId, body, id = null) {
|
|
const store = await loadStore(familyId);
|
|
const payload = {
|
|
child_id: +body.child_id,
|
|
category_id: body.category_id ? +body.category_id : null,
|
|
title: String(body.title ?? '').trim(),
|
|
description: String(body.description ?? ''),
|
|
task_type: body.task_type ?? 'check',
|
|
target_value: String(body.target_value ?? ''),
|
|
unit: String(body.unit ?? ''),
|
|
weekdays: Array.isArray(body.weekdays) ? body.weekdays.join(',') : String(body.weekdays ?? ''),
|
|
start_date: String(body.start_date ?? '').slice(0, 10),
|
|
end_date: body.end_date ? String(body.end_date).slice(0, 10) : null,
|
|
points: +body.points || 10,
|
|
active: true,
|
|
};
|
|
if (!payload.title) throw Object.assign(new Error('任务名称必填'), { statusCode: 400 });
|
|
if (id) {
|
|
const idx = store.templates.findIndex((t) => t.id === id);
|
|
if (idx < 0) throw Object.assign(new Error('模板不存在'), { statusCode: 404 });
|
|
store.templates[idx] = { ...store.templates[idx], ...payload };
|
|
} else {
|
|
store.templates.push({ id: store.meta.nextTemplateId++, ...payload });
|
|
}
|
|
await saveStore(store);
|
|
return { ok: true };
|
|
}
|
|
|
|
async function parentDeleteTemplate(familyId, id) {
|
|
const store = await loadStore(familyId);
|
|
const tpl = store.templates.find((t) => t.id === id);
|
|
if (!tpl) throw Object.assign(new Error('模板不存在'), { statusCode: 404 });
|
|
tpl.active = false;
|
|
await saveStore(store);
|
|
return { ok: true };
|
|
}
|
|
|
|
async function parentListCategories(familyId) {
|
|
const store = await loadStore(familyId);
|
|
return { data: store.categories };
|
|
}
|
|
|
|
async function parentAddCategory(familyId, body) {
|
|
const store = await loadStore(familyId);
|
|
const name = String(body.name ?? '').trim();
|
|
if (!name) throw Object.assign(new Error('分类名称必填'), { statusCode: 400 });
|
|
store.categories.push({
|
|
id: store.meta.nextCategoryId++,
|
|
name,
|
|
color: body.color ?? '#3b5bfd',
|
|
});
|
|
await saveStore(store);
|
|
return { ok: true };
|
|
}
|
|
|
|
async function parentDeleteCategory(familyId, id) {
|
|
const store = await loadStore(familyId);
|
|
store.categories = store.categories.filter((c) => c.id !== id);
|
|
for (const t of store.templates) {
|
|
if (t.category_id === id) t.category_id = null;
|
|
}
|
|
await saveStore(store);
|
|
return { ok: true };
|
|
}
|
|
|
|
async function parentListChildren(familyId) {
|
|
const store = await loadStore(familyId);
|
|
return { data: store.children };
|
|
}
|
|
|
|
async function parentAddChild(familyId, body) {
|
|
const store = await loadStore(familyId);
|
|
const name = String(body.name ?? '').trim();
|
|
if (!name) throw Object.assign(new Error('昵称必填'), { statusCode: 400 });
|
|
store.children.push({
|
|
id: store.meta.nextChildId++,
|
|
name,
|
|
emoji: body.emoji || '🙂',
|
|
});
|
|
await saveStore(store);
|
|
return { ok: true };
|
|
}
|
|
|
|
async function parentDeleteChild(familyId, id) {
|
|
const store = await loadStore(familyId);
|
|
store.children = store.children.filter((c) => c.id !== id);
|
|
store.tasks = store.tasks.filter((t) => t.child_id !== id);
|
|
store.templates = store.templates.filter((t) => t.child_id !== id);
|
|
await saveStore(store);
|
|
return { ok: true };
|
|
}
|
|
|
|
async function parentChangePassword(familyId, body) {
|
|
const store = await loadStore(familyId);
|
|
if (String(store.settings.password) !== String(body.old ?? '')) {
|
|
throw Object.assign(new Error('当前密码不正确'), { statusCode: 401 });
|
|
}
|
|
const next = String(body.new ?? '');
|
|
if (next.length < 3) throw Object.assign(new Error('新密码至少 3 位'), { statusCode: 400 });
|
|
store.settings.password = next;
|
|
await saveStore(store);
|
|
return { ok: true };
|
|
}
|
|
|
|
async function seedFamilyDefaults(familyId) {
|
|
const p = paths(familyId);
|
|
try {
|
|
await fs.access(p.children);
|
|
return { seeded: false, reason: 'already_exists' };
|
|
} catch {
|
|
// continue
|
|
}
|
|
const store = {
|
|
settings: { password: '123456' },
|
|
children: [
|
|
{ id: 2, name: '一心', emoji: '🙂' },
|
|
{ id: 3, name: '豆豆', emoji: '🙂' },
|
|
],
|
|
categories: [
|
|
{ id: 1, name: '数学', color: '#3b5bfd' },
|
|
{ id: 2, name: '英语', color: '#16a34a' },
|
|
{ id: 3, name: '语文', color: '#f59e0b' },
|
|
],
|
|
templates: [
|
|
{
|
|
id: 1,
|
|
child_id: 2,
|
|
category_id: 1,
|
|
title: '数学练习册',
|
|
description: '要求媒每天做两面练习',
|
|
task_type: 'count',
|
|
target_value: '2',
|
|
unit: '面',
|
|
weekdays: '1,2,3,4,5,6,7',
|
|
start_date: '2026-09-03',
|
|
end_date: null,
|
|
points: 15,
|
|
active: true,
|
|
},
|
|
{
|
|
id: 2,
|
|
child_id: 2,
|
|
category_id: 2,
|
|
title: '阅读30分钟',
|
|
description: '阅读30分钟,深度阅读',
|
|
task_type: 'check',
|
|
target_value: '30',
|
|
unit: '分钟',
|
|
weekdays: '1,2,3,4,5,6,7',
|
|
start_date: '2026-09-03',
|
|
end_date: null,
|
|
points: 10,
|
|
active: true,
|
|
},
|
|
{
|
|
id: 3,
|
|
child_id: 3,
|
|
category_id: 1,
|
|
title: '数学练习册',
|
|
description: '要求媒每天做两面练习',
|
|
task_type: 'count',
|
|
target_value: '2',
|
|
unit: '面',
|
|
weekdays: '1,2,3,4,5,6,7',
|
|
start_date: '2026-09-03',
|
|
end_date: null,
|
|
points: 15,
|
|
active: true,
|
|
},
|
|
{
|
|
id: 4,
|
|
child_id: 3,
|
|
category_id: 2,
|
|
title: '阅读30分钟',
|
|
description: '阅读30分钟,深度阅读',
|
|
task_type: 'check',
|
|
target_value: '30',
|
|
unit: '分钟',
|
|
weekdays: '1,2,3,4,5,6,7',
|
|
start_date: '2026-09-03',
|
|
end_date: null,
|
|
points: 10,
|
|
active: true,
|
|
},
|
|
],
|
|
tasks: [],
|
|
meta: { nextChildId: 4, nextCategoryId: 4, nextTemplateId: 5, nextTaskId: 1 },
|
|
paths: p,
|
|
};
|
|
await saveStore(store);
|
|
return { seeded: true, familyId };
|
|
}
|
|
|
|
async function assertLegacyPassword(familyId, password) {
|
|
const store = await loadStore(familyId);
|
|
if (String(store.settings.password ?? '') !== String(password ?? '')) {
|
|
const err = new Error('密码错误');
|
|
err.statusCode = 401;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
return {
|
|
loadStore,
|
|
seedFamilyDefaults,
|
|
listChildren,
|
|
getToday,
|
|
getDay,
|
|
checkin,
|
|
getCalendar,
|
|
getGrowth,
|
|
getHistory,
|
|
parentStats,
|
|
parentListTemplates,
|
|
parentSaveTemplate,
|
|
parentDeleteTemplate,
|
|
parentListCategories,
|
|
parentAddCategory,
|
|
parentDeleteCategory,
|
|
parentListChildren,
|
|
parentAddChild,
|
|
parentDeleteChild,
|
|
parentChangePassword,
|
|
assertLegacyPassword,
|
|
};
|
|
}
|
|
|
|
export const learnAssistantInternals = {
|
|
TANG_OWNER_ID,
|
|
todayCst,
|
|
};
|