@deepblend/dsh-blender-ui 0.2.2 → 0.2.3

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.
Files changed (2) hide show
  1. package/lib/client.js +454 -137
  2. package/package.json +2 -2
package/lib/client.js CHANGED
@@ -90,6 +90,323 @@ window.__ModuleLoader__.load({
90
90
  const h = (...args) => react().createElement(...args)
91
91
 
92
92
  // =========================================================================
93
+ // #region strings — every user-facing word in this file
94
+ //
95
+ // WHY A TABLE. The harness localizes itself: `dsh-client-locale` ships two locales (`zh`, `en`)
96
+ // and states its own fallback rule — "English is both the locale the UI opens in when the browser
97
+ // names no registered language (and for non-browser runs), and the dictionary consulted after the
98
+ // active locale misses a key ... because a browser naming no registered language is the reader
99
+ // least likely to read Chinese" (`FALLBACK_LOCALE = "en"`). This workbench used to hard-code
100
+ // Chinese in every one of these positions, so a reader the platform had deliberately routed to
101
+ // English got a Chinese screen — the one outcome the platform's own rule exists to prevent.
102
+ //
103
+ // The active locale arrives on the page: the locale plugin sets `document.documentElement.lang`
104
+ // (`snapshot.active === "zh" ? "zh-CN" : snapshot.active`), so this file reads the same signal the
105
+ // rest of the shell does rather than inventing a preference of its own.
106
+ //
107
+ // The two sides carry the SAME key set — the harness's own invariant, asserted in
108
+ // `contract/workbench-copy.test.mjs`, so a missing key cannot leave a hole in either direction.
109
+ const STRINGS = {
110
+ zh: {
111
+ 'tab.projects': '项目',
112
+ 'tab.scene': '场景树',
113
+ 'tab.preview': '预览对比',
114
+ 'tab.jobs': '任务',
115
+ 'tab.revisions': '版本',
116
+ 'common.unknown': '未知',
117
+ 'workbench.restartHint': '重启 profile(dsh web)即可。',
118
+ 'preview.diffFailed': 'diff 失败',
119
+ 'preview.keptPrevious': ';上一张已留作「上一次渲染」,可以直接并排比较',
120
+ 'preview.firstSheet': '(这是第一张;再渲染一次就能并排比较前后)',
121
+ 'preview.isArtifact': '。预览是产物:替换同一路径上的旧图,不产生新的 revision。',
122
+ 'jobs.cancelRequested': '取消已请求,但进程仍在',
123
+ 'projects.empty': '这个工作区还没有项目。',
124
+ 'projects.create': '新建项目(写操作经 Host)',
125
+ 'projects.titlePlaceholder': '标题,例如 watch-commercial',
126
+ 'projects.goal': '目标(可选)',
127
+ 'projects.creating': '创建中…',
128
+ 'projects.createButton': '创建',
129
+ 'scene.currentRevision': '当前 revision',
130
+ 'scene.revisionCount': 'revision 数',
131
+ 'scene.frameRange': '帧范围',
132
+ 'scene.activeCamera': '活动相机',
133
+ 'scene.counts': '对象 / 材质 / 灯 / 相机',
134
+ 'common.noProject': '尚未选择项目。',
135
+ 'common.empty': '空',
136
+ 'common.default': '默认',
137
+ 'scene.entities': '实体 entities',
138
+ 'scene.materials': '材质 materials',
139
+ 'scene.lights': '灯光 lights',
140
+ 'scene.cameras': '相机 cameras',
141
+ 'scene.shots': '镜头 shots',
142
+ 'scene.animationTracks': '动画轨道 animationTracks',
143
+ 'scene.assets': '资产 assets',
144
+ 'scene.patch': 'ScenePatch(写操作经 Host,原子提交为一个 revision)',
145
+ 'scene.submitting': '提交中…',
146
+ 'scene.submit': '提交',
147
+ 'scene.resetTemplate': '重置模板',
148
+ 'preview.thisRender': '本次渲染',
149
+ 'preview.lastRender': '上一次渲染',
150
+ 'common.noNote': '(无说明)',
151
+ 'revisions.currentSuffix': ' (当前)',
152
+ 'revisions.missing': '没有这个 revision',
153
+ 'preview.noneForRevision': '这个 revision 还没有预览图(渲染一次预览即可)。',
154
+ 'preview.noPrevious': '还没有上一次渲染:再点一次「渲染预览」,或者先在某个更早的 revision 上渲一次。',
155
+ 'preview.notRenderedHere': '这个 revision 还没有由面板渲过预览(上方的「渲染预览」会生成第一张)。',
156
+ 'preview.rendering': '渲染中…',
157
+ 'preview.render': '渲染预览',
158
+ 'preview.compare': '比较:',
159
+ 'preview.lastVsThis': '上一次 vs 本次渲染',
160
+ 'preview.twoRevisions': '两个 revision',
161
+ 'preview.structuralDiff': '看结构差异',
162
+ 'preview.identical': '结构完全相同',
163
+ 'jobs.empty': '还没有渲染任务。',
164
+ 'jobs.deliveryVerified': '交付已校验',
165
+ 'jobs.cancel': '取消',
166
+ 'jobs.resume': '继续渲染',
167
+ 'jobs.startDelivery': '启动一次交付渲染(写操作经 Host)',
168
+ 'jobs.frameStart': '帧起',
169
+ 'jobs.frameEnd': '帧止',
170
+ 'jobs.start': '启动',
171
+ 'qa.passed': '技术校验通过',
172
+ 'qa.none': '无技术校验记录',
173
+ 'qa.title': '技术校验(validation.json)',
174
+ 'qa.engine': '引擎',
175
+ 'qa.objects': '对象',
176
+ 'qa.materials': '材质',
177
+ 'qa.cameras': '相机',
178
+ 'qa.noErrors': '没有技术错误。',
179
+ 'qa.notices': '编译器 notices',
180
+ 'visual.title': '视觉评审(测量 + 模型 finding,两个来源不合并)',
181
+ 'visual.score': '分数',
182
+ 'common.yes': '通过',
183
+ 'common.yesShort': '是',
184
+ 'common.noShort': '否',
185
+ 'visual.rounds': '轮次',
186
+ 'visual.subjects': '主体',
187
+ 'visual.views': '视角数',
188
+ 'visual.reviewer': '审查器',
189
+ 'visual.called': '已调用',
190
+ 'visual.notCalled': '未调用',
191
+ 'visual.noFindings': '测量没有发现问题。',
192
+ 'visual.reviewerSilent': '审查器没有报告 finding。',
193
+ 'visual.noSecondOpinion': '没有第二意见。',
194
+ 'visual.none': '这个 revision 还没有视觉评审。可以用 blender_visual_review 跑一次。',
195
+ 'revisions.current': '当前',
196
+ 'revisions.compare': '对比',
197
+ 'revisions.restore': '恢复',
198
+ 'host.reading': '读取 Host 状态…',
199
+ 'diagnostics.exportHint': '导出一份可以附在问题里的诊断信息(版本、配置、项目与任务的摘要)',
200
+ 'diagnostics.export': '导出诊断',
201
+ 'common.refresh': '刷新',
202
+ 'blender.detecting': '检测 Blender…',
203
+ 'common.warning': '警告',
204
+ 'common.collapse': '收起',
205
+ 'common.details': '详情',
206
+ 'common.noRecord': '无记录',
207
+ 'jobs.running': '(运行中…)',
208
+ 'jobs.none': '无渲染任务',
209
+ 'jobs.started': '任务 {jobId} 已启动({frames} 帧)',
210
+ 'projects.updated': '当前 {revision} · 更新于 {when}',
211
+ 'qa.errorCount': '技术错误 {count}',
212
+ 'visual.scoreIs': '视觉评分 {score}',
213
+ 'visual.notRun': '未跑视觉评审',
214
+ 'host.observedResponse': '观察到的响应:{detail}。',
215
+ 'host.stale': '本进程里的 blenderUi 比磁盘上的包旧:/deepblend/{route} 没有被这一版的宿主回答',
216
+ 'host.staleDetail': '({detail}hostApiVersion={version},本 UI 需要 {needed})。',
217
+ 'host.notJson': 'HTTP {status},{bytes} 字节,非 JSON',
218
+ 'projects.created': '已创建 {id}',
219
+ 'scene.patchInvalidJson': 'patch 不是合法 JSON:{message}',
220
+ 'scene.committed': '已提交 {revision}(digest {digest})',
221
+ 'preview.rendered': '已渲染 {views} 个视角 → 合成 {revision} 的 contact sheet',
222
+ 'jobs.cancelled': '已取消 {jobId},进程实测已消失',
223
+ 'revisions.restored': '已恢复 {from} 为 {to}',
224
+ 'projects.unfinishedJobs': '{count} 个任务在跑',
225
+ 'projects.revisionCount': '{count} 个 revision',
226
+ 'projects.currentProject': '当前项目:{title}',
227
+ 'scene.material': '材质 {id}',
228
+ 'scene.track': '{target} · {property} · {keys} 关键帧',
229
+ 'preview.renderedAt': ' · 渲染于 {when}',
230
+ 'preview.lastRenderOf': '上一次渲染 · {revision}',
231
+ 'preview.thisRenderOf': '本次渲染 · {revision}',
232
+ 'preview.changeCount': '{count} 处结构变化',
233
+ 'jobs.approval': '需审批:{frames} 帧 > 阈值 {threshold}',
234
+ 'jobs.approvalShort': '需审批:{frames} 帧 > {threshold}',
235
+ 'qa.noticeCount': '{count} 条 notices',
236
+ 'workbench.title': '{panel} 工作台',
237
+ 'jobs.unfinished': '{count} 个任务在跑',
238
+ 'blender.probedAt': '探测于 {when}',
239
+ 'visual.operationCount': '{count} 个操作',
240
+ 'jobs.frameProgress': '{completed}/{expected} 帧 · {percent}%',
241
+ 'jobs.estimated': '预计 {seconds} s',
242
+ 'visual.counts': '测量 {measured} · 模型 finding {findings}',
243
+ 'visual.reviewerFailed': '审查器失败:{message}',
244
+ 'jobs.unfinishedShort': '{count} 个渲染在跑',
245
+ 'jobs.frameRange': '帧 {start}–{end}',
246
+ 'revisions.title': '版本({count})',
247
+ },
248
+ en: {
249
+ 'tab.projects': 'Projects',
250
+ 'tab.scene': 'Scene',
251
+ 'tab.preview': 'Preview',
252
+ 'tab.jobs': 'Jobs',
253
+ 'tab.revisions': 'Revisions',
254
+ 'common.unknown': 'unknown',
255
+ 'workbench.restartHint': 'Restart the profile (`dsh web`) and it will be there.',
256
+ 'preview.diffFailed': 'diff failed',
257
+ 'preview.keptPrevious': '; the previous one is kept as “last render”, so they can be compared side by side',
258
+ 'preview.firstSheet': '(this is the first; render once more to compare before and after)',
259
+ 'preview.isArtifact': '. A preview is an artifact: it replaces the old image at the same path and creates no revision.',
260
+ 'jobs.cancelRequested': 'cancellation requested, but the process is still there',
261
+ 'projects.empty': 'This workspace has no projects yet.',
262
+ 'projects.create': 'New project (the write goes through the Host)',
263
+ 'projects.titlePlaceholder': 'title, e.g. watch-commercial',
264
+ 'projects.goal': 'Goal (optional)',
265
+ 'projects.creating': 'Creating…',
266
+ 'projects.createButton': 'Create',
267
+ 'scene.currentRevision': 'current revision',
268
+ 'scene.revisionCount': 'revisions',
269
+ 'scene.frameRange': 'frame range',
270
+ 'scene.activeCamera': 'active camera',
271
+ 'scene.counts': 'objects / materials / lights / cameras',
272
+ 'common.noProject': 'No project selected.',
273
+ 'common.empty': 'empty',
274
+ 'common.default': 'default',
275
+ 'scene.entities': 'entities',
276
+ 'scene.materials': 'materials',
277
+ 'scene.lights': 'lights',
278
+ 'scene.cameras': 'cameras',
279
+ 'scene.shots': 'shots',
280
+ 'scene.animationTracks': 'animation tracks',
281
+ 'scene.assets': 'assets',
282
+ 'scene.patch': 'ScenePatch (the write goes through the Host and commits atomically as one revision)',
283
+ 'scene.submitting': 'Submitting…',
284
+ 'scene.submit': 'Submit',
285
+ 'scene.resetTemplate': 'Reset template',
286
+ 'preview.thisRender': 'this render',
287
+ 'preview.lastRender': 'last render',
288
+ 'common.noNote': '(no note)',
289
+ 'revisions.currentSuffix': ' (current)',
290
+ 'revisions.missing': 'no such revision',
291
+ 'preview.noneForRevision': 'This revision has no preview image yet (render a preview and it will).',
292
+ 'preview.noPrevious': 'No previous render yet: press “Render preview” again, or render once on an earlier revision.',
293
+ 'preview.notRenderedHere': 'This revision has no preview rendered from this panel yet (the “Render preview” button above makes the first one).',
294
+ 'preview.rendering': 'Rendering…',
295
+ 'preview.render': 'Render preview',
296
+ 'preview.compare': 'Compare:',
297
+ 'preview.lastVsThis': 'last vs this render',
298
+ 'preview.twoRevisions': 'two revisions',
299
+ 'preview.structuralDiff': 'structural diff',
300
+ 'preview.identical': 'structurally identical',
301
+ 'jobs.empty': 'No render jobs yet.',
302
+ 'jobs.deliveryVerified': 'delivery verified',
303
+ 'jobs.cancel': 'Cancel',
304
+ 'jobs.resume': 'Resume render',
305
+ 'jobs.startDelivery': 'Start a delivery render (the write goes through the Host)',
306
+ 'jobs.frameStart': 'from frame',
307
+ 'jobs.frameEnd': 'to frame',
308
+ 'jobs.start': 'Start',
309
+ 'qa.passed': 'technical checks passed',
310
+ 'qa.none': 'no technical check recorded',
311
+ 'qa.title': 'Technical checks (validation.json)',
312
+ 'qa.engine': 'engine',
313
+ 'qa.objects': 'objects',
314
+ 'qa.materials': 'materials',
315
+ 'qa.cameras': 'cameras',
316
+ 'qa.noErrors': 'No technical errors.',
317
+ 'qa.notices': 'compiler notices',
318
+ 'visual.title': 'Visual review (measurements + model findings; the two sources are not merged)',
319
+ 'visual.score': 'score',
320
+ 'common.yes': 'yes',
321
+ 'common.yesShort': 'yes',
322
+ 'common.noShort': 'no',
323
+ 'visual.rounds': 'rounds',
324
+ 'visual.subjects': 'subjects',
325
+ 'visual.views': 'views',
326
+ 'visual.reviewer': 'reviewer',
327
+ 'visual.called': 'called',
328
+ 'visual.notCalled': 'not called',
329
+ 'visual.noFindings': 'The measurements found nothing.',
330
+ 'visual.reviewerSilent': 'The reviewer reported no findings.',
331
+ 'visual.noSecondOpinion': 'No second opinion.',
332
+ 'visual.none': 'This revision has no visual review yet. Run one with blender_visual_review.',
333
+ 'revisions.current': 'current',
334
+ 'revisions.compare': 'compare',
335
+ 'revisions.restore': 'restore',
336
+ 'host.reading': 'Reading Host state…',
337
+ 'diagnostics.exportHint': 'Export diagnostics you can attach to a bug report (versions, config, a summary of projects and jobs)',
338
+ 'diagnostics.export': 'Export diagnostics',
339
+ 'common.refresh': 'Refresh',
340
+ 'blender.detecting': 'Detecting Blender…',
341
+ 'common.warning': 'warning',
342
+ 'common.collapse': 'collapse',
343
+ 'common.details': 'details',
344
+ 'common.noRecord': 'no record',
345
+ 'jobs.running': '(running…)',
346
+ 'jobs.none': 'no render job',
347
+ 'jobs.started': 'Job {jobId} started ({frames} frames)',
348
+ 'projects.updated': 'current {revision} · updated {when}',
349
+ 'qa.errorCount': '{count} technical errors',
350
+ 'visual.scoreIs': 'visual score {score}',
351
+ 'visual.notRun': 'no visual review yet',
352
+ 'host.observedResponse': 'the response observed: {detail}.',
353
+ 'host.stale': 'the blenderUi in this process is older than the package on disk: /deepblend/{route} was not answered by this host',
354
+ 'host.staleDetail': '({detail}hostApiVersion={version}, and this UI needs {needed}).',
355
+ 'host.notJson': 'HTTP {status}, {bytes} bytes, not JSON',
356
+ 'projects.created': 'created {id}',
357
+ 'scene.patchInvalidJson': 'the patch is not valid JSON: {message}',
358
+ 'scene.committed': 'committed {revision} (digest {digest})',
359
+ 'preview.rendered': 'rendered {views} views → a contact sheet for {revision}',
360
+ 'jobs.cancelled': 'cancelled {jobId}, and the process is measurably gone',
361
+ 'revisions.restored': 'restored {from} as {to}',
362
+ 'projects.unfinishedJobs': '{count} job(s) running',
363
+ 'projects.revisionCount': '{count} revision(s)',
364
+ 'projects.currentProject': 'current project: {title}',
365
+ 'scene.material': 'material {id}',
366
+ 'scene.track': '{target} · {property} · {keys} keyframes',
367
+ 'preview.renderedAt': ' · rendered {when}',
368
+ 'preview.lastRenderOf': 'last render · {revision}',
369
+ 'preview.thisRenderOf': 'this render · {revision}',
370
+ 'preview.changeCount': '{count} structural change(s)',
371
+ 'jobs.approval': 'approval needed: {frames} frames > threshold {threshold}',
372
+ 'jobs.approvalShort': 'approval needed: {frames} frames > {threshold}',
373
+ 'qa.noticeCount': '{count} notice(s)',
374
+ 'workbench.title': '{panel} workbench',
375
+ 'jobs.unfinished': '{count} job(s) running',
376
+ 'blender.probedAt': 'probed {when}',
377
+ 'visual.operationCount': '{count} operation(s)',
378
+ 'jobs.frameProgress': '{completed}/{expected} frames · {percent}%',
379
+ 'jobs.estimated': 'about {seconds} s left',
380
+ 'visual.counts': 'measured {measured} · model findings {findings}',
381
+ 'visual.reviewerFailed': 'the reviewer failed: {message}',
382
+ 'jobs.unfinishedShort': '{count} render(s) running',
383
+ 'jobs.frameRange': 'frames {start}–{end}',
384
+ 'revisions.title': 'Revisions ({count})',
385
+ },
386
+ }
387
+
388
+ /** The active locale id, lowercased and stripped of any region, or `en` when it is not ours. */
389
+ const LOCALE = (() => {
390
+ const named = typeof document === 'undefined' ? '' : String(document.documentElement?.lang ?? '')
391
+ const key = named.toLowerCase().split('-')[0]
392
+ return Object.prototype.hasOwnProperty.call(STRINGS, key) ? key : 'en'
393
+ })()
394
+
395
+ /**
396
+ * One string, in the active locale, with `{name}` placeholders filled from `params`.
397
+ *
398
+ * The fallback chain mirrors the platform's: the active locale, then English, then the key
399
+ * itself. The last one is for a key that exists in neither table — a hole a reader can SEE
400
+ * rather than a silent blank.
401
+ */
402
+ const t = (key, params) => {
403
+ const table = STRINGS[LOCALE] ?? STRINGS.en
404
+ const raw = table[key] ?? STRINGS.en[key] ?? key
405
+ if (params === undefined) return raw
406
+ return raw.replace(/\{(\w+)\}/g, (whole, name) => (name in params ? String(params[name]) : whole))
407
+ }
408
+ // #endregion strings
409
+
93
410
  // §B Vocabulary
94
411
  // =========================================================================
95
412
 
@@ -105,12 +422,12 @@ window.__ModuleLoader__.load({
105
422
  const POLL_IDLE_MS = 8000
106
423
 
107
424
  const VIEWS = [
108
- { id: 'projects', label: '项目' },
109
- { id: 'scene', label: '场景树' },
110
- { id: 'preview', label: '预览对比' },
111
- { id: 'jobs', label: '任务' },
425
+ { id: 'projects', label: t('tab.projects') },
426
+ { id: 'scene', label: t('tab.scene') },
427
+ { id: 'preview', label: t('tab.preview') },
428
+ { id: 'jobs', label: t('tab.jobs') },
112
429
  { id: 'qa', label: 'QA' },
113
- { id: 'revisions', label: '版本' },
430
+ { id: 'revisions', label: t('tab.revisions') },
114
431
  ]
115
432
 
116
433
  /** Every DeepBlend wire tool name this package draws a card for. */
@@ -400,14 +717,14 @@ window.__ModuleLoader__.load({
400
717
  function staleHostError(expectedRoute, observed) {
401
718
  const version = observed && observed.payload && observed.payload.hostApiVersion
402
719
  ? observed.payload.hostApiVersion
403
- : '未知'
404
- const detail = observed && observed.detail ? `观察到的响应:${observed.detail}。` : ''
720
+ : t('common.unknown')
721
+ const detail = observed && observed.detail ? t('host.observedResponse', { detail: observed.detail }) : ''
405
722
  return {
406
723
  code: 'UI_HOST_API_STALE',
407
724
  message:
408
- `本进程里的 blenderUi 比磁盘上的包旧:/deepblend/${expectedRoute} 没有被这一版的宿主回答` +
409
- `(${detail}hostApiVersion=${version},本 UI 需要 ${EXPECTED_HOST_API})。` +
410
- '重启 profile(dsh web)即可。',
725
+ t('host.stale', { route: expectedRoute }) +
726
+ t('host.staleDetail', { detail, version, needed: EXPECTED_HOST_API }) +
727
+ t('workbench.restartHint'),
411
728
  }
412
729
  }
413
730
 
@@ -443,7 +760,7 @@ window.__ModuleLoader__.load({
443
760
  payload,
444
761
  status: response.status,
445
762
  detail: payload === null
446
- ? `HTTP ${response.status},${text.length} 字节,非 JSON`
763
+ ? t('host.notJson', { status: response.status, bytes: text.length })
447
764
  : `route=${JSON.stringify(payload.route)}`,
448
765
  }),
449
766
  }
@@ -646,7 +963,7 @@ window.__ModuleLoader__.load({
646
963
  const response = await fetchImpl(`${projectRoute(target(), '/diff')}?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`, { headers: { accept: 'application/json' } })
647
964
  const parsed = await response.json()
648
965
  if (parsed && parsed.ok) set({ diff: parsed.diff })
649
- else set({ diff: null, diffError: (parsed && parsed.error) || { code: `HTTP_${response.status}`, message: 'diff 失败' } })
966
+ else set({ diff: null, diffError: (parsed && parsed.error) || { code: `HTTP_${response.status}`, message: t('preview.diffFailed') } })
650
967
  } catch (error) {
651
968
  set({ diff: null, diffError: { code: 'UI_FETCH_FAILED', message: String((error && error.message) || error) } })
652
969
  }
@@ -661,7 +978,7 @@ window.__ModuleLoader__.load({
661
978
  setIn('busy', 'create', false)
662
979
  if (outcome.ok) {
663
980
  set({ forms: { ...data.forms, title: '', goal: '' }, projectId: outcome.payload.project.projectId })
664
- setIn('notices', 'projects', { ok: true, message: `已创建 ${outcome.payload.project.projectId}` })
981
+ setIn('notices', 'projects', { ok: true, message: t('projects.created', { id: outcome.payload.project.projectId }) })
665
982
  reload()
666
983
  } else {
667
984
  setIn('notices', 'projects', { ok: false, message: `${outcome.error.code}: ${outcome.error.message}` })
@@ -674,14 +991,14 @@ window.__ModuleLoader__.load({
674
991
  try {
675
992
  patch = JSON.parse(text)
676
993
  } catch (error) {
677
- setIn('notices', 'scene', { ok: false, message: `patch 不是合法 JSON:${error.message}` })
994
+ setIn('notices', 'scene', { ok: false, message: t('scene.patchInvalidJson', { message: error.message }) })
678
995
  return
679
996
  }
680
997
  setIn('busy', 'patch', true)
681
998
  const outcome = await postJson(fetchImpl, projectRoute(target(), '/patch'), { patch })
682
999
  setIn('busy', 'patch', false)
683
1000
  if (outcome.ok) {
684
- setIn('notices', 'scene', { ok: true, message: `已提交 ${outcome.payload.revision.revision}(digest ${shortDigest(outcome.payload.revision.digest)})` })
1001
+ setIn('notices', 'scene', { ok: true, message: t('scene.committed', { revision: outcome.payload.revision.revision, digest: shortDigest(outcome.payload.revision.digest) }) })
685
1002
  setIn('forms', 'patch', null)
686
1003
  reload()
687
1004
  } else {
@@ -697,11 +1014,11 @@ window.__ModuleLoader__.load({
697
1014
  previewResult: outcome.ok
698
1015
  ? {
699
1016
  ok: true,
700
- message: `已渲染 ${outcome.payload.preview.views.length} 个视角 → 合成 ${outcome.payload.preview.revision} 的 contact sheet`
1017
+ message: t('preview.rendered', { views: outcome.payload.preview.views.length, revision: outcome.payload.preview.revision })
701
1018
  + (outcome.payload.preview.sheets && outcome.payload.preview.sheets.previous
702
- ? ';上一张已留作「上一次渲染」,可以直接并排比较'
703
- : '(这是第一张;再渲染一次就能并排比较前后)')
704
- + '。预览是产物:替换同一路径上的旧图,不产生新的 revision。',
1019
+ ? t('preview.keptPrevious')
1020
+ : t('preview.firstSheet'))
1021
+ + t('preview.isArtifact'),
705
1022
  }
706
1023
  : { ok: false, message: `${outcome.error.code}: ${outcome.error.message}` },
707
1024
  })
@@ -718,7 +1035,7 @@ window.__ModuleLoader__.load({
718
1035
  })
719
1036
  setIn('busy', 'render', false)
720
1037
  setIn('notices', 'jobs', outcome.ok
721
- ? { kind: 'render', ok: true, message: `任务 ${outcome.payload.job.jobId} 已启动(${outcome.payload.job.frames || '?'} 帧)` }
1038
+ ? { kind: 'render', ok: true, message: t('jobs.started', { jobId: outcome.payload.job.jobId, frames: outcome.payload.job.frames || '?' }) }
722
1039
  : { kind: 'render', ok: false, message: `${outcome.error.code}: ${outcome.error.message}` })
723
1040
  reload()
724
1041
  },
@@ -732,8 +1049,8 @@ window.__ModuleLoader__.load({
732
1049
  kind: 'cancel',
733
1050
  ok: outcome.payload.cancelled.processGone !== false,
734
1051
  message: outcome.payload.cancelled.processGone === false
735
- ? '取消已请求,但进程仍在'
736
- : `已取消 ${jobId},进程实测已消失`,
1052
+ ? t('jobs.cancelRequested')
1053
+ : t('jobs.cancelled', { jobId }),
737
1054
  }
738
1055
  : { kind: 'cancel', ok: false, message: `${outcome.error.code}: ${outcome.error.message}` })
739
1056
  reload()
@@ -744,7 +1061,7 @@ window.__ModuleLoader__.load({
744
1061
  const outcome = await postJson(fetchImpl, projectRoute(target(), '/restore'), { revision })
745
1062
  setIn('busy', 'restore', false)
746
1063
  setIn('notices', 'revisions', outcome.ok
747
- ? { ok: true, message: `已恢复 ${revision} 为 ${outcome.payload.revision.revision}` }
1064
+ ? { ok: true, message: t('revisions.restored', { from: revision, to: outcome.payload.revision.revision }) }
748
1065
  : { ok: false, message: `${outcome.error.code}: ${outcome.error.message}` })
749
1066
  reload()
750
1067
  },
@@ -787,9 +1104,9 @@ window.__ModuleLoader__.load({
787
1104
  return el('div', { 'data-view': 'projects' },
788
1105
  ErrorBox({ error: state.error }),
789
1106
  el('div', { className: 'db-card' },
790
- el('h4', null, '项目'),
1107
+ el('h4', null, t('tab.projects')),
791
1108
  state.projects.length === 0
792
- ? el('div', { className: 'db-muted' }, '这个工作区还没有项目。')
1109
+ ? el('div', { className: 'db-muted' }, t('projects.empty'))
793
1110
  : el('ul', { className: 'db-list' }, state.projects.map(project => el('li', { key: project.projectId, 'data-project': project.projectId },
794
1111
  el('div', { className: 'db-inline' },
795
1112
  Button({
@@ -799,29 +1116,29 @@ window.__ModuleLoader__.load({
799
1116
  children: project.title || project.projectId,
800
1117
  }),
801
1118
  el('span', { className: 'db-muted db-mono' }, project.projectId),
802
- project.unfinishedJobs > 0 ? Badge({ tone: 'live', name: 'unfinished', children: `${project.unfinishedJobs} 个任务在跑` }) : null,
803
- el('span', { className: 'db-muted' }, `${project.revisionCount} 个 revision`),
1119
+ project.unfinishedJobs > 0 ? Badge({ tone: 'live', name: 'unfinished', children: t('projects.unfinishedJobs', { count: project.unfinishedJobs }) }) : null,
1120
+ el('span', { className: 'db-muted' }, t('projects.revisionCount', { count: project.revisionCount })),
804
1121
  ),
805
- el('div', { className: 'db-muted db-mono' }, `当前 ${project.currentRevision || '—'} · 更新于 ${formatTime(project.updatedAt)}`),
1122
+ el('div', { className: 'db-muted db-mono' }, t('projects.updated', { revision: project.currentRevision || '—', when: formatTime(project.updatedAt) })),
806
1123
  project.goal ? el('div', { className: 'db-muted' }, project.goal) : null,
807
1124
  ))),
808
1125
  el('div', { className: 'db-muted db-mono', style: { marginTop: '6px' } }, `projectsRoot: ${state.projectsRoot || '—'}`),
809
1126
  ),
810
1127
 
811
1128
  el('div', { className: 'db-card' },
812
- el('h4', null, '新建项目(写操作经 Host)'),
1129
+ el('h4', null, t('projects.create')),
813
1130
  el('div', { className: 'db-inline' },
814
1131
  el('input', {
815
1132
  className: 'db-input',
816
1133
  'data-field': 'project-title',
817
- placeholder: '标题,例如 watch-commercial',
1134
+ placeholder: t('projects.titlePlaceholder'),
818
1135
  value: state.forms.title,
819
1136
  onChange: event => actions.setForm('title', event.target.value),
820
1137
  }),
821
1138
  el('input', {
822
1139
  className: 'db-input',
823
1140
  'data-field': 'project-goal',
824
- placeholder: '目标(可选)',
1141
+ placeholder: t('projects.goal'),
825
1142
  value: state.forms.goal,
826
1143
  onChange: event => actions.setForm('goal', event.target.value),
827
1144
  }),
@@ -830,28 +1147,28 @@ window.__ModuleLoader__.load({
830
1147
  action: 'create-project',
831
1148
  disabled: state.busy.create || state.forms.title.trim().length === 0,
832
1149
  onClick: actions.createProject,
833
- children: state.busy.create ? '创建中…' : '创建',
1150
+ children: state.busy.create ? t('projects.creating') : t('projects.createButton'),
834
1151
  }),
835
1152
  ),
836
1153
  state.notices.projects ? Notice(state.notices.projects, { marginTop: '8px' }) : null,
837
1154
  ),
838
1155
 
839
1156
  state.selected ? el('div', { className: 'db-card' },
840
- el('h4', null, `当前项目:${state.selected.project.title}`),
1157
+ el('h4', null, t('projects.currentProject', { title: state.selected.project.title })),
841
1158
  KeyValues({ entries: [
842
1159
  { label: 'projectId', value: state.selected.project.projectId },
843
- { label: '当前 revision', value: state.selected.currentRevision },
844
- { label: 'revision 数', value: state.selected.project.revisionCount },
1160
+ { label: t('scene.currentRevision'), value: state.selected.currentRevision },
1161
+ { label: t('scene.revisionCount'), value: state.selected.project.revisionCount },
845
1162
  { label: 'digest', value: shortDigest(state.selected.scene.digest) },
846
- { label: '帧范围', value: `${state.selected.scene.project.frameStart}–${state.selected.scene.project.frameEnd} @ ${state.selected.scene.project.fps}fps` },
847
- { label: '活动相机', value: state.selected.scene.project.activeCamera },
848
- { label: '对象 / 材质 / 灯 / 相机', value: [
1163
+ { label: t('scene.frameRange'), value: `${state.selected.scene.project.frameStart}–${state.selected.scene.project.frameEnd} @ ${state.selected.scene.project.fps}fps` },
1164
+ { label: t('scene.activeCamera'), value: state.selected.scene.project.activeCamera },
1165
+ { label: t('scene.counts'), value: [
849
1166
  state.selected.scene.counts.entities, state.selected.scene.counts.materials,
850
1167
  state.selected.scene.counts.lights, state.selected.scene.counts.cameras,
851
1168
  ].join(' / ') },
852
1169
  ] }),
853
1170
  el('div', { className: 'db-muted', style: { marginTop: '6px' } }, state.selected.qa.summary),
854
- ) : el('div', { className: 'db-card db-muted' }, '尚未选择项目。'),
1171
+ ) : el('div', { className: 'db-card db-muted' }, t('common.noProject')),
855
1172
  )
856
1173
  }
857
1174
 
@@ -871,7 +1188,7 @@ window.__ModuleLoader__.load({
871
1188
  const state = ctx.state
872
1189
  const actions = ctx.actions
873
1190
  const scene = state.selected ? state.selected.scene : null
874
- if (scene === null) return el('div', { 'data-view': 'scene', className: 'db-muted' }, '尚未选择项目。')
1191
+ if (scene === null) return el('div', { 'data-view': 'scene', className: 'db-muted' }, t('common.noProject'))
875
1192
 
876
1193
  const template = JSON.stringify({
877
1194
  baseRevision: state.selected.currentRevision,
@@ -886,7 +1203,7 @@ window.__ModuleLoader__.load({
886
1203
  const section = (title, items, render) => el('div', { className: 'db-card', key: title },
887
1204
  el('h4', null, `${title}(${items.length})`),
888
1205
  items.length === 0
889
- ? el('div', { className: 'db-muted' }, '空')
1206
+ ? el('div', { className: 'db-muted' }, t('common.empty'))
890
1207
  : el('ul', { className: 'db-list' }, items.map(item => el('li', { key: item.id }, render(item)))))
891
1208
 
892
1209
  return el('div', { 'data-view': 'scene' },
@@ -896,45 +1213,45 @@ window.__ModuleLoader__.load({
896
1213
  el('strong', null, String(scene.project.title || scene.project.id || '')),
897
1214
  Badge({ children: scene.revision }),
898
1215
  el('span', { className: 'db-muted db-mono' }, `digest ${shortDigest(scene.digest)}`),
899
- Badge({ children: `world ${scene.world ? `${(scene.world.color || []).join(',')} × ${scene.world.strength}` : '默认'}` }),
1216
+ Badge({ children: `world ${scene.world ? `${(scene.world.color || []).join(',')} × ${scene.world.strength}` : t('common.default')}` }),
900
1217
  ),
901
1218
  ),
902
1219
  el('div', { className: 'db-grid' },
903
- section('实体 entities', scene.nodes.entities, entity => el('div', null,
1220
+ section(t('scene.entities'), scene.nodes.entities, entity => el('div', null,
904
1221
  el('span', { className: 'db-mono', 'data-node': `entity:${entity.id}` }, entity.id), ' ',
905
1222
  el('span', { className: 'db-kind' }, entity.shape || entity.kind),
906
- entity.materialId ? el('span', { className: 'db-muted' }, `材质 ${entity.materialId}`) : null,
1223
+ entity.materialId ? el('span', { className: 'db-muted' }, t('scene.material', { id: entity.materialId })) : null,
907
1224
  entity.locked ? Badge({ tone: 'warn', children: 'locked' }) : null,
908
1225
  entity.tags.length > 0 ? el('span', { className: 'db-muted db-mono' }, ` tags=[${entity.tags.join(' ')}]`) : null,
909
1226
  entity.transform ? el('div', { className: 'db-muted db-mono' }, `loc ${(entity.transform.location || []).map(value => Number(value).toFixed(3)).join(', ')}`) : null)),
910
- section('材质 materials', scene.nodes.materials, material => el('div', null,
1227
+ section(t('scene.materials'), scene.nodes.materials, material => el('div', null,
911
1228
  el('span', { className: 'db-mono', 'data-node': `material:${material.id}` }, material.id), ' ',
912
1229
  el('span', { className: 'db-kind' }, material.shader),
913
1230
  material.parameters ? el('span', { className: 'db-muted db-mono' }, Object.entries(material.parameters).slice(0, 4).map(([key, value]) => `${key}=${Array.isArray(value) ? `[${value.join(',')}]` : value}`).join(' ')) : null)),
914
- section('灯光 lights', scene.nodes.lights, light => el('div', null,
1231
+ section(t('scene.lights'), scene.nodes.lights, light => el('div', null,
915
1232
  el('span', { className: 'db-mono', 'data-node': `light:${light.id}` }, light.id), ' ',
916
1233
  el('span', { className: 'db-kind' }, light.type),
917
1234
  el('span', { className: 'db-muted' }, `energy ${light.energy}`))),
918
- section('相机 cameras', scene.nodes.cameras, camera => el('div', null,
1235
+ section(t('scene.cameras'), scene.nodes.cameras, camera => el('div', null,
919
1236
  el('span', { className: 'db-mono', 'data-node': `camera:${camera.id}` }, camera.id), ' ',
920
1237
  camera.isActive ? Badge({ tone: 'ok', children: 'active' }) : null, ' ',
921
1238
  el('span', { className: 'db-kind' }, camera.role || 'no role'),
922
1239
  el('span', { className: 'db-muted' }, `lens ${camera.lens}`))),
923
- section('镜头 shots', scene.nodes.shots, shot => el('div', null,
1240
+ section(t('scene.shots'), scene.nodes.shots, shot => el('div', null,
924
1241
  el('span', { className: 'db-mono', 'data-node': `shot:${shot.id}` }, shot.id), ' ',
925
1242
  el('span', { className: 'db-muted' }, `${shot.cameraId} ${(shot.frameRange || []).join('–')}`))),
926
- section('动画轨道 animationTracks', scene.nodes.animationTracks, track => el('div', null,
1243
+ section(t('scene.animationTracks'), scene.nodes.animationTracks, track => el('div', null,
927
1244
  el('span', { className: 'db-mono', 'data-node': `track:${track.id}` }, track.id), ' ',
928
1245
  el('span', { className: 'db-kind' }, track.targetKind),
929
- el('span', { className: 'db-muted' }, `${track.targetId} · ${track.property} · ${track.keyframes} 关键帧`))),
930
- section('资产 assets', scene.nodes.assets, asset => el('div', null,
1246
+ el('span', { className: 'db-muted' }, t('scene.track', { target: track.targetId, property: track.property, keys: track.keyframes })))),
1247
+ section(t('scene.assets'), scene.nodes.assets, asset => el('div', null,
931
1248
  el('span', { className: 'db-mono', 'data-node': `asset:${asset.id}` }, asset.id), ' ',
932
1249
  el('span', { className: 'db-kind' }, asset.type),
933
1250
  el('span', { className: 'db-muted db-mono' }, String(asset.path || '')))),
934
1251
  ),
935
1252
 
936
1253
  el('div', { className: 'db-card' },
937
- el('h4', null, 'ScenePatch(写操作经 Host,原子提交为一个 revision)'),
1254
+ el('h4', null, t('scene.patch')),
938
1255
  el('textarea', {
939
1256
  className: 'db-area',
940
1257
  'data-field': 'scene-patch',
@@ -948,9 +1265,9 @@ window.__ModuleLoader__.load({
948
1265
  action: 'apply-patch',
949
1266
  disabled: state.busy.patch || state.activeProjectId === null,
950
1267
  onClick: actions.applyPatch,
951
- children: state.busy.patch ? '提交中…' : '提交',
1268
+ children: state.busy.patch ? t('scene.submitting') : t('scene.submit'),
952
1269
  }),
953
- Button({ action: 'reset-patch', onClick: () => actions.setForm('patch', null), children: '重置模板' }),
1270
+ Button({ action: 'reset-patch', onClick: () => actions.setForm('patch', null), children: t('scene.resetTemplate') }),
954
1271
  Notice(state.notices.scene, { border: 0, padding: '0 6px', marginBottom: 0 }),
955
1272
  ),
956
1273
  ),
@@ -971,7 +1288,7 @@ window.__ModuleLoader__.load({
971
1288
  if (entry === null) return null
972
1289
  const sheets = entry.contactSheets || []
973
1290
  const current = sheets.find(sheet => sheet.slot === 'preview-current')
974
- if (current && current.path) return { ...current, label: '本次渲染' }
1291
+ if (current && current.path) return { ...current, label: t('preview.thisRender') }
975
1292
  const sheet = sheets[sheets.length - 1]
976
1293
  if (sheet && sheet.path) return { ...sheet, label: 'contact sheet' }
977
1294
  const list = entry.previews || []
@@ -1010,8 +1327,8 @@ window.__ModuleLoader__.load({
1010
1327
  }
1011
1328
  }
1012
1329
  return {
1013
- current: current === null ? null : { ...current, label: '本次渲染', sourceRevision: entry.revision },
1014
- previous: previous === null ? null : { ...previous, label: '上一次渲染', sourceRevision: previousRevision },
1330
+ current: current === null ? null : { ...current, label: t('preview.thisRender'), sourceRevision: entry.revision },
1331
+ previous: previous === null ? null : { ...previous, label: t('preview.lastRender'), sourceRevision: previousRevision },
1015
1332
  }
1016
1333
  }
1017
1334
 
@@ -1020,7 +1337,7 @@ window.__ModuleLoader__.load({
1020
1337
  const state = ctx.state
1021
1338
  const actions = ctx.actions
1022
1339
  const previews = state.previews
1023
- if (previews === null) return el('div', { 'data-view': 'preview', className: 'db-muted' }, '尚未选择项目。')
1340
+ if (previews === null) return el('div', { 'data-view': 'preview', className: 'db-muted' }, t('common.noProject'))
1024
1341
  const revisions = previews.revisions
1025
1342
  const pick = wanted => (revisions.some(entry => entry.revision === wanted) ? wanted : (((revisions[revisions.length - 1] || {}).revision) || null))
1026
1343
  const left = pick(state.compareLeft || state.currentRevision)
@@ -1046,23 +1363,23 @@ window.__ModuleLoader__.load({
1046
1363
  src: artifactUrl(state.artifactBase, artifact),
1047
1364
  }),
1048
1365
  el('div', { className: 'db-muted db-mono', key: 'meta' },
1049
- `${artifact.sourceRevision ? `${artifact.sourceRevision} ` : ''}${artifact.slot ? artifact.slot : (artifact.kind || 'artifact')} · ${artifact.sha256 ? String(artifact.sha256).slice(0, 10) : '—'}${when === null ? '' : ` · 渲染于 ${when}`}`),
1366
+ `${artifact.sourceRevision ? `${artifact.sourceRevision} ` : ''}${artifact.slot ? artifact.slot : (artifact.kind || 'artifact')} · ${artifact.sha256 ? String(artifact.sha256).slice(0, 10) : '—'}${when === null ? '' : t('preview.renderedAt', { when })}`),
1050
1367
  ].filter(Boolean))
1051
1368
  }
1052
1369
 
1053
- const revisionMeta = entry => el('div', { className: 'db-muted', key: 'meta' }, `${entry.summary || '(无说明)'} · ${formatTime(entry.createdAt)}`)
1370
+ const revisionMeta = entry => el('div', { className: 'db-muted', key: 'meta' }, `${entry.summary || t('common.noNote')} · ${formatTime(entry.createdAt)}`)
1054
1371
 
1055
1372
  /** The revision axis: two revisions side by side, each one's newest image. */
1056
1373
  const revisionPane = (entry, side) => el('div', { className: 'db-shot', 'data-compare': side },
1057
- el('h5', null, entry === null ? '—' : `${entry.revision}${entry.isCurrent ? ' (当前)' : ''}`),
1374
+ el('h5', null, entry === null ? '—' : `${entry.revision}${entry.isCurrent ? t('revisions.currentSuffix') : ''}`),
1058
1375
  entry === null
1059
- ? el('div', { className: 'db-muted' }, '没有这个 revision')
1376
+ ? el('div', { className: 'db-muted' }, t('revisions.missing'))
1060
1377
  : [
1061
1378
  revisionMeta(entry),
1062
1379
  (() => {
1063
1380
  const sheet = sheetOf(entry)
1064
1381
  return sheet === null
1065
- ? el('div', { className: 'db-muted', key: 'none' }, '这个 revision 还没有预览图(渲染一次预览即可)。')
1382
+ ? el('div', { className: 'db-muted', key: 'none' }, t('preview.noneForRevision'))
1066
1383
  : el('img', {
1067
1384
  key: 'img',
1068
1385
  'data-artifact': sheet.path,
@@ -1078,7 +1395,7 @@ window.__ModuleLoader__.load({
1078
1395
  const sheet = sheetOf(entry)
1079
1396
  const when = artifactTime(sheet)
1080
1397
  return sheet === null ? null : el('div', { className: 'db-muted db-mono', key: 'sheet' },
1081
- `${sheet.sha256 ? String(sheet.sha256).slice(0, 10) : '—'}${when === null ? '' : ` · 渲染于 ${when}`}`)
1398
+ `${sheet.sha256 ? String(sheet.sha256).slice(0, 10) : '—'}${when === null ? '' : t('preview.renderedAt', { when })}`)
1082
1399
  })(),
1083
1400
  el('div', { className: 'db-muted db-mono', key: 'counts' }, `previews ${(entry.previews || []).length} · sheets ${(entry.contactSheets || []).length} · reviews ${(entry.reviews || []).length}`),
1084
1401
  (entry.reviews || []).length > 0
@@ -1102,10 +1419,10 @@ window.__ModuleLoader__.load({
1102
1419
  const pair = renderPairOf(entryOf(right), revisions)
1103
1420
  const rendersMode = state.compareMode !== 'revisions'
1104
1421
  const renderPairPanes = [
1105
- imagePane('left', pair.previous === null ? '上一次渲染' : `上一次渲染 · ${pair.previous.sourceRevision}`, pair.previous,
1106
- '还没有上一次渲染:再点一次「渲染预览」,或者先在某个更早的 revision 上渲一次。'),
1107
- imagePane('right', `本次渲染 · ${right}`, pair.current,
1108
- '这个 revision 还没有由面板渲过预览(上方的「渲染预览」会生成第一张)。'),
1422
+ imagePane('left', pair.previous === null ? t('preview.lastRender') : t('preview.lastRenderOf', { revision: pair.previous.sourceRevision }), pair.previous,
1423
+ t('preview.noPrevious')),
1424
+ imagePane('right', t('preview.thisRenderOf', { revision: right }), pair.current,
1425
+ t('preview.notRenderedHere')),
1109
1426
  ]
1110
1427
 
1111
1428
  return el('div', { 'data-view': 'preview' },
@@ -1116,24 +1433,24 @@ window.__ModuleLoader__.load({
1116
1433
  action: 'render-preview',
1117
1434
  disabled: state.previewBusy || state.activeProjectId === null,
1118
1435
  onClick: actions.renderPreview,
1119
- children: state.previewBusy ? '渲染中…' : '渲染预览',
1436
+ children: state.previewBusy ? t('preview.rendering') : t('preview.render'),
1120
1437
  }),
1121
1438
  Notice(state.previewResult, { border: 0, padding: '0 6px', marginBottom: 0 }),
1122
1439
  ),
1123
1440
  el('div', { className: 'db-tabs' },
1124
- el('span', { className: 'db-muted' }, '比较:'),
1441
+ el('span', { className: 'db-muted' }, t('preview.compare')),
1125
1442
  el('button', {
1126
1443
  type: 'button', className: 'db-btn', 'data-compare-mode': 'renders', 'data-active': String(rendersMode),
1127
1444
  onClick: () => actions.setCompareMode('renders'),
1128
- }, '上一次 vs 本次渲染'),
1445
+ }, t('preview.lastVsThis')),
1129
1446
  el('button', {
1130
1447
  type: 'button', className: 'db-btn', 'data-compare-mode': 'revisions', 'data-active': String(!rendersMode),
1131
1448
  onClick: () => actions.setCompareMode('revisions'),
1132
- }, '两个 revision'),
1449
+ }, t('preview.twoRevisions')),
1133
1450
  el('span', { style: { flex: 1 } }),
1134
1451
  rendersMode
1135
1452
  ? el('span', { className: 'db-inline' },
1136
- el('span', { className: 'db-muted' }, '版本'),
1453
+ el('span', { className: 'db-muted' }, t('tab.revisions')),
1137
1454
  el('select', {
1138
1455
  className: 'db-input', 'data-field': 'compare-revision', style: { width: 'auto' }, value: right || '',
1139
1456
  onChange: event => {
@@ -1151,14 +1468,14 @@ window.__ModuleLoader__.load({
1151
1468
  className: 'db-input', 'data-field': 'compare-right', style: { width: 'auto' }, value: right || '',
1152
1469
  onChange: event => actions.pickCompare('right', event.target.value),
1153
1470
  }, revisions.map(entry => el('option', { key: entry.revision, value: entry.revision }, entry.revision))),
1154
- state.activeProjectId ? Button({ action: 'diff', onClick: () => actions.diff(left, right), children: '看结构差异' }) : null),
1471
+ state.activeProjectId ? Button({ action: 'diff', onClick: () => actions.diff(left, right), children: t('preview.structuralDiff') }) : null),
1155
1472
  ),
1156
1473
  el('div', { style: { paddingTop: '2px' } },
1157
1474
  el('div', { className: 'db-grid' }, rendersMode
1158
1475
  ? renderPairPanes
1159
1476
  : [revisionPane(entryOf(left), 'left'), revisionPane(entryOf(right), 'right')]),
1160
1477
  state.diff ? el('div', { className: 'db-card', 'data-diff': state.diff.identical ? 'identical' : 'changed' },
1161
- el('h4', null, `${state.diff.fromRevision} → ${state.diff.toRevision}:${state.diff.identical ? '结构完全相同' : `${state.diff.totalChanges} 处结构变化`}`),
1478
+ el('h4', null, `${state.diff.fromRevision} → ${state.diff.toRevision}:${state.diff.identical ? t('preview.identical') : t('preview.changeCount', { count: state.diff.totalChanges })}`),
1162
1479
  state.diff.identical ? null : el('div', null,
1163
1480
  Object.entries(state.diff.collections)
1164
1481
  .filter(([, entry]) => entry.added.length + entry.removed.length + entry.changed.length > 0)
@@ -1181,25 +1498,25 @@ window.__ModuleLoader__.load({
1181
1498
  function JobsView(ctx) {
1182
1499
  const state = ctx.state
1183
1500
  const actions = ctx.actions
1184
- if (state.activeProjectId === null) return el('div', { 'data-view': 'jobs', className: 'db-muted' }, '尚未选择项目。')
1501
+ if (state.activeProjectId === null) return el('div', { 'data-view': 'jobs', className: 'db-muted' }, t('common.noProject'))
1185
1502
  const jobs = state.jobs || []
1186
1503
 
1187
1504
  return el('div', { 'data-view': 'jobs' },
1188
1505
  ErrorBox({ error: state.error }),
1189
1506
  el('div', { className: 'db-card' },
1190
- el('h4', null, '任务'),
1507
+ el('h4', null, t('tab.jobs')),
1191
1508
  jobs.length === 0
1192
- ? el('div', { className: 'db-muted' }, '还没有渲染任务。')
1509
+ ? el('div', { className: 'db-muted' }, t('jobs.empty'))
1193
1510
  : el('ul', { className: 'db-list' }, jobs.map(job => el('li', { key: job.jobId, 'data-job': job.jobId },
1194
1511
  el('div', { className: 'db-inline' },
1195
1512
  el('span', { className: 'db-mono' }, job.jobId),
1196
1513
  Badge({ tone: statusTone(job.status), children: job.status }),
1197
1514
  el('span', { className: 'db-muted' }, job.type),
1198
- el('span', { className: 'db-muted db-mono' }, `帧 ${job.frameStart}–${job.frameEnd}`),
1199
- job.approval && job.approval.required ? Badge({ tone: 'warn', name: 'approval', children: `需审批:${job.approval.frames} 帧 > 阈值 ${job.approval.threshold}` }) : null,
1200
- job.deliverable ? Badge({ tone: 'ok', children: '交付已校验' }) : null,
1201
- job.cancelable ? Button({ tone: 'danger', action: `cancel:${job.jobId}`, disabled: state.busy.render, onClick: () => actions.cancelJob(job.jobId), children: '取消' }) : null,
1202
- job.resumable ? Button({ action: `resume:${job.jobId}`, disabled: state.busy.render, onClick: () => actions.startRender(job.jobId), children: '继续渲染' }) : null,
1515
+ el('span', { className: 'db-muted db-mono' }, t('jobs.frameRange', { start: job.frameStart, end: job.frameEnd })),
1516
+ job.approval && job.approval.required ? Badge({ tone: 'warn', name: 'approval', children: t('jobs.approval', { frames: job.approval.frames, threshold: job.approval.threshold }) }) : null,
1517
+ job.deliverable ? Badge({ tone: 'ok', children: t('jobs.deliveryVerified') }) : null,
1518
+ job.cancelable ? Button({ tone: 'danger', action: `cancel:${job.jobId}`, disabled: state.busy.render, onClick: () => actions.cancelJob(job.jobId), children: t('jobs.cancel') }) : null,
1519
+ job.resumable ? Button({ action: `resume:${job.jobId}`, disabled: state.busy.render, onClick: () => actions.startRender(job.jobId), children: t('jobs.resume') }) : null,
1203
1520
  ),
1204
1521
  ProgressBar({ percent: job.progress.percent }),
1205
1522
  el('div', { className: 'db-muted', 'data-job-detail': job.jobId }, `${job.detail} · ${job.progress.percent}% · 缺失 ${job.progress.missing} · 损坏 ${job.progress.corrupt}`),
@@ -1209,11 +1526,11 @@ window.__ModuleLoader__.load({
1209
1526
  ),
1210
1527
 
1211
1528
  el('div', { className: 'db-card' },
1212
- el('h4', null, '启动一次交付渲染(写操作经 Host)'),
1529
+ el('h4', null, t('jobs.startDelivery')),
1213
1530
  el('div', { className: 'db-inline' },
1214
- el('label', { className: 'db-muted' }, '帧起'),
1531
+ el('label', { className: 'db-muted' }, t('jobs.frameStart')),
1215
1532
  el('input', { className: 'db-input', 'data-field': 'frame-start', style: { width: '90px' }, value: state.forms.frameStart, onChange: event => actions.setForm('frameStart', event.target.value) }),
1216
- el('label', { className: 'db-muted' }, '帧止'),
1533
+ el('label', { className: 'db-muted' }, t('jobs.frameEnd')),
1217
1534
  el('input', { className: 'db-input', 'data-field': 'frame-end', style: { width: '90px' }, value: state.forms.frameEnd, onChange: event => actions.setForm('frameEnd', event.target.value) }),
1218
1535
  el('label', { className: 'db-muted' }, 'profile'),
1219
1536
  el('select', {
@@ -1222,7 +1539,7 @@ window.__ModuleLoader__.load({
1222
1539
  },
1223
1540
  el('option', { value: 'preview' }, 'preview'),
1224
1541
  el('option', { value: 'final' }, 'final')),
1225
- Button({ tone: 'primary', action: 'start-render', disabled: state.busy.render, onClick: () => actions.startRender(undefined), children: state.busy.render ? '提交中…' : '启动' }),
1542
+ Button({ tone: 'primary', action: 'start-render', disabled: state.busy.render, onClick: () => actions.startRender(undefined), children: state.busy.render ? t('scene.submitting') : t('jobs.start') }),
1226
1543
  ),
1227
1544
  Notice(state.notices.jobs, { marginTop: '8px' }),
1228
1545
  ),
@@ -1233,7 +1550,7 @@ window.__ModuleLoader__.load({
1233
1550
  function QaView(ctx) {
1234
1551
  const state = ctx.state
1235
1552
  const qa = state.selected ? state.selected.qa : null
1236
- if (qa === null || qa === undefined) return el('div', { 'data-view': 'qa', className: 'db-muted' }, '尚未选择项目。')
1553
+ if (qa === null || qa === undefined) return el('div', { 'data-view': 'qa', className: 'db-muted' }, t('common.noProject'))
1237
1554
  const issueList = (issues, keyPrefix) => el('ul', { className: 'db-list' }, issues.map((issue, index) => el('li', { key: `${keyPrefix}${index}` },
1238
1555
  el('div', { className: 'db-inline' },
1239
1556
  Badge({ tone: issue.severity === 'critical' ? 'bad' : issue.severity === 'major' ? 'warn' : 'muted', children: issue.severity || '—' }),
@@ -1248,49 +1565,49 @@ window.__ModuleLoader__.load({
1248
1565
  el('div', { className: 'db-card', 'data-qa-revision': qa.revision },
1249
1566
  el('div', { className: 'db-inline' },
1250
1567
  el('strong', null, `QA · ${qa.revision}`),
1251
- Badge({ tone: qa.technical.ok ? 'ok' : 'bad', children: qa.technical.available ? (qa.technical.ok ? '技术校验通过' : `技术错误 ${qa.technical.errorCount}`) : '无技术校验记录' }),
1252
- qa.visual.available ? Badge({ tone: qa.visual.pass ? 'ok' : 'warn', children: `视觉评分 ${qa.visual.score}` }) : Badge({ children: '未跑视觉评审' }),
1253
- qa.semantic.noticeCount > 0 ? Badge({ tone: 'warn', children: `${qa.semantic.noticeCount} 条 notices` }) : null,
1568
+ Badge({ tone: qa.technical.ok ? 'ok' : 'bad', children: qa.technical.available ? (qa.technical.ok ? t('qa.passed') : t('qa.errorCount', { count: qa.technical.errorCount })) : t('qa.none') }),
1569
+ qa.visual.available ? Badge({ tone: qa.visual.pass ? 'ok' : 'warn', children: t('visual.scoreIs', { score: qa.visual.score }) }) : Badge({ children: t('visual.notRun') }),
1570
+ qa.semantic.noticeCount > 0 ? Badge({ tone: 'warn', children: t('qa.noticeCount', { count: qa.semantic.noticeCount }) }) : null,
1254
1571
  ),
1255
1572
  el('div', { className: 'db-muted', style: { marginTop: '4px' } }, qa.summary),
1256
1573
  ),
1257
1574
 
1258
1575
  el('div', { className: 'db-card' },
1259
- el('h4', null, '技术校验(validation.json)'),
1576
+ el('h4', null, t('qa.title')),
1260
1577
  KeyValues({ entries: [
1261
- { label: '引擎', value: qa.technical.engine },
1262
- { label: '活动相机', value: qa.technical.activeCamera },
1263
- { label: '帧范围', value: qa.technical.frameRange ? qa.technical.frameRange.join('–') : null },
1264
- { label: '对象', value: qa.technical.counts ? qa.technical.counts.objects : null },
1265
- { label: '材质', value: qa.technical.counts ? qa.technical.counts.materials : null },
1266
- { label: '相机', value: qa.technical.counts ? qa.technical.counts.cameraObjects : null },
1578
+ { label: t('qa.engine'), value: qa.technical.engine },
1579
+ { label: t('scene.activeCamera'), value: qa.technical.activeCamera },
1580
+ { label: t('scene.frameRange'), value: qa.technical.frameRange ? qa.technical.frameRange.join('–') : null },
1581
+ { label: t('qa.objects'), value: qa.technical.counts ? qa.technical.counts.objects : null },
1582
+ { label: t('qa.materials'), value: qa.technical.counts ? qa.technical.counts.materials : null },
1583
+ { label: t('qa.cameras'), value: qa.technical.counts ? qa.technical.counts.cameraObjects : null },
1267
1584
  ] }),
1268
1585
  qa.technical.errors.length === 0
1269
- ? el('div', { className: 'db-muted' }, '没有技术错误。')
1586
+ ? el('div', { className: 'db-muted' }, t('qa.noErrors'))
1270
1587
  : issueList(qa.technical.errors, 'tech'),
1271
1588
  qa.semantic.notices.length === 0 ? null : el('div', { style: { marginTop: '8px' } },
1272
- el('h4', null, '编译器 notices'),
1589
+ el('h4', null, t('qa.notices')),
1273
1590
  el('ul', { className: 'db-list' }, qa.semantic.notices.map((notice, index) => el('li', { key: `n${index}` },
1274
1591
  el('span', { className: 'db-mono' }, notice.code || ''), ' ', notice.message || '')))),
1275
1592
  ),
1276
1593
 
1277
1594
  el('div', { className: 'db-card' },
1278
- el('h4', null, '视觉评审(测量 + 模型 finding,两个来源不合并)'),
1595
+ el('h4', null, t('visual.title')),
1279
1596
  qa.visual.available
1280
1597
  ? el('div', null,
1281
1598
  KeyValues({ entries: [
1282
- { label: '分数', value: qa.visual.score },
1283
- { label: '通过', value: qa.visual.pass ? '是' : '否' },
1284
- { label: '轮次', value: qa.visual.iteration },
1285
- { label: '主体', value: qa.visual.subjectId },
1286
- { label: '视角数', value: qa.visual.viewCount },
1287
- { label: '审查器', value: qa.visual.reviewerAvailable ? (qa.visual.reviewerModel || '已调用') : (qa.visual.reviewerError || '未调用') },
1599
+ { label: t('visual.score'), value: qa.visual.score },
1600
+ { label: t('common.yes'), value: qa.visual.pass ? t('common.yesShort') : t('common.noShort') },
1601
+ { label: t('visual.rounds'), value: qa.visual.iteration },
1602
+ { label: t('visual.subjects'), value: qa.visual.subjectId },
1603
+ { label: t('visual.views'), value: qa.visual.viewCount },
1604
+ { label: t('visual.reviewer'), value: qa.visual.reviewerAvailable ? (qa.visual.reviewerModel || t('visual.called')) : (qa.visual.reviewerError || t('visual.notCalled')) },
1288
1605
  ] }),
1289
- qa.visual.measuredIssues.length === 0 ? el('div', { className: 'db-muted' }, '测量没有发现问题。') : issueList(qa.visual.measuredIssues, 'm'),
1606
+ qa.visual.measuredIssues.length === 0 ? el('div', { className: 'db-muted' }, t('visual.noFindings')) : issueList(qa.visual.measuredIssues, 'm'),
1290
1607
  qa.visual.findings.length === 0
1291
- ? el('div', { className: 'db-muted' }, qa.visual.reviewerAvailable ? '审查器没有报告 finding。' : '没有第二意见。')
1608
+ ? el('div', { className: 'db-muted' }, qa.visual.reviewerAvailable ? t('visual.reviewerSilent') : t('visual.noSecondOpinion'))
1292
1609
  : issueList(qa.visual.findings, 'f'))
1293
- : el('div', { className: 'db-muted' }, '这个 revision 还没有视觉评审。可以用 blender_visual_review 跑一次。'),
1610
+ : el('div', { className: 'db-muted' }, t('visual.none')),
1294
1611
  ),
1295
1612
  )
1296
1613
  }
@@ -1299,24 +1616,24 @@ window.__ModuleLoader__.load({
1299
1616
  function RevisionsView(ctx) {
1300
1617
  const state = ctx.state
1301
1618
  const actions = ctx.actions
1302
- if (state.activeProjectId === null) return el('div', { 'data-view': 'revisions', className: 'db-muted' }, '尚未选择项目。')
1619
+ if (state.activeProjectId === null) return el('div', { 'data-view': 'revisions', className: 'db-muted' }, t('common.noProject'))
1303
1620
  const revisions = (state.selected && state.selected.revisions) || []
1304
1621
 
1305
1622
  return el('div', { 'data-view': 'revisions' },
1306
1623
  ErrorBox({ error: state.error }),
1307
1624
  el('div', { className: 'db-card' },
1308
- el('h4', null, `版本(${revisions.length})`),
1625
+ el('h4', null, t('revisions.title', { count: revisions.length })),
1309
1626
  el('ul', { className: 'db-list' }, revisions.map(entry => el('li', { key: entry.revision, 'data-revision': entry.revision },
1310
1627
  el('div', { className: 'db-inline' },
1311
1628
  el('span', { className: 'db-mono' }, entry.revision),
1312
- entry.isCurrent ? Badge({ tone: 'ok', children: '当前' }) : null,
1629
+ entry.isCurrent ? Badge({ tone: 'ok', children: t('revisions.current') }) : null,
1313
1630
  el('span', { className: 'db-muted' }, entry.kind || '—'),
1314
1631
  el('span', { className: 'db-muted' }, formatTime(entry.createdAt)),
1315
1632
  el('span', { className: 'db-muted db-mono' }, `digest ${shortDigest(entry.digest)}`),
1316
- Button({ action: `compare-from:${entry.revision}`, onClick: () => actions.compareFrom(entry.revision), children: '对比' }),
1317
- entry.isCurrent ? null : Button({ action: `restore:${entry.revision}`, disabled: state.busy.restore, onClick: () => actions.restoreRevision(entry.revision), children: '恢复' }),
1633
+ Button({ action: `compare-from:${entry.revision}`, onClick: () => actions.compareFrom(entry.revision), children: t('revisions.compare') }),
1634
+ entry.isCurrent ? null : Button({ action: `restore:${entry.revision}`, disabled: state.busy.restore, onClick: () => actions.restoreRevision(entry.revision), children: t('revisions.restore') }),
1318
1635
  ),
1319
- el('div', { className: 'db-muted' }, entry.summary || '(无说明)'),
1636
+ el('div', { className: 'db-muted' }, entry.summary || t('common.noNote')),
1320
1637
  el('div', { className: 'db-muted db-mono' }, `previews ${(entry.previews || []).length} · validation ${entry.validation ? (entry.validation.ok ? 'ok' : `${entry.validation.errorCount} errors`) : '—'}`),
1321
1638
  ))),
1322
1639
  Notice(state.notices.revisions, { marginTop: '8px' }),
@@ -1352,16 +1669,16 @@ window.__ModuleLoader__.load({
1352
1669
  const body = stale
1353
1670
  ? el('div', { className: 'db-body' }, ErrorBox({ error: state.error }))
1354
1671
  : state.status === 'loading'
1355
- ? el('div', { className: 'db-body db-muted' }, '读取 Host 状态…')
1672
+ ? el('div', { className: 'db-body db-muted' }, t('host.reading'))
1356
1673
  : el('div', { className: 'db-body' }, renderView(ctx))
1357
1674
 
1358
1675
  return el('div', { className: 'db-root', 'data-deepblend-panel': PANEL_ID },
1359
1676
  el('div', { className: 'db-head' },
1360
- el('span', { className: 'db-title' }, `${PANEL_LABEL} 工作台`),
1677
+ el('span', { className: 'db-title' }, t('workbench.title', { panel: PANEL_LABEL })),
1361
1678
  state.selected ? el('span', { className: 'db-muted' }, state.selected.project.title) : null,
1362
1679
  state.selected ? Badge({ children: state.selected.currentRevision || '—' }) : null,
1363
1680
  state.unfinishedJobs.length > 0
1364
- ? Badge({ tone: 'live', name: 'unfinished', children: `${state.unfinishedJobs.length} 个任务在跑` })
1681
+ ? Badge({ tone: 'live', name: 'unfinished', children: t('jobs.unfinished', { count: state.unfinishedJobs.length }) })
1365
1682
  : null,
1366
1683
  state.hostApiVersion !== null && state.hostApiVersion !== EXPECTED_HOST_API
1367
1684
  ? Badge({ tone: 'warn', name: 'api', children: `hostApiVersion ${state.hostApiVersion} ≠ ${EXPECTED_HOST_API}` })
@@ -1376,9 +1693,9 @@ window.__ModuleLoader__.load({
1376
1693
  href: ROUTES.diagnostics,
1377
1694
  download: 'deepblend-diagnostics.json',
1378
1695
  'data-action': 'export-diagnostics',
1379
- title: '导出一份可以附在问题里的诊断信息(版本、配置、项目与任务的摘要)',
1380
- }, '导出诊断'),
1381
- Button({ action: 'reload', onClick: actions.reload, children: '刷新' }),
1696
+ title: t('diagnostics.exportHint'),
1697
+ }, t('diagnostics.export')),
1698
+ Button({ action: 'reload', onClick: actions.reload, children: t('common.refresh') }),
1382
1699
  ),
1383
1700
  el('div', { className: 'db-nav' }, VIEWS.map(entry => el('button', {
1384
1701
  key: entry.id,
@@ -1604,17 +1921,17 @@ window.__ModuleLoader__.load({
1604
1921
 
1605
1922
  return h('div', { 'data-deepblend-settings': PANEL_ID, style: { padding: '4px 2px' } },
1606
1923
  state.error !== null && state.error !== undefined ? h(RErrorBox, { error: state.error }) : null,
1607
- state.status === 'loading' ? h('div', { className: 'db-muted' }, '检测 Blender…') : null,
1924
+ state.status === 'loading' ? h('div', { className: 'db-muted' }, t('blender.detecting')) : null,
1608
1925
  card === null ? null : h('div', null,
1609
1926
  h('div', { className: 'db-inline' },
1610
1927
  h('strong', null, card.title),
1611
1928
  h(RBadge, { tone: card.status === 'ready' ? 'ok' : 'bad' }, card.statusLabel),
1612
- card.probedAt ? h('span', { className: 'db-muted' }, `探测于 ${formatTime(card.probedAt)}`) : null,
1929
+ card.probedAt ? h('span', { className: 'db-muted' }, t('blender.probedAt', { when: formatTime(card.probedAt) })) : null,
1613
1930
  ),
1614
1931
  h('div', { className: 'db-card', style: { marginTop: '8px' } },
1615
1932
  card.rows.map(row => h(RRow, { key: row.label, label: row.label, value: row.value }))),
1616
1933
  card.warnings.length === 0 ? null : h('div', { className: 'db-card' },
1617
- h('h4', null, '警告'),
1934
+ h('h4', null, t('common.warning')),
1618
1935
  h('ul', { className: 'db-list' }, card.warnings.map((entry, index) => h('li', { key: `w${index}` },
1619
1936
  h('span', { className: 'db-mono' }, entry.code || ''), ' ', entry.message || '')))),
1620
1937
  h('div', { className: 'db-muted db-mono' }, `hostApiVersion ${payload.hostApiVersion}`),
@@ -1731,17 +2048,17 @@ window.__ModuleLoader__.load({
1731
2048
  h('strong', { className: 'db-mono' }, toolName),
1732
2049
  projectId ? h('span', { className: 'db-muted db-mono' }, projectId) : null,
1733
2050
  jobId ? h(RBadge, null, jobId) : null,
1734
- Array.isArray(args.operations) ? h(RBadge, null, `${args.operations.length} 个操作`) : null,
1735
- job && job.approval && job.approval.required ? h(RBadge, { tone: 'warn', name: 'approval' }, `需审批:${job.approval.frames} 帧 > ${job.approval.threshold}`) : null,
2051
+ Array.isArray(args.operations) ? h(RBadge, null, t('visual.operationCount', { count: args.operations.length })) : null,
2052
+ job && job.approval && job.approval.required ? h(RBadge, { tone: 'warn', name: 'approval' }, t('jobs.approvalShort', { frames: job.approval.frames, threshold: job.approval.threshold })) : null,
1736
2053
  h('span', { style: { flex: 1 } }),
1737
- settled ? h('button', { type: 'button', className: 'db-chip', 'data-action': 'toggle-card', onClick: () => setExpanded(value => !value) }, expanded ? '收起' : '详情') : null,
2054
+ settled ? h('button', { type: 'button', className: 'db-chip', 'data-action': 'toggle-card', onClick: () => setExpanded(value => !value) }, expanded ? t('common.collapse') : t('common.details')) : null,
1738
2055
  ),
1739
2056
 
1740
2057
  jobShaped && job !== null ? h('div', { 'data-tool-job': job.jobId },
1741
2058
  h('div', { className: 'db-inline' },
1742
2059
  h(RBadge, { tone: statusTone(job.status) }, job.status),
1743
- h('span', { className: 'db-muted' }, `${job.progress.completed}/${job.progress.expected} 帧 · ${job.progress.percent}%`),
1744
- job.estimatedRemainingMs ? h('span', { className: 'db-muted' }, `预计 ${Math.round(job.estimatedRemainingMs / 1000)} s`) : null,
2060
+ h('span', { className: 'db-muted' }, t('jobs.frameProgress', { completed: job.progress.completed, expected: job.progress.expected, percent: job.progress.percent })),
2061
+ job.estimatedRemainingMs ? h('span', { className: 'db-muted' }, t('jobs.estimated', { seconds: Math.round(job.estimatedRemainingMs / 1000) })) : null,
1745
2062
  ),
1746
2063
  h(RProgressBar, { percent: job.progress.percent }),
1747
2064
  h('div', { className: 'db-muted' }, job.detail),
@@ -1750,13 +2067,13 @@ window.__ModuleLoader__.load({
1750
2067
 
1751
2068
  reviewShaped && qa !== null ? h('div', { 'data-tool-qa': qa.revision },
1752
2069
  h('div', { className: 'db-inline' },
1753
- qa.visual.available ? h(RBadge, { tone: qa.visual.pass ? 'ok' : 'warn' }, `视觉评分 ${qa.visual.score}`) : h(RBadge, null, '未跑视觉评审'),
1754
- h(RBadge, { tone: qa.technical.ok ? 'ok' : 'bad' }, qa.technical.available ? (qa.technical.ok ? '技术校验通过' : `${qa.technical.errorCount} 个技术错误`) : '无记录'),
1755
- h('span', { className: 'db-muted' }, `测量 ${qa.visual.measuredIssueCount} · 模型 finding ${qa.visual.findingCount}`),
2070
+ qa.visual.available ? h(RBadge, { tone: qa.visual.pass ? 'ok' : 'warn' }, t('visual.scoreIs', { score: qa.visual.score })) : h(RBadge, null, t('visual.notRun')),
2071
+ h(RBadge, { tone: qa.technical.ok ? 'ok' : 'bad' }, qa.technical.available ? (qa.technical.ok ? t('qa.passed') : t('qa.errorCount', { count: qa.technical.errorCount })) : t('common.noRecord')),
2072
+ h('span', { className: 'db-muted' }, t('visual.counts', { measured: qa.visual.measuredIssueCount, findings: qa.visual.findingCount })),
1756
2073
  ),
1757
2074
  qa.visual.measuredIssues.slice(0, 4).map((issue, index) => h('div', { key: `i${index}`, className: 'db-muted db-mono' },
1758
2075
  `[${issue.severity}] ${issue.code} ${issue.viewId || ''} :: ${issue.evidence || ''}`)),
1759
- qa.visual.reviewerError ? h('div', { className: 'db-error' }, `审查器失败:${qa.visual.reviewerError}`) : null,
2076
+ qa.visual.reviewerError ? h('div', { className: 'db-error' }, t('visual.reviewerFailed', { message: qa.visual.reviewerError })) : null,
1760
2077
  ) : null,
1761
2078
 
1762
2079
  reviewShaped && sheet !== null ? h('div', { style: { marginTop: '6px' } },
@@ -1767,9 +2084,9 @@ window.__ModuleLoader__.load({
1767
2084
  src: artifactUrl(artifactBase, sheet),
1768
2085
  style: { width: '100%', borderRadius: '6px', background: '#000' },
1769
2086
  }),
1770
- h('div', { className: 'db-muted db-mono' }, `${sheet.sha256 ? String(sheet.sha256).slice(0, 10) : '—'}${artifactTime(sheet) === null ? '' : ` · 渲染于 ${artifactTime(sheet)}`}`)) : null,
2087
+ h('div', { className: 'db-muted db-mono' }, `${sheet.sha256 ? String(sheet.sha256).slice(0, 10) : '—'}${artifactTime(sheet) === null ? '' : t('preview.renderedAt', { when: artifactTime(sheet) })}`)) : null,
1771
2088
 
1772
- expanded || !settled ? h('pre', { className: 'db-pre' }, (resultText(props.block) || '(运行中…)').slice(0, 4000)) : null,
2089
+ expanded || !settled ? h('pre', { className: 'db-pre' }, (resultText(props.block) || t('jobs.running')).slice(0, 4000)) : null,
1773
2090
  )
1774
2091
  }
1775
2092
 
@@ -1785,7 +2102,7 @@ window.__ModuleLoader__.load({
1785
2102
  title: projects.map(project => `${project.projectId}: ${project.unfinishedJobs || 0}`).join('\n'),
1786
2103
  },
1787
2104
  h('span', { className: 'db-dot', 'data-tone': unfinished > 0 ? 'live' : 'ok' }),
1788
- unfinished > 0 ? `${unfinished} 个渲染在跑` : '无渲染任务',
2105
+ unfinished > 0 ? t('jobs.unfinishedShort', { count: unfinished }) : t('jobs.none'),
1789
2106
  )
1790
2107
  }
1791
2108
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-ui",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "description": "DeepBlend workbench UI: Host half (typed HTTP API) and Web Client half (sidebar, panels, tool cards).",
6
6
  "main": "lib/index.js",
@@ -19,7 +19,7 @@
19
19
  }
20
20
  },
21
21
  "dependencies": {
22
- "@deepblend/dsh-blender-contracts": "0.2.2"
22
+ "@deepblend/dsh-blender-contracts": "0.2.3"
23
23
  },
24
24
  "license": "MIT",
25
25
  "repository": {