添加基础公共组件
- 创建非遗项目卡片组件(HeritageCard) - 创建传承人卡片组件(InheritorCard) - 创建自定义分页组件(CustomPagination) - 创建评论区组件(CommentSection)
This commit is contained in:
parent
b4947b89e1
commit
256f2ea649
101
src/components/CommentSection/index.css
Normal file
101
src/components/CommentSection/index.css
Normal 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;
|
||||
}
|
||||
172
src/components/CommentSection/index.tsx
Normal file
172
src/components/CommentSection/index.tsx
Normal 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
|
||||
278
src/components/CustomPagination/index.css
Normal file
278
src/components/CustomPagination/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
196
src/components/CustomPagination/index.tsx
Normal file
196
src/components/CustomPagination/index.tsx
Normal 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="首页"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
|
||||
{/* 上一页 */}
|
||||
<button
|
||||
className="pagination-nav-btn"
|
||||
disabled={current === 1}
|
||||
onClick={() => handlePageChange(current - 1)}
|
||||
title="上一页"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
|
||||
{/* 页码 */}
|
||||
<div className="pagination-pages">{renderPageNumbers()}</div>
|
||||
|
||||
{/* 下一页 */}
|
||||
<button
|
||||
className="pagination-nav-btn"
|
||||
disabled={current === totalPages}
|
||||
onClick={() => handlePageChange(current + 1)}
|
||||
title="下一页"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
|
||||
{/* 尾页 */}
|
||||
<button
|
||||
className="pagination-nav-btn"
|
||||
disabled={current === totalPages}
|
||||
onClick={() => handlePageChange(totalPages)}
|
||||
title="尾页"
|
||||
>
|
||||
»
|
||||
</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
|
||||
153
src/components/HeritageCard/index.css
Normal file
153
src/components/HeritageCard/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
90
src/components/HeritageCard/index.tsx
Normal file
90
src/components/HeritageCard/index.tsx
Normal 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
|
||||
113
src/components/InheritorCard/index.css
Normal file
113
src/components/InheritorCard/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
69
src/components/InheritorCard/index.tsx
Normal file
69
src/components/InheritorCard/index.tsx
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user