@huaqiu/dsh-tool-plugin-update 0.6.7 → 0.6.8

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.
@@ -162,122 +162,142 @@ function blockedReason(item: PluginUpdateItem, copy: UpdateCopy): string | undef
162
162
  return by.length > 0 ? by.join(', ') : undefined
163
163
  }
164
164
 
165
- /** One package card: glyph, name with its state tag, version line, end actions. */
165
+ /** Two-letter avatar initials for a card (CJK-safe). */
166
+ function avatarInitials(item: PluginUpdateItem): string {
167
+ const src =
168
+ typeof item.displayName === 'string' && item.displayName.trim().length > 0
169
+ ? item.displayName.trim()
170
+ : shortName(item.name)
171
+ if (/[⺀-鿿]/.test(src)) return src.slice(0, 2)
172
+ const words = src.split(/[\s_\-./@]+/).filter(Boolean)
173
+ if (words.length >= 2) return (words[0]![0]! + words[1]![0]!).toUpperCase()
174
+ return src.replace(/^@/, '').slice(0, 2).toUpperCase()
175
+ }
176
+
177
+ /** Shared per-card changelog toggle drawer (the per-plugin changes[] list). */
178
+ function changelogDrawer(item: PluginUpdateItem, copy: UpdateCopy, open: boolean): HTMLElement {
179
+ const drawer = el('div', 'hqpu-changelog-drawer')
180
+ drawer.style.display = open ? '' : 'none'
181
+ drawer.append(el('div', 'hqpu-changelog-drawer-title', copy.ui.changelogTitle))
182
+ const ul = el('ul')
183
+ for (const change of item.changes ?? []) {
184
+ if (typeof change === 'string' && change.length > 0) ul.append(el('li', '', change))
185
+ }
186
+ drawer.append(ul)
187
+ return drawer
188
+ }
189
+
190
+ /** One package card per the mockup: avatar initials, display name + package
191
+ * subtitle, old→new version row (new in emerald), a 日志 toggle expanding the
192
+ * inline changelog. Blocked/incompatible cards are dimmed with a rose
193
+ * 无法安装 badge and a reason line, and carry NO install button. */
166
194
  function packageCard(
167
195
  item: PluginUpdateItem,
168
196
  copy: UpdateCopy,
169
- installing: boolean,
170
- store: UpdateStoreLike,
197
+ open: boolean,
198
+ onToggle: () => void,
171
199
  ): HTMLLIElement {
172
- const card = el('li', 'hqpu-card')
200
+ const blocked = item.state === 'blocked'
201
+ const card = el('li', blocked ? 'hqpu-card hqpu-card--blocked' : 'hqpu-card')
173
202
  card.dataset.pluginName = item.name
174
203
  card.dataset.state = item.state
175
- // The full package name is the card's tooltip; the row shows the display name.
176
- card.title = `${item.name} · ${versionLine(item)}`
177
204
 
178
205
  const head = el('div', 'hqpu-card-head')
179
-
180
- const glyph = el('span', 'hqpu-card-icon')
181
- glyph.setAttribute('aria-hidden', 'true')
182
- glyph.innerHTML = PLUGIN_ICON(24)
206
+ const left = el('div')
207
+ const avatar = el('span', 'hqpu-avatar', avatarInitials(item))
208
+ avatar.setAttribute('aria-hidden', 'true')
183
209
 
184
210
  const main = el('div', 'hqpu-card-main')
185
- const titleRow = el('div', 'hqpu-title-row')
186
- // Prefer the user-facing display name from a v2 manifest; fall back to the
187
- // shortened package id (the shell's own naming rule).
188
211
  const titleText =
189
212
  typeof item.displayName === 'string' && item.displayName.trim().length > 0
190
213
  ? item.displayName.trim()
191
214
  : shortName(item.name)
192
- const title = el('span', 'hqpu-card-title', titleText)
193
- const tag = el('span', 'hqpu-tag')
194
- tag.dataset.tone = stateTone(item)
195
- tag.textContent = stateLabel(item, copy)
196
- titleRow.append(title, tag)
197
-
198
- // Version line, with the download size appended when the manifest knows it.
199
- const size = formatMb(item.sizeBytes ?? 0)
200
- const descText = size.length > 0 ? `${versionLine(item)} · ${size}` : versionLine(item)
201
- const desc = el('span', 'hqpu-card-desc', descText)
202
- main.append(titleRow, desc)
203
-
204
- // Optional one-line description from a v2 manifest.
205
- if (typeof item.description === 'string' && item.description.trim().length > 0) {
206
- main.append(el('span', 'hqpu-card-desc', item.description.trim()))
215
+ main.append(el('span', 'hqpu-card-title', titleText))
216
+ main.append(el('span', 'hqpu-card-sub', shortName(item.name)))
217
+
218
+ // old → new version row; the NEW version renders in emerald.
219
+ const ver = el('div', 'hqpu-version-row')
220
+ if (item.currentVersion !== null && item.currentVersion !== undefined) {
221
+ ver.append(el('span', 'hqpu-ver-old', item.currentVersion))
222
+ ver.append(el('span', 'hqpu-ver-arrow', '→'))
207
223
  }
208
-
209
- head.append(glyph, main)
210
-
211
- const end = el('div', 'hqpu-card-end')
212
- if (item.state === 'update' || item.state === 'install') {
213
- const button = el('button', 'hqpu-btn hqpu-btn--sm hqpu-btn--primary')
214
- button.type = 'button'
215
- button.disabled = installing
216
- // Honest label: this installs the WHOLE offered set, not just one card.
217
- button.innerHTML = `${DOWNLOAD_ICON(13)}<span>${copy.installOne}</span>`
218
- button.title = copy.installOneTip
219
- button.addEventListener('click', () => {
220
- void runInstall(store, copy)
221
- })
222
- end.append(button)
224
+ const size = formatMb(item.sizeBytes ?? 0)
225
+ ver.append(
226
+ el('span', 'hqpu-ver-new', size.length > 0 ? `${item.availableVersion} (${size})` : item.availableVersion),
227
+ )
228
+ main.append(ver)
229
+ left.append(avatar, main)
230
+
231
+ const right = el('div', 'hqpu-card-end')
232
+ if (blocked) {
233
+ right.append(el('span', 'hqpu-blocked-badge', copy.ui.blockedBadge))
234
+ } else if (Array.isArray(item.changes) && item.changes.length > 0) {
235
+ const toggle = el('button', 'hqpu-log-toggle')
236
+ toggle.type = 'button'
237
+ toggle.dataset.open = open ? 'true' : 'false'
238
+ toggle.innerHTML = `${copy.ui.changelogBtn}<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6L7.29289 9.29289C7.68342 9.68342 8.31658 9.68342 8.70711 9.29289L12 6"/></svg>`
239
+ toggle.addEventListener('click', onToggle)
240
+ right.append(toggle)
223
241
  }
224
- head.append(end)
242
+ head.append(left, right)
225
243
  card.append(head)
226
244
 
227
- // Per-plugin change list (v2 manifest), behind a small expander.
228
- if (Array.isArray(item.changes) && item.changes.length > 0) {
229
- const expander = el('div', 'hqpu-changes')
230
- const toggle = el('button', 'hqpu-btn hqpu-btn--ghost hqpu-btn--sm', copy.release.changesTitle)
231
- toggle.type = 'button'
232
- const list = el('ul', 'hqpu-changes-list')
233
- list.style.display = 'none'
234
- for (const change of item.changes) {
235
- if (typeof change === 'string' && change.length > 0) {
236
- list.append(el('li', '', change))
237
- }
238
- }
239
- toggle.addEventListener('click', () => {
240
- list.style.display = list.style.display === 'none' ? '' : 'none'
241
- })
242
- expander.append(toggle, list)
243
- card.append(expander)
245
+ // Inline changelog drawer (actionable items only).
246
+ if (!blocked && Array.isArray(item.changes) && item.changes.length > 0) {
247
+ card.append(changelogDrawer(item, copy, open))
244
248
  }
245
249
 
246
- // A block reason belongs to a blocked offer only — render BOTH the mapped
247
- // code copy and the server's full reasons[]/notes[] (computed but, before
248
- // now, never shown).
249
- if (item.state === 'blocked') {
250
+ // Blocked reason line(s): mapped copy plus the server's full reasons/notes.
251
+ if (blocked) {
250
252
  const reason = blockedReason(item, copy)
251
- if (reason !== undefined) {
252
- card.append(el('p', 'hqpu-card-reason', reason))
253
- }
254
- for (const entry of [...(item.reasons ?? []), ...(item.notes ?? [])]) {
255
- if (typeof entry === 'string' && entry.length > 0) {
256
- card.append(el('p', 'hqpu-card-reason', entry))
257
- }
253
+ const entries = [
254
+ ...(reason !== undefined ? [reason] : []),
255
+ ...[...(item.reasons ?? []), ...(item.notes ?? [])].filter(
256
+ (e): e is string => typeof e === 'string' && e.length > 0,
257
+ ),
258
+ ]
259
+ for (const entry of entries) {
260
+ const line = el('p', 'hqpu-reason-line')
261
+ line.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 5V8.5M8 11.25h.01M14.5 8a6.5 6.5 0 11-13 0 6.5 6.5 0 0113 0Z"/></svg>`
262
+ line.append(document.createTextNode(entry))
263
+ card.append(line)
258
264
  }
259
265
  }
260
266
  return card
261
267
  }
262
268
 
263
- /** A section: group head with its count, then the cards. */
269
+ /** A section: head (title + count pill + expand-all toggle) then its cards. */
264
270
  function group(
265
271
  heading: string,
266
272
  items: PluginUpdateItem[],
267
273
  copy: UpdateCopy,
268
- installing: boolean,
269
- store: UpdateStoreLike,
274
+ changelogOpen: Set<string>,
275
+ allOpen: boolean,
276
+ onToggle: (name: string) => void,
277
+ onToggleAll: () => void,
270
278
  ): HTMLElement | null {
271
279
  if (items.length === 0) return null
272
280
  const section = el('section', 'hqpu-group')
273
281
  section.dataset.group = heading
274
282
 
275
- const head = el('div', 'hqpu-group-head')
276
- head.append(el('h3', 'hqpu-group-title', heading))
277
- head.append(el('span', 'hqpu-group-count', copy.groupCount(items.length)))
283
+ const head = el('div', 'hqpu-section-head')
284
+ const titleWrap = el('div', 'hqpu-section-title')
285
+ titleWrap.append(el('span', '', heading))
286
+ titleWrap.append(el('span', 'hqpu-count-pill', String(items.length)))
287
+ head.append(titleWrap)
288
+ // Expand-all only makes sense over actionable, changelog-bearing cards.
289
+ const actionable = items.some((i) => i.state !== 'blocked' && Array.isArray(i.changes) && i.changes.length > 0)
290
+ if (actionable) {
291
+ const all = el('button', 'hqpu-all-changelog', copy.ui.allChangelog)
292
+ all.type = 'button'
293
+ all.addEventListener('click', onToggleAll)
294
+ head.append(all)
295
+ }
278
296
 
279
297
  const list = el('ul', 'hqpu-cards')
280
- for (const item of items) list.append(packageCard(item, copy, installing, store))
298
+ for (const item of items) {
299
+ list.append(packageCard(item, copy, allOpen || changelogOpen.has(item.name), () => onToggle(item.name)))
300
+ }
281
301
 
282
302
  section.append(head, list)
283
303
  return section
@@ -342,6 +362,19 @@ function formatEta(seconds: number | undefined): string | null {
342
362
  return `${Math.floor(minutes / 60)} h ${minutes % 60} min`
343
363
  }
344
364
 
365
+ /** "上次检查: N 分钟前" — relative time from an ISO timestamp. */
366
+ function relativeAgo(iso: string | null | undefined, copy: UpdateCopy): string | null {
367
+ if (typeof iso !== 'string' || iso.length === 0) return null
368
+ const then = new Date(iso).getTime()
369
+ if (!Number.isFinite(then)) return null
370
+ const diffMin = Math.round((Date.now() - then) / 60_000)
371
+ if (diffMin < 1) return copy.ui.agoJust
372
+ if (diffMin < 60) return copy.ui.agoMinutes(diffMin)
373
+ const hours = Math.round(diffMin / 60)
374
+ if (hours < 24) return copy.ui.agoHours(hours)
375
+ return copy.ui.agoDays(Math.round(hours / 24))
376
+ }
377
+
345
378
  function jobKindLabel(job: UpdateJob, copy: UpdateCopy): string {
346
379
  return job.kind === 'runtime' ? copy.job.kindRuntime : copy.job.kindPluginSet
347
380
  }
@@ -377,41 +410,57 @@ function stepsList(job: UpdateJob, copy: UpdateCopy): HTMLElement | null {
377
410
  return wrap
378
411
  }
379
412
 
413
+ /** Map a job phase onto the 4-step indicator index (下载/校验/替换/重启). */
414
+ function stepIndex(phase: UpdateJob['phase']): number {
415
+ switch (phase) {
416
+ case 'downloading':
417
+ return 0
418
+ case 'verifying':
419
+ case 'resolving':
420
+ return 1
421
+ case 'staging':
422
+ case 'activating':
423
+ case 'awaiting-host':
424
+ return 2
425
+ case 'restarting':
426
+ case 'verifying-after-restart':
427
+ return 3
428
+ case 'queued':
429
+ return -1
430
+ default:
431
+ return -1
432
+ }
433
+ }
434
+
380
435
  /**
381
- * The ACTIVE-JOB view: type + target, previous→target, current phase, a
382
- * determinate bar only when a real percentage exists, bytes/speed/ETA when the
383
- * server publishes them, and what happens next. Closing the panel never
384
- * cancels the job — say so.
436
+ * The ACTIVE-JOB banner per the mockup: pulsing brand dot + job title with the
437
+ * target version, mono percentage, a gradient bar only when `percent != null`,
438
+ * phase + speed line ("3.1 MB/s (286MB / 450MB)"), and a 4-step indicator row
439
+ * (done=emerald, active=pulsing brand, upcoming=muted).
385
440
  */
386
441
  function activeJobView(job: UpdateJob, copy: UpdateCopy): HTMLElement {
387
442
  const section = el('section', 'hqpu-group')
388
443
  section.dataset.group = 'active-job'
389
444
 
390
- const head = el('div', 'hqpu-group-head')
391
- const tag = el('span', 'hqpu-tag')
392
- tag.dataset.tone = 'info'
393
- tag.textContent = jobKindLabel(job, copy)
394
- head.append(el('h3', 'hqpu-group-title', `${jobKindLabel(job, copy)} → ${job.targetVersion}`), tag)
395
- section.append(head)
396
-
397
- const card = el('div', 'hqpu-job')
398
-
399
- const phaseLine = el('p', 'hqpu-status hqpu-status--dot')
400
- phaseLine.append(el('span', 'hqpu-spinner'))
401
- const transition =
402
- job.previousVersion !== null && job.previousVersion.length > 0
403
- ? `${job.previousVersion} → ${job.targetVersion} · ${phaseLabel(job, copy)}`
404
- : `${job.targetVersion} · ${phaseLabel(job, copy)}`
405
- phaseLine.append(el('span', '', transition))
406
- card.append(phaseLine)
407
-
408
- if (typeof job.message === 'string' && job.message.length > 0) {
409
- card.append(el('p', 'hqpu-status', job.message))
445
+ const card = el('div', 'hqpu-active')
446
+
447
+ // Top row: pulse dot + title, mono percent.
448
+ const top = el('div', 'hqpu-active-top')
449
+ const titleWrap = el('div', 'hqpu-active-title')
450
+ const pulse = el('span', 'hqpu-pulse-dot')
451
+ pulse.append(el('span', 'hqpu-pulse-ring'))
452
+ const title =
453
+ job.kind === 'runtime'
454
+ ? copy.ui.jobRuntimeTitle(job.targetVersion)
455
+ : copy.ui.jobPluginTitle(job.targetVersion)
456
+ titleWrap.append(pulse, el('span', '', title))
457
+ const pct = job.progress.percent
458
+ if (pct !== null) {
459
+ top.append(el('span', 'hqpu-active-percent', `${Math.round(pct)}%`))
410
460
  }
461
+ card.append(top)
411
462
 
412
- // Determinate bar only when the server gives a real percentage — never show
413
- // 100% before the job is terminal.
414
- const pct = job.progress.percent
463
+ // Gradient bar — only when the server publishes a real percentage.
415
464
  if (pct !== null) {
416
465
  const bar = el('div', 'hqpu-progress')
417
466
  const fill = el('div', 'hqpu-progress-fill')
@@ -420,23 +469,59 @@ function activeJobView(job: UpdateJob, copy: UpdateCopy): HTMLElement {
420
469
  card.append(bar)
421
470
  }
422
471
 
423
- const bits: string[] = []
424
- if (pct !== null) bits.push(`${Math.round(pct)}%`)
472
+ // Phase text + telemetry line: "3.1 MB/s (286MB / 450MB)".
473
+ const meta = el('div', 'hqpu-active-meta')
474
+ const phaseText =
475
+ typeof job.message === 'string' && job.message.length > 0 ? job.message : phaseLabel(job, copy)
476
+ meta.append(el('span', '', phaseText))
477
+ const speed = formatBytes(job.progress.bytesPerSecond)
425
478
  const done = formatBytes(job.progress.bytesDone)
426
479
  const total = formatBytes(job.progress.bytesTotal)
427
- if (done !== null && total !== null) bits.push(copy.job.bytes(done, total))
428
- const speed = formatBytes(job.progress.bytesPerSecond)
429
- if (speed !== null) bits.push(copy.job.speed(speed))
430
- const eta = formatEta(job.progress.etaSeconds)
431
- if (eta !== null) bits.push(copy.job.eta(eta))
432
- if (bits.length > 0) card.append(el('p', 'hqpu-status hqpu-job-meta', bits.join(' · ')))
433
-
434
- const steps = stepsList(job, copy)
435
- if (steps !== null) card.append(steps)
480
+ const telemetry =
481
+ speed !== null && done !== null && total !== null
482
+ ? `${speed}/s (${done} / ${total})`
483
+ : speed !== null
484
+ ? `${speed}/s`
485
+ : done !== null && total !== null
486
+ ? copy.job.bytes(done, total)
487
+ : ''
488
+ if (telemetry.length > 0) meta.append(el('span', '', telemetry))
489
+ card.append(meta)
490
+
491
+ // previous → target transition + what happens next (restart / elevation),
492
+ // and the honest "closing does not cancel" note.
493
+ const transition = el('div', 'hqpu-active-meta')
494
+ transition.append(
495
+ el(
496
+ 'span',
497
+ '',
498
+ job.previousVersion !== null && job.previousVersion.length > 0
499
+ ? `${job.previousVersion} → ${job.targetVersion}`
500
+ : job.targetVersion,
501
+ ),
502
+ )
503
+ transition.append(
504
+ el('span', '', job.kind === 'runtime' ? copy.job.willSwapRuntime : copy.job.willRestartDsh),
505
+ )
506
+ card.append(transition)
507
+ card.append(el('p', 'hqpu-job-meta', copy.job.closeNote))
508
+
509
+ // 4-step indicator: done=emerald, active=pulsing brand, upcoming=muted.
510
+ const current = stepIndex(job.phase)
511
+ const stepsRow = el('div', 'hqpu-job-steps4')
512
+ copy.ui.jobSteps.forEach((label: string, index: number) => {
513
+ const cls =
514
+ current === -1
515
+ ? 'hqpu-job-step--todo'
516
+ : index < current
517
+ ? 'hqpu-job-step--done'
518
+ : index === current
519
+ ? 'hqpu-job-step--active'
520
+ : 'hqpu-job-step--todo'
521
+ stepsRow.append(el('div', cls, label))
522
+ })
523
+ card.append(stepsRow)
436
524
 
437
- // What happens next — restart / runtime swap + elevation implications.
438
- card.append(el('p', 'hqpu-status', job.kind === 'runtime' ? copy.job.willSwapRuntime : copy.job.willRestartDsh))
439
- card.append(el('p', 'hqpu-status', copy.job.closeNote))
440
525
  section.append(card)
441
526
  return section
442
527
  }
@@ -785,6 +870,11 @@ function sourceEditor(
785
870
  const labels = sourceLabels(copy, kind)
786
871
  const block = el('div', `hqpu-source-editor hqpu-source-editor--${kind}`)
787
872
  if (!actions.sourceEditing) {
873
+ // Read mode: show the URL currently in effect (mono, muted), then edit.
874
+ const urlLine = el('p', 'hqpu-source-current-url', source.url)
875
+ urlLine.style.cssText =
876
+ 'margin:2px 0 6px;font-size:9px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:#a1a1aa;word-break:break-all;'
877
+ block.append(urlLine)
788
878
  block.append(el('p', 'hqpu-status hqpu-source-hint', labels.hint))
789
879
  const edit = el('button', 'hqpu-btn hqpu-btn--outline hqpu-btn--sm', labels.edit)
790
880
  edit.type = 'button'
@@ -870,172 +960,164 @@ function pluginSourceGroup(
870
960
  return section
871
961
  }
872
962
 
873
- /** The independent HQ Edge runtime report, as ruled fact rows. */
874
- function runtimeGroup(
963
+ /**
964
+ * The host's own report of the last runtime swap. Rolled-back / failed renders
965
+ * as an AMBER alert card (title, old→new body, errorCode chip, 忽略 dismiss);
966
+ * succeeded renders as a subtle green inline line — not a loud banner. The
967
+ * plugin has NO log-open affordance, so "查看诊断日志" is omitted (never faked).
968
+ */
969
+ function runtimeResultAlert(
875
970
  report: PluginUpdateReport,
876
971
  copy: UpdateCopy,
877
- actions: RuntimeActions,
878
- ): HTMLElement {
879
- const section = el('section', 'hqpu-group')
880
- section.dataset.group = 'runtime'
881
- const head = el('div', 'hqpu-group-head')
882
- head.append(el('h3', 'hqpu-group-title', copy.runtime.title))
883
- section.append(head)
884
-
885
- const facts = el('dl', 'hqpu-facts')
886
- const runtime = report.runtime
887
- // `runtime.latest` is a release ENTRY; only its version is ever displayed or
888
- // sent back as `targetVersion`.
889
- const latestVersion = runtimeLatestVersion(runtime)
972
+ dismissed: boolean,
973
+ onDismiss: () => void,
974
+ ): HTMLElement | null {
975
+ const lastResult = report.runtime.lastResult
976
+ if (lastResult === null || lastResult === undefined) return null
977
+
978
+ // Green inline confirmation — quiet by design.
979
+ if (lastResult.state === 'succeeded') {
980
+ const line = el('p', 'hqpu-alert-green')
981
+ line.dataset.role = 'runtime-success'
982
+ line.textContent = copy.ui.successInline
983
+ return line
984
+ }
985
+ if (dismissed) return null
890
986
 
891
- const row = (label: string, value: string, code = false): void => {
892
- const wrap = el('div')
893
- wrap.append(el('dt', '', label))
894
- const dd = el('dd')
895
- if (code) {
896
- const node = el('code', '', value)
897
- dd.append(node)
898
- } else {
899
- dd.textContent = value
900
- }
901
- wrap.append(dd)
902
- facts.append(wrap)
987
+ const section = el('section', 'hqpu-group')
988
+ section.dataset.group = 'runtime-alert'
989
+ const card = el('div', 'hqpu-alert hqpu-alert--amber')
990
+ card.innerHTML = WARNING_ICON(14)
991
+
992
+ const body = el('div')
993
+ const title = el('div', 'hqpu-alert-title')
994
+ title.textContent = lastResult.state === 'failed' ? copy.ui.failedTitle : copy.ui.rollbackTitle
995
+ body.append(title)
996
+
997
+ const oldVer = lastResult.installedVersion ?? lastResult.targetVersion ?? '—'
998
+ const code = typeof lastResult.errorCode === 'string' && lastResult.errorCode.length > 0
999
+ ? lastResult.errorCode
1000
+ : '—'
1001
+ const text =
1002
+ lastResult.state === 'failed'
1003
+ ? copy.ui.failedBody(oldVer, code)
1004
+ : copy.ui.rollbackBody(oldVer, code)
1005
+ // Build the body as text + an inline <code> chip for the error code.
1006
+ const bodyP = el('p', 'hqpu-alert-body')
1007
+ const textOnly =
1008
+ lastResult.state === 'failed'
1009
+ ? copy.ui.failedBody(oldVer, '')
1010
+ : copy.ui.rollbackBody(oldVer, '')
1011
+ // Strip the trailing "code" placeholder we appended; re-add as a chip.
1012
+ bodyP.append(document.createTextNode(textOnly.replace(/\s*$/, '')))
1013
+ if (typeof lastResult.errorCode === 'string' && lastResult.errorCode.length > 0) {
1014
+ bodyP.append(document.createTextNode(' '))
1015
+ const chip = el('code', '', lastResult.errorCode)
1016
+ bodyP.append(chip)
1017
+ }
1018
+ if (typeof lastResult.message === 'string' && lastResult.message.length > 0) {
1019
+ bodyP.append(el('br'), document.createTextNode(lastResult.message))
903
1020
  }
1021
+ body.append(bodyP)
904
1022
 
905
- // The source row: which manifest URL is in effect and where it came from.
906
- // Absent on an older hq-edge — then only the legacy notConfigured row shows.
907
- const source = normalizeSource(runtime.source)
1023
+ // Dismiss action (no log button — the plugin has no log-open affordance).
1024
+ const actions = el('div', 'hqpu-alert-actions')
1025
+ const dismiss = el('button', 'hqpu-alert-dismiss', copy.ui.dismiss)
1026
+ dismiss.type = 'button'
1027
+ dismiss.addEventListener('click', onDismiss)
1028
+ actions.append(dismiss)
1029
+ body.append(actions)
908
1030
 
909
- if (runtime.configured === false) {
910
- if (source !== null) {
911
- sourceFactRow(facts, source, copy)
912
- } else {
913
- row(copy.runtime.source, copy.runtime.notConfigured)
914
- }
915
- } else {
916
- row(copy.runtime.current, runtime.currentVersion ?? copy.runtime.unknown)
917
- if (runtime.updateAvailable && latestVersion !== null) {
918
- row(copy.runtime.latest, latestVersion)
919
- // v2 manifest metadata: download size before committing to the update.
920
- const latestEntry = runtime.latest
921
- const size = typeof latestEntry?.sizeBytes === 'number' ? formatMb(latestEntry.sizeBytes) : ''
922
- if (size.length > 0) row(copy.runtime.sizeLabel, size)
923
- }
924
- if (report.manifestVersion !== null) {
925
- row(copy.runtime.set, report.manifestVersion, true)
926
- }
927
- if (typeof runtime.checkedAt === 'string' && runtime.checkedAt.length > 0) {
928
- row(copy.runtime.checkedAt, new Date(runtime.checkedAt).toLocaleString())
929
- }
930
- if (source !== null) {
931
- sourceFactRow(facts, source, copy)
932
- }
933
- }
934
- section.append(facts)
1031
+ card.append(body)
1032
+ section.append(card)
1033
+ return section
1034
+ }
935
1035
 
936
- // The editor: point the runtime at another release channel (a staging
937
- // manifest, a local rig) without an env var and a restart. hq-edge persists
938
- // the override per host, so it survives restarts of both processes. It is a
939
- // developer affordance, so it hides behind an "advanced" disclosure.
940
- if (source !== null) {
941
- section.append(advancedBlock(copy, [sourceEditor('runtime', source, copy, actions)]))
1036
+ /** The HQ Edge runtime engine card per the mockup: current chip, "新版本 vY
1037
+ * 可用" with a 关键修复 tag when breaking/notes exist, size+summary line, a
1038
+ * 升级核心 button (existing arm→confirm flow), and the amber restart note. */
1039
+ function runtimeEngineCard(
1040
+ report: PluginUpdateReport,
1041
+ copy: UpdateCopy,
1042
+ actions: RuntimeActions,
1043
+ ): HTMLElement | null {
1044
+ const runtime = report.runtime
1045
+ const latestVersion = runtimeLatestVersion(runtime)
1046
+ const card = el('section', 'hqpu-engine')
1047
+ card.dataset.group = 'runtime'
1048
+
1049
+ const head = el('div', 'hqpu-engine-head')
1050
+ const titleWrap = el('div', 'hqpu-engine-title')
1051
+ titleWrap.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.33333 2L4.66667 8H8L6.66667 14L11.33333 8H8L9.33333 2Z"/></svg>`
1052
+ titleWrap.append(el('span', '', copy.ui.runtimeEngine))
1053
+ head.append(titleWrap)
1054
+ if (typeof runtime.currentVersion === 'string' && runtime.currentVersion.length > 0) {
1055
+ head.append(el('span', 'hqpu-chip', copy.ui.currentChip(runtime.currentVersion)))
942
1056
  }
1057
+ card.append(head)
943
1058
 
944
- // ── Runtime update action row ──
945
- // Only offered when hq-edge reports a newer distribution AND named its
946
- // version: the request needs `targetVersion`, so an entry without one has
947
- // nothing to send.
1059
+ // Update row (only when a newer distribution is known).
948
1060
  if (runtime.updateAvailable && latestVersion !== null) {
949
- const row = el('div', 'hqpu-actions hqpu-runtime-actions')
1061
+ const row = el('div', 'hqpu-engine-update-row')
1062
+ const left = el('div')
1063
+ const newLine = el('div', 'hqpu-engine-new-line')
1064
+ newLine.append(el('span', '', copy.ui.newVersion(latestVersion)))
1065
+ const latestEntry = runtime.latest
1066
+ const hasCritical =
1067
+ (Array.isArray(latestEntry?.breaking) && (latestEntry?.breaking?.length ?? 0) > 0) ||
1068
+ (Array.isArray(latestEntry?.notes) && (latestEntry?.notes?.length ?? 0) > 0)
1069
+ if (hasCritical) newLine.append(el('span', 'hqpu-tag--brand', copy.ui.breakingTag))
1070
+ left.append(newLine)
1071
+ const size = typeof latestEntry?.sizeBytes === 'number' ? formatMb(latestEntry.sizeBytes) : ''
1072
+ const summary = typeof latestEntry?.summary === 'string' ? latestEntry.summary : ''
1073
+ if (size.length > 0 || summary.length > 0) {
1074
+ left.append(el('p', 'hqpu-engine-size', copy.ui.sizeLine(size, summary)))
1075
+ }
1076
+ row.append(left)
1077
+
1078
+ // Upgrade action: arm → confirm, accepted/busy states preserved.
950
1079
  if (actions.accepted) {
951
- // Terminal state: no button. hq-edge has already exited, so re-offering
952
- // "update" here could only produce a transport error.
953
- const line = el('span', 'hqpu-status hqpu-status--dot')
954
- line.setAttribute('role', 'status')
955
- line.append(el('span', 'hqpu-spinner'), el('span', '', copy.runtime.updateAccepted))
956
- row.append(line)
1080
+ row.append(el('span', 'hqpu-status', copy.runtime.updateAccepted))
957
1081
  } else if (actions.busy) {
958
- // Downloading + verifying + staging the bundle: tens of seconds to a few
959
- // minutes. Say what is happening instead of an open-ended spinner.
960
- const line = el('span', 'hqpu-status hqpu-status--dot')
961
- line.setAttribute('role', 'status')
962
- line.append(el('span', 'hqpu-spinner'), el('span', '', copy.runtime.updating))
963
- row.append(line)
1082
+ row.append(el('span', 'hqpu-status', copy.runtime.updating))
964
1083
  } else if (actions.armed) {
965
- row.append(el('p', 'hqpu-status hqpu-runtime-confirm', copy.runtime.updateConfirm(latestVersion)))
966
- const yes = el('button', 'hqpu-btn hqpu-btn--primary hqpu-btn--sm', copy.confirmInstall)
1084
+ const confirmWrap = el('div')
1085
+ confirmWrap.append(el('p', 'hqpu-status', copy.runtime.updateConfirm(latestVersion)))
1086
+ const yes = el('button', 'hqpu-btn hqpu-btn--engine', copy.confirmInstall)
967
1087
  yes.type = 'button'
968
1088
  yes.dataset.runtimeConfirm = ''
969
1089
  yes.addEventListener('click', actions.onConfirm)
970
- const no = el('button', 'hqpu-btn hqpu-btn--outline hqpu-btn--sm', copy.cancel)
1090
+ const no = el('button', 'hqpu-btn hqpu-btn--engine', copy.cancel)
971
1091
  no.type = 'button'
972
1092
  no.addEventListener('click', actions.onCancel)
973
- row.append(yes, no)
1093
+ confirmWrap.append(yes, no)
1094
+ row.append(confirmWrap)
974
1095
  } else {
975
- const start = el('button', 'hqpu-btn hqpu-btn--primary hqpu-btn--sm')
1096
+ const start = el('button', 'hqpu-btn hqpu-btn--engine', copy.ui.upgradeCore)
976
1097
  start.type = 'button'
977
1098
  start.dataset.runtimeUpdate = ''
978
- start.innerHTML = `${DOWNLOAD_ICON(13)}<span>${copy.runtime.update}</span>`
979
1099
  start.addEventListener('click', actions.onArm)
980
1100
  row.append(start)
981
1101
  }
982
- section.append(row)
983
- // Honest duration note: no fixed "one minute" claim.
984
- section.append(el('p', 'hqpu-status hqpu-job-meta', copy.runtime.durationNote))
985
- } else {
986
- section.append(el('p', 'hqpu-status', copy.runtime.note))
1102
+ card.append(row)
1103
+ const note = el('p', 'hqpu-engine-note')
1104
+ note.innerHTML =
1105
+ `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2.5L14 13H2L8 2.5Z"/></svg><span>${copy.ui.runtimeRestartNote}</span>`
1106
+ card.append(note)
1107
+ } else if (typeof runtime.lastError === 'string' && runtime.lastError.length > 0) {
1108
+ card.append(el('p', 'hqpu-status', `${copy.lastError}: ${runtime.lastError}`))
987
1109
  }
988
-
989
- // The host's own report of the last runtime swap (succeeded / rolled_back /
990
- // failed). Before this it was read from disk by hq-edge and never shown — a
991
- // rolled-back swap was indistinguishable from "no update ever happened".
992
- const lastResult = runtime.lastResult
993
- if (lastResult !== null && lastResult !== undefined) {
994
- const state = lastResult.state
995
- const line = el('p', 'hqpu-status hqpu-status--dot')
996
- line.setAttribute('role', 'status')
997
- const dot = el('span', 'hqpu-dot')
998
- dot.dataset.state = state === 'succeeded' ? 'done' : state === 'failed' ? 'error' : 'warning'
999
- const head =
1000
- state === 'succeeded'
1001
- ? copy.runtime.lastResult.succeeded
1002
- : state === 'failed'
1003
- ? copy.runtime.lastResult.failed
1004
- : copy.runtime.lastResult.rolledBack
1005
- const parts = [head]
1006
- if (lastResult.targetVersion) parts.push(`→ ${lastResult.targetVersion}`)
1007
- if (lastResult.installedVersion) parts.push(`(${lastResult.installedVersion})`)
1008
- if (lastResult.at) parts.push(new Date(lastResult.at).toLocaleString())
1009
- line.append(dot, el('span', '', parts.join(' ')))
1010
- section.append(line)
1011
- if (typeof lastResult.message === 'string' && lastResult.message.length > 0) {
1012
- section.append(el('p', 'hqpu-status', lastResult.message))
1013
- }
1014
- if (typeof lastResult.errorCode === 'string' && lastResult.errorCode.length > 0) {
1015
- section.append(el('p', 'hqpu-status hqpu-job-meta', lastResult.errorCode))
1016
- }
1017
- if (state !== 'succeeded') {
1018
- section.append(el('p', 'hqpu-status hqpu-job-meta', copy.runtime.lastResult.elevation))
1019
- }
1020
- }
1021
-
1022
- if (typeof runtime.lastError === 'string' && runtime.lastError.length > 0) {
1023
- const line = el('p', 'hqpu-status hqpu-status--dot')
1024
- line.setAttribute('role', 'status')
1025
- const dot = el('span', 'hqpu-dot')
1026
- dot.dataset.state = 'error'
1027
- line.append(dot, el('span', '', `${copy.lastError}: ${runtime.lastError}`))
1028
- section.append(line)
1029
- }
1030
- return section
1110
+ return card
1031
1111
  }
1032
1112
 
1033
1113
  function renderList(
1034
1114
  section: HTMLElement,
1035
1115
  report: PluginUpdateReport | null,
1036
1116
  copy: UpdateCopy,
1037
- installing: boolean,
1038
- store: UpdateStoreLike,
1117
+ changelogOpen: Set<string>,
1118
+ allOpen: boolean,
1119
+ onToggleCard: (name: string) => void,
1120
+ onToggleAll: () => void,
1039
1121
  onRetryCheck: () => void,
1040
1122
  ): void {
1041
1123
  section.textContent = ''
@@ -1071,7 +1153,7 @@ function renderList(
1071
1153
  [copy.group.current, byState('current')],
1072
1154
  ]
1073
1155
  for (const [heading, items] of order) {
1074
- const node = group(heading, items, copy, installing, store)
1156
+ const node = group(heading, items, copy, changelogOpen, allOpen, onToggleCard, onToggleAll)
1075
1157
  if (node !== null) section.append(node)
1076
1158
  }
1077
1159
  }
@@ -1081,10 +1163,15 @@ function renderRuntime(
1081
1163
  report: PluginUpdateReport | null,
1082
1164
  copy: UpdateCopy,
1083
1165
  actions: RuntimeActions,
1166
+ dismissedAlert: boolean,
1167
+ onDismissAlert: () => void,
1084
1168
  ): void {
1085
1169
  section.textContent = ''
1086
1170
  if (report === null) return
1087
- section.append(runtimeGroup(report, copy, actions))
1171
+ const alert = runtimeResultAlert(report, copy, dismissedAlert, onDismissAlert)
1172
+ if (alert !== null) section.append(alert)
1173
+ const engine = runtimeEngineCard(report, copy, actions)
1174
+ if (engine !== null) section.append(engine)
1088
1175
  }
1089
1176
 
1090
1177
  /** The plugin-source group, above the plugin groups, hidden behind an advanced
@@ -1157,66 +1244,98 @@ function showToast(message: string, tone: 'success' | 'error'): void {
1157
1244
  }, TOAST_HOLD_MS)
1158
1245
  }
1159
1246
 
1160
- function renderActions(
1161
- bar: HTMLElement,
1247
+ /**
1248
+ * The collapsible "更新源配置 (高级)" panel (gear-toggled). It hosts the
1249
+ * EXISTING plugin-source and runtime-source editors (POST /plugin-source,
1250
+ * POST /runtime-source, save→check→refresh already wired). No new endpoints:
1251
+ * if a manifest source has no data, that field is omitted rather than faked.
1252
+ */
1253
+ function renderSettingsPanel(
1254
+ panel: HTMLElement,
1162
1255
  report: PluginUpdateReport | null,
1163
1256
  copy: UpdateCopy,
1164
- store: UpdateStoreLike,
1165
- installing: boolean,
1257
+ open: boolean,
1258
+ pluginActions: SourceEditorActions,
1259
+ runtimeActions: SourceEditorActions,
1166
1260
  ): void {
1167
- bar.textContent = ''
1168
- if (report === null) return
1261
+ panel.textContent = ''
1262
+ panel.style.display = open ? '' : 'none'
1263
+ if (!open || report === null) return
1264
+
1265
+ const row = el('div', 'hqpu-settings-row')
1266
+ row.append(el('div', 'hqpu-settings-title', copy.ui.settingsTitle))
1267
+ // Origin badge: custom override (amber) vs the default configured source (green).
1268
+ const pluginSource = normalizeSource(report.pluginSource)
1269
+ const runtimeSource = normalizeSource(report.runtime.source)
1270
+ const overridden = (pluginSource?.override ?? false) || (runtimeSource?.override ?? false)
1271
+ const badge = el('span', 'hqpu-origin-badge', overridden ? copy.ui.originCustom : copy.ui.originDefault)
1272
+ badge.dataset.origin = overridden ? 'override' : 'config'
1273
+ row.append(badge)
1274
+ panel.append(row)
1275
+
1276
+ if (pluginSource !== null) {
1277
+ panel.append(el('label', '', copy.ui.pluginUrlLabel))
1278
+ panel.append(sourceEditor('plugin', pluginSource, copy, pluginActions))
1279
+ }
1280
+ if (runtimeSource !== null) {
1281
+ panel.append(el('label', '', copy.ui.runtimeUrlLabel))
1282
+ panel.append(sourceEditor('runtime', runtimeSource, copy, runtimeActions))
1283
+ }
1284
+ }
1169
1285
 
1170
- if (installing) {
1171
- const line = el('span', 'hqpu-status hqpu-status--dot')
1172
- const spinner = el('span', 'hqpu-spinner')
1173
- line.append(spinner, el('span', '', copy.installing))
1174
- bar.append(line)
1286
+ /**
1287
+ * The sticky footer: left meta ("准备安装 N 项兼容插件" / "版本集: vX"), a
1288
+ * full-width primary "一键更新全套插件 (vX)" (install = one durable job),
1289
+ * a secondary 忽略 wired to the existing ignore/unignore affordance, and the
1290
+ * footnote. The count EXCLUDES blocked / ignored / already-current entries.
1291
+ */
1292
+ function renderFooter(
1293
+ footer: HTMLElement,
1294
+ installBtn: HTMLButtonElement,
1295
+ ignoreBtn: HTMLButtonElement,
1296
+ countEl: HTMLElement,
1297
+ setEl: HTMLElement,
1298
+ report: PluginUpdateReport | null,
1299
+ copy: UpdateCopy,
1300
+ installing: boolean,
1301
+ store: UpdateStoreLike,
1302
+ ): void {
1303
+ if (report === null) {
1304
+ countEl.textContent = ''
1305
+ setEl.textContent = ''
1306
+ installBtn.disabled = true
1307
+ ignoreBtn.style.display = 'none'
1175
1308
  return
1176
1309
  }
1177
1310
 
1178
1311
  const actionable = report.items.filter(
1179
1312
  (item) => item.state === 'update' || item.state === 'install',
1180
1313
  ).length
1181
- // Every write needs an evaluated set; hq-edge reports `null` until it has
1182
- // read a manifest, and posting `null` would only earn a 400.
1183
1314
  const hasSet = report.manifestVersion !== null
1184
-
1185
- if (actionable === 0) {
1186
- // The official page shows no install control when there is nothing to
1187
- // commit, so mirror that with a muted line rather than a dead button.
1188
- bar.append(el('span', 'hqpu-status', copy.nothingToInstall))
1189
- } else {
1190
- const installBtn = el('button', 'hqpu-btn hqpu-btn--primary')
1191
- installBtn.type = 'button'
1192
- installBtn.disabled = !hasSet || report.ignoredSet
1193
- installBtn.innerHTML = `${DOWNLOAD_ICON(13)}<span>${copy.installAll(actionable)}</span>`
1194
- installBtn.addEventListener('click', () => {
1195
- void runInstall(store, copy)
1196
- })
1197
- bar.append(installBtn)
1315
+ const setVersion: string = report.manifestVersion ?? ''
1316
+
1317
+ countEl.textContent = copy.ui.footerCount(actionable)
1318
+ setEl.textContent = hasSet ? copy.ui.footerSet(setVersion) : ''
1319
+
1320
+ // Install-all: honest set-level label; disabled while busy or nothing to do.
1321
+ installBtn.textContent = hasSet
1322
+ ? copy.ui.installAllSet(setVersion)
1323
+ : copy.ui.installAllSet('—')
1324
+ installBtn.disabled = installing || actionable === 0 || !hasSet || report.ignoredSet
1325
+ installBtn.dataset.busy = installing ? 'true' : 'false'
1326
+ installBtn.onclick = () => {
1327
+ void runInstall(store, copy)
1198
1328
  }
1199
1329
 
1200
- if (!hasSet) return
1201
-
1202
- if (report.ignoredSet) {
1203
- const unignore = el('button', 'hqpu-btn hqpu-btn--outline hqpu-btn--sm', copy.unignore)
1204
- unignore.type = 'button'
1205
- unignore.addEventListener('click', () => {
1206
- void unignoreOffer(report.manifestVersion as string)
1207
- .then(() => store.refresh())
1208
- .catch(() => undefined)
1209
- })
1210
- bar.append(unignore)
1211
- } else if (actionable > 0) {
1212
- const ignore = el('button', 'hqpu-btn hqpu-btn--ghost hqpu-btn--sm', copy.ignore)
1213
- ignore.type = 'button'
1214
- ignore.addEventListener('click', () => {
1215
- void ignoreOffer(report.manifestVersion as string)
1216
- .then(() => store.refresh())
1217
- .catch(() => undefined)
1218
- })
1219
- bar.append(ignore)
1330
+ // Secondary ignore button: only when there is an evaluated set to ignore.
1331
+ ignoreBtn.style.display = hasSet ? '' : 'none'
1332
+ ignoreBtn.textContent = report.ignoredSet ? copy.unignore : copy.ui.ignoreBtn
1333
+ ignoreBtn.onclick = () => {
1334
+ const version = report.manifestVersion as string
1335
+ const action = report.ignoredSet ? unignoreOffer(version) : ignoreOffer(version)
1336
+ void action
1337
+ .then(() => store.refresh())
1338
+ .catch(() => undefined)
1220
1339
  }
1221
1340
  }
1222
1341
 
@@ -1244,51 +1363,71 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
1244
1363
  panel.setAttribute('aria-label', copy.viewTitle)
1245
1364
  panel.tabIndex = -1
1246
1365
 
1247
- // ── Head row: title + intro, refresh toolbar ──
1366
+ // ── Sticky header: brand icon + title + relative checked time, refresh +
1367
+ // gear (advanced source settings) + close ──
1248
1368
  const head = el('div', 'hqpu-head')
1249
1369
  const headMain = el('div', 'hqpu-head-main')
1250
- headMain.append(el('h1', 'hqpu-title', copy.viewTitle))
1251
- const intro = el('p', 'hqpu-intro')
1252
- intro.append(el('span', '', copy.viewIntro))
1253
- const info = el('button', 'hqpu-info')
1254
- info.type = 'button'
1255
- info.setAttribute('aria-label', copy.viewInfo)
1256
- info.title = copy.viewInfo
1257
- info.innerHTML = INFO_ICON(11)
1258
- intro.append(info)
1259
- headMain.append(intro)
1370
+ const brandIcon = el('span', 'hqpu-brand-icon')
1371
+ brandIcon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5.6 2.6a6 6 0 015.6 1.6M12.4 13.4a6 6 0 01-5.6-1.6M10.6 2V5.4H7.2M5.4 14V10.6H8.8"/></svg>`
1372
+ const titleBlock = el('div', 'hqpu-title-block')
1373
+ titleBlock.append(el('h1', 'hqpu-title', copy.ui.title))
1374
+ const sub = el('p', 'hqpu-sub')
1375
+ titleBlock.append(sub)
1376
+ headMain.append(brandIcon, titleBlock)
1260
1377
 
1261
1378
  const toolbar = el('div', 'hqpu-toolbar')
1262
1379
  const refreshBtn = iconButton('hqpu-icon-btn', REFRESH_ICON(16), copy.refresh, () => {
1263
- void store.refresh()
1380
+ void store.check()
1264
1381
  })
1265
1382
  toolbar.append(refreshBtn)
1383
+ const gearBtn = iconButton(
1384
+ 'hqpu-icon-btn',
1385
+ `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="2.2"/><path d="M13.5 9.2v-2.4l-1.8-.6a4.4 4.4 0 00-.5-1.2l.9-1.7-1.7-1.7-1.7.9a4.4 4.4 0 00-1.2-.5L7.2 2.5H4.8l-.6 1.8a4.4 4.4 0 00-1.2.5l-1.7-.9-1.7 1.7.9 1.7a4.4 4.4 0 00-.5 1.2L2.5 7.2v2.4l1.8.6a4.4 4.4 0 00.5 1.2l-.9 1.7 1.7 1.7 1.7-.9a4.4 4.4 0 001.2.5l.6 1.8h2.4l.6-1.8a4.4 4.4 0 001.2-.5l1.7.9 1.7-1.7-.9-1.7a4.4 4.4 0 00.5-1.2l1.8-.6z"/></svg>`,
1386
+ copy.ui.gearTooltip,
1387
+ () => {
1388
+ settingsOpen = !settingsOpen
1389
+ render()
1390
+ },
1391
+ )
1392
+ toolbar.append(gearBtn)
1266
1393
  const closeBtn = iconButton('hqpu-icon-btn', CLOSE_ICON(16), copy.close, () => {
1267
1394
  onClose()
1268
1395
  })
1269
1396
  toolbar.append(closeBtn)
1270
-
1271
1397
  head.append(headMain, toolbar)
1272
1398
  panel.append(head)
1273
1399
 
1274
- // ── Scrolling body: the centred column ──
1400
+ // ── Collapsible advanced settings panel (gear-toggled) ──
1401
+ const settingsPanel = el('div', 'hqpu-settings')
1402
+ panel.append(settingsPanel)
1403
+
1404
+ // ── Scrolling body: the narrow column ──
1275
1405
  const scroll = el('div', 'hqpu-scroll')
1276
1406
  const column = el('div', 'hqpu-column')
1277
1407
  // The durable-job view leads: an active install/runtime update is resumed on
1278
1408
  // open BEFORE anything else, and a recent terminal result is shown here too.
1279
1409
  const jobSection = el('div', 'hqpu-stack')
1280
- const pluginSourceSection = el('div', 'hqpu-stack')
1281
1410
  const listSection = el('div', 'hqpu-stack')
1282
1411
  const runtimeSection = el('div', 'hqpu-stack')
1283
1412
  const toastSection = el('div', 'hqpu-toast-host')
1284
- column.append(jobSection, pluginSourceSection, listSection, runtimeSection, toastSection)
1413
+ column.append(jobSection, listSection, runtimeSection, toastSection)
1285
1414
  scroll.append(column)
1286
1415
  panel.append(scroll)
1287
1416
 
1288
- // ── Footer actions (ui-primitives Modal .footer) ──
1417
+ // ── Sticky footer ──
1289
1418
  const footer = el('div', 'hqpu-footer')
1290
- const actionBar = el('div', 'hqpu-actions')
1291
- footer.append(actionBar)
1419
+ const footMeta = el('div', 'hqpu-foot-meta')
1420
+ const footCount = el('span', '')
1421
+ const footSet = el('span', 'hqpu-foot-set')
1422
+ footMeta.append(footCount, footSet)
1423
+ const footActions = el('div', 'hqpu-foot-actions')
1424
+ const installAllBtn = el('button', 'hqpu-btn hqpu-btn--block')
1425
+ installAllBtn.type = 'button'
1426
+ const ignoreBtn = el('button', 'hqpu-btn--ignore', copy.ui.ignoreBtn)
1427
+ ignoreBtn.type = 'button'
1428
+ footActions.append(installAllBtn, ignoreBtn)
1429
+ const footnote = el('p', 'hqpu-footnote', copy.ui.footnote)
1430
+ footer.append(footMeta, footActions, footnote)
1292
1431
  panel.append(footer)
1293
1432
 
1294
1433
  overlay.append(scrim, panel)
@@ -1305,6 +1444,12 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
1305
1444
  let runtimeBusy = false
1306
1445
  let runtimeAccepted = false
1307
1446
 
1447
+ // ── Panel-closure UI state (survive store refreshes) ──
1448
+ const changelogOpen = new Set<string>()
1449
+ let allOpen = false
1450
+ let settingsOpen = false
1451
+ let dismissedAlert = false
1452
+
1308
1453
  // Source-editor state, same reasoning: a background refresh must not discard
1309
1454
  // a half-typed URL or silently close either editor.
1310
1455
  const runtimeSourceState: SourceState = { editing: false, saving: false, draft: '' }
@@ -1507,18 +1652,46 @@ export function openUpdateView(store: UpdateStoreLike, copy: UpdateCopy): void {
1507
1652
  const installing = store.installing() || jobActive
1508
1653
  const stale = report === null
1509
1654
  refreshBtn.disabled = stale
1655
+ refreshBtn.title = copy.refresh
1656
+
1657
+ // Header sub-line: "上次检查: N 分钟前" (from report.checkedAt).
1658
+ const ago = relativeAgo(report?.checkedAt, copy)
1659
+ sub.textContent = ago !== null ? copy.ui.checkedAgo(ago) : copy.ui.checkedUnknown
1660
+
1661
+ // Collapsible advanced settings panel (gear-toggled): existing source
1662
+ // editors — plugin + runtime manifest URL override, save→check→refresh.
1663
+ renderSettingsPanel(settingsPanel, report, copy, settingsOpen, pluginActions, runtimeSourceActions)
1664
+
1510
1665
  renderJobSection()
1511
1666
  if (stale) {
1512
1667
  listSection.textContent = ''
1513
1668
  listSection.append(skeleton(copy))
1514
1669
  } else {
1515
- renderList(listSection, report, copy, installing, store, () => {
1516
- void store.check()
1517
- })
1670
+ renderList(
1671
+ listSection,
1672
+ report,
1673
+ copy,
1674
+ changelogOpen,
1675
+ allOpen,
1676
+ (name) => {
1677
+ if (changelogOpen.has(name)) changelogOpen.delete(name)
1678
+ else changelogOpen.add(name)
1679
+ render()
1680
+ },
1681
+ () => {
1682
+ allOpen = !allOpen
1683
+ render()
1684
+ },
1685
+ () => {
1686
+ void store.check()
1687
+ },
1688
+ )
1518
1689
  }
1519
- renderPluginSource(pluginSourceSection, report, copy, pluginActions)
1520
- renderRuntime(runtimeSection, report, copy, runtimeActions)
1521
- renderActions(actionBar, report, copy, store, installing)
1690
+ renderRuntime(runtimeSection, report, copy, runtimeActions, dismissedAlert, () => {
1691
+ dismissedAlert = true
1692
+ render()
1693
+ })
1694
+ renderFooter(footer, installAllBtn, ignoreBtn, footCount, footSet, report, copy, installing, store)
1522
1695
  }
1523
1696
 
1524
1697
  const unsubscribe = store.subscribe(render)