专案完成

This commit is contained in:
‘Liammcl’
2025-01-04 20:09:06 +08:00
parent 9a60a688f3
commit 780e7519c1
12 changed files with 2688 additions and 63 deletions

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