优化系统界面并接入正式品牌标识

This commit is contained in:
Certificate System
2026-08-13 19:12:52 +08:00
parent 7d08f74580
commit 522f529c6a
17 changed files with 1092 additions and 313 deletions

View File

@@ -7,13 +7,13 @@ from app.api.deps import require_roles
from app.core.paths import data_path from app.core.paths import data_path
from app.db.session import get_db from app.db.session import get_db
from app.models import AdminUser, OperationLog from app.models import AdminUser, OperationLog
from app.schemas.log import OperationLogOut from app.schemas.log import OperationLogPage
from app.services.logs import HIGH_RISK_LOG_RETENTION_DAYS, LOG_RETENTION_DAYS, cleanup_expired_logs, format_log from app.services.logs import HIGH_RISK_LOG_RETENTION_DAYS, LOG_RETENTION_DAYS, cleanup_expired_logs, format_log
router = APIRouter() router = APIRouter()
@router.get("", response_model=list[OperationLogOut]) @router.get("", response_model=OperationLogPage)
def list_logs( def list_logs(
action: str | None = Query(default=None), action: str | None = Query(default=None),
object_type: str | None = Query(default=None), object_type: str | None = Query(default=None),

View File

@@ -19,3 +19,11 @@ class OperationLogOut(BaseModel):
summary: str | None = None summary: str | None = None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
class OperationLogPage(BaseModel):
items: list[OperationLogOut]
total: int
page: int
page_size: int
total_pages: int

View File

@@ -208,7 +208,7 @@ def render_practice_camp_certificate_image(certificate: Certificate, learner: Le
(end_month, 1058), (end_month, 1058),
]: ]:
draw.text( draw.text(
(x * x_scale, 648 * y_scale), (x * x_scale, 658 * y_scale),
value, value,
fill="#202020", fill="#202020",
font=font(41, "song", False, text_scale), font=font(41, "song", False, text_scale),
@@ -217,7 +217,7 @@ def render_practice_camp_certificate_image(certificate: Certificate, learner: Le
issue_date_text = f"{issue_year}{issue_month}{issue_day}" issue_date_text = f"{issue_year}{issue_month}{issue_day}"
issue_date_font = font(36, "song", False, text_scale) issue_date_font = font(36, "song", False, text_scale)
issue_date_position = (568 * x_scale, 1140 * y_scale) issue_date_position = (568 * x_scale, 1149 * y_scale)
draw.rectangle( draw.rectangle(
( (
442 * x_scale, 442 * x_scale,
@@ -362,7 +362,7 @@ def draw_issue_date_numbers(
y_scale: float, y_scale: float,
) -> None: ) -> None:
text_font = font(13, "song", False, scale) text_font = font(13, "song", False, scale)
y = 682 * y_scale y = 685 * y_scale
for value, x in [(issue_year, 500), (issue_month, 535), (issue_day, 566)]: for value, x in [(issue_year, 500), (issue_month, 535), (issue_day, 566)]:
draw.text((x * x_scale, y), value, fill="#111111", font=text_font, anchor="mm") draw.text((x * x_scale, y), value, fill="#111111", font=text_font, anchor="mm")

View File

@@ -0,0 +1,8 @@
from app.api.routes.admin_logs import router
from app.schemas.log import OperationLogPage
def test_operation_log_list_uses_paginated_response_model():
list_route = next(route for route in router.routes if route.path == "")
assert list_route.response_model is OperationLogPage

View File

@@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/brand/huiyu-logo.png" />
<title>培训结业证书管理与查询系统</title> <title>培训结业证书管理与查询系统</title>
</head> </head>
<body> <body>

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -0,0 +1,98 @@
<template>
<header class="service-header">
<div class="service-header-inner">
<router-link class="service-brand" to="/query" aria-label="返回证书查询首页">
<span class="brand-mark"><img src="/brand/huiyu-logo.png" alt="" /></span>
<span>
<strong>慧遇书院</strong>
<small>电子证书服务</small>
</span>
</router-link>
<span class="service-state"><i></i>证书查询与核验</span>
</div>
</header>
</template>
<style scoped>
.service-header {
width: 100%;
border-bottom: 1px solid #e2e7e9;
background: rgba(255, 255, 255, 0.96);
}
.service-header-inner {
width: min(1120px, calc(100% - 40px));
min-height: 76px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.service-brand {
display: flex;
align-items: center;
gap: 12px;
color: #18232c;
}
.service-brand:hover {
text-decoration: none;
}
.brand-mark {
width: 38px;
height: 38px;
display: grid;
place-items: center;
}
.brand-mark img {
width: 36px;
height: 32px;
object-fit: contain;
}
.service-brand > span:last-child {
display: grid;
gap: 2px;
}
.service-brand strong {
font-size: 16px;
letter-spacing: 0;
}
.service-brand small {
color: #75818a;
font-size: 11px;
}
.service-state {
display: inline-flex;
align-items: center;
gap: 8px;
color: #5d6d78;
font-size: 13px;
}
.service-state i {
width: 7px;
height: 7px;
border-radius: 50%;
background: #2f8a69;
box-shadow: 0 0 0 4px #e6f2ed;
}
@media (max-width: 640px) {
.service-header-inner {
width: calc(100% - 28px);
min-height: 66px;
}
.service-state {
font-size: 0;
}
}
</style>

View File

@@ -1,34 +1,39 @@
:root { :root {
--app-bg: #f5f8fa; --app-bg: #f3f5f7;
--app-surface: #ffffff; --app-surface: #ffffff;
--app-surface-soft: #f9fbfc; --app-surface-soft: #f7f9fa;
--app-border: #dbe6ea; --app-border: #dce3e7;
--app-border-strong: #c8d7dd; --app-border-strong: #c7d1d7;
--app-text: #172033; --app-text: #17212b;
--app-muted: #64748b; --app-muted: #647381;
--app-primary: #16817e; --app-primary: #176f69;
--app-primary-strong: #0f6f6b; --app-primary-strong: #105b57;
--app-primary-soft: #e6f5f3; --app-primary-soft: #e8f3f1;
--app-blue-soft: #eef5fb; --app-gold: #b28a43;
--app-gold: #b88a2d; --app-gold-soft: #f6f0e5;
--app-danger: #c9413a; --app-danger: #b83b36;
--app-warning: #b7791f; --app-warning: #a76518;
--app-success: #16817e; --app-success: #176f69;
--app-shadow: 0 14px 36px rgba(42, 64, 78, 0.08); --app-shadow: 0 12px 32px rgba(27, 43, 53, 0.08);
--app-shadow-soft: 0 8px 22px rgba(42, 64, 78, 0.055); --app-shadow-soft: 0 4px 14px rgba(27, 43, 53, 0.05);
} }
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
html,
body,
#app {
min-height: 100%;
}
body { body {
margin: 0; margin: 0;
color: var(--app-text); color: var(--app-text);
background: background: var(--app-bg);
linear-gradient(180deg, #f7fafb 0%, #eef4f6 100%); font-family: Inter, "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
font-family: -webkit-font-smoothing: antialiased;
Inter, "Microsoft YaHei", "PingFang SC", "Helvetica Neue", Arial, sans-serif;
} }
button, button,
@@ -38,25 +43,36 @@ select {
font: inherit; font: inherit;
} }
a {
color: var(--app-primary-strong);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.page-head { .page-head {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-end; align-items: center;
gap: 18px; gap: 20px;
margin-bottom: 18px; margin-bottom: 22px;
} }
.page-head h2 { .page-head h2 {
margin: 0; margin: 0;
color: var(--app-text); color: var(--app-text);
font-size: 24px; font-size: 26px;
font-weight: 750; font-weight: 720;
letter-spacing: 0; letter-spacing: 0;
} }
.page-head p { .page-head p {
max-width: 760px;
margin: 7px 0 0; margin: 7px 0 0;
color: var(--app-muted); color: var(--app-muted);
font-size: 14px;
line-height: 1.6; line-height: 1.6;
} }
@@ -64,21 +80,62 @@ select {
.el-card.panel, .el-card.panel,
.el-card.stat { .el-card.stat {
border: 1px solid var(--app-border); border: 1px solid var(--app-border);
border-radius: 10px; border-radius: 7px;
background: var(--app-surface); background: var(--app-surface);
box-shadow: var(--app-shadow-soft); box-shadow: var(--app-shadow-soft);
} }
.panel + .panel {
margin-top: 18px;
}
.panel .el-card__body {
padding: 22px;
}
.panel-head,
.form-section-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 20px;
}
.panel-head h3,
.form-section-head h3 {
margin: 0;
font-size: 17px;
font-weight: 700;
}
.panel-head p,
.form-section-head p {
margin: 5px 0 0;
color: var(--app-muted);
font-size: 13px;
line-height: 1.55;
}
.el-card { .el-card {
--el-card-border-color: var(--app-border); --el-card-border-color: var(--app-border);
--el-card-border-radius: 10px; --el-card-border-radius: 7px;
} }
.el-button { .el-button {
--el-button-border-radius: 7px; --el-button-border-radius: 6px;
min-height: 38px;
padding-right: 16px;
padding-left: 16px;
font-weight: 600; font-weight: 600;
} }
.el-button--small {
min-height: 30px;
padding-right: 11px;
padding-left: 11px;
}
.el-button--primary { .el-button--primary {
--el-button-bg-color: var(--app-primary); --el-button-bg-color: var(--app-primary);
--el-button-border-color: var(--app-primary); --el-button-border-color: var(--app-primary);
@@ -91,7 +148,8 @@ select {
.el-input__wrapper, .el-input__wrapper,
.el-select__wrapper, .el-select__wrapper,
.el-textarea__inner { .el-textarea__inner {
border-radius: 8px; min-height: 40px;
border-radius: 6px;
box-shadow: 0 0 0 1px var(--app-border) inset; box-shadow: 0 0 0 1px var(--app-border) inset;
} }
@@ -104,75 +162,98 @@ select {
.el-input__wrapper.is-focus, .el-input__wrapper.is-focus,
.el-select__wrapper.is-focused, .el-select__wrapper.is-focused,
.el-textarea__inner:focus { .el-textarea__inner:focus {
box-shadow: 0 0 0 1px var(--app-primary) inset, 0 0 0 3px rgba(22, 129, 126, 0.12); box-shadow: 0 0 0 1px var(--app-primary) inset, 0 0 0 3px rgba(23, 111, 105, 0.1);
} }
.el-form-item__label { .el-form-item__label {
color: #415466; color: #3f505e;
font-weight: 650; font-weight: 650;
} }
.el-table { .el-table {
--el-table-border-color: #e5edf0; --el-table-border-color: #e3e9ec;
--el-table-header-bg-color: #f6fafb; --el-table-header-bg-color: #f5f7f8;
--el-table-header-text-color: #52677a; --el-table-header-text-color: #536572;
--el-table-row-hover-bg-color: #f8fcfc; --el-table-row-hover-bg-color: #f6faf9;
border-radius: 8px; border-radius: 6px;
overflow: hidden; overflow: hidden;
} }
.el-table th.el-table__cell { .el-table th.el-table__cell {
height: 44px;
font-weight: 700; font-weight: 700;
} }
.el-table td.el-table__cell {
height: 48px;
}
.el-tag { .el-tag {
border-radius: 999px; border-radius: 999px;
font-weight: 650; font-weight: 650;
} }
.el-dialog { .el-dialog {
border-radius: 12px; border-radius: 8px;
box-shadow: 0 28px 72px rgba(20, 35, 45, 0.2);
} }
.el-dialog__header { .el-dialog__header {
padding: 22px 24px 12px; padding: 22px 24px 14px;
margin: 0; margin: 0;
border-bottom: 1px solid #edf1f3;
} }
.el-dialog__body { .el-dialog__body {
padding: 14px 24px 22px; padding: 20px 24px 24px;
}
.el-dialog__footer {
padding: 14px 24px 20px;
border-top: 1px solid #edf1f3;
} }
.el-dialog__title { .el-dialog__title {
color: var(--app-text); color: var(--app-text);
font-size: 20px; font-size: 19px;
font-weight: 750; font-weight: 720;
} }
.el-segmented { .el-segmented {
--el-segmented-item-selected-bg-color: var(--app-primary); --el-segmented-item-selected-bg-color: #ffffff;
--el-segmented-item-selected-color: #ffffff; --el-segmented-item-selected-color: var(--app-primary-strong);
border: 1px solid var(--app-border); border: 1px solid var(--app-border);
background: #f4f8fa; border-radius: 7px;
background: #eef2f3;
} }
.form-grid { .form-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px; gap: 16px 18px;
} }
.form-grid .el-form-item { .form-grid .el-form-item {
margin-bottom: 0; margin-bottom: 0;
} }
.form-actions {
display: flex;
grid-column: 1 / -1;
justify-content: flex-end;
gap: 10px;
margin-top: 4px;
padding-top: 18px;
border-top: 1px solid #edf1f3;
}
.toolbar, .toolbar,
.filters { .filters {
align-items: center; align-items: center;
} }
.pager { .pager {
border-top: 1px solid #edf2f4; border-top: 1px solid #edf1f3;
padding-top: 12px; padding-top: 12px;
} }
@@ -190,15 +271,6 @@ select {
transform: translateY(-1px); transform: translateY(-1px);
} }
a {
color: var(--app-primary-strong);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
@media (max-width: 1100px) { @media (max-width: 1100px) {
.form-grid { .form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -211,7 +283,25 @@ a:hover {
align-items: start; align-items: start;
} }
.page-head h2 {
font-size: 23px;
}
.form-grid { .form-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.form-actions {
display: grid;
grid-template-columns: 1fr 1fr;
}
.form-actions .el-button {
width: 100%;
margin: 0;
}
.el-dialog {
width: calc(100% - 24px) !important;
}
} }

View File

@@ -5,7 +5,7 @@
<h2>证书管理</h2> <h2>证书管理</h2>
<p>支持单张新增预览下载作废链接重置和批量预生成PDF</p> <p>支持单张新增预览下载作废链接重置和批量预生成PDF</p>
</div> </div>
<el-button type="primary" @click="openCreateDialog">创建证书</el-button> <el-button type="primary" :icon="Plus" @click="openCreateDialog">创建证书</el-button>
</header> </header>
<el-dialog v-model="createVisible" title="创建证书" width="920px" destroy-on-close> <el-dialog v-model="createVisible" title="创建证书" width="920px" destroy-on-close>
@@ -81,23 +81,27 @@
<el-card class="panel" shadow="never"> <el-card class="panel" shadow="never">
<div class="toolbar"> <div class="toolbar">
<el-input v-model.trim="keyword" placeholder="按证书编号、名称、项目代码搜索" clearable @keyup.enter="loadCertificates" /> <div class="filter-controls">
<el-select v-model="statusValue" clearable placeholder="状态"> <el-input v-model.trim="keyword" placeholder="按证书编号、名称、项目代码搜索" clearable @keyup.enter="loadCertificates" />
<el-option label="有效" value="valid" /> <el-select v-model="statusValue" clearable placeholder="状态">
<el-option label="已作废" value="voided" /> <el-option label="有效" value="valid" />
</el-select> <el-option label="已作废" value="voided" />
<el-select v-model="pdfStatusValue" clearable placeholder="PDF状态"> </el-select>
<el-option label="未生成" value="not_generated" /> <el-select v-model="pdfStatusValue" clearable placeholder="PDF状态">
<el-option label="生成" value="generated" /> <el-option label="生成" value="not_generated" />
<el-option label="已清理" value="cleaned" /> <el-option label="已生成" value="generated" />
</el-select> <el-option label="已清理" value="cleaned" />
<el-select v-model="importBatchId" clearable filterable placeholder="导入批次"> </el-select>
<el-option v-for="item in importBatches" :key="item.id" :label="`#${item.id} ${item.filename}`" :value="item.id" /> <el-select v-model="importBatchId" clearable filterable placeholder="导入批次">
</el-select> <el-option v-for="item in importBatches" :key="item.id" :label="`#${item.id} ${item.filename}`" :value="item.id" />
<el-button @click="loadCertificates">搜索</el-button> </el-select>
<el-button type="primary" :disabled="!selectedCertificates.length" :loading="pregenerationStarting" @click="startPregenerationSelected">预生成所选PDF</el-button> <el-button :icon="Search" @click="loadCertificates">搜索</el-button>
<el-button :disabled="!importBatchId" :loading="pregenerationStarting" @click="startPregenerationBatch">预生成当前批次全部</el-button> </div>
<el-button @click="exportCertificates">导出</el-button> <div class="bulk-actions">
<el-button :icon="Download" @click="exportCertificates">导出</el-button>
<el-button :icon="Files" :disabled="!importBatchId" :loading="pregenerationStarting" @click="startPregenerationBatch">预生成当前批次</el-button>
<el-button type="primary" :icon="MagicStick" :disabled="!selectedCertificates.length" :loading="pregenerationStarting" @click="startPregenerationSelected">预生成所选 PDF</el-button>
</div>
</div> </div>
<el-table :data="certificates" border @selection-change="handleSelectionChange"> <el-table :data="certificates" border @selection-change="handleSelectionChange">
@@ -183,6 +187,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Download, Files, MagicStick, Plus, Search } from "@element-plus/icons-vue";
import { ElLoading, ElMessage, ElMessageBox } from "element-plus"; import { ElLoading, ElMessage, ElMessageBox } from "element-plus";
import { computed, onMounted, onUnmounted, reactive, ref } from "vue"; import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
@@ -530,14 +535,35 @@ onUnmounted(stopPregenerationPolling);
} }
.toolbar { .toolbar {
justify-content: flex-start; align-items: flex-start;
flex-wrap: wrap;
justify-content: space-between;
padding-bottom: 16px;
border-bottom: 1px solid #edf1f3;
} }
.toolbar .el-input { .filter-controls,
.bulk-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.filter-controls {
flex: 1 1 660px;
}
.bulk-actions {
justify-content: flex-end;
}
.filter-controls .el-input {
flex: 1 1 260px;
max-width: 340px; max-width: 340px;
} }
.toolbar .el-select { .filter-controls .el-select {
width: 170px; width: 170px;
} }
@@ -604,5 +630,18 @@ onUnmounted(stopPregenerationPolling);
.create-grid { .create-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.filter-controls,
.bulk-actions {
width: 100%;
}
.filter-controls .el-input,
.filter-controls .el-select,
.bulk-actions .el-button {
width: 100%;
max-width: none;
margin: 0;
}
} }
</style> </style>

View File

@@ -2,22 +2,27 @@
<section> <section>
<header class="page-head"> <header class="page-head">
<div> <div>
<h2>一期工作台</h2> <h2>数据概览</h2>
<p>快速查看证书导入和状态数据</p> <p>查看学员证书和近期导入的核心数据</p>
</div> </div>
<el-button type="primary" @click="downloadTemplate">下载导入模板</el-button> <el-button type="primary" :icon="Download" @click="downloadTemplate">下载导入模板</el-button>
</header> </header>
<div class="stats"> <div class="stats">
<el-card v-for="item in cards" :key="item.label" class="stat" shadow="never"> <el-card v-for="item in cards" :key="item.label" class="stat" shadow="never">
<span>{{ item.label }}</span> <div class="stat-head">
<span>{{ item.label }}</span>
<el-icon :class="item.tone"><component :is="item.icon" /></el-icon>
</div>
<strong>{{ item.value }}</strong> <strong>{{ item.value }}</strong>
<small>{{ item.note }}</small>
</el-card> </el-card>
</div> </div>
</section> </section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { CircleCheck, DataBoard, Download, UploadFilled, User, Warning } from "@element-plus/icons-vue";
import { computed, onMounted, reactive } from "vue"; import { computed, onMounted, reactive } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
@@ -34,11 +39,11 @@ const summary = reactive({
}); });
const cards = computed(() => [ const cards = computed(() => [
{ label: "学员总数", value: summary.learner_count }, { label: "学员总数", value: summary.learner_count, note: "系统学员档案", icon: User, tone: "teal" },
{ label: "证书总数", value: summary.certificate_count }, { label: "证书总数", value: summary.certificate_count, note: "累计签发记录", icon: DataBoard, tone: "blue" },
{ label: "有效证书", value: summary.valid_certificate_count }, { label: "有效证书", value: summary.valid_certificate_count, note: "当前有效", icon: CircleCheck, tone: "green" },
{ label: "作废证书", value: summary.voided_certificate_count }, { label: "作废证书", value: summary.voided_certificate_count, note: "历史作废", icon: Warning, tone: "red" },
{ label: "近期导入", value: summary.recent_import_count }, { label: "近期导入", value: summary.recent_import_count, note: "最近导入批次", icon: UploadFilled, tone: "gold" },
]); ]);
async function loadSummary() { async function loadSummary() {
@@ -59,18 +64,6 @@ onMounted(loadSummary);
</script> </script>
<style scoped> <style scoped>
.page-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 18px;
}
.page-head p {
margin: 6px 0 0;
color: #64748b;
}
.stats { .stats {
display: grid; display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr)); grid-template-columns: repeat(5, minmax(0, 1fr));
@@ -78,10 +71,13 @@ onMounted(loadSummary);
} }
.stat { .stat {
border: 1px solid #dbe6ea; background: #ffffff;
border-radius: 10px; }
background: linear-gradient(180deg, #ffffff, #f8fbfc);
box-shadow: 0 8px 22px rgba(42, 64, 78, 0.055); .stat-head {
display: flex;
align-items: center;
justify-content: space-between;
} }
.stat span { .stat span {
@@ -92,10 +88,45 @@ onMounted(loadSummary);
.stat strong { .stat strong {
display: block; display: block;
margin-top: 8px; margin-top: 16px;
font-size: 30px; color: #17212b;
font-weight: 800; font-size: 32px;
color: #172033; font-weight: 760;
}
.stat small {
display: block;
margin-top: 4px;
color: #8a959d;
font-size: 12px;
}
.stat .el-icon {
width: 34px;
height: 34px;
border-radius: 6px;
font-size: 18px;
}
.stat .teal,
.stat .green {
color: #176f69;
background: #e8f3f1;
}
.stat .blue {
color: #3e6789;
background: #ebf1f6;
}
.stat .red {
color: #a83a35;
background: #f8eaea;
}
.stat .gold {
color: #8d6b2f;
background: #f6f0e5;
} }
@media (max-width: 1100px) { @media (max-width: 1100px) {

View File

@@ -5,10 +5,16 @@
<h2>学员管理</h2> <h2>学员管理</h2>
<p>支持手动新增修改删除和查询学员也可以继续通过 Excel 批量导入</p> <p>支持手动新增修改删除和查询学员也可以继续通过 Excel 批量导入</p>
</div> </div>
<el-button type="primary" @click="saveLearner">{{ editingId ? "保存修改" : "新增学员" }}</el-button>
</header> </header>
<el-card class="panel" shadow="never"> <el-card class="panel" shadow="never">
<div class="form-section-head">
<div>
<h3>{{ editingId ? "编辑学员" : "新增学员" }}</h3>
<p>{{ editingId ? `正在修改 ${form.current_name}` : "姓名和手机号用于证书归属及查询,请确认信息准确。" }}</p>
</div>
<el-tag v-if="editingId" type="warning">编辑中</el-tag>
</div>
<el-form class="form-grid" label-position="top"> <el-form class="form-grid" label-position="top">
<el-form-item label="姓名"> <el-form-item label="姓名">
<el-input v-model.trim="form.current_name" /> <el-input v-model.trim="form.current_name" />
@@ -28,9 +34,12 @@
<el-form-item label="备注" class="wide"> <el-form-item label="备注" class="wide">
<el-input v-model.trim="form.remark" /> <el-input v-model.trim="form.remark" />
</el-form-item> </el-form-item>
<el-form-item label=" "> <div class="form-actions">
<el-button @click="resetForm">清空</el-button> <el-button :icon="RefreshLeft" @click="resetForm">重置</el-button>
</el-form-item> <el-button type="primary" :icon="editingId ? Check : Plus" @click="saveLearner">
{{ editingId ? "保存修改" : "新增学员" }}
</el-button>
</div>
</el-form> </el-form>
</el-card> </el-card>
@@ -62,6 +71,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Check, Plus, RefreshLeft } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { onMounted, reactive, ref } from "vue"; import { onMounted, reactive, ref } from "vue";
@@ -124,7 +134,6 @@ onMounted(loadLearners);
</script> </script>
<style scoped> <style scoped>
.page-head,
.toolbar { .toolbar {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -133,24 +142,12 @@ onMounted(loadLearners);
margin-bottom: 16px; margin-bottom: 16px;
} }
.page-head p {
margin: 6px 0 0;
color: #64748b;
}
.panel { .panel {
margin-bottom: 16px; margin-bottom: 16px;
border-radius: 8px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
} }
.wide { .wide {
grid-column: span 3; grid-column: span 2;
} }
.toolbar { .toolbar {
@@ -160,4 +157,10 @@ onMounted(loadLearners);
.toolbar .el-input { .toolbar .el-input {
max-width: 380px; max-width: 380px;
} }
@media (max-width: 720px) {
.wide {
grid-column: 1;
}
}
</style> </style>

View File

@@ -1,15 +1,26 @@
<template> <template>
<main class="login-page"> <main class="login-page">
<section class="login-box"> <section class="login-box">
<h1>后台登录</h1> <header class="login-head">
<el-form label-position="top"> <span class="login-mark"><img src="/brand/huiyu-logo.png" alt="" /></span>
<div>
<p>慧遇书院</p>
<h1>证书管理后台</h1>
</div>
</header>
<p class="login-copy">请使用管理员账号登录</p>
<el-form label-position="top" size="large">
<el-form-item label="账号"> <el-form-item label="账号">
<el-input v-model.trim="form.username" /> <el-input v-model.trim="form.username" placeholder="请输入账号">
<template #prefix><el-icon><User /></el-icon></template>
</el-input>
</el-form-item> </el-form-item>
<el-form-item label="密码"> <el-form-item label="密码">
<el-input v-model="form.password" type="password" show-password @keyup.enter="login" /> <el-input v-model="form.password" type="password" show-password placeholder="请输入密码" @keyup.enter="login">
<template #prefix><el-icon><Lock /></el-icon></template>
</el-input>
</el-form-item> </el-form-item>
<el-button type="primary" class="submit" :loading="loading" @click="login">登录</el-button> <el-button type="primary" :icon="Right" class="submit" :loading="loading" @click="login">登录后台</el-button>
</el-form> </el-form>
<el-alert v-if="message" class="message" :title="message" type="error" :closable="false" /> <el-alert v-if="message" class="message" :title="message" type="error" :closable="false" />
</section> </section>
@@ -17,6 +28,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Lock, Right, User } from "@element-plus/icons-vue";
import { reactive, ref } from "vue"; import { reactive, ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
@@ -50,29 +62,69 @@ async function login() {
min-height: 100vh; min-height: 100vh;
display: grid; display: grid;
place-items: center; place-items: center;
background: linear-gradient(180deg, #f7fafb 0%, #eef4f6 100%); background: #f1f4f5;
padding: 24px; padding: 24px;
} }
.login-box { .login-box {
width: min(440px, 100%); width: min(440px, 100%);
padding: 32px; padding: 34px;
border: 1px solid #dbe6ea; border: 1px solid #dbe2e5;
border-radius: 14px; border-top: 3px solid #b28a43;
border-radius: 7px;
background: #ffffff; background: #ffffff;
box-shadow: 0 18px 48px rgba(42, 64, 78, 0.09); box-shadow: 0 18px 48px rgba(31, 47, 57, 0.1);
} }
h1 { .login-head {
margin: 0 0 22px; display: flex;
color: #172033; align-items: center;
font-size: 26px; gap: 13px;
font-weight: 800; }
.login-mark {
width: 42px;
height: 42px;
display: grid;
place-items: center;
}
.login-mark img {
width: 40px;
height: 35px;
object-fit: contain;
}
.login-head p,
.login-head h1 {
margin: 0;
}
.login-head p {
color: #9a7637;
font-size: 12px;
font-weight: 700;
}
.login-head h1 {
margin-top: 2px;
color: #17212b;
font-size: 23px;
font-weight: 750;
}
.login-copy {
margin: 22px 0 18px;
padding-bottom: 16px;
border-bottom: 1px solid #edf1f2;
color: #6b7982;
font-size: 13px;
} }
.submit { .submit {
width: 100%; width: 100%;
height: 38px; height: 42px;
margin-top: 4px;
font-weight: 700; font-weight: 700;
} }

View File

@@ -5,10 +5,16 @@
<h2>项目管理</h2> <h2>项目管理</h2>
<p>维护项目代码默认证书模板和课程信息</p> <p>维护项目代码默认证书模板和课程信息</p>
</div> </div>
<el-button type="primary" @click="saveProject">{{ editingId ? "保存修改" : "新增项目" }}</el-button>
</header> </header>
<el-card class="panel" shadow="never"> <el-card class="panel" shadow="never">
<div class="form-section-head">
<div>
<h3>{{ editingId ? "编辑项目" : "新增项目" }}</h3>
<p>{{ editingId ? `正在修改项目 ${form.code}` : "项目代码创建后不可修改,请使用清晰稳定的英文缩写。" }}</p>
</div>
<el-tag v-if="editingId" type="warning">编辑中</el-tag>
</div>
<el-form class="form-grid" label-position="top"> <el-form class="form-grid" label-position="top">
<el-form-item label="项目代码"> <el-form-item label="项目代码">
<el-input v-model.trim="form.code" :disabled="!!editingId" maxlength="16" /> <el-input v-model.trim="form.code" :disabled="!!editingId" maxlength="16" />
@@ -33,9 +39,12 @@
<el-option label="停用" value="disabled" /> <el-option label="停用" value="disabled" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label=" "> <div class="form-actions">
<el-button @click="resetForm">清空</el-button> <el-button :icon="RefreshLeft" @click="resetForm">重置</el-button>
</el-form-item> <el-button type="primary" :icon="editingId ? Check : Plus" @click="saveProject">
{{ editingId ? "保存修改" : "新增项目" }}
</el-button>
</div>
</el-form> </el-form>
</el-card> </el-card>
@@ -72,6 +81,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Check, Plus, RefreshLeft } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
@@ -182,7 +192,6 @@ onMounted(() => Promise.all([loadProjects(), loadTemplates()]));
</script> </script>
<style scoped> <style scoped>
.page-head,
.toolbar { .toolbar {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -190,20 +199,8 @@ onMounted(() => Promise.all([loadProjects(), loadTemplates()]));
margin-bottom: 16px; margin-bottom: 16px;
} }
.page-head p {
margin: 6px 0 0;
color: #64748b;
}
.panel { .panel {
margin-bottom: 16px; margin-bottom: 16px;
border-radius: 8px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
} }
.toolbar .el-input { .toolbar .el-input {

View File

@@ -1,30 +1,57 @@
<template> <template>
<main class="admin-shell"> <main class="admin-shell">
<aside class="sidebar"> <aside class="sidebar">
<div class="brand"> <div class="brand-row">
<h1>证书管理</h1> <div class="brand">
<p>培训结业证书后台</p> <span class="brand-mark"><img src="/brand/huiyu-logo.png" alt="" /></span>
<span>
<strong>证书管理</strong>
<small>慧遇书院管理后台</small>
</span>
</div>
<el-tooltip content="退出登录" placement="right">
<el-button class="logout" circle aria-label="退出登录" @click="logout"><el-icon><SwitchButton /></el-icon></el-button>
</el-tooltip>
</div> </div>
<el-menu :default-active="route.path" router> <el-menu :default-active="route.path" router>
<el-menu-item index="/admin">数据概览</el-menu-item> <el-menu-item index="/admin"><el-icon><DataAnalysis /></el-icon><span>数据概览</span></el-menu-item>
<el-menu-item index="/admin/projects">项目管理</el-menu-item> <li class="menu-label">业务管理</li>
<el-menu-item index="/admin/templates">证书模板</el-menu-item> <el-menu-item index="/admin/projects"><el-icon><Collection /></el-icon><span>项目管理</span></el-menu-item>
<el-menu-item index="/admin/learners">学员管理</el-menu-item> <el-menu-item index="/admin/templates"><el-icon><Picture /></el-icon><span>证书模板</span></el-menu-item>
<el-menu-item index="/admin/certificates">证书管理</el-menu-item> <el-menu-item index="/admin/learners"><el-icon><User /></el-icon><span>学员管理</span></el-menu-item>
<el-menu-item index="/admin/imports">Excel 导入</el-menu-item> <el-menu-item index="/admin/certificates"><el-icon><Postcard /></el-icon><span>证书管理</span></el-menu-item>
<el-menu-item index="/admin/logs">操作日志</el-menu-item> <el-menu-item index="/admin/imports"><el-icon><Upload /></el-icon><span>Excel 导入</span></el-menu-item>
<el-menu-item index="/admin/backups">备份管理</el-menu-item> <li class="menu-label">系统维护</li>
<el-menu-item index="/admin/settings">系统设置</el-menu-item> <el-menu-item index="/admin/logs"><el-icon><Document /></el-icon><span>操作日志</span></el-menu-item>
<el-menu-item index="/admin/backups"><el-icon><Coin /></el-icon><span>备份管理</span></el-menu-item>
<el-menu-item index="/admin/settings"><el-icon><Setting /></el-icon><span>系统设置</span></el-menu-item>
</el-menu> </el-menu>
<el-button class="logout" @click="logout">退出登录</el-button>
<div class="sidebar-foot">
<span class="avatar"></span>
<span><strong>系统管理员</strong><small>已安全登录</small></span>
</div>
</aside> </aside>
<section class="workspace"> <section class="workspace">
<router-view /> <div class="workspace-inner"><router-view /></div>
</section> </section>
</main> </main>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {
Coin,
Collection,
DataAnalysis,
Document,
Picture,
Postcard,
Setting,
SwitchButton,
Upload,
User,
} from "@element-plus/icons-vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
const route = useRoute(); const route = useRoute();
@@ -40,77 +67,206 @@ function logout() {
.admin-shell { .admin-shell {
min-height: 100vh; min-height: 100vh;
display: grid; display: grid;
grid-template-columns: 248px 1fr; grid-template-columns: 232px minmax(0, 1fr);
background: linear-gradient(180deg, #f7fafb 0%, #eef4f6 100%); background: var(--app-bg);
color: #172033; color: var(--app-text);
} }
.sidebar { .sidebar {
position: sticky; position: sticky;
top: 0; top: 0;
height: 100vh; height: 100vh;
background: rgba(255, 255, 255, 0.94); display: flex;
border-right: 1px solid #e1ebef; flex-direction: column;
padding: 24px 14px; padding: 20px 14px 16px;
box-shadow: 8px 0 26px rgba(42, 64, 78, 0.045); border-right: 1px solid #dfe5e8;
background: #ffffff;
}
.brand-row,
.brand {
display: flex;
align-items: center;
}
.brand-row {
justify-content: space-between;
gap: 8px;
padding: 0 6px 18px;
border-bottom: 1px solid #e8edef;
} }
.brand { .brand {
padding: 0 12px 20px; min-width: 0;
border-bottom: 1px solid #e1ebef; gap: 10px;
margin-bottom: 18px;
} }
.brand h1 { .brand-mark {
font-size: 21px; width: 34px;
font-weight: 800; height: 34px;
margin: 0; flex: 0 0 auto;
letter-spacing: 0; display: grid;
place-items: center;
} }
.brand p { .brand-mark img {
margin: 7px 0 0; width: 32px;
color: #6b8795; height: 28px;
font-size: 13px; object-fit: contain;
} }
:deep(.el-menu) { .brand > span:last-child,
border-right: 0; .sidebar-foot > span:last-child {
min-width: 0;
display: grid;
gap: 3px;
} }
:deep(.el-menu-item) { .brand strong {
height: 42px; font-size: 16px;
margin: 4px 0; font-weight: 750;
border-radius: 8px;
color: #42576a;
font-weight: 650;
} }
:deep(.el-menu-item.is-active), .brand small,
:deep(.el-menu-item:hover) { .sidebar-foot small {
background: #e6f5f3; color: #7b8992;
color: #116d6b; font-size: 11px;
} }
.logout { .logout {
width: calc(100% - 16px); flex: 0 0 auto;
margin: 20px 8px 0; min-height: 32px;
width: 32px;
height: 32px;
padding: 0;
}
:deep(.el-menu) {
flex: 1;
border-right: 0;
padding-top: 12px;
}
:deep(.el-menu-item) {
height: 40px;
margin: 3px 0;
border-radius: 6px;
color: #465965;
font-size: 14px;
font-weight: 600;
}
:deep(.el-menu-item .el-icon) {
font-size: 17px;
}
:deep(.el-menu-item.is-active) {
background: var(--app-primary-soft);
color: var(--app-primary-strong);
}
:deep(.el-menu-item:hover) {
background: #f3f6f7;
color: var(--app-text);
}
.menu-label {
list-style: none;
padding: 16px 14px 5px;
color: #99a4ab;
font-size: 11px;
font-weight: 700;
}
.sidebar-foot {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 8px 2px;
border-top: 1px solid #e8edef;
}
.sidebar-foot strong {
font-size: 13px;
}
.avatar {
width: 32px;
height: 32px;
display: grid;
place-items: center;
flex: 0 0 auto;
border-radius: 50%;
color: #805f27;
background: var(--app-gold-soft);
font-size: 12px;
font-weight: 700;
} }
.workspace { .workspace {
min-width: 0; min-width: 0;
padding: 28px;
overflow: auto; overflow: auto;
padding: 30px 32px 48px;
}
.workspace-inner {
width: min(1480px, 100%);
margin: 0 auto;
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.admin-shell { .admin-shell {
width: 100%;
min-width: 0;
grid-template-columns: 1fr; grid-template-columns: 1fr;
overflow: hidden;
} }
.sidebar { .sidebar {
position: static; position: sticky;
z-index: 20;
height: auto; height: auto;
width: 100%;
min-width: 0;
padding: 10px 14px 0;
overflow: hidden;
border-right: 0;
border-bottom: 1px solid #dfe5e8;
}
.brand-row {
padding: 0 2px 10px;
}
.brand small,
.sidebar-foot,
.menu-label {
display: none;
}
:deep(.el-menu) {
width: 100%;
min-width: 0;
display: flex;
overflow-x: auto;
gap: 4px;
padding: 0 0 8px;
}
:deep(.el-menu-item) {
flex: 0 0 auto;
padding: 0 12px;
}
.workspace {
width: 100%;
min-width: 0;
padding: 22px 16px 36px;
}
.workspace-inner {
width: 100%;
min-width: 0;
} }
} }
</style> </style>

View File

@@ -1,45 +1,73 @@
<template> <template>
<main class="public-page"> <main class="public-query-page">
<section class="query-panel"> <PublicServiceHeader />
<div class="heading">
<p>电子证书查询</p>
<h1>培训结业证书查询</h1>
</div>
<el-form label-position="top"> <section class="query-main">
<header class="query-intro">
<p class="eyebrow">CERTIFICATE VERIFICATION</p>
<h1>证书查询与核验</h1>
<p class="intro-copy">查询慧遇书院已签发的培训结业证书</p>
<div class="trust-row">
<span><el-icon><CircleCheck /></el-icon>签发记录可追溯</span>
<span><el-icon><Lock /></el-icon>查询信息安全保护</span>
</div>
</header>
<section class="query-card" aria-label="证书查询表单">
<div class="query-card-head">
<div>
<h2>查询证书</h2>
<p>请选择证书编号或短信验证码查询</p>
</div>
<span class="official-mark"><img src="/brand/huiyu-logo.png" alt="" />官方查询</span>
</div>
<el-form label-position="top" size="large">
<el-form-item label="查询方式"> <el-form-item label="查询方式">
<el-segmented v-model="queryMode" :options="queryOptions" /> <el-segmented v-model="queryMode" :options="queryOptions" class="query-mode" />
</el-form-item> </el-form-item>
<el-form-item v-if="queryMode === 'certificate'" label="证书编号"> <el-form-item v-if="queryMode === 'certificate'" label="证书编号">
<el-input v-model.trim="form.certificateNo" placeholder="例如 PX2026-DBY-K7M9Q2R-83" /> <el-input v-model.trim="form.certificateNo" placeholder="例如 PX2026-DBY-K7M9Q2R-83">
<template #prefix><el-icon><Postcard /></el-icon></template>
</el-input>
</el-form-item> </el-form-item>
<el-form-item v-if="queryMode === 'certificate'" label="姓名"> <el-form-item v-if="queryMode === 'certificate'" label="姓名">
<el-input v-model.trim="form.name" placeholder="请输入证书对应姓名" /> <el-input v-model.trim="form.name" placeholder="请输入证书对应姓名">
<template #prefix><el-icon><User /></el-icon></template>
</el-input>
</el-form-item> </el-form-item>
<el-form-item v-if="queryMode === 'certificate'" label="图形验证码"> <el-form-item v-if="queryMode === 'certificate'" label="图形验证码">
<div class="captcha-row"> <div class="captcha-row">
<el-input v-model.trim="form.captcha" placeholder="请输入验证码" /> <el-input v-model.trim="form.captcha" placeholder="请输入验证码">
<button class="captcha-button" type="button" @click="loadCaptcha"> <template #prefix><el-icon><Key /></el-icon></template>
</el-input>
<button class="captcha-button" type="button" title="点击刷新验证码" @click="loadCaptcha">
<img v-if="captchaImage" :src="captchaImage" alt="验证码" /> <img v-if="captchaImage" :src="captchaImage" alt="验证码" />
<span v-else>刷新</span> <span v-else><el-icon><RefreshRight /></el-icon>刷新</span>
</button> </button>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item v-if="queryMode === 'sms'" label="手机号"> <el-form-item v-if="queryMode === 'sms'" label="手机号">
<el-input v-model.trim="form.phone" placeholder="请输入证书对应手机号" /> <el-input v-model.trim="form.phone" placeholder="请输入证书对应手机号">
<template #prefix><el-icon><Phone /></el-icon></template>
</el-input>
</el-form-item> </el-form-item>
<el-form-item v-if="queryMode === 'sms'" label="图形验证码"> <el-form-item v-if="queryMode === 'sms'" label="图形验证码">
<div class="captcha-row"> <div class="captcha-row">
<el-input v-model.trim="form.smsCaptcha" placeholder="请输入验证码" /> <el-input v-model.trim="form.smsCaptcha" placeholder="请输入验证码">
<button class="captcha-button" type="button" @click="loadSmsCaptcha"> <template #prefix><el-icon><Key /></el-icon></template>
</el-input>
<button class="captcha-button" type="button" title="点击刷新验证码" @click="loadSmsCaptcha">
<img v-if="smsCaptchaImage" :src="smsCaptchaImage" alt="验证码" /> <img v-if="smsCaptchaImage" :src="smsCaptchaImage" alt="验证码" />
<span v-else>刷新</span> <span v-else><el-icon><RefreshRight /></el-icon>刷新</span>
</button> </button>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item v-if="queryMode === 'sms'" label="短信验证码"> <el-form-item v-if="queryMode === 'sms'" label="短信验证码">
<div class="sms-row"> <div class="sms-row">
<el-input v-model.trim="form.smsCode" placeholder="请输入短信验证码" /> <el-input v-model.trim="form.smsCode" placeholder="请输入短信验证码">
<template #prefix><el-icon><Message /></el-icon></template>
</el-input>
<el-button <el-button
type="primary" type="primary"
:disabled="smsCooldown > 0 || !form.phone" :disabled="smsCooldown > 0 || !form.phone"
@@ -50,10 +78,11 @@
</el-button> </el-button>
</div> </div>
</el-form-item> </el-form-item>
<el-button type="primary" class="submit" :loading="loading" @click="submitQuery">查询</el-button> <el-button type="primary" class="submit" :icon="Search" :loading="loading" @click="submitQuery">查询证书</el-button>
</el-form> </el-form>
<el-alert v-if="message" class="notice" :title="message" :type="messageType" :closable="false" /> <el-alert v-if="message" class="notice" :title="message" :type="messageType" :closable="false" />
</section>
<section v-if="results.length" class="results"> <section v-if="results.length" class="results">
<div class="results-head"> <div class="results-head">
@@ -89,14 +118,13 @@
<dt>发证日期</dt> <dt>发证日期</dt>
<dd>{{ item.issue_date }}</dd> <dd>{{ item.issue_date }}</dd>
</div> </div>
<div>
</div>
</dl> </dl>
<div class="card-actions"> <div class="card-actions">
<el-button @click="preview(item)">预览</el-button> <el-button :icon="View" @click="preview(item)">预览</el-button>
<el-button <el-button
type="primary" type="primary"
:icon="Download"
:disabled="!item.can_download_pdf" :disabled="!item.can_download_pdf"
:loading="downloadingCertificateNo === item.certificate_no" :loading="downloadingCertificateNo === item.certificate_no"
@click="downloadPdf(item)" @click="downloadPdf(item)"
@@ -107,11 +135,13 @@
</article> </article>
</section> </section>
<p class="disclaimer">
本证书仅用于证明学员完成相关培训课程/阶段学习不代表国家学历学位职业资格或职业技能等级认证
</p>
</section> </section>
<footer class="public-footer">
<p>本证书仅用于证明学员完成相关培训课程或阶段学习不代表国家学历学位职业资格或职业技能等级认证</p>
<span>慧遇书院电子证书服务</span>
</footer>
<el-dialog v-model="previewVisible" title="证书预览" width="720px" @closed="clearPreviewUrl"> <el-dialog v-model="previewVisible" title="证书预览" width="720px" @closed="clearPreviewUrl">
<div v-if="previewPdfUrl" class="pdf-frame"> <div v-if="previewPdfUrl" class="pdf-frame">
<embed :src="previewPdfUrl" type="application/pdf" /> <embed :src="previewPdfUrl" type="application/pdf" />
@@ -148,12 +178,26 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Loading } from "@element-plus/icons-vue"; import {
CircleCheck,
Download,
Key,
Loading,
Lock,
Message,
Phone,
Postcard,
RefreshRight,
Search,
User,
View,
} from "@element-plus/icons-vue";
import { ElLoading, ElMessage } from "element-plus"; import { ElLoading, ElMessage } from "element-plus";
import { onMounted, onUnmounted, reactive, ref, watch } from "vue"; import { onMounted, onUnmounted, reactive, ref, watch } from "vue";
import { http, type Captcha, type PublicCertificate } from "../api"; import { http, type Captcha, type PublicCertificate } from "../api";
import { apiErrorMessage, saveBlob } from "../download"; import { apiErrorMessage, saveBlob } from "../download";
import PublicServiceHeader from "../components/PublicServiceHeader.vue";
interface SearchResponse { interface SearchResponse {
items: PublicCertificate[]; items: PublicCertificate[];
@@ -364,34 +408,37 @@ onUnmounted(() => {
</script> </script>
<style scoped> <style scoped>
.public-page { .public-query-page {
min-height: 100vh; min-height: 100vh;
display: grid; background: #f4f6f7;
place-items: start center;
background: linear-gradient(180deg, #f7fafb 0%, #eef4f6 100%);
padding: 42px 20px;
} }
.query-panel { .query-main {
width: min(820px, 100%); width: min(880px, calc(100% - 40px));
background: #ffffff; margin: 0 auto;
border: 1px solid #dbe6ea; padding: 62px 0 48px;
border-radius: 14px;
padding: 32px;
box-shadow: 0 18px 48px rgba(42, 64, 78, 0.09);
} }
.heading { .query-intro {
margin-bottom: 22px; margin-bottom: 30px;
text-align: center;
} }
.heading p { .eyebrow {
margin: 0 0 6px; margin: 0 0 10px;
color: #16817e; color: #9a7637;
font-size: 13px; font-family: Georgia, "Times New Roman", serif;
font-size: 12px;
font-weight: 700; font-weight: 700;
} }
h1,
h2,
h3,
.query-intro p {
letter-spacing: 0;
}
h1, h1,
h2, h2,
h3 { h3 {
@@ -399,9 +446,91 @@ h3 {
} }
h1 { h1 {
font-size: 30px; color: #17212b;
color: #172033; font-size: 38px;
letter-spacing: 0; font-weight: 720;
}
.intro-copy {
margin: 12px 0 0;
color: #677681;
font-size: 15px;
}
.trust-row {
display: flex;
justify-content: center;
gap: 24px;
margin-top: 18px;
color: #5e6d77;
font-size: 13px;
}
.trust-row span {
display: inline-flex;
align-items: center;
gap: 7px;
}
.trust-row .el-icon {
color: #27765f;
}
.query-card {
padding: 28px 30px 30px;
border: 1px solid #dce3e6;
border-top: 3px solid #b28a43;
border-radius: 7px;
background: #ffffff;
box-shadow: 0 14px 36px rgba(31, 47, 57, 0.08);
}
.query-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
margin-bottom: 22px;
padding-bottom: 18px;
border-bottom: 1px solid #edf1f2;
}
.query-card-head h2 {
font-size: 20px;
}
.query-card-head p {
margin: 6px 0 0;
color: #75818a;
font-size: 13px;
}
.official-mark {
display: inline-flex;
align-items: center;
gap: 6px;
color: #87672f;
font-size: 12px;
font-weight: 650;
}
.official-mark img {
width: 18px;
height: 16px;
object-fit: contain;
}
.query-mode {
width: 100%;
}
:deep(.query-mode .el-segmented__group) {
width: 100%;
}
:deep(.query-mode .el-segmented__item) {
flex: 1;
min-height: 38px;
} }
.captcha-row { .captcha-row {
@@ -419,7 +548,7 @@ h1 {
} }
.captcha-button { .captcha-button {
height: 42px; height: 40px;
padding: 0; padding: 0;
border: 1px solid #dbe6ea; border: 1px solid #dbe6ea;
border-radius: 8px; border-radius: 8px;
@@ -434,9 +563,19 @@ h1 {
display: block; display: block;
} }
.captcha-button span {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
color: #5f6f79;
}
.submit { .submit {
width: 100%; width: 100%;
height: 38px; height: 44px;
margin-top: 4px;
font-weight: 700; font-weight: 700;
} }
@@ -452,6 +591,10 @@ h1 {
margin-bottom: 12px; margin-bottom: 12px;
} }
.results-head h2 {
font-size: 21px;
}
.results-head p { .results-head p {
margin: 4px 0 0; margin: 4px 0 0;
color: #64748b; color: #64748b;
@@ -459,12 +602,12 @@ h1 {
} }
.certificate-card { .certificate-card {
border: 1px solid #dbe6ea; border: 1px solid #dce3e6;
border-radius: 10px; border-radius: 7px;
padding: 20px; padding: 22px;
margin-bottom: 14px; margin-bottom: 12px;
background: linear-gradient(180deg, #ffffff, #f8fbfc); background: #ffffff;
box-shadow: 0 8px 22px rgba(42, 64, 78, 0.05); box-shadow: 0 4px 14px rgba(31, 47, 57, 0.05);
} }
.card-top { .card-top {
@@ -534,27 +677,18 @@ h1 {
gap: 10px; gap: 10px;
} }
.disclaimer {
margin: 20px 0 0;
color: #6b7280;
font-size: 13px;
line-height: 1.6;
padding-top: 16px;
border-top: 1px solid #e8eef1;
}
.preview-card { .preview-card {
border: 1px solid #dfe9ec; border: 1px solid #dfe9ec;
border-radius: 10px; border-radius: 7px;
padding: 32px; padding: 32px;
text-align: center; text-align: center;
background: linear-gradient(180deg, #ffffff, #f8fbfc); background: #f8fafb;
} }
.pdf-frame { .pdf-frame {
height: 70vh; height: 70vh;
border: 1px solid #dfe9ec; border: 1px solid #dfe9ec;
border-radius: 10px; border-radius: 7px;
overflow: hidden; overflow: hidden;
background: #f8fafc; background: #f8fafc;
} }
@@ -606,9 +740,48 @@ h1 {
font-size: 13px; font-size: 13px;
} }
.public-footer {
width: min(880px, calc(100% - 40px));
margin: 0 auto;
padding: 24px 0 32px;
border-top: 1px solid #dfe5e7;
color: #75818a;
font-size: 12px;
line-height: 1.7;
text-align: center;
}
.public-footer p {
margin: 0;
}
.public-footer span {
display: block;
margin-top: 8px;
color: #9a7637;
}
@media (max-width: 640px) { @media (max-width: 640px) {
.query-panel { .query-main {
padding: 22px; width: calc(100% - 28px);
padding: 38px 0 34px;
}
h1 {
font-size: 30px;
}
.trust-row {
display: grid;
gap: 8px;
}
.query-card {
padding: 22px 18px;
}
.query-card-head {
display: grid;
} }
.card-top, .card-top,
@@ -620,5 +793,15 @@ h1 {
.meta { .meta {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.captcha-row,
.sms-row {
grid-template-columns: minmax(0, 1fr) 126px;
}
.card-actions .el-button {
width: 100%;
margin: 0;
}
} }
</style> </style>

View File

@@ -1,11 +1,18 @@
<template> <template>
<main class="public-page"> <main class="public-page">
<PublicServiceHeader />
<section class="certificate-page"> <section class="certificate-page">
<p class="kicker">电子证书</p> <header class="page-title">
<h1>证书公开展示</h1> <p class="kicker">DIGITAL CERTIFICATE</p>
<h1>电子证书</h1>
<p>查看证书签发信息并下载正式 PDF 文件</p>
</header>
<el-alert v-if="message" class="notice" :title="message" :type="messageType" :closable="false" /> <el-alert v-if="message" class="notice" :title="message" :type="messageType" :closable="false" />
<article v-if="certificate" class="certificate-card"> <article v-if="certificate" class="certificate-card">
<span :class="['status-icon', certificate.status === 'valid' ? 'ok' : 'bad']">
<el-icon><CircleCheck v-if="certificate.status === 'valid'" /><Warning v-else /></el-icon>
</span>
<span :class="['status', certificate.status === 'valid' ? 'ok' : 'bad']">{{ statusText(certificate.status) }}</span> <span :class="['status', certificate.status === 'valid' ? 'ok' : 'bad']">{{ statusText(certificate.status) }}</span>
<h2>{{ certificate.certificate_name }}</h2> <h2>{{ certificate.certificate_name }}</h2>
<p class="name">{{ certificate.learner_name }}</p> <p class="name">{{ certificate.learner_name }}</p>
@@ -25,24 +32,25 @@
<dt>发证日期</dt> <dt>发证日期</dt>
<dd>{{ certificate.issue_date }}</dd> <dd>{{ certificate.issue_date }}</dd>
</div> </div>
<div>
</div>
</dl> </dl>
<el-button v-if="certificate.can_download_pdf" type="primary" class="download" :loading="downloading" @click="downloadPdf"> <el-button v-if="certificate.can_download_pdf" type="primary" :icon="Download" class="download" :loading="downloading" @click="downloadPdf">
下载 PDF 证书 下载 PDF 证书
</el-button> </el-button>
</article> </article>
</section> </section>
<footer class="page-foot">慧遇书院电子证书服务</footer>
</main> </main>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { CircleCheck, Download, Warning } from "@element-plus/icons-vue";
import { ElLoading, ElMessage } from "element-plus"; import { ElLoading, ElMessage } from "element-plus";
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { http, type PublicCertificate } from "../api"; import { http, type PublicCertificate } from "../api";
import { apiErrorMessage, saveBlob } from "../download"; import { apiErrorMessage, saveBlob } from "../download";
import PublicServiceHeader from "../components/PublicServiceHeader.vue";
const route = useRoute(); const route = useRoute();
const token = String(route.params.token); const token = String(route.params.token);
@@ -98,18 +106,17 @@ onMounted(loadCertificate);
<style scoped> <style scoped>
.public-page { .public-page {
min-height: 100vh; min-height: 100vh;
background: #f7fafc; background: #f4f6f7;
padding: 36px 20px;
} }
.certificate-page { .certificate-page {
max-width: 760px; max-width: 820px;
margin: 0 auto; margin: 0 auto;
background: #ffffff; padding: 54px 20px 36px;
border: 1px solid #e1ebef; }
border-radius: 12px;
padding: 30px; .page-title {
box-shadow: 0 16px 46px rgba(76, 102, 118, 0.08); text-align: center;
} }
.kicker { .kicker {
@@ -125,7 +132,13 @@ h2 {
} }
h1 { h1 {
font-size: 28px; font-size: 34px;
}
.page-title > p:last-child {
margin: 9px 0 0;
color: #6c7982;
font-size: 14px;
} }
.notice { .notice {
@@ -133,12 +146,34 @@ h1 {
} }
.certificate-card { .certificate-card {
margin-top: 20px; margin-top: 22px;
border: 1px solid #dfe9ec; border: 1px solid #dce3e6;
border-radius: 10px; border-top: 3px solid #b28a43;
padding: 34px; border-radius: 7px;
padding: 38px;
text-align: center; text-align: center;
background: #fbfefe; background: #ffffff;
box-shadow: 0 14px 36px rgba(31, 47, 57, 0.08);
}
.status-icon {
width: 46px;
height: 46px;
display: grid;
place-items: center;
margin: 0 auto 10px;
border-radius: 50%;
font-size: 25px;
}
.status-icon.ok {
color: #176f69;
background: #e8f3f1;
}
.status-icon.bad {
color: #a6312c;
background: #fae9e8;
} }
.status { .status {
@@ -176,11 +211,24 @@ h1 {
.meta { .meta {
display: grid; display: grid;
gap: 10px; grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0;
margin: 0; margin: 0;
border: 1px solid #e3e8ea;
border-radius: 6px;
color: #64748b; color: #64748b;
} }
.meta div {
min-width: 0;
padding: 14px;
border-right: 1px solid #e3e8ea;
}
.meta div:nth-child(3) {
border-right: 0;
}
.meta dt { .meta dt {
font-size: 12px; font-size: 12px;
} }
@@ -192,5 +240,36 @@ h1 {
.download { .download {
margin-top: 24px; margin-top: 24px;
min-width: 180px;
}
.page-foot {
padding: 22px 20px 30px;
color: #8a949b;
font-size: 12px;
text-align: center;
}
@media (max-width: 640px) {
.certificate-page {
padding-top: 36px;
}
.certificate-card {
padding: 28px 18px;
}
.meta {
grid-template-columns: 1fr;
}
.meta div {
border-right: 0;
border-bottom: 1px solid #e3e8ea;
}
.meta div:last-child {
border-bottom: 0;
}
} }
</style> </style>

View File

@@ -1,11 +1,15 @@
<template> <template>
<main class="public-page"> <main class="public-page">
<PublicServiceHeader />
<section class="verify-page"> <section class="verify-page">
<h1>证书核验</h1> <header class="verify-title">
<p>VERIFICATION RESULT</p>
<h1>证书核验结果</h1>
</header>
<el-alert :title="message" :type="messageType" :closable="false" /> <el-alert :title="message" :type="messageType" :closable="false" />
<el-descriptions v-if="certificate" :column="1" border class="details"> <el-descriptions v-if="certificate" :column="1" border class="details">
<el-descriptions-item label="核验结果">{{ certificate.status === "valid" ? "该证书真实有效" : "该证书已作废" }}</el-descriptions-item> <el-descriptions-item label="核验结果">{{ certificate.status === "valid" ? "该证书真实有效" : "该证书已作废" }}</el-descriptions-item>
<el-descriptions-item label="证书状态">{{ certificate.status }}</el-descriptions-item> <el-descriptions-item label="证书状态">{{ certificate.status === "valid" ? "有效" : "已作废" }}</el-descriptions-item>
<el-descriptions-item label="证书编号">{{ certificate.certificate_no }}</el-descriptions-item> <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.learner_name }}</el-descriptions-item>
<el-descriptions-item label="证书名称">{{ certificate.certificate_name }}</el-descriptions-item> <el-descriptions-item label="证书名称">{{ certificate.certificate_name }}</el-descriptions-item>
@@ -13,6 +17,7 @@
<el-descriptions-item label="发证日期">{{ certificate.issue_date }}</el-descriptions-item> <el-descriptions-item label="发证日期">{{ certificate.issue_date }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
</section> </section>
<footer class="page-foot">慧遇书院电子证书服务</footer>
</main> </main>
</template> </template>
@@ -21,6 +26,7 @@ import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { http, type PublicCertificate } from "../api"; import { http, type PublicCertificate } from "../api";
import PublicServiceHeader from "../components/PublicServiceHeader.vue";
const route = useRoute(); const route = useRoute();
const token = String(route.params.token); const token = String(route.params.token);
@@ -52,25 +58,53 @@ onMounted(loadCertificate);
<style scoped> <style scoped>
.public-page { .public-page {
min-height: 100vh; min-height: 100vh;
background: #f7f8fb; background: #f4f6f7;
padding: 32px;
} }
.verify-page { .verify-page {
max-width: 680px; max-width: 760px;
margin: 0 auto; margin: 0 auto;
background: #ffffff; padding: 54px 20px 36px;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 28px;
} }
h1 { .verify-title {
margin: 0 0 20px; margin-bottom: 22px;
font-size: 24px; text-align: center;
}
.verify-title p {
margin: 0 0 8px;
color: #9a7637;
font-family: Georgia, "Times New Roman", serif;
font-size: 12px;
font-weight: 700;
}
.verify-title h1 {
margin: 0;
font-size: 32px;
}
.verify-page > .el-alert,
.details {
background: #ffffff;
}
.verify-page > .el-alert {
border: 1px solid #dce3e6;
} }
.details { .details {
margin-top: 20px; margin-top: 14px;
border-radius: 7px;
overflow: hidden;
box-shadow: 0 10px 28px rgba(31, 47, 57, 0.06);
}
.page-foot {
padding: 22px 20px 30px;
color: #8a949b;
font-size: 12px;
text-align: center;
} }
</style> </style>