feat(content): 小程序详情富UI(阅读/动作条/留言)
- content-detail 微信文章式:标题 + 作者(头像/昵称/认证✓) + 日期 + 阅读X + 富文本正文 - 作者条+动作(点赞/转发=复制链接/收藏/写留言滚动) - 阅读X + 留言区(输入框+😊🖼+发送+列表,未登录提示登录;空态"还没有留言,来写首评") - utils/content.js +getComments/addComment Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user