feat(content): 小程序详情富UI(阅读/动作条/留言)

- content-detail 微信文章式:标题 + 作者(头像/昵称/认证✓) + 日期 + 阅读X + 富文本正文
- 作者条+动作(点赞/转发=复制链接/收藏/写留言滚动)
- 阅读X + 留言区(输入框+😊🖼+发送+列表,未登录提示登录;空态"还没有留言,来写首评")
- utils/content.js +getComments/addComment

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pine
2026-08-27 15:30:19 +08:00
parent e6152cb932
commit bab70c8eee
3 changed files with 122 additions and 28 deletions
+74 -21
View File
@@ -1,70 +1,123 @@
import { View, Text, Image, Video, ScrollView, Button, RichText } from '@tarojs/components';
import { View, Text, Image, Video, ScrollView, Input, Button, RichText } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useState, useEffect } from 'react';
import { getContent } from '@/utils/content';
import { getContent, getComments, addComment } from '@/utils/content';
import { isAuthed } from '@/utils/auth';
import './index.scss';
/** 资讯详情:封面/视频 + 正文 + 图集 + 外链 + 发布人。 */
const rw = (s) => (s || '').replace('T', ' ').replace('Z', '').slice(0, 16);
/** 资讯详情(微信文章式):标题/作者/阅读 + 正文 + 动作条 + 留言。 */
export default function ContentDetail() {
const [item, setItem] = useState(null);
const [comments, setComments] = useState([]);
const [err, setErr] = useState('');
const [loaded, setLoaded] = useState(false);
const [like, setLike] = useState(false);
const [fav, setFav] = useState(false);
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
useEffect(() => {
const inst = Taro.getCurrentInstance();
const id = (inst && inst.router && inst.router.params && inst.router.params.id) || '';
if (!id) { setErr('无效内容'); setLoaded(true); return; }
getContent(id).then(setItem).catch(() => setErr('加载失败')).finally(() => setLoaded(true));
getComments(id).then(setComments).catch(() => {});
}, []);
if (!loaded) return <View className="cd page-in"><Text className="cd-err">加载中</Text></View>;
if (err || !item) return <View className="cd page-in"><Text className="cd-err">{err || '内容不存在'}</Text></View>;
const id = item.id;
const img = item.cover || (item.video ? item.video_cover : '');
const images = Array.isArray(item.images) ? item.images : [];
const author = item.publisher_name || '云超服';
const views = item.read_count || 0;
const copyLink = () => Taro.setClipboardData({ data: `https://opc.pinesound.cn/#/content-detail?id=${id}` }).then(() => Taro.showToast({ title: '链接已复制', icon: 'none' }));
const scrollToComment = () => { Taro.pageScrollTo({ selector: '.cd-comments', duration: 300 }); };
const send = async () => {
if (!isAuthed()) { Taro.showToast({ title: '请先登录', icon: 'none' }); return; }
if (!draft.trim()) return;
setSending(true);
try {
const c = await addComment(id, draft.trim());
if (c) { setComments((m) => [c, ...m]); setDraft(''); Taro.showToast({ title: '已留言', icon: 'success' }); }
} catch (e) { Taro.showToast({ title: (e && e.message) || '留言失败', icon: 'none' }); }
finally { setSending(false); }
};
return (
<View className="cd page-in">
{/* 视频或封面 */}
{/* 封面/视频 */}
{item.video
? <Video className="cd-video" src={item.video} poster={item.video_cover || item.cover || ''} controls />
: img
? <Image className="cd-cover" src={img} mode="widthFix" />
: null}
{/* 标题 + 元信息 */}
<Text className="cd-title">{item.title}</Text>
{/* 发布人行 */}
<View className="cd-meta-row">
{item.publisher_avatar
? <Image className="cd-ava" src={item.publisher_avatar} mode="aspectFill" />
: <View className="cd-ava" />}
<Text className="cd-author">{item.publisher_name || '云超服'}</Text>
{item.publisher_avatar ? <Image className="cd-ava" src={item.publisher_avatar} mode="aspectFill" /> : <View className="cd-ava" />}
<Text className="cd-author">{author}<Text className="cd-badge"></Text></Text>
</View>
<View className="cd-meta-line">
<Text className="cd-meta-at">{rw(item.created_at)}</Text>
<Text className="cd-dot">·</Text>
<Text className="cd-meta-at">{fmt(item.created_at)}</Text>
<Text className="cd-meta-at">阅读 {views}</Text>
</View>
{/* 正文 */}
{item.summary ? <Text className="cd-summary">{item.summary}</Text> : null}
{item.body
? <RichText className="cd-body" nodes={item.body} />
: <Text className="cd-body">暂无正文</Text>}
{/* 图集 */}
{images.length > 0 && (
<ScrollView className="cd-gallery" scrollX>
{images.map((u, i) => <Image key={i} className="cd-gal-img" src={u} mode="aspectFill" />)}
</ScrollView>
)}
{item.link ? (
<Button className="btn btn-ghost cd-link" type="default" onClick={() => Taro.setClipboardData({ data: item.link })}>
原文链接点击复制
</Button>
<Button className="btn btn-ghost cd-link" type="default" onClick={() => Taro.setClipboardData({ data: item.link })}>原文链接点击复制</Button>
) : null}
{/* 作者条 + 动作 */}
<View className="cd-bar">
<View className="cd-bar-left">
{item.publisher_avatar ? <Image className="cd-ava2" src={item.publisher_avatar} mode="aspectFill" /> : <View className="cd-ava2" />}
<Text className="cd-author">{author}<Text className="cd-badge"></Text></Text>
</View>
<View className="cd-bar-actions">
<View className="cd-act" onClick={() => setLike((v) => !v)}><Text className={like ? 'cd-act-ic on' : 'cd-act-ic'}>👍</Text><Text className="cd-act-n">{like ? 1 : 0}</Text></View>
<View className="cd-act" onClick={copyLink}><Text className="cd-act-ic"></Text><Text className="cd-act-n">转发</Text></View>
<View className="cd-act" onClick={() => setFav((v) => !v)}><Text className={fav ? 'cd-act-ic on' : 'cd-act-ic'}></Text></View>
<View className="cd-act" onClick={scrollToComment}><Text className="cd-act-ic"></Text><Text className="cd-act-n">留言</Text></View>
</View>
</View>
{/* 阅读 + 留言 */}
<View className="cd-read">阅读 {views}</View>
<View className="cd-comments">
<Text className="cd-cmt-title">留言</Text>
<View className="cd-cmt-input">
<Input className="cd-cmt-field" placeholder="写留言…" value={draft} onInput={(e) => setDraft(e.detail.value)} />
<View className="cd-cmt-tools"><Text>😊</Text><Text>🖼</Text></View>
<Button className="btn btn-sm cd-cmt-send" disabled={sending || !draft.trim()} onClick={send}>发送</Button>
</View>
{comments.length === 0
? <Text className="cd-cmt-empty">还没有留言来写首评</Text>
: comments.map((c) => (
<View key={c.id} className="cd-cmt">
<View className="cd-cmt-head">
{c.avatar ? <Image className="cd-ava" src={c.avatar} mode="aspectFill" /> : <View className="cd-ava" />}
<View className="cd-cmt-meta"><Text className="cd-cmt-name">{c.nickname || c.username || '用户'}</Text><Text className="cd-cmt-time">{rw(c.created_at)}</Text></View>
</View>
<Text className="cd-cmt-text">{c.content}</Text>
</View>
))}
</View>
</View>
);
}
function fmt(s) {
return (s || '').replace('T', ' ').replace('Z', '').slice(0, 16);
}
@@ -1,18 +1,47 @@
/* 资讯详情 · 小程序样式(暗色令牌一致 */
/* 资讯详情 · 小程序样式(微信文章式 */
.cd { position: relative; z-index: 2; padding: 0 0 60rpx; }
.cd-cover { display: block; width: 100%; border-radius: 20rpx; }
.cd-video { width: 100%; height: 400rpx; border-radius: 20rpx; margin-bottom: 24rpx; }
.cd-title { display: block; margin-top: 24rpx; padding: 0 28rpx; font-size: 38rpx; color: #fff; font-weight: 700; line-height: 1.4; }
.cd-title { display: block; margin-top: 24rpx; padding: 0 28rpx; font-size: 40rpx; color: #fff; font-weight: 700; line-height: 1.4; }
.cd-meta-row { display: flex; align-items: center; gap: 10rpx; padding: 20rpx 28rpx 0; }
.cd-ava { width: 36rpx; height: 36rpx; border-radius: 50%; background: linear-gradient(135deg, #555, #333); }
.cd-author { font-size: 24rpx; color: #c4c2c3; }
.cd-dot { font-size: 24rpx; color: #6d6d72; }
.cd-meta-at { font-size: 24rpx; color: #7c7c80; }
.cd-ava { width: 36rpx; height: 36rpx; border-radius: 50%; background: linear-gradient(135deg, #555, #333); flex-shrink: 0; }
.cd-ava2 { width: 44rpx; height: 44rpx; border-radius: 50%; background: linear-gradient(135deg, #555, #333); flex-shrink: 0; }
.cd-author { font-size: 25rpx; color: #c4c2c3; display: inline-flex; align-items: center; gap: 4rpx; }
.cd-badge { font-size: 22rpx; color: #26a69a; }
.cd-meta-line { display: flex; align-items: center; gap: 8rpx; padding: 8rpx 28rpx 0; }
.cd-meta-at { font-size: 23rpx; color: #7c7c80; }
.cd-dot { font-size: 23rpx; color: #6d6d72; }
.cd-summary { display: block; margin: 20rpx 28rpx 0; font-size: 27rpx; color: #c4c2c3; line-height: 1.7; }
.cd-body { display: block; margin: 20rpx 28rpx 0; font-size: 28rpx; color: #e4e4e6; line-height: 1.8; }
.cd-body { display: block; margin: 20rpx 28rpx 0; font-size: 30rpx; color: #e4e4e6; line-height: 1.9; }
.cd-body img, .cd-body video { max-width: 100%; border-radius: 14rpx; }
.cd-body p { margin: 0 0 8rpx; }
.cd-gallery { display: flex; gap: 12rpx; margin: 24rpx 28rpx 0; white-space: nowrap; }
.cd-gal-img { width: 240rpx; height: 240rpx; border-radius: 14rpx; flex-shrink: 0; margin-right: 12rpx; }
.cd-link { margin: 28rpx 28rpx 0; }
/* 作者条 + 动作 */
.cd-bar { display: flex; align-items: center; justify-content: space-between; padding: 22rpx 28rpx; margin-top: 24rpx; border-top: 1rpx solid rgba(255,255,255,.08); }
.cd-bar-left { display: flex; align-items: center; gap: 12rpx; }
.cd-bar-actions { display: flex; align-items: center; gap: 2rpx; }
.cd-act { display: flex; flex-direction: column; align-items: center; gap: 2rpx; padding: 6rpx 16rpx; border-radius: 10rpx; }
.cd-act-ic { font-size: 34rpx; color: #cfcfd2; }
.cd-act-ic.on { color: #3b82f6; }
.cd-act-n { font-size: 20rpx; color: #8e8e8e; }
/* 阅读 + 留言 */
.cd-read { padding: 24rpx 28rpx 0; font-size: 24rpx; color: #7c7c80; }
.cd-comments { padding: 20rpx 28rpx 0; border-top: 1rpx solid rgba(255,255,255,.08); margin-top: 20rpx; }
.cd-cmt-title { display: block; font-size: 30rpx; color: #fff; font-weight: 700; margin-bottom: 16rpx; }
.cd-cmt-input { display: flex; align-items: center; gap: 12rpx; background: #14141a; border: 1rpx solid #2a2a2d; border-radius: 14rpx; padding: 10rpx 16rpx; }
.cd-cmt-field { flex: 1; color: #e4e4e6; font-size: 26rpx; }
.cd-cmt-tools { display: flex; gap: 14rpx; font-size: 30rpx; color: #9b9b9b; }
.cd-cmt-send { margin: 0; padding: 0 20rpx; font-size: 22rpx; line-height: 2; }
.cd-cmt-empty { display: block; padding: 60rpx 0; text-align: center; font-size: 24rpx; color: #8e8e8e; }
.cd-cmt { margin-top: 22rpx; }
.cd-cmt-head { display: flex; align-items: center; gap: 12rpx; }
.cd-cmt-meta { display: flex; flex-direction: column; }
.cd-cmt-name { font-size: 24rpx; color: #8e8e8e; }
.cd-cmt-time { font-size: 20rpx; color: #6d6d72; }
.cd-cmt-text { display: block; margin-top: 10rpx; font-size: 27rpx; color: #e4e4e6; line-height: 1.6; }
.cd-err { display: block; padding: 90rpx 0; text-align: center; font-size: 26rpx; color: #8e8e8e; }
+12
View File
@@ -24,3 +24,15 @@ export async function getContent(id) {
const r = await request('GET', `/opc/content/${id}`, {}, false);
return r || null;
}
/** 资讯留言列表(公开) */
export async function getComments(id) {
const r = await request('GET', `/opc/content/${id}/comments`, {}, false);
return Array.isArray(r?.items) ? r.items : [];
}
/** 发表留言(需登录) */
export async function addComment(id, content) {
const r = await request('POST', `/opc/content/${id}/comments`, { content }, true);
return r?.comment || null;
}