From 70c09558d1034dc120fc579cdd7ddf26c2107d50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A5=87=E8=B6=A3=E4=BF=9D=E7=BD=97?= Date: Mon, 24 Nov 2025 16:29:22 +0800 Subject: [PATCH] =?UTF-8?q?Feat:=20=E5=A2=9E=E5=8A=A0=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E8=BF=9E=E7=BA=BF=E7=BC=96=E8=BE=91=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E5=8F=AF=E4=B8=80=E9=94=AE=E8=A6=86=E7=9B=96=E5=8E=9F=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=BF=9D=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/biz/export-language-modal.tsx | 24 ++- .../biz/header-connection-indicator.tsx | 54 ++++++ src/components/biz/import-language-modal.tsx | 38 ++-- src/pages/editor.tsx | 29 ++- src/store/file-connection.ts | 173 ++++++++++++++++++ 5 files changed, 285 insertions(+), 33 deletions(-) create mode 100644 src/components/biz/header-connection-indicator.tsx create mode 100644 src/store/file-connection.ts diff --git a/src/components/biz/export-language-modal.tsx b/src/components/biz/export-language-modal.tsx index 10128e1..b0b7e1e 100644 --- a/src/components/biz/export-language-modal.tsx +++ b/src/components/biz/export-language-modal.tsx @@ -1,5 +1,5 @@ import { memo, useEffect, useMemo, useState } from "react"; -import { Check } from "lucide-react"; +import { Check, Copy, Download, Save } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -10,10 +10,12 @@ import { } from "@/components/ui/dialog"; import { unflattenValues } from "@/lib/i18n-structure"; import { useClipboard } from "@/hooks/use-clipboard"; +import { useFileConnections, writeLanguageToConnectedFile } from "@/store/file-connection"; type Props = { open: boolean; onOpenChange: (next: boolean) => void; + projectId: string; languages: string[]; valuesByLang: Record>; orderedPaths?: string[]; // 优先按照结构顺序导出 @@ -22,12 +24,14 @@ type Props = { function ExportLanguageModalImpl({ open, onOpenChange, + projectId, languages, valuesByLang, orderedPaths, }: Props) { const [selected, setSelected] = useState(""); const { copied, copy } = useClipboard({ resetAfterMs: 1500 }); + const connSnap = useFileConnections(projectId); useEffect(() => { if (open) { @@ -115,6 +119,13 @@ function ExportLanguageModalImpl({ copy(jsonText); } + const hasConnectedTarget = !!(selected && connSnap.connections[selected]); + + const handleWriteToFile = async () => { + if (!selected || !jsonText) return; + await writeLanguageToConnectedFile(projectId, selected, jsonText); + }; + return ( @@ -155,10 +166,15 @@ function ExportLanguageModalImpl({ 关闭 - + diff --git a/src/components/biz/header-connection-indicator.tsx b/src/components/biz/header-connection-indicator.tsx new file mode 100644 index 0000000..d31bc9b --- /dev/null +++ b/src/components/biz/header-connection-indicator.tsx @@ -0,0 +1,54 @@ +import { disconnectLanguage, useFileConnections } from "@/store/file-connection"; + +type Props = { + projectId: string; +}; + +export function HeaderConnectionIndicator({ projectId }: Props) { + const snap = useFileConnections(projectId); + const list = Object.values(snap.connections); + const hasAny = list.length > 0; + + return ( +
+ +
+
+
连线状态
+ {list.length === 0 ? ( +
暂无连线。通过“导入 JSON”选择文件后将建立连线。
+ ) : ( +
+ {list.map((c) => ( +
+
+
{c.language}
+
+ {c.name} +
+
+ +
+ ))} +
+ 注:出于隐私,浏览器不提供完整路径,仅显示文件名;刷新页面后连线不会自动恢复。 +
+
+ )} +
+
+
+ ); +} diff --git a/src/components/biz/import-language-modal.tsx b/src/components/biz/import-language-modal.tsx index 5a056db..4ef5fbc 100644 --- a/src/components/biz/import-language-modal.tsx +++ b/src/components/biz/import-language-modal.tsx @@ -1,4 +1,4 @@ -import { memo, useRef, useState } from "react"; +import { memo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { buildStructureFromObject, flattenValues } from "@/lib/i18n-structure"; @@ -11,6 +11,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Checkbox } from "../ui/checkbox"; +import { connectLanguageToFile, isFilePickerSupported } from "@/store/file-connection"; type Props = { open: boolean; @@ -32,29 +33,28 @@ function ImportLanguageModalImpl({ const [lang, setLang] = useState(""); const [importing, setImporting] = useState(false); const [error, setError] = useState(null); - const fileRef = useRef(null); const [forceOverride, setForceOverride] = useState(false); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); - if ( - !fileRef.current || - !fileRef.current.files || - fileRef.current.files.length === 0 - ) { - setError("请选择 JSON 文件"); - return; - } const language = lang.trim(); if (!language) { setError("请输入语言代号,如 en 或 zh-CN"); return; } + if (!isFilePickerSupported()) { + setError("当前浏览器不支持 showOpenFilePicker"); + return; + } setImporting(true); setError(null); try { - const file = fileRef.current.files[0]!; - const text = await file.text(); + const picked = await connectLanguageToFile(projectId, language); + if (!picked) { + setImporting(false); + return; + } + const text = picked.text; let json: unknown; try { json = JSON.parse(text); @@ -73,7 +73,6 @@ function ImportLanguageModalImpl({ const values = flattenValues(json); await upsertLanguageTranslations(projectId, language, values); setLang(""); - if (fileRef.current) fileRef.current.value = ""; setForceOverride(false); onOpenChange(false); await onImported(); @@ -102,17 +101,6 @@ function ImportLanguageModalImpl({
-
- - -
@@ -530,6 +550,7 @@ export default function Editor() { e.path)} diff --git a/src/store/file-connection.ts b/src/store/file-connection.ts new file mode 100644 index 0000000..9d8eccb --- /dev/null +++ b/src/store/file-connection.ts @@ -0,0 +1,173 @@ +import { useSyncExternalStore } from "react"; +import { toast } from "sonner"; + +export type LanguageConnection = { + language: string; + handle: FileSystemFileHandle; + name: string; +}; + +type Snapshot = { + connections: Record; +}; + +type Listener = () => void; + +// projectId -> Snapshot +const state = new Map(); +const listeners = new Map>(); + +function ensureProject(projectId: string) { + if (!state.has(projectId)) { + state.set(projectId, { connections: {} }); + } + if (!listeners.has(projectId)) { + listeners.set(projectId, new Set()); + } +} + +function emit(projectId: string) { + const set = listeners.get(projectId); + if (!set) return; + for (const l of Array.from(set)) { + try { + l(); + } catch { + // ignore + } + } +} + +export function useFileConnections(projectId: string): Snapshot { + ensureProject(projectId); + + return useSyncExternalStore( + (l) => { + const set = listeners.get(projectId)!; + set.add(l); + return () => set.delete(l); + }, + () => state.get(projectId)!, + () => ({ connections: {} }) + ); +} + +export function isFilePickerSupported(): boolean { + return typeof window !== "undefined" && typeof (window as unknown as { showOpenFilePicker?: unknown }).showOpenFilePicker === "function"; +} + +export async function connectLanguageToFile(projectId: string, language: string): Promise<{ text: string; connection: LanguageConnection } | null> { + if (!isFilePickerSupported()) { + toast.error("当前浏览器不支持文件系统访问 API(showOpenFilePicker)"); + return null; + } + ensureProject(projectId); + try { + // @ts-expect-error - showOpenFilePicker exists in supporting browsers + const [handle]: FileSystemFileHandle[] = await window.showOpenFilePicker({ + multiple: false, + types: [ + { + description: "JSON Files", + accept: { "application/json": [".json"] }, + }, + ], + excludeAcceptAllOption: true, + }); + if (!handle) return null; + + const canRead = await ensurePermission(handle, "read"); + if (!canRead) { + toast.error("没有读取权限"); + return null; + } + + const file = await handle.getFile(); + const text = await file.text(); + + const snap = state.get(projectId)!; + snap.connections[language] = { + language, + handle, + name: handle.name, + }; + emit(projectId); + + return { text, connection: snap.connections[language]! }; + } catch (e) { + const message = (e as Error)?.message ?? "选择文件失败"; + if (!/aborted|cancel/i.test(message)) { + toast.error(message); + } + return null; + } +} + +export async function writeLanguageToConnectedFile(projectId: string, language: string, text: string): Promise { + const snap = state.get(projectId); + const conn = snap?.connections[language]; + if (!conn) { + toast.error("该语言未连线到文件"); + return false; + } + try { + const canWrite = await ensurePermission(conn.handle, "readwrite"); + if (!canWrite) { + toast.error("没有写入权限"); + return false; + } + const writable = await conn.handle.createWritable(); + await writable.write(text); + await writable.close(); + toast.success(`已写入 ${conn.name}`); + return true; + } catch (e) { + toast.error((e as Error)?.message ?? "写入失败"); + return false; + } +} + +export function disconnectLanguage(projectId: string, language: string) { + const snap = state.get(projectId); + if (!snap) return; + if (snap.connections[language]) { + delete snap.connections[language]; + emit(projectId); + } +} + +export function clearAllConnections(projectId: string) { + if (!state.has(projectId)) return; + state.set(projectId, { connections: {} }); + emit(projectId); +} + +export function getConnection(projectId: string, language: string): LanguageConnection | undefined { + return state.get(projectId)?.connections[language]; +} + +export function listConnections(projectId: string): LanguageConnection[] { + return Object.values(state.get(projectId)?.connections ?? {}); +} + +async function ensurePermission(handle: FileSystemFileHandle, mode: "read" | "readwrite"): Promise { + try { + // @ts-expect-error - queryPermission/requestPermission exist in supporting browsers + const q = await handle.queryPermission?.({ mode }); + if (q === "granted") return true; + // @ts-expect-error - requestPermission exists in supporting browsers + const r = await handle.requestPermission?.({ mode }); + return r === "granted"; + } catch { + if (mode === "readwrite") { + try { + const w = await handle.createWritable(); + await w.close(); + return true; + } catch { + return false; + } + } + return false; + } +}