codesign-mcp-server 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +198 -2
- package/dist/client.d.ts +97 -0
- package/dist/client.js +773 -0
- package/dist/client.js.map +1 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.js +108 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +386 -0
- package/dist/index.js.map +1 -0
- package/dist/spec.d.ts +18 -0
- package/dist/spec.js +293 -0
- package/dist/spec.js.map +1 -0
- package/dist/tools.d.ts +61 -0
- package/dist/tools.js +184 -0
- package/dist/tools.js.map +1 -0
- package/dist/types.d.ts +191 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/concurrency.d.ts +2 -0
- package/dist/utils/concurrency.js +15 -0
- package/dist/utils/concurrency.js.map +1 -0
- package/dist/utils/filename.d.ts +10 -0
- package/dist/utils/filename.js +32 -0
- package/dist/utils/filename.js.map +1 -0
- package/dist/version.d.ts +7 -0
- package/dist/version.js +8 -0
- package/dist/version.js.map +1 -0
- package/package.json +53 -4
package/dist/client.js
ADDED
|
@@ -0,0 +1,773 @@
|
|
|
1
|
+
import { CoDesignError, explainError, explainNetworkError, isCoDesignError, } from "./errors.js";
|
|
2
|
+
const BASE = "https://codesign.qq.com";
|
|
3
|
+
const CDN_ORIGIN = "https://codesign.qq.com";
|
|
4
|
+
const DEFAULT_CDN_HOST = "https://cdn4.codesign.qq.com";
|
|
5
|
+
const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36";
|
|
6
|
+
/** 分享链接的各类写法,用于解析分享 ID */
|
|
7
|
+
const SHARE_PATTERNS = [
|
|
8
|
+
/#?\/app\/s\/(\d+)/,
|
|
9
|
+
/#?\/s\/(\d+)/,
|
|
10
|
+
/sharings?\/(\d+)/,
|
|
11
|
+
/^\s*(\d{10,})\s*$/,
|
|
12
|
+
];
|
|
13
|
+
/** 后台链接(需要登录,ID 是设计稿 ID 而非分享 ID),单独识别以便反查分享 */
|
|
14
|
+
const BACKEND_DESIGN_RE = /#?\/app\/design\/(\d+)/;
|
|
15
|
+
const BACKEND_OTHER_PATTERNS = [
|
|
16
|
+
{ re: /#?\/app\/team\//, kind: "团队页" },
|
|
17
|
+
{ re: /#?\/app\/project\//, kind: "项目页" },
|
|
18
|
+
];
|
|
19
|
+
/** 从后台链接里解析设计稿 ID(如 /app/design/<designId>/board) */
|
|
20
|
+
export function parseDesignId(url) {
|
|
21
|
+
const m = url.match(BACKEND_DESIGN_RE);
|
|
22
|
+
return m ? Number(m[1]) : undefined;
|
|
23
|
+
}
|
|
24
|
+
/** 从分享链接里解析分享 ID,兼容带不带 hash 前缀的写法 */
|
|
25
|
+
export function parseShareId(url) {
|
|
26
|
+
for (const re of SHARE_PATTERNS) {
|
|
27
|
+
const m = url.match(re);
|
|
28
|
+
if (m)
|
|
29
|
+
return Number(m[1]);
|
|
30
|
+
}
|
|
31
|
+
// 后台链接:ID 是设计稿 ID,需要先用 Cookie 反查对应的分享 ID
|
|
32
|
+
if (parseDesignId(url) !== undefined) {
|
|
33
|
+
throw new CoDesignError("这是设计稿后台链接,需要先反查分享 ID", undefined, "工具会自动用 Cookie 调用后台接口换出分享 ID;若仍失败,请改用分享链接。", "BACKEND_LINK");
|
|
34
|
+
}
|
|
35
|
+
for (const { re, kind } of BACKEND_OTHER_PATTERNS) {
|
|
36
|
+
if (re.test(url)) {
|
|
37
|
+
throw new CoDesignError(`这是${kind}链接,不是分享链接`, undefined, [
|
|
38
|
+
"本工具的接口只认「分享」体系。",
|
|
39
|
+
"",
|
|
40
|
+
"获取分享链接:",
|
|
41
|
+
" 1. 浏览器打开该设计稿",
|
|
42
|
+
" 2. 点右上角「分享」→ 复制链接(或开启分享)",
|
|
43
|
+
" 3. 链接形如 https://codesign.qq.com/app/s/<分享ID>",
|
|
44
|
+
].join("\n"), "BACKEND_LINK");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
throw new CoDesignError(`无法从链接中解析分享 ID:${url}`, undefined, [
|
|
48
|
+
"支持的格式:",
|
|
49
|
+
" https://codesign.qq.com/app/s/<分享ID>",
|
|
50
|
+
" https://codesign.qq.com/app/s/<分享ID>/<画板ID>",
|
|
51
|
+
" https://codesign.qq.com/s/<分享ID>/<画板ID>/inspect",
|
|
52
|
+
" https://codesign.qq.com/app/design/<设计ID>/board(需配 Cookie 反查)",
|
|
53
|
+
" 或直接传纯数字分享 ID",
|
|
54
|
+
].join("\n"));
|
|
55
|
+
}
|
|
56
|
+
/** 从分享链接里解析当前打开的画板 ID(可选) */
|
|
57
|
+
export function parseScreenId(url) {
|
|
58
|
+
// 分享链接:/app/s/<shareId>/<screenId> 或 /s/<shareId>/<screenId>/inspect
|
|
59
|
+
const m = url.match(/#?\/(?:app\/)?s\/\d+\/(\d+)/);
|
|
60
|
+
return m ? Number(m[1]) : undefined;
|
|
61
|
+
}
|
|
62
|
+
/** 把 Cookie 字符串拆成 map,便于按需取 access_token */
|
|
63
|
+
function parseCookies(cookie) {
|
|
64
|
+
const out = {};
|
|
65
|
+
for (const part of cookie.split(";")) {
|
|
66
|
+
const i = part.indexOf("=");
|
|
67
|
+
if (i < 0)
|
|
68
|
+
continue;
|
|
69
|
+
out[part.slice(0, i).trim()] = part.slice(i + 1).trim();
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
export class CoDesignClient {
|
|
74
|
+
cookie;
|
|
75
|
+
accessToken;
|
|
76
|
+
cookieTeamId;
|
|
77
|
+
timeoutMs;
|
|
78
|
+
retries;
|
|
79
|
+
defaultPassword;
|
|
80
|
+
/** 分享密码 → state-key 缓存,密码有效期由服务端决定(响应里带 expires) */
|
|
81
|
+
stateKeys = new Map();
|
|
82
|
+
constructor(cookie, options = {}) {
|
|
83
|
+
this.cookie = (cookie ?? "").trim();
|
|
84
|
+
this.timeoutMs = options.timeoutMs ?? 30_000;
|
|
85
|
+
this.retries = Math.max(0, options.retries ?? 2);
|
|
86
|
+
this.defaultPassword = options.defaultPassword?.trim() || undefined;
|
|
87
|
+
if (this.cookie) {
|
|
88
|
+
const jar = parseCookies(this.cookie);
|
|
89
|
+
this.accessToken = jar.access_token;
|
|
90
|
+
this.cookieTeamId = jar["X-Team-Id"];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
get hasCookie() {
|
|
94
|
+
return this.cookie.length > 0;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* 用访问密码换 state-key。
|
|
98
|
+
* 带密码的分享,`GET /api/sharings/:id` 会返回 403「请输入密码!」,
|
|
99
|
+
* 必须先 `POST /api/sharings/:id/state-keys` 拿 key,再以 `state-key` 头访问。
|
|
100
|
+
* 注意画板详情接口不需要这个头。
|
|
101
|
+
*/
|
|
102
|
+
async exchangePassword(shareId, password) {
|
|
103
|
+
const cacheKey = `${shareId}:${password}`;
|
|
104
|
+
const cached = this.stateKeys.get(cacheKey);
|
|
105
|
+
if (cached && cached.expires * 1000 > Date.now())
|
|
106
|
+
return cached.key;
|
|
107
|
+
const res = await this.send(`${BASE}/api/sharings/${shareId}/state-keys`, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: { ...this.authHeaders(), referer: `${BASE}/s/${shareId}`, "content-type": "application/json" },
|
|
110
|
+
body: JSON.stringify({ password }),
|
|
111
|
+
});
|
|
112
|
+
const text = await res.text();
|
|
113
|
+
if (!res.ok) {
|
|
114
|
+
// 错误密码返回 422,错误信息形如「password 必须是 4 个字符」
|
|
115
|
+
throw explainError(res.status, text, `${BASE}/api/sharings/${shareId}/state-keys`);
|
|
116
|
+
}
|
|
117
|
+
let parsed;
|
|
118
|
+
try {
|
|
119
|
+
parsed = JSON.parse(text);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
throw new CoDesignError("密码校验接口返回了非 JSON 内容", res.status, "接口可能已变更,请检查 CoDesign 是否改了分享密码机制。");
|
|
123
|
+
}
|
|
124
|
+
const key = parsed?.key;
|
|
125
|
+
if (typeof key !== "string" || !key) {
|
|
126
|
+
throw new CoDesignError("密码校验未返回 state-key", res.status);
|
|
127
|
+
}
|
|
128
|
+
const expires = typeof parsed.expires === "number" ? parsed.expires : Math.floor(Date.now() / 1000) + 3600;
|
|
129
|
+
this.stateKeys.set(cacheKey, { key, expires });
|
|
130
|
+
return key;
|
|
131
|
+
}
|
|
132
|
+
/** 鉴权接口:Cookie + Bearer(两者缺一不可,只带 Cookie 会返回「非项目成员」) */
|
|
133
|
+
authHeaders() {
|
|
134
|
+
const h = {
|
|
135
|
+
accept: "application/json",
|
|
136
|
+
"x-requested-with": "XMLHttpRequest",
|
|
137
|
+
"x-source": "web",
|
|
138
|
+
"user-agent": UA,
|
|
139
|
+
referer: `${BASE}/`,
|
|
140
|
+
};
|
|
141
|
+
if (this.cookie)
|
|
142
|
+
h.cookie = this.cookie;
|
|
143
|
+
if (this.accessToken)
|
|
144
|
+
h.authorization = `Bearer ${this.accessToken}`;
|
|
145
|
+
if (this.cookieTeamId)
|
|
146
|
+
h["x-team-id"] = this.cookieTeamId;
|
|
147
|
+
return h;
|
|
148
|
+
}
|
|
149
|
+
/** CDN 资源:必须带 Origin,否则 403 */
|
|
150
|
+
cdnHeaders() {
|
|
151
|
+
return {
|
|
152
|
+
origin: CDN_ORIGIN,
|
|
153
|
+
referer: `${BASE}/`,
|
|
154
|
+
"user-agent": UA,
|
|
155
|
+
accept: "*/*",
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* 带超时与重试的请求。只对幂等方法重试:CDN 素材全是 GET,
|
|
160
|
+
* 本项目的鉴权接口也都是查询,所以默认全部可重试。
|
|
161
|
+
*/
|
|
162
|
+
async send(url, init = {}) {
|
|
163
|
+
let lastErr;
|
|
164
|
+
for (let attempt = 0; attempt <= this.retries; attempt++) {
|
|
165
|
+
if (attempt > 0)
|
|
166
|
+
await sleep(300 * 2 ** (attempt - 1));
|
|
167
|
+
try {
|
|
168
|
+
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(this.timeoutMs) });
|
|
169
|
+
// 5xx / 429 才值得重试;4xx 是确定性的,重试只会白等
|
|
170
|
+
if (res.ok || !isRetryable(res.status) || attempt === this.retries)
|
|
171
|
+
return res;
|
|
172
|
+
// 读掉 body 释放连接,再等下一轮
|
|
173
|
+
await res.text().catch(() => "");
|
|
174
|
+
}
|
|
175
|
+
catch (e) {
|
|
176
|
+
lastErr = e;
|
|
177
|
+
if (attempt === this.retries)
|
|
178
|
+
throw explainNetworkError(e, url);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// 循环只在最后一次抛错时退出
|
|
182
|
+
throw explainNetworkError(lastErr, url);
|
|
183
|
+
}
|
|
184
|
+
async request(url, init) {
|
|
185
|
+
const res = await this.send(url, init);
|
|
186
|
+
if (!res.ok) {
|
|
187
|
+
const body = await res.text().catch(() => "");
|
|
188
|
+
throw explainError(res.status, body, url);
|
|
189
|
+
}
|
|
190
|
+
return (await res.json());
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* 设计稿元数据。公开分享无需登录即可拿到标题、团队、画板列表;
|
|
194
|
+
* 带密码的分享需要传 password,内部会先换 state-key 再请求。
|
|
195
|
+
*/
|
|
196
|
+
async getShare(shareId, password) {
|
|
197
|
+
// 未显式传密码时退回环境变量里配置的默认密码
|
|
198
|
+
const pwd = password?.trim() || this.defaultPassword;
|
|
199
|
+
// 有密码就先换 key;没密码则直接请求
|
|
200
|
+
let stateKey = pwd ? await this.exchangePassword(shareId, pwd) : undefined;
|
|
201
|
+
const fetchShare = (key) => this.request(`${BASE}/api/sharings/${shareId}`, {
|
|
202
|
+
headers: {
|
|
203
|
+
accept: "application/json",
|
|
204
|
+
referer: `${BASE}/s/${shareId}`,
|
|
205
|
+
"user-agent": UA,
|
|
206
|
+
...(key ? { "state-key": key } : {}),
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
let raw;
|
|
210
|
+
try {
|
|
211
|
+
raw = await fetchShare(stateKey);
|
|
212
|
+
}
|
|
213
|
+
catch (e) {
|
|
214
|
+
// 带 Cookie 请求密码分享时,服务端返回的是「您当前暂无权限」而不是「请输入密码」,
|
|
215
|
+
// 无法从这一条判断是否缺密码。用不含 Cookie / Bearer 的探测请求确认一下。
|
|
216
|
+
if (!stateKey && isCoDesignError(e) && e.code === "NO_PERMISSION" && (await this.needsPassword(shareId))) {
|
|
217
|
+
if (this.defaultPassword) {
|
|
218
|
+
raw = await fetchShare(await this.exchangePassword(shareId, this.defaultPassword));
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
throw new CoDesignError(`设计稿(分享 ${shareId})需要访问密码`, 403, [
|
|
222
|
+
"该分享开启了密码保护,需要提供访问密码。",
|
|
223
|
+
"请把密码作为 password 参数传入,或设置环境变量 CODESIGN_PASSWORD。",
|
|
224
|
+
'例如:design_get(url, password: "ABCD")',
|
|
225
|
+
"提示:若密码正确仍报「无权限」,说明当前 Cookie 账号不在项目内,",
|
|
226
|
+
" 密码只能解锁分享,解不开团队权限。",
|
|
227
|
+
].join("\n"), "NEED_PASSWORD");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
throw e;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const designs = (raw.designs ?? []).map((d) => {
|
|
235
|
+
const screens = [];
|
|
236
|
+
for (const section of d.sections ?? []) {
|
|
237
|
+
for (const s of section.screens ?? []) {
|
|
238
|
+
screens.push({ id: s.id, name: s.name });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
id: d.id,
|
|
243
|
+
name: d.name,
|
|
244
|
+
viewport: d.viewport ?? "",
|
|
245
|
+
screens,
|
|
246
|
+
};
|
|
247
|
+
});
|
|
248
|
+
return {
|
|
249
|
+
id: raw.id,
|
|
250
|
+
title: raw.title,
|
|
251
|
+
teamName: raw.team?.name ?? "",
|
|
252
|
+
permissions: {
|
|
253
|
+
commentable: raw.permissions?.commentable ?? false,
|
|
254
|
+
inspectable: raw.permissions?.inspectable ?? false,
|
|
255
|
+
assetsDownloadable: raw.permissions?.assetsDownloadable ?? false,
|
|
256
|
+
},
|
|
257
|
+
designs,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
/** 画板详情:拿 meta_url / slices_url / 预览图地址 */
|
|
261
|
+
async getScreen(designId, screenId) {
|
|
262
|
+
this.requireCookie("读取图层标注");
|
|
263
|
+
const raw = await this.request(`${BASE}/api/designs/${designId}/screens/${screenId}`, { headers: this.authHeaders() });
|
|
264
|
+
const cdnHost = (raw.cdn_host ?? DEFAULT_CDN_HOST).replace(/\/$/, "");
|
|
265
|
+
const rewrite = (u) => (u ? new URL(u, cdnHost).href : undefined);
|
|
266
|
+
return {
|
|
267
|
+
id: raw.id,
|
|
268
|
+
name: raw.name,
|
|
269
|
+
width: raw.width,
|
|
270
|
+
height: raw.height,
|
|
271
|
+
designId: raw.design_id ?? designId,
|
|
272
|
+
source: raw.source ?? "",
|
|
273
|
+
metaUrl: rewrite(raw.meta_url),
|
|
274
|
+
slicesUrl: rewrite(raw.slices_url),
|
|
275
|
+
previewUrl: rewrite(raw.preview_path),
|
|
276
|
+
slicesCount: raw.slices_count ?? 0,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* 下载并解析图层树 JSON。
|
|
281
|
+
* 注意:CoDesign 的 meta 里 `layers` 与 `groups` 是两个**扁平**数组,
|
|
282
|
+
* 真实层级只体现在 `parent_id` 上,必须自己重建,否则拿到的是一堆没有父子关系的散图层。
|
|
283
|
+
*/
|
|
284
|
+
async getMeta(metaUrl) {
|
|
285
|
+
const raw = await this.request(metaUrl, { headers: this.cdnHeaders() });
|
|
286
|
+
const nodes = [...(raw.layers ?? []), ...(raw.groups ?? [])];
|
|
287
|
+
return {
|
|
288
|
+
name: raw.name ?? "",
|
|
289
|
+
width: raw.width ?? 0,
|
|
290
|
+
height: raw.height ?? 0,
|
|
291
|
+
layers: buildLayerTree(nodes, raw.object_id),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
/** 下载并解析切图清单 JSON */
|
|
295
|
+
async getSlices(slicesUrl) {
|
|
296
|
+
const raw = await this.request(slicesUrl, { headers: this.cdnHeaders() });
|
|
297
|
+
return (raw ?? []).map((item) => ({
|
|
298
|
+
id: String(item.id ?? item.object_id ?? ""),
|
|
299
|
+
name: item.name ?? "",
|
|
300
|
+
rect: normalizeRect(item.rect),
|
|
301
|
+
exports: (item.exportables ?? []).map((ex) => ({
|
|
302
|
+
name: item.name ?? "",
|
|
303
|
+
scale: ex.scale ?? 1,
|
|
304
|
+
format: ex.format ?? "png",
|
|
305
|
+
width: ex.screenshot?.width ?? ex.size ?? 0,
|
|
306
|
+
height: ex.screenshot?.height ?? ex.size ?? 0,
|
|
307
|
+
// 清单里给的是 OSS 签名地址,直接下会 AccessDenied,
|
|
308
|
+
// 换到 CDN 域名并加 imageMogr2 参数才可下载(svg 会跳过该参数)
|
|
309
|
+
downloadUrl: toCdnUrl(ex.screenshot?.url ?? "", DEFAULT_CDN_HOST, ex.format),
|
|
310
|
+
objectId: String(ex.object_id ?? item.object_id ?? ""),
|
|
311
|
+
})),
|
|
312
|
+
}));
|
|
313
|
+
}
|
|
314
|
+
/** 下载任意 CDN 图片到 Buffer */
|
|
315
|
+
async downloadImage(url) {
|
|
316
|
+
const res = await this.send(url, { headers: this.cdnHeaders() });
|
|
317
|
+
if (!res.ok) {
|
|
318
|
+
const body = await res.text().catch(() => "");
|
|
319
|
+
throw explainError(res.status, body, url);
|
|
320
|
+
}
|
|
321
|
+
return Buffer.from(await res.arrayBuffer());
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* 探测某个分享是否设置了访问密码。
|
|
325
|
+
* 不带 Cookie/Bearer 请求分享详情:密码分享会返回 403「请输入密码!」,
|
|
326
|
+
* 普通分享则正常返回 200。仅用于诊断,失败时按「不需要密码」处理。
|
|
327
|
+
*/
|
|
328
|
+
async needsPassword(shareId) {
|
|
329
|
+
try {
|
|
330
|
+
const res = await this.send(`${BASE}/api/sharings/${shareId}`, {
|
|
331
|
+
headers: {
|
|
332
|
+
accept: "application/json",
|
|
333
|
+
referer: `${BASE}/s/${shareId}`,
|
|
334
|
+
"user-agent": UA,
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
if (res.ok)
|
|
338
|
+
return false;
|
|
339
|
+
const body = await res.text().catch(() => "");
|
|
340
|
+
const message = (() => {
|
|
341
|
+
try {
|
|
342
|
+
return String(JSON.parse(body)?.message ?? "");
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
return body;
|
|
346
|
+
}
|
|
347
|
+
})();
|
|
348
|
+
return res.status === 403 && message.includes("请输入密码");
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* 直接用后台链接的设计稿 ID 读取设计稿。
|
|
356
|
+
*
|
|
357
|
+
* 后台接口 `GET /api/designs/<designId>` 需要 Cookie + Bearer,
|
|
358
|
+
* 返回里带 name / team / sections[].screens[],结构与分享详情基本一致,
|
|
359
|
+
* 直接映射成 ShareInfo 即可复用后续全部流程,无需去找分享链接。
|
|
360
|
+
*
|
|
361
|
+
* `shareId` 会填成设计稿 ID(此时没有分享 ID 这个概念)。
|
|
362
|
+
*/
|
|
363
|
+
async getShareByDesignId(designId) {
|
|
364
|
+
this.requireCookie("用后台链接读取设计稿");
|
|
365
|
+
const raw = await this.request(`${BASE}/api/designs/${designId}`, {
|
|
366
|
+
headers: { ...this.authHeaders(), referer: `${BASE}/app/design/${designId}/board` },
|
|
367
|
+
});
|
|
368
|
+
// 后台返回的 sections 与分享详情同构;某些版本顶层也有 screens,做兜底
|
|
369
|
+
const sections = Array.isArray(raw?.sections) ? raw.sections : [];
|
|
370
|
+
let screens = sections.flatMap((sec) => (Array.isArray(sec?.screens) ? sec.screens : []));
|
|
371
|
+
if (!screens.length && Array.isArray(raw?.screens))
|
|
372
|
+
screens = raw.screens;
|
|
373
|
+
if (!screens.length) {
|
|
374
|
+
throw new CoDesignError(`设计稿 ${designId} 里没有找到任何画板`, undefined, [
|
|
375
|
+
"后台接口返回成功,但 sections/screens 为空。可能原因:",
|
|
376
|
+
" 1. 设计稿还在上传处理中,稍后重试",
|
|
377
|
+
" 2. 当前 Cookie 账号没有该设计稿的查看权限",
|
|
378
|
+
" 3. 该设计稿尚未上传源文件(只有封面图)",
|
|
379
|
+
].join("\n"));
|
|
380
|
+
}
|
|
381
|
+
// 后台接口用 permission_type / is_member 表达权限,映射成与分享一致的语义
|
|
382
|
+
const canInspect = raw?.is_member === true || raw?.is_team_member === true || raw?.permission_type !== "none";
|
|
383
|
+
const sharingPerm = Array.isArray(raw?.sharings) ? raw.sharings[0]?.permissions : undefined;
|
|
384
|
+
const assetsDownloadable = sharingPerm?.assetsDownloadable ?? raw?.permission?.assets_downloadable ?? true;
|
|
385
|
+
return {
|
|
386
|
+
id: designId,
|
|
387
|
+
title: raw?.name ?? `设计稿 ${designId}`,
|
|
388
|
+
teamName: raw?.team?.name ?? "",
|
|
389
|
+
permissions: {
|
|
390
|
+
commentable: sharingPerm?.commentable ?? true,
|
|
391
|
+
inspectable: canInspect,
|
|
392
|
+
assetsDownloadable: Boolean(assetsDownloadable),
|
|
393
|
+
},
|
|
394
|
+
designs: [
|
|
395
|
+
{
|
|
396
|
+
id: designId,
|
|
397
|
+
name: raw?.name ?? "",
|
|
398
|
+
viewport: raw?.viewport ?? "",
|
|
399
|
+
screens: screens.map((s) => ({ id: s.id, name: s.name })),
|
|
400
|
+
},
|
|
401
|
+
],
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
requireCookie(action) {
|
|
405
|
+
if (!this.hasCookie) {
|
|
406
|
+
throw new CoDesignError(`需要登录态才能${action}`, undefined, [
|
|
407
|
+
"未检测到 COOKIE 环境变量。",
|
|
408
|
+
"获取方式:",
|
|
409
|
+
" 1. 浏览器登录 https://codesign.qq.com",
|
|
410
|
+
" 2. 打开目标设计稿分享页,F12 → Network",
|
|
411
|
+
" 3. 任选一条 /api/ 请求 → 右键 → Copy as cURL",
|
|
412
|
+
" 4. 把 -b '...' 引号内的内容设为环境变量 CODESIGN_COOKIE",
|
|
413
|
+
].join("\n"));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
/** 5xx 与 429 属于瞬时失败,可重试 */
|
|
418
|
+
function isRetryable(status) {
|
|
419
|
+
return status === 429 || status >= 500;
|
|
420
|
+
}
|
|
421
|
+
function sleep(ms) {
|
|
422
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* 把 OSS 直链换成可下载的 CDN 直链。
|
|
426
|
+
* 注意:`imageMogr2` 是腾讯云图片处理参数,只能处理位图;
|
|
427
|
+
* 对 svg 这类矢量格式会直接报 `InvalidImageFormat` 400,必须跳过。
|
|
428
|
+
*/
|
|
429
|
+
export function toCdnUrl(url, cdnHost = DEFAULT_CDN_HOST, format) {
|
|
430
|
+
if (!url)
|
|
431
|
+
return "";
|
|
432
|
+
try {
|
|
433
|
+
const u = new URL(url);
|
|
434
|
+
const isVector = isVectorFormat(format) || /\.svgz?$/i.test(u.pathname);
|
|
435
|
+
// cdn4.codesign.qq.com 走腾讯云图片服务,位图必须带 imageMogr2 才放行
|
|
436
|
+
if (/^cdn\d*\.codesign\.qq\.com$/.test(u.hostname)) {
|
|
437
|
+
if (isVector)
|
|
438
|
+
return url;
|
|
439
|
+
return /imageMogr2/.test(url) ? url : appendImageMogr2(url, u);
|
|
440
|
+
}
|
|
441
|
+
// OSS 签名直链:换域名 + 补参数
|
|
442
|
+
const host = cdnHost.replace(/\/$/, "");
|
|
443
|
+
const rebuilt = `${host}${u.pathname}${u.search}`;
|
|
444
|
+
if (isVector)
|
|
445
|
+
return rebuilt;
|
|
446
|
+
return appendImageMogr2(rebuilt, u);
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
return url;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
function isVectorFormat(format) {
|
|
453
|
+
return typeof format === "string" && /^(svg|svgz|pdf|eps|ai)$/i.test(format.trim());
|
|
454
|
+
}
|
|
455
|
+
/** 追加 imageMogr2 时按 URL 解析结果判断分隔符,避免签名链接里已带 ? 时拼错 */
|
|
456
|
+
function appendImageMogr2(url, parsed) {
|
|
457
|
+
const sep = parsed.search ? "&" : "?";
|
|
458
|
+
return `${url}${sep}imageMogr2/format/png`;
|
|
459
|
+
}
|
|
460
|
+
// ---------------------------------------------------------------------------
|
|
461
|
+
// 颜色
|
|
462
|
+
// ---------------------------------------------------------------------------
|
|
463
|
+
/**
|
|
464
|
+
* CoDesign 的颜色对象字段是 r/g/b/a,同时带 color-hex / css-rgba 等现成写法。
|
|
465
|
+
*
|
|
466
|
+
* 实测数据里 alpha 的单位**不固定**:普通填充是 0–1(a:1),
|
|
467
|
+
* 但半透明遮罩会返回 a:100 并把 color-hex 写成 `#000000 10000%`
|
|
468
|
+
* —— 它的 css-rgba 也是 `rgba(0,0,0,100)`,是错的。所以 alpha 必须自己归一化。
|
|
469
|
+
*/
|
|
470
|
+
export function rgba(c) {
|
|
471
|
+
if (!c)
|
|
472
|
+
return undefined;
|
|
473
|
+
const alpha = resolveAlpha(c);
|
|
474
|
+
const hex = typeof c["color-hex"] === "string" ? c["color-hex"].trim() : undefined;
|
|
475
|
+
if (hex) {
|
|
476
|
+
const m = hex.match(/^(#[0-9a-f]{3,8})(?:\s+(\d+(?:\.\d+)?)%)?$/i);
|
|
477
|
+
if (m) {
|
|
478
|
+
const pct = m[2] !== undefined ? Number(m[2]) / 100 : undefined;
|
|
479
|
+
// color-hex 的百分比与 a 字段矛盾时以 a 为准(见上面的 10000% 案例)
|
|
480
|
+
const a = alpha ?? pct;
|
|
481
|
+
return a !== undefined && a < 1 ? withAlpha(m[1], a) : m[1];
|
|
482
|
+
}
|
|
483
|
+
const colorOnly = hex.split(/\s+/)[0];
|
|
484
|
+
if (/^#([0-9a-f]{3,8})$/i.test(colorOnly)) {
|
|
485
|
+
return alpha !== undefined && alpha < 1 ? withAlpha(colorOnly, alpha) : colorOnly;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
if (typeof c["css-rgba"] === "string")
|
|
489
|
+
return normalizeCssRgba(c["css-rgba"]);
|
|
490
|
+
const r = clamp255(c.r ?? c.red ?? 0);
|
|
491
|
+
const g = clamp255(c.g ?? c.green ?? 0);
|
|
492
|
+
const b = clamp255(c.b ?? c.blue ?? 0);
|
|
493
|
+
if (alpha !== undefined && alpha < 1) {
|
|
494
|
+
return `rgba(${r},${g},${b},${alpha})`;
|
|
495
|
+
}
|
|
496
|
+
return `#${[r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* a/alpha 字段可能是 0–1 也可能是 0–100,实测两种都有。
|
|
500
|
+
* 大于 1 的一律按百分比处理。
|
|
501
|
+
*/
|
|
502
|
+
function resolveAlpha(c) {
|
|
503
|
+
const raw = c.a ?? c.alpha;
|
|
504
|
+
if (typeof raw !== "number" || Number.isNaN(raw))
|
|
505
|
+
return undefined;
|
|
506
|
+
const a = raw > 1 ? raw / 100 : raw;
|
|
507
|
+
return Number(Math.max(0, Math.min(1, a)).toFixed(3));
|
|
508
|
+
}
|
|
509
|
+
function clamp255(v) {
|
|
510
|
+
const n = typeof v === "number" ? v : Number(v);
|
|
511
|
+
return Number.isFinite(n) ? Math.max(0, Math.min(255, Math.round(n))) : 0;
|
|
512
|
+
}
|
|
513
|
+
/** 把 `rgba(0,0,0,100)` 这类百分比 alpha 纠正成合法 CSS */
|
|
514
|
+
function normalizeCssRgba(css) {
|
|
515
|
+
return css.replace(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,\s/]+([\d.]+))?\s*\)/i, (_m, r, g, b, a) => {
|
|
516
|
+
const rr = clamp255(r);
|
|
517
|
+
const gg = clamp255(g);
|
|
518
|
+
const bb = clamp255(b);
|
|
519
|
+
if (a === undefined)
|
|
520
|
+
return `rgb(${rr},${gg},${bb})`;
|
|
521
|
+
const aa = Number(a) > 1 ? Number((Number(a) / 100).toFixed(3)) : Number(a);
|
|
522
|
+
return aa >= 1 ? `rgb(${rr},${gg},${bb})` : `rgba(${rr},${gg},${bb},${aa})`;
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
/** 把 alpha 合进 hex 颜色,输出 8 位 hex(浏览器普遍支持) */
|
|
526
|
+
function withAlpha(hex, alpha) {
|
|
527
|
+
const body = hex.slice(1);
|
|
528
|
+
if (body.length === 8)
|
|
529
|
+
return hex; // 已带 alpha
|
|
530
|
+
const full = body.length === 3 || body.length === 4
|
|
531
|
+
? body
|
|
532
|
+
.slice(0, 3)
|
|
533
|
+
.split("")
|
|
534
|
+
.map((ch) => ch + ch)
|
|
535
|
+
.join("")
|
|
536
|
+
: body;
|
|
537
|
+
return `#${full}${Math.round(Math.max(0, Math.min(1, alpha)) * 255)
|
|
538
|
+
.toString(16)
|
|
539
|
+
.padStart(2, "0")}`;
|
|
540
|
+
}
|
|
541
|
+
// ---------------------------------------------------------------------------
|
|
542
|
+
// 图层树重建
|
|
543
|
+
// ---------------------------------------------------------------------------
|
|
544
|
+
/**
|
|
545
|
+
* 用 parent_id 把扁平的 layers + groups 重建成真正的树。
|
|
546
|
+
* 画板自身的 object_id 是根;找不到父节点的节点挂到根上,避免丢数据。
|
|
547
|
+
*/
|
|
548
|
+
export function buildLayerTree(nodes, rootId) {
|
|
549
|
+
if (!nodes.length)
|
|
550
|
+
return [];
|
|
551
|
+
const byId = new Map();
|
|
552
|
+
for (const n of nodes) {
|
|
553
|
+
if (n?.object_id !== undefined)
|
|
554
|
+
byId.set(String(n.object_id), n);
|
|
555
|
+
}
|
|
556
|
+
const childrenOf = new Map();
|
|
557
|
+
for (const n of nodes) {
|
|
558
|
+
const pid = String(n?.parent_id ?? "");
|
|
559
|
+
if (!childrenOf.has(pid))
|
|
560
|
+
childrenOf.set(pid, []);
|
|
561
|
+
childrenOf.get(pid).push(n);
|
|
562
|
+
}
|
|
563
|
+
const build = (n, seen) => {
|
|
564
|
+
const layer = normalizeLayer(n);
|
|
565
|
+
const id = String(n?.object_id ?? "");
|
|
566
|
+
seen.add(id);
|
|
567
|
+
const kids = (childrenOf.get(id) ?? []).filter((k) => !seen.has(String(k.object_id)));
|
|
568
|
+
if (kids.length)
|
|
569
|
+
layer.children = kids.map((k) => build(k, seen));
|
|
570
|
+
return layer;
|
|
571
|
+
};
|
|
572
|
+
const rootChildren = childrenOf.get(String(rootId ?? "")) ?? [];
|
|
573
|
+
if (rootChildren.length) {
|
|
574
|
+
return rootChildren.map((n) => build(n, new Set()));
|
|
575
|
+
}
|
|
576
|
+
// 没有可用的根(例如画板 object_id 缺失):从“父节点不在集合里”的节点开始
|
|
577
|
+
const orphans = nodes.filter((n) => !byId.has(String(n?.parent_id ?? "")));
|
|
578
|
+
return (orphans.length ? orphans : nodes).map((n) => build(n, new Set()));
|
|
579
|
+
}
|
|
580
|
+
function normalizeRect(rect) {
|
|
581
|
+
return {
|
|
582
|
+
x: rect?.x ?? 0,
|
|
583
|
+
y: rect?.y ?? 0,
|
|
584
|
+
width: rect?.width ?? 0,
|
|
585
|
+
height: rect?.height ?? 0,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
function normalizeLayer(l) {
|
|
589
|
+
const layer = {
|
|
590
|
+
id: String(l.id ?? l.object_id ?? ""),
|
|
591
|
+
objectId: String(l.object_id ?? ""),
|
|
592
|
+
name: l.name ?? "",
|
|
593
|
+
type: l.type ?? "shape",
|
|
594
|
+
rect: normalizeRect(l.rect),
|
|
595
|
+
visible: l.visible !== false,
|
|
596
|
+
opacity: normalizeOpacity(l.opacity),
|
|
597
|
+
radius: normalizeRadius(l.radius),
|
|
598
|
+
fills: normalizePaints(l.fills),
|
|
599
|
+
borders: normalizeBorders(l.borders),
|
|
600
|
+
images: (l.images ?? []).map((img) => toCdnUrl(typeof img === "string" ? img : img?.url ?? "")),
|
|
601
|
+
// 设计稿自带的 CSS 片段是最可靠的样式来源,原样保留
|
|
602
|
+
css: Array.isArray(l.css) ? l.css.map(String) : typeof l.css === "string" ? [l.css] : undefined,
|
|
603
|
+
};
|
|
604
|
+
if (l.rotation)
|
|
605
|
+
layer.rotation = l.rotation;
|
|
606
|
+
if (l.fillOpacity ?? l.fillopacity)
|
|
607
|
+
layer.fillOpacity = normalizeOpacity(l.fillOpacity ?? l.fillopacity);
|
|
608
|
+
if (l.clipped)
|
|
609
|
+
layer.clipped = true;
|
|
610
|
+
if (l.blendOptions?.mode)
|
|
611
|
+
layer.blendMode = l.blendOptions.mode;
|
|
612
|
+
if (l.path)
|
|
613
|
+
layer.isVector = true;
|
|
614
|
+
// 位图图层:设计稿里是位图/智能对象,没有矢量信息可还原。
|
|
615
|
+
// 实测这类图层只返回盒子尺寸和 css,没有 fills/borders/effects,只能靠切图还原。
|
|
616
|
+
// 三种形态:带 pixels 的位图、图片填充、以及智能对象(smartObject,无 path)。
|
|
617
|
+
const hasPixels = l.pixels !== undefined && l.pixels !== null;
|
|
618
|
+
const hasImageFill = Array.isArray(l.fills) && l.fills.some((f) => f?.fillType === "image");
|
|
619
|
+
const isSmartObject = l.smartObject !== undefined && l.smartObject !== null;
|
|
620
|
+
if (!l.path && (hasPixels || hasImageFill || isSmartObject)) {
|
|
621
|
+
layer.isBitmap = true;
|
|
622
|
+
}
|
|
623
|
+
const effects = normalizeEffects(l.layerEffects);
|
|
624
|
+
if (effects.length)
|
|
625
|
+
layer.effects = effects;
|
|
626
|
+
if (l.type === "text") {
|
|
627
|
+
layer.text = normalizeText(l);
|
|
628
|
+
}
|
|
629
|
+
return layer;
|
|
630
|
+
}
|
|
631
|
+
function normalizeOpacity(v) {
|
|
632
|
+
if (typeof v !== "number" || Number.isNaN(v))
|
|
633
|
+
return undefined;
|
|
634
|
+
const n = v > 1 ? v / 100 : v;
|
|
635
|
+
return Number(Math.max(0, Math.min(1, n)).toFixed(3));
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* 文本样式。
|
|
639
|
+
*
|
|
640
|
+
* 实测要点:
|
|
641
|
+
* - `textStyle.size` 是排版原始字号,会发生缩放;`impliedFontSize` 才是渲染后的实际字号
|
|
642
|
+
* - `fontCaps: "allCaps"` 在当前数据里普遍出现在**越南语/普通文本**上,不是真的大写;
|
|
643
|
+
* CoDesign 的 `css` 字段也不会输出 text-transform,所以不能据此加 `text-transform:uppercase`
|
|
644
|
+
* - `textKey` 才是正文内容
|
|
645
|
+
*/
|
|
646
|
+
function normalizeText(l) {
|
|
647
|
+
const tr = l.text?.textStyleRange?.[0]?.textStyle ?? {};
|
|
648
|
+
const size = numberOr(tr.impliedFontSize, numberOr(l.fontSize, tr.size ?? 0));
|
|
649
|
+
const leading = numberOr(tr.impliedLeading, numberOr(l.lineHeight, tr.leading));
|
|
650
|
+
return {
|
|
651
|
+
content: resolveTextContent(l),
|
|
652
|
+
fontName: String(tr.fontName ?? l.fontFace ?? ""),
|
|
653
|
+
// 字重字段在不同文件里既可能是 "Bold" 也可能是 700,统一成字重值
|
|
654
|
+
fontStyleName: normalizeFontWeight(tr.fontStyleName ?? l.fontWeight),
|
|
655
|
+
size,
|
|
656
|
+
tracking: tr.tracking,
|
|
657
|
+
leading,
|
|
658
|
+
lineHeight: leading,
|
|
659
|
+
letterSpacing: numberOr(l.letterSpacing, undefined),
|
|
660
|
+
color: rgba(tr.color) ?? rgba(l.color),
|
|
661
|
+
textAlign: l.textAlign,
|
|
662
|
+
allCaps: false,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
/** 设计稿的字重可能是数字 700、字符串 "700" 或 "Bold" */
|
|
666
|
+
function normalizeFontWeight(v) {
|
|
667
|
+
if (typeof v === "number" && Number.isFinite(v))
|
|
668
|
+
return String(v);
|
|
669
|
+
if (typeof v === "string") {
|
|
670
|
+
// "Bold" / "Heavy" 这类保留原名,交给后面映射
|
|
671
|
+
return /^\d+$/.test(v.trim()) ? v.trim() : v;
|
|
672
|
+
}
|
|
673
|
+
return "";
|
|
674
|
+
}
|
|
675
|
+
/** textKey 才是正文;图层名只是设计工具里的标签 */
|
|
676
|
+
function resolveTextContent(l) {
|
|
677
|
+
const raw = l.text ?? {};
|
|
678
|
+
const candidates = [
|
|
679
|
+
raw.textKey,
|
|
680
|
+
raw.content,
|
|
681
|
+
raw.characters,
|
|
682
|
+
raw.value,
|
|
683
|
+
l.content,
|
|
684
|
+
];
|
|
685
|
+
for (const c of candidates) {
|
|
686
|
+
if (typeof c === "string" && c.trim())
|
|
687
|
+
return c;
|
|
688
|
+
}
|
|
689
|
+
return l.name ?? "";
|
|
690
|
+
}
|
|
691
|
+
function numberOr(...vals) {
|
|
692
|
+
for (const v of vals) {
|
|
693
|
+
if (typeof v === "number" && Number.isFinite(v))
|
|
694
|
+
return v;
|
|
695
|
+
}
|
|
696
|
+
return undefined;
|
|
697
|
+
}
|
|
698
|
+
function normalizeRadius(r) {
|
|
699
|
+
if (r === undefined || r === null)
|
|
700
|
+
return undefined;
|
|
701
|
+
if (Array.isArray(r))
|
|
702
|
+
return r.map((v) => Number(v) || 0);
|
|
703
|
+
if (typeof r === "number")
|
|
704
|
+
return [r, r, r, r];
|
|
705
|
+
if (typeof r === "object") {
|
|
706
|
+
const vals = [r.topLeft ?? r.tl, r.topRight ?? r.tr, r.bottomRight ?? r.br, r.bottomLeft ?? r.bl];
|
|
707
|
+
if (vals.some((v) => typeof v === "number"))
|
|
708
|
+
return vals.map((v) => Number(v) || 0);
|
|
709
|
+
}
|
|
710
|
+
return undefined;
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* fills/borders 都是 { fillType, color, ... }。
|
|
714
|
+
* `fillType: "gradient"` 时颜色在 gradient 里,直接读 color 会拿到 undefined,
|
|
715
|
+
* 所以保留整个对象交给上层处理。
|
|
716
|
+
*/
|
|
717
|
+
function normalizePaints(paints) {
|
|
718
|
+
if (!Array.isArray(paints) || !paints.length)
|
|
719
|
+
return undefined;
|
|
720
|
+
return paints.map((p) => ({
|
|
721
|
+
type: p.fillType ?? "",
|
|
722
|
+
color: rgba(p.color),
|
|
723
|
+
css: paintToCss(p),
|
|
724
|
+
}));
|
|
725
|
+
}
|
|
726
|
+
/** 边框额外带 thickness / position,之前只取了颜色 */
|
|
727
|
+
function normalizeBorders(borders) {
|
|
728
|
+
if (!Array.isArray(borders) || !borders.length)
|
|
729
|
+
return undefined;
|
|
730
|
+
return borders.map((b) => ({
|
|
731
|
+
type: b.fillType ?? "",
|
|
732
|
+
color: rgba(b.color),
|
|
733
|
+
css: paintToCss(b),
|
|
734
|
+
width: numberOr(b.thickness, b.size),
|
|
735
|
+
style: b.position === "inside" ? "inset" : undefined,
|
|
736
|
+
position: b.position,
|
|
737
|
+
}));
|
|
738
|
+
}
|
|
739
|
+
/** 渐变等非纯色填充,尽量还原成 CSS background 值 */
|
|
740
|
+
function paintToCss(p) {
|
|
741
|
+
if (typeof p.css === "string")
|
|
742
|
+
return String(p.css).replace(/^background:\s*/, "");
|
|
743
|
+
const g = p.gradient;
|
|
744
|
+
if (!g)
|
|
745
|
+
return undefined;
|
|
746
|
+
if (typeof g === "string")
|
|
747
|
+
return g;
|
|
748
|
+
if (Array.isArray(g.stops)) {
|
|
749
|
+
const angle = Math.round(g.angle ?? 180);
|
|
750
|
+
const stops = g.stops
|
|
751
|
+
.map((s) => `${rgba(s.color) ?? "transparent"} ${Math.round((s.location ?? 0) * 100)}%`)
|
|
752
|
+
.join(", ");
|
|
753
|
+
return `linear-gradient(${angle}deg, ${stops})`;
|
|
754
|
+
}
|
|
755
|
+
return undefined;
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* layerEffects 是 Photoshop 风格的对象:
|
|
759
|
+
* `{ innerGlow: {...}, frameFX: {...} }`,key 就是效果类型。
|
|
760
|
+
* 目前只把能可靠映射成 CSS 的效果转出来,其余原样保留。
|
|
761
|
+
*/
|
|
762
|
+
function normalizeEffects(effects) {
|
|
763
|
+
if (!effects || typeof effects !== "object")
|
|
764
|
+
return [];
|
|
765
|
+
const out = [];
|
|
766
|
+
for (const [type, cfg] of Object.entries(effects)) {
|
|
767
|
+
if (!cfg || typeof cfg !== "object")
|
|
768
|
+
continue;
|
|
769
|
+
out.push({ type, ...cfg });
|
|
770
|
+
}
|
|
771
|
+
return out;
|
|
772
|
+
}
|
|
773
|
+
//# sourceMappingURL=client.js.map
|