GENESIS ATELIER · v3.0

一起发明
这个世界上
还没有的新东西

用 SIT 系统化创新思维,从一个天马行空的想法出发,走过概念、设计、市场投放的完整旅程。

SIT 创新思维AI 共创市场模拟时空记忆

欢迎回来,创新者

登录后继续你的发明之旅,每一次创造都会被记录。

开通账号即视为同意本条款。

注册即表示同意:昵称与发明记录将用于创新者殿堂与活动存档展示

智道AI · SIT 思维 × AI 智脑

把一个问题,
变成可验证的创新方案。

SIT 系统创新思维 × AI 智脑 × 多维共创
面向研究者、教师、企业与机构的创新工作台。

课程与工具 全球创新赛题
正在加载创新案例……
成果库 · 创新案例
展示的全部作品为智道AI用户自愿公开或 AI 自动发明生成 · 创意可互相激发,请尊重版权、保护原创
左右滑动翻阅 · 上下滑动同样切换
GLOBAL SIGNALS · 全球创新信号
✦ 进入创新者号 · 未来观测站站内实时数据 · 全球创新信号 · 未来科技雷达 →
每日同步全球创新事件 / 成果 / 人物 / 政策
智道AI · 点亮每个人的创造之光
智道AI
欢迎来到 智道AI创新学院!
用户今天想从哪里开始?
💡
我有一个想法
说出一个生活中的麻烦,AI 陪你把它变成发明
🎯
今天挑战我
AI 即时出题,上天入地随你挑,三分钟打破惯性思维
🌙
观看 AI 造梦
旁听六位 AI 专家争论、推翻,再共同创造一个新物种
📖
成为点灯人
学习创新,绽放创造之光
WORKSHOPS · 创意工坊

创意工坊

四间创作工坊 + 两个 3D 板块——把灵感打磨成完整作品

🎨
产品设计工坊
外观 · 结构 · 场景——让发明看得见摸得着
推门进入 →
🎤
路演工坊
把发明讲成动人故事——完整报告与长图
推门进入 →
🔭
市场验证工坊
用户 · 风险 · 预言——先验证,再投入
推门进入 →
📜
专利导航工坊
创新点提示 · 材料清单 · 专业服务对接
推门进入 →
🧳
3D演示
家庭组合行李箱 · 跟随 · 呼叫 · 亲子坐骑
查看演示 →
🖨️
3D打印
纤薄磁吸摄像头 · GLB / Blender / 效果图
查看方案 →
MY LAB · 个人发明空间

我的实验室

用户的每一项发明,都被珍藏在这里

INSPIRATION HALL · 灵感大厅

今天,你想解决什么问题?

说出一个生活中的麻烦、一个普通物品,或一个大胆的"如果"——六位 AI 专家将为你集结

AI SHOWCASE · 全自动驾驶

AI 自动做发明

智道AI领衔六大角色接力,自选创作 1-5 个发明(每个 1000-2000 创作币随机)——从痛点、完善、论证到海报,全程无需动手

ROUND TABLE · 六院圆桌

六院圆桌会议

邀请专家 · 圆桌点将
请先点击以下1-6位专家的头像完成邀请
BIRTH CHAMBER · 发明诞生舱

发明诞生舱

今天,创世纪迎来了一项新的发明

BUSINESS ARENA · 商业竞技场

市场投放中心

你的产品即将经历从实验室到千家万户的旅程

HALL OF FAME · 未来博物馆

创新者殿堂

每一颗星,都是一个改变世界的想法

ADMIN CONSOLE

运营后台

每一次发明活动与生成的时空记忆 PDF 都会在此存档

用户留言
加载中……
假期特训预约
课程兑换码 一次有效;创新版 A01 与特训营第 1 天免费,其余凭码进入
每个码只兑换一次,兑换后开始计时
用户总览
账号与角色
创作币参数设置
每次扣费在上、下限之间随机,注册赠送对之后的新用户生效
留空的链接,用户点击时将提示联系 newvisualh@gmail.com
兑换码管理
金榜视频上传
上传后,【每周金榜】将在屏幕中间播放该视频;未上传则继续显示冠军海报图。支持 mp4/webm,不超过 20MB。
修改管理员密码
修改后下次登录生效;已写入账号库,优先于部署时的 ADMIN_PASSWORD 环境变量
数字资产库 · 图片与 PDF 统一留存
图片资产(效果图 / 海报 · 生成即同步服务器)
PDF 档案
活动记录
AI智道AI
AI智道AI

加载中

AI智道AI

创作币用完啦

您的创作币已经用完,请充值后继续召唤 AI 对话与绘图。
🎬

演示自检

!

先补充几个细节,讲稿会好很多

🎉
讲稿生成好了
放映时点右下角「✦ 请智道AI路演」,可让 AI 自动配讲词、配音与音效。
正在打开放映…
打不开?在新窗口放映 ↗
活动详情
'; } function poFitFrames(){ document.querySelectorAll('.po-fr').forEach(function(f){ const pv = f.parentElement; /* 容器可能还没布局(隐藏/刚插入),此时 clientWidth 为 0 —— 用屏幕宽度兜底, 并稍后重算一次,避免整块预览塌成 0 宽。 */ let w = pv.clientWidth; if(!w){ w = Math.min(680, (window.innerWidth || 400) - 60); } const k = w / 1280; f.style.transform = 'scale('+k+')'; pv.style.height = Math.round(720*k) + 'px'; }); } /* 布局稳定后重算一次(插入 DOM 的当帧拿不到宽度) */ function poFitSoon(){ poFitFrames(); requestAnimationFrame(function(){ poFitFrames(); }); setTimeout(function(){ try{ poFitFrames(); }catch(e){} }, 260); setTimeout(function(){ try{ poFitFrames(); }catch(e){} }, 900); } window.addEventListener('resize', function(){ try{ poFitFrames(); }catch(e){} }); /* ── 输入与上传 ── */ function poInput(el){ RS_PO.typed[el.getAttribute('data-gid')] = el.value; poUpdateButtons(); } async function poUpload(input, gid, kind){ const f = input.files && input.files[0]; if(!f) return; if(f.size > 20*1024*1024){ toast('文件太大(上限 20MB)','wand'); return; } toast('正在上传…','wand'); try{ const dataUrl = await new Promise((res,rej)=>{ const fr=new FileReader(); fr.onload=()=>res(fr.result); fr.onerror=()=>rej(new Error('读取失败')); fr.readAsDataURL(f); }); const r = await Store._api('roadshow/upload-asset', { method:'POST', body: JSON.stringify({ dataUrl:dataUrl, name:f.name, kind:kind||'image' }) }); r.name = f.name; RS_PO.uploads[gid] = r; poSnapshot(); rsPolishRender(RS_PO.data); poRestore(); toast('上传成功','check'); }catch(e){ toast('上传失败:'+rsErr(e),'wand'); } } function poSnapshot(){ document.querySelectorAll('#rs-body textarea[data-gid]').forEach(function(t){ RS_PO.typed[t.getAttribute('data-gid')] = t.value; }); } function poRestore(){ document.querySelectorAll('#rs-body textarea[data-gid]').forEach(function(t){ const v = RS_PO.typed[t.getAttribute('data-gid')]; if(v && !t.value) t.value = v; }); poUpdateButtons(); } /* ── 按钮激活逻辑 ── */ /* 滚动只用来更新提示文案,不再作为按钮的开关条件 (原来监听 window,但真正滚动的是 #rs-body 容器,所以永远收不到事件, 导致"全部填完也不亮"。现在只要有回答或上传就允许提交。) */ function poOnScroll(){ poUpdateButtons(); } /* 有没有任何回答或上传 —— 有就允许提交再审 */ function poDirty(){ try{ const t = RS_PO.typed || {}, u = RS_PO.uploads || {}; const anyText = Object.keys(t).some(function(k){ return String(t[k]||'').trim(); }); const anyUp = Object.keys(u).length > 0; return anyText || anyUp; }catch(e){ return false; } } function poMissing(){ const out = []; ((RS_PO.data||{}).asks||[]).forEach(function(g){ if(g.kind === 'image' || g.kind === 'audio' || g.kind === 'video'){ if(!RS_PO.uploads[g.id]) out.push(g); } else if(!String(RS_PO.typed[g.id]||'').trim()) out.push(g); }); return out; } function poUpdateButtons(){ const sub = document.getElementById('po-submit'), demo = document.getElementById('po-demo'), hint = document.getElementById('po-hint'), st = document.getElementById('po-fst'); if(!sub) return; const missing = poMissing(); const dirty = poDirty(); const total = ((RS_PO.data||{}).asks||[]).length; const filled = total - missing.length; /* ★ 按要求:「提交再审」始终可用 —— 它只负责"让系统再审一遍并给建议" */ sub.disabled = false; if(st){ if(!dirty) st.innerHTML = '点每一页的「编辑本页」直接改,或在上面的提问里补充;也可以直接「提交再审」让系统给建议'; else if(missing.length) st.innerHTML = '已补 '+filled+'/'+total+' 项 还有 '+missing.length+' 项可以再补'; else st.innerHTML = '已补 '+filled+'/'+total+' 项 可以提交再审了'; } if(hint){ if(!dirty) hint.textContent = '「提交再审」随时可点:系统会逐页审一遍,并列出还要改哪些地方。'; else if(missing.length) hint.textContent = '点「提交再审」会带着已填内容重新生成;剩下的 '+missing.length+' 项也可以下一轮再补。'; else hint.textContent = '都补完了,点「提交再审」让系统再审一遍。'; } /* 每页的"提交这一页"按钮:该页有输入就亮 */ try{ ((RS_PO.data||{}).asks||[]).forEach(function(g){ const pg = poAskPage(g.id); const b = document.getElementById('pob-'+pg); if(b) b.disabled = !poPageDirty(pg); }); }catch(e){} /* 「正式演示」:只有审核通过才亮 */ demo.disabled = !RS_PO.passed; if(!RS_PO.passed) demo.title = '先通过审核'; } /* ═══ 逐页直接编辑:在页面上改文字、传图/视频/音频,改完"确定本页" ═══ */ var PO_EDIT = {}; /* pageNo → { image, media } 本次新传的媒体 */ function poStartEdit(pageNo){ const d = RS_PO.data; if(!d) return; const sl = (d.slides||[]).find(function(x){ return x.i === pageNo; }); if(!sl) return; PO_EDIT[pageNo] = { image: null, media: null }; const box = document.getElementById('po-ed-' + pageNo); if(!box) return; box.innerHTML = poEditorHtml(sl); box.style.display = ''; const pv = document.getElementById('po-pv-' + pageNo); if(pv) pv.style.display = 'none'; const b = document.getElementById('poed-' + pageNo); if(b) b.style.display = 'none'; poFitFrames(); } function poEditorHtml(sl){ const fields = sl.fields || []; const fh = fields.map(function(f){ if(f.kind === 'text'){ return '
' + '
'; } if(f.kind === 'list'){ return '
' + f.value.map(function(v,i){ return ''; }).join('') + '
'; } if(f.kind === 'listlist'){ return '
' + f.value.map(function(row,ri){ return '
' + row.map(function(v,ci){ return ''; }).join('') + '
'; }).join('') + '
'; } if(f.kind === 'obj'){ return '
' + (f.sub||[]).map(function(sk,si){ return '
'+rsEsc((f.subLabels||[])[si]||sk)+'' + '
'; }).join('') + '
'; } if(f.kind === 'objlist'){ return '
' + f.value.map(function(o,ri){ return '
' + (f.sub||[]).map(function(sk,si){ return '
'+rsEsc((f.subLabels||[])[si]||sk)+'' + '
'; }).join('') + '
'; }).join('') + '
'; } return ''; }).join(''); const imgCur = sl.imgSrc || ''; const medCur = sl.mediaSrc || ''; return '
' + '
直接改下面任意一格;文字、图片、视频、音频都能换。改完点「确定本页」。
' + fh + '
' + '
' + '
' + (imgCur ? ('点击可更换') : '+ 点击选择图片') + '
' + '' + '
' + '
' + '
' + (medCur ? ('已上传:'+rsEsc(medCur.split('/').pop())+',点击可更换') : '+ 点击选择视频或音频') + '
' + '' + '
' + '
' + '
' + '' + '' + '
' + '
'; } function poMarkDirty(){} /* 上传(图片或音视频),立刻在编辑框里显示预览 */ async function poEditUpload(input, pageNo, which){ const f = input.files && input.files[0]; if(!f) return; const cap = /^video/.test(f.type) ? 60*1024*1024 : (/^audio/.test(f.type) ? 20*1024*1024 : 12*1024*1024); if(f.size > cap){ toast('文件太大(上限 '+Math.round(cap/1048576)+'MB)','wand'); return; } toast('正在上传…','wand'); try{ const dataUrl = await new Promise(function(res,rej){ const fr=new FileReader(); fr.onload=function(){res(fr.result);}; fr.onerror=function(){rej(new Error('读取失败'));}; fr.readAsDataURL(f); }); const r = await Store._api('roadshow/upload-asset', { method:'POST', body: JSON.stringify({ dataUrl:dataUrl, name:f.name }) }); PO_EDIT[pageNo] = PO_EDIT[pageNo] || {}; PO_EDIT[pageNo][which] = r; const box = document.getElementById(which === 'image' ? ('peimg-'+pageNo) : ('pemed-'+pageNo)); if(box){ if(which === 'image') box.innerHTML = '点击可更换'; else box.innerHTML = (r.kind === 'audio' ? '🎵 ' : '🎬 ') + rsEsc(f.name) + ',点击可更换'; } toast('上传成功','check'); }catch(e){ toast('上传失败:'+rsErr(e),'wand'); } } function poEditClear(pageNo, which){ PO_EDIT[pageNo] = PO_EDIT[pageNo] || {}; PO_EDIT[pageNo][which] = 'del'; const box = document.getElementById(which === 'image' ? ('peimg-'+pageNo) : ('pemed-'+pageNo)); if(box) box.innerHTML = (which === 'image' ? '+ 点击选择图片' : '+ 点击选择视频或音频'); } function poCancelEdit(pageNo){ const box = document.getElementById('po-ed-' + pageNo); if(box){ box.style.display = 'none'; box.innerHTML = ''; } const pv = document.getElementById('po-pv-' + pageNo); if(pv) pv.style.display = ''; const b = document.getElementById('poed-' + pageNo); if(b) b.style.display = ''; delete PO_EDIT[pageNo]; poFitFrames(); } /* 收集编辑器里的字段,按形状还原 */ function poCollectFields(pageNo){ const box = document.getElementById('po-ed-' + pageNo); if(!box) return {}; const sl = ((RS_PO.data||{}).slides||[]).find(function(x){ return x.i === pageNo; }) || {}; const byKey = {}; (sl.fields||[]).forEach(function(f){ byKey[f.key] = f.kind; }); const out = {}; box.querySelectorAll('[data-fk]').forEach(function(el){ const k = el.getAttribute('data-fk'), kind = byKey[k], v = el.value; if(kind === 'text'){ out[k] = v; return; } if(kind === 'list'){ (out[k] = out[k] || [])[parseInt(el.getAttribute('data-idx'),10)] = v; return; } if(kind === 'listlist'){ const ri = parseInt(el.getAttribute('data-ridx'),10), ci = parseInt(el.getAttribute('data-cidx'),10); out[k] = out[k] || []; out[k][ri] = out[k][ri] || []; out[k][ri][ci] = v; return; } if(kind === 'obj'){ const sk0 = el.getAttribute('data-sub'); out[k] = out[k] || {}; out[k][sk0] = v; return; } if(kind === 'objlist'){ const ri = parseInt(el.getAttribute('data-oidx'),10), sk = el.getAttribute('data-sub'); out[k] = out[k] || []; out[k][ri] = out[k][ri] || {}; out[k][ri][sk] = v; return; } }); return out; } /* 确定本页:保存 → 立刻重渲染这一页 */ async function poSavePage(pageNo){ const btn = document.getElementById('peok-' + pageNo); if(btn){ btn.disabled = true; btn.textContent = '正在保存…'; } try{ const payload = { page: pageNo, fields: poCollectFields(pageNo) }; const e = PO_EDIT[pageNo] || {}; if(e.image === 'del') payload.image = null; else if(e.image && e.image.path) payload.image = { path: e.image.path }; if(e.media === 'del') payload.media = null; else if(e.media && e.media.path) payload.media = { path: e.media.path, kind: e.media.kind || 'video' }; const r = await Store._api('roadshow/slide-save/' + RS_PO.id, { method:'POST', body: JSON.stringify(payload) }); /* 把服务端返回的最新内容直接贴回这一页,不用整份重取 */ const sl = ((RS_PO.data||{}).slides||[]).find(function(x){ return x.i === pageNo; }); if(sl){ sl.fields = r.fields || sl.fields; sl.imgSrc = r.imgSrc || ''; sl.mediaSrc = r.mediaSrc || ''; sl.mediaKind = r.mediaKind || ''; sl.issues = r.pageIssues || []; sl.html = r.html || sl.html; } RS_PO.passed = !!r.pass; RS_PO.block = r.block; RS_PO.warn = r.warn; toast('第 ' + pageNo + ' 页已保存' + (r.pass ? ',全部审核通过 ✅' : (r.block ? (',还有 ' + r.block + ' 处要改') : '')),'check'); poCancelEdit(pageNo); /* 只刷新这一页的预览与问题,不动其它页 */ poRefreshCard(pageNo); }catch(err){ toast('保存失败:'+rsErr(err),'wand'); if(btn){ btn.disabled = false; btn.textContent = '✓ 确定本页'; } } } /* 刷新单张卡片(预览 + 问题 + 编辑按钮) */ function poRefreshCard(pageNo){ const d = RS_PO.data; if(!d) return; const sl = (d.slides||[]).find(function(x){ return x.i === pageNo; }); const card = document.querySelector('.po-card[data-p="'+pageNo+'"]'); if(!sl || !card) return; const iss = sl.issues || []; const blk = iss.filter(function(x){ return x.sev==='block'; }).length; const wn = iss.filter(function(x){ return x.sev==='warn'; }).length; const bd = card.querySelector('.po-bd'); if(bd){ bd.className = 'po-bd ' + (blk ? 'b' : (wn ? 'w' : 'ok')); bd.textContent = blk ? ('必须修 ' + blk) : (wn ? ('建议 ' + wn) : '✓ 没问题'); } const issBox = card.querySelector('.po-iss'); if(issBox){ issBox.innerHTML = iss.length ? iss.map(function(x){ return '
'+(x.sev==='block'?'必须修':(x.sev==='warn'?'建议':'参考'))+' '+rsEsc(x.msg)+'
'; }).join('') : '
这一页检查通过
'; } const pv = card.querySelector('.po-pv'); if(pv){ pv.innerHTML = ''; } poFitFrames(); } /* ── 按页局部提交:改完这一页立刻看效果 ── */ function poAskPage(id){ const m = /^qc_\w+?_(\d+)/.exec(String(id||'')); return m ? parseInt(m[1],10) : 0; } function poPageAsks(pageNo){ return ((RS_PO.data||{}).asks||[]).filter(function(g){ return poAskPage(g.id) === pageNo; }); } /* 收集某一页的补充 */ function poPageSups(pageNo){ const out = {}; poPageAsks(pageNo).forEach(function(g){ const up = RS_PO.uploads[g.id]; if(up) out[g.id] = { path: up.path, kind: up.kind || 'image', text: up.text || '' }; else { const t = String(RS_PO.typed[g.id]||'').trim(); if(t) out[g.id] = t; } }); return out; } function poPageDirty(pageNo){ return poPageAsks(pageNo).some(function(g){ return RS_PO.uploads[g.id] || String(RS_PO.typed[g.id]||'').trim(); }); } async function poSubmitPage(pageNo, btn){ const sups = poPageSups(pageNo); if(!Object.keys(sups).length){ toast('这一页还没填内容','wand'); return; } const old = btn ? btn.textContent : ''; if(btn){ btn.disabled = true; btn.textContent = '正在生成这一页…'; } try{ const qc = await Store._api('roadshow/qc-fix/'+RS_PO.id, { method:'POST', body: JSON.stringify({ supplements: sups }) }); /* 这一页的补充已经消费掉,清掉;其它页还没提交的先留着别丢 */ Object.keys(sups).forEach(function(k){ delete RS_PO.typed[k]; delete RS_PO.uploads[k]; }); RS_PO.passed = !!qc.pass; await rsPolishOpen(RS_PO.id, false); toast('第 '+pageNo+' 页已更新'+(qc.pass?',全部审核通过 ✅':',还可以继续改别的页'),'check'); /* 滚回刚才那一页,别让用户找不到位置 */ setTimeout(function(){ const c = document.querySelector('.po-card[data-p="'+pageNo+'"]'); if(c) c.scrollIntoView({ behavior:'smooth', block:'start' }); }, 160); }catch(e){ toast('这一页提交失败:'+rsErr(e),'wand'); if(btn){ btn.disabled = false; btn.textContent = old; } } } /* ── 提交再审 ── */ /* 审核报告:把这一次审出什么明确摆出来(不然用户不知道"提交再审"到底做了什么) */ function poReportHtml(qc, round){ if(!qc) return ''; const iss = (qc.issues||[]).filter(function(x){ return x.sev !== 'info'; }); return '
' + '
📋 审核结果 第 ' + round + ' 轮
' + '
' + (qc.pass ? '✅ 全部通过,可以点「正式演示」了' : ('必须修 ' + (qc.block||0) + ' 处 建议 ' + (qc.warn||0) + ' 处')) + '
' + (iss.length ? '
' + iss.slice(0,14).map(function(x){ return '
'+(x.sev==='block'?'必须修':'建议')+' '+rsEsc(x.msg)+'
'; }).join('') + '
' : '
没有发现问题。
') + '
'; } async function rsPolishSubmit(){ poSnapshot(); /* 收集"问答框"里的补充(用【编辑本页】改的内容已经直接存进讲稿了,不在这里) */ const sups = {}; Object.keys(RS_PO.typed).forEach(function(k){ if(String(RS_PO.typed[k]||'').trim()) sups[k] = RS_PO.typed[k]; }); Object.keys(RS_PO.uploads).forEach(function(k){ const u = RS_PO.uploads[k]; sups[k] = { path:u.path, kind:u.kind||'image', text:u.text||'' }; }); const hasSups = Object.keys(sups).length > 0; const btn = document.getElementById('po-submit'); const old = btn ? btn.textContent : ''; if(btn){ btn.disabled = true; btn.textContent = hasSups ? '正在按补充重新生成…' : '正在逐页审核…'; } try{ /* 没有补充 → 只审不改(绝不重新生成,否则会把手改的内容冲掉) */ const qc = hasSups ? await Store._api('roadshow/qc-fix/'+RS_PO.id, { method:'POST', body: JSON.stringify({ supplements: sups }) }) : await Store._api('roadshow/qc/'+RS_PO.id, { method:'POST', body:'{}' }); RS_PO.round++; RS_PO.typed = {}; RS_PO.uploads = {}; RS_PO.reachedEnd = false; RS_PO.passed = !!qc.pass; RS_PO.report = qc; await rsPolishOpen(RS_PO.id, false); poShowReport(qc, RS_PO.round); toast(qc.pass ? '审核通过 ✅ 可以「正式演示」了' : ('审核完成:还有 ' + (qc.block||0) + ' 处必须修'), qc.pass ? 'check' : 'wand'); }catch(e){ toast('审核失败:'+rsErr(e),'wand'); if(btn){ btn.disabled = false; btn.textContent = old || '提交再审'; } } } /* 把审核报告插到底部按钮上方 */ function poShowReport(qc, round){ const foot = document.getElementById('po-foot'); if(!foot) return; let box = document.getElementById('po-report-box'); if(!box){ box = document.createElement('div'); box.id = 'po-report-box'; foot.parentElement.insertBefore(box, foot); } box.innerHTML = poReportHtml(qc, round); box.scrollIntoView({ behavior:'smooth', block:'center' }); const btn = document.getElementById('po-submit'); if(btn){ btn.disabled = false; btn.textContent = '提交再审'; } } /* 返回:回到路演大师的设置页 */ async function rsPolishBack(){ /* ★ 这里原来写的是 RS.saved = null —— 把"我生成的讲稿"清单清空了, 而 renderRoadshow 是同步的不会重新拉,于是清单就消失了。 正确做法:重新拉一次列表再渲染。 */ try{ RS.saved = await Store._api('roadshow/list'); }catch(e){ /* 拉取失败就沿用旧的 */ } renderRoadshow(); try{ window.scrollTo({ top:0, behavior:'smooth' }); }catch(e){} } function rsPolishDemo(){ if(!RS_PO.passed){ toast('要先通过审核','wand'); return; } playRoadshow(RS_PO.id); } function poSay(){ const n = (RS.po && RS.po.questions.length) || 0; const names = { jobs:'乔布斯式', leijun:'雷军式', musk:'马斯克式', luo:'罗振宇式', ted:'TED 式', sci:'少年科学家式' }; const sty = (RS.po && RS.po.style) || RS.style; const intros = { jobs:'乔布斯式最打动人的是"体验落差"——所以我想先知道,这个东西到底替人省掉了哪一步麻烦。', leijun:'雷军式靠数字说话——所以我想先把你最硬的那几个事实问清楚。', musk:'马斯克式要从本质推导——所以我想先问清你当初为什么做这个取舍。', luo:'罗振宇式先讲一个故事——所以我想先听你讲一个真实的场景。', ted:'TED 式全场只讲一个想法——所以我想先找到你心里那一句。', sci:'少年科学家式要给评审看推理链——所以我想先问清你的假设和方法。' }; return '我是智道AI。接下来问你 ' + n + ' 个问题——这些只有你自己答得出来。
' + '' + (intros[sty]||'') + ''; } function renderPolish(){ const qs = RS.po.questions || []; const i = RS.poQ, q = qs[i]; if(!q) return finishPolishView(); const bar = qs.map((_, k) => '').join(''); const up = RS.poUp[q.id]; let upBlock = ''; if(q.type === 'image' || q.type === 'video' || q.type === 'audio'){ const acc = q.type === 'image' ? 'image/*' : (q.type === 'video' ? 'video/*' : 'audio/*'); const label = q.type === 'image' ? '上传照片' : (q.type === 'video' ? '上传视频' : '上传录音'); const ic = q.type === 'image' ? '🖼' : (q.type === 'video' ? '🎬' : '🎙'); let prev = ''; if(up && up.display){ if(up.kind === 'image') prev = '
'; else if(up.kind === 'video') prev = '
'; else prev = '
'; } upBlock = '
'+ '
'+ic+'
'+(up&&up.display?'已上传,点击可换一个':label+'(从本地选择)')+'
'+ ''+prev; } $('#rs-body').innerHTML = rsBanner() + '
发
'+ '
智道AI · 发明教练
'+poSay()+'
'+ '
'+bar+'
'+ '
第 '+(i+1)+' / '+qs.length+' 题'+(RS.po.by==='rule'?' · 离线题组':'')+'
'+ '
'+rsEsc(q.q)+'
'+ (q.hint?'
'+rsEsc(q.hint)+'
':'')+ (q.type==='text' ? '' : upBlock) + '
'+ (i>0?'':'')+ '跳过这题 '+ ''+ '
'; } function poSave(){ const qs = RS.po.questions || [], q = qs[RS.poQ]; if(!q) return; if(q.type === 'text'){ const el = $('#po-text'); if(el) RS.poAns[q.id] = el.value.trim(); } } async function poNext(){ poSave(); const qs = RS.po.questions || [], q = qs[RS.poQ]; const txt = RS.poAns[q.id] || ''; const up = RS.poUp[q.id]; if(txt || (up && up.display)){ try{ await Store._api('roadshow/polish/answer', { method:'POST', body: JSON.stringify({ sessionId: RS.po.id, qid: q.id, text: txt }) }); }catch(e){} } if(RS.poQ >= qs.length - 1) return finishPolishView(); RS.poQ++; renderPolish(); } function poPrev(){ poSave(); if(RS.poQ>0){ RS.poQ--; renderPolish(); } } function poSkip(){ poSave(); const qs = RS.po.questions||[]; if(RS.poQ >= qs.length-1) return finishPolishView(); RS.poQ++; renderPolish(); } async function poUpload(input){ const f = input.files && input.files[0]; if(!f) return; const q = RS.po.questions[RS.poQ]; const maxMB = q.type==='video' ? 25 : (q.type==='audio' ? 15 : 12); if(f.size > maxMB*1024*1024){ toast('文件太大了(上限 '+maxMB+'MB)','wand'); return; } const t = toast('正在上传…','wand'); try{ const dataUrl = await new Promise((res, rej) => { const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => rej(new Error('读取文件失败')); fr.readAsDataURL(f); }); const r = await Store._api('roadshow/polish/upload', { method:'POST', body: JSON.stringify({ sessionId: RS.po.id, qid: q.id, dataUrl: dataUrl }) }); RS.poUp[q.id] = r; toast('上传成功','wand'); renderPolish(); }catch(e){ toast('上传失败:'+rsErr(e),'wand'); } } function finishPolishView(){ const answered = Object.keys(RS.poAns).filter(k=>RS.poAns[k]).length; const ups = Object.keys(RS.poUp).filter(k=>RS.poUp[k]&&RS.poUp[k].display).length; $('#rs-body').innerHTML = rsBanner() + '
✦
'+ '
素材收齐了
'+ '
'+ '你回答了 '+answered+' 题'+ (ups?',上传了 '+ups+' 个素材':'')+'。
'+ '智道AI会把这些内容编进讲稿——数字仍然由系统注入,不会编造。
'+ '
'+ ''+ ''+ '
'; } async function finishPolish(){ const btn = $('#po-go'); if(btn){ btn.disabled = true; btn.textContent = '智道AI正在精修…'; } toast('正在精修讲稿…','brain'); try{ const r = await Store._api('roadshow/polish/finish', { method:'POST', body: JSON.stringify({ sessionId: RS.po.id }) }); toast('精修讲稿完成!','wand'); RS.saved = await Store._api('roadshow/list').catch(()=>RS.saved); renderRoadshow(); runQcThenDone(r.id, '《' + (r.product||'讲稿') + '》精修版已就绪', '采纳了 ' + r.polishAnswers + ' 条你的回答' + (r.polishMedia ? ('、' + r.polishMedia + ' 个新素材') : '') + ',共 ' + r.slides + ' 页。点「马上演示」直接放映。', '✦'); toast('已采纳 '+r.polishAnswers+' 条回答'+(r.polishMedia?('、'+r.polishMedia+' 个新素材'):'')+ ',AI 编排 '+r.aiUsed+'/'+r.aiSlots+' 页','brain'); }catch(e){ toast('精修失败:'+rsErr(e),'wand'); if(btn){ btn.disabled = false; btn.textContent = '生成精修讲稿 ✦'; } } } /* ───────── 页面内放映(不弹新窗口) ───────── */ async function playRoadshow(id){ toast('正在打开放映…','wand'); const fr=$('#rs-frame'), box=$('#rs-player'), ld=$('#rs-load'); if(box) box.classList.add('on'); if(ld){ ld.style.display='flex'; ld.textContent='正在打开放映…'; } if(fr){ fr.onload=function(){ if(ld) ld.style.display='none'; }; fr.onerror=function(){ if(ld){ ld.style.display='flex'; ld.textContent='放映加载失败,请点右上角关闭后重试'; } }; } try{ const d=await Store._api('roadshow/play-token',{method:'POST',body:JSON.stringify({id:id})}); if(!d||!d.url) throw new Error('没有拿到放映地址'); const nt=$('#rs-newtab'); if(nt) nt.href=d.url; if(fr) fr.src=d.url; /* 预生成讲词/配音:等用户点「请智道AI路演」时不用再等几十秒 */ Store._api('roadshow/voice/'+id, {method:'POST', body: JSON.stringify({refresh:false})}).catch(()=>{}); }catch(e){ if(ld){ ld.style.display='flex'; ld.innerHTML='放映打开失败:'+esc(rsErr(e))+'

请点右上角「关闭」后重试'; } toast('打开失败:'+rsErr(e),'wand'); } } function rsClosePlayer(){ const fr = $('#rs-frame'), ld=$('#rs-load'); if(fr){ fr.onload=null; fr.onerror=null; fr.src='about:blank'; } if(ld){ ld.style.display='none'; ld.textContent='正在打开放映…'; } $('#rs-player').classList.remove('on'); } window.addEventListener('message', function(e){ if(e && e.data && e.data.type === 'rs-close') rsClosePlayer(); }); document.addEventListener('keydown', function(e){ if(e.key === 'Escape' && $('#rs-player').classList.contains('on')) rsClosePlayer(); }); /* ───────── 生成前素材体检 ───────── */ var RS_GATE = { gaps: [], uploads: {}, product: '' }; /* P30:补充答案按"作品"缓存 —— 同一个作品换个风格不用再答一遍 */ function rsSupsKey(){ return 'rs_sups_' + (RS.pick || 'default'); } function rsSupsLoad(){ try{ return JSON.parse(localStorage.getItem(rsSupsKey()) || '{}') || {}; }catch(e){ return {}; } } function rsSupsSave(o){ try{ localStorage.setItem(rsSupsKey(), JSON.stringify(o || {})); }catch(e){} } async function genWithGate(){ if(RS.busy) return; RS.busy = true; /* 这个作品之前补过资料 → 直接复用,不再打扰 */ const cached = rsSupsLoad(); if(cached && Object.keys(cached).length){ const n = Object.keys(cached).length; toast('沿用你之前补充的 ' + n + ' 条资料','spark'); RS.busy = false; return genRoadshow(cached); } const btn = $('#rs-quick'); if(btn){ btn.disabled = true; btn.querySelector('.t').textContent = '正在检查素材…'; btn.querySelector('.d').textContent = '约 1-2 秒'; } try{ const a = await Store._api('roadshow/assess', { method:'POST', body: JSON.stringify({ activityId: RS.pick }) }); if(a.level === 'good'){ RS.busy = false; if(btn){ btn.disabled = false; btn.querySelector('.t').textContent = '⚡ 一键预览'; btn.querySelector('.d').textContent = '不提问,直接出稿并进入演示。'; } return genRoadshow(); } /* 有缺口 → 弹补充对话框 */ RS_GATE.gaps = a.gaps || []; RS_GATE.uploads = {}; RS_GATE._last = a; RS_GATE.typed = {}; RS_GATE.product = a.product || ''; rsGateRender(a); }catch(e){ toast('检查失败,直接生成:' + rsErr(e), 'wand'); RS.busy = false; if(btn){ btn.disabled = false; btn.querySelector('.t').textContent = '⚡ 一键预览'; } return genRoadshow(); }finally{ RS.busy = false; } } function rsGateRender(a){ const req = RS_GATE.gaps.filter(g => g.need === 'high').length; $('#rs-gate-tt').textContent = a.level === 'poor' ? '这份素材还不够撑起一场路演' : '再补几个细节,讲稿会好很多'; $('#rs-gate-sub').textContent = '我看了你「' + RS_GATE.product + '」的操作记录。' + (a.level === 'poor' ? '目前可用的素材偏少,直接生成会显得空。补充下面几项,路演会具体、有说服力。' : '补上下面这些,讲稿会更贴你的作品。'); const st = a.stat || {}; $('#rs-gate-stat').innerHTML = '你的原话 ' + (st.kidChars || 0) + ' 字 ' + '可用引语 ' + (st.quotes || 0) + ' 条 ' + '方案描述 ' + (st.conceptChars || 0) + ' 字 ' + '决策记录 ' + (st.events || 0) + ' 条 ' + '产品图 ' + (st.hasImage ? '有' : '无') + ' ' + '海报图 ' + (st.hasPoster ? '有' : '无') + ''; $('#rs-gate-qs').innerHTML = RS_GATE.gaps.map((g, i) => { const up = RS_GATE.uploads[g.id]; if(g.kind === 'image'){ return '
' + (g.need === 'high' ? '必补' : '') + rsEsc(g.q) + '
' + rsEsc(g.why) + '
' + '
' + (up && up.display ? ('已上传,点击可更换') : '点击从本地选择图片') + '
' + '
'; } return '
' + (g.need === 'high' ? '必补' : '') + rsEsc(g.q) + '
' + rsEsc(g.why) + '
' + '
'; }).join(''); /* 回填之前输入过的内容 */ Object.keys(RS_GATE.typed || {}).forEach(k => { const e2 = document.getElementById('gin-' + k); if(e2 && !e2.value) e2.value = RS_GATE.typed[k]; }); $('#rs-gate').classList.add('on'); clearTimeout(rsGateRender._t); } function rsGateCollect(){ const out = {}; RS_GATE.gaps.forEach(g => { if(g.kind === 'image'){ const u = RS_GATE.uploads[g.id]; if(u && u.path) out[g.id] = { path: u.path, kind: 'image', slot: g.slot || '', text: u.text || '' }; return; } const el2 = document.getElementById('gin-' + g.id); const v2 = (el2 ? el2.value : (RS_GATE.typed || {})[g.id] || ''); if(v2 && String(v2).trim()) out[g.id] = String(v2).trim(); }); return out; } function rsGateClose(){ $('#rs-gate').classList.remove('on'); } async function rsGateGo(){ const sups = rsGateCollect(); const n = Object.keys(sups).length; if(!n){ toast('还没有填任何内容,可以点「先跳过」','wand'); return; } /* 必填项没填完 → 明确提示,由用户决定是否坚持跳过 */ const missing = (RS_GATE.gaps || []).filter(g => g.need === 'high' && !sups[g.id]); if(missing.length){ const names = missing.map(g => '· ' + g.q.replace(/^请(写出|上传)/, '')).join('\n'); if(!confirm('还有 ' + missing.length + ' 项必填没填:\n\n' + names + '\n\n不填的话,相关页面会显示「该参数尚未得知」或空着。\n\n确定要现在就生成吗?')) return; } rsSupsSave(sups); const btn = $('#rs-gate-go'); btn.disabled = true; btn.textContent = '正在生成…'; rsGateClose(); await genRoadshow(sups); btn.disabled = false; btn.textContent = '补充好了,开始生成'; } async function rsGateSkip(){ rsGateClose(); toast('将用现有素材生成,内容不足的页会标成「待你补充」','wand'); await genRoadshow(); } async function rsGateUpload(input, gid){ const f = input.files && input.files[0]; if(!f) return; if(f.size > 12*1024*1024){ toast('图片太大了(上限 12MB)','wand'); return; } toast('正在上传…','wand'); try{ const dataUrl = await new Promise((res, rej) => { const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => rej(new Error('读取失败')); fr.readAsDataURL(f); }); /* 用打磨会话的临时上传能力:借 polish/upload 需要 sessionId,这里改成独立上传 */ const r = await Store._api('roadshow/upload-asset', { method:'POST', body: JSON.stringify({ dataUrl: dataUrl, name: f.name }) }); /* 先存下用户已经填好的文字,再重绘,避免上传后文字被清空 */ RS_GATE.typed = RS_GATE.typed || {}; (RS_GATE.gaps || []).forEach(g => { if(g.kind !== 'image'){ const e2 = document.getElementById('gin-' + g.id); if(e2) RS_GATE.typed[g.id] = e2.value; } }); RS_GATE.uploads[gid] = r; toast('上传成功','wand'); rsGateRender(RS_GATE._last || { level:'thin', stat:{}, product: RS_GATE.product }); }catch(e){ toast('上传失败:' + rsErr(e), 'wand'); } } /* ───────── 演示自检闭环 ───────── 演出一丝差错就会被笑话,所以"生成完"不等于"能演"。 生成后先自检;发现问题就按问题提问,补充完重新生成并复检,直到通过。 */ var RS_QC = { id:'', rounds:0, last:null, meta:null, uploads:{} }; async function runQcThenDone(id, title, desc, icon){ RS_QC.id = id; RS_QC.rounds = 0; RS_QC.meta = { title:title, desc:desc, icon:icon }; try{ const qc = await Store._api('roadshow/qc/'+id, { method:'POST', body:'{}' }); if(qc.pass){ rsDoneShow(id, title, desc + ' ✅ 演示自检通过', icon); if(qc.warn) toast('自检通过,另有 '+qc.warn+' 条建议可忽略','check'); return; } rsQcRender(qc); }catch(e){ /* 自检本身失败不该拦住用户 */ toast('自检未完成('+rsErr(e)+'),可直接演示','wand'); rsDoneShow(id, title, desc, icon); } } function rsQcRender(qc){ RS_QC.last = qc; RS_QC.uploads = {}; const issues = qc.issues || []; const block = issues.filter(x=>x.sev==='block'), warn = issues.filter(x=>x.sev==='warn'), info = issues.filter(x=>x.sev==='info'); const asks = qc.asks || []; const icon = RS_QC.meta ? RS_QC.meta.icon : '🎬'; $('#rs-qc-ic').textContent = block.length ? '⚠️' : '✓'; $('#rs-qc-tt').textContent = block.length ? '演示自检发现 '+block.length+' 个必须先修的问题' : '演示自检通过('+warn.length+' 条建议)'; $('#rs-qc-sub').textContent = block.length ? '正式放映前先补一下。你补充后系统会重新生成并再检一遍,直到没问题。' : '可以直接放映。下面的建议可改可不改。'; $('#rs-qc-stat').innerHTML = '共 ' + ((qc.issues||[]).length) + ' 条 必须修 ' + block.length + ' 建议 ' + warn.length + ' 参考 ' + info.length + ''; const listHtml = issues.length ? issues.map(x=>'
' + '' + (x.sev==='block'?'必须修':(x.sev==='warn'?'建议':'参考')) + ' ' + rsEsc(x.msg) + '
').join('') : ''; const askHtml = asks.length ? '
—— 补充下面这些,系统会重新生成并复检 ——
' + asks.map((g,i)=>{ const up = RS_QC.uploads[g.id]; if(g.kind === 'image'){ return '
'+rsEsc(g.q)+'
'+rsEsc(g.why||'')+'
' + '
' + (up&&up.display ? ('已上传,点击可更换') : '点击从本地选择图片') + '
' + '
'; } return '
'+rsEsc(g.q)+'
'+rsEsc(g.why||'')+'
' + '
'; }).join('') : ''; $('#rs-qc-list').innerHTML = listHtml + askHtml; $('#rs-qc-go').style.display = asks.length ? '' : 'none'; $('#rs-qc').classList.add('on'); clearTimeout(rsQcRender._t); } function rsQcCollect(){ const out = {}; const asks = (RS_QC.last && RS_QC.last.asks) || []; asks.forEach(g=>{ if(g.kind === 'image'){ const u = RS_QC.uploads[g.id]; if(u && u.path) out[g.id] = { path:u.path, kind:'image', slot:g.slot||'', text:u.text||'' }; return; } const e = document.getElementById('qci-'+g.id); if(e && e.value.trim()) out[g.id] = e.value.trim(); }); return out; } async function rsQcUpload(input, gid){ const f = input.files && input.files[0]; if(!f) return; if(f.size > 12*1024*1024){ toast('图片太大了(上限 12MB)','wand'); return; } toast('正在上传…','wand'); try{ const dataUrl = await new Promise((res, rej)=>{ const fr=new FileReader(); fr.onload=()=>res(fr.result); fr.onerror=()=>rej(new Error('读取失败')); fr.readAsDataURL(f); }); const r = await Store._api('roadshow/upload-asset', { method:'POST', body: JSON.stringify({ dataUrl:dataUrl, name:f.name }) }); RS_QC.uploads[gid] = r; RS_QC._typed = RS_QC._typed || {}; ((RS_QC.last||{}).asks||[]).forEach(g=>{ if(g.kind!=='image'){ const e=document.getElementById('qci-'+g.id); if(e) RS_QC._typed[g.id]=e.value; } }); rsQcRender(RS_QC.last); Object.keys(RS_QC._typed).forEach(k=>{ const e=document.getElementById('qci-'+k); if(e && !e.value) e.value = RS_QC._typed[k]; }); toast('上传成功','wand'); }catch(e){ toast('上传失败:'+rsErr(e),'wand'); } } async function rsQcGo(){ const sups = rsQcCollect(); if(!Object.keys(sups).length){ toast('还没有补充任何内容,可以点「先跳过」','wand'); return; } const btn = $('#rs-qc-go'); btn.disabled = true; btn.textContent = '正在重新生成并复检…'; RS_QC.rounds++; try{ const qc = await Store._api('roadshow/qc-fix/'+RS_QC.id, { method:'POST', body: JSON.stringify({ supplements: sups }) }); const m = RS_QC.meta || {}; if(qc.pass){ $('#rs-qc').classList.remove('on'); toast('补充完成,自检已通过 ✅','check'); rsDoneShow(RS_QC.id, m.title, (m.desc||'') + ' ✅ 演示自检通过(共修订 ' + RS_QC.rounds + ' 轮)', m.icon); RS.saved = await Store._api('roadshow/list').catch(()=>RS.saved); renderRoadshow(); return; } toast('又发现 '+qc.block+' 个新问题,再补一下','wand'); rsQcRender(qc); }catch(e){ toast('重新生成失败:'+rsErr(e),'wand'); } btn.disabled = false; btn.textContent = '补充好了,重新自检'; } function rsQcSkip(){ const m = RS_QC.meta || {}; $('#rs-qc').classList.remove('on'); const last = RS_QC.last || {}; if((last.block||0) > 0) toast('已跳过自检,放映时请注意 ' + last.block + ' 处可能的问题','alert'); rsDoneShow(RS_QC.id, m.title, (m.desc||'') + ' ⚠️ 有 ' + (last.block||0) + ' 处未修问题', m.icon); } /* ───────── 生成完成浮层 ───────── */ var RS_DONE_ID = ''; function rsDoneShow(id, title, desc, icon){ RS_DONE_ID = id || ''; $('#rs-done-tt').textContent = title || '讲稿生成好了'; $('#rs-done-dd').textContent = desc || ''; $('#rs-done-ic').textContent = icon || '🎉'; $('#rs-done').classList.add('on'); var bd = $('#rs-done-bd'); if(bd) bd.classList.add('on'); clearTimeout(rsDoneShow._t); rsDoneShow._t = setTimeout(rsDoneHide, 30000); } function rsDoneHide(){ $('#rs-done').classList.remove('on'); var bd=$('#rs-done-bd'); if(bd) bd.classList.remove('on'); clearTimeout(rsDoneShow._t); } function rsDonePlay(){ const id = RS_DONE_ID; rsDoneHide(); if(id) playRoadshow(id); } /* ───────── postMessage 桥接:播放器要讲词,父页带 token 去取 ───────── */ window.addEventListener('message', async function(e){ const d = e && e.data; if(!d) return; /* 结尾致辞:借通用 TTS 接口合成欢快语调的一句话 */ const KI = document.documentElement.classList.contains('kiosk'); if(d.type === 'rs-tts'){ if(KI) return; /* 展台模式不放结尾致辞音频 */ const fr = $('#rs-frame'); const send = (msg) => { try{ if(fr && fr.contentWindow) fr.contentWindow.postMessage(msg, '*'); }catch(err){} }; try{ const r = await Store._api('tts', { method:'POST', body: JSON.stringify({ text: String(d.text || '').slice(0, 120), voice_id: d.voice || 'male-qn-jingying', emotion: d.emotion || 'happy', speed: 1.05 }) }); if(r && r.path){ const au = fr.contentDocument.getElementById('rs-audio'); au.onended = null; au.onerror = null; /* ★ 必须绝对化:播放器在 blob: iframe 里,相对路径取不到 */ au.src = new URL(r.path, location.origin).href; au.volume = 1; au.play().catch(()=>{}); } }catch(err){ /* 合成失败就静默收尾,不打断流程 */ } return; } if(d.type !== 'rs-voice') return; const fr = $('#rs-frame'); const send = (msg) => { try{ if(fr && fr.contentWindow) fr.contentWindow.postMessage(msg, '*'); }catch(err){} }; if(!d.id){ send({type:'rs-voice-err', error:'缺少讲稿标识'}); return; } try{ toast('智道AI正在写讲词、配音与配乐…','brain'); const r = await Store._api('roadshow/voice/'+d.id, { method:'POST', body: JSON.stringify({ refresh: !!d.refresh }) }); if(KI && r && Array.isArray(r.slides)){ r.slides = r.slides.map(function(sl){ return Object.assign({}, sl, { audio: '' }); }); r.muted = true; } send({ type:'rs-voice-ok', data: r }); const tts = r.tts || {}; toast((r.cached ? '已加载讲词' : '讲词配音完成') + ':' + r.slides.length + ' 页' + (tts.err ? ('(' + tts.err + ' 页配音失败,将以文字节奏播放)') : ''), 'brain'); }catch(err){ send({ type:'rs-voice-err', error: rsErr(err) }); toast('生成讲词失败:' + rsErr(err), 'wand'); } }); /* ───────── PDF / 导出(仍走新窗口,因为要唤起打印) ───────── */ async function pdfRoadshow(id){ toast('正在生成 PDF 版…','wand'); try{ const html = await rsFetchHtml('api/roadshow/pdf/'+id+'?print=1'); const url = URL.createObjectURL(new Blob([html], {type:'text/html;charset=utf-8'})); const w = window.open(url, '_blank'); if(w) toast('已打开 PDF 版——选「另存为 PDF」即可保存','wand'); else toast('浏览器拦截了新窗口,请允许弹出窗口','wand'); setTimeout(()=>URL.revokeObjectURL(url), 180000); }catch(e){ toast('生成失败:'+rsErr(e),'wand'); } } async function exportRoadshow(id){ toast('正在导出单文件 HTML…','wand'); try{ const html = await rsFetchHtml('api/roadshow/export/'+id); const url = URL.createObjectURL(new Blob([html], {type:'text/html;charset=utf-8'})); const rec = RS.saved.find(x=>x.id===id); const a = document.createElement('a'); a.href = url; a.download = (rec ? (rec.product+'-'+rec.style) : 'roadshow') + '.html'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(url), 60000); toast('已导出:单文件 HTML,可离线播放','wand'); }catch(e){ toast('导出失败:'+rsErr(e),'wand'); } } async function delRoadshow(id){ if(!confirm('确定删除这份讲稿?')) return; try{ await Store._api('roadshow/'+id, { method:'DELETE' }); RS.saved = await Store._api('roadshow/list').catch(()=>[]); renderRoadshow(); toast('已删除','wand'); }catch(e){ toast('删除失败:'+rsErr(e),'wand'); } } /* ---------------- 快速切换账号(机房共用电脑) ---------------- */ const ACCT_KEY='iv3_accounts'; function getAccounts(){ try{ return JSON.parse(localStorage.getItem(ACCT_KEY)||'[]'); }catch(e){ return []; } } function saveAccount(u){ if(!u||u.guest||u.admin) return; try{ const list=getAccounts().filter(a=>a.nick!==u.nick); list.unshift({nick:u.nick, avatar:u.avatar||'', hasPin:!!u.hasPin, at:Date.now()}); localStorage.setItem(ACCT_KEY, JSON.stringify(list.slice(0,6))); }catch(e){} renderQuickAccounts(); } function renderQuickAccounts(){ const box=document.getElementById('quick-accounts'); if(!box) return; const list=getAccounts(); if(!list.length){ box.style.display='none'; box.innerHTML=''; return; } box.style.display='block'; box.innerHTML='
这台电脑上的账号 · 点头像快速进入
'+ '
'+ list.map(function(a,i){ const initial=(a.nick||'?').slice(0,1).toUpperCase(); const av=a.avatar?(''):initial; return '
'+ '
'+av+'
'+ '
'+esc(a.nick)+'
'+ '
'+(a.hasPin?'PIN':'密码')+'
'; }).join('')+'
'; } function quickLogin(nick){ const rec=getAccounts().find(a=>a.nick===nick); if(!rec) return; if(!rec.hasPin){ pinUsePassword(nick); return; } PIN_NICK=nick; const w=document.getElementById('pin-who'); if(w) w.textContent='快速登录 · '+nick; const i=document.getElementById('pin-input'); if(i) i.value=''; const e=document.getElementById('pin-err'); if(e) e.textContent=''; document.getElementById('pin-modal').classList.add('active'); setTimeout(function(){ const x=document.getElementById('pin-input'); if(x) x.focus(); },80); } let PIN_NICK=''; function pinAuto(){ const v=(document.getElementById('pin-input').value||'').replace(/\D/g,''); document.getElementById('pin-input').value=v.slice(0,4); if(v.length===4) doPinLogin(); } async function doPinLogin(){ const pin=(document.getElementById('pin-input').value||'').trim(); const err=document.getElementById('pin-err'); if(pin.length!==4){ if(err) err.textContent='请输入 4 位数字'; return; } if(err) err.textContent=''; try{ const r=await fetch('api/pin/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({nick:PIN_NICK,pin:pin})}); const d=await r.json().catch(function(){return {};}); if(!r.ok) throw new Error(d.error||('登录失败 '+r.status)); Store.token=d.token; localStorage.setItem('iv3_token',d.token); Store.user=d.user; closeModal('pin-modal'); enterApp(d.user); saveAccount(d.user); toast('欢迎回来,'+d.user.nick,'check'); }catch(e){ if(err) err.textContent=e.message||'PIN 不正确'; const i=document.getElementById('pin-input'); if(i){ i.value=''; i.focus(); } } } function pinUsePassword(nick){ closeModal('pin-modal'); const n=document.getElementById('auth-nick'); if(n) n.value=nick||PIN_NICK||''; const p=document.getElementById('auth-pwd'); if(p) p.value=''; showScene('scene-auth'); switchAuth('login'); if(p) p.focus(); } function switchAccount(){ if(!confirm('切换账号?当前账号会退出登录(数据都在云端,不会丢)。')) return; logout(); } function openPinSet(){ const pin=prompt('设置一个 4 位数字 PIN(在共用电脑上快速进入,不要用生日等好猜的数字):'); if(pin==null) return; if(!/^\d{4}$/.test(String(pin).trim())) return toast('PIN 必须是 4 位数字','alert'); Store._api('pin/set',{method:'POST',body:JSON.stringify({pin:String(pin).trim()})}).then(function(){ Store.user.hasPin=true; saveAccount(Store.user); const b=document.getElementById('btn-pin'); if(b) b.textContent='修改快速登录 PIN'; toast('PIN 已设置','check'); }).catch(function(e){ toast('设置失败:'+e.message,'alert'); }); } /* ---------------- 找回码 · 账号卡 ---------------- */ let LAST_RC=''; async function genRecoveryCode(){ if(!Store.user||Store.user.guest){ toast('请先注册正式账号','alert'); return; } if(!confirm('生成新的找回码?旧找回码将立即失效。')) return; try{ const d=await Store._api('recover/code',{method:'POST'}); LAST_RC=d.code; Store.user.hasRecovery=true; const el=document.getElementById('card-code'); if(el) el.textContent=d.code; const pv=document.getElementById('card-preview'); if(pv) pv.innerHTML='
昵称:'+esc(d.nick)+'
'; document.getElementById('card-modal').classList.add('active'); const st=document.getElementById('pf-rc-state'); if(st) st.textContent='(已设置)'; const b=document.getElementById('btn-rc'); if(b) b.textContent='重新生成找回码'; }catch(e){ toast('生成失败:'+e.message,'alert'); } } function copyAccountCard(){ try{ navigator.clipboard.writeText(LAST_RC); toast('找回码已复制','check'); } catch(e){ prompt('请手动复制:', LAST_RC); } } function downloadAccountCard(){ if(!LAST_RC) return; const c=document.createElement('canvas'); c.width=760; c.height=460; const g=c.getContext('2d'); const grd=g.createLinearGradient(0,0,760,460); grd.addColorStop(0,'#0b1020'); grd.addColorStop(1,'#151d38'); g.fillStyle=grd; g.fillRect(0,0,760,460); g.strokeStyle='rgba(242,193,78,.55)'; g.lineWidth=3; g.strokeRect(12,12,736,436); g.fillStyle='#f2c14e'; g.font='bold 24px sans-serif'; g.fillText('创世纪创新者 · 账号卡',48,76); g.fillStyle='#9aa3bd'; g.font='16px sans-serif'; g.fillText('请像保管银行卡一样保管它:忘记密码时靠它找回账号',48,112); g.fillStyle='#8b93a9'; g.font='15px sans-serif'; g.fillText('昵称',48,196); g.fillStyle='#e9edf6'; g.font='bold 40px sans-serif'; g.fillText(Store.user?Store.user.nick:'',48,246); g.fillStyle='#8b93a9'; g.font='15px sans-serif'; g.fillText('找回码',48,318); g.fillStyle='#f2c14e'; g.font='bold 40px ui-monospace,monospace'; g.fillText(LAST_RC,48,368); g.fillStyle='#5b6378'; g.font='13px sans-serif'; g.fillText('生成时间:'+new Date().toLocaleString('zh-CN'),48,414); const a=document.createElement('a'); a.href=c.toDataURL('image/png'); a.download='账号卡-'+(Store.user?Store.user.nick:'student')+'.png'; document.body.appendChild(a); a.click(); a.remove(); toast('账号卡已下载,请保存好','check'); } /* ---------------- 忘记密码 ---------------- */ function openRecover(){ const n=document.getElementById('rc-nick'); const last=localStorage.getItem('iv3_last_nick')||''; if(n) n.value=last; const e=document.getElementById('rc-err'); if(e) e.textContent=''; document.getElementById('recover-modal').classList.add('active'); } async function doRecover(){ const nick=(document.getElementById('rc-nick').value||'').trim(); const code=(document.getElementById('rc-code').value||'').trim(); const pwd=(document.getElementById('rc-pwd').value||''); const err=document.getElementById('rc-err'); const fail=m=>{ if(err) err.textContent=m; }; if(!nick||!code){ return fail('请填写昵称和找回码'); } if(pwd.length<6){ return fail('新密码至少 6 位'); } if(err) err.textContent=''; try{ const r=await fetch('api/recover/reset',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({nick:nick,code:code,pwd:pwd})}); const d=await r.json().catch(function(){return {};}); if(!r.ok) throw new Error(d.error||('重置失败 '+r.status)); closeModal('recover-modal'); toast('密码已重置,请用新密码登录','check'); const an=document.getElementById('auth-nick'); if(an) an.value=nick; const ap=document.getElementById('auth-pwd'); if(ap) ap.value=pwd; }catch(e){ fail(e.message||'重置失败'); } } /* ---------------- 班级管理(老师) ---------------- */ async function openTeacher(){ document.getElementById('teacher-modal').classList.add('active'); const box=document.getElementById('teacher-body'); box.innerHTML='加载中…'; try{ const d=await Store._api('teacher/classes'); const cls=d.classes||[]; if(!cls.length){ box.innerHTML='
你还没有负责的班级
'; return; } box.innerHTML=cls.map(function(c){ const rows=(c.students||[]).map(function(st){ return ''+esc(st.nick)+''+fmtNum(st.points)+''+st.works+''+ ''; }).join(''); return '
'+ '
'+esc(c.name)+' '+ (c.students||[]).length+' 名学生 · 每人 '+fmtNum(c.points)+' 币
'+ '
'+ (rows||'')+'
学生创作币作品操作
还没有学生兑换班级码
'; }).join(''); }catch(e){ box.innerHTML='
加载失败:'+esc(e.message)+'
'; } } async function teacherResetPwd(nick){ if(!confirm('重置「'+nick+'」的密码?\n\n会生成一个临时密码,学生当前登录会被踢下线。')) return; try{ const d=await Store._api('teacher/reset-pwd',{method:'POST',body:JSON.stringify({nick:nick})}); alert('「'+nick+'」的临时密码:\n\n'+d.tempPassword+'\n\n请告诉学生,登录后建议自己改密码。'); openTeacher(); }catch(e){ toast('重置失败:'+e.message,'alert'); } } /* ---------------- 成长档案 ---------------- */ async function loadArchive(){ const box=document.getElementById('pf-archive'); if(!box) return; if(!Store.user||Store.user.guest){ box.innerHTML=''; return; } try{ const d=await Store._api('me/reports'); const list=d.months||[]; if(!list.length){ box.innerHTML='
成长档案:下个月起,系统会自动为你生成每月的成长报告。
'; return; } box.innerHTML='
📁 成长档案
'+ list.map(function(m){ const url='api/me/report-auto?ym='+encodeURIComponent(m.ym)+'&t='+encodeURIComponent(Store.token||''); return '
'+ ''+esc(m.ym)+' 月成长报告'+ '
'; }).join(''); }catch(e){ box.innerHTML=''; } } async function openProfile(){ if(!Store.user)return; const u=Store.user; const guest=isGuest(); $('#pf-title').textContent=guest?'完善资料 · 解锁 AI 绘图':'个人中心'; $('#pf-guest-tip').style.display=guest?'block':'none'; $('#pf-pwd-label').textContent='设置密码(必填)'; $('#pf-pwd-field').style.display=guest?'':'none'; /* 游客注册才要填密码 */ const pz=$('#pf-pwd-zone'); if(pz) pz.style.display=guest?'none':'block'; const pf=$('#pf-pwd-form'); if(pf) pf.style.display='none'; /* 每次打开都收起 */ $('#btn-pf-save').textContent=guest?'完成注册,获得 3,000 创作币':'保存资料'; $('#btn-pf-recharge').style.display=guest?'none':''; $('#pf-login-link').style.display=guest?'block':'none'; $('#pf-member-zone').style.display=guest?'none':'block'; const pga=$('#pf-agree-row'), pgn=$('#pf-agree-note'); if(pga) pga.style.display=guest?'flex':'none'; if(pgn) pgn.style.display=guest?'block':'none'; /* 账号安全区:找回码状态 / 班级入口 / 成长档案 */ (function(){ const safe=$('#pf-safe-zone'); if(safe) safe.style.display=guest?'none':'block'; const st=$('#pf-rc-state'); if(st) st.textContent=u.hasRecovery?'(已设置)':'(未设置)'; const b=$('#btn-rc'); if(b) b.textContent=u.hasRecovery?'重新生成找回码':'生成找回码'; const cb=$('#btn-class'); if(cb) cb.style.display=(u.teacherOf&&u.teacherOf.length)?'':'none'; const pb=$('#btn-pin'); if(pb) pb.textContent=u.hasPin?'修改快速登录 PIN':'设置快速登录 PIN'; loadArchive(); })(); /* P25:成长报告入口 —— 会员可下载,非会员展示为可开通的权益说明 */ (function(){ const zone=$('#pf-report-zone'), tip=$('#pf-report-tip'); const b1=$('#btn-pf-report'), b2=$('#btn-pf-report-pick'); if(!zone) return; if(guest){ zone.style.display='none'; return; } zone.style.display='block'; /* 周期按钮由脚本创建(可能已存在则复用) */ let gb=$('#btn-pf-gift'); if(!gb){ gb=document.createElement('button'); gb.className='btn sm gold'; gb.id='btn-pf-gift'; const _row=zone.querySelector('div>div:last-child'); if(_row) _row.appendChild(gb); } const isMember=!!u.member; /* P25r 报告周期为会员权益:非会员固定「最近 30 天」,点周期按钮引导开通 */ if(b1) b1.style.display=''; /* 导出按钮:所有人可见 */ if(b2) b2.style.display='none'; /* 旧的"选择月份"已由「报告周期」取代 */ if(isMember){ const until=u.memberUntil?(' · 有效期至 '+new Date(u.memberUntil).toLocaleDateString('zh-CN')):''; if(tip) tip.textContent='会员权益已开通'+until+'。可用「报告周期」自选时间段,导出对应周期的报告。每次导出扣 2000 创作币。'; if(gb) gb.textContent='报告周期'; if(gb) gb.onclick=()=>{ if(typeof pickReportPeriod==='function') pickReportPeriod(); }; }else{ if(tip) tip.textContent='免费版导出「最近 30 天」成长报告。开通会员后可自选月/季/年等任意周期。每次导出扣 2000 创作币。'; if(gb) gb.textContent='报告周期(会员权益)'; if(gb) gb.onclick=()=>{ if(typeof goMember==='function') goMember(); else toast('开通会员后可选任意报告周期','alert'); }; } })(); $('#pf-nick').value=guest?'':u.nick; $('#pf-phone').value=u.phone||''; $('#pf-pwd').value=''; $('#pf-reason').value=u.clubReason||''; $('#pf-nick-show').textContent=u.nick; renderPfAva(); refreshPoints(); $('#profile-modal').classList.add('active'); hydrateIcons($('#profile-modal')); if(guest) return; const list=$('#pf-ach-list'); list.innerHTML='
加载中……
'; try{ const acts=await Store.myActivities(); list.innerHTML = acts.length ? ''+acts.map(a=> ''+ ''+ '').join('')+'
时间产品净利润海报
'+new Date(a.ts).toLocaleDateString('zh-CN')+''+esc(a.product||'-')+''+fmtWan(a.profit||0)+''+(a.posterUrl?'':'—')+'
' : '
还没有发明记录,快去创造第一个作品吧!
'; }catch(e){ list.innerHTML='
加载失败
'; } } function uploadAvatar(input){ if(isGuest()){ input.value=''; toast('请先完成资料注册,再上传头像','alert'); return; } const f=input.files&&input.files[0]; if(!f)return; const rd=new FileReader(); rd.onload=()=>{ const img=new Image(); img.onload=async ()=>{ const cv=document.createElement('canvas'); cv.width=cv.height=128; const c=cv.getContext('2d'); const s=Math.min(img.width,img.height); c.drawImage(img,(img.width-s)/2,(img.height-s)/2,s,s,0,0,128,128); const data=cv.toDataURL('image/jpeg',0.85); try{ await Store.updateProfile({avatar:data}); renderPfAva(); toast('头像已更新','check'); } catch(e){ toast('保存失败:'+e.message,'alert'); } }; img.src=rd.result; }; rd.readAsDataURL(f); input.value=''; } async function saveProfile(){ const nick=$('#pf-nick').value.trim(), pwd=$('#pf-pwd').value, phone=$('#pf-phone').value.trim(), reason=$('#pf-reason').value.trim(); if(isGuest()){ // 游客转正:注册正式账号,解锁 AI 绘图 if(nick.length<2) return toast('请输入昵称(至少 2 个字符)','alert'); if(pwd.length<6) return toast('请设置密码(至少 6 位)','alert'); if(!/^1\d{10}$/.test(phone)) return toast('请输入 11 位手机号','alert'); if(!(document.getElementById('pf-agree')||{}).checked) return toast('请先阅读并同意《用户注册须知与同意书》','alert'); const btn=$('#btn-pf-save'); btn.disabled=true; btn.textContent='注册中……'; try{ const u=await Store.register(nick,pwd,true); if(reason||phone){ try{ await Store.updateProfile({phone, clubReason:reason}); }catch(e){} } // 轻量转正:不打断当前游戏场景,仅刷新顶栏身份 $('#user-nick').textContent=u.nick; $('#user-avatar').textContent=u.nick.slice(0,1); $('#btn-admin-entry').style.display=u.admin?'flex':'none'; updateRankChip(); refreshPoints(); renderPfAva(); closeModal('profile-modal'); toast('注册成功!已获得 3,000 创作币,AI 绘图已解锁','check'); const next=G._afterRegister; G._afterRegister=null; if(next) setTimeout(next,500); }catch(e){ toast('注册失败:'+e.message,'alert'); } btn.disabled=false; btn.textContent='完成注册,获得 3,000 创作币'; return; } if(nick.length<2) return toast('昵称至少 2 个字符','alert'); /* 密码改走独立的「修改密码」区(需旧密码 + 二次确认),这里不再处理 */ const patch={nick, phone, clubReason:reason}; try{ await Store.updateProfile(patch); $('#user-nick').textContent=nick; $('#pf-nick-show').textContent=nick; renderPfAva(); toast('资料已保存','check'); }catch(e){ toast(e.message,'alert'); } } /* ---- 修改密码 ---- */ function togglePwdZone(){ const f=$('#pf-pwd-form'); if(!f) return; const show = (f.style.display==='none'); f.style.display = show ? 'block' : 'none'; const t=$('#btn-pf-pwd-toggle'); if(t) t.textContent = show ? '收起' : '修改'; if(show){ ['pf-old-pwd','pf-new-pwd','pf-new-pwd2'].forEach(id=>{ const e=$('#'+id); if(e) e.value=''; }); setTimeout(()=>{ const e=$('#pf-old-pwd'); if(e&&e.focus) e.focus(); }, 80); } } async function savePassword(){ const op=$('#pf-old-pwd').value, np=$('#pf-new-pwd').value, np2=$('#pf-new-pwd2').value; if(!op) return toast('请输入当前密码','alert'); if(np.length<6) return toast('新密码至少 6 位','alert'); if(np.length>32) return toast('新密码最多 32 位','alert'); if(np!==np2) return toast('两次输入的新密码不一致','alert'); if(np===op) return toast('新密码不能和当前密码相同','alert'); const b=$('#btn-pf-pwd-save'); b.disabled=true; const old=b.textContent; b.textContent='修改中…'; try{ await Store.updateProfile({password:np, oldPassword:op}); togglePwdZone(); toast('密码已修改成功,其他设备已登出','check'); }catch(e){ toast(e.message||'修改失败,请重试','alert'); } b.disabled=false; b.textContent=old; } /* ===================================================== 创作币充值 · 微信支付 微信内走服务号 JSAPI,微信外走 Native 扫码;到账靠轮询订单状态, 不依赖前端跳转,避免用户付完款关页面导致"钱付了币没到"。 ===================================================== */ const PAYUI = { cfg:null, order:null, payParams:null, timer:0, deadline:0, amount:0, busy:false }; function isWeChatUA(){ return /MicroMessenger/i.test(navigator.userAgent||''); } function payStep(name){ ['amount','jsapi','qr','manual','redeem'].forEach(k=>{ const el=document.getElementById('pay-step-'+k); if(el) el.style.display = (k===name?'':'none'); }); const t=document.getElementById('pay-title'); if(t) t.textContent = name==='qr' ? '微信扫码充值' : (name==='redeem' ? '兑换码充值' : '创作币充值'); } function showPayErr(msg){ const e=document.getElementById('pay-err'); if(e){ e.textContent=msg; e.style.display=''; } } function hidePayErr(){ const e=document.getElementById('pay-err'); if(e) e.style.display='none'; } function renderPayPresets(){ const box=document.getElementById('pay-presets'); if(!box) return; const cfg=PAYUI.cfg||{}; const rate=cfg.creditsPerYuan||100; const list=(cfg.presets&&cfg.presets.length)?cfg.presets:[10,30,50,100,200,500]; box.innerHTML=list.map(y=>{ const on = (parseInt(document.getElementById('pay-yuan').value,10)===y); return ''; }).join(''); } function pickPayAmount(y){ const el=document.getElementById('pay-yuan'); if(el) el.value=y; updatePayCredits(); renderPayPresets(); } function updatePayCredits(){ const cfg=PAYUI.cfg||{}; const rate=cfg.creditsPerYuan||100; const y=parseInt(document.getElementById('pay-yuan').value,10); const el=document.getElementById('pay-credits'); if(el) el.textContent = fmtNum(Number.isFinite(y)&&y>0 ? y*rate : 0); } async function goRecharge(){ openPay(); } async function openPay(){ const u=Store.user; if(!u){ toast('请先注册或登录,再充值创作币','alert'); return; } if(u.guest){ toast('游客模式不能充值,注册一个账号就能保存创作币和作品','alert'); return; } if(!PAYUI.cfg){ try{ PAYUI.cfg=await Store._api('pay/config'); } catch(e){ PAYUI.cfg={enabled:false,creditsPerYuan:100,minYuan:1,maxYuan:5000,presets:[10,30,50,100,200,500]}; } } stopPayPoll(); PAYUI.order=null; PAYUI.payParams=null; PAYUI.busy=false; hidePayErr(); const rate=PAYUI.cfg.creditsPerYuan||100; const rateEl=document.getElementById('pay-rate'); if(rateEl) rateEl.textContent=rate; if(!PAYUI.amount) PAYUI.amount = (PAYUI.cfg.presets&&PAYUI.cfg.presets[3]) || 100; document.getElementById('pay-yuan').value=PAYUI.amount; payStep('amount'); renderPayPresets(); updatePayCredits(); document.getElementById('pay-modal').classList.add('active'); /* 未配置微信支付时退回人工收款码 */ if(!PAYUI.cfg.enabled){ payStep('manual'); try{ const links=await getLinks(); const url=links[LINK_KEYS.recharge]; const wrap=document.getElementById('pay-link-wrap'); if(wrap) wrap.innerHTML = (url && /^https?:\/\//.test(url)) ? '' : ''; }catch(e){} } } async function startPay(){ if(PAYUI.busy) return; const cfg=PAYUI.cfg||{}; const yuan=parseInt(document.getElementById('pay-yuan').value,10); const min=cfg.minYuan||1, max=cfg.maxYuan||5000; if(!Number.isFinite(yuan)||yuanmax){ showPayErr('请输入 '+min+'–'+max+' 之间的整数金额'); return; } if(!cfg.enabled){ payStep('manual'); return; } hidePayErr(); PAYUI.busy=true; const btn=document.getElementById('pay-go'); const old=btn?btn.textContent:''; if(btn){ btn.disabled=true; btn.textContent='正在下单…'; } try{ const channel=(isWeChatUA()&&cfg.jsapi)?'jsapi':'native'; const r=await fetch('api/pay/order',{method:'POST',headers:Store._hdr(),body:JSON.stringify({yuan:yuan,channel:channel})}); const d=await r.json().catch(()=>({})); if(r.status===409&&d.needOauth){ /* 微信内第一次支付:先过一次网页授权拿 openid,回来自动续上 */ try{ localStorage.setItem('iv3_pay_resume',String(yuan)); }catch(e){} location.href=d.oauthUrl; return; } if(!r.ok) throw new Error(d.error||('下单失败 '+r.status)); PAYUI.order=d.order; PAYUI.amount=yuan; if(channel==='jsapi'){ PAYUI.payParams=d.payParams; payStep('jsapi'); await invokeJsapiPay(); }else{ payStep('qr'); const lt=document.getElementById('pay-qr-longpress'); if(lt) lt.style.display=isWeChatUA()?'':'none'; document.getElementById('pay-qr').src=d.qrUrl; } startPayPoll(); }catch(e){ showPayErr(e.message||'下单失败,请稍后再试'); }finally{ PAYUI.busy=false; if(btn){ btn.disabled=false; btn.textContent=old||'立即支付'; } } } /** 调起微信收银台;需要 WeixinJSBridge 就绪 */ function invokeJsapiPay(){ return new Promise(function(resolve){ const p=PAYUI.payParams; if(!p){ resolve(); return; } const fire=function(){ if(typeof WeixinJSBridge==='undefined'){ showPayErr('当前不在微信中打开,请用微信扫一扫或改用手机微信打开本页'); resolve(); return; } WeixinJSBridge.invoke('getBrandWCPayRequest',p,function(res){ const msg=(res&&res.err_msg)||''; if(msg==='get_brand_wcpay_request:ok'){ toast('支付成功,正在确认到账…','check'); startPayPoll(); }else if(/cancel/i.test(msg)){ toast('已取消支付','alert'); payStep('amount'); hidePayErr(); }else{ showPayErr('支付未完成:'+(msg||'未知原因')+'(如已扣款,请等待自动到账)'); } resolve(); }); }; if(typeof WeixinJSBridge!=='undefined'){ fire(); return; } if(document.addEventListener){ document.addEventListener('WeixinJSBridgeReady',fire,{once:true}); setTimeout(function(){ if(typeof WeixinJSBridge==='undefined') fire(); },1800); }else resolve(); }); } /** 微信内兜底:改用 Native 二维码,长按识别即可付款(不依赖网页授权) */ async function payWithQrFallback(){ hidePayErr(); const yuan=parseInt(document.getElementById('pay-yuan').value,10)||PAYUI.amount; if(!Number.isFinite(yuan)||yuan<=0){ payStep('amount'); return; } try{ const r=await fetch('api/pay/order',{method:'POST',headers:Store._hdr(),body:JSON.stringify({yuan:yuan,channel:'native'})}); const d=await r.json().catch(()=>({})); if(!r.ok) throw new Error(d.error||('下单失败 '+r.status)); PAYUI.order=d.order; PAYUI.amount=yuan; const tip=document.getElementById('pay-qr-longpress'); if(tip) tip.style.display=isWeChatUA()?'':'none'; payStep('qr'); document.getElementById('pay-qr').src=d.qrUrl; startPayPoll(); }catch(e){ showPayErr(e.message||'下单失败,请稍后再试'); payStep('amount'); } } function showRedeem(){ hidePayErr(); const e=document.getElementById('rd-err'); if(e) e.style.display='none'; const i=document.getElementById('rd-code'); if(i) i.value=''; payStep('redeem'); setTimeout(function(){ const x=document.getElementById('rd-code'); if(x) x.focus(); },80); } async function doRedeem(){ const inp=document.getElementById('rd-code'); const err=document.getElementById('rd-err'); const btn=document.getElementById('rd-go'); const code=(inp&&inp.value||'').trim(); if(!code){ if(err){ err.textContent='请输入兑换码'; err.style.display=''; } return; } if(err) err.style.display='none'; if(btn){ btn.disabled=true; btn.textContent='兑换中…'; } try{ const r=await fetch('api/redeem',{method:'POST',headers:Store._hdr(),body:JSON.stringify({code:code})}); const d=await r.json().catch(function(){return {};}); if(!r.ok) throw new Error(d.error||('兑换失败 '+r.status)); if(Store.user) Store.user.points=d.points; refreshPoints(); if(d.role==='teacher'){ try{ Store.user=await Store._api('me'); }catch(e){} toast('你已成为「'+(d.className||'班级')+'」的班级管理员,可在个人中心管理本班学生','check'); setTimeout(function(){ closeModal('pay-modal'); },2600); return; } toast('兑换成功,'+fmtNum(d.added)+' 创作币已到账','check'); setTimeout(function(){ closeModal('pay-modal'); },1500); }catch(e){ if(err){ err.textContent=e.message||'兑换失败,请稍后再试'; err.style.display=''; } }finally{ if(btn){ btn.disabled=false; btn.textContent='兑换'; } } } function stopPayPoll(){ if(PAYUI.timer){ clearInterval(PAYUI.timer); PAYUI.timer=0; } PAYUI.deadline=0; } function startPayPoll(){ stopPayPoll(); PAYUI.deadline=Date.now()+3*60*1000; const tick=async function(){ if(!PAYUI.order){ stopPayPoll(); return; } if(Date.now()>PAYUI.deadline){ stopPayPoll(); const tip=document.getElementById('pay-qr-tip'); if(tip) tip.textContent='未在 3 分钟内检测到付款。若已付款,请稍后刷新页面查看余额。'; return; } try{ const r=await fetch('api/pay/order/'+encodeURIComponent(PAYUI.order.no),{headers:Store._hdr()}); const d=await r.json().catch(()=>({})); if(d&&d.order){ if(d.order.state==='paid'){ stopPayPoll(); if(Store.user) Store.user.points=d.points; refreshPoints(); toast('充值成功,'+fmtNum(d.order.credits)+' 创作币已到账','check'); setTimeout(function(){ closeModal('pay-modal'); },1400); return; } if(d.order.state==='closed'||d.order.state==='failed'){ stopPayPoll(); showPayErr('订单已关闭,请重新下单'); payStep('amount'); return; } } }catch(e){} }; PAYUI.timer=setInterval(tick,2000); setTimeout(tick,1200); } function closePay(){ stopPayPoll(); closeModal('pay-modal'); } /* 从微信网页授权返回后(?pay=resume),自动恢复刚才那笔充值 */ (function resumePayAfterOauth(){ try{ const q=new URLSearchParams(location.search||''); const flag=q.get('pay'); if(!flag) return; let yuan=0; try{ yuan=parseInt(localStorage.getItem('iv3_pay_resume')||'',10); localStorage.removeItem('iv3_pay_resume'); }catch(e){} try{ history.replaceState(null,'',location.pathname); }catch(e){} if(flag==='err'){ setTimeout(function(){ PAYUI.amount=yuan>0?yuan:0; openPay().then(function(){ if(yuan>0){ document.getElementById('pay-yuan').value=yuan; updatePayCredits(); payWithQrFallback(); } }); toast('微信授权未完成,已改用二维码支付','alert'); },600); return; } if(!Number.isFinite(yuan)||yuan<=0) return; let tries=0; const wait=setInterval(function(){ tries++; if(Store.user && !Store.user.guest){ clearInterval(wait); PAYUI.amount=yuan; openPay().then(function(){ document.getElementById('pay-yuan').value=yuan; updatePayCredits(); renderPayPresets(); startPay(); }); }else if(tries>40){ clearInterval(wait); } },300); }catch(e){} })(); /* ===================================================== 创新者殿堂 ===================================================== */ let BOARD_TAB='gold'; function openLeaderboard(){ showScene('scene-board'); renderBoard(); } function switchBoard(t){ if(t==='rotten' && !videoPlayed('pidan')){ playPidanEgg(()=>applyBoardTab('rotten')); return; } // 彩蛋:每次登录只播一次 applyBoardTab(t); } function applyBoardTab(t){ BOARD_TAB=t; $('#tab-gold').classList.toggle('active',t==='gold'); $('#tab-rotten').classList.toggle('active',t==='rotten'); const td=$('#tab-daring'); if(td) td.classList.toggle('active',t==='daring'); renderBoard(); } /* 首页序章:全黑 2 秒 → 打字机慢速两行 → 四幅漫画居中翻页轮播(每幅 5 秒,点击/滑动加速,第四幅多停 1 秒) → 2 秒渐亮呈现首页(每次会话一次,灯泡回顾) */ function playHomeIntro(){ try{ if(sessionStorage.getItem('iv3_intro_done')) return; sessionStorage.setItem('iv3_intro_done','1'); }catch(e){} /* P30:序章阶段就播放"进入学院"的声音 */ try{ if(!sessionStorage.getItem('rs_enter_voice')){ sessionStorage.setItem('rs_enter_voice','1'); const a=new Audio('a-enter.m4a'); a.volume=.82; const pr=a.play(); if(pr&&pr.catch) pr.catch(()=>{ const kick=()=>{ document.removeEventListener('pointerdown',kick,true); try{ a.play(); }catch(e){} }; document.addEventListener('pointerdown',kick,true); }); setTimeout(()=>{ try{ a.pause(); }catch(e){} }, 13000); } }catch(e){} /* 展台/回访快进:?fast=1 或已看过序章 → 直接进入学院 */ try{ const qs=new URLSearchParams(location.search); const seen=localStorage.getItem('fxm_intro_done')==='1'; if(qs.get('fast')==='1'||seen){ localStorage.setItem('fxm_intro_done','1'); document.getElementById('scene-home')?.classList.add('intro-skip'); return; } }catch(e){} const ov=document.createElement('div'); ov.className='intro-veil'; ov.innerHTML='
' +'' +''; document.body.appendChild(ov); const L1='每一个改变世界的产品,都曾经只是一个不被相信的想法。'; const L2='今天,让我们陪你,把它变成现实。'; const COMICS=['assets/comic-1.jpg','assets/comic-2.jpg','assets/comic-3.jpg','assets/comic-4.jpg']; COMICS.forEach(s=>{ const im=new Image(); im.src=s; }); // 预载,翻页不卡 let phase='text', finished=false, ci=0; const timers=[]; const later=(fn,ms)=>timers.push(setTimeout(fn,ms)); const clearTimers=()=>{ timers.forEach(clearTimeout); timers.length=0; }; const finishAll=()=>{ // 终场:2 秒渐亮,进入学院页面 if(finished) return; finished=true; try{ localStorage.setItem('fxm_intro_done','1'); }catch(e){} clearTimers(); ov.classList.add('fade'); setTimeout(()=>{ ov.remove(); },2100); }; const showComic=(n)=>{ ci=n; const img=ov.querySelector('.ic-img'); img.classList.remove('in','out'); void img.offsetWidth; // 重启动画 img.src=COMICS[n]; img.classList.add('in'); ov.querySelectorAll('.ic-dots i').forEach((d,k)=>d.classList.toggle('on',k===n)); clearTimers(); if(n{ if(finished||phase!=='comic') return; if(ci>=COMICS.length-1){ finishAll(); return; } const img=ov.querySelector('.ic-img'); img.classList.remove('in'); img.classList.add('out'); clearTimers(); later(()=>showComic(ci+1),480); }; const startComics=()=>{ if(phase!=='text'||finished) return; phase='comic'; clearTimers(); ov.classList.add('text-out'); setTimeout(()=>{ if(finished) return; ov.querySelector('.intro-type').style.display='none'; ov.querySelector('.intro-comics').style.display='flex'; showComic(0); },460); }; ov.querySelector('.intro-skipall').addEventListener('pointerdown',e=>{ e.stopPropagation(); finishAll(); }); /* ★ 任意键也能跳过/推进序章(原来只支持点击和滑动) */ const onIntroKey = (e)=>{ if(finished){ document.removeEventListener('keydown', onIntroKey); return; } if(e.key === 'Escape'){ finishAll(); return; } if(e.key === ' ' || e.key === 'Enter' || e.key.length === 1 || e.key.indexOf('Arrow') === 0){ e.preventDefault(); if(phase === 'text') startComics(); else if(phase === 'comic') nextComic(); } }; document.addEventListener('keydown', onIntroKey); ov._introKey = onIntroKey; /* 点击或左滑:文字阶段直接快进进漫画;漫画阶段加速翻到下一幅 */ let px=null; ov.addEventListener('pointerdown',e=>{ px=e.clientX; }); ov.addEventListener('pointerup',e=>{ if(finished||px===null) return; const swiped=(px-e.clientX)>40; px=null; if(phase==='text') startComics(); else if(phase==='comic') nextComic(); }); later(()=>{ // 2 秒全黑后开始打字(减速一倍) const l1=ov.querySelector('.il1'), l2=ov.querySelector('.il2'); let i=0; const t1=setInterval(()=>{ if(phase!=='text'||finished){ clearInterval(t1); return; } l1.textContent=L1.slice(0,++i); if(i>=L1.length){ clearInterval(t1); let j=0; const t2=setInterval(()=>{ if(phase!=='text'||finished){ clearInterval(t2); return; } l2.textContent=L2.slice(0,++j); if(j>=L2.length){ clearInterval(t2); later(startComics,1600); } },136); } },136); },2000); } /* 通用彩蛋视频:全屏播放,播完/跳过/出错即消失并回调 */ function playEggVideo(src, done){ if(!LIFE.video){ if(done)done(); return; } const old=$('.pidan-egg'); if(old) old.remove(); const ov=document.createElement('div'); ov.className='pidan-egg'; ov.innerHTML=''; document.body.appendChild(ov); requestAnimationFrame(()=>ov.classList.add('in')); const v=ov.querySelector('video'); let closed=false; const close=()=>{ if(closed) return; closed=true; try{ v.pause(); }catch(e){} ov.classList.remove('in'); ov.classList.add('out'); setTimeout(()=>{ ov.remove(); done&&done(); },380); }; v.onended=close; v.onerror=close; // 视频缺失时直接放行 ov.querySelector('.egg-skip').onclick=close; ov.onclick=e=>{ if(e.target===ov) close(); }; const p=v.play(); if(p&&p.catch) p.catch(()=>{}); } /* 创新榜彩蛋:点击先播放 pidan2.mp4,播完自动消失再展示榜单 */ function playPidanEgg(done){ playEggVideo('assets/pidan2.mp4', done); } const SEED_BOARD={gold:[ {name:'情绪变色衣',creator:'创新者007',value:8500000,score:94,ending:'全球传奇',date:'2026-06-15'}, {name:'自导航行李箱',creator:'科技迷阿强',value:6200000,score:88,ending:'全球传奇',date:'2026-06-14'}, {name:'翻译手语手套',creator:'公益小达人',value:4800000,score:85,ending:'全球传奇',date:'2026-06-13'}, {name:'模块化手机',creator:'拆解狂人',value:3500000,score:82,ending:'市场赢家',date:'2026-06-12'}], rotten:[ {name:'会自动逃跑的闹钟',creator:'起床困难户',value:-450000,score:45,ending:'市场教训',date:'2026-06-15'}, {name:'能打电话的拖鞋',creator:'脑洞大开',value:-380000,score:38,ending:'市场教训',date:'2026-06-14'}, {name:'喷香水的扫地机器人',creator:'洁癖患者',value:-280000,score:51,ending:'艰难求存',date:'2026-06-12'}]}; async function renderBoard(){ const tbl=$('#lb-table'); tbl.innerHTML='
'+icon('clock',30)+'
正在加载榜单……
'; let data; try{ const b=await Store.leaderboard(); data = b && b[BOARD_TAB] && b[BOARD_TAB].length ? b[BOARD_TAB] : SEED_BOARD[BOARD_TAB]; }catch(e){ data=SEED_BOARD[BOARD_TAB]; } let html='
#
图纸
发明产品 · 星级
'+(BOARD_TAB==='gold'?'净利润':'净亏损')+'
创新者
'; data.forEach((e,i)=>{ const rk=i===0?'1':i===1?'2':i===2?'3':''+(i+1)+''; const stars=e.score?''+'★'.repeat(Math.max(1,Math.round(e.score/20)))+'':''; const thumb=e.poster ?'' :'
秘
'; html+='
'+rk+'
'+ '
'+thumb+'
'+ '
'+esc(e.name)+stars+'
'+esc(e.ending||'')+'
'+ '
'+fmtWan(e.value)+'
'+ '
'+esc(e.creator)+''+esc(e.date||'')+'
'; }); tbl.innerHTML=html; }
INVENTION PROJECT · 发明档案
作品
—
GENESIS · 创新者护照