/** * 智能垃圾分类系统 - 高级交互 v3 * Minimalist premium design with haptic feedback + object identification */ (function () { 'use strict'; // ============================================ // 工具函数 // ============================================ const $ = (sel) => document.querySelector(sel); const $$ = (sel) => document.querySelectorAll(sel); // 触觉反馈 function haptic(pattern = 10) { if (navigator.vibrate) { navigator.vibrate(pattern); } } // 涟漪效果 function createRipple(e, element) { const ripple = document.createElement('span'); ripple.className = 'ripple-effect'; const rect = element.getBoundingClientRect(); const size = Math.max(rect.width, rect.height); const x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left - size / 2; const y = (e.touches ? e.touches[0].clientY : e.clientY) - rect.top - size / 2; ripple.style.width = ripple.style.height = size + 'px'; ripple.style.left = x + 'px'; ripple.style.top = y + 'px'; const container = $('#rippleContainer') || document.body; container.appendChild(ripple); ripple.addEventListener('animationend', () => ripple.remove()); } // 为所有带 data-ripple 的元素添加点击效果 function initRipples() { $$('[data-ripple]').forEach(el => { el.addEventListener('click', function (e) { haptic(); createRipple(e, this); }); el.addEventListener('touchstart', function (e) { haptic(8); }, { passive: true }); }); } // 鼠标跟踪光晕 function initGlowTracking() { const placeholder = $('#uploadPlaceholder'); if (!placeholder) return; placeholder.addEventListener('mousemove', (e) => { const rect = placeholder.getBoundingClientRect(); const x = ((e.clientX - rect.left) / rect.width) * 100; const y = ((e.clientY - rect.top) / rect.height) * 100; placeholder.style.setProperty('--mx', x + '%'); placeholder.style.setProperty('--my', y + '%'); }); } // 物品识别 emoji 图标 function getObjectEmoji(name) { if (!name) return '📦'; const emojiMap = [ ['水瓶', '🦴'], ['玻璃杯', '🥛'], ['咖啡杯', '☕'], ['茶杯', '🛖'], ['易拉罐', '🥛'], ['保温杯', '🧢'], ['啤酒瓶', '🍺'], ['塑料杯', '🥤'], ['纸杯', '🥤'], ['盘子', '🍽️'], ['碗', '🥣'], ['筷子', '🥢'], ['叉子', '🍴'], ['勺子', '🥄'], ['刀', '🔪'], ['外卖盒', '📦'], ['食物容器', '🍱'], ['苹果', '🍎'], ['香蕉', '🍌'], ['橙子', '🍊'], ['面包', '🍞'], ['蛋糕', '🎂'], ['饼干', '🍪'], ['鸡蛋', '🥚'], ['米饭', '🍚'], ['面条', '🍜'], ['三明治', '🥪'], ['披萨', '🍕'], ['巧克力', '🍫'], ['水果', '🍉'], ['蔬菜', '🥬'], ['西瓜', '🍉'], ['葡萄', '🍇'], ['草莓', '🍓'], ['薯片', '🥨'], ['泡面', '🍜'], ['手机', '📱'], ['笔记本', '💻'], ['平板', '📱'], ['耳机', '🎧'], ['充电线', '🔌'], ['遥控器', '📟'], ['键盘', '⌨️'], ['鼠标', '🖱️'], ['相机', '📷'], ['充电宝', '🔋'], ['T恤', '👕'], ['裤子', '👖'], ['鞋子', '👟'], ['帽子', '🧢'], ['外套', '🧥'], ['袜子', '🧦'], ['背包', '🎒'], ['手提包', '👜'], ['钱包', '👛'], ['手套', '🧤'], ['围巾', '🧣'], ['墨镜', '🕶️'], ['书本', '📚'], ['笔记本', '📓'], ['笔', '✏️'], ['铅笔', '✏️'], ['剪刀', '✂️'], ['报纸', '📰'], ['杂志', '📰'], ['纸张', '📄'], ['纸箱', '📦'], ['信封', '✉️'], ['钥匙', '🔑'], ['雨伞', '☂️'], ['眼镜', '👓'], ['牙刷', '🦷'], ['毛巾', '🦴'], ['肥皂', '🧼'], ['洗发水', '🦴'], ['纸巾盒', '📦'], ['垃圾袋', '🗑️'], ['塑料袋', '🛍️'], ['枕头', '🛏️'], ['毯子', '🛌'], ['靠垫', '🛋️'], ['玻璃罐', '🥙'], ['金属罐', '🥛'], ['牛奶盒', '🥛'], ['塑料瓶', '🦴'], ['锤子', '🔨'], ['螺丝刀', '🪛'], ['扳手', '🔧'], ['钳子', '🔧'], ['手电筒', '🔦'], ['足球', '⚽'], ['篮球', '🏀'], ['网球', '🎾'], ['棒球', '⚾'], ['飞盘', '🥏'], ['口罩', '😷'], ['烟蒂', '🚬'], ['电池', '🔋'], ['灯泡', '💡'], ['药片', '💊'], ['尿不湿', '👶'], ['湿巾', '🧻'], ['吸管', '🥤'], ['牙签', '🦷'], ['棉签', '🦷'], ['创可贴', '🪹'], ['花', '🌸'], ['盆栽', '🪴'], ['玩具', '🧸'], ['毛绒', '🧸'], ['口红', '💄'], ['化妆刷', '🖌️'], ['指甲油', '💅'], ['梳子', '🪮'], ['剃须刀', '🪒'], ['海绵', '🧽'], ['抹布', '🧹'], ['扫把', '🧹'], ['洗衣液', '🦴'], ['泡面桶', '🍜'], ['一次性筷子', '🥢'], ['纸碗', '🥣'], ['泡沫饭盒', '📦'], ]; for (const [key, emoji] of emojiMap) { if (name.includes(key)) return emoji; } return '📦'; } // ============================================ // DOM 元素 // ============================================ const uploadArea = $('#uploadArea'); const uploadPlaceholder = $('#uploadPlaceholder'); const uploadPreview = $('#uploadPreview'); const previewImage = $('#previewImage'); const fileInput = $('#fileInput'); const fileInputCamera = $('#fileInputCamera'); const btnCamera = $('#btnCamera'); const btnGallery = $('#btnGallery'); const btnUpload = $('#btnUpload'); const btnRemove = $('#btnRemove'); const btnAgain = $('#btnAgain'); const btnErrorRetry = $('#btnErrorRetry'); const uploadSection = $('#uploadSection'); const loadingSection = $('#loadingSection'); const resultSection = $('#resultSection'); const errorSection = $('#errorSection'); const resultCard = $('#resultCard'); const resultAccent = $('#resultAccent'); const badgeIcon = $('#badgeIcon'); const badgeText = $('#badgeText'); const binLabel = $('#binLabel'); const binDesc = $('#binDesc'); const examplesText = $('#examplesText'); const tipText = $('#tipText'); const binVisual = $('#binVisual'); const detectedItem = $('#detectedItem'); const detectedConfidence = $('#detectedConfidence'); const otherPredictions = $('#otherPredictions'); const predictionList = $('#predictionList'); const uploadActions = $('#uploadActions'); // DNA 知识卡片 DOM const dnaSection = $('#dnaSection'); const dnaCard = $('#dnaCard'); const dnaMaterial = $('#dnaMaterial'); const dnaDecomposition = $('#dnaDecomposition'); const dnaDecompositionDesc = $('#dnaDecompositionDesc'); const dnaRecycledInto = $('#dnaRecycledInto'); const dnaProcess = $('#dnaProcess'); const dnaCo2Row = $('#dnaCo2Row'); const dnaCo2Text = $('#dnaCo2Text'); const dnaTips = $('#dnaTips'); const dnaFacts = $('#dnaFacts'); // 物品识别 DOM const identifySection = $('#identifySection'); const objectName = $('#objectName'); const objectConfidence = $('#objectConfidence'); const objectIcon = $('#objectIcon'); const errorTitle = $('#errorTitle'); const errorDetail = $('#errorDetail'); // ============================================ // 状态 // ============================================ let selectedFile = null; let isProcessing = false; let currentMode = 'single'; // 'single' | 'multi' let multiObjects = []; // 多物体结果缓存 let selectedMultiIndex = 0; // ============================================ // 映射 // ============================================ const BIN_COLORS = { '可回收垃圾': { class: 'bg-blue', color: '#5b9cf5', label: '蓝色垃圾桶' }, '有害垃圾': { class: 'bg-red', color: '#f56b6b', label: '红色垃圾桶' }, '厨余垃圾': { class: 'bg-green', color: '#5bd68a', label: '绿色垃圾桶' }, '其他垃圾': { class: 'bg-gray', color: '#8a8a9a', label: '灰色/黑色垃圾桶' }, }; const BIN_ICONS = { '可回收垃圾': '♻️', '有害垃圾': '☣️', '厨余垃圾': '🍚', '其他垃圾': '🗑️', }; // ============================================ // 页面切换 // ============================================ function showSection(section) { [uploadSection, loadingSection, resultSection, errorSection].forEach(s => { s.style.display = 'none'; }); section.style.display = 'flex'; section.style.animation = 'none'; void section.offsetWidth; section.style.animation = ''; } // ============================================ // 文件选择 // ============================================ function handleFileSelect(file) { if (!file) return; const validTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/bmp', 'image/gif']; if (!validTypes.includes(file.type) && !file.type.startsWith('image/')) { showError('不支持的文件格式', '请选择 JPG、PNG 或 WebP 格式的图片'); haptic(20); return; } if (file.size > 10 * 1024 * 1024) { showError('图片太大', `文件 ${(file.size / 1024 / 1024).toFixed(1)}MB,请选择小于 10MB 的图片`); haptic(20); return; } selectedFile = file; btnUpload.disabled = false; haptic(12); const reader = new FileReader(); reader.onload = function (e) { previewImage.src = e.target.result; uploadPlaceholder.style.display = 'none'; uploadPreview.style.display = 'block'; if (uploadActions) uploadActions.style.display = 'none'; }; reader.readAsDataURL(file); } uploadPlaceholder.addEventListener('click', () => { haptic(6); fileInput.click(); }); fileInput.addEventListener('change', (e) => { if (e.target.files && e.target.files[0]) { handleFileSelect(e.target.files[0]); } }); if (btnCamera) { btnCamera.addEventListener('click', () => { haptic(10); fileInputCamera.click(); }); } if (btnGallery) { btnGallery.addEventListener('click', () => { haptic(10); fileInput.click(); }); } if (fileInputCamera) { fileInputCamera.addEventListener('change', (e) => { if (e.target.files && e.target.files[0]) { handleFileSelect(e.target.files[0]); } }); } btnRemove.addEventListener('click', (e) => { e.stopPropagation(); haptic(6); resetUpload(); }); // ============================================ // 模式切换 (单物体 / 多物体) // ============================================ const modeSingle = $('#modeSingle'); const modeMulti = $('#modeMulti'); const multiResultSection = $('#multiResultSection'); const multiResultImage = $('#multiResultImage'); const multiBboxCanvas = $('#multiBboxCanvas'); function switchMode(mode) { currentMode = mode; document.querySelectorAll('.mode-btn').forEach(btn => { btn.classList.toggle('mode-btn--active', btn.dataset.mode === mode); }); haptic(6); } if (modeSingle) { modeSingle.addEventListener('click', () => switchMode('single')); } if (modeMulti) { modeMulti.addEventListener('click', () => switchMode('multi')); } function resetUpload() { selectedFile = null; btnUpload.disabled = true; fileInput.value = ''; fileInputCamera.value = ''; previewImage.src = ''; uploadPreview.style.display = 'none'; uploadPlaceholder.style.display = 'flex'; if (uploadActions) uploadActions.style.display = 'grid'; } // ============================================ // 拖放 // ============================================ let dragCounter = 0; uploadArea.addEventListener('dragenter', (e) => { e.preventDefault(); e.stopPropagation(); dragCounter++; uploadPlaceholder.classList.add('dragover'); }); uploadArea.addEventListener('dragleave', (e) => { e.preventDefault(); e.stopPropagation(); dragCounter--; if (dragCounter === 0) { uploadPlaceholder.classList.remove('dragover'); } }); uploadArea.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); }); uploadArea.addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); dragCounter = 0; uploadPlaceholder.classList.remove('dragover'); const files = e.dataTransfer.files; if (files && files[0]) { handleFileSelect(files[0]); } }); // ============================================ // 分类请求 // ============================================ async function classifyImage() { if (!selectedFile || isProcessing) return; isProcessing = true; btnUpload.disabled = true; haptic(15); showSection(loadingSection); try { const formData = new FormData(); formData.append('file', selectedFile); // 根据模式选择端点 const endpoint = currentMode === 'multi' ? '/api/classify-multi' : '/api/classify'; const response = await fetch(endpoint, { method: 'POST', body: formData, }); if (!response.ok) { let detail = '服务器处理出错'; try { const errData = await response.json(); detail = errData.detail || detail; } catch (e) { } throw new Error(detail); } const result = await response.json(); await new Promise(r => setTimeout(r, 600)); if (currentMode === 'multi' && result.multi) { showMultiResult(result); } else { showResult(result); } } catch (err) { console.error('分类出错:', err); await new Promise(r => setTimeout(r, 400)); showError('识别失败', err.message || '网络错误,请检查连接后重试'); } finally { isProcessing = false; btnUpload.disabled = !selectedFile; } } btnUpload.addEventListener('click', classifyImage); document.addEventListener('keydown', (e) => { if (e.key === 'Enter' && selectedFile && !isProcessing) { const visible = uploadSection.style.display !== 'none'; if (visible) classifyImage(); } }); // ============================================ // 展示结果 // ============================================ function showResult(result) { haptic(20); if (!result.success) { showError('识别失败', '模型未能识别该图片中的垃圾类型,请换一张照片试试'); return; } const category = result.category || '其他垃圾'; const binColor = BIN_COLORS[category] || BIN_COLORS['其他垃圾']; // 卡片样式 resultCard.className = 'result-card ' + binColor.class; // 徽章 badgeIcon.textContent = BIN_ICONS[category] || '🗑️'; badgeText.textContent = category; binLabel.textContent = binColor.label; // 信息 const binInfo = result.category_info || {}; binDesc.textContent = binInfo.description || ''; examplesText.textContent = binInfo.examples || ''; tipText.textContent = binInfo.tip || ''; // 物品识别 if (result.object && result.object.name) { identifySection.style.display = 'flex'; // 优先使用自然描述(含OCR文字),其次用物品名 const displayText = result.object.description || result.object.name_zh || result.object.name; objectName.textContent = displayText; const objConfPct = (result.object.confidence * 100).toFixed(1); objectConfidence.textContent = objConfPct + '%'; objectConfidence.style.color = result.object.confidence > 0.5 ? 'var(--color-green)' : result.object.confidence > 0.3 ? '#f0a030' : 'var(--color-red)'; objectIcon.textContent = getObjectEmoji(result.object.name_zh || ''); } else { identifySection.style.display = 'none'; } // 详情 detectedItem.textContent = result.item_zh || result.item || '-'; const confPct = (result.confidence * 100).toFixed(1); detectedConfidence.textContent = confPct + '%'; const confNum = result.confidence; detectedConfidence.style.color = confNum > 0.7 ? 'var(--color-green)' : confNum > 0.4 ? '#f0a030' : 'var(--color-red)'; // 可能分类 if (result.predictions && result.predictions.length > 1) { otherPredictions.style.display = 'block'; predictionList.innerHTML = ''; result.predictions.forEach((p, index) => { const score = (p.score * 100).toFixed(1); const item = document.createElement('div'); item.className = 'prediction-item'; item.style.animationDelay = (index * 0.1) + 's'; const barWidth = Math.max(p.score * 100, 3); item.innerHTML = ` ${p.label_zh || p.label}
${score}% `; predictionList.appendChild(item); setTimeout(() => { const fill = item.querySelector('.prediction-bar-fill'); if (fill) fill.style.width = barWidth + '%'; }, 200 + index * 150); }); } else { otherPredictions.style.display = 'none'; } // 渲染垃圾 DNA 知识卡片 renderDnaCard(result.dna); showSection(resultSection); // 每日挑战追踪 ChallengeTracker.track(); // 碳足迹追踪 CarbonTracker.track(result); // 保存到图鉴 Atlas.addEntry(previewImage, result); setTimeout(() => { resultSection.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 100); haptic([10, 50, 10]); } // ============================================ // 垃圾 DNA 知识卡片渲染 // ============================================ function renderDnaCard(dna) { if (!dna || !dna.material) { dnaSection.style.display = 'none'; return; } dnaSection.style.display = 'block'; // 材质 dnaMaterial.textContent = dna.material; // 降解时间 const years = dna.decomposition_years; if (years !== null && years !== undefined) { let timeStr; if (years >= 1000000) { timeStr = '约 ' + (years / 10000).toFixed(0) + ' 万年'; } else if (years >= 10000) { timeStr = '约 ' + (years / 10000).toFixed(1) + ' 万年'; } else if (years >= 100) { timeStr = '约 ' + years.toFixed(0) + ' 年'; } else if (years >= 1) { timeStr = '约 ' + years.toFixed(0) + ' 年'; } else { timeStr = '约 ' + (years * 12).toFixed(0) + ' 个月'; } dnaDecomposition.textContent = timeStr; } else { dnaDecomposition.textContent = '极难自然降解'; } dnaDecompositionDesc.textContent = dna.decomposition_desc || ''; // 回收后可变成 dnaRecycledInto.innerHTML = ''; if (dna.recycled_into && dna.recycled_into.length > 0) { dna.recycled_into.forEach((item, index) => { const tag = document.createElement('span'); tag.className = 'dna-tag'; tag.textContent = item; tag.style.animationDelay = (index * 0.08) + 's'; dnaRecycledInto.appendChild(tag); }); } // 回收流程 dnaProcess.textContent = dna.recycling_process || ''; // 环境效益(碳减排) if (dna.co2_saving_kg !== null && dna.co2_saving_kg !== undefined) { dnaCo2Row.style.display = 'flex'; let co2Text = '每件回收可减少 ' + dna.co2_saving_kg.toFixed(1) + ' kg CO₂ 排放'; if (dna.tree_saving) { co2Text += ',相当于种了 ' + dna.tree_saving.toFixed(2) + ' 棵树'; } dnaCo2Text.textContent = co2Text; } else { dnaCo2Row.style.display = 'none'; } // 投放建议 dnaTips.innerHTML = ''; if (dna.disposal_tips && dna.disposal_tips.length > 0) { dna.disposal_tips.forEach((tip, index) => { const li = document.createElement('li'); li.textContent = tip; li.style.animationDelay = (index * 0.06) + 's'; dnaTips.appendChild(li); }); } // 趣味知识 dnaFacts.innerHTML = ''; if (dna.fun_facts && dna.fun_facts.length > 0) { dna.fun_facts.forEach((fact, index) => { const li = document.createElement('li'); li.textContent = fact; li.style.animationDelay = (index * 0.08) + 's'; dnaFacts.appendChild(li); }); } // 入场动画 dnaCard.style.animation = 'none'; void dnaCard.offsetWidth; dnaCard.style.animation = 'dnaFadeIn 0.6s ease-out forwards'; } // ============================================ // 错误处理 // ============================================ // ============================================ // 多物体识别 — 结果展示 // ============================================ function showMultiResult(result) { haptic(20); if (!result.success || !result.objects || result.objects.length === 0) { showError('识别失败', '多物体识别未检测到有效物品'); return; } multiObjects = result.objects; selectedMultiIndex = 0; // 显示图片 + 绘制 Bounding Box const imageUrl = previewImage.src; multiResultImage.src = imageUrl; multiResultImage.onload = function() { drawMultiBboxes(multiResultImage, multiBboxCanvas, multiObjects, selectedMultiIndex); }; // 如果图片已经加载完成 if (multiResultImage.complete && multiResultImage.naturalWidth > 0) { drawMultiBboxes(multiResultImage, multiBboxCanvas, multiObjects, selectedMultiIndex); } // 渲染物体标签 renderMultiTabs(multiObjects); // 选中第一个物体显示详情 selectMultiObject(0); // 隐藏其他 section,显示多物体结果 [uploadSection, loadingSection, resultSection, errorSection].forEach(s => { s.style.display = 'none'; }); multiResultSection.style.display = 'flex'; multiResultSection.style.animation = 'none'; void multiResultSection.offsetWidth; multiResultSection.style.animation = ''; // 碳足迹追踪 CarbonTracker.trackMulti(result.objects); // 图鉴 Atlas.addEntry(previewImage, { item_zh: result.objects.map(o => o.label_zh).join(' + '), category: result.overall || '多物体', confidence: result.objects[0]?.confidence || 0, }); setTimeout(() => { multiResultSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 100); haptic([10, 50, 10]); } function drawMultiBboxes(imgEl, canvasEl, objects, highlightIndex) { if (!canvasEl || !imgEl) return; const rect = imgEl.getBoundingClientRect(); const w = rect.width; const h = rect.height; // canvas 尺寸匹配显示尺寸 canvasEl.width = w; canvasEl.height = h; const ctx = canvasEl.getContext('2d'); ctx.clearRect(0, 0, w, h); // 图片实际渲染尺寸(保持比例) const iw = imgEl.naturalWidth || 1; const ih = imgEl.naturalHeight || 1; const scale = Math.min(w / iw, h / ih); const ox = (w - iw * scale) / 2; const oy = (h - ih * scale) / 2; const COLORS = ['#5bd68a', '#5b9cf5', '#f5a623', '#f56b6b', '#9b59b6', '#1abc9c']; objects.forEach((obj, index) => { if (!obj.bbox) return; const color = COLORS[index % COLORS.length]; const isHighlight = index === highlightIndex; const box = obj.bbox; const x = ox + box.xmin * scale; const y = oy + box.ymin * scale; const bw = (box.xmax - box.xmin) * scale; const bh = (box.ymax - box.ymin) * scale; ctx.lineWidth = isHighlight ? 3 : 1.5; ctx.strokeStyle = color; ctx.globalAlpha = isHighlight ? 1 : 0.6; ctx.strokeRect(x, y, bw, bh); // 标签背景 const label = obj.label_zh || obj.label || `物体${index + 1}`; const conf = obj.confidence ? (obj.confidence * 100).toFixed(0) + '%' : ''; const text = `#${index + 1} ${label} ${conf}`; ctx.font = 'bold 12px "Noto Sans SC", sans-serif'; const metrics = ctx.measureText(text); const tw = metrics.width + 10; const th = 22; // 标签画在方框上方 const lx = x; const ly = y - th > 0 ? y - th : y + bh; ctx.fillStyle = color; ctx.globalAlpha = isHighlight ? 0.95 : 0.75; ctx.beginPath(); ctx.roundRect(lx, ly, Math.min(tw, w - lx - 4), th, 4); ctx.fill(); ctx.fillStyle = '#fff'; ctx.globalAlpha = 1; ctx.fillText(text, lx + 5, ly + 15); }); } // 渲染物体切换标签 function renderMultiTabs(objects) { const container = $('#multiTabs'); if (!container) return; const COLORS = ['#5bd68a', '#5b9cf5', '#f5a623', '#f56b6b', '#9b59b6', '#1abc9c']; container.innerHTML = objects.map((obj, index) => { const color = COLORS[index % COLORS.length]; const catIcon = BIN_ICONS[obj.category] || '🗑️'; return ` `; }).join(''); // 绑定事件 container.querySelectorAll('.multi-tab').forEach(tab => { tab.addEventListener('click', function() { const idx = parseInt(this.dataset.index); selectMultiObject(idx); haptic(6); }); }); } function selectMultiObject(index) { if (!multiObjects[index]) return; selectedMultiIndex = index; const obj = multiObjects[index]; // 更新标签高亮 document.querySelectorAll('.multi-tab').forEach(tab => { tab.classList.toggle('multi-tab--active', parseInt(tab.dataset.index) === index); }); // 更新 bounding box 高亮 drawMultiBboxes(multiResultImage, multiBboxCanvas, multiObjects, index); // 更新详情卡片 const binName = (BIN_COLORS[obj.category] || BIN_COLORS['其他垃圾']).class; const binColor = (BIN_COLORS[obj.category] || BIN_COLORS['其他垃圾']).color; $('#multiDetailCard').className = 'multi-detail-card ' + binName; $('#multiDetailAccent').style.background = binColor; $('#multiBadgeIcon').textContent = BIN_ICONS[obj.category] || '🗑️'; $('#multiBadgeText').textContent = obj.category; // 物品识别 if (obj.object && obj.object.name) { const displayText = obj.object.description || obj.object.name_zh || obj.object.name; $('#multiObjectName').textContent = displayText; const objConf = (obj.object.confidence * 100).toFixed(1); $('#multiObjectConfidence').textContent = objConf + '%'; $('#multiObjectConfidence').style.color = obj.object.confidence > 0.5 ? 'var(--color-green)' : obj.object.confidence > 0.3 ? '#f0a030' : 'var(--color-red)'; $('#multiObjectIcon').textContent = getObjectEmoji(obj.object.name_zh || ''); $('#multiIdentifySection').style.display = 'flex'; } else { $('#multiIdentifySection').style.display = 'none'; } // 垃圾桶 const info = obj.category_info || {}; $('#multiBinLabel').textContent = (BIN_COLORS[obj.category] || BIN_COLORS['其他垃圾']).label; $('#multiBinBody').style.background = binColor; $('#multiBinDesc').textContent = info.description || ''; $('#multiExamplesText').textContent = info.examples || ''; $('#multiTipText').textContent = info.tip || ''; // DNA renderMultiDna(obj.dna); // 详情 $('#multiDetectedItem').textContent = obj.label_zh || obj.label || '-'; const confPct = (obj.confidence * 100).toFixed(1); $('#multiDetectedConfidence').textContent = confPct + '%'; $('#multiDetectedConfidence').style.color = obj.confidence > 0.7 ? 'var(--color-green)' : obj.confidence > 0.4 ? '#f0a030' : 'var(--color-red)'; } function renderMultiDna(dna) { const section = $('#multiDnaSection'); const body = $('#multiDnaBody'); if (!dna || !dna.material) { section.style.display = 'none'; return; } section.style.display = 'block'; const years = dna.decomposition_years; let timeStr = '极难自然降解'; if (years !== null && years !== undefined) { if (years >= 1000000) timeStr = '约 ' + (years / 10000).toFixed(0) + ' 万年'; else if (years >= 10000) timeStr = '约 ' + (years / 10000).toFixed(1) + ' 万年'; else if (years >= 1) timeStr = '约 ' + years.toFixed(0) + ' 年'; else timeStr = '约 ' + (years * 12).toFixed(0) + ' 个月'; } let tagsHtml = ''; if (dna.recycled_into && dna.recycled_into.length > 0) { tagsHtml = dna.recycled_into.map(t => `${t}` ).join(''); } let tipsHtml = ''; if (dna.disposal_tips && dna.disposal_tips.length > 0) { tipsHtml = dna.disposal_tips.map(t => `
  • ${t}
  • `).join(''); } let factsHtml = ''; if (dna.fun_facts && dna.fun_facts.length > 0) { factsHtml = dna.fun_facts.map(f => `
  • ${f}
  • `).join(''); } let co2Html = ''; if (dna.co2_saving_kg !== null && dna.co2_saving_kg !== undefined) { let co2Text = `每件回收可减少 ${dna.co2_saving_kg.toFixed(1)} kg CO₂ 排放`; if (dna.tree_saving) co2Text += `,相当于种了 ${dna.tree_saving.toFixed(2)} 棵树`; co2Html = `
    🌱
    环境效益
    ${co2Text}
    `; } body.innerHTML = `
    🧪
    材质构成
    ${dna.material}
    自然降解时间
    ${timeStr}
    ${dna.decomposition_desc || ''}
    ${tagsHtml ? `
    ♻️
    回收后可变成
    ${tagsHtml}
    ` : ''} ${co2Html} ${tipsHtml ? `
    💡
    投放小贴士
    ` : ''} ${factsHtml ? `
    🤯
    你知道吗?
    ` : ''} `; } function showError(title, detail) { haptic([20, 30, 20]); errorTitle.textContent = title || '识别失败'; errorDetail.textContent = detail || '请重试或换一张照片'; showSection(errorSection); } // ============================================ // 重置 // ============================================ function resetAll() { haptic(10); resetUpload(); showSection(uploadSection); multiResultSection.style.display = 'none'; } // 支持多物体的 "再识别一张" btnAgain.addEventListener('click', resetAll); btnErrorRetry.addEventListener('click', resetAll); const btnMultiAgain = $('#btnMultiAgain'); if (btnMultiAgain) btnMultiAgain.addEventListener('click', resetAll); // ============================================ // 初始化 // ============================================ function init() { showSection(uploadSection); initRipples(); initGlowTracking(); // 初始化每日挑战 ChallengeTracker.init(); // 初始化碳足迹 CarbonTracker.init(); // 初始化游戏中心 GameCenter.init(); TimedQuiz.init(); MemoryMatch.init(); // 图鉴清空按钮 const clearBtn = $('#atlasClear'); if (clearBtn) clearBtn.addEventListener('click', () => Atlas.clear()); // 碳足迹折叠按钮 const carbonToggleBtn = $('#carbonToggle'); if (carbonToggleBtn) { carbonToggleBtn.addEventListener('click', () => { const body = $('#carbonBody'); if (body) { body.classList.toggle('carbon-body--collapsed'); carbonToggleBtn.classList.toggle('carbon-toggle--open'); } }); } fetch('/api/health') .then(r => r.json()) .then(data => console.log('Backend connected:', data)) .catch(() => {}); // ESC 退出所有游戏界面 document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { hideAllGameScreens(); } }); } // 退出所有游戏界面,返回主页面 function hideAllGameScreens() { // 清除所有游戏计时器 if (TimedQuiz && TimedQuiz.timer) { clearInterval(TimedQuiz.timer); TimedQuiz.timer = null; } if (MemoryMatch && MemoryMatch.timer) { clearInterval(MemoryMatch.timer); MemoryMatch.timer = null; } $('#gameCenterOverlay').style.display = 'none'; $('#timedQuizScreen').style.display = 'none'; $('#memoryScreen').style.display = 'none'; // 恢复到主页面 showSection(uploadSection); haptic(6); } // ============================================ // 每日挑战 — 融入式追踪 // ============================================ const CHALLENGE_KEY = 'gc_challenge_data'; const CHALLENGE_GOAL = 10; // 每日目标分类数 const MEDAL_DEFS = [ { id: 'first_try', label: '首战告捷', desc: '首次完成每日挑战', icon: '🎖️', check: s => s.firstTry }, { id: 'streak_3', label: '三日连续', desc: '连续打卡 3 天', icon: '🔥', check: s => s.streak >= 3 }, { id: 'streak_7', label: '七日连续', desc: '连续打卡 7 天', icon: '💎', check: s => s.streak >= 7 }, { id: 'streak_30', label: '月之星', desc: '连续打卡 30 天', icon: '🌟', check: s => s.streak >= 30 }, { id: 'classify_50', label: '小试牛刀', desc: '累计分类 50 件', icon: '📦', check: s => s.total >= 50 }, { id: 'classify_100', label: '分类达人', desc: '累计分类 100 件', icon: '🎯', check: s => s.total >= 100 }, { id: 'classify_500', label: '环保先锋', desc: '累计分类 500 件', icon: '♻️', check: s => s.total >= 500 }, ]; const ChallengeTracker = { // 读取存档 load() { try { const raw = localStorage.getItem(CHALLENGE_KEY); if (raw) return JSON.parse(raw); } catch (e) {} return { count: 0, date: null, streak: 0, total: 0, medals: [], completedDates: [], firstTry: false }; }, save(data) { try { localStorage.setItem(CHALLENGE_KEY, JSON.stringify(data)); } catch (e) {} }, // 每次分类成功后调用 track() { const data = this.load(); const today = new Date().toISOString().split('T')[0]; // 如果是新的一天,重置计数 if (data.date !== today) { // 检查上一日是否有打卡 → 更新连续天数 const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0]; if (data.date === yesterday) { data.streak++; } else if (data.date !== today) { // 断了或第一次 if (data.date !== null) data.streak = 0; } data.count = 0; data.date = today; } // 递增 data.count++; data.total++; // 检查是否达成今日目标 const justCompleted = data.count === CHALLENGE_GOAL; if (justCompleted) { data.firstTry = true; if (!data.completedDates.includes(today)) { data.completedDates.push(today); } // 打卡天数 = streak + 今天 // 但如果 steam 已含今天就不重复加 } // 检查勋章 const newMedals = MEDAL_DEFS.filter(m => m.check(data) && !data.medals.includes(m.id)); newMedals.forEach(m => { if (!data.medals.includes(m.id)) data.medals.push(m.id); }); this.save(data); // 更新入口卡片 this.updateEntry(data); // 如果今日目标达成,弹庆祝 if (justCompleted) { setTimeout(() => this.showCelebration(data), 300); } return { data, justCompleted, newMedals }; }, // 更新入口卡片的进度 updateEntry(data) { const today = new Date().toISOString().split('T')[0]; // 如果日期变了但还没分类,显示 0 const count = (data.date === today) ? data.count : 0; const pct = Math.min(100, (count / CHALLENGE_GOAL) * 100); $('#challengeEntryDesc').textContent = `今天已分类 ${count} 件物品`; $('#challengeEntryPct').textContent = Math.round(pct) + '%'; // 环形进度 const circumference = 2 * Math.PI * 18; // r=18 const offset = circumference * (1 - pct / 100); const ring = $('#challengeEntryRing'); if (ring) ring.setAttribute('stroke-dashoffset', offset); // 等级 const level = data.total >= 500 ? 5 : data.total >= 100 ? 4 : data.total >= 50 ? 3 : data.total >= 10 ? 2 : 1; $('#challengeEntryBadge').textContent = 'Lv.' + level; }, // 显示庆祝 showCelebration(data) { const section = $('#challengeResultSection'); section.style.display = 'block'; section.style.animation = 'none'; void section.offsetWidth; section.style.animation = 'resultIn 0.6s ease-out'; $('#challengeResultIcon').textContent = '🎉'; $('#challengeResultTitle').textContent = '今日挑战完成!'; $('#challengeResultSub').textContent = `已成功分类 ${CHALLENGE_GOAL} 件垃圾`; $('#resultCount').textContent = data.count; // 如果 streak 是 0 但今天完成了,至少算 1 天 const streak = Math.max(1, data.streak); $('#resultStreak').textContent = streak; $('#resultTotal').textContent = data.total; $('#resultMedals').textContent = data.medals.length; // 新勋章 const newMedals = MEDAL_DEFS.filter(m => m.check(data) && !data._shownMedals); if (newMedals.length > 0) { const medalsDiv = $('#challengeMedals'); medalsDiv.style.display = 'block'; const list = $('#medalsList'); list.innerHTML = ''; newMedals.forEach((m, i) => { data._shownMedals = data._shownMedals || []; data._shownMedals.push(m.id); const div = document.createElement('div'); div.className = 'medal-item medal-new'; div.style.animationDelay = (i * 0.1) + 's'; div.innerHTML = ` ${m.icon}
    ${m.label} ${m.desc}
    NEW `; list.appendChild(div); }); } else { $('#challengeMedals').style.display = 'none'; } section.scrollIntoView({ behavior: 'smooth', block: 'center' }); haptic([15, 30, 15]); }, // 初始化入口卡片 init() { const data = this.load(); this.updateEntry(data); // 点击卡片打开图鉴 $('#btnChallengeDetails').addEventListener('click', () => { Atlas.toggle(); }); // 分享按钮 $('#btnChallengeShare').addEventListener('click', () => { const d = this.load(); const text = `🎉 我在垃圾分类系统中完成了今日挑战!\n\n` + `连续打卡: ${Math.max(1, d.streak)} 天\n` + `累计分类: ${d.total} 件\n` + `🏅 勋章: ${d.medals.length} 枚\n\n` + `一起保护地球吧!🌱`; if (navigator.share) { navigator.share({ title: '垃圾分类每日挑战', text }).catch(() => {}); } else { navigator.clipboard.writeText(text).then(() => haptic(10)).catch(() => {}); } }); }, }; // ============================================ // 图鉴系统 (Collection Atlas) // ============================================ const COLLECTION_KEY = 'gc_atlas'; const Atlas = { load() { try { const raw = localStorage.getItem(COLLECTION_KEY); return raw ? JSON.parse(raw) : []; } catch(e) { return []; } }, save(items) { try { localStorage.setItem(COLLECTION_KEY, JSON.stringify(items)); } catch(e) {} }, // 每次分类后保存 addEntry(imgEl, result) { let items = this.load(); if (items.length >= 20) items = items.slice(-19); // 提取图片 src,一些国产浏览器对 img 元素有兼容问题 const src = (imgEl && imgEl.src) || ''; if (!src) return; items.push({ id: Date.now(), image: src, item: result.item_zh || result.item || '未知', category: result.category || '其他垃圾', confidence: result.confidence ? (result.confidence * 100).toFixed(1) + '%' : '', }); this.save(items); }, // 切换图鉴显示 toggle() { const section = $('#atlasSection'); if (!section) return; const isOpen = section.style.display !== 'none'; if (isOpen) { section.style.display = 'none'; } else { this.render(); section.style.display = 'block'; section.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } }, render() { const items = this.load(); const grid = $('#atlasGrid'); const empty = $('#atlasEmpty'); const count = $('#atlasCount'); count.textContent = items.length + ' 件'; if (!items.length) { grid.innerHTML = ''; empty.style.display = 'flex'; return; } empty.style.display = 'none'; // 倒序显示(最新的在前) grid.innerHTML = [...items].reverse().map(item => { const catClass = item.category === '可回收垃圾' ? 'blue' : item.category === '有害垃圾' ? 'red' : item.category === '厨余垃圾' ? 'green' : 'gray'; return `
    ${item.item}
    ${item.category} ${item.item}
    `; }).join(''); }, clear() { if (confirm('确定清空所有图鉴记录?')) { this.save([]); this.render(); haptic(10); } }, }; // ============================================ // 碳足迹追踪 (Carbon Footprint Tracker) // ============================================ const CARBON_KEY = 'gc_carbon_data'; const CARBON_ACHIEVEMENTS = [ { id: 'first_step', name: '第一步', icon: '🌱', check: d => d.totalCo2 >= 0.1 }, { id: 'kg_co2', name: '减碳 1kg', icon: '🍃', check: d => d.totalCo2 >= 1 }, { id: 'tree_planter', name: '种树者', icon: '🌳', check: d => d.trees >= 1 }, { id: 'co2_10', name: '减碳 10kg', icon: '🌿', check: d => d.totalCo2 >= 10 }, { id: 'co2_50', name: '环保先锋', icon: '♻️', check: d => d.totalCo2 >= 50 }, { id: 'co2_100', name: '碳中和小能手', icon: '🏆', check: d => d.totalCo2 >= 100 }, { id: 'items_100', name: '分类 100件', icon: '📦', check: d => d.totalItems >= 100 }, { id: 'items_500', name: '分类 500件', icon: '💎', check: d => d.totalItems >= 500 }, { id: 'week_streak', name: '连续7天', icon: '🔥', check: d => d.weekStreak >= 7 }, ]; const CarbonTracker = { // 默认数据 _defaults() { return { totalCo2: 0, totalTrees: 0, totalItems: 0, history: [], // { date, co2, trees, items } weekly: {}, // '2026-W25': { co2, items } achievements: [], lastActiveDate: null, weekStreak: 0, }; }, load() { try { const raw = localStorage.getItem(CARBON_KEY); if (raw) { const d = JSON.parse(raw); // 确保所有字段存在 const def = this._defaults(); for (const k of Object.keys(def)) { if (d[k] === undefined) d[k] = def[k]; } return d; } } catch(e) {} return this._defaults(); }, save(data) { try { localStorage.setItem(CARBON_KEY, JSON.stringify(data)); } catch(e) {} }, // 单物体分类后追踪 track(result) { const data = this.load(); const today = new Date().toISOString().split('T')[0]; const co2 = result.dna?.co2_saving_kg || 0; const trees = result.dna?.tree_saving || 0; if (co2 > 0 || trees > 0) { data.totalCo2 += co2; data.totalTrees += trees; data.totalItems += 1; data.history.push({ date: today, co2: co2, trees: trees, item: result.item_zh || result.item || '未知', }); // 按天聚合 const todayEntry = data.history.filter(h => h.date === today); if (todayEntry.length > 0) { // 已有今日记录 } // 周聚合 const weekKey = this._getWeekKey(today); if (!data.weekly[weekKey]) data.weekly[weekKey] = { co2: 0, items: 0 }; data.weekly[weekKey].co2 += co2; data.weekly[weekKey].items += 1; // 连续活跃检查 if (data.lastActiveDate !== today) { const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0]; if (data.lastActiveDate === yesterday) { data.weekStreak = (data.weekStreak || 0) + 1; } else if (data.lastActiveDate !== today) { data.weekStreak = 1; } data.lastActiveDate = today; } this._checkAchievements(data); this.save(data); this.render(data); } }, // 多物体分类后追踪 trackMulti(objects) { if (!objects || objects.length === 0) return; const data = this.load(); const today = new Date().toISOString().split('T')[0]; const weekKey = this._getWeekKey(today); let totalCo2 = 0; let totalTrees = 0; objects.forEach(obj => { const co2 = obj.dna?.co2_saving_kg || 0; const trees = obj.dna?.tree_saving || 0; totalCo2 += co2; totalTrees += trees; }); if (totalCo2 > 0 || totalTrees > 0) { data.totalCo2 += totalCo2; data.totalTrees += totalTrees; data.totalItems += objects.length; data.history.push({ date: today, co2: totalCo2, trees: totalTrees, item: objects.map(o => o.label_zh).join(' + '), }); if (!data.weekly[weekKey]) data.weekly[weekKey] = { co2: 0, items: 0 }; data.weekly[weekKey].co2 += totalCo2; data.weekly[weekKey].items += objects.length; if (data.lastActiveDate !== today) { const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0]; if (data.lastActiveDate === yesterday) { data.weekStreak = (data.weekStreak || 0) + 1; } else if (data.lastActiveDate !== today) { data.weekStreak = 1; } data.lastActiveDate = today; } this._checkAchievements(data); this.save(data); this.render(data); } }, _getWeekKey(dateStr) { const d = new Date(dateStr + 'T00:00:00'); const dayNum = d.getUTCDay() || 7; d.setUTCDate(d.getUTCDate() + 4 - dayNum); const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); const weekNo = Math.ceil((((d - yearStart) / 86400000) + 1) / 7); return d.getUTCFullYear() + '-W' + String(weekNo).padStart(2, '0'); }, _checkAchievements(data) { CARBON_ACHIEVEMENTS.forEach(a => { if (!data.achievements.includes(a.id) && a.check(data)) { data.achievements.push(a.id); } }); }, render(data) { const section = $('#carbonSection'); if (!section) return; if (!data || data.totalCo2 <= 0 && data.totalItems <= 0) { section.style.display = 'none'; return; } section.style.display = 'block'; // 主要指标 $('#carbonCo2Saved').textContent = data.totalCo2.toFixed(1); $('#carbonTreesPlanted').textContent = data.totalTrees.toFixed(2); $('#carbonItemsClassified').textContent = data.totalItems || 0; // 等效对比(基于环保部推荐换算系数) const carKm = data.totalCo2 / 0.17; // 0.17 kg CO₂/km(普通家用车) const bulbHours = data.totalCo2 / 0.04; // 0.04 kg CO₂/h(60W节能灯) const phoneCharges = data.totalCo2 / 0.005; // 0.005 kg CO₂/次(手机充电) const treeDays = data.totalCo2 / 0.02; // 0.02 kg CO₂/天(一棵树日吸收量) $('#equivCar').textContent = `相当于开车减少 ${carKm >= 1 ? carKm.toFixed(1) : '<1'} km`; $('#equivBulb').textContent = `相当于灯泡点亮 ${bulbHours >= 1 ? Math.round(bulbHours) : '<1'} 小时`; $('#equivCharge').textContent = `相当于手机充电 ${phoneCharges >= 1 ? Math.round(phoneCharges) : '<1'} 次`; // 月份趋势 this._renderWeeklyBars(data); // 成就 this._renderAchievements(data); }, _renderWeeklyBars(data) { const container = $('#carbonBars'); const monthlySection = $('#carbonMonthly'); if (!container || !monthlySection) return; const weeks = Object.keys(data.weekly).sort().slice(-8); if (weeks.length < 2) { monthlySection.style.display = 'none'; return; } monthlySection.style.display = 'block'; const maxCo2 = Math.max(...weeks.map(w => data.weekly[w].co2), 0.01); container.innerHTML = weeks.map(w => { const weekData = data.weekly[w]; const pct = (weekData.co2 / maxCo2) * 100; const shortLabel = w.split('-W')[1] || w.slice(-2); return `
    周${shortLabel}: ${weekData.co2.toFixed(2)}kg
    `; }).join(''); }, _renderAchievements(data) { const list = $('#carbonAchievementList'); if (!list) return; list.innerHTML = CARBON_ACHIEVEMENTS.map(a => { const unlocked = data.achievements.includes(a.id); return `
    ${unlocked ? a.icon : '🔒'} ${a.name}
    `; }).join(''); }, init() { const data = this.load(); if (data && data.totalCo2 > 0) { this.render(data); } // 修改 showResult 中调用 track 的行为:添加碳足迹追踪 // 通过 monkey-patch ChallengeTracker.track 的方式已足够 // CarbonTracker 在 track() 和 trackMulti() 中自动保存 }, }; // ============================================ // 游戏数据常量 // ============================================ const CATEGORIES = ['可回收垃圾', '有害垃圾', '厨余垃圾', '其他垃圾']; const GAME_ITEMS = [ // 可回收垃圾 { name_en: 'plastic bottle for drinking', name_zh: '塑料瓶', category: '可回收垃圾', emoji: '🧴' }, { name_en: 'glass bottle for drinking', name_zh: '玻璃瓶', category: '可回收垃圾', emoji: '🥃' }, { name_en: 'aluminum can for soda or beer', name_zh: '易拉罐', category: '可回收垃圾', emoji: '🥤' }, { name_en: 'paper and cardboard for recycling', name_zh: '纸张', category: '可回收垃圾', emoji: '📄' }, { name_en: 'cardboard box for packaging', name_zh: '纸箱', category: '可回收垃圾', emoji: '📦' }, { name_en: 'newspaper and magazines', name_zh: '报纸杂志', category: '可回收垃圾', emoji: '📰' }, { name_en: 'clothing and textiles', name_zh: '衣物', category: '可回收垃圾', emoji: '👕' }, { name_en: 'plastic bag for shopping', name_zh: '塑料袋', category: '可回收垃圾', emoji: '🛍️' }, { name_en: 'metal tools and hardware', name_zh: '金属制品', category: '可回收垃圾', emoji: '🔧' }, { name_en: 'electronics and wires with circuit', name_zh: '电子产品', category: '可回收垃圾', emoji: '💻' }, { name_en: 'wooden furniture or wood pieces', name_zh: '木材家具', category: '可回收垃圾', emoji: '🪵' }, { name_en: 'books and notebooks for school', name_zh: '书本', category: '可回收垃圾', emoji: '📚' }, { name_en: 'glass jar with lid', name_zh: '玻璃罐', category: '可回收垃圾', emoji: '🫙' }, { name_en: 'iron and steel food cans', name_zh: '铁罐', category: '可回收垃圾', emoji: '🥫' }, { name_en: 'shoes and bags made of fabric', name_zh: '鞋包', category: '可回收垃圾', emoji: '👟' }, { name_en: 'plastic container for food storage', name_zh: '塑料容器', category: '可回收垃圾', emoji: '🥡' }, { name_en: 'milk carton or tetra pak drink box', name_zh: '牛奶盒', category: '可回收垃圾', emoji: '🥛' }, { name_en: 'plastic bottle cap', name_zh: '瓶盖', category: '可回收垃圾', emoji: '🔘' }, { name_en: 'disposable wooden chopsticks pair', name_zh: '一次性筷子', category: '可回收垃圾', emoji: '🥢' }, // 有害垃圾 { name_en: 'battery cell for electronics', name_zh: '电池', category: '有害垃圾', emoji: '🔋' }, { name_en: 'light bulb glass transparent', name_zh: '灯泡', category: '有害垃圾', emoji: '💡' }, { name_en: 'medicine pills and tablets', name_zh: '药品', category: '有害垃圾', emoji: '💊' }, { name_en: 'paint can with metal handle', name_zh: '油漆桶', category: '有害垃圾', emoji: '🎨' }, { name_en: 'mercury thermometer glass', name_zh: '温度计', category: '有害垃圾', emoji: '🌡️' }, { name_en: 'pesticide spray bottle', name_zh: '杀虫剂', category: '有害垃圾', emoji: '🧪' }, { name_en: 'fluorescent tube light', name_zh: '荧光灯管', category: '有害垃圾', emoji: '💡' }, { name_en: 'nail polish bottle small', name_zh: '化妆品', category: '有害垃圾', emoji: '💄' }, // 厨余垃圾 { name_en: 'food waste and leftovers on plate', name_zh: '食物残渣', category: '厨余垃圾', emoji: '🍽️' }, { name_en: 'fruit and fruit peels organic waste', name_zh: '水果果皮', category: '厨余垃圾', emoji: '🍌' }, { name_en: 'banana peel yellow', name_zh: '香蕉皮', category: '厨余垃圾', emoji: '🍌' }, { name_en: 'apple core with seeds', name_zh: '苹果核', category: '厨余垃圾', emoji: '🍎' }, { name_en: 'tea leaves and coffee grounds wet', name_zh: '茶叶咖啡渣', category: '厨余垃圾', emoji: '🫖' }, { name_en: 'egg shell broken pieces', name_zh: '蛋壳', category: '厨余垃圾', emoji: '🥚' }, { name_en: 'fish bones and chicken bones', name_zh: '骨头', category: '厨余垃圾', emoji: '🍗' }, { name_en: 'leftover rice and noodles in bowl', name_zh: '剩饭剩面', category: '厨余垃圾', emoji: '🍚' }, { name_en: 'cooked meat and bones leftovers', name_zh: '肉渣骨头', category: '厨余垃圾', emoji: '🥩' }, // 其他垃圾 { name_en: 'styrofoam and foam packaging white', name_zh: '泡沫塑料', category: '其他垃圾', emoji: '🧊' }, { name_en: 'disposable face mask blue', name_zh: '口罩', category: '其他垃圾', emoji: '😷' }, { name_en: 'tissue paper and napkins used', name_zh: '纸巾', category: '其他垃圾', emoji: '🧻' }, { name_en: 'cigarette butt with filter', name_zh: '烟蒂', category: '其他垃圾', emoji: '🚬' }, { name_en: 'diaper and sanitary pads', name_zh: '尿不湿', category: '其他垃圾', emoji: '👶' }, { name_en: 'broken glass not recyclable shards', name_zh: '碎玻璃', category: '其他垃圾', emoji: '🪟' }, { name_en: 'instant noodle cup styrofoam cup with lid', name_zh: '泡面桶', category: '其他垃圾', emoji: '🍜' }, { name_en: 'takeout food container plastic box', name_zh: '外卖盒', category: '其他垃圾', emoji: '🥡' }, { name_en: 'bubble tea cup with plastic lid and straw', name_zh: '奶茶杯', category: '其他垃圾', emoji: '🧋' }, { name_en: 'plastic drinking straw', name_zh: '吸管', category: '其他垃圾', emoji: '🥤' }, { name_en: 'wet wipes in package', name_zh: '湿巾', category: '其他垃圾', emoji: '🧻' }, { name_en: 'cotton swab for ears', name_zh: '棉签', category: '其他垃圾', emoji: '🪥' }, { name_en: 'bandage and adhesive tape medical', name_zh: '创可贴', category: '其他垃圾', emoji: '🩹' }, { name_en: 'broken porcelain ceramic shards', name_zh: '碎瓷器', category: '其他垃圾', emoji: '🏺' }, { name_en: 'disposable foam lunch box', name_zh: '泡沫饭盒', category: '其他垃圾', emoji: '📦' }, ]; const ITEM_FUN_FACTS = { "plastic bottle for drinking": "每回收 1 吨 PET 塑料瓶,可节省约 6 吨石油资源", "glass bottle for drinking": "玻璃可以无限次 100% 回收,质量不会下降", "aluminum can for soda or beer": "回收一个易拉罐节省的电量可让电视运行 3 小时", "paper and cardboard for recycling": "回收 1 吨废纸可造 800 公斤新纸,少砍 17 棵树", "cardboard box for packaging": "纸箱回收后可以做成再生纸板箱或鸡蛋托", "newspaper and magazines": "旧报纸回收后可以制成铅笔、鸡蛋托等新产品", "clothing and textiles": "旧衣物可以回收制成工业擦布、填充材料或再生纤维", "plastic bag for shopping": "塑料袋自然降解需要 10-1000 年", "metal tools and hardware": "金属可以反复回收利用,性能不会下降", "electronics and wires with circuit": "1 吨废旧手机含金量是 1 吨金矿的 100 倍", "books and notebooks for school": "旧书回收后可制成再生纸浆,用于生产包装纸", "glass jar with lid": "玻璃回收可减少 20% 的空气污染和 50% 的水污染", "milk carton or tetra pak drink box": "利乐包装可回收制成板材或再生纸", "plastic bottle cap": "瓶盖通常使用 HDPE 塑料,回收后可制成塑料管材", "battery cell for electronics": "一粒纽扣电池可污染 60 万升水", "light bulb glass transparent": "节能灯含汞,破碎后汞会挥发到空气中", "medicine pills and tablets": "过期药品属于有害垃圾,应投入红色垃圾桶", "paint can with metal handle": "油漆桶含有害化学物质,需专业处理", "nail polish bottle small": "化妆品和指甲油含有害化学物质", "food waste and leftovers on plate": "厨余垃圾可以堆肥转化为有机肥料", "fruit and fruit peels organic waste": "果皮果核是制作环保酵素的好材料", "banana peel yellow": "香蕉皮富含钾,可堆肥制成天然肥料", "tea leaves and coffee grounds wet": "茶渣咖啡渣是优质的堆肥原料,还可除臭", "egg shell broken pieces": "蛋壳富含钙质,磨碎后可作天然肥料", "fish bones and chicken bones": "骨头经粉碎后可加工成动物饲料或肥料", "styrofoam and foam packaging white": "泡沫塑料自然降解需要数百年", "disposable face mask blue": "一次性口罩的主要原料是聚丙烯,很难自然降解", "tissue paper and napkins used": "用过的纸巾水溶性太差,不适合回收造纸", "cigarette butt with filter": "烟蒂中的醋酸纤维在自然环境中需 10 年才能降解", "diaper and sanitary pads": "尿不湿含有高分子吸水树脂,属于其他垃圾", "instant noodle cup styrofoam cup with lid": "泡面桶内壁有塑料淋膜,回收难度大", "plastic drinking straw": "一次性吸管虽小,但回收价值低", "wet wipes in package": "湿巾含有化纤成分,不可冲入马桶", "cotton swab for ears": "棉签杆多为塑料材质,小件易被遗漏", "bandage and adhesive tape medical": "创可贴使用后可能沾有体液,不宜回收", }; // ============================================ // 游戏中心 (GameCenter) // ============================================ const GC_KEY = 'gc_game_data'; const GameCenter = { _defaults() { return { coins: 0, timedquiz_highest: 0, timedquiz_last: null, memory_besttime: null, memory_bestattempts: null, memory_last: null, }; }, load() { try { const raw = localStorage.getItem(GC_KEY); if (raw) { const d = JSON.parse(raw); const def = this._defaults(); for (const k of Object.keys(def)) { if (d[k] === undefined) d[k] = def[k]; } return d; } } catch(e) {} return this._defaults(); }, save(data) { try { localStorage.setItem(GC_KEY, JSON.stringify(data)); } catch(e) {} }, getCoins() { return this.load().coins; }, addCoins(amount) { const data = this.load(); data.coins += amount; this.save(data); this.updateCoinsDisplay(); }, updateCoinsDisplay() { const el = $('#gcCoinsDisplay'); if (el) el.textContent = '🪙 ' + this.getCoins(); }, showGameCenter() { const data = this.load(); // 更新高分数值 const tqHigh = $('#gcTqHighScore'); if (tqHigh) tqHigh.textContent = data.timedquiz_highest || '0'; const memBest = $('#gcMemBestTime'); if (memBest) { memBest.textContent = data.memory_besttime ? data.memory_besttime + 's' : '--'; } this.updateCoinsDisplay(); $('#gameCenterOverlay').style.display = 'flex'; haptic(8); }, hideGameCenter() { $('#gameCenterOverlay').style.display = 'none'; }, init() { // 入口卡片 const entry = $('#gameCenterEntry'); if (entry) { entry.addEventListener('click', () => this.showGameCenter()); } // 返回按钮 const backBtn = $('#gcBack'); if (backBtn) backBtn.addEventListener('click', () => this.hideGameCenter()); // 限时游戏入口 const tqCard = $('#gcTimedQuiz'); if (tqCard) tqCard.addEventListener('click', () => { this.hideGameCenter(); TimedQuiz.showStart(); }); // 连连看入口 const memCard = $('#gcMemoryMatch'); if (memCard) memCard.addEventListener('click', () => { this.hideGameCenter(); MemoryMatch.showStart(); }); }, }; // ============================================ // 限时分类挑战 (TimedQuiz) // ============================================ const TimedQuiz = { TOTAL_TIME: 60, ITEMS_PER_GAME: 20, SPEED_BONUS_TIME: 3, SPEED_BONUS_POINTS: 2, state: null, timer: null, showStart() { // 隐藏当前使用 section [uploadSection, loadingSection, resultSection, errorSection, multiResultSection].forEach(s => { s.style.display = 'none'; }); const data = GameCenter.load(); $('#tqStartHighScore').textContent = data.timedquiz_highest || '0'; $('#timedQuizScreen').style.display = 'flex'; $('#tqStart').style.display = 'flex'; $('#tqGame').style.display = 'none'; $('#tqResult').style.display = 'none'; }, startGame() { // 准备题目: 打乱后选 ITEMS_PER_GAME 个 const shuffled = [...GAME_ITEMS].sort(() => Math.random() - 0.5); const items = shuffled.slice(0, this.ITEMS_PER_GAME); this.state = { items: items, currentIndex: 0, score: 0, timeLeft: this.TOTAL_TIME, streak: 0, maxStreak: 0, answers: [], startTime: Date.now(), answered: false, }; $('#tqStart').style.display = 'none'; $('#tqGame').style.display = 'flex'; $('#tqResult').style.display = 'none'; this.renderHUD(); this.showItem(); this.resetFeedback(); // 启动计时器 if (this.timer) clearInterval(this.timer); this.timer = setInterval(() => this.tick(), 1000); }, tick() { if (!this.state) return; this.state.timeLeft--; this.renderHUD(); const timerEl = $('#tqTimer'); if (timerEl) { timerEl.classList.toggle('tq-timer--warning', this.state.timeLeft <= 5); } if (this.state.timeLeft <= 0) { this.endGame(); } }, currentItem() { return this.state.items[this.state.currentIndex]; }, showItem() { const item = this.currentItem(); if (!item) { this.endGame(); return; } this.state.answered = false; $('#tqItemEmoji').textContent = item.emoji || '📦'; $('#tqItemName').textContent = item.name_zh; // 重置按钮状态 $$('.tq-btn').forEach(btn => { btn.classList.remove('tq-btn--correct', 'tq-btn--wrong', 'tq-btn--show-correct'); btn.disabled = false; btn.style.pointerEvents = 'auto'; }); // 重置进度 const pct = ((this.state.currentIndex) / this.state.items.length) * 100; const fill = $('#tqProgressFill'); if (fill) fill.style.width = Math.min(pct, 100) + '%'; // 物品入场动画 const card = $('#tqItemCard'); if (card) { card.style.animation = 'none'; void card.offsetWidth; card.style.animation = 'tqItemIn 0.35s var(--ease-out)'; } this.resetFeedback(); }, submitAnswer(category) { if (this.state.answered) return; this.state.answered = true; const item = this.currentItem(); if (!item) return; const isCorrect = category === item.category; const timeElapsed = this.TOTAL_TIME - this.state.timeLeft; const isSpeedBonus = isCorrect && timeElapsed <= this.SPEED_BONUS_TIME; // 禁用所有按钮 $$('.tq-btn').forEach(btn => { btn.disabled = true; btn.style.pointerEvents = 'none'; }); // 高亮正确/错误 $$('.tq-btn').forEach(btn => { const btnCat = btn.dataset.category; if (btnCat === item.category) { btn.classList.add('tq-btn--correct'); if (!isCorrect) btn.classList.add('tq-btn--show-correct'); } if (btnCat === category && !isCorrect) { btn.classList.add('tq-btn--wrong'); } }); if (isCorrect) { this.state.score++; this.state.streak++; if (this.state.streak > this.state.maxStreak) this.state.maxStreak = this.state.streak; if (isSpeedBonus) this.state.score += this.SPEED_BONUS_POINTS; this.showFeedback(true, item, isSpeedBonus); // === 仅计入 ChallengeTracker(答题不产生实际减排) === ChallengeTracker.track(); // 不向 CarbonTracker 写入虚假数据 } else { this.state.streak = 0; this.showFeedback(false, item, false); } this.state.answers.push({ item, guess: category, correct: isCorrect, speedBonus: isSpeedBonus }); this.renderHUD(); // 下一题 this.state.currentIndex++; if (this.state.currentIndex >= this.state.items.length) { setTimeout(() => this.endGame(), 1200); } else { setTimeout(() => this.showItem(), 1200); } }, showFeedback(isCorrect, item, speedBonus) { const fb = $('#tqFeedback'); if (!fb) return; fb.style.display = 'flex'; if (isCorrect) { $('#tqFeedbackIcon').textContent = speedBonus ? '⚡' : '✅'; $('#tqFeedbackText').textContent = speedBonus ? `正确! +${1 + this.SPEED_BONUS_POINTS} 分 (速度奖励!)` : '正确! +1 分'; const fact = ITEM_FUN_FACTS[item.name_en]; $('#tqFeedbackSub').textContent = fact || `${item.name_zh} → ${item.category}`; } else { $('#tqFeedbackIcon').textContent = '❌'; $('#tqFeedbackText').textContent = `错了! 正确答案是: ${item.category}`; $('#tqFeedbackSub').textContent = ITEM_FUN_FACTS[item.name_en] || ''; } }, resetFeedback() { const fb = $('#tqFeedback'); if (fb) fb.style.display = 'none'; }, renderHUD() { const timerEl = $('#tqTimer'); if (timerEl) timerEl.textContent = Math.max(0, this.state.timeLeft); const scoreEl = $('#tqScore'); if (scoreEl) scoreEl.textContent = this.state.score; const streakEl = $('#tqStreak'); if (streakEl) streakEl.textContent = '🔥 ' + this.state.streak; }, endGame() { if (this.timer) { clearInterval(this.timer); this.timer = null; } if (!this.state || !this.state.answers) return; const total = this.state.answers.length; const correctCount = this.state.answers.filter(a => a.correct).length; const accuracy = total > 0 ? Math.round((correctCount / total) * 100) : 0; const score = this.state.score; const maxStreak = this.state.maxStreak; // 检查最高分 const data = GameCenter.load(); const isNewRecord = score > (data.timedquiz_highest || 0); if (isNewRecord) { data.timedquiz_highest = score; } // 游戏币: 答对×2 + 最高连击≥5 +10 + 新纪录+15 let coins = correctCount * 2; if (maxStreak >= 5) coins += 10; if (isNewRecord) coins += 15; data.timedquiz_last = { score, accuracy, maxStreak, correctCount, total, date: new Date().toISOString() }; GameCenter.addCoins(coins); // 显示结果 $('#tqGame').style.display = 'none'; $('#tqResult').style.display = 'flex'; if (score >= 30) $('#tqResultIcon').textContent = '🏆'; else if (score >= 20) $('#tqResultIcon').textContent = '🎉'; else if (score >= 10) $('#tqResultIcon').textContent = '👍'; else $('#tqResultIcon').textContent = '💪'; $('#tqResultTitle').textContent = isNewRecord ? '新纪录! 太棒了!' : '挑战完成!'; $('#tqResultScore').textContent = score + ' 分'; $('#tqResultNewRecord').style.display = isNewRecord ? 'block' : 'none'; $('#tqStatCorrect').textContent = correctCount; $('#tqStatTotal').textContent = total; $('#tqStatAccuracy').textContent = accuracy + '%'; $('#tqStatStreak').textContent = maxStreak; $('#tqCoinsEarned').textContent = coins; haptic([10, 30, 10]); }, init() { // 开始按钮 const startBtn = $('#tqStartBtn'); if (startBtn) startBtn.addEventListener('click', () => this.startGame()); // 分类按钮 $$('.tq-btn').forEach(btn => { btn.addEventListener('click', () => { const cat = btn.dataset.category; if (cat) this.submitAnswer(cat); }); }); // 返回按钮 const backBtns = ['tqBack', 'tqResultBack']; backBtns.forEach(id => { const el = $(`#${id}`); if (el) el.addEventListener('click', () => { if (this.timer) { clearInterval(this.timer); this.timer = null; } $('#timedQuizScreen').style.display = 'none'; GameCenter.showGameCenter(); }); }); // 再来一次 / 返回 const playAgain = $('#tqPlayAgain'); if (playAgain) playAgain.addEventListener('click', () => this.showStart()); const toGC = $('#tqToGameCenter'); if (toGC) toGC.addEventListener('click', () => { $('#timedQuizScreen').style.display = 'none'; GameCenter.showGameCenter(); }); // 游戏内退出按钮 const tqGameClose = $('#tqGameClose'); if (tqGameClose) tqGameClose.addEventListener('click', () => { if (this.timer) { clearInterval(this.timer); this.timer = null; } $('#timedQuizScreen').style.display = 'none'; GameCenter.showGameCenter(); }); }, }; // ============================================ // 垃圾分类连连看 (MemoryMatch) // ============================================ const MemoryMatch = { PAIRS_COUNT: 8, COLS: 4, state: null, timer: null, showStart() { [uploadSection, loadingSection, resultSection, errorSection, multiResultSection].forEach(s => { s.style.display = 'none'; }); const data = GameCenter.load(); const recordEl = $('#memStartRecord'); if (recordEl) { if (data.memory_besttime) { recordEl.innerHTML = `最佳纪录: ${data.memory_besttime}s (${data.memory_bestattempts || '?'} 次尝试)`; } else { recordEl.textContent = '来创造你的最佳纪录吧!'; } } $('#memoryScreen').style.display = 'flex'; $('#memStart').style.display = 'flex'; $('#memGame').style.display = 'none'; $('#memResult').style.display = 'none'; }, startGame() { // 选取 8 对物品 const shuffled = [...GAME_ITEMS].sort(() => Math.random() - 0.5); const selected = shuffled.slice(0, this.PAIRS_COUNT); const emojis = ['♻️', '☣️', '🍚', '🗑️', '📦', '🔋', '🥤', '🧴', '💊', '👕', '📄', '🔧', '🥫', '🧻', '🥢', '💄']; const catIcons = { '可回收垃圾': '♻️', '有害垃圾': '☣️', '厨余垃圾': '🍚', '其他垃圾': '🗑️' }; // 创建卡牌: 每对 = 1 张物品卡 + 1 张类别卡 const cards = []; selected.forEach((item, idx) => { // 物品卡 cards.push({ id: idx * 2, pairId: idx, type: 'item', name_zh: item.name_zh, emoji: item.emoji || '📦', category: item.category, }); // 类别卡 cards.push({ id: idx * 2 + 1, pairId: idx, type: 'category', name_zh: item.category, emoji: catIcons[item.category] || '🗑️', category: item.category, }); }); // 洗牌 cards.sort(() => Math.random() - 0.5); this.state = { cards: cards, flipped: [], matched: new Set(), attempts: 0, matchCount: 0, startTime: Date.now(), isLocked: false, }; $('#memStart').style.display = 'none'; $('#memGame').style.display = 'flex'; $('#memResult').style.display = 'none'; this.renderGrid(); this.updateHUD(); // 计时器 if (this.timer) clearInterval(this.timer); this.timer = setInterval(() => this.updateTimer(), 1000); }, renderGrid() { const grid = $('#memGrid'); if (!grid) return; grid.innerHTML = this.state.cards.map((card, index) => { return `
    ${card.type === 'item' ? `
    ${card.emoji}
    ${card.name_zh}
    ` : `
    ${card.emoji}
    ${card.name_zh}
    `}
    `; }).join(''); // 绑定点击事件 grid.querySelectorAll('.memory-card').forEach(el => { el.addEventListener('click', () => { const index = parseInt(el.dataset.index); this.flipCard(index); }); }); }, flipCard(index) { if (this.state.isLocked) return; const card = this.state.cards[index]; if (!card) return; if (this.state.flipped.includes(index)) return; if (this.state.matched.has(card.pairId)) return; this.state.flipped.push(index); const cardEl = document.querySelector(`.memory-card[data-index="${index}"]`); if (cardEl) cardEl.classList.add('memory-card--flipped'); haptic(4); if (this.state.flipped.length === 2) { this.state.attempts++; this.updateHUD(); this.checkMatch(); } }, checkMatch() { this.state.isLocked = true; const [i1, i2] = this.state.flipped; const c1 = this.state.cards[i1]; const c2 = this.state.cards[i2]; if (c1.pairId === c2.pairId) { // 匹配成功! this.state.matched.add(c1.pairId); this.state.matchCount++; const el1 = document.querySelector(`.memory-card[data-index="${i1}"]`); const el2 = document.querySelector(`.memory-card[data-index="${i2}"]`); if (el1) el1.classList.add('memory-card--matched'); if (el2) el2.classList.add('memory-card--matched'); this.state.flipped = []; this.state.isLocked = false; // 集成 ChallengeTracker.track(); this.updateHUD(); haptic([8, 8, 8]); if (this.state.matchCount >= this.PAIRS_COUNT) { setTimeout(() => this.endGame(), 600); } } else { // 不匹配 setTimeout(() => { const el1 = document.querySelector(`.memory-card[data-index="${i1}"]`); const el2 = document.querySelector(`.memory-card[data-index="${i2}"]`); if (el1) el1.classList.remove('memory-card--flipped'); if (el2) el2.classList.remove('memory-card--flipped'); this.state.flipped = []; this.state.isLocked = false; haptic(6); }, 900); } }, updateHUD() { const attempts = $('#memAttempts'); if (attempts) attempts.textContent = this.state.attempts; const matches = $('#memMatches'); if (matches) matches.textContent = this.state.matchCount + '/' + this.PAIRS_COUNT; }, updateTimer() { if (!this.state) return; const elapsed = Math.floor((Date.now() - this.state.startTime) / 1000); const timerEl = $('#memTimer'); if (timerEl) timerEl.textContent = elapsed + 's'; }, endGame() { if (this.timer) { clearInterval(this.timer); this.timer = null; } const elapsed = Math.floor((Date.now() - this.state.startTime) / 1000); const data = GameCenter.load(); const isNewTime = !data.memory_besttime || elapsed < data.memory_besttime; const isNewAttempts = !data.memory_bestattempts || this.state.attempts < data.memory_bestattempts; if (isNewTime) data.memory_besttime = elapsed; if (isNewAttempts) data.memory_bestattempts = this.state.attempts; data.memory_last = { elapsed, attempts: this.state.attempts, date: new Date().toISOString() }; GameCenter.save(data); $('#memGame').style.display = 'none'; $('#memResult').style.display = 'flex'; $('#memResultNewRecord').style.display = (isNewTime || isNewAttempts) ? 'block' : 'none'; $('#memResultAttempts').textContent = this.state.attempts + ' 次'; $('#memResultTime').textContent = elapsed + 's'; GameCenter.addCoins(this.state.attempts <= 12 ? 15 : 8); haptic([10, 30, 10]); }, init() { const startBtn = $('#memStartBtn'); if (startBtn) startBtn.addEventListener('click', () => this.startGame()); const backBtns = ['memBack', 'memResultBack']; backBtns.forEach(id => { const el = $(`#${id}`); if (el) el.addEventListener('click', () => { if (this.timer) { clearInterval(this.timer); this.timer = null; } $('#memoryScreen').style.display = 'none'; GameCenter.showGameCenter(); }); }); const playAgain = $('#memPlayAgain'); if (playAgain) playAgain.addEventListener('click', () => this.showStart()); const toGC = $('#memToGameCenter'); if (toGC) toGC.addEventListener('click', () => { $('#memoryScreen').style.display = 'none'; GameCenter.showGameCenter(); }); // 游戏内退出按钮 const memGameClose = $('#memGameClose'); if (memGameClose) memGameClose.addEventListener('click', () => { if (this.timer) { clearInterval(this.timer); this.timer = null; } $('#memoryScreen').style.display = 'none'; GameCenter.showGameCenter(); }); }, }; if (document.readyState === 'complete') { init(); } else { document.addEventListener('DOMContentLoaded', init); } })();