Feat: 新增首页和编辑器页面,支持导出导出

This commit is contained in:
2025-11-03 16:47:16 +08:00
parent d2ba984a75
commit f2acb91f95
16 changed files with 1614 additions and 166 deletions
@@ -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);
+141
View File
@@ -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
View File
@@ -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 };