Feat: 取消自动断线,回到首页后能查看项目的连线情况

This commit is contained in:
奇趣保罗 2025-12-15 14:32:49 +08:00
parent ff1cb22475
commit 735b37a617
7 changed files with 271 additions and 88 deletions

View File

@ -16,6 +16,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.16", "@tailwindcss/vite": "^4.1.16",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",

View File

@ -23,6 +23,9 @@ importers:
'@radix-ui/react-slot': '@radix-ui/react-slot':
specifier: ^1.2.3 specifier: ^1.2.3
version: 1.2.3(@types/react@19.2.2)(react@19.2.0) version: 1.2.3(@types/react@19.2.2)(react@19.2.0)
'@radix-ui/react-tooltip':
specifier: ^1.2.8
version: 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.1.16 specifier: ^4.1.16
version: 4.1.16(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)) version: 4.1.16(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2))
@ -702,6 +705,19 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/react-tooltip@1.2.8':
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-use-callback-ref@1.1.1': '@radix-ui/react-use-callback-ref@1.1.1':
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
peerDependencies: peerDependencies:
@ -774,6 +790,19 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/react-visually-hidden@1.2.3':
resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/rect@1.1.1': '@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
@ -2390,6 +2419,26 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.2 '@types/react': 19.2.2
'@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0)
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
optionalDependencies:
'@types/react': 19.2.2
'@types/react-dom': 19.2.2(@types/react@19.2.2)
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.2)(react@19.2.0)': '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.2)(react@19.2.0)':
dependencies: dependencies:
react: 19.2.0 react: 19.2.0
@ -2444,6 +2493,15 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.2 '@types/react': 19.2.2
'@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
optionalDependencies:
'@types/react': 19.2.2
'@types/react-dom': 19.2.2(@types/react@19.2.2)
'@radix-ui/rect@1.1.1': {} '@radix-ui/rect@1.1.1': {}
'@rolldown/pluginutils@1.0.0-beta.43': {} '@rolldown/pluginutils@1.0.0-beta.43': {}

View File

@ -1,54 +1,75 @@
import { disconnectLanguage, useFileConnections } from "@/store/file-connection"; import {
disconnectLanguage,
useFileConnections,
} from "@/store/file-connection";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { Button } from "../ui/button";
import { cn } from "@/lib/utils";
type Props = { type Props = {
projectId: string; projectId: string;
}; };
export function HeaderConnectionIndicator({ projectId }: Props) { export function HeaderConnectionIndicator({ projectId }: Props) {
const snap = useFileConnections(projectId); const snap = useFileConnections(projectId);
const list = Object.values(snap.connections); const list = Object.values(snap.connections);
const hasAny = list.length > 0; const hasAny = list.length > 0;
return ( return (
<div className="relative group"> <Tooltip>
<button <TooltipTrigger asChild>
type="button" <Button
className={`px-2 h-8 rounded border text-sm bg-white ${hasAny ? "text-foreground" : "text-muted-foreground"}`} variant="outline"
title={hasAny ? "已连线文件" : "未连线到任何文件"} title={hasAny ? "已连线文件" : "未连线到任何文件"}
> >
{hasAny ? `已连线 ${list.length}` : "未连线"} <span
</button> className={cn(
<div className="invisible opacity-0 group-hover:visible group-hover:opacity-100 transition-opacity duration-150 absolute right-0 top-full mt-2 z-50 w-80 rounded-md border bg-white text-foreground shadow"> "size-2 rounded-full",
<div className="p-3"> hasAny ? "bg-green-500" : "bg-red-500"
<div className="text-sm font-medium mb-2">线</div> )}
{list.length === 0 ? ( ></span>
<div className="text-sm text-muted-foreground">线 JSON线</div> {hasAny ? `已连线 ${list.length}` : "未连线"}
) : ( </Button>
<div className="space-y-2"> </TooltipTrigger>
{list.map((c) => ( <TooltipContent className="p-4">
<div key={c.language} className="flex items-start justify-between gap-2"> <div className="text-sm font-medium mb-2">线</div>
<div className="min-w-0"> {list.length === 0 ? (
<div className="text-sm font-medium truncate">{c.language}</div> <div className="text-sm text-muted-foreground">
<div className="text-xs text-muted-foreground truncate" title={c.name}> 线 JSON线
{c.name} </div>
</div> ) : (
</div> <div className="space-y-2">
<button {list.map((c) => (
type="button" <div
className="px-2 h-7 rounded border text-xs hover:bg-accent" key={c.language}
onClick={() => disconnectLanguage(projectId, c.language)} className="flex items-start justify-between gap-2"
> >
<div className="min-w-0">
</button> <div className="text-sm font-medium truncate">
</div> {c.language}
))} </div>
<div className="text-xs text-muted-foreground"> <div
线 className="text-xs text-muted-foreground truncate"
</div> title={c.name}
</div> >
)} {c.name}
</div> </div>
</div> </div>
</div> <button
); type="button"
className="px-2 h-7 rounded border text-xs hover:bg-accent"
onClick={() => disconnectLanguage(projectId, c.language)}
>
</button>
</div>
))}
<div className="text-xs text-muted-foreground">
线
</div>
</div>
)}
</TooltipContent>
</Tooltip>
);
} }

View File

@ -0,0 +1,59 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -38,7 +38,7 @@ import { useClipboard } from "@/hooks/use-clipboard";
import { toast } from "sonner"; import { toast } from "sonner";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { clearAllConnections, useFileConnections, writeLanguageToConnectedFile } from "@/store/file-connection"; import { useFileConnections, writeLanguageToConnectedFile } from "@/store/file-connection";
import { generateLanguageJson } from "@/lib/utils"; import { generateLanguageJson } from "@/lib/utils";
import { HeaderConnectionIndicator } from "@/components/biz/header-connection-indicator"; import { HeaderConnectionIndicator } from "@/components/biz/header-connection-indicator";
import { SyncFromFilesButton } from "@/components/biz/sync-from-files-button"; import { SyncFromFilesButton } from "@/components/biz/sync-from-files-button";
@ -457,14 +457,6 @@ export default function Editor() {
}; };
}, [projectId, languages]); }, [projectId, languages]);
useEffect(() => {
return () => {
if (projectId) {
clearAllConnections(projectId);
}
};
}, [projectId]);
return ( return (
<div className="h-screen flex flex-col"> <div className="h-screen flex flex-col">
<header className="h-14 px-2 md:px-4 from-blue-400 to-cyan-400 bg-linear-to-r text-white"> <header className="h-14 px-2 md:px-4 from-blue-400 to-cyan-400 bg-linear-to-r text-white">

View File

@ -5,6 +5,8 @@ import { Button } from "@/components/ui/button";
import { createProject, deleteProjectDeep, listProjects, type Project, updateProject } from "@/lib/db"; import { createProject, deleteProjectDeep, listProjects, type Project, updateProject } from "@/lib/db";
import { ProjectSettingsModal } from "@/components/biz/project-settings-modal"; import { ProjectSettingsModal } from "@/components/biz/project-settings-modal";
import { Settings } from "lucide-react"; import { Settings } from "lucide-react";
import { useConnectionFlags } from "@/store/file-connection";
import { cn } from "@/lib/utils";
function formatTime(ts: number) { function formatTime(ts: number) {
try { try {
@ -23,6 +25,7 @@ function App() {
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [currentProject, setCurrentProject] = useState<Project | null>(null); const [currentProject, setCurrentProject] = useState<Project | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const flags = useConnectionFlags();
async function refresh() { async function refresh() {
setLoading(true); setLoading(true);
@ -88,39 +91,52 @@ function App() {
<div className="text-sm text-muted-foreground"></div> <div className="text-sm text-muted-foreground"></div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{projects.map((p) => ( {projects.map((p) => {
<div const hasConn = !!flags[p.id];
key={p.id}
className="rounded-md border p-4 hover:shadow-sm transition cursor-pointer relative" return (
onClick={() => navigate(`/editor/${p.id}`)} <div
role="button" key={p.id}
tabIndex={0} className="rounded-md border p-4 hover:shadow-sm transition cursor-pointer relative"
onKeyDown={(e) => { onClick={() => navigate(`/editor/${p.id}`)}
if (e.key === "Enter" || e.key === " ") navigate(`/editor/${p.id}`); role="button"
}} tabIndex={0}
> onKeyDown={(e) => {
<button if (e.key === "Enter" || e.key === " ") navigate(`/editor/${p.id}`);
type="button"
aria-label="项目设置"
className="absolute top-2 right-2 inline-flex items-center justify-center rounded-md border px-2 py-1 text-xs text-muted-foreground hover:bg-accent"
onClick={(e) => {
e.stopPropagation();
setCurrentProject(p);
setSettingsOpen(true);
}} }}
> >
<Settings className="size-4" /> <button
</button> type="button"
<div className="truncate font-medium pr-8">{p.name}</div> aria-label="项目设置"
<div className="mt-1 text-xs text-muted-foreground truncate">ID: {p.id}</div> className="absolute top-2 right-2 inline-flex items-center justify-center rounded-md border px-2 py-1 text-xs text-muted-foreground hover:bg-accent"
<div className="mt-1 text-xs text-muted-foreground">: {formatTime(p.createdAt)}</div> onClick={(e) => {
{p.preferences?.aiPrompt ? ( e.stopPropagation();
<div className="mt-3 text-xs line-clamp-3 text-muted-foreground"> setCurrentProject(p);
{p.preferences.aiPrompt} setSettingsOpen(true);
}}
>
<Settings className="size-4" />
</button>
<div className="truncate font-medium pr-8 flex items-center gap-2">
<span
className={cn(
"inline-block size-2 rounded-full",
hasConn ? "bg-green-500" : "bg-zinc-300"
)}
title={hasConn ? "已连线" : "未连线"}
/>
{p.name}
</div> </div>
) : null} <div className="mt-1 text-xs text-muted-foreground truncate">ID: {p.id}</div>
</div> <div className="mt-1 text-xs text-muted-foreground">: {formatTime(p.createdAt)}</div>
))} {p.preferences?.aiPrompt ? (
<div className="mt-3 text-xs line-clamp-3 text-muted-foreground">
{p.preferences.aiPrompt}
</div>
) : null}
</div>
);
})}
</div> </div>
)} )}
</div> </div>

View File

@ -16,6 +16,10 @@ type Listener = () => void;
// projectId -> Snapshot // projectId -> Snapshot
const state = new Map<string, Snapshot>(); const state = new Map<string, Snapshot>();
const listeners = new Map<string, Set<Listener>>(); const listeners = new Map<string, Set<Listener>>();
const globalListeners = new Set<Listener>();
let globalVersion = 0;
let cachedFlags: Record<string, boolean> | null = null;
let cachedFlagsVersion = -1;
function ensureProject(projectId: string) { function ensureProject(projectId: string) {
if (!state.has(projectId)) { if (!state.has(projectId)) {
@ -28,8 +32,18 @@ function ensureProject(projectId: string) {
function emit(projectId: string) { function emit(projectId: string) {
const set = listeners.get(projectId); const set = listeners.get(projectId);
if (!set) return; if (set) {
for (const l of Array.from(set)) { for (const l of Array.from(set)) {
try {
l();
} catch {
// ignore
}
}
}
// bump global version and invalidate cache
globalVersion += 1;
for (const l of Array.from(globalListeners)) {
try { try {
l(); l();
} catch { } catch {
@ -56,6 +70,28 @@ export function isFilePickerSupported(): boolean {
return typeof window !== "undefined" && typeof (window as unknown as { showOpenFilePicker?: unknown }).showOpenFilePicker === "function"; return typeof window !== "undefined" && typeof (window as unknown as { showOpenFilePicker?: unknown }).showOpenFilePicker === "function";
} }
export function useConnectionFlags(): Record<string, boolean> {
return useSyncExternalStore(
(l) => {
globalListeners.add(l);
return () => globalListeners.delete(l);
},
() => {
if (cachedFlags && cachedFlagsVersion === globalVersion) {
return cachedFlags;
}
const flags: Record<string, boolean> = {};
for (const [pid, snap] of state.entries()) {
flags[pid] = Object.keys(snap.connections).length > 0;
}
cachedFlags = flags;
cachedFlagsVersion = globalVersion;
return cachedFlags;
},
() => ({})
);
}
export async function connectLanguageToFile(projectId: string, language: string): Promise<{ text: string; connection: LanguageConnection } | null> { export async function connectLanguageToFile(projectId: string, language: string): Promise<{ text: string; connection: LanguageConnection } | null> {
if (!isFilePickerSupported()) { if (!isFilePickerSupported()) {
toast.error("当前浏览器不支持文件系统访问 APIshowOpenFilePicker"); toast.error("当前浏览器不支持文件系统访问 APIshowOpenFilePicker");