177 lines
6.1 KiB
TypeScript
177 lines
6.1 KiB
TypeScript
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);
|