新增PDF生成并发设置

This commit is contained in:
2026-06-23 12:54:14 +08:00
parent 529e175d2c
commit 1e9404cc7d
27 changed files with 541 additions and 24 deletions

View 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")

View File

@@ -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"])

View File

@@ -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")

View 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

View File

@@ -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")

View File

@@ -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()

View File

@@ -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",
]

View 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)

View 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)

View File

@@ -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"

View File

@@ -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)

View 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))

View 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()

View 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

View File

@@ -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 导入模板已下载并试填。
- 占位证书模板已确认可临时使用。

View File

@@ -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 起步。

View File

@@ -394,6 +394,7 @@ docker compose exec backend python -m app.cli init-db --admin-username admin --a
- 创建基础角色。
- 创建默认管理员。
- 初始化默认项目配置。
- 初始化系统设置,`PDF 生成并发限制` 默认值为 `2`
以后升级代码时,一般执行:

View File

@@ -367,6 +367,7 @@ docker compose exec backend python -m app.cli init-db --admin-username admin --a
- 创建基础角色。
- 创建默认管理员。
- 初始化默认项目配置。
- 初始化系统设置,`PDF 生成并发限制` 默认值为 `2`
如果后续只是升级代码,一般执行:

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 },

View File

@@ -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) {
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) {

View File

@@ -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: "公开下载证书" },
];

View 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>

View File

@@ -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>

View File

@@ -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();
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;
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() {
@@ -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;

View File

@@ -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);