This commit is contained in:
2026-02-12 20:50:01 +08:00
parent d049f682f5
commit 414d3334fd
82 changed files with 1835 additions and 422 deletions

View File

@@ -1,78 +1,361 @@
.activity-detail {
padding-bottom: 80px;
.activity-detail-page {
min-height: 100vh;
background-color: #050505;
color: #fff;
padding-bottom: 100px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
.cover {
width: 100%;
display: block;
.loading-container, .error-container {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: #888;
font-size: 16px;
}
.content {
padding: 20px;
/* Hero Section */
.hero-section {
position: relative;
height: 320px;
width: 100%;
overflow: hidden;
.title {
font-size: 24px;
font-weight: bold;
display: block;
margin-bottom: 20px;
.hero-bg {
width: 100%;
height: 100%;
object-fit: cover;
}
.meta-box {
background: #f9f9f9;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
.hero-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(to top, #050505 0%, rgba(5,5,5,0.6) 50%, rgba(0,0,0,0.2) 100%);
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 24px;
box-sizing: border-box;
.meta-row {
display: flex;
margin-bottom: 8px;
.status-tag {
align-self: flex-start;
background: var(--primary-cyan, #00f0ff);
color: #000;
font-weight: 800;
padding: 4px 12px;
border-radius: 4px;
font-size: 14px;
color: #333;
margin-bottom: 12px;
box-shadow: 0 0 10px rgba(0, 240, 255, 0.4);
}
.hero-title {
font-size: 32px;
font-weight: 800;
color: #fff;
text-shadow: 0 2px 10px rgba(0,0,0,0.5);
line-height: 1.3;
}
}
}
.main-content {
padding: 0 24px;
transform: translateY(-20px);
position: relative;
z-index: 10;
}
/* Info Grid */
.info-grid {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
margin-bottom: 32px;
.info-card {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 16px;
display: flex;
align-items: center;
.info-text {
margin-left: 16px;
display: flex;
flex-direction: column;
.label {
color: #666;
width: 50px;
flex-shrink: 0;
font-size: 12px;
color: rgba(255,255,255,0.5);
margin-bottom: 4px;
}
&:last-child {
margin-bottom: 0;
.value {
font-size: 16px;
color: #fff;
font-weight: 500;
}
}
}
}
/* Stats Section */
.stats-section {
background: #111;
border-radius: 20px;
padding: 24px;
margin-bottom: 32px;
border: 1px solid rgba(255,255,255,0.05);
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
.stats-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 16px;
.stats-title {
font-size: 18px;
font-weight: 700;
color: #fff;
}
.stats-count {
.current {
font-size: 24px;
font-weight: 800;
color: var(--primary-cyan, #00f0ff);
}
.divider {
font-size: 16px;
color: #666;
margin: 0 4px;
}
.max {
font-size: 18px;
color: #888;
}
}
}
.section-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 10px;
border-left: 4px solid #00b96b;
padding-left: 10px;
}
.progress-bar-container {
height: 8px;
background: rgba(255,255,255,0.1);
border-radius: 4px;
position: relative;
overflow: hidden;
.description {
font-size: 16px;
line-height: 1.6;
color: #333;
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #00f0ff, #bd00ff);
border-radius: 4px;
transition: width 0.5s ease-out;
}
.progress-glow {
position: absolute;
top: 0;
width: 10px;
height: 100%;
background: #fff;
filter: blur(4px);
opacity: 0.8;
transform: translateX(-50%);
transition: left 0.5s ease-out;
}
}
}
.footer-bar {
/* Detail Section */
.detail-section {
.section-header {
display: flex;
align-items: center;
margin-bottom: 24px;
.title-text {
font-size: 22px;
font-weight: 800;
color: #fff;
margin-right: 16px;
white-space: nowrap;
}
.line {
flex: 1;
height: 1px;
background: linear-gradient(90deg, rgba(255,255,255,0.2), transparent);
}
}
.rich-text-wrapper {
color: #ccc;
font-size: 16px;
line-height: 1.8;
image {
max-width: 100%;
border-radius: 12px;
margin: 16px 0;
}
/* Markdown Styling */
h1, h2, h3, h4, h5, h6 { margin-top: 24px; margin-bottom: 16px; color: #fff; font-weight: 700; line-height: 1.4; }
h1 { font-size: 24px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 8px; }
h2 { font-size: 20px; border-bottom: 1px solid rgba(255,255,255,0.05); padding-bottom: 6px; }
h3 { font-size: 18px; }
p { margin-bottom: 16px; }
strong { font-weight: 800; color: #fff; }
em { font-style: italic; color: #aaa; }
del { text-decoration: line-through; color: #666; }
ul, ol { margin-bottom: 16px; padding-left: 20px; }
li { margin-bottom: 6px; list-style-position: outside; }
ul li { list-style-type: disc; }
ol li { list-style-type: decimal; }
blockquote {
border-left: 4px solid var(--primary-cyan, #00f0ff);
background: rgba(255, 255, 255, 0.05);
padding: 12px 16px;
margin: 16px 0;
border-radius: 4px;
color: #bbb;
font-style: italic;
p { margin-bottom: 0; }
}
a { color: var(--primary-cyan, #00f0ff); text-decoration: none; border-bottom: 1px solid transparent; transition: border-color 0.2s; }
hr {
height: 1px;
background: rgba(255,255,255,0.1);
border: none;
margin: 24px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
font-size: 14px;
overflow-x: auto;
display: block;
th, td {
border: 1px solid rgba(255,255,255,0.1);
padding: 10px;
text-align: left;
}
th {
background: rgba(255,255,255,0.05);
font-weight: 700;
color: #fff;
}
tr:nth-child(even) {
background: rgba(255,255,255,0.02);
}
}
code {
background: rgba(255,255,255,0.1);
padding: 3px 6px;
border-radius: 4px;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
color: #ff7875;
font-size: 14px;
margin: 0 4px;
}
pre {
background: #161616;
padding: 16px;
border-radius: 12px;
overflow-x: auto;
margin: 16px 0;
border: 1px solid #333;
box-shadow: inset 0 0 20px rgba(0,0,0,0.5);
code {
background: transparent;
color: #a6e22e;
padding: 0;
font-size: 14px;
margin: 0;
white-space: pre;
}
}
}
}
/* Footer Action Bar */
.footer-action-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #fff;
padding: 10px 20px;
box-shadow: 0 -2px 10px rgba(0,0,0,0.05);
padding-bottom: calc(10px + env(safe-area-inset-bottom));
background: rgba(10, 10, 10, 0.9);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
padding: 16px 24px;
padding-bottom: calc(16px + env(safe-area-inset-bottom));
display: flex;
align-items: center;
justify-content: space-between;
border-top: 1px solid rgba(255,255,255,0.1);
z-index: 100;
.btn-signup {
width: 100%;
background: #00b96b;
.left-info {
display: flex;
flex-direction: column;
.price {
font-size: 24px;
font-weight: 800;
color: var(--primary-cyan, #00f0ff);
}
.desc {
font-size: 12px;
color: #888;
}
}
.action-btn {
margin: 0;
padding: 0 40px;
height: 56px;
line-height: 56px;
border-radius: 28px;
font-size: 18px;
font-weight: 700;
border: none;
&[disabled] {
background: #ccc;
color: #fff;
&.active {
background: linear-gradient(90deg, #00f0ff, #00b96b);
color: #000;
box-shadow: 0 4px 15px rgba(0, 240, 255, 0.3);
&:active {
transform: scale(0.98);
}
}
&.disabled {
background: rgba(255,255,255,0.1);
color: #666;
}
}
}

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react'
import Taro, { useRouter, useShareAppMessage } from '@tarojs/taro'
import Taro, { useRouter, useShareAppMessage, useDidShow } from '@tarojs/taro'
import { View, Text, Image, Button, RichText } from '@tarojs/components'
import { AtIcon, AtProgress, AtModal, AtModalHeader, AtModalContent, AtModalAction, AtInput } from 'taro-ui'
import { getActivityDetail, signupActivity } from '../../../api'
import { marked } from 'marked'
import './detail.scss'
@@ -13,19 +14,41 @@ const ActivityDetail = () => {
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [htmlContent, setHtmlContent] = useState('')
const [signupPercentage, setSignupPercentage] = useState(0)
useEffect(() => {
// Signup Form State
const [showSignupModal, setShowSignupModal] = useState(false)
const [formData, setFormData] = useState<any>({})
// Function to refresh data, can be used in useDidShow
const refreshData = () => {
if (id) {
fetchDetail()
}
}
useDidShow(() => {
refreshData()
})
useEffect(() => {
refreshData()
}, [id])
const fetchDetail = async () => {
try {
const res = await getActivityDetail(Number(id))
setActivity(res.data)
if (res.data.description) {
const html = marked.parse(res.data.description)
const data = res.data || res
setActivity(data)
// Calculate signup progress
if (data.max_participants > 0) {
const percent = Math.min(100, Math.round((data.current_signups || 0) / data.max_participants * 100))
setSignupPercentage(percent)
}
if (data.description) {
const html = marked.parse(data.description)
setHtmlContent((html as string).replace(/<img/g, '<img style="max-width:100%;border-radius:8px;"'))
}
} catch (error) {
@@ -40,23 +63,58 @@ const ActivityDetail = () => {
const token = Taro.getStorageSync('token')
if (!token) {
Taro.showToast({ title: '请先登录', icon: 'none' })
// Optional: Redirect to login
// Taro.navigateTo({ url: '/pages/user/login' })
return
}
// Check if form config exists
if (activity.signup_form_config && activity.signup_form_config.length > 0) {
setFormData({}) // Reset form
setShowSignupModal(true)
return
}
// Direct signup if no config
submitSignup({})
}
const submitSignup = async (data: any) => {
setSubmitting(true)
try {
await signupActivity(Number(id))
await signupActivity(Number(id), { signup_info: data })
Taro.showToast({ title: '报名成功', icon: 'success' })
setShowSignupModal(false)
fetchDetail() // Refresh status
} catch (error) {
} catch (error: any) {
console.error(error)
const msg = error.response?.data?.error || '报名失败'
const msg = error.response?.data?.error || error.message || '报名失败'
Taro.showToast({ title: msg, icon: 'none' })
} finally {
setSubmitting(false)
}
}
const handleFormChange = (fieldName, value) => {
setFormData(prev => ({
...prev,
[fieldName]: value
}))
}
const handleModalConfirm = () => {
// Validate
if (activity.signup_form_config) {
for (const field of activity.signup_form_config) {
if (field.required && !formData[field.name]) {
Taro.showToast({ title: `请填写${field.label}`, icon: 'none' })
return
}
}
}
submitSignup(formData)
}
useShareAppMessage(() => {
return {
title: activity?.title || '社区活动',
@@ -64,49 +122,124 @@ const ActivityDetail = () => {
}
})
if (loading) return <View>Loading...</View>
if (!activity) return <View></View>
if (loading) return <View className='loading-container'><Text>Loading...</Text></View>
if (!activity) return <View className='error-container'><Text></Text></View>
const isFull = activity.max_participants > 0 && (activity.current_signups || 0) >= activity.max_participants
const isEnded = new Date(activity.end_time) < new Date()
const canSignup = activity.is_active && !isFull && !isEnded && !activity.has_signed_up
return (
<View className='activity-detail'>
<Image src={activity.cover_image} mode='widthFix' className='cover' />
<View className='content'>
<Text className='title'>{activity.title}</Text>
<View className='meta-box'>
<View className='meta-row'>
<Text className='label'></Text>
<Text>{new Date(activity.start_time).toLocaleString()} ~ {new Date(activity.end_time).toLocaleString()}</Text>
<View className='activity-detail-page'>
{/* Hero Banner */}
<View className='hero-section'>
<Image
src={activity.display_banner_url || activity.banner_url || activity.cover_image || 'https://via.placeholder.com/800x600'}
mode='aspectFill'
className='hero-bg'
/>
<View className='hero-overlay'>
<View className='status-tag'>
{isEnded ? '已结束' : (isFull ? '名额已满' : '报名中')}
</View>
<View className='meta-row'>
<Text className='label'></Text>
<Text>{activity.location || '线上活动'}</Text>
</View>
<View className='meta-row'>
<Text className='label'></Text>
<Text>{activity.current_signups} / {activity.max_participants > 0 ? activity.max_participants : '不限'}</Text>
</View>
</View>
<View className='description'>
<View className='section-title'></View>
<RichText nodes={htmlContent} />
<Text className='hero-title'>{activity.title}</Text>
</View>
</View>
<View className='footer-bar'>
<View className='main-content'>
{/* Info Cards */}
<View className='info-grid'>
<View className='info-card time'>
<AtIcon value='clock' size='18' color='#00f0ff' />
<View className='info-text'>
<Text className='label'></Text>
<Text className='value'>{new Date(activity.start_time).toLocaleString()}</Text>
</View>
</View>
<View className='info-card location'>
<AtIcon value='map-pin' size='18' color='#bd00ff' />
<View className='info-text'>
<Text className='label'></Text>
<Text className='value'>{activity.location || '线上直播'}</Text>
</View>
</View>
</View>
{/* Signup Stats */}
<View className='stats-section'>
<View className='stats-header'>
<Text className='stats-title'></Text>
<Text className='stats-count'>
<Text className='current'>{activity.current_signups || 0}</Text>
<Text className='divider'>/</Text>
<Text className='max'>{activity.max_participants > 0 ? activity.max_participants : '∞'}</Text>
</Text>
</View>
<View className='progress-bar-container'>
<View className='progress-bar' style={{width: `${signupPercentage}%`}} />
<View className='progress-glow' style={{left: `${signupPercentage}%`}} />
</View>
</View>
{/* Detail Content */}
<View className='detail-section'>
<View className='section-header'>
<Text className='title-text'></Text>
<View className='line' />
</View>
<View className='rich-text-wrapper'>
<RichText nodes={htmlContent} />
</View>
</View>
</View>
{/* Footer Action Bar */}
<View className='footer-action-bar'>
<View className='left-info'>
<Text className='price'></Text>
<Text className='desc'></Text>
</View>
<Button
className='btn-signup'
type='primary'
disabled={activity.has_signed_up || activity.status !== 'open' || submitting}
className={`action-btn ${canSignup ? 'active' : 'disabled'}`}
disabled={!canSignup || submitting}
onClick={handleSignup}
>
{activity.has_signed_up ? '已报名' : (activity.status === 'open' ? '立即报名' : '无法报名')}
{submitting ? '提交中...' : (
activity.has_signed_up ? '您已报名' : (
isEnded ? '活动已结束' : (
isFull ? '名额已满' : '立即报名'
)
)
)}
</Button>
</View>
{/* Signup Form Modal */}
<AtModal isOpened={showSignupModal} onClose={() => setShowSignupModal(false)}>
<AtModalHeader></AtModalHeader>
<AtModalContent>
<View className='signup-form'>
{activity.signup_form_config && activity.signup_form_config.map((field, idx) => (
<AtInput
key={idx}
name={field.name}
title={field.label}
type={field.type || 'text'}
placeholder={`请输入${field.label}`}
value={formData[field.name]}
onChange={(val) => handleFormChange(field.name, val)}
required={field.required}
/>
))}
</View>
</AtModalContent>
<AtModalAction>
<Button onClick={() => setShowSignupModal(false)}></Button>
<Button onClick={handleModalConfirm} style={{color: '#00b96b'}}></Button>
</AtModalAction>
</AtModal>
</View>
)
}
export default ActivityDetail
export default ActivityDetail

View File

@@ -1,108 +1,163 @@
.activity-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 20px;
background-color: #050505;
padding-bottom: 40px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
.tabs {
display: flex;
background: #fff;
padding: 10px 0;
.tabs-header {
background: rgba(10, 10, 10, 0.8);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
padding: 12px 24px;
position: sticky;
top: 0;
z-index: 10;
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
z-index: 100;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
.tab {
flex: 1;
text-align: center;
font-size: 16px;
color: #666;
padding: 10px 0;
position: relative;
&.active {
color: #00b96b;
font-weight: bold;
&:after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 30px;
height: 3px;
background: #00b96b;
border-radius: 2px;
.tabs-bg {
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
display: flex;
padding: 4px;
.tab {
flex: 1;
text-align: center;
font-size: 15px;
color: #888;
padding: 8px 0;
border-radius: 8px;
transition: all 0.3s;
font-weight: 500;
&.active {
color: #000;
background: var(--primary-cyan, #00f0ff);
font-weight: 700;
box-shadow: 0 2px 8px rgba(0, 240, 255, 0.3);
}
}
}
}
.list-container {
padding: 15px;
padding: 24px;
}
.empty {
text-align: center;
color: #999;
padding: 50px 0;
color: #666;
padding: 80px 0;
font-size: 16px;
}
.activity-card {
background: #fff;
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
border-radius: 20px;
overflow: hidden;
margin-bottom: 15px;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
margin-bottom: 24px;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
transition: transform 0.3s;
.cover {
&:active {
transform: scale(0.98);
}
.card-cover-wrapper {
position: relative;
height: 200px;
width: 100%;
height: 150px;
overflow: hidden;
.cover {
width: 100%;
height: 100%;
object-fit: cover;
}
.status-tag {
position: absolute;
top: 16px;
left: 16px;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(10px);
color: #fff;
font-size: 12px;
padding: 4px 10px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.2);
}
}
.info {
padding: 15px;
padding: 20px;
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 10px;
margin-bottom: 12px;
.title {
font-size: 18px;
font-weight: bold;
flex: 1;
margin-right: 10px;
}
.status {
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
background: #eee;
color: #666;
&.open { background: #e6ffed; color: #00b96b; }
&.upcoming { background: #e6f7ff; color: #1890ff; }
&.ended { background: #f5f5f5; color: #999; }
font-size: 20px;
font-weight: 700;
color: #fff;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
.meta {
display: flex;
flex-direction: column;
gap: 5px;
font-size: 14px;
color: #666;
gap: 8px;
margin-bottom: 20px;
.meta-item {
display: flex;
align-items: center;
font-size: 14px;
color: #999;
.icon {
margin-right: 8px;
font-size: 14px;
}
}
}
.signup-status {
margin-top: 10px;
text-align: right;
color: #00b96b;
font-size: 14px;
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 16px;
border-top: 1px solid rgba(255, 255, 255, 0.05);
.participants {
.highlight {
color: var(--primary-cyan, #00f0ff);
font-weight: 700;
font-size: 16px;
}
.total {
color: #666;
font-size: 13px;
margin-left: 2px;
}
}
.btn-view {
margin: 0;
background: rgba(255, 255, 255, 0.1);
color: #fff;
font-size: 14px;
padding: 0 16px;
height: 32px;
line-height: 32px;
border-radius: 16px;
border: none;
}
}
}
}

View File

@@ -1,15 +1,22 @@
import React, { useState, useEffect } from 'react'
import Taro, { usePullDownRefresh } from '@tarojs/taro'
import Taro, { usePullDownRefresh, useRouter, useDidShow } from '@tarojs/taro'
import { View, Text, Image, Button } from '@tarojs/components'
import { getActivities, getMySignups } from '../../../api'
import './index.scss'
const ActivityList = () => {
const router = useRouter()
const [activities, setActivities] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const [tab, setTab] = useState<'all' | 'mine'>('all')
const [mySignups, setMySignups] = useState<any[]>([])
useEffect(() => {
if (router.params.tab === 'mine') {
setTab('mine')
}
}, [router.params.tab])
const fetchData = async () => {
setLoading(true)
try {
@@ -33,6 +40,10 @@ const ActivityList = () => {
fetchData()
}, [tab])
useDidShow(() => {
fetchData()
})
usePullDownRefresh(() => {
fetchData()
})
@@ -54,33 +65,59 @@ const ActivityList = () => {
const renderList = (list) => {
if (list.length === 0 && !loading) return <View className='empty'></View>
return list.map(item => (
<View key={item.id} className='activity-card' onClick={() => goDetail(item.id)}>
<Image src={item.cover_image || 'https://via.placeholder.com/350x150'} mode='aspectFill' className='cover' />
<View className='info'>
<View className='header'>
<Text className='title'>{item.title}</Text>
<Text className={`status ${item.status}`}>{getStatusText(item.status)}</Text>
</View>
<View className='meta'>
<Text>📅 {new Date(item.start_time).toLocaleDateString()}</Text>
<Text>📍 {item.location || '线上活动'}</Text>
</View>
{tab === 'mine' && (
<View className='signup-status'>
<Text></Text>
return list.map(item => {
// Handle API structure differences
// For 'mine' tab, item is the signup record. activity_info is the full activity object.
const activity = tab === 'mine' ? (item.activity_info || item.activity || item) : item
return (
<View key={activity.id} className='activity-card' onClick={() => goDetail(activity.id)}>
<View className='card-cover-wrapper'>
<Image
src={activity.display_banner_url || activity.banner_url || activity.cover_image || 'https://via.placeholder.com/350x150'}
mode='aspectFill'
className='cover'
/>
<View className='status-tag'>
{getStatusText(activity.status)}
</View>
</View>
<View className='info'>
<View className='header'>
<Text className='title'>{activity.title}</Text>
</View>
)}
</View>
</View>
))
<View className='meta'>
<View className='meta-item'>
<Text className='icon'>📅</Text>
<Text>{new Date(activity.start_time).toLocaleDateString()}</Text>
</View>
<View className='meta-item'>
<Text className='icon'>📍</Text>
<Text>{activity.location || '线上活动'}</Text>
</View>
</View>
<View className='card-footer'>
<View className='participants'>
<Text className='highlight'>{activity.current_signups || 0}</Text>
<Text className='total'>/{activity.max_participants > 0 ? activity.max_participants : '∞'} </Text>
</View>
<Button className='btn-view'></Button>
</View>
</View>
</View>
)
})
}
return (
<View className='activity-page'>
<View className='tabs'>
<View className={`tab ${tab === 'all' ? 'active' : ''}`} onClick={() => setTab('all')}></View>
<View className={`tab ${tab === 'mine' ? 'active' : ''}`} onClick={() => setTab('mine')}></View>
<View className='tabs-header'>
<View className='tabs-bg'>
<View className={`tab ${tab === 'all' ? 'active' : ''}`} onClick={() => setTab('all')}></View>
<View className={`tab ${tab === 'mine' ? 'active' : ''}`} onClick={() => setTab('mine')}></View>
</View>
</View>
<View className='list-container'>

View File

@@ -1,9 +1,150 @@
.create-topic-page {
min-height: 100vh;
background-color: #000;
padding: 20px;
color: #fff;
.content-wrapper {
padding: 20px;
padding-bottom: 40px;
}
.header-actions {
display: flex;
margin-bottom: 20px;
background: rgba(255,255,255,0.1);
border-radius: 8px;
padding: 4px;
.action-item {
flex: 1;
text-align: center;
padding: 8px 0;
font-size: 14px;
color: #888;
border-radius: 6px;
transition: all 0.3s;
&.active {
background: #333;
color: #fff;
font-weight: 600;
}
}
}
.preview-container {
margin-bottom: 30px;
background: #111;
padding: 20px;
border-radius: 12px;
border: 1px solid #333;
.preview-title {
font-size: 20px;
font-weight: bold;
margin-bottom: 16px;
border-bottom: 1px solid #333;
padding-bottom: 10px;
}
.markdown-body {
font-size: 15px;
line-height: 1.6;
color: #e0e0e0;
image {
max-width: 100%;
border-radius: 8px;
margin: 10px 0;
}
h1, h2, h3, h4, h5, h6 { margin-top: 24px; margin-bottom: 12px; color: #fff; font-weight: 700; line-height: 1.4; }
h1 { font-size: 22px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 8px; }
h2 { font-size: 18px; border-bottom: 1px solid rgba(255,255,255,0.05); padding-bottom: 6px; }
h3 { font-size: 16px; }
p { margin-bottom: 14px; }
strong { font-weight: 800; color: #fff; }
em { font-style: italic; color: #aaa; }
del { text-decoration: line-through; color: #666; }
ul, ol { margin-bottom: 14px; padding-left: 20px; }
li { margin-bottom: 4px; list-style-position: outside; }
ul li { list-style-type: disc; }
ol li { list-style-type: decimal; }
blockquote {
border-left: 4px solid #00b96b;
background: rgba(255, 255, 255, 0.05);
padding: 10px 14px;
margin: 14px 0;
border-radius: 4px;
color: #bbb;
font-style: italic;
p { margin-bottom: 0; }
}
a { color: #00b96b; text-decoration: none; }
hr {
height: 1px;
background: rgba(255,255,255,0.1);
border: none;
margin: 20px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 14px 0;
font-size: 13px;
overflow-x: auto;
display: block;
th, td {
border: 1px solid rgba(255,255,255,0.1);
padding: 8px;
text-align: left;
}
th {
background: rgba(255,255,255,0.05);
font-weight: 700;
color: #fff;
}
}
code {
background: rgba(255,255,255,0.1);
padding: 2px 6px;
border-radius: 4px;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
color: #ff7875;
margin: 0 2px;
}
pre {
background: #161616;
padding: 16px;
border-radius: 8px;
overflow-x: auto;
margin: 16px 0;
border: 1px solid #333;
code {
background: transparent;
color: #a6e22e;
padding: 0;
font-size: 13px;
margin: 0;
white-space: pre;
}
}
}
}
.form-item {
margin-bottom: 20px;

View File

@@ -1,7 +1,8 @@
import React, { useState, useEffect } from 'react'
import Taro, { useRouter } from '@tarojs/taro'
import { View, Text, Input, Textarea, Button, Picker, ScrollView } from '@tarojs/components'
import { View, Text, Input, Textarea, Button, Picker, ScrollView, RichText } from '@tarojs/components'
import { createTopic, updateTopic, getTopicDetail, uploadMedia } from '../../../api'
import { marked } from 'marked'
import './create.scss'
const CreateTopic = () => {
@@ -12,6 +13,7 @@ const CreateTopic = () => {
const [content, setContent] = useState('')
const [categoryIndex, setCategoryIndex] = useState(0)
const [loading, setLoading] = useState(false)
const [isPreview, setIsPreview] = useState(false)
const categories = [
{ key: 'discussion', label: '技术讨论' },
@@ -25,7 +27,7 @@ const CreateTopic = () => {
Taro.showLoading({ title: '加载中...' })
try {
const res = await getTopicDetail(Number(id))
const topic = res.data
const topic = res.data || res
setTitle(topic.title)
setContent(topic.content)
const idx = categories.findIndex(c => c.key === topic.category)
@@ -64,7 +66,7 @@ const CreateTopic = () => {
let url = uploadRes.file
if (url && !url.startsWith('http')) {
const BASE_URL = process.env.TARO_APP_API_URL || 'https://market.quant-speed.com/api'
const BASE_URL = (typeof process !== 'undefined' && process.env && 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}`
@@ -124,47 +126,75 @@ const CreateTopic = () => {
return (
<ScrollView scrollY className='create-topic-page'>
<View className='form-item'>
<Text className='label'></Text>
<Picker range={categories} rangeKey='label' value={categoryIndex} onChange={handleCategoryChange}>
<View className='picker'>
{categories[categoryIndex].label}
</View>
</Picker>
</View>
<View className='form-item'>
<Text className='label'></Text>
<Input
value={title}
onInput={e => setTitle(e.detail.value)}
placeholder='请输入标题'
/>
</View>
<View className='form-item'>
<Text className='label'> ( Markdown)</Text>
<Textarea
value={content}
onInput={e => setContent(e.detail.value)}
placeholder='分享你的想法...'
maxlength={-1}
/>
</View>
<View className='media-upload'>
<View className='upload-btn' onClick={handleUpload}>
+ /
<View className='content-wrapper'>
<View className='header-actions'>
<View
className={`action-item ${!isPreview ? 'active' : ''}`}
onClick={() => setIsPreview(false)}
>
</View>
<View
className={`action-item ${isPreview ? 'active' : ''}`}
onClick={() => setIsPreview(true)}
>
</View>
</View>
</View>
<Button
className={`submit-btn ${loading ? 'disabled' : ''}`}
onClick={handleSubmit}
disabled={loading}
>
{loading ? (id ? '更新中...' : '发布中...') : (id ? '更新话题' : '发布话题')}
</Button>
{!isPreview ? (
<>
<View className='form-item'>
<Text className='label'></Text>
<Picker range={categories} rangeKey='label' value={categoryIndex} onChange={handleCategoryChange}>
<View className='picker'>
{categories[categoryIndex].label}
</View>
</Picker>
</View>
<View className='form-item'>
<Text className='label'></Text>
<Input
value={title}
onInput={e => setTitle(e.detail.value)}
placeholder='请输入标题'
/>
</View>
<View className='form-item'>
<Text className='label'> ( Markdown)</Text>
<Textarea
value={content}
onInput={e => setContent(e.detail.value)}
placeholder='分享你的想法...'
maxlength={-1}
/>
</View>
<View className='media-upload'>
<View className='upload-btn' onClick={handleUpload}>
+ /
</View>
</View>
</>
) : (
<View className='preview-container'>
<View className='preview-title'>{title || '无标题'}</View>
<View className='markdown-body'>
<RichText nodes={(marked.parse(content || '无内容') as string).replace(/<img/g, '<img style="max-width:100%;border-radius:8px;"')} />
</View>
</View>
)}
<Button
className={`submit-btn ${loading ? 'disabled' : ''}`}
onClick={handleSubmit}
disabled={loading}
>
{loading ? (id ? '更新中...' : '发布中...') : (id ? '更新话题' : '发布话题')}
</Button>
</View>
</ScrollView>
)
}

View File

@@ -1,102 +1,216 @@
.forum-detail-page {
min-height: 100vh;
background-color: #000;
padding-bottom: 80px;
background-color: #121212;
padding-bottom: 90px;
color: #fff;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
.topic-card {
background: rgba(20,20,20,0.8);
border-bottom: 1px solid rgba(255,255,255,0.1);
padding: 20px;
margin-bottom: 20px;
background: #1e1e1e;
border-bottom: 1px solid rgba(255,255,255,0.05);
padding: 24px 20px;
margin-bottom: 24px;
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
.header {
margin-bottom: 15px;
margin-bottom: 24px;
.title {
font-size: 20px;
font-weight: bold;
font-size: 32px; /* Increased from 28px */
font-weight: 800;
color: #fff;
margin-bottom: 10px;
margin-bottom: 20px;
line-height: 1.4;
letter-spacing: -0.5px;
}
.meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
gap: 12px;
color: #888;
font-size: 12px;
font-size: 14px; /* Increased from 13px */
.author {
display: flex;
align-items: center;
gap: 6px;
gap: 10px;
background: rgba(255,255,255,0.05);
padding: 6px 12px 6px 6px;
border-radius: 20px;
.avatar {
width: 24px;
height: 24px;
width: 32px; /* Larger avatar */
height: 32px;
border-radius: 50%;
border: 1px solid rgba(255,255,255,0.1);
}
.verified {
color: #00b96b;
font-size: 16px;
margin-left: 2px;
}
}
}
}
.content {
font-size: 16px;
line-height: 1.6;
color: #ddd;
font-size: 20px; /* Increased from 18px */
line-height: 1.9; /* Improved readability */
color: #e0e0e0;
letter-spacing: 0.3px;
image {
max-width: 100%;
border-radius: 8px;
margin: 10px 0;
border-radius: 12px;
margin: 24px 0;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
/* Markdown styling enhancements */
h1, h2, h3, h4, h5, h6 { margin-top: 32px; margin-bottom: 20px; color: #fff; font-weight: 700; line-height: 1.4; }
h1 { font-size: 28px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 12px; }
h2 { font-size: 24px; border-bottom: 1px solid rgba(255,255,255,0.05); padding-bottom: 8px; }
h3 { font-size: 22px; }
h4 { font-size: 20px; }
h5 { font-size: 18px; color: #ddd; }
p { margin-bottom: 24px; }
strong { font-weight: 800; color: #fff; }
em { font-style: italic; color: #aaa; }
del { text-decoration: line-through; color: #666; }
ul, ol { margin-bottom: 24px; padding-left: 20px; }
li { margin-bottom: 8px; list-style-position: outside; }
ul li { list-style-type: disc; }
ol li { list-style-type: decimal; }
/* Task lists */
li input[type="checkbox"] { margin-right: 8px; }
blockquote {
border-left: 4px solid #00b96b;
background: rgba(255, 255, 255, 0.05);
padding: 16px 20px;
margin: 24px 0;
border-radius: 4px;
color: #bbb;
font-size: 18px;
font-style: italic;
p { margin-bottom: 0; }
}
a { color: #00b96b; text-decoration: none; border-bottom: 1px solid transparent; transition: border-color 0.2s; }
hr {
height: 1px;
background: rgba(255,255,255,0.1);
border: none;
margin: 40px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 24px 0;
font-size: 16px;
overflow-x: auto;
display: block;
th, td {
border: 1px solid rgba(255,255,255,0.1);
padding: 12px;
text-align: left;
}
th {
background: rgba(255,255,255,0.05);
font-weight: 700;
color: #fff;
}
tr:nth-child(even) {
background: rgba(255,255,255,0.02);
}
}
code {
background: rgba(255,255,255,0.1);
padding: 4px 8px;
border-radius: 4px;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
color: #ff7875;
font-size: 16px;
margin: 0 4px;
}
pre {
background: #161616;
padding: 20px;
border-radius: 12px;
overflow-x: auto;
margin: 24px 0;
border: 1px solid #333;
box-shadow: inset 0 0 20px rgba(0,0,0,0.5);
code {
background: transparent;
color: #a6e22e;
padding: 0;
font-size: 15px;
margin: 0;
white-space: pre;
}
}
}
.media-list {
margin-top: 20px;
margin-top: 24px;
.media-item {
margin-bottom: 15px;
margin-bottom: 16px;
video {
width: 100%;
border-radius: 8px;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
}
}
}
.replies-section {
padding: 0 15px;
padding: 0 16px;
.section-title {
font-size: 16px;
font-weight: bold;
margin-bottom: 15px;
padding-left: 5px;
border-left: 3px solid #00b96b;
font-size: 18px;
font-weight: 700;
margin-bottom: 20px;
padding-left: 12px;
border-left: 4px solid #00b96b;
color: #fff;
}
.reply-card {
background: rgba(255,255,255,0.05);
border-radius: 8px;
padding: 15px;
margin-bottom: 15px;
background: #1e1e1e;
border-radius: 12px;
padding: 24px; /* Increased padding */
margin-bottom: 24px;
display: flex;
gap: 10px;
gap: 16px; /* Increased gap */
border: 1px solid rgba(255,255,255,0.03);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
.avatar {
width: 30px;
height: 30px;
width: 44px; /* Larger avatar */
height: 44px;
border-radius: 50%;
flex-shrink: 0;
border: 1px solid rgba(255,255,255,0.1);
}
.reply-main {
@@ -105,29 +219,33 @@
.reply-header {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
align-items: baseline;
margin-bottom: 12px;
.nickname {
font-weight: bold;
color: #aaa;
font-size: 14px;
font-weight: 600;
color: #ddd;
font-size: 16px; /* Larger nickname */
}
.time {
color: #666;
font-size: 12px;
font-size: 13px;
background: rgba(255,255,255,0.05);
padding: 3px 8px;
border-radius: 4px;
}
}
.reply-content {
font-size: 14px;
color: #eee;
line-height: 1.5;
font-size: 17px; /* Larger reply text */
color: #ccc;
line-height: 1.8;
image {
max-width: 100%;
border-radius: 4px;
margin-top: 5px;
border-radius: 8px;
margin-top: 14px;
}
}
}
@@ -139,36 +257,57 @@
bottom: 0;
left: 0;
right: 0;
background: #1a1a1a;
padding: 10px 15px;
border-top: 1px solid #333;
background: rgba(20, 20, 20, 0.95); /* More opaque */
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
padding: 16px 20px;
padding-bottom: calc(16px + env(safe-area-inset-bottom));
border-top: 1px solid rgba(255,255,255,0.1);
display: flex;
align-items: center;
gap: 10px;
gap: 16px;
z-index: 100;
box-shadow: 0 -4px 20px rgba(0,0,0,0.3);
.input-wrapper {
flex: 1;
background: #333;
border-radius: 20px;
padding: 8px 15px;
input {
color: #fff;
width: 100%;
}
flex: 1;
background: rgba(255,255,255,0.1);
border-radius: 26px;
padding: 14px 20px; /* Larger input area */
transition: all 0.3s;
border: 1px solid transparent;
&:focus-within {
background: rgba(255,255,255,0.15);
border-color: rgba(0, 185, 107, 0.5);
}
input {
color: #fff;
width: 100%;
font-size: 18px; /* Larger text */
}
}
.action-btn {
padding: 0 10px;
width: 48px; /* Larger touch target */
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background 0.2s;
&:active {
background: rgba(255,255,255,0.1);
}
}
.send-btn {
color: #00b96b;
font-weight: bold;
font-weight: 700;
font-size: 16px;
padding: 0 8px;
}
}
}

View File

@@ -20,11 +20,12 @@ const ForumDetail = () => {
const fetchDetail = async () => {
try {
const res = await getTopicDetail(Number(id))
setTopic(res.data)
const topicData = res.data || res
setTopic(topicData)
// Parse markdown
if (res.data.content) {
const html = marked.parse(res.data.content)
if (topicData.content) {
const html = marked.parse(topicData.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)
@@ -87,7 +88,7 @@ const ForumDetail = () => {
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 BASE_URL = (typeof process !== 'undefined' && process.env && 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}`
@@ -149,23 +150,26 @@ const ForumDetail = () => {
<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>
{userInfo && topic.author === userInfo.id && (
<View onClick={handleEdit} style={{display: 'flex', alignItems: 'center', marginLeft: 'auto', padding: '4px 8px', background: 'rgba(255,255,255,0.1)', borderRadius: 4}}>
<AtIcon value='edit' size='14' color='#00b96b' />
<Text style={{fontSize: 12, color: '#00b96b', marginLeft: 4}}></Text>
<View className='author'>
<Image className='avatar' src={topic.author_info?.avatar_url || 'https://via.placeholder.com/30'} />
<Text style={{fontWeight: 600, color: '#ccc'}}>{topic.author_info?.nickname}</Text>
{topic.is_verified_owner && <AtIcon value='check-circle' size='14' color='#00b96b' />}
</View>
)}
</View>
<Text></Text>
<Text>{new Date(topic.created_at).toLocaleDateString()}</Text>
<Text></Text>
<View style={{display: 'flex', alignItems: 'center'}}>
<AtIcon value='eye' size='14' color='#666' style={{marginRight: 4}} />
<Text>{topic.view_count}</Text>
</View>
{userInfo && topic.author === userInfo.id && (
<View onClick={handleEdit} style={{display: 'flex', alignItems: 'center', marginLeft: 'auto', padding: '6px 12px', background: 'rgba(0, 185, 107, 0.15)', borderRadius: 20}}>
<AtIcon value='edit' size='14' color='#00b96b' />
<Text style={{fontSize: 12, color: '#00b96b', marginLeft: 4, fontWeight: 600}}></Text>
</View>
)}
</View>
</View>
<View className='content'>
@@ -191,8 +195,11 @@ const ForumDetail = () => {
<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 style={{display: 'flex', flexDirection: 'column'}}>
<Text className='nickname'>{reply.author_info?.nickname}</Text>
<Text style={{fontSize: 10, color: '#666', marginTop: 2}}>#{idx + 1} {new Date(reply.created_at).toLocaleDateString()}</Text>
</View>
<AtIcon value='message' size='14' color='#444' />
</View>
<View className='reply-content'>
{/* Simple markdown render for replies or just text if complex */}