Files
xk-signature/index.html
2026-06-23 10:35:23 +08:00

499 lines
21 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>