Feat: 加入虚拟列表,导出 JSON 增加排序功能
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
title: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
confirmText?: string;
|
||||
onConfirm: (name: string) => void | Promise<void>;
|
||||
validate?: (name: string) => string | null;
|
||||
};
|
||||
|
||||
function EntryNameModalImpl({ open, onOpenChange, title, placeholder, defaultValue, confirmText = "确认", onConfirm, validate }: Props) {
|
||||
const [name, setName] = useState("");
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(defaultValue ?? "");
|
||||
setErr(null);
|
||||
setSaving(false);
|
||||
}
|
||||
}, [open, defaultValue]);
|
||||
|
||||
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 {
|
||||
await onConfirm(trimmed);
|
||||
onOpenChange(false);
|
||||
} catch (e) {
|
||||
setErr((e as Error)?.message ?? "操作失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!saving) onOpenChange(v); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
aria-label="名称"
|
||||
/>
|
||||
{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}>{confirmText}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export const EntryNameModal = memo(EntryNameModalImpl);
|
||||
|
||||
|
||||
@@ -14,31 +14,84 @@ type Props = {
|
||||
onOpenChange: (next: boolean) => void;
|
||||
languages: string[];
|
||||
valuesByLang: Record<string, Record<string, string>>;
|
||||
orderedPaths?: string[]; // 优先按照结构顺序导出
|
||||
};
|
||||
|
||||
function ExportLanguageModalImpl({ open, onOpenChange, languages, valuesByLang }: Props) {
|
||||
function ExportLanguageModalImpl({
|
||||
open,
|
||||
onOpenChange,
|
||||
languages,
|
||||
valuesByLang,
|
||||
orderedPaths,
|
||||
}: Props) {
|
||||
const [selected, setSelected] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelected((prev) => (prev && languages.includes(prev) ? prev : languages[0] ?? ""));
|
||||
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);
|
||||
|
||||
// 若提供结构顺序,则按结构顺序构建嵌套对象,保证键序与表格一致
|
||||
if (orderedPaths && orderedPaths.length > 0) {
|
||||
const root: Record<string, unknown> = {};
|
||||
const added = new Set<string>();
|
||||
const setByPath = (path: string, val: string) => {
|
||||
const parts = path.split(".");
|
||||
let cur: Record<string, unknown> = root;
|
||||
for (let i = 0; i < parts.length; i += 1) {
|
||||
const key = parts[i]!;
|
||||
const isLeaf = i === parts.length - 1;
|
||||
if (isLeaf) {
|
||||
cur[key] = val;
|
||||
} else {
|
||||
const next = cur[key];
|
||||
if (next && typeof next === "object" && !Array.isArray(next)) {
|
||||
cur = next as Record<string, unknown>;
|
||||
} else {
|
||||
const obj: Record<string, unknown> = {};
|
||||
cur[key] = obj;
|
||||
cur = obj;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const p of orderedPaths) {
|
||||
const v = Object.prototype.hasOwnProperty.call(flat, p) ? flat[p] : "";
|
||||
setByPath(p, v);
|
||||
added.add(p);
|
||||
}
|
||||
// 追加结构外的剩余键,避免丢数据
|
||||
for (const p of Object.keys(flat)) {
|
||||
if (!added.has(p)) setByPath(p, flat[p]!);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(root, null, 2);
|
||||
} catch {
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
|
||||
// 回退:无结构顺序时使用普通反扁平
|
||||
try {
|
||||
return JSON.stringify(nested, null, 2);
|
||||
return JSON.stringify(unflattenValues(flat), null, 2);
|
||||
} catch {
|
||||
return "{}";
|
||||
}
|
||||
}, [selected, valuesByLang]);
|
||||
}, [selected, valuesByLang, orderedPaths]);
|
||||
|
||||
function handleDownload() {
|
||||
if (!selected) return;
|
||||
const blob = new Blob([jsonText || "{}"], { type: "application/json;charset=utf-8" });
|
||||
const blob = new Blob([jsonText || "{}"], {
|
||||
type: "application/json;charset=utf-8",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
@@ -58,14 +111,18 @@ function ExportLanguageModalImpl({ open, onOpenChange, languages, valuesByLang }
|
||||
<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>
|
||||
<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" : ""}`}
|
||||
className={`px-3 h-8 rounded border text-sm ${
|
||||
selected === lang ? "bg-accent" : ""
|
||||
}`}
|
||||
>
|
||||
{lang}
|
||||
</button>
|
||||
@@ -73,14 +130,20 @@ function ExportLanguageModalImpl({ open, onOpenChange, languages, valuesByLang }
|
||||
)}
|
||||
</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>
|
||||
<textarea
|
||||
className="w-full h-[50vh] p-3 font-mono text-sm whitespace-pre"
|
||||
readOnly
|
||||
value={jsonText}
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>关闭</Button>
|
||||
<Button onClick={handleDownload} disabled={!selected || !jsonText}>导出 JSON</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button onClick={handleDownload} disabled={!selected || !jsonText}>
|
||||
导出 JSON
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -88,5 +151,3 @@ function ExportLanguageModalImpl({ open, onOpenChange, languages, valuesByLang }
|
||||
}
|
||||
|
||||
export const ExportLanguageModal = memo(ExportLanguageModalImpl);
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
Reference in New Issue
Block a user