Files
Toolbox/public/tools/fitness_diary/app.js
T

1289 lines
59 KiB
JavaScript
Raw Blame History

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