星火纪元 个人成长追踪 v1.0

- Flask + SQLite 自托管,单用户无 PIN
- 仪表盘:学科进度环轮播、健康双线图、任务接龙、书架墙、浮动打卡
- 后台:孩子信息、学科/单元课表、健康历史录入、数据管理(重置/新学期)
- 徽章由进度实时派生;主题由后台设置
This commit is contained in:
2026-08-11 15:20:53 +08:00
commit 0e6065897b
12 changed files with 2564 additions and 0 deletions
+18
View File
@@ -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>
+181
View File
@@ -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 %}
+14
View File
@@ -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 %}
+21
View File
@@ -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>
+437
View File
@@ -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 %}