文章详情

This commit is contained in:
liu
2026-07-01 22:33:51 +08:00
parent d98457e1dd
commit 66401507bf
7 changed files with 679 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '文章详情',
navigationStyle: 'custom',
})
+316
View File
@@ -0,0 +1,316 @@
/* ========================================
文章详情页
======================================== */
.article-page {
min-height: 100vh;
background: #fff;
display: flex;
flex-direction: column;
}
/* ========== 导航栏 ========== */
.article-nav {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
.nav-content {
height: 44px;
display: flex;
align-items: center;
padding: 0 24px;
position: relative;
.nav-back {
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
margin-left: -10px;
}
.nav-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: 32px;
font-weight: 500;
color: #1a1a1a;
}
.nav-placeholder {
width: 50px;
flex-shrink: 0;
}
}
}
/* ========== 滚动区域 ========== */
.article-scroll {
flex: 1;
}
/* ========== 文章内容 ========== */
.article-content {
padding: 24px 32px;
}
.article-title {
display: block;
font-size: 40px;
font-weight: 600;
color: #1a1a1a;
line-height: 1.4;
margin-bottom: 20px;
}
.article-meta {
display: flex;
align-items: center;
font-size: 26px;
color: #969799;
margin-bottom: 32px;
padding-bottom: 24px;
border-bottom: 1px solid #f2f3f5;
.meta-source {
color: #1989fa;
margin-right: 20px;
}
.meta-time {
margin-right: 20px;
}
.meta-read {
flex: 1;
text-align: right;
}
}
.article-body {
margin-bottom: 24px;
}
.article-paragraph {
display: block;
font-size: 32px;
line-height: 1.85;
color: #323233;
margin-bottom: 20px;
text-align: justify;
}
.article-images {
margin-bottom: 24px;
.article-img {
width: 100%;
border-radius: 8px;
margin-bottom: 16px;
}
}
/* ========== 点赞/评论/分享栏 ========== */
.action-bar {
display: flex;
justify-content: space-around;
align-items: center;
padding: 28px 32px;
margin: 0 32px;
background: #f7f8fa;
border-radius: 16px;
.action-item {
display: flex;
align-items: center;
gap: 8px;
.action-text {
font-size: 28px;
color: #646566;
}
}
}
/* ========== 评论区域 ========== */
.comment-section {
margin-top: 32px;
padding: 0 32px;
}
.comment-header {
display: flex;
align-items: baseline;
margin-bottom: 24px;
.comment-title {
font-size: 34px;
font-weight: 600;
color: #1a1a1a;
}
.comment-count {
font-size: 28px;
color: #969799;
margin-left: 8px;
}
}
/* 评论列表 */
.comment-list {
.comment-item {
display: flex;
padding: 24px 0;
border-bottom: 1px solid #f2f3f5;
.comment-avatar {
width: 72px;
height: 72px;
border-radius: 50%;
flex-shrink: 0;
}
.comment-body {
flex: 1;
margin-left: 20px;
}
.comment-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
.comment-name {
font-size: 28px;
font-weight: 500;
color: #323233;
}
.comment-time {
font-size: 24px;
color: #c8c9cc;
}
}
.comment-text {
display: block;
font-size: 30px;
color: #323233;
line-height: 1.6;
margin-bottom: 12px;
}
.comment-actions {
display: flex;
align-items: center;
.comment-like {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 0;
.like-count {
font-size: 24px;
color: #969799;
&.liked {
color: #ee0a24;
}
}
}
}
}
/* 子回复 */
.reply-list {
margin-top: 16px;
background: #f7f8fa;
border-radius: 12px;
padding: 16px 20px;
}
.reply-item {
display: flex;
padding: 12px 0;
& + .reply-item {
border-top: 1px solid #ebedf0;
}
.reply-avatar {
width: 56px;
height: 56px;
border-radius: 50%;
flex-shrink: 0;
}
}
}
.no-comments {
text-align: center;
padding: 60px 0;
font-size: 28px;
color: #c8c9cc;
}
/* ========== 底部评论输入框 ========== */
.comment-input-bar {
display: flex;
align-items: center;
padding: 16px 24px;
background: #fff;
border-top: 1px solid #f2f3f5;
gap: 16px;
.input-wrap {
flex: 1;
background: #f7f8fa;
border-radius: 40px;
padding: 12px 24px;
.comment-input {
font-size: 28px;
height: 44px;
line-height: 44px;
}
}
.send-btn {
flex-shrink: 0;
padding: 12px 28px;
background: #1989fa;
border-radius: 40px;
.send-text {
font-size: 28px;
color: #fff;
font-weight: 500;
}
}
}
/* 底部安全区占位 */
.comment-bottom-safe {
height: 100px;
}
/* 文章不存在 */
.empty-article {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
font-size: 30px;
color: #969799;
}
+250
View File
@@ -0,0 +1,250 @@
import { useState, useMemo, useCallback } from 'react'
import Taro, { useRouter } from '@tarojs/taro'
import { View, Text, Image, ScrollView, Input } from '@tarojs/components'
import { Icon } from '@antmjs/vantui'
import { getArticleDetail } from '@/mock/articleDetail'
import { comments } from '@/mock/comments'
import type { Comment } from '@/types/comment'
import './index.less'
/** 状态栏和导航栏高度 */
function getStatusBarH(): number {
try { return Taro.getSystemInfoSync().statusBarHeight || 20 }
catch { return 20 }
}
export default function ArticleDetail() {
// useRouter 在部分 Taro 版本首次渲染 params 为空,用 getCurrentInstance 兜底
const router = useRouter()
const id = router.params.id || Taro.getCurrentInstance().router?.params?.id
const article = useMemo(() => (id ? getArticleDetail(id) : null), [id])
const [commentText, setCommentText] = useState('')
const [commentList, setCommentList] = useState<Comment[]>(comments)
const statusBarH = useMemo(getStatusBarH, [])
/** 返回 */
const handleBack = useCallback(() => {
Taro.navigateBack()
}, [])
/** 点赞评论 */
const handleLikeComment = useCallback((commentId: string) => {
setCommentList((prev) =>
prev.map((c) => {
if (c.id === commentId) {
return { ...c, liked: !c.liked, likes: c.liked ? c.likes - 1 : c.likes + 1 }
}
if (c.replies) {
return {
...c,
replies: c.replies.map((r) =>
r.id === commentId ? { ...r, liked: !r.liked, likes: r.liked ? r.likes - 1 : r.likes + 1 } : r,
),
}
}
return c
}),
)
}, [])
/** 发送评论 */
const handleSendComment = useCallback(() => {
if (!commentText.trim()) return
const newComment: Comment = {
id: `c${Date.now()}`,
author: { name: '我', avatar: 'https://picsum.photos/seed/me/100/100' },
content: commentText.trim(),
time: '刚刚',
likes: 0,
}
setCommentList((prev) => [newComment, ...prev])
setCommentText('')
Taro.showToast({ title: '评论成功', icon: 'success' })
}, [commentText])
if (!article) {
return (
<View className='article-page'>
<View className='empty-article'></View>
</View>
)
}
// 正文段落
const paragraphs = article.content.split('\n\n').filter(Boolean)
return (
<View className='article-page'>
{/* ========== 自定义导航栏 ========== */}
<View className='article-nav' style={{ paddingTop: `${statusBarH}px` }}>
<View className='nav-content'>
<View className='nav-back' onClick={handleBack}>
<Icon name='arrow-left' size={22} color='#323233' />
</View>
<Text className='nav-title'></Text>
<View className='nav-placeholder' />
</View>
</View>
{/* 占位 */}
<View style={{ height: `${statusBarH + 44}px` }} />
<ScrollView className='article-scroll' scrollY enhanced showScrollbar={false}>
{/* ========== 文章内容 ========== */}
<View className='article-content'>
{/* 标题 */}
<Text className='article-title'>{article.title}</Text>
{/* Meta 信息 */}
<View className='article-meta'>
<Text className='meta-source'>{article.source}</Text>
<Text className='meta-time'>{article.time}</Text>
<Text className='meta-read'>{article.readCount} </Text>
</View>
{/* 正文 */}
<View className='article-body'>
{paragraphs.map((p, idx) => (
<Text key={idx} className='article-paragraph'>{p}</Text>
))}
</View>
{/* 图片 */}
{article.images && article.images.length > 0 && (
<View className='article-images'>
{article.images.map((img, idx) => (
<Image
key={idx}
className='article-img'
src={img}
mode='widthFix'
/>
))}
</View>
)}
</View>
{/* ========== 点赞/评论/分享栏 ========== */}
<View className='action-bar'>
<View className='action-item'>
<Icon name='like-o' size={20} color='#646566' />
<Text className='action-text'></Text>
</View>
<View className='action-item'>
<Icon name='comment-o' size={20} color='#646566' />
<Text className='action-text'>{commentList.length}</Text>
</View>
<View className='action-item'>
<Icon name='share-o' size={20} color='#646566' />
<Text className='action-text'></Text>
</View>
</View>
{/* ========== 评论区域 ========== */}
<View className='comment-section'>
<View className='comment-header'>
<Text className='comment-title'></Text>
<Text className='comment-count'>({commentList.length})</Text>
</View>
{commentList.length > 0 ? (
<View className='comment-list'>
{commentList.map((comment) => (
<View key={comment.id} className='comment-item'>
<Image
className='comment-avatar'
src={comment.author.avatar}
mode='aspectFill'
/>
<View className='comment-body'>
<View className='comment-top'>
<Text className='comment-name'>{comment.author.name}</Text>
<Text className='comment-time'>{comment.time}</Text>
</View>
<Text className='comment-text'>{comment.content}</Text>
<View className='comment-actions'>
<View
className='comment-like'
onClick={() => handleLikeComment(comment.id)}
>
<Icon
name={comment.liked ? 'like' : 'like-o'}
size={16}
color={comment.liked ? '#ee0a24' : '#969799'}
/>
<Text className={`like-count ${comment.liked ? 'liked' : ''}`}>
{comment.likes || '赞'}
</Text>
</View>
</View>
{/* 子回复 */}
{comment.replies && comment.replies.length > 0 && (
<View className='reply-list'>
{comment.replies.map((reply) => (
<View key={reply.id} className='reply-item'>
<Image
className='reply-avatar'
src={reply.author.avatar}
mode='aspectFill'
/>
<View className='comment-body'>
<View className='comment-top'>
<Text className='comment-name'>{reply.author.name}</Text>
<Text className='comment-time'>{reply.time}</Text>
</View>
<Text className='comment-text'>{reply.content}</Text>
<View className='comment-actions'>
<View
className='comment-like'
onClick={() => handleLikeComment(reply.id)}
>
<Icon
name={reply.liked ? 'like' : 'like-o'}
size={16}
color={reply.liked ? '#ee0a24' : '#969799'}
/>
<Text className={`like-count ${reply.liked ? 'liked' : ''}`}>
{reply.likes || '赞'}
</Text>
</View>
</View>
</View>
</View>
))}
</View>
)}
</View>
</View>
))}
</View>
) : (
<View className='no-comments'>~</View>
)}
</View>
{/* 底部占位(防止被输入框遮挡) */}
<View className='comment-bottom-safe' />
</ScrollView>
{/* ========== 底部评论输入框(固定) ========== */}
<View className='comment-input-bar'>
<View className='input-wrap'>
<Input
className='comment-input'
value={commentText}
placeholder='写下你的评论...'
placeholderStyle='color: #c8c9cc;'
onInput={(e) => setCommentText(e.detail.value)}
confirmType='send'
onConfirm={handleSendComment}
/>
</View>
<View className='send-btn' onClick={handleSendComment}>
<Text className='send-text'></Text>
</View>
</View>
</View>
)
}