Files
xk-signature/index.html
2026-06-23 13:38:03 +08:00

1242 lines
53 KiB
HTML
Raw Permalink 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>
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Phosphor Icons 图标库 -->
<script src="https://unpkg.com/@phosphor-icons/web"></script>
<script>
// Tailwind 配置
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',
dark: '#fafafa',
}
},
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;
}
/* 自定义滑块样式 */
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;
}
/* 视图切换动画 */
.view-hidden {
display: none !important;
}
}
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">
<!-- 左侧Logo 和标题 -->
<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">
<!-- 签名列表按钮(登录后显示) -->
<button id="navToList" class="hidden items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors">
<i class="ph ph-list text-base"></i>
<span class="hidden sm:inline">签名列表</span>
</button>
<!-- 返回签名按钮(列表页面显示) -->
<button id="navToSign" class="hidden items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors">
<i class="ph ph-pencil-simple text-base"></i>
<span class="hidden sm:inline">签名</span>
</button>
<!-- 登录/注销按钮 -->
<button id="authBtn" class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors">
<i class="ph ph-sign-in text-base" id="authIcon"></i>
<span id="authText">登录</span>
</button>
<!-- 主题切换按钮 -->
<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>
<!-- ================================================================== -->
<!-- 登录视图 -->
<!-- ================================================================== -->
<div id="loginView" class="view-hidden">
<main class="flex-grow flex items-center justify-center pt-14 pb-12 px-4">
<div class="w-full max-w-sm">
<!-- 登录卡片 -->
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 p-8">
<!-- 标题 -->
<div class="text-center mb-8">
<div class="w-12 h-12 bg-gray-100 dark:bg-gray-700 rounded-full flex items-center justify-center mx-auto mb-4">
<i class="ph ph-lock-key text-xl text-gray-600 dark:text-gray-400"></i>
</div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">登录系统</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">请输入账号密码以访问签名列表</p>
</div>
<!-- 登录表单 -->
<form id="loginForm" class="space-y-5">
<!-- 账号输入框 -->
<div>
<label for="loginAccount" class="block text-xs font-mono text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
账号
</label>
<input type="text" id="loginAccount"
class="w-full bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-2.5 text-sm text-gray-900 dark:text-gray-100 focus:border-gray-900 dark:focus:border-gray-100 outline-none transition-colors placeholder-gray-400 dark:placeholder-gray-500"
placeholder="请输入账号"
autocomplete="username">
</div>
<!-- 密码输入框 -->
<div>
<label for="loginPassword" class="block text-xs font-mono text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
密码
</label>
<input type="password" id="loginPassword"
class="w-full bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-2.5 text-sm text-gray-900 dark:text-gray-100 focus:border-gray-900 dark:focus:border-gray-100 outline-none transition-colors placeholder-gray-400 dark:placeholder-gray-500"
placeholder="请输入密码"
autocomplete="current-password">
</div>
<!-- 错误提示 -->
<div id="loginError" class="hidden text-sm text-red-500 dark:text-red-400 bg-red-50 dark:bg-red-900/20 px-4 py-2 rounded-lg">
<span id="loginErrorText"></span>
</div>
<!-- 登录按钮 -->
<button type="submit" id="loginSubmitBtn"
class="w-full 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 flex items-center justify-center gap-2">
<i class="ph ph-sign-in"></i>
<span>登录</span>
</button>
</form>
</div>
</div>
</main>
</div>
<!-- ================================================================== -->
<!-- 签名视图 -->
<!-- ================================================================== -->
<div id="signView">
<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>
<!-- 画布区域 -->
<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>
<div class="absolute inset-0 bg-white/20 translate-y-full group-hover:translate-y-0 transition-transform duration-300"></div>
</button>
</div>
</main>
</div>
<!-- ================================================================== -->
<!-- 签名列表视图 -->
<!-- ================================================================== -->
<div id="listView" class="view-hidden">
<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-6">
<!-- 页面标题 -->
<div class="w-full flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">签名列表</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1"><span id="signatureCount">0</span> 条签名记录</p>
</div>
<button id="refreshListBtn" class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors">
<i class="ph ph-arrow-clockwise text-base"></i>
<span>刷新</span>
</button>
</div>
<!-- 签名卡片容器 -->
<div id="signatureList" class="w-full space-y-4">
<!-- 动态生成签名卡片 -->
</div>
<!-- 空状态 -->
<div id="emptyState" class="hidden w-full flex flex-col items-center justify-center py-16 text-center">
<div class="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mb-4">
<i class="ph ph-file-dashed text-2xl text-gray-400 dark:text-gray-500"></i>
</div>
<p class="text-gray-500 dark:text-gray-400 text-sm">暂无签名记录</p>
<p class="text-gray-400 dark:text-gray-500 text-xs mt-1">完成签名后将显示在此处</p>
</div>
<!-- 加载状态 -->
<div id="loadingState" class="hidden w-full flex flex-col items-center justify-center py-16">
<i class="ph ph-spinner animate-spin text-2xl text-gray-400"></i>
<p class="text-gray-500 dark:text-gray-400 text-sm mt-4">加载中...</p>
</div>
</main>
</div>
<!-- ================================================================== -->
<!-- 姓名填写模态框 -->
<!-- ================================================================== -->
<div id="nameModal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/80 backdrop-blur-sm">
<div class="relative w-full max-w-sm mx-4">
<div class="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl overflow-hidden">
<!-- 头部 -->
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">请输入签署人姓名</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">归档前需要填写签署人姓名</p>
</div>
<!-- 输入框 -->
<div class="px-6 py-4">
<input type="text" id="modalSignerName"
class="w-full bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-3 text-lg font-medium text-gray-900 dark:text-gray-100 focus:border-gray-900 dark:focus:border-gray-100 outline-none transition-colors placeholder-gray-400 dark:placeholder-gray-500"
placeholder="请输入姓名">
</div>
<!-- 按钮 -->
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-end gap-3">
<button id="cancelNameBtn" class="px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors">
取消
</button>
<button id="confirmNameBtn" class="px-6 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 text-sm font-medium rounded-lg shadow-lg hover:shadow-xl transition-all duration-200">
确认归档
</button>
</div>
</div>
</div>
</div>
<!-- ================================================================== -->
<!-- 签名预览模态框 -->
<!-- ================================================================== -->
<div id="previewModal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/80 backdrop-blur-sm">
<div class="relative max-w-4xl w-full mx-4">
<!-- 关闭按钮 -->
<button id="closePreviewBtn" class="absolute -top-12 right-0 p-2 text-white/70 hover:text-white transition-colors">
<i class="ph ph-x text-2xl"></i>
</button>
<!-- 图片容器 -->
<div class="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl overflow-hidden">
<div id="previewHeader" class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 id="previewName" class="text-lg font-semibold text-gray-900 dark:text-gray-100"></h3>
<p id="previewDate" class="text-sm text-gray-500 dark:text-gray-400 mt-1"></p>
</div>
<div class="p-4">
<img id="previewImage" src="" alt="签名预览" class="w-full max-h-[70vh] object-contain bg-gray-50 dark:bg-gray-800 rounded-lg">
</div>
</div>
</div>
</div>
<!-- ================================================================== -->
<!-- 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>
<!-- ================================================================== -->
<!-- JavaScript -->
<!-- ================================================================== -->
<script>
document.addEventListener('DOMContentLoaded', function () {
// ==================================================================
// 全局状态
// ==================================================================
let authToken = localStorage.getItem('authToken') || ''; // 认证令牌
let currentView = 'sign'; // 当前视图:'login' | 'sign' | 'list'
// ==================================================================
// DOM 元素引用
// ==================================================================
const html = document.documentElement;
const themeToggle = document.getElementById('themeToggle');
// 视图容器
const loginView = document.getElementById('loginView');
const signView = document.getElementById('signView');
const listView = document.getElementById('listView');
// 导航按钮
const navToList = document.getElementById('navToList');
const navToSign = document.getElementById('navToSign');
const authBtn = document.getElementById('authBtn');
const authIcon = document.getElementById('authIcon');
const authText = document.getElementById('authText');
// 登录表单
const loginForm = document.getElementById('loginForm');
const loginAccount = document.getElementById('loginAccount');
const loginPassword = document.getElementById('loginPassword');
const loginError = document.getElementById('loginError');
const loginErrorText = document.getElementById('loginErrorText');
const loginSubmitBtn = document.getElementById('loginSubmitBtn');
// 姓名填写模态框
const nameModal = document.getElementById('nameModal');
const modalSignerName = document.getElementById('modalSignerName');
const cancelNameBtn = document.getElementById('cancelNameBtn');
const confirmNameBtn = document.getElementById('confirmNameBtn');
// 签名画布
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');
// 签名列表
const signatureList = document.getElementById('signatureList');
const signatureCount = document.getElementById('signatureCount');
const emptyState = document.getElementById('emptyState');
const loadingState = document.getElementById('loadingState');
const refreshListBtn = document.getElementById('refreshListBtn');
// Toast 提示
const toast = document.getElementById('toast');
const toastTitle = document.getElementById('toastTitle');
const toastMessage = document.getElementById('toastMessage');
const toastIcon = document.getElementById('toastIcon');
// 签名状态
let isDrawing = false;
let pathHistory = [];
let undoHistory = [];
// ==================================================================
// 主题切换
// ==================================================================
// 初始化主题
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');
localStorage.theme = html.classList.contains('dark') ? 'dark' : 'light';
});
// ==================================================================
// 日期显示
// ==================================================================
document.getElementById('currentDate').textContent = new Date().toISOString().split('T')[0];
// ==================================================================
// 视图切换
// ==================================================================
/**
* 切换显示的视图
* @param {string} view - 要显示的视图名称:'login' | 'sign' | 'list'
*/
function switchView(view) {
currentView = view;
// 隐藏所有视图
loginView.classList.add('view-hidden');
signView.classList.add('view-hidden');
listView.classList.add('view-hidden');
// 显示目标视图
switch (view) {
case 'login':
loginView.classList.remove('view-hidden');
break;
case 'sign':
signView.classList.remove('view-hidden');
break;
case 'list':
listView.classList.remove('view-hidden');
break;
}
// 更新导航按钮显示状态
updateNavButtons();
}
/**
* 更新导航按钮的显示状态
*/
function updateNavButtons() {
const isLoggedIn = authToken !== '';
// 签名列表按钮始终可见
navToList.classList.remove('hidden');
navToList.classList.add('flex');
// 更新登录/注销按钮状态
if (isLoggedIn) {
authText.textContent = '注销';
authIcon.classList.remove('ph-sign-in');
authIcon.classList.add('ph-sign-out');
} else {
authText.textContent = '登录';
authIcon.classList.remove('ph-sign-out');
authIcon.classList.add('ph-sign-in');
}
// 列表页面显示返回按钮
if (currentView === 'list') {
navToSign.classList.remove('hidden');
navToSign.classList.add('flex');
} else {
navToSign.classList.add('hidden');
navToSign.classList.remove('flex');
}
}
// ==================================================================
// 登录/注销
// ==================================================================
/**
* 显示登录错误信息
* @param {string} message - 错误信息
*/
function showLoginError(message) {
loginErrorText.textContent = message;
loginError.classList.remove('hidden');
}
/**
* 隐藏登录错误信息
*/
function hideLoginError() {
loginError.classList.add('hidden');
}
/**
* 处理登录表单提交
*/
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideLoginError();
const account = loginAccount.value.trim();
const password = loginPassword.value.trim();
// 验证输入
if (!account || !password) {
showLoginError('请输入账号和密码');
return;
}
// 禁用按钮,显示加载状态
loginSubmitBtn.disabled = true;
loginSubmitBtn.innerHTML = '<i class="ph ph-spinner animate-spin"></i> 登录中...';
try {
// 发送登录请求
const response = await fetch('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account, password })
});
const result = await response.json();
if (result.success) {
// 登录成功,保存令牌
authToken = result.token;
localStorage.setItem('authToken', authToken);
showToast('success', '登录成功', '欢迎回来!');
// 登录成功后跳转到签名列表
switchView('list');
loadSignatureList();
} else {
// 登录失败
showLoginError(result.message || '账号或密码错误');
}
} catch (error) {
console.error('登录失败:', error);
showLoginError('网络错误,请检查连接');
} finally {
// 恢复按钮状态
loginSubmitBtn.disabled = false;
loginSubmitBtn.innerHTML = '<i class="ph ph-sign-in"></i> <span>登录</span>';
}
});
/**
* 处理注销
*/
function handleLogout() {
authToken = '';
localStorage.removeItem('authToken');
showToast('success', '已注销', '您已安全退出登录');
// 注销后回到签名页面
switchView('sign');
}
/**
* 验证当前令牌是否有效
* @returns {Promise<boolean>} 令牌是否有效
*/
async function checkAuth() {
if (!authToken) return false;
try {
const response = await fetch('/api/check-auth', {
headers: { 'Authorization': `Bearer ${authToken}` }
});
const result = await response.json();
if (!result.authenticated) {
// 令牌无效,清除本地存储
authToken = '';
localStorage.removeItem('authToken');
}
return result.authenticated;
} catch (error) {
console.error('验证登录状态失败:', error);
return false;
}
}
// ==================================================================
// 导航按钮事件
// ==================================================================
// 登录/注销按钮
authBtn.addEventListener('click', async () => {
if (authToken) {
// 已登录,执行注销
handleLogout();
} else {
// 未登录,切换到登录视图
switchView('login');
}
});
// 签名列表按钮 - 点击时检查登录状态
navToList.addEventListener('click', async () => {
if (!authToken) {
// 未登录,跳转到登录页
switchView('login');
showToast('error', '需要登录', '请先登录后再查看签名列表');
return;
}
// 已登录,验证令牌有效性
const isAuth = await checkAuth();
if (isAuth) {
switchView('list');
loadSignatureList();
} else {
// 令牌无效,跳转登录页
switchView('login');
showToast('error', '登录已过期', '请重新登录');
}
});
// 返回签名按钮
navToSign.addEventListener('click', () => {
switchView('sign');
});
// 刷新列表按钮
refreshListBtn.addEventListener('click', () => {
loadSignatureList();
});
// ==================================================================
// 签名画布逻辑
// ==================================================================
/**
* 调整画布大小以适配容器
*/
function resizeCanvas() {
const parent = canvas.parentElement;
const dpr = window.devicePixelRatio || 2;
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);
/**
* 获取触摸/鼠标坐标
*/
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 = [];
updateCanvasUI();
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();
});
}
/**
* 更新画布 UI撤销/重做按钮状态)
*/
function updateCanvasUI() {
undoBtn.disabled = pathHistory.length === 0;
redoBtn.disabled = undoHistory.length === 0;
}
// 画布事件监听
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();
updateCanvasUI();
});
// 撤销
undoBtn.addEventListener('click', () => {
if (pathHistory.length > 0) {
undoHistory.push(pathHistory.pop());
redrawCanvas();
updateCanvasUI();
}
});
// 重做
redoBtn.addEventListener('click', () => {
if (undoHistory.length > 0) {
pathHistory.push(undoHistory.pop());
redrawCanvas();
updateCanvasUI();
}
});
// ==================================================================
// 颜色选择
// ==================================================================
colorBtns.forEach(btn => {
btn.addEventListener('click', (e) => {
colorBtns.forEach(b => {
b.classList.remove('ring-2');
b.style.transform = '';
});
strokeColorInput.value = e.target.dataset.color;
e.target.classList.add('ring-2');
});
});
colorBtns[0].click();
// ==================================================================
// 上传签名
// ==================================================================
saveBtn.addEventListener('click', async () => {
// 验证签名
if (pathHistory.length === 0) {
showToast('error', '画布为空', '请先进行签名。');
return;
}
const name = signerNameInput.value.trim();
// 名字为空,弹出模态框让用户填写
if (!name) {
openNameModal();
return;
}
// 执行上传
await uploadSignature(name);
});
/**
* 打开姓名填写模态框
*/
function openNameModal() {
modalSignerName.value = '';
nameModal.classList.remove('hidden');
nameModal.classList.add('flex');
document.body.style.overflow = 'hidden';
setTimeout(() => modalSignerName.focus(), 100);
}
/**
* 关闭姓名填写模态框
*/
function closeNameModal() {
nameModal.classList.add('hidden');
nameModal.classList.remove('flex');
document.body.style.overflow = '';
}
// 取消按钮事件
cancelNameBtn.addEventListener('click', closeNameModal);
// 点击背景关闭
nameModal.addEventListener('click', (e) => {
if (e.target === nameModal) {
closeNameModal();
}
});
// 确认归档按钮事件
confirmNameBtn.addEventListener('click', async () => {
const name = modalSignerName.value.trim();
if (!name) {
modalSignerName.classList.add('border-red-500');
setTimeout(() => modalSignerName.classList.remove('border-red-500'), 2000);
return;
}
signerNameInput.value = name;
closeNameModal();
await uploadSignature(name);
});
// 回车确认
modalSignerName.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
confirmNameBtn.click();
}
});
// ESC 关闭
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !nameModal.classList.contains('hidden')) {
closeNameModal();
}
});
/**
* 执行签名上传
* @param {string} name - 签署人姓名
*/
async function uploadSignature(name) {
// 禁用按钮,显示加载状态
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, image: dataURL })
});
const result = await response.json();
if (result.success) {
showToast('success', '归档成功', '签名已安全保存至服务器。');
// 清空画布
setTimeout(() => {
pathHistory = [];
undoHistory = [];
signerNameInput.value = '';
redrawCanvas();
updateCanvasUI();
}, 1200);
} else {
showToast('error', '上传失败', result.message);
}
} catch (error) {
console.error(error);
showToast('error', '连接错误', '无法连接到服务器,请检查网络。');
} finally {
saveBtn.disabled = false;
saveBtn.innerHTML = originalHTML;
}
}
// ==================================================================
// 签名列表
// ==================================================================
/**
* 加载签名列表
*/
async function loadSignatureList() {
// 验证登录状态
if (!authToken) {
switchView('login');
return;
}
// 显示加载状态
signatureList.innerHTML = '';
emptyState.classList.add('hidden');
loadingState.classList.remove('hidden');
try {
const response = await fetch('/api/signatures', {
headers: { 'Authorization': `Bearer ${authToken}` }
});
// 检查是否需要重新登录
if (response.status === 401) {
authToken = '';
localStorage.removeItem('authToken');
switchView('login');
showToast('error', '登录已过期', '请重新登录');
return;
}
const result = await response.json();
if (result.success) {
renderSignatureList(result.signatures);
} else {
showToast('error', '加载失败', result.message);
}
} catch (error) {
console.error('加载签名列表失败:', error);
showToast('error', '网络错误', '无法连接到服务器');
} finally {
loadingState.classList.add('hidden');
}
}
/**
* 渲染签名列表
* @param {Array} signatures - 签名数据数组
*/
function renderSignatureList(signatures) {
signatureCount.textContent = signatures.length;
if (signatures.length === 0) {
signatureList.innerHTML = '';
emptyState.classList.remove('hidden');
return;
}
emptyState.classList.add('hidden');
// 生成签名卡片 HTML
signatureList.innerHTML = signatures.map(sig => `
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 hover:shadow-lg transition-shadow duration-200">
<div class="flex items-center justify-between">
<!-- 左侧:签名信息 -->
<div class="flex items-center gap-4">
<!-- 签名预览按钮(点击弹出大图) -->
<button class="preview-btn w-12 h-12 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden hover:ring-2 hover:ring-gray-400 dark:hover:ring-gray-500 transition-all cursor-pointer"
data-path="${escapeHtml(sig.path)}" data-name="${escapeHtml(sig.name)}" data-date="${escapeHtml(sig.date)}">
<img src="/download?path=${encodeURIComponent(sig.path)}&token=${encodeURIComponent(authToken)}"
alt="签名预览"
class="w-full h-full object-contain pointer-events-none"
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">
<div class="w-full h-full items-center justify-center hidden pointer-events-none">
<i class="ph ph-user text-xl text-gray-500 dark:text-gray-400"></i>
</div>
</button>
<!-- 签名详情 -->
<div class="min-w-0">
<h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">${escapeHtml(sig.name)}</h3>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">${escapeHtml(sig.date)}</p>
<p class="text-xs text-gray-400 dark:text-gray-500 truncate mt-0.5" title="${escapeHtml(sig.path)}">${formatFileSize(sig.size)}</p>
</div>
</div>
<!-- 右侧:下载按钮 -->
<button class="download-btn flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors flex-shrink-0"
data-path="${escapeHtml(sig.path)}" data-name="${escapeHtml(sig.name)}_${escapeHtml(sig.date)}.png">
<i class="ph ph-download text-base"></i>
<span class="hidden sm:inline">下载</span>
</button>
</div>
</div>
`).join('');
// 绑定下载按钮事件
document.querySelectorAll('.download-btn').forEach(btn => {
btn.addEventListener('click', () => {
const path = btn.dataset.path;
const name = btn.dataset.name;
downloadSignature(path, name);
});
});
// 绑定预览按钮事件
document.querySelectorAll('.preview-btn').forEach(btn => {
btn.addEventListener('click', () => {
const path = btn.dataset.path;
const name = btn.dataset.name;
const date = btn.dataset.date;
openPreviewModal(path, name, date);
});
});
}
/**
* 下载签名文件
* @param {string} filePath - 文件路径
* @param {string} fileName - 下载时的文件名
*/
function downloadSignature(filePath, fileName) {
const url = `/download?path=${encodeURIComponent(filePath)}&token=${encodeURIComponent(authToken)}`;
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
// ==================================================================
// 签名预览模态框
// ==================================================================
const previewModal = document.getElementById('previewModal');
const previewImage = document.getElementById('previewImage');
const previewName = document.getElementById('previewName');
const previewDate = document.getElementById('previewDate');
/**
* 打开预览模态框
* @param {string} path - 签名文件路径
* @param {string} name - 签署人姓名
* @param {string} date - 签名日期
*/
function openPreviewModal(path, name, date) {
previewImage.src = `/download?path=${encodeURIComponent(path)}&token=${encodeURIComponent(authToken)}`;
previewName.textContent = name;
previewDate.textContent = date;
previewModal.classList.remove('hidden');
previewModal.classList.add('flex');
document.body.style.overflow = 'hidden'; // 禁止背景滚动
}
/**
* 关闭预览模态框
*/
function closePreviewModal() {
previewModal.classList.add('hidden');
previewModal.classList.remove('flex');
document.body.style.overflow = ''; // 恢复背景滚动
}
// 点击背景关闭模态框
previewModal.addEventListener('click', (e) => {
if (e.target === previewModal) {
closePreviewModal();
}
});
// 关闭按钮事件
document.getElementById('closePreviewBtn').addEventListener('click', closePreviewModal);
// ESC 键关闭模态框
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !previewModal.classList.contains('hidden')) {
closePreviewModal();
}
});
/**
* HTML 转义(防止 XSS
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* 格式化文件大小
*/
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
// ==================================================================
// Toast 通知
// ==================================================================
let toastTimeout;
/**
* 显示 Toast 提示
* @param {string} type - 类型:'success' | 'error'
* @param {string} title - 标题
* @param {string} message - 内容
*/
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);
}
// ==================================================================
// 初始化
// ==================================================================
function init() {
// 默认进入签名页面(无需登录)
switchView('sign');
}
// 启动应用
init();
});
</script>
</body>
</html>