dsh-pet 0.1.4 → 0.1.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.
Files changed (111) hide show
  1. package/README.en.md +10 -10
  2. package/README.md +11 -11
  3. package/assets/config.jsonc +88 -0
  4. package/assets/thumb//344/270/211/347/220/203/346/212/233/346/216/245.webm +0 -0
  5. package/assets/thumb//344/270/213/344/272/224/345/255/220/346/243/213.webm +0 -0
  6. package/assets/thumb//344/270/234/345/274/240/350/245/277/346/234/233.webm +0 -0
  7. package/assets/thumb//344/270/255/347/247/213/350/265/217/346/234/210/345/220/203/346/234/210/351/245/274.webm +0 -0
  8. package/assets/thumb//344/274/230/351/233/205/345/245/263/344/273/206/350/210/236.webm +0 -0
  9. package/assets/thumb//345/201/267/345/220/203/351/233/266/351/243/237/350/242/253/346/212/223/344/275/217.webm +0 -0
  10. package/assets/thumb//345/206/231/344/273/243/347/240/201.webm +0 -0
  11. package/assets/thumb//345/206/231/347/246/217/345/255/227.webm +0 -0
  12. package/assets/thumb//345/207/255/347/251/272/347/224/237/350/212/261.webm +0 -0
  13. package/assets/thumb//345/212/250/347/211/251/347/216/257/347/273/225.webm +0 -0
  14. package/assets/thumb//345/216/237/345/234/260/344/270/223/345/277/203/347/216/251/351/255/224/346/226/271.webm +0 -0
  15. package/assets/thumb//345/216/237/345/234/260/345/260/217/346/206/251/346/262/211/347/234/240.webm +0 -0
  16. package/assets/thumb//345/216/237/345/234/260/345/267/246/350/275/254/345/245/224/350/267/221.webm +0 -0
  17. package/assets/thumb//345/216/237/345/234/260/346/225/262/345/207/273/346/241/214/351/235/242/344/272/222/345/212/250.webm +0 -0
  18. package/assets/thumb//345/216/237/345/234/260/346/274/202/346/265/256/350/270/217/346/255/245.webm +0 -0
  19. package/assets/thumb//345/216/237/345/234/260/350/267/263/350/267/203/346/212/223/347/242/216/345/244/264/351/241/266/347/211/251/345/223/201.webm +0 -0
  20. package/assets/thumb//345/216/237/345/234/260/350/271/262/344/270/213/347/216/251/347/216/251/345/205/267/346/261/275/350/275/246.webm +0 -0
  21. package/assets/thumb//345/216/237/345/234/260/351/207/215/345/212/233/344/270/213/350/271/262/345/216/213/347/274/251.webm +0 -0
  22. package/assets/thumb//345/217/230/351/270/275/345/255/220.webm +0 -0
  23. package/assets/thumb//345/217/257/347/210/261/345/256/205/350/210/236.webm +0 -0
  24. package/assets/thumb//345/220/203Token.webm +0 -0
  25. package/assets/thumb//345/220/203/345/206/260/346/267/207/346/267/213/350/236/215/345/214/226.webm +0 -0
  26. package/assets/thumb//345/220/203/345/215/210/351/244/220.webm +0 -0
  27. package/assets/thumb//345/220/203/345/244/247/351/227/270/350/237/271.webm +0 -0
  28. package/assets/thumb//345/220/203/345/271/264/347/263/225.webm +0 -0
  29. package/assets/thumb//345/220/203/346/227/251/351/244/220.webm +0 -0
  30. package/assets/thumb//345/220/203/346/231/232/351/244/220.webm +0 -0
  31. package/assets/thumb//345/220/203/346/261/244/345/234/206.webm +0 -0
  32. package/assets/thumb//345/220/203/347/231/275/351/245/255.webm +0 -0
  33. package/assets/thumb//345/220/203/347/262/275/345/255/220.webm +0 -0
  34. package/assets/thumb//345/220/203/347/263/226/350/221/253/350/212/246.webm +0 -0
  35. package/assets/thumb//345/220/203/350/205/212/345/205/253/347/262/245.webm +0 -0
  36. package/assets/thumb//345/220/203/350/245/277/347/223/234.webm +0 -0
  37. package/assets/thumb//345/220/203/351/207/215/351/230/263/347/263/225.webm +0 -0
  38. package/assets/thumb//345/220/203/351/225/277/345/257/277/351/235/242.webm +0 -0
  39. package/assets/thumb//345/220/203/351/235/222/345/233/242.webm +0 -0
  40. package/assets/thumb//345/220/203/351/245/272/345/255/220.webm +0 -0
  41. package/assets/thumb//345/220/271/346/260/224/347/220/203.webm +0 -0
  42. package/assets/thumb//345/220/271/347/254/233/345/255/220.webm +0 -0
  43. package/assets/thumb//345/223/210/346/254/240/350/277/236/345/244/251.webm +0 -0
  44. package/assets/thumb//345/240/206/351/233/252/344/272/272.webm +0 -0
  45. package/assets/thumb//345/244/247/345/217/243/345/220/203/351/233/266/351/243/237.webm +0 -0
  46. package/assets/thumb//345/245/263/344/273/206/345/261/210/350/206/235/347/244/274/344/273/252.webm +0 -0
  47. package/assets/thumb//345/260/217/345/271/205/345/272/246/345/216/237/345/234/260360/345/272/246/346/227/213/350/275/254/345/261/225/347/244/272.webm +0 -0
  48. package/assets/thumb//345/260/217/346/217/220/347/220/264/346/274/224/345/245/217.webm +0 -0
  49. package/assets/thumb//345/276/205/346/234/272/345/221/274/345/220/270/344/274/221/351/227/262.webm +0 -0
  50. package/assets/thumb//346/202/240/351/227/262/345/223/274/346/255/214.webm +0 -0
  51. package/assets/thumb//346/211/221/345/205/213/351/255/224/346/234/257.webm +0 -0
  52. package/assets/thumb//346/211/223/347/236/214/347/235/241/350/242/253/346/203/212/351/206/222.webm +0 -0
  53. package/assets/thumb//346/212/275/351/231/200/350/236/272.webm +0 -0
  54. package/assets/thumb//346/213/206/347/244/274/347/211/251.webm +0 -0
  55. package/assets/thumb//346/217/222/350/214/261/350/220/270/350/265/217/350/217/212.webm +0 -0
  56. package/assets/thumb//346/221/207/346/211/207/347/272/263/345/207/211.webm +0 -0
  57. package/assets/thumb//346/222/270/347/214/253.webm +0 -0
  58. package/assets/thumb//346/224/266/347/272/242/345/214/205.webm +0 -0
  59. package/assets/thumb//346/224/276/345/255/224/346/230/216/347/201/257.webm +0 -0
  60. package/assets/thumb//346/224/276/346/262/263/347/201/257.webm +0 -0
  61. package/assets/thumb//346/224/276/347/203/237/350/212/261.webm +0 -0
  62. package/assets/thumb//346/224/276/351/243/216/347/255/235.webm +0 -0
  63. package/assets/thumb//346/225/264/344/275/223/346/215/242/350/243/205/350/257/225/350/211/262.webm +0 -0
  64. package/assets/thumb//346/230/257/345/225/212/357/274/214/345/220/203/344/273/200/344/271/210.webm +0 -0
  65. package/assets/thumb//346/231/250/351/227/264/345/210/267/347/211/231.webm +0 -0
  66. package/assets/thumb//346/266/256/347/201/253/351/224/205.webm +0 -0
  67. package/assets/thumb//346/267/261/345/272/246/346/200/235/350/200/203/347/242/216/347/242/216/345/277/265.webm +0 -0
  68. package/assets/thumb//347/202/271/345/207/273/345/233/236/345/272/224-/345/202/262/345/250/207/347/224/237/346/260/224.webm +0 -0
  69. package/assets/thumb//347/202/271/345/207/273/345/233/236/345/272/224-/345/205/203/346/260/224/346/214/245/346/211/213.webm +0 -0
  70. package/assets/thumb//347/202/271/345/207/273/345/233/236/345/272/224-/345/256/263/347/276/236/346/203/212/350/256/266.webm +0 -0
  71. package/assets/thumb//347/202/271/345/207/273/345/233/236/345/272/224-/345/274/200/345/277/203/350/267/203/345/212/250.webm +0 -0
  72. package/assets/thumb//347/202/271/345/207/273/345/233/236/345/272/224-/346/214/240/347/227/222/345/222/257/345/222/257/347/254/221.webm +0 -0
  73. package/assets/thumb//347/205/247/351/225/234/345/255/220.webm +0 -0
  74. package/assets/thumb//347/216/251/346/260/264/346/236/252.webm +0 -0
  75. package/assets/thumb//347/216/251/346/270/270/346/210/217/346/260/224/346/200/245/350/264/245/345/235/217.webm +0 -0
  76. package/assets/thumb//347/224/250/351/262/270/351/261/274/345/260/276/345/267/264/346/213/215/346/211/223/345/234/260/351/235/242.webm +0 -0
  77. package/assets/thumb//347/251/277/351/222/210/344/271/236/345/267/247.webm +0 -0
  78. package/assets/thumb//350/210/236/347/213/256/345/244/264.webm +0 -0
  79. package/assets/thumb//350/215/241/347/247/213/345/215/203.webm +0 -0
  80. package/assets/thumb//350/220/214/345/214/226/345/260/217/345/271/275/347/201/265.webm +0 -0
  81. package/assets/thumb//350/223/235/351/262/270/347/216/260/344/270/226.webm +0 -0
  82. package/assets/thumb//350/235/264/350/235/266/350/234/234/350/234/202/347/216/257/347/273/225/345/244/264/351/241/266/345/274/200/350/212/261.webm +0 -0
  83. package/assets/thumb//350/236/203/350/237/271/350/265/260/350/267/257.webm +0 -0
  84. package/assets/thumb//350/242/253/345/220/223/344/270/200/350/267/263.webm +0 -0
  85. package/assets/thumb//350/242/253/350/220/275/345/217/266/346/267/271/346/262/241.webm +0 -0
  86. package/assets/thumb//350/242/253/351/274/240/346/240/207/346/213/226/346/213/275/346/202/254/347/251/272/345/217/215/351/246/210.webm +0 -0
  87. package/assets/thumb//350/243/205/347/202/271/345/234/243/350/257/236/346/240/221.webm +0 -0
  88. package/assets/thumb//350/256/250/347/263/226/345/215/227/347/223/234/347/201/257.webm +0 -0
  89. package/assets/thumb//350/266/205/345/244/247/344/274/270/346/207/222/350/205/260.webm +0 -0
  90. package/assets/thumb//350/270/242/346/257/275/345/255/220.webm +0 -0
  91. package/assets/thumb//350/275/273/345/277/253/346/221/207/346/221/206/350/210/236.webm +0 -0
  92. package/assets/thumb//350/275/273/345/277/253/350/256/260/345/275/225.webm +0 -0
  93. package/assets/thumb//351/252/221/346/234/250/351/251/254.webm +0 -0
  94. package/assets/thumb//351/262/270/351/261/274/345/220/220/346/263/241/346/263/241/347/211/271/346/225/210.webm +0 -0
  95. package/lib/client.js +1105 -693
  96. package/lib/index.js +172 -125
  97. package/package.json +20 -6
  98. package/src/client/app.ts +488 -0
  99. package/src/client/config.ts +122 -0
  100. package/src/client/constants.ts +9 -0
  101. package/src/client/index.ts +15 -0
  102. package/src/client/pickers.ts +32 -0
  103. package/src/client/settings.ts +325 -0
  104. package/src/client/types.ts +37 -0
  105. package/src/env.d.ts +6 -0
  106. package/src/host/index.ts +204 -0
  107. package/assets/thumb//345/260/217/345/271/205/345/272/246/345/216/237/345/234/260 360 /345/272/246/346/227/213/350/275/254/345/261/225/347/244/272.webm +0 -0
  108. package/assets/thumb//347/202/271/345/207/273/345/233/236/345/272/224 - /345/202/262/345/250/207/347/224/237/346/260/224/357/274/210/344/276/247/350/272/253/345/261/225/347/244/272/357/274/211.webm +0 -0
  109. package/assets/thumb//347/202/271/345/207/273/345/233/236/345/272/224 - /345/256/263/347/276/236/346/203/212/350/256/266.webm +0 -0
  110. package/assets/thumb//347/202/271/345/207/273/345/233/236/345/272/224 - /345/274/200/345/277/203/350/267/203/345/212/250.webm +0 -0
  111. package/assets/thumb//350/242/253/345/220/223/344/270/200/350/267/263/357/274/210/347/202/270/346/257/233/357/274/211.webm +0 -0
@@ -0,0 +1,325 @@
1
+ /**
2
+ * 桌宠配置管理设置页(settings.section 插槽,id: pet-config)
3
+ *
4
+ * - 多开:管理多个桌宠,每个宠物独立 id/size/位置(corner + marginX/Y)
5
+ * - 数据流:设置页持有「合并后的完整宠物列表」→ 保存时全量 PUT /pet/config
6
+ * (用户覆盖层 = 完整列表,加载时全量替换默认,天然支持增删)
7
+ * - 即时生效:保存/恢复默认后调用 petBridge.sync 通知容器重新渲染,无需刷新页面
8
+ *
9
+ * 样式对齐官方设置页:max-width 720px、全走 --dsw-alias-* 语义 token(主题跟随)。
10
+ */
11
+ import type { Corner } from './types';
12
+
13
+ /** 单个宠物配置 */
14
+ export interface PetConfigUI {
15
+ id: string;
16
+ size: number;
17
+ corner: Corner;
18
+ marginX: number;
19
+ marginY: number;
20
+ }
21
+
22
+ /** 兜底默认(与 config.jsonc 的 pets[0](main)一致) */
23
+ export const DEFAULT_PETS: PetConfigUI[] = [
24
+ { id: 'main', size: 462, corner: 'top-right', marginX: 24, marginY: 100 },
25
+ ];
26
+
27
+ /** 容器与设置页共享的桥(同一 bundle 单例):
28
+ * current=最新完整宠物列表;sync=容器注册的重渲染回调(保存/恢复默认时调用);
29
+ * template=config.jsonc 默认宠物模板(pets[0]),「添加宠物」用它作为默认配置 */
30
+ export const petBridge: {
31
+ current: PetConfigUI[] | null;
32
+ sync: null | ((pets: PetConfigUI[]) => void);
33
+ template: PetConfigUI | null;
34
+ } = { current: null, sync: null, template: null };
35
+
36
+ /** 字典命名空间 */
37
+ export const NS = 'pet.config';
38
+
39
+ export const zh = {
40
+ nav: '桌宠配置',
41
+ intro: '管理多个桌宠:每个宠物可独立设置大小与位置(保存后即时生效)。',
42
+ petsLabel: '宠物列表',
43
+ add: '添加宠物',
44
+ remove: '删除',
45
+ atLeastOne: '至少保留一个宠物。',
46
+ emptyPets: '暂无宠物,点击「添加宠物」创建。',
47
+ sizeLabel: '大小(宽度 px)',
48
+ sizeHint: '高度自动 = 宽度 × 9/16。',
49
+ cornerLabel: '位置',
50
+ 'corner.top-left': '左上角',
51
+ 'corner.top-right': '右上角',
52
+ 'corner.bottom-left': '左下角',
53
+ 'corner.bottom-right': '右下角',
54
+ marginX: '水平偏移',
55
+ marginY: '垂直偏移',
56
+ save: '保存',
57
+ reset: '恢复默认',
58
+ saved: '已保存,桌宠即时生效。',
59
+ loadError: '加载配置失败',
60
+ invalid: '请检查输入:大小需为正数,边距可为任意数字。',
61
+ busy: '保存中…',
62
+ };
63
+
64
+ export const en = {
65
+ nav: 'Pet Config',
66
+ intro: 'Manage multiple pets: each pet has its own size and position (applies instantly after saving).',
67
+ petsLabel: 'Pets',
68
+ add: 'Add pet',
69
+ remove: 'Remove',
70
+ atLeastOne: 'Keep at least one pet.',
71
+ emptyPets: 'No pets yet — click "Add pet" to create one.',
72
+ sizeLabel: 'Size (width px)',
73
+ sizeHint: 'Height is automatic = width × 9/16.',
74
+ cornerLabel: 'Position',
75
+ 'corner.top-left': 'Top-left',
76
+ 'corner.top-right': 'Top-right',
77
+ 'corner.bottom-left': 'Bottom-left',
78
+ 'corner.bottom-right': 'Bottom-right',
79
+ marginX: 'Horizontal offset',
80
+ marginY: 'Vertical offset',
81
+ save: 'Save',
82
+ reset: 'Reset to default',
83
+ saved: 'Saved — the pets updated instantly.',
84
+ loadError: 'Failed to load config',
85
+ invalid: 'Check your input: size must be positive; margins can be any number.',
86
+ busy: 'Saving…',
87
+ };
88
+
89
+ /** 设置页组件工厂:h / hooks / t 由 factory 的 require 与 locale 注入(不顶层 import react) */
90
+ export function makePetConfigSection(rt: {
91
+ h: any;
92
+ useState: <T>(init: T) => [T, (v: T) => void];
93
+ t: (key: string) => string;
94
+ }): (props: { close?: () => void }) => any {
95
+ const { h, useState, t } = rt;
96
+
97
+ const CORNERS: Corner[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
98
+ const cornerLabel = (c: Corner): string => t('corner.' + c);
99
+
100
+ const inputStyle = {
101
+ boxSizing: 'border-box',
102
+ border: '1px solid var(--dsw-alias-border-l2)',
103
+ borderRadius: '8px',
104
+ background: 'var(--dsw-alias-bg-layer-1)',
105
+ color: 'var(--dsw-alias-label-primary)',
106
+ padding: '5px 10px',
107
+ fontSize: '13px',
108
+ minHeight: '28px',
109
+ outline: 'none',
110
+ } as any;
111
+
112
+ const stripJsonc = (src: string): string =>
113
+ src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^\\:])\/\/.*$/gm, '$1').trim();
114
+
115
+ /** 生成一个未占用的宠物 id(pet-2、pet-3…) */
116
+ const nextId = (list: PetConfigUI[]): string => {
117
+ let n = 2;
118
+ for (;; n++) {
119
+ const id = 'pet-' + n;
120
+ if (!list.some((p) => p.id === id)) return id;
121
+ }
122
+ };
123
+
124
+ const normalizePet = (p: any): PetConfigUI => {
125
+ const pos = p && p.position && typeof p.position === 'object' ? p.position : {};
126
+ return {
127
+ id: String(p && p.id ? p.id : 'main'),
128
+ size: Number(p && p.size) > 0 ? Number(p.size) : DEFAULT_PETS[0].size,
129
+ corner: (CORNERS as string[]).includes(pos.corner) ? (pos.corner as Corner) : DEFAULT_PETS[0].corner,
130
+ marginX: Number.isFinite(Number(pos.marginX)) ? Number(pos.marginX) : DEFAULT_PETS[0].marginX,
131
+ marginY: Number.isFinite(Number(pos.marginY)) ? Number(pos.marginY) : DEFAULT_PETS[0].marginY,
132
+ };
133
+ };
134
+
135
+ /** 从 config.jsonc 对象提取默认宠物列表:必须为 pets 数组;缺失/为空回落代码兜底 DEFAULT_PETS */
136
+ const defaultsFrom = (obj: any): PetConfigUI[] => {
137
+ const arr = obj && Array.isArray(obj.pets) ? obj.pets.filter((p: any) => p && p.id).map(normalizePet) : [];
138
+ return arr.length ? arr : DEFAULT_PETS.map((p) => ({ ...p }));
139
+ };
140
+
141
+ return function PetConfigSection() {
142
+ const initPets = petBridge.current ?? DEFAULT_PETS;
143
+ const [pets, setPets] = useState<PetConfigUI[]>(initPets.map((p) => ({ ...p })));
144
+ const [selId, setSelId] = useState<string>(initPets[0]?.id ?? '');
145
+ const [busy, setBusy] = useState(false);
146
+ const [msg, setMsg] = useState<{ kind: 'ok' | 'err' | ''; text: string }>({ kind: '', text: '' });
147
+
148
+ const sel = pets.find((p) => p.id === selId) ?? pets[0] ?? null;
149
+
150
+ const updateSel = (patch: Partial<PetConfigUI>) =>
151
+ setPets((list) => list.map((p) => (p.id === (sel?.id ?? '') ? { ...p, ...patch } : p)));
152
+
153
+ const validated = (): PetConfigUI[] | null => {
154
+ for (const p of pets) {
155
+ if (!Number.isFinite(p.size) || p.size <= 0 || !Number.isFinite(p.marginX) || !Number.isFinite(p.marginY)) {
156
+ setMsg({ kind: 'err', text: t('invalid') });
157
+ return null;
158
+ }
159
+ }
160
+ return pets;
161
+ };
162
+
163
+ const save = async () => {
164
+ const list = validated();
165
+ if (!list) return;
166
+ setBusy(true);
167
+ setMsg({ kind: '', text: '' });
168
+ try {
169
+ const res = await fetch('/pet/config', {
170
+ method: 'PUT',
171
+ headers: { 'content-type': 'application/json' },
172
+ body: JSON.stringify({
173
+ pets: list.map((p) => ({ id: p.id, size: p.size, position: { corner: p.corner, marginX: p.marginX, marginY: p.marginY } })),
174
+ }),
175
+ });
176
+ if (!res.ok) throw new Error('HTTP ' + res.status);
177
+ petBridge.current = list;
178
+ petBridge.sync?.(list);
179
+ setMsg({ kind: 'ok', text: t('saved') });
180
+ } catch {
181
+ setMsg({ kind: 'err', text: t('loadError') });
182
+ } finally {
183
+ setBusy(false);
184
+ }
185
+ };
186
+
187
+ const reset = async () => {
188
+ setBusy(true);
189
+ setMsg({ kind: '', text: '' });
190
+ try {
191
+ await fetch('/pet/config', { method: 'DELETE' });
192
+ const defRes = await fetch('/pet/config.jsonc');
193
+ const obj = JSON.parse(stripJsonc(await defRes.text()));
194
+ const defs = defaultsFrom(obj);
195
+ setPets(defs.map((p) => ({ ...p })));
196
+ setSelId(defs[0]?.id ?? '');
197
+ petBridge.current = defs;
198
+ petBridge.sync?.(defs);
199
+ setMsg({ kind: 'ok', text: t('saved') });
200
+ } catch {
201
+ setMsg({ kind: 'err', text: t('loadError') });
202
+ } finally {
203
+ setBusy(false);
204
+ }
205
+ };
206
+
207
+ const addPet = () => {
208
+ const id = nextId(pets);
209
+ // 默认模板 = config.jsonc 的 pets[0](main)→ 兜底 DEFAULT_PETS[0]
210
+ const tpl = petBridge.template ?? DEFAULT_PETS[0];
211
+ const np: PetConfigUI = { id, size: tpl.size, corner: tpl.corner, marginX: tpl.marginX, marginY: tpl.marginY };
212
+ setPets((list) => [...list, np]);
213
+ setSelId(id);
214
+ };
215
+
216
+ const removeSel = () => {
217
+ if (pets.length <= 1) {
218
+ setMsg({ kind: 'err', text: t('atLeastOne') });
219
+ return;
220
+ }
221
+ const list = pets.filter((p) => p.id !== (sel?.id ?? ''));
222
+ setPets(list);
223
+ setSelId(list[0].id);
224
+ };
225
+
226
+ const field = (key: 'size' | 'marginX' | 'marginY', value: number, setter: (v: number) => void, width: string) =>
227
+ h('input', {
228
+ type: 'number',
229
+ step: key === 'size' ? '10' : '1',
230
+ min: key === 'size' ? '120' : '',
231
+ value: String(value),
232
+ disabled: busy || !sel,
233
+ onChange: (e: any) => setter(Number(e.target.value)),
234
+ style: { width, ...inputStyle },
235
+ });
236
+
237
+ return h(
238
+ 'section',
239
+ {
240
+ style: { maxWidth: '720px', color: 'var(--dsw-alias-label-primary)', display: 'flex', flexDirection: 'column', gap: '6px' },
241
+ children: [
242
+ h('h2', { style: { margin: 0, fontSize: '16px', fontWeight: 500, lineHeight: '24px' }, children: t('nav') }),
243
+ h('p', { style: { margin: 0, fontSize: '14px', color: 'var(--dsw-alias-label-tertiary)', lineHeight: '22px' }, children: t('intro') }),
244
+
245
+ // 宠物列表 + 添加
246
+ h('div', { style: { display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center', marginTop: '4px' }, children: [
247
+ h('span', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' }, children: t('petsLabel') }),
248
+ ...pets.map((p) =>
249
+ h('button', {
250
+ key: p.id,
251
+ type: 'button',
252
+ onClick: () => setSelId(p.id),
253
+ style: {
254
+ border: '1px solid ' + (p.id === (sel?.id ?? '') ? 'var(--dsw-alias-state-business-primary)' : 'var(--dsw-alias-border-l2)'),
255
+ background: p.id === (sel?.id ?? '') ? 'var(--dsw-alias-interactive-bg-active)' : 'transparent',
256
+ color: 'var(--dsw-alias-label-primary)',
257
+ borderRadius: '8px',
258
+ padding: '4px 12px',
259
+ fontSize: '13px',
260
+ cursor: 'pointer',
261
+ },
262
+ children: p.id + ' (' + p.size + 'px)',
263
+ }),
264
+ ),
265
+ h('button', {
266
+ type: 'button', onClick: addPet, disabled: busy,
267
+ style: { border: '1px dashed var(--dsw-alias-border-l2)', background: 'transparent', color: 'var(--dsw-alias-label-secondary)', borderRadius: '8px', padding: '4px 12px', fontSize: '13px', cursor: 'pointer' },
268
+ children: '+ ' + t('add'),
269
+ }),
270
+ ] }),
271
+
272
+ // 选中宠物表单
273
+ sel
274
+ ? h('div', { style: { display: 'flex', gap: '16px', flexWrap: 'wrap', marginTop: '8px', padding: '12px 14px', border: '1px solid var(--dsw-alias-border-l2)', borderRadius: '12px' }, children: [
275
+ h('label', { style: { display: 'flex', flexDirection: 'column', gap: '4px', fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' }, children: [
276
+ t('sizeLabel'),
277
+ field('size', sel.size, (v) => updateSel({ size: v }), '150px'),
278
+ h('span', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)' }, children: t('sizeHint') }),
279
+ ] }),
280
+ h('label', { style: { display: 'flex', flexDirection: 'column', gap: '4px', fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' }, children: [
281
+ t('cornerLabel'),
282
+ h('select', {
283
+ value: sel.corner,
284
+ disabled: busy,
285
+ onChange: (e: any) => updateSel({ corner: e.target.value as Corner }),
286
+ style: { width: '160px', ...inputStyle },
287
+ children: CORNERS.map((c) => h('option', { key: c, value: c, children: cornerLabel(c) })),
288
+ }),
289
+ ] }),
290
+ h('label', { style: { display: 'flex', flexDirection: 'column', gap: '4px', fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' }, children: [
291
+ t('marginX'),
292
+ field('marginX', sel.marginX, (v) => updateSel({ marginX: v }), '120px'),
293
+ ] }),
294
+ h('label', { style: { display: 'flex', flexDirection: 'column', gap: '4px', fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' }, children: [
295
+ t('marginY'),
296
+ field('marginY', sel.marginY, (v) => updateSel({ marginY: v }), '120px'),
297
+ ] }),
298
+ h('button', {
299
+ type: 'button', onClick: removeSel, disabled: busy,
300
+ title: t('remove'),
301
+ style: { alignSelf: 'flex-end', border: '1px solid var(--dsw-alias-state-error-secondary)', background: 'transparent', color: 'var(--dsw-alias-state-error-primary)', borderRadius: '8px', padding: '4px 12px', fontSize: '12px', cursor: 'pointer' },
302
+ children: t('remove'),
303
+ }),
304
+ ] })
305
+ : h('p', { style: { margin: 0, fontSize: '13px', color: 'var(--dsw-alias-label-tertiary)' }, children: t('emptyPets') }),
306
+
307
+ // 操作区
308
+ h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', marginTop: '4px' }, children: [
309
+ h('button', {
310
+ type: 'button', disabled: busy, onClick: save,
311
+ style: { border: '1px solid var(--dsw-alias-button-info-fill)', background: 'var(--dsw-alias-button-info-fill)', color: '#fff', borderRadius: '8px', padding: '4px 14px', fontSize: '12px', cursor: 'pointer', opacity: busy ? 0.5 : 1 },
312
+ children: t('save'),
313
+ }),
314
+ h('button', {
315
+ type: 'button', disabled: busy, onClick: reset,
316
+ style: { border: '1px solid var(--dsw-alias-border-l2)', background: 'transparent', color: 'var(--dsw-alias-label-primary)', borderRadius: '8px', padding: '4px 14px', fontSize: '12px', cursor: 'pointer', opacity: busy ? 0.5 : 1 },
317
+ children: t('reset'),
318
+ }),
319
+ msg.text ? h('span', { style: { fontSize: '12px', color: msg.kind === 'err' ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-state-ok-primary)', marginLeft: '4px' }, children: msg.text }) : null,
320
+ ] }),
321
+ ],
322
+ },
323
+ );
324
+ };
325
+ }
@@ -0,0 +1,37 @@
1
+ // 动作配置的类型模型(唯一事实来源为 config.jsonc 的 animations/animationWeights)。
2
+ /** 移动动作:一个动作名 + 可选覆盖参数(未写字段取 moves.default) */
3
+ export interface MoveSpec {
4
+ name: string;
5
+ params?: Record<string, number>;
6
+ }
7
+ /** 移动池配置 */
8
+ export interface MovesConfig {
9
+ default: Record<string, number>;
10
+ actions: MoveSpec[];
11
+ }
12
+ /** 随机动作分类 */
13
+ export interface Category {
14
+ id: string;
15
+ weight: number;
16
+ /** 带文字、镜像会颠倒:facing=right 时跳过 */
17
+ noMirror?: boolean;
18
+ actions: string[];
19
+ }
20
+ /** 动画权重(百分比) */
21
+ export interface Weights {
22
+ idle: number;
23
+ turn: number;
24
+ move: number;
25
+ }
26
+ /** 运行时动作配置(由 config.jsonc 构建) */
27
+ export interface AnimConfig {
28
+ idle: string[];
29
+ turn: string[];
30
+ drag: string[];
31
+ clicks: string[];
32
+ moves: MovesConfig;
33
+ categories: Category[];
34
+ weights: Weights;
35
+ }
36
+ /** 支持的角落 */
37
+ export type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
package/src/env.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ // DSH peer 依赖类型补充:这些包由 DSH 宿主在运行时提供,本地开发无 node_modules,
2
+ // 这里为它们声明最小类型,便于 tsc 通过(运行时以 DSH 提供的为准)。
3
+ declare module '@deepseek-ai/dsh-home-paths' {
4
+ /** 解析 DSH 主目录($DSH_HOME,默认 ~/.dsh) */
5
+ export function resolveDshHome(): string;
6
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * dsh-pet 宿主半侧(host half)—— 宠物插件的"后端"部分
3
+ *
4
+ * 职责:在 DSH Web 服务器上注册 `/pet/` 前缀路由,把宠物动画 WebM / 配置 JSONC
5
+ * 流式返回给浏览器。源文件(src/host/index.ts)由 tsdown 构建为 lib/index.js。
6
+ *
7
+ * 路由:
8
+ * /pet/thumb/<动画名>.webm → 插件包内 assets/thumb/
9
+ * /pet/full/<动画名>.webm → $DSH_HOME/pet-assets/(原始母版,需手动下载)
10
+ * /pet/config.jsonc → 插件包内 assets/config.jsonc(默认值,只读)
11
+ * /pet/config → 用户覆盖配置(大小/位置,JSON)
12
+ * GET 读取、PUT 保存、DELETE 恢复默认(删除用户层)
13
+ *
14
+ * 安全性:resolveAsset 做"防穿越"校验,保证路径仍在 assets 根目录内。
15
+ *
16
+ * TODO(类型):peer 依赖类型包本地暂不可解析,ctx/config/req/res 暂用 any;
17
+ * 依赖可解析后替换为 DSH 官方类型。
18
+ */
19
+ import { createReadStream, existsSync } from 'node:fs';
20
+ import { readFile, rm, stat, writeFile } from 'node:fs/promises';
21
+ import { join, normalize, resolve, sep } from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+ import { resolveDshHome } from '@deepseek-ai/dsh-home-paths';
24
+
25
+ /** 插件行 id(与 cordis.patch.yml 一致) */
26
+ export const name = 'pet';
27
+ /** 需要注入的服务:webServer(Web 服务器路由注册表) */
28
+ export const inject = ['webServer'];
29
+
30
+ /** 本包目录:宿主构建产物位于 lib/,其上一级即包根。 */
31
+ const PACKAGE_ROOT = resolve(fileURLToPath(new URL('..', import.meta.url)));
32
+
33
+ /** 路由前缀 */
34
+ const ROUTE_PREFIX = '/pet';
35
+
36
+ /** 不同扩展名对应的 Content-Type 映射 */
37
+ const MIME: Record<string, string> = {
38
+ '.webm': 'video/webm',
39
+ '.mp4': 'video/mp4',
40
+ '.png': 'image/png',
41
+ '.json': 'application/json; charset=utf-8',
42
+ '.jsonc': 'application/json; charset=utf-8',
43
+ };
44
+
45
+ /**
46
+ * 规范化并校验请求路径,确保它在 assets 根目录内(防路径穿越)。
47
+ * @returns 规范化后的绝对文件路径;非法(穿越)时返回 undefined
48
+ */
49
+ function resolveAsset(root: string, rel: string): string | undefined {
50
+ if (rel.length === 0) return undefined;
51
+ const candidate = normalize(join(root, rel));
52
+ const rootWithSep = root.endsWith(sep) ? root : root + sep;
53
+ if (candidate !== root && !candidate.startsWith(rootWithSep)) return undefined;
54
+ return candidate;
55
+ }
56
+
57
+ /** 流式返回一个文件(带 Content-Type / 长度 / 缓存头)。 */
58
+ async function sendFile(res: any, file: string, contentType: string): Promise<void> {
59
+ const { size } = await stat(file);
60
+ res.writeHead(200, {
61
+ 'content-type': contentType,
62
+ 'content-length': size,
63
+ 'cache-control': 'public, max-age=3600',
64
+ });
65
+ const stream = createReadStream(file);
66
+ stream.on('error', () => res.destroy());
67
+ stream.pipe(res);
68
+ }
69
+
70
+ /** 支持的角落白名单(与 client 端一致) */
71
+ const CORNERS = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
72
+
73
+ /** 发送 JSON 响应 */
74
+ function sendJson(res: any, status: number, obj: unknown): void {
75
+ const body = JSON.stringify(obj);
76
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(body) });
77
+ res.end(body);
78
+ }
79
+
80
+ /** 收集请求体(文本) */
81
+ function readBody(req: any): Promise<string> {
82
+ return new Promise((resolve2, reject) => {
83
+ const chunks: Buffer[] = [];
84
+ req.on('data', (c: Buffer) => chunks.push(c));
85
+ req.on('end', () => resolve2(Buffer.concat(chunks).toString('utf8')));
86
+ req.on('error', reject);
87
+ });
88
+ }
89
+
90
+ /** 校验并归一化用户配置:只接受 { pets: [{ id, size, position }] } */
91
+ function sanitizeUserConfig(raw: unknown): { pets: unknown[] } | null {
92
+ const o = raw && typeof raw === 'object' ? raw as Record<string, any> : {};
93
+ const arr = Array.isArray(o.pets) ? o.pets : null;
94
+ if (!arr || !arr.length) return null;
95
+ const out: unknown[] = [];
96
+ for (const p of arr) {
97
+ if (!p || typeof p !== 'object') return null;
98
+ const id = String((p as any).id ?? '');
99
+ if (!id || id.length > 64 || /[\\/:\x00-\x1f]/.test(id)) return null;
100
+ const size = Number((p as any).size);
101
+ if (!Number.isFinite(size) || size <= 0) return null;
102
+ const pos = (p as any).position && typeof (p as any).position === 'object' ? (p as any).position : {};
103
+ const corner = String(pos.corner ?? '');
104
+ if (!CORNERS.includes(corner)) return null;
105
+ const marginX = Number(pos.marginX);
106
+ const marginY = Number(pos.marginY);
107
+ if (!Number.isFinite(marginX) || !Number.isFinite(marginY)) return null;
108
+ out.push({ id, size, position: { corner, marginX, marginY } });
109
+ }
110
+ return { pets: out };
111
+ }
112
+
113
+ /** 宿主插件主体:注册 `/pet` 前缀路由。 */
114
+ export function apply(ctx: any, config: any): void {
115
+ const thumbRoot = join(PACKAGE_ROOT, 'assets', 'thumb');
116
+ const fullRoot: string = config.fullRoot ?? join(resolveDshHome(), 'pet-assets');
117
+ // 用户覆盖层:设置页保存的大小/位置(与包内 config.jsonc 默认值合并,覆盖层优先)
118
+ const userConfigPath = join(resolveDshHome(), 'pet-config.json');
119
+
120
+ ctx.effect(() => ctx.webServer.register({
121
+ kind: 'prefix',
122
+ path: ROUTE_PREFIX,
123
+ handler: async (req: any, res: any) => {
124
+ const url = new URL(req.url ?? '/', 'http://localhost');
125
+ const rest = decodeURIComponent(url.pathname.slice(ROUTE_PREFIX.length + 1));
126
+
127
+ // 用户覆盖配置:/pet/config(GET / PUT / DELETE)
128
+ if (rest === 'config') {
129
+ if (req.method === 'GET') {
130
+ try {
131
+ const raw = await readFile(userConfigPath, 'utf8');
132
+ sendJson(res, 200, JSON.parse(raw));
133
+ } catch {
134
+ sendJson(res, 200, {}); // 无覆盖配置 → 空对象,client 回落默认
135
+ }
136
+ return;
137
+ }
138
+ if (req.method === 'PUT') {
139
+ try {
140
+ const body = await readBody(req);
141
+ const parsed = JSON.parse(body);
142
+ const clean = sanitizeUserConfig(parsed);
143
+ if (!clean) {
144
+ sendJson(res, 400, { error: 'invalid pet config: expected { pets:[{id,size,position:{corner,marginX,marginY}}] }' });
145
+ return;
146
+ }
147
+ await writeFile(userConfigPath, JSON.stringify(clean, null, 2), 'utf8');
148
+ sendJson(res, 200, { ok: true });
149
+ } catch {
150
+ sendJson(res, 400, { error: 'invalid JSON body' });
151
+ }
152
+ return;
153
+ }
154
+ if (req.method === 'DELETE') {
155
+ try {
156
+ await rm(userConfigPath, { force: true });
157
+ } catch { /* 不存在也视为成功 */ }
158
+ sendJson(res, 200, { ok: true });
159
+ return;
160
+ }
161
+ sendJson(res, 405, { error: 'method not allowed' });
162
+ return;
163
+ }
164
+
165
+ // 配置文件(JSONC):/pet/config.jsonc → 包内 assets/config.jsonc
166
+ if (rest === 'config.jsonc') {
167
+ const cfgFile = join(PACKAGE_ROOT, 'assets', 'config.jsonc');
168
+ if (!existsSync(cfgFile)) {
169
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
170
+ res.end('dsh-pet: config.jsonc not found');
171
+ return;
172
+ }
173
+ await sendFile(res, cfgFile, MIME['.jsonc'] ?? 'application/octet-stream');
174
+ return;
175
+ }
176
+
177
+ // 第一段是 scope:thumb 或 full
178
+ const [scope, ...nameParts] = rest.split('/');
179
+ if (scope !== 'thumb' && scope !== 'full') {
180
+ res.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' });
181
+ res.end('dsh-pet: expected /pet/{thumb|full}/<file>');
182
+ return;
183
+ }
184
+
185
+ const fileName = nameParts.join('/');
186
+ const root = scope === 'thumb' ? thumbRoot : fullRoot;
187
+ const file = resolveAsset(root, fileName);
188
+ if (file === undefined) {
189
+ res.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' });
190
+ res.end('dsh-pet: invalid path');
191
+ return;
192
+ }
193
+ if (!existsSync(file)) {
194
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
195
+ res.end(scope === 'full'
196
+ ? `dsh-pet: original asset not downloaded yet — run the fetch-assets script to populate ${fullRoot}`
197
+ : 'dsh-pet: asset not found');
198
+ return;
199
+ }
200
+ const ext = file.slice(file.lastIndexOf('.')).toLowerCase();
201
+ await sendFile(res, file, MIME[ext] ?? 'application/octet-stream');
202
+ },
203
+ }), 'dsh-pet: /pet asset route');
204
+ }