diff --git a/src/components/biz/add-entry-modal.tsx b/src/components/biz/add-entry-modal.tsx new file mode 100644 index 0000000..9426aee --- /dev/null +++ b/src/components/biz/add-entry-modal.tsx @@ -0,0 +1,176 @@ +import { memo, useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { toast } from "sonner"; +import { ClipboardPaste } from "lucide-react"; + +type Props = { + open: boolean; + onOpenChange: (v: boolean) => void; + position: "above" | "below"; + onConfirm: (name: string, values?: Record) => void | Promise; + validate?: (name: string) => string | null; + availableLanguages: string[]; +}; + +function AddEntryModalImpl({ open, onOpenChange, position, onConfirm, validate, availableLanguages }: Props) { + const [name, setName] = useState(""); + const [err, setErr] = useState(null); + const [saving, setSaving] = useState(false); + const [clipboardValues, setClipboardValues] = useState | null>(null); + const [useClipboard, setUseClipboard] = useState(false); + + useEffect(() => { + if (open) { + setName(""); + setErr(null); + setSaving(false); + setUseClipboard(false); + + // 尝试读取剪贴板内容 + checkClipboard(); + } + }, [open]); + + async function checkClipboard() { + try { + const text = await navigator.clipboard.readText(); + const parsed = JSON.parse(text); + + // 验证是否是有效的语言值对象 + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + const keys = Object.keys(parsed); + const allStrings = keys.every(key => typeof parsed[key] === 'string'); + + if (allStrings && keys.length > 0) { + setClipboardValues(parsed); + setUseClipboard(true); + return; + } + } + } catch { + // 剪贴板内容不是有效的 JSON 或无法访问,忽略 + } + + setClipboardValues(null); + setUseClipboard(false); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const trimmed = name.trim(); + if (!trimmed) return setErr("名称不能为空"); + if (trimmed.includes(".")) return setErr("名称不能包含 '.'"); + if (validate) { + const msg = validate(trimmed); + if (msg) return setErr(msg); + } + + setSaving(true); + try { + const values = useClipboard && clipboardValues ? clipboardValues : undefined; + await onConfirm(trimmed, values); + onOpenChange(false); + } catch (e) { + setErr((e as Error)?.message ?? "操作失败"); + } finally { + setSaving(false); + } + } + + const title = position === "above" ? "在上方新增条目" : "在下方新增条目"; + + return ( + { if (!saving) onOpenChange(v); }}> + + + {title} + +
+
+ setName(e.target.value)} + aria-label="名称" + /> +
+ + {clipboardValues && ( +
+
+ setUseClipboard(!!checked)} + /> + +
+ + {useClipboard && ( +
+
检测到以下语言的翻译:
+
+ {Object.entries(clipboardValues).map(([lang, value]) => { + const isAvailable = availableLanguages.includes(lang); + return ( +
+
+ {lang} + {!isAvailable && ( + (项目中不存在) + )} +
+
{value}
+
+ ); + })} +
+ {Object.keys(clipboardValues).some(lang => !availableLanguages.includes(lang)) && ( +
+ 注意:部分语言在当前项目中不存在,这些值将被忽略 +
+ )} +
+ )} +
+ )} + + {err &&
{err}
} + + + + + +
+
+
+ ); +} + +export const AddEntryModal = memo(AddEntryModalImpl); diff --git a/src/components/biz/editor/table.tsx b/src/components/biz/editor/table.tsx index 85983a6..90a9bea 100644 --- a/src/components/biz/editor/table.tsx +++ b/src/components/biz/editor/table.tsx @@ -3,7 +3,7 @@ import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMe import { Input } from "@/components/ui/input"; import { Checkbox } from "@/components/ui/checkbox"; import { Textarea } from "@/components/ui/textarea"; -import { ArrowBigDownDash, ArrowBigUpDash, Brackets, CaseSensitive, Filter, Languages, LocateFixed, MoreVertical, PencilLine, Trash2 } from "lucide-react"; +import { ArrowBigDownDash, ArrowBigUpDash, Brackets, CaseSensitive, Copy, Filter, Languages, LocateFixed, MoreVertical, PencilLine, Trash2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type React from "react"; import { TableVirtuoso, type TableVirtuosoHandle } from "react-virtuoso"; @@ -221,6 +221,22 @@ export default function EditorTable({ 重命名 + { + const allValues: Record = {}; + for (const lang of languages) { + const value = inlineEdit.getDisplayValue(entry.path, lang); + allValues[lang] = value; + } + const jsonStr = JSON.stringify(allValues, null, 2); + copy(jsonStr); + toast.success("已复制所有语言的值"); + }} + > + + 复制所有语言值 + + { await onMoveEntry(entry.path, -moveCountUp); @@ -283,7 +299,7 @@ export default function EditorTable({ ); - }, [copy, displayedLanguages, inlineEdit, moveCountDown, moveCountUp, onDeleteEntry, onMoveEntry, onOpenAddEntry, onOpenAiTranslate, onOpenRenameEntry, selected, setSelected]); + }, [copy, displayedLanguages, inlineEdit, languages, moveCountDown, moveCountUp, onDeleteEntry, onMoveEntry, onOpenAddEntry, onOpenAiTranslate, onOpenRenameEntry, selected, setSelected]); return ( diff --git a/src/pages/editor.tsx b/src/pages/editor.tsx index 5dbe51d..11c4ee5 100644 --- a/src/pages/editor.tsx +++ b/src/pages/editor.tsx @@ -18,6 +18,7 @@ import { buildStructureFromObject, flattenEntries, flattenValues, unflattenValue import { ImportLanguageModal } from "@/components/biz/import-language-modal"; import { ExportLanguageModal } from "@/components/biz/export-language-modal"; import { EntryNameModal } from "@/components/biz/entry-name-modal"; +import { AddEntryModal } from "@/components/biz/add-entry-modal"; import { ProjectSettingsModal } from "@/components/biz/project-settings-modal"; import { AiTranslateModal } from "@/components/biz/ai-translate-modal"; import { toast } from "sonner"; @@ -772,24 +773,44 @@ function EditorContent({ projectId }: EditorProps) { }} /> - stores.modalStore.getState().setAddModal((cur) => (cur ? { ...cur, open: v } : cur))} - title={addModal?.position === "above" ? "在上方新增条目" : "在下方新增条目"} - placeholder="请输入条目名称(不含点)" - onConfirm={async (name) => { + position={addModal?.position ?? "below"} + availableLanguages={languages} + onConfirm={async (name, values) => { if (!projectId) return; const { structure } = stores.projectStore.getState(); const { addModal } = stores.modalStore.getState(); + const { valuesByLang } = stores.dataStore.getState(); if (!structure || !addModal) return; const nextRoot = insertEntrySibling(structure.root, addModal.path, addModal.position, name); console.log("nextRoot", addModal, nextRoot); + // 计算新条目的完整路径 + const parentPath = addModal.path.split('.').slice(0, -1); + const newPath = parentPath.length > 0 ? `${parentPath.join('.')}.${name}` : name; + + // 保存结构 await upsertStructure({ projectId, root: nextRoot }); stores.projectStore.getState().setStructure({ projectId, root: nextRoot }); + + // 如果提供了多语言值,保存它们 + if (values) { + for (const [lang, value] of Object.entries(values)) { + // 只保存项目中存在的语言 + if (languages.includes(lang)) { + const prev = valuesByLang[lang] ?? {}; + const next = { ...prev, [newPath]: value }; + await upsertLanguageTranslations(projectId, lang, next); + stores.dataStore.getState().setValuesByLang((old) => ({ ...old, [lang]: next })); + } + } + toast.success(`已创建条目并填充 ${Object.keys(values).filter(l => languages.includes(l)).length} 种语言的翻译`); + } }} validate={(name) => { // 同级重名校验在结构函数中也会做,这里做基础提示即可