feat: 系统优化Phase2 - 面包屑导航/侧边栏间距/制度公示阅读签收/模板变量中文化/通知类型补全

- 面包屑导航组件,集成至TopNav header
- 侧边栏菜单分组间距增大,分组间分隔线
- 制度公示员工阅读签收:PolicyReadRecord模型、portal路由、管理端阅读统计
- 修复Policies.tsx民主程序推进bug(字段名/API路径/参数)
- 用工文本模板变量名英文转中文显示
- 通知类型TYPE_LABELS补全(RISK_ALERT/SOCIAL_INS/OVERTIME_ALERT/PAYSLIP_READY)
- 通知示例数据补充
- h2标题统一为text-sm font-medium
- 新增run.md
This commit is contained in:
selfrelease
2026-07-26 20:32:38 +08:00
parent 9cb0d1f63b
commit d79e3baa34
71 changed files with 18561 additions and 3230 deletions
+146
View File
@@ -0,0 +1,146 @@
/**
* 验证码存储服务
* 支持两种模式:
* 1. Redis(生产环境,REDIS_URL 环境变量存在时自动启用)
* 2. 内存 Map(开发环境 fallback
*
* 功能:存储、验证、过期清理、失败次数限制
*/
interface CodeEntry {
code: string
expiresAt: number
failCount: number
lastSentAt: number
}
// 内存存储 fallback
const memoryStore = new Map<string, CodeEntry>()
// 是否启用 Redis
const REDIS_URL = process.env.REDIS_URL || ''
const useRedis = !!REDIS_URL
// Redis 客户端(懒加载)
let redisClient: any = null
async function getRedis() {
if (!redisClient && useRedis) {
try {
const { createClient } = await import('redis')
redisClient = createClient({ url: REDIS_URL })
redisClient.on('error', (err: any) => console.error('[Redis] error:', err))
await redisClient.connect()
console.log('[Redis] 验证码存储已连接')
} catch (err) {
console.warn('[Redis] 连接失败,降级为内存存储:', err)
return null
}
}
return redisClient
}
const KEY_PREFIX = 'vcode:'
const TTL_SECONDS = 300 // 5 分钟
/**
* 存储验证码
*/
export async function setCode(key: string, code: string, ttlMs: number = 5 * 60 * 1000): Promise<void> {
const entry: CodeEntry = {
code,
expiresAt: Date.now() + ttlMs,
failCount: 0,
lastSentAt: Date.now(),
}
const redis = await getRedis()
if (redis) {
try {
await redis.set(`${KEY_PREFIX}${key}`, JSON.stringify(entry), { PX: ttlMs })
return
} catch (err) {
console.warn('[Redis] set 失败,降级内存:', err)
}
}
memoryStore.set(key, entry)
}
/**
* 获取验证码条目
*/
export async function getCode(key: string): Promise<CodeEntry | null> {
const redis = await getRedis()
if (redis) {
try {
const raw = await redis.get(`${KEY_PREFIX}${key}`)
if (!raw) return null
const entry = JSON.parse(raw) as CodeEntry
if (entry.expiresAt < Date.now()) {
await redis.del(`${KEY_PREFIX}${key}`)
return null
}
return entry
} catch (err) {
console.warn('[Redis] get 失败,降级内存:', err)
}
}
const entry = memoryStore.get(key)
if (!entry) return null
if (entry.expiresAt < Date.now()) {
memoryStore.delete(key)
return null
}
return entry
}
/**
* 更新验证码条目(如递增失败次数)
*/
export async function updateCode(key: string, updates: Partial<CodeEntry>): Promise<void> {
const redis = await getRedis()
if (redis) {
try {
const raw = await redis.get(`${KEY_PREFIX}${key}`)
if (raw) {
const entry = { ...JSON.parse(raw), ...updates }
await redis.set(`${KEY_PREFIX}${key}`, JSON.stringify(entry), { PX: TTL_SECONDS * 1000 })
}
return
} catch (err) {
console.warn('[Redis] update 失败,降级内存:', err)
}
}
const entry = memoryStore.get(key)
if (entry) {
Object.assign(entry, updates)
}
}
/**
* 删除验证码
*/
export async function deleteCode(key: string): Promise<void> {
const redis = await getRedis()
if (redis) {
try {
await redis.del(`${KEY_PREFIX}${key}`)
return
} catch (err) {
console.warn('[Redis] del 失败,降级内存:', err)
}
}
memoryStore.delete(key)
}
/**
* 检查发送频率限制(60秒内不可重复发送)
*/
export async function checkRateLimit(key: string, intervalMs: number = 60 * 1000): Promise<boolean> {
const entry = await getCode(key)
if (entry && entry.lastSentAt && Date.now() - entry.lastSentAt < intervalMs) {
return false // 被限流
}
return true // 允许发送
}