Initial certificate system
This commit is contained in:
980
local_app/server.py
Normal file
980
local_app/server.py
Normal 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()
|
||||
BIN
local_app/static/assets/certificate-template.jpg
Normal file
BIN
local_app/static/assets/certificate-template.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
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