添加核心页面

- 首页:轮播图、特色项目、传承人展示、最新资讯等
- 非遗项目页面:列表页和详情页,支持筛选和排序
- 传承人页面:列表页和详情页,展示个人作品和技艺
- 关于页面:核心价值观、使命愿景展示
- 搜索页面:全站搜索功能
- 数据可视化页面:统计图表展示
- 用户中心:登录、注册、个人信息管理
This commit is contained in:
Leo 2025-10-09 23:47:21 +08:00
parent f46513ab8b
commit 6257ce5c7b
22 changed files with 4748 additions and 0 deletions

202
src/pages/About/index.css Normal file
View File

@ -0,0 +1,202 @@
/* 关于我们页面样式 */
.about-page {
min-height: 100vh;
}
.page-header {
background: linear-gradient(135deg, #4a5f7f 0%, #2a3f5f 100%);
padding: 80px 0 60px;
text-align: center;
color: #ffffff;
}
.page-header h1 {
font-size: 42px;
font-weight: 700;
color: #ffffff;
margin-bottom: 16px;
font-family: 'Noto Serif SC', 'Songti SC', serif;
}
.page-header p {
font-size: 18px;
color: rgba(255, 255, 255, 0.9);
margin: 0;
}
.about-content {
background: #ffffff;
}
.mission-section {
background: #ffffff;
}
.values-section {
background: linear-gradient(135deg, #fff9f0 0%, #f5f0e8 100%);
}
.value-card {
position: relative;
border-radius: 16px;
padding: 40px 32px;
background: #ffffff;
border: 2px solid #f0f0f0;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
height: 100%;
overflow: hidden;
text-align: center;
}
.value-card:hover {
transform: translateY(-8px);
border-color: var(--gradient-end);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.12);
}
/* 定义各卡片的渐变色 */
.value-card[data-color='red'] {
--gradient-start: #ff6b6b;
--gradient-end: #c8363d;
--icon-bg: rgba(200, 54, 61, 0.1);
--icon-color: #c8363d;
}
.value-card[data-color='gold'] {
--gradient-start: #ffd89b;
--gradient-end: #d4a574;
--icon-bg: rgba(212, 165, 116, 0.1);
--icon-color: #d4a574;
}
.value-card[data-color='green'] {
--gradient-start: #52b788;
--gradient-end: #2a5e4d;
--icon-bg: rgba(42, 94, 77, 0.1);
--icon-color: #2a5e4d;
}
.value-card[data-color='blue'] {
--gradient-start: #7b9fd4;
--gradient-end: #4a5f7f;
--icon-bg: rgba(74, 95, 127, 0.1);
--icon-color: #4a5f7f;
}
/* 卡片头部 */
.value-card-header {
margin-bottom: 24px;
}
/* 图标容器 */
.value-icon-circle {
position: relative;
width: 90px;
height: 90px;
margin: 0 auto;
background: var(--icon-bg);
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 2;
}
.value-card:hover .value-icon-circle {
transform: scale(1.1);
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end));
}
.value-icon-circle .anticon {
font-size: 42px;
color: var(--icon-color);
transition: all 0.4s ease;
}
.value-card:hover .value-icon-circle .anticon {
color: #ffffff;
transform: scale(1.1);
}
/* 文字区域 */
.value-card-body {
position: relative;
z-index: 2;
}
.value-card-body h4 {
font-size: 22px;
font-weight: 600;
color: #2c2c2c;
margin-bottom: 12px;
font-family: 'Noto Serif SC', 'Songti SC', serif;
letter-spacing: 2px;
transition: color 0.3s ease;
}
.value-card:hover .value-card-body h4 {
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.value-card-body .ant-typography {
font-size: 14px;
color: #666666;
line-height: 1.8;
margin: 0;
}
.contact-section {
background: #ffffff;
}
.contact-section .ant-card {
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.contact-section .ant-typography {
font-size: 15px;
line-height: 2;
color: #666666;
}
/* 响应式 */
@media (max-width: 768px) {
.page-header {
padding: 60px 0 40px;
}
.page-header h1 {
font-size: 32px;
}
.page-header p {
font-size: 16px;
}
.value-card {
padding: 32px 24px;
}
.value-icon-circle {
width: 75px;
height: 75px;
}
.value-icon-circle .anticon {
font-size: 36px;
}
.value-card-body h4 {
font-size: 20px;
}
.value-card-body .ant-typography {
font-size: 13px;
}
}

123
src/pages/About/index.tsx Normal file
View File

@ -0,0 +1,123 @@
/**
*
*/
import React from 'react'
import { Row, Col, Typography, Card } from 'antd'
import { HeartOutlined, BulbOutlined, GlobalOutlined, StarOutlined } from '@ant-design/icons'
import './index.css'
const { Title, Paragraph } = Typography
const About: React.FC = () => {
return (
<div className="about-page">
<div className="page-header">
<div className="container">
<h1></h1>
<p></p>
</div>
</div>
<div className="about-content">
<section className="mission-section section-spacing">
<div className="container">
<div className="text-center" style={{ maxWidth: 800, margin: '0 auto' }}>
<Title level={2}>使</Title>
<Paragraph style={{ fontSize: 16, lineHeight: 1.8 }}>
广
</Paragraph>
</div>
</div>
</section>
<section className="values-section section-spacing gradient-bg-warm">
<div className="container">
<div className="text-center" style={{ marginBottom: 48 }}>
<Title level={2}></Title>
</div>
<Row gutter={[32, 32]}>
<Col xs={24} sm={12} lg={6}>
<div className="value-card" data-color="red">
<div className="value-card-header">
<div className="value-icon-circle">
<HeartOutlined />
</div>
</div>
<div className="value-card-body">
<Title level={4}></Title>
<Paragraph></Paragraph>
</div>
</div>
</Col>
<Col xs={24} sm={12} lg={6}>
<div className="value-card" data-color="gold">
<div className="value-card-header">
<div className="value-icon-circle">
<BulbOutlined />
</div>
</div>
<div className="value-card-body">
<Title level={4}></Title>
<Paragraph></Paragraph>
</div>
</div>
</Col>
<Col xs={24} sm={12} lg={6}>
<div className="value-card" data-color="green">
<div className="value-card-header">
<div className="value-icon-circle">
<GlobalOutlined />
</div>
</div>
<div className="value-card-body">
<Title level={4}></Title>
<Paragraph></Paragraph>
</div>
</div>
</Col>
<Col xs={24} sm={12} lg={6}>
<div className="value-card" data-color="blue">
<div className="value-card-header">
<div className="value-icon-circle">
<StarOutlined />
</div>
</div>
<div className="value-card-body">
<Title level={4}></Title>
<Paragraph></Paragraph>
</div>
</div>
</Col>
</Row>
</div>
</section>
<section className="contact-section section-spacing">
<div className="container">
<div className="text-center">
<Title level={2}></Title>
<Paragraph style={{ fontSize: 16, marginBottom: 32 }}>
</Paragraph>
<div style={{ maxWidth: 600, margin: '0 auto' }}>
<Card>
<Paragraph><strong></strong></Paragraph>
<Paragraph><strong></strong>400-123-4567</Paragraph>
<Paragraph><strong></strong>heritage@example.com</Paragraph>
<Paragraph style={{ marginBottom: 0 }}>
<strong></strong> 9:00-18:00
</Paragraph>
</Card>
</div>
</div>
</div>
</section>
</div>
</div>
)
}
export default About

414
src/pages/Data/index.css Normal file
View File

@ -0,0 +1,414 @@
/* 数据可视化页样式 */
.data-page {
min-height: 100vh;
background: #fafaf8;
}
.page-header {
background: linear-gradient(135deg, #3d5a80 0%, #2a4a6a 100%);
padding: 80px 0 60px;
text-align: center;
color: #ffffff;
}
.page-header h1 {
font-size: 42px;
font-weight: 700;
color: #ffffff;
margin-bottom: 16px;
font-family: 'Noto Serif SC', 'Songti SC', serif;
}
.page-header p {
font-size: 18px;
color: rgba(255, 255, 255, 0.9);
margin: 0;
}
/* 总体统计 */
.stats-overview {
margin-bottom: 32px;
}
.stats-overview .ant-card {
border-radius: 12px;
transition: all 0.3s ease;
}
.stats-overview .ant-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.stats-overview .ant-statistic-title {
font-size: 14px;
color: #999999;
margin-bottom: 12px;
}
.stats-overview .ant-statistic-content {
font-size: 32px;
font-weight: 700;
}
/* 数据卡片 */
.data-card {
border-radius: 12px;
margin-bottom: 24px;
}
.data-card .ant-card-head {
border-bottom: 2px solid #f0ebe3;
}
.data-card .ant-card-head-title {
font-size: 18px;
font-weight: 600;
color: #2c2c2c;
font-family: 'Noto Serif SC', 'Songti SC', serif;
}
/* 省份分布图 */
.province-chart {
display: flex;
flex-direction: column;
gap: 16px;
}
.province-item {
display: grid;
grid-template-columns: 40px 100px 1fr 60px;
align-items: center;
gap: 16px;
}
.province-rank {
width: 32px;
height: 32px;
background: linear-gradient(135deg, #d4a574 0%, #c8a060 100%);
color: #ffffff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 14px;
}
.province-name {
font-size: 14px;
font-weight: 500;
color: #2c2c2c;
}
.province-bar-wrapper {
background: #f5f0e8;
height: 24px;
border-radius: 12px;
overflow: hidden;
}
.province-bar {
height: 100%;
background: linear-gradient(90deg, #d4a574 0%, #c8a060 100%);
transition: width 0.6s ease;
}
.province-count {
font-size: 16px;
font-weight: 600;
color: #2c2c2c;
text-align: right;
}
/* 类别分布图 */
.category-chart {
display: flex;
flex-direction: column;
gap: 20px;
}
.category-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.category-info {
display: flex;
justify-content: space-between;
align-items: center;
}
.category-count {
font-size: 14px;
font-weight: 600;
color: #666666;
}
/* 级别分布图 */
.level-chart {
display: flex;
flex-direction: column;
gap: 16px;
}
.level-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: #fafaf8;
border-radius: 8px;
}
.level-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.level-name {
font-size: 14px;
font-weight: 500;
color: #2c2c2c;
}
.level-count {
font-size: 13px;
color: #999999;
}
.level-percentage {
font-size: 24px;
font-weight: 700;
color: #d4a574;
}
/* 年龄分布图 */
.age-chart {
display: flex;
flex-direction: column;
gap: 20px;
}
.age-item {
display: flex;
align-items: center;
gap: 16px;
}
.age-range {
width: 100px;
font-size: 14px;
font-weight: 500;
color: #2c2c2c;
}
.age-bar-wrapper {
flex: 1;
background: #f5f0e8;
height: 32px;
border-radius: 16px;
overflow: hidden;
}
.age-bar {
height: 100%;
background: linear-gradient(90deg, #c8363d 0%, #a82f35 100%);
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 12px;
transition: width 0.6s ease;
}
.age-count {
color: #ffffff;
font-weight: 600;
font-size: 14px;
}
/* 性别分布图 */
.gender-chart {
padding: 20px 0;
}
.gender-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
.gender-item {
text-align: center;
padding: 32px 20px;
background: #fafaf8;
border-radius: 12px;
transition: all 0.3s ease;
}
.gender-item:hover {
transform: translateY(-4px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
.gender-item.male {
border: 2px solid #3d5a80;
}
.gender-item.female {
border: 2px solid #c8363d;
}
.gender-icon {
font-size: 48px;
margin-bottom: 16px;
}
.gender-label {
font-size: 14px;
color: #999999;
margin-bottom: 8px;
}
.gender-count {
font-size: 32px;
font-weight: 700;
color: #2c2c2c;
margin-bottom: 8px;
}
.gender-percentage {
font-size: 18px;
font-weight: 600;
color: #d4a574;
}
/* 课程排行 */
.course-chart {
display: flex;
flex-direction: column;
gap: 16px;
}
.course-item {
display: grid;
grid-template-columns: 40px 1fr auto;
align-items: center;
gap: 20px;
padding: 16px;
background: #fafaf8;
border-radius: 8px;
transition: all 0.3s ease;
}
.course-item:hover {
background: #f5f0e8;
transform: translateX(4px);
}
.course-rank {
width: 36px;
height: 36px;
background: linear-gradient(135deg, #c8363d 0%, #a82f35 100%);
color: #ffffff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 16px;
}
.course-info {
flex: 1;
}
.course-title {
font-size: 15px;
font-weight: 600;
color: #2c2c2c;
margin-bottom: 6px;
}
.course-meta {
display: flex;
gap: 16px;
font-size: 13px;
color: #999999;
}
.course-instructor::before {
content: '👨‍🏫 ';
}
.course-enroll {
text-align: right;
}
.enroll-count {
font-size: 24px;
font-weight: 700;
color: #d4a574;
line-height: 1;
margin-bottom: 4px;
}
.enroll-label {
font-size: 12px;
color: #999999;
}
/* 响应式 */
@media (max-width: 768px) {
.page-header {
padding: 60px 0 40px;
}
.page-header h1 {
font-size: 32px;
}
.province-item {
grid-template-columns: 32px 80px 1fr 50px;
gap: 12px;
}
.gender-stats {
grid-template-columns: 1fr;
}
.course-item {
grid-template-columns: 1fr;
gap: 12px;
}
.course-enroll {
text-align: left;
}
}
@media (max-width: 576px) {
.stats-overview .ant-statistic-content {
font-size: 24px;
}
.province-item {
grid-template-columns: 1fr;
}
.province-bar-wrapper {
order: 3;
}
.age-item {
flex-direction: column;
align-items: stretch;
}
.age-range {
width: 100%;
}
}

254
src/pages/Data/index.tsx Normal file
View File

@ -0,0 +1,254 @@
/**
*
*/
import React, { useEffect, useState } from 'react'
import { Row, Col, Card, Statistic, Progress, Tag } from 'antd'
import {
GlobalOutlined,
UserOutlined,
} from '@ant-design/icons'
import * as mockData from '@services/mockData'
import './index.css'
interface ProvinceStats {
name: string
count: number
percentage: number
}
interface CategoryStats {
name: string
count: number
color: string
}
const DataVisualization: React.FC = () => {
const [provinceStats, setProvinceStats] = useState<ProvinceStats[]>([])
const [categoryStats, setCategoryStats] = useState<CategoryStats[]>([])
const [levelStats, setLevelStats] = useState<any[]>([])
useEffect(() => {
calculateStats()
}, [])
const calculateStats = () => {
// 计算省份统计
const provinceMap = new Map<string, number>()
mockData.mockHeritageItems.forEach((item) => {
provinceMap.set(item.province, (provinceMap.get(item.province) || 0) + 1)
})
const totalItems = mockData.mockHeritageItems.length
const provinces: ProvinceStats[] = Array.from(provinceMap.entries())
.map(([name, count]) => ({
name,
count,
percentage: Math.round((count / totalItems) * 100),
}))
.sort((a, b) => b.count - a.count)
.slice(0, 10)
setProvinceStats(provinces)
// 计算类别统计
const categoryMap = new Map<string, number>()
mockData.mockHeritageItems.forEach((item) => {
categoryMap.set(item.category, (categoryMap.get(item.category) || 0) + 1)
})
const colors = ['#c8363d', '#d4a574', '#2a5e40', '#3d5a80', '#8b4513']
const categories: CategoryStats[] = Array.from(categoryMap.entries())
.map(([name, count], index) => ({
name,
count,
color: colors[index % colors.length],
}))
.sort((a, b) => b.count - a.count)
setCategoryStats(categories)
// 计算级别统计
const levelMap = new Map<string, number>()
mockData.mockHeritageItems.forEach((item) => {
levelMap.set(item.level, (levelMap.get(item.level) || 0) + 1)
})
const levelLabels: Record<string, string> = {
world: '世界级',
national: '国家级',
provincial: '省级',
municipal: '市级',
county: '县级',
}
const levels = Array.from(levelMap.entries())
.map(([key, count]) => ({
name: levelLabels[key] || key,
count,
percentage: Math.round((count / totalItems) * 100),
}))
.sort((a, b) => b.count - a.count)
setLevelStats(levels)
}
return (
<div className="data-page">
<div className="page-header">
<div className="container">
<h1></h1>
<p></p>
</div>
</div>
<div className="page-content section-spacing">
<div className="container">
{/* 总体统计 */}
<Row gutter={[24, 24]} className="stats-overview">
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="非遗项目总数"
value={mockData.mockHeritageItems.length}
prefix={<GlobalOutlined />}
valueStyle={{ color: '#c8363d' }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="传承人总数"
value={mockData.mockInheritors.length}
prefix={<UserOutlined />}
valueStyle={{ color: '#d4a574' }}
/>
</Card>
</Col>
</Row>
{/* 省份分布 */}
<Card title="省份分布 TOP 10" className="data-card">
<div className="province-chart">
{provinceStats.map((item, index) => (
<div key={item.name} className="province-item">
<div className="province-rank">{index + 1}</div>
<div className="province-name">{item.name}</div>
<div className="province-bar-wrapper">
<div
className="province-bar"
style={{ width: `${item.percentage}%` }}
/>
</div>
<div className="province-count">{item.count}</div>
</div>
))}
</div>
</Card>
<Row gutter={[24, 24]}>
{/* 类别分布 */}
<Col xs={24} lg={12}>
<Card title="类别分布" className="data-card">
<div className="category-chart">
{categoryStats.map((item) => (
<div key={item.name} className="category-item">
<div className="category-info">
<Tag color={item.color}>{item.name}</Tag>
<span className="category-count">{item.count} </span>
</div>
<Progress
percent={Math.round((item.count / mockData.mockHeritageItems.length) * 100)}
strokeColor={item.color}
showInfo={false}
/>
</div>
))}
</div>
</Card>
</Col>
{/* 级别分布 */}
<Col xs={24} lg={12}>
<Card title="级别分布" className="data-card">
<div className="level-chart">
{levelStats.map((item, index) => (
<div key={item.name} className="level-item">
<div className="level-info">
<span className="level-name">{item.name}</span>
<span className="level-count">{item.count} </span>
</div>
<div className="level-percentage">{item.percentage}%</div>
</div>
))}
</div>
</Card>
</Col>
</Row>
{/* 传承人统计 */}
<Row gutter={[24, 24]}>
<Col xs={24} lg={12}>
<Card title="传承人年龄分布" className="data-card">
<div className="age-chart">
{[
{ range: '30岁以下', count: mockData.mockInheritors.filter(i => i.age < 30).length },
{ range: '30-50岁', count: mockData.mockInheritors.filter(i => i.age >= 30 && i.age <= 50).length },
{ range: '50-70岁', count: mockData.mockInheritors.filter(i => i.age > 50 && i.age <= 70).length },
{ range: '70岁以上', count: mockData.mockInheritors.filter(i => i.age > 70).length },
].map((item) => (
<div key={item.range} className="age-item">
<div className="age-range">{item.range}</div>
<div className="age-bar-wrapper">
<div
className="age-bar"
style={{
width: `${(item.count / mockData.mockInheritors.length) * 100}%`,
}}
>
<span className="age-count">{item.count}</span>
</div>
</div>
</div>
))}
</div>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title="传承人性别分布" className="data-card">
<div className="gender-chart">
<div className="gender-stats">
<div className="gender-item male">
<div className="gender-icon">👨</div>
<div className="gender-label"></div>
<div className="gender-count">
{mockData.mockInheritors.filter(i => i.gender === 'male').length}
</div>
<div className="gender-percentage">
{Math.round((mockData.mockInheritors.filter(i => i.gender === 'male').length / mockData.mockInheritors.length) * 100)}%
</div>
</div>
<div className="gender-item female">
<div className="gender-icon">👩</div>
<div className="gender-label"></div>
<div className="gender-count">
{mockData.mockInheritors.filter(i => i.gender === 'female').length}
</div>
<div className="gender-percentage">
{Math.round((mockData.mockInheritors.filter(i => i.gender === 'female').length / mockData.mockInheritors.length) * 100)}%
</div>
</div>
</div>
</div>
</Card>
</Col>
</Row>
</div>
</div>
</div>
)
}
export default DataVisualization

View File

@ -0,0 +1,126 @@
/* 非遗项目详情页样式 */
.heritage-detail-page {
min-height: 100vh;
background: #fafaf8;
}
.page-breadcrumb {
padding: 24px 0;
background: #ffffff;
border-bottom: 1px solid #f0ebe3;
}
.detail-content {
background: #fafaf8;
}
.detail-header {
margin-bottom: 32px;
}
.detail-title {
font-size: 32px;
font-weight: 700;
color: #2c2c2c;
margin-bottom: 16px;
font-family: 'Noto Serif SC', 'Songti SC', serif;
}
.detail-meta {
display: flex;
align-items: center;
gap: 24px;
font-size: 14px;
color: #666666;
}
.detail-meta .anticon {
margin-right: 6px;
color: #d4a574;
}
.detail-gallery {
margin-bottom: 32px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
.detail-gallery .ant-image {
border-radius: 8px;
overflow: hidden;
}
.detail-tabs {
margin-bottom: 40px;
}
.detail-text {
font-size: 15px;
line-height: 1.8;
color: #666666;
}
/* 侧边栏 */
.detail-sidebar {
position: sticky;
top: 80px;
}
.sidebar-actions {
margin-bottom: 24px;
}
.sidebar-actions button {
width: 100%;
}
.sidebar-info,
.sidebar-tags {
background: #ffffff;
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
}
.sidebar-info h3,
.sidebar-tags h3 {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
color: #2c2c2c;
}
.info-item {
display: flex;
padding: 12px 0;
border-bottom: 1px solid #f0ebe3;
}
.info-item:last-child {
border-bottom: none;
}
.info-label {
width: 80px;
color: #999999;
font-size: 14px;
}
.info-value {
flex: 1;
color: #2c2c2c;
font-size: 14px;
font-weight: 500;
}
@media (max-width: 992px) {
.detail-sidebar {
position: static;
}
.detail-title {
font-size: 24px;
}
}

View File

@ -0,0 +1,178 @@
/**
*
*/
import React, { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { Row, Col, Tag, Breadcrumb, Image, Tabs, Spin, Empty, Space } from 'antd'
import { HomeOutlined, EnvironmentOutlined, EyeOutlined, HeartOutlined } from '@ant-design/icons'
import { Link } from 'react-router-dom'
import type { HeritageItem } from '@types/index'
import { getHeritageById } from '@services/api'
import FavoriteButton from '@components/FavoriteButton'
import CommentSection from '@components/CommentSection'
import './Detail.css'
const { TabPane } = Tabs
const levelLabels: Record<string, string> = {
world: '世界级',
national: '国家级',
provincial: '省级',
municipal: '市级',
county: '县级',
}
const HeritageDetail: React.FC = () => {
const { id } = useParams<{ id: string }>()
const [data, setData] = useState<HeritageItem | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (id) {
fetchData(id)
}
}, [id])
const fetchData = async (heritageId: string) => {
setLoading(true)
try {
const result = await getHeritageById(heritageId)
setData(result)
} catch (error) {
console.error('Failed to fetch heritage detail:', error)
} finally {
setLoading(false)
}
}
if (loading) {
return (
<div className="loading-container">
<Spin size="large" />
</div>
)
}
if (!data) {
return (
<div className="empty-container">
<Empty description="未找到相关项目" />
</div>
)
}
return (
<div className="heritage-detail-page">
<div className="page-breadcrumb">
<div className="container">
<Breadcrumb>
<Breadcrumb.Item>
<Link to="/"><HomeOutlined /> </Link>
</Breadcrumb.Item>
<Breadcrumb.Item>
<Link to="/heritage"></Link>
</Breadcrumb.Item>
<Breadcrumb.Item>{data.name}</Breadcrumb.Item>
</Breadcrumb>
</div>
</div>
<div className="detail-content section-spacing-sm">
<div className="container">
<Row gutter={[40, 40]}>
<Col xs={24} lg={16}>
{/* 头部信息 */}
<div className="detail-header">
<h1 className="detail-title">{data.name}</h1>
<div className="detail-meta">
<Tag color="red">{levelLabels[data.level]}</Tag>
<span><EnvironmentOutlined /> {data.province}</span>
<span><EyeOutlined /> {data.viewCount.toLocaleString()}</span>
<span><HeartOutlined /> {data.likeCount.toLocaleString()}</span>
</div>
</div>
{/* 图片画廊 */}
<div className="detail-gallery">
<Image.PreviewGroup>
{data.images?.map((img, index) => (
<Image key={index} src={img} />
))}
</Image.PreviewGroup>
</div>
{/* 详细内容 */}
<Tabs defaultActiveKey="description" className="detail-tabs">
<TabPane tab="项目介绍" key="description">
<div className="detail-text">
<p>{data.description}</p>
</div>
</TabPane>
<TabPane tab="历史渊源" key="history">
<div className="detail-text">
<p>{data.history}</p>
</div>
</TabPane>
<TabPane tab="技艺特点" key="skills">
<div className="detail-text">
<p>{data.skills}</p>
</div>
</TabPane>
<TabPane tab="文化意义" key="significance">
<div className="detail-text">
<p>{data.significance}</p>
</div>
</TabPane>
</Tabs>
{/* 评论区 */}
<CommentSection targetType="heritage" targetId={data.id} showRating />
</Col>
<Col xs={24} lg={8}>
<div className="detail-sidebar">
<div className="sidebar-actions">
<FavoriteButton heritageId={data.id} size="large" />
</div>
<div className="sidebar-info">
<h3></h3>
<div className="info-item">
<span className="info-label"></span>
<span className="info-value">{data.category}</span>
</div>
<div className="info-item">
<span className="info-label"></span>
<span className="info-value">{levelLabels[data.level]}</span>
</div>
<div className="info-item">
<span className="info-label"></span>
<span className="info-value">{data.province}</span>
</div>
<div className="info-item">
<span className="info-label"></span>
<span className="info-value">{data.status === 'active' ? '正常传承' : '濒危'}</span>
</div>
</div>
{data.tags.length > 0 && (
<div className="sidebar-tags">
<h3></h3>
<Space wrap>
{data.tags.map((tag) => (
<Tag key={tag}>{tag}</Tag>
))}
</Space>
</div>
)}
</div>
</Col>
</Row>
</div>
</div>
</div>
)
}
export default HeritageDetail

161
src/pages/Heritage/List.css Normal file
View File

@ -0,0 +1,161 @@
/* 非遗项目列表页样式 */
.heritage-list-page {
min-height: 100vh;
}
.page-header {
background: linear-gradient(135deg, #c8363d 0%, #8b252b 100%);
padding: 80px 0 60px;
text-align: center;
color: #ffffff;
}
.page-header h1 {
font-size: 42px;
font-weight: 700;
color: #ffffff;
margin-bottom: 16px;
font-family: 'Noto Serif SC', 'Songti SC', serif;
}
.page-header p {
font-size: 18px;
color: rgba(255, 255, 255, 0.9);
margin: 0;
}
.page-content {
background: #ffffff;
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}
.pagination-container {
display: flex;
justify-content: center;
margin-top: 48px;
}
/* 筛选器区域样式 */
.filter-section {
background: #f5f0e8;
border-radius: 12px;
padding: 24px;
margin-bottom: 32px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.filter-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.filter-controls .ant-input-affix-wrapper {
height: 40px !important;
border-radius: 8px;
font-size: 14px;
padding: 4px 11px;
display: flex;
align-items: center;
}
.filter-controls .ant-input-affix-wrapper .ant-input {
height: 32px;
font-size: 14px;
}
.filter-controls .ant-input-affix-wrapper .ant-input-prefix {
margin-right: 8px;
}
.filter-controls .ant-select {
font-size: 14px;
}
.filter-controls .ant-select-selector {
height: 40px !important;
border-radius: 8px !important;
display: flex;
align-items: center;
}
.filter-controls .ant-btn {
height: 40px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
padding: 0 20px;
}
/* 筛选结果提示 */
.filter-result-info {
background: #ffffff;
border-radius: 8px;
padding: 12px 16px;
border-left: 3px solid #c8363d;
}
.filter-label {
color: #666666;
font-size: 14px;
font-weight: 500;
}
.filter-tag {
display: inline-block;
background: #fff9f0;
color: #c8363d;
padding: 4px 12px;
border-radius: 6px;
font-size: 13px;
font-weight: 400;
border: 1px solid rgba(200, 54, 61, 0.2);
}
/* 响应式 */
@media (max-width: 768px) {
.page-header {
padding: 60px 0 40px;
}
.page-header h1 {
font-size: 32px;
}
.page-header p {
font-size: 16px;
}
.filter-section {
padding: 16px;
margin-bottom: 24px;
}
.filter-controls {
flex-direction: column;
gap: 12px;
}
.filter-controls .ant-input,
.filter-controls .ant-select,
.filter-controls .ant-btn {
width: 100% !important;
}
.filter-result-info {
padding: 10px 12px;
}
.filter-label,
.filter-tag {
font-size: 12px;
}
}

287
src/pages/Heritage/List.tsx Normal file
View File

@ -0,0 +1,287 @@
/**
*
*/
import React, { useEffect, useState } from 'react'
import { Row, Col, Spin, Empty, Input, Select, Space, Button } from 'antd'
import { SearchOutlined, FilterOutlined, ReloadOutlined } from '@ant-design/icons'
import HeritageCard from '@components/HeritageCard'
import CustomPagination from '@components/CustomPagination'
import { getHeritageList } from '@services/api'
import type { HeritageItem, PaginationResult, HeritageCategory } from '@types/index'
import './List.css'
// 分类选项
const categoryOptions = [
{ label: '全部分类', value: '' },
{ label: '民间文学', value: 'folk-literature' },
{ label: '传统音乐', value: 'traditional-music' },
{ label: '传统舞蹈', value: 'traditional-dance' },
{ label: '传统戏剧', value: 'traditional-opera' },
{ label: '曲艺', value: 'folk-art' },
{ label: '传统体育、游艺与杂技', value: 'sports-acrobatics' },
{ label: '传统技艺', value: 'traditional-craft' },
{ label: '传统医药', value: 'traditional-medicine' },
{ label: '民俗', value: 'folk-custom' },
{ label: '传统美术', value: 'traditional-art' },
]
// 常用标签选项
const tagOptions = [
'传统技艺',
'传统美术',
'传统音乐',
'传统戏剧',
'民俗',
'金属工艺',
'刺绣',
'陶瓷',
'古琴',
'书法',
'造纸',
'宫廷艺术',
'江南文化',
'文人艺术',
'文房四宝',
]
const HeritageList: React.FC = () => {
const [data, setData] = useState<PaginationResult<HeritageItem> | null>(null)
const [loading, setLoading] = useState(true)
const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(12)
// 筛选条件状态
const [searchKeyword, setSearchKeyword] = useState('')
const [selectedCategory, setSelectedCategory] = useState<string>('')
const [selectedTags, setSelectedTags] = useState<string[]>([])
// 实际应用的筛选条件(用于提交后生效)
const [appliedFilters, setAppliedFilters] = useState({
keyword: '',
category: '',
tags: [] as string[],
})
const fetchData = async () => {
setLoading(true)
try {
// 获取所有数据(设置一个很大的 pageSize
const result = await getHeritageList({ page: 1, pageSize: 1000 })
setData(result)
} catch (error) {
console.error('Failed to fetch heritage list:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchData()
}, [])
const handlePageChange = (page: number, size: number) => {
// 只改变页码,不改变 pageSize
if (size === pageSize) {
setCurrentPage(page)
} else {
// pageSize 改变时,重置到第一页
setCurrentPage(1)
setPageSize(size)
}
window.scrollTo({ top: 0, behavior: 'smooth' })
}
const handleShowSizeChange = (current: number, size: number) => {
setCurrentPage(1)
setPageSize(size)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
// 应用筛选条件
const handleApplyFilters = () => {
setAppliedFilters({
keyword: searchKeyword,
category: selectedCategory,
tags: selectedTags,
})
setCurrentPage(1)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
// 重置筛选条件
const handleResetFilters = () => {
setSearchKeyword('')
setSelectedCategory('')
setSelectedTags([])
setAppliedFilters({
keyword: '',
category: '',
tags: [],
})
setCurrentPage(1)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
// 客户端筛选和分页数据
const getFilteredData = () => {
if (!data) return null
let filteredItems = [...data.data]
// 按名字搜索
if (appliedFilters.keyword) {
const keyword = appliedFilters.keyword.toLowerCase()
filteredItems = filteredItems.filter(
(item) =>
item.name.toLowerCase().includes(keyword) ||
item.description.toLowerCase().includes(keyword)
)
}
// 按分类筛选
if (appliedFilters.category) {
filteredItems = filteredItems.filter((item) => item.category === appliedFilters.category)
}
// 按标签筛选
if (appliedFilters.tags.length > 0) {
filteredItems = filteredItems.filter((item) =>
appliedFilters.tags.some((tag) => item.tags.includes(tag))
)
}
// 客户端分页
const total = filteredItems.length
const totalPages = Math.ceil(total / pageSize)
const start = (currentPage - 1) * pageSize
const end = start + pageSize
const paginatedItems = filteredItems.slice(start, end)
return {
data: paginatedItems,
total: total,
page: currentPage,
pageSize: pageSize,
totalPages: totalPages,
}
}
const filteredData = getFilteredData()
return (
<div className="heritage-list-page">
<div className="page-header">
<div className="container">
<h1></h1>
<p></p>
</div>
</div>
<div className="page-content section-spacing">
<div className="container-wide">
{/* 筛选器区域 */}
<div className="filter-section">
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Space wrap size="middle" className="filter-controls">
{/* 搜索框 */}
<Input
placeholder="搜索非遗项目名称或描述"
prefix={<SearchOutlined />}
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onPressEnter={handleApplyFilters}
style={{ width: 280 }}
allowClear
/>
{/* 分类选择 */}
<Select
placeholder="选择分类"
value={selectedCategory || undefined}
onChange={(value) => setSelectedCategory(value || '')}
style={{ width: 200 }}
options={categoryOptions}
allowClear
/>
{/* 标签选择 */}
<Select
mode="multiple"
placeholder="选择标签"
value={selectedTags}
onChange={setSelectedTags}
style={{ minWidth: 200, maxWidth: 400 }}
options={tagOptions.map((tag) => ({ label: tag, value: tag }))}
maxTagCount="responsive"
allowClear
/>
{/* 操作按钮 */}
<Button type="primary" icon={<FilterOutlined />} onClick={handleApplyFilters}>
</Button>
<Button icon={<ReloadOutlined />} onClick={handleResetFilters}>
</Button>
</Space>
{/* 筛选结果提示 */}
{(appliedFilters.keyword || appliedFilters.category || appliedFilters.tags.length > 0) && (
<div className="filter-result-info">
<Space wrap size="small">
<span className="filter-label"></span>
{appliedFilters.keyword && (
<span className="filter-tag">: {appliedFilters.keyword}</span>
)}
{appliedFilters.category && (
<span className="filter-tag">
:{' '}
{categoryOptions.find((opt) => opt.value === appliedFilters.category)
?.label}
</span>
)}
{appliedFilters.tags.map((tag) => (
<span key={tag} className="filter-tag">
: {tag}
</span>
))}
</Space>
</div>
)}
</Space>
</div>
{/* 内容区域 */}
{loading ? (
<div className="loading-container">
<Spin size="large" />
</div>
) : filteredData && filteredData.data.length > 0 ? (
<>
<Row gutter={[24, 24]}>
{filteredData.data.map((item) => (
<Col key={item.id} xs={24} sm={12} md={8} lg={6}>
<HeritageCard item={item} />
</Col>
))}
</Row>
<CustomPagination
current={currentPage}
total={filteredData.total}
pageSize={pageSize}
onChange={handlePageChange}
onShowSizeChange={handleShowSizeChange}
unit="项"
/>
</>
) : (
<Empty description="暂无数据" />
)}
</div>
</div>
</div>
)
}
export default HeritageList

558
src/pages/Home/index.css Normal file
View File

@ -0,0 +1,558 @@
/* 首页样式 */
.home-page {
width: 100%;
}
/* ===== Hero Section ===== */
.hero-section {
position: relative;
width: 100%;
height: 600px;
overflow: hidden;
}
.hero-carousel {
height: 100%;
}
.hero-carousel .slick-slide {
height: 600px;
}
.hero-slide {
position: relative;
width: 100%;
height: 600px;
display: flex !important;
align-items: center;
justify-content: center;
}
.hero-background {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
animation: ken-burns 20s ease-in-out infinite alternate;
}
@keyframes ken-burns {
0% {
transform: scale(1);
}
100% {
transform: scale(1.1);
}
}
.hero-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(
135deg,
rgba(200, 54, 61, 0.7) 0%,
rgba(139, 37, 43, 0.8) 100%
);
}
.hero-content {
position: relative;
z-index: 2;
width: 100%;
text-align: center;
color: #ffffff;
}
.hero-title {
font-size: 56px;
font-weight: 700;
color: #ffffff;
margin-bottom: 16px;
font-family: 'Noto Serif SC', 'Songti SC', serif;
text-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
animation-delay: 0.2s;
}
.hero-subtitle {
font-size: 24px;
color: rgba(255, 255, 255, 0.95);
margin-bottom: 40px;
font-weight: 300;
letter-spacing: 2px;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
animation-delay: 0.4s;
}
.hero-actions {
animation-delay: 0.6s;
}
.hero-actions .ant-btn {
height: 48px;
padding: 0 40px;
font-size: 16px;
border-radius: 24px;
}
.hero-actions .ant-btn-primary {
background: #ffffff;
color: #c8363d;
border: none;
font-weight: 600;
}
.hero-actions .ant-btn-primary:hover {
background: rgba(255, 255, 255, 0.9);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
.hero-actions .ant-btn:not(.ant-btn-primary) {
background: transparent;
color: #ffffff;
border: 2px solid #ffffff;
}
.hero-actions .ant-btn:not(.ant-btn-primary):hover {
background: rgba(255, 255, 255, 0.1);
border-color: #ffffff;
transform: translateY(-2px);
}
/* ===== Values Section ===== */
.values-section {
background: linear-gradient(180deg, #ffffff 0%, #fafaf8 100%);
position: relative;
}
.value-card {
text-align: center;
padding: 48px 32px;
background: #ffffff;
border-radius: 16px;
border: 2px solid #f0ebe3;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.value-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, var(--icon-color, #C8363D) 0%, transparent 100%);
opacity: 0;
transition: opacity 0.4s ease;
}
.value-card:hover::before {
opacity: 1;
}
.value-card:hover {
transform: translateY(-12px);
border-color: var(--icon-color, #C8363D);
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.12);
}
.value-icon-wrapper {
position: relative;
display: inline-block;
margin-bottom: 28px;
}
.value-icon {
width: 100px;
height: 100px;
margin: 0 auto;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--icon-color, #C8363D), rgba(var(--icon-color-rgb, 200, 54, 61), 0.7));
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
transform: rotate(45deg);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.value-card:hover .value-icon {
transform: rotate(45deg) scale(1.1);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.15);
}
.icon-text {
font-size: 42px;
font-weight: 700;
color: #ffffff;
transform: rotate(-45deg);
font-family: 'Noto Serif SC', 'Songti SC', serif;
}
.value-decoration {
position: absolute;
top: -10px;
right: -10px;
width: 30px;
height: 30px;
border: 3px solid var(--icon-color, #C8363D);
border-radius: 50%;
opacity: 0;
transform: scale(0.5);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.value-card:hover .value-decoration {
opacity: 1;
transform: scale(1);
}
.value-card h3 {
font-size: 22px;
font-weight: 600;
color: #2c2c2c;
margin-bottom: 16px;
font-family: 'Noto Serif SC', 'Songti SC', serif;
letter-spacing: 2px;
}
.value-divider {
width: 60px;
height: 3px;
background: linear-gradient(90deg, var(--icon-color, #C8363D) 0%, transparent 100%);
margin: 0 auto 16px;
border-radius: 2px;
}
.value-card p {
font-size: 15px;
color: #666666;
line-height: 1.8;
margin: 0;
}
/* ===== Categories Section ===== */
.categories-section {
background: #ffffff;
}
.category-card {
display: block;
padding: 32px 24px;
background: var(--category-bg, #ffffff);
border: 2px solid transparent;
border-radius: 16px;
text-decoration: none;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
min-height: 140px;
position: relative;
overflow: hidden;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
}
.category-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background: var(--category-color, #C8363D);
transform: scaleY(0);
transform-origin: top;
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.category-card:hover::before {
transform: scaleY(1);
}
.category-card:hover {
transform: translateY(-8px);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12);
border-color: var(--category-color, #C8363D);
}
.category-content {
position: relative;
z-index: 2;
}
.category-title {
font-size: 20px;
font-weight: 700;
color: #2c2c2c;
margin-bottom: 8px;
font-family: 'Noto Serif SC', 'Songti SC', serif;
letter-spacing: 2px;
transition: all 0.3s ease;
}
.category-card:hover .category-title {
color: var(--category-color, #C8363D);
transform: translateX(4px);
}
.category-desc {
font-size: 14px;
color: #666666;
font-family: 'Noto Serif SC', 'Songti SC', serif;
letter-spacing: 1px;
transition: all 0.3s ease;
}
.category-card:hover .category-desc {
color: #333333;
}
.category-corner {
position: absolute;
top: 12px;
right: 12px;
width: 40px;
height: 40px;
border-top: 3px solid var(--category-color, #C8363D);
border-right: 3px solid var(--category-color, #C8363D);
opacity: 0;
transform: translate(10px, -10px);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1;
}
.category-card:hover .category-corner {
opacity: 0.3;
transform: translate(0, 0);
}
.category-pattern {
position: absolute;
bottom: -20px;
right: -20px;
width: 100px;
height: 100px;
background: radial-gradient(circle, var(--category-color, #C8363D) 0%, transparent 70%);
opacity: 0;
transform: scale(0.5);
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 0;
}
.category-card:hover .category-pattern {
opacity: 0.1;
transform: scale(1);
}
/* ===== Section Header ===== */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 40px;
}
.section-header.text-center {
flex-direction: column;
text-align: center;
}
.section-header h2 {
font-size: 32px;
font-weight: 600;
color: #2c2c2c;
margin: 0 !important;
font-family: 'Noto Serif SC', 'Songti SC', serif;
}
.section-header p {
font-size: 16px;
color: #666666;
margin: 8px 0 0 0 !important;
}
.section-header a {
color: #c8363d;
font-size: 16px;
font-weight: 500;
text-decoration: none;
transition: all 0.3s ease;
}
.section-header a:hover {
color: #a82e34;
transform: translateX(4px);
}
/* ===== Featured Section ===== */
.featured-section {
background: #ffffff;
}
/* ===== Inheritors Section ===== */
.inheritors-section {
background: linear-gradient(135deg, #fff9f0 0%, #f5f0e8 100%);
}
/* ===== Statistics Section ===== */
.statistics-section {
background: #ffffff;
}
.stat-card {
text-align: center;
padding: 32px 24px;
background: #f5f0e8;
border-radius: 12px;
transition: all 0.3s ease;
}
.stat-card:hover {
transform: translateY(-4px);
background: #ffffff;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
}
.stat-card .ant-statistic {
margin: 0;
}
.stat-card .ant-statistic-title {
font-size: 14px;
color: #666666;
margin-bottom: 8px;
}
.stat-card .ant-statistic-content {
font-size: 32px;
font-weight: 700;
color: #c8363d;
font-family: 'Noto Serif SC', 'Songti SC', serif;
}
.stat-card .anticon {
font-size: 24px;
color: #d4a574;
margin-right: 8px;
}
/* ===== CTA Section ===== */
.cta-section {
background: linear-gradient(135deg, #c8363d 0%, #8b252b 100%);
position: relative;
overflow: hidden;
}
.cta-section .ant-btn-default.ant-btn-background-ghost {
border-color: #ffffff;
color: #ffffff;
}
.cta-section .ant-btn-default.ant-btn-background-ghost:hover {
background: #ffffff !important;
border-color: #ffffff !important;
color: #c8363d !important;
}
.cta-section .ant-btn:not(.ant-btn-background-ghost):hover {
background: rgba(255, 255, 255, 0.9) !important;
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
/* ===== 响应式 ===== */
@media (max-width: 992px) {
.hero-section,
.hero-carousel,
.hero-carousel .slick-slide,
.hero-slide {
height: 500px;
}
.hero-title {
font-size: 42px;
}
.hero-subtitle {
font-size: 20px;
}
.section-header h2 {
font-size: 28px;
}
}
@media (max-width: 768px) {
.hero-section,
.hero-carousel,
.hero-carousel .slick-slide,
.hero-slide {
height: 400px;
}
.hero-title {
font-size: 32px;
}
.hero-subtitle {
font-size: 16px;
}
.hero-actions .ant-btn {
height: 40px;
padding: 0 24px;
font-size: 14px;
}
.section-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.section-header h2 {
font-size: 24px;
}
.value-card {
padding: 36px 24px;
}
.value-icon {
width: 80px;
height: 80px;
}
.icon-text {
font-size: 36px;
}
.value-card h3 {
font-size: 20px;
}
.stat-card .ant-statistic-content {
font-size: 28px;
}
.category-card {
padding: 24px 20px;
min-height: 120px;
}
.category-title {
font-size: 18px;
}
.category-desc {
font-size: 13px;
}
}

312
src/pages/Home/index.tsx Normal file
View File

@ -0,0 +1,312 @@
/**
*
*/
import React, { useEffect, useState } from 'react'
import { Row, Col, Typography, Button, Statistic, Space, Carousel } from 'antd'
import {
ArrowRightOutlined,
GlobalOutlined,
TrophyOutlined,
TeamOutlined,
EnvironmentOutlined,
} from '@ant-design/icons'
import { Link } from 'react-router-dom'
import HeritageCard from '@components/HeritageCard'
import InheritorCard from '@components/InheritorCard'
import {
getStatistics,
getFeaturedHeritage,
getFeaturedInheritors,
} from '@services/api'
import type { HeritageItem, Inheritor, Statistics } from '@types/index'
import './index.css'
const { Title, Paragraph } = Typography
const categories = [
{ key: 'traditional-craft', label: '传统技艺', desc: '千年匠心', color: '#C8363D', bgGradient: 'linear-gradient(135deg, #FFEAE7 0%, #FFD6D1 100%)' },
{ key: 'traditional-art', label: '传统美术', desc: '丹青妙笔', color: '#D4A574', bgGradient: 'linear-gradient(135deg, #FFF4E6 0%, #FFE7C8 100%)' },
{ key: 'traditional-music', label: '传统音乐', desc: '余音绕梁', color: '#2A5E4D', bgGradient: 'linear-gradient(135deg, #E6F4F1 0%, #CCE8E1 100%)' },
{ key: 'traditional-opera', label: '传统戏剧', desc: '唱念做打', color: '#4A5F7F', bgGradient: 'linear-gradient(135deg, #E8EBF3 0%, #D1D7E8 100%)' },
{ key: 'folk-custom', label: '民俗', desc: '礼仪风尚', color: '#C8363D', bgGradient: 'linear-gradient(135deg, #FFEAE7 0%, #FFD6D1 100%)' },
{ key: 'traditional-medicine', label: '传统医药', desc: '岐黄之术', color: '#2A5E4D', bgGradient: 'linear-gradient(135deg, #E6F4F1 0%, #CCE8E1 100%)' },
{ key: 'folk-literature', label: '民间文学', desc: '口传心授', color: '#D4A574', bgGradient: 'linear-gradient(135deg, #FFF4E6 0%, #FFE7C8 100%)' },
{ key: 'traditional-dance', label: '传统舞蹈', desc: '翩若惊鸿', color: '#4A5F7F', bgGradient: 'linear-gradient(135deg, #E8EBF3 0%, #D1D7E8 100%)' },
]
const heroSlides = [
{
image: 'https://images.unsplash.com/photo-1610701596007-11502861dcfa?w=1600',
title: '传承千年技艺',
subtitle: '守护中华文化根脉',
},
{
image: 'https://images.unsplash.com/photo-1617038260897-41a1f14a8ca0?w=1600',
title: '匠心独运',
subtitle: '让非遗文化重焕新生',
},
{
image: 'https://images.unsplash.com/photo-1544947950-fa07a98d237f?w=1600',
title: '文化自信',
subtitle: '弘扬中华优秀传统文化',
},
]
const Home: React.FC = () => {
const [statistics, setStatistics] = useState<Statistics | null>(null)
const [featuredHeritage, setFeaturedHeritage] = useState<HeritageItem[]>([])
const [featuredInheritors, setFeaturedInheritors] = useState<Inheritor[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchData = async () => {
try {
const [stats, heritage, inheritors] = await Promise.all([
getStatistics(),
getFeaturedHeritage(6),
getFeaturedInheritors(4),
])
setStatistics(stats)
setFeaturedHeritage(heritage)
setFeaturedInheritors(inheritors)
} catch (error) {
console.error('Failed to fetch data:', error)
} finally {
setLoading(false)
}
}
fetchData()
}, [])
return (
<div className="home-page">
{/* Hero Banner */}
<section className="hero-section">
<Carousel autoplay autoplaySpeed={5000} effect="fade" className="hero-carousel">
{heroSlides.map((slide, index) => (
<div key={index} className="hero-slide">
<div
className="hero-background"
style={{ backgroundImage: `url(${slide.image})` }}
/>
<div className="hero-overlay" />
<div className="hero-content">
<div className="container">
<h1 className="hero-title animate-fade-in-up">{slide.title}</h1>
<p className="hero-subtitle animate-fade-in-up">{slide.subtitle}</p>
<Space size="large" className="hero-actions animate-fade-in-up">
<Button type="primary" size="large" href="#featured">
</Button>
<Button size="large" href="/heritage">
</Button>
</Space>
</div>
</div>
</div>
))}
</Carousel>
</section>
{/* 核心价值 */}
<section className="values-section section-spacing">
<div className="container">
<Row gutter={[32, 32]}>
<Col xs={24} sm={8}>
<div className="value-card">
<div className="value-icon-wrapper">
<div className="value-icon" style={{ '--icon-color': '#C8363D' } as React.CSSProperties}>
<div className="icon-text"></div>
</div>
<div className="value-decoration"></div>
</div>
<h3></h3>
<div className="value-divider"></div>
<p></p>
</div>
</Col>
<Col xs={24} sm={8}>
<div className="value-card">
<div className="value-icon-wrapper">
<div className="value-icon" style={{ '--icon-color': '#D4A574' } as React.CSSProperties}>
<div className="icon-text"></div>
</div>
<div className="value-decoration"></div>
</div>
<h3></h3>
<div className="value-divider"></div>
<p></p>
</div>
</Col>
<Col xs={24} sm={8}>
<div className="value-card">
<div className="value-icon-wrapper">
<div className="value-icon" style={{ '--icon-color': '#2A5E4D' } as React.CSSProperties}>
<div className="icon-text"></div>
</div>
<div className="value-decoration"></div>
</div>
<h3></h3>
<div className="value-divider"></div>
<p></p>
</div>
</Col>
</Row>
</div>
</section>
{/* 分类导航 */}
<section className="categories-section section-spacing-sm">
<div className="container">
<div className="section-header text-center">
<Title level={2}></Title>
<Paragraph></Paragraph>
</div>
<Row gutter={[24, 24]}>
{categories.map((category) => (
<Col key={category.key} xs={12} sm={12} md={6} lg={6}>
<Link
to={`/heritage/categories/${category.key}`}
className="category-card"
style={{
'--category-color': category.color,
'--category-bg': category.bgGradient,
} as React.CSSProperties}
>
<div className="category-content">
<div className="category-title">{category.label}</div>
<div className="category-desc">{category.desc}</div>
</div>
<div className="category-corner"></div>
<div className="category-pattern"></div>
</Link>
</Col>
))}
</Row>
</div>
</section>
{/* 精选非遗项目 */}
<section id="featured" className="featured-section section-spacing">
<div className="container">
<div className="section-header">
<Title level={2}></Title>
<Link to="/heritage">
<ArrowRightOutlined />
</Link>
</div>
<Row gutter={[24, 24]}>
{featuredHeritage.map((item) => (
<Col key={item.id} xs={24} sm={12} lg={8}>
<HeritageCard item={item} />
</Col>
))}
</Row>
</div>
</section>
{/* 代表性传承人 */}
<section className="inheritors-section section-spacing gradient-bg-warm">
<div className="container">
<div className="section-header text-center">
<Title level={2}></Title>
<Paragraph></Paragraph>
</div>
<Row gutter={[24, 24]}>
{featuredInheritors.map((item) => (
<Col key={item.id} xs={24} sm={12} lg={6}>
<InheritorCard item={item} />
</Col>
))}
</Row>
<div className="text-center" style={{ marginTop: 40 }}>
<Link to="/inheritors">
<Button type="primary" size="large">
<ArrowRightOutlined />
</Button>
</Link>
</div>
</div>
</section>
{/* 统计数据 */}
{statistics && (
<section className="statistics-section section-spacing">
<div className="container">
<Row gutter={[32, 32]}>
<Col xs={12} sm={6}>
<div className="stat-card">
<Statistic
title="非遗项目"
value={statistics.totalHeritageItems}
prefix={<GlobalOutlined />}
suffix="项"
/>
</div>
</Col>
<Col xs={12} sm={6}>
<div className="stat-card">
<Statistic
title="传承人"
value={statistics.totalInheritors}
prefix={<TeamOutlined />}
suffix="位"
/>
</div>
</Col>
<Col xs={12} sm={6}>
<div className="stat-card">
<Statistic
title="世界级遗产"
value={statistics.worldHeritage}
prefix={<TrophyOutlined />}
suffix="项"
/>
</div>
</Col>
<Col xs={12} sm={6}>
<div className="stat-card">
<Statistic
title="覆盖省份"
value={statistics.totalProvinces}
prefix={<EnvironmentOutlined />}
suffix="个"
/>
</div>
</Col>
</Row>
</div>
</section>
)}
{/* CTA 区块 */}
<section className="cta-section section-spacing gradient-bg-primary pattern-wave">
<div className="container text-center">
<Title level={2} style={{ color: '#ffffff', marginBottom: 16 }}>
</Title>
<Paragraph style={{ color: 'rgba(255,255,255,0.9)', fontSize: 16, marginBottom: 32 }}>
</Paragraph>
<Space size="large">
<Link to="/learn">
<Button type="default" size="large" ghost>
</Button>
</Link>
<Link to="/about">
<Button size="large" style={{ background: '#fff', color: '#C8363D' }}>
</Button>
</Link>
</Space>
</div>
</section>
</div>
)
}
export default Home

View File

@ -0,0 +1,276 @@
/* 传承人详情页样式 */
.inheritor-detail-page {
min-height: 100vh;
background: #fafaf8;
}
.page-breadcrumb {
padding: 24px 0;
background: #ffffff;
border-bottom: 1px solid #f0ebe3;
}
.detail-content {
background: #fafaf8;
}
/* 传承人信息头部 */
.detail-header {
margin-bottom: 32px;
background: #ffffff;
padding: 32px;
border-radius: 12px;
}
.inheritor-profile {
display: flex;
gap: 24px;
align-items: flex-start;
}
.inheritor-avatar {
flex-shrink: 0;
}
.inheritor-info {
flex: 1;
}
.detail-title {
font-size: 32px;
font-weight: 700;
color: #2c2c2c;
margin-bottom: 8px;
font-family: 'Noto Serif SC', 'Songti SC', serif;
}
.inheritor-title {
font-size: 16px;
color: #d4a574;
font-weight: 500;
margin-bottom: 16px;
}
.detail-meta {
display: flex;
align-items: center;
gap: 16px;
font-size: 14px;
color: #666666;
margin-bottom: 12px;
flex-wrap: wrap;
}
.detail-meta .anticon {
margin-right: 6px;
color: #d4a574;
}
.detail-stats {
display: flex;
gap: 24px;
font-size: 14px;
color: #666666;
padding-top: 16px;
border-top: 1px solid #f0ebe3;
}
.detail-stats .anticon {
margin-right: 6px;
color: #d4a574;
}
/* 详情卡片区块 */
.detail-section {
margin-bottom: 32px;
}
/* 作品展示 */
.detail-gallery {
margin-bottom: 32px;
background: #ffffff;
padding: 32px;
border-radius: 12px;
}
.detail-gallery h2 {
font-size: 20px;
font-weight: 600;
color: #2c2c2c;
margin-bottom: 24px;
font-family: 'Noto Serif SC', 'Songti SC', serif;
}
.detail-gallery .ant-image-preview-group {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
.detail-gallery .ant-image {
border-radius: 8px;
overflow: hidden;
}
/* 详细内容标签页 */
.detail-tabs {
margin-bottom: 40px;
background: #ffffff;
border-radius: 12px;
padding: 24px;
}
.detail-text {
font-size: 15px;
line-height: 1.8;
color: #666666;
}
.detail-text p {
margin-bottom: 16px;
}
/* 获奖记录 */
.awards-section {
margin-top: 32px;
padding-top: 32px;
border-top: 1px solid #f0ebe3;
}
.awards-section h3 {
font-size: 18px;
font-weight: 600;
color: #2c2c2c;
margin-bottom: 16px;
}
.awards-list {
list-style: none;
padding: 0;
}
.awards-list li {
padding: 12px 0;
border-bottom: 1px solid #f5f0e8;
font-size: 14px;
color: #666666;
}
.awards-list li:last-child {
border-bottom: none;
}
/* 侧边栏 */
.detail-sidebar {
position: sticky;
top: 80px;
}
.sidebar-actions {
margin-bottom: 24px;
}
.sidebar-actions button {
width: 100%;
}
.sidebar-info {
background: #ffffff;
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
}
.sidebar-info h3 {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
color: #2c2c2c;
}
.info-item {
display: flex;
padding: 12px 0;
border-bottom: 1px solid #f0ebe3;
}
.info-item:last-child {
border-bottom: none;
}
.info-label {
width: 80px;
color: #999999;
font-size: 14px;
}
.info-value {
flex: 1;
color: #2c2c2c;
font-size: 14px;
font-weight: 500;
}
.sidebar-card {
border-radius: 12px;
margin-bottom: 24px;
}
.sidebar-card .ant-card-head {
border-bottom: 1px solid #f0ebe3;
}
.sidebar-card .ant-card-head-title {
font-size: 16px;
font-weight: 600;
color: #2c2c2c;
}
/* 响应式 */
@media (max-width: 992px) {
.detail-sidebar {
position: static;
}
.detail-title {
font-size: 24px;
}
.inheritor-profile {
flex-direction: column;
align-items: center;
text-align: center;
}
.detail-meta {
justify-content: center;
}
.detail-stats {
justify-content: center;
}
}
@media (max-width: 576px) {
.detail-header {
padding: 20px;
}
.detail-gallery {
padding: 20px;
}
.inheritor-avatar {
width: 80px !important;
height: 80px !important;
}
.detail-title {
font-size: 20px;
}
.detail-gallery .ant-image-preview-group {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
}

View File

@ -0,0 +1,366 @@
/**
*
*/
import React, { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { Row, Col, Tag, Breadcrumb, Image, Tabs, Spin, Empty, Avatar, Card, Typography } from 'antd'
import {
HomeOutlined,
EnvironmentOutlined,
EyeOutlined,
TrophyOutlined,
UserOutlined,
CalendarOutlined,
ManOutlined,
WomanOutlined,
TeamOutlined,
PlayCircleOutlined,
} from '@ant-design/icons'
import { Link } from 'react-router-dom'
import type { Inheritor } from '@types/index'
import { getInheritorById } from '@services/api'
import './Detail.css'
const { Title, Paragraph, Text } = Typography
const levelLabels: Record<string, string> = {
national: '国家级',
provincial: '省级',
municipal: '市级',
}
const InheritorDetail: React.FC = () => {
const { id } = useParams<{ id: string }>()
const [data, setData] = useState<Inheritor | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (id) {
fetchData(id)
}
}, [id])
const fetchData = async (inheritorId: string) => {
setLoading(true)
try {
const result = await getInheritorById(inheritorId)
setData(result)
} catch (error) {
console.error('Failed to fetch inheritor detail:', error)
} finally {
setLoading(false)
}
}
if (loading) {
return (
<div className="loading-container">
<Spin size="large" />
</div>
)
}
if (!data) {
return (
<div className="empty-container" style={{ padding: '100px 20px', textAlign: 'center' }}>
<Empty description="未找到相关传承人" />
</div>
)
}
const age = new Date().getFullYear() - data.birthYear
return (
<div className="inheritor-detail-page">
{/* 面包屑导航 */}
<div className="page-breadcrumb">
<div className="container">
<Breadcrumb>
<Breadcrumb.Item>
<Link to="/">
<HomeOutlined />
</Link>
</Breadcrumb.Item>
<Breadcrumb.Item>
<Link to="/inheritors"></Link>
</Breadcrumb.Item>
<Breadcrumb.Item>{data.name}</Breadcrumb.Item>
</Breadcrumb>
</div>
</div>
{/* 封面图 */}
{data.coverImage && (
<div className="detail-cover" style={{ backgroundImage: `url(${data.coverImage})` }}>
<div className="cover-overlay"></div>
</div>
)}
{/* 详情内容 */}
<div className="detail-content section-spacing-sm">
<div className="container">
<Row gutter={[40, 40]}>
<Col xs={24} lg={16}>
{/* 头部信息 */}
<div className="detail-header">
<div className="inheritor-profile">
<Avatar size={120} src={data.avatar} icon={<UserOutlined />} className="inheritor-avatar" />
<div className="inheritor-info">
<Title level={1} className="detail-title">
{data.name}
</Title>
<Text className="inheritor-title">{data.title}</Text>
<div className="detail-meta">
<Tag color="red">{levelLabels[data.level]}</Tag>
<span>
<EnvironmentOutlined /> {data.province} {data.city && `· ${data.city}`}
</span>
<span>
{data.gender === 'male' ? <ManOutlined /> : <WomanOutlined />}
{data.gender === 'male' ? '男' : '女'}
</span>
<span>
<CalendarOutlined /> {age}
</span>
</div>
<div className="detail-stats">
<span>
<EyeOutlined /> {data.viewCount.toLocaleString()}
</span>
<span>
<TeamOutlined /> {data.followers.toLocaleString()}
</span>
</div>
</div>
</div>
</div>
{/* 个人简介 */}
<Card title="个人简介" className="detail-section">
<Paragraph style={{ fontSize: 15, lineHeight: 1.8, color: '#666' }}>
{data.bio}
</Paragraph>
</Card>
{/* 技艺特点 */}
<Card title="技艺特点" className="detail-section">
<Paragraph style={{ fontSize: 15, lineHeight: 1.8, color: '#666' }}>
{data.masterSkills}
</Paragraph>
</Card>
{/* 成就荣誉 */}
{data.achievements && data.achievements.length > 0 && (
<Card title="成就荣誉" className="detail-section">
<div className="achievements-grid">
{data.achievements.map((achievement) => (
<div key={achievement.id} className="achievement-item">
<div className="achievement-header">
<TrophyOutlined style={{ color: '#d4a574', fontSize: 20 }} />
<Title level={5} style={{ margin: '0 0 8px 0' }}>
{achievement.title}
</Title>
</div>
<Text type="secondary" style={{ fontSize: 13 }}>
{achievement.date}
</Text>
<Paragraph style={{ marginTop: 12, fontSize: 14, color: '#666' }}>
{achievement.description}
</Paragraph>
{achievement.images && achievement.images.length > 0 && (
<Image.PreviewGroup>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
{achievement.images.map((img, idx) => (
<Image key={idx} src={img} width={100} style={{ borderRadius: 8 }} />
))}
</div>
</Image.PreviewGroup>
)}
</div>
))}
</div>
</Card>
)}
{/* 获奖记录 */}
{data.awards && data.awards.length > 0 && (
<Card title="获奖记录" className="detail-section">
<div className="awards-list">
{data.awards.map((award) => (
<div key={award.id} className="award-item">
<TrophyOutlined style={{ color: '#d4a574', marginRight: 12 }} />
<div className="award-info">
<Text strong>{award.name}</Text>
<Text type="secondary" style={{ display: 'block', fontSize: 13 }}>
{award.organization} · {award.year} · {award.level}
</Text>
</div>
</div>
))}
</div>
</Card>
)}
{/* 代表作品 */}
{data.works && data.works.length > 0 && (
<Card title="代表作品" className="detail-section">
<div className="works-grid">
{data.works.map((work) => (
<div key={work.id} className="work-item">
<Image src={work.image} style={{ borderRadius: 8 }} />
<Title level={5} style={{ marginTop: 12 }}>
{work.name}
</Title>
<Paragraph ellipsis={{ rows: 2 }} style={{ fontSize: 13, color: '#666' }}>
{work.description}
</Paragraph>
<Text type="secondary" style={{ fontSize: 12 }}>
{work.year}
</Text>
</div>
))}
</div>
</Card>
)}
{/* 相关视频 */}
{data.videos && data.videos.length > 0 && (
<Card title="相关视频" className="detail-section">
<div className="videos-grid">
{data.videos.map((video) => (
<div key={video.id} className="video-item">
<div className="video-cover" style={{ backgroundImage: `url(${video.cover})` }}>
<PlayCircleOutlined style={{ fontSize: 48, color: '#fff' }} />
</div>
<Title level={5} style={{ marginTop: 12 }}>
{video.title}
</Title>
{video.description && (
<Paragraph ellipsis={{ rows: 2 }} style={{ fontSize: 13, color: '#666' }}>
{video.description}
</Paragraph>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: '#999' }}>
<span>
<EyeOutlined /> {video.viewCount.toLocaleString()}
</span>
<span>{Math.floor(video.duration / 60)}:{(video.duration % 60).toString().padStart(2, '0')}</span>
</div>
</div>
))}
</div>
</Card>
)}
</Col>
{/* 侧边栏 */}
<Col xs={24} lg={8}>
<div className="detail-sidebar">
{/* 基本信息 */}
<Card title="基本信息" className="sidebar-card">
<div className="info-item">
<span className="info-label"></span>
<span className="info-value">{data.name}</span>
</div>
<div className="info-item">
<span className="info-label"></span>
<span className="info-value">{data.gender === 'male' ? '男' : '女'}</span>
</div>
<div className="info-item">
<span className="info-label"></span>
<span className="info-value">{age}</span>
</div>
<div className="info-item">
<span className="info-label"></span>
<span className="info-value">{levelLabels[data.level]}</span>
</div>
<div className="info-item">
<span className="info-label"></span>
<span className="info-value">
{data.province}
{data.city && ` · ${data.city}`}
</span>
</div>
</Card>
{/* 联系方式 */}
{data.contactInfo && (
<Card title="联系方式" className="sidebar-card">
{data.contactInfo.phone && (
<div className="info-item">
<span className="info-label"></span>
<span className="info-value">{data.contactInfo.phone}</span>
</div>
)}
{data.contactInfo.email && (
<div className="info-item">
<span className="info-label"></span>
<span className="info-value">{data.contactInfo.email}</span>
</div>
)}
{data.contactInfo.address && (
<div className="info-item">
<span className="info-label"></span>
<span className="info-value">{data.contactInfo.address}</span>
</div>
)}
{data.contactInfo.website && (
<div className="info-item">
<span className="info-label"></span>
<span className="info-value">
<a href={data.contactInfo.website} target="_blank" rel="noopener noreferrer">
{data.contactInfo.website}
</a>
</span>
</div>
)}
</Card>
)}
{/* 社交媒体 */}
{data.socialMedia && (
<Card title="社交媒体" className="sidebar-card">
{data.socialMedia.weibo && (
<div className="info-item">
<span className="info-label"></span>
<span className="info-value">{data.socialMedia.weibo}</span>
</div>
)}
{data.socialMedia.wechat && (
<div className="info-item">
<span className="info-label"></span>
<span className="info-value">{data.socialMedia.wechat}</span>
</div>
)}
{data.socialMedia.douyin && (
<div className="info-item">
<span className="info-label"></span>
<span className="info-value">{data.socialMedia.douyin}</span>
</div>
)}
{data.socialMedia.bilibili && (
<div className="info-item">
<span className="info-label">B站</span>
<span className="info-value">{data.socialMedia.bilibili}</span>
</div>
)}
</Card>
)}
{/* 技艺传承 */}
<Card title="技艺传承" className="sidebar-card">
<Paragraph style={{ color: '#666', fontSize: 14, lineHeight: 1.6, margin: 0 }}>
</Paragraph>
</Card>
</div>
</Col>
</Row>
</div>
</div>
</div>
)
}
export default InheritorDetail

View File

@ -0,0 +1,58 @@
/* 传承人列表页样式 */
.inheritors-list-page {
min-height: 100vh;
}
.page-header {
background: linear-gradient(135deg, #2a5e4d 0%, #1a4a3a 100%);
padding: 80px 0 60px;
text-align: center;
color: #ffffff;
}
.page-header h1 {
font-size: 42px;
font-weight: 700;
color: #ffffff;
margin-bottom: 16px;
font-family: 'Noto Serif SC', 'Songti SC', serif;
}
.page-header p {
font-size: 18px;
color: rgba(255, 255, 255, 0.9);
margin: 0;
}
.page-content {
background: #ffffff;
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}
.pagination-container {
display: flex;
justify-content: center;
margin-top: 48px;
}
/* 响应式 */
@media (max-width: 768px) {
.page-header {
padding: 60px 0 40px;
}
.page-header h1 {
font-size: 32px;
}
.page-header p {
font-size: 16px;
}
}

View File

@ -0,0 +1,89 @@
/**
*
*/
import React, { useEffect, useState } from 'react'
import { Row, Col, Spin, Empty } from 'antd'
import InheritorCard from '@components/InheritorCard'
import CustomPagination from '@components/CustomPagination'
import { getInheritorList } from '@services/api'
import type { Inheritor, PaginationResult } from '@types/index'
import './List.css'
const InheritorsList: React.FC = () => {
const [data, setData] = useState<PaginationResult<Inheritor> | null>(null)
const [loading, setLoading] = useState(true)
const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(12)
const fetchData = async (page: number, size: number) => {
setLoading(true)
try {
const result = await getInheritorList({ page, pageSize: size })
setData(result)
} catch (error) {
console.error('Failed to fetch inheritors list:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchData(currentPage, pageSize)
}, [currentPage, pageSize])
const handlePageChange = (page: number, size: number) => {
setCurrentPage(page)
setPageSize(size)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
const handleShowSizeChange = (current: number, size: number) => {
setCurrentPage(1)
setPageSize(size)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
return (
<div className="inheritors-list-page">
<div className="page-header">
<div className="container">
<h1></h1>
<p></p>
</div>
</div>
<div className="page-content section-spacing">
<div className="container">
{loading ? (
<div className="loading-container">
<Spin size="large" />
</div>
) : data && data.data.length > 0 ? (
<>
<Row gutter={[24, 24]}>
{data.data.map((item) => (
<Col key={item.id} xs={24} sm={12} lg={6}>
<InheritorCard item={item} />
</Col>
))}
</Row>
<CustomPagination
current={data.page}
total={data.total}
pageSize={data.pageSize}
onChange={handlePageChange}
onShowSizeChange={handleShowSizeChange}
unit="位传承人"
/>
</>
) : (
<Empty description="暂无数据" />
)}
</div>
</div>
</div>
)
}
export default InheritorsList

133
src/pages/Search/index.css Normal file
View File

@ -0,0 +1,133 @@
/* 搜索页样式 */
.search-page {
min-height: 100vh;
background: #fafaf8;
}
.search-header {
background: linear-gradient(135deg, #d4a574 0%, #c8a060 100%);
padding: 80px 0 60px;
text-align: center;
color: #ffffff;
}
.search-header h1 {
font-size: 42px;
font-weight: 700;
color: #ffffff;
margin-bottom: 32px;
font-family: 'Noto Serif SC', 'Songti SC', serif;
}
.search-input {
max-width: 600px;
margin: 0 auto 24px;
}
.search-input .ant-input-wrapper {
border-radius: 24px;
overflow: hidden;
}
.search-input input {
border: none;
font-size: 16px;
padding: 12px 24px;
}
.search-input .ant-btn {
height: 48px;
border: none;
background: #c8363d;
color: #ffffff;
font-weight: 600;
}
.search-input .ant-btn:hover {
background: #a82f35;
}
.search-stats {
color: rgba(255, 255, 255, 0.9);
font-size: 16px;
margin: 0;
}
.search-stats strong {
color: #ffffff;
font-weight: 700;
font-size: 20px;
margin: 0 8px;
}
/* 搜索结果 */
.search-content {
background: #fafaf8;
}
.search-content .ant-tabs {
background: #ffffff;
border-radius: 12px;
padding: 24px;
}
.result-section {
margin-bottom: 48px;
}
.result-section:last-child {
margin-bottom: 0;
}
.result-section h2 {
font-size: 20px;
font-weight: 600;
color: #2c2c2c;
margin-bottom: 24px;
padding-bottom: 12px;
border-bottom: 2px solid #d4a574;
font-family: 'Noto Serif SC', 'Songti SC', serif;
}
.card-cover {
position: relative;
width: 100%;
padding-top: 66.67%;
overflow: hidden;
background: #f5f0e8;
}
.card-cover img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
/* 响应式 */
@media (max-width: 768px) {
.search-header {
padding: 60px 0 40px;
}
.search-header h1 {
font-size: 32px;
}
.search-input {
max-width: 100%;
}
}
@media (max-width: 576px) {
.search-header h1 {
font-size: 28px;
}
.result-section h2 {
font-size: 18px;
}
}

266
src/pages/Search/index.tsx Normal file
View File

@ -0,0 +1,266 @@
/**
*
*/
import React, { useEffect, useState } from 'react'
import { useSearchParams, Link } from 'react-router-dom'
import { Input, Tabs, List, Card, Tag, Empty, Spin, Row, Col, Avatar } from 'antd'
import {
SearchOutlined,
EnvironmentOutlined,
UserOutlined,
BookOutlined,
FileTextOutlined,
} from '@ant-design/icons'
import type { SearchResults } from '@types/index'
import { searchAll } from '@services/api'
import './index.css'
const { Search } = Input
const { TabPane } = Tabs
const SearchPage: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams()
const keyword = searchParams.get('q') || ''
const [searchKeyword, setSearchKeyword] = useState(keyword)
const [results, setResults] = useState<SearchResults | null>(null)
const [loading, setLoading] = useState(false)
const [activeTab, setActiveTab] = useState('all')
useEffect(() => {
if (keyword) {
setSearchKeyword(keyword)
handleSearch(keyword)
}
}, [keyword])
const handleSearch = async (value: string) => {
if (!value.trim()) return
setLoading(true)
try {
const result = await searchAll(value)
setResults(result)
setSearchParams({ q: value })
} catch (error) {
console.error('Search failed:', error)
} finally {
setLoading(false)
}
}
const getTotalCount = () => {
if (!results) return 0
return (
results.heritages.length +
results.inheritors.length +
results.news.length
)
}
return (
<div className="search-page">
<div className="search-header">
<div className="container">
<h1></h1>
<Search
placeholder="搜索非遗项目、传承人、课程..."
allowClear
enterButton={<SearchOutlined />}
size="large"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onSearch={handleSearch}
className="search-input"
/>
{results && (
<p className="search-stats">
<strong>{getTotalCount()}</strong>
</p>
)}
</div>
</div>
<div className="search-content section-spacing-sm">
<div className="container">
{loading ? (
<div className="loading-container">
<Spin size="large" />
</div>
) : results ? (
<Tabs activeKey={activeTab} onChange={setActiveTab}>
<TabPane
tab={`全部 (${getTotalCount()})`}
key="all"
>
{getTotalCount() > 0 ? (
<div className="search-results-all">
{/* 非遗项目 */}
{results.heritages.length > 0 && (
<div className="result-section">
<h2> ({results.heritages.length})</h2>
<Row gutter={[24, 24]}>
{results.heritages.slice(0, 3).map((item) => (
<Col key={item.id} xs={24} sm={12} lg={8}>
<Link to={`/heritage/${item.id}`}>
<Card
hoverable
cover={
<div className="card-cover">
<img alt={item.name} src={item.images[0]} />
</div>
}
>
<Card.Meta
title={item.name}
description={
<div>
<Tag color="red">{item.level}</Tag>
<Tag>{item.category}</Tag>
</div>
}
/>
</Card>
</Link>
</Col>
))}
</Row>
</div>
)}
{/* 传承人 */}
{results.inheritors.length > 0 && (
<div className="result-section">
<h2> ({results.inheritors.length})</h2>
<List
itemLayout="horizontal"
dataSource={results.inheritors.slice(0, 3)}
renderItem={(item) => (
<List.Item>
<List.Item.Meta
avatar={<Avatar size={64} src={item.avatar} />}
title={<Link to={`/inheritor/${item.id}`}>{item.name}</Link>}
description={
<div>
<p>{item.title}</p>
<div>
<Tag>{item.category}</Tag>
<Tag color="red">{item.level}</Tag>
<span style={{ marginLeft: 12, color: '#999' }}>
<EnvironmentOutlined /> {item.province}
</span>
</div>
</div>
}
/>
</List.Item>
)}
/>
</div>
)}
{/* 新闻 */}
{results.news.length > 0 && (
<div className="result-section">
<h2> ({results.news.length})</h2>
<List
itemLayout="horizontal"
dataSource={results.news.slice(0, 3)}
renderItem={(item) => (
<List.Item>
<List.Item.Meta
avatar={<FileTextOutlined style={{ fontSize: 32, color: '#d4a574' }} />}
title={<Link to={`/news/${item.id}`}>{item.title}</Link>}
description={item.summary}
/>
</List.Item>
)}
/>
</div>
)}
</div>
) : (
<Empty description="未找到相关结果" />
)}
</TabPane>
<TabPane
tab={`非遗项目 (${results.heritages.length})`}
key="heritage"
>
{results.heritages.length > 0 ? (
<Row gutter={[24, 24]}>
{results.heritages.map((item) => (
<Col key={item.id} xs={24} sm={12} lg={8}>
<Link to={`/heritage/${item.id}`}>
<Card
hoverable
cover={
<div className="card-cover">
<img alt={item.name} src={item.images[0]} />
</div>
}
>
<Card.Meta
title={item.name}
description={
<div>
<Tag color="red">{item.level}</Tag>
<Tag>{item.category}</Tag>
</div>
}
/>
</Card>
</Link>
</Col>
))}
</Row>
) : (
<Empty description="未找到相关非遗项目" />
)}
</TabPane>
<TabPane
tab={`传承人 (${results.inheritors.length})`}
key="inheritor"
>
{results.inheritors.length > 0 ? (
<List
itemLayout="horizontal"
dataSource={results.inheritors}
renderItem={(item) => (
<List.Item>
<List.Item.Meta
avatar={<Avatar size={64} src={item.avatar} />}
title={<Link to={`/inheritor/${item.id}`}>{item.name}</Link>}
description={
<div>
<p>{item.title}</p>
<div>
<Tag>{item.category}</Tag>
<Tag color="red">{item.level}</Tag>
<span style={{ marginLeft: 12, color: '#999' }}>
<EnvironmentOutlined /> {item.province}
</span>
</div>
</div>
}
/>
</List.Item>
)}
/>
) : (
<Empty description="未找到相关传承人" />
)}
</TabPane>
</Tabs>
) : (
<Empty description="请输入搜索关键词" />
)}
</div>
</div>
</div>
)
}
export default SearchPage

172
src/pages/User/Center.css Normal file
View File

@ -0,0 +1,172 @@
/* 用户中心页样式 */
.user-center-page {
min-height: 100vh;
background: #fafaf8;
}
.page-header {
background: linear-gradient(135deg, #d4a574 0%, #c8a060 100%);
padding: 60px 0;
color: #ffffff;
}
.user-profile {
text-align: center;
}
.user-profile h2 {
font-size: 24px;
font-weight: 600;
color: #ffffff;
margin: 16px 0 8px;
}
.user-email {
font-size: 14px;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 20px;
}
.user-actions {
display: flex;
gap: 12px;
justify-content: center;
}
.user-actions button {
border-radius: 20px;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: #ffffff;
backdrop-filter: blur(10px);
}
.user-actions button:hover {
background: rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.4);
color: #ffffff;
}
.user-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
background: rgba(255, 255, 255, 0.15);
padding: 32px;
border-radius: 12px;
backdrop-filter: blur(10px);
}
.user-stats .ant-statistic {
text-align: center;
}
.user-stats .ant-statistic-title {
color: rgba(255, 255, 255, 0.9);
font-size: 14px;
}
.user-stats .ant-statistic-content {
color: #ffffff;
font-size: 28px;
font-weight: 700;
}
.user-stats .anticon {
color: rgba(255, 255, 255, 0.8);
}
/* 页面内容 */
.page-content {
background: #fafaf8;
}
.page-content .ant-tabs {
background: #ffffff;
padding: 24px;
border-radius: 12px;
}
/* 卡片封面 */
.card-cover {
position: relative;
width: 100%;
padding-top: 66.67%;
overflow: hidden;
background: #f5f0e8;
}
.card-cover img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
/* 成就网格 */
.achievements-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 24px;
}
.achievement-card {
text-align: center;
padding: 32px 16px;
border-radius: 12px;
transition: all 0.3s ease;
}
.achievement-card.locked {
opacity: 0.5;
filter: grayscale(100%);
}
.achievement-card:not(.locked):hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.achievement-icon {
font-size: 48px;
color: #d4a574;
margin-bottom: 16px;
}
.achievement-card h3 {
font-size: 16px;
font-weight: 600;
color: #2c2c2c;
margin-bottom: 8px;
}
.achievement-card p {
font-size: 13px;
color: #999999;
margin: 0;
}
/* 响应式 */
@media (max-width: 992px) {
.user-stats {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 576px) {
.page-header {
padding: 40px 0;
}
.user-stats {
grid-template-columns: 1fr;
padding: 20px;
}
.achievements-grid {
grid-template-columns: 1fr;
}
}

173
src/pages/User/Center.tsx Normal file
View File

@ -0,0 +1,173 @@
/**
*
*/
import React, { useEffect, useState } from 'react'
import { Tabs, Card, Avatar, Button, List, Empty, Tag, Row, Col, Statistic } from 'antd'
import {
UserOutlined,
HeartOutlined,
BookOutlined,
CommentOutlined,
EditOutlined,
TrophyOutlined,
ClockCircleOutlined,
} from '@ant-design/icons'
import { Link, useNavigate } from 'react-router-dom'
import { useUserStore } from '@store/useUserStore'
import type { HeritageItem } from '@types/index'
import { getHeritageById } from '@services/api'
import './Center.css'
const { TabPane } = Tabs
const UserCenter: React.FC = () => {
const { user, isAuthenticated, logout } = useUserStore()
const navigate = useNavigate()
const [favorites, setFavorites] = useState<HeritageItem[]>([])
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!isAuthenticated) {
navigate('/login')
return
}
fetchUserData()
}, [isAuthenticated])
const fetchUserData = async () => {
if (!user) return
setLoading(true)
try {
// 获取收藏的非遗项目
const favoriteItems = await Promise.all(
user.favorites.slice(0, 6).map((id) => getHeritageById(id))
)
setFavorites(favoriteItems)
} catch (error) {
console.error('Failed to fetch user data:', error)
} finally {
setLoading(false)
}
}
const handleLogout = () => {
logout()
navigate('/')
}
if (!user) {
return (
<div className="loading-container">
<Empty description="未登录" />
</div>
)
}
return (
<div className="user-center-page">
<div className="page-header">
<div className="container">
<Row gutter={[24, 24]}>
<Col xs={24} md={8}>
<div className="user-profile">
<Avatar size={100} src={user.avatar} icon={<UserOutlined />} />
<h2>{user.username}</h2>
<p className="user-email">{user.email}</p>
<div className="user-actions">
<Button icon={<EditOutlined />}></Button>
<Button onClick={handleLogout}>退</Button>
</div>
</div>
</Col>
<Col xs={24} md={16}>
<div className="user-stats">
<Statistic
title="收藏数"
value={user.favorites.length}
prefix={<HeartOutlined />}
/>
<Statistic
title="评论数"
value={0}
prefix={<CommentOutlined />}
/>
</div>
</Col>
</Row>
</div>
</div>
<div className="page-content section-spacing-sm">
<div className="container">
<Tabs defaultActiveKey="favorites">
<TabPane tab="我的收藏" key="favorites">
{favorites.length > 0 ? (
<Row gutter={[24, 24]}>
{favorites.map((item) => (
<Col key={item.id} xs={24} sm={12} lg={8}>
<Link to={`/heritage/${item.id}`}>
<Card
hoverable
cover={
<div className="card-cover">
<img alt={item.name} src={item.images[0]} />
</div>
}
>
<Card.Meta
title={item.name}
description={
<div>
<Tag color="red">{item.level}</Tag>
<Tag>{item.category}</Tag>
</div>
}
/>
</Card>
</Link>
</Col>
))}
</Row>
) : (
<Empty description="暂无收藏" />
)}
</TabPane>
<TabPane tab="我的评论" key="comments">
<Empty description="暂无评论" />
</TabPane>
<TabPane tab="学习成就" key="achievements">
<div className="achievements-grid">
<Card className="achievement-card locked">
<TrophyOutlined className="achievement-icon" />
<h3></h3>
<p></p>
</Card>
<Card className="achievement-card locked">
<TrophyOutlined className="achievement-icon" />
<h3></h3>
<p>10</p>
</Card>
<Card className="achievement-card locked">
<TrophyOutlined className="achievement-icon" />
<h3></h3>
<p>50</p>
</Card>
<Card className="achievement-card locked">
<TrophyOutlined className="achievement-icon" />
<h3>使</h3>
<p>5</p>
</Card>
</div>
</TabPane>
</Tabs>
</div>
</div>
</div>
)
}
export default UserCenter

180
src/pages/User/Login.css Normal file
View File

@ -0,0 +1,180 @@
/* 用户登录页样式 */
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f5f0e8 0%, #ffffff 100%);
padding: 40px 20px;
}
.login-container {
width: 100%;
max-width: 1000px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.login-card {
padding: 60px 50px;
}
.login-header {
text-align: center;
margin-bottom: 40px;
}
.login-header h1 {
font-size: 28px;
font-weight: 700;
color: #2c2c2c;
margin-bottom: 8px;
font-family: 'Noto Serif SC', 'Songti SC', serif;
}
.login-header p {
font-size: 14px;
color: #999999;
margin: 0;
}
.login-options {
display: flex;
justify-content: flex-end;
}
.forgot-link {
color: #d4a574;
font-size: 14px;
}
.forgot-link:hover {
color: #c8a060;
}
.social-login {
display: flex;
gap: 12px;
margin-bottom: 24px;
}
.social-btn {
flex: 1;
border-radius: 8px;
}
.social-btn.wechat {
color: #07c160;
border-color: #07c160;
}
.social-btn.wechat:hover {
color: #ffffff;
background: #07c160;
border-color: #07c160;
}
.social-btn.alipay {
color: #1677ff;
border-color: #1677ff;
}
.social-btn.alipay:hover {
color: #ffffff;
background: #1677ff;
border-color: #1677ff;
}
.register-link {
text-align: center;
font-size: 14px;
color: #666666;
}
.register-link a {
color: #d4a574;
font-weight: 500;
margin-left: 8px;
}
.register-link a:hover {
color: #c8a060;
}
/* 插图区域 */
.login-illustration {
background: linear-gradient(135deg, #d4a574 0%, #c8a060 100%);
padding: 60px 50px;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
}
.illustration-content {
text-align: center;
}
.illustration-content h2 {
font-size: 32px;
font-weight: 700;
margin-bottom: 16px;
color: #ffffff;
font-family: 'Noto Serif SC', 'Songti SC', serif;
}
.illustration-content p {
font-size: 16px;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 40px;
}
.illustration-image {
width: 100%;
max-width: 300px;
margin: 0 auto;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
.illustration-image img {
width: 100%;
height: auto;
display: block;
}
/* 响应式 */
@media (max-width: 992px) {
.login-container {
grid-template-columns: 1fr;
}
.login-illustration {
display: none;
}
.login-card {
padding: 40px 30px;
}
}
@media (max-width: 576px) {
.login-card {
padding: 30px 20px;
}
.login-header h1 {
font-size: 24px;
}
.social-login {
flex-direction: column;
}
}

107
src/pages/User/Login.tsx Normal file
View File

@ -0,0 +1,107 @@
/**
*
*/
import React, { useState } from 'react'
import { Form, Input, Button, message, Divider } from 'antd'
import { UserOutlined, LockOutlined, WechatOutlined, AlipayOutlined } from '@ant-design/icons'
import { Link, useNavigate } from 'react-router-dom'
import { useUserStore } from '@store/useUserStore'
import './Login.css'
const Login: React.FC = () => {
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
const login = useUserStore((state) => state.login)
const onFinish = async (values: { username: string; password: string }) => {
setLoading(true)
try {
await login(values.username, values.password)
message.success('登录成功')
navigate('/')
} catch (error) {
message.error('登录失败,请检查用户名或密码')
} finally {
setLoading(false)
}
}
return (
<div className="login-page">
<div className="login-container">
<div className="login-card">
<div className="login-header">
<h1></h1>
<p></p>
</div>
<Form
name="login"
onFinish={onFinish}
autoComplete="off"
size="large"
>
<Form.Item
name="username"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input prefix={<UserOutlined />} placeholder="用户名" />
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
</Form.Item>
<Form.Item>
<div className="login-options">
<Link to="/forgot-password" className="forgot-link">
</Link>
</div>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>
</Button>
</Form.Item>
</Form>
<Divider plain></Divider>
<div className="social-login">
<Button icon={<WechatOutlined />} className="social-btn wechat">
</Button>
<Button icon={<AlipayOutlined />} className="social-btn alipay">
</Button>
</div>
<div className="register-link">
<Link to="/register"></Link>
</div>
</div>
<div className="login-illustration">
<div className="illustration-content">
<h2></h2>
<p></p>
<div className="illustration-image">
<img
src="https://images.unsplash.com/photo-1547618707-5e8b2c1f0f7c?w=600"
alt="传统文化"
/>
</div>
</div>
</div>
</div>
</div>
)
}
export default Login

146
src/pages/User/Register.css Normal file
View File

@ -0,0 +1,146 @@
/* 用户注册页样式 */
.register-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f5f0e8 0%, #ffffff 100%);
padding: 40px 20px;
}
.register-container {
width: 100%;
max-width: 1000px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.register-card {
padding: 60px 50px;
}
.register-header {
text-align: center;
margin-bottom: 40px;
}
.register-header h1 {
font-size: 28px;
font-weight: 700;
color: #2c2c2c;
margin-bottom: 8px;
font-family: 'Noto Serif SC', 'Songti SC', serif;
}
.register-header p {
font-size: 14px;
color: #999999;
margin: 0;
}
.login-link {
text-align: center;
font-size: 14px;
color: #666666;
}
.login-link a {
color: #d4a574;
font-weight: 500;
margin-left: 8px;
}
.login-link a:hover {
color: #c8a060;
}
/* 插图区域 */
.register-illustration {
background: linear-gradient(135deg, #c8363d 0%, #a82f35 100%);
padding: 60px 50px;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
}
.illustration-content {
text-align: center;
}
.illustration-content h2 {
font-size: 32px;
font-weight: 700;
margin-bottom: 16px;
color: #ffffff;
font-family: 'Noto Serif SC', 'Songti SC', serif;
}
.illustration-content > p {
font-size: 16px;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 40px;
}
.feature-list {
display: flex;
flex-direction: column;
gap: 24px;
}
.feature-item {
text-align: left;
background: rgba(255, 255, 255, 0.1);
padding: 20px;
border-radius: 12px;
backdrop-filter: blur(10px);
}
.feature-icon {
font-size: 32px;
margin-bottom: 12px;
}
.feature-item h3 {
font-size: 18px;
font-weight: 600;
color: #ffffff;
margin-bottom: 8px;
}
.feature-item p {
font-size: 14px;
color: rgba(255, 255, 255, 0.8);
margin: 0;
}
/* 响应式 */
@media (max-width: 992px) {
.register-container {
grid-template-columns: 1fr;
}
.register-illustration {
display: none;
}
.register-card {
padding: 40px 30px;
}
}
@media (max-width: 576px) {
.register-card {
padding: 30px 20px;
}
.register-header h1 {
font-size: 24px;
}
}

167
src/pages/User/Register.tsx Normal file
View File

@ -0,0 +1,167 @@
/**
*
*/
import React, { useState } from 'react'
import { Form, Input, Button, message, Checkbox } from 'antd'
import { UserOutlined, LockOutlined, MailOutlined, PhoneOutlined } from '@ant-design/icons'
import { Link, useNavigate } from 'react-router-dom'
import { useUserStore } from '@store/useUserStore'
import './Register.css'
const Register: React.FC = () => {
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
const register = useUserStore((state) => state.register)
const onFinish = async (values: {
username: string
email: string
phone: string
password: string
agree: boolean
}) => {
if (!values.agree) {
message.warning('请阅读并同意用户协议和隐私政策')
return
}
setLoading(true)
try {
await register(values.username, values.email, values.password)
message.success('注册成功,欢迎加入非遗文化传承平台')
navigate('/')
} catch (error) {
message.error('注册失败,请稍后重试')
} finally {
setLoading(false)
}
}
return (
<div className="register-page">
<div className="register-container">
<div className="register-card">
<div className="register-header">
<h1></h1>
<p></p>
</div>
<Form
name="register"
onFinish={onFinish}
autoComplete="off"
size="large"
>
<Form.Item
name="username"
rules={[
{ required: true, message: '请输入用户名' },
{ min: 3, message: '用户名至少3个字符' },
{ max: 20, message: '用户名最多20个字符' },
]}
>
<Input prefix={<UserOutlined />} placeholder="用户名" />
</Form.Item>
<Form.Item
name="email"
rules={[
{ required: true, message: '请输入邮箱' },
{ type: 'email', message: '请输入有效的邮箱地址' },
]}
>
<Input prefix={<MailOutlined />} placeholder="邮箱" />
</Form.Item>
<Form.Item
name="phone"
rules={[
{ required: true, message: '请输入手机号' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入有效的手机号' },
]}
>
<Input prefix={<PhoneOutlined />} placeholder="手机号" />
</Form.Item>
<Form.Item
name="password"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码至少6个字符' },
]}
>
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
</Form.Item>
<Form.Item
name="confirmPassword"
dependencies={['password']}
rules={[
{ required: true, message: '请确认密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve()
}
return Promise.reject(new Error('两次输入的密码不一致'))
},
}),
]}
>
<Input.Password prefix={<LockOutlined />} placeholder="确认密码" />
</Form.Item>
<Form.Item
name="agree"
valuePropName="checked"
>
<Checkbox>
<Link to="/terms" style={{ margin: '0 4px' }}></Link>
<Link to="/privacy" style={{ margin: '0 4px' }}></Link>
</Checkbox>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>
</Button>
</Form.Item>
</Form>
<div className="login-link">
<Link to="/login"></Link>
</div>
</div>
<div className="register-illustration">
<div className="illustration-content">
<h2></h2>
<p></p>
<div className="feature-list">
<div className="feature-item">
<div className="feature-icon">📚</div>
<h3>线</h3>
<p></p>
</div>
<div className="feature-item">
<div className="feature-icon">👥</div>
<h3></h3>
<p></p>
</div>
<div className="feature-item">
<div className="feature-icon">🏆</div>
<h3></h3>
<p></p>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
export default Register