dsh-pet 0.1.5 → 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.
- package/assets/config.jsonc +15 -10
- package/lib/client.js +580 -43
- package/lib/index.js +93 -1
- package/package.json +1 -1
- package/src/client/app.ts +76 -40
- package/src/client/config.ts +46 -1
- package/src/client/pickers.ts +3 -1
- package/src/client/settings.ts +325 -0
- package/src/host/index.ts +87 -2
package/lib/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createReadStream, existsSync } from "node:fs";
|
|
2
|
-
import { stat } from "node:fs/promises";
|
|
2
|
+
import { readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import { join, normalize, resolve, sep } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
|
|
@@ -42,15 +42,107 @@ async function sendFile(res, file, contentType) {
|
|
|
42
42
|
stream.on("error", () => res.destroy());
|
|
43
43
|
stream.pipe(res);
|
|
44
44
|
}
|
|
45
|
+
/** 支持的角落白名单(与 client 端一致) */
|
|
46
|
+
const CORNERS = [
|
|
47
|
+
"top-left",
|
|
48
|
+
"top-right",
|
|
49
|
+
"bottom-left",
|
|
50
|
+
"bottom-right"
|
|
51
|
+
];
|
|
52
|
+
/** 发送 JSON 响应 */
|
|
53
|
+
function sendJson(res, status, obj) {
|
|
54
|
+
const body = JSON.stringify(obj);
|
|
55
|
+
res.writeHead(status, {
|
|
56
|
+
"content-type": "application/json; charset=utf-8",
|
|
57
|
+
"content-length": Buffer.byteLength(body)
|
|
58
|
+
});
|
|
59
|
+
res.end(body);
|
|
60
|
+
}
|
|
61
|
+
/** 收集请求体(文本) */
|
|
62
|
+
function readBody(req) {
|
|
63
|
+
return new Promise((resolve2, reject) => {
|
|
64
|
+
const chunks = [];
|
|
65
|
+
req.on("data", (c) => chunks.push(c));
|
|
66
|
+
req.on("end", () => resolve2(Buffer.concat(chunks).toString("utf8")));
|
|
67
|
+
req.on("error", reject);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/** 校验并归一化用户配置:只接受 { pets: [{ id, size, position }] } */
|
|
71
|
+
function sanitizeUserConfig(raw) {
|
|
72
|
+
const o = raw && typeof raw === "object" ? raw : {};
|
|
73
|
+
const arr = Array.isArray(o.pets) ? o.pets : null;
|
|
74
|
+
if (!arr || !arr.length) return null;
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const p of arr) {
|
|
77
|
+
if (!p || typeof p !== "object") return null;
|
|
78
|
+
const id = String(p.id ?? "");
|
|
79
|
+
if (!id || id.length > 64 || /[\\/:\x00-\x1f]/.test(id)) return null;
|
|
80
|
+
const size = Number(p.size);
|
|
81
|
+
if (!Number.isFinite(size) || size <= 0) return null;
|
|
82
|
+
const pos = p.position && typeof p.position === "object" ? p.position : {};
|
|
83
|
+
const corner = String(pos.corner ?? "");
|
|
84
|
+
if (!CORNERS.includes(corner)) return null;
|
|
85
|
+
const marginX = Number(pos.marginX);
|
|
86
|
+
const marginY = Number(pos.marginY);
|
|
87
|
+
if (!Number.isFinite(marginX) || !Number.isFinite(marginY)) return null;
|
|
88
|
+
out.push({
|
|
89
|
+
id,
|
|
90
|
+
size,
|
|
91
|
+
position: {
|
|
92
|
+
corner,
|
|
93
|
+
marginX,
|
|
94
|
+
marginY
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return { pets: out };
|
|
99
|
+
}
|
|
45
100
|
function apply(ctx, config) {
|
|
46
101
|
const thumbRoot = join(PACKAGE_ROOT, "assets", "thumb");
|
|
47
102
|
const fullRoot = config.fullRoot ?? join(resolveDshHome(), "pet-assets");
|
|
103
|
+
const userConfigPath = join(resolveDshHome(), "pet-config.json");
|
|
48
104
|
ctx.effect(() => ctx.webServer.register({
|
|
49
105
|
kind: "prefix",
|
|
50
106
|
path: ROUTE_PREFIX,
|
|
51
107
|
handler: async (req, res) => {
|
|
52
108
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
53
109
|
const rest = decodeURIComponent(url.pathname.slice(ROUTE_PREFIX.length + 1));
|
|
110
|
+
if (rest === "config") {
|
|
111
|
+
if (req.method === "GET") {
|
|
112
|
+
try {
|
|
113
|
+
const raw = await readFile(userConfigPath, "utf8");
|
|
114
|
+
sendJson(res, 200, JSON.parse(raw));
|
|
115
|
+
} catch {
|
|
116
|
+
sendJson(res, 200, {});
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (req.method === "PUT") {
|
|
121
|
+
try {
|
|
122
|
+
const body = await readBody(req);
|
|
123
|
+
const parsed = JSON.parse(body);
|
|
124
|
+
const clean = sanitizeUserConfig(parsed);
|
|
125
|
+
if (!clean) {
|
|
126
|
+
sendJson(res, 400, { error: "invalid pet config: expected { pets:[{id,size,position:{corner,marginX,marginY}}] }" });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
await writeFile(userConfigPath, JSON.stringify(clean, null, 2), "utf8");
|
|
130
|
+
sendJson(res, 200, { ok: true });
|
|
131
|
+
} catch {
|
|
132
|
+
sendJson(res, 400, { error: "invalid JSON body" });
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (req.method === "DELETE") {
|
|
137
|
+
try {
|
|
138
|
+
await rm(userConfigPath, { force: true });
|
|
139
|
+
} catch {}
|
|
140
|
+
sendJson(res, 200, { ok: true });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
54
146
|
if (rest === "config.jsonc") {
|
|
55
147
|
const cfgFile = join(PACKAGE_ROOT, "assets", "config.jsonc");
|
|
56
148
|
if (!existsSync(cfgFile)) {
|
package/package.json
CHANGED
package/src/client/app.ts
CHANGED
|
@@ -3,14 +3,13 @@
|
|
|
3
3
|
// react 通过 factory 的 require 取得(不走 ESM 顶部 import),
|
|
4
4
|
// 以匹配 DSH 的 window.__ModuleLoader__.load({ id, factory }) 加载方式。
|
|
5
5
|
import { pick, randomBetween, pickWeightedCategory } from './pickers';
|
|
6
|
-
import { stripJsonc, buildAnim, buildMinimalAnim } from './config';
|
|
6
|
+
import { stripJsonc, buildAnim, buildMinimalAnim, extractDefaultPets, resolvePets } from './config';
|
|
7
7
|
import { CANVAS_H, FEET_Y, HIT_BOX, DRAG_THRESHOLD } from './constants';
|
|
8
|
+
import { petBridge, makePetConfigSection, NS, zh, en } from './settings';
|
|
9
|
+
import type { PetConfigUI } from './settings';
|
|
8
10
|
import type { AnimConfig, Corner } from './types';
|
|
9
11
|
import type * as ReactNS from 'react';
|
|
10
12
|
|
|
11
|
-
/** 支持的角落白名单 */
|
|
12
|
-
const CORNERS: Corner[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
|
|
13
|
-
|
|
14
13
|
/**
|
|
15
14
|
* 返回 DSH 插件 factory:`(require) => module`。
|
|
16
15
|
* 插件三件套(name / inject / apply)都在其返回的 module 上。
|
|
@@ -54,11 +53,11 @@ export function makeFactory(): (require: (mod: string) => any) => any {
|
|
|
54
53
|
let ANIM: AnimConfig = buildMinimalAnim();
|
|
55
54
|
|
|
56
55
|
// ============================================================================
|
|
57
|
-
//
|
|
56
|
+
// PetCard 组件 —— 单个宠物实例(配置由容器 PetMulti 传入)
|
|
58
57
|
// ============================================================================
|
|
59
|
-
function
|
|
60
|
-
// ----
|
|
61
|
-
const [size, setSize] = useState(
|
|
58
|
+
function PetCard({ cfg }: { cfg: PetConfigUI }) {
|
|
59
|
+
// ---- 尺寸(由配置传入;容器/设置页更新后即时跟随)----
|
|
60
|
+
const [size, setSize] = useState(cfg.size);
|
|
62
61
|
const halfW = size / 2;
|
|
63
62
|
const halfH = (size * 9) / 16 / 2;
|
|
64
63
|
|
|
@@ -68,38 +67,17 @@ export function makeFactory(): (require: (mod: string) => any) => any {
|
|
|
68
67
|
const [facing, setFacing] = useState('left' as 'left' | 'right');
|
|
69
68
|
const [dragging, setDragging] = useState(false);
|
|
70
69
|
const [customPos, setCustomPos] = useState<null | { rx: number; ry: number }>(null);
|
|
71
|
-
//
|
|
72
|
-
const [corner, setCorner] = useState<Corner>(
|
|
73
|
-
const [margin, setMargin] = useState({ x:
|
|
74
|
-
|
|
70
|
+
// 初始角落与边距(来自配置;可被容器更新覆盖)
|
|
71
|
+
const [corner, setCorner] = useState<Corner>(cfg.corner);
|
|
72
|
+
const [margin, setMargin] = useState({ x: cfg.marginX, y: cfg.marginY });
|
|
73
|
+
|
|
74
|
+
// 配置变化即时跟随(容器重新合并 / 设置页保存后通过 petBridge.sync 触发)
|
|
75
75
|
useEffect(() => {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
return r.text();
|
|
80
|
-
})
|
|
81
|
-
.then((src) => {
|
|
82
|
-
const obj = JSON.parse(stripJsonc(src));
|
|
83
|
-
const sz = Number(obj && obj.size);
|
|
84
|
-
if (Number.isFinite(sz) && sz > 0) setSize(sz);
|
|
85
|
-
ANIM = buildAnim(obj);
|
|
86
|
-
const p = obj && obj.position;
|
|
87
|
-
if (p) {
|
|
88
|
-
if (CORNERS.indexOf(p.corner) !== -1) setCorner(p.corner as Corner);
|
|
89
|
-
const mx = Number(p.marginX);
|
|
90
|
-
const my = Number(p.marginY);
|
|
91
|
-
if (!Number.isNaN(mx)) setMargin((m) => ({ ...m, x: mx }));
|
|
92
|
-
if (!Number.isNaN(my)) setMargin((m) => ({ ...m, y: my }));
|
|
93
|
-
}
|
|
94
|
-
// 配置就绪后用待机动画启动(ANIM 由 buildAnim 兜底,必有 idle)
|
|
95
|
-
if (ANIM.idle && ANIM.idle.length) {
|
|
96
|
-
setAnim(ANIM.idle[0]);
|
|
97
|
-
setSeq((s) => s + 1);
|
|
98
|
-
}
|
|
99
|
-
})
|
|
100
|
-
.catch(() => {});
|
|
76
|
+
setSize(cfg.size);
|
|
77
|
+
setCorner(cfg.corner);
|
|
78
|
+
setMargin({ x: cfg.marginX, y: cfg.marginY });
|
|
101
79
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
102
|
-
}, []);
|
|
80
|
+
}, [cfg.size, cfg.corner, cfg.marginX, cfg.marginY]);
|
|
103
81
|
const [seq, setSeq] = useState(0);
|
|
104
82
|
|
|
105
83
|
// ---- DOM / 状态 refs ----
|
|
@@ -432,16 +410,74 @@ export function makeFactory(): (require: (mod: string) => any) => any {
|
|
|
432
410
|
);
|
|
433
411
|
}
|
|
434
412
|
|
|
413
|
+
// ============================================================================
|
|
414
|
+
// PetMulti 容器 —— 多开:拉取配置 → 合并默认+用户层 pets → 渲染多个 PetCard
|
|
415
|
+
// ============================================================================
|
|
416
|
+
function PetMulti() {
|
|
417
|
+
const [pets, setPets] = useState<PetConfigUI[]>([]);
|
|
418
|
+
const [ready, setReady] = useState(false);
|
|
419
|
+
|
|
420
|
+
useEffect(() => {
|
|
421
|
+
let alive = true;
|
|
422
|
+
(async () => {
|
|
423
|
+
try {
|
|
424
|
+
const r1 = await fetch('/pet/config.jsonc');
|
|
425
|
+
if (!r1.ok) return;
|
|
426
|
+
const obj: any = JSON.parse(stripJsonc(await r1.text()));
|
|
427
|
+
ANIM = buildAnim(obj);
|
|
428
|
+
const defaults = extractDefaultPets(obj);
|
|
429
|
+
// 用户覆盖层(设置页保存的完整宠物列表)
|
|
430
|
+
let user: any = {};
|
|
431
|
+
try {
|
|
432
|
+
const r2 = await fetch('/pet/config');
|
|
433
|
+
if (r2.ok && r2.status !== 204) user = await r2.json().catch(() => ({}));
|
|
434
|
+
} catch { /* 无用户层时忽略 */ }
|
|
435
|
+
const merged = resolvePets(defaults, user);
|
|
436
|
+
if (!alive) return;
|
|
437
|
+
petBridge.current = merged;
|
|
438
|
+
petBridge.template = defaults.length ? defaults[0] : null;
|
|
439
|
+
petBridge.sync = (list: PetConfigUI[]) => {
|
|
440
|
+
setPets(list);
|
|
441
|
+
petBridge.current = list;
|
|
442
|
+
};
|
|
443
|
+
setPets(merged);
|
|
444
|
+
setReady(true);
|
|
445
|
+
} catch { /* 失败静默:保持不渲染(或兜底) */ }
|
|
446
|
+
})();
|
|
447
|
+
return () => {
|
|
448
|
+
alive = false;
|
|
449
|
+
petBridge.sync = null;
|
|
450
|
+
};
|
|
451
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
452
|
+
}, []);
|
|
453
|
+
|
|
454
|
+
return ready ? pets.map((p) => h(PetCard, { key: p.id, cfg: p })) : null;
|
|
455
|
+
}
|
|
456
|
+
|
|
435
457
|
// ============================================================================
|
|
436
458
|
// 插件主体(Cordis 插件三件套)
|
|
437
459
|
// ============================================================================
|
|
438
460
|
const name = 'pet';
|
|
439
|
-
const inject = ['slots'];
|
|
461
|
+
const inject = ['slots', 'locale'];
|
|
440
462
|
function apply(ctx: any, _config: any) {
|
|
463
|
+
// 本地化字典(设置页文案)
|
|
464
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-pet: dictionaries');
|
|
465
|
+
const t = ctx.locale.bind(NS);
|
|
466
|
+
|
|
467
|
+
// 宠物 overlay(多开:容器渲染多个 PetCard)
|
|
441
468
|
ctx.slots.inject('shell.overlay', function* () {
|
|
442
469
|
yield ctx.slots.register(
|
|
443
470
|
{ name: 'shell.overlay', id: 'pet', order: 1000 },
|
|
444
|
-
() => h(
|
|
471
|
+
() => h(PetMulti, {}),
|
|
472
|
+
);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
// 设置页:「桌宠配置」(大小/位置,保存即时生效)
|
|
476
|
+
const PetConfigSection = makePetConfigSection({ h, useState, t });
|
|
477
|
+
ctx.slots.inject('settings.section', function* () {
|
|
478
|
+
yield ctx.slots.register(
|
|
479
|
+
{ name: 'settings.section', id: 'pet-config', order: 30, label: () => t('nav'), inject: () => ({ t }) },
|
|
480
|
+
PetConfigSection,
|
|
445
481
|
);
|
|
446
482
|
});
|
|
447
483
|
}
|
package/src/client/config.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// 配置层:拉取 / 解析 config.jsonc,构建运行时动作配置(唯一事实来源)。
|
|
2
|
-
import
|
|
2
|
+
import { DEFAULT_PETS } from './settings';
|
|
3
|
+
import type { PetConfigUI } from './settings';
|
|
4
|
+
import type { AnimConfig, Corner } from './types';
|
|
3
5
|
|
|
4
6
|
/** 安全兜底待机名:用于初始渲染 / 配置缺失时宠物不至于空白 */
|
|
5
7
|
export const FALLBACK_IDLE = '待机呼吸休闲';
|
|
@@ -75,3 +77,46 @@ export const buildAnim = (obj: unknown): AnimConfig => {
|
|
|
75
77
|
},
|
|
76
78
|
};
|
|
77
79
|
};
|
|
80
|
+
|
|
81
|
+
// ============================================================================
|
|
82
|
+
// 多开宠物配置解析(纯函数,不依赖 React)
|
|
83
|
+
// ============================================================================
|
|
84
|
+
/** 支持的角落白名单 */
|
|
85
|
+
export const CORNERS: Corner[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
|
|
86
|
+
|
|
87
|
+
/** 归一化一个宠物条目(缺字段回落默认) */
|
|
88
|
+
export function normalizePet(p: any): PetConfigUI {
|
|
89
|
+
const pos = p && p.position && typeof p.position === 'object' ? p.position : {};
|
|
90
|
+
return {
|
|
91
|
+
id: String(p && p.id ? p.id : 'main'),
|
|
92
|
+
size: Number(p && p.size) > 0 ? Number(p.size) : DEFAULT_PETS[0].size,
|
|
93
|
+
corner: CORNERS.indexOf(pos.corner) !== -1 ? (pos.corner as Corner) : DEFAULT_PETS[0].corner,
|
|
94
|
+
marginX: Number.isFinite(Number(pos.marginX)) ? Number(pos.marginX) : DEFAULT_PETS[0].marginX,
|
|
95
|
+
marginY: Number.isFinite(Number(pos.marginY)) ? Number(pos.marginY) : DEFAULT_PETS[0].marginY,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 从 config.jsonc 对象提取默认宠物列表:必须为 pets 数组;缺失/为空回落代码兜底 DEFAULT_PETS */
|
|
100
|
+
export function extractDefaultPets(obj: any): PetConfigUI[] {
|
|
101
|
+
const arr = obj && Array.isArray(obj.pets) ? obj.pets.filter((p: any) => p && p.id) : [];
|
|
102
|
+
if (!arr.length) return DEFAULT_PETS.map((p) => ({ ...p }));
|
|
103
|
+
const seen = new Set<string>();
|
|
104
|
+
const out: PetConfigUI[] = [];
|
|
105
|
+
for (const p of arr) {
|
|
106
|
+
const id = String(p.id);
|
|
107
|
+
if (!seen.has(id)) {
|
|
108
|
+
seen.add(id);
|
|
109
|
+
out.push(normalizePet(p));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return out.length ? out : DEFAULT_PETS.map((p) => ({ ...p }));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 合并最终宠物列表:用户层({ pets: 完整列表 })全量替换默认;无用户层回落默认 */
|
|
116
|
+
export function resolvePets(defaults: PetConfigUI[], user: any): PetConfigUI[] {
|
|
117
|
+
if (user && Array.isArray(user.pets)) {
|
|
118
|
+
const list = user.pets.filter((p: any) => p && p.id).map(normalizePet);
|
|
119
|
+
return list.length ? list : defaults; // 用户层空数组视为无覆盖
|
|
120
|
+
}
|
|
121
|
+
return defaults;
|
|
122
|
+
}
|
package/src/client/pickers.ts
CHANGED
|
@@ -4,7 +4,9 @@ import type { Category } from './types';
|
|
|
4
4
|
/** 从字符串池里等概率随机抽一个;exclude 排除某个名字(避免连续重复) */
|
|
5
5
|
export const pick = <T,>(pool: T[], exclude?: T): T => {
|
|
6
6
|
const entries = exclude ? pool.filter((n) => n !== exclude) : pool;
|
|
7
|
-
|
|
7
|
+
// 排除后池空(单元素池 + 排除自己):退回原池抽——宁可重复,也不要返回 undefined
|
|
8
|
+
const src = entries.length ? entries : pool;
|
|
9
|
+
return src[Math.floor(Math.random() * src.length)];
|
|
8
10
|
};
|
|
9
11
|
|
|
10
12
|
/** 生成 [min, max) 区间内的随机整数 */
|
|
@@ -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
|
+
}
|