feat: Introduce a grammar check drawer for enhanced error management and i18n.

This commit is contained in:
JOYCEQL
2026-02-11 16:37:06 +08:00
parent f6cf5bfcdf
commit d14176d474
8 changed files with 380 additions and 69 deletions
+27 -16
View File
@@ -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",
+8 -49
View File
@@ -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,但如果是整句替换也可以
// 但根据新的 Promptsuggestion 应该是片段,所以这里可能不适用
// 暂时保留作为最后的手段,但记录日志或谨慎处理
// 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>
);
}
+2
View File
@@ -15,6 +15,7 @@ export const useGrammarCheck = () => {
checkGrammar,
clearErrors,
selectError,
dismissError,
} = useGrammarStore();
return {
@@ -24,5 +25,6 @@ export const useGrammarCheck = () => {
checkGrammar,
clearErrors,
selectError,
dismissError,
};
};
+18
View File
@@ -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."
}
}
+18
View File
@@ -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": "文档非常完美,没有发现任何错别字或标点错误。"
}
}
+30 -4
View File
@@ -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 };
});
},
}));
+7
View File
@@ -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;
}
}