文章详情

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
+1
View File
@@ -4,6 +4,7 @@ export default defineAppConfig({
'pages/tasks/index',
'pages/messages/index',
'pages/profile/index',
'pages/article/index',
],
window: {
backgroundTextStyle: 'light',
+37
View File
@@ -0,0 +1,37 @@
import { articles } from './articles'
/** 文章详情额外字段 */
interface ArticleDetail {
id: string
content: string
images: string[]
}
/** 用一段长文本来模拟文章正文 */
const FULL_CONTENT = `近日,学校召开了2024年秋季学期教学工作会议。会议总结了上学期教学工作取得的成绩,并对新学期教学工作进行了全面部署。
教务处处长在会议上指出,上学期全校教师积极探索教学改革,推行线上线下混合式教学模式,取得了显著成效。学生在各类学科竞赛中斩获国家级奖项12项、省级奖项35项,创历史新高。
会议特别强调了以下几点:
第一,持续推进课程思政建设。各学院要深入挖掘专业课程中蕴含的思政元素,将价值引领融入知识传授全过程,培养德才兼备的高素质人才。
第二,深化产教融合。学校将进一步加强与企业的合作,建设一批高水平的校外实践教学基地,为学生提供更多实习实训机会。目前已与30余家知名企业达成合作协议。
第三,优化课程体系。根据社会需求和学科发展趋势,及时调整课程设置,增加人工智能、大数据等前沿领域的课程内容,提升学生的核心竞争力。
第四,强化教学质量监控。完善教学督导制度,建立学生评教、同行评议、专家评价相结合的教学质量评价体系,确保教学质量持续提升。
会上,多位优秀教师代表分享了教学经验。来自计算机学院的张老师介绍了翻转课堂的实践经验,引起与会教师的广泛共鸣。
会议最后,校领导对新学期教学工作提出了殷切期望,希望全体教师以更加饱满的热情投入到教学工作中,为培养新时代优秀人才贡献力量。`
export function getArticleDetail(id: string) {
const article = articles.find((a) => a.id === id)
if (!article) return null
return {
...article,
content: article.excerpt + '\n\n' + FULL_CONTENT,
images: article.images,
} as ArticleDetail & typeof article
}
+57
View File
@@ -0,0 +1,57 @@
import type { Comment } from '@/types/comment'
/** 模拟评论数据 */
export const comments: Comment[] = [
{
id: 'c1',
author: { name: '张同学', avatar: 'https://picsum.photos/seed/c1/100/100' },
content: '写得太好了!对新学期的工作重点有了更清晰的了解,期待学校的教学改革能带来更好的学习体验。',
time: '2小时前',
likes: 28,
liked: true,
},
{
id: 'c2',
author: { name: '李老师', avatar: 'https://picsum.photos/seed/c2/100/100' },
content: '作为一线教师,深切感受到学校对教学工作的重视。翻转课堂确实能激发学生的学习主动性,推荐更多老师尝试。',
time: '3小时前',
likes: 15,
},
{
id: 'c3',
author: { name: '王同学', avatar: 'https://picsum.photos/seed/c3/100/100' },
content: '产教融合这个方向真的很赞,希望能多一些实习机会,让我们在校期间就能接触真实项目。',
time: '5小时前',
likes: 42,
replies: [
{
id: 'c3r1',
author: { name: '教务处回复', avatar: 'https://picsum.photos/seed/admin/100/100' },
content: '感谢同学的反馈!学校正在积极拓展合作企业,预计下学期将新增20个实习基地,请关注后续通知。',
time: '4小时前',
likes: 18,
},
],
},
{
id: 'c4',
author: { name: '赵同学', avatar: 'https://picsum.photos/seed/c4/100/100' },
content: '希望课程设置能多加入一些AI相关的内容,现在就业市场对这方面的需求很大。',
time: '6小时前',
likes: 35,
},
{
id: 'c5',
author: { name: '陈同学', avatar: 'https://picsum.photos/seed/c5/100/100' },
content: '选修课的种类能不能再多一些?特别是跨学科的课程,感觉很有意思。',
time: '8小时前',
likes: 12,
},
{
id: 'c6',
author: { name: '刘同学', avatar: 'https://picsum.photos/seed/c6/100/100' },
content: '教学质量监控很重要,希望能真实反映课堂情况,不要流于形式。',
time: '昨天',
likes: 20,
},
]
+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>
)
}
+14
View File
@@ -0,0 +1,14 @@
/** 评论 */
export interface Comment {
id: string
author: {
name: string
avatar: string
}
content: string
time: string
likes: number
liked?: boolean
/** 子回复 */
replies?: Comment[]
}