mirror of
https://github.com/JOYCEQL/magic-resume.git
synced 2026-07-03 14:07:11 +02:00
feat: Introduce a grammar check drawer for enhanced error management and i18n.
This commit is contained in:
@@ -24,24 +24,35 @@ export async function POST(req: NextRequest) {
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `你是一个专业的中文简历错别字检查助手。请完整检查以下文本,不要遗漏,以下是要求:
|
||||
1.所有考虑中文语境下的语法组词错误,包括拼写错误,语境用词错误,专业术语错误。
|
||||
2.验证是否有遗漏文字未检查
|
||||
3.对每个发现的问题,请按JSON格式返回
|
||||
content: `你是一个专业的中文简历校对助手。你的任务是**仅**找出简历中的**错别字**和**标点符号错误**。
|
||||
|
||||
**严格禁止**:
|
||||
1. ❌ **禁止**提供任何风格、语气、润色或改写建议。如果句子在语法上是正确的(即使读起来不够优美),也**绝对不要**报错。
|
||||
2. ❌ **禁止**报告“无明显错误”或类似的信息。如果没有发现错别字或标点错误,"errors" 数组必须为空。
|
||||
3. ❌ **禁止**对专业术语进行过度纠正,除非通过上下文非常确定是打字错误。
|
||||
|
||||
以下是示例格式:
|
||||
{
|
||||
"errors": [
|
||||
{
|
||||
"text": "错误的文本",
|
||||
"message": "详细的错误说明",
|
||||
"type": "spelling"或"grammar",
|
||||
"suggestions": ["建议修改1", "建议修改2"]
|
||||
}
|
||||
]
|
||||
}
|
||||
**仅检查以下两类错误**:
|
||||
1. ✅ **错别字**:例如将“作为”写成“做为”,将“经理”写成“经里”。
|
||||
2. ✅ **严重标点错误**:仅报告重复标点(如“,,”)或完全错误的符号位置。
|
||||
|
||||
**重要例外(绝不报错)**:
|
||||
- ❌ **忽略中英文标点混用**:在技术简历中,中文内容使用英文标点(如使用英文逗号, 代替中文逗号,或使用英文句点. 代替中文句号)是**完全接受**的风格。**绝对不要**报告此类“错误”。
|
||||
- ❌ **忽略空格使用**:不要报告中英文之间的空格遗漏或多余。
|
||||
|
||||
请确保返回的格式可以正常解析`,
|
||||
返回格式示例(JSON):
|
||||
{
|
||||
"errors": [
|
||||
{
|
||||
"context": "包含错误的完整句子(必须是原文)",
|
||||
"text": "具体的错误部分(必须是原文中实际存在的字符串)",
|
||||
"suggestion": "仅包含修正后的词汇或片段(**不要**返回整句,除非整句都是错误的)",
|
||||
"reason": "错别字 / 标点错误",
|
||||
"type": "spelling"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
再次强调:**只找错别字和标点错误,不要做任何润色!**`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
HoverCardContent
|
||||
} from "@/components/ui/hover-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { GrammarCheckDrawer } from "./grammar/GrammarCheckDrawer";
|
||||
|
||||
interface EditorHeaderProps {
|
||||
isMobile?: boolean;
|
||||
@@ -53,59 +54,17 @@ export function EditorHeader({ isMobile }: EditorHeaderProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<GrammarCheckDrawer />
|
||||
{errors.length > 0 && (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<div className="flex items-center space-x-1 cursor-pointer">
|
||||
<div
|
||||
className="flex items-center space-x-1 cursor-pointer animate-pulse"
|
||||
onClick={() => document.dispatchEvent(new CustomEvent('open-grammar-drawer'))}
|
||||
>
|
||||
<AlertCircle className="w-4 h-4 text-red-500" />
|
||||
<span className="text-sm text-red-500">
|
||||
发现 {errors.length} 个问题
|
||||
{t("grammarCheck.found_issues", { count: errors.length })}
|
||||
</span>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-80">
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">语法检查结果</h4>
|
||||
<div className="space-y-1">
|
||||
{errors.map((error, index) => (
|
||||
<div key={index} className="text-sm space-y-1">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 text-red-500 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p>{error.message}</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => selectError(index)}
|
||||
>
|
||||
定位
|
||||
</Button>
|
||||
</div>
|
||||
{error.suggestions.length > 0 && (
|
||||
<div className="mt-1">
|
||||
<p className="text-xs text-muted-foreground font-medium">
|
||||
建议修改:
|
||||
</p>
|
||||
{error.suggestions.map((suggestion, i) => (
|
||||
<p
|
||||
key={i}
|
||||
className="text-xs mt-1 px-2 py-1 bg-muted rounded"
|
||||
>
|
||||
{suggestion}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
defaultValue={activeResume?.title || ""}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
"use client";
|
||||
|
||||
import { useGrammarCheck } from "@/hooks/useGrammarCheck";
|
||||
import { useResumeStore } from "@/store/useResumeStore";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
} from "@/components/ui/sheet-no-overlay";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Check, X, ArrowRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect, useState } from "react";
|
||||
import Mark from "mark.js";
|
||||
import { toast } from "sonner";
|
||||
import { ResumeData } from "@/types/resume";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function GrammarCheckDrawer() {
|
||||
const t = useTranslations("grammarCheck");
|
||||
const {
|
||||
errors,
|
||||
clearErrors,
|
||||
selectError,
|
||||
selectedErrorIndex,
|
||||
dismissError,
|
||||
} = useGrammarCheck();
|
||||
|
||||
const { activeResume, updateResume } = useResumeStore();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (errors.length > 0) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}, [errors.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpenDrawer = () => setIsOpen(true);
|
||||
document.addEventListener("open-grammar-drawer", handleOpenDrawer);
|
||||
return () => {
|
||||
document.removeEventListener("open-grammar-drawer", handleOpenDrawer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setIsOpen(open);
|
||||
};
|
||||
|
||||
const handleAccept = (index: number) => {
|
||||
const error = errors[index];
|
||||
if (!error || !activeResume) return;
|
||||
|
||||
// 递归查找并替换简历数据中的文本
|
||||
const newResume = JSON.parse(JSON.stringify(activeResume));
|
||||
let replaced = false;
|
||||
|
||||
const traverseAndReplace = (obj: any) => {
|
||||
for (const key in obj) {
|
||||
if (typeof obj[key] === "string") {
|
||||
// 优先匹配 context
|
||||
if (error.context && obj[key].includes(error.context)) {
|
||||
// 在 context 中查找并替换 text,确保只替换错误部分
|
||||
// 1. 获取原文片段
|
||||
const originalContext = error.context;
|
||||
// 2. 在原文片段中替换错误词汇为建议词汇
|
||||
// 注意:这里假设 text 在 context 中只出现一次,或者我们需要替换的是 context 中的那个实例
|
||||
// 为了安全,我们只替换 context 中的 text
|
||||
if (originalContext.includes(error.text)) {
|
||||
const correctedContext = originalContext.replace(error.text, error.suggestion);
|
||||
// 3. 将修改后的 context 替换回 obj[key]
|
||||
obj[key] = obj[key].replace(originalContext, correctedContext);
|
||||
replaced = true;
|
||||
} else {
|
||||
// 如果 text 不在 context 中(奇怪的情况),尝试直接替换 context
|
||||
// 这通常是 fallback,但如果是整句替换也可以
|
||||
// 但根据新的 Prompt,suggestion 应该是片段,所以这里可能不适用
|
||||
// 暂时保留作为最后的手段,但记录日志或谨慎处理
|
||||
// obj[key] = obj[key].replace(error.context, error.suggestion);
|
||||
// 由于 suggestion 现在是片段,直接替换 context 是错误的。
|
||||
}
|
||||
}
|
||||
// 如果 context 没找到,尝试全局匹配 text(风险较高,但在某些情况下有效)
|
||||
// 只有当 text 足够长或者很独特时才安全
|
||||
else if (error.text && obj[key].includes(error.text) && error.text.length > 2) {
|
||||
obj[key] = obj[key].replace(error.text, error.suggestion);
|
||||
replaced = true;
|
||||
}
|
||||
} else if (typeof obj[key] === "object" && obj[key] !== null) {
|
||||
traverseAndReplace(obj[key]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
traverseAndReplace(newResume);
|
||||
|
||||
if (replaced) {
|
||||
updateResume(activeResume.id, newResume);
|
||||
dismissError(index);
|
||||
toast.success(t("applied_success"));
|
||||
} else {
|
||||
toast.error(t("apply_error"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleIgnore = (index: number) => {
|
||||
dismissError(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={handleOpenChange} modal={false}>
|
||||
<SheetContent side="left" className="w-[400px] sm:w-[450px] p-0 flex flex-col gap-0 border-r shadow-lg bg-muted/30 backdrop-blur-xl">
|
||||
<div className="p-6 pb-4 bg-background/80 backdrop-blur-sm sticky top-0 z-10 border-b">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<Check className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<SheetTitle className="text-lg font-semibold">{t("title")}</SheetTitle>
|
||||
<SheetDescription className="text-xs">
|
||||
{t("description", { count: errors.length })}
|
||||
</SheetDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
clearErrors();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 px-6 py-6 h-full">
|
||||
<div className="space-y-6 pb-20">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{errors.map((error, index) => (
|
||||
<motion.div
|
||||
key={`${error.text}-${index}`}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.2 } }}
|
||||
className={cn(
|
||||
"group relative bg-card rounded-xl border border-border/50 shadow-sm transition-all duration-300 overflow-hidden",
|
||||
"hover:shadow-md hover:border-primary/20",
|
||||
selectedErrorIndex === index && "border-primary bg-primary/5 shadow-sm"
|
||||
)}
|
||||
onClick={() => selectError(index)}
|
||||
>
|
||||
{/* 卡片头部 */}
|
||||
<div className="px-4 py-3 border-b border-border/50 flex justify-between items-center bg-muted/20 rounded-t-xl">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"text-[10px] px-2 py-0.5 h-5 font-normal tracking-wide",
|
||||
error.type === "spelling"
|
||||
? "bg-orange-50 text-orange-600 border-orange-100 dark:bg-orange-950/30 dark:text-orange-400 dark:border-orange-900/50"
|
||||
: "bg-blue-50 text-blue-600 border-blue-100 dark:bg-blue-950/30 dark:text-blue-400 dark:border-blue-900/50"
|
||||
)}
|
||||
>
|
||||
{error.type === "spelling" ? t("spelling") : t("punctuation")}
|
||||
</Badge>
|
||||
{/* 只有当 reason 与 Badge 内容不同时才显示 */}
|
||||
{error.reason && error.reason !== "错别字" && error.reason !== "标点符号" && (
|
||||
<span className="text-[10px] text-muted-foreground/70 italic max-w-[180px] truncate">
|
||||
{error.reason}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-5">
|
||||
{/* 内容对比区 */}
|
||||
<div className="grid gap-3">
|
||||
{/* 原文 */}
|
||||
<div className="space-y-1.5 group/original">
|
||||
<div className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider pl-1">
|
||||
{t("original")}
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-red-50/50 dark:bg-red-950/20 text-sm text-foreground/80 leading-relaxed border border-red-100/50 dark:border-red-900/30 transition-colors group-hover/original:bg-red-50 dark:group-hover/original:bg-red-950/30">
|
||||
<span className="line-through decoration-red-400/30 text-muted-foreground/80">
|
||||
{error.context}
|
||||
</span>
|
||||
{error.text && error.text !== error.context && (
|
||||
<div className="mt-2 text-xs flex items-center gap-1.5 text-red-500/80 font-medium">
|
||||
<span className="w-1 h-1 rounded-full bg-red-400 inline-block" />
|
||||
{error.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 箭头 */}
|
||||
<div className="flex justify-center -my-1 text-muted-foreground/20">
|
||||
<ArrowRight className="w-4 h-4 rotate-90" />
|
||||
</div>
|
||||
|
||||
{/* 建议 */}
|
||||
<div className="space-y-1.5 group/suggestion">
|
||||
<div className="text-[10px] font-medium text-emerald-600/80 dark:text-emerald-400/80 uppercase tracking-wider pl-1">
|
||||
{t("suggestion")}
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-emerald-50/50 dark:bg-emerald-950/20 text-sm text-foreground leading-relaxed border border-emerald-100/50 dark:border-emerald-900/30 font-medium transition-colors group-hover/suggestion:bg-emerald-50 dark:group-hover/suggestion:bg-emerald-950/30">
|
||||
{error.suggestion}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 按钮区 */}
|
||||
<div className="flex gap-3 pt-1">
|
||||
<Button
|
||||
className="flex-1 bg-primary hover:bg-primary/90 text-primary-foreground shadow-sm hover:shadow transition-all"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAccept(index);
|
||||
}}
|
||||
>
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
{t("accept")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 border-muted-foreground/20 text-muted-foreground hover:text-foreground hover:bg-muted/50 hover:border-muted-foreground/30 transition-all"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleIgnore(index);
|
||||
}}
|
||||
>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
{t("ignore")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{errors.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center px-4">
|
||||
<div className="w-20 h-20 bg-green-50 dark:bg-green-900/20 rounded-full flex items-center justify-center mb-6 animate-in zoom-in duration-500">
|
||||
<Check className="w-10 h-10 text-green-500 dark:text-green-400" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2">{t("no_errors_title")}</h3>
|
||||
<p className="text-muted-foreground max-w-[250px]">
|
||||
{t("no_errors_desc")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export const useGrammarCheck = () => {
|
||||
checkGrammar,
|
||||
clearErrors,
|
||||
selectError,
|
||||
dismissError,
|
||||
} = useGrammarStore();
|
||||
|
||||
return {
|
||||
@@ -24,5 +25,6 @@ export const useGrammarCheck = () => {
|
||||
checkGrammar,
|
||||
clearErrors,
|
||||
selectError,
|
||||
dismissError,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -642,5 +642,23 @@
|
||||
"confirmText": "Delete",
|
||||
"cancelText": "Cancel"
|
||||
}
|
||||
},
|
||||
"grammarCheck": {
|
||||
"title": "AI Grammar Check",
|
||||
"description": "Found {count} suggestions",
|
||||
"exit": "Exit",
|
||||
"spelling": "Spelling",
|
||||
"punctuation": "Punctuation",
|
||||
"original": "Original",
|
||||
"error_point": "Error",
|
||||
"suggestion": "Suggestion",
|
||||
"reason": "Reason",
|
||||
"accept": "Apply",
|
||||
"ignore": "Ignore",
|
||||
"found_issues": "Found {count} issues",
|
||||
"applied_success": "Changes applied",
|
||||
"apply_error": "Text not found, cannot apply automatically",
|
||||
"no_errors_title": "Perfect!",
|
||||
"no_errors_desc": "No grammar or punctuation errors found."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,5 +644,23 @@
|
||||
"confirmText": "删除",
|
||||
"cancelText": "取消"
|
||||
}
|
||||
},
|
||||
"grammarCheck": {
|
||||
"title": "AI 语法检查",
|
||||
"description": "发现 {count} 个建议优化项",
|
||||
"exit": "退出检查",
|
||||
"spelling": "错别字",
|
||||
"punctuation": "标点符号",
|
||||
"original": "原文",
|
||||
"error_point": "错误点",
|
||||
"suggestion": "建议修改",
|
||||
"reason": "原因",
|
||||
"accept": "应用",
|
||||
"ignore": "忽略",
|
||||
"found_issues": "发现 {count} 个问题",
|
||||
"applied_success": "已应用修改",
|
||||
"apply_error": "未找到对应文本,无法自动修改",
|
||||
"no_errors_title": "太棒了!",
|
||||
"no_errors_desc": "文档非常完美,没有发现任何错别字或标点错误。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,11 @@ import { AI_MODEL_CONFIGS } from "@/config/ai";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface GrammarError {
|
||||
error: string;
|
||||
suggestion: string;
|
||||
context: string;
|
||||
type?: string;
|
||||
text?: string;
|
||||
text: string;
|
||||
suggestion: string;
|
||||
reason: string;
|
||||
type: "spelling" | "grammar";
|
||||
}
|
||||
|
||||
interface GrammarStore {
|
||||
@@ -25,6 +25,7 @@ interface GrammarStore {
|
||||
checkGrammar: (text: string) => Promise<void>;
|
||||
clearErrors: () => void;
|
||||
selectError: (index: number) => void;
|
||||
dismissError: (index: number) => void;
|
||||
}
|
||||
|
||||
export const useGrammarStore = create<GrammarStore>((set, get) => ({
|
||||
@@ -159,4 +160,29 @@ export const useGrammarStore = create<GrammarStore>((set, get) => ({
|
||||
});
|
||||
}
|
||||
},
|
||||
dismissError: (index: number) => {
|
||||
set((state) => {
|
||||
const newErrors = [...state.errors];
|
||||
newErrors.splice(index, 1);
|
||||
|
||||
const preview = document.getElementById("resume-preview");
|
||||
if (preview) {
|
||||
// 重新标记剩余错误
|
||||
const marker = new Mark(preview);
|
||||
marker.unmark();
|
||||
newErrors.forEach((error, i) => {
|
||||
marker.mark(error.context || error.text || "", {
|
||||
className: cn(
|
||||
"bg-yellow-200 dark:bg-yellow-900",
|
||||
state.selectedErrorIndex === i && "bg-green-200 dark:bg-green-900"
|
||||
// 注意:selectedErrorIndex 可能因为删除而需要调整,这里简化处理,稍后在选中逻辑中可能需要优化
|
||||
// 为了保持一致性,最好是重新选中当前索引或重置选中
|
||||
)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { errors: newErrors, selectedErrorIndex: null };
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
declare module 'mark.js' {
|
||||
export default class Mark {
|
||||
constructor(element: string | HTMLElement | NodeList | null);
|
||||
mark(keyword: string | string[], options?: any): void;
|
||||
unmark(options?: any): void;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user