Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee8677cc6d | |||
| 6e7a75a79e | |||
| 348e192800 | |||
| b285fe24be |
@@ -0,0 +1,87 @@
|
||||
# 143 开发目录 Memind-dev
|
||||
|
||||
> 在 Mac Studio(103,`john@180.159.29.143`)上与本地 Mac 并行开发 Memind 的约定。
|
||||
> 生产运行目录仍是 `/Users/john/Project/Memind`;**日常改码只用** `/Users/john/Project-dev/Memind-dev`。
|
||||
|
||||
## 目录对照
|
||||
|
||||
| 机器 | 开发目录 | 生产 / 103 运行目录 |
|
||||
|------|----------|---------------------|
|
||||
| 本地 Mac | `/Users/john/Project/Memind` | — |
|
||||
| 143 | `/Users/john/Project-dev/Memind-dev` | `/Users/john/Project/Memind` |
|
||||
|
||||
代码同步靠 Git 远端 `git@git.tkmind.cn:tkmind/memind.git`,不要用 rsync 拷源码。
|
||||
|
||||
## 143 开发端口(与生产隔离)
|
||||
|
||||
| 服务 | 生产 | Memind-dev |
|
||||
|------|------|------------|
|
||||
| Portal / API | 8081 | **8086** |
|
||||
| MindSpace H5 (Vite) | — | **5173** |
|
||||
| memind_adm | 8082 | **8087** |
|
||||
| Ops | — | **3003** |
|
||||
| Plaza | 3001 | **3004** |
|
||||
|
||||
`.env` 在 Memind-dev 内单独维护(从生产 `.env` 复制后覆盖上表端口与 `MEMIND_RUNTIME_PROFILE=local`),**不要** commit。
|
||||
|
||||
## 日常命令
|
||||
|
||||
```bash
|
||||
ssh john@180.159.29.143
|
||||
cd /Users/john/Project-dev/Memind-dev
|
||||
source ~/.zshrc # 加载 /opt/homebrew 的 node@22 / pnpm
|
||||
git fetch origin --prune
|
||||
git pull --ff-only
|
||||
pnpm install # 仅 lockfile 变化时
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
本机浏览器经 SSH 隧道访问 143 上的 Vite:
|
||||
|
||||
```bash
|
||||
ssh -L 5173:127.0.0.1:5173 -L 8086:127.0.0.1:8086 john@180.159.29.143
|
||||
# 然后打开 http://127.0.0.1:5173/?preview=mindspace
|
||||
```
|
||||
|
||||
Cursor Remote SSH:打开文件夹 `/Users/john/Project-dev/Memind-dev`。
|
||||
|
||||
## 双机同步流程
|
||||
|
||||
**收工(任一台):**
|
||||
|
||||
```bash
|
||||
git status
|
||||
git add -A && git commit -m "..."
|
||||
git push origin feature/learn-assistant # 或 main,需明确批准 push
|
||||
```
|
||||
|
||||
**开工(另一台):**
|
||||
|
||||
```bash
|
||||
git fetch origin --prune
|
||||
git switch feature/learn-assistant
|
||||
git pull --ff-only
|
||||
```
|
||||
|
||||
## Git SSH(143 必配)
|
||||
|
||||
143 公钥需加入 `git.tkmind.cn`(端口 2222,见 `~/.ssh/config`):
|
||||
|
||||
```bash
|
||||
ssh john@180.159.29.143 'cat ~/.ssh/id_ed25519.pub'
|
||||
ssh -T git@git.tkmind.cn # 在 143 上验证
|
||||
```
|
||||
|
||||
未配公钥前,可临时用本机 `git bundle` 同步,但不建议长期使用。
|
||||
|
||||
## 103 发布
|
||||
|
||||
- 功能合并进 `main` 且 CI / verify 通过后,在 **生产目录** 拉最新 `main` 再跑发布脚本。
|
||||
- 禁止从 Memind-dev 脏工作区或功能分支直接 `release-portal-*-prod.sh`。
|
||||
- 发布前:`bash scripts/check-release-ready.sh`
|
||||
|
||||
## 注意
|
||||
|
||||
1. 两台机器不要长期各改同一分支却不 push/pull。
|
||||
2. Memind-dev 与生产共用 `DATABASE_URL` 时,开发操作可能影响线上数据,谨慎执行 destructive 脚本。
|
||||
3. `pnpm dev` 与 103 生产 Portal(8081)可同时存在;不要误改生产目录下的文件或重启生产 LaunchAgent。
|
||||
@@ -1,5 +1,34 @@
|
||||
# 历史分支处置登记
|
||||
|
||||
## `feature/wechat-mp-cursor-timeout-takeover`
|
||||
|
||||
**状态:禁止再次引用。已 fast-forward 并入本地 `main`(`2c3c1f7a`),103 已热更新 `wechat-mp.bundle.mjs` 与超时 env;完整 Portal runtime 与 `origin/main` push 待后续闭环。**
|
||||
|
||||
审计日期:2026-09-11
|
||||
分支 HEAD / 本地 `main`:`2c3c1f7a`
|
||||
|
||||
### 原始用途
|
||||
|
||||
微信服务号 `page.generate` 在 Goose 直连超时(`WECHAT_AGENT_REPLY_TIMEOUT`)后,由 Cursor 接手重试;默认超时从 15 分钟提升至 20 分钟。
|
||||
|
||||
### 验证摘要
|
||||
|
||||
- `node --test wechat-mp.test.mjs wechat-cursor-page-timeout-takeover.test.mjs cursor-page-timeout-takeover.test.mjs`:186 passed
|
||||
- 103:`deploy-wechat-mp-prod.sh` 上传 bundle(2026-09-11 15:32);`.env` 已设 `H5_WECHAT_MP_AGENT_REPLY_TIMEOUT_MS=1200000`、`MEMIND_CURSOR_PAGE_TIMEOUT_TAKEOVER=1`;Portal 8081 健康 200
|
||||
|
||||
### 103 发布记录(部分)
|
||||
|
||||
- 热更新:`wechat-mp.bundle.mjs`(含 `executeCursorTakeoverRun` / `WECHAT_AGENT_REPLY_TIMEOUT` 接手逻辑)
|
||||
- 未走完整 runtime 包:当前 manifest 仍为 `20260911-134117-cb3fb352`;`agent-run-gateway.mjs` 的 `getToolGatewayStatus` 导出不在 bundle 内(微信接手有 env 回退)
|
||||
|
||||
### 最终处置
|
||||
|
||||
- 保留本地分支名用于审计追溯。
|
||||
- 不要从该分支继续开发、merge、cherry-pick 或构建 runtime/artifact。
|
||||
- 后续若需完整对齐:push `origin/main` + `release-portal-fast-prod.sh`。
|
||||
|
||||
---
|
||||
|
||||
## `feature/wechat-news-morning-draft-worker`
|
||||
|
||||
**状态:禁止再次引用。已 fast-forward 并入 `origin/main` 并完成 103 Portal + memind_adm 发布。**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { stripPublicationHtmlCspMeta } from './plaza-embed.mjs';
|
||||
import {
|
||||
decorateMindSpaceSeoGeoHtml,
|
||||
} from './mindspace-seo-geo-delivery.mjs';
|
||||
import { stripPublicFileShareWidget } from './mindspace-public-share-widget.mjs';
|
||||
|
||||
const INLINE_SCRIPT_PATTERN = /<script\b(?![^>]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi;
|
||||
|
||||
@@ -24,6 +25,22 @@ export function collectInlineScriptHashes(html) {
|
||||
return hashes;
|
||||
}
|
||||
|
||||
export function shouldSkipPublicShareWidget({
|
||||
htmlFilePath = '',
|
||||
pageUrl = '',
|
||||
relativePath = '',
|
||||
requestPath = '',
|
||||
} = {}) {
|
||||
const haystack = `${htmlFilePath}\n${pageUrl}\n${relativePath}\n${requestPath}`
|
||||
.replace(/\\/g, '/')
|
||||
.toLowerCase();
|
||||
return (
|
||||
haystack.includes('learning-assistant') ||
|
||||
haystack.includes('/public/learn/') ||
|
||||
/\/learn(?:\/|$|[?#])/.test(haystack)
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleMindSpaceLongImageDownload({
|
||||
query,
|
||||
filePath,
|
||||
@@ -59,6 +76,8 @@ export function decorateMindSpacePublishedHtml({
|
||||
pageDataContext = null,
|
||||
context,
|
||||
htmlFilePath = '',
|
||||
relativePath = '',
|
||||
requestPath = '',
|
||||
fileExists = fs.existsSync,
|
||||
userAgent = '',
|
||||
preparePublicationHtmlForEmbed,
|
||||
@@ -97,10 +116,17 @@ export function decorateMindSpacePublishedHtml({
|
||||
if (wechatShare) {
|
||||
nextHtml = injectWechatShareBridge(nextHtml, { pageUrl: context.pageUrl });
|
||||
}
|
||||
const shareInjection = !embed
|
||||
? injectPublicFileShareButton(nextHtml, { isOwner })
|
||||
: { html: nextHtml, scriptHashes: [] };
|
||||
nextHtml = shareInjection.html;
|
||||
const skipShareWidget = !embed && shouldSkipPublicShareWidget({
|
||||
htmlFilePath,
|
||||
pageUrl: context?.pageUrl ?? '',
|
||||
relativePath,
|
||||
requestPath,
|
||||
});
|
||||
if (skipShareWidget) {
|
||||
nextHtml = stripPublicFileShareWidget(nextHtml);
|
||||
} else if (!embed) {
|
||||
nextHtml = injectPublicFileShareButton(nextHtml, { isOwner }).html;
|
||||
}
|
||||
// Retries transient asset-download failures client-side before a page's own onerror
|
||||
// permanently swaps the <img> for a placeholder. See mindspace-public-image-retry.mjs.
|
||||
if (!embed) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
collectInlineScriptHashes,
|
||||
decorateMindSpacePublishedHtml,
|
||||
handleMindSpaceLongImageDownload,
|
||||
shouldSkipPublicShareWidget,
|
||||
} from './mindspace-public-delivery.mjs';
|
||||
|
||||
test('collectInlineScriptHashes returns sha256 hashes for inline scripts only', () => {
|
||||
@@ -173,6 +174,67 @@ test('decorateMindSpacePublishedHtml forwards isOwner=false to the share button
|
||||
assert.equal(sharedIsOwner, false);
|
||||
});
|
||||
|
||||
test('shouldSkipPublicShareWidget matches learning assistant delivery paths', () => {
|
||||
assert.equal(
|
||||
shouldSkipPublicShareWidget({
|
||||
pageUrl: 'https://m.tkmind.cn/MindSpace/u/public/learning-assistant.html',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldSkipPublicShareWidget({
|
||||
htmlFilePath: '/Users/john/Project/Memind/public/learn/child.html',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldSkipPublicShareWidget({
|
||||
relativePath: 'public/learning-assistant.html',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldSkipPublicShareWidget({
|
||||
requestPath: '/MindSpace/u/public/learning-assistant-parent.html',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldSkipPublicShareWidget({
|
||||
pageUrl: 'https://m.tkmind.cn/MindSpace/u/public/report.html',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('decorateMindSpacePublishedHtml skips plaza share widget on learning assistant pages', () => {
|
||||
let shareInjected = false;
|
||||
const baked = `<html><body><h1>Learn</h1><style id="mindspace-public-share-style">x</style><script>x</script></body></html>`;
|
||||
const result = decorateMindSpacePublishedHtml({
|
||||
html: baked,
|
||||
embed: false,
|
||||
htmlFilePath: '/tmp/MindSpace/u/public/learning-assistant.html',
|
||||
context: {
|
||||
origin: 'https://m.tkmind.cn',
|
||||
pageUrl: 'https://m.tkmind.cn/MindSpace/u/public/learning-assistant.html',
|
||||
pageDirUrl: 'https://m.tkmind.cn/MindSpace/u/public/',
|
||||
fallbackImageUrl: '',
|
||||
},
|
||||
preparePublicationHtmlForEmbed: (value) => value,
|
||||
injectOgTags: (value) => value,
|
||||
injectWechatShareBridge: (value) => value,
|
||||
injectPublicFileShareButton: (value) => {
|
||||
shareInjected = true;
|
||||
return { html: `${value}<!--share-->`, scriptHashes: [] };
|
||||
},
|
||||
publishedPageCsp: (value) => value,
|
||||
isWechatUserAgent: () => false,
|
||||
});
|
||||
|
||||
assert.equal(shareInjected, false);
|
||||
assert.doesNotMatch(result.html, /mindspace-public-share/);
|
||||
});
|
||||
|
||||
test('decorateMindSpacePublishedHtml supports embed mode without share injection', () => {
|
||||
const result = decorateMindSpacePublishedHtml({
|
||||
html: '<html></html>',
|
||||
|
||||
@@ -202,7 +202,10 @@ const PUBLIC_FILE_SHARE_WIDGET_BLOCK_PATTERN =
|
||||
/<style id="mindspace-public-share-style">[\s\S]*?<\/script>\s*/i;
|
||||
|
||||
export function stripPublicFileShareWidget(html) {
|
||||
return String(html ?? '').replace(PUBLIC_FILE_SHARE_WIDGET_BLOCK_PATTERN, '');
|
||||
let next = String(html ?? '').replace(PUBLIC_FILE_SHARE_WIDGET_BLOCK_PATTERN, '');
|
||||
next = next.replace(/<div[^>]*data-mindspace-wechat-dialog[\s\S]*?<\/div>\s*/gi, '');
|
||||
next = next.replace(/<div[^>]*data-mindspace-public-share[\s\S]*?<\/div>\s*/gi, '');
|
||||
return next;
|
||||
}
|
||||
|
||||
export function injectPublicFileShareButton(html, { isOwner = true } = {}) {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { injectPublicFileShareButton } from './mindspace-public-share-widget.mjs';
|
||||
import {
|
||||
injectPublicFileShareButton,
|
||||
stripPublicFileShareWidget,
|
||||
} from './mindspace-public-share-widget.mjs';
|
||||
|
||||
test('injectPublicFileShareButton adds plaza entry and confirm dialog', () => {
|
||||
const result = injectPublicFileShareButton('<!doctype html><html><body><h1>Hi</h1></body></html>');
|
||||
@@ -63,3 +66,11 @@ test('injectPublicFileShareButton keeps Plaza entry when isOwner explicitly true
|
||||
assert.match(result.html, /data-action="plaza"/);
|
||||
assert.match(result.html, /data-mindspace-public-share-dialog/);
|
||||
});
|
||||
|
||||
test('stripPublicFileShareWidget removes injected share chrome', () => {
|
||||
const injected = injectPublicFileShareButton('<!doctype html><html><body><h1>Hi</h1></body></html>');
|
||||
const stripped = stripPublicFileShareWidget(injected.html);
|
||||
assert.doesNotMatch(stripped, /data-mindspace-public-share/);
|
||||
assert.doesNotMatch(stripped, /公众号/);
|
||||
assert.doesNotMatch(stripped, /保存长图/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<title>我的学习小助手</title><style>*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;background:#f2f4fa;color:#1c2333;min-height:100vh;padding-bottom:70px;padding-bottom:calc(70px + constant(safe-area-inset-bottom));padding-bottom:calc(70px + env(safe-area-inset-bottom))}
|
||||
.wrap{max-width:560px;margin:0 auto;padding:16px 14px}
|
||||
header.h{padding:16px 2px 4px}
|
||||
h1{font-size:22px;font-weight:700}
|
||||
.sub{font-size:13px;color:#6b7280;margin-top:3px}
|
||||
.card{background:#fff;border-radius:16px;padding:16px;margin-top:12px;box-shadow:0 1px 3px rgba(28,35,51,.06)}
|
||||
.big{font-size:26px;font-weight:700}
|
||||
.bar{height:10px;background:#e8ebf2;border-radius:5px;overflow:hidden;margin-top:8px}
|
||||
.bar i{display:block;height:100%;background:linear-gradient(90deg,#22c55e,#16a34a);border-radius:5px;transition:width .4s}
|
||||
.cat{margin-top:14px}
|
||||
.cat-title{font-size:13px;font-weight:700;padding:3px 10px;border-radius:999px;display:inline-flex;align-items:center;gap:5px}
|
||||
.task{display:flex;align-items:center;gap:12px;padding:12px 2px;border-bottom:1px solid #f0f2f7;cursor:pointer}
|
||||
.task:last-child{border-bottom:none}
|
||||
.task .ck{width:26px;height:26px;border-radius:50%;border:2px solid #cbd5e1;flex:none;display:flex;align-items:center;justify-content:center;font-size:15px;color:#fff}
|
||||
.task.done .ck{background:#16a34a;border-color:#16a34a}
|
||||
.task .info{flex:1;min-width:0}
|
||||
.task .t{font-size:15px;font-weight:600}
|
||||
.task.done .t{color:#9ca3af;text-decoration:line-through}
|
||||
.task .d{font-size:12px;color:#6b7280;margin-top:2px}
|
||||
.task .pt{font-size:12px;color:#f59e0b;font-weight:700;flex:none}
|
||||
.tabbar{position:fixed;left:0;right:0;bottom:0 !important;top:auto !important;background:#fff;border-top:1px solid #e5e8f0;display:flex;z-index:20;padding:6px 0 8px;padding:6px 0 calc(8px + constant(safe-area-inset-bottom));padding:6px 0 calc(8px + env(safe-area-inset-bottom))}
|
||||
.overlay{top:0 !important;left:0 !important;right:0 !important;bottom:0 !important}
|
||||
.tabbar a{flex:1;text-align:center;text-decoration:none;color:#9ca3af;font-size:11px;padding:4px 0}
|
||||
.tabbar a.on{color:#3b5bfd;font-weight:700}
|
||||
.tabbar .ic{font-size:20px;display:block}
|
||||
button{font-family:inherit;border:none;cursor:pointer;border-radius:12px;font-size:14px;font-weight:600;padding:10px 16px;background:#3b5bfd;color:#fff}
|
||||
button:active{transform:scale(.97)}
|
||||
button.ghost{background:#e8edff;color:#3b5bfd}
|
||||
button.danger{background:#ffe9ee;color:#e11d48}
|
||||
button:disabled{opacity:.4;cursor:not-allowed}
|
||||
input,select,textarea{font-family:inherit;width:100%;border:2px solid #e5e8f0;border-radius:10px;padding:10px 12px;font-size:15px;outline:none;background:#fff;color:#1c2333}
|
||||
input:focus,select:focus,textarea:focus{border-color:#3b5bfd}
|
||||
label{display:block;font-size:13px;font-weight:600;margin:12px 0 5px}
|
||||
.overlay{position:fixed;inset:0;background:rgba(20,24,38,.5);z-index:40;display:flex;align-items:center;justify-content:center;padding:20px}
|
||||
.modal{background:#fff;border-radius:18px;padding:24px;width:100%;max-width:360px;max-height:85vh;overflow:auto}
|
||||
.statcheck{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:12px}
|
||||
.sc{background:#fff;border-radius:14px;padding:13px 8px;text-align:center;box-shadow:0 1px 3px rgba(28,35,51,.06)}
|
||||
.sc .n{font-size:20px;font-weight:700}
|
||||
.sc .l{font-size:11px;color:#6b7280;margin-top:2px}
|
||||
.cal{display:grid;grid-template-columns:repeat(7,1fr);gap:6px;margin-top:10px}
|
||||
.cal .w{text-align:center;font-size:11px;color:#9ca3af;padding:4px 0}
|
||||
.cal .cell{text-align:center;padding:7px 0;border-radius:10px;font-size:14px;position:relative}
|
||||
.cal .cell.has{font-weight:700}
|
||||
.cal .cell.g{background:#e7f8ee;color:#16a34a}.cal .cell.y{background:#fff4dd;color:#d97706}.cal .cell.r{background:#ffe9ee;color:#e11d48}.cal .cell.n{color:#c3c9d6}
|
||||
.cal .today{outline:2px solid #3b5bfd}
|
||||
.legend{display:flex;gap:12px;font-size:11px;color:#6b7280;margin-top:10px;flex-wrap:wrap}
|
||||
.dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:4px;vertical-align:-1px}
|
||||
.feed{position:fixed;left:50%;top:38%;transform:translate(-50%,-50%);background:#fff;border-radius:20px;padding:28px 30px;text-align:center;z-index:50;box-shadow:0 10px 40px rgba(28,35,51,.25);animation:pop .3s}
|
||||
@keyframes pop{from{transform:translate(-50%,-50%) scale(.8);opacity:0}to{transform:translate(-50%,-50%) scale(1);opacity:1}}
|
||||
.feed .em{font-size:44px}.feed .t1{font-size:18px;font-weight:700;margin-top:8px}.feed .t2{font-size:13px;color:#6b7280;margin-top:6px;line-height:1.6}
|
||||
.feed .pt{color:#f59e0b;font-weight:700;margin-top:8px;font-size:15px}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
th,td{padding:8px 6px;border-bottom:1px solid #f0f2f7;text-align:left}
|
||||
th{font-size:11px;color:#6b7280}
|
||||
.row{display:flex;gap:10px;align-items:center}
|
||||
.row>*{flex:1}
|
||||
.pw{height:8px;background:#e8ebf2;border-radius:4px;overflow:hidden;min-width:60px}
|
||||
.pw i{display:block;height:100%;background:#3b5bfd}
|
||||
.chip{display:inline-flex;align-items:center;gap:6px;padding:6px 14px;border-radius:999px;border:2px solid #e5e8f0;font-size:13px;cursor:pointer;margin:0 8px 8px 0;background:#fff}
|
||||
.chip.on{border-color:#3b5bfd;background:#e8edff;color:#3b5bfd;font-weight:700}
|
||||
.hbtn{display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:600;color:#3b5bfd;background:#e8edff;padding:6px 13px;border-radius:999px;text-decoration:none;cursor:pointer;flex:none;white-space:nowrap}
|
||||
.hbtn:active{transform:scale(.95)}
|
||||
.err{background:#ffe9ee;color:#e11d48;padding:10px;border-radius:10px;font-size:13px;margin-top:10px;display:none}
|
||||
.tabs{display:flex;gap:8px;margin-top:14px}
|
||||
.tabs button{flex:1;background:#e8ebf2;color:#6b7280}
|
||||
.tabs button.on{background:#3b5bfd;color:#fff}
|
||||
.empty{text-align:center;color:#9ca3af;font-size:13px;padding:18px 0;line-height:1.7}
|
||||
[data-mindspace-public-share],[data-mindspace-wechat-dialog],.publication-share-fab,.publication-share-sheet{display:none!important;visibility:hidden!important}</style></head><body>
|
||||
<div class="wrap" id="app"></div><div class="tabbar" id="tabbar"></div>
|
||||
<div style="text-align:center;font-size:10px;color:#c3c9d6;padding:6px 0 76px">v2</div>
|
||||
<script>
|
||||
var FAMILY_SLUG=new URLSearchParams(location.search).get('f')||'tang';
|
||||
var API_BASE='/api/learn/public/'+encodeURIComponent(FAMILY_SLUG);
|
||||
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,'<').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='<div class="card"><h1>我是谁呀?</h1>';state.children.forEach(function(x){s+='<div class="chip" style="margin-top:12px;font-size:16px;padding:12px 20px" onclick="chooseChild('+x.id+')">'+esc(x.emoji)+' '+esc(x.name)+'</div>'});s+='</div>';return s}return c.id}
|
||||
function chooseChild(id){localStorage.setItem('learn_child',String(id));location.reload()}
|
||||
function showSwitcher(){var s='<div class="overlay" onclick="if(event.target===this)this.remove()"><div class="modal"><h3 style="font-size:17px">👤 切换学员</h3><div style="margin-top:8px">';state.children.forEach(function(x){s+='<div class="chip" style="width:100%;justify-content:center;margin:8px 0 0;font-size:16px;padding:12px 20px'+(x.id==state.child?';border-color:#3b5bfd;background:#e8edff;font-weight:700':'')+'" onclick="chooseChild('+x.id+')">'+esc(x.emoji)+' '+esc(x.name)+(x.id==state.child?' ✓':'')+'</div>'});s+='</div><button class="ghost" style="margin-top:16px;width:100%" onclick="this.closest(\'.overlay\').remove()">取消</button></div></div>';document.body.insertAdjacentHTML('beforeend',s)}
|
||||
function hdrActions(){var cur=state.children.find(function(x){return x.id==state.child});return '<div style="display:flex;gap:8px;flex:none;padding-top:6px"><span class="hbtn" onclick="showSwitcher()">⇄ '+(cur?'切换学员':'学员')+'</span><a class="hbtn" href="' + 'parent.html?f=' + FAMILY_SLUG + '">家长端</a></div>'}
|
||||
function greet(){var h=new Date().getHours();return h<11?'早上好':h<14?'中午好':h<18?'下午好':'晚上好'}
|
||||
function pad(n){return n<10?'0'+n:''+n}
|
||||
function wdCN(d){return ['日','一','二','三','四','五','六'][d]}
|
||||
function dowOf(ds){return new Date(Date.UTC(+ds.slice(0,4),+ds.slice(5,7)-1,+ds.slice(8,10))).getUTCDay()}
|
||||
function renderToday(){var d=state.data;var p=d.total?Math.round(d.done/d.total*100):0;var s='<header class="h"><div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px"><div><h1>'+greet()+','+esc(d.child.name)+' '+esc(d.child.emoji)+'</h1><div class="sub">'+d.date.replace(/-/g,'/')+' 周'+wdCN(dowOf(d.date))+'</div></div>'+hdrActions()+'</div></header><div class="card"><div class="row" style="align-items:baseline"><div><div class="big">'+d.done+' / '+d.total+'</div><div class="sub">今天已完成</div></div><div style="text-align:right"><div class="big" style="color:#f59e0b">⭐ '+d.energy+'</div><div class="sub">我的能量</div></div></div><div class="bar"><i style="width:'+p+'%"></i></div>'+(d.total&&d.done===d.total?'<div style="text-align:center;margin-top:10px;font-size:15px;font-weight:700;color:#16a34a">⭐ 今天全部完成,太棒啦!</div>':'')+'</div>';var byCat={},order=[];d.tasks.forEach(function(t){var k=t.category_name||'其他';if(!byCat[k]){byCat[k]={color:t.category_color||'#6b7280',list:[]};order.push(k)}byCat[k].list.push(t)});if(!d.tasks.length)s+='<div class="card"><div class="empty">今天没有任务<br>好好休息,或找爸爸妈妈安排~</div></div>';order.forEach(function(k){s+='<div class="cat"><span class="cat-title" style="background:'+byCat[k].color+'1a;color:'+byCat[k].color+'">'+esc(k)+'</span><div class="card">';byCat[k].list.forEach(function(t){var needVal=(t.task_type!=='check');var sub=[];if(t.description)sub.push(esc(t.description));if(needVal)sub.push('目标:'+esc(t.target_value||'')+(t.unit?esc(t.unit):''));if(t.start_date)sub.push('📅 '+t.start_date.replace(/-/g,'/')+' 开始');if(t.status==='done'){sub.unshift('✓ '+(t.completed_time||'')+' 完成'+(needVal&&t.value_submitted?','+esc(t.value_submitted)+(t.unit?esc(t.unit):''):''));}s+='<div class="task'+(t.status==='done'?' done':'')+'" onclick="doTask('+t.id+')"><div class="ck"'+(t.status==='pending'?' style="border-color:#e11d48"':'')+'>'+(t.status==='done'?'✓':'')+'</div><div class="info"><div class="t">'+esc(t.title)+'</div>'+(sub.length?'<div class="d">'+sub.join('<br>')+'</div>':'')+'</div><div class="pt">+'+t.points+'</div></div>'});s+='</div></div>'});if(d.streak>0)s+='<div class="card" style="text-align:center"><span style="font-size:15px">🔥 连续打卡 <b style="color:#f59e0b">'+d.streak+'</b> 天,继续保持!</span></div>';return s}
|
||||
function renderCal(){var m=state.cal.month;var first=m+'-01';var dim=new Date(Date.UTC(+m.slice(0,4),+m.slice(5,7),0)).getUTCDate();var lead=dowOf(first);var map={};state.cal.days.forEach(function(x){map[x.date]=x});var s='<header class="h"><div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px"><div><h1>我的日历 📅</h1><div class="sub">'+m.replace('-',' 年 ')+' 月</div></div>'+hdrActions()+'</div></header><div class="card"><div style="display:flex;justify-content:space-between;align-items:center"><button class="ghost" style="padding:6px 14px" onclick="calShift(-1)">← 上月</button><button class="ghost" style="padding:6px 14px" onclick="calShift(1)">下月 →</button></div><div class="cal">';['日','一','二','三','四','五','六'].forEach(function(w){s+='<div class="w">'+w+'</div>'});for(var i=0;i<lead;i++)s+='<div></div>';var today=new Date(Date.now()+8*3600e3).toISOString().slice(0,10);for(var d=1;d<=dim;d++){var ds=m+'-'+pad(d);var st=map[ds];var cls='cell ';var mark='';if(st){cls+=st.status==='green'?'g':st.status==='yellow'?'y':st.status==='red'?'r':'n';if(st.total){cls+=' has';if(st.status==='red')mark='<i style="position:absolute;top:4px;right:5px;width:7px;height:7px;border-radius:50%;background:#e11d48;box-shadow:0 0 4px #e11d48"></i>';else if(st.status==='yellow')mark='<i style="position:absolute;top:4px;right:5px;width:7px;height:7px;border-radius:50%;background:#f59e0b"></i>';}}if(ds===today)cls+=' today';s+='<div class="'+cls+'" style="position:relative;'+(st&&st.status==='red'?'box-shadow:0 0 0 2px #e11d48 inset':'')+'" onclick="showDay(\''+ds+'\')">'+d+mark+'</div>'}s+='</div><div class="legend"><span><i class="dot" style="background:#16a34a"></i>全部完成</span><span><i class="dot" style="background:#f59e0b"></i>部分完成</span><span><i class="dot" style="background:#e11d48"></i>有遗漏(红点)</span><span><i class="dot" style="background:#c3c9d6"></i>无任务</span><div style="width:100%;text-align:center;margin-top:4px;color:#3b5bfd">点任意日期查看当天详情</div></div></div><div id="dayDetail"></div>';return s}
|
||||
async function showDay(ds){var j=await api('/day?child='+state.child+'&date='+ds);var el=document.getElementById('dayDetail');var isToday=ds===new Date(Date.now()+8*3600e3).toISOString().slice(0,10);var stTxt='';var stColor='';var done=j.tasks.filter(function(t){return t.status==='done'}).length;if(j.tasks.length){if(done===j.tasks.length){stTxt='全部完成 ✓';stColor='#16a34a'}else if(done>0){stTxt='完成 '+done+'/'+j.tasks.length+(ds<new Date(Date.now()+8*3600e3).toISOString().slice(0,10)?',有遗漏':'');stColor=ds<new Date(Date.now()+8*3600e3).toISOString().slice(0,10)?'#e11d48':'#d97706'}else{stTxt=ds<new Date(Date.now()+8*3600e3).toISOString().slice(0,10)?'全部未完成 ✕':'待完成';stColor=ds<new Date(Date.now()+8*3600e3).toISOString().slice(0,10)?'#e11d48':'#d97706'}}var s='<div class="card"><div class="row" style="align-items:center;margin-bottom:6px"><b style="font-size:15px;flex:1">'+ds.replace(/-/g,'/')+' 周'+wdCN(dowOf(ds))+(isToday?'(今天)':'')+'</b>'+(stTxt?'<span style="font-size:12px;font-weight:700;color:'+stColor+'">'+stTxt+'</span>':'')+'</div>';if(!j.tasks.length)s+='<div class="empty">那天没有任务</div>';j.tasks.forEach(function(t){var overdue=t.status==='pending'&&ds<new Date(Date.now()+8*3600e3).toISOString().slice(0,10);s+='<div class="task'+(t.status==='done'?' done':'')+'"><div class="ck"'+(overdue?' style="border-color:#e11d48;color:#e11d48;font-size:12px"':'')+'>'+(t.status==='done'?'✓':(overdue?'✕':''))+'</div><div class="info"><div class="t">'+esc(t.title)+'</div><div class="d"'+((overdue)?' style="color:#e11d48"':'')+'>'+esc(t.category_name||'')+(t.status==='done'?' · '+(t.completed_time||'')+' 完成':(overdue?' · 未完成':' · 待完成')+(t.value_submitted?' · '+esc(t.value_submitted)+(t.unit||''):''))+'</div></div></div>'});if(j.tasks.length)s+='<div class="sub" style="margin-top:10px">完成率:'+Math.round(done/j.tasks.length*100)+'%</div>';s+='</div>';el.innerHTML=s}
|
||||
function renderGrow(){var g=state.grow;var s='<header class="h"><div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px"><div><h1>我的成长 🌱</h1></div>'+hdrActions()+'</div></header><div class="statcheck"><div class="sc"><div class="n" style="color:#f59e0b">⭐ '+g.energy+'</div><div class="l">我的能量</div></div><div class="sc"><div class="n" style="color:#ef4444">🔥 '+g.streak+'</div><div class="l">连续打卡</div></div><div class="sc"><div class="n">'+g.totalDone+'</div><div class="l">累计完成</div></div></div><div class="card"><div class="sub" style="margin-bottom:6px">💪 历史最长连续:<b>'+g.best+'</b> 天 | 全勤天数:<b>'+g.fullDays+'</b> 天</div></div><div class="card"><div class="t" style="font-weight:700;font-size:15px;margin-bottom:8px">近 30 天分类表现</div>';if(!g.byCategory.length)s+='<div class="empty">还没有记录,快去完成任务吧!</div>';g.byCategory.forEach(function(c){var p=c.total?Math.round(c.done/c.total*100):0;s+='<div style="margin:10px 0"><div class="row" style="align-items:center"><span style="flex:0 0 64px;font-size:13px;font-weight:600">'+esc(c.name)+'</span><div class="pw" style="flex:1"><i style="width:'+p+'%"></i></div><span style="flex:0 0 40px;text-align:right;font-size:12px;color:#6b7280">'+p+'%</span></div></div>'});s+='</div>';return s}
|
||||
function renderHist(){var h=state.hist;var today=new Date(Date.now()+8*3600e3).toISOString().slice(0,10);var s='<header class="h"><div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px"><div><h1>我的记录 📖</h1><div class="sub">每天的任务和完成情况,都记在这里'+(h.cycleStart?'<br>本期任务从 <b style="color:#3b5bfd">'+h.cycleStart.replace(/-/g,'/')+'</b> 开始':'')+'</div></div>'+hdrActions()+'</div></header>';if(!h.data.length)s+='<div class="card"><div class="empty">还没有记录<br>完成今天的任务,就会出现在这里哦</div></div>';var daysAsc=h.data.slice().reverse();daysAsc.forEach(function(day,idx){var label=day.date===today?'今天':(day.date===addDaysLocal(today,-1)?'昨天':(parseInt(day.date.slice(5),10))+'月'+parseInt(day.date.slice(8),10)+'日');var w='周'+wdCN(dowOf(day.date));var stTxt=day.status==='green'?'全部完成':(day.done>0?'完成 '+day.done+'/'+day.total:(day.date<today?'有遗漏':'待完成'));var stColor=day.status==='green'?'#16a34a':day.status==='red'?'#e11d48':'#d97706';var stBg=day.status==='green'?'#e7f8ee':day.status==='red'?'#ffe9ee':'#fff4dd';var open=day.date===today;var bodyId='hb'+idx;var dot=day.status==='green'?'🟢':(day.status==='red'?'🔴':'🟡');s+='<div class="card"><div class="row" style="align-items:center;cursor:pointer" onclick="toggleDay(\''+bodyId+'\',this)"><div style="flex:1;cursor:pointer"><span style="font-size:16px;font-weight:700;cursor:pointer">'+label+'</span> <span class="sub">'+w+' '+dot+'</span></div><span style="font-size:12px;font-weight:700;color:'+stColor+';background:'+stBg+';padding:3px 10px;border-radius:999px;cursor:pointer">'+stTxt+'</span><span style="flex:0 0 16px;text-align:right;color:#9ca3af;font-size:12px;cursor:pointer" class="arr">'+(open?'▾':'▸')+'</span></div><div id="'+bodyId+'" style="'+(open?'':'display:none')+';margin-top:4px">';day.tasks.forEach(function(t){var sub=[];if(t.status==='done')sub.push('✓ '+(t.completed_time||'')+' 完成'+(t.task_type!=='check'&&t.value_submitted?','+esc(t.value_submitted)+(t.unit?esc(t.unit):''):''));else if(t.status==='pending'&&day.date<today)sub.push('✕ 未完成');else sub.push('待完成');if(t.remark)sub.push('📝 '+esc(t.remark));s+='<div class="task'+(t.status==='done'?' done':'')+'"><div class="ck">'+(t.status==='done'?'✓':(t.status==='pending'&&day.date<today?'✕':''))+'</div><div class="info"><div class="t">'+esc(t.title)+'</div><div class="d">'+esc(t.category_name||'')+' · '+sub.join(' · ')+'</div></div></div>'});s+='</div></div>'});return s}
|
||||
function toggleDay(id,el){var b=document.getElementById(id);var arr=el.querySelector('.arr')||el.parentElement.querySelector('.arr');if(b.style.display==='none'){b.style.display='block';if(arr)arr.textContent='▾'}else{b.style.display='none';if(arr)arr.textContent='▸'}}
|
||||
function addDaysLocal(ds,n){var d=new Date(ds+'T00:00:00Z');d.setUTCDate(d.getUTCDate()+n);return d.toISOString().slice(0,10)}
|
||||
function renderTabbar(){var t=[['today','☀️','今日'],['hist','📖','记录'],['cal','📅','日历'],['grow','🌱','成长']];return t.map(function(x){return '<a href="javascript:void(0)" class="'+(state.tab===x[0]?'on':'')+'" onclick="go(\''+x[0]+'\')"><span class="ic">'+x[1]+'</span>'+x[2]+'</a>'}).join('')}
|
||||
async function go(tab){state.tab=tab;await load();render()}
|
||||
async function load(){if(!state.children.length){state.children=(await api('/children')).data}if(state.child==null){var c=pickChild();if(typeof c!=='number')return;state.child=c}if(state.child==null)return;if(state.tab==='today')state.data=await api('/today?child='+state.child);if(state.tab==='hist')state.hist=await api('/history?child='+state.child);if(state.tab==='cal'&&!state.cal)state.cal=await api('/calendar?child='+state.child);if(state.tab==='grow')state.grow=await api('/growth?child='+state.child)}
|
||||
function render(){var app=document.getElementById('app');if(!state.children.length){app.innerHTML='<div class="card"><div class="empty">还没有小朋友的信息<br>请爸爸妈妈先去 <a href="' + 'parent.html?f=' + FAMILY_SLUG + '" style="color:#3b5bfd">家长后台</a> 添加</div></div>';document.getElementById('tabbar').innerHTML='';return}if(state.child==null){app.innerHTML=pickChild();document.getElementById('tabbar').innerHTML='';return}var s=state.tab==='today'?renderToday():state.tab==='hist'?renderHist():state.tab==='cal'?renderCal():renderGrow();app.innerHTML=s;document.getElementById('tabbar').innerHTML=renderTabbar()}
|
||||
async function calShift(n){var m=state.cal.month.split('-');var y=+m[0],mo=+m[1]+n;if(mo<1){mo=12;y--}if(mo>12){mo=1;y++}state.cal=await api('/calendar?child='+state.child+'&month='+y+'-'+pad(mo));render()}
|
||||
function doTask(id){var t=null;state.data.tasks.forEach(function(x){if(x.id===id)t=x});if(!t)(state.data.pastPending||[]).forEach(function(x){if(x.id===id)t=x});if(!t)return;if(t.status==='done')return;if(t.task_type==='check'){submit(id)}else{var unit=t.unit||'';var label=t.task_type==='count'?'完成了多少'+(unit?esc(unit):''):(t.task_type==='duration'?'坚持了多少'+(unit||'分钟'):'得了多少分');var def=t.value_submitted||(t.task_type==='duration'?(t.target_value||''):(t.target_value||''));document.getElementById('app').insertAdjacentHTML('beforeend','<div class="overlay" onclick="if(event.target===this)this.remove()"><div class="modal"><h3 style="font-size:17px">✏️ '+esc(t.title)+'</h3><label>'+label+'</label><input id="vInput" inputmode="numeric" value="'+esc(def)+'" placeholder="填一个数字吧"><label>想说的悄悄话(选填)</label><textarea id="vRemark" rows="2" style="font-size:14px"></textarea><div class="row" style="margin-top:16px"><button class="ghost" onclick="this.closest(\'.overlay\').remove()">再等等</button><button onclick="submitVal('+id+')">✓ 完成打卡</button></div></div></div>');var inp=document.getElementById('vInput');if(inp)inp.focus()}}
|
||||
function submitVal(id){var v=document.getElementById('vInput').value;var r=document.getElementById('vRemark').value;submit(id,v,r)}
|
||||
async function submit(id,value,remark){try{var j=await api('/checkin',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:id,value:value,remark:remark})});var s='<div class="feed" onclick="this.remove()"><div class="em">🎉</div><div class="t1">完成啦!</div><div class="t2">今天已经完成 <b>'+j.done+'</b> / '+j.total+' 项<br>'+(j.allDone?'⭐ 太棒了,今天全部完成!':'再完成 '+(j.total-j.done)+' 项,今天就是全绿!')+'</div><div class="pt">+'+j.points+' 能量</div><div class="t2" style="margin-top:12px;font-size:11px;color:#c3c9d6">点一下继续</div></div>';document.body.insertAdjacentHTML('beforeend',s);await load();render()}catch(e){alert(e.message)}}
|
||||
(async function(){try{await load();render()}catch(e){document.getElementById('app').innerHTML='<div class="card"><div class="empty">'+esc(e.message)+'</div></div>'}})();
|
||||
</script></body></html>
|
||||
@@ -0,0 +1,5 @@
|
||||
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>学习小助手</title>
|
||||
<style>body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;background:#f2f4fa;padding:24px;text-align:center}.card{max-width:480px;margin:40px auto;background:#fff;border-radius:16px;padding:28px}a{display:inline-block;margin:8px;padding:12px 18px;border-radius:999px;background:#3b5bfd;color:#fff;text-decoration:none;font-weight:600}</style></head><body>
|
||||
<div class="card"><h1>学习小助手</h1><p>家长注册 Memind 后配置任务;孩子打开链接打卡。</p>
|
||||
<a href="/learn/parent.html">家长后台</a><a href="/learn/child.html?f=tang" style="background:#e8edff;color:#3b5bfd">儿童端示例</a>
|
||||
<a href="/?return_to=%2Flearn%2Fparent.html" style="background:#16a34a">登录 / 注册 Memind</a></div></body></html>
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>加入家庭</title>
|
||||
<style>body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;background:#f2f4fa;padding:24px}.card{max-width:420px;margin:40px auto;background:#fff;border-radius:16px;padding:24px;box-shadow:0 1px 3px rgba(0,0,0,.08)}button,a.btn{display:block;width:100%;text-align:center;padding:12px;border-radius:12px;border:none;font-weight:600;text-decoration:none;margin-top:10px}button{background:#3b5bfd;color:#fff}a.btn{background:#e8edff;color:#3b5bfd}</style></head><body>
|
||||
<div class="card"><h1>加入学习家庭</h1><p id="msg" class="sub">正在处理邀请…</p><a class="btn" id="login" href="/" style="display:none">登录 / 注册 Memind</a></div>
|
||||
<script>
|
||||
var token=new URLSearchParams(location.search).get('token');
|
||||
(async function(){
|
||||
var msg=document.getElementById('msg');
|
||||
if(!token){msg.textContent='邀请链接无效';return}
|
||||
var st=await fetch('/auth/status',{credentials:'include'}).then(function(r){return r.json()});
|
||||
if(!st.authenticated||!st.user){msg.textContent='请先登录 Memind,再加入家庭';document.getElementById('login').href='/?return_to='+encodeURIComponent(location.pathname+location.search);document.getElementById('login').style.display='block';return}
|
||||
try{
|
||||
var j=await fetch('/api/learn/me/join',{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:token})}).then(function(r){return r.json()});
|
||||
if(j.error)throw new Error(j.error);
|
||||
msg.textContent='已加入「'+j.family.name+'」,正在进入家长后台…';
|
||||
location.href='/learn/parent.html?f='+encodeURIComponent(j.family.slug);
|
||||
}catch(e){msg.textContent=e.message}
|
||||
})();
|
||||
</script></body></html>
|
||||
@@ -0,0 +1,127 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<title>家长后台 · 学习小助手</title><style>*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;background:#f2f4fa;color:#1c2333;min-height:100vh;padding-bottom:70px;padding-bottom:calc(70px + constant(safe-area-inset-bottom));padding-bottom:calc(70px + env(safe-area-inset-bottom))}
|
||||
.wrap{max-width:560px;margin:0 auto;padding:16px 14px}
|
||||
header.h{padding:16px 2px 4px}
|
||||
h1{font-size:22px;font-weight:700}
|
||||
.sub{font-size:13px;color:#6b7280;margin-top:3px}
|
||||
.card{background:#fff;border-radius:16px;padding:16px;margin-top:12px;box-shadow:0 1px 3px rgba(28,35,51,.06)}
|
||||
.big{font-size:26px;font-weight:700}
|
||||
.bar{height:10px;background:#e8ebf2;border-radius:5px;overflow:hidden;margin-top:8px}
|
||||
.bar i{display:block;height:100%;background:linear-gradient(90deg,#22c55e,#16a34a);border-radius:5px;transition:width .4s}
|
||||
.cat{margin-top:14px}
|
||||
.cat-title{font-size:13px;font-weight:700;padding:3px 10px;border-radius:999px;display:inline-flex;align-items:center;gap:5px}
|
||||
.task{display:flex;align-items:center;gap:12px;padding:12px 2px;border-bottom:1px solid #f0f2f7;cursor:pointer}
|
||||
.task:last-child{border-bottom:none}
|
||||
.task .ck{width:26px;height:26px;border-radius:50%;border:2px solid #cbd5e1;flex:none;display:flex;align-items:center;justify-content:center;font-size:15px;color:#fff}
|
||||
.task.done .ck{background:#16a34a;border-color:#16a34a}
|
||||
.task .info{flex:1;min-width:0}
|
||||
.task .t{font-size:15px;font-weight:600}
|
||||
.task.done .t{color:#9ca3af;text-decoration:line-through}
|
||||
.task .d{font-size:12px;color:#6b7280;margin-top:2px}
|
||||
.task .pt{font-size:12px;color:#f59e0b;font-weight:700;flex:none}
|
||||
.tabbar{position:fixed;left:0;right:0;bottom:0 !important;top:auto !important;background:#fff;border-top:1px solid #e5e8f0;display:flex;z-index:20;padding:6px 0 8px;padding:6px 0 calc(8px + constant(safe-area-inset-bottom));padding:6px 0 calc(8px + env(safe-area-inset-bottom))}
|
||||
.overlay{top:0 !important;left:0 !important;right:0 !important;bottom:0 !important}
|
||||
.tabbar a{flex:1;text-align:center;text-decoration:none;color:#9ca3af;font-size:11px;padding:4px 0}
|
||||
.tabbar a.on{color:#3b5bfd;font-weight:700}
|
||||
.tabbar .ic{font-size:20px;display:block}
|
||||
button{font-family:inherit;border:none;cursor:pointer;border-radius:12px;font-size:14px;font-weight:600;padding:10px 16px;background:#3b5bfd;color:#fff}
|
||||
button:active{transform:scale(.97)}
|
||||
button.ghost{background:#e8edff;color:#3b5bfd}
|
||||
button.danger{background:#ffe9ee;color:#e11d48}
|
||||
button:disabled{opacity:.4;cursor:not-allowed}
|
||||
input,select,textarea{font-family:inherit;width:100%;border:2px solid #e5e8f0;border-radius:10px;padding:10px 12px;font-size:15px;outline:none;background:#fff;color:#1c2333}
|
||||
input:focus,select:focus,textarea:focus{border-color:#3b5bfd}
|
||||
label{display:block;font-size:13px;font-weight:600;margin:12px 0 5px}
|
||||
.overlay{position:fixed;inset:0;background:rgba(20,24,38,.5);z-index:40;display:flex;align-items:center;justify-content:center;padding:20px}
|
||||
.modal{background:#fff;border-radius:18px;padding:24px;width:100%;max-width:360px;max-height:85vh;overflow:auto}
|
||||
.statcheck{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:12px}
|
||||
.sc{background:#fff;border-radius:14px;padding:13px 8px;text-align:center;box-shadow:0 1px 3px rgba(28,35,51,.06)}
|
||||
.sc .n{font-size:20px;font-weight:700}
|
||||
.sc .l{font-size:11px;color:#6b7280;margin-top:2px}
|
||||
.cal{display:grid;grid-template-columns:repeat(7,1fr);gap:6px;margin-top:10px}
|
||||
.cal .w{text-align:center;font-size:11px;color:#9ca3af;padding:4px 0}
|
||||
.cal .cell{text-align:center;padding:7px 0;border-radius:10px;font-size:14px;position:relative}
|
||||
.cal .cell.has{font-weight:700}
|
||||
.cal .cell.g{background:#e7f8ee;color:#16a34a}.cal .cell.y{background:#fff4dd;color:#d97706}.cal .cell.r{background:#ffe9ee;color:#e11d48}.cal .cell.n{color:#c3c9d6}
|
||||
.cal .today{outline:2px solid #3b5bfd}
|
||||
.legend{display:flex;gap:12px;font-size:11px;color:#6b7280;margin-top:10px;flex-wrap:wrap}
|
||||
.dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:4px;vertical-align:-1px}
|
||||
.feed{position:fixed;left:50%;top:38%;transform:translate(-50%,-50%);background:#fff;border-radius:20px;padding:28px 30px;text-align:center;z-index:50;box-shadow:0 10px 40px rgba(28,35,51,.25);animation:pop .3s}
|
||||
@keyframes pop{from{transform:translate(-50%,-50%) scale(.8);opacity:0}to{transform:translate(-50%,-50%) scale(1);opacity:1}}
|
||||
.feed .em{font-size:44px}.feed .t1{font-size:18px;font-weight:700;margin-top:8px}.feed .t2{font-size:13px;color:#6b7280;margin-top:6px;line-height:1.6}
|
||||
.feed .pt{color:#f59e0b;font-weight:700;margin-top:8px;font-size:15px}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
th,td{padding:8px 6px;border-bottom:1px solid #f0f2f7;text-align:left}
|
||||
th{font-size:11px;color:#6b7280}
|
||||
.row{display:flex;gap:10px;align-items:center}
|
||||
.row>*{flex:1}
|
||||
.pw{height:8px;background:#e8ebf2;border-radius:4px;overflow:hidden;min-width:60px}
|
||||
.pw i{display:block;height:100%;background:#3b5bfd}
|
||||
.chip{display:inline-flex;align-items:center;gap:6px;padding:6px 14px;border-radius:999px;border:2px solid #e5e8f0;font-size:13px;cursor:pointer;margin:0 8px 8px 0;background:#fff}
|
||||
.chip.on{border-color:#3b5bfd;background:#e8edff;color:#3b5bfd;font-weight:700}
|
||||
.hbtn{display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:600;color:#3b5bfd;background:#e8edff;padding:6px 13px;border-radius:999px;text-decoration:none;cursor:pointer;flex:none;white-space:nowrap}
|
||||
.hbtn:active{transform:scale(.95)}
|
||||
.err{background:#ffe9ee;color:#e11d48;padding:10px;border-radius:10px;font-size:13px;margin-top:10px;display:none}
|
||||
.tabs{display:flex;gap:8px;margin-top:14px}
|
||||
.tabs button{flex:1;background:#e8ebf2;color:#6b7280}
|
||||
.tabs button.on{background:#3b5bfd;color:#fff}
|
||||
.empty{text-align:center;color:#9ca3af;font-size:13px;padding:18px 0;line-height:1.7}
|
||||
[data-mindspace-public-share],[data-mindspace-wechat-dialog],.publication-share-fab,.publication-share-sheet{display:none!important;visibility:hidden!important}body{padding-bottom:30px}.tabs{position:sticky;top:0;background:#f2f4fa;padding-top:2px;z-index:10}.pl{max-width:720px}</style></head><body>
|
||||
<div class="wrap pl" id="app"></div>
|
||||
<script>
|
||||
var FAMILY_SLUG=new URLSearchParams(location.search).get('f')||'';
|
||||
var API_BASE='/api/learn/me';
|
||||
var state={tab:'dash',stats:null,templates:null,categories:null,children:null,family:null,role:null,invite:null};
|
||||
function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}
|
||||
async function api(p,opt){opt=opt||{};opt.credentials='include';opt.headers=Object.assign({'Content-Type':'application/json'},opt.headers||{});var r=await fetch(API_BASE+p,opt);var j=await r.json().catch(function(){return{}});if(!r.ok)throw new Error(j.error||j.message||'请求失败');return j}
|
||||
function pad(n){return n<10?'0'+n:''+n}
|
||||
var WD=['一','二','三','四','五','六','日'];
|
||||
function weekdaysCN(w){return w.split(',').map(function(x){return WD[+x-1]}).join('、')}
|
||||
var TYPE={check:'完成型',count:'数量型',duration:'时长型',score:'成绩型'};
|
||||
function childUrl(){return '/learn/child.html?f='+(state.family?state.family.slug:FAMILY_SLUG||'tang')}
|
||||
function memindAuthUrl(){
|
||||
return '/?return_to='+encodeURIComponent(location.pathname+location.search);
|
||||
}
|
||||
function renderAuth(){
|
||||
return '<header class="h"><h1>家长后台 🔐</h1><div class="sub">登录 Memind 后管理孩子任务;支持邀请爸爸/妈妈共同管理</div></header>'
|
||||
+'<div class="card"><p class="sub" style="line-height:1.8;margin-bottom:12px">请通过 Memind 首页登录或注册(不要用 /auth/login 直链)。登录成功后会自动回到本页。</p>'
|
||||
+'<a href="'+memindAuthUrl()+'" style="display:block;text-align:center;padding:12px;border-radius:12px;background:#3b5bfd;color:#fff;text-decoration:none;font-weight:600;margin-bottom:10px">登录 / 注册 Memind</a>'
|
||||
+'<div class="sub" style="text-align:center;margin-top:14px"><a href="'+childUrl()+'" style="color:#3b5bfd">← 返回小朋友页面</a></div></div>';
|
||||
}
|
||||
async function ensureFamily(){
|
||||
var info=await api('/family');
|
||||
state.family=info.family;state.role=info.role;return info;
|
||||
}
|
||||
function renderInvite(){
|
||||
if(state.role!=='owner')return '';
|
||||
var s='<div class="card"><div class="t" style="font-weight:700;font-size:15px;margin-bottom:8px">👨👩👧 邀请协家长</div><div class="sub" style="line-height:1.7">把链接发给爸爸/妈妈,对方注册 Memind 后打开即可加入同一家庭。</div>';
|
||||
if(state.invite&&state.invite.joinUrl){s+='<div class="sub" style="margin-top:10px;word-break:break-all;background:#f8fafc;padding:10px;border-radius:10px">'+esc(state.invite.joinUrl)+'</div>'}
|
||||
s+='<button style="width:100%;margin-top:12px" onclick="createInvite()">生成邀请链接</button></div>';
|
||||
return s;
|
||||
}
|
||||
async function createInvite(){state.invite=await api('/invite',{method:'POST'});render()}
|
||||
function renderSet(){
|
||||
return '<header class="h"><h1>设置 ⚙️</h1></header>'
|
||||
+renderInvite()
|
||||
+'<div class="card"><div class="sub" style="line-height:1.8">· 儿童端:<a href="'+childUrl()+'" style="color:#3b5bfd">'+esc(childUrl())+'</a><br>· 家庭 slug:<b>'+esc(state.family.slug)+'</b><br>· 你的角色:<b>'+(state.role==='owner'?'主家长':'协家长')+'</b><br>· 成员:'+state.family.members.map(function(m){return esc(m.displayName||m.userId.slice(0,8))+'('+(m.role==='owner'?'主':'协')+')'}).join('、')+'</div></div>';
|
||||
}
|
||||
|
||||
function renderLogin(){return renderAuth()}
|
||||
async function login(){await ensureFamily();await loadAll();render()}
|
||||
function renderDash(){var s=state.stats;var tp=s.today.total?Math.round(s.today.done/s.today.total*100):0;var wp=s.week.total?Math.round(s.week.done/s.week.total*100):0;var st=s.children.length?s.children[0].streak:0;var html='<header class="h"><h1>数据看板 📊</h1><div class="sub">'+s.children.map(function(c){return esc(c.emoji)+' '+esc(c.name)+' 连续 '+c.streak+' 天🔥'}).join(' ')+'</div></header><div class="statcheck"><div class="sc"><div class="n" style="color:'+(tp===100?'#16a34a':'#3b5bfd')+'">'+tp+'%</div><div class="l">今日完成率 '+(s.today.done+'/'+s.today.total)+'</div></div><div class="sc"><div class="n">'+wp+'%</div><div class="l">本周完成率</div></div><div class="sc"><div class="n" style="color:#f59e0b">'+st+'</div><div class="l">连续全勤(天)</div></div></div>';s.trend.forEach(function(d){var p=d.total?Math.round(d.done/d.total*100):0;html+='<div class="card"><div class="row" style="align-items:center"><span style="flex:0 0 58px;font-size:12px;color:#6b7280">'+d.d.slice(5)+'</span><div class="pw" style="flex:1"><i style="width:'+p+'%;background:'+(p===100?'#16a34a':'#3b5bfd')+'"></i></div><span style="flex:0 0 40px;text-align:right;font-size:12px;color:#6b7280">'+p+'%</span></div></div>'});return html}
|
||||
function renderTemplates(){var html='<header class="h"><h1>任务模板 📋</h1></header>';state.templates.forEach(function(t){html+='<div class="card"><b>'+esc(t.title)+'</b><div class="sub">+'+t.points+' 能量 · 每周 '+weekdaysCN(t.weekdays)+'</div></div>'});html+='<button style="width:100%;margin-top:14px" onclick="newTpl()">+ 新建任务模板</button>';return html}
|
||||
function tplForm(t){var kids=state.children.map(function(c){return '<option value="'+c.id+'">'+esc(c.name)+'</option>'}).join('');var cats=state.categories.map(function(c){return '<option value="'+c.id+'">'+esc(c.name)+'</option>'}).join('');var wchk=WD.map(function(w,i){return '<span class="chip" onclick="this.classList.toggle(\'on\')">'+w+'</span>'}).join('');return '<div class="overlay" onclick="if(event.target===this)this.remove()"><div class="modal"><h3>'+(t?'编辑':'新建')+'任务模板</h3><select id="f_child">'+kids+'</select><input id="f_title" placeholder="任务名称"><select id="f_type"><option value="check">完成型</option><option value="count">数量型</option></select><div>'+wchk+'</div><button onclick="saveTpl('+(t?t.id:0)+')">保存</button></div></div>'}
|
||||
function newTpl(){document.getElementById('app').insertAdjacentHTML('beforeend',tplForm(null))}
|
||||
async function saveTpl(id){var wds=[];document.querySelectorAll('.overlay .chip.on').forEach(function(c,i){wds.push(String(i+1))});var body={child_id:+document.getElementById('f_child').value,title:document.getElementById('f_title').value,task_type:document.getElementById('f_type').value,weekdays:wds,start_date:new Date().toISOString().slice(0,10),points:10};if(id)await api('/parent/templates/'+id,{method:'PUT',body:JSON.stringify(body)});else await api('/parent/templates',{method:'POST',body:JSON.stringify(body)});document.querySelector('.overlay').remove();await loadAll();render()}
|
||||
function renderCats(){var html='<header class="h"><h1>分类管理 🏷️</h1></header><div class="card"><table>';state.categories.forEach(function(c){html+='<tr><td>'+esc(c.name)+'</td></tr>'});html+='</table></div><div class="card"><input id="c_name" placeholder="分类名"><button onclick="addCat()">添加</button></div>';return html}
|
||||
async function addCat(){var n=document.getElementById('c_name').value.trim();if(!n)return;await api('/parent/categories',{method:'POST',body:JSON.stringify({name:n,color:'#3b5bfd'})});await loadAll();render()}
|
||||
function renderKids(){var html='<header class="h"><h1>孩子管理 👦</h1></header>';state.children.forEach(function(c){html+='<div class="card">'+esc(c.emoji)+' '+esc(c.name)+'</div>'});html+='<div class="card"><input id="k_name" placeholder="昵称"><button onclick="addKid()">添加</button></div>';return html}
|
||||
async function addKid(){var n=document.getElementById('k_name').value.trim();if(!n)return;await api('/parent/children',{method:'POST',body:JSON.stringify({name:n,emoji:'🙂'})});await loadAll();render()}
|
||||
async function loadAll(){if(state.tab==='dash')state.stats=await api('/parent/stats');if(state.tab==='tpl'){state.templates=(await api('/parent/templates')).data;state.categories=(await api('/parent/categories')).data;state.children=(await api('/parent/children')).data}if(state.tab==='cat'){state.categories=(await api('/parent/categories')).data}if(state.tab==='kid'){state.children=(await api('/parent/children')).data}}
|
||||
function render(){var app=document.getElementById('app');if(!state.family){app.innerHTML=renderAuth();return}var tabs=[['dash','📊','看板'],['tpl','📋','模板'],['cat','🏷️','分类'],['kid','👦','孩子'],['set','⚙️','设置']];var body=state.tab==='dash'?renderDash():state.tab==='tpl'?renderTemplates():state.tab==='cat'?renderCats():state.tab==='kid'?renderKids():renderSet();app.innerHTML=body+'<div class="tabs">'+tabs.map(function(t){return '<button class="'+(state.tab===t[0]?'on':'')+'" onclick="go(\''+t[0]+'\')">'+t[1]+' '+t[2]+'</button>'}).join('')+'</div>'}
|
||||
async function go(tab){state.tab=tab;await loadAll();render()}
|
||||
|
||||
(async function(){try{var st=await fetch('/auth/status',{credentials:'include'}).then(function(r){return r.json()});if(!st.authenticated||!st.user){document.getElementById('app').innerHTML=renderAuth();return}await ensureFamily();await loadAll();render()}catch(e){document.getElementById('app').innerHTML='<div class="card"><div class="empty">'+esc(e.message)+'</div></div>'}})();
|
||||
</script></body></html>
|
||||
@@ -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 `<!DOCTYPE html>
|
||||
<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<title>我的学习小助手</title><style>${sharedCss}</style></head><body>
|
||||
<div class="wrap" id="app"></div><div class="tabbar" id="tabbar"></div>
|
||||
<div style="text-align:center;font-size:10px;color:#c3c9d6;padding:6px 0 76px">v2</div>
|
||||
<script>
|
||||
var FAMILY_SLUG=new URLSearchParams(location.search).get('f')||'tang';
|
||||
var API_BASE='/api/learn/public/'+encodeURIComponent(FAMILY_SLUG);
|
||||
${childScriptBody()}
|
||||
</script></body></html>`;
|
||||
}
|
||||
|
||||
function childScriptBody() {
|
||||
// childScript() 在 tang 构建里经 template literal 会把 \\' 变成 \';
|
||||
// 这里直接读源码,需手动还原为 inline <script> 可用的转义。
|
||||
return fs.readFileSync(path.join(root, 'scripts/build-tang-learning-assistant-pages.mjs'), 'utf8')
|
||||
.match(/function childScript\(\) {\s*return `\s*([\s\S]*?)`;\s*}/)[1]
|
||||
.replace(/var API_BASE='[^']*';\n?/g, '')
|
||||
.replace(/learning-assistant-parent\.html/g, "' + 'parent.html?f=' + FAMILY_SLUG + '")
|
||||
.replace(/\\\\'/g, "\\'");
|
||||
}
|
||||
|
||||
function parentPage() {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<title>家长后台 · 学习小助手</title><style>${sharedCss}body{padding-bottom:30px}.tabs{position:sticky;top:0;background:#f2f4fa;padding-top:2px;z-index:10}.pl{max-width:720px}</style></head><body>
|
||||
<div class="wrap pl" id="app"></div>
|
||||
<script>
|
||||
var FAMILY_SLUG=new URLSearchParams(location.search).get('f')||'';
|
||||
var API_BASE='/api/learn/me';
|
||||
var state={tab:'dash',stats:null,templates:null,categories:null,children:null,family:null,role:null,invite:null};
|
||||
function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}
|
||||
async function api(p,opt){opt=opt||{};opt.credentials='include';opt.headers=Object.assign({'Content-Type':'application/json'},opt.headers||{});var r=await fetch(API_BASE+p,opt);var j=await r.json().catch(function(){return{}});if(!r.ok)throw new Error(j.error||j.message||'请求失败');return j}
|
||||
function pad(n){return n<10?'0'+n:''+n}
|
||||
var WD=['一','二','三','四','五','六','日'];
|
||||
function weekdaysCN(w){return w.split(',').map(function(x){return WD[+x-1]}).join('、')}
|
||||
var TYPE={check:'完成型',count:'数量型',duration:'时长型',score:'成绩型'};
|
||||
function childUrl(){return '/learn/child.html?f='+(state.family?state.family.slug:FAMILY_SLUG||'tang')}
|
||||
function memindAuthUrl(){
|
||||
return '/?return_to='+encodeURIComponent(location.pathname+location.search);
|
||||
}
|
||||
function renderAuth(){
|
||||
return '<header class="h"><h1>家长后台 🔐</h1><div class="sub">登录 Memind 后管理孩子任务;支持邀请爸爸/妈妈共同管理</div></header>'
|
||||
+'<div class="card"><p class="sub" style="line-height:1.8;margin-bottom:12px">请通过 Memind 首页登录或注册(不要用 /auth/login 直链)。登录成功后会自动回到本页。</p>'
|
||||
+'<a href="'+memindAuthUrl()+'" style="display:block;text-align:center;padding:12px;border-radius:12px;background:#3b5bfd;color:#fff;text-decoration:none;font-weight:600;margin-bottom:10px">登录 / 注册 Memind</a>'
|
||||
+'<div class="sub" style="text-align:center;margin-top:14px"><a href="'+childUrl()+'" style="color:#3b5bfd">← 返回小朋友页面</a></div></div>';
|
||||
}
|
||||
async function ensureFamily(){
|
||||
var info=await api('/family');
|
||||
state.family=info.family;state.role=info.role;return info;
|
||||
}
|
||||
function renderInvite(){
|
||||
if(state.role!=='owner')return '';
|
||||
var s='<div class="card"><div class="t" style="font-weight:700;font-size:15px;margin-bottom:8px">👨👩👧 邀请协家长</div><div class="sub" style="line-height:1.7">把链接发给爸爸/妈妈,对方注册 Memind 后打开即可加入同一家庭。</div>';
|
||||
if(state.invite&&state.invite.joinUrl){s+='<div class="sub" style="margin-top:10px;word-break:break-all;background:#f8fafc;padding:10px;border-radius:10px">'+esc(state.invite.joinUrl)+'</div>'}
|
||||
s+='<button style="width:100%;margin-top:12px" onclick="createInvite()">生成邀请链接</button></div>';
|
||||
return s;
|
||||
}
|
||||
async function createInvite(){state.invite=await api('/invite',{method:'POST'});render()}
|
||||
function renderSet(){
|
||||
return '<header class="h"><h1>设置 ⚙️</h1></header>'
|
||||
+renderInvite()
|
||||
+'<div class="card"><div class="sub" style="line-height:1.8">· 儿童端:<a href="'+childUrl()+'" style="color:#3b5bfd">'+esc(childUrl())+'</a><br>· 家庭 slug:<b>'+esc(state.family.slug)+'</b><br>· 你的角色:<b>'+(state.role==='owner'?'主家长':'协家长')+'</b><br>· 成员:'+state.family.members.map(function(m){return esc(m.displayName||m.userId.slice(0,8))+'('+(m.role==='owner'?'主':'协')+')'}).join('、')+'</div></div>';
|
||||
}
|
||||
${parentDashboardScript()}
|
||||
(async function(){try{var st=await fetch('/auth/status',{credentials:'include'}).then(function(r){return r.json()});if(!st.authenticated||!st.user){document.getElementById('app').innerHTML=renderAuth();return}await ensureFamily();await loadAll();render()}catch(e){document.getElementById('app').innerHTML='<div class="card"><div class="empty">'+esc(e.message)+'</div></div>'}})();
|
||||
</script></body></html>`;
|
||||
}
|
||||
|
||||
function parentDashboardScript() {
|
||||
return `
|
||||
function renderLogin(){return renderAuth()}
|
||||
async function login(){await ensureFamily();await loadAll();render()}
|
||||
function renderDash(){var s=state.stats;var tp=s.today.total?Math.round(s.today.done/s.today.total*100):0;var wp=s.week.total?Math.round(s.week.done/s.week.total*100):0;var st=s.children.length?s.children[0].streak:0;var html='<header class="h"><h1>数据看板 📊</h1><div class="sub">'+s.children.map(function(c){return esc(c.emoji)+' '+esc(c.name)+' 连续 '+c.streak+' 天🔥'}).join(' ')+'</div></header><div class="statcheck"><div class="sc"><div class="n" style="color:'+(tp===100?'#16a34a':'#3b5bfd')+'">'+tp+'%</div><div class="l">今日完成率 '+(s.today.done+'/'+s.today.total)+'</div></div><div class="sc"><div class="n">'+wp+'%</div><div class="l">本周完成率</div></div><div class="sc"><div class="n" style="color:#f59e0b">'+st+'</div><div class="l">连续全勤(天)</div></div></div>';s.trend.forEach(function(d){var p=d.total?Math.round(d.done/d.total*100):0;html+='<div class="card"><div class="row" style="align-items:center"><span style="flex:0 0 58px;font-size:12px;color:#6b7280">'+d.d.slice(5)+'</span><div class="pw" style="flex:1"><i style="width:'+p+'%;background:'+(p===100?'#16a34a':'#3b5bfd')+'"></i></div><span style="flex:0 0 40px;text-align:right;font-size:12px;color:#6b7280">'+p+'%</span></div></div>'});return html}
|
||||
function renderTemplates(){var html='<header class="h"><h1>任务模板 📋</h1></header>';state.templates.forEach(function(t){html+='<div class="card"><b>'+esc(t.title)+'</b><div class="sub">+'+t.points+' 能量 · 每周 '+weekdaysCN(t.weekdays)+'</div></div>'});html+='<button style="width:100%;margin-top:14px" onclick="newTpl()">+ 新建任务模板</button>';return html}
|
||||
function tplForm(t){var kids=state.children.map(function(c){return '<option value="'+c.id+'">'+esc(c.name)+'</option>'}).join('');var cats=state.categories.map(function(c){return '<option value="'+c.id+'">'+esc(c.name)+'</option>'}).join('');var wchk=WD.map(function(w,i){return '<span class="chip" onclick="this.classList.toggle(\\'on\\')">'+w+'</span>'}).join('');return '<div class="overlay" onclick="if(event.target===this)this.remove()"><div class="modal"><h3>'+(t?'编辑':'新建')+'任务模板</h3><select id="f_child">'+kids+'</select><input id="f_title" placeholder="任务名称"><select id="f_type"><option value="check">完成型</option><option value="count">数量型</option></select><div>'+wchk+'</div><button onclick="saveTpl('+(t?t.id:0)+')">保存</button></div></div>'}
|
||||
function newTpl(){document.getElementById('app').insertAdjacentHTML('beforeend',tplForm(null))}
|
||||
async function saveTpl(id){var wds=[];document.querySelectorAll('.overlay .chip.on').forEach(function(c,i){wds.push(String(i+1))});var body={child_id:+document.getElementById('f_child').value,title:document.getElementById('f_title').value,task_type:document.getElementById('f_type').value,weekdays:wds,start_date:new Date().toISOString().slice(0,10),points:10};if(id)await api('/parent/templates/'+id,{method:'PUT',body:JSON.stringify(body)});else await api('/parent/templates',{method:'POST',body:JSON.stringify(body)});document.querySelector('.overlay').remove();await loadAll();render()}
|
||||
function renderCats(){var html='<header class="h"><h1>分类管理 🏷️</h1></header><div class="card"><table>';state.categories.forEach(function(c){html+='<tr><td>'+esc(c.name)+'</td></tr>'});html+='</table></div><div class="card"><input id="c_name" placeholder="分类名"><button onclick="addCat()">添加</button></div>';return html}
|
||||
async function addCat(){var n=document.getElementById('c_name').value.trim();if(!n)return;await api('/parent/categories',{method:'POST',body:JSON.stringify({name:n,color:'#3b5bfd'})});await loadAll();render()}
|
||||
function renderKids(){var html='<header class="h"><h1>孩子管理 👦</h1></header>';state.children.forEach(function(c){html+='<div class="card">'+esc(c.emoji)+' '+esc(c.name)+'</div>'});html+='<div class="card"><input id="k_name" placeholder="昵称"><button onclick="addKid()">添加</button></div>';return html}
|
||||
async function addKid(){var n=document.getElementById('k_name').value.trim();if(!n)return;await api('/parent/children',{method:'POST',body:JSON.stringify({name:n,emoji:'🙂'})});await loadAll();render()}
|
||||
async function loadAll(){if(state.tab==='dash')state.stats=await api('/parent/stats');if(state.tab==='tpl'){state.templates=(await api('/parent/templates')).data;state.categories=(await api('/parent/categories')).data;state.children=(await api('/parent/children')).data}if(state.tab==='cat'){state.categories=(await api('/parent/categories')).data}if(state.tab==='kid'){state.children=(await api('/parent/children')).data}}
|
||||
function render(){var app=document.getElementById('app');if(!state.family){app.innerHTML=renderAuth();return}var tabs=[['dash','📊','看板'],['tpl','📋','模板'],['cat','🏷️','分类'],['kid','👦','孩子'],['set','⚙️','设置']];var body=state.tab==='dash'?renderDash():state.tab==='tpl'?renderTemplates():state.tab==='cat'?renderCats():state.tab==='kid'?renderKids():renderSet();app.innerHTML=body+'<div class="tabs">'+tabs.map(function(t){return '<button class="'+(state.tab===t[0]?'on':'')+'" onclick="go(\\''+t[0]+'\\')">'+t[1]+' '+t[2]+'</button>'}).join('')+'</div>'}
|
||||
async function go(tab){state.tab=tab;await loadAll();render()}
|
||||
`;
|
||||
}
|
||||
|
||||
function joinPage() {
|
||||
return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>加入家庭</title>
|
||||
<style>body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;background:#f2f4fa;padding:24px}.card{max-width:420px;margin:40px auto;background:#fff;border-radius:16px;padding:24px;box-shadow:0 1px 3px rgba(0,0,0,.08)}button,a.btn{display:block;width:100%;text-align:center;padding:12px;border-radius:12px;border:none;font-weight:600;text-decoration:none;margin-top:10px}button{background:#3b5bfd;color:#fff}a.btn{background:#e8edff;color:#3b5bfd}</style></head><body>
|
||||
<div class="card"><h1>加入学习家庭</h1><p id="msg" class="sub">正在处理邀请…</p><a class="btn" id="login" href="/" style="display:none">登录 / 注册 Memind</a></div>
|
||||
<script>
|
||||
var token=new URLSearchParams(location.search).get('token');
|
||||
(async function(){
|
||||
var msg=document.getElementById('msg');
|
||||
if(!token){msg.textContent='邀请链接无效';return}
|
||||
var st=await fetch('/auth/status',{credentials:'include'}).then(function(r){return r.json()});
|
||||
if(!st.authenticated||!st.user){msg.textContent='请先登录 Memind,再加入家庭';document.getElementById('login').href='/?return_to='+encodeURIComponent(location.pathname+location.search);document.getElementById('login').style.display='block';return}
|
||||
try{
|
||||
var j=await fetch('/api/learn/me/join',{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:token})}).then(function(r){return r.json()});
|
||||
if(j.error)throw new Error(j.error);
|
||||
msg.textContent='已加入「'+j.family.name+'」,正在进入家长后台…';
|
||||
location.href='/learn/parent.html?f='+encodeURIComponent(j.family.slug);
|
||||
}catch(e){msg.textContent=e.message}
|
||||
})();
|
||||
</script></body></html>`;
|
||||
}
|
||||
|
||||
function indexPage() {
|
||||
return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>学习小助手</title>
|
||||
<style>body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;background:#f2f4fa;padding:24px;text-align:center}.card{max-width:480px;margin:40px auto;background:#fff;border-radius:16px;padding:28px}a{display:inline-block;margin:8px;padding:12px 18px;border-radius:999px;background:#3b5bfd;color:#fff;text-decoration:none;font-weight:600}</style></head><body>
|
||||
<div class="card"><h1>学习小助手</h1><p>家长注册 Memind 后配置任务;孩子打开链接打卡。</p>
|
||||
<a href="/learn/parent.html">家长后台</a><a href="/learn/child.html?f=tang" style="background:#e8edff;color:#3b5bfd">儿童端示例</a>
|
||||
<a href="/?return_to=%2Flearn%2Fparent.html" style="background:#16a34a">登录 / 注册 Memind</a></div></body></html>`;
|
||||
}
|
||||
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(outDir, 'child.html'), childPage());
|
||||
fs.writeFileSync(path.join(outDir, 'parent.html'), parentPage());
|
||||
fs.writeFileSync(path.join(outDir, 'join.html'), joinPage());
|
||||
fs.writeFileSync(path.join(outDir, 'index.html'), indexPage());
|
||||
console.log(JSON.stringify({ ok: true, outDir }, null, 2));
|
||||
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 生成唐用户学习小助手 HTML(儿童端 + 家长端)
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createLearnAssistantService, learnAssistantInternals } from '../learn-assistant-service.mjs';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const TANG = learnAssistantInternals.TANG_OWNER_ID;
|
||||
const API_BASE = '/api/learn/tang';
|
||||
const outDir = path.join(root, 'MindSpace', TANG, 'public');
|
||||
|
||||
const sharedCss = `*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;background:#f2f4fa;color:#1c2333;min-height:100vh;padding-bottom:70px;padding-bottom:calc(70px + constant(safe-area-inset-bottom));padding-bottom:calc(70px + env(safe-area-inset-bottom))}
|
||||
.wrap{max-width:560px;margin:0 auto;padding:16px 14px}
|
||||
header.h{padding:16px 2px 4px}
|
||||
h1{font-size:22px;font-weight:700}
|
||||
.sub{font-size:13px;color:#6b7280;margin-top:3px}
|
||||
.card{background:#fff;border-radius:16px;padding:16px;margin-top:12px;box-shadow:0 1px 3px rgba(28,35,51,.06)}
|
||||
.big{font-size:26px;font-weight:700}
|
||||
.bar{height:10px;background:#e8ebf2;border-radius:5px;overflow:hidden;margin-top:8px}
|
||||
.bar i{display:block;height:100%;background:linear-gradient(90deg,#22c55e,#16a34a);border-radius:5px;transition:width .4s}
|
||||
.cat{margin-top:14px}
|
||||
.cat-title{font-size:13px;font-weight:700;padding:3px 10px;border-radius:999px;display:inline-flex;align-items:center;gap:5px}
|
||||
.task{display:flex;align-items:center;gap:12px;padding:12px 2px;border-bottom:1px solid #f0f2f7;cursor:pointer}
|
||||
.task:last-child{border-bottom:none}
|
||||
.task .ck{width:26px;height:26px;border-radius:50%;border:2px solid #cbd5e1;flex:none;display:flex;align-items:center;justify-content:center;font-size:15px;color:#fff}
|
||||
.task.done .ck{background:#16a34a;border-color:#16a34a}
|
||||
.task .info{flex:1;min-width:0}
|
||||
.task .t{font-size:15px;font-weight:600}
|
||||
.task.done .t{color:#9ca3af;text-decoration:line-through}
|
||||
.task .d{font-size:12px;color:#6b7280;margin-top:2px}
|
||||
.task .pt{font-size:12px;color:#f59e0b;font-weight:700;flex:none}
|
||||
.tabbar{position:fixed;left:0;right:0;bottom:0 !important;top:auto !important;background:#fff;border-top:1px solid #e5e8f0;display:flex;z-index:20;padding:6px 0 8px;padding:6px 0 calc(8px + constant(safe-area-inset-bottom));padding:6px 0 calc(8px + env(safe-area-inset-bottom))}
|
||||
.overlay{top:0 !important;left:0 !important;right:0 !important;bottom:0 !important}
|
||||
.tabbar a{flex:1;text-align:center;text-decoration:none;color:#9ca3af;font-size:11px;padding:4px 0}
|
||||
.tabbar a.on{color:#3b5bfd;font-weight:700}
|
||||
.tabbar .ic{font-size:20px;display:block}
|
||||
button{font-family:inherit;border:none;cursor:pointer;border-radius:12px;font-size:14px;font-weight:600;padding:10px 16px;background:#3b5bfd;color:#fff}
|
||||
button:active{transform:scale(.97)}
|
||||
button.ghost{background:#e8edff;color:#3b5bfd}
|
||||
button.danger{background:#ffe9ee;color:#e11d48}
|
||||
button:disabled{opacity:.4;cursor:not-allowed}
|
||||
input,select,textarea{font-family:inherit;width:100%;border:2px solid #e5e8f0;border-radius:10px;padding:10px 12px;font-size:15px;outline:none;background:#fff;color:#1c2333}
|
||||
input:focus,select:focus,textarea:focus{border-color:#3b5bfd}
|
||||
label{display:block;font-size:13px;font-weight:600;margin:12px 0 5px}
|
||||
.overlay{position:fixed;inset:0;background:rgba(20,24,38,.5);z-index:40;display:flex;align-items:center;justify-content:center;padding:20px}
|
||||
.modal{background:#fff;border-radius:18px;padding:24px;width:100%;max-width:360px;max-height:85vh;overflow:auto}
|
||||
.statcheck{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:12px}
|
||||
.sc{background:#fff;border-radius:14px;padding:13px 8px;text-align:center;box-shadow:0 1px 3px rgba(28,35,51,.06)}
|
||||
.sc .n{font-size:20px;font-weight:700}
|
||||
.sc .l{font-size:11px;color:#6b7280;margin-top:2px}
|
||||
.cal{display:grid;grid-template-columns:repeat(7,1fr);gap:6px;margin-top:10px}
|
||||
.cal .w{text-align:center;font-size:11px;color:#9ca3af;padding:4px 0}
|
||||
.cal .cell{text-align:center;padding:7px 0;border-radius:10px;font-size:14px;position:relative}
|
||||
.cal .cell.has{font-weight:700}
|
||||
.cal .cell.g{background:#e7f8ee;color:#16a34a}.cal .cell.y{background:#fff4dd;color:#d97706}.cal .cell.r{background:#ffe9ee;color:#e11d48}.cal .cell.n{color:#c3c9d6}
|
||||
.cal .today{outline:2px solid #3b5bfd}
|
||||
.legend{display:flex;gap:12px;font-size:11px;color:#6b7280;margin-top:10px;flex-wrap:wrap}
|
||||
.dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:4px;vertical-align:-1px}
|
||||
.feed{position:fixed;left:50%;top:38%;transform:translate(-50%,-50%);background:#fff;border-radius:20px;padding:28px 30px;text-align:center;z-index:50;box-shadow:0 10px 40px rgba(28,35,51,.25);animation:pop .3s}
|
||||
@keyframes pop{from{transform:translate(-50%,-50%) scale(.8);opacity:0}to{transform:translate(-50%,-50%) scale(1);opacity:1}}
|
||||
.feed .em{font-size:44px}.feed .t1{font-size:18px;font-weight:700;margin-top:8px}.feed .t2{font-size:13px;color:#6b7280;margin-top:6px;line-height:1.6}
|
||||
.feed .pt{color:#f59e0b;font-weight:700;margin-top:8px;font-size:15px}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
th,td{padding:8px 6px;border-bottom:1px solid #f0f2f7;text-align:left}
|
||||
th{font-size:11px;color:#6b7280}
|
||||
.row{display:flex;gap:10px;align-items:center}
|
||||
.row>*{flex:1}
|
||||
.pw{height:8px;background:#e8ebf2;border-radius:4px;overflow:hidden;min-width:60px}
|
||||
.pw i{display:block;height:100%;background:#3b5bfd}
|
||||
.chip{display:inline-flex;align-items:center;gap:6px;padding:6px 14px;border-radius:999px;border:2px solid #e5e8f0;font-size:13px;cursor:pointer;margin:0 8px 8px 0;background:#fff}
|
||||
.chip.on{border-color:#3b5bfd;background:#e8edff;color:#3b5bfd;font-weight:700}
|
||||
.hbtn{display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:600;color:#3b5bfd;background:#e8edff;padding:6px 13px;border-radius:999px;text-decoration:none;cursor:pointer;flex:none;white-space:nowrap}
|
||||
.hbtn:active{transform:scale(.95)}
|
||||
.err{background:#ffe9ee;color:#e11d48;padding:10px;border-radius:10px;font-size:13px;margin-top:10px;display:none}
|
||||
.tabs{display:flex;gap:8px;margin-top:14px}
|
||||
.tabs button{flex:1;background:#e8ebf2;color:#6b7280}
|
||||
.tabs button.on{background:#3b5bfd;color:#fff}
|
||||
.empty{text-align:center;color:#9ca3af;font-size:13px;padding:18px 0;line-height:1.7}
|
||||
[data-mindspace-public-share],[data-mindspace-wechat-dialog],.publication-share-fab,.publication-share-sheet{display:none!important;visibility:hidden!important}`;
|
||||
|
||||
function childScript() {
|
||||
return `
|
||||
var API_BASE='${API_BASE}';
|
||||
var state={tab:'today',child:null,children:[],data:null,cal:null,grow:null};
|
||||
function esc(s){return String(s==null?'':s).replace(/&/g,'&').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='<div class="card"><h1>我是谁呀?</h1>';state.children.forEach(function(x){s+='<div class="chip" style="margin-top:12px;font-size:16px;padding:12px 20px" onclick="chooseChild('+x.id+')">'+esc(x.emoji)+' '+esc(x.name)+'</div>'});s+='</div>';return s}return c.id}
|
||||
function chooseChild(id){localStorage.setItem('learn_child',String(id));location.reload()}
|
||||
function showSwitcher(){var s='<div class="overlay" onclick="if(event.target===this)this.remove()"><div class="modal"><h3 style="font-size:17px">👤 切换学员</h3><div style="margin-top:8px">';state.children.forEach(function(x){s+='<div class="chip" style="width:100%;justify-content:center;margin:8px 0 0;font-size:16px;padding:12px 20px'+(x.id==state.child?';border-color:#3b5bfd;background:#e8edff;font-weight:700':'')+'" onclick="chooseChild('+x.id+')">'+esc(x.emoji)+' '+esc(x.name)+(x.id==state.child?' ✓':'')+'</div>'});s+='</div><button class="ghost" style="margin-top:16px;width:100%" onclick="this.closest(\\'.overlay\\').remove()">取消</button></div></div>';document.body.insertAdjacentHTML('beforeend',s)}
|
||||
function hdrActions(){var cur=state.children.find(function(x){return x.id==state.child});return '<div style="display:flex;gap:8px;flex:none;padding-top:6px"><span class="hbtn" onclick="showSwitcher()">⇄ '+(cur?'切换学员':'学员')+'</span><a class="hbtn" href="learning-assistant-parent.html">家长端</a></div>'}
|
||||
function greet(){var h=new Date().getHours();return h<11?'早上好':h<14?'中午好':h<18?'下午好':'晚上好'}
|
||||
function pad(n){return n<10?'0'+n:''+n}
|
||||
function wdCN(d){return ['日','一','二','三','四','五','六'][d]}
|
||||
function dowOf(ds){return new Date(Date.UTC(+ds.slice(0,4),+ds.slice(5,7)-1,+ds.slice(8,10))).getUTCDay()}
|
||||
function renderToday(){var d=state.data;var p=d.total?Math.round(d.done/d.total*100):0;var s='<header class="h"><div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px"><div><h1>'+greet()+','+esc(d.child.name)+' '+esc(d.child.emoji)+'</h1><div class="sub">'+d.date.replace(/-/g,'/')+' 周'+wdCN(dowOf(d.date))+'</div></div>'+hdrActions()+'</div></header><div class="card"><div class="row" style="align-items:baseline"><div><div class="big">'+d.done+' / '+d.total+'</div><div class="sub">今天已完成</div></div><div style="text-align:right"><div class="big" style="color:#f59e0b">⭐ '+d.energy+'</div><div class="sub">我的能量</div></div></div><div class="bar"><i style="width:'+p+'%"></i></div>'+(d.total&&d.done===d.total?'<div style="text-align:center;margin-top:10px;font-size:15px;font-weight:700;color:#16a34a">⭐ 今天全部完成,太棒啦!</div>':'')+'</div>';var byCat={},order=[];d.tasks.forEach(function(t){var k=t.category_name||'其他';if(!byCat[k]){byCat[k]={color:t.category_color||'#6b7280',list:[]};order.push(k)}byCat[k].list.push(t)});if(!d.tasks.length)s+='<div class="card"><div class="empty">今天没有任务<br>好好休息,或找爸爸妈妈安排~</div></div>';order.forEach(function(k){s+='<div class="cat"><span class="cat-title" style="background:'+byCat[k].color+'1a;color:'+byCat[k].color+'">'+esc(k)+'</span><div class="card">';byCat[k].list.forEach(function(t){var needVal=(t.task_type!=='check');var sub=[];if(t.description)sub.push(esc(t.description));if(needVal)sub.push('目标:'+esc(t.target_value||'')+(t.unit?esc(t.unit):''));if(t.start_date)sub.push('📅 '+t.start_date.replace(/-/g,'/')+' 开始');if(t.status==='done'){sub.unshift('✓ '+(t.completed_time||'')+' 完成'+(needVal&&t.value_submitted?','+esc(t.value_submitted)+(t.unit?esc(t.unit):''):''));}s+='<div class="task'+(t.status==='done'?' done':'')+'" onclick="doTask('+t.id+')"><div class="ck"'+(t.status==='pending'?' style="border-color:#e11d48"':'')+'>'+(t.status==='done'?'✓':'')+'</div><div class="info"><div class="t">'+esc(t.title)+'</div>'+(sub.length?'<div class="d">'+sub.join('<br>')+'</div>':'')+'</div><div class="pt">+'+t.points+'</div></div>'});s+='</div></div>'});if(d.streak>0)s+='<div class="card" style="text-align:center"><span style="font-size:15px">🔥 连续打卡 <b style="color:#f59e0b">'+d.streak+'</b> 天,继续保持!</span></div>';return s}
|
||||
function renderCal(){var m=state.cal.month;var first=m+'-01';var dim=new Date(Date.UTC(+m.slice(0,4),+m.slice(5,7),0)).getUTCDate();var lead=dowOf(first);var map={};state.cal.days.forEach(function(x){map[x.date]=x});var s='<header class="h"><div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px"><div><h1>我的日历 📅</h1><div class="sub">'+m.replace('-',' 年 ')+' 月</div></div>'+hdrActions()+'</div></header><div class="card"><div style="display:flex;justify-content:space-between;align-items:center"><button class="ghost" style="padding:6px 14px" onclick="calShift(-1)">← 上月</button><button class="ghost" style="padding:6px 14px" onclick="calShift(1)">下月 →</button></div><div class="cal">';['日','一','二','三','四','五','六'].forEach(function(w){s+='<div class="w">'+w+'</div>'});for(var i=0;i<lead;i++)s+='<div></div>';var today=new Date(Date.now()+8*3600e3).toISOString().slice(0,10);for(var d=1;d<=dim;d++){var ds=m+'-'+pad(d);var st=map[ds];var cls='cell ';var mark='';if(st){cls+=st.status==='green'?'g':st.status==='yellow'?'y':st.status==='red'?'r':'n';if(st.total){cls+=' has';if(st.status==='red')mark='<i style="position:absolute;top:4px;right:5px;width:7px;height:7px;border-radius:50%;background:#e11d48;box-shadow:0 0 4px #e11d48"></i>';else if(st.status==='yellow')mark='<i style="position:absolute;top:4px;right:5px;width:7px;height:7px;border-radius:50%;background:#f59e0b"></i>';}}if(ds===today)cls+=' today';s+='<div class="'+cls+'" style="position:relative;'+(st&&st.status==='red'?'box-shadow:0 0 0 2px #e11d48 inset':'')+'" onclick="showDay(\\''+ds+'\\')">'+d+mark+'</div>'}s+='</div><div class="legend"><span><i class="dot" style="background:#16a34a"></i>全部完成</span><span><i class="dot" style="background:#f59e0b"></i>部分完成</span><span><i class="dot" style="background:#e11d48"></i>有遗漏(红点)</span><span><i class="dot" style="background:#c3c9d6"></i>无任务</span><div style="width:100%;text-align:center;margin-top:4px;color:#3b5bfd">点任意日期查看当天详情</div></div></div><div id="dayDetail"></div>';return s}
|
||||
async function showDay(ds){var j=await api('/day?child='+state.child+'&date='+ds);var el=document.getElementById('dayDetail');var isToday=ds===new Date(Date.now()+8*3600e3).toISOString().slice(0,10);var stTxt='';var stColor='';var done=j.tasks.filter(function(t){return t.status==='done'}).length;if(j.tasks.length){if(done===j.tasks.length){stTxt='全部完成 ✓';stColor='#16a34a'}else if(done>0){stTxt='完成 '+done+'/'+j.tasks.length+(ds<new Date(Date.now()+8*3600e3).toISOString().slice(0,10)?',有遗漏':'');stColor=ds<new Date(Date.now()+8*3600e3).toISOString().slice(0,10)?'#e11d48':'#d97706'}else{stTxt=ds<new Date(Date.now()+8*3600e3).toISOString().slice(0,10)?'全部未完成 ✕':'待完成';stColor=ds<new Date(Date.now()+8*3600e3).toISOString().slice(0,10)?'#e11d48':'#d97706'}}var s='<div class="card"><div class="row" style="align-items:center;margin-bottom:6px"><b style="font-size:15px;flex:1">'+ds.replace(/-/g,'/')+' 周'+wdCN(dowOf(ds))+(isToday?'(今天)':'')+'</b>'+(stTxt?'<span style="font-size:12px;font-weight:700;color:'+stColor+'">'+stTxt+'</span>':'')+'</div>';if(!j.tasks.length)s+='<div class="empty">那天没有任务</div>';j.tasks.forEach(function(t){var overdue=t.status==='pending'&&ds<new Date(Date.now()+8*3600e3).toISOString().slice(0,10);s+='<div class="task'+(t.status==='done'?' done':'')+'"><div class="ck"'+(overdue?' style="border-color:#e11d48;color:#e11d48;font-size:12px"':'')+'>'+(t.status==='done'?'✓':(overdue?'✕':''))+'</div><div class="info"><div class="t">'+esc(t.title)+'</div><div class="d"'+((overdue)?' style="color:#e11d48"':'')+'>'+esc(t.category_name||'')+(t.status==='done'?' · '+(t.completed_time||'')+' 完成':(overdue?' · 未完成':' · 待完成')+(t.value_submitted?' · '+esc(t.value_submitted)+(t.unit||''):''))+'</div></div></div>'});if(j.tasks.length)s+='<div class="sub" style="margin-top:10px">完成率:'+Math.round(done/j.tasks.length*100)+'%</div>';s+='</div>';el.innerHTML=s}
|
||||
function renderGrow(){var g=state.grow;var s='<header class="h"><div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px"><div><h1>我的成长 🌱</h1></div>'+hdrActions()+'</div></header><div class="statcheck"><div class="sc"><div class="n" style="color:#f59e0b">⭐ '+g.energy+'</div><div class="l">我的能量</div></div><div class="sc"><div class="n" style="color:#ef4444">🔥 '+g.streak+'</div><div class="l">连续打卡</div></div><div class="sc"><div class="n">'+g.totalDone+'</div><div class="l">累计完成</div></div></div><div class="card"><div class="sub" style="margin-bottom:6px">💪 历史最长连续:<b>'+g.best+'</b> 天 | 全勤天数:<b>'+g.fullDays+'</b> 天</div></div><div class="card"><div class="t" style="font-weight:700;font-size:15px;margin-bottom:8px">近 30 天分类表现</div>';if(!g.byCategory.length)s+='<div class="empty">还没有记录,快去完成任务吧!</div>';g.byCategory.forEach(function(c){var p=c.total?Math.round(c.done/c.total*100):0;s+='<div style="margin:10px 0"><div class="row" style="align-items:center"><span style="flex:0 0 64px;font-size:13px;font-weight:600">'+esc(c.name)+'</span><div class="pw" style="flex:1"><i style="width:'+p+'%"></i></div><span style="flex:0 0 40px;text-align:right;font-size:12px;color:#6b7280">'+p+'%</span></div></div>'});s+='</div>';return s}
|
||||
function renderHist(){var h=state.hist;var today=new Date(Date.now()+8*3600e3).toISOString().slice(0,10);var s='<header class="h"><div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px"><div><h1>我的记录 📖</h1><div class="sub">每天的任务和完成情况,都记在这里'+(h.cycleStart?'<br>本期任务从 <b style="color:#3b5bfd">'+h.cycleStart.replace(/-/g,'/')+'</b> 开始':'')+'</div></div>'+hdrActions()+'</div></header>';if(!h.data.length)s+='<div class="card"><div class="empty">还没有记录<br>完成今天的任务,就会出现在这里哦</div></div>';var daysAsc=h.data.slice().reverse();daysAsc.forEach(function(day,idx){var label=day.date===today?'今天':(day.date===addDaysLocal(today,-1)?'昨天':(parseInt(day.date.slice(5),10))+'月'+parseInt(day.date.slice(8),10)+'日');var w='周'+wdCN(dowOf(day.date));var stTxt=day.status==='green'?'全部完成':(day.done>0?'完成 '+day.done+'/'+day.total:(day.date<today?'有遗漏':'待完成'));var stColor=day.status==='green'?'#16a34a':day.status==='red'?'#e11d48':'#d97706';var stBg=day.status==='green'?'#e7f8ee':day.status==='red'?'#ffe9ee':'#fff4dd';var open=day.date===today;var bodyId='hb'+idx;var dot=day.status==='green'?'🟢':(day.status==='red'?'🔴':'🟡');s+='<div class="card"><div class="row" style="align-items:center;cursor:pointer" onclick="toggleDay(\\''+bodyId+'\\',this)"><div style="flex:1;cursor:pointer"><span style="font-size:16px;font-weight:700;cursor:pointer">'+label+'</span> <span class="sub">'+w+' '+dot+'</span></div><span style="font-size:12px;font-weight:700;color:'+stColor+';background:'+stBg+';padding:3px 10px;border-radius:999px;cursor:pointer">'+stTxt+'</span><span style="flex:0 0 16px;text-align:right;color:#9ca3af;font-size:12px;cursor:pointer" class="arr">'+(open?'▾':'▸')+'</span></div><div id="'+bodyId+'" style="'+(open?'':'display:none')+';margin-top:4px">';day.tasks.forEach(function(t){var sub=[];if(t.status==='done')sub.push('✓ '+(t.completed_time||'')+' 完成'+(t.task_type!=='check'&&t.value_submitted?','+esc(t.value_submitted)+(t.unit?esc(t.unit):''):''));else if(t.status==='pending'&&day.date<today)sub.push('✕ 未完成');else sub.push('待完成');if(t.remark)sub.push('📝 '+esc(t.remark));s+='<div class="task'+(t.status==='done'?' done':'')+'"><div class="ck">'+(t.status==='done'?'✓':(t.status==='pending'&&day.date<today?'✕':''))+'</div><div class="info"><div class="t">'+esc(t.title)+'</div><div class="d">'+esc(t.category_name||'')+' · '+sub.join(' · ')+'</div></div></div>'});s+='</div></div>'});return s}
|
||||
function toggleDay(id,el){var b=document.getElementById(id);var arr=el.querySelector('.arr')||el.parentElement.querySelector('.arr');if(b.style.display==='none'){b.style.display='block';if(arr)arr.textContent='▾'}else{b.style.display='none';if(arr)arr.textContent='▸'}}
|
||||
function addDaysLocal(ds,n){var d=new Date(ds+'T00:00:00Z');d.setUTCDate(d.getUTCDate()+n);return d.toISOString().slice(0,10)}
|
||||
function renderTabbar(){var t=[['today','☀️','今日'],['hist','📖','记录'],['cal','📅','日历'],['grow','🌱','成长']];return t.map(function(x){return '<a href="javascript:void(0)" class="'+(state.tab===x[0]?'on':'')+'" onclick="go(\\''+x[0]+'\\')"><span class="ic">'+x[1]+'</span>'+x[2]+'</a>'}).join('')}
|
||||
async function go(tab){state.tab=tab;await load();render()}
|
||||
async function load(){if(!state.children.length){state.children=(await api('/children')).data}if(state.child==null){var c=pickChild();if(typeof c!=='number')return;state.child=c}if(state.child==null)return;if(state.tab==='today')state.data=await api('/today?child='+state.child);if(state.tab==='hist')state.hist=await api('/history?child='+state.child);if(state.tab==='cal'&&!state.cal)state.cal=await api('/calendar?child='+state.child);if(state.tab==='grow')state.grow=await api('/growth?child='+state.child)}
|
||||
function render(){var app=document.getElementById('app');if(!state.children.length){app.innerHTML='<div class="card"><div class="empty">还没有小朋友的信息<br>请爸爸妈妈先去 <a href="learning-assistant-parent.html" style="color:#3b5bfd">家长后台</a> 添加</div></div>';document.getElementById('tabbar').innerHTML='';return}if(state.child==null){app.innerHTML=pickChild();document.getElementById('tabbar').innerHTML='';return}var s=state.tab==='today'?renderToday():state.tab==='hist'?renderHist():state.tab==='cal'?renderCal():renderGrow();app.innerHTML=s;document.getElementById('tabbar').innerHTML=renderTabbar()}
|
||||
async function calShift(n){var m=state.cal.month.split('-');var y=+m[0],mo=+m[1]+n;if(mo<1){mo=12;y--}if(mo>12){mo=1;y++}state.cal=await api('/calendar?child='+state.child+'&month='+y+'-'+pad(mo));render()}
|
||||
function doTask(id){var t=null;state.data.tasks.forEach(function(x){if(x.id===id)t=x});if(!t)(state.data.pastPending||[]).forEach(function(x){if(x.id===id)t=x});if(!t)return;if(t.status==='done')return;if(t.task_type==='check'){submit(id)}else{var unit=t.unit||'';var label=t.task_type==='count'?'完成了多少'+(unit?esc(unit):''):(t.task_type==='duration'?'坚持了多少'+(unit||'分钟'):'得了多少分');var def=t.value_submitted||(t.task_type==='duration'?(t.target_value||''):(t.target_value||''));document.getElementById('app').insertAdjacentHTML('beforeend','<div class="overlay" onclick="if(event.target===this)this.remove()"><div class="modal"><h3 style="font-size:17px">✏️ '+esc(t.title)+'</h3><label>'+label+'</label><input id="vInput" inputmode="numeric" value="'+esc(def)+'" placeholder="填一个数字吧"><label>想说的悄悄话(选填)</label><textarea id="vRemark" rows="2" style="font-size:14px"></textarea><div class="row" style="margin-top:16px"><button class="ghost" onclick="this.closest(\\'.overlay\\').remove()">再等等</button><button onclick="submitVal('+id+')">✓ 完成打卡</button></div></div></div>');var inp=document.getElementById('vInput');if(inp)inp.focus()}}
|
||||
function submitVal(id){var v=document.getElementById('vInput').value;var r=document.getElementById('vRemark').value;submit(id,v,r)}
|
||||
async function submit(id,value,remark){try{var j=await api('/checkin',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:id,value:value,remark:remark})});var s='<div class="feed" onclick="this.remove()"><div class="em">🎉</div><div class="t1">完成啦!</div><div class="t2">今天已经完成 <b>'+j.done+'</b> / '+j.total+' 项<br>'+(j.allDone?'⭐ 太棒了,今天全部完成!':'再完成 '+(j.total-j.done)+' 项,今天就是全绿!')+'</div><div class="pt">+'+j.points+' 能量</div><div class="t2" style="margin-top:12px;font-size:11px;color:#c3c9d6">点一下继续</div></div>';document.body.insertAdjacentHTML('beforeend',s);await load();render()}catch(e){alert(e.message)}}
|
||||
(async function(){try{await load();render()}catch(e){document.getElementById('app').innerHTML='<div class="card"><div class="empty">'+esc(e.message)+'</div></div>'}})();`;
|
||||
}
|
||||
|
||||
function parentScript() {
|
||||
return `
|
||||
var API_BASE='${API_BASE}';
|
||||
var state={pw:localStorage.getItem('learn_pw')||'',tab:'dash',stats:null,templates:null,categories:null,children:null,editId:null};
|
||||
function esc(s){return String(s==null?'':s).replace(/&/g,'&').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 '<header class="h"><h1>家长后台 🔐</h1><div class="sub">任务配置 · 数据统计 · 习惯分析</div></header><div class="card"><label>管理密码</label><input type="password" id="pw" placeholder="请输入密码" onkeydown="if(event.key===\\'Enter\\')login()"><div class="err" id="err"></div><button style="width:100%;margin-top:16px" onclick="login()">进入后台</button><div class="sub" style="text-align:center;margin-top:14px"><a href="learning-assistant.html" style="color:#3b5bfd">← 返回小朋友页面</a></div></div>'}
|
||||
async function login(){state.pw=document.getElementById('pw').value;try{await api('/parent/stats');localStorage.setItem('learn_pw',state.pw);state.tab='dash';await loadAll();render()}catch(e){var el=document.getElementById('err');el.style.display='block';el.textContent=e.message}}
|
||||
function renderDash(){var s=state.stats;var tp=s.today.total?Math.round(s.today.done/s.today.total*100):0;var wp=s.week.total?Math.round(s.week.done/s.week.total*100):0;var st=s.children.length?s.children[0].streak:0;var html='<header class="h"><h1>数据看板 📊</h1><div class="sub">'+s.children.map(function(c){return esc(c.emoji)+' '+esc(c.name)+' 连续 '+c.streak+' 天🔥'}).join(' ')+'</div></header><div class="statcheck"><div class="sc"><div class="n" style="color:'+(tp===100?'#16a34a':'#3b5bfd')+'">'+tp+'%</div><div class="l">今日完成率 '+(s.today.done+'/'+s.today.total)+'</div></div><div class="sc"><div class="n">'+wp+'%</div><div class="l">本周完成率</div></div><div class="sc"><div class="n" style="color:#f59e0b">'+st+'</div><div class="l">连续全勤(天)</div></div></div><div class="card"><div class="t" style="font-weight:700;font-size:15px;margin-bottom:8px">近 7 天趋势</div>';s.trend.forEach(function(d){var p=d.total?Math.round(d.done/d.total*100):0;html+='<div style="margin:8px 0"><div class="row" style="align-items:center"><span style="flex:0 0 58px;font-size:12px;color:#6b7280">'+d.d.slice(5)+'</span><div class="pw" style="flex:1"><i style="width:'+p+'%;background:'+(p===100?'#16a34a':'#3b5bfd')+'"></i></div><span style="flex:0 0 40px;text-align:right;font-size:12px;color:#6b7280">'+p+'%</span></div></div>'});html+='</div>';if(s.missed.length){html+='<div class="card"><div class="t" style="font-weight:700;font-size:15px;margin-bottom:8px">⚠️ 近 30 天容易遗漏</div><table>';s.missed.forEach(function(m){html+='<tr><td>'+esc(m.title)+'</td><td style="color:#e11d48;font-weight:700;text-align:right">漏 '+m.count+' 次</td></tr>'});html+='</table></div>'}if(s.categoryRate.length){html+='<div class="card"><div class="t" style="font-weight:700;font-size:15px;margin-bottom:8px">📚 分类完成率(近 30 天)</div>';s.categoryRate.forEach(function(c){var p=c.total?Math.round(c.done/c.total*100):0;html+='<div style="margin:8px 0"><div class="row" style="align-items:center"><span style="flex:0 0 64px;font-size:13px;font-weight:600">'+esc(c.name)+'</span><div class="pw" style="flex:1"><i style="width:'+p+'%;background:'+(p>=90?'#16a34a':p>=70?'#f59e0b':'#e11d48')+'"></i></div><span style="flex:0 0 40px;text-align:right;font-size:12px;color:#6b7280">'+p+'%</span></div></div>'});html+='</div>'}if(s.avgTime.length){html+='<div class="card"><div class="t" style="font-weight:700;font-size:15px;margin-bottom:8px">⏰ 平均完成时间(近 30 天)</div><table>';s.avgTime.forEach(function(a){html+='<tr><td>'+esc(a.name)+'</td><td style="text-align:right;font-weight:600">'+a.time+'</td></tr>'});html+='</table><div class="sub" style="margin-top:8px">提示:越晚完成的科目,遗漏概率往往越高</div></div>'}return html}
|
||||
function renderTemplates(){var html='<header class="h"><h1>任务模板 📋</h1><div class="sub">配置周期规则,系统每天自动生成当日任务</div></header>';if(!state.templates.length)html+='<div class="card"><div class="empty">还没有任务模板<br>点下方按钮创建第一个</div></div>';state.templates.forEach(function(t){html+='<div class="card"><div class="row" style="align-items:flex-start"><div style="flex:1"><div style="font-size:16px;font-weight:700">'+esc(t.title)+'</div><div class="sub" style="margin-top:4px;line-height:1.7">'+(t.category_name?'【'+esc(t.category_name)+'】 ':'')+TYPE[t.task_type]+(t.target_value?' · '+esc(t.target_value)+(t.unit?esc(t.unit):''):'')+'<br>每周:'+weekdaysCN(t.weekdays)+' | 从 '+String(t.start_date).slice(0,10)+' 起'+(t.end_date?' 至 '+String(t.end_date).slice(0,10):'(长期)')+'<br>+'+t.points+' 能量'+(t.description?'<br>要求:'+esc(t.description):'')+'</div></div><div style="display:flex;flex-direction:column;gap:8px;flex:0 0 66px"><button class="ghost" style="padding:7px 10px;font-size:12px" onclick="editTpl('+t.id+')">编辑</button><button class="danger" style="padding:7px 10px;font-size:12px" onclick="delTpl('+t.id+',\\''+esc(t.title).replace(/'/g,'')+'\\')">停用</button></div></div></div>'});html+='<button style="width:100%;padding:14px;margin-top:14px" onclick="newTpl()">+ 新建任务模板</button>';return html}
|
||||
function tplForm(t){var kids=state.children.map(function(c){return '<option value="'+c.id+'"'+(t&&t.child_id===c.id?' selected':'')+'>'+esc(c.name)+'</option>'}).join('');var cats=state.categories.map(function(c){return '<option value="'+c.id+'"'+(t&&t.category_id===c.id?' selected':'')+'>'+esc(c.name)+'</option>'}).join('');var wds=(t?t.weekdays:'1,2,3,4,5').split(',');var wchk=WD.map(function(w,i){return '<span class="chip'+(wds.indexOf(String(i+1))>=0?' on':'')+'" onclick="this.classList.toggle(\\'on\\')">'+w+'</span>'}).join('');return '<div class="overlay" onclick="if(event.target===this)this.remove()"><div class="modal"><h3 style="font-size:17px;margin-bottom:4px">'+(t?'编辑任务模板':'新建任务模板')+'</h3><label>给谁安排</label><select id="f_child">'+kids+'</select><label>分类</label><select id="f_cat"><option value="">(无分类)</option>'+cats+'</select><label>任务名称 *</label><input id="f_title" value="'+(t?esc(t.title):'')+'" placeholder="如:英语单词复习" maxlength="60"><label>任务要求(孩子可见)</label><textarea id="f_desc" rows="2" maxlength="300" style="font-size:14px">'+(t?esc(t.description||''):'')+'</textarea><label>任务类型</label><select id="f_type">'+['check','count','duration','score'].map(function(k){return '<option value="'+k+'"'+(t&&t.task_type===k?' selected':'')+'>'+TYPE[k]+'</option>'}).join('')+'</select><div class="row"><div><label>目标值</label><input id="f_target" value="'+(t?esc(t.target_value||''):'')+'" placeholder="如 20" inputmode="numeric"></div><div><label>单位</label><input id="f_unit" value="'+(t?esc(t.unit||''):'')+'" placeholder="如 题/分钟/个"></div></div><label>重复的星期(点选)</label><div style="margin-top:2px">'+wchk+'</div><div class="row"><div><label>开始日期</label><input id="f_start" type="date" value="'+(t?String(t.start_date).slice(0,10):new Date().toISOString().slice(0,10))+'"></div><div><label>结束日期(可空)</label><input id="f_end" type="date" value="'+(t&&t.end_date?String(t.end_date).slice(0,10):'')+'"></div></div><label>能量值(完成奖励)</label><input id="f_points" type="number" value="'+(t?t.points:10)+'" min="0" max="100"><div class="err" id="f_err"></div><div class="row" style="margin-top:18px"><button class="ghost" onclick="this.closest(\\'.overlay\\').remove()">取消</button><button onclick="saveTpl('+(t?t.id:0)+')">保存</button></div></div></div>'}
|
||||
function newTpl(){document.getElementById('app').insertAdjacentHTML('beforeend',tplForm(null))}
|
||||
function editTpl(id){var t=state.templates.find(function(x){return x.id===id});if(t)document.getElementById('app').insertAdjacentHTML('beforeend',tplForm(t))}
|
||||
async function delTpl(id,name){if(!confirm('停用「'+name+'」?已生成的历史任务保留,今后不再生成。'))return;await api('/parent/templates/'+id,{method:'DELETE'});await loadAll();render()}
|
||||
async function saveTpl(id){var wds=[];document.querySelectorAll('.overlay .chip.on').forEach(function(c){wds.push(String(WD.indexOf(c.textContent)+1))});var body={child_id:+document.getElementById('f_child').value,category_id:+document.getElementById('f_cat').value||null,title:document.getElementById('f_title').value,description:document.getElementById('f_desc').value,task_type:document.getElementById('f_type').value,target_value:document.getElementById('f_target').value,unit:document.getElementById('f_unit').value,weekdays:wds,start_date:document.getElementById('f_start').value,end_date:document.getElementById('f_end').value,points:+document.getElementById('f_points').value};try{if(!body.title)throw new Error('任务名称必填');if(!wds.length)throw new Error('请至少选择一个星期');if(id)await api('/parent/templates/'+id,{method:'PUT',body:JSON.stringify(body)});else await api('/parent/templates',{method:'POST',body:JSON.stringify(body)});document.querySelector('.overlay').remove();await loadAll();render()}catch(e){var el=document.getElementById('f_err');el.style.display='block';el.textContent=e.message}}
|
||||
function renderCats(){var html='<header class="h"><h1>分类管理 🏷️</h1><div class="sub">学习 + 习惯,不限语数外</div></header><div class="card"><table>';state.categories.forEach(function(c){html+='<tr><td><span class="dot" style="background:'+esc(c.color)+'"></span>'+esc(c.name)+'</td><td style="text-align:right"><button class="danger" style="padding:5px 10px;font-size:12px" onclick="delCat('+c.id+',\\''+esc(c.name)+'\\')">删除</button></td></tr>'});html+='</table></div><div class="card"><label>新增分类</label><div class="row"><input id="c_name" placeholder="如:练琴" maxlength="10"><input id="c_color" type="color" style="flex:0 0 56px;padding:4px" value="#3b5bfd"><button style="flex:0 0 70px" onclick="addCat()">添加</button></div></div>';return html}
|
||||
async function addCat(){var n=document.getElementById('c_name').value.trim();if(!n)return;await api('/parent/categories',{method:'POST',body:JSON.stringify({name:n,color:document.getElementById('c_color').value})});await loadAll();render()}
|
||||
async function delCat(id,name){if(!confirm('删除分类「'+name+'」?使用它的模板将变为无分类。'))return;await api('/parent/categories/'+id,{method:'DELETE'});await loadAll();render()}
|
||||
function renderKids(){var html='<header class="h"><h1>孩子管理 👦</h1><div class="sub">儿童端选择孩子后进入</div></header>';state.children.forEach(function(c){html+='<div class="card"><div class="row"><div style="flex:1;font-size:16px;font-weight:700">'+esc(c.emoji)+' '+esc(c.name)+'</div><button class="danger" style="flex:0 0 66px;padding:7px 0;font-size:12px" onclick="delKid('+c.id+',\\''+esc(c.name)+'\\')">删除</button></div></div>'});html+='<div class="card"><label>添加孩子</label><div class="row"><input id="k_emoji" placeholder="😀" style="flex:0 0 64px" maxlength="2"><input id="k_name" placeholder="昵称,如:小宇" maxlength="10"><button style="flex:0 0 70px" onclick="addKid()">添加</button></div></div>';return html}
|
||||
async function addKid(){var n=document.getElementById('k_name').value.trim();if(!n)return;await api('/parent/children',{method:'POST',body:JSON.stringify({name:n,emoji:document.getElementById('k_emoji').value||'🙂'})});await loadAll();render()}
|
||||
async function delKid(id,name){if(!confirm('删除「'+name+'」及其所有任务记录?此操作不可恢复!'))return;await api('/parent/children/'+id,{method:'DELETE'});await loadAll();render()}
|
||||
function renderSet(){return '<header class="h"><h1>设置 ⚙️</h1></header><div class="card"><label>修改管理密码</label><input type="password" id="p_old" placeholder="当前密码"><input type="password" id="p_new" placeholder="新密码(至少3位)" style="margin-top:8px"><div class="err" id="p_err"></div><button style="width:100%;margin-top:14px" onclick="chpw()">保存新密码</button></div><div class="card"><div class="sub" style="line-height:1.8">· 儿童端地址:<a href="learning-assistant.html" style="color:#3b5bfd">打开</a><br>· 任务每天首次访问时自动生成<br>· 历史任务保存快照,改模板不影响过去记录<br>· 数据存储:Memind Portal</div></div><button class="danger" style="width:100%;padding:12px" onclick="logout()">退出登录</button>'}
|
||||
async function chpw(){try{await api('/parent/password',{method:'POST',body:JSON.stringify({old:document.getElementById('p_old').value,new:document.getElementById('p_new').value})});alert('密码已更新');state.pw=document.getElementById('p_new').value;localStorage.setItem('learn_pw',state.pw)}catch(e){var el=document.getElementById('p_err');el.style.display='block';el.textContent=e.message}}
|
||||
function logout(){localStorage.removeItem('learn_pw');state.pw='';render()}
|
||||
async function loadAll(){if(state.tab==='dash')state.stats=await api('/parent/stats');if(state.tab==='tpl'){state.templates=(await api('/parent/templates')).data;state.categories=(await api('/parent/categories')).data;state.children=(await api('/parent/children')).data}if(state.tab==='cat'){state.categories=(await api('/parent/categories')).data}if(state.tab==='kid'){state.children=(await api('/parent/children')).data}}
|
||||
function render(){var app=document.getElementById('app');if(!state.pw){app.innerHTML=renderLogin();return}var tabs=[['dash','📊','看板'],['tpl','📋','模板'],['cat','🏷️','分类'],['kid','👦','孩子'],['set','⚙️','设置']];var body=state.tab==='dash'?renderDash():state.tab==='tpl'?renderTemplates():state.tab==='cat'?renderCats():state.tab==='kid'?renderKids():renderSet();app.innerHTML=body+'<div class="tabs">'+tabs.map(function(t){return '<button class="'+(state.tab===t[0]?'on':'')+'" onclick="go(\\''+t[0]+'\\')">'+t[1]+' '+t[2]+'</button>'}).join('')+'</div>'}
|
||||
async function go(tab){state.tab=tab;try{await loadAll();render()}catch(e){if(e.message.indexOf('密码')>=0){state.pw='';localStorage.removeItem('learn_pw')}render()}}
|
||||
(async function(){if(state.pw){try{await loadAll()}catch(e){if(e.message.indexOf('密码')>=0){state.pw='';localStorage.removeItem('learn_pw')}}}render()})();`;
|
||||
}
|
||||
|
||||
function wrapHtml(title, bodyExtra, script) {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<title>${title}</title>
|
||||
<style>${sharedCss}${bodyExtra || ''}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap" id="app"></div>
|
||||
${title.includes('小助手') ? '<div class="tabbar" id="tabbar"></div><div style="text-align:center;font-size:10px;color:#c3c9d6;padding:6px 0 76px">v1</div>' : '<div class="wrap pl" id="app"></div>'.replace('id="app"', '')}
|
||||
<script>${script}<\/script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const service = createLearnAssistantService({ dataRoot: path.join(root, 'data', 'learn-assistant') });
|
||||
const seed = await service.seedTangDefaults();
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const childHtml = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<title>我的学习小助手</title>
|
||||
<style>${sharedCss}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap" id="app"></div>
|
||||
<div class="tabbar" id="tabbar"></div>
|
||||
<div style="text-align:center;font-size:10px;color:#c3c9d6;padding:6px 0 76px">v1</div>
|
||||
<script>${childScript()}<\/script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const parentHtml = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<title>家长后台</title>
|
||||
<style>${sharedCss}body{padding-bottom:30px}.tabs{position:sticky;top:0;background:#f2f4fa;padding-top:2px;z-index:10}.pl{max-width:720px}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap pl" id="app"></div>
|
||||
<script>${parentScript()}<\/script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
fs.writeFileSync(path.join(outDir, 'learning-assistant.html'), childHtml);
|
||||
fs.writeFileSync(path.join(outDir, 'learning-assistant-parent.html'), parentHtml);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
seed,
|
||||
files: [
|
||||
path.join(outDir, 'learning-assistant.html'),
|
||||
path.join(outDir, 'learning-assistant-parent.html'),
|
||||
],
|
||||
publicUrls: [
|
||||
`https://m.tkmind.cn/MindSpace/${TANG}/public/learning-assistant.html`,
|
||||
`https://m.tkmind.cn/MindSpace/${TANG}/public/learning-assistant-parent.html`,
|
||||
],
|
||||
defaultParentPassword: '123456',
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 部署唐用户学习小助手(HTML + 后端 + 种子数据)到 103
|
||||
* Usage: node scripts/deploy-tang-learning-assistant-103.mjs [--send-wechat]
|
||||
*/
|
||||
import { execSync } from 'node:child_process';
|
||||
import crypto from 'node:crypto';
|
||||
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 HOST = 'john@180.159.29.143';
|
||||
const REMOTE_ROOT = '/Users/john/Project/Memind';
|
||||
const TANG = 'a70ff537-8908-486e-9b6c-042e07cc25db';
|
||||
const NODE103 = '/opt/homebrew/opt/node@24/bin/node';
|
||||
const FILES = ['learning-assistant.html', 'learning-assistant-parent.html'];
|
||||
const BACKEND_FILES = ['learn-assistant-service.mjs', 'learn-assistant-routes.mjs'];
|
||||
const PUBLIC_BASE = `https://m.tkmind.cn/MindSpace/${TANG}/public`;
|
||||
const sendWechat = process.argv.includes('--send-wechat');
|
||||
|
||||
function sh(cmd) {
|
||||
execSync(cmd, { stdio: 'inherit' });
|
||||
}
|
||||
|
||||
const remoteScript = `
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import mysql from 'mysql2/promise';
|
||||
import { createLearnAssistantService } from './learn-assistant-service.mjs';
|
||||
|
||||
const TANG = '${TANG}';
|
||||
const REMOTE_ROOT = '${REMOTE_ROOT}';
|
||||
const FILES = ${JSON.stringify(FILES)};
|
||||
const REQUEST_ID = 'deploy-learning-assistant-20260911';
|
||||
const PUBLIC_BASE = '${PUBLIC_BASE}';
|
||||
|
||||
process.loadEnvFile(path.join(REMOTE_ROOT, '.env'));
|
||||
const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 });
|
||||
const service = createLearnAssistantService({ dataRoot: path.join(REMOTE_ROOT, 'data', 'learn-assistant') });
|
||||
|
||||
async function ensureReady(relativePath) {
|
||||
const now = Date.now();
|
||||
const id = crypto.randomUUID();
|
||||
await pool.query(
|
||||
\`INSERT INTO h5_page_delivery_contracts
|
||||
(id, user_id, request_id, workspace_relative_path, data_mode, status, ready_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'static', 'ready', ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE status = 'ready', ready_at = VALUES(ready_at), failure_reason = NULL, updated_at = VALUES(updated_at)\`,
|
||||
[id, TANG, REQUEST_ID, relativePath, now, now, now],
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const seed = await service.seedTangDefaults();
|
||||
const results = [];
|
||||
for (const name of FILES) {
|
||||
const relativePath = 'public/' + name;
|
||||
const abs = path.join(REMOTE_ROOT, 'MindSpace', TANG, relativePath);
|
||||
const exists = fs.existsSync(abs);
|
||||
const size = exists ? fs.statSync(abs).size : 0;
|
||||
if (exists) await ensureReady(relativePath);
|
||||
results.push({ file: name, exists, size, url: PUBLIC_BASE + '/' + name });
|
||||
}
|
||||
console.log(JSON.stringify({ ok: true, seed, pages: results }, null, 2));
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
`.trim();
|
||||
|
||||
async function sendWechatLinks() {
|
||||
const remoteWechat = `
|
||||
import path from 'node:path';
|
||||
import mysql from 'mysql2/promise';
|
||||
|
||||
const TANG = '${TANG}';
|
||||
const PUBLIC_BASE = '${PUBLIC_BASE}';
|
||||
|
||||
process.loadEnvFile('${REMOTE_ROOT}/.env');
|
||||
const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 });
|
||||
const appId = process.env.H5_WECHAT_MP_APP_ID ?? process.env.WECHAT_MP_APP_ID;
|
||||
const appSecret = process.env.H5_WECHAT_MP_APP_SECRET ?? process.env.WECHAT_MP_APP_SECRET;
|
||||
if (!appId || !appSecret) throw new Error('missing wechat credentials');
|
||||
|
||||
const [ident] = await pool.query(
|
||||
'SELECT openid FROM h5_user_wechat_identities WHERE user_id = ? AND app_id = ? LIMIT 1',
|
||||
[TANG, appId],
|
||||
);
|
||||
const openid = ident?.[0]?.openid;
|
||||
if (!openid) throw new Error('tang not bound to wechat');
|
||||
|
||||
const tokenRes = await fetch('https://api.weixin.qq.com/cgi-bin/stable_token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ grant_type: 'client_credential', appid: appId, secret: appSecret }),
|
||||
});
|
||||
const tokenPayload = await tokenRes.json();
|
||||
if (!tokenPayload.access_token) throw new Error(JSON.stringify(tokenPayload));
|
||||
|
||||
const text = [
|
||||
'学习小助手已发布:',
|
||||
'',
|
||||
'👧 小朋友端',
|
||||
PUBLIC_BASE + '/learning-assistant.html',
|
||||
'',
|
||||
'🔐 家长后台(默认密码 123456)',
|
||||
PUBLIC_BASE + '/learning-assistant-parent.html',
|
||||
].join('\\n').slice(0, 2048);
|
||||
|
||||
const sendRes = await fetch('https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=' + encodeURIComponent(tokenPayload.access_token), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ touser: openid, msgtype: 'text', text: { content: text } }),
|
||||
});
|
||||
const sendPayload = await sendRes.json();
|
||||
if (Number(sendPayload.errcode ?? 0) !== 0) throw new Error(JSON.stringify(sendPayload));
|
||||
console.log(JSON.stringify({ ok: true, sent: true, openid: openid.slice(0, 8) + '...' }));
|
||||
await pool.end();
|
||||
`.trim();
|
||||
|
||||
const tmp = `${REMOTE_ROOT}/.tmp-tang-learning-wechat.mjs`;
|
||||
fs.writeFileSync(path.join(root, '.tmp-tang-learning-wechat.mjs'), remoteWechat);
|
||||
sh(`scp -q ${path.join(root, '.tmp-tang-learning-wechat.mjs')} ${HOST}:${tmp}`);
|
||||
sh(`ssh -o BatchMode=yes ${HOST} 'cd ${REMOTE_ROOT} && ${NODE103} ${tmp} && rm -f ${tmp}'`);
|
||||
fs.unlinkSync(path.join(root, '.tmp-tang-learning-wechat.mjs'));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
sh(`node ${path.join(root, 'scripts/build-tang-learning-assistant-pages.mjs')}`);
|
||||
|
||||
const localPublic = path.join(root, 'MindSpace', TANG, 'public');
|
||||
for (const f of FILES) {
|
||||
if (!fs.existsSync(path.join(localPublic, f))) throw new Error(`missing ${f}`);
|
||||
}
|
||||
|
||||
const remotePublic = `${REMOTE_ROOT}/MindSpace/${TANG}/public`;
|
||||
sh(`ssh -o BatchMode=yes ${HOST} 'mkdir -p ${remotePublic} ${REMOTE_ROOT}/data/learn-assistant/${TANG}'`);
|
||||
for (const f of FILES) {
|
||||
sh(`scp -q ${path.join(localPublic, f)} ${HOST}:${remotePublic}/${f}`);
|
||||
}
|
||||
for (const f of BACKEND_FILES) {
|
||||
sh(`scp -q ${path.join(root, f)} ${HOST}:${REMOTE_ROOT}/${f}`);
|
||||
}
|
||||
|
||||
sh(`scp -q ${path.join(root, 'scripts/patch-portal-learn-assistant-103.mjs')} ${HOST}:${REMOTE_ROOT}/scripts/patch-portal-learn-assistant-103.mjs`);
|
||||
sh(`ssh -o BatchMode=yes ${HOST} 'cd ${REMOTE_ROOT} && ${NODE103} scripts/patch-portal-learn-assistant-103.mjs'`);
|
||||
|
||||
const tmpLocal = path.join(root, '.tmp-tang-learning-deploy.mjs');
|
||||
fs.writeFileSync(tmpLocal, remoteScript);
|
||||
const tmpRemote = `${REMOTE_ROOT}/.tmp-tang-learning-deploy.mjs`;
|
||||
sh(`scp -q ${tmpLocal} ${HOST}:${tmpRemote}`);
|
||||
sh(`ssh -o BatchMode=yes ${HOST} 'cd ${REMOTE_ROOT} && ${NODE103} ${tmpRemote} && rm -f ${tmpRemote}'`);
|
||||
fs.unlinkSync(tmpLocal);
|
||||
|
||||
sh(`ssh -o BatchMode=yes ${HOST} 'launchctl kickstart -k gui/$(id -u)/cn.tkmind.memind-portal || true'`);
|
||||
|
||||
console.log('\n=== Production URLs ===');
|
||||
for (const f of FILES) console.log(`${PUBLIC_BASE}/${f}`);
|
||||
console.log(`API: https://m.tkmind.cn/api/learn/tang/children`);
|
||||
console.log('家长默认密码: 123456');
|
||||
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
for (const f of FILES) {
|
||||
const code = execSync(
|
||||
`curl -sS -o /dev/null -w '%{http_code}' '${PUBLIC_BASE}/${f}'`,
|
||||
{ encoding: 'utf8' },
|
||||
).trim();
|
||||
console.log(`${f}: HTTP ${code}`);
|
||||
}
|
||||
const apiCode = execSync(
|
||||
`curl -sS -o /dev/null -w '%{http_code}' 'https://m.tkmind.cn/api/learn/tang/children'`,
|
||||
{ encoding: 'utf8' },
|
||||
).trim();
|
||||
console.log(`API children: HTTP ${apiCode}`);
|
||||
|
||||
if (sendWechat) await sendWechatLinks();
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 在 103 已打包的 server.mjs 中热插入 learn-assistant 路由
|
||||
*/
|
||||
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 serverPath = path.join(root, 'server.mjs');
|
||||
let src = fs.readFileSync(serverPath, 'utf8');
|
||||
|
||||
if (src.includes('attachLearnAssistantPublicRoutes(api')) {
|
||||
console.log(JSON.stringify({ ok: true, patched: false, reason: 'already_patched' }));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const publicNeedle = 'attachShenmeiOpinionFormRoutes(api, { rootDir: __dirname6 });';
|
||||
const publicInsert = `${publicNeedle}
|
||||
attachLearnAssistantPublicRoutes(api, { rootDir: __dirname6 });`;
|
||||
|
||||
const parentNeedle = '}));\n\nattachPortalImageMakeRuntimeConfigRoute(api, {';
|
||||
const parentInsert = `}));\n\nattachLearnAssistantParentRoutes(api, { rootDir: __dirname6 });\n\nattachPortalImageMakeRuntimeConfigRoute(api, {`;
|
||||
|
||||
if (!src.includes(publicNeedle) || !src.includes(parentNeedle)) {
|
||||
console.error('patch needles not found in bundled server.mjs');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const topImport = "import { attachLearnAssistantPublicRoutes, attachLearnAssistantParentRoutes } from './learn-assistant-routes.mjs';\n";
|
||||
if (!src.includes("from './learn-assistant-routes.mjs'")) {
|
||||
const firstImportEnd = src.indexOf(';\n') + 2;
|
||||
src = `${src.slice(0, firstImportEnd)}${topImport}${src.slice(firstImportEnd)}`;
|
||||
}
|
||||
|
||||
src = src.replace(publicNeedle, publicInsert);
|
||||
src = src.replace(parentNeedle, parentInsert);
|
||||
fs.writeFileSync(serverPath, src);
|
||||
console.log(JSON.stringify({ ok: true, patched: true, serverPath }));
|
||||
@@ -119,6 +119,10 @@ import { attachAsrRoutes } from './asr-proxy.mjs';
|
||||
import { attachPageDataRoutes, isLegacyPageDataApiPath } from './page-data-routes.mjs';
|
||||
import { isPageDataPublicPath } from './page-data-public-service.mjs';
|
||||
import { attachShenmeiOpinionFormRoutes } from './shenmei-opinion-form-routes.mjs';
|
||||
import {
|
||||
attachLearnAssistantParentRoutes,
|
||||
attachLearnAssistantPublicRoutes,
|
||||
} from './learn-assistant-routes.mjs';
|
||||
import { isNativeH5ApiPath } from './policies.mjs';
|
||||
import {
|
||||
applyMemindRuntimeProfile,
|
||||
@@ -820,6 +824,7 @@ app.use('/wiki-api', wikiApi);
|
||||
const api = express.Router();
|
||||
api.use(jsonUnlessMultipart);
|
||||
attachShenmeiOpinionFormRoutes(api, { rootDir: __dirname });
|
||||
attachLearnAssistantPublicRoutes(api, { rootDir: __dirname });
|
||||
|
||||
api.use(createPortalApiAuthMiddleware({
|
||||
waitForUserAuthReady: () => userAuthReady,
|
||||
@@ -841,6 +846,8 @@ api.use(createPortalApiAuthMiddleware({
|
||||
logger: console,
|
||||
}));
|
||||
|
||||
attachLearnAssistantParentRoutes(api, { rootDir: __dirname });
|
||||
|
||||
attachPortalImageMakeRuntimeConfigRoute(api, {
|
||||
getImageMakeAdminConfigService: () => imageMakeAdminConfigService,
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from '../mindspace-public-page-context.mjs';
|
||||
import {
|
||||
collectInlineScriptHashes,
|
||||
shouldSkipPublicShareWidget,
|
||||
} from '../mindspace-public-delivery.mjs';
|
||||
import {
|
||||
buildViewerAnalyticsIdentity,
|
||||
@@ -336,7 +337,15 @@ export function createPortalPublishedPageDelivery({
|
||||
!raw &&
|
||||
isFullHtml &&
|
||||
result.publication?.accessMode !==
|
||||
'password';
|
||||
'password' &&
|
||||
!shouldSkipPublicShareWidget({
|
||||
pageUrl,
|
||||
requestPath: originalPath,
|
||||
relativePath:
|
||||
result.relativePath ??
|
||||
result.pageSource?.relativePath ??
|
||||
'',
|
||||
});
|
||||
if (canWrapWithShell) {
|
||||
const title =
|
||||
detectPublishedPageTitle(html);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
injectOgTags,
|
||||
renderWechatSharePreviewHtml,
|
||||
} from '../mindspace-og-tags.mjs';
|
||||
import { stripPublicFileShareWidget } from '../mindspace-public-share-widget.mjs';
|
||||
|
||||
export function attachPortalStaticDeliveryRoutes({
|
||||
app,
|
||||
@@ -161,6 +162,32 @@ export function attachPortalStaticDeliveryRoutes({
|
||||
{ maxAge: '1d' },
|
||||
),
|
||||
);
|
||||
const learnRoot = pathApi.join(h5Root, 'public/learn');
|
||||
const learnStatic = staticMiddleware(learnRoot, { maxAge: '0' });
|
||||
app.use('/learn', (req, res, next) => {
|
||||
const pathname = String(req.path ?? '').split('?')[0];
|
||||
if (!/\.html$/i.test(pathname)) {
|
||||
return learnStatic(req, res, next);
|
||||
}
|
||||
const filePath = pathApi.join(learnRoot, pathname);
|
||||
const resolvedRoot = pathApi.resolve(learnRoot);
|
||||
const resolvedPath = pathApi.resolve(filePath);
|
||||
if (
|
||||
!resolvedPath.startsWith(`${resolvedRoot}${pathApi.sep}`) &&
|
||||
resolvedPath !== resolvedRoot
|
||||
) {
|
||||
return res.status(403).json({ message: '禁止访问' });
|
||||
}
|
||||
if (!fsApi.existsSync(resolvedPath) || !fsApi.statSync(resolvedPath).isFile()) {
|
||||
return learnStatic(req, res, next);
|
||||
}
|
||||
const html = stripPublicFileShareWidget(
|
||||
fsApi.readFileSync(resolvedPath, 'utf8'),
|
||||
);
|
||||
res.set('Content-Type', 'text/html; charset=utf-8');
|
||||
res.set('Cache-Control', 'no-store');
|
||||
return res.send(html);
|
||||
});
|
||||
|
||||
app.get(
|
||||
'/dev/wechat-share-preview',
|
||||
|
||||
@@ -288,6 +288,9 @@ export function createPortalWorkspacePublicationDelivery({
|
||||
isOwner,
|
||||
pageDataContext,
|
||||
context,
|
||||
htmlFilePath: delivery.filePath ?? '',
|
||||
relativePath: delivery.relativePath ?? '',
|
||||
requestPath: req.originalUrl || req.url || '',
|
||||
userAgent:
|
||||
req.get('user-agent') || '',
|
||||
preparePublicationHtmlForEmbed,
|
||||
|
||||
@@ -26,6 +26,7 @@ export default defineConfig(({ mode }) => {
|
||||
'/MindSpace': portalTarget,
|
||||
'/dev': portalTarget,
|
||||
'/brand': portalTarget,
|
||||
'/learn': portalTarget,
|
||||
'^/u/': portalTarget,
|
||||
'^/s/': portalTarget,
|
||||
'^/user/': portalTarget,
|
||||
|
||||
Reference in New Issue
Block a user