dsh-tingdong 0.3.0 → 0.4.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
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { stats } from './srs.js'
8
8
  import {
9
- $, api, app, clearMediaControls, curTrack, esc, fmt, fmtDur, getPos, keys, loadAudio, loadLesson, loop, nav, player,
9
+ $, api, app, clearMediaControls, curTrack, esc, fmt, fmtDur, getPos, isDoc, keys, loadAudio, loadLesson, loop, nav, player,
10
10
  playSentence, S, TRACK_ORDER, trackLabel, store, tm, toast, usable, useTrack, when,
11
11
  } from './core.js'
12
12
  import { paneDrill, paneText, paneWords } from './listen.js'
@@ -98,9 +98,11 @@ async function render(soft = false) {
98
98
  const r = route()
99
99
  $('#back').hidden = r.view === 'library'
100
100
  if (!soft) { keys.handler = null; loop.on = false; clearMediaControls() }
101
+ $('#manage').hidden = r.view !== 'library'
101
102
  if (r.view === 'library') {
102
103
  if (!soft) player.stop()
103
104
  $('#brand').textContent = '听懂 · 英文听力'
105
+ if (!soft) renderManage()
104
106
  rendered = 'library'
105
107
  renderLibrary()
106
108
  // 从课程页回来:进度变了,后台重新拉一次课程列表
@@ -135,36 +137,35 @@ async function render(soft = false) {
135
137
  function renderLibrary() {
136
138
  const st = S.status || {}
137
139
  const canImport = !S.status || st.ffmpeg
138
- const warn = []
139
- if (S.status && !st.models && canImport) warn.push('第一次导入会先下载语音识别模型(约 240 MB),之后都在本机识别。')
140
- if (S.status && !st.llm && canImport) warn.push('没找到可用的大模型:只能识别英文,翻译讲解要等配置好模型后点「补全翻译」。')
141
-
142
140
  const groups = new Map()
143
141
  for (const l of S.lessons) {
144
142
  const g = l.group || ''
145
143
  if (!groups.has(g)) groups.set(g, [])
146
144
  groups.get(g).push(l)
147
145
  }
146
+ const type = store.get('newType', '')
148
147
 
149
148
  app.innerHTML = `
150
149
  <div id="todayCard">${todayCard(S.today)}</div>
151
- ${warn.length ? `<div class="card small">${warn.map(w => `<div>⚠ ${w}</div>`).join('')}</div>` : ''}
152
- ${S.lessons.length ? '' : `<div class="empty">还没有课。导入一个英文 MP3:本机识别出每一句英文,AI 翻译讲解,然后一句一句练到一听就懂。</div>`}
150
+ ${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>
161
+ </div>
162
+ <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>
165
+ ${S.lessons.length ? '' : `<div class="empty">还没有课。上面选一种:导入英文音频,或导入英文文档。</div>`}
153
166
  ${[...groups].map(([g, ls]) => `
154
167
  ${g ? `<div class="group-title">${esc(g)}</div>` : (groups.size > 1 ? '<div class="group-title">其他</div>' : '')}
155
168
  ${ls.map(lessonCard).join('')}`).join('')}
156
- ${canImport ? `<div class="card">
157
- <h3>导入英文音频</h3>
158
- <div class="drop" id="drop">
159
- <div class="row">
160
- <input type="text" class="grow" id="path" placeholder="粘贴 MP3 文件或文件夹的路径" style="flex:1 1 200px">
161
- <button class="btn primary" id="importBtn">导入</button>
162
- </div>
163
- <div class="faint" style="margin-top:8px">也可以把音频拖到这里,或 <a href="#" class="link" id="pick">选择文件</a>。文件夹会把里面的每个音频各做成一课。</div>
164
- <input type="file" id="file" accept="audio/*,.mp3,.m4a,.m4b,.wav,.flac,.ogg,.opus,.aac" multiple hidden>
165
- </div>
166
- <div id="jobs"></div>
167
- </div>` : `<div class="card small muted">这里只用来学习:在电脑上导入、做好课,再用电脑上的「发送到 VPS」发过来。<div id="jobs"></div></div>`}
168
169
  <div id="vpsCard"></div>
169
170
  `
170
171
  renderJobs()
@@ -172,7 +173,41 @@ function renderLibrary() {
172
173
  renderVps()
173
174
  app.querySelectorAll('.lesson').forEach(el => { el.onclick = () => go(`#/l/${el.dataset.id}`) })
174
175
  if (!canImport) return
176
+ app.querySelectorAll('[data-new]').forEach(b => {
177
+ b.onclick = () => {
178
+ const t = store.get('newType', '') === b.dataset.new ? '' : b.dataset.new // 再点一次收起
179
+ store.set('newType', t)
180
+ app.querySelectorAll('[data-new]').forEach(x => x.classList.toggle('on', x.dataset.new === t))
181
+ renderNewPanel(t)
182
+ }
183
+ })
184
+ renderNewPanel(type)
185
+ }
186
+
187
+ /** 新建课程:展开所选的那种 */
188
+ function renderNewPanel(type) {
189
+ const box = $('#newPanel')
190
+ if (!box) return
191
+ if (type === 'audio') return newAudioPanel(box)
192
+ if (type === 'doc') return newDocPanel(box)
193
+ box.innerHTML = ''
194
+ }
175
195
 
196
+ function newAudioPanel(box) {
197
+ const st = S.status || {}
198
+ 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">
205
+ <button class="btn primary" id="importBtn">导入</button>
206
+ </div>
207
+ <div class="faint" style="margin-top:8px">也可以把音频拖到这里,或 <a href="#" class="link" id="pick">选择文件</a>。文件夹会把里面的每个音频各做成一课。</div>
208
+ <input type="file" id="file" accept="audio/*,.mp3,.m4a,.m4b,.wav,.flac,.ogg,.opus,.aac" multiple hidden>
209
+ </div>
210
+ </div>`
176
211
  const input = $('#path')
177
212
  input.value = store.get('lastPath', '')
178
213
  const doImport = async () => {
@@ -198,6 +233,104 @@ function renderLibrary() {
198
233
  drop.ondrop = e => { e.preventDefault(); drop.classList.remove('over'); uploadFiles([...e.dataTransfer.files]) }
199
234
  }
200
235
 
236
+ let docHelp = null
237
+ async function newDocPanel(box) {
238
+ box.innerHTML = '<div class="card"><div class="faint">…</div></div>'
239
+ try {
240
+ if (!docHelp) docHelp = await api('doc-help')
241
+ if (!S.voices) S.voices = await api('voices')
242
+ } catch (e) { box.innerHTML = `<div class="card small" style="color:var(--bad)">${esc(e.message)}</div>`; return }
243
+ const V = S.voices
244
+ const edgeReady = V.edge && V.edge.ready
245
+ const engine = store.get('docEngine', 'edge')
246
+ const list = engine === 'edge' ? ((V.edge && V.edge.en) || []) : ((V.kokoro && V.kokoro.voices) || []).map(v => ({ ...v, gender: v.id[1] === 'm' ? 'm' : 'f' }))
247
+ const voice = store.get(engine === 'edge' ? 'docVoiceEdge' : 'docVoiceKokoro', engine === 'edge' ? 'en-US-AndrewNeural' : 'am_michael')
248
+ const speed = store.get('docSpeed', 1)
249
+ const group = (g, name) => `<optgroup label="${name}">${list.filter(x => x.gender === g).map(x => `<option value="${esc(x.id)}" ${x.id === voice ? 'selected' : ''}>${esc(x.label)}</option>`).join('')}</optgroup>`
250
+ box.innerHTML = `<div class="card doc-new">
251
+ <h3>导入英文文档</h3>
252
+ <div class="step"><span class="num">1</span><div class="grow">
253
+ <b>先让 AI 把材料整理好</b>
254
+ <div class="faint">PDF、电子书、网页都行:把下面这段话连同材料一起发给 ChatGPT、DeepSeek、Kimi 等,它会整理成能直接导入的英文文字(只留正文、修好断行、按章节分好)。扫描版 PDF 也可以,让它先识别文字。</div>
255
+ ${S.status && S.status.host === 'dsh' ? '<div class="faint" style="margin-top:4px">💡 更省事:在 DSH 的对话里把 PDF 发给 AI,说「按听懂的格式整理后导入听懂」,它会整理好直接导入,不用复制粘贴。</div>' : ''}
256
+ <details style="margin-top:6px"><summary class="link">查看整理要求</summary><pre class="prompt">${esc(docHelp.prompt)}</pre></details>
257
+ <button class="btn" id="copyPrompt" style="margin-top:6px">复制整理要求</button>
258
+ </div></div>
259
+ <div class="step"><span class="num">2</span><div class="grow">
260
+ <b>把整理好的文字粘贴进来</b>
261
+ <textarea id="docText" rows="8" placeholder="# 第一章标题&#10;&#10;First paragraph of the chapter...&#10;&#10;Second paragraph...&#10;&#10;# 第二章标题&#10;&#10;..."></textarea>
262
+ <div class="row" style="margin-top:4px">
263
+ <span class="faint grow" id="docCount">每个「# 标题」开始一课;没有标题就整篇一课</span>
264
+ <a href="#" class="link" id="docPick">或选择 .txt / .md 文件</a>
265
+ <input type="file" id="docFile" accept=".txt,.md,.markdown,text/plain,text/markdown" hidden>
266
+ </div>
267
+ <input type="text" id="docTitle" placeholder="文档标题(可不填,用第一章的标题)" style="margin-top:6px">
268
+ </div></div>
269
+ <div class="step"><span class="num">3</span><div class="grow">
270
+ <b>选朗读的声音</b>
271
+ <div class="chips" style="margin-top:4px">
272
+ <button class="chip ${engine === 'edge' ? 'on' : ''}" data-docengine="edge">微软 Edge(最自然,要联网)</button>
273
+ <button class="chip ${engine === 'kokoro' ? 'on' : ''}" data-docengine="kokoro">Kokoro(离线)</button>
274
+ </div>
275
+ ${engine === 'edge' && !edgeReady ? `<div class="faint">Edge 语音要先装一个小组件(edge-tts,约 15 MB,装在插件自己的目录里,需要电脑上有 Python 3)。文字会发到微软的服务器朗读。</div>
276
+ <button class="btn" id="docEdgeInstall" style="margin-top:6px">安装 Edge 语音组件</button>` : `
277
+ <div class="row" style="margin-top:4px"><select id="docVoice">${group('m', '男声')}${group('f', '女声')}</select></div>
278
+ <div class="chips" style="margin-top:6px">${[0.85, 1, 1.15].map(x => `<button class="chip ${x === speed ? 'on' : ''}" data-docspeed="${x}">${x === 1 ? '正常语速' : x < 1 ? '慢一点' : '快一点'}</button>`).join('')}</div>`}
279
+ </div></div>
280
+ <button class="btn primary big" id="docGo" ${engine === 'edge' && !edgeReady ? 'disabled' : ''}>开始导入</button>
281
+ </div>`
282
+ const ta = $('#docText')
283
+ ta.value = store.get('docDraft', '')
284
+ const count = () => {
285
+ const t = ta.value
286
+ const chapters = (t.match(/^#\s+\S/gm) || []).length
287
+ const words = (t.match(/[A-Za-z']+/g) || []).length
288
+ $('#docCount').textContent = t.trim() ? `${words.toLocaleString()} 个英文单词 · ${chapters ? `${chapters} 章(每章一课)` : '没有「# 标题」:整篇一课'}` : '每个「# 标题」开始一课;没有标题就整篇一课'
289
+ }
290
+ ta.oninput = () => { count(); try { store.set('docDraft', ta.value.slice(0, 200000)) } catch { /* 太大就不存草稿 */ } }
291
+ count()
292
+ $('#copyPrompt').onclick = () => copy(docHelp.prompt)
293
+ $('#docPick').onclick = e => { e.preventDefault(); $('#docFile').click() }
294
+ $('#docFile').onchange = async e => {
295
+ const f = e.target.files[0]
296
+ if (!f) return
297
+ ta.value = await f.text()
298
+ if (!$('#docTitle').value) $('#docTitle').value = f.name.replace(/\.(txt|md|markdown)$/i, '')
299
+ ta.oninput()
300
+ }
301
+ box.querySelectorAll('[data-docengine]').forEach(b => { b.onclick = () => { store.set('docEngine', b.dataset.docengine); newDocPanel(box) } })
302
+ box.querySelectorAll('[data-docspeed]').forEach(b => {
303
+ b.onclick = () => { store.set('docSpeed', Number(b.dataset.docspeed)); box.querySelectorAll('[data-docspeed]').forEach(x => x.classList.toggle('on', x === b)) }
304
+ })
305
+ const vs = $('#docVoice')
306
+ if (vs) vs.onchange = () => store.set(engine === 'edge' ? 'docVoiceEdge' : 'docVoiceKokoro', vs.value)
307
+ const inst = $('#docEdgeInstall')
308
+ if (inst) inst.onclick = async () => {
309
+ inst.disabled = true
310
+ inst.textContent = '正在安装…(第一次要一两分钟)'
311
+ try {
312
+ const r = await api('edge/install', {})
313
+ toast(r.ready ? `Edge 语音组件已装好(${r.version})` : '安装没成功')
314
+ S.voices = null
315
+ newDocPanel(box)
316
+ } catch (e) { toast(e.message); inst.disabled = false; inst.textContent = '安装 Edge 语音组件' }
317
+ }
318
+ $('#docGo').onclick = async () => {
319
+ const btn = $('#docGo')
320
+ btn.disabled = true
321
+ try {
322
+ const r = await api('import-doc', { text: ta.value, title: $('#docTitle').value.trim(), engine, voice: vs ? vs.value : undefined, speed: store.get('docSpeed', 1) })
323
+ const fresh = r.items.filter(x => !x.existed)
324
+ toast(fresh.length ? `《${r.title}》分成 ${r.items.length} 课,正在朗读和翻译` : '这份文档导入过了')
325
+ store.set('docDraft', '')
326
+ ta.value = ''
327
+ count()
328
+ pollJobsNow()
329
+ } catch (e) { toast(e.message) }
330
+ btn.disabled = false
331
+ }
332
+ }
333
+
201
334
  // —— 今日学习卡片 ——
202
335
  async function loadToday() {
203
336
  try {
@@ -241,7 +374,7 @@ function lessonCard(l) {
241
374
  error: '<span class="badge err">出错</span>',
242
375
  }[l.status] || (s.due ? `<span class="badge">待复习 ${s.due}</span>` : (s.total && s.mastered === s.total ? '<span class="badge ok">已掌握</span>' : ''))
243
376
  return `<div class="card lesson" data-id="${esc(l.id)}">
244
- <div class="row"><div class="grow"><div class="t">${esc(l.titleZh || l.title)}</div>${l.titleZh ? `<div class="en">${esc(l.title)}</div>` : ''}</div>${badge}</div>
377
+ <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>
245
378
  <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>
246
379
  <div class="bar two"><i class="seen" style="width:${seenPct}%"></i><i style="width:${pct}%"></i></div>
247
380
  ${l.error ? `<div class="small" style="color:var(--bad);margin-top:6px">${esc(l.error)}</div>` : ''}
@@ -359,7 +492,7 @@ async function copy(text) {
359
492
 
360
493
  // ———————————————————— 一节课 ————————————————————
361
494
 
362
- const TABS = [['overview', '概览'], ['study', '精听'], ['review', '复习'], ['text', '连续听'], ['drill', '磨耳朵'], ['words', '词汇']]
495
+ const TABS = [['overview', '概览'], ['study', '精听'], ['review', '复习'], ['text', '连续听'], ['drill', '磨耳朵'], ['words', '词汇'], ['export', '导出']]
363
496
 
364
497
  function renderLesson(tab) {
365
498
  const l = S.cur.lesson
@@ -381,7 +514,7 @@ function renderLesson(tab) {
381
514
  bindManage(pane, l)
382
515
  return
383
516
  }
384
- ;({ overview: paneOverview, study: paneStudy, review: paneReview, text: paneText, drill: paneDrill, words: paneWords }[tab] || paneOverview)(pane, l, st)
517
+ ;({ overview: paneOverview, study: paneStudy, review: paneReview, text: paneText, drill: paneDrill, words: paneWords, export: paneExport }[tab] || paneOverview)(pane, l, st)
385
518
  }
386
519
 
387
520
  /** 课程页上方的「声音」选择条 */
@@ -438,7 +571,7 @@ function paneOverview(pane, l, st) {
438
571
  pane.innerHTML = `
439
572
  <div class="card">
440
573
  ${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>`}
441
- <div class="faint" style="margin-top:2px">${fmtDur(l.duration)} · ${l.sentences.length} 句${l.model ? ` · 翻译:${esc(l.model)}` : ''}</div>
574
+ <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>
442
575
  ${l.summaryZh ? `<p class="summary">${esc(l.summaryZh)}</p>` : ''}
443
576
  ${l.note ? `<div class="small" style="color:var(--mid)">${esc(l.note)}</div>` : ''}
444
577
  ${l.status === 'error' ? `<div class="small" style="color:var(--bad)">${esc(l.error)}</div>` : ''}
@@ -460,7 +593,10 @@ function paneOverview(pane, l, st) {
460
593
  ${stars ? `<div class="faint" style="margin-top:6px">难句本:收藏了 ${stars} 句,在「复习」或「磨耳朵」里选「收藏」专门练。</div>` : ''}
461
594
  </div>
462
595
  ${voicesCard(l)}
463
- ${zhCard(l)}
596
+ <div class="card row">
597
+ <div class="grow"><b>导出</b><div class="faint">中文配音(MP3)、双语字幕、LRC 歌词、录音稿</div></div>
598
+ <button class="btn" id="goExport">去导出 ›</button>
599
+ </div>
464
600
  <div class="card">
465
601
  <h3>怎么练到「一听就懂」</h3>
466
602
  <ol class="steps">
@@ -474,7 +610,7 @@ function paneOverview(pane, l, st) {
474
610
  </div>
475
611
  <div class="card"><h3>管理</h3>${manageButtons(l)}</div>`
476
612
  bindVoices(pane, l)
477
- bindZh(pane, l)
613
+ $('#goExport').onclick = () => go(`#/l/${l.id}/export`)
478
614
  $('#goStudy').onclick = () => go(`#/l/${l.id}/study`)
479
615
  $('#goReview').onclick = () => go(`#/l/${l.id}/review`)
480
616
  $('#goDrill').onclick = () => go(`#/l/${l.id}/drill`)
@@ -499,6 +635,8 @@ function voicesCard(l) {
499
635
  const opts = (list, sel) => list.map(x => `<option value="${esc(x.id)}" ${x.id === sel ? 'selected' : ''}>${esc(x.label)}</option>`).join('')
500
636
  const speeds = sel => [0.8, 0.9, 1].map(x => `<option value="${x}" ${x === sel ? 'selected' : ''}>${x === 1 ? '正常语速' : x + ' 倍语速'}</option>`).join('')
501
637
  const row = name => {
638
+ // 文档课没有真实录音:「去空白」「压背景」没意义
639
+ if (isDoc() && (name === 'clean' || name === 'cleannr')) return ''
502
640
  const meta = name === 'full' ? { duration: l.duration } : S.cur.tracks[name]
503
641
  if (!meta && !canMake) return ''
504
642
  const job = busy(name)
@@ -513,7 +651,7 @@ function voicesCard(l) {
513
651
  return `<div class="track ${name === t ? 'on' : ''}">
514
652
  <div class="row"><b class="grow">${esc(trackLabel(name))}</b>
515
653
  ${meta ? `<span class="faint">${fmt(meta.duration)}${meta.voice ? ' · ' + esc(meta.voice) : ''}</span>` : '<span class="faint">未生成</span>'}</div>
516
- <div class="faint">${TRACK_NOTES[name]}${name === 'kokoro' && v.kokoro && !v.kokoro.ready && canMake ? ';第一次生成会下载约 103 MB 语音模型' : ''}</div>
654
+ <div class="faint">${name === 'full' && isDoc() ? '导入文档时 AI 朗读生成的音频' : TRACK_NOTES[name]}${name === 'kokoro' && v.kokoro && !v.kokoro.ready && canMake ? ';第一次生成会下载约 103 MB 语音模型' : ''}</div>
517
655
  ${stale ? '<div class="small" style="color:var(--mid)">句子改过了,这条声音对不上了,建议重新生成</div>' : ''}
518
656
  ${job ? `<div class="small muted" style="margin-top:4px">${job.state === 'queued' ? '排队中' : esc(job.stage)}${job.total ? ` ${job.done}/${job.total}` : ''}…</div>` : `
519
657
  <div class="row" style="margin-top:6px">
@@ -564,6 +702,12 @@ let zhAudio = null // 试听用的独立播放器,不打扰课程的播放器
564
702
  function stopZhAudio() { if (zhAudio) { zhAudio.pause(); zhAudio = null } }
565
703
  window.addEventListener('hashchange', stopZhAudio)
566
704
 
705
+ /** 「导出」页签 */
706
+ function paneExport(pane, l) {
707
+ pane.innerHTML = zhCard(l)
708
+ bindZh(pane, l)
709
+ }
710
+
567
711
  const TEXT_FORMATS = [['srt-bi', '双语字幕'], ['srt-en', '英文字幕'], ['srt-zh', '中文字幕'], ['lrc', '歌词 LRC'], ['script', '录音稿'], ['csv', '表格']]
568
712
 
569
713
  function zhCard(l) {
@@ -590,6 +734,7 @@ function zhCard(l) {
590
734
  <div class="row" style="margin-top:6px">
591
735
  <button class="btn" id="zhPlay">▶ 试听</button>
592
736
  <a class="btn primary" href="audio?id=${encodeURIComponent(l.id)}&track=zh&download=1" download>下载 MP3</a>
737
+ ${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>'}
593
738
  </div>` : ''}
594
739
  ${job ? `<div class="small muted" style="margin-top:8px">${job.state === 'queued' ? '排队中' : esc(job.stage)}${job.total ? ` ${job.done}/${job.total}` : ''}…</div>` : canMake ? `
595
740
  <div class="form">
@@ -687,6 +832,22 @@ function bindZh(root, l) {
687
832
  }
688
833
  }
689
834
 
835
+ // ———————————————————— 版本号(首页标题旁) ————————————————————
836
+ // 更新、卸载在 DSH 的「设置 → 听懂」里;这里只显示版本,有新版本时提醒一句。
837
+ let manageInfo = null
838
+ async function renderManage() {
839
+ const box = $('#manage')
840
+ if (!manageInfo) {
841
+ try { manageInfo = await api('update') } catch { manageInfo = null }
842
+ }
843
+ const m = manageInfo
844
+ if (!m || !m.managed) { box.hidden = true; return }
845
+ box.hidden = route().view !== 'library'
846
+ box.innerHTML = `<span class="faint ver">v${esc(m.running)}</span>${m.restartPending
847
+ ? `<span class="badge warn">v${esc(m.restartPending)} 已装好,重启 DSH 生效</span>`
848
+ : m.available ? `<span class="badge" title="在 DSH 的「设置 → 听懂」里点更新">有新版本 v${esc(m.latest)} · 设置 → 听懂 里更新</span>` : ''}`
849
+ }
850
+
690
851
  // ———————————————————— 启动 ————————————————————
691
852
 
692
853
  ;(async () => {