516 lines
17 KiB
TypeScript
516 lines
17 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
||
import { Link } from 'react-router-dom';
|
||
import {
|
||
Alert,
|
||
Button,
|
||
Descriptions,
|
||
Form,
|
||
Input,
|
||
InputNumber,
|
||
Modal,
|
||
Popconfirm,
|
||
Select,
|
||
Space,
|
||
Switch,
|
||
Table,
|
||
Tag,
|
||
Typography,
|
||
message,
|
||
} from 'antd';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import {
|
||
WAREHOUSE_FULFILLMENT_MODE_LABELS,
|
||
WAREHOUSE_MANAGER_LABELS,
|
||
WAREHOUSE_STATUS_LABELS,
|
||
WarehouseFulfillmentMode,
|
||
WarehouseManagerType,
|
||
WarehouseStatus,
|
||
type FulfillmentProviderDto,
|
||
} from '@dukang/shared-types';
|
||
import { request, type Paginated } from '../lib/api';
|
||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||
import { useAdminList } from '../lib/useAdminList';
|
||
|
||
type Row = {
|
||
id: string;
|
||
cityId: string;
|
||
cityName: string;
|
||
cityCode: string;
|
||
name: string;
|
||
address: string;
|
||
contactName: string;
|
||
contactPhone: string;
|
||
managerType: WarehouseManagerType;
|
||
partnerAccountId: string | null;
|
||
partnerCompanyName?: string | null;
|
||
status: string;
|
||
fulfillmentMode?: string;
|
||
fulfillmentProviderId?: string | null;
|
||
fulfillmentProviderName?: string | null;
|
||
manualCarrierLabel?: string | null;
|
||
manualQueryUrlTemplate?: string | null;
|
||
lng?: number | null;
|
||
lat?: number | null;
|
||
createdAt: string;
|
||
};
|
||
|
||
type CityOption = { id: string; name: string; code: string };
|
||
type PartnerOption = { id: string; companyName: string };
|
||
|
||
const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label }));
|
||
const STATUS_OPTIONS = Object.entries(WAREHOUSE_STATUS_LABELS).map(([value, label]) => ({ value, label }));
|
||
|
||
function FulfillmentFields({
|
||
mode,
|
||
providerOptions,
|
||
onModeChange,
|
||
}: {
|
||
mode: WarehouseFulfillmentMode;
|
||
providerOptions: FulfillmentProviderDto[];
|
||
onModeChange: (mode: WarehouseFulfillmentMode) => void;
|
||
}) {
|
||
const autoShip = mode === WarehouseFulfillmentMode.API_AUTO;
|
||
return (
|
||
<>
|
||
<Form.Item
|
||
name="fulfillmentMode"
|
||
label="支付后自动发货"
|
||
extra={
|
||
autoShip
|
||
? '同城订单支付成功后,自动推送仓配承运商(如小飞侠)并进入配送中'
|
||
: '支付后仅分配仓库,保持待发货,由仓管或总部手工填运单'
|
||
}
|
||
getValueProps={(v) => ({ checked: v === WarehouseFulfillmentMode.API_AUTO })}
|
||
getValueFromEvent={(checked: boolean) =>
|
||
checked ? WarehouseFulfillmentMode.API_AUTO : WarehouseFulfillmentMode.MANUAL
|
||
}
|
||
>
|
||
<Switch
|
||
checkedChildren="开"
|
||
unCheckedChildren="关"
|
||
onChange={(checked) => {
|
||
onModeChange(
|
||
checked ? WarehouseFulfillmentMode.API_AUTO : WarehouseFulfillmentMode.MANUAL,
|
||
);
|
||
}}
|
||
/>
|
||
</Form.Item>
|
||
{autoShip && (
|
||
<Form.Item
|
||
name="fulfillmentProviderId"
|
||
label="仓配承运商"
|
||
rules={[{ required: true, message: '自动发货须选择承运商' }]}
|
||
>
|
||
<Select
|
||
placeholder={providerOptions.length ? '选择已注册承运商' : '请先在仓配管理注册'}
|
||
options={providerOptions.map((p) => ({ value: p.id, label: `${p.name} (${p.code})` }))}
|
||
/>
|
||
</Form.Item>
|
||
)}
|
||
{!autoShip && (
|
||
<>
|
||
<Form.Item name="manualCarrierLabel" label="默认承运商名称">
|
||
<Input placeholder="如 顺丰速运" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="manualQueryUrlTemplate"
|
||
label="物流查询链接模板"
|
||
extra="可用 {trackingNo} 占位符"
|
||
>
|
||
<Input placeholder="https://example.com/track?no={trackingNo}" />
|
||
</Form.Item>
|
||
</>
|
||
)}
|
||
<Space>
|
||
<Form.Item name="lng" label="经度">
|
||
<InputNumber step={0.001} placeholder="API推单寄件坐标" />
|
||
</Form.Item>
|
||
<Form.Item name="lat" label="纬度">
|
||
<InputNumber step={0.001} />
|
||
</Form.Item>
|
||
</Space>
|
||
</>
|
||
);
|
||
}
|
||
|
||
export default function CityWarehousesPage() {
|
||
const [filterForm] = Form.useForm();
|
||
const [createForm] = Form.useForm();
|
||
const [editForm] = Form.useForm();
|
||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||
'/admin/city-warehouses',
|
||
() => {
|
||
const qs = new URLSearchParams();
|
||
if (filters.name) qs.set('name', filters.name);
|
||
if (filters.cityId) qs.set('cityId', filters.cityId);
|
||
if (filters.managerType) qs.set('managerType', filters.managerType);
|
||
if (filters.status) qs.set('status', filters.status);
|
||
return qs;
|
||
},
|
||
[filters],
|
||
);
|
||
const [cities, setCities] = useState<CityOption[]>([]);
|
||
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||
const [createOpen, setCreateOpen] = useState(false);
|
||
const [editOpen, setEditOpen] = useState(false);
|
||
const [editRow, setEditRow] = useState<Row | null>(null);
|
||
const [createManagerType, setCreateManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
|
||
const [editManagerType, setEditManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
|
||
const [createCityId, setCreateCityId] = useState<string | undefined>();
|
||
const [createFulfillmentMode, setCreateFulfillmentMode] = useState<WarehouseFulfillmentMode>(
|
||
WarehouseFulfillmentMode.MANUAL,
|
||
);
|
||
const [editFulfillmentMode, setEditFulfillmentMode] = useState<WarehouseFulfillmentMode>(
|
||
WarehouseFulfillmentMode.MANUAL,
|
||
);
|
||
const [providerOptions, setProviderOptions] = useState<FulfillmentProviderDto[]>([]);
|
||
|
||
const loadCities = useCallback(async () => {
|
||
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||
setCities(res.items);
|
||
}, []);
|
||
|
||
const loadPartners = useCallback(async (cityId: string) => {
|
||
const res = await request<Paginated<PartnerOption>>(
|
||
`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&cityId=${cityId}`,
|
||
);
|
||
setPartners(res.items);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void loadCities();
|
||
void request<FulfillmentProviderDto[]>('/admin/fulfillment-providers/active-api')
|
||
.then(setProviderOptions)
|
||
.catch(() => {});
|
||
}, [loadCities]);
|
||
|
||
async function openEdit(row: Row) {
|
||
setEditRow(row);
|
||
setEditManagerType(row.managerType);
|
||
await loadPartners(row.cityId);
|
||
editForm.setFieldsValue({
|
||
name: row.name,
|
||
address: row.address,
|
||
contactName: row.contactName,
|
||
contactPhone: row.contactPhone,
|
||
managerType: row.managerType,
|
||
partnerAccountId: row.partnerAccountId,
|
||
status: row.status,
|
||
fulfillmentMode: row.fulfillmentMode ?? WarehouseFulfillmentMode.MANUAL,
|
||
fulfillmentProviderId: row.fulfillmentProviderId ?? undefined,
|
||
manualCarrierLabel: row.manualCarrierLabel ?? undefined,
|
||
manualQueryUrlTemplate: row.manualQueryUrlTemplate ?? undefined,
|
||
lng: row.lng ?? undefined,
|
||
lat: row.lat ?? undefined,
|
||
});
|
||
setEditFulfillmentMode((row.fulfillmentMode as WarehouseFulfillmentMode) ?? WarehouseFulfillmentMode.MANUAL);
|
||
setEditOpen(true);
|
||
}
|
||
|
||
function onManagerTypeChange(
|
||
v: WarehouseManagerType,
|
||
form: typeof createForm,
|
||
setType: (t: WarehouseManagerType) => void,
|
||
) {
|
||
setType(v);
|
||
if (v !== WarehouseManagerType.PARTNER) {
|
||
form.setFieldValue('partnerAccountId', undefined);
|
||
}
|
||
}
|
||
|
||
const columns: ColumnsType<Row> = [
|
||
{
|
||
title: '城市',
|
||
width: 100,
|
||
render: (_, row) => (
|
||
<span title={row.cityCode}>
|
||
{row.cityName}
|
||
</span>
|
||
),
|
||
},
|
||
{ title: '仓库', dataIndex: 'name', width: 140, ellipsis: true },
|
||
{ title: '地址', dataIndex: 'address', width: 180, ellipsis: true },
|
||
{ title: '联系人', dataIndex: 'contactName', width: 90 },
|
||
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
|
||
{
|
||
title: '管仓',
|
||
width: 90,
|
||
render: (_, row) => WAREHOUSE_MANAGER_LABELS[row.managerType] || row.managerType,
|
||
},
|
||
{
|
||
title: '合伙人',
|
||
dataIndex: 'partnerCompanyName',
|
||
width: 120,
|
||
ellipsis: true,
|
||
render: (v, row) =>
|
||
v && row.partnerAccountId ? (
|
||
<Link to="/city-partners">{v}</Link>
|
||
) : (
|
||
'—'
|
||
),
|
||
},
|
||
{
|
||
title: '自动发货',
|
||
width: 130,
|
||
render: (_, row) =>
|
||
row.fulfillmentMode === 'API_AUTO' ? (
|
||
<Tag color="blue">{row.fulfillmentProviderName || '自动'}</Tag>
|
||
) : (
|
||
<Tag>{WAREHOUSE_FULFILLMENT_MODE_LABELS[WarehouseFulfillmentMode.MANUAL]}</Tag>
|
||
),
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
width: 80,
|
||
render: (s) => (
|
||
<Tag color={s === WarehouseStatus.ACTIVE ? 'green' : 'default'}>
|
||
{WAREHOUSE_STATUS_LABELS[s as WarehouseStatus] || s}
|
||
</Tag>
|
||
),
|
||
},
|
||
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
||
{
|
||
title: '操作',
|
||
width: 120,
|
||
fixed: 'right',
|
||
render: (_, row) => (
|
||
<Space size={0}>
|
||
<Button type="link" size="small" onClick={() => void openEdit(row)}>
|
||
编辑
|
||
</Button>
|
||
<Popconfirm
|
||
title="确定删除该仓库?"
|
||
description="删除后将解除关联合伙人的管仓绑定"
|
||
onConfirm={async () => {
|
||
await request(`/admin/city-warehouses/${row.id}`, { method: 'DELETE' });
|
||
message.success('已删除');
|
||
void reload();
|
||
}}
|
||
>
|
||
<Button type="link" size="small" danger>
|
||
删除
|
||
</Button>
|
||
</Popconfirm>
|
||
</Space>
|
||
),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div>
|
||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||
<Typography.Title level={4} style={{ margin: 0 }}>仓库</Typography.Title>
|
||
<Button
|
||
type="primary"
|
||
onClick={() => {
|
||
createForm.resetFields();
|
||
setCreateManagerType(WarehouseManagerType.HQ);
|
||
setCreateCityId(undefined);
|
||
setCreateFulfillmentMode(WarehouseFulfillmentMode.MANUAL);
|
||
createForm.setFieldsValue({
|
||
managerType: WarehouseManagerType.HQ,
|
||
status: WarehouseStatus.ACTIVE,
|
||
fulfillmentMode: WarehouseFulfillmentMode.MANUAL,
|
||
});
|
||
setCreateOpen(true);
|
||
}}
|
||
>
|
||
新增仓库
|
||
</Button>
|
||
</Space>
|
||
|
||
<Alert
|
||
type="info"
|
||
showIcon
|
||
style={{ marginBottom: 16 }}
|
||
message="同城有仓订单:支付后按「自动发货」开关决定是否推仓配 API;关闭则待人工填单。跨城/无仓仍走总部快递填单。"
|
||
/>
|
||
|
||
<Form
|
||
form={filterForm}
|
||
layout="inline"
|
||
style={{ marginBottom: 16 }}
|
||
onFinish={(v) => {
|
||
setFilters(v);
|
||
setPage(1);
|
||
}}
|
||
>
|
||
<Form.Item name="name" label="仓库名">
|
||
<Input allowClear />
|
||
</Form.Item>
|
||
<Form.Item name="cityId" label="城市">
|
||
<Select
|
||
allowClear
|
||
showSearch
|
||
optionFilterProp="label"
|
||
style={{ width: 140 }}
|
||
options={cities.map((c) => ({ value: c.id, label: c.name }))}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="managerType" label="管仓">
|
||
<Select allowClear style={{ width: 120 }} options={MANAGER_OPTIONS} />
|
||
</Form.Item>
|
||
<Form.Item name="status" label="状态">
|
||
<Select allowClear style={{ width: 100 }} options={STATUS_OPTIONS} />
|
||
</Form.Item>
|
||
<Form.Item>
|
||
<Button type="primary" htmlType="submit">
|
||
查询
|
||
</Button>
|
||
</Form.Item>
|
||
</Form>
|
||
|
||
<Table
|
||
rowKey="id"
|
||
loading={loading}
|
||
columns={columns}
|
||
dataSource={data?.items ?? []}
|
||
scroll={{ x: 1400 }}
|
||
pagination={{
|
||
current: page,
|
||
pageSize,
|
||
total: data?.total ?? 0,
|
||
showSizeChanger: true,
|
||
onChange: (p, ps) => {
|
||
setPage(p);
|
||
setPageSize(ps);
|
||
},
|
||
}}
|
||
/>
|
||
|
||
<Modal
|
||
title="新增仓库"
|
||
open={createOpen}
|
||
width={520}
|
||
onCancel={() => setCreateOpen(false)}
|
||
onOk={async () => {
|
||
const v = await createForm.validateFields();
|
||
await request(`/admin/cities/${v.cityId}/warehouses`, {
|
||
method: 'POST',
|
||
body: JSON.stringify(v),
|
||
});
|
||
message.success('已创建');
|
||
setCreateOpen(false);
|
||
void reload();
|
||
}}
|
||
>
|
||
<Form form={createForm} layout="vertical">
|
||
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
|
||
<Select
|
||
showSearch
|
||
optionFilterProp="label"
|
||
options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))}
|
||
onChange={(id) => {
|
||
setCreateCityId(id);
|
||
createForm.setFieldValue('partnerAccountId', undefined);
|
||
void loadPartners(id);
|
||
}}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="name" label="仓库名" rules={[{ required: true }]}>
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item name="address" label="地址" rules={[{ required: true }]}>
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item name="contactName" label="联系人" rules={[{ required: true }]}>
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item name="contactPhone" label="联系电话" rules={[{ required: true }]}>
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item name="managerType" label="管仓类型" rules={[{ required: true }]}>
|
||
<Select
|
||
options={MANAGER_OPTIONS}
|
||
onChange={(v) => onManagerTypeChange(v, createForm, setCreateManagerType)}
|
||
/>
|
||
</Form.Item>
|
||
{createManagerType === WarehouseManagerType.PARTNER && createCityId && (
|
||
<Form.Item name="partnerAccountId" label="管仓合伙人" rules={[{ required: true }]}>
|
||
<Select
|
||
showSearch
|
||
optionFilterProp="label"
|
||
placeholder={partners.length ? '选择合伙人' : '该城市暂无合伙人'}
|
||
options={partners.map((p) => ({ value: p.id, label: p.companyName }))}
|
||
/>
|
||
</Form.Item>
|
||
)}
|
||
<Form.Item name="status" label="状态" initialValue={WarehouseStatus.ACTIVE}>
|
||
<Select options={STATUS_OPTIONS} />
|
||
</Form.Item>
|
||
<FulfillmentFields
|
||
mode={createFulfillmentMode}
|
||
providerOptions={providerOptions}
|
||
onModeChange={setCreateFulfillmentMode}
|
||
/>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title="编辑仓库"
|
||
open={editOpen}
|
||
width={520}
|
||
onCancel={() => setEditOpen(false)}
|
||
onOk={async () => {
|
||
if (!editRow) return;
|
||
const v = await editForm.validateFields();
|
||
await request(`/admin/city-warehouses/${editRow.id}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify(v),
|
||
});
|
||
message.success('已更新');
|
||
setEditOpen(false);
|
||
void reload();
|
||
}}
|
||
>
|
||
{editRow && (
|
||
<Descriptions column={1} size="small" bordered style={{ marginBottom: 16 }}>
|
||
<Descriptions.Item label="所属城市">
|
||
{editRow.cityName}({editRow.cityCode})
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
)}
|
||
<Form form={editForm} layout="vertical">
|
||
<Form.Item name="name" label="仓库名" rules={[{ required: true }]}>
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item name="address" label="地址" rules={[{ required: true }]}>
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item name="contactName" label="联系人" rules={[{ required: true }]}>
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item name="contactPhone" label="联系电话" rules={[{ required: true }]}>
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item name="managerType" label="管仓类型" rules={[{ required: true }]}>
|
||
<Select
|
||
options={MANAGER_OPTIONS}
|
||
onChange={(v) => onManagerTypeChange(v, editForm, setEditManagerType)}
|
||
/>
|
||
</Form.Item>
|
||
{editManagerType === WarehouseManagerType.PARTNER && editRow && (
|
||
<Form.Item name="partnerAccountId" label="管仓合伙人" rules={[{ required: true }]}>
|
||
<Select
|
||
showSearch
|
||
optionFilterProp="label"
|
||
options={partners.map((p) => ({ value: p.id, label: p.companyName }))}
|
||
/>
|
||
</Form.Item>
|
||
)}
|
||
<Form.Item name="status" label="状态">
|
||
<Select options={STATUS_OPTIONS} />
|
||
</Form.Item>
|
||
<FulfillmentFields
|
||
mode={editFulfillmentMode}
|
||
providerOptions={providerOptions}
|
||
onModeChange={setEditFulfillmentMode}
|
||
/>
|
||
</Form>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
}
|