876 lines
32 KiB
JavaScript
876 lines
32 KiB
JavaScript
const Database = require('better-sqlite3');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const axios = require('axios');
|
|
const XLSX = require('xlsx');
|
|
|
|
let db = null;
|
|
|
|
function initDb(dbPath) {
|
|
const dir = path.dirname(dbPath);
|
|
if (!fs.existsSync(dir)) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
|
|
db = new Database(dbPath);
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS exercise_types (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL UNIQUE,
|
|
unit TEXT NOT NULL,
|
|
weekly_goal INTEGER DEFAULT 0
|
|
)
|
|
`);
|
|
try {
|
|
const cols = db.prepare("PRAGMA table_info(exercise_types)").all();
|
|
if (!cols.some(c => c.name === 'sort_order')) {
|
|
db.exec("ALTER TABLE exercise_types ADD COLUMN sort_order INTEGER DEFAULT 0");
|
|
}
|
|
} catch (e) {}
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS daily_plans (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL UNIQUE,
|
|
description TEXT,
|
|
is_active BOOLEAN DEFAULT 1
|
|
)
|
|
`);
|
|
try {
|
|
const cols = db.prepare("PRAGMA table_info(daily_plans)").all();
|
|
if (!cols.some(c => c.name === 'is_active')) {
|
|
db.exec("ALTER TABLE daily_plans ADD COLUMN is_active BOOLEAN DEFAULT 1");
|
|
}
|
|
// Normalize NULL values to active
|
|
db.exec("UPDATE daily_plans SET is_active = 1 WHERE is_active IS NULL");
|
|
} catch (e) {}
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS exercise_records (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
record_date TEXT NOT NULL,
|
|
calories INTEGER NOT NULL DEFAULT 0,
|
|
exercise_duration INTEGER NOT NULL DEFAULT 0,
|
|
stand_count INTEGER NOT NULL DEFAULT 0,
|
|
daily_plan_id INTEGER,
|
|
diastolic_bp INTEGER,
|
|
systolic_bp INTEGER,
|
|
heart_rate INTEGER,
|
|
waist_circ INTEGER,
|
|
weight_kg REAL,
|
|
created_time TEXT NOT NULL,
|
|
FOREIGN KEY (daily_plan_id) REFERENCES daily_plans(id)
|
|
)
|
|
`);
|
|
try {
|
|
const cols = db.prepare("PRAGMA table_info(exercise_records)").all();
|
|
const colNames = new Set(cols.map(c => c.name));
|
|
if (!colNames.has('diastolic_bp')) {
|
|
db.exec("ALTER TABLE exercise_records ADD COLUMN diastolic_bp INTEGER");
|
|
}
|
|
if (!colNames.has('systolic_bp')) {
|
|
db.exec("ALTER TABLE exercise_records ADD COLUMN systolic_bp INTEGER");
|
|
}
|
|
if (!colNames.has('waist_circ')) {
|
|
db.exec("ALTER TABLE exercise_records ADD COLUMN waist_circ INTEGER");
|
|
}
|
|
if (!colNames.has('heart_rate')) {
|
|
db.exec("ALTER TABLE exercise_records ADD COLUMN heart_rate INTEGER");
|
|
}
|
|
if (!colNames.has('weight_kg')) {
|
|
db.exec("ALTER TABLE exercise_records ADD COLUMN weight_kg REAL");
|
|
}
|
|
} catch (e) {
|
|
console.error('[FitnessDiary] Migration error:', e.message);
|
|
}
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS exercise_items (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
record_id INTEGER NOT NULL,
|
|
exercise_type_id INTEGER NOT NULL,
|
|
quantity INTEGER NOT NULL DEFAULT 0,
|
|
unit TEXT NOT NULL,
|
|
FOREIGN KEY (record_id) REFERENCES exercise_records(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (exercise_type_id) REFERENCES exercise_types(id)
|
|
)
|
|
`);
|
|
|
|
// Performance indexes
|
|
try {
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_records_date ON exercise_records(record_date)`);
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_items_record ON exercise_items(record_id)`);
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_items_type ON exercise_items(exercise_type_id)`);
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_records_plan ON exercise_records(daily_plan_id)`);
|
|
} catch (e) {}
|
|
// Seed or import data for daily_plans/exercise_types when empty
|
|
try {
|
|
const typeCount = db.prepare('SELECT COUNT(1) AS c FROM exercise_types').get().c || 0;
|
|
const planCount = db.prepare('SELECT COUNT(1) AS c FROM daily_plans').get().c || 0;
|
|
if (planCount === 0) {
|
|
// Try import from old DB first
|
|
const oldPath = path.join('d:\\', 'Trae_Files', 'FitnessDiary_1', 'exercise_log.db');
|
|
let imported = false;
|
|
try {
|
|
if (fs.existsSync(oldPath)) {
|
|
const OldDB = require('better-sqlite3');
|
|
const old = new OldDB(oldPath);
|
|
const oldPlans = old.prepare('SELECT id, name FROM daily_plans').all();
|
|
if (Array.isArray(oldPlans) && oldPlans.length) {
|
|
const stmt = db.prepare('INSERT OR IGNORE INTO daily_plans (name, description, is_active) VALUES (?, ?, 1)');
|
|
for (const p of oldPlans) {
|
|
stmt.run(String(p.name || '').trim(), '', 1);
|
|
}
|
|
imported = true;
|
|
}
|
|
}
|
|
} catch {}
|
|
if (!imported) {
|
|
// Fallback seed defaults
|
|
const defaults = ['正常训练', '轻断食', '跑步日', '力量日', '恢复日', '高强运动日'];
|
|
const stmt = db.prepare('INSERT OR IGNORE INTO daily_plans (name, description, is_active) VALUES (?, ?, 1)');
|
|
defaults.forEach(n => stmt.run(n, '', 1));
|
|
}
|
|
}
|
|
if (typeCount === 0) {
|
|
// Try import exercise_types from old DB
|
|
try {
|
|
const oldPath = path.join('d:\\', 'Trae_Files', 'FitnessDiary_1', 'exercise_log.db');
|
|
if (fs.existsSync(oldPath)) {
|
|
const OldDB = require('better-sqlite3');
|
|
const old = new OldDB(oldPath);
|
|
const oldTypes = old.prepare('SELECT id, name, unit, weekly_goal FROM exercise_types').all();
|
|
if (Array.isArray(oldTypes) && oldTypes.length) {
|
|
const stmt = db.prepare('INSERT OR IGNORE INTO exercise_types (name, unit, weekly_goal, sort_order) VALUES (?, ?, ?, ?)');
|
|
let sort = 0;
|
|
for (const t of oldTypes) {
|
|
stmt.run(String(t.name || '').trim(), String(t.unit || '').trim(), parseInt(t.weekly_goal || 0), sort++);
|
|
}
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
function getDb() {
|
|
return db;
|
|
}
|
|
|
|
// Helper: Push to Weekly
|
|
async function pushToWeekly(recordId) {
|
|
try {
|
|
const record = db.prepare(`
|
|
SELECT r.*, p.name as plan_name
|
|
FROM exercise_records r
|
|
LEFT JOIN daily_plans p ON r.daily_plan_id = p.id
|
|
WHERE r.id = ?
|
|
`).get(recordId);
|
|
|
|
if (!record || record.calories <= 100) {
|
|
if (record && record.calories <= 100) console.log(`[FitnessDiary] Skipping push to weekly: calories ${record.calories} <= 100`);
|
|
return;
|
|
}
|
|
|
|
const dateStr = String(record.record_date || '').slice(0, 10);
|
|
if (!dateStr) return;
|
|
|
|
const records = db.prepare(`
|
|
SELECT r.*, p.name as plan_name
|
|
FROM exercise_records r
|
|
LEFT JOIN daily_plans p ON r.daily_plan_id = p.id
|
|
WHERE r.record_date = ?
|
|
ORDER BY r.id
|
|
`).all(dateStr);
|
|
|
|
if (!Array.isArray(records) || !records.length) return;
|
|
|
|
const ids = records.map(r => r.id);
|
|
let itemsByRecord = {};
|
|
if (ids.length) {
|
|
const placeholders = ids.map(() => '?').join(',');
|
|
const rows = db.prepare(`
|
|
SELECT i.record_id, i.quantity, i.unit, t.name
|
|
FROM exercise_items i
|
|
LEFT JOIN exercise_types t ON i.exercise_type_id = t.id
|
|
WHERE i.record_id IN (${placeholders})
|
|
`).all(...ids);
|
|
for (const it of rows) {
|
|
const rid = it.record_id;
|
|
if (!itemsByRecord[rid]) itemsByRecord[rid] = [];
|
|
itemsByRecord[rid].push(it);
|
|
}
|
|
}
|
|
|
|
const escapeHtml = s => String(s || '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
|
|
let totalCalories = 0;
|
|
let totalDuration = 0;
|
|
let totalStand = 0;
|
|
let html = '<table border="1" cellspacing="0" cellpadding="4"><thead><tr><th>日期</th><th>计划</th><th>项目</th><th>数量</th><th>单位</th></tr></thead><tbody>';
|
|
for (const r of records) {
|
|
const list = itemsByRecord[r.id] || [];
|
|
if (!list.length) {
|
|
html += `<tr><td>${escapeHtml(dateStr)}</td><td>${escapeHtml(r.plan_name || '')}</td><td></td><td></td><td></td></tr>`;
|
|
} else {
|
|
for (const it of list) {
|
|
html += `<tr><td>${escapeHtml(dateStr)}</td><td>${escapeHtml(r.plan_name || '')}</td><td>${escapeHtml(it.name || '')}</td><td>${Number(it.quantity || 0)}</td><td>${escapeHtml(it.unit || '')}</td></tr>`;
|
|
}
|
|
}
|
|
totalCalories += Number(r.calories || 0);
|
|
totalDuration += Number(r.exercise_duration || 0);
|
|
totalStand += Number(r.stand_count || 0);
|
|
}
|
|
html += '</tbody></table>';
|
|
|
|
const payload = {
|
|
key: process.env.FITNESS_DIARY_API_KEY || process.env.SYSTEM_AUTH_PASS_93220 || '',
|
|
external_id: String(recordId),
|
|
date: dateStr,
|
|
items: html,
|
|
calories: totalCalories,
|
|
duration: totalDuration
|
|
};
|
|
|
|
// Assuming local server is running on process.env.PORT or default 8976
|
|
const port = process.env.PORT || 8976;
|
|
const url = `http://127.0.0.1:${port}/api/weekly/ext/fitness`;
|
|
|
|
console.log(`[FitnessDiary] Pushing to weekly: ${url}`, JSON.stringify(payload));
|
|
await axios.post(url, payload);
|
|
console.log('[FitnessDiary] Push successful');
|
|
|
|
} catch (err) {
|
|
console.error('[FitnessDiary] Failed to push to weekly:', err.message);
|
|
}
|
|
}
|
|
|
|
async function deleteFromWeekly(recordId) {
|
|
try {
|
|
const port = process.env.PORT || 8976;
|
|
const url = `http://127.0.0.1:${port}/api/weekly/ext/fitness`;
|
|
const payload = {
|
|
key: process.env.FITNESS_DIARY_API_KEY || process.env.SYSTEM_AUTH_PASS_93220 || '',
|
|
external_id: String(recordId),
|
|
action: 'delete'
|
|
};
|
|
console.log(`[FitnessDiary] Deleting from weekly: ${url}`, JSON.stringify(payload));
|
|
await axios.post(url, payload);
|
|
console.log('[FitnessDiary] Delete push successful');
|
|
} catch (err) {
|
|
console.error('[FitnessDiary] Failed to delete from weekly:', err.message);
|
|
}
|
|
}
|
|
|
|
// Auth Helper - DEPRECATED: Auth handled by main server middleware
|
|
// const NAV_AUTH_COOKIE = 'nav_gate';
|
|
// const parseCookie = ...
|
|
// const checkAuth = ...
|
|
|
|
function bindRoutes(app) {
|
|
const API = '/api/fitness_diary';
|
|
|
|
// Disable caching for all fitness_diary API responses
|
|
app.use(API, (req, res, next) => {
|
|
try {
|
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
|
res.set('Pragma', 'no-cache');
|
|
res.set('Expires', '0');
|
|
res.set('Surrogate-Control', 'no-store');
|
|
} catch {}
|
|
next();
|
|
});
|
|
|
|
// Get all exercise types
|
|
app.get(`${API}/types`, (req, res) => {
|
|
try {
|
|
const rows = db.prepare('SELECT * FROM exercise_types ORDER BY sort_order ASC, name ASC').all();
|
|
res.json({ ok: true, data: rows });
|
|
} catch (e) {
|
|
res.status(500).json({ ok: false, error: e.message });
|
|
}
|
|
});
|
|
|
|
// Get all daily plans
|
|
app.get(`${API}/daily_plans`, (req, res) => {
|
|
try {
|
|
let hasActive = false;
|
|
try {
|
|
const cols = db.prepare('PRAGMA table_info(daily_plans)').all();
|
|
hasActive = cols.some(c => c.name === 'is_active');
|
|
} catch {}
|
|
const sql = hasActive ? 'SELECT * FROM daily_plans WHERE is_active = 1 ORDER BY id' : 'SELECT * FROM daily_plans ORDER BY id';
|
|
const rows = db.prepare(sql).all();
|
|
res.json({ ok: true, data: rows });
|
|
} catch (e) {
|
|
res.status(500).json({ ok: false, error: e.message });
|
|
}
|
|
});
|
|
|
|
// Get records (with optional date filter)
|
|
app.get(`${API}/records`, (req, res) => {
|
|
try {
|
|
const { start, end } = req.query;
|
|
// console.log(`[FitnessDiary] Get records: start=${start}, end=${end}`);
|
|
let sql = `
|
|
SELECT r.*, p.name as plan_name
|
|
FROM exercise_records r
|
|
LEFT JOIN daily_plans p ON r.daily_plan_id = p.id
|
|
`;
|
|
const params = [];
|
|
|
|
if (start && end) {
|
|
sql += ` WHERE r.record_date >= ? AND r.record_date <= ?`;
|
|
params.push(start, end);
|
|
}
|
|
|
|
sql += ` ORDER BY r.record_date DESC`;
|
|
|
|
const records = db.prepare(sql).all(...params);
|
|
// console.log(`[FitnessDiary] Found ${records.length} records`);
|
|
const ids = records.map(r => r.id);
|
|
let itemsByRecord = {};
|
|
if (ids.length) {
|
|
const placeholders = ids.map(()=>'?').join(',');
|
|
const items = db.prepare(`
|
|
SELECT i.*, t.name as type_name
|
|
FROM exercise_items i
|
|
LEFT JOIN exercise_types t ON i.exercise_type_id = t.id
|
|
WHERE i.record_id IN (${placeholders})
|
|
`).all(...ids);
|
|
for (const it of items) {
|
|
const rid = it.record_id;
|
|
if (!itemsByRecord[rid]) itemsByRecord[rid] = [];
|
|
itemsByRecord[rid].push(it);
|
|
}
|
|
}
|
|
for (const rec of records) {
|
|
rec.items = itemsByRecord[rec.id] || [];
|
|
}
|
|
|
|
res.json({ ok: true, data: records });
|
|
} catch (e) {
|
|
console.error('[FitnessDiary] Get records error:', e);
|
|
res.status(500).json({ ok: false, error: e.message });
|
|
}
|
|
});
|
|
|
|
// Add new record
|
|
app.post(`${API}/records`, async (req, res) => {
|
|
try {
|
|
const {
|
|
record_date,
|
|
calories,
|
|
exercise_duration,
|
|
stand_count,
|
|
daily_plan_id,
|
|
diastolic_bp,
|
|
systolic_bp,
|
|
heart_rate,
|
|
waist_circ,
|
|
weight_kg,
|
|
items
|
|
} = req.body;
|
|
|
|
// 1. Check duplicate for same day
|
|
const existing = db.prepare('SELECT id FROM exercise_records WHERE record_date = ?').get(record_date);
|
|
if (existing) {
|
|
return res.json({ ok: false, error: '该日期已存在记录,请勿重复添加' });
|
|
}
|
|
|
|
// 2. Insert Record
|
|
const insertRecord = db.prepare(`
|
|
INSERT INTO exercise_records (record_date, calories, exercise_duration, stand_count, daily_plan_id, diastolic_bp, systolic_bp, heart_rate, waist_circ, weight_kg, created_time)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now', 'localtime'))
|
|
`);
|
|
let planId = null;
|
|
if (daily_plan_id !== undefined && daily_plan_id !== null && daily_plan_id !== '') {
|
|
const n = parseInt(daily_plan_id, 10);
|
|
if (!Number.isNaN(n) && n > 0) planId = n;
|
|
}
|
|
|
|
const getIntOrNull = (val) => {
|
|
if (val === undefined || val === null || val === '') return null;
|
|
const n = parseInt(val, 10);
|
|
return (!Number.isNaN(n) && n > 0) ? n : null;
|
|
};
|
|
|
|
const getRealOrNull = (val) => {
|
|
if (val === undefined || val === null || val === '') return null;
|
|
const n = parseFloat(val);
|
|
return (!Number.isNaN(n)) ? n : null;
|
|
};
|
|
|
|
const info = insertRecord.run(
|
|
record_date,
|
|
calories,
|
|
exercise_duration,
|
|
stand_count,
|
|
planId,
|
|
getIntOrNull(diastolic_bp),
|
|
getIntOrNull(systolic_bp),
|
|
getIntOrNull(heart_rate),
|
|
getIntOrNull(waist_circ),
|
|
getRealOrNull(weight_kg)
|
|
);
|
|
const recordId = info.lastInsertRowid;
|
|
|
|
// 3. Insert Items
|
|
const insertItem = db.prepare(`
|
|
INSERT INTO exercise_items (record_id, exercise_type_id, quantity, unit)
|
|
VALUES (?, ?, ?, ?)
|
|
`);
|
|
|
|
if (Array.isArray(items)) {
|
|
for (const it of items) {
|
|
insertItem.run(recordId, it.exercise_type_id, it.quantity, it.unit);
|
|
}
|
|
}
|
|
|
|
// 4. Async Push to Weekly
|
|
const pushResult = await pushToWeekly(recordId);
|
|
res.json({ ok: true, id: recordId, push: pushResult });
|
|
|
|
} catch (e) {
|
|
res.status(500).json({ ok: false, error: e.message });
|
|
}
|
|
});
|
|
|
|
// Delete record
|
|
app.delete(`${API}/records/:id`, async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
db.prepare('DELETE FROM exercise_records WHERE id = ?').run(id);
|
|
// Items cascade delete automatically
|
|
try { await deleteFromWeekly(id); } catch {}
|
|
res.json({ ok: true });
|
|
} catch (e) {
|
|
res.status(500).json({ ok: false, error: e.message });
|
|
}
|
|
});
|
|
|
|
// Record detail
|
|
app.get(`${API}/record/:id`, (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const r = db.prepare(`
|
|
SELECT r.*, p.name as plan_name
|
|
FROM exercise_records r
|
|
LEFT JOIN daily_plans p ON r.daily_plan_id = p.id
|
|
WHERE r.id = ?
|
|
`).get(id);
|
|
if (!r) return res.status(404).json({ ok: false, error: 'not_found' });
|
|
const items = db.prepare(`
|
|
SELECT i.id, i.exercise_type_id, i.quantity, i.unit, t.name
|
|
FROM exercise_items i
|
|
LEFT JOIN exercise_types t ON i.exercise_type_id = t.id
|
|
WHERE i.record_id = ?
|
|
`).all(id);
|
|
res.json({
|
|
id: r.id,
|
|
record_date: r.record_date,
|
|
calories: r.calories,
|
|
exercise_duration: r.exercise_duration,
|
|
stand_count: r.stand_count,
|
|
diastolic_bp: r.diastolic_bp,
|
|
systolic_bp: r.systolic_bp,
|
|
heart_rate: r.heart_rate,
|
|
waist_circ: r.waist_circ,
|
|
weight_kg: r.weight_kg,
|
|
daily_plan: r.plan_name || '',
|
|
daily_plan_id: r.daily_plan_id,
|
|
items
|
|
});
|
|
} catch (e) {
|
|
res.status(500).json({ ok: false, error: e.message });
|
|
}
|
|
});
|
|
|
|
// Update record
|
|
app.put(`${API}/records/:id`, async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const body = req.body || {};
|
|
const r = db.prepare(`SELECT * FROM exercise_records WHERE id = ?`).get(id);
|
|
if (!r) return res.status(404).json({ ok: false, error: 'not_found' });
|
|
const recordDate = new Date(String(r.record_date).slice(0,10)+'T00:00:00');
|
|
const diffDays = Math.floor((Date.now() - recordDate.getTime())/86400000);
|
|
if (diffDays > 31) return res.status(400).json({ ok: false, error: '超过一个月的历史日期不允许修改' });
|
|
|
|
const getRealOrNull = (val) => {
|
|
if (val === undefined || val === null || val === '') return null;
|
|
const n = parseFloat(val);
|
|
return (!Number.isNaN(n)) ? n : null;
|
|
};
|
|
|
|
db.prepare(`
|
|
UPDATE exercise_records
|
|
SET daily_plan_id = ?, calories = ?, exercise_duration = ?, stand_count = ?, diastolic_bp = ?, systolic_bp = ?, heart_rate = ?, waist_circ = ?, weight_kg = ?
|
|
WHERE id = ?
|
|
`).run((() => {
|
|
const v = body.daily_plan_id;
|
|
if (v === undefined || v === null || v === '') return null;
|
|
const n = parseInt(v, 10);
|
|
return (!Number.isNaN(n) && n > 0) ? n : null;
|
|
})(),
|
|
parseInt(body.calories||0),
|
|
parseInt(body.exercise_duration||0),
|
|
parseInt(body.stand_count||0),
|
|
body.diastolic_bp ? parseInt(body.diastolic_bp, 10) : null,
|
|
body.systolic_bp ? parseInt(body.systolic_bp, 10) : null,
|
|
body.heart_rate ? parseInt(body.heart_rate, 10) : null,
|
|
body.waist_circ ? parseInt(body.waist_circ, 10) : null,
|
|
getRealOrNull(body.weight_kg),
|
|
id);
|
|
db.prepare(`DELETE FROM exercise_items WHERE record_id = ?`).run(id);
|
|
if (Array.isArray(body.items)) {
|
|
const stmt = db.prepare(`INSERT INTO exercise_items (record_id, exercise_type_id, quantity, unit) VALUES (?, ?, ?, ?)`);
|
|
for (const it of body.items) {
|
|
stmt.run(id, it.exercise_type_id, parseInt(it.quantity||0), String(it.unit||''));
|
|
}
|
|
}
|
|
|
|
const pushResult = await pushToWeekly(id);
|
|
res.json({ ok: true, push: pushResult });
|
|
|
|
} catch (e) {
|
|
res.status(500).json({ ok: false, error: e.message });
|
|
}
|
|
});
|
|
|
|
// Stats (Sum for range)
|
|
app.get(`${API}/stats`, (req, res) => {
|
|
try {
|
|
const { start, end } = req.query;
|
|
let sql = `
|
|
SELECT
|
|
SUM(calories) as sum_calories,
|
|
SUM(exercise_duration) as sum_duration,
|
|
SUM(stand_count) as sum_stand
|
|
FROM exercise_records
|
|
`;
|
|
const params = [];
|
|
if (start && end) {
|
|
sql += ` WHERE record_date >= ? AND record_date <= ?`;
|
|
params.push(start, end);
|
|
}
|
|
|
|
const stats = db.prepare(sql).get(...params);
|
|
res.json({
|
|
ok: true,
|
|
stats: {
|
|
sum_calories: stats.sum_calories || 0,
|
|
sum_duration: stats.sum_duration || 0,
|
|
sum_stand: stats.sum_stand || 0
|
|
}
|
|
});
|
|
} catch (e) {
|
|
res.status(500).json({ ok: false, error: e.message });
|
|
}
|
|
});
|
|
|
|
// Weekly Progress (for progress bars)
|
|
app.get(`${API}/weekly_progress`, (req, res) => {
|
|
try {
|
|
const fmt = d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
|
const baseDateStr = (req.query.date ? String(req.query.date).slice(0, 10) : fmt(new Date()));
|
|
const d = new Date(baseDateStr + 'T00:00:00');
|
|
const day = d.getDay() || 7;
|
|
const start = new Date(d);
|
|
start.setDate(d.getDate() - day + 1);
|
|
const end = new Date(start);
|
|
end.setDate(start.getDate() + 6);
|
|
const sStr = fmt(start);
|
|
const eStr = fmt(end);
|
|
|
|
// Get all types and goals
|
|
const types = db.prepare('SELECT * FROM exercise_types ORDER BY sort_order ASC, id ASC').all();
|
|
const progress = {};
|
|
types.forEach(t => {
|
|
progress[t.id] = { ...t, done: 0 };
|
|
});
|
|
|
|
// Sum up records in range
|
|
const records = db.prepare(`
|
|
SELECT i.exercise_type_id, i.quantity
|
|
FROM exercise_items i
|
|
JOIN exercise_records r ON i.record_id = r.id
|
|
WHERE r.record_date >= ? AND r.record_date <= ?
|
|
`).all(sStr, eStr);
|
|
|
|
records.forEach(r => {
|
|
if (progress[r.exercise_type_id]) {
|
|
progress[r.exercise_type_id].done += r.quantity;
|
|
}
|
|
});
|
|
|
|
const result = Object.values(progress).map(p => ({
|
|
type_id: p.id,
|
|
name: p.name,
|
|
unit: p.unit,
|
|
weekly_goal: p.weekly_goal,
|
|
done: p.done,
|
|
remaining: Math.max(0, p.weekly_goal - p.done)
|
|
}));
|
|
|
|
res.json({ ok: true, week_start: sStr, week_end: eStr, progress: result });
|
|
|
|
} catch (e) {
|
|
res.status(500).json({ ok: false, error: e.message });
|
|
}
|
|
});
|
|
|
|
// Update types
|
|
app.post(`${API}/types/update`, (req, res) => {
|
|
try {
|
|
const body = req.body || {};
|
|
const arr = Array.isArray(body.types) ? body.types : [];
|
|
const names = arr.map(x => String(x.name||'').trim()).filter(Boolean);
|
|
const dup = names.filter((n, i) => names.indexOf(n) !== i);
|
|
if (dup.length) return res.status(400).json({ ok: false, error: `项目名称重复:${Array.from(new Set(dup)).join('、')}` });
|
|
const update = db.prepare(`UPDATE exercise_types SET name = ?, unit = ?, weekly_goal = ?, sort_order = ? WHERE id = ?`);
|
|
const checkUnique = db.prepare(`SELECT id FROM exercise_types WHERE name = ? AND id != ?`);
|
|
for (const t of arr) {
|
|
const name = String(t.name||'').trim();
|
|
const unit = String(t.unit||'').trim();
|
|
const goal = parseInt(t.weekly_goal||0);
|
|
const sort = parseInt(t.sort_order||0);
|
|
const id = parseInt(t.id||0);
|
|
if (!id || !name || !unit) continue;
|
|
const exists = checkUnique.get(name, id);
|
|
if (exists) return res.status(400).json({ ok: false, error: '保存失败:项目名称已存在(数据库唯一约束)。请检查并修改后再保存。' });
|
|
update.run(name, unit, goal, sort, id);
|
|
}
|
|
res.json({ ok: true });
|
|
} catch (e) {
|
|
res.status(500).json({ ok: false, error: e.message });
|
|
}
|
|
});
|
|
|
|
// Add type
|
|
app.post(`${API}/types/add`, (req, res) => {
|
|
try {
|
|
const { name, unit, weekly_goal } = req.body || {};
|
|
const n = String(name||'').trim();
|
|
const u = String(unit||'').trim();
|
|
const g = parseInt(weekly_goal||0);
|
|
if (!n || !u) return res.status(400).json({ ok: false, error: '请输入项目名称和单位' });
|
|
const exists = db.prepare(`SELECT id FROM exercise_types WHERE name = ?`).get(n);
|
|
if (exists) return res.status(400).json({ ok: false, error: '该项目已存在' });
|
|
db.prepare(`INSERT INTO exercise_types (name, unit, weekly_goal, sort_order) VALUES (?, ?, ?, ?)`).run(n, u, g, 0);
|
|
res.json({ ok: true });
|
|
} catch (e) {
|
|
res.status(500).json({ ok: false, error: e.message });
|
|
}
|
|
});
|
|
|
|
// Delete type
|
|
app.delete(`${API}/types/:id`, (req, res) => {
|
|
try {
|
|
const id = parseInt(req.params.id || '0', 10);
|
|
if (!id) return res.status(400).json({ ok: false, error: '参数错误' });
|
|
const used = db.prepare(`SELECT COUNT(1) AS c FROM exercise_items WHERE exercise_type_id = ?`).get(id);
|
|
if (Number(used.c || 0) > 0) {
|
|
return res.status(400).json({ ok: false, error: '该项目已在记录中使用,无法删除' });
|
|
}
|
|
db.prepare(`DELETE FROM exercise_types WHERE id = ?`).run(id);
|
|
res.json({ ok: true });
|
|
} catch (e) {
|
|
res.status(500).json({ ok: false, error: e.message });
|
|
}
|
|
});
|
|
|
|
// Update plans
|
|
app.post(`${API}/plans/update`, (req, res) => {
|
|
try {
|
|
const body = req.body || {};
|
|
const arr = Array.isArray(body.plans) ? body.plans : [];
|
|
const names = arr.map(x => String(x.name||'').trim()).filter(Boolean);
|
|
const dup = names.filter((n, i) => names.indexOf(n) !== i);
|
|
if (dup.length) return res.status(400).json({ ok: false, error: `计划名称重复:${Array.from(new Set(dup)).join('、')}` });
|
|
const update = db.prepare(`UPDATE daily_plans SET name = ? WHERE id = ?`);
|
|
const checkUnique = db.prepare(`SELECT id FROM daily_plans WHERE name = ? AND id != ?`);
|
|
for (const p of arr) {
|
|
const name = String(p.name||'').trim();
|
|
const id = parseInt(p.id||0);
|
|
if (!id || !name) continue;
|
|
const exists = checkUnique.get(name, id);
|
|
if (exists) return res.status(400).json({ ok: false, error: '保存失败:计划名称已存在。' });
|
|
update.run(name, id);
|
|
}
|
|
res.json({ ok: true });
|
|
} catch (e) {
|
|
res.status(500).json({ ok: false, error: e.message });
|
|
}
|
|
});
|
|
|
|
// Add plan
|
|
app.post(`${API}/plans/add`, (req, res) => {
|
|
try {
|
|
const { name } = req.body || {};
|
|
const n = String(name||'').trim();
|
|
if (!n) return res.status(400).json({ ok: false, error: '请输入计划名称' });
|
|
const exists = db.prepare(`SELECT id FROM daily_plans WHERE name = ?`).get(n);
|
|
if (exists) return res.status(400).json({ ok: false, error: '该计划已存在' });
|
|
db.prepare(`INSERT INTO daily_plans (name) VALUES (?)`).run(n);
|
|
res.json({ ok: true });
|
|
} catch (e) {
|
|
res.status(500).json({ ok: false, error: e.message });
|
|
}
|
|
});
|
|
|
|
// Delete plan (soft delete)
|
|
app.delete(`${API}/plans/:id`, (req, res) => {
|
|
try {
|
|
const id = parseInt(req.params.id || '0', 10);
|
|
if (!id) return res.status(400).json({ ok: false, error: '参数错误' });
|
|
db.prepare(`UPDATE daily_plans SET is_active = 0 WHERE id = ?`).run(id);
|
|
res.json({ ok: true });
|
|
} catch (e) {
|
|
res.status(500).json({ ok: false, error: e.message });
|
|
}
|
|
});
|
|
|
|
// Clear data
|
|
app.post(`${API}/records/clear`, (req, res) => {
|
|
try {
|
|
db.prepare(`DELETE FROM exercise_items`).run();
|
|
db.prepare(`DELETE FROM exercise_records`).run();
|
|
res.json({ ok: true });
|
|
} catch (e) {
|
|
res.status(500).json({ ok: false, error: e.message });
|
|
}
|
|
});
|
|
|
|
// Auto-fill missing records for previous days
|
|
app.post(`${API}/records/auto-fill-missing`, (req, res) => {
|
|
try {
|
|
const today = new Date();
|
|
const todayStr = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(today.getDate()).padStart(2,'0')}`;
|
|
|
|
// Get all records before today, ordered by date descending
|
|
const allRecords = db.prepare(`
|
|
SELECT id, record_date, calories, exercise_duration, stand_count
|
|
FROM exercise_records
|
|
WHERE record_date < ?
|
|
ORDER BY record_date DESC
|
|
`).all(todayStr);
|
|
|
|
if (!allRecords || allRecords.length === 0) {
|
|
return res.json({ ok: true, filled: 0, message: '无历史空缺记录' });
|
|
}
|
|
|
|
// Group records by date (in case of multiple records per day)
|
|
const recordsByDate = {};
|
|
allRecords.forEach(r => {
|
|
const dateStr = String(r.record_date).slice(0, 10);
|
|
if (!recordsByDate[dateStr]) {
|
|
recordsByDate[dateStr] = [];
|
|
}
|
|
recordsByDate[dateStr].push(r);
|
|
});
|
|
|
|
const filledDates = [];
|
|
const updateStmt = db.prepare(`
|
|
UPDATE exercise_records
|
|
SET calories = ?, exercise_duration = ?, stand_count = ?
|
|
WHERE id = ?
|
|
`);
|
|
|
|
// Check each date before today
|
|
Object.keys(recordsByDate).sort().forEach(dateStr => {
|
|
const records = recordsByDate[dateStr];
|
|
|
|
// Check if any record on this date has missing fields
|
|
records.forEach(record => {
|
|
const needsFill = (record.calories === 0 || record.calories === null) ||
|
|
(record.exercise_duration === 0 || record.exercise_duration === null) ||
|
|
(record.stand_count === 0 || record.stand_count === null);
|
|
|
|
if (needsFill) {
|
|
const newCalories = (record.calories === 0 || record.calories === null) ? 330 : record.calories;
|
|
const newDuration = (record.exercise_duration === 0 || record.exercise_duration === null) ? 45 : record.exercise_duration;
|
|
const newStandCount = (record.stand_count === 0 || record.stand_count === null) ? 12 : record.stand_count;
|
|
|
|
updateStmt.run(newCalories, newDuration, newStandCount, record.id);
|
|
|
|
if (!filledDates.includes(dateStr)) {
|
|
filledDates.push(dateStr);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
const filledCount = filledDates.length;
|
|
const message = filledCount > 0
|
|
? `已自动补填 ${filledCount} 天空缺记录:${filledDates.join('、')}`
|
|
: '无空缺记录需要补填';
|
|
|
|
console.log(`[FitnessDiary] Auto-fill missing: ${message}`);
|
|
res.json({ ok: true, filled: filledCount, dates: filledDates, message });
|
|
|
|
} catch (e) {
|
|
console.error('[FitnessDiary] Auto-fill error:', e);
|
|
res.status(500).json({ ok: false, error: e.message });
|
|
}
|
|
});
|
|
|
|
// Export Excel
|
|
app.get(`${API}/export`, (req, res) => {
|
|
try {
|
|
const { start, end } = req.query;
|
|
let sql = `SELECT r.*, p.name as plan_name FROM exercise_records r LEFT JOIN daily_plans p ON r.daily_plan_id = p.id`;
|
|
const params = [];
|
|
if (start && end) {
|
|
sql += ` WHERE r.record_date >= ? AND r.record_date <= ?`;
|
|
params.push(start, end);
|
|
}
|
|
sql += ` ORDER BY r.record_date ASC`;
|
|
const records = db.prepare(sql).all(...params);
|
|
const items = [];
|
|
for (const r of records) {
|
|
const its = db.prepare(`
|
|
SELECT i.*, t.name as type_name
|
|
FROM exercise_items i
|
|
LEFT JOIN exercise_types t ON i.exercise_type_id = t.id
|
|
WHERE i.record_id = ?
|
|
`).all(r.id);
|
|
its.forEach(it => items.push({
|
|
id: it.id, record_id: r.id, date: r.record_date, exercise_type: it.type_name || '', quantity: it.quantity, unit: it.unit
|
|
}));
|
|
}
|
|
const ws1 = XLSX.utils.json_to_sheet(records.map(r => ({
|
|
id: r.id,
|
|
record_date: r.record_date,
|
|
daily_plan: r.plan_name || '',
|
|
calories: r.calories,
|
|
exercise_duration: r.exercise_duration,
|
|
stand_count: r.stand_count,
|
|
created_time: r.created_time
|
|
})));
|
|
const ws2 = XLSX.utils.json_to_sheet(items);
|
|
const wb = XLSX.utils.book_new();
|
|
XLSX.utils.book_append_sheet(wb, ws1, 'Records');
|
|
XLSX.utils.book_append_sheet(wb, ws2, 'Items');
|
|
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
|
const startName = (start || '').replace(/-/g, '') || 'start';
|
|
const endName = (end || '').replace(/-/g, '') || 'end';
|
|
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
|
res.setHeader('Content-Disposition', `attachment; filename="export_${startName}_${endName}.xlsx"`);
|
|
res.send(buf);
|
|
} catch (e) {
|
|
res.status(500).json({ ok: false, error: e.message });
|
|
}
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
initDb,
|
|
getDb,
|
|
bindRoutes
|
|
};
|