beauticode-dsh 1.0.7 → 1.0.13

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/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  该 Cordis 插件向 DSH Web 注入 beautiCode 浏览器客户端,并提供本机鉴权接口。支持图片、MP4、播放位置、静音、摸鱼模式以及清除背景。
4
4
 
5
- 装好插件并运行 `dsh web` 后,侧栏「设置」上方会出现「背景」:可从文件夹选图片或 MP4、清除、开关声音、切换或删除已保存主题,以及打开「皮肤中心」安装已审核的社区皮肤。导入的图片和视频会自动进入已保存主题。网页控制台没有摸鱼;浅色/深色跟 DSH 自己的外观走。下次打开会恢复上次背景。设置 `BEAUTICODE_SKIN_CENTER` 或填写 `skin-center.json` 后,皮肤中心才会显示远程目录。
5
+ 装好插件并运行 `dsh web` 后,侧栏「设置」上方会出现「背景」:在 Windows 上点击图片或视频会打开系统文件选择器,选好后在页面内命名,背景立即应用并进入已保存主题;也可以清除、开关声音、切换或删除主题,以及打开「皮肤中心」安装已审核的社区皮肤。本地导入只在 Node 端保存绝对路径,并通过带 Range 支持的回环媒体服务读取原文件,不复制用户选择的图片或整段视频主文件。Windows 强制使用本地引用;原生选择器不可用时会明确报错,不会静默上传或复制媒体。非 Windows 环境保留兼容上传,并明确提示该模式会保存托管副本。网页控制台没有摸鱼;浅色/深色跟 DSH 自己的外观走。下次打开会恢复上次背景。设置 `BEAUTICODE_SKIN_CENTER` 或填写 `skin-center.json` 后,皮肤中心才会显示远程目录。
6
6
 
7
7
  插件也会注册对话工具(`beauticode_*`)和斜杠命令(`/bg`、`/bg-theme`、`/bg-clear`)。不需要托盘;托盘若已在跑则复用它。
8
8
 
@@ -40,7 +40,7 @@ npx @deepseek-ai/dsh web
40
40
 
41
41
  手动接入时:
42
42
 
43
- 1. 修改 `cordis.patch.example.yml` 中的 `file:///.../index.mjs`。
43
+ 1. 修改 `cordis.patch.yml` 中的插件接入项;优先使用上面的安装命令自动完成接线。
44
44
  2. 确保 DSH 和 beautiCode 使用相同的 `BEAUTICODE_DATA_ROOT`。
45
45
  3. 自己运行 `dsh web`(或带 `--patch`)。
46
46
  4. 打开页面后用侧栏「背景」,或输入 `/bg <本机绝对路径>`,或直接跟 AI 说把某个视频/图片设为背景。
package/agent.mjs CHANGED
@@ -61,15 +61,20 @@ async function chineseError(error) {
61
61
  return raw || "操作失败。";
62
62
  }
63
63
 
64
- async function unwrapApplyResult(result, fallbackMode, message) {
65
- if (!result || result.ok === false) {
66
- fail(await chineseError(result?.error || "操作失败。"));
67
- }
68
- return {
69
- ok: true,
70
- generation: result.generation ?? null,
71
- mode: result.mode ?? fallbackMode,
72
- message,
64
+ async function unwrapApplyResult(result, fallbackMode, message) {
65
+ if (!result || result.ok === false) {
66
+ const failure = new Error(await chineseError(result?.error || "操作失败。"));
67
+ if (result?.sourceMode != null) failure.sourceMode = result.sourceMode;
68
+ if (result?.timings != null) failure.timings = result.timings;
69
+ throw failure;
70
+ }
71
+ return {
72
+ ok: true,
73
+ generation: result.generation ?? null,
74
+ mode: result.mode ?? fallbackMode,
75
+ sourceMode: result.sourceMode ?? null,
76
+ timings: result.timings ?? null,
77
+ message,
73
78
  ...(result.theme
74
79
  ? {
75
80
  theme: {
@@ -98,7 +103,7 @@ function normalizeActionOptions(dataRootOrOptions) {
98
103
  : {};
99
104
  }
100
105
 
101
- function unwrapApply(result, fallbackMode, message) {
106
+ function unwrapApply(result, fallbackMode, message) {
102
107
  if (!result || result.ok === false) {
103
108
  fail(result?.error || "操作失败。");
104
109
  }
@@ -116,18 +121,24 @@ function unwrapApply(result, fallbackMode, message) {
116
121
  },
117
122
  }
118
123
  : {}),
119
- };
120
- }
121
-
122
- function presentStatus(status) {
123
- return {
124
+ };
125
+ }
126
+
127
+ function presentStatus(status) {
128
+ const background = status.manifest?.background ?? status.background ?? null;
129
+ return {
124
130
  ok: true,
125
131
  hostReady: status.hostReady === true || status.sessions > 0,
126
132
  sessions: status.sessions ?? 0,
127
133
  fish: status.fish === true,
128
134
  muted: status.muted !== false,
129
135
  tone: status.tone ?? "dark",
130
- background: status.manifest?.background ?? status.background ?? null,
136
+ background,
137
+ sourceMode: background
138
+ ? background.source?.kind === "local"
139
+ ? "local"
140
+ : "managed"
141
+ : "clear",
131
142
  themeId: typeof status.themeId === "string" && status.themeId ? status.themeId : null,
132
143
  message: formatStatusText(status),
133
144
  };
@@ -167,21 +178,26 @@ export function createBeauticodeActions(dataRootOrOptions) {
167
178
  }
168
179
  const effects = effectsForPreset(options?.effects?.preset) || options?.effects || null;
169
180
  const persistTheme = options?.persistTheme !== false;
170
- const themeName =
171
- String(options?.themeName ?? "").trim() ||
172
- themeNameFromFilePath(inspected.path, "图片");
173
- const input = { type: "image", imagePath: inspected.path };
174
- if (effects) input.effects = effects;
175
- const resolved = await backend();
176
- if (resolved.kind === "tray") {
177
- const body = { imagePath: inspected.path, persistTheme, themeName };
178
- if (effects) body.effects = effects;
179
- const result = await request({
180
- method: "POST",
181
- path: "/apply/image",
182
- body,
183
- signal,
184
- });
181
+ const themeName =
182
+ String(options?.themeName ?? "").trim() ||
183
+ themeNameFromFilePath(inspected.path, "图片");
184
+ const source = options?.source === "managed" ? "managed" : "local";
185
+ const input = { type: "image", imagePath: inspected.path, source };
186
+ if (effects) input.effects = effects;
187
+ const resolved = await backend();
188
+ if (resolved.kind === "tray") {
189
+ const result = await request({
190
+ method: "POST",
191
+ path: persistTheme ? "/theme/apply" : "/apply/image",
192
+ body: persistTheme
193
+ ? { name: themeName, input }
194
+ : {
195
+ imagePath: inspected.path,
196
+ source,
197
+ ...(effects ? { effects } : {}),
198
+ },
199
+ signal,
200
+ });
185
201
  return unwrapApplyResult(
186
202
  result,
187
203
  "image",
@@ -189,10 +205,10 @@ export function createBeauticodeActions(dataRootOrOptions) {
189
205
  ? `已将「${result.theme?.name || themeName}」设为背景。`
190
206
  : "已将图片设为背景。",
191
207
  );
192
- }
193
- const applied = persistTheme
194
- ? await resolved.session.applyImportedMedia(input, { name: themeName })
195
- : await resolved.session.apply(input);
208
+ }
209
+ const applied = persistTheme
210
+ ? await resolved.session.applyAndSaveTheme(input, themeName)
211
+ : await resolved.session.apply(input);
196
212
  return unwrapApplyResult(
197
213
  applied,
198
214
  "image",
@@ -245,11 +261,12 @@ export function createBeauticodeActions(dataRootOrOptions) {
245
261
  fail("beauticode_apply_video 只接受 .mp4 文件。");
246
262
  }
247
263
  const persistTheme = input?.persistTheme !== false;
248
- const themeName =
249
- String(input?.themeName ?? "").trim() ||
250
- themeNameFromFilePath(inspected.path, "视频");
251
- const body = { videoPath: inspected.path, persistTheme, themeName };
252
- const localInput = { type: "video", videoPath: inspected.path };
264
+ const themeName =
265
+ String(input?.themeName ?? "").trim() ||
266
+ themeNameFromFilePath(inspected.path, "视频");
267
+ const source = input?.source === "managed" ? "managed" : "local";
268
+ const body = { videoPath: inspected.path, persistTheme, themeName, source };
269
+ const localInput = { type: "video", videoPath: inspected.path, source };
253
270
  if (typeof input.poster === "string" && input.poster.trim()) {
254
271
  const poster = await inspectLocalMedia(input.poster);
255
272
  if (!poster.ok) fail(poster.error);
@@ -265,14 +282,14 @@ export function createBeauticodeActions(dataRootOrOptions) {
265
282
  body.startAt = startAt;
266
283
  localInput.startAt = startAt;
267
284
  }
268
- const resolved = await backend();
269
- if (resolved.kind === "tray") {
270
- const result = await request({
271
- method: "POST",
272
- path: "/apply/video",
273
- body,
274
- signal,
275
- });
285
+ const resolved = await backend();
286
+ if (resolved.kind === "tray") {
287
+ const result = await request({
288
+ method: "POST",
289
+ path: persistTheme ? "/theme/apply" : "/apply/video",
290
+ body: persistTheme ? { name: themeName, input: localInput } : body,
291
+ signal,
292
+ });
276
293
  return unwrapApplyResult(
277
294
  result,
278
295
  "video",
@@ -280,10 +297,10 @@ export function createBeauticodeActions(dataRootOrOptions) {
280
297
  ? `已将「${result.theme?.name || themeName}」设为背景。`
281
298
  : "已将视频设为背景。",
282
299
  );
283
- }
284
- const applied = persistTheme
285
- ? await resolved.session.applyImportedMedia(localInput, { name: themeName })
286
- : await resolved.session.apply(localInput);
300
+ }
301
+ const applied = persistTheme
302
+ ? await resolved.session.applyAndSaveTheme(localInput, themeName)
303
+ : await resolved.session.apply(localInput);
287
304
  return unwrapApplyResult(
288
305
  applied,
289
306
  "video",
package/client.js CHANGED
@@ -197,25 +197,103 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
197
197
  });
198
198
  }
199
199
 
200
- function waitForVideo(video) {
201
- if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return Promise.resolve();
202
- return new Promise((resolve, reject) => {
203
- const done = () => {
204
- cleanup();
205
- resolve();
206
- };
207
- const failed = () => {
208
- cleanup();
209
- reject(new Error("MP4 加载或解码失败"));
210
- };
211
- const cleanup = () => {
212
- video.removeEventListener("loadeddata", done);
213
- video.removeEventListener("error", failed);
214
- };
200
+ function describeVideoState(video, phase) {
201
+ const mediaErrorNames = {
202
+ 1: "MEDIA_ERR_ABORTED",
203
+ 2: "MEDIA_ERR_NETWORK",
204
+ 3: "MEDIA_ERR_DECODE",
205
+ 4: "MEDIA_ERR_SRC_NOT_SUPPORTED",
206
+ };
207
+ const code = Number(video?.error?.code) || 0;
208
+ const mediaError = code ? mediaErrorNames[code] || `MEDIA_ERR_${code}` : "none";
209
+ return `${phase};mediaError=${mediaError};readyState=${video?.readyState ?? -1};networkState=${video?.networkState ?? -1};paused=${Boolean(video?.paused)}`;
210
+ }
211
+
212
+ function waitForVideo(video, timeoutMs = 20_000) {
213
+ if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) return Promise.resolve();
214
+ return new Promise((resolve, reject) => {
215
+ const done = () => {
216
+ cleanup();
217
+ resolve();
218
+ };
219
+ const failed = () => {
220
+ cleanup();
221
+ reject(new Error(describeVideoState(video, "MP4 加载或解码失败")));
222
+ };
223
+ const timer = setTimeout(() => {
224
+ cleanup();
225
+ reject(new Error(describeVideoState(video, "等待视频首帧超时")));
226
+ }, timeoutMs);
227
+ const cleanup = () => {
228
+ clearTimeout(timer);
229
+ video.removeEventListener("loadeddata", done);
230
+ video.removeEventListener("error", failed);
231
+ };
215
232
  video.addEventListener("loadeddata", done, { once: true });
216
233
  video.addEventListener("error", failed, { once: true });
217
- });
218
- }
234
+ });
235
+ }
236
+
237
+ function waitForStablePlayback(video, timeoutMs = 8_000) {
238
+ return new Promise((resolve, reject) => {
239
+ const startedAt = performance.now();
240
+ const initialTime = Number.isFinite(video.currentTime) ? video.currentTime : 0;
241
+ let frameSeen = false;
242
+ let settled = false;
243
+ let frameRequest = null;
244
+
245
+ const cleanup = () => {
246
+ clearInterval(timer);
247
+ video.removeEventListener("playing", check);
248
+ video.removeEventListener("timeupdate", check);
249
+ video.removeEventListener("error", failed);
250
+ if (frameRequest != null && typeof video.cancelVideoFrameCallback === "function") {
251
+ video.cancelVideoFrameCallback(frameRequest);
252
+ }
253
+ };
254
+ const finish = (error) => {
255
+ if (settled) return;
256
+ settled = true;
257
+ cleanup();
258
+ if (error) reject(error);
259
+ else resolve();
260
+ };
261
+ const failed = () => {
262
+ finish(new Error(describeVideoState(video, "视频解码器报告失败")));
263
+ };
264
+ const check = () => {
265
+ if (settled) return;
266
+ if (video.error) {
267
+ failed();
268
+ return;
269
+ }
270
+ const progressed =
271
+ Number.isFinite(video.currentTime) && video.currentTime >= initialTime + 0.08;
272
+ if (
273
+ video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA &&
274
+ !video.paused &&
275
+ (frameSeen || progressed)
276
+ ) {
277
+ finish();
278
+ return;
279
+ }
280
+ if (performance.now() - startedAt >= timeoutMs) {
281
+ finish(new Error(describeVideoState(video, "视频未在稳定窗口内输出首帧")));
282
+ }
283
+ };
284
+ const timer = setInterval(check, 100);
285
+ video.addEventListener("playing", check);
286
+ video.addEventListener("timeupdate", check);
287
+ video.addEventListener("error", failed, { once: true });
288
+ if (typeof video.requestVideoFrameCallback === "function") {
289
+ frameRequest = video.requestVideoFrameCallback(() => {
290
+ frameSeen = true;
291
+ check();
292
+ });
293
+ }
294
+ check();
295
+ });
296
+ }
219
297
 
220
298
  function seekVideo(video, value) {
221
299
  const requested = Number(value);
@@ -299,11 +377,13 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
299
377
  video.poster = payload.imageUrl;
300
378
  video.src = payload.videoUrl;
301
379
  node.replaceChildren(image, video);
302
- await waitForVideo(video);
303
- if (activePayload !== payload) return;
304
- seekVideo(video, payload.startAt);
305
- await playWithPreference(video);
306
- document.documentElement.dataset.bcActive = "true";
380
+ await waitForVideo(video);
381
+ if (activePayload !== payload) return;
382
+ seekVideo(video, payload.startAt);
383
+ await playWithPreference(video);
384
+ await waitForStablePlayback(video);
385
+ if (activePayload !== payload) return;
386
+ document.documentElement.dataset.bcActive = "true";
307
387
  document.documentElement.dataset.bcMedia = "video";
308
388
  const visible = video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && !video.paused;
309
389
  await acknowledgeRender(payload, visible, visible, visible ? null : "视频未开始播放");
package/console.js CHANGED
@@ -34,14 +34,27 @@ body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-toggle:hover{ba
34
34
  body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-list{border-color:rgba(0,0,0,.08);background:#f4f6f8}
35
35
  #beauticode-console-pop .bc-theme-row{display:flex;gap:4px;align-items:center}
36
36
  #beauticode-console-pop .bc-theme-item{cursor:pointer;display:block;flex:1;min-width:0;height:32px;padding:0 8px;border:none;border-radius:8px;background:transparent;color:inherit;font-size:13px;line-height:32px;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
37
- #beauticode-console-pop .bc-theme-item:hover{background:rgba(255,255,255,.08)}
38
- body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-item:hover{background:rgba(0,0,0,.06)}
37
+ #beauticode-console-pop .bc-theme-item:hover{background:rgba(255,255,255,.08)}
38
+ body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-item:hover{background:rgba(0,0,0,.06)}
39
+ #beauticode-console-pop .bc-source{margin-left:6px;font-size:11px;opacity:.62}
39
40
  #beauticode-console-pop .bc-theme-del{cursor:pointer;flex:none;width:32px;height:32px;padding:0;border:none;border-radius:8px;background:transparent;color:inherit;font-size:12px;opacity:.72}
40
41
  #beauticode-console-pop .bc-theme-del:hover{background:rgba(255,255,255,.08);opacity:1}
41
42
  body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-del:hover{background:rgba(0,0,0,.06)}
42
- #beauticode-console-pop .bc-msg{color:var(--dsw-alias-label-secondary,#9aa3ad);font-size:12px;line-height:16px;margin:6px 0 0;max-height:3.2em;overflow:hidden}
43
- #beauticode-console-file{display:none !important}
44
- `;
43
+ #beauticode-console-pop .bc-msg{color:var(--dsw-alias-label-secondary,#9aa3ad);font-size:12px;line-height:16px;margin:6px 0 0;max-height:3.2em;overflow:hidden}
44
+ #beauticode-console-file{display:none !important}
45
+ #beauticode-name-dialog{position:fixed;inset:0;z-index:3000;display:grid;place-items:center;padding:24px;background:rgba(0,0,0,.42);font:inherit}
46
+ #beauticode-name-dialog .bc-name-card{display:flex;flex-direction:column;gap:10px;width:min(380px,calc(100vw - 48px));padding:18px;border:1px solid rgba(255,255,255,.12);border-radius:18px;background:#2c323c;color:#e8eaed;box-shadow:0 18px 60px rgba(0,0,0,.35)}
47
+ #beauticode-name-dialog .bc-name-title{margin:0;font-size:15px;font-weight:600}
48
+ #beauticode-name-dialog .bc-name-file,#beauticode-name-dialog .bc-name-note,#beauticode-name-dialog .bc-name-error{margin:0;color:#aeb7c3;font-size:12px;line-height:18px;overflow-wrap:anywhere}
49
+ #beauticode-name-dialog .bc-name-error{color:#ff9a9a}
50
+ #beauticode-name-dialog input{height:36px;padding:0 10px;border:1px solid rgba(255,255,255,.14);border-radius:10px;background:rgba(255,255,255,.08);color:inherit;font:inherit;font-size:13px}
51
+ #beauticode-name-dialog .bc-name-actions{display:flex;justify-content:flex-end;gap:8px}
52
+ #beauticode-name-dialog button{cursor:pointer;height:34px;padding:0 13px;border:1px solid rgba(255,255,255,.12);border-radius:10px;background:rgba(255,255,255,.08);color:inherit;font:inherit}
53
+ #beauticode-name-dialog button[data-name="confirm"]{background:#4f7cff;border-color:#4f7cff;color:#fff}
54
+ body:not([data-ds-dark-theme]) #beauticode-name-dialog .bc-name-card{background:#fff;color:#1b1f24;border-color:rgba(0,0,0,.1)}
55
+ body:not([data-ds-dark-theme]) #beauticode-name-dialog input,body:not([data-ds-dark-theme]) #beauticode-name-dialog button{border-color:rgba(0,0,0,.12);background:#f4f6f8}
56
+ body:not([data-ds-dark-theme]) #beauticode-name-dialog button[data-name="confirm"]{background:#315efb;border-color:#315efb;color:#fff}
57
+ `;
45
58
  document.head.append(style);
46
59
 
47
60
  const host = document.createElement("div");
@@ -144,7 +157,7 @@ body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-del:hover{backg
144
157
  msgEl.textContent = text;
145
158
  }
146
159
 
147
- function renderStatus(data) {
160
+ function renderStatus(data) {
148
161
  if (!data?.ok) {
149
162
  statusEl.textContent = data?.error || "未就绪";
150
163
  return;
@@ -157,7 +170,13 @@ body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-del:hover{backg
157
170
  : data.media === "image"
158
171
  ? "图片"
159
172
  : "无背景";
160
- statusEl.textContent = label;
173
+ const sourceLabel =
174
+ data.sourceMode === "local"
175
+ ? "本地引用"
176
+ : data.sourceMode === "managed"
177
+ ? "托管副本"
178
+ : "";
179
+ statusEl.textContent = sourceLabel ? `${label} · ${sourceLabel}` : label;
161
180
  if (typeof data.themeId === "string" && data.themeId) {
162
181
  currentThemeId = data.themeId;
163
182
  } else if (data.atmosphere === "gallery") {
@@ -181,7 +200,9 @@ body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-del:hover{backg
181
200
  theme.bundled === true
182
201
  ? ""
183
202
  : `<button type="button" class="bc-theme-del" data-theme-delete="${escapeAttr(theme.id)}" data-theme-name="${escapeAttr(theme.name)}" aria-label="删除 ${escapeAttr(theme.name)}">删</button>`;
184
- return `<div class="bc-theme-row"><button type="button" class="bc-theme-item" data-theme-id="${escapeAttr(theme.id)}">${escapeText(theme.name)}</button>${del}</div>`;
203
+ const source =
204
+ theme.sourceMode === "local" ? "本地" : theme.bundled ? "内置" : "托管";
205
+ return `<div class="bc-theme-row"><button type="button" class="bc-theme-item" data-theme-id="${escapeAttr(theme.id)}">${escapeText(theme.name)}<span class="bc-source">${source}</span></button>${del}</div>`;
185
206
  })
186
207
  .join("");
187
208
  const selected = themes.find((theme) => theme.id === currentThemeId);
@@ -206,9 +227,12 @@ body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-del:hover{backg
206
227
  ...(init?.headers || {}),
207
228
  },
208
229
  });
209
- const body = await response.json().catch(() => null);
210
- if (!response.ok || body?.ok === false) {
211
- throw new Error(body?.error || `请求失败(${response.status})`);
230
+ const body = await response.json().catch(() => null);
231
+ if (!response.ok || body?.ok === false) {
232
+ const error = new Error(body?.error || `请求失败(${response.status})`);
233
+ error.status = response.status;
234
+ error.code = body?.code || "";
235
+ throw error;
212
236
  }
213
237
  return body;
214
238
  }
@@ -222,44 +246,138 @@ body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-del:hover{backg
222
246
  }
223
247
 
224
248
  async function run(task) {
225
- if (busy) return;
226
- busy = true;
249
+ if (busy) return;
250
+ busy = true;
251
+ let afterRun = null;
227
252
  for (const button of pop.querySelectorAll(".bc-btn, .bc-theme-toggle, .bc-theme-item, .bc-theme-del")) button.disabled = true;
228
253
  showMessage("");
229
254
  try {
230
- const result = await task();
231
- if (result?.theme?.id) currentThemeId = result.theme.id;
232
- if (result?.message) showMessage(result.message);
255
+ const result = await task();
256
+ if (typeof result?.afterRun === "function") afterRun = result.afterRun;
257
+ if (result?.theme?.id) currentThemeId = result.theme.id;
258
+ if (result?.message) {
259
+ const source =
260
+ result.sourceMode === "local"
261
+ ? "本地引用,未复制主媒体"
262
+ : result.sourceMode === "managed"
263
+ ? "托管副本"
264
+ : "";
265
+ const totalMs = Number(result.importTimings?.applyAndSaveMs ?? result.timings?.totalMs);
266
+ const duration = Number.isFinite(totalMs) ? `${Math.round(totalMs)} ms` : "";
267
+ showMessage([result.message, source, duration].filter(Boolean).join(" · "));
268
+ }
233
269
  await refresh();
234
270
  } catch (error) {
235
271
  showMessage(error instanceof Error ? error.message : String(error));
236
272
  } finally {
237
- busy = false;
238
- for (const button of pop.querySelectorAll(".bc-btn, .bc-theme-toggle, .bc-theme-item, .bc-theme-del")) button.disabled = false;
273
+ busy = false;
274
+ for (const button of pop.querySelectorAll(".bc-btn, .bc-theme-toggle, .bc-theme-item, .bc-theme-del")) button.disabled = false;
239
275
  }
276
+ if (afterRun) queueMicrotask(afterRun);
277
+ }
278
+
279
+ function validateThemeName(value) {
280
+ const name = String(value || "").trim();
281
+ if (!name) return "主题名不能为空。";
282
+ if (name.length > 80) return "主题名不能超过 80 个字符。";
283
+ if (/[<>:"/\\|?*]/.test(name) || /[\u0000-\u001f]/.test(name)) {
284
+ return '主题名不能包含 < > : " / \\ | ? * 或控制字符。';
285
+ }
286
+ return "";
240
287
  }
241
288
 
242
- function askThemeName(fileName) {
289
+ function defaultThemeName(fileName) {
243
290
  const suggested = String(fileName || "")
244
291
  .replace(/\.[^.]+$/, "")
245
292
  .trim()
246
293
  .slice(0, 80);
247
- const entered = window.prompt("请给该主题取个名字(1–80 个字符):", suggested);
248
- if (entered == null) return null;
249
- const name = entered.trim();
250
- if (!name) {
251
- showMessage("主题名不能为空。");
252
- return null;
253
- }
254
- if (name.length > 80) {
255
- showMessage("主题名不能超过 80 个字符。");
256
- return null;
257
- }
258
- if (/[<>:"/\\|?*]/.test(name) || /[\u0000-\u001f]/.test(name)) {
259
- showMessage('主题名不能包含 < > : " / \\ | ? * 或控制字符。');
260
- return null;
294
+ return suggested || "新主题";
295
+ }
296
+
297
+ function askThemeName(fileName, suggestedName, options = {}) {
298
+ return new Promise((resolve) => {
299
+ const dialog = document.createElement("div");
300
+ dialog.id = "beauticode-name-dialog";
301
+ dialog.innerHTML =
302
+ '<div class="bc-name-card" role="dialog" aria-modal="true" aria-labelledby="beauticode-name-title">' +
303
+ '<p id="beauticode-name-title" class="bc-name-title">给主题取个名字</p>' +
304
+ `<p class="bc-name-file">${escapeText(fileName)}</p>` +
305
+ (options.compatibilityUpload
306
+ ? '<p class="bc-name-note">原生选择器不可用,兼容模式会复制媒体文件。</p>'
307
+ : "") +
308
+ `<input type="text" maxlength="80" aria-label="主题名称" value="${escapeAttr(suggestedName || defaultThemeName(fileName))}"/>` +
309
+ '<p class="bc-name-error" hidden></p>' +
310
+ '<div class="bc-name-actions"><button type="button" data-name="cancel">取消</button><button type="button" data-name="confirm">保存并应用</button></div>' +
311
+ "</div>";
312
+ document.body.append(dialog);
313
+ const input = dialog.querySelector('input[aria-label="主题名称"]');
314
+ const errorEl = dialog.querySelector(".bc-name-error");
315
+ let closed = false;
316
+
317
+ const close = (value) => {
318
+ if (closed) return;
319
+ closed = true;
320
+ dialog.remove();
321
+ resolve(value);
322
+ };
323
+ const confirm = () => {
324
+ const error = validateThemeName(input.value);
325
+ if (error) {
326
+ errorEl.hidden = false;
327
+ errorEl.textContent = error;
328
+ input.focus();
329
+ return;
330
+ }
331
+ close(input.value.trim());
332
+ };
333
+ dialog.querySelector('[data-name="confirm"]').addEventListener("click", confirm);
334
+ dialog.querySelector('[data-name="cancel"]').addEventListener("click", () => close(null));
335
+ input.addEventListener("input", () => {
336
+ errorEl.hidden = true;
337
+ errorEl.textContent = "";
338
+ });
339
+ input.addEventListener("keydown", (event) => {
340
+ if (event.key === "Enter") confirm();
341
+ if (event.key === "Escape") close(null);
342
+ });
343
+ dialog.addEventListener("click", (event) => {
344
+ if (event.target === dialog) close(null);
345
+ });
346
+ input.focus();
347
+ input.select();
348
+ });
349
+ }
350
+
351
+ async function pickAndImport(kind) {
352
+ let picked;
353
+ try {
354
+ picked = await request("/__beauticode/ui/pick", {
355
+ method: "POST",
356
+ headers: { "content-type": "application/json" },
357
+ body: JSON.stringify({ kind }),
358
+ });
359
+ } catch (error) {
360
+ if (error?.code !== "native_picker_unavailable") throw error;
361
+ return {
362
+ ok: true,
363
+ afterRun: () => {
364
+ fileInput.accept = kind === "video" ? VIDEO_ACCEPT : IMAGE_ACCEPT;
365
+ fileInput.dataset.compatibilityUpload = "true";
366
+ fileInput.click();
367
+ },
368
+ };
261
369
  }
262
- return name;
370
+ if (picked.cancelled) return { ok: true };
371
+ const themeName = await askThemeName(
372
+ picked.name,
373
+ picked.suggestedThemeName,
374
+ );
375
+ if (!themeName) return { ok: true };
376
+ return request("/__beauticode/ui/import-selected", {
377
+ method: "POST",
378
+ headers: { "content-type": "application/json" },
379
+ body: JSON.stringify({ selectionId: picked.selectionId, themeName }),
380
+ });
263
381
  }
264
382
 
265
383
  trigger.addEventListener("click", (event) => {
@@ -267,12 +385,10 @@ body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-del:hover{backg
267
385
  setOpen(pop.hidden);
268
386
  });
269
387
  pop.querySelector('[data-act="image"]').addEventListener("click", () => {
270
- fileInput.accept = IMAGE_ACCEPT;
271
- fileInput.click();
388
+ void run(() => pickAndImport("image"));
272
389
  });
273
390
  pop.querySelector('[data-act="video"]').addEventListener("click", () => {
274
- fileInput.accept = VIDEO_ACCEPT;
275
- fileInput.click();
391
+ void run(() => pickAndImport("video"));
276
392
  });
277
393
  pop.querySelector('[data-act="gallery"]').addEventListener("click", (event) => {
278
394
  event.stopPropagation();
@@ -338,21 +454,25 @@ body:not([data-ds-dark-theme]) #beauticode-console-pop .bc-theme-del:hover{backg
338
454
  });
339
455
  fileInput.addEventListener("change", () => {
340
456
  const file = fileInput.files?.[0];
457
+ const compatibilityUpload = fileInput.dataset.compatibilityUpload === "true";
341
458
  fileInput.value = "";
459
+ delete fileInput.dataset.compatibilityUpload;
342
460
  if (!file) return;
343
- const themeName = askThemeName(file.name);
344
- if (!themeName) return;
345
- void run(() =>
346
- request("/__beauticode/ui/import", {
461
+ void run(async () => {
462
+ const themeName = await askThemeName(file.name, defaultThemeName(file.name), {
463
+ compatibilityUpload,
464
+ });
465
+ if (!themeName) return { ok: true };
466
+ return request("/__beauticode/ui/import", {
347
467
  method: "POST",
348
468
  headers: {
349
469
  "x-beauticode-filename": encodeURIComponent(file.name),
350
- "x-beauticode-theme-name": encodeURIComponent(themeName),
470
+ "x-beauticode-theme-name": encodeURIComponent(themeName),
351
471
  },
352
472
  body: file,
353
- }),
354
- );
355
- });
473
+ });
474
+ });
475
+ });
356
476
 
357
477
  document.addEventListener("click", (event) => {
358
478
  if (pop.hidden) return;
@@ -505,10 +505,12 @@ export async function callDshControl(dataRoot, spec) {
505
505
  typeof payload?.error === "string"
506
506
  ? payload.error
507
507
  : `托盘请求失败(HTTP ${response.status})。`;
508
- const error = new Error(message);
509
- error.statusCode = response.status;
510
- error.payload = payload;
511
- throw error;
508
+ const error = new Error(message);
509
+ error.statusCode = response.status;
510
+ error.payload = payload;
511
+ if (payload?.sourceMode != null) error.sourceMode = payload.sourceMode;
512
+ if (payload?.timings != null) error.timings = payload.timings;
513
+ throw error;
512
514
  }
513
515
  return payload;
514
516
  }