✨ feat(vscode): 代码阅读认出 React 函数组件,读出属性、状态、引用、读取的数据、派生值、副作用、处理函数和渲染的 JSX 大纲(纯函数,luffy 七个真实组件当测试样本)

This commit is contained in:
cruldra committed 2026-10-03 03:11:46 +08:00
1 parent ad2efdcdae
commit 3b42be9002
9 files changed
+2541

No files matched your search

@@ -0,0 +1,48 @@
"use client";
import Link from "next/link";
import { sectionHref } from "@/app/(main)/apps/[appId]/settings/nav";
import { useAppVault } from "@/app/(main)/vault/use-app-vault";
import { SettingHeader } from "./settings-primitives";
/** 会话里只看这个应用拿到哪些密钥与变量; 增删改在应用设置里, 所有会话共用那一份. */
export const AppVaultSummary = ({ appId }: { appId: string }) => {
const { appItems, inherited } = useAppVault(appId);
const effective = [
...appItems.map((item) => ({ item, label: "本应用" })),
...inherited.map((item) => ({ item, label: "账户级·仅沙箱" })),
];
return (
<>
<SettingHeader
compact
align="center"
title="密钥与变量"
description="按应用保存,这个应用的所有会话共用"
right={
<Link
href={sectionHref(appId, "vault")}
className="text-[13px] text-primary underline underline-offset-2"
>
管理
</Link>
}
/>
{effective.length > 0 && (
<ul className="mt-2 space-y-1">
{effective.map(({ item, label }) => (
<li key={item.id} className="flex items-center justify-between gap-2 text-[12px]">
<span className="truncate font-mono text-foreground">{item.name}</span>
<span className="shrink-0 rounded border border-border px-1 py-px text-[10px] text-muted-foreground">
{label}
</span>
</li>
))}
</ul>
)}
</>
);
};
@@ -0,0 +1,127 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Database, Link2, Server, Wallet } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { type AppDetail, DELETE_APP_CONFIRMATION, deleteApp, dropAppQueries } from "@/lib/apps";
import { SESSION_ROUTE } from "@/lib/sessions";
interface DeleteAppDialogProps {
app: AppDetail;
open: boolean;
onOpenChange: (open: boolean) => void;
}
const LIVE_STATUSES = new Set(["running", "building", "switching", "suspended"]);
/**
* 删应用确认框: 逐条列清会拆掉什么, 手打确认词才放行.
*
* 这是把一个应用连同部署、两个托管库、域名一起清掉的唯一入口, 不可撤销.
*/
export const DeleteAppDialog = ({ app, open, onOpenChange }: DeleteAppDialogProps) => {
const router = useRouter();
const queryClient = useQueryClient();
const [confirm, setConfirm] = useState("");
const remove = useMutation({
mutationFn: () => deleteApp(app.id),
onSuccess: () => {
onOpenChange(false);
router.push(SESSION_ROUTE);
void dropAppQueries(queryClient, app.id);
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "删除应用失败");
},
});
const hasDeployment = app.deployment != null && LIVE_STATUSES.has(app.deployment.status);
const losses = [
hasDeployment
? { icon: Server, text: "拆掉 1 个在跑的部署,线上地址立刻打不开" }
: null,
{
icon: Database,
text: "永久删除应用的两个托管库(线上库 + 开发库),里面的业务数据一并清空",
},
app.custom_domain ? { icon: Link2, text: "释放自定义域名,别人可以再绑" } : null,
].filter((x) => x != null);
const pending = remove.isPending;
const ready = confirm === DELETE_APP_CONFIRMATION;
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (pending) return;
onOpenChange(next);
if (!next) setConfirm("");
}}
>
<DialogContent showCloseButton={!pending}>
<DialogHeader>
<DialogTitle className="text-destructive">删除应用「{app.title}」?</DialogTitle>
<DialogDescription>删掉之后没有任何地方能把它找回来。</DialogDescription>
</DialogHeader>
<ul className="flex flex-col gap-2 text-[13px] text-foreground">
{losses.map(({ icon: Icon, text }) => (
<li key={text} className="flex items-start gap-2">
<Icon size={14} className="mt-0.5 shrink-0 text-destructive" />
<span>{text}</span>
</li>
))}
</ul>
{hasDeployment && (
<p className="flex items-start gap-2 text-[13px] text-muted-foreground">
<Wallet size={14} className="mt-0.5 shrink-0" />
<span>占用的 1 个部署位退回,可以给别的应用用</span>
</p>
)}
<div className="grid gap-2">
<Label htmlFor="delete-app-confirm" className="font-semibold text-destructive">
请输入「{DELETE_APP_CONFIRMATION}」
</Label>
<input
id="delete-app-confirm"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
autoComplete="off"
disabled={pending}
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={pending}>
取消
</Button>
<Button
variant="destructive"
onClick={() => remove.mutate()}
disabled={!ready || pending}
>
{pending ? "删除中…" : "确认删除并清空数据"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,226 @@
"use client";
import { useEffect, useState } from "react";
import { Monitor } from "lucide-react";
import { SettingHeader, SettingsSwitch } from "@/app/(main)/chat/code/settings-primitives";
import { ColorThemeToggle } from "@/components/ColorThemeToggle";
import { ThemeToggle } from "@/components/ThemeToggle";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useDesktopShellInfo } from "@/hooks/use-desktop-shell-info";
import { SHORTCUTS } from "@/lib/desktop/shortcuts";
import { displayPath } from "@/lib/desktop/display-path";
import { type SettingsSection, useDesktopShell } from "@/lib/desktop/store";
import { type ShellSettings, tauriBridge } from "@/lib/desktop/tauri-bridge";
import { cn } from "@/lib/utils";
const SECTIONS: { key: SettingsSection; label: string }[] = [
{ key: "general", label: "通用" },
{ key: "appearance", label: "外观" },
{ key: "runtime", label: "本机运行时" },
{ key: "shortcuts", label: "快捷键" },
];
const STATE_DOT = { running: "bg-success", idle: "bg-ink-4", stopped: "bg-border-strong" } as const;
const STATE_NOTE = { running: "运行中", idle: "空闲", stopped: "已停止" } as const;
const LEVEL = { info: "text-muted-foreground", warn: "text-warning-deep", error: "text-destructive" } as const;
type GeneralKey = keyof Pick<ShellSettings, "autostart" | "keep_tray_on_close" | "notify_on_done">;
const GENERAL_SWITCHES: { key: GeneralKey; label: string; note: string }[] = [
{ key: "autostart", label: "开机时自动启动", note: "启动后在后台待命" },
{ key: "keep_tray_on_close", label: "关闭窗口后留在托盘", note: "本机运行中的服务继续保持" },
{ key: "notify_on_done", label: "任务完成时发送系统通知", note: "仅在窗口未激活时提醒" },
];
/** 壳 workspace_move 的错误码 → 给用户看的话; 其余原因原样显示. */
const MOVE_ERRORS: Record<string, string> = {
WORKSPACE_BUSY: "有命令或任务正在运行, 等它结束或停掉后再更改目录",
TARGET_NOT_EMPTY: "目标目录不是空的, 换一个",
TARGET_INSIDE_WORKSPACE: "目标目录不能在当前工作区里面",
};
const SectionTitle = ({ children }: { children: string }) => (
<h3 className="mb-2 text-[13px] font-semibold text-muted-foreground">{children}</h3>
);
/** 通用节: 三个开关读壳的设置文件, 翻一个立即写回, 以壳回的为准; 写不进去就翻回来并说原因. */
const GeneralSection = () => {
const [settings, setSettings] = useState<ShellSettings | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
useEffect(() => {
void tauriBridge.settings.get().then(setSettings);
}, []);
const toggle = async (key: GeneralKey, value: boolean) => {
setSaveError(null);
setSettings((prev) => (prev ? { ...prev, [key]: value } : prev));
try {
const next = await tauriBridge.settings.set({ [key]: value });
if (next) setSettings(next);
} catch (e) {
setSettings((prev) => (prev ? { ...prev, [key]: !value } : prev));
setSaveError(`保存失败: ${String(e)}`);
}
};
return (
<>
<SectionTitle>通用</SectionTitle>
{GENERAL_SWITCHES.map((g) => (
<div key={g.key} className="py-2">
<SettingHeader
align="center"
title={g.label}
description={g.note}
right={
<SettingsSwitch
checked={settings?.[g.key] ?? false}
disabled={!settings}
onChange={(v) => void toggle(g.key, v)}
ariaLabel={g.label}
/>
}
/>
</div>
))}
{saveError && <p className="mt-1 text-[12px] text-destructive">{saveError}</p>}
</>
);
};
/** 工作区目录行: 「更改」选一个新目录, 壳把整个工作区搬过去; 目录行随壳的状态事件更新. */
const WorkspaceRootRow = () => {
const workspaceRoot = useDesktopShell((s) => displayPath(s.workspace_root, s.home));
const [moving, setMoving] = useState(false);
const [moveError, setMoveError] = useState<string | null>(null);
const changeRoot = async () => {
const picked = await tauriBridge.pickDirectory();
if (typeof picked !== "string") return;
setMoveError(null);
setMoving(true);
try {
await tauriBridge.workspace.move(picked);
} catch (e) {
setMoveError(MOVE_ERRORS[String(e)] ?? String(e));
} finally {
setMoving(false);
}
};
return (
<section>
<SectionTitle>工作区目录</SectionTitle>
<div className="flex items-center gap-3">
<p data-testid="workspace-root" className="min-w-0 flex-1 truncate font-mono text-[12.5px] text-foreground">
{workspaceRoot}
</p>
<Button variant="outline" size="sm" disabled={moving} onClick={() => void changeRoot()}>
更改
</Button>
</div>
{moveError && <p className="mt-1 text-[12px] text-destructive">{moveError}</p>}
</section>
);
};
const RuntimeSection = () => {
const device = useDesktopShell((s) => s.device);
const workspaces = useDesktopShell((s) => s.workspaces);
const workspaceTitles = useDesktopShell((s) => s.workspaceTitles);
const activity = useDesktopShell((s) => s.activity);
const deviceFacts = [device.os, device.chip, device.memory, device.disk_free && `磁盘剩余 ${device.disk_free}`]
.filter(Boolean)
.join(" · ");
return (
<div className="space-y-6">
<section
data-testid="device-row"
className="flex items-center gap-3 rounded-xl border border-border bg-surface px-4 py-3"
>
<Monitor size={20} className="text-muted-foreground" aria-hidden />
<div>
<p className="text-[14px] font-medium text-foreground">{device.name || "本机"}</p>
{deviceFacts && <p className="text-[12px] text-muted-foreground">{deviceFacts}</p>}
</div>
</section>
<WorkspaceRootRow />
<section>
<SectionTitle>各应用占用</SectionTitle>
{workspaces.map((w) => (
<div key={w.subdir} data-testid="workspace-row" className="flex items-center gap-3 border-b border-border py-2 last:border-0">
<span className={cn("h-1.5 w-1.5 shrink-0 rounded-full", STATE_DOT[w.state])} aria-hidden />
<div className="min-w-0 flex-1">
<p className="truncate text-[13.5px] text-foreground">
{workspaceTitles[w.subdir] ?? "未知应用"}
</p>
<p className="font-mono text-[11.5px] text-muted-foreground">
{w.subdir} · {STATE_NOTE[w.state]}
</p>
</div>
<span className="font-mono text-[12px] text-muted-foreground">{w.size}</span>
</div>
))}
</section>
<section>
<SectionTitle>活动日志 · 最近 200 条</SectionTitle>
{activity.map((a) => (
<div key={a.seq} data-testid="activity-row" className="flex gap-3 py-1 font-mono text-[12px]">
<span className="shrink-0 text-ink-4">{a.time}</span>
<span className={cn("shrink-0", LEVEL[a.level])}>{a.method}</span>
<span className="min-w-0 truncate text-foreground">{a.detail}</span>
</div>
))}
</section>
</div>
);
};
/** 设置弹层: 通用 / 外观 / 本机运行时 / 快捷键. 标题栏、托盘、快捷键都经 store 的 openSettings 打开它. */
export const DesktopSettingsDialog = () => {
const section = useDesktopShell((s) => s.settingsSection);
const openSettings = useDesktopShell((s) => s.openSettings);
const closeSettings = useDesktopShell((s) => s.closeSettings);
const platform = useDesktopShellInfo()?.platform ?? "macos";
if (!section) return null;
return (
<Dialog open onOpenChange={(next) => !next && closeSettings()}>
<DialogContent className="h-[560px] overflow-hidden p-0 sm:max-w-[880px]">
<Tabs
value={section}
onValueChange={(value: SettingsSection) => openSettings(value)}
orientation="vertical"
className="flex h-full min-h-0 flex-row gap-0"
>
<aside className="w-[200px] shrink-0 border-r border-border bg-surface p-3">
<DialogTitle className="px-2 pb-3 text-[15px]">设置</DialogTitle>
<TabsList aria-label="设置" variant="line" className="w-full items-stretch gap-0.5">
{SECTIONS.map((s) => (
<TabsTrigger key={s.key} value={s.key}>
{s.label}
</TabsTrigger>
))}
</TabsList>
</aside>
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto p-6">
<TabsContent value="general">
<GeneralSection />
</TabsContent>
<TabsContent value="appearance">
<SectionTitle>外观</SectionTitle>
<ThemeToggle />
<ColorThemeToggle />
</TabsContent>
<TabsContent value="runtime">
<RuntimeSection />
</TabsContent>
<TabsContent value="shortcuts">
<SectionTitle>全局</SectionTitle>
{SHORTCUTS(platform).map((s) => (
<div key={s.label} data-testid="shortcut-row" className="flex items-center justify-between border-b border-border py-2 last:border-0">
<span className="text-[13px] text-foreground">{s.label}</span>
<kbd className="rounded-md bg-surface-2 px-1.5 py-0.5 font-mono text-[12px] text-muted-foreground">{s.key}</kbd>
</div>
))}
</TabsContent>
</div>
</Tabs>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,31 @@
"use client";
import { usePathname } from "next/navigation";
import type { DesktopShell } from "@/lib/desktop-shell";
import { PAGE_TITLES } from "@/lib/desktop/page-titles";
import { useDesktopShell } from "@/lib/desktop/store";
import { WindowControls } from "./WindowControls";
/** 窗口顶部 40px. 整条连同里面的文字都是拖拽区 (data-tauri-drag-region="deep"), 按钮除外. 侧栏开关只在侧栏顶部. */
export const DesktopTitlebar = ({ platform }: { platform: DesktopShell["platform"] }) => {
const pathname = usePathname();
const title = useDesktopShell((s) => s.title);
const center = title ?? PAGE_TITLES[pathname] ?? "";
return (
<div
data-tauri-drag-region="deep"
className="flex h-10 shrink-0 select-none items-center border-b border-border bg-background"
>
<div className="flex items-center pl-2">
{platform === "macos" && <span data-testid="traffic-light-inset" className="w-[70px]" aria-hidden />}
</div>
<div className="flex min-w-0 flex-1 items-center justify-center text-[13px]">
<span className="max-w-[40vw] truncate font-medium text-foreground">{center}</span>
</div>
{platform === "windows" && <WindowControls />}
</div>
);
};
@@ -0,0 +1,285 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
ChevronLeft,
FileText,
Printer,
ShieldCheck,
Sparkles,
} from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { useSiteConfig } from "@/lib/site-config";
interface LegalDocPageProps {
/** terms = 用户服务协议; privacy = 隐私政策 */
doc: "terms" | "privacy";
}
const DOC_TITLE: Record<LegalDocPageProps["doc"], string> = {
terms: "用户服务协议",
privacy: "隐私政策",
};
/** 由 ## 标题文本生成稳定锚点 id; 中文可直接作为 HTML id, querySelector 用属性选择器规避转义. */
const slugify = (raw: string): string => `sec-${raw.trim().replace(/\s+/g, "-")}`;
/** 递归取 ReactMarkdown 传入的 heading children 的纯文本, 用于生成与 TOC 一致的 id. */
const nodeText = (node: React.ReactNode): string => {
if (typeof node === "string" || typeof node === "number") return String(node);
if (Array.isArray(node)) return node.map(nodeText).join("");
if (node && typeof node === "object" && "props" in node) {
return nodeText((node as React.ReactElement<{ children?: React.ReactNode }>).props.children);
}
return "";
};
/** 从 markdown 抽取全部 ## 二级标题, 作为左侧目录数据源 (顺序即正文顺序). */
const extractHeadings = (md: string): { id: string; title: string }[] => {
const out: { id: string; title: string }[] = [];
for (const line of md.split("\n")) {
const m = /^##\s+(.+?)\s*$/.exec(line);
if (m) out.push({ id: slugify(m[1]), title: m[1] });
}
return out;
};
/**
* 协议正文页面 (服务协议 / 隐私政策共享).
*
* 正文为 Markdown, 由运营在 admin 站点配置页维护, 经 sys_config(site 组)下发,
* 这里通过 `useSiteConfig()` 读取 —— 不再硬编码文案。左侧目录从正文的 ## 标题自动生成。
*/
export const LegalDocPage = ({ doc }: LegalDocPageProps) => {
const cfg = useSiteConfig();
const router = useRouter();
const markdown = doc === "terms" ? cfg.termsContent : cfg.privacyContent;
const headings = useMemo(() => extractHeadings(markdown), [markdown]);
const scrollRef = useRef<HTMLDivElement>(null);
const [activeSection, setActiveSection] = useState<string>(
headings[0]?.id ?? "",
);
// doc 切换时渲染期重置高亮 (React 官方 in-render reset 模式, 避免 effect 内 setState 级联渲染).
const [prevDoc, setPrevDoc] = useState(doc);
if (doc !== prevDoc) {
setPrevDoc(doc);
setActiveSection(headings[0]?.id ?? "");
}
// 切换文档时正文滚回顶部 (DOM 副作用).
useEffect(() => {
scrollRef.current?.scrollTo({ top: 0 });
}, [doc]);
// 滚动时按 heading.top 计算当前高亮章节.
useEffect(() => {
const container = scrollRef.current;
if (!container) return;
const handleScroll = () => {
const hs = Array.from(container.querySelectorAll<HTMLElement>("h2[id]"));
// 尾部章节比视口短时 heading 永远到不了激活线, 滚到底就直接高亮最后一章.
const atBottom =
container.scrollTop + container.clientHeight >=
container.scrollHeight - 1;
let currentId = hs[0]?.id ?? "";
if (atBottom) {
currentId = hs[hs.length - 1]?.id ?? currentId;
} else {
for (const h of hs) {
if (h.getBoundingClientRect().top <= 150) currentId = h.id;
}
}
setActiveSection(currentId);
};
container.addEventListener("scroll", handleScroll, { passive: true });
return () => container.removeEventListener("scroll", handleScroll);
}, [doc, headings]);
const handleSectionClick = (id: string) => {
const container = scrollRef.current;
const target = container?.querySelector<HTMLElement>(`[id="${id}"]`);
if (container && target) {
container.scrollTo({ top: target.offsetTop - 40, behavior: "smooth" });
}
};
const handlePrint = () => {
if (typeof window !== "undefined") window.print();
};
const handleBack = () => {
if (typeof window !== "undefined" && window.history.length > 1) {
window.history.back();
return;
}
router.push("/home");
};
return (
<div className="flex flex-col h-screen w-full bg-surface text-foreground font-sans antialiased overflow-hidden">
{/* 顶部导航栏: 返回 + 文档切换 + 打印 */}
<header className="h-[60px] md:h-[72px] shrink-0 border-b border-border flex items-center justify-between px-6 md:px-10 bg-surface/80 backdrop-blur-md z-50">
<div className="flex items-center gap-6">
<button
type="button"
onClick={handleBack}
aria-label="返回"
className="flex items-center justify-center w-8 h-8 rounded-full hover:bg-background text-foreground transition-colors -ml-2"
>
<ChevronLeft size={20} />
</button>
<div className="hidden md:flex items-center gap-2">
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center shadow-sm">
<Sparkles size={16} className="text-primary-foreground" />
</div>
<span className="font-semibold tracking-tight">法律与隐私</span>
</div>
</div>
{/* 双文档切换 (Apple Segmented Control) */}
<div className="flex bg-background p-1 rounded-full w-fit">
<Link
href="/terms"
className={`flex items-center gap-1.5 px-4 md:px-6 py-1.5 rounded-full text-[13px] md:text-[14px] font-semibold transition-all duration-300 ${
doc === "terms"
? "bg-surface text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
<FileText size={16} />
<span>服务协议</span>
</Link>
<Link
href="/privacy"
className={`flex items-center gap-1.5 px-4 md:px-6 py-1.5 rounded-full text-[13px] md:text-[14px] font-semibold transition-all duration-300 ${
doc === "privacy"
? "bg-surface text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
<ShieldCheck size={16} />
<span>隐私政策</span>
</Link>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handlePrint}
title="打印"
aria-label="打印"
className="w-10 h-10 rounded-full hover:bg-background flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
>
<Printer size={18} />
</button>
</div>
</header>
{/* 主体阅读区 (双栏布局) */}
<div className="flex-1 flex overflow-hidden bg-surface max-w-[1200px] mx-auto w-full">
{/* 左侧 TOC, 仅 lg 显示 */}
<aside className="hidden lg:block w-[280px] h-full shrink-0 border-r border-border py-10 pr-8 overflow-y-auto legal-scroll">
<h3 className="text-[12px] font-bold text-muted-foreground tracking-widest uppercase mb-6 pl-4">
目录
</h3>
<nav className="flex flex-col relative">
<div className="absolute left-[3px] top-2 bottom-2 w-px bg-border -z-10" />
{headings.map((h) => {
const isActive = activeSection === h.id;
return (
<div key={h.id} className="relative flex items-center mb-1">
<div
className={`absolute left-[2px] w-[3px] h-4 rounded-full transition-colors duration-300 ${
isActive ? "bg-primary" : "bg-transparent"
}`}
/>
<button
type="button"
onClick={() => handleSectionClick(h.id)}
className={`w-full text-left pl-6 py-2.5 rounded-lg text-[14px] transition-colors ${
isActive
? "font-semibold text-foreground bg-background"
: "font-medium text-muted-foreground hover:text-foreground hover:bg-background/50"
}`}
>
{h.title}
</button>
</div>
);
})}
</nav>
</aside>
{/* 右侧正文 */}
<main
ref={scrollRef}
className="flex-1 h-full overflow-y-auto px-6 md:px-16 lg:px-20 py-12 scroll-smooth legal-scroll"
>
<div className="max-w-[720px] pb-32">
<div className="mb-12 border-b border-border pb-10">
<h1 className="text-[36px] md:text-[44px] font-bold tracking-tight text-foreground leading-tight">
{DOC_TITLE[doc]}
</h1>
</div>
{markdown.trim() ? (
<div className="markdown-body text-[15px] leading-loose">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h2: ({ children }) => (
<h2
id={slugify(nodeText(children))}
className="text-[20px] font-bold text-foreground mt-12 mb-4 tracking-tight scroll-mt-8"
>
{children}
</h2>
),
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{children}
</a>
),
}}
>
{markdown}
</ReactMarkdown>
</div>
) : (
<p className="text-[14px] text-muted-foreground">
{DOC_TITLE[doc]}内容尚未配置, 请在管理后台“站点配置”中填写。
</p>
)}
</div>
</main>
</div>
{/* 打印态与滚动条样式 */}
<style
dangerouslySetInnerHTML={{
__html: `
@media print {
aside, header { display: none !important; }
main { overflow: visible !important; padding: 0 !important; height: auto !important; }
}
.legal-scroll::-webkit-scrollbar { width: 6px; }
.legal-scroll::-webkit-scrollbar-track { background: transparent; }
.legal-scroll::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 10px; }
.legal-scroll::-webkit-scrollbar-thumb:hover { background: var(--muted-foreground); }
`,
}}
/>
</div>
);
};
@@ -0,0 +1,216 @@
"use client";
import { Download, Share2 } from "lucide-react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { buildInviteLink, type LinkTarget } from "@/lib/invite/inviteLink";
import { cn } from "@/lib/utils";
import { composePosterPng } from "./compose-poster";
import { PosterCanvas, type PosterConfig } from "./PosterCanvas";
import { POSTER_H, POSTER_TEMPLATES, POSTER_W, PREVIEW_SCALE_MAX, THUMB_SCALE } from "./templates";
const StepLabel = ({ n, children }: { n: number; children: ReactNode }) => (
<div className="mb-2 flex items-center gap-2 text-meta font-medium text-foreground">
<span className="inline-flex size-5 items-center justify-center rounded-full bg-primary text-[11px] font-bold text-primary-foreground">
{n}
</span>
{children}
</div>
);
const ScaledPoster = ({
config,
link,
inviteCode,
scale,
testId,
}: {
config: PosterConfig;
link: string;
inviteCode: string;
scale: number;
testId?: string;
}) => {
const w = POSTER_W * scale;
const h = POSTER_H * scale;
return (
<div
className="relative shrink-0 overflow-hidden rounded-2xl bg-black/5 shadow-sm ring-1 ring-border"
style={{ width: w, height: h }}
data-testid={testId}
>
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: POSTER_W,
height: POSTER_H,
transform: `scale(${scale})`,
transformOrigin: "0 0",
}}
>
<PosterCanvas config={config} link={link} inviteCode={inviteCode} />
</div>
</div>
);
};
export const PosterDialog = ({
inviteCode,
target,
open,
onOpenChange,
}: {
inviteCode: string;
target: LinkTarget;
open: boolean;
onOpenChange: (open: boolean) => void;
}) => {
const previewSlotRef = useRef<HTMLDivElement>(null);
const [previewScale, setPreviewScale] = useState(PREVIEW_SCALE_MAX);
const [config, setConfig] = useState<PosterConfig>({
template: POSTER_TEMPLATES[0],
});
const link = buildInviteLink(inviteCode, target);
// 窄屏:按预览槽可用宽度下调缩放,避免横向溢出
useEffect(() => {
if (!open) return;
const el = previewSlotRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
const avail = entry.contentRect.width;
if (avail <= 0) return;
setPreviewScale(Math.min(PREVIEW_SCALE_MAX, avail / POSTER_W));
});
ro.observe(el);
return () => ro.disconnect();
}, [open]);
const exportPng = async (): Promise<Blob | null> => {
try {
return await composePosterPng({ imageUrl: config.template.image, link });
} catch {
toast.error("生成失败,请重试");
return null;
}
};
const download = async () => {
const blob = await exportPng();
if (!blob) return;
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `邀请海报-${config.template.id}-${inviteCode}.png`;
a.click();
// iOS 会异步读 blob,立刻 revoke 会下到空文件
window.setTimeout(() => URL.revokeObjectURL(url), 2000);
toast.success("海报已保存");
};
const share = async () => {
const blob = await exportPng();
if (!blob) return;
const file = new File([blob], `邀请海报-${inviteCode}.png`, { type: "image/png" });
try {
await navigator.share({ files: [file] });
} catch {
// 用户取消不算失败
}
};
const canShareFile =
typeof navigator !== "undefined" && !!navigator.canShare?.({ files: [new File([], "x.png", { type: "image/png" })] });
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[min(98dvh,1200px)] min-h-[min(88dvh,920px)] w-full max-w-[calc(100%-0.75rem)] flex-col gap-3 overflow-y-auto sm:max-w-7xl">
<DialogHeader className="shrink-0 space-y-1 pb-0">
<DialogTitle>生成邀请海报</DialogTitle>
<p className="text-meta text-muted-foreground">好友通过你的链接注册,可领取新人礼包</p>
</DialogHeader>
<div className="grid min-h-0 flex-1 items-center gap-4 lg:grid-cols-[minmax(180px,220px)_minmax(0,1fr)_minmax(96px,112px)]">
<div className="space-y-5 self-start">
<section>
<StepLabel n={1}>选择海报样式</StepLabel>
<div className="flex flex-col gap-2">
{POSTER_TEMPLATES.map((t) => (
<button
key={t.id}
type="button"
onClick={() => setConfig((c) => ({ ...c, template: t }))}
className={cn(
"rounded-xl px-3 py-2.5 text-left ring-1 transition-colors",
config.template.id === t.id
? "bg-primary-soft font-medium text-primary ring-primary/40"
: "text-muted-foreground ring-border hover:text-foreground",
)}
>
<div className="text-meta">{t.name}</div>
<div className="mt-0.5 text-caption opacity-80">{t.blurb}</div>
</button>
))}
</div>
</section>
<div className="flex flex-col gap-2 pt-1">
<Button className="w-full" size="lg" onClick={download}>
<Download /> 下载海报
</Button>
{canShareFile && (
<Button variant="outline" className="w-full" size="lg" onClick={share}>
<Share2 /> 分享给好友
</Button>
)}
</div>
</div>
{/* 中:宽度 100%,内部按可用宽度缩放,防窄屏横向溢出 */}
<div
ref={previewSlotRef}
className="flex min-h-0 min-w-0 w-full flex-col items-center justify-center self-stretch overflow-x-hidden"
data-testid="poster-preview-slot"
>
<ScaledPoster
config={config}
link={link}
inviteCode={inviteCode}
scale={previewScale}
testId="poster-preview"
/>
</div>
<div className="hidden self-center lg:flex lg:flex-col lg:items-center lg:gap-2.5">
<div className="text-meta font-medium text-foreground">更多样式</div>
{POSTER_TEMPLATES.map((t) => {
const thumbConfig: PosterConfig = { ...config, template: t };
const active = config.template.id === t.id;
return (
<button
key={t.id}
type="button"
onClick={() => setConfig((c) => ({ ...c, template: t }))}
className={cn(
"flex w-full flex-col items-center rounded-xl p-1 transition-shadow",
active ? "shadow-md ring-2 ring-primary" : "ring-1 ring-border hover:ring-primary/40",
)}
aria-label={`样式 ${t.name}`}
>
<ScaledPoster config={thumbConfig} link={link} inviteCode={inviteCode} scale={THUMB_SCALE} />
<div className="mt-1 text-center text-caption text-muted-foreground">{t.name}</div>
</button>
);
})}
</div>
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,115 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Loader2, RefreshCw, Share2 } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { createShare } from "@/lib/api/share";
import { CopyLinkItem } from "./channels/CopyLinkItem";
import { WeChatShareItem } from "./channels/WeChatShareItem";
/**
* 分享按钮 (受控 Popover): 分享的是【已发布的持久 URL】而非易失的沙箱预览。
* 未发布时父组件在 onOpenChange 里拦截去走发布确认流程 (分享即发布); 发布成功后
* 父组件置 open, 本组件懒建分享链并展示复制链接 / 微信二维码。
*/
export const ShareButton = ({
open,
onOpenChange,
deployUrl,
sessionId,
title,
}: {
open: boolean;
onOpenChange: (next: boolean) => void;
/** 已发布的持久 URL; null = 尚未发布 (点击后由父组件驱动发布)。 */
deployUrl: string | null;
sessionId: string;
title: string;
}) => {
const [landingUrl, setLandingUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState(false);
const activeRef = useRef(true);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
// 建分享链。creatingRef 去重 (StrictMode 双调用 / effect 与重试重复触发只建一次);
// setState 均在 async 微任务内, 不在 effect 同步体, 规避 react-hooks/set-state-in-effect。
const creatingRef = useRef(false);
const startShare = useCallback(() => {
if (!deployUrl || creatingRef.current) return;
creatingRef.current = true;
void (async () => {
setErr(false);
setLoading(true);
try {
const r = await createShare({
targetType: "session_artifact",
sessionId,
previewUrl: deployUrl,
title,
});
if (activeRef.current) setLandingUrl(r.landingUrl);
} catch {
if (activeRef.current) setErr(true);
} finally {
creatingRef.current = false;
if (activeRef.current) setLoading(false);
}
})();
}, [deployUrl, sessionId, title]);
// 面板打开且已有发布 URL、尚未建链 → 懒建 (含父组件发布成功后自动开面板的情形)。
useEffect(() => {
if (open && deployUrl && !landingUrl) startShare();
}, [open, deployUrl, landingUrl, startShare]);
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger
aria-label="分享"
title="分享"
className={cn(
buttonVariants({ variant: "ghost", size: "icon" }),
"text-muted-foreground",
)}
>
<Share2 size={16} strokeWidth={1.8} />
</PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="w-56 p-1.5">
{loading && (
<div className="flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" /> 生成分享链接…
</div>
)}
{err && (
<button
type="button"
onClick={startShare}
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm text-red-600 hover:bg-surface-2"
>
<RefreshCw className="size-4" />
生成失败,点击重试
</button>
)}
{landingUrl && (
<div className="flex flex-col">
<WeChatShareItem url={landingUrl} />
<CopyLinkItem url={landingUrl} />
</div>
)}
</PopoverContent>
</Popover>
);
};
@@ -0,0 +1,378 @@
import type { CodeBlock, Node } from '../contract'
import type { ComponentFacts, OutlineNode } from './reactTree'
import { describe, expect, it } from 'vitest'
import { createTypeScriptAdapter } from '../languages/typescript'
import { nodesToText, readFixture, walk, WASM_DIR, withBlocks } from '../testSupport'
import { bodyOf, classesOf, componentOf, declaredTypes, enclosingComponent, outlineOf, placesOf, propsOf } from './reactTree'
const ts = createTypeScriptAdapter(WASM_DIR)
const TITLEBAR = readFixture('desktopTitlebar.tsx.txt')
const LEGAL = readFixture('legalDocPage.tsx.txt')
const SETTINGS = readFixture('desktopSettingsDialog.tsx.txt')
const POSTER = readFixture('posterDialog.tsx.txt')
const DELETE_APP = readFixture('deleteAppDialog.tsx.txt')
const SHARE = readFixture('shareButton.tsx.txt')
const VAULT = readFixture('appVaultSummary.tsx.txt')
/** 源码里认出的各个组件(块树里先父后子),交给 use;语法树随后释放。 */
async function withComponents<T>(source: string, use: (components: ComponentFacts[], blocks: CodeBlock[]) => T): Promise<T> {
return withBlocks(ts, 'typescriptreact', source, (blocks) => {
const found: ComponentFacts[] = []
walk(blocks, (block) => {
const component = componentOf(block)
if (component)
found.push(component)
})
return use(found, blocks)
})
}
/** 名字是 name 的组件。 */
async function withComponent<T>(source: string, name: string, use: (component: ComponentFacts) => T): Promise<T> {
return withComponents(source, (components) => {
const component = components.find(item => item.name.text === name)
if (!component)
throw new Error(`没有组件 ${name}`)
return use(component)
})
}
/** 大纲写成文字:元素是「名字(组件带 *).类数+按条件切换的个数 属性… 文字」,下一层多缩进两格;条件、三元、列表照读法写。 */
function outlineLines(nodes: readonly OutlineNode[], pad = ''): string[] {
const text = (list: readonly Node[]): string => list.map(item => item.text.replace(/\s+/g, ' ').trim()).join('')
return nodes.flatMap((node): string[] => {
const when = 'when' in node && node.when ? `当 ${node.when.text} 时 ` : ''
switch (node.kind) {
case 'element': {
const classes = node.classes ? ` .${node.classes.base.length}${node.classes.conditions.length ? `+${node.classes.conditions.length}` : ''}${node.classes.dynamic.length ? '+?' : ''}` : ''
const head = `${node.name?.text ?? '<>'}${node.component ? '*' : ''}${classes}${node.attributes.map(attribute => ` ${attribute.text.replace(/\s+/g, ' ')}`).join('')}`
return [`${pad}${when}${head}${node.text.length ? ` 「${text(node.text)}」` : ''}`, ...outlineLines(node.children, `${pad} `)]
}
case 'text':
return [`${pad}${when}「${text(node.nodes)}」`]
case 'comment':
return [`${pad}${node.node.text}`]
case 'ternary':
return [`${pad}${when}如果 ${node.condition.text}`, `${pad} 是`, ...outlineLines(node.yes, `${pad} `), `${pad} 否`, ...outlineLines(node.no, `${pad} `)]
case 'map':
return [
`${pad}${when}对 ${node.source.text} 的每一项 ${node.item?.text ?? ''} key ${node.key?.text ?? '无'}`,
...node.locals.map(local => `${pad} 其中 ${local.text}`),
...outlineLines(node.children, `${pad} `),
]
default:
return [`${pad}空`]
}
})
}
describe('react 组件:认组件', () => {
it('luffy 的七个文件:大写开头、渲染 JSX 的函数声明 / 箭头函数常量都认,小写的工具函数、不渲染 JSX 的不认', async () => {
const summary = (components: ComponentFacts[]): string[] => components.map(component =>
`${component.name.text} ${component.exported ?? '不导出'}${component.client ? ' 客户端' : ''}${component.body.type === 'statement_block' ? '' : ' 直接返回'}`)
expect(await withComponents(TITLEBAR, summary)).toEqual(['DesktopTitlebar named 客户端'])
expect(await withComponents(LEGAL, summary)).toEqual(['LegalDocPage named 客户端'])
expect(await withComponents(SETTINGS, summary)).toEqual([
'SectionTitle 不导出 客户端 直接返回',
'GeneralSection 不导出 客户端',
'WorkspaceRootRow 不导出 客户端',
'RuntimeSection 不导出 客户端',
'DesktopSettingsDialog named 客户端',
])
expect(await withComponents(POSTER, summary)).toEqual(['StepLabel 不导出 客户端 直接返回', 'ScaledPoster 不导出 客户端', 'PosterDialog named 客户端'])
expect(await withComponents(DELETE_APP, summary)).toEqual(['DeleteAppDialog named 客户端'])
expect(await withComponents(SHARE, summary)).toEqual(['ShareButton named 客户端'])
expect(await withComponents(VAULT, summary)).toEqual(['AppVaultSummary named 客户端'])
})
it('memo / forwardRef 包着的、默认导出的、在文件末尾 export 的都认;返回 null 和 JSX 混着的也认', async () => {
const source = [
'import { forwardRef, memo } from "react";',
'export default function Page() { return <main /> }',
'export const Card = memo(forwardRef<HTMLDivElement, CardProps>((props, ref) => <div ref={ref} />));',
'const Row = React.memo(function Row({ id }: { id: string }) {',
' if (!id) return null;',
' return <li>{id}</li>;',
'});',
'function Hidden() { return null }',
'function Upper() { return 1 }',
'const lower = () => <div />;',
'export { Row };',
'const Late = () => <span />;',
'export default Late;',
].join('\n')
expect(await withComponents(source, components => components.map(component =>
`${component.name.text} ${component.wrappers.join('+') || '-'} ${component.exported ?? '不导出'} ${component.client}`))).toEqual([
'Page - default false',
'Card memo+forwardRef named false',
'Row memo named false',
'Late - default false',
])
})
it('说明是紧贴在前面的 JSDoc;组件体里的语句认得出它在哪个组件里', async () => {
await withComponents(LEGAL, (components, blocks) => {
expect(components[0].doc?.text.split('\n')[1]).toBe(' * 协议正文页面 (服务协议 / 隐私政策共享).')
const card = blocks.find(block => componentOf(block))
const inner = card?.children.map(child => enclosingComponent(child.node)?.name.text)
expect(inner?.every(name => name === 'LegalDocPage')).toBe(true)
// 副作用函数里面的语句不是组件体的直接子语句
const nested = card?.children.find(child => child.children.length > 0 && child.kind === 'statement')?.children[0]
expect(nested && enclosingComponent(nested.node)).toBeUndefined()
})
})
})
describe('react 组件:属性', () => {
it('luffy 的 DesktopTitlebar:行内类型字面量,解构出 platform,类型取字段的', async () => {
expect(await withComponent(TITLEBAR, 'DesktopTitlebar', component => nodesToText(propsOf(component)))).toEqual({
parameter: '{ platform }: { platform: DesktopShell["platform"] }',
type: '{ platform: DesktopShell["platform"] }',
fields: [{ name: 'platform', type: 'DesktopShell["platform"]', optional: false, callback: false, docs: [] }],
bindings: [{ key: 'platform', name: 'platform', rest: false }],
})
})
it('luffy 的 LegalDocPage:类型是同文件的接口,来自 LegalDocPageProps,字段带 JSDoc', async () => {
const props = await withComponent(LEGAL, 'LegalDocPage', component => nodesToText(propsOf(component)))
expect(props).toMatchObject({
from: 'LegalDocPageProps',
fields: [{ name: 'doc', type: '"terms" | "privacy"', optional: false, callback: false, docs: ['/** terms = 用户服务协议; privacy = 隐私政策 */'] }],
})
})
it('luffy 的 ShareButton:回调是函数类型,说明写在字段上面;StepLabel 的 children', async () => {
const share = await withComponent(SHARE, 'ShareButton', component => propsOf(component).fields?.map(field => `${field.name.text}${field.callback ? ' 回调' : ''}${field.docs.length ? ' 有说明' : ''}`))
expect(share).toEqual(['open', 'onOpenChange 回调', 'deployUrl 有说明', 'sessionId', 'title'])
const step = await withComponent(POSTER, 'StepLabel', component => nodesToText(propsOf(component).fields))
expect(step).toEqual([
{ name: 'n', type: 'number', optional: false, callback: false, docs: [] },
{ name: 'children', type: 'ReactNode', optional: false, callback: false, docs: [] },
])
})
it('默认值、改名、其余属性;方法签名算回调;FC<Props>、forwardRef<R, Props> 里的属性类型;导入的类型查不到字段', async () => {
const source = [
'interface Props { /** 大小 */ size?: number; onPick(v: string): void; label: string }',
'type Alias = Readonly<Props & { extra: boolean }>;',
'export const A = ({ size = 16, label: text, ...rest }: Props) => <i />;',
'export const B: FC<Alias> = (props) => <i />;',
'export const C = forwardRef<HTMLDivElement, Imported>(({ x }, ref) => <i ref={ref} />);',
'export function D() { return <i /> }',
].join('\n')
await withComponents(source, (components) => {
const [a, b, c, d] = components.map(propsOf)
expect(nodesToText(a.bindings)).toEqual([
{ key: 'size', name: 'size', default: '16', rest: false },
{ key: 'label', name: 'text', rest: false },
{ key: 'rest', name: 'rest', rest: true },
])
expect(a.fields?.map(field => `${field.name.text}${field.optional ? '?' : ''}${field.callback ? ' 回调' : ''} ${field.type?.text}${field.returns ? ` => ${field.returns.text}` : ''}`)).toEqual([
'size? number',
'onPick 回调 (v: string) => void',
'label string',
])
expect(b.from?.text).toBe('Alias')
expect(b.bindings).toEqual([])
expect(b.fields?.map(field => field.name.text)).toEqual(['size', 'onPick', 'label', 'extra'])
expect(c.from?.text).toBe('Imported')
expect(c.fields).toBeUndefined()
expect(d.parameter).toBeUndefined()
})
})
it('declaredTypes:文件顶层接口、类型别名的字段写成文字,交给工作区索引', async () => {
const source = [
'export interface CardProps {',
' /** 标题 */',
' title: string;',
' onClose?: () => void;',
' onPick(id: number): boolean;',
'}',
'type Other = { a: number };',
'type Name = string;',
].join('\n')
const types = await withBlocks(ts, 'typescriptreact', source, blocks => declaredTypes(blocks[0].node.tree.rootNode).map(entry => ({ name: entry.name.text, fields: entry.fields })))
expect(types).toEqual([
{ name: 'CardProps', fields: [
{ name: 'title', type: 'string', optional: false, callback: false, doc: '标题' },
{ name: 'onClose', type: '() => void', optional: true, callback: true },
{ name: 'onPick', type: '(id: number) => boolean', optional: false, callback: true },
] },
{ name: 'Other', fields: [{ name: 'a', type: 'number', optional: false, callback: false }] },
])
})
})
describe('react 组件:组件体', () => {
it('luffy 的 LegalDocPage:两个状态、一个引用、两处读数据、两个派生值、两个副作用、三个处理函数、一处渲染中重置、一处返回', async () => {
const facts = await withComponent(LEGAL, 'LegalDocPage', (component) => {
const body = bodyOf(component)
return {
states: nodesToText(body.states),
refs: nodesToText(body.refs),
reads: body.reads.map(read => `${read.target.text} = ${read.hook.text}`),
derived: body.derived.map(item => `${item.name.text}${item.memo ? ` ${item.memo.hook.text} ${item.memo.deps?.text}` : ''}`),
effects: body.effects.map(effect => `${effect.hook.text} ${effect.deps?.text} ${effect.cleanup ? '有清理' : '无清理'} ${effect.comments.map(comment => comment.text).join('')}`),
handlers: body.handlers.map(handler => handler.name.text),
resets: body.resets.map(reset => `${reset.condition.text}: ${reset.setters.map(setter => setter.text).join('、')}`),
returns: body.returns.map(item => `${item.when?.text ?? '否则'} ${item.value ? '渲染' : '不渲染'}`),
}
})
expect(facts.states).toMatchObject([
{ hook: 'useState', value: 'activeSection', setter: 'setActiveSection', initial: 'headings[0]?.id ?? ""' },
{ hook: 'useState', value: 'prevDoc', setter: 'setPrevDoc', initial: 'doc' },
])
expect(facts.refs).toMatchObject([{ name: 'scrollRef', initial: 'null' }])
expect(facts.reads).toEqual(['cfg = useSiteConfig', 'router = useRouter'])
expect(facts.derived).toEqual(['markdown', 'headings useMemo [markdown]'])
expect(facts.effects).toEqual([
'useEffect [doc] 无清理 // 切换文档时正文滚回顶部 (DOM 副作用).',
'useEffect [doc, headings] 有清理 // 滚动时按 heading.top 计算当前高亮章节.',
])
expect(facts.handlers).toEqual(['handleSectionClick', 'handlePrint', 'handleBack'])
expect(facts.resets).toEqual(['doc !== prevDoc: setPrevDoc、setActiveSection'])
expect(facts.returns).toEqual(['否则 渲染'])
})
it('luffy 的 DesktopSettingsDialog:先 return null 的提前返回带条件;StepLabel 直接返回的 JSX 是唯一的返回', async () => {
const returns = await withComponent(SETTINGS, 'DesktopSettingsDialog', component => bodyOf(component).returns.map(item => `${item.when?.text ?? '否则'} ${item.value ? '渲染' : '不渲染'}`))
expect(returns).toEqual(['!section 不渲染', '否则 渲染'])
const reads = await withComponent(SETTINGS, 'DesktopSettingsDialog', component => bodyOf(component).reads.map(read => `${read.target.text} = ${read.call.text}`))
expect(reads).toEqual([
'section = useDesktopShell((s) => s.settingsSection)',
'openSettings = useDesktopShell((s) => s.openSettings)',
'closeSettings = useDesktopShell((s) => s.closeSettings)',
])
expect(await withComponent(SETTINGS, 'DesktopSettingsDialog', component => bodyOf(component).derived.map(item => item.name.text))).toEqual(['platform'])
const step = await withComponent(POSTER, 'StepLabel', component => bodyOf(component).returns.length)
expect(step).toBe(1)
})
it('luffy 的 ShareButton:useCallback 算派生值、带依赖;没写依赖的副作用、只在挂载时的副作用', async () => {
const facts = await withComponent(SHARE, 'ShareButton', (component) => {
const body = bodyOf(component)
return {
derived: body.derived.map(item => `${item.name.text} ${item.memo?.hook.text} ${item.memo?.deps?.text}`),
refs: body.refs.map(ref => ref.name.text),
effects: body.effects.map(effect => effect.deps?.text),
}
})
expect(facts.derived).toEqual(['startShare useCallback [deployUrl, sessionId, title]'])
expect(facts.refs).toEqual(['activeRef', 'creatingRef'])
expect(facts.effects).toEqual(['[]', '[open, deployUrl, landingUrl, startShare]'])
})
})
describe('react 组件:用在哪', () => {
it('luffy 的 LegalDocPage:改状态的函数在副作用、渲染中重置里;引用挂在 <main> 的 ref 上、副作用和处理函数里读它;处理函数绑在按钮上', async () => {
const places = await withComponent(LEGAL, 'LegalDocPage', (component) => {
const body = bodyOf(component)
const say = (name: Node): string[] => placesOf(name, component, body).map((place) => {
switch (place.kind) {
case 'effect':
return `副作用 ${place.index + 1}`
case 'handler':
case 'derived':
return `${place.kind} ${place.name.text}`
case 'reset':
return `重置 ${place.condition.text}`
case 'jsx':
return `<${place.element.childForFieldName('name')?.text}> ${place.attribute.text}${place.each ? ' 每个' : ''}${place.call ? ` 调用 ${place.call.text}` : ''}`
default:
return place.kind
}
})
return {
setActiveSection: say(body.states[0].setter as Node),
scrollRef: say(body.refs[0].name),
handleSectionClick: say(body.handlers[0].name),
handleBack: say(body.handlers[2].name),
router: say(body.reads[1].target),
}
})
expect(places).toEqual({
setActiveSection: ['重置 doc !== prevDoc', '副作用 2'],
scrollRef: ['副作用 1', '副作用 2', 'handler handleSectionClick', '<main> ref'],
handleSectionClick: ['<button> onClick 每个 调用 handleSectionClick(h.id)'],
handleBack: ['<button> onClick'],
router: ['handler handleBack'],
})
})
})
describe('react 组件:渲染的 JSX', () => {
it('luffy 的 DesktopTitlebar:原生标签、组件、类、属性,cond && <X/> 带条件,只有插值的写在同一行', async () => {
const lines = await withComponent(TITLEBAR, 'DesktopTitlebar', component => outlineLines(outlineOf(bodyOf(component).returns[0].value)))
expect(lines).toEqual([
'div .8 data-tauri-drag-region="deep"',
' div .3',
' 当 platform === "macos" 时 span .1 data-testid="traffic-light-inset" aria-hidden',
' div .6',
' span .4 「{center}」',
' 当 platform === "windows" 时 WindowControls*',
])
})
it('luffy 的 LegalDocPage:JSX 注释、按条件切换的类、列表(其中先算的常量、key)、三元的是 / 否两支', async () => {
const lines = await withComponent(LEGAL, 'LegalDocPage', component => outlineLines(outlineOf(bodyOf(component).returns[0].value)))
expect(lines.slice(0, 5)).toEqual([
'div .9',
' /* 顶部导航栏: 返回 + 文档切换 + 打印 */',
' header .13',
' div .3',
' button .10 type="button" onClick={handleBack} aria-label="返回"',
])
expect(lines).toContain(' Link* .12+1 href="/terms"')
expect(lines).toContain(' 对 headings 的每一项 h key h.id')
expect(lines).toContain(' 其中 isActive = activeSection === h.id')
expect(lines).toContain(' button .7+1 type="button" onClick={() => handleSectionClick(h.id)} 「{h.title}」')
const ternary = lines.indexOf(' 如果 markdown.trim()')
expect(lines.slice(ternary, ternary + 3)).toEqual([' 如果 markdown.trim()', ' 是', ' div .3'])
})
it('luffy 的 DeleteAppDialog 的删除按钮:文字的三元;列表解构每一项;AppVaultSummary 的片段', async () => {
const lines = await withComponent(DELETE_APP, 'DeleteAppDialog', component => outlineLines(outlineOf(bodyOf(component).returns[0].value)))
expect(lines).toContain(' 对 losses 的每一项 { icon: Icon, text } key text')
expect(lines).toContain(' li .3')
expect(lines).toContain(' Icon* .3 size={14}')
const button = lines.indexOf(' Button* variant="destructive" onClick={() => remove.mutate()} disabled={!ready || pending}')
expect(lines.slice(button, button + 6)).toEqual([
' Button* variant="destructive" onClick={() => remove.mutate()} disabled={!ready || pending}',
' 如果 pending',
' 是',
' 「"删除中…"」',
' 否',
' 「"确认删除并清空数据"」',
])
const vault = await withComponent(VAULT, 'AppVaultSummary', component => outlineLines(outlineOf(bodyOf(component).returns[0].value)))
expect(vault[0]).toBe('<>')
expect(vault[2]).toBe(' 当 effective.length > 0 时 ul .2')
})
it('classesOf:模板字符串里的三元、cn(…) 的实参、clsx 的对象写法、算出来的表达式', async () => {
const source = [
['const a = <div className={`p-1 m-2 $', '{on ? "x y" : "z"}`} />;'].join(''),
'const b = <div className={cn("h-1 w-1", STATE_DOT[w.state], active && "ring")} />;',
'const c = <div className={clsx({ "is-on": on, big })} />;',
'const d = <div className="flex gap-2" />;',
].join('\n')
const facts = await withBlocks(ts, 'typescriptreact', source, (blocks) => {
const values: unknown[] = []
const visit = (node: Node): void => {
if (node.type === 'jsx_attribute' && node.namedChildren[0]?.text === 'className' && node.namedChildren[1])
values.push(nodesToText(classesOf(node.namedChildren[1])))
node.namedChildren.forEach(child => child && visit(child))
}
visit(blocks[0].node.tree.rootNode)
return values
})
expect(facts).toEqual([
{ base: ['p-1', 'm-2'], conditions: [{ condition: 'on', yes: ['x', 'y'], no: ['z'] }], dynamic: [] },
{ base: ['h-1', 'w-1'], conditions: [{ condition: 'active', yes: ['ring'], no: [] }], dynamic: ['STATE_DOT[w.state]'] },
{ base: [], conditions: [{ condition: 'on', yes: ['is-on'], no: [] }], dynamic: ['big'] },
{ base: ['flex', 'gap-2'], conditions: [], dynamic: [] },
])
})
})
File diff suppressed because it is too large. Load diff