499 lines
21 KiB
HTML
499 lines
21 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN" class="scroll-smooth">
|
||
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||
<title>电子签名归档系统</title>
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<!-- 引入 Phosphor Icons (更现代的图标库,比 FontAwesome 更有设计感) -->
|
||
<script src="https://unpkg.com/@phosphor-icons/web"></script>
|
||
<script>
|
||
tailwind.config = {
|
||
darkMode: 'class', // 手动控制暗色模式
|
||
theme: {
|
||
extend: {
|
||
colors: {
|
||
// 使用 Zinc 色系打造冷峻的高级感
|
||
gray: {
|
||
50: '#fafafa',
|
||
100: '#f4f4f5',
|
||
200: '#e4e4e7',
|
||
300: '#d4d4d8',
|
||
400: '#a1a1aa',
|
||
500: '#71717a',
|
||
600: '#52525b',
|
||
700: '#3f3f46',
|
||
800: '#27272a',
|
||
900: '#18181b',
|
||
950: '#09090b',
|
||
},
|
||
// 强调色:克制的深黑/亮白
|
||
primary: {
|
||
DEFAULT: '#18181b', // Zinc 900
|
||
dark: '#fafafa', // Zinc 50
|
||
}
|
||
},
|
||
fontFamily: {
|
||
sans: ['Inter', '-apple-system', 'system-ui', 'sans-serif'],
|
||
mono: ['JetBrains Mono', 'Menlo', 'monospace'], // 增加代码感
|
||
},
|
||
boxShadow: {
|
||
'paper': '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)',
|
||
'paper-hover': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
|
||
'dark-glow': '0 0 20px rgba(255, 255, 255, 0.05)',
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
<style type="text/tailwindcss">
|
||
@layer utilities {
|
||
.signature-canvas {
|
||
@apply w-full h-full cursor-crosshair touch-none;
|
||
}
|
||
/* 隐藏滚动条但保留功能 */
|
||
.no-scrollbar::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
.no-scrollbar {
|
||
-ms-overflow-style: none;
|
||
scrollbar-width: none;
|
||
}
|
||
/* 自定义 Range 滑块 - 极简风格 */
|
||
input[type=range] {
|
||
@apply h-1 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer;
|
||
}
|
||
input[type=range]::-webkit-slider-thumb {
|
||
@apply appearance-none w-3 h-3 rounded-full bg-gray-900 dark:bg-gray-100 shadow-sm transition-transform hover:scale-125;
|
||
}
|
||
}
|
||
body {
|
||
overscroll-behavior: none;
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 font-sans min-h-screen flex flex-col transition-colors duration-300 selection:bg-gray-900 selection:text-white dark:selection:bg-white dark:selection:text-gray-900">
|
||
|
||
<!-- 顶部极简导航 -->
|
||
<nav class="fixed top-0 w-full z-40 bg-gray-50/80 dark:bg-gray-900/80 backdrop-blur-md border-b border-gray-200 dark:border-gray-800 transition-colors duration-300">
|
||
<div class="max-w-3xl mx-auto px-6 h-14 flex items-center justify-between">
|
||
<div class="flex items-center gap-3">
|
||
<div class="w-2 h-2 bg-gray-900 dark:bg-gray-100 rounded-full"></div>
|
||
<h1 class="font-semibold text-sm tracking-wide uppercase text-gray-900 dark:text-gray-100">电子签名<span class="text-gray-400 dark:text-gray-600 font-light">.go</span></h1>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-4">
|
||
<span class="text-[10px] font-mono text-gray-400 hidden sm:block">服务状态: 在线</span>
|
||
<!-- 主题切换按钮 -->
|
||
<button id="themeToggle" class="p-2 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 transition-colors rounded-full focus:outline-none">
|
||
<i class="ph ph-moon text-lg dark:hidden"></i>
|
||
<i class="ph ph-sun text-lg hidden dark:block"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
|
||
<!-- 主内容区 -->
|
||
<main class="flex-grow flex flex-col items-center pt-24 pb-12 px-4 sm:px-6 w-full max-w-3xl mx-auto gap-8">
|
||
|
||
<!-- 头部信息输入 -->
|
||
<div class="w-full space-y-6">
|
||
<div class="flex flex-col sm:flex-row items-start sm:items-end justify-between gap-4">
|
||
<div class="w-full sm:w-2/3 space-y-1">
|
||
<label for="signerName" class="block text-xs font-mono text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||
签署人姓名
|
||
</label>
|
||
<input type="text" id="signerName"
|
||
class="w-full bg-transparent border-b-2 border-gray-200 dark:border-gray-700 py-2 text-xl font-medium text-gray-900 dark:text-gray-100 focus:border-gray-900 dark:focus:border-gray-100 outline-none transition-colors placeholder-gray-300 dark:placeholder-gray-600 rounded-none"
|
||
placeholder="请输入您的真实姓名">
|
||
</div>
|
||
<div class="font-mono text-xs text-gray-400 dark:text-gray-500 text-right w-full sm:w-auto">
|
||
<div id="currentDate">2026-01-15</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 签名区域容器 -->
|
||
<div class="w-full relative group">
|
||
|
||
<!-- 工具栏 (悬浮式/极简) -->
|
||
<div class="flex flex-wrap items-center justify-between gap-4 mb-4">
|
||
<div class="flex items-center gap-6 bg-white dark:bg-gray-800 px-4 py-2 rounded-full border border-gray-200 dark:border-gray-700 shadow-sm transition-colors duration-300">
|
||
<!-- 粗细 -->
|
||
<div class="flex items-center gap-2" title="笔触粗细">
|
||
<i class="ph ph-pencil-simple text-gray-400 text-sm"></i>
|
||
<input type="range" id="strokeWidth" min="1" max="8" value="2" class="w-16">
|
||
</div>
|
||
|
||
<div class="w-px h-3 bg-gray-200 dark:bg-gray-600"></div>
|
||
|
||
<!-- 颜色 (仅提供黑蓝红) -->
|
||
<div class="flex items-center gap-2">
|
||
<button class="color-btn w-4 h-4 rounded-full bg-black border border-gray-200 ring-offset-2 ring-gray-900 transition-all hover:scale-110 active-color" data-color="#000000"></button>
|
||
<button class="color-btn w-4 h-4 rounded-full bg-blue-600 border border-transparent ring-offset-2 ring-blue-600 transition-all hover:scale-110" data-color="#2563EB"></button>
|
||
<button class="color-btn w-4 h-4 rounded-full bg-red-600 border border-transparent ring-offset-2 ring-red-600 transition-all hover:scale-110" data-color="#DC2626"></button>
|
||
<input type="hidden" id="strokeColor" value="#000000">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-2">
|
||
<button id="undoBtn" class="p-2 text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-30 transition-colors" disabled title="撤销">
|
||
<i class="ph ph-arrow-u-up-left text-lg"></i>
|
||
</button>
|
||
<button id="redoBtn" class="p-2 text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-30 transition-colors" disabled title="重做">
|
||
<i class="ph ph-arrow-u-up-right text-lg"></i>
|
||
</button>
|
||
<button id="clearBtn" class="ml-2 px-3 py-1.5 text-xs font-medium text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md transition-colors">
|
||
清空
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 画布 (纸张隐喻) -->
|
||
<!-- 关键设计:即使在Dark Mode下,画布也是亮的(off-white),模拟桌面上的纸张,确保黑色笔迹可见且符合物理直觉 -->
|
||
<div class="relative w-full aspect-[16/9] sm:aspect-[2/1] bg-white rounded-lg shadow-paper hover:shadow-paper-hover dark:shadow-dark-glow transition-all duration-300 overflow-hidden border border-gray-200 dark:border-gray-700/50 dark:brightness-90">
|
||
<!-- 网格背景 (可选,增加专业感) -->
|
||
<div class="absolute inset-0 opacity-[0.03] pointer-events-none"
|
||
style="background-image: radial-gradient(#000 1px, transparent 1px); background-size: 20px 20px;">
|
||
</div>
|
||
|
||
<div id="watermark" class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none transition-opacity duration-300 select-none text-gray-300 dark:text-gray-200">
|
||
<span class="text-xs font-mono border border-dashed border-gray-300 px-3 py-1 rounded text-gray-400">在此区域签名</span>
|
||
</div>
|
||
|
||
<canvas id="signatureCanvas" class="signature-canvas relative z-10 mix-blend-multiply"></canvas>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- 底部操作栏 -->
|
||
<div class="w-full flex items-center justify-between pt-6 border-t border-gray-200 dark:border-gray-800">
|
||
<div class="flex items-center gap-2 text-xs text-gray-400">
|
||
<i class="ph ph-shield-check text-base"></i>
|
||
<span class="hidden sm:inline">已启用加密传输 & 安全存储</span>
|
||
</div>
|
||
|
||
<button id="saveToServerBtn" class="group relative px-6 py-2.5 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 text-sm font-medium rounded-lg shadow-lg hover:shadow-xl hover:-translate-y-0.5 transition-all duration-200 overflow-hidden disabled:opacity-70 disabled:cursor-not-allowed">
|
||
<span class="relative z-10 flex items-center gap-2">
|
||
<i class="ph ph-floppy-disk"></i>
|
||
确认归档
|
||
</span>
|
||
<!-- 按钮Hover光效 -->
|
||
<div class="absolute inset-0 bg-white/20 translate-y-full group-hover:translate-y-0 transition-transform duration-300"></div>
|
||
</button>
|
||
</div>
|
||
|
||
</main>
|
||
|
||
<!-- Toast 通知 -->
|
||
<div id="toast" class="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 transition-all duration-300 opacity-0 translate-y-4 pointer-events-none">
|
||
<div class="bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 px-4 py-3 rounded-lg shadow-2xl flex items-center gap-3 min-w-[300px] border border-gray-800 dark:border-gray-200">
|
||
<div id="toastIcon" class="text-lg"></div>
|
||
<div class="flex-1">
|
||
<h4 id="toastTitle" class="text-sm font-semibold"></h4>
|
||
<p id="toastMessage" class="text-xs opacity-80"></p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
// --- Dark Mode Logic ---
|
||
const themeToggle = document.getElementById('themeToggle');
|
||
const html = document.documentElement;
|
||
|
||
// 初始化:检查本地存储或系统偏好
|
||
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||
html.classList.add('dark');
|
||
} else {
|
||
html.classList.remove('dark');
|
||
}
|
||
|
||
themeToggle.addEventListener('click', () => {
|
||
html.classList.toggle('dark');
|
||
if (html.classList.contains('dark')) {
|
||
localStorage.theme = 'dark';
|
||
} else {
|
||
localStorage.theme = 'light';
|
||
}
|
||
});
|
||
|
||
// --- 基础设置 ---
|
||
const now = new Date();
|
||
document.getElementById('currentDate').textContent = now.toISOString().split('T')[0];
|
||
|
||
// --- Canvas & Core Logic ---
|
||
const canvas = document.getElementById('signatureCanvas');
|
||
const ctx = canvas.getContext('2d');
|
||
const watermark = document.getElementById('watermark');
|
||
const strokeWidth = document.getElementById('strokeWidth');
|
||
const strokeColorInput = document.getElementById('strokeColor');
|
||
const colorBtns = document.querySelectorAll('.color-btn');
|
||
const clearBtn = document.getElementById('clearBtn');
|
||
const saveBtn = document.getElementById('saveToServerBtn');
|
||
const undoBtn = document.getElementById('undoBtn');
|
||
const redoBtn = document.getElementById('redoBtn');
|
||
const signerNameInput = document.getElementById('signerName');
|
||
|
||
// Toast Refs
|
||
const toast = document.getElementById('toast');
|
||
const toastTitle = document.getElementById('toastTitle');
|
||
const toastMessage = document.getElementById('toastMessage');
|
||
const toastIcon = document.getElementById('toastIcon');
|
||
|
||
// State
|
||
let isDrawing = false;
|
||
let pathHistory = [];
|
||
let undoHistory = [];
|
||
|
||
// --- 颜色选择逻辑 ---
|
||
colorBtns.forEach(btn => {
|
||
btn.addEventListener('click', (e) => {
|
||
// 移除所有激活状态
|
||
colorBtns.forEach(b => {
|
||
b.classList.remove('ring-2');
|
||
// 移除 transform scale
|
||
b.style.transform = '';
|
||
});
|
||
|
||
// 激活当前
|
||
const color = e.target.dataset.color;
|
||
strokeColorInput.value = color;
|
||
e.target.classList.add('ring-2');
|
||
});
|
||
});
|
||
// 默认选中第一个
|
||
colorBtns[0].click();
|
||
|
||
// --- Canvas Resize ---
|
||
function resizeCanvas() {
|
||
const parent = canvas.parentElement;
|
||
const dpr = window.devicePixelRatio || 2; // 强制使用高DPI
|
||
const rect = parent.getBoundingClientRect();
|
||
|
||
canvas.width = rect.width * dpr;
|
||
canvas.height = rect.height * dpr;
|
||
|
||
ctx.scale(dpr, dpr);
|
||
canvas.style.width = `${rect.width}px`;
|
||
canvas.style.height = `${rect.height}px`;
|
||
|
||
redrawCanvas();
|
||
}
|
||
|
||
let resizeTimeout;
|
||
window.addEventListener('resize', () => {
|
||
clearTimeout(resizeTimeout);
|
||
resizeTimeout = setTimeout(resizeCanvas, 100);
|
||
});
|
||
setTimeout(resizeCanvas, 50);
|
||
|
||
// --- Drawing Logic ---
|
||
function getPoint(e) {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
|
||
return {
|
||
x: clientX - rect.left,
|
||
y: clientY - rect.top
|
||
};
|
||
}
|
||
|
||
// 贝塞尔曲线平滑处理 (让签名更好看)
|
||
// 这里简化为直线连接以保持性能,如果需要更平滑可以加二次贝塞尔插值
|
||
function startDrawing(e) {
|
||
if (e.cancelable) e.preventDefault();
|
||
isDrawing = true;
|
||
watermark.style.opacity = '0';
|
||
|
||
const point = getPoint(e);
|
||
|
||
const newPath = {
|
||
points: [point],
|
||
color: strokeColorInput.value,
|
||
width: parseInt(strokeWidth.value)
|
||
};
|
||
|
||
pathHistory.push(newPath);
|
||
undoHistory = [];
|
||
updateUI();
|
||
|
||
ctx.beginPath();
|
||
ctx.moveTo(point.x, point.y);
|
||
ctx.lineCap = 'round';
|
||
ctx.lineJoin = 'round';
|
||
ctx.strokeStyle = newPath.color;
|
||
ctx.lineWidth = newPath.width;
|
||
ctx.lineTo(point.x, point.y); // 画点
|
||
ctx.stroke();
|
||
}
|
||
|
||
function draw(e) {
|
||
if (!isDrawing) return;
|
||
if (e.cancelable) e.preventDefault();
|
||
|
||
const point = getPoint(e);
|
||
const currentPath = pathHistory[pathHistory.length - 1];
|
||
currentPath.points.push(point);
|
||
|
||
ctx.lineTo(point.x, point.y);
|
||
ctx.stroke();
|
||
}
|
||
|
||
function stopDrawing() {
|
||
if (isDrawing) {
|
||
isDrawing = false;
|
||
ctx.closePath();
|
||
}
|
||
}
|
||
|
||
function redrawCanvas() {
|
||
ctx.save();
|
||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
ctx.restore();
|
||
|
||
if (pathHistory.length === 0) {
|
||
watermark.style.opacity = '1';
|
||
return;
|
||
}
|
||
watermark.style.opacity = '0';
|
||
|
||
pathHistory.forEach(path => {
|
||
if (path.points.length < 1) return;
|
||
|
||
ctx.beginPath();
|
||
ctx.lineCap = 'round';
|
||
ctx.lineJoin = 'round';
|
||
ctx.strokeStyle = path.color;
|
||
ctx.lineWidth = path.width;
|
||
|
||
ctx.moveTo(path.points[0].x, path.points[0].y);
|
||
for (let i = 1; i < path.points.length; i++) {
|
||
ctx.lineTo(path.points[i].x, path.points[i].y);
|
||
}
|
||
ctx.stroke();
|
||
});
|
||
}
|
||
|
||
function updateUI() {
|
||
undoBtn.disabled = pathHistory.length === 0;
|
||
redoBtn.disabled = undoHistory.length === 0;
|
||
}
|
||
|
||
// --- Event Listeners ---
|
||
canvas.addEventListener('mousedown', startDrawing);
|
||
canvas.addEventListener('mousemove', draw);
|
||
window.addEventListener('mouseup', stopDrawing);
|
||
|
||
canvas.addEventListener('touchstart', startDrawing, { passive: false });
|
||
canvas.addEventListener('touchmove', draw, { passive: false });
|
||
canvas.addEventListener('touchend', stopDrawing);
|
||
|
||
clearBtn.addEventListener('click', () => {
|
||
pathHistory = [];
|
||
undoHistory = [];
|
||
redrawCanvas();
|
||
updateUI();
|
||
});
|
||
|
||
undoBtn.addEventListener('click', () => {
|
||
if (pathHistory.length > 0) {
|
||
undoHistory.push(pathHistory.pop());
|
||
redrawCanvas();
|
||
updateUI();
|
||
}
|
||
});
|
||
|
||
redoBtn.addEventListener('click', () => {
|
||
if (undoHistory.length > 0) {
|
||
pathHistory.push(undoHistory.pop());
|
||
redrawCanvas();
|
||
updateUI();
|
||
}
|
||
});
|
||
|
||
// --- Upload Logic ---
|
||
saveBtn.addEventListener('click', async () => {
|
||
const name = signerNameInput.value.trim();
|
||
|
||
if (!name) {
|
||
showToast('error', '信息缺失', '请在保存前输入您的姓名。');
|
||
signerNameInput.focus();
|
||
signerNameInput.classList.add('border-red-500');
|
||
setTimeout(() => signerNameInput.classList.remove('border-red-500'), 2000);
|
||
return;
|
||
}
|
||
|
||
if (pathHistory.length === 0) {
|
||
showToast('error', '画布为空', '请先进行签名。');
|
||
return;
|
||
}
|
||
|
||
const originalHTML = saveBtn.innerHTML;
|
||
saveBtn.disabled = true;
|
||
saveBtn.innerHTML = '<i class="ph ph-spinner animate-spin"></i> 处理中...';
|
||
|
||
try {
|
||
const dataURL = canvas.toDataURL('image/png');
|
||
|
||
const response = await fetch('/upload', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
name: name,
|
||
image: dataURL
|
||
})
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
showToast('success', '归档成功', '签名已安全保存至服务器。');
|
||
|
||
setTimeout(() => {
|
||
// 清空画布和输入
|
||
pathHistory = [];
|
||
undoHistory = [];
|
||
signerNameInput.value = '';
|
||
redrawCanvas();
|
||
updateUI();
|
||
}, 1200);
|
||
|
||
} else {
|
||
showToast('error', '上传失败', result.message);
|
||
}
|
||
} catch (error) {
|
||
console.error(error);
|
||
showToast('error', '连接错误', '无法连接到服务器,请检查网络。');
|
||
} finally {
|
||
saveBtn.disabled = false;
|
||
saveBtn.innerHTML = originalHTML;
|
||
}
|
||
});
|
||
|
||
let toastTimeout;
|
||
function showToast(type, title, message) {
|
||
if (toastTimeout) clearTimeout(toastTimeout);
|
||
|
||
const icons = {
|
||
success: '<i class="ph ph-check-circle text-green-400"></i>',
|
||
error: '<i class="ph ph-warning-circle text-red-400"></i>'
|
||
};
|
||
|
||
toastIcon.innerHTML = icons[type];
|
||
toastTitle.textContent = title;
|
||
toastMessage.textContent = message;
|
||
|
||
toast.classList.remove('translate-y-4', 'opacity-0', 'pointer-events-none');
|
||
|
||
toastTimeout = setTimeout(() => {
|
||
toast.classList.add('translate-y-4', 'opacity-0', 'pointer-events-none');
|
||
}, 3500);
|
||
}
|
||
});
|
||
</script>
|
||
</body>
|
||
</html> |