✨ 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>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user