UI 打磨与功能调整:中国生长标准、日历合并、弹窗去框、轮播记忆位置、小环居中、后台补卡与重置保留
This commit is contained in:
@@ -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 天」(按后台设定的学期止计算)。
|
||||||
|
|
||||||
## 数据与备份
|
## 数据与备份
|
||||||
|
|
||||||
|
|||||||
@@ -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,32 +1305,82 @@ 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")
|
||||||
|
# 书架:保留时连同阅读记录一起留着
|
||||||
|
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 health_log")
|
||||||
db.execute("DELETE FROM exercise_log")
|
db.execute("DELETE FROM exercise_log")
|
||||||
db.execute("DELETE FROM reading_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 (?,?,?,?)",
|
||||||
|
(cid["id"], d, kind, datetime.date.today().isoformat()))
|
||||||
db.commit()
|
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
@@ -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; }
|
||||||
|
|||||||
+55
-25
@@ -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('确定重置数据?未勾选保留的项将被清空,此操作不可撤销!')">
|
||||||
<button class="mini danger">🗑️ 重置全部数据</button>
|
<div class="row" style="gap:18px;flex-wrap:wrap;align-items:flex-start">
|
||||||
</form>
|
<label class="chk"><input type="checkbox" name="keep_health" value="1"> 保留健康记录(身高/体重/睡眠/运动)</label>
|
||||||
<form method="post" action="{{ url_for('admin_new_term') }}"
|
<label class="chk"><input type="checkbox" name="keep_bookshelf" value="1"> 保留书架(书目与阅读记录)</label>
|
||||||
onsubmit="return confirm('开启新学期:进度重置为未开始、清空打卡,并把学期起设为今天。确定?')">
|
|
||||||
<button class="mini">🎒 开启新学期</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row" style="margin-top:12px">
|
||||||
|
<button class="mini danger">🗑️ 重置数据</button>
|
||||||
|
</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>
|
||||||
|
{% 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,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>
|
||||||
|
|||||||
+27
-19
@@ -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,10 +67,10 @@
|
|||||||
</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-main">
|
||||||
<div class="hs-val" style="color:#10b981">{{ cur_h if cur_h is not none else '—' }}<em>cm</em></div>
|
<div class="hs-val" style="color:#10b981">{{ cur_h if cur_h is not none else '—' }}<em>cm</em></div>
|
||||||
<div class="hs-std">标准 {{ std_h }} cm
|
<div class="hs-std">标准 {{ std_h }} cm
|
||||||
{%- if cur_h is not none %}
|
{%- if cur_h is not none %}
|
||||||
@@ -81,8 +80,10 @@
|
|||||||
{%- endif %}
|
{%- endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hstat">
|
</div>
|
||||||
<div class="hs-label">当前体重</div>
|
<div class="hstat" onclick="openHealth()" title="点击查看健康图表详情">
|
||||||
|
<div class="hs-label">体重</div>
|
||||||
|
<div class="hs-main">
|
||||||
<div class="hs-val" style="color:#f59e0b">{{ cur_w if cur_w is not none else '—' }}<em>kg</em></div>
|
<div class="hs-val" style="color:#f59e0b">{{ cur_w if cur_w is not none else '—' }}<em>kg</em></div>
|
||||||
<div class="hs-std">标准 {{ std_w }} kg
|
<div class="hs-std">标准 {{ std_w }} kg
|
||||||
{%- if cur_w is not none %}
|
{%- if cur_w is not none %}
|
||||||
@@ -93,17 +94,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-foot">
|
|
||||||
<button type="button" class="mini" onclick="openHealth()">📈 图表详情</button>
|
|
||||||
<p class="sub tiny">按 {{ age }} 岁{{ '男孩' if (child.gender or 'M') == 'M' else '女孩' }}的生长参考中位数估算,仅供参考。</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="sub tiny">按中国儿童生长标准(WS/T 612-2018){{ age }} 岁{{ '男孩' if (child.gender or 'M') == 'M' else '女孩' }}同龄中位数估算,仅供参考。</p>
|
||||||
</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);
|
||||||
|
|||||||
Reference in New Issue
Block a user