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 @@ + + diff --git a/frontend/src/views/AdminShell.vue b/frontend/src/views/AdminShell.vue index afccd92..5b6d0e1 100644 --- a/frontend/src/views/AdminShell.vue +++ b/frontend/src/views/AdminShell.vue @@ -13,6 +13,7 @@ Excel 导入 操作日志 备份管理 + 系统设置 退出登录 diff --git a/frontend/src/views/CertificateQuery.vue b/frontend/src/views/CertificateQuery.vue index 789036f..619dbdc 100644 --- a/frontend/src/views/CertificateQuery.vue +++ b/frontend/src/views/CertificateQuery.vue @@ -97,7 +97,14 @@
预览 - 下载 PDF + + 下载 PDF +
@@ -111,6 +118,10 @@
+
+ +

PDF正在排队或生成,请稍候...

+

Training Completion Certificate

{{ current.certificate_name }}

@@ -126,16 +137,26 @@