Files
Resume/public/index.html
2026-04-17 13:23:50 +08:00

1259 lines
38 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#f8fafc" />
<title>简历解析 · 面试评估</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Noto+Sans+SC:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<style>
:root {
--bg: #f1f5f9;
--bg-elevated: #ffffff;
--text: #0f172a;
--text-muted: #475569;
--border: #e2e8f0;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--accent-soft: #eff6ff;
--success: #059669;
--success-bg: #ecfdf5;
--warn-bg: #fffbeb;
--warn-text: #92400e;
--error-bg: #fef2f2;
--error-text: #b91c1c;
--code-bg: #0f172a;
--code-text: #e2e8f0;
--radius: 12px;
--radius-lg: 16px;
--shadow: 0 1px 3px rgba(15, 23, 42, 0.06), 0 8px 24px rgba(15, 23, 42, 0.06);
--touch: 44px;
--font: "Inter", "Noto Sans SC", system-ui, -apple-system, sans-serif;
--text-body: clamp(0.875rem, 0.82rem + 0.25vw, 1rem);
--text-title: clamp(1.375rem, 1.2rem + 0.8vw, 1.75rem);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100dvh;
font-family: var(--font);
font-size: var(--text-body);
line-height: 1.6;
color: var(--text);
background: linear-gradient(165deg, #f8fafc 0%, #e2e8f0 55%, #f1f5f9 100%);
padding: max(16px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right))
max(24px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
.page {
width: 100%;
max-width: min(960px, 100%);
margin: 0 auto;
}
.hero {
margin-bottom: clamp(1.25rem, 3vw, 2rem);
}
.hero h1 {
margin: 0 0 0.5rem;
font-size: var(--text-title);
font-weight: 600;
letter-spacing: -0.02em;
color: var(--text);
}
.hero p {
margin: 0;
max-width: 52ch;
color: var(--text-muted);
font-size: clamp(0.8125rem, 0.78rem + 0.2vw, 0.9375rem);
line-height: 1.65;
}
.card {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
padding: clamp(1rem, 3vw, 1.5rem);
}
.card + .card {
margin-top: clamp(0.875rem, 2vw, 1.25rem);
}
/* 主内容:评价区视觉权重 */
.card--featured {
border: 1px solid #bfdbfe;
box-shadow: 0 4px 28px rgba(37, 99, 235, 0.14), var(--shadow);
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
}
.card--featured .section-label {
font-size: 0.75rem;
letter-spacing: 0.08em;
color: #1e40af;
}
.card--featured .section-label::before {
content: "";
display: inline-block;
width: 4px;
height: 1em;
margin-right: 0.5rem;
border-radius: 2px;
background: var(--accent);
vertical-align: -0.1em;
}
.upload-row {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
@media (min-width: 640px) {
.upload-row {
flex-direction: row;
flex-wrap: wrap;
align-items: stretch;
gap: 1rem;
}
.upload-row .file-wrap {
flex: 1 1 200px;
min-width: 0;
}
.upload-row .btn-primary {
flex: 0 0 auto;
align-self: flex-end;
}
}
.file-wrap {
position: relative;
border: 1px dashed #cbd5e1;
border-radius: var(--radius);
background: #fafbfc;
transition: border-color 0.2s ease, background 0.2s ease;
}
.file-wrap:hover {
border-color: #94a3b8;
background: #f8fafc;
}
.file-wrap:focus-within {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.file-inner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
min-height: var(--touch);
}
.file-inner svg {
flex-shrink: 0;
opacity: 0.55;
}
.file-label {
display: block;
font-size: 0.75rem;
font-weight: 500;
color: var(--text-muted);
margin-bottom: 0.25rem;
}
input[type="file"] {
width: 100%;
font-size: 0.8125rem;
color: var(--text);
cursor: pointer;
}
input[type="file"]::file-selector-button {
margin-right: 0.75rem;
padding: 0.5rem 0.875rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-elevated);
font-family: inherit;
font-size: 0.8125rem;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
}
input[type="file"]::file-selector-button:hover {
background: #f1f5f9;
border-color: #cbd5e1;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: var(--touch);
padding: 0.625rem 1.25rem;
border: none;
border-radius: var(--radius);
font-family: inherit;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s ease, transform 0.15s ease;
}
.btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn-primary {
width: 100%;
background: var(--accent);
color: #fff;
}
@media (min-width: 640px) {
.btn-primary {
width: auto;
min-width: 8.5rem;
}
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.btn-secondary {
background: var(--success);
color: #fff;
}
.btn-secondary:hover:not(:disabled) {
background: #047857;
}
.actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 1rem;
}
@media (min-width: 480px) {
.actions {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
}
}
.hint-inline {
font-size: 0.8125rem;
color: var(--text-muted);
line-height: 1.5;
}
.status {
margin-top: 1rem;
padding: 0.875rem 1rem;
border-radius: var(--radius);
background: var(--accent-soft);
color: #1e40af;
font-size: 0.875rem;
}
.status.error {
background: var(--error-bg);
color: var(--error-text);
}
.parser-note {
margin-top: 0.5rem;
font-size: 0.8125rem;
color: var(--text-muted);
line-height: 1.55;
}
.dup-banner {
display: none;
margin-top: 1rem;
padding: 0.875rem 1rem;
border-radius: var(--radius);
font-size: 0.875rem;
line-height: 1.55;
border: 1px solid transparent;
}
.dup-banner.is-warn {
background: var(--warn-bg);
color: var(--warn-text);
border-color: #fde68a;
}
.dup-banner.is-ok {
background: var(--success-bg);
color: #065f46;
border-color: #a7f3d0;
}
.section-label {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
margin: 0 0 0.625rem;
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #64748b;
}
.panel-raw {
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fafbfc;
min-height: 8rem;
max-height: min(52vh, 28rem);
overflow: auto;
-webkit-overflow-scrolling: touch;
}
@media (min-width: 768px) and (min-height: 600px) {
.panel-raw {
max-height: min(48vh, 32rem);
}
}
.panel-raw pre {
margin: 0;
padding: clamp(0.875rem, 2vw, 1.125rem);
font-family: ui-monospace, "Cascadia Code", "Segoe UI Mono", monospace;
font-size: clamp(0.75rem, 0.7rem + 0.35vw, 0.8125rem);
line-height: 1.65;
white-space: pre-wrap;
word-break: break-word;
color: var(--text);
}
.code-block {
margin: 0;
padding: clamp(0.875rem, 2vw, 1.125rem);
border-radius: var(--radius);
background: var(--code-bg);
color: var(--code-text);
font-family: ui-monospace, "Cascadia Code", "Segoe UI Mono", monospace;
font-size: clamp(0.6875rem, 0.65rem + 0.3vw, 0.8125rem);
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
min-height: 4rem;
max-height: min(40vh, 22rem);
overflow-y: auto;
}
details.details-opt {
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fafbfc;
overflow: hidden;
}
details.details-opt summary {
padding: 0.75rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-muted);
cursor: pointer;
list-style: none;
user-select: none;
transition: background 0.15s ease;
}
details.details-opt summary::-webkit-details-marker {
display: none;
}
details.details-opt summary::after {
content: "";
float: right;
width: 0.5rem;
height: 0.5rem;
margin-top: 0.35rem;
border-right: 2px solid #94a3b8;
border-bottom: 2px solid #94a3b8;
transform: rotate(45deg);
transition: transform 0.2s ease;
}
details.details-opt[open] summary::after {
transform: rotate(-135deg);
margin-top: 0.5rem;
}
details.details-opt summary:hover {
background: #f1f5f9;
}
details.details-opt .code-block {
border-radius: 0;
border-top: 1px solid var(--border);
max-height: min(35vh, 18rem);
}
details.details-opt .panel-raw {
border-radius: 0;
border: none;
border-top: 1px solid var(--border);
}
.footer-note {
margin-top: clamp(1.5rem, 4vw, 2.5rem);
padding-top: 1rem;
border-top: 1px solid var(--border);
font-size: 0.75rem;
color: #94a3b8;
text-align: center;
}
code {
font-size: 0.85em;
padding: 0.1em 0.35em;
border-radius: 4px;
background: #f1f5f9;
color: #334155;
}
/* 评价与面试要点 · 卡片布局 */
.evaluation-root {
display: flex;
flex-direction: column;
gap: 1rem;
}
.eval-empty {
margin: 0;
padding: 1.25rem;
text-align: center;
color: var(--text-muted);
font-size: 0.875rem;
border: 1px dashed var(--border);
border-radius: var(--radius);
background: #fafbfc;
}
.eval-card {
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-elevated);
padding: clamp(1rem, 2.5vw, 1.25rem);
transition: box-shadow 0.2s ease;
}
.eval-card:hover {
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.06);
}
.eval-card--hero {
background: linear-gradient(135deg, #f8fafc 0%, #eff6ff 100%);
border-color: #bfdbfe;
}
.eval-card__title {
margin: 0 0 0.75rem;
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #64748b;
}
.eval-rating-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1rem;
margin-bottom: 0.75rem;
}
.eval-badge {
display: inline-flex;
align-items: center;
min-height: 2rem;
padding: 0 0.75rem;
border-radius: 999px;
font-size: 0.875rem;
font-weight: 600;
background: #1e40af;
color: #fff;
}
.eval-score {
font-size: 0.9375rem;
color: var(--text);
font-weight: 500;
}
.eval-reason {
margin: 0;
font-size: 0.875rem;
line-height: 1.65;
color: #334155;
}
.eval-body-text {
margin: 0;
font-size: 0.875rem;
line-height: 1.7;
color: var(--text);
}
.eval-split {
display: grid;
gap: 1rem;
}
@media (min-width: 768px) {
.eval-split--2 {
grid-template-columns: 1fr 1fr;
}
}
.eval-list {
margin: 0;
padding-left: 1.125rem;
font-size: 0.875rem;
line-height: 1.65;
color: #334155;
}
.eval-list li + li {
margin-top: 0.375rem;
}
.eval-chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.eval-chip {
display: inline-block;
padding: 0.35rem 0.65rem;
border-radius: 8px;
font-size: 0.8125rem;
background: #f1f5f9;
color: #334155;
border: 1px solid #e2e8f0;
}
.eval-questions-title {
margin: 0.25rem 0 0.5rem;
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #64748b;
}
.q-card {
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fafbfc;
overflow: hidden;
margin-bottom: 0.75rem;
}
.q-card:last-child {
margin-bottom: 0;
}
.q-card__head {
padding: 0.875rem 1rem;
border-bottom: 1px solid var(--border);
background: #fff;
}
.q-card__num {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.75rem;
height: 1.75rem;
margin-right: 0.5rem;
border-radius: 8px;
font-size: 0.75rem;
font-weight: 600;
background: var(--accent);
color: #fff;
vertical-align: middle;
}
.q-card__q {
display: inline;
font-size: 0.9375rem;
font-weight: 600;
color: var(--text);
line-height: 1.5;
}
.q-card__body {
padding: 0.875rem 1rem;
}
.q-card__label {
margin: 0 0 0.5rem;
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #64748b;
}
.q-card__answer {
margin: 0;
font-size: 0.875rem;
line-height: 1.7;
color: #334155;
}
details.eval-json {
border: 1px solid var(--border);
border-radius: var(--radius);
background: #f8fafc;
overflow: hidden;
}
details.eval-json summary {
padding: 0.65rem 1rem;
font-size: 0.75rem;
color: var(--text-muted);
cursor: pointer;
list-style: none;
user-select: none;
transition: background 0.15s ease;
}
details.eval-json summary:hover {
background: #f1f5f9;
}
details.eval-json pre {
margin: 0;
padding: 1rem;
max-height: 12rem;
overflow: auto;
font-size: 0.6875rem;
line-height: 1.5;
background: var(--code-bg);
color: var(--code-text);
border-top: 1px solid var(--border);
}
</style>
</head>
<body>
<div class="page">
<header class="hero">
<h1>简历解析与面试评估</h1>
<p>
上传 <code>.docx</code><code>.pdf</code> 完成查重后,系统会在后台生成专业简历评价并保存;就绪后「查看评价」按钮将可点击,即可加载完整评价与面试要点。若与已有记录重复,将直接沿用已保存结果。
</p>
</header>
<section class="card card--featured" aria-labelledby="eval-heading">
<span id="eval-heading" class="section-label">简历评价与面试要点</span>
<div
id="interviewReportRoot"
class="evaluation-root"
role="region"
aria-label="简历评价与面试要点"
>
<p class="eval-empty">上传并完成查重后,待评估生成完毕并点击「查看评价」,将在此以卡片形式展示内容。</p>
</div>
</section>
<section class="card" aria-labelledby="upload-heading">
<span id="upload-heading" class="section-label">上传</span>
<form id="uploadForm" class="upload-row">
<div class="file-wrap">
<div class="file-inner">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M12 4v12m0 0l-4-4m4 4l4-4M5 20h14"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<div style="flex: 1; min-width: 0">
<span class="file-label" id="file-label-text">选择简历文件</span>
<input
id="fileInput"
name="file"
type="file"
accept=".docx,.pdf"
required
aria-labelledby="file-label-text"
/>
</div>
</div>
</div>
<button id="submitBtn" class="btn btn-primary" type="submit">上传并查重</button>
</form>
<div id="dupBanner" class="dup-banner" style="display: none" role="status" aria-live="polite"></div>
<div class="actions">
<button type="button" id="evalViewBtn" class="btn btn-secondary" disabled>
查看评价
</button>
<span id="consultHint" class="hint-inline"></span>
</div>
<div
id="status"
class="status"
role="status"
aria-live="polite"
>
等待上传文件…
</div>
<div id="parserNote" class="parser-note">尚未开始</div>
</section>
<section class="card">
<details class="details-opt">
<summary id="raw-summary">
提取文本
<span
style="font-weight: 400; text-transform: none; letter-spacing: 0; font-size: 0.8125rem; color: var(--text-muted)"
>点击展开查看从文档抽取的原文</span
>
</summary>
<div class="panel-raw" role="region" aria-labelledby="raw-summary">
<pre id="rawText">上传成功后将显示从文档中提取的原始文本。</pre>
</div>
</details>
</section>
<p class="footer-note">本地服务 · 数据以实际部署环境为准</p>
</div>
<script>
const form = document.getElementById("uploadForm");
const fileInput = document.getElementById("fileInput");
const submitBtn = document.getElementById("submitBtn");
const evalViewBtn = document.getElementById("evalViewBtn");
const consultHint = document.getElementById("consultHint");
const dupBanner = document.getElementById("dupBanner");
const statusEl = document.getElementById("status");
const parserNoteEl = document.getElementById("parserNote");
const rawTextEl = document.getElementById("rawText");
const interviewReportRoot = document.getElementById("interviewReportRoot");
let lastStep1 = null;
let pollTimer = null;
let readinessPolling = false;
let consultLoading = false;
const POLL_INTERVAL_MS = 2500;
const POLL_MAX_ATTEMPTS = 48;
function stopPolling() {
if (pollTimer) {
clearTimeout(pollTimer);
pollTimer = null;
}
readinessPolling = false;
}
function setEvalButtonState() {
evalViewBtn.disabled =
!lastStep1 ||
!lastStep1.consultAllowed ||
readinessPolling ||
consultLoading;
}
function hasInterviewContent(ir) {
if (!ir || typeof ir !== "object") return false;
if (Array.isArray(ir.interviewQuestions) && ir.interviewQuestions.length > 0) {
return true;
}
if (typeof ir.abilitySummary === "string" && ir.abilitySummary.trim()) return true;
const r = ir.rating || {};
if (r.level || r.score || r.reason) return true;
const tech = ir.techStack;
if (Array.isArray(tech) && tech.length > 0) return true;
if (typeof tech === "string" && tech.trim()) return true;
if (Array.isArray(ir.strengths) && ir.strengths.length) return true;
if (Array.isArray(ir.weaknesses) && ir.weaknesses.length) return true;
return false;
}
async function fetchStoredEvaluation() {
const response = await fetch("/api/resume/consult-ai", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fileSha256: lastStep1.fileSha256 }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "读取失败");
}
return data;
}
async function fetchReadinessStatus() {
const u = new URL("/api/resume/ai-ready", window.location.origin);
u.searchParams.set("fileSha256", lastStep1.fileSha256);
const response = await fetch(u.toString());
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "查询就绪状态失败");
}
return data;
}
/**
* 非重复或尚无完整评估时:轮询服务端是否已保存评价,就绪后再点亮「查看评价」。
* 完整内容在用户点击按钮后拉取。
*/
function pollUntilEvaluationReady() {
stopPolling();
if (!lastStep1 || !lastStep1.fileSha256) return;
readinessPolling = true;
lastStep1.consultAllowed = false;
setEvalButtonState();
consultHint.textContent = "正在同步:等待服务端保存评估结果…";
clearInterviewReport(
"系统正在生成专业评价并保存。就绪后将自动点亮「查看评价」,请点击加载完整内容。"
);
let attempt = 0;
async function tick() {
if (!lastStep1 || !lastStep1.fileSha256) {
stopPolling();
setEvalButtonState();
return;
}
attempt += 1;
try {
const data = await fetchReadinessStatus();
if (data.ready) {
stopPolling();
lastStep1.consultAllowed = true;
setEvalButtonState();
consultHint.textContent =
"评估已就绪,请点击「查看评价」立即加载。";
clearInterviewReport(
"评价已保存至服务端。请点击下方「查看评价」加载完整内容。"
);
return;
}
if (attempt >= POLL_MAX_ATTEMPTS) {
stopPolling();
lastStep1.consultAllowed = true;
setEvalButtonState();
consultHint.textContent =
"长时间未检测到就绪,可点击「查看评价」重试;若仍失败请稍后再传。";
clearInterviewReport(
"未在预期时间内检测到已保存的评价。若仍在生成中,请稍后点击「查看评价」重试。"
);
return;
}
consultHint.textContent =
"等待评估就绪…(" + attempt + "/" + POLL_MAX_ATTEMPTS + "";
pollTimer = setTimeout(tick, POLL_INTERVAL_MS);
} catch (err) {
stopPolling();
lastStep1.consultAllowed = true;
consultHint.textContent = "同步失败:" + err.message + "(可尝试点击「查看评价」)";
clearInterviewReport("同步中断。可点击「查看评价」尝试重新加载。");
setEvalButtonState();
}
}
tick();
}
function clearInterviewReport(message) {
interviewReportRoot.innerHTML = "";
const p = document.createElement("p");
p.className = "eval-empty";
p.textContent = message;
interviewReportRoot.appendChild(p);
}
function renderInterviewReport(ir) {
interviewReportRoot.innerHTML = "";
if (!ir || typeof ir !== "object") {
clearInterviewReport("未收到有效的评估数据。");
return;
}
const rating = ir.rating || {};
const hasRating = rating.level || rating.score || rating.reason;
if (hasRating) {
const card = document.createElement("div");
card.className = "eval-card eval-card--hero";
const h = document.createElement("h3");
h.className = "eval-card__title";
h.textContent = "综合评级";
card.appendChild(h);
const row = document.createElement("div");
row.className = "eval-rating-row";
if (rating.level) {
const b = document.createElement("span");
b.className = "eval-badge";
b.textContent = String(rating.level).trim();
row.appendChild(b);
}
if (rating.score !== undefined && rating.score !== "") {
const s = document.createElement("span");
s.className = "eval-score";
s.textContent = "评分:" + String(rating.score).trim();
row.appendChild(s);
}
card.appendChild(row);
if (rating.reason) {
const pr = document.createElement("p");
pr.className = "eval-reason";
pr.textContent = String(rating.reason).trim();
card.appendChild(pr);
}
interviewReportRoot.appendChild(card);
}
if (ir.abilitySummary) {
const card = document.createElement("div");
card.className = "eval-card";
const h = document.createElement("h3");
h.className = "eval-card__title";
h.textContent = "能力总结";
card.appendChild(h);
const p = document.createElement("p");
p.className = "eval-body-text";
p.textContent = String(ir.abilitySummary).trim();
card.appendChild(p);
interviewReportRoot.appendChild(card);
}
const tech = Array.isArray(ir.techStack)
? ir.techStack
: typeof ir.techStack === "string"
? ir.techStack.split(/[,、;;]/).map((x) => x.trim()).filter(Boolean)
: [];
if (tech.length) {
const card = document.createElement("div");
card.className = "eval-card";
const h = document.createElement("h3");
h.className = "eval-card__title";
h.textContent = "技术栈";
card.appendChild(h);
const wrap = document.createElement("div");
wrap.className = "eval-chips";
tech.forEach((t) => {
const span = document.createElement("span");
span.className = "eval-chip";
span.textContent = String(t);
wrap.appendChild(span);
});
card.appendChild(wrap);
interviewReportRoot.appendChild(card);
}
const strengths = Array.isArray(ir.strengths) ? ir.strengths : [];
const weaknesses = Array.isArray(ir.weaknesses) ? ir.weaknesses : [];
if (strengths.length || weaknesses.length) {
const split = document.createElement("div");
split.className = "eval-split eval-split--2";
if (strengths.length) {
const card = document.createElement("div");
card.className = "eval-card";
const h = document.createElement("h3");
h.className = "eval-card__title";
h.textContent = "优势";
card.appendChild(h);
const ul = document.createElement("ul");
ul.className = "eval-list";
strengths.forEach((x) => {
const li = document.createElement("li");
li.textContent = String(x);
ul.appendChild(li);
});
card.appendChild(ul);
split.appendChild(card);
}
if (weaknesses.length) {
const card = document.createElement("div");
card.className = "eval-card";
const h = document.createElement("h3");
h.className = "eval-card__title";
h.textContent = "待提升";
card.appendChild(h);
const ul = document.createElement("ul");
ul.className = "eval-list";
weaknesses.forEach((x) => {
const li = document.createElement("li");
li.textContent = String(x);
ul.appendChild(li);
});
card.appendChild(ul);
split.appendChild(card);
}
interviewReportRoot.appendChild(split);
}
const questions = Array.isArray(ir.interviewQuestions) ? ir.interviewQuestions : [];
if (questions.length) {
const h2 = document.createElement("h4");
h2.className = "eval-questions-title";
h2.textContent = "面试题与参考要点";
interviewReportRoot.appendChild(h2);
questions.forEach((item, idx) => {
const q = item && typeof item === "object" ? item.question : "";
const a = item && typeof item === "object" ? item.standardAnswer : "";
const card = document.createElement("article");
card.className = "q-card";
const head = document.createElement("div");
head.className = "q-card__head";
const num = document.createElement("span");
num.className = "q-card__num";
num.textContent = String(idx + 1);
const qq = document.createElement("span");
qq.className = "q-card__q";
qq.textContent = q ? String(q).trim() : "(题目)";
head.appendChild(num);
head.appendChild(qq);
card.appendChild(head);
const body = document.createElement("div");
body.className = "q-card__body";
const lbl = document.createElement("p");
lbl.className = "q-card__label";
lbl.textContent = "参考要点 / 标准答案";
const ans = document.createElement("p");
ans.className = "q-card__answer";
ans.textContent = a ? String(a).trim() : "—";
body.appendChild(lbl);
body.appendChild(ans);
card.appendChild(body);
interviewReportRoot.appendChild(card);
});
}
if (!interviewReportRoot.children.length) {
clearInterviewReport("评估内容为空或格式异常。");
return;
}
const det = document.createElement("details");
det.className = "eval-json";
const sum = document.createElement("summary");
sum.textContent = "结构化数据(供核对)";
det.appendChild(sum);
const pre = document.createElement("pre");
pre.textContent = JSON.stringify(ir, null, 2);
det.appendChild(pre);
interviewReportRoot.appendChild(det);
}
function setDupBanner(isDuplicate, duplicateOfId, hasReadableReport) {
dupBanner.style.display = "block";
if (isDuplicate) {
dupBanner.className = "dup-banner is-warn";
const idStr = duplicateOfId ?? "未知";
if (hasReadableReport) {
dupBanner.textContent =
"检测结果:与已有记录重复(已有可展示的评价)。记录 ID" +
idStr +
"。可直接查看上方内容,也可再点「查看评价」从服务端同步。";
} else {
dupBanner.textContent =
"检测结果:与已有记录重复。记录 ID" +
idStr +
"。若随后写入完整评价,将自动尝试同步;也可手动点「查看评价」。";
}
} else {
dupBanner.className = "dup-banner is-ok";
dupBanner.textContent =
"检测结果:未命中重复。系统正在后台生成评价,就绪后将点亮「查看评价」。";
}
}
fileInput.addEventListener("change", () => {
stopPolling();
lastStep1 = null;
consultHint.textContent = "";
dupBanner.style.display = "none";
setEvalButtonState();
clearInterviewReport("请先完成「上传并查重」,再获取简历评价。");
});
evalViewBtn.addEventListener("click", async () => {
if (!lastStep1 || !lastStep1.fileSha256 || !lastStep1.consultAllowed) {
return;
}
stopPolling();
consultLoading = true;
setEvalButtonState();
consultHint.textContent = "正在加载评价内容…";
try {
const data = await fetchStoredEvaluation();
if (!hasInterviewContent(data.interviewReport)) {
clearInterviewReport("暂未返回有效的评价内容,请稍后重试。");
consultHint.textContent = "未拿到有效内容,请稍后重试。";
return;
}
renderInterviewReport(data.interviewReport);
let note = data.parserNote || "已完成";
if (data.db?.saved) {
note += " | 已保存,记录编号 " + data.db.id;
} else if (data.fromCache) {
note += " | 从服务端缓存读取";
}
parserNoteEl.textContent = note;
consultHint.textContent = data.fromCache
? "内容已从服务端读取。"
: "已加载评价。";
} catch (err) {
consultHint.textContent = "读取失败:" + err.message;
} finally {
consultLoading = false;
setEvalButtonState();
}
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
const file = fileInput.files[0];
if (!file) {
statusEl.textContent = "请先选择一个 .docx 或 .pdf 文件";
statusEl.className = "status error";
return;
}
const formData = new FormData();
formData.append("file", file);
stopPolling();
lastStep1 = null;
consultHint.textContent = "";
dupBanner.style.display = "none";
evalViewBtn.disabled = true;
submitBtn.disabled = true;
statusEl.textContent = "正在上传并查重…";
statusEl.className = "status";
parserNoteEl.textContent = "第一步:抽取文本并比对库中文件…";
rawTextEl.textContent = "";
clearInterviewReport("正在处理,完成后将显示评价与面试要点…");
try {
const response = await fetch("/api/resume/step1", {
method: "POST",
body: formData,
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "请求失败");
}
lastStep1 = {
fileSha256: data.fileSha256,
uploadId: data.uploadId,
isDuplicate: data.isDuplicate,
consultAllowed: false,
};
statusEl.textContent = "第一步完成";
statusEl.className = "status";
const irFromStep = data.interviewReport;
const hasReport = hasInterviewContent(irFromStep);
setDupBanner(data.isDuplicate, data.duplicateOfId, hasReport);
let note = data.hint || "";
if (data.pdfExtraction) {
const pe = data.pdfExtraction;
note += (note ? " | " : "") + `PDF 文本层约 ${pe.textLayerChars}`;
if (pe.ocrSkippedReason) {
note += " | " + pe.ocrSkippedReason;
}
}
parserNoteEl.textContent = note || "—";
rawTextEl.textContent = data.text || "";
if (data.isDuplicate && hasReport) {
lastStep1.consultAllowed = true;
renderInterviewReport(irFromStep);
consultHint.textContent =
"重复简历:已展示已保存的评价。也可再点「查看评价」从服务端重新加载。";
setEvalButtonState();
} else {
pollUntilEvaluationReady();
}
} catch (error) {
statusEl.textContent = "失败:" + error.message;
statusEl.className = "status error";
parserNoteEl.textContent = "第一步未完成";
stopPolling();
lastStep1 = null;
evalViewBtn.disabled = true;
} finally {
submitBtn.disabled = false;
}
});
</script>
</body>
</html>