新增PDF生成并发设置
This commit is contained in:
49
backend/alembic/versions/20260623_0004_system_settings.py
Normal file
49
backend/alembic/versions/20260623_0004_system_settings.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""add system settings
|
||||||
|
|
||||||
|
Revision ID: 20260623_0004
|
||||||
|
Revises: 20260609_0003
|
||||||
|
Create Date: 2026-06-23
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "20260623_0004"
|
||||||
|
down_revision: Union[str, None] = "20260609_0003"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"system_settings",
|
||||||
|
sa.Column("key", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("value", sa.Text(), nullable=False),
|
||||||
|
sa.Column("description", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("key"),
|
||||||
|
)
|
||||||
|
op.bulk_insert(
|
||||||
|
sa.table(
|
||||||
|
"system_settings",
|
||||||
|
sa.column("key", sa.String),
|
||||||
|
sa.column("value", sa.Text),
|
||||||
|
sa.column("description", sa.String),
|
||||||
|
sa.column("updated_at", sa.DateTime),
|
||||||
|
),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"key": "pdf_generation_concurrency_limit",
|
||||||
|
"value": "2",
|
||||||
|
"description": "PDF证书同时生成数量限制",
|
||||||
|
"updated_at": datetime.utcnow(),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("system_settings")
|
||||||
@@ -10,6 +10,7 @@ from app.api.routes import (
|
|||||||
admin_logs,
|
admin_logs,
|
||||||
admin_pdf_cleanup,
|
admin_pdf_cleanup,
|
||||||
admin_projects,
|
admin_projects,
|
||||||
|
admin_settings,
|
||||||
auth,
|
auth,
|
||||||
health,
|
health,
|
||||||
public,
|
public,
|
||||||
@@ -27,4 +28,5 @@ api_router.include_router(admin_exports.router, prefix="/admin/exports", tags=["
|
|||||||
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"])
|
||||||
api_router.include_router(admin_pdf_cleanup.router, prefix="/admin/pdf-cleanup", tags=["admin-pdf-cleanup"])
|
api_router.include_router(admin_pdf_cleanup.router, prefix="/admin/pdf-cleanup", tags=["admin-pdf-cleanup"])
|
||||||
api_router.include_router(admin_backups.router, prefix="/admin/backups", tags=["admin-backups"])
|
api_router.include_router(admin_backups.router, prefix="/admin/backups", tags=["admin-backups"])
|
||||||
|
api_router.include_router(admin_settings.router, prefix="/admin/settings", tags=["admin-settings"])
|
||||||
api_router.include_router(public.router, prefix="/public", tags=["public"])
|
api_router.include_router(public.router, prefix="/public", tags=["public"])
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ from app.models import AdminUser, Certificate, CertificateAccessToken, Learner,
|
|||||||
from app.schemas.certificate import CertificateCreate, CertificateOut
|
from app.schemas.certificate import CertificateCreate, CertificateOut
|
||||||
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.pdf import render_certificate_pdf
|
from app.services.pdf import PdfGenerationBusy, render_certificate_pdf
|
||||||
|
from app.services.system_settings import get_pdf_generation_concurrency_limit
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -143,7 +144,16 @@ def download_certificate(
|
|||||||
project = db.query(ProjectCourse).filter(ProjectCourse.code == certificate.project_code).first()
|
project = db.query(ProjectCourse).filter(ProjectCourse.code == certificate.project_code).first()
|
||||||
if not learner or not token:
|
if not learner or not token:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Certificate data is incomplete")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Certificate data is incomplete")
|
||||||
pdf_path = render_certificate_pdf(certificate, learner, project, token)
|
try:
|
||||||
|
pdf_path = render_certificate_pdf(
|
||||||
|
certificate,
|
||||||
|
learner,
|
||||||
|
project,
|
||||||
|
token,
|
||||||
|
concurrency_limit=get_pdf_generation_concurrency_limit(db),
|
||||||
|
)
|
||||||
|
except PdfGenerationBusy as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=str(exc)) from exc
|
||||||
db.commit()
|
db.commit()
|
||||||
return FileResponse(pdf_path, media_type="application/pdf", filename=f"{certificate.certificate_no}.pdf")
|
return FileResponse(pdf_path, media_type="application/pdf", filename=f"{certificate.certificate_no}.pdf")
|
||||||
|
|
||||||
|
|||||||
44
backend/app/api/routes/admin_settings.py
Normal file
44
backend/app/api/routes/admin_settings.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import require_roles
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models import AdminUser
|
||||||
|
from app.schemas.setting import PdfSettingsOut, PdfSettingsUpdate
|
||||||
|
from app.services.logs import log_action
|
||||||
|
from app.services.system_settings import get_pdf_settings, update_pdf_generation_concurrency_limit
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pdf", response_model=PdfSettingsOut)
|
||||||
|
def get_pdf_generation_settings(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: AdminUser = Depends(require_roles("system_admin")),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
settings = get_pdf_settings(db)
|
||||||
|
db.commit()
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/pdf", response_model=PdfSettingsOut)
|
||||||
|
def update_pdf_generation_settings(
|
||||||
|
payload: PdfSettingsUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
admin: AdminUser = Depends(require_roles("system_admin")),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
before = get_pdf_settings(db)
|
||||||
|
after = update_pdf_generation_concurrency_limit(db, payload.pdf_generation_concurrency_limit)
|
||||||
|
log_action(
|
||||||
|
db,
|
||||||
|
admin,
|
||||||
|
"update_pdf_settings",
|
||||||
|
"system_setting",
|
||||||
|
"pdf_generation_concurrency_limit",
|
||||||
|
detail={
|
||||||
|
"before": before["pdf_generation_concurrency_limit"],
|
||||||
|
"after": after["pdf_generation_concurrency_limit"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return after
|
||||||
@@ -8,9 +8,10 @@ from app.db.session import get_db
|
|||||||
from app.models import Certificate, CertificateAccessToken, Learner, ProjectCourse
|
from app.models import Certificate, CertificateAccessToken, Learner, ProjectCourse
|
||||||
from app.services.captcha import make_captcha, verify_captcha
|
from app.services.captcha import make_captcha, verify_captcha
|
||||||
from app.services.logs import log_action, mask_phone
|
from app.services.logs import log_action, mask_phone
|
||||||
from app.services.pdf import render_certificate_pdf
|
from app.services.pdf import PdfGenerationBusy, render_certificate_pdf
|
||||||
from app.services.rate_limit import hit_too_often
|
from app.services.rate_limit import hit_too_often
|
||||||
from app.services.sms import generate_sms_code, verify_sms_code
|
from app.services.sms import generate_sms_code, verify_sms_code
|
||||||
|
from app.services.system_settings import get_pdf_generation_concurrency_limit
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -150,7 +151,16 @@ def download_certificate_pdf(token: str, db: Session = Depends(get_db)) -> FileR
|
|||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="证书已作废,不支持下载正常 PDF")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="证书已作废,不支持下载正常 PDF")
|
||||||
|
|
||||||
project = db.query(ProjectCourse).filter(ProjectCourse.code == certificate.project_code).first()
|
project = db.query(ProjectCourse).filter(ProjectCourse.code == certificate.project_code).first()
|
||||||
pdf_path = render_certificate_pdf(certificate, learner, project, access_token)
|
try:
|
||||||
|
pdf_path = render_certificate_pdf(
|
||||||
|
certificate,
|
||||||
|
learner,
|
||||||
|
project,
|
||||||
|
access_token,
|
||||||
|
concurrency_limit=get_pdf_generation_concurrency_limit(db),
|
||||||
|
)
|
||||||
|
except PdfGenerationBusy as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=str(exc)) from exc
|
||||||
log_action(db, None, "public_download_certificate", "certificate", certificate.id, {"certificate_no": certificate.certificate_no, "learner_name": learner.current_name, "project_code": certificate.project_code})
|
log_action(db, None, "public_download_certificate", "certificate", certificate.id, {"certificate_no": certificate.certificate_no, "learner_name": learner.current_name, "project_code": certificate.project_code})
|
||||||
db.commit()
|
db.commit()
|
||||||
return FileResponse(pdf_path, media_type="application/pdf", filename=f"{certificate.certificate_no}.pdf")
|
return FileResponse(pdf_path, media_type="application/pdf", filename=f"{certificate.certificate_no}.pdf")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from app.core.security import hash_password
|
|||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
from app.db.session import engine
|
from app.db.session import engine
|
||||||
from app.models import AdminUser, ProjectCourse, Role
|
from app.models import AdminUser, ProjectCourse, Role
|
||||||
|
from app.services.system_settings import ensure_default_settings
|
||||||
|
|
||||||
|
|
||||||
def create_tables() -> None:
|
def create_tables() -> None:
|
||||||
@@ -47,4 +48,5 @@ def seed_defaults(db: Session, admin_username: str, admin_password: str) -> None
|
|||||||
role_code="system_admin",
|
role_code="system_admin",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
ensure_default_settings(db)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from app.models.certificate import Certificate, CertificateAccessToken, ProjectC
|
|||||||
from app.models.import_batch import ImportBatch, ImportBatchRow
|
from app.models.import_batch import ImportBatch, ImportBatchRow
|
||||||
from app.models.learner import Learner, LearnerNameHistory, LearnerPhoneHistory
|
from app.models.learner import Learner, LearnerNameHistory, LearnerPhoneHistory
|
||||||
from app.models.log import OperationLog
|
from app.models.log import OperationLog
|
||||||
|
from app.models.setting import SystemSetting
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AdminUser",
|
"AdminUser",
|
||||||
@@ -16,4 +17,5 @@ __all__ = [
|
|||||||
"OperationLog",
|
"OperationLog",
|
||||||
"ProjectCourse",
|
"ProjectCourse",
|
||||||
"Role",
|
"Role",
|
||||||
|
"SystemSetting",
|
||||||
]
|
]
|
||||||
|
|||||||
15
backend/app/models/setting.py
Normal file
15
backend/app/models/setting.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class SystemSetting(Base):
|
||||||
|
__tablename__ = "system_settings"
|
||||||
|
|
||||||
|
key: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||||
|
value: Mapped[str] = mapped_column(Text)
|
||||||
|
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
12
backend/app/schemas/setting.py
Normal file
12
backend/app/schemas/setting.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class PdfSettingsOut(BaseModel):
|
||||||
|
pdf_generation_concurrency_limit: int
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PdfSettingsUpdate(BaseModel):
|
||||||
|
pdf_generation_concurrency_limit: int = Field(ge=1, le=10)
|
||||||
@@ -23,6 +23,7 @@ ACTION_LABELS = {
|
|||||||
"export_certificates": "\u5bfc\u51fa\u8bc1\u4e66",
|
"export_certificates": "\u5bfc\u51fa\u8bc1\u4e66",
|
||||||
"create_database_backup": "\u521b\u5efa\u6570\u636e\u5e93\u5907\u4efd",
|
"create_database_backup": "\u521b\u5efa\u6570\u636e\u5e93\u5907\u4efd",
|
||||||
"restore_database_backup": "\u56de\u6eda\u6570\u636e\u5e93\u5907\u4efd",
|
"restore_database_backup": "\u56de\u6eda\u6570\u636e\u5e93\u5907\u4efd",
|
||||||
|
"update_pdf_settings": "\u4fee\u6539PDF\u751f\u6210\u8bbe\u7f6e",
|
||||||
"public_search_certificate": "\u516c\u5f00\u67e5\u8be2\u8bc1\u4e66",
|
"public_search_certificate": "\u516c\u5f00\u67e5\u8be2\u8bc1\u4e66",
|
||||||
"public_download_certificate": "\u516c\u5f00\u4e0b\u8f7d\u8bc1\u4e66",
|
"public_download_certificate": "\u516c\u5f00\u4e0b\u8f7d\u8bc1\u4e66",
|
||||||
}
|
}
|
||||||
@@ -33,6 +34,7 @@ OBJECT_LABELS = {
|
|||||||
"certificate": "\u8bc1\u4e66",
|
"certificate": "\u8bc1\u4e66",
|
||||||
"import_batch": "\u5bfc\u5165\u6279\u6b21",
|
"import_batch": "\u5bfc\u5165\u6279\u6b21",
|
||||||
"database_backup": "\u6570\u636e\u5e93\u5907\u4efd",
|
"database_backup": "\u6570\u636e\u5e93\u5907\u4efd",
|
||||||
|
"system_setting": "\u7cfb\u7edf\u8bbe\u7f6e",
|
||||||
"public_access": "\u516c\u5f00\u8bbf\u95ee",
|
"public_access": "\u516c\u5f00\u8bbf\u95ee",
|
||||||
}
|
}
|
||||||
HIGH_RISK_ACTIONS = {
|
HIGH_RISK_ACTIONS = {
|
||||||
@@ -48,6 +50,7 @@ MEDIUM_RISK_ACTIONS = {
|
|||||||
"confirm_import_batch",
|
"confirm_import_batch",
|
||||||
"create_certificate",
|
"create_certificate",
|
||||||
"create_database_backup",
|
"create_database_backup",
|
||||||
|
"update_pdf_settings",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -116,6 +119,8 @@ def log_summary(action: str, object_type: str, object_id: object, detail: dict[s
|
|||||||
filename = detail.get("filename") or object_id
|
filename = detail.get("filename") or object_id
|
||||||
size_label = detail.get("size_label")
|
size_label = detail.get("size_label")
|
||||||
return f"{label}\uff1a{filename}" + (f"\uff08{size_label}\uff09" if size_label else "")
|
return f"{label}\uff1a{filename}" + (f"\uff08{size_label}\uff09" if size_label else "")
|
||||||
|
if action == "update_pdf_settings":
|
||||||
|
return f"{label}\uff1a\u5e76\u53d1\u6570 {detail.get('before')} -> {detail.get('after')}"
|
||||||
if action == "public_search_certificate":
|
if action == "public_search_certificate":
|
||||||
mode = "\u8bc1\u4e66\u7f16\u53f7" if detail.get("mode") == "certificate_no" else "\u624b\u673a\u53f7"
|
mode = "\u8bc1\u4e66\u7f16\u53f7" if detail.get("mode") == "certificate_no" else "\u624b\u673a\u53f7"
|
||||||
return f"{label}\uff1a{detail.get('name') or '-'} \u7528{mode}\u67e5\u8be2\uff0c\u5339\u914d {detail.get('matched_count', 0)} \u6761"
|
return f"{label}\uff1a{detail.get('name') or '-'} \u7528{mode}\u67e5\u8be2\uff0c\u5339\u914d {detail.get('matched_count', 0)} \u6761"
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import threading
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from time import monotonic
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
@@ -10,6 +12,35 @@ from app.models import Certificate, CertificateAccessToken, Learner, ProjectCour
|
|||||||
DESIGN_WIDTH = 1024
|
DESIGN_WIDTH = 1024
|
||||||
DESIGN_HEIGHT = 759
|
DESIGN_HEIGHT = 759
|
||||||
PDF_BASE_RESOLUTION = 150.0
|
PDF_BASE_RESOLUTION = 150.0
|
||||||
|
PDF_GENERATION_WAIT_TIMEOUT_SECONDS = 120
|
||||||
|
|
||||||
|
|
||||||
|
class PdfGenerationBusy(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PdfGenerationLimiter:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._condition = threading.Condition()
|
||||||
|
self._active = 0
|
||||||
|
|
||||||
|
def acquire(self, limit: int, timeout_seconds: int = PDF_GENERATION_WAIT_TIMEOUT_SECONDS) -> None:
|
||||||
|
deadline = monotonic() + timeout_seconds
|
||||||
|
with self._condition:
|
||||||
|
while self._active >= limit:
|
||||||
|
remaining = deadline - monotonic()
|
||||||
|
if remaining <= 0:
|
||||||
|
raise PdfGenerationBusy("当前PDF生成任务较多,请稍后重试")
|
||||||
|
self._condition.wait(remaining)
|
||||||
|
self._active += 1
|
||||||
|
|
||||||
|
def release(self) -> None:
|
||||||
|
with self._condition:
|
||||||
|
self._active = max(0, self._active - 1)
|
||||||
|
self._condition.notify_all()
|
||||||
|
|
||||||
|
|
||||||
|
pdf_generation_limiter = PdfGenerationLimiter()
|
||||||
|
|
||||||
|
|
||||||
def pdf_cache_path(certificate_no: str) -> Path:
|
def pdf_cache_path(certificate_no: str) -> Path:
|
||||||
@@ -51,6 +82,7 @@ def render_certificate_pdf(
|
|||||||
learner: Learner,
|
learner: Learner,
|
||||||
project: ProjectCourse | None,
|
project: ProjectCourse | None,
|
||||||
token: CertificateAccessToken,
|
token: CertificateAccessToken,
|
||||||
|
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_path = certificate_template_path()
|
||||||
@@ -60,8 +92,15 @@ def render_certificate_pdf(
|
|||||||
update_pdf_access_time(certificate)
|
update_pdf_access_time(certificate)
|
||||||
return output_path
|
return output_path
|
||||||
|
|
||||||
image = render_certificate_image(certificate, learner, project)
|
pdf_generation_limiter.acquire(max(1, concurrency_limit))
|
||||||
image.save(output_path, "PDF", resolution=pdf_resolution(image))
|
try:
|
||||||
|
if cached_pdf_is_fresh(output_path) and output_path.stat().st_mtime >= latest_renderer_mtime:
|
||||||
|
update_pdf_access_time(certificate)
|
||||||
|
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_status = "generated"
|
||||||
certificate.pdf_file_path = str(output_path)
|
certificate.pdf_file_path = str(output_path)
|
||||||
|
|||||||
64
backend/app/services/system_settings.py
Normal file
64
backend/app/services/system_settings.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import SystemSetting
|
||||||
|
|
||||||
|
PDF_GENERATION_CONCURRENCY_KEY = "pdf_generation_concurrency_limit"
|
||||||
|
DEFAULT_PDF_GENERATION_CONCURRENCY = 2
|
||||||
|
MIN_PDF_GENERATION_CONCURRENCY = 1
|
||||||
|
MAX_PDF_GENERATION_CONCURRENCY = 10
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_default_settings(db: Session) -> None:
|
||||||
|
if not db.get(SystemSetting, PDF_GENERATION_CONCURRENCY_KEY):
|
||||||
|
db.add(
|
||||||
|
SystemSetting(
|
||||||
|
key=PDF_GENERATION_CONCURRENCY_KEY,
|
||||||
|
value=str(DEFAULT_PDF_GENERATION_CONCURRENCY),
|
||||||
|
description="PDF证书同时生成数量限制",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_pdf_generation_concurrency_limit(db: Session) -> int:
|
||||||
|
setting = db.get(SystemSetting, PDF_GENERATION_CONCURRENCY_KEY)
|
||||||
|
return _coerce_limit(setting.value if setting else None)
|
||||||
|
|
||||||
|
|
||||||
|
def get_pdf_settings(db: Session) -> dict[str, object]:
|
||||||
|
ensure_default_settings(db)
|
||||||
|
setting = db.get(SystemSetting, PDF_GENERATION_CONCURRENCY_KEY)
|
||||||
|
return {
|
||||||
|
"pdf_generation_concurrency_limit": get_pdf_generation_concurrency_limit(db),
|
||||||
|
"updated_at": setting.updated_at if setting else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def update_pdf_generation_concurrency_limit(db: Session, value: int) -> dict[str, object]:
|
||||||
|
limit = _coerce_limit(value)
|
||||||
|
ensure_default_settings(db)
|
||||||
|
setting = db.get(SystemSetting, PDF_GENERATION_CONCURRENCY_KEY)
|
||||||
|
if setting is None:
|
||||||
|
setting = SystemSetting(
|
||||||
|
key=PDF_GENERATION_CONCURRENCY_KEY,
|
||||||
|
value=str(limit),
|
||||||
|
description="PDF证书同时生成数量限制",
|
||||||
|
)
|
||||||
|
db.add(setting)
|
||||||
|
else:
|
||||||
|
setting.value = str(limit)
|
||||||
|
setting.updated_at = datetime.utcnow()
|
||||||
|
db.flush()
|
||||||
|
return {
|
||||||
|
"pdf_generation_concurrency_limit": limit,
|
||||||
|
"updated_at": setting.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_limit(value: object) -> int:
|
||||||
|
try:
|
||||||
|
limit = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
limit = DEFAULT_PDF_GENERATION_CONCURRENCY
|
||||||
|
return max(MIN_PDF_GENERATION_CONCURRENCY, min(MAX_PDF_GENERATION_CONCURRENCY, limit))
|
||||||
13
backend/tests/test_pdf_limiter.py
Normal file
13
backend/tests/test_pdf_limiter.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.pdf import PdfGenerationBusy, PdfGenerationLimiter
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_generation_limiter_times_out_when_full():
|
||||||
|
limiter = PdfGenerationLimiter()
|
||||||
|
limiter.acquire(limit=1)
|
||||||
|
with pytest.raises(PdfGenerationBusy):
|
||||||
|
limiter.acquire(limit=1, timeout_seconds=0)
|
||||||
|
limiter.release()
|
||||||
|
limiter.acquire(limit=1, timeout_seconds=0)
|
||||||
|
limiter.release()
|
||||||
8
backend/tests/test_system_settings.py
Normal file
8
backend/tests/test_system_settings.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from app.services.system_settings import _coerce_limit
|
||||||
|
|
||||||
|
|
||||||
|
def test_pdf_concurrency_limit_is_clamped():
|
||||||
|
assert _coerce_limit(None) == 2
|
||||||
|
assert _coerce_limit(0) == 1
|
||||||
|
assert _coerce_limit(6) == 6
|
||||||
|
assert _coerce_limit(99) == 10
|
||||||
@@ -36,6 +36,7 @@ docker compose exec backend python -m app.cli init-db --admin-username admin --a
|
|||||||
|
|
||||||
- 项目代码已维护,例如 `DBY`、`QSX`。
|
- 项目代码已维护,例如 `DBY`、`QSX`。
|
||||||
- 发证单位名称已确认。
|
- 发证单位名称已确认。
|
||||||
|
- 系统设置里的 `PDF 生成并发限制` 已确认,2 核 4G 建议保持默认 `2`。
|
||||||
- Excel 导入模板已下载并试填。
|
- Excel 导入模板已下载并试填。
|
||||||
- 占位证书模板已确认可临时使用。
|
- 占位证书模板已确认可临时使用。
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ reset-local-data.bat
|
|||||||
4. 进入“证书管理”,手动新增证书,支持单张预览、PDF 下载、作废、重置直达链接和导出。
|
4. 进入“证书管理”,手动新增证书,支持单张预览、PDF 下载、作废、重置直达链接和导出。
|
||||||
5. 进入“Excel 导入”,下载模板、上传 Excel、查看校验结果,确认后正式导入。模板只需要填写姓名、手机号、项目代码、发证日期;证书名称、课程名称、阶段名称、发证单位从项目配置自动带出。
|
5. 进入“Excel 导入”,下载模板、上传 Excel、查看校验结果,确认后正式导入。模板只需要填写姓名、手机号、项目代码、发证日期;证书名称、课程名称、阶段名称、发证单位从项目配置自动带出。
|
||||||
“确认导入”表示把已校验通过的临时数据正式写入学员和证书表。导入记录支持下载原文件、下载错误报告和删除;删除导入记录只删除上传文件、错误报告和批次记录,不删除已经生成的学员和证书。
|
“确认导入”表示把已校验通过的临时数据正式写入学员和证书表。导入记录支持下载原文件、下载错误报告和删除;删除导入记录只删除上传文件、错误报告和批次记录,不删除已经生成的学员和证书。
|
||||||
6. 进入“操作日志”,查看关键后台操作。日志动作已转成中文显示。
|
6. 进入“系统设置”,确认 `PDF 生成并发限制`。2 核 4G 服务器建议保持默认值 `2`。
|
||||||
|
7. 进入“操作日志”,查看关键后台操作。日志动作已转成中文显示。
|
||||||
|
|
||||||
## 公开查询流程
|
## 公开查询流程
|
||||||
|
|
||||||
@@ -62,6 +63,8 @@ PDF 由服务端按需生成,并使用 30 天缓存。这样可以保证模板
|
|||||||
|
|
||||||
后台 PDF 下载不能用简单的 `window.location` 直接跳转,因为后台接口需要登录态。如果直接打开下载地址,浏览器不会自动带上前端保存的 Authorization token,容易出现下载失败。当前后台已改为通过带 token 的请求下载 blob 文件,再在浏览器里保存。
|
后台 PDF 下载不能用简单的 `window.location` 直接跳转,因为后台接口需要登录态。如果直接打开下载地址,浏览器不会自动带上前端保存的 Authorization token,容易出现下载失败。当前后台已改为通过带 token 的请求下载 blob 文件,再在浏览器里保存。
|
||||||
|
|
||||||
|
首次生成 PDF 会占用 CPU。后台“系统设置”里的 `PDF 生成并发限制` 默认是 `2`,用于防止多人同时首次下载 PDF 时把 2 核 4G 服务器打满。前端下载和预览时会显示“PDF正在排队或生成”,避免用户误以为页面卡死。
|
||||||
|
|
||||||
## 正式部署准备
|
## 正式部署准备
|
||||||
|
|
||||||
- Linux 服务器,建议 2 核 4G 起步。
|
- Linux 服务器,建议 2 核 4G 起步。
|
||||||
|
|||||||
@@ -394,6 +394,7 @@ docker compose exec backend python -m app.cli init-db --admin-username admin --a
|
|||||||
- 创建基础角色。
|
- 创建基础角色。
|
||||||
- 创建默认管理员。
|
- 创建默认管理员。
|
||||||
- 初始化默认项目配置。
|
- 初始化默认项目配置。
|
||||||
|
- 初始化系统设置,`PDF 生成并发限制` 默认值为 `2`。
|
||||||
|
|
||||||
以后升级代码时,一般执行:
|
以后升级代码时,一般执行:
|
||||||
|
|
||||||
|
|||||||
@@ -367,6 +367,7 @@ docker compose exec backend python -m app.cli init-db --admin-username admin --a
|
|||||||
- 创建基础角色。
|
- 创建基础角色。
|
||||||
- 创建默认管理员。
|
- 创建默认管理员。
|
||||||
- 初始化默认项目配置。
|
- 初始化默认项目配置。
|
||||||
|
- 初始化系统设置,`PDF 生成并发限制` 默认值为 `2`。
|
||||||
|
|
||||||
如果后续只是升级代码,一般执行:
|
如果后续只是升级代码,一般执行:
|
||||||
|
|
||||||
|
|||||||
@@ -119,3 +119,8 @@ export interface RestoreDatabaseBackupResult {
|
|||||||
restored_backup: DatabaseBackup;
|
restored_backup: DatabaseBackup;
|
||||||
pre_restore_backup: DatabaseBackup;
|
pre_restore_backup: DatabaseBackup;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PdfSettings {
|
||||||
|
pdf_generation_concurrency_limit: number;
|
||||||
|
updated_at: string | null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,9 +2,26 @@ import { http } from "./api";
|
|||||||
|
|
||||||
export async function downloadFile(url: string, filename: string) {
|
export async function downloadFile(url: string, filename: string) {
|
||||||
const { data } = await http.get(url, { responseType: "blob" });
|
const { data } = await http.get(url, { responseType: "blob" });
|
||||||
|
saveBlob(data, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveBlob(data: Blob, filename: string) {
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = URL.createObjectURL(data);
|
link.href = URL.createObjectURL(data);
|
||||||
link.download = filename;
|
link.download = filename;
|
||||||
link.click();
|
link.click();
|
||||||
URL.revokeObjectURL(link.href);
|
URL.revokeObjectURL(link.href);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function apiErrorMessage(error: any, fallback: string) {
|
||||||
|
const data = error?.response?.data;
|
||||||
|
if (data instanceof Blob) {
|
||||||
|
const text = await data.text();
|
||||||
|
try {
|
||||||
|
return JSON.parse(text).detail || fallback;
|
||||||
|
} catch {
|
||||||
|
return text || fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data?.detail || fallback;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import AdminLogin from "../views/AdminLogin.vue";
|
|||||||
import AdminLogs from "../views/AdminLogs.vue";
|
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 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";
|
||||||
@@ -28,7 +29,8 @@ export const router = createRouter({
|
|||||||
{ path: "certificates", component: AdminCertificates },
|
{ path: "certificates", component: AdminCertificates },
|
||||||
{ path: "imports", component: AdminImports },
|
{ path: "imports", component: AdminImports },
|
||||||
{ path: "logs", component: AdminLogs },
|
{ path: "logs", component: AdminLogs },
|
||||||
{ path: "backups", component: AdminBackups }
|
{ path: "backups", component: AdminBackups },
|
||||||
|
{ path: "settings", component: AdminSettings }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{ path: "/query", component: CertificateQuery },
|
{ path: "/query", component: CertificateQuery },
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
<el-button size="small" @click="previewCertificate(row)">预览</el-button>
|
<el-button size="small" @click="previewCertificate(row)">预览</el-button>
|
||||||
<el-button size="small" @click="downloadCertificate(row)">下载</el-button>
|
<el-button size="small" :loading="downloadingCertificateId === row.id" @click="downloadCertificate(row)">下载</el-button>
|
||||||
<el-button size="small" @click="regenerateToken(row)">重置链接</el-button>
|
<el-button size="small" @click="regenerateToken(row)">重置链接</el-button>
|
||||||
<el-button v-if="row.status === 'valid'" size="small" type="danger" @click="voidCertificate(row)">作废</el-button>
|
<el-button v-if="row.status === 'valid'" size="small" type="danger" @click="voidCertificate(row)">作废</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,11 +94,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElLoading, ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { computed, onMounted, reactive, ref } from "vue";
|
import { computed, onMounted, reactive, ref } from "vue";
|
||||||
|
|
||||||
import { http, type AdminCertificate, type ProjectCourse } from "../api";
|
import { http, type AdminCertificate, type ProjectCourse } from "../api";
|
||||||
import { downloadFile } from "../download";
|
import { apiErrorMessage, downloadFile } from "../download";
|
||||||
|
|
||||||
const certificates = ref<AdminCertificate[]>([]);
|
const certificates = ref<AdminCertificate[]>([]);
|
||||||
const projects = ref<ProjectCourse[]>([]);
|
const projects = ref<ProjectCourse[]>([]);
|
||||||
@@ -106,6 +106,7 @@ const keyword = ref("");
|
|||||||
const statusValue = ref("");
|
const statusValue = ref("");
|
||||||
const previewVisible = ref(false);
|
const previewVisible = ref(false);
|
||||||
const preview = ref<any>(null);
|
const preview = ref<any>(null);
|
||||||
|
const downloadingCertificateId = ref<number | null>(null);
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
learner_id: undefined as number | undefined,
|
learner_id: undefined as number | undefined,
|
||||||
project_code: "",
|
project_code: "",
|
||||||
@@ -159,7 +160,20 @@ async function previewCertificate(row: AdminCertificate) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function downloadCertificate(row: AdminCertificate) {
|
async function downloadCertificate(row: AdminCertificate) {
|
||||||
await downloadFile(`/admin/certificates/${row.id}/download`, `${row.certificate_no}.pdf`);
|
downloadingCertificateId.value = row.id;
|
||||||
|
const loading = ElLoading.service({
|
||||||
|
lock: true,
|
||||||
|
text: "PDF正在排队或生成,请稍候...",
|
||||||
|
background: "rgba(255, 255, 255, 0.72)",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await downloadFile(`/admin/certificates/${row.id}/download`, `${row.certificate_no}.pdf`);
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(await apiErrorMessage(error, "PDF生成失败,请稍后再试"));
|
||||||
|
} finally {
|
||||||
|
loading.close();
|
||||||
|
downloadingCertificateId.value = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function voidCertificate(row: AdminCertificate) {
|
async function voidCertificate(row: AdminCertificate) {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
<el-option label="证书" value="certificate" />
|
<el-option label="证书" value="certificate" />
|
||||||
<el-option label="导入批次" value="import_batch" />
|
<el-option label="导入批次" value="import_batch" />
|
||||||
<el-option label="数据库备份" value="database_backup" />
|
<el-option label="数据库备份" value="database_backup" />
|
||||||
|
<el-option label="系统设置" value="system_setting" />
|
||||||
<el-option label="公开访问" value="public_access" />
|
<el-option label="公开访问" value="public_access" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-select v-model="filters.risk" clearable placeholder="风险等级">
|
<el-select v-model="filters.risk" clearable placeholder="风险等级">
|
||||||
@@ -122,6 +123,7 @@ const actionOptions = [
|
|||||||
{ value: "export_certificates", label: "导出证书" },
|
{ value: "export_certificates", label: "导出证书" },
|
||||||
{ value: "create_database_backup", label: "创建数据库备份" },
|
{ value: "create_database_backup", label: "创建数据库备份" },
|
||||||
{ value: "restore_database_backup", label: "回滚数据库备份" },
|
{ value: "restore_database_backup", label: "回滚数据库备份" },
|
||||||
|
{ value: "update_pdf_settings", label: "修改PDF生成设置" },
|
||||||
{ value: "public_search_certificate", label: "公开查询证书" },
|
{ value: "public_search_certificate", label: "公开查询证书" },
|
||||||
{ value: "public_download_certificate", label: "公开下载证书" },
|
{ value: "public_download_certificate", label: "公开下载证书" },
|
||||||
];
|
];
|
||||||
|
|||||||
114
frontend/src/views/AdminSettings.vue
Normal file
114
frontend/src/views/AdminSettings.vue
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<header class="page-head">
|
||||||
|
<div>
|
||||||
|
<h2>系统设置</h2>
|
||||||
|
<p>调整会影响正式服务运行的后台参数。</p>
|
||||||
|
</div>
|
||||||
|
<el-button @click="loadSettings">刷新</el-button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<el-card class="panel" shadow="never">
|
||||||
|
<div class="setting-head">
|
||||||
|
<div>
|
||||||
|
<h3>PDF 生成并发限制</h3>
|
||||||
|
<p>限制同时生成证书 PDF 的数量。2核4G服务器建议保持默认值 2。</p>
|
||||||
|
</div>
|
||||||
|
<el-tag type="info">默认 2</el-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form class="settings-form" label-position="top">
|
||||||
|
<el-form-item label="同时生成 PDF 数量">
|
||||||
|
<el-input-number v-model="form.pdf_generation_concurrency_limit" :min="1" :max="10" :step="1" />
|
||||||
|
<div class="form-tip">
|
||||||
|
缓存命中的 PDF 下载不占用生成名额;首次生成会排队等待,前台会显示生成中提示。
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-button type="primary" :loading="saving" @click="saveSettings">保存设置</el-button>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
|
import { onMounted, reactive, ref } from "vue";
|
||||||
|
|
||||||
|
import { http, type PdfSettings } from "../api";
|
||||||
|
|
||||||
|
const saving = ref(false);
|
||||||
|
const form = reactive({
|
||||||
|
pdf_generation_concurrency_limit: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
const { data } = await http.get<PdfSettings>("/admin/settings/pdf");
|
||||||
|
form.pdf_generation_concurrency_limit = data.pdf_generation_concurrency_limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings() {
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await http.put<PdfSettings>("/admin/settings/pdf", {
|
||||||
|
pdf_generation_concurrency_limit: form.pdf_generation_concurrency_limit,
|
||||||
|
});
|
||||||
|
form.pdf_generation_concurrency_limit = data.pdf_generation_concurrency_limit;
|
||||||
|
ElMessage.success("系统设置已保存");
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadSettings);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head p,
|
||||||
|
.setting-head p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form {
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-tip {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.page-head,
|
||||||
|
.setting-head {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
<el-menu-item index="/admin/imports">Excel 导入</el-menu-item>
|
<el-menu-item index="/admin/imports">Excel 导入</el-menu-item>
|
||||||
<el-menu-item index="/admin/logs">操作日志</el-menu-item>
|
<el-menu-item index="/admin/logs">操作日志</el-menu-item>
|
||||||
<el-menu-item index="/admin/backups">备份管理</el-menu-item>
|
<el-menu-item index="/admin/backups">备份管理</el-menu-item>
|
||||||
|
<el-menu-item index="/admin/settings">系统设置</el-menu-item>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
<el-button class="logout" @click="logout">退出登录</el-button>
|
<el-button class="logout" @click="logout">退出登录</el-button>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -97,7 +97,14 @@
|
|||||||
|
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<el-button @click="preview(item)">预览</el-button>
|
<el-button @click="preview(item)">预览</el-button>
|
||||||
<el-button type="primary" :disabled="!item.can_download_pdf" @click="downloadPdf(item)">下载 PDF</el-button>
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:disabled="!item.can_download_pdf"
|
||||||
|
:loading="downloadingCertificateNo === item.certificate_no"
|
||||||
|
@click="downloadPdf(item)"
|
||||||
|
>
|
||||||
|
下载 PDF
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
@@ -111,6 +118,10 @@
|
|||||||
<div v-if="previewPdfUrl" class="pdf-frame">
|
<div v-if="previewPdfUrl" class="pdf-frame">
|
||||||
<embed :src="previewPdfUrl" type="application/pdf" />
|
<embed :src="previewPdfUrl" type="application/pdf" />
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="previewLoading" class="pdf-loading">
|
||||||
|
<el-icon class="is-loading"><Loading /></el-icon>
|
||||||
|
<p>PDF正在排队或生成,请稍候...</p>
|
||||||
|
</div>
|
||||||
<div v-else-if="current" class="preview-card">
|
<div v-else-if="current" class="preview-card">
|
||||||
<p class="preview-kicker">Training Completion Certificate</p>
|
<p class="preview-kicker">Training Completion Certificate</p>
|
||||||
<h2>{{ current.certificate_name }}</h2>
|
<h2>{{ current.certificate_name }}</h2>
|
||||||
@@ -126,16 +137,26 @@
|
|||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="closePreview">关闭</el-button>
|
<el-button @click="closePreview">关闭</el-button>
|
||||||
<el-button v-if="current?.can_download_pdf" type="primary" @click="downloadPdf(current)">下载 PDF</el-button>
|
<el-button
|
||||||
|
v-if="current?.can_download_pdf"
|
||||||
|
type="primary"
|
||||||
|
:loading="downloadingCertificateNo === current.certificate_no"
|
||||||
|
@click="downloadPdf(current)"
|
||||||
|
>
|
||||||
|
下载 PDF
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Loading } from "@element-plus/icons-vue";
|
||||||
|
import { ElLoading, ElMessage } from "element-plus";
|
||||||
import { onMounted, onUnmounted, reactive, ref, watch } from "vue";
|
import { onMounted, onUnmounted, reactive, ref, watch } from "vue";
|
||||||
|
|
||||||
import { http, type Captcha, type PublicCertificate } from "../api";
|
import { http, type Captcha, type PublicCertificate } from "../api";
|
||||||
|
import { apiErrorMessage, saveBlob } from "../download";
|
||||||
|
|
||||||
interface SearchResponse {
|
interface SearchResponse {
|
||||||
items: PublicCertificate[];
|
items: PublicCertificate[];
|
||||||
@@ -159,6 +180,8 @@ const results = ref<PublicCertificate[]>([]);
|
|||||||
const previewVisible = ref(false);
|
const previewVisible = ref(false);
|
||||||
const current = ref<PublicCertificate | null>(null);
|
const current = ref<PublicCertificate | null>(null);
|
||||||
const previewPdfUrl = ref("");
|
const previewPdfUrl = ref("");
|
||||||
|
const previewLoading = ref(false);
|
||||||
|
const downloadingCertificateNo = ref("");
|
||||||
const smsId = ref("");
|
const smsId = ref("");
|
||||||
const sendingSms = ref(false);
|
const sendingSms = ref(false);
|
||||||
const smsCooldown = ref(0);
|
const smsCooldown = ref(0);
|
||||||
@@ -285,12 +308,20 @@ async function submitQuery() {
|
|||||||
async function preview(item: PublicCertificate) {
|
async function preview(item: PublicCertificate) {
|
||||||
current.value = item;
|
current.value = item;
|
||||||
clearPreviewUrl();
|
clearPreviewUrl();
|
||||||
if (item.can_download_pdf && item.download_url) {
|
|
||||||
const url = item.download_url.replace(/^\/api/, "");
|
|
||||||
const { data } = await http.get(url, { responseType: "blob" });
|
|
||||||
previewPdfUrl.value = URL.createObjectURL(data);
|
|
||||||
}
|
|
||||||
previewVisible.value = true;
|
previewVisible.value = true;
|
||||||
|
if (item.can_download_pdf && item.download_url) {
|
||||||
|
previewLoading.value = true;
|
||||||
|
try {
|
||||||
|
const url = item.download_url.replace(/^\/api/, "");
|
||||||
|
const { data } = await http.get(url, { responseType: "blob" });
|
||||||
|
previewPdfUrl.value = URL.createObjectURL(data);
|
||||||
|
} catch (error: any) {
|
||||||
|
message.value = await apiErrorMessage(error, "PDF生成失败,请稍后再试");
|
||||||
|
messageType.value = "error";
|
||||||
|
} finally {
|
||||||
|
previewLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearPreviewUrl() {
|
function clearPreviewUrl() {
|
||||||
@@ -304,8 +335,24 @@ function closePreview() {
|
|||||||
previewVisible.value = false;
|
previewVisible.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadPdf(item: PublicCertificate) {
|
async function downloadPdf(item: PublicCertificate) {
|
||||||
if (item.download_url) window.location.href = item.download_url;
|
if (!item.download_url) return;
|
||||||
|
downloadingCertificateNo.value = item.certificate_no;
|
||||||
|
const loading = ElLoading.service({
|
||||||
|
lock: true,
|
||||||
|
text: "PDF正在排队或生成,请稍候...",
|
||||||
|
background: "rgba(255, 255, 255, 0.72)",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const url = item.download_url.replace(/^\/api/, "");
|
||||||
|
const { data } = await http.get(url, { responseType: "blob" });
|
||||||
|
saveBlob(data, `${item.certificate_no}.pdf`);
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(await apiErrorMessage(error, "PDF生成失败,请稍后再试"));
|
||||||
|
} finally {
|
||||||
|
loading.close();
|
||||||
|
downloadingCertificateNo.value = "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadCaptcha);
|
onMounted(loadCaptcha);
|
||||||
@@ -520,6 +567,20 @@ h1 {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pdf-loading {
|
||||||
|
min-height: 240px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
align-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-loading .el-icon {
|
||||||
|
color: #16817e;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-kicker {
|
.preview-kicker {
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
color: #16817e;
|
color: #16817e;
|
||||||
|
|||||||
@@ -26,23 +26,28 @@
|
|||||||
<dd>{{ certificate.issuer_name }}</dd>
|
<dd>{{ certificate.issuer_name }}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<el-button v-if="certificate.can_download_pdf" type="primary" class="download" @click="downloadPdf">下载 PDF 证书</el-button>
|
<el-button v-if="certificate.can_download_pdf" type="primary" class="download" :loading="downloading" @click="downloadPdf">
|
||||||
|
下载 PDF 证书
|
||||||
|
</el-button>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ElLoading, ElMessage } from "element-plus";
|
||||||
import { onMounted, ref } from "vue";
|
import { onMounted, ref } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
|
|
||||||
import { http, type PublicCertificate } from "../api";
|
import { http, type PublicCertificate } from "../api";
|
||||||
|
import { apiErrorMessage, saveBlob } from "../download";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const token = String(route.params.token);
|
const token = String(route.params.token);
|
||||||
const certificate = ref<PublicCertificate | null>(null);
|
const certificate = ref<PublicCertificate | null>(null);
|
||||||
const message = ref("");
|
const message = ref("");
|
||||||
const messageType = ref<"success" | "warning" | "error">("success");
|
const messageType = ref<"success" | "warning" | "error">("success");
|
||||||
|
const downloading = ref(false);
|
||||||
|
|
||||||
function statusText(status: string) {
|
function statusText(status: string) {
|
||||||
return status === "valid" ? "有效" : "已作废";
|
return status === "valid" ? "有效" : "已作废";
|
||||||
@@ -60,8 +65,23 @@ async function loadCertificate() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadPdf() {
|
async function downloadPdf() {
|
||||||
window.location.href = `/api/public/certificates/token/${token}/download`;
|
if (!certificate.value) return;
|
||||||
|
downloading.value = true;
|
||||||
|
const loading = ElLoading.service({
|
||||||
|
lock: true,
|
||||||
|
text: "PDF正在排队或生成,请稍候...",
|
||||||
|
background: "rgba(255, 255, 255, 0.72)",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const { data } = await http.get(`/public/certificates/token/${token}/download`, { responseType: "blob" });
|
||||||
|
saveBlob(data, `${certificate.value.certificate_no}.pdf`);
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(await apiErrorMessage(error, "PDF生成失败,请稍后再试"));
|
||||||
|
} finally {
|
||||||
|
loading.close();
|
||||||
|
downloading.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadCertificate);
|
onMounted(loadCertificate);
|
||||||
|
|||||||
Reference in New Issue
Block a user