Initial certificate system
This commit is contained in:
25
certificate-template-lab/README.md
Normal file
25
certificate-template-lab/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# 结业证书模板调试模块
|
||||
|
||||
这个目录是证书模板的独立验证模块,暂时不接主系统数据库。
|
||||
|
||||
## 启动
|
||||
|
||||
在项目根目录执行:
|
||||
|
||||
```powershell
|
||||
$py = "C:\Users\nelso\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe"
|
||||
& $py -m http.server 8020 -d .\certificate-template-lab
|
||||
```
|
||||
|
||||
然后访问:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8020
|
||||
```
|
||||
|
||||
## 当前用途
|
||||
|
||||
- 以 `assets/original.jpg` 作为证书底图。
|
||||
- 擦除可变文字区域并重新绘制学员、课程、日期、单位、证书编号等信息。
|
||||
- 支持下载 PNG,用于先确认视觉效果。
|
||||
- 后续确认版式后,再把这套渲染参数迁移到主系统的 PDF/图片生成流程。
|
||||
465
certificate-template-lab/app.js
Normal file
465
certificate-template-lab/app.js
Normal file
@@ -0,0 +1,465 @@
|
||||
(function () {
|
||||
const canvas = document.getElementById("certificateCanvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const state = document.getElementById("renderState");
|
||||
const qrScratch = document.getElementById("qrScratch");
|
||||
|
||||
const W = canvas.width;
|
||||
const H = canvas.height;
|
||||
|
||||
const text = {
|
||||
loading: "\u52a0\u8f7d\u4e2d",
|
||||
rendering: "\u6e32\u67d3\u4e2d",
|
||||
ready: "\u5df2\u66f4\u65b0",
|
||||
imageFailed: "\u539f\u56fe\u52a0\u8f7d\u5931\u8d25",
|
||||
title: "\u7ed3\u4e1a\u8bc1\u4e66",
|
||||
proves: "\u7279\u6b64\u8bc1\u660e",
|
||||
inWord: "\u5728",
|
||||
year: "\u5e74",
|
||||
month: "\u6708",
|
||||
day: "\u65e5",
|
||||
toWord: "\u81f3",
|
||||
finished: "\u65e5\u5b8c\u6210\u4e86",
|
||||
issueDate: "\u53d1\u8bc1\u65e5\u671f\uff1a",
|
||||
certNo: "\u8bc1\u4e66\u7f16\u53f7\uff1a",
|
||||
fallbackStudent: "\u5b66\u5458",
|
||||
filePrefix: "\u7ed3\u4e1a\u8bc1\u4e66"
|
||||
};
|
||||
|
||||
const defaults = {
|
||||
studentName: "\u5f20\u4e09",
|
||||
courseName: "\u667a\u6167\u8d4b\u80fd\u7597\u6108\u5e08",
|
||||
stageName: "\u521d\u7ea7\u8bfe\u7a0b\u7684\u4e13\u4e1a\u5b66\u4e60\u3002",
|
||||
certificateNo: "HY20260602DYC001",
|
||||
startYear: "2026",
|
||||
startMonth: "6",
|
||||
startDay: "1",
|
||||
endYear: "2026",
|
||||
endMonth: "6",
|
||||
endDay: "2",
|
||||
issueDate: "2026 \u5e74 6 \u6708 2 \u65e5",
|
||||
mentorLabel: "\u5bfc\u5e08\uff1a",
|
||||
mentorName: "\u5362\u6167",
|
||||
issuerLeft: "\u6df1\u5733\u5e02\u6167\u6108\u6587\u5316\u79d1\u6280\u6709\u9650\u516c\u53f8",
|
||||
issuerRight: "\u56fd\u9645\u751f\u547d\u667a\u6167\u542f\u8499\u7814\u7a76\u9662",
|
||||
verifyUrl: "https://example.com/cert/HY20260602DYC001"
|
||||
};
|
||||
|
||||
const controls = {};
|
||||
Object.keys(defaults).forEach((key) => {
|
||||
controls[key] = document.getElementById(key);
|
||||
});
|
||||
controls.showOriginal = document.getElementById("showOriginal");
|
||||
controls.showDebug = document.getElementById("showDebug");
|
||||
|
||||
const original = new Image();
|
||||
original.src = "./assets/original.jpg";
|
||||
original.onload = render;
|
||||
original.onerror = () => {
|
||||
state.textContent = text.imageFailed;
|
||||
render();
|
||||
};
|
||||
|
||||
let renderTimer = null;
|
||||
let qrText = "";
|
||||
|
||||
bindEvents();
|
||||
render();
|
||||
|
||||
function bindEvents() {
|
||||
Object.values(controls).forEach((input) => {
|
||||
if (!input) return;
|
||||
input.addEventListener("input", scheduleRender);
|
||||
input.addEventListener("change", scheduleRender);
|
||||
});
|
||||
|
||||
document.getElementById("resetBtn").addEventListener("click", () => {
|
||||
Object.entries(defaults).forEach(([key, value]) => {
|
||||
controls[key].value = value;
|
||||
});
|
||||
controls.showOriginal.checked = true;
|
||||
controls.showDebug.checked = false;
|
||||
render();
|
||||
});
|
||||
|
||||
document.getElementById("downloadBtn").addEventListener("click", () => {
|
||||
render();
|
||||
const link = document.createElement("a");
|
||||
const name = safeFileName(value("studentName") || text.fallbackStudent);
|
||||
link.download = `${text.filePrefix}_${name}.png`;
|
||||
link.href = canvas.toDataURL("image/png", 1);
|
||||
link.click();
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleRender() {
|
||||
window.clearTimeout(renderTimer);
|
||||
renderTimer = window.setTimeout(render, 80);
|
||||
}
|
||||
|
||||
function value(key) {
|
||||
return (controls[key] && controls[key].value || "").trim();
|
||||
}
|
||||
|
||||
function render() {
|
||||
try {
|
||||
state.textContent = text.rendering;
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
const usingOriginal = controls.showOriginal.checked && original.complete && original.naturalWidth;
|
||||
|
||||
if (usingOriginal) {
|
||||
ctx.drawImage(original, 0, 0, W, H);
|
||||
eraseEditableAreas();
|
||||
} else {
|
||||
drawFallbackBase();
|
||||
}
|
||||
|
||||
drawStudentName();
|
||||
drawMainText();
|
||||
drawIssueDate();
|
||||
drawCertificateNo();
|
||||
|
||||
if (usingOriginal) {
|
||||
drawOriginalMentorLayer();
|
||||
} else {
|
||||
drawMentor();
|
||||
drawFooter();
|
||||
drawQr();
|
||||
}
|
||||
|
||||
if (controls.showDebug.checked) {
|
||||
drawDebugAreas();
|
||||
}
|
||||
|
||||
state.textContent = text.ready;
|
||||
} catch (err) {
|
||||
console.error("Certificate render failed:", err);
|
||||
state.textContent = "渲染异常";
|
||||
}
|
||||
}
|
||||
|
||||
const areas = [
|
||||
{ x: 150, y: 442, w: 760, h: 132, pad: 16 },
|
||||
{ x: 404, y: 675, w: 220, h: 38, pad: 10 }
|
||||
];
|
||||
|
||||
function eraseEditableAreas() {
|
||||
areas.forEach((area) => eraseWithPaper(area));
|
||||
}
|
||||
|
||||
function eraseWithPaper(area) {
|
||||
if (!original.complete || !original.naturalWidth) return;
|
||||
|
||||
const pad = area.pad || 8;
|
||||
const sourceX = 72;
|
||||
const sourceW = 88;
|
||||
const patch = document.createElement("canvas");
|
||||
patch.width = area.w + pad * 2;
|
||||
patch.height = area.h + pad * 2;
|
||||
const p = patch.getContext("2d");
|
||||
|
||||
for (let x = 0; x < patch.width; x += sourceW) {
|
||||
const tileW = Math.min(sourceW, patch.width - x);
|
||||
p.drawImage(
|
||||
original,
|
||||
sourceX,
|
||||
Math.max(0, area.y - pad),
|
||||
tileW,
|
||||
patch.height,
|
||||
x,
|
||||
0,
|
||||
tileW,
|
||||
patch.height
|
||||
);
|
||||
}
|
||||
|
||||
p.globalCompositeOperation = "destination-in";
|
||||
p.globalCompositeOperation = "source-over";
|
||||
p.globalAlpha = 0.38;
|
||||
p.fillStyle = "#fbf7ef";
|
||||
p.fillRect(0, 0, patch.width, patch.height);
|
||||
p.globalAlpha = 1;
|
||||
|
||||
p.globalCompositeOperation = "destination-in";
|
||||
const g = p.createLinearGradient(0, 0, 0, patch.height);
|
||||
g.addColorStop(0, "rgba(0,0,0,0)");
|
||||
g.addColorStop(0.04, "rgba(0,0,0,1)");
|
||||
g.addColorStop(0.96, "rgba(0,0,0,1)");
|
||||
g.addColorStop(1, "rgba(0,0,0,0)");
|
||||
p.fillStyle = g;
|
||||
p.fillRect(0, 0, patch.width, patch.height);
|
||||
|
||||
p.globalCompositeOperation = "destination-in";
|
||||
const gx = p.createLinearGradient(0, 0, patch.width, 0);
|
||||
gx.addColorStop(0, "rgba(0,0,0,0)");
|
||||
gx.addColorStop(0.035, "rgba(0,0,0,1)");
|
||||
gx.addColorStop(0.965, "rgba(0,0,0,1)");
|
||||
gx.addColorStop(1, "rgba(0,0,0,0)");
|
||||
p.fillStyle = gx;
|
||||
p.fillRect(0, 0, patch.width, patch.height);
|
||||
|
||||
ctx.drawImage(patch, area.x - pad, area.y - pad);
|
||||
}
|
||||
|
||||
function drawFallbackBase() {
|
||||
ctx.fillStyle = "#fbf7ef";
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
ctx.strokeStyle = "#25344f";
|
||||
ctx.lineWidth = 12;
|
||||
ctx.strokeRect(6, 6, W - 12, H - 12);
|
||||
ctx.strokeStyle = "#a98732";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(42, 38, W - 84, H - 76);
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillStyle = "#111";
|
||||
ctx.font = '700 52px "SimSun", serif';
|
||||
ctx.fillText(text.title, W / 2, 196);
|
||||
}
|
||||
|
||||
function drawSubTitle() {
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillStyle = "#49341f";
|
||||
ctx.font = '700 17px "SimSun", "Microsoft YaHei", serif';
|
||||
ctx.fillText(text.proves, W / 2, 329);
|
||||
ctx.fillStyle = "#555";
|
||||
ctx.font = '13px Georgia, serif';
|
||||
ctx.fillText("This Certifies That", W / 2, 351);
|
||||
}
|
||||
|
||||
function drawStudentName() {
|
||||
const name = value("studentName");
|
||||
if (!name) return;
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillStyle = "#111";
|
||||
ctx.font = '700 32px "KaiTi", "STKaiti", "SimSun", serif';
|
||||
ctx.fillText(name, W / 2, 414);
|
||||
}
|
||||
|
||||
function drawMainText() {
|
||||
const line2 = value("stageName");
|
||||
|
||||
ctx.fillStyle = "#111";
|
||||
ctx.textAlign = "left";
|
||||
let x = 185;
|
||||
const y = 494;
|
||||
x = drawInlineText(text.inWord, x, y, 24, 400, 18);
|
||||
x = drawInlineText(value("startYear"), x, y, 24, 900, 8, "dateNumber");
|
||||
x = drawInlineText(text.year, x, y, 24, 400, 12);
|
||||
x = drawInlineText(value("startMonth"), x, y, 24, 900, 6, "dateNumber");
|
||||
x = drawInlineText(text.month, x, y, 24, 400, 12);
|
||||
x = drawInlineText(value("startDay"), x, y, 24, 900, 4, "dateNumber");
|
||||
x = drawInlineText(`${text.day}${text.toWord}`, x, y, 24, 400, 12);
|
||||
x = drawInlineText(value("endYear"), x, y, 24, 900, 8, "dateNumber");
|
||||
x = drawInlineText(text.year, x, y, 24, 400, 12);
|
||||
x = drawInlineText(value("endMonth"), x, y, 24, 900, 6, "dateNumber");
|
||||
x = drawInlineText(text.month, x, y, 24, 400, 12);
|
||||
x = drawInlineText(value("endDay"), x, y, 24, 900, 4, "dateNumber");
|
||||
x = drawInlineText(`${text.day}\u5b8c\u6210\u4e86`, x, y, 24, 400, 8);
|
||||
drawInlineFlow([
|
||||
{ text: `\u201c${value("courseName")}\u201d`, style: "course" },
|
||||
{ text: line2, style: "normal", gapBefore: 14 },
|
||||
], x, y);
|
||||
}
|
||||
|
||||
function drawInlineFlow(segments, startX, startY) {
|
||||
let x = startX;
|
||||
let y = startY;
|
||||
const lineStart = 185;
|
||||
const lineHeight = 42;
|
||||
|
||||
segments.forEach((segment) => {
|
||||
if (segment.gapBefore) x += segment.gapBefore;
|
||||
Array.from(segment.text || "").forEach((char) => {
|
||||
const style = inlineStyle(segment.style);
|
||||
ctx.font = style.font;
|
||||
const maxRight = y === startY ? 900 : 730;
|
||||
const width = ctx.measureText(char).width;
|
||||
if (x + width > maxRight && x > lineStart) {
|
||||
x = lineStart;
|
||||
y += lineHeight;
|
||||
}
|
||||
ctx.fillText(char, x, y);
|
||||
if (segment.style === "course") ctx.fillText(char, x + 0.45, y);
|
||||
x += width;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function inlineStyle(style) {
|
||||
if (style === "course") {
|
||||
return { font: '900 27px "KaiTi", "STKaiti", "Kaiti SC", "Noto Serif SC", serif' };
|
||||
}
|
||||
return { font: '400 24px "SimSun", "Noto Serif SC", serif' };
|
||||
}
|
||||
|
||||
function drawInlineText(raw, x, y, size, weight, gap, style) {
|
||||
const textValue = raw || "";
|
||||
const useKaiti = style === true || style === "dateNumber";
|
||||
const family = useKaiti
|
||||
? '"KaiTi", "STKaiti", "Kaiti SC", "Noto Serif SC", serif'
|
||||
: '"SimSun", "Noto Serif SC", serif';
|
||||
ctx.font = `${weight} ${size}px ${family}`;
|
||||
ctx.fillText(textValue, x, y);
|
||||
if (style === true || style === "dateNumber") {
|
||||
ctx.fillText(textValue, x + 0.45, y);
|
||||
}
|
||||
return x + ctx.measureText(textValue).width + gap;
|
||||
}
|
||||
|
||||
function drawMentor() {
|
||||
const label = value("mentorLabel");
|
||||
const name = value("mentorName");
|
||||
if (!label && !name) return;
|
||||
|
||||
ctx.fillStyle = "#111";
|
||||
ctx.textAlign = "left";
|
||||
ctx.font = '700 21px "SimSun", "Microsoft YaHei", serif';
|
||||
ctx.fillText(label, 740, 586);
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(812, 594);
|
||||
ctx.rotate(-0.05);
|
||||
ctx.fillStyle = "#1b1b1b";
|
||||
ctx.font = '700 34px "KaiTi", "STKaiti", "SimSun", serif';
|
||||
ctx.fillText(name, 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawOriginalMentorLayer() {
|
||||
if (!original.complete || !original.naturalWidth) return;
|
||||
ctx.drawImage(original, 720, 535, 230, 94, 720, 535, 230, 94);
|
||||
}
|
||||
|
||||
function drawFooter() {
|
||||
ctx.fillStyle = "#111";
|
||||
ctx.font = '700 20px "SimSun", "Microsoft YaHei", serif';
|
||||
ctx.textAlign = "left";
|
||||
drawTextFit(value("issuerLeft"), 190, 643, 300, 20);
|
||||
|
||||
ctx.textAlign = "right";
|
||||
drawTextFit(value("issuerRight"), 835, 643, 320, 20);
|
||||
|
||||
ctx.textAlign = "center";
|
||||
drawIssueDate();
|
||||
}
|
||||
|
||||
function drawIssueDate() {
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillStyle = "#43382f";
|
||||
ctx.font = '700 13px "SimSun", serif';
|
||||
ctx.fillText(`${text.issueDate}${value("issueDate")}`, W / 2, 704);
|
||||
}
|
||||
|
||||
function drawCertificateNo() {
|
||||
const no = value("certificateNo");
|
||||
if (!no) return;
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillStyle = "#111827";
|
||||
ctx.font = '700 14px Arial, "Microsoft YaHei", sans-serif';
|
||||
ctx.fillText(`${text.certNo}${no}`, 105, 76);
|
||||
}
|
||||
|
||||
function drawQr() {
|
||||
const x = W / 2 - 33;
|
||||
const y = 602;
|
||||
const box = 66;
|
||||
|
||||
const qrNode = getQrNode(box);
|
||||
ctx.save();
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.fillRect(x - 5, y - 5, box + 10, box + 10);
|
||||
ctx.strokeStyle = "#d3c6a4";
|
||||
ctx.strokeRect(x - 5, y - 5, box + 10, box + 10);
|
||||
|
||||
if (qrNode) {
|
||||
try {
|
||||
ctx.drawImage(qrNode, x, y, box, box);
|
||||
} catch (err) {
|
||||
console.warn("QR image is not ready yet, using fallback.", err);
|
||||
drawQrFallbackPixels(x, y);
|
||||
window.setTimeout(render, 120);
|
||||
}
|
||||
} else {
|
||||
drawQrFallbackPixels(x, y);
|
||||
}
|
||||
|
||||
ctx.fillStyle = "#1f67ad";
|
||||
ctx.fillRect(x + 28, y + 28, 10, 10);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function getQrNode(size) {
|
||||
if (!window.QRCode || !qrScratch) return null;
|
||||
|
||||
const nextText = value("verifyUrl") || "https://example.com";
|
||||
if (nextText !== qrText || !qrScratch.firstChild) {
|
||||
qrText = nextText;
|
||||
qrScratch.innerHTML = "";
|
||||
new QRCode(qrScratch, {
|
||||
text: nextText,
|
||||
width: size,
|
||||
height: size,
|
||||
colorDark: "#111111",
|
||||
colorLight: "#ffffff",
|
||||
correctLevel: QRCode.CorrectLevel.H
|
||||
});
|
||||
}
|
||||
|
||||
const canvasNode = qrScratch.querySelector("canvas");
|
||||
if (canvasNode) return canvasNode;
|
||||
|
||||
const imgNode = qrScratch.querySelector("img");
|
||||
if (imgNode && imgNode.complete && imgNode.naturalWidth) return imgNode;
|
||||
return null;
|
||||
}
|
||||
|
||||
function drawQrFallbackPixels(x, y) {
|
||||
ctx.fillStyle = "#111";
|
||||
const seed = hash(value("verifyUrl"));
|
||||
const cell = 3;
|
||||
for (let row = 0; row < 22; row++) {
|
||||
for (let col = 0; col < 22; col++) {
|
||||
const fixed = finder(col, row) || finder(col - 15, row) || finder(col, row - 15);
|
||||
const on = fixed || ((seed + row * 17 + col * 31 + row * col) % 7 < 3);
|
||||
if (on) ctx.fillRect(x + col * cell, y + row * cell, cell, cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function finder(col, row) {
|
||||
if (col < 0 || row < 0 || col > 6 || row > 6) return false;
|
||||
return col === 0 || row === 0 || col === 6 || row === 6 || (col >= 2 && col <= 4 && row >= 2 && row <= 4);
|
||||
}
|
||||
|
||||
function hash(raw) {
|
||||
let n = 0;
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
n = (n * 31 + raw.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function drawTextFit(raw, x, y, maxWidth, size) {
|
||||
let fontSize = size;
|
||||
const textValue = raw || "";
|
||||
do {
|
||||
ctx.font = ctx.font.replace(/\d+px/, `${fontSize}px`);
|
||||
fontSize--;
|
||||
} while (ctx.measureText(textValue).width > maxWidth && fontSize > 12);
|
||||
ctx.fillText(textValue, x, y);
|
||||
}
|
||||
|
||||
function drawDebugAreas() {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "#f04438";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([6, 5]);
|
||||
areas.forEach((area) => ctx.strokeRect(area.x, area.y, area.w, area.h));
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function safeFileName(raw) {
|
||||
return raw.replace(/[\\/:*?"<>|]/g, "_");
|
||||
}
|
||||
})();
|
||||
BIN
certificate-template-lab/assets/original.jpg
Normal file
BIN
certificate-template-lab/assets/original.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
117
certificate-template-lab/index.html
Normal file
117
certificate-template-lab/index.html
Normal file
@@ -0,0 +1,117 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>结业证书模板调试</title>
|
||||
<link rel="stylesheet" href="./style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<aside class="panel">
|
||||
<div class="panel-head">
|
||||
<p class="eyebrow">Certificate Lab</p>
|
||||
<h1>结业证书模板调试</h1>
|
||||
<p class="hint">这里先单独调证书视觉,不接主系统数据。改字段后右侧会实时重绘。</p>
|
||||
</div>
|
||||
|
||||
<section class="group">
|
||||
<h2>学员与课程</h2>
|
||||
<label>学员姓名
|
||||
<input id="studentName" value="张三">
|
||||
</label>
|
||||
<label>课程名称
|
||||
<input id="courseName" value="智慧赋能疗愈师">
|
||||
</label>
|
||||
<label>课程阶段
|
||||
<input id="stageName" value="初级课程的专业学习。">
|
||||
</label>
|
||||
<label>证书编号
|
||||
<input id="certificateNo" value="HY20260602DYC001">
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="group">
|
||||
<h2>日期</h2>
|
||||
<div class="grid">
|
||||
<label>开始年
|
||||
<input id="startYear" value="2026">
|
||||
</label>
|
||||
<label>开始月
|
||||
<input id="startMonth" value="6">
|
||||
</label>
|
||||
<label>开始日
|
||||
<input id="startDay" value="1">
|
||||
</label>
|
||||
<label>结束年
|
||||
<input id="endYear" value="2026">
|
||||
</label>
|
||||
<label>结束月
|
||||
<input id="endMonth" value="6">
|
||||
</label>
|
||||
<label>结束日
|
||||
<input id="endDay" value="2">
|
||||
</label>
|
||||
</div>
|
||||
<label>发证日期
|
||||
<input id="issueDate" value="2026 年 6 月 2 日">
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="group muted-group">
|
||||
<h2>底部图层</h2>
|
||||
<p class="group-note">导师、机构和二维码先使用原图。导师会作为最上层图层覆盖。</p>
|
||||
<label>导师称谓
|
||||
<input id="mentorLabel" value="导师:" disabled>
|
||||
</label>
|
||||
<label>导师姓名
|
||||
<input id="mentorName" value="卢慧" disabled>
|
||||
</label>
|
||||
<label>左侧单位
|
||||
<input id="issuerLeft" value="深圳市慧愈文化科技有限公司" disabled>
|
||||
</label>
|
||||
<label>右侧单位
|
||||
<input id="issuerRight" value="国际生命智慧启蒙研究院" disabled>
|
||||
</label>
|
||||
<label>查询链接
|
||||
<input id="verifyUrl" value="https://example.com/cert/HY20260602DYC001" disabled>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="group">
|
||||
<h2>显示开关</h2>
|
||||
<label class="check">
|
||||
<input type="checkbox" id="showOriginal" checked>
|
||||
使用原图作为底图
|
||||
</label>
|
||||
<label class="check">
|
||||
<input type="checkbox" id="showDebug">
|
||||
显示擦除区域
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<div class="actions">
|
||||
<button id="resetBtn" type="button">恢复示例</button>
|
||||
<button id="downloadBtn" type="button">下载 PNG</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="preview">
|
||||
<div class="preview-head">
|
||||
<div>
|
||||
<p class="eyebrow">1024 x 759</p>
|
||||
<h2>实时预览</h2>
|
||||
</div>
|
||||
<span id="renderState">加载中</span>
|
||||
</div>
|
||||
<div class="canvas-shell">
|
||||
<canvas id="certificateCanvas" width="1024" height="759"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="qrScratch" aria-hidden="true"></div>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
|
||||
<script src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
246
certificate-template-lab/style.css
Normal file
246
certificate-template-lab/style.css
Normal file
@@ -0,0 +1,246 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f5f8fb;
|
||||
--panel: #ffffff;
|
||||
--line: #d9e2ec;
|
||||
--text: #172033;
|
||||
--muted: #657184;
|
||||
--blue: #2f6fbd;
|
||||
--blue-dark: #215493;
|
||||
--gold: #a98732;
|
||||
--shadow: 0 18px 45px rgba(30, 52, 82, .12);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: "Microsoft YaHei", "PingFang SC", Arial, sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
linear-gradient(120deg, rgba(47, 111, 189, .08), transparent 38%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
.page {
|
||||
display: grid;
|
||||
grid-template-columns: 360px minmax(0, 1fr);
|
||||
gap: 22px;
|
||||
min-height: 100vh;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.panel,
|
||||
.preview {
|
||||
background: rgba(255, 255, 255, .92);
|
||||
border: 1px solid rgba(217, 226, 236, .9);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
padding: 22px 22px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 6px;
|
||||
color: var(--gold);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
margin: 0;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.group {
|
||||
padding: 16px 22px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.group h2 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.group-note {
|
||||
margin: -4px 0 12px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.muted-group input:disabled {
|
||||
color: #8b96a8;
|
||||
background: #f2f5f8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
color: #3a4658;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
border: 1px solid #cdd8e6;
|
||||
border-radius: 6px;
|
||||
padding: 10px 11px;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: var(--blue);
|
||||
box-shadow: 0 0 0 3px rgba(47, 111, 189, .14);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.check {
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.check input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
padding: 16px 22px 22px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
padding: 11px 12px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#resetBtn {
|
||||
color: var(--blue-dark);
|
||||
background: #e8f1fc;
|
||||
}
|
||||
|
||||
#downloadBtn {
|
||||
color: #fff;
|
||||
background: var(--blue);
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 22px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
#renderState {
|
||||
color: var(--blue-dark);
|
||||
background: #e8f1fc;
|
||||
border-radius: 999px;
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.canvas-shell {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 1;
|
||||
padding: 28px;
|
||||
overflow: auto;
|
||||
background:
|
||||
linear-gradient(45deg, #eef3f8 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #eef3f8 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #eef3f8 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #eef3f8 75%);
|
||||
background-size: 24px 24px;
|
||||
background-position: 0 0, 0 12px, 12px -12px, -12px 0;
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
width: min(100%, 1024px);
|
||||
height: auto;
|
||||
background: #faf7ef;
|
||||
box-shadow: 0 18px 40px rgba(28, 42, 62, .18);
|
||||
}
|
||||
|
||||
#qrScratch {
|
||||
position: fixed;
|
||||
left: -9999px;
|
||||
top: -9999px;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.page {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.panel {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user