446 lines
19 KiB
HTML
446 lines
19 KiB
HTML
{% 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="link-btn" 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 class="now-left" id="nowLeft">{{ days_left_str }}</div>
|
||
</div>
|
||
<div class="termcal">
|
||
<div class="termcal-title">📅 日历 <span class="termcal-range-inline" id="calRange">{{ cal_start_str }} ~ {{ cal_end_str }}</span></div>
|
||
<div class="termcal-scroll">{{ term_calendar_svg(active_counts, cal_start, cal_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">
|
||
<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">
|
||
<div class="hstats">
|
||
<div class="hstat" onclick="openHealth()" title="点击查看健康图表详情">
|
||
<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-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>
|
||
<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-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>
|
||
<p class="sub tiny">按中国儿童生长标准(WS/T 612-2018){{ age }} 岁{{ '男孩' if (child.gender or 'M') == 'M' else '女孩' }}同龄中位数估算,仅供参考。</p>
|
||
</section>
|
||
</div>
|
||
|
||
<div class="grid-2">
|
||
<section class="card">
|
||
<h2>📚 学科进度</h2>
|
||
<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="link-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="link-btn" onclick="openShelf()">点击查看详情 ›</button>
|
||
</h2>
|
||
{% 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 LS = 'carIdx_' + rootId;
|
||
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;
|
||
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 || '');
|
||
try { localStorage.setItem(LS, idx); } catch (e) {}
|
||
clearTimeout(timer);
|
||
if (slides.length > 1) timer = setTimeout(function () { show(idx + 1); }, iv);
|
||
}
|
||
show(idx);
|
||
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(); }
|
||
|
||
/* ---------- 学期 / 假期 日历:仪表盘只显示当前所在时段 ---------- */
|
||
|
||
/* ---------- 书架墙封面点击打开详情 ---------- */
|
||
document.querySelectorAll('.shelf-mini .book-cover').forEach(function (el) {
|
||
el.style.cursor = 'pointer';
|
||
el.addEventListener('click', openShelf);
|
||
});
|
||
|
||
/* ---------- 弹窗 ---------- */
|
||
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 %}
|