mirror of
https://github.com/JOYCEQL/magic-resume.git
synced 2026-07-03 14:07:11 +02:00
feat: ai polish
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { apiKey, model, content } = body;
|
||||
|
||||
const response = await fetch(
|
||||
"https://ark.cn-beijing.volces.com/api/v3/chat/completions",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `你是一个专业的简历优化助手。请帮助优化以下文本,使其更加专业和有吸引力。
|
||||
|
||||
优化原则:
|
||||
1. 使用更专业的词汇和表达方式
|
||||
2. 突出关键成就和技能
|
||||
3. 保持简洁清晰
|
||||
4. 使用主动语气
|
||||
5. 保持原有信息的完整性
|
||||
6. 保留我输入的格式
|
||||
|
||||
请直接返回优化后的文本,不要包含任何解释或其他内容。`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content,
|
||||
},
|
||||
],
|
||||
stream: true,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
if (!response.body) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
controller.close();
|
||||
break;
|
||||
}
|
||||
|
||||
const chunk = decoder.decode(value);
|
||||
const lines = chunk
|
||||
.split("\n")
|
||||
.filter((line) => line.trim() !== "");
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes("[DONE]")) continue;
|
||||
if (!line.startsWith("data:")) continue;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(line.slice(5));
|
||||
const content = data.choices[0]?.delta?.content;
|
||||
if (content) {
|
||||
controller.enqueue(encoder.encode(content));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error parsing JSON:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Stream reading error:", error);
|
||||
controller.error(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Polish error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to polish content" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,23 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { motion } from "framer-motion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import RichTextEditor from "@/components/shared/rich-editor/RichEditor";
|
||||
import { Button } from "../ui/button";
|
||||
import RichTextEditor from "../shared/rich-editor/RichEditor";
|
||||
import AIPolishDialog from "../shared/ai/AIPolishDialog";
|
||||
import { useAIConfigStore } from "@/store/useAIConfigStore";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type FieldProps = {
|
||||
interface FieldProps {
|
||||
label?: string;
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
@@ -21,9 +25,9 @@ type FieldProps = {
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const Field: React.FC<FieldProps> = ({
|
||||
const Field = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
@@ -31,72 +35,79 @@ const Field: React.FC<FieldProps> = ({
|
||||
placeholder,
|
||||
required,
|
||||
className,
|
||||
}) => {
|
||||
const renderLabel = () => (
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium flex gap-1 items-center",
|
||||
"dark:text-neutral-300 text-gray-600"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{required && <span className="text-red-500">*</span>}
|
||||
</span>
|
||||
);
|
||||
}: FieldProps) => {
|
||||
const router = useRouter();
|
||||
const [showPolishDialog, setShowPolishDialog] = useState(false);
|
||||
const { doubaoModelId, doubaoApiKey } = useAIConfigStore();
|
||||
|
||||
const renderLabel = () => {
|
||||
if (!label) return null;
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={cn(
|
||||
"block text-sm font-medium",
|
||||
"text-gray-700 dark:text-neutral-300"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const inputStyles = cn(
|
||||
"mt-1.5 block w-full transition-all duration-200",
|
||||
"rounded-lg px-4 py-2.5",
|
||||
"border"
|
||||
"block w-full rounded-md border-0 py-1.5 px-3",
|
||||
"text-gray-900 dark:text-neutral-300",
|
||||
"shadow-sm ring-1 ring-inset ring-gray-300",
|
||||
"placeholder:text-gray-400",
|
||||
"focus:ring-2 focus:ring-inset focus:ring-primary",
|
||||
"dark:bg-neutral-900/30 dark:ring-neutral-700 dark:focus:ring-primary",
|
||||
"sm:text-sm sm:leading-6",
|
||||
className
|
||||
);
|
||||
|
||||
if (type === "date") {
|
||||
const date = value ? new Date(value) : undefined;
|
||||
|
||||
return (
|
||||
<label className="block">
|
||||
<span className={cn("text-sm font-medium flex gap-1 items-center")}>
|
||||
{label}
|
||||
{required && <span className="text-red-500">*</span>}
|
||||
</span>
|
||||
<div className="block">
|
||||
{renderLabel()}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn("w-full flex items-center justify-between")}
|
||||
variant={"outline"}
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal mt-1.5",
|
||||
!value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span>{date ? format(date, "yyyy-MM-dd") : "选择日期"}</span>
|
||||
<CalendarIcon className="w-4 h-4 opacity-50" />
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{value ? (
|
||||
format(new Date(value), "PPP")
|
||||
) : (
|
||||
<span>Pick a date</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<PopoverContent className="w-auto p-0">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={(date) => onChange(date ? date.toISOString() : "")}
|
||||
selected={value ? new Date(value) : undefined}
|
||||
onSelect={(date) => onChange(date?.toISOString() || "")}
|
||||
initialFocus
|
||||
className={cn("rounded-lg p-3")}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "textarea") {
|
||||
return (
|
||||
<label className="block">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium flex gap-1 items-center",
|
||||
"dark:text-neutral-300 text-gray-600"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{required && <span className="text-red-500">*</span>}
|
||||
</span>
|
||||
{renderLabel()}
|
||||
<motion.textarea
|
||||
value={value || ""}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={inputStyles}
|
||||
@@ -108,6 +119,7 @@ const Field: React.FC<FieldProps> = ({
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "editor") {
|
||||
return (
|
||||
<motion.div className="block">
|
||||
@@ -117,30 +129,49 @@ const Field: React.FC<FieldProps> = ({
|
||||
content={value || ""}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
onPolish={() => {
|
||||
if (!doubaoApiKey || !doubaoModelId) {
|
||||
toast.error(
|
||||
<>
|
||||
<span>请先配置 ApiKey 和 模型Id</span>
|
||||
<Button
|
||||
className="p-0 h-auto text-white"
|
||||
onClick={() => router.push("/dashboard/settings")}
|
||||
>
|
||||
去配置
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
return;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AIPolishDialog
|
||||
open={showPolishDialog}
|
||||
onOpenChange={setShowPolishDialog}
|
||||
content={value || ""}
|
||||
onApply={(content) => {
|
||||
onChange(content);
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="block">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium flex gap-1 items-center",
|
||||
"dark:text-neutral-300 text-gray-600"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{required && <span className="text-red-500">*</span>}
|
||||
</span>
|
||||
<Input
|
||||
{renderLabel()}
|
||||
<motion.input
|
||||
type="text"
|
||||
value={value || ""}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={inputStyles}
|
||||
required={required}
|
||||
whileHover={{ scale: 1.005 }}
|
||||
whileTap={{ scale: 0.995 }}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { Loader2, Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAIConfigStore } from "@/store/useAIConfigStore";
|
||||
|
||||
interface AIPolishDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
content: string;
|
||||
onApply: (content: string) => void;
|
||||
}
|
||||
|
||||
export default function AIPolishDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
content,
|
||||
onApply,
|
||||
}: AIPolishDialogProps) {
|
||||
const [isPolishing, setIsPolishing] = useState(false);
|
||||
const [polishedContent, setPolishedContent] = useState("");
|
||||
const { doubaoApiKey, doubaoModelId } = useAIConfigStore();
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const polishedContentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handlePolish = async () => {
|
||||
try {
|
||||
setIsPolishing(true);
|
||||
setPolishedContent("");
|
||||
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
const response = await fetch("/api/polish", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content,
|
||||
apiKey: doubaoApiKey,
|
||||
model: doubaoModelId,
|
||||
}),
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to polish content");
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("No response body");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value);
|
||||
setPolishedContent((prev) => {
|
||||
const newContent = prev + chunk;
|
||||
requestAnimationFrame(() => {
|
||||
if (polishedContentRef.current) {
|
||||
const container = polishedContentRef.current;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
});
|
||||
return newContent;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
console.log("Polish aborted");
|
||||
return;
|
||||
}
|
||||
console.error("Polish error:", error);
|
||||
toast.error("润色失败");
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setIsPolishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
handlePolish();
|
||||
} else {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
setPolishedContent("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleClose = () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
onOpenChange(false);
|
||||
setPolishedContent("");
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
onApply(polishedContent);
|
||||
handleClose();
|
||||
toast.success("已应用润色内容");
|
||||
};
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open && !isPolishing) {
|
||||
onOpenChange(open);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
className="sm:max-w-[1000px] bg-[#1a1d21] border-gray-800"
|
||||
onPointerDownOutside={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onEscapeKeyDown={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onInteractOutside={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="pb-6">
|
||||
<DialogTitle className="flex items-center gap-2 text-2xl text-white">
|
||||
<Sparkles className="h-6 w-6 text-primary animate-pulse" />
|
||||
AI 润色
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-base text-gray-400">
|
||||
{isPolishing
|
||||
? "正在为您润色内容..."
|
||||
: "已经为您优化了内容,请查看效果"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 px-3">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-gray-500"></div>
|
||||
<span className="text-sm font-medium text-gray-400">
|
||||
原始内容
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative rounded-xl border border-gray-800 bg-[#24282c] p-6 h-[400px] overflow-auto shadow-sm">
|
||||
<div
|
||||
className="prose prose-invert max-w-none text-gray-300"
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 px-3">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse"></div>
|
||||
<span className="text-sm font-medium text-primary">
|
||||
润色后的内容
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
ref={polishedContentRef}
|
||||
className="relative rounded-xl border border-primary/20 bg-primary/[0.03] p-6 h-[400px] overflow-auto shadow-sm scroll-smooth"
|
||||
>
|
||||
<div
|
||||
className="prose prose-invert max-w-none text-white"
|
||||
dangerouslySetInnerHTML={{ __html: polishedContent }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6 flex items-center gap-3">
|
||||
<Button
|
||||
onClick={handlePolish}
|
||||
disabled={isPolishing}
|
||||
className="flex-1 bg-gradient-to-r from-[#9333EA] to-[#EC4899] hover:opacity-90 text-white border-none h-11 shadow-lg shadow-purple-500/20"
|
||||
>
|
||||
{isPolishing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
生成中...
|
||||
</div>
|
||||
) : (
|
||||
"重新生成"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleApply}
|
||||
disabled={isPolishing || !polishedContent}
|
||||
className="flex-1 bg-primary hover:bg-primary/90 text-white h-11 shadow-lg shadow-primary/20"
|
||||
>
|
||||
应用内容
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import { useEditor, EditorContent, BubbleMenu } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import TextAlign from "@tiptap/extension-text-align";
|
||||
@@ -10,7 +10,7 @@ import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Bold,
|
||||
@@ -29,7 +29,8 @@ import {
|
||||
PaintBucket,
|
||||
Type,
|
||||
ChevronDown,
|
||||
Highlighter
|
||||
Highlighter,
|
||||
Wand2,
|
||||
} from "lucide-react";
|
||||
import Highlight from "@tiptap/extension-highlight";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -40,6 +41,7 @@ interface RichTextEditorProps {
|
||||
content?: string;
|
||||
onChange: (content: string) => void;
|
||||
placeholder?: string;
|
||||
onPolish?: () => void;
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
@@ -57,7 +59,7 @@ const COLORS = [
|
||||
{ label: "蓝色", value: "#0000FF" },
|
||||
{ label: "紫色", value: "#6600FF" },
|
||||
{ label: "紫红", value: "#CC00FF" },
|
||||
{ label: "粉色", value: "#FF00FF" }
|
||||
{ label: "粉色", value: "#FF00FF" },
|
||||
];
|
||||
|
||||
const BG_COLORS = COLORS;
|
||||
@@ -86,7 +88,7 @@ const MenuButton = ({
|
||||
disabled = false,
|
||||
children,
|
||||
className = "",
|
||||
tooltip
|
||||
tooltip,
|
||||
}: MenuButtonProps) => {
|
||||
const [showTooltip, setShowTooltip] = React.useState(false);
|
||||
|
||||
@@ -155,7 +157,7 @@ const TextColorButton = ({ editor }) => {
|
||||
color: activeColor || "currentColor",
|
||||
filter: activeColor
|
||||
? "drop-shadow(0 1px 1px rgba(0,0,0,0.1))"
|
||||
: "none"
|
||||
: "none",
|
||||
}}
|
||||
/>
|
||||
<span className="sr-only">文字颜色</span>
|
||||
@@ -187,7 +189,7 @@ const TextColorButton = ({ editor }) => {
|
||||
style={{
|
||||
backgroundColor: color.value,
|
||||
borderColor:
|
||||
color.value === "#FFFFFF" ? "#E2E8F0" : color.value
|
||||
color.value === "#FFFFFF" ? "#E2E8F0" : color.value,
|
||||
}}
|
||||
onClick={() => {
|
||||
editor.chain().focus().setColor(color.value).run();
|
||||
@@ -229,7 +231,7 @@ const BackgroundColorButton = ({ editor }) => {
|
||||
color: activeBgColor ? "currentColor" : "currentColor",
|
||||
filter: activeBgColor
|
||||
? "drop-shadow(0 1px 1px rgba(0,0,0,0.1))"
|
||||
: "none"
|
||||
: "none",
|
||||
}}
|
||||
/>
|
||||
{activeBgColor && (
|
||||
@@ -267,7 +269,7 @@ const BackgroundColorButton = ({ editor }) => {
|
||||
${activeBgColor === color.value ? "ring-2 ring-primary ring-offset-2" : ""}`}
|
||||
style={{
|
||||
backgroundColor: color.value,
|
||||
borderColor: "transparent"
|
||||
borderColor: "transparent",
|
||||
}}
|
||||
onClick={() => {
|
||||
editor
|
||||
@@ -320,7 +322,7 @@ const HeadingSelect = ({ editor }) => {
|
||||
{ label: "正文", value: "p" },
|
||||
{ label: "标题 1", value: "1" },
|
||||
{ label: "标题 2", value: "2" },
|
||||
{ label: "标题 3", value: "3" }
|
||||
{ label: "标题 3", value: "3" },
|
||||
].map((item) => (
|
||||
<Button
|
||||
key={item.value}
|
||||
@@ -357,7 +359,11 @@ const HeadingSelect = ({ editor }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const RichTextEditor = ({ content = "", onChange }: RichTextEditorProps) => {
|
||||
const RichTextEditor = ({
|
||||
content = "",
|
||||
onChange,
|
||||
onPolish,
|
||||
}: RichTextEditorProps) => {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
@@ -365,28 +371,28 @@ const RichTextEditor = ({ content = "", onChange }: RichTextEditorProps) => {
|
||||
orderedList: false,
|
||||
listItem: false,
|
||||
heading: {
|
||||
levels: [1, 2, 3]
|
||||
}
|
||||
levels: [1, 2, 3],
|
||||
},
|
||||
}),
|
||||
BulletList.configure({
|
||||
HTMLAttributes: {
|
||||
class: "custom-list"
|
||||
}
|
||||
class: "custom-list",
|
||||
},
|
||||
}),
|
||||
OrderedList.configure({
|
||||
HTMLAttributes: {
|
||||
class: "custom-list-ordered"
|
||||
}
|
||||
class: "custom-list-ordered",
|
||||
},
|
||||
}),
|
||||
ListItem,
|
||||
TextAlign.configure({
|
||||
types: ["heading", "paragraph"],
|
||||
alignments: ["left", "center", "right", "justify"]
|
||||
alignments: ["left", "center", "right", "justify"],
|
||||
}),
|
||||
TextStyle,
|
||||
Underline,
|
||||
Color,
|
||||
Highlight.configure({ multicolor: true })
|
||||
Highlight.configure({ multicolor: true }),
|
||||
],
|
||||
content,
|
||||
onUpdate: ({ editor }) => {
|
||||
@@ -405,12 +411,18 @@ const RichTextEditor = ({ content = "", onChange }: RichTextEditorProps) => {
|
||||
"dark:prose-blockquote:border-neutral-700",
|
||||
"dark:prose-ul:text-neutral-300",
|
||||
"dark:prose-ol:text-neutral-300"
|
||||
)
|
||||
}
|
||||
),
|
||||
},
|
||||
},
|
||||
immediatelyRender: false
|
||||
immediatelyRender: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && content !== editor.getHTML()) {
|
||||
editor.commands.setContent(content);
|
||||
}
|
||||
}, [content, editor]);
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
@@ -530,21 +542,32 @@ const RichTextEditor = ({ content = "", onChange }: RichTextEditorProps) => {
|
||||
|
||||
<div className={cn("h-5 w-px", "bg-border/60 dark:bg-neutral-800")} />
|
||||
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className="flex items-center space-x-1">
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
tooltip="撤销"
|
||||
>
|
||||
<Undo className="h-5 w-5" />
|
||||
<Undo className="h-4 w-4" />
|
||||
</MenuButton>
|
||||
<MenuButton
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
tooltip="重做"
|
||||
>
|
||||
<Redo className="h-5 w-5" />
|
||||
<Redo className="h-4 w-4" />
|
||||
</MenuButton>
|
||||
{onPolish && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onPolish}
|
||||
className="bg-gradient-to-r from-purple-400 to-pink-500 hover:from-pink-500 hover:to-purple-400 text-white border-none shadow-md transition-all duration-300"
|
||||
>
|
||||
<Wand2 className="h-4 w-4 mr-2" />
|
||||
AI 润色
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
@@ -26,8 +26,8 @@ const DialogOverlay = React.forwardRef<
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
@@ -45,13 +45,13 @@ const DialogContent = React.forwardRef<
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<X className="h-8 w-8" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
@@ -64,8 +64,8 @@ const DialogHeader = ({
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
@@ -78,8 +78,8 @@ const DialogFooter = ({
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
@@ -93,8 +93,8 @@ const DialogTitle = React.forwardRef<
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
@@ -105,8 +105,8 @@ const DialogDescription = React.forwardRef<
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
@@ -119,4 +119,4 @@ export {
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user