diff --git a/src/app/api/grammar/route.ts b/src/app/api/grammar/route.ts
index c495ca9..ec26873 100644
--- a/src/app/api/grammar/route.ts
+++ b/src/app/api/grammar/route.ts
@@ -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",
diff --git a/src/components/editor/EditorHeader.tsx b/src/components/editor/EditorHeader.tsx
index ef38b87..f8f9dca 100644
--- a/src/components/editor/EditorHeader.tsx
+++ b/src/components/editor/EditorHeader.tsx
@@ -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) {
+
{errors.length > 0 && (
-
-
-
+
document.dispatchEvent(new CustomEvent('open-grammar-drawer'))}
+ >
- 发现 {errors.length} 个问题
+ {t("grammarCheck.found_issues", { count: errors.length })}
-
-
-
-
-
语法检查结果
-
- {errors.map((error, index) => (
-
-
-
-
-
-
{error.message}
-
-
- {error.suggestions.length > 0 && (
-
-
- 建议修改:
-
- {error.suggestions.map((suggestion, i) => (
-
- {suggestion}
-
- ))}
-
- )}
-
-
-
- ))}
-
-
-
-
+
)}
{
+ 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 (
+
+
+
+
+
+
+
+
+
+ {t("title")}
+
+ {t("description", { count: errors.length })}
+
+
+
+
+
+
+
+
+
+
+ {errors.map((error, index) => (
+ selectError(index)}
+ >
+ {/* 卡片头部 */}
+
+
+ {error.type === "spelling" ? t("spelling") : t("punctuation")}
+
+ {/* 只有当 reason 与 Badge 内容不同时才显示 */}
+ {error.reason && error.reason !== "错别字" && error.reason !== "标点符号" && (
+
+ {error.reason}
+
+ )}
+
+
+
+ {/* 内容对比区 */}
+
+ {/* 原文 */}
+
+
+ {t("original")}
+
+
+
+ {error.context}
+
+ {error.text && error.text !== error.context && (
+
+
+ {error.text}
+
+ )}
+
+
+
+ {/* 箭头 */}
+
+
+ {/* 建议 */}
+
+
+ {t("suggestion")}
+
+
+ {error.suggestion}
+
+
+
+
+ {/* 按钮区 */}
+
+
+
+
+
+
+ ))}
+
+
+ {errors.length === 0 && (
+
+
+
+
+
{t("no_errors_title")}
+
+ {t("no_errors_desc")}
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/hooks/useGrammarCheck.ts b/src/hooks/useGrammarCheck.ts
index 90d9580..5115a0a 100644
--- a/src/hooks/useGrammarCheck.ts
+++ b/src/hooks/useGrammarCheck.ts
@@ -15,6 +15,7 @@ export const useGrammarCheck = () => {
checkGrammar,
clearErrors,
selectError,
+ dismissError,
} = useGrammarStore();
return {
@@ -24,5 +25,6 @@ export const useGrammarCheck = () => {
checkGrammar,
clearErrors,
selectError,
+ dismissError,
};
};
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 34c98f6..e7a1d92 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -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."
}
}
diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json
index b0a0341..56da5e2 100644
--- a/src/i18n/locales/zh.json
+++ b/src/i18n/locales/zh.json
@@ -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": "文档非常完美,没有发现任何错别字或标点错误。"
}
}
diff --git a/src/store/useGrammarStore.ts b/src/store/useGrammarStore.ts
index 108dd25..5eed195 100644
--- a/src/store/useGrammarStore.ts
+++ b/src/store/useGrammarStore.ts
@@ -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;
clearErrors: () => void;
selectError: (index: number) => void;
+ dismissError: (index: number) => void;
}
export const useGrammarStore = create((set, get) => ({
@@ -159,4 +160,29 @@ export const useGrammarStore = create((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 };
+ });
+ },
}));
diff --git a/src/types/mark.js.d.ts b/src/types/mark.js.d.ts
new file mode 100644
index 0000000..e3a9487
--- /dev/null
+++ b/src/types/mark.js.d.ts
@@ -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;
+ }
+}