Files
spark-tracker/app.py
T
luff 0e6065897b 星火纪元 个人成长追踪 v1.0
- Flask + SQLite 自托管,单用户无 PIN
- 仪表盘:学科进度环轮播、健康双线图、任务接龙、书架墙、浮动打卡
- 后台:孩子信息、学科/单元课表、健康历史录入、数据管理(重置/新学期)
- 徽章由进度实时派生;主题由后台设置
2026-08-11 15:20:53 +08:00

1377 lines
54 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
个人成长系统(儿童向 · 自托管)
- 单孩子、无 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'''
<svg width="{size}" height="{size}" viewBox="0 0 {size} {size}" class="ring">
<circle cx="{cx}" cy="{cx}" r="{r}" fill="none" class="ring-track" stroke-width="12"/>
<circle cx="{cx}" cy="{cx}" r="{r}" fill="none" stroke="{color}" stroke-width="12"
stroke-linecap="round" stroke-dasharray="{dash:.1f} {circ:.1f}"
transform="rotate(-90 {cx} {cx})"/>
<text x="{cx}" y="{cx-2}" text-anchor="middle" font-size="26" font-weight="700" class="ring-pct">{pct:.0f}%</text>
<text x="{cx}" y="{cx+20}" text-anchor="middle" font-size="12" class="ring-label">{label}</text>
</svg>'''
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'''<svg width="{size}" height="{size}" viewBox="0 0 {size} {size}" class="miniring">
<circle cx="{cx}" cy="{cx}" r="{r}" fill="none" class="ring-track" stroke-width="8"/>
<circle cx="{cx}" cy="{cx}" r="{r}" fill="none" stroke="{color}" stroke-width="8"
stroke-linecap="round" stroke-dasharray="{dash:.1f} {circ:.1f}"
transform="rotate(-90 {cx} {cx})"/>
<text x="{cx}" y="{cx+5}" text-anchor="middle" font-size="16" font-weight="700" class="ring-pct">{pct:.0f}%</text>
</svg>'''
def bar_svg(pct, color):
pct = max(0, min(100, pct))
return f'''
<div class="bar"><div class="bar-fill" style="width:{pct:.1f}%;background:{color}"></div>
<span class="bar-pct">{pct:.0f}%</span></div>'''
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'<rect x="{x}" y="{y}" width="{cell}" height="{cell}" rx="2" fill="{fill}"><title>{iso}</title></rect>')
return f'<svg width="{w}" height="{h}" viewBox="0 0 {w} {h}" class="heatmap">{"".join(rects)}</svg>'
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 '<div class="spark-empty">数据不足</div>'
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'''
<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}" class="spark">
<polyline points="{poly}" fill="none" stroke="{color}" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>'''
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 '<div class="spark-empty">还没有足够的身高体重记录</div>'
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'<rect x="0" y="0" width="{width}" height="{height}" fill="none"/>']
# 横向网格
for k in range(1, 4):
y = pad_t + ih * k / 4
parts.append(f'<line x1="{pad_l}" y1="{y:.1f}" x2="{width - pad_r}" y2="{y:.1f}" class="hc-grid"/>')
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'<path d="{d}" fill="none" stroke="{color}" stroke-width="2.5" '
f'stroke-linejoin="round" stroke-linecap="round"/>')
for x, y in pts:
parts.append(f'<circle cx="{x:.1f}" cy="{y:.1f}" r="2.6" fill="{color}"/>')
lx, ly = pts[-1]
parts.append(f'<circle cx="{lx:.1f}" cy="{ly:.1f}" r="4.5" fill="{color}" '
f'stroke="#fff" stroke-width="2"/>')
if std is not None and mn < std < mx:
sy = pad_t + ih - (std - mn) / (mx - mn) * ih
parts.append(f'<line x1="{pad_l}" y1="{sy:.1f}" x2="{width - pad_r}" y2="{sy:.1f}" '
f'stroke="{color}" stroke-width="1.4" stroke-dasharray="5 4" opacity=".55"/>')
draw(hs, "#10b981", "h", std_h)
draw(ws, "#f59e0b", "w", std_w)
# 横轴首尾日期
if points:
parts.append(f'<text x="{pad_l}" y="{height - 6}" font-size="11" class="hc-axis">'
f'{points[0]["date"][5:].replace("-", "/")}</text>')
parts.append(f'<text x="{width - pad_r}" y="{height - 6}" font-size="11" '
f'text-anchor="end" class="hc-axis">{points[-1]["date"][5:].replace("-", "/")}</text>')
return (f'<svg viewBox="0 0 {width} {height}" class="healthchart" '
f'preserveAspectRatio="none">{"".join(parts)}</svg>')
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'<rect x="{x}" y="{y}" width="{cell}" height="{cell}" rx="2" '
f'class="tc {c}{tcls}"><title>{title}</title></rect>')
return (f'<svg width="{w}" height="{h}" viewBox="0 0 {w} {h}" class="termcal-svg">'
f'{"".join(rects)}</svg>')
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/<int:uid>/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/<kind>")
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/<int:bid>")
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/<int:bid>")
@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/<int:bid>")
@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/<int:bid>")
@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/<int:sid>")
@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/<int:uid>")
@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)