29 changed files with 1021 additions and 49 deletions
@ -0,0 +1,325 @@ |
|||
# CmsAd 类型修复说明 |
|||
|
|||
## 📋 问题描述 |
|||
|
|||
原始代码中 `CmsAd` 接口的 `images` 和 `imageList` 字段使用了 `any` 类型,违反了 TypeScript ESLint 规则: |
|||
|
|||
```typescript |
|||
// ❌ 修复前 - 使用 any 类型 |
|||
export interface CmsAd { |
|||
images?: any; // Error: Unexpected any |
|||
imageList?: any; // Error: Unexpected any |
|||
} |
|||
``` |
|||
|
|||
## 🔧 修复方案 |
|||
|
|||
### 1. 新增 CmsAdImage 接口 |
|||
|
|||
```typescript |
|||
/** |
|||
* 广告图片信息 |
|||
*/ |
|||
export interface CmsAdImage { |
|||
id?: number; // 图片ID |
|||
url?: string; // 图片URL |
|||
alt?: string; // 图片alt属性 |
|||
title?: string; // 图片标题 |
|||
link?: string; // 图片链接 |
|||
sortOrder?: number; // 排序顺序 |
|||
} |
|||
``` |
|||
|
|||
### 2. 更新 CmsAd 接口 |
|||
|
|||
```typescript |
|||
// ✅ 修复后 - 使用具体类型 |
|||
export interface CmsAd { |
|||
// 广告图片(单个图片或图片URL) |
|||
images?: string | CmsAdImage | CmsAdImage[]; |
|||
// 广告图片列表 |
|||
imageList?: CmsAdImage[]; |
|||
} |
|||
``` |
|||
|
|||
## 🎯 类型设计说明 |
|||
|
|||
### images 字段类型 |
|||
```typescript |
|||
images?: string | CmsAdImage | CmsAdImage[]; |
|||
``` |
|||
|
|||
支持三种数据格式: |
|||
- **`string`**: 单个图片URL字符串 |
|||
- **`CmsAdImage`**: 单个图片对象 |
|||
- **`CmsAdImage[]`**: 图片对象数组 |
|||
|
|||
### imageList 字段类型 |
|||
```typescript |
|||
imageList?: CmsAdImage[]; |
|||
``` |
|||
|
|||
专门用于图片列表,只接受图片对象数组。 |
|||
|
|||
## 📖 使用示例 |
|||
|
|||
### 1. 基础用法 |
|||
|
|||
```typescript |
|||
import { CmsAd, CmsAdImage } from '@/api/cms/cmsAd/model'; |
|||
|
|||
// 示例1:使用字符串URL |
|||
const ad1: CmsAd = { |
|||
adId: 1, |
|||
name: '首页横幅', |
|||
images: 'https://example.com/banner.jpg' |
|||
}; |
|||
|
|||
// 示例2:使用单个图片对象 |
|||
const ad2: CmsAd = { |
|||
adId: 2, |
|||
name: '侧边栏广告', |
|||
images: { |
|||
id: 1, |
|||
url: 'https://example.com/sidebar.jpg', |
|||
alt: '侧边栏广告', |
|||
link: '/products' |
|||
} |
|||
}; |
|||
|
|||
// 示例3:使用图片数组 |
|||
const ad3: CmsAd = { |
|||
adId: 3, |
|||
name: '轮播广告', |
|||
images: [ |
|||
{ |
|||
id: 1, |
|||
url: 'https://example.com/slide1.jpg', |
|||
alt: '轮播图1', |
|||
link: '/page1' |
|||
}, |
|||
{ |
|||
id: 2, |
|||
url: 'https://example.com/slide2.jpg', |
|||
alt: '轮播图2', |
|||
link: '/page2' |
|||
} |
|||
] |
|||
}; |
|||
``` |
|||
|
|||
### 2. 类型守卫函数 |
|||
|
|||
```typescript |
|||
// 检查是否为字符串URL |
|||
function isImageUrl(images: CmsAd['images']): images is string { |
|||
return typeof images === 'string'; |
|||
} |
|||
|
|||
// 检查是否为单个图片对象 |
|||
function isSingleImage(images: CmsAd['images']): images is CmsAdImage { |
|||
return typeof images === 'object' && !Array.isArray(images) && images !== null; |
|||
} |
|||
|
|||
// 检查是否为图片数组 |
|||
function isImageArray(images: CmsAd['images']): images is CmsAdImage[] { |
|||
return Array.isArray(images); |
|||
} |
|||
|
|||
// 使用示例 |
|||
function renderAdImages(ad: CmsAd) { |
|||
if (isImageUrl(ad.images)) { |
|||
return <img src={ad.images} alt={ad.name} />; |
|||
} |
|||
|
|||
if (isSingleImage(ad.images)) { |
|||
return ( |
|||
<img |
|||
src={ad.images.url} |
|||
alt={ad.images.alt || ad.name} |
|||
onClick={() => ad.images.link && window.open(ad.images.link)} |
|||
/> |
|||
); |
|||
} |
|||
|
|||
if (isImageArray(ad.images)) { |
|||
return ( |
|||
<div className="image-gallery"> |
|||
{ad.images.map(img => ( |
|||
<img |
|||
key={img.id} |
|||
src={img.url} |
|||
alt={img.alt} |
|||
onClick={() => img.link && window.open(img.link)} |
|||
/> |
|||
))} |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
``` |
|||
|
|||
### 3. React 组件示例 |
|||
|
|||
```typescript |
|||
import React from 'react'; |
|||
import { CmsAd } from '@/api/cms/cmsAd/model'; |
|||
|
|||
interface AdComponentProps { |
|||
ad: CmsAd; |
|||
} |
|||
|
|||
const AdComponent: React.FC<AdComponentProps> = ({ ad }) => { |
|||
const renderImages = () => { |
|||
if (!ad.images) return null; |
|||
|
|||
// 处理字符串URL |
|||
if (typeof ad.images === 'string') { |
|||
return ( |
|||
<img |
|||
src={ad.images} |
|||
alt={ad.name} |
|||
style={{ width: ad.width, height: ad.height }} |
|||
/> |
|||
); |
|||
} |
|||
|
|||
// 处理单个图片对象 |
|||
if (!Array.isArray(ad.images)) { |
|||
return ( |
|||
<a href={ad.images.link} target="_blank" rel="noopener noreferrer"> |
|||
<img |
|||
src={ad.images.url} |
|||
alt={ad.images.alt || ad.name} |
|||
title={ad.images.title} |
|||
style={{ width: ad.width, height: ad.height }} |
|||
/> |
|||
</a> |
|||
); |
|||
} |
|||
|
|||
// 处理图片数组 |
|||
return ( |
|||
<div className="ad-carousel"> |
|||
{ad.images.map((img, index) => ( |
|||
<a |
|||
key={img.id || index} |
|||
href={img.link} |
|||
target="_blank" |
|||
rel="noopener noreferrer" |
|||
> |
|||
<img |
|||
src={img.url} |
|||
alt={img.alt || ad.name} |
|||
title={img.title} |
|||
style={{ width: ad.width, height: ad.height }} |
|||
/> |
|||
</a> |
|||
))} |
|||
</div> |
|||
); |
|||
}; |
|||
|
|||
return ( |
|||
<div className="ad-container" style={{ ...JSON.parse(ad.style || '{}') }}> |
|||
{renderImages()} |
|||
{ad.imageList && ad.imageList.length > 0 && ( |
|||
<div className="ad-image-list"> |
|||
{ad.imageList.map(img => ( |
|||
<img |
|||
key={img.id} |
|||
src={img.url} |
|||
alt={img.alt} |
|||
title={img.title} |
|||
/> |
|||
))} |
|||
</div> |
|||
)} |
|||
</div> |
|||
); |
|||
}; |
|||
|
|||
export default AdComponent; |
|||
``` |
|||
|
|||
## 🔍 类型检查优势 |
|||
|
|||
### 1. 编译时类型安全 |
|||
```typescript |
|||
// ✅ 正确用法 |
|||
const ad: CmsAd = { |
|||
images: 'https://example.com/image.jpg' // string |
|||
}; |
|||
|
|||
const ad2: CmsAd = { |
|||
images: { url: 'https://example.com/image.jpg' } // CmsAdImage |
|||
}; |
|||
|
|||
// ❌ 错误用法 - TypeScript 会报错 |
|||
const ad3: CmsAd = { |
|||
images: 123 // Error: Type 'number' is not assignable |
|||
}; |
|||
``` |
|||
|
|||
### 2. 智能代码提示 |
|||
```typescript |
|||
const ad: CmsAd = { |
|||
images: { |
|||
// IDE 会提供智能提示:id, url, alt, title, link, sortOrder |
|||
} |
|||
}; |
|||
``` |
|||
|
|||
### 3. 重构安全性 |
|||
当修改 `CmsAdImage` 接口时,TypeScript 会自动检查所有使用该类型的代码,确保类型一致性。 |
|||
|
|||
## 📈 性能优化建议 |
|||
|
|||
### 1. 图片懒加载 |
|||
```typescript |
|||
const LazyAdImage: React.FC<{ image: CmsAdImage }> = ({ image }) => { |
|||
return ( |
|||
<img |
|||
src={image.url} |
|||
alt={image.alt} |
|||
loading="lazy" // 原生懒加载 |
|||
decoding="async" |
|||
/> |
|||
); |
|||
}; |
|||
``` |
|||
|
|||
### 2. 图片预加载 |
|||
```typescript |
|||
function preloadAdImages(ad: CmsAd) { |
|||
const urls: string[] = []; |
|||
|
|||
if (typeof ad.images === 'string') { |
|||
urls.push(ad.images); |
|||
} else if (Array.isArray(ad.images)) { |
|||
urls.push(...ad.images.map(img => img.url).filter(Boolean)); |
|||
} else if (ad.images?.url) { |
|||
urls.push(ad.images.url); |
|||
} |
|||
|
|||
urls.forEach(url => { |
|||
const link = document.createElement('link'); |
|||
link.rel = 'preload'; |
|||
link.as = 'image'; |
|||
link.href = url; |
|||
document.head.appendChild(link); |
|||
}); |
|||
} |
|||
``` |
|||
|
|||
## ✅ 修复结果 |
|||
|
|||
- ✅ 消除了 `@typescript-eslint/no-explicit-any` 错误 |
|||
- ✅ 提供了完整的类型安全 |
|||
- ✅ 支持多种图片数据格式 |
|||
- ✅ 保持了向后兼容性 |
|||
- ✅ 提供了清晰的类型文档 |
|||
|
|||
现在 `CmsAd` 接口具有完整的类型安全,可以在开发时提供更好的智能提示和错误检查! |
@ -0,0 +1,50 @@ |
|||
import { ReactNode } from 'react'; |
|||
|
|||
interface ContainerProps { |
|||
children: ReactNode; |
|||
className?: string; |
|||
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | '7xl' | 'full'; |
|||
padding?: 'none' | 'sm' | 'md' | 'lg' | 'xl'; |
|||
} |
|||
|
|||
/** |
|||
* 通用容器组件,用于内容居中和响应式布局 |
|||
*/ |
|||
const Container = ({ |
|||
children, |
|||
className = '', |
|||
size = '7xl', |
|||
padding = 'md' |
|||
}: ContainerProps) => { |
|||
// 根据 size 参数设置最大宽度
|
|||
const maxWidthClass = { |
|||
'sm': 'max-w-sm', |
|||
'md': 'max-w-md', |
|||
'lg': 'max-w-lg', |
|||
'xl': 'max-w-xl', |
|||
'2xl': 'max-w-2xl', |
|||
'3xl': 'max-w-3xl', |
|||
'4xl': 'max-w-4xl', |
|||
'5xl': 'max-w-5xl', |
|||
'6xl': 'max-w-6xl', |
|||
'7xl': 'max-w-7xl', |
|||
'full': 'max-w-full' |
|||
}[size]; |
|||
|
|||
// 根据 padding 参数设置内边距
|
|||
const paddingClass = { |
|||
'none': '', |
|||
'sm': 'px-2 sm:px-4', |
|||
'md': 'px-4 sm:px-6 lg:px-8', |
|||
'lg': 'px-6 sm:px-8 lg:px-12', |
|||
'xl': 'px-8 sm:px-12 lg:px-16' |
|||
}[padding]; |
|||
|
|||
return ( |
|||
<div className={`${maxWidthClass} mx-auto ${paddingClass} ${className}`}> |
|||
{children} |
|||
</div> |
|||
); |
|||
}; |
|||
|
|||
export default Container; |
@ -0,0 +1,159 @@ |
|||
'use client'; |
|||
import { useSiteInfoValue } from '@/hooks/useSiteInfo'; |
|||
import Link from 'next/link'; |
|||
|
|||
/** |
|||
* 导航菜单展示组件 |
|||
* 演示如何使用全局状态中的导航菜单数据 |
|||
*/ |
|||
const NavigationDisplay = () => { |
|||
const { bottomNavs, topNavs, navsLoading, navsError, isNavsLoaded } = useSiteInfoValue(); |
|||
|
|||
if (navsLoading) { |
|||
return ( |
|||
<div className="bg-gray-50 p-6 rounded-lg"> |
|||
<div className="animate-pulse space-y-4"> |
|||
<div className="h-4 bg-gray-300 rounded w-1/4"></div> |
|||
<div className="space-y-2"> |
|||
<div className="h-3 bg-gray-300 rounded w-1/2"></div> |
|||
<div className="h-3 bg-gray-300 rounded w-1/3"></div> |
|||
<div className="h-3 bg-gray-300 rounded w-1/4"></div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
if (navsError) { |
|||
return ( |
|||
<div className="bg-red-50 border border-red-200 p-4 rounded-lg"> |
|||
<p className="text-red-600 text-sm">加载导航菜单失败: {navsError}</p> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
// 按父级分组底部导航
|
|||
const groupedBottomNavs = bottomNavs.reduce((groups: Record<string, typeof bottomNavs>, nav) => { |
|||
if (nav.parentId === 0) { |
|||
// 顶级菜单作为分组标题
|
|||
groups[nav.title || 'default'] = bottomNavs.filter(child => child.parentId === nav.navigationId); |
|||
} |
|||
return groups; |
|||
}, {}); |
|||
|
|||
// 按父级分组顶部导航
|
|||
const groupedTopNavs = topNavs.reduce((groups: Record<string, typeof topNavs>, nav) => { |
|||
if (nav.parentId === 0) { |
|||
// 顶级菜单作为分组标题
|
|||
groups[nav.title || 'default'] = topNavs.filter(child => child.parentId === nav.navigationId); |
|||
} |
|||
return groups; |
|||
}, {}); |
|||
|
|||
return ( |
|||
<div className="space-y-8"> |
|||
{/* 顶部导航菜单 */} |
|||
{Object.keys(groupedTopNavs).length > 0 && ( |
|||
<div className="bg-white border border-gray-200 p-6 rounded-lg shadow-sm"> |
|||
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center"> |
|||
<svg className="w-5 h-5 mr-2 text-blue-600" fill="currentColor" viewBox="0 0 20 20"> |
|||
<path fillRule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clipRule="evenodd" /> |
|||
</svg> |
|||
顶部导航菜单 |
|||
</h3> |
|||
|
|||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> |
|||
{Object.entries(groupedTopNavs).map(([groupTitle, navs]) => ( |
|||
<div key={groupTitle}> |
|||
<h4 className="font-medium text-gray-900 mb-3">{groupTitle}</h4> |
|||
<ul className="space-y-2"> |
|||
{navs.map((nav) => ( |
|||
<li key={nav.navigationId}> |
|||
<Link |
|||
href={nav.path || '#'} |
|||
className="text-sm text-gray-600 hover:text-blue-600 transition-colors flex items-center" |
|||
target={nav.target === '_blank' ? '_blank' : undefined} |
|||
rel={nav.target === '_blank' ? 'noopener noreferrer' : undefined} |
|||
> |
|||
{nav.icon && ( |
|||
<span className="mr-2" dangerouslySetInnerHTML={{ __html: nav.icon }} /> |
|||
)} |
|||
{nav.title} |
|||
</Link> |
|||
</li> |
|||
))} |
|||
</ul> |
|||
</div> |
|||
))} |
|||
</div> |
|||
</div> |
|||
)} |
|||
|
|||
{/* 底部导航菜单 */} |
|||
{Object.keys(groupedBottomNavs).length > 0 && ( |
|||
<div className="bg-white border border-gray-200 p-6 rounded-lg shadow-sm"> |
|||
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center"> |
|||
<svg className="w-5 h-5 mr-2 text-green-600" fill="currentColor" viewBox="0 0 20 20"> |
|||
<path fillRule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clipRule="evenodd" /> |
|||
</svg> |
|||
底部导航菜单 |
|||
</h3> |
|||
|
|||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> |
|||
{Object.entries(groupedBottomNavs).map(([groupTitle, navs]) => ( |
|||
<div key={groupTitle}> |
|||
<h4 className="font-medium text-gray-900 mb-3">{groupTitle}</h4> |
|||
<ul className="space-y-2"> |
|||
{navs.map((nav) => ( |
|||
<li key={nav.navigationId}> |
|||
<Link |
|||
href={nav.path || '#'} |
|||
className="text-sm text-gray-600 hover:text-green-600 transition-colors flex items-center" |
|||
target={nav.target === '_blank' ? '_blank' : undefined} |
|||
rel={nav.target === '_blank' ? 'noopener noreferrer' : undefined} |
|||
> |
|||
{nav.icon && ( |
|||
<span className="mr-2" dangerouslySetInnerHTML={{ __html: nav.icon }} /> |
|||
)} |
|||
{nav.title} |
|||
</Link> |
|||
</li> |
|||
))} |
|||
</ul> |
|||
</div> |
|||
))} |
|||
</div> |
|||
</div> |
|||
)} |
|||
|
|||
{/* 无数据状态 */} |
|||
{!isNavsLoaded && !navsLoading && ( |
|||
<div className="bg-yellow-50 border border-yellow-200 p-6 rounded-lg text-center"> |
|||
<svg className="w-12 h-12 text-yellow-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
|||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> |
|||
</svg> |
|||
<h3 className="text-lg font-medium text-yellow-800 mb-2">暂无导航菜单数据</h3> |
|||
<p className="text-yellow-600 text-sm"> |
|||
请在后台管理系统中配置导航菜单,或者检查 API 接口是否正常返回数据。 |
|||
</p> |
|||
</div> |
|||
)} |
|||
|
|||
{/* 导航菜单统计信息 */} |
|||
{isNavsLoaded && ( |
|||
<div className="bg-blue-50 border border-blue-200 p-4 rounded-lg"> |
|||
<div className="flex items-center justify-between text-sm"> |
|||
<span className="text-blue-800"> |
|||
导航菜单统计: 顶部 {topNavs.length} 个,底部 {bottomNavs.length} 个 |
|||
</span> |
|||
<span className="text-blue-600"> |
|||
数据来源: 全局状态缓存 |
|||
</span> |
|||
</div> |
|||
</div> |
|||
)} |
|||
</div> |
|||
); |
|||
}; |
|||
|
|||
export default NavigationDisplay; |
@ -0,0 +1,113 @@ |
|||
'use client'; |
|||
import { useSiteInfoValue } from '@/hooks/useSiteInfo'; |
|||
|
|||
/** |
|||
* 站点信息展示组件 |
|||
* 演示如何在任意组件中使用全局站点信息 |
|||
*/ |
|||
const SiteInfoDisplay = () => { |
|||
const { siteInfo, loading, error, isLoaded } = useSiteInfoValue(); |
|||
|
|||
if (loading) { |
|||
return ( |
|||
<div className="bg-gray-50 p-4 rounded-lg"> |
|||
<div className="animate-pulse"> |
|||
<div className="h-4 bg-gray-300 rounded w-1/4 mb-2"></div> |
|||
<div className="h-3 bg-gray-300 rounded w-1/2"></div> |
|||
</div> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
if (error) { |
|||
return ( |
|||
<div className="bg-red-50 border border-red-200 p-4 rounded-lg"> |
|||
<p className="text-red-600 text-sm">加载站点信息失败: {error}</p> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
if (!isLoaded || !siteInfo) { |
|||
return ( |
|||
<div className="bg-yellow-50 border border-yellow-200 p-4 rounded-lg"> |
|||
<p className="text-yellow-600 text-sm">暂无站点信息</p> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
return ( |
|||
<div className="bg-white border border-gray-200 p-6 rounded-lg shadow-sm"> |
|||
<h3 className="text-lg font-semibold text-gray-900 mb-4">站点信息</h3> |
|||
|
|||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> |
|||
<div> |
|||
<label className="block text-sm font-medium text-gray-700 mb-1"> |
|||
网站名称 |
|||
</label> |
|||
<p className="text-sm text-gray-900">{siteInfo.websiteName || '未设置'}</p> |
|||
</div> |
|||
|
|||
<div> |
|||
<label className="block text-sm font-medium text-gray-700 mb-1"> |
|||
网站标识 |
|||
</label> |
|||
<p className="text-sm text-gray-900">{siteInfo.websiteCode || '未设置'}</p> |
|||
</div> |
|||
|
|||
<div> |
|||
<label className="block text-sm font-medium text-gray-700 mb-1"> |
|||
联系电话 |
|||
</label> |
|||
<p className="text-sm text-gray-900">{siteInfo.phone || '未设置'}</p> |
|||
</div> |
|||
|
|||
<div> |
|||
<label className="block text-sm font-medium text-gray-700 mb-1"> |
|||
电子邮箱 |
|||
</label> |
|||
<p className="text-sm text-gray-900">{siteInfo.email || '未设置'}</p> |
|||
</div> |
|||
|
|||
<div> |
|||
<label className="block text-sm font-medium text-gray-700 mb-1"> |
|||
ICP备案号 |
|||
</label> |
|||
<p className="text-sm text-gray-900">{siteInfo.icpNo || '未设置'}</p> |
|||
</div> |
|||
|
|||
<div> |
|||
<label className="block text-sm font-medium text-gray-700 mb-1"> |
|||
状态 |
|||
</label> |
|||
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${ |
|||
siteInfo.status === 1 |
|||
? 'bg-green-100 text-green-800' |
|||
: 'bg-red-100 text-red-800' |
|||
}`}>
|
|||
{siteInfo.status === 1 ? '运行中' : '未运行'} |
|||
</span> |
|||
</div> |
|||
</div> |
|||
|
|||
{siteInfo.address && ( |
|||
<div className="mt-4"> |
|||
<label className="block text-sm font-medium text-gray-700 mb-1"> |
|||
地址 |
|||
</label> |
|||
<p className="text-sm text-gray-900">{siteInfo.address}</p> |
|||
</div> |
|||
)} |
|||
|
|||
{siteInfo.comments && ( |
|||
<div className="mt-4"> |
|||
<label className="block text-sm font-medium text-gray-700 mb-1"> |
|||
备注 |
|||
</label> |
|||
<p className="text-sm text-gray-900">{siteInfo.comments}</p> |
|||
</div> |
|||
)} |
|||
</div> |
|||
); |
|||
}; |
|||
|
|||
export default SiteInfoDisplay; |
@ -0,0 +1,109 @@ |
|||
import { useEffect } from 'react' |
|||
import { useSiteStore } from '@/stores/useSiteStore' |
|||
|
|||
/** |
|||
* 站点信息 Hook |
|||
* 自动获取站点信息,提供缓存和错误处理 |
|||
*/ |
|||
export const useSiteInfo = (options?: { |
|||
/** 是否自动获取数据 */ |
|||
autoFetch?: boolean |
|||
/** 是否强制刷新 */ |
|||
forceRefresh?: boolean |
|||
/** 是否同时获取导航菜单 */ |
|||
includeNavs?: boolean |
|||
}) => { |
|||
const { |
|||
siteInfo, |
|||
bottomNavs, |
|||
topNavs, |
|||
loading, |
|||
navsLoading, |
|||
error, |
|||
navsError, |
|||
lastFetched, |
|||
navsLastFetched, |
|||
fetchSiteInfo, |
|||
fetchNavigations, |
|||
refreshSiteInfo, |
|||
refreshNavigations, |
|||
clearCache, |
|||
setSiteInfo, |
|||
setNavigations |
|||
} = useSiteStore() |
|||
|
|||
const { autoFetch = true, forceRefresh = false, includeNavs = true } = options || {} |
|||
|
|||
useEffect(() => { |
|||
if (autoFetch) { |
|||
if (forceRefresh) { |
|||
refreshSiteInfo() |
|||
if (includeNavs) { |
|||
refreshNavigations() |
|||
} |
|||
} else { |
|||
fetchSiteInfo() |
|||
if (includeNavs) { |
|||
fetchNavigations() |
|||
} |
|||
} |
|||
} |
|||
}, [autoFetch, forceRefresh, includeNavs, fetchSiteInfo, fetchNavigations, refreshSiteInfo, refreshNavigations]) |
|||
|
|||
return { |
|||
// 数据状态
|
|||
siteInfo, |
|||
bottomNavs, |
|||
topNavs, |
|||
loading, |
|||
navsLoading, |
|||
error, |
|||
navsError, |
|||
lastFetched, |
|||
navsLastFetched, |
|||
|
|||
// 计算属性
|
|||
isLoaded: !!siteInfo, |
|||
isNavsLoaded: bottomNavs.length > 0 || topNavs.length > 0, |
|||
isCacheValid: lastFetched ? (Date.now() - lastFetched) < 30 * 60 * 1000 : false, |
|||
isNavsCacheValid: navsLastFetched ? (Date.now() - navsLastFetched) < 30 * 60 * 1000 : false, |
|||
|
|||
// 操作方法
|
|||
refetch: fetchSiteInfo, |
|||
refetchNavs: fetchNavigations, |
|||
refresh: refreshSiteInfo, |
|||
refreshNavs: refreshNavigations, |
|||
clearCache, |
|||
setSiteInfo, |
|||
setNavigations, |
|||
|
|||
// 便捷方法
|
|||
retry: () => refreshSiteInfo(), |
|||
retryNavs: () => refreshNavigations() |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 仅获取站点信息的 Hook(不自动请求) |
|||
*/ |
|||
export const useSiteInfoValue = () => { |
|||
const siteInfo = useSiteStore(state => state.siteInfo) |
|||
const bottomNavs = useSiteStore(state => state.bottomNavs) |
|||
const topNavs = useSiteStore(state => state.topNavs) |
|||
const loading = useSiteStore(state => state.loading) |
|||
const navsLoading = useSiteStore(state => state.navsLoading) |
|||
const error = useSiteStore(state => state.error) |
|||
const navsError = useSiteStore(state => state.navsError) |
|||
|
|||
return { |
|||
siteInfo, |
|||
bottomNavs, |
|||
topNavs, |
|||
loading, |
|||
navsLoading, |
|||
error, |
|||
navsError, |
|||
isLoaded: !!siteInfo, |
|||
isNavsLoaded: bottomNavs.length > 0 || topNavs.length > 0 |
|||
} |
|||
} |
@ -0,0 +1,218 @@ |
|||
import { create } from 'zustand' |
|||
import { persist, createJSONStorage } from 'zustand/middleware' |
|||
import { getSiteInfo, getBottomNavigations, getTopNavigations } from '@/api/layout' |
|||
import { CmsWebsite } from '@/api/cms/cmsWebsite/model' |
|||
import { CmsNavigation } from '@/api/cms/cmsNavigation/model' |
|||
|
|||
interface SiteState { |
|||
// 状态
|
|||
siteInfo: CmsWebsite | null |
|||
bottomNavs: CmsNavigation[] |
|||
topNavs: CmsNavigation[] |
|||
loading: boolean |
|||
navsLoading: boolean |
|||
error: string | null |
|||
navsError: string | null |
|||
lastFetched: number | null |
|||
navsLastFetched: number | null |
|||
|
|||
// 操作方法
|
|||
fetchSiteInfo: () => Promise<void> |
|||
fetchNavigations: () => Promise<void> |
|||
refreshSiteInfo: () => Promise<void> |
|||
refreshNavigations: () => Promise<void> |
|||
clearCache: () => void |
|||
setSiteInfo: (siteInfo: CmsWebsite) => void |
|||
setNavigations: (bottomNavs: CmsNavigation[], topNavs: CmsNavigation[]) => void |
|||
} |
|||
|
|||
// 缓存有效期:30分钟
|
|||
const CACHE_DURATION = 30 * 60 * 1000 |
|||
|
|||
export const useSiteStore = create<SiteState>()( |
|||
persist( |
|||
(set, get) => ({ |
|||
// 初始状态
|
|||
siteInfo: null, |
|||
bottomNavs: [], |
|||
topNavs: [], |
|||
loading: false, |
|||
navsLoading: false, |
|||
error: null, |
|||
navsError: null, |
|||
lastFetched: null, |
|||
navsLastFetched: null, |
|||
|
|||
// 获取站点信息(带缓存逻辑)
|
|||
fetchSiteInfo: async () => { |
|||
const { siteInfo, lastFetched } = get() |
|||
const now = Date.now() |
|||
|
|||
// 如果有缓存且未过期,直接返回
|
|||
if (siteInfo && lastFetched && (now - lastFetched) < CACHE_DURATION) { |
|||
return |
|||
} |
|||
|
|||
set({ loading: true, error: null }) |
|||
|
|||
try { |
|||
const data = await getSiteInfo() |
|||
set({ |
|||
siteInfo: data || null, |
|||
loading: false, |
|||
error: null, |
|||
lastFetched: now |
|||
}) |
|||
} catch (error) { |
|||
const errorMessage = error instanceof Error ? error.message : '获取站点信息失败' |
|||
set({ |
|||
loading: false, |
|||
error: errorMessage |
|||
}) |
|||
console.error('获取站点信息失败:', error) |
|||
} |
|||
}, |
|||
|
|||
// 获取导航菜单(带缓存逻辑)
|
|||
fetchNavigations: async () => { |
|||
const { bottomNavs, navsLastFetched } = get() |
|||
const now = Date.now() |
|||
|
|||
// 如果有缓存且未过期,直接返回
|
|||
if (bottomNavs.length > 0 && navsLastFetched && (now - navsLastFetched) < CACHE_DURATION) { |
|||
return |
|||
} |
|||
|
|||
set({ navsLoading: true, navsError: null }) |
|||
|
|||
try { |
|||
const [bottomNavsData, topNavsData] = await Promise.all([ |
|||
getBottomNavigations(), |
|||
getTopNavigations() |
|||
]) |
|||
|
|||
set({ |
|||
bottomNavs: bottomNavsData || [], |
|||
topNavs: topNavsData || [], |
|||
navsLoading: false, |
|||
navsError: null, |
|||
navsLastFetched: now |
|||
}) |
|||
} catch (error) { |
|||
const errorMessage = error instanceof Error ? error.message : '获取导航菜单失败' |
|||
set({ |
|||
navsLoading: false, |
|||
navsError: errorMessage |
|||
}) |
|||
console.error('获取导航菜单失败:', error) |
|||
} |
|||
}, |
|||
|
|||
// 强制刷新站点信息
|
|||
refreshSiteInfo: async () => { |
|||
set({ loading: true, error: null }) |
|||
|
|||
try { |
|||
const data = await getSiteInfo() |
|||
set({ |
|||
siteInfo: data || null, |
|||
loading: false, |
|||
error: null, |
|||
lastFetched: Date.now() |
|||
}) |
|||
} catch (error) { |
|||
const errorMessage = error instanceof Error ? error.message : '刷新站点信息失败' |
|||
set({ |
|||
loading: false, |
|||
error: errorMessage |
|||
}) |
|||
console.error('刷新站点信息失败:', error) |
|||
} |
|||
}, |
|||
|
|||
// 强制刷新导航菜单
|
|||
refreshNavigations: async () => { |
|||
set({ navsLoading: true, navsError: null }) |
|||
|
|||
try { |
|||
const [bottomNavsData, topNavsData] = await Promise.all([ |
|||
getBottomNavigations(), |
|||
getTopNavigations() |
|||
]) |
|||
|
|||
set({ |
|||
bottomNavs: bottomNavsData || [], |
|||
topNavs: topNavsData || [], |
|||
navsLoading: false, |
|||
navsError: null, |
|||
navsLastFetched: Date.now() |
|||
}) |
|||
} catch (error) { |
|||
const errorMessage = error instanceof Error ? error.message : '刷新导航菜单失败' |
|||
set({ |
|||
navsLoading: false, |
|||
navsError: errorMessage |
|||
}) |
|||
console.error('刷新导航菜单失败:', error) |
|||
} |
|||
}, |
|||
|
|||
// 清除缓存
|
|||
clearCache: () => { |
|||
set({ |
|||
siteInfo: null, |
|||
bottomNavs: [], |
|||
topNavs: [], |
|||
loading: false, |
|||
navsLoading: false, |
|||
error: null, |
|||
navsError: null, |
|||
lastFetched: null, |
|||
navsLastFetched: null |
|||
}) |
|||
}, |
|||
|
|||
// 手动设置站点信息
|
|||
setSiteInfo: (siteInfo: CmsWebsite) => { |
|||
set({ |
|||
siteInfo, |
|||
lastFetched: Date.now(), |
|||
error: null |
|||
}) |
|||
}, |
|||
|
|||
// 手动设置导航菜单
|
|||
setNavigations: (bottomNavs: CmsNavigation[], topNavs: CmsNavigation[]) => { |
|||
set({ |
|||
bottomNavs, |
|||
topNavs, |
|||
navsLastFetched: Date.now(), |
|||
navsError: null |
|||
}) |
|||
} |
|||
}), |
|||
{ |
|||
name: 'site-storage', // 存储键名
|
|||
storage: createJSONStorage(() => { |
|||
// 优先使用 sessionStorage,降级到 localStorage
|
|||
if (typeof window !== 'undefined') { |
|||
return window.sessionStorage || window.localStorage |
|||
} |
|||
// 服务端渲染时的占位符
|
|||
return { |
|||
getItem: () => null, |
|||
setItem: () => {}, |
|||
removeItem: () => {} |
|||
} |
|||
}), |
|||
// 只持久化必要的数据,不持久化 loading 状态
|
|||
partialize: (state) => ({ |
|||
siteInfo: state.siteInfo, |
|||
bottomNavs: state.bottomNavs, |
|||
topNavs: state.topNavs, |
|||
lastFetched: state.lastFetched, |
|||
navsLastFetched: state.navsLastFetched |
|||
}) |
|||
} |
|||
) |
|||
) |
Loading…
Reference in new issue