星火纪元 个人成长追踪 v1.0
- Flask + SQLite 自托管,单用户无 PIN - 仪表盘:学科进度环轮播、健康双线图、任务接龙、书架墙、浮动打卡 - 后台:孩子信息、学科/单元课表、健康历史录入、数据管理(重置/新学期) - 徽章由进度实时派生;主题由后台设置
This commit is contained in:
+18
@@ -0,0 +1,18 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Virtual environment
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Local data / database
|
||||||
|
data/
|
||||||
|
|
||||||
|
# WorkBuddy internal
|
||||||
|
.workbuddy/
|
||||||
|
|
||||||
|
# Editor / OS
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# 星火纪元 · 儿童个人成长系统(自托管)
|
||||||
|
|
||||||
|
给自家孩子用的轻量成长记录 Web 服务:追踪**学科进度**、**读书**、**运动打卡**、**健康**并颁发轻量徽章。
|
||||||
|
纯本地、离线可用,数据存在你自己的 SQLite 文件里,不依赖任何云、不用 Docker。
|
||||||
|
|
||||||
|
## 特性
|
||||||
|
|
||||||
|
- 📅 **学期日历小图**:进度环旁的点状日历,每点代表一天,按颜色区分打卡密度(越深越多 / 蓝=周末 / 灰=未打卡 / 圆环=今天),一眼看学期投入节奏。起止日期在家长后台「孩子信息」里设定。
|
||||||
|
- 🎡 **各学科进度一览**:一行小进度环,每屏 3 个学科自动轮播;右侧有半透明「›」箭头可手动切下一屏。
|
||||||
|
- 📚 **学科进度(单科轮播)**:整年课表(学科 → 单元/章节)逐科展示;每科按任务顺序显示「已完成 3 · 进行中 1 · 待完成 3」,点一下即完成、下一任务自动接上。点「详情」弹窗看全部单元与目标日。
|
||||||
|
- 📖 **书架墙**:已读完的书以封面墙展示——上排 3 本最近读完、下排 3 本随机回顾;点「详情」弹窗可按书名/作者/分类搜索、分类筛选、排序。无封面时显示彩色书脊,封面完成日期标在右下角。封面支持本地上传(自动标准化为统一 2:3 并生成缩略图)或填图片 URL。
|
||||||
|
- 💗 **健康综合**:主视图只显示「当前身高 / 当前体重」及同龄标准参考;点「📈 图表详情」弹窗看最近两年的身高(绿)/体重(橙)双线趋势。家长后台可补录任意历史日期的健康数据。
|
||||||
|
- 🚀 **浮动快捷打卡**:仪表盘右侧常驻三个竖向按钮——跳绳打卡、仰卧起坐打卡、读书打卡,点一下即记录当天打卡。
|
||||||
|
- 🏅 **轻量徽章**:由任务完成数、读书、运动等实时派生(如「迈出第一步」「任务小达人」「单科全通关」「学年进度过半」等),随进展自动获得;仪表盘顶部轮播已得徽章,点开可看全部(含未解锁)与获得时间。
|
||||||
|
- 🎨 **主题**:家长后台「孩子信息」里设定默认主题(浅色 / 暗色 / 糖果 / 海洋),服务端直接应用,刷新即生效。
|
||||||
|
- 🔒 **双角色**:孩子无 PIN 直进仪表盘;家长后台 `/admin` 用密码管理课表、健康记录与数据(含一键导出 JSON)。
|
||||||
|
|
||||||
|
## 运行(不用 Docker)
|
||||||
|
|
||||||
|
需要 Python 3.10+。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tracker
|
||||||
|
python -m venv venv
|
||||||
|
venv\Scripts\activate # Windows
|
||||||
|
# macOS/Linux: source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
启动后打开:
|
||||||
|
- 仪表盘: http://127.0.0.1:5000/
|
||||||
|
- 家长后台: http://127.0.0.1:5000/admin (默认账号 `admin` / 密码 `admin123`)
|
||||||
|
|
||||||
|
首次启动会自动建库并写入一份示例数据,方便直接看效果。
|
||||||
|
|
||||||
|
## 配置(环境变量,可选)
|
||||||
|
|
||||||
|
| 变量 | 默认 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `ADMIN_USER` | admin | 家长后台用户名 |
|
||||||
|
| `ADMIN_PASSWORD` | admin123 | 家长后台密码 |
|
||||||
|
| `PORT` | 5000 | 服务端口 |
|
||||||
|
| `SECRET_KEY` | change-me... | Flask 会话密钥 |
|
||||||
|
|
||||||
|
例:`ADMIN_PASSWORD=你的强密码 PORT=8080 python app.py`
|
||||||
|
|
||||||
|
## 家长后台数据管理
|
||||||
|
|
||||||
|
- **重置全部数据**:清空所有进度 / 打卡 / 健康记录,但保留学科、单元、书目结构(方便从零开始新一轮记录)。
|
||||||
|
- **开启新学期**:把单元进度重置为「未开始」、清空打卡,并把学期起设为今天;适合换学期时一键归零。
|
||||||
|
|
||||||
|
> 徽章由进度实时派生,重置或开启新学期后会随孩子的新进展自动重新累积,无需手动发徽章。
|
||||||
|
|
||||||
|
## 数据与备份
|
||||||
|
|
||||||
|
- 数据库文件:`data/tracker.db`(单文件,复制即备份)。
|
||||||
|
- 上传的本地封面:`static/covers/`(备份时建议连同此目录一起复制)。
|
||||||
|
- 家长后台「导出全部数据」可下载 `tracker-export.json`。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
tracker/
|
||||||
|
├── app.py # Flask 应用 + 数据库 + 纯 SVG 图表逻辑
|
||||||
|
├── requirements.txt
|
||||||
|
├── data/tracker.db # 自动生成
|
||||||
|
├── templates/
|
||||||
|
│ ├── base.html # 布局 + 主题类挂载
|
||||||
|
│ ├── dashboard.html # 孩子仪表盘
|
||||||
|
│ ├── admin.html # 家长后台
|
||||||
|
│ ├── login.html
|
||||||
|
│ └── _book_cover.html # 书架封面局部(主墙与详情弹窗共用)
|
||||||
|
└── static/
|
||||||
|
├── style.css
|
||||||
|
└── covers/ # 上传的本地封面
|
||||||
|
```
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
- 图表用纯 SVG 生成,无任何外部 CDN 依赖,断网也能用。
|
||||||
|
- 按需求定制:单孩子、无 PIN、Python、不用 Docker、小学阶段、手动录课表、轻量徽章。
|
||||||
|
- 多孩子、教材章节模板、更多健康指标可在现有数据模型上平滑扩展。
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
Flask>=3.0
|
||||||
|
Pillow>=10.0
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
@@ -0,0 +1,414 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #f5f7fb;
|
||||||
|
--card: #ffffff;
|
||||||
|
--ink: #111827;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--line: #e5e7eb;
|
||||||
|
--blue: #3b82f6;
|
||||||
|
--panel: #fafbff;
|
||||||
|
--panel2: #f7efe6;
|
||||||
|
--track: #e5e7eb;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 主题风格 ===== */
|
||||||
|
html.theme-dark {
|
||||||
|
--bg: #0f172a; --card: #1e293b; --ink: #e2e8f0; --muted: #94a3b8;
|
||||||
|
--line: #334155; --blue: #60a5fa; --panel: #172033; --panel2: #16233a; --track: #334155;
|
||||||
|
}
|
||||||
|
html.theme-candy {
|
||||||
|
--bg: #fff1f6; --card: #ffffff; --ink: #831843; --muted: #db2777;
|
||||||
|
--line: #fbcfe8; --blue: #ec4899; --panel: #fff6fb; --panel2: #ffe9f3; --track: #fbcfe8;
|
||||||
|
}
|
||||||
|
html.theme-ocean {
|
||||||
|
--bg: #eef6ff; --card: #ffffff; --ink: #0c4a6e; --muted: #0369a1;
|
||||||
|
--line: #bae6fd; --blue: #0ea5e9; --panel: #f0f9ff; --panel2: #e0f2fe; --track: #bae6fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SVG 环形进度(适配主题) */
|
||||||
|
.ring-track { stroke: var(--track); }
|
||||||
|
.ring-pct { fill: var(--ink); }
|
||||||
|
.ring-label { fill: var(--muted); }
|
||||||
|
|
||||||
|
/* 学期日历配色(CSS 类,支持暗色) */
|
||||||
|
.tc { transition: fill .2s; }
|
||||||
|
.tc-act3 { fill: #15803d; }
|
||||||
|
.tc-act2 { fill: #22c55e; }
|
||||||
|
.tc-act1 { fill: #86efac; }
|
||||||
|
.tc-week { fill: #dbeafe; }
|
||||||
|
.tc-none { fill: #eef2f7; }
|
||||||
|
.tc-today { stroke: #111827; stroke-width: 2; }
|
||||||
|
html.theme-dark .tc-week { fill: #1e3a5f; }
|
||||||
|
html.theme-dark .tc-none { fill: #243049; }
|
||||||
|
html.theme-dark .tc-today { stroke: #e2e8f0; }
|
||||||
|
html.theme-candy .tc-week { fill: #fbcfe8; }
|
||||||
|
html.theme-candy .tc-none { fill: #fde7f1; }
|
||||||
|
html.theme-ocean .tc-week { fill: #bae6fd; }
|
||||||
|
html.theme-ocean .tc-none { fill: #d6efff; }
|
||||||
|
|
||||||
|
/* 图例色块 */
|
||||||
|
.lg { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 4px; vertical-align: -1px; }
|
||||||
|
.lg.tc-act3 { background: #15803d; }
|
||||||
|
.lg.tc-act2 { background: #22c55e; }
|
||||||
|
.lg.tc-act1 { background: #86efac; }
|
||||||
|
.lg.tc-week { background: #dbeafe; }
|
||||||
|
.lg.tc-none { background: #eef2f7; }
|
||||||
|
.lg.tc-today { background: #fff; border: 2px solid #111827; }
|
||||||
|
html.theme-dark .lg.tc-week { background: #1e3a5f; }
|
||||||
|
html.theme-dark .lg.tc-none { background: #243049; }
|
||||||
|
html.theme-dark .lg.tc-today { border-color: #e2e8f0; }
|
||||||
|
html.theme-candy .lg.tc-week { background: #fbcfe8; }
|
||||||
|
html.theme-candy .lg.tc-none { background: #fde7f1; }
|
||||||
|
html.theme-ocean .lg.tc-week { background: #bae6fd; }
|
||||||
|
html.theme-ocean .lg.tc-none { background: #d6efff; }
|
||||||
|
.topbar {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 12px 24px; background: #fff; border-bottom: 1px solid var(--line);
|
||||||
|
position: sticky; top: 0; z-index: 10;
|
||||||
|
}
|
||||||
|
.brand { font-weight: 700; font-size: 18px; }
|
||||||
|
.topbar nav a { margin-left: 18px; color: var(--muted); text-decoration: none; font-size: 14px; }
|
||||||
|
.topbar nav a:hover { color: var(--ink); }
|
||||||
|
.container { max-width: 1080px; margin: 0 auto; padding: 24px; }
|
||||||
|
|
||||||
|
.hero { display: flex; align-items: center; justify-content: space-between; gap: 24px; flex-wrap: wrap; }
|
||||||
|
.hero h1 { margin: 0; font-size: 26px; }
|
||||||
|
.sub { color: var(--muted); margin: 6px 0 0; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--card); border: 1px solid var(--line); border-radius: 16px;
|
||||||
|
padding: 20px; margin: 18px 0; box-shadow: 0 1px 3px rgba(0,0,0,.04);
|
||||||
|
}
|
||||||
|
.card h2 { margin: 0 0 16px; font-size: 18px; }
|
||||||
|
|
||||||
|
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||||||
|
@media (max-width: 760px) { .grid-2 { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
/* 学科 */
|
||||||
|
.subjects { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||||
|
@media (max-width: 760px) { .subjects { grid-template-columns: 1fr; } }
|
||||||
|
.subject { background: var(--panel); border-radius: 12px; padding: 14px; }
|
||||||
|
.subject-head { display: flex; align-items: center; gap: 8px; font-weight: 600; }
|
||||||
|
.subject-icon { font-size: 20px; }
|
||||||
|
.subject-name { flex: 1; }
|
||||||
|
.subject-pct { color: var(--muted); font-size: 14px; }
|
||||||
|
.units { list-style: none; padding: 0; margin: 10px 0 0; }
|
||||||
|
.unit { display: flex; align-items: center; gap: 8px; padding: 4px 0; }
|
||||||
|
.unit-form { margin: 0; }
|
||||||
|
.unit-btn {
|
||||||
|
background: none; border: none; cursor: pointer; font-size: 14px; text-align: left;
|
||||||
|
color: var(--ink); padding: 2px 0;
|
||||||
|
}
|
||||||
|
.unit-2 .unit-btn { color: #111; font-weight: 600; }
|
||||||
|
.unit-1 .unit-btn { color: #374151; }
|
||||||
|
.unit-0 .unit-btn { color: var(--muted); }
|
||||||
|
.unit-date { font-size: 12px; color: var(--muted); }
|
||||||
|
|
||||||
|
/* 进度条 */
|
||||||
|
.bar { position: relative; height: 18px; background: var(--track); border-radius: 10px; margin: 8px 0; overflow: hidden; }
|
||||||
|
.bar-fill { height: 100%; border-radius: 10px; transition: width .4s; }
|
||||||
|
.bar-pct { position: absolute; right: 8px; top: 0; font-size: 12px; line-height: 18px; color: #fff; mix-blend-mode: difference; }
|
||||||
|
|
||||||
|
/* 读书 */
|
||||||
|
.progress-line { margin: 4px 0 8px; }
|
||||||
|
.book-list { list-style: none; padding: 0; margin: 12px 0; }
|
||||||
|
.book { display: flex; align-items: center; justify-content: space-between; padding: 8px 0; border-bottom: 1px dashed var(--line); }
|
||||||
|
.tag { font-size: 12px; padding: 2px 8px; border-radius: 999px; margin-left: 6px; }
|
||||||
|
.tag-done { background: #dcfce7; color: #166534; }
|
||||||
|
.tag-reading { background: #dbeafe; color: #1e40af; }
|
||||||
|
.tag-want { background: #f3f4f6; color: #6b7280; }
|
||||||
|
.muted { color: var(--muted); font-size: 13px; }
|
||||||
|
|
||||||
|
/* 表单 */
|
||||||
|
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 10px; }
|
||||||
|
.row.wrap { flex-wrap: wrap; }
|
||||||
|
.row input, .row select {
|
||||||
|
padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; font-size: 14px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background: var(--blue); color: #fff; border: none; padding: 8px 14px;
|
||||||
|
border-radius: 8px; cursor: pointer; font-size: 14px;
|
||||||
|
}
|
||||||
|
button:hover { filter: brightness(.95); }
|
||||||
|
.inline { display: inline; margin: 0; }
|
||||||
|
.mini { padding: 4px 10px; font-size: 12px; background: #eef2ff; color: #3730a3; }
|
||||||
|
.mini.danger { background: #fee2e2; color: #b91c1c; }
|
||||||
|
|
||||||
|
/* 热力图 */
|
||||||
|
.heatmap { max-width: 100%; }
|
||||||
|
.spark-empty { color: var(--muted); font-size: 13px; }
|
||||||
|
|
||||||
|
/* 健康 */
|
||||||
|
.health-charts { display: flex; gap: 20px; flex-wrap: wrap; }
|
||||||
|
.chart-label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
||||||
|
|
||||||
|
/* 徽章 */
|
||||||
|
.badges { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||||
|
.badge { display: flex; align-items: center; gap: 10px; padding: 10px; border-radius: 12px; }
|
||||||
|
.badge-on { background: #fff7ed; border: 1px solid #fed7aa; }
|
||||||
|
.badge-off { background: #f3f4f6; opacity: .6; }
|
||||||
|
.badge-icon { font-size: 24px; }
|
||||||
|
.badge-name { flex: 1; font-size: 14px; }
|
||||||
|
.badge-state { font-size: 12px; }
|
||||||
|
.badge-on .badge-state { color: #c2410c; }
|
||||||
|
.badge-off .badge-state { color: var(--muted); }
|
||||||
|
|
||||||
|
/* 后台 */
|
||||||
|
.admin-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.admin-head h1 { margin: 0; flex: 1; }
|
||||||
|
.btn-outline { border: 1px solid var(--line); padding: 6px 12px; border-radius: 8px; text-decoration: none; color: var(--ink); font-size: 13px; }
|
||||||
|
.admin-subject { border: 1px solid var(--line); border-radius: 12px; padding: 12px; margin-top: 12px; }
|
||||||
|
.admin-subject-head { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.admin-units { list-style: none; padding: 0; margin: 10px 0; }
|
||||||
|
.admin-units li { padding: 4px 0; font-size: 14px; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.unit-state { font-size: 12px; padding: 1px 8px; border-radius: 999px; }
|
||||||
|
.unit-state.unit-2 { background: #dcfce7; color: #166534; }
|
||||||
|
.unit-state.unit-1 { background: #dbeafe; color: #1e40af; }
|
||||||
|
.unit-state.unit-0 { background: #f3f4f6; color: #6b7280; }
|
||||||
|
.tbl { width: 100%; border-collapse: collapse; }
|
||||||
|
.tbl th, .tbl td { text-align: left; padding: 8px; border-bottom: 1px solid var(--line); }
|
||||||
|
|
||||||
|
/* 登录 */
|
||||||
|
.login-box { max-width: 360px; margin: 60px auto; background: #fff; border: 1px solid var(--line); border-radius: 16px; padding: 28px; }
|
||||||
|
.login-form { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.login-form input { padding: 10px; border: 1px solid var(--line); border-radius: 8px; }
|
||||||
|
.error { color: #b91c1c; }
|
||||||
|
|
||||||
|
/* hero 右侧:进度环 + 学期日历 */
|
||||||
|
.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-range { color: var(--muted); font-size: 12px; margin: 2px 0 8px; }
|
||||||
|
.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); }
|
||||||
|
.termcal-legend i.lg { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 4px; vertical-align: -1px; }
|
||||||
|
.lg-act3 { background: #15803d; }
|
||||||
|
.lg-act1 { background: #86efac; }
|
||||||
|
.lg-week { background: #dbeafe; }
|
||||||
|
.lg-none { background: #eef2f7; }
|
||||||
|
.lg-today { background: #fff; border: 2px solid #111827; }
|
||||||
|
|
||||||
|
/* 学科单元点阵 */
|
||||||
|
.subject-pct { margin-left: auto; }
|
||||||
|
.detail-btn { margin-left: 10px; }
|
||||||
|
.dotstrip { display: flex; flex-wrap: wrap; gap: 4px; margin: 10px 0 4px; }
|
||||||
|
.dotform { margin: 0; line-height: 0; }
|
||||||
|
.dot { width: 16px; height: 16px; border-radius: 3px; border: 1px solid rgba(0,0,0,.06); cursor: pointer; padding: 0; transition: transform .1s; }
|
||||||
|
.dot:hover { transform: scale(1.18); }
|
||||||
|
.dot.d-done { box-shadow: inset 0 0 0 1px rgba(255,255,255,.35); }
|
||||||
|
.subject-detail .units { margin-top: 0; }
|
||||||
|
|
||||||
|
/* 详情弹窗 */
|
||||||
|
.modal-mask {
|
||||||
|
position: fixed; inset: 0; background: rgba(17,24,39,.45);
|
||||||
|
display: none; align-items: center; justify-content: center; z-index: 50; padding: 20px;
|
||||||
|
}
|
||||||
|
.modal-mask.show { display: flex; }
|
||||||
|
.modal {
|
||||||
|
background: #fff; 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 span { font-weight: 700; }
|
||||||
|
.modal-body { padding: 16px 18px; }
|
||||||
|
|
||||||
|
/* 书架墙 */
|
||||||
|
.shelf { display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-end; padding: 8px 4px 14px; border-bottom: 10px solid #b08968; background: linear-gradient(180deg, var(--panel) 0%, var(--panel2) 100%); border-radius: 8px; min-height: 60px; }
|
||||||
|
.book-cover { width: 110px; text-align: center; }
|
||||||
|
.book-cover img, .cover-ph {
|
||||||
|
width: 100px; height: 140px; object-fit: cover; border-radius: 4px 6px 6px 4px;
|
||||||
|
box-shadow: 2px 3px 8px rgba(0,0,0,.18); margin: 0 auto; display: block;
|
||||||
|
}
|
||||||
|
.cover-ph { display: flex; align-items: center; justify-content: center; padding: 10px; }
|
||||||
|
.cover-ph span { color: #1f2937; font-weight: 700; font-size: 14px; line-height: 1.3; text-shadow: 0 1px 0 rgba(255,255,255,.4); }
|
||||||
|
.cover-title { font-size: 12px; margin-top: 6px; color: var(--ink); max-height: 32px; overflow: hidden; }
|
||||||
|
.cover-rating { color: #f59e0b; font-size: 13px; }
|
||||||
|
.cover-meta { font-size: 11px; color: var(--muted); margin-top: 1px; }
|
||||||
|
.book-cover[hidden] { display: none !important; }
|
||||||
|
|
||||||
|
/* 书架墙:搜索 / 分类 / 排序 */
|
||||||
|
.shelf-tools { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 8px; }
|
||||||
|
.shelf-search {
|
||||||
|
flex: 1 1 200px; min-width: 160px; padding: 7px 10px; font-size: 14px;
|
||||||
|
border: 1px solid var(--line); border-radius: 999px; background: var(--panel); color: var(--ink);
|
||||||
|
}
|
||||||
|
.shelf-search:focus { outline: none; border-color: var(--blue); }
|
||||||
|
.shelf-sort { padding: 7px 8px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); color: var(--ink); font-size: 13px; }
|
||||||
|
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; }
|
||||||
|
.chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 5px; cursor: pointer;
|
||||||
|
padding: 4px 11px; font-size: 13px; line-height: 1.6;
|
||||||
|
border: 1px solid var(--line); border-radius: 999px;
|
||||||
|
background: var(--panel); color: var(--ink);
|
||||||
|
}
|
||||||
|
.chip:hover { border-color: #9ca3af; }
|
||||||
|
.chip.active { background: var(--blue); border-color: var(--blue); color: #fff; }
|
||||||
|
.chip-n { font-size: 11px; opacity: .7; }
|
||||||
|
.chip.active .chip-n { opacity: .85; }
|
||||||
|
.shelf-empty { text-align: center; color: var(--muted); padding: 18px 0 4px; font-size: 14px; }
|
||||||
|
|
||||||
|
/* 各学科进度一览(小环一行) */
|
||||||
|
.mini-rings { display: flex; flex-wrap: wrap; gap: 14px 10px; }
|
||||||
|
.mini-ring { text-align: center; width: 92px; }
|
||||||
|
.mini-name { font-size: 12px; margin-top: 2px; font-weight: 600; line-height: 1.2; }
|
||||||
|
|
||||||
|
/* 学科轮播 */
|
||||||
|
.carousel { position: relative; }
|
||||||
|
.slide { display: flex; gap: 16px; }
|
||||||
|
.slide .subject { flex: 1; }
|
||||||
|
.carousel-nav { display: flex; align-items: center; justify-content: center; gap: 14px; margin-top: 14px; }
|
||||||
|
#carouselInfo { font-size: 13px; min-width: 70px; text-align: center; }
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.slide { flex-direction: column; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主题切换器 */
|
||||||
|
.theme-picker { margin-top: 12px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.tp-label { font-size: 13px; color: var(--muted); margin-right: 4px; }
|
||||||
|
.theme-opt {
|
||||||
|
background: var(--panel); color: var(--ink); border: 1px solid var(--line);
|
||||||
|
padding: 4px 10px; border-radius: 999px; cursor: pointer; font-size: 12px;
|
||||||
|
}
|
||||||
|
.theme-opt:hover { filter: brightness(.97); }
|
||||||
|
.theme-opt.active { border-color: var(--blue); box-shadow: 0 0 0 2px color-mix(in srgb, var(--blue) 30%, transparent); }
|
||||||
|
|
||||||
|
/* 后台:轮播分组 */
|
||||||
|
.group-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 10px 0; border-bottom: 1px dashed var(--line); }
|
||||||
|
.group-row .row { flex: 1; }
|
||||||
|
.admin-subject-controls { display: inline-flex; align-items: center; gap: 8px; }
|
||||||
|
.admin-subject-controls select { padding: 4px 6px; border: 1px solid var(--line); border-radius: 8px; font-size: 13px; }
|
||||||
|
|
||||||
|
/* 后台:书目表格内的封面上传表单 */
|
||||||
|
.tbl .row { flex-wrap: wrap; }
|
||||||
|
.tbl input[type="file"] { max-width: 170px; }
|
||||||
|
.tbl input[name="cover_url"] { flex: 1; min-width: 110px; }
|
||||||
|
|
||||||
|
/* ===== hero 左侧:徽章轮播 ===== */
|
||||||
|
.hero-left { flex: 1 1 320px; min-width: 0; }
|
||||||
|
.badgebar { display: flex; align-items: center; min-height: 34px; margin-top: 8px; }
|
||||||
|
.badge-slide {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
background: var(--panel); border: 1px solid var(--line); border-radius: 999px;
|
||||||
|
padding: 6px 14px; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.badge-slide.badge-empty { background: transparent; border-style: dashed; }
|
||||||
|
.badgebar .badge-icon { font-size: 22px; }
|
||||||
|
.badgebar .badge-name { font-size: 14px; font-weight: 600; }
|
||||||
|
.badgebar .badge-state { font-size: 12px; color: #c2410c; margin-left: 2px; }
|
||||||
|
|
||||||
|
/* ===== hero 当前日期时钟 ===== */
|
||||||
|
.nowbox {
|
||||||
|
text-align: center; background: var(--panel); border: 1px solid var(--line);
|
||||||
|
border-radius: 14px; padding: 12px 18px; min-width: 158px;
|
||||||
|
}
|
||||||
|
.now-date { font-size: 16px; font-weight: 700; }
|
||||||
|
.now-week { font-size: 13px; color: var(--muted); margin-top: 2px; }
|
||||||
|
.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; }
|
||||||
|
.ring-slide .mini-ring { display: flex; flex-direction: row; align-items: center; gap: 6px; width: auto; }
|
||||||
|
|
||||||
|
/* ===== 健康综合 ===== */
|
||||||
|
.healthbox { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 10px; }
|
||||||
|
.healthchart { display: block; width: 100%; height: auto; }
|
||||||
|
.hc-grid { stroke: var(--line); stroke-width: 1; }
|
||||||
|
.hc-axis { fill: var(--muted); }
|
||||||
|
.lgline { display: inline-block; width: 14px; height: 4px; border-radius: 2px; vertical-align: middle; margin-right: 4px; }
|
||||||
|
.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; }
|
||||||
|
.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; }
|
||||||
|
.hs-diff.up { color: #16a34a; }
|
||||||
|
.hs-diff.down { color: #dc2626; }
|
||||||
|
|
||||||
|
/* ===== 学科进度:任务清单 ===== */
|
||||||
|
.tasklist { list-style: none; padding: 0; margin: 10px 0 0; }
|
||||||
|
.task { display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px dashed var(--line); flex-wrap: wrap; }
|
||||||
|
.task-btn { display: flex; align-items: center; gap: 8px; background: none; border: none; cursor: pointer; font-size: 14px; color: var(--ink); padding: 4px 0; text-align: left; }
|
||||||
|
.task-btn:hover { opacity: .85; }
|
||||||
|
.task-ico { font-size: 16px; }
|
||||||
|
.task-title { flex: 1; }
|
||||||
|
.task-0 .task-title { color: var(--muted); }
|
||||||
|
.task-1 .task-title { color: #374151; font-weight: 600; }
|
||||||
|
.task-2 .task-title { color: #111; }
|
||||||
|
.task-flag { font-size: 11px; background: var(--blue); color: #fff; padding: 2px 8px; border-radius: 999px; }
|
||||||
|
|
||||||
|
/* ===== 书架墙 mini ===== */
|
||||||
|
.shelf-mini { margin-top: 6px; }
|
||||||
|
.shelf-row-label { font-size: 13px; color: var(--muted); margin: 10px 0 6px; font-weight: 600; }
|
||||||
|
.shelf-3 { justify-content: flex-start; gap: 14px; align-items: flex-start; }
|
||||||
|
.shelf-3 .book-cover { width: 92px; }
|
||||||
|
.shelf-3 .book-cover img, .shelf-3 .cover-ph { width: 84px; height: 118px; }
|
||||||
|
|
||||||
|
/* ===== 浮动打卡 dock ===== */
|
||||||
|
.dock { position: fixed; right: 18px; bottom: 18px; display: flex; flex-direction: column; gap: 10px; z-index: 30; }
|
||||||
|
.dock-btn {
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
|
||||||
|
width: 60px; height: 60px; border-radius: 50%; background: var(--blue); color: #fff;
|
||||||
|
box-shadow: 0 6px 18px rgba(0,0,0,.2); font-size: 11px; line-height: 1.2; padding: 0; border: none; cursor: pointer;
|
||||||
|
}
|
||||||
|
.dock-btn .dock-ico { font-size: 20px; }
|
||||||
|
.dock-btn.on { background: #16a34a; }
|
||||||
|
.dock-btn.pop { animation: dockpop .4s ease; }
|
||||||
|
@keyframes dockpop { 0% { transform: scale(1); } 40% { transform: scale(1.18); } 100% { transform: scale(1); } }
|
||||||
|
|
||||||
|
/* ===== 宽弹窗(书架详情) ===== */
|
||||||
|
.modal-wide { width: min(860px, 100%); }
|
||||||
|
|
||||||
|
/* ===== hero 当前日期时钟:去方框 ===== */
|
||||||
|
.nowbox {
|
||||||
|
background: none; border: none; border-radius: 0; padding: 0; min-width: 0; text-align: left;
|
||||||
|
}
|
||||||
|
.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); }
|
||||||
|
|
||||||
|
/* ===== 进度一览 / 健康综合:压缩到约 1/3 高度 ===== */
|
||||||
|
.ringcar { min-height: auto; }
|
||||||
|
.ring-slide .mini-ring svg { width: 46px; height: 46px; }
|
||||||
|
.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 {
|
||||||
|
position: absolute; right: 0; top: 50%; transform: translateY(-50%);
|
||||||
|
width: 26px; height: 48px; border: none; border-radius: 8px;
|
||||||
|
background: var(--blue); color: #fff; font-size: 22px; line-height: 1;
|
||||||
|
opacity: .28; cursor: pointer; transition: opacity .15s; padding: 0;
|
||||||
|
}
|
||||||
|
.ring-next:hover { opacity: .75; }
|
||||||
|
.healthbox .healthchart { height: 48px; }
|
||||||
|
.healthbox-detail .healthchart { height: 240px; }
|
||||||
|
.card-foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 12px; flex-wrap: wrap; }
|
||||||
|
.badge-slide { cursor: pointer; }
|
||||||
|
|
||||||
|
/* ===== 书架墙:仅封面 + 右下角日期 ===== */
|
||||||
|
.cover-frame { position: relative; display: inline-block; line-height: 0; }
|
||||||
|
.cover-date {
|
||||||
|
position: absolute; right: 2px; bottom: 2px;
|
||||||
|
background: rgba(17,24,39,.62); color: #fff;
|
||||||
|
font-size: 10px; padding: 1px 5px; border-radius: 4px; line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 徽章详情弹窗 ===== */
|
||||||
|
.badge-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; margin-top: 6px; }
|
||||||
|
.badge-cell { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 14px 10px; text-align: center; }
|
||||||
|
.badge-cell.locked { opacity: .5; filter: grayscale(.7); }
|
||||||
|
.bc-icon { font-size: 30px; }
|
||||||
|
.bc-name { font-weight: 700; margin-top: 6px; font-size: 14px; }
|
||||||
|
.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); }
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{# 单本书封面卡片:仅显示封面,无封面用文字代替;完成日期显示在封面右下角 #}
|
||||||
|
<div class="book-cover"
|
||||||
|
data-title="{{ b.title }}"
|
||||||
|
data-cat="{{ b.category }}"
|
||||||
|
data-rating="{{ b.rating|int }}"
|
||||||
|
data-finish="{{ b.finish }}"
|
||||||
|
data-s="{{ (b.title ~ ' ' ~ b.author ~ ' ' ~ b.category)|lower }}">
|
||||||
|
<div class="cover-frame">
|
||||||
|
{% if b.cover %}
|
||||||
|
<img src="{{ b.cover }}" alt="{{ b.title }}" loading="lazy">
|
||||||
|
{% else %}
|
||||||
|
<div class="cover-ph" style="background:{{ b.bg }}"><span>{{ b.title }}</span></div>
|
||||||
|
{% endif %}
|
||||||
|
{% if b.finish_str %}
|
||||||
|
<span class="cover-date">{{ b.finish_str }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}家长后台{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="admin-head">
|
||||||
|
<h1>🛠️ 家长后台</h1>
|
||||||
|
<a href="{{ url_for('admin_export') }}" class="btn-outline">⬇ 导出全部数据(JSON)</a>
|
||||||
|
<a href="{{ url_for('admin_logout') }}" class="btn-outline">退出</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>👦 孩子信息</h2>
|
||||||
|
<form method="post" action="{{ url_for('admin_child_update') }}" class="row wrap">
|
||||||
|
<label>姓名<input name="name" value="{{ child.name }}" placeholder="姓名"></label>
|
||||||
|
<label>出生日期<input type="date" name="birth_date" value="{{ child.birth_date or '' }}"></label>
|
||||||
|
<label>性别
|
||||||
|
<select name="gender">
|
||||||
|
<option value="M" {% if child.gender == 'M' %}selected{% endif %}>男</option>
|
||||||
|
<option value="F" {% if child.gender == 'F' %}selected{% endif %}>女</option>
|
||||||
|
</select>
|
||||||
|
</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 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>默认主题
|
||||||
|
<select name="default_theme">
|
||||||
|
{% for t,label in themes %}
|
||||||
|
<option value="{{ t }}" {% if child.default_theme == t %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button>保存</button>
|
||||||
|
</form>
|
||||||
|
{% 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>
|
||||||
|
{% endif %}
|
||||||
|
</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">
|
||||||
|
<h2>📈 健康记录(可补历史数据)</h2>
|
||||||
|
<p class="sub">填入任意日期即可补录历史身高体重;仪表盘「健康综合」会展示最近两年。</p>
|
||||||
|
<form method="post" action="{{ url_for('health_add') }}" class="row wrap">
|
||||||
|
<label>日期<input type="date" name="date" value="{{ now }}" required></label>
|
||||||
|
<label>身高(cm)<input name="height_cm" type="number" step="0.1" placeholder="如 130.5" style="width:90px"></label>
|
||||||
|
<label>体重(kg)<input name="weight_kg" type="number" step="0.1" placeholder="如 27.2" style="width:90px"></label>
|
||||||
|
<label>睡眠(时)<input name="sleep_hours" type="number" step="0.1" style="width:80px"></label>
|
||||||
|
<label>喝水(杯)<input name="water_cups" type="number" style="width:70px"></label>
|
||||||
|
<label>心情<input name="mood" placeholder="如 开心" style="width:90px"></label>
|
||||||
|
<button>添加记录</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>📚 学科与单元(整年课表)</h2>
|
||||||
|
<form method="post" action="{{ url_for('admin_subject_add') }}" class="row">
|
||||||
|
<input name="name" placeholder="学科名,如 语文" required>
|
||||||
|
<input name="icon" placeholder="图标" style="width:70px" value="📘">
|
||||||
|
<input name="color" type="color" value="#3b82f6" style="width:50px">
|
||||||
|
<button>加学科</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% for s in subjects %}
|
||||||
|
<div class="admin-subject">
|
||||||
|
<div class="admin-subject-head">
|
||||||
|
<span>{{ s.icon }} <b>{{ s.name }}</b></span>
|
||||||
|
<span class="admin-subject-controls">
|
||||||
|
<form method="post" action="{{ url_for('admin_subject_delete', sid=s.id) }}" class="inline"
|
||||||
|
onsubmit="return confirm('删除该学科及其所有单元?')">
|
||||||
|
<button class="mini danger">删除学科</button>
|
||||||
|
</form>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul class="admin-units">
|
||||||
|
{% for u in s.units %}
|
||||||
|
<li>
|
||||||
|
<span class="unit-state unit-{{ u.status }}">
|
||||||
|
{% if u.status==2 %}已完成{% elif u.status==1 %}进行中{% else %}未开始{% endif %}
|
||||||
|
</span>
|
||||||
|
{{ u.title }}
|
||||||
|
{% if u.target_date %}<span class="muted">(🎯{{ u.target_date }})</span>{% endif %}
|
||||||
|
<form method="post" action="{{ url_for('admin_unit_delete', uid=u.id) }}" class="inline"
|
||||||
|
onsubmit="return confirm('删除该单元?')">
|
||||||
|
<button class="mini danger">×</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<form method="post" action="{{ url_for('admin_unit_add') }}" class="row">
|
||||||
|
<input type="hidden" name="subject_id" value="{{ s.id }}">
|
||||||
|
<input name="title" placeholder="新增单元/章节名" required>
|
||||||
|
<input name="target_date" type="date" placeholder="目标完成日">
|
||||||
|
<button>加单元</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>📖 书目(含封面)</h2>
|
||||||
|
<form method="post" action="{{ url_for('book_add') }}" class="row" enctype="multipart/form-data">
|
||||||
|
<input name="title" placeholder="书名" required>
|
||||||
|
<input name="author" placeholder="作者">
|
||||||
|
<input name="total_pages" type="number" placeholder="页数" style="width:80px">
|
||||||
|
<select name="category">
|
||||||
|
{% for c in book_categories %}<option value="{{ c }}">{{ c }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
<input type="file" name="cover" accept="image/*" style="width:170px">
|
||||||
|
<button>加书</button>
|
||||||
|
</form>
|
||||||
|
<p class="sub">支持本地上传封面(png/jpg/gif/webp,<b>不限制大小</b>,离线可见);上传后会<b>自动标准化</b>为统一的 2:3 封面并生成缩略图。也可在下方每本书填图片 URL。<br>
|
||||||
|
<b>分类与评分</b>决定书架墙上的筛选标签和排序,改完记得点「保存」。</p>
|
||||||
|
<table class="tbl">
|
||||||
|
<tr><th>封面</th><th>书名</th><th>作者</th><th>状态</th><th>分类 / 评分</th><th>更新封面</th></tr>
|
||||||
|
{% for b in books %}
|
||||||
|
<tr>
|
||||||
|
<td>{% if b.cover_thumb or b.cover_url %}<img src="{{ b.cover_thumb or b.cover_url }}" alt="" style="height:60px;border-radius:3px">{% else %}<span class="muted">无</span>{% endif %}</td>
|
||||||
|
<td>{{ b.title }}</td>
|
||||||
|
<td>{{ b.author or '—' }}</td>
|
||||||
|
<td>{% if b.status=='done' %}已读完{% elif b.status=='reading' %}在读{% else %}想读{% endif %}</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="{{ url_for('admin_book_meta', bid=b.id) }}" class="row" style="margin:0">
|
||||||
|
<select name="category">
|
||||||
|
{% for c in book_categories %}
|
||||||
|
<option value="{{ c }}" {% if (b.category or '未分类') == c %}selected{% endif %}>{{ c }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<select name="rating" title="评分">
|
||||||
|
{% for r in range(6) %}
|
||||||
|
<option value="{{ r }}" {% if (b.rating or 0) == r %}selected{% endif %}>{{ r }}★</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button class="mini">保存</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="{{ url_for('admin_book_cover', bid=b.id) }}" enctype="multipart/form-data" class="row" style="margin:0">
|
||||||
|
<input type="file" name="cover" accept="image/*" style="width:150px">
|
||||||
|
<input name="cover_url" placeholder="或图片URL" value="{{ b.cover_url or '' }}" style="width:130px">
|
||||||
|
<button class="mini">设封面</button>
|
||||||
|
</form>
|
||||||
|
{% if b.cover_url and b.cover_url.startswith('/static/covers/') %}
|
||||||
|
<form method="post" action="{{ url_for('admin_book_cover_delete', bid=b.id) }}" class="inline" style="margin-top:6px" onsubmit="return confirm('删除这张封面?')">
|
||||||
|
<button class="mini danger">删除封面</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>🧹 数据管理</h2>
|
||||||
|
<p class="sub">徽章由进度/读书/运动实时派生,重置或新学期后会自动重新累积,无需手动发徽章。</p>
|
||||||
|
<div class="row" style="gap:12px;flex-wrap:wrap">
|
||||||
|
<form method="post" action="{{ url_for('admin_reset') }}"
|
||||||
|
onsubmit="return confirm('确定清空全部进度 / 打卡 / 健康记录?学科与书目结构保留。此操作不可撤销!')">
|
||||||
|
<button class="mini danger">🗑️ 重置全部数据</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{{ url_for('admin_new_term') }}"
|
||||||
|
onsubmit="return confirm('开启新学期:进度重置为未开始、清空打卡,并把学期起设为今天。确定?')">
|
||||||
|
<button class="mini">🎒 开启新学期</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}家长登录{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="login-box">
|
||||||
|
<h1>🔐 家长后台登录</h1>
|
||||||
|
{% if error %}<p class="error">{{ error }}</p>{% endif %}
|
||||||
|
<form method="post" action="{{ url_for('admin_login') }}" class="login-form">
|
||||||
|
<input name="username" placeholder="用户名" required>
|
||||||
|
<input name="password" type="password" placeholder="密码" required>
|
||||||
|
<button>登录</button>
|
||||||
|
</form>
|
||||||
|
<p class="muted">默认账号 admin / 密码 admin123,可用环境变量 ADMIN_USER / ADMIN_PASSWORD 修改。</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN" class="theme-{{ theme|default('light') }}">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}个人成长系统{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
</head>
|
||||||
|
<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">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ child.name }} 的成长仪表盘{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="hero">
|
||||||
|
<div class="hero-left">
|
||||||
|
<h1>👦 {{ child.name }} 的成长仪表盘</h1>
|
||||||
|
<div class="badgebar" id="badgeBar" data-interval="5">
|
||||||
|
{% if got_badges %}
|
||||||
|
{% for b in got_badges %}
|
||||||
|
<div class="badge-slide" style="display:{% if loop.first %}flex{% else %}none{% endif %}" data-key="{{ b.key }}" onclick="openBadge('{{ b.key }}')">
|
||||||
|
<span class="badge-icon">{{ b.icon }}</span>
|
||||||
|
<span class="badge-name">{{ b.name }}</span>
|
||||||
|
<span class="badge-state">已获得</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="badge-slide badge-empty" style="display:flex">
|
||||||
|
<span class="badge-icon">🏅</span>
|
||||||
|
<span class="badge-name">还没有徽章,今天就去解锁一个吧</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="mini" onclick="openBadges()">🏅 全部徽章</button>
|
||||||
|
</div>
|
||||||
|
<div class="hero-right">
|
||||||
|
<div class="nowbox">
|
||||||
|
<div class="now-date" id="nowDate">—</div>
|
||||||
|
<div class="now-week" id="nowWeek">—</div>
|
||||||
|
<div class="now-time" id="nowTime">--:--:--</div>
|
||||||
|
</div>
|
||||||
|
<div class="termcal">
|
||||||
|
<div class="termcal-title">📅 学期日历</div>
|
||||||
|
<div class="termcal-range">{{ term_start_str }} ~ {{ term_end_str }}</div>
|
||||||
|
<div class="termcal-scroll">{{ term_calendar_svg(active_counts, term_start, term_end) | safe }}</div>
|
||||||
|
<div class="termcal-legend">
|
||||||
|
<span><i class="lg tc-act3"></i>多项</span>
|
||||||
|
<span><i class="lg tc-act1"></i>有打卡</span>
|
||||||
|
<span><i class="lg tc-week"></i>周末</span>
|
||||||
|
<span><i class="lg tc-none"></i>未打卡</span>
|
||||||
|
<span><i class="lg tc-today"></i>今天</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hero-ring">
|
||||||
|
{{ ring_svg(yprog, '#3b82f6', 130, '学年总进度') | safe }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
<section class="card">
|
||||||
|
<h2>🎡 各学科进度一览</h2>
|
||||||
|
<div class="ring-wrap">
|
||||||
|
<div class="ringcar" id="ringCar" data-interval="{{ ring_sec }}">
|
||||||
|
{% for grp in ring_slides %}
|
||||||
|
<div class="ring-slide" style="display:{% if loop.first %}flex{% else %}none{% endif %}">
|
||||||
|
{% for s in grp %}
|
||||||
|
<div class="mini-ring" title="{{ s.name }}({{ s.progress }}%)">
|
||||||
|
<div class="mini-name">{{ s.icon }} {{ s.name }}</div>
|
||||||
|
{{ mini_ring_svg(s.progress, s.color) | safe }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="ring-next" aria-label="下一屏" onclick="ringCar.next()">›</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>💗 健康综合</h2>
|
||||||
|
<div class="hstats">
|
||||||
|
<div class="hstat">
|
||||||
|
<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-std">标准 {{ std_h }} cm
|
||||||
|
{%- if cur_h is not none %}
|
||||||
|
<span class="hs-diff {{ 'up' if cur_h >= std_h else 'down' }}">
|
||||||
|
{{ '+' if cur_h >= std_h else '' }}{{ (cur_h - std_h) | round(1) }}
|
||||||
|
</span>
|
||||||
|
{%- endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hstat">
|
||||||
|
<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-std">标准 {{ std_w }} kg
|
||||||
|
{%- if cur_w is not none %}
|
||||||
|
<span class="hs-diff {{ 'up' if cur_w >= std_w else 'down' }}">
|
||||||
|
{{ '+' if cur_w >= std_w else '' }}{{ (cur_w - std_w) | round(1) }}
|
||||||
|
</span>
|
||||||
|
{%- endif %}
|
||||||
|
</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>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
<section class="card">
|
||||||
|
<h2>📚 学科进度</h2>
|
||||||
|
<p class="sub">按任务顺序显示:完成 3 个 · 进行中 1 个 · 待完成 3 个。点一下就完成,下一个自动接上。</p>
|
||||||
|
<div class="carousel" id="subjCarousel" data-interval="{{ carousel_sec }}">
|
||||||
|
{% for grp in slides %}
|
||||||
|
<div class="slide" style="display:{% if loop.first %}block{% else %}none{% endif %}">
|
||||||
|
{% for s in grp %}
|
||||||
|
<div class="subject" style="border-left:6px solid {{ s.color }}">
|
||||||
|
<div class="subject-head">
|
||||||
|
<span class="subject-icon">{{ s.icon }}</span>
|
||||||
|
<span class="subject-name">{{ s.name }}</span>
|
||||||
|
<span class="subject-pct">{{ s.done }}/{{ s.total }} · {{ s.progress }}%</span>
|
||||||
|
<button type="button" class="mini detail-btn" onclick="openDetail({{ s.id }})">详情</button>
|
||||||
|
</div>
|
||||||
|
{{ bar_svg(s.progress, s.color) | safe }}
|
||||||
|
<ul class="tasklist">
|
||||||
|
{% for u in s.window %}
|
||||||
|
<li class="task task-{{ u.status }}">
|
||||||
|
<form method="post" action="{{ url_for('unit_toggle', uid=u.id) }}" class="unit-form">
|
||||||
|
<button type="submit" class="task-btn">
|
||||||
|
<span class="task-ico">{% if u.status == 2 %}✅{% elif u.status == 1 %}🔵{% else %}⚪{% endif %}</span>
|
||||||
|
<span class="task-title">{{ u.title }}</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% if u.status == 1 %}<span class="task-flag">进行中</span>{% endif %}
|
||||||
|
{% if u.target_date %}<span class="unit-date">🎯{{ u.target_date }}</span>{% endif %}
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="muted">这个学科还没录入任务,去后台加一条吧</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<div class="subject-detail" id="detail-{{ s.id }}" style="display:none">
|
||||||
|
<h3 style="margin:0 0 10px">{{ s.icon }} {{ s.name }} · {{ s.progress }}%({{ s.done }}/{{ s.total }})</h3>
|
||||||
|
<ul class="units">
|
||||||
|
{% for u in s.units %}
|
||||||
|
<li class="unit unit-{{ u.status }}">
|
||||||
|
<form method="post" action="{{ url_for('unit_toggle', uid=u.id) }}" class="unit-form">
|
||||||
|
<button type="submit" class="unit-btn">
|
||||||
|
{% if u.status == 2 %}✅{% elif u.status == 1 %}🔵{% else %}⚪{% endif %}
|
||||||
|
{{ u.title }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% if u.target_date %}<span class="unit-date">🎯{{ u.target_date }}</span>{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="carousel-nav">
|
||||||
|
<button type="button" class="mini" onclick="carouselPrev()">‹ 上一个</button>
|
||||||
|
<span id="carouselInfo" class="muted"></span>
|
||||||
|
<button type="button" class="mini" onclick="carouselNext()">下一个 ›</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>📖 我的书架墙
|
||||||
|
<button type="button" class="mini detail-btn" onclick="openShelf()">详情</button>
|
||||||
|
</h2>
|
||||||
|
<p class="sub">共读完 {{ shelf|length }} 本 · 上排最近读完,下排随机回顾</p>
|
||||||
|
{% if shelf %}
|
||||||
|
<div class="shelf-mini">
|
||||||
|
<div class="shelf-row-label">🆕 最近读完</div>
|
||||||
|
<div class="shelf shelf-3">
|
||||||
|
{% for b in shelf_recent %}{% include "_book_cover.html" %}{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% if shelf_random %}
|
||||||
|
<div class="shelf-row-label">🎲 随机回顾</div>
|
||||||
|
<div class="shelf shelf-3">
|
||||||
|
{% for b in shelf_random %}{% include "_book_cover.html" %}{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="shelf-empty">书架还是空的,读完第一本书就会出现在这里 📚</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 浮动快捷打卡 -->
|
||||||
|
<div class="dock">
|
||||||
|
{% for c in checkins %}
|
||||||
|
<button type="button" class="dock-btn {{ 'on' if c.done }}"
|
||||||
|
data-kind="{{ c.key }}" data-name="{{ c.name }}" data-done="{{ c.done_name }}"
|
||||||
|
title="{{ c.name }}">
|
||||||
|
<span class="dock-ico">{{ c.icon }}</span>
|
||||||
|
<span class="dock-txt">{{ c.done_name if c.done else c.name }}</span>
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 单元详情弹窗 -->
|
||||||
|
<div class="modal-mask" id="modalMask" onclick="if(event.target===this)closeDetail()">
|
||||||
|
<div class="modal" role="dialog" aria-modal="true">
|
||||||
|
<div class="modal-head">
|
||||||
|
<span>任务详情</span>
|
||||||
|
<button type="button" class="mini" onclick="closeDetail()">✕ 关闭</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="modalBody"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 书架详情弹窗(搜索 / 分类 / 排序都在这里) -->
|
||||||
|
<div class="modal-mask" id="shelfMask" onclick="if(event.target===this)closeShelf()">
|
||||||
|
<div class="modal modal-wide" role="dialog" aria-modal="true">
|
||||||
|
<div class="modal-head">
|
||||||
|
<span>📚 书架详情</span>
|
||||||
|
<button type="button" class="mini" onclick="closeShelf()">✕ 关闭</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="shelf-tools">
|
||||||
|
<input id="shelfSearch" class="shelf-search" type="search" placeholder="🔍 搜书名 / 作者 / 分类…" autocomplete="off">
|
||||||
|
<select id="shelfSort" class="shelf-sort" title="排序">
|
||||||
|
<option value="recent">最近读完</option>
|
||||||
|
<option value="rating">评分最高</option>
|
||||||
|
<option value="title">按书名</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" id="shelfReset" class="mini">重置</button>
|
||||||
|
</div>
|
||||||
|
<div class="chips" id="shelfCats">
|
||||||
|
<button type="button" class="chip active" data-cat="__all__">全部<span class="chip-n">{{ shelf|length }}</span></button>
|
||||||
|
{% for c in shelf_cats %}
|
||||||
|
<button type="button" class="chip" data-cat="{{ c.name }}">{{ c.name }}<span class="chip-n">{{ c.count }}</span></button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<p class="sub" id="shelfCount" data-total="{{ shelf|length }}">已读完 {{ shelf|length }} 本</p>
|
||||||
|
<div class="shelf" id="shelfWall">
|
||||||
|
{% for b in shelf %}{% include "_book_cover.html" %}{% endfor %}
|
||||||
|
</div>
|
||||||
|
<p class="shelf-empty" id="shelfEmpty" hidden>🙈 没有匹配的书,换个词或分类试试</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 徽章详情弹窗 -->
|
||||||
|
<div class="modal-mask" id="badgeMask" onclick="if(event.target===this)closeBadges()">
|
||||||
|
<div class="modal" role="dialog" aria-modal="true">
|
||||||
|
<div class="modal-head">
|
||||||
|
<span>🏅 我的徽章</span>
|
||||||
|
<button type="button" class="mini" onclick="closeBadges()">✕ 关闭</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="sub">徽章会随任务、读书、运动的进展自动更新。</p>
|
||||||
|
<div class="badge-grid" id="badgeList"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 健康图表详情弹窗 -->
|
||||||
|
<div class="modal-mask" id="healthMask" onclick="if(event.target===this)closeHealth()">
|
||||||
|
<div class="modal modal-wide" role="dialog" aria-modal="true">
|
||||||
|
<div class="modal-head">
|
||||||
|
<span>💗 健康图表(近 2 年)</span>
|
||||||
|
<button type="button" class="mini" onclick="closeHealth()">✕ 关闭</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="sub">
|
||||||
|
<i class="lgline" style="background:#10b981"></i>身高(cm)
|
||||||
|
<i class="lgline" style="background:#f59e0b"></i>体重(kg)
|
||||||
|
</p>
|
||||||
|
<div class="healthbox healthbox-detail">{{ health_chart_svg(hpoints) | safe }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/* ---------- 当前日期时间(每秒刷新) ---------- */
|
||||||
|
(function () {
|
||||||
|
var WD = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
|
||||||
|
function pad(n) { return n < 10 ? '0' + n : '' + n; }
|
||||||
|
function tick() {
|
||||||
|
var d = new Date();
|
||||||
|
var de = document.getElementById('nowDate');
|
||||||
|
var we = document.getElementById('nowWeek');
|
||||||
|
var te = document.getElementById('nowTime');
|
||||||
|
if (de) de.textContent = d.getFullYear() + '年' + (d.getMonth() + 1) + '月' + d.getDate() + '日';
|
||||||
|
if (we) we.textContent = WD[d.getDay()];
|
||||||
|
if (te) te.textContent = pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
|
||||||
|
}
|
||||||
|
tick();
|
||||||
|
setInterval(tick, 1000);
|
||||||
|
})();
|
||||||
|
|
||||||
|
/* ---------- 通用轮播 ---------- */
|
||||||
|
function makeCarousel(rootId, slideSel, infoId, display, unit) {
|
||||||
|
var root = document.getElementById(rootId);
|
||||||
|
if (!root) return { next: function () {}, prev: function () {} };
|
||||||
|
var slides = Array.prototype.slice.call(root.querySelectorAll(slideSel));
|
||||||
|
var iv = (parseInt(root.dataset.interval, 10) || 8) * 1000;
|
||||||
|
var idx = 0, timer = null;
|
||||||
|
var info = infoId ? document.getElementById(infoId) : null;
|
||||||
|
function show(i) {
|
||||||
|
if (!slides.length) return;
|
||||||
|
idx = (i + slides.length) % slides.length;
|
||||||
|
slides.forEach(function (el, k) { el.style.display = (k === idx) ? display : 'none'; });
|
||||||
|
if (info) info.textContent = (idx + 1) + ' / ' + slides.length + (unit || '');
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (slides.length > 1) timer = setTimeout(function () { show(idx + 1); }, iv);
|
||||||
|
}
|
||||||
|
show(0);
|
||||||
|
return { next: function () { show(idx + 1); }, prev: function () { show(idx - 1); } };
|
||||||
|
}
|
||||||
|
|
||||||
|
var subjCar = makeCarousel('subjCarousel', '.slide', 'carouselInfo', 'block', ' 科');
|
||||||
|
var ringCar = makeCarousel('ringCar', '.ring-slide', null, 'flex', '');
|
||||||
|
makeCarousel('badgeBar', '.badge-slide', null, 'flex', '');
|
||||||
|
function carouselNext() { subjCar.next(); }
|
||||||
|
function carouselPrev() { subjCar.prev(); }
|
||||||
|
|
||||||
|
/* ---------- 弹窗 ---------- */
|
||||||
|
function openDetail(id) {
|
||||||
|
var src = document.getElementById('detail-' + id);
|
||||||
|
document.getElementById('modalBody').innerHTML = src ? src.innerHTML : '';
|
||||||
|
document.getElementById('modalMask').classList.add('show');
|
||||||
|
}
|
||||||
|
function closeDetail() { document.getElementById('modalMask').classList.remove('show'); }
|
||||||
|
function openShelf() { document.getElementById('shelfMask').classList.add('show'); }
|
||||||
|
function closeShelf() { document.getElementById('shelfMask').classList.remove('show'); }
|
||||||
|
|
||||||
|
/* ---------- 徽章详情 ---------- */
|
||||||
|
var BADGES = {{ badges | tojson }};
|
||||||
|
function badgeCell(b) {
|
||||||
|
var cls = 'badge-cell' + (b.got ? '' : ' locked');
|
||||||
|
return '<div class="' + cls + '" data-key="' + b.key + '">' +
|
||||||
|
'<div class="bc-icon">' + b.icon + '</div>' +
|
||||||
|
'<div class="bc-name">' + b.name + '</div>' +
|
||||||
|
'<div class="bc-desc">' + (b.desc || '') + '</div>' +
|
||||||
|
'<div class="bc-state">' +
|
||||||
|
(b.got ? ('✅ 已获得' + (b.got_at ? ' · ' + b.got_at : '')) : '🔒 未解锁') +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
function renderBadges() {
|
||||||
|
document.getElementById('badgeList').innerHTML = BADGES.map(badgeCell).join('');
|
||||||
|
}
|
||||||
|
function openBadges() { renderBadges(); document.getElementById('badgeMask').classList.add('show'); }
|
||||||
|
function openBadge(key) {
|
||||||
|
renderBadges();
|
||||||
|
document.getElementById('badgeMask').classList.add('show');
|
||||||
|
var el = document.querySelector('#badgeList [data-key="' + key + '"]');
|
||||||
|
if (el) el.scrollIntoView({ block: 'center' });
|
||||||
|
}
|
||||||
|
function closeBadges() { document.getElementById('badgeMask').classList.remove('show'); }
|
||||||
|
function openHealth() { document.getElementById('healthMask').classList.add('show'); }
|
||||||
|
function closeHealth() { document.getElementById('healthMask').classList.remove('show'); }
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') { closeDetail(); closeShelf(); closeBadges(); closeHealth(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------- 浮动打卡 ---------- */
|
||||||
|
document.querySelectorAll('.dock-btn').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
btn.disabled = true;
|
||||||
|
fetch('/checkin/' + btn.dataset.kind, {
|
||||||
|
method: 'POST', headers: { 'X-Ajax': '1' }
|
||||||
|
}).then(function (r) { return r.json(); }).then(function (j) {
|
||||||
|
var txt = btn.querySelector('.dock-txt');
|
||||||
|
btn.classList.toggle('on', !!j.done);
|
||||||
|
if (txt) txt.textContent = j.done ? btn.dataset.done : btn.dataset.name;
|
||||||
|
if (j.done) {
|
||||||
|
btn.classList.add('pop');
|
||||||
|
setTimeout(function () { btn.classList.remove('pop'); }, 400);
|
||||||
|
}
|
||||||
|
}).catch(function () {}).then(function () { btn.disabled = false; });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------- 书架详情:搜索 + 分类 + 排序 ---------- */
|
||||||
|
(function () {
|
||||||
|
var wall = document.getElementById('shelfWall');
|
||||||
|
if (!wall) return;
|
||||||
|
var items = Array.prototype.slice.call(wall.querySelectorAll('.book-cover'));
|
||||||
|
var search = document.getElementById('shelfSearch');
|
||||||
|
var sortSel = document.getElementById('shelfSort');
|
||||||
|
var countEl = document.getElementById('shelfCount');
|
||||||
|
var emptyEl = document.getElementById('shelfEmpty');
|
||||||
|
var resetBtn = document.getElementById('shelfReset');
|
||||||
|
var chips = Array.prototype.slice.call(document.querySelectorAll('#shelfCats .chip'));
|
||||||
|
var total = items.length;
|
||||||
|
var curCat = '__all__';
|
||||||
|
var baseText = countEl.textContent;
|
||||||
|
|
||||||
|
function cmp(a, b) {
|
||||||
|
var mode = sortSel.value;
|
||||||
|
if (mode === 'rating') {
|
||||||
|
var d = (+b.dataset.rating) - (+a.dataset.rating);
|
||||||
|
if (d) return d;
|
||||||
|
return a.dataset.title.localeCompare(b.dataset.title, 'zh');
|
||||||
|
}
|
||||||
|
if (mode === 'title') return a.dataset.title.localeCompare(b.dataset.title, 'zh');
|
||||||
|
return (b.dataset.finish || '').localeCompare(a.dataset.finish || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply() {
|
||||||
|
var q = (search.value || '').trim().toLowerCase();
|
||||||
|
var shown = 0;
|
||||||
|
items.forEach(function (el) {
|
||||||
|
var okCat = (curCat === '__all__') || (el.dataset.cat === curCat);
|
||||||
|
var okQ = !q || el.dataset.s.indexOf(q) >= 0;
|
||||||
|
var ok = okCat && okQ;
|
||||||
|
el.hidden = !ok;
|
||||||
|
if (ok) shown++;
|
||||||
|
});
|
||||||
|
items.slice().sort(cmp).forEach(function (el) { wall.appendChild(el); });
|
||||||
|
var filtering = q || curCat !== '__all__';
|
||||||
|
countEl.textContent = filtering ? ('筛选出 ' + shown + ' 本 / 共 ' + total + ' 本') : baseText;
|
||||||
|
emptyEl.hidden = shown > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
chips.forEach(function (c) {
|
||||||
|
c.onclick = function () {
|
||||||
|
curCat = c.dataset.cat;
|
||||||
|
chips.forEach(function (x) { x.classList.toggle('active', x === c); });
|
||||||
|
apply();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
search.addEventListener('input', apply);
|
||||||
|
sortSel.addEventListener('change', apply);
|
||||||
|
resetBtn.onclick = function () {
|
||||||
|
search.value = '';
|
||||||
|
curCat = '__all__';
|
||||||
|
sortSel.value = 'recent';
|
||||||
|
chips.forEach(function (x) { x.classList.toggle('active', x.dataset.cat === '__all__'); });
|
||||||
|
apply();
|
||||||
|
};
|
||||||
|
apply();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user