@iasiv5/dsh-skins 1.5.11 → 1.5.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.
Files changed (3) hide show
  1. package/lib/client.js +170 -52
  2. package/lib/index.js +95 -26
  3. package/package.json +4 -2
package/lib/client.js CHANGED
@@ -940,9 +940,11 @@ ${darkSelector}{${dark.join(";")}}`;
940
940
  "personalization.panelLabel": "个性化设置",
941
941
  "personalization.saveFailed": "保存失败,请重试",
942
942
  "personalization.saveConflict": "配置已在其他窗口更新,请重试",
943
+ "personalization.fieldReverted": "壁纸已失效,已回退默认",
944
+ "personalization.library.previewRef": "未保存引用",
943
945
  "personalization.library.clear": "清空图库",
944
946
  "personalization.library.clearConfirm": "将永久删除全部 {count} 张上传图片,且不可恢复。以下壁纸设置将回退为默认:{names}",
945
- "personalization.library.clearFailed": "部分图片删除失败,已刷新剩余列表",
947
+ "personalization.library.clearFailed": "清空图库失败,已刷新剩余列表",
946
948
  "personalization.library.uploading": "正在上传…",
947
949
  "personalization.library.uploadingBatch": "正在上传 {done}/{total}…",
948
950
  "personalization.library.uploadSomeFailed": "已上传 {ok} 张,{failed} 张失败({reason})",
@@ -1013,9 +1015,11 @@ ${darkSelector}{${dark.join(";")}}`;
1013
1015
  "personalization.panelLabel": "Personalization settings",
1014
1016
  "personalization.saveFailed": "Save failed; try again",
1015
1017
  "personalization.saveConflict": "Settings changed in another window; try again",
1018
+ "personalization.fieldReverted": "Wallpaper no longer exists; reset to default",
1019
+ "personalization.library.previewRef": "unsaved reference",
1016
1020
  "personalization.library.clear": "Clear library",
1017
1021
  "personalization.library.clearConfirm": "This permanently deletes all {count} uploaded images. These wallpaper settings fall back to defaults: {names}",
1018
- "personalization.library.clearFailed": "Some deletions failed; the remaining list was refreshed",
1022
+ "personalization.library.clearFailed": "Clear library failed; the remaining list was refreshed",
1019
1023
  "personalization.library.uploading": "Uploading…",
1020
1024
  "personalization.library.uploadingBatch": "Uploading {done}/{total}…",
1021
1025
  "personalization.library.uploadSomeFailed": "Uploaded {ok}; {failed} failed ({reason})",
@@ -1260,39 +1264,36 @@ ${darkSelector}{${dark.join(";")}}`;
1260
1264
  setDeletingId(null);
1261
1265
  }
1262
1266
  };
1267
+ const referenceLabelsFor = (assetId) => configClient.referencesFor(assetId).map((entry) => entry.layer === "preview" ? `${labelFor(entry.skinId)} · ${entry.key} · ${tr("personalization.library.previewRef")}` : `${labelFor(entry.skinId)} · ${entry.key}`);
1263
1268
  const removeAsset = (asset) => {
1264
1269
  if (deletingId !== null || clearing) return;
1265
- const references = state.references[asset.id] ?? [];
1270
+ const references = referenceLabelsFor(asset.id);
1266
1271
  if (references.length === 0) return deleteAsset(asset);
1267
- const names = references.map((entry) => `${labelFor(entry.skinId)} · ${entry.key}`).join("\n");
1268
1272
  requestConfirm(
1269
1273
  `${tr("personalization.library.deleteConfirm")}
1270
- ${names}`,
1274
+ ${references.join("\n")}`,
1271
1275
  () => deleteAsset(asset)
1272
1276
  );
1273
1277
  };
1274
1278
  const clearLibrary = () => {
1275
1279
  if (clearing || deletingId !== null || library.length === 0) return;
1276
- const pairs = /* @__PURE__ */ new Map();
1280
+ const names = /* @__PURE__ */ new Map();
1277
1281
  for (const asset of library) {
1278
- for (const entry of state.references[asset.id] ?? []) {
1279
- pairs.set(`${labelFor(entry.skinId)} · ${entry.key}`, true);
1282
+ for (const label of referenceLabelsFor(asset.id)) {
1283
+ names.set(label, true);
1280
1284
  }
1281
1285
  }
1282
- const names = [...pairs.keys()].join("、");
1286
+ const listed = [...names.keys()].join("、");
1283
1287
  requestConfirm(tr("personalization.library.clearConfirm", {
1284
1288
  count: library.length,
1285
- names: names === "" ? "—" : names
1289
+ names: listed === "" ? "—" : listed
1286
1290
  }), async () => {
1287
1291
  setClearing(true);
1288
1292
  try {
1289
- for (const asset of [...library]) {
1290
- const result = await configClient.deleteImage(asset.id);
1291
- if (result.error) {
1292
- setMessage(tr("personalization.library.clearFailed"));
1293
- void configClient.refetch();
1294
- return;
1295
- }
1293
+ const result = await configClient.clearLibrary();
1294
+ if (result.error) {
1295
+ setMessage(tr("personalization.library.clearFailed"));
1296
+ void configClient.refetch();
1296
1297
  }
1297
1298
  } catch {
1298
1299
  setMessage(tr("personalization.library.clearFailed"));
@@ -1405,7 +1406,10 @@ ${names}`,
1405
1406
  );
1406
1407
  }
1407
1408
  }
1408
- if (lastAssetId !== null) onValue(lastAssetId);
1409
+ if (lastAssetId !== null) {
1410
+ onValue(lastAssetId);
1411
+ await configClient.flushNow();
1412
+ }
1409
1413
  setMessage(firstError === null ? null : ok === 0 ? firstError : tr("personalization.library.uploadSomeFailed", {
1410
1414
  ok,
1411
1415
  failed: files.length - ok,
@@ -1417,6 +1421,7 @@ ${names}`,
1417
1421
  if (result.asset) {
1418
1422
  setMessage(null);
1419
1423
  onValue(result.asset.id);
1424
+ await configClient.flushNow();
1420
1425
  } else {
1421
1426
  setMessage(resolveHostErrorText(
1422
1427
  { code: result.error, message: tr("personalization.library.uploadFailed") },
@@ -1470,8 +1475,7 @@ ${names}`,
1470
1475
  if (confirmationRef.current !== confirmation) return;
1471
1476
  confirmationRef.current = null;
1472
1477
  setConfirmation(null);
1473
- const current = configClient.getState();
1474
- if (current.status === "synced" && current.mode !== "recovery") {
1478
+ if (configClient.canWrite()) {
1475
1479
  void confirmation.accept();
1476
1480
  }
1477
1481
  };
@@ -1483,7 +1487,7 @@ ${names}`,
1483
1487
  headerRef.current?.focus?.(wideLayout ? { preventScroll: true } : void 0);
1484
1488
  }, []);
1485
1489
  if (schema === null) return null;
1486
- const writesBlocked = state.status !== "synced" || state.mode === "recovery";
1490
+ const writesBlocked = !configClient.canWrite();
1487
1491
  const overrides = configClient.effectiveOverrides(skinId);
1488
1492
  const { values } = mergeValues(skinId, overrides, void 0, profile);
1489
1493
  const hasAnyOverride = Object.keys(overrides).length > 0;
@@ -1534,7 +1538,7 @@ ${names}`,
1534
1538
  jsx("button", {
1535
1539
  type: "button",
1536
1540
  className: "dsh-skins-pz-btn",
1537
- onClick: () => configClient.retry(),
1541
+ onClick: () => configClient.refetch(),
1538
1542
  children: tr("personalization.status.retry")
1539
1543
  })
1540
1544
  ] }));
@@ -1544,14 +1548,27 @@ ${names}`,
1544
1548
  jsx("span", { children: tr("personalization.status.unsupported") })
1545
1549
  ] }));
1546
1550
  }
1547
- if (state.lastFlushCode === "REVISION_CONFLICT") {
1551
+ const notices = state.notices;
1552
+ const conflictNotice = notices.find((notice) => notice.kind === "conflict");
1553
+ const errorNotice = notices.find((notice) => notice.kind === "error");
1554
+ if (conflictNotice !== void 0) {
1548
1555
  statusCluster.push(jsx("div", { key: "save-conflict", className: "dsh-skins-pz-status dsh-skins-pz-warn", children: [
1549
1556
  jsx("span", { children: tr("personalization.saveConflict") })
1550
1557
  ] }));
1551
- } else if (state.lastFlushError) {
1558
+ } else if (errorNotice !== void 0) {
1552
1559
  statusCluster.push(jsx("div", { key: "save-error", className: "dsh-skins-pz-status dsh-skins-pz-warn", children: [
1553
1560
  jsx("span", { children: tr("personalization.saveFailed") }),
1554
- jsx("div", { className: "dsh-skins-pz-muted", children: state.lastFlushError })
1561
+ jsx("div", { className: "dsh-skins-pz-muted", children: errorNotice.message })
1562
+ ] }));
1563
+ }
1564
+ const revertedNotice = notices.find((notice) => notice.kind === "reverted");
1565
+ if (revertedNotice !== void 0 && revertedNotice.fields.length > 0) {
1566
+ statusCluster.push(jsx("div", { key: "reverted", className: "dsh-skins-pz-status", children: [
1567
+ jsx("div", { children: tr("personalization.fieldReverted") }),
1568
+ jsx("div", {
1569
+ className: "dsh-skins-pz-muted",
1570
+ children: revertedNotice.fields.map((entry) => `${labelFor(entry.skinId)} · ${entry.key}`).join("、")
1571
+ })
1555
1572
  ] }));
1556
1573
  }
1557
1574
  return jsx("div", { className: "dsh-skins-pz", children: [
@@ -2191,12 +2208,20 @@ ${names}`,
2191
2208
  let fetchGeneration = 0;
2192
2209
  let disposed = false;
2193
2210
  let writeChain = Promise.resolve();
2194
- let lastFlushCode = null;
2195
- let lastFlushError = null;
2211
+ let notices = [];
2196
2212
  let channel = null;
2197
2213
  function setFlushFailure(code, message) {
2198
- lastFlushCode = code;
2199
- lastFlushError = message;
2214
+ notices = notices.filter((notice) => notice.kind === "reverted");
2215
+ if (code === "REVISION_CONFLICT") notices.push({ kind: "conflict" });
2216
+ else if (message) notices.push({ kind: "error", message });
2217
+ }
2218
+ function noteReverted(entry) {
2219
+ const current = notices.find((notice) => notice.kind === "reverted");
2220
+ if (current !== void 0 && current.fields.some((field) => field.skinId === entry.skinId && field.key === entry.key)) return;
2221
+ notices = [
2222
+ ...notices.filter((notice) => notice.kind !== "reverted"),
2223
+ { kind: "reverted", fields: [...current?.fields ?? [], entry] }
2224
+ ];
2200
2225
  }
2201
2226
  function emit() {
2202
2227
  for (const listener of [...listeners]) {
@@ -2216,8 +2241,7 @@ ${names}`,
2216
2241
  references: snapshot.references ?? {},
2217
2242
  recovery: snapshot.recovery,
2218
2243
  quota: snapshot.quota,
2219
- lastFlushCode,
2220
- lastFlushError,
2244
+ notices: structuredClone(notices),
2221
2245
  dirtyCount: previews.size
2222
2246
  };
2223
2247
  }
@@ -2281,8 +2305,10 @@ ${names}`,
2281
2305
  }
2282
2306
  const FLUSH_DEBOUNCE_MS = 400;
2283
2307
  let flushTimer = null;
2284
- function gateWrites() {
2285
- if (status === "synced") return null;
2308
+ function writeGate() {
2309
+ if (status === "synced") {
2310
+ return snapshot.mode === "recovery" ? "recovery" : null;
2311
+ }
2286
2312
  if (status === "loading") return "loading";
2287
2313
  if (status === "unsupported-readonly") return "unsupported";
2288
2314
  return "offline";
@@ -2309,7 +2335,7 @@ ${names}`,
2309
2335
  writeChain = writeChain.then(async () => {
2310
2336
  try {
2311
2337
  if (disposed) return { flushed: 0, blocked: "disposed" };
2312
- const blocked = gateWrites();
2338
+ const blocked = writeGate();
2313
2339
  if (blocked !== null) return { flushed: 0, blocked };
2314
2340
  const attemptedRevision = snapshot.revision;
2315
2341
  for (let attempt = 0; attempt < 2; attempt += 1) {
@@ -2374,8 +2400,28 @@ ${names}`,
2374
2400
  if (!response.ok) {
2375
2401
  const errorBody = await response.json().catch(() => null);
2376
2402
  const errorMessage = errorBody?.error ?? `HTTP ${response.status}`;
2377
- setFlushFailure(null, errorMessage);
2403
+ const issues = Array.isArray(errorBody?.params?.issues) ? errorBody.params.issues : null;
2404
+ let excised = false;
2405
+ let onlyDangling = issues !== null && issues.length > 0;
2406
+ if (issues !== null) {
2407
+ for (const issue of issues) {
2408
+ const dangling = issue.code === "MISSING_ASSET" || issue.code === "BAD_ASSET";
2409
+ if (!dangling) onlyDangling = false;
2410
+ if (dangling && typeof issue.skinId === "string" && typeof issue.key === "string") {
2411
+ const composite = `${issue.skinId} ${issue.key}`;
2412
+ if (previews.has(composite)) {
2413
+ previews.delete(composite);
2414
+ excised = true;
2415
+ noteReverted({ skinId: issue.skinId, key: issue.key });
2416
+ }
2417
+ }
2418
+ }
2419
+ }
2420
+ const healed = onlyDangling && excised;
2421
+ setFlushFailure(null, healed ? null : errorMessage);
2378
2422
  emit();
2423
+ if (excised && previews.size > 0) void flushNow();
2424
+ if (healed) return { flushed: 0, blocked: null, selfHealed: true };
2379
2425
  return { flushed: 0, blocked: "error", errorMessage };
2380
2426
  }
2381
2427
  await response.json().catch(() => null);
@@ -2383,6 +2429,7 @@ ${names}`,
2383
2429
  if (previews.get(composite) === value) previews.delete(composite);
2384
2430
  }
2385
2431
  setFlushFailure(null, null);
2432
+ notices = [];
2386
2433
  await refetch();
2387
2434
  announce();
2388
2435
  return { flushed: count };
@@ -2440,10 +2487,6 @@ ${names}`,
2440
2487
  }
2441
2488
  return merged;
2442
2489
  }
2443
- function retry() {
2444
- if (status !== "offline-failed" && status !== "synced") return refetch();
2445
- return refetch();
2446
- }
2447
2490
  async function assetFingerprint(bytes) {
2448
2491
  if (typeof crypto?.subtle?.digest !== "function") return null;
2449
2492
  const digest = await crypto.subtle.digest("SHA-256", bytes);
@@ -2477,7 +2520,7 @@ ${names}`,
2477
2520
  return { error: `HTTP ${response.status}` };
2478
2521
  }
2479
2522
  async function uploadImage(file, displayName) {
2480
- const blocked = gateWrites();
2523
+ const blocked = writeGate();
2481
2524
  if (blocked !== null) return { error: blocked };
2482
2525
  const bytes = file instanceof Uint8Array ? file : new Uint8Array(await file.arrayBuffer());
2483
2526
  let networkError;
@@ -2508,22 +2551,96 @@ ${names}`,
2508
2551
  async function concludeUpload(outcome) {
2509
2552
  if (outcome.error !== void 0) return { error: outcome.error };
2510
2553
  const body = await outcome.response.json();
2511
- await refetch();
2554
+ if (body?.asset?.id !== void 0) {
2555
+ snapshot = {
2556
+ ...snapshot,
2557
+ revision: typeof body.revision === "number" ? body.revision : snapshot.revision,
2558
+ library: [...snapshot.library, body.asset],
2559
+ quota: {
2560
+ count: (snapshot.quota?.count ?? 0) + 1,
2561
+ totalBytes: (snapshot.quota?.totalBytes ?? 0) + (body.asset.byteLength ?? 0)
2562
+ }
2563
+ };
2564
+ emit();
2565
+ }
2512
2566
  announce();
2513
2567
  return { asset: body.asset };
2514
2568
  }
2569
+ function runGalleryOp(operations, goalAchieved) {
2570
+ writeChain = writeChain.then(async () => {
2571
+ if (disposed) return { error: "disposed" };
2572
+ const blocked = writeGate();
2573
+ if (blocked !== null) return { error: blocked };
2574
+ for (let attempt = 0; attempt < 2; attempt += 1) {
2575
+ let response;
2576
+ try {
2577
+ response = await request("/config", {
2578
+ method: "PATCH",
2579
+ headers: { "content-type": "application/json" },
2580
+ body: JSON.stringify({ baseRevision: snapshot.revision, operations })
2581
+ });
2582
+ } catch {
2583
+ return { error: "OFFLINE" };
2584
+ }
2585
+ if (disposed) return { error: "disposed" };
2586
+ if (response.status === 409) {
2587
+ const conflictBody = await response.json().catch(() => null);
2588
+ if (conflictBody?.code === "STORE_READONLY") {
2589
+ setFlushFailure(null, null);
2590
+ setStatus("unsupported-readonly");
2591
+ return { error: "conflict" };
2592
+ }
2593
+ await refetch();
2594
+ if (goalAchieved()) return { affectedSkins: [] };
2595
+ if (attempt === 1) {
2596
+ setFlushFailure(null, null);
2597
+ emit();
2598
+ return { error: "REVISION_CONFLICT" };
2599
+ }
2600
+ continue;
2601
+ }
2602
+ if (!response.ok) {
2603
+ const body2 = await response.json().catch(() => null);
2604
+ if (body2?.code === "INVALID_CONFIG" && body2.params?.issues?.some((issue) => issue.code === "ASSET_NOT_FOUND")) {
2605
+ await refetch();
2606
+ if (goalAchieved()) return { affectedSkins: [] };
2607
+ return { error: "ASSET_NOT_FOUND" };
2608
+ }
2609
+ return { error: body2?.code ?? `HTTP ${response.status}` };
2610
+ }
2611
+ const body = await response.json().catch(() => null);
2612
+ await refetch();
2613
+ announce();
2614
+ return { affectedSkins: body?.affectedSkins ?? [], revision: body?.revision };
2615
+ }
2616
+ return { error: "REVISION_CONFLICT" };
2617
+ });
2618
+ return writeChain;
2619
+ }
2515
2620
  async function deleteImage(id) {
2516
- const blocked = gateWrites();
2621
+ const blocked = writeGate();
2517
2622
  if (blocked !== null) return { error: blocked };
2518
- const response = await request(`/library/${id}`, { method: "DELETE" });
2519
- if (!response.ok) {
2520
- const body2 = await response.json().catch(() => null);
2521
- return { error: body2?.code ?? `HTTP ${response.status}` };
2623
+ return runGalleryOp(
2624
+ [{ op: "deleteAsset", assetId: id }],
2625
+ () => !snapshot.library.some((entry) => entry.id === id)
2626
+ );
2627
+ }
2628
+ function clearLibrary() {
2629
+ return runGalleryOp([{ op: "clearLibrary" }], () => snapshot.library.length === 0);
2630
+ }
2631
+ function valuedPreviews() {
2632
+ const fields = [];
2633
+ for (const [composite, value] of previews) {
2634
+ if (value === null) continue;
2635
+ const [skinId, key] = composite.split(" ");
2636
+ fields.push({ skinId, key, value });
2522
2637
  }
2523
- const body = await response.json();
2524
- await refetch();
2525
- announce();
2526
- return { affectedSkins: body.affectedSkins ?? [], revision: body.revision };
2638
+ return fields;
2639
+ }
2640
+ function referencesFor(assetId) {
2641
+ const persisted = (snapshot.references?.[assetId] ?? []).map((entry) => ({ skinId: entry.skinId, key: entry.key, layer: "persisted" }));
2642
+ const preview2 = valuedPreviews().filter((entry) => entry.value === assetId).map((entry) => ({ skinId: entry.skinId, key: entry.key, layer: "preview" }));
2643
+ return [...persisted, ...preview2];
2527
2644
  }
2528
2645
  async function confirmRecovery() {
2529
2646
  const response = await request("/recovery", { method: "POST" });
@@ -2541,17 +2658,18 @@ ${names}`,
2541
2658
  const api = {
2542
2659
  boot: refetch,
2543
2660
  refetch,
2544
- retry,
2545
2661
  flushNow,
2546
2662
  preview,
2547
2663
  previewReset,
2548
2664
  effectiveOverrides,
2549
2665
  uploadImage,
2550
2666
  deleteImage,
2667
+ clearLibrary,
2668
+ referencesFor,
2551
2669
  confirmRecovery,
2552
2670
  assetUrl,
2553
2671
  getState: publicState,
2554
- writeBlocked: gateWrites,
2672
+ canWrite: () => writeGate() === null,
2555
2673
  onStateChange(listener) {
2556
2674
  listeners.add(listener);
2557
2675
  return () => listeners.delete(listener);
package/lib/index.js CHANGED
@@ -693,6 +693,20 @@ function createPersonalizationStore(options = {}) {
693
693
  if (mode === "recovery") base.recovery = recovery;
694
694
  return base;
695
695
  }
696
+ function applyGalleryDelete(draft, id, affectedSkins) {
697
+ const meta = draft.library[id];
698
+ for (const [skinId, section] of Object.entries(draft.skins)) {
699
+ for (const field of listAssetFields(skinId)) {
700
+ if (section?.[field.key] === id) {
701
+ delete section[field.key];
702
+ affectedSkins.push({ skinId, key: field.key });
703
+ }
704
+ }
705
+ if (Object.keys(section).length === 0) delete draft.skins[skinId];
706
+ }
707
+ delete draft.library[id];
708
+ return meta;
709
+ }
696
710
  async function applyOperations({ baseRevision, operations }) {
697
711
  init();
698
712
  requireNormal("配置写入");
@@ -703,36 +717,100 @@ function createPersonalizationStore(options = {}) {
703
717
  if (!Array.isArray(operations) || operations.length === 0 || operations.length > 64) {
704
718
  throw codedError("INVALID_CONFIG", "operations 必须是 1–64 个元素的数组");
705
719
  }
706
- for (const operation of operations) {
720
+ const issues = [];
721
+ for (let index = 0; index < operations.length; index += 1) {
722
+ const operation = operations[index];
707
723
  if (operation === null || typeof operation !== "object") {
708
- throw codedError("INVALID_CONFIG", "operation 必须是对象");
724
+ issues.push({ opIndex: index, code: "INVALID_OP" });
725
+ continue;
709
726
  }
710
727
  const { op, skinId, key } = operation;
711
- if (op !== "set" && op !== "delete") throw codedError("INVALID_CONFIG", "op 必须是 set 或 delete");
712
- if (getField(skinId, key) === null) {
713
- throw codedError("INVALID_CONFIG", `未知字段 ${skinId}.${key}`);
728
+ if (op === "set" || op === "delete") {
729
+ if (getField(skinId, key) === null) {
730
+ issues.push({ opIndex: index, skinId, key, code: "UNKNOWN_FIELD" });
731
+ }
732
+ } else if (op === "deleteAsset") {
733
+ const assetId = operation.assetId;
734
+ if (typeof assetId !== "string" || !ASSET_ID_PATTERN.test(assetId)) {
735
+ issues.push({ opIndex: index, assetId, code: "INVALID_ASSET_ID" });
736
+ } else if (state.library[assetId] === void 0) {
737
+ issues.push({ opIndex: index, assetId, code: "ASSET_NOT_FOUND" });
738
+ }
739
+ } else if (op !== "clearLibrary") {
740
+ issues.push({ opIndex: index, code: "INVALID_OP" });
714
741
  }
715
742
  }
716
743
  const draft = structuredClone(state);
717
744
  const provider = metaProviderFactory(draft.library);
745
+ for (let index = 0; index < operations.length; index += 1) {
746
+ const operation = operations[index];
747
+ if (operation?.op !== "set") continue;
748
+ const verdict = validateOverride(operation.skinId, operation.key, operation.value, provider);
749
+ if (!verdict.ok) {
750
+ issues.push({ opIndex: index, skinId: operation.skinId, key: operation.key, code: verdict.code });
751
+ }
752
+ }
753
+ if (issues.length > 0) {
754
+ issues.sort((a, b) => a.opIndex - b.opIndex);
755
+ throw codedError("INVALID_CONFIG", `operations 校验失败(${issues[0].code})`, {
756
+ code: issues[0].code,
757
+ // retained for old readers (ADR-0015 Q&A R1-8)
758
+ issues
759
+ });
760
+ }
761
+ const affectedSkins = [];
762
+ const removedAssets = [];
763
+ let galleryOps = false;
764
+ const hasFieldOps = operations.some((operation) => operation?.op === "set" || operation?.op === "delete");
718
765
  for (const operation of operations) {
719
766
  const { op, skinId, key } = operation;
720
- if (op === "set") {
721
- const verdict = validateOverride(skinId, key, operation.value, provider);
722
- if (!verdict.ok) {
723
- throw codedError("INVALID_CONFIG", `${skinId}.${key} 校验失败(${verdict.code})`, { code: verdict.code });
767
+ if (op === "set" || op === "delete") {
768
+ draft.skins[skinId] = draft.skins[skinId] ?? {};
769
+ if (op === "set") draft.skins[skinId][key] = operation.value;
770
+ else delete draft.skins[skinId][key];
771
+ } else if (op === "deleteAsset") {
772
+ galleryOps = true;
773
+ const meta = applyGalleryDelete(draft, operation.assetId, affectedSkins);
774
+ if (meta !== void 0) removedAssets.push(meta);
775
+ } else if (op === "clearLibrary") {
776
+ galleryOps = true;
777
+ for (const id of Object.keys(draft.library)) {
778
+ const meta = applyGalleryDelete(draft, id, affectedSkins);
779
+ if (meta !== void 0) removedAssets.push(meta);
724
780
  }
725
781
  }
726
782
  }
727
- for (const { op, skinId, key, value } of operations) {
728
- draft.skins[skinId] = draft.skins[skinId] ?? {};
729
- if (op === "set") draft.skins[skinId][key] = value;
730
- else delete draft.skins[skinId][key];
783
+ if (galleryOps && !hasFieldOps && removedAssets.length === 0) {
784
+ return { revision: state.revision, affectedSkins };
785
+ }
786
+ if (galleryOps) {
787
+ const finalProvider = metaProviderFactory(draft.library);
788
+ for (const [skinId, section] of Object.entries(draft.skins)) {
789
+ for (const field of listAssetFields(skinId)) {
790
+ const value = section?.[field.key];
791
+ if (value === void 0) continue;
792
+ const verdict = validateOverride(skinId, field.key, value, finalProvider);
793
+ if (!verdict.ok) {
794
+ throw codedError("INVALID_CONFIG", `终态校验失败:${skinId}.${field.key} 引用已删除资产`, {
795
+ code: verdict.code,
796
+ issues: [{ skinId, key: field.key, code: verdict.code }]
797
+ });
798
+ }
799
+ }
800
+ }
731
801
  }
732
802
  draft.revision += 1;
733
803
  commitState(draft);
804
+ for (const meta of removedAssets) {
805
+ try {
806
+ fs.unlinkSync(blobFileFor(meta.id, meta.extension));
807
+ } catch {
808
+ }
809
+ }
734
810
  gcNow();
735
- return { revision: draft.revision };
811
+ const result = { revision: draft.revision };
812
+ if (galleryOps) result.affectedSkins = affectedSkins;
813
+ return result;
736
814
  });
737
815
  }
738
816
  function ingestAssetMeta(buffer, { declaredMime } = {}) {
@@ -829,23 +907,14 @@ function createPersonalizationStore(options = {}) {
829
907
  throw codedError("INVALID_ASSET_ID", "非法的图片 id");
830
908
  }
831
909
  return enqueue(() => {
832
- const meta = state.library[id];
833
- if (meta === void 0) throw codedError("ASSET_NOT_FOUND", "图片不存在");
910
+ if (state.library[id] === void 0) throw codedError("ASSET_NOT_FOUND", "图片不存在");
834
911
  const draft = structuredClone(state);
835
912
  const affectedSkins = [];
836
- for (const [skinId, section] of Object.entries(draft.skins)) {
837
- for (const field of listAssetFields(skinId)) {
838
- if (section?.[field.key] === id) {
839
- delete section[field.key];
840
- affectedSkins.push({ skinId, key: field.key });
841
- }
842
- }
843
- }
844
- delete draft.library[id];
913
+ const meta = applyGalleryDelete(draft, id, affectedSkins);
845
914
  draft.revision += 1;
846
915
  commitState(draft);
847
916
  try {
848
- fs.unlinkSync(blobFileFor(id, meta.extension));
917
+ fs.unlinkSync(blobFileFor(meta.id, meta.extension));
849
918
  } catch {
850
919
  }
851
920
  gcNow();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@iasiv5/dsh-skins",
3
3
  "description": "DSH Web 皮肤插件:多套品牌皮肤侧栏热切换。",
4
- "version": "1.5.11",
4
+ "version": "1.5.13",
5
5
  "homepage": "https://github.com/iasiv5/dsh-skins#readme",
6
6
  "repository": {
7
7
  "type": "git",
@@ -66,6 +66,7 @@
66
66
  "devDependencies": {
67
67
  "@deepseek-ai/cordis": "^4.0.4",
68
68
  "esbuild": "^0.28.2",
69
+ "eslint": "^10.13.0",
69
70
  "playwright-core": "1.62.1",
70
71
  "semver": "^7.8.5"
71
72
  },
@@ -77,6 +78,7 @@
77
78
  "verify:readme": "node scripts/verify-readme-pairing.mjs",
78
79
  "verify:bundle": "node scripts/verify-bundle-guard.mjs",
79
80
  "verify:hooks": "node scripts/verify-upstream-hooks.mjs",
80
- "check": "pnpm run check:lockfile && pnpm run build && node --check lib/client.js && node --check lib/index.js && pnpm run test && pnpm run verify:readme && pnpm run verify:bundle && pnpm run verify:hooks"
81
+ "check": "pnpm run check:lockfile && pnpm run build && node --check lib/client.js && node --check lib/index.js && pnpm run lint:tests && pnpm run test && pnpm run verify:readme && pnpm run verify:bundle && pnpm run verify:hooks && git diff --exit-code -- lib/index.js lib/client.js",
82
+ "lint:tests": "eslint tests/"
81
83
  }
82
84
  }