"""
个人成长系统(儿童向 · 自托管)
- 单孩子、无 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_kind 表,可在后台增删改)
CHECKIN_SEED = [
{"key": "rope", "icon": "🪢", "name": "跳绳打卡", "done": "已跳绳"},
{"key": "situp", "icon": "🤸", "name": "仰卧起坐打卡", "done": "已做仰卧起坐"},
{"key": "read", "icon": "📖", "name": "读书打卡", "done": "已读书"},
]
# 同龄标准身高(cm)/体重(kg) 中位数(P50),用于仪表盘「健康综合」的对照参考线。
# 数据来源(中国最新国标,非 WHO):
# · 身高中位数 7~18 岁:国家卫健委 WS/T 612—2018《7岁~18岁儿童青少年身高发育等级评价》
# (依据 2015 年九市儿童体格发育调查,首都儿科研究所李辉团队制定)。
# · 体重中位数 3~18 岁 & 身高 3~6 岁:首都儿科研究所《0~18岁儿童青少年身高、体重百分位数值表》P50
# (2005 九市调查 / 2024 年修订版,同源)。
# 线性插值见 standard_hw(),仅作对照参考,不是诊断依据。
GROWTH_REF = {
"M": {
3: (96.8, 14.65), 4: (104.1, 16.64), 5: (111.3, 18.98), 6: (117.7, 21.26),
7: (125.48, 24.06), 8: (130.72, 27.33), 9: (135.81, 30.46), 10: (140.76, 33.74),
11: (146.01, 37.69), 12: (152.18, 42.49), 13: (160.19, 48.08), 14: (165.63, 53.37),
15: (169.02, 57.08), 16: (170.58, 59.35), 17: (171.39, 60.68), 18: (171.42, 61.40),
},
"F": {
3: (95.6, 14.13), 4: (103.1, 16.17), 5: (110.2, 18.26), 6: (116.6, 20.37),
7: (124.13, 22.64), 8: (129.34, 25.25), 9: (134.91, 28.19), 10: (141.18, 31.76),
11: (147.36, 36.10), 12: (152.41, 40.77), 13: (156.07, 44.79), 14: (157.78, 47.83),
15: (158.47, 49.82), 16: (158.93, 50.81), 17: (159.18, 51.20), 18: (160.01, 51.41),
},
}
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);
CREATE TABLE IF NOT EXISTS checkin_kind (
id INTEGER PRIMARY KEY,
key TEXT UNIQUE,
icon TEXT,
name TEXT,
done_name TEXT,
sort_order INTEGER DEFAULT 0
);
"""
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")
# 旧库残留的 holiday_* 列统一删除,去除“学期 / 假期”区分
for col in ("holiday_start", "holiday_end"):
try:
db.execute(f"ALTER TABLE child DROP COLUMN {col}")
except Exception:
pass
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.execute("""CREATE TABLE IF NOT EXISTS checkin_kind (
id INTEGER PRIMARY KEY,
key TEXT UNIQUE, icon TEXT, name TEXT, done_name TEXT, sort_order INTEGER DEFAULT 0
)""")
_seed_checkin_kinds(db)
# 起止时间由后台“孩子信息”自行设定,不再自动填充假期范围
db.commit()
db.close()
def _seed_checkin_kinds(db):
"""checkin_kind 为空时写入默认三项。"""
if db.execute("SELECT 1 FROM checkin_kind LIMIT 1").fetchone():
return
for i, k in enumerate(CHECKIN_SEED):
db.execute(
"INSERT OR IGNORE INTO checkin_kind (key, icon, name, done_name, sort_order) VALUES (?,?,?,?,?)",
(k["key"], k["icon"], k["name"], k["done"], i))
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
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, created_at, term_start, term_end, "
"default_theme, birth_date, gender, carousel_sec, ring_sec) VALUES (?,?,?,?,?,?,?,?,?)",
("小明", today.isoformat(), term_start, term_end,
"light", birth, "M", 8, 6),
)
child_id = cur.lastrowid
_seed_checkin_kinds(db)
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'''
'''
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''''''
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''
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'')
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'')
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),
})
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
# 日历(学期 / 假期 二选一展示):后台可分别设定起止;仪表盘只显示当前所在时段。
today = datetime.date.today()
try:
y0 = int(str(child["school_year"]).split("-")[0])
fallback_start = datetime.date(y0, 9, 1)
except Exception:
fallback_start = today - datetime.timedelta(days=120)
def _parse(d):
try:
return datetime.date.fromisoformat(d) if d else None
except Exception:
return None
def _safe_end(end):
# 展示用结束日:若已过去则截断到今天,避免日历画到空白未来
return end if (end is None or end >= today) else today
# 起止时间(单一时段,不再区分学期 / 假期)
ts_raw = child["term_start"]
te_raw = child["term_end"]
cal_start = _parse(ts_raw) or fallback_start
cal_end = _safe_end(_parse(te_raw))
cal_s_raw, cal_e_raw = ts_raw, te_raw
if cal_start > today:
cal_start = today - datetime.timedelta(days=150)
cal_start_str = cal_start.strftime("%Y.%m.%d") if cal_start else "—"
cal_end_str = cal_end.strftime("%Y.%m.%d") if cal_end else "—"
# 距结束还有多少天:当前日期距该日历的停止(结束)日期
if cal_e_raw:
try:
dl = (datetime.date.fromisoformat(cal_e_raw) - today).days
except Exception:
dl = 0
else:
dl = 0
if dl > 0:
days_left_str = f"距结束还有 {dl} 天"
elif dl == 0:
days_left_str = "今天结束"
else:
days_left_str = f"已结束 {abs(dl)} 天"
# 轮播间隔(后台统一设定,不再分组)
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()}
ck_rows = db.execute(
"SELECT key, icon, name, done_name FROM checkin_kind ORDER BY sort_order, id").fetchall()
checkins = [{"key": r["key"], "icon": r["icon"], "name": r["name"], "done_name": r["done_name"],
"done": r["key"] in done_kinds} for r in ck_rows]
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,
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, cal_start=cal_start, cal_end=cal_end,
cal_start_str=cal_start_str, cal_end_str=cal_end_str,
days_left_str=days_left_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):
"""浮动快捷打卡:类型来自 checkin_kind 表,后台可增删改。已打卡再点一次可撤销。"""
db = get_db()
if not db.execute("SELECT 1 FROM checkin_kind WHERE key=?", (kind,)).fetchone():
abort(404)
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 []
checkin_kinds = db.execute(
"SELECT id, key, icon, name, done_name FROM checkin_kind ORDER BY sort_order, id").fetchall() if cid else []
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,
checkin_kinds=checkin_kinds,
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"
term_start = request.form.get("term_start") or None
term_end = request.form.get("term_end") or None
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"),
term_start, term_end,
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()
keep_health = request.form.get("keep_health") == "1"
keep_bookshelf = request.form.get("keep_bookshelf") == "1"
# 任务进度(含徽章来源)与快捷打卡始终清空
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")
# 书架:保留时连同阅读记录一起留着
if not keep_bookshelf:
db.execute("DELETE FROM reading_log")
db.execute("DELETE FROM book")
# 健康:保留时连同运动记录一起留着
if not keep_health:
db.execute("DELETE FROM health_log")
db.execute("DELETE FROM exercise_log")
db.commit()
return redirect(url_for("admin"))
@app.post("/admin/checkin/add")
@admin_required
def admin_checkin_add():
"""新增一个浮动打卡项。"""
db = get_db()
key = (request.form.get("key") or "").strip()
if key:
n = db.execute("SELECT COUNT(*) c FROM checkin_kind").fetchone()["c"]
db.execute(
"INSERT OR IGNORE INTO checkin_kind (key, icon, name, done_name, sort_order) VALUES (?,?,?,?,?)",
(key, request.form.get("icon", "✅"),
request.form.get("name", key), request.form.get("done_name", "已完成"), n))
db.commit()
return redirect(url_for("admin"))
@app.post("/admin/checkin/update/")
@admin_required
def admin_checkin_update(kid):
"""修改一个浮动打卡项(名称/图标/完成态文案)。"""
db = get_db()
db.execute(
"UPDATE checkin_kind SET icon=?, name=?, done_name=? WHERE id=?",
(request.form.get("icon", "✅"), request.form.get("name", ""),
request.form.get("done_name", "已完成"), kid))
db.commit()
return redirect(url_for("admin"))
@app.post("/admin/checkin/delete/")
@admin_required
def admin_checkin_delete(kid):
"""删除一个浮动打卡项(同时清掉该类型的打卡记录)。"""
db = get_db()
k = db.execute("SELECT key FROM checkin_kind WHERE id=?", (kid,)).fetchone()
if k:
db.execute("DELETE FROM checkin WHERE kind=?", (k["key"],))
db.execute("DELETE FROM checkin_kind WHERE id=?", (kid,))
db.commit()
return redirect(url_for("admin"))
@app.post("/admin/checkin/backfill")
@admin_required
def admin_checkin_backfill():
"""为指定历史日期补记一次打卡(按 child_id+date+kind 唯一,防重复)。"""
db = get_db()
cid = db.execute("SELECT id FROM child LIMIT 1").fetchone()
if cid:
d = request.form.get("date")
kind = request.form.get("kind")
if d and kind:
db.execute(
"INSERT OR IGNORE INTO checkin (child_id, date, kind, created_at) VALUES (?,?,?,?)",
(cid["id"], d, kind, datetime.date.today().isoformat()))
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.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)