新增多证书模板管理流程
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 (
|
from app.api.routes import (
|
||||||
admin_backups,
|
admin_backups,
|
||||||
admin_certificates,
|
admin_certificates,
|
||||||
|
admin_certificate_templates,
|
||||||
admin_dashboard,
|
admin_dashboard,
|
||||||
admin_exports,
|
admin_exports,
|
||||||
admin_imports,
|
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_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_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_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_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_exports.router, prefix="/admin/exports", tags=["admin-exports"])
|
||||||
api_router.include_router(admin_logs.router, prefix="/admin/logs", tags=["admin-logs"])
|
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 import PdfGenerationBusy, render_certificate_pdf
|
||||||
from app.services.pdf_pregeneration import pdf_pregeneration_manager
|
from app.services.pdf_pregeneration import pdf_pregeneration_manager
|
||||||
from app.services.system_settings import get_pdf_generation_concurrency_limit
|
from app.services.system_settings import get_pdf_generation_concurrency_limit
|
||||||
|
from app.services.certificate_templates import get_certificate_template
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -73,6 +74,11 @@ def create_certificate(
|
|||||||
project = db.query(ProjectCourse).filter(ProjectCourse.code == payload.project_code, ProjectCourse.status == "active").first()
|
project = db.query(ProjectCourse).filter(ProjectCourse.code == payload.project_code, ProjectCourse.status == "active").first()
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Project code is inactive or missing")
|
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(
|
certificate = Certificate(
|
||||||
learner_id=payload.learner_id,
|
learner_id=payload.learner_id,
|
||||||
@@ -86,6 +92,7 @@ def create_certificate(
|
|||||||
course_end_date=payload.course_end_date,
|
course_end_date=payload.course_end_date,
|
||||||
issue_date=payload.issue_date,
|
issue_date=payload.issue_date,
|
||||||
issuer_name=project.default_issuer_name,
|
issuer_name=project.default_issuer_name,
|
||||||
|
template_code=template_code,
|
||||||
remark=payload.remark,
|
remark=payload.remark,
|
||||||
)
|
)
|
||||||
db.add(certificate)
|
db.add(certificate)
|
||||||
@@ -172,6 +179,7 @@ def _certificate_payload(certificate: Certificate, learner: Learner | None = Non
|
|||||||
"course_end_date": certificate.course_end_date,
|
"course_end_date": certificate.course_end_date,
|
||||||
"issue_date": certificate.issue_date,
|
"issue_date": certificate.issue_date,
|
||||||
"issuer_name": certificate.issuer_name,
|
"issuer_name": certificate.issuer_name,
|
||||||
|
"template_code": certificate.template_code,
|
||||||
"status": certificate.status,
|
"status": certificate.status,
|
||||||
"pdf_status": certificate.pdf_status,
|
"pdf_status": certificate.pdf_status,
|
||||||
"created_at": certificate.created_at,
|
"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,
|
"course_end_date": certificate.course_end_date.isoformat() if certificate.course_end_date else None,
|
||||||
"issue_date": certificate.issue_date.isoformat(),
|
"issue_date": certificate.issue_date.isoformat(),
|
||||||
"issuer_name": certificate.issuer_name,
|
"issuer_name": certificate.issuer_name,
|
||||||
|
"template_code": certificate.template_code,
|
||||||
"status": certificate.status,
|
"status": certificate.status,
|
||||||
"public_url": f"/cert/{public_token.token_value}" if public_token else "",
|
"public_url": f"/cert/{public_token.token_value}" if public_token else "",
|
||||||
"verify_url": f"/verify/{qr_token.token_value}" if qr_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,
|
certificate.certificate_no,
|
||||||
f"{settings.public_base_url}/cert/{token.token_value}",
|
f"{settings.public_base_url}/cert/{token.token_value}",
|
||||||
certificate.project_code,
|
certificate.project_code,
|
||||||
|
certificate.template_code,
|
||||||
certificate.certificate_name,
|
certificate.certificate_name,
|
||||||
certificate.course_name,
|
certificate.course_name,
|
||||||
certificate.stage_name,
|
certificate.stage_name,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import shutil
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from pathlib import Path
|
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 fastapi.responses import FileResponse, StreamingResponse
|
||||||
from openpyxl import Workbook, load_workbook
|
from openpyxl import Workbook, load_workbook
|
||||||
from openpyxl.comments import Comment
|
from openpyxl.comments import Comment
|
||||||
@@ -28,6 +28,7 @@ from app.models import (
|
|||||||
from app.schemas.import_batch import ImportBatchOut
|
from app.schemas.import_batch import ImportBatchOut
|
||||||
from app.services.certificate_number import build_certificate_no
|
from app.services.certificate_number import build_certificate_no
|
||||||
from app.services.logs import log_action
|
from app.services.logs import log_action
|
||||||
|
from app.services.certificate_templates import get_certificate_template
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -74,18 +75,23 @@ def build_import_template_workbook() -> Workbook:
|
|||||||
|
|
||||||
@router.post("", response_model=ImportBatchOut, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=ImportBatchOut, status_code=status.HTTP_201_CREATED)
|
||||||
def upload_import_file(
|
def upload_import_file(
|
||||||
|
template_code: str = Form(...),
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
|
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
|
||||||
) -> ImportBatch:
|
) -> ImportBatch:
|
||||||
if not file.filename or not file.filename.lower().endswith(".xlsx"):
|
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")
|
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
|
upload_path = data_path("uploads") / file.filename
|
||||||
with upload_path.open("wb") as target:
|
with upload_path.open("wb") as target:
|
||||||
shutil.copyfileobj(file.file, 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.add(batch)
|
||||||
db.flush()
|
db.flush()
|
||||||
validate_batch(db, batch, upload_path)
|
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}"
|
row.error_message = f"Project code is inactive or missing: {project_code}"
|
||||||
failed_rows += 1
|
failed_rows += 1
|
||||||
continue
|
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:
|
if duplicate:
|
||||||
row.status = "skipped"
|
row.status = "skipped"
|
||||||
row.error_message = "\u5df2\u5b58\u5728\uff0c\u65e0\u9700\u5904\u7406"
|
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,
|
course_end_date=course_end_date,
|
||||||
issue_date=issue_date,
|
issue_date=issue_date,
|
||||||
issuer_name=project.default_issuer_name,
|
issuer_name=project.default_issuer_name,
|
||||||
|
template_code=batch.template_code,
|
||||||
remark=None,
|
remark=None,
|
||||||
)
|
)
|
||||||
db.add(certificate)
|
db.add(certificate)
|
||||||
@@ -326,6 +341,7 @@ def find_duplicate_certificate(
|
|||||||
course_start_date: date,
|
course_start_date: date,
|
||||||
course_end_date: date,
|
course_end_date: date,
|
||||||
issue_date: date,
|
issue_date: date,
|
||||||
|
template_code: str,
|
||||||
) -> Certificate | None:
|
) -> Certificate | None:
|
||||||
return (
|
return (
|
||||||
db.query(Certificate)
|
db.query(Certificate)
|
||||||
@@ -337,6 +353,7 @@ def find_duplicate_certificate(
|
|||||||
.filter(Certificate.course_start_date == course_start_date)
|
.filter(Certificate.course_start_date == course_start_date)
|
||||||
.filter(Certificate.course_end_date == course_end_date)
|
.filter(Certificate.course_end_date == course_end_date)
|
||||||
.filter(Certificate.issue_date == issue_date)
|
.filter(Certificate.issue_date == issue_date)
|
||||||
|
.filter(Certificate.template_code == template_code)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from app.db.session import get_db
|
|||||||
from app.models import AdminUser, ProjectCourse
|
from app.models import AdminUser, ProjectCourse
|
||||||
from app.schemas.project import ProjectCourseCreate, ProjectCourseOut, ProjectCourseUpdate
|
from app.schemas.project import ProjectCourseCreate, ProjectCourseOut, ProjectCourseUpdate
|
||||||
from app.services.logs import diff_values, log_action
|
from app.services.logs import diff_values, log_action
|
||||||
|
from app.services.certificate_templates import get_certificate_template
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -24,6 +25,10 @@ def create_project(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
|
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
|
||||||
) -> ProjectCourse:
|
) -> 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():
|
if db.query(ProjectCourse).filter(ProjectCourse.code == payload.code).first():
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="项目代码已存在")
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="项目代码已存在")
|
||||||
project = ProjectCourse(
|
project = ProjectCourse(
|
||||||
@@ -33,6 +38,7 @@ def create_project(
|
|||||||
default_course_name=payload.default_course_name,
|
default_course_name=payload.default_course_name,
|
||||||
default_stage_name=payload.default_stage_name,
|
default_stage_name=payload.default_stage_name,
|
||||||
default_issuer_name=payload.default_issuer_name,
|
default_issuer_name=payload.default_issuer_name,
|
||||||
|
default_template_code=template_code,
|
||||||
)
|
)
|
||||||
db.add(project)
|
db.add(project)
|
||||||
log_action(db, admin, "create_project", "project_course", detail={"code": payload.code, "name": payload.name, "status": project.status})
|
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_course_name": project.default_course_name,
|
||||||
"default_stage_name": project.default_stage_name,
|
"default_stage_name": project.default_stage_name,
|
||||||
"default_issuer_name": project.default_issuer_name,
|
"default_issuer_name": project.default_issuer_name,
|
||||||
|
"default_template_code": project.default_template_code,
|
||||||
"status": project.status,
|
"status": project.status,
|
||||||
}
|
}
|
||||||
if payload.name is not None:
|
if payload.name is not None:
|
||||||
@@ -67,6 +74,12 @@ def update_project(
|
|||||||
project.default_stage_name = payload.default_stage_name
|
project.default_stage_name = payload.default_stage_name
|
||||||
if payload.default_issuer_name is not None:
|
if payload.default_issuer_name is not None:
|
||||||
project.default_issuer_name = payload.default_issuer_name
|
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:
|
if payload.status is not None:
|
||||||
project.status = payload.status
|
project.status = payload.status
|
||||||
after = {
|
after = {
|
||||||
@@ -74,6 +87,7 @@ def update_project(
|
|||||||
"default_course_name": project.default_course_name,
|
"default_course_name": project.default_course_name,
|
||||||
"default_stage_name": project.default_stage_name,
|
"default_stage_name": project.default_stage_name,
|
||||||
"default_issuer_name": project.default_issuer_name,
|
"default_issuer_name": project.default_issuer_name,
|
||||||
|
"default_template_code": project.default_template_code,
|
||||||
"status": project.status,
|
"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()))})
|
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,
|
"course_end_date": certificate.course_end_date.isoformat() if certificate.course_end_date else None,
|
||||||
"issue_date": certificate.issue_date.isoformat(),
|
"issue_date": certificate.issue_date.isoformat(),
|
||||||
"issuer_name": certificate.issuer_name,
|
"issuer_name": certificate.issuer_name,
|
||||||
|
"template_code": certificate.template_code,
|
||||||
"status": certificate.status,
|
"status": certificate.status,
|
||||||
"pdf_status": certificate.pdf_status,
|
"pdf_status": certificate.pdf_status,
|
||||||
"public_token": token_value,
|
"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_course_name=name,
|
||||||
default_stage_name=None,
|
default_stage_name=None,
|
||||||
default_issuer_name="本公司",
|
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_course_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
default_stage_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_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")
|
status: Mapped[str] = mapped_column(String(32), default="active")
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=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)
|
course_end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
issue_date: Mapped[date] = mapped_column(Date)
|
issue_date: Mapped[date] = mapped_column(Date)
|
||||||
issuer_name: Mapped[str] = mapped_column(String(128))
|
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")
|
status: Mapped[str] = mapped_column(String(32), default="valid")
|
||||||
pdf_status: Mapped[str] = mapped_column(String(32), default="not_generated")
|
pdf_status: Mapped[str] = mapped_column(String(32), default="not_generated")
|
||||||
pdf_file_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
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)
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
filename: Mapped[str] = mapped_column(String(255))
|
filename: Mapped[str] = mapped_column(String(255))
|
||||||
file_path: Mapped[str] = mapped_column(String(512))
|
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")
|
status: Mapped[str] = mapped_column(String(32), default="uploaded")
|
||||||
total_rows: Mapped[int] = mapped_column(Integer, default=0)
|
total_rows: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
valid_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_start_date: date
|
||||||
course_end_date: date
|
course_end_date: date
|
||||||
issue_date: date
|
issue_date: date
|
||||||
|
template_code: str | None = Field(default=None, min_length=1, max_length=32)
|
||||||
remark: str | None = None
|
remark: str | None = None
|
||||||
|
|
||||||
@field_validator("project_code")
|
@field_validator("project_code")
|
||||||
@@ -44,6 +45,7 @@ class CertificateOut(BaseModel):
|
|||||||
course_end_date: date | None
|
course_end_date: date | None
|
||||||
issue_date: date
|
issue_date: date
|
||||||
issuer_name: str
|
issuer_name: str
|
||||||
|
template_code: str
|
||||||
status: str
|
status: str
|
||||||
pdf_status: str
|
pdf_status: str
|
||||||
created_at: datetime
|
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):
|
class ImportBatchOut(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
filename: str
|
filename: str
|
||||||
|
template_code: str
|
||||||
status: str
|
status: str
|
||||||
total_rows: int
|
total_rows: int
|
||||||
valid_rows: int
|
valid_rows: int
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class ProjectCourseCreate(BaseModel):
|
|||||||
default_course_name: str | None = Field(default=None, max_length=128)
|
default_course_name: str | None = Field(default=None, max_length=128)
|
||||||
default_stage_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_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")
|
@field_validator("code")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -31,6 +32,7 @@ class ProjectCourseUpdate(BaseModel):
|
|||||||
default_course_name: str | None = Field(default=None, max_length=128)
|
default_course_name: str | None = Field(default=None, max_length=128)
|
||||||
default_stage_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_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)$")
|
status: str | None = Field(default=None, pattern="^(active|disabled)$")
|
||||||
|
|
||||||
@field_validator("default_certificate_name", "default_course_name", "default_stage_name")
|
@field_validator("default_certificate_name", "default_course_name", "default_stage_name")
|
||||||
@@ -50,6 +52,7 @@ class ProjectCourseOut(BaseModel):
|
|||||||
default_course_name: str | None
|
default_course_name: str | None
|
||||||
default_stage_name: str | None
|
default_stage_name: str | None
|
||||||
default_issuer_name: str
|
default_issuer_name: str
|
||||||
|
default_template_code: str
|
||||||
status: str
|
status: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_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.config import settings
|
||||||
from app.core.paths import data_path
|
from app.core.paths import data_path
|
||||||
from app.models import Certificate, CertificateAccessToken, Learner, ProjectCourse
|
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_WIDTH = 1024
|
||||||
DESIGN_HEIGHT = 759
|
DESIGN_HEIGHT = 759
|
||||||
@@ -70,13 +75,8 @@ def mark_pdf_generated(certificate: Certificate, output_path: Path) -> None:
|
|||||||
update_pdf_access_time(certificate)
|
update_pdf_access_time(certificate)
|
||||||
|
|
||||||
|
|
||||||
def certificate_template_path() -> Path:
|
def certificate_template_path(template_code: str = CLASSIC_TEMPLATE_CODE) -> Path:
|
||||||
assets_dir = Path(__file__).resolve().parents[1] / "assets"
|
return get_certificate_template(template_code).asset_path
|
||||||
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 pdf_resolution(image: Image.Image) -> float:
|
def pdf_resolution(image: Image.Image) -> float:
|
||||||
@@ -91,7 +91,8 @@ def render_certificate_pdf(
|
|||||||
concurrency_limit: int = 2,
|
concurrency_limit: int = 2,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
output_path = pdf_cache_path(certificate.certificate_no)
|
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)
|
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:
|
if cached_pdf_is_fresh(output_path) and output_path.stat().st_mtime >= latest_renderer_mtime:
|
||||||
mark_pdf_generated(certificate, output_path)
|
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:
|
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")
|
image = Image.open(template).convert("RGB")
|
||||||
base_image = image.copy()
|
base_image = image.copy()
|
||||||
draw = ImageDraw.Draw(image)
|
draw = ImageDraw.Draw(image)
|
||||||
@@ -174,6 +181,62 @@ def render_certificate_image(certificate: Certificate, learner: Learner, project
|
|||||||
return image
|
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:
|
def font(size: int, kind: str = "song", bold: bool = False, scale: float = 1.0) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||||
choices = {
|
choices = {
|
||||||
"kai": [
|
"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;
|
course_end_date: string | null;
|
||||||
issue_date: string;
|
issue_date: string;
|
||||||
issuer_name: string;
|
issuer_name: string;
|
||||||
|
template_code: string;
|
||||||
status: string;
|
status: string;
|
||||||
pdf_status: string;
|
pdf_status: string;
|
||||||
public_token: string | null;
|
public_token: string | null;
|
||||||
@@ -45,6 +46,7 @@ export interface ProjectCourse {
|
|||||||
default_course_name: string | null;
|
default_course_name: string | null;
|
||||||
default_stage_name: string | null;
|
default_stage_name: string | null;
|
||||||
default_issuer_name: string;
|
default_issuer_name: string;
|
||||||
|
default_template_code: string;
|
||||||
status: string;
|
status: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -76,6 +78,7 @@ export interface AdminCertificate {
|
|||||||
course_end_date: string | null;
|
course_end_date: string | null;
|
||||||
issue_date: string;
|
issue_date: string;
|
||||||
issuer_name: string;
|
issuer_name: string;
|
||||||
|
template_code: string;
|
||||||
status: string;
|
status: string;
|
||||||
pdf_status: string;
|
pdf_status: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -85,6 +88,7 @@ export interface AdminCertificate {
|
|||||||
export interface ImportBatch {
|
export interface ImportBatch {
|
||||||
id: number;
|
id: number;
|
||||||
filename: string;
|
filename: string;
|
||||||
|
template_code: string;
|
||||||
status: string;
|
status: string;
|
||||||
total_rows: number;
|
total_rows: number;
|
||||||
valid_rows: number;
|
valid_rows: number;
|
||||||
@@ -95,6 +99,15 @@ export interface ImportBatch {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CertificateTemplate {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
dynamic_fields: string[];
|
||||||
|
preview_url: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OperationLog {
|
export interface OperationLog {
|
||||||
id: number;
|
id: number;
|
||||||
admin_user_id: number | null;
|
admin_user_id: number | null;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import AdminLogs from "../views/AdminLogs.vue";
|
|||||||
import AdminLearners from "../views/AdminLearners.vue";
|
import AdminLearners from "../views/AdminLearners.vue";
|
||||||
import AdminProjects from "../views/AdminProjects.vue";
|
import AdminProjects from "../views/AdminProjects.vue";
|
||||||
import AdminSettings from "../views/AdminSettings.vue";
|
import AdminSettings from "../views/AdminSettings.vue";
|
||||||
|
import AdminTemplates from "../views/AdminTemplates.vue";
|
||||||
import AdminShell from "../views/AdminShell.vue";
|
import AdminShell from "../views/AdminShell.vue";
|
||||||
import CertificateQuery from "../views/CertificateQuery.vue";
|
import CertificateQuery from "../views/CertificateQuery.vue";
|
||||||
import CertificateView from "../views/CertificateView.vue";
|
import CertificateView from "../views/CertificateView.vue";
|
||||||
@@ -25,6 +26,7 @@ export const router = createRouter({
|
|||||||
children: [
|
children: [
|
||||||
{ path: "", component: AdminHome },
|
{ path: "", component: AdminHome },
|
||||||
{ path: "projects", component: AdminProjects },
|
{ path: "projects", component: AdminProjects },
|
||||||
|
{ path: "templates", component: AdminTemplates },
|
||||||
{ path: "learners", component: AdminLearners },
|
{ path: "learners", component: AdminLearners },
|
||||||
{ path: "certificates", component: AdminCertificates },
|
{ path: "certificates", component: AdminCertificates },
|
||||||
{ path: "imports", component: AdminImports },
|
{ path: "imports", component: AdminImports },
|
||||||
|
|||||||
@@ -5,41 +5,79 @@
|
|||||||
<h2>证书管理</h2>
|
<h2>证书管理</h2>
|
||||||
<p>支持单张新增、预览、下载、作废、链接重置和批量预生成PDF。</p>
|
<p>支持单张新增、预览、下载、作废、链接重置和批量预生成PDF。</p>
|
||||||
</div>
|
</div>
|
||||||
<el-button type="primary" @click="createCertificate">新增证书</el-button>
|
<el-button type="primary" @click="openCreateDialog">创建证书</el-button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<el-card class="panel" shadow="never">
|
<el-dialog v-model="createVisible" title="创建证书" width="920px" destroy-on-close>
|
||||||
<el-form class="form-grid" label-position="top">
|
<el-form label-position="top">
|
||||||
<el-form-item label="学员ID">
|
<el-form-item label="证书模板" required>
|
||||||
<el-input v-model.number="form.learner_id" />
|
<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>
|
||||||
<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-option v-for="item in activeProjects" :key="item.code" :label="`${item.code} ${item.name}`" :value="item.code" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="发证日期">
|
<el-form-item label="课程开始日期" required>
|
||||||
<el-date-picker v-model="form.issue_date" type="date" value-format="YYYY-MM-DD" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="课程开始日期">
|
|
||||||
<el-date-picker v-model="form.course_start_date" type="date" value-format="YYYY-MM-DD" placeholder="选择开始日期" />
|
<el-date-picker v-model="form.course_start_date" type="date" value-format="YYYY-MM-DD" placeholder="选择开始日期" />
|
||||||
</el-form-item>
|
</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="选择结束日期" />
|
<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>
|
||||||
|
<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-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>
|
||||||
<el-form-item label="阶段名称">
|
<el-form-item label="阶段名称">
|
||||||
<el-input v-model.trim="form.stage_name" placeholder="如:全部、初级、中级" />
|
<el-input v-model.trim="form.stage_name" placeholder="留空使用项目默认值" />
|
||||||
<div class="form-tip">证书上将显示为「{输入内容}课程的专业学习」,留空则默认显示「初级课程的专业学习」</div>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="备注">
|
</template>
|
||||||
<el-input v-model.trim="form.remark" />
|
<el-form-item label="内部备注">
|
||||||
|
<el-input v-model.trim="form.remark" placeholder="不会显示在证书上" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
<el-alert
|
||||||
|
v-if="selectedTemplate"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
:title="`${selectedTemplate.name}将填写:${selectedTemplate.dynamic_fields.join('、')}`"
|
||||||
|
/>
|
||||||
</el-form>
|
</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">
|
<el-card class="panel" shadow="never">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
@@ -67,6 +105,9 @@
|
|||||||
<el-table-column prop="certificate_no" label="证书编号" width="180" show-overflow-tooltip />
|
<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="learner_name" label="学员姓名" width="100" show-overflow-tooltip />
|
||||||
<el-table-column prop="project_code" label="项目" width="72" />
|
<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 prop="certificate_name" label="证书名称" min-width="140" show-overflow-tooltip />
|
||||||
<el-table-column label="课程时间" width="190">
|
<el-table-column label="课程时间" width="190">
|
||||||
<template #default="{ row }">{{ coursePeriodText(row) }}</template>
|
<template #default="{ row }">{{ coursePeriodText(row) }}</template>
|
||||||
@@ -103,6 +144,7 @@
|
|||||||
<el-descriptions-item label="项目/课程/阶段">
|
<el-descriptions-item label="项目/课程/阶段">
|
||||||
{{ preview.project_code }} {{ preview.course_name || "" }} {{ preview.stage_name || "" }}
|
{{ preview.project_code }} {{ preview.course_name || "" }} {{ preview.stage_name || "" }}
|
||||||
</el-descriptions-item>
|
</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="课程时间">{{ coursePeriodText(preview) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="发证日期">{{ preview.issue_date }}</el-descriptions-item>
|
<el-descriptions-item label="发证日期">{{ preview.issue_date }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="发证单位">{{ preview.issuer_name }}</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 { ElLoading, ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
|
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";
|
import { apiErrorMessage, downloadFile } from "../download";
|
||||||
|
|
||||||
const certificates = ref<AdminCertificate[]>([]);
|
const certificates = ref<AdminCertificate[]>([]);
|
||||||
const projects = ref<ProjectCourse[]>([]);
|
const projects = ref<ProjectCourse[]>([]);
|
||||||
|
const templates = ref<CertificateTemplate[]>([]);
|
||||||
|
const learners = ref<Learner[]>([]);
|
||||||
const importBatches = ref<ImportBatch[]>([]);
|
const importBatches = ref<ImportBatch[]>([]);
|
||||||
const keyword = ref("");
|
const keyword = ref("");
|
||||||
const statusValue = ref("");
|
const statusValue = ref("");
|
||||||
@@ -162,6 +214,9 @@ const selectedCertificates = ref<AdminCertificate[]>([]);
|
|||||||
const pregenerationStarting = ref(false);
|
const pregenerationStarting = ref(false);
|
||||||
const pregenerationVisible = ref(false);
|
const pregenerationVisible = ref(false);
|
||||||
const pregenerationJob = ref<PdfPregenerationJob | null>(null);
|
const pregenerationJob = ref<PdfPregenerationJob | null>(null);
|
||||||
|
const createVisible = ref(false);
|
||||||
|
const creating = ref(false);
|
||||||
|
const templateManuallySelected = ref(false);
|
||||||
let pregenerationTimer: number | null = null;
|
let pregenerationTimer: number | null = null;
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
learner_id: undefined as number | undefined,
|
learner_id: undefined as number | undefined,
|
||||||
@@ -171,10 +226,13 @@ const form = reactive({
|
|||||||
course_start_date: "",
|
course_start_date: "",
|
||||||
course_end_date: "",
|
course_end_date: "",
|
||||||
issue_date: "",
|
issue_date: "",
|
||||||
|
template_code: "classic",
|
||||||
remark: "",
|
remark: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeProjects = computed(() => projects.value.filter((item) => item.status === "active"));
|
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) {
|
function statusText(status: string) {
|
||||||
return status === "valid" ? "有效" : "已作废";
|
return status === "valid" ? "有效" : "已作废";
|
||||||
@@ -190,6 +248,10 @@ function coursePeriodText(certificate: Pick<AdminCertificate, "course_start_date
|
|||||||
return `${start} 至 ${end}`;
|
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 || ""));
|
const pregenerationDone = computed(() => ["completed", "completed_with_errors"].includes(pregenerationJob.value?.status || ""));
|
||||||
|
|
||||||
async function loadProjects() {
|
async function loadProjects() {
|
||||||
@@ -197,6 +259,16 @@ async function loadProjects() {
|
|||||||
projects.value = data;
|
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() {
|
async function loadImportBatches() {
|
||||||
const { data } = await http.get<ImportBatch[]>("/admin/import-batches");
|
const { data } = await http.get<ImportBatch[]>("/admin/import-batches");
|
||||||
importBatches.value = data;
|
importBatches.value = data;
|
||||||
@@ -218,6 +290,36 @@ function handleSelectionChange(rows: AdminCertificate[]) {
|
|||||||
selectedCertificates.value = rows;
|
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() {
|
async function createCertificate() {
|
||||||
if (!form.learner_id || !form.project_code || !form.course_start_date || !form.course_end_date || !form.issue_date) {
|
if (!form.learner_id || !form.project_code || !form.course_start_date || !form.course_end_date || !form.issue_date) {
|
||||||
ElMessage.warning("请填写学员ID、项目、课程起止日期和发证日期");
|
ElMessage.warning("请填写学员ID、项目、课程起止日期和发证日期");
|
||||||
@@ -227,18 +329,25 @@ async function createCertificate() {
|
|||||||
ElMessage.warning("课程结束日期不能早于课程开始日期");
|
ElMessage.warning("课程结束日期不能早于课程开始日期");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
creating.value = true;
|
||||||
|
try {
|
||||||
await http.post("/admin/certificates", {
|
await http.post("/admin/certificates", {
|
||||||
learner_id: form.learner_id,
|
learner_id: form.learner_id,
|
||||||
project_code: form.project_code,
|
project_code: form.project_code,
|
||||||
course_name: form.course_name || null,
|
course_name: form.template_code === "classic" ? form.course_name || null : null,
|
||||||
stage_name: form.stage_name || null,
|
stage_name: form.template_code === "classic" ? form.stage_name || null : null,
|
||||||
course_start_date: form.course_start_date,
|
course_start_date: form.course_start_date,
|
||||||
course_end_date: form.course_end_date,
|
course_end_date: form.course_end_date,
|
||||||
issue_date: form.issue_date,
|
issue_date: form.issue_date,
|
||||||
|
template_code: form.template_code,
|
||||||
remark: form.remark || null,
|
remark: form.remark || null,
|
||||||
});
|
});
|
||||||
ElMessage.success("证书已创建");
|
ElMessage.success("证书已创建");
|
||||||
|
createVisible.value = false;
|
||||||
await loadCertificates();
|
await loadCertificates();
|
||||||
|
} finally {
|
||||||
|
creating.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function previewCertificate(row: AdminCertificate) {
|
async function previewCertificate(row: AdminCertificate) {
|
||||||
@@ -328,7 +437,7 @@ function stopPregenerationPolling() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadProjects(), loadImportBatches(), loadCertificates()]);
|
await Promise.all([loadProjects(), loadTemplates(), loadLearners(), loadImportBatches(), loadCertificates()]);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(stopPregenerationPolling);
|
onUnmounted(stopPregenerationPolling);
|
||||||
@@ -360,6 +469,67 @@ onUnmounted(stopPregenerationPolling);
|
|||||||
gap: 12px;
|
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 {
|
.toolbar {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
@@ -429,4 +599,11 @@ onUnmounted(stopPregenerationPolling);
|
|||||||
padding-left: 18px;
|
padding-left: 18px;
|
||||||
color: #991b1b;
|
color: #991b1b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.template-choices,
|
||||||
|
.create-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -15,6 +15,17 @@
|
|||||||
title="课程开始日期、课程结束日期和发证日期均为必填项,请统一使用 YYYY-MM-DD 格式,例如 2026-06-01。"
|
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
|
<el-upload
|
||||||
class="upload"
|
class="upload"
|
||||||
drag
|
drag
|
||||||
@@ -34,6 +45,9 @@
|
|||||||
<el-table :data="batches" border>
|
<el-table :data="batches" border>
|
||||||
<el-table-column prop="id" label="批次ID" width="90" />
|
<el-table-column prop="id" label="批次ID" width="90" />
|
||||||
<el-table-column prop="filename" label="文件名" min-width="220" />
|
<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">
|
<el-table-column label="状态" width="120">
|
||||||
<template #default="{ row }">{{ statusText(row.status) }}</template>
|
<template #default="{ row }">{{ statusText(row.status) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -99,10 +113,12 @@ import { UploadFilled } from "@element-plus/icons-vue";
|
|||||||
import { ElMessage, ElMessageBox, type UploadFile } from "element-plus";
|
import { ElMessage, ElMessageBox, type UploadFile } from "element-plus";
|
||||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
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";
|
import { apiErrorMessage, downloadFile } from "../download";
|
||||||
|
|
||||||
const batches = ref<ImportBatch[]>([]);
|
const batches = ref<ImportBatch[]>([]);
|
||||||
|
const templates = ref<CertificateTemplate[]>([]);
|
||||||
|
const templateCode = ref("classic");
|
||||||
const selectedFile = ref<File | null>(null);
|
const selectedFile = ref<File | null>(null);
|
||||||
const uploading = ref(false);
|
const uploading = ref(false);
|
||||||
const confirmingId = ref<number | null>(null);
|
const confirmingId = ref<number | null>(null);
|
||||||
@@ -112,6 +128,11 @@ const pregenerationJob = ref<PdfPregenerationJob | null>(null);
|
|||||||
let pregenerationTimer: number | null = null;
|
let pregenerationTimer: number | null = null;
|
||||||
|
|
||||||
const pregenerationDone = computed(() => ["completed", "completed_with_errors"].includes(pregenerationJob.value?.status || ""));
|
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) {
|
function statusText(status: string) {
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
@@ -136,12 +157,18 @@ async function loadBatches() {
|
|||||||
batches.value = data;
|
batches.value = data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadTemplates() {
|
||||||
|
const { data } = await http.get<CertificateTemplate[]>("/admin/certificate-templates");
|
||||||
|
templates.value = data;
|
||||||
|
}
|
||||||
|
|
||||||
async function uploadFile() {
|
async function uploadFile() {
|
||||||
if (!selectedFile.value) return;
|
if (!selectedFile.value) return;
|
||||||
uploading.value = true;
|
uploading.value = true;
|
||||||
try {
|
try {
|
||||||
const body = new FormData();
|
const body = new FormData();
|
||||||
body.append("file", selectedFile.value);
|
body.append("file", selectedFile.value);
|
||||||
|
body.append("template_code", templateCode.value);
|
||||||
await http.post("/admin/import-batches", body);
|
await http.post("/admin/import-batches", body);
|
||||||
ElMessage.success("文件已上传并完成校验,请检查结果后点击确认导入");
|
ElMessage.success("文件已上传并完成校验,请检查结果后点击确认导入");
|
||||||
selectedFile.value = null;
|
selectedFile.value = null;
|
||||||
@@ -213,7 +240,7 @@ function stopPregenerationPolling() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadBatches);
|
onMounted(() => Promise.all([loadBatches(), loadTemplates()]));
|
||||||
onUnmounted(stopPregenerationPolling);
|
onUnmounted(stopPregenerationPolling);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -235,6 +262,35 @@ onUnmounted(stopPregenerationPolling);
|
|||||||
margin-bottom: 12px;
|
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 {
|
.import-tip {
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
@@ -289,4 +345,14 @@ onUnmounted(stopPregenerationPolling);
|
|||||||
color: #b91c1c;
|
color: #b91c1c;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.batch-template {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-template img {
|
||||||
|
width: min(100%, 260px);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -19,6 +19,11 @@
|
|||||||
<el-form-item label="默认发证单位">
|
<el-form-item label="默认发证单位">
|
||||||
<el-input v-model.trim="form.default_issuer_name" maxlength="128" />
|
<el-input v-model.trim="form.default_issuer_name" maxlength="128" />
|
||||||
</el-form-item>
|
</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-form-item label="默认课程名称">
|
||||||
<el-input v-model.trim="form.default_course_name" maxlength="128" />
|
<el-input v-model.trim="form.default_course_name" maxlength="128" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -47,6 +52,9 @@
|
|||||||
<el-table-column prop="default_course_name" label="课程名称" min-width="140" />
|
<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_stage_name" label="阶段名称" min-width="120" />
|
||||||
<el-table-column prop="default_issuer_name" label="发证单位" min-width="140" />
|
<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">
|
<el-table-column label="状态" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="row.status === 'active' ? 'success' : 'info'">
|
<el-tag :type="row.status === 'active' ? 'success' : 'info'">
|
||||||
@@ -71,17 +79,19 @@
|
|||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import { computed, onMounted, reactive, ref } from "vue";
|
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 allProjects = ref<ProjectCourse[]>([]);
|
||||||
const keyword = ref("");
|
const keyword = ref("");
|
||||||
const editingId = ref<number | null>(null);
|
const editingId = ref<number | null>(null);
|
||||||
|
const templates = ref<CertificateTemplate[]>([]);
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
code: "",
|
code: "",
|
||||||
name: "",
|
name: "",
|
||||||
default_course_name: "",
|
default_course_name: "",
|
||||||
default_stage_name: "",
|
default_stage_name: "",
|
||||||
default_issuer_name: "本公司",
|
default_issuer_name: "本公司",
|
||||||
|
default_template_code: "classic",
|
||||||
status: "active",
|
status: "active",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,6 +120,15 @@ async function loadProjects() {
|
|||||||
allProjects.value = data;
|
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) {
|
function editProject(row: ProjectCourse) {
|
||||||
editingId.value = row.id;
|
editingId.value = row.id;
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
@@ -118,6 +137,7 @@ function editProject(row: ProjectCourse) {
|
|||||||
default_course_name: row.default_course_name || "",
|
default_course_name: row.default_course_name || "",
|
||||||
default_stage_name: row.default_stage_name || "",
|
default_stage_name: row.default_stage_name || "",
|
||||||
default_issuer_name: row.default_issuer_name,
|
default_issuer_name: row.default_issuer_name,
|
||||||
|
default_template_code: row.default_template_code,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -130,6 +150,7 @@ function resetForm() {
|
|||||||
default_course_name: "",
|
default_course_name: "",
|
||||||
default_stage_name: "",
|
default_stage_name: "",
|
||||||
default_issuer_name: "本公司",
|
default_issuer_name: "本公司",
|
||||||
|
default_template_code: "classic",
|
||||||
status: "active",
|
status: "active",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -141,6 +162,7 @@ function projectPayload() {
|
|||||||
default_course_name: form.default_course_name || null,
|
default_course_name: form.default_course_name || null,
|
||||||
default_stage_name: form.default_stage_name || null,
|
default_stage_name: form.default_stage_name || null,
|
||||||
default_issuer_name: form.default_issuer_name,
|
default_issuer_name: form.default_issuer_name,
|
||||||
|
default_template_code: form.default_template_code,
|
||||||
status: form.status,
|
status: form.status,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -165,7 +187,7 @@ async function toggleStatus(row: ProjectCourse) {
|
|||||||
await loadProjects();
|
await loadProjects();
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadProjects);
|
onMounted(() => Promise.all([loadProjects(), loadTemplates()]));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<el-menu :default-active="route.path" router>
|
<el-menu :default-active="route.path" router>
|
||||||
<el-menu-item index="/admin">数据概览</el-menu-item>
|
<el-menu-item index="/admin">数据概览</el-menu-item>
|
||||||
<el-menu-item index="/admin/projects">项目管理</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/learners">学员管理</el-menu-item>
|
||||||
<el-menu-item index="/admin/certificates">证书管理</el-menu-item>
|
<el-menu-item index="/admin/certificates">证书管理</el-menu-item>
|
||||||
<el-menu-item index="/admin/imports">Excel 导入</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