From 1e9404cc7d4f98f6797eab70c5c7ae86c238a6cc Mon Sep 17 00:00:00 2001
From: Nelson <1475262689@qq.com>
Date: Tue, 23 Jun 2026 12:54:14 +0800
Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9EPDF=E7=94=9F=E6=88=90?=
=?UTF-8?q?=E5=B9=B6=E5=8F=91=E8=AE=BE=E7=BD=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../versions/20260623_0004_system_settings.py | 49 ++++++++
backend/app/api/router.py | 2 +
backend/app/api/routes/admin_certificates.py | 14 ++-
backend/app/api/routes/admin_settings.py | 44 +++++++
backend/app/api/routes/public.py | 14 ++-
backend/app/db/init_db.py | 2 +
backend/app/models/__init__.py | 2 +
backend/app/models/setting.py | 15 +++
backend/app/schemas/setting.py | 12 ++
backend/app/services/logs.py | 5 +
backend/app/services/pdf.py | 43 ++++++-
backend/app/services/system_settings.py | 64 ++++++++++
backend/tests/test_pdf_limiter.py | 13 ++
backend/tests/test_system_settings.py | 8 ++
docs/deployment-checklist.md | 1 +
docs/usage-deployment-manual.md | 5 +-
docs/正式部署操作手册.md | 1 +
docs/阿里云正式部署详细操作手册.md | 1 +
frontend/src/api.ts | 5 +
frontend/src/download.ts | 17 +++
frontend/src/router/index.ts | 4 +-
frontend/src/views/AdminCertificates.vue | 22 +++-
frontend/src/views/AdminLogs.vue | 2 +
frontend/src/views/AdminSettings.vue | 114 ++++++++++++++++++
frontend/src/views/AdminShell.vue | 1 +
frontend/src/views/CertificateQuery.vue | 79 ++++++++++--
frontend/src/views/CertificateView.vue | 26 +++-
27 files changed, 541 insertions(+), 24 deletions(-)
create mode 100644 backend/alembic/versions/20260623_0004_system_settings.py
create mode 100644 backend/app/api/routes/admin_settings.py
create mode 100644 backend/app/models/setting.py
create mode 100644 backend/app/schemas/setting.py
create mode 100644 backend/app/services/system_settings.py
create mode 100644 backend/tests/test_pdf_limiter.py
create mode 100644 backend/tests/test_system_settings.py
create mode 100644 frontend/src/views/AdminSettings.vue
diff --git a/backend/alembic/versions/20260623_0004_system_settings.py b/backend/alembic/versions/20260623_0004_system_settings.py
new file mode 100644
index 0000000..1cedae6
--- /dev/null
+++ b/backend/alembic/versions/20260623_0004_system_settings.py
@@ -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")
diff --git a/backend/app/api/router.py b/backend/app/api/router.py
index 5f4f7f0..9c210c1 100644
--- a/backend/app/api/router.py
+++ b/backend/app/api/router.py
@@ -10,6 +10,7 @@ from app.api.routes import (
admin_logs,
admin_pdf_cleanup,
admin_projects,
+ admin_settings,
auth,
health,
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_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_settings.router, prefix="/admin/settings", tags=["admin-settings"])
api_router.include_router(public.router, prefix="/public", tags=["public"])
diff --git a/backend/app/api/routes/admin_certificates.py b/backend/app/api/routes/admin_certificates.py
index 472a73f..93e8b6b 100644
--- a/backend/app/api/routes/admin_certificates.py
+++ b/backend/app/api/routes/admin_certificates.py
@@ -10,7 +10,8 @@ from app.models import AdminUser, Certificate, CertificateAccessToken, Learner,
from app.schemas.certificate import CertificateCreate, CertificateOut
from app.services.certificate_number import build_certificate_no
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()
@@ -143,7 +144,16 @@ def download_certificate(
project = db.query(ProjectCourse).filter(ProjectCourse.code == certificate.project_code).first()
if not learner or not token:
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()
return FileResponse(pdf_path, media_type="application/pdf", filename=f"{certificate.certificate_no}.pdf")
diff --git a/backend/app/api/routes/admin_settings.py b/backend/app/api/routes/admin_settings.py
new file mode 100644
index 0000000..9b14965
--- /dev/null
+++ b/backend/app/api/routes/admin_settings.py
@@ -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
diff --git a/backend/app/api/routes/public.py b/backend/app/api/routes/public.py
index be2115e..fe1c550 100644
--- a/backend/app/api/routes/public.py
+++ b/backend/app/api/routes/public.py
@@ -8,9 +8,10 @@ from app.db.session import get_db
from app.models import Certificate, CertificateAccessToken, Learner, ProjectCourse
from app.services.captcha import make_captcha, verify_captcha
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.sms import generate_sms_code, verify_sms_code
+from app.services.system_settings import get_pdf_generation_concurrency_limit
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")
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})
db.commit()
return FileResponse(pdf_path, media_type="application/pdf", filename=f"{certificate.certificate_no}.pdf")
diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py
index 23899f8..4b8ed4b 100644
--- a/backend/app/db/init_db.py
+++ b/backend/app/db/init_db.py
@@ -4,6 +4,7 @@ from app.core.security import hash_password
from app.db.base import Base
from app.db.session import engine
from app.models import AdminUser, ProjectCourse, Role
+from app.services.system_settings import ensure_default_settings
def create_tables() -> None:
@@ -47,4 +48,5 @@ def seed_defaults(db: Session, admin_username: str, admin_password: str) -> None
role_code="system_admin",
)
)
+ ensure_default_settings(db)
db.commit()
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index 85c3aa3..e33ae33 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -3,6 +3,7 @@ from app.models.certificate import Certificate, CertificateAccessToken, ProjectC
from app.models.import_batch import ImportBatch, ImportBatchRow
from app.models.learner import Learner, LearnerNameHistory, LearnerPhoneHistory
from app.models.log import OperationLog
+from app.models.setting import SystemSetting
__all__ = [
"AdminUser",
@@ -16,4 +17,5 @@ __all__ = [
"OperationLog",
"ProjectCourse",
"Role",
+ "SystemSetting",
]
diff --git a/backend/app/models/setting.py b/backend/app/models/setting.py
new file mode 100644
index 0000000..f754ba3
--- /dev/null
+++ b/backend/app/models/setting.py
@@ -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)
diff --git a/backend/app/schemas/setting.py b/backend/app/schemas/setting.py
new file mode 100644
index 0000000..debccdd
--- /dev/null
+++ b/backend/app/schemas/setting.py
@@ -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)
diff --git a/backend/app/services/logs.py b/backend/app/services/logs.py
index a69f287..1ab8906 100644
--- a/backend/app/services/logs.py
+++ b/backend/app/services/logs.py
@@ -23,6 +23,7 @@ ACTION_LABELS = {
"export_certificates": "\u5bfc\u51fa\u8bc1\u4e66",
"create_database_backup": "\u521b\u5efa\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_download_certificate": "\u516c\u5f00\u4e0b\u8f7d\u8bc1\u4e66",
}
@@ -33,6 +34,7 @@ OBJECT_LABELS = {
"certificate": "\u8bc1\u4e66",
"import_batch": "\u5bfc\u5165\u6279\u6b21",
"database_backup": "\u6570\u636e\u5e93\u5907\u4efd",
+ "system_setting": "\u7cfb\u7edf\u8bbe\u7f6e",
"public_access": "\u516c\u5f00\u8bbf\u95ee",
}
HIGH_RISK_ACTIONS = {
@@ -48,6 +50,7 @@ MEDIUM_RISK_ACTIONS = {
"confirm_import_batch",
"create_certificate",
"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
size_label = detail.get("size_label")
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":
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"
diff --git a/backend/app/services/pdf.py b/backend/app/services/pdf.py
index c09bb2b..078d507 100644
--- a/backend/app/services/pdf.py
+++ b/backend/app/services/pdf.py
@@ -1,5 +1,7 @@
+import threading
from datetime import date, datetime, timedelta
from pathlib import Path
+from time import monotonic
from PIL import Image, ImageDraw, ImageFont
@@ -10,6 +12,35 @@ from app.models import Certificate, CertificateAccessToken, Learner, ProjectCour
DESIGN_WIDTH = 1024
DESIGN_HEIGHT = 759
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:
@@ -51,6 +82,7 @@ def render_certificate_pdf(
learner: Learner,
project: ProjectCourse | None,
token: CertificateAccessToken,
+ concurrency_limit: int = 2,
) -> Path:
output_path = pdf_cache_path(certificate.certificate_no)
template_path = certificate_template_path()
@@ -60,8 +92,15 @@ def render_certificate_pdf(
update_pdf_access_time(certificate)
return output_path
- image = render_certificate_image(certificate, learner, project)
- image.save(output_path, "PDF", resolution=pdf_resolution(image))
+ 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)
+ 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)
diff --git a/backend/app/services/system_settings.py b/backend/app/services/system_settings.py
new file mode 100644
index 0000000..72c0655
--- /dev/null
+++ b/backend/app/services/system_settings.py
@@ -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))
diff --git a/backend/tests/test_pdf_limiter.py b/backend/tests/test_pdf_limiter.py
new file mode 100644
index 0000000..16ec1ad
--- /dev/null
+++ b/backend/tests/test_pdf_limiter.py
@@ -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()
diff --git a/backend/tests/test_system_settings.py b/backend/tests/test_system_settings.py
new file mode 100644
index 0000000..298d762
--- /dev/null
+++ b/backend/tests/test_system_settings.py
@@ -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
diff --git a/docs/deployment-checklist.md b/docs/deployment-checklist.md
index abab440..2b1c9f7 100644
--- a/docs/deployment-checklist.md
+++ b/docs/deployment-checklist.md
@@ -36,6 +36,7 @@ docker compose exec backend python -m app.cli init-db --admin-username admin --a
- 项目代码已维护,例如 `DBY`、`QSX`。
- 发证单位名称已确认。
+- 系统设置里的 `PDF 生成并发限制` 已确认,2 核 4G 建议保持默认 `2`。
- Excel 导入模板已下载并试填。
- 占位证书模板已确认可临时使用。
diff --git a/docs/usage-deployment-manual.md b/docs/usage-deployment-manual.md
index e5af314..d68ce8a 100644
--- a/docs/usage-deployment-manual.md
+++ b/docs/usage-deployment-manual.md
@@ -44,7 +44,8 @@ reset-local-data.bat
4. 进入“证书管理”,手动新增证书,支持单张预览、PDF 下载、作废、重置直达链接和导出。
5. 进入“Excel 导入”,下载模板、上传 Excel、查看校验结果,确认后正式导入。模板只需要填写姓名、手机号、项目代码、发证日期;证书名称、课程名称、阶段名称、发证单位从项目配置自动带出。
“确认导入”表示把已校验通过的临时数据正式写入学员和证书表。导入记录支持下载原文件、下载错误报告和删除;删除导入记录只删除上传文件、错误报告和批次记录,不删除已经生成的学员和证书。
-6. 进入“操作日志”,查看关键后台操作。日志动作已转成中文显示。
+6. 进入“系统设置”,确认 `PDF 生成并发限制`。2 核 4G 服务器建议保持默认值 `2`。
+7. 进入“操作日志”,查看关键后台操作。日志动作已转成中文显示。
## 公开查询流程
@@ -62,6 +63,8 @@ PDF 由服务端按需生成,并使用 30 天缓存。这样可以保证模板
后台 PDF 下载不能用简单的 `window.location` 直接跳转,因为后台接口需要登录态。如果直接打开下载地址,浏览器不会自动带上前端保存的 Authorization token,容易出现下载失败。当前后台已改为通过带 token 的请求下载 blob 文件,再在浏览器里保存。
+首次生成 PDF 会占用 CPU。后台“系统设置”里的 `PDF 生成并发限制` 默认是 `2`,用于防止多人同时首次下载 PDF 时把 2 核 4G 服务器打满。前端下载和预览时会显示“PDF正在排队或生成”,避免用户误以为页面卡死。
+
## 正式部署准备
- Linux 服务器,建议 2 核 4G 起步。
diff --git a/docs/正式部署操作手册.md b/docs/正式部署操作手册.md
index 06a2f37..cd89759 100644
--- a/docs/正式部署操作手册.md
+++ b/docs/正式部署操作手册.md
@@ -394,6 +394,7 @@ docker compose exec backend python -m app.cli init-db --admin-username admin --a
- 创建基础角色。
- 创建默认管理员。
- 初始化默认项目配置。
+- 初始化系统设置,`PDF 生成并发限制` 默认值为 `2`。
以后升级代码时,一般执行:
diff --git a/docs/阿里云正式部署详细操作手册.md b/docs/阿里云正式部署详细操作手册.md
index 6a313f1..20d0ae9 100644
--- a/docs/阿里云正式部署详细操作手册.md
+++ b/docs/阿里云正式部署详细操作手册.md
@@ -367,6 +367,7 @@ docker compose exec backend python -m app.cli init-db --admin-username admin --a
- 创建基础角色。
- 创建默认管理员。
- 初始化默认项目配置。
+- 初始化系统设置,`PDF 生成并发限制` 默认值为 `2`。
如果后续只是升级代码,一般执行:
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index cca767d..cab7d09 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -119,3 +119,8 @@ export interface RestoreDatabaseBackupResult {
restored_backup: DatabaseBackup;
pre_restore_backup: DatabaseBackup;
}
+
+export interface PdfSettings {
+ pdf_generation_concurrency_limit: number;
+ updated_at: string | null;
+}
diff --git a/frontend/src/download.ts b/frontend/src/download.ts
index 304953a..87fb5ae 100644
--- a/frontend/src/download.ts
+++ b/frontend/src/download.ts
@@ -2,9 +2,26 @@ import { http } from "./api";
export async function downloadFile(url: string, filename: string) {
const { data } = await http.get(url, { responseType: "blob" });
+ saveBlob(data, filename);
+}
+
+export function saveBlob(data: Blob, filename: string) {
const link = document.createElement("a");
link.href = URL.createObjectURL(data);
link.download = filename;
link.click();
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;
+}
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index 82397f3..03a1911 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -8,6 +8,7 @@ import AdminLogin from "../views/AdminLogin.vue";
import AdminLogs from "../views/AdminLogs.vue";
import AdminLearners from "../views/AdminLearners.vue";
import AdminProjects from "../views/AdminProjects.vue";
+import AdminSettings from "../views/AdminSettings.vue";
import AdminShell from "../views/AdminShell.vue";
import CertificateQuery from "../views/CertificateQuery.vue";
import CertificateView from "../views/CertificateView.vue";
@@ -28,7 +29,8 @@ export const router = createRouter({
{ path: "certificates", component: AdminCertificates },
{ path: "imports", component: AdminImports },
{ path: "logs", component: AdminLogs },
- { path: "backups", component: AdminBackups }
+ { path: "backups", component: AdminBackups },
+ { path: "settings", component: AdminSettings }
]
},
{ path: "/query", component: CertificateQuery },
diff --git a/frontend/src/views/AdminCertificates.vue b/frontend/src/views/AdminCertificates.vue
index 235824a..0c26a91 100644
--- a/frontend/src/views/AdminCertificates.vue
+++ b/frontend/src/views/AdminCertificates.vue
@@ -63,7 +63,7 @@
PDF正在排队或生成,请稍候...
+Training Completion Certificate