专案完成
This commit is contained in:
615
src/components/ProjectList/index.jsx
Normal file
615
src/components/ProjectList/index.jsx
Normal file
@@ -0,0 +1,615 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Form,
|
||||
Input,
|
||||
Button,
|
||||
Card,
|
||||
Typography,
|
||||
Modal,
|
||||
message,
|
||||
Select,
|
||||
Divider,
|
||||
Upload,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
CheckOutlined,
|
||||
CloseOutlined,
|
||||
InboxOutlined,
|
||||
UploadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { supabase } from "@/config/supabase";
|
||||
import { supabaseService } from "@/hooks/supabaseService";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ProjectResourceList = ({ form, isView, formValues, type = "project" }) => {
|
||||
const [editingSectionIndex, setEditingSectionIndex] = useState(null);
|
||||
const [editingSectionName, setEditingSectionName] = useState("");
|
||||
const [templateModalVisible, setTemplateModalVisible] = useState(false);
|
||||
const [availableSections, setAvailableSections] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [resourceTypes, setResourceTypes] = useState([]);
|
||||
const [loadingResourceTypes, setLoadingResourceTypes] = useState(false);
|
||||
const [units, setUnits] = useState([]);
|
||||
const [loadingUnits, setLoadingUnits] = useState(false);
|
||||
const [uploadModalVisible, setUploadModalVisible] = useState(false);
|
||||
const [currentAddItem, setCurrentAddItem] = useState(null);
|
||||
|
||||
const fetchAvailableSections = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const { data: sections, error } = await supabase
|
||||
.from("resources")
|
||||
.select("*")
|
||||
.eq("type", "sections")
|
||||
.eq("attributes->>template_type", type)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
setAvailableSections(sections || []);
|
||||
} catch (error) {
|
||||
message.error("获取资源模板失败");
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchResourceTypes = async () => {
|
||||
setLoadingResourceTypes(true);
|
||||
try {
|
||||
const { data: types } = await supabaseService.select("resources", {
|
||||
filter: {
|
||||
type: { eq: "resource_types" },
|
||||
"attributes->>template_type": { in: `(${type})` },
|
||||
},
|
||||
order: {
|
||||
column: "created_at",
|
||||
ascending: false,
|
||||
},
|
||||
});
|
||||
setResourceTypes(types || []);
|
||||
} catch (error) {
|
||||
console.error('获取资源类型失败:', error);
|
||||
} finally {
|
||||
setLoadingResourceTypes(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSectionNameEdit = (sectionIndex, initialValue) => {
|
||||
setEditingSectionIndex(sectionIndex);
|
||||
setEditingSectionName(initialValue || "");
|
||||
};
|
||||
|
||||
const handleSectionNameSave = () => {
|
||||
if (!editingSectionName.trim()) {
|
||||
message.error("请输入名称");
|
||||
return;
|
||||
}
|
||||
|
||||
const sections = form.getFieldValue("sections");
|
||||
const newSections = [...sections];
|
||||
newSections[editingSectionIndex] = {
|
||||
...newSections[editingSectionIndex],
|
||||
sectionName: editingSectionName.trim(),
|
||||
};
|
||||
form.setFieldValue("sections", newSections);
|
||||
setEditingSectionIndex(null);
|
||||
setEditingSectionName("");
|
||||
};
|
||||
const handleAddUnit = async (unitName) => {
|
||||
try {
|
||||
const { error } = await supabase.from("resources").insert([
|
||||
{
|
||||
type: "units",
|
||||
attributes: {
|
||||
name: unitName,
|
||||
template_type: type,
|
||||
},
|
||||
schema_version: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
if (error) throw error;
|
||||
message.success("新增资源类型成功");
|
||||
fetchUnits();
|
||||
return true;
|
||||
} catch (error) {
|
||||
message.error("新增资源类型失败");
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUseTemplate = (template, add) => {
|
||||
const newSection = {
|
||||
key: uuidv4(),
|
||||
sectionName: template.attributes.name,
|
||||
items: (template.attributes.items || []).map((item) => ({
|
||||
key: uuidv4(),
|
||||
title: item.title || "",
|
||||
unit: item.unit || "",
|
||||
url: item.url || "",
|
||||
description: item.description || "",
|
||||
})),
|
||||
};
|
||||
add(newSection);
|
||||
setTemplateModalVisible(false);
|
||||
message.success("套用模版成功");
|
||||
};
|
||||
|
||||
const handleCreateCustom = (add, fieldsLength) => {
|
||||
add({
|
||||
key: uuidv4(),
|
||||
sectionName: `资源分组 ${fieldsLength + 1}`,
|
||||
items: [
|
||||
{
|
||||
key: uuidv4(),
|
||||
title: "",
|
||||
unit: "",
|
||||
url: "",
|
||||
description: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
setTemplateModalVisible(false);
|
||||
};
|
||||
|
||||
const handleAddItem = (addItem) => {
|
||||
addItem({
|
||||
key: uuidv4(),
|
||||
title: "",
|
||||
unit: "",
|
||||
url: "",
|
||||
description: "",
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileUpload = async (file, addItem) => {
|
||||
try {
|
||||
const fileExt = file.name.split('.').pop();
|
||||
const fileName = `${Date.now()}.${fileExt}`;
|
||||
const filePath = `${type}/${fileName}`;
|
||||
|
||||
const { data, error } = await supabase.storage
|
||||
.from('file')
|
||||
.upload(filePath, file);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from('file')
|
||||
.getPublicUrl(filePath);
|
||||
|
||||
let fileType = '';
|
||||
if (file.type.startsWith('image/')) {
|
||||
fileType = '图片';
|
||||
} else if (file.type.startsWith('video/')) {
|
||||
fileType = '视频';
|
||||
} else if (file.type.includes('pdf')) {
|
||||
fileType = 'PDF文档';
|
||||
} else {
|
||||
fileType = '其他文件';
|
||||
}
|
||||
|
||||
addItem({
|
||||
key: uuidv4(),
|
||||
url: publicUrl,
|
||||
title: `${fileType}-${file.name}`,
|
||||
unit: fileType,
|
||||
description: '',
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
message.error('文件上传失败');
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleButtonClick = (addItem) => {
|
||||
setCurrentAddItem(addItem);
|
||||
setUploadModalVisible(true);
|
||||
};
|
||||
|
||||
const renderUploadModal = () => (
|
||||
<Modal
|
||||
title="上传资源文件"
|
||||
open={uploadModalVisible}
|
||||
onCancel={() => setUploadModalVisible(false)}
|
||||
footer={null}
|
||||
>
|
||||
<Upload.Dragger
|
||||
multiple
|
||||
customRequest={async ({ file, onSuccess, onError }) => {
|
||||
try {
|
||||
const result = await handleFileUpload(file, currentAddItem);
|
||||
if (result) {
|
||||
onSuccess();
|
||||
} else {
|
||||
onError();
|
||||
}
|
||||
} catch (err) {
|
||||
onError();
|
||||
}
|
||||
}}
|
||||
onChange={(info) => {
|
||||
if (info.file.status === 'done') {
|
||||
message.success(`${info.file.name} 上传成功`);
|
||||
} else if (info.file.status === 'error') {
|
||||
message.error(`${info.file.name} 上传失败`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">点击或拖拽文件到此区域上传</p>
|
||||
<p className="ant-upload-hint">支持单个或批量上传</p>
|
||||
</Upload.Dragger>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
const fetchUnits = async () => {
|
||||
setLoadingUnits(true);
|
||||
try {
|
||||
const { data: units } = await supabaseService.select("resources", {
|
||||
filter: {
|
||||
type: { eq: "units" },
|
||||
"attributes->>template_type": { in: `(${type})` },
|
||||
},
|
||||
order: {
|
||||
column: "created_at",
|
||||
ascending: false,
|
||||
},
|
||||
});
|
||||
setUnits(units || []);
|
||||
} catch (error) {
|
||||
message.error("获取状态列表失败");
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoadingUnits(false);
|
||||
}
|
||||
};
|
||||
useEffect(()=>{
|
||||
fetchUnits()
|
||||
},[])
|
||||
|
||||
const addSectionFn = async (field) => {
|
||||
try {
|
||||
const sections = form.getFieldValue("sections");
|
||||
const currentSection = sections[field.name];
|
||||
|
||||
const { error } = await supabase.from("resources").insert([
|
||||
{
|
||||
type: "sections",
|
||||
attributes: {
|
||||
name: currentSection.sectionName,
|
||||
template_type: type,
|
||||
items: currentSection.items.map(item => ({
|
||||
title: item.title,
|
||||
unit: item.unit,
|
||||
url: item.url,
|
||||
description: item.description
|
||||
}))
|
||||
},
|
||||
schema_version: 1
|
||||
}
|
||||
]);
|
||||
|
||||
if (error) throw error;
|
||||
message.success("保存模板成功");
|
||||
// 刷新可用模板列表
|
||||
fetchAvailableSections();
|
||||
} catch (error) {
|
||||
message.error("保存模板失败");
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.List name="sections">
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
<div className="space-y-4 overflow-auto">
|
||||
{fields.map((field, sectionIndex) => (
|
||||
<Card
|
||||
key={field.key}
|
||||
className="shadow-sm rounded-lg min-w-[1200px]"
|
||||
type="inner"
|
||||
title={
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{editingSectionIndex === sectionIndex ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={editingSectionName}
|
||||
onChange={(e) => setEditingSectionName(e.target.value)}
|
||||
onPressEnter={handleSectionNameSave}
|
||||
autoFocus
|
||||
className="w-48"
|
||||
/>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<CheckOutlined />}
|
||||
onClick={handleSectionNameSave}
|
||||
className="text-green-500"
|
||||
/>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={() => {
|
||||
setEditingSectionIndex(null);
|
||||
setEditingSectionName("");
|
||||
}}
|
||||
className="text-red-500"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1 h-4 bg-purple-500 rounded-full" />
|
||||
<Text strong className="text-lg">
|
||||
{form.getFieldValue([
|
||||
"sections",
|
||||
sectionIndex,
|
||||
"sectionName",
|
||||
]) || `资源分组 ${sectionIndex + 1}`}
|
||||
</Text>
|
||||
{!isView && (
|
||||
<Button
|
||||
type="link"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() =>
|
||||
handleSectionNameEdit(
|
||||
sectionIndex,
|
||||
form.getFieldValue([
|
||||
"sections",
|
||||
sectionIndex,
|
||||
"sectionName",
|
||||
])
|
||||
)
|
||||
}
|
||||
className="text-gray-400 hover:text-blue-500"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isView && (
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => addSectionFn(field)}
|
||||
className="ml-2"
|
||||
>
|
||||
添加到模版
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => remove(field.name)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Form.List name={[field.name, "items"]}>
|
||||
{(itemFields, { add: addItem, remove: removeItem }) => (
|
||||
<>
|
||||
<div className="grid grid-cols-[2fr_1fr_2fr_2fr_1fr] gap-4 mb-2 text-gray-500 px-2">
|
||||
<div>资源链接</div>
|
||||
<div>资源标题</div>
|
||||
<div>资源类型</div>
|
||||
<div>资源描述</div>
|
||||
<div className="text-center">操作</div>
|
||||
</div>
|
||||
|
||||
{itemFields.map((itemField) => {
|
||||
const { key, ...restItemField } = itemField;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="grid grid-cols-[2fr_1fr_2fr_2fr_1fr] gap-4 mb-4 items-start"
|
||||
>
|
||||
<Form.Item
|
||||
{...restItemField}
|
||||
name={[itemField.name, "url"]}
|
||||
rules={[{ required: true, message: '请输入资源链接' }]}
|
||||
className="!mb-0"
|
||||
>
|
||||
<Input placeholder="资源链接" disabled={isView} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{...restItemField}
|
||||
name={[itemField.name, "title"]}
|
||||
rules={[{ required: true, message: '请输入资源标题' }]}
|
||||
className="!mb-0"
|
||||
>
|
||||
<Input placeholder="资源标题" disabled={isView} />
|
||||
</Form.Item>
|
||||
|
||||
{/* 资源类型 */}
|
||||
<Form.Item
|
||||
{...restItemField}
|
||||
name={[itemField.name, "unit"]}
|
||||
className="!mb-0"
|
||||
>
|
||||
<Select
|
||||
placeholder="资源类型"
|
||||
loading={loadingUnits}
|
||||
showSearch
|
||||
allowClear
|
||||
style={{ minWidth: "120px" }}
|
||||
options={units.map((unit) => ({
|
||||
label: unit.attributes.name,
|
||||
value: unit.attributes.name,
|
||||
}))}
|
||||
// onDropdownVisibleChange={(open) => {
|
||||
// if (open) fetchUnits();
|
||||
// }}
|
||||
dropdownRender={(menu) => (
|
||||
<>
|
||||
{menu}
|
||||
<Divider style={{ margin: "12px 0" }} />
|
||||
<div style={{ padding: "4px" }}>
|
||||
<Input.Search
|
||||
placeholder="输入新类型"
|
||||
enterButton={<PlusOutlined />}
|
||||
onSearch={async (value) => {
|
||||
if (!value.trim()) return;
|
||||
if (
|
||||
await handleAddUnit(value.trim())
|
||||
) {
|
||||
const currentItems =
|
||||
form.getFieldValue([
|
||||
"sections",
|
||||
field.name,
|
||||
"items",
|
||||
]);
|
||||
currentItems[
|
||||
itemField.name
|
||||
].unit = value.trim();
|
||||
form.setFieldValue(
|
||||
[
|
||||
"sections",
|
||||
field.name,
|
||||
"items",
|
||||
],
|
||||
currentItems
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
|
||||
|
||||
{/* 资源描述 */}
|
||||
<Form.Item
|
||||
{...restItemField}
|
||||
name={[itemField.name, "description"]}
|
||||
className="!mb-0"
|
||||
>
|
||||
<Input placeholder="资源描述" disabled={isView} />
|
||||
</Form.Item>
|
||||
|
||||
{!isView && itemFields.length > 1 && (
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => removeItem(itemField.name)}
|
||||
className="flex items-center justify-center"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{!isView && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="dashed"
|
||||
onClick={() => handleAddItem(addItem)}
|
||||
icon={<PlusOutlined />}
|
||||
className="w-full hover:border-blue-400 hover:text-blue-500"
|
||||
>
|
||||
手动添加资源
|
||||
</Button>
|
||||
<Button
|
||||
type="dashed"
|
||||
onClick={() => {
|
||||
setCurrentAddItem(() => addItem);
|
||||
setUploadModalVisible(true);
|
||||
}}
|
||||
icon={<UploadOutlined />}
|
||||
className="w-full hover:border-blue-400 hover:text-blue-500"
|
||||
>
|
||||
上传文件
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 添加资源分组按钮 */}
|
||||
<div className="flex items-center justify-center mt-6 flex-col">
|
||||
{!isView && (
|
||||
<div className="w-full flex justify-center">
|
||||
<Button
|
||||
type="dashed"
|
||||
onClick={() => {
|
||||
setTemplateModalVisible(true);
|
||||
fetchAvailableSections();
|
||||
}}
|
||||
icon={<PlusOutlined />}
|
||||
className="w-1/3 border-2 hover:border-blue-400 hover:text-blue-500"
|
||||
>
|
||||
新建资源分组
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 模板选择弹窗 */}
|
||||
<Modal
|
||||
title={<h3 className="text-lg font-medium">选择模版</h3>}
|
||||
open={templateModalVisible}
|
||||
onCancel={() => setTemplateModalVisible(false)}
|
||||
footer={null}
|
||||
width={800}
|
||||
closeIcon={<CloseOutlined className="text-gray-500" />}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{availableSections.map((section) => (
|
||||
<Card
|
||||
key={section.id}
|
||||
hoverable
|
||||
onClick={() => handleUseTemplate(section, add)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Card.Meta
|
||||
title={section.attributes.name}
|
||||
description={`${section.attributes.items?.length || 0} 个资源`}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
<Card
|
||||
hoverable
|
||||
onClick={() => handleCreateCustom(add, fields.length)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<PlusOutlined className="text-2xl" />
|
||||
<span className="ml-2">创建自定义分组</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{renderUploadModal()}
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectResourceList;
|
||||
Reference in New Issue
Block a user