Initial certificate system

This commit is contained in:
nelso
2026-06-06 19:32:24 +08:00
commit ce4ed6213b
105 changed files with 9133 additions and 0 deletions

434
local_app/static/index.html Normal file
View File

@@ -0,0 +1,434 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>&#22521;&#35757;&#32467;&#19994;&#35777;&#20070;&#31649;&#29702;&#19982;&#26597;&#35810;&#31995;&#32479;</title>
<style>
* { box-sizing: border-box; }
:root {
--bg: #f5f8fa;
--surface: #ffffff;
--surface-soft: #f9fbfc;
--border: #dbe6ea;
--border-strong: #c8d7dd;
--text: #172033;
--muted: #64748b;
--primary: #16817e;
--primary-strong: #0f6f6b;
--primary-soft: #e6f5f3;
--danger: #c9413a;
--warning: #b7791f;
--shadow: 0 14px 36px rgba(42, 64, 78, .08);
--shadow-soft: 0 8px 22px rgba(42, 64, 78, .055);
}
body { margin: 0; font-family: Inter, "Microsoft YaHei", "PingFang SC", Arial, sans-serif; color: var(--text); background: linear-gradient(180deg, #f7fafb 0%, #eef4f6 100%); }
button, input, select { font: inherit; }
button { height: 36px; border: 0; border-radius: 7px; padding: 0 14px; background: var(--primary); color: #fff; cursor: pointer; font-weight: 650; }
button:hover { filter: brightness(.96); }
button.secondary { color: #33515f; background: #fff; border: 1px solid var(--border-strong); }
button.danger { background: var(--danger); }
button.ghost { background: transparent; color: #475569; }
input, select { height: 36px; min-width: 150px; border: 1px solid var(--border); border-radius: 8px; padding: 0 11px; background: #fff; outline: none; }
input:focus, select:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(22,129,126,.12); }
select {
appearance: none;
padding-right: 38px;
background-image: linear-gradient(45deg, transparent 50%, #334155 50%), linear-gradient(135deg, #334155 50%, transparent 50%);
background-position: calc(100% - 19px) 14px, calc(100% - 13px) 14px;
background-size: 6px 6px, 6px 6px;
background-repeat: no-repeat;
}
h1, h2, h3 { margin: 0; }
table { width: 100%; border-collapse: collapse; background: #fff; }
th, td { padding: 11px 12px; border-bottom: 1px solid #e5edf0; text-align: left; font-size: 14px; vertical-align: middle; }
th { background: #f6fafb; color: #52677a; font-weight: 700; }
tr:hover td { background: #f8fcfc; }
.hidden { display: none !important; }
.login-page, .query-page { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
.login-card, .query-card { width: min(540px, 100%); background: #fff; border: 1px solid var(--border); border-radius: 14px; padding: 30px; box-shadow: var(--shadow); }
.login-card h1, .query-card h1 { font-size: 26px; margin-bottom: 18px; }
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.stack { display: grid; gap: 12px; }
.choice { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 4px; border: 1px solid var(--border); border-radius: 9px; background: #f4f8fa; }
.choice button { background: transparent; color: #42576a; }
.choice button.active { background: var(--primary); color: #fff; }
.muted { color: #64748b; font-size: 13px; line-height: 1.6; }
.app { min-height: 100vh; display: grid; grid-template-columns: 236px 1fr; }
.side { background: rgba(255,255,255,.94); color: #314355; padding: 24px 14px; border-right: 1px solid #e1ebef; box-shadow: 8px 0 26px rgba(42,64,78,.045); }
.brand { padding: 0 12px 20px; border-bottom: 1px solid #e1ebef; margin-bottom: 18px; }
.brand h1 { font-size: 21px; color: #172033; font-weight: 800; }
.brand p { margin: 6px 0 0; color: #6b8795; font-size: 12px; }
.nav { display: grid; gap: 6px; }
.nav button { width: 100%; justify-content: flex-start; text-align: left; background: transparent; color: #42576a; border-radius: 8px; }
.nav button.active, .nav button:hover { background: var(--primary-soft); color: var(--primary-strong); }
.main { padding: 28px; overflow: auto; }
.topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 18px; }
.topbar h2 { font-size: 24px; }
.panel { background: #fff; border: 1px solid var(--border); border-radius: 10px; padding: 18px; margin-bottom: 16px; box-shadow: var(--shadow-soft); }
.panel-title { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; }
.form-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.form-grid label { display: grid; gap: 6px; color: #475569; font-size: 13px; }
.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: 12px; }
.pager { display: flex; align-items: center; justify-content: flex-end; gap: 6px; margin: 12px 0; flex-wrap: wrap; }
.pager button { min-width: 34px; padding: 0 10px; background: #fff; color: #33515f; border: 1px solid #cbd5e1; }
.pager button.active { background: #208a87; color: #fff; border-color: #208a87; }
.pager button:disabled { opacity: .45; cursor: not-allowed; }
.pager span { color: #64748b; padding: 0 4px; }
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
.stat { padding: 18px; border: 1px solid var(--border); border-radius: 10px; background: linear-gradient(180deg, #fff, #f8fbfc); box-shadow: var(--shadow-soft); }
.stat span { color: #64748b; font-size: 13px; }
.stat strong { display: block; margin-top: 8px; font-size: 30px; }
.pill { display: inline-flex; align-items: center; height: 24px; border-radius: 999px; padding: 0 9px; font-size: 12px; background: var(--primary-soft); color: var(--primary-strong); font-weight: 650; }
.pill.bad { background: #fee2e2; color: #991b1b; }
.pill.warn { background: #fef3c7; color: #92400e; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
.dialog { position: fixed; inset: 0; display: grid; place-items: center; background: rgba(15,23,42,.45); padding: 24px; z-index: 20; }
.dialog-card { width: min(720px, 100%); background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 24px 80px rgba(42,64,78,.22); }
.dialog-card pre { white-space: pre-wrap; background: #f8fafc; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; }
.result-grid { display: grid; gap: 14px; }
.cert-card { border: 1px solid var(--border); border-radius: 10px; padding: 20px; background: linear-gradient(180deg,#fff,#f8fbfc); box-shadow: var(--shadow-soft); }
.cert-card h3 { margin-bottom: 10px; }
.cert-card .meta { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 10px 18px; margin: 14px 0; }
.cert-card .meta span { display: block; color: #64748b; font-size: 12px; margin-bottom: 3px; }
@media (max-width: 900px) {
.app { grid-template-columns: 1fr; }
.side { position: static; }
.form-grid, .stats { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div id="public" class="query-page hidden">
<section class="query-card">
<h1>&#22521;&#35757;&#32467;&#19994;&#35777;&#20070;&#26597;&#35810;</h1>
<div class="stack">
<div class="choice">
<button id="modeCert" class="active" onclick="setQueryMode('certificate')">&#35777;&#20070;&#32534;&#21495;&#26597;&#35810;</button>
<button id="modePhone" onclick="setQueryMode('phone')">&#25163;&#26426;&#21495;&#26597;&#35810;</button>
</div>
<input id="qNo" placeholder="&#35777;&#20070;&#32534;&#21495;" />
<input id="qPhone" class="hidden" placeholder="&#25163;&#26426;&#21495;" />
<input id="qName" placeholder="&#22995;&#21517;" />
<div class="row">
<input id="qCode" placeholder="&#39564;&#35777;&#30721;" />
<img id="captchaImg" style="height:34px;border:1px solid #cbd5e1;border-radius:6px" />
<button class="secondary" onclick="loadCaptcha()">&#21047;&#26032;</button>
<button onclick="searchCert()">&#26597;&#35810;</button>
</div>
<p class="muted">&#26412;&#35777;&#20070;&#20165;&#29992;&#20110;&#35777;&#26126;&#23398;&#21592;&#23436;&#25104;&#30456;&#20851;&#22521;&#35757;&#35838;&#31243;/&#38454;&#27573;&#23398;&#20064;&#65292;&#19981;&#20195;&#34920;&#22269;&#23478;&#23398;&#21382;&#12289;&#23398;&#20301;&#12289;&#32844;&#19994;&#36164;&#26684;&#25110;&#32844;&#19994;&#25216;&#33021;&#31561;&#32423;&#35748;&#35777;&#12290;</p>
</div>
</section>
<section id="publicResult" class="query-card hidden"></section>
</div>
<div id="adminLogin" class="login-page hidden">
<section class="login-card">
<h1>&#21518;&#21488;&#30331;&#24405;</h1>
<div class="stack">
<input id="loginUser" value="admin" placeholder="&#36134;&#21495;" />
<input id="loginPass" value="Admin123!" type="password" placeholder="&#23494;&#30721;" />
<button onclick="login()">&#30331;&#24405;</button>
<p class="muted">&#26412;&#22320;&#39564;&#25910;&#40664;&#35748;&#36134;&#21495;&#65306;admin / Admin123!</p>
</div>
</section>
</div>
<div id="admin" class="app hidden">
<aside class="side">
<div class="brand">
<h1>&#35777;&#20070;&#31649;&#29702;</h1>
<p>Certificate Console</p>
</div>
<nav class="nav">
<button onclick="showTab('dash', this)">&#25968;&#25454;&#27010;&#35272;</button>
<button onclick="showTab('projects', this)">&#39033;&#30446;&#20195;&#30721;</button>
<button onclick="showTab('learners', this)">&#23398;&#21592;&#31649;&#29702;</button>
<button onclick="showTab('certs', this)">&#35777;&#20070;&#31649;&#29702;</button>
<button onclick="showTab('imports', this)">Excel &#23548;&#20837;</button>
<button onclick="showTab('logs', this)">&#25805;&#20316;&#26085;&#24535;</button>
<button onclick="logout()">&#36864;&#20986;&#30331;&#24405;</button>
</nav>
</aside>
<main class="main">
<div class="topbar">
<h2 id="pageTitle">&#25968;&#25454;&#27010;&#35272;</h2>
<span class="pill">&#26412;&#22320;&#39564;&#25910;&#29256;</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()">&#20851;&#38381;</button></div>
<div id="dialogBody"></div>
</div>
</div>
<script>
let captchaId = "";
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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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 `<div class="panel"><p class="muted">暂无数据</p></div>`;
return `<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>`;
}
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;
qNo.classList.toggle("hidden", mode !== "certificate");
qPhone.classList.toggle("hidden", mode !== "phone");
modeCert.classList.toggle("active", mode === "certificate");
modePhone.classList.toggle("active", mode === "phone");
publicResult.classList.add("hidden");
}
async function loadCaptcha() { const c = await api("/public/captcha"); captchaId = c.captcha_id; captchaImg.src = c.image; }
async function searchCert() {
try {
renderPublic(await api("/public/certificates/search", { method: "POST", body: JSON.stringify({ certificate_no: queryMode === "certificate" ? qNo.value : "", phone: queryMode === "phone" ? qPhone.value : "", name: qName.value, captcha_id: captchaId, captcha_code: qCode.value }) }));
} catch (e) { alert(e.message); 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");
publicResult.innerHTML = `<div class="panel-title"><div><h2>查询结果</h2><p class="muted">共找到 ${list.length} 张证书</p></div></div><div class="result-grid">${list.map((c,i) => `<div class="cert-card">
<span class="pill ${c.status==="valid"?"":"bad"}">${c.status === "valid" ? "有效" : "已作废"}</span>
<h3>${esc(c.certificate_name)}</h3>
<div class="meta"><p><span>证书编号</span>${esc(c.certificate_no)}</p><p><span>姓名</span>${esc(c.learner_name)}</p><p><span>项目/课程/阶段</span>${esc(c.project_code)} ${esc(c.course_name)} ${esc(c.stage_name)}</p><p><span>发证日期</span>${esc(c.issue_date)}</p><p><span>发证单位</span>${esc(c.issuer_name)}</p></div>
<div class="actions"><button class="secondary" onclick="previewPublicCert(${i})">预览</button>${c.can_download_pdf ? `<button onclick="location.href='${esc(c.download_url)}'">下载 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>&nbsp;<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>&nbsp;<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>&nbsp;<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();
}
showPage();
</script>
</body>
</html>