Spaces:
Running
Running
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>FigMirror 风格盲评</title> | |
| <style> | |
| :root{ | |
| --bg:#eef1f6; --panel:#fff; --ink:#182031; --dim:#6b7688; --line:#e3e7ef; --letterbox:#eef2f7; | |
| --a:#2563eb; --a-soft:#e8f0fe; --b:#7c3aed; --b-soft:#f1eafe; --good:#0fa356; --warn:#e0902f; | |
| --shadow:0 1px 2px rgba(20,30,50,.06),0 6px 20px rgba(20,30,50,.07); --hdr:48px; | |
| } | |
| *{box-sizing:border-box} | |
| html,body{height:100%;margin:0;overflow:hidden} | |
| body{background:var(--bg);color:var(--ink); | |
| font:14px/1.5 system-ui,-apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif} | |
| button{font:inherit;color:inherit;cursor:pointer} | |
| .app{height:100vh;height:100dvh;display:grid; | |
| grid-template-rows:var(--hdr) clamp(92px,20vh,210px) minmax(0,1fr) auto;overflow:hidden} | |
| /* header */ | |
| .topbar{display:flex;align-items:center;gap:14px;padding:0 16px;background:var(--panel);border-bottom:1px solid var(--line)} | |
| .brand{font-weight:750;font-size:15px;white-space:nowrap} | |
| .brand span{color:var(--a)} | |
| .rater{font-size:12px;font-weight:700;color:#fff;background:var(--a);border-radius:20px;padding:2px 10px;white-space:nowrap} | |
| .prog{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:4px;max-width:560px;margin:0 auto} | |
| .prog .t{font-size:12px;color:var(--dim);text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} | |
| .prog .t b{color:var(--ink)} | |
| .prog .bar{height:6px;border-radius:20px;background:#e2e7f0;overflow:hidden} | |
| .prog .bar>i{display:block;height:100%;width:0;background:linear-gradient(90deg,var(--a),#7c85f5);transition:width .25s} | |
| .sync{font-size:11.5px;white-space:nowrap;color:var(--dim)} | |
| .sync .ok{color:var(--good);font-weight:700} | |
| .sync .wait{color:var(--warn);font-weight:700} | |
| .hbtn{border:1px solid var(--line);background:#fff;border-radius:8px;padding:6px 10px;font-size:12px} | |
| .hbtn:hover{background:#f7fafe;border-color:#c7cfdc} | |
| /* reference */ | |
| .refband{position:relative;background:var(--panel);border-bottom:1px solid var(--line)} | |
| .refband img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;padding:8px 8px 6px;cursor:zoom-in} | |
| .reftag{position:absolute;top:7px;left:14px;z-index:2;font-size:11px;font-weight:700;letter-spacing:.1em; | |
| color:var(--a);background:var(--a-soft);border-radius:20px;padding:2px 9px;pointer-events:none} | |
| .refhint{position:absolute;top:7px;right:14px;z-index:2;font-size:11px;color:var(--dim);pointer-events:none} | |
| /* candidates */ | |
| .stage{min-height:0;min-width:0;display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr); | |
| grid-template-rows:minmax(0,1fr);gap:12px;padding:10px 12px} | |
| .cand{position:relative;min-width:0;min-height:0;overflow:hidden;background:var(--letterbox); | |
| border:2px solid var(--line);border-radius:13px;cursor:zoom-in} | |
| .cand img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;padding:8px;display:block} | |
| .cand .badge{position:absolute;top:9px;left:9px;z-index:2;width:28px;height:28px;border-radius:8px; | |
| display:grid;place-items:center;font-weight:800;font-size:14px;color:#fff;box-shadow:0 2px 6px rgba(20,30,50,.2)} | |
| #cardA .badge{background:var(--a)} #cardB .badge{background:var(--b)} | |
| .cand .chosen{position:absolute;top:9px;right:9px;z-index:2;display:none;font-size:11px;font-weight:700;color:#fff; | |
| background:var(--good);border-radius:20px;padding:3px 9px} | |
| #cardA.win{border-color:var(--a);box-shadow:0 0 0 3px var(--a-soft)} | |
| #cardB.win{border-color:var(--b);box-shadow:0 0 0 3px var(--b-soft)} | |
| .cand.win .chosen{display:block} | |
| /* vote bar */ | |
| .votebar{display:flex;align-items:center;justify-content:center;gap:10px;padding:9px 12px; | |
| background:var(--panel);border-top:1px solid var(--line)} | |
| .nav{flex:0 0 auto;border:1px solid var(--line);background:#fff;border-radius:10px;padding:10px 14px; | |
| font-size:13px;font-weight:600;color:var(--dim)} | |
| .nav:hover:not(:disabled){background:#f7fafe;border-color:#c7cfdc;color:var(--ink)} | |
| .nav:disabled{opacity:.4;cursor:default} | |
| .choice{border:2px solid;border-radius:12px;padding:11px 14px;font-size:14.5px;font-weight:800; | |
| display:inline-flex;align-items:center;gap:8px;white-space:nowrap} | |
| .choice kbd{font:700 10.5px/1 ui-monospace,Menlo,monospace;padding:2px 5px;border-radius:5px;background:rgba(20,30,50,.07);color:var(--dim)} | |
| .cA{color:var(--a);border-color:var(--a);background:#fff;flex:0 1 240px} .cA:hover{background:var(--a-soft)} | |
| .cA.on{background:var(--a);color:#fff} .cA.on kbd{background:rgba(255,255,255,.25);color:#fff} | |
| .cB{color:var(--b);border-color:var(--b);background:#fff;flex:0 1 240px} .cB:hover{background:var(--b-soft)} | |
| .cB.on{background:var(--b);color:#fff} .cB.on kbd{background:rgba(255,255,255,.25);color:#fff} | |
| .cMid{color:var(--dim);border-color:var(--line);background:#fff;font-weight:700;font-size:13px} | |
| .cMid:hover{background:#f4f6fa;border-color:#c7cfdc} .cMid.on{background:#3a4557;color:#fff;border-color:#3a4557} | |
| .cMid.on kbd{background:rgba(255,255,255,.22);color:#fff} | |
| /* overlays */ | |
| .overlay{position:fixed;inset:0;z-index:60;background:rgba(238,241,246,.97);display:grid;place-items:center;padding:22px} | |
| .panel{background:#fff;border:1px solid var(--line);border-radius:18px;box-shadow:var(--shadow);max-width:520px;padding:30px 34px;text-align:center} | |
| .panel h1{font-size:22px;margin:0 0 6px} .panel h1 span{color:var(--a)} | |
| .panel p{color:#3f475a;margin:8px 0;font-size:14.5px} | |
| .go{margin-top:16px;border:0;border-radius:12px;background:var(--a);color:#fff;font-weight:750;font-size:15px;padding:12px 30px} | |
| .go:hover{background:#1e57d6} | |
| .rules{text-align:left;background:#f6f9fc;border:1px solid var(--line);border-radius:11px;padding:12px 16px;margin:14px 0 0} | |
| .rules li{margin:4px 0;color:#3f475a;font-size:13.5px} | |
| .raters{display:flex;gap:10px;justify-content:center;margin-top:16px;flex-wrap:wrap} | |
| .raters a{border:1px solid var(--line);border-radius:10px;padding:10px 16px;font-weight:700;color:var(--a);text-decoration:none} | |
| .raters a:hover{background:var(--a-soft)} | |
| .hidden{display:none!important} | |
| /* lightbox */ | |
| .lb{position:fixed;inset:0;z-index:80;background:#0b0f16;display:none;flex-direction:column} | |
| .lb.on{display:flex} | |
| .lbtop{display:flex;align-items:center;gap:10px;padding:10px 14px;color:#dfe6f2} | |
| .lbtop .t{font-size:12.5px;color:#aeb8ca} .lbtop .sp{flex:1} | |
| .lbbtn{border:1px solid #3a4459;background:#1b2334;color:#dfe6f2;border-radius:8px;height:34px;padding:0 12px;font-size:13px} | |
| .lbbtn:hover{background:#242e44} | |
| .lbstage{flex:1;min-height:0;overflow:hidden;position:relative;cursor:grab} | |
| .lbstage.drag{cursor:grabbing} | |
| .lbstage img{position:absolute;left:50%;top:50%;max-width:94vw;max-height:82vh;object-fit:contain; | |
| transform:translate(-50%,-50%);transform-origin:center;user-select:none;-webkit-user-drag:none} | |
| @media(max-width:820px){.stage{grid-template-columns:1fr;grid-template-rows:1fr 1fr}.votebar{flex-wrap:wrap}} | |
| </style> | |
| </head> | |
| <body> | |
| <!-- landing (no valid rater) --> | |
| <div class="overlay" id="landing"> | |
| <div class="panel"> | |
| <h1><span>FigMirror</span> 风格盲评</h1> | |
| <p>请打开分配给你的链接(带 <code>?rater=编号</code>)。或直接选你的编号:</p> | |
| <div class="raters" id="raterLinks"></div> | |
| <p style="margin-top:14px;font-size:12.5px;color:#8a93a3">编号是我发给每个人的,别人和你的题目不完全一样。</p> | |
| </div> | |
| </div> | |
| <!-- start --> | |
| <div class="overlay hidden" id="start"> | |
| <div class="panel"> | |
| <h1><span>FigMirror</span> 风格盲评 · <span id="startRater"></span></h1> | |
| <p>顶上是<b>参考图</b>,下面 A / B 两张。选<b>哪张更好地复刻了参考图的风格</b>——配色、字体、间距、布局。</p> | |
| <ul class="rules"> | |
| <li>只看<b>风格像不像参考图</b>,别管数字对不对,也别管单看谁更好看。</li> | |
| <li>实在难分就点<b>「差不多」</b>;这一对看不了就<b>「跳过」</b>。</li> | |
| <li><b>点任意图片放大</b>看细节。快捷键:<b>1</b>=A,<b>2</b>=B,<b>3</b>=差不多,<b>4</b>=跳过,<b>←/→</b>翻页。</li> | |
| <li>进度自动保存,可随时关掉、下次接着标。</li> | |
| </ul> | |
| <button class="go" id="btnStart">开始标注</button> | |
| </div> | |
| </div> | |
| <!-- done --> | |
| <div class="overlay hidden" id="done"> | |
| <div class="panel"> | |
| <h1>你的题标完了 🎉</h1> | |
| <p id="doneMsg"></p> | |
| <button class="go" id="btnReview">回去检查</button> | |
| <div style="margin-top:10px"><button class="hbtn" id="btnExport2">导出我的结果(备份)</button></div> | |
| </div> | |
| </div> | |
| <div class="app"> | |
| <div class="topbar"> | |
| <div class="brand"><span>FigMirror</span> 盲评</div> | |
| <div class="rater" id="raterTag">—</div> | |
| <div class="prog"> | |
| <div class="t">第 <b id="pIdx">1</b> / <b id="pTot">0</b> · 已评 <b id="pDone">0</b></div> | |
| <div class="bar"><i id="barFill"></i></div> | |
| </div> | |
| <div class="sync" id="syncUI">…</div> | |
| <button class="hbtn" id="btnExport">导出</button> | |
| </div> | |
| <div class="refband"> | |
| <div class="reftag">参考图 REFERENCE</div> | |
| <div class="refhint">点图放大 🔍</div> | |
| <img id="refImg" alt="reference"> | |
| </div> | |
| <div class="stage"> | |
| <div class="cand" id="cardA"><div class="badge">A</div><div class="chosen">✓ 已选</div><img id="imgA" alt="A"></div> | |
| <div class="cand" id="cardB"><div class="badge">B</div><div class="chosen">✓ 已选</div><img id="imgB" alt="B"></div> | |
| </div> | |
| <div class="votebar"> | |
| <button class="nav" id="btnPrev">← 上一张</button> | |
| <button class="choice cA" id="btnA">A 更好 <kbd>1</kbd></button> | |
| <button class="choice cMid" id="btnTie">差不多 <kbd>3</kbd></button> | |
| <button class="choice cMid" id="btnSkip">跳过 <kbd>4</kbd></button> | |
| <button class="choice cB" id="btnB">B 更好 <kbd>2</kbd></button> | |
| <button class="nav" id="btnNext">下一张 →</button> | |
| </div> | |
| </div> | |
| <!-- lightbox --> | |
| <div class="lb" id="lb"> | |
| <div class="lbtop"><span class="t" id="lbTitle"></span><span class="sp"></span> | |
| <button class="lbbtn" id="lbOut">−</button><button class="lbbtn" id="lbFit">适应</button> | |
| <button class="lbbtn" id="lbIn">+</button><button class="lbbtn" id="lbClose">✕ 关闭</button> | |
| </div> | |
| <div class="lbstage" id="lbStage"><img id="lbImg" alt="zoom"></div> | |
| </div> | |
| <script src="manifest.js"></script> | |
| <script> | |
| const $ = id => document.getElementById(id); | |
| const A = window.ARENA || {pairs:[],assignment:{}}; | |
| const byId = {}; A.pairs.forEach(p => byId[p.id] = p); | |
| // Central collector endpoint (Google Apps Script Web App URL). Empty => local-only | |
| // (votes still saved in the browser + exportable); set to enable auto-collection. | |
| const COLLECT_URL = "https://script.google.com/macros/s/AKfycbzDYgGaW43lpzWaFMlBXfWvnz7KmL_XRUFKdFPtA020u00QCb9dESHVnnHodrlh07mNYg/exec"; | |
| // ---- rater resolution ---- | |
| const params = new URLSearchParams(location.search); | |
| let rater = params.get('rater'); | |
| const N_RATERS = A.n_raters || 5; | |
| const validRater = (rater && /^[1-9][0-9]*$/.test(rater) && +rater>=1 && +rater<=N_RATERS) ? +rater : null; | |
| const zoom = { | |
| scale:1,tx:0,ty:0,drag:false,sx:0,sy:0, | |
| open(src,title){ $('lbImg').src=src; $('lbTitle').textContent=title+' · 滚轮缩放 · 拖动平移'; this.fit(); $('lb').classList.add('on'); }, | |
| close(){ $('lb').classList.remove('on'); }, | |
| isOpen(){ return $('lb').classList.contains('on'); }, | |
| apply(){ $('lbImg').style.transform=`translate(calc(-50% + ${this.tx}px),calc(-50% + ${this.ty}px)) scale(${this.scale})`; }, | |
| fit(){ this.scale=1;this.tx=0;this.ty=0;this.apply(); }, | |
| by(f){ this.scale=Math.min(9,Math.max(1,this.scale*f)); if(this.scale===1){this.tx=0;this.ty=0;} this.apply(); }, | |
| init(){ | |
| const s=$('lbStage'); | |
| s.addEventListener('wheel',e=>{e.preventDefault();const r=s.getBoundingClientRect(); | |
| const cx=e.clientX-(r.left+r.width/2),cy=e.clientY-(r.top+r.height/2); | |
| const f=e.deltaY<0?1.18:1/1.18,ns=Math.min(9,Math.max(1,this.scale*f)),k=ns/this.scale; | |
| this.tx=cx-k*(cx-this.tx);this.ty=cy-k*(cy-this.ty);this.scale=ns; | |
| if(ns===1){this.tx=0;this.ty=0;}this.apply();},{passive:false}); | |
| s.addEventListener('mousedown',e=>{this.drag=true;this.sx=e.clientX-this.tx;this.sy=e.clientY-this.ty;s.classList.add('drag');}); | |
| window.addEventListener('mousemove',e=>{if(this.drag){this.tx=e.clientX-this.sx;this.ty=e.clientY-this.sy;this.apply();}}); | |
| window.addEventListener('mouseup',()=>{this.drag=false;s.classList.remove('drag');}); | |
| s.addEventListener('click',e=>{if(e.target===s)this.close();}); | |
| $('lbIn').onclick=()=>this.by(1.3); $('lbOut').onclick=()=>this.by(1/1.3); | |
| $('lbFit').onclick=()=>this.fit(); $('lbClose').onclick=()=>this.close(); | |
| } | |
| }; | |
| const app = { | |
| rater:validRater, pairs:[], idx:0, votes:{}, KEY:'', | |
| cur(){ return this.pairs[this.idx]; }, | |
| boot(){ | |
| zoom.init(); | |
| if(!this.rater){ | |
| const box=$('raterLinks'); | |
| for(let i=1;i<=N_RATERS;i++){ const a=document.createElement('a'); a.href='?rater='+i; a.textContent='编号 '+i; box.appendChild(a); } | |
| return; // stay on landing | |
| } | |
| $('landing').classList.add('hidden'); | |
| this.KEY='arena_votes_r'+this.rater; | |
| try{ this.votes=JSON.parse(localStorage.getItem(this.KEY))||{}; }catch(e){ this.votes={}; } | |
| const ids=(A.assignment&&A.assignment[this.rater])||A.pairs.map(p=>p.id); | |
| this.pairs=ids.map(id=>byId[id]).filter(Boolean); | |
| $('pTot').textContent=this.pairs.length; | |
| $('raterTag').textContent='编号 '+this.rater; | |
| $('startRater').textContent='编号 '+this.rater; | |
| // resume at first unjudged | |
| const fu=this.pairs.findIndex(p=>!this.votes[p.id]); | |
| this.idx=fu===-1?0:fu; | |
| $('start').classList.remove('hidden'); | |
| $('btnStart').onclick=()=>{ $('start').classList.add('hidden'); this.render(); }; | |
| $('btnReview').onclick=()=>{ $('done').classList.add('hidden'); this.idx=0; this.render(); }; | |
| $('btnA').onclick=()=>this.vote('A'); $('btnB').onclick=()=>this.vote('B'); | |
| $('btnTie').onclick=()=>this.vote('tie'); $('btnSkip').onclick=()=>this.vote('skip'); | |
| $('btnPrev').onclick=()=>this.go(-1); $('btnNext').onclick=()=>this.go(1); | |
| $('btnExport').onclick=()=>this.export(); $('btnExport2').onclick=()=>this.export(); | |
| $('cardA').onclick=()=>zoom.open(this.cur().A.img,'A'); $('cardB').onclick=()=>zoom.open(this.cur().B.img,'B'); | |
| $('refImg').onclick=()=>zoom.open(this.cur().ref,'参考图'); | |
| document.addEventListener('keydown',e=>{ | |
| if(zoom.isOpen()){ if(e.key==='Escape')zoom.close(); else if(e.key==='+'||e.key==='=')zoom.by(1.3); | |
| else if(e.key==='-')zoom.by(1/1.3); else if(e.key==='0')zoom.fit(); return; } | |
| if(!$('start').classList.contains('hidden')||!$('done').classList.contains('hidden')) return; | |
| if(e.key==='1')this.vote('A'); else if(e.key==='2')this.vote('B'); | |
| else if(e.key==='3')this.vote('tie'); else if(e.key==='4')this.vote('skip'); | |
| else if(e.key==='ArrowLeft')this.go(-1); else if(e.key==='ArrowRight')this.go(1); | |
| }); | |
| this.render(); this.syncLoop(); | |
| setInterval(()=>this.syncLoop(), 5000); | |
| }, | |
| render(){ | |
| const p=this.cur(); if(!p)return; | |
| $('refImg').src=p.ref; $('imgA').src=p.A.img; $('imgB').src=p.B.img; | |
| $('pIdx').textContent=this.idx+1; | |
| const done=Object.keys(this.votes).length; | |
| $('pDone').textContent=done; | |
| $('barFill').style.width=(done/this.pairs.length*100)+'%'; | |
| $('btnPrev').disabled=this.idx===0; $('btnNext').disabled=this.idx===this.pairs.length-1; | |
| const c=this.votes[p.id]?this.votes[p.id].choice:null; | |
| $('cardA').classList.toggle('win',c==='A'); | |
| $('cardB').classList.toggle('win',c==='B'); | |
| $('btnA').classList.toggle('on',c==='A'); | |
| $('btnB').classList.toggle('on',c==='B'); | |
| $('btnTie').classList.toggle('on',c==='tie'); | |
| $('btnSkip').classList.toggle('on',c==='skip'); | |
| this.syncUI(); | |
| }, | |
| uuid(){ return 'v'+Date.now().toString(36)+Math.floor(performance.now()*1000).toString(36); }, | |
| vote(choice){ | |
| const p=this.cur(); const fresh=!this.votes[p.id]; | |
| const rec={ rater:this.rater, pair_id:p.id, figure_id:p.figure_id, | |
| A_method:p.A.method, B_method:p.B.method, choice, | |
| winner_method: choice==='A'?p.A.method : choice==='B'?p.B.method : null, | |
| uuid:(this.votes[p.id]&&this.votes[p.id].uuid)||this.uuid(), | |
| ts:new Date().toISOString(), synced:false }; | |
| this.votes[p.id]=rec; this.save(); this.render(); this.post(rec); | |
| if(fresh) setTimeout(()=>this.advance(),260); | |
| }, | |
| advance(){ | |
| const nxt=this.pairs.findIndex((p,i)=>i>this.idx && !this.votes[p.id]); | |
| if(nxt!==-1){ this.idx=nxt; this.render(); } | |
| else if(this.idx<this.pairs.length-1){ this.idx++; this.render(); } | |
| else if(Object.keys(this.votes).length>=this.pairs.length){ this.finish(); } | |
| }, | |
| go(d){ const n=this.idx+d; if(n>=0&&n<this.pairs.length){ this.idx=n; this.render(); } }, | |
| finish(){ $('doneMsg').textContent=`你(编号 ${this.rater})一共标了 ${Object.keys(this.votes).length} 对,已自动保存并上传。辛苦!`; $('done').classList.remove('hidden'); }, | |
| save(){ localStorage.setItem(this.KEY, JSON.stringify(this.votes)); }, | |
| async post(rec){ | |
| if(!COLLECT_URL) return; // local-only until a collector URL is set | |
| try{ | |
| const {synced, ...payload}=rec; | |
| // no-cors: Apps Script gives an opaque cross-origin response we can't read; a | |
| // resolved fetch means the request was delivered. The script dedups by | |
| // rater+pair_id, so a retry never double-counts. | |
| await fetch(COLLECT_URL,{method:'POST',mode:'no-cors',body:JSON.stringify(payload)}); | |
| rec.synced=true; this.save(); this.syncUI(); | |
| }catch(e){ /* real network failure -> stays unsynced, retried by syncLoop */ } | |
| }, | |
| syncLoop(){ if(!COLLECT_URL){ this.syncUI(); return; } for(const id in this.votes){ if(!this.votes[id].synced) this.post(this.votes[id]); } this.syncUI(); }, | |
| syncUI(){ | |
| if(!COLLECT_URL){ $('syncUI').innerHTML='<span class="ok">✓ 本地已保存</span>'; return; } | |
| const pend=Object.values(this.votes).filter(v=>!v.synced).length; | |
| $('syncUI').innerHTML = pend===0 ? '<span class="ok">✓ 已全部保存</span>' : `<span class="wait">⟳ 待上传 ${pend}</span>`; | |
| }, | |
| export(){ | |
| const rows=Object.values(this.votes).map(({synced,...r})=>r); | |
| const blob=new Blob([JSON.stringify({rater:this.rater,n:rows.length,votes:rows},null,2)],{type:'application/json'}); | |
| const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=`arena_rater${this.rater}.json`; a.click(); | |
| }, | |
| }; | |
| app.boot(); | |
| window.app=app; window.zoom=zoom; | |
| </script> | |
| </body> | |
| </html> | |