模块概览
app
├── components
│ ├── AuthGuard.tsx
│ ├── InitProvider.tsx
│ └── Logo.tsx
├── globals.css
├── layout.tsx
├── lib
├── auth.ts
├── storage.ts
└── transform.ts
- 公用库( lib )
- auth
- hash
- storage
- 公用组件( components )
- AuthGuard
- Initialer
- Logo
1. 公用库设计
auth.ts
// app/lib/auth.ts
/**
* @file 钱包认证与账户管理核心模块
* @description 负责钱包连接、签名验证、登录状态持久化、登出等完整认证流程
* 支持 MetaMask、WalletConnect、Coinbase Wallet 等主流钱包
* @author Guangyang Zhong | github: https://github.com/cgcj0907
* @date 2025-08-16
*/
export const LOGIN_FLAG_KEY = 'isLoggedIn';
export const LOGIN_EXPIRES_KEY = 'loginExpiresAt';
const DEFAULT_EXPIRE_MINUTES = 10;
/**获取过期时间 */
export function getLoginExpiresAt(): number | null {}
/**检查登录状态是否合法 */
export function isLoggedInLocal(): boolean {}
/** 刷新过期时间(默认 +10 分钟)并确保 flag = '1' */
export function refreshLoginExpiry(minutes = DEFAULT_EXPIRE_MINUTES) {}
/** 退出登录(本地清理) */
export function logoutLocal() {}
transform.ts
// @/app/lib/hash.ts
/**
* @file 密码哈希与 ArrayBuffer/Base64 互转工具
* @description 提供安全的密码 SHA-256 哈希(返回 Base64)和高性能的二进制 ↔ Base64 转换工具
* @author Guangyang Zhong | github: https://github.com/cgcj0907
* @date 2025-11-27
*/
/**
* 将 ArrayBuffer 转换为 Base64 字符串
*
* 使用 Uint8Array + btoa 的经典写法,比 Buffer.toString('base64') 更兼容浏览器环境
*
* @param buffer 要转换的 ArrayBuffer
* @returns Base64 编码字符串
* @example
* const b64 = arrayBufferToBase64(hashBuffer);
*/
export function arrayBufferToBase64(buffer: ArrayBuffer): string {}
/**
* 将 Base64 字符串转换为 Uint8Array
*
* @param base64 Base64 编码的字符串
* @returns 对应的 Uint8Array
* @example
* const uint8 = base64ToUint8Array(storedHash);
*/
export function base64ToUint8Array(base64: string): Uint8Array {}
/**
* 对明文密码进行安全的 SHA-256 哈希,并返回 Base64 编码结果
*
* 使用 Web Crypto API(crypto.subtle),在所有现代浏览器中均为原生实现,安全且高性能。
*
* @param password 明文密码
* @returns SHA-256 哈希后的 Base64 字符串(固定 44 字符)
* @example
* const hashed = await hashPassword('mySecret123');
* // → "iOcJ9qP3b...=="
*/
export async function hashPassword(password: string): Promise<string> {}
storage.ts
👉 关于 IndexedDB 的使用方法请见:IndexedDB 浏览器数据库
// app/lib/storage.ts
/**
* @file IndexedDB 轻量封装工具(按需懒创建 objectStore)
* @description 提供类似 localStorage 的简洁 API,支持自动升级版本创建表,避免预设 schema
* @author Guangyang Zhong | github: https://github.com/cgcj0907
* @date 2025-11-27
*/
import { openDB, IDBPDatabase } from 'idb';
const DB_NAME = '0907wallet-db';
const DEFAULT_STORE_NAME = 'users';
/**
* 初始化(或打开)IndexedDB 数据库
*
* 数据库不存在时会自动创建版本 1,且不预创建任何 objectStore,
* 所有表都通过 ensureStore 按需懒创建,保持最小的初始 schema。
*
* @returns IndexedDB 数据库实例
* @example
* const db = await initDB();
*/
export async function initDB(): Promise<IDBPDatabase> {}
/**
* 确保指定名称的 objectStore 存在(内部私有函数)
*
* 如果表不存在,会关闭当前连接 → 版本 +1 → 在 upgrade 事务中创建表。
* 这是 IndexedDB 的硬性限制:只有 versionchange 事务才能创建/删除 objectStore。
*
* @param db 当前数据库实例
* @param storeName 要确保存在的表名
* @returns 包含该表的最新数据库实例
*/
async function ensureObjectStoreExists(
db: IDBPDatabase,
storeName: string,
): Promise<IDBPDatabase> {}
/**
* 根据 key 从指定表中读取数据
*
* @param key 键名
* @param table 表名,默认为 'users'
* @returns 对应的值,若不存在返回 undefined
* @example
* const user = await get('currentUser');
*/
export async function get<T = unknown>(
key: string,
table: string = DEFAULT_STORE_NAME,
): Promise<T | undefined> {}
/**
* 向指定表中写入或更新数据
*
* @param key 键名
* @param value 要存储的值(必须支持结构化克隆)
* @param table 表名,默认为 'users'
* @example
* await set('currentUser', { address: '0x...', name: 'Alice' });
*/
export async function set<T = unknown>(
key: string,
value: T,
table: string = DEFAULT_STORE_NAME,
): Promise<void> {}
/**
* 根据 key 删除指定表中的记录
*
* @param key 键名
* @param table 表名,默认为 'users'
*/
export async function del(
key: string,
table: string = DEFAULT_STORE_NAME,
): Promise<void> {}
/**
* 清空指定表的所有记录
*
* @param table 表名,默认为 'users'
*/
export async function clear(table: string = DEFAULT_STORE_NAME): Promise<void> {}
/**
* 获取指定表中所有 key
*
* @param table 表名,默认为 'users'
* @returns key 数组
*/
export async function keys(
table: string = DEFAULT_STORE_NAME,
): Promise<IDBValidKey[]> {}
/**
* 获取指定表中所有值(不包含 key)
*
* @param table 表名,默认为 'users'
* @returns 值数组
*/
export async function values<T = unknown>(
table: string = DEFAULT_STORE_NAME,
): Promise<T[]> {}
/**
* 获取指定表的记录总数
*
* @param table 表名,默认为 'users'
* @returns 记录数量
*/
export async function count(table: string = DEFAULT_STORE_NAME): Promise<number> {}
2. 公用组件设计
AuthGard.tsx
// app/components/AuthGuard.tsx
/**
* @file 全局路由权限守卫(客户端组件)
* @description
* 1. 未登录 → 强制跳转 /userLogin(公开页除外)
* 2. 已登录但无钱包 → 强制跳转 /generateWallet
* 3. 全部通过 → 放行并自动刷新登录有效期
* @author Guangyang Zhong | github: https://github.com/cgcj0907
* @date 2025-11-27
*/
'use client';
import React, { useEffect, useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { count } from '@/app/lib/storage';
import * as auth from '@/app/lib/auth';
interface AuthGuardProps {
/** 要渲染的子内容(受保护的页面) */
children: React.ReactNode;
}
// 公开路径:无需登录即可访问
const PUBLIC_PATHS = ['/userLogin', '/generateWallet'] as const;
// 钱包存储表名(避免魔法值)
const WALLETS_STORE_NAME = 'Wallets';
export default function AuthGuard({ children }: AuthGuardProps): React.ReactElement {
const router = useRouter();
const pathname = usePathname();
const [isChecking, setIsChecking] = useState<boolean>(true);
useEffect(() => {
let isMounted = true;
/**
* 核心鉴权逻辑
* 为什么放在 useEffect 里单独执行?
* - 避免阻塞渲染
* - 需要 async/await 必须封装在函数内
* - 配合 isMounted 防止内存泄漏
*/
async function performAuthCheck(): Promise<void> {
try {
// pathname 可能为 null(Next.js 某些边缘情况)
if (!pathname) {
if (isMounted) setIsChecking(false);
return;
}
const isPublicPath = PUBLIC_PATHS.includes(pathname as any);
// Step 1: 检查本地登录态(同步快速)
const isLoggedIn = auth.isLoggedInLocal();
if (!isLoggedIn && !isPublicPath) {
router.replace('/userLogin');
if (isMounted) setIsChecking(false);
return;
}
// Step 2: 已登录 → 检查是否已创建钱包
if (isLoggedIn) {
const walletCount = await count(WALLETS_STORE_NAME);
// count 返回 number,0 表示无钱包
const hasWallet = walletCount > 0;
if (!hasWallet && pathname !== '/generateWallet') {
router.replace('/generateWallet');
if (isMounted) setIsChecking(false);
return;
}
}
// Step 3: 全部通过 → 刷新登录有效期并放行
if (isMounted) {
auth.refreshLoginExpiry(); // 延长本地登录态过期时间
setIsChecking(false);
}
} catch (error) {
// 为什么 catch 后直接跳转登录?
// IndexedDB 异常、本地存储损坏等都视为“不安全状态”,必须重新登录
console.error('[AuthGuard] 鉴权过程中发生异常:', error);
if (isMounted) {
setIsChecking(false);
router.replace('/userLogin');
}
}
}
performAuthCheck();
// 清理函数
return () => {
isMounted = false;
};
}, [pathname, router]);
// Loading 状态:全屏居中动效
if (isChecking) {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-50">
<p className="text-gray-600 text-lg mb-4 animate-pulse">
正在验证授权...
</p>
<div className="w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
</div>
);
}
// 鉴权通过:渲染子内容
return <>{children}</>;
}
InitProvider.tsx
'use client'
import { useState } from 'react';
import clsx from 'clsx';
/**
* @file Logo 组件(首页/导航栏主 Logo)
* @description 带悬停交互的圆形 Logo,鼠标悬停时向上浮起并展示文字说明,支持缓慢弹跳动画
* @author Guangyang Zhong | github: https://github.com/cgcj0907
* @date 2025-11-27
*/
/**
* 主 Logo 组件
* - 默认状态:Logo 向下位移一半,遮挡下方文字区域(视觉上形成“半隐藏”效果)
* - 悬停状态:Logo 上移至正常位置,同时下方文字卡片从 0 高度展开
* - 使用 z-[-1]/z-[-2] 保证图片始终在文字卡片上方,但整体被外部容器裁剪
*/
export default function Logo() {
// hover 状态控制整个交互动画的开启与关闭
const [hover, setHover] = useState(false);
return (
// 外层容器:相对定位,用于控制子元素的绝对/固定定位基准,同时垂直居中排列
<div
className="relative flex flex-col items-center"
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
>
{/* 图片 Logo(主视觉元素) */}
<div
className={clsx(
// 负 z-index 让图片在文字卡片之下,但通过 translate-y 制造“浮在前面”的视觉错位
// w-86/h-86 ≈ 344px(Tailwind 自定义单位),圆形裁剪 + 溢出隐藏 + 阴影 + 平滑位移动画
"z-[-1] w-86 h-86 rounded-full overflow-hidden cursor-pointer shadow-lg transition-transform duration-300",
// 未悬停时向下位移 48(192px),刚好遮住下方文字卡片一半,形成悬念感
// 悬停时恢复原位,视觉上像“Logo 向上浮起”
hover ? "translate-y-0" : "translate-y-48"
)}
>
{/* Logo 图片自适应铺满容器,保持原始比例不被拉伸 */}
<img
src="/logo.png"
alt="Logo"
className="w-full h-full object-cover"
/>
</div>
{/* 文字说明卡片区域(默认完全收起,悬停时通过 max-h 展开) */}
<div
className={clsx(
// 使用 overflow-hidden + max-h 实现平滑的展开/收起动画
// mt-2 给卡片留出一点上边距,避免紧贴 Logo
"z-[-2] overflow-hidden transition-all duration-300 mt-2",
hover ? "max-h-40" : "max-h-0"
)}
>
{/* 文字卡片本体:圆角背景 + 内边距 + 固定宽度 + 文字居中 + 缓慢弹跳动画 */}
<div className="rounded-2xl p-3 w-40 text-center flex flex-col items-center gap-1 animate-bounce-slow">
{/* 主标题 */}
<p className="text-sm font-semibold text-blue-500 flex items-center justify-center gap-1">
0907 Wallet
</p>
{/* 副标题(技术支持声明) */}
<p className="text-xs text-blue-300">Supported by SST</p>
</div>
</div>
</div>
);
}