Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions frontend/src/app/email/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -365,8 +365,8 @@ export default function EmailPage() {
</motion.section>
) : null}

<Modal isOpen={isOpen} onOpenChange={onOpenChange} placement="center"
isDismissable={!emailConnectionBusy} isKeyboardDismissDisabled={emailConnectionBusy} hideCloseButton={emailConnectionBusy}>
{/* 弹窗任何时候都能关:连接过程中关闭只是收起窗口,不会让用户被困在这里。 */}
<Modal isOpen={isOpen} onOpenChange={onOpenChange} placement="center">
<ModalContent className={bauhausModalContentClassName}>
{(onClose) => <>
<ModalHeader>连接求职邮箱</ModalHeader>
Expand All @@ -376,6 +376,9 @@ export default function EmailPage() {
</>}
</ModalContent>
</Modal>
{emailConnectionBusy && !isOpen && (
<p role="status" className="text-xs text-[var(--foreground-muted)]">邮箱正在后台连接,完成后状态会自动更新。</p>
)}
{isConnected && <Link href="/applications" className="inline-block text-sm font-semibold underline">查看并确认求职进展</Link>}

{notifications && notifications.length > 0 ? (
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/app/jobs/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -1209,7 +1210,7 @@ export default function JobDetailPage() {
</p>
<Button
as={Link}
href={`/optimize?job_ids=${job.id}`}
href={tailorResumeHref([job.id])}
className="bauhaus-button bauhaus-button-blue mt-4 !px-4 !py-3 !text-[11px]"
>
进入简历提案
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/app/jobs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
type Pool,
} from "@/lib/hooks";
import { safeClientErrorMessage } from "@/lib/safe-error";
import { tailorResumeHref } from "@/app/resume/resumeModes";

const container = {
hidden: { opacity: 0 },
Expand Down Expand Up @@ -408,7 +409,7 @@ export default function JobsPage() {
const goOptimizeWithSelection = useCallback(() => {
if (selectedIds.size === 0) return;
const jobIds = Array.from(selectedIds).sort((a, b) => a - b);
router.push(`/optimize?job_ids=${jobIds.join(",")}`);
router.push(tailorResumeHref(jobIds));
}, [router, selectedIds]);

const refreshAfterMutation = useCallback(async () => {
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/app/optimize/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

// =============================================
// 简历定制 — 材料阶段内的流程步骤 (ADR 0033)
// 岗位定制 — 「简历」子功能的 tailor 模式(原 /optimize,ADR 0033)
// 收敛为紧凑工作区:去营销 Hero,规则一句话说明,直接进入定制流程。
// =============================================

Expand Down Expand Up @@ -35,7 +35,7 @@ export default function OptimizePage() {
>
<header className="flex flex-wrap items-end justify-between gap-3 px-1">
<div>
<h1 className="text-[22px] font-semibold tracking-tight text-[var(--foreground)]">简历定制</h1>
<h1 className="text-[22px] font-semibold tracking-tight text-[var(--foreground)]">岗位定制</h1>
<p className="mt-1 text-[13px] text-[var(--foreground-muted)]">选一个岗位,AI 用你档案里已确认的事实提出修改,你逐条决定。</p>
</div>
<Link href="/profile" className="text-[13px] font-medium text-[var(--foreground-muted)] underline-offset-4 hover:text-[var(--foreground)] hover:underline">
Expand Down
20 changes: 12 additions & 8 deletions frontend/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import { readDeliveries, matchDeliveryForAction } from "@/components/career/deli
import { ResumeReengagementCard } from "@/components/career/ResumeReengagementCard";

import { request } from "@/lib/api";
import { tailorResumeHref } from "@/app/resume/resumeModes";

type SignalNotification = Notification & { acknowledged_at?: string | null };

Expand Down Expand Up @@ -210,7 +211,8 @@ function AutomationTaskControls({
taskId: string;
status: string;
retryable: boolean;
busy: string | null;
/** 正在进行中的操作键(taskId:action)。只锁对应那一个任务,其它任务照常可点。 */
busy: ReadonlyArray<string>;
onAction: (taskId: string, action: "cancel" | "retry") => void;
error?: string;
}) {
Expand All @@ -221,7 +223,7 @@ function AutomationTaskControls({
if ((status === "failed" || status === "blocked") && agentRecovery(error).configure) {
return <div className="flex flex-wrap items-center gap-2">
<Link href={MODEL_SETTINGS_ROUTE} className="text-[11px] font-medium text-[var(--primary-red)] underline underline-offset-4">检查模型连接</Link>
{retryable && <button type="button" disabled={busy !== null} onClick={() => onAction(taskId, "retry")} className="rounded border border-[var(--border)] px-2 py-1 text-[11px] disabled:opacity-50">配置后重试</button>}
{retryable && <button type="button" disabled={busy.some((key) => key.startsWith(`${taskId}:`))} onClick={() => onAction(taskId, "retry")} className="rounded border border-[var(--border)] px-2 py-1 text-[11px] disabled:opacity-50">配置后重试</button>}
</div>;
}
if (!cancelable && !retryableStatus && !terminalFailure) return null;
Expand All @@ -245,15 +247,15 @@ 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"
? "border-[var(--border)] text-[var(--foreground-muted)] hover:border-[var(--border-strong)] hover:text-[var(--foreground)]"
: "border-[var(--primary-red)]/35 text-[var(--primary-red)] hover:bg-[var(--primary-red)]/8"
}`}
>
{busy === actionKey ? (
{busy.includes(actionKey) ? (
<LoaderCircle size={12} className="animate-spin" />
) : action === "cancel" ? (
<X size={12} />
Expand Down Expand Up @@ -455,20 +457,22 @@ export default function TodayPage() {
[careerTasks, taskIdsInInbox],
);
const visibleAutomationCount = pendingAutomation.length + standaloneCareerTasks.length;
const [taskAction, setTaskAction] = useState<string | null>(null);
// 每个任务各自的进行中状态:取消 A 时不应锁住 B 的按钮。
const [taskAction, setTaskAction] = useState<string[]>([]);
const [taskActionError, setTaskActionError] = useState<string | null>(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);
await Promise.all([mutateCareerTasks(), mutateAutomationInbox()]);
} catch (error) {
setTaskActionError(safeClientErrorMessage(error, "任务操作失败,请稍后重试"));
} finally {
setTaskAction(null);
setTaskAction((current) => current.filter((key) => key !== actionKey));
}
};
const pipelineRecords = useMemo(
Expand Down Expand Up @@ -1373,7 +1377,7 @@ export default function TodayPage() {
</div>
{recentJobs.length > 0 && (
<div className="mt-2 flex gap-2 px-1">
<Link href="/optimize" className="bauhaus-button bauhaus-button-sm bauhaus-button-outline">
<Link href={tailorResumeHref()} className="bauhaus-button bauhaus-button-sm bauhaus-button-outline">
<Sparkles size={12} />
为选中岗位定制简历
</Link>
Expand Down
65 changes: 65 additions & 0 deletions frontend/src/app/resume/ResumeHub.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="grid min-h-[30vh] place-items-center">
<p className="text-sm text-[var(--foreground-muted)]">正在打开…</p>
</div>
);
}

export default function ResumeHub() {
const location = useLocation();
const params = new URLSearchParams(location.search);
const mode = parseResumeMode(params.get("mode"));

return (
<div className="resume-hub space-y-5" data-testid="resume-hub" data-mode={mode}>
<nav
aria-label="简历模式"
className="flex flex-wrap items-center gap-1 border-b border-[#e8e6dc] px-1"
>
{RESUME_MODES.map((item) => {
const active = item.id === mode;
return (
<span key={item.id} className="inline-flex items-center gap-1.5">
<RouterLink
to={resumeModeHref(item.id, location.search)}
aria-current={active ? "page" : undefined}
className={`-mb-px border-b-2 px-3 py-2 text-[13px] transition-colors ${
active
? "border-[#1B365D] font-semibold text-[#1B365D]"
: "border-transparent text-[var(--foreground-muted)] hover:text-[var(--foreground)]"
}`}
>
{item.label}
</RouterLink>
<QuietHint label={`${item.label}是做什么的?`}>{item.hint}</QuietHint>
</span>
);
})}
</nav>

<Suspense fallback={<ModeFallback />}>
{mode === "tailor" ? <OptimizePage /> : mode === "layout" ? <StudioPage /> : <ResumesListPage />}
</Suspense>
</div>
);
}
4 changes: 3 additions & 1 deletion frontend/src/app/resume/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -321,14 +322,15 @@ function ProposalCard({
<div><p className="text-xs font-black text-violet-950">AI 建议 · {groups.length} 组</p><p className="mt-1 text-[11px] text-violet-800">按结构与段落比较岗位版本;每组一次采用,仍可单独修改。</p></div>
<Badge tone={proposal.fact_gate_status === "passed" ? "green" : "orange"}>事实门:{proposal.fact_gate_status === "passed" ? "通过" : proposal.fact_gate_status}</Badge>
</div>
{factGateBlocked && <p className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-amber-900" data-testid="fact-gate-way-out">有改写缺少档案证据,暂时不能采用。<Link href="/profile" className="font-bold underline underline-offset-4">去档案补充证据</Link><span className="text-[var(--foreground-muted)]">或先「拒绝」这些条目,其它条目不受影响。</span></p>}
</div>
{changes.length === 0 && <div className="rounded-xl border border-dashed border-[var(--border-strong)]/20 p-4 text-xs text-[var(--foreground-muted)]">当前提案没有需要审核的变化。</div>}
{groups.map(([key, group]) => {
const ids = group.filter((change) => !reviews[String(change.change_id)]).map((change) => String(change.change_id));
return <section key={key} className="space-y-2" data-testid="resume-proposal-group">
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-violet-50 p-2">
<p className="text-xs font-black">段落 · {EDITOR_SECTION_TYPES.find(([type]) => type === key)?.[1] || group[0].before?.title || group[0].after?.title || group[0].title || key}</p>
{ids.length > 0 && <div className="flex gap-2"><button type="button" disabled={!!pending || factGateBlocked || ids.some((id) => !!edited[id])} onClick={() => onGroupAction(ids, "accept")} className="rounded bg-black px-2 py-1 text-[11px] text-white disabled:opacity-50">采用本段</button><button type="button" disabled={!!pending} onClick={() => onGroupAction(ids, "reject")} className="rounded border px-2 py-1 text-[11px] disabled:opacity-50">保留本段原文</button></div>}
{ids.length > 0 && <div className="flex gap-2"><button type="button" disabled={!!pending || factGateBlocked || ids.some((id) => !!edited[id])} title={factGateBlocked ? "事实门未通过,请先补充证据" : ids.some((id) => !!edited[id]) ? "本段有你改过的条目,请逐条接受" : pending ? "上一条正在保存…" : undefined} onClick={() => onGroupAction(ids, "accept")} className="rounded bg-black px-2 py-1 text-[11px] text-white disabled:opacity-50">采用本段</button><button type="button" disabled={!!pending} onClick={() => onGroupAction(ids, "reject")} className="rounded border px-2 py-1 text-[11px] disabled:opacity-50">保留本段原文</button></div>}
</div>
{group.map((change) => {
const id = String(change.change_id || "");
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/resume/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ export default function ResumesListPage() {
<div>
<h1 className="text-[22px] font-semibold tracking-tight text-[var(--foreground)]">简历</h1>
<p className="mt-1 text-[13px] text-[var(--foreground-muted)]">
{resumes?.length ? `${resumes.length} 份简历。` : ""}通用版本在这里维护;针对某个岗位的版本从岗位详情或「简历定制」生成。
{resumes?.length ? `${resumes.length} 份简历。` : ""}通用版本在这里维护;针对某个岗位的版本在「岗位定制」里生成。
</p>
</div>
<div className="flex flex-wrap gap-2">
Expand Down
21 changes: 21 additions & 0 deletions frontend/src/app/resume/resumeModes.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
29 changes: 29 additions & 0 deletions frontend/src/app/resume/resumeModes.ts
Original file line number Diff line number Diff line change
@@ -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<number | string> = []): string {
const ids = jobIds.map(String).filter(Boolean);
return ids.length ? `/resume?mode=tailor&job_ids=${ids.join(",")}` : "/resume?mode=tailor";
}
2 changes: 2 additions & 0 deletions frontend/src/app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1348,6 +1349,7 @@ export default function SettingsPage() {

<AgentConnectionPanel />
<JobSourceConnectionsCard />
<HintsToggle />


<section id="model-settings" ref={modelSectionRef} className="scroll-mt-6 rounded-xl border border-[var(--border)] bg-[var(--surface)]" data-testid="embedded-model-settings">
Expand Down
Loading
Loading