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) => `
`).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(``);
lines.push(`${data.exercise_duration}
`);
// 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=>`| ${it.name} | ${it.quantity} | ${it.unit} |
`).join(''));
lines.push('
');
} 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||'删除失败'); }
};
});
}