新增PDF生成并发设置
This commit is contained in:
@@ -119,3 +119,8 @@ export interface RestoreDatabaseBackupResult {
|
||||
restored_backup: DatabaseBackup;
|
||||
pre_restore_backup: DatabaseBackup;
|
||||
}
|
||||
|
||||
export interface PdfSettings {
|
||||
pdf_generation_concurrency_limit: number;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,26 @@ import { http } from "./api";
|
||||
|
||||
export async function downloadFile(url: string, filename: string) {
|
||||
const { data } = await http.get(url, { responseType: "blob" });
|
||||
saveBlob(data, filename);
|
||||
}
|
||||
|
||||
export function saveBlob(data: Blob, filename: string) {
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(data);
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
}
|
||||
|
||||
export async function apiErrorMessage(error: any, fallback: string) {
|
||||
const data = error?.response?.data;
|
||||
if (data instanceof Blob) {
|
||||
const text = await data.text();
|
||||
try {
|
||||
return JSON.parse(text).detail || fallback;
|
||||
} catch {
|
||||
return text || fallback;
|
||||
}
|
||||
}
|
||||
return data?.detail || fallback;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import AdminLogin from "../views/AdminLogin.vue";
|
||||
import AdminLogs from "../views/AdminLogs.vue";
|
||||
import AdminLearners from "../views/AdminLearners.vue";
|
||||
import AdminProjects from "../views/AdminProjects.vue";
|
||||
import AdminSettings from "../views/AdminSettings.vue";
|
||||
import AdminShell from "../views/AdminShell.vue";
|
||||
import CertificateQuery from "../views/CertificateQuery.vue";
|
||||
import CertificateView from "../views/CertificateView.vue";
|
||||
@@ -28,7 +29,8 @@ export const router = createRouter({
|
||||
{ path: "certificates", component: AdminCertificates },
|
||||
{ path: "imports", component: AdminImports },
|
||||
{ path: "logs", component: AdminLogs },
|
||||
{ path: "backups", component: AdminBackups }
|
||||
{ path: "backups", component: AdminBackups },
|
||||
{ path: "settings", component: AdminSettings }
|
||||
]
|
||||
},
|
||||
{ path: "/query", component: CertificateQuery },
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button size="small" @click="previewCertificate(row)">预览</el-button>
|
||||
<el-button size="small" @click="downloadCertificate(row)">下载</el-button>
|
||||
<el-button size="small" :loading="downloadingCertificateId === row.id" @click="downloadCertificate(row)">下载</el-button>
|
||||
<el-button size="small" @click="regenerateToken(row)">重置链接</el-button>
|
||||
<el-button v-if="row.status === 'valid'" size="small" type="danger" @click="voidCertificate(row)">作废</el-button>
|
||||
</div>
|
||||
@@ -94,11 +94,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { ElLoading, ElMessage, ElMessageBox } from "element-plus";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
|
||||
import { http, type AdminCertificate, type ProjectCourse } from "../api";
|
||||
import { downloadFile } from "../download";
|
||||
import { apiErrorMessage, downloadFile } from "../download";
|
||||
|
||||
const certificates = ref<AdminCertificate[]>([]);
|
||||
const projects = ref<ProjectCourse[]>([]);
|
||||
@@ -106,6 +106,7 @@ const keyword = ref("");
|
||||
const statusValue = ref("");
|
||||
const previewVisible = ref(false);
|
||||
const preview = ref<any>(null);
|
||||
const downloadingCertificateId = ref<number | null>(null);
|
||||
const form = reactive({
|
||||
learner_id: undefined as number | undefined,
|
||||
project_code: "",
|
||||
@@ -159,7 +160,20 @@ async function previewCertificate(row: AdminCertificate) {
|
||||
}
|
||||
|
||||
async function downloadCertificate(row: AdminCertificate) {
|
||||
await downloadFile(`/admin/certificates/${row.id}/download`, `${row.certificate_no}.pdf`);
|
||||
downloadingCertificateId.value = row.id;
|
||||
const loading = ElLoading.service({
|
||||
lock: true,
|
||||
text: "PDF正在排队或生成,请稍候...",
|
||||
background: "rgba(255, 255, 255, 0.72)",
|
||||
});
|
||||
try {
|
||||
await downloadFile(`/admin/certificates/${row.id}/download`, `${row.certificate_no}.pdf`);
|
||||
} catch (error: any) {
|
||||
ElMessage.error(await apiErrorMessage(error, "PDF生成失败,请稍后再试"));
|
||||
} finally {
|
||||
loading.close();
|
||||
downloadingCertificateId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function voidCertificate(row: AdminCertificate) {
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<el-option label="证书" value="certificate" />
|
||||
<el-option label="导入批次" value="import_batch" />
|
||||
<el-option label="数据库备份" value="database_backup" />
|
||||
<el-option label="系统设置" value="system_setting" />
|
||||
<el-option label="公开访问" value="public_access" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.risk" clearable placeholder="风险等级">
|
||||
@@ -122,6 +123,7 @@ const actionOptions = [
|
||||
{ value: "export_certificates", label: "导出证书" },
|
||||
{ value: "create_database_backup", label: "创建数据库备份" },
|
||||
{ value: "restore_database_backup", label: "回滚数据库备份" },
|
||||
{ value: "update_pdf_settings", label: "修改PDF生成设置" },
|
||||
{ value: "public_search_certificate", label: "公开查询证书" },
|
||||
{ value: "public_download_certificate", label: "公开下载证书" },
|
||||
];
|
||||
|
||||
114
frontend/src/views/AdminSettings.vue
Normal file
114
frontend/src/views/AdminSettings.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<section>
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h2>系统设置</h2>
|
||||
<p>调整会影响正式服务运行的后台参数。</p>
|
||||
</div>
|
||||
<el-button @click="loadSettings">刷新</el-button>
|
||||
</header>
|
||||
|
||||
<el-card class="panel" shadow="never">
|
||||
<div class="setting-head">
|
||||
<div>
|
||||
<h3>PDF 生成并发限制</h3>
|
||||
<p>限制同时生成证书 PDF 的数量。2核4G服务器建议保持默认值 2。</p>
|
||||
</div>
|
||||
<el-tag type="info">默认 2</el-tag>
|
||||
</div>
|
||||
|
||||
<el-form class="settings-form" label-position="top">
|
||||
<el-form-item label="同时生成 PDF 数量">
|
||||
<el-input-number v-model="form.pdf_generation_concurrency_limit" :min="1" :max="10" :step="1" />
|
||||
<div class="form-tip">
|
||||
缓存命中的 PDF 下载不占用生成名额;首次生成会排队等待,前台会显示生成中提示。
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-button type="primary" :loading="saving" @click="saveSettings">保存设置</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from "element-plus";
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
|
||||
import { http, type PdfSettings } from "../api";
|
||||
|
||||
const saving = ref(false);
|
||||
const form = reactive({
|
||||
pdf_generation_concurrency_limit: 2,
|
||||
});
|
||||
|
||||
async function loadSettings() {
|
||||
const { data } = await http.get<PdfSettings>("/admin/settings/pdf");
|
||||
form.pdf_generation_concurrency_limit = data.pdf_generation_concurrency_limit;
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
saving.value = true;
|
||||
try {
|
||||
const { data } = await http.put<PdfSettings>("/admin/settings/pdf", {
|
||||
pdf_generation_concurrency_limit: form.pdf_generation_concurrency_limit,
|
||||
});
|
||||
form.pdf_generation_concurrency_limit = data.pdf_generation_concurrency_limit;
|
||||
ElMessage.success("系统设置已保存");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadSettings);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.page-head p,
|
||||
.setting-head p {
|
||||
margin: 6px 0 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.setting-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.setting-head h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
margin-top: 8px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.page-head,
|
||||
.setting-head {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -13,6 +13,7 @@
|
||||
<el-menu-item index="/admin/imports">Excel 导入</el-menu-item>
|
||||
<el-menu-item index="/admin/logs">操作日志</el-menu-item>
|
||||
<el-menu-item index="/admin/backups">备份管理</el-menu-item>
|
||||
<el-menu-item index="/admin/settings">系统设置</el-menu-item>
|
||||
</el-menu>
|
||||
<el-button class="logout" @click="logout">退出登录</el-button>
|
||||
</aside>
|
||||
|
||||
@@ -97,7 +97,14 @@
|
||||
|
||||
<div class="card-actions">
|
||||
<el-button @click="preview(item)">预览</el-button>
|
||||
<el-button type="primary" :disabled="!item.can_download_pdf" @click="downloadPdf(item)">下载 PDF</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:disabled="!item.can_download_pdf"
|
||||
:loading="downloadingCertificateNo === item.certificate_no"
|
||||
@click="downloadPdf(item)"
|
||||
>
|
||||
下载 PDF
|
||||
</el-button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
@@ -111,6 +118,10 @@
|
||||
<div v-if="previewPdfUrl" class="pdf-frame">
|
||||
<embed :src="previewPdfUrl" type="application/pdf" />
|
||||
</div>
|
||||
<div v-else-if="previewLoading" class="pdf-loading">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<p>PDF正在排队或生成,请稍候...</p>
|
||||
</div>
|
||||
<div v-else-if="current" class="preview-card">
|
||||
<p class="preview-kicker">Training Completion Certificate</p>
|
||||
<h2>{{ current.certificate_name }}</h2>
|
||||
@@ -126,16 +137,26 @@
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="closePreview">关闭</el-button>
|
||||
<el-button v-if="current?.can_download_pdf" type="primary" @click="downloadPdf(current)">下载 PDF</el-button>
|
||||
<el-button
|
||||
v-if="current?.can_download_pdf"
|
||||
type="primary"
|
||||
:loading="downloadingCertificateNo === current.certificate_no"
|
||||
@click="downloadPdf(current)"
|
||||
>
|
||||
下载 PDF
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Loading } from "@element-plus/icons-vue";
|
||||
import { ElLoading, ElMessage } from "element-plus";
|
||||
import { onMounted, onUnmounted, reactive, ref, watch } from "vue";
|
||||
|
||||
import { http, type Captcha, type PublicCertificate } from "../api";
|
||||
import { apiErrorMessage, saveBlob } from "../download";
|
||||
|
||||
interface SearchResponse {
|
||||
items: PublicCertificate[];
|
||||
@@ -159,6 +180,8 @@ const results = ref<PublicCertificate[]>([]);
|
||||
const previewVisible = ref(false);
|
||||
const current = ref<PublicCertificate | null>(null);
|
||||
const previewPdfUrl = ref("");
|
||||
const previewLoading = ref(false);
|
||||
const downloadingCertificateNo = ref("");
|
||||
const smsId = ref("");
|
||||
const sendingSms = ref(false);
|
||||
const smsCooldown = ref(0);
|
||||
@@ -285,12 +308,20 @@ async function submitQuery() {
|
||||
async function preview(item: PublicCertificate) {
|
||||
current.value = item;
|
||||
clearPreviewUrl();
|
||||
if (item.can_download_pdf && item.download_url) {
|
||||
const url = item.download_url.replace(/^\/api/, "");
|
||||
const { data } = await http.get(url, { responseType: "blob" });
|
||||
previewPdfUrl.value = URL.createObjectURL(data);
|
||||
}
|
||||
previewVisible.value = true;
|
||||
if (item.can_download_pdf && item.download_url) {
|
||||
previewLoading.value = true;
|
||||
try {
|
||||
const url = item.download_url.replace(/^\/api/, "");
|
||||
const { data } = await http.get(url, { responseType: "blob" });
|
||||
previewPdfUrl.value = URL.createObjectURL(data);
|
||||
} catch (error: any) {
|
||||
message.value = await apiErrorMessage(error, "PDF生成失败,请稍后再试");
|
||||
messageType.value = "error";
|
||||
} finally {
|
||||
previewLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearPreviewUrl() {
|
||||
@@ -304,8 +335,24 @@ function closePreview() {
|
||||
previewVisible.value = false;
|
||||
}
|
||||
|
||||
function downloadPdf(item: PublicCertificate) {
|
||||
if (item.download_url) window.location.href = item.download_url;
|
||||
async function downloadPdf(item: PublicCertificate) {
|
||||
if (!item.download_url) return;
|
||||
downloadingCertificateNo.value = item.certificate_no;
|
||||
const loading = ElLoading.service({
|
||||
lock: true,
|
||||
text: "PDF正在排队或生成,请稍候...",
|
||||
background: "rgba(255, 255, 255, 0.72)",
|
||||
});
|
||||
try {
|
||||
const url = item.download_url.replace(/^\/api/, "");
|
||||
const { data } = await http.get(url, { responseType: "blob" });
|
||||
saveBlob(data, `${item.certificate_no}.pdf`);
|
||||
} catch (error: any) {
|
||||
ElMessage.error(await apiErrorMessage(error, "PDF生成失败,请稍后再试"));
|
||||
} finally {
|
||||
loading.close();
|
||||
downloadingCertificateNo.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadCaptcha);
|
||||
@@ -520,6 +567,20 @@ h1 {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.pdf-loading {
|
||||
min-height: 240px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.pdf-loading .el-icon {
|
||||
color: #16817e;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.preview-kicker {
|
||||
margin: 0 0 10px;
|
||||
color: #16817e;
|
||||
|
||||
@@ -26,23 +26,28 @@
|
||||
<dd>{{ certificate.issuer_name }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<el-button v-if="certificate.can_download_pdf" type="primary" class="download" @click="downloadPdf">下载 PDF 证书</el-button>
|
||||
<el-button v-if="certificate.can_download_pdf" type="primary" class="download" :loading="downloading" @click="downloadPdf">
|
||||
下载 PDF 证书
|
||||
</el-button>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElLoading, ElMessage } from "element-plus";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
import { http, type PublicCertificate } from "../api";
|
||||
import { apiErrorMessage, saveBlob } from "../download";
|
||||
|
||||
const route = useRoute();
|
||||
const token = String(route.params.token);
|
||||
const certificate = ref<PublicCertificate | null>(null);
|
||||
const message = ref("");
|
||||
const messageType = ref<"success" | "warning" | "error">("success");
|
||||
const downloading = ref(false);
|
||||
|
||||
function statusText(status: string) {
|
||||
return status === "valid" ? "有效" : "已作废";
|
||||
@@ -60,8 +65,23 @@ async function loadCertificate() {
|
||||
}
|
||||
}
|
||||
|
||||
function downloadPdf() {
|
||||
window.location.href = `/api/public/certificates/token/${token}/download`;
|
||||
async function downloadPdf() {
|
||||
if (!certificate.value) return;
|
||||
downloading.value = true;
|
||||
const loading = ElLoading.service({
|
||||
lock: true,
|
||||
text: "PDF正在排队或生成,请稍候...",
|
||||
background: "rgba(255, 255, 255, 0.72)",
|
||||
});
|
||||
try {
|
||||
const { data } = await http.get(`/public/certificates/token/${token}/download`, { responseType: "blob" });
|
||||
saveBlob(data, `${certificate.value.certificate_no}.pdf`);
|
||||
} catch (error: any) {
|
||||
ElMessage.error(await apiErrorMessage(error, "PDF生成失败,请稍后再试"));
|
||||
} finally {
|
||||
loading.close();
|
||||
downloading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadCertificate);
|
||||
|
||||
Reference in New Issue
Block a user