Initial certificate system
This commit is contained in:
434
local_app/static/index.html
Normal file
434
local_app/static/index.html
Normal 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>培训结业证书管理与查询系统</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>培训结业证书查询</h1>
|
||||
<div class="stack">
|
||||
<div class="choice">
|
||||
<button id="modeCert" class="active" onclick="setQueryMode('certificate')">证书编号查询</button>
|
||||
<button id="modePhone" onclick="setQueryMode('phone')">手机号查询</button>
|
||||
</div>
|
||||
<input id="qNo" placeholder="证书编号" />
|
||||
<input id="qPhone" class="hidden" placeholder="手机号" />
|
||||
<input id="qName" placeholder="姓名" />
|
||||
<div class="row">
|
||||
<input id="qCode" placeholder="验证码" />
|
||||
<img id="captchaImg" style="height:34px;border:1px solid #cbd5e1;border-radius:6px" />
|
||||
<button class="secondary" onclick="loadCaptcha()">刷新</button>
|
||||
<button onclick="searchCert()">查询</button>
|
||||
</div>
|
||||
<p class="muted">本证书仅用于证明学员完成相关培训课程/阶段学习,不代表国家学历、学位、职业资格或职业技能等级认证。</p>
|
||||
</div>
|
||||
</section>
|
||||
<section id="publicResult" class="query-card hidden"></section>
|
||||
</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="muted">本地验收默认账号: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 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 `<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> <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();
|
||||
}
|
||||
showPage();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user