From 3b42be9002dece1e83b35a68bb4282e61b8086c3 Mon Sep 17 00:00:00 2001 From: cruldra Date: Wed, 30 Sep 2026 17:00:16 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E9=98=85=E8=AF=BB=E8=AE=A4=E5=87=BA=20React=20=E5=87=BD?= =?UTF-8?q?=E6=95=B0=E7=BB=84=E4=BB=B6=EF=BC=8C=E8=AF=BB=E5=87=BA=E5=B1=9E?= =?UTF-8?q?=E6=80=A7=E3=80=81=E7=8A=B6=E6=80=81=E3=80=81=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E3=80=81=E8=AF=BB=E5=8F=96=E7=9A=84=E6=95=B0=E6=8D=AE=E3=80=81?= =?UTF-8?q?=E6=B4=BE=E7=94=9F=E5=80=BC=E3=80=81=E5=89=AF=E4=BD=9C=E7=94=A8?= =?UTF-8?q?=E3=80=81=E5=A4=84=E7=90=86=E5=87=BD=E6=95=B0=E5=92=8C=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E7=9A=84=20JSX=20=E5=A4=A7=E7=BA=B2=EF=BC=88=E7=BA=AF?= =?UTF-8?q?=E5=87=BD=E6=95=B0=EF=BC=8Cluffy=20=E4=B8=83=E4=B8=AA=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E7=BB=84=E4=BB=B6=E5=BD=93=E6=B5=8B=E8=AF=95=E6=A0=B7?= =?UTF-8?q?=E6=9C=AC=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fixtures/appVaultSummary.tsx.txt | 48 + .../fixtures/deleteAppDialog.tsx.txt | 127 ++ .../fixtures/desktopSettingsDialog.tsx.txt | 226 ++++ .../fixtures/desktopTitlebar.tsx.txt | 31 + .../codeReader/fixtures/legalDocPage.tsx.txt | 285 +++++ .../codeReader/fixtures/posterDialog.tsx.txt | 216 ++++ .../codeReader/fixtures/shareButton.tsx.txt | 115 ++ vscode/src/codeReader/rules/reactTree.test.ts | 378 ++++++ vscode/src/codeReader/rules/reactTree.ts | 1115 +++++++++++++++++ 9 files changed, 2541 insertions(+) create mode 100644 vscode/src/codeReader/fixtures/appVaultSummary.tsx.txt create mode 100644 vscode/src/codeReader/fixtures/deleteAppDialog.tsx.txt create mode 100644 vscode/src/codeReader/fixtures/desktopSettingsDialog.tsx.txt create mode 100644 vscode/src/codeReader/fixtures/desktopTitlebar.tsx.txt create mode 100644 vscode/src/codeReader/fixtures/legalDocPage.tsx.txt create mode 100644 vscode/src/codeReader/fixtures/posterDialog.tsx.txt create mode 100644 vscode/src/codeReader/fixtures/shareButton.tsx.txt create mode 100644 vscode/src/codeReader/rules/reactTree.test.ts create mode 100644 vscode/src/codeReader/rules/reactTree.ts diff --git a/vscode/src/codeReader/fixtures/appVaultSummary.tsx.txt b/vscode/src/codeReader/fixtures/appVaultSummary.tsx.txt new file mode 100644 index 0000000..a8cced7 --- /dev/null +++ b/vscode/src/codeReader/fixtures/appVaultSummary.tsx.txt @@ -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 ( + <> + + 管理 + + } + /> + {effective.length > 0 && ( + + )} + + ); +}; diff --git a/vscode/src/codeReader/fixtures/deleteAppDialog.tsx.txt b/vscode/src/codeReader/fixtures/deleteAppDialog.tsx.txt new file mode 100644 index 0000000..ec1309f --- /dev/null +++ b/vscode/src/codeReader/fixtures/deleteAppDialog.tsx.txt @@ -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 ( + { + if (pending) return; + onOpenChange(next); + if (!next) setConfirm(""); + }} + > + + + 删除应用「{app.title}」? + 删掉之后没有任何地方能把它找回来。 + + +
    + {losses.map(({ icon: Icon, text }) => ( +
  • + + {text} +
  • + ))} +
+ + {hasDeployment && ( +

+ + 占用的 1 个部署位退回,可以给别的应用用 +

+ )} + +
+ + 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" + /> +
+ + + + + +
+
+ ); +}; diff --git a/vscode/src/codeReader/fixtures/desktopSettingsDialog.tsx.txt b/vscode/src/codeReader/fixtures/desktopSettingsDialog.tsx.txt new file mode 100644 index 0000000..6452569 --- /dev/null +++ b/vscode/src/codeReader/fixtures/desktopSettingsDialog.tsx.txt @@ -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; +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 = { + WORKSPACE_BUSY: "有命令或任务正在运行, 等它结束或停掉后再更改目录", + TARGET_NOT_EMPTY: "目标目录不是空的, 换一个", + TARGET_INSIDE_WORKSPACE: "目标目录不能在当前工作区里面", +}; + +const SectionTitle = ({ children }: { children: string }) => ( +

{children}

+); + +/** 通用节: 三个开关读壳的设置文件, 翻一个立即写回, 以壳回的为准; 写不进去就翻回来并说原因. */ +const GeneralSection = () => { + const [settings, setSettings] = useState(null); + const [saveError, setSaveError] = useState(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 ( + <> + 通用 + {GENERAL_SWITCHES.map((g) => ( +
+ void toggle(g.key, v)} + ariaLabel={g.label} + /> + } + /> +
+ ))} + {saveError &&

{saveError}

} + + ); +}; + +/** 工作区目录行: 「更改」选一个新目录, 壳把整个工作区搬过去; 目录行随壳的状态事件更新. */ +const WorkspaceRootRow = () => { + const workspaceRoot = useDesktopShell((s) => displayPath(s.workspace_root, s.home)); + const [moving, setMoving] = useState(false); + const [moveError, setMoveError] = useState(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 ( +
+ 工作区目录 +
+

+ {workspaceRoot} +

+ +
+ {moveError &&

{moveError}

} +
+ ); +}; + +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 ( +
+
+ +
+

{device.name || "本机"}

+ {deviceFacts &&

{deviceFacts}

} +
+
+ +
+ 各应用占用 + {workspaces.map((w) => ( +
+ +
+

+ {workspaceTitles[w.subdir] ?? "未知应用"} +

+

+ {w.subdir} · {STATE_NOTE[w.state]} +

+
+ {w.size} +
+ ))} +
+
+ 活动日志 · 最近 200 条 + {activity.map((a) => ( +
+ {a.time} + {a.method} + {a.detail} +
+ ))} +
+
+ ); +}; + +/** 设置弹层: 通用 / 外观 / 本机运行时 / 快捷键. 标题栏、托盘、快捷键都经 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 ( + !next && closeSettings()}> + + openSettings(value)} + orientation="vertical" + className="flex h-full min-h-0 flex-row gap-0" + > + +
+ + + + + 外观 + + + + + + + + 全局 + {SHORTCUTS(platform).map((s) => ( +
+ {s.label} + {s.key} +
+ ))} +
+
+
+
+
+ ); +}; diff --git a/vscode/src/codeReader/fixtures/desktopTitlebar.tsx.txt b/vscode/src/codeReader/fixtures/desktopTitlebar.tsx.txt new file mode 100644 index 0000000..2c0be6f --- /dev/null +++ b/vscode/src/codeReader/fixtures/desktopTitlebar.tsx.txt @@ -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 ( +
+
+ {platform === "macos" && } +
+
+ {center} +
+ {platform === "windows" && } +
+ ); +}; diff --git a/vscode/src/codeReader/fixtures/legalDocPage.tsx.txt b/vscode/src/codeReader/fixtures/legalDocPage.tsx.txt new file mode 100644 index 0000000..60f3d7b --- /dev/null +++ b/vscode/src/codeReader/fixtures/legalDocPage.tsx.txt @@ -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 = { + 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(null); + const [activeSection, setActiveSection] = useState( + 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("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(`[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 ( +
+ {/* 顶部导航栏: 返回 + 文档切换 + 打印 */} +
+
+ +
+
+ +
+ 法律与隐私 +
+
+ + {/* 双文档切换 (Apple Segmented Control) */} +
+ + + 服务协议 + + + + 隐私政策 + +
+ +
+ +
+
+ + {/* 主体阅读区 (双栏布局) */} +
+ {/* 左侧 TOC, 仅 lg 显示 */} + + + {/* 右侧正文 */} +
+
+
+

+ {DOC_TITLE[doc]} +

+
+ + {markdown.trim() ? ( +
+ ( +

+ {children} +

+ ), + a: ({ href, children }) => ( + + {children} + + ), + }} + > + {markdown} +
+
+ ) : ( +

+ {DOC_TITLE[doc]}内容尚未配置, 请在管理后台“站点配置”中填写。 +

+ )} +
+
+
+ + {/* 打印态与滚动条样式 */} +