From 0e6065897bcf51ca02bdb8c11c804214bdb39520 Mon Sep 17 00:00:00 2001 From: luff Date: Tue, 11 Aug 2026 15:20:53 +0800 Subject: [PATCH] =?UTF-8?q?=E6=98=9F=E7=81=AB=E7=BA=AA=E5=85=83=20?= =?UTF-8?q?=E4=B8=AA=E4=BA=BA=E6=88=90=E9=95=BF=E8=BF=BD=E8=B8=AA=20v1.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Flask + SQLite 自托管,单用户无 PIN - 仪表盘:学科进度环轮播、健康双线图、任务接龙、书架墙、浮动打卡 - 后台:孩子信息、学科/单元课表、健康历史录入、数据管理(重置/新学期) - 徽章由进度实时派生;主题由后台设置 --- .gitignore | 18 + README.md | 83 ++ app.py | 1376 ++++++++++++++++++++++++++++++ requirements.txt | 2 + static/covers/6_1786429622.jpg | Bin 0 -> 19959 bytes static/covers/6_1786429622_t.jpg | Bin 0 -> 4751 bytes static/style.css | 414 +++++++++ templates/_book_cover.html | 18 + templates/admin.html | 181 ++++ templates/admin_login.html | 14 + templates/base.html | 21 + templates/dashboard.html | 437 ++++++++++ 12 files changed, 2564 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.py create mode 100644 requirements.txt create mode 100644 static/covers/6_1786429622.jpg create mode 100644 static/covers/6_1786429622_t.jpg create mode 100644 static/style.css create mode 100644 templates/_book_cover.html create mode 100644 templates/admin.html create mode 100644 templates/admin_login.html create mode 100644 templates/base.html create mode 100644 templates/dashboard.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7cd5387 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Python +__pycache__/ +*.pyc + +# Virtual environment +venv/ + +# Local data / database +data/ + +# WorkBuddy internal +.workbuddy/ + +# Editor / OS +.idea/ +.vscode/ +.DS_Store +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..f75e0f0 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# 星火纪元 · 儿童个人成长系统(自托管) + +给自家孩子用的轻量成长记录 Web 服务:追踪**学科进度**、**读书**、**运动打卡**、**健康**并颁发轻量徽章。 +纯本地、离线可用,数据存在你自己的 SQLite 文件里,不依赖任何云、不用 Docker。 + +## 特性 + +- 📅 **学期日历小图**:进度环旁的点状日历,每点代表一天,按颜色区分打卡密度(越深越多 / 蓝=周末 / 灰=未打卡 / 圆环=今天),一眼看学期投入节奏。起止日期在家长后台「孩子信息」里设定。 +- 🎡 **各学科进度一览**:一行小进度环,每屏 3 个学科自动轮播;右侧有半透明「›」箭头可手动切下一屏。 +- 📚 **学科进度(单科轮播)**:整年课表(学科 → 单元/章节)逐科展示;每科按任务顺序显示「已完成 3 · 进行中 1 · 待完成 3」,点一下即完成、下一任务自动接上。点「详情」弹窗看全部单元与目标日。 +- 📖 **书架墙**:已读完的书以封面墙展示——上排 3 本最近读完、下排 3 本随机回顾;点「详情」弹窗可按书名/作者/分类搜索、分类筛选、排序。无封面时显示彩色书脊,封面完成日期标在右下角。封面支持本地上传(自动标准化为统一 2:3 并生成缩略图)或填图片 URL。 +- 💗 **健康综合**:主视图只显示「当前身高 / 当前体重」及同龄标准参考;点「📈 图表详情」弹窗看最近两年的身高(绿)/体重(橙)双线趋势。家长后台可补录任意历史日期的健康数据。 +- 🚀 **浮动快捷打卡**:仪表盘右侧常驻三个竖向按钮——跳绳打卡、仰卧起坐打卡、读书打卡,点一下即记录当天打卡。 +- 🏅 **轻量徽章**:由任务完成数、读书、运动等实时派生(如「迈出第一步」「任务小达人」「单科全通关」「学年进度过半」等),随进展自动获得;仪表盘顶部轮播已得徽章,点开可看全部(含未解锁)与获得时间。 +- 🎨 **主题**:家长后台「孩子信息」里设定默认主题(浅色 / 暗色 / 糖果 / 海洋),服务端直接应用,刷新即生效。 +- 🔒 **双角色**:孩子无 PIN 直进仪表盘;家长后台 `/admin` 用密码管理课表、健康记录与数据(含一键导出 JSON)。 + +## 运行(不用 Docker) + +需要 Python 3.10+。 + +```bash +cd tracker +python -m venv venv +venv\Scripts\activate # Windows +# macOS/Linux: source venv/bin/activate +pip install -r requirements.txt +python app.py +``` + +启动后打开: +- 仪表盘: http://127.0.0.1:5000/ +- 家长后台: http://127.0.0.1:5000/admin (默认账号 `admin` / 密码 `admin123`) + +首次启动会自动建库并写入一份示例数据,方便直接看效果。 + +## 配置(环境变量,可选) + +| 变量 | 默认 | 说明 | +|---|---|---| +| `ADMIN_USER` | admin | 家长后台用户名 | +| `ADMIN_PASSWORD` | admin123 | 家长后台密码 | +| `PORT` | 5000 | 服务端口 | +| `SECRET_KEY` | change-me... | Flask 会话密钥 | + +例:`ADMIN_PASSWORD=你的强密码 PORT=8080 python app.py` + +## 家长后台数据管理 + +- **重置全部数据**:清空所有进度 / 打卡 / 健康记录,但保留学科、单元、书目结构(方便从零开始新一轮记录)。 +- **开启新学期**:把单元进度重置为「未开始」、清空打卡,并把学期起设为今天;适合换学期时一键归零。 + +> 徽章由进度实时派生,重置或开启新学期后会随孩子的新进展自动重新累积,无需手动发徽章。 + +## 数据与备份 + +- 数据库文件:`data/tracker.db`(单文件,复制即备份)。 +- 上传的本地封面:`static/covers/`(备份时建议连同此目录一起复制)。 +- 家长后台「导出全部数据」可下载 `tracker-export.json`。 + +## 目录结构 + +``` +tracker/ +├── app.py # Flask 应用 + 数据库 + 纯 SVG 图表逻辑 +├── requirements.txt +├── data/tracker.db # 自动生成 +├── templates/ +│ ├── base.html # 布局 + 主题类挂载 +│ ├── dashboard.html # 孩子仪表盘 +│ ├── admin.html # 家长后台 +│ ├── login.html +│ └── _book_cover.html # 书架封面局部(主墙与详情弹窗共用) +└── static/ + ├── style.css + └── covers/ # 上传的本地封面 +``` + +## 说明 + +- 图表用纯 SVG 生成,无任何外部 CDN 依赖,断网也能用。 +- 按需求定制:单孩子、无 PIN、Python、不用 Docker、小学阶段、手动录课表、轻量徽章。 +- 多孩子、教材章节模板、更多健康指标可在现有数据模型上平滑扩展。 diff --git a/app.py b/app.py new file mode 100644 index 0000000..7566ffd --- /dev/null +++ b/app.py @@ -0,0 +1,1376 @@ +""" +个人成长系统(儿童向 · 自托管) +- 单孩子、无 PIN 直进仪表盘 +- Python + Flask + SQLite,纯本地、离线可用、无 Docker +- 模块:学科年级进度 / 读书 / 运动 / 健康 / 轻量徽章 +""" + +import os +import io +import sqlite3 +import json +import datetime +import time + +from flask import ( + Flask, request, session, redirect, url_for, + render_template, g, Response, abort, +) +from werkzeug.utils import secure_filename + +# 图片处理:有 Pillow 时自动把上传封面标准化(统一 2:3、压缩为 JPEG); +# 没有则用原始文件兜底,不影响其余功能。 +try: + from PIL import Image + HAVE_PIL = True +except Exception: + Image = None + HAVE_PIL = False + +BASE = os.path.dirname(os.path.abspath(__file__)) +DATA_DIR = os.path.join(BASE, "data") +DB_PATH = os.path.join(DATA_DIR, "tracker.db") +COVERS_DIR = os.path.join(BASE, "static", "covers") +ALLOWED_COVER_EXT = {"png", "jpg", "jpeg", "gif", "webp"} + +# 封面标准化规格:统一裁切进 2:3 画布(居中留白),输出 JPEG +COVER_W, COVER_H = 360, 540 # 标准封面 +THUMB_W, THUMB_H = 160, 240 # 书架墙缩略图 +COVER_QUALITY = 88 +THUMB_QUALITY = 80 + +# 书籍分类(书架墙筛选用)。第一项为默认值。 +BOOK_CATEGORIES = [ + "未分类", "绘本故事", "童话文学", "科普百科", + "历史人文", "漫画", "英文读物", "工具书", +] + +# 浮动快捷打卡项 +CHECKIN_KINDS = { + "rope": {"icon": "🪢", "name": "跳绳打卡", "done": "已跳绳"}, + "situp": {"icon": "🤸", "name": "仰卧起坐打卡", "done": "已做仰卧起坐"}, + "read": {"icon": "📖", "name": "读书打卡", "done": "已读书"}, +} + +# 同龄标准身高(cm)/体重(kg) 参考中位数(WHO 生长参考,仅作对照,不是诊断依据) +GROWTH_REF = { + "M": { + 3: (96.1, 14.3), 4: (103.3, 16.3), 5: (110.0, 18.3), 6: (116.0, 20.5), + 7: (121.7, 22.9), 8: (127.3, 25.4), 9: (132.6, 28.1), 10: (137.8, 31.2), + 11: (143.1, 34.7), 12: (149.1, 38.7), 13: (156.0, 43.4), 14: (163.2, 48.8), + 15: (169.0, 54.0), 16: (172.9, 58.1), 17: (175.2, 61.5), 18: (176.5, 64.0), + }, + "F": { + 3: (95.1, 13.9), 4: (102.7, 16.0), 5: (109.4, 17.9), 6: (115.1, 19.9), + 7: (120.8, 22.4), 8: (126.6, 25.0), 9: (132.5, 28.2), 10: (138.6, 31.9), + 11: (145.0, 36.1), 12: (151.2, 40.8), 13: (156.4, 45.0), 14: (159.8, 47.6), + 15: (161.7, 49.4), 16: (162.5, 50.8), 17: (162.9, 51.8), 18: (163.1, 52.5), + }, +} + +CN_NUM = {"一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9} + +ADMIN_USER = os.environ.get("ADMIN_USER", "admin") +ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "admin123") +PORT = int(os.environ.get("PORT", "5000")) + +app = Flask(__name__) +app.secret_key = os.environ.get("SECRET_KEY", "change-me-tracker-secret") +# 不限制上传大小:大图交由本地标准化流程压缩,不会原样落盘。 + +# --------------------------------------------------------------------------- +# 数据库 +# --------------------------------------------------------------------------- + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS child ( + id INTEGER PRIMARY KEY, + name TEXT, + grade_label TEXT, + school_year TEXT, + created_at TEXT, + term_start TEXT, + term_end TEXT, + default_theme TEXT DEFAULT 'light', + birth_date TEXT, + gender TEXT DEFAULT 'M', + carousel_sec INTEGER DEFAULT 8, + ring_sec INTEGER DEFAULT 6 +); +CREATE TABLE IF NOT EXISTS subject ( + id INTEGER PRIMARY KEY, + child_id INTEGER, + name TEXT, + color TEXT, + icon TEXT, + sort_order INTEGER DEFAULT 0, + group_id INTEGER DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS carousel_group ( + id INTEGER PRIMARY KEY, + name TEXT, + interval_sec INTEGER DEFAULT 8, + sort_order INTEGER DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS unit ( + id INTEGER PRIMARY KEY, + subject_id INTEGER, + title TEXT, + seq INTEGER DEFAULT 0, + target_date TEXT, + status INTEGER DEFAULT 0, -- 0未开始 1进行中 2已完成 + completed_at TEXT +); +CREATE TABLE IF NOT EXISTS book ( + id INTEGER PRIMARY KEY, + child_id INTEGER, + title TEXT, + author TEXT, + total_pages INTEGER DEFAULT 0, + status TEXT DEFAULT 'want', -- want / reading / done + start_date TEXT, + finish_date TEXT, + rating INTEGER DEFAULT 0, + category TEXT DEFAULT '未分类', + cover_url TEXT, + cover_thumb TEXT +); +CREATE TABLE IF NOT EXISTS reading_log ( + id INTEGER PRIMARY KEY, + book_id INTEGER, + date TEXT, + pages INTEGER DEFAULT 0, + minutes INTEGER DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS exercise_log ( + id INTEGER PRIMARY KEY, + child_id INTEGER, + date TEXT, + type TEXT, + duration_min INTEGER DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS health_log ( + id INTEGER PRIMARY KEY, + child_id INTEGER, + date TEXT, + sleep_hours REAL, + height_cm REAL, + weight_kg REAL, + water_cups INTEGER, + mood TEXT +); +CREATE TABLE IF NOT EXISTS goal ( + id INTEGER PRIMARY KEY, + child_id INTEGER, + book_target INTEGER DEFAULT 30, + week_exercise_min INTEGER DEFAULT 120 +); +CREATE TABLE IF NOT EXISTS checkin ( + id INTEGER PRIMARY KEY, + child_id INTEGER, + date TEXT, + kind TEXT, + created_at TEXT +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_checkin_uniq ON checkin (child_id, date, kind); +""" + +DEFAULT_SUBJECTS = [ + ("语文", "#ef4444", "📖"), + ("数学", "#3b82f6", "🔢"), + ("英语", "#f59e0b", "🔤"), + ("科学", "#10b981", "🔬"), + ("体育", "#8b5cf6", "⚽"), + ("音乐", "#ec4899", "🎵"), + ("美术", "#14b8a6", "🎨"), + ("道德与法治", "#f97316", "🌟"), + ("信息技术", "#6366f1", "💻"), + ("劳动", "#84cc16", "🧹"), +] + + +def get_db(): + if "db" not in g: + os.makedirs(DATA_DIR, exist_ok=True) + g.db = sqlite3.connect(DB_PATH) + g.db.row_factory = sqlite3.Row + return g.db + + +@app.teardown_appcontext +def close_db(e): + db = g.pop("db", None) + if db: + db.close() + + +def init_db(): + os.makedirs(DATA_DIR, exist_ok=True) + db = sqlite3.connect(DB_PATH) + db.row_factory = sqlite3.Row + db.executescript(SCHEMA) + db.commit() + db.close() + migrate_db() + + +def migrate_db(): + """已存在的数据库补列/建表,避免删库重建。""" + if not os.path.exists(DB_PATH): + return + db = sqlite3.connect(DB_PATH) + db.row_factory = sqlite3.Row + # book.cover_url + cols = [r["name"] for r in db.execute("PRAGMA table_info(book)").fetchall()] + if "cover_url" not in cols: + db.execute("ALTER TABLE book ADD COLUMN cover_url TEXT") + if "cover_thumb" not in cols: + db.execute("ALTER TABLE book ADD COLUMN cover_thumb TEXT") + if "category" not in cols: + db.execute("ALTER TABLE book ADD COLUMN category TEXT DEFAULT '未分类'") + db.execute("UPDATE book SET category='未分类' WHERE category IS NULL OR category=''") + # child 学期起止 + 默认主题 + 生日/性别 + 轮播间隔 + ccols = [r["name"] for r in db.execute("PRAGMA table_info(child)").fetchall()] + for c in ("term_start", "term_end", "default_theme", "birth_date", "gender"): + if c not in ccols: + db.execute(f"ALTER TABLE child ADD COLUMN {c} TEXT") + if "gender" not in ccols: + db.execute("UPDATE child SET gender='M' WHERE gender IS NULL OR gender=''") + if "carousel_sec" not in ccols: + db.execute("ALTER TABLE child ADD COLUMN carousel_sec INTEGER DEFAULT 8") + if "ring_sec" not in ccols: + db.execute("ALTER TABLE child ADD COLUMN ring_sec INTEGER DEFAULT 6") + # subject.group_id(分组功能已下线,保留列避免老库报错) + scols = [r["name"] for r in db.execute("PRAGMA table_info(subject)").fetchall()] + if "group_id" not in scols: + db.execute("ALTER TABLE subject ADD COLUMN group_id INTEGER DEFAULT 0") + # 快捷打卡表 + db.execute("""CREATE TABLE IF NOT EXISTS checkin ( + id INTEGER PRIMARY KEY, + child_id INTEGER, + date TEXT, + kind TEXT, + created_at TEXT + )""") + db.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_checkin_uniq ON checkin (child_id, date, kind)") + db.commit() + db.close() + + +def seed_if_empty(): + db = sqlite3.connect(DB_PATH) + db.row_factory = sqlite3.Row + child = db.execute("SELECT * FROM child LIMIT 1").fetchone() + if child: + return + today = datetime.date.today() + y0 = today.year + sy = f"{y0}-{y0 + 1}" + term_start = datetime.date(y0, 9, 1).isoformat() + term_end = datetime.date(y0 + 1, 8, 31).isoformat() + birth = (today - datetime.timedelta(days=int(365.25 * 8.5))).isoformat() + cur = db.execute( + "INSERT INTO child (name, grade_label, school_year, created_at, term_start, term_end, " + "default_theme, birth_date, gender, carousel_sec, ring_sec) VALUES (?,?,?,?,?,?,?,?,?,?,?)", + ("小明", "小学三年级", sy, today.isoformat(), term_start, term_end, + "light", birth, "M", 8, 6), + ) + child_id = cur.lastrowid + db.execute( + "INSERT INTO goal (child_id, book_target, week_exercise_min) VALUES (?,?,?)", + (child_id, 30, 120), + ) + subj_ids = {} + for i, (name, color, icon) in enumerate(DEFAULT_SUBJECTS): + sid = db.execute( + "INSERT INTO subject (child_id, name, color, icon, sort_order) VALUES (?,?,?,?,?)", + (child_id, name, color, icon, i), + ).lastrowid + subj_ids[name] = sid + + # 样例单元(按任务顺序:前面已完成、当前进行中、后面未开始) + samples = { + "语文": ["第一单元 课文朗读", "第二单元 古诗背诵", "第三单元 写话练习", "第四单元 阅读理解", + "第五单元 标点符号", "第六单元 看图写话", "第七单元 口语交际", "第八单元 综合复习"], + "数学": ["万以内的加法", "万以内的减法", "倍的认识", "长方形和正方形", + "时分秒", "多位数乘一位数", "分数的初步认识", "周长"], + "英语": ["Unit1 Hello", "Unit2 Colors", "Unit3 Animals", "Unit4 Numbers", + "Unit5 Food", "Unit6 School", "Unit7 Family", "Unit8 Review"], + "科学": ["水的三态", "植物的一生", "空气的秘密", "光与影", + "磁铁游戏", "简单机械", "天气观察", "复习与实验"], + } + for name, units in samples.items(): + for j, title in enumerate(units): + st = 2 if j < 4 else (1 if j == 4 else 0) + done_at = (today - datetime.timedelta(days=(4 - j) * 6)).isoformat() if j < 4 else None + db.execute( + "INSERT INTO unit (subject_id, title, seq, status, completed_at) VALUES (?,?,?,?,?)", + (subj_ids[name], title, j, st, done_at), + ) + + # 样例书目 + db.execute( + "INSERT INTO book (child_id, title, author, total_pages, status, start_date, category) " + "VALUES (?,?,?,?,?,?,?)", + (child_id, "小王子", "圣埃克苏佩里", 120, "reading", today, "童话文学"), + ) + for t, a, pages, days, rate, cat in [ + ("夏洛的网", "E.B.怀特", 180, 3, 5, "童话文学"), + ("神奇校车·地球内部探秘", "乔安娜·柯尔", 40, 20, 4, "科普百科"), + ("窗边的小豆豆", "黑柳彻子", 220, 45, 5, "童话文学"), + ("大卫,不可以", "大卫·香农", 32, 70, 4, "绘本故事"), + ("中国历史故事集", "林汉达", 260, 95, 3, "历史人文"), + ]: + db.execute( + "INSERT INTO book (child_id, title, author, total_pages, status, finish_date, rating, category) " + "VALUES (?,?,?,?,?,?,?,?)", + (child_id, t, a, pages, "done", + (datetime.date.today() - datetime.timedelta(days=days)).isoformat(), rate, cat), + ) + + # 样例运动(近两周,制造连续打卡) + ex_types = ["跑步", "跳绳", "游泳", "骑车"] + for d in range(12, 0, -1): + day = (datetime.date.today() - datetime.timedelta(days=d)).isoformat() + db.execute( + "INSERT INTO exercise_log (child_id, date, type, duration_min) VALUES (?,?,?,?)", + (child_id, day, ex_types[d % len(ex_types)], 30), + ) + # 样例健康:近两年的每月体检记录,身高体重稳步增长 + for i in range(23, -1, -1): + day = (today - datetime.timedelta(days=i * 30)).isoformat() + height = round(124.0 + (11 - i) * 0.6, 1) + weight = round(24.4 + (11 - i) * 0.26, 1) + db.execute( + "INSERT INTO health_log (child_id, date, sleep_hours, height_cm, weight_kg, water_cups, mood) VALUES (?,?,?,?,?,?,?)", + (child_id, day, 9.0 + (i % 2) * 0.5, height, weight, 6, "开心"), + ) + # 样例快捷打卡(近一周) + for i in range(6, 0, -1): + day = (today - datetime.timedelta(days=i)).isoformat() + for kind in ("rope", "read"): + db.execute( + "INSERT OR IGNORE INTO checkin (child_id, date, kind, created_at) VALUES (?,?,?,?)", + (child_id, day, kind, day)) + db.commit() + db.close() + + +# --------------------------------------------------------------------------- +# 任务流转 / 年龄与生长参考 +# --------------------------------------------------------------------------- + +def normalize_units(db, subject_id): + """按任务顺序整理状态:已完成保持完成,第一个未完成的自动变「进行中」,其余「未开始」。""" + rows = db.execute( + "SELECT id, status FROM unit WHERE subject_id=? ORDER BY seq, id", (subject_id,)).fetchall() + found = False + for r in rows: + if r["status"] == 2: + continue + want = 1 if not found else 0 + found = True + if r["status"] != want: + db.execute("UPDATE unit SET status=? WHERE id=?", (want, r["id"])) + + +def unit_window(units, before=3, after=3): + """取「最近完成的 N 个 + 进行中的 1 个 + 后面未完成的 N 个」这一段任务。""" + items = [dict(u) for u in units] + if not items: + return [] + cur = next((i for i, u in enumerate(items) if u["status"] == 1), None) + if cur is None: + cur = next((i for i, u in enumerate(items) if u["status"] != 2), None) + if cur is None: # 全部完成 + return items[-(before + after + 1):] + head = [u for u in items[:cur] if u["status"] == 2][-before:] + tail = [u for u in items[cur + 1:] if u["status"] != 2][:after] + return head + [items[cur]] + tail + + +def child_age(child): + """孩子年龄(浮点年)。优先用生日,没填则按年级估算。""" + bd = child["birth_date"] if "birth_date" in child.keys() else None + if bd: + try: + b = datetime.date.fromisoformat(bd) + return max(0.0, (datetime.date.today() - b).days / 365.25) + except Exception: + pass + label = child["grade_label"] or "" + for ch, n in CN_NUM.items(): + if ch + "年级" in label: + return 6.0 + n - 0.5 + import re + m = re.search(r"(\d+)\s*年级", label) + if m: + return 6.0 + int(m.group(1)) - 0.5 + return 8.5 + + +def standard_hw(age, gender="M"): + """按年龄+性别线性插值出标准身高(cm)/体重(kg),超出参考范围则取端点。""" + table = GROWTH_REF.get((gender or "M").upper(), GROWTH_REF["M"]) + ages = sorted(table) + if age <= ages[0]: + return table[ages[0]] + if age >= ages[-1]: + return table[ages[-1]] + lo = max(a for a in ages if a <= age) + hi = min(a for a in ages if a >= age) + if lo == hi: + return table[lo] + t = (age - lo) / (hi - lo) + h = table[lo][0] + (table[hi][0] - table[lo][0]) * t + w = table[lo][1] + (table[hi][1] - table[lo][1]) * t + return (round(h, 1), round(w, 1)) + + +# --------------------------------------------------------------------------- +# 进度 / 徽章 计算 +# --------------------------------------------------------------------------- + +def subject_progress(db, subject_id): + rows = db.execute("SELECT status FROM unit WHERE subject_id=?", (subject_id,)).fetchall() + total = len(rows) + if total == 0: + return 0.0 + done = sum(1 for r in rows if r["status"] == 2) + return round(done / total * 100, 1) + + +def year_progress(db, child_id): + subs = db.execute("SELECT id FROM subject WHERE child_id=?", (child_id,)).fetchall() + total_units = 0 + done_units = 0 + for s in subs: + rows = db.execute("SELECT status FROM unit WHERE subject_id=?", (s["id"],)).fetchall() + total_units += len(rows) + done_units += sum(1 for r in rows if r["status"] == 2) + if total_units == 0: + return 0.0 + return round(done_units / total_units * 100, 1) + + +def max_streak(dates): + s = sorted(set(dates)) + if not s: + return 0 + best = cur = 1 + for i in range(1, len(s)): + try: + d1 = datetime.date.fromisoformat(s[i - 1]) + d2 = datetime.date.fromisoformat(s[i]) + except Exception: + continue + gap = (d2 - d1).days + if gap == 1: + cur += 1 + best = max(best, cur) + elif gap == 0: + pass + else: + cur = 1 + return best + + +def compute_badges(db, child_id, yprog): + """徽章完全由数据派生(任务/读书/运动/进度),随任务增删改即时同步。""" + ex_dates = [r["date"] for r in db.execute( + "SELECT date FROM exercise_log WHERE child_id=?", (child_id,)).fetchall()] + books = db.execute( + "SELECT finish_date FROM book WHERE child_id=? AND status='done'", (child_id,)).fetchall() + done_books = len(books) + # 任务完成数(按完成时间排序,用于推算获得时间) + done_units = db.execute( + "SELECT completed_at FROM unit WHERE status=2 AND completed_at IS NOT NULL ORDER BY completed_at" + ).fetchall() + done_units_dates = [r["completed_at"][:10] for r in done_units] + total_done = len(done_units_dates) + subs = db.execute("SELECT id FROM subject WHERE child_id=?", (child_id,)).fetchall() + any_master = any(subject_progress(db, s["id"]) >= 100 for s in subs) + master_at = None + for s in subs: + if subject_progress(db, s["id"]) >= 100: + mx = db.execute( + "SELECT MAX(completed_at) m FROM unit WHERE subject_id=? AND completed_at IS NOT NULL", + (s["id"],)).fetchone()["m"] + if mx: + master_at = mx[:10] + break + # 任意活动连续天数(运动 + 单元完成日) + act_dates = set(ex_dates) | set(done_units_dates) + + def nth_date(n): + return done_units_dates[n - 1] if total_done >= n else None + + first_book = min((b["finish_date"] for b in books if b["finish_date"]), default=None) + + badges = [ + {"key": "task_first", "name": "迈出第一步", "icon": "👣", + "desc": "完成第 1 个任务", "got": total_done >= 1, "got_at": nth_date(1)}, + {"key": "task10", "name": "任务小达人", "icon": "⭐", + "desc": "累计完成 10 个任务", "got": total_done >= 10, "got_at": nth_date(10)}, + {"key": "task30", "name": "任务大师", "icon": "🏅", + "desc": "累计完成 30 个任务", "got": total_done >= 30, "got_at": nth_date(30)}, + {"key": "book_first", "name": "读完第一本书", "icon": "📚", + "desc": "读完第 1 本书", "got": done_books >= 1, "got_at": first_book}, + {"key": "exercise7", "name": "连续运动 7 天", "icon": "🔥", + "desc": "运动打卡连续 7 天", "got": max_streak(ex_dates) >= 7, "got_at": None}, + {"key": "subject_master", "name": "单科全通关", "icon": "🏆", + "desc": "某一学科全部任务完成", "got": any_master, "got_at": master_at}, + {"key": "year_half", "name": "学年进度过半", "icon": "🌈", + "desc": "学年总进度达到 50%", "got": yprog >= 50, "got_at": None}, + {"key": "streak30", "name": "连续打卡 30 天", "icon": "💎", + "desc": "任意活动连续打卡 30 天", "got": max_streak(act_dates) >= 30, "got_at": None}, + ] + return badges + + +# --------------------------------------------------------------------------- +# SVG 图表(纯离线,无外部库) +# --------------------------------------------------------------------------- + +def ring_svg(pct, color, size=130, label=""): + pct = max(0, min(100, pct)) + r = size / 2 - 12 + cx = size / 2 + circ = 2 * 3.1415926 * r + dash = circ * (pct / 100) + return f''' + + + + {pct:.0f}% + {label} + ''' + + +def mini_ring_svg(pct, color, size=76): + """小型学科进度环(用于「各学科进度一览」一行多环)。""" + pct = max(0, min(100, pct)) + r = size / 2 - 8 + cx = size / 2 + circ = 2 * 3.1415926 * r + dash = circ * (pct / 100) + return f''' + + + {pct:.0f}% + ''' + + +def bar_svg(pct, color): + pct = max(0, min(100, pct)) + return f''' +
+ {pct:.0f}%
''' + + +def heatmap_svg(active_dates, days=371): + active = set(active_dates) + end = datetime.date.today() + start = end - datetime.timedelta(days=days) + # 对齐到周一 + start -= datetime.timedelta(days=start.weekday()) + cells = [] + d = start + while d <= end: + iso = d.isoformat() + on = iso in active + cells.append((iso, on, d.weekday())) + d += datetime.timedelta(days=1) + weeks = (len(cells) + 6) // 7 + cell = 11 + gap = 3 + w = weeks * (cell + gap) + h = 7 * (cell + gap) + rects = [] + for i, (iso, on, wd) in enumerate(cells): + col = i // 7 + row = i % 7 + x = col * (cell + gap) + y = row * (cell + gap) + fill = "#34d399" if on else "#ebedf0" + rects.append(f'{iso}') + return f'{"".join(rects)}' + + +def sparkline_svg(values, color="#3b82f6", width=220, height=48): + vals = [v for v in values if v is not None] + if len(vals) < 2: + return '
数据不足
' + mn, mx = min(vals), max(vals) + if mx == mn: + mx = mn + 1 + n = len(vals) + step = width / (n - 1) + pts = [] + for i, v in enumerate(vals): + x = i * step + y = height - ((v - mn) / (mx - mn)) * (height - 6) - 3 + pts.append(f"{x:.1f},{y:.1f}") + poly = " ".join(pts) + return f''' + + + ''' + + +def health_chart_svg(points, std_h=None, std_w=None, width=460, height=200): + """身高/体重综合折线图:两条不同颜色的线画在同一张图里。 + + - 左轴 = 身高(cm),右轴 = 体重(kg),各自独立归一化,避免量纲差异压扁曲线; + - 虚线 = 同龄标准参考值(有则画)。 + points: [{"date": "YYYY-MM-DD", "h": float|None, "w": float|None}, ...] 按日期升序 + """ + pad_l, pad_r, pad_t, pad_b = 8, 8, 16, 24 + iw = width - pad_l - pad_r + ih = height - pad_t - pad_b + hs = [(i, p["h"]) for i, p in enumerate(points) if p["h"] is not None] + ws = [(i, p["w"]) for i, p in enumerate(points) if p["w"] is not None] + if len(hs) < 2 and len(ws) < 2: + return '
还没有足够的身高体重记录
' + n = max(len(points) - 1, 1) + + def scale(vals, extra=None): + arr = list(vals) + ([extra] if extra is not None else []) + mn, mx = min(arr), max(arr) + span = mx - mn + if span < 1e-6: + mn, mx = mn - 1, mx + 1 + span = mx - mn + mn -= span * 0.18 + mx += span * 0.18 + return mn, mx + + def path_of(series, mn, mx): + pts = [] + for i, v in series: + x = pad_l + (i / n) * iw + y = pad_t + ih - (v - mn) / (mx - mn) * ih + pts.append((x, y)) + return pts + + parts = [f''] + # 横向网格 + for k in range(1, 4): + y = pad_t + ih * k / 4 + parts.append(f'') + + def draw(series, color, cls, std): + if len(series) < 2: + return + mn, mx = scale([v for _, v in series], std) + pts = path_of(series, mn, mx) + d = " ".join(f"{'M' if i == 0 else 'L'}{x:.1f},{y:.1f}" for i, (x, y) in enumerate(pts)) + parts.append(f'') + for x, y in pts: + parts.append(f'') + lx, ly = pts[-1] + parts.append(f'') + if std is not None and mn < std < mx: + sy = pad_t + ih - (std - mn) / (mx - mn) * ih + parts.append(f'') + + draw(hs, "#10b981", "h", std_h) + draw(ws, "#f59e0b", "w", std_w) + + # 横轴首尾日期 + if points: + parts.append(f'' + f'{points[0]["date"][5:].replace("-", "/")}') + parts.append(f'{points[-1]["date"][5:].replace("-", "/")}') + return (f'{"".join(parts)}') + + +def term_calendar_svg(active_counts, term_start, term_end): + """学期日历小图:每点代表一天,不用具体日期,靠颜色区分。 + + - 绿系 = 当天有打卡(越深=打卡项越多) + - 蓝色 = 周末且无打卡 + - 灰色 = 工作日且无打卡 + - 圆环 = 今天 + 配色走 CSS class(tc-*),方便暗色主题切换。 + """ + cell, gap = 8, 3 + today = datetime.date.today() + start = term_start - datetime.timedelta(days=term_start.weekday()) # 对齐周一 + cells = [] + d = start + while d <= term_end: + iso = d.isoformat() + cnt = active_counts.get(iso, 0) + cells.append((iso, cnt, d.weekday(), d == today)) + d += datetime.timedelta(days=1) + weeks = (len(cells) + 6) // 7 + w = weeks * (cell + gap) + h = 7 * (cell + gap) + + def cls(cnt, wd): + if cnt >= 3: + return "tc-act3" + if cnt == 2: + return "tc-act2" + if cnt == 1: + return "tc-act1" + return "tc-week" if wd >= 5 else "tc-none" + + rects = [] + for i, (iso, cnt, wd, is_today) in enumerate(cells): + col = i // 7 + row = i % 7 + x = col * (cell + gap) + y = row * (cell + gap) + c = cls(cnt, wd) + tcls = " tc-today" if is_today else "" + title = f"{iso} 打卡 {cnt} 项" + rects.append( + f'{title}') + return (f'' + f'{"".join(rects)}') + + +def cover_bg(title): + """没有封面时,按书名生成稳定的占位底色。""" + import hashlib + h = int(hashlib.md5(title.encode("utf-8")).hexdigest(), 16) + return f"hsl({h % 360}, 58%, 72%)" + + +def _cover_bytes(img, W, H, quality): + """把图按比例缩放进 W×H 白色画布(居中留白),返回 JPEG 字节。""" + src = img.convert("RGB") + src.thumbnail((W, H), Image.LANCZOS) + canvas = Image.new("RGB", (W, H), (255, 255, 255)) + canvas.paste(src, ((W - src.width) // 2, (H - src.height) // 2)) + buf = io.BytesIO() + canvas.save(buf, format="JPEG", quality=quality, optimize=True, progressive=True) + return buf.getvalue() + + +def _safe_cover_path(stored): + """把数据库里的封面路径解析为 COVERS_DIR 内的安全绝对路径,越界返回 None。""" + if not stored: + return None + name = os.path.basename(stored.split("?")[0]) + if not name: + return None + full = os.path.normpath(os.path.join(COVERS_DIR, name)) + if os.path.commonpath([COVERS_DIR, full]) != os.path.normpath(COVERS_DIR): + return None + return full + + +def _remove_cover_files(full_url, thumb_url): + """删除已落盘的封面文件(仅限 COVERS_DIR 内),失败忽略。""" + for u in (full_url, thumb_url): + p = _safe_cover_path(u) + if p and os.path.exists(p): + try: + os.remove(p) + except OSError: + pass + + +def process_and_save_cover(f, bid, old_full=None, old_thumb=None): + """处理上传封面并标准化保存,返回 (full_url, thumb_url);非法/失败返回 None。 + + - 不限制原图大小/格式,统一裁切进 2:3 画布并压缩为 JPEG; + - 覆盖旧封面时会先删除旧文件,避免堆积。 + """ + if not f or not f.filename: + return None + ext = f.filename.rsplit(".", 1)[-1].lower() if "." in f.filename else "" + if ext not in ALLOWED_COVER_EXT: + return None + try: + data = f.read() + if not data: + return None + if HAVE_PIL: + img = Image.open(io.BytesIO(data)) + img.load() + full = _cover_bytes(img, COVER_W, COVER_H, COVER_QUALITY) + thumb = _cover_bytes(img, THUMB_W, THUMB_H, THUMB_QUALITY) + else: + full = data + thumb = data + except Exception: + return None + os.makedirs(COVERS_DIR, exist_ok=True) + ts = int(time.time()) + ext_out = "jpg" if HAVE_PIL else (ext or "jpg") + full_name = f"{bid}_{ts}.{ext_out}" + thumb_name = f"{bid}_{ts}_t.{ext_out}" + with open(os.path.join(COVERS_DIR, full_name), "wb") as fh: + fh.write(full) + with open(os.path.join(COVERS_DIR, thumb_name), "wb") as fh: + fh.write(thumb) + # 覆盖旧文件 + if old_full or old_thumb: + _remove_cover_files(old_full, old_thumb) + return (f"/static/covers/{full_name}", f"/static/covers/{thumb_name}") + + +# --------------------------------------------------------------------------- +# 路由 +# --------------------------------------------------------------------------- + +@app.route("/") +def dashboard(): + db = get_db() + child = db.execute("SELECT * FROM child LIMIT 1").fetchone() + if not child: + return redirect(url_for("admin")) + cid = child["id"] + yprog = year_progress(db, cid) + + subjects = [] + for s in db.execute("SELECT * FROM subject WHERE child_id=? ORDER BY sort_order", (cid,)).fetchall(): + units = db.execute( + "SELECT * FROM unit WHERE subject_id=? ORDER BY seq, id", (s["id"],)).fetchall() + subjects.append({ + "id": s["id"], "name": s["name"], "color": s["color"], "icon": s["icon"], + "progress": subject_progress(db, s["id"]), "units": units, + "window": unit_window(units), + "total": len(units), + "done": sum(1 for u in units if u["status"] == 2), + }) + + goal = db.execute("SELECT * FROM goal WHERE child_id=?", (cid,)).fetchone() + books = db.execute("SELECT * FROM book WHERE child_id=? ORDER BY id DESC", (cid,)).fetchall() + done_books = sum(1 for b in books if b["status"] == "done") + + # 健康:仅展示最近两年 + cut = (datetime.date.today() - datetime.timedelta(days=730)).isoformat() + health_rows = db.execute( + "SELECT date, sleep_hours, height_cm, weight_kg FROM health_log " + "WHERE child_id=? AND date>=? ORDER BY date", + (cid, cut)).fetchall() + hpoints = [{"date": r["date"], "h": r["height_cm"], "w": r["weight_kg"]} + for r in health_rows if r["height_cm"] is not None or r["weight_kg"] is not None] + cur_h = next((r["height_cm"] for r in reversed(health_rows) if r["height_cm"] is not None), None) + cur_w = next((r["weight_kg"] for r in reversed(health_rows) if r["weight_kg"] is not None), None) + age = child_age(child) + gender = (child["gender"] or "M") if "gender" in child.keys() else "M" + std_h, std_w = standard_hw(age, gender) + + badges = compute_badges(db, cid, yprog) + got_badges = [b for b in badges if b["got"]] + + # 学期日历:每日活跃打卡数(学科完成 + 运动 + 阅读 + 健康 + 快捷打卡) + from collections import defaultdict + ac = defaultdict(int) + for r in db.execute("SELECT completed_at FROM unit WHERE completed_at IS NOT NULL").fetchall(): + ac[r["completed_at"][:10]] += 1 + for r in db.execute("SELECT date FROM exercise_log WHERE child_id=?", (cid,)).fetchall(): + ac[r["date"]] += 1 + for r in db.execute( + "SELECT rl.date FROM reading_log rl JOIN book b ON rl.book_id=b.id WHERE b.child_id=?", (cid,)).fetchall(): + ac[r["date"]] += 1 + for r in db.execute("SELECT date FROM health_log WHERE child_id=?", (cid,)).fetchall(): + ac[r["date"]] += 1 + for r in db.execute("SELECT date FROM checkin WHERE child_id=?", (cid,)).fetchall(): + ac[r["date"]] += 1 + + # 学期起止:后台可设定 term_start / term_end;否则按学年 9/1 开学推算 + today = datetime.date.today() + try: + y0 = int(child["school_year"].split("-")[0]) + fallback_start = datetime.date(y0, 9, 1) + except Exception: + fallback_start = today - datetime.timedelta(days=120) + ts_raw = child["term_start"] + term_start = datetime.date.fromisoformat(ts_raw) if ts_raw else fallback_start + # 结束:若是过去日期用设定值,否则显示到今天 + term_end = today + te_raw = child["term_end"] + if te_raw: + try: + te = datetime.date.fromisoformat(te_raw) + if te < today: + term_end = te + except Exception: + pass + # 展示起点:学期还没开始(起点在未来)时回退到近 150 天,避免空白 + disp_start = term_start if term_start <= today else (today - datetime.timedelta(days=150)) + term_start_str = disp_start.strftime("%Y.%m.%d") + term_end_str = term_end.strftime("%Y.%m.%d") + + # 轮播间隔(后台统一设定,不再分组) + ring_sec = (child["ring_sec"] if "ring_sec" in child.keys() else None) or 6 + carousel_sec = (child["carousel_sec"] if "carousel_sec" in child.keys() else None) or 8 + # 各学科进度一览:每屏 3 个小环,其余轮播 + ring_slides = [subjects[i:i + 3] for i in range(0, len(subjects), 3)] or [[]] + # 学科进度:每屏 1 个学科 + slides = [[s] for s in subjects] + + # 主题:后台统一设定 + theme = child["default_theme"] or "light" + + # 书架墙:已读完的书 + 封面占位色(优先用标准化缩略图) + shelf = [] + for b in books: + if b["status"] == "done": + cat = (b["category"] or "").strip() or "未分类" + fin = b["finish_date"] or "" + shelf.append({ + "id": b["id"], + "title": b["title"], "author": b["author"] or "", + "cover": b["cover_thumb"] or b["cover_url"] or "", "bg": cover_bg(b["title"]), + "rating": b["rating"] or 0, + "category": cat, + "finish": fin, + "finish_str": fin.replace("-", ".") if fin else "", + }) + # 默认按读完日期倒序(无日期排最后) + shelf.sort(key=lambda x: (x["finish"] or "0000-00-00"), reverse=True) + # 首页只放 6 本:上排 3 本最近读完,下排 3 本从其余已读里随机 + import random + shelf_recent = shelf[:3] + rest = shelf[3:] + shelf_random = random.sample(rest, min(3, len(rest))) if rest else [] + # 分类计数(只统计书架上真实出现过的分类,按预设顺序排) + cat_count = {} + for it in shelf: + cat_count[it["category"]] = cat_count.get(it["category"], 0) + 1 + ordered = [c for c in BOOK_CATEGORIES if c in cat_count] + ordered += [c for c in cat_count if c not in BOOK_CATEGORIES] + shelf_cats = [{"name": c, "count": cat_count[c]} for c in ordered] + + # 今日快捷打卡状态 + done_kinds = {r["kind"] for r in db.execute( + "SELECT kind FROM checkin WHERE child_id=? AND date=?", (cid, today.isoformat())).fetchall()} + checkins = [{"key": k, "icon": v["icon"], "name": v["name"], "done_name": v["done"], + "done": k in done_kinds} for k, v in CHECKIN_KINDS.items()] + + return render_template( + "dashboard.html", + child=child, yprog=yprog, subjects=subjects, + ring_svg=ring_svg, bar_svg=bar_svg, mini_ring_svg=mini_ring_svg, + term_calendar_svg=term_calendar_svg, cover_bg=cover_bg, + health_chart_svg=health_chart_svg, + goal=goal, books=books, done_books=done_books, + hpoints=hpoints, cur_h=cur_h, cur_w=cur_w, + std_h=std_h, std_w=std_w, age=round(age, 1), + badges=badges, got_badges=got_badges, + active_counts=ac, term_start=disp_start, term_end=term_end, + term_start_str=term_start_str, term_end_str=term_end_str, + slides=slides, ring_slides=ring_slides, + carousel_sec=carousel_sec, ring_sec=ring_sec, + theme=theme, + today=today, today_str=today.strftime("%Y.%m.%d"), + shelf=shelf, shelf_recent=shelf_recent, shelf_random=shelf_random, + shelf_cats=shelf_cats, book_categories=BOOK_CATEGORIES, + checkins=checkins, + now=datetime.date.today().isoformat(), + ) + + +@app.post("/unit//toggle") +def unit_toggle(uid): + """完成/取消一个任务;完成后同学科的下一个任务自动变成「进行中」。""" + db = get_db() + u = db.execute("SELECT * FROM unit WHERE id=?", (uid,)).fetchone() + if not u: + abort(404) + today = datetime.date.today().isoformat() + if u["status"] == 2: + db.execute("UPDATE unit SET status=0, completed_at=NULL WHERE id=?", (uid,)) + else: + db.execute("UPDATE unit SET status=2, completed_at=? WHERE id=?", (today, uid)) + normalize_units(db, u["subject_id"]) + db.commit() + return redirect(url_for("dashboard")) + + +@app.post("/checkin/") +def checkin_toggle(kind): + """浮动快捷打卡:跳绳 / 仰卧起坐 / 读书。已打卡再点一次可撤销。""" + if kind not in CHECKIN_KINDS: + abort(404) + db = get_db() + row = db.execute("SELECT id FROM child LIMIT 1").fetchone() + if not row: + abort(404) + cid = row["id"] + today = datetime.date.today().isoformat() + exist = db.execute( + "SELECT id FROM checkin WHERE child_id=? AND date=? AND kind=?", (cid, today, kind)).fetchone() + if exist: + db.execute("DELETE FROM checkin WHERE id=?", (exist["id"],)) + done = False + else: + db.execute( + "INSERT OR IGNORE INTO checkin (child_id, date, kind, created_at) VALUES (?,?,?,?)", + (cid, today, kind, datetime.datetime.now().isoformat(timespec="seconds"))) + done = True + db.commit() + if request.headers.get("X-Ajax") == "1": + return {"ok": True, "kind": kind, "done": done} + return redirect(url_for("dashboard")) + + +@app.post("/reading/add") +def reading_add(): + db = get_db() + cid = db.execute("SELECT id FROM child LIMIT 1").fetchone()["id"] + book_id = request.form.get("book_id", type=int) + pages = request.form.get("pages", type=int, default=0) or 0 + minutes = request.form.get("minutes", type=int, default=0) or 0 + date = request.form.get("date") or datetime.date.today().isoformat() + if book_id: + db.execute( + "INSERT INTO reading_log (book_id, date, pages, minutes) VALUES (?,?,?,?)", + (book_id, date, pages, minutes)) + b = db.execute("SELECT status FROM book WHERE id=?", (book_id,)).fetchone() + if b and b["status"] == "want": + db.execute("UPDATE book SET status='reading', start_date=? WHERE id=?", (date, book_id)) + db.commit() + return redirect(url_for("dashboard")) + + +@app.post("/book/add") +def book_add(): + db = get_db() + cid = db.execute("SELECT id FROM child LIMIT 1").fetchone()["id"] + title = request.form.get("title", "").strip() + if title: + cat = (request.form.get("category", "") or "").strip() + if cat not in BOOK_CATEGORIES: + cat = BOOK_CATEGORIES[0] + cur = db.execute( + "INSERT INTO book (child_id, title, author, total_pages, status, category, cover_url, cover_thumb) " + "VALUES (?,?,?,?,?,?,?,?)", + (cid, title, request.form.get("author", ""), + request.form.get("total_pages", type=int, default=0) or 0, "want", cat, None, None)) + bid = cur.lastrowid + res = process_and_save_cover(request.files.get("cover"), bid) + if res: + full, thumb = res + db.execute("UPDATE book SET cover_url=?, cover_thumb=? WHERE id=?", (full, thumb, bid)) + else: + url = request.form.get("cover_url", "").strip() + if url: + db.execute("UPDATE book SET cover_url=?, cover_thumb=? WHERE id=?", (url, None, bid)) + db.commit() + return redirect(url_for("dashboard")) + + +@app.post("/book/done/") +def book_done(bid): + db = get_db() + today = datetime.date.today().isoformat() + db.execute("UPDATE book SET status='done', finish_date=? WHERE id=?", (today, bid)) + db.commit() + return redirect(url_for("dashboard")) + + +@app.post("/exercise/add") +def exercise_add(): + db = get_db() + cid = db.execute("SELECT id FROM child LIMIT 1").fetchone()["id"] + etype = request.form.get("type", "运动") + minutes = request.form.get("duration_min", type=int, default=0) or 0 + date = request.form.get("date") or datetime.date.today().isoformat() + if minutes > 0: + db.execute( + "INSERT INTO exercise_log (child_id, date, type, duration_min) VALUES (?,?,?,?)", + (cid, date, etype, minutes)) + db.commit() + return redirect(url_for("dashboard")) + + +@app.post("/health/add") +def health_add(): + db = get_db() + cid = db.execute("SELECT id FROM child LIMIT 1").fetchone()["id"] + date = request.form.get("date") or datetime.date.today().isoformat() + db.execute( + "INSERT INTO health_log (child_id, date, sleep_hours, height_cm, weight_kg, water_cups, mood) VALUES (?,?,?,?,?,?,?)", + (cid, date, + request.form.get("sleep_hours", type=float, default=None), + request.form.get("height_cm", type=float, default=None), + request.form.get("weight_kg", type=float, default=None), + request.form.get("water_cups", type=int, default=None), + request.form.get("mood", ""))) + db.commit() + return redirect(url_for("admin")) + + +# --------------------------------------------------------------------------- +# 家长后台 +# --------------------------------------------------------------------------- + +@app.route("/admin/login", methods=["GET", "POST"]) +def admin_login(): + if request.method == "POST": + u = request.form.get("username", "") + p = request.form.get("password", "") + if u == ADMIN_USER and p == ADMIN_PASSWORD: + session["admin"] = True + return redirect(url_for("admin")) + return render_template("admin_login.html", error="用户名或密码错误") + return render_template("admin_login.html", error=None) + + +@app.route("/admin/logout") +def admin_logout(): + session.pop("admin", None) + return redirect(url_for("admin_login")) + + +def admin_required(f): + from functools import wraps + + @wraps(f) + def wrap(*a, **k): + if not session.get("admin"): + return redirect(url_for("admin_login")) + return f(*a, **k) + return wrap + + +@app.post("/admin/book/cover/") +@admin_required +def admin_book_cover(bid): + db = get_db() + b = db.execute("SELECT cover_url, cover_thumb FROM book WHERE id=?", (bid,)).fetchone() + old_full, old_thumb = (b["cover_url"], b["cover_thumb"]) if b else (None, None) + res = process_and_save_cover(request.files.get("cover"), bid, old_full, old_thumb) + if res: + full, thumb = res + db.execute("UPDATE book SET cover_url=?, cover_thumb=? WHERE id=?", (full, thumb, bid)) + db.commit() + else: + url = request.form.get("cover_url", "").strip() + if url: + # 改成外链:先清掉本地文件 + _remove_cover_files(old_full, old_thumb) + db.execute("UPDATE book SET cover_url=?, cover_thumb=? WHERE id=?", (url, None, bid)) + db.commit() + # 既没文件也没 URL:保留原封面,避免误清空 + return redirect(url_for("admin")) + + +@app.post("/admin/book/cover/delete/") +@admin_required +def admin_book_cover_delete(bid): + db = get_db() + b = db.execute("SELECT cover_url, cover_thumb FROM book WHERE id=?", (bid,)).fetchone() + if b: + _remove_cover_files(b["cover_url"], b["cover_thumb"]) + db.execute("UPDATE book SET cover_url=NULL, cover_thumb=NULL WHERE id=?", (bid,)) + db.commit() + return redirect(url_for("admin")) + + +@app.post("/admin/book/meta/") +@admin_required +def admin_book_meta(bid): + """更新书籍分类与评分(书架墙筛选/排序依据)。""" + db = get_db() + cat = (request.form.get("category", "") or "").strip() + if cat not in BOOK_CATEGORIES: + cat = BOOK_CATEGORIES[0] + rating = request.form.get("rating", type=int, default=0) or 0 + rating = max(0, min(5, rating)) + db.execute("UPDATE book SET category=?, rating=? WHERE id=?", (cat, rating, bid)) + db.commit() + return redirect(url_for("admin")) + + +@app.route("/admin") +@admin_required +def admin(): + db = get_db() + child = db.execute("SELECT * FROM child LIMIT 1").fetchone() + cid = child["id"] if child else None + raw_subjects = db.execute( + "SELECT * FROM subject WHERE child_id=? ORDER BY sort_order", (cid,)).fetchall() if cid else [] + subjects = [] + for s in raw_subjects: + units = db.execute("SELECT * FROM unit WHERE subject_id=? ORDER BY seq, id", (s["id"],)).fetchall() + subjects.append({"id": s["id"], "name": s["name"], "color": s["color"], + "icon": s["icon"], "units": units}) + books = db.execute("SELECT * FROM book WHERE child_id=? ORDER BY id DESC", (cid,)).fetchall() if cid else [] + goal = db.execute("SELECT * FROM goal WHERE child_id=?", (cid,)).fetchone() if cid else None + themes = [("light", "浅色"), ("dark", "暗色"), ("candy", "糖果"), ("ocean", "海洋")] + age = child_age(child) if child else 0 + std_h, std_w = standard_hw(age, (child["gender"] or "M") if child else "M") + return render_template("admin.html", child=child, subjects=subjects, books=books, goal=goal, + themes=themes, book_categories=BOOK_CATEGORIES, + age=round(age, 1), std_h=std_h, std_w=std_w, + now=datetime.date.today().isoformat()) + + +@app.post("/admin/child/update") +@admin_required +def admin_child_update(): + db = get_db() + cid = db.execute("SELECT id FROM child LIMIT 1").fetchone() + if cid: + gender = (request.form.get("gender", "M") or "M").upper() + if gender not in ("M", "F"): + gender = "M" + db.execute( + "UPDATE child SET name=?, term_start=?, term_end=?, " + "default_theme=?, birth_date=?, gender=?, carousel_sec=?, ring_sec=? WHERE id=?", + (request.form.get("name"), + request.form.get("term_start") or None, + request.form.get("term_end") or None, + request.form.get("default_theme", "light"), + request.form.get("birth_date") or None, + gender, + max(2, request.form.get("carousel_sec", type=int, default=8) or 8), + max(2, request.form.get("ring_sec", type=int, default=6) or 6), + cid["id"])) + db.commit() + return redirect(url_for("admin")) + + +@app.post("/admin/reset") +@admin_required +def admin_reset(): + """清空所有动态进度与活动数据,保留孩子/学科/单元/书目结构。""" + db = get_db() + db.execute("UPDATE unit SET status=0, completed_at=NULL") + for s in db.execute("SELECT id FROM subject").fetchall(): + normalize_units(db, s["id"]) + db.execute("DELETE FROM checkin") + db.execute("DELETE FROM health_log") + db.execute("DELETE FROM exercise_log") + db.execute("DELETE FROM reading_log") + db.commit() + return redirect(url_for("admin")) + + +@app.post("/admin/term/new") +@admin_required +def admin_new_term(): + """开启新学期:进度重置为未开始、清空打卡,学期起设为今天。""" + db = get_db() + today = datetime.date.today().isoformat() + cid = db.execute("SELECT id FROM child LIMIT 1").fetchone() + if cid: + db.execute("UPDATE child SET term_start=? WHERE id=?", (today, cid["id"])) + db.execute("UPDATE unit SET status=0, completed_at=NULL") + for s in db.execute("SELECT id FROM subject").fetchall(): + normalize_units(db, s["id"]) + db.execute("DELETE FROM checkin") + db.commit() + return redirect(url_for("admin")) + + +@app.post("/admin/subject/add") +@admin_required +def admin_subject_add(): + db = get_db() + cid = db.execute("SELECT id FROM child LIMIT 1").fetchone()["id"] + n = db.execute("SELECT COUNT(*) c FROM subject WHERE child_id=?", (cid,)).fetchone()["c"] + db.execute( + "INSERT INTO subject (child_id, name, color, icon, sort_order, group_id) VALUES (?,?,?,?,?,?)", + (cid, request.form.get("name", "新学科"), request.form.get("color", "#3b82f6"), + request.form.get("icon", "📘"), n, 0)) + db.commit() + return redirect(url_for("admin")) + + +@app.post("/admin/subject/delete/") +@admin_required +def admin_subject_delete(sid): + db = get_db() + db.execute("DELETE FROM unit WHERE subject_id=?", (sid,)) + db.execute("DELETE FROM subject WHERE id=?", (sid,)) + db.commit() + return redirect(url_for("admin")) + + +@app.post("/admin/unit/add") +@admin_required +def admin_unit_add(): + db = get_db() + sid = request.form.get("subject_id", type=int) + title = request.form.get("title", "").strip() + if sid and title: + n = db.execute("SELECT COUNT(*) c FROM unit WHERE subject_id=?", (sid,)).fetchone()["c"] + db.execute( + "INSERT INTO unit (subject_id, title, seq, target_date, status) VALUES (?,?,?,?,?)", + (sid, title, n, request.form.get("target_date") or None, 0)) + normalize_units(db, sid) + db.commit() + return redirect(url_for("admin")) + + +@app.post("/admin/unit/delete/") +@admin_required +def admin_unit_delete(uid): + db = get_db() + row = db.execute("SELECT subject_id FROM unit WHERE id=?", (uid,)).fetchone() + db.execute("DELETE FROM unit WHERE id=?", (uid,)) + if row: + normalize_units(db, row["subject_id"]) + db.commit() + return redirect(url_for("admin")) + + +@app.post("/admin/goal/update") +@admin_required +def admin_goal_update(): + db = get_db() + cid = db.execute("SELECT id FROM child LIMIT 1").fetchone()["id"] + db.execute( + "UPDATE goal SET book_target=?, week_exercise_min=? WHERE child_id=?", + (request.form.get("book_target", type=int, default=30), + request.form.get("week_exercise_min", type=int, default=120), cid)) + db.commit() + return redirect(url_for("admin")) + + +@app.route("/admin/export") +@admin_required +def admin_export(): + db = get_db() + cid = db.execute("SELECT id FROM child LIMIT 1").fetchone()["id"] + out = {"child": [dict(r) for r in db.execute("SELECT * FROM child WHERE id=?", (cid,)).fetchall()], + "subject": [dict(r) for r in db.execute("SELECT * FROM subject WHERE child_id=?", (cid,)).fetchall()], + "unit": [dict(r) for r in db.execute("SELECT * FROM unit WHERE subject_id IN (SELECT id FROM subject WHERE child_id=?)", (cid,)).fetchall()], + "book": [dict(r) for r in db.execute("SELECT * FROM book WHERE child_id=?", (cid,)).fetchall()], + "reading_log": [dict(r) for r in db.execute("SELECT * FROM reading_log WHERE book_id IN (SELECT id FROM book WHERE child_id=?)", (cid,)).fetchall()], + "exercise_log": [dict(r) for r in db.execute("SELECT * FROM exercise_log WHERE child_id=?", (cid,)).fetchall()], + "health_log": [dict(r) for r in db.execute("SELECT * FROM health_log WHERE child_id=?", (cid,)).fetchall()], + "checkin": [dict(r) for r in db.execute("SELECT * FROM checkin WHERE child_id=?", (cid,)).fetchall()]} + text = json.dumps(out, ensure_ascii=False, indent=2) + return Response(text, mimetype="application/json", + headers={"Content-Disposition": "attachment; filename=tracker-export.json"}) + + +# --------------------------------------------------------------------------- +# 启动 +# --------------------------------------------------------------------------- + +@app.before_request +def _ensure(): + if request.endpoint not in ("static",): + migrate_db() + seed_if_empty() + + +if __name__ == "__main__": + init_db() + seed_if_empty() + print(f"个人成长系统已启动: http://127.0.0.1:{PORT} (后台 /admin ,账号 {ADMIN_USER})") + app.run(host="0.0.0.0", port=PORT, debug=False) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d9cf053 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +Flask>=3.0 +Pillow>=10.0 diff --git a/static/covers/6_1786429622.jpg b/static/covers/6_1786429622.jpg new file mode 100644 index 0000000000000000000000000000000000000000..599dd747bc41fe10fbe6c4d21bb46d6af1528a82 GIT binary patch literal 19959 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%0Dw~yXuU%Q&dY1Ryt>|5BF3Fq{{Q$KgAW;Ky^Har z|E)6;{h)o`vFci1m${aty_IxbjJSm}Tm5r{DHx<~o$vzya1mtJ5m#B$==IV)h4wf1 z=a9*Z?ZZ7nt$T1RPh#}UKPYcsExdt%8ktIn~b5)_}$$Yq_joI^~wb2iuQD(H07a4P3* zO*Bfix>X?!kEbel4fekYI3)16q=4aBSebQuB>OwwxA;kTLE^1|>-gnAa37&4OA+?c7Dp=dqhAi&X5E z>g-r0kEkpAB3Atz(SnAqD|m;och$g&XhB_In_38^n9+YoHF2FmRa>%@G~R#4K7JWQ zfdfzE(6McNtoh}+{K#|ml70U+?eTup<>R>7({_%u@mTW96Y3Gt%um1(L(*a)nV6;x z)_U;}aZ;bB0Gu6cfeF9W5j{gFnI6|hqhE~A-HT4qWF3B)Nn`|Lmd?rCCf^~bXn~96 zz)Qg98otAtU!Q$~FOQPL`_rTc%oWx5Hxe&czzTz$te@0Fk1*vA0^wa*;R2n+Q6Fpk znC!s8;eE+(F*9jB@~CzMJ9IAH!X40WF!H}8=I-YAP~Sq>I-lKB||g>thzO$@G~YZcX%;%kiySojq#J|X4^4j8*cF|jxl?zes7NODmE)f?6nB`nZM1*Gaaz+kgkFFsbCNIm{tyc7ejvSlkv` zU1I&VvGZ4V+MhCulqtitR|AXa)P&#GOLB)?8CjNh>lh>vUs+@LJ{Ap&vrXB5=IW)) z-+~Btq2B4}W{VRn@C6U-glfBoDk1P{s)!ep?yBTXtEDfQXijRG)2_g&xQNmWb_J6v z9amu_cmUkOugemCY*I!sZ(haj?eh|-nzEC#Z6nAT3B$DxQgI8{sK8vjJ7#`G|N^YK)B}6wt3x8+sOQc zAp=qG2xre@mpeq*iMX$6cnFBB`eMIU`-qSJ8AU6?7;ZuX){NLYXPA_yZs}!qjNu{{~gK1vAaK*@U5*1ElVrQ~;7c$)r~a9k&hfWw9_6uzO#hrW z#VQ|@a=1H9fV^b`o^?vzQhpj{F^Ka*2C09FD0(y!MJ){e5(=1u0Qojg!Sch}0x=ZaO%9HK%ES6FbT?!uo#Y3YW-gr{kMU|a z^dm0kW>66IH}F_Fmtv5$5I8|NC*(6Y4ZU2-j+oy{Pwk99$Hyb8R;e5nV;poXcgy%V_NptnB6;)%#u^_<7FS9Q$XrWW^SmEXk1iwqv0F|EH%1||h?0Pug zH0)fAC7cq~NY%cfDqiYNzuG`~C){GFq{L0sV2SBccHy&Mr&@q8Qdg}rL}gAKgORGs zR$R>CahZTNswmv!cN6q?sv6@;)#X1AOuxth`Zj0J0TGn@j;Z&}*dYETQ@L$n?4WdZ zS~5b-{0bC21s;bupZk)VZJnd|V=K^Q@U=lHv*F5o3I+YG^na|+Osge>Y^m|gz*wYX z_y%Yt%jSqw!>4YMa}9d7*7QZ8J6skMOIQ+WZcM)@?1Yk%(4Lt|4+gPrbqXH4!eL=e zu`hfd*GwonQe%{gzvfp$u3TmyEWK`Nh1Tb?Vv@$qpLp^XC zn4Y;1)5CjswZV{b2%nN?B(EgBz9Zn2R;4q#*4Y&J9eeX-O{Jbl zvxqiA+U2k}8->PLi&OpQ%j^(lBS5`Hv(#|rUqbk$ZGjfxpy|A(;GQL4jw!^iYliJF zXkcx9&nv>lS&*Sdf0lc&L~)Em2|A7jq6H;>XDlFIF0LjYJ!8_hL(N$A*>Ib5uGeDT zmH@=cK>beMwuCvHG7;gY>DX^K2&+J2DH8UR(%q9>;vb(iui%P8;8%KvKBvR6_f>CE z^AZczqAd-i!R@^hy1e`B*zqD>n{mDymKcN&-lse-mM%?lzTA~CX7>Fgw@x%n$Zi=@ zM!?xNe2h+jt@!*a4qYbebk_=F+=FHct*Pft=$Wxf*o@1}#1_`bnv4=opvmr2ty1`- zx?tVGaFC=ulZ#|2SH*x_ppbyLznfB%iZPXPmz=}^$pP$%Mvcs6kcer4#z-k%0_#tf za#-vNylJ2B`Fe1@x`n7JFf+ux>$-hveVBqMozcmSzz+&FHmYV*lZ=hGGXG5cwm%bp zv;;KZgn9>TtT8I9%O06ZMN^(`#x+LqY$D^x;*{Q^-9 zT@8Du=#SvsJ=doZA)Z%@%Y0%>2@weyyc<5us|4Nb`%M_$isTsNv^?^Wm&g|aeeAeX z?&mt_3N*=q<|zu~M3)+9pgYT9$)s$04jUSkYz7WC-CU-?NJ$2-1@v+iuZ7p+({MQA zHe}di4NzE;z0+|I!MT*;gZFv&zog&#;%N@$2t3F5sXNg*Lct>+!>V;;lw#OgOe3Kmqx&#H*D!&aShq`CDl&=ui&WowUgadjo3DIsb_q}1H zxSHan1fEW24Re!`sF~HDf?TEDB{Y7FKfEvmi)keUHUL31R6dyWMt>Vyu6*jjARG6X zn#MP*!<3-Ns9ksDq57^*@ z`5e94np!9QcELa?2Q!gG7%G;?>@Oe=e?PtoPTQZM3R=XWs2(mrIDexIsp@4KORQ8$ z)}u|E7j9Qoy+CjDkB-B?#697udx?H_f{-wCPT(c{4(zU+FROgW1;h`Zob#MCL9Hga zn?!&s=j|~vtTBQxT7{TOfBULC zVhSz9yc9@D;iOf1gfqaLBkGKK*l!u61x~3!6Ivr{FRIhdG$*eXU5}0X2di7&kTZ@i zw`DJg#)ZP-3{Q>Oveg%&xeHF`DRYwzWE?OP8u^Yo zqD?>d?Ow*hwSMRU*+LIlr=_lYKzSW5k4-{YQ71H9gghGRvI^~4o0x={p!^)r&s@K> z06&REN;+oW5RK+bQ<8O|_fRUj1=){&?T)QZ_h%6};^qvZ(btRKiP&1B!Io!LgQ7f+ z8f(^IRLyKw`2gHQj&jzt)8t8LuI0wj`q9qg)9a8qc7 z1(P+lNoLJyj7m!}nB(Kb3pC2)AF(Mp$0%ABIA-N zio(Mu1z;lD8e2w-{{ULtt<-9|W#60*OaA~@D;r4p^Q2t~R^z^h^8(#UKSgR556kWeWB6V7pA zkS_9Bb`0t&(Aoew2(2NSLk)IHY^Y<%Mur4iX%aX#7T!gyOAK|-0L{92BI+cXYg{)| zLA*3|QsW1X*?1d6R9m)g2BEbGGidtbc!Pq@!2u{$EmhGFS-rUfZyK!mXNZQEMME@M ze2!5geFfKhZ%alVI1mkxjp4SEN-kNH8|0MS16{Qm%( zm9gNbYWRJV^Dmkm6m#crYI;AlSg$@l&>_+ulXSIK@0g2vpp|%;&+9NyFs*R~whnjR z?mK)vhk2%&&iX@y)!$-7E{lVLnllx(EWv<0p_D+RGlheI%K>!Jl5}JsEof!14X*6i zTZIXx)1=7u*Vejg6SzV zSs{7|^6{~1{{Y9{zn}QrtUU+j99F1IdFowZ)k9lh$6war+bnD=XrT0Tdd8=z^cdED zlU*M&8kxEew08JZBvHxM0E=^_lHd^KxGkVyu8a)zOi&_*3=)o8y2MmA)tkbL>aJ58 z%->J|*%oA=92d}x@ODgW+T>F>dID~wtg2ars<#cAs6u8pLMUiqy51mDrF@vc(BWrB z#||4zAxepKjz*KYat8L78(xyzeF*3tfFuT;(Pg_{h!6gW?4GGACGh9xhbAF_UB#fe#5Z}kR2 z$d-mI9G$u5$a!Ir6%rNP=?F{Gb_l-_01!Bc_a>sPZ>??v^G1jlijC7<4YR@htwti0Psuy0N|LpPofh+$4&DB?9y3W zlCui+YF1a%#F1CFrqM49BvNKRL00`rCNHZB1zPU)Vo9J2wotQL#I1kCr9goKPzW*Q zkOlhS%dfyF#a;kVb6Nu=4~x1$MM*-=*0HzJBFIodyGe9g0@V5S>cda`1PD@qK!F`8 zgO&juj&xhFlpNNqJOT-jf{>{!Rr08wq#7l2OkCz80a&bY3g^UzXr9yN{fWp~Jcd(xn{zW35SSOW{Kmlq9=g22>)Scf_cRfkm k^(S-GozGHtJxSd4Cv()D&r)|iNbtmkhU<_1@BaY**+CBM9{>OV literal 0 HcmV?d00001 diff --git a/static/covers/6_1786429622_t.jpg b/static/covers/6_1786429622_t.jpg new file mode 100644 index 0000000000000000000000000000000000000000..367148caf86b1c5c274fd495cb93319828bf71e3 GIT binary patch literal 4751 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 +
+ {% if b.cover %} + {{ b.title }} + {% else %} +
{{ b.title }}
+ {% endif %} + {% if b.finish_str %} + {{ b.finish_str }} + {% endif %} +
+ diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..a2a5d28 --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,181 @@ +{% extends "base.html" %} +{% block title %}家长后台{% endblock %} +{% block content %} + +
+

🛠️ 家长后台

+ ⬇ 导出全部数据(JSON) + 退出 +
+ +
+

👦 孩子信息

+
+ + + + + + + + + +
+ {% if age is not none %} +

当前年龄约 {{ '%.1f'|format(age) }} 岁 · 标准身高 {{ std_h }} cm · 标准体重 {{ std_w }} kg(WHO 中位参考)

+ {% endif %} +
+ +
+

🎯 年度目标

+
+ + + +
+
+ +
+

📈 健康记录(可补历史数据)

+

填入任意日期即可补录历史身高体重;仪表盘「健康综合」会展示最近两年。

+
+ + + + + + + +
+
+ +
+

📚 学科与单元(整年课表)

+
+ + + + +
+ + {% for s in subjects %} +
+
+ {{ s.icon }} {{ s.name }} + +
+ +
+
+
+
    + {% for u in s.units %} +
  • + + {% if u.status==2 %}已完成{% elif u.status==1 %}进行中{% else %}未开始{% endif %} + + {{ u.title }} + {% if u.target_date %}(🎯{{ u.target_date }}){% endif %} +
    + +
    +
  • + {% endfor %} +
+
+ + + + +
+
+ {% endfor %} +
+ +
+

📖 书目(含封面)

+
+ + + + + + +
+

支持本地上传封面(png/jpg/gif/webp,不限制大小,离线可见);上传后会自动标准化为统一的 2:3 封面并生成缩略图。也可在下方每本书填图片 URL。
+ 分类与评分决定书架墙上的筛选标签和排序,改完记得点「保存」。

+ + + {% for b in books %} + + + + + + + + + {% endfor %} +
封面书名作者状态分类 / 评分更新封面
{% if b.cover_thumb or b.cover_url %}{% else %}{% endif %}{{ b.title }}{{ b.author or '—' }}{% if b.status=='done' %}已读完{% elif b.status=='reading' %}在读{% else %}想读{% endif %} +
+ + + +
+
+
+ + + +
+ {% if b.cover_url and b.cover_url.startswith('/static/covers/') %} +
+ +
+ {% endif %} +
+
+ +
+

🧹 数据管理

+

徽章由进度/读书/运动实时派生,重置或新学期后会自动重新累积,无需手动发徽章。

+
+
+ +
+
+ +
+
+
+ +{% endblock %} diff --git a/templates/admin_login.html b/templates/admin_login.html new file mode 100644 index 0000000..f105e8f --- /dev/null +++ b/templates/admin_login.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block title %}家长登录{% endblock %} +{% block content %} + +{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..148e784 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,21 @@ + + + + + + {% block title %}个人成长系统{% endblock %} + + + +
+
🌱 个人成长系统
+ +
+
+ {% block content %}{% endblock %} +
+ + diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..d8b619f --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,437 @@ +{% extends "base.html" %} +{% block title %}{{ child.name }} 的成长仪表盘{% endblock %} +{% block content %} + +
+
+

👦 {{ child.name }} 的成长仪表盘

+
+ {% if got_badges %} + {% for b in got_badges %} +
+ {{ b.icon }} + {{ b.name }} + 已获得 +
+ {% endfor %} + {% else %} +
+ 🏅 + 还没有徽章,今天就去解锁一个吧 +
+ {% endif %} +
+ +
+
+
+
+
+
--:--:--
+
+
+
📅 学期日历
+
{{ term_start_str }} ~ {{ term_end_str }}
+
{{ term_calendar_svg(active_counts, term_start, term_end) | safe }}
+
+ 多项 + 有打卡 + 周末 + 未打卡 + 今天 +
+
+
+ {{ ring_svg(yprog, '#3b82f6', 130, '学年总进度') | safe }} +
+
+
+ +
+
+

🎡 各学科进度一览

+
+
+ {% for grp in ring_slides %} +
+ {% for s in grp %} +
+
{{ s.icon }} {{ s.name }}
+ {{ mini_ring_svg(s.progress, s.color) | safe }} +
+ {% endfor %} +
+ {% endfor %} +
+ +
+
+ +
+

💗 健康综合

+
+
+
当前身高
+
{{ cur_h if cur_h is not none else '—' }}cm
+
标准 {{ std_h }} cm + {%- if cur_h is not none %} + + {{ '+' if cur_h >= std_h else '' }}{{ (cur_h - std_h) | round(1) }} + + {%- endif %} +
+
+
+
当前体重
+
{{ cur_w if cur_w is not none else '—' }}kg
+
标准 {{ std_w }} kg + {%- if cur_w is not none %} + + {{ '+' if cur_w >= std_w else '' }}{{ (cur_w - std_w) | round(1) }} + + {%- endif %} +
+
+
+
+ +

按 {{ age }} 岁{{ '男孩' if (child.gender or 'M') == 'M' else '女孩' }}的生长参考中位数估算,仅供参考。

+
+
+
+ +
+
+

📚 学科进度

+

按任务顺序显示:完成 3 个 · 进行中 1 个 · 待完成 3 个。点一下就完成,下一个自动接上。

+ + +
+ +
+

📖 我的书架墙 + +

+

共读完 {{ shelf|length }} 本 · 上排最近读完,下排随机回顾

+ {% if shelf %} +
+
🆕 最近读完
+
+ {% for b in shelf_recent %}{% include "_book_cover.html" %}{% endfor %} +
+ {% if shelf_random %} +
🎲 随机回顾
+
+ {% for b in shelf_random %}{% include "_book_cover.html" %}{% endfor %} +
+ {% endif %} +
+ {% else %} +

书架还是空的,读完第一本书就会出现在这里 📚

+ {% endif %} +
+
+ + +
+ {% for c in checkins %} + + {% endfor %} +
+ + + + + + + + + + + + + + + +{% endblock %}