@templmf/temp-solf-lmf 0.0.161 → 0.0.162
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/package.json +1 -1
- package/server/env.example +13 -0
- package/server/package-lock.json +861 -0
- package/server/package.json +17 -0
- package/server/public/index.html +683 -0
- package/server/src/index.js +269 -0
- package/server/src/opencodeClient.js +40 -0
- package/server/src/ownership.js +60 -0
- package/SKILL.md +0 -93
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import "dotenv/config"; // 必须放在最前面:opencodeClient.js 在被 import 时就会读取 process.env
|
|
2
|
+
import express from "express";
|
|
3
|
+
import cookieParser from "cookie-parser";
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { opencode, opencodeBaseUrl, opencodeAuthHeaders } from "./opencodeClient.js";
|
|
8
|
+
import { ownership } from "./ownership.js";
|
|
9
|
+
|
|
10
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const PORT = process.env.PORT || 3000;
|
|
12
|
+
const COOKIE_NAME = "oc_uid";
|
|
13
|
+
|
|
14
|
+
const app = express();
|
|
15
|
+
app.use(express.json());
|
|
16
|
+
app.use(cookieParser(process.env.COOKIE_SECRET || "dev-secret"));
|
|
17
|
+
app.use(express.static(path.join(__dirname, "..", "public")));
|
|
18
|
+
|
|
19
|
+
/* ---------------------------------------------------------------------- *
|
|
20
|
+
* 1. 用户识别
|
|
21
|
+
* 这是一个演示,用最简单的方式给每个浏览器发一个匿名用户 id(签名 cookie)。
|
|
22
|
+
* 真实项目里把这一步换成你现有的登录体系即可,
|
|
23
|
+
* 后面所有隔离逻辑只依赖 req.userId 这一个字段。
|
|
24
|
+
* ---------------------------------------------------------------------- */
|
|
25
|
+
app.use((req, res, next) => {
|
|
26
|
+
let uid = req.signedCookies[COOKIE_NAME];
|
|
27
|
+
if (!uid) {
|
|
28
|
+
uid = crypto.randomUUID();
|
|
29
|
+
res.cookie(COOKIE_NAME, uid, {
|
|
30
|
+
httpOnly: true,
|
|
31
|
+
signed: true,
|
|
32
|
+
sameSite: "lax",
|
|
33
|
+
maxAge: 1000 * 60 * 60 * 24 * 30,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
req.userId = uid;
|
|
37
|
+
next();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
app.get("/api/me", (req, res) => {
|
|
41
|
+
res.json({ userId: req.userId, opencodeServer: opencodeBaseUrl });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// 可用的 provider / model 列表——这是全局配置,不涉及用户隔离,所有用户共享同一份
|
|
45
|
+
app.get("/api/providers", async (req, res) => {
|
|
46
|
+
try {
|
|
47
|
+
const { providers, default: defaults } = await opencode.config.providers();
|
|
48
|
+
res.json({ providers, defaults });
|
|
49
|
+
} catch (err) {
|
|
50
|
+
console.error(err);
|
|
51
|
+
res.status(500).json({ error: "获取模型列表失败" });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/* ---------------------------------------------------------------------- *
|
|
56
|
+
* 2. session 归属校验中间件
|
|
57
|
+
* 凡是带 :id 的路由,先确认这个 opencode sessionId 是不是当前用户创建的,
|
|
58
|
+
* 不是的话直接 403 —— 这就是"同一个 opencode server,session 互不可见"的
|
|
59
|
+
* 真正落地位置。opencode server 自己没有用户概念,隔离必须在网关层做。
|
|
60
|
+
* ---------------------------------------------------------------------- */
|
|
61
|
+
function requireOwnedSession(req, res, next) {
|
|
62
|
+
const { id } = req.params;
|
|
63
|
+
if (!ownership.owns(req.userId, id)) {
|
|
64
|
+
return res.status(403).json({ error: "无权访问该 session(不属于当前用户)" });
|
|
65
|
+
}
|
|
66
|
+
next();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* ---------------------------------------------------------------------- *
|
|
70
|
+
* 3. Session 相关接口
|
|
71
|
+
* ---------------------------------------------------------------------- */
|
|
72
|
+
|
|
73
|
+
// 列出当前用户自己的 session(不是 opencode server 上的全部 session)
|
|
74
|
+
app.get("/api/sessions", async (req, res) => {
|
|
75
|
+
try {
|
|
76
|
+
const ownedIds = new Set(ownership.listOwnedIds(req.userId));
|
|
77
|
+
if (ownedIds.size === 0) return res.json([]);
|
|
78
|
+
|
|
79
|
+
const all = await opencode.session.list();
|
|
80
|
+
const mine = all.filter((s) => ownedIds.has(s.id));
|
|
81
|
+
res.json(mine);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
console.error(err);
|
|
84
|
+
res.status(500).json({ error: "获取 session 列表失败" });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// 新建 session,并把归属记录在网关这一侧
|
|
89
|
+
app.post("/api/sessions", async (req, res) => {
|
|
90
|
+
try {
|
|
91
|
+
const title = req.body?.title || "新会话";
|
|
92
|
+
const session = await opencode.session.create({ body: { title } });
|
|
93
|
+
ownership.claim(req.userId, session.id);
|
|
94
|
+
res.status(201).json(session);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error(err);
|
|
97
|
+
res.status(500).json({ error: "创建 session 失败" });
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// 查看某个 session 的历史消息
|
|
102
|
+
app.get("/api/sessions/:id/messages", requireOwnedSession, async (req, res) => {
|
|
103
|
+
try {
|
|
104
|
+
const messages = await opencode.session.messages({ path: { id: req.params.id } });
|
|
105
|
+
res.json(messages);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.error(err);
|
|
108
|
+
res.status(500).json({ error: "获取消息失败" });
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// 发送一条 prompt,等待 opencode 的完整回复
|
|
113
|
+
app.post("/api/sessions/:id/prompt", requireOwnedSession, async (req, res) => {
|
|
114
|
+
try {
|
|
115
|
+
const { text, providerID, modelID } = req.body;
|
|
116
|
+
const body = { parts: [{ type: "text", text }] };
|
|
117
|
+
if (providerID && modelID) body.model = { providerID, modelID };
|
|
118
|
+
|
|
119
|
+
const reply = await opencode.session.prompt({
|
|
120
|
+
path: { id: req.params.id },
|
|
121
|
+
body,
|
|
122
|
+
});
|
|
123
|
+
res.json(reply);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
console.error(err);
|
|
126
|
+
res.status(500).json({ error: "发送消息失败" });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// 中止正在进行的生成(对应 opencode SDK 的 session.abort)
|
|
131
|
+
app.post("/api/sessions/:id/abort", requireOwnedSession, async (req, res) => {
|
|
132
|
+
try {
|
|
133
|
+
const ok = await opencode.session.abort({ path: { id: req.params.id } });
|
|
134
|
+
res.json({ ok });
|
|
135
|
+
} catch (err) {
|
|
136
|
+
console.error(err);
|
|
137
|
+
res.status(500).json({ error: "中止会话失败" });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// 删除 session
|
|
142
|
+
app.delete("/api/sessions/:id", requireOwnedSession, async (req, res) => {
|
|
143
|
+
try {
|
|
144
|
+
await opencode.session.delete({ path: { id: req.params.id } });
|
|
145
|
+
ownership.release(req.userId, req.params.id);
|
|
146
|
+
res.json({ ok: true });
|
|
147
|
+
} catch (err) {
|
|
148
|
+
console.error(err);
|
|
149
|
+
res.status(500).json({ error: "删除 session 失败" });
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
/* ---------------------------------------------------------------------- *
|
|
154
|
+
* 4. 事件流(SSE)
|
|
155
|
+
* opencode server 只对外暴露一条"全局"事件流,里面混着所有用户、所有
|
|
156
|
+
* session 的事件。网关订阅这一条全局流一次,然后按 sessionId 找到归属
|
|
157
|
+
* 用户,只广播给对应用户自己的浏览器连接,做到互不可见。
|
|
158
|
+
* ---------------------------------------------------------------------- */
|
|
159
|
+
|
|
160
|
+
/** userId -> Set<res>,同一个用户可能开多个标签页/多条连接 */
|
|
161
|
+
const userConnections = new Map();
|
|
162
|
+
|
|
163
|
+
function sendToUser(userId, event) {
|
|
164
|
+
const conns = userConnections.get(userId);
|
|
165
|
+
if (!conns) return;
|
|
166
|
+
const payload = `data: ${JSON.stringify(event)}\n\n`;
|
|
167
|
+
for (const res of conns) res.write(payload);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 从 opencode 事件里尽量取出 sessionId(不同事件类型字段名可能不同,按需扩展) */
|
|
171
|
+
function extractSessionId(event) {
|
|
172
|
+
const p = event.properties || {};
|
|
173
|
+
return (
|
|
174
|
+
p.sessionID ||
|
|
175
|
+
p.sessionId ||
|
|
176
|
+
p.info?.sessionID ||
|
|
177
|
+
p.part?.sessionID ||
|
|
178
|
+
p.session?.id ||
|
|
179
|
+
null
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function startGlobalEventBridge() {
|
|
184
|
+
const debug = true; // 排查阶段先常开,确认没问题后可以改成按需开关
|
|
185
|
+
|
|
186
|
+
while (true) {
|
|
187
|
+
try {
|
|
188
|
+
const res = await fetch(`${opencodeBaseUrl}/event`, {
|
|
189
|
+
headers: { ...opencodeAuthHeaders },
|
|
190
|
+
});
|
|
191
|
+
if (!res.ok || !res.body) {
|
|
192
|
+
throw new Error(`订阅事件流失败: HTTP ${res.status}`);
|
|
193
|
+
}
|
|
194
|
+
console.log("[opencode] 已连接全局事件流(手写 SSE 解析)");
|
|
195
|
+
|
|
196
|
+
const reader = res.body.getReader();
|
|
197
|
+
const decoder = new TextDecoder();
|
|
198
|
+
let buffer = "";
|
|
199
|
+
|
|
200
|
+
while (true) {
|
|
201
|
+
const { value, done } = await reader.read();
|
|
202
|
+
if (done) break;
|
|
203
|
+
buffer += decoder.decode(value, { stream: true });
|
|
204
|
+
|
|
205
|
+
// SSE 帧以空行(\n\n)分隔,一帧里可能有多行,我们只关心 "data:" 那一行
|
|
206
|
+
let sepIndex;
|
|
207
|
+
while ((sepIndex = buffer.indexOf("\n\n")) !== -1) {
|
|
208
|
+
const frame = buffer.slice(0, sepIndex);
|
|
209
|
+
buffer = buffer.slice(sepIndex + 2);
|
|
210
|
+
|
|
211
|
+
const dataLine = frame.split("\n").find((l) => l.startsWith("data:"));
|
|
212
|
+
if (!dataLine) continue;
|
|
213
|
+
const jsonText = dataLine.slice(5).trim();
|
|
214
|
+
if (!jsonText) continue;
|
|
215
|
+
|
|
216
|
+
let event;
|
|
217
|
+
try {
|
|
218
|
+
event = JSON.parse(jsonText);
|
|
219
|
+
} catch {
|
|
220
|
+
if (debug) console.log(`[opencode:raw] 无法解析为 JSON: ${jsonText}`);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (debug) console.log(`[opencode:raw] ${jsonText}`);
|
|
225
|
+
|
|
226
|
+
const sessionId = extractSessionId(event);
|
|
227
|
+
if (!sessionId) continue; // 非 session 相关事件(如心跳)忽略
|
|
228
|
+
const ownerId = ownership.findOwner(sessionId);
|
|
229
|
+
if (debug) {
|
|
230
|
+
console.log(
|
|
231
|
+
`[opencode:route] session=${sessionId} owner=${ownerId ?? "(无归属记录,事件将被丢弃)"}`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
if (ownerId) sendToUser(ownerId, event);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
console.warn("[opencode] 事件流被服务端关闭,3 秒后重连");
|
|
238
|
+
} catch (err) {
|
|
239
|
+
console.error("[opencode] 事件流出错,3 秒后重连:", err.message);
|
|
240
|
+
}
|
|
241
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
startGlobalEventBridge();
|
|
245
|
+
|
|
246
|
+
app.get("/api/events", (req, res) => {
|
|
247
|
+
req.socket.setNoDelay(true); // 关键:SSE 都是频繁的小包,Nagle 算法会把它们攒在一起延迟发送
|
|
248
|
+
res.set({
|
|
249
|
+
"Content-Type": "text/event-stream",
|
|
250
|
+
"Cache-Control": "no-cache",
|
|
251
|
+
Connection: "keep-alive",
|
|
252
|
+
});
|
|
253
|
+
res.flushHeaders();
|
|
254
|
+
|
|
255
|
+
const userId = req.userId;
|
|
256
|
+
if (!userConnections.has(userId)) userConnections.set(userId, new Set());
|
|
257
|
+
userConnections.get(userId).add(res);
|
|
258
|
+
|
|
259
|
+
res.write(`data: ${JSON.stringify({ type: "connected" })}\n\n`);
|
|
260
|
+
|
|
261
|
+
req.on("close", () => {
|
|
262
|
+
userConnections.get(userId)?.delete(res);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
app.listen(PORT, () => {
|
|
267
|
+
console.log(`网关已启动: http://localhost:${PORT}`);
|
|
268
|
+
console.log(`共用的 opencode server: ${opencodeBaseUrl}`);
|
|
269
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createOpencodeClient } from "@opencode-ai/sdk";
|
|
2
|
+
|
|
3
|
+
const baseUrl = process.env.OPENCODE_SERVER_URL || "http://127.0.0.1:4096";
|
|
4
|
+
const username = process.env.OPENCODE_SERVER_USERNAME;
|
|
5
|
+
const password = process.env.OPENCODE_SERVER_PASSWORD;
|
|
6
|
+
|
|
7
|
+
// 整个网关进程只建立一个到 opencode server 的连接。
|
|
8
|
+
// 多用户共享这一个连接,隔离逻辑完全在网关层(ownership store)里做,
|
|
9
|
+
// opencode server 本身并不知道"用户"这个概念。
|
|
10
|
+
const fetchOptions = {};
|
|
11
|
+
if (password) {
|
|
12
|
+
const basic = Buffer.from(`${username || "opencode"}:${password}`).toString("base64");
|
|
13
|
+
fetchOptions.headers = { Authorization: `Basic ${basic}` };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const opencode = createOpencodeClient({
|
|
17
|
+
baseUrl,
|
|
18
|
+
// data 模式:方法直接返回数据本身,出错时抛异常,方便 try/catch
|
|
19
|
+
responseStyle: "data",
|
|
20
|
+
throwOnError: true,
|
|
21
|
+
fetch: async (input, init) => {
|
|
22
|
+
const res = await fetch(input, {
|
|
23
|
+
...init,
|
|
24
|
+
headers: { ...(init && init.headers), ...fetchOptions.headers },
|
|
25
|
+
});
|
|
26
|
+
if (!res.ok) {
|
|
27
|
+
// 克隆一份用于打印,避免 body 被消费掉导致 SDK 那边读不到
|
|
28
|
+
const cloned = res.clone();
|
|
29
|
+
const text = await cloned.text().catch(() => "<无法读取 body>");
|
|
30
|
+
console.error(
|
|
31
|
+
`[opencode] ${init?.method || "GET"} ${input} -> ${res.status} ${res.statusText}\n` +
|
|
32
|
+
`响应体: ${text}`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return res;
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export const opencodeAuthHeaders = fetchOptions.headers || {};
|
|
40
|
+
export { baseUrl as opencodeBaseUrl };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const DATA_DIR = path.join(__dirname, "..", "data");
|
|
7
|
+
const DATA_FILE = path.join(DATA_DIR, "ownership.json");
|
|
8
|
+
|
|
9
|
+
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
10
|
+
if (!fs.existsSync(DATA_FILE)) fs.writeFileSync(DATA_FILE, "{}");
|
|
11
|
+
|
|
12
|
+
/** @type {Record<string, {sessionIds: string[], name?: string}>} */
|
|
13
|
+
let db = JSON.parse(fs.readFileSync(DATA_FILE, "utf-8"));
|
|
14
|
+
|
|
15
|
+
function persist() {
|
|
16
|
+
fs.writeFileSync(DATA_FILE, JSON.stringify(db, null, 2));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function ensureUser(userId) {
|
|
20
|
+
if (!db[userId]) db[userId] = { sessionIds: [] };
|
|
21
|
+
return db[userId];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const ownership = {
|
|
25
|
+
/** 记录:某个 opencode sessionId 属于某个 userId */
|
|
26
|
+
claim(userId, sessionId) {
|
|
27
|
+
const u = ensureUser(userId);
|
|
28
|
+
if (!u.sessionIds.includes(sessionId)) {
|
|
29
|
+
u.sessionIds.push(sessionId);
|
|
30
|
+
persist();
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
/** 判断某个 session 是否属于该用户 —— 所有跨 session 的访问都要过这一关 */
|
|
35
|
+
owns(userId, sessionId) {
|
|
36
|
+
const u = db[userId];
|
|
37
|
+
return !!u && u.sessionIds.includes(sessionId);
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
/** 该用户拥有的全部 sessionId */
|
|
41
|
+
listOwnedIds(userId) {
|
|
42
|
+
return db[userId]?.sessionIds ?? [];
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
/** session 删除后同步移除归属记录 */
|
|
46
|
+
release(userId, sessionId) {
|
|
47
|
+
const u = db[userId];
|
|
48
|
+
if (!u) return;
|
|
49
|
+
u.sessionIds = u.sessionIds.filter((id) => id !== sessionId);
|
|
50
|
+
persist();
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
/** 反查某个 sessionId 属于哪个 userId(SSE 广播过滤时要用) */
|
|
54
|
+
findOwner(sessionId) {
|
|
55
|
+
for (const [userId, u] of Object.entries(db)) {
|
|
56
|
+
if (u.sessionIds.includes(sessionId)) return userId;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
},
|
|
60
|
+
};
|
package/SKILL.md
DELETED
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: figma-to-css
|
|
3
|
-
description: 将 Figma 节点数据(通过 Figma MCP 获取)转换为 CSS/HTML 时应遵循的规则。当用户提供 Figma 节点 JSON(fills、size、padding、cornerRadius、effects、layoutMode 等字段),要求还原为网页样式、生成 HTML/CSS 代码、或将设计稿转换为前端代码时触发此技能。
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Figma 节点 → CSS 转换规则
|
|
7
|
-
|
|
8
|
-
本技能定义了将 Figma MCP 返回的节点数据字段,映射为 CSS 样式的标准规则。转换代码或手动还原样式时,严格按以下规则执行,不要引入未在规则中说明的默认值或推测行为。
|
|
9
|
-
|
|
10
|
-
## 1. 填充 Fill(`fills` 字段)
|
|
11
|
-
|
|
12
|
-
- **非文本节点**:`fills` 代表背景,可能是 image、gradient 或纯色(color)
|
|
13
|
-
- 转换为对应的 `background` / `background-image` / `background-color`
|
|
14
|
-
- 若 `fills` 为空数组,**不要**设置任何背景色,不能自动补默认背景
|
|
15
|
-
- 若填充类型为 image,额外设置 `background-size: contain`
|
|
16
|
-
- **文本节点**:`fills` 代表文字颜色,转换为 `color`
|
|
17
|
-
|
|
18
|
-
## 2. 盒模型(Box Model)
|
|
19
|
-
|
|
20
|
-
- 严格遵守 `size` 字段还原元素的 `width` / `height`
|
|
21
|
-
- 严格遵守 `paddingLeft`、`paddingRight`、`paddingTop`、`paddingBottom`,一一对应转换为 CSS 的 `padding-left` / `padding-right` / `padding-top` / `padding-bottom`
|
|
22
|
-
- 不要用简写 `padding` 合并四个方向导致精度丢失,除非四个值恰好相等
|
|
23
|
-
|
|
24
|
-
## 3. 圆角(Corner Radius)
|
|
25
|
-
|
|
26
|
-
- `cornerRadius`(单一数值)→ `border-radius`(统一四角)
|
|
27
|
-
- `rectangleCornerRadii`(数组,顺序为 `[topLeft, topRight, bottomRight, bottomLeft]`)→ 按位顺序直接映射:
|
|
28
|
-
```css
|
|
29
|
-
border-radius: {topLeft} {topRight} {bottomRight} {bottomLeft};
|
|
30
|
-
```
|
|
31
|
-
该数组顺序与 CSS `border-radius` 简写的顺时针顺序一致,无需重新排序
|
|
32
|
-
|
|
33
|
-
## 4. 阴影(Shadow)
|
|
34
|
-
|
|
35
|
-
- 遍历 `effects` 字段,取其中 `type === 'DROP_SHADOW'` 的项
|
|
36
|
-
- 转换为 CSS `box-shadow`,映射关系:
|
|
37
|
-
- `offset.x` → `box-shadow` 第一个长度值
|
|
38
|
-
- `offset.y` → `box-shadow` 第二个长度值
|
|
39
|
-
- `radius` → 模糊半径(第三个值)
|
|
40
|
-
- `spread`(若存在)→ 扩散半径(第四个值)
|
|
41
|
-
- `color`(含透明度)→ 阴影颜色
|
|
42
|
-
- 若存在多个 `DROP_SHADOW`,用逗号拼接为多重阴影
|
|
43
|
-
|
|
44
|
-
## 5. 布局方式(优先级从高到低)
|
|
45
|
-
|
|
46
|
-
### 5.1 绝对定位优先
|
|
47
|
-
- 若节点 `layoutPositioning === 'ABSOLUTE'`:
|
|
48
|
-
- 该节点使用 `position: absolute`
|
|
49
|
-
- 其父节点必须设置 `position: relative`(作为定位上下文)
|
|
50
|
-
|
|
51
|
-
### 5.2 无 `layoutMode`:自然布局
|
|
52
|
-
- 节点不存在 `layoutMode` 字段时,使用自然文档流:
|
|
53
|
-
- 若内部元素为**横向排列**:子元素使用 `display: inline-block` + `vertical-align`(对齐方式),元素间距使用 `margin`(如 `margin-right`)模拟,不使用 `gap`
|
|
54
|
-
- 若内部元素为**纵向排列**:使用默认块级文档流(块级元素天然纵向堆叠),无需额外设置 display
|
|
55
|
-
|
|
56
|
-
### 5.3 存在 `layoutMode`:Flex 布局
|
|
57
|
-
- 节点存在 `layoutMode` 字段时,使用 `display: flex`:
|
|
58
|
-
- `layoutMode: HORIZONTAL` → `flex-direction: row`
|
|
59
|
-
- `layoutMode: VERTICAL` → `flex-direction: column`
|
|
60
|
-
- `itemSpacing` → `gap`
|
|
61
|
-
- `primaryAxisAlignItems` → `justify-content`(主轴对齐):
|
|
62
|
-
| Figma 值 | CSS 值 |
|
|
63
|
-
|---|---|
|
|
64
|
-
| MIN | flex-start |
|
|
65
|
-
| CENTER | center |
|
|
66
|
-
| MAX | flex-end |
|
|
67
|
-
| SPACE_BETWEEN | space-between |
|
|
68
|
-
- `counterAxisAlignItems` → `align-items`(交叉轴对齐):
|
|
69
|
-
| Figma 值 | CSS 值 |
|
|
70
|
-
|---|---|
|
|
71
|
-
| MIN | flex-start |
|
|
72
|
-
| CENTER | center |
|
|
73
|
-
| MAX | flex-end |
|
|
74
|
-
| BASELINE | baseline |
|
|
75
|
-
|
|
76
|
-
## 6. 特殊节点类型
|
|
77
|
-
|
|
78
|
-
- `type === 'VECTOR'` 的节点,不做 SVG 内联转换,统一转换为图片引用方式:
|
|
79
|
-
```html
|
|
80
|
-
<img src="xxx.svg" />
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
## 转换检查清单
|
|
84
|
-
|
|
85
|
-
处理每个节点时,按以下顺序检查并应用规则:
|
|
86
|
-
|
|
87
|
-
1. 是否 `layoutPositioning === ABSOLUTE`?→ 定位方式
|
|
88
|
-
2. 节点类型是否为 `VECTOR`?→ 转 `<img>` 引用
|
|
89
|
-
3. 是否有 `fills`?→ 区分文本/非文本节点,转背景或颜色
|
|
90
|
-
4. `size` / `paddingLeft` / `paddingRight` / `paddingTop` / `paddingBottom` → 盒模型
|
|
91
|
-
5. `cornerRadius` / `rectangleCornerRadii` → 圆角
|
|
92
|
-
6. `effects` 中的 `DROP_SHADOW` → 阴影
|
|
93
|
-
7. 是否有 `layoutMode`?→ 决定用 flex 还是自然布局,并处理对齐与间距
|