添加基础公共组件

- 创建非遗项目卡片组件(HeritageCard)
- 创建传承人卡片组件(InheritorCard)
- 创建自定义分页组件(CustomPagination)
- 创建评论区组件(CommentSection)
This commit is contained in:
Leo 2025-10-09 23:46:10 +08:00
parent b4947b89e1
commit 256f2ea649
8 changed files with 1172 additions and 0 deletions

View File

@ -0,0 +1,101 @@
/* 评论区组件样式 */
.comment-section {
margin-top: 40px;
padding: 24px;
background: #ffffff;
border-radius: 12px;
}
.comment-header h3 {
font-size: 20px;
font-weight: 600;
margin-bottom: 24px;
color: #2c2c2c;
}
/* 评论编辑器 */
.comment-editor {
margin-bottom: 32px;
padding: 20px;
background: #f5f0e8;
border-radius: 8px;
}
.comment-rating {
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 12px;
}
.comment-rating span {
font-size: 14px;
color: #666666;
}
.comment-editor .ant-input {
margin-bottom: 12px;
}
.comment-actions {
display: flex;
justify-content: flex-end;
}
.comment-login-tip {
padding: 40px 20px;
text-align: center;
background: #f5f0e8;
border-radius: 8px;
margin-bottom: 24px;
}
.comment-login-tip p {
color: #666666;
margin: 0;
}
/* 评论列表 */
.comment-list .ant-list-item {
padding: 20px 0;
border-bottom: 1px solid #f0ebe3;
}
.comment-list .ant-list-item:last-child {
border-bottom: none;
}
.comment-title {
display: flex;
align-items: center;
gap: 12px;
}
.comment-author {
font-size: 15px;
font-weight: 600;
color: #2c2c2c;
}
.comment-content p {
font-size: 14px;
color: #666666;
line-height: 1.8;
margin: 8px 0;
}
.comment-time {
font-size: 13px;
color: #999999;
}
.comment-action {
color: #999999;
cursor: pointer;
transition: color 0.3s ease;
}
.comment-action:hover {
color: #c8363d;
}

View File

@ -0,0 +1,172 @@
/**
*
*/
import React, { useState, useEffect } from 'react'
import { List, Avatar, Rate, Button, Input, message, Empty } from 'antd'
import { LikeOutlined, LikeFilled } from '@ant-design/icons'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import 'dayjs/locale/zh-cn'
import type { Comment } from '@types/index'
import { getCommentsByTarget, addComment } from '@services/api'
import { useUserStore } from '@store/useUserStore'
import './index.css'
dayjs.extend(relativeTime)
dayjs.locale('zh-cn')
const { TextArea } = Input
interface CommentSectionProps {
targetType: 'heritage' | 'inheritor' | 'course' | 'news'
targetId: string
showRating?: boolean
}
const CommentSection: React.FC<CommentSectionProps> = ({
targetType,
targetId,
showRating = false,
}) => {
const [comments, setComments] = useState<Comment[]>([])
const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [content, setContent] = useState('')
const [rating, setRating] = useState(5)
const { user, isAuthenticated } = useUserStore()
useEffect(() => {
fetchComments()
}, [targetType, targetId])
const fetchComments = async () => {
setLoading(true)
try {
const data = await getCommentsByTarget(targetType, targetId)
setComments(data)
} catch (error) {
console.error('Failed to fetch comments:', error)
} finally {
setLoading(false)
}
}
const handleSubmit = async () => {
if (!isAuthenticated) {
message.warning('请先登录后再发表评论')
return
}
if (!content.trim()) {
message.warning('请输入评论内容')
return
}
setSubmitting(true)
try {
const newComment = await addComment({
userId: user!.id,
userName: user!.nickname,
userAvatar: user!.avatar,
targetType,
targetId,
content,
rating: showRating ? rating : undefined,
likeCount: 0,
replyCount: 0,
})
setComments([newComment, ...comments])
setContent('')
setRating(5)
message.success('评论发表成功')
} catch (error) {
message.error('评论发表失败')
} finally {
setSubmitting(false)
}
}
return (
<div className="comment-section">
<div className="comment-header">
<h3> ({comments.length})</h3>
</div>
{/* 评论输入框 */}
{isAuthenticated && (
<div className="comment-editor">
{showRating && (
<div className="comment-rating">
<span></span>
<Rate value={rating} onChange={setRating} />
</div>
)}
<TextArea
rows={4}
placeholder="写下你的评论..."
value={content}
onChange={(e) => setContent(e.target.value)}
maxLength={500}
showCount
/>
<div className="comment-actions">
<Button type="primary" loading={submitting} onClick={handleSubmit}>
</Button>
</div>
</div>
)}
{!isAuthenticated && (
<div className="comment-login-tip">
<p></p>
</div>
)}
{/* 评论列表 */}
<List
className="comment-list"
loading={loading}
itemLayout="horizontal"
dataSource={comments}
locale={{
emptyText: <Empty description="暂无评论,快来发表第一条评论吧!" />,
}}
renderItem={(item) => (
<List.Item
actions={[
<span key="like" className="comment-action">
<LikeOutlined /> {item.likeCount}
</span>,
]}
>
<List.Item.Meta
avatar={<Avatar src={item.userAvatar} size={40} />}
title={
<div className="comment-title">
<span className="comment-author">{item.userName}</span>
{item.rating && (
<Rate disabled defaultValue={item.rating} style={{ fontSize: 14 }} />
)}
</div>
}
description={
<div className="comment-content">
<p>{item.content}</p>
<span className="comment-time">
{dayjs(item.createdAt).fromNow()}
</span>
</div>
}
/>
</List.Item>
)}
/>
</div>
)
}
export default CommentSection

View File

@ -0,0 +1,278 @@
/* 非遗文化分页组件 - 参考 CoiPagination 设计 */
.heritage-pagination-wrapper {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
padding: 16px 0;
margin-top: 24px;
flex-wrap: wrap;
user-select: none;
}
/* ========== 信息展示区域 ========== */
.pagination-info {
display: flex;
align-items: center;
gap: 12px;
font-size: 15px;
color: #666666;
font-weight: 400;
white-space: nowrap;
}
.info-text {
color: #666666;
font-size: 15px;
}
.info-number {
color: #c8363d;
font-weight: 600;
font-size: 15px;
}
/* ========== 每页条数选择器 ========== */
.pagination-size-select {
min-width: 120px;
display: flex;
align-items: center;
}
.pagination-size-select .ant-select-selector {
height: 32px !important;
border-radius: 6px !important;
border-color: #d9d9d9 !important;
font-size: 14px;
font-weight: 400;
}
.pagination-size-select .ant-select-selection-item {
line-height: 30px !important;
color: #333333;
}
.pagination-size-select:hover .ant-select-selector {
border-color: #c8363d !important;
}
.pagination-size-select.ant-select-focused .ant-select-selector {
border-color: #c8363d !important;
box-shadow: 0 0 0 2px rgba(200, 54, 61, 0.1) !important;
}
/* ========== 分页控制按钮区域 ========== */
.pagination-controls {
display: flex;
align-items: center;
gap: 16px;
}
/* ========== 页码区域 ========== */
.pagination-pager {
display: flex;
align-items: center;
gap: 8px;
}
/* 导航按钮(首页、上一页、下一页、尾页) */
.pagination-nav-btn {
display: flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 32px;
padding: 0;
border: 1px solid #d9d9d9;
border-radius: 6px;
background: #ffffff;
color: #333333;
font-size: 16px;
font-weight: 400;
cursor: pointer;
transition: all 0.2s ease;
outline: none;
}
.pagination-nav-btn:hover:not(:disabled) {
border-color: #c8363d;
color: #c8363d;
background: rgba(200, 54, 61, 0.08);
}
.pagination-nav-btn:disabled {
cursor: not-allowed;
opacity: 0.5;
background: #ffffff;
color: #cccccc;
border-color: #d9d9d9;
}
/* 页码按钮容器 */
.pagination-pages {
display: flex;
align-items: center;
gap: 4px;
}
/* 页码按钮 */
.pagination-page-btn {
display: flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 32px;
padding: 0 8px;
border: 1px solid #d9d9d9;
border-radius: 6px;
background: #ffffff;
color: #333333;
font-size: 14px;
font-weight: 400;
cursor: pointer;
transition: all 0.2s ease;
outline: none;
}
.pagination-page-btn:hover {
border-color: #c8363d;
color: #c8363d;
background: rgba(200, 54, 61, 0.08);
}
/* 当前激活页码 */
.pagination-page-btn.active {
border-color: #c8363d !important;
background-color: #c8363d !important;
color: #ffffff !important;
font-weight: 700;
}
.pagination-page-btn.active:hover {
border-color: #b02f37 !important;
background-color: #b02f37 !important;
color: #ffffff !important;
}
/* 省略号 */
.pagination-ellipsis {
display: flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 32px;
color: #999999;
font-size: 14px;
font-weight: 400;
user-select: none;
}
/* ========== 快速跳转区域 ========== */
.pagination-jump {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.jump-text {
font-size: 14px;
color: #666666;
font-weight: 400;
}
.jump-input {
width: 60px;
height: 32px;
border: 1px solid #d9d9d9;
border-radius: 6px;
padding: 0 8px;
text-align: center;
font-size: 14px;
font-weight: 400;
color: #333333;
transition: all 0.2s ease;
outline: none;
}
.jump-input:hover {
border-color: #c8363d;
}
.jump-input:focus {
border-color: #c8363d;
box-shadow: 0 0 0 2px rgba(200, 54, 61, 0.1);
}
.jump-btn {
height: 32px;
padding: 0 16px;
border: 1px solid #d9d9d9;
border-radius: 6px;
background: #ffffff;
color: #333333;
font-size: 14px;
font-weight: 400;
cursor: pointer;
transition: all 0.2s ease;
outline: none;
}
.jump-btn:hover {
border-color: #c8363d;
color: #c8363d;
background: rgba(200, 54, 61, 0.08);
}
.jump-btn:active {
transform: scale(0.98);
}
/* ========== 响应式设计 ========== */
@media (max-width: 768px) {
.heritage-pagination-wrapper {
flex-direction: column;
gap: 16px;
padding: 12px 0;
}
.pagination-info {
width: 100%;
justify-content: center;
font-size: 13px;
}
.pagination-controls {
flex-direction: column;
gap: 12px;
}
.pagination-pager {
flex-wrap: wrap;
justify-content: center;
}
.pagination-size-select {
min-width: 100px;
}
.pagination-nav-btn,
.pagination-page-btn {
min-width: 36px;
height: 36px;
font-size: 16px;
}
}
@media (max-width: 576px) {
.pagination-nav-btn,
.pagination-page-btn {
min-width: 32px;
height: 32px;
}
.jump-input {
width: 50px;
}
}

View File

@ -0,0 +1,196 @@
/**
* -
*
*/
import React, { useState, useEffect } from 'react'
import { Select } from 'antd'
import type { PaginationProps } from 'antd'
import './index.css'
interface CustomPaginationProps extends Omit<PaginationProps, 'onChange' | 'onShowSizeChange'> {
unit?: string // 单位,如 "项"、"位传承人"、"条"
onChange?: (page: number, pageSize: number) => void
onShowSizeChange?: (current: number, size: number) => void
}
const CustomPagination: React.FC<CustomPaginationProps> = ({
unit = '条',
current = 1,
total = 0,
pageSize = 12,
pageSizeOptions = ['12', '24', '36', '48'],
onChange,
onShowSizeChange,
}) => {
const [jumpPage, setJumpPage] = useState<string>('')
const totalPages = Math.ceil(total / pageSize)
const startItem = (current - 1) * pageSize + 1
const endItem = Math.min(current * pageSize, total)
// 页码改变处理
const handlePageChange = (newPage: number) => {
if (newPage < 1 || newPage > totalPages || newPage === current) return
onChange?.(newPage, pageSize)
}
// 每页条数改变处理
const handleSizeChange = (newSize: number) => {
onShowSizeChange?.(1, newSize)
onChange?.(1, newSize)
}
// 跳转到指定页
const handleJump = () => {
const pageNum = parseInt(jumpPage)
if (!isNaN(pageNum) && pageNum >= 1 && pageNum <= totalPages) {
handlePageChange(pageNum)
setJumpPage('')
}
}
// 生成页码按钮
const renderPageNumbers = () => {
const pages: (number | string)[] = []
const showPages = 5 // 显示的页码数量
if (totalPages <= showPages + 2) {
// 总页数较少,显示全部
for (let i = 1; i <= totalPages; i++) {
pages.push(i)
}
} else {
// 总页数较多,显示部分
pages.push(1)
if (current > 3) {
pages.push('...')
}
const start = Math.max(2, current - 1)
const end = Math.min(totalPages - 1, current + 1)
for (let i = start; i <= end; i++) {
pages.push(i)
}
if (current < totalPages - 2) {
pages.push('...')
}
pages.push(totalPages)
}
return pages.map((page, index) => {
if (page === '...') {
return (
<span key={`ellipsis-${index}`} className="pagination-ellipsis">
...
</span>
)
}
return (
<button
key={page}
className={`pagination-page-btn ${page === current ? 'active' : ''}`}
onClick={() => handlePageChange(page as number)}
>
{page}
</button>
)
})
}
if (total === 0) return null
return (
<div className="heritage-pagination-wrapper">
<div className="pagination-info">
<span className="info-text">
<span className="info-number">{total}</span> {unit}
</span>
<span className="info-text">
{startItem}-{endItem} {unit}
</span>
<span className="info-text"></span>
</div>
<Select
value={pageSize}
onChange={handleSizeChange}
className="pagination-size-select"
options={pageSizeOptions.map((size) => ({
label: `${size} ${unit}/页`,
value: Number(size),
}))}
/>
<div className="pagination-controls">
<div className="pagination-pager">
{/* 首页 */}
<button
className="pagination-nav-btn"
disabled={current === 1}
onClick={() => handlePageChange(1)}
title="首页"
>
&#171;
</button>
{/* 上一页 */}
<button
className="pagination-nav-btn"
disabled={current === 1}
onClick={() => handlePageChange(current - 1)}
title="上一页"
>
&#8249;
</button>
{/* 页码 */}
<div className="pagination-pages">{renderPageNumbers()}</div>
{/* 下一页 */}
<button
className="pagination-nav-btn"
disabled={current === totalPages}
onClick={() => handlePageChange(current + 1)}
title="下一页"
>
&#8250;
</button>
{/* 尾页 */}
<button
className="pagination-nav-btn"
disabled={current === totalPages}
onClick={() => handlePageChange(totalPages)}
title="尾页"
>
&#187;
</button>
</div>
</div>
{/* 快速跳转 */}
<div className="pagination-jump">
<span className="jump-text"></span>
<input
type="text"
value={jumpPage}
onChange={(e) => setJumpPage(e.target.value.replace(/\D/g, ''))}
onKeyDown={(e) => e.key === 'Enter' && handleJump()}
className="jump-input"
placeholder=""
/>
<span className="jump-text"></span>
<button className="jump-btn" onClick={handleJump}>
</button>
</div>
</div>
)
}
export default CustomPagination

View File

@ -0,0 +1,153 @@
/* 非遗项目卡片样式 */
.heritage-card-link {
text-decoration: none;
display: block;
height: 100%;
}
.heritage-card {
height: 100%;
border-radius: 12px;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
border: 1px solid #e8e3db;
}
.heritage-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
border-color: #c8363d;
}
.heritage-card .ant-card-body {
padding: 20px;
}
.heritage-card-cover {
position: relative;
width: 100%;
padding-top: 66.67%; /* 3:2 比例 */
overflow: hidden;
background-color: #f5f0e8;
}
.heritage-card-cover img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.5s ease;
}
.heritage-card:hover .heritage-card-cover img {
transform: scale(1.08);
}
.heritage-card-overlay {
position: absolute;
top: 12px;
right: 12px;
}
.level-tag {
border: none;
font-size: 12px;
font-weight: 600;
padding: 4px 12px;
border-radius: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.heritage-card-content {
display: flex;
flex-direction: column;
gap: 12px;
}
.heritage-card-title {
font-size: 16px;
font-weight: 600;
color: #2c2c2c;
margin: 0;
line-height: 1.4;
font-family: 'Noto Serif SC', 'Songti SC', serif;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.heritage-card-location {
display: flex;
align-items: center;
gap: 6px;
color: #999999;
font-size: 13px;
}
.heritage-card-location .anticon {
font-size: 14px;
color: #d4a574;
}
.heritage-card-description {
margin: 0 !important;
font-size: 13px;
color: #666666;
line-height: 1.6;
}
.heritage-card-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.heritage-tag {
background-color: #f5f0e8;
color: #666666;
border: none;
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
}
.heritage-card-footer {
padding-top: 12px;
border-top: 1px solid #f0ebe3;
}
.stat-item {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #999999;
}
.stat-item .anticon {
font-size: 14px;
}
.stat-item .ant-typography {
color: #999999;
font-size: 13px;
}
/* 响应式 */
@media (max-width: 768px) {
.heritage-card .ant-card-body {
padding: 16px;
}
.heritage-card-title {
font-size: 15px;
}
.heritage-card-description {
font-size: 12px;
}
}

View File

@ -0,0 +1,90 @@
/**
*
*/
import React from 'react'
import { Card, Tag, Space, Typography } from 'antd'
import { EyeOutlined, HeartOutlined, EnvironmentOutlined } from '@ant-design/icons'
import { Link } from 'react-router-dom'
import type { HeritageItem } from '@types/index'
import './index.css'
const { Text, Paragraph } = Typography
interface HeritageCardProps {
item: HeritageItem
hoverable?: boolean
}
const levelLabels: Record<string, string> = {
world: '世界级',
national: '国家级',
provincial: '省级',
municipal: '市级',
county: '县级',
}
const levelColors: Record<string, string> = {
world: 'gold',
national: 'red',
provincial: 'blue',
municipal: 'green',
county: 'default',
}
const HeritageCard: React.FC<HeritageCardProps> = ({ item, hoverable = true }) => {
return (
<Link to={`/heritage/${item.id}`} className="heritage-card-link">
<Card
hoverable={hoverable}
cover={
<div className="heritage-card-cover">
<img alt={item.name} src={item.coverImage} />
<div className="heritage-card-overlay">
<Tag color={levelColors[item.level]} className="level-tag">
{levelLabels[item.level]}
</Tag>
</div>
</div>
}
className="heritage-card"
>
<div className="heritage-card-content">
<h3 className="heritage-card-title">{item.name}</h3>
<div className="heritage-card-location">
<EnvironmentOutlined />
<Text type="secondary">{item.province}</Text>
</div>
<Paragraph ellipsis={{ rows: 2 }} className="heritage-card-description">
{item.description}
</Paragraph>
<div className="heritage-card-tags">
{item.tags.slice(0, 3).map((tag) => (
<Tag key={tag} className="heritage-tag">
{tag}
</Tag>
))}
</div>
<div className="heritage-card-footer">
<Space size="large">
<span className="stat-item">
<EyeOutlined />
<Text type="secondary">{item.viewCount.toLocaleString()}</Text>
</span>
<span className="stat-item">
<HeartOutlined />
<Text type="secondary">{item.likeCount.toLocaleString()}</Text>
</span>
</Space>
</div>
</div>
</Card>
</Link>
)
}
export default HeritageCard

View File

@ -0,0 +1,113 @@
/* 传承人卡片样式 */
.inheritor-card-link {
text-decoration: none;
display: block;
height: 100%;
}
.inheritor-card {
height: 100%;
border-radius: 12px;
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
border: 1px solid #e8e3db;
}
.inheritor-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
border-color: #c8363d;
}
.inheritor-card .ant-card-body {
padding: 24px;
}
.inheritor-card-header {
display: flex;
gap: 16px;
margin-bottom: 16px;
}
.inheritor-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
justify-content: center;
}
.inheritor-name {
font-size: 18px;
font-weight: 600;
color: #2c2c2c;
margin: 0;
font-family: 'Noto Serif SC', 'Songti SC', serif;
}
.inheritor-level {
align-self: flex-start;
border: none;
font-size: 12px;
font-weight: 600;
padding: 2px 10px;
border-radius: 4px;
}
.inheritor-age {
font-size: 13px;
color: #999999;
}
.inheritor-bio {
margin: 0 0 8px 0 !important;
font-size: 14px;
color: #666666;
line-height: 1.6;
font-weight: 500;
}
.inheritor-skills {
margin: 0 0 16px 0 !important;
font-size: 13px;
color: #999999;
line-height: 1.6;
}
.inheritor-card-footer {
padding-top: 16px;
border-top: 1px solid #f0ebe3;
}
.stat-item {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #999999;
}
.stat-item .anticon {
font-size: 14px;
color: #d4a574;
}
.stat-item .ant-typography {
color: #999999;
font-size: 13px;
}
/* 响应式 */
@media (max-width: 768px) {
.inheritor-card .ant-card-body {
padding: 20px;
}
.inheritor-card-header {
gap: 12px;
}
.inheritor-name {
font-size: 16px;
}
}

View File

@ -0,0 +1,69 @@
/**
*
*/
import React from 'react'
import { Card, Avatar, Tag, Space, Typography } from 'antd'
import { UserOutlined, EyeOutlined, TeamOutlined } from '@ant-design/icons'
import { Link } from 'react-router-dom'
import type { Inheritor } from '@types/index'
import './index.css'
const { Text, Paragraph } = Typography
interface InheritorCardProps {
item: Inheritor
hoverable?: boolean
}
const levelLabels: Record<string, string> = {
national: '国家级',
provincial: '省级',
municipal: '市级',
}
const InheritorCard: React.FC<InheritorCardProps> = ({ item, hoverable = true }) => {
const age = new Date().getFullYear() - item.birthYear
return (
<Link to={`/inheritor/${item.id}`} className="inheritor-card-link">
<Card hoverable={hoverable} className="inheritor-card">
<div className="inheritor-card-header">
<Avatar size={80} src={item.avatar} icon={<UserOutlined />} />
<div className="inheritor-info">
<h3 className="inheritor-name">{item.name}</h3>
<Tag color="red" className="inheritor-level">
{levelLabels[item.level]}
</Tag>
<Text type="secondary" className="inheritor-age">
{age} · {item.province}
</Text>
</div>
</div>
<Paragraph ellipsis={{ rows: 2 }} className="inheritor-bio">
{item.title}
</Paragraph>
<Paragraph ellipsis={{ rows: 2 }} className="inheritor-skills">
{item.masterSkills}
</Paragraph>
<div className="inheritor-card-footer">
<Space size="large">
<span className="stat-item">
<TeamOutlined />
<Text type="secondary">{item.followers.toLocaleString()}</Text>
</span>
<span className="stat-item">
<EyeOutlined />
<Text type="secondary">{item.viewCount.toLocaleString()}</Text>
</span>
</Space>
</div>
</Card>
</Link>
)
}
export default InheritorCard