Feat: 新增复制/使用所有语言值功能

This commit is contained in:
奇趣保罗 2026-02-10 13:20:26 +08:00
parent b214fa944b
commit 6cdc3699cf
3 changed files with 219 additions and 6 deletions

View File

@ -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<string, string>) => void | Promise<void>;
validate?: (name: string) => string | null;
availableLanguages: string[];
};
function AddEntryModalImpl({ open, onOpenChange, position, onConfirm, validate, availableLanguages }: Props) {
const [name, setName] = useState("");
const [err, setErr] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [clipboardValues, setClipboardValues] = useState<Record<string, string> | 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 (
<Dialog open={open} onOpenChange={(v) => { if (!saving) onOpenChange(v); }}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Input
placeholder="请输入条目名称(不含点)"
value={name}
onChange={(e) => setName(e.target.value)}
aria-label="名称"
/>
</div>
{clipboardValues && (
<div className="space-y-3 p-4 border rounded-md bg-muted/30">
<div className="flex items-center gap-2">
<Checkbox
id="use-clipboard"
checked={useClipboard}
onCheckedChange={(checked) => setUseClipboard(!!checked)}
/>
<label
htmlFor="use-clipboard"
className="text-sm font-medium cursor-pointer flex items-center gap-2"
>
<ClipboardPaste className="w-4 h-4" />
使
</label>
</div>
{useClipboard && (
<div className="space-y-2 pl-6">
<div className="text-xs text-muted-foreground"></div>
<div className="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto">
{Object.entries(clipboardValues).map(([lang, value]) => {
const isAvailable = availableLanguages.includes(lang);
return (
<div
key={lang}
className={`text-xs p-2 rounded border ${
isAvailable
? "bg-background border-border"
: "bg-muted/50 border-muted text-muted-foreground"
}`}
>
<div className="font-mono font-semibold mb-1">
{lang}
{!isAvailable && (
<span className="ml-1 text-[10px] text-orange-600">()</span>
)}
</div>
<div className="break-all line-clamp-2">{value}</div>
</div>
);
})}
</div>
{Object.keys(clipboardValues).some(lang => !availableLanguages.includes(lang)) && (
<div className="text-xs text-orange-600">
</div>
)}
</div>
)}
</div>
)}
{err && <div className="text-sm text-red-600">{err}</div>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
</Button>
<Button type="submit" disabled={saving}>
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export const AddEntryModal = memo(AddEntryModalImpl);

View File

@ -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({
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
const allValues: Record<string, string> = {};
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("已复制所有语言的值");
}}
>
<Copy />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={async () => {
await onMoveEntry(entry.path, -moveCountUp);
@ -283,7 +299,7 @@ export default function EditorTable({
</td>
</>
);
}, [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 (

View File

@ -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) {
}}
/>
<EntryNameModal
<AddEntryModal
open={!!addModal?.open}
onOpenChange={(v) => 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) => {
// 同级重名校验在结构函数中也会做,这里做基础提示即可