feat: 添加 JSON 格式化工具组件及相关依赖
Some checks failed
Build and Push Container Image, Deploy to Host / build-and-push-and-deploy (push) Failing after 1m15s

This commit is contained in:
2025-08-14 02:02:31 +08:00
parent 2206a1ece1
commit e7e5128c56
5 changed files with 313 additions and 0 deletions

View File

@ -19,6 +19,7 @@
"@radix-ui/react-tabs": "^1.1.12",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"highlight.js": "^11.11.1",
"html-to-image": "^1.11.13",
"lucide-react": "^0.539.0",
"next": "15.4.6",

9
pnpm-lock.yaml generated
View File

@ -38,6 +38,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
highlight.js:
specifier: ^11.11.1
version: 11.11.1
html-to-image:
specifier: ^1.11.13
version: 1.11.13
@ -1544,6 +1547,10 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
highlight.js@11.11.1:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
html-to-image@1.11.13:
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
@ -3876,6 +3883,8 @@ snapshots:
dependencies:
function-bind: 1.1.2
highlight.js@11.11.1: {}
html-to-image@1.11.13: {}
ignore@5.3.2: {}

View File

@ -0,0 +1,232 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import hljs from "highlight.js/lib/core";
import json from "highlight.js/lib/languages/json";
import "highlight.js/styles/github-dark.css";
// 注册 JSON 语言
hljs.registerLanguage("json", json);
import { cn } from "@/lib/utils";
import { Clipboard, Download, AlertTriangle } from "lucide-react";
interface FormatOptions {
indent: number;
sortKeys: boolean;
colorize: boolean;
}
export default function JSONFormatter() {
const [input, setInput] = useState("");
const [output, setOutput] = useState("");
const [error, setError] = useState<string | null>(null);
const [options, setOptions] = useState<FormatOptions>({
indent: 2,
sortKeys: false,
colorize: true,
});
const [highlightedCode, setHighlightedCode] = useState("");
const formatJSON = useCallback(() => {
try {
if (!input.trim()) {
setOutput("");
setError(null);
return;
}
let parsedJSON = JSON.parse(input);
if (options.sortKeys) {
parsedJSON = sortObjectKeys(parsedJSON);
}
const formatted = JSON.stringify(parsedJSON, null, options.indent);
setOutput(formatted);
setError(null);
} catch (err) {
setError((err as Error).message);
setOutput("");
}
}, [input, options]);
useEffect(() => {
if (options.colorize && output) {
const highlighted = hljs.highlight(output, { language: "json" }).value;
setHighlightedCode(highlighted);
}
}, [output, options.colorize]);
const sortObjectKeys = (obj: any): any => {
if (obj === null || typeof obj !== "object") {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(sortObjectKeys);
}
return Object.keys(obj)
.sort()
.reduce((result: Record<string, any>, key) => {
result[key] = sortObjectKeys(obj[key]);
return result;
}, {});
};
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(output);
} catch (err) {
console.error("Failed to copy text:", err);
}
};
const downloadJSON = () => {
const blob = new Blob([output], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "formatted.json";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
return (
<main className="flex min-h-screen flex-col items-center justify-center p-4">
<Card className="w-full max-w-6xl mx-auto">
<CardHeader>
<CardTitle>JSON </CardTitle>
<CardDescription>
JSON
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-6">
<div className="space-y-2">
<div className="flex justify-between items-center">
<Label></Label>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Label htmlFor="indent"></Label>
<Input
id="indent"
type="number"
min={0}
max={8}
value={options.indent}
onChange={(e) =>
setOptions({
...options,
indent: parseInt(e.target.value) || 0,
})
}
className="w-20"
/>
</div>
<div className="flex items-center gap-2">
<Switch
id="sort-keys"
checked={options.sortKeys}
onCheckedChange={(checked) =>
setOptions({ ...options, sortKeys: checked })
}
/>
<Label htmlFor="sort-keys"></Label>
</div>
<div className="flex items-center gap-2">
<Switch
id="colorize"
checked={options.colorize}
onCheckedChange={(checked) =>
setOptions({ ...options, colorize: checked })
}
/>
<Label htmlFor="colorize"></Label>
</div>
</div>
</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label> JSON</Label>
<Textarea
placeholder="在此输入 JSON 文本..."
value={input}
onChange={(e) => {
setInput(e.target.value);
formatJSON();
}}
className="h-[500px] font-mono"
/>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<Label></Label>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={copyToClipboard}
disabled={!output}
>
<Clipboard className="w-4 h-4 mr-2" />
</Button>
<Button
variant="outline"
size="sm"
onClick={downloadJSON}
disabled={!output}
>
<Download className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{error ? (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
) : (
<pre
className={cn(
"block p-4 rounded-lg border bg-muted h-[500px] overflow-auto font-mono"
)}
>
{options.colorize ? (
<code
dangerouslySetInnerHTML={{ __html: highlightedCode }}
/>
) : (
<code>{output}</code>
)}
</pre>
)}
</div>
</div>
</div>
</CardContent>
</Card>
</main>
);
}

View File

@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@ -9,5 +9,10 @@ export const tools: Tool[] = [
title: "Rail Transit Guide",
description: "轨道交通导视生成器",
href: "/rt-guide",
},
{
title: "JSON formatter",
description: "JSON 格式化工具",
href: "/json-formatter",
}
];