新增多证书模板管理流程
This commit is contained in:
@@ -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")
|
||||
@@ -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"])
|
||||
|
||||
25
backend/app/api/routes/admin_certificate_templates.py
Normal file
25
backend/app/api/routes/admin_certificate_templates.py
Normal 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")
|
||||
@@ -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 "",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
|
||||
@@ -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()))})
|
||||
|
||||
@@ -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,
|
||||
|
||||
BIN
backend/app/assets/certificate-template-practice-camp.png
Normal file
BIN
backend/app/assets/certificate-template-practice-camp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 699 KiB |
@@ -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",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
10
backend/app/schemas/certificate_template.py
Normal file
10
backend/app/schemas/certificate_template.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
61
backend/app/services/certificate_templates.py
Normal file
61
backend/app/services/certificate_templates.py
Normal 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
|
||||
@@ -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": [
|
||||
|
||||
28
backend/docs/certificate-template-management.md
Normal file
28
backend/docs/certificate-template-management.md
Normal 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` 增加模板资源和渲染测试。
|
||||
|
||||
模板代码一旦用于正式证书,不应重命名或复用;需要大幅改版时应注册新的模板代码,以保证历史证书可以稳定重现。
|
||||
33
backend/tests/test_certificate_templates.py
Normal file
33
backend/tests/test_certificate_templates.py
Normal 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 == ("姓名", "课程开始日期", "课程结束日期", "发证日期")
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
118
frontend/src/views/AdminTemplates.vue
Normal file
118
frontend/src/views/AdminTemplates.vue
Normal 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>
|
||||
Reference in New Issue
Block a user