281 lines
10 KiB
JavaScript
281 lines
10 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
||
import { Modal, Form, Input, Button, message, Upload, Select } from 'antd';
|
||
import { UploadOutlined } from '@ant-design/icons';
|
||
import { createTopic, updateTopic, uploadMedia, getMyPaidItems } from '../api';
|
||
import MDEditor from '@uiw/react-md-editor';
|
||
import rehypeKatex from 'rehype-katex';
|
||
import remarkMath from 'remark-math';
|
||
import 'katex/dist/katex.css';
|
||
|
||
const { Option } = Select;
|
||
|
||
const CreateTopicModal = ({ visible, onClose, onSuccess, initialValues, isEditMode, topicId }) => {
|
||
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([]);
|
||
// eslint-disable-next-line no-unused-vars
|
||
const [mediaList, setMediaList] = useState([]); // Store uploaded media details for preview
|
||
const [content, setContent] = useState("");
|
||
|
||
useEffect(() => {
|
||
if (visible) {
|
||
fetchPaidItems();
|
||
if (isEditMode && initialValues) {
|
||
// Edit Mode: Populate form with initial values
|
||
form.setFieldsValue({
|
||
title: initialValues.title,
|
||
category: initialValues.category,
|
||
});
|
||
setContent(initialValues.content);
|
||
form.setFieldValue('content', initialValues.content);
|
||
|
||
// Handle related item
|
||
let relatedVal = null;
|
||
if (initialValues.related_product) relatedVal = `config_${initialValues.related_product.id || initialValues.related_product}`;
|
||
else if (initialValues.related_course) relatedVal = `course_${initialValues.related_course.id || initialValues.related_course}`;
|
||
else if (initialValues.related_service) relatedVal = `service_${initialValues.related_service.id || initialValues.related_service}`;
|
||
|
||
if (relatedVal) form.setFieldValue('related_item', relatedVal);
|
||
|
||
// Note: We start with empty *new* media IDs.
|
||
// Existing media is embedded in content or stored in DB, we don't need to re-upload or track them here unless we want to delete them (which is complex).
|
||
// For now, we just allow adding NEW media.
|
||
setMediaIds([]);
|
||
setMediaList([]);
|
||
} else {
|
||
// Create Mode: Reset form
|
||
setMediaIds([]);
|
||
setMediaList([]);
|
||
setContent("");
|
||
form.resetFields();
|
||
form.setFieldsValue({ content: "", category: 'discussion' });
|
||
}
|
||
}
|
||
}, [visible, isEditMode, initialValues, form]);
|
||
|
||
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 insertText = file.type.startsWith('video')
|
||
? `\n<video src="${url}" controls width="100%"></video>\n`
|
||
: `\n\n`;
|
||
|
||
const newContent = content + insertText;
|
||
setContent(newContent);
|
||
form.setFieldsValue({ content: newContent });
|
||
|
||
message.success('上传成功');
|
||
} catch (error) {
|
||
console.error(error);
|
||
message.error('上传失败');
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
return false; // 阻止默认上传行为
|
||
};
|
||
|
||
const handleSubmit = async (values) => {
|
||
setLoading(true);
|
||
try {
|
||
// 处理关联项目 ID (select value format: "type_id")
|
||
const relatedValue = values.related_item;
|
||
// Use content state instead of form value to ensure consistency
|
||
const payload = { ...values, content: content, 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;
|
||
} else {
|
||
// If cleared, set to null
|
||
payload.related_product = null;
|
||
payload.related_course = null;
|
||
payload.related_service = null;
|
||
}
|
||
|
||
if (isEditMode && topicId) {
|
||
await updateTopic(topicId, payload);
|
||
message.success('修改成功');
|
||
} else {
|
||
await createTopic(payload);
|
||
message.success('发布成功');
|
||
}
|
||
|
||
form.resetFields();
|
||
if (onSuccess) onSuccess();
|
||
onClose();
|
||
} catch (error) {
|
||
console.error(error);
|
||
message.error((isEditMode ? '修改' : '发布') + '失败: ' + (error.response?.data?.detail || '网络错误'));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Modal
|
||
title={isEditMode ? "编辑帖子" : "发布新帖"}
|
||
open={visible}
|
||
onCancel={onClose}
|
||
footer={null}
|
||
destroyOnHidden
|
||
width={1000}
|
||
centered
|
||
maskClosable={false}
|
||
>
|
||
<Form
|
||
form={form}
|
||
layout="vertical"
|
||
onFinish={handleSubmit}
|
||
initialValues={{ category: 'discussion' }}
|
||
>
|
||
<Form.Item
|
||
name="title"
|
||
label="标题"
|
||
rules={[{ required: true, message: '请输入标题' }, { max: 100, message: '标题最多100字' }]}
|
||
>
|
||
<Input placeholder="请输入清晰的问题或讨论标题" size="large" />
|
||
</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="内容 (支持 Markdown 与 LaTeX 公式)"
|
||
rules={[{ required: true, message: '请输入内容' }]}
|
||
>
|
||
<div data-color-mode="light">
|
||
<div style={{ marginBottom: 10 }}>
|
||
<Upload
|
||
beforeUpload={handleUpload}
|
||
showUploadList={false}
|
||
accept="image/*,video/*"
|
||
>
|
||
<Button icon={<UploadOutlined />} loading={uploading} size="small">
|
||
插入图片/视频
|
||
</Button>
|
||
</Upload>
|
||
</div>
|
||
|
||
<MDEditor
|
||
value={content}
|
||
onChange={(val) => {
|
||
setContent(val);
|
||
form.setFieldsValue({ content: val });
|
||
}}
|
||
height={400}
|
||
previewOptions={{
|
||
rehypePlugins: [[rehypeKatex]],
|
||
remarkPlugins: [[remarkMath]],
|
||
}}
|
||
/>
|
||
</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} size="large">
|
||
{isEditMode ? "保存修改" : "立即发布"}
|
||
</Button>
|
||
</div>
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
);
|
||
};
|
||
|
||
export default CreateTopicModal; |