onRetryJob(job)} className="h-7 w-full rounded px-2 text-xs leading-none">
重试
+ ) : job.deliveryStatus === "AI分析失败" ? (
+
) : (job.deliveryStatus || "").includes("已投递") ? (
@@ -228,7 +246,14 @@ export function BossJobTable({
)}
-
+ |
+ onOpenText("岗位名称", job.jobName)}>{job.jobName || "-"}
+ {job.scanResultSource === "HISTORICAL_REUSED" ? (
+
+ 历史结果
+
+ ) : null}
+ |
@@ -256,7 +281,7 @@ export function BossJobTable({
- onOpenText("AI原因", job.aiReason)}>{job.aiReason || "-"}
+ onOpenText("AI分析详情", formatAiReasonDetail(job.aiReason))}>{aiReason.summary}
|
{job.priorityCompany ? "是" : "-"}
diff --git a/front/app/boss/analysis/components/BossPendingCards.tsx b/front/app/boss/analysis/components/BossPendingCards.tsx
index 0c54ba5..cf2385e 100644
--- a/front/app/boss/analysis/components/BossPendingCards.tsx
+++ b/front/app/boss/analysis/components/BossPendingCards.tsx
@@ -5,7 +5,7 @@ import { BiBlock, BiBriefcase, BiCheckCircle, BiChevronDown, BiChevronUp, BiFilt
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import type { BossJob } from "../types"
-import { riskTextOf } from "../utils"
+import { formatAiReasonDetail, parseAiReason, riskTextOf } from "../utils"
function PendingJobCard({
job,
@@ -30,6 +30,7 @@ function PendingJobCard({
}) {
const jobTitle = job.jobName || "未命名岗位"
const company = job.companyName || "未知公司"
+ const aiReason = parseAiReason(job.aiReason)
return (
@@ -72,17 +73,22 @@ function PendingJobCard({
@@ -94,7 +100,7 @@ function PendingJobCard({
>
最终沟通话术
- {job.greetingSource === "USER_EDITED" ? "人工编辑稿" : job.greetingSource === "AI_GREETING" ? "AI 原稿" : job.greetingSource === "PROFILE_DEFAULT" ? "档案默认" : "空白警告"}
+ {job.greetingSource === "USER_EDITED" ? "人工编辑稿" : job.greetingSource === "AI_GREETING" ? "岗位 JD 定制" : job.greetingSource === "PROFILE_DEFAULT" ? "AI 失败兜底(档案默认)" : "空白警告"}
{job.finalGreeting || "暂无可用话术,请先编辑后再确认"}
diff --git a/front/app/boss/analysis/components/BossThresholdSettings.test.tsx b/front/app/boss/analysis/components/BossThresholdSettings.test.tsx
new file mode 100644
index 0000000..8cd6df5
--- /dev/null
+++ b/front/app/boss/analysis/components/BossThresholdSettings.test.tsx
@@ -0,0 +1,67 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react"
+import { afterEach, describe, expect, it, vi } from "vitest"
+
+import { BossThresholdSettings } from "./BossThresholdSettings"
+
+const jsonResponse = (payload: unknown, status = 200) => new Response(JSON.stringify(payload), {
+ status,
+ headers: { "Content-Type": "application/json" },
+})
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+describe("Boss分数线历史岗位更新", () => {
+ it("保存后展示历史提升数量并通知分析页刷新", async () => {
+ const onApplied = vi.fn().mockResolvedValue(undefined)
+ const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
+ if (!init?.method) {
+ return Promise.resolve(jsonResponse({
+ success: true,
+ data: { applyThreshold: 75, priorityApplyThreshold: 65 },
+ }))
+ }
+ return Promise.resolve(jsonResponse({
+ success: true,
+ data: {
+ applyThreshold: 60,
+ priorityApplyThreshold: 60,
+ bossHistoricalPromotedCount: 58,
+ },
+ }))
+ })
+ vi.stubGlobal("fetch", fetchMock)
+ render()
+
+ const applyInput = await screen.findByLabelText("普通公司最低分")
+ fireEvent.change(applyInput, { target: { value: "60" } })
+ fireEvent.click(screen.getByRole("button", { name: "保存分数线" }))
+
+ expect(await screen.findByText(/58个历史岗位已改为“待确认”/)).toBeInTheDocument()
+ expect(onApplied).toHaveBeenCalledTimes(1)
+ const postCall = fetchMock.mock.calls.find((call) => call[1]?.method === "POST")
+ expect(postCall).toBeDefined()
+ expect(JSON.parse(String(postCall?.[1]?.body))).toEqual({
+ applyThreshold: 60,
+ priorityApplyThreshold: 60,
+ })
+ })
+
+ it("数据已保存但列表刷新失败时给出准确提示", async () => {
+ const onApplied = vi.fn().mockRejectedValue(new Error("refresh failed"))
+ vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL, init?: RequestInit) => Promise.resolve(jsonResponse({
+ success: true,
+ data: init?.method
+ ? { applyThreshold: 60, priorityApplyThreshold: 50, bossHistoricalPromotedCount: 3 }
+ : { applyThreshold: 60, priorityApplyThreshold: 50 },
+ }))))
+ render()
+
+ await screen.findByDisplayValue("60")
+ fireEvent.click(screen.getByRole("button", { name: "保存分数线" }))
+
+ expect(await screen.findByText(/3个历史岗位已改为“待确认”/)).toBeInTheDocument()
+ await waitFor(() => expect(screen.getByText(/列表刷新失败/)).toBeInTheDocument())
+ })
+})
diff --git a/front/app/boss/analysis/components/BossThresholdSettings.tsx b/front/app/boss/analysis/components/BossThresholdSettings.tsx
index ad9713b..68431f6 100644
--- a/front/app/boss/analysis/components/BossThresholdSettings.tsx
+++ b/front/app/boss/analysis/components/BossThresholdSettings.tsx
@@ -14,6 +14,11 @@ const DEFAULT_PRIORITY_APPLY_THRESHOLD = 65
type ThresholdConfig = {
applyThreshold: number
priorityApplyThreshold: number
+ bossHistoricalPromotedCount?: number
+}
+
+type BossThresholdSettingsProps = {
+ onApplied?: () => void | Promise
}
const parseThreshold = (value: unknown, fallback: number) => {
@@ -21,7 +26,7 @@ const parseThreshold = (value: unknown, fallback: number) => {
return Number.isInteger(parsed) && parsed >= 0 && parsed <= 100 ? parsed : fallback
}
-export function BossThresholdSettings() {
+export function BossThresholdSettings({ onApplied }: BossThresholdSettingsProps) {
const [thresholds, setThresholds] = useState({
applyThreshold: DEFAULT_APPLY_THRESHOLD,
priorityApplyThreshold: DEFAULT_PRIORITY_APPLY_THRESHOLD,
@@ -91,8 +96,18 @@ export function BossThresholdSettings() {
thresholds.priorityApplyThreshold,
),
}
+ const promotedCount = Math.max(0, Math.trunc(Number(result.data?.bossHistoricalPromotedCount) || 0))
setThresholds(savedThresholds)
- setMessage(`已保存:普通公司${savedThresholds.applyThreshold}分,优先公司${savedThresholds.priorityApplyThreshold}分`)
+ const successMessage = `已保存:普通公司${savedThresholds.applyThreshold}分,优先公司${savedThresholds.priorityApplyThreshold}分;${promotedCount}个历史岗位已改为“待确认”`
+ setMessage(successMessage)
+ if (onApplied) {
+ try {
+ await onApplied()
+ } catch (refreshError) {
+ console.error("分数线保存后刷新Boss岗位失败:", refreshError)
+ setError("分数线和历史岗位已经更新,但列表刷新失败,请点击“刷新”重新加载。")
+ }
+ }
} catch (saveError) {
console.error("保存AI投递分数线失败:", saveError)
setError(friendlyApiError(saveError, "分数线保存失败"))
@@ -164,7 +179,7 @@ export function BossThresholdSettings() {
- AI分数达到或超过对应分数线后进入“待确认”,仍需你确认才会实际投递。
+ 保存后,历史“AI不匹配”岗位也会按已有AI分数自动更新;进入“待确认”后仍需你确认才会实际投递。
{message ? {message} : null}
{error ? {error} : null}
diff --git a/front/app/boss/analysis/hooks/useBossAnalysisTasks.test.tsx b/front/app/boss/analysis/hooks/useBossAnalysisTasks.test.tsx
new file mode 100644
index 0000000..f706fe3
--- /dev/null
+++ b/front/app/boss/analysis/hooks/useBossAnalysisTasks.test.tsx
@@ -0,0 +1,84 @@
+import { act, renderHook } from "@testing-library/react"
+import { afterEach, describe, expect, it, vi } from "vitest"
+
+import { useBossAnalysisTasks } from "./useBossAnalysisTasks"
+
+function response(queueSize: number) {
+ return new Response(JSON.stringify({
+ success: true,
+ queueSize,
+ pendingCount: queueSize,
+ processingCount: 0,
+ data: queueSize > 0 ? [{
+ id: 9,
+ profileId: 1,
+ platform: "boss",
+ jobKey: "job-9",
+ jobRowId: 99,
+ status: "PENDING",
+ attemptCount: 0,
+ }] : [],
+ }), { status: 200, headers: { "Content-Type": "application/json" } })
+}
+
+describe("Boss AI任务轮询", () => {
+ afterEach(() => {
+ vi.useRealTimers()
+ vi.restoreAllMocks()
+ })
+
+ it("页面可见且有未完成任务时每3秒刷新,队列清空后停止", async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true })
+ const fetchMock = vi.fn()
+ .mockResolvedValueOnce(response(1))
+ .mockResolvedValueOnce(response(0))
+ vi.stubGlobal("fetch", fetchMock)
+
+ const { result } = renderHook(() => useBossAnalysisTasks())
+ await vi.waitFor(() => expect(result.current.queueSize).toBe(1))
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(3000)
+ })
+
+ await vi.waitFor(() => expect(result.current.queueSize).toBe(0))
+ expect(fetchMock).toHaveBeenCalledTimes(2)
+ expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining("platform=boss"))
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(6000)
+ })
+ expect(fetchMock).toHaveBeenCalledTimes(2)
+ })
+
+ it("页面隐藏时不启动定时轮询", async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true })
+ vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden")
+ const fetchMock = vi.fn().mockResolvedValue(response(1))
+ vi.stubGlobal("fetch", fetchMock)
+
+ const { result } = renderHook(() => useBossAnalysisTasks())
+ await vi.waitFor(() => expect(result.current.loading).toBe(false))
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(6000)
+ })
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ })
+
+ it("相同任务快照不会重复触发岗位列表刷新", async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true })
+ const fetchMock = vi.fn().mockResolvedValue(response(1))
+ vi.stubGlobal("fetch", fetchMock)
+
+ const { result } = renderHook(() => useBossAnalysisTasks())
+ await vi.waitFor(() => expect(result.current.queueSize).toBe(1))
+ expect(result.current.pollRevision).toBe(0)
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(6000)
+ })
+
+ expect(fetchMock).toHaveBeenCalledTimes(3)
+ expect(result.current.pollRevision).toBe(0)
+ })
+})
diff --git a/front/app/boss/analysis/hooks/useBossAnalysisTasks.ts b/front/app/boss/analysis/hooks/useBossAnalysisTasks.ts
new file mode 100644
index 0000000..694c6b7
--- /dev/null
+++ b/front/app/boss/analysis/hooks/useBossAnalysisTasks.ts
@@ -0,0 +1,128 @@
+"use client"
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react"
+
+import { API_BASE } from "@/lib/api"
+import type { JobAnalysisTask, JobAnalysisTasksResponse } from "../types"
+
+export function useBossAnalysisTasks() {
+ const [tasks, setTasks] = useState([])
+ const [queueSize, setQueueSize] = useState(0)
+ const [pendingCount, setPendingCount] = useState(0)
+ const [processingCount, setProcessingCount] = useState(0)
+ const [loading, setLoading] = useState(true)
+ const [retryingTaskId, setRetryingTaskId] = useState(null)
+ const [error, setError] = useState("")
+ const [visible, setVisible] = useState(true)
+ const [pollRevision, setPollRevision] = useState(0)
+ const requestInFlight = useRef | null>(null)
+ const snapshotSignature = useRef(null)
+
+ const loadTasks = useCallback((): Promise => {
+ if (requestInFlight.current) return requestInFlight.current
+ const request = (async () => {
+ try {
+ const response = await fetch(`${API_BASE}/api/ai/job-analysis/tasks?limit=200&platform=boss`)
+ const payload: JobAnalysisTasksResponse = await response.json()
+ if (!response.ok || !payload.success) {
+ throw new Error(payload.message || "AI分析任务读取失败")
+ }
+ const bossTasks = (payload.data || []).filter(
+ (task) => task.platform.toLowerCase() === "boss",
+ )
+ const nextSignature = JSON.stringify({
+ queueSize: payload.queueSize ?? 0,
+ pendingCount: payload.pendingCount ?? 0,
+ processingCount: payload.processingCount ?? 0,
+ tasks: bossTasks.map((task) => [task.id, task.status, task.updatedAt, task.lastError]),
+ })
+ setTasks(bossTasks)
+ setQueueSize(payload.queueSize ?? bossTasks.filter((task) => task.status === "PENDING" || task.status === "LEASED").length)
+ setPendingCount(payload.pendingCount ?? bossTasks.filter((task) => task.status === "PENDING").length)
+ setProcessingCount(payload.processingCount ?? bossTasks.filter((task) => task.status === "LEASED").length)
+ setError("")
+ if (snapshotSignature.current !== null && snapshotSignature.current !== nextSignature) {
+ setPollRevision((revision) => revision + 1)
+ }
+ snapshotSignature.current = nextSignature
+ } catch (loadError) {
+ setError(loadError instanceof Error ? loadError.message : "AI分析任务读取失败")
+ } finally {
+ setLoading(false)
+ }
+ })()
+ requestInFlight.current = request
+ void request.finally(() => {
+ if (requestInFlight.current === request) requestInFlight.current = null
+ })
+ return request
+ }, [])
+
+ useEffect(() => {
+ setVisible(document.visibilityState === "visible")
+ const onVisibilityChange = () => {
+ const nextVisible = document.visibilityState === "visible"
+ setVisible(nextVisible)
+ if (nextVisible) void loadTasks()
+ }
+ document.addEventListener("visibilitychange", onVisibilityChange)
+ return () => document.removeEventListener("visibilitychange", onVisibilityChange)
+ }, [loadTasks])
+
+ useEffect(() => {
+ void loadTasks()
+ }, [loadTasks])
+
+ useEffect(() => {
+ if (!visible || queueSize <= 0) return
+ const timer = window.setInterval(() => void loadTasks(), 3000)
+ return () => window.clearInterval(timer)
+ }, [loadTasks, queueSize, visible])
+
+ const taskByJobId = useMemo(() => {
+ const mapped = new Map()
+ tasks.forEach((task) => {
+ if (!mapped.has(task.jobRowId)) mapped.set(task.jobRowId, task)
+ })
+ return mapped
+ }, [tasks])
+
+ const retryTask = useCallback(async (task: JobAnalysisTask) => {
+ setRetryingTaskId(task.id)
+ try {
+ const confirmUnknown = task.status === "UNKNOWN"
+ const response = await fetch(
+ `${API_BASE}/api/ai/job-analysis/tasks/${task.id}/retry?confirmUnknown=${confirmUnknown}`,
+ { method: "POST" },
+ )
+ const payload: JobAnalysisTasksResponse = await response.json()
+ if (!response.ok || !payload.success) {
+ throw new Error(payload.message || "AI分析任务重试失败")
+ }
+ if (requestInFlight.current) await requestInFlight.current
+ await loadTasks()
+ return { success: true, message: payload.message || "AI分析任务已重新进入队列" }
+ } catch (retryError) {
+ return {
+ success: false,
+ message: retryError instanceof Error ? retryError.message : "AI分析任务重试失败",
+ }
+ } finally {
+ setRetryingTaskId(null)
+ }
+ }, [loadTasks])
+
+ return {
+ tasks,
+ taskByJobId,
+ queueSize,
+ pendingCount,
+ processingCount,
+ loading,
+ retryingTaskId,
+ error,
+ pollRevision,
+ loadTasks,
+ retryTask,
+ }
+}
diff --git a/front/app/boss/analysis/hooks/useBossDeliveryActions.ts b/front/app/boss/analysis/hooks/useBossDeliveryActions.ts
index 2cc2a5e..a6fca8a 100644
--- a/front/app/boss/analysis/hooks/useBossDeliveryActions.ts
+++ b/front/app/boss/analysis/hooks/useBossDeliveryActions.ts
@@ -9,6 +9,13 @@ import type { BossJob, FilterState } from "../types"
type ReservedTask = { id?: number; requestKey?: string }
type BatchPreviewItem = { id?: number; companyName?: string; jobName?: string; greeting?: string; greetingSource?: string; empty?: boolean }
+function greetingSourceLabel(source?: string) {
+ if (source === "USER_EDITED") return "人工编辑稿"
+ if (source === "AI_GREETING") return "岗位 JD 定制"
+ if (source === "PROFILE_DEFAULT") return "AI 失败兜底(档案默认)"
+ return "空白警告"
+}
+
function greetingSnapshots(items: BatchPreviewItem[]) {
return Object.fromEntries(items
.filter((item): item is BatchPreviewItem & { id: number; greeting: string } => (
@@ -51,10 +58,13 @@ function formatBatchDeliveryResult(result: Record) {
if (rows.length === 0) return summary
const details = rows.slice(0, 50).map((row, index) => {
const item = row && typeof row === "object"
- ? row as { id?: unknown; requestKey?: unknown; outcome?: unknown; evidence?: unknown; persisted?: unknown; message?: unknown }
+ ? row as { id?: unknown; requestKey?: unknown; outcome?: unknown; evidence?: unknown; greetingOutcome?: unknown; greetingEvidence?: unknown; skipped?: unknown; persisted?: unknown; message?: unknown }
: {}
const persisted = item.persisted === true ? "已落库" : "待补偿"
- return `${index + 1}. 岗位 ${String(item.id || "-")} · ${String(item.outcome || "UNKNOWN")} · ${persisted} · ${String(item.evidence || "-")}\n${String(item.message || "")}`
+ const action = item.skipped === true ? "未触达" : "已执行"
+ return `${index + 1}. 岗位 ${String(item.id || "-")} · ${String(item.outcome || "UNKNOWN")} · ${action} · ${persisted}\n`
+ + `平台证据:${String(item.evidence || "-")};话术结果:${String(item.greetingOutcome || "-")};话术证据:${String(item.greetingEvidence || "-")}\n`
+ + String(item.message || "")
})
return `${summary}\n\n逐条结果:\n${details.join("\n")}`
}
@@ -62,7 +72,7 @@ function formatBatchDeliveryResult(result: Record) {
function formatBatchGreetingPreview(items: BatchPreviewItem[], title: string) {
const lines = items.map((item, index) => (
`${index + 1}. ${item.companyName || "未知公司"} / ${item.jobName || "未命名岗位"}\n`
- + `来源:${item.greetingSource || "EMPTY"}\n${item.greeting || "【空白】"}`
+ + `来源:${greetingSourceLabel(item.greetingSource)}\n${item.greeting || "【空白】"}`
))
return `${title}\n\n将使用以下 ${items.length} 条沟通话术:\n\n${lines.join("\n\n")}\n\n确认后才会创建投递任务并交给 Chrome。`
}
@@ -73,6 +83,7 @@ async function loadBatchPreview(body: unknown) {
success: data.success !== false,
message: String(data.message || ""),
items: Array.isArray(data.items) ? data.items as BatchPreviewItem[] : [],
+ nativeGreetingDisabledConfirmed: data.nativeGreetingDisabledConfirmed === true,
}
}
@@ -86,6 +97,8 @@ async function markUnknownReservations(tasks: ReservedTask[], reason: string) {
requestKey: task.requestKey,
outcome: "UNKNOWN",
evidence: "NO_CONFIRMATION",
+ greetingOutcome: "UNKNOWN",
+ greetingEvidence: "FRONTEND_RESULT_UNAVAILABLE",
message: reason,
}),
})
@@ -179,7 +192,7 @@ export function useBossDeliveryActions({
const handleReconcileJob = useCallback(async (job: BossJob) => {
const answer = window.prompt(
- "请先在 Boss 平台核对该岗位。输入“已投递”确认成功,输入“未投递”确认失败;其他内容不会修改状态。",
+ "请先在 Boss 平台核对该岗位是否已存在沟通。输入“已投递”确认沟通存在,输入“未投递”确认失败;这里只核对沟通状态,不会把话术标成精确确认,也不会补发。",
)?.trim()
if (answer !== "已投递" && answer !== "未投递") return
try {
@@ -254,6 +267,10 @@ export function useBossDeliveryActions({
setActingBatch(true)
const body = currentBatchFilters()
const preview = await loadBatchPreview(body)
+ if (!preview.nativeGreetingDisabledConfirmed) {
+ openTextDialog("批量投递预览", "请先到 AI 配置页确认已关闭 BOSS 平台自带打招呼语。")
+ return
+ }
if (!preview.success || preview.items.length === 0) {
openTextDialog("批量投递预览", preview.message || "当前筛选条件下没有待确认岗位。")
return
@@ -303,6 +320,10 @@ export function useBossDeliveryActions({
scanRunId: activeScanRunId || undefined,
}
const preview = await loadBatchPreview(body)
+ if (!preview.nativeGreetingDisabledConfirmed) {
+ openTextDialog("AI推荐投递预览", "请先到 AI 配置页确认已关闭 BOSS 平台自带打招呼语。")
+ return
+ }
if (!preview.success || preview.items.length === 0) {
openTextDialog("AI推荐投递预览", preview.message || "当前没有 AI 推荐的待确认岗位。")
return
@@ -355,6 +376,10 @@ export function useBossDeliveryActions({
setActingManualBatch(true)
const body = { ids: uniqueIds, manualOverrideAiNotMatch: true }
const preview = await loadBatchPreview(body)
+ if (!preview.nativeGreetingDisabledConfirmed) {
+ openTextDialog("人工投递预览", "请先到 AI 配置页确认已关闭 BOSS 平台自带打招呼语。")
+ return false
+ }
if (!preview.success || preview.items.length === 0) {
openTextDialog("人工投递预览", preview.message || "所选岗位中没有可人工投递的AI不匹配岗位。")
return false
diff --git a/front/app/boss/analysis/hooks/useBossFilters.ts b/front/app/boss/analysis/hooks/useBossFilters.ts
index a4a18af..59df2da 100644
--- a/front/app/boss/analysis/hooks/useBossFilters.ts
+++ b/front/app/boss/analysis/hooks/useBossFilters.ts
@@ -5,7 +5,6 @@ import { useCallback, useMemo, useState } from "react"
import {
DEFAULT_PENDING_FILTERS,
EMPTY_FILTERS,
- LIST_COLLECTED_FILTERS,
type FilterState,
} from "../types"
@@ -66,11 +65,6 @@ export function useBossFilters() {
setFilters(DEFAULT_PENDING_FILTERS)
}, [])
- const showListCollectedFilters = useCallback(() => {
- setDraftFilters(LIST_COLLECTED_FILTERS)
- setFilters(LIST_COLLECTED_FILTERS)
- }, [])
-
return {
filters,
draftFilters,
@@ -83,6 +77,5 @@ export function useBossFilters() {
applyFilters,
resetFilters,
resetToPendingFilters,
- showListCollectedFilters,
}
}
diff --git a/front/app/boss/analysis/hooks/useBossJobs.ts b/front/app/boss/analysis/hooks/useBossJobs.ts
index d2ab79f..b38ede4 100644
--- a/front/app/boss/analysis/hooks/useBossJobs.ts
+++ b/front/app/boss/analysis/hooks/useBossJobs.ts
@@ -1,6 +1,6 @@
"use client"
-import { useCallback, useEffect, useMemo, useState } from "react"
+import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { API_BASE } from "@/lib/api"
import type { BossJob, FilterState, PagedResult } from "../types"
@@ -22,6 +22,7 @@ export function useBossJobs({
const [inputSize, setInputSize] = useState(20)
const [loadingList, setLoadingList] = useState(false)
const [reloading, setReloading] = useState(false)
+ const listRequestSequence = useRef(0)
const activeScanRunId = useMemo(
() => requestedScanRunId.trim(),
@@ -37,6 +38,7 @@ export function useBossJobs({
}, [size])
const loadList = useCallback(async (toPage = page, toSize = size) => {
+ const requestSequence = ++listRequestSequence.current
const params = buildFilterParams(filters, activeScanRunId)
params.set("page", String(toPage))
params.set("size", String(toSize))
@@ -45,6 +47,7 @@ export function useBossJobs({
setLoadingList(true)
const res = await fetch(`${API_BASE}/api/boss/list?${params.toString()}`)
const data: PagedResult = await res.json()
+ if (requestSequence !== listRequestSequence.current) return
const filteredItems = (data.items || []).filter((item) => {
if (!filters.filterHeadhunter) return true
const hrPosition = (item.hrPosition || "").toLowerCase()
@@ -55,9 +58,9 @@ export function useBossJobs({
setPage(data.page || toPage)
setSize(data.size || toSize)
} catch (error) {
- console.error("fetch list failed", error)
+ if (requestSequence === listRequestSequence.current) console.error("fetch list failed", error)
} finally {
- setLoadingList(false)
+ if (requestSequence === listRequestSequence.current) setLoadingList(false)
}
}, [activeScanRunId, buildFilterParams, filters, page, size])
diff --git a/front/app/boss/analysis/hooks/useBossStats.ts b/front/app/boss/analysis/hooks/useBossStats.ts
index 750997b..4e90343 100644
--- a/front/app/boss/analysis/hooks/useBossStats.ts
+++ b/front/app/boss/analysis/hooks/useBossStats.ts
@@ -1,6 +1,6 @@
"use client"
-import { useCallback, useState } from "react"
+import { useCallback, useRef, useState } from "react"
import { API_BASE } from "@/lib/api"
import type { FilterState, StatsResponse } from "../types"
@@ -17,31 +17,35 @@ export function useBossStats({
const [stats, setStats] = useState(null)
const [dashboardStats, setDashboardStats] = useState(null)
const [loadingDashboardStats, setLoadingDashboardStats] = useState(true)
+ const statsRequestSequence = useRef(0)
+ const dashboardRequestSequence = useRef(0)
const loadStats = useCallback(async () => {
+ const requestSequence = ++statsRequestSequence.current
const params = buildFilterParams(filters, activeScanRunId)
try {
const res = await fetch(`${API_BASE}/api/boss/stats?${params.toString()}`)
const data: StatsResponse = await res.json()
- setStats(data)
+ if (requestSequence === statsRequestSequence.current) setStats(data)
} catch (error) {
- console.error("fetch stats failed", error)
+ if (requestSequence === statsRequestSequence.current) console.error("fetch stats failed", error)
}
}, [activeScanRunId, buildFilterParams, filters])
const loadDashboardStats = useCallback(async () => {
+ const requestSequence = ++dashboardRequestSequence.current
try {
setLoadingDashboardStats(true)
const params = new URLSearchParams()
if (activeScanRunId) params.set("scanRunId", activeScanRunId)
const res = await fetch(`${API_BASE}/api/boss/stats?${params.toString()}`)
const data: StatsResponse = await res.json()
- setDashboardStats(data)
+ if (requestSequence === dashboardRequestSequence.current) setDashboardStats(data)
} catch (error) {
- console.error("fetch dashboard stats failed", error)
+ if (requestSequence === dashboardRequestSequence.current) console.error("fetch dashboard stats failed", error)
} finally {
- setLoadingDashboardStats(false)
+ if (requestSequence === dashboardRequestSequence.current) setLoadingDashboardStats(false)
}
}, [activeScanRunId])
diff --git a/front/app/boss/analysis/hooks/useCsvExport.ts b/front/app/boss/analysis/hooks/useCsvExport.ts
index 0d5ba3f..00530fd 100644
--- a/front/app/boss/analysis/hooks/useCsvExport.ts
+++ b/front/app/boss/analysis/hooks/useCsvExport.ts
@@ -59,6 +59,7 @@ export function useCsvExport({
"AI分",
"AI决策",
"AI原因",
+ "结果来源",
"优先公司",
"链接",
"创建时间",
@@ -77,6 +78,7 @@ export function useCsvExport({
item.aiScore ?? "",
item.aiDecision || "",
item.aiReason || "",
+ item.scanResultSource === "HISTORICAL_REUSED" ? "历史结果" : item.scanResultSource === "CURRENT_SCAN" ? "本次扫描" : "",
item.priorityCompany ? "是" : "",
item.jobUrl || "",
item.createdAt || "",
diff --git a/front/app/boss/analysis/types.ts b/front/app/boss/analysis/types.ts
index 0b7063f..b3c224f 100644
--- a/front/app/boss/analysis/types.ts
+++ b/front/app/boss/analysis/types.ts
@@ -71,6 +71,7 @@ export type BossJob = {
priorityCompany?: number
sourceKeyword?: string
scanRunId?: string
+ scanResultSource?: "CURRENT_SCAN" | "HISTORICAL_REUSED"
createdAt?: string
aiGreeting?: string
greetingDraft?: string
@@ -79,6 +80,62 @@ export type BossJob = {
finalGreeting?: string
}
+export type AiReasonDimension = {
+ key: string
+ label: string
+ weight: number
+ status: "MATCH" | "PARTIAL" | "UNKNOWN" | "CONFLICT" | string
+ awarded: number
+ jobEvidence: string[]
+ resumeEvidence: string[]
+ note: string
+}
+
+export type AiReasonHardConflict = {
+ requirement: string
+ jobEvidence: string[]
+ resumeEvidence: string[]
+}
+
+export type ParsedAiReason = {
+ schemaVersion: number
+ summary: string
+ matches: string[]
+ gaps: string[]
+ unknowns: string[]
+ dimensions: AiReasonDimension[]
+ hardConflicts: AiReasonHardConflict[]
+ threshold?: number
+ errorCode?: string
+ malformed: boolean
+}
+
+export type JobAnalysisTask = {
+ id: number
+ profileId: number
+ platform: string
+ jobKey: string
+ jobRowId: number
+ scanRunId?: string
+ status: "PENDING" | "LEASED" | "SUCCEEDED" | "FAILED" | "UNKNOWN"
+ attemptCount: number
+ leaseExpiresAt?: string | null
+ lastError?: string | null
+ createdAt?: string | null
+ updatedAt?: string | null
+ startedAt?: string | null
+ completedAt?: string | null
+}
+
+export type JobAnalysisTasksResponse = {
+ success: boolean
+ data?: JobAnalysisTask[]
+ queueSize?: number
+ pendingCount?: number
+ processingCount?: number
+ message?: string
+}
+
export type PagedResult = {
items: BossJob[]
total: number
@@ -115,8 +172,6 @@ export const EMPTY_FILTERS: FilterState = {
}
export const DEFAULT_PENDING_FILTERS: FilterState = { ...EMPTY_FILTERS, statuses: ["待确认"] }
-export const LIST_COLLECTED_FILTERS: FilterState = { ...EMPTY_FILTERS, statuses: ["LIST_COLLECTED"] }
-
export const FAILURE_TYPE_LABELS: Record = {
LOGIN_EXPIRED: "登录失效",
PLATFORM_VERIFICATION: "平台验证",
diff --git a/front/app/boss/analysis/utils.test.ts b/front/app/boss/analysis/utils.test.ts
new file mode 100644
index 0000000..f69dbd1
--- /dev/null
+++ b/front/app/boss/analysis/utils.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, it } from "vitest"
+
+import { formatAiReasonDetail, parseAiReason, riskTextOf } from "./utils"
+
+describe("AI岗位理由兼容解析", () => {
+ it("解析 schemaVersion 2 的证据、待核实和分项得分", () => {
+ const reason = JSON.stringify({
+ schemaVersion: 2,
+ summary: "核心技能匹配,地点需要确认",
+ matches: ["岗位要求 Java,简历写有 Java"],
+ gaps: ["岗位要求带团队,简历仅描述个人贡献"],
+ unknowns: ["未写明到岗时间"],
+ dimensions: [{
+ key: "CORE_SKILLS",
+ label: "核心职责与技能",
+ weight: 35,
+ status: "MATCH",
+ awarded: 35,
+ jobEvidence: ["Java"],
+ resumeEvidence: ["Java"],
+ note: "技术栈一致",
+ }],
+ hardConflicts: [],
+ threshold: 75,
+ errorCode: null,
+ })
+
+ const parsed = parseAiReason(reason)
+
+ expect(parsed.schemaVersion).toBe(2)
+ expect(parsed.matches).toEqual(["岗位要求 Java,简历写有 Java"])
+ expect(parsed.unknowns).toEqual(["未写明到岗时间"])
+ const detail = formatAiReasonDetail(reason)
+ expect(detail).toContain("结论")
+ expect(detail).toContain("匹配证据")
+ expect(detail).toContain("明确差距")
+ expect(detail).toContain("待核实")
+ expect(detail).toContain("分项得分")
+ })
+
+ it("兼容旧版 JSON 并把 strengths/risks 映射为匹配和差距", () => {
+ const parsed = parseAiReason(JSON.stringify({
+ summary: "旧版结论",
+ strengths: ["Java经验"],
+ risks: ["学历待确认"],
+ threshold: 75,
+ }))
+
+ expect(parsed.schemaVersion).toBe(1)
+ expect(parsed.matches).toEqual(["Java经验"])
+ expect(parsed.gaps).toEqual(["学历待确认"])
+ })
+
+ it("兼容历史纯文本且不把损坏 JSON 原文显示给用户", () => {
+ expect(parseAiReason("升级前任务上下文已丢失").summary).toBe("升级前任务上下文已丢失")
+ const malformed = parseAiReason('{"summary":')
+ expect(malformed.malformed).toBe(true)
+ expect(malformed.summary).toBe("AI分析理由格式异常,请重试该岗位")
+ expect(malformed.summary).not.toContain('{"summary"')
+ })
+
+ it("风险摘要只展示差距和待核实,不重复整段理由", () => {
+ const aiReason = JSON.stringify({
+ schemaVersion: 2,
+ summary: "总体匹配",
+ matches: ["技能匹配"],
+ gaps: ["管理经验不足"],
+ unknowns: ["薪资待确认"],
+ dimensions: [],
+ hardConflicts: [],
+ })
+
+ expect(riskTextOf({ id: 1, aiReason, jobUrl: "https://example.com" }))
+ .toBe("管理经验不足\n待核实:薪资待确认")
+ })
+
+ it("没有单独 gaps 时仍展示分项中的部分匹配和冲突", () => {
+ const aiReason = JSON.stringify({
+ schemaVersion: 2,
+ summary: "需要复核经验与地点",
+ matches: [],
+ gaps: [],
+ unknowns: [],
+ dimensions: [
+ { key: "RELEVANT_EXPERIENCE", label: "相关经历", status: "PARTIAL", note: "经验方向接近" },
+ { key: "LOCATION_SALARY", label: "地点与薪资", status: "CONFLICT", note: "地点明确不符" },
+ ],
+ hardConflicts: [],
+ })
+
+ expect(riskTextOf({ id: 1, aiReason, jobUrl: "https://example.com" }))
+ .toBe("相关经历:经验方向接近\n地点与薪资:地点明确不符")
+ })
+})
diff --git a/front/app/boss/analysis/utils.ts b/front/app/boss/analysis/utils.ts
index 6ced181..424f17b 100644
--- a/front/app/boss/analysis/utils.ts
+++ b/front/app/boss/analysis/utils.ts
@@ -1,4 +1,4 @@
-import type { BossJob } from "./types"
+import type { AiReasonDimension, AiReasonHardConflict, BossJob, ParsedAiReason } from "./types"
import { FAILURE_TYPE_LABELS } from "./types"
export function formatDateOnlyValue(value?: string | null) {
@@ -46,13 +46,141 @@ export function deliveryStatusLabel(value?: string) {
}
export function riskTextOf(job: BossJob) {
- const reason = (job.aiReason || "").trim()
- if (reason) return reason
+ const reason = parseAiReason(job.aiReason)
+ const dimensionRisks = reason.dimensions
+ .filter((item) => item.status === "PARTIAL" || item.status === "CONFLICT")
+ .map((item) => `${item.label}:${item.note || (item.status === "CONFLICT" ? "存在冲突" : "部分匹配")}`)
+ const risks = Array.from(new Set([
+ ...reason.hardConflicts.map((item) => `硬冲突:${item.requirement}`),
+ ...reason.gaps,
+ ...dimensionRisks,
+ ...reason.unknowns.map((item) => `待核实:${item}`),
+ ]))
+ if (risks.length > 0) return risks.join("\n")
+ if (reason.errorCode) return `${reason.summary}(${reason.errorCode})`
if (!job.jobUrl) return "缺少原岗位链接,确认前建议核对岗位来源。"
if (!job.aiScore && job.aiScore !== 0) return "暂无AI分数,确认前建议人工复核。"
return "暂无明显风险点。"
}
+function isRecord(value: unknown): value is Record {
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value)
+}
+
+function stringList(value: unknown) {
+ return Array.isArray(value)
+ ? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
+ .map((item) => item.trim())
+ : []
+}
+
+function numberValue(value: unknown) {
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined
+}
+
+const MATCH_STATUS_LABEL: Record = {
+ MATCH: "匹配",
+ PARTIAL: "部分匹配",
+ UNKNOWN: "待核实",
+ CONFLICT: "冲突",
+}
+
+function parseDimensions(value: unknown): AiReasonDimension[] {
+ if (!Array.isArray(value)) return []
+ return value.flatMap((item) => {
+ if (!isRecord(item) || typeof item.key !== "string") return []
+ return [{
+ key: item.key,
+ label: typeof item.label === "string" && item.label.trim() ? item.label.trim() : item.key,
+ weight: numberValue(item.weight) ?? 0,
+ status: typeof item.status === "string" ? item.status : "UNKNOWN",
+ awarded: numberValue(item.awarded) ?? 0,
+ jobEvidence: stringList(item.jobEvidence),
+ resumeEvidence: stringList(item.resumeEvidence),
+ note: typeof item.note === "string" ? item.note.trim() : "",
+ }]
+ })
+}
+
+function parseHardConflicts(value: unknown): AiReasonHardConflict[] {
+ if (!Array.isArray(value)) return []
+ return value.flatMap((item) => {
+ if (!isRecord(item) || typeof item.requirement !== "string" || !item.requirement.trim()) return []
+ return [{
+ requirement: item.requirement.trim(),
+ jobEvidence: stringList(item.jobEvidence),
+ resumeEvidence: stringList(item.resumeEvidence),
+ }]
+ })
+}
+
+export function parseAiReason(value?: string | null): ParsedAiReason {
+ const text = (value || "").trim()
+ const empty: ParsedAiReason = {
+ schemaVersion: 0,
+ summary: "暂无AI理由",
+ matches: [],
+ gaps: [],
+ unknowns: [],
+ dimensions: [],
+ hardConflicts: [],
+ malformed: false,
+ }
+ if (!text) return empty
+ try {
+ const parsed: unknown = JSON.parse(text)
+ if (!isRecord(parsed)) throw new Error("reason is not an object")
+ const schemaVersion = numberValue(parsed.schemaVersion) ?? 1
+ return {
+ schemaVersion,
+ summary: typeof parsed.summary === "string" && parsed.summary.trim()
+ ? parsed.summary.trim()
+ : "暂无AI结论",
+ matches: stringList(schemaVersion >= 2 ? parsed.matches : parsed.strengths),
+ gaps: stringList(schemaVersion >= 2 ? parsed.gaps : parsed.risks),
+ unknowns: stringList(parsed.unknowns),
+ dimensions: parseDimensions(parsed.dimensions),
+ hardConflicts: parseHardConflicts(parsed.hardConflicts),
+ threshold: numberValue(parsed.threshold),
+ errorCode: typeof parsed.errorCode === "string" && parsed.errorCode.trim()
+ ? parsed.errorCode.trim()
+ : undefined,
+ malformed: false,
+ }
+ } catch {
+ if (text.startsWith("{") || text.startsWith("[")) {
+ return {
+ ...empty,
+ summary: "AI分析理由格式异常,请重试该岗位",
+ errorCode: "AI_REASON_INVALID_JSON",
+ malformed: true,
+ }
+ }
+ return { ...empty, summary: text }
+ }
+}
+
+export function formatAiReasonDetail(value?: string | null) {
+ const reason = parseAiReason(value)
+ const sections: string[] = [`结论\n${reason.summary}`]
+ if (reason.matches.length > 0) sections.push(`匹配证据\n${reason.matches.map((item, index) => `${index + 1}. ${item}`).join("\n")}`)
+ if (reason.gaps.length > 0) sections.push(`明确差距\n${reason.gaps.map((item, index) => `${index + 1}. ${item}`).join("\n")}`)
+ if (reason.unknowns.length > 0) sections.push(`待核实\n${reason.unknowns.map((item, index) => `${index + 1}. ${item}`).join("\n")}`)
+ if (reason.dimensions.length > 0) {
+ sections.push(`分项得分\n${reason.dimensions.map((item) =>
+ `${item.label}:${Number.isInteger(item.awarded) ? item.awarded : item.awarded.toFixed(2)}/${item.weight}(${MATCH_STATUS_LABEL[item.status] || item.status})${item.note ? `,${item.note}` : ""}`
+ + `${item.jobEvidence.length > 0 ? `\n 岗位原文:${item.jobEvidence.join(";")}` : ""}`
+ + `${item.resumeEvidence.length > 0 ? `\n 简历原文:${item.resumeEvidence.join(";")}` : ""}`).join("\n")}`)
+ }
+ if (reason.hardConflicts.length > 0) {
+ sections.push(`硬冲突\n${reason.hardConflicts.map((item, index) =>
+ `${index + 1}. ${item.requirement}\n 岗位原文:${item.jobEvidence.join(";")}\n 简历原文:${item.resumeEvidence.join(";")}`).join("\n")}`)
+ }
+ if (reason.threshold !== undefined) sections.push(`当前投递阈值\n${reason.threshold}`)
+ if (reason.errorCode) sections.push(`错误代码\n${reason.errorCode}`)
+ return sections.join("\n\n")
+}
+
export function badgeClass(kind: "delivery" | "hr" | "recruitment", value?: string) {
const base = "px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap"
const v = (value || "").trim()
diff --git a/front/app/boss/page.tsx b/front/app/boss/page.tsx
index ff080fd..c637085 100644
--- a/front/app/boss/page.tsx
+++ b/front/app/boss/page.tsx
@@ -14,7 +14,11 @@ import { Select } from '@/components/ui/select'
import PageHeader from '@/app/components/PageHeader'
import AnalysisContent from '@/app/boss/analysis/AnalysisContent'
import CurrentProfileBadge, { type CurrentProfile } from '@/app/components/CurrentProfileBadge'
+import KeywordTagInput from '@/app/components/KeywordTagInput'
import { formatSetupMissingMessage, validateSetupForPlatform } from '@/lib/setupChecklist'
+import { hasBossScanResult, readBossScanRunId } from '@/app/boss/scan-result'
+import { MAX_JOB_KEYWORDS, parseJobKeywords, serializeJobKeywords } from '@/lib/job-keywords'
+import { normalizeScanProfileId, scanEventMatchesProfile } from '@/lib/scan-profile'
interface BossConfig {
id?: number
@@ -46,6 +50,12 @@ type BossConfigEnvelope = ApiEnvelope & {
hasProfile?: boolean
}
+type JobKeywordRecommendations = {
+ keywords?: string[]
+ maxSelected?: number
+ recommendedSelectionCount?: number
+}
+
interface BossOption {
id: number
type: string
@@ -112,6 +122,7 @@ interface BossCurrentPageCollectResponse extends BossDiagnosticsResponse {
skippedCount?: number
saved?: number
listCollected?: number
+ restored?: number
missingFieldCounts?: Record
failures?: Array<{
index?: number
@@ -123,6 +134,7 @@ interface BossCurrentPageCollectResponse extends BossDiagnosticsResponse {
backend?: {
saved?: number
listCollected?: number
+ restored?: number
collectionWarnings?: Array>
}
}
@@ -138,6 +150,7 @@ interface BossApiPocResponse extends BossDiagnosticsResponse {
collectorSource?: string
saved?: number
listCollected?: number
+ restored?: number
}
const BOSS_DELIVERY_STEPS: Array<{ key: BossStep; title: string; description: string }> = [
@@ -187,8 +200,8 @@ export default function BossPage() {
filterDeadHr: 0,
autoDeliver: 0,
})
- // 关键词显示用(无括号无引号,逗号分隔)
- const [keywordsDisplay, setKeywordsDisplay] = useState('')
+ const [keywordsDisplay, setKeywordsDisplay] = useState([])
+ const [recommendedKeywords, setRecommendedKeywords] = useState([])
// 多选选中的代码集合(按括号列表保存)
const [selectedIndustry, setSelectedIndustry] = useState([])
const [selectedExperience, setSelectedExperience] = useState([])
@@ -267,10 +280,13 @@ export default function BossPage() {
}, [])
const syncBossScanStatus = useCallback(async (silent = false) => {
+ const profileId = normalizeScanProfileId(currentProfile?.id)
+ if (!profileId) return
try {
const status = await sendChromeBridgeMessage({
type: 'BOSS_SCAN_STATUS',
platform: 'boss',
+ profileId,
}, 2000)
const paused = Boolean(status.paused || (status.stage === 'blocked' && status.resumable))
const runId = typeof status.runId === 'string' && status.runId.trim() ? status.runId.trim() : null
@@ -312,7 +328,7 @@ export default function BossPage() {
} catch {
// 扩展未连接或平台页未打开时,保持当前前端状态。
}
- }, [appendProgressLog])
+ }, [appendProgressLog, currentProfile?.id])
const focusLogSection = useCallback(() => {
setActiveStep('scan')
@@ -323,8 +339,8 @@ export default function BossPage() {
window.setTimeout(() => setLogSpotlight(false), 2200)
}, [])
- const guideToConfirmStep = useCallback(() => {
- setAnalysisFocusRunId('')
+ const guideToConfirmStep = useCallback((payload: Record) => {
+ setAnalysisFocusRunId(readBossScanRunId(payload))
setHasScanResult(true)
setAnalysisRefreshSignal((value) => value + 1)
}, [])
@@ -438,6 +454,7 @@ export default function BossPage() {
try {
const raw = JSON.parse(event.data)
const data = typeof raw === 'string' ? JSON.parse(raw) : raw
+ if (!scanEventMatchesProfile(data, currentProfile?.id, true)) return
appendProgressLog({
type: data.type || 'info',
message: data.message || '',
@@ -450,7 +467,7 @@ export default function BossPage() {
if (typeof data.runId === 'string' && data.runId.trim()) setActiveRunId(data.runId.trim())
}
if (shouldRefreshAnalysisFromProgress(data)) {
- guideToConfirmStep()
+ guideToConfirmStep(data)
}
if (data.type === 'error') {
setIsDelivering(false)
@@ -468,12 +485,13 @@ export default function BossPage() {
})
return () => client.close()
- }, [appendProgressLog, guideToConfirmStep])
+ }, [appendProgressLog, currentProfile?.id, guideToConfirmStep])
useEffect(() => {
return subscribeChromeBridgeEvents((event) => {
const payload = event.payload
if (!payload || payload.platform !== 'boss') return
+ if (!scanEventMatchesProfile(payload, currentProfile?.id, true)) return
appendProgressLog({
type: payload.type || 'info',
@@ -482,7 +500,7 @@ export default function BossPage() {
})
if (shouldRefreshAnalysisFromProgress(payload)) {
- guideToConfirmStep()
+ guideToConfirmStep(payload)
}
if (payload.stage === 'blocked' && (payload.paused || payload.resumable)) {
setIsDelivering(false)
@@ -497,7 +515,7 @@ export default function BossPage() {
setActiveRunId(null)
}
})
- }, [appendProgressLog, guideToConfirmStep])
+ }, [appendProgressLog, currentProfile?.id, guideToConfirmStep])
const checkChromeBridge = async () => {
try {
@@ -520,8 +538,17 @@ export default function BossPage() {
const fetchAllData = async () => {
setLoading(true)
try {
- const response = await fetch(`${API_BASE}/api/boss/config`)
+ const [response, recommendationResponse] = await Promise.all([
+ fetch(`${API_BASE}/api/boss/config`),
+ fetch(`${API_BASE}/api/ai/job-keywords`).catch(() => null),
+ ])
const data = await readApiResponse(response, 'Boss配置加载失败') as BossConfigEnvelope
+ if (recommendationResponse?.ok) {
+ const recommendationResult = await readApiResponse(recommendationResponse, '岗位关键词推荐加载失败')
+ setRecommendedKeywords(parseJobKeywords(recommendationResult.data?.keywords))
+ } else {
+ setRecommendedKeywords([])
+ }
console.log('Fetched data:', data)
console.log('Blacklist:', data.blacklist)
@@ -551,31 +578,7 @@ export default function BossPage() {
searchJobLimit,
autoDeliver: 0,
})
- // 将后端存储的关键词(可能是 JSON 数组或括号列表)转为展示用逗号分隔文本
- const toDisplayKeywords = (raw?: string): string => {
- if (!raw) return ''
- const s = raw.trim()
- // 尝试作为 JSON 数组解析
- if (s.startsWith('[') && s.endsWith(']')) {
- try {
- const arr = JSON.parse(s)
- if (Array.isArray(arr)) {
- return arr.map((v) => String(v).trim()).filter((v) => v.length > 0).join(', ')
- }
- } catch (_) {
- // 非严格 JSON,如 [a,b],走拆括号与逗号分隔
- const inner = s.slice(1, -1)
- return inner
- .split(',')
- .map((v) => v.trim().replace(/^"|"$/g, ''))
- .filter((v) => v.length > 0)
- .join(', ')
- }
- }
- // 普通文本:直接返回,去掉多余空格
- return s
- }
- setKeywordsDisplay(toDisplayKeywords(data.config.keywords))
+ setKeywordsDisplay(parseJobKeywords(data.config.keywords))
// 解析括号列表为数组
setSelectedIndustry(parseListString(data.config.industry))
setSelectedExperience(parseListString(data.config.experience))
@@ -744,6 +747,17 @@ export default function BossPage() {
setShowSaveDialog(true)
return
}
+ if (!keywordsDisplay.length || keywordsDisplay.length > MAX_JOB_KEYWORDS) {
+ setSaveDialogKind('save')
+ setSaveResult({
+ success: false,
+ message: !keywordsDisplay.length
+ ? '请至少选择一个搜索关键词。'
+ : `岗位关键词最多选择 ${MAX_JOB_KEYWORDS} 个,请先删减后再保存。`,
+ })
+ setShowSaveDialog(true)
+ return
+ }
try {
const searchJobLimit = commitSearchJobLimit(overrides?.searchJobLimit)
// 组装要保存的负载:多选使用括号列表
@@ -751,8 +765,7 @@ export default function BossPage() {
...config,
// 覆盖字段(用于失焦时使用当前控件值,避免异步状态滞后)
...(overrides || {}),
- // 关键词:前端发送逗号分隔的纯文本,后端统一组装为 JSON 列表
- keywords: keywordsDisplay,
+ keywords: serializeJobKeywords(keywordsDisplay),
searchJobLimit,
industry: toBracketList(selectedIndustry),
experience: toBracketList(selectedExperience),
@@ -863,6 +876,19 @@ export default function BossPage() {
alert('请先在简历配置页新建档案。')
return
}
+ const profileId = normalizeScanProfileId(currentProfile?.id)
+ if (!profileId) {
+ appendProgressLog({ type: 'error', message: '当前档案 ID 无效,请刷新档案后重试。' })
+ return
+ }
+ if (!keywordsDisplay.length || keywordsDisplay.length > MAX_JOB_KEYWORDS) {
+ const message = !keywordsDisplay.length
+ ? '请至少选择一个搜索关键词。'
+ : `岗位关键词最多选择 ${MAX_JOB_KEYWORDS} 个,请先删减后再开始扫描。`
+ appendProgressLog({ type: 'error', message })
+ alert(message)
+ return
+ }
focusLogSection()
const setup = await validateSetupForPlatform('boss', { requirePlatformLogin: false })
if (!setup.ready) {
@@ -881,6 +907,7 @@ export default function BossPage() {
const data = await sendChromeBridgeMessage({
type: 'BOSS_SCAN_START',
platform: 'boss',
+ profileId,
runId,
config: {
...config,
@@ -965,6 +992,11 @@ export default function BossPage() {
appendProgressLog({ type: 'error', message: '请先在简历配置页新建档案,后端需要用当前档案保存岗位。' })
return
}
+ const profileId = normalizeScanProfileId(currentProfile?.id)
+ if (!profileId) {
+ appendProgressLog({ type: 'error', message: '当前档案 ID 无效,请刷新页面后重试。' })
+ return
+ }
focusLogSection()
setIsCollectingCurrentPage(true)
@@ -976,7 +1008,8 @@ export default function BossPage() {
const data = await sendChromeBridgeMessage({
type: 'BOSS_COLLECT_CURRENT_PAGE',
platform: 'boss',
- keyword: keywordsDisplay,
+ profileId,
+ keyword: keywordsDisplay.join(', '),
runId: `boss-list-${Date.now()}`,
}, 70000) as BossCurrentPageCollectResponse
@@ -1009,7 +1042,7 @@ export default function BossPage() {
message: '当前页面未识别到岗位详情链接,可能是未进入搜索结果页、未登录、安全验证、页面结构变化或选择器失效。',
})
}
- if (data.success && typeof data.runId === 'string' && Number(data.saved || data.listCollected || 0) > 0) {
+ if (data.success && typeof data.runId === 'string' && hasBossScanResult(data)) {
setAnalysisFocusRunId(data.runId)
setHasScanResult(true)
setAnalysisRefreshSignal((value) => value + 1)
@@ -1033,13 +1066,13 @@ export default function BossPage() {
appendProgressLog({ type: 'error', message: '请先在简历配置页新建档案,后端需要用当前档案保存 POC 岗位。' })
return
}
+ const profileId = normalizeScanProfileId(currentProfile?.id)
+ if (!profileId) {
+ appendProgressLog({ type: 'error', message: '当前档案 ID 无效,请刷新页面后重试。' })
+ return
+ }
- const keywords = Array.from(new Set(
- keywordsDisplay
- .split(/[,,;;\n\r]+/)
- .map((item) => item.trim())
- .filter(Boolean),
- ))
+ const keywords = parseJobKeywords(keywordsDisplay)
if (keywords.length !== 1) {
appendProgressLog({ type: 'error', message: 'Boss API POC 仅支持一个关键词,请把关键词配置改为恰好一个后再测试。' })
return
@@ -1061,6 +1094,7 @@ export default function BossPage() {
const data = await sendChromeBridgeMessage({
type: 'BOSS_API_POC_COLLECT',
platform: 'boss',
+ profileId,
keyword: keywords[0],
cityCode,
page: 1,
@@ -1085,10 +1119,10 @@ export default function BossPage() {
})
appendProgressLog({
type: data.success ? 'info' : 'warning',
- message: `Boss API POC 诊断:diagnosticType=${data.diagnosticType || '未知'};apiCode=${data.apiCode ?? '无'};httpStatus=${Number(data.httpStatus || 0)};candidateCount=${Number(data.candidateCount || 0)};missingSalaryCount=${Number(data.missingSalaryCount || 0)};fallbackUsed=${Boolean(data.fallbackUsed)};collectorSource=${data.collectorSource || 'none'};saved=${Number(data.saved || 0)};listCollected=${Number(data.listCollected || 0)}。`,
+ message: `Boss API POC 诊断:diagnosticType=${data.diagnosticType || '未知'};apiCode=${data.apiCode ?? '无'};httpStatus=${Number(data.httpStatus || 0)};candidateCount=${Number(data.candidateCount || 0)};missingSalaryCount=${Number(data.missingSalaryCount || 0)};fallbackUsed=${Boolean(data.fallbackUsed)};collectorSource=${data.collectorSource || 'none'};saved=${Number(data.saved || 0)};listCollected=${Number(data.listCollected || 0)};restored=${Number(data.restored || 0)}。`,
})
- if (data.success && typeof data.runId === 'string' && Number(data.saved || data.listCollected || 0) > 0) {
+ if (data.success && typeof data.runId === 'string' && hasBossScanResult(data)) {
setAnalysisFocusRunId(data.runId)
setHasScanResult(true)
setAnalysisRefreshSignal((value) => value + 1)
@@ -1107,13 +1141,15 @@ export default function BossPage() {
setIsStopping(true)
try {
const runId = activeRunId
+ const profileId = normalizeScanProfileId(currentProfile?.id)
+ if (!profileId) throw new Error('当前档案 ID 无效')
await fetch(`${API_BASE}/api/boss/chrome/stop`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ runId }),
+ body: JSON.stringify({ runId, profileId }),
}).catch(() => null)
- const data = await sendChromeBridgeMessage({ type: 'BOSS_SCAN_STOP', platform: 'boss', runId }, 1500)
+ const data = await sendChromeBridgeMessage({ type: 'BOSS_SCAN_STOP', platform: 'boss', runId, profileId }, 1500)
if (data.success) {
appendProgressLog({ type: 'warning', message: data.message || 'Boss扫描停止请求已发送。' })
@@ -1242,14 +1278,14 @@ export default function BossPage() {
{isStopping ? '停止中...' : '停止扫描'}
) : (
- |