1474 lines
58 KiB
Python
1474 lines
58 KiB
Python
"""
|
||
个人成长系统(儿童向 · 自托管)
|
||
- 单孩子、无 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'''
|
||
<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),
|
||
})
|
||
|
||
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/<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):
|
||
"""浮动快捷打卡:类型来自 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/<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 []
|
||
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/<int:kid>")
|
||
@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/<int:kid>")
|
||
@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/<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.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)
|