forum
This commit is contained in:
@@ -38,7 +38,6 @@ export const sendSms = (data) => api.post('/auth/send-sms/', data);
|
||||
export const queryMyOrders = (data) => api.post('/orders/my_orders/', data);
|
||||
export const phoneLogin = (data) => api.post('/auth/phone-login/', data);
|
||||
export const getUserInfo = () => {
|
||||
const token = localStorage.getItem('token');
|
||||
// 如果没有获取用户信息的接口,可以暂时从本地解析或依赖 update_user_info 的返回
|
||||
// 但后端有 /wechat/update/ 可以返回用户信息,或者我们可以加一个 /auth/me/
|
||||
// 目前 phone_login 返回了用户信息,前端可以保存。
|
||||
@@ -47,22 +46,31 @@ export const getUserInfo = () => {
|
||||
return api.post('/wechat/update/', {});
|
||||
};
|
||||
|
||||
// Community / Forum API
|
||||
export const getTopics = (params) => api.get('/topics/', { params });
|
||||
export const getTopicDetail = (id) => api.get(`/topics/${id}/`);
|
||||
export const createTopic = (data) => api.post('/topics/', data);
|
||||
export const getReplies = (params) => api.get('/replies/', { params });
|
||||
export const createReply = (data) => api.post('/replies/', data);
|
||||
export const uploadMedia = (data) => {
|
||||
return api.post('/media/', data, {
|
||||
export const updateUserInfo = (data) => api.post('/wechat/update/', data);
|
||||
export const uploadUserAvatar = (data) => {
|
||||
// 使用 axios 直接请求外部接口,避免 base URL 和拦截器干扰
|
||||
return axios.post('https://data.tangledup-ai.com/upload?folder=uploads/market/avator', data, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
}
|
||||
});
|
||||
};
|
||||
// 获取明星技术用户 (目前暂无专门接口,通过 /wechat/login 返回的 token 获取当前用户信息,或者通过 filter 获取用户列表如果后端开放)
|
||||
// 由于没有专门的用户列表接口,我们暂时不实现 getStarUsers API,或者在 ForumList 中模拟或请求特定的 Top 榜单接口。
|
||||
// 为了演示,我们假设后端开放一个 user list 接口,或者我们修改 Topic 列表返回 author_info 时前端自行筛选。
|
||||
// 最好的方式是后端提供一个 star_users 接口。我们暂时跳过,只在 ForumList 中处理。
|
||||
|
||||
// Community / Forum API
|
||||
export const getTopics = (params) => api.get('/community/topics/', { params });
|
||||
export const getTopicDetail = (id) => api.get(`/community/topics/${id}/`);
|
||||
export const createTopic = (data) => api.post('/community/topics/', data);
|
||||
export const getReplies = (params) => api.get('/community/replies/', { params });
|
||||
export const createReply = (data) => api.post('/community/replies/', data);
|
||||
export const uploadMedia = (data) => {
|
||||
return api.post('/community/media/', data, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
}
|
||||
});
|
||||
};
|
||||
export const getStarUsers = () => api.get('/users/stars/');
|
||||
export const getMyPaidItems = () => api.get('/users/paid-items/');
|
||||
export const getAnnouncements = () => api.get('/community/announcements/');
|
||||
|
||||
export default api;
|
||||
|
||||
@@ -1,19 +1,116 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal, Form, Input, Button, message, Upload, Select } from 'antd';
|
||||
import { InboxOutlined } from '@ant-design/icons';
|
||||
import { createTopic } from '../api';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Form, Input, Button, message, Upload, Select, Divider, Radio, Tabs, Alert } from 'antd';
|
||||
import { InboxOutlined, PlusOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import { createTopic, uploadMedia, getMyPaidItems } from '../api';
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Option } = Select;
|
||||
const { Dragger } = Upload;
|
||||
|
||||
const CreateTopicModal = ({ visible, onClose, onSuccess }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [paidItems, setPaidItems] = useState({ configs: [], courses: [], services: [] });
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [mediaIds, setMediaIds] = useState([]);
|
||||
const [mediaList, setMediaList] = useState([]); // Store uploaded media details for preview
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
fetchPaidItems();
|
||||
setMediaIds([]); // Reset media IDs
|
||||
setMediaList([]); // Reset media list
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const fetchPaidItems = async () => {
|
||||
try {
|
||||
const res = await getMyPaidItems();
|
||||
setPaidItems(res.data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch paid items", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
// 默认为 image,如果需要支持视频需根据 file.type 判断
|
||||
formData.append('media_type', file.type.startsWith('video') ? 'video' : 'image');
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const res = await uploadMedia(formData);
|
||||
// 记录上传的媒体 ID
|
||||
if (res.data.id) {
|
||||
setMediaIds(prev => [...prev, res.data.id]);
|
||||
}
|
||||
|
||||
// 确保 URL 是完整的
|
||||
// 由于后端现在是转发到外部OSS,返回的URL通常是完整的,但也可能是相对的,这里统一处理
|
||||
let url = res.data.file;
|
||||
|
||||
// 处理反斜杠问题(防止 Windows 路径风格影响 URL)
|
||||
if (url) {
|
||||
url = url.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
if (url && !url.startsWith('http')) {
|
||||
// 如果返回的是相对路径,拼接 API URL 或 Base URL
|
||||
const baseURL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||
// 移除 baseURL 末尾的 /api 或 /
|
||||
const host = baseURL.replace(/\/api\/?$/, '');
|
||||
// 确保 url 以 / 开头
|
||||
if (!url.startsWith('/')) url = '/' + url;
|
||||
url = `${host}${url}`;
|
||||
}
|
||||
|
||||
// 清理 URL 中的双斜杠 (除协议头外)
|
||||
url = url.replace(/([^:]\/)\/+/g, '$1');
|
||||
|
||||
// Add to media list for preview
|
||||
setMediaList(prev => [...prev, {
|
||||
id: res.data.id,
|
||||
url: url,
|
||||
type: file.type.startsWith('video') ? 'video' : 'image',
|
||||
name: file.name
|
||||
}]);
|
||||
|
||||
// 插入到编辑器
|
||||
const currentContent = form.getFieldValue('content') || '';
|
||||
const insertText = file.type.startsWith('video')
|
||||
? `\n<video src="${url}" controls width="100%"></video>\n`
|
||||
: `\n\n`;
|
||||
|
||||
form.setFieldsValue({
|
||||
content: currentContent + insertText
|
||||
});
|
||||
message.success('上传成功');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
return false; // 阻止默认上传行为
|
||||
};
|
||||
|
||||
const handleSubmit = async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await createTopic(values);
|
||||
// 处理关联项目 ID (select value format: "type_id")
|
||||
const relatedValue = values.related_item;
|
||||
const payload = { ...values, media_ids: mediaIds };
|
||||
delete payload.related_item;
|
||||
|
||||
if (relatedValue) {
|
||||
const [type, id] = relatedValue.split('_');
|
||||
if (type === 'config') payload.related_product = id;
|
||||
if (type === 'course') payload.related_course = id;
|
||||
if (type === 'service') payload.related_service = id;
|
||||
}
|
||||
|
||||
await createTopic(payload);
|
||||
message.success('发布成功');
|
||||
form.resetFields();
|
||||
if (onSuccess) onSuccess();
|
||||
@@ -33,12 +130,13 @@ const CreateTopicModal = ({ visible, onClose, onSuccess }) => {
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
width={800}
|
||||
style={{ top: 20 }}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
style={{ marginTop: 20 }}
|
||||
initialValues={{ category: 'discussion' }}
|
||||
>
|
||||
<Form.Item
|
||||
@@ -46,38 +144,100 @@ const CreateTopicModal = ({ visible, onClose, onSuccess }) => {
|
||||
label="标题"
|
||||
rules={[{ required: true, message: '请输入标题' }, { max: 100, message: '标题最多100字' }]}
|
||||
>
|
||||
<Input placeholder="请输入清晰的问题或讨论标题" />
|
||||
<Input placeholder="请输入清晰的问题或讨论标题" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="category"
|
||||
label="分类"
|
||||
rules={[{ required: true, message: '请选择分类' }]}
|
||||
>
|
||||
<Select>
|
||||
<Option value="discussion">技术讨论</Option>
|
||||
<Option value="help">求助问答</Option>
|
||||
<Option value="share">经验分享</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', gap: 20 }}>
|
||||
<Form.Item
|
||||
name="category"
|
||||
label="分类"
|
||||
style={{ width: 200 }}
|
||||
rules={[{ required: true, message: '请选择分类' }]}
|
||||
>
|
||||
<Select>
|
||||
<Option value="discussion">技术讨论</Option>
|
||||
<Option value="help">求助问答</Option>
|
||||
<Option value="share">经验分享</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="related_item"
|
||||
label="关联已购项目 (可选)"
|
||||
style={{ flex: 1 }}
|
||||
tooltip="关联已购项目可获得“认证用户”标识"
|
||||
>
|
||||
<Select placeholder="选择关联项目..." allowClear>
|
||||
<Select.OptGroup label="硬件产品">
|
||||
{paidItems.configs.map(i => (
|
||||
<Option key={`config_${i.id}`} value={`config_${i.id}`}>{i.name}</Option>
|
||||
))}
|
||||
</Select.OptGroup>
|
||||
<Select.OptGroup label="VC 课程">
|
||||
{paidItems.courses.map(i => (
|
||||
<Option key={`course_${i.id}`} value={`course_${i.id}`}>{i.title}</Option>
|
||||
))}
|
||||
</Select.OptGroup>
|
||||
<Select.OptGroup label="AI 服务">
|
||||
{paidItems.services.map(i => (
|
||||
<Option key={`service_${i.id}`} value={`service_${i.id}`}>{i.title}</Option>
|
||||
))}
|
||||
</Select.OptGroup>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
name="content"
|
||||
label="内容"
|
||||
label="内容 (支持 Markdown)"
|
||||
rules={[{ required: true, message: '请输入内容' }]}
|
||||
>
|
||||
<TextArea
|
||||
rows={6}
|
||||
placeholder="请详细描述您的问题,支持 Markdown 格式"
|
||||
showCount
|
||||
maxLength={5000}
|
||||
/>
|
||||
<div>
|
||||
<Upload
|
||||
beforeUpload={handleUpload}
|
||||
showUploadList={false}
|
||||
accept="image/*,video/*"
|
||||
>
|
||||
<Button icon={<UploadOutlined />} loading={uploading} size="small" style={{ marginBottom: 8 }}>
|
||||
插入图片/视频
|
||||
</Button>
|
||||
</Upload>
|
||||
|
||||
{/* Media Preview Area */}
|
||||
{mediaList.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginBottom: 10 }}>
|
||||
{mediaList.map((item, index) => (
|
||||
<div key={index} style={{ position: 'relative', width: 80, height: 80, border: '1px solid #ddd', borderRadius: 4, overflow: 'hidden' }}>
|
||||
{item.type === 'video' ? (
|
||||
<video src={item.url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<img src={item.url} alt="preview" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TextArea
|
||||
rows={12}
|
||||
placeholder="请详细描述您的问题...
|
||||
支持 Markdown 语法:
|
||||
**加粗**
|
||||
# 标题
|
||||
- 列表
|
||||
[链接](url)
|
||||
"
|
||||
showCount
|
||||
maxLength={10000}
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
/>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
<Button type="primary" htmlType="submit" loading={loading}>
|
||||
<Button type="primary" htmlType="submit" loading={loading} size="large">
|
||||
立即发布
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RobotOutlined, MenuOutlined, AppstoreOutlined, EyeOutlined, SearchOutli
|
||||
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import ParticleBackground from './ParticleBackground';
|
||||
import LoginModal from './LoginModal';
|
||||
import ProfileModal from './ProfileModal';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
@@ -15,6 +16,7 @@ const Layout = ({ children }) => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [loginVisible, setLoginVisible] = useState(false);
|
||||
const [profileVisible, setProfileVisible] = useState(false);
|
||||
|
||||
const { user, login, logout } = useAuth();
|
||||
|
||||
@@ -34,6 +36,12 @@ const Layout = ({ children }) => {
|
||||
|
||||
const userMenu = {
|
||||
items: [
|
||||
{
|
||||
key: 'profile',
|
||||
label: '个人设置',
|
||||
icon: <UserOutlined />,
|
||||
onClick: () => setProfileVisible(true)
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
label: '退出登录',
|
||||
@@ -201,8 +209,16 @@ const Layout = ({ children }) => {
|
||||
<div style={{ padding: '20px', textAlign: 'center', borderBottom: '1px solid #333' }}>
|
||||
{user ? (
|
||||
<div style={{ color: '#fff' }}>
|
||||
<Avatar src={user.avatar_url} icon={<UserOutlined />} size="large" style={{ marginBottom: 10 }} />
|
||||
<div>{user.nickname}</div>
|
||||
<Avatar
|
||||
src={user.avatar_url}
|
||||
icon={<UserOutlined />}
|
||||
size="large"
|
||||
style={{ marginBottom: 10, cursor: 'pointer' }}
|
||||
onClick={() => { setProfileVisible(true); setMobileMenuOpen(false); }}
|
||||
/>
|
||||
<div onClick={() => { setProfileVisible(true); setMobileMenuOpen(false); }} style={{ cursor: 'pointer' }}>
|
||||
{user.nickname}
|
||||
</div>
|
||||
<Button type="link" danger onClick={handleLogout} style={{ marginTop: 10 }}>退出登录</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -225,6 +241,11 @@ const Layout = ({ children }) => {
|
||||
onLoginSuccess={(userData) => login(userData)}
|
||||
/>
|
||||
|
||||
<ProfileModal
|
||||
visible={profileVisible}
|
||||
onClose={() => setProfileVisible(false)}
|
||||
/>
|
||||
|
||||
<Content style={{ marginTop: 72, padding: '40px 20px', overflowX: 'hidden' }}>
|
||||
<div style={{
|
||||
maxWidth: '1200px',
|
||||
|
||||
124
frontend/src/components/ProfileModal.jsx
Normal file
124
frontend/src/components/ProfileModal.jsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Form, Input, Upload, Button, message, Avatar } from 'antd';
|
||||
import { UserOutlined, UploadOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { updateUserInfo, uploadUserAvatar } from '../api';
|
||||
|
||||
const ProfileModal = ({ visible, onClose }) => {
|
||||
const { user, updateUser } = useAuth();
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [avatarUrl, setAvatarUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && user) {
|
||||
form.setFieldsValue({
|
||||
nickname: user.nickname,
|
||||
});
|
||||
setAvatarUrl(user.avatar_url);
|
||||
}
|
||||
}, [visible, user, form]);
|
||||
|
||||
const handleUpload = async (file) => {
|
||||
const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png';
|
||||
if (!isJpgOrPng) {
|
||||
message.error('You can only upload JPG/PNG file!');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
const isLt2M = file.size / 1024 / 1024 < 2;
|
||||
if (!isLt2M) {
|
||||
message.error('Image must smaller than 2MB!');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const res = await uploadUserAvatar(formData);
|
||||
if (res.data.success) {
|
||||
setAvatarUrl(res.data.file_url);
|
||||
message.success('头像上传成功');
|
||||
} else {
|
||||
message.error('头像上传失败: ' + (res.data.message || '未知错误'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error);
|
||||
message.error('头像上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
return false; // Prevent default auto upload
|
||||
};
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
|
||||
const updateData = {
|
||||
nickname: values.nickname,
|
||||
avatar_url: avatarUrl
|
||||
};
|
||||
|
||||
const res = await updateUserInfo(updateData);
|
||||
updateUser(res.data);
|
||||
message.success('个人信息更新成功');
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Update failed:', error);
|
||||
message.error('更新失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="个人设置"
|
||||
open={visible}
|
||||
onOk={handleOk}
|
||||
onCancel={onClose}
|
||||
confirmLoading={loading}
|
||||
centered
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
style={{ marginTop: 20 }}
|
||||
>
|
||||
<Form.Item label="头像" style={{ textAlign: 'center' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 15 }}>
|
||||
<Avatar
|
||||
size={100}
|
||||
src={avatarUrl}
|
||||
icon={<UserOutlined />}
|
||||
/>
|
||||
<Upload
|
||||
name="avatar"
|
||||
showUploadList={false}
|
||||
beforeUpload={handleUpload}
|
||||
accept="image/*"
|
||||
>
|
||||
<Button icon={uploading ? <LoadingOutlined /> : <UploadOutlined />} loading={uploading}>
|
||||
{uploading ? '上传中...' : '更换头像'}
|
||||
</Button>
|
||||
</Upload>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="nickname"
|
||||
label="昵称"
|
||||
rules={[{ required: true, message: '请输入昵称' }]}
|
||||
>
|
||||
<Input placeholder="请输入昵称" maxLength={20} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileModal;
|
||||
@@ -32,8 +32,12 @@ const ForumDetail = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const hasFetched = React.useRef(false);
|
||||
useEffect(() => {
|
||||
fetchTopic();
|
||||
if (!hasFetched.current) {
|
||||
fetchTopic();
|
||||
hasFetched.current = true;
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const handleSubmitReply = async () => {
|
||||
@@ -122,8 +126,45 @@ const ForumDetail = () => {
|
||||
minHeight: 200,
|
||||
whiteSpace: 'pre-wrap' // Preserve formatting
|
||||
}}>
|
||||
{topic.content}
|
||||
{topic.content.replace(/!\[.*?\]\(.*?\)/g, '[图片]')}
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const regexMatches = topic.content.match(/!\[.*?\]\((.*?)\)/g);
|
||||
const regexImages = regexMatches ? regexMatches.map(match => match.match(/!\[.*?\]\((.*?)\)/)[1]) : [];
|
||||
|
||||
// 优先使用 Markdown 中解析出的图片(保持顺序)
|
||||
if (regexImages.length > 0) {
|
||||
return regexImages.map((url, index) => (
|
||||
<div key={`regex-${index}`} style={{ marginTop: 12 }}>
|
||||
<img
|
||||
src={url}
|
||||
alt="content"
|
||||
style={{ maxHeight: 400, borderRadius: 8, maxWidth: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
// 兜底:如果 Markdown 解析失败或未插入但已上传,显示关联的媒体资源
|
||||
if (topic.media && topic.media.length > 0) {
|
||||
return topic.media.map((media) => (
|
||||
<div key={`media-${media.id}`} style={{ marginTop: 12 }}>
|
||||
{media.media_type === 'video' ? (
|
||||
<video src={media.url} controls style={{ maxHeight: 400, borderRadius: 8, maxWidth: '100%' }} />
|
||||
) : (
|
||||
<img
|
||||
src={media.url}
|
||||
alt="content"
|
||||
style={{ maxHeight: 400, borderRadius: 8, maxWidth: '100%' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
return null;
|
||||
})()}
|
||||
</Card>
|
||||
|
||||
{/* Replies List */}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Typography, Input, Button, List, Tag, Avatar, Card, Space, Spin, messag
|
||||
import { SearchOutlined, PlusOutlined, UserOutlined, MessageOutlined, EyeOutlined, CheckCircleFilled, FireOutlined, StarFilled, QuestionCircleOutlined, ShareAltOutlined, SoundOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { getTopics } from '../api';
|
||||
import { getTopics, getStarUsers, getAnnouncements } from '../api';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import CreateTopicModal from '../components/CreateTopicModal';
|
||||
import LoginModal from '../components/LoginModal';
|
||||
@@ -16,6 +16,8 @@ const ForumList = () => {
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [topics, setTopics] = useState([]);
|
||||
const [starUsers, setStarUsers] = useState([]);
|
||||
const [announcements, setAnnouncements] = useState([]);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [category, setCategory] = useState('all');
|
||||
const [createModalVisible, setCreateModalVisible] = useState(false);
|
||||
@@ -38,8 +40,28 @@ const ForumList = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchStarUsers = async () => {
|
||||
try {
|
||||
const res = await getStarUsers();
|
||||
setStarUsers(res.data);
|
||||
} catch (error) {
|
||||
console.error("Fetch star users failed", error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAnnouncements = async () => {
|
||||
try {
|
||||
const res = await getAnnouncements();
|
||||
setAnnouncements(res.data.results || res.data);
|
||||
} catch (error) {
|
||||
console.error("Fetch announcements failed", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTopics(searchText, category);
|
||||
fetchStarUsers();
|
||||
fetchAnnouncements();
|
||||
}, [category]);
|
||||
|
||||
const handleSearch = (value) => {
|
||||
@@ -176,8 +198,18 @@ const ForumList = () => {
|
||||
ellipsis={{ rows: 2 }}
|
||||
style={{ color: '#aaa', marginBottom: 12, fontSize: 14 }}
|
||||
>
|
||||
{item.content.replace(/[#*`]/g, '')} {/* Simple markdown strip */}
|
||||
{item.content.replace(/!\[.*?\]\(.*?\)/g, '[图片]').replace(/[#*`]/g, '')} {/* Simple markdown strip */}
|
||||
</Paragraph>
|
||||
|
||||
{item.content.match(/!\[.*?\]\((.*?)\)/) && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<img
|
||||
src={item.content.match(/!\[.*?\]\((.*?)\)/)[1]}
|
||||
alt="cover"
|
||||
style={{ maxHeight: 150, borderRadius: 8, maxWidth: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Space size="middle" style={{ color: '#666', fontSize: 13 }}>
|
||||
<Space>
|
||||
@@ -224,21 +256,21 @@ const ForumList = () => {
|
||||
headStyle={{ borderBottom: '1px solid rgba(255,255,255,0.1)' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', padding: '20px 0', color: '#666' }}>
|
||||
{/* 这里可以通过 API 获取专家列表,目前先做静态展示或从帖子中提取 */}
|
||||
<div style={{ marginBottom: 15, display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<Avatar size="large" src="https://api.dicebear.com/7.x/avataaars/svg?seed=Expert1" />
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
<div style={{ color: '#fff', fontWeight: 'bold' }}>QuantMaster <StarFilled style={{ color: '#ffd700', fontSize: 12 }} /></div>
|
||||
<div style={{ color: '#666', fontSize: 12 }}>官方技术支持</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: 15, display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<Avatar size="large" src="https://api.dicebear.com/7.x/avataaars/svg?seed=Expert2" />
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
<div style={{ color: '#fff', fontWeight: 'bold' }}>AI_Wizard <StarFilled style={{ color: '#ffd700', fontSize: 12 }} /></div>
|
||||
<div style={{ color: '#666', fontSize: 12 }}>社区贡献者</div>
|
||||
</div>
|
||||
</div>
|
||||
{starUsers.length > 0 ? (
|
||||
starUsers.map(u => (
|
||||
<div key={u.id} style={{ marginBottom: 15, display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<Avatar size="large" src={u.avatar_url} icon={<UserOutlined />} />
|
||||
<div style={{ textAlign: 'left' }}>
|
||||
<div style={{ color: '#fff', fontWeight: 'bold' }}>
|
||||
{u.nickname} <StarFilled style={{ color: '#ffd700', fontSize: 12 }} />
|
||||
</div>
|
||||
<div style={{ color: '#666', fontSize: 12 }}>{u.title || '技术专家'}</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div style={{ color: '#888' }}>暂无上榜专家</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -249,12 +281,27 @@ const ForumList = () => {
|
||||
>
|
||||
<List
|
||||
size="small"
|
||||
dataSource={[
|
||||
'欢迎来到 Quant Speed 开发者社区',
|
||||
'发帖前请阅读社区规范',
|
||||
'如何获取“认证用户”标识?'
|
||||
]}
|
||||
renderItem={item => <List.Item style={{ color: '#aaa', padding: '8px 0', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>{item}</List.Item>}
|
||||
dataSource={announcements}
|
||||
renderItem={item => (
|
||||
<List.Item style={{ padding: '12px 0', borderBottom: '1px solid rgba(255,255,255,0.05)', display: 'block' }}>
|
||||
{item.display_image_url && (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<img src={item.display_image_url} alt={item.title} style={{ width: '100%', borderRadius: 4 }} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ color: '#fff', marginBottom: 4, fontWeight: 'bold' }}>
|
||||
{item.link_url ? (
|
||||
<a href={item.link_url} target="_blank" rel="noopener noreferrer" style={{ color: '#fff' }}>{item.title}</a>
|
||||
) : (
|
||||
<span>{item.title}</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ color: '#888', fontSize: 12 }}>
|
||||
{item.content}
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
locale={{ emptyText: <div style={{ color: '#666', padding: '20px 0', textAlign: 'center' }}>暂无公告</div> }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
Reference in New Issue
Block a user