Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Contribute to GitLab
Sign in
Toggle navigation
R
recruit-sys
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
李文光
recruit-sys
Commits
be7f8224
Commit
be7f8224
authored
Sep 03, 2026
by
李文光
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: 分析报告 v2 富报告(Phase B)——SSE生成+预览写回+JD逐条比对+打印水印, 修复风险条目JSON外漏
parent
09cee42c
Show whitespace changes
Inline
Side-by-side
Showing
11 changed files
with
1246 additions
and
399 deletions
+1246
-399
README.md
README.md
+1
-0
report_repository.py
backend/app/repositories/report_repository.py
+9
-4
reports.py
backend/app/routers/reports.py
+75
-15
candidate_report_v2.py
backend/app/services/candidate_report_v2.py
+168
-0
test_reports.py
backend/tests/test_reports.py
+95
-0
0002-candidate-report-share.md
docs/adr/0002-candidate-report-share.md
+16
-0
recruitment.js
vue-app/src/api/recruitment.js
+54
-2
ReportContentView.vue
vue-app/src/components/reports/ReportContentView.vue
+624
-0
ResumeDetail.vue
vue-app/src/components/resumes/ResumeDetail.vue
+139
-15
report.js
vue-app/src/utils/report.js
+26
-1
ShareReportView.vue
vue-app/src/views/ShareReportView.vue
+39
-362
No files found.
README.md
View file @
be7f8224
...
@@ -171,6 +171,7 @@ backend\.venv\Scripts\python.exe backend/scripts/start_server.py --with-frontend
...
@@ -171,6 +171,7 @@ backend\.venv\Scripts\python.exe backend/scripts/start_server.py --with-frontend
| GET |
`/
api/ingest-queue
`
| 查看待确认采集队列 |
| GET |
`/
api/ingest-queue
`
| 查看待确认采集队列 |
| POST |
`/
api/ingest-queue/clear
`
| 确认并清空采集队列 |
| POST |
`/
api/ingest-queue/clear
`
| 确认并清空采集队列 |
| POST |
`/
api/reports
`
| 生成候选人分析报告(冻结 AI 评估快照 + 公网分享链接) |
| POST |
`/
api/reports
`
| 生成候选人分析报告(冻结 AI 评估快照 + 公网分享链接) |
| POST |
`/
api/reports/generate-v2/stream
`
| v2 富报告 SSE 流式生成(预览 → 仅快照 / 写回) |
| GET |
`/
api/reports?candidateId
=
`
| 列出候选人分析报告历史 |
| GET |
`/
api/reports?candidateId
=
`
| 列出候选人分析报告历史 |
| PATCH |
`/
api/reports/
{
reportId
}
`
| 停用 / 重新启用分析报告分享 |
| PATCH |
`/
api/reports/
{
reportId
}
`
| 停用 / 重新启用分析报告分享 |
| GET |
`/
api/share/report/
{
reportId
}
`
| 分析报告公网匿名只读(需链接内 token) |
| GET |
`/
api/share/report/
{
reportId
}
`
| 分析报告公网匿名只读(需链接内 token) |
...
...
backend/app/repositories/report_repository.py
View file @
be7f8224
...
@@ -31,10 +31,14 @@ class ReportError(Exception):
...
@@ -31,10 +31,14 @@ class ReportError(Exception):
super
()
.
__init__
(
detail
)
super
()
.
__init__
(
detail
)
def
build_snapshot
(
candidate
:
Candidate
)
->
dict
[
str
,
Any
]:
def
build_snapshot
(
candidate
:
Candidate
,
analysis_override
:
dict
[
str
,
Any
]
|
None
=
None
)
->
dict
[
str
,
Any
]:
"""从候选人行 data JSON 冻结分享快照,只取对外安全字段。"""
"""从候选人行 data JSON 冻结分享快照,只取对外安全字段。
analysis_override:v2「仅快照(不写回)」分支直接传入新生成的富分析对象 R;
否则复用候选行里已持久化的 analysis(v1 路径)。
"""
data
=
parse_json
(
candidate
.
data
,
{})
data
=
parse_json
(
candidate
.
data
,
{})
analysis
=
data
.
get
(
"analysis"
)
analysis
=
analysis_override
if
analysis_override
is
not
None
else
data
.
get
(
"analysis"
)
if
not
isinstance
(
analysis
,
dict
)
or
analysis
.
get
(
"score"
)
in
(
None
,
""
):
if
not
isinstance
(
analysis
,
dict
)
or
analysis
.
get
(
"score"
)
in
(
None
,
""
):
raise
ReportError
(
400
,
"候选人尚未生成有效的 AI 评估报告,请先生成后再分享"
)
raise
ReportError
(
400
,
"候选人尚未生成有效的 AI 评估报告,请先生成后再分享"
)
return
{
return
{
...
@@ -80,8 +84,9 @@ def create_report(
...
@@ -80,8 +84,9 @@ def create_report(
author_username
:
str
,
author_username
:
str
,
author_name
:
str
,
author_name
:
str
,
base_url
:
str
,
base_url
:
str
,
analysis_override
:
dict
[
str
,
Any
]
|
None
=
None
,
)
->
dict
[
str
,
Any
]:
)
->
dict
[
str
,
Any
]:
snapshot
=
build_snapshot
(
candidate
)
snapshot
=
build_snapshot
(
candidate
,
analysis_override
)
snapshot
[
"author"
]
=
{
"username"
:
author_username
,
"name"
:
author_name
}
snapshot
[
"author"
]
=
{
"username"
:
author_username
,
"name"
:
author_name
}
snapshot
[
"generatedAt"
]
=
utc_now_iso
()
snapshot
[
"generatedAt"
]
=
utc_now_iso
()
now
=
utc_now_iso
()
now
=
utc_now_iso
()
...
...
backend/app/routers/reports.py
View file @
be7f8224
"""分析报告(候选人报告)路由(ADR 0002)。
"""分析报告(候选人报告)路由(ADR 0002
/ Phase B v2
)。
- POST /api/reports 为当前工作区候选人冻结一份分享快照
管理端点(登录态 + resolve_owner 隔离):
-
GET /api/reports?candidateId=... 列出该候选人报告历史(附分享开关状态
)
-
POST /api/reports 冻结快照并创建分享(可选 analysis 覆盖 → v2「仅快照」
)
-
PATCH /api/reports/{report_id} 停用 / 重新启用({status: active|disabled}
)
-
GET /api/reports?candidateId=... 列出候选人报告历史(附分享开关状态
)
-
GET /api/share/report/{report_id} 公网匿名只读(带不可猜 token,security.py 放行)
-
PATCH /api/reports/{report_id} 停用 / 重新启用
- POST /api/reports/generate-v2/stream v2 富报告 SSE 流式生成(LLM → 本地回退)
管理端点一律登录态 + resolve_owner 工作区隔离;公开只读端点独立于账号体系,
公开只读(匿名,security.py 放行 GET /api/share/):
路由内部校验 report_id + token,失败统一按「不存在/已失效」处理。
- GET /api/share/report/{report_id} 带不可猜 token 读取快照
"""
"""
import
json
from
collections.abc
import
AsyncIterator
from
typing
import
Any
from
typing
import
Any
from
fastapi
import
APIRouter
,
Depends
,
HTTPException
,
Request
from
fastapi
import
APIRouter
,
Depends
,
HTTPException
,
Request
from
fastapi.responses
import
StreamingResponse
from
sqlalchemy
import
select
from
sqlalchemy
import
select
from
sqlalchemy.orm
import
Session
from
sqlalchemy.orm
import
Session
from
backend.app.config
import
get_settings
from
backend.app.config
import
get_settings
from
backend.app.db
import
get_db
from
backend.app.db
import
get_db
from
backend.app.dependencies
import
CurrentUser
,
get_current_user
,
resolve_owner
from
backend.app.dependencies
import
CurrentUser
,
get_current_user
,
resolve_owner
from
backend.app.models
import
Candidate
from
backend.app.models
import
Candidate
,
Job
from
backend.app.repositories.json_utils
import
parse_json
from
backend.app.repositories.pool_repository
import
POOL_OWNER
from
backend.app.repositories.pool_repository
import
POOL_OWNER
from
backend.app.repositories.report_repository
import
(
from
backend.app.repositories.report_repository
import
(
ReportError
,
ReportError
,
...
@@ -27,6 +31,7 @@ from backend.app.repositories.report_repository import (
...
@@ -27,6 +31,7 @@ from backend.app.repositories.report_repository import (
read_public_report
,
read_public_report
,
set_report_status
,
set_report_status
,
)
)
from
backend.app.services.candidate_report_v2
import
generate_v2_analysis
router
=
APIRouter
()
router
=
APIRouter
()
...
@@ -44,6 +49,15 @@ def _candidate_id(payload: dict[str, Any]) -> str:
...
@@ -44,6 +49,15 @@ def _candidate_id(payload: dict[str, Any]) -> str:
return
value
return
value
def
_candidate_row
(
db
:
Session
,
candidate_id
:
str
,
owner
:
str
)
->
Candidate
:
if
owner
==
POOL_OWNER
:
raise
HTTPException
(
status_code
=
400
,
detail
=
"人才池候选人的分析报告暂不支持,请先认领到工作区"
)
candidate
=
db
.
scalar
(
select
(
Candidate
)
.
where
(
Candidate
.
id
==
candidate_id
,
Candidate
.
owner_id
==
owner
))
if
candidate
is
None
:
raise
HTTPException
(
status_code
=
404
,
detail
=
"候选人不存在或不属于当前工作区"
)
return
candidate
@
router
.
post
(
"/reports"
)
@
router
.
post
(
"/reports"
)
def
generate_report
(
def
generate_report
(
payload
:
dict
[
str
,
Any
],
payload
:
dict
[
str
,
Any
],
...
@@ -52,12 +66,11 @@ def generate_report(
...
@@ -52,12 +66,11 @@ def generate_report(
user
:
CurrentUser
=
Depends
(
get_current_user
),
user
:
CurrentUser
=
Depends
(
get_current_user
),
owner
:
str
=
Depends
(
resolve_owner
),
owner
:
str
=
Depends
(
resolve_owner
),
)
->
dict
[
str
,
Any
]:
)
->
dict
[
str
,
Any
]:
if
owner
==
POOL_OWNER
:
raise
HTTPException
(
status_code
=
400
,
detail
=
"人才池候选人的分析报告暂不支持,请先认领到工作区"
)
candidate_id
=
_candidate_id
(
payload
)
candidate_id
=
_candidate_id
(
payload
)
candidate
=
db
.
scalar
(
select
(
Candidate
)
.
where
(
Candidate
.
id
==
candidate_id
,
Candidate
.
owner_id
==
owner
))
candidate
=
_candidate_row
(
db
,
candidate_id
,
owner
)
if
candidate
is
None
:
analysis_override
=
payload
.
get
(
"analysis"
)
raise
HTTPException
(
status_code
=
404
,
detail
=
"候选人不存在或不属于当前工作区"
)
if
analysis_override
is
not
None
and
not
isinstance
(
analysis_override
,
dict
):
raise
HTTPException
(
status_code
=
400
,
detail
=
"analysis 参数必须是对象"
)
try
:
try
:
report
=
create_report
(
report
=
create_report
(
db
,
db
,
...
@@ -66,6 +79,7 @@ def generate_report(
...
@@ -66,6 +79,7 @@ def generate_report(
author_username
=
user
.
username
,
author_username
=
user
.
username
,
author_name
=
user
.
name
,
author_name
=
user
.
name
,
base_url
=
_base_url
(
request
),
base_url
=
_base_url
(
request
),
analysis_override
=
analysis_override
,
)
)
except
ReportError
as
exc
:
except
ReportError
as
exc
:
raise
HTTPException
(
status_code
=
exc
.
status_code
,
detail
=
exc
.
detail
)
from
exc
raise
HTTPException
(
status_code
=
exc
.
status_code
,
detail
=
exc
.
detail
)
from
exc
...
@@ -113,6 +127,52 @@ def update_report_status(
...
@@ -113,6 +127,52 @@ def update_report_status(
return
{
"ok"
:
True
,
"report"
:
report
}
return
{
"ok"
:
True
,
"report"
:
report
}
@
router
.
post
(
"/reports/generate-v2/stream"
)
async
def
generate_v2_report_stream
(
payload
:
dict
[
str
,
Any
],
db
:
Session
=
Depends
(
get_db
),
user
:
CurrentUser
=
Depends
(
get_current_user
),
owner
:
str
=
Depends
(
resolve_owner
),
)
->
StreamingResponse
:
"""v2 富报告 SSE 流式生成(Phase B)。
在同步段先锁定候选/岗位数据,事件流只做异步 LLM/本地组装:
- {"type":"stage","stage":"...","message":"..."} 进度
- {"type":"done","analysis":{...}} 完成(v2 富对象 R)
- {"type":"error","message":"..."} 失败
"""
candidate_id
=
_candidate_id
(
payload
)
candidate
=
_candidate_row
(
db
,
candidate_id
,
owner
)
candidate_data
=
parse_json
(
candidate
.
data
,
{})
job_id
=
candidate
.
primary_job_id
or
candidate_data
.
get
(
"jobId"
)
job_row
=
db
.
scalar
(
select
(
Job
)
.
where
(
Job
.
id
==
job_id
,
Job
.
owner_id
==
owner
))
if
job_id
else
None
job_data
=
parse_json
(
job_row
.
data
,
{})
if
job_row
else
{}
if
job_row
is
not
None
:
job_data
=
{
**
job_data
,
"id"
:
job_row
.
id
,
"title"
:
job_data
.
get
(
"title"
)
or
job_row
.
title
or
""
}
async
def
event_stream
()
->
AsyncIterator
[
str
]:
def
emit
(
event
:
dict
[
str
,
Any
])
->
str
:
return
f
"data: {json.dumps(event, ensure_ascii=False)}
\n\n
"
yield
emit
({
"type"
:
"stage"
,
"stage"
:
"preparing"
,
"message"
:
"正在读取候选人与岗位资料…"
})
if
job_row
is
None
or
not
str
(
job_data
.
get
(
"title"
)
or
""
)
.
strip
():
yield
emit
({
"type"
:
"error"
,
"message"
:
"请先为候选人选择并确认关联岗位,再生成报告"
})
return
yield
emit
({
"type"
:
"stage"
,
"stage"
:
"analyzing"
,
"message"
:
"AI 正在做初筛评估与逐条比对…"
})
try
:
analysis
=
await
generate_v2_analysis
(
candidate_data
,
job_data
)
except
Exception
as
exc
:
# pragma: no cover - defensive
yield
emit
({
"type"
:
"error"
,
"message"
:
f
"报告生成失败: {exc}"
})
return
yield
emit
({
"type"
:
"done"
,
"analysis"
:
analysis
})
return
StreamingResponse
(
event_stream
(),
media_type
=
"text/event-stream"
,
headers
=
{
"Cache-Control"
:
"no-cache"
,
"X-Accel-Buffering"
:
"no"
},
)
@
router
.
get
(
"/share/report/{report_id}"
)
@
router
.
get
(
"/share/report/{report_id}"
)
def
public_share_report
(
report_id
:
str
,
request
:
Request
,
db
:
Session
=
Depends
(
get_db
))
->
dict
[
str
,
Any
]:
def
public_share_report
(
report_id
:
str
,
request
:
Request
,
db
:
Session
=
Depends
(
get_db
))
->
dict
[
str
,
Any
]:
"""公网匿名只读:不依赖登录态,报告路由在 security.py 已放行 GET。"""
"""公网匿名只读:不依赖登录态,报告路由在 security.py 已放行 GET。"""
...
...
backend/app/services/candidate_report_v2.py
0 → 100644
View file @
be7f8224
"""v2 分析报告生成(Phase B,ADR 0002 追加)。
设计(与决策一致):
1. 先生成 v1 兼容基础评估(复用 analyze_resume 的 LLM → local-structured 回退链);
2. 再生成「对外富报告」字段(conclusion/grade/highlights/jdCompare/dimensionNotes),
LLM 优先、无 Key 时本地规则组装,绝不 500;
3. 输出单一富对象 R = 基础评估 + v2 字段 + version:2,供「预览 → 仅快照 / 写回」两分支复用。
铁律对齐 llm.py:候选人在发送前脱敏;岗位内部字段(_sanitize_job_for_external)不进 prompt;
自述优势不作为亮点/JD 比对证据。
"""
import
json
from
typing
import
Any
from
backend.app.config
import
get_settings
from
backend.app.services.llm
import
(
_chat_json
,
_sanitize_job_for_external
,
analyze_resume
,
job_criteria
,
)
_GRADE_RULES
=
((
85
,
"A"
),
(
72
,
"B"
),
(
60
,
"C"
))
def
_list
(
value
:
Any
)
->
list
[
str
]:
if
isinstance
(
value
,
list
):
return
[
str
(
item
)
.
strip
()
for
item
in
value
if
str
(
item
)
.
strip
()]
return
[]
def
grade_for_score
(
score
:
Any
)
->
str
:
try
:
value
=
int
(
round
(
float
(
score
)))
except
(
TypeError
,
ValueError
):
return
"C"
for
threshold
,
grade
in
_GRADE_RULES
:
if
value
>=
threshold
:
return
grade
return
"D"
def
_find_sentence
(
keyword
:
str
,
sentences
:
list
[
str
])
->
str
:
needle
=
str
(
keyword
or
""
)
.
strip
()
.
lower
()
for
sentence
in
sentences
:
if
needle
and
needle
in
str
(
sentence
)
.
lower
():
return
str
(
sentence
)
return
""
def
local_jd_compare
(
base
:
dict
[
str
,
Any
],
criteria
:
dict
[
str
,
list
[
str
]])
->
list
[
dict
[
str
,
str
]]:
hit_pool
=
[
*
_list
(
base
.
get
(
"hardMatch"
)),
*
_list
(
base
.
get
(
"strengths"
)),
*
_list
(
base
.
get
(
"projectEvidence"
)),
*
_list
(
base
.
get
(
"evidence"
)),
]
miss_pool
=
[
*
_list
(
base
.
get
(
"risks"
)),
*
_list
(
base
.
get
(
"interviewQuestions"
))]
matched_terms
=
[
str
(
item
)
for
item
in
_list
(
base
.
get
(
"evidence"
))]
rows
:
list
[
dict
[
str
,
str
]]
=
[]
def
add_row
(
requirement
:
str
)
->
None
:
evidence
=
_find_sentence
(
requirement
,
hit_pool
)
if
evidence
and
any
(
token
in
evidence
for
token
in
(
"待确认"
,
"缺口"
,
"未体现"
,
"未在简历"
,
"建议面试"
)):
conclusion
=
"待确认"
elif
evidence
:
conclusion
=
"匹配"
elif
_find_sentence
(
requirement
,
miss_pool
)
or
requirement
in
matched_terms
or
not
requirement
:
conclusion
=
"未体现"
else
:
conclusion
=
"待确认"
rows
.
append
({
"requirement"
:
requirement
,
"evidence"
:
evidence
,
"conclusion"
:
conclusion
})
for
requirement
in
criteria
.
get
(
"mustHave"
)
or
[]:
add_row
(
requirement
)
for
requirement
in
(
criteria
.
get
(
"niceToHave"
)
or
[])[:
6
]:
add_row
(
requirement
)
for
requirement
in
(
criteria
.
get
(
"knockout"
)
or
[])[:
4
]:
add_row
(
requirement
)
return
rows
def
local_v2_fields
(
base
:
dict
[
str
,
Any
],
job
:
dict
[
str
,
Any
],
criteria
:
dict
[
str
,
list
[
str
]])
->
dict
[
str
,
Any
]:
score
=
base
.
get
(
"score"
)
decision
=
str
(
base
.
get
(
"decision"
)
or
"建议进入初筛"
)
summary
=
str
(
base
.
get
(
"summary"
)
or
""
)
highlights
=
_list
(
base
.
get
(
"strengths"
))[:
4
]
or
[
"暂无突出亮点,建议结合简历与面试进一步核实。"
]
risks
=
_list
(
base
.
get
(
"risks"
))
risk_text
=
";"
.
join
(
risks
[:
3
])
if
risks
else
"暂未发现明显风险"
dimension_notes
=
{
"hard"
:
";"
.
join
(
_list
(
base
.
get
(
"hardMatch"
))[:
3
])
or
"见评估明细"
,
"experience"
:
";"
.
join
(
_list
(
base
.
get
(
"experienceMatch"
))[:
3
])
or
"见评估明细"
,
"evidence"
:
";"
.
join
(
_list
(
base
.
get
(
"projectEvidence"
))[:
3
])
or
"见评估明细"
,
"competency"
:
";"
.
join
(
_list
(
base
.
get
(
"competencyFrame"
))[:
3
])
or
"见评估明细"
,
"risk"
:
risk_text
,
}
return
{
"version"
:
2
,
"conclusion"
:
f
"{decision}({score} 分)。{summary}"
if
summary
else
f
"{decision}({score} 分)。"
,
"grade"
:
grade_for_score
(
score
),
"highlights"
:
highlights
,
"jdCompare"
:
local_jd_compare
(
base
,
criteria
),
"dimensionNotes"
:
dimension_notes
,
"enrichProvider"
:
"local"
,
"targetJob"
:
str
(
job
.
get
(
"title"
)
or
base
.
get
(
"jobTitle"
)
or
""
),
}
async
def
llm_v2_fields
(
payload
:
dict
[
str
,
Any
],
base
:
dict
[
str
,
Any
])
->
dict
[
str
,
Any
]
|
None
:
settings
=
get_settings
()
if
not
settings
.
effective_llm_api_key
:
return
None
safe_job
=
_sanitize_job_for_external
(
payload
.
get
(
"job"
)
or
{})
criteria
=
job_criteria
(
safe_job
)
criteria_json
=
json
.
dumps
(
criteria
,
ensure_ascii
=
False
,
indent
=
2
)
job_json
=
json
.
dumps
({
k
:
v
for
k
,
v
in
safe_job
.
items
()
if
k
not
in
(
"criteria"
,
"jdHistory"
)},
ensure_ascii
=
False
,
indent
=
2
)[:
6000
]
candidate
=
payload
.
get
(
"candidate"
)
or
{}
candidate_json
=
json
.
dumps
({
k
:
v
for
k
,
v
in
candidate
.
items
()
if
k
!=
"resumeText"
},
ensure_ascii
=
False
,
indent
=
2
)[:
4000
]
resume_text
=
(
candidate
.
get
(
"resumeText"
)
or
""
)[:
16000
]
score
=
base
.
get
(
"score"
)
risk_summary
=
";"
.
join
(
_list
(
base
.
get
(
"risks"
))[:
3
])
or
"暂未发现明显风险"
user
=
(
"请基于下面的岗位要求与脱敏简历,产出一份对外初筛分析报告的细化字段,只返回 JSON,不要 Markdown。
\n
"
"字段与规则:
\n
"
"- conclusion:一句话结论(不超过 60 字),涵盖匹配要点与主要风险提示,与给定综合得分口径一致;
\n
"
"- grade:按综合得分定级:>=85 为 A,>=72 为 B,>=60 为 C,否则 D;
\n
"
"- highlights:核心亮点数组,每项 1 句话并带简历证据(如“主导 XX 项目,将 YY 提升 Z
%
”),最多 4 项,没有则给 1 条客观说明;
\n
"
"- jdCompare:逐条比对数组,每项含 requirement、evidence、conclusion 三个字段:requirement 取岗位硬性/加分/排除要求原文;"
"evidence 是候选人简历中的证据短句(无证据给空字符串);conclusion 只能是“匹配 / 部分匹配 / 未体现 / 待确认”之一;
\n
"
"- dimensionNotes:对象,含 hard、experience、evidence、competency、risk 五个键,每个键 1-2 句话说明该维度得分理由或待确认点。
\n
"
"评估铁律:候选人在简历“自我评价 / 个人优势”中的主观表述(如“抗压能力强”)不作为亮点或比对证据,"
"只能作为面试待验证线索;证据只能来自工作/项目/实习的职责、产出与数据、教育背景、证书与明确技能。
\n
"
f
"综合得分:{score};基础结论:{base.get('decision')};风险摘要:{risk_summary}
\n
"
f
"岗位要求:{criteria_json}
\n
"
f
"岗位信息:{job_json}
\n
"
f
"脱敏候选人:{candidate_json}
\n
"
f
"脱敏简历正文:
\n
{resume_text}"
)
parsed
=
await
_chat_json
(
settings
,
"你是企业招聘系统中的资深 HR 报告撰写助手。只返回 JSON;结论必须与给定综合得分一致,不得推翻分数或虚构简历证据。"
,
user
,
temperature
=
0
,
)
if
not
isinstance
(
parsed
,
dict
):
return
None
parsed
.
setdefault
(
"dimensionNotes"
,
{})
parsed
.
setdefault
(
"jdCompare"
,
[])
parsed
.
setdefault
(
"highlights"
,
[])
parsed
[
"version"
]
=
2
parsed
[
"enrichProvider"
]
=
f
"llm:{settings.effective_llm_model}"
parsed
[
"targetJob"
]
=
str
(
safe_job
.
get
(
"title"
)
or
""
)
return
parsed
async
def
generate_v2_analysis
(
candidate_data
:
dict
[
str
,
Any
],
job_data
:
dict
[
str
,
Any
])
->
dict
[
str
,
Any
]:
"""生成 v2 富报告对象 R(基础评估 + 富报告字段)。"""
payload
=
{
"candidate"
:
candidate_data
,
"job"
:
job_data
}
base
=
await
analyze_resume
(
payload
)
enriched
=
await
llm_v2_fields
(
payload
,
base
)
if
enriched
is
None
:
enriched
=
local_v2_fields
(
base
,
job_data
,
job_criteria
(
job_data
))
result
=
{
**
base
,
**
enriched
}
result
.
setdefault
(
"grade"
,
grade_for_score
(
base
.
get
(
"score"
)))
result
.
setdefault
(
"conclusion"
,
f
"{base.get('decision')}({base.get('score')} 分)。"
)
return
result
backend/tests/test_reports.py
View file @
be7f8224
...
@@ -6,6 +6,7 @@
...
@@ -6,6 +6,7 @@
- 关闭模式(auth_reports_client):管理端点工作区隔离;公网只读匿名可访问。
- 关闭模式(auth_reports_client):管理端点工作区隔离;公网只读匿名可访问。
"""
"""
import
json
from
urllib.parse
import
parse_qs
,
urlparse
from
urllib.parse
import
parse_qs
,
urlparse
import
pytest
import
pytest
...
@@ -197,6 +198,100 @@ def test_public_read_wrong_token_or_unknown(share_open_client):
...
@@ -197,6 +198,100 @@ def test_public_read_wrong_token_or_unknown(share_open_client):
assert
body
[
"ok"
]
is
False
and
body
[
"reason"
]
==
"not_found"
assert
body
[
"ok"
]
is
False
and
body
[
"reason"
]
==
"not_found"
# ---- Phase B v2:富报告 SSE 生成 + analysis 覆盖冻结 ----
def
_v2_state
():
job
=
{
"id"
:
"job-v2-1"
,
"title"
:
"数据分析师"
,
"department"
:
"数据部"
,
"jd"
:
"负责业务数据体系与经营分析,输出可执行结论。"
,
"mustHave"
:
"本科及以上
\n
3年以上数据分析经验"
,
"niceToHave"
:
"SQL
\n
Python"
,
"knockout"
:
"无数据项目经历"
,
"competency"
:
"业务理解
\n
沟通协同"
,
"matchKeywords"
:
"SQL,Python,数据分析"
,
}
candidate
=
{
"id"
:
"cand-v2-1"
,
"jobId"
:
"job-v2-1"
,
"jobTitle"
:
"数据分析师"
,
"name"
:
"李四"
,
"source"
:
"Boss"
,
"stage"
:
"初筛通过"
,
"education"
:
"本科"
,
"school"
:
"某大学"
,
"major"
:
"统计学"
,
"years"
:
"3年"
,
"skills"
:
[
"SQL"
,
"Python"
],
"resumeText"
:
"三年数据分析经验,熟悉 SQL、Python,主导过订单分析项目,将报表效率提升 40
%
,服务过电商客户。"
,
}
return
{
"jobs"
:
[
job
],
"candidates"
:
[
candidate
],
"offers"
:
[],
"tasks"
:
[],
"eventLog"
:
[]}
def
_sse_events
(
text
):
events
=
[]
for
line
in
str
(
text
)
.
splitlines
():
line
=
line
.
strip
()
if
line
.
startswith
(
"data:"
):
try
:
events
.
append
(
json
.
loads
(
line
[
5
:]
.
strip
()))
except
json
.
JSONDecodeError
:
continue
return
events
def
test_v2_stream_local_generation
(
share_open_client
):
assert
share_open_client
.
put
(
"/api/state"
,
json
=
_v2_state
())
.
status_code
==
200
response
=
share_open_client
.
post
(
"/api/reports/generate-v2/stream"
,
json
=
{
"candidateId"
:
"cand-v2-1"
})
assert
response
.
status_code
==
200
assert
response
.
headers
[
"content-type"
]
.
startswith
(
"text/event-stream"
)
events
=
_sse_events
(
response
.
text
)
done
=
[
event
for
event
in
events
if
event
.
get
(
"type"
)
==
"done"
]
assert
len
(
done
)
==
1
analysis
=
done
[
0
][
"analysis"
]
assert
analysis
.
get
(
"version"
)
==
2
assert
analysis
.
get
(
"grade"
)
in
(
"A"
,
"B"
,
"C"
,
"D"
)
assert
analysis
.
get
(
"score"
)
is
not
None
assert
isinstance
(
analysis
.
get
(
"jdCompare"
),
list
)
and
analysis
[
"jdCompare"
]
assert
analysis
.
get
(
"conclusion"
)
def
test_v2_stream_missing_job_error
(
share_open_client
):
state
=
_v2_state
()
state
[
"candidates"
][
0
][
"jobId"
]
=
""
state
[
"jobs"
]
=
[]
assert
share_open_client
.
put
(
"/api/state"
,
json
=
state
)
.
status_code
==
200
response
=
share_open_client
.
post
(
"/api/reports/generate-v2/stream"
,
json
=
{
"candidateId"
:
"cand-v2-1"
})
events
=
_sse_events
(
response
.
text
)
errors
=
[
event
for
event
in
events
if
event
.
get
(
"type"
)
==
"error"
]
assert
errors
and
"关联岗位"
in
errors
[
0
][
"message"
]
def
test_create_report_with_analysis_override_v2
(
share_open_client
):
assert
share_open_client
.
put
(
"/api/state"
,
json
=
_v2_state
())
.
status_code
==
200
analysis
=
{
"version"
:
2
,
"score"
:
82
,
"decision"
:
"建议复核后进入初筛"
,
"conclusion"
:
"总体匹配,需复核数据分析深度。"
,
"grade"
:
"B"
,
"highlights"
:
[
"主导订单分析项目,报表效率提升 40
%
"
],
"jdCompare"
:
[{
"requirement"
:
"本科及以上"
,
"evidence"
:
"统招本科"
,
"conclusion"
:
"匹配"
}],
"dimensionNotes"
:
{
"hard"
:
"学历达标"
,
"experience"
:
"3 年经验"
,
"evidence"
:
"有项目证据"
,
"competency"
:
"待面试观察"
,
"risk"
:
"无明显风险"
},
}
created
=
share_open_client
.
post
(
"/api/reports"
,
json
=
{
"candidateId"
:
"cand-v2-1"
,
"analysis"
:
analysis
}
)
assert
created
.
status_code
==
200
report
=
created
.
json
()[
"report"
]
assert
report
[
"snapshot"
][
"analysis"
][
"version"
]
==
2
assert
report
[
"snapshot"
][
"analysis"
][
"grade"
]
==
"B"
# 「仅快照」不改写候选行 analysis
state
=
share_open_client
.
get
(
"/api/state"
)
.
json
()
assert
"analysis"
not
in
state
[
"candidates"
][
0
]
# ---- SPA 直链回退(公网分享页深链需返回 index.html)----
# ---- SPA 直链回退(公网分享页深链需返回 index.html)----
def
test_spa_deep_link_fallback_serves_index
(
client
):
def
test_spa_deep_link_fallback_serves_index
(
client
):
...
...
docs/adr/0002-candidate-report-share.md
View file @
be7f8224
...
@@ -26,3 +26,19 @@
...
@@ -26,3 +26,19 @@
-
正向:分享产物与实时评估解耦,符合「导出时刻所见即所得」;不破坏整包 state 机制;匿名面小(只读 + 令牌 + 白名单 + 默认关)。
-
正向:分享产物与实时评估解耦,符合「导出时刻所见即所得」;不破坏整包 state 机制;匿名面小(只读 + 令牌 + 白名单 + 默认关)。
-
代价/注意:报告独立存储 →
**整包「导出备份」不含报告**
,需要时后续补报告专用导出/迁移;快照内容在生成后不随候选人资料更新(这是刻意语义,分享页应显示「报告生成时间」);公开接口需在
`security.py`
放行名单中精确按前缀处理,避免误放其它
`/api/share`
业务。
-
代价/注意:报告独立存储 →
**整包「导出备份」不含报告**
,需要时后续补报告专用导出/迁移;快照内容在生成后不随候选人资料更新(这是刻意语义,分享页应显示「报告生成时间」);公开接口需在
`security.py`
放行名单中精确按前缀处理,避免误放其它
`/api/share`
业务。
-
后续可做:Phase B 更丰富的 AI 报告模板(一句话结论 + 维度/亮点/风险 + JD 逐条比对表,同 AI秒聘);PDF(水印)导出;自动过期;人才池报告;报告备份/迁移。
-
后续可做:Phase B 更丰富的 AI 报告模板(一句话结论 + 维度/亮点/风险 + JD 逐条比对表,同 AI秒聘);PDF(水印)导出;自动过期;人才池报告;报告备份/迁移。
## Phase B(v2)追加决策(2026-09-03,已实现 P0+P1)
-
状态:已接受
-
**v2 生成方式**
:先生成 v1 兼容基础评估(复用
`analyze_resume`
的 LLM → local-structured 回退链),
再做一次「富化」LLM 调用产出 conclusion/grade/highlights/jdCompare/dimensionNotes;无 Key 时用本地规则组装,
绝不 500。输出
**单一富对象 R**
= 基础评估 + v2 字段 +
`version:2`
(保留 v1 兼容键,抽屉「评估」页可继续渲染)。
-
**SSE 流式**
:新增
`POST /api/reports/generate-v2/stream`
(stage/done/error),沿用 JD 生成的 SSE 模式,不引入任务队列。
-
**维度评分**
:沿用现有 scoreBreakdown 五维(硬性/经验/项目证据/胜任力/风险),不引入岗位自定义权重维度。
-
**预览与写回(决策 5)**
:v2 生成先给预览;「仅生成分享报告」=
`POST /api/reports`
携带 analysis 覆盖冻结快照
(不改候选行);「同时刷新 AI 评估」= 先写回
`candidate.analysis`
(补 jobSnapshot/inputKey/generatedAt)→ persist →
再按 v1 语义从候选行冻结。两种都产出带 report_id 的分享链接,旧 v1 快照/旧分析兼容渲染。
-
**PDF(P1,轻量)**
:不做服务端渲染器;公网页「打印 / 存 PDF」+ 每页固定轻量水印(报告编号/内部资料/生成人),
浏览器另存 PDF 即得水印版。
-
边界更新:v2 仍限个人工作区简历库抽屉;人才池报告、自动过期(表字段已预留)、报告备份/迁移仍为后续项。
vue-app/src/api/recruitment.js
View file @
be7f8224
...
@@ -179,12 +179,64 @@ export async function listCandidateReports(candidateId) {
...
@@ -179,12 +179,64 @@ export async function listCandidateReports(candidateId) {
return
data
return
data
}
}
export
async
function
createCandidateReport
(
candidateId
)
{
export
async
function
createCandidateReport
(
candidateId
,
analysis
=
null
)
{
const
{
data
}
=
await
http
.
post
(
'/api/reports'
,
{
candidateId
})
const
body
=
{
candidateId
}
if
(
analysis
&&
typeof
analysis
===
'object'
)
body
.
analysis
=
analysis
const
{
data
}
=
await
http
.
post
(
'/api/reports'
,
body
)
if
(
data
.
error
)
throw
new
Error
(
data
.
error
)
if
(
data
.
error
)
throw
new
Error
(
data
.
error
)
return
data
return
data
}
}
// v2 富报告 SSE 流式生成(Phase B):stage / done / error 事件。
export
async
function
generateV2ReportStream
(
candidateId
,
handlers
=
{})
{
const
{
onStage
,
onDone
,
onError
}
=
handlers
let
lastError
=
''
let
base
=
''
if
(
typeof
window
!==
'undefined'
&&
window
.
RECRUITMENT_API_BASE_URL
)
{
base
=
String
(
window
.
RECRUITMENT_API_BASE_URL
).
replace
(
/
\/
+$/
,
''
)
}
const
token
=
readToken
()
const
response
=
await
fetch
(
`
${
base
}
/api/reports/generate-v2/stream`
,
{
method
:
'POST'
,
headers
:
{
'Content-Type'
:
'application/json'
,
...(
token
?
{
Authorization
:
`Bearer
${
token
}
`
}
:
{}),
},
body
:
JSON
.
stringify
({
candidateId
}),
})
if
(
!
response
.
ok
||
!
response
.
body
)
{
throw
new
Error
(
`报告生成请求失败(HTTP
${
response
.
status
}
)`
)
}
const
reader
=
response
.
body
.
getReader
()
const
decoder
=
new
TextDecoder
(
'utf-8'
)
let
buffer
=
''
for
(;;)
{
const
{
done
,
value
}
=
await
reader
.
read
()
if
(
done
)
break
buffer
+=
decoder
.
decode
(
value
,
{
stream
:
true
})
const
lines
=
buffer
.
split
(
'
\
n'
)
buffer
=
lines
.
pop
()
||
''
for
(
const
line
of
lines
)
{
const
trimmed
=
line
.
trim
()
if
(
!
trimmed
.
startsWith
(
'data:'
))
continue
let
event
try
{
event
=
JSON
.
parse
(
trimmed
.
slice
(
5
).
trim
())
}
catch
{
continue
}
if
(
event
.
type
===
'stage'
&&
onStage
)
onStage
(
event
.
stage
,
event
.
message
||
''
)
else
if
(
event
.
type
===
'done'
&&
onDone
)
onDone
(
event
.
analysis
)
else
if
(
event
.
type
===
'error'
)
{
lastError
=
event
.
message
||
'报告生成失败'
if
(
onError
)
onError
(
lastError
)
}
}
}
if
(
lastError
)
throw
new
Error
(
lastError
)
}
export
async
function
setCandidateReportStatus
(
reportId
,
status
)
{
export
async
function
setCandidateReportStatus
(
reportId
,
status
)
{
const
{
data
}
=
await
http
.
patch
(
`/api/reports/
${
reportId
}
`
,
{
status
})
const
{
data
}
=
await
http
.
patch
(
`/api/reports/
${
reportId
}
`
,
{
status
})
if
(
data
.
error
)
throw
new
Error
(
data
.
error
)
if
(
data
.
error
)
throw
new
Error
(
data
.
error
)
...
...
vue-app/src/components/reports/ReportContentView.vue
0 → 100644
View file @
be7f8224
<
script
setup
>
// 候选人分析报告内容渲染(ADR 0002 Phase A/B 通用):
// - v1 快照:得分/结论/摘要/风险/评估明细(与抽屉「评估」同源);
// - v2 富对象:额外渲染等级、一句话结论、维度评分条、核心亮点、JD 逐条比对。
// 数据全部来自后端冻结的 snapshot,页面/抽屉预览共用,避免两套渲染分叉。
import
{
computed
}
from
'vue'
import
{
providerLabel
,
scoreBreakdownItems
}
from
'@/utils/scoring'
import
{
assessmentDecision
,
assessmentReportRows
,
assessmentScoreText
,
riskProfileRows
}
from
'@/utils/report'
const
props
=
defineProps
({
report
:
{
type
:
Object
,
required
:
true
},
})
const
snapshot
=
computed
(()
=>
props
.
report
?.
snapshot
||
{})
const
analysis
=
computed
(()
=>
snapshot
.
value
.
analysis
||
{})
const
isV2
=
computed
(()
=>
analysis
.
value
.
version
===
2
)
const
candidate
=
computed
(()
=>
({
id
:
snapshot
.
value
.
candidateId
,
name
:
snapshot
.
value
.
candidateName
,
jobTitle
:
snapshot
.
value
.
jobTitle
,
analysis
:
analysis
.
value
,
matchReport
:
snapshot
.
value
.
matchReport
,
quickMatchScore
:
snapshot
.
value
.
quickMatchScore
,
}))
const
scoreText
=
computed
(()
=>
assessmentScoreText
(
candidate
.
value
,
'ai'
))
const
decisionInfo
=
computed
(()
=>
assessmentDecision
(
candidate
.
value
,
'ai'
))
const
reportRows
=
computed
(()
=>
assessmentReportRows
(
candidate
.
value
,
'ai'
))
const
riskProfile
=
computed
(()
=>
riskProfileRows
(
analysis
.
value
))
const
dimensionItems
=
computed
(()
=>
scoreBreakdownItems
(
analysis
.
value
))
const
author
=
computed
(()
=>
snapshot
.
value
.
author
||
{})
const
gradeTone
=
computed
(()
=>
{
const
grade
=
String
(
analysis
.
value
.
grade
||
''
).
toUpperCase
()
if
(
grade
===
'A'
)
return
'success'
if
(
grade
===
'B'
)
return
'primary'
if
(
grade
===
'C'
)
return
'warning'
return
'danger'
})
const
jdConclusionMeta
=
(
conclusion
=
''
)
=>
{
const
map
=
{
匹配
:
{
type
:
'success'
,
text
:
'匹配'
},
部分匹配
:
{
type
:
'primary'
,
text
:
'部分匹配'
},
未体现
:
{
type
:
'danger'
,
text
:
'未体现'
},
待确认
:
{
type
:
'warning'
,
text
:
'待确认'
},
}
return
map
[
conclusion
]
||
{
type
:
'info'
,
text
:
conclusion
||
'待确认'
}
}
const
toneTag
=
(
level
)
=>
({
ok
:
{
type
:
'success'
,
text
:
'正常'
},
hint
:
{
type
:
'warning'
,
text
:
'提示'
},
warn
:
{
type
:
'danger'
,
text
:
'需确认'
},
})[
level
]
||
{
type
:
'info'
,
text
:
level
}
const
DIM_NOTES_KEY
=
[
'hard'
,
'experience'
,
'evidence'
,
'competency'
,
'risk'
]
const
dimensionNote
=
(
index
)
=>
{
const
notes
=
analysis
.
value
.
dimensionNotes
||
{}
const
key
=
DIM_NOTES_KEY
[
index
]
return
key
?
notes
[
key
]
:
''
}
const
toText
=
(
item
)
=>
{
if
(
item
&&
typeof
item
===
'object'
)
{
if
(
item
.
title
&&
item
.
detail
)
return
`
${
item
.
title
}
:
${
item
.
detail
}
`
return
item
.
title
||
item
.
text
||
item
.
detail
||
JSON
.
stringify
(
item
)
}
return
String
(
item
??
''
)
}
const
formatTime
=
(
iso
=
''
)
=>
{
if
(
!
iso
)
return
''
const
date
=
new
Date
(
iso
)
if
(
Number
.
isNaN
(
date
.
getTime
()))
return
iso
const
pad
=
(
n
)
=>
String
(
n
).
padStart
(
2
,
'0'
)
return
`
${
date
.
getFullYear
()}
-
${
pad
(
date
.
getMonth
()
+
1
)}
-
${
pad
(
date
.
getDate
())}
${
pad
(
date
.
getHours
())}
:
${
pad
(
date
.
getMinutes
())}
`
}
</
script
>
<
template
>
<div
class=
"report-content"
>
<section
class=
"candidate-head"
>
<div
class=
"name-row"
>
<h1>
{{
snapshot
.
candidateName
||
'未命名候选人'
}}
</h1>
<el-tag
size=
"small"
effect=
"plain"
>
{{
snapshot
.
jobTitle
||
'待匹配岗位'
}}
</el-tag>
<el-tag
v-if=
"snapshot.source"
size=
"small"
type=
"info"
effect=
"plain"
>
{{
snapshot
.
source
}}
</el-tag>
<el-tag
v-if=
"isV2"
size=
"small"
:type=
"gradeTone"
effect=
"dark"
class=
"grade-tag"
>
等级
{{
analysis
.
grade
}}
</el-tag>
<el-tag
v-if=
"isV2"
size=
"small"
type=
"primary"
effect=
"plain"
>
v2 富评估
</el-tag>
</div>
<div
class=
"meta-row"
>
<span>
报告编号:
{{
report
.
reportId
||
'—'
}}
</span>
<span>
生成时间:
{{
formatTime
(
report
.
generatedAt
)
}}
</span>
<span>
评估方式:
{{
providerLabel
(
analysis
.
provider
||
''
)
}}
</span>
<span>
生成人:
{{
author
.
name
||
author
.
username
||
'—'
}}
</span>
</div>
</section>
<section
class=
"summary-strip"
:class=
"`tone-$
{decisionInfo.tone}`">
<div
class=
"score-block"
>
<strong
class=
"score-value"
>
{{
scoreText
}}
</strong>
<span>
综合得分
</span>
</div>
<div
class=
"decision-block"
>
<span
class=
"eyebrow"
>
初筛建议
</span>
<strong>
{{
decisionInfo
.
text
}}
</strong>
<p>
{{
decisionInfo
.
note
}}
</p>
</div>
</section>
<section
v-if=
"isV2 && analysis.conclusion"
class=
"block"
>
<h4>
一句话结论
</h4>
<p
class=
"conclusion-text"
>
{{
analysis
.
conclusion
}}
</p>
</section>
<section
v-if=
"analysis.summary"
class=
"block"
>
<h4>
评估摘要
</h4>
<p
class=
"summary-text"
>
{{
analysis
.
summary
}}
</p>
</section>
<section
v-if=
"isV2 && dimensionItems.length"
class=
"block"
>
<h4>
维度评分
</h4>
<div
class=
"dimension-list"
>
<div
v-for=
"(item, index) in dimensionItems"
:key=
"item.label"
class=
"dimension-item"
:class=
"
{ total: item.total }"
>
<div
class=
"dimension-head"
>
<b>
{{
item
.
label
}}
</b>
<strong>
{{
item
.
value
<
0
?
item
.
value
:
`${item.value
}
/${item.max
}
`
}}
<
/strong
>
<
/div
>
<
div
class
=
"dimension-track"
>
<
i
:
style
=
"{
width: item.total
? `${Math.min(100, Math.max(0, item.value))
}
%`
: `${Math.min(100, (Math.abs(item.value) / item.max) * 100)
}
%`,
}
"
/>
<
/div
>
<
p
class
=
"dimension-hint"
>
{{
item
.
hint
}}
<
/p
>
<
p
v
-
if
=
"dimensionNote(index)"
class
=
"dimension-note"
>
{{
dimensionNote
(
index
)
}}
<
/p
>
<
/div
>
<
/div
>
<
/section
>
<
section
v
-
if
=
"isV2 && analysis.highlights && analysis.highlights.length"
class
=
"block"
>
<
h4
>
核心亮点
<
/h4
>
<
ul
class
=
"highlight-list"
>
<
li
v
-
for
=
"(item, index) in analysis.highlights"
:
key
=
"index"
>
{{
toText
(
item
)
}}
<
/li
>
<
/ul
>
<
/section
>
<
section
v
-
if
=
"isV2 && analysis.jdCompare && analysis.jdCompare.length"
class
=
"block"
>
<
h4
>
JD
逐条比对
<
/h4
>
<
div
class
=
"jd-compare"
>
<
div
v
-
for
=
"(row, index) in analysis.jdCompare"
:
key
=
"index"
class
=
"jd-row"
>
<
div
class
=
"jd-requirement"
>
<
span
>
岗位要求
<
/span
>
<
p
>
{{
toText
(
row
.
requirement
)
}}
<
/p
>
<
/div
>
<
div
class
=
"jd-evidence"
>
<
span
>
候选人证据
<
/span
>
<
p
>
{{
toText
(
row
.
evidence
)
||
'未在简历中找到明确证据'
}}
<
/p
>
<
/div
>
<
el
-
tag
:
type
=
"jdConclusionMeta(row.conclusion).type"
size
=
"small"
effect
=
"light"
>
{{
jdConclusionMeta
(
row
.
conclusion
).
text
}}
<
/el-tag
>
<
/div
>
<
/div
>
<
/section
>
<
section
v
-
if
=
"riskProfile.length"
class
=
"block"
>
<
h4
>
风险点评估
<
/h4
>
<
div
class
=
"risk-list"
>
<
div
v
-
for
=
"item in riskProfile"
:
key
=
"item.dimension"
class
=
"risk-item"
:
class
=
"`level-${item.level
}
`"
>
<
el
-
tag
:
type
=
"toneTag(item.level).type"
size
=
"small"
effect
=
"light"
>
{{
toneTag
(
item
.
level
).
text
}}
<
/el-tag
>
<
b
>
{{
item
.
label
}}
<
/b
>
<
p
>
{{
item
.
summary
}}
<
/p
>
<
span
v
-
for
=
"(ev, index) in item.evidence || []"
:
key
=
"`${item.dimension
}
-${index
}
`"
>
依据:
{{
ev
}}
<
/span
>
<
/div
>
<
/div
>
<
/section
>
<
section
v
-
if
=
"reportRows.length"
class
=
"block"
>
<
h4
>
评估明细
<
/h4
>
<
div
v
-
for
=
"row in reportRows"
:
key
=
"row.label"
class
=
"detail-card"
:
class
=
"`tone-${row.tone
}
`"
>
<
div
class
=
"detail-head"
>
<
b
>
{{
row
.
label
}}
<
/b
>
<
el
-
tag
:
type
=
"row.tone"
size
=
"small"
effect
=
"light"
>
{{
row
.
status
}}
<
/el-tag
>
<
/div
>
<
ul
v
-
if
=
"row.items.length"
>
<
li
v
-
for
=
"(item, index) in row.items"
:
key
=
"index"
>
{{
item
}}
<
/li
>
<
/ul
>
<
p
v
-
else
class
=
"detail-empty"
>
暂无
<
/p
>
<
/div
>
<
/section
>
<
footer
class
=
"report-footer"
>
<
p
>
本报告由
AI
辅助生成,基于生成时刻的简历与岗位信息冻结,仅供内部初筛参考,请以人工复核与面试为准。
<
/p
>
<
p
>
不含候选人联系方式与原简历文件。
<
/p
>
<
/footer
>
<
/div
>
<
/template
>
<
style
lang
=
"scss"
scoped
>
.
report
-
content
{
width
:
100
%
;
}
.
candidate
-
head
{
padding
:
4
px
0
14
px
;
.
name
-
row
{
display
:
flex
;
align
-
items
:
center
;
flex
-
wrap
:
wrap
;
gap
:
8
px
;
h1
{
margin
:
0
;
font
-
size
:
24
px
;
}
.
grade
-
tag
{
font
-
weight
:
700
;
}
}
.
meta
-
row
{
display
:
flex
;
flex
-
wrap
:
wrap
;
gap
:
6
px
18
px
;
margin
-
top
:
10
px
;
font
-
size
:
12
px
;
color
:
var
(
--
muted
);
}
}
.
summary
-
strip
{
display
:
flex
;
align
-
items
:
stretch
;
border
:
1
px
solid
var
(
--
line
);
border
-
radius
:
12
px
;
overflow
:
hidden
;
margin
-
bottom
:
16
px
;
.
score
-
block
{
display
:
flex
;
flex
-
direction
:
column
;
align
-
items
:
center
;
justify
-
content
:
center
;
gap
:
4
px
;
min
-
width
:
168
px
;
padding
:
16
px
18
px
;
background
:
rgba
(
255
,
255
,
255
,
0.8
);
.
score
-
value
{
font
-
size
:
30
px
;
line
-
height
:
1
;
}
span
{
font
-
size
:
12
px
;
color
:
var
(
--
muted
);
}
}
.
decision
-
block
{
flex
:
1
;
min
-
width
:
0
;
padding
:
14
px
18
px
;
display
:
flex
;
flex
-
direction
:
column
;
justify
-
content
:
center
;
border
-
left
:
1
px
solid
var
(
--
line
);
.
eyebrow
{
font
-
size
:
11
px
;
letter
-
spacing
:
1
px
;
color
:
var
(
--
muted
);
}
strong
{
margin
:
4
px
0
2
px
;
font
-
size
:
17
px
;
}
p
{
margin
:
0
;
font
-
size
:
12
px
;
color
:
var
(
--
muted
);
line
-
height
:
1.5
;
}
}
&
.
tone
-
success
{
border
-
color
:
color
-
mix
(
in
srgb
,
var
(
--
green
)
55
%
,
var
(
--
line
));
.
score
-
value
,
.
decision
-
block
strong
{
color
:
var
(
--
green
);
}
}
&
.
tone
-
warning
{
border
-
color
:
color
-
mix
(
in
srgb
,
var
(
--
yellow
)
55
%
,
var
(
--
line
));
.
score
-
value
,
.
decision
-
block
strong
{
color
:
var
(
--
yellow
);
}
}
&
.
tone
-
danger
{
border
-
color
:
color
-
mix
(
in
srgb
,
var
(
--
rose
)
55
%
,
var
(
--
line
));
.
score
-
value
,
.
decision
-
block
strong
{
color
:
var
(
--
rose
);
}
}
&
.
tone
-
info
{
.
score
-
value
,
.
decision
-
block
strong
{
color
:
var
(
--
blue
);
}
}
}
.
block
{
margin
-
bottom
:
16
px
;
h4
{
margin
:
0
0
8
px
;
font
-
size
:
14
px
;
}
.
conclusion
-
text
{
margin
:
0
;
padding
:
12
px
14
px
;
border
:
1
px
solid
var
(
--
line
);
border
-
left
:
3
px
solid
var
(
--
blue
);
border
-
radius
:
10
px
;
background
:
color
-
mix
(
in
srgb
,
var
(
--
blue
)
6
%
,
#
fff
);
font
-
size
:
14
px
;
line
-
height
:
1.7
;
color
:
var
(
--
ink
);
font
-
weight
:
500
;
}
.
summary
-
text
{
margin
:
0
;
padding
:
12
px
14
px
;
border
:
1
px
solid
var
(
--
line
);
border
-
radius
:
10
px
;
background
:
rgba
(
255
,
255
,
255
,
0.7
);
font
-
size
:
13
px
;
line
-
height
:
1.7
;
color
:
var
(
--
ink
);
}
}
.
dimension
-
list
{
display
:
flex
;
flex
-
direction
:
column
;
gap
:
8
px
;
.
dimension
-
item
{
padding
:
9
px
12
px
;
border
:
1
px
solid
var
(
--
line
);
border
-
radius
:
10
px
;
background
:
rgba
(
255
,
255
,
255
,
0.7
);
&
.
total
{
border
-
color
:
color
-
mix
(
in
srgb
,
var
(
--
blue
)
45
%
,
var
(
--
line
));
background
:
color
-
mix
(
in
srgb
,
var
(
--
blue
)
5
%
,
#
fff
);
}
.
dimension
-
head
{
display
:
flex
;
align
-
items
:
center
;
justify
-
content
:
space
-
between
;
gap
:
10
px
;
b
{
font
-
size
:
13
px
;
}
strong
{
font
-
size
:
13
px
;
}
}
.
dimension
-
track
{
margin
:
6
px
0
4
px
;
height
:
6
px
;
border
-
radius
:
999
px
;
background
:
var
(
--
line
);
overflow
:
hidden
;
i
{
display
:
block
;
height
:
100
%
;
border
-
radius
:
999
px
;
background
:
linear
-
gradient
(
90
deg
,
var
(
--
blue
),
var
(
--
cyan
));
}
}
.
dimension
-
hint
,
.
dimension
-
note
{
margin
:
0
;
font
-
size
:
12
px
;
color
:
var
(
--
muted
);
line
-
height
:
1.5
;
}
.
dimension
-
note
{
margin
-
top
:
2
px
;
color
:
var
(
--
ink
);
}
}
}
.
highlight
-
list
{
margin
:
0
;
padding
:
0
;
list
-
style
:
none
;
display
:
flex
;
flex
-
direction
:
column
;
gap
:
6
px
;
li
{
padding
:
8
px
12
px
;
border
:
1
px
solid
var
(
--
line
);
border
-
left
:
3
px
solid
var
(
--
green
);
border
-
radius
:
8
px
;
background
:
color
-
mix
(
in
srgb
,
var
(
--
green
)
6
%
,
#
fff
);
font
-
size
:
13
px
;
line
-
height
:
1.6
;
}
}
.
jd
-
compare
{
display
:
flex
;
flex
-
direction
:
column
;
gap
:
8
px
;
.
jd
-
row
{
display
:
grid
;
grid
-
template
-
columns
:
30
%
1
fr
auto
;
gap
:
12
px
;
align
-
items
:
start
;
padding
:
10
px
12
px
;
border
:
1
px
solid
var
(
--
line
);
border
-
radius
:
10
px
;
background
:
rgba
(
255
,
255
,
255
,
0.7
);
.
jd
-
requirement
,
.
jd
-
evidence
{
min
-
width
:
0
;
span
{
font
-
size
:
11
px
;
color
:
var
(
--
muted
);
}
p
{
margin
:
3
px
0
0
;
font
-
size
:
13
px
;
line
-
height
:
1.55
;
color
:
var
(
--
ink
);
word
-
break
:
break
-
word
;
}
}
}
}
.
risk
-
list
{
display
:
flex
;
flex
-
direction
:
column
;
gap
:
8
px
;
.
risk
-
item
{
display
:
grid
;
grid
-
template
-
columns
:
auto
auto
1
fr
;
align
-
items
:
center
;
gap
:
4
px
10
px
;
padding
:
10
px
12
px
;
border
-
radius
:
10
px
;
border
:
1
px
solid
var
(
--
line
);
background
:
rgba
(
255
,
255
,
255
,
0.6
);
b
{
font
-
size
:
13
px
;
}
p
{
grid
-
column
:
1
/
-
1
;
margin
:
2
px
0
0
;
font
-
size
:
13
px
;
line
-
height
:
1.6
;
}
span
{
grid
-
column
:
1
/
-
1
;
font
-
size
:
12
px
;
color
:
var
(
--
muted
);
line
-
height
:
1.5
;
}
&
.
level
-
warn
{
border
-
color
:
color
-
mix
(
in
srgb
,
var
(
--
rose
)
45
%
,
var
(
--
line
));
background
:
color
-
mix
(
in
srgb
,
var
(
--
rose
)
6
%
,
#
fff
);
}
&
.
level
-
hint
{
border
-
color
:
color
-
mix
(
in
srgb
,
var
(
--
yellow
)
45
%
,
var
(
--
line
));
background
:
color
-
mix
(
in
srgb
,
var
(
--
yellow
)
7
%
,
#
fff
);
}
}
}
.
detail
-
card
{
margin
-
bottom
:
10
px
;
padding
:
12
px
14
px
;
border
:
1
px
solid
var
(
--
line
);
border
-
left
-
width
:
3
px
;
border
-
radius
:
10
px
;
background
:
rgba
(
255
,
255
,
255
,
0.7
);
&
.
tone
-
success
{
border
-
left
-
color
:
var
(
--
green
);
}
&
.
tone
-
warning
{
border
-
left
-
color
:
var
(
--
yellow
);
}
&
.
tone
-
danger
{
border
-
left
-
color
:
var
(
--
rose
);
}
&
.
tone
-
info
{
border
-
left
-
color
:
var
(
--
blue
);
}
.
detail
-
head
{
display
:
flex
;
align
-
items
:
center
;
justify
-
content
:
space
-
between
;
gap
:
10
px
;
margin
-
bottom
:
6
px
;
b
{
font
-
size
:
14
px
;
}
}
ul
{
margin
:
0
;
padding
-
left
:
18
px
;
display
:
flex
;
flex
-
direction
:
column
;
gap
:
5
px
;
li
{
font
-
size
:
13
px
;
line
-
height
:
1.6
;
color
:
var
(
--
ink
);
}
}
.
detail
-
empty
{
margin
:
0
;
font
-
size
:
13
px
;
color
:
var
(
--
muted
);
}
}
.
report
-
footer
{
margin
-
top
:
18
px
;
padding
-
top
:
12
px
;
border
-
top
:
1
px
dashed
var
(
--
line
);
font
-
size
:
12
px
;
color
:
var
(
--
muted
);
line
-
height
:
1.6
;
p
{
margin
:
2
px
0
;
}
}
@
media
(
max
-
width
:
620
px
)
{
.
summary
-
strip
{
flex
-
direction
:
column
;
.
score
-
block
{
min
-
width
:
0
;
padding
:
14
px
;
}
.
decision
-
block
{
border
-
left
:
0
;
border
-
top
:
1
px
solid
var
(
--
line
);
}
}
.
jd
-
compare
.
jd
-
row
{
grid
-
template
-
columns
:
1
fr
auto
;
}
}
<
/style
>
vue-app/src/components/resumes/ResumeDetail.vue
View file @
be7f8224
...
@@ -6,17 +6,19 @@ import { ElMessageBox } from 'element-plus'
...
@@ -6,17 +6,19 @@ import { ElMessageBox } from 'element-plus'
import
http
from
'@/api'
import
http
from
'@/api'
import
*
as
reportApi
from
'@/api/recruitment'
import
*
as
reportApi
from
'@/api/recruitment'
import
ReportContentView
from
'@/components/reports/ReportContentView.vue'
import
{
useRecruitmentStore
}
from
'@/stores/recruitment'
import
{
useRecruitmentStore
}
from
'@/stores/recruitment'
import
{
CANDIDATE_TAG_OPTIONS
,
candidateTagTone
}
from
'@/utils/constants'
import
{
CANDIDATE_TAG_OPTIONS
,
candidateTagTone
}
from
'@/utils/constants'
import
{
candidateWorkflow
,
workflowStageClass
}
from
'@/utils/task'
import
{
candidateWorkflow
,
workflowStageClass
}
from
'@/utils/task'
import
{
candidateScreeningConclusion
,
quickScore
}
from
'@/utils/resume'
import
{
candidateScreeningConclusion
,
quickScore
}
from
'@/utils/resume'
import
{
isAiEvaluationReport
}
from
'@/utils/scoring'
import
{
isAiEvaluationReport
}
from
'@/utils/scoring'
import
{
analysisNeedsRefresh
}
from
'@/utils/analysis'
import
{
analysis
InputKey
,
analysis
NeedsRefresh
}
from
'@/utils/analysis'
import
{
resumeDisplayName
,
candidateAge
,
candidateYears
}
from
'@/utils/candidate'
import
{
resumeDisplayName
,
candidateAge
,
candidateYears
}
from
'@/utils/candidate'
import
{
uid
}
from
'@/utils/normalize'
import
{
uid
}
from
'@/utils/normalize'
import
{
assessmentDecision
,
assessmentReportRows
,
assessmentScoreText
,
riskProfileRows
}
from
'@/utils/report'
import
{
assessmentDecision
,
assessmentReportRows
,
assessmentScoreText
,
riskProfileRows
}
from
'@/utils/report'
import
{
getFilteredCandidates
}
from
'@/utils/matching'
import
{
getFilteredCandidates
}
from
'@/utils/matching'
import
{
jobCandidates
}
from
'@/utils/links'
import
{
buildJobSnapshot
}
from
'@/utils/job'
import
{
jobCandidates
,
matchedJobForCandidate
}
from
'@/utils/links'
import
{
showToast
}
from
'@/utils/toast'
import
{
showToast
}
from
'@/utils/toast'
const
store
=
useRecruitmentStore
()
const
store
=
useRecruitmentStore
()
...
@@ -321,6 +323,10 @@ const reportListLoading = ref(false)
...
@@ -321,6 +323,10 @@ const reportListLoading = ref(false)
const
reportList
=
ref
([])
const
reportList
=
ref
([])
const
reportGenerating
=
ref
(
false
)
const
reportGenerating
=
ref
(
false
)
const
reportShareEnabled
=
ref
(
true
)
const
reportShareEnabled
=
ref
(
true
)
const
reportGeneratingStage
=
ref
(
''
)
const
reportPreviewOpen
=
ref
(
false
)
const
reportPreview
=
ref
(
null
)
const
reportCommitting
=
ref
(
false
)
const
formatReportTime
=
(
iso
=
''
)
=>
{
const
formatReportTime
=
(
iso
=
''
)
=>
{
if
(
!
iso
)
return
''
if
(
!
iso
)
return
''
...
@@ -378,30 +384,76 @@ const generateReport = async () => {
...
@@ -378,30 +384,76 @@ const generateReport = async () => {
return
return
}
}
reportGenerating
.
value
=
true
reportGenerating
.
value
=
true
reportGeneratingStage
.
value
=
'准备中…'
try
{
try
{
if
(
!
store
.
localMatch
(
candidate
.
id
))
throw
new
Error
(
'请先为候选人选择并确认关联岗位'
)
if
(
!
store
.
localMatch
(
candidate
.
id
))
throw
new
Error
(
'请先为候选人选择并确认关联岗位'
)
const
previous
=
candidate
.
analysis
await
store
.
saveStateToServer
()
const
reusable
=
isAiEvaluationReport
(
previous
)
&&
!
analysisNeedsRefresh
(
candidate
)
let
analysis
=
null
if
(
!
reusable
)
{
await
reportApi
.
generateV2ReportStream
(
candidate
.
id
,
{
showToast
(
'正在生成 AI 评估报告...'
,
'info'
)
onStage
:
(
_stage
,
message
)
=>
{
candidate
.
analysis
=
await
store
.
analyzeResume
(
candidate
)
reportGeneratingStage
.
value
=
message
||
'AI 正在生成报告…'
store
.
resumeDetailMode
=
'ai'
},
onDone
:
(
value
)
=>
{
analysis
=
value
},
})
if
(
!
analysis
)
throw
new
Error
(
'报告生成失败,请稍后重试'
)
reportPreview
.
value
=
{
reportId
:
'预览'
,
generatedAt
:
new
Date
().
toISOString
(),
snapshot
:
{
candidateId
:
candidate
.
id
,
candidateName
:
candidate
.
name
,
jobTitle
:
candidate
.
jobTitle
,
source
:
candidate
.
source
,
analysis
,
},
}
}
reportPreviewOpen
.
value
=
true
}
catch
(
error
)
{
showToast
(
error
.
message
||
'分析报告生成失败'
,
'error'
)
}
finally
{
reportGenerating
.
value
=
false
reportGeneratingStage
.
value
=
''
}
}
// 预览确认(决策 5):仅冻结快照(不写回),或先刷新 candidate.analysis 再冻结。
const
commitPreview
=
async
(
writeBack
=
false
)
=>
{
const
candidate
=
selected
.
value
const
analysis
=
reportPreview
.
value
?.
snapshot
?.
analysis
if
(
!
candidate
||
!
analysis
||
reportCommitting
.
value
)
return
reportCommitting
.
value
=
true
try
{
if
(
writeBack
)
{
const
job
=
matchedJobForCandidate
(
candidate
,
store
.
jobs
)
||
{}
const
jobSnapshot
=
buildJobSnapshot
(
job
)
analysis
.
jobSnapshot
=
jobSnapshot
analysis
.
inputKey
=
analysisInputKey
(
candidate
,
jobSnapshot
)
analysis
.
generatedAt
=
new
Date
().
toISOString
()
candidate
.
analysis
=
analysis
store
.
resumeDetailMode
=
'ai'
await
store
.
saveStateToServer
()
await
store
.
saveStateToServer
()
const
payload
=
await
reportApi
.
createCandidateReport
(
candidate
.
id
)
}
const
payload
=
writeBack
?
await
reportApi
.
createCandidateReport
(
candidate
.
id
)
:
await
reportApi
.
createCandidateReport
(
candidate
.
id
,
analysis
)
const
report
=
payload
.
report
const
report
=
payload
.
report
store
.
logEvent
(
'report_generated'
,
candidate
,
{
store
.
logEvent
(
'report_generated'
,
candidate
,
{
reportId
:
report
.
reportId
,
reportId
:
report
.
reportId
,
score
:
reportSnapshotScore
(
report
.
snapshot
),
score
:
reportSnapshotScore
(
report
.
snapshot
),
reportVersion
:
report
.
snapshot
?.
analysis
?.
version
===
2
?
'v2'
:
'v1'
,
})
})
store
.
persist
(
'分析报告已生成'
)
store
.
persist
(
'分析报告已生成'
)
reportPreviewOpen
.
value
=
false
reportPreview
.
value
=
null
await
copyText
(
report
.
shareUrl
)
await
copyText
(
report
.
shareUrl
)
showToast
(
'分析报告已生成,链接已复制到剪贴板'
,
'success'
)
showToast
(
'分析报告已生成,链接已复制到剪贴板'
,
'success'
)
await
loadReports
()
await
loadReports
()
}
catch
(
error
)
{
}
catch
(
error
)
{
showToast
(
error
.
message
||
'
分析报告生成
失败'
,
'error'
)
showToast
(
error
.
message
||
'
报告创建
失败'
,
'error'
)
}
finally
{
}
finally
{
report
Genera
ting
.
value
=
false
report
Commit
ting
.
value
=
false
}
}
}
}
...
@@ -1085,7 +1137,7 @@ const saveInterview = (candidate) => {
...
@@ -1085,7 +1137,7 @@ const saveInterview = (candidate) => {
</footer>
</footer>
</div>
</div>
<el-dialog
v-model=
"reportDialogOpen"
title=
"分析报告"
width=
"620px"
append-to-body
class=
"report-panel-dialog"
>
<el-dialog
v-model=
"reportDialogOpen"
:close-on-click-modal=
"false"
title=
"分析报告"
width=
"620px"
append-to-body
class=
"report-panel-dialog"
>
<div
class=
"report-panel"
>
<div
class=
"report-panel"
>
<div
class=
"report-panel-head"
>
<div
class=
"report-panel-head"
>
<div
class=
"report-panel-identity"
>
<div
class=
"report-panel-identity"
>
...
@@ -1093,7 +1145,7 @@ const saveInterview = (candidate) => {
...
@@ -1093,7 +1145,7 @@ const saveInterview = (candidate) => {
<span>
{{ selected?.jobTitle || '待匹配岗位' }}
</span>
<span>
{{ selected?.jobTitle || '待匹配岗位' }}
</span>
</div>
</div>
<el-button
type=
"primary"
:loading=
"reportGenerating"
:disabled=
"!reportShareEnabled"
@
click=
"generateReport"
>
<el-button
type=
"primary"
:loading=
"reportGenerating"
:disabled=
"!reportShareEnabled"
@
click=
"generateReport"
>
生成
新
报告
生成
v2
报告
</el-button>
</el-button>
</div>
</div>
<el-alert
<el-alert
...
@@ -1106,8 +1158,12 @@ const saveInterview = (candidate) => {
...
@@ -1106,8 +1158,12 @@ const saveInterview = (candidate) => {
description=
"当前部署未开启 REPORT_SHARE_ENABLED,生成的链接外部无法访问;请联系管理员在配置中开启。"
description=
"当前部署未开启 REPORT_SHARE_ENABLED,生成的链接外部无法访问;请联系管理员在配置中开启。"
/>
/>
<p
class=
"report-hint"
>
<p
class=
"report-hint"
>
每次「生成新报告」会基于当前 AI
每次「生成 v2 报告」会先生成富报告并弹窗预览,可在「仅生成分享报告」与「同时刷新 AI
评估冻结一份快照并创建公网链接;分享页仅含姓名、岗位与评估结论,不含联系方式与原简历。
评估」之间选择;确认后才会冻结快照并创建公网链接。分享页仅含姓名、岗位与评估结论,不含联系方式与原简历。
</p>
<p
v-if=
"reportGenerating"
class=
"report-stage"
>
<el-icon
class=
"is-loading"
><Loading
/></el-icon>
{{ reportGeneratingStage }}
</p>
</p>
<div
v-if=
"reportListLoading"
class=
"report-list-loading"
>
加载中…
</div>
<div
v-if=
"reportListLoading"
class=
"report-list-loading"
>
加载中…
</div>
<div
v-else-if=
"!reportList.length"
class=
"report-empty"
>
还没有分析报告,点击右上角「生成新报告」创建。
</div>
<div
v-else-if=
"!reportList.length"
class=
"report-empty"
>
还没有分析报告,点击右上角「生成新报告」创建。
</div>
...
@@ -1157,6 +1213,33 @@ const saveInterview = (candidate) => {
...
@@ -1157,6 +1213,33 @@ const saveInterview = (candidate) => {
</div>
</div>
</div>
</div>
</el-dialog>
</el-dialog>
<el-dialog
v-model=
"reportPreviewOpen"
title=
"报告预览(v2 富评估)"
width=
"820px"
top=
"6vh"
append-to-body
class=
"report-preview-dialog"
>
<div
class=
"report-preview-body"
>
<ReportContentView
v-if=
"reportPreview"
:report=
"reportPreview"
/>
</div>
<
template
#
footer
>
<div
class=
"preview-footer"
>
<span
class=
"preview-tip"
>
预览尚未保存。「同时刷新 AI 评估并分享」会把该富报告写回候选人评估(抽屉「评估」页同步变丰富)后再生成链接。
</span>
<div
class=
"preview-actions"
>
<el-button
:disabled=
"reportCommitting"
@
click=
"reportPreviewOpen = false"
>
取消
</el-button>
<el-button
:loading=
"reportCommitting"
@
click=
"commitPreview(false)"
>
仅生成分享报告
</el-button>
<el-button
type=
"primary"
:loading=
"reportCommitting"
@
click=
"commitPreview(true)"
>
同时刷新 AI 评估并分享
</el-button>
</div>
</div>
</
template
>
</el-dialog>
</el-drawer>
</el-drawer>
</template>
</template>
<
style
lang=
"scss"
scoped
>
<
style
lang=
"scss"
scoped
>
...
@@ -2173,6 +2256,47 @@ const saveInterview = (candidate) => {
...
@@ -2173,6 +2256,47 @@ const saveInterview = (candidate) => {
}
}
}
}
}
}
// 报告生成进度与 v2 预览(Phase B)
.report-stage
{
display
:
flex
;
align-items
:
center
;
gap
:
6px
;
margin
:
0
0
10px
;
padding
:
8px
10px
;
border-radius
:
8px
;
background
:
color-mix
(
in
srgb
,
var
(
--
blue
)
6%
,
#fff
);
color
:
var
(
--
ink
);
font-size
:
12px
;
}
.report-preview-dialog
{
.report-preview-body
{
max-height
:
62vh
;
overflow
:
auto
;
padding
:
2px
4px
;
}
.preview-footer
{
display
:
flex
;
flex-direction
:
column
;
align-items
:
stretch
;
gap
:
10px
;
.preview-tip
{
font-size
:
12px
;
line-height
:
1
.6
;
color
:
var
(
--
muted
);
}
.preview-actions
{
display
:
flex
;
justify-content
:
flex-end
;
flex-wrap
:
wrap
;
gap
:
8px
;
}
}
}
// 分析报告面板(ADR 0002)
// 分析报告面板(ADR 0002)
.report-panel
{
.report-panel
{
.report-panel-head
{
.report-panel-head
{
...
...
vue-app/src/utils/report.js
View file @
be7f8224
// 把评估字段里的条目统一转成可读文本:LLM 可能返回对象(如风险
// {type,finding,basis})或 JSON 字符串,直接插值会显示成原始 JSON。
function
plainText
(
item
)
{
if
(
item
&&
typeof
item
===
'object'
)
{
const
type
=
item
.
type
||
item
.
label
||
item
.
dimension
||
''
const
finding
=
item
.
finding
||
item
.
summary
||
item
.
text
||
item
.
detail
||
''
const
basis
=
item
.
basis
||
item
.
evidence
||
''
let
text
=
String
(
finding
||
''
).
trim
()
const
basisText
=
String
(
basis
||
''
).
trim
()
if
(
basisText
)
text
=
text
?
`
${
text
}
(依据:
${
basisText
}
)`
:
`依据:
${
basisText
}
`
if
(
type
)
text
=
text
?
`【
${
type
}
】
${
text
}
`
:
`【
${
type
}
】`
return
text
||
JSON
.
stringify
(
item
)
}
const
value
=
String
(
item
??
''
).
trim
()
if
(
value
.
startsWith
(
'{'
))
{
try
{
const
parsed
=
JSON
.
parse
(
value
)
if
(
parsed
&&
typeof
parsed
===
'object'
)
return
plainText
(
parsed
)
}
catch
{
// 保持原文
}
}
return
value
}
function
listItems
(
value
)
{
function
listItems
(
value
)
{
return
Array
.
isArray
(
value
)
?
value
.
filter
((
item
)
=>
String
(
item
||
''
).
trim
())
:
[]
return
Array
.
isArray
(
value
)
?
value
.
map
(
plainText
).
filter
((
item
)
=>
String
(
item
||
''
).
trim
())
:
[]
}
}
// 需求 17:风险固定维度(跳槽频率 / 竞业协议 / 职业空窗 / 高层职业道德)
// 需求 17:风险固定维度(跳槽频率 / 竞业协议 / 职业空窗 / 高层职业道德)
...
...
vue-app/src/views/ShareReportView.vue
View file @
be7f8224
<
script
setup
>
<
script
setup
>
// 分析报告公网分享页(ADR 0002):免登录只读展示候选人 AI 初筛分析快照。
// 分析报告公网分享页(ADR 0002):免登录只读展示候选人分析报告快照。
// 数据来自后端匿名只读接口 /api/share/report/{reportId}?token=...;
// 内容渲染交给 ReportContentView(v1/v2 通用);本页负责外壳、加载/失效态与打印水印(P1 轻量 PDF)。
// 渲染逻辑复用 utils/report.js(与抽屉「评估」同源),快照不含电话/邮箱/原简历。
import
{
computed
,
onMounted
,
ref
}
from
'vue'
import
{
computed
,
onMounted
,
ref
}
from
'vue'
import
{
useRoute
}
from
'vue-router'
import
{
useRoute
}
from
'vue-router'
import
{
fetchSharedReport
}
from
'@/api/recruitment'
import
{
fetchSharedReport
}
from
'@/api/recruitment'
import
{
providerLabel
}
from
'@/utils/scoring'
import
ReportContentView
from
'@/components/reports/ReportContentView.vue'
import
{
assessmentDecision
,
assessmentReportRows
,
assessmentScoreText
,
riskProfileRows
}
from
'@/utils/report'
const
route
=
useRoute
()
const
route
=
useRoute
()
...
@@ -15,37 +13,15 @@ const loading = ref(true)
...
@@ -15,37 +13,15 @@ const loading = ref(true)
const
errorReason
=
ref
(
''
)
// '' | 'disabled' | 'not_found'
const
errorReason
=
ref
(
''
)
// '' | 'disabled' | 'not_found'
const
report
=
ref
(
null
)
const
report
=
ref
(
null
)
const
snapshot
=
computed
(()
=>
report
.
value
?.
snapshot
||
{})
const
author
=
computed
(()
=>
report
.
value
?.
snapshot
?.
author
||
{})
const
candidate
=
computed
(()
=>
({
const
generatedDate
=
computed
(()
=>
{
id
:
snapshot
.
value
.
candidateId
,
const
iso
=
report
.
value
?.
generatedAt
||
''
name
:
snapshot
.
value
.
candidateName
,
jobTitle
:
snapshot
.
value
.
jobTitle
,
analysis
:
snapshot
.
value
.
analysis
,
matchReport
:
snapshot
.
value
.
matchReport
,
quickMatchScore
:
snapshot
.
value
.
quickMatchScore
,
}))
const
scoreText
=
computed
(()
=>
(
report
.
value
?
assessmentScoreText
(
candidate
.
value
,
'ai'
)
:
''
))
const
decisionInfo
=
computed
(()
=>
(
report
.
value
?
assessmentDecision
(
candidate
.
value
,
'ai'
)
:
{}))
const
reportRows
=
computed
(()
=>
(
report
.
value
?
assessmentReportRows
(
candidate
.
value
,
'ai'
)
:
[]))
const
riskProfile
=
computed
(()
=>
riskProfileRows
(
snapshot
.
value
.
analysis
||
{}))
const
author
=
computed
(()
=>
snapshot
.
value
.
author
||
{})
const
provider
=
computed
(()
=>
providerLabel
(
snapshot
.
value
.
analysis
?.
provider
||
''
))
const
toneTag
=
(
level
)
=>
({
ok
:
{
type
:
'success'
,
text
:
'正常'
},
hint
:
{
type
:
'warning'
,
text
:
'提示'
},
warn
:
{
type
:
'danger'
,
text
:
'需确认'
},
})[
level
]
||
{
type
:
'info'
,
text
:
level
}
const
formatTime
=
(
iso
=
''
)
=>
{
if
(
!
iso
)
return
''
if
(
!
iso
)
return
''
const
date
=
new
Date
(
iso
)
const
date
=
new
Date
(
iso
)
if
(
Number
.
isNaN
(
date
.
getTime
()))
return
iso
if
(
Number
.
isNaN
(
date
.
getTime
()))
return
iso
const
pad
=
(
n
)
=>
String
(
n
).
padStart
(
2
,
'0'
)
const
pad
=
(
n
)
=>
String
(
n
).
padStart
(
2
,
'0'
)
return
`
${
date
.
getFullYear
()}
-
${
pad
(
date
.
getMonth
()
+
1
)}
-
${
pad
(
date
.
getDate
())}
${
pad
(
date
.
getHours
())}
:
${
pad
(
date
.
getMinutes
())}
`
return
`
${
date
.
getFullYear
()}
-
${
pad
(
date
.
getMonth
()
+
1
)}
-
${
pad
(
date
.
getDate
())}
${
pad
(
date
.
getHours
())}
:
${
pad
(
date
.
getMinutes
())}
`
}
}
)
const
printPage
=
()
=>
window
.
print
()
const
printPage
=
()
=>
window
.
print
()
...
@@ -78,7 +54,7 @@ onMounted(async () => {
...
@@ -78,7 +54,7 @@ onMounted(async () => {
<span>
候选人 AI 初筛分析报告
</span>
<span>
候选人 AI 初筛分析报告
</span>
</div>
</div>
</div>
</div>
<el-button
size=
"small"
class=
"print-btn no-print"
@
click=
"printPage"
>
<el-button
v-if=
"report"
size=
"small"
class=
"print-btn no-print"
@
click=
"printPage"
>
<el-icon><Printer
/></el-icon>
<el-icon><Printer
/></el-icon>
打印 / 存 PDF
打印 / 存 PDF
</el-button>
</el-button>
...
@@ -97,69 +73,12 @@ onMounted(async () => {
...
@@ -97,69 +73,12 @@ onMounted(async () => {
</div>
</div>
<template
v-else-if=
"report"
>
<template
v-else-if=
"report"
>
<section
class=
"candidate-head"
>
<ReportContentView
:report=
"report"
/>
<div
class=
"name-row"
>
<div
class=
"print-watermark no-screen"
aria-hidden=
"true"
>
<h1>
{{
snapshot
.
candidateName
||
'未命名候选人'
}}
</h1>
<div>
内部资料 · 仅供内部初筛参考
</div>
<el-tag
size=
"small"
effect=
"plain"
>
{{
snapshot
.
jobTitle
||
'待匹配岗位'
}}
</el-tag>
<div>
{{
report
.
reportId
}}
</div>
<el-tag
v-if=
"snapshot.source"
size=
"small"
type=
"info"
effect=
"plain"
>
{{
snapshot
.
source
}}
</el-tag>
<div
v-if=
"author.name || author.username"
>
{{
author
.
name
||
author
.
username
}}
·
{{
generatedDate
}}
</div>
</div>
<div
class=
"meta-row"
>
<span>
报告编号:
{{
report
.
reportId
}}
</span>
<span>
生成时间:
{{
formatTime
(
report
.
generatedAt
)
}}
</span>
<span>
评估方式:
{{
provider
}}
</span>
<span>
生成人:
{{
author
.
name
||
author
.
username
||
'—'
}}
</span>
</div>
</section>
<section
class=
"summary-strip"
:class=
"`tone-$
{decisionInfo.tone}`">
<div
class=
"score-block"
>
<strong
class=
"score-value"
>
{{
scoreText
}}
</strong>
<span>
综合得分
</span>
</div>
<div
class=
"decision-block"
>
<span
class=
"eyebrow"
>
初筛建议
</span>
<strong>
{{
decisionInfo
.
text
}}
</strong>
<p>
{{
decisionInfo
.
note
}}
</p>
</div>
</section>
<section
v-if=
"snapshot.analysis?.summary"
class=
"block"
>
<h4>
评估摘要
</h4>
<p
class=
"summary-text"
>
{{
snapshot
.
analysis
.
summary
}}
</p>
</section>
<section
v-if=
"riskProfile.length"
class=
"block"
>
<h4>
风险点评估
</h4>
<div
class=
"risk-list"
>
<div
v-for=
"item in riskProfile"
:key=
"item.dimension"
class=
"risk-item"
:class=
"`level-$
{item.level}`">
<el-tag
:type=
"toneTag(item.level).type"
size=
"small"
effect=
"light"
>
{{
toneTag
(
item
.
level
).
text
}}
</el-tag>
<b>
{{
item
.
label
}}
</b>
<p>
{{
item
.
summary
}}
</p>
<span
v-for=
"(ev, index) in item.evidence || []"
:key=
"`$
{item.dimension}-${index}`">依据:
{{
ev
}}
</span>
</div>
</div>
</section>
<section
v-if=
"reportRows.length"
class=
"block"
>
<h4>
评估明细
</h4>
<div
v-for=
"row in reportRows"
:key=
"row.label"
class=
"detail-card"
:class=
"`tone-$
{row.tone}`">
<div
class=
"detail-head"
>
<b>
{{
row
.
label
}}
</b>
<el-tag
:type=
"row.tone"
size=
"small"
effect=
"light"
>
{{
row
.
status
}}
</el-tag>
</div>
<ul
v-if=
"row.items.length"
>
<li
v-for=
"(item, index) in row.items"
:key=
"index"
>
{{
item
}}
</li>
</ul>
<p
v-else
class=
"detail-empty"
>
暂无
</p>
</div>
</div>
</section>
<footer
class=
"report-footer"
>
<p>
本报告由 AI 辅助生成,基于生成时刻的简历与岗位信息冻结,仅供内部初筛参考,请以人工复核与面试为准。
</p>
<p>
不含候选人联系方式与原简历文件。
</p>
</footer>
</
template
>
</
template
>
</main>
</main>
</div>
</div>
...
@@ -224,6 +143,10 @@ onMounted(async () => {
...
@@ -224,6 +143,10 @@ onMounted(async () => {
}
}
}
}
.no-screen
{
display
:
none
;
}
.state-box
{
.state-box
{
padding
:
72px
24px
;
padding
:
72px
24px
;
text-align
:
center
;
text-align
:
center
;
...
@@ -255,259 +178,6 @@ onMounted(async () => {
...
@@ -255,259 +178,6 @@ onMounted(async () => {
}
}
}
}
.candidate-head
{
padding
:
18px
0
14px
;
.name-row
{
display
:
flex
;
align-items
:
center
;
flex-wrap
:
wrap
;
gap
:
8px
;
h1
{
margin
:
0
;
font-size
:
24px
;
}
}
.meta-row
{
display
:
flex
;
flex-wrap
:
wrap
;
gap
:
6px
18px
;
margin-top
:
10px
;
font-size
:
12px
;
color
:
var
(
--
muted
);
}
}
.summary-strip
{
display
:
flex
;
align-items
:
stretch
;
gap
:
0
;
border
:
1px
solid
var
(
--
line
);
border-radius
:
12px
;
overflow
:
hidden
;
margin-bottom
:
16px
;
.score-block
{
display
:
flex
;
flex-direction
:
column
;
align-items
:
center
;
justify-content
:
center
;
gap
:
4px
;
min-width
:
168px
;
padding
:
16px
18px
;
background
:
rgba
(
255
,
255
,
255
,
0
.8
);
.score-value
{
font-size
:
30px
;
line-height
:
1
;
}
span
{
font-size
:
12px
;
color
:
var
(
--
muted
);
}
}
.decision-block
{
flex
:
1
;
min-width
:
0
;
padding
:
14px
18px
;
display
:
flex
;
flex-direction
:
column
;
justify-content
:
center
;
border-left
:
1px
solid
var
(
--
line
);
.eyebrow
{
font-size
:
11px
;
letter-spacing
:
1px
;
color
:
var
(
--
muted
);
}
strong
{
margin
:
4px
0
2px
;
font-size
:
17px
;
}
p
{
margin
:
0
;
font-size
:
12px
;
color
:
var
(
--
muted
);
line-height
:
1
.5
;
}
}
&
.tone-success
{
border-color
:
color-mix
(
in
srgb
,
var
(
--
green
)
55%
,
var
(
--
line
));
.score-value
,
.decision-block
strong
{
color
:
var
(
--
green
);
}
}
&
.tone-warning
{
border-color
:
color-mix
(
in
srgb
,
var
(
--
yellow
)
55%
,
var
(
--
line
));
.score-value
,
.decision-block
strong
{
color
:
var
(
--
yellow
);
}
}
&
.tone-danger
{
border-color
:
color-mix
(
in
srgb
,
var
(
--
rose
)
55%
,
var
(
--
line
));
.score-value
,
.decision-block
strong
{
color
:
var
(
--
rose
);
}
}
&
.tone-info
{
.score-value
,
.decision-block
strong
{
color
:
var
(
--
blue
);
}
}
}
.block
{
margin-bottom
:
16px
;
h4
{
margin
:
0
0
8px
;
font-size
:
14px
;
}
.summary-text
{
margin
:
0
;
padding
:
12px
14px
;
border
:
1px
solid
var
(
--
line
);
border-radius
:
10px
;
background
:
rgba
(
255
,
255
,
255
,
0
.7
);
font-size
:
13px
;
line-height
:
1
.7
;
color
:
var
(
--
ink
);
}
}
.risk-list
{
display
:
flex
;
flex-direction
:
column
;
gap
:
8px
;
.risk-item
{
display
:
grid
;
grid-template-columns
:
auto
auto
1fr
;
align-items
:
center
;
gap
:
4px
10px
;
padding
:
10px
12px
;
border-radius
:
10px
;
border
:
1px
solid
var
(
--
line
);
background
:
rgba
(
255
,
255
,
255
,
0
.6
);
b
{
font-size
:
13px
;
}
p
{
grid-column
:
1
/
-1
;
margin
:
2px
0
0
;
font-size
:
13px
;
line-height
:
1
.6
;
}
span
{
grid-column
:
1
/
-1
;
font-size
:
12px
;
color
:
var
(
--
muted
);
line-height
:
1
.5
;
}
&
.level-warn
{
border-color
:
color-mix
(
in
srgb
,
var
(
--
rose
)
45%
,
var
(
--
line
));
background
:
color-mix
(
in
srgb
,
var
(
--
rose
)
6%
,
#fff
);
}
&
.level-hint
{
border-color
:
color-mix
(
in
srgb
,
var
(
--
yellow
)
45%
,
var
(
--
line
));
background
:
color-mix
(
in
srgb
,
var
(
--
yellow
)
7%
,
#fff
);
}
}
}
.detail-card
{
margin-bottom
:
10px
;
padding
:
12px
14px
;
border
:
1px
solid
var
(
--
line
);
border-left-width
:
3px
;
border-radius
:
10px
;
background
:
rgba
(
255
,
255
,
255
,
0
.7
);
&
.tone-success
{
border-left-color
:
var
(
--
green
);
}
&
.tone-warning
{
border-left-color
:
var
(
--
yellow
);
}
&
.tone-danger
{
border-left-color
:
var
(
--
rose
);
}
&
.tone-info
{
border-left-color
:
var
(
--
blue
);
}
.detail-head
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
gap
:
10px
;
margin-bottom
:
6px
;
b
{
font-size
:
14px
;
}
}
ul
{
margin
:
0
;
padding-left
:
18px
;
display
:
flex
;
flex-direction
:
column
;
gap
:
5px
;
li
{
font-size
:
13px
;
line-height
:
1
.6
;
color
:
var
(
--
ink
);
}
}
.detail-empty
{
margin
:
0
;
font-size
:
13px
;
color
:
var
(
--
muted
);
}
}
.report-footer
{
margin-top
:
18px
;
padding-top
:
12px
;
border-top
:
1px
dashed
var
(
--
line
);
font-size
:
12px
;
color
:
var
(
--
muted
);
line-height
:
1
.6
;
p
{
margin
:
2px
0
;
}
}
@keyframes
pulse
{
@keyframes
pulse
{
0
%
,
0
%
,
100
%
{
100
%
{
...
@@ -522,20 +192,6 @@ onMounted(async () => {
...
@@ -522,20 +192,6 @@ onMounted(async () => {
}
}
@media
(
max-width
:
620px
)
{
@media
(
max-width
:
620px
)
{
.summary-strip
{
flex-direction
:
column
;
.score-block
{
min-width
:
0
;
padding
:
14px
;
}
.decision-block
{
border-left
:
0
;
border-top
:
1px
solid
var
(
--
line
);
}
}
.report-head
{
.report-head
{
align-items
:
flex-start
;
align-items
:
flex-start
;
}
}
...
@@ -555,6 +211,27 @@ onMounted(async () => {
...
@@ -555,6 +211,27 @@ onMounted(async () => {
box-shadow
:
none
;
box-shadow
:
none
;
border
:
0
;
border
:
0
;
width
:
100%
;
width
:
100%
;
max-width
:
none
;
}
// 轻量水印(P1):每页固定覆盖,避免截图外传
.print-watermark
{
position
:
fixed
;
inset
:
0
;
display
:
flex
;
flex-direction
:
column
;
align-items
:
center
;
justify-content
:
center
;
gap
:
10px
;
transform
:
rotate
(
-28deg
);
font-size
:
24px
;
font-weight
:
700
;
letter-spacing
:
3px
;
color
:
#000
;
opacity
:
0
.07
;
pointer-events
:
none
;
z-index
:
9999
;
white-space
:
nowrap
;
}
}
}
}
</
style
>
</
style
>
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment