Spaces:
Running
Running
| /** | |
| * 智能垃圾分类系统 - 高级交互 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 = ` | |
| <span class="prediction-label">${p.label_zh || p.label}</span> | |
| <div class="prediction-bar"> | |
| <div class="prediction-bar-fill"></div> | |
| </div> | |
| <span class="prediction-score">${score}%</span> | |
| `; | |
| 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 ` | |
| <button class="multi-tab ${index === selectedMultiIndex ? 'multi-tab--active' : ''}" | |
| data-index="${index}" data-ripple> | |
| <span class="multi-tab-index" style="background:${color}">${index + 1}</span> | |
| <span>${obj.label_zh || obj.label}</span> | |
| <span style="font-size:0.7rem;opacity:0.6">${catIcon}</span> | |
| </button> | |
| `; | |
| }).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 => | |
| `<span class="dna-tag">${t}</span>` | |
| ).join(''); | |
| } | |
| let tipsHtml = ''; | |
| if (dna.disposal_tips && dna.disposal_tips.length > 0) { | |
| tipsHtml = dna.disposal_tips.map(t => `<li>${t}</li>`).join(''); | |
| } | |
| let factsHtml = ''; | |
| if (dna.fun_facts && dna.fun_facts.length > 0) { | |
| factsHtml = dna.fun_facts.map(f => `<li>${f}</li>`).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 = ` | |
| <div class="dna-row dna-row-co2"> | |
| <div class="dna-row-icon">🌱</div> | |
| <div class="dna-row-content"> | |
| <div class="dna-row-label">环境效益</div> | |
| <div class="dna-row-value">${co2Text}</div> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| body.innerHTML = ` | |
| <div class="dna-row dna-row-material"> | |
| <div class="dna-row-icon">🧪</div> | |
| <div class="dna-row-content"> | |
| <div class="dna-row-label">材质构成</div> | |
| <div class="dna-row-value">${dna.material}</div> | |
| </div> | |
| </div> | |
| <div class="dna-row dna-row-decomposition"> | |
| <div class="dna-row-icon">⏳</div> | |
| <div class="dna-row-content"> | |
| <div class="dna-row-label">自然降解时间</div> | |
| <div class="dna-row-value">${timeStr}</div> | |
| <div class="dna-row-sub">${dna.decomposition_desc || ''}</div> | |
| </div> | |
| </div> | |
| ${tagsHtml ? ` | |
| <div class="dna-row dna-row-recycle"> | |
| <div class="dna-row-icon">♻️</div> | |
| <div class="dna-row-content"> | |
| <div class="dna-row-label">回收后可变成</div> | |
| <div class="dna-row-tags">${tagsHtml}</div> | |
| </div> | |
| </div>` : ''} | |
| ${co2Html} | |
| ${tipsHtml ? ` | |
| <div class="dna-row dna-row-tips"> | |
| <div class="dna-row-icon">💡</div> | |
| <div class="dna-row-content"> | |
| <div class="dna-row-label">投放小贴士</div> | |
| <ul class="dna-tips-list">${tipsHtml}</ul> | |
| </div> | |
| </div>` : ''} | |
| ${factsHtml ? ` | |
| <div class="dna-row dna-row-facts"> | |
| <div class="dna-row-icon">🤯</div> | |
| <div class="dna-row-content"> | |
| <div class="dna-row-label">你知道吗?</div> | |
| <ul class="dna-facts-list">${factsHtml}</ul> | |
| </div> | |
| </div>` : ''} | |
| `; | |
| } | |
| 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 = ` | |
| <span class="medal-icon">${m.icon}</span> | |
| <div class="medal-info"> | |
| <span class="medal-name">${m.label}</span> | |
| <span class="medal-desc">${m.desc}</span> | |
| </div> | |
| <span class="medal-new-badge">NEW</span> | |
| `; | |
| 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 ` | |
| <div class="atlas-item"> | |
| <img src="${item.image}" alt="${item.item}" loading="lazy"> | |
| <div class="atlas-item-overlay"> | |
| <span class="atlas-item-cat" style="color: var(--color-${catClass})">${item.category}</span> | |
| <span class="atlas-item-name">${item.item}</span> | |
| </div> | |
| </div> | |
| `; | |
| }).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 ` | |
| <div class="carbon-bar" style="height:${Math.max(pct, 4)}%;background:linear-gradient(to top, #2d8a4e, #5bd68a);" | |
| title="第${shortLabel}周: ${weekData.co2.toFixed(2)}kg CO₂"> | |
| <div class="carbon-bar-tooltip">周${shortLabel}: ${weekData.co2.toFixed(2)}kg</div> | |
| </div> | |
| `; | |
| }).join(''); | |
| }, | |
| _renderAchievements(data) { | |
| const list = $('#carbonAchievementList'); | |
| if (!list) return; | |
| list.innerHTML = CARBON_ACHIEVEMENTS.map(a => { | |
| const unlocked = data.achievements.includes(a.id); | |
| return ` | |
| <div class="carbon-achievement ${unlocked ? 'carbon-achievement--unlocked' : 'carbon-achievement--locked'}"> | |
| <span class="carbon-achievement-icon">${unlocked ? a.icon : '🔒'}</span> | |
| <span class="carbon-achievement-name">${a.name}</span> | |
| </div> | |
| `; | |
| }).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 = `最佳纪录: <span>${data.memory_besttime}s</span> (${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 ` | |
| <div class="memory-card" data-index="${index}" data-ripple> | |
| <div class="memory-card-inner"> | |
| <div class="memory-card-front"></div> | |
| <div class="memory-card-back"> | |
| ${card.type === 'item' ? ` | |
| <div class="mem-card-item">${card.emoji}</div> | |
| <div class="mem-card-name">${card.name_zh}</div> | |
| ` : ` | |
| <div class="mem-card-category-icon">${card.emoji}</div> | |
| <div class="mem-card-category-name">${card.name_zh}</div> | |
| `} | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| }).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); | |
| } | |
| })(); | |