新增课程起止日期管理

This commit is contained in:
Certificate System
2026-08-13 14:39:44 +08:00
parent 2e1a639179
commit c9667d56fb
20 changed files with 313 additions and 33 deletions

View File

@@ -0,0 +1,26 @@
"""add certificate course period
Revision ID: 20260813_0006
Revises: 20260623_0005
Create Date: 2026-08-13
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260813_0006"
down_revision: Union[str, None] = "20260623_0005"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("certificates", sa.Column("course_start_date", sa.Date(), nullable=True))
op.add_column("certificates", sa.Column("course_end_date", sa.Date(), nullable=True))
def downgrade() -> None:
op.drop_column("certificates", "course_end_date")
op.drop_column("certificates", "course_start_date")

View File

@@ -82,6 +82,8 @@ def create_certificate(
class_name=payload.class_name,
course_name=payload.course_name or project.default_course_name or project.name,
stage_name=payload.stage_name or project.default_stage_name,
course_start_date=payload.course_start_date,
course_end_date=payload.course_end_date,
issue_date=payload.issue_date,
issuer_name=payload.issuer_name or project.default_issuer_name,
remark=payload.remark,
@@ -166,6 +168,8 @@ def _certificate_payload(certificate: Certificate, learner: Learner | None = Non
"class_name": certificate.class_name,
"course_name": certificate.course_name,
"stage_name": certificate.stage_name,
"course_start_date": certificate.course_start_date,
"course_end_date": certificate.course_end_date,
"issue_date": certificate.issue_date,
"issuer_name": certificate.issuer_name,
"status": certificate.status,
@@ -234,6 +238,8 @@ def preview_certificate(
"project_code": certificate.project_code,
"course_name": certificate.course_name,
"stage_name": certificate.stage_name,
"course_start_date": certificate.course_start_date.isoformat() if certificate.course_start_date else None,
"course_end_date": certificate.course_end_date.isoformat() if certificate.course_end_date else None,
"issue_date": certificate.issue_date.isoformat(),
"issuer_name": certificate.issuer_name,
"status": certificate.status,

View File

@@ -44,6 +44,8 @@ def export_certificates(
"证书名称",
"课程名称",
"阶段名称",
"课程开始日期",
"课程结束日期",
"发证日期",
"证书状态",
"PDF状态",
@@ -60,6 +62,8 @@ def export_certificates(
certificate.certificate_name,
certificate.course_name,
certificate.stage_name,
certificate.course_start_date.isoformat() if certificate.course_start_date else "",
certificate.course_end_date.isoformat() if certificate.course_end_date else "",
certificate.issue_date.isoformat(),
certificate.status,
certificate.pdf_status,

View File

@@ -6,6 +6,9 @@ from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse, StreamingResponse
from openpyxl import Workbook, load_workbook
from openpyxl.comments import Comment
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.worksheet.datavalidation import DataValidation
from sqlalchemy.orm import Session
from app.api.deps import require_roles
@@ -31,23 +34,24 @@ router = APIRouter()
COL_NAME = "\u59d3\u540d"
COL_PHONE = "\u624b\u673a\u53f7"
COL_PROJECT = "\u9879\u76ee\u4ee3\u7801"
COL_COURSE_START_DATE = "课程开始日期"
COL_COURSE_END_DATE = "课程结束日期"
COL_ISSUE_DATE = "\u53d1\u8bc1\u65e5\u671f"
TEMPLATE_HEADERS = [
COL_NAME,
COL_PHONE,
COL_PROJECT,
COL_COURSE_START_DATE,
COL_COURSE_END_DATE,
COL_ISSUE_DATE,
]
REQUIRED_HEADERS = [COL_NAME, COL_PHONE, COL_PROJECT, COL_ISSUE_DATE]
REQUIRED_HEADERS = TEMPLATE_HEADERS
@router.get("/template")
def download_template(_: AdminUser = Depends(require_roles("system_admin", "certificate_admin"))) -> StreamingResponse:
workbook = Workbook()
sheet = workbook.active
sheet.title = "\u8bc1\u4e66\u5bfc\u5165\u6a21\u677f"
sheet.append(TEMPLATE_HEADERS)
workbook = build_import_template_workbook()
stream_path = data_path("exports") / "certificate-import-template.xlsx"
workbook.save(stream_path)
file_handle = stream_path.open("rb")
@@ -58,6 +62,16 @@ def download_template(_: AdminUser = Depends(require_roles("system_admin", "cert
)
def build_import_template_workbook() -> Workbook:
workbook = Workbook()
sheet = workbook.active
sheet.title = "\u8bc1\u4e66\u5bfc\u5165\u6a21\u677f"
sheet.append(TEMPLATE_HEADERS)
_format_template_sheet(sheet)
_add_template_instructions(workbook)
return workbook
@router.post("", response_model=ImportBatchOut, status_code=status.HTTP_201_CREATED)
def upload_import_file(
file: UploadFile = File(...),
@@ -164,6 +178,8 @@ def confirm_import_batch(
for row in rows:
row_data = json.loads(row.raw_json or "{}")
learner = upsert_learner(db, row_data)
course_start_date = parse_date(row_data[COL_COURSE_START_DATE], COL_COURSE_START_DATE)
course_end_date = parse_date(row_data[COL_COURSE_END_DATE], COL_COURSE_END_DATE)
issue_date = parse_issue_date(row_data[COL_ISSUE_DATE])
project_code = str(row_data[COL_PROJECT]).strip().upper()
project = db.query(ProjectCourse).filter(ProjectCourse.code == project_code, ProjectCourse.status == "active").first()
@@ -172,7 +188,7 @@ def confirm_import_batch(
row.error_message = f"Project code is inactive or missing: {project_code}"
failed_rows += 1
continue
duplicate = find_duplicate_certificate(db, learner.id, project, issue_date)
duplicate = find_duplicate_certificate(db, learner.id, project, course_start_date, course_end_date, issue_date)
if duplicate:
row.status = "skipped"
row.error_message = "\u5df2\u5b58\u5728\uff0c\u65e0\u9700\u5904\u7406"
@@ -187,6 +203,8 @@ def confirm_import_batch(
certificate_name=project.default_certificate_name,
course_name=project.default_course_name,
stage_name=project.default_stage_name,
course_start_date=course_start_date,
course_end_date=course_end_date,
issue_date=issue_date,
issuer_name=project.default_issuer_name,
remark=None,
@@ -266,8 +284,14 @@ def row_errors(row_data: dict[str, object], active_codes: set[str]) -> list[str]
project_code = str(row_data.get(COL_PROJECT) or "").strip().upper()
if project_code and project_code not in active_codes:
errors.append("Project code is inactive or missing")
if row_data.get(COL_ISSUE_DATE) and not date_is_valid(row_data[COL_ISSUE_DATE]):
errors.append("Issue date format is invalid")
for column in [COL_COURSE_START_DATE, COL_COURSE_END_DATE, COL_ISSUE_DATE]:
if row_data.get(column) and not date_is_valid(row_data[column]):
errors.append(f"{column}格式错误请使用YYYY-MM-DD例如2026-06-01")
if all(row_data.get(column) and date_is_valid(row_data[column]) for column in [COL_COURSE_START_DATE, COL_COURSE_END_DATE]):
start_date = parse_date(row_data[COL_COURSE_START_DATE], COL_COURSE_START_DATE)
end_date = parse_date(row_data[COL_COURSE_END_DATE], COL_COURSE_END_DATE)
if end_date < start_date:
errors.append("课程结束日期不能早于课程开始日期")
return errors
@@ -295,7 +319,14 @@ def get_active_project(db: Session, project_code: str) -> ProjectCourse:
return project
def find_duplicate_certificate(db: Session, learner_id: int, project: ProjectCourse, issue_date: date) -> Certificate | None:
def find_duplicate_certificate(
db: Session,
learner_id: int,
project: ProjectCourse,
course_start_date: date,
course_end_date: date,
issue_date: date,
) -> Certificate | None:
return (
db.query(Certificate)
.filter(Certificate.learner_id == learner_id)
@@ -303,6 +334,8 @@ def find_duplicate_certificate(db: Session, learner_id: int, project: ProjectCou
.filter(Certificate.certificate_name == project.default_certificate_name)
.filter(Certificate.course_name == project.default_course_name)
.filter(Certificate.stage_name == project.default_stage_name)
.filter(Certificate.course_start_date == course_start_date)
.filter(Certificate.course_end_date == course_end_date)
.filter(Certificate.issue_date == issue_date)
.first()
)
@@ -329,6 +362,10 @@ def optional_text(value: object) -> str | None:
def parse_issue_date(value: object) -> date:
return parse_date(value, COL_ISSUE_DATE)
def parse_date(value: object, field_name: str = "日期") -> date:
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
@@ -339,7 +376,7 @@ def parse_issue_date(value: object) -> date:
return datetime.strptime(text, fmt).date()
except ValueError:
continue
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid issue date: {text}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name}格式错误请使用YYYY-MM-DD{text}")
def date_is_valid(value: object) -> bool:
@@ -361,3 +398,62 @@ def write_error_report(db: Session, batch_id: int) -> Path:
report_path = data_path("error-reports") / f"import-errors-{batch_id}.xlsx"
workbook.save(report_path)
return report_path
def _format_template_sheet(sheet) -> None:
header_fill = PatternFill("solid", fgColor="208A87")
for cell in sheet[1]:
cell.fill = header_fill
cell.font = Font(color="FFFFFF", bold=True)
cell.alignment = Alignment(horizontal="center", vertical="center")
sheet.freeze_panes = "A2"
sheet.auto_filter.ref = f"A1:{sheet.cell(1, len(TEMPLATE_HEADERS)).coordinate}"
widths = [16, 18, 16, 18, 18, 18]
for index, width in enumerate(widths, start=1):
sheet.column_dimensions[sheet.cell(1, index).column_letter].width = width
sheet.column_dimensions["B"].number_format = "@"
for column_index in [4, 5, 6]:
column_letter = sheet.cell(1, column_index).column_letter
sheet.column_dimensions[column_letter].number_format = "yyyy-mm-dd"
validation = DataValidation(type="date", operator="between", formula1="DATE(2000,1,1)", formula2="DATE(2100,12,31)", allow_blank=False)
validation.promptTitle = "日期格式"
validation.prompt = "请按 YYYY-MM-DD 填写,例如 2026-06-01"
validation.errorTitle = "日期格式错误"
validation.error = "请填写 2000-01-01 至 2100-12-31 之间的有效日期"
validation.errorStyle = "stop"
validation.showInputMessage = True
validation.showErrorMessage = True
sheet.add_data_validation(validation)
validation.add(f"{column_letter}2:{column_letter}5000")
sheet.cell(1, column_index).comment = Comment("必填。请使用 YYYY-MM-DD 格式,例如 2026-06-01。", "证书管理系统")
def _add_template_instructions(workbook: Workbook) -> None:
sheet = workbook.create_sheet("填写说明")
sheet.append(["字段", "是否必填", "格式或示例", "填写说明"])
rows = [
(COL_NAME, "", "张三", "填写学员真实姓名"),
(COL_PHONE, "", "13800000000", "建议将单元格设为文本,避免手机号格式变化"),
(COL_PROJECT, "", "DBY", "填写系统中已启用的项目代码"),
(COL_COURSE_START_DATE, "", "2026-06-01", "课程实际开始日期,必须使用 YYYY-MM-DD"),
(COL_COURSE_END_DATE, "", "2026-06-30", "不得早于课程开始日期,必须使用 YYYY-MM-DD"),
(COL_ISSUE_DATE, "", "2026-07-05", "证书签发日期,必须使用 YYYY-MM-DD"),
]
for row in rows:
sheet.append(row)
sheet.append([])
sheet.append(["重要提示", "请在“证书导入模板”工作表填写正式数据,不要修改第一行列名。日期统一填写为 YYYY-MM-DD例如 2026-06-01。"])
sheet.merge_cells(start_row=9, start_column=2, end_row=9, end_column=4)
for cell in sheet[1]:
cell.fill = PatternFill("solid", fgColor="208A87")
cell.font = Font(color="FFFFFF", bold=True)
cell.alignment = Alignment(horizontal="center")
sheet["A9"].font = Font(color="C00000", bold=True)
sheet["B9"].font = Font(color="C00000", bold=True)
sheet["B9"].alignment = Alignment(wrap_text=True, vertical="center")
sheet.row_dimensions[9].height = 34
sheet.column_dimensions["A"].width = 20
sheet.column_dimensions["B"].width = 16
sheet.column_dimensions["C"].width = 22
sheet.column_dimensions["D"].width = 54
sheet.freeze_panes = "A2"

View File

@@ -176,6 +176,8 @@ def public_certificate_payload(db: Session, certificate: Certificate, learner: L
"project_code": certificate.project_code,
"course_name": certificate.course_name,
"stage_name": certificate.stage_name,
"course_start_date": certificate.course_start_date.isoformat() if certificate.course_start_date else None,
"course_end_date": certificate.course_end_date.isoformat() if certificate.course_end_date else None,
"issue_date": certificate.issue_date.isoformat(),
"issuer_name": certificate.issuer_name,
"status": certificate.status,

View File

@@ -33,6 +33,8 @@ class Certificate(Base):
class_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
course_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
stage_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
course_start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
course_end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
issue_date: Mapped[date] = mapped_column(Date)
issuer_name: Mapped[str] = mapped_column(String(128))
status: Mapped[str] = mapped_column(String(32), default="valid")

View File

@@ -1,6 +1,6 @@
from datetime import date, datetime
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, ValidationInfo, field_validator
class CertificateCreate(BaseModel):
@@ -10,6 +10,8 @@ class CertificateCreate(BaseModel):
class_name: str | None = Field(default=None, max_length=128)
course_name: str | None = Field(default=None, max_length=128)
stage_name: str | None = Field(default=None, max_length=128)
course_start_date: date
course_end_date: date
issue_date: date
issuer_name: str = Field(min_length=1, max_length=128)
remark: str | None = None
@@ -19,6 +21,14 @@ class CertificateCreate(BaseModel):
def normalize_project_code(cls, value: str) -> str:
return value.strip().upper()
@field_validator("course_end_date")
@classmethod
def validate_course_period(cls, value: date, info: ValidationInfo) -> date:
start_date = info.data.get("course_start_date")
if start_date and value < start_date:
raise ValueError("课程结束日期不能早于课程开始日期")
return value
class CertificateOut(BaseModel):
id: int
@@ -31,6 +41,8 @@ class CertificateOut(BaseModel):
class_name: str | None
course_name: str | None
stage_name: str | None
course_start_date: date | None
course_end_date: date | None
issue_date: date
issuer_name: str
status: str

View File

@@ -121,6 +121,8 @@ def render_certificate_image(certificate: Certificate, learner: Learner, project
text_scale = (x_scale + y_scale) / 2
issue_year, issue_month, issue_day = date_parts(certificate.issue_date)
start_year, start_month, start_day = date_parts(certificate.course_start_date or certificate.issue_date)
end_year, end_month, end_day = date_parts(certificate.course_end_date or certificate.issue_date)
course_name = certificate.course_name or certificate.certificate_name or (project.name if project else certificate.project_code)
stage_input = (certificate.stage_name or "").strip()
stage_name = f"{stage_input}\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002" if stage_input else "\u521d\u7ea7\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002"
@@ -138,17 +140,17 @@ def render_certificate_image(certificate: Certificate, learner: Learner, project
x = 185.0 * x_scale
y = 494 * y_scale
x = draw_inline(draw, x, y, "\u5728", 24, 18, scale=text_scale, x_scale=x_scale)
x = draw_inline(draw, x, y, issue_year, 24, 8, scale=text_scale, x_scale=x_scale)
x = draw_inline(draw, x, y, start_year, 24, 8, scale=text_scale, x_scale=x_scale)
x = draw_inline(draw, x, y, "\u5e74", 24, 12, scale=text_scale, x_scale=x_scale)
x = draw_inline(draw, x, y, issue_month, 24, 6, scale=text_scale, x_scale=x_scale)
x = draw_inline(draw, x, y, start_month, 24, 6, scale=text_scale, x_scale=x_scale)
x = draw_inline(draw, x, y, "\u6708", 24, 12, scale=text_scale, x_scale=x_scale)
x = draw_inline(draw, x, y, issue_day, 24, 4, scale=text_scale, x_scale=x_scale)
x = draw_inline(draw, x, y, start_day, 24, 4, scale=text_scale, x_scale=x_scale)
x = draw_inline(draw, x, y, "\u65e5\u81f3", 24, 12, scale=text_scale, x_scale=x_scale)
x = draw_inline(draw, x, y, issue_year, 24, 8, scale=text_scale, x_scale=x_scale)
x = draw_inline(draw, x, y, end_year, 24, 8, scale=text_scale, x_scale=x_scale)
x = draw_inline(draw, x, y, "\u5e74", 24, 12, scale=text_scale, x_scale=x_scale)
x = draw_inline(draw, x, y, issue_month, 24, 6, scale=text_scale, x_scale=x_scale)
x = draw_inline(draw, x, y, end_month, 24, 6, scale=text_scale, x_scale=x_scale)
x = draw_inline(draw, x, y, "\u6708", 24, 12, scale=text_scale, x_scale=x_scale)
x = draw_inline(draw, x, y, issue_day, 24, 4, scale=text_scale, x_scale=x_scale)
x = draw_inline(draw, x, y, end_day, 24, 4, scale=text_scale, x_scale=x_scale)
x = draw_inline(draw, x, y, "\u65e5\u5b8c\u6210\u4e86", 24, 8, scale=text_scale, x_scale=x_scale)
draw_inline_flow(
draw,

View File

@@ -1,14 +1,22 @@
from datetime import date
import pytest
from pydantic import ValidationError
from app.api.routes.admin_imports import (
COL_ISSUE_DATE,
COL_COURSE_END_DATE,
COL_COURSE_START_DATE,
COL_NAME,
COL_PHONE,
COL_PROJECT,
TEMPLATE_HEADERS,
build_import_template_workbook,
date_is_valid,
parse_issue_date,
row_errors,
)
from app.schemas.certificate import CertificateCreate
def test_parse_issue_date_accepts_common_formats():
@@ -21,6 +29,8 @@ def test_row_errors_require_project_code_to_exist():
COL_NAME: "张三",
COL_PHONE: "13800000000",
COL_PROJECT: "BAD",
COL_COURSE_START_DATE: "2026-05-01",
COL_COURSE_END_DATE: "2026-05-31",
COL_ISSUE_DATE: "2026-06-01",
}
assert "Project code is inactive or missing" in row_errors(row, {"DBY"})
@@ -28,3 +38,51 @@ def test_row_errors_require_project_code_to_exist():
def test_date_is_valid_rejects_bad_text():
assert not date_is_valid("not-a-date")
def test_row_errors_rejects_reversed_course_period():
row = {
COL_NAME: "张三",
COL_PHONE: "13800000000",
COL_PROJECT: "DBY",
COL_COURSE_START_DATE: "2026-06-30",
COL_COURSE_END_DATE: "2026-06-01",
COL_ISSUE_DATE: "2026-07-05",
}
assert "课程结束日期不能早于课程开始日期" in row_errors(row, {"DBY"})
def test_row_errors_explains_required_date_format():
row = {
COL_NAME: "张三",
COL_PHONE: "13800000000",
COL_PROJECT: "DBY",
COL_COURSE_START_DATE: "2026年6月1日",
COL_COURSE_END_DATE: "2026-06-30",
COL_ISSUE_DATE: "2026-07-05",
}
assert "课程开始日期格式错误请使用YYYY-MM-DD例如2026-06-01" in row_errors(row, {"DBY"})
def test_import_template_contains_date_examples_and_validation():
workbook = build_import_template_workbook()
data_sheet = workbook["证书导入模板"]
instruction_sheet = workbook["填写说明"]
assert [cell.value for cell in data_sheet[1]] == TEMPLATE_HEADERS
assert data_sheet.column_dimensions["D"].number_format == "yyyy-mm-dd"
assert len(data_sheet.data_validations.dataValidation) == 3
assert instruction_sheet["C5"].value == "2026-06-01"
assert "YYYY-MM-DD" in instruction_sheet["D5"].value
def test_certificate_create_rejects_reversed_course_period():
with pytest.raises(ValidationError, match="课程结束日期不能早于课程开始日期"):
CertificateCreate(
learner_id=1,
project_code="DBY",
course_start_date="2026-06-30",
course_end_date="2026-06-01",
issue_date="2026-07-05",
issuer_name="测试单位",
)

View File

@@ -85,7 +85,7 @@
### C009 Excel 导入字段
- 决定:导入模板保留姓名、手机号、项目代码、发证日期。
- 决定:导入模板保留姓名、手机号、项目代码、课程开始日期、课程结束日期、发证日期。
- 原因:证书名称、课程名称、阶段名称、发证单位属于项目配置,不应该在每一行 Excel 里重复填写。
- 备注:导入备注字段一期移除;批量导入后的备注缺少明确展示和业务使用场景。
@@ -98,7 +98,7 @@
- Decision: keep `certificate-template-lab` as a standalone visual tuning page.
- Decision: production PDF rendering now uses the approved certificate image as the base layer, then renders learner/course/date/certificate number dynamically with Pillow.
- Current limitation: the data model only has `issue_date`; until separate training start/end dates are added, the rendered course date range uses `issue_date` for both start and end.
- Current behavior: the rendered course date range uses the separate course start/end dates; legacy certificates without them fall back to `issue_date`.
- Current limitation: mentor, issuer names, and QR area remain from the approved base image for visual consistency.
### C012 PDF 批量预生成
@@ -107,3 +107,10 @@
- 策略:预生成继续使用系统设置里的 `PDF 生成并发限制`,不单独开无限制后台任务。
- 原因:大批量学员毕业前先生成缓存,可以降低公开查询高峰的等待时间和 CPU 压力。
- 当前限制:任务进度保存在后端进程内,服务重启后只保留已生成的 PDF 缓存,不恢复历史进度。
### C013 课程起止日期独立建模
- 决定:证书增加“课程开始日期”和“课程结束日期”两个独立字段,不再用发证日期代替课程时间。
- 原因:课程学习周期与证书签发日期含义不同,拆分后可校验时间顺序,也便于后续按开班、结业周期统计。
- 导入约定:两个课程日期与发证日期均必填,模板统一推荐 `YYYY-MM-DD`,例如 `2026-06-01`;课程结束日期不得早于开始日期。
- 兼容策略:历史证书没有课程日期时,展示和 PDF 继续回退到发证日期,避免升级后旧证书无法查看或下载。

View File

@@ -38,6 +38,7 @@ docker compose exec backend python -m app.cli init-db --admin-username admin --a
- 发证单位名称已确认。
- 系统设置里的 `PDF 生成并发限制` 已确认2 核 4G 建议保持默认 `2`
- Excel 导入模板已下载并试填。
- 导入模板中的课程开始日期、课程结束日期和发证日期已统一使用 `YYYY-MM-DD`,例如 `2026-06-01`
- 占位证书模板已确认可临时使用。
- 大批量毕业前的操作约定已确认确认导入后先在“Excel 导入”或“证书管理”里批量预生成 PDF。
@@ -49,6 +50,8 @@ docker compose exec backend python -m app.cli init-db --admin-username admin --a
- 可上传 Excel 并确认导入。
- 已导入批次可以启动“预生成PDF”进度弹窗能显示总数、已完成、失败数和并发数。
- 证书管理页可按导入批次和 PDF 状态筛选,并能勾选多张证书批量预生成。
- 倒序的课程起止日期会在手工新增和 Excel 导入校验阶段被拦截。
- 新证书正文显示课程起止日期,底部发证日期仍显示证书签发日期。
- 可导出证书和直达链接。
- 公开查询支持两种方式:
- 证书编号查询:必须输入姓名和图形验证码。

View File

@@ -39,11 +39,8 @@
1. 姓名
2. 手机号
3. 项目代码
4. 证书名称
5. 课程名称
6. 阶段名称
7. 发证日期
8. 发证单位
9. 备注
4. 课程开始日期
5. 课程结束日期
6. 发证日期
证书编号不在 Excel 中填写,由系统确认导入后自动生成。
三个日期统一使用 `YYYY-MM-DD`,例如 `2026-06-01`,课程结束日期不得早于开始日期。证书名称、课程名称、阶段名称和发证单位从项目配置自动带出;证书编号由系统确认导入后自动生成。

View File

@@ -42,7 +42,7 @@ reset-local-data.bat
2. 进入“项目管理”,维护 `DBY``QSX` 等项目代码。项目支持搜索、修改、启用和停用。
3. 进入“学员管理”,手动新增、查询、修改或删除学员。
4. 进入“证书管理”手动新增证书支持单张预览、PDF 下载、作废、重置直达链接、按条件筛选、勾选后批量预生成 PDF 和导出。
5. 进入“Excel 导入”,下载模板、上传 Excel、查看校验结果确认后正式导入。模板需要填写姓名、手机号、项目代码、发证日期;证书名称、课程名称、阶段名称、发证单位从项目配置自动带出。
5. 进入“Excel 导入”,下载模板、上传 Excel、查看校验结果确认后正式导入。模板需要填写姓名、手机号、项目代码、课程开始日期、课程结束日期、发证日期;三个日期统一按 `YYYY-MM-DD` 填写,例如 `2026-06-01`证书名称、课程名称、阶段名称、发证单位从项目配置自动带出。
“确认导入”表示把已校验通过的临时数据正式写入学员和证书表。导入记录支持下载原文件、下载错误报告和删除;删除导入记录只删除上传文件、错误报告和批次记录,不删除已经生成的学员和证书。
确认导入后可以在该批次行点击“预生成PDF”系统会提前为这一批有效证书生成 PDF并在弹窗里显示总数、已完成、新生成、已缓存、跳过和失败数量。
6. 进入“系统设置”,确认 `PDF 生成并发限制`。2 核 4G 服务器建议保持默认值 `2`
@@ -143,7 +143,7 @@ docker compose exec backend pytest
- 学员手动新增、查询、修改、删除。
- 证书新增、查询、后台预览、后台下载、作废、重置直达链接。
- 证书按导入批次筛选、按 PDF 状态筛选、批量预生成 PDF。
- Excel 模板下载、上传校验、错误报告下载、确认导入。导入模板字段为姓名、手机号、项目代码、发证日期
- Excel 模板下载、上传校验、错误报告下载、确认导入。导入模板字段为姓名、手机号、项目代码、课程开始日期、课程结束日期、发证日期,并附有日期格式示例和填写说明
- 导入批次原文件下载和导入批次删除。
- 导入后自动生成证书编号、直达链接 token、二维码 token。
- 证书导出,包含证书编号和对外直达链接。

View File

@@ -529,12 +529,15 @@ https://你的域名/admin/login
姓名
手机号
项目代码
课程开始日期
课程结束日期
发证日期
```
检查:
- 能下载模板。
- 模板“填写说明”页包含 `YYYY-MM-DD` 示例,课程结束日期早于开始日期时校验失败。
- 能上传 Excel。
- 错误数据能生成错误报告。
- 校验通过后能确认导入。

View File

@@ -520,18 +520,21 @@ https://你的域名/admin/login
### 13.5 Excel 导入
确认模板字段只有
确认模板字段
```text
姓名
手机号
项目代码
课程开始日期
课程结束日期
发证日期
```
确认:
- 能上传 Excel。
- 模板“填写说明”页包含 `YYYY-MM-DD` 示例,课程结束日期早于开始日期时校验失败。
- 能下载原文件。
- 能删除导入批次。
- 能确认导入。

View File

@@ -19,6 +19,8 @@ export interface PublicCertificate {
project_code: string;
course_name: string | null;
stage_name: string | null;
course_start_date: string | null;
course_end_date: string | null;
issue_date: string;
issuer_name: string;
status: string;
@@ -70,6 +72,8 @@ export interface AdminCertificate {
class_name: string | null;
course_name: string | null;
stage_name: string | null;
course_start_date: string | null;
course_end_date: string | null;
issue_date: string;
issuer_name: string;
status: string;

View File

@@ -21,6 +21,13 @@
<el-form-item label="发证日期">
<el-date-picker v-model="form.issue_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="课程开始日期">
<el-date-picker v-model="form.course_start_date" type="date" value-format="YYYY-MM-DD" placeholder="选择开始日期" />
</el-form-item>
<el-form-item label="课程结束日期">
<el-date-picker v-model="form.course_end_date" type="date" value-format="YYYY-MM-DD" placeholder="选择结束日期" />
<div class="form-tip">证书正文将显示这段课程起止时间结束日期不能早于开始日期</div>
</el-form-item>
<el-form-item label="课程名称">
<el-input v-model.trim="form.course_name" />
</el-form-item>
@@ -64,6 +71,9 @@
<el-table-column prop="learner_name" label="学员姓名" width="100" show-overflow-tooltip />
<el-table-column prop="project_code" label="项目" width="72" />
<el-table-column prop="certificate_name" label="证书名称" min-width="140" show-overflow-tooltip />
<el-table-column label="课程时间" width="190">
<template #default="{ row }">{{ coursePeriodText(row) }}</template>
</el-table-column>
<el-table-column prop="issue_date" label="发证日期" width="108" />
<el-table-column label="PDF" width="92">
<template #default="{ row }">
@@ -96,6 +106,7 @@
<el-descriptions-item label="项目/课程/阶段">
{{ preview.project_code }} {{ preview.course_name || "" }} {{ preview.stage_name || "" }}
</el-descriptions-item>
<el-descriptions-item label="课程时间">{{ coursePeriodText(preview) }}</el-descriptions-item>
<el-descriptions-item label="发证日期">{{ preview.issue_date }}</el-descriptions-item>
<el-descriptions-item label="发证单位">{{ preview.issuer_name }}</el-descriptions-item>
<el-descriptions-item label="公开链接">
@@ -160,6 +171,8 @@ const form = reactive({
project_code: "",
course_name: "",
stage_name: "",
course_start_date: "",
course_end_date: "",
issue_date: "",
issuer_name: "",
remark: "",
@@ -175,6 +188,12 @@ function pdfStatusText(status: string) {
return { generated: "已生成", cleaned: "已清理", not_generated: "未生成" }[status] || status;
}
function coursePeriodText(certificate: Pick<AdminCertificate, "course_start_date" | "course_end_date" | "issue_date">) {
const start = certificate.course_start_date || certificate.issue_date;
const end = certificate.course_end_date || certificate.issue_date;
return `${start}${end}`;
}
const pregenerationDone = computed(() => ["completed", "completed_with_errors"].includes(pregenerationJob.value?.status || ""));
async function loadProjects() {
@@ -204,8 +223,12 @@ function handleSelectionChange(rows: AdminCertificate[]) {
}
async function createCertificate() {
if (!form.learner_id || !form.project_code || !form.issue_date || !form.issuer_name) {
ElMessage.warning("请填写学员ID、项目、发证日期和发证单位");
if (!form.learner_id || !form.project_code || !form.course_start_date || !form.course_end_date || !form.issue_date || !form.issuer_name) {
ElMessage.warning("请填写学员ID、项目、课程起止日期、发证日期和发证单位");
return;
}
if (form.course_end_date < form.course_start_date) {
ElMessage.warning("课程结束日期不能早于课程开始日期");
return;
}
await http.post("/admin/certificates", {
@@ -213,6 +236,8 @@ async function createCertificate() {
project_code: form.project_code,
course_name: form.course_name || null,
stage_name: form.stage_name || null,
course_start_date: form.course_start_date,
course_end_date: form.course_end_date,
issue_date: form.issue_date,
issuer_name: form.issuer_name,
remark: form.remark || null,

View File

@@ -8,6 +8,13 @@
<el-button @click="downloadTemplate">下载模板</el-button>
</header>
<el-alert
class="import-tip"
type="info"
:closable="false"
title="课程开始日期、课程结束日期和发证日期均为必填项,请统一使用 YYYY-MM-DD 格式,例如 2026-06-01。"
/>
<el-upload
class="upload"
drag
@@ -224,9 +231,14 @@ onUnmounted(stopPregenerationPolling);
}
.upload {
margin-top: 16px;
margin-bottom: 12px;
}
.import-tip {
margin-bottom: 4px;
}
.upload-icon {
font-size: 36px;
color: #208a87;

View File

@@ -17,6 +17,10 @@
<dt>证书编号</dt>
<dd>{{ certificate.certificate_no }}</dd>
</div>
<div>
<dt>课程时间</dt>
<dd>{{ coursePeriodText }}</dd>
</div>
<div>
<dt>发证日期</dt>
<dd>{{ certificate.issue_date }}</dd>
@@ -36,7 +40,7 @@
<script setup lang="ts">
import { ElLoading, ElMessage } from "element-plus";
import { onMounted, ref } from "vue";
import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { http, type PublicCertificate } from "../api";
@@ -48,6 +52,12 @@ const certificate = ref<PublicCertificate | null>(null);
const message = ref("");
const messageType = ref<"success" | "warning" | "error">("success");
const downloading = ref(false);
const coursePeriodText = computed(() => {
if (!certificate.value) return "";
const start = certificate.value.course_start_date || certificate.value.issue_date;
const end = certificate.value.course_end_date || certificate.value.issue_date;
return `${start}${end}`;
});
function statusText(status: string) {
return status === "valid" ? "有效" : "已作废";

View File

@@ -9,6 +9,8 @@
<el-descriptions-item label="证书编号">{{ certificate.certificate_no }}</el-descriptions-item>
<el-descriptions-item label="学员姓名">{{ certificate.learner_name }}</el-descriptions-item>
<el-descriptions-item label="证书名称">{{ certificate.certificate_name }}</el-descriptions-item>
<el-descriptions-item label="课程时间">{{ coursePeriodText }}</el-descriptions-item>
<el-descriptions-item label="发证日期">{{ certificate.issue_date }}</el-descriptions-item>
<el-descriptions-item label="发证单位">{{ certificate.issuer_name }}</el-descriptions-item>
</el-descriptions>
</section>
@@ -16,7 +18,7 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { http, type PublicCertificate } from "../api";
@@ -26,6 +28,12 @@ const token = String(route.params.token);
const certificate = ref<PublicCertificate | null>(null);
const message = ref("正在核验证书信息");
const messageType = ref<"success" | "warning" | "error" | "info">("info");
const coursePeriodText = computed(() => {
if (!certificate.value) return "";
const start = certificate.value.course_start_date || certificate.value.issue_date;
const end = certificate.value.course_end_date || certificate.value.issue_date;
return `${start}${end}`;
});
async function loadCertificate() {
try {