From 6f126ed0d9bac63c5fc602ecc86e9e3bc417cf8c Mon Sep 17 00:00:00 2001 From: avabbbb <89175608+avabbbb@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:28:48 +0800 Subject: [PATCH] feat(resume): unify resume entries, quiet hints, and no-deadlock fixes --- frontend/src/app/email/page.tsx | 7 +- frontend/src/app/jobs/[id]/page.tsx | 3 +- frontend/src/app/jobs/page.tsx | 3 +- frontend/src/app/optimize/page.tsx | 4 +- frontend/src/app/page.tsx | 20 ++-- frontend/src/app/resume/ResumeHub.tsx | 65 +++++++++++ frontend/src/app/resume/[id]/page.tsx | 4 +- frontend/src/app/resume/page.tsx | 2 +- frontend/src/app/resume/resumeModes.test.ts | 21 ++++ frontend/src/app/resume/resumeModes.ts | 29 +++++ frontend/src/app/settings/page.tsx | 2 + frontend/src/app/studio/page.tsx | 49 +++++---- frontend/src/components/hints/HintsToggle.tsx | 28 +++++ .../src/components/hints/QuietHint.test.tsx | 51 +++++++++ frontend/src/components/hints/QuietHint.tsx | 103 ++++++++++++++++++ frontend/src/components/hints/hintPrefs.ts | 43 ++++++++ frontend/src/components/hints/index.ts | 3 + .../components/jobs/JobNextStepCard.test.tsx | 4 +- .../src/components/jobs/JobNextStepCard.tsx | 5 +- frontend/src/components/layout/Sidebar.tsx | 6 +- .../components/workbench/CommandPalette.tsx | 3 +- .../src/vite/OfferURoutes.resume.test.tsx | 46 ++++++++ frontend/src/vite/OfferURoutes.tsx | 19 ++-- 23 files changed, 464 insertions(+), 56 deletions(-) create mode 100644 frontend/src/app/resume/ResumeHub.tsx create mode 100644 frontend/src/app/resume/resumeModes.test.ts create mode 100644 frontend/src/app/resume/resumeModes.ts create mode 100644 frontend/src/components/hints/HintsToggle.tsx create mode 100644 frontend/src/components/hints/QuietHint.test.tsx create mode 100644 frontend/src/components/hints/QuietHint.tsx create mode 100644 frontend/src/components/hints/hintPrefs.ts create mode 100644 frontend/src/components/hints/index.ts create mode 100644 frontend/src/vite/OfferURoutes.resume.test.tsx diff --git a/frontend/src/app/email/page.tsx b/frontend/src/app/email/page.tsx index a5cb542c..128d4b87 100644 --- a/frontend/src/app/email/page.tsx +++ b/frontend/src/app/email/page.tsx @@ -365,8 +365,8 @@ export default function EmailPage() { ) : null} - + {/* 弹窗任何时候都能关:连接过程中关闭只是收起窗口,不会让用户被困在这里。 */} + {(onClose) => <> 连接求职邮箱 @@ -376,6 +376,9 @@ export default function EmailPage() { } + {emailConnectionBusy && !isOpen && ( +

邮箱正在后台连接,完成后状态会自动更新。

+ )} {isConnected && 查看并确认求职进展} {notifications && notifications.length > 0 ? ( diff --git a/frontend/src/app/jobs/[id]/page.tsx b/frontend/src/app/jobs/[id]/page.tsx index c0ea0041..9d39ba4b 100644 --- a/frontend/src/app/jobs/[id]/page.tsx +++ b/frontend/src/app/jobs/[id]/page.tsx @@ -58,6 +58,7 @@ import { } from "@/lib/bauhaus"; import { safeClientErrorMessage } from "@/lib/safe-error"; import { ExternalUrlLink, openExternalUrl } from "@/components/ExternalUrlLink"; +import { tailorResumeHref } from "@/app/resume/resumeModes"; const MANUAL_DECISION_STAGES = new Set([ "needs_research", @@ -1209,7 +1210,7 @@ export default function JobDetailPage() {

} + {retryable && } ; } if (!cancelable && !retryableStatus && !terminalFailure) return null; @@ -245,7 +247,7 @@ function AutomationTaskControls({ type="button" aria-label={action === "cancel" ? "取消后台任务" : "重试后台任务"} title={action === "cancel" ? "取消任务" : "重试任务"} - disabled={busy !== null} + disabled={busy.some((key) => key.startsWith(`${taskId}:`))} onClick={() => onAction(taskId, action)} className={`inline-flex h-7 items-center gap-1 rounded-md border px-2 text-[11px] font-medium transition-colors duration-[var(--dur-quick)] disabled:cursor-wait disabled:opacity-60 ${ action === "cancel" @@ -253,7 +255,7 @@ function AutomationTaskControls({ : "border-[var(--primary-red)]/35 text-[var(--primary-red)] hover:bg-[var(--primary-red)]/8" }`} > - {busy === actionKey ? ( + {busy.includes(actionKey) ? ( ) : action === "cancel" ? ( @@ -455,12 +457,14 @@ export default function TodayPage() { [careerTasks, taskIdsInInbox], ); const visibleAutomationCount = pendingAutomation.length + standaloneCareerTasks.length; - const [taskAction, setTaskAction] = useState(null); + // 每个任务各自的进行中状态:取消 A 时不应锁住 B 的按钮。 + const [taskAction, setTaskAction] = useState([]); const [taskActionError, setTaskActionError] = useState(null); const handleCareerTaskAction = async (taskId: string, action: "cancel" | "retry") => { const actionKey = `${taskId}:${action}`; - setTaskAction(actionKey); + if (taskAction.some((key) => key.startsWith(`${taskId}:`))) return; + setTaskAction((current) => [...current, actionKey]); setTaskActionError(null); try { await controlCareerTask(taskId, action); @@ -468,7 +472,7 @@ export default function TodayPage() { } catch (error) { setTaskActionError(safeClientErrorMessage(error, "任务操作失败,请稍后重试")); } finally { - setTaskAction(null); + setTaskAction((current) => current.filter((key) => key !== actionKey)); } }; const pipelineRecords = useMemo( @@ -1373,7 +1377,7 @@ export default function TodayPage() { {recentJobs.length > 0 && (
- + 为选中岗位定制简历 diff --git a/frontend/src/app/resume/ResumeHub.tsx b/frontend/src/app/resume/ResumeHub.tsx new file mode 100644 index 00000000..424666a2 --- /dev/null +++ b/frontend/src/app/resume/ResumeHub.tsx @@ -0,0 +1,65 @@ +"use client"; + +// ============================================= +// 简历 — 唯一入口 +// 原来的「简历」「简历定制」「工作室」三个侧边栏入口合并为一个子功能: +// 我的简历(list) · 岗位定制(tailor) · 版式(layout) +// 模式写在 URL 上(?mode=),刷新、后退、分享链接都能回到同一处; +// 切换模式从不打断别处正在跑的任务,也不需要确认。 +// ============================================= + +import { lazy, Suspense } from "react"; +import { Link as RouterLink, useLocation } from "react-router-dom"; +import { QuietHint } from "@/components/hints"; +import { RESUME_MODES, parseResumeMode, resumeModeHref } from "./resumeModes"; + +const ResumesListPage = lazy(() => import("./page")); +const OptimizePage = lazy(() => import("@/app/optimize/page")); +const StudioPage = lazy(() => import("@/app/studio/page")); + +function ModeFallback() { + return ( +
+

正在打开…

+
+ ); +} + +export default function ResumeHub() { + const location = useLocation(); + const params = new URLSearchParams(location.search); + const mode = parseResumeMode(params.get("mode")); + + return ( +
+ + + }> + {mode === "tailor" ? : mode === "layout" ? : } + +
+ ); +} diff --git a/frontend/src/app/resume/[id]/page.tsx b/frontend/src/app/resume/[id]/page.tsx index 5a190750..92301a62 100644 --- a/frontend/src/app/resume/[id]/page.tsx +++ b/frontend/src/app/resume/[id]/page.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useParams, useRouter } from "next/navigation"; +import Link from "next/link"; import { ArrowLeft, Check, @@ -321,6 +322,7 @@ function ProposalCard({

AI 建议 · {groups.length} 组

按结构与段落比较岗位版本;每组一次采用,仍可单独修改。

事实门:{proposal.fact_gate_status === "passed" ? "通过" : proposal.fact_gate_status}
+ {factGateBlocked &&

有改写缺少档案证据,暂时不能采用。去档案补充证据或先「拒绝」这些条目,其它条目不受影响。

} {changes.length === 0 &&
当前提案没有需要审核的变化。
} {groups.map(([key, group]) => { @@ -328,7 +330,7 @@ function ProposalCard({ return

段落 · {EDITOR_SECTION_TYPES.find(([type]) => type === key)?.[1] || group[0].before?.title || group[0].after?.title || group[0].title || key}

- {ids.length > 0 &&
} + {ids.length > 0 &&
}
{group.map((change) => { const id = String(change.change_id || ""); diff --git a/frontend/src/app/resume/page.tsx b/frontend/src/app/resume/page.tsx index 7d21beff..e5dbe7b6 100644 --- a/frontend/src/app/resume/page.tsx +++ b/frontend/src/app/resume/page.tsx @@ -275,7 +275,7 @@ export default function ResumesListPage() {

简历

- {resumes?.length ? `${resumes.length} 份简历。` : ""}通用版本在这里维护;针对某个岗位的版本从岗位详情或「简历定制」生成。 + {resumes?.length ? `${resumes.length} 份简历。` : ""}通用版本在这里维护;针对某个岗位的版本在「岗位定制」里生成。

diff --git a/frontend/src/app/resume/resumeModes.test.ts b/frontend/src/app/resume/resumeModes.test.ts new file mode 100644 index 00000000..615b3feb --- /dev/null +++ b/frontend/src/app/resume/resumeModes.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { parseResumeMode, resumeModeHref, tailorResumeHref } from "./resumeModes"; + +describe("简历模式地址", () => { + it("未知模式回落到「我的简历」", () => { + expect(parseResumeMode(null)).toBe("list"); + expect(parseResumeMode("studio")).toBe("list"); + expect(parseResumeMode("tailor")).toBe("tailor"); + }); + + it("旧 /optimize 的 job_ids 在岗位定制里保留,切到其它模式时丢弃", () => { + expect(resumeModeHref("tailor", "?job_ids=3,4")).toBe("/resume?job_ids=3%2C4&mode=tailor"); + expect(resumeModeHref("layout", "?job_ids=3")).toBe("/resume?mode=layout"); + expect(resumeModeHref("list", "?mode=layout")).toBe("/resume"); + }); + + it("岗位定制直达地址", () => { + expect(tailorResumeHref()).toBe("/resume?mode=tailor"); + expect(tailorResumeHref([9, 12])).toBe("/resume?mode=tailor&job_ids=9,12"); + }); +}); diff --git a/frontend/src/app/resume/resumeModes.ts b/frontend/src/app/resume/resumeModes.ts new file mode 100644 index 00000000..01fc5541 --- /dev/null +++ b/frontend/src/app/resume/resumeModes.ts @@ -0,0 +1,29 @@ +// 简历子功能的三种模式。/optimize 与 /studio 旧入口都重定向到这里, +// 这样外部书签、岗位页里的旧链接都不会坏。 +export type ResumeMode = "list" | "tailor" | "layout"; + +export const RESUME_MODES: Array<{ id: ResumeMode; label: string; hint: string }> = [ + { id: "list", label: "我的简历", hint: "所有简历版本都在这里。点开任意一份,直接在纸面上修改。" }, + { id: "tailor", label: "岗位定制", hint: "选一个岗位,AI 只用你档案里已确认的事实提出修改,你逐条决定,原稿不会被覆盖。" }, + { id: "layout", label: "版式", hint: "换模板、字体和主题色,生成可分享的 HTML 简历。不改内容。" }, +]; + +export function parseResumeMode(value: string | null | undefined): ResumeMode { + return value === "tailor" || value === "layout" ? value : "list"; +} + +/** 生成某个模式的地址,保留其它查询参数(如 job_ids)。 */ +export function resumeModeHref(mode: ResumeMode, search: string | URLSearchParams = ""): string { + const params = new URLSearchParams(search); + if (mode === "list") params.delete("mode"); + else params.set("mode", mode); + if (mode !== "tailor") params.delete("job_ids"); + const query = params.toString(); + return query ? `/resume?${query}` : "/resume"; +} + +/** 岗位定制的直达地址:从岗位页、Today、命令面板跳转都用它。 */ +export function tailorResumeHref(jobIds: Array = []): string { + const ids = jobIds.map(String).filter(Boolean); + return ids.length ? `/resume?mode=tailor&job_ids=${ids.join(",")}` : "/resume?mode=tailor"; +} diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index 0362b401..354c3647 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -55,6 +55,7 @@ import { SHOWCASE } from "@/lib/showcase/router"; import { useConfig, updateConfig } from "@/lib/hooks"; import { AgentConnectionPanel } from "@/components/workbench/AgentConnectionPanel"; import { JobSourceConnectionsCard } from "@/components/settings/JobSourceConnectionsCard"; +import { HintsToggle } from "@/components/hints"; interface ProviderPreset { id: string; @@ -1348,6 +1349,7 @@ export default function SettingsPage() { +
diff --git a/frontend/src/app/studio/page.tsx b/frontend/src/app/studio/page.tsx index 332ec1db..04ddc016 100644 --- a/frontend/src/app/studio/page.tsx +++ b/frontend/src/app/studio/page.tsx @@ -22,7 +22,8 @@ interface Template { // 与 template_seeder 的 design_tokens 键名一致,后端把 overrides 合并进 design_tokens。 const DESIGN_FONTS = ["Inter", "Poppins", "JetBrains Mono"]; -const DEFAULT_PRIMARY_COLOR = "#2563eb"; +// 默认用 Kami 的油墨蓝:整套简历只保留这一个强调色。 +const DEFAULT_PRIMARY_COLOR = "#1B365D"; export default function StudioPage() { const [templates, setTemplates] = useState([]); @@ -109,37 +110,37 @@ export default function StudioPage() { }; return ( -
-
-

HTML 简历工作室

-

选择模板,AI 一键生成可视化简历

-
+
+
+

版式

+

选一个模板,调主题色和字体,生成可分享的 HTML 简历。只改样子,不改内容。

+
{/* 宽屏三栏;中小屏按模板 → 预览 → 设计控制顺序重排。 */}
{/* 左侧:模板列表 */}
-

选择模板

+

1 · 选择模板

{templatesError && ( -

+

模板加载失败:{templatesError}

)} {!templatesError && templates.length === 0 && ( -

暂无可用模板

+

暂无可用模板。确认后端已启动后刷新本页。

)} {templates.map(tpl => ( setSelectedTemplate(tpl.id)} >
{SHOWCASE || previewFailures[tpl.id] ? ( // 展示模式或资源不可用时保留可操作的品牌占位态 -
+
{tpl.display_name} {previewFailures[tpl.id] && 预览图暂不可用} @@ -153,8 +154,8 @@ export default function StudioPage() { onError={() => setPreviewFailures((current) => ({ ...current, [tpl.id]: true }))} /> )} -
{tpl.display_name}
-
{tpl.category}
+
{tpl.display_name}
+
{tpl.category}
))} @@ -162,7 +163,7 @@ export default function StudioPage() { {/* 中间:预览区 */}
-
+
{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 · 调整样式

+
- +
setEnabled(event.target.checked)} + className="h-4 w-4 accent-[#1B365D]" + /> + 显示提示 + +
+
+ ); +} 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 {location.pathname + location.search}; +} + +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() { } /> } /> } /> - } /> + } /> } /> } /> } />