const API_BASE = '/api/fitness_diary'; const clearUrlQuery = () => { try { const cleanUrl = `${window.location.origin}${window.location.pathname}${window.location.hash || ''}`; window.history.replaceState({}, '', cleanUrl); } catch {} }; // State let state = { types: [], dailyPlans: [], records: [], filterStart: '', filterEnd: '', statsStart: '', statsEnd: '', currentTab: 'records', calYear: 0, calMonth: 0 }; // DOM Elements const els = { // Stats statCalories: document.getElementById('stat-calories'), statDuration: document.getElementById('stat-duration'), statStand: document.getElementById('stat-stand'), // Filters filterStart: document.getElementById('filter-start'), filterEnd: document.getElementById('filter-end'), btnFilter: document.getElementById('btn-filter'), btnPrevWeek: document.getElementById('btn-prev-week'), btnThisWeek: document.getElementById('btn-this-week'), btnNextWeek: document.getElementById('btn-next-week'), // List recordList: document.getElementById('record-list'), // Modal & Form modal: document.getElementById('modal-form'), btnAddModal: document.getElementById('btn-add-modal'), btnCloseModal: document.getElementById('btn-close-modal'), recordForm: document.getElementById('record-form'), itemsContainer: document.getElementById('items-container'), addItemBtn: document.getElementById('add-item-btn'), dailyPlanSelect: document.getElementById('daily-plan-select'), itemTemplate: document.getElementById('item-template') }; // Helpers function getWeekRange(date = new Date()) { const day = date.getDay() || 7; // 1 (Mon) - 7 (Sun) const start = new Date(date); start.setDate(date.getDate() - day + 1); const end = new Date(start); end.setDate(start.getDate() + 6); const fmt = d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; return { start: fmt(start), end: fmt(end) }; } function naturalCompare(a, b) { const na = String(a || ''); const nb = String(b || ''); const ra = /^\s*(\d+)/.exec(na); const rb = /^\s*(\d+)/.exec(nb); if (ra && rb) { const diff = parseInt(ra[1],10) - parseInt(rb[1],10); if (diff !== 0) return diff; } else if (ra) return -1; else if (rb) return 1; return na.localeCompare(nb, 'zh-CN', { numeric: true, sensitivity: 'base' }); } function formatDate(dateStr) { return dateStr.split('T')[0]; } const WEEKLY_MUST_RULES = [ { key: '轻断食', label: '轻断食', required: 2, keywords: ['轻断食'] }, { key: '跑步日', label: '跑步日', required: 1, keywords: ['跑步日'] }, { key: '高强组', label: '高强组', required: 1, keywords: ['高强组'] }, { key: '家务清洁', label: '家务清洁', required: 1, keywords: ['家务清洁', '家务日'] } ]; function updateWeeklyMustWarning(records) { const box = document.getElementById('weekly-must-warning'); if (!box) return; const counts = {}; WEEKLY_MUST_RULES.forEach(r => { counts[r.key] = 0; }); let hasHealthData = false; (records || []).forEach(r => { if (r.diastolic_bp || r.systolic_bp || r.heart_rate || r.waist_circ || r.weight_kg) { hasHealthData = true; } const name = String(r.plan_name || r.daily_plan || '').trim(); if (!name) return; WEEKLY_MUST_RULES.forEach(rule => { const keywords = Array.isArray(rule.keywords) ? rule.keywords : [rule.key]; if (keywords.some(k => name.includes(k))) counts[rule.key] += 1; }); }); const missing = WEEKLY_MUST_RULES.map(rule => { const left = Math.max(0, rule.required - (counts[rule.key] || 0)); return left > 0 ? `${left}次“${rule.label}”` : ''; }).filter(Boolean); if (!hasHealthData) { missing.push('1次“健康数据(血压/心率/腰围/体重)”'); } if (missing.length) { box.textContent = `❗本周尚缺${missing.join(',')}❗`; box.style.display = ''; } else { box.textContent = ''; box.style.display = 'none'; } } function showTab(tab) { state.currentTab = tab; document.querySelectorAll('.tab-btn').forEach(b => { b.classList.toggle('active', b.dataset.tab === tab); }); document.querySelectorAll('.tab-content').forEach(s => { s.classList.toggle('active', s.id === `section-${tab}`); }); if (tab === 'records') { loadRecords(); loadStats(); } else if (tab === 'weekly') { loadWeeklyProgress(); } else if (tab === 'calendar') { ensureCalendarInit(); renderCalendar(); } else if (tab === 'stats') { renderStats(); } else if (tab === 'screen') { renderScreen(); } else if (tab === 'settings') { renderSettings(); } } function ensureCalendarInit() { if (!state.calYear || !state.calMonth) { const d = new Date(); state.calYear = d.getFullYear(); state.calMonth = d.getMonth() + 1; } } function monthLastDay(y, m) { return new Date(y, m, 0).getDate(); } function getMonthWeeks(y, m) { const first = new Date(y, m - 1, 1); const last = new Date(y, m, 0); const days = []; for (let d = 1; d <= last.getDate(); d++) { const dt = new Date(y, m - 1, d); days.push(dt); } const weeks = []; let week = []; for (let i = 0; i < days.length; i++) { const d = days[i]; if (week.length === 0) { const w = (d.getDay() + 6) % 7; for (let k = 0; k < w; k++) week.push(null); } week.push(d); if (week.length === 7) { weeks.push(week); week = []; } } if (week.length) { while (week.length < 7) week.push(null); weeks.push(week); } return weeks; } // Waiting Box Helpers function showWaitingBox(title, steps) { const modal = document.getElementById('waiting-modal'); const titleEl = document.getElementById('waiting-title'); const stepsEl = document.getElementById('waiting-steps'); if (!modal || !titleEl || !stepsEl) return null; titleEl.textContent = title; stepsEl.innerHTML = steps.map((step, index) => `
${step}
`).join(''); modal.classList.add('active'); return { updateStep: (index, status) => { const el = document.getElementById(`waiting-step-${index}`); if (el) { el.className = `waiting-step ${status}`; const icon = el.querySelector('.step-icon'); if (status === 'done') icon.innerHTML = '✓'; else if (status === 'error') icon.innerHTML = '×'; else if (status === 'active') icon.innerHTML = '
'; else icon.innerHTML = '
'; // pending } }, close: () => modal.classList.remove('active') }; } // Init async function init() { const waitingBox = showWaitingBox('初始化运动日志工具...', [ '设置默认周筛选条件...', '绑定事件监听器...', '加载运动类型...', '加载每日计划...', '显示记录标签...' ]); try { if (waitingBox) waitingBox.updateStep(0, 'active'); // Set default filter to current week const range = getWeekRange(); state.filterStart = range.start; state.filterEnd = range.end; state.statsStart = range.start; state.statsEnd = range.end; els.filterStart.value = state.filterStart; els.filterEnd.value = state.filterEnd; if (waitingBox) waitingBox.updateStep(0, 'done'); if (waitingBox) waitingBox.updateStep(1, 'active'); // Bind Events document.querySelectorAll('.tab-btn').forEach(b => { b.addEventListener('click', () => showTab(b.dataset.tab)); }); els.btnFilter.addEventListener('click', async () => { const waitingBox = showWaitingBox('加载数据...', ['加载记录...', '加载统计...']); try { if (waitingBox) waitingBox.updateStep(0, 'active'); state.filterStart = els.filterStart.value; state.filterEnd = els.filterEnd.value; await loadRecords(); if (waitingBox) waitingBox.updateStep(0, 'done'); if (waitingBox) waitingBox.updateStep(1, 'active'); // Also update stats if user manually filters? // Usually stats cards are "Weekly" but user might want to see stats for filter range. // Let's update stats to match filter for better UX. state.statsStart = state.filterStart; state.statsEnd = state.filterEnd; await loadStats(); if (waitingBox) waitingBox.updateStep(1, 'done'); } finally { if (waitingBox) setTimeout(() => waitingBox.close(), 300); } }); els.btnThisWeek.addEventListener('click', () => setWeek(0)); els.btnPrevWeek.addEventListener('click', () => setWeek(-1)); els.btnNextWeek.addEventListener('click', () => setWeek(1)); els.btnAddModal.addEventListener('click', openModal); els.btnCloseModal.addEventListener('click', closeModal); els.addItemBtn.addEventListener('click', () => addItemRow()); els.recordForm.addEventListener('submit', handleSubmit); const btnPrevMonth = document.getElementById('btn-prev-month'); const btnNextMonth = document.getElementById('btn-next-month'); btnPrevMonth.addEventListener('click', () => { ensureCalendarInit(); state.calMonth -= 1; if (state.calMonth <= 0) { state.calMonth = 12; state.calYear -= 1; } renderCalendar(); }); btnNextMonth.addEventListener('click', () => { ensureCalendarInit(); state.calMonth += 1; if (state.calMonth > 12) { state.calMonth = 1; state.calYear += 1; } renderCalendar(); }); const sStart = document.getElementById('stats-start'); const sEnd = document.getElementById('stats-end'); document.getElementById('btn-stats').addEventListener('click', () => { state.statsStart = sStart.value; state.statsEnd = sEnd.value; renderStats(); }); document.getElementById('btn-export').addEventListener('click', () => { const ss = sStart.value; const ee = sEnd.value; if (!ss || !ee) return; window.location.href = `${API_BASE}/export?start=${ss}&end=${ee}`; }); document.getElementById('s-prev-week').addEventListener('click', () => { const base = sStart.value ? new Date(sStart.value) : new Date(); base.setDate(base.getDate() - 7); const rr = getWeekRange(base); sStart.value = rr.start; sEnd.value = rr.end; state.statsStart = rr.start; state.statsEnd = rr.end; renderStats(); }); document.getElementById('s-this-week').addEventListener('click', () => { const rr = getWeekRange(new Date()); sStart.value = rr.start; sEnd.value = rr.end; state.statsStart = rr.start; state.statsEnd = rr.end; renderStats(); }); document.getElementById('s-next-week').addEventListener('click', () => { const base = sStart.value ? new Date(sStart.value) : new Date(); base.setDate(base.getDate() + 7); const rr = getWeekRange(base); sStart.value = rr.start; sEnd.value = rr.end; state.statsStart = rr.start; state.statsEnd = rr.end; renderStats(); }); document.getElementById('s-prev-month').addEventListener('click', () => { const base = sStart.value ? new Date(sStart.value) : new Date(); const first = new Date(base.getFullYear(), base.getMonth() - 1, 1); const last = new Date(base.getFullYear(), base.getMonth(), 0); const fmt = d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; sStart.value = fmt(first); sEnd.value = fmt(last); state.statsStart = fmt(first); state.statsEnd = fmt(last); renderStats(); }); document.getElementById('s-this-month').addEventListener('click', () => { const t = new Date(); const first = new Date(t.getFullYear(), t.getMonth(), 1); const last = new Date(t.getFullYear(), t.getMonth() + 1, 0); const fmt = d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; sStart.value = fmt(first); sEnd.value = fmt(last); state.statsStart = fmt(first); state.statsEnd = fmt(last); renderStats(); }); document.getElementById('s-next-month').addEventListener('click', () => { const base = sStart.value ? new Date(sStart.value) : new Date(); const first = new Date(base.getFullYear(), base.getMonth() + 1, 1); const last = new Date(base.getFullYear(), base.getMonth() + 2, 0); const fmt = d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; sStart.value = fmt(first); sEnd.value = fmt(last); state.statsStart = fmt(first); state.statsEnd = fmt(last); renderStats(); }); document.getElementById('btn-close-detail').addEventListener('click', () => document.getElementById('record-detail-modal').classList.remove('active')); document.getElementById('record-detail-modal').addEventListener('click', (e) => { if (e.target === document.getElementById('record-detail-modal')) document.getElementById('record-detail-modal').classList.remove('active'); }); if (waitingBox) waitingBox.updateStep(1, 'done'); if (waitingBox) waitingBox.updateStep(2, 'active'); await loadTypes(); if (waitingBox) waitingBox.updateStep(2, 'done'); if (waitingBox) waitingBox.updateStep(3, 'active'); await loadDailyPlans(); if (waitingBox) waitingBox.updateStep(3, 'done'); if (waitingBox) waitingBox.updateStep(4, 'active'); showTab('records'); if (waitingBox) waitingBox.updateStep(4, 'done'); } catch (e) { if (waitingBox) waitingBox.updateStep(4, 'error'); console.error(e); } finally { if (waitingBox) setTimeout(() => waitingBox.close(), 500); } } async function setWeek(offset) { const waitingBox = showWaitingBox('切换周...', ['计算日期范围...', '加载数据...']); try { if (waitingBox) waitingBox.updateStep(0, 'active'); let baseDate = new Date(state.filterStart || new Date()); if (offset === 0) baseDate = new Date(); // This week else baseDate.setDate(baseDate.getDate() + (offset * 7)); // Prev/Next week const range = getWeekRange(baseDate); state.filterStart = range.start; state.filterEnd = range.end; state.statsStart = range.start; state.statsEnd = range.end; els.filterStart.value = state.filterStart; els.filterEnd.value = state.filterEnd; if (waitingBox) waitingBox.updateStep(0, 'done'); if (waitingBox) waitingBox.updateStep(1, 'active'); await loadRecords(); await loadStats(); if (waitingBox) waitingBox.updateStep(1, 'done'); } finally { if (waitingBox) setTimeout(() => waitingBox.close(), 300); } } // Data Loading async function loadTypes() { const res = await fetch(`${API_BASE}/types`).then(r => r.json()); if (res.ok) { state.types = res.data; } else { throw new Error(res.error || '加载运动类型失败'); } } async function loadDailyPlans() { const res = await fetch(`${API_BASE}/daily_plans`).then(r => r.json()); if (res.ok) { state.dailyPlans = res.data; renderDailyPlans(); } else { throw new Error(res.error || '加载每日计划失败'); } } async function loadRecords() { const params = new URLSearchParams({ start: state.filterStart, end: state.filterEnd }); params.set('t', Date.now()); const res = await fetch(`${API_BASE}/records?${params}`, { cache: 'no-store' }).then(r => r.json()); if (res.ok) { state.records = res.data; renderRecords(); } else { throw new Error(res.error || '加载记录失败'); } } async function loadStats() { const params = new URLSearchParams({ start: state.statsStart, end: state.statsEnd }); params.set('t', Date.now()); const res = await fetch(`${API_BASE}/stats?${params}`, { cache: 'no-store' }).then(r => r.json()); if (res.ok) { const s = res.stats; els.statCalories.textContent = s.sum_calories; els.statDuration.innerHTML = `${s.sum_duration}分钟`; els.statStand.textContent = s.sum_stand; } } // Rendering function renderDailyPlans() { const plans = state.dailyPlans.slice().sort((a,b)=>naturalCompare(a.name,b.name)); els.dailyPlanSelect.innerHTML = '' + plans.map(p => ``).join(''); } function renderRecords() { updateWeeklyMustWarning(state.records); if (state.records.length === 0) { els.recordList.innerHTML = '暂无记录'; return; } els.recordList.innerHTML = state.records.map(r => { const itemsHtml = r.items && r.items.length > 0 ? r.items.map(i => `${i.type_name || i.name}: ${i.quantity}${i.unit}`).join('
') : ''; const bp = r.systolic_bp || r.diastolic_bp ? `${r.systolic_bp || '-'}/${r.diastolic_bp || '-'}` : '' const hr = r.heart_rate ? `${r.heart_rate}bpm` : '' const waist = r.waist_circ ? `${r.waist_circ}cm` : '' const weight = r.weight_kg ? `${r.weight_kg}kg` : '' const healthLines = [] if (bp) healthLines.push(`❤️ 血压 ${bp}`) if (hr) healthLines.push(`🩺 心率 ${hr}`) if (waist) healthLines.push(`📐 腰围 ${waist}`) if (weight) healthLines.push(`⚖️ 体重 ${weight}`) const healthHtml = healthLines.length > 0 ? healthLines.join('
') : '' let detailHtml = '' if (itemsHtml && healthHtml) { detailHtml = itemsHtml + '
' + healthHtml } else if (itemsHtml) { detailHtml = itemsHtml } else if (healthHtml) { detailHtml = healthHtml } else { detailHtml = '-' } return ` ${formatDate(r.record_date)} ${r.plan_name ? `${r.plan_name}` : '-'} ${r.calories} ${r.exercise_duration} ${r.stand_count} ${detailHtml} `; }).join(''); } // Modal Actions async function openModal() { els.modal.classList.add('active'); try { document.body.style.overflow = 'hidden'; } catch {} // Set default date to today const d = new Date(); const today = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; els.recordForm.querySelector('[name="record_date"]').value = today; // Clear items els.itemsContainer.innerHTML = ''; // Add one empty item row addItemRow(); // Auto-fill missing records for previous days try { const res = await fetch(`${API_BASE}/records/auto-fill-missing`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, cache: 'no-store' }).then(r => r.json()); if (res.ok && res.filled > 0) { console.log(`[FitnessDiary] ${res.message}`); // Refresh the list to show filled records await loadRecords(); await loadStats(); } } catch (e) { console.error('[FitnessDiary] Auto-fill check failed:', e); } } function closeModal() { els.modal.classList.remove('active'); try { document.body.style.overflow = ''; } catch {} } function addItemRow() { const clone = els.itemTemplate.content.cloneNode(true); const row = clone.querySelector('.item-row'); const select = row.querySelector('.item-type-select'); const unitSpan = row.querySelector('.item-unit'); // Populate types const typesSorted = state.types; typesSorted.forEach(t => { const opt = document.createElement('option'); opt.value = t.id; opt.textContent = t.name; opt.dataset.unit = t.unit; select.appendChild(opt); }); // Unit update logic select.addEventListener('change', (e) => { const opt = e.target.selectedOptions[0]; unitSpan.textContent = opt.dataset.unit || '单位'; }); row.querySelector('.btn-remove').addEventListener('click', () => row.remove()); els.itemsContainer.appendChild(row); } // Submit async function handleSubmit(e) { e.preventDefault(); const fd = new FormData(e.target); const diastolic = fd.get('diastolic_bp'); const systolic = fd.get('systolic_bp'); const heartRate = fd.get('heart_rate'); const waist = fd.get('waist_circ'); const weight = fd.get('weight_kg'); if ((diastolic && !systolic) || (!diastolic && systolic)) { alert('舒张压和收缩压必须同时填写,或者同时不填。'); return; } const payload = { record_date: fd.get('record_date'), daily_plan_id: fd.get('daily_plan_id'), calories: parseInt(fd.get('calories') || 0), exercise_duration: parseInt(fd.get('exercise_duration') || 0), stand_count: parseInt(fd.get('stand_count') || 0), diastolic_bp: diastolic ? parseInt(diastolic) : null, systolic_bp: systolic ? parseInt(systolic) : null, heart_rate: heartRate ? parseInt(heartRate) : null, waist_circ: waist ? parseInt(waist) : null, weight_kg: weight ? parseFloat(weight) : null, items: [] }; // Collect items const itemRows = els.itemsContainer.querySelectorAll('.item-row'); itemRows.forEach(row => { const typeId = row.querySelector('.item-type-select').value; const qty = row.querySelector('.item-quantity').value; const unit = row.querySelector('.item-unit').textContent; if (typeId && qty) { payload.items.push({ exercise_type_id: parseInt(typeId), quantity: parseInt(qty), unit: unit }); } }); const isEdit = !!els.recordForm.dataset.editId; const waitingBox = showWaitingBox(isEdit ? '更新记录...' : '创建记录...', [ '验证表单数据...', '提交数据到服务器...', '同步到周计划...', '刷新列表...' ]); try { if (waitingBox) waitingBox.updateStep(0, 'active'); // Simple validation (already done by HTML5 mostly, but good to have) if (waitingBox) waitingBox.updateStep(0, 'done'); if (waitingBox) waitingBox.updateStep(1, 'active'); const editId = els.recordForm.dataset.editId || ''; const url = editId ? `${API_BASE}/records/${editId}` : `${API_BASE}/records`; const method = editId ? 'PUT' : 'POST'; const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), cache: 'no-store' }).then(r => r.json()); if (res.ok) { if (waitingBox) waitingBox.updateStep(1, 'done'); if (waitingBox) waitingBox.updateStep(2, 'active'); if (res.push && res.push.success) { if (waitingBox) waitingBox.updateStep(2, 'done'); } else if (res.push && !res.push.success) { if (waitingBox) waitingBox.updateStep(2, 'error'); console.warn('Push failed:', res.push.message); // We don't block success, but we show error // Actually, let's just mark it as error in the step but continue } else { // No push info or skipped if (waitingBox) waitingBox.updateStep(2, 'done'); } if (waitingBox) waitingBox.updateStep(3, 'active'); const editIdNow = els.recordForm.dataset.editId || ''; if (editIdNow) { // Optimistic update (or just reload) // Since we are reloading anyway, we can skip complex local update logic if we trust loadRecords // But the existing code had local update logic. Let's keep it or just rely on loadRecords. // The existing code did local update AND loadRecords. } closeModal(); els.recordForm.dataset.editId = ''; await loadRecords(); await loadStats(); if (waitingBox) waitingBox.updateStep(3, 'done'); setTimeout(() => { if (waitingBox) waitingBox.close(); if (res.push && !res.push.success) { alert(`保存成功,但同步到周计划失败: ${res.push.message}`); } else { // Optional: alert('保存成功!'); } }, 500); } else { if (waitingBox) waitingBox.updateStep(1, 'error'); setTimeout(() => { if (waitingBox) waitingBox.close(); alert('保存失败: ' + res.error); }, 500); } } catch (e) { if (waitingBox) waitingBox.updateStep(1, 'error'); console.error(e); setTimeout(() => { if (waitingBox) waitingBox.close(); alert('保存出错'); }, 500); } } async function deleteRecord(id) { if (!confirm('确认删除该记录?')) return; const waitingBox = showWaitingBox('删除记录...', [ '发送删除请求...', '刷新列表...' ]); try { if (waitingBox) waitingBox.updateStep(0, 'active'); const res = await fetch(`${API_BASE}/records/${id}`, { method: 'DELETE', cache: 'no-store' }).then(r => r.json()); if (res.ok) { if (waitingBox) waitingBox.updateStep(0, 'done'); if (waitingBox) waitingBox.updateStep(1, 'active'); await loadRecords(); await loadStats(); if (waitingBox) waitingBox.updateStep(1, 'done'); setTimeout(() => { if (waitingBox) waitingBox.close(); }, 500); } else { if (waitingBox) waitingBox.updateStep(0, 'error'); setTimeout(() => { if (waitingBox) waitingBox.close(); alert('删除失败: ' + res.error); }, 500); } } catch (e) { if (waitingBox) waitingBox.updateStep(0, 'error'); setTimeout(() => { if (waitingBox) waitingBox.close(); alert('删除出错'); }, 500); } } // Make deleteRecord global window.deleteRecord = deleteRecord; window.editRecord = async function(id) { try { const res = await fetch(`${API_BASE}/record/${id}`).then(r => r.json()); const data = res && (res.ok === false ? null : res); if (!data || !data.id) return; const modal = document.getElementById('record-detail-modal'); const box = document.getElementById('record-detail-content'); const items = Array.isArray(data.items) ? data.items : []; const lines = []; lines.push(`
${data.record_date}
${data.daily_plan||''}
${data.calories}
`); lines.push(`
${data.exercise_duration}
${data.stand_count}
`); // Add health data if (data.diastolic_bp || data.systolic_bp || data.heart_rate || data.waist_circ || data.weight_kg) { lines.push(`
${data.diastolic_bp || '-'}
${data.systolic_bp || '-'}
${data.heart_rate || '-'}
${data.waist_circ || '-'}
${data.weight_kg || '-'}
`); } lines.push(`

项目明细

`); if (items.length) { lines.push(''); lines.push(items.map(it=>``).join('')); lines.push('
项目数量单位
${it.name}${it.quantity}${it.unit}
'); } else { lines.push('
暂无明细
'); } box.innerHTML = lines.join(''); modal.classList.add('active'); } catch (e) {} }; window.openEditRecord = async function(id) { try { const res = await fetch(`${API_BASE}/record/${id}`).then(r => r.json()); const data = res && (res.ok === false ? null : res); if (!data || !data.id) return; els.modal.classList.add('active'); els.recordForm.dataset.editId = String(id); els.recordForm.querySelector('[name="record_date"]').value = String(data.record_date).slice(0,10); const planId = data.daily_plan_id || ''; els.dailyPlanSelect.value = planId || ''; els.recordForm.querySelector('[name="calories"]').value = data.calories||0; els.recordForm.querySelector('[name="exercise_duration"]').value = data.exercise_duration||0; els.recordForm.querySelector('[name="stand_count"]').value = data.stand_count||0; els.recordForm.querySelector('[name="diastolic_bp"]').value = data.diastolic_bp||''; els.recordForm.querySelector('[name="systolic_bp"]').value = data.systolic_bp||''; els.recordForm.querySelector('[name="heart_rate"]').value = data.heart_rate||''; els.recordForm.querySelector('[name="waist_circ"]').value = data.waist_circ||''; els.recordForm.querySelector('[name="weight_kg"]').value = data.weight_kg||''; els.itemsContainer.innerHTML = ''; const rows = Array.isArray(data.items)?data.items:[]; if (rows.length===0) addItemRow(); rows.forEach(it=>{ const clone = els.itemTemplate.content.cloneNode(true); const row = clone.querySelector('.item-row'); const select = row.querySelector('.item-type-select'); const unitSpan = row.querySelector('.item-unit'); state.types.forEach(t => { const opt = document.createElement('option'); opt.value = t.id; opt.textContent = t.name; opt.dataset.unit = t.unit; if (String(t.id)===String(it.exercise_type_id)) opt.selected = true; select.appendChild(opt); }); unitSpan.textContent = it.unit || '单位'; row.querySelector('.item-quantity').value = it.quantity || 0; select.addEventListener('change', (e) => { const opt = e.target.selectedOptions[0]; unitSpan.textContent = opt.dataset.unit || '单位'; }); row.querySelector('.btn-remove').addEventListener('click', () => row.remove()); els.itemsContainer.appendChild(row); }); } catch (e) {} }; // Start clearUrlQuery(); init(); async function loadWeeklyProgress() { const d = new Date(); const today = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; const res = await fetch(`${API_BASE}/weekly_progress?date=${today}`).then(r => r.json()); if (!res || res.ok === false) return; document.getElementById('weekly-range').textContent = `周范围:${res.week_start} - ${res.week_end}`; const list = document.getElementById('weekly-progress'); const arr = Array.isArray(res.progress) ? res.progress : []; const sorted = arr.slice().sort((a,b)=>{ const na = String(a.name||''); const nb = String(b.name||''); const ra = /^\s*(\d+)/.exec(na); const rb = /^\s*(\d+)/.exec(nb); if (ra && rb) { const diff = parseInt(ra[1],10) - parseInt(rb[1],10); if (diff !== 0) return diff; } else if (ra) return -1; else if (rb) return 1; return na.localeCompare(nb, 'zh-CN', { numeric: true, sensitivity: 'base' }); }); list.innerHTML = sorted.map(item => { const done = item.done || 0; const goal = item.weekly_goal || 0; const remaining = Math.max(0, goal - done); const flag = remaining === 0 ? `
` : ''; const cls = done === 0 ? '' : (goal > 0 && remaining === 0 ? 'card-complete' : (goal > 0 && remaining > 0 ? 'card-incomplete' : '')); return `
${flag}${item.name}
剩余 ${remaining} ${item.unit}(目标 ${goal}, 已完成 ${done})
`; }).join(''); } function renderCalendar() { const title = document.getElementById('calendar-title'); const body = document.getElementById('calendar-body'); title.textContent = `${state.calYear}年${String(state.calMonth).padStart(2,'0')}月`; const weeks = getMonthWeeks(state.calYear, state.calMonth); const first = `${state.calYear}-${String(state.calMonth).padStart(2,'0')}-01`; const last = `${state.calYear}-${String(state.calMonth).padStart(2,'0')}-${String(monthLastDay(state.calYear,state.calMonth)).padStart(2,'0')}`; fetch(`${API_BASE}/records?start=${first}&end=${last}&t=${Date.now()}`, { cache: 'no-store' }).then(r=>r.json()).then(res=>{ const records = Array.isArray(res.data) ? res.data : []; const map = {}; records.forEach(r=>{ const d = String(r.record_date).slice(0,10); map[d] = r; }); body.innerHTML = weeks.map(week=>{ return `` + week.map(d=>{ if (!d) return ``; const ds = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; const rec = map[ds]; if (rec) { const itemsCount = Array.isArray(rec.items) ? rec.items.length : 0; const details = Array.isArray(rec.items) ? rec.items.map(it=>`${it.type_name||it.name} ${it.quantity} ${it.unit}`).join('
') : ''; return `
${d.getDate()}
计划:${rec.plan_name||'—'}
千卡:${rec.calories}
项目:${itemsCount}
项目内容:
${details}
`; } else { return `
${d.getDate()}
空
`; } }).join('') + ``; }).join(''); }); } function renderStats() { const s = document.getElementById('stats-start'); const e = document.getElementById('stats-end'); if (!s.value || !e.value) { s.value = state.statsStart; e.value = state.statsEnd; } const params = new URLSearchParams({ start: s.value, end: e.value }); fetch(`${API_BASE}/records?${params}`).then(r=>r.json()).then(res=>{ const records = Array.isArray(res.data)?res.data:[]; let total_calories=0,total_duration=0,total_stand=0; const weekly = {}; const rowsHtml = records.map(r=>{ total_calories += r.calories||0; total_duration += r.exercise_duration||0; total_stand += r.stand_count||0; const d = new Date(String(r.record_date).slice(0,10)+'T00:00:00'); const isoYear = d.getFullYear(); const tmp = new Date(d); const day = tmp.getDay()||7; tmp.setDate(tmp.getDate()-day+1); const monday = `${tmp.getFullYear()}-W${Math.floor((new Date(tmp.getFullYear(), tmp.getMonth(), tmp.getDate() - ((tmp.getDay()+6)%7)).getTime() - new Date(tmp.getFullYear(),0,1).getTime())/604800000)+1}`; weekly[monday] = weekly[monday] || { calories:0, duration:0, stand:0 }; weekly[monday].calories += r.calories||0; weekly[monday].duration += r.exercise_duration||0; weekly[monday].stand += r.stand_count||0; const itemsCount = Array.isArray(r.items)?r.items.length:0; return `${String(r.record_date).slice(0,10)}${r.plan_name||''}${r.calories||0}${r.exercise_duration||0}${r.stand_count||0}${itemsCount}`; }).join(''); document.getElementById('stats-total-cal').textContent = total_calories; document.getElementById('stats-total-dur').textContent = total_duration; document.getElementById('stats-total-stand').textContent = total_stand; document.getElementById('stats-records').innerHTML = rowsHtml || `所选范围内暂无记录`; const totalDays = Math.max(1, (new Date(e.value).getTime() - new Date(s.value).getTime())/86400000 + 1); const recordedDays = new Set(records.map(r=>String(r.record_date).slice(0,10))).size; const emptyDays = Math.max(0, totalDays - recordedDays); const labels = Object.keys(weekly).sort(); const cal = labels.map(l=>weekly[l].calories); const dur = labels.map(l=>weekly[l].duration); const st = labels.map(l=>weekly[l].stand); const pieCanvas = document.getElementById('pieDays'); const barCanvas = document.getElementById('barWeekly'); if (window.Chart && pieCanvas && barCanvas && pieCanvas.getContext && barCanvas.getContext) { const pieCtx = pieCanvas.getContext('2d'); new Chart(pieCtx, { type:'pie', data:{ labels:['记录天数','空缺天数'], datasets:[{ data:[recordedDays, emptyDays], backgroundColor:['#00a8ff','#ff9f43'], borderColor:['#fff','#fff'], borderWidth:2 }]} , options:{ responsive:true, plugins:{ legend:{ position:'bottom' }, tooltip:{ enabled:true, callbacks:{ label: (ctx)=> `${ctx.label}: ${ctx.parsed||0} 天` } } } } }); const barCtx = barCanvas.getContext('2d'); new Chart(barCtx, { type:'bar', data:{ labels, datasets:[{ label:'千卡', data:cal, backgroundColor:'#00a8ff'},{ label:'时长(分钟)', data:dur, backgroundColor:'#00d8a7'},{ label:'站立次数', data:st, backgroundColor:'#ff9f43'}] }, options:{ responsive:true, interaction:{ mode:'index', intersect:false }, plugins:{ tooltip:{ enabled:true, callbacks:{ title: (items)=> String(items[0]?.label||''), label: (ctx)=> { const v = ctx.parsed.y||0; const lab = ctx.dataset?.label||''; if (lab.indexOf('时长')>=0) return `时长:${v} 分钟`; if (lab.indexOf('站立')>=0) return `站立:${v} 次`; return `千卡:${v}`; } } } } } }); } else { // Fallback minimal charts if (pieCanvas && pieCanvas.getContext) { const ctx = pieCanvas.getContext('2d'); const w = pieCanvas.width, h = pieCanvas.height, r = Math.min(w,h)/2 - 6; const cx = w/2, cy = h/2; ctx.clearRect(0,0,w,h); const total = recordedDays + emptyDays || 1; const angles = [recordedDays/total*2*Math.PI, emptyDays/total*2*Math.PI]; let start = -Math.PI/2; const colors = ['#00a8ff','#ff9f43']; for (let i=0;i<2;i++){ ctx.beginPath(); ctx.moveTo(cx,cy); ctx.arc(cx,cy,r,start,start+angles[i]); ctx.closePath(); ctx.fillStyle = colors[i]; ctx.fill(); start += angles[i]; } } if (barCanvas && barCanvas.getContext) { const ctx = barCanvas.getContext('2d'); const w = barCanvas.width, h = barCanvas.height; ctx.clearRect(0,0,w,h); const pad = 24; const plotW = w - pad*2; const plotH = h - pad*2; const n = labels.length || 1; const barW = plotW / n / 4; // 3 series + gap const maxVal = Math.max(1, Math.max(...cal, ...dur, ...st)); function drawSeries(data,color,offset){ for(let i=0;ix.json()).then(res=>{ const records = Array.isArray(res.data)?res.data:[]; const weekLabels = ['周一','周二','周三','周四','周五','周六','周日']; const weekStart = new Date(r.start+'T00:00:00'); const kc = [0,0,0,0,0,0,0]; const du = [0,0,0,0,0,0,0]; const st = [0,0,0,0,0,0,0]; const doneMap = {}; state.types.forEach(t=>{ doneMap[t.id]=0; }); records.forEach(rr=>{ const idx = Math.floor((new Date(String(rr.record_date).slice(0,10)).getTime() - weekStart.getTime())/86400000); if (idx>=0 && idx<7) { kc[idx] += rr.calories||0; du[idx] += rr.exercise_duration||0; st[idx] += rr.stand_count||0; } (rr.items||[]).forEach(it=>{ if (doneMap[it.exercise_type_id]!=null) doneMap[it.exercise_type_id]+=it.quantity||0; }); }); const compArr = []; Object.keys(doneMap).forEach(id=>{ const t = state.types.find(x=>String(x.id)===String(id)); const v = doneMap[id]||0; if (t && v>0) { compArr.push({ name: t.name, value: v }); } }); compArr.sort((a,b)=>naturalCompare(a.name,b.name)); const compNames = compArr.map(x=>x.name); const compValues = compArr.map(x=>x.value); const today = new Date(); const monthlyLabels=[], monthlyCalories=[], monthlyDuration=[]; for (let i=11; i>=0; i--) { const y = new Date(today.getFullYear(), today.getMonth()-i, 1).getFullYear(); const m = new Date(today.getFullYear(), today.getMonth()-i, 1).getMonth()+1; const start = `${y}-${String(m).padStart(2,'0')}-01`; const end = `${y}-${String(m).padStart(2,'0')}-${String(monthLastDay(y,m)).padStart(2,'0')}`; monthlyLabels.push(`${y}-${String(m).padStart(2,'0')}`); // Aggregate monthlyCalories.push(0); monthlyDuration.push(0); } const monthsMap = {}; monthlyLabels.forEach((lab, idx)=>{ monthsMap[lab]={ idx }; }); const allStart = monthlyLabels[0]+'-01'; const allEnd = monthlyLabels[monthlyLabels.length-1]+'-31'; fetch(`${API_BASE}/records?start=${allStart}&end=${allEnd}`).then(r=>r.json()).then(res2=>{ const arr = Array.isArray(res2.data)?res2.data:[]; arr.forEach(R=>{ const d = new Date(String(R.record_date).slice(0,10)); const lab = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`; if (monthsMap[lab]) { const idx = monthsMap[lab].idx; monthlyCalories[idx] += R.calories||0; monthlyDuration[idx] += R.exercise_duration||0; } }); if (window.Chart) { const lc = document.getElementById('lineCal').getContext('2d'); new Chart(lc, { type:'line', data:{ labels:weekLabels, datasets:[{ label:'千卡', data:kc, borderColor:'#4fc3f7', backgroundColor:'rgba(79,195,247,0.2)', tension:0.4, borderWidth:3, pointRadius:3, pointHoverRadius:5, pointHitRadius:10 }] }, options:{ interaction:{ mode:'index', intersect:false }, plugins:{ legend:{ display:false }, tooltip: { enabled: true, callbacks: { title: (items)=> String(items[0]?.label||''), label: (ctx)=> `千卡:${ctx.parsed.y||0}` } } } } }); const ld = document.getElementById('lineDur').getContext('2d'); new Chart(ld, { type:'line', data:{ labels:weekLabels, datasets:[{ label:'时长', data:du, borderColor:'#7c4dff', backgroundColor:'rgba(124,77,255,0.2)', tension:0.4, borderWidth:3, pointRadius:3, pointHoverRadius:5, pointHitRadius:10 }] }, options:{ interaction:{ mode:'index', intersect:false }, plugins:{ legend:{ display:false }, tooltip: { enabled: true, callbacks: { title: (items)=> String(items[0]?.label||''), label: (ctx)=> `时长:${ctx.parsed.y||0} 分钟` } } } } }); const ls = document.getElementById('lineStand').getContext('2d'); new Chart(ls, { type:'line', data:{ labels:weekLabels, datasets:[{ label:'站立', data:st, borderColor:'#ffb300', backgroundColor:'rgba(255,179,0,0.2)', tension:0.4, borderWidth:3, pointRadius:3, pointHoverRadius:5, pointHitRadius:10 }] }, options:{ interaction:{ mode:'index', intersect:false }, plugins:{ legend:{ display:false }, tooltip: { enabled: true, callbacks: { title: (items)=> String(items[0]?.label||''), label: (ctx)=> `站立:${ctx.parsed.y||0} 次` } } } } }); const pc = document.getElementById('pieComp').getContext('2d'); new Chart(pc, { type:'pie', data:{ labels:compNames, datasets:[{ data:compValues, backgroundColor:['#4fc3f7','#7c4dff','#ffb300','#00d8a7','#ff6b6b','#ab47bc','#26a69a','#feca57'], borderColor:'#fff', borderWidth:2 }] }, options:{ plugins:{ tooltip:{ callbacks:{ label: (ctx)=> `${ctx.label}: ${ctx.parsed||0}` } } } } }); const bm = document.getElementById('barMonthly').getContext('2d'); new Chart(bm, { type:'bar', data:{ labels:monthlyLabels, datasets:[{ label:'千卡', data:monthlyCalories, backgroundColor:'#4fc3f7'},{ label:'时长', data:monthlyDuration, backgroundColor:'#7c4dff'}] }, options:{ responsive:true, interaction:{ mode:'index', intersect:false }, plugins:{ tooltip:{ enabled: true, callbacks:{ title: (items)=> String(items[0]?.label||''), label: (ctx)=> { const v = ctx.parsed.y||0; const lab = ctx.dataset?.label||''; return lab === '时长' ? `时长:${v} 分钟` : `千卡:${v}`; } } } } } }); } else { const lc = document.getElementById('lineCal'); const ld = document.getElementById('lineDur'); const ls = document.getElementById('lineStand'); const pc = document.getElementById('pieComp'); const bm = document.getElementById('barMonthly'); function drawLine(canvas, data, color) { if (!canvas || !canvas.getContext) return; const ctx = canvas.getContext('2d'); const w = canvas.width, h = canvas.height; ctx.clearRect(0,0,w,h); const pad = 24; const plotW = w - pad*2, plotH = h - pad*2; const n = data.length || 1; const maxVal = Math.max(1, Math.max(...data)); ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.beginPath(); for (let i=0;ia+b,0) || 1; let start = -Math.PI/2; const colors = ['#4fc3f7','#7c4dff','#ffb300','#00d8a7','#ff6b6b','#ab47bc','#26a69a','#feca57']; for (let i=0;i{ const done = doneMap[t.id]||0; const goal = t.weekly_goal||0; const percent = goal>0 ? Math.min(100, Math.floor(done*100/goal)) : 0; const remaining = Math.max(0, goal - done); return { name:t.name, unit:t.unit, goal, done, percent, remaining }; }).filter(p=>p.goal>0); const sorted2 = progress.slice().sort((a,b)=>{ return naturalCompare(a.name,b.name); }); list.innerHTML = sorted2.map(p=>`
${p.name}
✅ 已完成 ${p.done} / 🎯 目标 ${p.goal} ${p.unit} ${p.remaining>0?`🔜 剩余 ${p.remaining}`:`🎉 目标已完成!`}
`).join('') || `
暂无设置目标,请在设置页面配置运动目标
`; }); }); } function renderSettings() { const tBody = document.getElementById('settings-types'); const typesSorted = state.types; tBody.innerHTML = typesSorted.map(t=>` `).join(''); const pBody = document.getElementById('settings-plans'); const plansSorted = state.dailyPlans.slice().sort((a,b)=>naturalCompare(a.name,b.name)); const rowsHtml = plansSorted.map(p=>` `).join(''); pBody.innerHTML = rowsHtml || `暂无计划`; document.getElementById('btn-save-types').onclick = async () => { const ids = state.types.map(t=>t.id); const payload = { types: ids.map(id=>{ const name = document.querySelector(`.type-name[data-type-id="${id}"]`).value.trim(); const unit = document.querySelector(`.type-unit[data-type-id="${id}"]`).value.trim(); const weekly_goal = parseInt(document.querySelector(`.type-goal[data-type-id="${id}"]`).value||0); const sort_order = parseInt(document.querySelector(`.type-sort[data-type-id="${id}"]`).value||0); return { id, name, unit, weekly_goal, sort_order }; })}; const res = await fetch(`${API_BASE}/types/update`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) }).then(r=>r.json()); if (res.ok) { alert('已保存'); await loadTypes(); renderSettings(); } else { alert(res.error||'保存失败'); } }; document.getElementById('btn-add-type').onclick = async () => { const name = document.getElementById('add-type-name').value.trim(); const unit = document.getElementById('add-type-unit').value.trim(); const weekly_goal = parseInt(document.getElementById('add-type-goal').value||0); const res = await fetch(`${API_BASE}/types/add`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ name, unit, weekly_goal }) }).then(r=>r.json()); if (res.ok) { alert('已新增'); document.getElementById('add-type-name').value=''; document.getElementById('add-type-unit').value=''; document.getElementById('add-type-goal').value='0'; await loadTypes(); renderSettings(); } else { alert(res.error||'新增失败'); } }; Array.from(document.querySelectorAll('.btn-del-type')).forEach(btn=>{ btn.onclick = async () => { const id = parseInt(btn.dataset.typeId || '0'); if (!id) return; if (!confirm('确认删除该项目?删除后不可恢复。')) return; const res = await fetch(`${API_BASE}/types/${id}`, { method:'DELETE' }).then(r=>r.json()); if (res.ok) { await loadTypes(); renderSettings(); } else { alert(res.error||'删除失败'); } }; }); document.getElementById('btn-save-plans').onclick = async () => { const ids = state.dailyPlans.map(p=>p.id); const payload = { plans: ids.map(id=>{ const name = document.querySelector(`.plan-name[data-plan-id="${id}"]`).value.trim(); return { id, name }; })}; const res = await fetch(`${API_BASE}/plans/update`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) }).then(r=>r.json()); if (res.ok) { alert('已保存'); await loadDailyPlans(); renderSettings(); } else { alert(res.error||'保存失败'); } }; document.getElementById('btn-add-plan').onclick = async () => { const name = document.getElementById('add-plan-name').value.trim(); const res = await fetch(`${API_BASE}/plans/add`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ name }) }).then(r=>r.json()); if (res.ok) { alert('已新增'); document.getElementById('add-plan-name').value=''; await loadDailyPlans(); renderSettings(); } else { alert(res.error||'新增失败'); } }; Array.from(document.querySelectorAll('.btn-del-plan')).forEach(btn=>{ btn.onclick = async () => { const id = parseInt(btn.dataset.planId || '0'); if (!id) return; if (!confirm('确认删除该计划?将被隐藏且不可用于新记录。')) return; const res = await fetch(`${API_BASE}/plans/${id}`, { method:'DELETE' }).then(r=>r.json()); if (res.ok) { await loadDailyPlans(); renderSettings(); } else { alert(res.error||'删除失败'); } }; }); }