Feat: 新增首页和编辑器页面,支持导出导出
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import { memo, useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { unflattenValues } from "@/lib/i18n-structure";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (next: boolean) => void;
|
||||
languages: string[];
|
||||
valuesByLang: Record<string, Record<string, string>>;
|
||||
};
|
||||
|
||||
function ExportLanguageModalImpl({ open, onOpenChange, languages, valuesByLang }: Props) {
|
||||
const [selected, setSelected] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelected((prev) => (prev && languages.includes(prev) ? prev : languages[0] ?? ""));
|
||||
}
|
||||
}, [open, languages]);
|
||||
|
||||
const jsonText = useMemo(() => {
|
||||
if (!selected) return "";
|
||||
const flat = valuesByLang[selected] ?? {};
|
||||
const nested = unflattenValues(flat);
|
||||
try {
|
||||
return JSON.stringify(nested, null, 2);
|
||||
} catch {
|
||||
return "{}";
|
||||
}
|
||||
}, [selected, valuesByLang]);
|
||||
|
||||
function handleDownload() {
|
||||
if (!selected) return;
|
||||
const blob = new Blob([jsonText || "{}"], { type: "application/json;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${selected}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>导出语言 JSON</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 min-w-0">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{languages.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">暂无可导出的语言</div>
|
||||
) : (
|
||||
languages.map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
type="button"
|
||||
onClick={() => setSelected(lang)}
|
||||
className={`px-3 h-8 rounded border text-sm ${selected === lang ? "bg-accent" : ""}`}
|
||||
>
|
||||
{lang}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<textarea className="w-full h-[50vh] p-3 font-mono text-sm whitespace-pre" readOnly>
|
||||
{jsonText}
|
||||
</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>关闭</Button>
|
||||
<Button onClick={handleDownload} disabled={!selected || !jsonText}>导出 JSON</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export const ExportLanguageModal = memo(ExportLanguageModalImpl);
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { memo, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { buildStructureFromObject, flattenValues } from "@/lib/i18n-structure";
|
||||
import { upsertLanguageTranslations, upsertStructure } from "@/lib/db";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (next: boolean) => void;
|
||||
projectId: string;
|
||||
hasStructure: boolean;
|
||||
onImported: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
function ImportLanguageModalImpl({
|
||||
open,
|
||||
onOpenChange,
|
||||
projectId,
|
||||
hasStructure,
|
||||
onImported,
|
||||
}: Props) {
|
||||
const [lang, setLang] = useState("");
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
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;
|
||||
}
|
||||
setImporting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const file = fileRef.current.files[0]!;
|
||||
const text = await file.text();
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error("JSON 解析失败,请检查文件内容");
|
||||
}
|
||||
if (!hasStructure) {
|
||||
const root = buildStructureFromObject(json);
|
||||
await upsertStructure({ projectId, root });
|
||||
}
|
||||
const values = flattenValues(json);
|
||||
await upsertLanguageTranslations(projectId, language, values);
|
||||
setLang("");
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
onOpenChange(false);
|
||||
await onImported();
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "导入失败");
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (!importing) {
|
||||
onOpenChange(v);
|
||||
if (!v) setError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{hasStructure ? "导入语言 JSON" : "导入翻译 JSON 并构建结构"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="mt-2 space-y-3">
|
||||
<div>
|
||||
<label className="text-sm text-muted-foreground">
|
||||
选择 JSON 文件
|
||||
</label>
|
||||
<Input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="mt-1 block w-full text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-muted-foreground">
|
||||
语言代号(如 en、ja、zh-CN)
|
||||
</label>
|
||||
<Input
|
||||
placeholder="en"
|
||||
value={lang}
|
||||
onChange={(e) => setLang(e.target.value)}
|
||||
aria-label="语言代号"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
setError(null);
|
||||
}}
|
||||
disabled={importing}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={importing}>
|
||||
{importing ? "导入中..." : "确认导入"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export const ImportLanguageModal = memo(ImportLanguageModalImpl);
|
||||
@@ -0,0 +1,141 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
+20
-15
@@ -2,20 +2,25 @@ import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
|
||||
Reference in New Issue
Block a user