commit 0e6065897bcf51ca02bdb8c11c804214bdb39520 Author: luff Date: Tue Aug 11 15:20:53 2026 +0800 星火纪元 个人成长追踪 v1.0 - Flask + SQLite 自托管,单用户无 PIN - 仪表盘:学科进度环轮播、健康双线图、任务接龙、书架墙、浮动打卡 - 后台:孩子信息、学科/单元课表、健康历史录入、数据管理(重置/新学期) - 徽章由进度实时派生;主题由后台设置 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 0000000..599dd74 Binary files /dev/null and b/static/covers/6_1786429622.jpg differ diff --git a/static/covers/6_1786429622_t.jpg b/static/covers/6_1786429622_t.jpg new file mode 100644 index 0000000..367148c Binary files /dev/null and b/static/covers/6_1786429622_t.jpg differ diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..31381ce --- /dev/null +++ b/static/style.css @@ -0,0 +1,414 @@ +:root { + --bg: #f5f7fb; + --card: #ffffff; + --ink: #111827; + --muted: #6b7280; + --line: #e5e7eb; + --blue: #3b82f6; + --panel: #fafbff; + --panel2: #f7efe6; + --track: #e5e7eb; +} +* { box-sizing: border-box; } +body { + margin: 0; + font-family: -apple-system, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif; + background: var(--bg); + color: var(--ink); +} + +/* ===== 主题风格 ===== */ +html.theme-dark { + --bg: #0f172a; --card: #1e293b; --ink: #e2e8f0; --muted: #94a3b8; + --line: #334155; --blue: #60a5fa; --panel: #172033; --panel2: #16233a; --track: #334155; +} +html.theme-candy { + --bg: #fff1f6; --card: #ffffff; --ink: #831843; --muted: #db2777; + --line: #fbcfe8; --blue: #ec4899; --panel: #fff6fb; --panel2: #ffe9f3; --track: #fbcfe8; +} +html.theme-ocean { + --bg: #eef6ff; --card: #ffffff; --ink: #0c4a6e; --muted: #0369a1; + --line: #bae6fd; --blue: #0ea5e9; --panel: #f0f9ff; --panel2: #e0f2fe; --track: #bae6fd; +} + +/* SVG 环形进度(适配主题) */ +.ring-track { stroke: var(--track); } +.ring-pct { fill: var(--ink); } +.ring-label { fill: var(--muted); } + +/* 学期日历配色(CSS 类,支持暗色) */ +.tc { transition: fill .2s; } +.tc-act3 { fill: #15803d; } +.tc-act2 { fill: #22c55e; } +.tc-act1 { fill: #86efac; } +.tc-week { fill: #dbeafe; } +.tc-none { fill: #eef2f7; } +.tc-today { stroke: #111827; stroke-width: 2; } +html.theme-dark .tc-week { fill: #1e3a5f; } +html.theme-dark .tc-none { fill: #243049; } +html.theme-dark .tc-today { stroke: #e2e8f0; } +html.theme-candy .tc-week { fill: #fbcfe8; } +html.theme-candy .tc-none { fill: #fde7f1; } +html.theme-ocean .tc-week { fill: #bae6fd; } +html.theme-ocean .tc-none { fill: #d6efff; } + +/* 图例色块 */ +.lg { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 4px; vertical-align: -1px; } +.lg.tc-act3 { background: #15803d; } +.lg.tc-act2 { background: #22c55e; } +.lg.tc-act1 { background: #86efac; } +.lg.tc-week { background: #dbeafe; } +.lg.tc-none { background: #eef2f7; } +.lg.tc-today { background: #fff; border: 2px solid #111827; } +html.theme-dark .lg.tc-week { background: #1e3a5f; } +html.theme-dark .lg.tc-none { background: #243049; } +html.theme-dark .lg.tc-today { border-color: #e2e8f0; } +html.theme-candy .lg.tc-week { background: #fbcfe8; } +html.theme-candy .lg.tc-none { background: #fde7f1; } +html.theme-ocean .lg.tc-week { background: #bae6fd; } +html.theme-ocean .lg.tc-none { background: #d6efff; } +.topbar { + display: flex; align-items: center; justify-content: space-between; + padding: 12px 24px; background: #fff; border-bottom: 1px solid var(--line); + position: sticky; top: 0; z-index: 10; +} +.brand { font-weight: 700; font-size: 18px; } +.topbar nav a { margin-left: 18px; color: var(--muted); text-decoration: none; font-size: 14px; } +.topbar nav a:hover { color: var(--ink); } +.container { max-width: 1080px; margin: 0 auto; padding: 24px; } + +.hero { display: flex; align-items: center; justify-content: space-between; gap: 24px; flex-wrap: wrap; } +.hero h1 { margin: 0; font-size: 26px; } +.sub { color: var(--muted); margin: 6px 0 0; } + +.card { + background: var(--card); border: 1px solid var(--line); border-radius: 16px; + padding: 20px; margin: 18px 0; box-shadow: 0 1px 3px rgba(0,0,0,.04); +} +.card h2 { margin: 0 0 16px; font-size: 18px; } + +.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; } +@media (max-width: 760px) { .grid-2 { grid-template-columns: 1fr; } } + +/* 学科 */ +.subjects { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } +@media (max-width: 760px) { .subjects { grid-template-columns: 1fr; } } +.subject { background: var(--panel); border-radius: 12px; padding: 14px; } +.subject-head { display: flex; align-items: center; gap: 8px; font-weight: 600; } +.subject-icon { font-size: 20px; } +.subject-name { flex: 1; } +.subject-pct { color: var(--muted); font-size: 14px; } +.units { list-style: none; padding: 0; margin: 10px 0 0; } +.unit { display: flex; align-items: center; gap: 8px; padding: 4px 0; } +.unit-form { margin: 0; } +.unit-btn { + background: none; border: none; cursor: pointer; font-size: 14px; text-align: left; + color: var(--ink); padding: 2px 0; +} +.unit-2 .unit-btn { color: #111; font-weight: 600; } +.unit-1 .unit-btn { color: #374151; } +.unit-0 .unit-btn { color: var(--muted); } +.unit-date { font-size: 12px; color: var(--muted); } + +/* 进度条 */ +.bar { position: relative; height: 18px; background: var(--track); border-radius: 10px; margin: 8px 0; overflow: hidden; } +.bar-fill { height: 100%; border-radius: 10px; transition: width .4s; } +.bar-pct { position: absolute; right: 8px; top: 0; font-size: 12px; line-height: 18px; color: #fff; mix-blend-mode: difference; } + +/* 读书 */ +.progress-line { margin: 4px 0 8px; } +.book-list { list-style: none; padding: 0; margin: 12px 0; } +.book { display: flex; align-items: center; justify-content: space-between; padding: 8px 0; border-bottom: 1px dashed var(--line); } +.tag { font-size: 12px; padding: 2px 8px; border-radius: 999px; margin-left: 6px; } +.tag-done { background: #dcfce7; color: #166534; } +.tag-reading { background: #dbeafe; color: #1e40af; } +.tag-want { background: #f3f4f6; color: #6b7280; } +.muted { color: var(--muted); font-size: 13px; } + +/* 表单 */ +.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 10px; } +.row.wrap { flex-wrap: wrap; } +.row input, .row select { + padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; font-size: 14px; +} +button { + background: var(--blue); color: #fff; border: none; padding: 8px 14px; + border-radius: 8px; cursor: pointer; font-size: 14px; +} +button:hover { filter: brightness(.95); } +.inline { display: inline; margin: 0; } +.mini { padding: 4px 10px; font-size: 12px; background: #eef2ff; color: #3730a3; } +.mini.danger { background: #fee2e2; color: #b91c1c; } + +/* 热力图 */ +.heatmap { max-width: 100%; } +.spark-empty { color: var(--muted); font-size: 13px; } + +/* 健康 */ +.health-charts { display: flex; gap: 20px; flex-wrap: wrap; } +.chart-label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; } + +/* 徽章 */ +.badges { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.badge { display: flex; align-items: center; gap: 10px; padding: 10px; border-radius: 12px; } +.badge-on { background: #fff7ed; border: 1px solid #fed7aa; } +.badge-off { background: #f3f4f6; opacity: .6; } +.badge-icon { font-size: 24px; } +.badge-name { flex: 1; font-size: 14px; } +.badge-state { font-size: 12px; } +.badge-on .badge-state { color: #c2410c; } +.badge-off .badge-state { color: var(--muted); } + +/* 后台 */ +.admin-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } +.admin-head h1 { margin: 0; flex: 1; } +.btn-outline { border: 1px solid var(--line); padding: 6px 12px; border-radius: 8px; text-decoration: none; color: var(--ink); font-size: 13px; } +.admin-subject { border: 1px solid var(--line); border-radius: 12px; padding: 12px; margin-top: 12px; } +.admin-subject-head { display: flex; justify-content: space-between; align-items: center; } +.admin-units { list-style: none; padding: 0; margin: 10px 0; } +.admin-units li { padding: 4px 0; font-size: 14px; display: flex; align-items: center; gap: 8px; } +.unit-state { font-size: 12px; padding: 1px 8px; border-radius: 999px; } +.unit-state.unit-2 { background: #dcfce7; color: #166534; } +.unit-state.unit-1 { background: #dbeafe; color: #1e40af; } +.unit-state.unit-0 { background: #f3f4f6; color: #6b7280; } +.tbl { width: 100%; border-collapse: collapse; } +.tbl th, .tbl td { text-align: left; padding: 8px; border-bottom: 1px solid var(--line); } + +/* 登录 */ +.login-box { max-width: 360px; margin: 60px auto; background: #fff; border: 1px solid var(--line); border-radius: 16px; padding: 28px; } +.login-form { display: flex; flex-direction: column; gap: 10px; } +.login-form input { padding: 10px; border: 1px solid var(--line); border-radius: 8px; } +.error { color: #b91c1c; } + +/* hero 右侧:进度环 + 学期日历 */ +.hero-right { display: flex; align-items: center; gap: 22px; flex-wrap: wrap; } +.hero-ring { flex: 0 0 auto; } +.termcal { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; } +.termcal-title { font-weight: 700; font-size: 15px; } +.termcal-range { color: var(--muted); font-size: 12px; margin: 2px 0 8px; } +.termcal-scroll { overflow-x: auto; max-width: 100%; } +.termcal-svg { display: block; } +.termcal-legend { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 8px; font-size: 12px; color: var(--muted); } +.termcal-legend i.lg { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 4px; vertical-align: -1px; } +.lg-act3 { background: #15803d; } +.lg-act1 { background: #86efac; } +.lg-week { background: #dbeafe; } +.lg-none { background: #eef2f7; } +.lg-today { background: #fff; border: 2px solid #111827; } + +/* 学科单元点阵 */ +.subject-pct { margin-left: auto; } +.detail-btn { margin-left: 10px; } +.dotstrip { display: flex; flex-wrap: wrap; gap: 4px; margin: 10px 0 4px; } +.dotform { margin: 0; line-height: 0; } +.dot { width: 16px; height: 16px; border-radius: 3px; border: 1px solid rgba(0,0,0,.06); cursor: pointer; padding: 0; transition: transform .1s; } +.dot:hover { transform: scale(1.18); } +.dot.d-done { box-shadow: inset 0 0 0 1px rgba(255,255,255,.35); } +.subject-detail .units { margin-top: 0; } + +/* 详情弹窗 */ +.modal-mask { + position: fixed; inset: 0; background: rgba(17,24,39,.45); + display: none; align-items: center; justify-content: center; z-index: 50; padding: 20px; +} +.modal-mask.show { display: flex; } +.modal { + background: #fff; border-radius: 16px; width: min(560px, 100%); max-height: 80vh; + overflow: auto; box-shadow: 0 20px 60px rgba(0,0,0,.25); +} +.modal-head { display: flex; justify-content: space-between; align-items: center; padding: 14px 18px; border-bottom: 1px solid var(--line); position: sticky; top: 0; background: #fff; } +.modal-head span { font-weight: 700; } +.modal-body { padding: 16px 18px; } + +/* 书架墙 */ +.shelf { display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-end; padding: 8px 4px 14px; border-bottom: 10px solid #b08968; background: linear-gradient(180deg, var(--panel) 0%, var(--panel2) 100%); border-radius: 8px; min-height: 60px; } +.book-cover { width: 110px; text-align: center; } +.book-cover img, .cover-ph { + width: 100px; height: 140px; object-fit: cover; border-radius: 4px 6px 6px 4px; + box-shadow: 2px 3px 8px rgba(0,0,0,.18); margin: 0 auto; display: block; +} +.cover-ph { display: flex; align-items: center; justify-content: center; padding: 10px; } +.cover-ph span { color: #1f2937; font-weight: 700; font-size: 14px; line-height: 1.3; text-shadow: 0 1px 0 rgba(255,255,255,.4); } +.cover-title { font-size: 12px; margin-top: 6px; color: var(--ink); max-height: 32px; overflow: hidden; } +.cover-rating { color: #f59e0b; font-size: 13px; } +.cover-meta { font-size: 11px; color: var(--muted); margin-top: 1px; } +.book-cover[hidden] { display: none !important; } + +/* 书架墙:搜索 / 分类 / 排序 */ +.shelf-tools { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 8px; } +.shelf-search { + flex: 1 1 200px; min-width: 160px; padding: 7px 10px; font-size: 14px; + border: 1px solid var(--line); border-radius: 999px; background: var(--panel); color: var(--ink); +} +.shelf-search:focus { outline: none; border-color: var(--blue); } +.shelf-sort { padding: 7px 8px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); color: var(--ink); font-size: 13px; } +.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; } +.chip { + display: inline-flex; align-items: center; gap: 5px; cursor: pointer; + padding: 4px 11px; font-size: 13px; line-height: 1.6; + border: 1px solid var(--line); border-radius: 999px; + background: var(--panel); color: var(--ink); +} +.chip:hover { border-color: #9ca3af; } +.chip.active { background: var(--blue); border-color: var(--blue); color: #fff; } +.chip-n { font-size: 11px; opacity: .7; } +.chip.active .chip-n { opacity: .85; } +.shelf-empty { text-align: center; color: var(--muted); padding: 18px 0 4px; font-size: 14px; } + +/* 各学科进度一览(小环一行) */ +.mini-rings { display: flex; flex-wrap: wrap; gap: 14px 10px; } +.mini-ring { text-align: center; width: 92px; } +.mini-name { font-size: 12px; margin-top: 2px; font-weight: 600; line-height: 1.2; } + +/* 学科轮播 */ +.carousel { position: relative; } +.slide { display: flex; gap: 16px; } +.slide .subject { flex: 1; } +.carousel-nav { display: flex; align-items: center; justify-content: center; gap: 14px; margin-top: 14px; } +#carouselInfo { font-size: 13px; min-width: 70px; text-align: center; } +@media (max-width: 760px) { + .slide { flex-direction: column; } +} + +/* 主题切换器 */ +.theme-picker { margin-top: 12px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } +.tp-label { font-size: 13px; color: var(--muted); margin-right: 4px; } +.theme-opt { + background: var(--panel); color: var(--ink); border: 1px solid var(--line); + padding: 4px 10px; border-radius: 999px; cursor: pointer; font-size: 12px; +} +.theme-opt:hover { filter: brightness(.97); } +.theme-opt.active { border-color: var(--blue); box-shadow: 0 0 0 2px color-mix(in srgb, var(--blue) 30%, transparent); } + +/* 后台:轮播分组 */ +.group-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 10px 0; border-bottom: 1px dashed var(--line); } +.group-row .row { flex: 1; } +.admin-subject-controls { display: inline-flex; align-items: center; gap: 8px; } +.admin-subject-controls select { padding: 4px 6px; border: 1px solid var(--line); border-radius: 8px; font-size: 13px; } + +/* 后台:书目表格内的封面上传表单 */ +.tbl .row { flex-wrap: wrap; } +.tbl input[type="file"] { max-width: 170px; } +.tbl input[name="cover_url"] { flex: 1; min-width: 110px; } + +/* ===== hero 左侧:徽章轮播 ===== */ +.hero-left { flex: 1 1 320px; min-width: 0; } +.badgebar { display: flex; align-items: center; min-height: 34px; margin-top: 8px; } +.badge-slide { + display: flex; align-items: center; gap: 10px; + background: var(--panel); border: 1px solid var(--line); border-radius: 999px; + padding: 6px 14px; white-space: nowrap; +} +.badge-slide.badge-empty { background: transparent; border-style: dashed; } +.badgebar .badge-icon { font-size: 22px; } +.badgebar .badge-name { font-size: 14px; font-weight: 600; } +.badgebar .badge-state { font-size: 12px; color: #c2410c; margin-left: 2px; } + +/* ===== hero 当前日期时钟 ===== */ +.nowbox { + text-align: center; background: var(--panel); border: 1px solid var(--line); + border-radius: 14px; padding: 12px 18px; min-width: 158px; +} +.now-date { font-size: 16px; font-weight: 700; } +.now-week { font-size: 13px; color: var(--muted); margin-top: 2px; } +.now-time { font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums; margin-top: 4px; color: var(--blue); } + +/* ===== 学科进度一览(轮播小环) ===== */ +.ringcar { min-height: 150px; } +.ring-slide { display: flex; gap: 18px; justify-content: space-around; flex-wrap: wrap; } +.ring-slide .mini-ring { display: flex; flex-direction: row; align-items: center; gap: 6px; width: auto; } + +/* ===== 健康综合 ===== */ +.healthbox { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 10px; } +.healthchart { display: block; width: 100%; height: auto; } +.hc-grid { stroke: var(--line); stroke-width: 1; } +.hc-axis { fill: var(--muted); } +.lgline { display: inline-block; width: 14px; height: 4px; border-radius: 2px; vertical-align: middle; margin-right: 4px; } +.lgline + .lgline { margin-left: 12px; } +.sub.tiny { font-size: 12px; margin-top: 8px; } +.hstats { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 12px; } +.hstat { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 12px; text-align: center; } +.hs-label { font-size: 12px; color: var(--muted); } +.hs-val { font-size: 26px; font-weight: 800; line-height: 1.1; margin: 4px 0; } +.hs-val em { font-size: 13px; font-style: normal; color: var(--muted); margin-left: 3px; } +.hs-std { font-size: 12px; color: var(--muted); } +.hs-diff { margin-left: 4px; font-weight: 700; } +.hs-diff.up { color: #16a34a; } +.hs-diff.down { color: #dc2626; } + +/* ===== 学科进度:任务清单 ===== */ +.tasklist { list-style: none; padding: 0; margin: 10px 0 0; } +.task { display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px dashed var(--line); flex-wrap: wrap; } +.task-btn { display: flex; align-items: center; gap: 8px; background: none; border: none; cursor: pointer; font-size: 14px; color: var(--ink); padding: 4px 0; text-align: left; } +.task-btn:hover { opacity: .85; } +.task-ico { font-size: 16px; } +.task-title { flex: 1; } +.task-0 .task-title { color: var(--muted); } +.task-1 .task-title { color: #374151; font-weight: 600; } +.task-2 .task-title { color: #111; } +.task-flag { font-size: 11px; background: var(--blue); color: #fff; padding: 2px 8px; border-radius: 999px; } + +/* ===== 书架墙 mini ===== */ +.shelf-mini { margin-top: 6px; } +.shelf-row-label { font-size: 13px; color: var(--muted); margin: 10px 0 6px; font-weight: 600; } +.shelf-3 { justify-content: flex-start; gap: 14px; align-items: flex-start; } +.shelf-3 .book-cover { width: 92px; } +.shelf-3 .book-cover img, .shelf-3 .cover-ph { width: 84px; height: 118px; } + +/* ===== 浮动打卡 dock ===== */ +.dock { position: fixed; right: 18px; bottom: 18px; display: flex; flex-direction: column; gap: 10px; z-index: 30; } +.dock-btn { + display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; + width: 60px; height: 60px; border-radius: 50%; background: var(--blue); color: #fff; + box-shadow: 0 6px 18px rgba(0,0,0,.2); font-size: 11px; line-height: 1.2; padding: 0; border: none; cursor: pointer; +} +.dock-btn .dock-ico { font-size: 20px; } +.dock-btn.on { background: #16a34a; } +.dock-btn.pop { animation: dockpop .4s ease; } +@keyframes dockpop { 0% { transform: scale(1); } 40% { transform: scale(1.18); } 100% { transform: scale(1); } } + +/* ===== 宽弹窗(书架详情) ===== */ +.modal-wide { width: min(860px, 100%); } + +/* ===== hero 当前日期时钟:去方框 ===== */ +.nowbox { + background: none; border: none; border-radius: 0; padding: 0; min-width: 0; text-align: left; +} +.now-date { font-size: 15px; font-weight: 700; } +.now-week { font-size: 13px; color: var(--muted); margin-top: 1px; } +.now-time { font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums; margin-top: 2px; color: var(--blue); } + +/* ===== 进度一览 / 健康综合:压缩到约 1/3 高度 ===== */ +.ringcar { min-height: auto; } +.ring-slide .mini-ring svg { width: 46px; height: 46px; } +.ring-slide .mini-name { font-size: 11px; margin-bottom: 0; text-align: left; white-space: nowrap; } +.ring-wrap { position: relative; padding-right: 30px; } +.ring-next { + position: absolute; right: 0; top: 50%; transform: translateY(-50%); + width: 26px; height: 48px; border: none; border-radius: 8px; + background: var(--blue); color: #fff; font-size: 22px; line-height: 1; + opacity: .28; cursor: pointer; transition: opacity .15s; padding: 0; +} +.ring-next:hover { opacity: .75; } +.healthbox .healthchart { height: 48px; } +.healthbox-detail .healthchart { height: 240px; } +.card-foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 12px; flex-wrap: wrap; } +.badge-slide { cursor: pointer; } + +/* ===== 书架墙:仅封面 + 右下角日期 ===== */ +.cover-frame { position: relative; display: inline-block; line-height: 0; } +.cover-date { + position: absolute; right: 2px; bottom: 2px; + background: rgba(17,24,39,.62); color: #fff; + font-size: 10px; padding: 1px 5px; border-radius: 4px; line-height: 1.4; +} + +/* ===== 徽章详情弹窗 ===== */ +.badge-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; margin-top: 6px; } +.badge-cell { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 14px 10px; text-align: center; } +.badge-cell.locked { opacity: .5; filter: grayscale(.7); } +.bc-icon { font-size: 30px; } +.bc-name { font-weight: 700; margin-top: 6px; font-size: 14px; } +.bc-desc { font-size: 12px; color: var(--muted); margin-top: 4px; } +.bc-state { font-size: 12px; margin-top: 8px; color: #16a34a; } +.badge-cell.locked .bc-state { color: var(--muted); } diff --git a/templates/_book_cover.html b/templates/_book_cover.html new file mode 100644 index 0000000..546c715 --- /dev/null +++ b/templates/_book_cover.html @@ -0,0 +1,18 @@ +{# 单本书封面卡片:仅显示封面,无封面用文字代替;完成日期显示在封面右下角 #} +
+
+ {% 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 %}