calorie-tracker 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.html ADDED
@@ -0,0 +1,800 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
6
+ <meta name="apple-mobile-web-app-capable" content="yes">
7
+ <meta name="apple-mobile-web-app-status-bar-style" content="default">
8
+ <meta name="apple-mobile-web-app-title" content="卡路里记录">
9
+ <meta name="theme-color" content="#f2f2f7">
10
+ <link rel="manifest" href="manifest.json">
11
+ <link rel="apple-touch-icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect fill='%23007aff' width='100' height='100' rx='20'/><text x='50' y='68' text-anchor='middle' fill='white' font-size='50' font-family='sans-serif'>&#127860;</text></svg>">
12
+ <title>卡路里记录</title>
13
+ <style>
14
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
15
+ :root{--bg:#f2f2f7;--card:#fff;--text:#000;--secondary:#8e8e93;--blue:#007aff;--green:#34c759;--orange:#ff9500;--red:#ff3b30;--yellow:#ffcc00;--radius:14px;--safe-bottom:env(safe-area-inset-bottom,20px)}
16
+ body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Neue',sans-serif;background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased;-webkit-tap-highlight-color:transparent;user-select:none;overflow:hidden;height:100vh;height:100dvh}
17
+ #app{display:flex;flex-direction:column;height:100%}
18
+ #app.hidden{display:none}
19
+ #install-banner{display:none;background:#fff3cd;color:#856404;padding:10px 16px;font-size:13px;text-align:center;cursor:pointer}
20
+ #install-banner.show{display:block}
21
+
22
+ /* Header */
23
+ .header{background:var(--bg);padding:12px 16px;display:flex;align-items:center;justify-content:space-between;min-height:44px}
24
+ .header-title{font-size:18px;font-weight:700}
25
+ .header-btn{font-size:15px;color:var(--blue);background:none;border:none;cursor:pointer;padding:4px 8px}
26
+
27
+ /* Pages */
28
+ .page{display:none;flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch;padding-bottom:90px}
29
+ .page.active{display:flex;flex-direction:column}
30
+
31
+ /* TabBar */
32
+ .tabbar{position:fixed;bottom:0;left:0;right:0;background:#f9f9f9;border-top:1px solid #e0e0e0;display:flex;padding:6px 0 var(--safe-bottom);z-index:100}
33
+ .tab{flex:1;display:flex;flex-direction:column;align-items:center;gap:3px;background:none;border:none;cursor:pointer;padding:4px 0;-webkit-tap-highlight-color:transparent}
34
+ .tab .icon{font-size:22px}
35
+ .tab .label{font-size:10px;color:var(--secondary)}
36
+ .tab.active .label{color:var(--blue)}
37
+
38
+ /* Summary Card */
39
+ .summary{padding:10px 14px}
40
+ .summary-card{background:var(--card);border-radius:var(--radius);padding:16px 18px;display:flex;align-items:center;justify-content:space-between}
41
+ .summary-label{font-size:13px;color:var(--secondary)}
42
+ .summary-value{font-size:34px;font-weight:800;line-height:1}
43
+ .summary-unit{font-size:14px;color:var(--secondary);font-weight:400}
44
+ .ring-wrap{position:relative;width:58px;height:58px}
45
+ .ring-wrap svg{transform:rotate(-90deg)}
46
+ .ring-text{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700}
47
+
48
+ /* Entries */
49
+ .entries{padding:4px 14px}
50
+ .entry{background:var(--card);border-radius:12px;padding:14px 16px;margin-bottom:8px;display:flex;align-items:center;justify-content:space-between;cursor:pointer;transition:transform .15s}
51
+ .entry:active{transform:scale(.98)}
52
+ .entry-name{font-size:16px;font-weight:500}
53
+ .entry-time{font-size:12px;color:var(--secondary)}
54
+ .entry-cal{font-size:17px;font-weight:700}
55
+ .entry-cal span{font-size:11px;color:var(--secondary);font-weight:400}
56
+
57
+ /* FAB */
58
+ .fab{position:fixed;bottom:100px;left:50%;transform:translateX(-50%);width:52px;height:52px;border-radius:50%;background:var(--blue);box-shadow:0 4px 16px rgba(0,122,255,.4);display:flex;align-items:center;justify-content:center;color:#fff;font-size:26px;border:none;cursor:pointer;z-index:99;transition:transform .2s}
59
+ .fab:active{transform:translateX(-50%) scale(.9)}
60
+
61
+ /* Empty */
62
+ .empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--secondary)}
63
+ .empty .empty-icon{font-size:48px;margin-bottom:12px;opacity:.4}
64
+ .empty .empty-title{font-size:17px;font-weight:600}
65
+ .empty .empty-sub{font-size:13px;margin-top:4px;opacity:.8}
66
+
67
+ /* Sheet */
68
+ .sheet-overlay{position:fixed;inset:0;background:rgba(0,0,0,.3);z-index:200;display:none;animation:fadeIn .2s}
69
+ .sheet-overlay.show{display:block}
70
+ .sheet{position:fixed;bottom:0;left:0;right:0;background:#fff;border-radius:16px 16px 0 0;z-index:201;transform:translateY(100%);transition:transform .25s ease-out;padding-bottom:var(--safe-bottom)}
71
+ .sheet.show{transform:translateY(0)}
72
+ .sheet-item{padding:14px 20px;text-align:center;font-size:17px;color:var(--blue);border-bottom:1px solid #f0f0f0;cursor:pointer}
73
+ .sheet-cancel{padding:14px 20px;text-align:center;font-size:17px;font-weight:600;color:var(--text);margin-top:6px;border-top:6px solid #f0f0f0;cursor:pointer}
74
+
75
+ /* Analysis page */
76
+ .analysis-page{position:fixed;inset:0;background:var(--bg);z-index:150;display:none;flex-direction:column}
77
+ .analysis-page.show{display:flex}
78
+ .analysis-img{width:100%;height:240px;object-fit:contain;background:#e5e5ea}
79
+ .analysis-content{flex:1;display:flex;flex-direction:column;padding:16px;overflow-y:auto}
80
+ .analysis-state{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center}
81
+ .spinner{width:36px;height:36px;border-radius:50%;border:3px solid #e5e5ea;border-top-color:var(--blue);animation:spin .8s linear infinite;margin-bottom:14px}
82
+ @keyframes spin{to{transform:rotate(360deg)}}
83
+ .analysis-error{text-align:center;color:var(--secondary)}
84
+ .analysis-error .err-icon{font-size:40px;margin-bottom:8px}
85
+ .result-card{background:var(--card);border-radius:var(--radius);padding:18px;margin-bottom:16px}
86
+ .result-name{font-size:22px;font-weight:700;margin-bottom:12px}
87
+ .result-divider{height:1px;background:#e5e5ea;margin:10px 0}
88
+ .nutrients{display:flex;gap:10px;justify-content:space-around}
89
+ .nutrient{text-align:center}
90
+ .nutrient .n-val{font-size:18px;font-weight:700}
91
+ .nutrient .n-unit{font-size:10px;color:var(--secondary)}
92
+ .nutrient .n-label{font-size:11px;color:var(--secondary)}
93
+ .btn-row{display:flex;gap:12px;padding:8px 0 16px}
94
+ .btn{flex:1;padding:13px;border-radius:12px;text-align:center;font-size:16px;font-weight:600;border:none;cursor:pointer}
95
+ .btn-primary{background:var(--blue);color:#fff}
96
+ .btn-secondary{background:#e5e5ea;color:var(--text)}
97
+
98
+ /* Detail page */
99
+ .detail-page{position:fixed;inset:0;background:var(--bg);z-index:150;display:none;flex-direction:column}
100
+ .detail-page.show{display:flex}
101
+ .detail-section{background:var(--card);margin:10px 14px;border-radius:12px;overflow:hidden}
102
+ .detail-row{display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid #f0f0f0;font-size:15px}
103
+ .detail-row:last-child{border-bottom:none}
104
+ .detail-delete{padding:12px 16px;text-align:center;color:var(--red);font-size:15px;cursor:pointer}
105
+
106
+ /* Settings */
107
+ .settings-section{margin:12px 14px}
108
+ .settings-title{font-size:13px;color:#6d6d72;padding:6px 0;text-transform:uppercase}
109
+ .settings-card{background:var(--card);border-radius:12px;overflow:hidden}
110
+ .settings-row{padding:12px 16px;border-bottom:1px solid #f0f0f0;display:flex;justify-content:space-between;align-items:center;font-size:15px}
111
+ .settings-row:last-child{border-bottom:none}
112
+ .settings-input{border:none;font-size:15px;outline:none;background:none;width:100%;padding:12px 16px}
113
+ .settings-input::placeholder{color:#c7c7cc}
114
+ .settings-footer{font-size:12px;color:var(--secondary);padding:6px 16px 12px}
115
+ .settings-link{color:var(--blue);text-decoration:none}
116
+
117
+ /* Stats */
118
+ .stats-cards{display:flex;gap:10px;padding:12px 14px}
119
+ .stat-card{flex:1;background:var(--card);border-radius:12px;padding:14px}
120
+ .stat-title{font-size:13px;color:var(--secondary)}
121
+ .stat-value{font-size:26px;font-weight:800;margin-top:2px}
122
+ .stat-unit{font-size:11px;color:var(--secondary)}
123
+ .chart-card{background:var(--card);border-radius:var(--radius);margin:4px 14px;padding:18px}
124
+ .chart-title{font-size:13px;color:var(--secondary);margin-bottom:14px}
125
+ .bars{display:flex;gap:6px;align-items:flex-end;height:160px}
126
+ .bar-col{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;gap:4px}
127
+ .bar-bar{width:100%;max-width:38px;border-radius:4px 4px 0 0;min-height:2px;transition:height .4s}
128
+ .bar-bar .under{background:#34c759}.bar-bar.over{background:var(--red)}
129
+ .bar-bl{font-size:10px;color:var(--secondary)}
130
+ .bar-bv{font-size:9px;color:var(--secondary)}
131
+
132
+ /* Toast */
133
+ .toast{position:fixed;top:60px;left:50%;transform:translateX(-50%);background:#333;color:#fff;padding:10px 20px;border-radius:20px;font-size:14px;z-index:999;opacity:0;transition:opacity .3s;pointer-events:none}
134
+ .toast.show{opacity:1}
135
+
136
+ /* loading dots */
137
+ .loading-text{font-size:16px;font-weight:600;margin-top:4px}
138
+ </style>
139
+ </head>
140
+ <body>
141
+
142
+ <div id="install-banner" onclick="this.classList.remove('show')">点击添加到主屏幕,使用更方便 →</div>
143
+ <div id="toast" class="toast"></div>
144
+ <div id="app">
145
+
146
+ <!-- ====== HEADER ====== -->
147
+ <div class="header" id="header">
148
+ <span id="header-back" class="header-btn" style="visibility:hidden">← 返回</span>
149
+ <span class="header-title" id="header-title">饮食记录</span>
150
+ <button class="header-btn" id="header-right">⚙</button>
151
+ </div>
152
+
153
+ <!-- ====== HISTORY PAGE ====== -->
154
+ <div class="page active" id="page-history">
155
+ <div class="summary" id="summary-area"></div>
156
+ <div class="entries" id="entry-list"></div>
157
+ <div class="empty" id="empty-state" style="display:none">
158
+ <div class="empty-icon">🍽️</div>
159
+ <div class="empty-title">还没有记录</div>
160
+ <div class="empty-sub">点击下方按钮拍照</div>
161
+ </div>
162
+ </div>
163
+
164
+ <!-- ====== STATS PAGE ====== -->
165
+ <div class="page" id="page-stats">
166
+ <div class="stats-cards" id="stats-summary"></div>
167
+ <div class="chart-card">
168
+ <div class="chart-title">本周每日摄入</div>
169
+ <div class="bars" id="chart-bars"></div>
170
+ </div>
171
+ </div>
172
+
173
+ <!-- ====== SETTINGS PAGE (in-page) ====== -->
174
+ <div class="page" id="page-settings">
175
+ <div class="settings-section">
176
+ <div class="settings-title">API 密钥</div>
177
+ <div class="settings-card">
178
+ <input class="settings-input" type="password" id="api-key-input" placeholder="请输入 API Key">
179
+ <div class="settings-footer">前往 open.bigmodel.cn 注册并获取免费 API Key</div>
180
+ </div>
181
+ </div>
182
+ <div class="settings-section">
183
+ <div class="settings-title">模型选择</div>
184
+ <div class="settings-card">
185
+ <div class="settings-row" id="model-select" onclick="toggleModel()">
186
+ <span>模型</span><span style="color:var(--secondary)" id="model-name">glm-4v-flash ›</span>
187
+ </div>
188
+ <div class="settings-footer">glm-4v-flash 免费,glm-4v 付费效果更好</div>
189
+ </div>
190
+ </div>
191
+ <div class="settings-section">
192
+ <div class="settings-title">关于</div>
193
+ <div class="settings-card">
194
+ <div class="settings-row"><span>版本</span><span style="color:var(--secondary)">1.0.0</span></div>
195
+ <div class="settings-row"><a class="settings-link" href="https://open.bigmodel.cn" target="_blank">智谱 AI 开放平台 ↗</a></div>
196
+ </div>
197
+ </div>
198
+ </div>
199
+
200
+ <!-- ====== TAB BAR ====== -->
201
+ <div class="tabbar">
202
+ <button class="tab active" data-page="history"><span class="icon">☰</span><span class="label">记录</span></button>
203
+ <button class="tab" data-page="stats"><span class="icon">📊</span><span class="label">统计</span></button>
204
+ </div>
205
+
206
+ </div><!-- #app -->
207
+
208
+ <!-- ====== SHEET ====== -->
209
+ <div class="sheet-overlay" id="sheet-overlay"></div>
210
+ <div class="sheet" id="sheet">
211
+ <div class="sheet-item" onclick="capturePhoto()">📷 拍照</div>
212
+ <div class="sheet-item" onclick="pickPhoto()">🖼 从相册选择</div>
213
+ <div class="sheet-cancel" onclick="closeSheet()">取消</div>
214
+ </div>
215
+
216
+ <!-- ====== ANALYSIS PAGE ====== -->
217
+ <div class="analysis-page" id="analysis-page">
218
+ <div class="header">
219
+ <button class="header-btn" id="analysis-back">← 返回</button>
220
+ <span class="header-title" id="analysis-title">分析中</span>
221
+ <span></span>
222
+ </div>
223
+ <img class="analysis-img" id="analysis-img" src="" alt="">
224
+ <div class="analysis-content" id="analysis-content"></div>
225
+ </div>
226
+
227
+ <!-- ====== DETAIL PAGE ====== -->
228
+ <div class="detail-page" id="detail-page">
229
+ <div class="header">
230
+ <button class="header-btn" onclick="closeDetail()">← 返回</button>
231
+ <span class="header-title">详情</span>
232
+ <span></span>
233
+ </div>
234
+ <div class="detail-section" id="detail-food-section"></div>
235
+ <div class="detail-section" id="detail-nutrition-section"></div>
236
+ <div class="detail-section" id="detail-time-section"></div>
237
+ <div class="detail-section" onclick="deleteEntry()"><div class="detail-delete">删除记录</div></div>
238
+ </div>
239
+
240
+ <!-- Hidden file inputs -->
241
+ <input type="file" accept="image/*" capture="environment" id="camera-input" style="display:none" onchange="handleImageSelect(event)">
242
+ <input type="file" accept="image/*" id="photo-input" style="display:none" onchange="handleImageSelect(event)">
243
+
244
+ <script>
245
+ // ============================================================
246
+ // STORAGE - IndexedDB
247
+ // ============================================================
248
+ const DB_NAME = 'calorie_tracker';
249
+ const DB_VERSION = 1;
250
+ let db;
251
+
252
+ function openDB() {
253
+ return new Promise((resolve, reject) => {
254
+ const req = indexedDB.open(DB_NAME, DB_VERSION);
255
+ req.onupgradeneeded = (e) => {
256
+ const db = e.target.result;
257
+ if (!db.objectStoreNames.contains('entries')) {
258
+ const store = db.createObjectStore('entries', { keyPath: 'id' });
259
+ store.createIndex('createdAt', 'createdAt', { unique: false });
260
+ }
261
+ };
262
+ req.onsuccess = (e) => { db = e.target.result; resolve(db); };
263
+ req.onerror = (e) => reject(e.target.error);
264
+ });
265
+ }
266
+
267
+ function saveEntry(entry) {
268
+ return new Promise((resolve, reject) => {
269
+ const tx = db.transaction('entries', 'readwrite');
270
+ tx.objectStore('entries').put(entry);
271
+ tx.oncomplete = resolve;
272
+ tx.onerror = (e) => reject(e.target.error);
273
+ });
274
+ }
275
+
276
+ function getAllEntries() {
277
+ return new Promise((resolve, reject) => {
278
+ const tx = db.transaction('entries', 'readonly');
279
+ const req = tx.objectStore('entries').index('createdAt').openCursor(null, 'prev');
280
+ const entries = [];
281
+ req.onsuccess = (e) => {
282
+ const cursor = e.target.result;
283
+ if (cursor) { entries.push(cursor.value); cursor.continue(); }
284
+ else resolve(entries);
285
+ };
286
+ req.onerror = (e) => reject(e.target.error);
287
+ });
288
+ }
289
+
290
+ function deleteEntryFromDB(id) {
291
+ return new Promise((resolve, reject) => {
292
+ const tx = db.transaction('entries', 'readwrite');
293
+ tx.objectStore('entries').delete(id);
294
+ tx.oncomplete = resolve;
295
+ tx.onerror = (e) => reject(e.target.error);
296
+ });
297
+ }
298
+
299
+ // ============================================================
300
+ // HELPERS
301
+ // ============================================================
302
+ function getAPIKey() { return localStorage.getItem('zp_api_key') || ''; }
303
+ function setAPIKey(k) { localStorage.setItem('zp_api_key', k); }
304
+ function getModel() { return localStorage.getItem('zp_model') || 'glm-4v-flash'; }
305
+ function setModel(m) { localStorage.setItem('zp_model', m); }
306
+ function today() { return new Date().toISOString().split('T')[0]; }
307
+ function weekStart() {
308
+ const d = new Date(); d.setHours(0,0,0,0);
309
+ const day = d.getDay(); const diff = d.getDate() - day + (day === 0 ? -6 : 1);
310
+ d.setDate(diff); return d;
311
+ }
312
+
313
+ function showToast(msg) {
314
+ const t = document.getElementById('toast');
315
+ t.textContent = msg; t.classList.add('show');
316
+ clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove('show'), 2000);
317
+ }
318
+
319
+ function resizeImage(file, maxDim = 1024) {
320
+ return new Promise((resolve) => {
321
+ const reader = new FileReader();
322
+ reader.onload = (e) => {
323
+ const img = new Image();
324
+ img.onload = () => {
325
+ let { width, height } = img;
326
+ const scale = Math.min(maxDim / width, maxDim / height, 1);
327
+ width *= scale; height *= scale;
328
+ const canvas = document.createElement('canvas');
329
+ canvas.width = Math.round(width); canvas.height = Math.round(height);
330
+ const ctx = canvas.getContext('2d');
331
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
332
+ resolve(canvas.toDataURL('image/jpeg', 0.8).split(',')[1]);
333
+ };
334
+ img.src = e.target.result;
335
+ };
336
+ reader.readAsDataURL(file);
337
+ });
338
+ }
339
+
340
+ // ============================================================
341
+ // SHEET
342
+ // ============================================================
343
+ function openSheet() {
344
+ document.getElementById('sheet-overlay').classList.add('show');
345
+ document.getElementById('sheet').classList.add('show');
346
+ }
347
+ function closeSheet() {
348
+ document.getElementById('sheet-overlay').classList.remove('show');
349
+ document.getElementById('sheet').classList.remove('show');
350
+ }
351
+ document.getElementById('sheet-overlay').addEventListener('click', closeSheet);
352
+
353
+ // ============================================================
354
+ // CAMERA & PHOTO
355
+ // ============================================================
356
+ function capturePhoto() {
357
+ closeSheet();
358
+ document.getElementById('camera-input').click();
359
+ }
360
+ function pickPhoto() {
361
+ closeSheet();
362
+ document.getElementById('photo-input').click();
363
+ }
364
+
365
+ let currentImageBase64 = '';
366
+ let currentImageFile = null;
367
+
368
+ async function handleImageSelect(e) {
369
+ const file = e.target.files[0];
370
+ if (!file) return;
371
+ currentImageFile = file;
372
+
373
+ // Show preview
374
+ const reader = new FileReader();
375
+ reader.onload = (ev) => {
376
+ document.getElementById('analysis-img').src = ev.target.result;
377
+ };
378
+ reader.readAsDataURL(file);
379
+
380
+ // Show analysis page
381
+ openAnalysisPage('analyzing');
382
+
383
+ try {
384
+ const base64 = await resizeImage(file);
385
+ currentImageBase64 = base64;
386
+ await runAnalysis(base64);
387
+ } catch (err) {
388
+ showAnalysisError('图片处理失败: ' + err.message);
389
+ }
390
+
391
+ // Reset input
392
+ e.target.value = '';
393
+ }
394
+
395
+ // ============================================================
396
+ // API CALL
397
+ // ============================================================
398
+ async function runAnalysis(imageBase64) {
399
+ const apiKey = getAPIKey();
400
+ if (!apiKey) {
401
+ showAnalysisError('请先在设置中配置 API 密钥');
402
+ return;
403
+ }
404
+
405
+ const model = getModel();
406
+ const prompt = `请识别图片中的食物,并估算以下信息。请严格以JSON格式回复,不要包含任何其他文字:
407
+ {
408
+ "foodName": "食物名称",
409
+ "calories": 热量千卡数(整数),
410
+ "protein": 蛋白质克数,
411
+ "fat": 脂肪克数,
412
+ "carbs": 碳水化合物克数
413
+ }
414
+ 如果无法识别食物,请将foodName设为"未知食物",calories设为0。如果无法确定某个营养素的数值,该字段填空值null。`;
415
+
416
+ try {
417
+ const resp = await fetch('https://open.bigmodel.cn/api/paas/v4/chat/completions', {
418
+ method: 'POST',
419
+ headers: {
420
+ 'Content-Type': 'application/json',
421
+ 'Authorization': 'Bearer ' + apiKey
422
+ },
423
+ body: JSON.stringify({
424
+ model: model,
425
+ messages: [{
426
+ role: 'user',
427
+ content: [
428
+ { type: 'text', text: prompt },
429
+ { type: 'image_url', image_url: { url: imageBase64 } }
430
+ ]
431
+ }],
432
+ max_tokens: 500,
433
+ temperature: 0.1
434
+ })
435
+ });
436
+
437
+ if (!resp.ok) {
438
+ const errText = await resp.text();
439
+ if (resp.status === 401) throw new Error('API 密钥无效,请在设置中更新');
440
+ if (resp.status === 429) throw new Error('请求过于频繁,请稍后重试');
441
+ throw new Error('服务器错误 (HTTP ' + resp.status + '): ' + errText.slice(0, 100));
442
+ }
443
+
444
+ const data = await resp.json();
445
+ const content = data.choices?.[0]?.message?.content;
446
+ if (!content) throw new Error('API 返回为空');
447
+
448
+ // Parse JSON from AI response
449
+ let cleaned = content.replace(/```json/g, '').replace(/```/g, '').trim();
450
+ const start = cleaned.indexOf('{');
451
+ const end = cleaned.lastIndexOf('}');
452
+ if (start === -1 || end === -1 || start >= end) throw new Error('AI 返回格式异常');
453
+ cleaned = cleaned.slice(start, end + 1);
454
+
455
+ const result = JSON.parse(cleaned);
456
+
457
+ if (result.foodName === '未知食物' || result.calories === 0) {
458
+ showAnalysisError('未能识别图片中的食物,请换一张清晰的图片');
459
+ return;
460
+ }
461
+
462
+ showAnalysisResult(result);
463
+
464
+ } catch (err) {
465
+ if (err.message.includes('Failed to fetch') || err.message.includes('NetworkError')) {
466
+ showAnalysisError('网络连接失败,请检查网络后重试。如果持续失败,可能是浏览器跨域限制,可尝试在 Safari 中打开。');
467
+ } else {
468
+ showAnalysisError(err.message);
469
+ }
470
+ }
471
+ }
472
+
473
+ // ============================================================
474
+ // ANALYSIS PAGE
475
+ // ============================================================
476
+ let currentResult = null;
477
+
478
+ function openAnalysisPage(state) {
479
+ document.getElementById('analysis-page').classList.add('show');
480
+ document.getElementById('analysis-title').textContent = state === 'analyzing' ? '分析中' : '分析结果';
481
+ const content = document.getElementById('analysis-content');
482
+ if (state === 'analyzing') {
483
+ content.innerHTML = '<div class="analysis-state"><div class="spinner"></div><div class="loading-text">正在分析食物...</div></div>';
484
+ }
485
+ }
486
+
487
+ function showAnalysisError(msg) {
488
+ document.getElementById('analysis-title').textContent = '分析失败';
489
+ const content = document.getElementById('analysis-content');
490
+ content.innerHTML = `
491
+ <div class="analysis-state">
492
+ <div class="analysis-error">
493
+ <div class="err-icon">⚠️</div>
494
+ <div>${msg}</div>
495
+ <button class="btn btn-primary" style="margin-top:16px;width:auto;padding:10px 30px" onclick="retryAnalysis()">重试</button>
496
+ </div>
497
+ </div>`;
498
+ }
499
+
500
+ function showAnalysisResult(result) {
501
+ currentResult = result;
502
+ document.getElementById('analysis-title').textContent = '分析结果';
503
+ const content = document.getElementById('analysis-content');
504
+ content.innerHTML = `
505
+ <div class="result-card">
506
+ <div class="result-name">${result.foodName}</div>
507
+ <div class="result-divider"></div>
508
+ <div class="nutrients">
509
+ <div class="nutrient"><div class="n-val" style="color:var(--orange)">${result.calories}</div><div class="n-unit">千卡</div><div class="n-label">热量</div></div>
510
+ <div class="nutrient"><div class="n-val" style="color:var(--blue)">${result.protein != null ? result.protein.toFixed(1) : '--'}</div><div class="n-unit">g</div><div class="n-label">蛋白质</div></div>
511
+ <div class="nutrient"><div class="n-val" style="color:var(--yellow)">${result.fat != null ? result.fat.toFixed(1) : '--'}</div><div class="n-unit">g</div><div class="n-label">脂肪</div></div>
512
+ <div class="nutrient"><div class="n-val" style="color:var(--green)">${result.carbs != null ? result.carbs.toFixed(1) : '--'}</div><div class="n-unit">g</div><div class="n-label">碳水</div></div>
513
+ </div>
514
+ </div>
515
+ <div class="btn-row">
516
+ <button class="btn btn-secondary" onclick="retakePhoto()">重新拍照</button>
517
+ <button class="btn btn-primary" onclick="saveResult()">保存记录</button>
518
+ </div>`;
519
+ }
520
+
521
+ function retryAnalysis() {
522
+ if (currentImageBase64) {
523
+ openAnalysisPage('analyzing');
524
+ runAnalysis(currentImageBase64);
525
+ }
526
+ }
527
+
528
+ function retakePhoto() {
529
+ closeAnalysis();
530
+ currentImageBase64 = '';
531
+ currentResult = null;
532
+ currentImageFile = null;
533
+ openSheet();
534
+ }
535
+
536
+ async function saveResult() {
537
+ if (!currentResult) return;
538
+ const entry = {
539
+ id: Date.now().toString(),
540
+ createdAt: new Date().toISOString(),
541
+ foodName: currentResult.foodName,
542
+ calories: currentResult.calories,
543
+ protein: currentResult.protein ?? null,
544
+ fat: currentResult.fat ?? null,
545
+ carbs: currentResult.carbs ?? null
546
+ };
547
+ await saveEntry(entry);
548
+ currentImageBase64 = '';
549
+ currentResult = null;
550
+ currentImageFile = null;
551
+ closeAnalysis();
552
+ await refreshUI();
553
+ showToast('已保存');
554
+ }
555
+
556
+ function closeAnalysis() {
557
+ document.getElementById('analysis-page').classList.remove('show');
558
+ document.getElementById('analysis-img').src = '';
559
+ document.getElementById('analysis-content').innerHTML = '';
560
+ }
561
+ document.getElementById('analysis-back').addEventListener('click', () => {
562
+ if (currentResult) {
563
+ retakePhoto();
564
+ } else {
565
+ closeAnalysis();
566
+ }
567
+ });
568
+
569
+ // ============================================================
570
+ // DETAIL PAGE
571
+ // ============================================================
572
+ let detailEntryId = null;
573
+
574
+ function openDetail(entry) {
575
+ detailEntryId = entry.id;
576
+ document.getElementById('detail-page').classList.add('show');
577
+ document.getElementById('detail-food-section').innerHTML = `<div class="detail-row"><span>食物</span><span style="font-weight:700;font-size:17px">${entry.foodName}</span></div>`;
578
+ document.getElementById('detail-nutrition-section').innerHTML = `
579
+ <div class="detail-row"><span>热量</span><span style="color:var(--orange);font-weight:700">${entry.calories} 千卡</span></div>
580
+ <div class="detail-row"><span>蛋白质</span><span style="color:var(--secondary)">${entry.protein != null ? entry.protein.toFixed(1) + ' g' : '未知'}</span></div>
581
+ <div class="detail-row"><span>脂肪</span><span style="color:var(--secondary)">${entry.fat != null ? entry.fat.toFixed(1) + ' g' : '未知'}</span></div>
582
+ <div class="detail-row"><span>碳水化合物</span><span style="color:var(--secondary)">${entry.carbs != null ? entry.carbs.toFixed(1) + ' g' : '未知'}</span></div>`;
583
+ const d = new Date(entry.createdAt);
584
+ document.getElementById('detail-time-section').innerHTML = `
585
+ <div class="detail-row"><span>日期</span><span style="color:var(--secondary)">${d.toLocaleDateString('zh-CN')}</span></div>
586
+ <div class="detail-row"><span>时间</span><span style="color:var(--secondary)">${d.toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'})}</span></div>`;
587
+ }
588
+
589
+ function closeDetail() {
590
+ document.getElementById('detail-page').classList.remove('show');
591
+ detailEntryId = null;
592
+ }
593
+
594
+ async function deleteEntry() {
595
+ if (!detailEntryId) return;
596
+ if (!confirm('确认删除这条记录?删除后将无法恢复。')) return;
597
+ await deleteEntryFromDB(detailEntryId);
598
+ closeDetail();
599
+ await refreshUI();
600
+ showToast('已删除');
601
+ }
602
+
603
+ // ============================================================
604
+ // RENDER
605
+ // ============================================================
606
+ async function refreshUI() {
607
+ const entries = await getAllEntries();
608
+ renderHistory(entries);
609
+ if (document.getElementById('page-stats').classList.contains('active')) {
610
+ renderStats(entries);
611
+ }
612
+ }
613
+
614
+ function renderHistory(entries) {
615
+ const todayStr = today();
616
+ const todayEntries = entries.filter(e => e.createdAt.startsWith(todayStr));
617
+ const todayCals = todayEntries.reduce((s, e) => s + e.calories, 0);
618
+ const target = 2000;
619
+ const pct = Math.min(Math.round(todayCals / target * 100), 100);
620
+ const circumference = 2 * Math.PI * 25;
621
+ const offset = circumference - (pct / 100) * circumference;
622
+
623
+ document.getElementById('summary-area').innerHTML = `
624
+ <div class="summary-card">
625
+ <div>
626
+ <div class="summary-label">今日摄入</div>
627
+ <div class="summary-value">${todayCals}<span class="summary-unit"> 千卡</span></div>
628
+ </div>
629
+ <div class="ring-wrap">
630
+ <svg width="58" height="58" viewBox="0 0 58 58">
631
+ <circle cx="29" cy="29" r="25" fill="none" stroke="#e5e5ea" stroke-width="6"/>
632
+ <circle cx="29" cy="29" r="25" fill="none" stroke="${todayCals > target ? '#ff3b30' : '#34c759'}" stroke-width="6" stroke-linecap="round" stroke-dasharray="${circumference}" stroke-dashoffset="${offset}"/>
633
+ </svg>
634
+ <div class="ring-text">${pct}%</div>
635
+ </div>
636
+ </div>`;
637
+
638
+ const list = document.getElementById('entry-list');
639
+ const empty = document.getElementById('empty-state');
640
+
641
+ if (entries.length === 0) {
642
+ list.innerHTML = '';
643
+ empty.style.display = 'flex';
644
+ } else {
645
+ empty.style.display = 'none';
646
+ list.innerHTML = entries.map(e => {
647
+ const d = new Date(e.createdAt);
648
+ const time = d.toLocaleTimeString('zh-CN', {hour:'2-digit',minute:'2-digit'});
649
+ return `<div class="entry" data-id="${e.id}">
650
+ <div><div class="entry-name">${e.foodName}</div><div class="entry-time">${time}</div></div>
651
+ <div class="entry-cal">${e.calories} <span>千卡</span></div>
652
+ </div>`;
653
+ }).join('');
654
+
655
+ // Add click handlers
656
+ list.querySelectorAll('.entry').forEach(el => {
657
+ el.addEventListener('click', () => {
658
+ const e = entries.find(en => en.id === el.dataset.id);
659
+ if (e) openDetail(e);
660
+ });
661
+ });
662
+ }
663
+ }
664
+
665
+ function renderStats(entries) {
666
+ const ws = weekStart();
667
+ const we = new Date(ws); we.setDate(we.getDate() + 7);
668
+ const weekEntries = entries.filter(e => {
669
+ const d = new Date(e.createdAt);
670
+ return d >= ws && d < we;
671
+ });
672
+ const weekTotal = weekEntries.reduce((s, e) => s + e.calories, 0);
673
+ const daysWithData = new Set(weekEntries.map(e => e.createdAt.split('T')[0])).size;
674
+ const avg = daysWithData > 0 ? Math.round(weekTotal / daysWithData) : 0;
675
+
676
+ document.getElementById('stats-summary').innerHTML = `
677
+ <div class="stat-card"><div class="stat-title">本周总热量</div><div class="stat-value" style="color:var(--orange)">${weekTotal}</div><div class="stat-unit">千卡</div></div>
678
+ <div class="stat-card"><div class="stat-title">日均</div><div class="stat-value" style="color:var(--blue)">${avg}</div><div class="stat-unit">千卡</div></div>`;
679
+
680
+ const dayNames = ['一','二','三','四','五','六','日'];
681
+ const maxCal = Math.max(2000, ...weekEntries.reduce((m, e) => { const day = new Date(e.createdAt).getDay(); const idx = day === 0 ? 6 : day - 1; m[idx] = (m[idx]||0) + e.calories; return m; }, Array(7).fill(0)));
682
+
683
+ document.getElementById('chart-bars').innerHTML = dayNames.map((name, i) => {
684
+ const d = new Date(ws); d.setDate(d.getDate() + i);
685
+ const dayStr = d.toISOString().split('T')[0];
686
+ const cal = weekEntries.filter(e => e.createdAt.startsWith(dayStr)).reduce((s, e) => s + e.calories, 0);
687
+ const h = maxCal > 0 ? Math.max((cal / 2200) * 150, 2) : 2;
688
+ return `<div class="bar-col"><div class="bar-bv">${cal||''}</div><div class="bar-bar ${cal > 2000 ? 'over' : ''}" style="height:${h}px;background:${cal > 2000 ? 'var(--red)' : 'var(--green)'}"></div><div class="bar-bl">周${name}</div></div>`;
689
+ }).join('');
690
+ }
691
+
692
+ // ============================================================
693
+ // NAVIGATION
694
+ // ============================================================
695
+ function switchPage(name) {
696
+ document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
697
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
698
+
699
+ if (name === 'history') {
700
+ document.getElementById('page-history').classList.add('active');
701
+ document.querySelector('.tab[data-page="history"]').classList.add('active');
702
+ document.getElementById('header-title').textContent = '饮食记录';
703
+ document.getElementById('header-right').textContent = '⚙';
704
+ document.getElementById('header-right').style.visibility = 'visible';
705
+ document.getElementById('header-back').style.visibility = 'hidden';
706
+ document.getElementById('page-settings').classList.remove('active');
707
+ } else if (name === 'stats') {
708
+ document.getElementById('page-stats').classList.add('active');
709
+ document.querySelector('.tab[data-page="stats"]').classList.add('active');
710
+ document.getElementById('header-title').textContent = '统计';
711
+ document.getElementById('header-right').style.visibility = 'hidden';
712
+ document.getElementById('header-back').style.visibility = 'hidden';
713
+ document.getElementById('page-settings').classList.remove('active');
714
+ getAllEntries().then(renderStats);
715
+ } else if (name === 'settings') {
716
+ document.getElementById('page-history').classList.add('active');
717
+ document.getElementById('page-settings').classList.add('active');
718
+ document.getElementById('header-title').textContent = '设置';
719
+ document.getElementById('header-right').style.visibility = 'hidden';
720
+ document.getElementById('header-back').style.visibility = 'visible';
721
+ document.getElementById('api-key-input').value = getAPIKey();
722
+ document.getElementById('model-name').textContent = getModel() + ' ›';
723
+ }
724
+
725
+ // Update scroll position
726
+ document.querySelectorAll('.page.active').forEach(p => p.scrollTop = 0);
727
+ }
728
+
729
+ // Tab clicks
730
+ document.querySelectorAll('.tab').forEach(tab => {
731
+ tab.addEventListener('click', () => switchPage(tab.dataset.page));
732
+ });
733
+
734
+ // Header right button (settings)
735
+ document.getElementById('header-right').addEventListener('click', () => switchPage('settings'));
736
+
737
+ // Header back button
738
+ document.getElementById('header-back').addEventListener('click', () => {
739
+ document.getElementById('header-back').style.visibility = 'hidden';
740
+ switchPage('history');
741
+ refreshUI();
742
+ });
743
+
744
+ // FAB
745
+ document.addEventListener('click', (e) => {
746
+ if (e.target.closest('#fab')) openSheet();
747
+ });
748
+
749
+ // Settings
750
+ document.getElementById('api-key-input').addEventListener('input', (e) => {
751
+ setAPIKey(e.target.value);
752
+ });
753
+
754
+ function toggleModel() {
755
+ const current = getModel();
756
+ const next = current === 'glm-4v-flash' ? 'glm-4v' : 'glm-4v-flash';
757
+ setModel(next);
758
+ document.getElementById('model-name').textContent = next + ' ›';
759
+ showToast('已切换为 ' + next);
760
+ }
761
+
762
+ // PWA install
763
+ window.addEventListener('beforeinstallprompt', (e) => {
764
+ e.preventDefault();
765
+ window._installPrompt = e;
766
+ document.getElementById('install-banner').classList.add('show');
767
+ });
768
+ document.getElementById('install-banner').addEventListener('click', () => {
769
+ if (window._installPrompt) {
770
+ window._installPrompt.prompt();
771
+ }
772
+ });
773
+
774
+ // ============================================================
775
+ // INIT
776
+ // ============================================================
777
+ async function init() {
778
+ await openDB();
779
+ document.getElementById('api-key-input').value = getAPIKey();
780
+ document.getElementById('model-name').textContent = getModel() + ' ›';
781
+
782
+ // Add FAB
783
+ const fab = document.createElement('button');
784
+ fab.id = 'fab';
785
+ fab.className = 'fab';
786
+ fab.innerHTML = '📷';
787
+ document.body.appendChild(fab);
788
+
789
+ await refreshUI();
790
+
791
+ // Register service worker
792
+ if ('serviceWorker' in navigator) {
793
+ try { await navigator.serviceWorker.register('sw.js'); } catch(e) {}
794
+ }
795
+ }
796
+
797
+ init();
798
+ </script>
799
+ </body>
800
+ </html>
package/manifest.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "卡路里记录",
3
+ "short_name": "卡路里",
4
+ "description": "拍照识别食物热量",
5
+ "start_url": "index.html",
6
+ "display": "standalone",
7
+ "orientation": "portrait",
8
+ "background_color": "#f2f2f7",
9
+ "theme_color": "#f2f2f7",
10
+ "icons": [
11
+ {
12
+ "src": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect fill='%23007aff' width='100' height='100' rx='20'/><text x='50' y='68' text-anchor='middle' fill='white' font-size='50' font-family='sans-serif'>🍽</text></svg>",
13
+ "sizes": "100x100",
14
+ "type": "image/svg+xml",
15
+ "purpose": "any"
16
+ }
17
+ ]
18
+ }
package/package.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "calorie-tracker",
3
+ "version": "1.0.0",
4
+ "description": "拍照识别食物热量 - 卡路里记录",
5
+ "main": "index.html",
6
+ "files": ["index.html", "manifest.json", "sw.js"]
7
+ }
package/sw.js ADDED
@@ -0,0 +1,22 @@
1
+ const CACHE = 'calorie-tracker-v1';
2
+ const ASSETS = ['index.html', 'manifest.json'];
3
+
4
+ self.addEventListener('install', (e) => {
5
+ e.waitUntil(caches.open(CACHE).then(c => c.addAll(ASSETS)));
6
+ });
7
+
8
+ self.addEventListener('fetch', (e) => {
9
+ if (e.request.method !== 'GET') return;
10
+ e.respondWith(
11
+ caches.match(e.request).then((cached) => {
12
+ const fetched = fetch(e.request).then((resp) => {
13
+ if (resp.ok) {
14
+ const clone = resp.clone();
15
+ caches.open(CACHE).then(c => c.put(e.request, clone));
16
+ }
17
+ return resp;
18
+ }).catch(() => cached);
19
+ return cached || fetched;
20
+ })
21
+ );
22
+ });