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-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.16",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View File

@ -23,6 +23,9 @@ importers:
'@radix-ui/react-slot':
specifier: ^1.2.3
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':
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))
@ -702,6 +705,19 @@ packages:
'@types/react':
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':
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
peerDependencies:
@ -774,6 +790,19 @@ packages:
'@types/react':
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':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
@ -2390,6 +2419,26 @@ snapshots:
optionalDependencies:
'@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)':
dependencies:
react: 19.2.0
@ -2444,6 +2493,15 @@ snapshots:
optionalDependencies:
'@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': {}
'@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 = {
projectId: string;
projectId: string;
};
export function HeaderConnectionIndicator({ projectId }: Props) {
const snap = useFileConnections(projectId);
const list = Object.values(snap.connections);
const hasAny = list.length > 0;
const snap = useFileConnections(projectId);
const list = Object.values(snap.connections);
const hasAny = list.length > 0;
return (
<div className="relative group">
<button
type="button"
className={`px-2 h-8 rounded border text-sm bg-white ${hasAny ? "text-foreground" : "text-muted-foreground"}`}
title={hasAny ? "已连线文件" : "未连线到任何文件"}
>
{hasAny ? `已连线 ${list.length}` : "未连线"}
</button>
<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">
<div className="p-3">
<div className="text-sm font-medium mb-2">线</div>
{list.length === 0 ? (
<div className="text-sm text-muted-foreground">线 JSON线</div>
) : (
<div className="space-y-2">
{list.map((c) => (
<div key={c.language} className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-sm font-medium truncate">{c.language}</div>
<div className="text-xs text-muted-foreground truncate" title={c.name}>
{c.name}
</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>
)}
</div>
</div>
</div>
);
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
title={hasAny ? "已连线文件" : "未连线到任何文件"}
>
<span
className={cn(
"size-2 rounded-full",
hasAny ? "bg-green-500" : "bg-red-500"
)}
></span>
{hasAny ? `已连线 ${list.length}` : "未连线"}
</Button>
</TooltipTrigger>
<TooltipContent className="p-4">
<div className="text-sm font-medium mb-2">线</div>
{list.length === 0 ? (
<div className="text-sm text-muted-foreground">
线 JSON线
</div>
) : (
<div className="space-y-2">
{list.map((c) => (
<div
key={c.language}
className="flex items-start justify-between gap-2"
>
<div className="min-w-0">
<div className="text-sm font-medium truncate">
{c.language}
</div>
<div
className="text-xs text-muted-foreground truncate"
title={c.name}
>
{c.name}
</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 { Checkbox } from "@/components/ui/checkbox";
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 { HeaderConnectionIndicator } from "@/components/biz/header-connection-indicator";
import { SyncFromFilesButton } from "@/components/biz/sync-from-files-button";
@ -457,14 +457,6 @@ export default function Editor() {
};
}, [projectId, languages]);
useEffect(() => {
return () => {
if (projectId) {
clearAllConnections(projectId);
}
};
}, [projectId]);
return (
<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">

View File

@ -5,6 +5,8 @@ import { Button } from "@/components/ui/button";
import { createProject, deleteProjectDeep, listProjects, type Project, updateProject } from "@/lib/db";
import { ProjectSettingsModal } from "@/components/biz/project-settings-modal";
import { Settings } from "lucide-react";
import { useConnectionFlags } from "@/store/file-connection";
import { cn } from "@/lib/utils";
function formatTime(ts: number) {
try {
@ -23,6 +25,7 @@ function App() {
const [settingsOpen, setSettingsOpen] = useState(false);
const [currentProject, setCurrentProject] = useState<Project | null>(null);
const navigate = useNavigate();
const flags = useConnectionFlags();
async function refresh() {
setLoading(true);
@ -88,39 +91,52 @@ function App() {
<div className="text-sm text-muted-foreground"></div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{projects.map((p) => (
<div
key={p.id}
className="rounded-md border p-4 hover:shadow-sm transition cursor-pointer relative"
onClick={() => navigate(`/editor/${p.id}`)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") navigate(`/editor/${p.id}`);
}}
>
<button
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);
{projects.map((p) => {
const hasConn = !!flags[p.id];
return (
<div
key={p.id}
className="rounded-md border p-4 hover:shadow-sm transition cursor-pointer relative"
onClick={() => navigate(`/editor/${p.id}`)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") navigate(`/editor/${p.id}`);
}}
>
<Settings className="size-4" />
</button>
<div className="truncate font-medium pr-8">{p.name}</div>
<div className="mt-1 text-xs text-muted-foreground truncate">ID: {p.id}</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}
<button
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>
<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>
) : null}
</div>
))}
<div className="mt-1 text-xs text-muted-foreground truncate">ID: {p.id}</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>

View File

@ -16,6 +16,10 @@ type Listener = () => void;
// projectId -> Snapshot
const state = new Map<string, Snapshot>();
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) {
if (!state.has(projectId)) {
@ -28,8 +32,18 @@ function ensureProject(projectId: string) {
function emit(projectId: string) {
const set = listeners.get(projectId);
if (!set) return;
for (const l of Array.from(set)) {
if (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 {
l();
} catch {
@ -56,6 +70,28 @@ export function isFilePickerSupported(): boolean {
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> {
if (!isFilePickerSupported()) {
toast.error("当前浏览器不支持文件系统访问 APIshowOpenFilePicker");