// ==================== Baby Tracker Main App ==================== document.addEventListener('alpine:init', () => { const LAST_MILK_KEY = 'baby_last_milk'; const MILK_PRESETS = [120, 150, 180, 210, 240]; function loadLastMilkPref() { try { const raw = localStorage.getItem(LAST_MILK_KEY); if (!raw) return null; return JSON.parse(raw); } catch { return null; } } function saveLastMilkPref(amount, subType) { try { localStorage.setItem(LAST_MILK_KEY, JSON.stringify({ amount, subType })); } catch { /* ignore quota */ } } /** UTC ISO → datetime-local string in Asia/Shanghai (YYYY-MM-DDTHH:mm) */ function isoToCNLocal(iso) { if (!iso) return ''; const d = new Date(new Date(iso).getTime() + 8 * 60 * 60 * 1000); return d.toISOString().slice(0, 16); } Alpine.data('app', () => ({ // --- State --- ready: false, view: 'record', loading: false, uploading: false, exporting: false, showModal: false, modalImage: '', expandedId: null, now: Date.now(), _clockTimer: null, _previewUrl: null, _darkMql: null, _darkMqlListener: null, // Theme theme: 'light', // Edit mode editingId: null, existingImageUrl: null, removeImage: false, milkPresets: MILK_PRESETS, lastMilkAmount: null, lastMilkSubType: null, form: { date: '', time: '', type: 'milk', subType: '配方奶', amount: '', isNewFood: false, tags: '无异常', notes: '', imageFile: null, previewUrl: null }, historyDate: '', logs: [], stats: { milk: 0, food: 0, poo: 0, vitD: false }, _historyCache: {}, // dateStr → { logs, stats } in-memory cache _fetchHistoryLock: null, // pending fetch promise (prevents duplicate requests) todaySummary: { loading: true, milk: 0, food: 0, poo: 0, vitD: false, lastMilkAt: null }, vitamindReminder: { show: false, checked: false }, growth: { initialized: false, loading: false, error: false, errorMessage: '', aiVisible: false, aiText: '正在分析最新数据...' }, // Report tab state report: { range: 7, loading: false, summary: { avgDailyMilk: 0, totalMilk: 0, foodVariety: 0, poopCount: 0, activeDays: 0 }, foodLog: [], pooSummary: { normal: 0, abnormal: 0, total: 0 }, _data: null, _rendered: false }, _cfg: { IMGBED_API: '', GROW_API_URL: '', AI_API_URL: '', BIRTH_DATE: '2025/03/09' }, get isEditing() { return !!this.editingId; }, get formDatetime() { if (!this.form.date || !this.form.time) return ''; return `${this.form.date}T${this.form.time}`; }, /** Set form date/time from a datetime-local style string (YYYY-MM-DDTHH:mm) */ setFormDatetime(datetimeLocal) { if (!datetimeLocal || typeof datetimeLocal !== 'string') { this.form.date = ''; this.form.time = ''; return; } const [date = '', time = ''] = datetimeLocal.split('T'); this.form.date = date; this.form.time = (time || '').slice(0, 5); }, // --- Lifecycle --- async init() { try { if (window.envReady) await window.envReady; await BabyDB.init(); } catch (e) { console.warn('Init warning:', e); } this._cfg = { IMGBED_API: window.env?.IMGBED_API || '', GROW_API_URL: window.env?.GROW_API_URL || '', AI_API_URL: window.env?.AI_API_URL || '', BIRTH_DATE: window.env?.BIRTH_DATE || '2025/03/09' }; // Restore last milk prefs const pref = loadLastMilkPref(); if (pref?.amount) this.lastMilkAmount = pref.amount; if (pref?.subType) this.lastMilkSubType = pref.subType; this.resetForm(false); this.historyDate = BabyUtils.getTodayCN(); this.ready = true; this._clockTimer = setInterval(() => { this.now = Date.now(); }, 30000); window.addEventListener('beforeunload', () => this.destroy()); // Init theme const stored = localStorage.getItem('baby_theme'); if (stored === 'dark' || stored === 'light') { this.theme = stored; } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) { this.theme = 'dark'; } this.applyTheme(); // Listen for OS-level theme changes (only when user hasn't stored a preference) this._darkMql = window.matchMedia('(prefers-color-scheme: dark)'); this._darkMqlListener = (e) => { if (!localStorage.getItem('baby_theme')) { this.theme = e.matches ? 'dark' : 'light'; this.applyTheme(); } }; this._darkMql.addEventListener('change', this._darkMqlListener); // Parallel bootstrap fetches this.checkYesterdayVitD(); this.refreshTodaySummary(); this.hydrateLatestMilk(); }, destroy() { if (this._clockTimer) { clearInterval(this._clockTimer); this._clockTimer = null; } this._revokePreview(); if (this._darkMql && this._darkMqlListener) { this._darkMql.removeEventListener('change', this._darkMqlListener); } if (window.BabyCharts) BabyCharts.disposeAll(); }, // --- Theme --- toggleTheme() { this.theme = this.theme === 'dark' ? 'light' : 'dark'; this.applyTheme(); localStorage.setItem('baby_theme', this.theme); }, applyTheme() { document.documentElement.dataset.theme = this.theme; const meta = document.getElementById('theme-color-meta'); if (meta) { meta.content = this.theme === 'dark' ? '#1A1816' : '#D4896A'; } // iOS status bar content style (black-translucent needs dark icons on light bg) let statusMeta = document.querySelector('meta[name="apple-mobile-web-app-status-bar-style"]'); if (statusMeta) { statusMeta.content = this.theme === 'dark' ? 'black-translucent' : 'default'; } // Re-render charts on current view to pick up new colors if (this.view === 'report' && this.report._rendered) { this.$nextTick(() => this.renderReportCharts()); } if (this.view === 'growth' && this.growth.initialized) { // Full reload picks up CSS var colors; resize alone keeps old palette this.growth.initialized = false; this.$nextTick(() => this.loadGrowthData()); } }, // --- View Switching --- async switchToHistory() { this.view = 'history'; await this.fetchHistory(); }, async switchToGrowth() { this.view = 'growth'; // Start ECharts load early (no need to await — renderChart will handle it) BabyCharts.ensureECharts(); if (!this.growth.initialized && !this.growth.loading) { await this.loadGrowthData(); } else if (this.growth.initialized) { this.$nextTick(() => BabyCharts.resizeAll()); } }, switchToRecord() { this.view = 'record'; // Keep edit state if mid-edit; otherwise refresh summary quietly if (!this.isEditing) this.refreshTodaySummary(); }, async switchToReport() { this.view = 'report'; BabyCharts.ensureECharts(); if (!this.report._rendered && !this.report.loading) { await this.loadReportData(); } else if (this.report._rendered) { this.$nextTick(() => this.renderReportCharts()); } }, // --- Today summary / last milk --- async refreshTodaySummary() { try { const today = BabyUtils.getTodayCN(); const start = `${today}T00:00:00+08:00`; const end = `${today}T23:59:59.999+08:00`; const { data, error } = await BabyDB.fetchRecords(start, end); if (error) throw new Error(error.message); const milks = data.filter(i => i.type === 'milk'); this.todaySummary = { loading: false, milk: milks.reduce((a, b) => a + (b.amount || 0), 0), food: data.filter(i => i.type === 'food').length, poo: data.filter(i => i.type === 'poo').length, vitD: data.some(i => BabyUtils.isVitDRecord(i)), // records are desc by time → first milk is latest lastMilkAt: milks[0]?.recorded_at || null }; } catch (err) { console.warn('Today summary failed:', err); this.todaySummary.loading = false; } }, async hydrateLatestMilk() { try { const { data } = await BabyDB.fetchLatestMilk(); if (!data) return; if (!this.todaySummary.lastMilkAt) { this.todaySummary.lastMilkAt = data.recorded_at; } if (data.amount && !this.lastMilkAmount) { this.lastMilkAmount = data.amount; } if (data.sub_type && !this.lastMilkSubType) { this.lastMilkSubType = data.sub_type; } // If form is still empty milk defaults, apply once if (!this.isEditing && this.form.type === 'milk' && !this.form.amount && data.amount) { this.form.amount = String(data.amount); if (data.sub_type) this.form.subType = data.sub_type; } } catch (err) { console.warn('Latest milk hydrate failed:', err); } }, // --- Form --- toggleExpand(id) { this.expandedId = this.expandedId === id ? null : id; }, /** Shared type defaults — used by both setType() and resetForm() */ _applyTypeDefaults(t) { if (t === 'milk') { this.form.subType = this.lastMilkSubType || '配方奶'; this.form.amount = this.lastMilkAmount ? String(this.lastMilkAmount) : ''; } else if (t === 'food') { this.form.subType = ''; this.form.amount = ''; } else if (t === 'poo') { this.form.subType = '正常糊状'; this.form.amount = ''; } else if (t === 'meds') { this.form.subType = '维D'; this.form.amount = ''; } }, setType(t) { if (this.form.type === t) return; this.form.type = t; this.form.notes = this.isEditing ? this.form.notes : ''; this.form.isNewFood = false; this.form.tags = '无异常'; // Clear image when leaving poo if (t !== 'poo') { this._revokePreview(); this.form.imageFile = null; this.form.previewUrl = null; this.existingImageUrl = null; this.removeImage = true; const input = document.querySelector('.file-input'); if (input) input.value = ''; } this._applyTypeDefaults(t); }, setMilkAmount(ml) { this.form.amount = String(ml); }, handleFileSelect(event) { const file = event.target.files?.[0]; if (!file) return; if (!file.type.startsWith('image/')) { return BabyUtils.toast('请选择图片文件', 'warning'); } if (file.size > 5 * 1024 * 1024) { return BabyUtils.toast('图片太大了,请小于 5MB', 'warning'); } this._revokePreview(); this.form.imageFile = file; this.form.previewUrl = URL.createObjectURL(file); this._previewUrl = this.form.previewUrl; this.removeImage = false; }, clearFile() { this._revokePreview(); this.form.imageFile = null; this.form.previewUrl = null; this.existingImageUrl = null; this.removeImage = true; const input = document.querySelector('.file-input'); if (input) input.value = ''; }, _revokePreview() { if (this._previewUrl) { BabyUtils.revokeObjectURL(this._previewUrl); this._previewUrl = null; } }, async uploadImage(file) { const api = this._cfg.IMGBED_API; if (!api || api.includes('YOUR_')) { throw new Error('图片上传服务未配置'); } const formData = new FormData(); formData.append('file', file); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 30000); try { const response = await fetch(api, { method: 'POST', body: formData, signal: controller.signal }); if (!response.ok) throw new Error(`上传失败 (${response.status})`); const json = await response.json(); if (Array.isArray(json) && json[0]?.src) { return new URL(json[0].src, api).href; } if (json.url) return json.url; if (json.data?.url) return json.data.url; if (json.src) return new URL(json.src, api).href; throw new Error('无法解析图片返回格式'); } finally { clearTimeout(timeoutId); } }, validateForm() { if (!this.form.date || !this.form.time) { BabyUtils.toast('请选择记录时间', 'warning'); return false; } if (this.form.type === 'milk') { const amount = parseInt(this.form.amount, 10); if (!amount || amount <= 0) { BabyUtils.toast('请填写有效奶量', 'warning'); return false; } if (amount > 500) { BabyUtils.toast('奶量看起来不太对,请确认', 'warning'); return false; } } if (this.form.type === 'food' && !String(this.form.subType || '').trim()) { BabyUtils.toast('请填写食材', 'warning'); return false; } return true; }, buildPayload(imageUrl) { return { recorded_at: BabyUtils.toISOFromCNLocal(this.formDatetime), type: this.form.type, sub_type: this.form.subType || null, amount: this.form.amount ? parseInt(this.form.amount, 10) : null, is_new_food: this.form.type === 'food' ? !!this.form.isNewFood : false, tags: this.form.type === 'food' ? this.form.tags : null, notes: (this.form.notes || '').trim() || null, image_url: imageUrl }; }, async submit() { if (this.loading) return; if (!this.validateForm()) return; this.loading = true; try { // Resolve image let imageUrl = this.removeImage ? null : this.existingImageUrl; if (this.form.type === 'poo' && this.form.imageFile) { this.uploading = true; try { imageUrl = await this.uploadImage(this.form.imageFile); } catch (e) { BabyUtils.toast('图片上传失败: ' + e.message, 'error'); return; } finally { this.uploading = false; } } else if (this.form.type !== 'poo') { imageUrl = null; } const payload = this.buildPayload(imageUrl); const editing = this.isEditing; if (editing) { const { error } = await BabyDB.updateRecord(this.editingId, payload); if (error) throw new Error(error.message); BabyUtils.toast('已更新', 'success'); } else { const { error } = await BabyDB.insertRecord(payload); if (error) throw new Error(error.message); BabyUtils.toast('保存成功', 'success'); } // Remember milk prefs if (this.form.type === 'milk' && payload.amount) { this.lastMilkAmount = payload.amount; this.lastMilkSubType = payload.sub_type || '配方奶'; saveLastMilkPref(this.lastMilkAmount, this.lastMilkSubType); } const savedDate = this.form.date; // Invalidate cache for the affected date so next view shows fresh data delete this._historyCache[savedDate]; if (this.form.type === 'meds') { this.vitamindReminder.show = false; } this.resetForm(true); this.refreshTodaySummary(); this.historyDate = savedDate; await this.switchToHistory(); } catch (err) { console.error('Submit error:', err); BabyUtils.toast((this.isEditing ? '更新' : '保存') + '失败: ' + (err.message || '未知错误'), 'error'); } finally { this.loading = false; this.uploading = false; } }, /** Reset form; keepType=true preserves current type for quick re-entry */ resetForm(keepType) { const type = keepType ? this.form.type : 'milk'; this.editingId = null; this.existingImageUrl = null; this.removeImage = false; this._revokePreview(); this.form.imageFile = null; this.form.previewUrl = null; this._previewUrl = null; const input = document.querySelector('.file-input'); if (input) input.value = ''; this.form.type = type; this.form.notes = ''; this.form.isNewFood = false; this.form.tags = '无异常'; this.resetTimeCN(); this._applyTypeDefaults(type); }, // --- Edit --- startEdit(log) { if (!log?.id) return; this.editingId = log.id; this.expandedId = null; this.removeImage = false; this._revokePreview(); this.form.imageFile = null; this._previewUrl = null; this.form.type = log.type; this.setFormDatetime(isoToCNLocal(log.recorded_at)); this.form.subType = log.sub_type || ( log.type === 'milk' ? '配方奶' : log.type === 'poo' ? '正常糊状' : log.type === 'meds' ? '维D' : '' ); this.form.amount = log.amount != null ? String(log.amount) : ''; this.form.isNewFood = !!log.is_new_food; this.form.tags = log.tags || '无异常'; this.form.notes = log.notes || ''; if (log.type === 'poo' && log.image_url) { this.existingImageUrl = log.image_url; this.form.previewUrl = log.image_url; } else { this.existingImageUrl = null; this.form.previewUrl = null; } this.view = 'record'; // Scroll form into view on next tick this.$nextTick(() => { const el = document.querySelector('.edit-banner') || document.querySelector('.modern-card'); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); }); }, cancelEdit() { this.resetForm(false); BabyUtils.toast('已取消编辑', 'info'); }, // --- History --- computeStats(data) { return { milk: data.filter(i => i.type === 'milk').reduce((a, b) => a + (b.amount || 0), 0), food: data.filter(i => i.type === 'food').length, poo: data.filter(i => i.type === 'poo').length, vitD: data.some(i => BabyUtils.isVitDRecord(i)) }; }, async fetchHistory() { if (!this.historyDate) return; // Guard against future dates — show friendly message instead of empty const today = BabyUtils.getTodayCN(); if (this.historyDate > today) { this.logs = []; this.stats = { milk: 0, food: 0, poo: 0, vitD: false }; return; } // Return cached result if we already have this date if (this._historyCache[this.historyDate]) { const cached = this._historyCache[this.historyDate]; this.logs = cached.logs; this.stats = cached.stats; if (this.historyDate === today) { this.todaySummary = { ...this.todaySummary, ...cached.todaySummary }; } return; } // Deduplicate concurrent fetches for the same date if (this._fetchHistoryLock) { await this._fetchHistoryLock; return; } this.loading = true; this.expandedId = null; let resolveLock; this._fetchHistoryLock = new Promise(r => { resolveLock = r; }); try { const start = `${this.historyDate}T00:00:00+08:00`; const end = `${this.historyDate}T23:59:59.999+08:00`; const { data, error } = await BabyDB.fetchRecords(start, end); if (error) throw new Error(error.message); this.logs = data; this.stats = this.computeStats(data); const cacheEntry = { logs: data, stats: { ...this.stats }, todaySummary: null }; // Keep today summary in sync when viewing today if (this.historyDate === BabyUtils.getTodayCN()) { const milks = data.filter(i => i.type === 'milk'); this.todaySummary = { loading: false, milk: this.stats.milk, food: this.stats.food, poo: this.stats.poo, vitD: this.stats.vitD, lastMilkAt: milks[0]?.recorded_at || this.todaySummary.lastMilkAt }; cacheEntry.todaySummary = { ...this.todaySummary }; } // Cache the result this._historyCache[this.historyDate] = cacheEntry; } catch (err) { console.error('Fetch history error:', err); BabyUtils.toast('加载失败: ' + err.message, 'error'); this.logs = []; this.stats = { milk: 0, food: 0, poo: 0, vitD: false }; } finally { this.loading = false; this._fetchHistoryLock = null; if (resolveLock) resolveLock(); } }, async deleteRecord(id) { if (!confirm('确认删除这条记录?')) return; try { const { error } = await BabyDB.deleteRecord(id); if (error) throw new Error(error.message); BabyUtils.toast('已删除', 'success'); // Invalidate cache for current history date delete this._historyCache[this.historyDate]; // If deleting the record currently being edited if (this.editingId === id) this.resetForm(false); await this.fetchHistory(); this.refreshTodaySummary(); this.checkYesterdayVitD(); } catch (err) { BabyUtils.toast('删除失败: ' + err.message, 'error'); } }, async exportData() { if (this.exporting) return; this.exporting = true; try { const today = BabyUtils.getTodayCN(); const startDate = BabyUtils.addDays(today, -29); const start = `${startDate}T00:00:00+08:00`; const end = `${today}T23:59:59.999+08:00`; const { data: foodData, error } = await BabyDB.fetchRecordsByType( start, end, ['milk', 'food'] ); if (error) throw new Error(error.message); let growthRecord = null; try { const url = this._cfg.GROW_API_URL; if (url && !url.includes('YOUR_')) { const res = await fetch(url); if (res.ok) { const json = await res.json(); if (Array.isArray(json?.data) && json.data.length) { growthRecord = [...json.data].sort( (a, b) => new Date(b.checkDate) - new Date(a.checkDate) )[0]; } } } } catch (e) { console.warn('获取生长数据失败:', e); } const birthDateStr = this._cfg.BIRTH_DATE || '未知'; const birthDate = BabyUtils.parseFlexibleDate(birthDateStr); const currentAge = BabyUtils.getMonthDiff(birthDate, new Date()).toFixed(1); const milkRecords = (foodData || []).filter(r => r.type === 'milk'); const foodRecords = (foodData || []).filter(r => r.type === 'food'); const totalMilk = milkRecords.reduce((a, b) => a + (b.amount || 0), 0); const activeDays = new Set( (foodData || []).map(r => { const d = new Date(new Date(r.recorded_at).getTime() + 8 * 3600 * 1000); return d.toISOString().slice(0, 10); }) ); const dayCount = Math.max(activeDays.size, 1); const avgDailyMilk = Math.round(totalMilk / dayCount); let text = ''; text += `【宝宝基本信息】\n`; text += `出生日期: ${birthDateStr}\n`; text += `当前月龄: 约${currentAge}个月\n`; text += `\n【最近一次生长记录】\n`; if (growthRecord) { text += `检查日期: ${growthRecord.checkDate}\n`; text += `体重: ${growthRecord.weight}kg\n`; text += `身高: ${growthRecord.height}cm\n`; text += `头围: ${growthRecord.headCircumference}cm\n`; } else { text += `暂无生长数据\n`; } text += `\n【最近30天饮食统计】\n`; text += `有记录天数: ${activeDays.size}天\n`; text += `喝奶记录: ${milkRecords.length}次\n`; text += `总奶量: ${totalMilk}ml\n`; text += `日均奶量: ${avgDailyMilk}ml(按有记录天数)\n`; text += `辅食记录: ${foodRecords.length}次\n`; text += `\n【详细饮食记录(按日期分组)】\n`; const groupedByDate = {}; (foodData || []).forEach(record => { const d = new Date(new Date(record.recorded_at).getTime() + 8 * 3600 * 1000); const date = d.toISOString().slice(0, 10); if (!groupedByDate[date]) groupedByDate[date] = []; groupedByDate[date].push(record); }); Object.keys(groupedByDate) .sort((a, b) => b.localeCompare(a)) .forEach(date => { const records = groupedByDate[date]; const dayMilk = records .filter(r => r.type === 'milk') .reduce((a, b) => a + (b.amount || 0), 0); const dayFoods = records .filter(r => r.type === 'food') .map(r => r.sub_type) .filter(Boolean); text += `\n${date} (${BabyUtils.getDayLabel(date)}):\n`; if (dayMilk > 0) text += ` 喝奶: ${dayMilk}ml\n`; if (dayFoods.length > 0) text += ` 辅食: ${dayFoods.join(';')}\n`; }); text += `\n【备注】\n`; text += `以上数据可用于分析宝宝的饮食结构和生长发育情况,请根据这些信息给出科学的饮食建议。`; const copied = await BabyUtils.copyToClipboard(text); if (copied) { BabyUtils.toast('数据已复制,可发送给 AI 获取饮食建议', 'success'); } else { BabyUtils.toast('复制失败,请检查浏览器权限', 'error'); } } catch (err) { console.error('导出失败:', err); BabyUtils.toast('导出失败: ' + err.message, 'error'); } finally { this.exporting = false; } }, openImage(url) { if (!url) return; this.modalImage = url; this.showModal = true; }, closeModal() { this.showModal = false; this.modalImage = ''; }, // --- VitD Reminder --- async checkYesterdayVitD() { try { const todayStr = BabyUtils.getTodayCN(); const yesterdayStr = BabyUtils.getYesterdayCN(); const { today, yesterday } = await BabyDB.checkVitDStatus(yesterdayStr, todayStr); this.vitamindReminder.show = !today && !yesterday; this.vitamindReminder.checked = true; } catch (err) { console.warn('VitD check failed:', err); this.vitamindReminder.checked = true; } }, // --- Growth Charts --- async loadGrowthData() { this.growth.loading = true; this.growth.error = false; this.growth.errorMessage = ''; this.growth.aiVisible = false; try { const url = this._cfg.GROW_API_URL; if (!url || url.includes('YOUR_')) { throw new Error('生长数据 API 未配置'); } const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 15000); let res; try { res = await fetch(url, { signal: controller.signal }); } finally { clearTimeout(timeoutId); } if (!res.ok) throw new Error(`获取生长数据失败 (${res.status})`); const json = await res.json(); if (!json || !Array.isArray(json.data)) { throw new Error('生长数据格式不正确'); } const records = json.data; const birthDate = BabyUtils.parseFlexibleDate(this._cfg.BIRTH_DATE); const sorted = [...records] .filter(r => r.checkDate) .sort((a, b) => new Date(a.checkDate) - new Date(b.checkDate)); const latest = sorted[sorted.length - 1]; if (latest) this.fetchAIAssessment(latest); const userData = { weight: [], height: [], head: [], bmi: [] }; records.forEach(item => { if (!item.checkDate) return; const checkDate = new Date(item.checkDate); const age = BabyUtils.getMonthDiff(birthDate, checkDate); if (age < -0.5 || age > 25) return; if (item.weight > 0) userData.weight.push([age, item.weight]); if (item.height > 0) userData.height.push([age, item.height]); if (item.headCircumference > 0) userData.head.push([age, item.headCircumference]); const bmi = BabyUtils.calcBMI(item.weight, item.height); if (bmi) userData.bmi.push([age, bmi]); }); const sortFn = (a, b) => a[0] - b[0]; Object.values(userData).forEach(arr => arr.sort(sortFn)); this.growth.loading = false; this.growth.initialized = true; this.$nextTick(() => { BabyCharts.renderChart('bmiChart', 'BMI', userData.bmi, 'bmi', [12, 22]); BabyCharts.renderChart('weightChart', '体重 (kg)', userData.weight, 'weight', [0, 16]); BabyCharts.renderChart('heightChart', '身长 (cm)', userData.height, 'height', [45, 100]); BabyCharts.renderChart('headChart', '头围 (cm)', userData.head, 'head', [30, 52]); }); } catch (err) { console.error(err); this.growth.loading = false; this.growth.error = true; this.growth.errorMessage = err.name === 'AbortError' ? '请求超时,请稍后重试' : (err.message || '加载失败'); } }, async retryGrowth() { this.growth.initialized = false; await this.loadGrowthData(); }, async fetchAIAssessment(latestRecord) { const apiUrl = this._cfg.AI_API_URL; if (!apiUrl || apiUrl === '' || apiUrl.includes('YOUR_')) { this.growth.aiVisible = false; return; } this.growth.aiVisible = true; this.growth.aiText = '正在分析最新数据...'; try { const { checkDate, weight, height, headCircumference } = latestRecord; const birth = BabyUtils.parseFlexibleDate(this._cfg.BIRTH_DATE); const birthDateStr = [ birth.getFullYear(), String(birth.getMonth() + 1).padStart(2, '0'), String(birth.getDate()).padStart(2, '0') ].join('.'); const queryString = `男婴出生日期 ${birthDateStr} 检查日期:${checkDate} ` + `体重:${weight}kg 身高:${height}cm 头围:${headCircumference}cm ` + `根据中国男婴《WS/T 423—2022》标准,给出当前水平,` + `标明每个数值是什么范围之间(P50~P75)。150字左右`; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 15000); let res; try { res = await fetch(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: queryString }), signal: controller.signal }); } finally { clearTimeout(timeoutId); } if (!res.ok) throw new Error(`服务器返回错误 ${res.status}`); const text = await res.text(); if (!text || !text.trim()) throw new Error('AI 服务未返回数据'); this.growth.aiText = text.trim(); } catch (err) { console.error('AI API Error:', err); let errorMsg; if (err.name === 'AbortError') { errorMsg = 'AI 服务响应超时,请稍后重试'; } else if (/502|503/.test(err.message)) { errorMsg = 'AI 服务暂时不可用'; } else if (/Failed to fetch|NetworkError/i.test(err.message)) { errorMsg = '网络连接失败,请检查网络或 API 地址'; } else { errorMsg = err.message || '未知错误'; } this.growth.aiText = `智能评估暂时不可用\n\n${errorMsg}\n\n` + `可稍后切换到生长页重试,或检查 AI_API_URL 配置`; } }, // --- Report --- async loadReportData() { this.report.loading = true; this.report._rendered = false; try { const end = BabyUtils.getTodayCN(); const start = BabyUtils.addDays(end, -(this.report.range - 1)); const { data, error } = await BabyDB.fetchRecords( `${start}T00:00:00+08:00`, `${end}T23:59:59.999+08:00` ); if (error) throw new Error(error.message); this.report._data = data || []; this.computeReportSummary(); this.report.loading = false; this.$nextTick(() => this.renderReportCharts()); } catch (err) { console.error('Report load failed:', err); BabyUtils.toast('加载报告失败: ' + err.message, 'error'); this.report.loading = false; this.report.summary.activeDays = 0; } }, computeReportSummary() { const data = this.report._data || []; const milkRecords = data.filter(r => r.type === 'milk'); const foodRecords = data.filter(r => r.type === 'food'); const pooRecords = data.filter(r => r.type === 'poo'); // Active days const activeDays = new Set( data.map(r => { const d = new Date(new Date(r.recorded_at).getTime() + 8 * 3600 * 1000); return d.toISOString().slice(0, 10); }) ); // Milk stats const totalMilk = milkRecords.reduce((a, b) => a + (b.amount || 0), 0); const dayCount = Math.max(activeDays.size, 1); const avgDailyMilk = Math.round(totalMilk / dayCount); // Food variety: parse sub_type strings split by common delimiters const foodSet = new Set(); foodRecords.forEach(r => { const text = r.sub_type || ''; // Remove parenthesized portions like (半碗), then split const cleaned = text.replace(/[((][^))]*[))]/g, '、'); cleaned.split(/[、,,;;]+/).forEach(item => { const trimmed = item.trim(); if (trimmed && trimmed.length < 30) foodSet.add(trimmed); }); }); const foodLog = Array.from(foodSet).sort(); // Poo summary let normalPoo = 0, abnormalPoo = 0; pooRecords.forEach(r => { if (r.sub_type === '正常糊状') { normalPoo++; } else { abnormalPoo++; } }); this.report.summary = { avgDailyMilk, totalMilk, foodVariety: foodSet.size, poopCount: pooRecords.length, activeDays: activeDays.size }; this.report.foodLog = foodLog; this.report.pooSummary = { normal: normalPoo, abnormal: abnormalPoo, total: pooRecords.length }; }, renderReportCharts() { const data = this.report._data || []; if (!data.length) return; this.report._rendered = true; // --- Daily Milk Bar Chart --- const end = BabyUtils.getTodayCN(); const start = BabyUtils.addDays(end, -(this.report.range - 1)); const dailyMilk = {}; for (let i = 0; i < this.report.range; i++) { const d = BabyUtils.addDays(start, i); dailyMilk[d] = 0; } data.filter(r => r.type === 'milk').forEach(r => { const d = new Date(new Date(r.recorded_at).getTime() + 8 * 3600 * 1000) .toISOString().slice(0, 10); if (dailyMilk.hasOwnProperty(d)) { dailyMilk[d] += (r.amount || 0); } }); const dates = Object.keys(dailyMilk).sort(); const values = dates.map(d => dailyMilk[d]); const labels = dates.map(d => d.slice(5)); // "MM-DD" BabyCharts.renderBarChart('reportMilkBar', '每日奶量 (ml)', labels, values); // --- Milk Type Donut Chart --- const typeMap = {}; data.filter(r => r.type === 'milk').forEach(r => { const st = r.sub_type || '未知'; typeMap[st] = (typeMap[st] || 0) + (r.amount || 0); }); const donutData = Object.entries(typeMap).map(([name, value]) => ({ name, value })); BabyCharts.renderDonutChart('reportMilkDonut', '奶量分布', donutData); }, // --- Date / Time Helpers --- resetTimeCN() { this.setFormDatetime(BabyUtils.getNowCNStr().slice(0, 16)); }, getDayLabel(d) { return BabyUtils.getDayLabel(d); }, formatTimeCN(iso) { return BabyUtils.formatTimeCN(iso); }, formatType(t) { return BabyUtils.formatType(t); }, getIcon(t) { return BabyUtils.getIcon(t); }, changeDate(days) { this.historyDate = BabyUtils.addDays(this.historyDate, days); this.fetchHistory(); }, resetToToday() { this.historyDate = BabyUtils.getTodayCN(); this.fetchHistory(); }, getLatestMilkId() { if (this.historyDate !== BabyUtils.getTodayCN()) return null; const milkLog = this.logs.find(l => l.type === 'milk'); return milkLog ? milkLog.id : null; }, getTimeDiff(recordTime) { return BabyUtils.getTimeDiff(recordTime, this.now); }, /** Display string for today-summary last feed */ getLastFeedLabel() { const t = this.todaySummary.lastMilkAt; if (!t) return '暂无'; return this.getTimeDiff(t); }, getAgeDisplay() { return BabyUtils.getAgeDisplay(this._cfg.BIRTH_DATE, this.historyDate); }, isToday() { return this.historyDate === BabyUtils.getTodayCN(); } })); }); // Register shell-only service worker (static assets; never APIs) if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('./sw.js').catch((err) => { console.warn('SW register skipped:', err && err.message); }); }); }