From 348e19280095d3b9a837464939ea7630ae75d591 Mon Sep 17 00:00:00 2001 From: john Date: Fri, 11 Sep 2026 17:09:52 +0800 Subject: [PATCH] feat(learn): add family learning assistant API routes and public pages Wire /learn static delivery, parent/child portal routes, and Tang page build scripts for 103 deploy. Co-authored-by: Cursor --- learn-assistant-family.mjs | 271 +++++++ learn-assistant-family.test.mjs | 55 ++ learn-assistant-routes.mjs | 294 +++++++ learn-assistant-service.mjs | 725 ++++++++++++++++++ public/learn/child.html | 103 +++ public/learn/index.html | 5 + public/learn/join.html | 18 + public/learn/parent.html | 126 +++ .../build-learning-assistant-public-pages.mjs | 135 ++++ .../build-tang-learning-assistant-pages.mjs | 223 ++++++ .../deploy-tang-learning-assistant-103.mjs | 184 +++++ scripts/patch-portal-learn-assistant-103.mjs | 39 + server.mjs | 7 + server/portal-static-delivery-routes.mjs | 6 + vite.config.ts | 1 + 15 files changed, 2192 insertions(+) create mode 100644 learn-assistant-family.mjs create mode 100644 learn-assistant-family.test.mjs create mode 100644 learn-assistant-routes.mjs create mode 100644 learn-assistant-service.mjs create mode 100644 public/learn/child.html create mode 100644 public/learn/index.html create mode 100644 public/learn/join.html create mode 100644 public/learn/parent.html create mode 100644 scripts/build-learning-assistant-public-pages.mjs create mode 100644 scripts/build-tang-learning-assistant-pages.mjs create mode 100644 scripts/deploy-tang-learning-assistant-103.mjs create mode 100644 scripts/patch-portal-learn-assistant-103.mjs diff --git a/learn-assistant-family.mjs b/learn-assistant-family.mjs new file mode 100644 index 0000000..f454463 --- /dev/null +++ b/learn-assistant-family.mjs @@ -0,0 +1,271 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const TANG_OWNER_ID = 'a70ff537-8908-486e-9b6c-042e07cc25db'; +const LEGACY_TANG_SLUG = 'tang'; +const INVITE_TTL_MS = 7 * 24 * 3600 * 1000; + +async function readJson(filePath, fallback) { + try { + return JSON.parse(await fs.readFile(filePath, 'utf8')); + } 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 memberLabel(userId, displayName) { + return displayName?.trim() || `用户 ${String(userId).slice(0, 8)}`; +} + +export function createLearnAssistantFamilyRegistry({ dataRoot, seedFamilyDefaults } = {}) { + if (!dataRoot) throw new Error('dataRoot is required'); + + const registryPath = path.join(dataRoot, 'registry.json'); + const invitesDir = path.join(dataRoot, 'invites'); + const familiesDir = path.join(dataRoot, 'families'); + + async function loadRegistry() { + return readJson(registryPath, { + families: {}, + slugToFamilyId: {}, + userToFamilyId: {}, + }); + } + + async function saveRegistry(registry) { + await writeJson(registryPath, registry); + } + + function familyStorePath(familyId) { + return path.join(familiesDir, familyId); + } + + async function ensureFamilyStore(familyId) { + await fs.mkdir(familyStorePath(familyId), { recursive: true }); + } + + async function migrateLegacyOwnerStore(ownerId, familyId) { + const legacyDir = path.join(dataRoot, ownerId); + const targetDir = familyStorePath(familyId); + try { + await fs.access(legacyDir); + } catch { + return { migrated: false }; + } + try { + await fs.access(targetDir); + return { migrated: false, reason: 'target_exists' }; + } catch { + await fs.mkdir(path.dirname(targetDir), { recursive: true }); + await fs.rename(legacyDir, targetDir); + return { migrated: true, from: legacyDir, to: targetDir }; + } + } + + async function ensureLegacyTangFamily(registry) { + if (registry.slugToFamilyId[LEGACY_TANG_SLUG]) { + return registry.families[registry.slugToFamilyId[LEGACY_TANG_SLUG]]; + } + const familyId = crypto.randomUUID(); + const now = new Date().toISOString(); + const family = { + id: familyId, + slug: LEGACY_TANG_SLUG, + name: '唐家庭', + ownerUserId: TANG_OWNER_ID, + createdAt: now, + members: [{ userId: TANG_OWNER_ID, role: 'owner', joinedAt: now, displayName: '唐' }], + }; + registry.families[familyId] = family; + registry.slugToFamilyId[LEGACY_TANG_SLUG] = familyId; + registry.userToFamilyId[TANG_OWNER_ID] = familyId; + await ensureFamilyStore(familyId); + await migrateLegacyOwnerStore(TANG_OWNER_ID, familyId); + if (seedFamilyDefaults) { + try { + await fs.access(path.join(familyStorePath(familyId), 'children.json')); + } catch { + await seedFamilyDefaults(familyId); + } + } + await saveRegistry(registry); + return family; + } + + async function getFamilyBySlug(slug) { + const registry = await loadRegistry(); + await ensureLegacyTangFamily(registry); + const fresh = await loadRegistry(); + const familyId = fresh.slugToFamilyId[String(slug ?? '').trim()]; + if (!familyId) { + const err = new Error('家庭不存在'); + err.statusCode = 404; + throw err; + } + return fresh.families[familyId]; + } + + async function getFamilyById(familyId) { + const registry = await loadRegistry(); + const family = registry.families[familyId]; + if (!family) { + const err = new Error('家庭不存在'); + err.statusCode = 404; + throw err; + } + return family; + } + + async function getFamilyForUser(userId, { displayName } = {}) { + if (!userId) { + const err = new Error('请先登录 Memind'); + err.statusCode = 401; + throw err; + } + let registry = await loadRegistry(); + await ensureLegacyTangFamily(registry); + registry = await loadRegistry(); + + let familyId = registry.userToFamilyId[userId]; + if (familyId && registry.families[familyId]) { + return { family: registry.families[familyId], created: false }; + } + + familyId = crypto.randomUUID(); + const slug = `home_${familyId.replace(/-/g, '').slice(0, 8)}`; + const now = new Date().toISOString(); + const family = { + id: familyId, + slug, + name: `${memberLabel(userId, displayName)}的家庭`, + ownerUserId: userId, + createdAt: now, + members: [{ userId, role: 'owner', joinedAt: now, displayName: displayName ?? null }], + }; + registry.families[familyId] = family; + registry.slugToFamilyId[slug] = familyId; + registry.userToFamilyId[userId] = familyId; + await ensureFamilyStore(familyId); + await saveRegistry(registry); + return { family, created: true }; + } + + function assertFamilyMember(family, userId) { + const member = family.members.find((m) => m.userId === userId); + if (!member) { + const err = new Error('无权访问该家庭'); + err.statusCode = 403; + throw err; + } + return member; + } + + async function requireFamilyMember(userId, familyId) { + const family = await getFamilyById(familyId); + assertFamilyMember(family, userId); + return family; + } + + async function requireFamilyForUser(userId, { displayName } = {}) { + const { family } = await getFamilyForUser(userId, { displayName }); + assertFamilyMember(family, userId); + return family; + } + + async function createInvite(userId, { displayName } = {}) { + const family = await requireFamilyForUser(userId, { displayName }); + const member = assertFamilyMember(family, userId); + if (member.role !== 'owner') { + const err = new Error('仅主家长可邀请协家长'); + err.statusCode = 403; + throw err; + } + const token = crypto.randomBytes(16).toString('hex'); + const invite = { + token, + familyId: family.id, + invitedBy: userId, + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + INVITE_TTL_MS).toISOString(), + }; + await writeJson(path.join(invitesDir, `${token}.json`), invite); + return { token, familyId: family.id, familySlug: family.slug, expiresAt: invite.expiresAt }; + } + + async function acceptInvite(userId, token, { displayName } = {}) { + if (!token) { + const err = new Error('邀请码无效'); + err.statusCode = 400; + throw err; + } + const invite = await readJson(path.join(invitesDir, `${token}.json`), null); + if (!invite) { + const err = new Error('邀请不存在或已失效'); + err.statusCode = 404; + throw err; + } + if (new Date(invite.expiresAt).getTime() < Date.now()) { + const err = new Error('邀请已过期'); + err.statusCode = 410; + throw err; + } + + const registry = await loadRegistry(); + const existingFamilyId = registry.userToFamilyId[userId]; + if (existingFamilyId && existingFamilyId !== invite.familyId) { + const err = new Error('你已加入其他家庭,需先退出后再接受新邀请'); + err.statusCode = 409; + throw err; + } + + const family = registry.families[invite.familyId]; + if (!family) { + const err = new Error('家庭不存在'); + err.statusCode = 404; + throw err; + } + if (!family.members.some((m) => m.userId === userId)) { + family.members.push({ + userId, + role: 'parent', + joinedAt: new Date().toISOString(), + displayName: displayName ?? null, + }); + registry.userToFamilyId[userId] = family.id; + registry.families[family.id] = family; + await saveRegistry(registry); + } + await fs.unlink(path.join(invitesDir, `${token}.json`)).catch(() => {}); + return { family, joined: true }; + } + + function familyUrls(family, publicBase = 'https://m.tkmind.cn') { + const childUrl = `${publicBase}/learn/child.html?f=${encodeURIComponent(family.slug)}`; + const parentUrl = `${publicBase}/learn/parent.html?f=${encodeURIComponent(family.slug)}`; + const joinPath = `${publicBase}/learn/join.html?token=`; + return { childUrl, parentUrl, joinPath }; + } + + return { + LEGACY_TANG_SLUG, + familyStorePath, + loadRegistry, + ensureLegacyTangFamily, + getFamilyBySlug, + getFamilyById, + getFamilyForUser, + requireFamilyForUser, + requireFamilyMember, + createInvite, + acceptInvite, + familyUrls, + resolveFamilyIdFromSlug: async (slug) => (await getFamilyBySlug(slug)).id, + }; +} diff --git a/learn-assistant-family.test.mjs b/learn-assistant-family.test.mjs new file mode 100644 index 0000000..b6b459d --- /dev/null +++ b/learn-assistant-family.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { createLearnAssistantFamilyRegistry } from './learn-assistant-family.mjs'; +import { createLearnAssistantService } from './learn-assistant-service.mjs'; + +test('family registry creates owner family and accepts co-parent invite', async () => { + const dataRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'learn-family-')); + const service = createLearnAssistantService({ dataRoot }); + const registry = createLearnAssistantFamilyRegistry({ + dataRoot, + seedFamilyDefaults: service.seedFamilyDefaults, + }); + + const dad = 'dad-user-id-001'; + const mom = 'mom-user-id-002'; + + const { family: dadFamily, created } = await registry.getFamilyForUser(dad, { displayName: '爸爸' }); + assert.equal(created, true); + assert.equal(dadFamily.ownerUserId, dad); + + const invite = await registry.createInvite(dad, { displayName: '爸爸' }); + assert.ok(invite.token); + + const joined = await registry.acceptInvite(mom, invite.token, { displayName: '妈妈' }); + assert.equal(joined.family.id, dadFamily.id); + assert.equal(joined.family.members.length, 2); + + const momFamily = await registry.requireFamilyForUser(mom); + assert.equal(momFamily.id, dadFamily.id); + + await service.parentAddChild(dadFamily.id, { name: '宝宝', emoji: '🙂' }); + const children = await service.listChildren(dadFamily.id); + assert.equal(children.data.length, 1); + + const momView = await service.listChildren((await registry.requireFamilyForUser(mom)).id); + assert.equal(momView.data[0].name, '宝宝'); +}); + +test('legacy tang slug resolves and seeds defaults', async () => { + const dataRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'learn-tang-')); + const service = createLearnAssistantService({ dataRoot }); + const registry = createLearnAssistantFamilyRegistry({ + dataRoot, + seedFamilyDefaults: service.seedFamilyDefaults, + }); + + const family = await registry.getFamilyBySlug('tang'); + assert.equal(family.slug, 'tang'); + const children = await service.listChildren(family.id); + assert.equal(children.data.length, 2); +}); diff --git a/learn-assistant-routes.mjs b/learn-assistant-routes.mjs new file mode 100644 index 0000000..aac08a5 --- /dev/null +++ b/learn-assistant-routes.mjs @@ -0,0 +1,294 @@ +import path from 'node:path'; +import { createLearnAssistantFamilyRegistry } from './learn-assistant-family.mjs'; +import { createLearnAssistantService } from './learn-assistant-service.mjs'; + +function handleError(res, error) { + const status = error?.statusCode ?? 500; + const message = error instanceof Error ? error.message : '请求失败'; + if (status >= 500) console.error('[LearnAssistant]', message); + return res.status(status).json({ error: message }); +} + +async function runHandler(res, work) { + try { + const body = await work; + return res.json(body); + } catch (error) { + return handleError(res, error); + } +} + +function readLegacyPassword(req) { + return req.get('x-parent-pw') || req.get('x-admin-pw') || ''; +} + +function currentUser(req) { + return req.currentUser ?? null; +} + +export function createLearnAssistantRuntime(options = {}) { + const dataRoot = options.dataRoot ?? path.join(options.rootDir ?? process.cwd(), 'data', 'learn-assistant'); + const service = options.service ?? createLearnAssistantService({ dataRoot }); + const familyRegistry = options.familyRegistry + ?? createLearnAssistantFamilyRegistry({ dataRoot, seedFamilyDefaults: service.seedFamilyDefaults }); + return { dataRoot, familyRegistry, service }; +} + +export function attachLearnAssistantPublicRoutes(api, options = {}) { + const { familyRegistry, service } = createLearnAssistantRuntime(options); + const prefix = options.routePrefix ?? '/learn'; + + async function withSlug(req, res, handler) { + try { + const family = await familyRegistry.getFamilyBySlug(req.params.slug); + return runHandler(res, handler(family)); + } catch (error) { + return handleError(res, error); + } + } + + api.get(`${prefix}/public/:slug/children`, (req, res) => + withSlug(req, res, (family) => service.listChildren(family.id))); + + api.get(`${prefix}/public/:slug/today`, (req, res) => + withSlug(req, res, (family) => + service.getToday(family.id, req.query.child, req.query.date))); + + api.get(`${prefix}/public/:slug/day`, (req, res) => + withSlug(req, res, (family) => + service.getDay(family.id, req.query.child, req.query.date))); + + api.post(`${prefix}/public/:slug/checkin`, (req, res) => + withSlug(req, res, (family) => + service.checkin(family.id, req.body?.id, req.body))); + + api.get(`${prefix}/public/:slug/calendar`, (req, res) => + withSlug(req, res, (family) => + service.getCalendar(family.id, req.query.child, req.query.month))); + + api.get(`${prefix}/public/:slug/growth`, (req, res) => + withSlug(req, res, (family) => + service.getGrowth(family.id, req.query.child))); + + api.get(`${prefix}/public/:slug/history`, (req, res) => + withSlug(req, res, (family) => + service.getHistory(family.id, req.query.child))); + + // legacy slug routes (tang 等旧链接) + api.get(`${prefix}/:slug/children`, (req, res) => + withSlug(req, res, (family) => service.listChildren(family.id))); + + api.get(`${prefix}/:slug/today`, (req, res) => + withSlug(req, res, (family) => + service.getToday(family.id, req.query.child, req.query.date))); + + api.get(`${prefix}/:slug/day`, (req, res) => + withSlug(req, res, (family) => + service.getDay(family.id, req.query.child, req.query.date))); + + api.post(`${prefix}/:slug/checkin`, (req, res) => + withSlug(req, res, (family) => + service.checkin(family.id, req.body?.id, req.body))); + + api.get(`${prefix}/:slug/calendar`, (req, res) => + withSlug(req, res, (family) => + service.getCalendar(family.id, req.query.child, req.query.month))); + + api.get(`${prefix}/:slug/growth`, (req, res) => + withSlug(req, res, (family) => + service.getGrowth(family.id, req.query.child))); + + api.get(`${prefix}/:slug/history`, (req, res) => + withSlug(req, res, (family) => + service.getHistory(family.id, req.query.child))); + + // legacy password parent API (tang 旧家长端) + async function withLegacyParent(req, res, handler) { + try { + const family = await familyRegistry.getFamilyBySlug(req.params.slug); + await service.assertLegacyPassword(family.id, readLegacyPassword(req)); + return runHandler(res, handler(family)); + } catch (error) { + return handleError(res, error); + } + } + + api.get(`${prefix}/:slug/parent/stats`, (req, res) => + withLegacyParent(req, res, (family) => service.parentStats(family.id))); + + api.get(`${prefix}/:slug/parent/templates`, (req, res) => + withLegacyParent(req, res, (family) => service.parentListTemplates(family.id))); + + api.post(`${prefix}/:slug/parent/templates`, (req, res) => + withLegacyParent(req, res, (family) => + service.parentSaveTemplate(family.id, req.body))); + + api.put(`${prefix}/:slug/parent/templates/:id`, (req, res) => + withLegacyParent(req, res, (family) => + service.parentSaveTemplate(family.id, req.body, +req.params.id))); + + api.delete(`${prefix}/:slug/parent/templates/:id`, (req, res) => + withLegacyParent(req, res, (family) => + service.parentDeleteTemplate(family.id, +req.params.id))); + + api.get(`${prefix}/:slug/parent/categories`, (req, res) => + withLegacyParent(req, res, (family) => service.parentListCategories(family.id))); + + api.post(`${prefix}/:slug/parent/categories`, (req, res) => + withLegacyParent(req, res, (family) => + service.parentAddCategory(family.id, req.body))); + + api.delete(`${prefix}/:slug/parent/categories/:id`, (req, res) => + withLegacyParent(req, res, (family) => + service.parentDeleteCategory(family.id, +req.params.id))); + + api.get(`${prefix}/:slug/parent/children`, (req, res) => + withLegacyParent(req, res, (family) => service.parentListChildren(family.id))); + + api.post(`${prefix}/:slug/parent/children`, (req, res) => + withLegacyParent(req, res, (family) => + service.parentAddChild(family.id, req.body))); + + api.delete(`${prefix}/:slug/parent/children/:id`, (req, res) => + withLegacyParent(req, res, (family) => + service.parentDeleteChild(family.id, +req.params.id))); + + api.post(`${prefix}/:slug/parent/password`, (req, res) => + withLegacyParent(req, res, (family) => + service.parentChangePassword(family.id, req.body))); + + return { familyRegistry, service }; +} + +export function attachLearnAssistantParentRoutes(api, options = {}) { + const { familyRegistry, service } = createLearnAssistantRuntime(options); + const prefix = options.routePrefix ?? '/learn'; + + function requireUser(req, res) { + const user = currentUser(req); + if (!user?.id) { + res.status(401).json({ error: '请先登录 Memind' }); + return null; + } + return user; + } + + async function withFamily(req, res, handler) { + const user = requireUser(req, res); + if (!user) return null; + try { + const family = await familyRegistry.requireFamilyForUser(user.id, { + displayName: user.displayName ?? user.display_name ?? user.username, + }); + return runHandler(res, handler(family, user)); + } catch (error) { + return handleError(res, error); + } + } + + api.get(`${prefix}/me/family`, async (req, res) => { + const user = requireUser(req, res); + if (!user) return; + try { + const { family, created } = await familyRegistry.getFamilyForUser(user.id, { + displayName: user.displayName ?? user.display_name ?? user.username, + }); + const urls = familyRegistry.familyUrls(family); + return res.json({ + family: { + id: family.id, + slug: family.slug, + name: family.name, + members: family.members, + }, + role: family.members.find((m) => m.userId === user.id)?.role ?? 'parent', + urls, + created, + }); + } catch (error) { + return handleError(res, error); + } + }); + + api.post(`${prefix}/me/invite`, (req, res) => + withFamily(req, res, async (family, user) => { + const invite = await familyRegistry.createInvite(user.id, { + displayName: user.displayName ?? user.display_name ?? user.username, + }); + const urls = familyRegistry.familyUrls(family); + return { + ...invite, + joinUrl: `${urls.joinPath}${invite.token}`, + }; + })); + + api.post(`${prefix}/me/join`, async (req, res) => { + const user = requireUser(req, res); + if (!user) return; + try { + const result = await familyRegistry.acceptInvite(user.id, req.body?.token, { + displayName: user.displayName ?? user.display_name ?? user.username, + }); + return res.json({ + ok: true, + family: { + id: result.family.id, + slug: result.family.slug, + name: result.family.name, + members: result.family.members, + }, + }); + } catch (error) { + return handleError(res, error); + } + }); + + api.get(`${prefix}/me/parent/stats`, (req, res) => + withFamily(req, res, (family) => service.parentStats(family.id))); + + api.get(`${prefix}/me/parent/templates`, (req, res) => + withFamily(req, res, (family) => service.parentListTemplates(family.id))); + + api.post(`${prefix}/me/parent/templates`, (req, res) => + withFamily(req, res, (family) => + service.parentSaveTemplate(family.id, req.body))); + + api.put(`${prefix}/me/parent/templates/:id`, (req, res) => + withFamily(req, res, (family) => + service.parentSaveTemplate(family.id, req.body, +req.params.id))); + + api.delete(`${prefix}/me/parent/templates/:id`, (req, res) => + withFamily(req, res, (family) => + service.parentDeleteTemplate(family.id, +req.params.id))); + + api.get(`${prefix}/me/parent/categories`, (req, res) => + withFamily(req, res, (family) => service.parentListCategories(family.id))); + + api.post(`${prefix}/me/parent/categories`, (req, res) => + withFamily(req, res, (family) => + service.parentAddCategory(family.id, req.body))); + + api.delete(`${prefix}/me/parent/categories/:id`, (req, res) => + withFamily(req, res, (family) => + service.parentDeleteCategory(family.id, +req.params.id))); + + api.get(`${prefix}/me/parent/children`, (req, res) => + withFamily(req, res, (family) => service.parentListChildren(family.id))); + + api.post(`${prefix}/me/parent/children`, (req, res) => + withFamily(req, res, (family) => + service.parentAddChild(family.id, req.body))); + + api.delete(`${prefix}/me/parent/children/:id`, (req, res) => + withFamily(req, res, (family) => + service.parentDeleteChild(family.id, +req.params.id))); + + return { familyRegistry, service }; +} + +/** @deprecated use attachLearnAssistantPublicRoutes + attachLearnAssistantParentRoutes */ +export function attachLearnAssistantRoutes(api, options = {}) { + const runtime = attachLearnAssistantPublicRoutes(api, options); + attachLearnAssistantParentRoutes(api, options); + return runtime; +} diff --git a/learn-assistant-service.mjs b/learn-assistant-service.mjs new file mode 100644 index 0000000..c00bd9d --- /dev/null +++ b/learn-assistant-service.mjs @@ -0,0 +1,725 @@ +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, +}; diff --git a/public/learn/child.html b/public/learn/child.html new file mode 100644 index 0000000..ccc016d --- /dev/null +++ b/public/learn/child.html @@ -0,0 +1,103 @@ + + +我的学习小助手 +
+
v2
+ \ No newline at end of file diff --git a/public/learn/index.html b/public/learn/index.html new file mode 100644 index 0000000..7703a40 --- /dev/null +++ b/public/learn/index.html @@ -0,0 +1,5 @@ +学习小助手 + +

学习小助手

家长注册 Memind 后配置任务;孩子打开链接打卡。

+家长后台儿童端示例 +登录 / 注册 Memind
\ No newline at end of file diff --git a/public/learn/join.html b/public/learn/join.html new file mode 100644 index 0000000..fc6470d --- /dev/null +++ b/public/learn/join.html @@ -0,0 +1,18 @@ +加入家庭 + +

加入学习家庭

正在处理邀请…

+ \ No newline at end of file diff --git a/public/learn/parent.html b/public/learn/parent.html new file mode 100644 index 0000000..73b2eea --- /dev/null +++ b/public/learn/parent.html @@ -0,0 +1,126 @@ + + +家长后台 · 学习小助手 +
+ \ No newline at end of file diff --git a/scripts/build-learning-assistant-public-pages.mjs b/scripts/build-learning-assistant-public-pages.mjs new file mode 100644 index 0000000..4fd41de --- /dev/null +++ b/scripts/build-learning-assistant-public-pages.mjs @@ -0,0 +1,135 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); +const outDir = path.join(root, 'public', 'learn'); + +const sharedCss = fs.readFileSync(path.join(root, 'scripts/build-tang-learning-assistant-pages.mjs'), 'utf8') + .match(/const sharedCss = `([\s\S]*?)`;/)[1]; + +function childPage() { + return ` + +我的学习小助手 +
+
v2
+`; +} + +function childScriptBody() { + // childScript() 在 tang 构建里经 template literal 会把 \\' 变成 \'; + // 这里直接读源码,需手动还原为 inline `; +} + +function parentDashboardScript() { + return ` +function renderLogin(){return renderAuth()} +async function login(){await ensureFamily();await loadAll();render()} +function renderDash(){var s=state.stats;var tp=s.today.total?Math.round(s.today.done/s.today.total*100):0;var wp=s.week.total?Math.round(s.week.done/s.week.total*100):0;var st=s.children.length?s.children[0].streak:0;var html='

数据看板 📊

'+s.children.map(function(c){return esc(c.emoji)+' '+esc(c.name)+' 连续 '+c.streak+' 天🔥'}).join(' ')+'
'+tp+'%
今日完成率 '+(s.today.done+'/'+s.today.total)+'
'+wp+'%
本周完成率
'+st+'
连续全勤(天)
';s.trend.forEach(function(d){var p=d.total?Math.round(d.done/d.total*100):0;html+='
'+d.d.slice(5)+'
'+p+'%
'});return html} +function renderTemplates(){var html='

任务模板 📋

';state.templates.forEach(function(t){html+='
'+esc(t.title)+'
+'+t.points+' 能量 · 每周 '+weekdaysCN(t.weekdays)+'
'});html+='';return html} +function tplForm(t){var kids=state.children.map(function(c){return ''}).join('');var cats=state.categories.map(function(c){return ''}).join('');var wchk=WD.map(function(w,i){return ''+w+''}).join('');return '
'} +function newTpl(){document.getElementById('app').insertAdjacentHTML('beforeend',tplForm(null))} +async function saveTpl(id){var wds=[];document.querySelectorAll('.overlay .chip.on').forEach(function(c,i){wds.push(String(i+1))});var body={child_id:+document.getElementById('f_child').value,title:document.getElementById('f_title').value,task_type:document.getElementById('f_type').value,weekdays:wds,start_date:new Date().toISOString().slice(0,10),points:10};if(id)await api('/parent/templates/'+id,{method:'PUT',body:JSON.stringify(body)});else await api('/parent/templates',{method:'POST',body:JSON.stringify(body)});document.querySelector('.overlay').remove();await loadAll();render()} +function renderCats(){var html='

分类管理 🏷️

';state.categories.forEach(function(c){html+=''});html+='
'+esc(c.name)+'
';return html} +async function addCat(){var n=document.getElementById('c_name').value.trim();if(!n)return;await api('/parent/categories',{method:'POST',body:JSON.stringify({name:n,color:'#3b5bfd'})});await loadAll();render()} +function renderKids(){var html='

孩子管理 👦

';state.children.forEach(function(c){html+='
'+esc(c.emoji)+' '+esc(c.name)+'
'});html+='
';return html} +async function addKid(){var n=document.getElementById('k_name').value.trim();if(!n)return;await api('/parent/children',{method:'POST',body:JSON.stringify({name:n,emoji:'🙂'})});await loadAll();render()} +async function loadAll(){if(state.tab==='dash')state.stats=await api('/parent/stats');if(state.tab==='tpl'){state.templates=(await api('/parent/templates')).data;state.categories=(await api('/parent/categories')).data;state.children=(await api('/parent/children')).data}if(state.tab==='cat'){state.categories=(await api('/parent/categories')).data}if(state.tab==='kid'){state.children=(await api('/parent/children')).data}} +function render(){var app=document.getElementById('app');if(!state.family){app.innerHTML=renderAuth();return}var tabs=[['dash','📊','看板'],['tpl','📋','模板'],['cat','🏷️','分类'],['kid','👦','孩子'],['set','⚙️','设置']];var body=state.tab==='dash'?renderDash():state.tab==='tpl'?renderTemplates():state.tab==='cat'?renderCats():state.tab==='kid'?renderKids():renderSet();app.innerHTML=body+'
'+tabs.map(function(t){return ''}).join('')+'
'} +async function go(tab){state.tab=tab;await loadAll();render()} +`; +} + +function joinPage() { + return `加入家庭 + +

加入学习家庭

正在处理邀请…

+`; +} + +function indexPage() { + return `学习小助手 + +

学习小助手

家长注册 Memind 后配置任务;孩子打开链接打卡。

+家长后台儿童端示例 +登录 / 注册 Memind
`; +} + +fs.mkdirSync(outDir, { recursive: true }); +fs.writeFileSync(path.join(outDir, 'child.html'), childPage()); +fs.writeFileSync(path.join(outDir, 'parent.html'), parentPage()); +fs.writeFileSync(path.join(outDir, 'join.html'), joinPage()); +fs.writeFileSync(path.join(outDir, 'index.html'), indexPage()); +console.log(JSON.stringify({ ok: true, outDir }, null, 2)); diff --git a/scripts/build-tang-learning-assistant-pages.mjs b/scripts/build-tang-learning-assistant-pages.mjs new file mode 100644 index 0000000..971029d --- /dev/null +++ b/scripts/build-tang-learning-assistant-pages.mjs @@ -0,0 +1,223 @@ +#!/usr/bin/env node +/** + * 生成唐用户学习小助手 HTML(儿童端 + 家长端) + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createLearnAssistantService, learnAssistantInternals } from '../learn-assistant-service.mjs'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); +const TANG = learnAssistantInternals.TANG_OWNER_ID; +const API_BASE = '/api/learn/tang'; +const outDir = path.join(root, 'MindSpace', TANG, 'public'); + +const sharedCss = `*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent} +body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;background:#f2f4fa;color:#1c2333;min-height:100vh;padding-bottom:70px;padding-bottom:calc(70px + constant(safe-area-inset-bottom));padding-bottom:calc(70px + env(safe-area-inset-bottom))} +.wrap{max-width:560px;margin:0 auto;padding:16px 14px} +header.h{padding:16px 2px 4px} +h1{font-size:22px;font-weight:700} +.sub{font-size:13px;color:#6b7280;margin-top:3px} +.card{background:#fff;border-radius:16px;padding:16px;margin-top:12px;box-shadow:0 1px 3px rgba(28,35,51,.06)} +.big{font-size:26px;font-weight:700} +.bar{height:10px;background:#e8ebf2;border-radius:5px;overflow:hidden;margin-top:8px} +.bar i{display:block;height:100%;background:linear-gradient(90deg,#22c55e,#16a34a);border-radius:5px;transition:width .4s} +.cat{margin-top:14px} +.cat-title{font-size:13px;font-weight:700;padding:3px 10px;border-radius:999px;display:inline-flex;align-items:center;gap:5px} +.task{display:flex;align-items:center;gap:12px;padding:12px 2px;border-bottom:1px solid #f0f2f7;cursor:pointer} +.task:last-child{border-bottom:none} +.task .ck{width:26px;height:26px;border-radius:50%;border:2px solid #cbd5e1;flex:none;display:flex;align-items:center;justify-content:center;font-size:15px;color:#fff} +.task.done .ck{background:#16a34a;border-color:#16a34a} +.task .info{flex:1;min-width:0} +.task .t{font-size:15px;font-weight:600} +.task.done .t{color:#9ca3af;text-decoration:line-through} +.task .d{font-size:12px;color:#6b7280;margin-top:2px} +.task .pt{font-size:12px;color:#f59e0b;font-weight:700;flex:none} +.tabbar{position:fixed;left:0;right:0;bottom:0 !important;top:auto !important;background:#fff;border-top:1px solid #e5e8f0;display:flex;z-index:20;padding:6px 0 8px;padding:6px 0 calc(8px + constant(safe-area-inset-bottom));padding:6px 0 calc(8px + env(safe-area-inset-bottom))} +.overlay{top:0 !important;left:0 !important;right:0 !important;bottom:0 !important} +.tabbar a{flex:1;text-align:center;text-decoration:none;color:#9ca3af;font-size:11px;padding:4px 0} +.tabbar a.on{color:#3b5bfd;font-weight:700} +.tabbar .ic{font-size:20px;display:block} +button{font-family:inherit;border:none;cursor:pointer;border-radius:12px;font-size:14px;font-weight:600;padding:10px 16px;background:#3b5bfd;color:#fff} +button:active{transform:scale(.97)} +button.ghost{background:#e8edff;color:#3b5bfd} +button.danger{background:#ffe9ee;color:#e11d48} +button:disabled{opacity:.4;cursor:not-allowed} +input,select,textarea{font-family:inherit;width:100%;border:2px solid #e5e8f0;border-radius:10px;padding:10px 12px;font-size:15px;outline:none;background:#fff;color:#1c2333} +input:focus,select:focus,textarea:focus{border-color:#3b5bfd} +label{display:block;font-size:13px;font-weight:600;margin:12px 0 5px} +.overlay{position:fixed;inset:0;background:rgba(20,24,38,.5);z-index:40;display:flex;align-items:center;justify-content:center;padding:20px} +.modal{background:#fff;border-radius:18px;padding:24px;width:100%;max-width:360px;max-height:85vh;overflow:auto} +.statcheck{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:12px} +.sc{background:#fff;border-radius:14px;padding:13px 8px;text-align:center;box-shadow:0 1px 3px rgba(28,35,51,.06)} +.sc .n{font-size:20px;font-weight:700} +.sc .l{font-size:11px;color:#6b7280;margin-top:2px} +.cal{display:grid;grid-template-columns:repeat(7,1fr);gap:6px;margin-top:10px} +.cal .w{text-align:center;font-size:11px;color:#9ca3af;padding:4px 0} +.cal .cell{text-align:center;padding:7px 0;border-radius:10px;font-size:14px;position:relative} +.cal .cell.has{font-weight:700} +.cal .cell.g{background:#e7f8ee;color:#16a34a}.cal .cell.y{background:#fff4dd;color:#d97706}.cal .cell.r{background:#ffe9ee;color:#e11d48}.cal .cell.n{color:#c3c9d6} +.cal .today{outline:2px solid #3b5bfd} +.legend{display:flex;gap:12px;font-size:11px;color:#6b7280;margin-top:10px;flex-wrap:wrap} +.dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:4px;vertical-align:-1px} +.feed{position:fixed;left:50%;top:38%;transform:translate(-50%,-50%);background:#fff;border-radius:20px;padding:28px 30px;text-align:center;z-index:50;box-shadow:0 10px 40px rgba(28,35,51,.25);animation:pop .3s} +@keyframes pop{from{transform:translate(-50%,-50%) scale(.8);opacity:0}to{transform:translate(-50%,-50%) scale(1);opacity:1}} +.feed .em{font-size:44px}.feed .t1{font-size:18px;font-weight:700;margin-top:8px}.feed .t2{font-size:13px;color:#6b7280;margin-top:6px;line-height:1.6} +.feed .pt{color:#f59e0b;font-weight:700;margin-top:8px;font-size:15px} +table{width:100%;border-collapse:collapse;font-size:13px} +th,td{padding:8px 6px;border-bottom:1px solid #f0f2f7;text-align:left} +th{font-size:11px;color:#6b7280} +.row{display:flex;gap:10px;align-items:center} +.row>*{flex:1} +.pw{height:8px;background:#e8ebf2;border-radius:4px;overflow:hidden;min-width:60px} +.pw i{display:block;height:100%;background:#3b5bfd} +.chip{display:inline-flex;align-items:center;gap:6px;padding:6px 14px;border-radius:999px;border:2px solid #e5e8f0;font-size:13px;cursor:pointer;margin:0 8px 8px 0;background:#fff} +.chip.on{border-color:#3b5bfd;background:#e8edff;color:#3b5bfd;font-weight:700} +.hbtn{display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:600;color:#3b5bfd;background:#e8edff;padding:6px 13px;border-radius:999px;text-decoration:none;cursor:pointer;flex:none;white-space:nowrap} +.hbtn:active{transform:scale(.95)} +.err{background:#ffe9ee;color:#e11d48;padding:10px;border-radius:10px;font-size:13px;margin-top:10px;display:none} +.tabs{display:flex;gap:8px;margin-top:14px} +.tabs button{flex:1;background:#e8ebf2;color:#6b7280} +.tabs button.on{background:#3b5bfd;color:#fff} +.empty{text-align:center;color:#9ca3af;font-size:13px;padding:18px 0;line-height:1.7}`; + +function childScript() { + return ` +var API_BASE='${API_BASE}'; +var state={tab:'today',child:null,children:[],data:null,cal:null,grow:null}; +function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(//g,'>')} +async function api(p,opt){var r=await fetch(API_BASE+p,opt);var j=await r.json().catch(function(){return{}});if(!r.ok)throw new Error(j.error||'请求失败');return j} +function pickChild(){if(state.children.length<=1)return state.children[0]?state.children[0].id:1;var saved=localStorage.getItem('learn_child');var c=state.children.find(function(x){return String(x.id)===saved});if(!c){var s='

我是谁呀?

';state.children.forEach(function(x){s+='
'+esc(x.emoji)+' '+esc(x.name)+'
'});s+='
';return s}return c.id} +function chooseChild(id){localStorage.setItem('learn_child',String(id));location.reload()} +function showSwitcher(){var s='
';document.body.insertAdjacentHTML('beforeend',s)} +function hdrActions(){var cur=state.children.find(function(x){return x.id==state.child});return '
⇄ '+(cur?'切换学员':'学员')+'家长端
'} +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='

'+greet()+','+esc(d.child.name)+' '+esc(d.child.emoji)+'

'+d.date.replace(/-/g,'/')+' 周'+wdCN(dowOf(d.date))+'
'+hdrActions()+'
'+d.done+' / '+d.total+'
今天已完成
⭐ '+d.energy+'
我的能量
'+(d.total&&d.done===d.total?'
⭐ 今天全部完成,太棒啦!
':'')+'
';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+='
今天没有任务
好好休息,或找爸爸妈妈安排~
';order.forEach(function(k){s+='
'+esc(k)+'
';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+='
'+(t.status==='done'?'✓':'')+'
'+esc(t.title)+'
'+(sub.length?'
'+sub.join('
')+'
':'')+'
+'+t.points+'
'});s+='
'});if(d.streak>0)s+='
🔥 连续打卡 '+d.streak+' 天,继续保持!
';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='

我的日历 📅

'+m.replace('-',' 年 ')+' 月
'+hdrActions()+'
';['日','一','二','三','四','五','六'].forEach(function(w){s+='
'+w+'
'});for(var i=0;i
';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='';else if(st.status==='yellow')mark='';}}if(ds===today)cls+=' today';s+='
'+d+mark+'
'}s+='
全部完成部分完成有遗漏(红点)无任务
点任意日期查看当天详情
';return s} +async function showDay(ds){var j=await api('/day?child='+state.child+'&date='+ds);var el=document.getElementById('dayDetail');var isToday=ds===new Date(Date.now()+8*3600e3).toISOString().slice(0,10);var stTxt='';var stColor='';var done=j.tasks.filter(function(t){return t.status==='done'}).length;if(j.tasks.length){if(done===j.tasks.length){stTxt='全部完成 ✓';stColor='#16a34a'}else if(done>0){stTxt='完成 '+done+'/'+j.tasks.length+(ds'+(stTxt?''+stTxt+'':'')+'';if(!j.tasks.length)s+='
那天没有任务
';j.tasks.forEach(function(t){var overdue=t.status==='pending'&&ds
'+(t.status==='done'?'✓':(overdue?'✕':''))+'
'+esc(t.title)+'
'+esc(t.category_name||'')+(t.status==='done'?' · '+(t.completed_time||'')+' 完成':(overdue?' · 未完成':' · 待完成')+(t.value_submitted?' · '+esc(t.value_submitted)+(t.unit||''):''))+'
'});if(j.tasks.length)s+='
完成率:'+Math.round(done/j.tasks.length*100)+'%
';s+='';el.innerHTML=s} +function renderGrow(){var g=state.grow;var s='

我的成长 🌱

'+hdrActions()+'
⭐ '+g.energy+'
我的能量
🔥 '+g.streak+'
连续打卡
'+g.totalDone+'
累计完成
💪 历史最长连续:'+g.best+' 天 | 全勤天数:'+g.fullDays+'
近 30 天分类表现
';if(!g.byCategory.length)s+='
还没有记录,快去完成任务吧!
';g.byCategory.forEach(function(c){var p=c.total?Math.round(c.done/c.total*100):0;s+='
'+esc(c.name)+'
'+p+'%
'});s+='
';return s} +function renderHist(){var h=state.hist;var today=new Date(Date.now()+8*3600e3).toISOString().slice(0,10);var s='

我的记录 📖

每天的任务和完成情况,都记在这里'+(h.cycleStart?'
本期任务从 '+h.cycleStart.replace(/-/g,'/')+' 开始':'')+'
'+hdrActions()+'
';if(!h.data.length)s+='
还没有记录
完成今天的任务,就会出现在这里哦
';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
'+label+' '+w+' '+dot+'
'+stTxt+''+(open?'▾':'▸')+'
';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
'+(t.status==='done'?'✓':(t.status==='pending'&&day.date
'+esc(t.title)+'
'+esc(t.category_name||'')+' · '+sub.join(' · ')+'
'});s+='
'});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 ''+x[1]+''+x[2]+''}).join('')} +async function go(tab){state.tab=tab;await load();render()} +async function load(){if(!state.children.length){state.children=(await api('/children')).data}if(state.child==null){var c=pickChild();if(typeof c!=='number')return;state.child=c}if(state.child==null)return;if(state.tab==='today')state.data=await api('/today?child='+state.child);if(state.tab==='hist')state.hist=await api('/history?child='+state.child);if(state.tab==='cal'&&!state.cal)state.cal=await api('/calendar?child='+state.child);if(state.tab==='grow')state.grow=await api('/growth?child='+state.child)} +function render(){var app=document.getElementById('app');if(!state.children.length){app.innerHTML='
还没有小朋友的信息
请爸爸妈妈先去 家长后台 添加
';document.getElementById('tabbar').innerHTML='';return}if(state.child==null){app.innerHTML=pickChild();document.getElementById('tabbar').innerHTML='';return}var s=state.tab==='today'?renderToday():state.tab==='hist'?renderHist():state.tab==='cal'?renderCal():renderGrow();app.innerHTML=s;document.getElementById('tabbar').innerHTML=renderTabbar()} +async function calShift(n){var m=state.cal.month.split('-');var y=+m[0],mo=+m[1]+n;if(mo<1){mo=12;y--}if(mo>12){mo=1;y++}state.cal=await api('/calendar?child='+state.child+'&month='+y+'-'+pad(mo));render()} +function doTask(id){var t=null;state.data.tasks.forEach(function(x){if(x.id===id)t=x});if(!t)(state.data.pastPending||[]).forEach(function(x){if(x.id===id)t=x});if(!t)return;if(t.status==='done')return;if(t.task_type==='check'){submit(id)}else{var unit=t.unit||'';var label=t.task_type==='count'?'完成了多少'+(unit?esc(unit):''):(t.task_type==='duration'?'坚持了多少'+(unit||'分钟'):'得了多少分');var def=t.value_submitted||(t.task_type==='duration'?(t.target_value||''):(t.target_value||''));document.getElementById('app').insertAdjacentHTML('beforeend','
');var inp=document.getElementById('vInput');if(inp)inp.focus()}} +function submitVal(id){var v=document.getElementById('vInput').value;var r=document.getElementById('vRemark').value;submit(id,v,r)} +async function submit(id,value,remark){try{var j=await api('/checkin',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:id,value:value,remark:remark})});var s='
🎉
完成啦!
今天已经完成 '+j.done+' / '+j.total+' 项
'+(j.allDone?'⭐ 太棒了,今天全部完成!':'再完成 '+(j.total-j.done)+' 项,今天就是全绿!')+'
+'+j.points+' 能量
点一下继续
';document.body.insertAdjacentHTML('beforeend',s);await load();render()}catch(e){alert(e.message)}} +(async function(){try{await load();render()}catch(e){document.getElementById('app').innerHTML='
'+esc(e.message)+'
'}})();`; +} + +function parentScript() { + return ` +var API_BASE='${API_BASE}'; +var state={pw:localStorage.getItem('learn_pw')||'',tab:'dash',stats:null,templates:null,categories:null,children:null,editId:null}; +function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(//g,'>')} +async function api(p,opt){opt=opt||{};opt.headers=Object.assign({'X-Parent-Pw':state.pw,'X-Admin-Pw':state.pw,'Content-Type':'application/json'},opt.headers||{});var r=await fetch(API_BASE+p,opt);var j=await r.json().catch(function(){return{}});if(!r.ok)throw new Error(j.error||'请求失败');return j} +function pad(n){return n<10?'0'+n:''+n} +var WD=['一','二','三','四','五','六','日']; +function weekdaysCN(w){return w.split(',').map(function(x){return WD[+x-1]}).join('、')} +var TYPE={check:'完成型',count:'数量型',duration:'时长型',score:'成绩型'}; +function renderLogin(){return '

家长后台 🔐

任务配置 · 数据统计 · 习惯分析
'} +async function login(){state.pw=document.getElementById('pw').value;try{await api('/parent/stats');localStorage.setItem('learn_pw',state.pw);state.tab='dash';await loadAll();render()}catch(e){var el=document.getElementById('err');el.style.display='block';el.textContent=e.message}} +function renderDash(){var s=state.stats;var tp=s.today.total?Math.round(s.today.done/s.today.total*100):0;var wp=s.week.total?Math.round(s.week.done/s.week.total*100):0;var st=s.children.length?s.children[0].streak:0;var html='

数据看板 📊

'+s.children.map(function(c){return esc(c.emoji)+' '+esc(c.name)+' 连续 '+c.streak+' 天🔥'}).join(' ')+'
'+tp+'%
今日完成率 '+(s.today.done+'/'+s.today.total)+'
'+wp+'%
本周完成率
'+st+'
连续全勤(天)
近 7 天趋势
';s.trend.forEach(function(d){var p=d.total?Math.round(d.done/d.total*100):0;html+='
'+d.d.slice(5)+'
'+p+'%
'});html+='
';if(s.missed.length){html+='
⚠️ 近 30 天容易遗漏
';s.missed.forEach(function(m){html+=''});html+='
'+esc(m.title)+'漏 '+m.count+' 次
'}if(s.categoryRate.length){html+='
📚 分类完成率(近 30 天)
';s.categoryRate.forEach(function(c){var p=c.total?Math.round(c.done/c.total*100):0;html+='
'+esc(c.name)+'
'+p+'%
'});html+='
'}if(s.avgTime.length){html+='
⏰ 平均完成时间(近 30 天)
';s.avgTime.forEach(function(a){html+=''});html+='
'+esc(a.name)+''+a.time+'
提示:越晚完成的科目,遗漏概率往往越高
'}return html} +function renderTemplates(){var html='

任务模板 📋

配置周期规则,系统每天自动生成当日任务
';if(!state.templates.length)html+='
还没有任务模板
点下方按钮创建第一个
';state.templates.forEach(function(t){html+='
'+esc(t.title)+'
'+(t.category_name?'【'+esc(t.category_name)+'】 ':'')+TYPE[t.task_type]+(t.target_value?' · '+esc(t.target_value)+(t.unit?esc(t.unit):''):'')+'
每周:'+weekdaysCN(t.weekdays)+' | 从 '+String(t.start_date).slice(0,10)+' 起'+(t.end_date?' 至 '+String(t.end_date).slice(0,10):'(长期)')+'
+'+t.points+' 能量'+(t.description?'
要求:'+esc(t.description):'')+'
'});html+='';return html} +function tplForm(t){var kids=state.children.map(function(c){return ''}).join('');var cats=state.categories.map(function(c){return ''}).join('');var wds=(t?t.weekdays:'1,2,3,4,5').split(',');var wchk=WD.map(function(w,i){return ''+w+''}).join('');return '
'} +function newTpl(){document.getElementById('app').insertAdjacentHTML('beforeend',tplForm(null))} +function editTpl(id){var t=state.templates.find(function(x){return x.id===id});if(t)document.getElementById('app').insertAdjacentHTML('beforeend',tplForm(t))} +async function delTpl(id,name){if(!confirm('停用「'+name+'」?已生成的历史任务保留,今后不再生成。'))return;await api('/parent/templates/'+id,{method:'DELETE'});await loadAll();render()} +async function saveTpl(id){var wds=[];document.querySelectorAll('.overlay .chip.on').forEach(function(c){wds.push(String(WD.indexOf(c.textContent)+1))});var body={child_id:+document.getElementById('f_child').value,category_id:+document.getElementById('f_cat').value||null,title:document.getElementById('f_title').value,description:document.getElementById('f_desc').value,task_type:document.getElementById('f_type').value,target_value:document.getElementById('f_target').value,unit:document.getElementById('f_unit').value,weekdays:wds,start_date:document.getElementById('f_start').value,end_date:document.getElementById('f_end').value,points:+document.getElementById('f_points').value};try{if(!body.title)throw new Error('任务名称必填');if(!wds.length)throw new Error('请至少选择一个星期');if(id)await api('/parent/templates/'+id,{method:'PUT',body:JSON.stringify(body)});else await api('/parent/templates',{method:'POST',body:JSON.stringify(body)});document.querySelector('.overlay').remove();await loadAll();render()}catch(e){var el=document.getElementById('f_err');el.style.display='block';el.textContent=e.message}} +function renderCats(){var html='

分类管理 🏷️

学习 + 习惯,不限语数外
';state.categories.forEach(function(c){html+=''});html+='
'+esc(c.name)+'
';return html} +async function addCat(){var n=document.getElementById('c_name').value.trim();if(!n)return;await api('/parent/categories',{method:'POST',body:JSON.stringify({name:n,color:document.getElementById('c_color').value})});await loadAll();render()} +async function delCat(id,name){if(!confirm('删除分类「'+name+'」?使用它的模板将变为无分类。'))return;await api('/parent/categories/'+id,{method:'DELETE'});await loadAll();render()} +function renderKids(){var html='

孩子管理 👦

儿童端选择孩子后进入
';state.children.forEach(function(c){html+='
'+esc(c.emoji)+' '+esc(c.name)+'
'});html+='
';return html} +async function addKid(){var n=document.getElementById('k_name').value.trim();if(!n)return;await api('/parent/children',{method:'POST',body:JSON.stringify({name:n,emoji:document.getElementById('k_emoji').value||'🙂'})});await loadAll();render()} +async function delKid(id,name){if(!confirm('删除「'+name+'」及其所有任务记录?此操作不可恢复!'))return;await api('/parent/children/'+id,{method:'DELETE'});await loadAll();render()} +function renderSet(){return '

设置 ⚙️

· 儿童端地址:打开
· 任务每天首次访问时自动生成
· 历史任务保存快照,改模板不影响过去记录
· 数据存储:Memind Portal
'} +async function chpw(){try{await api('/parent/password',{method:'POST',body:JSON.stringify({old:document.getElementById('p_old').value,new:document.getElementById('p_new').value})});alert('密码已更新');state.pw=document.getElementById('p_new').value;localStorage.setItem('learn_pw',state.pw)}catch(e){var el=document.getElementById('p_err');el.style.display='block';el.textContent=e.message}} +function logout(){localStorage.removeItem('learn_pw');state.pw='';render()} +async function loadAll(){if(state.tab==='dash')state.stats=await api('/parent/stats');if(state.tab==='tpl'){state.templates=(await api('/parent/templates')).data;state.categories=(await api('/parent/categories')).data;state.children=(await api('/parent/children')).data}if(state.tab==='cat'){state.categories=(await api('/parent/categories')).data}if(state.tab==='kid'){state.children=(await api('/parent/children')).data}} +function render(){var app=document.getElementById('app');if(!state.pw){app.innerHTML=renderLogin();return}var tabs=[['dash','📊','看板'],['tpl','📋','模板'],['cat','🏷️','分类'],['kid','👦','孩子'],['set','⚙️','设置']];var body=state.tab==='dash'?renderDash():state.tab==='tpl'?renderTemplates():state.tab==='cat'?renderCats():state.tab==='kid'?renderKids():renderSet();app.innerHTML=body+'
'+tabs.map(function(t){return ''}).join('')+'
'} +async function go(tab){state.tab=tab;try{await loadAll();render()}catch(e){if(e.message.indexOf('密码')>=0){state.pw='';localStorage.removeItem('learn_pw')}render()}} +(async function(){if(state.pw){try{await loadAll()}catch(e){if(e.message.indexOf('密码')>=0){state.pw='';localStorage.removeItem('learn_pw')}}}render()})();`; +} + +function wrapHtml(title, bodyExtra, script) { + return ` + + + + +${title} + + + +
+${title.includes('小助手') ? '
v1
' : '
'.replace('id="app"', '')} +