Browse Source

next版本的pc端程序

master
科技小王子 4 weeks ago
parent
commit
5e47cc3737
  1. 173
      docs/IMAGE_DOMAINS_CONFIG.md
  2. 161
      docs/NEXT_CONFIG_IMAGES_SUMMARY.md
  3. 190
      src/api/README-TENANT.md
  4. 145
      src/api/examples/tenant-api-usage.ts
  5. 390
      src/api/modules/article.ts
  6. 358
      src/app/articles/[id]/page.tsx
  7. 380
      src/app/articles/page.tsx
  8. 158
      src/components/layout/Footer.tsx
  9. 139
      src/components/layout/Header.tsx
  10. 120
      src/components/sections/ARTICLES_SECTION_FIXES.md
  11. 173
      src/components/sections/AboutSection.tsx
  12. 206
      src/components/sections/ArticlesSection.tsx
  13. 279
      src/components/sections/ContactSection.tsx
  14. 103
      src/components/sections/FeaturesSection.tsx
  15. 76
      src/components/sections/HeroSection.tsx

173
docs/IMAGE_DOMAINS_CONFIG.md

@ -0,0 +1,173 @@
# Next.js 图片域名配置说明
## 问题描述
当使用 Next.js 的 `Image` 组件加载外部图片时,如果图片域名没有在 `next.config.ts` 中配置,会出现以下错误:
```
hostname "oss.wsdns.cn" is not configured under images in your `next.config`
```
## 解决方案
`next.config.ts` 文件中配置 `images.remotePatterns` 来允许特定域名的图片加载。
### 配置示例
```typescript
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
// OSS 图片域名
{
protocol: 'https',
hostname: 'oss.wsdns.cn',
port: '',
pathname: '/**',
},
{
protocol: 'http',
hostname: 'oss.wsdns.cn',
port: '',
pathname: '/**',
},
// API 服务器图片
{
protocol: 'https',
hostname: 'cms-api.websoft.top',
port: '',
pathname: '/**',
},
// 其他常见 CDN 域名...
],
},
};
export default nextConfig;
```
## 配置参数说明
### remotePatterns 配置项
- **protocol**: 协议类型 (`'http'` 或 `'https'`)
- **hostname**: 域名 (支持通配符,如 `*.example.com`)
- **port**: 端口号 (通常为空字符串)
- **pathname**: 路径模式 (通常使用 `'/**'` 匹配所有路径)
### 通配符支持
```typescript
{
protocol: 'https',
hostname: '*.aliyuncs.com', // 匹配所有阿里云 OSS 子域名
pathname: '/**',
}
```
## 已配置的域名
当前项目已配置以下图片域名:
### 主要业务域名
- `oss.wsdns.cn` - OSS 存储域名
- `cms-api.websoft.top` - API 服务器图片
### 常见 CDN 域名
- `*.aliyuncs.com` - 阿里云 OSS
- `*.qiniudn.com` - 七牛云 CDN
- `*.qiniu.com` - 七牛云新域名
### 开发环境
- `localhost` - 本地开发
## 使用示例
配置完成后,可以在组件中正常使用外部图片:
```tsx
import Image from 'next/image';
function ArticleImage({ article }) {
return (
<Image
src={article.image || '/placeholder.jpg'}
alt={article.title || '文章图片'}
width={400}
height={200}
className="article-image"
/>
);
}
```
## 安全注意事项
1. **最小权限原则**: 只配置必要的域名,避免使用过于宽泛的通配符
2. **HTTPS 优先**: 优先使用 HTTPS 协议的域名
3. **路径限制**: 如果可能,限制特定的路径模式而不是使用 `/**`
### 推荐的安全配置
```typescript
{
protocol: 'https',
hostname: 'oss.wsdns.cn',
pathname: '/images/**', // 只允许 /images/ 路径下的图片
}
```
## 故障排除
### 1. 配置不生效
**解决方案**: 修改 `next.config.ts` 后需要重启开发服务器:
```bash
# 停止当前服务器 (Ctrl+C)
npm run dev
```
### 2. 通配符不工作
**原因**: 确保通配符语法正确,只能在域名的开头使用 `*`
```typescript
// ✅ 正确
hostname: '*.example.com'
// ❌ 错误
hostname: 'cdn.*.example.com'
```
### 3. 图片仍然无法加载
**检查步骤**:
1. 确认域名拼写正确
2. 检查协议是否匹配 (http vs https)
3. 验证图片 URL 是否可访问
4. 查看浏览器控制台错误信息
## 性能优化建议
1. **图片格式**: 使用现代图片格式 (WebP, AVIF)
2. **尺寸优化**: 配置合适的 `width``height`
3. **懒加载**: Next.js Image 组件默认启用懒加载
4. **优先级**: 对重要图片使用 `priority` 属性
```tsx
<Image
src="/hero-image.jpg"
alt="Hero"
width={1200}
height={600}
priority // 优先加载
/>
```
## 相关文档
- [Next.js Image Optimization](https://nextjs.org/docs/basic-features/image-optimization)
- [Next.js Configuration](https://nextjs.org/docs/api-reference/next.config.js/images)

161
docs/NEXT_CONFIG_IMAGES_SUMMARY.md

@ -0,0 +1,161 @@
# Next.js 图片域名配置修复总结
## 问题描述
在使用 Next.js 的 `Image` 组件加载外部图片时,出现以下错误:
```
hostname "oss.wsdns.cn" is not configured under images in your `next.config`
```
## 解决方案
### 1. 修改 `next.config.ts` 文件
在 Next.js 配置文件中添加了 `images.remotePatterns` 配置:
```typescript
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
// OSS 图片域名
{
protocol: 'https',
hostname: 'oss.wsdns.cn',
port: '',
pathname: '/**',
},
{
protocol: 'http',
hostname: 'oss.wsdns.cn',
port: '',
pathname: '/**',
},
// API 服务器图片
{
protocol: 'https',
hostname: 'cms-api.websoft.top',
port: '',
pathname: '/**',
},
{
protocol: 'http',
hostname: 'cms-api.websoft.top',
port: '',
pathname: '/**',
},
// 常见的图片 CDN 域名
{
protocol: 'https',
hostname: '*.aliyuncs.com',
port: '',
pathname: '/**',
},
{
protocol: 'https',
hostname: '*.qiniudn.com',
port: '',
pathname: '/**',
},
{
protocol: 'https',
hostname: '*.qiniu.com',
port: '',
pathname: '/**',
},
// 本地开发
{
protocol: 'http',
hostname: 'localhost',
port: '',
pathname: '/**',
},
{
protocol: 'https',
hostname: 'localhost',
port: '',
pathname: '/**',
},
],
},
};
export default nextConfig;
```
### 2. 配置的域名列表
已配置的图片域名包括:
#### 主要业务域名
- `oss.wsdns.cn` - OSS 存储域名(HTTP/HTTPS)
- `cms-api.websoft.top` - API 服务器图片(HTTP/HTTPS)
#### 常见 CDN 域名
- `*.aliyuncs.com` - 阿里云 OSS
- `*.qiniudn.com` - 七牛云 CDN
- `*.qiniu.com` - 七牛云新域名
#### 开发环境
- `localhost` - 本地开发(HTTP/HTTPS)
### 3. 重启服务器
修改 `next.config.ts` 后需要重启开发服务器:
```bash
# 停止当前服务器 (Ctrl+C)
npm run dev
```
## 修复效果
### ✅ 已解决的问题
1. **外部图片加载**:现在可以正常加载来自 `oss.wsdns.cn` 的图片
2. **API 图片支持**:支持从 `cms-api.websoft.top` 加载图片
3. **CDN 兼容性**:支持常见的 CDN 域名
4. **开发环境**:支持本地开发时的图片加载
### ⚠️ 注意事项
1. **404 错误正常**:日志中的 `/hero-image.jpg`、`/about-image.jpg` 等 404 错误是正常的,因为这些本地图片文件不存在
2. **Tailwind 警告**:`px-6` 等 Tailwind 类的警告不影响图片功能
3. **配置生效**:服务器已自动重启并应用了新配置
## 使用示例
现在可以在组件中正常使用外部图片:
```tsx
import Image from 'next/image';
function ArticleImage({ article }) {
return (
<Image
src={article.image || '/placeholder.jpg'} // 支持 oss.wsdns.cn 域名
alt={article.title || '文章图片'}
width={400}
height={200}
className="article-image"
/>
);
}
```
## 安全考虑
1. **最小权限**:只配置了必要的域名
2. **协议支持**:同时支持 HTTP 和 HTTPS
3. **路径限制**:使用 `/**` 允许所有路径(可根据需要进一步限制)
## 相关文档
- `docs/IMAGE_DOMAINS_CONFIG.md` - 详细的图片域名配置说明
- [Next.js Image Optimization](https://nextjs.org/docs/basic-features/image-optimization)
## 状态
**已完成** - 图片域名配置问题已解决,外部图片可以正常加载

190
src/api/README-TENANT.md

@ -0,0 +1,190 @@
# 租户 ID 配置和使用说明
## 概述
本项目的 API 请求系统已经集成了租户 ID 功能,所有 API 请求都会自动添加租户 ID,无需手动处理。
## 配置方式
### 1. 环境变量配置(推荐)
在项目根目录的 `.env.local` 文件中配置:
```env
# 租户配置
NEXT_PUBLIC_TENANT_ID=1001
```
### 2. 配置文件
租户 ID 在 `src/config/setting.ts` 中定义:
```typescript
export const APP_CONFIG = {
// 租户ID配置
TENANT_ID: process.env.NEXT_PUBLIC_TENANT_ID || '10001',
// ... 其他配置
} as const;
```
## 自动添加机制
### 1. 请求头
所有请求都会自动添加 `TenantId` 请求头:
```
TenantId: 1001
```
### 2. GET 请求查询参数
GET 请求会自动在查询参数中添加 `tenantId`
```
GET /api/users?page=1&limit=10&tenantId=1001
```
### 3. POST/PUT/PATCH 请求体
POST、PUT、PATCH 请求会自动在请求体中添加 `tenantId`
```json
{
"name": "用户名",
"email": "user@example.com",
"tenantId": "1001"
}
```
## 使用示例
### 基础使用
```typescript
import { request } from '@/api';
// GET 请求 - 自动添加租户ID到查询参数
const users = await request.get('/users', { page: 1, limit: 10 });
// POST 请求 - 自动添加租户ID到请求体
const newUser = await request.post('/users', {
name: '张三',
email: 'zhangsan@example.com'
});
// PUT 请求 - 自动添加租户ID到请求体
const updatedUser = await request.put('/users/1', {
name: '李四'
});
// DELETE 请求 - 自动添加租户ID到请求头
await request.delete('/users/1');
```
### 兼容现有代码
如果使用 `src/utils/request.ts` 中的兼容性封装:
```typescript
import request from '@/utils/request';
// 所有请求都会自动添加租户ID
const response = await request.get('/users');
const { data } = response; // ApiResult<T> 格式
```
## 特殊情况处理
### 1. FormData 请求
对于 FormData 类型的请求,租户 ID 只会添加到请求头中,不会修改 FormData 内容:
```typescript
const formData = new FormData();
formData.append('file', file);
// 只在请求头中添加 TenantId,不修改 FormData
await request.post('/upload', formData);
```
### 2. 覆盖租户 ID
如果需要使用不同的租户 ID,可以在请求参数中显式指定:
```typescript
// GET 请求中覆盖
await request.get('/users', { tenantId: '2001' });
// POST 请求中覆盖
await request.post('/users', {
name: '张三',
tenantId: '2001'
});
```
### 3. 禁用自动添加
目前不支持禁用自动添加租户 ID。如果有特殊需求,请联系开发团队。
## 环境配置
### 开发环境
```env
NEXT_PUBLIC_TENANT_ID=1001
```
### 测试环境
```env
NEXT_PUBLIC_TENANT_ID=2001
```
### 生产环境
```env
NEXT_PUBLIC_TENANT_ID=3001
```
## 注意事项
1. **环境变量优先级**:环境变量 > 配置文件默认值
2. **客户端可见**:使用 `NEXT_PUBLIC_` 前缀的环境变量在客户端可见
3. **类型安全**:租户 ID 统一使用字符串类型
4. **自动重载**:修改 `.env.local` 文件后,开发服务器会自动重载
5. **部署配置**:生产环境部署时需要正确配置环境变量
## 故障排除
### 1. 租户 ID 未生效
检查环境变量是否正确配置:
```bash
# 检查环境变量
echo $NEXT_PUBLIC_TENANT_ID
```
### 2. 请求中看不到租户 ID
使用浏览器开发者工具检查:
- Network 标签页查看请求头和请求体
- 确认 `TenantId` 请求头是否存在
- 确认查询参数或请求体中是否包含 `tenantId`
### 3. 类型错误
确保 TypeScript 配置正确,重启开发服务器:
```bash
npm run dev
```
## 相关文件
- `src/config/setting.ts` - 配置文件
- `src/api/index.ts` - API 核心实现
- `src/utils/request.ts` - 兼容性封装
- `src/api/examples/tenant-api-usage.ts` - 使用示例
- `.env.local` - 环境变量配置

145
src/api/examples/tenant-api-usage.ts

@ -0,0 +1,145 @@
/**
* API 使
* 使 ID API
*/
import { request } from '@/api';
import type { ApiResult, PageResult, PageParam } from '@/api';
// ==================== 示例数据类型 ====================
interface User {
id: number;
name: string;
email: string;
tenantId: string;
createTime: string;
}
interface CreateUserData {
name: string;
email: string;
password: string;
}
interface UpdateUserData {
name?: string;
email?: string;
}
// ==================== API 使用示例 ====================
/**
* - GET
* ID
*/
export const getUserList = async (params?: PageParam): Promise<ApiResult<PageResult<User>>> => {
return request.get<PageResult<User>>('/users', params);
};
/**
* - GET
* ID
*/
export const getUserById = async (id: number): Promise<ApiResult<User>> => {
return request.get<User>(`/users/${id}`);
};
/**
* - POST
* ID
*/
export const createUser = async (data: CreateUserData): Promise<ApiResult<User>> => {
return request.post<User>('/users', data);
};
/**
* - PUT
* ID
*/
export const updateUser = async (id: number, data: UpdateUserData): Promise<ApiResult<User>> => {
return request.put<User>(`/users/${id}`, data);
};
/**
* - PATCH
* ID
*/
export const patchUser = async (id: number, data: Partial<UpdateUserData>): Promise<ApiResult<User>> => {
return request.patch<User>(`/users/${id}`, data);
};
/**
* - DELETE
* ID
*/
export const deleteUser = async (id: number): Promise<ApiResult<void>> => {
return request.delete<void>(`/users/${id}`);
};
// ==================== 使用示例 ====================
/**
* 使
*/
export const exampleUsage = async () => {
try {
// 获取用户列表
const userListResult = await getUserList({
page: 1,
limit: 10,
keywords: '张三'
});
if (userListResult.code === 0) {
console.log('用户列表:', userListResult.data?.list);
}
// 创建用户
const createResult = await createUser({
name: '新用户',
email: 'newuser@example.com',
password: '123456'
});
if (createResult.code === 0) {
console.log('创建成功:', createResult.data);
}
// 更新用户
if (createResult.data?.id) {
const updateResult = await updateUser(createResult.data.id, {
name: '更新后的用户名'
});
if (updateResult.code === 0) {
console.log('更新成功:', updateResult.data);
}
}
} catch (error) {
console.error('API 请求失败:', error);
}
};
// ==================== 注意事项 ====================
/**
* ID
*
* 1. GET ID
* /users?page=1&limit=10&tenantId=1001
*
* 2. POST/PUT/PATCH ID
* { name: '用户名', email: 'email@example.com', tenantId: '10001' }
*
* 3. ID
* TenantId: 1001
*
* 4. ID
* - NEXT_PUBLIC_TENANT_ID
* - APP_CONFIG.TENANT_ID
* - '10001'
*
* 5. ID
*/

390
src/api/modules/article.ts

@ -0,0 +1,390 @@
/**
* API
*/
import { request, type ApiResult, type PageResult, type PageParam } from '@/api';
// ==================== 类型定义 ====================
/**
*
*/
export interface Article {
id: number;
title: string;
content: string;
excerpt?: string;
thumbnail?: string;
author: string;
authorId: number;
categoryId?: number;
categoryName?: string;
tags?: string[];
status: 'draft' | 'published' | 'archived';
viewCount: number;
likeCount: number;
commentCount: number;
isTop: boolean;
isRecommend: boolean;
publishTime?: string;
createTime: string;
updateTime: string;
seoTitle?: string;
seoKeywords?: string;
seoDescription?: string;
}
/**
*
*/
export interface ArticleCategory {
id: number;
name: string;
slug: string;
description?: string;
parentId?: number;
sortOrder: number;
articleCount: number;
status: number;
createTime: string;
children?: ArticleCategory[];
}
/**
*
*/
export interface ArticleTag {
id: number;
name: string;
slug: string;
color?: string;
articleCount: number;
createTime: string;
}
/**
*
*/
export interface ArticleSearchParams extends PageParam {
title?: string;
categoryId?: number;
authorId?: number;
status?: 'draft' | 'published' | 'archived';
isTop?: boolean;
isRecommend?: boolean;
tags?: string[];
startTime?: string;
endTime?: string;
}
/**
*
*/
export interface CreateArticleParams {
title: string;
content: string;
excerpt?: string;
thumbnail?: string;
categoryId?: number;
tags?: string[];
status?: 'draft' | 'published';
isTop?: boolean;
isRecommend?: boolean;
publishTime?: string;
seoTitle?: string;
seoKeywords?: string;
seoDescription?: string;
}
/**
*
*/
export interface UpdateArticleParams extends CreateArticleParams {
id: number;
}
// ==================== API 方法 ====================
/**
* API
*/
export class ArticleApi {
/**
*
*/
static async getArticles(params?: ArticleSearchParams): Promise<ApiResult<PageResult<Article>>> {
return request.get<PageResult<Article>>('/cms/cms-article/page', params);
}
/**
*
*/
static async getRecommendArticles(limit = 6): Promise<ApiResult<Article[]>> {
return request.get<Article[]>('/cms/cms-article/recommend', { limit });
}
/**
*
*/
static async getHotArticles(limit = 10): Promise<ApiResult<Article[]>> {
return request.get<Article[]>('/cms/cms-article/hot', { limit });
}
/**
*
*/
static async getLatestArticles(limit = 10): Promise<ApiResult<Article[]>> {
return request.get<Article[]>('/cms/cms-article', { limit });
}
/**
* ID
*/
static async getArticleById(id: number): Promise<ApiResult<Article>> {
return request.get<Article>(`/cms/cms-article/${id}`);
}
/**
*
*/
static async getArticlesByCategory(
categoryId: number,
params?: Omit<ArticleSearchParams, 'categoryId'>
): Promise<ApiResult<PageResult<Article>>> {
return request.get<PageResult<Article>>('/cms/cms-article/page', {
...params,
categoryId,
});
}
/**
*
*/
static async getArticlesByTag(
tag: string,
params?: Omit<ArticleSearchParams, 'tags'>
): Promise<ApiResult<PageResult<Article>>> {
return request.get<PageResult<Article>>('/cms/cms-article/page', {
...params,
tags: [tag],
});
}
/**
*
*/
static async searchArticles(
keyword: string,
params?: ArticleSearchParams
): Promise<ApiResult<PageResult<Article>>> {
return request.get<PageResult<Article>>('/cms/cms-article/search', {
...params,
keywords: keyword,
});
}
/**
*
*/
static async getRelatedArticles(id: number, limit = 5): Promise<ApiResult<Article[]>> {
return request.get<Article[]>(`/cms/cms-article/${id}/related`, { limit });
}
/**
*
*/
static async increaseViewCount(id: number): Promise<ApiResult<void>> {
return request.post<void>(`/cms/cms-article/${id}/view`);
}
/**
*
*/
static async likeArticle(id: number): Promise<ApiResult<void>> {
return request.post<void>(`/cms/cms-article/${id}/like`);
}
/**
*
*/
static async unlikeArticle(id: number): Promise<ApiResult<void>> {
return request.delete<void>(`/cms/cms-article/${id}/like`);
}
/**
*
*/
static async createArticle(params: CreateArticleParams): Promise<ApiResult<string>> {
return request.post<string>('/cms/cms-article', params);
}
/**
*
*/
static async updateArticle(params: UpdateArticleParams): Promise<ApiResult<string>> {
return request.put<string>('/cms/cms-article', params);
}
/**
*
*/
static async deleteArticle(id: number): Promise<ApiResult<string>> {
return request.delete<string>(`/cms/cms-article/${id}`);
}
/**
*
*/
static async batchDeleteArticles(ids: number[]): Promise<ApiResult<string>> {
return request.post<string>('/cms/cms-article/batch-delete', ids);
}
/**
*
*/
static async publishArticle(id: number): Promise<ApiResult<string>> {
return request.post<string>(`/cms/cms-article/${id}/publish`);
}
/**
*
*/
static async unpublishArticle(id: number): Promise<ApiResult<string>> {
return request.post<string>(`/cms/cms-article/${id}/unpublish`);
}
}
/**
* API
*/
export class ArticleCategoryApi {
/**
*
*/
static async getCategories(): Promise<ApiResult<ArticleCategory[]>> {
return request.get<ArticleCategory[]>('/cms/cms-article-category');
}
/**
*
*/
static async getCategoryTree(): Promise<ApiResult<ArticleCategory[]>> {
return request.get<ArticleCategory[]>('/cms/cms-article-category/tree');
}
/**
* ID
*/
static async getCategoryById(id: number): Promise<ApiResult<ArticleCategory>> {
return request.get<ArticleCategory>(`/cms/cms-article-category/${id}`);
}
/**
*
*/
static async createCategory(params: {
name: string;
slug?: string;
description?: string;
parentId?: number;
sortOrder?: number;
}): Promise<ApiResult<string>> {
return request.post<string>('/cms/cms-article-category', params);
}
/**
*
*/
static async updateCategory(params: {
id: number;
name?: string;
slug?: string;
description?: string;
parentId?: number;
sortOrder?: number;
}): Promise<ApiResult<string>> {
return request.put<string>('/cms/cms-article-category', params);
}
/**
*
*/
static async deleteCategory(id: number): Promise<ApiResult<string>> {
return request.delete<string>(`/cms/cms-article-category/${id}`);
}
}
/**
* API
*/
export class ArticleTagApi {
/**
*
*/
static async getTags(): Promise<ApiResult<ArticleTag[]>> {
return request.get<ArticleTag[]>('/cms/cms-article-tag');
}
/**
*
*/
static async getHotTags(limit = 20): Promise<ApiResult<ArticleTag[]>> {
return request.get<ArticleTag[]>('/cms/cms-article-tag/hot', { limit });
}
/**
* ID
*/
static async getTagById(id: number): Promise<ApiResult<ArticleTag>> {
return request.get<ArticleTag>(`/cms/cms-article-tag/${id}`);
}
/**
*
*/
static async searchTags(keyword: string): Promise<ApiResult<ArticleTag[]>> {
return request.get<ArticleTag[]>('/cms/cms-article-tag/search', { keyword });
}
}
// ==================== 便捷方法导出 ====================
export const {
getArticles,
getRecommendArticles,
getHotArticles,
getLatestArticles,
getArticleById,
getArticlesByCategory,
getArticlesByTag,
searchArticles,
getRelatedArticles,
increaseViewCount,
likeArticle,
unlikeArticle,
createArticle,
updateArticle,
deleteArticle,
batchDeleteArticles,
publishArticle,
unpublishArticle,
} = ArticleApi;
export const {
getCategories,
getCategoryTree,
getCategoryById,
createCategory,
updateCategory,
deleteCategory,
} = ArticleCategoryApi;
export const {
getTags,
getHotTags,
getTagById,
searchTags,
} = ArticleTagApi;
// 默认导出
export default ArticleApi;

358
src/app/articles/[id]/page.tsx

@ -0,0 +1,358 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import Image from 'next/image';
import { ArticleApi, type Article } from '@/api/modules/article';
const ArticleDetailPage = () => {
const params = useParams();
const articleId = parseInt(params.id as string);
const [article, setArticle] = useState<Article | null>(null);
const [relatedArticles, setRelatedArticles] = useState<Article[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [liked, setLiked] = useState(false);
useEffect(() => {
if (articleId) {
fetchArticle();
fetchRelatedArticles();
}
}, [articleId]);
const fetchArticle = async () => {
try {
setLoading(true);
setError(null);
// 增加浏览量
await ArticleApi.increaseViewCount(articleId);
// 获取文章详情
const response = await ArticleApi.getArticleById(articleId);
if (response.code === 0 && response.data) {
setArticle(response.data);
} else {
setError(response.message || '文章不存在');
}
} catch (error) {
console.error('获取文章失败:', error);
setError('网络错误,请稍后重试');
// 设置模拟文章数据
setArticle({
id: articleId,
title: '企业数字化转型的关键要素与实施策略',
content: `
<h2></h2>
<p></p>
<h2></h2>
<h3>1. </h3>
<p></p>
<ul>
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
<h3>2. </h3>
<p>IT架构</p>
<ul>
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
<h3>3. </h3>
<p></p>
<ul>
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
<h2></h2>
<h3></h3>
<p></p>
<ol>
<li><strong></strong></li>
<li><strong></strong></li>
<li><strong></strong></li>
<li><strong>广</strong>广</li>
<li><strong></strong></li>
</ol>
<h2></h2>
<p>30%50%</p>
<h2></h2>
<p></p>
`,
excerpt: '在数字化时代,企业需要从战略、技术、人才等多个维度进行全面转型,以适应快速变化的市场环境。本文深入分析了企业数字化转型的关键要素和实施策略。',
thumbnail: '/article-detail.jpg',
author: '张三',
authorId: 1,
categoryId: 1,
categoryName: '行业洞察',
tags: ['数字化转型', '企业管理', '技术创新'],
status: 'published',
viewCount: 1250,
likeCount: 89,
commentCount: 23,
isTop: false,
isRecommend: true,
publishTime: '2024-01-15T10:30:00Z',
createTime: '2024-01-15T10:30:00Z',
updateTime: '2024-01-15T10:30:00Z',
seoTitle: '企业数字化转型的关键要素与实施策略 - 专业指南',
seoKeywords: '数字化转型,企业管理,技术创新,战略规划',
seoDescription: '深入解析企业数字化转型的核心要素,提供实用的实施策略和成功案例分析,助力企业在数字时代取得成功。'
});
} finally {
setLoading(false);
}
};
const fetchRelatedArticles = async () => {
try {
const response = await ArticleApi.getRelatedArticles(articleId, 4);
if (response.code === 0 && response.data) {
setRelatedArticles(response.data);
}
} catch (error) {
console.error('获取相关文章失败:', error);
// 设置模拟相关文章数据
setRelatedArticles([]);
}
};
const handleLike = async () => {
try {
if (liked) {
await ArticleApi.unlikeArticle(articleId);
setLiked(false);
if (article) {
setArticle({ ...article, likeCount: article.likeCount - 1 });
}
} else {
await ArticleApi.likeArticle(articleId);
setLiked(true);
if (article) {
setArticle({ ...article, likeCount: article.likeCount + 1 });
}
}
} catch (error) {
console.error('点赞操作失败:', error);
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const formatNumber = (num: number) => {
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'k';
}
return num.toString();
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50">
<div className="container py-12">
<div className="max-w-4xl mx-auto">
<div className="animate-pulse">
<div className="h-8 bg-gray-300 rounded mb-4"></div>
<div className="h-6 bg-gray-300 rounded mb-8 w-3/4"></div>
<div className="h-64 bg-gray-300 rounded mb-8"></div>
<div className="space-y-4">
<div className="h-4 bg-gray-300 rounded"></div>
<div className="h-4 bg-gray-300 rounded"></div>
<div className="h-4 bg-gray-300 rounded w-5/6"></div>
</div>
</div>
</div>
</div>
</div>
);
}
if (error || !article) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="text-6xl text-gray-400 mb-4">404</div>
<h1 className="text-2xl font-bold text-gray-900 mb-4"></h1>
<p className="text-gray-600 mb-8">{error || '您访问的文章可能已被删除或不存在'}</p>
<Link href="/articles" className="btn btn-primary">
</Link>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
{/* 面包屑导航 */}
<div className="bg-white border-b border-gray-200">
<div className="container py-4">
<nav className="flex items-center space-x-2 text-sm text-gray-600">
<Link href="/" className="hover:text-blue-600"></Link>
<span>/</span>
<Link href="/articles" className="hover:text-blue-600"></Link>
<span>/</span>
<span className="text-gray-900">{article.title}</span>
</nav>
</div>
</div>
<div className="container py-12">
<div className="max-w-4xl mx-auto">
{/* 文章头部 */}
<header className="bg-white rounded-lg shadow-sm p-8 mb-8">
{/* 分类标签 */}
{article.categoryName && (
<div className="mb-4">
<span className="inline-block px-3 py-1 text-sm font-medium bg-blue-100 text-blue-800 rounded-full">
{article.categoryName}
</span>
</div>
)}
{/* 文章标题 */}
<h1 className="text-3xl lg:text-4xl font-bold text-gray-900 mb-6">
{article.title}
</h1>
{/* 文章元信息 */}
<div className="flex flex-wrap items-center justify-between text-gray-600 mb-6">
<div className="flex items-center space-x-6">
<span className="flex items-center">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
{article.author}
</span>
<span className="flex items-center">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{formatDate(article.createTime)}
</span>
</div>
<div className="flex items-center space-x-4">
<span className="flex items-center">
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
{formatNumber(article.viewCount)}
</span>
<button
onClick={handleLike}
className={`flex items-center transition-colors duration-300 ${
liked ? 'text-red-500' : 'text-gray-600 hover:text-red-500'
}`}
>
<svg className="w-5 h-5 mr-1" fill={liked ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
{formatNumber(article.likeCount)}
</button>
</div>
</div>
{/* 文章摘要 */}
{article.excerpt && (
<div className="bg-blue-50 border-l-4 border-blue-400 p-4 mb-6">
<p className="text-blue-800 font-medium">{article.excerpt}</p>
</div>
)}
{/* 标签 */}
{article.tags && article.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{article.tags.map((tag, index) => (
<span
key={index}
className="inline-block px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-full"
>
#{tag}
</span>
))}
</div>
)}
</header>
{/* 文章内容 */}
<article className="bg-white rounded-lg shadow-sm p-8 mb-8">
<div
className="prose prose-lg max-w-none"
dangerouslySetInnerHTML={{ __html: article.content }}
/>
</article>
{/* 相关文章 */}
{relatedArticles.length > 0 && (
<section className="bg-white rounded-lg shadow-sm p-8">
<h3 className="text-2xl font-bold mb-6"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{relatedArticles.map((relatedArticle) => (
<Link
key={relatedArticle.id}
href={`/articles/${relatedArticle.id}`}
className="flex space-x-4 p-4 rounded-lg hover:bg-gray-50 transition-colors duration-300"
>
<Image
src={relatedArticle.thumbnail || '/placeholder-article.jpg'}
alt={relatedArticle.title}
width={120}
height={80}
className="rounded-lg object-cover flex-shrink-0"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTIwIiBoZWlnaHQ9IjgwIiB2aWV3Qm94PSIwIDAgMTIwIDgwIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cmVjdCB3aWR0aD0iMTIwIiBoZWlnaHQ9IjgwIiBmaWxsPSIjRjNGNEY2Ii8+CjxwYXRoIGQ9Ik01MCAzMEg3MFY1MEg1MFYzMFoiIGZpbGw9IiM5Q0EzQUYiLz4KPHA+CjxwYXRoIGQ9Ik00MCA0MEg4MFY0MEg0MFoiIGZpbGw9IiM5Q0EzQUYiLz4KPC9zdmc+Cg==';
}}
/>
<div className="flex-1">
<h4 className="font-semibold text-gray-900 mb-2 line-clamp-2">
{relatedArticle.title}
</h4>
<p className="text-sm text-gray-600 mb-2 line-clamp-2">
{relatedArticle.excerpt}
</p>
<div className="flex items-center justify-between text-xs text-gray-500">
<span>{relatedArticle.categoryName}</span>
<span>{formatDate(relatedArticle.createTime)}</span>
</div>
</div>
</Link>
))}
</div>
</section>
)}
</div>
</div>
</div>
);
};
export default ArticleDetailPage;

380
src/app/articles/page.tsx

@ -0,0 +1,380 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { ArticleApi, ArticleCategoryApi, type Article, type ArticleCategory } from '@/api/modules/article';
const ArticlesPage = () => {
const [articles, setArticles] = useState<Article[]>([]);
const [categories, setCategories] = useState<ArticleCategory[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [selectedCategory, setSelectedCategory] = useState<number | null>(null);
const [searchKeyword, setSearchKeyword] = useState('');
const pageSize = 12;
useEffect(() => {
fetchCategories();
}, []);
useEffect(() => {
fetchArticles();
}, [currentPage, selectedCategory, searchKeyword]);
const fetchCategories = async () => {
try {
const response = await ArticleCategoryApi.getCategories();
if (response.code === 0 && response.data) {
setCategories(response.data);
}
} catch (error) {
console.error('获取分类失败:', error);
// 设置模拟分类数据
setCategories([
{ id: 1, name: '行业洞察', slug: 'industry', description: '', parentId: 0, sortOrder: 1, articleCount: 15, status: 1, createTime: '2024-01-01T00:00:00Z' },
{ id: 2, name: '技术分享', slug: 'tech', description: '', parentId: 0, sortOrder: 2, articleCount: 23, status: 1, createTime: '2024-01-01T00:00:00Z' },
{ id: 3, name: '前沿科技', slug: 'innovation', description: '', parentId: 0, sortOrder: 3, articleCount: 18, status: 1, createTime: '2024-01-01T00:00:00Z' },
{ id: 4, name: '企业动态', slug: 'news', description: '', parentId: 0, sortOrder: 4, articleCount: 12, status: 1, createTime: '2024-01-01T00:00:00Z' },
]);
}
};
const fetchArticles = async () => {
try {
setLoading(true);
setError(null);
let response;
if (searchKeyword) {
response = await ArticleApi.searchArticles(searchKeyword, {
page: currentPage,
limit: pageSize,
categoryId: selectedCategory || undefined,
status: 'published',
});
} else {
response = await ArticleApi.getArticles();
}
if (response.code === 0 && response.data) {
setArticles(response.data.list);
setTotalPages(Math.ceil(response.data.count / pageSize));
} else {
setError(response.message || '获取文章失败');
}
} catch (error) {
console.error('获取文章失败:', error);
setError('网络错误,请稍后重试');
// 设置模拟文章数据
const mockArticles: Article[] = Array.from({ length: pageSize }, (_, index) => ({
id: (currentPage - 1) * pageSize + index + 1,
title: `文章标题 ${(currentPage - 1) * pageSize + index + 1}`,
content: '这是文章的详细内容...',
excerpt: '这是文章的摘要内容,介绍了文章的主要观点和核心内容...',
thumbnail: `/article-${(index % 3) + 1}.jpg`,
author: ['张三', '李四', '王五'][index % 3],
authorId: (index % 3) + 1,
categoryId: selectedCategory || ((index % 4) + 1),
categoryName: categories.find(c => c.id === (selectedCategory || ((index % 4) + 1)))?.name || '默认分类',
tags: ['标签1', '标签2'],
status: 'published',
viewCount: Math.floor(Math.random() * 2000) + 100,
likeCount: Math.floor(Math.random() * 200) + 10,
commentCount: Math.floor(Math.random() * 50) + 1,
isTop: index === 0,
isRecommend: index < 3,
createTime: new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000).toISOString(),
updateTime: new Date().toISOString(),
}));
setArticles(mockArticles);
setTotalPages(5); // 模拟总页数
} finally {
setLoading(false);
}
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setCurrentPage(1);
fetchArticles();
};
const handleCategoryChange = (categoryId: number | null) => {
setSelectedCategory(categoryId);
setCurrentPage(1);
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
const formatNumber = (num: number) => {
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'k';
}
return num.toString();
};
return (
<div className="min-h-screen bg-gray-50">
{/* 页面头部 */}
<div className="bg-white border-b border-gray-200">
<div className="container py-12">
<div className="text-center">
<h1 className="text-4xl font-bold text-gray-900 mb-4"></h1>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
沿
</p>
</div>
</div>
</div>
<div className="container py-12">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
{/* 侧边栏 */}
<div className="lg:col-span-1">
{/* 搜索框 */}
<div className="bg-white rounded-lg shadow-sm p-6 mb-8">
<h3 className="text-lg font-semibold mb-4"></h3>
<form onSubmit={handleSearch}>
<div className="flex">
<input
type="text"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
placeholder="输入关键词..."
className="flex-1 px-4 py-2 border border-gray-300 rounded-l-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-r-lg hover:bg-blue-700 transition-colors duration-300"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
</div>
</form>
</div>
{/* 分类筛选 */}
<div className="bg-white rounded-lg shadow-sm p-6">
<h3 className="text-lg font-semibold mb-4"></h3>
<ul className="space-y-2">
<li>
<button
onClick={() => handleCategoryChange(null)}
className={`w-full text-left px-3 py-2 rounded-lg transition-colors duration-300 ${
selectedCategory === null
? 'bg-blue-100 text-blue-600'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
</button>
</li>
{categories.map((category) => (
<li key={category.id}>
<button
onClick={() => handleCategoryChange(category.id)}
className={`w-full text-left px-3 py-2 rounded-lg transition-colors duration-300 flex justify-between items-center ${
selectedCategory === category.id
? 'bg-blue-100 text-blue-600'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<span>{category.name}</span>
<span className="text-sm text-gray-500">({category.articleCount})</span>
</button>
</li>
))}
</ul>
</div>
</div>
{/* 主内容区 */}
<div className="lg:col-span-3">
{/* 筛选信息 */}
<div className="flex justify-between items-center mb-8">
<div className="text-gray-600">
{selectedCategory && (
<span>
<span className="text-blue-600 font-medium">
{categories.find(c => c.id === selectedCategory)?.name}
</span>
</span>
)}
{searchKeyword && (
<span className="ml-4">
<span className="text-blue-600 font-medium">"{searchKeyword}"</span>
</span>
)}
</div>
<div className="text-gray-600">
{articles.length}
</div>
</div>
{/* 错误提示 */}
{error && (
<div className="bg-yellow-50 border border-yellow-200 text-yellow-800 px-4 py-3 rounded-lg mb-8">
<p>{error}</p>
<p className="text-sm mt-2"></p>
</div>
)}
{/* 文章列表 */}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="article-card animate-pulse">
<div className="w-full h-48 bg-gray-300"></div>
<div className="article-content">
<div className="h-4 bg-gray-300 rounded mb-3"></div>
<div className="h-6 bg-gray-300 rounded mb-3"></div>
<div className="h-4 bg-gray-300 rounded mb-4"></div>
<div className="h-4 bg-gray-300 rounded"></div>
</div>
</div>
))}
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8">
{articles.map((article, index) => (
<article
key={article.id}
className="article-card fade-in-up"
style={{ animationDelay: `${index * 0.1}s` }}
>
{/* 文章图片 */}
<div className="relative overflow-hidden">
<Image
src={article.thumbnail || '/placeholder-article.jpg'}
alt={article.title}
width={400}
height={200}
className="article-image hover:scale-105 transition-transform duration-300"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjIwMCIgdmlld0JveD0iMCAwIDQwMCAyMDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSI0MDAiIGhlaWdodD0iMjAwIiBmaWxsPSIjRjNGNEY2Ii8+CjxwYXRoIGQ9Ik0xNzUgNzVIMjI1VjEyNUgxNzVWNzVaIiBmaWxsPSIjOUNBM0FGIi8+CjxwYXRoIGQ9Ik0xNTAgMTAwSDI1MFYxMDBIMTUwWiIgZmlsbD0iIzlDQTNBRiIvPgo8L3N2Zz4K';
}}
/>
{/* 置顶标签 */}
{article.isTop && (
<div className="absolute top-4 left-4 bg-red-500 text-white px-2 py-1 text-xs rounded">
</div>
)}
{/* 推荐标签 */}
{article.isRecommend && (
<div className="absolute top-4 right-4 bg-blue-500 text-white px-2 py-1 text-xs rounded">
</div>
)}
</div>
{/* 文章内容 */}
<div className="article-content">
{/* 分类标签 */}
{article.categoryName && (
<span className="article-category">
{article.categoryName}
</span>
)}
{/* 文章标题 */}
<h3 className="article-title">
<Link href={`/articles/${article.id}`}>
{article.title}
</Link>
</h3>
{/* 文章摘要 */}
<p className="article-excerpt">
{article.excerpt || article.content?.substring(0, 100) + '...'}
</p>
{/* 文章元信息 */}
<div className="article-meta">
<div className="flex items-center space-x-4">
<span className="flex items-center">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
{formatNumber(article.viewCount)}
</span>
<span className="flex items-center">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
{formatNumber(article.likeCount)}
</span>
</div>
<span>{formatDate(article.createTime)}</span>
</div>
</div>
</article>
))}
</div>
{/* 分页 */}
{totalPages > 1 && (
<div className="flex justify-center mt-12">
<nav className="flex items-center space-x-2">
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<button
key={page}
onClick={() => setCurrentPage(page)}
className={`px-3 py-2 text-sm font-medium rounded-md ${
currentPage === page
? 'text-blue-600 bg-blue-50 border border-blue-300'
: 'text-gray-500 bg-white border border-gray-300 hover:bg-gray-50'
}`}
>
{page}
</button>
))}
<button
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</nav>
</div>
)}
</>
)}
</div>
</div>
</div>
</div>
);
};
export default ArticlesPage;

158
src/components/layout/Footer.tsx

@ -0,0 +1,158 @@
import Link from 'next/link';
const Footer = () => {
const currentYear = new Date().getFullYear();
const footerLinks = {
company: [
{ name: '关于我们', href: '/about' },
{ name: '企业文化', href: '/culture' },
{ name: '发展历程', href: '/history' },
{ name: '招聘信息', href: '/careers' },
],
services: [
{ name: '产品服务', href: '/services' },
{ name: '解决方案', href: '/solutions' },
{ name: '技术支持', href: '/support' },
{ name: '客户案例', href: '/cases' },
],
resources: [
{ name: '新闻资讯', href: '/articles' },
{ name: '行业动态', href: '/news' },
{ name: '技术博客', href: '/blog' },
{ name: '下载中心', href: '/downloads' },
],
contact: [
{ name: '联系我们', href: '/contact' },
{ name: '在线客服', href: '/service' },
{ name: '意见反馈', href: '/feedback' },
{ name: '合作伙伴', href: '/partners' },
],
};
return (
<footer className="footer">
<div className="container">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-8">
{/* 公司信息 */}
<div className="lg:col-span-2">
<div className="footer-section">
<h3 className="footer-title"></h3>
<p className="text-gray-300 mb-6 max-w-md">
</p>
<div className="space-y-2 text-gray-300">
<div className="flex items-center">
<svg className="w-5 h-5 mr-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clipRule="evenodd" />
</svg>
<span> 888 </span>
</div>
<div className="flex items-center">
<svg className="w-5 h-5 mr-3" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 3a1 1 0 011-1h2.153a1 1 0 01.986.836l.74 4.435a1 1 0 01-.54 1.06l-1.548.773a11.037 11.037 0 006.105 6.105l.774-1.548a1 1 0 011.059-.54l4.435.74a1 1 0 01.836.986V17a1 1 0 01-1 1h-2C7.82 18 2 12.18 2 5V3z" />
</svg>
<span>400-888-8888</span>
</div>
<div className="flex items-center">
<svg className="w-5 h-5 mr-3" fill="currentColor" viewBox="0 0 20 20">
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
</svg>
<span>contact@company.com</span>
</div>
</div>
</div>
</div>
{/* 公司链接 */}
<div className="footer-section">
<h3 className="footer-title"></h3>
<ul className="space-y-3">
{footerLinks.company.map((link) => (
<li key={link.name}>
<Link href={link.href} className="footer-link">
{link.name}
</Link>
</li>
))}
</ul>
</div>
{/* 服务链接 */}
<div className="footer-section">
<h3 className="footer-title"></h3>
<ul className="space-y-3">
{footerLinks.services.map((link) => (
<li key={link.name}>
<Link href={link.href} className="footer-link">
{link.name}
</Link>
</li>
))}
</ul>
</div>
{/* 资源链接 */}
<div className="footer-section">
<h3 className="footer-title"></h3>
<ul className="space-y-3">
{footerLinks.resources.map((link) => (
<li key={link.name}>
<Link href={link.href} className="footer-link">
{link.name}
</Link>
</li>
))}
</ul>
</div>
</div>
{/* 底部版权信息 */}
<div className="border-t border-gray-800 mt-12 pt-8">
<div className="flex flex-col md:flex-row justify-between items-center">
<div className="text-gray-400 text-sm">
© {currentYear} . .
</div>
<div className="flex space-x-6 mt-4 md:mt-0">
<Link href="/privacy" className="text-gray-400 hover:text-white text-sm transition-colors duration-300">
</Link>
<Link href="/terms" className="text-gray-400 hover:text-white text-sm transition-colors duration-300">
</Link>
<Link href="/sitemap" className="text-gray-400 hover:text-white text-sm transition-colors duration-300">
</Link>
</div>
</div>
</div>
{/* 社交媒体链接 */}
<div className="flex justify-center space-x-6 mt-8">
<a href="#" className="text-gray-400 hover:text-white transition-colors duration-300">
<span className="sr-only"></span>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 01.213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 00.167-.054l1.903-1.114a.864.864 0 01.717-.098 10.16 10.16 0 002.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18 0 .659-.52 1.188-1.162 1.188-.642 0-1.162-.529-1.162-1.188 0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18 0 .659-.52 1.188-1.162 1.188-.642 0-1.162-.529-1.162-1.188 0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229 2.579 0 5.061-1.314 6.266-3.276a.545.545 0 00.034-.31.545.545 0 00-.169-.267c-.293-.24-.652-.379-1.019-.379-.642 0-1.162.529-1.162 1.188 0 .659.52 1.188 1.162 1.188.367 0 .726-.139 1.019-.379a.545.545 0 00.169-.267.545.545 0 00-.034-.31c-1.205-1.962-3.687-3.276-6.266-3.276-3.218 0-5.942 1.776-6.884 4.229-.907 2.5.06 4.792 1.78 6.22 1.534 1.274 3.483 1.838 5.28 1.786z"/>
</svg>
</a>
<a href="#" className="text-gray-400 hover:text-white transition-colors duration-300">
<span className="sr-only"></span>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M9.996 14.456c-1.747 0-3.166-1.418-3.166-3.166s1.419-3.166 3.166-3.166 3.166 1.418 3.166 3.166-1.419 3.166-3.166 3.166zm0-5.332c-1.197 0-2.166.969-2.166 2.166s.969 2.166 2.166 2.166 2.166-.969 2.166-2.166-.969-2.166-2.166-2.166z"/>
</svg>
</a>
<a href="#" className="text-gray-400 hover:text-white transition-colors duration-300">
<span className="sr-only">QQ</span>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm0 18c-4.411 0-8-3.589-8-8s3.589-8 8-8 8 3.589 8 8-3.589 8-8 8z"/>
</svg>
</a>
</div>
</div>
</footer>
);
};
export default Footer;

139
src/components/layout/Header.tsx

@ -0,0 +1,139 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
const Header = () => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const pathname = usePathname();
const navigation = [
{ name: '首页', href: '/' },
{ name: '关于我们', href: '/about' },
{ name: '产品服务', href: '/services' },
{ name: '新闻资讯', href: '/articles' },
{ name: '联系我们', href: '/contact' },
];
const isActive = (href: string) => {
if (href === '/') {
return pathname === '/';
}
return pathname.startsWith(href);
};
return (
<header className="navbar">
<div className="container">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<div className="flex-shrink-0">
<Link href="/" className="navbar-brand">
</Link>
</div>
{/* Desktop Navigation */}
<nav className="hidden md:block">
<div className="navbar-nav">
{navigation.map((item) => (
<Link
key={item.name}
href={item.href}
className={`navbar-link ${isActive(item.href) ? 'active' : ''}`}
>
{item.name}
</Link>
))}
</div>
</nav>
{/* CTA Button */}
<div className="hidden md:block">
<Link href="/contact" className="btn btn-primary">
</Link>
</div>
{/* Mobile menu button */}
<div className="md:hidden">
<button
onClick={() => setIsMenuOpen(!isMenuOpen)}
className="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
aria-expanded="false"
>
<span className="sr-only"></span>
{!isMenuOpen ? (
<svg
className="block h-6 w-6"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
) : (
<svg
className="block h-6 w-6"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
)}
</button>
</div>
</div>
{/* Mobile Navigation */}
{isMenuOpen && (
<div className="md:hidden">
<div className="px-2 pt-2 pb-3 space-y-1 sm:px-3 bg-white border-t border-gray-200">
{navigation.map((item) => (
<Link
key={item.name}
href={item.href}
className={`block px-3 py-2 rounded-md text-base font-medium transition-colors duration-300 ${
isActive(item.href)
? 'text-blue-600 bg-blue-50'
: 'text-gray-700 hover:text-blue-600 hover:bg-gray-50'
}`}
onClick={() => setIsMenuOpen(false)}
>
{item.name}
</Link>
))}
<div className="px-3 py-2">
<Link
href="/contact"
className="btn btn-primary w-full"
onClick={() => setIsMenuOpen(false)}
>
</Link>
</div>
</div>
</div>
)}
</div>
</header>
);
};
export default Header;

120
src/components/sections/ARTICLES_SECTION_FIXES.md

@ -0,0 +1,120 @@
# ArticlesSection 组件修复说明
## 修复的问题
### 1. TypeScript 类型不匹配问题
**问题描述:**
- `setArticles(response?.list)` 报错:TypeScript 类型不匹配
- `pageCmsArticle` 函数返回类型与组件状态类型不一致
**解决方案:**
- 将 `articles` 状态类型从 `PageResult<CmsArticle>` 改为 `CmsArticle[]`
- 使用类型断言安全地处理 API 响应
- 添加数组类型检查确保数据安全
### 2. 字段映射问题
**问题描述:**
`CmsArticle` 类型的字段名与组件中使用的字段名不匹配:
| 组件中使用的字段 | CmsArticle 实际字段 | 修复后 |
|-----------------|-------------------|--------|
| `article.id` | `article.articleId` | ✅ 已修复 |
| `article.thumbnail` | `article.image` | ✅ 已修复 |
| `article.isTop` | 无对应字段 | ✅ 已移除 |
| `article.isRecommend` | `article.recommend` | ✅ 已修复 |
| `article.excerpt` | `article.overview` | ✅ 已修复 |
| `article.viewCount` | `article.actualViews/virtualViews` | ✅ 已修复 |
| `article.likeCount` | `article.likes` | ✅ 已修复 |
### 3. 运行时错误修复
**问题描述:**
- `formatNumber` 函数在接收 `undefined` 值时报错
- `formatDate` 函数缺少边界情况处理
**解决方案:**
- 修改函数参数类型支持 `undefined``null`
- 添加完善的错误处理和默认值
## 修复后的代码结构
### 状态管理
```typescript
const [articles, setArticles] = useState<CmsArticle[]>([]);
```
### API 调用
```typescript
const response = await pageCmsArticle({ limit: 6 });
const pageResult = response as unknown as PageResult<CmsArticle>;
if (pageResult && Array.isArray(pageResult.list)) {
setArticles(pageResult.list);
}
```
### 字段映射
```typescript
// 文章 ID
key={article.articleId || index}
// 图片
src={article.image || '/placeholder-article.jpg'}
// 推荐标签
{article.recommend === 1 && (...)}
// 摘要
{article.overview || article.content?.substring(0, 100) + '...'}
// 浏览量
{formatNumber(article.actualViews || article.virtualViews)}
// 点赞数
{formatNumber(article.likes)}
```
### 格式化函数
```typescript
const formatNumber = (num?: number | null) => {
if (num === undefined || num === null || isNaN(num)) {
return '0';
}
// ... 其他逻辑
};
const formatDate = (dateString?: string | null) => {
if (!dateString) {
return '未知时间';
}
// ... 其他逻辑
};
```
## 测试建议
1. **数据完整性测试**
- 测试 API 返回完整数据的情况
- 测试 API 返回部分字段缺失的情况
- 测试 API 返回空数组的情况
2. **边界情况测试**
- 测试 `actualViews``virtualViews` 都为 `undefined` 的情况
- 测试 `likes``null` 的情况
- 测试 `createTime` 为无效日期的情况
3. **UI 渲染测试**
- 验证文章列表正确渲染
- 验证推荐标签显示逻辑
- 验证图片加载失败时的占位符
## 注意事项
1. **类型安全**:使用了类型断言来处理 API 响应类型不匹配的问题,在生产环境中建议修复 API 函数的返回类型定义。
2. **字段兼容性**:如果后端 API 字段发生变化,需要相应更新字段映射。
3. **性能优化**:考虑添加 loading 状态和错误重试机制。
4. **数据验证**:建议在生产环境中添加更严格的数据验证逻辑。

173
src/components/sections/AboutSection.tsx

@ -0,0 +1,173 @@
'use client';
import Image from 'next/image';
const AboutSection = () => {
const achievements = [
{
number: '10+',
label: '年行业经验',
description: '深耕行业多年,积累丰富经验'
},
{
number: '500+',
label: '成功项目',
description: '服务各行各业,项目经验丰富'
},
{
number: '50+',
label: '专业团队',
description: '技术精湛的专业开发团队'
},
{
number: '98%',
label: '客户满意度',
description: '客户认可,口碑良好'
}
];
const values = [
{
title: '创新驱动',
description: '持续技术创新,引领行业发展',
icon: (
<svg className="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
)
},
{
title: '质量至上',
description: '严格质量控制,确保产品卓越',
icon: (
<svg className="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)
},
{
title: '客户第一',
description: '以客户需求为中心,提供优质服务',
icon: (
<svg className="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
)
},
{
title: '团队协作',
description: '高效团队协作,共创美好未来',
icon: (
<svg className="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
)
}
];
return (
<section className="section-padding">
<div className="container">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center mb-20">
{/* 左侧图片 */}
<div className="relative">
<Image
src="/about-image.jpg"
alt="关于我们"
width={600}
height={400}
className="rounded-lg shadow-lg"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAwIiBoZWlnaHQ9IjQwMCIgdmlld0JveD0iMCAwIDYwMCA0MDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSI2MDAiIGhlaWdodD0iNDAwIiBmaWxsPSIjRjNGNEY2Ii8+CjxwYXRoIGQ9Ik0yNzUgMTc1SDMyNVYyMjVIMjc1VjE3NVoiIGZpbGw9IiM5Q0EzQUYiLz4KPHA+CjxwYXRoIGQ9Ik0yNTAgMjAwSDM1MFYyMDBIMjUwWiIgZmlsbD0iIzlDQTNBRiIvPgo8L3N2Zz4K';
}}
/>
{/* 装饰元素 */}
<div className="absolute -bottom-6 -right-6 bg-blue-600 text-white p-6 rounded-lg shadow-lg">
<div className="text-2xl font-bold">10+</div>
<div className="text-sm"></div>
</div>
</div>
{/* 右侧内容 */}
<div>
<h2 className="text-3xl lg:text-4xl font-bold mb-6">
</h2>
<p className="text-lg text-gray-600 mb-6">
2014
</p>
<p className="text-gray-600 mb-8">
使
</p>
{/* 成就数据 */}
<div className="grid grid-cols-2 gap-6">
{achievements.map((achievement, index) => (
<div key={index} className="text-center lg:text-left">
<div className="text-3xl font-bold text-blue-600 mb-2">
{achievement.number}
</div>
<div className="font-semibold mb-1">{achievement.label}</div>
<div className="text-sm text-gray-600">{achievement.description}</div>
</div>
))}
</div>
</div>
</div>
{/* 企业价值观 */}
<div className="text-center mb-16">
<h3 className="text-2xl lg:text-3xl font-bold mb-4"></h3>
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
{values.map((value, index) => (
<div key={index} className="text-center p-6 rounded-lg bg-white shadow-sm hover:shadow-md transition-shadow duration-300">
<div className="flex justify-center mb-4">
{value.icon}
</div>
<h4 className="text-xl font-semibold mb-3">{value.title}</h4>
<p className="text-gray-600">{value.description}</p>
</div>
))}
</div>
{/* 团队介绍 */}
<div className="mt-20 text-center">
<h3 className="text-2xl lg:text-3xl font-bold mb-6"></h3>
<p className="text-lg text-gray-600 mb-12 max-w-3xl mx-auto">
</p>
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-2xl p-8">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 text-center">
<div>
<div className="text-3xl font-bold text-blue-600 mb-2">15+</div>
<div className="text-gray-600"></div>
</div>
<div>
<div className="text-3xl font-bold text-blue-600 mb-2">8+</div>
<div className="text-gray-600"></div>
</div>
<div>
<div className="text-3xl font-bold text-blue-600 mb-2">5+</div>
<div className="text-gray-600"></div>
</div>
</div>
</div>
</div>
</div>
</section>
);
};
export default AboutSection;

206
src/components/sections/ArticlesSection.tsx

@ -0,0 +1,206 @@
'use client';
import {useState, useEffect} from 'react';
import Link from 'next/link';
import Image from 'next/image';
import {pageCmsArticle} from "@/api/cms/cmsArticle";
import {CmsArticle} from "@/api/cms/cmsArticle/model";
const ArticlesSection = () => {
const [articles, setArticles] = useState<CmsArticle[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchArticles = async () => {
try {
setLoading(true);
setError(null);
// 获取最新文章
const response = await pageCmsArticle({ limit: 6 });
setArticles(response?.list || []);
} catch (err) {
console.error('获取文章失败:', err);
setError('网络错误,请稍后重试');
// 设置模拟数据作为后备
setArticles([]);
} finally {
setLoading(false);
}
};
fetchArticles();
}, []);
const formatDate = (dateString?: string | null) => {
// 处理 undefined 或 null 值
if (!dateString) {
return '未知时间';
}
const date = new Date(dateString);
// 检查日期是否有效
if (isNaN(date.getTime())) {
return '无效日期';
}
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
const formatNumber = (num?: number | null) => {
// 处理 undefined 或 null 值
if (num === undefined || num === null || isNaN(num)) {
return '0';
}
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'k';
}
return num.toString();
};
if (loading) {
return (
<section className="section-padding bg-gray-50">
<div className="container">
<div className="text-center mb-16">
<h2 className="section-title"></h2>
<p className="section-subtitle">
沿
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="article-card animate-pulse">
<div className="w-full h-48 bg-gray-300"></div>
<div className="article-content">
<div className="h-4 bg-gray-300 rounded mb-3"></div>
<div className="h-6 bg-gray-300 rounded mb-3"></div>
<div className="h-4 bg-gray-300 rounded mb-4"></div>
<div className="h-4 bg-gray-300 rounded"></div>
</div>
</div>
))}
</div>
</div>
</section>
);
}
return (
<section className="section-padding bg-gray-50">
<div className="container">
<div className="text-center mb-16">
<h2 className="section-title"></h2>
<p className="section-subtitle">
沿
</p>
</div>
{error && (
<div
className="bg-yellow-50 border border-yellow-200 text-yellow-800 px-4 py-3 rounded-lg mb-8 text-center">
<p>{error}</p>
<p className="text-sm mt-2"></p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{articles?.map((article, index) => (
<article
key={article.articleId || index}
className="article-card fade-in-up"
style={{animationDelay: `${index * 0.1}s`}}
>
{/* 文章图片 */}
<div className="relative overflow-hidden">
<Image
src={article.image || '/placeholder-article.jpg'}
alt={article.title || '文章图片'}
width={400}
height={200}
className="article-image hover:scale-105 transition-transform duration-300"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjIwMCIgdmlld0JveD0iMCAwIDQwMCAyMDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSI0MDAiIGhlaWdodD0iMjAwIiBmaWxsPSIjRjNGNEY2Ii8+CjxwYXRoIGQ9Ik0xNzUgNzVIMjI1VjEyNUgxNzVWNzVaIiBmaWxsPSIjOUNBM0FGIi8+CjxwYXRoIGQ9Ik0xNTAgMTAwSDI1MFYxMDBIMTUwWiIgZmlsbD0iIzlDQTNBRiIvPgo8L3N2Zz4K';
}}
/>
{/* 推荐标签 */}
{article.recommend === 1 && (
<div
className="absolute top-4 right-4 bg-blue-500 text-white px-2 py-1 text-xs rounded">
</div>
)}
</div>
{/* 文章内容 */}
<div className="article-content">
{/* 分类标签 */}
{article.categoryName && (
<span className="article-category">
{article.categoryName}
</span>
)}
{/* 文章标题 */}
<h3 className="article-title">
<Link href={`/articles/${article.articleId}`}>
{article.title}
</Link>
</h3>
{/* 文章摘要 */}
<p className="article-excerpt">
{article.overview || article.content?.substring(0, 100) + '...'}
</p>
{/* 文章元信息 */}
<div className="article-meta">
<div className="flex items-center space-x-4">
<span className="flex items-center">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
{formatNumber(article.actualViews || article.virtualViews)}
</span>
<span className="flex items-center">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"/>
</svg>
{formatNumber(article.likes)}
</span>
</div>
<span>{formatDate(article.createTime)}</span>
</div>
</div>
</article>
))}
</div>
{/* 查看更多按钮 */}
<div className="text-center mt-12">
<Link href="/articles" className="btn btn-outline">
</Link>
</div>
</div>
</section>
);
};
export default ArticlesSection;

279
src/components/sections/ContactSection.tsx

@ -0,0 +1,279 @@
'use client';
import { useState } from 'react';
const ContactSection = () => {
const [formData, setFormData] = useState({
name: '',
email: '',
phone: '',
company: '',
message: ''
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setSubmitStatus('idle');
try {
// 这里可以调用实际的 API
// const response = await ContactApi.submitForm(formData);
// 模拟 API 调用
await new Promise(resolve => setTimeout(resolve, 2000));
setSubmitStatus('success');
setFormData({
name: '',
email: '',
phone: '',
company: '',
message: ''
});
} catch (error) {
console.error('提交失败:', error);
setSubmitStatus('error');
} finally {
setIsSubmitting(false);
}
};
const contactInfo = [
{
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
title: '公司地址',
content: '北京市朝阳区某某大厦 888 号',
link: 'https://maps.google.com'
},
{
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
),
title: '联系电话',
content: '400-888-8888',
link: 'tel:400-888-8888'
},
{
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
),
title: '邮箱地址',
content: 'contact@company.com',
link: 'mailto:contact@company.com'
},
{
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
title: '工作时间',
content: '周一至周五 9:00-18:00',
link: null
}
];
return (
<section className="section-padding">
<div className="container">
<div className="text-center mb-16">
<h2 className="section-title"></h2>
<p className="section-subtitle">
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16">
{/* 左侧联系信息 */}
<div>
<h3 className="text-2xl font-bold mb-8"></h3>
<div className="space-y-6 mb-12">
{contactInfo.map((info, index) => (
<div key={index} className="flex items-start space-x-4">
<div className="flex-shrink-0 w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center text-blue-600">
{info.icon}
</div>
<div>
<h4 className="font-semibold mb-1">{info.title}</h4>
{info.link ? (
<a
href={info.link}
className="text-gray-600 hover:text-blue-600 transition-colors duration-300"
target={info.link.startsWith('http') ? '_blank' : undefined}
rel={info.link.startsWith('http') ? 'noopener noreferrer' : undefined}
>
{info.content}
</a>
) : (
<p className="text-gray-600">{info.content}</p>
)}
</div>
</div>
))}
</div>
{/* 社交媒体 */}
<div>
<h4 className="font-semibold mb-4"></h4>
<div className="flex space-x-4">
<a href="#" className="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center text-gray-600 hover:bg-blue-600 hover:text-white transition-colors duration-300">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M24 4.557c-.883.392-1.832.656-2.828.775 1.017-.609 1.798-1.574 2.165-2.724-.951.564-2.005.974-3.127 1.195-.897-.957-2.178-1.555-3.594-1.555-3.179 0-5.515 2.966-4.797 6.045-4.091-.205-7.719-2.165-10.148-5.144-1.29 2.213-.669 5.108 1.523 6.574-.806-.026-1.566-.247-2.229-.616-.054 2.281 1.581 4.415 3.949 4.89-.693.188-1.452.232-2.224.084.626 1.956 2.444 3.379 4.6 3.419-2.07 1.623-4.678 2.348-7.29 2.04 2.179 1.397 4.768 2.212 7.548 2.212 9.142 0 14.307-7.721 13.995-14.646.962-.695 1.797-1.562 2.457-2.549z"/>
</svg>
</a>
<a href="#" className="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center text-gray-600 hover:bg-blue-600 hover:text-white transition-colors duration-300">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M22.46 6c-.77.35-1.6.58-2.46.69.88-.53 1.56-1.37 1.88-2.38-.83.5-1.75.85-2.72 1.05C18.37 4.5 17.26 4 16 4c-2.35 0-4.27 1.92-4.27 4.29 0 .34.04.67.11.98C8.28 9.09 5.11 7.38 3 4.79c-.37.63-.58 1.37-.58 2.15 0 1.49.75 2.81 1.91 3.56-.71 0-1.37-.2-1.95-.5v.03c0 2.08 1.48 3.82 3.44 4.21a4.22 4.22 0 0 1-1.93.07 4.28 4.28 0 0 0 4 2.98 8.521 8.521 0 0 1-5.33 1.84c-.34 0-.68-.02-1.02-.06C3.44 20.29 5.7 21 8.12 21 16 21 20.33 14.46 20.33 8.79c0-.19 0-.37-.01-.56.84-.6 1.56-1.36 2.14-2.23z"/>
</svg>
</a>
<a href="#" className="w-10 h-10 bg-gray-100 rounded-lg flex items-center justify-center text-gray-600 hover:bg-blue-600 hover:text-white transition-colors duration-300">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
</a>
</div>
</div>
</div>
{/* 右侧联系表单 */}
<div>
<h3 className="text-2xl font-bold mb-8"></h3>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-2">
*
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleInputChange}
required
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors duration-300"
placeholder="请输入您的姓名"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
*
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleInputChange}
required
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors duration-300"
placeholder="请输入您的邮箱"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="phone" className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
type="tel"
id="phone"
name="phone"
value={formData.phone}
onChange={handleInputChange}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors duration-300"
placeholder="请输入您的电话"
/>
</div>
<div>
<label htmlFor="company" className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
type="text"
id="company"
name="company"
value={formData.company}
onChange={handleInputChange}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors duration-300"
placeholder="请输入您的公司"
/>
</div>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium text-gray-700 mb-2">
*
</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleInputChange}
required
rows={6}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors duration-300 resize-none"
placeholder="请详细描述您的需求或问题..."
/>
</div>
{/* 提交状态提示 */}
{submitStatus === 'success' && (
<div className="bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded-lg">
</div>
)}
{submitStatus === 'error' && (
<div className="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-lg">
</div>
)}
<button
type="submit"
disabled={isSubmitting}
className="w-full btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? (
<span className="flex items-center justify-center">
<div className="loading mr-2"></div>
...
</span>
) : (
'发送消息'
)}
</button>
</form>
</div>
</div>
</div>
</section>
);
};
export default ContactSection;

103
src/components/sections/FeaturesSection.tsx

@ -0,0 +1,103 @@
'use client';
const FeaturesSection = () => {
const features = [
{
icon: (
<svg className="feature-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
),
title: '高效快速',
description: '采用先进的技术架构,确保系统运行高效稳定,响应速度快,为您的业务提供强有力的技术支撑。'
},
{
icon: (
<svg className="feature-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
title: '安全可靠',
description: '多层安全防护机制,数据加密传输,定期安全检测,确保您的数据和业务安全无忧。'
},
{
icon: (
<svg className="feature-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
),
title: '用户友好',
description: '简洁直观的用户界面设计,操作简单易懂,降低学习成本,提升用户体验和工作效率。'
},
{
icon: (
<svg className="feature-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zM21 5a2 2 0 00-2-2h-4a2 2 0 00-2 2v12a4 4 0 004 4h4a2 2 0 002-2V5z" />
</svg>
),
title: '灵活扩展',
description: '模块化设计架构,支持灵活配置和扩展,可根据业务需求进行定制化开发和功能扩展。'
},
{
icon: (
<svg className="feature-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192L5.636 18.364M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-5 0a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
),
title: '专业服务',
description: '专业的技术团队提供全方位服务支持,从需求分析到实施部署,再到后期维护,全程贴心服务。'
},
{
icon: (
<svg className="feature-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
title: '数据分析',
description: '强大的数据分析功能,实时监控业务指标,生成详细报表,为决策提供数据支撑。'
}
];
return (
<section className="section-padding bg-gray-50">
<div className="container">
<div className="text-center mb-16">
<h2 className="section-title"></h2>
<p className="section-subtitle">
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{features.map((feature, index) => (
<div key={index} className="feature-card fade-in-up" style={{ animationDelay: `${index * 0.1}s` }}>
{feature.icon}
<h3 className="feature-title">{feature.title}</h3>
<p className="feature-description">{feature.description}</p>
</div>
))}
</div>
{/* 底部 CTA */}
<div className="text-center mt-16">
<div className="bg-white rounded-2xl shadow-lg p-8 max-w-4xl mx-auto">
<h3 className="text-2xl font-bold mb-4"></h3>
<p className="text-gray-600 mb-6">
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<a href="/contact" className="btn btn-primary">
</a>
<a href="/demo" className="btn btn-outline">
</a>
</div>
</div>
</div>
</div>
</section>
);
};
export default FeaturesSection;

76
src/components/sections/HeroSection.tsx

@ -0,0 +1,76 @@
'use client';
import Link from 'next/link';
import Image from 'next/image';
const HeroSection = () => {
return (
<section className="hero">
<div className="container">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
{/* 左侧内容 */}
<div className="text-center lg:text-left">
<h1 className="hero-title">
<span className="text-blue-600"></span>
</h1>
<p className="hero-subtitle">
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center lg:justify-start">
<Link href="/contact" className="btn btn-primary">
</Link>
<Link href="/services" className="btn btn-outline">
</Link>
</div>
{/* 统计数据 */}
<div className="grid grid-cols-3 gap-8 mt-12 pt-8 border-t border-gray-200">
<div className="text-center lg:text-left">
<div className="text-3xl font-bold text-blue-600 mb-2">500+</div>
<div className="text-gray-600"></div>
</div>
<div className="text-center lg:text-left">
<div className="text-3xl font-bold text-blue-600 mb-2">98%</div>
<div className="text-gray-600"></div>
</div>
<div className="text-center lg:text-left">
<div className="text-3xl font-bold text-blue-600 mb-2">24/7</div>
<div className="text-gray-600"></div>
</div>
</div>
</div>
{/* 右侧图片 */}
<div className="relative">
<div className="relative z-10">
<Image
src="/hero-image.jpg"
alt="企业解决方案"
width={600}
height={400}
className="rounded-lg shadow-2xl"
priority
onError={(e) => {
// 如果图片加载失败,显示占位符
const target = e.target as HTMLImageElement;
target.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAwIiBoZWlnaHQ9IjQwMCIgdmlld0JveD0iMCAwIDYwMCA0MDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSI2MDAiIGhlaWdodD0iNDAwIiBmaWxsPSIjRjNGNEY2Ii8+CjxwYXRoIGQ9Ik0yNzUgMTc1SDMyNVYyMjVIMjc1VjE3NVoiIGZpbGw9IiM5Q0EzQUYiLz4KPHA+CjxwYXRoIGQ9Ik0yNTAgMjAwSDM1MFYyMDBIMjUwWiIgZmlsbD0iIzlDQTNBRiIvPgo8L3N2Zz4K';
}}
/>
</div>
{/* 装饰性元素 */}
<div className="absolute -top-4 -right-4 w-72 h-72 bg-blue-100 rounded-full mix-blend-multiply filter blur-xl opacity-70 animate-pulse"></div>
<div className="absolute -bottom-8 -left-4 w-72 h-72 bg-purple-100 rounded-full mix-blend-multiply filter blur-xl opacity-70 animate-pulse"></div>
</div>
</div>
</div>
</section>
);
};
export default HeroSection;
Loading…
Cancel
Save