diff --git a/README.md b/README.md index f75e0f0..981c082 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,12 @@ ## 特性 -- 📅 **学期日历小图**:进度环旁的点状日历,每点代表一天,按颜色区分打卡密度(越深越多 / 蓝=周末 / 灰=未打卡 / 圆环=今天),一眼看学期投入节奏。起止日期在家长后台「孩子信息」里设定。 +- 📅 **学期 / 假期双日历**:进度环旁两张点状日历——「学期日历」与「假期日历」,每点代表一天,按颜色区分打卡密度(越深越多 / 蓝=周末 / 灰=未打卡 / 圆环=今天)。起止日期(学期起止、假期起止)都在家长后台「孩子信息」里设定。 - 🎡 **各学科进度一览**:一行小进度环,每屏 3 个学科自动轮播;右侧有半透明「›」箭头可手动切下一屏。 - 📚 **学科进度(单科轮播)**:整年课表(学科 → 单元/章节)逐科展示;每科按任务顺序显示「已完成 3 · 进行中 1 · 待完成 3」,点一下即完成、下一任务自动接上。点「详情」弹窗看全部单元与目标日。 - 📖 **书架墙**:已读完的书以封面墙展示——上排 3 本最近读完、下排 3 本随机回顾;点「详情」弹窗可按书名/作者/分类搜索、分类筛选、排序。无封面时显示彩色书脊,封面完成日期标在右下角。封面支持本地上传(自动标准化为统一 2:3 并生成缩略图)或填图片 URL。 -- 💗 **健康综合**:主视图只显示「当前身高 / 当前体重」及同龄标准参考;点「📈 图表详情」弹窗看最近两年的身高(绿)/体重(橙)双线趋势。家长后台可补录任意历史日期的健康数据。 -- 🚀 **浮动快捷打卡**:仪表盘右侧常驻三个竖向按钮——跳绳打卡、仰卧起坐打卡、读书打卡,点一下即记录当天打卡。 +- 💗 **健康综合**:主视图只显示「身高 / 体重」当前值与同龄标准参考(标签置于数字左侧);点右上角「📈 图表详情」弹窗看最近两年的身高(绿)/体重(橙)双线趋势。家长后台可补录任意历史日期的健康数据。 +- 🚀 **浮动快捷打卡**:仪表盘右下角常驻竖向按钮(默认跳绳 / 仰卧起坐 / 读书),点一下即记录当天打卡;**类型可在家长后台「浮动打卡项」里新增、改名、删除**,仪表盘即时更新。 - 🏅 **轻量徽章**:由任务完成数、读书、运动等实时派生(如「迈出第一步」「任务小达人」「单科全通关」「学年进度过半」等),随进展自动获得;仪表盘顶部轮播已得徽章,点开可看全部(含未解锁)与获得时间。 - 🎨 **主题**:家长后台「孩子信息」里设定默认主题(浅色 / 暗色 / 糖果 / 海洋),服务端直接应用,刷新即生效。 - 🔒 **双角色**:孩子无 PIN 直进仪表盘;家长后台 `/admin` 用密码管理课表、健康记录与数据(含一键导出 JSON)。 @@ -48,9 +48,11 @@ python app.py ## 家长后台数据管理 - **重置全部数据**:清空所有进度 / 打卡 / 健康记录,但保留学科、单元、书目结构(方便从零开始新一轮记录)。 -- **开启新学期**:把单元进度重置为「未开始」、清空打卡,并把学期起设为今天;适合换学期时一键归零。 +- **浮动打卡项(可增删改)**:新增 / 改名 / 删除右下角常驻的打卡按钮;删除时会一并清掉该类型的打卡记录。 -> 徽章由进度实时派生,重置或开启新学期后会随孩子的新进展自动重新累积,无需手动发徽章。 +> 徽章由进度实时派生,重置后会随孩子的新进展自动重新累积,无需手动发徽章。 + +界面说明:仪表盘无顶部导航栏;时钟居中并显示「距结束还有 XX 天」(按后台设定的学期止计算)。 ## 数据与备份 diff --git a/app.py b/app.py index 7566ffd..d2cea7e 100644 --- a/app.py +++ b/app.py @@ -45,26 +45,32 @@ BOOK_CATEGORIES = [ "历史人文", "漫画", "英文读物", "工具书", ] -# 浮动快捷打卡项 -CHECKIN_KINDS = { - "rope": {"icon": "🪢", "name": "跳绳打卡", "done": "已跳绳"}, - "situp": {"icon": "🤸", "name": "仰卧起坐打卡", "done": "已做仰卧起坐"}, - "read": {"icon": "📖", "name": "读书打卡", "done": "已读书"}, -} +# 浮动快捷打卡项(默认种子,存进 checkin_kind 表,可在后台增删改) +CHECKIN_SEED = [ + {"key": "rope", "icon": "🪢", "name": "跳绳打卡", "done": "已跳绳"}, + {"key": "situp", "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 = { "M": { - 3: (96.1, 14.3), 4: (103.3, 16.3), 5: (110.0, 18.3), 6: (116.0, 20.5), - 7: (121.7, 22.9), 8: (127.3, 25.4), 9: (132.6, 28.1), 10: (137.8, 31.2), - 11: (143.1, 34.7), 12: (149.1, 38.7), 13: (156.0, 43.4), 14: (163.2, 48.8), - 15: (169.0, 54.0), 16: (172.9, 58.1), 17: (175.2, 61.5), 18: (176.5, 64.0), + 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.1, 13.9), 4: (102.7, 16.0), 5: (109.4, 17.9), 6: (115.1, 19.9), - 7: (120.8, 22.4), 8: (126.6, 25.0), 9: (132.5, 28.2), 10: (138.6, 31.9), - 11: (145.0, 36.1), 12: (151.2, 40.8), 13: (156.4, 45.0), 14: (159.8, 47.6), - 15: (161.7, 49.4), 16: (162.5, 50.8), 17: (162.9, 51.8), 18: (163.1, 52.5), + 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), }, } @@ -173,6 +179,14 @@ CREATE TABLE IF NOT EXISTS checkin ( 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 = [ @@ -229,11 +243,17 @@ def migrate_db(): 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 学期起止 + 默认主题 + 生日/性别 + 轮播间隔 + # 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: @@ -253,10 +273,28 @@ def migrate_db(): 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 @@ -265,21 +303,17 @@ def seed_if_empty(): return today = datetime.date.today() y0 = today.year - sy = f"{y0}-{y0 + 1}" term_start = datetime.date(y0, 9, 1).isoformat() term_end = datetime.date(y0 + 1, 8, 31).isoformat() birth = (today - datetime.timedelta(days=int(365.25 * 8.5))).isoformat() cur = db.execute( - "INSERT INTO child (name, grade_label, school_year, created_at, term_start, term_end, " - "default_theme, birth_date, gender, carousel_sec, ring_sec) VALUES (?,?,?,?,?,?,?,?,?,?,?)", - ("小明", "小学三年级", sy, today.isoformat(), term_start, term_end, + "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 - db.execute( - "INSERT INTO goal (child_id, book_target, week_exercise_min) VALUES (?,?,?)", - (child_id, 30, 120), - ) + _seed_checkin_kinds(db) subj_ids = {} for i, (name, color, icon) in enumerate(DEFAULT_SUBJECTS): sid = db.execute( @@ -850,7 +884,6 @@ def dashboard(): "done": sum(1 for u in units if u["status"] == 2), }) - goal = db.execute("SELECT * FROM goal WHERE child_id=?", (cid,)).fetchone() books = db.execute("SELECT * FROM book WHERE child_id=? ORDER BY id DESC", (cid,)).fetchall() done_books = sum(1 for b in books if b["status"] == "done") @@ -886,29 +919,50 @@ def dashboard(): for r in db.execute("SELECT date FROM checkin WHERE child_id=?", (cid,)).fetchall(): ac[r["date"]] += 1 - # 学期起止:后台可设定 term_start / term_end;否则按学年 9/1 开学推算 + # 日历(学期 / 假期 二选一展示):后台可分别设定起止;仪表盘只显示当前所在时段。 today = datetime.date.today() try: - y0 = int(child["school_year"].split("-")[0]) + y0 = int(str(child["school_year"]).split("-")[0]) fallback_start = datetime.date(y0, 9, 1) except Exception: fallback_start = today - datetime.timedelta(days=120) - ts_raw = child["term_start"] - term_start = datetime.date.fromisoformat(ts_raw) if ts_raw else fallback_start - # 结束:若是过去日期用设定值,否则显示到今天 - term_end = today - te_raw = child["term_end"] - if te_raw: + + def _parse(d): try: - te = datetime.date.fromisoformat(te_raw) - if te < today: - term_end = te + return datetime.date.fromisoformat(d) if d else None except Exception: - pass - # 展示起点:学期还没开始(起点在未来)时回退到近 150 天,避免空白 - disp_start = term_start if term_start <= today else (today - datetime.timedelta(days=150)) - term_start_str = disp_start.strftime("%Y.%m.%d") - term_end_str = term_end.strftime("%Y.%m.%d") + 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 @@ -954,8 +1008,10 @@ def dashboard(): # 今日快捷打卡状态 done_kinds = {r["kind"] for r in db.execute( "SELECT kind FROM checkin WHERE child_id=? AND date=?", (cid, today.isoformat())).fetchall()} - checkins = [{"key": k, "icon": v["icon"], "name": v["name"], "done_name": v["done"], - "done": k in done_kinds} for k, v in CHECKIN_KINDS.items()] + 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", @@ -963,12 +1019,13 @@ def dashboard(): ring_svg=ring_svg, bar_svg=bar_svg, mini_ring_svg=mini_ring_svg, term_calendar_svg=term_calendar_svg, cover_bg=cover_bg, health_chart_svg=health_chart_svg, - goal=goal, books=books, done_books=done_books, + books=books, done_books=done_books, hpoints=hpoints, cur_h=cur_h, cur_w=cur_w, std_h=std_h, std_w=std_w, age=round(age, 1), badges=badges, got_badges=got_badges, - active_counts=ac, term_start=disp_start, term_end=term_end, - term_start_str=term_start_str, term_end_str=term_end_str, + 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, @@ -999,10 +1056,10 @@ def unit_toggle(uid): @app.post("/checkin/") def checkin_toggle(kind): - """浮动快捷打卡:跳绳 / 仰卧起坐 / 读书。已打卡再点一次可撤销。""" - if kind not in CHECKIN_KINDS: - abort(404) + """浮动快捷打卡:类型来自 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) @@ -1207,11 +1264,13 @@ def admin(): subjects.append({"id": s["id"], "name": s["name"], "color": s["color"], "icon": s["icon"], "units": units}) books = db.execute("SELECT * FROM book WHERE child_id=? ORDER BY id DESC", (cid,)).fetchall() if cid else [] - goal = db.execute("SELECT * FROM goal WHERE child_id=?", (cid,)).fetchone() if cid else None + 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, goal=goal, + 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()) @@ -1226,12 +1285,13 @@ def admin_child_update(): 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"), - request.form.get("term_start") or None, - request.form.get("term_end") or None, + term_start, term_end, request.form.get("default_theme", "light"), request.form.get("birth_date") or None, gender, @@ -1245,33 +1305,83 @@ def admin_child_update(): @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") - db.execute("DELETE FROM health_log") - db.execute("DELETE FROM exercise_log") - db.execute("DELETE FROM reading_log") + # 书架:保留时连同阅读记录一起留着 + 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/term/new") +@app.post("/admin/checkin/add") @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/") +@admin_required +def admin_checkin_update(kid): + """修改一个浮动打卡项(名称/图标/完成态文案)。""" + db = get_db() + db.execute( + "UPDATE checkin_kind SET icon=?, name=?, done_name=? WHERE id=?", + (request.form.get("icon", "✅"), request.form.get("name", ""), + request.form.get("done_name", "已完成"), kid)) + db.commit() + return redirect(url_for("admin")) + + +@app.post("/admin/checkin/delete/") +@admin_required +def admin_checkin_delete(kid): + """删除一个浮动打卡项(同时清掉该类型的打卡记录)。""" + db = get_db() + k = db.execute("SELECT key FROM checkin_kind WHERE id=?", (kid,)).fetchone() + if k: + db.execute("DELETE FROM checkin WHERE kind=?", (k["key"],)) + db.execute("DELETE FROM checkin_kind WHERE id=?", (kid,)) + db.commit() + return redirect(url_for("admin")) + + +@app.post("/admin/checkin/backfill") +@admin_required +def admin_checkin_backfill(): + """为指定历史日期补记一次打卡(按 child_id+date+kind 唯一,防重复)。""" db = get_db() - today = datetime.date.today().isoformat() cid = db.execute("SELECT id FROM child LIMIT 1").fetchone() if cid: - db.execute("UPDATE child SET term_start=? WHERE id=?", (today, cid["id"])) - db.execute("UPDATE unit SET status=0, completed_at=NULL") - for s in db.execute("SELECT id FROM subject").fetchall(): - normalize_units(db, s["id"]) - db.execute("DELETE FROM checkin") - db.commit() + 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")) @@ -1327,19 +1437,6 @@ def admin_unit_delete(uid): return redirect(url_for("admin")) -@app.post("/admin/goal/update") -@admin_required -def admin_goal_update(): - db = get_db() - cid = db.execute("SELECT id FROM child LIMIT 1").fetchone()["id"] - db.execute( - "UPDATE goal SET book_target=?, week_exercise_min=? WHERE child_id=?", - (request.form.get("book_target", type=int, default=30), - request.form.get("week_exercise_min", type=int, default=120), cid)) - db.commit() - return redirect(url_for("admin")) - - @app.route("/admin/export") @admin_required def admin_export(): diff --git a/static/style.css b/static/style.css index 31381ce..602c464 100644 --- a/static/style.css +++ b/static/style.css @@ -184,8 +184,9 @@ button:hover { filter: brightness(.95); } .hero-right { display: flex; align-items: center; gap: 22px; flex-wrap: wrap; } .hero-ring { flex: 0 0 auto; } .termcal { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; } -.termcal-title { font-weight: 700; font-size: 15px; } +.termcal-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-inline { color: var(--muted); font-size: 12px; font-weight: 400; } .termcal-scroll { overflow-x: auto; max-width: 100%; } .termcal-svg { display: block; } .termcal-legend { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 8px; font-size: 12px; color: var(--muted); } @@ -213,10 +214,10 @@ button:hover { filter: brightness(.95); } } .modal-mask.show { display: flex; } .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); } -.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-body { padding: 16px 18px; } @@ -265,6 +266,8 @@ button:hover { filter: brightness(.95); } .slide { display: flex; gap: 16px; } .slide .subject { flex: 1; } .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; } @media (max-width: 760px) { .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); } /* ===== 学科进度一览(轮播小环) ===== */ -.ringcar { min-height: 150px; } -.ring-slide { display: flex; gap: 18px; justify-content: space-around; flex-wrap: wrap; } +.ringcar { min-height: 150px; display: flex; align-items: center; } +.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; } /* ===== 健康综合 ===== */ @@ -327,9 +330,11 @@ button:hover { filter: brightness(.95); } .lgline + .lgline { margin-left: 12px; } .sub.tiny { font-size: 12px; margin-top: 8px; } .hstats { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 12px; } -.hstat { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 12px; text-align: center; } -.hs-label { font-size: 12px; color: var(--muted); } -.hs-val { font-size: 26px; font-weight: 800; line-height: 1.1; margin: 4px 0; } +.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; } +.hstat:hover { background: var(--card); border-color: var(--blue); } +.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-std { font-size: 12px; color: var(--muted); } .hs-diff { margin-left: 4px; font-weight: 700; } @@ -370,17 +375,18 @@ button:hover { filter: brightness(.95); } /* ===== 宽弹窗(书架详情) ===== */ .modal-wide { width: min(860px, 100%); } -/* ===== hero 当前日期时钟:去方框 ===== */ +/* ===== hero 当前日期时钟:去方框 + 居中 + 距结束小字 ===== */ .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-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-left { font-size: 12px; color: var(--muted); margin-top: 4px; } /* ===== 进度一览 / 健康综合:压缩到约 1/3 高度 ===== */ .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-wrap { position: relative; padding-right: 30px; } .ring-next { @@ -412,3 +418,29 @@ button:hover { filter: brightness(.95); } .bc-desc { font-size: 12px; color: var(--muted); margin-top: 4px; } .bc-state { font-size: 12px; margin-top: 8px; color: #16a34a; } .badge-cell.locked .bc-state { color: var(--muted); } + +/* ===== 弹窗:方框背景(主题自适应卡片) ===== */ +.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; } diff --git a/templates/admin.html b/templates/admin.html index a2a5d28..ca1d6b6 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -19,8 +19,8 @@ - - + +