报价单魔魁
This commit is contained in:
@@ -14,8 +14,10 @@
|
|||||||
"@monaco-editor/react": "^4.6.0",
|
"@monaco-editor/react": "^4.6.0",
|
||||||
"@supabase/supabase-js": "^2.38.4",
|
"@supabase/supabase-js": "^2.38.4",
|
||||||
"antd": "^5.11.0",
|
"antd": "^5.11.0",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-infinite-scroll-component": "^6.1.0",
|
||||||
"react-router-dom": "^6.18.0",
|
"react-router-dom": "^6.18.0",
|
||||||
"recharts": "^2.9.0",
|
"recharts": "^2.9.0",
|
||||||
"styled-components": "^6.1.0"
|
"styled-components": "^6.1.0"
|
||||||
|
|||||||
22
pnpm-lock.yaml
generated
22
pnpm-lock.yaml
generated
@@ -20,12 +20,18 @@ importers:
|
|||||||
antd:
|
antd:
|
||||||
specifier: ^5.11.0
|
specifier: ^5.11.0
|
||||||
version: 5.22.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 5.22.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
lodash:
|
||||||
|
specifier: ^4.17.21
|
||||||
|
version: 4.17.21
|
||||||
react:
|
react:
|
||||||
specifier: ^18.2.0
|
specifier: ^18.2.0
|
||||||
version: 18.3.1
|
version: 18.3.1
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^18.2.0
|
specifier: ^18.2.0
|
||||||
version: 18.3.1(react@18.3.1)
|
version: 18.3.1(react@18.3.1)
|
||||||
|
react-infinite-scroll-component:
|
||||||
|
specifier: ^6.1.0
|
||||||
|
version: 6.1.0(react@18.3.1)
|
||||||
react-router-dom:
|
react-router-dom:
|
||||||
specifier: ^6.18.0
|
specifier: ^6.18.0
|
||||||
version: 6.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 6.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
@@ -1884,6 +1890,11 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18.3.1
|
react: ^18.3.1
|
||||||
|
|
||||||
|
react-infinite-scroll-component@6.1.0:
|
||||||
|
resolution: {integrity: sha512-SQu5nCqy8DxQWpnUVLx7V7b7LcA37aM7tvoWjTLZp1dk6EJibM5/4EJKzOnl07/BsM1Y40sKLuqjCwwH/xV0TQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.0.0'
|
||||||
|
|
||||||
react-is@16.13.1:
|
react-is@16.13.1:
|
||||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||||
|
|
||||||
@@ -2139,6 +2150,10 @@ packages:
|
|||||||
thenify@3.3.1:
|
thenify@3.3.1:
|
||||||
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
|
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
|
||||||
|
|
||||||
|
throttle-debounce@2.3.0:
|
||||||
|
resolution: {integrity: sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
throttle-debounce@5.0.2:
|
throttle-debounce@5.0.2:
|
||||||
resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==}
|
resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==}
|
||||||
engines: {node: '>=12.22'}
|
engines: {node: '>=12.22'}
|
||||||
@@ -4369,6 +4384,11 @@ snapshots:
|
|||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
scheduler: 0.23.2
|
scheduler: 0.23.2
|
||||||
|
|
||||||
|
react-infinite-scroll-component@6.1.0(react@18.3.1):
|
||||||
|
dependencies:
|
||||||
|
react: 18.3.1
|
||||||
|
throttle-debounce: 2.3.0
|
||||||
|
|
||||||
react-is@16.13.1: {}
|
react-is@16.13.1: {}
|
||||||
|
|
||||||
react-is@18.3.1: {}
|
react-is@18.3.1: {}
|
||||||
@@ -4713,6 +4733,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
any-promise: 1.3.0
|
any-promise: 1.3.0
|
||||||
|
|
||||||
|
throttle-debounce@2.3.0: {}
|
||||||
|
|
||||||
throttle-debounce@5.0.2: {}
|
throttle-debounce@5.0.2: {}
|
||||||
|
|
||||||
tiny-invariant@1.3.3: {}
|
tiny-invariant@1.3.3: {}
|
||||||
|
|||||||
@@ -12,19 +12,22 @@ const MainLayout = () => {
|
|||||||
const { isDarkMode } = useTheme();
|
const { isDarkMode } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout style={{ minHeight: '100vh' }}>
|
<Layout className="min-h-screen">
|
||||||
<Sidebar collapsed={collapsed} />
|
<Sidebar collapsed={collapsed} />
|
||||||
<Layout>
|
<Layout className="flex flex-col h-screen">
|
||||||
<Header collapsed={collapsed} setCollapsed={setCollapsed} />
|
<Header collapsed={collapsed} setCollapsed={setCollapsed} />
|
||||||
<Content
|
<Content
|
||||||
style={{
|
className={`
|
||||||
margin: '24px 16px',
|
m-2 p-4 rounded-lg overflow-auto
|
||||||
padding: 24,
|
${isDarkMode ? 'bg-[#141414]' : 'bg-white'}
|
||||||
background: isDarkMode ? '#141414' : '#fff',
|
flex-1
|
||||||
borderRadius: '4px',
|
`}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Suspense fallback={<Spin size="large" />}>
|
<Suspense fallback={
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Content>
|
</Content>
|
||||||
|
|||||||
119
src/components/PrintView/index.jsx
Normal file
119
src/components/PrintView/index.jsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Table } from 'antd';
|
||||||
|
|
||||||
|
const PrintView = ({ data }) => {
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '序号',
|
||||||
|
dataIndex: 'index',
|
||||||
|
width: 60,
|
||||||
|
render: (_, __, index) => index + 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '名称',
|
||||||
|
dataIndex: 'productName',
|
||||||
|
width: '40%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '数量',
|
||||||
|
dataIndex: 'quantity',
|
||||||
|
width: '20%',
|
||||||
|
align: 'right',
|
||||||
|
render: (value) => value?.toLocaleString('zh-CN', { minimumFractionDigits: 2 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '单价',
|
||||||
|
dataIndex: 'price',
|
||||||
|
width: '20%',
|
||||||
|
align: 'right',
|
||||||
|
render: (value) => value?.toLocaleString('zh-CN', { minimumFractionDigits: 2 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '小计',
|
||||||
|
width: '20%',
|
||||||
|
align: 'right',
|
||||||
|
render: (_, record) => (
|
||||||
|
(record.quantity * record.price)?.toLocaleString('zh-CN', { minimumFractionDigits: 2 })
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="print-wrapper">
|
||||||
|
<div className="print-header">
|
||||||
|
<h1>{data.attributes.quataName}</h1>
|
||||||
|
<div className="quotation-id">报价单号:{data.id}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="info-grid">
|
||||||
|
<div className="info-item">
|
||||||
|
<span className="info-label">客户公司</span>
|
||||||
|
<span className="info-value">{data.attributes.companyName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="info-item">
|
||||||
|
<span className="info-label">供应商</span>
|
||||||
|
<span className="info-value">{data.attributes.supplierName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="info-item">
|
||||||
|
<span className="info-label">报价日期</span>
|
||||||
|
<span className="info-value">
|
||||||
|
{new Date(data.created_at).toLocaleDateString('zh-CN')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="info-item">
|
||||||
|
<span className="info-label">报价有效期</span>
|
||||||
|
<span className="info-value">
|
||||||
|
{new Date(Date.now() + 30*24*60*60*1000).toLocaleDateString('zh-CN')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="section-title">报价明细</div>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data.attributes.items}
|
||||||
|
pagination={false}
|
||||||
|
rowKey={(record, index) => index}
|
||||||
|
bordered
|
||||||
|
summary={() => (
|
||||||
|
<Table.Summary fixed>
|
||||||
|
<Table.Summary.Row>
|
||||||
|
<Table.Summary.Cell index={0} colSpan={4} align="right">
|
||||||
|
总计({data.attributes.currency}):
|
||||||
|
</Table.Summary.Cell>
|
||||||
|
<Table.Summary.Cell index={1} align="right">
|
||||||
|
<span style={{ color: '#1890ff', fontSize: '16px' }}>
|
||||||
|
{data.attributes.totalAmount?.toLocaleString('zh-CN', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
style: 'currency',
|
||||||
|
currency: data.attributes.currency
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</Table.Summary.Cell>
|
||||||
|
</Table.Summary.Row>
|
||||||
|
</Table.Summary>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.attributes.description && (
|
||||||
|
<div className="description-section">
|
||||||
|
<div className="section-title">补充说明</div>
|
||||||
|
<div>{data.attributes.description}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="notes-section">
|
||||||
|
<div className="notes-title">注意事项:</div>
|
||||||
|
<ul className="notes-list">
|
||||||
|
<li>本报价单有效期为30天</li>
|
||||||
|
<li>最终解释权归本公司所有</li>
|
||||||
|
<li>如有疑问请及时联系我们</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PrintView;
|
||||||
@@ -31,6 +31,19 @@ const companyRoutes = [
|
|||||||
component: lazy(() => import('@/pages/company/quotation')),
|
component: lazy(() => import('@/pages/company/quotation')),
|
||||||
name: '报价单',
|
name: '报价单',
|
||||||
icon: 'file',
|
icon: 'file',
|
||||||
|
}, {
|
||||||
|
path: 'quotaInfo/:id?', // 添加可选的 id 参数
|
||||||
|
hidden: true,
|
||||||
|
component: lazy(() => import('@/pages/company/quotation/detail')),
|
||||||
|
name: '报价单详情',
|
||||||
|
icon: 'file',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'quotaInfo/preview/:id?', // 添加可选的 id 参数
|
||||||
|
hidden: true,
|
||||||
|
component: lazy(() => import('@/pages/company/quotation/view')),
|
||||||
|
name: '报价单预览',
|
||||||
|
icon: 'file',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'customer',
|
path: 'customer',
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ export const useResources = (initialPagination, initialSorter) => {
|
|||||||
|
|
||||||
return { data, total: newTotal };
|
return { data, total: newTotal };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取资源列表失败:', error);
|
console.error('获取列表失败:', error);
|
||||||
message.error('获取资源列表失败');
|
message.error('获取列表失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -48,10 +48,10 @@ export const useResources = (initialPagination, initialSorter) => {
|
|||||||
try {
|
try {
|
||||||
const newResource = await resourceService.createResource(values);
|
const newResource = await resourceService.createResource(values);
|
||||||
await fetchResources({ current: 1 });
|
await fetchResources({ current: 1 });
|
||||||
message.success('创建资源成功');
|
message.success('创建成功');
|
||||||
return newResource;
|
return newResource;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('创建资源失败');
|
message.error('创建失败');
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -60,10 +60,10 @@ export const useResources = (initialPagination, initialSorter) => {
|
|||||||
try {
|
try {
|
||||||
const updatedResource = await resourceService.updateResource(id, values);
|
const updatedResource = await resourceService.updateResource(id, values);
|
||||||
await fetchResources({ current: currentPagination.current });
|
await fetchResources({ current: currentPagination.current });
|
||||||
message.success('更新资源成功');
|
message.success('更新成功');
|
||||||
return updatedResource;
|
return updatedResource;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('更新资源失败');
|
message.error('更新失败');
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -75,9 +75,9 @@ export const useResources = (initialPagination, initialSorter) => {
|
|||||||
? currentPagination.current - 1
|
? currentPagination.current - 1
|
||||||
: currentPagination.current;
|
: currentPagination.current;
|
||||||
await fetchResources({ current: newCurrent });
|
await fetchResources({ current: newCurrent });
|
||||||
message.success('删除资源成功');
|
message.success('删除成功');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('删除资源失败');
|
message.error('删除失败');
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
414
src/pages/company/quotation/detail/index.jsx
Normal file
414
src/pages/company/quotation/detail/index.jsx
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Form, Input, InputNumber, Select, Button, Space, Card, Table, Typography } from 'antd';
|
||||||
|
import { PlusOutlined, ArrowLeftOutlined, SaveOutlined } from '@ant-design/icons';
|
||||||
|
import { supabase } from '@/config/supabase';
|
||||||
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
|
import { round } from 'lodash';
|
||||||
|
|
||||||
|
const { TextArea } = Input;
|
||||||
|
const { Title } = Typography;
|
||||||
|
|
||||||
|
// 添加货币符号映射
|
||||||
|
const CURRENCY_SYMBOLS = {
|
||||||
|
CNY: '¥',
|
||||||
|
TWD: 'NT$',
|
||||||
|
USD: '$'
|
||||||
|
};
|
||||||
|
|
||||||
|
const QuotationForm = () => {
|
||||||
|
const { id } = useParams();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const isEdit = searchParams.get('edit') === 'true';
|
||||||
|
const isView = id && !isEdit;
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [dataSource, setDataSource] = useState([{ id: Date.now() }]);
|
||||||
|
const [totalAmount, setTotalAmount] = useState(0);
|
||||||
|
const [currentCurrency, setCurrentCurrency] = useState('CNY');
|
||||||
|
|
||||||
|
const calculateTotal = (items = []) => {
|
||||||
|
const total = items.reduce((sum, item) => {
|
||||||
|
const itemAmount = round(
|
||||||
|
Number(item?.quantity || 0) * Number(item?.price || 0),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
return round(sum + itemAmount, 2);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
setTotalAmount(total);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleValuesChange = (changedValues, allValues) => {
|
||||||
|
if (changedValues.currency) {
|
||||||
|
setCurrentCurrency(changedValues.currency);
|
||||||
|
}
|
||||||
|
if (changedValues.items) {
|
||||||
|
const changedField = Object.keys(changedValues.items[0])[0];
|
||||||
|
if (changedField === 'quantity' || changedField === 'price') {
|
||||||
|
calculateTotal(allValues.items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '名称',
|
||||||
|
dataIndex: 'productName',
|
||||||
|
width: '40%',
|
||||||
|
render: (_, record, index) => (
|
||||||
|
<Form.Item
|
||||||
|
name={['items', index, 'productName']}
|
||||||
|
rules={[{ required: true, message: '请输入名称' }]}
|
||||||
|
style={{ margin: 0 }}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入名称" />
|
||||||
|
</Form.Item>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
title: '数量',
|
||||||
|
dataIndex: 'quantity',
|
||||||
|
width: '20%',
|
||||||
|
render: (_, record, index) => (
|
||||||
|
<Form.Item
|
||||||
|
name={['items', index, 'quantity']}
|
||||||
|
rules={[{ required: true, message: '请输入数量' }]}
|
||||||
|
style={{ margin: 0 }}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
precision={2}
|
||||||
|
placeholder="数量"
|
||||||
|
onChange={(value) => {
|
||||||
|
const values = form.getFieldValue('items');
|
||||||
|
values[index].quantity = value;
|
||||||
|
calculateTotal(values);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '单价',
|
||||||
|
dataIndex: 'price',
|
||||||
|
width: '20%',
|
||||||
|
render: (_, record, index) => (
|
||||||
|
<Form.Item
|
||||||
|
name={['items', index, 'price']}
|
||||||
|
rules={[{ required: true, message: '请输入单价' }]}
|
||||||
|
style={{ margin: 0 }}
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
precision={2}
|
||||||
|
placeholder="单价"
|
||||||
|
onChange={(value) => {
|
||||||
|
const values = form.getFieldValue('items');
|
||||||
|
values[index].price = value;
|
||||||
|
calculateTotal(values);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '说明',
|
||||||
|
dataIndex: 'note',
|
||||||
|
width: '40%',
|
||||||
|
render: (_, record, index) => (
|
||||||
|
<Form.Item
|
||||||
|
name={['items', index, 'note']}
|
||||||
|
style={{ margin: 0 }}
|
||||||
|
>
|
||||||
|
<Input placeholder="备注说明" />
|
||||||
|
</Form.Item>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: '10%',
|
||||||
|
render: (_, record, index) => (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
danger
|
||||||
|
onClick={() => {
|
||||||
|
const newData = dataSource.filter(item => item.id !== record.id);
|
||||||
|
setDataSource(newData);
|
||||||
|
const values = form.getFieldValue('items');
|
||||||
|
values.splice(index, 1);
|
||||||
|
form.setFieldValue('items', values);
|
||||||
|
calculateTotal(values);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const fetchQuotationDetail = async () => {
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('resources')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
|
||||||
|
if (isEdit) {
|
||||||
|
// 设置表单初始值
|
||||||
|
form.setFieldsValue({
|
||||||
|
quataName: data.attributes.quataName,
|
||||||
|
companyName: data.attributes.companyName,
|
||||||
|
supplierName: data.attributes.supplierName,
|
||||||
|
description: data.attributes.description,
|
||||||
|
currency: data.attributes.currency,
|
||||||
|
items: data.attributes.items,
|
||||||
|
});
|
||||||
|
|
||||||
|
setDataSource(data.attributes.items.map((item, index) => ({
|
||||||
|
id: Date.now() + index,
|
||||||
|
...item
|
||||||
|
})));
|
||||||
|
|
||||||
|
setCurrentCurrency(data.attributes.currency);
|
||||||
|
calculateTotal(data.attributes.items);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取报价单详情失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) {
|
||||||
|
fetchQuotationDetail();
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const onFinish = async (values) => {
|
||||||
|
try {
|
||||||
|
const quotationData = {
|
||||||
|
type: 'quota',
|
||||||
|
attributes: {
|
||||||
|
quataName: values.quataName,
|
||||||
|
companyName: values.companyName,
|
||||||
|
supplierName: values.supplierName,
|
||||||
|
description: values.description,
|
||||||
|
currency: values.currency,
|
||||||
|
items: values.items,
|
||||||
|
totalAmount
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let result;
|
||||||
|
if (id) {
|
||||||
|
result = await supabase
|
||||||
|
.from('resources')
|
||||||
|
.update(quotationData)
|
||||||
|
.eq('id', id)
|
||||||
|
.select();
|
||||||
|
} else {
|
||||||
|
result = await supabase
|
||||||
|
.from('resources')
|
||||||
|
.insert([quotationData])
|
||||||
|
.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
navigate('/company/quotation');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gradient-to-b from-gray-50 to-white min-h-screen p-6">
|
||||||
|
<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/quotation')}
|
||||||
|
>
|
||||||
|
返回
|
||||||
|
</Button>
|
||||||
|
{!isView && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SaveOutlined />}
|
||||||
|
onClick={() => form.submit()}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
bodyStyle={{ backgroundColor: '#fff' }}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
onFinish={onFinish}
|
||||||
|
layout="vertical"
|
||||||
|
onValuesChange={handleValuesChange}
|
||||||
|
initialValues={{
|
||||||
|
currency: 'CNY',
|
||||||
|
items: [{}]
|
||||||
|
}}
|
||||||
|
className="space-y-6"
|
||||||
|
disabled={isView}
|
||||||
|
>
|
||||||
|
{/* 基本信息卡片 */}
|
||||||
|
<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="quataName"
|
||||||
|
label={<span className="text-gray-700 font-medium">活动名称</span>}
|
||||||
|
rules={[{ required: true, message: '活动名称' }]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="请输入活动名称"
|
||||||
|
className="rounded-md hover:border-blue-400 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="companyName"
|
||||||
|
label={<span className="text-gray-700 font-medium">公司名称</span>}
|
||||||
|
rules={[{ required: true, message: '请输入公司名称' }]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="请输入公司名称"
|
||||||
|
className="rounded-md hover:border-blue-400 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="supplierName"
|
||||||
|
label={<span className="text-gray-700 font-medium">供应商名称</span>}
|
||||||
|
rules={[{ required: true, message: '请输入供应商名称' }]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="请输入供应商名称"
|
||||||
|
className="rounded-md hover:border-blue-400 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 报价明细卡片 */}
|
||||||
|
<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-green-500 rounded-full"/>
|
||||||
|
<span>报价明细</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
bordered={false}
|
||||||
|
extra={
|
||||||
|
!isView && (
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
icon={<PlusOutlined className="text-green-500" />}
|
||||||
|
onClick={() => {
|
||||||
|
const newId = Date.now();
|
||||||
|
setDataSource([...dataSource, { id: newId }]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
新增一栏
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
dataSource={dataSource}
|
||||||
|
columns={columns}
|
||||||
|
pagination={false}
|
||||||
|
rowKey="id"
|
||||||
|
className="border-t border-gray-100"
|
||||||
|
rowClassName="hover:bg-gray-50 transition-colors"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end items-center mt-6 space-x-4 pt-4 border-t border-gray-100">
|
||||||
|
<span className="text-gray-600 font-medium">总金额:</span>
|
||||||
|
<span className="text-2xl font-semibold text-blue-600">
|
||||||
|
{CURRENCY_SYMBOLS[currentCurrency]}
|
||||||
|
{totalAmount.toLocaleString('zh-CN', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<Form.Item
|
||||||
|
name="currency"
|
||||||
|
style={{ margin: 0 }}
|
||||||
|
rules={[{ required: true }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
className="w-28 rounded-md"
|
||||||
|
onChange={(value) => setCurrentCurrency(value)}
|
||||||
|
dropdownClassName="rounded-md shadow-lg"
|
||||||
|
>
|
||||||
|
<Select.Option value="CNY">RMB</Select.Option>
|
||||||
|
<Select.Option value="TWD">台币</Select.Option>
|
||||||
|
<Select.Option value="USD">美元</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<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-purple-500 rounded-full"/>
|
||||||
|
<span>补充说明</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
bordered={false}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="description"
|
||||||
|
className="mb-0"
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
rows={4}
|
||||||
|
placeholder="请输入补充说明信息"
|
||||||
|
className="rounded-md hover:border-purple-400 focus:border-purple-500"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Card>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QuotationForm;
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Card, Table, Button, Modal, Form, Input, message, Popconfirm } from 'antd';
|
import { Card, Table, Button, message, Popconfirm, Tag, Space, Tooltip } from 'antd';
|
||||||
import { PlusOutlined } from '@ant-design/icons';
|
import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons';
|
||||||
import { useResources } from '@/hooks/resource/useResource';
|
import { useResources } from '@/hooks/resource/useResource';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { formatCurrency } from '@/utils/format'; // 假设你有这个工具函数,如果没有我会提供
|
||||||
|
|
||||||
const QuotationPage = () => {
|
const QuotationPage = () => {
|
||||||
const [form] = Form.useForm();
|
const navigate = useNavigate();
|
||||||
const [visible, setVisible] = useState(false);
|
|
||||||
const [editingId, setEditingId] = useState(null);
|
|
||||||
const [pagination, setPagination] = useState({ current: 1, pageSize: 10 });
|
const [pagination, setPagination] = useState({ current: 1, pageSize: 10 });
|
||||||
const [sorter, setSorter] = useState({ field: 'created_at', order: 'descend' });
|
const [sorter, setSorter] = useState({ field: 'created_at', order: 'descend' });
|
||||||
|
|
||||||
@@ -15,8 +15,6 @@ const QuotationPage = () => {
|
|||||||
loading,
|
loading,
|
||||||
total,
|
total,
|
||||||
fetchResources: fetchQuotations,
|
fetchResources: fetchQuotations,
|
||||||
createResource: createQuotation,
|
|
||||||
updateResource: updateQuotation,
|
|
||||||
deleteResource: deleteQuotation
|
deleteResource: deleteQuotation
|
||||||
} = useResources(pagination, sorter);
|
} = useResources(pagination, sorter);
|
||||||
|
|
||||||
@@ -35,109 +33,119 @@ const QuotationPage = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (values) => {
|
|
||||||
try {
|
|
||||||
const quotationData = {
|
|
||||||
external_id: values.id,
|
|
||||||
attributes: {
|
|
||||||
customerName: values.customerName,
|
|
||||||
status: values.status || '新建',
|
|
||||||
},
|
|
||||||
type: 'quota'
|
|
||||||
};
|
|
||||||
|
|
||||||
if (editingId) {
|
|
||||||
await updateQuotation(editingId, quotationData);
|
|
||||||
} else {
|
|
||||||
await createQuotation(quotationData);
|
|
||||||
}
|
|
||||||
setVisible(false);
|
|
||||||
form.resetFields();
|
|
||||||
fetchQuotations();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('提交失败:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id) => {
|
const handleDelete = async (id) => {
|
||||||
try {
|
try {
|
||||||
await deleteQuotation(id);
|
await deleteQuotation(id);
|
||||||
|
message.success('删除成功');
|
||||||
fetchQuotations();
|
fetchQuotations();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('删除失败:', error);
|
message.error('删除失败:' + error.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: '报价单号',
|
title: '报价单名称',
|
||||||
dataIndex: ['external_id'],
|
dataIndex: ['attributes', 'quataName'],
|
||||||
key: 'external_id',
|
key: 'quataName',
|
||||||
sorter: true,
|
ellipsis: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '客户名称',
|
title: '客户信息',
|
||||||
dataIndex: ['attributes', 'customerName'],
|
dataIndex: ['attributes', 'companyName'],
|
||||||
key: 'customerName',
|
key: 'companyName',
|
||||||
|
render: (companyName) => (
|
||||||
|
<Tag color="blue">{companyName}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '供应商',
|
||||||
|
dataIndex: ['attributes', 'supplierName'],
|
||||||
|
key: 'supplierName',
|
||||||
|
render: (supplierName) => (
|
||||||
|
<Tag color="green">{supplierName}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '报价总额',
|
||||||
|
dataIndex: ['attributes', 'totalAmount'],
|
||||||
|
key: 'totalAmount',
|
||||||
|
align: 'right',
|
||||||
|
render: (amount) => (
|
||||||
|
<span style={{ color: '#f50', fontWeight: 'bold' }}>
|
||||||
|
¥{amount?.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '创建日期',
|
title: '创建日期',
|
||||||
dataIndex: 'created_at',
|
dataIndex: 'created_at',
|
||||||
key: 'created_at',
|
key: 'created_at',
|
||||||
sorter: true,
|
sorter: true,
|
||||||
render: (text) => new Date(text).toLocaleString(),
|
width: 180,
|
||||||
},
|
render: (text) => (
|
||||||
{
|
<span>{new Date(text).toLocaleString('zh-CN', {
|
||||||
title: '状态',
|
year: 'numeric',
|
||||||
dataIndex: ['attributes', 'status'],
|
month: '2-digit',
|
||||||
key: 'status',
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})}</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'action',
|
key: 'action',
|
||||||
|
width:180,
|
||||||
render: (_, record) => (
|
render: (_, record) => (
|
||||||
<span>
|
<Space size="mini">
|
||||||
<Button
|
<Button
|
||||||
|
size='mini'
|
||||||
type="link"
|
type="link"
|
||||||
onClick={() => {
|
icon={<EyeOutlined />}
|
||||||
setEditingId(record.id);
|
onClick={() => navigate(`/company/quotaInfo/preview/${record.id}`)}
|
||||||
form.setFieldsValue({
|
>
|
||||||
id: record.external_id,
|
查看
|
||||||
customerName: record.attributes?.customerName,
|
</Button>
|
||||||
status: record.attributes?.status,
|
<Button
|
||||||
});
|
size='mini'
|
||||||
setVisible(true);
|
type="link"
|
||||||
}}
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => navigate(`/company/quotaInfo/${record.id}?edit=true`)}
|
||||||
>
|
>
|
||||||
编辑
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="确定要删除这个报价单吗?"
|
title="确定要删除这个报价单吗?"
|
||||||
|
description="删除后将无法恢复!"
|
||||||
onConfirm={() => handleDelete(record.id)}
|
onConfirm={() => handleDelete(record.id)}
|
||||||
okText="确定"
|
okText="确定"
|
||||||
cancelText="取消"
|
cancelText="取消"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
>
|
>
|
||||||
<Button type="link" danger>
|
<Button size='mini' type="link" danger icon={<DeleteOutlined />}>
|
||||||
删除
|
删除
|
||||||
</Button>
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</span>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
title="报价单管理"
|
title={
|
||||||
|
<Space>
|
||||||
|
<span>报价单管理</span>
|
||||||
|
<Tag color="blue">{total} 个报价单</Tag>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
className='h-full w-full overflow-auto'
|
||||||
extra={
|
extra={
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
onClick={() => {
|
onClick={() => navigate('/company/quotaInfo')}
|
||||||
setEditingId(null);
|
|
||||||
form.resetFields();
|
|
||||||
setVisible(true);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
新增报价单
|
新增报价单
|
||||||
</Button>
|
</Button>
|
||||||
@@ -152,55 +160,11 @@ const QuotationPage = () => {
|
|||||||
pagination={{
|
pagination={{
|
||||||
...pagination,
|
...pagination,
|
||||||
total,
|
total,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showQuickJumper: true,
|
||||||
|
showTotal: (total) => `共 ${total} 条记录`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Modal
|
|
||||||
title={editingId ? '编辑报价单' : '新增报价单'}
|
|
||||||
open={visible}
|
|
||||||
onCancel={() => {
|
|
||||||
setVisible(false);
|
|
||||||
setEditingId(null);
|
|
||||||
form.resetFields();
|
|
||||||
}}
|
|
||||||
footer={null}
|
|
||||||
>
|
|
||||||
<Form
|
|
||||||
form={form}
|
|
||||||
layout="vertical"
|
|
||||||
onFinish={handleSubmit}
|
|
||||||
>
|
|
||||||
<Form.Item
|
|
||||||
name="id"
|
|
||||||
label="报价单号"
|
|
||||||
rules={[{ required: true, message: '请输入报价单号' }]}
|
|
||||||
>
|
|
||||||
<Input placeholder="请输入报价单号" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="customerName"
|
|
||||||
label="客户名称"
|
|
||||||
rules={[{ required: true, message: '请输入客户名称' }]}
|
|
||||||
>
|
|
||||||
<Input placeholder="请输入客户名称" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
name="status"
|
|
||||||
label="状态"
|
|
||||||
initialValue="新建"
|
|
||||||
>
|
|
||||||
<Input placeholder="请输入状态" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item>
|
|
||||||
<Button type="primary" htmlType="submit" block>
|
|
||||||
提交
|
|
||||||
</Button>
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
264
src/pages/company/quotation/view/index.jsx
Normal file
264
src/pages/company/quotation/view/index.jsx
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Card, Typography, Descriptions, Table, Button, Space, Spin } from 'antd';
|
||||||
|
import { ArrowLeftOutlined, PrinterOutlined, EditOutlined } from '@ant-design/icons';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { supabase } from '@/config/supabase';
|
||||||
|
import ReactDOMServer from 'react-dom/server';
|
||||||
|
import PrintView from '@/components/PrintView';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
const printStyles = `
|
||||||
|
@media print {
|
||||||
|
/* 隐藏所有按钮和不需要的元素 */
|
||||||
|
button, .no-print {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 只打印卡片内容 */
|
||||||
|
.ant-card {
|
||||||
|
box-shadow: none !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移除背景色 */
|
||||||
|
body, .bg-gray-50 {
|
||||||
|
background: white !important;
|
||||||
|
min-height: auto !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 确保内容完整打印 */
|
||||||
|
.ant-card-body {
|
||||||
|
padding: 24px !important;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 优化表格打印样式 */
|
||||||
|
.ant-table {
|
||||||
|
page-break-inside: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-table-row {
|
||||||
|
page-break-inside: avoid !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const QuotationView = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { id } = useParams();
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchQuotationDetail = async () => {
|
||||||
|
try {
|
||||||
|
const { data: quotation, error } = await supabase
|
||||||
|
.from('resources')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
setData(quotation);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取报价单<E4BBB7><E58D95>情失败:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) {
|
||||||
|
fetchQuotationDetail();
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '序号',
|
||||||
|
dataIndex: 'index',
|
||||||
|
width: 100,
|
||||||
|
render: (_, __, index) => index + 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '名称',
|
||||||
|
dataIndex: 'productName',
|
||||||
|
width: '40%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '数量',
|
||||||
|
dataIndex: 'quantity',
|
||||||
|
width: '20%',
|
||||||
|
align: 'right',
|
||||||
|
render: (value) => value?.toLocaleString('zh-CN', { minimumFractionDigits: 2 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '单价',
|
||||||
|
dataIndex: 'price',
|
||||||
|
width: '20%',
|
||||||
|
align: 'right',
|
||||||
|
render: (value) => value?.toLocaleString('zh-CN', { minimumFractionDigits: 2 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '小计',
|
||||||
|
width: '20%',
|
||||||
|
align: 'right',
|
||||||
|
render: (_, record) => (
|
||||||
|
(record.quantity * record.price)?.toLocaleString('zh-CN', { minimumFractionDigits: 2 })
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handlePrint = () => {
|
||||||
|
const printWindow = window.open('', '_blank');
|
||||||
|
const printContent = ReactDOMServer.renderToString(<PrintView data={data} />);
|
||||||
|
printWindow.document.write(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>打印报价单</title>
|
||||||
|
<link rel="stylesheet" href="${window.location.origin}/antd.min.css">
|
||||||
|
<style>
|
||||||
|
@media print {
|
||||||
|
body {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${printContent}
|
||||||
|
<script>
|
||||||
|
window.onload = function() {
|
||||||
|
window.print();
|
||||||
|
window.onafterprint = function() {
|
||||||
|
window.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
printWindow.document.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center min-h-screen">
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{printStyles}</style>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 min-h-screen ">
|
||||||
|
<Card className="max-w-5xl mx-auto shadow-lg">
|
||||||
|
<div className="flex justify-between items-center mb-6 no-print">
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
icon={<ArrowLeftOutlined />}
|
||||||
|
onClick={() => navigate('/company/quotation')}
|
||||||
|
>
|
||||||
|
返回列表
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
type="primary"
|
||||||
|
onClick={() => navigate(`/company/quotaInfo/${data.id}?edit=true`)}
|
||||||
|
>
|
||||||
|
编辑报价单
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<PrinterOutlined />}
|
||||||
|
onClick={handlePrint}
|
||||||
|
>
|
||||||
|
打印报价单
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<Title level={2} className="!mb-2">{data.attributes.quataName}</Title>
|
||||||
|
<Text type="secondary">报价单号:{data.id}</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 p-6 rounded-lg mb-8">
|
||||||
|
<Descriptions column={2} bordered>
|
||||||
|
<Descriptions.Item label="客户公司" span={1}>
|
||||||
|
{data.attributes.companyName}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="供应商" span={1}>
|
||||||
|
{data.attributes.supplierName}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="报价日期" span={1}>
|
||||||
|
{new Date(data.created_at).toLocaleDateString('zh-CN')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="报价有效期" span={1}>
|
||||||
|
{new Date(Date.now() + 30*24*60*60*1000).toLocaleDateString('zh-CN')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
<Title level={4} className="mb-4">报价明细</Title>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data.attributes.items}
|
||||||
|
pagination={false}
|
||||||
|
rowKey={(record, index) => index}
|
||||||
|
className="border rounded-lg"
|
||||||
|
summary={() => (
|
||||||
|
<Table.Summary fixed>
|
||||||
|
<Table.Summary.Row className="bg-gray-50 font-bold">
|
||||||
|
<Table.Summary.Cell index={0} colSpan={4} align="right">
|
||||||
|
总计({data.attributes.currency}):
|
||||||
|
</Table.Summary.Cell>
|
||||||
|
<Table.Summary.Cell index={1} align="right">
|
||||||
|
{data.attributes.totalAmount?.toLocaleString('zh-CN', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
style: 'currency',
|
||||||
|
currency: data.attributes.currency
|
||||||
|
})}
|
||||||
|
</Table.Summary.Cell>
|
||||||
|
</Table.Summary.Row>
|
||||||
|
</Table.Summary>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.attributes.description && (
|
||||||
|
<div className="bg-gray-50 p-6 rounded-lg">
|
||||||
|
<Title level={4} className="mb-4">补充说明</Title>
|
||||||
|
<Text>{data.attributes.description}</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-8 pt-8 border-t border-gray-200">
|
||||||
|
<Text type="secondary" className="block mb-2">
|
||||||
|
注意事项:
|
||||||
|
</Text>
|
||||||
|
<ul className="text-gray-500 text-sm">
|
||||||
|
<li>1. 本报价单有效期为30天</li>
|
||||||
|
<li>2. 最终解释权归本公司所有</li>
|
||||||
|
<li>3. 如有疑问请及时联系我们</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QuotationView;
|
||||||
34
src/pages/notFound/index.jsx
Normal file
34
src/pages/notFound/index.jsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button, Result } from 'antd';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
const NotFound = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<Result
|
||||||
|
status="404"
|
||||||
|
title="404"
|
||||||
|
subTitle="抱歉,您访问的页面不存在。"
|
||||||
|
extra={[
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
key="home"
|
||||||
|
onClick={() => navigate('/dashboard')}
|
||||||
|
>
|
||||||
|
返回首页
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="back"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
>
|
||||||
|
返回上一页
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotFound;
|
||||||
@@ -1,76 +1,121 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
import { supabase } from '@/config/supabase';
|
import { supabase } from "@/config/supabase";
|
||||||
import { Upload, Button, message, List, Switch, Space, Input, Tag, Pagination, Modal, Image, Popconfirm } from 'antd';
|
import {
|
||||||
import { UploadOutlined, FileTextOutlined, FileImageOutlined,
|
Upload,
|
||||||
FileMarkdownOutlined, FilePdfOutlined, FileWordOutlined,
|
Button,
|
||||||
FileExcelOutlined, InboxOutlined, SearchOutlined, EditOutlined } from '@ant-design/icons';
|
message,
|
||||||
import MonacoEditor from '@monaco-editor/react';
|
List,
|
||||||
|
Switch,
|
||||||
|
Space,
|
||||||
|
Input,
|
||||||
|
Tag,
|
||||||
|
Modal,
|
||||||
|
Image,
|
||||||
|
Popconfirm,
|
||||||
|
} from "antd";
|
||||||
|
import {
|
||||||
|
UploadOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
FileImageOutlined,
|
||||||
|
FileMarkdownOutlined,
|
||||||
|
FilePdfOutlined,
|
||||||
|
FileWordOutlined,
|
||||||
|
FileExcelOutlined,
|
||||||
|
InboxOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import MonacoEditor from "@monaco-editor/react";
|
||||||
|
import InfiniteScroll from 'react-infinite-scroll-component';
|
||||||
|
|
||||||
const { Dragger } = Upload;
|
const { Dragger } = Upload;
|
||||||
const { Search } = Input;
|
const { Search } = Input;
|
||||||
|
|
||||||
// 文件类型配置
|
// 文件类型配置
|
||||||
const FILE_TYPES = {
|
const FILE_TYPES = {
|
||||||
'图片': ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'],
|
图片: ["image/jpeg", "image/png", "image/gif", "image/svg+xml"],
|
||||||
'文档': ['application/pdf', 'application/msword',
|
文档: [
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
"application/pdf",
|
||||||
'text/plain', 'text/markdown'],
|
"application/msword",
|
||||||
'表格': ['application/vnd.ms-excel',
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
"text/plain",
|
||||||
'text/csv'],
|
"text/markdown",
|
||||||
'代码': ['text/html', 'text/javascript', 'text/css', 'application/json',
|
],
|
||||||
'text/jsx', 'text/tsx'],
|
表格: [
|
||||||
'其他': []
|
"application/vnd.ms-excel",
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
"text/csv",
|
||||||
|
],
|
||||||
|
代码: [
|
||||||
|
"text/html",
|
||||||
|
"text/javascript",
|
||||||
|
"text/css",
|
||||||
|
"application/json",
|
||||||
|
"text/jsx",
|
||||||
|
"text/tsx",
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const StorageManager = () => {
|
const StorageManager = () => {
|
||||||
const [allFiles, setAllFiles] = useState([]);
|
const [allFiles, setAllFiles] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [selectedFile, setSelectedFile] = useState(null);
|
const [selectedFile, setSelectedFile] = useState(null);
|
||||||
const [fileContent, setFileContent] = useState('');
|
const [fileContent, setFileContent] = useState("");
|
||||||
const [isPreview, setIsPreview] = useState(false);
|
const [isPreview, setIsPreview] = useState(false);
|
||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState("");
|
||||||
const [selectedType, setSelectedType] = useState('全部');
|
const [selectedType, setSelectedType] = useState("全部");
|
||||||
const [pagination, setPagination] = useState({
|
const [hasMore, setHasMore] = useState(true);
|
||||||
current: 1,
|
const [displayFiles, setDisplayFiles] = useState([]);
|
||||||
pageSize: 200,
|
const INITIAL_LOAD_SIZE = 200; // 初始加载200条
|
||||||
});
|
const LOAD_MORE_SIZE = 100; // 每次加载100条
|
||||||
const [isRenaming, setIsRenaming] = useState(false);
|
const [isRenaming, setIsRenaming] = useState(false);
|
||||||
const [newFileName, setNewFileName] = useState('');
|
const [newFileName, setNewFileName] = useState("");
|
||||||
|
|
||||||
// 文件图标映射
|
// 文件图标映射
|
||||||
const getFileIcon = (file) => {
|
const getFileIcon = (file) => {
|
||||||
const mimetype = file.metadata?.mimetype;
|
const mimetype = file.metadata?.mimetype;
|
||||||
const iconMap = {
|
const iconMap = {
|
||||||
'text/plain': <FileTextOutlined />,
|
"text/plain": <FileTextOutlined />,
|
||||||
'text/markdown': <FileMarkdownOutlined />,
|
"text/markdown": <FileMarkdownOutlined />,
|
||||||
'application/pdf': <FilePdfOutlined />,
|
"application/pdf": <FilePdfOutlined />,
|
||||||
'application/msword': <FileWordOutlined />,
|
"application/msword": <FileWordOutlined />,
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': <FileWordOutlined />,
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
|
||||||
'application/vnd.ms-excel': <FileExcelOutlined />,
|
<FileWordOutlined />,
|
||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': <FileExcelOutlined />,
|
"application/vnd.ms-excel": <FileExcelOutlined />,
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": (
|
||||||
|
<FileExcelOutlined />
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (mimetype?.startsWith('image/')) {
|
if (mimetype?.startsWith("image/")) {
|
||||||
return <FileImageOutlined />;
|
return <FileImageOutlined />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return iconMap[mimetype] || <FileTextOutlined />;
|
return iconMap[mimetype] || <FileTextOutlined />;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取所有文件
|
// 获取所有文件
|
||||||
const fetchAllFiles = async () => {
|
const fetchAllFiles = async (isInitial = true) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase.storage
|
const { data, error } = await supabase.storage
|
||||||
.from('file')
|
.from("file")
|
||||||
.list('', {
|
.list("", {
|
||||||
sortBy: { column: 'created_at', order: 'desc' } // 按创建时间倒序
|
limit: isInitial ? INITIAL_LOAD_SIZE : LOAD_MORE_SIZE,
|
||||||
|
offset: isInitial ? 0 : displayFiles.length,
|
||||||
|
sortBy: { column: "created_at", order: "desc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
setAllFiles(data || []);
|
if (isInitial) {
|
||||||
|
setDisplayFiles(data || []);
|
||||||
|
} else {
|
||||||
|
setDisplayFiles(prev => [...prev, ...(data || [])]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
setHasMore(data?.length === (isInitial ? INITIAL_LOAD_SIZE : LOAD_MORE_SIZE));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取文件列表错误:', error);
|
|
||||||
message.error(`获取文件列表失败: ${error.message}`);
|
message.error(`获取文件列表失败: ${error.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -79,50 +124,57 @@ const StorageManager = () => {
|
|||||||
|
|
||||||
// 获取文件URL
|
// 获取文件URL
|
||||||
const getFileUrl = (fileName) => {
|
const getFileUrl = (fileName) => {
|
||||||
const { data } = supabase.storage
|
const { data } = supabase.storage.from("file").getPublicUrl(fileName);
|
||||||
.from('file')
|
|
||||||
.getPublicUrl(fileName);
|
|
||||||
return data.publicUrl;
|
return data.publicUrl;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 预览文件
|
// 预览文件
|
||||||
const previewFile = async (file) => {
|
const previewFile = async (file) => {
|
||||||
try {
|
try {
|
||||||
|
// Handle PDF and other binary files
|
||||||
|
if (file.metadata?.mimetype === 'application/pdf' ||
|
||||||
|
file.metadata?.mimetype.includes('msword') ||
|
||||||
|
file.metadata?.mimetype.includes('spreadsheet')) {
|
||||||
|
window.open(getFileUrl(file.name), '_blank');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing preview logic for text files
|
||||||
const { data, error } = await supabase.storage
|
const { data, error } = await supabase.storage
|
||||||
.from('file')
|
.from("file")
|
||||||
.download(file.name);
|
.download(file.name);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
const content = await data.text();
|
const content = await data.text();
|
||||||
setSelectedFile(file);
|
setSelectedFile(file);
|
||||||
setFileContent(content);
|
setFileContent(content);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('文件预览失败');
|
message.error("文件预览失败");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 上传文件配置
|
// 上传文件配置
|
||||||
const uploadProps = {
|
const uploadProps = {
|
||||||
name: 'file',
|
name: "file",
|
||||||
multiple: true,
|
multiple: true,
|
||||||
showUploadList: false,
|
showUploadList: false,
|
||||||
customRequest: async ({ file, onSuccess, onError }) => {
|
customRequest: async ({ file, onSuccess, onError }) => {
|
||||||
try {
|
try {
|
||||||
const fileName = file.name;
|
const fileName = file.name;
|
||||||
// 检查文件是否已存在
|
// 检查文件是否存在
|
||||||
const fileExists = allFiles.some(f => f.name === fileName);
|
const fileExists = allFiles.some((f) => f.name === fileName);
|
||||||
|
|
||||||
if (fileExists) {
|
if (fileExists) {
|
||||||
throw new Error('文件已存在');
|
throw new Error("文件已存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, error } = await supabase.storage
|
const { data, error } = await supabase.storage
|
||||||
.from('file')
|
.from("file")
|
||||||
.upload(fileName, file);
|
.upload(fileName, file);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
message.success(`${fileName} 上传成功`);
|
message.success(`${fileName} 上传成功`);
|
||||||
onSuccess(data);
|
onSuccess(data);
|
||||||
fetchAllFiles();
|
fetchAllFiles();
|
||||||
@@ -134,7 +186,7 @@ const StorageManager = () => {
|
|||||||
beforeUpload: (file) => {
|
beforeUpload: (file) => {
|
||||||
const isLt50M = file.size / 1024 / 1024 < 50;
|
const isLt50M = file.size / 1024 / 1024 < 50;
|
||||||
if (!isLt50M) {
|
if (!isLt50M) {
|
||||||
message.error('文件必须小于 50MB!');
|
message.error("文件必须小于 50MB!");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -143,40 +195,55 @@ const StorageManager = () => {
|
|||||||
|
|
||||||
// 文件过滤逻辑
|
// 文件过滤逻辑
|
||||||
const filteredFiles = useMemo(() => {
|
const filteredFiles = useMemo(() => {
|
||||||
return allFiles.filter(file => {
|
return displayFiles.filter((file) => {
|
||||||
const matchesSearch = file.name.toLowerCase().includes(searchText.toLowerCase());
|
const matchesSearch = file.name
|
||||||
if (selectedType === '全部') return matchesSearch;
|
.toLowerCase()
|
||||||
|
.includes(searchText.toLowerCase());
|
||||||
|
|
||||||
|
if (selectedType === "全部") return matchesSearch;
|
||||||
|
|
||||||
const mimetype = file.metadata?.mimetype;
|
const mimetype = file.metadata?.mimetype;
|
||||||
const matchesType = FILE_TYPES[selectedType]?.includes(mimetype);
|
const matchesType = FILE_TYPES[selectedType]?.includes(mimetype);
|
||||||
|
|
||||||
return matchesSearch && matchesType;
|
return matchesSearch && matchesType;
|
||||||
});
|
});
|
||||||
}, [allFiles, searchText, selectedType]);
|
}, [displayFiles, searchText, selectedType]);
|
||||||
|
|
||||||
// 当前页的文件
|
// 当前页的文件
|
||||||
const currentPageFiles = useMemo(() => {
|
|
||||||
const { current, pageSize } = pagination;
|
|
||||||
const start = (current - 1) * pageSize;
|
|
||||||
const end = start + pageSize;
|
|
||||||
return filteredFiles.slice(start, end);
|
|
||||||
}, [filteredFiles, pagination]);
|
|
||||||
|
|
||||||
// 获取文件类型统计
|
// 获取文件类型统计
|
||||||
const typeStats = useMemo(() => {
|
const typeStats = useMemo(() => {
|
||||||
const stats = { '全部': allFiles.length };
|
const stats = { 全部: displayFiles.length };
|
||||||
Object.entries(FILE_TYPES).forEach(([type, mimetypes]) => {
|
|
||||||
stats[type] = allFiles.filter(file => {
|
// 初始化所有类型的计数为0
|
||||||
const mimetype = file.metadata?.mimetype;
|
Object.keys(FILE_TYPES).forEach(type => {
|
||||||
return mimetypes.includes(mimetype);
|
stats[type] = 0;
|
||||||
}).length;
|
|
||||||
});
|
});
|
||||||
return stats;
|
|
||||||
}, [allFiles]);
|
|
||||||
|
|
||||||
// 处理分页变化
|
// 统计每个文件的类型
|
||||||
const handlePageChange = (page, pageSize) => {
|
displayFiles.forEach((file) => {
|
||||||
setPagination({ ...pagination, current: page, pageSize });
|
const mimetype = file.metadata?.mimetype;
|
||||||
};
|
let counted = false;
|
||||||
|
|
||||||
|
// 遍历所有文件类型配置
|
||||||
|
Object.entries(FILE_TYPES).forEach(([type, mimetypes]) => {
|
||||||
|
if (mimetypes.includes(mimetype)) {
|
||||||
|
stats[type]++;
|
||||||
|
counted = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果文件类型不在预定义类型中,归类为"其他"
|
||||||
|
if (!counted) {
|
||||||
|
stats['其他']++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}, [displayFiles]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAllFiles();
|
fetchAllFiles();
|
||||||
@@ -184,34 +251,34 @@ const StorageManager = () => {
|
|||||||
|
|
||||||
// 判断是否是图片
|
// 判断是否是图片
|
||||||
const isImage = (file) => {
|
const isImage = (file) => {
|
||||||
return file.metadata?.mimetype?.startsWith('image/');
|
return file.metadata?.mimetype?.startsWith("image/");
|
||||||
};
|
};
|
||||||
|
|
||||||
// 判断是否是HTML
|
// 判断是否是HTML
|
||||||
const isHtml = (file) => {
|
const isHtml = (file) => {
|
||||||
return file.metadata?.mimetype === 'text/html';
|
return file.metadata?.mimetype === "text/html";
|
||||||
};
|
};
|
||||||
|
|
||||||
// 保存文件内容
|
// 保存文件内容
|
||||||
const handleSaveContent = async () => {
|
const handleSaveContent = async () => {
|
||||||
if (!selectedFile) return;
|
if (!selectedFile) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 创建 Blob 对象
|
// 创建 Blob 对象
|
||||||
const blob = new Blob([fileContent], { type: 'text/plain' });
|
const blob = new Blob([fileContent], { type: "text/plain" });
|
||||||
const file = new File([blob], selectedFile.name, { type: 'text/plain' });
|
const file = new File([blob], selectedFile.name, { type: "text/plain" });
|
||||||
|
|
||||||
// 上传更新后的文件
|
// 上<EFBFBD><EFBFBD>更新后的文件
|
||||||
const { error } = await supabase.storage
|
const { error } = await supabase.storage
|
||||||
.from('file')
|
.from("file")
|
||||||
.update(selectedFile.name, file);
|
.update(selectedFile.name, file);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
message.success('保存成功');
|
message.success("保存成功");
|
||||||
fetchAllFiles(); // 刷新文件列表
|
fetchAllFiles(); // 刷新文件列表
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('保存文件错误:', error);
|
console.error("保存文件错误:", error);
|
||||||
message.error(`保存失败: ${error.message}`);
|
message.error(`保存失败: ${error.message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -224,41 +291,41 @@ const StorageManager = () => {
|
|||||||
// 处理重命名
|
// 处理重命名
|
||||||
const handleRename = async () => {
|
const handleRename = async () => {
|
||||||
if (!selectedFile || !newFileName) return;
|
if (!selectedFile || !newFileName) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 检查新文件名是否已存在
|
// 检查新文件名是否已存在
|
||||||
const fileExists = allFiles.some(f => f.name === newFileName);
|
const fileExists = allFiles.some((f) => f.name === newFileName);
|
||||||
if (fileExists) {
|
if (fileExists) {
|
||||||
throw new Error('文件名已存在');
|
throw new Error("文件名已存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取原文件内容
|
// 获取原文件内容
|
||||||
const { data: fileData, error: downloadError } = await supabase.storage
|
const { data: fileData, error: downloadError } = await supabase.storage
|
||||||
.from('file')
|
.from("file")
|
||||||
.download(selectedFile.name);
|
.download(selectedFile.name);
|
||||||
|
|
||||||
if (downloadError) throw downloadError;
|
if (downloadError) throw downloadError;
|
||||||
|
|
||||||
// 创建新文件
|
// 创建新文件
|
||||||
const { error: uploadError } = await supabase.storage
|
const { error: uploadError } = await supabase.storage
|
||||||
.from('file')
|
.from("file")
|
||||||
.upload(newFileName, fileData);
|
.upload(newFileName, fileData);
|
||||||
|
|
||||||
if (uploadError) throw uploadError;
|
if (uploadError) throw uploadError;
|
||||||
|
|
||||||
// 删除旧文件
|
// 删除旧文件
|
||||||
const { error: deleteError } = await supabase.storage
|
const { error: deleteError } = await supabase.storage
|
||||||
.from('file')
|
.from("file")
|
||||||
.remove([selectedFile.name]);
|
.remove([selectedFile.name]);
|
||||||
|
|
||||||
if (deleteError) throw deleteError;
|
if (deleteError) throw deleteError;
|
||||||
|
|
||||||
message.success('重命名成功');
|
message.success("重命名成功");
|
||||||
setIsRenaming(false);
|
setIsRenaming(false);
|
||||||
setSelectedFile({ ...selectedFile, name: newFileName });
|
setSelectedFile({ ...selectedFile, name: newFileName });
|
||||||
fetchAllFiles();
|
fetchAllFiles();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('重命名错误:', error);
|
console.error("重命名错误:", error);
|
||||||
message.error(`重命名失败: ${error.message}`);
|
message.error(`重命名失败: ${error.message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -268,37 +335,169 @@ 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
|
const { error } = await supabase.storage.from("file").remove([fileName]);
|
||||||
.from('file')
|
|
||||||
.remove([fileName]);
|
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
message.success("文件删除成功");
|
||||||
message.success('文件删除成功');
|
|
||||||
fetchAllFiles();
|
fetchAllFiles();
|
||||||
if (selectedFile?.name === fileName) {
|
if (selectedFile?.name === fileName) {
|
||||||
setSelectedFile(null);
|
setSelectedFile(null);
|
||||||
setFileContent('');
|
setFileContent("");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(`删除失败: ${error.message}`);
|
message.error(`删除失败: ${error.message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 判断是否有过滤条件
|
||||||
|
const hasFilters = useMemo(() => {
|
||||||
|
return searchText !== '' || selectedType !== '全部';
|
||||||
|
}, [searchText, selectedType]);
|
||||||
|
|
||||||
|
// 加载更多数据
|
||||||
|
const loadMoreFiles = () => {
|
||||||
|
if (!hasMore || loading || hasFilters) return; // 有过滤条件时不加载更多
|
||||||
|
fetchAllFiles(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化加载
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAllFiles(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 搜索或筛选时重新加载
|
||||||
|
const handleSearch = (value) => {
|
||||||
|
setSearchText(value);
|
||||||
|
// 不需要重新调用 fetchAllFiles,因为搜索是在前端过滤
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTypeChange = (type) => {
|
||||||
|
setSelectedType(type);
|
||||||
|
// 不需要重新调用 fetchAllFiles,因为类型筛选是在前端过滤
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染文件列表
|
||||||
|
const renderFileList = () => (
|
||||||
|
<div className="flex-1 overflow-y-auto" id="scrollableDiv">
|
||||||
|
<InfiniteScroll
|
||||||
|
dataLength={displayFiles.length}
|
||||||
|
next={loadMoreFiles}
|
||||||
|
hasMore={!hasFilters && hasMore} // 只在没有过滤条件时显示加载更多
|
||||||
|
loader={
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-r-transparent" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
scrollableTarget="scrollableDiv"
|
||||||
|
endMessage={
|
||||||
|
<p className="text-center text-gray-500 py-4">
|
||||||
|
{displayFiles.length > 0
|
||||||
|
? hasFilters
|
||||||
|
? "已显示所有匹配文件"
|
||||||
|
: "已加载全部文件"
|
||||||
|
: "暂无文件"}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<List
|
||||||
|
loading={loading && displayFiles.length === 0}
|
||||||
|
dataSource={filteredFiles}
|
||||||
|
locale={{ emptyText: "暂无文件" }}
|
||||||
|
renderItem={(file) => (
|
||||||
|
<List.Item
|
||||||
|
className={`cursor-pointer hover:bg-gray-100 ${
|
||||||
|
selectedFile?.name === file.name ? "bg-blue-50" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => previewFile(file)}
|
||||||
|
actions={[
|
||||||
|
<Popconfirm
|
||||||
|
key="delete"
|
||||||
|
title="确认删除"
|
||||||
|
description={`是否确认删除文件 "${file.name}"?`}
|
||||||
|
onConfirm={(e) => {
|
||||||
|
e?.stopPropagation();
|
||||||
|
handleDelete(file.name);
|
||||||
|
}}
|
||||||
|
okText="确认"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<List.Item.Meta
|
||||||
|
avatar={
|
||||||
|
isImage(file) ? (
|
||||||
|
<Image
|
||||||
|
src={getFileUrl(file.name)}
|
||||||
|
alt={file.name}
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
className="object-cover rounded"
|
||||||
|
preview={false}
|
||||||
|
loading="lazy"
|
||||||
|
placeholder={
|
||||||
|
<div className="w-8 h-8 bg-gray-200 rounded animate-pulse" />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
getFileIcon(file)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
title={file.name}
|
||||||
|
description={
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
<span>类型: {file.metadata?.mimetype}</span>
|
||||||
|
<span className="ml-2">
|
||||||
|
大小: {(file.metadata?.size / 1024).toFixed(2)} KB
|
||||||
|
</span>
|
||||||
|
<span className="ml-2">
|
||||||
|
创建时间: {new Date(file.created_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</InfiniteScroll>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 渲染文件类型标签
|
||||||
|
const renderTypeTags = () => (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{Object.entries({ 全部: null, ...FILE_TYPES }).map(([type]) => (
|
||||||
|
<Tag.CheckableTag
|
||||||
|
key={type}
|
||||||
|
checked={selectedType === type}
|
||||||
|
onChange={(checked) => handleTypeChange(checked ? type : "全部")}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
{`${type} (${typeStats[type] || 0})`}
|
||||||
|
</Tag.CheckableTag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-gray-50">
|
<div className="flex h-screen bg-gray-50">
|
||||||
{/* 左侧文件列表 */}
|
{/* 左侧文件列表 */}
|
||||||
<div className="w-1/3 p-4 border-r border-gray-200 flex flex-col">
|
<div className="w-1/3 p-2 border-r border-gray-200 flex flex-col">
|
||||||
{/* 上传区域 */}
|
<div className="mb-4 h-[150px]">
|
||||||
<div className="mb-4">
|
<Dragger
|
||||||
<Dragger {...uploadProps} className="bg-white p-4 rounded-lg shadow-sm">
|
{...uploadProps}
|
||||||
<p className="ant-upload-drag-icon">
|
className="bg-white p-2 rounded-lg shadow-sm"
|
||||||
<InboxOutlined />
|
>
|
||||||
</p>
|
<p className="text-base">点击或者拖拽文件到此区域上传</p>
|
||||||
<p className="ant-upload-text">点击或者拖拽文件到此区域上传</p>
|
|
||||||
<p className="ant-upload-hint text-xs text-gray-500">
|
<p className="ant-upload-hint text-xs text-gray-500">
|
||||||
支持单个或批量上传,文件大小不超过50MB
|
支持单个或批量上传,文件大小不超过50MB
|
||||||
</p>
|
</p>
|
||||||
@@ -310,112 +509,17 @@ const handleDelete = async (fileName) => {
|
|||||||
<Search
|
<Search
|
||||||
placeholder="搜索文件名..."
|
placeholder="搜索文件名..."
|
||||||
allowClear
|
allowClear
|
||||||
onChange={e => {
|
onChange={(e) => {
|
||||||
setSearchText(e.target.value);
|
setSearchText(e.target.value);
|
||||||
setPagination(prev => ({ ...prev, current: 1 }));
|
|
||||||
}}
|
}}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
size="large"
|
size="large"
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-wrap gap-2">
|
{renderTypeTags()}
|
||||||
{Object.entries({ '全部': null, ...FILE_TYPES }).map(([type]) => (
|
|
||||||
<Tag.CheckableTag
|
|
||||||
key={type}
|
|
||||||
checked={selectedType === type}
|
|
||||||
onChange={checked => {
|
|
||||||
setSelectedType(checked ? type : '全部');
|
|
||||||
setPagination(prev => ({ ...prev, current: 1 }));
|
|
||||||
}}
|
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
|
||||||
{type} ({typeStats[type] || 0})
|
|
||||||
</Tag.CheckableTag>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 文件列表 */}
|
{/* 文件列表 */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
{renderFileList()}
|
||||||
<List
|
|
||||||
loading={loading}
|
|
||||||
dataSource={searchText ? filteredFiles : currentPageFiles}
|
|
||||||
locale={{ emptyText: '暂无文件' }}
|
|
||||||
renderItem={file => (
|
|
||||||
<List.Item
|
|
||||||
className={`cursor-pointer hover:bg-gray-100 ${
|
|
||||||
selectedFile?.name === file.name ? 'bg-blue-50' : ''
|
|
||||||
}`}
|
|
||||||
onClick={() => previewFile(file)}
|
|
||||||
actions={[
|
|
||||||
<Popconfirm
|
|
||||||
key="delete"
|
|
||||||
title="确认删除"
|
|
||||||
description={`是否确认删除文件 "${file.name}"?`}
|
|
||||||
onConfirm={(e) => {
|
|
||||||
e?.stopPropagation();
|
|
||||||
handleDelete(file.name);
|
|
||||||
}}
|
|
||||||
okText="确认"
|
|
||||||
cancelText="取消"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
danger
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<List.Item.Meta
|
|
||||||
avatar={
|
|
||||||
isImage(file) ? (
|
|
||||||
<Image
|
|
||||||
src={getFileUrl(file.name)}
|
|
||||||
alt={file.name}
|
|
||||||
width={32}
|
|
||||||
height={32}
|
|
||||||
className="object-cover rounded"
|
|
||||||
preview={false}
|
|
||||||
loading="lazy"
|
|
||||||
placeholder={
|
|
||||||
<div className="w-8 h-8 bg-gray-200 rounded animate-pulse" />
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : getFileIcon(file)
|
|
||||||
}
|
|
||||||
title={file.name}
|
|
||||||
description={
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
<span>类型: {file.metadata?.mimetype}</span>
|
|
||||||
<span className="ml-2">大小: {(file.metadata?.size / 1024).toFixed(2)} KB</span>
|
|
||||||
<span className="ml-2">
|
|
||||||
创建时间: {new Date(file.created_at).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</List.Item>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 分页器 - 只<><E58FAA>非搜索状态下显示 */}
|
|
||||||
{!searchText && (
|
|
||||||
<div className="mt-4 flex justify-center">
|
|
||||||
<Pagination
|
|
||||||
current={pagination.current}
|
|
||||||
pageSize={pagination.pageSize}
|
|
||||||
total={filteredFiles.length}
|
|
||||||
onChange={handlePageChange}
|
|
||||||
showSizeChanger
|
|
||||||
showQuickJumper
|
|
||||||
showTotal={(total) => `共 ${total} 个文件`}
|
|
||||||
pageSizeOptions={['10', '20', '50', '100', '200']}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 右侧预览区域 */}
|
{/* 右侧预览区域 */}
|
||||||
@@ -428,7 +532,7 @@ const handleDelete = async (fileName) => {
|
|||||||
<span className="font-medium text-lg">
|
<span className="font-medium text-lg">
|
||||||
{selectedFile.name}
|
{selectedFile.name}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
icon={<EditOutlined />}
|
icon={<EditOutlined />}
|
||||||
onClick={startRename}
|
onClick={startRename}
|
||||||
@@ -443,27 +547,24 @@ const handleDelete = async (fileName) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!isImage(selectedFile) && (
|
{!isImage(selectedFile) && (
|
||||||
<Button
|
<Button type="primary" onClick={handleSaveContent}>
|
||||||
type="primary"
|
|
||||||
onClick={handleSaveContent}
|
|
||||||
>
|
|
||||||
保存
|
保存
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
{isImage(selectedFile) ? (
|
{isImage(selectedFile) ? (
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Image
|
<Image
|
||||||
src={getFileUrl(selectedFile.name)}
|
src={getFileUrl(selectedFile.name)}
|
||||||
alt={selectedFile.name}
|
alt={selectedFile.name}
|
||||||
className="max-w-full max-h-[80vh] object-contain"
|
className="max-w-full max-h-[80vh] object-contain"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
preview={{
|
preview={{
|
||||||
toolbarRender: () => null, // 隐藏底部工具栏
|
toolbarRender: () => null, // 隐藏底部工具栏
|
||||||
maskClassName: 'backdrop-blur-sm'
|
maskClassName: "backdrop-blur-sm",
|
||||||
}}
|
}}
|
||||||
placeholder={
|
placeholder={
|
||||||
<div className="w-full h-[80vh] bg-gray-100 rounded flex items-center justify-center">
|
<div className="w-full h-[80vh] bg-gray-100 rounded flex items-center justify-center">
|
||||||
@@ -486,7 +587,7 @@ const handleDelete = async (fileName) => {
|
|||||||
value={fileContent}
|
value={fileContent}
|
||||||
onChange={handleEditorChange}
|
onChange={handleEditorChange}
|
||||||
options={{
|
options={{
|
||||||
minimap: { enabled: true }
|
minimap: { enabled: true },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -498,7 +599,7 @@ const handleDelete = async (fileName) => {
|
|||||||
value={fileContent}
|
value={fileContent}
|
||||||
onChange={handleEditorChange}
|
onChange={handleEditorChange}
|
||||||
options={{
|
options={{
|
||||||
minimap: { enabled: false }
|
minimap: { enabled: false },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -522,7 +623,7 @@ const handleDelete = async (fileName) => {
|
|||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
value={newFileName}
|
value={newFileName}
|
||||||
onChange={e => setNewFileName(e.target.value)}
|
onChange={(e) => setNewFileName(e.target.value)}
|
||||||
placeholder="请输入新文件名"
|
placeholder="请输入新文件名"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
@@ -531,5 +632,4 @@ const handleDelete = async (fileName) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default StorageManager;
|
||||||
export default StorageManager;
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Spin } from 'antd';
|
|||||||
import MainLayout from '@/components/Layout/MainLayout';
|
import MainLayout from '@/components/Layout/MainLayout';
|
||||||
import { routes } from '@/config/routes';
|
import { routes } from '@/config/routes';
|
||||||
import Login from '@/pages/auth/Login';
|
import Login from '@/pages/auth/Login';
|
||||||
import Register from '@/pages/auth/Register';
|
import NotFound from '@/pages/notFound';
|
||||||
import Dashboard from '@/pages/Dashboard';
|
import Dashboard from '@/pages/Dashboard';
|
||||||
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
@@ -14,34 +14,36 @@ const LoadingComponent = () => (
|
|||||||
<Spin size="large" />
|
<Spin size="large" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
// 这里做premeision 路由限制
|
||||||
const renderRoutes = (routes) => {
|
const renderRoutes = (routes) => {
|
||||||
return routes.map(route => {
|
return routes
|
||||||
const Component = route.component;
|
// .filter(route => !route.hidden)
|
||||||
if (route.children) {
|
.map(route => {
|
||||||
return (
|
const Component = route.component;
|
||||||
<Route key={route.path} path={route.path} element={
|
if (route.children) {
|
||||||
<Suspense fallback={<LoadingComponent />}>
|
return (
|
||||||
<Component />
|
<Route key={route.path} path={route.path} element={
|
||||||
</Suspense>
|
<Suspense fallback={<LoadingComponent />}>
|
||||||
}>
|
<Component />
|
||||||
{renderRoutes(route.children)}
|
</Suspense>
|
||||||
</Route>
|
}>
|
||||||
);
|
{renderRoutes(route.children)}
|
||||||
}
|
</Route>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Route
|
<Route
|
||||||
key={route.path}
|
key={route.path}
|
||||||
path={route.path}
|
path={route.path}
|
||||||
element={
|
element={
|
||||||
<Suspense fallback={<LoadingComponent />}>
|
<Suspense fallback={<LoadingComponent />}>
|
||||||
<Component />
|
<Component />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const AppRoutes = () => {
|
const AppRoutes = () => {
|
||||||
@@ -76,7 +78,7 @@ const AppRoutes = () => {
|
|||||||
/>
|
/>
|
||||||
{renderRoutes(routes)}
|
{renderRoutes(routes)}
|
||||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export const resourceService = {
|
|||||||
let query = supabase
|
let query = supabase
|
||||||
.from('resources')
|
.from('resources')
|
||||||
.select('*', { count: 'exact' })
|
.select('*', { count: 'exact' })
|
||||||
.eq('type','shorturl')
|
.eq('type','quota')
|
||||||
|
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
query = query.or(`external_id.ilike.%${searchQuery}%`);
|
query = query.or(`external_id.ilike.%${searchQuery}%`);
|
||||||
@@ -73,11 +73,10 @@ export const resourceService = {
|
|||||||
try {
|
try {
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from('resources')
|
.from('resources')
|
||||||
.update({
|
.delete()
|
||||||
deleted_at: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('type', 'quota');
|
.eq('type', 'quota')
|
||||||
|
.select()
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const getAntIcon = (iconName) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const generateMenuItems = (routes, parentPath = '') => {
|
const generateMenuItems = (routes, parentPath = '') => {
|
||||||
return routes.map((route) => {
|
return routes.filter(route => !route.hidden).map((route) => {
|
||||||
const fullPath = `${parentPath}/${route.path}`.replace(/\/+/g, '/');
|
const fullPath = `${parentPath}/${route.path}`.replace(/\/+/g, '/');
|
||||||
const icon = route.icon && <ColorIcon icon={getAntIcon(route.icon)} />;
|
const icon = route.icon && <ColorIcon icon={getAntIcon(route.icon)} />;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user