This commit is contained in:
jeremygan2021
2026-02-12 15:02:53 +08:00
parent b4ac97c3c2
commit 9e81eaaaab
23 changed files with 844 additions and 104 deletions

View File

@@ -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![${file.name}](${url})\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>

View File

@@ -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',

View 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;