@dsh-plugins/dsh-museav-assets 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js ADDED
@@ -0,0 +1,1157 @@
1
+ /**
2
+ * dsh-museav-assets —— Client(浏览器)半。
3
+ *
4
+ * 注册为 conversation.view 的第 6 个 tab「资产」(order 40,排在对话/轨迹/记忆/上下文/
5
+ * 电脑环境之后)。左边选项目,右边看这个项目的素材库和最近出图。
6
+ *
7
+ * 只做三件事,一件不多:
8
+ * 1. 列出工作区(项目),并显示每个项目的素材数与出图计数
9
+ * 2. 看某个项目的素材库,点一下复制素材 URL(agent 拿去当 --ref 垫图)
10
+ * 3. 看最近出图记录
11
+ *
12
+ * 不做的事:不在这里出图、不传文件。出图走 museav CLI / MCP 工具,插件不重复造。
13
+ *
14
+ * @module dsh-museav-assets/client
15
+ */
16
+
17
+ window.__ModuleLoader__.load({
18
+ id: '@dsh-plugins/dsh-museav-assets',
19
+ factory: (require) => {
20
+ const module = { exports: {} }
21
+ const exports = module.exports
22
+
23
+ // @ts-ignore
24
+ const React = require('react')
25
+ const h = React.createElement
26
+
27
+ const NS = 'dsh-museav-assets'
28
+ const API = '/api/dsh-museav-assets'
29
+ const STORE_KEY = 'dsh-museav-assets:project'
30
+ /**
31
+ * 快照缓存的 key。
32
+ *
33
+ * 为什么必须有:CLI 到中台的链路是**间歇性** `❌ fetch failed`(2026-09-29 实测:
34
+ * 同一分钟里 whoami / jobs 连续失败,直连 API 却是 200,再连打 8 次又全好)。
35
+ * 没有缓存时撞上一次抖动,整面作品墙直接空掉,用户只看到「啥都没有」。
36
+ * 所以策略是 stale-while-revalidate:挂载先用快照把画面铺出来,刷新在后台跑,
37
+ * 失败只在顶上留一条提示,不清空已有内容。
38
+ */
39
+ const CACHE_KEY = 'dsh-museav-assets:cache:v2'
40
+
41
+ const LOCALES = {
42
+ zh: {
43
+ title: '资产',
44
+ subtitle: '按项目管素材。点素材复制直链,可直接当垫图(--ref)用。',
45
+ projects: '项目',
46
+ assets: '素材库',
47
+ jobs: '最近出图',
48
+ newProject: '+ 新建项目',
49
+ refresh: '🔄 刷新',
50
+ creating: '创建中…',
51
+ namePlaceholder: '项目名(≤20 字)',
52
+ emptyProjects: '还没有项目。点右上角「新建项目」开一个。',
53
+ emptyAssets: '这个项目还没有素材。',
54
+ emptyJobs: '还没有出图记录。',
55
+ jobsNote: '服务端只返回最近 50 条,且不按项目区分 —— 这里是你账户的全部出图流水。',
56
+ netFail: '读取失败(CLI 到中台的链路会间歇性抽风)',
57
+ keepOld: '下面显示的是上一次拿到的数据。',
58
+ jumpTo: '「{name}」里有 {n} 条 →',
59
+ fullHint: '工作区已满({used}/{limit}),新建会被服务端拒绝。',
60
+ genCount: '出图 {done}/{total}',
61
+ assetCount: '素材 {n}',
62
+ copied: '已复制素材直链',
63
+ loadFailed: '读取失败',
64
+ loginHint: 'CLI 读不到数据,多半是没登录:终端跑 museav login。',
65
+ limit: '{used}/{limit} 个项目',
66
+ brand: 'brand:{brand}',
67
+ upload: '+ 传素材',
68
+ uploading: '上传中…',
69
+ tooBig: '文件超过 8MB。更大的视频请回终端跑 museav upload。',
70
+ readFailed: '文件读不出来',
71
+ uploaded: '素材已入库',
72
+ deleted: '素材已删',
73
+ deleteAsset: '删掉这条素材(硬删,不可恢复)',
74
+ confirmDelete: '确认删掉「{name}」?R2 上的文件会一起没了。',
75
+ yes: '删',
76
+ no: '算了',
77
+ templates: '模板',
78
+ tplMine: '我建的',
79
+ tplTenant: '本租户',
80
+ tplPlatform: '平台共享',
81
+ searchTemplate: '搜分类…',
82
+ emptyTemplates: '这一档没有模板。',
83
+ fieldCount: '{n} 字段',
84
+ copyTemplateId: '点一下复制模板 id(museav gen --template <id>)',
85
+ studio: '出图台',
86
+ viaPrompt: '自由提示词模式',
87
+ viaTemplate: '走模板「{name}」',
88
+ promptPlaceholder: '写一句提示词…(⌘/Ctrl + Enter 直接出图)',
89
+ noTemplate: '不用模板(自由提示词)',
90
+ noProject: '不归档',
91
+ generate: '出图',
92
+ generating: '出图中…(20-60 秒)',
93
+ genHint: '提示词 / 垫图 / 归档项目都在这儿定;垫图从下面的素材库点一下就加进来了。',
94
+ needPrompt: '写句提示词,或者选个模板',
95
+ genDone: '出好了,在下面作品墙里',
96
+ genFailed: '出图失败',
97
+ refs: '垫图',
98
+ useAsRef: '点一下当垫图',
99
+ unref: '点一下取消垫图',
100
+ works: '作品集',
101
+ myImages: '我的出图',
102
+ myVideos: '我的视频',
103
+ copyUrl: '复制直链',
104
+ reuse: '用这条提示词再出',
105
+ close: '关闭',
106
+ tplNew: '+ 新增模板',
107
+ tplName: '模板名',
108
+ tplPrompt: '提示词模板(占位符写 {key})',
109
+ tplCategory: '分类',
110
+ tplShare: '开放到共享池',
111
+ tplUnshare: '撤回共享',
112
+ tplDelete: '删除模板',
113
+ tplManage: '管理',
114
+ tplSaved: '模板已建',
115
+ tplShared: '已开放到共享池',
116
+ tplUnshared: '已撤回,恢复为仅自己可见',
117
+ tplDeleted: '模板已删',
118
+ },
119
+ en: {
120
+ title: 'Assets',
121
+ subtitle: 'Assets by project. Click an asset to copy its direct URL for use as a ref image (--ref).',
122
+ projects: 'Projects',
123
+ assets: 'Library',
124
+ jobs: 'Recent generations',
125
+ jobsNote: 'The server only returns the latest 50, and it does not split them by project — this is your whole account history.',
126
+ netFail: 'Could not load (the CLI→platform link drops intermittently)',
127
+ keepOld: 'Showing the last data we got.',
128
+ jumpTo: '"{name}" has {n} →',
129
+ newProject: '+ New project',
130
+ refresh: '🔄 Refresh',
131
+ creating: 'Creating…',
132
+ namePlaceholder: 'Project name (≤20 chars)',
133
+ emptyProjects: 'No project yet. Create one with the button above.',
134
+ emptyAssets: 'This project has no assets yet.',
135
+ emptyJobs: 'No generation records yet.',
136
+ fullHint: 'Workspace is full ({used}/{limit}); the server will refuse new ones.',
137
+ genCount: 'gen {done}/{total}',
138
+ assetCount: 'assets {n}',
139
+ copied: 'Asset URL copied',
140
+ loadFailed: 'Failed to load',
141
+ loginHint: 'The CLI returned no data — you are probably not logged in: run museav login.',
142
+ limit: '{used}/{limit} projects',
143
+ brand: 'brand:{brand}',
144
+ upload: '+ Upload asset',
145
+ uploading: 'Uploading…',
146
+ tooBig: 'File is over 8MB. Use museav upload for larger videos.',
147
+ readFailed: 'Could not read the file',
148
+ uploaded: 'Asset added',
149
+ deleted: 'Asset deleted',
150
+ deleteAsset: 'Delete this asset (hard delete, no undo)',
151
+ confirmDelete: 'Delete "{name}"? The file on R2 goes with it.',
152
+ yes: 'Delete',
153
+ no: 'Keep',
154
+ templates: 'Templates',
155
+ tplMine: 'Mine',
156
+ tplTenant: 'This tenant',
157
+ tplPlatform: 'Platform',
158
+ searchTemplate: 'Filter by category…',
159
+ emptyTemplates: 'No templates in this group.',
160
+ fieldCount: '{n} fields',
161
+ copyTemplateId: 'Click to copy the template id (museav gen --template <id>)',
162
+ studio: 'Studio',
163
+ viaPrompt: 'Free-prompt mode',
164
+ viaTemplate: 'Using template "{name}"',
165
+ promptPlaceholder: 'Write a prompt… (⌘/Ctrl + Enter to generate)',
166
+ noTemplate: 'No template (free prompt)',
167
+ noProject: 'No archive',
168
+ generate: 'Generate',
169
+ generating: 'Generating… (20-60s)',
170
+ genHint: 'Prompt, refs and the archive project are set here; click an asset below to add it as a ref.',
171
+ needPrompt: 'Write a prompt or pick a template',
172
+ genDone: 'Done — it is in the wall below',
173
+ genFailed: 'Generation failed',
174
+ refs: 'Refs',
175
+ useAsRef: 'Click to use as a ref image',
176
+ unref: 'Click to remove this ref',
177
+ works: 'Works',
178
+ myImages: 'My images',
179
+ myVideos: 'My videos',
180
+ copyUrl: 'Copy URL',
181
+ reuse: 'Reuse this prompt',
182
+ close: 'Close',
183
+ tplNew: '+ New template',
184
+ tplName: 'Template name',
185
+ tplPrompt: 'Prompt template (placeholders as {key})',
186
+ tplCategory: 'Category',
187
+ tplShare: 'Share publicly',
188
+ tplUnshare: 'Unshare',
189
+ tplDelete: 'Delete template',
190
+ tplManage: 'Manage',
191
+ tplSaved: 'Template created',
192
+ tplShared: 'Shared to the public pool',
193
+ tplUnshared: 'Unshared — back to private',
194
+ tplDeleted: 'Template deleted',
195
+ },
196
+ }
197
+
198
+ const CSS = `
199
+ .dsh-museav-view {
200
+ padding: 16px 20px 168px; font-size: 13px; line-height: 1.6;
201
+ height: 100%; overflow: auto;
202
+ }
203
+ .dsh-museav-view > * { max-width: 1080px; margin-left: auto; margin-right: auto; }
204
+
205
+ .dma-head {
206
+ display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
207
+ padding: 12px 14px; border-radius: 12px; margin-bottom: 14px;
208
+ background: var(--dsw-alias-bg-layer-2, rgba(0,0,0,.02));
209
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.06));
210
+ }
211
+ .dma-head-eyebrow {
212
+ font-size: 10px; letter-spacing: .15em; text-transform: uppercase;
213
+ color: var(--dsw-alias-label-tertiary, #999);
214
+ }
215
+ .dma-head-sub { font-size: 12px; color: var(--dsw-alias-label-secondary, #666); flex: 1; min-width: 180px; }
216
+ .dma-count {
217
+ font-size: 11px; color: var(--dsw-alias-label-tertiary, #999);
218
+ font-variant-numeric: tabular-nums;
219
+ }
220
+ .dma-count.is-full { color: var(--dsw-alias-state-error-primary, #d44); }
221
+ .dma-btn {
222
+ background: none; border: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.12));
223
+ color: var(--dsw-alias-label-secondary, #666); border-radius: 7px;
224
+ font-size: 11.5px; padding: 3px 9px; cursor: pointer; font-family: inherit;
225
+ white-space: nowrap;
226
+ }
227
+ .dma-btn:hover:not(:disabled) { background: var(--dsw-alias-bg-layer-3, rgba(0,0,0,.05)); }
228
+ .dma-btn:disabled { opacity: .45; cursor: default; }
229
+ .dma-btn.is-primary {
230
+ border-color: var(--dsw-alias-brand-primary, #8b5cf6);
231
+ color: var(--dsw-alias-brand-primary, #8b5cf6);
232
+ }
233
+ .dma-new {
234
+ display: flex; gap: 6px; align-items: center; margin: 0 0 14px;
235
+ }
236
+ .dma-input {
237
+ flex: 1; min-width: 0; font: inherit; font-size: 12.5px;
238
+ padding: 5px 9px; border-radius: 7px;
239
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.12));
240
+ background: var(--dsw-alias-bg-layer-1, #fff);
241
+ color: var(--dsw-alias-label-primary, #222);
242
+ }
243
+ .dma-input:focus { outline: 1px solid var(--dsw-alias-brand-primary, #8b5cf6); }
244
+
245
+ /* 项目切换条:胶囊,不做卡片墙 —— 项目数 ≤5,一行放得下 */
246
+ .dma-projects { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 6px; }
247
+ .dma-chip {
248
+ display: flex; flex-direction: column; gap: 1px; align-items: flex-start;
249
+ padding: 6px 11px; border-radius: 9px; cursor: pointer; font-family: inherit;
250
+ background: var(--dsw-alias-bg-layer-1, rgba(0,0,0,.015));
251
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.07));
252
+ color: var(--dsw-alias-label-primary, #222);
253
+ }
254
+ .dma-chip:hover { border-color: var(--dsw-alias-border-l1, rgba(0,0,0,.16)); }
255
+ .dma-chip.is-active {
256
+ border-color: var(--dsw-alias-brand-primary, #8b5cf6);
257
+ background: var(--dsw-alias-bg-layer-3, rgba(139,92,246,.08));
258
+ }
259
+ .dma-chip-name { font-size: 13px; font-weight: 600; }
260
+ .dma-chip.is-active .dma-chip-name { color: var(--dsw-alias-brand-primary, #8b5cf6); }
261
+ .dma-chip-meta {
262
+ font-size: 10.5px; color: var(--dsw-alias-label-tertiary, #999);
263
+ font-variant-numeric: tabular-nums;
264
+ }
265
+ .dma-warn {
266
+ font-size: 11.5px; color: var(--dsw-alias-state-error-primary, #d44);
267
+ margin: 0 0 10px;
268
+ }
269
+
270
+ .dma-sec { display: flex; align-items: baseline; gap: 9px; margin: 20px 0 9px; }
271
+ .dma-sec h4 {
272
+ margin: 0; font-size: 11px; letter-spacing: .12em; text-transform: uppercase;
273
+ color: var(--dsw-alias-label-secondary, #666); font-weight: 600;
274
+ }
275
+ .dma-sec .n { font-size: 11px; color: var(--dsw-alias-label-tertiary, #999); font-variant-numeric: tabular-nums; }
276
+
277
+ /* 素材网格:方图,缩略图铺满,多余裁切 */
278
+ .dma-grid {
279
+ display: grid; gap: 8px;
280
+ grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
281
+ }
282
+ .dma-asset {
283
+ position: relative; aspect-ratio: 1 / 1; border-radius: 9px; overflow: hidden;
284
+ cursor: pointer; background: var(--dsw-alias-bg-layer-2, rgba(0,0,0,.03));
285
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.07));
286
+ }
287
+ .dma-asset:hover { border-color: var(--dsw-alias-brand-primary, #8b5cf6); }
288
+ .dma-asset.is-ref { border-color: var(--dsw-alias-brand-primary, #8b5cf6); box-shadow: 0 0 0 2px var(--dsw-alias-brand-primary, #8b5cf6) inset; }
289
+ .dma-asset img { width: 100%; height: 100%; object-fit: cover; display: block; }
290
+ .dma-asset-cap {
291
+ position: absolute; left: 0; right: 0; bottom: 0; padding: 10px 6px 4px;
292
+ font-size: 10px; color: #fff; text-align: left;
293
+ background: linear-gradient(transparent, rgba(0,0,0,.72));
294
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
295
+ }
296
+ .dma-asset.is-video .dma-asset-cap::before { content: '🎬 '; }
297
+ /* 删除按钮只在 hover / 聚焦时出现:素材是拿去当垫图的,误删的代价不对称 */
298
+ .dma-asset-del {
299
+ position: absolute; top: 4px; right: 4px; z-index: 2;
300
+ width: 20px; height: 20px; border-radius: 6px; cursor: pointer;
301
+ border: 1px solid rgba(255,255,255,.25); background: rgba(0,0,0,.55);
302
+ color: #fff; font-size: 11px; line-height: 1; padding: 0;
303
+ opacity: 0; transition: opacity .12s; font-family: inherit;
304
+ }
305
+ .dma-asset:hover .dma-asset-del, .dma-asset-del:focus-visible { opacity: 1; }
306
+ .dma-asset-del:hover { background: var(--dsw-alias-state-error-primary, #d44); border-color: transparent; }
307
+
308
+ /* 模板行:一行一条,归属徽章在右 */
309
+ .dma-tpl {
310
+ display: flex; gap: 10px; align-items: center; padding: 7px 10px;
311
+ border-radius: 9px; margin-bottom: 3px; cursor: pointer;
312
+ background: var(--dsw-alias-bg-layer-1, rgba(0,0,0,.015));
313
+ border: 1px solid transparent;
314
+ }
315
+ .dma-tpl:hover { border-color: var(--dsw-alias-border-l1, rgba(0,0,0,.07)); }
316
+ .dma-tpl { display: flex; gap: 8px; align-items: center; }
317
+ .dma-tpl-main {
318
+ flex: 1; min-width: 0; display: flex; gap: 10px; align-items: center;
319
+ background: none; border: none; padding: 0; cursor: pointer; font-family: inherit; text-align: left;
320
+ }
321
+ .dma-tpl-acts { display: flex; gap: 6px; flex: none; opacity: 0; transition: opacity .12s; }
322
+ .dma-tpl:hover .dma-tpl-acts, .dma-tpl-acts:focus-within { opacity: 1; }
323
+ .dma-tpl-form {
324
+ display: flex; flex-direction: column; gap: 7px; padding: 11px 12px; margin-bottom: 10px;
325
+ border-radius: 10px; background: var(--dsw-alias-bg-layer-2, rgba(0,0,0,.02));
326
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.07));
327
+ }
328
+ .dma-tpl-name {
329
+ flex: 1; min-width: 0; color: var(--dsw-alias-label-primary, #222);
330
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
331
+ }
332
+ .dma-tpl-meta {
333
+ display: flex; gap: 8px; align-items: center; flex: none;
334
+ font-size: 11px; color: var(--dsw-alias-label-tertiary, #999);
335
+ font-variant-numeric: tabular-nums;
336
+ }
337
+ .dma-badge {
338
+ font-size: 10px; padding: 0 5px; border-radius: 4px;
339
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.1));
340
+ color: var(--dsw-alias-label-secondary, #666);
341
+ }
342
+ .dma-badge.is-mine {
343
+ border-color: var(--dsw-alias-brand-primary, #8b5cf6);
344
+ color: var(--dsw-alias-brand-primary, #8b5cf6);
345
+ }
346
+
347
+ .dma-job {
348
+ display: flex; gap: 10px; align-items: flex-start; padding: 8px 10px;
349
+ border-radius: 9px; margin-bottom: 4px;
350
+ background: var(--dsw-alias-bg-layer-1, rgba(0,0,0,.015));
351
+ border: 1px solid transparent;
352
+ }
353
+ .dma-job:hover { border-color: var(--dsw-alias-border-l1, rgba(0,0,0,.07)); }
354
+ .dma-job-dot { flex: none; line-height: 1.5; }
355
+ .dma-job-thumb {
356
+ flex: none; width: 52px; height: 52px; border-radius: 8px; overflow: hidden;
357
+ background: var(--dsw-alias-bg-layer-2, rgba(0,0,0,.03));
358
+ display: flex; align-items: center; justify-content: center;
359
+ }
360
+ .dma-job-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
361
+ /* 失败任务没有产物可画 —— 画个虚线框,别留一个看着像「加载失败」的空白块 */
362
+ .dma-job-thumb.is-none {
363
+ border: 1px dashed var(--dsw-alias-border-l1, rgba(0,0,0,.14));
364
+ color: var(--dsw-alias-label-tertiary, #999); font-size: 13px;
365
+ }
366
+ .dma-note { font-size: 11px; color: var(--dsw-alias-label-tertiary, #999); margin: 0 0 8px; }
367
+
368
+ /* ── 出图台:这个 tab 的主功能,视觉上要压得住其余所有段 ── */
369
+ .dma-studio {
370
+ padding: 13px 14px 12px; border-radius: 12px; margin: 14px 0 4px;
371
+ background: var(--dsw-alias-bg-layer-2, rgba(0,0,0,.02));
372
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.07));
373
+ }
374
+ .dma-studio-head { display: flex; gap: 9px; align-items: baseline; margin-bottom: 9px; }
375
+ .dma-studio-title { font-size: 13px; font-weight: 650; color: var(--dsw-alias-label-primary, #222); }
376
+ .dma-studio-note { font-size: 11px; color: var(--dsw-alias-label-tertiary, #999); }
377
+ .dma-prompt {
378
+ width: 100%; box-sizing: border-box; resize: vertical; font: inherit; font-size: 13px;
379
+ line-height: 1.6; padding: 8px 10px; border-radius: 9px;
380
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.12));
381
+ background: var(--dsw-alias-bg-layer-1, #fff);
382
+ color: var(--dsw-alias-label-primary, #222);
383
+ }
384
+ .dma-prompt:focus { outline: 1px solid var(--dsw-alias-brand-primary, #8b5cf6); }
385
+ .dma-controls { display: flex; gap: 8px; align-items: center; margin-top: 9px; flex-wrap: wrap; }
386
+ .dma-select {
387
+ font: inherit; font-size: 12px; padding: 4px 8px; border-radius: 7px; max-width: 220px;
388
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.12));
389
+ background: var(--dsw-alias-bg-layer-1, #fff); color: var(--dsw-alias-label-primary, #222);
390
+ }
391
+ .dma-archive {
392
+ font-size: 11.5px; color: var(--dsw-alias-label-secondary, #666);
393
+ padding: 3px 8px; border-radius: 7px;
394
+ border: 1px dashed var(--dsw-alias-border-l1, rgba(0,0,0,.14));
395
+ }
396
+ .dma-gen {
397
+ font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer;
398
+ padding: 5px 16px; border-radius: 8px; border: 1px solid transparent;
399
+ background: var(--dsw-alias-brand-primary, #8b5cf6); color: #fff;
400
+ }
401
+ .dma-gen:disabled { opacity: .45; cursor: default; }
402
+ .dma-hint { font-size: 10.5px; color: var(--dsw-alias-label-tertiary, #999); margin-top: 7px; }
403
+ .dma-fields {
404
+ display: grid; gap: 7px; margin-bottom: 9px;
405
+ grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
406
+ }
407
+ .dma-field { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
408
+ .dma-field > span { font-size: 10.5px; color: var(--dsw-alias-label-tertiary, #999); }
409
+ .dma-refs { display: flex; gap: 7px; align-items: center; margin-top: 9px; flex-wrap: wrap; }
410
+ .dma-refs-label { font-size: 11px; color: var(--dsw-alias-label-tertiary, #999); }
411
+ .dma-ref {
412
+ position: relative; width: 40px; height: 40px; border-radius: 7px; overflow: hidden;
413
+ border: 1px solid var(--dsw-alias-brand-primary, #8b5cf6);
414
+ }
415
+ .dma-ref img { width: 100%; height: 100%; object-fit: cover; display: block; }
416
+ .dma-ref .dma-asset-del { opacity: 1; width: 15px; height: 15px; font-size: 9px; }
417
+
418
+ .dma-sub {
419
+ display: flex; gap: 8px; align-items: baseline;
420
+ margin: 14px 0 8px; font-size: 12px; font-weight: 600;
421
+ color: var(--dsw-alias-label-secondary, #666);
422
+ }
423
+ .dma-sub .n { font-size: 11px; color: var(--dsw-alias-label-tertiary, #999); font-weight: 400; }
424
+ /* ── 作品集:这是「看图」的地方,图片本身是主角 ── */
425
+ .dma-wall {
426
+ display: grid; gap: 8px;
427
+ grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
428
+ }
429
+ .dma-work {
430
+ position: relative; aspect-ratio: 1 / 1; border-radius: 10px; overflow: hidden;
431
+ cursor: zoom-in; padding: 0; font-family: inherit; background: var(--dsw-alias-bg-layer-2, rgba(0,0,0,.03));
432
+ border: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.07));
433
+ }
434
+ .dma-work:hover { border-color: var(--dsw-alias-brand-primary, #8b5cf6); }
435
+ .dma-work img { width: 100%; height: 100%; object-fit: cover; display: block; }
436
+ .dma-work-cap {
437
+ position: absolute; left: 0; right: 0; bottom: 0; padding: 16px 7px 5px;
438
+ display: flex; gap: 5px; align-items: flex-end;
439
+ background: linear-gradient(transparent, rgba(0,0,0,.78));
440
+ text-align: left;
441
+ }
442
+ .dma-work-dot { flex: none; font-size: 9px; line-height: 1.3; }
443
+ .dma-work-prompt {
444
+ font-size: 10.5px; color: #fff; line-height: 1.35;
445
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
446
+ }
447
+ .dma-work-video {
448
+ position: absolute; inset: 0; display: flex; flex-direction: column;
449
+ align-items: center; justify-content: center; gap: 2px;
450
+ font-size: 22px; color: var(--dsw-alias-label-tertiary, #999);
451
+ }
452
+ .dma-work-video > span { font-size: 9px; letter-spacing: .08em; }
453
+ .dma-work.is-video { cursor: pointer; }
454
+
455
+ /* ── 放大层 ── */
456
+ .dma-zoom {
457
+ position: fixed; inset: 0; z-index: 90; display: flex; align-items: center; justify-content: center;
458
+ background: rgba(0,0,0,.72); padding: 32px; box-sizing: border-box;
459
+ }
460
+ .dma-zoom-box {
461
+ max-width: min(1000px, 92vw); max-height: 92vh; display: flex; flex-direction: column; gap: 10px;
462
+ background: var(--dsw-alias-bg-layer-1, #111); border-radius: 12px; padding: 12px;
463
+ }
464
+ .dma-zoom-box img {
465
+ max-width: 100%; max-height: 70vh; object-fit: contain; display: block; border-radius: 8px;
466
+ }
467
+ .dma-zoom-meta { min-width: 0; }
468
+ .dma-zoom-prompt {
469
+ margin: 0 0 6px; font-size: 12.5px; color: var(--dsw-alias-label-primary, #222);
470
+ max-height: 5.4em; overflow: auto; line-height: 1.5;
471
+ }
472
+ .dma-zoom-actions { display: flex; gap: 8px; margin-top: 8px; }
473
+ /* 空态里的「去有素材的项目看看」 */
474
+ .dma-jump {
475
+ display: inline-block; margin-top: 9px; cursor: pointer; font-family: inherit;
476
+ background: none; border: 1px solid var(--dsw-alias-brand-primary, #8b5cf6);
477
+ color: var(--dsw-alias-brand-primary, #8b5cf6); border-radius: 7px;
478
+ font-size: 11.5px; padding: 3px 10px;
479
+ }
480
+ .dma-job-body { flex: 1; min-width: 0; }
481
+ .dma-job-prompt {
482
+ margin: 0; color: var(--dsw-alias-label-primary, #222);
483
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
484
+ overflow: hidden; overflow-wrap: anywhere;
485
+ }
486
+ .dma-job-meta {
487
+ display: flex; gap: 9px; align-items: center; margin-top: 3px; flex-wrap: wrap;
488
+ font-size: 11px; color: var(--dsw-alias-label-tertiary, #999);
489
+ font-variant-numeric: tabular-nums;
490
+ }
491
+ .dma-job.is-failed .dma-job-prompt { color: var(--dsw-alias-state-error-primary, #d44); }
492
+
493
+ .dma-empty {
494
+ padding: 18px; border-radius: 10px; text-align: center;
495
+ color: var(--dsw-alias-label-tertiary, #999); font-size: 12.5px;
496
+ border: 1px dashed var(--dsw-alias-border-l1, rgba(0,0,0,.1));
497
+ }
498
+ .dma-error {
499
+ padding: 12px 14px; border-radius: 10px; margin-bottom: 12px;
500
+ color: var(--dsw-alias-state-error-primary, #d44); font-size: 12.5px;
501
+ background: var(--dsw-alias-bg-layer-1, rgba(0,0,0,.015));
502
+ border: 1px solid var(--dsw-alias-state-error-primary, rgba(212,68,68,.35));
503
+ overflow-wrap: anywhere;
504
+ }
505
+ `
506
+
507
+ /** 写快照。只存纯数据(不存 React 状态),读的时候再补齐缺省字段。 */
508
+ function readCache () {
509
+ try {
510
+ const raw = localStorage.getItem(CACHE_KEY)
511
+ if (!raw) return null
512
+ const data = JSON.parse(raw)
513
+ if (!data || typeof data !== 'object' || !Array.isArray(data.jobs)) return null
514
+ return data
515
+ } catch {
516
+ return null
517
+ }
518
+ }
519
+
520
+ function writeCache (data) {
521
+ try {
522
+ localStorage.setItem(CACHE_KEY, JSON.stringify(data))
523
+ } catch {
524
+ // 配额满 / 隐私模式:缓存不是功能,存不下就算了
525
+ }
526
+ }
527
+
528
+ function ensureStyle () {
529
+ if (typeof document === 'undefined') return
530
+ if (document.getElementById('dsh-museav-assets-style')) return
531
+ const tag = document.createElement('style')
532
+ tag.id = 'dsh-museav-assets-style'
533
+ tag.textContent = CSS
534
+ document.head.appendChild(tag)
535
+ }
536
+
537
+ /**
538
+ * 首次打开(或上次选的项目没了)落在**素材最多**的那个项目上。
539
+ *
540
+ * 为什么不是列表第一个:这个 tab 是来看资产的,开在一个 0 素材的项目上,
541
+ * 用户看到的就是「啥都没有」—— 2026-09-29 真机就是这个效果:4 个项目里
542
+ * 第一个是空的,唯一有 1 张素材的排在第 4 个。素材数相同时比出图量,
543
+ * 都一样就取第一个,保证结果稳定不跳。
544
+ */
545
+ function richestProject (projects) {
546
+ const list = [...projects]
547
+ list.sort((a, b) =>
548
+ (b.assetCount ?? 0) - (a.assetCount ?? 0) ||
549
+ (b.genDone ?? 0) - (a.genDone ?? 0))
550
+ return list[0] ?? null
551
+ }
552
+
553
+ /**
554
+ * 出图台。
555
+ *
556
+ * 这一版的排布逻辑:**出图在最上面,作品在中间,素材垫图在下面**。
557
+ * 上一版是「项目 / 素材 / 模板 / 出图流水」四段报表,模板被 50 条流水压到屏幕外,
558
+ * 而它其实是出图的入口。现在模板折进出图表单里当选择器,流水换成作品墙。
559
+ */
560
+ function AssetsView (props) {
561
+ const t = props.t ?? ((k) => k)
562
+ // 挂载时先铺缓存:loading 直接为 false,画面立刻有东西;随后后台刷新。
563
+ const [state, setState] = React.useState(() => {
564
+ const cached = typeof localStorage !== 'undefined' ? readCache() : null
565
+ if (!cached) {
566
+ return {
567
+ loading: true, refreshing: false, error: null, stale: false,
568
+ projects: [], used: 0, limit: 5, full: false,
569
+ project: null, assets: [], assetsError: null, jobs: [],
570
+ templates: [], templatesError: null,
571
+ }
572
+ }
573
+ return {
574
+ ...cached,
575
+ loading: false, refreshing: true, error: null,
576
+ // 缓存里的 project 可能已经不在了,挂载后由 load() 校正
577
+ stale: true,
578
+ }
579
+ })
580
+ const [form, setForm] = React.useState({
581
+ prompt: '', ratio: '3:4', templateId: '', fields: {}, refs: [],
582
+ })
583
+ const [running, setRunning] = React.useState(false)
584
+ const [toast, setToast] = React.useState(null)
585
+ const [draft, setDraft] = React.useState(null)
586
+ const [busy, setBusy] = React.useState(false)
587
+ const [uploading, setUploading] = React.useState(false)
588
+ const [pendingDelete, setPendingDelete] = React.useState(null)
589
+ const [zoom, setZoom] = React.useState(null)
590
+ const [tplForm, setTplForm] = React.useState(null)
591
+ const [tplBusy, setTplBusy] = React.useState(false)
592
+ const [tplSource, setTplSource] = React.useState('mine')
593
+ const fileRef = React.useRef(null)
594
+
595
+ const patch = (next) => setState((s) => ({ ...s, ...next }))
596
+
597
+ const loadProjectData = React.useCallback((project) => {
598
+ if (!project) {
599
+ patch({ assets: [], jobs: [], assetsError: null })
600
+ return
601
+ }
602
+ return Promise.all([
603
+ fetch(`${API}/assets?project=${encodeURIComponent(project.id)}`).then((r) => r.json()),
604
+ fetch(`${API}/jobs?limit=50`).then((r) => r.json()).catch(() => ({ ok: false, error: t('netFail') })),
605
+ ]).then(([assets, jobs]) => {
606
+ setState((s) => {
607
+ // 只有**成功**才覆盖。撞上 fetch failed 时保留旧画面 + 顶上给一条提示 ——
608
+ // 清空等于把用户刚看到的东西抹掉,比"数据旧了 10 分钟"糟糕得多。
609
+ const next = {
610
+ ...s,
611
+ assets: assets.ok ? assets.assets : s.assets,
612
+ assetsError: assets.ok ? null : (assets.error || t('loadFailed')),
613
+ jobs: jobs.ok ? jobs.jobs : s.jobs,
614
+ jobsError: jobs.ok ? null : (jobs.error || t('loadFailed')),
615
+ stale: false,
616
+ }
617
+ writeCache({
618
+ projects: next.projects, used: next.used, limit: next.limit, full: next.full,
619
+ project: next.project, assets: next.assets, jobs: next.jobs,
620
+ templates: next.templates, savedAt: Date.now(),
621
+ })
622
+ return next
623
+ })
624
+ })
625
+ }, [t])
626
+
627
+ const loadTemplates = React.useCallback((source) => {
628
+ setTplSource(source)
629
+ return fetch(`${API}/templates?source=${source}`, { headers: { Accept: 'application/json' } })
630
+ .then((r) => r.json())
631
+ .then((data) => patch({
632
+ templates: data.ok ? data.templates : [],
633
+ templatesError: data.ok ? null : (data.error || t('loadFailed')),
634
+ }))
635
+ .catch((e) => patch({ templatesError: String(e.message || e) }))
636
+ }, [t])
637
+
638
+ const load = React.useCallback((manual) => {
639
+ setState((s) => ({ ...s, loading: !manual, refreshing: !!manual }))
640
+ return fetch(`${API}/projects`, { headers: { Accept: 'application/json' } })
641
+ .then((r) => r.json())
642
+ .then((data) => {
643
+ if (!data.ok) throw new Error(data.error || t('loadFailed'))
644
+ const saved = localStorage.getItem(STORE_KEY)
645
+ // 选中的项目可能刚被删了 —— 对不上就重挑一个,别停在空视图上。
646
+ const keep = data.projects.find((p) => p.id === saved || p.name === saved)
647
+ const project = keep ?? richestProject(data.projects)
648
+ setState((s) => ({
649
+ ...s, loading: false, refreshing: false, error: null,
650
+ projects: data.projects, used: data.used, limit: data.limit, full: data.full,
651
+ project,
652
+ }))
653
+ return project
654
+ })
655
+ .then((project) => loadProjectData(project))
656
+ .catch((e) => setState((s) => ({ ...s, loading: false, refreshing: false, error: String(e.message || e) })))
657
+ }, [t, loadProjectData])
658
+
659
+ // 挂载时拉一次;点「🔄」才重跑 —— 不轮询,生成中另走 /gen 的同步回执。
660
+ React.useEffect(() => {
661
+ load(false)
662
+ loadTemplates('mine')
663
+ }, [load, loadTemplates])
664
+
665
+ const pick = (project) => {
666
+ localStorage.setItem(STORE_KEY, project.id)
667
+ patch({ project, assets: [], assetsError: null })
668
+ loadProjectData(project).catch((e) => patch({ assetsError: String(e.message || e) }))
669
+ }
670
+
671
+ const create = () => {
672
+ const name = String(draft ?? '').trim()
673
+ if (!name) return
674
+ setBusy(true)
675
+ fetch(`${API}/projects`, {
676
+ method: 'POST',
677
+ headers: { 'Content-Type': 'application/json' },
678
+ body: JSON.stringify({ name }),
679
+ })
680
+ .then((r) => r.json())
681
+ .then((data) => {
682
+ setBusy(false)
683
+ if (!data.ok) { setToast(data.error || t('loadFailed')); return }
684
+ setDraft(null)
685
+ load(true)
686
+ })
687
+ .catch((e) => { setBusy(false); setToast(String(e.message || e)) })
688
+ }
689
+
690
+ const copy = (text, message) => {
691
+ navigator.clipboard?.writeText(text)
692
+ .then(() => setToast(message ?? t('copied')))
693
+ .catch(() => setToast(text))
694
+ }
695
+
696
+ /** 出图:CLI 同步跑完(20~60s),回执直接进作品墙。 */
697
+ const generate = () => {
698
+ if (running) return
699
+ if (!form.prompt.trim() && !form.templateId) { setToast(t('needPrompt')); return }
700
+ setRunning(true)
701
+ const body = {
702
+ prompt: form.prompt.trim(),
703
+ ratio: form.ratio,
704
+ project: state.project?.id ?? '',
705
+ refs: form.refs,
706
+ }
707
+ if (form.templateId) {
708
+ body.templateId = form.templateId
709
+ body.fields = form.fields
710
+ }
711
+ fetch(`${API}/gen`, {
712
+ method: 'POST',
713
+ headers: { 'Content-Type': 'application/json' },
714
+ body: JSON.stringify(body),
715
+ })
716
+ .then((r) => r.json())
717
+ .then((res) => {
718
+ setRunning(false)
719
+ if (!res.ok) { setToast(res.error || t('genFailed')); return }
720
+ setToast(t('genDone'))
721
+ // 立刻刷新:服务端那边已经落库了
722
+ loadProjectData(state.project)
723
+ })
724
+ .catch((e) => { setRunning(false); setToast(String(e.message || e)) })
725
+ }
726
+
727
+ /** 模板的一个动作:新建 / 分享 / 撤回 / 删除。CLI 子命令一一对应。 */
728
+ const tplAction = (body) => {
729
+ setTplBusy(true)
730
+ return fetch(`${API}/templates`, {
731
+ method: 'POST',
732
+ headers: { 'Content-Type': 'application/json' },
733
+ body: JSON.stringify(body),
734
+ })
735
+ .then((r) => r.json())
736
+ .then((res) => {
737
+ setTplBusy(false)
738
+ if (!res.ok) { setToast(res.error || t('loadFailed')); return res }
739
+ if (body.action === 'create') { setTplForm(null); setToast(t('tplSaved')) }
740
+ else if (body.action === 'share') setToast(t('tplShared'))
741
+ else if (body.action === 'unshare') setToast(t('tplUnshared'))
742
+ else if (body.action === 'delete') setToast(t('tplDeleted'))
743
+ loadTemplates(tplSource)
744
+ return res
745
+ })
746
+ .catch((e) => { setTplBusy(false); setToast(String(e.message || e)); return { ok: false } })
747
+ }
748
+
749
+ const upload = (file) => {
750
+ if (!file || !state.project) return
751
+ if (file.size > 8 * 1024 * 1024) { setToast(t('tooBig')); return }
752
+ setUploading(true)
753
+ const reader = new FileReader()
754
+ reader.onerror = () => { setUploading(false); setToast(t('readFailed')) }
755
+ reader.onload = () => {
756
+ const data = String(reader.result ?? '').split(',')[1] ?? ''
757
+ fetch(`${API}/assets`, {
758
+ method: 'POST',
759
+ headers: { 'Content-Type': 'application/json' },
760
+ body: JSON.stringify({ project: state.project.id, filename: file.name, data }),
761
+ })
762
+ .then((r) => r.json())
763
+ .then((res) => {
764
+ setUploading(false)
765
+ if (!res.ok) { setToast(res.error || t('loadFailed')); return }
766
+ setToast(t('uploaded'))
767
+ loadProjectData(state.project)
768
+ })
769
+ .catch((e) => { setUploading(false); setToast(String(e.message || e)) })
770
+ }
771
+ reader.readAsDataURL(file)
772
+ }
773
+
774
+ const doDelete = (asset) => {
775
+ setPendingDelete(null)
776
+ fetch(`${API}/assets?id=${encodeURIComponent(asset.id)}`, { method: 'DELETE' })
777
+ .then((r) => r.json())
778
+ .then((res) => {
779
+ if (!res.ok) { setToast(res.error || t('loadFailed')); return }
780
+ setToast(t('deleted'))
781
+ loadProjectData(state.project)
782
+ })
783
+ .catch((e) => setToast(String(e.message || e)))
784
+ }
785
+
786
+ // toast 自己收摊,3 秒够看清
787
+ React.useEffect(() => {
788
+ if (!toast) return
789
+ const timer = setTimeout(() => setToast(null), 3200)
790
+ return () => clearTimeout(timer)
791
+ }, [toast])
792
+
793
+ if (state.loading) {
794
+ return h('div', { className: 'dsh-museav-view' }, h('div', { className: 'dma-empty' }, '…'))
795
+ }
796
+
797
+ const current = state.project
798
+ const template = state.templates.find((x) => x.id === form.templateId) ?? null
799
+ // 当前项目空着的时候,「哪个项目还有素材」先算好一次
800
+ const elsewhere = current ? richestProject(state.projects.filter((p) => p.id !== current.id)) : null
801
+ const jumpTarget = elsewhere?.assetCount ? elsewhere : null
802
+ /**
803
+ * 作品集 = 只放**出的成的**,且图片和视频分家。
804
+ *
805
+ * 三条口径(owner 2026-09-29):
806
+ * · 报错记录不是作品 —— 资源库里出现「我又没查」的错误记录是噪声,直接不进墙。
807
+ * 真实数据里 50 条有 11 条 failed,之前铺在墙上占了 22% 的格子、满屏红字。
808
+ * · 图片就是图片、视频就是视频,不能混在一面网格里 —— 视频 cdn_url 是 .mp4,
809
+ * 塞进 <img> 就是破图(真机 5 格全是破的)。
810
+ * · media_type 还有第三类:reverse(读图逆向的记录,2 条,cdn_url 是被读的
811
+ * 那张原图)。它不是作品,也不该进作品集。
812
+ * 失败条数在标题旁用一行小字交代,不藏也不占位。
813
+ */
814
+ const doneJobs = state.jobs.filter((j) => j.status === 'done')
815
+ const imageWorks = doneJobs.filter((j) => j.mediaType === 'image')
816
+ const videoWorks = doneJobs.filter((j) => j.mediaType === 'video')
817
+ const refAssets = form.refs
818
+ .map((url) => state.assets.find((a) => a.url === url))
819
+ .filter(Boolean)
820
+
821
+ return h('div', { className: 'dsh-museav-view' },
822
+ h('div', { className: 'dma-head' },
823
+ h('div', null,
824
+ h('div', { className: 'dma-head-eyebrow' }, '🗂 MUSE AV'),
825
+ h('div', { className: 'dma-head-sub' }, t('subtitle')),
826
+ ),
827
+ h('span', { className: state.full ? 'dma-count is-full' : 'dma-count' },
828
+ t('limit', { used: state.used, limit: state.limit })),
829
+ h('button', {
830
+ className: 'dma-btn', onClick: () => load(true), disabled: state.refreshing,
831
+ }, t('refresh')),
832
+ ),
833
+
834
+ state.error ? h('div', { className: 'dma-error' }, '⚠️ ', state.error, ' — ', t('loginHint')) : null,
835
+ toast ? h('div', { className: 'dma-warn' }, toast) : null,
836
+ pendingDelete
837
+ ? h('div', { className: 'dma-error' },
838
+ '⚠️ ', t('confirmDelete', { name: pendingDelete.name || pendingDelete.id }), ' ',
839
+ h('button', { className: 'dma-btn is-primary', onClick: () => doDelete(pendingDelete) }, t('yes')),
840
+ ' ',
841
+ h('button', { className: 'dma-btn', onClick: () => setPendingDelete(null) }, t('no')))
842
+ : null,
843
+
844
+ h('div', { className: 'dma-projects' },
845
+ state.projects.map((p) => h('button', {
846
+ className: current && p.id === current.id ? 'dma-chip is-active' : 'dma-chip',
847
+ onClick: () => pick(p),
848
+ },
849
+ h('span', { className: 'dma-chip-name' }, p.name),
850
+ h('span', { className: 'dma-chip-meta' },
851
+ p.assetCount === null ? '' : t('assetCount', { n: p.assetCount }),
852
+ p.genTotal ? ` · ${t('genCount', { done: p.genDone, total: p.genTotal })}` : ''),
853
+ )),
854
+ h('button', {
855
+ className: 'dma-btn is-primary', onClick: () => setDraft(''),
856
+ disabled: state.full || busy, title: state.full ? t('fullHint', { used: state.used, limit: state.limit }) : '',
857
+ }, t('newProject')),
858
+ ),
859
+
860
+ draft !== null
861
+ ? h('div', { className: 'dma-new' },
862
+ h('input', {
863
+ className: 'dma-input', value: draft, autoFocus: true,
864
+ placeholder: t('namePlaceholder'),
865
+ onChange: (e) => setDraft(e.target.value),
866
+ onKeyDown: (e) => { if (e.key === 'Enter') create(); if (e.key === 'Escape') setDraft(null) },
867
+ }),
868
+ h('button', { className: 'dma-btn is-primary', onClick: create, disabled: busy || !String(draft).trim() },
869
+ busy ? t('creating') : t('newProject')),
870
+ h('button', { className: 'dma-btn', onClick: () => setDraft(null) }, '✕'))
871
+ : null,
872
+
873
+ // ── 出图台 ──
874
+ h('div', { className: 'dma-studio' },
875
+ h('div', { className: 'dma-studio-head' },
876
+ h('span', { className: 'dma-studio-title' }, '🎨 ', t('studio')),
877
+ h('span', { className: 'dma-studio-note' },
878
+ running ? t('generating') : (template ? t('viaTemplate', { name: template.name }) : t('viaPrompt'))),
879
+ ),
880
+ template
881
+ ? h('div', { className: 'dma-fields' },
882
+ (template.fields ?? []).map((key) => h('label', { className: 'dma-field', key },
883
+ h('span', null, key),
884
+ h('input', {
885
+ className: 'dma-input',
886
+ value: form.fields[key] ?? '',
887
+ onChange: (e) => setForm((f) => ({ ...f, fields: { ...f.fields, [key]: e.target.value } })),
888
+ }),
889
+ )))
890
+ : null,
891
+ h('textarea', {
892
+ className: 'dma-prompt',
893
+ rows: 3,
894
+ value: form.prompt,
895
+ placeholder: t('promptPlaceholder'),
896
+ onChange: (e) => setForm((f) => ({ ...f, prompt: e.target.value })),
897
+ onKeyDown: (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) generate() },
898
+ }),
899
+ h('div', { className: 'dma-controls' },
900
+ h('select', {
901
+ className: 'dma-select',
902
+ value: form.templateId,
903
+ onChange: (e) => setForm((f) => ({ ...f, templateId: e.target.value, fields: {} })),
904
+ },
905
+ h('option', { value: '' }, t('noTemplate')),
906
+ state.templates.map((tpl) => h('option', { value: tpl.id }, `${tpl.name}${tpl.ratio ? ` · ${tpl.ratio}` : ''}`)),
907
+ ),
908
+ h('select', {
909
+ className: 'dma-select',
910
+ value: form.ratio,
911
+ onChange: (e) => setForm((f) => ({ ...f, ratio: e.target.value })),
912
+ }, ['3:4', '9:16', '1:1', '4:3', '16:9'].map((r) => h('option', { value: r }, r))),
913
+ h('span', { className: 'dma-archive' }, '📁 ', current ? current.name : t('noProject')),
914
+ h('span', { style: { flex: 1 } }),
915
+ h('button', {
916
+ className: 'dma-gen',
917
+ onClick: generate,
918
+ disabled: running || (!form.prompt.trim() && !form.templateId),
919
+ }, running ? t('generating') : '🎨 ', t('generate')),
920
+ ),
921
+ h('div', { className: 'dma-hint' }, t('genHint')),
922
+ refAssets.length
923
+ ? h('div', { className: 'dma-refs' },
924
+ h('span', { className: 'dma-refs-label' }, t('refs')),
925
+ refAssets.map((a) => h('div', { className: 'dma-ref' },
926
+ h('img', { src: a.url, alt: a.name }),
927
+ h('button', {
928
+ className: 'dma-asset-del',
929
+ onClick: () => setForm((f) => ({ ...f, refs: f.refs.filter((u) => u !== a.url) })),
930
+ }, '✕'))))
931
+ : null,
932
+ ),
933
+
934
+ // ── 作品集:出的成的才进来;图片与视频分家 ──
935
+ h('div', { className: 'dma-sec' },
936
+ h('h4', null, '🖼 ', t('works')),
937
+ h('span', { className: 'n' }, String(imageWorks.length + videoWorks.length)),
938
+ h('span', { style: { flex: 1 } }),
939
+ h('button', { className: 'dma-btn', onClick: () => loadProjectData(current) }, t('refresh')),
940
+ ),
941
+ h('p', { className: 'dma-note' }, t('jobsNote')),
942
+ state.jobsError
943
+ ? h('div', { className: 'dma-warn' }, '⚠️ ', state.jobsError, ' ', t('keepOld'))
944
+ : null,
945
+
946
+ imageWorks.length
947
+ ? h('div', null,
948
+ h('div', { className: 'dma-sub' }, '🖼 ', t('myImages'), h('span', { className: 'n' }, String(imageWorks.length))),
949
+ h('div', { className: 'dma-wall' }, imageWorks.map((job, i) => h('button', {
950
+ className: 'dma-work',
951
+ onClick: () => setZoom(job),
952
+ title: job.prompt || job.id,
953
+ },
954
+ h('img', { src: job.url, alt: job.model, loading: i < 12 ? 'eager' : 'lazy' }),
955
+ h('span', { className: 'dma-work-cap' },
956
+ h('span', { className: 'dma-work-prompt' }, job.prompt || job.id)),
957
+ ))))
958
+ : null,
959
+
960
+ videoWorks.length
961
+ ? h('div', null,
962
+ h('div', { className: 'dma-sub' }, '🎬 ', t('myVideos'), h('span', { className: 'n' }, String(videoWorks.length))),
963
+ h('div', { className: 'dma-wall' }, videoWorks.map((job) => h('button', {
964
+ className: 'dma-work is-video', onClick: () => setZoom(job),
965
+ title: job.prompt || job.id,
966
+ },
967
+ // 视频的 cdn_url 是 .mp4,塞进 <img> 就是破图(真机 5 格全破)。
968
+ // 缩略位放 ▶ 徽章,真播放在放大层里用 <video controls>。
969
+ h('span', { className: 'dma-work-video' }, '▶', h('span', null, job.model || 'MP4')),
970
+ h('span', { className: 'dma-work-cap' },
971
+ h('span', { className: 'dma-work-prompt' }, job.prompt || job.id)),
972
+ ))))
973
+ : null,
974
+
975
+ imageWorks.length + videoWorks.length === 0
976
+ ? h('div', { className: 'dma-empty' }, t('emptyJobs'))
977
+ : null,
978
+
979
+ // ── 素材库(垫图来源)──
980
+ h('div', { className: 'dma-sec' },
981
+ h('h4', null, '🧱 ', t('assets')),
982
+ h('span', { className: 'n' }, String(state.assets.length)),
983
+ h('span', { style: { flex: 1 } }),
984
+ h('input', {
985
+ type: 'file', ref: fileRef, style: { display: 'none' },
986
+ accept: 'image/*,audio/*,video/*',
987
+ onChange: (e) => { upload(e.target.files?.[0]); e.target.value = '' },
988
+ }),
989
+ h('button', {
990
+ className: 'dma-btn', disabled: uploading || !current,
991
+ onClick: () => fileRef.current?.click(),
992
+ }, uploading ? t('uploading') : t('upload')),
993
+ ),
994
+ state.assetsError
995
+ ? h('div', { className: 'dma-error' }, '⚠️ ', state.assetsError)
996
+ : state.assets.length === 0
997
+ ? h('div', { className: 'dma-empty' },
998
+ t('emptyAssets'),
999
+ jumpTarget
1000
+ ? h('div', null, h('button', {
1001
+ className: 'dma-jump', onClick: () => pick(jumpTarget),
1002
+ }, t('jumpTo', { name: jumpTarget.name, n: jumpTarget.assetCount })))
1003
+ : null,
1004
+ h('div', { style: { marginTop: '9px' } },
1005
+ h('button', { className: 'dma-jump', onClick: () => fileRef.current?.click() }, t('upload'))))
1006
+ : h('div', { className: 'dma-grid' },
1007
+ state.assets.map((a) => {
1008
+ const on = form.refs.includes(a.url)
1009
+ return h('div', {
1010
+ className: `${a.mediaType === 'video' ? 'dma-asset is-video' : 'dma-asset'}${on ? ' is-ref' : ''}`,
1011
+ onClick: () => setForm((f) => ({
1012
+ ...f,
1013
+ refs: on ? f.refs.filter((u) => u !== a.url) : [...f.refs, a.url].slice(0, 5),
1014
+ })),
1015
+ title: on ? t('unref') : t('useAsRef'),
1016
+ },
1017
+ h('button', {
1018
+ className: 'dma-asset-del',
1019
+ title: t('deleteAsset'),
1020
+ onClick: (e) => { e.stopPropagation(); setPendingDelete(a) },
1021
+ }, '🗑'),
1022
+ a.mediaType === 'video'
1023
+ ? h('div', { className: 'dma-asset-cap' }, a.name)
1024
+ : h('img', { src: a.url, alt: a.name, loading: 'lazy' }),
1025
+ a.mediaType !== 'video' ? h('div', { className: 'dma-asset-cap' }, a.name) : null,
1026
+ )
1027
+ })),
1028
+
1029
+ // ── 模板管理:浏览 / 新增 / 分享 / 撤回 / 删除 ──
1030
+ h('div', { className: 'dma-sec' },
1031
+ h('h4', null, '🧩 ', t('templates')),
1032
+ h('span', { className: 'n' }, String(state.templates.length)),
1033
+ h('span', { style: { flex: 1 } }),
1034
+ h('button', {
1035
+ className: 'dma-btn is-primary', disabled: tplBusy,
1036
+ onClick: () => setTplForm({ name: '', prompt: '', category: '', ratio: '3:4' }),
1037
+ }, t('tplNew')),
1038
+ ),
1039
+ h('div', { className: 'dma-projects', style: { marginBottom: '10px' } },
1040
+ [['mine', t('tplMine')], ['tenant', t('tplTenant')], ['platform', t('tplPlatform')]].map(([key, label]) =>
1041
+ h('button', {
1042
+ className: tplSource === key ? 'dma-chip is-active' : 'dma-chip',
1043
+ onClick: () => loadTemplates(key),
1044
+ }, h('span', { className: 'dma-chip-name' }, label))),
1045
+ ),
1046
+ tplForm
1047
+ ? h('div', { className: 'dma-tpl-form' },
1048
+ h('input', {
1049
+ className: 'dma-input', value: tplForm.name, placeholder: t('tplName'), autoFocus: true,
1050
+ onChange: (e) => setTplForm((f) => ({ ...f, name: e.target.value })),
1051
+ }),
1052
+ h('textarea', {
1053
+ className: 'dma-prompt', rows: 2, value: tplForm.prompt, placeholder: t('tplPrompt'),
1054
+ onChange: (e) => setTplForm((f) => ({ ...f, prompt: e.target.value })),
1055
+ }),
1056
+ h('div', { className: 'dma-controls' },
1057
+ h('input', {
1058
+ className: 'dma-input', style: { width: 150 }, value: tplForm.category,
1059
+ placeholder: t('tplCategory'),
1060
+ onChange: (e) => setTplForm((f) => ({ ...f, category: e.target.value })),
1061
+ }),
1062
+ h('select', {
1063
+ className: 'dma-select',
1064
+ value: tplForm.ratio,
1065
+ onChange: (e) => setTplForm((f) => ({ ...f, ratio: e.target.value })),
1066
+ }, ['3:4', '9:16', '1:1', '4:3', '16:9'].map((r) => h('option', { value: r }, r))),
1067
+ h('span', { style: { flex: 1 } }),
1068
+ h('button', {
1069
+ className: 'dma-gen', disabled: tplBusy || !tplForm.name.trim() || !tplForm.prompt.trim(),
1070
+ onClick: () => tplAction({ action: 'create', ...tplForm }),
1071
+ }, t('tplNew')),
1072
+ h('button', { className: 'dma-btn', onClick: () => setTplForm(null) }, '✕')))
1073
+ : null,
1074
+ state.templatesError
1075
+ ? h('div', { className: 'dma-error' }, '⚠️ ', state.templatesError)
1076
+ : state.templates.length === 0
1077
+ ? h('div', { className: 'dma-empty' }, t('emptyTemplates'))
1078
+ : h('div', null, state.templates.map((tpl) => h('div', { className: 'dma-tpl' },
1079
+ h('button', {
1080
+ className: 'dma-tpl-main', title: t('copyTemplateId'),
1081
+ onClick: () => {
1082
+ copy(tpl.id)
1083
+ setForm((f) => ({ ...f, templateId: tpl.id, fields: {}, ratio: tpl.ratio || f.ratio }))
1084
+ if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' })
1085
+ },
1086
+ },
1087
+ h('span', { className: 'dma-tpl-name' }, tpl.name),
1088
+ h('span', { className: 'dma-tpl-meta' },
1089
+ tpl.ratio ? h('span', null, tpl.ratio) : null,
1090
+ tpl.fieldCount ? h('span', null, t('fieldCount', { n: tpl.fieldCount })) : null,
1091
+ h('span', { className: tpl.source === '个人' ? 'dma-badge is-mine' : 'dma-badge' }, tpl.source ?? '—'))),
1092
+ tpl.source !== '平台'
1093
+ ? h('div', { className: 'dma-tpl-acts' },
1094
+ h('button', {
1095
+ className: 'dma-btn', title: tpl.source === '租户' ? t('tplUnshare') : t('tplShare'),
1096
+ disabled: tplBusy,
1097
+ onClick: () => tplAction({ action: tpl.source === '租户' ? 'unshare' : 'share', id: tpl.id }),
1098
+ }, tpl.source === '租户' ? '🔒' : '🌐'),
1099
+ h('button', {
1100
+ className: 'dma-btn', title: t('tplDelete'), disabled: tplBusy,
1101
+ onClick: () => tplAction({ action: 'delete', id: tpl.id }),
1102
+ }, '🗑'))
1103
+ : null,
1104
+ ))),
1105
+
1106
+ zoom
1107
+ ? h('div', { className: 'dma-zoom', onClick: () => setZoom(null) },
1108
+ h('div', { className: 'dma-zoom-box', onClick: (e) => e.stopPropagation() },
1109
+ zoom.mediaType === 'video'
1110
+ ? h('video', { src: zoom.url, controls: true, style: { maxWidth: '100%', maxHeight: '70vh' } })
1111
+ : h('img', { src: zoom.url, alt: zoom.model }),
1112
+ h('div', { className: 'dma-zoom-meta' },
1113
+ h('p', { className: 'dma-zoom-prompt' }, zoom.prompt || zoom.id),
1114
+ h('div', { className: 'dma-job-meta' },
1115
+ h('span', null, zoom.model),
1116
+ zoom.ratio ? h('span', null, zoom.ratio) : null,
1117
+ zoom.createdAt ? h('span', null, String(zoom.createdAt).slice(5, 16).replace('T', ' ')) : null,
1118
+ ),
1119
+ h('div', { className: 'dma-zoom-actions' },
1120
+ h('button', { className: 'dma-btn', onClick: () => copy(zoom.url) }, t('copyUrl')),
1121
+ h('button', {
1122
+ className: 'dma-btn is-primary',
1123
+ onClick: () => { setForm((f) => ({ ...f, prompt: zoom.prompt || '' })); setZoom(null) },
1124
+ }, t('reuse')),
1125
+ h('button', { className: 'dma-btn', onClick: () => setZoom(null) }, t('close')),
1126
+ ))))
1127
+ : null,
1128
+ )
1129
+ }
1130
+
1131
+ const inject = ['slots', 'locale']
1132
+
1133
+ function apply (ctx) {
1134
+ ensureStyle()
1135
+ ctx.effect(() => ctx.locale.register(NS, LOCALES), 'dsh-museav-assets: dictionaries')
1136
+ const t = ctx.locale.bind(NS)
1137
+
1138
+ // conversation.view 由 ui-conversation 声明,激活顺序不保证 —— 用 slots.inject
1139
+ // 等它上账,不假定顺序直接 register。
1140
+ ctx.slots.inject('conversation.view', () => ctx.slots.register({
1141
+ name: 'conversation.view',
1142
+ id: 'museav-assets',
1143
+ order: 40,
1144
+ label: () => t('title'),
1145
+ locale: NS,
1146
+ inject: () => ({ t }),
1147
+ }, AssetsView))
1148
+ }
1149
+
1150
+ exports.apply = apply
1151
+ // 测试缝:默认选项目的那段逻辑跑在 fetch 回调里,mock React 驱动不到,
1152
+ // 单独挂出来让 test/client-ui.test.mjs 直接断言。
1153
+ exports.richestProject = richestProject
1154
+ exports.inject = inject
1155
+ return module.exports
1156
+ },
1157
+ })