diff --git a/backend/alembic/versions/20260813_0007_certificate_templates.py b/backend/alembic/versions/20260813_0007_certificate_templates.py new file mode 100644 index 0000000..a39b4e7 --- /dev/null +++ b/backend/alembic/versions/20260813_0007_certificate_templates.py @@ -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") diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 9c210c1..d1ca378 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -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"]) diff --git a/backend/app/api/routes/admin_certificate_templates.py b/backend/app/api/routes/admin_certificate_templates.py new file mode 100644 index 0000000..99295bc --- /dev/null +++ b/backend/app/api/routes/admin_certificate_templates.py @@ -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") diff --git a/backend/app/api/routes/admin_certificates.py b/backend/app/api/routes/admin_certificates.py index 7afbfe3..ed51a5b 100644 --- a/backend/app/api/routes/admin_certificates.py +++ b/backend/app/api/routes/admin_certificates.py @@ -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 "", diff --git a/backend/app/api/routes/admin_exports.py b/backend/app/api/routes/admin_exports.py index 2884bf6..2267119 100644 --- a/backend/app/api/routes/admin_exports.py +++ b/backend/app/api/routes/admin_exports.py @@ -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, diff --git a/backend/app/api/routes/admin_imports.py b/backend/app/api/routes/admin_imports.py index af4f189..422e038 100644 --- a/backend/app/api/routes/admin_imports.py +++ b/backend/app/api/routes/admin_imports.py @@ -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() ) diff --git a/backend/app/api/routes/admin_projects.py b/backend/app/api/routes/admin_projects.py index 0aa72a0..fc49997 100644 --- a/backend/app/api/routes/admin_projects.py +++ b/backend/app/api/routes/admin_projects.py @@ -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()))}) diff --git a/backend/app/api/routes/public.py b/backend/app/api/routes/public.py index 2133adc..fd189f3 100644 --- a/backend/app/api/routes/public.py +++ b/backend/app/api/routes/public.py @@ -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, diff --git a/backend/app/assets/certificate-template-practice-camp.png b/backend/app/assets/certificate-template-practice-camp.png new file mode 100644 index 0000000..6b2e9ce Binary files /dev/null and b/backend/app/assets/certificate-template-practice-camp.png differ diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py index 4b8ed4b..5a60ce0 100644 --- a/backend/app/db/init_db.py +++ b/backend/app/db/init_db.py @@ -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", ) ) diff --git a/backend/app/models/certificate.py b/backend/app/models/certificate.py index 84fa984..d91255d 100644 --- a/backend/app/models/certificate.py +++ b/backend/app/models/certificate.py @@ -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) diff --git a/backend/app/models/import_batch.py b/backend/app/models/import_batch.py index 3676387..2e78a9b 100644 --- a/backend/app/models/import_batch.py +++ b/backend/app/models/import_batch.py @@ -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) diff --git a/backend/app/schemas/certificate.py b/backend/app/schemas/certificate.py index ed05d4d..6b1c66d 100644 --- a/backend/app/schemas/certificate.py +++ b/backend/app/schemas/certificate.py @@ -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 diff --git a/backend/app/schemas/certificate_template.py b/backend/app/schemas/certificate_template.py new file mode 100644 index 0000000..2ac3aa0 --- /dev/null +++ b/backend/app/schemas/certificate_template.py @@ -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 diff --git a/backend/app/schemas/import_batch.py b/backend/app/schemas/import_batch.py index 812ac65..8e77cb1 100644 --- a/backend/app/schemas/import_batch.py +++ b/backend/app/schemas/import_batch.py @@ -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 diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py index 8fd8527..3a9d9c0 100644 --- a/backend/app/schemas/project.py +++ b/backend/app/schemas/project.py @@ -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 diff --git a/backend/app/services/certificate_templates.py b/backend/app/services/certificate_templates.py new file mode 100644 index 0000000..6ec47a2 --- /dev/null +++ b/backend/app/services/certificate_templates.py @@ -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 diff --git a/backend/app/services/pdf.py b/backend/app/services/pdf.py index 07fd329..93ac01b 100644 --- a/backend/app/services/pdf.py +++ b/backend/app/services/pdf.py @@ -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": [ diff --git a/backend/docs/certificate-template-management.md b/backend/docs/certificate-template-management.md new file mode 100644 index 0000000..b4bca93 --- /dev/null +++ b/backend/docs/certificate-template-management.md @@ -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` 增加模板资源和渲染测试。 + +模板代码一旦用于正式证书,不应重命名或复用;需要大幅改版时应注册新的模板代码,以保证历史证书可以稳定重现。 diff --git a/backend/tests/test_certificate_templates.py b/backend/tests/test_certificate_templates.py new file mode 100644 index 0000000..486d942 --- /dev/null +++ b/backend/tests/test_certificate_templates.py @@ -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 == ("姓名", "课程开始日期", "课程结束日期", "发证日期") diff --git a/frontend/src/api.ts b/frontend/src/api.ts index b6b9240..c181048 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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; diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 03a1911..6363a1b 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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 }, diff --git a/frontend/src/views/AdminCertificates.vue b/frontend/src/views/AdminCertificates.vue index 58aec6e..1ffe238 100644 --- a/frontend/src/views/AdminCertificates.vue +++ b/frontend/src/views/AdminCertificates.vue @@ -5,41 +5,79 @@
支持单张新增、预览、下载、作废、链接重置和批量预生成PDF。
-