dsh-paoding 0.4.4 → 0.4.6

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.
@@ -8,14 +8,15 @@
8
8
  */
9
9
  import os from 'node:os'
10
10
  import path from 'node:path'
11
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
11
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
12
12
  import { readFile } from 'node:fs/promises'
13
13
  import { assignSlugs, collectState, generateAndInstall, presetIdOf } from '../../tools/install.mjs'
14
14
  import { autoAssignments, baseAssignments } from '../../tools/lib/alloc.mjs'
15
+ import { computeRestrictAllow } from '../../tools/lib/compose.mjs'
15
16
  import { loadConfig, normalizeConfig, normalizeTargetFields, saveConfig, serializeConfig, stripNonTargetKeys } from '../../tools/lib/config.mjs'
16
17
  import { parseYamlSubset, preprocessPatchText } from '../../tools/lib/yaml.mjs'
17
18
  import { captureWarnings, healthCheck } from '../../tools/lib/health.mjs'
18
- import { warn } from '../../tools/lib/util.mjs'
19
+ import { ROLES, warn } from '../../tools/lib/util.mjs'
19
20
  import {
20
21
  backfillHomePatchRows,
21
22
  detectAgentRolesFlavor,
@@ -288,10 +289,178 @@ export async function importConfigContent(content) {
288
289
  error.statusCode = 422
289
290
  throw error
290
291
  }
292
+ // 写盘前自动轮换备份(当前配置推入 bak-1,旧 bak 依次后移;首次保存无
293
+ // 「当前」则不备份)——apply / 工作区预设写盘 / 回滚同一口径,见下方备份节。
294
+ rotateConfigBackups()
291
295
  saveConfig(resolveConfigFile(), normalizeConfig(data))
292
296
  return { ok: true }
293
297
  }
294
298
 
299
+ // ── 配置自动备份与回滚(配置文件写路径的护栏,页头「更多」菜单服务端核心)──────
300
+ //
301
+ // 语义:凡面板写配置(apply / import / 工作区预设写盘 / 回滚本身)在落盘前先把
302
+ // 当前配置推入备份链:当前 → bak-1(最新),旧 bak 依次后移(bak-1→bak-2、
303
+ // bak-2→bak-3),bak-3(最老)淘汰。首次保存(配置文件尚不存在)没有「当前」
304
+ // 可备份,不产生任何 bak 文件。备份名 = 配置文件名 + .bak-N(默认配置即
305
+ // dsh-paoding.config.yml.bak-1/2/3),自定义配置名(DSH_PAODING_CONFIG)随之
306
+ // 派生,清单与还原按同一派生口径校验。
307
+
308
+ /** 备份名校验用正则:配置文件名转义后拼 .bak-N 后缀(N = 1..3 层)。 */
309
+ function configBackupRe() {
310
+ const stem = path.basename(resolveConfigFile()).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
311
+ return new RegExp(`^${stem}\\.bak-([123])$`)
312
+ }
313
+
314
+ /** 第 n 层备份的绝对路径(与配置文件同目录,文件名恒为单层 basename)。 */
315
+ function configBackupPath(n) {
316
+ return `${resolveConfigFile()}.bak-${n}`
317
+ }
318
+
319
+ /**
320
+ * 备份轮换:把当前配置推入 bak-1,旧 bak 依次后移,bak-3 淘汰。当前配置不
321
+ * 存在(首次保存)时不写任何文件、返回 false;轮换完成返回 true。当前文件
322
+ * 用拷贝而非改名进 bak-1——原地保留,读侧(生成管线 / 回滚校验)随时可读;
323
+ * 后移用改名,同一目录内 rename 是原子操作,中断也不产生半个备份。
324
+ */
325
+ export function rotateConfigBackups() {
326
+ const config = resolveConfigFile()
327
+ if (!existsSync(config)) return false
328
+ rmSync(configBackupPath(3), { force: true }) // 最老一层直接淘汰(不存在也无妨)
329
+ for (const [from, to] of [[2, 3], [1, 2]]) {
330
+ try {
331
+ renameSync(configBackupPath(from), configBackupPath(to))
332
+ } catch (err) {
333
+ if (err.code !== 'ENOENT') throw err // 该层尚无备份:正常,跳过
334
+ }
335
+ }
336
+ copyFileSync(config, configBackupPath(1))
337
+ return true
338
+ }
339
+
340
+ /**
341
+ * 备份清单(GET config/backups):{ file, mtimeMs, size }[],按新→旧排序
342
+ *(mtime 降序,同刻并列时层号小者在前)。只读不写;配置目录读不了 / 无备份
343
+ * 一律返回空数组,绝不让清单页 5xx。file 恒为 basename(还原路由只认配置
344
+ * 目录内的备份名,回传它即可防路径穿越)。
345
+ */
346
+ export function listConfigBackups() {
347
+ let names = []
348
+ try {
349
+ names = readdirSync(path.dirname(resolveConfigFile()))
350
+ } catch {
351
+ return [] // 目录不存在 / 不可读 = 无备份可言
352
+ }
353
+ const backupRe = configBackupRe()
354
+ return names
355
+ .map((name) => {
356
+ const m = backupRe.exec(name)
357
+ if (!m) return null
358
+ try {
359
+ const st = statSync(path.join(path.dirname(resolveConfigFile()), name))
360
+ return { file: name, mtimeMs: st.mtimeMs, size: st.size, layer: Number(m[1]) }
361
+ } catch {
362
+ return null // 列举途中被删 / stat 失败:跳过该条
363
+ }
364
+ })
365
+ .filter(Boolean)
366
+ .sort((a, b) => b.mtimeMs - a.mtimeMs || a.layer - b.layer)
367
+ .map(({ file, mtimeMs, size }) => ({ file, mtimeMs, size }))
368
+ }
369
+
370
+ /**
371
+ * 回滚配置(POST config/restore):file 须精确匹配「配置文件名.bak-1/2/3」,
372
+ * 显式拒任何含分隔符 / 上跳段的名字(防路径穿越);通过后先按 loadConfig 同一
373
+ * 口径验证备份可解析(坏备份拒绝覆盖,422;备份不存在 404——statusCode 由
374
+ * 路由层外层 catch 按其回状态码 + { error } 信封),再把「当前配置」按同款
375
+ * 轮换推入备份链(滚错了还能再滚回来),最后拷贝备份为当前。落盘即改 mtime,
376
+ * state 缓存靠既有对账自动失效,无需手动清理。
377
+ */
378
+ export async function restoreConfigBackup(file) {
379
+ const name = typeof file === 'string' ? file : ''
380
+ if (name === '' || name.includes('/') || name.includes('..') || !configBackupRe().test(name)) {
381
+ const error = new Error(`备份文件名不合法(须为 ${path.basename(resolveConfigFile())}.bak-1/2/3):${name === '' ? '(空)' : name}`)
382
+ error.statusCode = 422
383
+ throw error
384
+ }
385
+ const backupPath = path.join(path.dirname(resolveConfigFile()), name)
386
+ // 宿主 yaml 模块从 state 顺路取(collectState 已探测好);state 通道异常时
387
+ // 退化为仅内置解析器校验,与 importConfigContent 同款降级——宁严勿漏。
388
+ let yamlMod = null
389
+ try {
390
+ yamlMod = (await getState())?.yamlMod ?? null
391
+ } catch { /* 校验口径降级,见上 */ }
392
+ let parsed = null
393
+ try {
394
+ parsed = loadConfig(backupPath, yamlMod)
395
+ } catch (err) {
396
+ const error = new Error(`备份无法解析为合法配置,已拒绝回滚:${err.message}`)
397
+ error.statusCode = 422
398
+ throw error
399
+ }
400
+ if (parsed === null) {
401
+ const error = new Error(`备份不存在:${name}`)
402
+ error.statusCode = 404
403
+ throw error
404
+ }
405
+ // 先把选定备份的内容读进内存再轮换:轮换会把整条备份链后移一层,固定层号
406
+ // (bak-N)路径下的内容随之改变——后读的话落到「当前」的就不是用户选的
407
+ // 那份快照了。配置文件很小,整读零成本,顺序必须遵守。
408
+ const content = readFileSync(backupPath)
409
+ // 回滚本身也是一次写配置:先把「当前」按同款轮换推入备份链(滚错了还能
410
+ // 再滚回来),再落选定快照。mtime 变化令 state 缓存按既有对账自动失效,
411
+ // 无需手动清理。
412
+ rotateConfigBackups()
413
+ writeFileSync(resolveConfigFile(), content, { mode: 0o600 }) // 权限口径与 saveConfig 一致
414
+ return { ok: true }
415
+ }
416
+
417
+ // ── 工具面瘦身统计(slim:state / preview / apply 响应的派生字段)─────────────
418
+
419
+ /**
420
+ * 主 agent 工具面瘦身统计,口径与生成管线严格同源(composeGenerated):
421
+ * - total = 可用工具全集 = presetUniverse ∪ inventory 的名字数。presetUniverse
422
+ * = restrict.mjs 主 agent 白名单(restrictBase)∪ 源 preset 各角色 allow
423
+ * 自带面;inventory = 检测到的可用 MCP / 插件工具名。这正是单 agent 模式下
424
+ * 主 agent 名下可能挂着的一切工具(computeRestrictAllow / usableWith 判定
425
+ * 用的同一候选全集),即「不拆角色时的完整工具面」;
426
+ * - main = 当前 assignments 下主 agent 的有效 allow 计数:computeRestrictAllow
427
+ * (base ∪ extra ∪ 自定义角色名 − main_agent_remove − roles_remove,过可用
428
+ * 性过滤)的结果长度;结果与 base 全同时该函数返回 null,按 base 计数。
429
+ * state 收原始通道(blocks 为 Map、inventory 为 Set)与序列化视图(blocks 为
430
+ * 普通对象、inventory 为数组)两态,一律只读。assignments 先剥 profile /
431
+ * workspaces、过 normalizeTargetFields 再算——手写未规范化的提交也得到与
432
+ * 落盘一致的口径。
433
+ */
434
+ export function slimStatsOf(state, assignments) {
435
+ const restrictBase = Array.isArray(state?.restrictBase) ? state.restrictBase : []
436
+ // blocks 两种形态:原始 state 的 Map 值带 names 数组;序列化视图的值即名字数组
437
+ const blockValues = state?.blocks instanceof Map
438
+ ? [...state.blocks.values()]
439
+ : (state?.blocks && typeof state.blocks === 'object' ? Object.values(state.blocks) : [])
440
+ const presetUniverse = new Set(restrictBase)
441
+ for (const block of blockValues) {
442
+ for (const name of Array.isArray(block?.names) ? block.names : Array.isArray(block) ? block : []) {
443
+ presetUniverse.add(name)
444
+ }
445
+ }
446
+ const inventory = state?.inventory instanceof Set
447
+ ? state.inventory
448
+ : new Set(Array.isArray(state?.inventory) ? state.inventory : [])
449
+ const total = new Set([...presetUniverse, ...inventory]).size
450
+ const target = normalizeTargetFields(stripNonTargetKeys(assignments ?? {}))
451
+ const customToolNames = Object.keys(target.roles).filter((n) => !ROLES.includes(n))
452
+ const allow = computeRestrictAllow(
453
+ restrictBase,
454
+ presetUniverse,
455
+ inventory,
456
+ target.main_agent_extra,
457
+ target.main_agent_remove,
458
+ customToolNames,
459
+ target.roles_remove,
460
+ ) ?? restrictBase
461
+ return { total, main: allow.length }
462
+ }
463
+
295
464
  // ── 预设 CRUD(presets 路由族的服务端核心)──────────────────────────────────
296
465
  //
297
466
  // 面板 apply 只覆盖「全局 / 单个工作区视角的生成」,这里补齐预设对象本身的
@@ -0,0 +1,8 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2
+ <rect width="64" height="64" rx="14" fill="#4D6BFE"/>
3
+ <g transform="rotate(-45 32 32)">
4
+ <rect x="14" y="11" width="32" height="22" rx="3" fill="#FFFFFF"/>
5
+ <circle cx="41" cy="16.5" r="2" fill="#4D6BFE"/>
6
+ <rect x="44" y="17" width="13" height="10" rx="4" fill="#FFFFFF" opacity="0.85"/>
7
+ </g>
8
+ </svg>
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * 把 dsh-paoding 可视化配置器整合进 DSH Web GUI(左侧栏全页配置,无独立服务端):
5
5
  * - Node 侧:注册 /api/paoding/* 前缀路由(state/models/workspaces/presets/
6
- * version/upgrade/preview/apply/rescan/config/export/config/import +
6
+ * version/upgrade/preview/apply/rescan/config/export/config/import/
7
+ * config/backups/config/restore +
7
8
  * client.css 静态样式),经
8
9
  * ./api-core.mjs 复用 tools/install.mjs 的检测与生成管线;workspaces 直连
9
10
  * 宿主 workspaceRegistry 服务(list / create,幂等);presets 提供预设
@@ -39,10 +40,12 @@ import { readFile } from 'node:fs/promises'
39
40
  import os from 'node:os'
40
41
  import path from 'node:path'
41
42
  import { fileURLToPath } from 'node:url'
42
- import { getState, refreshState, isStateCacheStale, serializeState, installAssignments, resolveDshHome, discoverWorkspaceCandidates, ensurePresetInstalled, pluginVersion, exportConfigPayload, importConfigContent, listPresets, resolveWorkspacePathByPresetId, deleteWorkspacePreset } from './api-core.mjs'
43
+ import { getState, refreshState, isStateCacheStale, serializeState, installAssignments, resolveDshHome, discoverWorkspaceCandidates, ensurePresetInstalled, pluginVersion, exportConfigPayload, importConfigContent, listConfigBackups, restoreConfigBackup, rotateConfigBackups, slimStatsOf, listPresets, resolveWorkspacePathByPresetId, deleteWorkspacePreset } from './api-core.mjs'
43
44
  import { collectModelRefs } from '../../tools/lib/health.mjs'
45
+ import { diffAssignments, loadConfig } from '../../tools/lib/config.mjs'
44
46
  import { detectSkills } from '../../tools/lib/skills.mjs'
45
- import { collectRuntimeFacts } from './runtime-inventory.mjs'
47
+ import { collectRuntimeFacts, detectHmrOf } from './runtime-inventory.mjs'
48
+ import { collectDelegationStats } from './stats-core.mjs'
46
49
  import { getVersionInfo } from '../../tools/lib/version.mjs'
47
50
  import { runUpgrade } from '../../tools/lib/upgrade.mjs'
48
51
 
@@ -216,9 +219,17 @@ function persistenceAvailableOf(ctx) {
216
219
  }
217
220
  }
218
221
 
219
- /** 面板 state 响应统一挂 persistenceAvailable(state / rescan 同一形状)。 */
222
+ /** 面板 state 响应统一挂 persistenceAvailable(state / rescan 同一形状)。
223
+ * slim:工具面瘦身统计(total = 可用工具全集名数、main = 已保存配置下主 agent
224
+ * 有效 allow 计数),按磁盘已保存配置算——state 是序列化视图(blocks 普通对象 /
225
+ * inventory 数组),slimStatsOf 两态通吃;无配置文件时 existing 为 null,按空
226
+ * 目标字段算(main = restrict.mjs 基准白名单长度)。 */
220
227
  function stateResponse(ctx, state) {
221
- return { ...state, persistenceAvailable: persistenceAvailableOf(ctx) }
228
+ return {
229
+ ...state,
230
+ persistenceAvailable: persistenceAvailableOf(ctx),
231
+ slim: slimStatsOf(state, state.existing ?? {}),
232
+ }
222
233
  }
223
234
 
224
235
  // ── state 读取共享 helper(「采集运行时事实 → 重刷 → 读缓存 →〔按需〕序列化」)──
@@ -322,6 +333,9 @@ async function writeWorkspacePreset(ctx, res, { workspacePath, assignments, with
322
333
  }
323
334
  }
324
335
  try {
336
+ // 写盘前自动轮换备份(当前配置推入 bak-1;首次保存无「当前」则不备份),
337
+ // 与 apply / import / 回滚同一口径——工作区预设写盘同样是配置写路径。
338
+ rotateConfigBackups()
325
339
  const result = installAssignments(s, assignments, {
326
340
  dryRun: false,
327
341
  saveConfigOnWrite: true,
@@ -436,6 +450,23 @@ export async function apply(ctx) {
436
450
  return
437
451
  }
438
452
 
453
+ if (route === 'stats' && req.method === 'GET') {
454
+ // 委派效果统计(GET stats?days=30&project=<slug>):宿主
455
+ // sessionPersistence 服务逐会话冷读抽摘要聚合(A 路,见
456
+ // stats-core.mjs 文件头——服务面、slug 口径与失败判据的实测证据都在
457
+ // 那里)。服务经 ctx.reflect 软探测(绝不进 inject 数组:硬注入会让
458
+ // 缺服务的老宿主在挂载时就炸),拿不到回 { ok:false,
459
+ // reason:'persistence-unavailable' },客户端整卡不渲染。project
460
+ // 缺省 = 当前面板项目(cwd → projectKey 换算),days 缺省 30 上限
461
+ // 365;project 非法在核心层抛 422(外层 catch 统一回 { error })。
462
+ // 路由注册在既有 /api/paoding 前缀下,上方信任围栏自动覆盖本路由。
463
+ json(res, 200, await collectDelegationStats(ctx, {
464
+ project: url.searchParams.get('project') ?? undefined,
465
+ days: url.searchParams.get('days') ?? undefined,
466
+ }))
467
+ return
468
+ }
469
+
439
470
  if (route === 'models' && req.method === 'GET') {
440
471
  // 模型目录:DSH 设置页模型选择器同款目录(deepseek-official 三模型、
441
472
  // pi-ai 各路由等),JSON-safe 纯对象。与 state 缓存无关,每次实调——
@@ -608,6 +639,14 @@ export async function apply(ctx) {
608
639
  }
609
640
  }
610
641
  try {
642
+ // diff 旧值先读:apply 的落盘会覆盖磁盘配置,改前这一读才拿得到
643
+ // 磁盘真实内容(preview 不落盘,先后等价,统一走改前口径)。生成
644
+ // 管线随后对同一文件做同样的 loadConfig 护栏,解析失败同样 422。
645
+ const diskAssignments = loadConfig(s.configFile, s.yamlMod)
646
+ // 写盘前自动轮换备份(当前配置推入 bak-1;首次保存无「当前」则不
647
+ // 备份),与 import / 工作区预设写盘 / 回滚同一口径。预览不落盘,
648
+ // 不轮换。
649
+ if (!dryRun) rotateConfigBackups()
611
650
  const result = installAssignments(s, body.assignments, {
612
651
  dryRun,
613
652
  saveConfigOnWrite: !dryRun,
@@ -639,6 +678,12 @@ export async function apply(ctx) {
639
678
  // 结构化体检结果(tools/lib/health.mjs):未挂载工具 / 零工具角色 /
640
679
  // 未知模型 / 预设漂移 / 生成期告警。纯增量字段,客户端批次负责渲染。
641
680
  warnings: result.warnings,
681
+ // 本次提交相对磁盘现配置的差分(tools/lib/config.mjs 的纯结构化
682
+ // 结果,零文案,客户端 i18n 渲染)与工具面瘦身统计(total = 可用
683
+ // 工具全集,main = 本次提交下主 agent 有效 allow 计数)。均为纯
684
+ // 增量字段。
685
+ diff: diffAssignments(diskAssignments, body.assignments),
686
+ slim: slimStatsOf(s, body.assignments),
642
687
  })
643
688
  } catch (err) {
644
689
  // 422 保持既有 { error } 形状;体检结果(如零工具角色的可读说明)
@@ -688,13 +733,50 @@ export async function apply(ctx) {
688
733
  return
689
734
  }
690
735
 
736
+ if (route === 'config/backups' && req.method === 'GET') {
737
+ // 备份清单(新→旧):{ file, mtimeMs, size }[],无备份返回空数组。
738
+ // 纯读不写;file 恒为 basename,客户端回传它即够(还原路由只认配置
739
+ // 目录内的备份名)。
740
+ json(res, 200, { ok: true, backups: listConfigBackups() })
741
+ return
742
+ }
743
+
744
+ if (route === 'config/restore' && req.method === 'POST') {
745
+ // 回滚配置:{ file } 须精确匹配「配置文件名.bak-1/2/3」。与 apply /
746
+ // import 共用同一把 in-flight 闸(写盘路径防并发交错)。核心函数先
747
+ // loadConfig 验证备份可解析(坏备份 422 / 备份不存在 404,statusCode
748
+ // 由外层 catch 回 { error } 信封,不写盘),再把「当前配置」按同款
749
+ // 轮换推入备份链后拷贝备份为当前;mtime 变化令 state 缓存按既有对账
750
+ // 自动失效,无需手动清理。
751
+ const body = await readBody(req)
752
+ if (applyInFlight) {
753
+ json(res, 409, { error: '已有一次应用(apply)正在进行,请稍后重试' })
754
+ return
755
+ }
756
+ applyInFlight = true
757
+ try {
758
+ json(res, 200, await restoreConfigBackup(typeof body.file === 'string' ? body.file : ''))
759
+ } finally {
760
+ applyInFlight = false
761
+ }
762
+ return
763
+ }
764
+
691
765
  if (route === 'version' && req.method === 'GET') {
692
766
  // 版本提示:npm registry 优先,npmmirror / GitHub 依次兜底(version.mjs,
693
767
  // 带 1 小时内存缓存,命中不发外网请求)。绝不 5xx——检测失败也回
694
768
  // 200 + error 字段,面板据此静默降级(不打扰使用)。timeoutMs 3000:
695
769
  // 检测只是锦上添花,不值得让面板等更久。layout 告知客户端挂载形态
696
770
  //(升级确认框按此分流警示文案),见 MOUNT_LAYOUT 注释。
697
- json(res, 200, { ...(await getVersionInfo({ timeoutMs: 3000 })), layout: MOUNT_LAYOUT })
771
+ // hotReloadable:宿主插件清单里有启用的 dsh-hmr 插件行(0.2.1+ web/
772
+ // desktop 默认)→ 升级后刷新页面即生效,确认框 / 成功 note 换
773
+ // 「刷新即生效」文案;探测失败 / 非 web 部署拿不到运行时 → false,
774
+ // 回落「需重启 DSH」文案。永不抛出(detectHmrOf 自捕)。
775
+ json(res, 200, {
776
+ ...(await getVersionInfo({ timeoutMs: 3000 })),
777
+ layout: MOUNT_LAYOUT,
778
+ hotReloadable: detectHmrOf(ctx),
779
+ })
698
780
  return
699
781
  }
700
782
 
@@ -364,6 +364,37 @@
364
364
  padding: 2px 0;
365
365
  }
366
366
 
367
+ /* 工具分组筛选框:分组超过 8 项时,网格上方一行小输入框。oninput 直改
368
+ 网格单项显隐(纯 DOM,不走重渲染——重渲染会吃掉输入框焦点),观感取
369
+ omd-nameInput 同族(边框 / 圆角 / 字号 token 一致),尺寸更紧凑。 */
370
+ .omd-toolFilterWrap {
371
+ display: flex;
372
+ flex-direction: column;
373
+ gap: 2px;
374
+ padding: 2px 0;
375
+ }
376
+
377
+ .omd-toolFilter {
378
+ width: 100%;
379
+ box-sizing: border-box;
380
+ padding: 5px 8px;
381
+ border: 1px solid var(--dsw-alias-border-l2);
382
+ border-radius: 8px;
383
+ background: var(--dsw-alias-bg-layer-2);
384
+ color: var(--dsw-alias-label-primary);
385
+ font: inherit;
386
+ font-size: 12px;
387
+ line-height: 18px;
388
+ transition: border-color 0.16s ease;
389
+ }
390
+
391
+ .omd-toolFilter:focus-visible {
392
+ outline: 2px solid var(--dsw-alias-state-business-primary);
393
+ outline-offset: 1px;
394
+ }
395
+
396
+ .omd-toolFilter::placeholder { color: var(--dsw-alias-label-tertiary); }
397
+
367
398
  .omd-toolItem {
368
399
  display: flex;
369
400
  align-items: center;
@@ -603,6 +634,64 @@
603
634
  overflow-wrap: anywhere;
604
635
  }
605
636
 
637
+ /* 预览「本次变更」区块:preview.diff 的结构化差分行(净新增绿 / 净移除红 /
638
+ 变更明细中性),卡片观感同 omd-pre(描边浅底圆角),行多随预览区滚动。 */
639
+ .omd-diffBlock {
640
+ display: flex;
641
+ flex-direction: column;
642
+ gap: 4px;
643
+ margin: 6px 0 0;
644
+ padding: 8px 12px;
645
+ box-sizing: border-box;
646
+ border: 1px solid var(--dsw-alias-border-l1);
647
+ border-radius: 12px;
648
+ background: var(--dsw-alias-bg-layer-2);
649
+ }
650
+
651
+ .omd-diffHeading {
652
+ font-size: 11px;
653
+ line-height: 16px;
654
+ font-weight: 600;
655
+ letter-spacing: 0.05em;
656
+ text-transform: uppercase;
657
+ color: var(--dsw-alias-label-secondary);
658
+ }
659
+
660
+ .omd-diffLines {
661
+ display: flex;
662
+ flex-direction: column;
663
+ gap: 2px;
664
+ }
665
+
666
+ .omd-diffLine {
667
+ font-size: 12px;
668
+ line-height: 18px;
669
+ color: var(--dsw-alias-label-secondary);
670
+ overflow-wrap: anywhere;
671
+ }
672
+
673
+ .omd-diffLineAdd { color: var(--dsw-alias-state-success-primary); }
674
+
675
+ .omd-diffLineDel { color: var(--dsw-alias-state-error-primary); }
676
+
677
+ .omd-diffNone {
678
+ font-size: 12px;
679
+ line-height: 18px;
680
+ color: var(--dsw-alias-label-tertiary);
681
+ }
682
+
683
+ /* 预览里的瘦身统计一行(按本次提交配置算):与页头 pd-slimLine 同语义,
684
+ 尾随「按当前未保存配置」小注再弱一档。 */
685
+ .omd-slimLine {
686
+ margin: 6px 0 0;
687
+ font-size: 11px;
688
+ line-height: 16px;
689
+ color: var(--dsw-alias-label-secondary);
690
+ font-variant-numeric: tabular-nums;
691
+ }
692
+
693
+ .omd-slimNote { color: var(--dsw-alias-label-tertiary); }
694
+
606
695
  .omd-pre {
607
696
  box-sizing: border-box;
608
697
  margin: 0;
@@ -1286,6 +1375,85 @@ html[data-dsh-paoding-active]:not([data-dsh-mnemon-active]):not([data-dsh-taskbo
1286
1375
  color: var(--dsw-alias-label-tertiary);
1287
1376
  }
1288
1377
 
1378
+ /* 工具面瘦身统计一行:副标题之下(版本号 / 更新卡同区),按已保存配置算
1379
+ 的一行弱化小字;数字用等宽数位避免加载 / 应用后跳动。 */
1380
+ .pd-slimLine {
1381
+ margin: 2px 0 0;
1382
+ font-size: 11px;
1383
+ line-height: 16px;
1384
+ color: var(--dsw-alias-label-tertiary);
1385
+ font-variant-numeric: tabular-nums;
1386
+ }
1387
+
1388
+ /* 委派效果卡:页头瘦身统计行之下的只读紧凑表(GET stats 懒加载,服务缺失 /
1389
+ 拉取失败整卡不渲染)。行内五列网格——角色名占余宽可截断,数字列定宽右对
1390
+ 齐,最近使用列偏右;数字等宽数位防跳动,失败 >0 标红提醒。 */
1391
+ .pd-delegCard {
1392
+ margin: 10px 0 0;
1393
+ padding: 10px 12px 8px;
1394
+ border: 1px solid var(--dsw-alias-border-l1);
1395
+ border-radius: 12px;
1396
+ }
1397
+
1398
+ .pd-delegTitle {
1399
+ font-size: 12px;
1400
+ line-height: 18px;
1401
+ font-weight: 600;
1402
+ color: var(--dsw-alias-label-primary);
1403
+ }
1404
+
1405
+ .pd-delegTable {
1406
+ margin-top: 6px;
1407
+ font-size: 11px;
1408
+ line-height: 20px;
1409
+ color: var(--dsw-alias-label-secondary);
1410
+ font-variant-numeric: tabular-nums;
1411
+ }
1412
+
1413
+ .pd-delegRow {
1414
+ display: grid;
1415
+ grid-template-columns: minmax(0, 1fr) 44px 56px 44px 88px;
1416
+ gap: 0 10px;
1417
+ align-items: baseline;
1418
+ }
1419
+
1420
+ .pd-delegHeaderRow {
1421
+ color: var(--dsw-alias-label-tertiary);
1422
+ }
1423
+
1424
+ .pd-delegRole {
1425
+ overflow: hidden;
1426
+ white-space: nowrap;
1427
+ text-overflow: ellipsis;
1428
+ }
1429
+
1430
+ .pd-delegNum {
1431
+ text-align: right;
1432
+ }
1433
+
1434
+ .pd-delegFail {
1435
+ color: var(--dsw-alias-state-error-primary);
1436
+ }
1437
+
1438
+ .pd-delegWhen {
1439
+ text-align: right;
1440
+ color: var(--dsw-alias-label-tertiary);
1441
+ }
1442
+
1443
+ .pd-delegMore {
1444
+ margin-top: 2px;
1445
+ font-size: 11px;
1446
+ line-height: 16px;
1447
+ color: var(--dsw-alias-label-tertiary);
1448
+ }
1449
+
1450
+ .pd-delegEmpty {
1451
+ margin-top: 4px;
1452
+ font-size: 11px;
1453
+ line-height: 16px;
1454
+ color: var(--dsw-alias-label-tertiary);
1455
+ }
1456
+
1289
1457
  .pd-closeBtn {
1290
1458
  flex: none;
1291
1459
  display: inline-flex;
@@ -1457,6 +1625,82 @@ html[data-dsh-paoding-active]:not([data-dsh-mnemon-active]):not([data-dsh-taskbo
1457
1625
  cursor: not-allowed;
1458
1626
  }
1459
1627
 
1628
+ /* ── 恢复备份弹层:备份清单行(bak 徽标 + 本地时间 + 大小,整行可点)──── */
1629
+ /* 列表高度封顶出滚:备份链随每次 apply / restore 前滚一格,再长不顶破视口。 */
1630
+ .pd-backupList {
1631
+ display: flex;
1632
+ flex-direction: column;
1633
+ gap: 6px;
1634
+ max-height: 320px;
1635
+ overflow-y: auto;
1636
+ padding: 2px;
1637
+ }
1638
+
1639
+ .pd-backupItem {
1640
+ display: flex;
1641
+ align-items: center;
1642
+ gap: 10px;
1643
+ width: 100%;
1644
+ padding: 8px 10px;
1645
+ box-sizing: border-box;
1646
+ border: 1px solid var(--dsw-alias-border-l2);
1647
+ border-radius: 10px;
1648
+ background: var(--dsw-alias-bg-layer-2);
1649
+ color: var(--dsw-alias-label-primary);
1650
+ font: inherit;
1651
+ font-size: 12px;
1652
+ line-height: 18px;
1653
+ text-align: left;
1654
+ cursor: pointer;
1655
+ transition: border-color 0.12s ease, background 0.12s ease;
1656
+ }
1657
+
1658
+ .pd-backupItem:hover:not(:disabled) {
1659
+ border-color: var(--dsw-alias-state-business-primary);
1660
+ background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 6%, transparent);
1661
+ }
1662
+
1663
+ .pd-backupItem:focus-visible {
1664
+ outline: 2px solid var(--dsw-alias-state-business-primary);
1665
+ outline-offset: 1px;
1666
+ }
1667
+
1668
+ .pd-backupItem:disabled { opacity: 0.55; cursor: default; }
1669
+
1670
+ .pd-backupTag {
1671
+ flex: none;
1672
+ padding: 1px 8px;
1673
+ border-radius: 999px;
1674
+ background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 10%, transparent);
1675
+ color: var(--dsw-alias-state-business-primary);
1676
+ font-size: 11px;
1677
+ line-height: 16px;
1678
+ font-weight: 500;
1679
+ font-variant-numeric: tabular-nums;
1680
+ }
1681
+
1682
+ .pd-backupTime {
1683
+ flex: 1;
1684
+ min-width: 0;
1685
+ overflow-wrap: anywhere;
1686
+ text-align: left;
1687
+ }
1688
+
1689
+ .pd-backupSize {
1690
+ flex: none;
1691
+ color: var(--dsw-alias-label-tertiary);
1692
+ font-variant-numeric: tabular-nums;
1693
+ }
1694
+
1695
+ /* 加载中 / 暂无备份:一行居中弱化文案(omd-empty 的弹窗版)。 */
1696
+ .pd-backupEmpty {
1697
+ padding: 12px 0 8px;
1698
+ font-size: 12px;
1699
+ line-height: 18px;
1700
+ color: var(--dsw-alias-label-tertiary);
1701
+ text-align: center;
1702
+ }
1703
+
1460
1704
  /* ── Toast 反馈(fixed 悬浮于操作坞上方)──────────────────────────────── */
1461
1705
  .pd-toastHost {
1462
1706
  position: fixed;