1566 lines
67 KiB
HTML
1566 lines
67 KiB
HTML
<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>培训结业证书管理与查询系统</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
:root {
|
|
--bg: #f0f7f7;
|
|
--surface: #ffffff;
|
|
--surface-soft: #f7fbfb;
|
|
--border: #d4e4e4;
|
|
--border-strong: #b8d0d0;
|
|
--text: #1a2e2e;
|
|
--muted: #5a7a7a;
|
|
--primary: #16817e;
|
|
--primary-strong: #0f6f6b;
|
|
--primary-soft: #e6f5f3;
|
|
--primary-light: #f0faf9;
|
|
--danger: #c9413a;
|
|
--warning: #b7791f;
|
|
--shadow: 0 20px 60px rgba(22, 129, 126, .1);
|
|
--shadow-soft: 0 8px 30px rgba(22, 129, 126, .06);
|
|
--radius: 12px;
|
|
--radius-lg: 20px;
|
|
}
|
|
body {
|
|
font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
color: var(--text);
|
|
background: var(--bg);
|
|
min-height: 100vh;
|
|
line-height: 1.6;
|
|
}
|
|
button, input, select { font: inherit; }
|
|
button {
|
|
height: 44px;
|
|
border: 0;
|
|
border-radius: var(--radius);
|
|
padding: 0 24px;
|
|
background: var(--primary);
|
|
color: #fff;
|
|
cursor: pointer;
|
|
font-weight: 600;
|
|
font-size: 15px;
|
|
transition: all 0.2s ease;
|
|
}
|
|
button:hover {
|
|
background: var(--primary-strong);
|
|
transform: translateY(-1px);
|
|
box-shadow: 0 4px 12px rgba(22, 129, 126, .3);
|
|
}
|
|
button:active { transform: translateY(0); }
|
|
button.secondary {
|
|
color: var(--primary);
|
|
background: var(--primary-soft);
|
|
border: 1px solid transparent;
|
|
}
|
|
button.secondary:hover {
|
|
background: #d5eeeb;
|
|
box-shadow: none;
|
|
}
|
|
button.danger { background: var(--danger); }
|
|
button.ghost { background: transparent; color: var(--muted); }
|
|
input, select {
|
|
height: 48px;
|
|
min-width: 150px;
|
|
border: 2px solid var(--border);
|
|
border-radius: 14px;
|
|
padding: 0 18px;
|
|
background: #fff;
|
|
outline: none;
|
|
font-size: 15px;
|
|
transition: all 0.3s ease;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,.02);
|
|
}
|
|
input:focus, select:focus {
|
|
border-color: var(--primary);
|
|
box-shadow:
|
|
0 0 0 4px rgba(22,129,126,.12),
|
|
0 2px 8px rgba(22,129,126,.1);
|
|
transform: translateY(-1px);
|
|
}
|
|
input::placeholder { color: #9cb8b8; font-weight: 400; }
|
|
h1, h2, h3 { margin: 0; }
|
|
.hidden { display: none !important; }
|
|
|
|
/* 查询页面 */
|
|
.query-page {
|
|
min-height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 40px 24px;
|
|
background: linear-gradient(160deg, #e8f4f4 0%, #f0f8f8 30%, #f8fcfc 60%, #e8f2f2 100%);
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
.query-page::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: -50%;
|
|
left: -50%;
|
|
width: 200%;
|
|
height: 200%;
|
|
background:
|
|
radial-gradient(circle at 20% 30%, rgba(22, 129, 126, .06) 0%, transparent 40%),
|
|
radial-gradient(circle at 80% 70%, rgba(22, 129, 126, .04) 0%, transparent 35%),
|
|
radial-gradient(circle at 50% 50%, rgba(22, 129, 126, .02) 0%, transparent 50%);
|
|
animation: float 25s ease-in-out infinite;
|
|
}
|
|
.query-page::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background:
|
|
linear-gradient(90deg, transparent 49.5%, rgba(22, 129, 126, .03) 49.5%, rgba(22, 129, 126, .03) 50.5%, transparent 50.5%),
|
|
linear-gradient(0deg, transparent 49.5%, rgba(22, 129, 126, .03) 49.5%, rgba(22, 129, 126, .03) 50.5%, transparent 50.5%);
|
|
background-size: 60px 60px;
|
|
opacity: 0.5;
|
|
pointer-events: none;
|
|
}
|
|
@keyframes float {
|
|
0%, 100% { transform: translate(0, 0) rotate(0deg); }
|
|
33% { transform: translate(-1%, 1%) rotate(0.5deg); }
|
|
66% { transform: translate(1%, -1%) rotate(-0.5deg); }
|
|
}
|
|
|
|
.query-header {
|
|
text-align: center;
|
|
margin-bottom: 36px;
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
.query-header .icon {
|
|
width: 72px;
|
|
height: 72px;
|
|
background: linear-gradient(135deg, var(--primary) 0%, #0d5f5c 100%);
|
|
border-radius: 20px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
margin: 0 auto 24px;
|
|
box-shadow:
|
|
0 12px 32px rgba(22, 129, 126, .35),
|
|
0 4px 12px rgba(22, 129, 126, .2),
|
|
inset 0 1px 0 rgba(255,255,255,.2);
|
|
position: relative;
|
|
}
|
|
.query-header .icon::before {
|
|
content: '';
|
|
position: absolute;
|
|
inset: -3px;
|
|
border-radius: 23px;
|
|
background: linear-gradient(135deg, rgba(255,255,255,.3), rgba(255,255,255,0));
|
|
z-index: -1;
|
|
}
|
|
.query-header .icon svg {
|
|
width: 36px;
|
|
height: 36px;
|
|
fill: white;
|
|
filter: drop-shadow(0 2px 4px rgba(0,0,0,.2));
|
|
}
|
|
.query-header h1 {
|
|
font-size: 32px;
|
|
font-weight: 800;
|
|
color: var(--text);
|
|
margin-bottom: 10px;
|
|
letter-spacing: -0.5px;
|
|
text-shadow: 0 1px 2px rgba(0,0,0,.05);
|
|
}
|
|
.query-header p {
|
|
color: var(--muted);
|
|
font-size: 16px;
|
|
font-weight: 400;
|
|
}
|
|
|
|
.query-card {
|
|
width: min(520px, 100%);
|
|
background: rgba(255,255,255,.95);
|
|
border: 2px solid var(--border);
|
|
border-top: 4px solid var(--primary);
|
|
border-radius: 16px;
|
|
padding: 32px 36px;
|
|
box-shadow:
|
|
0 24px 64px rgba(22, 129, 126, .12),
|
|
0 8px 24px rgba(22, 129, 126, .08),
|
|
0 2px 8px rgba(22, 129, 126, .04);
|
|
position: relative;
|
|
z-index: 1;
|
|
backdrop-filter: blur(10px);
|
|
}
|
|
|
|
.form-section {
|
|
margin-bottom: 28px;
|
|
}
|
|
.form-section-title {
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
color: var(--primary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 1.5px;
|
|
margin-bottom: 14px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.form-section-title::before {
|
|
content: '';
|
|
width: 4px;
|
|
height: 16px;
|
|
background: var(--primary);
|
|
border-radius: 2px;
|
|
}
|
|
|
|
.choice {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 6px;
|
|
padding: 6px;
|
|
border: 2px solid var(--border);
|
|
border-radius: 16px;
|
|
background: var(--surface-soft);
|
|
}
|
|
.choice button {
|
|
background: transparent;
|
|
color: var(--muted);
|
|
height: 44px;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
border-radius: 12px;
|
|
transition: all 0.3s ease;
|
|
}
|
|
.choice button.active {
|
|
background: var(--primary);
|
|
color: #fff;
|
|
box-shadow:
|
|
0 4px 16px rgba(22, 129, 126, .35),
|
|
0 2px 4px rgba(22, 129, 126, .2);
|
|
transform: translateY(-1px);
|
|
}
|
|
.choice button:not(.active):hover {
|
|
background: rgba(22, 129, 126, .08);
|
|
color: var(--primary);
|
|
}
|
|
|
|
.field-group {
|
|
display: grid;
|
|
gap: 20px;
|
|
animation: fadeIn .4s ease;
|
|
}
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; transform: translateY(12px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
|
|
.captcha-row {
|
|
display: flex;
|
|
gap: 12px;
|
|
align-items: center;
|
|
}
|
|
.captcha-row input {
|
|
flex: 1;
|
|
height: 48px;
|
|
border-radius: 14px;
|
|
font-size: 16px;
|
|
letter-spacing: 2px;
|
|
}
|
|
.captcha-row img {
|
|
height: 48px;
|
|
border: 2px solid var(--border);
|
|
border-radius: 14px;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,.05);
|
|
}
|
|
.captcha-row img:hover {
|
|
border-color: var(--primary);
|
|
box-shadow: 0 4px 12px rgba(22, 129, 126, .2);
|
|
transform: scale(1.02);
|
|
}
|
|
|
|
.sms-row {
|
|
display: flex;
|
|
gap: 12px;
|
|
align-items: center;
|
|
}
|
|
.sms-row input {
|
|
flex: 1;
|
|
min-width: 0;
|
|
height: 48px;
|
|
border-radius: 14px;
|
|
font-size: 16px;
|
|
letter-spacing: 4px;
|
|
text-align: center;
|
|
}
|
|
.sms-row button {
|
|
flex-shrink: 0;
|
|
white-space: nowrap;
|
|
padding: 0 20px;
|
|
height: 48px;
|
|
border-radius: 14px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
#smsFields .form-section {
|
|
margin-bottom: 0;
|
|
}
|
|
#smsFields .form-section > input {
|
|
width: 100%;
|
|
display: block;
|
|
}
|
|
|
|
.submit-btn {
|
|
width: 100%;
|
|
height: 52px;
|
|
font-size: 17px;
|
|
font-weight: 700;
|
|
background: linear-gradient(135deg, var(--primary) 0%, #0d5f5c 100%);
|
|
border: none;
|
|
border-radius: 16px;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
position: relative;
|
|
overflow: hidden;
|
|
letter-spacing: 1px;
|
|
box-shadow:
|
|
0 8px 24px rgba(22, 129, 126, .3),
|
|
0 4px 8px rgba(22, 129, 126, .2);
|
|
}
|
|
.submit-btn::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0;
|
|
left: -100%;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: linear-gradient(90deg, transparent, rgba(255,255,255,.25), transparent);
|
|
transition: left 0.6s ease;
|
|
}
|
|
.submit-btn:hover::before {
|
|
left: 100%;
|
|
}
|
|
.submit-btn:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow:
|
|
0 12px 32px rgba(22, 129, 126, .4),
|
|
0 6px 12px rgba(22, 129, 126, .25);
|
|
}
|
|
.submit-btn:active {
|
|
transform: translateY(0);
|
|
}
|
|
|
|
.disclaimer {
|
|
margin-top: 24px;
|
|
padding-top: 20px;
|
|
border-top: 1px solid var(--border);
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
line-height: 1.8;
|
|
text-align: center;
|
|
opacity: 0.8;
|
|
}
|
|
|
|
/* 结果区域 */
|
|
.result-card {
|
|
margin-top: 28px;
|
|
width: min(520px, 100%);
|
|
background: rgba(255,255,255,.95);
|
|
border: 2px solid var(--border);
|
|
border-top: 4px solid var(--primary);
|
|
border-radius: 16px;
|
|
padding: 32px 36px;
|
|
box-shadow:
|
|
0 24px 64px rgba(22, 129, 126, .12),
|
|
0 8px 24px rgba(22, 129, 126, .08);
|
|
position: relative;
|
|
z-index: 1;
|
|
animation: slideUp .5s ease;
|
|
backdrop-filter: blur(10px);
|
|
}
|
|
@keyframes slideUp {
|
|
from { opacity: 0; transform: translateY(24px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
|
|
.result-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 14px;
|
|
margin-bottom: 24px;
|
|
padding-bottom: 18px;
|
|
border-bottom: 2px solid var(--border);
|
|
}
|
|
.result-icon {
|
|
width: 44px;
|
|
height: 44px;
|
|
border-radius: 14px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,.1);
|
|
}
|
|
.result-icon.success {
|
|
background: linear-gradient(135deg, #ecfdf5, #d1fae5);
|
|
color: #059669;
|
|
}
|
|
.result-icon.warning {
|
|
background: linear-gradient(135deg, #fef3c7, #fde68a);
|
|
color: #d97706;
|
|
}
|
|
.result-header h3 {
|
|
font-size: 20px;
|
|
font-weight: 700;
|
|
color: var(--text);
|
|
}
|
|
|
|
.cert-item {
|
|
padding: 20px;
|
|
border: 2px solid var(--border);
|
|
border-radius: 16px;
|
|
margin-bottom: 16px;
|
|
background: linear-gradient(135deg, var(--surface-soft), #fff);
|
|
transition: all 0.3s ease;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
.cert-item::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 4px;
|
|
height: 100%;
|
|
background: var(--primary);
|
|
border-radius: 2px 0 0 2px;
|
|
opacity: 0;
|
|
transition: opacity 0.3s ease;
|
|
}
|
|
.cert-item:hover {
|
|
border-color: var(--primary);
|
|
box-shadow:
|
|
0 8px 24px rgba(22, 129, 126, .15),
|
|
0 4px 8px rgba(22, 129, 126, .1);
|
|
transform: translateY(-2px);
|
|
}
|
|
.cert-item:hover::before {
|
|
opacity: 1;
|
|
}
|
|
.cert-item:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
.cert-name {
|
|
font-weight: 700;
|
|
font-size: 18px;
|
|
color: var(--text);
|
|
margin-bottom: 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
.cert-meta {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 12px;
|
|
font-size: 14px;
|
|
}
|
|
.cert-meta span {
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
}
|
|
.cert-meta strong {
|
|
color: var(--text);
|
|
font-weight: 600;
|
|
display: block;
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.status-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
height: 28px;
|
|
padding: 0 12px;
|
|
border-radius: 999px;
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,.1);
|
|
}
|
|
.status-badge.valid {
|
|
background: linear-gradient(135deg, #ecfdf5, #d1fae5);
|
|
color: #059669;
|
|
}
|
|
.status-badge.void {
|
|
background: linear-gradient(135deg, #fef2f2, #fecaca);
|
|
color: #dc2626;
|
|
}
|
|
|
|
.cert-actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-top: 16px;
|
|
padding-top: 16px;
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
.cert-actions button {
|
|
height: 40px;
|
|
padding: 0 20px;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
border-radius: 12px;
|
|
transition: all 0.3s ease;
|
|
}
|
|
.cert-actions button:hover {
|
|
transform: translateY(-1px);
|
|
box-shadow: 0 4px 12px rgba(22, 129, 126, .25);
|
|
}
|
|
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 40px 20px;
|
|
color: var(--muted);
|
|
}
|
|
.empty-state svg {
|
|
width: 48px;
|
|
height: 48px;
|
|
margin-bottom: 16px;
|
|
opacity: .5;
|
|
}
|
|
|
|
/* 消息提示 */
|
|
.message {
|
|
padding: 12px 16px;
|
|
border-radius: var(--radius);
|
|
font-size: 14px;
|
|
margin-bottom: 16px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.message.success {
|
|
background: #ecfdf5;
|
|
color: #059669;
|
|
border: 1px solid #a7f3d0;
|
|
}
|
|
.message.warning {
|
|
background: #fef3c7;
|
|
color: #92400e;
|
|
border: 1px solid #fde68a;
|
|
}
|
|
.message.error {
|
|
background: #fef2f2;
|
|
color: #dc2626;
|
|
border: 1px solid #fecaca;
|
|
}
|
|
|
|
/* 加载状态 */
|
|
.loading {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.loading::after {
|
|
content: '';
|
|
width: 16px;
|
|
height: 16px;
|
|
border: 2px solid transparent;
|
|
border-top-color: currentColor;
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
/* 后台页面 */
|
|
.login-page {
|
|
min-height: 100vh;
|
|
display: grid;
|
|
place-items: center;
|
|
padding: 24px;
|
|
background: var(--bg);
|
|
}
|
|
.login-card {
|
|
width: min(400px, 100%);
|
|
background: #fff;
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-lg);
|
|
padding: 40px;
|
|
box-shadow: var(--shadow);
|
|
}
|
|
.login-card h1 {
|
|
font-size: 24px;
|
|
margin-bottom: 24px;
|
|
text-align: center;
|
|
}
|
|
.login-card .stack { display: grid; gap: 16px; }
|
|
.login-card .hint {
|
|
text-align: center;
|
|
font-size: 13px;
|
|
color: var(--muted);
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.app {
|
|
min-height: 100vh;
|
|
display: grid;
|
|
grid-template-columns: 240px 1fr;
|
|
background: var(--bg);
|
|
}
|
|
.side {
|
|
background: var(--surface);
|
|
color: var(--text);
|
|
padding: 24px 16px;
|
|
border-right: 1px solid var(--border);
|
|
box-shadow: 4px 0 20px rgba(22,129,126,.03);
|
|
}
|
|
.brand {
|
|
padding: 0 12px 24px;
|
|
border-bottom: 1px solid var(--border);
|
|
margin-bottom: 20px;
|
|
}
|
|
.brand h1 {
|
|
font-size: 18px;
|
|
font-weight: 700;
|
|
color: var(--primary);
|
|
}
|
|
.brand p {
|
|
margin: 6px 0 0;
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
}
|
|
.nav { display: grid; gap: 4px; }
|
|
.nav button {
|
|
width: 100%;
|
|
justify-content: flex-start;
|
|
text-align: left;
|
|
background: transparent;
|
|
color: var(--muted);
|
|
border-radius: 8px;
|
|
height: 40px;
|
|
font-weight: 500;
|
|
}
|
|
.nav button.active, .nav button:hover {
|
|
background: var(--primary-soft);
|
|
color: var(--primary-strong);
|
|
}
|
|
.main {
|
|
padding: 32px;
|
|
overflow: auto;
|
|
}
|
|
.topbar {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 24px;
|
|
}
|
|
.topbar h2 {
|
|
font-size: 22px;
|
|
font-weight: 700;
|
|
}
|
|
.panel {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-lg);
|
|
padding: 24px;
|
|
margin-bottom: 20px;
|
|
box-shadow: var(--shadow-soft);
|
|
}
|
|
.panel-title {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 16px;
|
|
}
|
|
.form-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
gap: 16px;
|
|
}
|
|
.form-grid label {
|
|
display: grid;
|
|
gap: 6px;
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
}
|
|
.form-grid input, .form-grid select {
|
|
width: 100%;
|
|
min-width: 0;
|
|
}
|
|
.toolbar {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 12px;
|
|
margin-bottom: 16px;
|
|
}
|
|
.toolbar input {
|
|
flex: 1;
|
|
min-width: 240px;
|
|
max-width: 460px;
|
|
}
|
|
.toolbar select {
|
|
min-width: 140px;
|
|
}
|
|
.table-wrap {
|
|
width: 100%;
|
|
overflow-x: auto;
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
background: var(--surface);
|
|
}
|
|
table {
|
|
width: 100%;
|
|
min-width: 680px;
|
|
border-collapse: collapse;
|
|
}
|
|
th,
|
|
td {
|
|
padding: 12px 14px;
|
|
border-bottom: 1px solid var(--border);
|
|
text-align: left;
|
|
vertical-align: middle;
|
|
white-space: nowrap;
|
|
}
|
|
th {
|
|
background: var(--surface-soft);
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
}
|
|
td {
|
|
color: var(--text);
|
|
font-size: 14px;
|
|
}
|
|
tbody tr:last-child td {
|
|
border-bottom: 0;
|
|
}
|
|
tbody tr:hover {
|
|
background: var(--primary-light);
|
|
}
|
|
td:last-child,
|
|
th:last-child {
|
|
min-width: 168px;
|
|
}
|
|
.actions {
|
|
flex-wrap: nowrap;
|
|
}
|
|
.actions button {
|
|
flex-shrink: 0;
|
|
height: 36px;
|
|
padding: 0 14px;
|
|
border-radius: 10px;
|
|
font-size: 14px;
|
|
}
|
|
.pager {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
gap: 6px;
|
|
margin: 16px 0;
|
|
flex-wrap: wrap;
|
|
}
|
|
.pager button {
|
|
min-width: 36px;
|
|
padding: 0 12px;
|
|
background: var(--surface);
|
|
color: var(--text);
|
|
border: 1px solid var(--border);
|
|
height: 36px;
|
|
}
|
|
.pager button.active {
|
|
background: var(--primary);
|
|
color: #fff;
|
|
border-color: var(--primary);
|
|
}
|
|
.pager button:disabled {
|
|
opacity: .45;
|
|
cursor: not-allowed;
|
|
}
|
|
.pager span {
|
|
color: var(--muted);
|
|
padding: 0 4px;
|
|
}
|
|
.stats {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 16px;
|
|
}
|
|
.stat {
|
|
padding: 20px;
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-lg);
|
|
background: linear-gradient(180deg, var(--surface), var(--surface-soft));
|
|
box-shadow: var(--shadow-soft);
|
|
}
|
|
.stat span {
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
}
|
|
.stat strong {
|
|
display: block;
|
|
margin-top: 8px;
|
|
font-size: 28px;
|
|
color: var(--primary);
|
|
}
|
|
.pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
height: 24px;
|
|
border-radius: 999px;
|
|
padding: 0 10px;
|
|
font-size: 12px;
|
|
background: var(--primary-soft);
|
|
color: var(--primary-strong);
|
|
font-weight: 600;
|
|
}
|
|
.pill.bad {
|
|
background: #fef2f2;
|
|
color: #dc2626;
|
|
}
|
|
.pill.warn {
|
|
background: #fef3c7;
|
|
color: #d97706;
|
|
}
|
|
.actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: nowrap;
|
|
}
|
|
.dialog {
|
|
position: fixed;
|
|
inset: 0;
|
|
display: grid;
|
|
place-items: center;
|
|
background: rgba(15,23,42,.5);
|
|
padding: 24px;
|
|
z-index: 20;
|
|
backdrop-filter: blur(4px);
|
|
}
|
|
.dialog-card {
|
|
width: min(720px, 100%);
|
|
background: var(--surface);
|
|
border-radius: var(--radius-lg);
|
|
padding: 28px;
|
|
box-shadow: 0 25px 80px rgba(22,129,126,.25);
|
|
animation: dialogIn .3s ease;
|
|
}
|
|
@keyframes dialogIn {
|
|
from { opacity: 0; transform: scale(.95) translateY(10px); }
|
|
to { opacity: 1; transform: scale(1) translateY(0); }
|
|
}
|
|
.dialog-card pre {
|
|
white-space: pre-wrap;
|
|
background: var(--surface-soft);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
padding: 16px;
|
|
font-size: 13px;
|
|
}
|
|
.result-grid { display: grid; gap: 16px; }
|
|
.cert-card {
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-lg);
|
|
padding: 24px;
|
|
background: linear-gradient(180deg,var(--surface),var(--surface-soft));
|
|
box-shadow: var(--shadow-soft);
|
|
}
|
|
.cert-card h3 {
|
|
margin-bottom: 12px;
|
|
font-size: 16px;
|
|
}
|
|
.cert-card .meta {
|
|
display: grid;
|
|
grid-template-columns: repeat(2,minmax(0,1fr));
|
|
gap: 12px 20px;
|
|
margin: 16px 0;
|
|
}
|
|
.cert-card .meta span {
|
|
display: block;
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
margin-bottom: 4px;
|
|
}
|
|
@media (max-width: 900px) {
|
|
.app { grid-template-columns: 1fr; }
|
|
.side { position: static; }
|
|
.form-grid, .stats { grid-template-columns: 1fr; }
|
|
.cert-meta { grid-template-columns: 1fr; }
|
|
.main {
|
|
padding: 20px;
|
|
}
|
|
.toolbar {
|
|
align-items: stretch;
|
|
flex-direction: column;
|
|
}
|
|
.toolbar input,
|
|
.toolbar select,
|
|
.toolbar button {
|
|
width: 100%;
|
|
max-width: none;
|
|
}
|
|
}
|
|
|
|
/* 图形验证码弹窗 */
|
|
.captcha-dialog {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0,0,0,.5);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 1000;
|
|
padding: 20px;
|
|
backdrop-filter: blur(4px);
|
|
animation: fadeIn .2s ease;
|
|
}
|
|
.captcha-dialog-content {
|
|
background: var(--surface);
|
|
border-radius: var(--radius-lg);
|
|
width: min(380px, 100%);
|
|
box-shadow: 0 25px 80px rgba(0,0,0,.3);
|
|
animation: slideUp .3s ease;
|
|
}
|
|
.captcha-dialog-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 20px 24px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.captcha-dialog-header h3 {
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
}
|
|
.close-btn {
|
|
width: 32px;
|
|
height: 32px;
|
|
border: none;
|
|
background: transparent;
|
|
font-size: 24px;
|
|
color: var(--muted);
|
|
cursor: pointer;
|
|
border-radius: 8px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.close-btn:hover {
|
|
background: var(--primary-soft);
|
|
color: var(--primary);
|
|
}
|
|
.captcha-dialog-desc {
|
|
padding: 16px 24px 0;
|
|
color: var(--muted);
|
|
font-size: 14px;
|
|
}
|
|
.captcha-dialog-body {
|
|
padding: 20px 24px;
|
|
}
|
|
.captcha-display {
|
|
display: flex;
|
|
gap: 12px;
|
|
align-items: center;
|
|
margin-bottom: 16px;
|
|
}
|
|
.captcha-display img {
|
|
height: 48px;
|
|
border: 1.5px solid var(--border);
|
|
border-radius: var(--radius);
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
flex: 1;
|
|
}
|
|
.captcha-display img:hover {
|
|
border-color: var(--primary);
|
|
}
|
|
.refresh-btn {
|
|
height: 48px;
|
|
padding: 0 16px;
|
|
background: var(--primary-soft);
|
|
color: var(--primary);
|
|
border: none;
|
|
border-radius: var(--radius);
|
|
cursor: pointer;
|
|
font-weight: 500;
|
|
white-space: nowrap;
|
|
}
|
|
.refresh-btn:hover {
|
|
background: #d5eeeb;
|
|
}
|
|
.captcha-dialog-body input {
|
|
width: 100%;
|
|
height: 48px;
|
|
border: 1.5px solid var(--border);
|
|
border-radius: var(--radius);
|
|
padding: 0 16px;
|
|
font-size: 16px;
|
|
text-align: center;
|
|
letter-spacing: 8px;
|
|
}
|
|
.captcha-dialog-body input:focus {
|
|
border-color: var(--primary);
|
|
box-shadow: 0 0 0 4px rgba(22,129,126,.1);
|
|
outline: none;
|
|
}
|
|
.captcha-dialog-footer {
|
|
display: flex;
|
|
gap: 12px;
|
|
padding: 0 24px 24px;
|
|
}
|
|
.cancel-btn {
|
|
flex: 1;
|
|
height: 44px;
|
|
background: var(--surface-soft);
|
|
color: var(--text);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
cursor: pointer;
|
|
font-weight: 500;
|
|
}
|
|
.cancel-btn:hover {
|
|
background: #e8f2f2;
|
|
}
|
|
.confirm-btn {
|
|
flex: 1;
|
|
height: 44px;
|
|
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-strong) 100%);
|
|
color: white;
|
|
border: none;
|
|
border-radius: var(--radius);
|
|
cursor: pointer;
|
|
font-weight: 600;
|
|
}
|
|
.confirm-btn:hover {
|
|
transform: translateY(-1px);
|
|
box-shadow: 0 4px 12px rgba(22,129,126,.3);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="public" class="query-page hidden">
|
|
<div class="query-header">
|
|
<div class="icon">
|
|
<svg viewBox="0 0 24 24"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
|
|
</div>
|
|
<h1>培训结业证书查询</h1>
|
|
<p>输入信息查询您的培训证书</p>
|
|
</div>
|
|
|
|
<section class="query-card">
|
|
<div class="form-section">
|
|
<div class="form-section-title">选择查询方式</div>
|
|
<div class="choice">
|
|
<button id="modeCert" class="active" onclick="setQueryMode('certificate')">证书编号查询</button>
|
|
<button id="modeSms" onclick="setQueryMode('sms')">短信验证码查询</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="certFields" class="field-group">
|
|
<div class="form-section">
|
|
<div class="form-section-title">证书信息</div>
|
|
<div style="display:grid;gap:12px">
|
|
<input id="qNo" placeholder="请输入证书编号,如:PX2026-DBY-K7M9Q2R-83" maxlength="64" />
|
|
<input id="qName" placeholder="请输入证书对应的姓名" maxlength="64" />
|
|
</div>
|
|
</div>
|
|
<div class="form-section">
|
|
<div class="form-section-title">审核验证</div>
|
|
<div class="captcha-row">
|
|
<input id="qCode" placeholder="请输入4位图形验证码" maxlength="4" pattern="[A-Za-z0-9]{4}" />
|
|
<img id="captchaImg" onclick="loadCaptcha()" title="点击刷新验证码" />
|
|
<button class="secondary" onclick="loadCaptcha()">刷新</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="smsFields" class="field-group hidden">
|
|
<div class="form-section">
|
|
<div class="form-section-title">手机号</div>
|
|
<input id="qPhone" placeholder="请输入11位手机号" maxlength="11" pattern="[0-9]{11}" inputmode="numeric" />
|
|
<div class="form-section-title" style="margin-top:14px">短信验证码</div>
|
|
<div class="sms-row">
|
|
<input id="qSmsCode" placeholder="请输入6位短信验证码" maxlength="6" pattern="[0-9]{6}" inputmode="numeric" />
|
|
<button id="btnSendSms" class="secondary" onclick="handleShowCaptchaDialog()">获取验证码</button>
|
|
</div>
|
|
</div>
|
|
<div style="margin-top:8px">
|
|
<button class="submit-btn" onclick="searchCert()">查询证书</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="disclaimer">
|
|
本证书仅用于证明学员完成相关培训课程/阶段学习,不代表国家学历、学位、职业资格或职业技能等级认证。
|
|
</div>
|
|
</section>
|
|
|
|
<section id="publicResult" class="result-card hidden"></section>
|
|
</div>
|
|
|
|
<div id="captchaDialog" class="captcha-dialog hidden">
|
|
<div class="captcha-dialog-content">
|
|
<div class="captcha-dialog-header">
|
|
<h3>验证身份</h3>
|
|
<button class="close-btn" onclick="closeCaptchaDialog()">×</button>
|
|
</div>
|
|
<p class="captcha-dialog-desc">请输入图形验证码以获取短信验证码</p>
|
|
<div class="captcha-dialog-body">
|
|
<div class="captcha-display">
|
|
<img id="dialogCaptchaImg" onclick="loadDialogCaptcha()" title="点击刷新验证码" />
|
|
<button class="refresh-btn" onclick="loadDialogCaptcha()">刷新</button>
|
|
</div>
|
|
<input id="dialogCaptchaInput" placeholder="请输入4位验证码" maxlength="4" pattern="[A-Za-z0-9]{4}" />
|
|
</div>
|
|
<div class="captcha-dialog-footer">
|
|
<button class="cancel-btn" onclick="closeCaptchaDialog()">取消</button>
|
|
<button class="confirm-btn" onclick="confirmCaptcha()">确认</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="adminLogin" class="login-page hidden">
|
|
<section class="login-card">
|
|
<h1>后台登录</h1>
|
|
<div class="stack">
|
|
<input id="loginUser" value="admin" placeholder="账号" />
|
|
<input id="loginPass" value="Admin123!" type="password" placeholder="密码" />
|
|
<button onclick="login()">登录</button>
|
|
<p class="hint">本地验收默认账号:admin / Admin123!</p>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<div id="admin" class="app hidden">
|
|
<aside class="side">
|
|
<div class="brand">
|
|
<h1>证书管理</h1>
|
|
<p>Certificate Console</p>
|
|
</div>
|
|
<nav class="nav">
|
|
<button onclick="showTab('dash', this)">数据概览</button>
|
|
<button onclick="showTab('projects', this)">项目代码</button>
|
|
<button onclick="showTab('learners', this)">学员管理</button>
|
|
<button onclick="showTab('certs', this)">证书管理</button>
|
|
<button onclick="showTab('imports', this)">Excel 导入</button>
|
|
<button onclick="showTab('logs', this)">操作日志</button>
|
|
<button onclick="logout()">退出登录</button>
|
|
</nav>
|
|
</aside>
|
|
<main class="main">
|
|
<div class="topbar">
|
|
<h2 id="pageTitle">数据概览</h2>
|
|
<span class="pill">本地验收版</span>
|
|
</div>
|
|
<section id="dash" class="tab"></section>
|
|
<section id="projects" class="tab hidden"></section>
|
|
<section id="learners" class="tab hidden"></section>
|
|
<section id="certs" class="tab hidden"></section>
|
|
<section id="imports" class="tab hidden"></section>
|
|
<section id="logs" class="tab hidden"></section>
|
|
</main>
|
|
</div>
|
|
|
|
<div id="dialog" class="dialog hidden">
|
|
<div class="dialog-card">
|
|
<div class="panel-title"><h3 id="dialogTitle"></h3><button class="ghost" onclick="closeDialog()">关闭</button></div>
|
|
<div id="dialogBody"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let captchaId = "";
|
|
let smsCaptchaId = "";
|
|
let smsId = "";
|
|
let smsCooldown = 0;
|
|
let smsTimer = null;
|
|
let editingLearnerId = null;
|
|
let editingProjectId = null;
|
|
let projectKeyword = "";
|
|
let queryMode = "certificate";
|
|
const titles = { dash: "数据概览", projects: "项目代码", learners: "学员管理", certs: "证书管理", imports: "Excel 导入", logs: "操作日志" };
|
|
const token = () => localStorage.getItem("token") || "";
|
|
let previewObjectUrl = "";
|
|
|
|
async function api(url, opts = {}) {
|
|
opts.headers = opts.headers || {};
|
|
if (!(opts.body instanceof FormData)) opts.headers["Content-Type"] = opts.headers["Content-Type"] || "application/json";
|
|
if (token()) opts.headers.Authorization = "Bearer " + token();
|
|
const res = await fetch("/api" + url, opts);
|
|
if (!res.ok) throw new Error((await res.json()).detail || "请求失败");
|
|
const ct = res.headers.get("content-type") || "";
|
|
return ct.includes("json") ? res.json() : res.blob();
|
|
}
|
|
function esc(v) { return String(v ?? "").replace(/[&<>"']/g, s => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[s])); }
|
|
function statusPill(value) { return `<span class="pill ${value === "voided" || value === "disabled" || value === "deleted" ? "bad" : ""}">${esc(value)}</span>`; }
|
|
function table(rows, cols, actions) {
|
|
if (!rows.length) return `<p class="muted">暂无数据</p>`;
|
|
return `<div class="table-wrap"><table><thead><tr>${cols.map(c=>`<th>${c[1]}</th>`).join("")}${actions?"<th>操作</th>":""}</tr></thead><tbody>` +
|
|
rows.map(r => `<tr>${cols.map(c=>`<td>${c[2] ? c[2](r[c[0]], r) : esc(r[c[0]])}</td>`).join("")}${actions?`<td><div class="actions">${actions(r)}</div></td>`:""}</tr>`).join("") + `</tbody></table></div>`;
|
|
}
|
|
function showDialog(title, html) { dialogTitle.textContent = title; dialogBody.innerHTML = html; dialog.classList.remove("hidden"); }
|
|
function closeDialog() { dialog.classList.add("hidden"); if (previewObjectUrl) { URL.revokeObjectURL(previewObjectUrl); previewObjectUrl = ""; } }
|
|
function downloadBlob(blob, name) { const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = name; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(a.href); }
|
|
async function downloadApi(url, name) { downloadBlob(await api(url), name); }
|
|
const actionNames = {
|
|
create_project: "新增项目",
|
|
update_project: "修改项目",
|
|
create_learner: "新增学员",
|
|
update_learner: "修改学员",
|
|
delete_learner: "删除学员",
|
|
create_certificate: "新增证书",
|
|
void_certificate: "作废证书",
|
|
regenerate_public_token: "重置证书链接",
|
|
upload_import_file: "上传导入文件",
|
|
confirm_import_batch: "确认导入",
|
|
delete_import_batch: "删除导入记录",
|
|
public_search_certificate: "公开查询证书",
|
|
public_download_certificate: "公开下载证书"
|
|
};
|
|
|
|
function importStatusText(value) {
|
|
return ({ uploaded: "已上传", validated: "已校验", imported: "已导入", failed: "校验失败" })[value] || value;
|
|
}
|
|
function importStatusPill(value) {
|
|
return `<span class="pill ${value === "failed" ? "bad" : ""}">${importStatusText(value)}</span>`;
|
|
}
|
|
async function loadImports(){
|
|
const rows=await api("/admin/import-batches");
|
|
imports.innerHTML=`<div class="panel"><div class="panel-title"><h3>导入证书</h3><button class="secondary" onclick="downloadTemplate()">下载模板</button></div><div class="row"><input type="file" id="importFile" accept=".xlsx"><button onclick="uploadImport()">上传校验</button></div><p class="muted">上传后只是完成校验;点击“确认导入”后,校验通过的数据才会正式写入学员和证书。</p></div><div class="panel">${table(rows,[["id","批次"],["filename","文件"],["status","状态",(v)=>importStatusPill(v)],["total_rows","总行"],["valid_rows","可导入"],["failed_rows","失败"]], r=>`<button onclick="downloadImportFile(${r.id},'${esc(r.filename)}')">下载原文件</button>${r.status==="validated"?`<button id="confirmImport${r.id}" onclick="confirmImport(${r.id})">确认导入</button>`:""}<button class="danger" onclick="deleteImport(${r.id})">删除</button>`)}</div>`;
|
|
}
|
|
async function confirmImport(id){
|
|
if(!confirm("确认把校验通过的数据正式写入系统?")) return;
|
|
const btn=document.getElementById("confirmImport"+id);
|
|
if(btn){ btn.disabled=true; btn.textContent="正在导入..."; }
|
|
const row=await api(`/admin/import-batches/${id}/confirm`,{method:"POST"});
|
|
alert(`导入完成,当前状态:${importStatusText(row.status)}`);
|
|
await loadImports();
|
|
}
|
|
function showPage() {
|
|
const path = location.pathname;
|
|
public.classList.toggle("hidden", !(path === "/" || path === "/query" || path.startsWith("/cert/") || path.startsWith("/verify/")));
|
|
adminLogin.classList.toggle("hidden", path !== "/admin/login");
|
|
admin.classList.toggle("hidden", path !== "/admin");
|
|
if (path === "/admin" && !token()) location.href = "/admin/login";
|
|
if (path === "/admin") showTab("dash", document.querySelector(".nav button"));
|
|
if (path === "/" || path === "/query") loadCaptcha();
|
|
if (path.startsWith("/cert/") || path.startsWith("/verify/")) loadPublicToken(path.split("/").pop());
|
|
}
|
|
async function login() {
|
|
const data = await api("/admin/auth/login", { method: "POST", body: JSON.stringify({ username: loginUser.value, password: loginPass.value }) });
|
|
localStorage.setItem("token", data.access_token); location.href = "/admin";
|
|
}
|
|
function logout() { localStorage.removeItem("token"); location.href = "/admin/login"; }
|
|
function setQueryMode(mode) {
|
|
queryMode = mode;
|
|
certFields.classList.toggle("hidden", mode !== "certificate");
|
|
smsFields.classList.toggle("hidden", mode !== "sms");
|
|
modeCert.classList.toggle("active", mode === "certificate");
|
|
modeSms.classList.toggle("active", mode === "sms");
|
|
publicResult.classList.add("hidden");
|
|
if (mode === "certificate") loadCaptcha();
|
|
}
|
|
async function loadCaptcha() { const c = await api("/public/captcha"); captchaId = c.captcha_id; captchaImg.src = c.image; }
|
|
async function loadDialogCaptcha() { const c = await api("/public/captcha"); smsCaptchaId = c.captcha_id; dialogCaptchaImg.src = c.image; }
|
|
|
|
function handleShowCaptchaDialog() {
|
|
showCaptchaDialog().catch(err => alert(err.message));
|
|
}
|
|
|
|
function isValidPhone(phone) {
|
|
return /^1[3-9]\d{9}$/.test(phone);
|
|
}
|
|
|
|
async function showCaptchaDialog() {
|
|
const phone = qPhone.value.trim();
|
|
if (!phone) { alert("请先输入手机号"); qPhone.focus(); return; }
|
|
if (!isValidPhone(phone)) { alert("请输入有效的11位手机号"); qPhone.focus(); return; }
|
|
if (smsCooldown > 0) return;
|
|
captchaDialog.classList.remove("hidden");
|
|
dialogCaptchaInput.value = "";
|
|
await loadDialogCaptcha();
|
|
setTimeout(() => dialogCaptchaInput.focus(), 100);
|
|
}
|
|
|
|
function closeCaptchaDialog() {
|
|
captchaDialog.classList.add("hidden");
|
|
dialogCaptchaInput.value = "";
|
|
}
|
|
|
|
async function confirmCaptcha() {
|
|
const phone = qPhone.value.trim();
|
|
const captchaCode = dialogCaptchaInput.value.trim();
|
|
if (!captchaCode) { alert("请输入图形验证码"); dialogCaptchaInput.focus(); return; }
|
|
if (!/^[A-Za-z0-9]{4}$/.test(captchaCode)) { alert("请输入4位图形验证码"); dialogCaptchaInput.focus(); return; }
|
|
|
|
try {
|
|
const data = await api("/public/sms/send", { method: "POST", body: JSON.stringify({ phone, captcha_id: smsCaptchaId, captcha_code: captchaCode }) });
|
|
smsId = data.sms_id;
|
|
closeCaptchaDialog();
|
|
alert("验证码已发送,请查看服务器终端获取验证码");
|
|
smsCooldown = 60;
|
|
btnSendSms.disabled = true;
|
|
smsTimer = setInterval(() => {
|
|
smsCooldown--;
|
|
btnSendSms.textContent = smsCooldown + "秒后重试";
|
|
if (smsCooldown <= 0) {
|
|
clearInterval(smsTimer);
|
|
smsTimer = null;
|
|
btnSendSms.disabled = false;
|
|
btnSendSms.textContent = "获取验证码";
|
|
}
|
|
}, 1000);
|
|
} catch (e) {
|
|
alert(e.message);
|
|
loadDialogCaptcha();
|
|
dialogCaptchaInput.value = "";
|
|
dialogCaptchaInput.focus();
|
|
}
|
|
}
|
|
async function searchCert() {
|
|
try {
|
|
if (queryMode === "certificate") {
|
|
const certNo = qNo.value.trim();
|
|
const name = qName.value.trim();
|
|
const captchaCode = qCode.value.trim();
|
|
if (!certNo && !name) { alert("请输入证书编号或姓名"); return; }
|
|
if (!captchaCode) { alert("请输入图形验证码"); qCode.focus(); return; }
|
|
if (!/^[A-Za-z0-9]{4}$/.test(captchaCode)) { alert("请输入4位图形验证码"); qCode.focus(); return; }
|
|
renderPublic(await api("/public/certificates/search", { method: "POST", body: JSON.stringify({ certificate_no: certNo, name: name, captcha_id: captchaId, captcha_code: captchaCode }) }));
|
|
} else {
|
|
const phone = qPhone.value.trim();
|
|
const smsCode = qSmsCode.value.trim();
|
|
if (!phone) { alert("请输入手机号"); qPhone.focus(); return; }
|
|
if (!isValidPhone(phone)) { alert("请输入有效的11位手机号"); qPhone.focus(); return; }
|
|
if (!smsCode) { alert("请输入短信验证码"); qSmsCode.focus(); return; }
|
|
if (!/^\d{6}$/.test(smsCode)) { alert("请输入6位短信验证码"); qSmsCode.focus(); return; }
|
|
if (!smsId) { alert("请先获取短信验证码"); return; }
|
|
renderPublic(await api("/public/certificates/search-by-sms", { method: "POST", body: JSON.stringify({ phone: phone, sms_id: smsId, sms_code: smsCode }) }));
|
|
}
|
|
} catch (e) { alert(e.message); if (queryMode === "certificate") await loadCaptcha(); }
|
|
}
|
|
async function loadPublicToken(t) { renderPublic(await api("/public/certificates/token/" + t)); }
|
|
function renderPublic(data) {
|
|
const list = data.items || [data];
|
|
publicResult.classList.remove("hidden");
|
|
const hasVoided = list.some(c => c.status !== "valid");
|
|
const statusClass = hasVoided ? "warning" : "success";
|
|
const statusIcon = hasVoided
|
|
? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>'
|
|
: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>';
|
|
|
|
publicResult.innerHTML = `
|
|
<div class="result-header">
|
|
<div class="result-icon ${statusClass}">${statusIcon}</div>
|
|
<div>
|
|
<h3>查询结果</h3>
|
|
<p style="font-size:13px;color:var(--muted)">共找到 ${list.length} 张证书${hasVoided ? ',其中包含作废证书' : ''}</p>
|
|
</div>
|
|
</div>
|
|
<div class="result-list">
|
|
${list.map((c, i) => `
|
|
<div class="cert-item">
|
|
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:12px">
|
|
<div class="cert-name">${esc(c.certificate_name)}</div>
|
|
<span class="status-badge ${c.status === 'valid' ? 'valid' : 'void'}">${c.status === 'valid' ? '有效' : '已作废'}</span>
|
|
</div>
|
|
<div class="cert-meta">
|
|
<div><span>证书编号</span><strong>${esc(c.certificate_no)}</strong></div>
|
|
<div><span>姓名</span><strong>${esc(c.learner_name)}</strong></div>
|
|
<div><span>项目/课程</span><strong>${esc(c.project_code)} ${esc(c.course_name)}</strong></div>
|
|
<div><span>阶段</span><strong>${esc(c.stage_name)}</strong></div>
|
|
<div><span>发证日期</span><strong>${esc(c.issue_date)}</strong></div>
|
|
<div><span>发证单位</span><strong>${esc(c.issuer_name)}</strong></div>
|
|
</div>
|
|
<div class="cert-actions">
|
|
<button class="secondary" onclick="previewPublicCert(${i})">
|
|
<svg style="width:14px;height:14px;margin-right:4px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
|
|
预览
|
|
</button>
|
|
${c.can_download_pdf ? `<button onclick="location.href='${esc(c.download_url)}'">
|
|
<svg style="width:14px;height:14px;margin-right:4px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
|
下载 PDF
|
|
</button>` : ''}
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
window.publicResultItems = list;
|
|
}
|
|
async function previewPublicCert(index) {
|
|
const c = window.publicResultItems[index];
|
|
if (previewObjectUrl) URL.revokeObjectURL(previewObjectUrl);
|
|
previewObjectUrl = c.can_download_pdf ? URL.createObjectURL(await api(c.download_url.replace(/^\/api/, ""))) : "";
|
|
showDialog("证书预览", `<div style="height:70vh;border:1px solid #dfe9ec;border-radius:10px;overflow:hidden;background:#f8fafc">${c.can_download_pdf ? `<embed src="${previewObjectUrl}" type="application/pdf" style="width:100%;height:100%">` : `<p class="muted" style="padding:24px">该证书当前不支持下载或预览 PDF。</p>`}</div><div style="margin-top:14px;text-align:right">${c.can_download_pdf ? `<button onclick="location.href='${esc(c.download_url)}'">下载 PDF</button>` : ""}</div>`);
|
|
}
|
|
async function showTab(id, btn) {
|
|
document.querySelectorAll(".tab").forEach(x => x.classList.add("hidden"));
|
|
document.querySelectorAll(".nav button").forEach(x => x.classList.remove("active"));
|
|
document.getElementById(id).classList.remove("hidden");
|
|
if (btn) btn.classList.add("active");
|
|
pageTitle.textContent = titles[id];
|
|
if (id === "dash") loadDash();
|
|
if (id === "projects") loadProjects();
|
|
if (id === "learners") loadLearners();
|
|
if (id === "certs") loadCerts();
|
|
if (id === "imports") loadImports();
|
|
if (id === "logs") loadLogs();
|
|
}
|
|
async function loadDash() {
|
|
const s = await api("/admin/dashboard/summary");
|
|
dash.innerHTML = `<div class="stats"><div class="stat"><span>证书总数</span><strong>${s.certificate_count}</strong></div><div class="stat"><span>有效证书</span><strong>${s.valid_certificate_count}</strong></div><div class="stat"><span>作废证书</span><strong>${s.voided_certificate_count}</strong></div><div class="stat"><span>导入批次</span><strong>${s.recent_import_count}</strong></div></div>`;
|
|
}
|
|
async function loadProjects() {
|
|
const rows = await api("/admin/projects");
|
|
const filtered = rows.filter(r => !projectKeyword || `${r.code} ${r.name} ${r.status}`.toLowerCase().includes(projectKeyword.toLowerCase()));
|
|
projects.innerHTML = `<div class="panel"><div class="panel-title"><h3>${editingProjectId ? "修改项目" : "新增项目"}</h3><button onclick="resetProject()" class="secondary">清空</button></div>
|
|
<div class="form-grid"><label>项目代码<input id="pCode"></label><label>项目名称<input id="pName"></label><label>状态<select id="pStatus"><option value="active">active</option><option value="disabled">disabled</option></select></label><label> <button onclick="saveProject()">保存项目</button></label></div></div>
|
|
<div class="panel"><div class="toolbar"><input id="projectSearch" placeholder="搜索项目代码、名称、状态" value="${esc(projectKeyword)}" oninput="projectKeyword=this.value; loadProjects()"><button class="secondary" onclick="projectKeyword=''; loadProjects()">重置</button></div>${table(filtered,[["code","项目代码"],["name","项目名称"],["status","状态",(v)=>statusPill(v)]], r=>`<button onclick='editProject(${JSON.stringify(r)})'>修改</button><button class="secondary" onclick="toggleProject(${r.id},'${r.status}')">${r.status==="active"?"停用":"启用"}</button>`)}</div>`;
|
|
}
|
|
function editProject(r){ editingProjectId=r.id; pCode.value=r.code; pCode.disabled=true; pName.value=r.name; pStatus.value=r.status; }
|
|
function resetProject(){ editingProjectId=null; pCode.disabled=false; pCode.value=""; pName.value=""; pStatus.value="active"; }
|
|
async function saveProject(){ const body={code:pCode.value,name:pName.value,status:pStatus.value}; if(editingProjectId) await api("/admin/projects/"+editingProjectId,{method:"PUT",body:JSON.stringify(body)}); else await api("/admin/projects",{method:"POST",body:JSON.stringify(body)}); editingProjectId=null; loadProjects(); }
|
|
async function toggleProject(id,status){ await api("/admin/projects/"+id,{method:"PUT",body:JSON.stringify({status:status==="active"?"disabled":"active"})}); loadProjects(); }
|
|
|
|
async function loadLearners() {
|
|
const rows = await api("/admin/learners");
|
|
learners.innerHTML = `<div class="panel"><div class="panel-title"><h3>${editingLearnerId ? "修改学员" : "新增学员"}</h3><button onclick="resetLearner()" class="secondary">清空</button></div>
|
|
<div class="form-grid"><label>姓名<input id="lName"></label><label>手机号<input id="lPhone"></label><label>学员编号<input id="lNo"></label><label>状态<select id="lStatus"><option value="active">active</option><option value="disabled">disabled</option></select></label><label style="grid-column:span 3">备注<input id="lRemark"></label><label> <button onclick="saveLearner()">保存学员</button></label></div></div>
|
|
<div class="panel">${table(rows,[["id","ID"],["current_name","姓名"],["phone","手机号"],["student_no","学员编号"],["status","状态",(v)=>statusPill(v)],["remark","备注"]], r=>`<button onclick='editLearner(${JSON.stringify(r)})'>修改</button><button class="danger" onclick="deleteLearner(${r.id})">删除</button>`)}</div>`;
|
|
}
|
|
function editLearner(r){ editingLearnerId=r.id; lName.value=r.current_name||""; lPhone.value=r.phone||""; lNo.value=r.student_no||""; lStatus.value=r.status||"active"; lRemark.value=r.remark||""; }
|
|
function resetLearner(){ editingLearnerId=null; lName.value=""; lPhone.value=""; lNo.value=""; lStatus.value="active"; lRemark.value=""; }
|
|
async function saveLearner(){ const body={current_name:lName.value,phone:lPhone.value,student_no:lNo.value,status:lStatus.value,remark:lRemark.value}; if(editingLearnerId) await api("/admin/learners/"+editingLearnerId,{method:"PUT",body:JSON.stringify(body)}); else await api("/admin/learners",{method:"POST",body:JSON.stringify(body)}); editingLearnerId=null; loadLearners(); }
|
|
async function deleteLearner(id){ if(confirm("确认删除该学员?")){ await api("/admin/learners/"+id,{method:"DELETE"}); loadLearners(); } }
|
|
|
|
async function loadCerts() {
|
|
const rows = await api("/admin/certificates");
|
|
certs.innerHTML = `<div class="panel"><div class="panel-title"><h3>新增证书</h3><button class="secondary" onclick="exportCerts()">导出证书</button></div>
|
|
<div class="form-grid"><label>学员ID<input id="cLearner"></label><label>项目代码<input id="cProject" value="DBY"></label><label>发证日期<input id="cDate" type="date"></label><label>课程名称<input id="cCourse" placeholder="留空则使用项目配置"></label><label>阶段名称<input id="cStage" placeholder="留空则使用项目配置"></label><label>发证单位<input id="cIssuer" placeholder="留空则使用项目配置"></label><label> <button onclick="saveCert()">新增证书</button></label></div><p class="muted">证书名称自动使用项目名称,不需要单独填写。</p></div>
|
|
<div class="panel">${table(rows,[["certificate_no","证书编号"],["learner_name","学员"],["project_code","项目"],["certificate_name","证书名称"],["issue_date","发证日期"],["status","状态",(v)=>statusPill(v)]], r=>`<button onclick="previewCert(${r.id})">预览</button><button onclick="downloadAdminCert(${r.id})">下载</button><button class="secondary" onclick="regen(${r.id})">重置链接</button>${r.status==="valid"?`<button class="danger" onclick="voidCert(${r.id})">作废</button>`:""}`)}</div>`;
|
|
}
|
|
async function saveCert(){ await api("/admin/certificates",{method:"POST",body:JSON.stringify({learner_id:Number(cLearner.value),project_code:cProject.value,course_name:cCourse.value,stage_name:cStage.value,issue_date:cDate.value,issuer_name:cIssuer.value})}); loadCerts(); }
|
|
async function previewCert(id){ const c=await api("/admin/certificates/"+id); if (previewObjectUrl) URL.revokeObjectURL(previewObjectUrl); previewObjectUrl = URL.createObjectURL(await api("/admin/certificates/"+id+"/download")); showDialog("证书预览", `<div style="height:70vh;border:1px solid #dfe9ec;border-radius:10px;overflow:hidden;background:#f8fafc"><embed src="${previewObjectUrl}" type="application/pdf" style="width:100%;height:100%"></div><div style="margin-top:14px;display:grid;gap:8px"><div class="muted">证书编号:${esc(c.certificate_no)}</div><div class="muted">公开链接:<a href="${esc(c.public_url)}" target="_blank" rel="noreferrer">${esc(c.public_url)}</a></div><div class="muted">核验链接:<a href="${esc(c.verify_url)}" target="_blank" rel="noreferrer">${esc(c.verify_url)}</a></div><div style="text-align:right"><button onclick="downloadAdminCert(${id}, '${esc(c.certificate_no)}.pdf')">下载 PDF</button></div></div>`); }
|
|
async function downloadAdminCert(id, name="certificate.pdf"){ await downloadApi("/admin/certificates/"+id+"/download", name); }
|
|
async function voidCert(id){ await api(`/admin/certificates/${id}/void`,{method:"POST"}); loadCerts(); }
|
|
async function regen(id){ await api(`/admin/certificates/${id}/token/regenerate`,{method:"POST"}); alert("链接已重置,重新导出可获取新链接"); loadCerts(); }
|
|
async function exportCerts(){ await downloadApi("/admin/exports/certificates", "certificates-export.xlsx"); }
|
|
|
|
async function loadImports(){ const rows=await api("/admin/import-batches"); imports.innerHTML=`<div class="panel"><div class="panel-title"><h3>导入证书</h3><button class="secondary" onclick="downloadTemplate()">下载模板</button></div><div class="row"><input type="file" id="importFile" accept=".xlsx"><button onclick="uploadImport()">上传校验</button></div><p class="muted">上传后只是完成校验;点击“确认导入”后,校验通过的数据才会正式写入学员和证书。</p></div><div class="panel">${table(rows,[["id","批次"],["filename","文件"],["status","状态",(v)=>statusPill(v)],["total_rows","总行"],["valid_rows","可导入"],["failed_rows","失败"]], r=>`<button onclick="downloadImportFile(${r.id},'${esc(r.filename)}')">下载原文件</button>${r.status==="validated"?`<button onclick="confirmImport(${r.id})">确认导入</button>`:""}<button class="danger" onclick="deleteImport(${r.id})">删除</button>`)}</div>`; }
|
|
async function downloadTemplate(){ await downloadApi("/admin/import-batches/template", "certificate-import-template.xlsx"); }
|
|
async function uploadImport(){ const fd=new FormData(); fd.append("file", importFile.files[0]); await api("/admin/import-batches",{method:"POST",body:fd,headers:{}}); loadImports(); }
|
|
async function confirmImport(id){ if(confirm("确认把校验通过的数据正式写入系统?")){ await api(`/admin/import-batches/${id}/confirm`,{method:"POST"}); alert("导入完成,学员和证书数据已写入"); loadImports(); } }
|
|
async function downloadImportFile(id,name){ await downloadApi(`/admin/import-batches/${id}/file`, name); }
|
|
async function deleteImport(id){ if(confirm("确认删除该导入记录和服务器上的上传文件?已生成的学员和证书不会被删除。")){ await api(`/admin/import-batches/${id}`,{method:"DELETE"}); loadImports(); } }
|
|
function logRiskPill(value, label) {
|
|
const cls = value === "high" ? "bad" : value === "medium" ? "warn" : "";
|
|
return `<span class="pill ${cls}">${esc(label || value)}</span>`;
|
|
}
|
|
function logFiltersQuery() {
|
|
const params = new URLSearchParams();
|
|
const values = {
|
|
action: document.getElementById("logAction")?.value || "",
|
|
object_type: document.getElementById("logObject")?.value || "",
|
|
risk: document.getElementById("logRisk")?.value || "",
|
|
keyword: document.getElementById("logKeyword")?.value || "",
|
|
};
|
|
Object.entries(values).forEach(([key, value]) => { if (value) params.set(key, value); });
|
|
const text = params.toString();
|
|
return text ? "?" + text : "";
|
|
}
|
|
function logPageQuery(page) {
|
|
const params = new URLSearchParams(logFiltersQuery().slice(1));
|
|
params.set("page", page);
|
|
params.set("page_size", 20);
|
|
return "?" + params.toString();
|
|
}
|
|
function compactPages(current, total) {
|
|
const pages = [];
|
|
const add = value => { if (!pages.includes(value)) pages.push(value); };
|
|
add(1);
|
|
for (let i = current - 2; i <= current + 2; i += 1) if (i > 1 && i < total) add(i);
|
|
if (total > 1) add(total);
|
|
pages.sort((a, b) => a - b);
|
|
const result = [];
|
|
pages.forEach((page, index) => {
|
|
if (index && page - pages[index - 1] > 1) result.push("...");
|
|
result.push(page);
|
|
});
|
|
return result;
|
|
}
|
|
function renderPager(meta) {
|
|
if (!meta || meta.total_pages <= 1) return "";
|
|
const buttons = compactPages(meta.page, meta.total_pages).map(item => item === "..."
|
|
? `<span>...</span>`
|
|
: `<button class="${item === meta.page ? "active" : ""}" onclick="loadLogs(${item})">${item}</button>`).join("");
|
|
return `<div class="pager"><button ${meta.page <= 1 ? "disabled" : ""} onclick="loadLogs(${meta.page - 1})">上一页</button>${buttons}<button ${meta.page >= meta.total_pages ? "disabled" : ""} onclick="loadLogs(${meta.page + 1})">下一页</button><span>共 ${meta.total} 条</span></div>`;
|
|
}
|
|
function resetLogFilters() {
|
|
["logAction", "logObject", "logRisk", "logKeyword"].forEach(id => { const el = document.getElementById(id); if (el) el.value = ""; });
|
|
loadLogs(1);
|
|
}
|
|
function showLogDetail(index) {
|
|
const row = window.logRows[index];
|
|
let detail = "{}";
|
|
try { detail = row.detail_json ? JSON.stringify(JSON.parse(row.detail_json), null, 2) : "{}"; } catch { detail = row.detail_json || "{}"; }
|
|
showDialog("操作日志详情", `<div class="meta"><p><span>时间</span>${esc(row.created_at)}</p><p><span>操作人ID</span>${esc(row.admin_user_id || "-")}</p><p><span>操作</span>${esc(row.action_label)}</p><p><span>对象</span>${esc(row.object_label)} #${esc(row.object_id || "-")}</p><p><span>风险等级</span>${logRiskPill(row.risk, row.risk_label)}</p><p><span>摘要</span>${esc(row.summary)}</p></div><pre style="white-space:pre-wrap;background:#f8fafc;border:1px solid #dfe9ec;border-radius:8px;padding:14px;max-height:360px;overflow:auto">${esc(detail)}</pre>`);
|
|
}
|
|
async function exportLogs(){ await downloadApi("/admin/logs/export" + logFiltersQuery(), "operation-logs.xlsx"); }
|
|
async function cleanupLogs(){
|
|
if(!confirm("只会清理超过保留周期的日志:普通日志 180 天,高风险日志 365 天。确认清理?")) return;
|
|
const result = await api("/admin/logs/cleanup", {method:"POST"});
|
|
alert(`已清理 ${result.removed} 条过期日志`);
|
|
loadLogs();
|
|
}
|
|
async function loadLogs(page=1){
|
|
const current = {
|
|
action: document.getElementById("logAction")?.value || "",
|
|
object_type: document.getElementById("logObject")?.value || "",
|
|
risk: document.getElementById("logRisk")?.value || "",
|
|
keyword: document.getElementById("logKeyword")?.value || "",
|
|
};
|
|
const result=await api("/admin/logs" + logPageQuery(page));
|
|
const rows=result.items || [];
|
|
window.logRows = rows;
|
|
const pager = renderPager(result);
|
|
logs.innerHTML=`<div class="panel"><p class="muted">日志保留策略:普通日志和公开查询/下载日志保留 180 天,高风险后台操作保留 365 天。</p><div class="toolbar"><input id="logKeyword" placeholder="搜索摘要、对象ID、详情" value="${esc(current.keyword)}" onkeydown="if(event.key==='Enter')loadLogs(1)"><select id="logAction"><option value="">全部操作</option>${Object.entries(actionNames).map(([key,label])=>`<option value="${key}" ${current.action===key?"selected":""}>${label}</option>`).join("")}</select><select id="logObject"><option value="">全部对象</option><option value="project" ${current.object_type==="project"?"selected":""}>项目</option><option value="learner" ${current.object_type==="learner"?"selected":""}>学员</option><option value="certificate" ${current.object_type==="certificate"?"selected":""}>证书</option><option value="import_batch" ${current.object_type==="import_batch"?"selected":""}>导入批次</option><option value="public_access" ${current.object_type==="public_access"?"selected":""}>公开访问</option></select><select id="logRisk"><option value="">全部风险</option><option value="high" ${current.risk==="high"?"selected":""}>高风险</option><option value="medium" ${current.risk==="medium"?"selected":""}>中风险</option><option value="low" ${current.risk==="low"?"selected":""}>低风险</option></select><button onclick="loadLogs(1)">搜索</button><button class="secondary" onclick="resetLogFilters()">重置</button><button class="secondary" onclick="exportLogs()">导出</button><button class="secondary" onclick="cleanupLogs()">清理过期日志</button></div>${pager}${table(rows,[["created_at","时间"],["action_label","操作"],["object_label","对象"],["object_id","对象ID"],["risk","风险",(v,r)=>logRiskPill(v,r.risk_label)],["summary","摘要"]], (r)=>`<button onclick="showLogDetail(${rows.indexOf(r)})">详情</button>`)}${pager}</div>`;
|
|
}
|
|
|
|
async function loadImports(){
|
|
const rows=await api("/admin/import-batches");
|
|
imports.innerHTML=`<div class="panel"><div class="panel-title"><h3>导入证书</h3><button class="secondary" onclick="downloadTemplate()">下载模板</button></div><div class="row"><input type="file" id="importFile" accept=".xlsx"><button onclick="uploadImport()">上传校验</button></div><p class="muted">上传后只是完成校验;点击“确认导入”后,校验通过的数据才会正式写入学员和证书。</p></div><div class="panel">${table(rows,[["id","批次"],["filename","文件"],["status","状态",(v)=>importStatusPill(v)],["total_rows","总行"],["valid_rows","可导入"],["failed_rows","失败"]], r=>`<button onclick="downloadImportFile(${r.id},'${esc(r.filename)}')">下载原文件</button>${r.status==="validated"?`<button id="confirmImport${r.id}" onclick="confirmImport(${r.id})">确认导入</button>`:""}<button class="danger" onclick="deleteImport(${r.id})">删除</button>`)}</div>`;
|
|
}
|
|
async function confirmImport(id){
|
|
if(!confirm("确认把校验通过的数据正式写入系统?")) return;
|
|
const btn=document.getElementById("confirmImport"+id);
|
|
if(btn){ btn.disabled=true; btn.textContent="正在导入..."; }
|
|
const row=await api(`/admin/import-batches/${id}/confirm`,{method:"POST"});
|
|
alert(`导入完成,当前状态:${importStatusText(row.status)}`);
|
|
await loadImports();
|
|
}
|
|
|
|
// 键盘事件处理
|
|
document.addEventListener('keydown', function(e) {
|
|
// 弹窗中按回车键确认
|
|
if (e.key === 'Enter' && !captchaDialog.classList.contains('hidden')) {
|
|
confirmCaptcha();
|
|
}
|
|
// 弹窗中按ESC键关闭
|
|
if (e.key === 'Escape' && !captchaDialog.classList.contains('hidden')) {
|
|
closeCaptchaDialog();
|
|
}
|
|
});
|
|
|
|
showPage();
|
|
</script>
|
|
</body>
|
|
</html>
|