✨ feat(vscode): 代码阅读认出 React 函数组件,读出属性、状态、引用、读取的数据、派生值、副作用、处理函数和渲染的 JSX 大纲(纯函数,luffy 七个真实组件当测试样本)
This commit is contained in:
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
Reference in new issue
Block a user