File size: 18,789 Bytes
8a7b89b
aca8514
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68dcc48
aca8514
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68dcc48
 
 
 
 
 
aca8514
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8a7b89b
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
<!doctype html>
<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>