forum
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '话题详情',
|
||||
backgroundColor: '#000000',
|
||||
navigationBarBackgroundColor: '#000000',
|
||||
navigationBarTextStyle: 'white'
|
||||
})
|
||||
174
miniprogram/src/subpackages/forum/detail/detail.scss
Normal file
174
miniprogram/src/subpackages/forum/detail/detail.scss
Normal file
@@ -0,0 +1,174 @@
|
||||
.forum-detail-page {
|
||||
min-height: 100vh;
|
||||
background-color: #000;
|
||||
padding-bottom: 80px;
|
||||
color: #fff;
|
||||
|
||||
.topic-card {
|
||||
background: rgba(20,20,20,0.8);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.header {
|
||||
margin-bottom: 15px;
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
|
||||
.author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.verified {
|
||||
color: #00b96b;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: #ddd;
|
||||
|
||||
image {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.media-list {
|
||||
margin-top: 20px;
|
||||
|
||||
.media-item {
|
||||
margin-bottom: 15px;
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.replies-section {
|
||||
padding: 0 15px;
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 15px;
|
||||
padding-left: 5px;
|
||||
border-left: 3px solid #00b96b;
|
||||
}
|
||||
|
||||
.reply-card {
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
|
||||
.avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reply-main {
|
||||
flex: 1;
|
||||
|
||||
.reply-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
|
||||
.nickname {
|
||||
font-weight: bold;
|
||||
color: #aaa;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.time {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.reply-content {
|
||||
font-size: 14px;
|
||||
color: #eee;
|
||||
line-height: 1.5;
|
||||
|
||||
image {
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.reply-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #1a1a1a;
|
||||
padding: 10px 15px;
|
||||
border-top: 1px solid #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
z-index: 100;
|
||||
|
||||
.input-wrapper {
|
||||
flex: 1;
|
||||
background: #333;
|
||||
border-radius: 20px;
|
||||
padding: 8px 15px;
|
||||
|
||||
input {
|
||||
color: #fff;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
color: #00b96b;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
202
miniprogram/src/subpackages/forum/detail/index.tsx
Normal file
202
miniprogram/src/subpackages/forum/detail/index.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Taro, { useRouter, useShareAppMessage } from '@tarojs/taro'
|
||||
import { View, Text, Image, Video, RichText, Input, Button } from '@tarojs/components'
|
||||
import { AtActivityIndicator, AtIcon } from 'taro-ui'
|
||||
import { getTopicDetail, createReply, uploadMedia } from '../../../api'
|
||||
import { marked } from 'marked'
|
||||
import './detail.scss'
|
||||
|
||||
const ForumDetail = () => {
|
||||
const router = useRouter()
|
||||
const { id } = router.params
|
||||
|
||||
const [topic, setTopic] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [replyContent, setReplyContent] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
const [htmlContent, setHtmlContent] = useState('')
|
||||
|
||||
const fetchDetail = async () => {
|
||||
try {
|
||||
const res = await getTopicDetail(Number(id))
|
||||
setTopic(res.data)
|
||||
|
||||
// Parse markdown
|
||||
if (res.data.content) {
|
||||
const html = marked.parse(res.data.content)
|
||||
// Basic fix for images to fit screen
|
||||
const styledHtml = (html as string).replace(/<img/g, '<img style="max-width:100%;border-radius:8px;"')
|
||||
setHtmlContent(styledHtml)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
Taro.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchDetail()
|
||||
}
|
||||
}, [id])
|
||||
|
||||
useShareAppMessage(() => {
|
||||
return {
|
||||
title: topic?.title || '技术社区',
|
||||
path: `/subpackages/forum/detail/index?id=${id}`
|
||||
}
|
||||
})
|
||||
|
||||
const handleReplyChange = (e) => {
|
||||
setReplyContent(e.detail.value)
|
||||
}
|
||||
|
||||
const handleUpload = async () => {
|
||||
try {
|
||||
const res = await Taro.chooseMedia({
|
||||
count: 1,
|
||||
mediaType: ['image', 'video'],
|
||||
sourceType: ['album', 'camera']
|
||||
})
|
||||
|
||||
const file = res.tempFiles[0]
|
||||
const type = file.fileType === 'video' ? 'video' : 'image'
|
||||
|
||||
Taro.showLoading({ title: '上传中...' })
|
||||
|
||||
const uploadRes = await uploadMedia(file.tempFilePath, type)
|
||||
|
||||
let url = uploadRes.file
|
||||
// Ensure full URL if needed (backend usually returns relative or absolute)
|
||||
if (url && !url.startsWith('http')) {
|
||||
const BASE_URL = process.env.TARO_APP_API_URL || 'https://market.quant-speed.com/api'
|
||||
const host = BASE_URL.replace(/\/api\/?$/, '')
|
||||
if (!url.startsWith('/')) url = '/' + url
|
||||
url = `${host}${url}`
|
||||
}
|
||||
|
||||
const insertText = type === 'video'
|
||||
? `\n<video src="${url}" controls width="100%"></video>\n`
|
||||
: `\n\n`
|
||||
|
||||
setReplyContent(prev => prev + insertText)
|
||||
|
||||
Taro.hideLoading()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
Taro.hideLoading()
|
||||
Taro.showToast({ title: '上传失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const token = Taro.getStorageSync('token')
|
||||
if (!token) {
|
||||
Taro.showToast({ title: '请先登录', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!replyContent.trim()) {
|
||||
Taro.showToast({ title: '请输入内容', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
setSending(true)
|
||||
try {
|
||||
await createReply({
|
||||
topic: Number(id),
|
||||
content: replyContent
|
||||
})
|
||||
Taro.showToast({ title: '回复成功', icon: 'success' })
|
||||
setReplyContent('')
|
||||
fetchDetail() // Refresh
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
Taro.showToast({ title: '回复失败', icon: 'none' })
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <View className='forum-detail-page'><AtActivityIndicator mode='center' /></View>
|
||||
if (!topic) return <View className='forum-detail-page'><View style={{padding: 20, textAlign: 'center'}}>话题不存在</View></View>
|
||||
|
||||
return (
|
||||
<View className='forum-detail-page'>
|
||||
<View className='topic-card'>
|
||||
<View className='header'>
|
||||
{topic.is_pinned && <Text style={{color: '#ff4d4f', marginRight: 5, fontSize: 12, border: '1px solid #ff4d4f', padding: '0 4px', borderRadius: 4}}>置顶</Text>}
|
||||
<Text className='title'>{topic.title}</Text>
|
||||
|
||||
<View className='meta'>
|
||||
<View className='author'>
|
||||
<Image className='avatar' src={topic.author_info?.avatar_url || 'https://via.placeholder.com/30'} />
|
||||
<Text>{topic.author_info?.nickname}</Text>
|
||||
{topic.is_verified_owner && <Text className='verified'>✓</Text>}
|
||||
</View>
|
||||
<Text>•</Text>
|
||||
<Text>{new Date(topic.created_at).toLocaleDateString()}</Text>
|
||||
<Text>•</Text>
|
||||
<Text>{topic.view_count} 阅读</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='content'>
|
||||
<RichText nodes={htmlContent} />
|
||||
</View>
|
||||
|
||||
{topic.media && topic.media.length > 0 && (
|
||||
<View className='media-list'>
|
||||
{topic.media.filter(m => m.media_type === 'video').map(m => (
|
||||
<View key={m.id} className='media-item'>
|
||||
<Video src={m.url} controls />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className='replies-section'>
|
||||
<View className='section-title'>{topic.replies?.length || 0} 条回复</View>
|
||||
|
||||
{topic.replies?.map((reply, idx) => (
|
||||
<View key={reply.id} className='reply-card'>
|
||||
<Image className='avatar' src={reply.author_info?.avatar_url || 'https://via.placeholder.com/30'} />
|
||||
<View className='reply-main'>
|
||||
<View className='reply-header'>
|
||||
<Text className='nickname'>{reply.author_info?.nickname}</Text>
|
||||
<Text className='time'>#{idx + 1} • {new Date(reply.created_at).toLocaleDateString()}</Text>
|
||||
</View>
|
||||
<View className='reply-content'>
|
||||
{/* Simple markdown render for replies or just text if complex */}
|
||||
<RichText nodes={marked.parse(reply.content) as string} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View className='reply-bar'>
|
||||
<View className='action-btn' onClick={handleUpload}>
|
||||
<AtIcon value='image' size='20' color='#888' />
|
||||
</View>
|
||||
<View className='input-wrapper'>
|
||||
<Input
|
||||
value={replyContent}
|
||||
onInput={handleReplyChange}
|
||||
placeholder='发表回复...'
|
||||
confirmType='send'
|
||||
onConfirm={handleSubmit}
|
||||
/>
|
||||
</View>
|
||||
<View className='action-btn' onClick={handleSubmit}>
|
||||
{sending ? <AtActivityIndicator size={20} /> : <Text className='send-btn'>发送</Text>}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default ForumDetail
|
||||
Reference in New Issue
Block a user