Files

446 lines
19 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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 %}