(null);
@@ -136,7 +139,7 @@ const CustomItem = ({
"font-medium truncate text-foreground"
)}
>
- {item.title || "未命名模块"}
+ {item.title || t("unnamedModule")}
{item.subtitle && (
{
+ const t = useTranslations("workbench.customPanel");
const { addCustomItem, updateCustomData, activeResume } = useResumeStore();
const { customData } = activeResume || {};
const items = customData?.[sectionId] || [];
@@ -36,7 +38,7 @@ const CustomPanel = memo(({ sectionId }: { sectionId: string }) => {
diff --git a/src/components/editor/education/EducationItem.tsx b/src/components/editor/education/EducationItem.tsx
index 7b05c716..81840f55 100644
--- a/src/components/editor/education/EducationItem.tsx
+++ b/src/components/editor/education/EducationItem.tsx
@@ -26,6 +26,7 @@ const EducationEditor: React.FC = ({
onSave,
}) => {
const t = useTranslations("workbench.educationItem");
+ const tFallbacks = useTranslations("workbench.fallbacks");
const handleChange = (field: keyof Education, value: string) => {
onSave({
...education,
@@ -182,7 +183,7 @@ const EducationItem = ({ education }: { education: Education }) => {
"text-foreground"
)}
>
- {education.school || "未填写学校"}
+ {education.school || tFallbacks("emptySchool")}
{(education.major || education.degree) && (
= ({
onSave,
}) => {
const t = useTranslations("workbench.experienceItem");
+ const tFallbacks = useTranslations("workbench.fallbacks");
const handleChange = (field: keyof Experience, value: string) => {
onSave({
@@ -153,7 +154,7 @@ const ExperienceItem = ({ experience }: { experience: Experience }) => {
"text-foreground"
)}
>
- {experience.company || "家里蹲公司"}
+ {experience.company || tFallbacks("unnamedCompany")}
diff --git a/src/components/editor/grammar/GrammarCheckDrawer.tsx b/src/components/editor/grammar/GrammarCheckDrawer.tsx
index 8def5dfe..08f3012e 100644
--- a/src/components/editor/grammar/GrammarCheckDrawer.tsx
+++ b/src/components/editor/grammar/GrammarCheckDrawer.tsx
@@ -174,12 +174,6 @@ export function GrammarCheckDrawer() {
>
{error.type === "spelling" ? t("spelling") : t("punctuation")}
- {/* 只有当 reason 与 Badge 内容不同时才显示 */}
- {error.reason && error.reason !== "错别字" && error.reason !== "标点符号" && (
-
- {error.reason}
-
- )}
diff --git a/src/components/editor/project/ProjectItem.tsx b/src/components/editor/project/ProjectItem.tsx
index 95dfb0af..484609bf 100644
--- a/src/components/editor/project/ProjectItem.tsx
+++ b/src/components/editor/project/ProjectItem.tsx
@@ -24,6 +24,7 @@ interface ProjectEditorProps {
const ProjectEditor: React.FC
= ({ project, onSave }) => {
const t = useTranslations("workbench.projectItem");
+ const tFallbacks = useTranslations("workbench.fallbacks");
const handleChange = (field: keyof Project, value: string) => {
onSave({
...project,
@@ -195,7 +196,7 @@ const ProjectItem = ({ project }: { project: Project }) => {
"text-gray-700 dark:text-neutral-200"
)}
>
- {project.name || "未命名项目"}
+ {project.name || tFallbacks("unnamedProject")}
diff --git a/src/components/editor/self-evaluation/SelfEvaluationPanel.tsx b/src/components/editor/self-evaluation/SelfEvaluationPanel.tsx
index e9d0e9ee..726ff230 100644
--- a/src/components/editor/self-evaluation/SelfEvaluationPanel.tsx
+++ b/src/components/editor/self-evaluation/SelfEvaluationPanel.tsx
@@ -1,8 +1,10 @@
+import { useTranslations } from "@/i18n/compat/client";
import { useResumeStore } from "@/store/useResumeStore";
import { cn } from "@/lib/utils";
import Field from "../Field";
const SelfEvaluationPanel = () => {
+ const t = useTranslations("workbench.selfEvaluationPanel");
const { activeResume, updateSelfEvaluationContent } = useResumeStore();
const selfEvaluationContent = activeResume?.selfEvaluationContent ?? "";
const handleChange = (value: string) => {
@@ -21,7 +23,7 @@ const SelfEvaluationPanel = () => {
value={selfEvaluationContent}
onChange={handleChange}
type="editor"
- placeholder="描述你的自我评价..."
+ placeholder={t("placeholder")}
/>
);
diff --git a/src/components/editor/skills/SkillPanel.tsx b/src/components/editor/skills/SkillPanel.tsx
index da07d0b3..7209bdf2 100644
--- a/src/components/editor/skills/SkillPanel.tsx
+++ b/src/components/editor/skills/SkillPanel.tsx
@@ -1,8 +1,10 @@
+import { useTranslations } from "@/i18n/compat/client";
import { useResumeStore } from "@/store/useResumeStore";
import { cn } from "@/lib/utils";
import Field from "../Field";
const SkillPanel = () => {
+ const t = useTranslations("workbench.skillsPanel");
const { activeResume, updateSkillContent } = useResumeStore();
const { skillContent } = activeResume || {};
const handleChange = (value: string) => {
@@ -21,7 +23,7 @@ const SkillPanel = () => {
value={skillContent}
onChange={handleChange}
type="editor"
- placeholder="描述你的技能、专长等..."
+ placeholder={t("placeholder")}
/>
);
diff --git a/src/components/preview/PreviewDock.tsx b/src/components/preview/PreviewDock.tsx
index 1b1b9627..745b617c 100644
--- a/src/components/preview/PreviewDock.tsx
+++ b/src/components/preview/PreviewDock.tsx
@@ -16,7 +16,7 @@ import {
import { RiMarkdownLine } from "@remixicon/react";
import { toast } from "sonner";
import { motion } from "framer-motion";
-import { useTranslations } from "@/i18n/compat/client";
+import { useTranslations, useLocale } from "@/i18n/compat/client";
import { useRouter } from "@/lib/navigation";
import { Dock, DockIcon } from "@/components/magicui/dock";
import { Button } from "@/components/ui/button";
@@ -91,6 +91,7 @@ const PreviewDock = ({
}: PreviewDockProps) => {
const router = useRouter();
const t = useTranslations("previewDock");
+ const locale = useLocale();
const { checkGrammar, isChecking } = useGrammarCheck();
const {
@@ -130,11 +131,11 @@ const PreviewDock = ({
return;
}
- await checkGrammar(text);
+ await checkGrammar(text, locale);
} catch (error) {
toast.error(t("grammarCheck.errorToast"));
}
- }, [resumeContentRef, checkConfiguration, checkGrammar, t]);
+ }, [resumeContentRef, checkConfiguration, checkGrammar, locale, t]);
const handleGoGitHub = () => {
window.open(GITHUB_REPO_URL, "_blank");
diff --git a/src/components/shared/GithubContribution.tsx b/src/components/shared/GithubContribution.tsx
index 745b8b84..38ea33fb 100644
--- a/src/components/shared/GithubContribution.tsx
+++ b/src/components/shared/GithubContribution.tsx
@@ -1,5 +1,7 @@
import React, { useEffect, useState } from "react";
+import { useLocale } from "@/i18n/compat/client";
+import { heroUiLocales } from "@/i18n/config";
import { cn } from "@/lib/utils";
interface ContributionDay {
@@ -30,9 +32,9 @@ const getColorLevel = (count: number): string => {
return colorLevels[4];
};
-const formatDate = (dateString: string): string => {
+const formatDate = (dateString: string, locale: string): string => {
const date = new Date(dateString);
- return date.toLocaleDateString("zh-CN", {
+ return date.toLocaleDateString(locale, {
year: "numeric",
month: "long",
day: "numeric",
@@ -125,6 +127,8 @@ const GithubContributions: React.FC = ({
className,
year = new Date().getFullYear(),
}) => {
+ const appLocale = useLocale();
+ const dateLocale = heroUiLocales[appLocale];
const [weeks, setWeeks] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
@@ -223,7 +227,7 @@ const GithubContributions: React.FC = ({
transitionDuration: "150ms",
}}
>
- {formatDate(day.date)}: {day.count} contributions
+ {formatDate(day.date, dateLocale)}: {day.count} contributions
)}
diff --git a/src/components/shared/LanguageSwitch.tsx b/src/components/shared/LanguageSwitch.tsx
index a9f47729..c1451d2f 100644
--- a/src/components/shared/LanguageSwitch.tsx
+++ b/src/components/shared/LanguageSwitch.tsx
@@ -1,5 +1,4 @@
import { useLocale } from "@/i18n/compat/client";
-import { useLocation, useNavigate } from "@tanstack/react-router";
import { Languages } from "lucide-react";
import {
DropdownMenu,
@@ -9,23 +8,11 @@ import {
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { locales, localeNames } from "@/i18n/config";
-import { getLocaleFromPathname, replacePathLocale } from "@/i18n/runtime";
+import { useSetAppLocale } from "@/i18n/locale-context";
export default function LanguageSwitch() {
const locale = useLocale();
- const navigate = useNavigate();
- const pathname = useLocation({
- select: (location) => location.pathname
- });
-
- const handleSwitchLocale = (nextLocale: (typeof locales)[number]) => {
- document.cookie = `NEXT_LOCALE=${nextLocale}; path=/; max-age=31536000`;
-
- const currentPathLocale = getLocaleFromPathname(pathname);
- if (currentPathLocale) {
- navigate({ to: replacePathLocale(pathname, nextLocale) });
- }
- };
+ const setLocale = useSetAppLocale();
return (
@@ -44,7 +31,7 @@ export default function LanguageSwitch() {
handleSwitchLocale(loc)}
+ onClick={() => setLocale(loc)}
>
{localeNames[loc]}
diff --git a/src/components/shared/TemplateSheet.tsx b/src/components/shared/TemplateSheet.tsx
index c63bb832..6d3be181 100644
--- a/src/components/shared/TemplateSheet.tsx
+++ b/src/components/shared/TemplateSheet.tsx
@@ -10,6 +10,7 @@ import {
SheetTrigger,
} from "@/components/ui/sheet-no-overlay";
import { cn } from "@/lib/utils";
+import { getTemplateKey } from "@/lib/templates";
import { DEFAULT_TEMPLATES } from "@/config";
import { useResumeStore } from "@/store/useResumeStore";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -19,6 +20,7 @@ type TemplateItem = (typeof DEFAULT_TEMPLATES)[number];
interface TemplatePreviewProps {
template: TemplateItem;
+ templateName: string;
isActive: boolean;
snapshotSrc: string | null;
onSelect: (templateId: string) => void;
@@ -26,6 +28,7 @@ interface TemplatePreviewProps {
const TemplatePreview = ({
template,
+ templateName,
isActive,
snapshotSrc,
onSelect,
@@ -44,7 +47,7 @@ const TemplatePreview = ({
{snapshotSrc ? (
- {template.name}
+ {templateName}
)}
@@ -72,6 +75,7 @@ const TemplatePreview = ({
const TemplateSheet = () => {
const t = useTranslations("templates");
+ const tTemplates = useTranslations("dashboard.templates");
const locale = useLocale();
const { activeResume, setTemplate } = useResumeStore();
const { snapshotMap } = useTemplateSnapshots(locale);
@@ -98,6 +102,7 @@ const TemplateSheet = () => {
= {
+ zh: DEFAULT_FIELD_ORDER,
+ en: DEFAULT_FIELD_ORDER_EN,
+ ru: DEFAULT_FIELD_ORDER_RU,
+};
+
+export function getDefaultFieldOrder(locale: Locale): BasicFieldType[] {
+ return FIELD_ORDER_BY_LOCALE[locale] ?? DEFAULT_FIELD_ORDER;
+}
+
export const GITHUB_REPO_URL = "https://github.com/JOYCEQL/magic-resume";
export const PDF_EXPORT_CONFIG = {
diff --git a/src/config/initialResumeData.ts b/src/config/initialResumeData.ts
index 160e28eb..603357dc 100644
--- a/src/config/initialResumeData.ts
+++ b/src/config/initialResumeData.ts
@@ -1,4 +1,8 @@
-import { DEFAULT_FIELD_ORDER } from "./constants";
+import {
+ DEFAULT_FIELD_ORDER,
+ DEFAULT_FIELD_ORDER_EN,
+ DEFAULT_FIELD_ORDER_RU,
+} from "./constants";
import { GlobalSettings, DEFAULT_CONFIG, ResumeData } from "../types/resume";
const initialGlobalSettings: GlobalSettings = {
baseFontSize: 16,
@@ -180,7 +184,7 @@ export const initialResumeStateEn = {
phone: "555-123-4567",
location: "San Francisco, CA",
birthDate: "",
- fieldOrder: DEFAULT_FIELD_ORDER,
+ fieldOrder: DEFAULT_FIELD_ORDER_EN,
icons: {
email: "Mail",
phone: "Phone",
@@ -378,3 +382,188 @@ export const blankResumeStateEn = {
certificates: [],
menuSections: [initialResumeStateEn.menuSections[0]],
};
+
+export const initialResumeStateRu = {
+ title: "Новое резюме",
+ basic: {
+ name: "Анна Иванова",
+ title: "Старший фронтенд-инженер",
+ employementStatus: "В поиске работы",
+ email: "anna.ivanova@example.com",
+ phone: "+7 (999) 123-45-67",
+ location: "Москва, Россия",
+ birthDate: "",
+ fieldOrder: DEFAULT_FIELD_ORDER_RU,
+ icons: {
+ email: "Mail",
+ phone: "Phone",
+ birthDate: "CalendarRange",
+ employementStatus: "Briefcase",
+ location: "MapPin",
+ },
+ photoConfig: DEFAULT_CONFIG,
+ customFields: [],
+ photo: "/avatar.png",
+ githubKey: "",
+ githubUseName: "",
+ githubContributionsVisible: false,
+ },
+ education: [
+ {
+ id: "1",
+ school: "МГУ им. М.В. Ломоносова",
+ major: "Прикладная математика и информатика",
+ degree: "",
+ startDate: "2013-09",
+ endDate: "2017-06",
+ visible: true,
+ gpa: "",
+ description: `
+ - Основные курсы: структуры данных, алгоритмы, операционные системы, компьютерные сети, веб-разработка
+ - Топ 5% потока, три года подряд получала стипендию за успеваемость
+ - Руководила техническим отделом студенческого IT-клуба, организовывала митапы
+ - Участвовала в open-source проектах, сертификат GitHub Campus Expert
+
`,
+ },
+ ],
+ skillContent: `
+
+ - Фронтенд-фреймворки: React, Vue.js, Next.js, Nuxt.js и другие SSR-фреймворки
+ - Языки: TypeScript, JavaScript(ES6+), HTML5, CSS3
+ - UI/стили: TailwindCSS, Sass/Less, CSS Modules, Styled-components
+ - Управление состоянием: Redux, Vuex, Zustand, Jotai, React Query
+ - Сборка: Webpack, Vite, Rollup, Babel, ESLint
+ - Тестирование: Jest, React Testing Library, Cypress
+ - Производительность: принципы рендеринга браузера, мониторинг метрик, code splitting, lazy loading
+ - Контроль версий: Git, SVN
+ - Техническое лидерство: опыт управления командой, выбор технологий и архитектура крупных проектов
+
+
`,
+ selfEvaluationContent: "",
+ experience: [
+ {
+ id: "1",
+ company: "Яндекс",
+ position: "Старший фронтенд-инженер",
+ date: "2021.07 - 2024.12",
+ visible: true,
+ details: `
+ - Разработка и поддержка платформы для создателей контента, проектирование архитектуры ключевых функций
+ - Оптимизация сборки: время сборки сокращено с 8 до 2 минут
+ - Разработка библиотеки компонентов, повышение переиспользования кода на 70%
+ - Руководство проектом по оптимизации производительности, сокращение времени загрузки на 50%
+ - Наставничество junior-разработчиков, организация технических митапов
+
`,
+ },
+ ],
+ draggingProjectId: null,
+ projects: [
+ {
+ id: "p1",
+ name: "Платформа для создателей контента",
+ role: "Ведущий фронтенд-разработчик",
+ date: "2022.06 - 2023.12",
+ description: `
+ - React-платформа аналитики и управления контентом для миллионов создателей
+ - Подсистемы аналитики, управления контентом и монетизации
+ - Redux для управления состоянием и сложными потоками данных
+ - Ant Design для единообразия интерфейса
+ - Code splitting и lazy loading для оптимизации загрузки
+
`,
+ visible: true,
+ },
+ {
+ id: "p2",
+ name: "Инструменты разработки мини-приложений",
+ role: "Ключевой разработчик",
+ date: "2020.03 - 2021.06",
+ description: `
+ - Комплексное решение для разработки, отладки и публикации мини-приложений
+ - Кроссплатформенное десктопное приложение на Electron
+ - Поддержка Windows, macOS и Linux
+ - Логирование ошибок и анализ производительности в реальном времени
+ - Интеграция сторонних плагинов и SDK
+
`,
+ visible: true,
+ },
+ {
+ id: "p3",
+ name: "Платформа мониторинга фронтенда",
+ role: "Технический руководитель",
+ date: "2021.09 - 2022.05",
+ description: `
+ - Мониторинг ошибок, производительности и поведения пользователей
+ - Vue и Element UI, визуализация данных в реальном времени
+ - Метрики ошибок, производительности и поведения пользователей
+ - Инструменты анализа для поиска и устранения проблем
+ - Интеграция сторонних плагинов и SDK
+
`,
+ visible: true,
+ },
+ ],
+ menuSections: [
+ {
+ id: "basic",
+ title: "Профиль",
+ icon: "👤",
+ enabled: true,
+ order: 0,
+ },
+ {
+ id: "skills",
+ title: "Навыки",
+ icon: "⚡",
+ enabled: true,
+ order: 1,
+ },
+ {
+ id: "experience",
+ title: "Опыт",
+ icon: "💼",
+ enabled: true,
+ order: 2,
+ },
+ {
+ id: "projects",
+ title: "Проекты",
+ icon: "🚀",
+ enabled: true,
+ order: 3,
+ },
+ {
+ id: "education",
+ title: "Образование",
+ icon: "🎓",
+ enabled: true,
+ order: 4,
+ },
+ ],
+ certificates: [],
+ customData: {},
+ activeSection: "basic",
+ globalSettings: initialGlobalSettings,
+};
+
+export const blankResumeStateRu = {
+ ...initialResumeStateRu,
+ title: "Новое резюме",
+ basic: {
+ ...initialResumeStateRu.basic,
+ name: "",
+ title: "",
+ email: "",
+ phone: "",
+ location: "",
+ birthDate: "",
+ employementStatus: "",
+ photo: "",
+ customFields: [],
+ },
+ education: [],
+ skillContent: "",
+ selfEvaluationContent: "",
+ experience: [],
+ projects: [],
+ certificates: [],
+ menuSections: [initialResumeStateRu.menuSections[0]],
+};
diff --git a/src/config/localeResumeData.test.ts b/src/config/localeResumeData.test.ts
new file mode 100644
index 00000000..707d7531
--- /dev/null
+++ b/src/config/localeResumeData.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from "vitest";
+import { getInitialResumeStateForLocale } from "./localeResumeData";
+import { initialResumeStateRu } from "./initialResumeData";
+
+describe("getInitialResumeStateForLocale", () => {
+ it("returns Russian demo resume for ru locale", () => {
+ const resume = getInitialResumeStateForLocale("ru");
+
+ expect(resume.basic.name).toBe(initialResumeStateRu.basic.name);
+ expect(resume.basic.name).not.toBe(
+ getInitialResumeStateForLocale("zh").basic.name
+ );
+ });
+});
diff --git a/src/config/localeResumeData.ts b/src/config/localeResumeData.ts
new file mode 100644
index 00000000..28f89f62
--- /dev/null
+++ b/src/config/localeResumeData.ts
@@ -0,0 +1,49 @@
+import type { Locale } from "@/i18n/config";
+import { defaultLocale } from "@/i18n/config";
+import { getMessagesForLocale } from "@/i18n/messages";
+import { getCookieLocale as readCookieLocale } from "@/i18n/runtime";
+import {
+ blankResumeState,
+ blankResumeStateEn,
+ blankResumeStateRu,
+ initialResumeState,
+ initialResumeStateEn,
+ initialResumeStateRu,
+} from "./initialResumeData";
+
+type ResumeTemplateState = typeof initialResumeState;
+
+const INITIAL_RESUME_BY_LOCALE: Record = {
+ zh: initialResumeState,
+ en: initialResumeStateEn,
+ ru: initialResumeStateRu,
+};
+
+const BLANK_RESUME_BY_LOCALE: Record = {
+ zh: blankResumeState,
+ en: blankResumeStateEn,
+ ru: blankResumeStateRu,
+};
+
+export function getCookieLocale(): Locale {
+ return readCookieLocale();
+}
+
+export function getInitialResumeStateForLocale(locale: Locale): ResumeTemplateState {
+ return INITIAL_RESUME_BY_LOCALE[locale] ?? INITIAL_RESUME_BY_LOCALE[defaultLocale];
+}
+
+export function getBlankResumeStateForLocale(locale: Locale): ResumeTemplateState {
+ return BLANK_RESUME_BY_LOCALE[locale] ?? BLANK_RESUME_BY_LOCALE[defaultLocale];
+}
+
+export function getLocalizedCommonLabel(
+ locale: Locale,
+ key: "newResume" | "copy"
+): string {
+ const messages = getMessagesForLocale(locale) as {
+ common: Record;
+ };
+
+ return messages.common[key] ?? key;
+}
diff --git a/src/generated/templateSnapshotManifest.ts b/src/generated/templateSnapshotManifest.ts
index a7a1f1cd..f73a72f6 100644
--- a/src/generated/templateSnapshotManifest.ts
+++ b/src/generated/templateSnapshotManifest.ts
@@ -1,28 +1,39 @@
export const TEMPLATE_SNAPSHOT_MANIFEST = {
"version": 1,
- "generatedAt": "2026-06-01T05:05:39.212Z",
+ "generatedAt": "2026-06-09T15:38:50.087Z",
"locales": {
"zh": {
- "classic": "/template-snapshots/zh/classic.png?v=2026-06-01T05%3A05%3A39.212Z",
- "modern": "/template-snapshots/zh/modern.png?v=2026-06-01T05%3A05%3A39.212Z",
- "left-right": "/template-snapshots/zh/left-right.png?v=2026-06-01T05%3A05%3A39.212Z",
- "timeline": "/template-snapshots/zh/timeline.png?v=2026-06-01T05%3A05%3A39.212Z",
- "minimalist": "/template-snapshots/zh/minimalist.png?v=2026-06-01T05%3A05%3A39.212Z",
- "elegant": "/template-snapshots/zh/elegant.png?v=2026-06-01T05%3A05%3A39.212Z",
- "creative": "/template-snapshots/zh/creative.png?v=2026-06-01T05%3A05%3A39.212Z",
- "editorial": "/template-snapshots/zh/editorial.png?v=2026-06-01T05%3A05%3A39.212Z",
- "swiss": "/template-snapshots/zh/swiss.png?v=2026-06-01T05%3A05%3A39.212Z"
+ "classic": "/template-snapshots/zh/classic.png?v=2026-06-09T15%3A38%3A50.087Z",
+ "modern": "/template-snapshots/zh/modern.png?v=2026-06-09T15%3A38%3A50.087Z",
+ "left-right": "/template-snapshots/zh/left-right.png?v=2026-06-09T15%3A38%3A50.087Z",
+ "timeline": "/template-snapshots/zh/timeline.png?v=2026-06-09T15%3A38%3A50.087Z",
+ "minimalist": "/template-snapshots/zh/minimalist.png?v=2026-06-09T15%3A38%3A50.087Z",
+ "elegant": "/template-snapshots/zh/elegant.png?v=2026-06-09T15%3A38%3A50.087Z",
+ "creative": "/template-snapshots/zh/creative.png?v=2026-06-09T15%3A38%3A50.087Z",
+ "editorial": "/template-snapshots/zh/editorial.png?v=2026-06-09T15%3A38%3A50.087Z",
+ "swiss": "/template-snapshots/zh/swiss.png?v=2026-06-09T15%3A38%3A50.087Z"
},
"en": {
- "classic": "/template-snapshots/en/classic.png?v=2026-06-01T05%3A05%3A39.212Z",
- "modern": "/template-snapshots/en/modern.png?v=2026-06-01T05%3A05%3A39.212Z",
- "left-right": "/template-snapshots/en/left-right.png?v=2026-06-01T05%3A05%3A39.212Z",
- "timeline": "/template-snapshots/en/timeline.png?v=2026-06-01T05%3A05%3A39.212Z",
- "minimalist": "/template-snapshots/en/minimalist.png?v=2026-06-01T05%3A05%3A39.212Z",
- "elegant": "/template-snapshots/en/elegant.png?v=2026-06-01T05%3A05%3A39.212Z",
- "creative": "/template-snapshots/en/creative.png?v=2026-06-01T05%3A05%3A39.212Z",
- "editorial": "/template-snapshots/en/editorial.png?v=2026-06-01T05%3A05%3A39.212Z",
- "swiss": "/template-snapshots/en/swiss.png?v=2026-06-01T05%3A05%3A39.212Z"
+ "classic": "/template-snapshots/en/classic.png?v=2026-06-09T15%3A38%3A50.087Z",
+ "modern": "/template-snapshots/en/modern.png?v=2026-06-09T15%3A38%3A50.087Z",
+ "left-right": "/template-snapshots/en/left-right.png?v=2026-06-09T15%3A38%3A50.087Z",
+ "timeline": "/template-snapshots/en/timeline.png?v=2026-06-09T15%3A38%3A50.087Z",
+ "minimalist": "/template-snapshots/en/minimalist.png?v=2026-06-09T15%3A38%3A50.087Z",
+ "elegant": "/template-snapshots/en/elegant.png?v=2026-06-09T15%3A38%3A50.087Z",
+ "creative": "/template-snapshots/en/creative.png?v=2026-06-09T15%3A38%3A50.087Z",
+ "editorial": "/template-snapshots/en/editorial.png?v=2026-06-09T15%3A38%3A50.087Z",
+ "swiss": "/template-snapshots/en/swiss.png?v=2026-06-09T15%3A38%3A50.087Z"
+ },
+ "ru": {
+ "classic": "/template-snapshots/ru/classic.png?v=2026-06-09T15%3A38%3A50.087Z",
+ "modern": "/template-snapshots/ru/modern.png?v=2026-06-09T15%3A38%3A50.087Z",
+ "left-right": "/template-snapshots/ru/left-right.png?v=2026-06-09T15%3A38%3A50.087Z",
+ "timeline": "/template-snapshots/ru/timeline.png?v=2026-06-09T15%3A38%3A50.087Z",
+ "minimalist": "/template-snapshots/ru/minimalist.png?v=2026-06-09T15%3A38%3A50.087Z",
+ "elegant": "/template-snapshots/ru/elegant.png?v=2026-06-09T15%3A38%3A50.087Z",
+ "creative": "/template-snapshots/ru/creative.png?v=2026-06-09T15%3A38%3A50.087Z",
+ "editorial": "/template-snapshots/ru/editorial.png?v=2026-06-09T15%3A38%3A50.087Z",
+ "swiss": "/template-snapshots/ru/swiss.png?v=2026-06-09T15%3A38%3A50.087Z"
}
}
} as const;
diff --git a/src/i18n/compat/server.ts b/src/i18n/compat/server.ts
index 4c0e5732..ef6938ac 100644
--- a/src/i18n/compat/server.ts
+++ b/src/i18n/compat/server.ts
@@ -1,15 +1,9 @@
import { defaultLocale, Locale } from "@/i18n/config";
-import zhMessages from "@/i18n/locales/zh.json";
-import enMessages from "@/i18n/locales/en.json";
+import { getMessagesForLocale } from "@/i18n/messages";
import { createTranslator } from "./utils";
type Messages = Record;
-const MESSAGES: Record = {
- zh: zhMessages as Messages,
- en: enMessages as Messages
-};
-
let requestLocale: Locale = defaultLocale;
export function setRequestLocale(locale: Locale) {
@@ -21,7 +15,7 @@ export async function getLocale() {
}
export async function getMessages({ locale }: { locale?: Locale } = {}) {
- return MESSAGES[locale ?? requestLocale] ?? MESSAGES[defaultLocale];
+ return getMessagesForLocale(locale ?? requestLocale);
}
export async function getTranslations({
diff --git a/src/i18n/config.ts b/src/i18n/config.ts
index fae98859..a834a129 100644
--- a/src/i18n/config.ts
+++ b/src/i18n/config.ts
@@ -1,4 +1,4 @@
-export const locales = ["zh", "en"] as const;
+export const locales = ["zh", "en", "ru"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "zh";
@@ -6,4 +6,23 @@ export const defaultLocale: Locale = "zh";
export const localeNames: Record = {
zh: "中文",
en: "English",
+ ru: "Русский",
+};
+
+export const localeTags: Record = {
+ zh: "zh_CN",
+ en: "en_US",
+ ru: "ru_RU",
+};
+
+export const heroUiLocales: Record = {
+ zh: "zh-CN",
+ en: "en-US",
+ ru: "ru-RU",
+};
+
+export const importLanguages: Record = {
+ zh: "Chinese",
+ en: "English",
+ ru: "Russian",
};
diff --git a/src/i18n/locale-context.test.tsx b/src/i18n/locale-context.test.tsx
new file mode 100644
index 00000000..66e194ac
--- /dev/null
+++ b/src/i18n/locale-context.test.tsx
@@ -0,0 +1,50 @@
+import { describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { LocaleProvider, useAppLocale, useSetAppLocale } from "./locale-context";
+import { LOCALE_COOKIE_NAME } from "./runtime";
+
+const navigate = vi.fn();
+
+vi.mock("@tanstack/react-router", () => ({
+ useLocation: (opts?: { select?: (loc: { pathname: string }) => string }) => {
+ const location = { pathname: "/app/dashboard" };
+ return opts?.select ? opts.select(location) : location;
+ },
+ useNavigate: () => navigate,
+}));
+
+function LocaleConsumer() {
+ const locale = useAppLocale();
+ const setLocale = useSetAppLocale();
+
+ return (
+
+ {locale}
+
+
+ );
+}
+
+describe("LocaleProvider", () => {
+ it("updates locale on app routes without navigation", async () => {
+ document.cookie = `${LOCALE_COOKIE_NAME}=en; path=/`;
+ const user = userEvent.setup();
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByTestId("locale")).toHaveTextContent("en");
+
+ await user.click(screen.getByRole("button", { name: "Switch to Russian" }));
+
+ expect(screen.getByTestId("locale")).toHaveTextContent("ru");
+ expect(document.cookie).toContain(`${LOCALE_COOKIE_NAME}=ru`);
+ expect(navigate).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/i18n/locale-context.tsx b/src/i18n/locale-context.tsx
new file mode 100644
index 00000000..0418c9de
--- /dev/null
+++ b/src/i18n/locale-context.tsx
@@ -0,0 +1,73 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ type ReactNode,
+} from "react";
+import { useLocation, useNavigate } from "@tanstack/react-router";
+import type { Locale } from "./config";
+import {
+ getLocaleFromPathname,
+ getPreferredLocale,
+ replacePathLocale,
+ setCookieLocale,
+} from "./runtime";
+
+type LocaleContextValue = {
+ locale: Locale;
+ setLocale: (nextLocale: Locale) => void;
+};
+
+const LocaleContext = createContext(null);
+
+export function LocaleProvider({ children }: { children: ReactNode }) {
+ const pathname = useLocation({
+ select: (location) => location.pathname,
+ });
+ const navigate = useNavigate();
+ const [locale, setLocaleState] = useState(() =>
+ getPreferredLocale(pathname)
+ );
+
+ useEffect(() => {
+ setLocaleState(getPreferredLocale(pathname));
+ }, [pathname]);
+
+ const setLocale = useCallback(
+ (nextLocale: Locale) => {
+ setCookieLocale(nextLocale);
+ setLocaleState(nextLocale);
+
+ const currentPathLocale = getLocaleFromPathname(pathname);
+ if (currentPathLocale) {
+ navigate({ to: replacePathLocale(pathname, nextLocale) });
+ }
+ },
+ [navigate, pathname]
+ );
+
+ const value = useMemo(() => ({ locale, setLocale }), [locale, setLocale]);
+
+ return (
+ {children}
+ );
+}
+
+export function useAppLocale(): Locale {
+ const context = useContext(LocaleContext);
+ if (!context) {
+ throw new Error("useAppLocale must be used within LocaleProvider");
+ }
+ return context.locale;
+}
+
+export function useSetAppLocale(): (nextLocale: Locale) => void {
+ const context = useContext(LocaleContext);
+ if (!context) {
+ throw new Error("useSetAppLocale must be used within LocaleProvider");
+ }
+ return context.setLocale;
+}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 27d5afd8..264cbabc 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -13,7 +13,8 @@
"deleteSuccess": "Deleted successfully",
"deleteModuleConfirm": "Are you sure you want to delete this module? This action cannot be undone.",
"configured": "Configured",
- "notConfigured": "Not configured"
+ "notConfigured": "Not configured",
+ "notFound": "Page not found"
},
"home": {
"header": {
@@ -58,6 +59,7 @@
"preview": {
"badge": "Real-time Preview",
"title": "What You See Is What You Get",
+ "description": "Edit and preview in real time with professional templates. Export to PDF anytime and apply with confidence.",
"item1": "Real-time preview of editing effects",
"item2": "Multiple export format support"
}
@@ -401,6 +403,10 @@
"flexibleHeaderLayout": {
"title": "Long Title"
}
+ },
+ "accessibility": {
+ "increase": "Increase",
+ "decrease": "Decrease"
}
},
"basicPanel": {
@@ -409,6 +415,9 @@
"basicField": "Basic",
"customField": "Custom",
"layout": "Align",
+ "layoutLeft": "Align left",
+ "layoutCenter": "Align center",
+ "layoutRight": "Align right",
"customFields": {
"placeholders": {
"label": "Label",
@@ -431,7 +440,17 @@
"show": "Show",
"hide": "Hide"
},
- "githubContributions": "GitHub Contributions"
+ "githubContributions": "GitHub Contributions",
+ "placeholders": {
+ "field": "Enter {label}",
+ "githubToken": "Enter GitHub access token",
+ "githubUsername": "Enter GitHub username"
+ },
+ "align": {
+ "left": "Align left",
+ "center": "Center",
+ "right": "Align right"
+ }
},
"experiencePanel": {
"title": "Work Experience",
@@ -480,12 +499,14 @@
"name": "Personal Project",
"description": "Project Description",
"role": "Responsibilities",
+ "technologies": "Tech Stack",
"date": "2023.01 - 2023.06"
},
"placeholders": {
"name": "Project Name",
"description": "Briefly describe project background and goals",
"role": "Your role and responsibilities in the project",
+ "technologies": "Technologies and tools used",
"date": "Project time range",
"link": "Project link "
}
@@ -578,6 +599,38 @@
"width": "Width (%)",
"delete": "Delete",
"empty": "No images yet. Please upload or paste images."
+ },
+ "skillsPanel": {
+ "placeholder": "Describe your skills, expertise, etc..."
+ },
+ "selfEvaluationPanel": {
+ "placeholder": "Describe your self-evaluation..."
+ },
+ "customPanel": {
+ "add": "Add"
+ },
+ "customItem": {
+ "title": "Title",
+ "titlePlaceholder": "Title",
+ "subtitle": "Subtitle",
+ "subtitlePlaceholder": "Subtitle",
+ "dateRange": "Date Range",
+ "dateRangePlaceholder": "e.g. 2023.01 - 2024.01",
+ "description": "Description",
+ "descriptionPlaceholder": "Enter detailed description...",
+ "unnamedModule": "Unnamed Module"
+ },
+ "fallbacks": {
+ "unnamedProject": "Unnamed Project",
+ "unnamedCompany": "Company Name",
+ "emptySchool": "School not filled",
+ "unnamedResume": "Untitled Resume"
+ },
+ "editorHeader": {
+ "placeholder": "Resume name"
+ },
+ "editPanel": {
+ "focusHint": "Click the text to start editing"
}
},
"field": {
diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json
new file mode 100644
index 00000000..b83a8a08
--- /dev/null
+++ b/src/i18n/locales/ru.json
@@ -0,0 +1,875 @@
+{
+ "common": {
+ "title": "Magic Resume",
+ "subtitle": "Редактор резюме с ИИ",
+ "description": "Magic Resume — бесплатный редактор резюме с открытым исходным кодом, ориентированный на конфиденциальность. Регистрация не требуется, все данные хранятся локально, поддерживается резервное копирование и экспорт.",
+ "dashboard": "Панель управления",
+ "edit": "Редактировать",
+ "delete": "Удалить",
+ "newResume": "Новое резюме",
+ "copy": "Копировать",
+ "cancel": "Отмена",
+ "confirm": "Подтвердить",
+ "deleteSuccess": "Успешно удалено",
+ "deleteModuleConfirm": "Вы уверены, что хотите удалить этот модуль? Это действие нельзя отменить.",
+ "configured": "Настроено",
+ "notConfigured": "Не настроено",
+ "notFound": "Страница не найдена"
+ },
+ "home": {
+ "header": {
+ "title": "Magic Resume",
+ "startButton": "Начать",
+ "features": "Возможности",
+ "pricing": "Цены",
+ "about": "О проекте",
+ "login": "Войти",
+ "register": "Регистрация",
+ "dashboard": "Панель управления"
+ },
+ "hero": {
+ "badge": "Умное создание резюме",
+ "title": "Сделайте создание резюме простым и умным",
+ "subtitle": "Magic Resume использует технологии ИИ, чтобы помочь вам быстро создать профессиональное резюме. Без регистрации, бесплатно, с безопасным хранением данных.",
+ "cta": "Начать",
+ "secondary": "Просмотреть шаблоны"
+ },
+ "features": {
+ "title": "Почему Magic Resume?",
+ "subtitle": "Мы предлагаем комплексное решение для резюме, чтобы сделать поиск работы проще",
+ "ai": {
+ "badge": "ИИ-проверка",
+ "title": "Умное обнаружение, профессиональные советы",
+ "description": "Встроенная проверка грамматики автоматически выявляет неудачные формулировки и предлагает профессиональные правки, делая ваше резюме заметнее.",
+ "item1": "Интеллектуальное улучшение",
+ "item1_description": "ИИ автоматически оптимизирует формулировки, делая резюме более профессиональным",
+ "item2": "Проверка грамматики",
+ "item2_description": "Автоматически обнаруживает и исправляет грамматические и орфографические ошибки"
+ },
+ "storage": {
+ "badge": "Локальное хранение",
+ "title": "Безопасность данных, конфиденциальность прежде всего",
+ "description": "Все данные резюме хранятся локально — не нужно беспокоиться об утечках. Поддерживается экспорт для резервного копирования.",
+ "item1": "Локальное хранение файлов",
+ "item1_description": "Данные резюме надёжно хранятся на жёстком диске вашего компьютера",
+ "item2": "Несколько форматов экспорта",
+ "item2_description": "Поддержка экспорта в PDF и JSON",
+ "item3": "Резервное копирование данных"
+ },
+ "preview": {
+ "badge": "Предпросмотр в реальном времени",
+ "title": "Что видите — то и получаете",
+ "description": "Редактируйте и сразу смотрите результат с профессиональными шаблонами. Экспорт в PDF в любой момент.",
+ "item1": "Предпросмотр изменений в реальном времени",
+ "item2": "Поддержка нескольких форматов экспорта"
+ }
+ },
+ "news": {
+ "label": "Новости",
+ "content": "Запущена новая функция улучшения резюме с ИИ"
+ },
+ "footer": {
+ "copyright": " 2025 Magic Resume. Все права защищены."
+ },
+ "changelog": "История изменений",
+ "cta": {
+ "title": "Начните новую главу карьеры",
+ "description": "Начните использовать Magic Resume прямо сейчас, чтобы создать впечатляющее резюме",
+ "button": "Начать бесплатно"
+ },
+ "faq": {
+ "title": "Часто задаваемые вопросы",
+ "items": [
+ {
+ "question": "Magic Resume бесплатен?",
+ "answer": "Magic Resume сейчас бесплатен и покрывает базовые потребности в создании резюме. Функции open-source версии останутся без изменений."
+ },
+ {
+ "question": "Мои данные резюме в безопасности?",
+ "answer": "Да, полностью. Magic Resume использует локальное хранение — все данные хранятся на вашем устройстве, без облака, что обеспечивает полную защиту конфиденциальности."
+ },
+ {
+ "question": "Какие форматы экспорта поддерживаются?",
+ "answer": "Сейчас поддерживается экспорт в PDF с сохранением форматирования на любом устройстве. В будущем планируется поддержка других форматов."
+ },
+ {
+ "question": "Как синхронизировать между устройствами?",
+ "answer": "Мы предоставляем экспорт JSON-конфигурации, позволяющий сохранить настройки резюме и открыть их на любом устройстве."
+ },
+ {
+ "question": "Насколько гибкая настройка?",
+ "answer": "Мы предлагаем широкие возможности настройки: цвета, макеты и многое другое — под ваши предпочтения и требования отрасли."
+ }
+ ]
+ }
+ },
+ "dashboard": {
+ "sidebar": {
+ "appName": "Magic Resume",
+ "resumes": "Резюме",
+ "settings": "Настройки",
+ "templates": "Шаблоны",
+ "ai": "Настройки ИИ"
+ },
+ "resumes": {
+ "created": "Создано",
+ "synced": "Синхронизированные файлы",
+ "view": "Просмотр",
+ "myResume": "Мои резюме",
+ "create": "Создать резюме",
+ "newResume": "Новое резюме",
+ "newResumeDescription": "Создайте новое резюме, чтобы начать.",
+ "import": "Импорт резюме",
+ "untitled": "Резюме без названия",
+ "importSuccess": "Конфигурация успешно импортирована",
+ "importError": "Ошибка импорта, проверьте формат файла",
+ "importDialog": {
+ "title": "Импорт резюме",
+ "jsonTitle": "Импорт JSON",
+ "jsonDescription": "Импорт экспортированного файла конфигурации резюме (.json)",
+ "pdfTitle": "Импорт PDF",
+ "pdfDescription": "Использовать Gemini для преобразования в структурированные данные резюме",
+ "importing": "Импорт, пожалуйста подождите...",
+ "geminiConfigRequired": "Сначала настройте Gemini API Key и Model ID в настройках ИИ",
+ "pdfSuccess": "PDF успешно импортирован",
+ "pdfError": "Ошибка импорта PDF. Проверьте содержимое PDF или настройки Gemini"
+ },
+ "notice": {
+ "title": "Внимание",
+ "description": "Рекомендуем настроить папку резервного копирования резюме в настройках, чтобы данные не потерялись при очистке кэша браузера",
+ "goToSettings": "Перейти в настройки"
+ },
+ "deleteConfirmTitle": "Подтвердить удаление резюме?",
+ "deleteConfirmDescription": "Это действие нельзя отменить. Резюме будет безвозвратно удалено с вашего устройства.",
+ "createDialog": {
+ "title": "Создать новое резюме",
+ "description": "Начните с пустого шаблона",
+ "tabs": {
+ "fromTemplate": "Из шаблона",
+ "uploadFile": "Загрузить файл"
+ },
+ "namePlaceholder": "Название",
+ "switchTemplate": "Сменить шаблон",
+ "cancel": "Отмена",
+ "create": "Создать",
+ "blankTitle": "Пустое резюме",
+ "startFromBlank": "Начать с пустого",
+ "startFromTemplate": "Начать с шаблона",
+ "blankCardDescription": "Используйте встроенный макет по умолчанию и начните с нуля.",
+ "blankThumbnailDescription": "Начните с чистой страницы и создайте резюме с полным контролем.",
+ "createNow": "Создать сейчас",
+ "backToGrid": "Вернуться к сетке шаблонов",
+ "blankPreviewDescription": "Выберите пустую страницу и создайте всё с нуля без ограничений.",
+ "useThisTemplate": "Использовать этот шаблон",
+ "sample": {
+ "company": "Google",
+ "position": "Старший инженер-программист",
+ "present": "Настоящее время",
+ "workDescription": "Руководил фронтенд-командой и улучшил производительность страниц на 40%."
+ }
+ }
+ },
+ "settings": {
+ "title": "Настройки",
+ "syncDirectory": {
+ "title": "Папка синхронизации",
+ "description": "Выберите папку для синхронизации и резервного копирования резюме.",
+ "currentSyncFolder": "Текущая папка синхронизации",
+ "noFolderConfigured": "Папка не настроена",
+ "changeFolder": "Сменить папку",
+ "selectFolder": "Выбрать папку"
+ },
+ "sync": {
+ "title": "Папка синхронизации",
+ "description": "Выберите папку для синхронизации и резервного копирования резюме.",
+ "select": "Выбрать папку"
+ },
+ "ai": {
+ "title": "Настройки ИИ",
+ "currentModel": "Текущая модель",
+ "selectModel": "Выбрать модель",
+ "getApiKey": "Получить API-ключ",
+ "doubao": {
+ "title": "Doubao",
+ "description": "Получите API-ключ на Volcengine",
+ "apiKey": "Doubao API Key",
+ "modelId": "ID модели"
+ },
+ "deepseek": {
+ "title": "DeepSeek",
+ "description": "Получите API-ключ на платформе DeepSeek",
+ "apiKey": "DeepSeek API Key"
+ },
+ "openai": {
+ "title": "OpenAI",
+ "description": "Получите API-ключ на OpenAI или совместимой платформе",
+ "apiKey": "OpenAI API Key",
+ "modelId": "ID модели",
+ "apiEndpoint": "API Endpoint, например: https://openai.example.org/v1"
+ },
+ "gemini": {
+ "title": "Gemini",
+ "description": "Поддерживает улучшение, проверку грамматики и импорт PDF. Рекомендуемая модель: gemini-flash-latest",
+ "apiKey": "Gemini API Key",
+ "modelId": "Gemini Model ID"
+ }
+ }
+ },
+ "templates": {
+ "title": "Шаблоны",
+ "useTemplate": "Использовать шаблон",
+ "preview": "Предпросмотр",
+ "switchTemplate": "Сменить шаблон",
+ "classic": {
+ "name": "Классический",
+ "description": "Традиционный минималистичный макет, подходит для большинства вакансий"
+ },
+ "modern": {
+ "name": "Две колонки",
+ "description": "Классический двухколоночный макет, подчёркивающий личные качества"
+ },
+ "leftRight": {
+ "name": "Заголовки с фоном",
+ "description": "Выразительные заголовки разделов с цветным фоном"
+ },
+ "timeline": {
+ "name": "Временная шкала",
+ "description": "Стиль временной шкалы, акцент на хронологии опыта"
+ },
+ "minimalist": {
+ "name": "Минимализм",
+ "description": "Много свободного пространства, чистый и лаконичный стиль"
+ },
+ "elegant": {
+ "name": "Элегантный",
+ "description": "Одноколоночный дизайн с центрированным заголовком и ноткой элегантности"
+ },
+ "creative": {
+ "name": "Креативный",
+ "description": "Визуальный контраст, яркий и индивидуальный дизайн"
+ },
+ "editorial": {
+ "name": "Редакционный",
+ "description": "Роскошное сочетание жирного шрифта с засечками и утончённого sans-serif"
+ },
+ "swiss": {
+ "name": "Швейцарский стиль",
+ "description": "Художественный макет в стиле Баухаус с сильной типографической иерархией и геометрическими акцентами"
+ }
+ }
+ },
+ "pdfExport": {
+ "modal": {
+ "title": "Экспорт резюме",
+ "subtitle": "Выберите формат экспорта резюме",
+ "pdfDesc": "Высокоточный рендеринг с 100% точностью форматирования. Рекомендуется для откликов на вакансии.",
+ "printDesc": "Сохранение через системный диалог печати. Запасной вариант или для настройки полей.",
+ "jsonDesc": "Экспорт данных резюме в JSON для резервного копирования или переноса между устройствами.",
+ "markdownDesc": "Конвертация в Markdown для удобного обмена с ИИ-моделями.",
+ "privacyNotice": "Magic Resume никогда не собирает, не загружает и не хранит ваши данные резюме. Ваша конфиденциальность полностью защищена."
+ },
+ "button": {
+ "export": "Экспорт",
+ "exportPdf": "Экспорт PDF (сервер)",
+ "exportJson": "Экспорт JSON",
+ "exportMarkdown": "Экспорт Markdown",
+ "exporting": "Экспорт...",
+ "exportingJson": "Экспорт...",
+ "exportingMarkdown": "Экспорт...",
+ "print": "Печать в браузере"
+ },
+ "toast": {
+ "success": "PDF успешно экспортирован",
+ "error": "Ошибка экспорта PDF",
+ "jsonSuccess": "Конфигурация успешно экспортирована",
+ "jsonError": "Ошибка экспорта конфигурации",
+ "markdownSuccess": "Markdown успешно экспортирован",
+ "markdownError": "Ошибка экспорта Markdown"
+ }
+ },
+ "previewDock": {
+ "switchTemplate": "Сменить шаблон",
+ "grammarCheck": {
+ "idle": "ИИ-проверка грамматики",
+ "checking": "Проверка...",
+ "configurePrompt": "Настройте модель ИИ",
+ "configureButton": "Настроить",
+ "errorToast": "Ошибка проверки грамматики, попробуйте снова"
+ },
+ "sidePanel": {
+ "expand": "Развернуть боковую панель",
+ "collapse": "Свернуть боковую панель"
+ },
+ "editPanel": {
+ "expand": "Развернуть панель редактирования",
+ "collapse": "Свернуть панель редактирования"
+ },
+ "previewPanel": {
+ "expand": "Развернуть панель предпросмотра",
+ "collapse": "Свернуть панель предпросмотра"
+ },
+ "github": "GitHub",
+ "backToDashboard": "Вернуться на панель",
+ "copyResume": {
+ "tooltip": "Копировать резюме",
+ "success": "Резюме успешно скопировано",
+ "error": "Не удалось скопировать резюме"
+ },
+ "export": {
+ "tooltip": "Экспорт резюме",
+ "pdf": "Экспорт PDF",
+ "json": "Экспорт JSON",
+ "markdown": "Экспорт Markdown",
+ "print": "Печать"
+ },
+ "autoOnePage": {
+ "tooltip": "Автоподгонка на одну страницу",
+ "enabled": "Режим одной страницы включён",
+ "disabled": "Режим одной страницы выключен",
+ "cannotFit": "Слишком много контента. Оптимизировано по максимуму, но не помещается на одну страницу. Попробуйте упростить содержание или настроить поля и размер шрифта в боковой панели."
+ },
+ "backup": {
+ "configured": "Резервная копия настроена",
+ "notConfigured": "Резервная копия не настроена",
+ "clickToConfigure": "Нажмите для настройки"
+ }
+ },
+ "workbench": {
+ "sidePanel": {
+ "layout": {
+ "title": "Макет",
+ "addCustomSection": "Добавить модуль",
+ "addCustomSectionOption": "Добавить пользовательский раздел",
+ "standardSections": {
+ "skills": "Навыки",
+ "experience": "Опыт",
+ "projects": "Проекты",
+ "education": "Образование",
+ "selfEvaluation": "Самооценка",
+ "certificates": "Сертификаты"
+ }
+ },
+ "theme": {
+ "title": "Цвет темы",
+ "custom": "Свой"
+ },
+ "typography": {
+ "title": "Типографика",
+ "font": {
+ "title": "Шрифт",
+ "alibaba": "Alibaba PuHuiTi",
+ "misans": "MiSans",
+ "notosanssc": "Noto Sans SC",
+ "sourcehanserifsc": "Source Han Serif SC",
+ "note": "MiSans используется по бесплатной коммерческой лицензии. Сохраняйте атрибуцию MiSans при распространении проекта."
+ },
+ "lineHeight": {
+ "title": "Межстрочный интервал",
+ "normal": "По умолчанию",
+ "relaxed": "Свободный",
+ "loose": "Широкий"
+ },
+ "baseFontSize": {
+ "title": "Базовый размер шрифта"
+ },
+ "headerSize": {
+ "title": "Размер заголовка раздела"
+ },
+ "subheaderSize": {
+ "title": "Размер подзаголовка"
+ }
+ },
+ "spacing": {
+ "title": "Отступы",
+ "pagePadding": {
+ "title": "Поля страницы"
+ },
+ "sectionSpacing": {
+ "title": "Отступ между разделами"
+ },
+ "paragraphSpacing": {
+ "title": "Отступ между абзацами"
+ }
+ },
+ "mode": {
+ "title": "Режим",
+ "useIconMode": {
+ "title": "Режим иконок"
+ },
+ "centerSubtitle": {
+ "title": "Центрировать подзаголовок"
+ },
+ "flexibleHeaderLayout": {
+ "title": "Длинный заголовок"
+ }
+ },
+ "accessibility": {
+ "increase": "Увеличить",
+ "decrease": "Уменьшить"
+ }
+ },
+ "basicPanel": {
+ "title": "Профиль",
+ "avatar": "Аватар",
+ "basicField": "Основное",
+ "customField": "Дополнительно",
+ "layout": "Выравнивание",
+ "layoutLeft": "По левому краю",
+ "layoutCenter": "По центру",
+ "layoutRight": "По правому краю",
+ "customFields": {
+ "placeholders": {
+ "label": "Метка",
+ "value": "Значение"
+ },
+ "displayLabel": "Показывать метку",
+ "addButton": "Добавить поле"
+ },
+ "basicFields": {
+ "name": "Имя",
+ "title": "Должность",
+ "email": "Email",
+ "phone": "Телефон",
+ "website": "Сайт",
+ "location": "Местоположение",
+ "birthDate": "Дата рождения",
+ "employementStatus": "Занятость"
+ },
+ "fieldVisibility": {
+ "show": "Показать",
+ "hide": "Скрыть"
+ },
+ "githubContributions": "Вклад на GitHub",
+ "placeholders": {
+ "field": "Введите {label}",
+ "githubToken": "Введите GitHub access token",
+ "githubUsername": "Введите имя пользователя GitHub"
+ },
+ "align": {
+ "left": "По левому краю",
+ "center": "По центру",
+ "right": "По правому краю"
+ }
+ },
+ "experiencePanel": {
+ "title": "Опыт работы",
+ "addButton": "Добавить опыт работы",
+ "defaultProject": {
+ "company": "ООО Технологии",
+ "position": "Старший фронтенд-инженер",
+ "date": "2020 — настоящее время",
+ "details": "Отвечал за разработку основных продуктов..."
+ },
+ "placeholders": {
+ "company": "название компании",
+ "position": "должность",
+ "date": "период работы",
+ "details": "обязанности и достижения"
+ }
+ },
+ "experienceItem": {
+ "labels": {
+ "company": "Название компании",
+ "position": "Должность",
+ "date": "Период работы",
+ "details": "Обязанности"
+ },
+ "placeholders": {
+ "company": "Введите название компании",
+ "position": "например, фронтенд-инженер",
+ "date": "например, 2020 — настоящее время",
+ "details": "Опишите обязанности и достижения на этой должности"
+ },
+ "buttons": {
+ "edit": "Редактировать",
+ "save": "Сохранить",
+ "cancel": "Отмена",
+ "delete": "Удалить"
+ },
+ "visibility": {
+ "show": "Показать",
+ "hide": "Скрыть"
+ }
+ },
+ "projectPanel": {
+ "title": "Проекты",
+ "addButton": "Добавить проект",
+ "defaultProject": {
+ "name": "Личный проект",
+ "description": "Описание проекта",
+ "role": "Обязанности",
+ "technologies": "Технологии",
+ "date": "2023.01 — 2023.06"
+ },
+ "placeholders": {
+ "name": "Название проекта",
+ "description": "Кратко опишите контекст и цели проекта",
+ "role": "Ваша роль и обязанности в проекте",
+ "technologies": "Используемые технологии и инструменты",
+ "date": "Период проекта",
+ "link": "Ссылка на проект"
+ }
+ },
+ "projectItem": {
+ "labels": {
+ "name": "Название проекта",
+ "role": "Роль в проекте",
+ "date": "Период проекта",
+ "description": "Описание проекта",
+ "technologies": "Технологии",
+ "link": "Ссылка на проект",
+ "linkLabel": "Текст ссылки"
+ },
+ "placeholders": {
+ "name": "Введите название проекта",
+ "role": "Ваша роль в проекте",
+ "date": "Период проекта",
+ "description": "Кратко опишите контекст и цели проекта",
+ "technologies": "Используемые технологии и инструменты",
+ "link": "Ссылка на проект",
+ "linkLabel": "Текст ссылки"
+ },
+ "hints": {
+ "linkLabel": "Если текст ссылки пуст, автоматически отобразится домен или полный URL. Поддерживаются только ссылки http:// и https://."
+ },
+ "buttons": {
+ "edit": "Редактировать",
+ "save": "Сохранить",
+ "cancel": "Отмена",
+ "delete": "Удалить"
+ },
+ "visibility": {
+ "show": "Показать",
+ "hide": "Скрыть"
+ }
+ },
+ "educationPanel": {
+ "title": "Образование",
+ "addButton": "Добавить образование",
+ "defaultProject": {
+ "school": "Название учебного заведения",
+ "degree": "Степень",
+ "major": "Специальность",
+ "date": "2020.09 — 2024.06"
+ },
+ "placeholders": {
+ "school": "Введите название учебного заведения",
+ "degree": "Выберите степень",
+ "major": "Введите специальность",
+ "date": "Введите период обучения"
+ }
+ },
+ "educationItem": {
+ "labels": {
+ "school": "Учебное заведение",
+ "degree": "Степень",
+ "major": "Специальность",
+ "date": "Период обучения",
+ "description": "Описание",
+ "gpa": "Средний балл",
+ "location": "Местоположение",
+ "startDate": "Дата начала",
+ "endDate": "Дата окончания"
+ },
+ "placeholders": {
+ "school": "Введите название учебного заведения",
+ "degree": "Выберите степень",
+ "major": "Введите специальность",
+ "date": "Введите период обучения",
+ "description": "Введите описание",
+ "gpa": "Введите средний балл",
+ "location": "Введите местоположение"
+ },
+ "buttons": {
+ "edit": "Редактировать",
+ "save": "Сохранить",
+ "cancel": "Отмена",
+ "delete": "Удалить"
+ },
+ "visibility": {
+ "show": "Показать",
+ "hide": "Скрыть"
+ }
+ },
+ "certificatesPanel": {
+ "title": "Сертификаты",
+ "addButton": "Добавить сертификат",
+ "tips": "Загрузите или вставьте (Cmd/Ctrl + V) изображения. Настройте ширину для горизонтального расположения.",
+ "width": "Ширина (%)",
+ "delete": "Удалить",
+ "empty": "Изображений пока нет. Загрузите или вставьте изображения."
+ },
+ "skillsPanel": {
+ "placeholder": "Опишите ваши навыки, специализацию и т.д..."
+ },
+ "selfEvaluationPanel": {
+ "placeholder": "Опишите вашу самооценку..."
+ },
+ "customPanel": {
+ "add": "Добавить"
+ },
+ "customItem": {
+ "title": "Заголовок",
+ "titlePlaceholder": "Заголовок",
+ "subtitle": "Подзаголовок",
+ "subtitlePlaceholder": "Подзаголовок",
+ "dateRange": "Период",
+ "dateRangePlaceholder": "например: 2023.01 - 2024.01",
+ "description": "Описание",
+ "descriptionPlaceholder": "Введите подробное описание...",
+ "unnamedModule": "Безымянный модуль"
+ },
+ "fallbacks": {
+ "unnamedProject": "Безымянный проект",
+ "unnamedCompany": "Название компании",
+ "emptySchool": "Учебное заведение не указано",
+ "unnamedResume": "Резюме без названия"
+ },
+ "editorHeader": {
+ "placeholder": "Название резюме"
+ },
+ "editPanel": {
+ "focusHint": "Нажмите на текст, чтобы начать редактирование"
+ }
+ },
+ "field": {
+ "selectDate": "Выбрать дату",
+ "enterYear": "Введите год",
+ "toPresent": "По настоящее время"
+ },
+ "richEditor": {
+ "bold": "Жирный",
+ "italic": "Курсив",
+ "underline": "Подчёркнутый",
+ "link": "Ссылка",
+ "linkPlaceholder": "Введите URL, например https://example.com",
+ "linkApply": "Применить",
+ "linkRemove": "Удалить",
+ "linkInvalid": "Введите корректный URL",
+ "textColor": "Цвет текста",
+ "backgroundColor": "Цвет фона",
+ "alignLeft": "По левому краю",
+ "alignCenter": "По центру",
+ "alignRight": "По правому краю",
+ "alignJustify": "По ширине",
+ "bulletList": "Маркированный список",
+ "orderedList": "Нумерованный список",
+ "undo": "Отменить",
+ "redo": "Повторить",
+ "aiPolish": "ИИ-улучшение",
+ "paragraph": "Абзац",
+ "heading1": "Заголовок 1",
+ "heading2": "Заголовок 2",
+ "heading3": "Заголовок 3",
+ "colors": {
+ "black": "Чёрный",
+ "darkGray": "Тёмно-серый",
+ "gray": "Серый",
+ "red": "Красный",
+ "orange": "Оранжевый",
+ "orangeYellow": "Оранжево-жёлтый",
+ "yellow": "Жёлтый",
+ "yellowGreen": "Жёлто-зелёный",
+ "green": "Зелёный",
+ "cyan": "Голубой",
+ "lightBlue": "Светло-синий",
+ "blue": "Синий",
+ "purple": "Фиолетовый",
+ "magenta": "Пурпурный",
+ "pink": "Розовый"
+ }
+ },
+ "iconSelector": {
+ "all": "Все",
+ "searchPlaceholder": "Поиск иконок...",
+ "noMatchingIcons": "Иконки не найдены",
+ "tryOtherKeywords": "Попробуйте другие ключевые слова",
+ "selectOtherCategory": "Выберите другую категорию",
+ "categories": {
+ "personal": "Личная информация",
+ "education": "Образование",
+ "experience": "Опыт работы",
+ "skills": "Навыки",
+ "languages": "Языки",
+ "projects": "Проекты",
+ "achievements": "Достижения",
+ "hobbies": "Хобби",
+ "social": "Социальные сети",
+ "others": "Другое"
+ },
+ "icons": {
+ "user": "Пользователь",
+ "email": "Email",
+ "phone": "Телефон",
+ "address": "Адрес",
+ "website": "Сайт",
+ "mobile": "Мобильный",
+ "education": "Образование",
+ "school": "Учебное заведение",
+ "major": "Специальность",
+ "library": "Библиотека",
+ "scholarship": "Стипендия",
+ "work": "Работа",
+ "company": "Компания",
+ "office": "Офис",
+ "dateRange": "Период",
+ "workTime": "Время работы",
+ "programming": "Программирование",
+ "system": "Система",
+ "database": "База данных",
+ "terminal": "Терминал",
+ "techStack": "Технологический стек",
+ "language": "Язык",
+ "speaking": "Разговорная речь",
+ "communication": "Коммуникация",
+ "project": "Проект",
+ "branch": "Ветка",
+ "release": "Релиз",
+ "target": "Цель",
+ "trophy": "Трофей",
+ "medal": "Медаль",
+ "star": "Звезда",
+ "interest": "Интерес",
+ "music": "Музыка",
+ "art": "Искусство",
+ "photography": "Фотография",
+ "linkedin": "LinkedIn",
+ "twitter": "Twitter",
+ "facebook": "Facebook",
+ "instagram": "Instagram",
+ "profile": "Профиль",
+ "review": "Обзор",
+ "filter": "Фильтр",
+ "link": "Ссылка",
+ "salary": "Зарплата",
+ "idea": "Идея",
+ "send": "Отправить",
+ "share": "Поделиться",
+ "settings": "Настройки",
+ "search": "Поиск",
+ "flag": "Флаг",
+ "bookmark": "Закладка",
+ "thumbsUp": "Нравится",
+ "skill": "Навык"
+ }
+ },
+ "aiPolishDialog": {
+ "title": "ИИ-улучшение",
+ "description": {
+ "ready": "Добавьте инструкции (необязательно) и нажмите «Начать улучшение»",
+ "polishing": "Улучшаем ваш текст...",
+ "finished": "Текст оптимизирован, проверьте результат"
+ },
+ "error": {
+ "configRequired": "Сначала настройте модель ИИ",
+ "polishFailed": "Ошибка улучшения",
+ "applied": "Улучшенный текст применён"
+ },
+ "content": {
+ "original": "Исходный текст",
+ "polished": "Улучшенный текст"
+ },
+ "customInstructions": "Дополнительные инструкции",
+ "customInstructionsPlaceholder": "например: используйте более технические термины, выделите метрики, сохраните формальный тон… (необязательно)",
+ "button": {
+ "start": "Начать улучшение",
+ "regenerate": "Перегенерировать",
+ "generating": "Генерация...",
+ "apply": "Применить текст"
+ }
+ },
+ "photoConfig": {
+ "title": "Настройки фото",
+ "description": "Настройте фото для резюме",
+ "upload": {
+ "title": "Ссылка",
+ "dragHint": "Перетащите или нажмите для загрузки",
+ "sizeLimit": "Размер изображения не должен превышать 2 МБ",
+ "typeLimit": "Загрузите файл изображения",
+ "urlPlaceholder": "Введите ссылку на изображение",
+ "invalidUrl": "Некорректная ссылка или недоступное изображение",
+ "timeout": "Превышено время загрузки",
+ "loadError": "Не удалось загрузить изображение"
+ },
+ "config": {
+ "aspectRatio": "Соотношение сторон",
+ "size": "Размер",
+ "width": "Ширина",
+ "height": "Высота",
+ "border-radius": "Скругление углов",
+ "widthPlaceholder": "Ширина",
+ "heightPlaceholder": "Высота",
+ "ratios": {
+ "1:1": "1:1 Квадрат",
+ "4:3": "4:3 Альбомная",
+ "3:4": "3:4 Портретная",
+ "16:9": "16:9 Широкоэкранная",
+ "custom": "Свой"
+ },
+ "borderRadius": {
+ "none": "Нет",
+ "medium": "Среднее",
+ "full": "Круг",
+ "custom": "Свой",
+ "customPlaceholder": "Своё скругление"
+ }
+ },
+ "actions": {
+ "reset": "Сбросить",
+ "close": "Закрыть",
+ "cancel": "Отмена",
+ "removePhoto": "Удалить фото"
+ }
+ },
+ "templates": {
+ "switchTemplate": "Сменить шаблон"
+ },
+ "themeModal": {
+ "delete": {
+ "title": "Подтвердить удаление",
+ "description": "Вы уверены, что хотите удалить {title}?",
+ "confirmText": "Удалить",
+ "cancelText": "Отмена"
+ }
+ },
+ "grammarCheck": {
+ "title": "ИИ-проверка грамматики",
+ "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": "Грамматических и пунктуационных ошибок не найдено."
+ },
+ "faqDialog": {
+ "title": "Часто задаваемые вопросы (FAQ)",
+ "description": "Советы и решения для рабочей области Magic Resume.",
+ "items": {
+ "browser-compatibility": {
+ "question": "Какой браузер рекомендуется?",
+ "answer": "Рекомендуем последнюю версию Chrome или Edge для лучшего макета и экспорта. Устаревшие браузеры могут вызывать проблемы со стилями."
+ },
+ "export-failure": {
+ "question": "Что делать, если экспорт не работает или стили сломаны?",
+ "answer": "1. Рекомендуем Google Chrome (https://www.google.com/chrome/) для лучшей совместимости.\n2. Если не помогает, попробуйте режим инкогнито, чтобы исключить влияние расширений.\n3. Или нажмите «PDF (резерв)» в меню экспорта и выберите «Сохранить как PDF» в диалоге печати."
+ },
+ "export-methods": {
+ "question": "В чём разница между двумя способами экспорта?",
+ "answer": "• Экспорт PDF: использует серверный рендеринг с высокой точностью, 100% воспроизведение макета. Рекомендуется для отправки HR.\n\n• Печать в браузере: вызывает встроенную функцию печати браузера (Сохранить как PDF). Подходит, когда серверный экспорт недоступен или нужна тонкая настройка полей."
+ },
+ "drag-and-drop": {
+ "question": "Как перетаскивать модули для изменения порядка?",
+ "answer": "В настройках «Макет» левой панели редактирования наведите курсор на «ручку перетаскивания» (иконка с шестью точками) слева от карточки модуля. Зажмите левую кнопку мыши и перетащите модуль вверх или вниз для изменения порядка разделов резюме."
+ }
+ }
+ }
+}
diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json
index 282ac713..ec570ec9 100644
--- a/src/i18n/locales/zh.json
+++ b/src/i18n/locales/zh.json
@@ -13,7 +13,8 @@
"deleteSuccess": "删除成功",
"deleteModuleConfirm": "确定要删除此模块吗?此操作无法撤销。",
"configured": "已配置",
- "notConfigured": "未配置"
+ "notConfigured": "未配置",
+ "notFound": "页面不存在"
},
"home": {
"header": {
@@ -355,6 +356,10 @@
"flexibleHeaderLayout": {
"title": "长标题模式"
}
+ },
+ "accessibility": {
+ "increase": "增加",
+ "decrease": "减少"
}
},
"basicPanel": {
@@ -388,6 +393,16 @@
"fieldVisibility": {
"show": "显示",
"hide": "隐藏"
+ },
+ "placeholders": {
+ "field": "请输入{label}",
+ "githubToken": "请输入 GitHub access token",
+ "githubUsername": "请输入 GitHub 用户名"
+ },
+ "align": {
+ "left": "居左",
+ "center": "居中",
+ "right": "居右"
}
},
"experiencePanel": {
@@ -456,7 +471,8 @@
"date": "项目时间",
"description": "项目描述",
"link": "项目链接",
- "linkLabel": "显示文字"
+ "linkLabel": "显示文字",
+ "technologies": "技术栈"
},
"placeholders": {
"name": "请输入项目名称",
@@ -464,7 +480,8 @@
"date": "项目时间范围",
"description": "简要描述项目背景和目标",
"link": "项目链接",
- "linkLabel": "显示文字"
+ "linkLabel": "显示文字",
+ "technologies": "使用的技术和工具"
},
"hints": {
"linkLabel": "显示文字留空时,将自动显示域名或完整链接。链接仅支持 http:// 或 https:// 链接。"
@@ -504,6 +521,7 @@
"date": "就读时间",
"description": "学校简介",
"gpa": "GPA",
+ "location": "地址",
"startDate": "开始时间",
"endDate": "结束时间"
},
@@ -513,7 +531,8 @@
"major": "请输入专业名称",
"date": "请输入就读时间范围",
"description": "请输入学校简介",
- "gpa": "请输入GPA"
+ "gpa": "请输入GPA",
+ "location": "请输入地址"
},
"buttons": {
"edit": "编辑",
@@ -533,6 +552,38 @@
"width": "宽度 (横向拼接占比)",
"delete": "删除",
"empty": "暂无图片,请上传或粘贴图片"
+ },
+ "skillsPanel": {
+ "placeholder": "描述你的技能、专长等..."
+ },
+ "selfEvaluationPanel": {
+ "placeholder": "描述你的自我评价..."
+ },
+ "customPanel": {
+ "add": "添加"
+ },
+ "customItem": {
+ "title": "标题",
+ "titlePlaceholder": "标题",
+ "subtitle": "副标题",
+ "subtitlePlaceholder": "副标题",
+ "dateRange": "时间范围",
+ "dateRangePlaceholder": "例如: 2023.01 - 2024.01",
+ "description": "详细描述",
+ "descriptionPlaceholder": "请输入详细描述...",
+ "unnamedModule": "未命名模块"
+ },
+ "fallbacks": {
+ "unnamedProject": "未命名项目",
+ "unnamedCompany": "家里蹲公司",
+ "emptySchool": "未填写学校",
+ "unnamedResume": "未命名简历"
+ },
+ "editorHeader": {
+ "placeholder": "简历名称"
+ },
+ "editPanel": {
+ "focusHint": "点击文字部分即可聚焦编辑"
}
},
"field": {
diff --git a/src/i18n/messages.test.ts b/src/i18n/messages.test.ts
new file mode 100644
index 00000000..0c9750f1
--- /dev/null
+++ b/src/i18n/messages.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from "vitest";
+import { getMessagesForLocale } from "./messages";
+
+describe("getMessagesForLocale", () => {
+ it("returns Russian messages for ru locale", () => {
+ const messages = getMessagesForLocale("ru") as {
+ common: { title: string };
+ };
+
+ expect(messages.common.title).toBeTruthy();
+ expect(messages.common.title).not.toBe(
+ (getMessagesForLocale("zh") as { common: { title: string } }).common.title
+ );
+ });
+
+ it("falls back to default locale for unknown locale", () => {
+ const fallback = getMessagesForLocale("zh");
+ const unknown = getMessagesForLocale("xx" as "zh");
+
+ expect(unknown).toBe(fallback);
+ });
+});
diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts
new file mode 100644
index 00000000..93da9e72
--- /dev/null
+++ b/src/i18n/messages.ts
@@ -0,0 +1,17 @@
+// Static imports for all locales. TODO: lazy-load per locale when locale count grows.
+import { defaultLocale, type Locale } from "./config";
+import zhMessages from "./locales/zh.json";
+import enMessages from "./locales/en.json";
+import ruMessages from "./locales/ru.json";
+
+type Messages = Record;
+
+export const messagesByLocale: Record = {
+ zh: zhMessages as Messages,
+ en: enMessages as Messages,
+ ru: ruMessages as Messages,
+};
+
+export function getMessagesForLocale(locale: Locale): Messages {
+ return messagesByLocale[locale] ?? messagesByLocale[defaultLocale];
+}
diff --git a/src/i18n/runtime.test.ts b/src/i18n/runtime.test.ts
new file mode 100644
index 00000000..27bc683d
--- /dev/null
+++ b/src/i18n/runtime.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it, beforeEach } from "vitest";
+import {
+ getCookieLocale,
+ getPreferredLocale,
+ isSupportedLocale,
+ LOCALE_COOKIE_NAME,
+ parseCookieLocale,
+ setCookieLocale,
+} from "./runtime";
+
+describe("isSupportedLocale", () => {
+ it("accepts supported locales", () => {
+ expect(isSupportedLocale("zh")).toBe(true);
+ expect(isSupportedLocale("en")).toBe(true);
+ expect(isSupportedLocale("ru")).toBe(true);
+ });
+
+ it("rejects unknown locales", () => {
+ expect(isSupportedLocale("de")).toBe(false);
+ expect(isSupportedLocale("")).toBe(false);
+ });
+});
+
+describe("cookie locale helpers", () => {
+ beforeEach(() => {
+ document.cookie = `${LOCALE_COOKIE_NAME}=; path=/; max-age=0`;
+ });
+
+ it("reads valid cookie locale", () => {
+ document.cookie = `${LOCALE_COOKIE_NAME}=ru; path=/`;
+
+ expect(parseCookieLocale(document.cookie)).toBe("ru");
+ expect(getCookieLocale()).toBe("ru");
+ });
+
+ it("ignores invalid cookie locale", () => {
+ document.cookie = `${LOCALE_COOKIE_NAME}=invalid; path=/`;
+
+ expect(parseCookieLocale(document.cookie)).toBeNull();
+ expect(getCookieLocale()).toBe("zh");
+ });
+
+ it("writes cookie locale", () => {
+ setCookieLocale("en");
+
+ expect(getCookieLocale()).toBe("en");
+ });
+});
+
+describe("getPreferredLocale", () => {
+ beforeEach(() => {
+ document.cookie = `${LOCALE_COOKIE_NAME}=; path=/; max-age=0`;
+ });
+
+ it("prefers locale from pathname", () => {
+ expect(getPreferredLocale("/ru")).toBe("ru");
+ expect(getPreferredLocale("/en/about")).toBe("en");
+ });
+
+ it("uses cookie locale on app routes", () => {
+ document.cookie = `${LOCALE_COOKIE_NAME}=ru; path=/`;
+
+ expect(getPreferredLocale("/app/dashboard")).toBe("ru");
+ });
+});
diff --git a/src/i18n/runtime.ts b/src/i18n/runtime.ts
index 7251f2df..1bdf4f0c 100644
--- a/src/i18n/runtime.ts
+++ b/src/i18n/runtime.ts
@@ -2,6 +2,9 @@ import { defaultLocale, locales, Locale } from "./config";
const localeSet = new Set(locales);
+export const LOCALE_COOKIE_NAME = "NEXT_LOCALE";
+const LOCALE_COOKIE_MAX_AGE = 31536000;
+
export function isSupportedLocale(value: string): value is Locale {
return localeSet.has(value as Locale);
}
@@ -14,6 +17,35 @@ export function getLocaleFromPathname(pathname: string): Locale | null {
return isSupportedLocale(firstSegment) ? firstSegment : null;
}
+export function parseCookieLocale(cookieHeader?: string): Locale | null {
+ if (!cookieHeader) {
+ return null;
+ }
+
+ const cookieLocale = cookieHeader
+ .split("; ")
+ .find((row) => row.startsWith(`${LOCALE_COOKIE_NAME}=`))
+ ?.split("=")[1];
+
+ return cookieLocale && isSupportedLocale(cookieLocale) ? cookieLocale : null;
+}
+
+export function getCookieLocale(): Locale {
+ if (typeof document === "undefined") {
+ return defaultLocale;
+ }
+
+ return parseCookieLocale(document.cookie) ?? defaultLocale;
+}
+
+export function setCookieLocale(locale: Locale): void {
+ if (typeof document === "undefined") {
+ return;
+ }
+
+ document.cookie = `${LOCALE_COOKIE_NAME}=${locale}; path=/; max-age=${LOCALE_COOKIE_MAX_AGE}`;
+}
+
export function getPreferredLocale(pathname: string): Locale {
const localeFromPath = getLocaleFromPathname(pathname);
if (localeFromPath) {
@@ -21,12 +53,8 @@ export function getPreferredLocale(pathname: string): Locale {
}
if (typeof document !== "undefined") {
- const cookieLocale = document.cookie
- .split("; ")
- .find((row) => row.startsWith("NEXT_LOCALE="))
- ?.split("=")[1];
-
- if (cookieLocale && isSupportedLocale(cookieLocale)) {
+ const cookieLocale = parseCookieLocale(document.cookie);
+ if (cookieLocale) {
return cookieLocale;
}
}
diff --git a/src/lib/server/ai-prompts.ts b/src/lib/server/ai-prompts.ts
new file mode 100644
index 00000000..aef23e6c
--- /dev/null
+++ b/src/lib/server/ai-prompts.ts
@@ -0,0 +1,245 @@
+import {
+ defaultLocale,
+ importLanguages,
+ type Locale,
+} from "@/i18n/config";
+
+const RESUME_JSON_SCHEMA = `{
+ "title": "Resume title",
+ "basic": {
+ "name": "",
+ "title": "",
+ "email": "",
+ "phone": "",
+ "location": "",
+ "employementStatus": "",
+ "birthDate": ""
+ },
+ "education": [
+ {
+ "school": "",
+ "major": "",
+ "degree": "",
+ "startDate": "",
+ "endDate": "",
+ "gpa": "",
+ "description": ["", ""]
+ }
+ ],
+ "experience": [
+ {
+ "company": "",
+ "position": "",
+ "date": "",
+ "details": ["", ""]
+ }
+ ],
+ "projects": [
+ {
+ "name": "",
+ "role": "",
+ "date": "",
+ "description": ["", ""],
+ "link": "",
+ "linkLabel": ""
+ }
+ ],
+ "skills": ["", ""]
+}`;
+
+export function resolveApiLocale(locale?: string): Locale {
+ if (locale && locale in importLanguages) {
+ return locale as Locale;
+ }
+ return defaultLocale;
+}
+
+export function buildResumeImportPrompt(locale: Locale): string {
+ const language = importLanguages[locale];
+
+ return `You are a professional resume structuring assistant. Extract information from the user's resume content and output exactly one valid JSON object.
+
+Output constraints:
+1. Output JSON only. No Markdown, no explanations.
+2. If a field is uncertain, use an empty string or empty array.
+3. Write all text content in ${language}.
+4. description/details fields must be string arrays; each item is one readable sentence.
+
+JSON schema:
+${RESUME_JSON_SCHEMA}`;
+}
+
+export function buildResumeImportUserPrompt(
+ locale: Locale,
+ hasContent: boolean
+): string {
+ if (hasContent) {
+ return "";
+ }
+
+ const prompts: Record = {
+ zh: "请识别以下简历页面图片中的信息,并严格按 JSON 结构输出。",
+ en: "Extract information from the resume page images below and output strictly according to the JSON schema.",
+ ru: "Извлеките информацию со страниц резюме ниже и выведите строго в соответствии со схемой JSON.",
+ };
+
+ return prompts[locale];
+}
+
+const GRAMMAR_PROMPTS: Record = {
+ zh: `你是一个专业的中文简历校对助手。你的任务是**仅**找出简历中的**错别字**和**标点符号错误**。
+
+**严格禁止**:
+1. ❌ **禁止**提供任何风格、语气、润色或改写建议。如果句子在语法上是正确的(即使读起来不够优美),也**绝对不要**报错。
+2. ❌ **禁止**报告“无明显错误”或类似的信息。如果没有发现错别字或标点错误,"errors" 数组必须为空。
+3. ❌ **禁止**对专业术语进行过度纠正,除非通过上下文非常确定是打字错误。
+
+**仅检查以下两类错误**:
+1. ✅ **错别字**:例如将“作为”写成“做为”,将“经理”写成“经里”。
+2. ✅ **严重标点错误**:仅报告重复标点(如“,,”)或完全错误的符号位置。
+
+**重要例外(绝不报错)**:
+- ❌ **忽略中英文标点混用**:在技术简历中,中文内容使用英文标点是可接受的。**绝对不要**报告此类“错误”。
+- ❌ **忽略空格使用**:不要报告中英文之间的空格遗漏或多余。
+
+返回格式(JSON):
+{
+ "errors": [
+ {
+ "context": "包含错误的完整句子(必须是原文)",
+ "text": "具体的错误部分(必须是原文中实际存在的字符串)",
+ "suggestion": "仅包含修正后的词汇或片段",
+ "type": "spelling"
+ }
+ ]
+}
+
+"type" must be either "spelling" or "punctuation". Do not include a free-form "reason" field.`,
+
+ en: `You are a professional English resume proofreading assistant. Find **only** spelling mistakes and serious punctuation errors.
+
+**Strictly forbidden**:
+1. Do not suggest style, tone, polish, or rewrites. If a sentence is grammatically correct, do not report it.
+2. If no spelling or punctuation errors are found, return an empty "errors" array.
+3. Do not over-correct technical terms unless clearly a typo.
+
+**Check only**:
+1. Spelling mistakes (typos).
+2. Serious punctuation errors (duplicate punctuation, clearly wrong symbol placement).
+
+**Ignore**:
+- Mixed punctuation styles common in tech resumes.
+- Optional spacing around punctuation.
+
+Return JSON:
+{
+ "errors": [
+ {
+ "context": "Full sentence containing the error (exact original text)",
+ "text": "The erroneous substring (must exist in the original)",
+ "suggestion": "Corrected word or fragment only",
+ "type": "spelling"
+ }
+ ]
+}
+
+"type" must be either "spelling" or "punctuation". Do not include a free-form "reason" field.`,
+
+ ru: `Вы — профессиональный корректор русскоязычных резюме. Находите **только** орфографические ошибки и серьёзные пунктуационные ошибки.
+
+**Строго запрещено**:
+1. Не предлагайте стилистические правки, перефразирование или улучшение формулировок.
+2. Если ошибок нет, верните пустой массив "errors".
+3. Не исправляйте профессиональную терминологию без явной опечатки.
+
+**Проверяйте только**:
+1. Орфографические ошибки (опечатки).
+2. Серьёзные пунктуационные ошибки (дублирование знаков, явно неверное расположение).
+
+**Игнорируйте**:
+- Смешанную пунктуацию, типичную для IT-резюме.
+- Пробелы вокруг знаков препинания.
+
+Формат ответа (JSON):
+{
+ "errors": [
+ {
+ "context": "Полное предложение с ошибкой (точный оригинальный текст)",
+ "text": "Ошибочный фрагмент (должен существовать в оригинале)",
+ "suggestion": "Только исправленное слово или фрагмент",
+ "type": "spelling"
+ }
+ ]
+}
+
+"type" must be either "spelling" or "punctuation". Do not include a free-form "reason" field.`,
+};
+
+export function buildGrammarPrompt(locale: Locale): string {
+ return GRAMMAR_PROMPTS[locale];
+}
+
+const POLISH_PROMPTS: Record = {
+ zh: `你是一个专业的简历优化助手。请帮助优化以下 Markdown 格式的文本,使其更加专业和有吸引力。
+
+优化原则:
+1. 使用更专业的词汇和表达方式
+2. 突出关键成就和技能
+3. 保持简洁清晰
+4. 使用主动语气
+5. 保持原有信息的完整性
+6. 严格保留原有的 Markdown 格式结构
+
+输出强约束(必须遵守):
+1. 只能输出“润色后的正文内容”本身。
+2. 禁止输出任何前言、说明、总结、附加建议。
+3. 禁止使用 Markdown 代码块包裹结果。`,
+
+ en: `You are a professional resume optimization assistant. Polish the following Markdown text to make it more professional and compelling.
+
+Principles:
+1. Use professional vocabulary and phrasing
+2. Highlight key achievements and skills
+3. Keep it concise and clear
+4. Use active voice
+5. Preserve all original information
+6. Strictly preserve the original Markdown structure
+
+Output constraints:
+1. Output only the polished body text.
+2. No preface, explanation, summary, or extra suggestions.
+3. Do not wrap the result in Markdown code blocks.`,
+
+ ru: `Вы — профессиональный ассистент по оптимизации резюме. Отполируйте следующий Markdown-текст, сделав его более профессиональным и убедительным.
+
+Принципы:
+1. Используйте профессиональную лексику и формулировки
+2. Выделяйте ключевые достижения и навыки
+3. Будьте лаконичны и ясны
+4. Используйте активный залог
+5. Сохраняйте всю исходную информацию
+6. Строго сохраняйте исходную структуру Markdown
+
+Ограничения вывода:
+1. Выводите только отполированный текст.
+2. Без вступлений, пояснений, резюме и дополнительных советов.
+3. Не оборачивайте результат в блоки Markdown-кода.`,
+};
+
+export function buildPolishPrompt(
+ locale: Locale,
+ customInstructions?: string
+): string {
+ let prompt = POLISH_PROMPTS[locale];
+
+ if (customInstructions?.trim()) {
+ const labels: Record = {
+ zh: "用户额外要求",
+ en: "Additional user instructions",
+ ru: "Дополнительные требования пользователя",
+ };
+ prompt += `\n\n${labels[locale]}:\n${customInstructions.trim()}`;
+ }
+
+ return prompt;
+}
diff --git a/src/lib/templatePreview.ts b/src/lib/templatePreview.ts
index d41bf92f..b37e2dc3 100644
--- a/src/lib/templatePreview.ts
+++ b/src/lib/templatePreview.ts
@@ -1,8 +1,6 @@
import { DEFAULT_TEMPLATES } from "@/config";
-import {
- initialResumeState,
- initialResumeStateEn,
-} from "@/config/initialResumeData";
+import { getInitialResumeStateForLocale } from "@/config/localeResumeData";
+import { locales, type Locale } from "@/i18n/config";
import type { ResumeData } from "@/types/resume";
import type { ResumeTemplate } from "@/types/template";
@@ -12,9 +10,9 @@ export const TEMPLATE_SNAPSHOT_VERSION = 1;
export const TEMPLATE_SNAPSHOT_ROOT_ATTRIBUTE = "data-template-snapshot-root";
export const TEMPLATE_SNAPSHOT_ROOT_SELECTOR = `[${TEMPLATE_SNAPSHOT_ROOT_ATTRIBUTE}]`;
export const TEMPLATE_SNAPSHOT_PUBLIC_DIR = "template-snapshots";
-export const TEMPLATE_PREVIEW_LOCALES = ["zh", "en"] as const;
+export const TEMPLATE_PREVIEW_LOCALES = locales;
-export type TemplatePreviewLocale = (typeof TEMPLATE_PREVIEW_LOCALES)[number];
+export type TemplatePreviewLocale = Locale;
export interface TemplateSnapshotManifest {
version: number;
@@ -29,20 +27,23 @@ export const createEmptyTemplateSnapshotManifest =
locales: {
zh: {},
en: {},
+ ru: {},
},
});
export const isTemplatePreviewLocale = (
value: string | null | undefined
): value is TemplatePreviewLocale =>
- value === "zh" || value === "en";
+ value !== null &&
+ value !== undefined &&
+ (locales as readonly string[]).includes(value);
export const getTemplateById = (templateId: string | undefined): ResumeTemplate =>
DEFAULT_TEMPLATES.find((template) => template.id === templateId) ??
DEFAULT_TEMPLATES[0];
export const getTemplatePreviewBaseData = (locale: TemplatePreviewLocale) =>
- locale === "en" ? initialResumeStateEn : initialResumeState;
+ getInitialResumeStateForLocale(locale);
export const createTemplatePreviewData = (
template: ResumeTemplate,
diff --git a/src/lib/templates.ts b/src/lib/templates.ts
new file mode 100644
index 00000000..f043778f
--- /dev/null
+++ b/src/lib/templates.ts
@@ -0,0 +1,3 @@
+export function getTemplateKey(templateId: string): string {
+ return templateId === "left-right" ? "leftRight" : templateId;
+}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index 58041e54..9b8690ff 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -1,5 +1,7 @@
-import { clsx, type ClassValue } from "clsx"
-import { twMerge } from "tailwind-merge"
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+import { defaultLocale, heroUiLocales, type Locale } from "@/i18n/config";
+import { isSupportedLocale } from "@/i18n/runtime";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
@@ -35,7 +37,7 @@ function parseToDate(dateStr: string): Date | null {
return null;
}
-export function formatDateString(dateStr: string | undefined, locale: string = "zh"): string {
+export function formatDateString(dateStr: string | undefined, locale: string = defaultLocale): string {
if (!dateStr) return "";
if (dateStr.includes(DATE_RANGE_SEPARATOR)) {
@@ -50,7 +52,13 @@ export function formatDateString(dateStr: string | undefined, locale: string = "
if (locale === "zh" || locale === "zh-CN") {
return `${date.getUTCFullYear()}/${String(date.getUTCMonth() + 1).padStart(2, "0")}`;
}
- const formatter = new Intl.DateTimeFormat(locale, {
+ const intlLocale =
+ locale in heroUiLocales
+ ? heroUiLocales[locale as Locale]
+ : isSupportedLocale(locale)
+ ? heroUiLocales[locale]
+ : locale;
+ const formatter = new Intl.DateTimeFormat(intlLocale, {
year: 'numeric',
month: '2-digit',
timeZone: 'UTC'
@@ -64,7 +72,7 @@ export function formatDateString(dateStr: string | undefined, locale: string = "
export function formatDateRange(
startDate: string | undefined,
endDate: string | undefined,
- locale: string = "zh"
+ locale: string = defaultLocale
): string {
const start = formatDateString(startDate, locale).trim();
const end = formatDateString(endDate, locale).trim();
diff --git a/src/routes/$locale.tsx b/src/routes/$locale.tsx
index 5697d10d..d810daf3 100644
--- a/src/routes/$locale.tsx
+++ b/src/routes/$locale.tsx
@@ -1,8 +1,7 @@
import { createFileRoute, notFound } from "@tanstack/react-router";
import LandingPage from "@/app/(public)/[locale]/page";
-import { defaultLocale, locales, type Locale } from "@/i18n/config";
-import zhMessages from "@/i18n/locales/zh.json";
-import enMessages from "@/i18n/locales/en.json";
+import { defaultLocale, localeTags, locales, type Locale } from "@/i18n/config";
+import { getMessagesForLocale } from "@/i18n/messages";
const SEO_BASE_URL = "https://magicv.art";
@@ -14,19 +13,21 @@ function resolveLocale(rawLocale: string): Locale {
}
function getLocaleSeo(locale: Locale) {
- const messages = locale === "en" ? enMessages : zhMessages;
+ const messages = getMessagesForLocale(locale) as {
+ common: { title: string; subtitle: string; description: string };
+ };
const title = `${messages.common.title} - ${messages.common.subtitle}`;
const description = messages.common.description;
- const localeTag = locale === "en" ? "en_US" : "zh_CN";
+ const localeTag = localeTags[locale];
const canonical = `${SEO_BASE_URL}/${locale}`;
- const alternateLocale = locale === "en" ? "zh" : "en";
+ const alternateLocales = locales.filter((loc) => loc !== locale);
return {
title,
description,
localeTag,
canonical,
- alternateLocale
+ alternateLocales
};
}
@@ -45,6 +46,10 @@ export const Route = createFileRoute("/$locale")({
{ property: "og:title", content: seo.title },
{ property: "og:description", content: seo.description },
{ property: "og:locale", content: seo.localeTag },
+ ...seo.alternateLocales.map((loc) => ({
+ property: "og:locale:alternate",
+ content: localeTags[loc],
+ })),
{ property: "og:url", content: seo.canonical },
{ property: "og:image", content: `${SEO_BASE_URL}/web-shot.png` },
{ name: "twitter:card", content: "summary_large_image" },
@@ -54,12 +59,11 @@ export const Route = createFileRoute("/$locale")({
],
links: [
{ rel: "canonical", href: seo.canonical },
- { rel: "alternate", hrefLang: locale, href: seo.canonical },
- {
- rel: "alternate",
- hrefLang: seo.alternateLocale,
- href: `${SEO_BASE_URL}/${seo.alternateLocale}`
- },
+ ...locales.map((loc) => ({
+ rel: "alternate" as const,
+ hrefLang: loc,
+ href: `${SEO_BASE_URL}/${loc}`
+ })),
{ rel: "alternate", hrefLang: "x-default", href: `${SEO_BASE_URL}/zh` }
]
};
diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx
index 84a53b49..408c3481 100644
--- a/src/routes/__root.tsx
+++ b/src/routes/__root.tsx
@@ -3,18 +3,17 @@ import {
HeadContent,
Outlet,
Scripts,
- useLocation
} from "@tanstack/react-router";
import appCss from "../app/globals.css?url";
import appFontCss from "../app/font.css?url";
import tiptapCss from "../styles/tiptap.scss?url";
import { NextIntlClientProvider } from "@/i18n/compat/client";
-import { useEffect } from "react";
-import zhMessages from "@/i18n/locales/zh.json";
-import enMessages from "@/i18n/locales/en.json";
+import { getMessagesForLocale } from "@/i18n/messages";
import { Providers } from "@/app/providers";
import { Toaster } from "@/components/ui/sonner";
-import { getPreferredLocale } from "@/i18n/runtime";
+import { heroUiLocales } from "@/i18n/config";
+import { LocaleProvider, useAppLocale } from "@/i18n/locale-context";
+import { useTranslations } from "@/i18n/compat/client";
export const Route = createRootRoute({
head: () => ({
@@ -22,42 +21,35 @@ export const Route = createRootRoute({
{ charSet: "utf-8" },
{
name: "viewport",
- content: "width=device-width, initial-scale=1"
+ content: "width=device-width, initial-scale=1",
},
- { title: "Magic Resume" }
+ { title: "Magic Resume" },
],
links: [
{
rel: "stylesheet",
- href: appCss
+ href: appCss,
},
{
rel: "stylesheet",
- href: appFontCss
+ href: appFontCss,
},
{
rel: "stylesheet",
- href: tiptapCss
- }
- ]
+ href: tiptapCss,
+ },
+ ],
}),
component: RootComponent,
- notFoundComponent: RootNotFound
+ notFoundComponent: RootNotFound,
});
-function RootComponent() {
- const pathname = useLocation({
- select: (location) => location.pathname
- });
- const locale = getPreferredLocale(pathname);
- const messages = locale === "en" ? enMessages : zhMessages;
-
- useEffect(() => {
- document.cookie = `NEXT_LOCALE=${locale}; path=/; max-age=31536000`;
- }, [locale]);
+function AppShell({ children }: { children: React.ReactNode }) {
+ const locale = useAppLocale();
+ const messages = getMessagesForLocale(locale);
return (
-
+
@@ -70,7 +62,7 @@ function RootComponent() {
timeZone="Asia/Shanghai"
>
-
+ {children}
@@ -80,10 +72,32 @@ function RootComponent() {
);
}
-function RootNotFound() {
+function RootComponent() {
+ return (
+
+
+
+
+
+ );
+}
+
+function NotFoundContent() {
+ const t = useTranslations("common");
+
return (
- 页面不存在
+ {t("notFound")}
);
}
+
+function RootNotFound() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/routes/api/grammar.ts b/src/routes/api/grammar.ts
index 7dd968d1..2372977a 100644
--- a/src/routes/api/grammar.ts
+++ b/src/routes/api/grammar.ts
@@ -1,6 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { AIModelType, AI_MODEL_CONFIGS } from "@/config/ai";
import { formatGeminiErrorMessage, getGeminiModelInstance } from "@/lib/server/gemini";
+import { buildGrammarPrompt, resolveApiLocale } from "@/lib/server/ai-prompts";
const parseUpstreamError = (raw: string, fallback: string) => {
if (!raw) return { message: fallback };
@@ -24,12 +25,13 @@ export const Route = createFileRoute("/api/grammar")({
POST: async ({ request }) => {
try {
const body = await request.json();
- const { apiKey, model, content, modelType, apiEndpoint } = body as {
+ const { apiKey, model, content, modelType, apiEndpoint, locale } = body as {
apiKey: string;
model: string;
content: string;
modelType: AIModelType;
apiEndpoint?: string;
+ locale?: string;
};
const modelConfig = AI_MODEL_CONFIGS[modelType as AIModelType];
@@ -37,35 +39,8 @@ export const Route = createFileRoute("/api/grammar")({
throw new Error("Invalid model type");
}
- const systemPrompt = `你是一个专业的中文简历校对助手。你的任务是**仅**找出简历中的**错别字**和**标点符号错误**。
-
- **严格禁止**:
- 1. ❌ **禁止**提供任何风格、语气、润色或改写建议。如果句子在语法上是正确的(即使读起来不够优美),也**绝对不要**报错。
- 2. ❌ **禁止**报告“无明显错误”或类似的信息。如果没有发现错别字或标点错误,"errors" 数组必须为空。
- 3. ❌ **禁止**对专业术语进行过度纠正,除非通过上下文非常确定是打字错误。
-
- **仅检查以下两类错误**:
- 1. ✅ **错别字**:例如将“作为”写成“做为”,将“经理”写成“经里”。
- 2. ✅ **严重标点错误**:仅报告重复标点(如“,,”)或完全错误的符号位置。
-
- **重要例外(绝不报错)**:
- - ❌ **忽略中英文标点混用**:在技术简历中,中文内容使用英文标点(如使用英文逗号, 代替中文逗号,或使用英文句点. 代替中文句号)是**完全接受**的风格。**绝对不要**报告此类“错误”。
- - ❌ **忽略空格使用**:不要报告中英文之间的空格遗漏或多余。
-
- 返回格式示例(JSON):
- {
- "errors": [
- {
- "context": "包含错误的完整句子(必须是原文)",
- "text": "具体的错误部分(必须是原文中实际存在的字符串)",
- "suggestion": "仅包含修正后的词汇或片段(**不要**返回整句,除非整句都是错误的)",
- "reason": "错别字 / 标点错误",
- "type": "spelling"
- }
- ]
- }
-
- 再次强调:**只找错别字和标点错误,不要做任何润色!**`;
+ const resolvedLocale = resolveApiLocale(locale);
+ const systemPrompt = buildGrammarPrompt(resolvedLocale);
if (modelType === "gemini") {
const geminiModel = model || "gemini-flash-latest";
diff --git a/src/routes/api/polish.ts b/src/routes/api/polish.ts
index e25b98cf..25a4c0aa 100644
--- a/src/routes/api/polish.ts
+++ b/src/routes/api/polish.ts
@@ -1,6 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { AIModelType, AI_MODEL_CONFIGS } from "@/config/ai";
import { formatGeminiErrorMessage, getGeminiModelInstance } from "@/lib/server/gemini";
+import { buildPolishPrompt, resolveApiLocale } from "@/lib/server/ai-prompts";
const parseUpstreamError = (raw: string, fallback: string) => {
if (!raw) return { message: fallback };
@@ -24,13 +25,14 @@ export const Route = createFileRoute("/api/polish")({
POST: async ({ request }) => {
try {
const body = await request.json();
- const { apiKey, model, content, modelType, apiEndpoint, customInstructions } = body as {
+ const { apiKey, model, content, modelType, apiEndpoint, customInstructions, locale } = body as {
apiKey: string;
model: string;
content: string;
modelType: AIModelType;
apiEndpoint?: string;
customInstructions?: string;
+ locale?: string;
};
const modelConfig = AI_MODEL_CONFIGS[modelType as AIModelType];
@@ -38,27 +40,8 @@ export const Route = createFileRoute("/api/polish")({
throw new Error("Invalid model type");
}
- let systemPrompt = `你是一个专业的简历优化助手。请帮助优化以下 Markdown 格式的文本,使其更加专业和有吸引力。
-
- 优化原则:
- 1. 使用更专业的词汇和表达方式
- 2. 突出关键成就和技能
- 3. 保持简洁清晰
- 4. 使用主动语气
- 5. 保持原有信息的完整性
- 6. 严格保留原有的 Markdown 格式结构(列表项保持为列表项,加粗保持加粗等)
-
- 输出强约束(必须遵守):
- 1. 只能输出“润色后的正文内容”本身。
- 2. 禁止输出任何前言、说明、总结、附加建议。
- 3. 禁止出现这类引导语:如“以下是...”“根据您提供...”“这是...”“特点:”“说明:”“总结:”等。
- 4. 禁止新增与原文无关的章节标题或收尾段落。
- 5. 不要使用 Markdown 代码块(\`\`\`)包裹结果。
- 6. 若你产生了解释性内容,必须在输出前自检并删除,只保留最终正文。`;
-
- if (customInstructions?.trim()) {
- systemPrompt += `\n\n用户额外要求:\n${customInstructions.trim()}`;
- }
+ const resolvedLocale = resolveApiLocale(locale);
+ const systemPrompt = buildPolishPrompt(resolvedLocale, customInstructions);
if (modelType === "gemini") {
const geminiModel = model || "gemini-flash-latest";
diff --git a/src/routes/api/resume-import.ts b/src/routes/api/resume-import.ts
index 343863d0..e9e99494 100644
--- a/src/routes/api/resume-import.ts
+++ b/src/routes/api/resume-import.ts
@@ -1,5 +1,12 @@
import { createFileRoute } from "@tanstack/react-router";
+import type { Locale } from "@/i18n/config";
+import { defaultLocale } from "@/i18n/config";
import { formatGeminiErrorMessage, getGeminiModelInstance } from "@/lib/server/gemini";
+import {
+ buildResumeImportPrompt,
+ buildResumeImportUserPrompt,
+ resolveApiLocale,
+} from "@/lib/server/ai-prompts";
const parseJsonPayload = (content: string) => {
const text = content.trim();
@@ -60,7 +67,7 @@ export const Route = createFileRoute("/api/resume-import")({
);
}
- const language = locale === "en" ? "English" : "Chinese";
+ const resolvedLocale = resolveApiLocale(locale);
const geminiModel = model || "gemini-flash-latest";
const imageParts = Array.isArray(images)
? images.map((image) => {
@@ -76,68 +83,20 @@ export const Route = createFileRoute("/api/resume-import")({
const modelInstance = getGeminiModelInstance({
apiKey,
model: geminiModel,
- systemInstruction: `你是一个专业的简历结构化助手。根据用户提供的简历内容,提取信息并只输出一个合法 JSON 对象。
-
-输出约束:
-1. 只允许输出 JSON,不要输出 Markdown,不要输出解释。
-2. 如果某个字段不确定,使用空字符串或空数组。
-3. 请使用 ${language} 输出内容文本。
-4. description/details 字段输出字符串数组,每一项为一句可读内容。
-
-JSON 结构:
-{
- "title": "简历标题",
- "basic": {
- "name": "",
- "title": "",
- "email": "",
- "phone": "",
- "location": "",
- "employementStatus": "",
- "birthDate": ""
- },
- "education": [
- {
- "school": "",
- "major": "",
- "degree": "",
- "startDate": "",
- "endDate": "",
- "gpa": "",
- "description": ["", ""]
- }
- ],
- "experience": [
- {
- "company": "",
- "position": "",
- "date": "",
- "details": ["", ""]
- }
- ],
- "projects": [
- {
- "name": "",
- "role": "",
- "date": "",
- "description": ["", ""],
- "link": "",
- "linkLabel": ""
- }
- ],
- "skills": ["", ""]
-}`,
+ systemInstruction: buildResumeImportPrompt(resolvedLocale),
generationConfig: {
temperature: 0.2,
responseMimeType: "application/json",
},
});
+ const userPrompt =
+ content ||
+ buildResumeImportUserPrompt(resolvedLocale, Boolean(content));
+
const inputParts = [
{
- text:
- content ||
- "请识别以下简历页面图片中的信息,并严格按 JSON 结构输出。",
+ text: userPrompt,
},
...imageParts,
];
diff --git a/src/store/useGrammarStore.ts b/src/store/useGrammarStore.ts
index 39ce6150..28d61f0b 100644
--- a/src/store/useGrammarStore.ts
+++ b/src/store/useGrammarStore.ts
@@ -4,13 +4,13 @@ import Mark from "mark.js";
import { useAIConfigStore } from "@/store/useAIConfigStore";
import { AI_MODEL_CONFIGS } from "@/config/ai";
import { cn } from "@/lib/utils";
+import { defaultLocale, type Locale } from "@/i18n/config";
export interface GrammarError {
context: string;
text: string;
suggestion: string;
- reason: string;
- type: "spelling" | "grammar";
+ type: "spelling" | "punctuation";
}
interface GrammarStore {
@@ -22,12 +22,35 @@ interface GrammarStore {
setIsChecking: (isChecking: boolean) => void;
setSelectedErrorIndex: (index: number | null) => void;
incrementHighlightKey: () => void;
- checkGrammar: (text: string) => Promise;
+ checkGrammar: (text: string, locale?: Locale) => Promise;
clearErrors: () => void;
selectError: (index: number) => void;
dismissError: (index: number) => void;
}
+const normalizeGrammarError = (error: {
+ context?: string;
+ text?: string;
+ suggestion?: string;
+ type?: string;
+ reason?: string;
+}): GrammarError | null => {
+ if (!error.text?.trim() || !error.context?.trim()) {
+ return null;
+ }
+
+ const rawType = error.type?.toLowerCase() ?? "";
+ const type: GrammarError["type"] =
+ rawType === "punctuation" || rawType === "grammar" ? "punctuation" : "spelling";
+
+ return {
+ context: error.context,
+ text: error.text,
+ suggestion: error.suggestion ?? "",
+ type,
+ };
+};
+
const markSingleError = (
marker: Mark,
error: GrammarError,
@@ -89,7 +112,7 @@ export const useGrammarStore = create((set, get) => ({
incrementHighlightKey: () =>
set((state) => ({ highlightKey: state.highlightKey + 1 })),
- checkGrammar: async (text: string) => {
+ checkGrammar: async (text: string, locale: Locale = defaultLocale) => {
const {
selectedModel,
doubaoApiKey,
@@ -135,6 +158,7 @@ export const useGrammarStore = create((set, get) => ({
model: config.requiresModelId ? modelId : config.defaultModel,
modelType: selectedModel,
apiEndpoint: selectedModel === "openai" ? openaiApiEndpoint : undefined,
+ locale,
}),
});
@@ -158,19 +182,22 @@ export const useGrammarStore = create((set, get) => ({
try {
const grammarErrors = JSON.parse(aiResponse);
- if (grammarErrors.errors.length === 0) {
+ const normalizedErrors = (grammarErrors.errors ?? [])
+ .map(normalizeGrammarError)
+ .filter(Boolean) as GrammarError[];
+
+ if (normalizedErrors.length === 0) {
set({ errors: [] });
toast.success("无语法错误");
return;
}
- set({ errors: grammarErrors.errors });
+ set({ errors: normalizedErrors });
const preview = document.getElementById("resume-preview");
if (preview) {
const marker = new Mark(preview);
marker.unmark();
- grammarErrors.errors.forEach((error: GrammarError) => {
- // 仅标注错误片段,避免整句/全局模糊匹配造成误高亮
+ normalizedErrors.forEach((error: GrammarError) => {
markSingleError(marker, error);
});
}
diff --git a/src/store/useResumeStore.ts b/src/store/useResumeStore.ts
index 999ffd07..f1e43faf 100644
--- a/src/store/useResumeStore.ts
+++ b/src/store/useResumeStore.ts
@@ -14,11 +14,12 @@ import {
} from "../types/resume";
import { DEFAULT_TEMPLATES } from "@/config";
import {
- initialResumeState,
- initialResumeStateEn,
- blankResumeState,
- blankResumeStateEn,
-} from "@/config/initialResumeData";
+ getBlankResumeStateForLocale,
+ getCookieLocale,
+ getInitialResumeStateForLocale,
+ getLocalizedCommonLabel,
+} from "@/config/localeResumeData";
+import type { Locale } from "@/i18n/config";
import { generateUUID } from "@/utils/uuid";
interface ResumeStore {
resumes: Record;
@@ -208,22 +209,11 @@ export const useResumeStore = create(
activeResume: null,
createResume: (templateId = null, isBlank = false) => {
- const locale =
- typeof document !== "undefined"
- ? document.cookie
- .split("; ")
- .find((row) => row.startsWith("NEXT_LOCALE="))
- ?.split("=")[1] || "zh"
- : "zh";
-
- let initialResumeData: any;
- if (isBlank) {
- initialResumeData =
- locale === "en" ? blankResumeStateEn : blankResumeState;
- } else {
- initialResumeData =
- locale === "en" ? initialResumeStateEn : initialResumeState;
- }
+ const locale = getCookieLocale();
+
+ const initialResumeData = isBlank
+ ? getBlankResumeStateForLocale(locale)
+ : getInitialResumeStateForLocale(locale);
const id = generateUUID();
const template = templateId
@@ -236,7 +226,7 @@ export const useResumeStore = create(
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
templateId: template?.id,
- title: `${locale === "en" ? "New Resume" : "新建简历"} ${id.slice(
+ title: `${getLocalizedCommonLabel(locale, "newResume")} ${id.slice(
0,
6
)}`,
@@ -348,21 +338,15 @@ export const useResumeStore = create(
return "";
}
- // 获取当前语言环境
- const locale =
- typeof document !== "undefined"
- ? document.cookie
- .split("; ")
- .find((row) => row.startsWith("NEXT_LOCALE="))
- ?.split("=")[1] || "zh"
- : "zh";
+ const locale = getCookieLocale();
const duplicatedResume = {
...structuredClone(originalResume),
id: newId,
- title: `${originalResume.title} (${
- locale === "en" ? "Copy" : "复制"
- })`,
+ title: `${originalResume.title} (${getLocalizedCommonLabel(
+ locale as Locale,
+ "copy"
+ )})`,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
diff --git a/src/test/setup.ts b/src/test/setup.ts
new file mode 100644
index 00000000..f149f27a
--- /dev/null
+++ b/src/test/setup.ts
@@ -0,0 +1 @@
+import "@testing-library/jest-dom/vitest";
diff --git a/vite.config.ts b/vite.config.ts
index 2aae8add..27504fa9 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,3 +1,4 @@
+///
import { defineConfig } from "vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
@@ -22,5 +23,10 @@ export default defineConfig({
}
}),
viteReact()
- ]
+ ],
+ test: {
+ environment: "jsdom",
+ include: ["src/**/*.test.{ts,tsx}"],
+ setupFiles: ["src/test/setup.ts"],
+ },
});