专案完成
This commit is contained in:
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -92,6 +92,9 @@ importers:
|
|||||||
uuid:
|
uuid:
|
||||||
specifier: ^11.0.3
|
specifier: ^11.0.3
|
||||||
version: 11.0.3
|
version: 11.0.3
|
||||||
|
zod:
|
||||||
|
specifier: ^3.24.1
|
||||||
|
version: 3.24.1
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^18.2.15
|
specifier: ^18.2.15
|
||||||
|
|||||||
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;
|
||||||
@@ -24,14 +24,16 @@ export const useResources = (initialPagination, initialSorter, type) => {
|
|||||||
|
|
||||||
setCurrentPagination(newPagination);
|
setCurrentPagination(newPagination);
|
||||||
setCurrentSorter(newSorter);
|
setCurrentSorter(newSorter);
|
||||||
|
console.log(params.searchQuery,'params.searchQuery');
|
||||||
|
|
||||||
const { data, total: newTotal } = await resourceService.getResources({
|
const { data, total: newTotal } = await resourceService.getResources({
|
||||||
page: newPagination.current,
|
page: newPagination.current,
|
||||||
pageSize: newPagination.pageSize,
|
pageSize: newPagination.pageSize,
|
||||||
orderBy: newSorter.field,
|
orderBy: newSorter.field,
|
||||||
ascending: newSorter.order === "ascend",
|
ascending: newSorter.order === "ascend",
|
||||||
type: type,
|
type: type,
|
||||||
...(params?.search !== "" ? { searchQuery: params.search } : {}),
|
...(params?.search ? { searchQuery: params.search } : {}),
|
||||||
|
...(params?.searchQuery ? {searchQuery:params.searchQuery} : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
setResources(data || []);
|
setResources(data || []);
|
||||||
|
|||||||
@@ -1,7 +1,437 @@
|
|||||||
import React from 'react'
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { supabase } from "@/config/supabase";
|
||||||
|
import { supabaseService } from "@/hooks/supabaseService";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Button,
|
||||||
|
Space,
|
||||||
|
message,
|
||||||
|
Select,
|
||||||
|
DatePicker,
|
||||||
|
Typography,
|
||||||
|
Spin
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
ArrowLeftOutlined,
|
||||||
|
SaveOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import ProjectResourceList from '@/components/ProjectList';
|
||||||
|
const { Title } = Typography;
|
||||||
|
const { TextArea } = Input;
|
||||||
|
const TYPE = 'project';
|
||||||
|
|
||||||
export default function ProjectDetail() {
|
export default function ProjectDetail() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const isEdit = searchParams.get("edit") === "true";
|
||||||
|
const templateId = searchParams.get("templateId");
|
||||||
|
const isView = id && !isEdit;
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [customers, setCustomers] = useState([]);
|
||||||
|
const [formValues, setFormValues] = useState({});
|
||||||
|
|
||||||
|
const [tasks, setTasks] = useState([]);
|
||||||
|
const [loadingTasks, setLoadingTasks] = useState(false);
|
||||||
|
|
||||||
|
const initialValues = {
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
uniqueId: uuidv4(),
|
||||||
|
sectionName: "资源分组 1",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
uniqueId: uuidv4(),
|
||||||
|
title: "",
|
||||||
|
unit: "",
|
||||||
|
url: "",
|
||||||
|
description: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
timeRange: null,
|
||||||
|
relatedTasks: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const fetchProjectDetail = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("resources")
|
||||||
|
.select("*")
|
||||||
|
.eq("id", id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
if (data?.attributes) {
|
||||||
|
const formData = {
|
||||||
|
projectName: data.attributes.projectName,
|
||||||
|
customers: data.attributes.customers.map((customer) => customer.id) || [],
|
||||||
|
description: data.attributes.description,
|
||||||
|
sections: data.attributes.sections.map((section) => ({
|
||||||
|
uniqueId: uuidv4(),
|
||||||
|
sectionName: section.sectionName,
|
||||||
|
items: section.items.map((item) => ({
|
||||||
|
uniqueId: uuidv4(),
|
||||||
|
title: item.title,
|
||||||
|
unit: item.unit,
|
||||||
|
url: item.url,
|
||||||
|
description: item.description || "",
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
timeRange: data.attributes.startDate && data.attributes.endDate
|
||||||
|
? [dayjs(data.attributes.startDate), dayjs(data.attributes.endDate)]
|
||||||
|
: null,
|
||||||
|
relatedTasks: data.attributes.relatedTasks?.map(task => task.id) || [],
|
||||||
|
};
|
||||||
|
|
||||||
|
form.setFieldsValue(formData);
|
||||||
|
setFormValues(formData);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取项目详情失败:", error);
|
||||||
|
message.error("获取项目详情失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchTemplateData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const { data: template, error } = await supabase
|
||||||
|
.from("resources")
|
||||||
|
.select("*")
|
||||||
|
.eq("type", "projectTemplate")
|
||||||
|
.eq("id", templateId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
if (template?.attributes) {
|
||||||
|
const projectData = {
|
||||||
|
projectName: template.attributes.templateName,
|
||||||
|
description: template.attributes.description,
|
||||||
|
sections: template.attributes.sections.map((section) => ({
|
||||||
|
uniqueId: uuidv4(),
|
||||||
|
sectionName: section.sectionName,
|
||||||
|
items: section.items.map((item) => ({
|
||||||
|
uniqueId: uuidv4(),
|
||||||
|
title: item.title,
|
||||||
|
unit: item.unit,
|
||||||
|
url: item.url,
|
||||||
|
description: item.description,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
form.setFieldsValue(projectData);
|
||||||
|
setFormValues(projectData);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取模板数据失败:", error);
|
||||||
|
message.error("获取模板数据失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchCustomers = async () => {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("resources")
|
||||||
|
.select("*")
|
||||||
|
.eq("type", "customer");
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
setCustomers(data || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取客户列表失败:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchTasks = async () => {
|
||||||
|
setLoadingTasks(true);
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("resources")
|
||||||
|
.select("*")
|
||||||
|
.eq("type", "task")
|
||||||
|
.order("created_at", { ascending: false });
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
setTasks(data || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取任务列表失败:", error);
|
||||||
|
message.error("获取任务列表失败");
|
||||||
|
} finally {
|
||||||
|
setLoadingTasks(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleValuesChange = (changedValues, allValues) => {
|
||||||
|
setFormValues(allValues);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFinish = async (values) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const projectData = {
|
||||||
|
type: "project",
|
||||||
|
attributes: {
|
||||||
|
projectName: values.projectName,
|
||||||
|
customers: customers
|
||||||
|
.filter(customer => values.customers.includes(customer.id))
|
||||||
|
.map(customer => ({
|
||||||
|
id: customer.id,
|
||||||
|
name: customer.attributes.name
|
||||||
|
})),
|
||||||
|
description: values.description,
|
||||||
|
sections: values.sections.map((section) => ({
|
||||||
|
sectionName: section.sectionName,
|
||||||
|
items: section.items.map((item) => ({
|
||||||
|
title: item.title,
|
||||||
|
unit: item.unit,
|
||||||
|
url: item.url,
|
||||||
|
description: item.description,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
startDate: values.timeRange ? values.timeRange[0].format('YYYY-MM-DD') : null,
|
||||||
|
endDate: values.timeRange ? values.timeRange[1].format('YYYY-MM-DD') : null,
|
||||||
|
relatedTasks: tasks
|
||||||
|
.filter(task => values.relatedTasks?.includes(task.id))
|
||||||
|
.map(task => ({
|
||||||
|
id: task.id,
|
||||||
|
name: task.attributes.taskName
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let result;
|
||||||
|
if (id) {
|
||||||
|
result = await supabase
|
||||||
|
.from("resources")
|
||||||
|
.update(projectData)
|
||||||
|
.eq("id", id)
|
||||||
|
.select();
|
||||||
|
} else {
|
||||||
|
result = await supabase
|
||||||
|
.from("resources")
|
||||||
|
.insert([projectData])
|
||||||
|
.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
|
||||||
|
message.success("保存成功");
|
||||||
|
navigate("/company/project");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("保存失败:", error);
|
||||||
|
message.error("保存失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCustomers();
|
||||||
|
fetchTasks();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) {
|
||||||
|
fetchProjectDetail();
|
||||||
|
} else if (templateId) {
|
||||||
|
fetchTemplateData();
|
||||||
|
} else {
|
||||||
|
form.setFieldsValue(initialValues);
|
||||||
|
setFormValues(initialValues);
|
||||||
|
}
|
||||||
|
}, [id, templateId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>ProjectDetail</div>
|
<div className="bg-gradient-to-b from-gray-50 to-white dark:from-gray-800 dark:to-gray-900/90 min-h-screen p-2">
|
||||||
)
|
<Spin spinning={loading}>
|
||||||
|
<Card
|
||||||
|
className="shadow-lg rounded-lg border-0"
|
||||||
|
title={
|
||||||
|
<div className="flex justify-between items-center py-2">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<Title level={4} className="mb-0 text-gray-800">
|
||||||
|
{id ? (isEdit ? "编辑专案" : "查看专案") : "新建专案"}
|
||||||
|
</Title>
|
||||||
|
<span className="text-gray-400 text-sm">
|
||||||
|
{id
|
||||||
|
? isEdit
|
||||||
|
? "请修改专案信息"
|
||||||
|
: "专案详情"
|
||||||
|
: "请填写专案信息"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Space size="middle">
|
||||||
|
<Button
|
||||||
|
icon={<ArrowLeftOutlined />}
|
||||||
|
onClick={() => navigate("/company/project")}
|
||||||
|
>
|
||||||
|
返回
|
||||||
|
</Button>
|
||||||
|
{!isView && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SaveOutlined />}
|
||||||
|
onClick={() => form.submit()}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
onFinish={onFinish}
|
||||||
|
onValuesChange={handleValuesChange}
|
||||||
|
layout="vertical"
|
||||||
|
disabled={isView}
|
||||||
|
initialValues={initialValues}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
className="shadow-sm rounded-lg"
|
||||||
|
type="inner"
|
||||||
|
title={
|
||||||
|
<span className="flex items-center space-x-2 text-gray-700">
|
||||||
|
<span className="w-1 h-4 bg-blue-500 rounded-full" />
|
||||||
|
<span>基本信息</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
bordered={false}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-8">
|
||||||
|
<Form.Item
|
||||||
|
name="projectName"
|
||||||
|
label={<span className="text-gray-700 font-medium">专案名称</span>}
|
||||||
|
rules={[{ required: true, message: "请输入专案名称" }]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="请输入专案名称"
|
||||||
|
className="hover:border-blue-400 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="customers"
|
||||||
|
label={<span className="text-gray-700 font-medium">客户名称</span>}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
placeholder="请选择客户"
|
||||||
|
className="hover:border-blue-400 focus:border-blue-500"
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="children"
|
||||||
|
filterOption={(input, option) =>
|
||||||
|
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
|
||||||
|
}
|
||||||
|
options={customers.map((customer) => ({
|
||||||
|
value: customer.id,
|
||||||
|
label: customer.attributes.name,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="timeRange"
|
||||||
|
label={<span className="text-gray-700 font-medium">时间范围</span>}
|
||||||
|
>
|
||||||
|
<DatePicker.RangePicker
|
||||||
|
className="w-full hover:border-blue-400 focus:border-blue-500"
|
||||||
|
format="YYYY-MM-DD"
|
||||||
|
onChange={(dates) => {
|
||||||
|
if (dates) {
|
||||||
|
form.setFieldValue('timeRange', [
|
||||||
|
dayjs(dates[0]),
|
||||||
|
dayjs(dates[1])
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
form.setFieldValue('timeRange', null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="relatedTasks"
|
||||||
|
label={<span className="text-gray-700 font-medium">关联任务</span>}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
placeholder="请选择关联任务"
|
||||||
|
className="hover:border-blue-400 focus:border-blue-500"
|
||||||
|
loading={loadingTasks}
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="children"
|
||||||
|
filterOption={(input, option) =>
|
||||||
|
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
|
||||||
|
}
|
||||||
|
options={tasks.map((task) => ({
|
||||||
|
value: task.id,
|
||||||
|
label: task.attributes.taskName,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="description"
|
||||||
|
label={<span className="text-gray-700 font-medium">专案描述</span>}
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
rows={4}
|
||||||
|
placeholder="请输入专案描述"
|
||||||
|
className="hover:border-blue-400 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
className="shadow-sm rounded-lg mt-6"
|
||||||
|
type="inner"
|
||||||
|
title={
|
||||||
|
<span className="flex items-center space-x-2 text-gray-700">
|
||||||
|
<span className="w-1 h-4 bg-blue-500 rounded-full" />
|
||||||
|
<span>专案资源</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
bordered={false}
|
||||||
|
>
|
||||||
|
<ProjectResourceList
|
||||||
|
type={TYPE}
|
||||||
|
form={form}
|
||||||
|
isView={isView}
|
||||||
|
formValues={formValues}
|
||||||
|
onValuesChange={handleValuesChange}
|
||||||
|
/>
|
||||||
|
{/* <TaskList type={TYPE}
|
||||||
|
form={form}
|
||||||
|
isView={isView}
|
||||||
|
formValues={formValues}
|
||||||
|
onValuesChange={handleValuesChange} /> */}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</Spin>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,331 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Card, Table, Button } from 'antd';
|
import { Card, Table, Button, message, Popconfirm, Tag, Space, Select } from 'antd';
|
||||||
import { PlusOutlined } from '@ant-design/icons';
|
import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons';
|
||||||
|
import { useResources } from "@/hooks/resource/useResource";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { supabase } from "@/config/supabase";
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
const ProjectPage = () => {
|
const ProjectPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [pagination, setPagination] = useState({ current: 1, pageSize: 10 });
|
||||||
|
const [sorter, setSorter] = useState({
|
||||||
|
field: "created_at",
|
||||||
|
order: "descend",
|
||||||
|
});
|
||||||
|
const [customers, setCustomers] = useState([]);
|
||||||
|
const [loadingCustomers, setLoadingCustomers] = useState(false);
|
||||||
|
const [selectedCustomer, setSelectedCustomer] = useState(null);
|
||||||
|
const [tasks, setTasks] = useState([]);
|
||||||
|
const [loadingTasks, setLoadingTasks] = useState(false);
|
||||||
|
const [selectedTask, setSelectedTask] = useState(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
resources: projects,
|
||||||
|
loading: loadingProjects,
|
||||||
|
total,
|
||||||
|
fetchResources: fetchProjects,
|
||||||
|
deleteResource: deleteProject,
|
||||||
|
} = useResources(pagination, sorter, "project");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProjects();
|
||||||
|
fetchCustomers();
|
||||||
|
fetchTasks();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchCustomers = async () => {
|
||||||
|
setLoadingCustomers(true);
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('resources')
|
||||||
|
.select('*')
|
||||||
|
.eq('type', 'customer')
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
setCustomers(data || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取客户列表失败:', error);
|
||||||
|
message.error('获取客户列表失败');
|
||||||
|
} finally {
|
||||||
|
setLoadingCustomers(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchTasks = async () => {
|
||||||
|
setLoadingTasks(true);
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('resources')
|
||||||
|
.select('*')
|
||||||
|
.eq('type', 'task')
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
setTasks(data || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取任务列表失败:', error);
|
||||||
|
message.error('获取任务列表失败');
|
||||||
|
} finally {
|
||||||
|
setLoadingTasks(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: '项目名称',
|
title: "专案名称",
|
||||||
dataIndex: 'name',
|
dataIndex: ["attributes", "projectName"],
|
||||||
key: 'name',
|
key: "projectName",
|
||||||
|
ellipsis: true,
|
||||||
|
className: "dark:text-gray-200",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '负责人',
|
title: "客户",
|
||||||
dataIndex: 'manager',
|
dataIndex: ["attributes", "customers"],
|
||||||
key: 'manager',
|
key: "customers",
|
||||||
|
render: (customers) => (
|
||||||
|
<Space size={4}>
|
||||||
|
{customers?.map(customer => (
|
||||||
|
<Tag
|
||||||
|
key={customer.id}
|
||||||
|
color="blue"
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
{customer.name}
|
||||||
|
</Tag>
|
||||||
|
)) || '-'}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '开始日期',
|
title: "相关任务",
|
||||||
dataIndex: 'startDate',
|
dataIndex: ["attributes", "relatedTasks"],
|
||||||
key: 'startDate',
|
key: "relatedTasks",
|
||||||
|
render: (tasks) => (
|
||||||
|
<Space size={4} wrap>
|
||||||
|
{tasks?.map(task => (
|
||||||
|
<Tag
|
||||||
|
key={task.id}
|
||||||
|
color="green"
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
{task.name}
|
||||||
|
</Tag>
|
||||||
|
)) || '-'}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: "项目周期",
|
||||||
dataIndex: 'status',
|
key: "projectPeriod",
|
||||||
key: 'status',
|
render: (_, record) => {
|
||||||
|
const startDate = record.attributes.startDate;
|
||||||
|
const endDate = record.attributes.endDate;
|
||||||
|
if (!startDate || !endDate) return '-';
|
||||||
|
|
||||||
|
const start = dayjs(startDate);
|
||||||
|
const end = dayjs(endDate);
|
||||||
|
const days = end.diff(start, 'day');
|
||||||
|
const months = end.diff(start, 'month', true).toFixed(1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="dark:text-gray-300">
|
||||||
|
{days > 60
|
||||||
|
? `约 ${months} 个月`
|
||||||
|
: `${days} 天`}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "开始日期",
|
||||||
|
dataIndex: ["attributes", "startDate"],
|
||||||
|
key: "startDate",
|
||||||
|
render: (date) => (
|
||||||
|
<span className="dark:text-gray-300">
|
||||||
|
{date || '-'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
title: "创建时间",
|
||||||
|
dataIndex: "created_at",
|
||||||
|
key: "created_at",
|
||||||
|
sorter: true,
|
||||||
|
render: (text) => (
|
||||||
|
<span className="dark:text-gray-300">
|
||||||
|
{dayjs(text).format('YYYY-MM-DD HH:mm')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
key: "action",
|
||||||
|
fixed: "right",
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space size={0} className="dark:text-gray-300">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="link"
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
onClick={() => navigate(`/company/projectView/${record.id}`)}
|
||||||
|
className="dark:text-gray-300 dark:hover:text-blue-400"
|
||||||
|
>
|
||||||
|
查看
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="link"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => navigate(`/company/projectInfo/${record.id}?edit=true`)}
|
||||||
|
className="dark:text-gray-300 dark:hover:text-blue-400"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定要删除这个专案吗?"
|
||||||
|
description="删除后将无法恢复!"
|
||||||
|
onConfirm={() => handleDelete(record.id)}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="link"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
className="dark:hover:text-red-400"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
try {
|
||||||
|
await deleteProject(id);
|
||||||
|
fetchProjects();
|
||||||
|
message.success('删除成功');
|
||||||
|
} catch (error) {
|
||||||
|
message.error("删除失败:" + error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTableChange = (pagination, filters, sorter) => {
|
||||||
|
setPagination(pagination);
|
||||||
|
setSorter({
|
||||||
|
field: sorter.field || "created_at",
|
||||||
|
order: sorter.order || "descend",
|
||||||
|
});
|
||||||
|
fetchProjects({
|
||||||
|
current: pagination.current,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
field: sorter.field,
|
||||||
|
order: sorter.order,
|
||||||
|
search: JSON.stringify({
|
||||||
|
...(selectedCustomer && { customerId: selectedCustomer }),
|
||||||
|
...(selectedTask && { taskId: selectedTask })
|
||||||
|
})
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
title="专案管理"
|
title={
|
||||||
|
<Space className="dark:text-gray-200 py-4 px-2">
|
||||||
|
<span>专案管理</span>
|
||||||
|
<Tag color="blue" className="dark:border-opacity-50">
|
||||||
|
{total} 个专案
|
||||||
|
</Tag>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
className="h-full w-full overflow-auto "
|
||||||
extra={
|
extra={
|
||||||
<Button type="primary" icon={<PlusOutlined />}>
|
<Space>
|
||||||
新增专案
|
<Select
|
||||||
</Button>
|
allowClear
|
||||||
|
loading={loadingCustomers}
|
||||||
|
placeholder="按客户筛选"
|
||||||
|
variant="filled"
|
||||||
|
style={{ width: 200 }}
|
||||||
|
options={customers.map(c => ({
|
||||||
|
label: c.attributes.name,
|
||||||
|
value: c.id
|
||||||
|
}))}
|
||||||
|
onChange={(value) => {
|
||||||
|
setSelectedCustomer(value);
|
||||||
|
setPagination({ current: 1, pageSize: pagination.pageSize });
|
||||||
|
fetchProjects({
|
||||||
|
current: 1,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
field: sorter.field,
|
||||||
|
order: sorter.order,
|
||||||
|
searchQuery: JSON.stringify({
|
||||||
|
...(value && { customerId: value }),
|
||||||
|
...(selectedTask && { taskId: selectedTask })
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* <Select
|
||||||
|
allowClear
|
||||||
|
loading={loadingTasks}
|
||||||
|
placeholder="按任务筛选"
|
||||||
|
variant="filled"
|
||||||
|
style={{ width: 200 }}
|
||||||
|
options={tasks.map(t => ({
|
||||||
|
label: t.attributes.taskName,
|
||||||
|
value: t.id
|
||||||
|
}))}
|
||||||
|
onChange={(value) => {
|
||||||
|
|
||||||
|
setSelectedTask(value);
|
||||||
|
setPagination({ current: 1, pageSize: pagination.pageSize });
|
||||||
|
fetchProjects({
|
||||||
|
current: 1,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
field: sorter.field,
|
||||||
|
order: sorter.order,
|
||||||
|
searchQuery: JSON.stringify({
|
||||||
|
...(selectedCustomer && { customerId: selectedCustomer }),
|
||||||
|
...(value && { taskId: value })
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/> */}
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => navigate('/company/projectInfo')}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
新增专案
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Table columns={columns} dataSource={[]} rowKey="id" />
|
<Table
|
||||||
|
className="w-full"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={projects}
|
||||||
|
rowKey="id"
|
||||||
|
scroll={{ x: true }}
|
||||||
|
loading={loadingProjects}
|
||||||
|
onChange={handleTableChange}
|
||||||
|
pagination={{
|
||||||
|
...pagination,
|
||||||
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showQuickJumper: true,
|
||||||
|
showTotal: (total) => `共 ${total} 条记录`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
758
src/pages/company/project/info.jsx
Normal file
758
src/pages/company/project/info.jsx
Normal file
@@ -0,0 +1,758 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { supabase } from "@/config/supabase";
|
||||||
|
import { Spin, Tag, Empty, Button, Modal, Form, Input, Collapse, message, Upload,Tabs, Select, Divider } from 'antd';
|
||||||
|
import { PlusOutlined, FolderOpenOutlined, ClockCircleOutlined, InboxOutlined } from '@ant-design/icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import {supabaseService}from '@/hooks/supabaseService'
|
||||||
|
const type="project"
|
||||||
|
export default function ProjectInfo() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [projectData, setProjectData] = useState(null);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||||
|
const [units, setUnits] = useState([]);
|
||||||
|
const [loadingUnits, setLoadingUnits] = useState(false);
|
||||||
|
const [currentUnit, setCurrentUnit] = useState('');
|
||||||
|
const [relatedTasks, setRelatedTasks] = useState([]);
|
||||||
|
const [loadingTasks, setLoadingTasks] = useState(false);
|
||||||
|
const [uploadFileList, setUploadFileList] = useState([]);
|
||||||
|
|
||||||
|
const fetchProjectDetail = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("resources")
|
||||||
|
.select("*")
|
||||||
|
.eq("id", id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
setProjectData(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取项目详情失败:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) {
|
||||||
|
fetchProjectDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const handleAddItem = async (values) => {
|
||||||
|
try {
|
||||||
|
const newItem = {
|
||||||
|
url: values.url,
|
||||||
|
unit: currentUnit,
|
||||||
|
title: values.title,
|
||||||
|
description: values.description,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedSections = projectData.attributes.sections.map(section => {
|
||||||
|
if (section.sectionName === currentUnit) {
|
||||||
|
return {
|
||||||
|
...section,
|
||||||
|
items: [...(section.items || []), newItem]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return section;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("resources")
|
||||||
|
.update({
|
||||||
|
attributes: {
|
||||||
|
...projectData.attributes,
|
||||||
|
sections: updatedSections
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.eq("id", id);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
setProjectData({
|
||||||
|
...projectData,
|
||||||
|
attributes: {
|
||||||
|
...projectData.attributes,
|
||||||
|
sections: updatedSections
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
message.success('添加成功');
|
||||||
|
setIsModalVisible(false);
|
||||||
|
form.resetFields();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("添加资源项失败:", error);
|
||||||
|
message.error('添加失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddUnit = async (unitName) => {
|
||||||
|
try {
|
||||||
|
const { error } = await supabase.from("resources").insert([
|
||||||
|
{
|
||||||
|
type: "units",
|
||||||
|
attributes: {
|
||||||
|
name: unitName,
|
||||||
|
template_type: "project",
|
||||||
|
},
|
||||||
|
schema_version: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
message.success("新增资源类型成功");
|
||||||
|
fetchUnits();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
message.error("新增资源类型失败");
|
||||||
|
console.error(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUnits();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getDomainFromUrl = (url) => {
|
||||||
|
try {
|
||||||
|
const domain = new URL(url).hostname;
|
||||||
|
return domain.replace('www.', '');
|
||||||
|
} catch (error) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理资源分组
|
||||||
|
const getResourcesByUnit = () => {
|
||||||
|
const resourcesByUnit = {};
|
||||||
|
|
||||||
|
// 确保 sections 存在且是数组
|
||||||
|
if (projectData?.attributes?.sections && Array.isArray(projectData.attributes.sections)) {
|
||||||
|
projectData.attributes.sections.forEach(section => {
|
||||||
|
// 确保 items 存在且是数组
|
||||||
|
if (section.items && Array.isArray(section.items)) {
|
||||||
|
// 使用 sectionName 作为 unit
|
||||||
|
const unit = section.sectionName;
|
||||||
|
if (!resourcesByUnit[unit]) {
|
||||||
|
resourcesByUnit[unit] = [];
|
||||||
|
}
|
||||||
|
// 将该 section 的所有 items 添加到对应的 unit 中
|
||||||
|
resourcesByUnit[unit].push(...section.items);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return resourcesByUnit;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 在渲染之前获取处理后的资源
|
||||||
|
const resourcesByUnit = getResourcesByUnit();
|
||||||
|
|
||||||
|
// 添加一个图标映射函数
|
||||||
|
const getResourceIcon = (url) => {
|
||||||
|
const domain = getDomainFromUrl(url).toLowerCase();
|
||||||
|
if (domain.includes('figma')) return '🎨';
|
||||||
|
if (domain.includes('github')) return '💻';
|
||||||
|
if (domain.includes('notion')) return '📝';
|
||||||
|
if (domain.includes('docs.google')) return '📄';
|
||||||
|
if (domain.includes('sheets.google')) return '📊';
|
||||||
|
if (domain.includes('youtube')) return '🎥';
|
||||||
|
if (domain.includes('drive.google')) return '📁';
|
||||||
|
return '🔗';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取关联任务的详细信息
|
||||||
|
const fetchRelatedTasks = async () => {
|
||||||
|
if (!projectData?.attributes?.relatedTasks?.length) return;
|
||||||
|
|
||||||
|
setLoadingTasks(true);
|
||||||
|
try {
|
||||||
|
const taskIds = projectData.attributes.relatedTasks.map(task => task.id);
|
||||||
|
const { data: tasks, error } = await supabase
|
||||||
|
.from("resources")
|
||||||
|
.select("*")
|
||||||
|
.eq("type", "task")
|
||||||
|
.in("id", taskIds);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
setRelatedTasks(tasks || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取关联任务失败:", error);
|
||||||
|
message.error("获取关联任务失败");
|
||||||
|
} finally {
|
||||||
|
setLoadingTasks(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (projectData) {
|
||||||
|
fetchRelatedTasks();
|
||||||
|
}
|
||||||
|
}, [projectData]);
|
||||||
|
|
||||||
|
// 渲染任务详情
|
||||||
|
const renderTaskDetails = () => {
|
||||||
|
if (loadingTasks) {
|
||||||
|
return <Spin />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!relatedTasks.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lg:col-span-3 mb-6">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white flex items-center mb-6">
|
||||||
|
<div className="w-1 h-6 bg-purple-500 rounded-full mr-3 shadow-lg shadow-purple-500/50"></div>
|
||||||
|
关联任务
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{relatedTasks.map((task) => (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className="border border-gray-100 dark:border-gray-700 rounded-xl p-6
|
||||||
|
hover:border-purple-100 dark:hover:border-purple-900/50
|
||||||
|
transition-all duration-300"
|
||||||
|
>
|
||||||
|
{/* 任务标题和状态 */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
|
||||||
|
{task.attributes.taskName}
|
||||||
|
</h3>
|
||||||
|
<Tag
|
||||||
|
className={`px-3 py-1 rounded-full
|
||||||
|
${task.attributes.status === '进行中' ? 'bg-blue-50 text-blue-600 border-blue-200' :
|
||||||
|
task.attributes.status === '已完成' ? 'bg-green-50 text-green-600 border-green-200' :
|
||||||
|
'bg-gray-50 text-gray-600 border-gray-200'}`}
|
||||||
|
>
|
||||||
|
{task.attributes.status}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 任务时间和客户 */}
|
||||||
|
<div className="flex items-center gap-6 mb-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<ClockCircleOutlined />
|
||||||
|
{task.attributes.timeRange ?
|
||||||
|
`${dayjs(task.attributes.timeRange[0]).format('YYYY-MM-DD')} 至 ${dayjs(task.attributes.timeRange[1]).format('YYYY-MM-DD')}` :
|
||||||
|
'未设置时间'
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
{/* <div className="flex items-center gap-2">
|
||||||
|
<span>客户:</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{task.attributes.customers?.map((customer) => (
|
||||||
|
<Tag
|
||||||
|
key={customer.id}
|
||||||
|
className="bg-purple-50 text-purple-600 border-purple-200 dark:bg-purple-900/30 dark:text-purple-300"
|
||||||
|
>
|
||||||
|
{customer.name}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 任务流程 */}
|
||||||
|
<Collapse
|
||||||
|
ghost
|
||||||
|
className="bg-transparent border-none"
|
||||||
|
>
|
||||||
|
<Collapse.Panel
|
||||||
|
header="任务流程"
|
||||||
|
key="1"
|
||||||
|
className="bg-gray-50/50 dark:bg-gray-800/50 rounded-xl px-4"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{task.attributes.sections?.map((section, sIndex) => (
|
||||||
|
<div key={sIndex} className="space-y-2">
|
||||||
|
<h4 className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{section.sectionName}
|
||||||
|
</h4>
|
||||||
|
<div className="pl-4 space-y-2">
|
||||||
|
{section.items?.map((item, iIndex) => (
|
||||||
|
<div
|
||||||
|
key={iIndex}
|
||||||
|
className="flex items-start gap-4 p-3 rounded-lg
|
||||||
|
bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700"
|
||||||
|
>
|
||||||
|
<div className="flex-grow">
|
||||||
|
<div className="text-gray-900 dark:text-white font-medium">
|
||||||
|
{item.name}
|
||||||
|
</div>
|
||||||
|
{item.description && (
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{item.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Tag
|
||||||
|
className={`flex-shrink-0
|
||||||
|
${item.unit === '已完成' ? 'bg-green-50 text-green-600 border-green-200' :
|
||||||
|
item.unit === '进行中' ? 'bg-blue-50 text-blue-600 border-blue-200' :
|
||||||
|
'bg-gray-50 text-gray-600 border-gray-200'}`}
|
||||||
|
>
|
||||||
|
{item.unit}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Collapse.Panel>
|
||||||
|
</Collapse>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 修改文件上传处理函数
|
||||||
|
const handleFileUpload = async (file) => {
|
||||||
|
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 = '其他文件';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动填充表单
|
||||||
|
form.setFieldsValue({
|
||||||
|
url: publicUrl,
|
||||||
|
title: `${fileType}-${file.name}`,
|
||||||
|
unit: fileType,
|
||||||
|
description: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('文件上传失败:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 修改模态框内容
|
||||||
|
const renderModal = () => (
|
||||||
|
<Modal
|
||||||
|
title="添加资源"
|
||||||
|
open={isModalVisible}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsModalVisible(false);
|
||||||
|
form.resetFields();
|
||||||
|
}}
|
||||||
|
destroyOnClose={true}
|
||||||
|
footer={null}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={handleAddItem}
|
||||||
|
className="mt-4"
|
||||||
|
preserve={false}
|
||||||
|
>
|
||||||
|
<Upload.Dragger
|
||||||
|
multiple={false}
|
||||||
|
maxCount={1}
|
||||||
|
customRequest={async ({ file, onSuccess, onError }) => {
|
||||||
|
try {
|
||||||
|
const result = await handleFileUpload(file);
|
||||||
|
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} 上传失败`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onRemove={() => {
|
||||||
|
form.resetFields();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="ant-upload-drag-icon">
|
||||||
|
<InboxOutlined />
|
||||||
|
</p>
|
||||||
|
<p className="ant-upload-text">点击或拖拽文件到此区域上传</p>
|
||||||
|
<p className="ant-upload-hint">上传文件后可修改相关信息</p>
|
||||||
|
</Upload.Dragger>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="url"
|
||||||
|
label="资源链接"
|
||||||
|
rules={[{ required: true, message: '请输入资源链接' }]}
|
||||||
|
className="mt-4"
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入资源链接" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="title"
|
||||||
|
label="资源标题"
|
||||||
|
rules={[{ required: true, message: '请输入资源标题' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入资源标题" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="unit"
|
||||||
|
label="资源类型"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder="请选择资源类型"
|
||||||
|
loading={loadingUnits}
|
||||||
|
showSearch
|
||||||
|
allowClear
|
||||||
|
options={units.map((unit) => ({
|
||||||
|
label: unit.attributes.name,
|
||||||
|
value: unit.attributes.name,
|
||||||
|
}))}
|
||||||
|
dropdownRender={(menu) => (
|
||||||
|
<>
|
||||||
|
{menu}
|
||||||
|
<Divider style={{ margin: '8px 0' }} />
|
||||||
|
<div style={{ padding: '4px' }}>
|
||||||
|
<Input.Search
|
||||||
|
placeholder="输入新类型"
|
||||||
|
enterButton={<PlusOutlined />}
|
||||||
|
onSearch={async (value) => {
|
||||||
|
if (!value.trim()) return;
|
||||||
|
if (await handleAddUnit(value.trim())) {
|
||||||
|
form.setFieldValue('unit', value.trim());
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="description"
|
||||||
|
label="资源描述"
|
||||||
|
>
|
||||||
|
<Input.TextArea rows={4} placeholder="请输入资源描述" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item className="mb-0 flex justify-end gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setIsModalVisible(false);
|
||||||
|
form.resetFields();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
确定
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<Spin tip="加载中..." />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!projectData) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<Empty description="未找到项目信息" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { attributes } = projectData;
|
||||||
|
|
||||||
|
// 打开所有资源的函数
|
||||||
|
const openAllResources = (items) => {
|
||||||
|
items.forEach(item => {
|
||||||
|
if (item.url) {
|
||||||
|
window.open(item.url, '_blank');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50/50 dark:bg-gray-900/95">
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
{renderTaskDetails()}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div className="p-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">
|
||||||
|
{attributes.projectName}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-3">
|
||||||
|
项目描述
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-700 dark:text-gray-300 leading-relaxed">
|
||||||
|
{attributes.description || '暂无描述'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-3">
|
||||||
|
项目周期
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-700 dark:text-gray-300">
|
||||||
|
{attributes.startDate && attributes.endDate ? (
|
||||||
|
<span className="inline-flex items-center px-3 py-1 rounded-full bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-300">
|
||||||
|
{dayjs(attributes.startDate).format('YYYY-MM-DD')} 至 {dayjs(attributes.endDate).format('YYYY-MM-DD')}
|
||||||
|
</span>
|
||||||
|
) : '未设置'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-3">
|
||||||
|
相关客户
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{attributes.customers?.map((customer) => (
|
||||||
|
<Tag
|
||||||
|
key={customer.id}
|
||||||
|
className="px-3 py-1 rounded-full bg-purple-50 text-purple-600 border-purple-200
|
||||||
|
dark:bg-purple-900/30 dark:text-purple-300 dark:border-purple-800"
|
||||||
|
>
|
||||||
|
{customer.name}
|
||||||
|
</Tag>
|
||||||
|
)) || '暂无客户'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-3">
|
||||||
|
关联任务
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{attributes.relatedTasks?.map((task) => (
|
||||||
|
<Tag
|
||||||
|
key={task.id}
|
||||||
|
className="px-3 py-1 rounded-full bg-green-50 text-green-600 border-green-200
|
||||||
|
dark:bg-green-900/30 dark:text-green-300 dark:border-green-800"
|
||||||
|
>
|
||||||
|
{task.name}
|
||||||
|
</Tag>
|
||||||
|
)) || '暂无任务'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{Object.entries(resourcesByUnit).map(([unit, items]) => (
|
||||||
|
<div
|
||||||
|
key={unit}
|
||||||
|
className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700 overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<div className="w-1 h-6 bg-blue-500 rounded-full mr-3 shadow-lg shadow-blue-500/50"></div>
|
||||||
|
{unit}
|
||||||
|
</h2>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{items.length > 0 && (
|
||||||
|
<Button
|
||||||
|
type="default"
|
||||||
|
icon={<FolderOpenOutlined />}
|
||||||
|
onClick={() => openAllResources(items)}
|
||||||
|
className="flex items-center hover:text-blue-600 hover:border-blue-600 transition-colors
|
||||||
|
dark:hover:text-blue-400 dark:hover:border-blue-400"
|
||||||
|
>
|
||||||
|
打开全部
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentUnit(unit);
|
||||||
|
setIsModalVisible(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center bg-blue-500 hover:bg-blue-600 border-none shadow-lg
|
||||||
|
shadow-blue-500/30 transition-all hover:shadow-blue-500/50"
|
||||||
|
>
|
||||||
|
添加资源
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length > 0 ? (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{items.map((item, itemIndex) => (
|
||||||
|
<div
|
||||||
|
key={itemIndex}
|
||||||
|
className="group relative p-4 rounded-xl border border-gray-100 dark:border-gray-700
|
||||||
|
hover:border-blue-100 dark:hover:border-blue-900/50
|
||||||
|
hover:bg-gradient-to-r hover:from-blue-50 hover:to-transparent
|
||||||
|
dark:hover:from-blue-900/20 dark:hover:to-transparent
|
||||||
|
transition-all duration-300 bg-white dark:bg-gray-800/50"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
{/* 资源图标 */}
|
||||||
|
<div className="flex-shrink-0 w-10 h-10 flex items-center justify-center
|
||||||
|
bg-gradient-to-br from-blue-50 to-blue-100
|
||||||
|
dark:from-blue-900/30 dark:to-blue-800/30
|
||||||
|
rounded-lg text-xl shadow-sm">
|
||||||
|
{item.url && getResourceIcon(item.url)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 资源内容 */}
|
||||||
|
<div className="flex-grow min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white truncate">
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
{item.url && (
|
||||||
|
<a
|
||||||
|
href={item.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center text-blue-500 hover:text-blue-600 text-sm
|
||||||
|
hover:underline gap-1 transition-colors mt-1"
|
||||||
|
>
|
||||||
|
{getDomainFromUrl(item.url)}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 快捷操作按钮 */}
|
||||||
|
<div className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{item.url && (
|
||||||
|
<a
|
||||||
|
href={item.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center px-3 py-1 text-sm
|
||||||
|
bg-blue-500 hover:bg-blue-600 text-white rounded-full
|
||||||
|
shadow-sm hover:shadow-md transition-all duration-200"
|
||||||
|
>
|
||||||
|
打开链接
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 资源描述 */}
|
||||||
|
{item.description && (
|
||||||
|
<div className="mt-2 relative">
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 text-sm leading-relaxed
|
||||||
|
line-clamp-2 group-hover:line-clamp-none transition-all duration-200">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
<div className="absolute inset-x-0 bottom-0 h-4 bg-gradient-to-t from-white dark:from-gray-800/50
|
||||||
|
to-transparent group-hover:opacity-0 transition-opacity"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12 px-4">
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full
|
||||||
|
bg-gray-100 dark:bg-gray-800 mb-4">
|
||||||
|
<span className="text-2xl">📂</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||||
|
暂无资源
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
点击"添加资源"按钮开始添加资源
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{renderModal()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -353,6 +353,7 @@ useEffect(()=>{
|
|||||||
<Space>
|
<Space>
|
||||||
<Select
|
<Select
|
||||||
allowClear
|
allowClear
|
||||||
|
variant="filled"
|
||||||
loading={loadingCustomers}
|
loading={loadingCustomers}
|
||||||
placeholder="按客户筛选"
|
placeholder="按客户筛选"
|
||||||
style={{ width: 200 }}
|
style={{ width: 200 }}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import QuataSections from "./quotation";
|
import QuataSections from "./quotation";
|
||||||
import TaskSections from "./task";
|
import TaskSections from "./task";
|
||||||
|
import Project from './project'
|
||||||
export default function SectionComponent({ activeType }) {
|
export default function SectionComponent({ activeType }) {
|
||||||
const renderFn = (type) => {
|
const renderFn = (type) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -10,7 +10,7 @@ export default function SectionComponent({ activeType }) {
|
|||||||
case "task":
|
case "task":
|
||||||
return <TaskSections />;
|
return <TaskSections />;
|
||||||
default:
|
default:
|
||||||
return <div></div>;
|
return <Project></Project>;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return <>{renderFn(activeType)}</>;
|
return <>{renderFn(activeType)}</>;
|
||||||
|
|||||||
544
src/pages/company/service/itemsManange/sections/project.jsx
Normal file
544
src/pages/company/service/itemsManange/sections/project.jsx
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Space,
|
||||||
|
message,
|
||||||
|
Popconfirm,
|
||||||
|
Select,
|
||||||
|
Divider,
|
||||||
|
Card,
|
||||||
|
Typography,
|
||||||
|
Upload,
|
||||||
|
Modal
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
InboxOutlined,
|
||||||
|
UploadOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { supabaseService } from '@/hooks/supabaseService';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { supabase } from "@/config/supabase";
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
const TYPE = 'project';
|
||||||
|
|
||||||
|
const ProjectSections = () => {
|
||||||
|
const [data, setData] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [editingKey, setEditingKey] = useState('');
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [loadingUnits, setLoadingUnits] = useState(false);
|
||||||
|
const [units, setUnits] = useState([]);
|
||||||
|
const [formValues, setFormValues] = useState({});
|
||||||
|
const [uploadModalVisible, setUploadModalVisible] = useState(false);
|
||||||
|
const [currentAddItem, setCurrentAddItem] = useState(null);
|
||||||
|
|
||||||
|
const fetchSections = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data: sections } = await supabaseService.select('resources', {
|
||||||
|
filter: {
|
||||||
|
'type': { eq: 'sections' },
|
||||||
|
'attributes->>template_type': { eq: TYPE }
|
||||||
|
},
|
||||||
|
order: {
|
||||||
|
column: 'created_at',
|
||||||
|
ascending: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setData(sections || []);
|
||||||
|
} catch (error) {
|
||||||
|
message.error('获取模块数据失败');
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
fetchSections();
|
||||||
|
fetchUnits();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
const newData = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
attributes: {
|
||||||
|
name: '',
|
||||||
|
template_type: TYPE,
|
||||||
|
items: [{
|
||||||
|
key: uuidv4(),
|
||||||
|
url: '',
|
||||||
|
title: '',
|
||||||
|
unit: '',
|
||||||
|
description: ''
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
isNew: true
|
||||||
|
};
|
||||||
|
setData([newData, ...data]);
|
||||||
|
setEditingKey(newData.id);
|
||||||
|
form.setFieldsValue(newData.attributes);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (record) => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
const items = form.getFieldValue(['items']) || [];
|
||||||
|
|
||||||
|
if (!items.length || !items.some(item => item.title)) {
|
||||||
|
message.error('请至少添加一个有效的资源项目');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedItems = items.filter(item => item.title).map(item => ({
|
||||||
|
url: item.url,
|
||||||
|
title: item.title,
|
||||||
|
unit: item.unit,
|
||||||
|
description: item.description
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (record.isNew) {
|
||||||
|
await supabaseService.insert('resources', {
|
||||||
|
type: 'sections',
|
||||||
|
attributes: {
|
||||||
|
name: values.name,
|
||||||
|
template_type: TYPE,
|
||||||
|
items: formattedItems,
|
||||||
|
},
|
||||||
|
schema_version: 1
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await supabaseService.update('resources',
|
||||||
|
{ id: record.id },
|
||||||
|
{
|
||||||
|
attributes: {
|
||||||
|
name: values.name,
|
||||||
|
template_type: TYPE,
|
||||||
|
items: formattedItems,
|
||||||
|
},
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
message.success('保存成功');
|
||||||
|
setEditingKey('');
|
||||||
|
fetchSections();
|
||||||
|
} catch (error) {
|
||||||
|
message.error('保存失败');
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (record) => {
|
||||||
|
try {
|
||||||
|
await supabaseService.delete('resources', { id: record.id });
|
||||||
|
message.success('删除成功');
|
||||||
|
fetchSections();
|
||||||
|
} catch (error) {
|
||||||
|
message.error('删除失败');
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddUnit = async (unitName) => {
|
||||||
|
try {
|
||||||
|
await supabaseService.insert("resources", {
|
||||||
|
type: "units",
|
||||||
|
attributes: {
|
||||||
|
name: unitName,
|
||||||
|
template_type: TYPE,
|
||||||
|
},
|
||||||
|
schema_version: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
message.success("新增资源类型成功");
|
||||||
|
fetchUnits();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
message.error("新增资源类型失败");
|
||||||
|
console.error(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleValuesChange = (changedValues, allValues) => {
|
||||||
|
setFormValues(allValues);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = async (file, addItem) => {
|
||||||
|
try {
|
||||||
|
const fileExt = file.name.split('.').pop();
|
||||||
|
const fileName = `${Date.now()}.${fileExt}`;
|
||||||
|
|
||||||
|
const { data, error } = await supabase.storage
|
||||||
|
.from('file')
|
||||||
|
.upload(filePath, file);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
const { data: { publicUrl } } = supabase.storage
|
||||||
|
.from('file')
|
||||||
|
.getPublicUrl(fileName);
|
||||||
|
|
||||||
|
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 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 columns = [
|
||||||
|
{
|
||||||
|
title: '模块名称',
|
||||||
|
dataIndex: ['attributes', 'name'],
|
||||||
|
width: 200,
|
||||||
|
render: (text, record) => {
|
||||||
|
const isEditing = record.id === editingKey;
|
||||||
|
return isEditing ? (
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
style={{ margin: 0 }}
|
||||||
|
rules={[{ required: true, message: '请输入模块名称!' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入模块名称" />
|
||||||
|
</Form.Item>
|
||||||
|
) : (
|
||||||
|
<span className="font-medium">{text}</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '资源项目',
|
||||||
|
dataIndex: ['attributes', 'items'],
|
||||||
|
render: (items, record) => {
|
||||||
|
const isEditing = record.id === editingKey;
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<Form.List name="items">
|
||||||
|
{(fields, { add, remove }) => (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<Card key={field.key} size="small" className="bg-gray-50">
|
||||||
|
<div className="grid grid-cols-5 gap-4">
|
||||||
|
<Form.Item
|
||||||
|
{...field}
|
||||||
|
name={[field.name, 'url']}
|
||||||
|
rules={[{ required: true, message: '请输入资源链接' }]}
|
||||||
|
className="mb-0"
|
||||||
|
>
|
||||||
|
<Input placeholder="资源链接" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
{...field}
|
||||||
|
name={[field.name, 'title']}
|
||||||
|
rules={[{ required: true, message: '请输入资源标题' }]}
|
||||||
|
className="mb-0"
|
||||||
|
>
|
||||||
|
<Input placeholder="资源标题" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
{...field}
|
||||||
|
name={[field.name, 'unit']}
|
||||||
|
className="mb-0"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder="资源类型"
|
||||||
|
loading={loadingUnits}
|
||||||
|
showSearch
|
||||||
|
allowClear
|
||||||
|
options={units.map((unit) => ({
|
||||||
|
label: unit.attributes.name,
|
||||||
|
value: unit.attributes.name,
|
||||||
|
}))}
|
||||||
|
dropdownRender={(menu) => (
|
||||||
|
<>
|
||||||
|
{menu}
|
||||||
|
<Divider style={{ margin: '8px 0' }} />
|
||||||
|
<div style={{ padding: '4px 8px' }}>
|
||||||
|
<Input.Search
|
||||||
|
placeholder="输入新类型"
|
||||||
|
enterButton={<PlusOutlined />}
|
||||||
|
onSearch={async (value) => {
|
||||||
|
if (!value.trim()) return;
|
||||||
|
if (await handleAddUnit(value.trim())) {
|
||||||
|
const currentItems = form.getFieldValue('items');
|
||||||
|
currentItems[index].unit = value.trim();
|
||||||
|
form.setFieldValue('items', currentItems);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
{...field}
|
||||||
|
name={[field.name, 'description']}
|
||||||
|
className="mb-0"
|
||||||
|
>
|
||||||
|
<Input placeholder="资源描述" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => remove(field.name)}
|
||||||
|
disabled={fields.length === 1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
onClick={() => add({
|
||||||
|
key: uuidv4(),
|
||||||
|
url: '',
|
||||||
|
title: '',
|
||||||
|
unit: '',
|
||||||
|
description: ''
|
||||||
|
})}
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
className="w-full hover:border-blue-400 hover:text-blue-500"
|
||||||
|
>
|
||||||
|
手动添加资源
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentAddItem(() => add);
|
||||||
|
setUploadModalVisible(true);
|
||||||
|
}}
|
||||||
|
icon={<UploadOutlined />}
|
||||||
|
className="w-full hover:border-blue-400 hover:text-blue-500"
|
||||||
|
>
|
||||||
|
上传文件
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(items || []).map((item, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-4 text-sm">
|
||||||
|
<a href={item.url} target="_blank" rel="noopener noreferrer" className="text-blue-600">
|
||||||
|
{item.title}
|
||||||
|
</a>
|
||||||
|
<span className="text-gray-500">{item.unit || '未设置类型'}</span>
|
||||||
|
{item.description && (
|
||||||
|
<span className="text-gray-500">({item.description})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 160,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, record) => {
|
||||||
|
const isEditing = record.id === editingKey;
|
||||||
|
return isEditing ? (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
className="text-green-600 hover:text-green-500 font-medium"
|
||||||
|
onClick={() => handleSave(record)}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
className="text-gray-600 hover:text-gray-500 font-medium"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingKey('');
|
||||||
|
if (record.isNew) {
|
||||||
|
setData(data.filter(item => item.id !== record.id));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
className="text-blue-600 hover:text-blue-500 font-medium"
|
||||||
|
disabled={editingKey !== ''}
|
||||||
|
onClick={() => {
|
||||||
|
setEditingKey(record.id);
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: record.attributes.name,
|
||||||
|
items: record.attributes.items
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除"
|
||||||
|
description="确定要删除这个模块吗?"
|
||||||
|
onConfirm={() => handleDelete(record)}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
okButtonProps={{
|
||||||
|
className: "bg-red-500 hover:bg-red-600 border-red-500"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
className="text-red-600 hover:text-red-500 font-medium"
|
||||||
|
disabled={editingKey !== ''}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="rounded-lg shadow-sm mb-6 p-4">
|
||||||
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={handleAdd}
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 border-0 shadow-sm h-10"
|
||||||
|
>
|
||||||
|
新增模块
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg shadow-sm">
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
onValuesChange={handleValuesChange}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
scroll={{ x: 1200 }}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{
|
||||||
|
pageSize: 10,
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
|
className: "px-4"
|
||||||
|
}}
|
||||||
|
className="rounded-lg"
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
{renderUploadModal()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProjectSections;
|
||||||
@@ -313,7 +313,6 @@ const TaskPage = () => {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 按分类分组
|
|
||||||
templates.forEach((template) => {
|
templates.forEach((template) => {
|
||||||
if (template.attributes.category) {
|
if (template.attributes.category) {
|
||||||
template.attributes.category.forEach((cat) => {
|
template.attributes.category.forEach((cat) => {
|
||||||
|
|||||||
@@ -362,7 +362,6 @@ const StorageManager = () => {
|
|||||||
setNewFileName(selectedFile.name);
|
setNewFileName(selectedFile.name);
|
||||||
setIsRenaming(true);
|
setIsRenaming(true);
|
||||||
};
|
};
|
||||||
// 添加文件删除功能
|
|
||||||
const handleDelete = async (fileName) => {
|
const handleDelete = async (fileName) => {
|
||||||
try {
|
try {
|
||||||
const { error } = await supabase.storage.from("file").remove([fileName]);
|
const { error } = await supabase.storage.from("file").remove([fileName]);
|
||||||
@@ -370,29 +369,22 @@ const StorageManager = () => {
|
|||||||
|
|
||||||
message.success("文件删除成功");
|
message.success("文件删除成功");
|
||||||
|
|
||||||
// 直接从本地状态中移除被删除的文件
|
|
||||||
setDisplayFiles(prev => prev.filter(file => file.name !== fileName));
|
setDisplayFiles(prev => prev.filter(file => file.name !== fileName));
|
||||||
|
|
||||||
// 如果删除的是当前选中的文件,清空预览
|
|
||||||
if (selectedFile?.name === fileName) {
|
if (selectedFile?.name === fileName) {
|
||||||
setSelectedFile(null);
|
setSelectedFile(null);
|
||||||
setFileContent("");
|
setFileContent("");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新文件类型统计
|
|
||||||
// 注意:typeStats 是通过 useMemo 自动计算的,不需要手动更新
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(`删除失败: ${error.message}`);
|
message.error(`删除失败: ${error.message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 判断是否有过滤条件
|
|
||||||
const hasFilters = useMemo(() => {
|
const hasFilters = useMemo(() => {
|
||||||
return searchText !== '' || selectedType !== '全部';
|
return searchText !== '' || selectedType !== '全部';
|
||||||
}, [searchText, selectedType]);
|
}, [searchText, selectedType]);
|
||||||
|
|
||||||
// 加载更多数据
|
|
||||||
const loadMoreFiles = () => {
|
const loadMoreFiles = () => {
|
||||||
if (!hasMore || loading || hasFilters) return; // 有过滤条件不加载更多
|
if (!hasMore || loading || hasFilters) return; // 有过滤条件不加载更多
|
||||||
fetchAllFiles(false);
|
fetchAllFiles(false);
|
||||||
@@ -403,35 +395,17 @@ const StorageManager = () => {
|
|||||||
fetchAllFiles(true);
|
fetchAllFiles(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 搜索或筛选时重新加载
|
|
||||||
const handleSearch = (value) => {
|
|
||||||
setSearchText(value);
|
|
||||||
// 不需要重新调用 fetchAllFiles,因为搜索是在前端过滤
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTypeChange = (type) => {
|
const handleTypeChange = (type) => {
|
||||||
setSelectedType(type);
|
setSelectedType(type);
|
||||||
// 不需要重新调用 fetchAllFiles,因为类型筛选是在前端过滤
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理文件名中的空格
|
|
||||||
const handleFileName = (fileName) => {
|
const handleFileName = (fileName) => {
|
||||||
// 替换空格为下划线或编码空格
|
|
||||||
return fileName.replace(/\s+/g, '_');
|
return fileName.replace(/\s+/g, '_');
|
||||||
};
|
};
|
||||||
|
|
||||||
// 修改文件类型判断
|
|
||||||
const getFileType = (mimetype) => {
|
|
||||||
if (!mimetype) return '其他';
|
|
||||||
|
|
||||||
for (const [type, mimetypes] of Object.entries(FILE_TYPES)) {
|
|
||||||
if (mimetypes.some(t => mimetype.startsWith(t) || mimetype === t)) {
|
|
||||||
return type;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return '其他';
|
|
||||||
};
|
|
||||||
|
|
||||||
// 渲染加载状态
|
// 渲染加载状态
|
||||||
const LoadingSpinner = () => (
|
const LoadingSpinner = () => (
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ const companyRoutes = [
|
|||||||
name: "报价单详情",
|
name: "报价单详情",
|
||||||
icon: "file",
|
icon: "file",
|
||||||
roles: ["ADMIN", "OWNER"],
|
roles: ["ADMIN", "OWNER"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "project",
|
||||||
|
component: lazy(() => import("@/pages/company/project")),
|
||||||
|
name: "专案管理",
|
||||||
|
icon: "appstore",
|
||||||
|
roles: ["ADMIN", "OWNER"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "task",
|
path: "task",
|
||||||
@@ -119,13 +126,7 @@ const companyRoutes = [
|
|||||||
icon: "branches",
|
icon: "branches",
|
||||||
roles: ["ADMIN", "OWNER"],
|
roles: ["ADMIN", "OWNER"],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "project",
|
|
||||||
component: lazy(() => import("@/pages/company/project")),
|
|
||||||
name: "专案管理",
|
|
||||||
icon: "appstore",
|
|
||||||
roles: ["ADMIN", "OWNER"],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "projectInfo/:id?",
|
path: "projectInfo/:id?",
|
||||||
hidden: true,
|
hidden: true,
|
||||||
@@ -133,6 +134,14 @@ const companyRoutes = [
|
|||||||
name: "专案管理详情",
|
name: "专案管理详情",
|
||||||
icon: "appstore",
|
icon: "appstore",
|
||||||
roles: ["ADMIN", "OWNER"],
|
roles: ["ADMIN", "OWNER"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "projectView/:id?",
|
||||||
|
hidden: true,
|
||||||
|
component: lazy(() => import("@/pages/company/project/info")),
|
||||||
|
name: "专案详情",
|
||||||
|
icon: "appstore",
|
||||||
|
roles: ["ADMIN", "OWNER"],
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user