1259 lines
38 KiB
HTML
1259 lines
38 KiB
HTML
<!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>
|