diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a2949f..d3e0f89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ - Release 产物、版本命名和校验文档 - Dependabot、CODEOWNERS、开发者启动文档和统一文档中心 - 完全虚构的 Demo 简历、岗位和分析示例数据 +- 四平台只读能力注册表与统一 AI 分析入口;Boss / 智联为一级链,猎聘 / 51job 为二级兼容链 +- Flyway V9 沟通草稿、猎聘 / 51job AI 字段及投递话术快照迁移 +- Boss / 智联 AI 沟通助手:来源展示、人工编辑、复制、恢复原稿和单个 / 批量确认预览 +- 本机操作令牌、`expectedUpdatedAt` 乐观并发和确认话术快照校验 ### Changed @@ -30,12 +34,14 @@ - Dependabot 默认只自动提交 Minor 与 Patch 更新,避免未经评估的大版本升级 - 中英文 README 增加 CodeQL、Release、下载说明和文档中心入口 - 安全文档增加自动化检查和 Release 数据边界 +- 智联跨扫描去重不再受 `scanRunId` 限制,保留已有投递和 AI 状态 +- 猎聘 / 51job 普通启动改为只读采集;真实投递模式需要显式请求与二次确认头 ### 计划中 - 将 Demo 示例数据接入独立、可重置且不触发真实投递的离线 Demo 模式 - 提供不依赖开发环境的完整 Windows 发行包 -- 统一智联、猎聘和 51job 的平台适配层 +- 补齐猎聘 / 51job 沟通助手前端入口和旧记录的统一 runId - 继续拆分其他平台分析页面的 hooks 与 components - 补充真实界面截图和操作 GIF - 继续将旧兼容 DDL 迁移到 Flyway diff --git a/README.en.md b/README.en.md index 938801b..a1ca7df 100644 --- a/README.en.md +++ b/README.en.md @@ -63,14 +63,16 @@ AI JobPilot brings these steps into a local workspace: ## Platform support -| Platform | Collection | AI analysis | Human review | Current status | -| --- | :---: | :---: | :---: | --- | -| Boss Zhipin | ✅ | ✅ | ✅ | Main Chrome Bridge flow; limited API POC and page fallback collection | -| Zhaopin | ✅ | ✅ | ✅ | Main Chrome Bridge flow | -| Liepin | 🟡 | ✅ | ✅ | Basic local flow; adapter work is ongoing | -| 51job | 🟡 | ✅ | ✅ | Basic local flow; adapter work is ongoing | +| Platform | Quality tier | Collection | AI analysis / review queue | Formal execution mode | +| --- | --- | --- | :---: | --- | +| Boss Zhipin | Tier 1 | Chrome Bridge; limited API POC and page fallback | ✅ | Chrome Bridge | +| Zhaopin | Tier 1 | Chrome Bridge | ✅ | Chrome Bridge | +| Liepin | Tier 2 compatibility | Legacy Playwright, read-only collection by default | ✅ | Legacy Playwright with explicit real-action confirmation | +| 51job | Tier 2 compatibility | Legacy Playwright, read-only collection by default | ✅ | Legacy Playwright with explicit real-action confirmation | -`✅` means the current primary workflow is supported. `🟡` means basic functionality exists but coverage and stability still need validation. +The read-only `GET /api/platforms/capabilities` endpoint exposes the current capability declarations. Automated coverage does not replace a small real-account smoke test that stops at the review queue. + +Boss and Zhaopin review cards also expose the final communication draft, its source, editing, copying, and AI-original restore. User edits are stored separately from AI output. Single and batch confirmation validate the exact reviewed text before creating a browser task; this does not add a separate automatic messaging entry point. ## Downloads and releases @@ -120,9 +122,10 @@ Open the workspace and health endpoint: ```text Frontend: http://localhost:6866 Backend health: http://localhost:8888/api/health +Backend readiness: http://localhost:8888/api/ready ``` -The basic setup is ready when the dashboard checks pass and the health endpoint returns `UP`. +`/api/health` returning `UP` only proves that the process is alive. The workspace is ready for business operations only when the dashboard checks pass and `/api/ready` reports readiness for the database, schema, and task queue. See [WINDOWS_SETUP.md](WINDOWS_SETUP.md) for the full beginner setup and troubleshooting guide. diff --git a/README.md b/README.md index 4d3f211..2616037 100644 --- a/README.md +++ b/README.md @@ -84,14 +84,14 @@ Boss 直聘和智联招聘支持 Chrome Bridge,复用你已登录的 Chrome ## 平台支持 -| 平台 | 岗位采集 | AI 分析 | 人工确认 | 当前定位 | -| --- | :---: | :---: | :---: | --- | -| Boss 直聘 | ✅ | ✅ | ✅ | Chrome Bridge 主要流程;含受限 API POC 与页面降级采集 | -| 智联招聘 | ✅ | ✅ | ✅ | Chrome Bridge 主要流程 | -| 猎聘 | 🟡 | — | 🟡 | 旧 Playwright 兼容流程;尚未接入统一 AI 分析与待确认队列 | -| 前程无忧 51job | 🟡 | — | 🟡 | 旧 Playwright 兼容流程;尚未接入统一 AI 分析与待确认队列 | +| 平台 | 质量等级 | 岗位采集 | AI 分析 / 待确认 | 正式执行模式 | +| --- | --- | --- | :---: | --- | +| Boss 直聘 | 一级日常链 | Chrome Bridge;含受限 API POC 与页面降级采集 | ✅ | Chrome Bridge | +| 智联招聘 | 一级日常链 | Chrome Bridge | ✅ | Chrome Bridge | +| 猎聘 | 二级兼容链 | 旧 Playwright,默认只读采集 | ✅ | 旧 Playwright;真实投递必须显式二次确认 | +| 前程无忧 51job | 二级兼容链 | 旧 Playwright,默认只读采集 | ✅ | 旧 Playwright;真实投递必须显式二次确认 | -`✅` 表示当前主流程支持,`🟡` 表示已有基础能力但稳定性和覆盖范围仍需继续验证,`—` 表示尚未接入该统一流程。 +`✅` 表示代码与自动化契约已经接通,不代表当前账号和当天页面已经完成真实 smoke。四个平台的当前声明可通过只读接口 `GET /api/platforms/capabilities` 查看;招聘网站改版后仍应先小范围验证到“待确认”。 ## 下载与版本 @@ -177,6 +177,18 @@ Boss 直聘和智联招聘推荐使用 Chrome Bridge: 扩展只负责辅助本地流程,不会绕过登录验证、验证码或平台投递限制。 +## AI 沟通助手 + +Boss 和智联的待确认岗位现在会显示最终沟通话术及其来源,并支持编辑、复制和恢复 AI 原稿。话术优先级固定为: + +```text +人工编辑稿 → AI greeting → 当前档案默认话术 → 空白警告 +``` + +人工稿与 AI 原稿分开保存,重新分析岗位不会覆盖人工编辑。保存接口使用只在本机内存中存在的操作令牌和 `expectedUpdatedAt` 并发校验;单个、批量确认和显式重试还会再次校验页面预览快照,话术变化时会停止并要求重新确认。 + +该能力不会增加独立的自动发消息入口。Boss 的确认任务会把核对后的话术交给现有 Chrome Bridge;智联当前把话术随任务保存并提供复制,但现有扩展不会自动发送聊天消息。 + ## 手动开发启动 后端: @@ -253,9 +265,9 @@ db/getjobs.db ## 项目状态 -最新正式版本为 `1.3.0`。当前 `codex/stable-v1-completion` 分支处于稳定 V1 验收阶段,重点是验证 Boss / 智联 Chrome Bridge 主链、猎聘 / 51job 兼容链、数据库迁移和人工确认边界。 +最新正式版本为 `1.3.0`。`codex/stable-v1-completion` 已作为待验收基线整理为面向 `main` 的 PR;当前功能分支已完成四平台能力注册表、统一 AI 分析契约和 Boss / 智联 AI 沟通助手的代码与自动化验证。 -仓库现已具备后端与前端 CI、Chrome 扩展校验、Docker 配置校验、CodeQL 安全扫描、Dependabot 依赖维护和 Release 产物校验。下一阶段先完成四平台分级能力与真实 smoke,再把已有 AI `greeting` 做成可见、可编辑、人工确认的沟通草稿;不会新增绕过人工确认的自动发送入口。详见 [ROADMAP.md](ROADMAP.md)。 +真实平台 smoke 仍未执行:Boss / 智联、猎聘 / 51job 都需要在操作当时由用户明确确认,并且第一轮只运行到待确认队列。验收前不会合并 `main`,也不会关闭被替代分支。详见 [ROADMAP.md](ROADMAP.md)。 ## 参与贡献 diff --git a/ROADMAP.md b/ROADMAP.md index 1cf76e0..1927b46 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -28,25 +28,27 @@ - [x] 在数据库副本上执行 V8 迁移演练并保护原库指纹 - [ ] Boss / 智联真实验证到待确认队列,不执行真实投递 - [ ] 猎聘 / 51job 验证现有采集、Profile 隔离和错误恢复 -- [ ] 将稳定 V1 完整差异收口为面向 `main` 的可审查 PR -- [ ] 校正文档、版本状态和平台能力说明 +- [x] 将稳定 V1 完整差异收口为面向 `main` 的可审查 PR(PR #42,尚未合并) +- [x] 校正文档、版本状态和平台能力说明 ## Next:四平台分级闭环 -- [ ] 建立统一平台能力注册表;Boss / 智联为一级质量,猎聘 / 51job 为二级兼容质量 -- [ ] 将四平台接入统一 AI 分析和待确认语义,同时保留各自采集实现 -- [ ] 为每个平台声明唯一正式执行模式,避免 Chrome Bridge 与旧 Worker 重复执行 -- [ ] 统一 `profileId + runId + requestKey` 关联信息和失败语义 -- [ ] 修复跨扫描岗位去重差异,并建立脱敏回归夹具 +- [x] 建立统一平台能力注册表;Boss / 智联为一级质量,猎聘 / 51job 为二级兼容质量 +- [x] 将四平台接入统一 AI 分析和待确认语义,同时保留各自采集实现 +- [x] 为每个平台声明唯一正式执行模式;猎聘 / 51job 的普通启动默认降级为只读采集 +- [ ] 完成全部 `profileId + runId + requestKey` 关联信息(Boss / 智联已串联;旧平台历史记录仍缺统一 runId) +- [x] 修复智联跨扫描岗位去重差异,并建立脱敏回归测试 ## After:AI 沟通助手 复用已有 AI `greeting`,先做可见、可编辑、可复制和人工确认,不增加独立自动发送入口: -- [ ] Boss / 智联展示 AI 原稿、人工草稿和明确来源 -- [ ] 人工草稿独立持久化,AI 重新分析不得覆盖用户编辑 -- [ ] 单个和批量确认前展示最终话术 -- [ ] 猎聘 / 51job 完成统一分析后复用相同能力 +- [x] Boss / 智联展示 AI 原稿、人工草稿和明确来源 +- [x] 人工草稿独立持久化,AI 重新分析不得覆盖用户编辑 +- [x] 单个和批量确认前展示最终话术,并校验预览快照未被并发改变 +- [ ] 猎聘 / 51job 完成统一分析后复用相同能力(后端草稿契约已支持,前端入口待真实 smoke 后补齐) + +当前代码门禁和 V9 数据库副本演练已经通过,但“每日可用核心”仍必须等四个平台在正确 Profile 下完成真实、只到待确认队列的 smoke 后才能宣布完成。 ## Later:首次体验与求职复盘 diff --git "a/doc/\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/doc/\344\275\277\347\224\250\346\214\207\345\215\227.md" index c40bb62..73ac24a 100644 --- "a/doc/\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/doc/\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -2,7 +2,7 @@ 本文面向希望直接运行投递牛马的使用者,覆盖环境准备、启动方式、网页端配置、平台使用和常见问题。 -当前项目阶段:`1.3.x 稳定 V1 验收阶段`。当前推荐工作流是配置检查 → 扫描岗位 → AI 分析 → 待确认 → 用户确认后投递 → 状态回写 → 分析统计。Boss / 智联是 Chrome Bridge 主链,猎聘 / 51job 暂时是旧 Playwright 兼容链。 +当前项目阶段:`1.3.x 稳定 V1 验收阶段`。当前推荐工作流是配置检查 → 扫描岗位 → AI 分析 → 待确认 → 核对沟通话术 → 用户确认后投递 → 状态回写 → 分析统计。Boss / 智联是一级 Chrome Bridge 主链,猎聘 / 51job 是二级旧 Playwright 兼容链。 ## 运行前准备 @@ -121,11 +121,19 @@ Boss 和智联在 1.3 版本继续使用并增强 Chrome Bridge 路线。它会 4. 扩展会打开或复用平台标签页,采集岗位列表和详情。 5. 后端收到岗位后执行 AI 分析,命中岗位会进入“待确认”。 6. 到对应平台的岗位分析页,筛选 `待确认`。 -7. 点击单个 Chrome 投递,或批量投递当前筛选的待确认岗位。 +7. 先核对每个岗位最终将使用的沟通话术,再点击单个 Chrome 投递或批量确认当前筛选岗位。 8. 投递完成后,状态会写回为 `已投递` 或 `投递失败`。 实际投递前请看清确认弹窗。建议每次先少量投递,确认账号没有触发安全验证后再继续。 +### 沟通话术编辑与确认 + +- Boss / 智联待确认卡片会显示话术来源:人工编辑稿、AI 原稿、档案默认话术或空白警告。 +- 点击“编辑沟通语”可以修改、复制或恢复 AI 原稿;人工稿会独立保存,重新分析岗位不会覆盖它。 +- 最终优先级是:人工编辑稿 → AI 原稿 → 当前档案默认话术 → 空白警告。 +- 单个确认、批量确认和显式重试都会再次显示最终话术。如果另一页面已经修改草稿,后端会拒绝旧预览并要求刷新。 +- Boss 会把确认后的话术交给 Chrome Bridge;智联当前提供保存和复制,扩展不会自动发送聊天消息。 + ## AI 配置 AI 配置主要用于 Boss 直聘岗位匹配和打招呼语生成。常见字段包括: @@ -165,13 +173,15 @@ MODEL=gpt-5-nano ### 猎聘 -- 支持扫码登录、岗位搜索、筛选和投递。 +- 支持扫码登录、岗位搜索、筛选、统一 AI 分析和待确认状态。 +- 页面“开始只读采集”不会执行投递;旧 Playwright 真实投递只保留为显式兼容模式。 - 通常需要在猎聘 App 里配置默认打招呼语。 - 原项目说明中提到猎聘更适合批量打招呼,但主动发消息可能存在上限。 ### 51job -- 支持配置、登录、投递、统计和结果列表。 +- 支持配置、登录、只读采集、统一 AI 分析、待确认状态、统计和结果列表。 +- 页面普通启动不会执行投递;旧 Playwright 真实投递只保留为显式兼容模式。 - 平台岗位质量和投递上限可能变化,建议小批量运行并观察结果。 ### 智联招聘 diff --git a/front/app/51job/analysis/AnalysisContent.tsx b/front/app/51job/analysis/AnalysisContent.tsx index bafcab2..7949357 100644 --- a/front/app/51job/analysis/AnalysisContent.tsx +++ b/front/app/51job/analysis/AnalysisContent.tsx @@ -36,6 +36,7 @@ type StatsResponse = { } type Job51Item = { + id?: number jobId: number companyName?: string jobName?: string @@ -50,6 +51,9 @@ type Job51Item = { createdAt?: string industry?: string companyScale?: string + aiScore?: number + aiDecision?: string + aiReason?: string } type PagedResult51 = { @@ -111,8 +115,9 @@ export default function AnalysisContent({ showHeader = false }:{ showHeader?: bo const [reloading,setReloading]=useState(false) const [exporting,setExporting]=useState(false) const [recoveringJobId,setRecoveringJobId]=useState(null) + const [analyzingJobId,setAnalyzingJobId]=useState(null) - const statusOptions = ["未投递", "投递确认中", "投递结果待确认", "已投递", "投递失败"] + const statusOptions = ["待确认", "AI分析中", "AI不匹配", "AI分析失败", "未投递", "投递确认中", "投递结果待确认", "已投递", "投递失败"] useEffect(()=>{ loadStats() },[]) useEffect(()=>{ setInputPage(page) },[page]) @@ -178,6 +183,19 @@ export default function AnalysisContent({ showHeader = false }:{ showHeader?: bo } } + const analyzeJob = async (job:Job51Item)=>{ + if (!job.id){ alert("该 51job 岗位缺少内部 ID,无法加入统一 AI 分析队列。"); return } + try{ + setAnalyzingJobId(job.id) + const response=await fetch(`${API_BASE}/api/platforms/51job/jobs/${job.id}/analyze`,{ method:"POST" }) + const data=await response.json().catch(()=>({})) + if (!response.ok || data.success===false) throw new Error(data.message||"加入 AI 分析队列失败") + alert(data.queued ? "已加入统一 AI 分析队列。" : (data.message||"该岗位已有分析任务。")) + await loadList(page,size) + }catch(error){ alert(error instanceof Error ? error.message : "加入 AI 分析队列失败") } + finally{ setAnalyzingJobId(null) } + } + const exportCSV = async ()=>{ try{ setExporting(true) const baseParams = new URLSearchParams() @@ -303,6 +321,10 @@ export default function AnalysisContent({ showHeader = false }:{ showHeader?: bo
{it.hrName||'-'}
{it.deliveryStatus||'-'} +
AI {it.aiScore??'-'} · {it.aiDecision||'未分析'}
+ {["未投递","AI分析失败","LIST_COLLECTED"].includes((it.deliveryStatus||"").trim()) && ( +
+ )} {it.deliveryStatus === "投递结果待确认" && (
diff --git a/front/app/51job/page.tsx b/front/app/51job/page.tsx index b5b8faf..dfc6346 100644 --- a/front/app/51job/page.tsx +++ b/front/app/51job/page.tsx @@ -338,11 +338,11 @@ export default function Job51Page() { ) : isDelivering ? ( ) : ( )}
) } diff --git a/front/app/boss/analysis/components/BossPendingCards.tsx b/front/app/boss/analysis/components/BossPendingCards.tsx index c56f038..0c54ba5 100644 --- a/front/app/boss/analysis/components/BossPendingCards.tsx +++ b/front/app/boss/analysis/components/BossPendingCards.tsx @@ -1,6 +1,6 @@ "use client" -import { BiBlock, BiBriefcase, BiCheckCircle, BiChevronDown, BiChevronUp, BiFilterAlt, BiLinkExternal, BiX } from "react-icons/bi" +import { BiBlock, BiBriefcase, BiCheckCircle, BiChevronDown, BiChevronUp, BiFilterAlt, BiLinkExternal, BiMessageDetail, BiX } from "react-icons/bi" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" @@ -14,6 +14,7 @@ function PendingJobCard({ riskText, onOpenText, onConfirm, + onEditGreeting, onSkip, onBlacklist, }: { @@ -23,6 +24,7 @@ function PendingJobCard({ riskText: string onOpenText: (title: string, content?: string) => void onConfirm: () => void + onEditGreeting: () => void onSkip: () => void onBlacklist: () => void }) { @@ -85,6 +87,18 @@ function PendingJobCard({ + +
{job.jobUrl ? ( + @@ -127,6 +144,7 @@ export function BossPendingCards({ onConfirmAiRecommendedBatch, onOpenText, onConfirmJob, + onEditGreeting, onSkipJob, onBlacklistCompany, }: { @@ -144,6 +162,7 @@ export function BossPendingCards({ onConfirmAiRecommendedBatch: () => void onOpenText: (title: string, content?: string) => void onConfirmJob: (job: BossJob) => void + onEditGreeting: (job: BossJob) => void onSkipJob: (job: BossJob) => void onBlacklistCompany: (job: BossJob) => void }) { @@ -190,6 +209,7 @@ export function BossPendingCards({ riskText={riskTextOf(job)} onOpenText={onOpenText} onConfirm={() => onConfirmJob(job)} + onEditGreeting={() => onEditGreeting(job)} onSkip={() => onSkipJob(job)} onBlacklist={() => onBlacklistCompany(job)} /> diff --git a/front/app/boss/analysis/hooks/useBossDeliveryActions.ts b/front/app/boss/analysis/hooks/useBossDeliveryActions.ts index 6442b00..2cc2a5e 100644 --- a/front/app/boss/analysis/hooks/useBossDeliveryActions.ts +++ b/front/app/boss/analysis/hooks/useBossDeliveryActions.ts @@ -2,11 +2,20 @@ import { useCallback, useState } from "react" -import { API_BASE } from "@/lib/api" +import { API_BASE, readApiResponse } from "@/lib/api" import { sendChromeBridgeMessage } from "@/lib/chromeBridge" 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 greetingSnapshots(items: BatchPreviewItem[]) { + return Object.fromEntries(items + .filter((item): item is BatchPreviewItem & { id: number; greeting: string } => ( + typeof item.id === "number" && typeof item.greeting === "string" + )) + .map((item) => [String(item.id), item.greeting])) +} async function postJsonWithRetry(url: string, body?: unknown) { let lastError: unknown @@ -50,6 +59,23 @@ function formatBatchDeliveryResult(result: Record) { return `${summary}\n\n逐条结果:\n${details.join("\n")}` } +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 || "【空白】"}` + )) + return `${title}\n\n将使用以下 ${items.length} 条沟通话术:\n\n${lines.join("\n\n")}\n\n确认后才会创建投递任务并交给 Chrome。` +} + +async function loadBatchPreview(body: unknown) { + const data = await postJsonWithRetry(`${API_BASE}/api/boss/jobs/confirm-batch/preview`, body) + return { + success: data.success !== false, + message: String(data.message || ""), + items: Array.isArray(data.items) ? data.items as BatchPreviewItem[] : [], + } +} + async function markUnknownReservations(tasks: ReservedTask[], reason: string) { const results = await Promise.allSettled(tasks.map(async (task) => { if (!task.id || !task.requestKey) return @@ -122,13 +148,11 @@ export function useBossDeliveryActions({ } }, [openTextDialog]) - const handleConfirmJob = useCallback(async (job: BossJob) => { + const handleConfirmJob = useCallback(async (job: BossJob, greetingSnapshot: string) => { let reservedTasks: ReservedTask[] = [] try { setActingJobId(job.id) - const ok = window.confirm(`将通过 Chrome 真实联系 Boss HR:${job.companyName || ""} / ${job.jobName || ""}。确认继续?`) - if (!ok) return - const data = await postJsonWithRetry(`${API_BASE}/api/boss/jobs/${job.id}/confirm`) + const data = await postJsonWithRetry(`${API_BASE}/api/boss/jobs/${job.id}/confirm`, { greetingSnapshot }) if (!data.success) { openTextDialog("确认投递", data.message || "该岗位暂不能投递。") return @@ -176,11 +200,18 @@ export function useBossDeliveryActions({ const handleRetryJob = useCallback(async (job: BossJob) => { let reservedTasks: ReservedTask[] = [] - const ok = window.confirm("这会创建新的投递 attempt,并可能再次联系该 Boss HR。确认显式重试?") - if (!ok) return try { setActingJobId(job.id) - const data = await postJsonWithRetry(`${API_BASE}/api/boss/jobs/${job.id}/delivery-retry`) + const greetingResponse = await fetch(`${API_BASE}/api/platforms/boss/jobs/${job.id}/greeting`) + const greetingResult = await readApiResponse<{ finalGreeting: string; greetingSource: string }>(greetingResponse, "读取最终沟通话术失败") + const finalGreeting = greetingResult.data?.finalGreeting || "" + if (!finalGreeting.trim()) { + openTextDialog("重试投递", "最终沟通话术为空,请先编辑后再重试。") + return + } + const ok = window.confirm(`这会创建新的投递 attempt,并可能再次联系该 Boss HR。\n\n最终话术:\n${finalGreeting}\n\n确认显式重试?`) + if (!ok) return + const data = await postJsonWithRetry(`${API_BASE}/api/boss/jobs/${job.id}/delivery-retry`, { greetingSnapshot: finalGreeting }) if (!data.success || !data.task) { openTextDialog("重试投递", data.message || "当前岗位不能重试。") return @@ -221,9 +252,22 @@ export function useBossDeliveryActions({ let reservedTasks: ReservedTask[] = [] try { setActingBatch(true) - const ok = window.confirm("将通过 Chrome 真实联系当前筛选范围内的 Boss 待确认岗位。确认继续?") + const body = currentBatchFilters() + const preview = await loadBatchPreview(body) + if (!preview.success || preview.items.length === 0) { + openTextDialog("批量投递预览", preview.message || "当前筛选条件下没有待确认岗位。") + return + } + if (preview.items.some((item) => item.empty || !item.greeting?.trim())) { + openTextDialog("批量投递预览", "批量范围内存在空白沟通话术,请逐条编辑后再确认。") + return + } + const ok = window.confirm(formatBatchGreetingPreview(preview.items, "Boss 批量投递预览")) if (!ok) return - const data = await postJsonWithRetry(`${API_BASE}/api/boss/jobs/confirm-batch`, currentBatchFilters()) + const data = await postJsonWithRetry(`${API_BASE}/api/boss/jobs/confirm-batch`, { + ...body, + greetingSnapshots: greetingSnapshots(preview.items), + }) const tasks = data.tasks || [] reservedTasks = tasks if (!data.success || tasks.length === 0) { @@ -254,11 +298,24 @@ export function useBossDeliveryActions({ let reservedTasks: ReservedTask[] = [] try { setActingAiBatch(true) - const ok = window.confirm("将通过 Chrome 真实联系当前批次中 AI 推荐的 Boss 待确认岗位。确认继续?") - if (!ok) return - const data = await postJsonWithRetry(`${API_BASE}/api/boss/jobs/confirm-batch`, { + const body = { aiRecommendedOnly: true, scanRunId: activeScanRunId || undefined, + } + const preview = await loadBatchPreview(body) + if (!preview.success || preview.items.length === 0) { + openTextDialog("AI推荐投递预览", preview.message || "当前没有 AI 推荐的待确认岗位。") + return + } + if (preview.items.some((item) => item.empty || !item.greeting?.trim())) { + openTextDialog("AI推荐投递预览", "推荐范围内存在空白沟通话术,请逐条编辑后再确认。") + return + } + const ok = window.confirm(formatBatchGreetingPreview(preview.items, "Boss AI 推荐投递预览")) + if (!ok) return + const data = await postJsonWithRetry(`${API_BASE}/api/boss/jobs/confirm-batch`, { + ...body, + greetingSnapshots: greetingSnapshots(preview.items), }) const tasks = data.tasks || [] reservedTasks = tasks @@ -296,14 +353,21 @@ export function useBossDeliveryActions({ let reservedTasks: ReservedTask[] = [] try { setActingManualBatch(true) - const ok = window.confirm( - `AI 已将这些岗位判定为不匹配。你正在按人工判断强制投递 ${uniqueIds.length} 个岗位,` - + "将通过 Chrome 真实联系 Boss HR。确认继续?", - ) + const body = { ids: uniqueIds, manualOverrideAiNotMatch: true } + const preview = await loadBatchPreview(body) + if (!preview.success || preview.items.length === 0) { + openTextDialog("人工投递预览", preview.message || "所选岗位中没有可人工投递的AI不匹配岗位。") + return false + } + if (preview.items.some((item) => item.empty || !item.greeting?.trim())) { + openTextDialog("人工投递预览", "所选岗位中存在空白沟通话术,请逐条编辑后再确认。") + return false + } + const ok = window.confirm(formatBatchGreetingPreview(preview.items, `人工覆盖 ${uniqueIds.length} 个 AI 不匹配岗位`)) if (!ok) return false const data = await postJsonWithRetry(`${API_BASE}/api/boss/jobs/confirm-batch`, { - ids: uniqueIds, - manualOverrideAiNotMatch: true, + ...body, + greetingSnapshots: greetingSnapshots(preview.items), }) const tasks = data.tasks || [] reservedTasks = tasks diff --git a/front/app/boss/analysis/types.ts b/front/app/boss/analysis/types.ts index c444f2d..0b7063f 100644 --- a/front/app/boss/analysis/types.ts +++ b/front/app/boss/analysis/types.ts @@ -72,6 +72,11 @@ export type BossJob = { sourceKeyword?: string scanRunId?: string createdAt?: string + aiGreeting?: string + greetingDraft?: string + greetingSource?: "USER_EDITED" | "AI_GREETING" | "PROFILE_DEFAULT" | "EMPTY" + greetingUpdatedAt?: string | null + finalGreeting?: string } export type PagedResult = { diff --git a/front/app/liepin/analysis/AnalysisContent.tsx b/front/app/liepin/analysis/AnalysisContent.tsx index baafe7a..4b1b98d 100644 --- a/front/app/liepin/analysis/AnalysisContent.tsx +++ b/front/app/liepin/analysis/AnalysisContent.tsx @@ -39,6 +39,7 @@ type StatsResponse = { } type LiepinJob = { + id?: number jobId: number compName?: string compIndustry?: string @@ -55,6 +56,9 @@ type LiepinJob = { delivered?: number deliveryStatus?: string createTime?: string + aiScore?: number + aiDecision?: string + aiReason?: string } function deliveryStatusOf(job: LiepinJob) { @@ -234,10 +238,11 @@ export default function AnalysisContent({ showHeader = false }: { showHeader?: b const [loadingList, setLoadingList] = useState(false) const [exporting, setExporting] = useState(false) const [recoveringJobId, setRecoveringJobId] = useState(null) + const [analyzingJobId, setAnalyzingJobId] = useState(null) const [detailJob, setDetailJob] = useState(null) const [computedSalaryBuckets, setComputedSalaryBuckets] = useState([]) - const statusOptions = ["未投递", "投递确认中", "投递结果待确认", "已投递", "投递失败"] + const statusOptions = ["待确认", "AI分析中", "AI不匹配", "AI分析失败", "未投递", "投递确认中", "投递结果待确认", "已投递", "投递失败"] useEffect(() => { loadStats() @@ -414,6 +419,25 @@ export default function AnalysisContent({ showHeader = false }: { showHeader?: b } } + const analyzeJob = async (job: LiepinJob) => { + if (!job.id) { + alert("该猎聘岗位缺少内部 ID,无法加入统一 AI 分析队列。") + return + } + try { + setAnalyzingJobId(job.id) + const response = await fetch(`${API_BASE}/api/platforms/liepin/jobs/${job.id}/analyze`, { method: "POST" }) + const data = await response.json().catch(() => ({})) + if (!response.ok || data.success === false) throw new Error(data.message || "加入 AI 分析队列失败") + alert(data.queued ? "已加入统一 AI 分析队列。" : (data.message || "该岗位已有分析任务。")) + await loadList(page, size) + } catch (error) { + alert(error instanceof Error ? error.message : "加入 AI 分析队列失败") + } finally { + setAnalyzingJobId(null) + } + } + // 当后端的薪资分布为空或全零时,使用全量分页数据计算分布 const refreshComputedSalaryBuckets = async () => { try { @@ -817,6 +841,14 @@ export default function AnalysisContent({ showHeader = false }: { showHeader?: b {it.hrName || ""} {deliveryStatusOf(it)} +
AI {it.aiScore ?? "-"} · {it.aiDecision || "未分析"}
+ {["未投递", "AI分析失败", "LIST_COLLECTED"].includes(deliveryStatusOf(it)) && ( +
+ +
+ )} {deliveryStatusOf(it) === "投递结果待确认" && (
diff --git a/front/app/liepin/page.tsx b/front/app/liepin/page.tsx index 959dc02..2d0fbd0 100644 --- a/front/app/liepin/page.tsx +++ b/front/app/liepin/page.tsx @@ -282,11 +282,11 @@ export default function LiepinPage() { ) : isDelivering ? ( ) : ( )}
+ +
{job.jobLink ? ( +
@@ -596,6 +633,8 @@ export default function AnalysisContent({ showHeader = false, refreshSignal = 0 const [actingJobId, setActingJobId] = useState(null) const [actingBatch, setActingBatch] = useState(false) const [pendingCardsExpanded, setPendingCardsExpanded] = useState(false) + const [greetingJob, setGreetingJob] = useState(null) + const [greetingConfirmMode, setGreetingConfirmMode] = useState(false) const activeScanRunId = "" const statusOptions = ["待确认", "投递确认中", "投递结果待确认", "AI分析中", "未投递", "已投递", "已过滤", "投递失败", "AI不匹配", "AI分析失败"] @@ -850,7 +889,7 @@ export default function AnalysisContent({ showHeader = false, refreshSignal = 0 scanRunId: activeScanRunId || undefined, }) - const handleConfirmJob = async (job: ZhilianJob) => { + const handleConfirmJob = async (job: ZhilianJob, greetingSnapshot: string) => { if (!job.id) { alert("该智联岗位缺少内部ID,无法确认投递。") return @@ -858,9 +897,7 @@ export default function AnalysisContent({ showHeader = false, refreshSignal = 0 let reservedTasks: Array<{ id?: number; requestKey?: string }> = [] try { setActingJobId(job.id) - const ok = window.confirm(`将通过 Chrome 真实申请智联岗位:${job.companyName || ""} / ${job.jobTitle || ""}。确认继续?`) - if (!ok) return - const data = await postZhilianJsonWithRetry(`${API_BASE}/api/zhilian/jobs/${job.id}/confirm`) + const data = await postZhilianJsonWithRetry(`${API_BASE}/api/zhilian/jobs/${job.id}/confirm`, { greetingSnapshot }) if (!data.success) { alert(data.message || "该智联岗位暂不能投递。") return @@ -911,12 +948,19 @@ export default function AnalysisContent({ showHeader = false, refreshSignal = 0 const handleRetryJob = async (job: ZhilianJob) => { if (!job.id) return - const ok = window.confirm("这会创建新的投递 attempt,并可能再次申请该智联岗位。确认显式重试?") - if (!ok) return let reservedTasks: Array<{ id?: number; requestKey?: string }> = [] try { setActingJobId(job.id) - const data = await postZhilianJsonWithRetry(`${API_BASE}/api/zhilian/jobs/${job.id}/delivery-retry`) + const greetingResponse = await fetch(`${API_BASE}/api/platforms/zhilian/jobs/${job.id}/greeting`) + const greetingResult = await readApiResponse<{ finalGreeting: string }>(greetingResponse, "读取最终沟通话术失败") + const finalGreeting = greetingResult.data?.finalGreeting || "" + if (!finalGreeting.trim()) { + alert("最终沟通话术为空,请先编辑后再重试。") + return + } + const ok = window.confirm(`这会创建新的投递 attempt,并可能再次申请该智联岗位。\n\n最终话术:\n${finalGreeting}\n\n确认显式重试?`) + if (!ok) return + const data = await postZhilianJsonWithRetry(`${API_BASE}/api/zhilian/jobs/${job.id}/delivery-retry`, { greetingSnapshot: finalGreeting }) if (!data.success || !data.task) { alert(data.message || "当前岗位不能重试。") return @@ -946,9 +990,23 @@ export default function AnalysisContent({ showHeader = false, refreshSignal = 0 let reservedTasks: Array<{ id?: number; requestKey?: string }> = [] try { setActingBatch(true) - const ok = window.confirm("将通过 Chrome 真实申请当前筛选范围内的智联待确认岗位。确认继续?") + const body = currentBatchFilters() + const preview = await postZhilianJsonWithRetry(`${API_BASE}/api/zhilian/jobs/confirm-batch/preview`, body) + const previewItems = Array.isArray(preview.items) ? preview.items as ZhilianBatchPreviewItem[] : [] + if (preview.success === false || previewItems.length === 0) { + alert(preview.message || "当前筛选条件下没有智联待确认岗位。") + return + } + if (previewItems.some((item) => item.empty || !item.greeting?.trim())) { + alert("批量范围内存在空白沟通话术,请逐条编辑后再确认。") + return + } + const ok = window.confirm(formatZhilianGreetingPreview(previewItems)) if (!ok) return - const data = await postZhilianJsonWithRetry(`${API_BASE}/api/zhilian/jobs/confirm-batch`, currentBatchFilters()) + const data = await postZhilianJsonWithRetry(`${API_BASE}/api/zhilian/jobs/confirm-batch`, { + ...body, + greetingSnapshots: zhilianGreetingSnapshots(previewItems), + }) const tasks = data.tasks || [] reservedTasks = tasks if (!data.success || tasks.length === 0) { @@ -1020,6 +1078,26 @@ export default function AnalysisContent({ showHeader = false, refreshSignal = 0 pendingCardsExpanded ? pendingJobs : pendingJobs.slice(0, 2) ), [pendingCardsExpanded, pendingJobs]) + const openGreetingDialog = (job: ZhilianJob, confirmMode: boolean) => { + if (!job.id) { + alert("该智联岗位缺少内部 ID,无法编辑沟通草稿。") + return + } + setGreetingJob(job) + setGreetingConfirmMode(confirmMode) + } + + const greetingDialogJob: GreetingJob | null = greetingJob?.id ? { + id: greetingJob.id, + companyName: greetingJob.companyName, + jobName: greetingJob.jobTitle, + aiGreeting: greetingJob.aiGreeting || "", + greetingDraft: greetingJob.greetingDraft || "", + greetingSource: greetingJob.greetingSource || "EMPTY", + greetingUpdatedAt: greetingJob.greetingUpdatedAt || null, + finalGreeting: greetingJob.finalGreeting || "", + } : null + return (
{showHeader && ( @@ -1156,7 +1234,8 @@ export default function AnalysisContent({ showHeader = false, refreshSignal = 0 key={job.id || job.jobId} job={job} acting={actingJobId === job.id} - onConfirm={() => handleConfirmJob(job)} + onConfirm={() => openGreetingDialog(job, true)} + onEditGreeting={() => openGreetingDialog(job, false)} /> ))}
@@ -1317,7 +1396,7 @@ export default function AnalysisContent({ showHeader = false, refreshSignal = 0