+
{previewUrl ? (
// Resume HTML is untrusted generated content served from the
// backend origin. An empty `sandbox` grants no capabilities:
@@ -176,13 +177,13 @@ export default function StudioPage() {
className="w-full h-full border-0"
/>
) : (
-
- 选择模板后点击生成预览
+
+ {selectedTemplate ? "点右侧「生成简历」查看预览" : "先在左边选一个模板"}
)}
{generateError && (
-
+
生成失败:{generateError}
)}
@@ -190,8 +191,8 @@ export default function StudioPage() {
{/* 右侧:设计系统 */}
-
设计系统
-
+ 2 · 调整样式
+
-
+
+ {!selectedTemplate && (
+ 先在左边选一个模板,按钮就会亮起。
+ )}
diff --git a/frontend/src/components/hints/HintsToggle.tsx b/frontend/src/components/hints/HintsToggle.tsx
new file mode 100644
index 00000000..1beccf9f
--- /dev/null
+++ b/frontend/src/components/hints/HintsToggle.tsx
@@ -0,0 +1,28 @@
+// 设置页的「显示提示」开关:熟手可以一键隐藏所有安静提示。
+import { useHintsEnabled } from "./hintPrefs";
+
+export function HintsToggle() {
+ const [enabled, setEnabled] = useHintsEnabled();
+ return (
+
+
+
+
界面提示
+
+ 标题旁的小「?」只在你需要时展开,不会自己弹出。熟悉之后可以全部隐藏。
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/hints/QuietHint.test.tsx b/frontend/src/components/hints/QuietHint.test.tsx
new file mode 100644
index 00000000..e67971b9
--- /dev/null
+++ b/frontend/src/components/hints/QuietHint.test.tsx
@@ -0,0 +1,51 @@
+import { act, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { MemoryRouter } from "react-router-dom";
+import { afterEach, describe, expect, it } from "vitest";
+import { QuietHint } from "./QuietHint";
+import { HINTS_STORAGE_KEY, setHintsEnabled } from "./hintPrefs";
+
+function renderHint() {
+ return render(
+
+
+ 标题 一句话说明
+
+
+ ,
+ );
+}
+
+describe("QuietHint — 安静、不强制、需要时可展开", () => {
+ afterEach(() => window.localStorage.removeItem(HINTS_STORAGE_KEY));
+
+ it("默认只显示小标记,不自动弹出说明", () => {
+ renderHint();
+ expect(screen.getByRole("button", { name: "这是什么?" })).toBeInTheDocument();
+ expect(screen.queryByRole("note")).not.toBeInTheDocument();
+ });
+
+ it("点击展开说明和指引链接,Esc 关闭", async () => {
+ renderHint();
+ await userEvent.click(screen.getByRole("button", { name: "这是什么?" }));
+ expect(screen.getByRole("note")).toHaveTextContent("一句话说明");
+ expect(screen.getByText("去档案").closest("a")).toHaveAttribute("href", "/profile");
+ await userEvent.keyboard("{Escape}");
+ expect(screen.queryByRole("note")).not.toBeInTheDocument();
+ });
+
+ it("展开时不挡住页面其它操作,点外面即收起", async () => {
+ renderHint();
+ await userEvent.click(screen.getByRole("button", { name: "这是什么?" }));
+ await userEvent.click(screen.getByRole("button", { name: "外面的按钮" }));
+ expect(screen.queryByRole("note")).not.toBeInTheDocument();
+ });
+
+ it("设置里关闭提示后整体隐藏,重新打开后恢复", () => {
+ renderHint();
+ act(() => setHintsEnabled(false));
+ expect(screen.queryByTestId("quiet-hint")).not.toBeInTheDocument();
+ act(() => setHintsEnabled(true));
+ expect(screen.getByTestId("quiet-hint")).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/hints/QuietHint.tsx b/frontend/src/components/hints/QuietHint.tsx
new file mode 100644
index 00000000..4629545b
--- /dev/null
+++ b/frontend/src/components/hints/QuietHint.tsx
@@ -0,0 +1,103 @@
+// =============================================
+// QuietHint — 安静的静态小标记
+// 设计约束:
+// - 默认只是一个灰色小圆点「?」,无动画、不自动弹出、不抢焦点;
+// - 用户悬停 / 聚焦 / 点击才展开一句话说明,可带一个「怎么做」链接;
+// - Esc、点外面、再点一次都能关;从不阻断下面的操作(不是模态);
+// - 设置里关闭「显示提示」后整体隐藏。
+// =============================================
+
+import { useEffect, useId, useRef, useState, type ReactNode } from "react";
+import Link from "next/link";
+import { useHintsEnabled } from "./hintPrefs";
+
+interface QuietHintProps {
+ /** 一句话说明,尽量 ≤ 40 字 */
+ children: ReactNode;
+ /** 屏幕阅读器标签,同时是标记的 title */
+ label?: string;
+ /** 可选:带用户去做这件事的入口 */
+ action?: { href: string; label: string };
+ align?: "start" | "end";
+ className?: string;
+}
+
+export function QuietHint({ children, label = "这是什么?", action, align = "start", className = "" }: QuietHintProps) {
+ const [enabled] = useHintsEnabled();
+ const [open, setOpen] = useState(false);
+ const [pinned, setPinned] = useState(false);
+ const rootRef = useRef
(null);
+ const panelId = useId();
+
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ setOpen(false);
+ setPinned(false);
+ }
+ };
+ const onPointer = (event: PointerEvent) => {
+ if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
+ setOpen(false);
+ setPinned(false);
+ }
+ };
+ document.addEventListener("keydown", onKey);
+ document.addEventListener("pointerdown", onPointer);
+ return () => {
+ document.removeEventListener("keydown", onKey);
+ document.removeEventListener("pointerdown", onPointer);
+ };
+ }, [open]);
+
+ if (!enabled) return null;
+
+ return (
+ setOpen(true)}
+ onMouseLeave={() => {
+ if (!pinned) setOpen(false);
+ }}
+ data-testid="quiet-hint"
+ >
+
+ {open && (
+
+ {children}
+ {action && (
+
+ {action.label}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/hints/hintPrefs.ts b/frontend/src/components/hints/hintPrefs.ts
new file mode 100644
index 00000000..e579de87
--- /dev/null
+++ b/frontend/src/components/hints/hintPrefs.ts
@@ -0,0 +1,43 @@
+// =============================================
+// 安静提示(Quiet Hints)偏好
+// 默认开启;用户可在设置里整体关闭。只存在本机 localStorage,
+// 读写失败(隐私模式 / 配额)时退回默认值,绝不抛错打断界面。
+// =============================================
+
+import { useEffect, useState } from "react";
+
+export const HINTS_STORAGE_KEY = "offeru.quietHints.enabled";
+const HINTS_EVENT = "offeru:quiet-hints-changed";
+
+export function readHintsEnabled(): boolean {
+ try {
+ return window.localStorage.getItem(HINTS_STORAGE_KEY) !== "false";
+ } catch {
+ return true;
+ }
+}
+
+export function setHintsEnabled(enabled: boolean): void {
+ try {
+ window.localStorage.setItem(HINTS_STORAGE_KEY, enabled ? "true" : "false");
+ } catch {
+ // 存不下也不影响当前会话:下面的事件仍会同步本页状态。
+ }
+ window.dispatchEvent(new CustomEvent(HINTS_EVENT, { detail: enabled }));
+}
+
+export function useHintsEnabled(): [boolean, (enabled: boolean) => void] {
+ const [enabled, setEnabled] = useState(() => readHintsEnabled());
+
+ useEffect(() => {
+ const sync = () => setEnabled(readHintsEnabled());
+ window.addEventListener(HINTS_EVENT, sync);
+ window.addEventListener("storage", sync);
+ return () => {
+ window.removeEventListener(HINTS_EVENT, sync);
+ window.removeEventListener("storage", sync);
+ };
+ }, []);
+
+ return [enabled, setHintsEnabled];
+}
diff --git a/frontend/src/components/hints/index.ts b/frontend/src/components/hints/index.ts
new file mode 100644
index 00000000..960e7c47
--- /dev/null
+++ b/frontend/src/components/hints/index.ts
@@ -0,0 +1,3 @@
+export { QuietHint } from "./QuietHint";
+export { HintsToggle } from "./HintsToggle";
+export { useHintsEnabled, setHintsEnabled, readHintsEnabled, HINTS_STORAGE_KEY } from "./hintPrefs";
diff --git a/frontend/src/components/jobs/JobNextStepCard.test.tsx b/frontend/src/components/jobs/JobNextStepCard.test.tsx
index 213bf71d..bcb9590d 100644
--- a/frontend/src/components/jobs/JobNextStepCard.test.tsx
+++ b/frontend/src/components/jobs/JobNextStepCard.test.tsx
@@ -41,13 +41,13 @@ describe("JobNextStepCard", () => {
it("决定投递后直达这个岗位的简历定制", () => {
renderCard({ stage: "ready_for_resume_proposal" });
- expect(screen.getByText("开始简历定制").closest("a")).toHaveAttribute("href", expect.stringContaining("/optimize?job_ids=9"));
+ expect(screen.getByText("开始简历定制").closest("a")).toHaveAttribute("href", expect.stringContaining("/resume?mode=tailor&job_ids=9"));
});
it("人工决定后新调研到达时只提示重新评估,不覆盖当前决定", async () => {
const props = renderCard({ stage: "ready_for_resume_proposal", researchRefreshAvailable: true });
expect(screen.getByText("有新的岗位情报,要重新评估吗?")).toBeInTheDocument();
- expect(screen.getByText("继续用当前决定").closest("a")).toHaveAttribute("href", expect.stringContaining("/optimize?job_ids=9"));
+ expect(screen.getByText("继续用当前决定").closest("a")).toHaveAttribute("href", expect.stringContaining("/resume?mode=tailor&job_ids=9"));
await userEvent.click(screen.getByRole("button", { name: "基于新情报重新评估" }));
expect(props.onPrepareDecision).toHaveBeenCalledTimes(1);
});
diff --git a/frontend/src/components/jobs/JobNextStepCard.tsx b/frontend/src/components/jobs/JobNextStepCard.tsx
index 667037a2..a68993a8 100644
--- a/frontend/src/components/jobs/JobNextStepCard.tsx
+++ b/frontend/src/components/jobs/JobNextStepCard.tsx
@@ -3,6 +3,7 @@
import Link from "next/link";
import { Button, Spinner } from "@heroui/react";
import { ArrowRight } from "lucide-react";
+import { tailorResumeHref } from "@/app/resume/resumeModes";
/**
* One "what now" for the Job Workspace, projected from the backend
@@ -57,7 +58,7 @@ function resolveStep(p: Props): Step {
body: "你之前的人工决定仍然有效,不会被新调研静默覆盖。只有你主动重新评估,才会生成一份基于新证据的建议。",
tone: "action",
primary: { kind: "run", run: p.onPrepareDecision, label: "基于新情报重新评估", busy: p.preparing },
- secondary: { kind: "link", href: `/optimize?job_ids=${p.jobId}`, label: "继续用当前决定" },
+ secondary: { kind: "link", href: tailorResumeHref([p.jobId]), label: "继续用当前决定" },
};
}
@@ -110,7 +111,7 @@ function resolveStep(p: Props): Step {
case "completed_insufficient_evidence":
return { title: "证据不足,暂不投", body: "补充档案后可以重新判断。", tone: "done", primary: { kind: "link", href: "/profile", label: "去补充档案" } };
case "ready_for_resume_proposal":
- return { title: "定制这份简历", body: "已决定投递。下一步按这个岗位生成简历修改提案。", tone: "action", primary: { kind: "link", href: `/optimize?job_ids=${p.jobId}`, label: "开始简历定制" } };
+ return { title: "定制这份简历", body: "已决定投递。下一步按这个岗位生成简历修改提案。", tone: "action", primary: { kind: "link", href: tailorResumeHref([p.jobId]), label: "开始简历定制" } };
case "resume_proposal_ready":
return p.hasResumeProposal
? { title: "审核简历提案", body: "逐条看修改和证据,接受后打开这个岗位专属的简历。", tone: "action", primary: { kind: "scroll", target: "resume-proposal", label: "去审核提案" }, secondary: { kind: "run", run: p.onOpenResumeWorkspace, label: "打开岗位简历" } }
diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx
index 61ffd78f..5f9d1d1b 100644
--- a/frontend/src/components/layout/Sidebar.tsx
+++ b/frontend/src/components/layout/Sidebar.tsx
@@ -14,11 +14,9 @@ import {
FileText,
Mail,
MessagesSquare,
- Palette,
Search,
Settings,
Send,
- Sparkles,
Sun,
UserRound,
} from "lucide-react";
@@ -44,12 +42,10 @@ const stageItems: NavItem[] = [
];
const workspaceItems: NavItem[] = [
- { href: "/resume", label: "简历", hint: "版本与编辑", icon: FileText, match: ["/resume"] },
+ { href: "/resume", label: "简历", hint: "编辑 · 岗位定制 · 版式", icon: FileText, match: ["/resume", "/optimize", "/studio"] },
{ href: "/interview", label: "面试", hint: "题库与面经", icon: MessagesSquare, match: ["/interview"] },
- { href: "/optimize", label: "简历定制", hint: "按岗位改写", icon: Sparkles, match: ["/optimize"] },
{ href: "/email", label: "邮件", hint: "进展与提醒", icon: Mail, match: ["/email"] },
{ href: "/calendar", label: "日历", hint: "面试日程", icon: CalendarDays, match: ["/calendar"] },
- { href: "/studio", label: "工作室", hint: "HTML 简历", icon: Palette, match: ["/studio"] },
];
const supportItems: NavItem[] = [
diff --git a/frontend/src/components/workbench/CommandPalette.tsx b/frontend/src/components/workbench/CommandPalette.tsx
index 96324c6f..73f40a94 100644
--- a/frontend/src/components/workbench/CommandPalette.tsx
+++ b/frontend/src/components/workbench/CommandPalette.tsx
@@ -24,6 +24,7 @@ import {
UserRound,
} from "lucide-react";
import { useWorkbench } from "@/lib/workbench";
+import { tailorResumeHref } from "@/app/resume/resumeModes";
interface Command {
id: string;
@@ -80,7 +81,7 @@ export function CommandPalette() {
hint: "材料 · 从岗位生成",
keywords: "optimize 优化 定制 简历 tailor",
icon: Sparkles,
- run: go("/optimize"),
+ run: go(tailorResumeHref()),
},
];
}, [router, close, setRailMode, setRailOpen]);
diff --git a/frontend/src/vite/OfferURoutes.resume.test.tsx b/frontend/src/vite/OfferURoutes.resume.test.tsx
new file mode 100644
index 00000000..16957075
--- /dev/null
+++ b/frontend/src/vite/OfferURoutes.resume.test.tsx
@@ -0,0 +1,46 @@
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("@/app/resume/page", () => ({ default: () => 简历列表
}));
+vi.mock("@/app/optimize/page", () => ({ default: () => 岗位定制内容
}));
+vi.mock("@/app/studio/page", () => ({ default: () => 版式内容
}));
+
+import ResumeHub from "@/app/resume/ResumeHub";
+import { OfferURoutes } from "./OfferURoutes";
+
+function Where() {
+ const location = useLocation();
+ return ;
+}
+
+function renderAt(path: string) {
+ return render(
+
+
+ } />
+ ,
+ );
+}
+
+describe("简历三入口合一", () => {
+ it("旧 /optimize 链接带着岗位参数进入「岗位定制」", async () => {
+ renderAt("/optimize?job_ids=9");
+ expect(await screen.findByText("岗位定制内容")).toBeInTheDocument();
+ expect(screen.getByTestId("where")).toHaveTextContent("/resume?job_ids=9&mode=tailor");
+ });
+
+ it("旧 /studio 链接进入「版式」", async () => {
+ renderAt("/studio");
+ expect(await screen.findByText("版式内容")).toBeInTheDocument();
+ expect(screen.getByTestId("where")).toHaveTextContent("/resume?mode=layout");
+ });
+
+ it("三个模式都在同一排标签里,当前模式高亮", async () => {
+ render();
+ expect(await screen.findByText("岗位定制内容")).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "岗位定制" })).toHaveAttribute("aria-current", "page");
+ expect(screen.getByRole("link", { name: "我的简历" })).toHaveAttribute("href", "/resume");
+ expect(screen.getByRole("link", { name: "版式" })).toHaveAttribute("href", "/resume?mode=layout");
+ });
+});
diff --git a/frontend/src/vite/OfferURoutes.tsx b/frontend/src/vite/OfferURoutes.tsx
index 92b037f1..1a7d9d6e 100644
--- a/frontend/src/vite/OfferURoutes.tsx
+++ b/frontend/src/vite/OfferURoutes.tsx
@@ -1,12 +1,12 @@
import { lazy, Suspense } from "react";
-import { Navigate, Route, Routes } from "react-router-dom";
+import { Navigate, Route, Routes, useLocation } from "react-router-dom";
+import { resumeModeHref, type ResumeMode } from "@/app/resume/resumeModes";
const TodayPage = lazy(() => import("@/app/page"));
const JobsPage = lazy(() => import("@/app/jobs/page"));
const JobDetailPage = lazy(() => import("@/app/jobs/[id]/page"));
-const ResumePage = lazy(() => import("@/app/resume/page"));
+const ResumeHub = lazy(() => import("@/app/resume/ResumeHub"));
const ResumeDetailPage = lazy(() => import("@/app/resume/[id]/page"));
-const OptimizePage = lazy(() => import("@/app/optimize/page"));
const ApplicationsPage = lazy(() => import("@/app/applications/page"));
const InterviewPage = lazy(() => import("@/app/interview/page"));
const AIInterviewPage = lazy(() => import("@/app/interview/ai/page"));
@@ -15,7 +15,6 @@ const CalendarPage = lazy(() => import("@/app/calendar/page"));
const EmailPage = lazy(() => import("@/app/email/page"));
const ProfilePage = lazy(() => import("@/app/profile/page"));
const SettingsPage = lazy(() => import("@/app/settings/page"));
-const StudioPage = lazy(() => import("@/app/studio/page"));
function RouteFallback() {
return (
@@ -25,6 +24,12 @@ function RouteFallback() {
);
}
+/** 旧入口 /optimize、/studio 合并进「简历」后的重定向,保留 job_ids 等参数。 */
+function LegacyResumeRedirect({ mode }: { mode: ResumeMode }) {
+ const { search } = useLocation();
+ return ;
+}
+
export function OfferURoutes() {
return (
}>
@@ -32,9 +37,9 @@ export function OfferURoutes() {
} />
} />
} />
- } />
+ } />
} />
- } />
+ } />
} />
} />
} />
@@ -43,7 +48,7 @@ export function OfferURoutes() {
} />
} />
} />
- } />
+ } />
} />
} />
} />