新增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_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"])
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
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.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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
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",
|
||||
"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"
|
||||
|
||||
@@ -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
|
||||
|
||||
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)
|
||||
|
||||
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`。
|
||||
- 发证单位名称已确认。
|
||||
- 系统设置里的 `PDF 生成并发限制` 已确认,2 核 4G 建议保持默认 `2`。
|
||||
- Excel 导入模板已下载并试填。
|
||||
- 占位证书模板已确认可临时使用。
|
||||
|
||||
|
||||
@@ -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 起步。
|
||||
|
||||
@@ -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;
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<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 v-if="row.status === 'valid'" size="small" type="danger" @click="voidCertificate(row)">作废</el-button>
|
||||
</div>
|
||||
@@ -94,11 +94,11 @@
|
||||
</template>
|
||||
|
||||
<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 { http, type AdminCertificate, type ProjectCourse } from "../api";
|
||||
import { downloadFile } from "../download";
|
||||
import { apiErrorMessage, downloadFile } from "../download";
|
||||
|
||||
const certificates = ref<AdminCertificate[]>([]);
|
||||
const projects = ref<ProjectCourse[]>([]);
|
||||
@@ -106,6 +106,7 @@ const keyword = ref("");
|
||||
const statusValue = ref("");
|
||||
const previewVisible = ref(false);
|
||||
const preview = ref<any>(null);
|
||||
const downloadingCertificateId = ref<number | null>(null);
|
||||
const form = reactive({
|
||||
learner_id: undefined as number | undefined,
|
||||
project_code: "",
|
||||
@@ -159,7 +160,20 @@ async function previewCertificate(row: AdminCertificate) {
|
||||
}
|
||||
|
||||
async function downloadCertificate(row: AdminCertificate) {
|
||||
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) {
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<el-option label="证书" value="certificate" />
|
||||
<el-option label="导入批次" value="import_batch" />
|
||||
<el-option label="数据库备份" value="database_backup" />
|
||||
<el-option label="系统设置" value="system_setting" />
|
||||
<el-option label="公开访问" value="public_access" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.risk" clearable placeholder="风险等级">
|
||||
@@ -122,6 +123,7 @@ const actionOptions = [
|
||||
{ value: "export_certificates", label: "导出证书" },
|
||||
{ value: "create_database_backup", label: "创建数据库备份" },
|
||||
{ value: "restore_database_backup", label: "回滚数据库备份" },
|
||||
{ value: "update_pdf_settings", label: "修改PDF生成设置" },
|
||||
{ value: "public_search_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/logs">操作日志</el-menu-item>
|
||||
<el-menu-item index="/admin/backups">备份管理</el-menu-item>
|
||||
<el-menu-item index="/admin/settings">系统设置</el-menu-item>
|
||||
</el-menu>
|
||||
<el-button class="logout" @click="logout">退出登录</el-button>
|
||||
</aside>
|
||||
|
||||
@@ -97,7 +97,14 @@
|
||||
|
||||
<div class="card-actions">
|
||||
<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>
|
||||
</article>
|
||||
</section>
|
||||
@@ -111,6 +118,10 @@
|
||||
<div v-if="previewPdfUrl" class="pdf-frame">
|
||||
<embed :src="previewPdfUrl" type="application/pdf" />
|
||||
</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">
|
||||
<p class="preview-kicker">Training Completion Certificate</p>
|
||||
<h2>{{ current.certificate_name }}</h2>
|
||||
@@ -126,16 +137,26 @@
|
||||
</div>
|
||||
<template #footer>
|
||||
<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>
|
||||
</el-dialog>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<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 { http, type Captcha, type PublicCertificate } from "../api";
|
||||
import { apiErrorMessage, saveBlob } from "../download";
|
||||
|
||||
interface SearchResponse {
|
||||
items: PublicCertificate[];
|
||||
@@ -159,6 +180,8 @@ const results = ref<PublicCertificate[]>([]);
|
||||
const previewVisible = ref(false);
|
||||
const current = ref<PublicCertificate | null>(null);
|
||||
const previewPdfUrl = ref("");
|
||||
const previewLoading = ref(false);
|
||||
const downloadingCertificateNo = ref("");
|
||||
const smsId = ref("");
|
||||
const sendingSms = ref(false);
|
||||
const smsCooldown = ref(0);
|
||||
@@ -285,12 +308,20 @@ async function submitQuery() {
|
||||
async function preview(item: PublicCertificate) {
|
||||
current.value = item;
|
||||
clearPreviewUrl();
|
||||
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;
|
||||
}
|
||||
}
|
||||
previewVisible.value = true;
|
||||
}
|
||||
|
||||
function clearPreviewUrl() {
|
||||
@@ -304,8 +335,24 @@ function closePreview() {
|
||||
previewVisible.value = false;
|
||||
}
|
||||
|
||||
function downloadPdf(item: PublicCertificate) {
|
||||
if (item.download_url) window.location.href = item.download_url;
|
||||
async function downloadPdf(item: PublicCertificate) {
|
||||
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);
|
||||
@@ -520,6 +567,20 @@ h1 {
|
||||
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 {
|
||||
margin: 0 0 10px;
|
||||
color: #16817e;
|
||||
|
||||
@@ -26,23 +26,28 @@
|
||||
<dd>{{ certificate.issuer_name }}</dd>
|
||||
</div>
|
||||
</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>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElLoading, ElMessage } from "element-plus";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
import { http, type PublicCertificate } from "../api";
|
||||
import { apiErrorMessage, saveBlob } from "../download";
|
||||
|
||||
const route = useRoute();
|
||||
const token = String(route.params.token);
|
||||
const certificate = ref<PublicCertificate | null>(null);
|
||||
const message = ref("");
|
||||
const messageType = ref<"success" | "warning" | "error">("success");
|
||||
const downloading = ref(false);
|
||||
|
||||
function statusText(status: string) {
|
||||
return status === "valid" ? "有效" : "已作废";
|
||||
@@ -60,8 +65,23 @@ async function loadCertificate() {
|
||||
}
|
||||
}
|
||||
|
||||
function downloadPdf() {
|
||||
window.location.href = `/api/public/certificates/token/${token}/download`;
|
||||
async function downloadPdf() {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user