@zzclub/pipeline 0.13.2 → 0.16.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.
Files changed (60) hide show
  1. package/ARTICLE-THEMES.md +86 -0
  2. package/LICENSE +21 -0
  3. package/README.md +182 -788
  4. package/THIRD-PARTY-NOTICES.md +25 -0
  5. package/package.json +6 -2
  6. package/src/adapter-types.ts +3 -0
  7. package/src/adapters/builtin-markdown-renderer.ts +1 -0
  8. package/src/article-theme.test.ts +87 -0
  9. package/src/article-theme.ts +115 -0
  10. package/src/commands/abandon.test.ts +1 -1
  11. package/src/commands/article-theme.ts +16 -0
  12. package/src/commands/checkpoint.test.ts +2 -2
  13. package/src/commands/ingest-handoff.ts +6 -0
  14. package/src/commands/init.ts +10 -0
  15. package/src/commands/prepare.ts +5 -1
  16. package/src/commands/publish.ts +4 -0
  17. package/src/commands/reset.ts +2 -0
  18. package/src/commands/wechat-export.ts +4 -0
  19. package/src/commands/wechat-preview.ts +35 -0
  20. package/src/plugins.ts +2 -0
  21. package/src/providers/index.ts +17 -2
  22. package/src/providers/publish-core.ts +32 -1
  23. package/src/providers/wechat.test.ts +12 -0
  24. package/src/providers/wechat.ts +60 -39
  25. package/src/render-branding.ts +1 -1
  26. package/src/schema/config.ts +12 -0
  27. package/src/schema/state.ts +5 -0
  28. package/src/wechat-preview/assets/browser-dist/.vite/manifest.json +21 -1
  29. package/src/wechat-preview/assets/browser-dist/assets/style-C9-YtTIm.css +1 -0
  30. package/src/wechat-preview/assets/browser-dist/assets/wechat-renderer-D1jHoaM2.js +624 -0
  31. package/src/wechat-preview/assets/browser-dist/editor-export.js +1 -512
  32. package/src/wechat-preview/assets/browser-dist/src/wechat-preview/browser/studio/studio.html +293 -0
  33. package/src/wechat-preview/assets/browser-dist/studio.js +332 -0
  34. package/src/wechat-preview/browser/editor-export.ts +17 -2
  35. package/src/wechat-preview/browser/highlight.ts +2 -0
  36. package/src/wechat-preview/browser/studio/color-palettes.test.ts +31 -0
  37. package/src/wechat-preview/browser/studio/color-palettes.ts +115 -0
  38. package/src/wechat-preview/browser/studio/studio.css +638 -0
  39. package/src/wechat-preview/browser/studio/studio.html +292 -0
  40. package/src/wechat-preview/browser/studio/studio.ts +721 -0
  41. package/src/wechat-preview/browser/studio/syntax-presets.test.ts +74 -0
  42. package/src/wechat-preview/browser/studio/syntax-presets.ts +590 -0
  43. package/src/wechat-preview/fixtures/all-nodes.md +38 -0
  44. package/src/wechat-preview/index.ts +36 -5
  45. package/src/wechat-preview/plugins/builtin-badge.ts +152 -0
  46. package/src/wechat-preview/plugins/builtin-callout.ts +261 -0
  47. package/src/wechat-preview/plugins/builtin-highlight.ts +46 -0
  48. package/src/wechat-preview/plugins/builtin-kbd.ts +133 -0
  49. package/src/wechat-preview/plugins/index.test.ts +313 -0
  50. package/src/wechat-preview/plugins/index.ts +6 -0
  51. package/src/wechat-preview/plugins/registry.ts +62 -0
  52. package/src/wechat-preview/plugins/types.ts +31 -0
  53. package/src/wechat-preview/server/http.ts +160 -3
  54. package/src/wechat-preview/server/paths.ts +2 -2
  55. package/src/wechat-preview/server/singleton.ts +14 -12
  56. package/src/wechat-preview/server/studio.test.ts +92 -0
  57. package/src/wechat-preview/server/ui.ts +1 -0
  58. package/src/wechat-preview/wechat-export.integration.test.ts +9 -1
  59. package/src/wechat-preview/wechat-renderer.ts +72 -7
  60. package/src/workflow.test.ts +1 -0
@@ -8,6 +8,9 @@ import type { PipelineConfig, ResolvedWorkspacePaths } from "../config";
8
8
  import type { PublishResult, PublishTarget, WorkflowState } from "../state";
9
9
  import { getPublishProvider, type PublishRouteContext } from "./index";
10
10
  import { reportProgress } from "../monitor/recorder";
11
+ import { join } from "node:path";
12
+ import { resolveConfigRelativePath } from "../config";
13
+ import { readArticleTheme, snapshotArticleTheme, type ArticleThemePackage } from "../article-theme";
11
14
 
12
15
  export interface PublishTargetError {
13
16
  route: string;
@@ -54,6 +57,7 @@ export function filterIdempotent(
54
57
  existingResults: PublishResult[],
55
58
  contentVersion: number,
56
59
  renderVersion: number,
60
+ themeHashes?: Map<string, string | null>,
57
61
  ): PublishTarget[] {
58
62
  return targets.filter((target) => {
59
63
  const existing = existingResults.find(
@@ -64,7 +68,8 @@ export function filterIdempotent(
64
68
  r.content_version === contentVersion &&
65
69
  r.render_version === renderVersion,
66
70
  );
67
- return !existing;
71
+ // 模板变化必须重新导出;旧结果无模板时继续兼容。
72
+ return !existing || (themeHashes && (existing.article_theme_hash ?? null) !== (themeHashes.get(`${target.route}@${target.account}`) ?? null));
68
73
  });
69
74
  }
70
75
 
@@ -92,11 +97,32 @@ export async function executePublishTargets(
92
97
  const { state, targets, dryRun, config, workspacePaths, onResult } = params;
93
98
 
94
99
  const deduped = dedupeTargets(targets);
100
+ const themes = new Map<string, ArticleThemePackage>();
101
+ const themeHashes = new Map<string, string | null>();
102
+ const themeErrors = new Map<string, unknown>();
103
+ for (const target of deduped) {
104
+ if (target.route !== "wechat-article") continue;
105
+ const account = config.wx.accounts[target.account] ?? config.wx.accounts[config.wx.defaultAccount];
106
+ const selected = state.intent.article_theme || resolveConfigRelativePath(account?.articleTheme);
107
+ if (!selected) continue;
108
+ const key = `${target.route}@${target.account}`;
109
+ try {
110
+ const source = await readArticleTheme(selected);
111
+ const theme = dryRun ? source : await snapshotArticleTheme(source, join(state.workspace_root, ".zzhub-media", "article-themes"));
112
+ themes.set(key, theme);
113
+ themeHashes.set(key, theme.hash);
114
+ } catch (error) {
115
+ // 一个账号的模板损坏不阻断其他发布目标,结果仍按原契约逐个记录。
116
+ themeHashes.set(key, "invalid");
117
+ themeErrors.set(key, error);
118
+ }
119
+ }
95
120
  const filtered = filterIdempotent(
96
121
  deduped,
97
122
  state.publish.results,
98
123
  state.artifacts.content_version,
99
124
  state.artifacts.render_version,
125
+ themeHashes,
100
126
  );
101
127
 
102
128
  const results: PublishResult[] = [];
@@ -106,6 +132,9 @@ export async function executePublishTargets(
106
132
  reportProgress({ stage: "publish.targets", message: "正在发布", current: results.length, total: filtered.length, unit: "targets", route: target.route, account: target.account });
107
133
  let result: PublishResult;
108
134
  try {
135
+ const previous = state.publish.results.find(item => item.route === target.route && item.account === target.account);
136
+ if (previous?.detail?.includes("WX_RESULT_UNKNOWN")) throw new Error("WX_RESULT_UNKNOWN: 上次发送结果未知,请检查草稿箱后再通过 reset --mode publish 明确重试");
137
+ if (themeErrors.has(`${target.route}@${target.account}`)) throw themeErrors.get(`${target.route}@${target.account}`);
109
138
  const provider = getPublishProvider(target.route);
110
139
  const ctx: PublishRouteContext = {
111
140
  state,
@@ -113,6 +142,7 @@ export async function executePublishTargets(
113
142
  config,
114
143
  workspacePaths,
115
144
  accountOverride: target.account,
145
+ articleTheme: themes.get(`${target.route}@${target.account}`),
116
146
  };
117
147
  const providerResult = await provider(ctx);
118
148
  result = {
@@ -121,6 +151,7 @@ export async function executePublishTargets(
121
151
  account: target.account,
122
152
  content_version: state.artifacts.content_version,
123
153
  render_version: state.artifacts.render_version,
154
+ article_theme_hash: themeHashes.get(`${target.route}@${target.account}`) ?? null,
124
155
  };
125
156
  } catch (err) {
126
157
  const error: PublishTargetError = {
@@ -10,8 +10,13 @@ import {
10
10
  resolveFilenameFromUrl,
11
11
  resolveDraftMediaId,
12
12
  readResponseBodyWithLimit,
13
+ createWechatNewspic,
13
14
  } from "./wechat";
14
15
 
16
+ test("贴图配文按 UTF-8 长度校验,上传前拒绝超限内容", async () => {
17
+ await expect(createWechatNewspic({ content: "😀".repeat(512) + "a" } as any)).rejects.toThrow("贴图配文过长");
18
+ });
19
+
15
20
  describe("resolveDraftMediaId", () => {
16
21
  test("extracts media_id from a successful draft response", () => {
17
22
  expect(resolveDraftMediaId({ code: 0, data: { media_id: "media-1" } }))
@@ -193,6 +198,13 @@ describe("extractImageUrls", () => {
193
198
  });
194
199
 
195
200
  describe("replaceImageUrls", () => {
201
+ test("replaces large Base64 sources and preserves literal replacement URLs", () => {
202
+ const src = `data:image/png;base64,${"A".repeat(3_400_000)}`;
203
+ const url = "https://example.com/image.png?value=$&";
204
+ expect(replaceImageUrls(`<img src="${src}" data-src="${src}">`, { [src]: url }))
205
+ .toBe(`<img src="${url}" data-src="${url}">`);
206
+ });
207
+
196
208
  test("replaces URLs using the mapping", () => {
197
209
  const html = '<img src="https://old.com/img.png">';
198
210
  const result = replaceImageUrls(html, { "https://old.com/img.png": "https://new.com/img.png" });
@@ -94,7 +94,7 @@ export function resolveDraftMediaId(
94
94
  ? record.media_id
95
95
  : null;
96
96
  if (!mediaId) {
97
- throw new Error("WeChat draft API response did not include media_id");
97
+ throw new Error("WX_RESULT_UNKNOWN: WeChat draft API response did not include media_id");
98
98
  }
99
99
  return mediaId;
100
100
  }
@@ -130,6 +130,8 @@ interface BaseWechatPublishInput {
130
130
  timeout?: number;
131
131
  config: PipelineConfig;
132
132
  noteId?: string | null;
133
+ /** 同一运行的同一发布版本复用幂等标识。 */
134
+ idempotencyKey?: string;
133
135
  nezusBaseUrl?: string | null;
134
136
  nezusPat?: string | null;
135
137
  }
@@ -140,6 +142,8 @@ export interface WechatDraftInput extends BaseWechatPublishInput {
140
142
  }
141
143
 
142
144
  export interface WechatNewspicInput extends BaseWechatPublishInput {
145
+ /** 明确选择配图时,不从正文中的示例语法补充图片。 */
146
+ explicitPhotos?: boolean;
143
147
  content: string;
144
148
  existingDraftMediaId?: string | null;
145
149
  }
@@ -170,6 +174,7 @@ export function fillWxAccountConfig(source?: WxAccountConfig): WxAccountConfig {
170
174
  appId: source?.appId ?? "",
171
175
  appSecret: source?.appSecret ?? "",
172
176
  customCss: source?.customCss ?? null,
177
+ ...(source?.articleTheme ? { articleTheme: source.articleTheme } : {}),
173
178
  theme: source?.theme ?? { editorVars: {}, exportTheme: {} },
174
179
  } as WxAccountConfig;
175
180
  }
@@ -186,7 +191,9 @@ function getWxRuntimeConfig(
186
191
  accountOverride || config.wx.defaultAccount,
187
192
  );
188
193
  const configuredAccount = config.wx.accounts[accountName];
189
- if (!configuredAccount) {
194
+ // 云端 worker 以每个任务的环境变量注入凭据,不要求把用户账号写进基础 config。
195
+ const hasInjectedCredentials = Boolean(process.env.WX_APPID && process.env.WX_APPSECRET && process.env.ZZCLUB_PAT);
196
+ if (!configuredAccount && !hasInjectedCredentials) {
190
197
  throw new Error(`Unknown wx account: ${accountName}`);
191
198
  }
192
199
  const accountConfig = fillWxAccountConfig(configuredAccount);
@@ -276,8 +283,8 @@ export function replaceImageUrls(html: string, imageUrlMap: Record<string, strin
276
283
  if (!orig || !wx) {
277
284
  continue;
278
285
  }
279
- const escaped = orig.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&");
280
- replaced = replaced.replace(new RegExp(escaped, "g"), wx);
286
+ // Base64 图片地址可能数 MB,按字面替换,避免超大正则及替换串转义。
287
+ replaced = replaced.replaceAll(orig, () => wx);
281
288
  }
282
289
  return replaced;
283
290
  }
@@ -320,28 +327,51 @@ export async function readResponseBodyWithLimit(
320
327
  return body.buffer;
321
328
  }
322
329
 
330
+ /** 服务端错误只保留分类和脱敏提示,正文及凭据不进入 Pipeline 状态。 */
331
+ function serviceError(response: Response, payload: unknown, mutating: boolean): Error {
332
+ const detail = asRecord(payload);
333
+ const code = String(asRecord(detail.data).code || "WX_SERVICE_ERROR").replace(/[^A-Z0-9_]/g, "").slice(0, 80);
334
+ if (mutating && response.status >= 500 && !code.startsWith("WX_")) return unknownResult();
335
+ if (mutating && response.status >= 500 && code === "WX_SERVICE_ERROR") return unknownResult();
336
+ const message = String(detail.statusMessage || detail.message || "发布服务请求失败")
337
+ .replace(/Bearer\s+[^\s"',}]+/gi, "Bearer [redacted]")
338
+ .replace(/(["']?(?:access_token|token|pat|appSecret|secret)["']?\s*[:=]\s*)["']?[^\s"'&,}]+["']?/gi, "$1[redacted]");
339
+ const requestId = response.headers.get("x-request-id") || String(detail.requestId || "");
340
+ const trace = /^[a-zA-Z0-9_-]{1,100}$/.test(requestId) ? ` [request ${requestId}]` : "";
341
+ return new Error(`API request failed with status ${response.status}: ${code}: ${message.slice(0, 300)}${trace}`);
342
+ }
343
+
344
+ function unknownResult(): Error {
345
+ return new Error("WX_RESULT_UNKNOWN: 请求结果未确认,请检查公众号草稿箱后再决定是否重试");
346
+ }
347
+
348
+ /** 网络、响应读取及 JSON 解析中断都可能发生在上游已经写入之后。 */
349
+ async function requestWechat(url: string, options: RequestInit, timeout: number): Promise<unknown> {
350
+ const mutating = /\/draft\/(?:add|update|delete)$/.test(url);
351
+ let response: Response;
352
+ let payload: unknown;
353
+ try {
354
+ response = await fetch(url, { ...options, signal: AbortSignal.timeout(timeout) });
355
+ const responseText = await response.text();
356
+ try { payload = responseText ? JSON.parse(responseText) : {}; }
357
+ catch {
358
+ if (response.ok || mutating) throw new Error("invalid response");
359
+ payload = {};
360
+ }
361
+ } catch (error) {
362
+ if (mutating) throw unknownResult();
363
+ throw error;
364
+ }
365
+ if (!response.ok) throw serviceError(response, payload, mutating);
366
+ return payload;
367
+ }
368
+
323
369
  async function requestJson(
324
370
  url: string,
325
371
  options: { method: string; headers?: Record<string, string>; body?: unknown },
326
372
  timeout: number,
327
373
  ): Promise<unknown> {
328
- const controller = new AbortController();
329
- const timeoutId = setTimeout(() => controller.abort(), timeout);
330
- try {
331
- const response = await fetch(url, {
332
- method: options.method,
333
- headers: options.headers,
334
- body: options.body ? JSON.stringify(options.body) : undefined,
335
- signal: controller.signal,
336
- });
337
- const responseText = await response.text();
338
- if (!response.ok) {
339
- throw new Error(`API request failed with status ${response.status}: ${responseText}`);
340
- }
341
- return responseText ? JSON.parse(responseText) : {};
342
- } finally {
343
- clearTimeout(timeoutId);
344
- }
374
+ return requestWechat(url, { ...options, body: options.body ? JSON.stringify(options.body) : undefined }, timeout);
345
375
  }
346
376
 
347
377
  async function requestFormData(
@@ -349,23 +379,7 @@ async function requestFormData(
349
379
  options: { method: string; headers?: Record<string, string>; body: FormData },
350
380
  timeout: number,
351
381
  ): Promise<unknown> {
352
- const controller = new AbortController();
353
- const timeoutId = setTimeout(() => controller.abort(), timeout);
354
- try {
355
- const response = await fetch(url, {
356
- method: options.method,
357
- headers: options.headers,
358
- body: options.body,
359
- signal: controller.signal,
360
- });
361
- const responseText = await response.text();
362
- if (!response.ok) {
363
- throw new Error(`API request failed with status ${response.status}: ${responseText}`);
364
- }
365
- return responseText ? JSON.parse(responseText) : {};
366
- } finally {
367
- clearTimeout(timeoutId);
368
- }
382
+ return requestWechat(url, options, timeout);
369
383
  }
370
384
 
371
385
  async function sleep(ms: number): Promise<void> {
@@ -639,6 +653,7 @@ export async function createWechatDraft(input: WechatDraftInput): Promise<Record
639
653
  headers: {
640
654
  "Content-Type": "application/json",
641
655
  Authorization: `Bearer ${runtime.pat}`,
656
+ ...(input.idempotencyKey ? { "Idempotency-Key": input.idempotencyKey } : {}),
642
657
  },
643
658
  body: draftBody,
644
659
  },
@@ -672,13 +687,18 @@ export async function createWechatDraft(input: WechatDraftInput): Promise<Record
672
687
  }
673
688
 
674
689
  export async function createWechatNewspic(input: WechatNewspicInput): Promise<Record<string, unknown>> {
690
+ // 配文超限时在上传图片前拒绝,避免微信返回不明确的 45166。
691
+ if (Buffer.byteLength(input.content, "utf8") > 2048)
692
+ throw new Error("贴图配文过长,请精简至 2KB 以内;完整文章可保留在内容图中");
675
693
  const runtime = getWxRuntimeConfig(input.config, input.account);
676
- const fallbackPhotos = extractImageUrls(input.content);
694
+ const fallbackPhotos = input.explicitPhotos ? [] : extractImageUrls(input.content);
677
695
  const photos = parsePhotos(input.photos);
678
696
  const finalPhotos = mergePhotoLists(photos, fallbackPhotos);
679
697
  if (finalPhotos.length === 0) {
680
698
  throw new Error("No photos available for wx newspic upload");
681
699
  }
700
+ if (finalPhotos.length > 20)
701
+ throw new Error("微信贴图最多支持 20 张配图");
682
702
 
683
703
  const tokenTimeout = resolveTimeout(TOKEN_TIMEOUT, runtime.timeout, input.timeout);
684
704
  const uploadTimeout = resolveTimeout(UPLOAD_TIMEOUT, runtime.timeout, input.timeout);
@@ -726,6 +746,7 @@ export async function createWechatNewspic(input: WechatNewspicInput): Promise<Re
726
746
  headers: {
727
747
  "Content-Type": "application/json",
728
748
  Authorization: `Bearer ${runtime.pat}`,
749
+ ...(input.idempotencyKey ? { "Idempotency-Key": input.idempotencyKey } : {}),
729
750
  },
730
751
  body: draftBody,
731
752
  },
@@ -16,7 +16,7 @@ export function resolveRenderBranding(config: PipelineConfig, account: string):
16
16
  const branding = config.render.branding;
17
17
  const selected = branding.accounts[account];
18
18
  const visual = getVisualParams(account);
19
- const logo = selected?.logo ?? branding.logo ?? config.imgx.icon;
19
+ const logo = process.env.ZZHUB_PIPELINE_BRANDING_LOGO?.trim() || (selected?.logo ?? branding.logo ?? config.imgx.icon);
20
20
  return {
21
21
  logo: logo === null ? resolveInputPath(visual?.fallback_icon ?? join(ICONS_DIR, "logo.png")) : resolveBrandLogo(logo),
22
22
  footerText: selected?.footerText ?? branding.footerText ?? visual?.footer ?? config.wx.accounts[account]?.name ?? "",
@@ -53,6 +53,16 @@ const WechatThemeOverridesSchema = withObjectDefault(
53
53
  z.object({
54
54
  editorVars: z.record(z.string(), z.string()).default({}),
55
55
  exportTheme: withObjectDefault(WechatExportThemeOverridesSchema),
56
+ syntaxPresets: z.record(z.string(), z.string()).optional(),
57
+ structure: z.object({
58
+ numberedHeadings: z.boolean().optional(),
59
+ headingLabel: z.string().optional(),
60
+ quoteLabel: z.string().optional(),
61
+ calloutPresets: z.record(z.string(), z.object({
62
+ title: z.string().optional(),
63
+ icon: z.string().optional(),
64
+ })).optional(),
65
+ }).optional(),
56
66
  }),
57
67
  );
58
68
 
@@ -67,6 +77,8 @@ const WxAccountConfigSchema = z.object({
67
77
  appId: z.string().default(""),
68
78
  appSecret: z.string().default(""),
69
79
  customCss: z.string().nullable().default(null),
80
+ /** 账号默认的本地正文模板,未配置时保持原样式。 */
81
+ articleTheme: z.string().nullable().optional(),
70
82
  theme: WechatThemeOverridesSchema,
71
83
  });
72
84
 
@@ -260,6 +260,7 @@ const ImagesSchema = withObjectDefault(
260
260
  );
261
261
 
262
262
  const PublishResultSchema = z.object({
263
+ article_theme_hash: z.string().nullable().optional(),
263
264
  route: RoutePrimarySchema,
264
265
  account: trimmedString("default"),
265
266
  status: PublishResultStatusSchema,
@@ -273,6 +274,7 @@ const PublishResultSchema = z.object({
273
274
  const PublishResultsSchema = withObjectDefault(
274
275
  z.object({
275
276
  results: z.array(PublishResultSchema).default([]),
277
+ attempt: z.number().int().nonnegative().optional(),
276
278
  }),
277
279
  );
278
280
 
@@ -310,7 +312,10 @@ const IntentSchema = withObjectDefault(
310
312
  .transform((arr) => arr.filter((s) => s.trim().length > 0)),
311
313
  style_hint: z.string().nullable().default(null),
312
314
  cover_theme: z.string().trim().min(1).nullable().default(null),
315
+ article_theme: z.string().nullable().optional(),
313
316
  newspic_render: NewspicRenderSpecSchema.nullable().default(null),
317
+ // 用户确认的贴图副本文件,正文不进入状态文件。
318
+ newspic_file: z.string().optional(),
314
319
  requires: IntentRequiresSchema,
315
320
  existing_draft_media_id: z.string().nullable().default(null),
316
321
  note_id: z.string().nullable().default(null),
@@ -1,8 +1,28 @@
1
1
  {
2
+ "_wechat-renderer-D1jHoaM2.js": {
3
+ "file": "assets/wechat-renderer-D1jHoaM2.js",
4
+ "name": "wechat-renderer"
5
+ },
2
6
  "src/wechat-preview/browser/editor-export.ts": {
3
7
  "file": "editor-export.js",
4
8
  "name": "editor-export",
5
9
  "src": "src/wechat-preview/browser/editor-export.ts",
6
- "isEntry": true
10
+ "isEntry": true,
11
+ "imports": [
12
+ "_wechat-renderer-D1jHoaM2.js"
13
+ ]
14
+ },
15
+ "src/wechat-preview/browser/studio/studio.html": {
16
+ "file": "studio.js",
17
+ "name": "studio",
18
+ "src": "src/wechat-preview/browser/studio/studio.html",
19
+ "isEntry": true,
20
+ "imports": [
21
+ "_wechat-renderer-D1jHoaM2.js"
22
+ ]
23
+ },
24
+ "style.css": {
25
+ "file": "assets/style-C9-YtTIm.css",
26
+ "src": "style.css"
7
27
  }
8
28
  }
@@ -0,0 +1 @@
1
+ :root{--bg-main: #f8fafc;--bg-panel: #ffffff;--border: #e2e8f0;--border-focus: #ca6093;--text-main: #1e293b;--text-muted: #64748b;--brand: #ca6093;--brand-hover: #b35082;--brand-light: rgba(202, 96, 147, .08);--success: #10b981;--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;--font-mono: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace}*{box-sizing:border-box;margin:0;padding:0}body{font-family:var(--font-sans);color:var(--text-main);background:var(--bg-main);height:100vh;display:flex;flex-direction:column;overflow:hidden}.studio-header{height:52px;background:var(--bg-panel);border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;padding:0 16px;z-index:10}.brand-section{display:flex;align-items:center;gap:10px}.brand-logo{font-size:18px}.brand-title{font-size:15px;font-weight:700;color:var(--text-main);letter-spacing:-.01em}.brand-badge{font-size:11px;font-weight:600;background:var(--brand-light);color:var(--brand);padding:2px 8px;border-radius:999px}.header-controls{display:flex;align-items:center;gap:12px}.account-select-wrap{display:flex;align-items:center;gap:6px;font-size:13px;color:var(--text-muted)}.account-select{padding:5px 10px;font-size:13px;border-radius:6px;border:1px solid var(--border);background:#fff;color:var(--text-main);outline:none;cursor:pointer}.account-select:focus{border-color:var(--border-focus)}.btn{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border-radius:6px;font-size:13px;font-weight:500;cursor:pointer;border:1px solid var(--border);background:#fff;color:var(--text-main);transition:all .15s ease;text-decoration:none}.btn:hover{background:#f1f5f9}.btn-primary{background:var(--brand);color:#fff;border-color:var(--brand)}.btn-primary:hover{background:var(--brand-hover);border-color:var(--brand-hover)}.btn-success{background:#059669;color:#fff;border-color:#059669}.btn-success:hover{background:#047857;border-color:#047857}.studio-body{flex:1;display:flex;min-height:0;overflow:hidden}.panel{display:flex;flex-direction:column;background:var(--bg-panel);min-height:0}.panel-header{padding:10px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;background:#fafbfc}.panel-title{font-size:13px;font-weight:650;color:var(--text-main);display:flex;align-items:center;gap:6px}.left-panel{width:360px;border-right:1px solid var(--border);flex-shrink:0}.tab-nav{display:flex;border-bottom:1px solid var(--border);background:#f8fafc}.tab-btn{flex:1;padding:10px 12px;font-size:13px;font-weight:600;border:none;background:transparent;color:var(--text-muted);cursor:pointer;border-bottom:2px solid transparent;transition:all .15s}.tab-btn:hover{color:var(--text-main)}.tab-btn.active{color:var(--brand);background:#fff;border-bottom-color:var(--brand)}.tab-content{flex:1;overflow-y:auto;padding:16px}.config-section{margin-bottom:22px}.section-label{font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-muted);margin-bottom:12px;display:flex;align-items:center;gap:6px}.form-group{margin-bottom:14px}.form-label{display:flex;justify-content:space-between;align-items:center;font-size:12px;color:var(--text-main);margin-bottom:6px}.form-val{font-family:var(--font-mono);font-size:11px;color:var(--text-muted)}.slider-input{width:100%;accent-color:var(--brand);cursor:pointer}.color-input-row{display:flex;align-items:center;gap:8px}.color-picker{-webkit-appearance:none;appearance:none;border:1px solid var(--border);border-radius:6px;width:32px;height:32px;cursor:pointer;padding:2px;background:#fff}.color-picker::-webkit-color-swatch-wrapper{padding:0}.color-picker::-webkit-color-swatch{border:none;border-radius:4px}.color-hex{flex:1;padding:6px 10px;font-family:var(--font-mono);font-size:12px;border:1px solid var(--border);border-radius:6px;color:var(--text-main)}.switch-label{display:flex;align-items:center;justify-content:space-between;cursor:pointer;font-size:12px}.switch-toggle{position:relative;display:inline-block;width:38px;height:20px}.switch-toggle input{opacity:0;width:0;height:0}.slider-round{position:absolute;cursor:pointer;inset:0;background-color:#cbd5e1;transition:.2s;border-radius:20px}.slider-round:before{position:absolute;content:"";height:14px;width:14px;left:3px;bottom:3px;background-color:#fff;transition:.2s;border-radius:50%}input:checked+.slider-round{background-color:var(--brand)}input:checked+.slider-round:before{transform:translate(18px)}.css-editor{width:100%;height:140px;font-family:var(--font-mono);font-size:12px;line-height:1.5;padding:8px 10px;border:1px solid var(--border);border-radius:6px;background:#fafbfc;color:#0f172a;resize:vertical;outline:none}.css-editor:focus{border-color:var(--border-focus);background:#fff}.syntax-card{border:1px solid var(--border);border-radius:8px;padding:12px;margin-bottom:12px;background:#fff;transition:border-color .15s}.syntax-card:hover{border-color:#cbd5e1}.syntax-card-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}.syntax-card-title{font-size:13px;font-weight:650;color:var(--text-main)}.syntax-card-badge{font-size:10px;font-weight:600;padding:1px 6px;border-radius:999px;background:#f1f5f9;color:var(--text-muted)}.syntax-card-desc{font-size:12px;color:var(--text-muted);margin-bottom:8px;line-height:1.4}.syntax-code-snippet{background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;padding:8px 10px;font-family:var(--font-mono);font-size:11px;color:#334155;white-space:pre-wrap;margin-bottom:8px}.syntax-btn-insert{width:100%;padding:4px 8px;font-size:11px;font-weight:600;border:1px dashed var(--border);border-radius:4px;background:#fafbfc;color:var(--brand);cursor:pointer}.syntax-btn-insert:hover{background:var(--brand-light);border-color:var(--brand)}.editor-panel{flex:1;border-right:1px solid var(--border);min-width:320px}.editor-toolbar{display:flex;gap:8px;align-items:center}.editor-textarea{flex:1;width:100%;border:none;outline:none;resize:none;padding:16px 20px;font-family:var(--font-mono);font-size:13px;line-height:1.7;color:#1e293b;background:#fff}.editor-footer{padding:6px 16px;border-top:1px solid var(--border);background:#fafbfc;font-size:11px;color:var(--text-muted);display:flex;justify-content:space-between}.preview-panel{width:480px;flex-shrink:0;background:#f1f5f9}.preview-container{flex:1;overflow-y:auto;display:flex;flex-direction:column;align-items:center;padding:24px 16px 48px}.device-frame{width:430px;min-height:680px;background:#fff;border-radius:18px;box-shadow:0 10px 30px #00000014,0 1px 3px #0000000d;overflow:hidden;display:flex;flex-direction:column;border:1px solid #e2e8f0}.device-notch-bar{height:28px;background:#fafbfc;border-bottom:1px solid #f1f5f9;display:flex;align-items:center;justify-content:space-between;padding:0 16px;font-size:11px;font-weight:600;color:#64748b}.device-screen{flex:1;padding:20px 16px 36px;box-sizing:border-box;overflow-y:auto}.toast-container{position:fixed;bottom:24px;right:24px;z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none}.toast{background:#1e293b;color:#fff;padding:10px 16px;border-radius:8px;font-size:13px;font-weight:500;box-shadow:0 6px 18px #00000026;opacity:0;transform:translateY(10px);transition:all .25s ease;pointer-events:auto}.toast.show{opacity:1;transform:translateY(0)}.toast.toast-success{background:#065f46;border:1px solid #10b981}.preset-group{margin-bottom:10px;padding:10px 12px;background:#f8fafc;border:1px solid var(--border);border-radius:var(--radius-sm);transition:border-color .15s}.preset-group:hover{border-color:#cbd5e1}.preset-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}.preset-title{font-size:12px;font-weight:650;color:var(--text-main);display:flex;align-items:center;gap:5px}.preset-select{width:100%;padding:5px 8px;font-size:12px;font-weight:500;border:1px solid var(--border);border-radius:4px;background:#fff;color:var(--text-main);outline:none;cursor:pointer;transition:border-color .15s}.preset-select:focus{border-color:var(--brand)}.preset-desc{font-size:11px;color:var(--text-muted);margin-top:5px;line-height:1.4;padding-left:2px}.btn-extract-css{padding:2px 7px;font-size:11px;font-weight:550;color:var(--brand);background:#fff;border:1px solid var(--border);border-radius:4px;cursor:pointer;transition:all .15s ease;white-space:nowrap}.btn-extract-css:hover{background:#f1f5f9;border-color:var(--brand)}#studio-editor-host{display:none}