纯前端实现项目过期

项目过期逻辑

延长方法:修改 EXPIRY_DATE 和 BUILD_DATE 和 EXPIRY_VERSION

/**
 * 项目有效期检查工具
 * 设置项目在指定日期后无法访问
 *
 * 注意:此方案通过多重验证增加绕过难度,但无法100%防止修改系统时间
 * 在纯前端无网络环境下,所有验证最终都依赖系统时间
 */

// 设置过期日期(延长到2025年12月31日)
const EXPIRY_DATE = new Date('2025-10-30 23:59:59')
const EXPIRY_TIMESTAMP = EXPIRY_DATE.getTime()

// 项目部署/编译时间 - 当前时间不应该早于这个时间(根据实际情况修改)
// 注意:这个日期应该设置为项目实际构建的日期,不是过期日期!
const BUILD_DATE = new Date('2025-10-14 00:00:00')
const BUILD_TIMESTAMP = BUILD_DATE.getTime()

// 过期配置版本号 - 每次修改过期日期时递增,用于自动清除旧的锁定标记
const EXPIRY_VERSION = '1' // 从'1'改为'2'表示这是新的过期配置

// LocalStorage 存储键(使用隐蔽的键名)
const STORAGE_KEYS = {
  FIRST_ACCESS: '_app_ft_',      // 首次访问时间
  LAST_ACCESS: '_app_lt_',       // 最后访问时间
  MAX_TIMESTAMP: '_app_mt_',     // 历史最大时间戳
  ACCESS_COUNT: '_app_ac_',      // 访问次数
  VERIFICATION_HASH: '_app_vh_', // 验证哈希
  EXPIRED_LOCK: '_app_el_',      // 过期锁定标记(一旦过期就永久锁定)
  EXPIRY_VERSION: '_app_ev_',    // 过期配置版本号
}

// SessionStorage 备份键(防止清除localStorage)
const SESSION_KEYS = {
  BACKUP: '_app_session_backup_',
}

// Cookie 备份键
const COOKIE_NAME = '_app_ck_'

/**
 * 简单的字符串哈希函数
 */
function simpleHash(str: string): string {
  let hash = 0
  for (let i = 0; i < str.length; i++) {
    const char = str.charCodeAt(i)
    hash = ((hash << 5) - hash) + char
    hash = hash & hash
  }
  return Math.abs(hash).toString(36)
}

/**
 * 设置Cookie
 */
function setCookie(name: string, value: string, days: number = 365): void {
  try {
    const expires = new Date()
    expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000)
    document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/;SameSite=Strict`
  } catch (error) {
    console.error('设置Cookie失败:', error)
  }
}

/**
 * 获取Cookie
 */
function getCookie(name: string): string | null {
  try {
    const nameEQ = name + '='
    const ca = document.cookie.split(';')
    for (let i = 0; i < ca.length; i++) {
      let c: string | undefined = ca[i]
      if (!c) continue
      while (c.charAt(0) === ' ') {
        c = c.substring(1, c.length)
      }
      if (c.indexOf(nameEQ) === 0) {
        return c.substring(nameEQ.length, c.length)
      }
    }
    return null
  } catch (error) {
    console.error('读取Cookie失败:', error)
    return null
  }
}

/**
 * 从多个存储位置读取时间戳
 */
function getStoredTimestamp(key: string): number | null {
  try {
    // 优先从localStorage读取
    const localValue = localStorage.getItem(key)
    if (localValue) return parseInt(localValue, 10)

    // 尝试从sessionStorage读取
    const sessionData = sessionStorage.getItem(SESSION_KEYS.BACKUP)
    if (sessionData) {
      const data = JSON.parse(sessionData)
      if (data[key]) return parseInt(data[key], 10)
    }

    // 尝试从Cookie读取
    const cookieValue = getCookie(COOKIE_NAME)
    if (cookieValue) {
      const data = JSON.parse(decodeURIComponent(cookieValue))
      if (data[key]) return parseInt(data[key], 10)
    }

    return null
  } catch {
    return null
  }
}

/**
 * 保存到多个存储位置
 */
function setStoredValue(key: string, value: string): void {
  try {
    // 保存到localStorage
    localStorage.setItem(key, value)

    // 备份到sessionStorage
    const sessionData = sessionStorage.getItem(SESSION_KEYS.BACKUP)
    const data = sessionData ? JSON.parse(sessionData) : {}
    data[key] = value
    sessionStorage.setItem(SESSION_KEYS.BACKUP, JSON.stringify(data))

    // 备份到Cookie
    const cookieData = getCookie(COOKIE_NAME)
    const cookieObj = cookieData ? JSON.parse(decodeURIComponent(cookieData)) : {}
    cookieObj[key] = value
    setCookie(COOKIE_NAME, encodeURIComponent(JSON.stringify(cookieObj)), 365)
  } catch (error) {
    console.error('保存数据失败:', error)
  }
}

/**
 * 生成验证哈希
 */
function generateVerificationHash(timestamp: number, count: number): string {
  return simpleHash(`${timestamp}_${count}_${EXPIRY_TIMESTAMP}`)
}

/**
 * 检测时间是否被回调或异常
 */
function detectTimeRollback(): boolean {
  const currentTime = Date.now()

  try {
    // 检查0: 当前时间不应该早于项目构建时间
    if (currentTime < BUILD_TIMESTAMP) {
      console.warn('检测到时间异常:当前时间早于项目构建时间')
      return true
    }

    // 检查1: 是否早于历史最大时间戳
    const maxTimestamp = getStoredTimestamp(STORAGE_KEYS.MAX_TIMESTAMP)
    if (maxTimestamp && currentTime < maxTimestamp - 60000) { // 允许1分钟误差
      console.warn('检测到时间异常:当前时间早于历史记录')
      return true
    }

    // 检查2: 是否早于最后访问时间
    const lastAccess = getStoredTimestamp(STORAGE_KEYS.LAST_ACCESS)
    if (lastAccess && currentTime < lastAccess - 60000) {
      console.warn('检测到时间异常:当前时间早于上次访问')
      return true
    }

    // 检查3: 是否早于首次访问时间
    const firstAccess = getStoredTimestamp(STORAGE_KEYS.FIRST_ACCESS)
    if (firstAccess && currentTime < firstAccess) {
      console.warn('检测到时间异常:当前时间早于首次访问')
      return true
    }

    return false
  } catch (error) {
    console.error('时间检测出错:', error)
    return false
  }
}

/**
 * 更新访问记录
 */
function updateAccessRecord(): void {
  const currentTime = Date.now()

  try {
    // 设置首次访问时间
    const firstAccess = getStoredTimestamp(STORAGE_KEYS.FIRST_ACCESS)
    if (!firstAccess) {
      setStoredValue(STORAGE_KEYS.FIRST_ACCESS, currentTime.toString())
    }

    // 更新最后访问时间
    setStoredValue(STORAGE_KEYS.LAST_ACCESS, currentTime.toString())

    // 更新历史最大时间戳
    const maxTimestamp = getStoredTimestamp(STORAGE_KEYS.MAX_TIMESTAMP)
    if (!maxTimestamp || currentTime > maxTimestamp) {
      setStoredValue(STORAGE_KEYS.MAX_TIMESTAMP, currentTime.toString())
    }

    // 更新访问次数
    const countStr = localStorage.getItem(STORAGE_KEYS.ACCESS_COUNT) ||
                     sessionStorage.getItem(STORAGE_KEYS.ACCESS_COUNT) || '0'
    const count = parseInt(countStr, 10)
    const newCount = count + 1
    setStoredValue(STORAGE_KEYS.ACCESS_COUNT, newCount.toString())

    // 更新验证哈希
    const hash = generateVerificationHash(currentTime, newCount)
    setStoredValue(STORAGE_KEYS.VERIFICATION_HASH, hash)
  } catch (error) {
    console.error('更新访问记录失败:', error)
  }
}

/**
 * 验证数据完整性
 */
function verifyDataIntegrity(): boolean {
  try {
    const lastAccess = localStorage.getItem(STORAGE_KEYS.LAST_ACCESS)
    const count = localStorage.getItem(STORAGE_KEYS.ACCESS_COUNT)
    const storedHash = localStorage.getItem(STORAGE_KEYS.VERIFICATION_HASH)

    if (!lastAccess || !count || !storedHash) {
      return true // 首次访问,验证通过
    }

    const expectedHash = generateVerificationHash(parseInt(lastAccess, 10), parseInt(count, 10))
    if (expectedHash !== storedHash) {
      console.warn('检测到数据完整性异常')
      return false
    }

    return true
  } catch (error) {
    console.error('数据完整性验证出错:', error)
    return true // 出错时放行,避免误判
  }
}

/**
 * 检查是否存在过期锁定标记
 */
function isLocked(): boolean {
  try {
    // 从多个存储位置检查锁定标记
    const localLock = localStorage.getItem(STORAGE_KEYS.EXPIRED_LOCK)
    const sessionData = sessionStorage.getItem(SESSION_KEYS.BACKUP)
    const sessionLock = sessionData ? JSON.parse(sessionData)[STORAGE_KEYS.EXPIRED_LOCK] : null
    const cookieValue = getCookie(COOKIE_NAME)
    const cookieLock = cookieValue ? JSON.parse(decodeURIComponent(cookieValue))[STORAGE_KEYS.EXPIRED_LOCK] : null

    // 只要任何一个位置有锁定标记,就认为已锁定
    return localLock === 'true' || sessionLock === 'true' || cookieLock === 'true'
  } catch {
    return false
  }
}

/**
 * 设置过期锁定标记
 */
function setExpiredLock(): void {
  try {
    const lockValue = 'true'
    setStoredValue(STORAGE_KEYS.EXPIRED_LOCK, lockValue)
    console.warn('项目已过期并锁定')
  } catch (error) {
    console.error('设置过期锁定失败:', error)
  }
}

/**
 * 检查项目是否已过期
 * @returns {boolean} true表示已过期,false表示未过期
 */
export function isExpired(): boolean {
  const currentDate = new Date()
  const currentTime = currentDate.getTime()

  // 0. 检查版本号,如果版本不匹配,自动清除旧的锁定标记(延长过期时间时使用)
  const storedVersion = localStorage.getItem(STORAGE_KEYS.EXPIRY_VERSION)
  if (storedVersion !== EXPIRY_VERSION) {
    console.log('检测到过期配置已更新,清除旧的锁定标记')
    clearExpiredLock()
    setStoredValue(STORAGE_KEYS.EXPIRY_VERSION, EXPIRY_VERSION)
  }

  // 1. 检查过期锁定标记(在版本检查之后)
  if (isLocked()) {
    console.warn('检测到过期锁定标记,项目已被永久锁定')
    return true
  }

  // 2. 基础过期检查
  if (currentTime > EXPIRY_TIMESTAMP) {
    setExpiredLock() // 设置过期锁定
    return true
  }

  // 3. 检测时间回调
  if (detectTimeRollback()) {
    console.warn('检测到时间被回调,视为已过期')
    setExpiredLock() // 设置过期锁定
    return true
  }

  // 4. 验证数据完整性
  if (!verifyDataIntegrity()) {
    console.warn('数据完整性验证失败,视为已过期')
    setExpiredLock() // 设置过期锁定
    return true
  }

  // 5. 更新访问记录
  updateAccessRecord()

  return false
}

/**
 * 获取过期日期
 * @returns {Date} 过期日期
 */
export function getExpiryDate(): Date {
  return EXPIRY_DATE
}

/**
 * 获取剩余天数
 * @returns {number} 剩余天数,如果已过期则返回负数
 */
export function getRemainingDays(): number {
  const currentDate = new Date()
  const timeDiff = EXPIRY_TIMESTAMP - currentDate.getTime()
  return Math.ceil(timeDiff / (1000 * 3600 * 24))
}

/**
 * 格式化日期显示
 * @param date 日期对象
 * @returns {string} 格式化后的日期字符串
 */
export function formatDate(date: Date): string {
  return date.toLocaleDateString('zh-CN', {
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
  })
}

/**
 * 清除过期锁定标记(延长过期时间时使用)
 * 使用场景:修改了EXPIRY_DATE延长过期时间后,需要清除旧的锁定标记
 */
export function clearExpiredLock(): void {
  try {
    // 清除localStorage中的锁定标记
    localStorage.removeItem(STORAGE_KEYS.EXPIRED_LOCK)

    // 清除sessionStorage中的锁定标记
    const sessionData = sessionStorage.getItem(SESSION_KEYS.BACKUP)
    if (sessionData) {
      const data = JSON.parse(sessionData)
      delete data[STORAGE_KEYS.EXPIRED_LOCK]
      sessionStorage.setItem(SESSION_KEYS.BACKUP, JSON.stringify(data))
    }

    // 清除Cookie中的锁定标记
    const cookieValue = getCookie(COOKIE_NAME)
    if (cookieValue) {
      const cookieObj = JSON.parse(decodeURIComponent(cookieValue))
      delete cookieObj[STORAGE_KEYS.EXPIRED_LOCK]
      setCookie(COOKIE_NAME, encodeURIComponent(JSON.stringify(cookieObj)), 365)
    }

    console.log('过期锁定标记已清除')
  } catch (error) {
    console.error('清除过期锁定标记失败:', error)
  }
}

/**
 * 重置所有验证数据(仅用于开发测试)
 * 注意:这会清除所有历史记录,包括首次访问时间、访问计数等
 */
export function resetVerification(): void {
  try {
    // 清除localStorage
    Object.values(STORAGE_KEYS).forEach(key => {
      localStorage.removeItem(key)
    })

    // 清除sessionStorage
    sessionStorage.removeItem(SESSION_KEYS.BACKUP)

    // 清除Cookie
    document.cookie = `${COOKIE_NAME}=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/;`

    console.log('所有验证数据已重置')
  } catch (error) {
    console.error('重置验证数据失败:', error)
  }
}

// 开发环境下暴露到全局对象(方便调试)
if (typeof window !== 'undefined' && import.meta.env.DEV) {
  interface WindowWithDebug extends Window {
    __clearExpiredLock?: () => void
    __resetVerification?: () => void
  }
  (window as WindowWithDebug).__clearExpiredLock = clearExpiredLock;
  (window as WindowWithDebug).__resetVerification = resetVerification;
  console.log('开发模式:已暴露调试函数到全局');
  console.log('使用 window.__clearExpiredLock() 清除过期锁定');
  console.log('使用 window.__resetVerification() 重置所有验证数据');
}

项目过期页面

<template>
  <div class="expired-wrapper">
    <div class="expired-panel">
      <div class="expired-panel-header">
        <svg class="expired-panel-icon" viewBox="0 0 1024 1024" width="68" height="68">
          <path fill="#D32F2F" d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm-32 232c0-4.4 3.6-8 8-8h48c4.4 0 8 3.6 8 8v272c0 4.4-3.6 8-8 8h-48c-4.4 0-8-3.6-8-8V296zm32 440a48.01 48.01 0 0 1 0-96 48.01 48.01 0 0 1 0 96z"/>
        </svg>
        <div class="expired-title">
          系统授权已到期
        </div>
      </div>
      <div class="divider"></div>
      <div class="expired-message">
        <p>
          您好,当前系统的使用有效期已到期。为保证您的账号和数据安全,系统已暂停访问。
        </p>
        <div class="expired-info-row">
          <svg class="expired-panel-clock" viewBox="0 0 1024 1024" width="16" height="16">
            <path fill="#D32F2F" d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372z"/>
            <path fill="#D32F2F" d="M686.7 638.6L544.1 535.5V288c0-4.4-3.6-8-8-8H488c-4.4 0-8 3.6-8 8v275.4c0 2.6 1.2 5 3.3 6.5l165.4 120.6c3.6 2.6 8.6 1.8 11.2-1.7l28.6-39c2.6-3.7 1.8-8.7-1.8-11.2z"/>
          </svg>
          <span>到期时间:{{ formatDate(expiryDate) }}</span>
        </div>
        <div class="expired-alert">
          <svg class="alert-icon" viewBox="0 0 1024 1024" width="16" height="16">
            <path fill="#E6A23C" d="M955.7 856l-416-720c-6.2-10.7-16.9-16-27.7-16s-21.6 5.3-27.7 16l-416 720C56 877.4 71.4 904 96 904h832c24.6 0 40-26.6 27.7-48zM480 416c0-4.4 3.6-8 8-8h48c4.4 0 8 3.6 8 8v184c0 4.4-3.6 8-8 8h-48c-4.4 0-8-3.6-8-8V416zm32 352a48.01 48.01 0 0 1 0-96 48.01 48.01 0 0 1 0 96z"/>
          </svg>
          <span>如需续期或有其他需求,请联系相关负责人</span>
        </div>
      </div>
      <div class="divider"></div>
      <div class="expired-panel-actions">
        <button class="exp-btn exp-btn-primary" @click="refreshPage">
          <svg class="btn-icon" viewBox="0 0 1024 1024" width="16" height="16">
            <path fill="currentColor" d="M909.1 209.3l-56.4 44.1C775.8 155.1 656.2 92 521.9 92 290 92 102.3 279.5 102 511.5 101.7 743.7 289.8 932 521.9 932c181.3 0 335.8-115 394.6-276.1 1.5-4.2-.7-8.9-4.9-10.3l-56.7-19.5a8 8 0 0 0-10.1 4.8c-1.8 5-3.8 10-5.9 14.9-17.3 41-42.1 77.8-73.7 109.4A344.77 344.77 0 0 1 655.9 856c-42.3 17.9-87.4 27-133.8 27-46.5 0-91.5-9.1-133.8-27A344.77 344.77 0 0 1 279 755.8a344.72 344.72 0 0 1-73.7-109.4c-17.9-42.4-27-87.4-27-133.9s9.1-91.5 27-133.9c17.3-41 42.1-77.8 73.7-109.4 31.6-31.6 68.4-56.4 109.3-73.8 42.3-17.9 87.4-27 133.8-27 46.5 0 91.5 9.1 133.8 27a344.77 344.77 0 0 1 109.3 73.8c9.9 9.9 19.2 20.4 27.8 31.4l-60.2 47a8 8 0 0 0 3 14.1l175.6 43c5 1.2 9.9-2.6 9.9-7.7l.8-180.9c-.1-6.6-7.8-10.3-13-6.2z"/>
          </svg>
          刷新
        </button>
        <button class="exp-btn exp-btn-default" @click="closeWindow">
          <svg class="btn-icon" viewBox="0 0 1024 1024" width="16" height="16">
            <path fill="currentColor" d="M563.8 512l262.5-312.9c4.4-5.2.7-13.1-6.1-13.1h-79.8c-4.7 0-9.2 2.1-12.3 5.7L511.6 449.8 295.1 191.7c-3-3.6-7.5-5.7-12.3-5.7H203c-6.8 0-10.5 7.9-6.1 13.1L459.4 512 196.9 824.9A7.95 7.95 0 0 0 203 838h79.8c4.7 0 9.2-2.1 12.3-5.7l216.5-258.1 216.5 258.1c3 3.6 7.5 5.7 12.3 5.7h79.8c6.8 0 10.5-7.9 6.1-13.1L563.8 512z"/>
          </svg>
          关闭
        </button>
      </div>
      <div class="expired-panel-footer">
        &copy; {{ new Date().getFullYear() }} 杭州汇知思行 版权所有
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { getExpiryDate, formatDate } from '@/utils/expiryCheck'

const expiryDate = getExpiryDate()

const refreshPage = () => {
  window.location.reload()
}

const closeWindow = () => {
  window.close()
  if (!window.closed) {
    // 简单的警告提示
    setTimeout(() => {
      alert('如无法自动关闭,请手动关闭当前页面')
    }, 100)
  }
}
</script>

<style scoped>
.expired-wrapper {
  min-height: 100vh;
  width: 100vw;
  background: #f4f6fa;
  display: flex;
  justify-content: center;
  align-items: center;
}

.expired-panel {
  background: #fff;
  border-radius: 10px;
  padding: 48px 40px 24px 40px;
  box-shadow: 0 3px 24px 0 rgba(44, 41, 80, 0.10);
  min-width: 310px;
  max-width: 405px;
  width: 94vw;
  display: flex;
  flex-direction: column;
  align-items: center;
}

.expired-panel-header {
  width: 100%;
  text-align: center;
  margin-bottom: 12px;
}

.expired-panel-icon {
  margin-bottom: 8px;
}

.expired-title {
  font-size: 1.45rem;
  font-weight: 600;
  color: #D32F2F;
  margin-bottom: 4px;
  letter-spacing: 1px;
}

.expired-message {
  text-align: left;
  width: 100%;
  color: #444;
  font-size: 1rem;
  margin-bottom: 6px;
  padding: 0 2px;
}

.expired-info-row {
  display: flex;
  align-items: center;
  font-size: 1.01rem;
  font-weight: 500;
  margin: 16px 0 12px 0;
  color: #D32F2F;
  gap: 7px;
}

/* 分割线样式 */
.divider {
  width: 100%;
  height: 1px;
  background: #e8e8e8;
  margin: 20px 0;
}

/* 警告框样式 */
.expired-alert {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 10px 0 18px 0;
  padding: 8px 12px;
  font-size: 15px;
  border-radius: 4px;
  background: #fff8ea;
  border: 1px solid #faecd8;
  color: #8b7330;
  line-height: 1.5;
}

.alert-icon {
  flex-shrink: 0;
}

.expired-panel-actions {
  display: flex;
  justify-content: center;
  gap: 14px;
  margin-top: 14px;
  margin-bottom: 6px;
  width: 100%;
}

/* 按钮基础样式 */
.exp-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  padding: 8px 22px;
  font-size: 15px;
  border-radius: 16px;
  border: 1px solid;
  cursor: pointer;
  transition: all 0.3s;
  font-weight: 500;
  outline: none;
}

.btn-icon {
  flex-shrink: 0;
}

/* 主要按钮样式 */
.exp-btn-primary {
  background: #409eff;
  border-color: #409eff;
  color: #fff;
}

.exp-btn-primary:hover {
  background: #66b1ff;
  border-color: #66b1ff;
}

.exp-btn-primary:active {
  background: #3a8ee6;
  border-color: #3a8ee6;
}

/* 默认按钮样式 */
.exp-btn-default {
  background: #fff;
  border-color: #dcdfe6;
  color: #606266;
}

.exp-btn-default:hover {
  color: #409eff;
  border-color: #c6e2ff;
  background: #ecf5ff;
}

.exp-btn-default:active {
  color: #3a8ee6;
  border-color: #3a8ee6;
}

.expired-panel-footer {
  text-align: center;
  width: 100%;
  margin-top: 20px;
  color: #bdbdbd;
  font-size: 13px;
  letter-spacing: 1px;
  border-top: 1px solid #f0f0f0;
  padding-top: 8px;
}

@media (max-width: 600px) {
  .expired-panel {
    padding: 22px 5vw 18px 5vw;
    min-width: unset;
    max-width: 98vw;
  }
  .expired-title {
    font-size: 1.12rem;
  }
  .expired-panel-footer {
    font-size: 12px;
    padding-top: 6px;
    margin-top: 11px;
  }
}
</style>


使用地点 main.ts

import './assets/main.css'

import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import { createPinia } from 'pinia'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
import { isExpired } from './utils/expiryCheck'
import ExpiredPage from './components/ExpiredPage.vue'
import 'element-plus/dist/index.css'

if(isExpired()){
  // 如果已过期,显示过期页面
  const expiredApp = createApp(ExpiredPage)
  expiredApp.mount('#app')
} else {
  const app = createApp(App)
  app.use(createPinia())
  app.use(router)
  app.use(ElementPlus)
  for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
    app.component(key, component)
  }
  app.mount('#app')
}


posted @ 2025-10-11 11:42  韩德才  阅读(14)  评论(0)    收藏  举报