新增多证书模板管理流程

This commit is contained in:
Certificate System
2026-08-13 18:06:05 +08:00
parent eac19ea593
commit abbefc4b48
27 changed files with 774 additions and 61 deletions

View File

@@ -0,0 +1,39 @@
"""add certificate template selection
Revision ID: 20260813_0007
Revises: 20260813_0006
Create Date: 2026-08-13
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260813_0007"
down_revision: Union[str, None] = "20260813_0006"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"project_courses",
sa.Column("default_template_code", sa.String(length=32), nullable=False, server_default="classic"),
)
op.add_column(
"certificates",
sa.Column("template_code", sa.String(length=32), nullable=False, server_default="classic"),
)
op.create_index("ix_certificates_template_code", "certificates", ["template_code"])
op.add_column(
"import_batches",
sa.Column("template_code", sa.String(length=32), nullable=False, server_default="classic"),
)
def downgrade() -> None:
op.drop_column("import_batches", "template_code")
op.drop_index("ix_certificates_template_code", table_name="certificates")
op.drop_column("certificates", "template_code")
op.drop_column("project_courses", "default_template_code")

View File

@@ -3,6 +3,7 @@ from fastapi import APIRouter
from app.api.routes import (
admin_backups,
admin_certificates,
admin_certificate_templates,
admin_dashboard,
admin_exports,
admin_imports,
@@ -23,6 +24,7 @@ api_router.include_router(admin_projects.router, prefix="/admin/projects", tags=
api_router.include_router(admin_dashboard.router, prefix="/admin/dashboard", tags=["admin-dashboard"])
api_router.include_router(admin_learners.router, prefix="/admin/learners", tags=["admin-learners"])
api_router.include_router(admin_certificates.router, prefix="/admin/certificates", tags=["admin-certificates"])
api_router.include_router(admin_certificate_templates.router, prefix="/admin/certificate-templates", tags=["admin-certificate-templates"])
api_router.include_router(admin_imports.router, prefix="/admin/import-batches", tags=["admin-imports"])
api_router.include_router(admin_exports.router, prefix="/admin/exports", tags=["admin-exports"])
api_router.include_router(admin_logs.router, prefix="/admin/logs", tags=["admin-logs"])

View File

@@ -0,0 +1,25 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import FileResponse
from app.api.deps import require_roles
from app.models import AdminUser
from app.schemas.certificate_template import CertificateTemplateOut
from app.services.certificate_templates import get_certificate_template, list_certificate_templates
router = APIRouter()
@router.get("", response_model=list[CertificateTemplateOut])
def list_templates(
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> list[dict[str, object]]:
return [template.to_dict() for template in list_certificate_templates()]
@router.get("/{template_code}/preview")
def preview_template(template_code: str) -> FileResponse:
try:
template = get_certificate_template(template_code)
except (ValueError, FileNotFoundError) as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
return FileResponse(template.asset_path, media_type="image/png")

View File

@@ -13,6 +13,7 @@ from app.services.logs import log_action
from app.services.pdf import PdfGenerationBusy, render_certificate_pdf
from app.services.pdf_pregeneration import pdf_pregeneration_manager
from app.services.system_settings import get_pdf_generation_concurrency_limit
from app.services.certificate_templates import get_certificate_template
router = APIRouter()
@@ -73,6 +74,11 @@ def create_certificate(
project = db.query(ProjectCourse).filter(ProjectCourse.code == payload.project_code, ProjectCourse.status == "active").first()
if not project:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Project code is inactive or missing")
template_code = payload.template_code or project.default_template_code
try:
template_code = get_certificate_template(template_code).code
except (ValueError, FileNotFoundError) as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
certificate = Certificate(
learner_id=payload.learner_id,
@@ -86,6 +92,7 @@ def create_certificate(
course_end_date=payload.course_end_date,
issue_date=payload.issue_date,
issuer_name=project.default_issuer_name,
template_code=template_code,
remark=payload.remark,
)
db.add(certificate)
@@ -172,6 +179,7 @@ def _certificate_payload(certificate: Certificate, learner: Learner | None = Non
"course_end_date": certificate.course_end_date,
"issue_date": certificate.issue_date,
"issuer_name": certificate.issuer_name,
"template_code": certificate.template_code,
"status": certificate.status,
"pdf_status": certificate.pdf_status,
"created_at": certificate.created_at,
@@ -242,6 +250,7 @@ def preview_certificate(
"course_end_date": certificate.course_end_date.isoformat() if certificate.course_end_date else None,
"issue_date": certificate.issue_date.isoformat(),
"issuer_name": certificate.issuer_name,
"template_code": certificate.template_code,
"status": certificate.status,
"public_url": f"/cert/{public_token.token_value}" if public_token else "",
"verify_url": f"/verify/{qr_token.token_value}" if qr_token else "",

View File

@@ -41,6 +41,7 @@ def export_certificates(
"证书编号",
"证书对外直达链接",
"项目代码",
"证书模板",
"证书名称",
"课程名称",
"阶段名称",
@@ -59,6 +60,7 @@ def export_certificates(
certificate.certificate_no,
f"{settings.public_base_url}/cert/{token.token_value}",
certificate.project_code,
certificate.template_code,
certificate.certificate_name,
certificate.course_name,
certificate.stage_name,

View File

@@ -3,7 +3,7 @@ import shutil
from datetime import date, datetime
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from fastapi.responses import FileResponse, StreamingResponse
from openpyxl import Workbook, load_workbook
from openpyxl.comments import Comment
@@ -28,6 +28,7 @@ from app.models import (
from app.schemas.import_batch import ImportBatchOut
from app.services.certificate_number import build_certificate_no
from app.services.logs import log_action
from app.services.certificate_templates import get_certificate_template
router = APIRouter()
@@ -74,18 +75,23 @@ def build_import_template_workbook() -> Workbook:
@router.post("", response_model=ImportBatchOut, status_code=status.HTTP_201_CREATED)
def upload_import_file(
template_code: str = Form(...),
file: UploadFile = File(...),
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> ImportBatch:
if not file.filename or not file.filename.lower().endswith(".xlsx"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Only .xlsx files are supported")
try:
template_code = get_certificate_template(template_code).code
except (ValueError, FileNotFoundError) as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
upload_path = data_path("uploads") / file.filename
with upload_path.open("wb") as target:
shutil.copyfileobj(file.file, target)
batch = ImportBatch(filename=file.filename, file_path=str(upload_path), created_by=admin.id)
batch = ImportBatch(filename=file.filename, file_path=str(upload_path), template_code=template_code, created_by=admin.id)
db.add(batch)
db.flush()
validate_batch(db, batch, upload_path)
@@ -188,7 +194,15 @@ def confirm_import_batch(
row.error_message = f"Project code is inactive or missing: {project_code}"
failed_rows += 1
continue
duplicate = find_duplicate_certificate(db, learner.id, project, course_start_date, course_end_date, issue_date)
duplicate = find_duplicate_certificate(
db,
learner.id,
project,
course_start_date,
course_end_date,
issue_date,
batch.template_code,
)
if duplicate:
row.status = "skipped"
row.error_message = "\u5df2\u5b58\u5728\uff0c\u65e0\u9700\u5904\u7406"
@@ -207,6 +221,7 @@ def confirm_import_batch(
course_end_date=course_end_date,
issue_date=issue_date,
issuer_name=project.default_issuer_name,
template_code=batch.template_code,
remark=None,
)
db.add(certificate)
@@ -326,6 +341,7 @@ def find_duplicate_certificate(
course_start_date: date,
course_end_date: date,
issue_date: date,
template_code: str,
) -> Certificate | None:
return (
db.query(Certificate)
@@ -337,6 +353,7 @@ def find_duplicate_certificate(
.filter(Certificate.course_start_date == course_start_date)
.filter(Certificate.course_end_date == course_end_date)
.filter(Certificate.issue_date == issue_date)
.filter(Certificate.template_code == template_code)
.first()
)

View File

@@ -6,6 +6,7 @@ from app.db.session import get_db
from app.models import AdminUser, ProjectCourse
from app.schemas.project import ProjectCourseCreate, ProjectCourseOut, ProjectCourseUpdate
from app.services.logs import diff_values, log_action
from app.services.certificate_templates import get_certificate_template
router = APIRouter()
@@ -24,6 +25,10 @@ def create_project(
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> ProjectCourse:
try:
template_code = get_certificate_template(payload.default_template_code).code
except (ValueError, FileNotFoundError) as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
if db.query(ProjectCourse).filter(ProjectCourse.code == payload.code).first():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="项目代码已存在")
project = ProjectCourse(
@@ -33,6 +38,7 @@ def create_project(
default_course_name=payload.default_course_name,
default_stage_name=payload.default_stage_name,
default_issuer_name=payload.default_issuer_name,
default_template_code=template_code,
)
db.add(project)
log_action(db, admin, "create_project", "project_course", detail={"code": payload.code, "name": payload.name, "status": project.status})
@@ -56,6 +62,7 @@ def update_project(
"default_course_name": project.default_course_name,
"default_stage_name": project.default_stage_name,
"default_issuer_name": project.default_issuer_name,
"default_template_code": project.default_template_code,
"status": project.status,
}
if payload.name is not None:
@@ -67,6 +74,12 @@ def update_project(
project.default_stage_name = payload.default_stage_name
if payload.default_issuer_name is not None:
project.default_issuer_name = payload.default_issuer_name
if payload.default_template_code is not None:
try:
template_code = get_certificate_template(payload.default_template_code).code
except (ValueError, FileNotFoundError) as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
project.default_template_code = template_code
if payload.status is not None:
project.status = payload.status
after = {
@@ -74,6 +87,7 @@ def update_project(
"default_course_name": project.default_course_name,
"default_stage_name": project.default_stage_name,
"default_issuer_name": project.default_issuer_name,
"default_template_code": project.default_template_code,
"status": project.status,
}
log_action(db, admin, "update_project", "project_course", project.id, {"code": project.code, "name": project.name, "changes": diff_values(before, after, list(after.keys()))})

View File

@@ -180,6 +180,7 @@ def public_certificate_payload(db: Session, certificate: Certificate, learner: L
"course_end_date": certificate.course_end_date.isoformat() if certificate.course_end_date else None,
"issue_date": certificate.issue_date.isoformat(),
"issuer_name": certificate.issuer_name,
"template_code": certificate.template_code,
"status": certificate.status,
"pdf_status": certificate.pdf_status,
"public_token": token_value,

Binary file not shown.

After

Width:  |  Height:  |  Size: 699 KiB

View File

@@ -37,6 +37,7 @@ def seed_defaults(db: Session, admin_username: str, admin_password: str) -> None
default_course_name=name,
default_stage_name=None,
default_issuer_name="本公司",
default_template_code="classic",
)
)

View File

@@ -16,6 +16,7 @@ class ProjectCourse(Base):
default_course_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
default_stage_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
default_issuer_name: Mapped[str] = mapped_column(String(128), default="本公司")
default_template_code: Mapped[str] = mapped_column(String(32), default="classic")
status: Mapped[str] = mapped_column(String(32), default="active")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -37,6 +38,7 @@ class Certificate(Base):
course_end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
issue_date: Mapped[date] = mapped_column(Date)
issuer_name: Mapped[str] = mapped_column(String(128))
template_code: Mapped[str] = mapped_column(String(32), default="classic", index=True)
status: Mapped[str] = mapped_column(String(32), default="valid")
pdf_status: Mapped[str] = mapped_column(String(32), default="not_generated")
pdf_file_path: Mapped[str | None] = mapped_column(String(512), nullable=True)

View File

@@ -12,6 +12,7 @@ class ImportBatch(Base):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
filename: Mapped[str] = mapped_column(String(255))
file_path: Mapped[str] = mapped_column(String(512))
template_code: Mapped[str] = mapped_column(String(32), default="classic")
status: Mapped[str] = mapped_column(String(32), default="uploaded")
total_rows: Mapped[int] = mapped_column(Integer, default=0)
valid_rows: Mapped[int] = mapped_column(Integer, default=0)

View File

@@ -13,6 +13,7 @@ class CertificateCreate(BaseModel):
course_start_date: date
course_end_date: date
issue_date: date
template_code: str | None = Field(default=None, min_length=1, max_length=32)
remark: str | None = None
@field_validator("project_code")
@@ -44,6 +45,7 @@ class CertificateOut(BaseModel):
course_end_date: date | None
issue_date: date
issuer_name: str
template_code: str
status: str
pdf_status: str
created_at: datetime

View File

@@ -0,0 +1,10 @@
from pydantic import BaseModel
class CertificateTemplateOut(BaseModel):
code: str
name: str
description: str
dynamic_fields: list[str]
preview_url: str
status: str

View File

@@ -6,6 +6,7 @@ from pydantic import BaseModel
class ImportBatchOut(BaseModel):
id: int
filename: str
template_code: str
status: str
total_rows: int
valid_rows: int

View File

@@ -10,6 +10,7 @@ class ProjectCourseCreate(BaseModel):
default_course_name: str | None = Field(default=None, max_length=128)
default_stage_name: str | None = Field(default=None, max_length=128)
default_issuer_name: str = Field(default="本公司", min_length=1, max_length=128)
default_template_code: str = Field(default="classic", min_length=1, max_length=32)
@field_validator("code")
@classmethod
@@ -31,6 +32,7 @@ class ProjectCourseUpdate(BaseModel):
default_course_name: str | None = Field(default=None, max_length=128)
default_stage_name: str | None = Field(default=None, max_length=128)
default_issuer_name: str | None = Field(default=None, min_length=1, max_length=128)
default_template_code: str | None = Field(default=None, min_length=1, max_length=32)
status: str | None = Field(default=None, pattern="^(active|disabled)$")
@field_validator("default_certificate_name", "default_course_name", "default_stage_name")
@@ -50,6 +52,7 @@ class ProjectCourseOut(BaseModel):
default_course_name: str | None
default_stage_name: str | None
default_issuer_name: str
default_template_code: str
status: str
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,61 @@
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class CertificateTemplateDefinition:
code: str
name: str
description: str
asset_filename: str
dynamic_fields: tuple[str, ...]
status: str = "active"
@property
def asset_path(self) -> Path:
return Path(__file__).resolve().parents[1] / "assets" / self.asset_filename
def to_dict(self) -> dict[str, object]:
return {
"code": self.code,
"name": self.name,
"description": self.description,
"dynamic_fields": list(self.dynamic_fields),
"preview_url": f"/api/admin/certificate-templates/{self.code}/preview",
"status": self.status,
}
CLASSIC_TEMPLATE_CODE = "classic"
PRACTICE_CAMP_TEMPLATE_CODE = "practice-camp"
CERTIFICATE_TEMPLATES = {
CLASSIC_TEMPLATE_CODE: CertificateTemplateDefinition(
code=CLASSIC_TEMPLATE_CODE,
name="经典结业证书",
description="通用课程结业证书,正文包含课程名称、阶段、课程时间和发证日期。",
asset_filename="certificate-template.png",
dynamic_fields=("姓名", "课程名称", "阶段名称", "课程开始日期", "课程结束日期", "发证日期"),
),
PRACTICE_CAMP_TEMPLATE_CODE: CertificateTemplateDefinition(
code=PRACTICE_CAMP_TEMPLATE_CODE,
name="实修大本营结业证书",
description="人本智慧五个月线上实修大本营专用版,只填写姓名、课程开始日期、课程结束日期和发证日期。",
asset_filename="certificate-template-practice-camp.png",
dynamic_fields=("姓名", "课程开始日期", "课程结束日期", "发证日期"),
),
}
def list_certificate_templates() -> list[CertificateTemplateDefinition]:
return list(CERTIFICATE_TEMPLATES.values())
def get_certificate_template(code: str | None) -> CertificateTemplateDefinition:
normalized_code = (code or CLASSIC_TEMPLATE_CODE).strip().lower()
template = CERTIFICATE_TEMPLATES.get(normalized_code)
if not template or template.status != "active":
raise ValueError(f"未知或已停用的证书模板:{normalized_code}")
if not template.asset_path.exists():
raise FileNotFoundError(f"证书模板图片不存在:{template.asset_filename}")
return template

View File

@@ -8,6 +8,11 @@ from PIL import Image, ImageDraw, ImageFont
from app.core.config import settings
from app.core.paths import data_path
from app.models import Certificate, CertificateAccessToken, Learner, ProjectCourse
from app.services.certificate_templates import (
CLASSIC_TEMPLATE_CODE,
PRACTICE_CAMP_TEMPLATE_CODE,
get_certificate_template,
)
DESIGN_WIDTH = 1024
DESIGN_HEIGHT = 759
@@ -70,13 +75,8 @@ def mark_pdf_generated(certificate: Certificate, output_path: Path) -> None:
update_pdf_access_time(certificate)
def certificate_template_path() -> Path:
assets_dir = Path(__file__).resolve().parents[1] / "assets"
for name in ["certificate-template.png", "certificate-template.jpg"]:
path = assets_dir / name
if path.exists():
return path
raise FileNotFoundError("Certificate template image not found")
def certificate_template_path(template_code: str = CLASSIC_TEMPLATE_CODE) -> Path:
return get_certificate_template(template_code).asset_path
def pdf_resolution(image: Image.Image) -> float:
@@ -91,7 +91,8 @@ def render_certificate_pdf(
concurrency_limit: int = 2,
) -> Path:
output_path = pdf_cache_path(certificate.certificate_no)
template_path = certificate_template_path()
template_code = getattr(certificate, "template_code", CLASSIC_TEMPLATE_CODE)
template_path = certificate_template_path(template_code)
latest_renderer_mtime = max(template_path.stat().st_mtime, Path(__file__).stat().st_mtime)
if cached_pdf_is_fresh(output_path) and output_path.stat().st_mtime >= latest_renderer_mtime:
mark_pdf_generated(certificate, output_path)
@@ -112,7 +113,13 @@ def render_certificate_pdf(
def render_certificate_image(certificate: Certificate, learner: Learner, project: ProjectCourse | None) -> Image.Image:
template = certificate_template_path()
if getattr(certificate, "template_code", CLASSIC_TEMPLATE_CODE) == PRACTICE_CAMP_TEMPLATE_CODE:
return render_practice_camp_certificate_image(certificate, learner)
return render_classic_certificate_image(certificate, learner, project)
def render_classic_certificate_image(certificate: Certificate, learner: Learner, project: ProjectCourse | None) -> Image.Image:
template = certificate_template_path(CLASSIC_TEMPLATE_CODE)
image = Image.open(template).convert("RGB")
base_image = image.copy()
draw = ImageDraw.Draw(image)
@@ -174,6 +181,62 @@ def render_certificate_image(certificate: Certificate, learner: Learner, project
return image
def render_practice_camp_certificate_image(certificate: Certificate, learner: Learner) -> Image.Image:
design_width = 1854
design_height = 1359
image = Image.open(certificate_template_path(PRACTICE_CAMP_TEMPLATE_CODE)).convert("RGB")
draw = ImageDraw.Draw(image)
x_scale = image.width / design_width
y_scale = image.height / design_height
text_scale = (x_scale + y_scale) / 2
start_year, start_month, _ = date_parts(certificate.course_start_date or certificate.issue_date)
end_year, end_month, _ = date_parts(certificate.course_end_date or certificate.issue_date)
issue_year, issue_month, issue_day = date_parts(certificate.issue_date)
draw.text(
(568 * x_scale, 542 * y_scale),
learner.current_name,
fill="#202020",
font=font(42, "kai", True, text_scale),
anchor="ms",
)
for value, x in [
(start_year, 474),
(start_month, 630),
(end_year, 888),
(end_month, 1058),
]:
draw.text(
(x * x_scale, 668 * y_scale),
value,
fill="#202020",
font=font(41, "song", False, text_scale),
anchor="ms",
)
issue_date_text = f"{issue_year}{issue_month}{issue_day}"
issue_date_font = font(36, "song", False, text_scale)
issue_date_position = (568 * x_scale, 1158 * y_scale)
draw.rectangle(
(
442 * x_scale,
1118 * y_scale,
696 * x_scale,
1168 * y_scale,
),
fill="#ffffff",
)
draw.text(
issue_date_position,
issue_date_text,
fill="#202020",
font=issue_date_font,
anchor="ms",
)
return image
def font(size: int, kind: str = "song", bold: bool = False, scale: float = 1.0) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
choices = {
"kai": [

View File

@@ -0,0 +1,28 @@
# 证书模板管理说明
## 当前模板
系统内置两套模板:
- `classic`:经典结业证书,动态填写姓名、课程名称、阶段名称、课程起止日期和发证日期。
- `practice-camp`:实修大本营结业证书,只动态填写姓名、课程开始日期、课程结束日期和发证日期。
模板定义集中在 `app/services/certificate_templates.py`,底图位于 `app/assets/`,文字坐标和绘制逻辑位于 `app/services/pdf.py`
## 后台流程
1. 在“证书模板”查看可用模板和动态字段。
2. 在“项目管理”为项目配置默认模板。
3. 单张创建证书时,系统自动采用项目默认模板,管理员可以在提交前切换。
4. Excel 导入时先选择本批次模板,再上传文件;确认导入后整批证书固化为所选模板。
5. 证书创建后保存 `template_code`,以后修改项目默认模板不会改变历史证书。
## 增加新模板
1. 将清晰底图放入 `app/assets/`,不要在代码中引用工作区外部文件。
2.`certificate_templates.py` 注册模板代码、名称、底图文件和动态字段。
3.`pdf.py` 增加独立渲染函数,并由 `render_certificate_image` 按模板代码分发。
4. 使用真实姓名和日期生成 PDF再通过 `pdftoppm` 渲染为 PNG 检查位置、清晰度和文字遮挡。
5.`tests/test_certificate_templates.py` 增加模板资源和渲染测试。
模板代码一旦用于正式证书,不应重命名或复用;需要大幅改版时应注册新的模板代码,以保证历史证书可以稳定重现。

View File

@@ -0,0 +1,33 @@
from datetime import date
from types import SimpleNamespace
from app.services.certificate_templates import (
CLASSIC_TEMPLATE_CODE,
PRACTICE_CAMP_TEMPLATE_CODE,
get_certificate_template,
list_certificate_templates,
)
from app.services.pdf import render_certificate_image
def test_builtin_certificate_templates_have_assets():
templates = list_certificate_templates()
assert {item.code for item in templates} == {CLASSIC_TEMPLATE_CODE, PRACTICE_CAMP_TEMPLATE_CODE}
assert all(item.asset_path.exists() for item in templates)
def test_practice_camp_template_renders_four_dynamic_values():
certificate = SimpleNamespace(
template_code=PRACTICE_CAMP_TEMPLATE_CODE,
course_start_date=date(2026, 3, 1),
course_end_date=date(2026, 8, 31),
issue_date=date(2026, 9, 5),
)
learner = SimpleNamespace(current_name="张晓慧")
rendered = render_certificate_image(certificate, learner, None)
template = get_certificate_template(PRACTICE_CAMP_TEMPLATE_CODE)
assert rendered.size == (3437, 2551)
assert template.dynamic_fields == ("姓名", "课程开始日期", "课程结束日期", "发证日期")

View File

@@ -23,6 +23,7 @@ export interface PublicCertificate {
course_end_date: string | null;
issue_date: string;
issuer_name: string;
template_code: string;
status: string;
pdf_status: string;
public_token: string | null;
@@ -45,6 +46,7 @@ export interface ProjectCourse {
default_course_name: string | null;
default_stage_name: string | null;
default_issuer_name: string;
default_template_code: string;
status: string;
created_at: string;
updated_at: string;
@@ -76,6 +78,7 @@ export interface AdminCertificate {
course_end_date: string | null;
issue_date: string;
issuer_name: string;
template_code: string;
status: string;
pdf_status: string;
created_at: string;
@@ -85,6 +88,7 @@ export interface AdminCertificate {
export interface ImportBatch {
id: number;
filename: string;
template_code: string;
status: string;
total_rows: number;
valid_rows: number;
@@ -95,6 +99,15 @@ export interface ImportBatch {
updated_at: string;
}
export interface CertificateTemplate {
code: string;
name: string;
description: string;
dynamic_fields: string[];
preview_url: string;
status: string;
}
export interface OperationLog {
id: number;
admin_user_id: number | null;

View File

@@ -9,6 +9,7 @@ import AdminLogs from "../views/AdminLogs.vue";
import AdminLearners from "../views/AdminLearners.vue";
import AdminProjects from "../views/AdminProjects.vue";
import AdminSettings from "../views/AdminSettings.vue";
import AdminTemplates from "../views/AdminTemplates.vue";
import AdminShell from "../views/AdminShell.vue";
import CertificateQuery from "../views/CertificateQuery.vue";
import CertificateView from "../views/CertificateView.vue";
@@ -25,6 +26,7 @@ export const router = createRouter({
children: [
{ path: "", component: AdminHome },
{ path: "projects", component: AdminProjects },
{ path: "templates", component: AdminTemplates },
{ path: "learners", component: AdminLearners },
{ path: "certificates", component: AdminCertificates },
{ path: "imports", component: AdminImports },

View File

@@ -5,41 +5,79 @@
<h2>证书管理</h2>
<p>支持单张新增预览下载作废链接重置和批量预生成PDF</p>
</div>
<el-button type="primary" @click="createCertificate">新增证书</el-button>
<el-button type="primary" @click="openCreateDialog">创建证书</el-button>
</header>
<el-card class="panel" shadow="never">
<el-form class="form-grid" label-position="top">
<el-form-item label="学员ID">
<el-input v-model.number="form.learner_id" />
<el-dialog v-model="createVisible" title="创建证书" width="920px" destroy-on-close>
<el-form label-position="top">
<el-form-item label="证书模板" required>
<div class="template-choices">
<button
v-for="item in templates"
:key="item.code"
type="button"
class="template-choice"
:class="{ selected: form.template_code === item.code }"
@click="selectTemplate(item.code)"
>
<img :src="item.preview_url" :alt="item.name" />
<span>
<strong>{{ item.name }}</strong>
<small>{{ item.dynamic_fields.join("、") }}</small>
</span>
</button>
</div>
</el-form-item>
<el-form-item label="项目代码">
<el-select v-model="form.project_code" filterable>
<div class="create-grid">
<el-form-item label="学员" required>
<el-select v-model="form.learner_id" filterable placeholder="按姓名或手机号选择">
<el-option
v-for="item in activeLearners"
:key="item.id"
:label="`${item.current_name} ${item.phone}`"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="项目" required>
<el-select v-model="form.project_code" filterable placeholder="选择项目" @change="handleProjectChange">
<el-option v-for="item in activeProjects" :key="item.code" :label="`${item.code} ${item.name}`" :value="item.code" />
</el-select>
</el-form-item>
<el-form-item label="发证日期">
<el-date-picker v-model="form.issue_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="课程开始日期">
<el-form-item label="课程开始日期" required>
<el-date-picker v-model="form.course_start_date" type="date" value-format="YYYY-MM-DD" placeholder="选择开始日期" />
</el-form-item>
<el-form-item label="课程结束日期">
<el-form-item label="课程结束日期" required>
<el-date-picker v-model="form.course_end_date" type="date" value-format="YYYY-MM-DD" placeholder="选择结束日期" />
<div class="form-tip">证书正文将显示这段课程起止时间结束日期不能早于开始日期</div>
</el-form-item>
<el-form-item label="发证日期" required>
<el-date-picker v-model="form.issue_date" type="date" value-format="YYYY-MM-DD" placeholder="选择发证日期" />
</el-form-item>
<template v-if="form.template_code === 'classic'">
<el-form-item label="课程名称">
<el-input v-model.trim="form.course_name" />
<el-input v-model.trim="form.course_name" placeholder="留空使用项目默认值" />
</el-form-item>
<el-form-item label="阶段名称">
<el-input v-model.trim="form.stage_name" placeholder="如:全部、初级、中级" />
<div class="form-tip">证书上将显示为{输入内容}课程的专业学习留空则默认显示初级课程的专业学习</div>
<el-input v-model.trim="form.stage_name" placeholder="留空使用项目默认值" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model.trim="form.remark" />
</template>
<el-form-item label="内部备注">
<el-input v-model.trim="form.remark" placeholder="不会显示在证书上" />
</el-form-item>
</div>
<el-alert
v-if="selectedTemplate"
type="info"
:closable="false"
:title="`${selectedTemplate.name}将填写:${selectedTemplate.dynamic_fields.join('、')}`"
/>
</el-form>
</el-card>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="creating" @click="createCertificate">确认创建</el-button>
</template>
</el-dialog>
<el-card class="panel" shadow="never">
<div class="toolbar">
@@ -67,6 +105,9 @@
<el-table-column prop="certificate_no" label="证书编号" width="180" show-overflow-tooltip />
<el-table-column prop="learner_name" label="学员姓名" width="100" show-overflow-tooltip />
<el-table-column prop="project_code" label="项目" width="72" />
<el-table-column label="模板" width="150" show-overflow-tooltip>
<template #default="{ row }">{{ templateName(row.template_code) }}</template>
</el-table-column>
<el-table-column prop="certificate_name" label="证书名称" min-width="140" show-overflow-tooltip />
<el-table-column label="课程时间" width="190">
<template #default="{ row }">{{ coursePeriodText(row) }}</template>
@@ -103,6 +144,7 @@
<el-descriptions-item label="项目/课程/阶段">
{{ preview.project_code }} {{ preview.course_name || "" }} {{ preview.stage_name || "" }}
</el-descriptions-item>
<el-descriptions-item label="证书模板">{{ templateName(preview.template_code) }}</el-descriptions-item>
<el-descriptions-item label="课程时间">{{ coursePeriodText(preview) }}</el-descriptions-item>
<el-descriptions-item label="发证日期">{{ preview.issue_date }}</el-descriptions-item>
<el-descriptions-item label="发证单位">{{ preview.issuer_name }}</el-descriptions-item>
@@ -145,11 +187,21 @@
import { ElLoading, ElMessage, ElMessageBox } from "element-plus";
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
import { http, type AdminCertificate, type ImportBatch, type PdfPregenerationJob, type ProjectCourse } from "../api";
import {
http,
type AdminCertificate,
type CertificateTemplate,
type ImportBatch,
type Learner,
type PdfPregenerationJob,
type ProjectCourse,
} from "../api";
import { apiErrorMessage, downloadFile } from "../download";
const certificates = ref<AdminCertificate[]>([]);
const projects = ref<ProjectCourse[]>([]);
const templates = ref<CertificateTemplate[]>([]);
const learners = ref<Learner[]>([]);
const importBatches = ref<ImportBatch[]>([]);
const keyword = ref("");
const statusValue = ref("");
@@ -162,6 +214,9 @@ const selectedCertificates = ref<AdminCertificate[]>([]);
const pregenerationStarting = ref(false);
const pregenerationVisible = ref(false);
const pregenerationJob = ref<PdfPregenerationJob | null>(null);
const createVisible = ref(false);
const creating = ref(false);
const templateManuallySelected = ref(false);
let pregenerationTimer: number | null = null;
const form = reactive({
learner_id: undefined as number | undefined,
@@ -171,10 +226,13 @@ const form = reactive({
course_start_date: "",
course_end_date: "",
issue_date: "",
template_code: "classic",
remark: "",
});
const activeProjects = computed(() => projects.value.filter((item) => item.status === "active"));
const activeLearners = computed(() => learners.value.filter((item) => item.status === "active"));
const selectedTemplate = computed(() => templates.value.find((item) => item.code === form.template_code));
function statusText(status: string) {
return status === "valid" ? "有效" : "已作废";
@@ -190,6 +248,10 @@ function coursePeriodText(certificate: Pick<AdminCertificate, "course_start_date
return `${start}${end}`;
}
function templateName(code: string) {
return templates.value.find((item) => item.code === code)?.name || code;
}
const pregenerationDone = computed(() => ["completed", "completed_with_errors"].includes(pregenerationJob.value?.status || ""));
async function loadProjects() {
@@ -197,6 +259,16 @@ async function loadProjects() {
projects.value = data;
}
async function loadTemplates() {
const { data } = await http.get<CertificateTemplate[]>("/admin/certificate-templates");
templates.value = data;
}
async function loadLearners() {
const { data } = await http.get<Learner[]>("/admin/learners");
learners.value = data;
}
async function loadImportBatches() {
const { data } = await http.get<ImportBatch[]>("/admin/import-batches");
importBatches.value = data;
@@ -218,6 +290,36 @@ function handleSelectionChange(rows: AdminCertificate[]) {
selectedCertificates.value = rows;
}
function resetCreateForm() {
templateManuallySelected.value = false;
Object.assign(form, {
learner_id: undefined,
project_code: "",
course_name: "",
stage_name: "",
course_start_date: "",
course_end_date: "",
issue_date: "",
template_code: "classic",
remark: "",
});
}
function selectTemplate(templateCode: string) {
form.template_code = templateCode;
templateManuallySelected.value = true;
}
function openCreateDialog() {
resetCreateForm();
createVisible.value = true;
}
function handleProjectChange(projectCode: string) {
const project = projects.value.find((item) => item.code === projectCode);
if (project && !templateManuallySelected.value) form.template_code = project.default_template_code;
}
async function createCertificate() {
if (!form.learner_id || !form.project_code || !form.course_start_date || !form.course_end_date || !form.issue_date) {
ElMessage.warning("请填写学员ID、项目、课程起止日期和发证日期");
@@ -227,18 +329,25 @@ async function createCertificate() {
ElMessage.warning("课程结束日期不能早于课程开始日期");
return;
}
creating.value = true;
try {
await http.post("/admin/certificates", {
learner_id: form.learner_id,
project_code: form.project_code,
course_name: form.course_name || null,
stage_name: form.stage_name || null,
course_name: form.template_code === "classic" ? form.course_name || null : null,
stage_name: form.template_code === "classic" ? form.stage_name || null : null,
course_start_date: form.course_start_date,
course_end_date: form.course_end_date,
issue_date: form.issue_date,
template_code: form.template_code,
remark: form.remark || null,
});
ElMessage.success("证书已创建");
createVisible.value = false;
await loadCertificates();
} finally {
creating.value = false;
}
}
async function previewCertificate(row: AdminCertificate) {
@@ -328,7 +437,7 @@ function stopPregenerationPolling() {
}
onMounted(async () => {
await Promise.all([loadProjects(), loadImportBatches(), loadCertificates()]);
await Promise.all([loadProjects(), loadTemplates(), loadLearners(), loadImportBatches(), loadCertificates()]);
});
onUnmounted(stopPregenerationPolling);
@@ -360,6 +469,67 @@ onUnmounted(stopPregenerationPolling);
gap: 12px;
}
.template-choices {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
width: 100%;
}
.template-choice {
display: grid;
grid-template-columns: 148px minmax(0, 1fr);
align-items: center;
gap: 14px;
min-height: 112px;
padding: 10px;
border: 1px solid var(--app-border);
border-radius: 8px;
background: #fff;
color: var(--app-text);
text-align: left;
cursor: pointer;
}
.template-choice:hover,
.template-choice.selected {
border-color: var(--app-primary);
box-shadow: 0 0 0 3px rgba(22, 129, 126, 0.1);
}
.template-choice img {
width: 148px;
aspect-ratio: 1.36;
object-fit: contain;
border: 1px solid #edf2f4;
background: #f8fafb;
}
.template-choice span {
display: grid;
gap: 8px;
}
.template-choice strong {
font-size: 15px;
}
.template-choice small {
color: var(--app-muted);
line-height: 1.55;
}
.create-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0 14px;
}
.create-grid :deep(.el-select),
.create-grid :deep(.el-date-editor) {
width: 100%;
}
.toolbar {
justify-content: flex-start;
}
@@ -429,4 +599,11 @@ onUnmounted(stopPregenerationPolling);
padding-left: 18px;
color: #991b1b;
}
@media (max-width: 760px) {
.template-choices,
.create-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -15,6 +15,17 @@
title="课程开始日期、课程结束日期和发证日期均为必填项,请统一使用 YYYY-MM-DD 格式,例如 2026-06-01。"
/>
<div class="batch-template">
<div>
<strong>本批次证书模板</strong>
<span>确认导入后整批证书都会固化为所选模板</span>
</div>
<el-select v-model="templateCode" placeholder="选择证书模板">
<el-option v-for="item in templates" :key="item.code" :label="item.name" :value="item.code" />
</el-select>
<img v-if="selectedTemplate" :src="selectedTemplate.preview_url" :alt="selectedTemplate.name" />
</div>
<el-upload
class="upload"
drag
@@ -34,6 +45,9 @@
<el-table :data="batches" border>
<el-table-column prop="id" label="批次ID" width="90" />
<el-table-column prop="filename" label="文件名" min-width="220" />
<el-table-column label="证书模板" width="170">
<template #default="{ row }">{{ templateName(row.template_code) }}</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="{ row }">{{ statusText(row.status) }}</template>
</el-table-column>
@@ -99,10 +113,12 @@ import { UploadFilled } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox, type UploadFile } from "element-plus";
import { computed, onMounted, onUnmounted, ref } from "vue";
import { http, type ImportBatch, type PdfPregenerationJob } from "../api";
import { http, type CertificateTemplate, type ImportBatch, type PdfPregenerationJob } from "../api";
import { apiErrorMessage, downloadFile } from "../download";
const batches = ref<ImportBatch[]>([]);
const templates = ref<CertificateTemplate[]>([]);
const templateCode = ref("classic");
const selectedFile = ref<File | null>(null);
const uploading = ref(false);
const confirmingId = ref<number | null>(null);
@@ -112,6 +128,11 @@ const pregenerationJob = ref<PdfPregenerationJob | null>(null);
let pregenerationTimer: number | null = null;
const pregenerationDone = computed(() => ["completed", "completed_with_errors"].includes(pregenerationJob.value?.status || ""));
const selectedTemplate = computed(() => templates.value.find((item) => item.code === templateCode.value));
function templateName(code: string) {
return templates.value.find((item) => item.code === code)?.name || code;
}
function statusText(status: string) {
const labels: Record<string, string> = {
@@ -136,12 +157,18 @@ async function loadBatches() {
batches.value = data;
}
async function loadTemplates() {
const { data } = await http.get<CertificateTemplate[]>("/admin/certificate-templates");
templates.value = data;
}
async function uploadFile() {
if (!selectedFile.value) return;
uploading.value = true;
try {
const body = new FormData();
body.append("file", selectedFile.value);
body.append("template_code", templateCode.value);
await http.post("/admin/import-batches", body);
ElMessage.success("文件已上传并完成校验,请检查结果后点击确认导入");
selectedFile.value = null;
@@ -213,7 +240,7 @@ function stopPregenerationPolling() {
}
}
onMounted(loadBatches);
onMounted(() => Promise.all([loadBatches(), loadTemplates()]));
onUnmounted(stopPregenerationPolling);
</script>
@@ -235,6 +262,35 @@ onUnmounted(stopPregenerationPolling);
margin-bottom: 12px;
}
.batch-template {
display: grid;
grid-template-columns: minmax(260px, 1fr) 240px 180px;
align-items: center;
gap: 18px;
margin-top: 16px;
padding: 14px 16px;
border: 1px solid var(--app-border);
border-radius: 8px;
background: #fff;
}
.batch-template div {
display: grid;
gap: 5px;
}
.batch-template span {
color: var(--app-muted);
font-size: 13px;
}
.batch-template img {
width: 180px;
aspect-ratio: 1.36;
object-fit: contain;
border: 1px solid #edf2f4;
}
.import-tip {
margin-bottom: 4px;
}
@@ -289,4 +345,14 @@ onUnmounted(stopPregenerationPolling);
color: #b91c1c;
line-height: 1.8;
}
@media (max-width: 900px) {
.batch-template {
grid-template-columns: 1fr;
}
.batch-template img {
width: min(100%, 260px);
}
}
</style>

View File

@@ -19,6 +19,11 @@
<el-form-item label="默认发证单位">
<el-input v-model.trim="form.default_issuer_name" maxlength="128" />
</el-form-item>
<el-form-item label="默认证书模板">
<el-select v-model="form.default_template_code">
<el-option v-for="item in templates" :key="item.code" :label="item.name" :value="item.code" />
</el-select>
</el-form-item>
<el-form-item label="默认课程名称">
<el-input v-model.trim="form.default_course_name" maxlength="128" />
</el-form-item>
@@ -47,6 +52,9 @@
<el-table-column prop="default_course_name" label="课程名称" min-width="140" />
<el-table-column prop="default_stage_name" label="阶段名称" min-width="120" />
<el-table-column prop="default_issuer_name" label="发证单位" min-width="140" />
<el-table-column label="默认模板" min-width="150">
<template #default="{ row }">{{ templateName(row.default_template_code) }}</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'info'">
@@ -71,17 +79,19 @@
import { ElMessage } from "element-plus";
import { computed, onMounted, reactive, ref } from "vue";
import { http, type ProjectCourse } from "../api";
import { http, type CertificateTemplate, type ProjectCourse } from "../api";
const allProjects = ref<ProjectCourse[]>([]);
const keyword = ref("");
const editingId = ref<number | null>(null);
const templates = ref<CertificateTemplate[]>([]);
const form = reactive({
code: "",
name: "",
default_course_name: "",
default_stage_name: "",
default_issuer_name: "本公司",
default_template_code: "classic",
status: "active",
});
@@ -110,6 +120,15 @@ async function loadProjects() {
allProjects.value = data;
}
async function loadTemplates() {
const { data } = await http.get<CertificateTemplate[]>("/admin/certificate-templates");
templates.value = data;
}
function templateName(code: string) {
return templates.value.find((item) => item.code === code)?.name || code;
}
function editProject(row: ProjectCourse) {
editingId.value = row.id;
Object.assign(form, {
@@ -118,6 +137,7 @@ function editProject(row: ProjectCourse) {
default_course_name: row.default_course_name || "",
default_stage_name: row.default_stage_name || "",
default_issuer_name: row.default_issuer_name,
default_template_code: row.default_template_code,
status: row.status,
});
}
@@ -130,6 +150,7 @@ function resetForm() {
default_course_name: "",
default_stage_name: "",
default_issuer_name: "本公司",
default_template_code: "classic",
status: "active",
});
}
@@ -141,6 +162,7 @@ function projectPayload() {
default_course_name: form.default_course_name || null,
default_stage_name: form.default_stage_name || null,
default_issuer_name: form.default_issuer_name,
default_template_code: form.default_template_code,
status: form.status,
};
}
@@ -165,7 +187,7 @@ async function toggleStatus(row: ProjectCourse) {
await loadProjects();
}
onMounted(loadProjects);
onMounted(() => Promise.all([loadProjects(), loadTemplates()]));
</script>
<style scoped>

View File

@@ -8,6 +8,7 @@
<el-menu :default-active="route.path" router>
<el-menu-item index="/admin">数据概览</el-menu-item>
<el-menu-item index="/admin/projects">项目管理</el-menu-item>
<el-menu-item index="/admin/templates">证书模板</el-menu-item>
<el-menu-item index="/admin/learners">学员管理</el-menu-item>
<el-menu-item index="/admin/certificates">证书管理</el-menu-item>
<el-menu-item index="/admin/imports">Excel 导入</el-menu-item>

View File

@@ -0,0 +1,118 @@
<template>
<section>
<header class="page-head">
<div>
<h2>证书模板</h2>
<p>查看系统可用模板及其动态填写内容项目默认模板可在项目管理中设置</p>
</div>
</header>
<div class="template-list">
<article v-for="item in templates" :key="item.code" class="template-item">
<img :src="item.preview_url" :alt="item.name" />
<div class="template-content">
<div class="template-title">
<h3>{{ item.name }}</h3>
<el-tag type="success">启用</el-tag>
</div>
<p>{{ item.description }}</p>
<dl>
<dt>模板代码</dt>
<dd>{{ item.code }}</dd>
<dt>动态内容</dt>
<dd>
<el-tag v-for="field in item.dynamic_fields" :key="field" type="info">{{ field }}</el-tag>
</dd>
</dl>
</div>
</article>
</div>
</section>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { http, type CertificateTemplate } from "../api";
const templates = ref<CertificateTemplate[]>([]);
async function loadTemplates() {
const { data } = await http.get<CertificateTemplate[]>("/admin/certificate-templates");
templates.value = data;
}
onMounted(loadTemplates);
</script>
<style scoped>
.template-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}
.template-item {
overflow: hidden;
border: 1px solid var(--app-border);
border-radius: 8px;
background: #fff;
}
.template-item img {
display: block;
width: 100%;
aspect-ratio: 1.36;
object-fit: contain;
background: #f6f8f9;
border-bottom: 1px solid var(--app-border);
}
.template-content {
padding: 18px;
}
.template-title {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.template-title h3,
.template-content p,
.template-content dl {
margin: 0;
}
.template-content p {
min-height: 48px;
margin-top: 8px;
color: var(--app-muted);
line-height: 1.6;
}
.template-content dl {
display: grid;
grid-template-columns: 72px 1fr;
gap: 12px;
margin-top: 16px;
}
.template-content dt {
color: var(--app-muted);
}
.template-content dd {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 0;
}
@media (max-width: 1000px) {
.template-list {
grid-template-columns: 1fr;
}
}
</style>