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

980
local_app/server.py Normal file
View File

@@ -0,0 +1,980 @@
import base64
import hashlib
import hmac
import io
import json
import secrets
import sqlite3
import time
import traceback
from datetime import date, datetime
from datetime import timedelta
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse
from openpyxl import Workbook, load_workbook
from PIL import Image, ImageDraw, ImageFont
from reportlab.lib.pagesizes import A4, landscape
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen import canvas
ROOT = Path(__file__).resolve().parent
DATA = ROOT / "data"
DB_PATH = DATA / "local.db"
SECRET = "local-dev-secret"
ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
CAPTCHAS: dict[str, tuple[str, float]] = {}
LOG_RETENTION_DAYS = 180
HIGH_RISK_LOG_RETENTION_DAYS = 365
COL_NAME = "\u59d3\u540d"
COL_PHONE = "\u624b\u673a\u53f7"
COL_PROJECT = "\u9879\u76ee\u4ee3\u7801"
COL_ISSUE_DATE = "\u53d1\u8bc1\u65e5\u671f"
COLS = [COL_NAME, COL_PHONE, COL_PROJECT, COL_ISSUE_DATE]
DISCLAIMER = "\u672c\u8bc1\u4e66\u4ec5\u7528\u4e8e\u8bc1\u660e\u5b66\u5458\u5b8c\u6210\u76f8\u5173\u57f9\u8bad\u8bfe\u7a0b/\u9636\u6bb5\u5b66\u4e60\uff0c\u4e0d\u4ee3\u8868\u56fd\u5bb6\u5b66\u5386\u3001\u5b66\u4f4d\u3001\u804c\u4e1a\u8d44\u683c\u6216\u804c\u4e1a\u6280\u80fd\u7b49\u7ea7\u8ba4\u8bc1\u3002"
ACTION_LABELS = {
"create_project": "\u65b0\u589e\u9879\u76ee",
"update_project": "\u4fee\u6539\u9879\u76ee",
"create_learner": "\u65b0\u589e\u5b66\u5458",
"update_learner": "\u4fee\u6539\u5b66\u5458",
"delete_learner": "\u5220\u9664\u5b66\u5458",
"create_certificate": "\u65b0\u589e\u8bc1\u4e66",
"void_certificate": "\u4f5c\u5e9f\u8bc1\u4e66",
"regenerate_public_token": "\u91cd\u7f6e\u8bc1\u4e66\u94fe\u63a5",
"upload_import_file": "\u4e0a\u4f20\u5bfc\u5165\u6587\u4ef6",
"confirm_import_batch": "\u786e\u8ba4\u5bfc\u5165",
"delete_import_batch": "\u5220\u9664\u5bfc\u5165\u6279\u6b21",
"public_search_certificate": "\u516c\u5f00\u67e5\u8be2\u8bc1\u4e66",
"public_download_certificate": "\u516c\u5f00\u4e0b\u8f7d\u8bc1\u4e66",
}
OBJECT_LABELS = {
"project": "\u9879\u76ee",
"project_course": "\u9879\u76ee",
"learner": "\u5b66\u5458",
"certificate": "\u8bc1\u4e66",
"import_batch": "\u5bfc\u5165\u6279\u6b21",
"public_access": "\u516c\u5f00\u8bbf\u95ee",
}
HIGH_RISK_ACTIONS = {"void_certificate", "delete_import_batch", "regenerate_public_token", "delete_learner"}
MEDIUM_RISK_ACTIONS = {"update_project", "update_learner", "confirm_import_batch", "create_certificate"}
def db() -> sqlite3.Connection:
DATA.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db() -> None:
with db() as conn:
conn.executescript(
"""
create table if not exists admins(id integer primary key, username text unique, password_hash text);
create table if not exists projects(
id integer primary key, code text unique, name text,
default_certificate_name text, default_course_name text, default_stage_name text, default_issuer_name text,
status text
);
create table if not exists learners(id integer primary key, phone text unique, current_name text, student_no text, status text, remark text, created_at text, updated_at text);
create table if not exists certificates(
id integer primary key, learner_id integer, project_code text, certificate_no text unique, certificate_name text,
course_name text, stage_name text, issue_date text, issuer_name text, status text, pdf_status text,
public_token text, qr_token text, remark text, created_at text, updated_at text
);
create table if not exists import_batches(id integer primary key, filename text, status text, total_rows integer, valid_rows integer, failed_rows integer, error_report_path text, created_at text, updated_at text);
create table if not exists logs(id integer primary key, admin_user_id integer, action text, object_type text, object_id text, detail_json text, created_at text);
"""
)
if not conn.execute("select 1 from admins where username='admin'").fetchone():
conn.execute("insert into admins(username,password_hash) values(?,?)", ("admin", password_hash("Admin123!")))
ensure_project_columns(conn)
for code, name in [("DBY", "\u5927\u672c\u8425"), ("QSX", "\u4e03\u4e09\u7ebf")]:
if not conn.execute("select 1 from projects where code=?", (code,)).fetchone():
conn.execute(
"insert into projects(code,name,default_certificate_name,default_course_name,default_stage_name,default_issuer_name,status) values(?,?,?,?,?,?,?)",
(code, name, name, name, None, "\u672c\u516c\u53f8", "active"),
)
conn.execute("update projects set default_certificate_name=name, default_course_name=coalesce(default_course_name,name), default_issuer_name=coalesce(default_issuer_name,?)", ("\u672c\u516c\u53f8",))
cleanup_expired_logs(conn)
def ensure_project_columns(conn: sqlite3.Connection) -> None:
columns = {row["name"] for row in conn.execute("pragma table_info(projects)").fetchall()}
for name in ["default_certificate_name", "default_course_name", "default_stage_name", "default_issuer_name"]:
if name not in columns:
conn.execute(f"alter table projects add column {name} text")
def password_hash(password: str) -> str:
salt = secrets.token_hex(8)
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 120_000).hex()
return f"{salt}${digest}"
def check_password(password: str, stored: str) -> bool:
salt, digest = stored.split("$", 1)
actual = hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), 120_000).hex()
return hmac.compare_digest(actual, digest)
def make_token(admin_id: int) -> str:
payload = f"{admin_id}:{int(time.time()) + 86400}:{secrets.token_hex(8)}"
sig = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()
return base64.urlsafe_b64encode(f"{payload}:{sig}".encode()).decode()
def token_admin_id(token: str) -> int | None:
try:
raw = base64.urlsafe_b64decode(token.encode()).decode()
admin_id, exp, nonce, sig = raw.split(":", 3)
payload = f"{admin_id}:{exp}:{nonce}"
expected = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()
if hmac.compare_digest(expected, sig) and int(exp) > time.time():
return int(admin_id)
except Exception:
return None
return None
def now() -> str:
return datetime.now().isoformat(timespec="seconds")
def log(conn: sqlite3.Connection, admin_id: int | None, action: str, object_type: str, object_id: object = None, detail: dict | None = None) -> None:
conn.execute(
"insert into logs(admin_user_id,action,object_type,object_id,detail_json,created_at) values(?,?,?,?,?,?)",
(admin_id, action, object_type, str(object_id) if object_id is not None else None, json.dumps(detail or {}, ensure_ascii=False), now()),
)
def mask_phone(phone: object) -> str:
raw = "".join(ch for ch in str(phone or "") if ch.isdigit())
if len(raw) >= 7:
return raw[:3] + "****" + raw[-4:]
return raw or "-"
def log_risk(action: str) -> str:
if action in HIGH_RISK_ACTIONS:
return "high"
if action in MEDIUM_RISK_ACTIONS:
return "medium"
return "low"
def log_risk_text(risk: str) -> str:
return {"high": "\u9ad8\u98ce\u9669", "medium": "\u4e2d\u98ce\u9669", "low": "\u4f4e\u98ce\u9669"}.get(risk, risk)
def diff_detail(before: sqlite3.Row | None, after: dict, fields: list[str]) -> dict[str, dict[str, object]]:
changes: dict[str, dict[str, object]] = {}
for field in fields:
old_value = before[field] if before else None
new_value = after.get(field, old_value)
if old_value != new_value:
changes[field] = {"before": old_value, "after": new_value}
return changes
def log_summary(action: str, object_type: str, object_id: object, detail: dict[str, object]) -> str:
label = ACTION_LABELS.get(action, action)
if action in {"create_project", "update_project"}:
return f"{label}\uff1a{detail.get('name') or detail.get('code') or object_id}"
if action in {"create_learner", "update_learner", "delete_learner"}:
phone = detail.get("phone")
return f"{label}\uff1a{detail.get('name') or object_id}" + (f"\uff08{phone}\uff09" if phone else "")
if action in {"create_certificate", "void_certificate", "regenerate_public_token"}:
learner_name = detail.get("learner_name")
return f"{label}\uff1a{detail.get('certificate_no') or object_id}" + (f"\uff08{learner_name}\uff09" if learner_name else "")
if action in {"upload_import_file", "delete_import_batch"}:
return f"{label}\uff1a{detail.get('filename') or object_id}"
if action == "confirm_import_batch":
count = detail.get("imported_rows", detail.get("valid_rows", 0))
return f"{label}\uff1a\u6279\u6b21 {object_id}\uff0c\u5bfc\u5165 {count} \u884c"
if action == "public_search_certificate":
mode = "\u8bc1\u4e66\u7f16\u53f7" if detail.get("mode") == "certificate_no" else "\u624b\u673a\u53f7"
return f"{label}\uff1a{detail.get('name') or '-'} \u7528{mode}\u67e5\u8be2\uff0c\u5339\u914d {detail.get('matched_count', 0)} \u6761"
if action == "public_download_certificate":
return f"{label}\uff1a{detail.get('certificate_no') or object_id}\uff08{detail.get('learner_name') or '-'}\uff09"
return f"{label}\uff1a{OBJECT_LABELS.get(object_type, object_type)} {object_id or ''}".strip()
def format_log_row(row: sqlite3.Row) -> dict:
detail: dict[str, object] = {}
if row["detail_json"]:
try:
detail = json.loads(row["detail_json"])
except Exception:
detail = {"raw": row["detail_json"]}
risk = log_risk(row["action"])
payload = row_dict(row)
payload.update({
"action_label": ACTION_LABELS.get(row["action"], row["action"]),
"object_label": OBJECT_LABELS.get(row["object_type"], row["object_type"]),
"risk": risk,
"risk_label": log_risk_text(risk),
"summary": log_summary(row["action"], row["object_type"], row["object_id"], detail),
})
return payload
def filtered_operation_logs(conn: sqlite3.Connection, query: dict[str, list[str]]) -> list[dict]:
action = (query.get("action") or [""])[0]
object_type = (query.get("object_type") or [""])[0]
risk = (query.get("risk") or [""])[0]
keyword = (query.get("keyword") or [""])[0].strip().lower()
items = [format_log_row(row) for row in conn.execute("select * from logs order by id desc limit 500").fetchall()]
if action:
items = [item for item in items if item["action"] == action]
if object_type:
items = [item for item in items if item["object_type"] == object_type]
if risk:
items = [item for item in items if item["risk"] == risk]
if keyword:
items = [
item for item in items
if keyword in " ".join(str(item.get(key, "")) for key in ["object_id", "summary", "detail_json", "action_label", "object_label"]).lower()
]
return items
def list_operation_logs(conn: sqlite3.Connection, query: dict[str, list[str]]) -> dict[str, object]:
items = filtered_operation_logs(conn, query)
page = max(1, int((query.get("page") or ["1"])[0] or "1"))
page_size = max(1, min(100, int((query.get("page_size") or ["20"])[0] or "20")))
total = len(items)
total_pages = max(1, (total + page_size - 1) // page_size)
page = min(page, total_pages)
start = (page - 1) * page_size
return {
"items": items[start:start + page_size],
"total": total,
"page": page,
"page_size": page_size,
"total_pages": total_pages,
}
def cleanup_expired_logs(conn: sqlite3.Connection) -> int:
normal_before = (datetime.now() - timedelta(days=LOG_RETENTION_DAYS)).isoformat(timespec="seconds")
high_before = (datetime.now() - timedelta(days=HIGH_RISK_LOG_RETENTION_DAYS)).isoformat(timespec="seconds")
high_actions = tuple(HIGH_RISK_ACTIONS)
normal_sql = f"delete from logs where created_at < ? and action not in ({','.join('?' for _ in high_actions)})"
cur = conn.execute(normal_sql, (normal_before, *high_actions))
removed = cur.rowcount if cur.rowcount != -1 else 0
high_sql = f"delete from logs where created_at < ? and action in ({','.join('?' for _ in high_actions)})"
cur = conn.execute(high_sql, (high_before, *high_actions))
removed += cur.rowcount if cur.rowcount != -1 else 0
return removed
def row_dict(row: sqlite3.Row | None) -> dict | None:
if row is None:
return None
return {key: row[key] for key in row.keys()}
def rows(conn: sqlite3.Connection, sql: str, args: tuple = ()) -> list[dict]:
return [row_dict(row) for row in conn.execute(sql, args).fetchall()]
def digest_int(text: str) -> int:
return int.from_bytes(hashlib.sha256(text.encode()).digest()[:8], "big")
def base_code(value: int, length: int) -> str:
chars = []
while value:
value, rem = divmod(value, len(ALPHABET))
chars.append(ALPHABET[rem])
return ("".join(reversed(chars)) or ALPHABET[0])[-length:].rjust(length, ALPHABET[0])
def certificate_no(cert_id: int, project: str, issue_date: str) -> str:
year = issue_date[:4] if issue_date else str(date.today().year)
seed = f"{SECRET}:{year}:{project}:{cert_id}"
short = base_code(digest_int(seed), 7)
check = base_code(digest_int(f"check:{seed}:{short}"), 2)
return f"PX{year}-{project}-{short}-{check}"
def json_bytes(value) -> bytes:
return json.dumps(value, ensure_ascii=False, default=str).encode("utf-8")
def make_captcha() -> dict:
code = "".join(secrets.choice("ABCDEFGHJKLMNPQRSTUVWXYZ23456789") for _ in range(4))
captcha_id = secrets.token_urlsafe(12)
CAPTCHAS[captcha_id] = (code, time.time() + 300)
image = Image.new("RGB", (132, 44), "#f8fafc")
draw = ImageDraw.Draw(image)
font = ImageFont.load_default()
for i, ch in enumerate(code):
draw.text((18 + i * 26, 14), ch, fill="#111827", font=font)
buf = io.BytesIO()
image.save(buf, "PNG")
return {"captcha_id": captcha_id, "image": "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()}
def verify_captcha(captcha_id: str, code: str) -> bool:
record = CAPTCHAS.pop(captcha_id, None)
return bool(record and record[1] > time.time() and record[0].upper() == code.strip().upper())
def parse_multipart(body: bytes, content_type: str) -> tuple[str, bytes]:
boundary = content_type.split("boundary=", 1)[1].encode()
parts = body.split(b"--" + boundary)
for part in parts:
if b"filename=" not in part:
continue
head, data = part.split(b"\r\n\r\n", 1)
filename = head.split(b'filename="', 1)[1].split(b'"', 1)[0].decode(errors="ignore")
return filename, data.rsplit(b"\r\n", 1)[0]
raise ValueError("file not found")
def public_payload(conn: sqlite3.Connection, cert: sqlite3.Row) -> dict:
learner = conn.execute("select * from learners where id=?", (cert["learner_id"],)).fetchone()
return {
"certificate_no": cert["certificate_no"],
"learner_name": learner["current_name"] if learner else "",
"certificate_name": cert["certificate_name"],
"project_code": cert["project_code"],
"course_name": cert["course_name"],
"stage_name": cert["stage_name"],
"issue_date": cert["issue_date"],
"issuer_name": cert["issuer_name"],
"status": cert["status"],
"pdf_status": cert["pdf_status"],
"public_token": cert["public_token"],
"public_url": f"/cert/{cert['public_token']}",
"download_url": f"/api/public/certificates/token/{cert['public_token']}/download" if cert["status"] == "valid" else None,
"can_download_pdf": cert["status"] == "valid",
"disclaimer": DISCLAIMER,
}
def font_name() -> str:
for path in [r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc"]:
if Path(path).exists():
try:
pdfmetrics.registerFont(TTFont("LocalCJK", path))
return "LocalCJK"
except Exception:
pass
return "Helvetica"
def pil_font(size: int, kind: str = "song", bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
choices = {
"kai": [r"C:\Windows\Fonts\simkai.ttf", r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc"],
"hei": [r"C:\Windows\Fonts\simhei.ttf", r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\msyh.ttc"],
"song": [r"C:\Windows\Fonts\msyh.ttc", r"C:\Windows\Fonts\simsun.ttc"],
}
paths = choices.get(kind, choices["song"])
if bold and kind == "song":
paths = [r"C:\Windows\Fonts\msyhbd.ttc", r"C:\Windows\Fonts\simhei.ttf"] + paths
for item in paths:
if Path(item).exists():
try:
return ImageFont.truetype(item, size)
except Exception:
pass
return ImageFont.load_default()
def date_parts(value: str) -> tuple[str, str, str]:
raw = str(value or "").strip()
for fmt in ("%Y-%m-%d", "%Y/%m/%d"):
try:
d = datetime.strptime(raw[:10], fmt)
return str(d.year), str(d.month), str(d.day)
except ValueError:
pass
if len(raw) >= 10 and raw[0:4].isdigit():
return raw[0:4], raw[5:7].lstrip("0") or raw[5:7], raw[8:10].lstrip("0") or raw[8:10]
today = date.today()
return str(today.year), str(today.month), str(today.day)
def draw_fit(draw: ImageDraw.ImageDraw, pos: tuple[int, int], text: str, font: ImageFont.ImageFont, fill: str, max_width: int) -> None:
font_obj = font
size = getattr(font, "size", 24)
while draw.textlength(text, font=font_obj) > max_width and size > 12:
size -= 1
font_obj = pil_font(size, "song", True)
draw.text(pos, text, fill=fill, font=font_obj)
def draw_inline(draw: ImageDraw.ImageDraw, x: float, y: int, text: str, size: int, gap: int, kind: str = "song", bold: bool = False) -> float:
font = pil_font(size, kind, bold)
draw.text((x, y), text, fill="#111111", font=font, anchor="ls")
if bold:
draw.text((x + 0.45, y), text, fill="#111111", font=font, anchor="ls")
return x + draw.textlength(text, font=font) + gap
def draw_course_name(draw: ImageDraw.ImageDraw, text: str, x: float, y: int) -> int:
max_width = 900 - x
text_font = pil_font(27, "kai", True)
if draw.textlength(text, font=text_font) <= max_width:
draw.text((x, y), text, fill="#111111", font=text_font)
draw.text((x + 0.45, y), text, fill="#111111", font=text_font)
return y
start_y = y + 34
lines = split_by_width(draw, text, text_font, 690)[:2]
for index, line in enumerate(lines):
line_y = start_y + index * 34
draw.text((185, line_y), line, fill="#111111", font=text_font)
draw.text((185.45, line_y), line, fill="#111111", font=text_font)
return start_y + (len(lines) - 1) * 34
def draw_inline_flow(draw: ImageDraw.ImageDraw, segments: list[tuple[str, str, int]], start_x: float, start_y: int) -> None:
x = start_x
y = start_y
line_start = 185
line_height = 42
for text, style, gap_before in segments:
x += gap_before
text_font = pil_font(27, "kai", True) if style == "course" else pil_font(24, "song", False)
for char in text:
max_right = 900 if y == start_y else 730
width = draw.textlength(char, font=text_font)
if x + width > max_right and x > line_start:
x = line_start
y += line_height
draw.text((x, y), char, fill="#111111", font=text_font, anchor="ls")
if style == "course":
draw.text((x + 0.45, y), char, fill="#111111", font=text_font, anchor="ls")
x += width
def split_by_width(draw: ImageDraw.ImageDraw, text: str, text_font: ImageFont.ImageFont, max_width: int) -> list[str]:
lines: list[str] = []
line = ""
for char in text:
next_line = line + char
if line and draw.textlength(next_line, font=text_font) > max_width:
lines.append(line)
line = char
else:
line = next_line
if line:
lines.append(line)
return lines
def paper_patch(image: Image.Image, box: tuple[int, int, int, int]) -> None:
x, y, w, h = box
patch = Image.new("RGB", (w, h), "#fbf7ef")
source = image.crop((72, max(0, y), 160, max(0, y) + h))
for tx in range(0, w, source.width):
patch.paste(source.crop((0, 0, min(source.width, w - tx), h)), (tx, 0))
cover = Image.new("RGB", (w, h), "#fbf7ef")
patch = Image.blend(patch, cover, 0.38)
image.paste(patch, (x, y))
def render_certificate_image(conn: sqlite3.Connection, cert: sqlite3.Row) -> Image.Image:
template = ROOT / "static" / "assets" / "certificate-template.jpg"
image = Image.open(template).convert("RGB")
base_image = image.copy()
draw = ImageDraw.Draw(image)
learner = conn.execute("select * from learners where id=?", (cert["learner_id"],)).fetchone()
learner_name = (learner["current_name"] if learner else "") or ""
issue_year, issue_month, issue_day = date_parts(cert["issue_date"])
for box in [(150, 442, 760, 132), (404, 675, 220, 38)]:
paper_patch(image, box)
draw.text((512, 414), learner_name, fill="#111111", font=pil_font(32, "kai", True), anchor="mm")
x = 185.0
y = 494
x = draw_inline(draw, x, y, "\u5728", 24, 18)
x = draw_inline(draw, x, y, issue_year, 24, 8, "kai", True)
x = draw_inline(draw, x, y, "\u5e74", 24, 12)
x = draw_inline(draw, x, y, issue_month, 24, 6, "kai", True)
x = draw_inline(draw, x, y, "\u6708", 24, 12)
x = draw_inline(draw, x, y, issue_day, 24, 4, "kai", True)
x = draw_inline(draw, x, y, "\u65e5\u81f3", 24, 12)
x = draw_inline(draw, x, y, issue_year, 24, 8, "kai", True)
x = draw_inline(draw, x, y, "\u5e74", 24, 12)
x = draw_inline(draw, x, y, issue_month, 24, 6, "kai", True)
x = draw_inline(draw, x, y, "\u6708", 24, 12)
x = draw_inline(draw, x, y, issue_day, 24, 4, "kai", True)
x = draw_inline(draw, x, y, "\u65e5\u5b8c\u6210\u4e86", 24, 8)
course_text = f"\u201c{cert['course_name'] or cert['certificate_name'] or cert['project_code']}\u201d"
stage = cert["stage_name"] or "\u521d\u7ea7\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002"
draw_inline_flow(draw, [(course_text, "course", 0), (stage, "normal", 14)], x, y)
issue_label = f"\u53d1\u8bc1\u65e5\u671f\uff1a{issue_year} \u5e74 {issue_month} \u6708 {issue_day} \u65e5"
draw.text((512, 704), issue_label, fill="#43382f", font=pil_font(13, "song", True), anchor="mm")
draw.text((105, 76), f"\u8bc1\u4e66\u7f16\u53f7\uff1a{cert['certificate_no']}", fill="#111827", font=pil_font(14, "hei", True))
image.paste(base_image.crop((720, 535, 950, 629)), (720, 535))
return image
def build_pdf(conn: sqlite3.Connection, cert: sqlite3.Row) -> Path:
pdf_dir = DATA / "pdf-cache"
pdf_dir.mkdir(exist_ok=True)
path = pdf_dir / f"{cert['certificate_no']}.pdf"
template = ROOT / "static" / "assets" / "certificate-template.jpg"
renderer_mtime = Path(__file__).stat().st_mtime
if path.exists() and path.stat().st_mtime >= max(template.stat().st_mtime, renderer_mtime):
return path
image = render_certificate_image(conn, cert)
image.save(path, "PDF", resolution=150.0)
conn.execute("update certificates set pdf_status=? where id=?", ("generated", cert["id"]))
return path
def validate_import(conn: sqlite3.Connection, file_path: Path, filename: str) -> dict:
wb = load_workbook(file_path, data_only=True)
ws = wb.active
header = [cell.value for cell in ws[1]]
valid_rows = []
failed = 0
for row in ws.iter_rows(min_row=2, values_only=True):
if not any(row):
continue
item = dict(zip(header, row))
missing = [c for c in COLS if not item.get(c)]
project = conn.execute("select 1 from projects where code=? and status='active'", (str(item.get(COL_PROJECT, "")).upper(),)).fetchone()
if missing or not project:
failed += 1
else:
valid_rows.append(item)
cur = conn.execute(
"insert into import_batches(filename,status,total_rows,valid_rows,failed_rows,error_report_path,created_at,updated_at) values(?,?,?,?,?,?,?,?)",
(filename, "validated", len(valid_rows) + failed, len(valid_rows), failed, None, now(), now()),
)
batch_id = cur.lastrowid
(DATA / f"batch-{batch_id}.json").write_text(json.dumps(valid_rows, ensure_ascii=False, default=str), encoding="utf-8")
return row_dict(conn.execute("select * from import_batches where id=?", (batch_id,)).fetchone())
def import_batch(conn: sqlite3.Connection, batch_id: int, admin_id: int):
batch = conn.execute("select * from import_batches where id=?", (batch_id,)).fetchone()
if not batch:
raise ValueError("Import batch not found")
if batch["status"] == "imported":
return row_dict(batch)
if batch["status"] != "validated":
raise ValueError("Import batch is not ready")
rows_data = json.loads((DATA / f"batch-{batch_id}.json").read_text(encoding="utf-8"))
ok_rows = 0
failed_rows = 0
for item in rows_data:
phone = str(item[COL_PHONE])
learner = conn.execute("select * from learners where phone=?", (phone,)).fetchone()
if learner:
learner_id = learner["id"]
conn.execute("update learners set current_name=?,updated_at=? where id=?", (item[COL_NAME], now(), learner_id))
else:
cur = conn.execute(
"insert into learners(phone,current_name,status,created_at,updated_at) values(?,?,?,?,?)",
(phone, item[COL_NAME], "active", now(), now()),
)
learner_id = cur.lastrowid
issue = str(item[COL_ISSUE_DATE])[:10].replace("/", "-")
project = conn.execute("select * from projects where code=? and status='active'", (str(item[COL_PROJECT]).upper(),)).fetchone()
if not project:
failed_rows += 1
continue
duplicate = conn.execute(
"""
select 1 from certificates
where learner_id=?
and project_code=?
and certificate_name=?
and coalesce(course_name,'')=?
and coalesce(stage_name,'')=?
and issue_date=?
""",
(
learner_id,
project["code"],
project["name"],
project["default_course_name"] or "",
project["default_stage_name"] or "",
issue,
),
).fetchone()
if duplicate:
ok_rows += 1
continue
cur = conn.execute(
"insert into certificates(learner_id,project_code,certificate_no,certificate_name,course_name,stage_name,issue_date,issuer_name,status,pdf_status,public_token,qr_token,remark,created_at,updated_at) values(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(learner_id, project["code"], "PENDING", project["name"], project["default_course_name"], project["default_stage_name"], issue, project["default_issuer_name"], "valid", "not_generated", secrets.token_urlsafe(24), secrets.token_urlsafe(24), None, now(), now()),
)
cert_id = cur.lastrowid
conn.execute("update certificates set certificate_no=? where id=?", (certificate_no(cert_id, str(item[COL_PROJECT]).upper(), issue), cert_id))
ok_rows += 1
next_status = "imported" if ok_rows else "failed"
conn.execute("update import_batches set status=?,failed_rows=?,updated_at=? where id=?", (next_status, failed_rows or batch["failed_rows"], now(), batch_id))
log(conn, admin_id, "confirm_import_batch", "import_batch", batch_id, {"filename": batch["filename"], "imported_rows": ok_rows, "failed_rows": failed_rows})
return row_dict(conn.execute("select * from import_batches where id=?", (batch_id,)).fetchone())
def export_certs(conn: sqlite3.Connection) -> bytes:
wb = Workbook()
ws = wb.active
ws.append([COL_NAME, COL_PHONE, "\u8bc1\u4e66\u7f16\u53f7", "\u8bc1\u4e66\u5bf9\u5916\u76f4\u8fbe\u94fe\u63a5", COL_PROJECT, "\u8bc1\u4e66\u540d\u79f0", COL_ISSUE_DATE, "\u8bc1\u4e66\u72b6\u6001"])
certs = conn.execute("select c.*,l.current_name,l.phone from certificates c join learners l on l.id=c.learner_id order by c.id desc").fetchall()
for row in certs:
phone = row["phone"] or ""
ws.append([row["current_name"], phone[:3] + "****" + phone[-4:] if len(phone) >= 7 else phone, row["certificate_no"], f"http://localhost:8000/cert/{row['public_token']}", row["project_code"], row["certificate_name"], row["issue_date"], row["status"]])
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def export_logs(items: list[dict]) -> bytes:
wb = Workbook()
ws = wb.active
ws.title = "\u64cd\u4f5c\u65e5\u5fd7"
ws.append(["\u65f6\u95f4", "\u64cd\u4f5c\u4ebaID", "\u64cd\u4f5c", "\u5bf9\u8c61", "\u5bf9\u8c61ID", "\u98ce\u9669\u7b49\u7ea7", "\u6458\u8981", "\u8be6\u60c5"])
for item in items:
ws.append([
item.get("created_at"),
item.get("admin_user_id"),
item.get("action_label"),
item.get("object_label"),
item.get("object_id"),
item.get("risk_label"),
item.get("summary"),
item.get("detail_json"),
])
for column in "ABCDEFGH":
ws.column_dimensions[column].width = 22 if column != "H" else 60
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.route()
def do_POST(self):
self.route()
def do_PUT(self):
self.route()
def do_DELETE(self):
self.route()
def route(self):
parsed = urlparse(self.path)
path = parsed.path
try:
if path.startswith("/api/"):
return self.handle_api(path, parse_qs(parsed.query))
if path in ["/", "/admin", "/admin/login", "/query"] or path.startswith("/cert/") or path.startswith("/verify/"):
return self.send_file(ROOT / "static" / "index.html", "text/html; charset=utf-8")
target = ROOT / "static" / path.lstrip("/")
if target.exists():
return self.send_file(target, "application/octet-stream")
return self.error(HTTPStatus.NOT_FOUND, "Not found")
except Exception as exc:
(DATA / "last-error.log").write_text(traceback.format_exc(), encoding="utf-8")
return self.error(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
def handle_api(self, path: str, query: dict):
body = self.read_body()
admin_id = self.admin_id()
with db() as conn:
if path == "/api/health":
return self.json({"status": "ok"})
if path == "/api/admin/auth/login":
data = json.loads(body or b"{}")
admin = conn.execute("select * from admins where username=?", (data.get("username"),)).fetchone()
if not admin or not check_password(data.get("password", ""), admin["password_hash"]):
return self.error(401, "\u8d26\u53f7\u6216\u5bc6\u7801\u9519\u8bef\uff0c\u8bf7\u91cd\u8bd5")
return self.json({"access_token": make_token(admin["id"]), "token_type": "bearer", "username": admin["username"], "role_code": "system_admin"})
if path == "/api/public/captcha":
return self.json(make_captcha())
if path == "/api/public/certificates/search":
data = json.loads(body or b"{}")
if not verify_captcha(data.get("captcha_id", ""), data.get("captcha_code", "")):
return self.error(400, "\u9a8c\u8bc1\u7801\u9519\u8bef\u6216\u5df2\u8fc7\u671f\uff0c\u8bf7\u91cd\u65b0\u8f93\u5165")
name = data.get("name", "").strip()
query_certificate_no = data.get("certificate_no", "").strip().upper()
phone = data.get("phone", "").strip()
if not name or (not query_certificate_no and not phone):
return self.error(400, "\u8bf7\u8f93\u5165\u59d3\u540d\uff0c\u5e76\u586b\u5199\u8bc1\u4e66\u7f16\u53f7\u6216\u624b\u673a\u53f7\u5176\u4e2d\u4e00\u9879")
if query_certificate_no:
result = rows(conn,
"select c.* from certificates c join learners l on l.id=c.learner_id where c.certificate_no=? and l.current_name=?",
(query_certificate_no, name),
)
else:
result = rows(conn,
"select c.* from certificates c join learners l on l.id=c.learner_id where l.phone=? and l.current_name=? order by c.issue_date desc,c.id desc",
(phone, name),
)
if not result:
log(conn, None, "public_search_certificate", "public_access", None, {"mode": "certificate_no" if query_certificate_no else "phone", "name": name, "certificate_no": query_certificate_no or None, "phone": mask_phone(phone), "matched_count": 0, "result": "not_found"})
return self.error(404, "\u672a\u67e5\u8be2\u5230\u5339\u914d\u7684\u6709\u6548\u8bc1\u4e66\u4fe1\u606f\uff0c\u8bf7\u6838\u5bf9\u540e\u91cd\u8bd5")
log(conn, None, "public_search_certificate", "public_access", result[0]["certificate_no"], {"mode": "certificate_no" if query_certificate_no else "phone", "name": name, "certificate_no": query_certificate_no or None, "phone": mask_phone(phone), "matched_count": len(result), "result": "matched"})
return self.json({"items": [public_payload(conn, item) for item in result]})
if path.startswith("/api/public/certificates/token/"):
token = unquote(path.split("/")[5])
cert = conn.execute("select * from certificates where public_token=? or qr_token=?", (token, token)).fetchone()
if not cert:
return self.error(404, "\u94fe\u63a5\u65e0\u6548\u6216\u5df2\u5931\u6548")
if path.endswith("/download"):
if cert["status"] != "valid":
return self.error(403, "\u8bc1\u4e66\u5df2\u4f5c\u5e9f\uff0c\u4e0d\u652f\u6301\u4e0b\u8f7d\u6b63\u5e38 PDF")
learner = conn.execute("select * from learners where id=?", (cert["learner_id"],)).fetchone()
log(conn, None, "public_download_certificate", "certificate", cert["id"], {"certificate_no": cert["certificate_no"], "learner_name": learner["current_name"] if learner else "", "project_code": cert["project_code"]})
return self.send_file(build_pdf(conn, cert), "application/pdf", f"{cert['certificate_no']}.pdf")
return self.json(public_payload(conn, cert))
if not admin_id:
return self.error(401, "Not authenticated")
if path == "/api/admin/dashboard/summary":
return self.json({
"learner_count": conn.execute("select count(*) from learners where status!='deleted'").fetchone()[0],
"certificate_count": conn.execute("select count(*) from certificates").fetchone()[0],
"valid_certificate_count": conn.execute("select count(*) from certificates where status='valid'").fetchone()[0],
"voided_certificate_count": conn.execute("select count(*) from certificates where status='voided'").fetchone()[0],
"recent_import_count": conn.execute("select count(*) from import_batches").fetchone()[0],
})
if path == "/api/admin/projects" and self.command == "GET":
return self.json(rows(conn, "select * from projects order by code"))
if path == "/api/admin/projects" and self.command == "POST":
data = json.loads(body)
conn.execute(
"insert into projects(code,name,default_certificate_name,default_course_name,default_stage_name,default_issuer_name,status) values(?,?,?,?,?,?,?)",
(
data["code"].strip().upper(),
data["name"].strip(),
data["name"].strip(),
data.get("default_course_name"),
data.get("default_stage_name"),
data.get("default_issuer_name") or "\u672c\u516c\u53f8",
"active",
),
)
log(conn, admin_id, "create_project", "project", data["code"], {"code": data["code"].strip().upper(), "name": data["name"].strip(), "status": "active"})
return self.json({"ok": True}, 201)
if path.startswith("/api/admin/projects/") and self.command == "PUT":
project_id = int(path.rsplit("/", 1)[1])
data = json.loads(body)
project = conn.execute("select * from projects where id=?", (project_id,)).fetchone()
if not project:
return self.error(404, "Project not found")
next_data = {
"name": data.get("name", project["name"]),
"default_course_name": data.get("default_course_name", project["default_course_name"]),
"default_stage_name": data.get("default_stage_name", project["default_stage_name"]),
"default_issuer_name": data.get("default_issuer_name", project["default_issuer_name"]),
"status": data.get("status", project["status"]),
}
conn.execute(
"update projects set name=?,default_certificate_name=?,default_course_name=?,default_stage_name=?,default_issuer_name=?,status=? where id=?",
(
next_data["name"],
next_data["name"],
next_data["default_course_name"],
next_data["default_stage_name"],
next_data["default_issuer_name"],
next_data["status"],
project_id,
),
)
log(conn, admin_id, "update_project", "project", project_id, {"code": project["code"], "name": next_data["name"], "changes": diff_detail(project, next_data, ["name", "default_course_name", "default_stage_name", "default_issuer_name", "status"])})
return self.json(row_dict(conn.execute("select * from projects where id=?", (project_id,)).fetchone()))
if path == "/api/admin/learners" and self.command == "GET":
keyword = (query.get("keyword") or [""])[0]
if keyword:
return self.json(rows(conn, "select * from learners where status!='deleted' and (current_name like ? or phone like ? or student_no like ?) order by id desc", (f"%{keyword}%", f"%{keyword}%", f"%{keyword}%")))
return self.json(rows(conn, "select * from learners where status!='deleted' order by id desc limit 200"))
if path == "/api/admin/learners" and self.command == "POST":
data = json.loads(body)
cur = conn.execute(
"insert into learners(phone,current_name,student_no,status,remark,created_at,updated_at) values(?,?,?,?,?,?,?)",
(data["phone"].strip(), data["current_name"].strip(), data.get("student_no"), "active", data.get("remark"), now(), now()),
)
log(conn, admin_id, "create_learner", "learner", cur.lastrowid, {"name": data["current_name"].strip(), "phone": mask_phone(data["phone"]), "status": "active"})
return self.json(row_dict(conn.execute("select * from learners where id=?", (cur.lastrowid,)).fetchone()), 201)
if path.startswith("/api/admin/learners/"):
learner_id = int(path.rsplit("/", 1)[1])
if self.command == "GET":
learner = row_dict(conn.execute("select * from learners where id=?", (learner_id,)).fetchone())
return self.json(learner) if learner else self.error(404, "Learner not found")
if self.command == "PUT":
data = json.loads(body)
learner = conn.execute("select * from learners where id=?", (learner_id,)).fetchone()
if not learner:
return self.error(404, "Learner not found")
next_data = {
"phone": data.get("phone", learner["phone"]),
"current_name": data.get("current_name", learner["current_name"]),
"student_no": data.get("student_no", learner["student_no"]),
"status": data.get("status", learner["status"]),
"remark": data.get("remark", learner["remark"]),
}
conn.execute(
"update learners set phone=?,current_name=?,student_no=?,status=?,remark=?,updated_at=? where id=?",
(next_data["phone"], next_data["current_name"], next_data["student_no"], next_data["status"], next_data["remark"], now(), learner_id),
)
changes = diff_detail(learner, next_data, ["phone", "current_name", "student_no", "status", "remark"])
if "phone" in changes:
changes["phone"] = {"before": mask_phone(changes["phone"]["before"]), "after": mask_phone(changes["phone"]["after"])}
log(conn, admin_id, "update_learner", "learner", learner_id, {"name": next_data["current_name"], "phone": mask_phone(next_data["phone"]), "changes": changes})
return self.json(row_dict(conn.execute("select * from learners where id=?", (learner_id,)).fetchone()))
if self.command == "DELETE":
learner = conn.execute("select * from learners where id=?", (learner_id,)).fetchone()
conn.execute("update learners set status='deleted',updated_at=? where id=?", (now(), learner_id))
log(conn, admin_id, "delete_learner", "learner", learner_id, {"name": learner["current_name"] if learner else learner_id, "phone": mask_phone(learner["phone"] if learner else "")})
return self.json({"ok": True})
if path == "/api/admin/certificates" and self.command == "GET":
return self.json(rows(conn, "select c.*,l.current_name learner_name from certificates c left join learners l on l.id=c.learner_id order by c.id desc limit 200"))
if path == "/api/admin/certificates" and self.command == "POST":
data = json.loads(body)
project = conn.execute("select * from projects where code=? and status='active'", (data["project_code"].strip().upper(),)).fetchone()
if not project:
return self.error(400, "Project code is inactive or missing")
cur = conn.execute(
"insert into certificates(learner_id,project_code,certificate_no,certificate_name,course_name,stage_name,issue_date,issuer_name,status,pdf_status,public_token,qr_token,remark,created_at,updated_at) values(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(data["learner_id"], project["code"], "PENDING", project["name"], data.get("course_name") or project["default_course_name"], data.get("stage_name") or project["default_stage_name"], data["issue_date"], data.get("issuer_name") or project["default_issuer_name"], "valid", "not_generated", secrets.token_urlsafe(24), secrets.token_urlsafe(24), data.get("remark"), now(), now()),
)
no = certificate_no(cur.lastrowid, project["code"], data["issue_date"])
conn.execute("update certificates set certificate_no=? where id=?", (no, cur.lastrowid))
learner = conn.execute("select * from learners where id=?", (data["learner_id"],)).fetchone()
log(conn, admin_id, "create_certificate", "certificate", cur.lastrowid, {"certificate_no": no, "learner_name": learner["current_name"] if learner else "", "project_code": project["code"], "issue_date": data["issue_date"]})
return self.json(row_dict(conn.execute("select * from certificates where id=?", (cur.lastrowid,)).fetchone()), 201)
if path.startswith("/api/admin/certificates/"):
parts = path.split("/")
cert_id = int(parts[4])
cert = conn.execute("select * from certificates where id=?", (cert_id,)).fetchone()
if not cert:
return self.error(404, "Certificate not found")
if self.command == "GET" and len(parts) == 5:
payload = row_dict(cert)
payload["public_url"] = f"http://127.0.0.1:8000/cert/{cert['public_token']}"
payload["verify_url"] = f"http://127.0.0.1:8000/verify/{cert['qr_token']}"
return self.json(payload)
if path.endswith("/preview"):
return self.json(public_payload(conn, cert))
if path.endswith("/download"):
if cert["status"] != "valid":
return self.error(403, "\u8bc1\u4e66\u5df2\u4f5c\u5e9f\uff0c\u4e0d\u652f\u6301\u4e0b\u8f7d\u6b63\u5e38 PDF")
return self.send_file(build_pdf(conn, cert), "application/pdf", f"{cert['certificate_no']}.pdf")
if path.endswith("/void"):
conn.execute("update certificates set status='voided',pdf_status='not_generated' where id=?", (cert_id,))
learner = conn.execute("select * from learners where id=?", (cert["learner_id"],)).fetchone()
log(conn, admin_id, "void_certificate", "certificate", cert_id, {"certificate_no": cert["certificate_no"], "learner_name": learner["current_name"] if learner else "", "previous_status": cert["status"], "status": "voided"})
return self.json(row_dict(conn.execute("select * from certificates where id=?", (cert_id,)).fetchone()))
if path.endswith("/token/regenerate"):
conn.execute("update certificates set public_token=? where id=?", (secrets.token_urlsafe(24), cert_id))
learner = conn.execute("select * from learners where id=?", (cert["learner_id"],)).fetchone()
log(conn, admin_id, "regenerate_public_token", "certificate", cert_id, {"certificate_no": cert["certificate_no"], "learner_name": learner["current_name"] if learner else ""})
return self.json(row_dict(conn.execute("select * from certificates where id=?", (cert_id,)).fetchone()))
if path == "/api/admin/import-batches/template":
wb = Workbook()
ws = wb.active
ws.append(COLS)
buf = io.BytesIO()
wb.save(buf)
return self.bytes(buf.getvalue(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "certificate-import-template.xlsx")
if path == "/api/admin/import-batches" and self.command == "GET":
return self.json(rows(conn, "select * from import_batches order by id desc"))
if path == "/api/admin/import-batches" and self.command == "POST":
filename, content = parse_multipart(body, self.headers.get("Content-Type", ""))
uploads = DATA / "uploads"
uploads.mkdir(exist_ok=True)
file_path = uploads / filename
file_path.write_bytes(content)
batch = validate_import(conn, file_path, filename)
log(conn, admin_id, "upload_import_file", "import_batch", batch["id"], {"filename": filename, "total_rows": batch["total_rows"], "valid_rows": batch["valid_rows"], "failed_rows": batch["failed_rows"], "status": batch["status"]})
return self.json(batch, 201)
if path.endswith("/confirm") and path.startswith("/api/admin/import-batches/"):
batch_id = int(path.split("/")[4])
return self.json(import_batch(conn, batch_id, admin_id))
if path.endswith("/file") and path.startswith("/api/admin/import-batches/"):
batch_id = int(path.split("/")[4])
batch = conn.execute("select * from import_batches where id=?", (batch_id,)).fetchone()
if not batch:
return self.error(404, "Import batch not found")
file_path = DATA / "uploads" / batch["filename"]
if not file_path.exists():
return self.error(404, "\u539f\u59cb\u6587\u4ef6\u4e0d\u5b58\u5728")
return self.send_file(file_path, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", batch["filename"])
if path.startswith("/api/admin/import-batches/") and self.command == "DELETE":
batch_id = int(path.rsplit("/", 1)[1])
batch = conn.execute("select * from import_batches where id=?", (batch_id,)).fetchone()
if not batch:
return self.error(404, "Import batch not found")
for file_path in [DATA / "uploads" / batch["filename"], DATA / f"batch-{batch_id}.json"]:
if file_path.exists():
file_path.unlink()
conn.execute("delete from import_batches where id=?", (batch_id,))
log(conn, admin_id, "delete_import_batch", "import_batch", batch_id, {"filename": batch["filename"], "status": batch["status"], "total_rows": batch["total_rows"]})
return self.json({"ok": True})
if path == "/api/admin/exports/certificates":
return self.bytes(export_certs(conn), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "certificates-export.xlsx")
if path == "/api/admin/logs/export":
return self.bytes(export_logs(filtered_operation_logs(conn, query)), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "operation-logs.xlsx")
if path == "/api/admin/logs/cleanup" and self.command == "POST":
removed = cleanup_expired_logs(conn)
return self.json({"removed": removed, "normal_retention_days": LOG_RETENTION_DAYS, "high_risk_retention_days": HIGH_RISK_LOG_RETENTION_DAYS})
if path == "/api/admin/logs":
cleanup_expired_logs(conn)
return self.json(list_operation_logs(conn, query))
return self.error(404, "Not found")
def admin_id(self) -> int | None:
auth = self.headers.get("Authorization", "")
return token_admin_id(auth[7:]) if auth.startswith("Bearer ") else None
def read_body(self) -> bytes:
return self.rfile.read(int(self.headers.get("Content-Length", "0") or "0"))
def json(self, value, status_code=200):
self.bytes(json_bytes(value), "application/json; charset=utf-8", status_code=status_code)
def bytes(self, data: bytes, content_type: str, filename: str | None = None, status_code=200):
self.send_response(status_code)
self.send_header("Content-Type", content_type)
self.send_header("Cache-Control", "no-store")
if filename:
self.send_header("Content-Disposition", f'attachment; filename="{filename}"')
self.end_headers()
self.wfile.write(data)
def send_file(self, path: Path, content_type: str, filename: str | None = None):
self.bytes(path.read_bytes(), content_type, filename)
def error(self, status_code, message):
self.bytes(json_bytes({"detail": str(message)}), "application/json; charset=utf-8", status_code=status_code)
def main():
init_db()
server = ThreadingHTTPServer(("127.0.0.1", 8000), Handler)
print("Local app running at http://127.0.0.1:8000")
print("Admin: admin / Admin123!")
server.serve_forever()
if __name__ == "__main__":
main()

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

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>