UI 打磨与功能调整:中国生长标准、日历合并、弹窗去框、轮播记忆位置、小环居中、后台补卡与重置保留

This commit is contained in:
2026-08-12 15:29:52 +08:00
parent 0e6065897b
commit ddd892dd8b
6 changed files with 325 additions and 163 deletions
+7 -5
View File
@@ -5,12 +5,12 @@
## 特性 ## 特性
- 📅 **学期日历小图**:进度环旁点状日历,每点代表一天,按颜色区分打卡密度(越深越多 / 蓝=周末 / 灰=未打卡 / 圆环=今天),一眼看学期投入节奏。起止日期在家长后台「孩子信息」里设定。 - 📅 **学期 / 假期双日历**:进度环旁两张点状日历——「学期日历」与「假期日历」,每点代表一天,按颜色区分打卡密度(越深越多 / 蓝=周末 / 灰=未打卡 / 圆环=今天)。起止日期(学期起止、假期起止)都在家长后台「孩子信息」里设定。
- 🎡 **各学科进度一览**:一行小进度环,每屏 3 个学科自动轮播;右侧有半透明「›」箭头可手动切下一屏。 - 🎡 **各学科进度一览**:一行小进度环,每屏 3 个学科自动轮播;右侧有半透明「›」箭头可手动切下一屏。
- 📚 **学科进度(单科轮播)**:整年课表(学科 → 单元/章节)逐科展示;每科按任务顺序显示「已完成 3 · 进行中 1 · 待完成 3」,点一下即完成、下一任务自动接上。点「详情」弹窗看全部单元与目标日。 - 📚 **学科进度(单科轮播)**:整年课表(学科 → 单元/章节)逐科展示;每科按任务顺序显示「已完成 3 · 进行中 1 · 待完成 3」,点一下即完成、下一任务自动接上。点「详情」弹窗看全部单元与目标日。
- 📖 **书架墙**:已读完的书以封面墙展示——上排 3 本最近读完、下排 3 本随机回顾;点「详情」弹窗可按书名/作者/分类搜索、分类筛选、排序。无封面时显示彩色书脊,封面完成日期标在右下角。封面支持本地上传(自动标准化为统一 2:3 并生成缩略图)或填图片 URL。 - 📖 **书架墙**:已读完的书以封面墙展示——上排 3 本最近读完、下排 3 本随机回顾;点「详情」弹窗可按书名/作者/分类搜索、分类筛选、排序。无封面时显示彩色书脊,封面完成日期标在右下角。封面支持本地上传(自动标准化为统一 2:3 并生成缩略图)或填图片 URL。
- 💗 **健康综合**:主视图只显示「当前身高 / 当前体重」同龄标准参考;点「📈 图表详情」弹窗看最近两年的身高(绿)/体重(橙)双线趋势。家长后台可补录任意历史日期的健康数据。 - 💗 **健康综合**:主视图只显示「身高 / 体重」当前值与同龄标准参考(标签置于数字左侧);点右上角「📈 图表详情」弹窗看最近两年的身高(绿)/体重(橙)双线趋势。家长后台可补录任意历史日期的健康数据。
- 🚀 **浮动快捷打卡**:仪表盘右侧常驻三个竖向按钮——跳绳打卡、仰卧起坐打卡、读书打卡,点一下即记录当天打卡 - 🚀 **浮动快捷打卡**:仪表盘右下角常驻竖向按钮(默认跳绳 / 仰卧起坐 / 读书),点一下即记录当天打卡;**类型可在家长后台「浮动打卡项」里新增、改名、删除**,仪表盘即时更新
- 🏅 **轻量徽章**:由任务完成数、读书、运动等实时派生(如「迈出第一步」「任务小达人」「单科全通关」「学年进度过半」等),随进展自动获得;仪表盘顶部轮播已得徽章,点开可看全部(含未解锁)与获得时间。 - 🏅 **轻量徽章**:由任务完成数、读书、运动等实时派生(如「迈出第一步」「任务小达人」「单科全通关」「学年进度过半」等),随进展自动获得;仪表盘顶部轮播已得徽章,点开可看全部(含未解锁)与获得时间。
- 🎨 **主题**:家长后台「孩子信息」里设定默认主题(浅色 / 暗色 / 糖果 / 海洋),服务端直接应用,刷新即生效。 - 🎨 **主题**:家长后台「孩子信息」里设定默认主题(浅色 / 暗色 / 糖果 / 海洋),服务端直接应用,刷新即生效。
- 🔒 **双角色**:孩子无 PIN 直进仪表盘;家长后台 `/admin` 用密码管理课表、健康记录与数据(含一键导出 JSON)。 - 🔒 **双角色**:孩子无 PIN 直进仪表盘;家长后台 `/admin` 用密码管理课表、健康记录与数据(含一键导出 JSON)。
@@ -48,9 +48,11 @@ python app.py
## 家长后台数据管理 ## 家长后台数据管理
- **重置全部数据**:清空所有进度 / 打卡 / 健康记录,但保留学科、单元、书目结构(方便从零开始新一轮记录)。 - **重置全部数据**:清空所有进度 / 打卡 / 健康记录,但保留学科、单元、书目结构(方便从零开始新一轮记录)。
- **开启新学期**:把单元进度重置为「未开始」、清空打卡,并把学期起设为今天;适合换学期时一键归零 - **浮动打卡项(可增删改)**:新增 / 改名 / 删除右下角常驻的打卡按钮;删除时会一并清掉该类型的打卡记录
> 徽章由进度实时派生,重置或开启新学期后会随孩子的新进展自动重新累积,无需手动发徽章。 > 徽章由进度实时派生,重置后会随孩子的新进展自动重新累积,无需手动发徽章。
界面说明:仪表盘无顶部导航栏;时钟居中并显示「距结束还有 XX 天」(按后台设定的学期止计算)。
## 数据与备份 ## 数据与备份
+177 -80
View File
@@ -45,26 +45,32 @@ BOOK_CATEGORIES = [
"历史人文", "漫画", "英文读物", "工具书", "历史人文", "漫画", "英文读物", "工具书",
] ]
# 浮动快捷打卡项 # 浮动快捷打卡项(默认种子,存进 checkin_kind 表,可在后台增删改)
CHECKIN_KINDS = { CHECKIN_SEED = [
"rope": {"icon": "🪢", "name": "跳绳打卡", "done": "已跳绳"}, {"key": "rope", "icon": "🪢", "name": "跳绳打卡", "done": "已跳绳"},
"situp": {"icon": "🤸", "name": "仰卧起坐打卡", "done": "已做仰卧起坐"}, {"key": "situp", "icon": "🤸", "name": "仰卧起坐打卡", "done": "已做仰卧起坐"},
"read": {"icon": "📖", "name": "读书打卡", "done": "已读书"}, {"key": "read", "icon": "📖", "name": "读书打卡", "done": "已读书"},
} ]
# 同龄标准身高(cm)/体重(kg) 参考中位数(WHO 生长参考,仅作对照,不是诊断依据) # 同龄标准身高(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 = { GROWTH_REF = {
"M": { "M": {
3: (96.1, 14.3), 4: (103.3, 16.3), 5: (110.0, 18.3), 6: (116.0, 20.5), 3: (96.8, 14.65), 4: (104.1, 16.64), 5: (111.3, 18.98), 6: (117.7, 21.26),
7: (121.7, 22.9), 8: (127.3, 25.4), 9: (132.6, 28.1), 10: (137.8, 31.2), 7: (125.48, 24.06), 8: (130.72, 27.33), 9: (135.81, 30.46), 10: (140.76, 33.74),
11: (143.1, 34.7), 12: (149.1, 38.7), 13: (156.0, 43.4), 14: (163.2, 48.8), 11: (146.01, 37.69), 12: (152.18, 42.49), 13: (160.19, 48.08), 14: (165.63, 53.37),
15: (169.0, 54.0), 16: (172.9, 58.1), 17: (175.2, 61.5), 18: (176.5, 64.0), 15: (169.02, 57.08), 16: (170.58, 59.35), 17: (171.39, 60.68), 18: (171.42, 61.40),
}, },
"F": { "F": {
3: (95.1, 13.9), 4: (102.7, 16.0), 5: (109.4, 17.9), 6: (115.1, 19.9), 3: (95.6, 14.13), 4: (103.1, 16.17), 5: (110.2, 18.26), 6: (116.6, 20.37),
7: (120.8, 22.4), 8: (126.6, 25.0), 9: (132.5, 28.2), 10: (138.6, 31.9), 7: (124.13, 22.64), 8: (129.34, 25.25), 9: (134.91, 28.19), 10: (141.18, 31.76),
11: (145.0, 36.1), 12: (151.2, 40.8), 13: (156.4, 45.0), 14: (159.8, 47.6), 11: (147.36, 36.10), 12: (152.41, 40.77), 13: (156.07, 44.79), 14: (157.78, 47.83),
15: (161.7, 49.4), 16: (162.5, 50.8), 17: (162.9, 51.8), 18: (163.1, 52.5), 15: (158.47, 49.82), 16: (158.93, 50.81), 17: (159.18, 51.20), 18: (160.01, 51.41),
}, },
} }
@@ -173,6 +179,14 @@ CREATE TABLE IF NOT EXISTS checkin (
created_at TEXT created_at TEXT
); );
CREATE UNIQUE INDEX IF NOT EXISTS idx_checkin_uniq ON checkin (child_id, date, kind); 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 = [ DEFAULT_SUBJECTS = [
@@ -229,11 +243,17 @@ def migrate_db():
if "category" not in cols: if "category" not in cols:
db.execute("ALTER TABLE book ADD COLUMN category TEXT DEFAULT '未分类'") db.execute("ALTER TABLE book ADD COLUMN category TEXT DEFAULT '未分类'")
db.execute("UPDATE book SET category='未分类' WHERE category IS NULL OR category=''") db.execute("UPDATE book SET category='未分类' WHERE category IS NULL OR category=''")
# child 学期起止 + 默认主题 + 生日/性别 + 轮播间隔 # child 起止时间 + 默认主题 + 生日/性别 + 轮播间隔
ccols = [r["name"] for r in db.execute("PRAGMA table_info(child)").fetchall()] 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"): for c in ("term_start", "term_end", "default_theme", "birth_date", "gender"):
if c not in ccols: if c not in ccols:
db.execute(f"ALTER TABLE child ADD COLUMN {c} TEXT") 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: if "gender" not in ccols:
db.execute("UPDATE child SET gender='M' WHERE gender IS NULL OR gender=''") db.execute("UPDATE child SET gender='M' WHERE gender IS NULL OR gender=''")
if "carousel_sec" not in ccols: if "carousel_sec" not in ccols:
@@ -253,10 +273,28 @@ def migrate_db():
created_at TEXT created_at TEXT
)""") )""")
db.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_checkin_uniq ON checkin (child_id, date, kind)") 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.commit()
db.close() 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(): def seed_if_empty():
db = sqlite3.connect(DB_PATH) db = sqlite3.connect(DB_PATH)
db.row_factory = sqlite3.Row db.row_factory = sqlite3.Row
@@ -265,21 +303,17 @@ def seed_if_empty():
return return
today = datetime.date.today() today = datetime.date.today()
y0 = today.year y0 = today.year
sy = f"{y0}-{y0 + 1}"
term_start = datetime.date(y0, 9, 1).isoformat() term_start = datetime.date(y0, 9, 1).isoformat()
term_end = datetime.date(y0 + 1, 8, 31).isoformat() term_end = datetime.date(y0 + 1, 8, 31).isoformat()
birth = (today - datetime.timedelta(days=int(365.25 * 8.5))).isoformat() birth = (today - datetime.timedelta(days=int(365.25 * 8.5))).isoformat()
cur = db.execute( cur = db.execute(
"INSERT INTO child (name, grade_label, school_year, created_at, term_start, term_end, " "INSERT INTO child (name, created_at, term_start, term_end, "
"default_theme, birth_date, gender, carousel_sec, ring_sec) VALUES (?,?,?,?,?,?,?,?,?,?,?)", "default_theme, birth_date, gender, carousel_sec, ring_sec) VALUES (?,?,?,?,?,?,?,?,?)",
("小明", "小学三年级", sy, today.isoformat(), term_start, term_end, ("小明", today.isoformat(), term_start, term_end,
"light", birth, "M", 8, 6), "light", birth, "M", 8, 6),
) )
child_id = cur.lastrowid child_id = cur.lastrowid
db.execute( _seed_checkin_kinds(db)
"INSERT INTO goal (child_id, book_target, week_exercise_min) VALUES (?,?,?)",
(child_id, 30, 120),
)
subj_ids = {} subj_ids = {}
for i, (name, color, icon) in enumerate(DEFAULT_SUBJECTS): for i, (name, color, icon) in enumerate(DEFAULT_SUBJECTS):
sid = db.execute( sid = db.execute(
@@ -850,7 +884,6 @@ def dashboard():
"done": sum(1 for u in units if u["status"] == 2), "done": sum(1 for u in units if u["status"] == 2),
}) })
goal = db.execute("SELECT * FROM goal WHERE child_id=?", (cid,)).fetchone()
books = db.execute("SELECT * FROM book WHERE child_id=? ORDER BY id DESC", (cid,)).fetchall() 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") done_books = sum(1 for b in books if b["status"] == "done")
@@ -886,29 +919,50 @@ def dashboard():
for r in db.execute("SELECT date FROM checkin WHERE child_id=?", (cid,)).fetchall(): for r in db.execute("SELECT date FROM checkin WHERE child_id=?", (cid,)).fetchall():
ac[r["date"]] += 1 ac[r["date"]] += 1
# 学期起止:后台可设定 term_start / term_end;否则按学年 9/1 开学推算 # 日历(学期 / 假期 二选一展示):后台可分别设定起止;仪表盘只显示当前所在时段。
today = datetime.date.today() today = datetime.date.today()
try: try:
y0 = int(child["school_year"].split("-")[0]) y0 = int(str(child["school_year"]).split("-")[0])
fallback_start = datetime.date(y0, 9, 1) fallback_start = datetime.date(y0, 9, 1)
except Exception: except Exception:
fallback_start = today - datetime.timedelta(days=120) fallback_start = today - datetime.timedelta(days=120)
ts_raw = child["term_start"]
term_start = datetime.date.fromisoformat(ts_raw) if ts_raw else fallback_start def _parse(d):
# 结束:若是过去日期用设定值,否则显示到今天
term_end = today
te_raw = child["term_end"]
if te_raw:
try: try:
te = datetime.date.fromisoformat(te_raw) return datetime.date.fromisoformat(d) if d else None
if te < today:
term_end = te
except Exception: except Exception:
pass return None
# 展示起点:学期还没开始(起点在未来)时回退到近 150 天,避免空白
disp_start = term_start if term_start <= today else (today - datetime.timedelta(days=150)) def _safe_end(end):
term_start_str = disp_start.strftime("%Y.%m.%d") # 展示用结束日:若已过去则截断到今天,避免日历画到空白未来
term_end_str = term_end.strftime("%Y.%m.%d") 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 ring_sec = (child["ring_sec"] if "ring_sec" in child.keys() else None) or 6
@@ -954,8 +1008,10 @@ def dashboard():
# 今日快捷打卡状态 # 今日快捷打卡状态
done_kinds = {r["kind"] for r in db.execute( done_kinds = {r["kind"] for r in db.execute(
"SELECT kind FROM checkin WHERE child_id=? AND date=?", (cid, today.isoformat())).fetchall()} "SELECT kind FROM checkin WHERE child_id=? AND date=?", (cid, today.isoformat())).fetchall()}
checkins = [{"key": k, "icon": v["icon"], "name": v["name"], "done_name": v["done"], ck_rows = db.execute(
"done": k in done_kinds} for k, v in CHECKIN_KINDS.items()] "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( return render_template(
"dashboard.html", "dashboard.html",
@@ -963,12 +1019,13 @@ def dashboard():
ring_svg=ring_svg, bar_svg=bar_svg, mini_ring_svg=mini_ring_svg, ring_svg=ring_svg, bar_svg=bar_svg, mini_ring_svg=mini_ring_svg,
term_calendar_svg=term_calendar_svg, cover_bg=cover_bg, term_calendar_svg=term_calendar_svg, cover_bg=cover_bg,
health_chart_svg=health_chart_svg, health_chart_svg=health_chart_svg,
goal=goal, books=books, done_books=done_books, books=books, done_books=done_books,
hpoints=hpoints, cur_h=cur_h, cur_w=cur_w, hpoints=hpoints, cur_h=cur_h, cur_w=cur_w,
std_h=std_h, std_w=std_w, age=round(age, 1), std_h=std_h, std_w=std_w, age=round(age, 1),
badges=badges, got_badges=got_badges, badges=badges, got_badges=got_badges,
active_counts=ac, term_start=disp_start, term_end=term_end, active_counts=ac, cal_start=cal_start, cal_end=cal_end,
term_start_str=term_start_str, term_end_str=term_end_str, cal_start_str=cal_start_str, cal_end_str=cal_end_str,
days_left_str=days_left_str,
slides=slides, ring_slides=ring_slides, slides=slides, ring_slides=ring_slides,
carousel_sec=carousel_sec, ring_sec=ring_sec, carousel_sec=carousel_sec, ring_sec=ring_sec,
theme=theme, theme=theme,
@@ -999,10 +1056,10 @@ def unit_toggle(uid):
@app.post("/checkin/<kind>") @app.post("/checkin/<kind>")
def checkin_toggle(kind): def checkin_toggle(kind):
"""浮动快捷打卡:跳绳 / 仰卧起坐 / 读书。已打卡再点一次可撤销。""" """浮动快捷打卡:类型来自 checkin_kind 表,后台可增删改。已打卡再点一次可撤销。"""
if kind not in CHECKIN_KINDS:
abort(404)
db = get_db() 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() row = db.execute("SELECT id FROM child LIMIT 1").fetchone()
if not row: if not row:
abort(404) abort(404)
@@ -1207,11 +1264,13 @@ def admin():
subjects.append({"id": s["id"], "name": s["name"], "color": s["color"], subjects.append({"id": s["id"], "name": s["name"], "color": s["color"],
"icon": s["icon"], "units": units}) "icon": s["icon"], "units": units})
books = db.execute("SELECT * FROM book WHERE child_id=? ORDER BY id DESC", (cid,)).fetchall() if cid else [] books = db.execute("SELECT * FROM book WHERE child_id=? ORDER BY id DESC", (cid,)).fetchall() if cid else []
goal = db.execute("SELECT * FROM goal WHERE child_id=?", (cid,)).fetchone() if cid else None 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", "海洋")] themes = [("light", "浅色"), ("dark", "暗色"), ("candy", "糖果"), ("ocean", "海洋")]
age = child_age(child) if child else 0 age = child_age(child) if child else 0
std_h, std_w = standard_hw(age, (child["gender"] or "M") if child else "M") std_h, std_w = standard_hw(age, (child["gender"] or "M") if child else "M")
return render_template("admin.html", child=child, subjects=subjects, books=books, goal=goal, return render_template("admin.html", child=child, subjects=subjects, books=books,
checkin_kinds=checkin_kinds,
themes=themes, book_categories=BOOK_CATEGORIES, themes=themes, book_categories=BOOK_CATEGORIES,
age=round(age, 1), std_h=std_h, std_w=std_w, age=round(age, 1), std_h=std_h, std_w=std_w,
now=datetime.date.today().isoformat()) now=datetime.date.today().isoformat())
@@ -1226,12 +1285,13 @@ def admin_child_update():
gender = (request.form.get("gender", "M") or "M").upper() gender = (request.form.get("gender", "M") or "M").upper()
if gender not in ("M", "F"): if gender not in ("M", "F"):
gender = "M" gender = "M"
term_start = request.form.get("term_start") or None
term_end = request.form.get("term_end") or None
db.execute( db.execute(
"UPDATE child SET name=?, term_start=?, term_end=?, " "UPDATE child SET name=?, term_start=?, term_end=?, "
"default_theme=?, birth_date=?, gender=?, carousel_sec=?, ring_sec=? WHERE id=?", "default_theme=?, birth_date=?, gender=?, carousel_sec=?, ring_sec=? WHERE id=?",
(request.form.get("name"), (request.form.get("name"),
request.form.get("term_start") or None, term_start, term_end,
request.form.get("term_end") or None,
request.form.get("default_theme", "light"), request.form.get("default_theme", "light"),
request.form.get("birth_date") or None, request.form.get("birth_date") or None,
gender, gender,
@@ -1245,33 +1305,83 @@ def admin_child_update():
@app.post("/admin/reset") @app.post("/admin/reset")
@admin_required @admin_required
def admin_reset(): def admin_reset():
"""清空所有动态进度与活动数据,保留孩子/学科/单元/书目结构""" """清空动态进度与活动数据。默认全部清空;可勾选保留健康或书架"""
db = get_db() 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") db.execute("UPDATE unit SET status=0, completed_at=NULL")
for s in db.execute("SELECT id FROM subject").fetchall(): for s in db.execute("SELECT id FROM subject").fetchall():
normalize_units(db, s["id"]) normalize_units(db, s["id"])
db.execute("DELETE FROM checkin") db.execute("DELETE FROM checkin")
db.execute("DELETE FROM health_log") # 书架:保留时连同阅读记录一起留着
db.execute("DELETE FROM exercise_log") if not keep_bookshelf:
db.execute("DELETE FROM reading_log") 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() db.commit()
return redirect(url_for("admin")) return redirect(url_for("admin"))
@app.post("/admin/term/new") @app.post("/admin/checkin/add")
@admin_required @admin_required
def admin_new_term(): 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() db = get_db()
today = datetime.date.today().isoformat()
cid = db.execute("SELECT id FROM child LIMIT 1").fetchone() cid = db.execute("SELECT id FROM child LIMIT 1").fetchone()
if cid: if cid:
db.execute("UPDATE child SET term_start=? WHERE id=?", (today, cid["id"])) d = request.form.get("date")
db.execute("UPDATE unit SET status=0, completed_at=NULL") kind = request.form.get("kind")
for s in db.execute("SELECT id FROM subject").fetchall(): if d and kind:
normalize_units(db, s["id"]) db.execute(
db.execute("DELETE FROM checkin") "INSERT OR IGNORE INTO checkin (child_id, date, kind, created_at) VALUES (?,?,?,?)",
db.commit() (cid["id"], d, kind, datetime.date.today().isoformat()))
db.commit()
return redirect(url_for("admin")) return redirect(url_for("admin"))
@@ -1327,19 +1437,6 @@ def admin_unit_delete(uid):
return redirect(url_for("admin")) return redirect(url_for("admin"))
@app.post("/admin/goal/update")
@admin_required
def admin_goal_update():
db = get_db()
cid = db.execute("SELECT id FROM child LIMIT 1").fetchone()["id"]
db.execute(
"UPDATE goal SET book_target=?, week_exercise_min=? WHERE child_id=?",
(request.form.get("book_target", type=int, default=30),
request.form.get("week_exercise_min", type=int, default=120), cid))
db.commit()
return redirect(url_for("admin"))
@app.route("/admin/export") @app.route("/admin/export")
@admin_required @admin_required
def admin_export(): def admin_export():
+43 -11
View File
@@ -184,8 +184,9 @@ button:hover { filter: brightness(.95); }
.hero-right { display: flex; align-items: center; gap: 22px; flex-wrap: wrap; } .hero-right { display: flex; align-items: center; gap: 22px; flex-wrap: wrap; }
.hero-ring { flex: 0 0 auto; } .hero-ring { flex: 0 0 auto; }
.termcal { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; } .termcal { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; }
.termcal-title { font-weight: 700; font-size: 15px; } .termcal-title { font-weight: 700; font-size: 15px; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.termcal-range { color: var(--muted); font-size: 12px; margin: 2px 0 8px; } .termcal-range { color: var(--muted); font-size: 12px; margin: 2px 0 8px; }
.termcal-range-inline { color: var(--muted); font-size: 12px; font-weight: 400; }
.termcal-scroll { overflow-x: auto; max-width: 100%; } .termcal-scroll { overflow-x: auto; max-width: 100%; }
.termcal-svg { display: block; } .termcal-svg { display: block; }
.termcal-legend { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 8px; font-size: 12px; color: var(--muted); } .termcal-legend { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 8px; font-size: 12px; color: var(--muted); }
@@ -213,10 +214,10 @@ button:hover { filter: brightness(.95); }
} }
.modal-mask.show { display: flex; } .modal-mask.show { display: flex; }
.modal { .modal {
background: #fff; border-radius: 16px; width: min(560px, 100%); max-height: 80vh; background: var(--card); border: none; border-radius: 16px; width: min(560px, 100%); max-height: 80vh;
overflow: auto; box-shadow: 0 20px 60px rgba(0,0,0,.25); overflow: auto; box-shadow: 0 20px 60px rgba(0,0,0,.25);
} }
.modal-head { display: flex; justify-content: space-between; align-items: center; padding: 14px 18px; border-bottom: 1px solid var(--line); position: sticky; top: 0; background: #fff; } .modal-head { display: flex; justify-content: space-between; align-items: center; padding: 14px 18px; border-bottom: 1px solid var(--line); position: sticky; top: 0; background: var(--card); color: var(--ink); }
.modal-head span { font-weight: 700; } .modal-head span { font-weight: 700; }
.modal-body { padding: 16px 18px; } .modal-body { padding: 16px 18px; }
@@ -265,6 +266,8 @@ button:hover { filter: brightness(.95); }
.slide { display: flex; gap: 16px; } .slide { display: flex; gap: 16px; }
.slide .subject { flex: 1; } .slide .subject { flex: 1; }
.carousel-nav { display: flex; align-items: center; justify-content: center; gap: 14px; margin-top: 14px; } .carousel-nav { display: flex; align-items: center; justify-content: center; gap: 14px; margin-top: 14px; }
.carousel-nav button { background: none; border: none; color: var(--muted); font-size: 13px; padding: 4px 10px; cursor: pointer; }
.carousel-nav button:hover { color: var(--ink); }
#carouselInfo { font-size: 13px; min-width: 70px; text-align: center; } #carouselInfo { font-size: 13px; min-width: 70px; text-align: center; }
@media (max-width: 760px) { @media (max-width: 760px) {
.slide { flex-direction: column; } .slide { flex-direction: column; }
@@ -314,8 +317,8 @@ button:hover { filter: brightness(.95); }
.now-time { font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums; margin-top: 4px; color: var(--blue); } .now-time { font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums; margin-top: 4px; color: var(--blue); }
/* ===== 学科进度一览(轮播小环) ===== */ /* ===== 学科进度一览(轮播小环) ===== */
.ringcar { min-height: 150px; } .ringcar { min-height: 150px; display: flex; align-items: center; }
.ring-slide { display: flex; gap: 18px; justify-content: space-around; flex-wrap: wrap; } .ring-slide { display: flex; gap: 18px; justify-content: space-around; align-items: center; flex-wrap: wrap; width: 100%; }
.ring-slide .mini-ring { display: flex; flex-direction: row; align-items: center; gap: 6px; width: auto; } .ring-slide .mini-ring { display: flex; flex-direction: row; align-items: center; gap: 6px; width: auto; }
/* ===== 健康综合 ===== */ /* ===== 健康综合 ===== */
@@ -327,9 +330,11 @@ button:hover { filter: brightness(.95); }
.lgline + .lgline { margin-left: 12px; } .lgline + .lgline { margin-left: 12px; }
.sub.tiny { font-size: 12px; margin-top: 8px; } .sub.tiny { font-size: 12px; margin-top: 8px; }
.hstats { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 12px; } .hstats { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 12px; }
.hstat { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 12px; text-align: center; } .hstat { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 12px; display: flex; align-items: center; justify-content: space-between; gap: 10px; cursor: pointer; transition: background .15s, border-color .15s; }
.hs-label { font-size: 12px; color: var(--muted); } .hstat:hover { background: var(--card); border-color: var(--blue); }
.hs-val { font-size: 26px; font-weight: 800; line-height: 1.1; margin: 4px 0; } .hs-label { font-size: 12px; color: var(--muted); flex: 0 0 auto; }
.hs-main { text-align: right; }
.hs-val { font-size: 26px; font-weight: 800; line-height: 1.1; }
.hs-val em { font-size: 13px; font-style: normal; color: var(--muted); margin-left: 3px; } .hs-val em { font-size: 13px; font-style: normal; color: var(--muted); margin-left: 3px; }
.hs-std { font-size: 12px; color: var(--muted); } .hs-std { font-size: 12px; color: var(--muted); }
.hs-diff { margin-left: 4px; font-weight: 700; } .hs-diff { margin-left: 4px; font-weight: 700; }
@@ -370,17 +375,18 @@ button:hover { filter: brightness(.95); }
/* ===== 宽弹窗(书架详情) ===== */ /* ===== 宽弹窗(书架详情) ===== */
.modal-wide { width: min(860px, 100%); } .modal-wide { width: min(860px, 100%); }
/* ===== hero 当前日期时钟:去方框 ===== */ /* ===== hero 当前日期时钟:去方框 + 居中 + 距结束小字 ===== */
.nowbox { .nowbox {
background: none; border: none; border-radius: 0; padding: 0; min-width: 0; text-align: left; background: none; border: none; border-radius: 0; padding: 0; min-width: 0; text-align: center;
} }
.now-date { font-size: 15px; font-weight: 700; } .now-date { font-size: 15px; font-weight: 700; }
.now-week { font-size: 13px; color: var(--muted); margin-top: 1px; } .now-week { font-size: 13px; color: var(--muted); margin-top: 1px; }
.now-time { font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums; margin-top: 2px; color: var(--blue); } .now-time { font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums; margin-top: 2px; color: var(--blue); }
.now-left { font-size: 12px; color: var(--muted); margin-top: 4px; }
/* ===== 进度一览 / 健康综合:压缩到约 1/3 高度 ===== */ /* ===== 进度一览 / 健康综合:压缩到约 1/3 高度 ===== */
.ringcar { min-height: auto; } .ringcar { min-height: auto; }
.ring-slide .mini-ring svg { width: 46px; height: 46px; } .ring-slide .mini-ring svg { width: 69px; height: 69px; }
.ring-slide .mini-name { font-size: 11px; margin-bottom: 0; text-align: left; white-space: nowrap; } .ring-slide .mini-name { font-size: 11px; margin-bottom: 0; text-align: left; white-space: nowrap; }
.ring-wrap { position: relative; padding-right: 30px; } .ring-wrap { position: relative; padding-right: 30px; }
.ring-next { .ring-next {
@@ -412,3 +418,29 @@ button:hover { filter: brightness(.95); }
.bc-desc { font-size: 12px; color: var(--muted); margin-top: 4px; } .bc-desc { font-size: 12px; color: var(--muted); margin-top: 4px; }
.bc-state { font-size: 12px; margin-top: 8px; color: #16a34a; } .bc-state { font-size: 12px; margin-top: 8px; color: #16a34a; }
.badge-cell.locked .bc-state { color: var(--muted); } .badge-cell.locked .bc-state { color: var(--muted); }
/* ===== 弹窗:方框背景(主题自适应卡片) ===== */
.modal-mask { background: rgba(15,23,42,.55); }
.modal { border: 1px solid var(--line); }
/* ===== 纯文字触发按钮(替代带方框的 mini 按钮) ===== */
.link-btn {
background: none; border: none; padding: 2px 4px; cursor: pointer;
color: var(--blue); font-size: 12px; font-weight: 600;
}
.link-btn:hover { text-decoration: underline; }
/* ===== 学期 / 假期 日历切换 ===== */
.termcal-tabs { display: flex; gap: 4px; margin-bottom: 6px; }
.tctab {
background: none; border: none; cursor: pointer; padding: 4px 12px;
font-size: 14px; color: var(--muted); border-radius: 999px;
}
.tctab.active { color: var(--ink); font-weight: 700; box-shadow: inset 0 -2px 0 var(--blue); border-radius: 0; }
/* ===== 后台:重置保留选项 ===== */
.chk { display: inline-flex; align-items: center; gap: 6px; font-size: 14px; cursor: pointer; }
/* ===== 书架墙提示 + 封面可点开详情 ===== */
.shelf-hint { font-size: 12px; color: var(--muted); margin: 8px 0 0; }
.shelf-mini .book-cover { cursor: pointer; }
+56 -26
View File
@@ -19,8 +19,8 @@
<option value="F" {% if child.gender == 'F' %}selected{% endif %}></option> <option value="F" {% if child.gender == 'F' %}selected{% endif %}></option>
</select> </select>
</label> </label>
<label>学期起<input type="date" name="term_start" value="{{ child.term_start or '' }}"></label> <label>开始日期<input type="date" name="term_start" value="{{ child.term_start or '' }}"></label>
<label>学期止<input type="date" name="term_end" value="{{ child.term_end or '' }}"></label> <label>结束日期<input type="date" name="term_end" value="{{ child.term_end or '' }}"></label>
<label>进度环轮播(秒)<input name="ring_sec" type="number" value="{{ child.ring_sec or 6 }}" style="width:80px" min="2"></label> <label>进度环轮播(秒)<input name="ring_sec" type="number" value="{{ child.ring_sec or 6 }}" style="width:80px" min="2"></label>
<label>学科轮播(秒)<input name="carousel_sec" type="number" value="{{ child.carousel_sec or 8 }}" style="width:80px" min="2"></label> <label>学科轮播(秒)<input name="carousel_sec" type="number" value="{{ child.carousel_sec or 8 }}" style="width:80px" min="2"></label>
<label>默认主题 <label>默认主题
@@ -33,23 +33,10 @@
<button>保存</button> <button>保存</button>
</form> </form>
{% if age is not none %} {% if age is not none %}
<p class="sub">当前年龄约 <b>{{ '%.1f'|format(age) }}</b> 岁 · 标准身高 <b>{{ std_h }} cm</b> · 标准体重 <b>{{ std_w }} kg</b>WHO 中位参考</p> <p class="sub">当前年龄约 <b>{{ '%.1f'|format(age) }}</b> 岁 · 标准身高 <b>{{ std_h }} cm</b> · 标准体重 <b>{{ std_w }} kg</b>中国儿童生长标准 WS/T 612-2018</p>
{% endif %} {% endif %}
</section> </section>
<section class="card">
<h2>🎯 年度目标</h2>
<form method="post" action="{{ url_for('admin_goal_update') }}" class="row">
<label>读书目标(本/年)
<input name="book_target" type="number" value="{{ goal.book_target }}" style="width:90px">
</label>
<label>运动目标(分钟/周)
<input name="week_exercise_min" type="number" value="{{ goal.week_exercise_min }}" style="width:90px">
</label>
<button>保存</button>
</form>
</section>
<section class="card"> <section class="card">
<h2>📈 健康记录(可补历史数据)</h2> <h2>📈 健康记录(可补历史数据)</h2>
<p class="sub">填入任意日期即可补录历史身高体重;仪表盘「健康综合」会展示最近两年。</p> <p class="sub">填入任意日期即可补录历史身高体重;仪表盘「健康综合」会展示最近两年。</p>
@@ -165,17 +152,60 @@
<section class="card"> <section class="card">
<h2>🧹 数据管理</h2> <h2>🧹 数据管理</h2>
<p class="sub">徽章由进度/读书/运动实时派生,重置或新学期后会自动重新累积,无需手动发徽章。</p> <p class="sub">徽章由进度/读书/运动实时派生,重置后会自动重新累积,无需手动发徽章。</p>
<div class="row" style="gap:12px;flex-wrap:wrap"> <form method="post" action="{{ url_for('admin_reset') }}"
<form method="post" action="{{ url_for('admin_reset') }}" onsubmit="return confirm('确定重置数据?未勾选保留的项将被清空,此操作不可撤销!')">
onsubmit="return confirm('确定清空全部进度 / 打卡 / 健康记录?学科与书目结构保留。此操作不可撤销!')"> <div class="row" style="gap:18px;flex-wrap:wrap;align-items:flex-start">
<button class="mini danger">🗑️ 重置全部数据</button> <label class="chk"><input type="checkbox" name="keep_health" value="1"> 保留健康记录(身高/体重/睡眠/运动)</label>
</form> <label class="chk"><input type="checkbox" name="keep_bookshelf" value="1"> 保留书架(书目与阅读记录)</label>
<form method="post" action="{{ url_for('admin_new_term') }}" </div>
onsubmit="return confirm('开启新学期:进度重置为未开始、清空打卡,并把学期起设为今天。确定?')"> <div class="row" style="margin-top:12px">
<button class="mini">🎒 开启新学期</button> <button class="mini danger">🗑️ 重置数据</button>
</form> </div>
</form>
</section>
<section class="card">
<h2>⚡ 浮动打卡项(可增删改)</h2>
<p class="sub">仪表盘右下角的竖排打卡按钮;这里新增 / 改名 / 删除,仪表盘即时更新。</p>
<form method="post" action="{{ url_for('admin_checkin_add') }}" class="row wrap">
<input name="key" placeholder="标识,如 walk" required style="width:120px">
<input name="icon" placeholder="图标" style="width:60px" value="🚶">
<input name="name" placeholder="按钮名,如 走路打卡" required>
<input name="done_name" placeholder="完成后文案,如 已走路" style="width:120px">
<button>加一项</button>
</form>
{% for k in checkin_kinds %}
<div class="admin-subject">
<div class="admin-subject-head">
<span>{{ k.icon }} <b>{{ k.name }}</b> <span class="muted">{{ k.key }} · 完成显示「{{ k.done_name }}」)</span></span>
<span class="admin-subject-controls">
<form method="post" action="{{ url_for('admin_checkin_update', kid=k.id) }}" class="inline row" style="margin:0">
<input name="icon" value="{{ k.icon }}" style="width:46px">
<input name="name" value="{{ k.name }}" style="width:120px">
<input name="done_name" value="{{ k.done_name }}" style="width:110px">
<button class="mini">保存</button>
</form>
<form method="post" action="{{ url_for('admin_checkin_delete', kid=k.id) }}" class="inline"
onsubmit="return confirm('删除该打卡项及其打卡记录?')">
<button class="mini danger">删除</button>
</form>
</span>
</div>
</div> </div>
{% endfor %}
</section>
<section class="card">
<h2>🗓️ 补卡(补录历史打卡)</h2>
<p class="sub">为过去的某一天补记一次打卡;同一天同一项目不会重复记。仪表盘日历会显示该日已打卡。</p>
<form method="post" action="{{ url_for('admin_checkin_backfill') }}" class="row wrap">
<label>日期<input type="date" name="date" value="{{ now }}" required></label>
<select name="kind">
{% for k in checkin_kinds %}<option value="{{ k.key }}">{{ k.icon }} {{ k.name }}</option>{% endfor %}
</select>
<button>补卡</button>
</form>
</section> </section>
{% endblock %} {% endblock %}
-7
View File
@@ -7,13 +7,6 @@
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head> </head>
<body> <body>
<header class="topbar">
<div class="brand">🌱 个人成长系统</div>
<nav>
<a href="{{ url_for('dashboard') }}">仪表盘</a>
<a href="{{ url_for('admin') }}">家长后台</a>
</nav>
</header>
<main class="container"> <main class="container">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
+42 -34
View File
@@ -21,18 +21,18 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
<button type="button" class="mini" onclick="openBadges()">🏅 全部徽章</button> <button type="button" class="link-btn" onclick="openBadges()">🏅 点击查看全部徽章 </button>
</div> </div>
<div class="hero-right"> <div class="hero-right">
<div class="nowbox"> <div class="nowbox">
<div class="now-date" id="nowDate"></div> <div class="now-date" id="nowDate"></div>
<div class="now-week" id="nowWeek"></div> <div class="now-week" id="nowWeek"></div>
<div class="now-time" id="nowTime">--:--:--</div> <div class="now-time" id="nowTime">--:--:--</div>
<div class="now-left" id="nowLeft">{{ days_left_str }}</div>
</div> </div>
<div class="termcal"> <div class="termcal">
<div class="termcal-title">📅 学期日历</div> <div class="termcal-title">📅 日历 <span class="termcal-range-inline" id="calRange">{{ cal_start_str }} ~ {{ cal_end_str }}</span></div>
<div class="termcal-range">{{ term_start_str }} ~ {{ term_end_str }}</div> <div class="termcal-scroll">{{ term_calendar_svg(active_counts, cal_start, cal_end) | safe }}</div>
<div class="termcal-scroll">{{ term_calendar_svg(active_counts, term_start, term_end) | safe }}</div>
<div class="termcal-legend"> <div class="termcal-legend">
<span><i class="lg tc-act3"></i>多项</span> <span><i class="lg tc-act3"></i>多项</span>
<span><i class="lg tc-act1"></i>有打卡</span> <span><i class="lg tc-act1"></i>有打卡</span>
@@ -42,14 +42,13 @@
</div> </div>
</div> </div>
<div class="hero-ring"> <div class="hero-ring">
{{ ring_svg(yprog, '#3b82f6', 130, '学年总进度') | safe }} {{ ring_svg(yprog, '#3b82f6', 130, '总进度') | safe }}
</div> </div>
</div> </div>
</div> </div>
<div class="grid-2"> <div class="grid-2">
<section class="card"> <section class="card">
<h2>🎡 各学科进度一览</h2>
<div class="ring-wrap"> <div class="ring-wrap">
<div class="ringcar" id="ringCar" data-interval="{{ ring_sec }}"> <div class="ringcar" id="ringCar" data-interval="{{ ring_sec }}">
{% for grp in ring_slides %} {% for grp in ring_slides %}
@@ -68,42 +67,41 @@
</section> </section>
<section class="card"> <section class="card">
<h2>💗 健康综合</h2>
<div class="hstats"> <div class="hstats">
<div class="hstat"> <div class="hstat" onclick="openHealth()" title="点击查看健康图表详情">
<div class="hs-label">当前身高</div> <div class="hs-label">身高</div>
<div class="hs-val" style="color:#10b981">{{ cur_h if cur_h is not none else '—' }}<em>cm</em></div> <div class="hs-main">
<div class="hs-std">标准 {{ std_h }} cm <div class="hs-val" style="color:#10b981">{{ cur_h if cur_h is not none else '—' }}<em>cm</em></div>
{%- if cur_h is not none %} <div class="hs-std">标准 {{ std_h }} cm
<span class="hs-diff {{ 'up' if cur_h >= std_h else 'down' }}"> {%- if cur_h is not none %}
{{ '+' if cur_h >= std_h else '' }}{{ (cur_h - std_h) | round(1) }} <span class="hs-diff {{ 'up' if cur_h >= std_h else 'down' }}">
</span> {{ '+' if cur_h >= std_h else '' }}{{ (cur_h - std_h) | round(1) }}
{%- endif %} </span>
{%- endif %}
</div>
</div> </div>
</div> </div>
<div class="hstat"> <div class="hstat" onclick="openHealth()" title="点击查看健康图表详情">
<div class="hs-label">当前体重</div> <div class="hs-label">体重</div>
<div class="hs-val" style="color:#f59e0b">{{ cur_w if cur_w is not none else '—' }}<em>kg</em></div> <div class="hs-main">
<div class="hs-std">标准 {{ std_w }} kg <div class="hs-val" style="color:#f59e0b">{{ cur_w if cur_w is not none else '—' }}<em>kg</em></div>
{%- if cur_w is not none %} <div class="hs-std">标准 {{ std_w }} kg
<span class="hs-diff {{ 'up' if cur_w >= std_w else 'down' }}"> {%- if cur_w is not none %}
{{ '+' if cur_w >= std_w else '' }}{{ (cur_w - std_w) | round(1) }} <span class="hs-diff {{ 'up' if cur_w >= std_w else 'down' }}">
</span> {{ '+' if cur_w >= std_w else '' }}{{ (cur_w - std_w) | round(1) }}
{%- endif %} </span>
{%- endif %}
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="card-foot"> <p class="sub tiny">按中国儿童生长标准(WS/T 612-2018{{ age }} 岁{{ '男孩' if (child.gender or 'M') == 'M' else '女孩' }}同龄中位数估算,仅供参考。</p>
<button type="button" class="mini" onclick="openHealth()">📈 图表详情</button>
<p class="sub tiny">按 {{ age }} 岁{{ '男孩' if (child.gender or 'M') == 'M' else '女孩' }}的生长参考中位数估算,仅供参考。</p>
</div>
</section> </section>
</div> </div>
<div class="grid-2"> <div class="grid-2">
<section class="card"> <section class="card">
<h2>📚 学科进度</h2> <h2>📚 学科进度</h2>
<p class="sub">按任务顺序显示:完成 3 个 · 进行中 1 个 · 待完成 3 个。点一下就完成,下一个自动接上。</p>
<div class="carousel" id="subjCarousel" data-interval="{{ carousel_sec }}"> <div class="carousel" id="subjCarousel" data-interval="{{ carousel_sec }}">
{% for grp in slides %} {% for grp in slides %}
<div class="slide" style="display:{% if loop.first %}block{% else %}none{% endif %}"> <div class="slide" style="display:{% if loop.first %}block{% else %}none{% endif %}">
@@ -113,7 +111,7 @@
<span class="subject-icon">{{ s.icon }}</span> <span class="subject-icon">{{ s.icon }}</span>
<span class="subject-name">{{ s.name }}</span> <span class="subject-name">{{ s.name }}</span>
<span class="subject-pct">{{ s.done }}/{{ s.total }} · {{ s.progress }}%</span> <span class="subject-pct">{{ s.done }}/{{ s.total }} · {{ s.progress }}%</span>
<button type="button" class="mini detail-btn" onclick="openDetail({{ s.id }})">详情</button> <button type="button" class="link-btn" onclick="openDetail({{ s.id }})">点击查看详情 </button>
</div> </div>
{{ bar_svg(s.progress, s.color) | safe }} {{ bar_svg(s.progress, s.color) | safe }}
<ul class="tasklist"> <ul class="tasklist">
@@ -162,9 +160,8 @@
<section class="card"> <section class="card">
<h2>📖 我的书架墙 <h2>📖 我的书架墙
<button type="button" class="mini detail-btn" onclick="openShelf()">详情</button> <button type="button" class="link-btn" onclick="openShelf()">点击查看详情 </button>
</h2> </h2>
<p class="sub">共读完 {{ shelf|length }} 本 · 上排最近读完,下排随机回顾</p>
{% if shelf %} {% if shelf %}
<div class="shelf-mini"> <div class="shelf-mini">
<div class="shelf-row-label">🆕 最近读完</div> <div class="shelf-row-label">🆕 最近读完</div>
@@ -294,17 +291,20 @@
if (!root) return { next: function () {}, prev: function () {} }; if (!root) return { next: function () {}, prev: function () {} };
var slides = Array.prototype.slice.call(root.querySelectorAll(slideSel)); var slides = Array.prototype.slice.call(root.querySelectorAll(slideSel));
var iv = (parseInt(root.dataset.interval, 10) || 8) * 1000; var iv = (parseInt(root.dataset.interval, 10) || 8) * 1000;
var LS = 'carIdx_' + rootId;
var idx = 0, timer = null; var idx = 0, timer = null;
try { var sv = parseInt(localStorage.getItem(LS), 10); if (!isNaN(sv)) idx = sv; } catch (e) {}
var info = infoId ? document.getElementById(infoId) : null; var info = infoId ? document.getElementById(infoId) : null;
function show(i) { function show(i) {
if (!slides.length) return; if (!slides.length) return;
idx = (i + slides.length) % slides.length; idx = (i + slides.length) % slides.length;
slides.forEach(function (el, k) { el.style.display = (k === idx) ? display : 'none'; }); slides.forEach(function (el, k) { el.style.display = (k === idx) ? display : 'none'; });
if (info) info.textContent = (idx + 1) + ' / ' + slides.length + (unit || ''); if (info) info.textContent = (idx + 1) + ' / ' + slides.length + (unit || '');
try { localStorage.setItem(LS, idx); } catch (e) {}
clearTimeout(timer); clearTimeout(timer);
if (slides.length > 1) timer = setTimeout(function () { show(idx + 1); }, iv); if (slides.length > 1) timer = setTimeout(function () { show(idx + 1); }, iv);
} }
show(0); show(idx);
return { next: function () { show(idx + 1); }, prev: function () { show(idx - 1); } }; return { next: function () { show(idx + 1); }, prev: function () { show(idx - 1); } };
} }
@@ -314,6 +314,14 @@
function carouselNext() { subjCar.next(); } function carouselNext() { subjCar.next(); }
function carouselPrev() { subjCar.prev(); } function carouselPrev() { subjCar.prev(); }
/* ---------- 学期 / 假期 日历:仪表盘只显示当前所在时段 ---------- */
/* ---------- 书架墙封面点击打开详情 ---------- */
document.querySelectorAll('.shelf-mini .book-cover').forEach(function (el) {
el.style.cursor = 'pointer';
el.addEventListener('click', openShelf);
});
/* ---------- 弹窗 ---------- */ /* ---------- 弹窗 ---------- */
function openDetail(id) { function openDetail(id) {
var src = document.getElementById('detail-' + id); var src = document.getElementById('detail-' + id);