报价单魔魁

This commit is contained in:
‘Liammcl’
2024-12-18 02:01:29 +08:00
parent 585c9b7bf8
commit 9b4a7f5fd8
14 changed files with 1326 additions and 390 deletions

View File

@@ -14,8 +14,10 @@
"@monaco-editor/react": "^4.6.0",
"@supabase/supabase-js": "^2.38.4",
"antd": "^5.11.0",
"lodash": "^4.17.21",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-infinite-scroll-component": "^6.1.0",
"react-router-dom": "^6.18.0",
"recharts": "^2.9.0",
"styled-components": "^6.1.0"

22
pnpm-lock.yaml generated
View File

@@ -20,12 +20,18 @@ importers:
antd:
specifier: ^5.11.0
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:
specifier: ^18.2.0
version: 18.3.1
react-dom:
specifier: ^18.2.0
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:
specifier: ^6.18.0
version: 6.28.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -1884,6 +1890,11 @@ packages:
peerDependencies:
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:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -2139,6 +2150,10 @@ packages:
thenify@3.3.1:
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:
resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==}
engines: {node: '>=12.22'}
@@ -4369,6 +4384,11 @@ snapshots:
react: 18.3.1
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@18.3.1: {}
@@ -4713,6 +4733,8 @@ snapshots:
dependencies:
any-promise: 1.3.0
throttle-debounce@2.3.0: {}
throttle-debounce@5.0.2: {}
tiny-invariant@1.3.3: {}

View File

@@ -12,19 +12,22 @@ const MainLayout = () => {
const { isDarkMode } = useTheme();
return (
<Layout style={{ minHeight: '100vh' }}>
<Layout className="min-h-screen">
<Sidebar collapsed={collapsed} />
<Layout>
<Layout className="flex flex-col h-screen">
<Header collapsed={collapsed} setCollapsed={setCollapsed} />
<Content
style={{
margin: '24px 16px',
padding: 24,
background: isDarkMode ? '#141414' : '#fff',
borderRadius: '4px',
}}
className={`
m-2 p-4 rounded-lg overflow-auto
${isDarkMode ? 'bg-[#141414]' : 'bg-white'}
flex-1
`}
>
<Suspense fallback={<Spin size="large" />}>
<Suspense fallback={
<div className="flex items-center justify-center h-full">
<Spin size="large" />
</div>
}>
<Outlet />
</Suspense>
</Content>

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

View File

@@ -31,6 +31,19 @@ const companyRoutes = [
component: lazy(() => import('@/pages/company/quotation')),
name: '报价单',
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',

View File

@@ -37,8 +37,8 @@ export const useResources = (initialPagination, initialSorter) => {
return { data, total: newTotal };
} catch (error) {
console.error('获取资源列表失败:', error);
message.error('获取资源列表失败');
console.error('获取列表失败:', error);
message.error('获取列表失败');
} finally {
setLoading(false);
}
@@ -48,10 +48,10 @@ export const useResources = (initialPagination, initialSorter) => {
try {
const newResource = await resourceService.createResource(values);
await fetchResources({ current: 1 });
message.success('创建资源成功');
message.success('创建成功');
return newResource;
} catch (error) {
message.error('创建资源失败');
message.error('创建失败');
throw error;
}
};
@@ -60,10 +60,10 @@ export const useResources = (initialPagination, initialSorter) => {
try {
const updatedResource = await resourceService.updateResource(id, values);
await fetchResources({ current: currentPagination.current });
message.success('更新资源成功');
message.success('更新成功');
return updatedResource;
} catch (error) {
message.error('更新资源失败');
message.error('更新失败');
throw error;
}
};
@@ -75,9 +75,9 @@ export const useResources = (initialPagination, initialSorter) => {
? currentPagination.current - 1
: currentPagination.current;
await fetchResources({ current: newCurrent });
message.success('删除资源成功');
message.success('删除成功');
} catch (error) {
message.error('删除资源失败');
message.error('删除失败');
throw error;
}
};

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

View File

@@ -1,12 +1,12 @@
import React, { useEffect, useState } from 'react';
import { Card, Table, Button, Modal, Form, Input, message, Popconfirm } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { Card, Table, Button, message, Popconfirm, Tag, Space, Tooltip } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons';
import { useResources } from '@/hooks/resource/useResource';
import { useNavigate } from 'react-router-dom';
import { formatCurrency } from '@/utils/format'; // 假设你有这个工具函数,如果没有我会提供
const QuotationPage = () => {
const [form] = Form.useForm();
const [visible, setVisible] = useState(false);
const [editingId, setEditingId] = useState(null);
const navigate = useNavigate();
const [pagination, setPagination] = useState({ current: 1, pageSize: 10 });
const [sorter, setSorter] = useState({ field: 'created_at', order: 'descend' });
@@ -15,8 +15,6 @@ const QuotationPage = () => {
loading,
total,
fetchResources: fetchQuotations,
createResource: createQuotation,
updateResource: updateQuotation,
deleteResource: deleteQuotation
} = 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) => {
try {
await deleteQuotation(id);
message.success('删除成功');
fetchQuotations();
} catch (error) {
console.error('删除失败:', error);
message.error('删除失败' + error.message);
}
};
const columns = [
{
title: '报价单',
dataIndex: ['external_id'],
key: 'external_id',
sorter: true,
title: '报价单名称',
dataIndex: ['attributes', 'quataName'],
key: 'quataName',
ellipsis: true,
},
{
title: '客户名称',
dataIndex: ['attributes', 'customerName'],
key: 'customerName',
title: '客户信息',
dataIndex: ['attributes', 'companyName'],
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: '创建日期',
dataIndex: 'created_at',
key: 'created_at',
sorter: true,
render: (text) => new Date(text).toLocaleString(),
},
{
title: '状态',
dataIndex: ['attributes', 'status'],
key: 'status',
width: 180,
render: (text) => (
<span>{new Date(text).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})}</span>
),
},
{
title: '操作',
key: 'action',
width:180,
render: (_, record) => (
<span>
<Space size="mini">
<Button
size='mini'
type="link"
onClick={() => {
setEditingId(record.id);
form.setFieldsValue({
id: record.external_id,
customerName: record.attributes?.customerName,
status: record.attributes?.status,
});
setVisible(true);
}}
icon={<EyeOutlined />}
onClick={() => navigate(`/company/quotaInfo/preview/${record.id}`)}
>
查看
</Button>
<Button
size='mini'
type="link"
icon={<EditOutlined />}
onClick={() => navigate(`/company/quotaInfo/${record.id}?edit=true`)}
>
编辑
</Button>
<Popconfirm
title="确定要删除这个报价单吗?"
description="删除后将无法恢复!"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Button type="link" danger>
<Button size='mini' type="link" danger icon={<DeleteOutlined />}>
删除
</Button>
</Popconfirm>
</span>
</Space>
),
},
];
return (
<Card
title="报价单管理"
title={
<Space>
<span>报价单管理</span>
<Tag color="blue">{total} 个报价单</Tag>
</Space>
}
className='h-full w-full overflow-auto'
extra={
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setEditingId(null);
form.resetFields();
setVisible(true);
}}
onClick={() => navigate('/company/quotaInfo')}
>
新增报价单
</Button>
@@ -152,55 +160,11 @@ const QuotationPage = () => {
pagination={{
...pagination,
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>
);
};

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

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

View File

@@ -1,76 +1,121 @@
import React, { useState, useEffect, useMemo } from 'react';
import { supabase } from '@/config/supabase';
import { Upload, Button, message, List, Switch, Space, Input, Tag, Pagination, 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 React, { useState, useEffect, useMemo } from "react";
import { supabase } from "@/config/supabase";
import {
Upload,
Button,
message,
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 { Search } = Input;
// 文件类型配置
const FILE_TYPES = {
'图片': ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'],
'文档': ['application/pdf', 'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain', 'text/markdown'],
'表格': ['application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/csv'],
'代码': ['text/html', 'text/javascript', 'text/css', 'application/json',
'text/jsx', 'text/tsx'],
'其他': []
图片: ["image/jpeg", "image/png", "image/gif", "image/svg+xml"],
文档: [
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"text/plain",
"text/markdown",
],
表格: [
"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 [allFiles, setAllFiles] = useState([]);
const [loading, setLoading] = useState(false);
const [selectedFile, setSelectedFile] = useState(null);
const [fileContent, setFileContent] = useState('');
const [fileContent, setFileContent] = useState("");
const [isPreview, setIsPreview] = useState(false);
const [searchText, setSearchText] = useState('');
const [selectedType, setSelectedType] = useState('全部');
const [pagination, setPagination] = useState({
current: 1,
pageSize: 200,
});
const [searchText, setSearchText] = useState("");
const [selectedType, setSelectedType] = useState("全部");
const [hasMore, setHasMore] = useState(true);
const [displayFiles, setDisplayFiles] = useState([]);
const INITIAL_LOAD_SIZE = 200; // 初始加载200条
const LOAD_MORE_SIZE = 100; // 每次加载100条
const [isRenaming, setIsRenaming] = useState(false);
const [newFileName, setNewFileName] = useState('');
const [newFileName, setNewFileName] = useState("");
// 文件图标映射
const getFileIcon = (file) => {
const mimetype = file.metadata?.mimetype;
const iconMap = {
'text/plain': <FileTextOutlined />,
'text/markdown': <FileMarkdownOutlined />,
'application/pdf': <FilePdfOutlined />,
'application/msword': <FileWordOutlined />,
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': <FileWordOutlined />,
'application/vnd.ms-excel': <FileExcelOutlined />,
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': <FileExcelOutlined />,
"text/plain": <FileTextOutlined />,
"text/markdown": <FileMarkdownOutlined />,
"application/pdf": <FilePdfOutlined />,
"application/msword": <FileWordOutlined />,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
<FileWordOutlined />,
"application/vnd.ms-excel": <FileExcelOutlined />,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": (
<FileExcelOutlined />
),
};
if (mimetype?.startsWith('image/')) {
if (mimetype?.startsWith("image/")) {
return <FileImageOutlined />;
}
return iconMap[mimetype] || <FileTextOutlined />;
};
// 获取所有文件
const fetchAllFiles = async () => {
const fetchAllFiles = async (isInitial = true) => {
setLoading(true);
try {
const { data, error } = await supabase.storage
.from('file')
.list('', {
sortBy: { column: 'created_at', order: 'desc' } // 按创建时间倒序
.from("file")
.list("", {
limit: isInitial ? INITIAL_LOAD_SIZE : LOAD_MORE_SIZE,
offset: isInitial ? 0 : displayFiles.length,
sortBy: { column: "created_at", order: "desc" },
});
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) {
console.error('获取文件列表错误:', error);
message.error(`获取文件列表失败: ${error.message}`);
} finally {
setLoading(false);
@@ -79,50 +124,57 @@ const StorageManager = () => {
// 获取文件URL
const getFileUrl = (fileName) => {
const { data } = supabase.storage
.from('file')
.getPublicUrl(fileName);
const { data } = supabase.storage.from("file").getPublicUrl(fileName);
return data.publicUrl;
};
// 预览文件
const previewFile = async (file) => {
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
.from('file')
.from("file")
.download(file.name);
if (error) throw error;
const content = await data.text();
setSelectedFile(file);
setFileContent(content);
} catch (error) {
message.error('文件预览失败');
message.error("文件预览失败");
}
};
// 上传文件配置
const uploadProps = {
name: 'file',
name: "file",
multiple: true,
showUploadList: false,
customRequest: async ({ file, onSuccess, onError }) => {
try {
const fileName = file.name;
// 检查文件是否存在
const fileExists = allFiles.some(f => f.name === fileName);
// 检查文件是否存在
const fileExists = allFiles.some((f) => f.name === fileName);
if (fileExists) {
throw new Error('文件已存在');
throw new Error("文件已存在");
}
const { data, error } = await supabase.storage
.from('file')
.from("file")
.upload(fileName, file);
if (error) throw error;
message.success(`${fileName} 上传成功`);
onSuccess(data);
fetchAllFiles();
@@ -134,7 +186,7 @@ const StorageManager = () => {
beforeUpload: (file) => {
const isLt50M = file.size / 1024 / 1024 < 50;
if (!isLt50M) {
message.error('文件必须小于 50MB!');
message.error("文件必须小于 50MB!");
return false;
}
return true;
@@ -143,40 +195,55 @@ const StorageManager = () => {
// 文件过滤逻辑
const filteredFiles = useMemo(() => {
return allFiles.filter(file => {
const matchesSearch = file.name.toLowerCase().includes(searchText.toLowerCase());
if (selectedType === '全部') return matchesSearch;
return displayFiles.filter((file) => {
const matchesSearch = file.name
.toLowerCase()
.includes(searchText.toLowerCase());
if (selectedType === "全部") return matchesSearch;
const mimetype = file.metadata?.mimetype;
const matchesType = FILE_TYPES[selectedType]?.includes(mimetype);
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 stats = { '全部': allFiles.length };
Object.entries(FILE_TYPES).forEach(([type, mimetypes]) => {
stats[type] = allFiles.filter(file => {
const mimetype = file.metadata?.mimetype;
return mimetypes.includes(mimetype);
}).length;
const stats = { 全部: displayFiles.length };
// 初始化所有类型的计数为0
Object.keys(FILE_TYPES).forEach(type => {
stats[type] = 0;
});
return stats;
}, [allFiles]);
// 处理分页变化
const handlePageChange = (page, pageSize) => {
setPagination({ ...pagination, current: page, pageSize });
};
// 统计每个文件的类型
displayFiles.forEach((file) => {
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(() => {
fetchAllFiles();
@@ -184,34 +251,34 @@ const StorageManager = () => {
// 判断是否是图片
const isImage = (file) => {
return file.metadata?.mimetype?.startsWith('image/');
return file.metadata?.mimetype?.startsWith("image/");
};
// 判断是否是HTML
const isHtml = (file) => {
return file.metadata?.mimetype === 'text/html';
return file.metadata?.mimetype === "text/html";
};
// 保存文件内容
const handleSaveContent = async () => {
if (!selectedFile) return;
try {
// 创建 Blob 对象
const blob = new Blob([fileContent], { type: 'text/plain' });
const file = new File([blob], selectedFile.name, { type: 'text/plain' });
const blob = new Blob([fileContent], { type: "text/plain" });
const file = new File([blob], selectedFile.name, { type: "text/plain" });
// 上更新后的文件
// 上<EFBFBD><EFBFBD>更新后的文件
const { error } = await supabase.storage
.from('file')
.from("file")
.update(selectedFile.name, file);
if (error) throw error;
message.success('保存成功');
message.success("保存成功");
fetchAllFiles(); // 刷新文件列表
} catch (error) {
console.error('保存文件错误:', error);
console.error("保存文件错误:", error);
message.error(`保存失败: ${error.message}`);
}
};
@@ -224,41 +291,41 @@ const StorageManager = () => {
// 处理重命名
const handleRename = async () => {
if (!selectedFile || !newFileName) return;
try {
// 检查新文件名是否已存在
const fileExists = allFiles.some(f => f.name === newFileName);
const fileExists = allFiles.some((f) => f.name === newFileName);
if (fileExists) {
throw new Error('文件名已存在');
throw new Error("文件名已存在");
}
// 获取原文件内容
const { data: fileData, error: downloadError } = await supabase.storage
.from('file')
.from("file")
.download(selectedFile.name);
if (downloadError) throw downloadError;
// 创建新文件
const { error: uploadError } = await supabase.storage
.from('file')
.from("file")
.upload(newFileName, fileData);
if (uploadError) throw uploadError;
// 删除旧文件
const { error: deleteError } = await supabase.storage
.from('file')
.from("file")
.remove([selectedFile.name]);
if (deleteError) throw deleteError;
message.success('重命名成功');
message.success("重命名成功");
setIsRenaming(false);
setSelectedFile({ ...selectedFile, name: newFileName });
fetchAllFiles();
} catch (error) {
console.error('重命名错误:', error);
console.error("重命名错误:", error);
message.error(`重命名失败: ${error.message}`);
}
};
@@ -268,37 +335,169 @@ const StorageManager = () => {
setNewFileName(selectedFile.name);
setIsRenaming(true);
};
// 添加文件删除功能
const handleDelete = async (fileName) => {
// 添加文件删除功能
const handleDelete = async (fileName) => {
try {
const { error } = await supabase.storage
.from('file')
.remove([fileName]);
const { error } = await supabase.storage.from("file").remove([fileName]);
if (error) throw error;
message.success('文件删除成功');
message.success("文件删除成功");
fetchAllFiles();
if (selectedFile?.name === fileName) {
setSelectedFile(null);
setFileContent('');
setFileContent("");
}
} catch (error) {
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 (
<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="mb-4">
<Dragger {...uploadProps} className="bg-white p-4 rounded-lg shadow-sm">
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text">点击或者拖拽文件到此区域上传</p>
<div className="w-1/3 p-2 border-r border-gray-200 flex flex-col">
<div className="mb-4 h-[150px]">
<Dragger
{...uploadProps}
className="bg-white p-2 rounded-lg shadow-sm"
>
<p className="text-base">点击或者拖拽文件到此区域上传</p>
<p className="ant-upload-hint text-xs text-gray-500">
支持单个或批量上传文件大小不超过50MB
</p>
@@ -310,112 +509,17 @@ const handleDelete = async (fileName) => {
<Search
placeholder="搜索文件名..."
allowClear
onChange={e => {
onChange={(e) => {
setSearchText(e.target.value);
setPagination(prev => ({ ...prev, current: 1 }));
}}
className="w-full"
size="large"
/>
<div className="flex flex-wrap gap-2">
{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>
{renderTypeTags()}
</div>
{/* 文件列表 */}
<div className="flex-1 overflow-y-auto">
<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>
{renderFileList()}
</div>
{/* 右侧预览区域 */}
@@ -428,7 +532,7 @@ const handleDelete = async (fileName) => {
<span className="font-medium text-lg">
{selectedFile.name}
</span>
<Button
<Button
type="text"
icon={<EditOutlined />}
onClick={startRename}
@@ -443,27 +547,24 @@ const handleDelete = async (fileName) => {
/>
)}
{!isImage(selectedFile) && (
<Button
type="primary"
onClick={handleSaveContent}
>
<Button type="primary" onClick={handleSaveContent}>
保存
</Button>
)}
</Space>
</div>
<div className="mt-4">
{isImage(selectedFile) ? (
<div className="flex justify-center">
<Image
src={getFileUrl(selectedFile.name)}
<Image
src={getFileUrl(selectedFile.name)}
alt={selectedFile.name}
className="max-w-full max-h-[80vh] object-contain"
loading="lazy"
preview={{
preview={{
toolbarRender: () => null, // 隐藏底部工具栏
maskClassName: 'backdrop-blur-sm'
maskClassName: "backdrop-blur-sm",
}}
placeholder={
<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}
onChange={handleEditorChange}
options={{
minimap: { enabled: true }
minimap: { enabled: true },
}}
/>
)
@@ -498,7 +599,7 @@ const handleDelete = async (fileName) => {
value={fileContent}
onChange={handleEditorChange}
options={{
minimap: { enabled: false }
minimap: { enabled: false },
}}
/>
)}
@@ -522,7 +623,7 @@ const handleDelete = async (fileName) => {
>
<Input
value={newFileName}
onChange={e => setNewFileName(e.target.value)}
onChange={(e) => setNewFileName(e.target.value)}
placeholder="请输入新文件名"
autoFocus
/>
@@ -531,5 +632,4 @@ const handleDelete = async (fileName) => {
);
};
export default StorageManager;
export default StorageManager;

View File

@@ -4,7 +4,7 @@ import { Spin } from 'antd';
import MainLayout from '@/components/Layout/MainLayout';
import { routes } from '@/config/routes';
import Login from '@/pages/auth/Login';
import Register from '@/pages/auth/Register';
import NotFound from '@/pages/notFound';
import Dashboard from '@/pages/Dashboard';
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
import { useAuth } from '@/contexts/AuthContext';
@@ -14,34 +14,36 @@ const LoadingComponent = () => (
<Spin size="large" />
</div>
);
// 这里做premeision 路由限制
const renderRoutes = (routes) => {
return routes.map(route => {
const Component = route.component;
if (route.children) {
return (
<Route key={route.path} path={route.path} element={
<Suspense fallback={<LoadingComponent />}>
<Component />
</Suspense>
}>
{renderRoutes(route.children)}
</Route>
);
}
return routes
// .filter(route => !route.hidden)
.map(route => {
const Component = route.component;
if (route.children) {
return (
<Route key={route.path} path={route.path} element={
<Suspense fallback={<LoadingComponent />}>
<Component />
</Suspense>
}>
{renderRoutes(route.children)}
</Route>
);
}
return (
<Route
key={route.path}
path={route.path}
element={
<Suspense fallback={<LoadingComponent />}>
<Component />
</Suspense>
}
/>
);
});
return (
<Route
key={route.path}
path={route.path}
element={
<Suspense fallback={<LoadingComponent />}>
<Component />
</Suspense>
}
/>
);
});
};
const AppRoutes = () => {
@@ -76,7 +78,7 @@ const AppRoutes = () => {
/>
{renderRoutes(routes)}
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
);

View File

@@ -6,7 +6,7 @@ export const resourceService = {
let query = supabase
.from('resources')
.select('*', { count: 'exact' })
.eq('type','shorturl')
.eq('type','quota')
if (searchQuery) {
query = query.or(`external_id.ilike.%${searchQuery}%`);
@@ -73,11 +73,10 @@ export const resourceService = {
try {
const { error } = await supabase
.from('resources')
.update({
deleted_at: new Date().toISOString(),
})
.delete()
.eq('id', id)
.eq('type', 'quota');
.eq('type', 'quota')
.select()
if (error) throw error;
return true;

View File

@@ -13,7 +13,7 @@ const getAntIcon = (iconName) => {
};
const generateMenuItems = (routes, parentPath = '') => {
return routes.map((route) => {
return routes.filter(route => !route.hidden).map((route) => {
const fullPath = `${parentPath}/${route.path}`.replace(/\/+/g, '/');
const icon = route.icon && <ColorIcon icon={getAntIcon(route.icon)} />;