新增PDF批量预生成

This commit is contained in:
2026-06-23 13:59:56 +08:00
parent b6be8b4593
commit 4742c175c3
18 changed files with 725 additions and 24 deletions

View File

@@ -45,7 +45,7 @@ reset-local-data.bat
- 学员手动新增、查询、修改、删除。
- 项目新增、搜索、修改、启用、停用。
- 证书手动新增、后台预览、后台下载、作废、重置链接。
- 证书手动新增、后台预览、后台下载、作废、重置链接、批量预生成 PDF
- Excel 模板下载、导入、确认导入、导出证书。
- 操作日志中文显示。
- 公开查询(支持证书编号+姓名+图形验证码、手机号+短信验证码两种方式、证书直达页、PDF 下载。

View File

@@ -0,0 +1,26 @@
"""add certificate import batch id
Revision ID: 20260623_0005
Revises: 20260623_0004
Create Date: 2026-06-23
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260623_0005"
down_revision: Union[str, None] = "20260623_0004"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("certificates", sa.Column("import_batch_id", sa.Integer(), nullable=True))
op.create_index("ix_certificates_import_batch_id", "certificates", ["import_batch_id"])
def downgrade() -> None:
op.drop_index("ix_certificates_import_batch_id", table_name="certificates")
op.drop_column("certificates", "import_batch_id")

View File

@@ -7,10 +7,11 @@ from app.api.deps import require_roles
from app.core.security import generate_public_token, hash_token
from app.db.session import get_db
from app.models import AdminUser, Certificate, CertificateAccessToken, Learner, ProjectCourse
from app.schemas.certificate import CertificateCreate, CertificateOut
from app.schemas.certificate import CertificateCreate, CertificateOut, PdfPregenerationJobCreate, PdfPregenerationJobOut
from app.services.certificate_number import build_certificate_no
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
router = APIRouter()
@@ -33,10 +34,12 @@ def _create_token(db: Session, certificate_id: int, token_type: str) -> int:
def list_certificates(
keyword: str | None = Query(default=None),
status_value: str | None = Query(default=None, alias="status"),
pdf_status: str | None = Query(default=None),
import_batch_id: int | None = Query(default=None),
db: Session = Depends(get_db),
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> list[Certificate]:
query = db.query(Certificate).order_by(Certificate.id.desc())
) -> list[dict[str, object]]:
query = db.query(Certificate, Learner).outerjoin(Learner, Learner.id == Certificate.learner_id).order_by(Certificate.id.desc())
if keyword:
like = f"%{keyword}%"
query = query.filter(
@@ -46,11 +49,16 @@ def list_certificates(
Certificate.project_code.like(like),
Certificate.course_name.like(like),
Certificate.stage_name.like(like),
Learner.current_name.like(like),
)
)
if status_value:
query = query.filter(Certificate.status == status_value)
return query.limit(100).all()
if pdf_status:
query = query.filter(Certificate.pdf_status == pdf_status)
if import_batch_id:
query = query.filter(Certificate.import_batch_id == import_batch_id)
return [_certificate_payload(certificate, learner) for certificate, learner in query.limit(200).all()]
@router.post("", response_model=CertificateOut, status_code=status.HTTP_201_CREATED)
@@ -89,6 +97,51 @@ def create_certificate(
return certificate
@router.get("/pdf-pregeneration-jobs", response_model=list[PdfPregenerationJobOut])
def list_pdf_pregeneration_jobs(
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> list[dict[str, object]]:
return [job.to_dict() for job in pdf_pregeneration_manager.list()]
@router.post("/pdf-pregeneration-jobs", response_model=PdfPregenerationJobOut, status_code=status.HTTP_201_CREATED)
def start_pdf_pregeneration_job(
payload: PdfPregenerationJobCreate,
db: Session = Depends(get_db),
admin: AdminUser = Depends(require_roles("system_admin", "certificate_admin")),
) -> dict[str, object]:
certificate_ids = _resolve_pregeneration_certificate_ids(db, payload)
if not certificate_ids:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="没有可预生成的证书")
concurrency_limit = get_pdf_generation_concurrency_limit(db)
job = pdf_pregeneration_manager.start(certificate_ids, concurrency_limit)
log_action(
db,
admin,
"start_pdf_pregeneration",
"certificate",
job.id,
detail={
"total": job.total,
"concurrency_limit": concurrency_limit,
"import_batch_id": payload.import_batch_id,
},
)
db.commit()
return job.to_dict()
@router.get("/pdf-pregeneration-jobs/{job_id}", response_model=PdfPregenerationJobOut)
def get_pdf_pregeneration_job(
job_id: str,
_: AdminUser = Depends(require_roles("system_admin", "certificate_admin", "readonly")),
) -> dict[str, object]:
job = pdf_pregeneration_manager.get(job_id)
if not job:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="预生成任务不存在或已过期")
return job.to_dict()
@router.get("/{certificate_id}", response_model=CertificateOut)
def get_certificate(
certificate_id: int,
@@ -101,6 +154,67 @@ def get_certificate(
return certificate
def _certificate_payload(certificate: Certificate, learner: Learner | None = None) -> dict[str, object]:
return {
"id": certificate.id,
"learner_id": certificate.learner_id,
"learner_name": learner.current_name if learner else None,
"import_batch_id": certificate.import_batch_id,
"project_code": certificate.project_code,
"certificate_no": certificate.certificate_no,
"certificate_name": certificate.certificate_name,
"class_name": certificate.class_name,
"course_name": certificate.course_name,
"stage_name": certificate.stage_name,
"issue_date": certificate.issue_date,
"issuer_name": certificate.issuer_name,
"status": certificate.status,
"pdf_status": certificate.pdf_status,
"created_at": certificate.created_at,
"updated_at": certificate.updated_at,
}
def _resolve_pregeneration_certificate_ids(db: Session, payload: PdfPregenerationJobCreate) -> list[int]:
ids: list[int] = []
if payload.import_batch_id:
ids.extend(
item[0]
for item in db.query(Certificate.id)
.filter(Certificate.import_batch_id == payload.import_batch_id)
.filter(Certificate.status == "valid")
.order_by(Certificate.id.asc())
.all()
)
selected_ids = _dedupe_ids(payload.certificate_ids)
if selected_ids:
valid_selected_ids = {
item[0]
for item in db.query(Certificate.id)
.filter(Certificate.id.in_(selected_ids))
.filter(Certificate.status == "valid")
.all()
}
ids.extend(certificate_id for certificate_id in selected_ids if certificate_id in valid_selected_ids)
seen: set[int] = set()
result: list[int] = []
for item in ids:
if item not in seen:
seen.add(item)
result.append(item)
return result
def _dedupe_ids(values: list[int]) -> list[int]:
seen: set[int] = set()
result: list[int] = []
for value in values:
if value not in seen:
seen.add(value)
result.append(value)
return result
@router.get("/{certificate_id}/preview")
def preview_certificate(
certificate_id: int,

View File

@@ -181,6 +181,7 @@ def confirm_import_batch(
certificate = Certificate(
learner_id=learner.id,
import_batch_id=batch.id,
project_code=project.code,
certificate_no="PENDING",
certificate_name=project.default_certificate_name,

View File

@@ -26,6 +26,7 @@ class Certificate(Base):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
learner_id: Mapped[int] = mapped_column(index=True)
import_batch_id: Mapped[int | None] = mapped_column(nullable=True, index=True)
project_code: Mapped[str] = mapped_column(String(16), index=True)
certificate_no: Mapped[str] = mapped_column(String(64), unique=True, index=True)
certificate_name: Mapped[str] = mapped_column(String(128))

View File

@@ -23,6 +23,8 @@ class CertificateCreate(BaseModel):
class CertificateOut(BaseModel):
id: int
learner_id: int
learner_name: str | None = None
import_batch_id: int | None = None
project_code: str
certificate_no: str
certificate_name: str
@@ -37,3 +39,24 @@ class CertificateOut(BaseModel):
updated_at: datetime
model_config = {"from_attributes": True}
class PdfPregenerationJobCreate(BaseModel):
certificate_ids: list[int] = Field(default_factory=list, max_length=5000)
import_batch_id: int | None = None
class PdfPregenerationJobOut(BaseModel):
id: str
status: str
total: int
completed: int
generated: int
cached: int
skipped: int
failed: int
percent: int
concurrency_limit: int
errors: list[str]
created_at: datetime
finished_at: datetime | None = None

View File

@@ -15,6 +15,7 @@ ACTION_LABELS = {
"update_learner": "\u4fee\u6539\u5b66\u5458",
"delete_learner": "\u5220\u9664\u5b66\u5458",
"create_certificate": "\u65b0\u589e\u8bc1\u4e66",
"start_pdf_pregeneration": "\u542f\u52a8PDF\u6279\u91cf\u9884\u751f\u6210",
"void_certificate": "\u4f5c\u5e9f\u8bc1\u4e66",
"regenerate_public_token": "\u91cd\u7f6e\u8bc1\u4e66\u94fe\u63a5",
"upload_import_file": "\u4e0a\u4f20\u5bfc\u5165\u6587\u4ef6",
@@ -49,6 +50,7 @@ MEDIUM_RISK_ACTIONS = {
"update_learner",
"confirm_import_batch",
"create_certificate",
"start_pdf_pregeneration",
"create_database_backup",
"update_pdf_settings",
}
@@ -110,6 +112,8 @@ def log_summary(action: str, object_type: str, object_id: object, detail: dict[s
if action in {"create_certificate", "void_certificate", "regenerate_public_token"}:
learner_name = detail.get("learner_name")
return f"{label}\uff1a{detail.get('certificate_no') or object_id}" + (f"\uff08{learner_name}\uff09" if learner_name else "")
if action == "start_pdf_pregeneration":
return f"{label}\uff1a{detail.get('total', 0)} \u5f20\uff0c\u5e76\u53d1 {detail.get('concurrency_limit')}"
if action in {"upload_import_file", "delete_import_batch"}:
return f"{label}\uff1a{detail.get('filename') or object_id}"
if action == "confirm_import_batch":

View File

@@ -64,6 +64,12 @@ def update_pdf_access_time(certificate: Certificate) -> None:
certificate.pdf_last_accessed_at = datetime.utcnow()
def mark_pdf_generated(certificate: Certificate, output_path: Path) -> None:
certificate.pdf_status = "generated"
certificate.pdf_file_path = str(output_path)
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"]:
@@ -88,24 +94,20 @@ def render_certificate_pdf(
template_path = certificate_template_path()
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:
# 更新访问时间
update_pdf_access_time(certificate)
mark_pdf_generated(certificate, output_path)
return output_path
pdf_generation_limiter.acquire(max(1, concurrency_limit))
try:
if cached_pdf_is_fresh(output_path) and output_path.stat().st_mtime >= latest_renderer_mtime:
update_pdf_access_time(certificate)
mark_pdf_generated(certificate, output_path)
return output_path
image = render_certificate_image(certificate, learner, project)
image.save(output_path, "PDF", resolution=pdf_resolution(image))
finally:
pdf_generation_limiter.release()
certificate.pdf_status = "generated"
certificate.pdf_file_path = str(output_path)
# 更新访问时间
update_pdf_access_time(certificate)
mark_pdf_generated(certificate, output_path)
return output_path

View File

@@ -0,0 +1,182 @@
import queue
import threading
import uuid
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from app.db.session import SessionLocal
from app.models import Certificate, CertificateAccessToken, Learner, ProjectCourse
from app.services.pdf import cached_pdf_is_fresh, certificate_template_path, pdf_cache_path, render_certificate_pdf
MAX_PDF_PREGENERATION_JOBS = 30
def _utc_now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None)
@dataclass
class PdfPregenerationJob:
id: str
certificate_ids: list[int]
concurrency_limit: int
status: str = "running"
total: int = 0
completed: int = 0
generated: int = 0
cached: int = 0
skipped: int = 0
failed: int = 0
errors: list[str] = field(default_factory=list)
created_at: datetime = field(default_factory=_utc_now)
finished_at: datetime | None = None
def to_dict(self) -> dict[str, object]:
percent = round((self.completed / self.total) * 100) if self.total else 0
return {
"id": self.id,
"status": self.status,
"total": self.total,
"completed": self.completed,
"generated": self.generated,
"cached": self.cached,
"skipped": self.skipped,
"failed": self.failed,
"percent": percent,
"concurrency_limit": self.concurrency_limit,
"errors": self.errors[-10:],
"created_at": self.created_at,
"finished_at": self.finished_at,
}
class PdfPregenerationManager:
def __init__(self) -> None:
self._jobs: dict[str, PdfPregenerationJob] = {}
self._lock = threading.Lock()
def start(self, certificate_ids: list[int], concurrency_limit: int) -> PdfPregenerationJob:
unique_ids = _unique_ids(certificate_ids)
if not unique_ids:
raise ValueError("请选择需要预生成PDF的证书")
job = PdfPregenerationJob(
id=uuid.uuid4().hex,
certificate_ids=unique_ids,
concurrency_limit=max(1, concurrency_limit),
total=len(unique_ids),
)
with self._lock:
self._jobs[job.id] = job
self._trim_jobs()
thread = threading.Thread(target=self._run_job, args=(job.id,), daemon=True)
thread.start()
return job
def get(self, job_id: str) -> PdfPregenerationJob | None:
with self._lock:
return self._jobs.get(job_id)
def list(self) -> list[PdfPregenerationJob]:
with self._lock:
return sorted(self._jobs.values(), key=lambda item: item.created_at, reverse=True)
def _run_job(self, job_id: str) -> None:
job = self.get(job_id)
if not job:
return
work_queue: queue.Queue[int] = queue.Queue()
for certificate_id in job.certificate_ids:
work_queue.put(certificate_id)
workers = [
threading.Thread(target=self._worker, args=(job_id, work_queue), daemon=True)
for _ in range(min(job.concurrency_limit, job.total))
]
for worker in workers:
worker.start()
for worker in workers:
worker.join()
with self._lock:
job.status = "completed_with_errors" if job.failed else "completed"
job.finished_at = _utc_now()
def _worker(self, job_id: str, work_queue: queue.Queue[int]) -> None:
while True:
try:
certificate_id = work_queue.get_nowait()
except queue.Empty:
return
try:
job = self.get(job_id)
if not job:
return
result = pre_generate_certificate_pdf(certificate_id, job.concurrency_limit)
self._record(job_id, result)
except Exception as exc:
self._record(job_id, "failed", f"证书ID {certificate_id}{exc}")
finally:
work_queue.task_done()
def _record(self, job_id: str, result: str, error: str | None = None) -> None:
job = self.get(job_id)
if not job:
return
with self._lock:
job.completed += 1
if result == "generated":
job.generated += 1
elif result == "cached":
job.cached += 1
elif result == "skipped":
job.skipped += 1
else:
job.failed += 1
if error:
job.errors.append(error)
def _trim_jobs(self) -> None:
if len(self._jobs) <= MAX_PDF_PREGENERATION_JOBS:
return
old_jobs = sorted(self._jobs.values(), key=lambda item: item.created_at)
for job in old_jobs[: len(self._jobs) - MAX_PDF_PREGENERATION_JOBS]:
self._jobs.pop(job.id, None)
def pre_generate_certificate_pdf(certificate_id: int, concurrency_limit: int) -> str:
with SessionLocal() as db:
certificate = db.get(Certificate, certificate_id)
if not certificate or certificate.status != "valid":
return "skipped"
learner = db.get(Learner, certificate.learner_id)
token = db.get(CertificateAccessToken, certificate.qr_token_id or certificate.public_token_id)
project = db.query(ProjectCourse).filter(ProjectCourse.code == certificate.project_code).first()
if not learner or not token:
raise ValueError("证书数据不完整")
was_cached = certificate_pdf_is_ready(certificate)
render_certificate_pdf(certificate, learner, project, token, concurrency_limit=concurrency_limit)
db.commit()
return "cached" if was_cached else "generated"
def certificate_pdf_is_ready(certificate: Certificate) -> bool:
output_path = pdf_cache_path(certificate.certificate_no)
template_path = certificate_template_path()
latest_renderer_mtime = max(template_path.stat().st_mtime, Path(__file__).with_name("pdf.py").stat().st_mtime)
return cached_pdf_is_fresh(output_path) and output_path.stat().st_mtime >= latest_renderer_mtime
def _unique_ids(values: list[int]) -> list[int]:
seen: set[int] = set()
result: list[int] = []
for value in values:
if value not in seen:
seen.add(value)
result.append(value)
return result
pdf_pregeneration_manager = PdfPregenerationManager()

View File

@@ -0,0 +1,31 @@
from time import monotonic, sleep
from app.services import pdf_pregeneration
def test_pdf_pregeneration_job_deduplicates_and_tracks_progress(monkeypatch):
calls: list[tuple[int, int]] = []
def fake_pre_generate_certificate_pdf(certificate_id: int, concurrency_limit: int) -> str:
calls.append((certificate_id, concurrency_limit))
return {101: "generated", 102: "cached", 103: "skipped"}[certificate_id]
monkeypatch.setattr(pdf_pregeneration, "pre_generate_certificate_pdf", fake_pre_generate_certificate_pdf)
manager = pdf_pregeneration.PdfPregenerationManager()
job = manager.start([101, 102, 102, 103], concurrency_limit=2)
deadline = monotonic() + 2
while job.status == "running" and monotonic() < deadline:
sleep(0.01)
assert job.status == "completed"
assert job.total == 3
assert job.completed == 3
assert job.generated == 1
assert job.cached == 1
assert job.skipped == 1
assert job.failed == 0
assert sorted(certificate_id for certificate_id, _ in calls) == [101, 102, 103]
assert {limit for _, limit in calls} == {2}
assert job.to_dict()["percent"] == 100

View File

@@ -100,3 +100,10 @@
- Decision: production PDF rendering now uses the approved certificate image as the base layer, then renders learner/course/date/certificate number dynamically with Pillow.
- Current limitation: the data model only has `issue_date`; until separate training start/end dates are added, the rendered course date range uses `issue_date` for both start and end.
- Current limitation: mentor, issuer names, and QR area remain from the approved base image for visual consistency.
### C012 PDF 批量预生成
- 决定:后台提供批量预生成 PDF 能力,可按导入批次生成,也可在证书管理中筛选和勾选后生成。
- 策略:预生成继续使用系统设置里的 `PDF 生成并发限制`,不单独开无限制后台任务。
- 原因:大批量学员毕业前先生成缓存,可以降低公开查询高峰的等待时间和 CPU 压力。
- 当前限制:任务进度保存在后端进程内,服务重启后只保留已生成的 PDF 缓存,不恢复历史进度。

View File

@@ -39,6 +39,7 @@ docker compose exec backend python -m app.cli init-db --admin-username admin --a
- 系统设置里的 `PDF 生成并发限制` 已确认2 核 4G 建议保持默认 `2`
- Excel 导入模板已下载并试填。
- 占位证书模板已确认可临时使用。
- 大批量毕业前的操作约定已确认确认导入后先在“Excel 导入”或“证书管理”里批量预生成 PDF。
## 验收动作
@@ -46,6 +47,8 @@ docker compose exec backend python -m app.cli init-db --admin-username admin --a
- 可新增学员。
- 可新增证书并生成编号。
- 可上传 Excel 并确认导入。
- 已导入批次可以启动“预生成PDF”进度弹窗能显示总数、已完成、失败数和并发数。
- 证书管理页可按导入批次和 PDF 状态筛选,并能勾选多张证书批量预生成。
- 可导出证书和直达链接。
- 公开查询支持两种方式:
- 证书编号查询:必须输入姓名和图形验证码。

View File

@@ -41,9 +41,10 @@ reset-local-data.bat
1. 登录 `/admin/login`
2. 进入“项目管理”,维护 `DBY``QSX` 等项目代码。项目支持搜索、修改、启用和停用。
3. 进入“学员管理”,手动新增、查询、修改或删除学员。
4. 进入“证书管理”手动新增证书支持单张预览、PDF 下载、作废、重置直达链接和导出。
4. 进入“证书管理”手动新增证书支持单张预览、PDF 下载、作废、重置直达链接、按条件筛选、勾选后批量预生成 PDF 和导出。
5. 进入“Excel 导入”,下载模板、上传 Excel、查看校验结果确认后正式导入。模板只需要填写姓名、手机号、项目代码、发证日期证书名称、课程名称、阶段名称、发证单位从项目配置自动带出。
“确认导入”表示把已校验通过的临时数据正式写入学员和证书表。导入记录支持下载原文件、下载错误报告和删除;删除导入记录只删除上传文件、错误报告和批次记录,不删除已经生成的学员和证书。
确认导入后可以在该批次行点击“预生成PDF”系统会提前为这一批有效证书生成 PDF并在弹窗里显示总数、已完成、新生成、已缓存、跳过和失败数量。
6. 进入“系统设置”,确认 `PDF 生成并发限制`。2 核 4G 服务器建议保持默认值 `2`
7. 进入“操作日志”,查看关键后台操作。日志动作已转成中文显示。
@@ -65,6 +66,13 @@ PDF 由服务端按需生成,并使用 30 天缓存。这样可以保证模板
首次生成 PDF 会占用 CPU。后台“系统设置”里的 `PDF 生成并发限制` 默认是 `2`,用于防止多人同时首次下载 PDF 时把 2 核 4G 服务器打满。前端下载和预览时会显示“当前查询人数较多,请稍后。。。”,避免用户误以为页面卡死。
如果一次性导入大量毕业学员,建议管理员先做批量预生成:
1. 进入“Excel 导入”找到已确认导入的批次点击“预生成PDF”。
2. 或进入“证书管理”按导入批次、PDF 状态、项目或关键词筛选勾选需要提前生成的人再点击“预生成所选PDF”。
3. 批量预生成会继续遵守 `PDF 生成并发限制`,不会绕过系统并发保护。
4. 预生成任务只在当前后端进程内记录进度;如果服务重启,已生成的 PDF 缓存仍然保留,但任务进度弹窗不会恢复。
## 正式部署准备
- Linux 服务器,建议 2 核 4G 起步。
@@ -134,6 +142,7 @@ docker compose exec backend pytest
- 项目新增、搜索、修改、启用、停用。
- 学员手动新增、查询、修改、删除。
- 证书新增、查询、后台预览、后台下载、作废、重置直达链接。
- 证书按导入批次筛选、按 PDF 状态筛选、批量预生成 PDF。
- Excel 模板下载、上传校验、错误报告下载、确认导入。导入模板字段为姓名、手机号、项目代码、发证日期。
- 导入批次原文件下载和导入批次删除。
- 导入后自动生成证书编号、直达链接 token、二维码 token。

View File

@@ -516,6 +516,7 @@ https://你的域名/admin/login
- 证书编号自动生成。
- 能预览证书。
- 能下载 PDF。
- 能按导入批次或 PDF 状态筛选证书,并勾选多张证书批量预生成 PDF。
- PDF 底图、姓名、日期、课程、导师、二维码清晰。
- 作废证书后不能下载正常 PDF。
- 重置链接后旧链接失效。
@@ -538,6 +539,7 @@ https://你的域名/admin/login
- 错误数据能生成错误报告。
- 校验通过后能确认导入。
- 确认导入后能生成学员和证书。
- 已导入批次能点击“预生成PDF”进度弹窗显示总数、已完成和失败数。
- 能下载导入原文件。
### 9.6 公开查询

View File

@@ -513,6 +513,7 @@ https://你的域名/admin/login
- 证书名称使用项目名称。
- 能预览证书。
- 能下载 PDF。
- 能按导入批次或 PDF 状态筛选证书,并勾选多张证书批量预生成 PDF。
- 证书日期、课程标题、导师、二维码显示正常。
- 能作废证书。
- 作废证书不能下载正常 PDF。
@@ -536,6 +537,7 @@ https://你的域名/admin/login
- 能确认导入。
- 确认导入后状态变成已导入。
- 不能重复确认导入。
- 已导入批次能点击“预生成PDF”进度弹窗显示总数、已完成和失败数。
### 13.6 公开查询

View File

@@ -62,6 +62,8 @@ export interface Learner {
export interface AdminCertificate {
id: number;
learner_id: number;
learner_name: string | null;
import_batch_id: number | null;
project_code: string;
certificate_no: string;
certificate_name: string;
@@ -124,3 +126,19 @@ export interface PdfSettings {
pdf_generation_concurrency_limit: number;
updated_at: string | null;
}
export interface PdfPregenerationJob {
id: string;
status: string;
total: number;
completed: number;
generated: number;
cached: number;
skipped: number;
failed: number;
percent: number;
concurrency_limit: number;
errors: string[];
created_at: string;
finished_at: string | null;
}

View File

@@ -3,7 +3,7 @@
<header class="page-head">
<div>
<h2>证书管理</h2>
<p>支持单张新增预览下载作废和链接重置</p>
<p>支持单张新增预览下载作废链接重置和批量预生成PDF</p>
</div>
<el-button type="primary" @click="createCertificate">新增证书</el-button>
</header>
@@ -44,16 +44,32 @@
<el-option label="有效" value="valid" />
<el-option label="已作废" value="voided" />
</el-select>
<el-select v-model="pdfStatusValue" clearable placeholder="PDF状态">
<el-option label="未生成" value="not_generated" />
<el-option label="已生成" value="generated" />
<el-option label="已清理" value="cleaned" />
</el-select>
<el-select v-model="importBatchId" clearable filterable placeholder="导入批次">
<el-option v-for="item in importBatches" :key="item.id" :label="`#${item.id} ${item.filename}`" :value="item.id" />
</el-select>
<el-button @click="loadCertificates">搜索</el-button>
<el-button type="primary" :disabled="!selectedCertificates.length" :loading="pregenerationStarting" @click="startPregenerationSelected">预生成所选PDF</el-button>
<el-button :disabled="!importBatchId" :loading="pregenerationStarting" @click="startPregenerationBatch">预生成当前批次全部</el-button>
<el-button @click="exportCertificates">导出</el-button>
</div>
<el-table :data="certificates" border>
<el-table :data="certificates" border @selection-change="handleSelectionChange">
<el-table-column type="selection" width="44" />
<el-table-column prop="certificate_no" label="证书编号" width="180" show-overflow-tooltip />
<el-table-column prop="learner_id" label="学员ID" width="76" />
<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="certificate_name" label="证书名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="issue_date" label="发证日期" width="108" />
<el-table-column label="PDF" width="92">
<template #default="{ row }">
<el-tag :type="row.pdf_status === 'generated' ? 'success' : 'info'">{{ pdfStatusText(row.pdf_status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 'valid' ? 'success' : 'danger'">{{ statusText(row.status) }}</el-tag>
@@ -90,23 +106,55 @@
</el-descriptions-item>
</el-descriptions>
</el-dialog>
<el-dialog v-model="pregenerationVisible" title="批量预生成PDF" width="560px" @closed="stopPregenerationPolling">
<div v-if="pregenerationJob" class="pregeneration-panel">
<el-progress :percentage="pregenerationJob.percent" />
<dl class="pregeneration-stats">
<div><dt>总数</dt><dd>{{ pregenerationJob.total }}</dd></div>
<div><dt>已完成</dt><dd>{{ pregenerationJob.completed }}</dd></div>
<div><dt>新生成</dt><dd>{{ pregenerationJob.generated }}</dd></div>
<div><dt>已缓存</dt><dd>{{ pregenerationJob.cached }}</dd></div>
<div><dt>跳过</dt><dd>{{ pregenerationJob.skipped }}</dd></div>
<div><dt>失败</dt><dd>{{ pregenerationJob.failed }}</dd></div>
</dl>
<p class="progress-note">当前任务按系统设置的最大并发数执行{{ pregenerationJob.concurrency_limit }}</p>
<el-alert
v-if="pregenerationDone"
:type="pregenerationJob.failed ? 'warning' : 'success'"
:closable="false"
:title="pregenerationJob.failed ? '预生成已完成,但有部分失败。' : '预生成已完成。'"
/>
<ul v-if="pregenerationJob.errors.length" class="error-list">
<li v-for="item in pregenerationJob.errors" :key="item">{{ item }}</li>
</ul>
</div>
</el-dialog>
</section>
</template>
<script setup lang="ts">
import { ElLoading, ElMessage, ElMessageBox } from "element-plus";
import { computed, onMounted, reactive, ref } from "vue";
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
import { http, type AdminCertificate, type ProjectCourse } from "../api";
import { http, type AdminCertificate, type ImportBatch, type PdfPregenerationJob, type ProjectCourse } from "../api";
import { apiErrorMessage, downloadFile } from "../download";
const certificates = ref<AdminCertificate[]>([]);
const projects = ref<ProjectCourse[]>([]);
const importBatches = ref<ImportBatch[]>([]);
const keyword = ref("");
const statusValue = ref("");
const pdfStatusValue = ref("");
const importBatchId = ref<number | undefined>();
const previewVisible = ref(false);
const preview = ref<any>(null);
const downloadingCertificateId = ref<number | null>(null);
const selectedCertificates = ref<AdminCertificate[]>([]);
const pregenerationStarting = ref(false);
const pregenerationVisible = ref(false);
const pregenerationJob = ref<PdfPregenerationJob | null>(null);
let pregenerationTimer: number | null = null;
const form = reactive({
learner_id: undefined as number | undefined,
project_code: "",
@@ -123,18 +171,38 @@ function statusText(status: string) {
return status === "valid" ? "有效" : "已作废";
}
function pdfStatusText(status: string) {
return { generated: "已生成", cleaned: "已清理", not_generated: "未生成" }[status] || status;
}
const pregenerationDone = computed(() => ["completed", "completed_with_errors"].includes(pregenerationJob.value?.status || ""));
async function loadProjects() {
const { data } = await http.get<ProjectCourse[]>("/admin/projects");
projects.value = data;
}
async function loadImportBatches() {
const { data } = await http.get<ImportBatch[]>("/admin/import-batches");
importBatches.value = data;
}
async function loadCertificates() {
const { data } = await http.get<AdminCertificate[]>("/admin/certificates", {
params: { keyword: keyword.value || undefined, status: statusValue.value || undefined },
params: {
keyword: keyword.value || undefined,
status: statusValue.value || undefined,
pdf_status: pdfStatusValue.value || undefined,
import_batch_id: importBatchId.value || undefined,
},
});
certificates.value = data;
}
function handleSelectionChange(rows: AdminCertificate[]) {
selectedCertificates.value = rows;
}
async function createCertificate() {
if (!form.learner_id || !form.project_code || !form.issue_date || !form.issuer_name) {
ElMessage.warning("请填写学员ID、项目、发证日期和发证单位");
@@ -192,9 +260,58 @@ function exportCertificates() {
downloadFile("/admin/exports/certificates", "certificates-export.xlsx");
}
async function startPregenerationSelected() {
const ids = selectedCertificates.value.map((item) => item.id);
if (!ids.length) {
ElMessage.warning("请先勾选证书");
return;
}
await startPregeneration({ certificate_ids: ids });
}
async function startPregenerationBatch() {
if (!importBatchId.value) return;
await startPregeneration({ import_batch_id: importBatchId.value });
}
async function startPregeneration(payload: { certificate_ids?: number[]; import_batch_id?: number }) {
pregenerationStarting.value = true;
try {
const { data } = await http.post<PdfPregenerationJob>("/admin/certificates/pdf-pregeneration-jobs", payload);
pregenerationJob.value = data;
pregenerationVisible.value = true;
startPregenerationPolling(data.id);
} catch (error: any) {
ElMessage.error(await apiErrorMessage(error, "启动PDF预生成失败"));
} finally {
pregenerationStarting.value = false;
}
}
function startPregenerationPolling(jobId: string) {
stopPregenerationPolling();
pregenerationTimer = window.setInterval(async () => {
const { data } = await http.get<PdfPregenerationJob>(`/admin/certificates/pdf-pregeneration-jobs/${jobId}`);
pregenerationJob.value = data;
if (["completed", "completed_with_errors"].includes(data.status)) {
stopPregenerationPolling();
await loadCertificates();
}
}, 1000);
}
function stopPregenerationPolling() {
if (pregenerationTimer) {
clearInterval(pregenerationTimer);
pregenerationTimer = null;
}
}
onMounted(async () => {
await Promise.all([loadProjects(), loadCertificates()]);
await Promise.all([loadProjects(), loadImportBatches(), loadCertificates()]);
});
onUnmounted(stopPregenerationPolling);
</script>
<style scoped>
@@ -231,6 +348,10 @@ onMounted(async () => {
max-width: 340px;
}
.toolbar .el-select {
width: 170px;
}
.form-tip {
font-size: 12px;
color: #94a3b8;
@@ -247,4 +368,45 @@ onMounted(async () => {
.table-actions :deep(.el-button + .el-button) {
margin-left: 0;
}
.pregeneration-panel {
display: grid;
gap: 16px;
}
.pregeneration-stats {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 8px;
margin: 0;
}
.pregeneration-stats div {
border: 1px solid #e2edf0;
border-radius: 8px;
padding: 10px;
text-align: center;
}
.pregeneration-stats dt {
color: #64748b;
font-size: 12px;
}
.pregeneration-stats dd {
margin: 4px 0 0;
font-size: 18px;
font-weight: 700;
}
.progress-note {
margin: 0;
color: #64748b;
}
.error-list {
margin: 0;
padding-left: 18px;
color: #991b1b;
}
</style>

View File

@@ -34,7 +34,7 @@
<el-table-column prop="valid_rows" label="可导入" width="100" />
<el-table-column prop="failed_rows" label="失败" width="100" />
<el-table-column prop="created_at" label="上传时间" width="190" />
<el-table-column label="操作" width="380">
<el-table-column label="操作" width="470">
<template #default="{ row }">
<el-button size="small" @click="downloadSource(row)">下载原文件</el-button>
<el-button
@@ -46,27 +46,65 @@
>
确认导入
</el-button>
<el-button
v-if="row.status === 'imported'"
size="small"
:loading="preGeneratingId === row.id"
@click="startPregeneration(row)"
>
预生成PDF
</el-button>
<el-button v-if="row.error_report_path" size="small" @click="downloadErrorReport(row)">错误报告</el-button>
<el-button size="small" type="danger" @click="deleteBatch(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="pregenerationVisible" title="批量预生成PDF" width="560px" @closed="stopPregenerationPolling">
<div v-if="pregenerationJob" class="pregeneration-panel">
<el-progress :percentage="pregenerationJob.percent" />
<dl class="pregeneration-stats">
<div><dt>总数</dt><dd>{{ pregenerationJob.total }}</dd></div>
<div><dt>已完成</dt><dd>{{ pregenerationJob.completed }}</dd></div>
<div><dt>新生成</dt><dd>{{ pregenerationJob.generated }}</dd></div>
<div><dt>已缓存</dt><dd>{{ pregenerationJob.cached }}</dd></div>
<div><dt>跳过</dt><dd>{{ pregenerationJob.skipped }}</dd></div>
<div><dt>失败</dt><dd>{{ pregenerationJob.failed }}</dd></div>
</dl>
<p class="progress-note">当前任务按系统设置的最大并发数执行{{ pregenerationJob.concurrency_limit }}</p>
<el-alert
v-if="pregenerationDone"
:type="pregenerationJob.failed ? 'warning' : 'success'"
:closable="false"
:title="pregenerationJob.failed ? '预生成已完成,但有部分失败。' : '预生成已完成。'"
/>
<ul v-if="pregenerationJob.errors.length" class="error-list">
<li v-for="item in pregenerationJob.errors" :key="item">{{ item }}</li>
</ul>
</div>
</el-dialog>
</section>
</template>
<script setup lang="ts">
import { UploadFilled } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox, type UploadFile } from "element-plus";
import { onMounted, ref } from "vue";
import { computed, onMounted, onUnmounted, ref } from "vue";
import { http, type ImportBatch } from "../api";
import { downloadFile } from "../download";
import { http, type ImportBatch, type PdfPregenerationJob } from "../api";
import { apiErrorMessage, downloadFile } from "../download";
const batches = ref<ImportBatch[]>([]);
const selectedFile = ref<File | null>(null);
const uploading = ref(false);
const confirmingId = ref<number | null>(null);
const preGeneratingId = ref<number | null>(null);
const pregenerationVisible = ref(false);
const pregenerationJob = ref<PdfPregenerationJob | null>(null);
let pregenerationTimer: number | null = null;
const pregenerationDone = computed(() => ["completed", "completed_with_errors"].includes(pregenerationJob.value?.status || ""));
function statusText(status: string) {
const labels: Record<string, string> = {
@@ -134,7 +172,42 @@ async function deleteBatch(row: ImportBatch) {
await loadBatches();
}
async function startPregeneration(row: ImportBatch) {
preGeneratingId.value = row.id;
try {
const { data } = await http.post<PdfPregenerationJob>("/admin/certificates/pdf-pregeneration-jobs", {
import_batch_id: row.id,
});
pregenerationJob.value = data;
pregenerationVisible.value = true;
startPregenerationPolling(data.id);
} catch (error: any) {
ElMessage.error(await apiErrorMessage(error, "启动PDF预生成失败"));
} finally {
preGeneratingId.value = null;
}
}
function startPregenerationPolling(jobId: string) {
stopPregenerationPolling();
pregenerationTimer = window.setInterval(async () => {
const { data } = await http.get<PdfPregenerationJob>(`/admin/certificates/pdf-pregeneration-jobs/${jobId}`);
pregenerationJob.value = data;
if (["completed", "completed_with_errors"].includes(data.status)) {
stopPregenerationPolling();
}
}, 1000);
}
function stopPregenerationPolling() {
if (pregenerationTimer) {
clearInterval(pregenerationTimer);
pregenerationTimer = null;
}
}
onMounted(loadBatches);
onUnmounted(stopPregenerationPolling);
</script>
<style scoped>
@@ -163,4 +236,45 @@ onMounted(loadBatches);
margin-top: 20px;
border-radius: 8px;
}
.pregeneration-panel {
display: grid;
gap: 16px;
}
.pregeneration-stats {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 8px;
margin: 0;
}
.pregeneration-stats div {
border: 1px solid #e2edf0;
border-radius: 8px;
padding: 10px;
text-align: center;
}
.pregeneration-stats dt {
color: #64748b;
font-size: 12px;
}
.pregeneration-stats dd {
margin: 4px 0 0;
font-size: 18px;
font-weight: 700;
}
.progress-note {
margin: 0;
color: #64748b;
}
.error-list {
margin: 0;
color: #b91c1c;
line-height: 1.8;
}
</style>