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