1289 lines
59 KiB
JavaScript
1289 lines
59 KiB
JavaScript
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) => `
|
||
<div class="waiting-step pending" id="waiting-step-${index}">
|
||
<div class="step-icon"><div class="spinner"></div></div>
|
||
<span>${step}</span>
|
||
</div>
|
||
`).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 = '<div class="spinner"></div>';
|
||
else icon.innerHTML = '<div class="spinner"></div>'; // 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}<span style="font-size: 16px;">分钟</span>`;
|
||
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 = '<option value="">选择计划...</option>' +
|
||
plans.map(p => `<option value="${p.id}">${p.name}</option>`).join('');
|
||
}
|
||
|
||
function renderRecords() {
|
||
updateWeeklyMustWarning(state.records);
|
||
if (state.records.length === 0) {
|
||
els.recordList.innerHTML = '<tr><td colspan="7" class="muted" style="text-align:center; padding: 20px;">暂无记录</td></tr>';
|
||
return;
|
||
}
|
||
|
||
els.recordList.innerHTML = state.records.map(r => {
|
||
const itemsHtml = r.items && r.items.length > 0
|
||
? r.items.map(i => `<span class="plan-badge" style="background:#f0f7ff; color:#00a8ff; margin-right:4px;">${i.type_name || i.name}: ${i.quantity}${i.unit}</span>`).join('<br>')
|
||
: '';
|
||
|
||
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(`<span style="color:#666;">❤️ 血压 ${bp}</span>`)
|
||
if (hr) healthLines.push(`<span style="color:#666;">🩺 心率 ${hr}</span>`)
|
||
if (waist) healthLines.push(`<span style="color:#666;">📐 腰围 ${waist}</span>`)
|
||
if (weight) healthLines.push(`<span style="color:#666;">⚖️ 体重 ${weight}</span>`)
|
||
const healthHtml = healthLines.length > 0 ? healthLines.join('<br>') : ''
|
||
|
||
let detailHtml = ''
|
||
if (itemsHtml && healthHtml) {
|
||
detailHtml = itemsHtml + '<hr style="margin:4px 0; border:none; border-top:1px solid #ddd;">' + healthHtml
|
||
} else if (itemsHtml) {
|
||
detailHtml = itemsHtml
|
||
} else if (healthHtml) {
|
||
detailHtml = healthHtml
|
||
} else {
|
||
detailHtml = '<span class="muted">-</span>'
|
||
}
|
||
|
||
return `
|
||
<tr>
|
||
<td><strong>${formatDate(r.record_date)}</strong></td>
|
||
<td>
|
||
${r.plan_name ? `<span class="plan-badge">${r.plan_name}</span>` : '<span class="muted">-</span>'}
|
||
</td>
|
||
<td style="color: #ff6b6b; font-weight: 600;">${r.calories}</td>
|
||
<td style="color: #00a8ff; font-weight: 600;">${r.exercise_duration}</td>
|
||
<td style="color: #00d8a7; font-weight: 600;">${r.stand_count}</td>
|
||
<td>${detailHtml}</td>
|
||
<td>
|
||
<button class="secondary" style="padding:4px 8px;font-size:12px;" onclick="openEditRecord(${r.id})">✏️</button>
|
||
<button class="danger" style="padding: 4px 8px; font-size: 12px;" onclick="deleteRecord(${r.id})">🗑️</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
// Modal Actions
|
||
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();
|
||
}
|
||
|
||
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(`<div class="grid-3"><div><label>日期</label><div>${data.record_date}</div></div><div><label>计划</label><div>${data.daily_plan||''}</div></div><div><label>千卡</label><div>${data.calories}</div></div></div>`);
|
||
lines.push(`<div class="grid-3" style="margin-top:8px;"><div><label>时长(分钟)</label><div>${data.exercise_duration}</div></div><div><label>站立次数</label><div>${data.stand_count}</div></div></div>`);
|
||
|
||
// Add health data
|
||
if (data.diastolic_bp || data.systolic_bp || data.heart_rate || data.waist_circ || data.weight_kg) {
|
||
lines.push(`<div class="grid-4" style="margin-top:8px; padding-top:8px; border-top: 1px dashed #eee;">
|
||
<div><label>舒张压(mmHg)</label><div>${data.diastolic_bp || '-'}</div></div>
|
||
<div><label>收缩压(mmHg)</label><div>${data.systolic_bp || '-'}</div></div>
|
||
<div><label>心率(bpm)</label><div>${data.heart_rate || '-'}</div></div>
|
||
<div><label>腰围(cm)</label><div>${data.waist_circ || '-'}</div></div>
|
||
<div><label>体重(kg)</label><div>${data.weight_kg || '-'}</div></div>
|
||
</div>`);
|
||
}
|
||
|
||
lines.push(`<h4 style="margin-top:12px;">项目明细</h4>`);
|
||
if (items.length) {
|
||
lines.push('<table class="data-table"><thead><tr><th>项目</th><th>数量</th><th>单位</th></tr></thead><tbody>');
|
||
lines.push(items.map(it=>`<tr><td>${it.name}</td><td>${it.quantity}</td><td>${it.unit}</td></tr>`).join(''));
|
||
lines.push('</tbody></table>');
|
||
} else {
|
||
lines.push('<div class="muted">暂无明细</div>');
|
||
}
|
||
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 ? `<div class="flag" style="background-image:url('data:image/png;base64,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');"></div>` : '';
|
||
const cls = done === 0 ? '' : (goal > 0 && remaining === 0 ? 'card-complete' : (goal > 0 && remaining > 0 ? 'card-incomplete' : ''));
|
||
return `<div class="card ${cls}">${flag}<strong>${item.name}</strong><div class="muted" style="margin-top:4px;">剩余 <span class="num-remain">${remaining}</span> ${item.unit}(目标 <span class="num-goal">${goal}</span>, 已完成 <span class="num-done">${done}</span>)</div></div>`;
|
||
}).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 `<tr>` + week.map(d=>{
|
||
if (!d) return `<td class="empty"></td>`;
|
||
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('<br/>') : '';
|
||
return `<td class="day" data-date="${ds}"><div class="date">${d.getDate()}</div><div class="metric metric-plan">计划:${rec.plan_name||'—'}</div><div class="metric metric-kcal">千卡:${rec.calories}</div><div class="metric metric-project">项目:${itemsCount}</div><div class="metric small metric-details">项目内容:</div><div class="small metric-details">${details}</div><button type="button" class="link" onclick="editRecord(${rec.id})">详情</button></td>`;
|
||
} else {
|
||
return `<td class="day"><div class="date">${d.getDate()}</div><div class="muted">空</div></td>`;
|
||
}
|
||
}).join('') + `</tr>`;
|
||
}).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 `<tr><td>${String(r.record_date).slice(0,10)}</td><td>${r.plan_name||''}</td><td>${r.calories||0}</td><td>${r.exercise_duration||0}</td><td>${r.stand_count||0}</td><td>${itemsCount}</td></tr>`;
|
||
}).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 || `<tr><td colspan="6" class="muted" style="text-align:center;">所选范围内暂无记录</td></tr>`;
|
||
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;i<n;i++){
|
||
const x = pad + i*(plotW/n) + offset*barW;
|
||
const y = pad + plotH - (data[i]||0)/maxVal*plotH;
|
||
const hBar = (data[i]||0)/maxVal*plotH;
|
||
ctx.fillStyle = color;
|
||
ctx.fillRect(x,y,barW-2,hBar);
|
||
}
|
||
}
|
||
drawSeries(cal,'#00a8ff',0);
|
||
drawSeries(dur,'#00d8a7',1);
|
||
drawSeries(st,'#ff9f43',2);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function renderScreen() {
|
||
const r = getWeekRange(new Date());
|
||
fetch(`${API_BASE}/records?start=${r.start}&end=${r.end}`).then(x=>x.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;i<n;i++) {
|
||
const x = pad + i*(plotW/(n-1||1));
|
||
const y = pad + plotH - (data[i]||0)/maxVal*plotH;
|
||
if (i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
|
||
}
|
||
ctx.stroke();
|
||
}
|
||
function drawPie(canvas, labels, values) {
|
||
if (!canvas || !canvas.getContext) return;
|
||
const ctx = canvas.getContext('2d');
|
||
const w = canvas.width, h = canvas.height, r = Math.min(w,h)/2 - 6;
|
||
const cx = w/2, cy = h/2;
|
||
ctx.clearRect(0,0,w,h);
|
||
const total = values.reduce((a,b)=>a+b,0) || 1;
|
||
let start = -Math.PI/2;
|
||
const colors = ['#4fc3f7','#7c4dff','#ffb300','#00d8a7','#ff6b6b','#ab47bc','#26a69a','#feca57'];
|
||
for (let i=0;i<values.length;i++){
|
||
const ang = values[i]/total*2*Math.PI;
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx,cy);
|
||
ctx.arc(cx,cy,r,start,start+ang);
|
||
ctx.closePath();
|
||
ctx.fillStyle = colors[i%colors.length];
|
||
ctx.fill();
|
||
start += ang;
|
||
}
|
||
}
|
||
function drawBars(canvas, labels, a, b) {
|
||
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, plotW = w-pad*2, plotH = h-pad*2, n = labels.length||1;
|
||
const barW = plotW/n/3;
|
||
const maxVal = Math.max(1, Math.max(...a, ...b));
|
||
function draw(data,color,offset){
|
||
for(let i=0;i<n;i++){
|
||
const x = pad + i*(plotW/n) + offset*barW;
|
||
const hBar = (data[i]||0)/maxVal*plotH;
|
||
const y = pad + plotH - hBar;
|
||
ctx.fillStyle = color;
|
||
ctx.fillRect(x,y,barW-2,hBar);
|
||
}
|
||
}
|
||
draw(a,'#4fc3f7',0);
|
||
draw(b,'#7c4dff',1);
|
||
}
|
||
drawLine(lc, kc, '#4fc3f7');
|
||
drawLine(ld, du, '#7c4dff');
|
||
drawLine(ls, st, '#ffb300');
|
||
drawPie(pc, compNames, compValues);
|
||
drawBars(bm, monthlyLabels, monthlyCalories, monthlyDuration);
|
||
}
|
||
const list = document.getElementById('weekly-progress-list');
|
||
const progress = state.types.map(t=>{
|
||
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=>`<div class="progress-item"><div class="progress-title">${p.name}</div><div class="progress"><div class="fill" style="width:${p.percent}%"></div></div><div class="progress-info">✅ 已完成 ${p.done} / 🎯 目标 ${p.goal} ${p.unit} ${p.remaining>0?`<span style="color:#ffb300;">🔜 剩余 ${p.remaining}</span>`:`<span style="color:#4fc3f7;">🎉 目标已完成!</span>`}</div></div>`).join('') || `<div style="text-align:center;color:#90caf9;font-style:italic;padding:20px;">暂无设置目标,请在设置页面配置运动目标</div>`;
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderSettings() {
|
||
const tBody = document.getElementById('settings-types');
|
||
const typesSorted = state.types;
|
||
tBody.innerHTML = typesSorted.map(t=>`
|
||
<tr>
|
||
<td><input type="text" data-type-id="${t.id}" class="type-name" value="${t.name}"></td>
|
||
<td>
|
||
<select class="type-unit" data-type-id="${t.id}">
|
||
<option value="分钟" ${t.unit==='分钟'?'selected':''}>分钟</option>
|
||
<option value="次" ${t.unit==='次'?'selected':''}>次</option>
|
||
<option value="个" ${t.unit==='个'?'selected':''}>个</option>
|
||
<option value="公里" ${t.unit==='公里'?'selected':''}>公里</option>
|
||
</select>
|
||
</td>
|
||
<td><input type="number" class="type-goal" data-type-id="${t.id}" value="${t.weekly_goal||0}"></td>
|
||
<td><input type="number" class="type-sort" data-type-id="${t.id}" value="${t.sort_order||0}"></td>
|
||
<td><button class="danger btn-del-type" data-type-id="${t.id}" style="padding:6px 10px;">删除</button></td>
|
||
</tr>
|
||
`).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=>`
|
||
<tr>
|
||
<td><input type="text" class="plan-name" data-plan-id="${p.id}" value="${p.name}"></td>
|
||
<td><button class="danger btn-del-plan" data-plan-id="${p.id}" style="padding:6px 10px;">删除</button></td>
|
||
</tr>
|
||
`).join('');
|
||
pBody.innerHTML = rowsHtml || `<tr><td class="muted" style="text-align:center;">暂无计划</td><td></td></tr>`;
|
||
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||'删除失败'); }
|
||
};
|
||
});
|
||
}
|