dsh-tingdong 0.5.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/web/panel.js CHANGED
@@ -12,6 +12,16 @@ import {
12
12
  import { paneDrill, paneText, paneWords } from './listen.js'
13
13
  import { paneReview, paneStudy, paneToday } from './study.js'
14
14
 
15
+ // ———————————————————— 图标(细线,跟着文字颜色) ————————————————————
16
+
17
+ const ICONS = {
18
+ audio: '<path d="M4 10v4M8 7v10M12 4v16M16 8v8M20 11v2"/>',
19
+ doc: '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M9 13h6M9 17h4"/>',
20
+ down: '<path d="M12 4v11M7 11l5 5 5-5M5 20h14"/>',
21
+ phone: '<rect x="7" y="3" width="10" height="18" rx="2"/><path d="M11 18h2"/>',
22
+ }
23
+ const ico = name => `<svg class="ico" viewBox="0 0 24 24" aria-hidden="true">${ICONS[name]}</svg>`
24
+
15
25
  // ———————————————————— 路由 ————————————————————
16
26
 
17
27
  function route() {
@@ -70,7 +80,7 @@ function renderJobs() {
70
80
  return
71
81
  }
72
82
  const recent = S.jobs.filter(j => j.state === 'running' || j.state === 'queued' || (j.finishedAt && Date.now() - j.finishedAt < 60e3 && j.state !== 'done'))
73
- box.innerHTML = recent.length ? `<div style="margin-top:12px">${recent.map(jobLine).join('')}</div>` : ''
83
+ box.innerHTML = recent.length ? `<div class="card jobs">${recent.map(jobLine).join('')}</div>` : ''
74
84
  box.querySelectorAll('[data-cancel]').forEach(b => {
75
85
  b.onclick = async () => { try { await api('cancel', { job: b.dataset.cancel }); pollJobsNow() } catch (e) { toast(e.message) } }
76
86
  })
@@ -79,10 +89,10 @@ function renderJobs() {
79
89
  function jobLine(j) {
80
90
  const pct = j.total ? Math.round(j.done / j.total * 100) : (j.state === 'running' ? 5 : 0)
81
91
  const state = { queued: '排队中', running: j.stage, error: '出错', cancelled: '已取消', done: '完成' }[j.state]
82
- return `<div style="margin:8px 0">
83
- <div class="row small"><span class="grow ellipsis">${esc(j.title)}</span>
84
- <span class="muted">${esc(state)}${j.total ? ` ${j.done}/${j.total} ${esc(j.message || '')}` : ''}</span>
85
- ${j.state === 'running' || j.state === 'queued' ? `<button class="btn ghost small" data-cancel="${esc(j.id)}" style="padding:0 8px">取消</button>` : ''}</div>
92
+ return `<div class="job">
93
+ <div class="row nowrap small"><span class="grow ellipsis">${esc(j.title)}</span>
94
+ <span class="muted ellipsis">${esc(state)}${j.total ? ` ${j.done}/${j.total} ${esc(j.message || '')}` : ''}</span>
95
+ ${j.state === 'running' || j.state === 'queued' ? `<button class="btn ghost small" data-cancel="${esc(j.id)}">取消</button>` : ''}</div>
86
96
  ${j.state === 'running' ? `<div class="bar"><i style="width:${pct}%"></i></div>` : ''}
87
97
  ${j.error ? `<div class="small" style="color:var(--bad)">${esc(j.error)}</div>` : ''}
88
98
  </div>`
@@ -145,33 +155,37 @@ function renderLibrary() {
145
155
  }
146
156
  const type = store.get('newType', '')
147
157
 
158
+ const flow = (kind, icon, steps, note, cta) => `
159
+ <button class="flow ${type === kind ? 'on' : ''}" data-new="${kind}">
160
+ ${ico(icon)}
161
+ <span class="grow">
162
+ <span class="flow-line">${steps.map((s, k) => `${k ? '<span class="arr">→</span>' : ''}<span>${s}</span>`).join('')}</span>
163
+ <span class="faint">${note}</span>
164
+ </span>
165
+ <span class="cta">${cta} ›</span>
166
+ </button>`
167
+
148
168
  app.innerHTML = `
149
169
  <div id="todayCard">${todayCard(S.today)}</div>
150
170
  ${canImport ? `
151
- <div class="section-title">新建课程</div>
152
- <div class="new-grid">
153
- <button class="new-card ${type === 'audio' ? 'on' : ''}" data-new="audio">
154
- <span class="new-ico">🎧</span><b>导入音频</b>
155
- <span class="faint">有声书、讲座、引导音频……MP3 / M4A / WAV / FLAC。本机识别出每一句英文,AI 翻译讲解。</span>
156
- </button>
157
- <button class="new-card ${type === 'doc' ? 'on' : ''}" data-new="doc">
158
- <span class="new-ico">📄</span><b>文档转音频</b>
159
- <span class="faint">文章、手册、书……整理好的英文文字(粘贴或 TXT / Markdown)。AI 朗读成音频,再翻译讲解。</span>
160
- </button>
171
+ <div class="section-title">新建 <span class="n">做好的同时也是一节听力课,成品都能下载到本地</span></div>
172
+ <div class="flows">
173
+ ${flow('audio', 'audio', ['英文音频', '带时间的字幕', '中文配音'], '识别出每一句英文和它的时间,导出字幕;再按同样的时间生成中文音频', '导入音频')}
174
+ ${flow('doc', 'doc', ['文档 / 文字', '朗读音频', '下载到本地'], 'PDF、Word、EPUB、TXT 或粘贴的文字,AI 朗读成 MP3,单课或整篇下载', '文档转音频')}
161
175
  </div>
162
176
  <div id="newPanel"></div>
163
- <div id="jobs"></div>` : `<div class="card small muted">这里只用来学习:在电脑上导入、做好课,再用电脑上的「发送到 VPS」发过来。<div id="jobs"></div></div>`}
164
- <div class="section-title">我的课程</div>
177
+ <div id="jobs"></div>` : `<div class="card small muted">这里只用来学习:在电脑上导入、做好课,再用电脑上的「发送到 VPS」发过来。</div><div id="jobs"></div>`}
178
+ <div class="section-title">我的课程 ${S.lessons.length ? `<span class="n">${S.lessons.length}</span>` : ''}</div>
165
179
  ${S.lessons.length ? '' : `<div class="empty">还没有课。上面选一种:导入英文音频,或把英文文档转成音频。</div>`}
166
- ${[...groups].map(([g, ls]) => `
167
- ${g ? `<div class="group-title row"><span class="grow">${esc(g)}</span>${docDownload(ls)}</div>` : (groups.size > 1 ? '<div class="group-title">其他</div>' : '')}
168
- ${ls.map(lessonCard).join('')}`).join('')}
180
+ ${[...groups].map(([g, ls]) => `<div class="list">
181
+ ${g || groups.size > 1 ? `<div class="list-head"><b class="ellipsis">${esc(g || '其他')}</b><span class="grow">${ls.length} 课</span>${docDownload(ls)}</div>` : ''}
182
+ ${ls.map(lessonRow).join('')}</div>`).join('')}
169
183
  <div id="vpsCard"></div>
170
184
  `
171
185
  renderJobs()
172
186
  loadToday()
173
187
  renderVps()
174
- app.querySelectorAll('.lesson').forEach(el => { el.onclick = () => go(`#/l/${el.dataset.id}`) })
188
+ app.querySelectorAll('.lrow').forEach(el => { el.onclick = () => go(`#/l/${el.dataset.id}`) })
175
189
  if (!canImport) return
176
190
  app.querySelectorAll('[data-new]').forEach(b => {
177
191
  b.onclick = () => {
@@ -196,17 +210,17 @@ function renderNewPanel(type) {
196
210
  function newAudioPanel(box) {
197
211
  const st = S.status || {}
198
212
  box.innerHTML = `<div class="card">
199
- <h3>导入英文音频</h3>
200
- ${!st.models ? '<div class="faint">第一次导入会先下载语音识别模型(约 240 MB),之后都在本机识别,音频不上传。</div>' : ''}
201
- ${!st.llm ? '<div class="small" style="color:var(--mid)">没找到可用的大模型:只能识别英文,翻译讲解要等配置好模型后点「补全翻译」。</div>' : ''}
202
- <div class="drop" id="drop" style="margin-top:8px">
203
- <div class="row">
204
- <input type="text" class="grow" id="path" placeholder="粘贴音频文件或文件夹的路径" style="flex:1 1 200px">
213
+ <div class="drop" id="drop">
214
+ <div class="row nowrap">
215
+ <input type="text" class="grow" id="path" placeholder="粘贴音频文件或文件夹的路径">
205
216
  <button class="btn primary" id="importBtn">导入</button>
206
217
  </div>
207
- <div class="faint" style="margin-top:8px">也可以把音频拖到这里,或 <a href="#" class="link" id="pick">选择文件</a>。文件夹会把里面的每个音频各做成一课。</div>
218
+ <div class="faint" style="margin-top:6px">或把音频拖到这里 · <a href="#" class="link" id="pick">选择文件</a> · MP3 / M4A / WAV / FLAC,文件夹里每个音频各做成一课</div>
208
219
  <input type="file" id="file" accept="audio/*,.mp3,.m4a,.m4b,.wav,.flac,.ogg,.opus,.aac" multiple hidden>
209
220
  </div>
221
+ <div class="faint" style="margin-top:6px">做好后在课程的「导出」里下载:带时间的字幕(SRT / LRC)、时间对齐的中文配音(MP3 / FLAC)。</div>
222
+ ${!st.models ? '<div class="faint">第一次导入会先下载语音识别模型(约 240 MB),之后都在本机识别,音频不上传。</div>' : ''}
223
+ ${!st.llm ? '<div class="small" style="color:var(--mid)">没找到可用的大模型:只能识别英文,翻译讲解要等配置好模型后点「补全翻译」。</div>' : ''}
210
224
  </div>`
211
225
  const input = $('#path')
212
226
  input.value = store.get('lastPath', '')
@@ -238,21 +252,19 @@ let pendingDoc = null // 已经读出文字、等确认的文档
238
252
  function newDocPanel(box) {
239
253
  if (pendingDoc) return docConfirm(box, pendingDoc)
240
254
  box.innerHTML = `<div class="card">
241
- <h3>文档转音频</h3>
242
255
  <div class="drop" id="docDrop">
243
- <div class="new-ico">📄</div>
244
256
  <div><b>把文档拖到这里</b>,或 <a href="#" class="link" id="docPick">选择文件</a></div>
245
- <div class="faint" style="margin-top:4px">PDF、Word(.docx)、EPUB 电子书、网页(.html)、TXT、Markdown · 最大 100 MB · 约 6 万词以内</div>
257
+ <div class="faint">PDF · Word(.docx)· EPUB · 网页 · TXT · Markdown,最大 100 MB、约 6 万词</div>
246
258
  <input type="file" id="docFile" accept=".pdf,.docx,.epub,.html,.htm,.txt,.md,.markdown" hidden>
247
259
  </div>
248
- <div class="faint" style="margin-top:8px">导入后先让你确认:AI 会把文字整理好(去掉页眉页码、修好断行、分好章节,原文不改),再朗读成音频、逐句翻译讲解。</div>
249
- <details style="margin-top:8px"><summary class="link">没有文件?直接粘贴英文</summary>
250
- <textarea id="docText" rows="6" placeholder="把英文文章粘贴到这里"></textarea>
251
- <button class="btn" id="docPaste" style="margin-top:6px">下一步</button>
260
+ <div class="faint" style="margin-top:6px">先让你确认:AI 把文字整理好(去页眉页码、修断行、分章节,原文不改),再朗读成音频。转好的音频在课程列表和课程页里下载到本地。</div>
261
+ <details style="margin-top:6px"><summary class="link">没有文件?直接粘贴英文</summary>
262
+ <textarea id="docText" rows="5" placeholder="把英文文章粘贴到这里" style="margin-top:6px"></textarea>
263
+ <button class="btn" id="docPaste" style="margin-top:4px">下一步</button>
252
264
  </details>
253
265
  <div id="docBusy"></div>
254
266
  </div>`
255
- const busy = msg => { $('#docBusy').innerHTML = msg ? `<div class="faint" style="margin-top:8px">${msg}</div>` : '' }
267
+ const busy = msg => { $('#docBusy').innerHTML = msg ? `<div class="faint" style="margin-top:6px">${msg}</div>` : '' }
256
268
  const got = doc => { pendingDoc = doc; docConfirm(box, doc) }
257
269
  const upload = f => {
258
270
  if (!f) return
@@ -303,16 +315,14 @@ async function docConfirm(box, doc) {
303
315
  const e = doc.estimate
304
316
  const blocked = engine === 'edge' && !edgeReady
305
317
  box.innerHTML = `<div class="card doc-confirm">
306
- <h3>确认导入</h3>
307
- <div class="row"><span class="new-ico">📄</span><div class="grow"><b>${esc(doc.name)}</b>
308
- <div class="faint">${esc(doc.type)}${doc.pages ? ` · ${doc.pages} 页` : ''} · 约 ${e.words.toLocaleString()} 个英文单词</div></div></div>
309
- <details style="margin-top:6px"><summary class="link">看看读到的文字</summary><pre class="prompt">${esc(doc.preview)}${doc.preview.length >= 600 ? '…' : ''}</pre></details>
318
+ <div class="row nowrap">${ico('doc')}<div class="grow"><b>${esc(doc.name)}</b>
319
+ <div class="faint">${esc(doc.type)}${doc.pages ? ` · ${doc.pages} 页` : ''} · 约 ${e.words.toLocaleString()} 个英文单词 · <details style="display:inline"><summary class="link" style="display:inline">看看读到的文字</summary><pre class="prompt">${esc(doc.preview)}${doc.preview.length >= 600 ? '…' : ''}</pre></details></div></div></div>
310
320
  <div class="ai-note">
311
321
  ${doc.needsCleanup
312
322
  ? (doc.hasModel
313
- ? `🤖 <b>会用 AI 整理文字</b>(${esc(doc.model)}):去掉页眉页码和目录、修好断行、分好章节。只整理格式,原文一个字都不改,不翻译。整理好的文字会存档,随时能下载。`
314
- : '⚠ 没有可用的大模型:只能简单接好断行,页眉页码可能留下。配好 DSH 的默认模型再导入效果更好。')
315
- : '✓ 已经是整理好的格式(有「# 章节」),不用 AI 整理。'}
323
+ ? `<b>会用 AI 整理文字</b>(${esc(doc.model)}):去掉页眉页码和目录、修好断行、分好章节。只整理格式,原文一个字都不改,不翻译。整理好的文字会存档,随时能下载。`
324
+ : '没有可用的大模型:只能简单接好断行,页眉页码可能留下。配好 DSH 的默认模型再导入效果更好。')
325
+ : '已经是整理好的格式(有「# 章节」),不用 AI 整理。'}
316
326
  </div>
317
327
  <div class="est">
318
328
  <div><b>${e.lessons}</b><span>约几课</span></div>
@@ -332,8 +342,8 @@ async function docConfirm(box, doc) {
332
342
  <label>声音</label><select id="docVoice">${group('m', '男声')}${group('f', '女声')}</select>
333
343
  <label>语速</label><div class="chips">${[0.85, 1, 1.15].map(x => `<button class="chip ${x === speed ? 'on' : ''}" data-docspeed="${x}">${x === 1 ? '正常' : x < 1 ? '慢一点' : '快一点'}</button>`).join('')}</div>`}
334
344
  </div>
335
- <div class="row" style="margin-top:12px">
336
- <button class="btn primary big" id="docGo" ${blocked ? 'disabled' : ''}>确定,开始导入</button>
345
+ <div class="row" style="margin-top:10px">
346
+ <button class="btn primary big" id="docGo" ${blocked ? 'disabled' : ''}>确定,开始转音频</button>
337
347
  <button class="btn ghost" id="docCancel">取消</button>
338
348
  </div>
339
349
  </div>`
@@ -378,7 +388,7 @@ function docDownload(ls) {
378
388
  if (ls.some(l => l.kind !== 'doc') || ids.length !== 1) return ''
379
389
  const waiting = ls.filter(l => !l.hasAudio).length
380
390
  if (waiting) return `<span class="faint">还有 ${waiting} 课在转音频</span>`
381
- return `<a class="link" href="doc/audio?id=${ids[0]}" download title="整篇文档的朗读,合成一个 MP3">⬇ 下载整篇音频</a>`
391
+ return `<a class="btn small" href="doc/audio?id=${ids[0]}" download title="整篇文档的朗读,合成一个 MP3 下载到本地">${ico('down')}整篇音频</a>`
382
392
  }
383
393
 
384
394
  // —— 今日学习卡片 ——
@@ -395,24 +405,23 @@ function todayCard(t) {
395
405
  const n = t.queue.length
396
406
  const max = Math.max(1, ...t.week.map(d => d.count))
397
407
  const wd = ['日', '一', '二', '三', '四', '五', '六']
408
+ const sub = [
409
+ n ? `复习 ${t.dueCount} · 新句 ${t.newCount}` : '可以去「连续听」或「磨耳朵」再听一遍',
410
+ t.todayCount ? `今天已学 ${t.todayCount} 句 · 约 ${t.minutes} 分钟` : '',
411
+ t.streak ? `连续 ${t.streak} 天` : '',
412
+ ].filter(Boolean).join(' · ')
398
413
  return `<div class="card today">
399
- <div class="row">
400
- <div class="grow">
401
- <div class="big-num">${n ? `今天要学 <b>${n}</b> 句` : (t.todayCount ? '今天的学完了 🎉' : '今天没有要学的')}</div>
402
- <div class="faint">${n ? `复习 ${t.dueCount} 句 · 新句子 ${t.newCount} 句` : '可以去「连续听」或「磨耳朵」再听一遍'}</div>
403
- </div>
404
- <div class="streak" title="连续学习天数"><b>${t.streak}</b><span>连续天数</span></div>
405
- </div>
406
- <div class="week">${t.week.map(d => `<div class="day ${d.day === t.today ? 'now' : ''}"><i style="height:${Math.round(d.count / max * 100)}%" title="${d.count} 句"></i><span>${wd[new Date(d.day + 'T12:00:00').getDay()]}</span></div>`).join('')}</div>
407
- <div class="row" style="margin-top:10px">
408
- <button class="btn primary big grow" id="goToday" ${n ? '' : 'disabled'}>${n ? '开始今天的学习' : '已完成'}</button>
409
- <span class="faint">今天 ${t.todayCount} 句 · 约 ${t.minutes} 分钟</span>
414
+ <div class="grow">
415
+ <div class="t">${n ? `今天要学 <b>${n}</b> 句` : (t.todayCount ? '今天的学完了' : '今天没有要学的')}</div>
416
+ <div class="faint ellipsis">${sub}</div>
410
417
  </div>
418
+ <div class="spark" title="最近 7 天学了多少句">${t.week.map(d => `<i class="${d.count ? 'has' : ''} ${d.day === t.today ? 'now' : ''}" style="height:${Math.round(d.count / max * 100)}%" title="周${wd[new Date(d.day + 'T12:00:00').getDay()]} ${d.count} 句"></i>`).join('')}</div>
419
+ ${n ? '<button class="btn primary big" id="goToday">开始 ›</button>' : ''}
411
420
  </div>`
412
421
  }
413
422
  function bindToday() { const b = $('#goToday'); if (b) b.onclick = () => go('#/today') }
414
423
 
415
- function lessonCard(l) {
424
+ function lessonRow(l) {
416
425
  const s = l.stats
417
426
  const pct = s.total ? Math.round(s.mastered / s.total * 100) : 0
418
427
  const seenPct = s.total ? Math.round(s.seen / s.total * 100) : 0
@@ -423,11 +432,17 @@ function lessonCard(l) {
423
432
  paused: '<span class="badge warn">已暂停</span>',
424
433
  error: '<span class="badge err">出错</span>',
425
434
  }[l.status] || (s.due ? `<span class="badge">待复习 ${s.due}</span>` : (s.total && s.mastered === s.total ? '<span class="badge ok">已掌握</span>' : ''))
426
- return `<div class="card lesson" data-id="${esc(l.id)}">
427
- <div class="row"><div class="grow"><div class="t"><span class="kind ${l.kind === 'doc' ? 'doc' : ''}">${l.kind === 'doc' ? '文档' : '音频'}</span>${esc(l.titleZh || l.title)}</div>${l.titleZh ? `<div class="en">${esc(l.title)}</div>` : ''}</div>${badge}</div>
428
- <div class="faint" style="margin:6px 0">${fmtDur(l.duration)} · ${l.sentences} 句${l.translated < l.sentences ? ` · 已翻译 ${l.translated}` : ''} · 学过 ${s.seen} · 掌握 ${s.mastered}/${s.total}${s.nextDue && !s.due ? ` · 下次复习 ${when(s.nextDue)}` : ''}</div>
429
- <div class="bar two"><i class="seen" style="width:${seenPct}%"></i><i style="width:${pct}%"></i></div>
430
- ${l.error ? `<div class="small" style="color:var(--bad);margin-top:6px">${esc(l.error)}</div>` : ''}
435
+ const doc = l.kind === 'doc'
436
+ return `<div class="lrow ${doc ? 'doc' : ''}" data-id="${esc(l.id)}" title="${esc(l.title)}">
437
+ <span title="${doc ? '文档转的音频' : '导入的音频'}" style="display:flex">${ico(doc ? 'doc' : 'audio')}</span>
438
+ <div class="grow">
439
+ <div class="t ellipsis">${esc(l.titleZh || l.title)}</div>
440
+ <div class="m ellipsis">${fmtDur(l.duration)} · ${l.sentences} 句${l.translated < l.sentences ? ` · 已翻译 ${l.translated}` : ''} · 掌握 ${s.mastered}/${s.total}${s.nextDue && !s.due ? ` · ${when(s.nextDue)}复习` : ''}</div>
441
+ ${l.error ? `<div class="err ellipsis">${esc(l.error)}</div>` : ''}
442
+ </div>
443
+ ${badge}
444
+ <div class="bar two prog" title="学过 ${s.seen} · 掌握 ${s.mastered} / ${s.total}"><i class="seen" style="width:${seenPct}%"></i><i style="width:${pct}%"></i></div>
445
+ <span class="chev">›</span>
431
446
  </div>`
432
447
  }
433
448
 
@@ -472,24 +487,23 @@ async function renderVps() {
472
487
  if (!v.enabled || (!v.config && !v.hosts.length)) { box.innerHTML = ''; return }
473
488
  const c = v.config
474
489
  const editing = !c || box.dataset.edit === '1'
475
- box.innerHTML = `<div class="card" id="vps">
476
- <h3>手机学习(VPS)</h3>
490
+ box.innerHTML = `<div class="section-title">手机上学</div><div class="card" id="vps">
477
491
  ${!editing ? `
478
- <div class="small">发到:<b>${esc(c.domain || c.host)}</b> <span class="faint">(SSH ${esc(c.host)} · 用户 ${esc(c.user)})</span></div>
479
- ${v.url ? `<div class="row" style="margin-top:8px"><span class="faint">手机打开:</span><a class="link ellipsis grow" href="${esc(v.url)}" target="_blank" rel="noopener">${esc(v.url)}</a><button class="btn ghost" id="copyUrl">复制</button></div>` : ''}
480
- <div class="row" style="margin-top:10px">
481
- <button class="btn primary" id="sendAll">全部发送到 VPS</button>
482
- <button class="btn ghost" id="vpsEdit">改设置</button>
492
+ <div class="row nowrap">${ico('phone')}
493
+ <div class="grow"><div class="ellipsis"><b>${esc(c.domain || c.host)}</b> <span class="faint">SSH ${esc(c.host)} · 用户 ${esc(c.user)}</span></div></div>
494
+ <button class="btn primary small" id="sendAll" title="把全部课程发到 VPS;只发课程内容和声音,不会覆盖那边的学习进度">全部发送</button>
495
+ <button class="btn ghost small" id="vpsEdit">设置</button>
483
496
  </div>
484
- <div class="faint" style="margin-top:6px">只发课程内容和声音,不会覆盖 VPS 上的学习进度。每课页面的「管理」里也能单独发送。VPS 上的 DSH 要装好「听懂」插件。</div>` : `
497
+ ${v.url ? `<div class="row nowrap" style="margin-top:6px"><span class="faint">手机打开</span><a class="link ellipsis grow" href="${esc(v.url)}" target="_blank" rel="noopener">${esc(v.url)}</a><button class="btn ghost small" id="copyUrl">复制</button></div>` : ''}
498
+ <div class="faint" style="margin-top:4px">只发课程和声音,不覆盖 VPS 上的学习进度;每课的「管理」里也能单独发送。</div>` : `
485
499
  <div class="faint">在电脑上做好课,发到 VPS 上的 DSH,手机打开就能学。这台电脑要能用 SSH 钥匙登录 VPS(dsh-vps-manager 添加机器时会自动配好)。</div>
486
- <div class="row" style="margin-top:8px">
500
+ <div class="row" style="margin-top:6px">
487
501
  <span class="faint">SSH 主机</span>
488
502
  <select id="vpsHost">${v.hosts.map(h => `<option ${c && c.host === h ? 'selected' : ''}>${esc(h)}</option>`).join('')}</select>
489
503
  <button class="btn" id="vpsDetect">查找 VPS 上的 DSH</button>
504
+ ${c ? '<button class="btn ghost" id="vpsCancel">取消</button>' : ''}
490
505
  </div>
491
- <div id="vpsFound">${vpsFound ? foundList(vpsFound) : ''}</div>
492
- ${c ? '<button class="btn ghost" id="vpsCancel" style="margin-top:8px">取消</button>' : ''}`}
506
+ <div id="vpsFound">${vpsFound ? foundList(vpsFound) : ''}</div>`}
493
507
  </div>`
494
508
  const on = (sel, fn) => { const el = box.querySelector(sel); if (el) el.onclick = fn }
495
509
  on('#vpsEdit', () => { box.dataset.edit = '1'; renderVps() })
@@ -512,11 +526,11 @@ async function renderVps() {
512
526
 
513
527
  function foundList(f) {
514
528
  if (!f.instances.length) return '<div class="small" style="color:var(--bad);margin-top:8px">这台 VPS 上没找到正在运行的 DSH 网页版。先用 dsh-vps 装好 DSH。</div>'
515
- return `<div style="margin-top:10px">${f.instances.map((x, k) => `
516
- <div class="row inst">
529
+ return `<div style="margin-top:6px">${f.instances.map((x, k) => `
530
+ <div class="row nowrap inst">
517
531
  <div class="grow"><b>${esc(x.domain || `端口 ${x.port}`)}</b> <span class="faint">用户 ${esc(x.user)} · ${esc(x.home)}</span>
518
- <div class="faint">${x.hasPlugin ? '✓ 已装「听懂」插件' : '还没装「听懂」插件'}</div></div>
519
- <button class="btn primary" data-pick="${k}">用这个</button>
532
+ <div class="faint">${x.hasPlugin ? '已装「听懂」插件' : '还没装「听懂」插件'}</div></div>
533
+ <button class="btn primary small" data-pick="${k}">用这个</button>
520
534
  </div>`).join('')}
521
535
  ${!f.root && f.instances.some(x => x.user !== f.sshUser) ? `<div class="faint">注意:SSH 用的是 ${esc(f.sshUser)},和 DSH 的用户不同,可能没有权限写进去。</div>` : ''}</div>`
522
536
  }
@@ -586,12 +600,13 @@ function bindVoiceBar() {
586
600
  function manageButtons(l) {
587
601
  const missing = (l.sentences || []).some(s => !s.zh && !s.skip) || (l.sentences.length && !l.summaryZh) || l.status === 'error' || l.status === 'paused'
588
602
  const canWork = !S.status || S.status.ffmpeg
589
- return `<div class="row" style="margin-top:10px">
590
- ${S.vps && S.vps.config && l.status === 'ready' ? `<button class="btn primary" data-act="send">发送到 VPS</button>` : ''}
591
- ${missing && canWork ? '<button class="btn" data-act="resume">补全翻译</button>' : ''}
592
- ${l.sentences && l.sentences.length && canWork ? '<button class="btn ghost" data-act="retranslate">重新翻译</button>' : ''}
593
- <button class="btn ghost" data-act="reset">重置进度</button>
594
- <button class="btn ghost danger" data-act="delete">删除这课</button>
603
+ return `<div class="row manage-row">
604
+ <span class="faint">管理</span>
605
+ ${S.vps && S.vps.config && l.status === 'ready' ? `<button class="btn small" data-act="send">发送到 VPS</button>` : ''}
606
+ ${missing && canWork ? '<button class="btn small" data-act="resume">补全翻译</button>' : ''}
607
+ ${l.sentences && l.sentences.length && canWork ? '<button class="btn ghost small" data-act="retranslate">重新翻译</button>' : ''}
608
+ <button class="btn ghost small" data-act="reset">重置进度</button>
609
+ <button class="btn ghost small danger" data-act="delete">删除这课</button>
595
610
  </div>`
596
611
  }
597
612
 
@@ -618,39 +633,44 @@ function bindManage(root, l) {
618
633
  function paneOverview(pane, l, st) {
619
634
  const pos = getPos()
620
635
  const stars = (S.cur.progress.stars || []).length
636
+ const doc = l.kind === 'doc'
621
637
  pane.innerHTML = `
622
- <div class="card">
623
- ${l.titleZh ? `<div style="font-weight:600">${esc(l.titleZh)}</div><div class="faint">${esc(l.title)}</div>` : `<div style="font-weight:600">${esc(l.title)}</div>`}
624
- <div class="faint" style="margin-top:2px">${l.kind === 'doc' ? `文档 · 朗读:${esc(l.voiceLabel || 'AI')} · ` : ''}${fmtDur(l.duration)} · ${l.sentences.length} 句${l.model ? ` · 翻译:${esc(l.model)}` : ''}</div>
625
- ${l.source && l.source.docId ? `<div class="faint" style="margin-top:2px">原文档:${esc(l.source.name)} · <a class="link" href="doc/file?id=${l.source.docId}&which=original" download>下载原文件</a> · <a class="link" href="doc/file?id=${l.source.docId}&which=clean" download>整理后的文字</a></div>` : ''}
626
- ${l.summaryZh ? `<p class="summary">${esc(l.summaryZh)}</p>` : ''}
638
+ <div class="card lesson-head">
639
+ <div class="title">${esc(l.titleZh || l.title)}</div>
640
+ ${l.titleZh ? `<div class="faint">${esc(l.title)}</div>` : ''}
641
+ <div class="faint">${doc ? `文档 · 朗读:${esc(l.voiceLabel || 'AI')} · ` : ''}${fmtDur(l.duration)} · ${l.sentences.length} 句${l.model ? ` · 翻译:${esc(l.model)}` : ''}</div>
642
+ ${l.source && l.source.docId ? `<div class="faint">原文档:${esc(l.source.name)} · <a class="link" href="doc/file?id=${l.source.docId}&which=original" download>原文件</a> · <a class="link" href="doc/file?id=${l.source.docId}&which=clean" download>整理后的文字</a></div>` : ''}
643
+ ${l.summaryZh ? `<p class="summary clamp" id="summary" title="点一下展开">${esc(l.summaryZh)}</p>` : ''}
627
644
  ${l.note ? `<div class="small" style="color:var(--mid)">${esc(l.note)}</div>` : ''}
628
645
  ${l.status === 'error' ? `<div class="small" style="color:var(--bad)">${esc(l.error)}</div>` : ''}
629
- </div>
630
- ${l.kind === 'doc' && S.cur.lesson.sentences.length ? docAudioCard(l) : ''}
631
- <div class="card">
632
- <div class="stat-grid">
633
- <div><b>${st.fresh}</b><span>没学过</span></div>
634
- <div><b>${st.learning}</b><span>学习中</span></div>
635
- <div><b style="color:var(--good)">${st.mastered}</b><span>已掌握</span></div>
636
- <div><b style="color:var(--accent)">${st.due}</b><span>待复习</span></div>
646
+ <div class="statline">
647
+ <span><b>${st.fresh}</b>没学过</span><span><b>${st.learning}</b>学习中</span>
648
+ <span><b style="color:var(--good)">${st.mastered}</b>已掌握</span><span><b style="color:var(--accent)">${st.due}</b>待复习</span>
649
+ ${stars ? `<span title="在「复习」或「磨耳朵」里选「收藏」专门练"><b style="color:var(--mid)">${stars}</b>难句</span>` : ''}
637
650
  </div>
638
651
  <div class="bar good"><i style="width:${st.total ? Math.round(st.mastered / st.total * 100) : 0}%"></i></div>
639
- <div class="row" style="margin-top:12px">
640
- <button class="btn primary big" id="goStudy">${pos > 0 ? `继续精听(第 ${pos + 1} 句)` : '开始精听'}</button>
652
+ <div class="row" style="margin-top:10px">
653
+ <button class="btn primary big" id="goStudy">${pos > 0 ? `继续精听 · 第 ${pos + 1} 句` : '开始精听'}</button>
641
654
  <button class="btn big" id="goReview" ${st.due || st.fresh ? '' : 'disabled'}>复习${st.due ? ` ${st.due} 句` : ''}</button>
642
655
  <button class="btn big" id="goDrill">磨耳朵</button>
656
+ ${!st.due && st.nextDue ? `<span class="faint">下一批复习 ${when(st.nextDue)}</span>` : ''}
643
657
  </div>
644
- ${!st.due && st.nextDue ? `<div class="faint" style="margin-top:6px">下一批复习:${when(st.nextDue)}</div>` : ''}
645
- ${stars ? `<div class="faint" style="margin-top:6px">难句本:收藏了 ${stars} 句,在「复习」或「磨耳朵」里选「收藏」专门练。</div>` : ''}
646
658
  </div>
647
- ${voicesCard(l)}
648
- <div class="card row">
649
- <div class="grow"><b>导出</b><div class="faint">中文配音(MP3)、双语字幕、LRC 歌词、录音稿</div></div>
650
- <button class="btn" id="goExport">去导出 ›</button>
659
+ ${doc ? `<div class="card"><div class="row"><b>下载转好的音频</b><span class="faint grow">存到本地,放手机或播放器上听</span></div>
660
+ <div class="row" style="margin-top:6px">${docAudioButtons(l)}</div></div>` : ''}
661
+ <div class="flows" style="margin-bottom:8px">
662
+ <button class="flow" id="goExport">
663
+ ${ico('down')}
664
+ <span class="grow">
665
+ <span class="flow-line"><span>导出到本地</span></span>
666
+ <span class="faint">${doc ? '朗读音频(MP3)' : '英文音频(去空白 / 朗读版)'} · 带时间的字幕(SRT / LRC)· 时间对齐的中文配音</span>
667
+ </span>
668
+ <span class="cta">去导出 ›</span>
669
+ </button>
651
670
  </div>
652
- <div class="card">
653
- <h3>怎么练到「一听就懂」</h3>
671
+ ${voicesCard(l)}
672
+ <details class="card">
673
+ <summary class="row nowrap"><b class="grow">怎么练到「一听就懂」</b><span class="faint">展开</span></summary>
654
674
  <ol class="steps">
655
675
  <li><b>精听</b>:每句先<b>只听不看</b>,猜意思 → 看英文,找出没听出来的词(点词看意思)→ 看中文和生词 → 再听一遍。</li>
656
676
  <li>老实自评:<span class="g0">没听懂</span> / <span class="g1">半懂</span> / <span class="g2">听懂了</span>;拿不准的点 ☆ 收进难句本。</li>
@@ -659,9 +679,11 @@ function paneOverview(pane, l, st) {
659
679
  <li><b>连续听</b>:选「原声·去空白」一句接一句不停地听;先用朗读的声音听清每个词,再回到原声;最后关掉字幕,能跟上就是真听懂了。</li>
660
680
  </ol>
661
681
  <div class="faint" style="margin-top:6px">电脑快捷键:空格 重听 · 回车 下一步 · 1/2/3 评分 · ←/→ 换句 · S 变速 · L 循环。手机上左右滑动换句。</div>
662
- </div>
663
- <div class="card"><h3>管理</h3>${manageButtons(l)}</div>`
682
+ </details>
683
+ ${manageButtons(l)}`
664
684
  bindVoices(pane, l)
685
+ const sum = $('#summary')
686
+ if (sum) sum.onclick = () => sum.classList.toggle('clamp')
665
687
  $('#goExport').onclick = () => go(`#/l/${l.id}/export`)
666
688
  $('#goStudy').onclick = () => go(`#/l/${l.id}/study`)
667
689
  $('#goReview').onclick = () => go(`#/l/${l.id}/review`)
@@ -670,19 +692,13 @@ function paneOverview(pane, l, st) {
670
692
  }
671
693
 
672
694
  /** 文档课:下载朗读音频(这一课 / 整篇) */
673
- function docAudioCard(l) {
695
+ function docAudioButtons(l) {
674
696
  const same = S.lessons.filter(x => x.docId && l.source && x.docId === l.source.docId)
675
697
  const waiting = same.filter(x => !x.hasAudio).length
676
- return `<div class="card">
677
- <h3>下载音频</h3>
678
- <div class="faint">把文档转成的音频下载成 MP3,放到手机或播放器上听(朗读:${esc(l.voiceLabel || 'AI')})。</div>
679
- <div class="row" style="margin-top:8px">
680
- <a class="btn primary" href="audio?id=${encodeURIComponent(l.id)}&track=full&download=1" download>这一课 MP3(${fmt(l.duration)})</a>
681
- ${same.length > 1 ? (waiting
682
- ? `<span class="faint">整篇 ${same.length} 课,还有 ${waiting} 课在转音频,做好后可以合成一个下载</span>`
683
- : `<a class="btn" href="doc/audio?id=${l.source.docId}" download>整篇 ${same.length} 课合成一个 MP3</a>`) : ''}
684
- </div>
685
- </div>`
698
+ return `<a class="btn primary" href="audio?id=${encodeURIComponent(l.id)}&track=full&download=1" download>${ico('down')}这一课 MP3 · ${fmt(l.duration)}</a>
699
+ ${same.length > 1 ? (waiting
700
+ ? `<span class="faint">整篇 ${same.length} 课,还有 ${waiting} 课在转音频</span>`
701
+ : `<a class="btn" href="doc/audio?id=${l.source.docId}" download>${ico('down')}整篇 ${same.length} 课合成一个 MP3</a>`) : ''}`
686
702
  }
687
703
 
688
704
  // —— 概览里的「声音」卡片:每种声音一行:状态、选用、试听、生成、下载 ——
@@ -716,25 +732,25 @@ function voicesCard(l) {
716
732
  ? opts(((v.kokoro && v.kokoro.voices) || []), (meta && meta.voice) || 'am_michael')
717
733
  : opts(sayList, (meta && meta.voice) || (sayList[0] && sayList[0].id))}</select>
718
734
  <select data-speed="${name}">${speeds((meta && meta.speed) || 0.9)}</select>` : ''
719
- return `<div class="track ${name === t ? 'on' : ''}">
720
- <div class="row"><b class="grow">${esc(trackLabel(name))}</b>
721
- ${meta ? `<span class="faint">${fmt(meta.duration)}${meta.voice ? ' · ' + esc(meta.voice) : ''}</span>` : '<span class="faint">未生成</span>'}</div>
722
- <div class="faint">${name === 'full' && isDoc() ? '文档转音频时 AI 朗读生成的音频' : TRACK_NOTES[name]}${name === 'kokoro' && v.kokoro && !v.kokoro.ready && canMake ? ';第一次生成会下载约 103 MB 语音模型' : ''}</div>
723
- ${stale ? '<div class="small" style="color:var(--mid)">句子改过了,这条声音对不上了,建议重新生成</div>' : ''}
724
- ${job ? `<div class="small muted" style="margin-top:4px">${job.state === 'queued' ? '排队中' : esc(job.stage)}${job.total ? ` ${job.done}/${job.total}` : ''}…</div>` : `
725
- <div class="row" style="margin-top:6px">
726
- ${meta ? (name === t ? '<span class="badge ok">正在用</span>' : `<button class="btn" data-use="${name}">选用</button>`) : ''}
727
- ${meta ? `<button class="btn ghost" data-try="${name}">▶ 试听</button>` : ''}
728
- ${meta ? `<a class="btn ghost" href="audio?id=${encodeURIComponent(l.id)}&track=${name}&download=1" download>下载 MP3</a>` : ''}
729
- ${voiceSel}
730
- ${name !== 'full' && canMake ? `<button class="btn ${meta ? 'ghost' : 'primary'}" data-make="${name}">${meta ? '重新生成' : '生成'}</button>` : ''}
731
- </div>`}
735
+ const note = (name === 'full' && isDoc() ? '文档转音频时 AI 朗读生成的音频' : TRACK_NOTES[name]) + (name === 'kokoro' && v.kokoro && !v.kokoro.ready && canMake ? ';第一次生成会下载约 103 MB 语音模型' : '')
736
+ const make = name !== 'full' && canMake
737
+ return `<div class="track ${name === t ? 'on' : ''}" title="${esc(note)}">
738
+ <span class="name">${esc(trackLabel(name))}</span>
739
+ ${meta ? `<span class="faint">${fmt(meta.duration)}</span>` : ''}
740
+ <span class="faint grow ellipsis">${meta && meta.voice ? esc(meta.voice) + ' · ' : ''}${esc(note)}</span>
741
+ ${job ? `<span class="small muted">${job.state === 'queued' ? '排队中' : esc(job.stage)}${job.total ? ` ${job.done}/${job.total}` : ''}…</span>` : `
742
+ ${meta ? (name === t ? '<span class="badge ok">正在用</span>' : `<button class="btn small" data-use="${name}">选用</button>`) : ''}
743
+ ${meta ? `<button class="btn ghost small" data-try="${name}" title="试听">▶</button>` : ''}
744
+ ${meta ? `<a class="btn ghost small" href="audio?id=${encodeURIComponent(l.id)}&track=${name}&download=1" download title="下载 MP3 到本地">${ico('down')}</a>` : ''}
745
+ ${make && !tts ? `<button class="btn small ${meta ? 'ghost' : 'primary'}" data-make="${name}">${meta ? '重做' : '生成'}</button>` : ''}
746
+ ${make && tts ? `<div class="opts">${voiceSel}<button class="btn small ${meta ? 'ghost' : 'primary'}" data-make="${name}">${meta ? '重新生成' : '生成'}</button></div>` : ''}`}
747
+ ${stale ? '<div class="small" style="color:var(--mid);flex-basis:100%">句子改过了,这条声音对不上了,建议重新生成</div>' : ''}
732
748
  </div>`
733
749
  }
734
- return `<div class="card" id="voices"><h3>声音</h3>
735
- <div class="faint" style="margin-bottom:6px">同样的句子,换一种声音听。去空白和朗读的声音都可以不停顿地连续听,也能下载到手机。</div>
750
+ return `<div class="card" id="voices">
751
+ <div class="row nowrap" style="margin-bottom:2px"><b>声音</b><span class="faint grow ellipsis">同样的句子换一种声音听,都能下载到本地</span></div>
736
752
  ${TRACK_ORDER.map(row).join('')}
737
- ${canMake && v.sayHint && (v.say || []).length && (v.say || []).every(x => x.quality < 2) ? `<div class="faint" style="margin-top:8px">💡 ${esc(v.sayHint)}</div>` : ''}
753
+ ${canMake && v.sayHint && (v.say || []).length && (v.say || []).every(x => x.quality < 2) ? `<div class="faint" style="margin-top:6px">${esc(v.sayHint)}</div>` : ''}
738
754
  </div>`
739
755
  }
740
756
 
@@ -778,59 +794,104 @@ function paneExport(pane, l) {
778
794
 
779
795
  const TEXT_FORMATS = [['srt-bi', '双语字幕'], ['srt-en', '英文字幕'], ['srt-zh', '中文字幕'], ['lrc', '歌词 LRC'], ['script', '录音稿'], ['csv', '表格']]
780
796
 
797
+ /** 中文配音用哪种语音:kokoro / edge / ming(这台电脑用不了 Ming 就退回 Kokoro) */
798
+ const ZH_VOICE_KEY = { kokoro: 'zhVoice', edge: 'zhVoiceEdge', ming: 'zhVoiceMing' }
799
+ function zhEngineNow() {
800
+ const meta = S.cur.tracks.zh
801
+ const V = S.voices || {}
802
+ const e = store.get('zhEngine', (meta && meta.engine) || 'kokoro')
803
+ return e === 'ming' && !(V.ming && V.ming.supported) ? 'kokoro' : (ZH_VOICE_KEY[e] ? e : 'kokoro')
804
+ }
805
+
806
+ /** 本机 Ming 还没装好时:说明 + 安装按钮,或者安装进度 */
807
+ function mingSetup(m) {
808
+ if (m.installing) {
809
+ const x = m.installing
810
+ const pct = x.total ? Math.round(x.done / x.total * 100) : 0
811
+ return `<div class="small muted">${esc(x.step)}${x.total ? ` · ${Math.round(x.done / 1048576)} / ${Math.round(x.total / 1048576)} MB` : ''}…</div>
812
+ ${x.total ? `<div class="bar" style="margin-top:4px"><i style="width:${pct}%"></i></div>` : ''}
813
+ <div class="faint" style="margin-top:4px">在后台装,可以先去做别的;装好后这里会变成选音色。</div>`
814
+ }
815
+ return `<div class="faint">Mac 本机 Ming:在 Apple 芯片上离线合成,声音自然,不联网、不花钱。要先装一次:约 450 MB 程序 + 1.6 GB 声音模型(和 dsh-her 的本机 Ming 是同一个,装过 dsh-her 的就不用再下)。生成时占约 2 GB 内存。</div>
816
+ ${m.error ? `<div class="small" style="color:var(--bad);margin-top:4px">${esc(m.error)}</div>` : ''}
817
+ <button class="btn" id="mingInstall" style="margin-top:6px">安装本机 Ming(约 2 GB)</button>`
818
+ }
819
+
781
820
  function zhCard(l) {
782
821
  if (!l.sentences || !l.sentences.length) return ''
783
822
  const meta = S.cur.tracks.zh
784
823
  const canMake = !S.status || S.status.ffmpeg
785
824
  const V = S.voices || {}
786
- const engine = store.get('zhEngine', (meta && meta.engine) || 'kokoro')
787
- const list = engine === 'edge' ? ((V.edge && V.edge.voices) || []) : ((V.zh && V.zh.voices) || [])
825
+ const engine = zhEngineNow()
826
+ const list = engine === 'edge' ? ((V.edge && V.edge.voices) || []) : engine === 'ming' ? ((V.ming && V.ming.voices) || []) : ((V.zh && V.zh.voices) || [])
788
827
  const job = S.jobs.find(j => j.lessonId === l.id && j.kind === 'track' && j.track === 'zh' && (j.state === 'running' || j.state === 'queued'))
789
- const selKey = engine === 'edge' ? 'zhVoiceEdge' : 'zhVoice'
828
+ const selKey = ZH_VOICE_KEY[engine]
790
829
  const sel = (meta && meta.engine === engine && meta.voice) || store.get(selKey, list[0] && list[0].id)
791
830
  const speed = (meta && meta.speed) || store.get('zhSpeed', 1)
792
831
  const clean = S.cur.tracks.clean
793
832
  const align = store.get('zhAlign', (meta && meta.align) || 'full')
794
833
  const group = (g, name) => `<optgroup label="${name}">${list.filter(x => x.gender === g).map(x => `<option value="${x.id}" ${x.id === sel ? 'selected' : ''}>${esc(x.label)}</option>`).join('')}</optgroup>`
795
834
  const edgeMissing = engine === 'edge' && !(V.edge && V.edge.ready)
835
+ const mingMissing = engine === 'ming' && !(V.ming && V.ming.ready)
836
+ const missing = edgeMissing || mingMissing
796
837
  const alignChips = attr => `<button class="chip ${align === 'full' ? 'on' : ''}" data-${attr}="full">原音频(${fmt(l.duration)})</button>
797
838
  ${clean ? `<button class="chip ${align === 'clean' ? 'on' : ''}" data-${attr}="clean">原声·去空白(${fmt(clean.duration)})</button>` : ''}`
798
- return `<div class="card" id="zhCard"><h3>导出</h3>
799
- <div class="sub-h">中文配音(音频)</div>
839
+ const doc = l.kind === 'doc'
840
+ const id = encodeURIComponent(l.id)
841
+ // 三步,顺序就是流水线:音频课「字幕 → 中文配音 → 英文音频」,文档课「朗读音频 → 字幕 → 中文配音」
842
+ const audioCard = n => `<div class="card">
843
+ <div class="exp-h"><span class="num">${n}</span><b>${doc ? '朗读音频' : '英文音频'}</b><span class="faint">${doc ? '文档转好的音频,存到本地' : '换一种声音的版本,存到本地'}</span></div>
844
+ <div class="row">${doc ? docAudioButtons(l) : TRACK_ORDER.filter(x => x === 'full' || S.cur.tracks[x]).map(x => {
845
+ const m = x === 'full' ? { duration: l.duration } : S.cur.tracks[x]
846
+ return `<a class="btn" href="audio?id=${id}&track=${x}&download=1" download>${ico('down')}${esc(trackLabel(x))} · ${fmt(m.duration)}</a>`
847
+ }).join('')}</div>
848
+ ${doc ? '' : '<div class="faint" style="margin-top:4px">「去空白」「朗读」这些版本在「概览 → 声音」里生成。</div>'}
849
+ </div>`
850
+ const textCard = n => `<div class="card">
851
+ <div class="exp-h"><span class="num">${n}</span><b>带时间的字幕</b><span class="faint">每句英文、中文对译和它的起止时间</span></div>
852
+ <div class="form" style="margin-top:4px">
853
+ <label>时间按</label><div class="chips">${alignChips('txalign')}</div>
854
+ <label>下载</label><div class="row wrap">${TEXT_FORMATS.map(([f, name]) => `<a class="btn" data-fmt="${f}" href="export?id=${id}&format=${f}&align=${align}" download>${name}</a>`).join('')}</div>
855
+ </div>
856
+ <div class="faint" style="margin-top:4px">想自己录中文:下载「录音稿」,每句标了开始时间和可用时长。</div>
857
+ </div>`
858
+ const steps = doc ? ['文档', '朗读音频', '带时间的字幕', '中文配音'] : ['英文音频', '带时间的字幕', '中文配音']
859
+ return `<div id="zhCard">
860
+ <div class="pipe">${steps.map((s, k) => `${k ? '<span class="arr">→</span>' : ''}<b>${s}</b>`).join('')}<span>每一步的成品都能下载到本地</span></div>
861
+ ${doc ? audioCard(1) + textCard(2) : textCard(1)}
862
+ <div class="card">
863
+ <div class="exp-h"><span class="num">${doc ? 3 : 2}</span><b>中文配音</b><span class="faint">和英文同一时间的中文音频</span></div>
800
864
  <div class="faint">只有中文、没有背景音,每句中文从对应英文开始的时刻开始说;比空档长的句子会读快一点,不会挤到下一句。</div>
801
- ${meta ? `<div class="row" style="margin-top:8px"><span class="badge ok">已生成</span><span class="faint grow">${esc(meta.voiceLabel || meta.voice)} · ${meta.align === 'clean' ? '对齐原声·去空白' : '对齐原音频'} · ${fmt(meta.duration)}${meta.squeezed ? ` · ${meta.squeezed} 句读快了一点` : ''}</span></div>
865
+ ${meta ? `<div class="row nowrap" style="margin-top:6px"><span class="badge ok">已生成</span><span class="faint grow ellipsis">${esc(meta.voiceLabel || meta.voice)} · ${meta.align === 'clean' ? '对齐原声·去空白' : '对齐原音频'} · ${fmt(meta.duration)}${meta.squeezed ? ` · ${meta.squeezed} 句读快了一点` : ''}</span></div>
802
866
  <div class="row" style="margin-top:6px">
803
867
  <button class="btn" id="zhPlay">▶ 试听</button>
804
- <a class="btn primary" href="audio?id=${encodeURIComponent(l.id)}&track=zh&download=1" download>下载 MP3</a>
805
- ${meta.flac ? `<a class="btn primary" href="audio?id=${encodeURIComponent(l.id)}&track=zh&fmt=flac&download=1" download>下载 FLAC(无损)</a>` : '<span class="faint">要 FLAC:重新生成一次</span>'}
868
+ <a class="btn primary" href="audio?id=${id}&track=zh&download=1" download>${ico('down')}MP3</a>
869
+ ${meta.flac ? `<a class="btn primary" href="audio?id=${id}&track=zh&fmt=flac&download=1" download>${ico('down')}FLAC(无损)</a>` : '<span class="faint">要 FLAC:重新生成一次</span>'}
806
870
  </div>` : ''}
807
871
  ${job ? `<div class="small muted" style="margin-top:8px">${job.state === 'queued' ? '排队中' : esc(job.stage)}${job.total ? ` ${job.done}/${job.total}` : ''}…</div>` : canMake ? `
808
872
  <div class="form">
809
873
  <label>语音</label><div class="chips">
810
- <button class="chip ${engine === 'kokoro' ? 'on' : ''}" data-zhengine="kokoro">本地 Kokoro(100 种,离线)</button>
811
- <button class="chip ${engine === 'edge' ? 'on' : ''}" data-zhengine="edge">微软 Edge(14 种,要联网)</button>
874
+ <button class="chip ${engine === 'kokoro' ? 'on' : ''}" data-zhengine="kokoro" title="100 种声音,离线">Kokoro(离线)</button>
875
+ <button class="chip ${engine === 'edge' ? 'on' : ''}" data-zhengine="edge" title="14 种声音,要联网">微软 Edge(联网)</button>
876
+ ${V.ming && V.ming.supported ? `<button class="chip ${engine === 'ming' ? 'on' : ''}" data-zhengine="ming" title="${V.ming.voices.length} 种声音,Apple 芯片上离线合成">Mac 本机 Ming</button>` : ''}
812
877
  </div>
813
- ${edgeMissing ? `<label></label><div><div class="faint">Edge 语音要先装一个小组件(edge-tts,约 15 MB,装在插件自己的目录里,需要电脑上有 Python 3)。用的时候中文会发到微软的服务器;这不是微软公开的正式接口,以后可能失效。</div>
878
+ ${mingMissing ? `<label></label><div>${mingSetup(V.ming)}</div>` : edgeMissing ? `<label></label><div><div class="faint">Edge 语音要先装一个小组件(edge-tts,约 15 MB,装在插件自己的目录里,需要电脑上有 Python 3)。用的时候中文会发到微软的服务器;这不是微软公开的正式接口,以后可能失效。</div>
814
879
  <button class="btn" id="edgeInstall" style="margin-top:6px">安装 Edge 语音组件</button></div>` : `
815
880
  <label>音色</label><div class="row"><select id="zhVoice">${group('f', '女声')}${group('m', '男声')}</select><button class="btn ghost" id="zhTry">试听这个音色</button></div>
816
881
  <label>语速</label><div class="chips">${[0.9, 1, 1.1].map(x => `<button class="chip ${x === speed ? 'on' : ''}" data-zhspeed="${x}">${x === 1 ? '正常' : x < 1 ? '慢一点' : '快一点'}</button>`).join('')}</div>
817
882
  <label>时间对齐</label><div class="chips">${alignChips('zhalign')}</div>`}
818
883
  </div>
819
- ${edgeMissing ? '' : `<div class="faint" style="margin:6px 0 10px">对齐原音频:总长和原音频一样,可以和原音频对着听;对齐去空白:和「原声·去空白」一句对一句。${engine === 'kokoro' && !(V.zh && V.zh.ready) ? '第一次会先下载约 147 MB 中文语音模型。' : ''}${engine === 'edge' && V.edge && V.edge.version ? ` Edge 组件 ${esc(V.edge.version)},<a href="#" class="link" id="edgeUpdate">更新</a>。` : ''}</div>
884
+ ${missing ? '' : `<div class="faint" style="margin:6px 0 8px">对齐原音频:总长和原音频一样,可以和原音频对着听;对齐去空白:和「原声·去空白」一句对一句。${engine === 'kokoro' && !(V.zh && V.zh.ready) ? '第一次会先下载约 147 MB 中文语音模型。' : ''}${engine === 'ming' ? ` 本机 Ming 每句约 2 秒,一课要几分钟。${V.ming.shared ? '用的是 dsh-her 装好的本机 Ming。' : V.ming.own ? '<a href="#" class="link" id="mingRemove">卸载本机 Ming</a>(空出约 2 GB)。' : ''}` : ''}${engine === 'edge' && V.edge && V.edge.version ? ` Edge 组件 ${esc(V.edge.version)},<a href="#" class="link" id="edgeUpdate">更新</a>。` : ''}</div>
820
885
  <button class="btn ${meta ? 'ghost' : 'primary'}" id="zhMake">${meta ? '换音色重新生成' : '生成中文配音'}</button>`}` : ''}
821
- <div class="sub-h" style="margin-top:16px">文字(字幕 / 歌词 / 录音稿)</div>
822
- <div class="faint">英文原文和中文对译,带时间。想自己录中文配音,就下载「录音稿」:每句标了开始时间和可用时长。</div>
823
- <div class="form">
824
- <label>时间按</label><div class="chips">${alignChips('txalign')}</div>
825
- <label>下载</label><div class="row wrap">${TEXT_FORMATS.map(([f, name]) => `<a class="btn ghost" data-fmt="${f}" href="export?id=${encodeURIComponent(l.id)}&format=${f}&align=${align}" download>${name}</a>`).join('')}</div>
826
886
  </div>
887
+ ${doc ? '' : audioCard(3)}
827
888
  </div>`
828
889
  }
829
890
 
830
891
  function bindZh(root, l) {
831
892
  const card = root.querySelector('#zhCard')
832
893
  if (!card) return
833
- const engine = () => store.get('zhEngine', (S.cur.tracks.zh && S.cur.tracks.zh.engine) || 'kokoro')
894
+ const engine = zhEngineNow
834
895
  let speed = (S.cur.tracks.zh && S.cur.tracks.zh.speed) || store.get('zhSpeed', 1)
835
896
  const rerender = () => { const box = document.createElement('div'); box.innerHTML = zhCard(l); card.replaceWith(box.firstElementChild); bindZh(root, l) }
836
897
  card.querySelectorAll('[data-zhengine]').forEach(b => { b.onclick = () => { store.set('zhEngine', b.dataset.zhengine); stopZhAudio(); rerender() } })
@@ -842,7 +903,28 @@ function bindZh(root, l) {
842
903
  b.onclick = () => { store.set('zhAlign', b.dataset.zhalign || b.dataset.txalign); rerender() }
843
904
  })
844
905
  const voiceSel = card.querySelector('#zhVoice')
845
- if (voiceSel) voiceSel.onchange = () => store.set(engine() === 'edge' ? 'zhVoiceEdge' : 'zhVoice', voiceSel.value)
906
+ if (voiceSel) voiceSel.onchange = () => store.set(ZH_VOICE_KEY[engine()], voiceSel.value)
907
+ // 本机 Ming:安装在后台跑,装的时候每 2 秒看一次进度
908
+ const mingInst = card.querySelector('#mingInstall')
909
+ if (mingInst) mingInst.onclick = async () => {
910
+ mingInst.disabled = true
911
+ try { await api('ming/install', {}); S.voices = await api('voices'); rerender() } catch (e) { toast(e.message); mingInst.disabled = false }
912
+ }
913
+ if (S.voices && S.voices.ming && S.voices.ming.installing) {
914
+ setTimeout(async () => {
915
+ if (!card.isConnected) return
916
+ try { S.voices = await api('voices') } catch { /* 下次再看 */ }
917
+ if (!card.isConnected) return
918
+ if (S.voices.ming && S.voices.ming.ready) toast('本机 Ming 装好了')
919
+ rerender()
920
+ }, 2000)
921
+ }
922
+ const mingRm = card.querySelector('#mingRemove')
923
+ if (mingRm) mingRm.onclick = async e => {
924
+ e.preventDefault()
925
+ if (!confirm('卸载本机 Ming?会删掉它的程序和声音模型(约 2 GB)。已经生成的中文配音不受影响。')) return
926
+ try { await api('ming/uninstall', {}); S.voices = await api('voices'); rerender() } catch (err) { toast(err.message) }
927
+ }
846
928
  const install = async (btn) => {
847
929
  btn.disabled = true
848
930
  const old = btn.textContent
@@ -865,7 +947,7 @@ function bindZh(root, l) {
865
947
  player.stop()
866
948
  tryBtn.disabled = true
867
949
  const first = engine() === 'kokoro' && !(S.voices && S.voices.zh && S.voices.zh.ready)
868
- tryBtn.textContent = first ? '第一次要下载中文语音模型,稍等…' : '正在生成试听…'
950
+ tryBtn.textContent = first ? '第一次要下载中文语音模型,稍等…' : engine() === 'ming' ? '正在加载本机 Ming,约 10 秒…' : '正在生成试听…'
869
951
  try {
870
952
  const r = await fetch('voice-sample', { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json', 'x-tingdong': '1' }, body: JSON.stringify({ engine: engine(), voice: voiceSel.value }) })
871
953
  if (!r.ok) throw new Error(((await r.json().catch(() => ({}))).error) || `试听失败(${r.status})`)