797b20979d
承运商 HTML 按收货市下发;textarea 回车在 C 端转成换行。含门店去掉分享按钮与小程序企微客服。 Co-authored-by: Cursor <cursoragent@cursor.com>
424 lines
14 KiB
TypeScript
424 lines
14 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
Alert,
|
||
Button,
|
||
Divider,
|
||
Form,
|
||
Input,
|
||
InputNumber,
|
||
Modal,
|
||
Popconfirm,
|
||
Select,
|
||
Space,
|
||
Table,
|
||
Tag,
|
||
message,
|
||
} from 'antd';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import {
|
||
DEFAULT_XFX_LOGISTICS_PRICING,
|
||
FULFILLMENT_PROVIDER_STATUS_LABELS,
|
||
FULFILLMENT_PROVIDER_TYPE_LABELS,
|
||
FulfillmentProviderStatus,
|
||
FulfillmentProviderType,
|
||
LOGISTICS_SETTLEMENT_METHOD_LABELS,
|
||
LogisticsSettlementMethod,
|
||
isXfxProviderCode,
|
||
type FulfillmentProviderDto,
|
||
} from '@dukang/shared-types';
|
||
import { request } from '../lib/api';
|
||
import { fmtTime } from '../lib/constants';
|
||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||
import { AdminListHeader } from '../components/AdminListHeader';
|
||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||
|
||
|
||
const TYPE_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_TYPE_LABELS).map(([value, label]) => ({
|
||
value,
|
||
label,
|
||
}));
|
||
const STATUS_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_STATUS_LABELS).map(([value, label]) => ({
|
||
value,
|
||
label,
|
||
}));
|
||
const SETTLEMENT_OPTIONS = Object.entries(LOGISTICS_SETTLEMENT_METHOD_LABELS).map(
|
||
([value, label]) => ({ value, label }),
|
||
);
|
||
const SIGN_OPTIONS = [
|
||
{ value: 'MD5', label: 'MD5' },
|
||
{ value: 'HMAC-SHA256', label: 'HMAC-SHA256' },
|
||
];
|
||
|
||
const DEFAULT_XFX_API_URL = 'https://beta.51xiaoju.cn/app/api/interface.do';
|
||
|
||
export default function FulfillmentProvidersPage() {
|
||
const [rows, setRows] = useState<FulfillmentProviderDto[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [open, setOpen] = useState(false);
|
||
const [editRow, setEditRow] = useState<FulfillmentProviderDto | null>(null);
|
||
const [form] = Form.useForm();
|
||
const watchedCode = Form.useWatch('code', form);
|
||
const watchedType = Form.useWatch('type', form);
|
||
|
||
const showXfxFields = useMemo(
|
||
() => isXfxProviderCode(String(watchedCode || '')) && watchedType === FulfillmentProviderType.API,
|
||
[watchedCode, watchedType],
|
||
);
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const res = await request<FulfillmentProviderDto[]>('/admin/fulfillment-providers');
|
||
setRows(Array.isArray(res) ? res : []);
|
||
} catch (e) {
|
||
setRows([]);
|
||
message.error(e instanceof Error ? e.message : '加载承运商失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void load();
|
||
}, [load]);
|
||
|
||
function openCreate() {
|
||
setEditRow(null);
|
||
form.resetFields();
|
||
form.setFieldsValue({
|
||
code: 'XFX',
|
||
name: '小飞侠',
|
||
type: FulfillmentProviderType.API,
|
||
status: FulfillmentProviderStatus.ACTIVE,
|
||
apiUrl: DEFAULT_XFX_API_URL,
|
||
signType: 'MD5',
|
||
settlementMethod: LogisticsSettlementMethod.PREPAID,
|
||
baseBottles: DEFAULT_XFX_LOGISTICS_PRICING.baseBottles,
|
||
baseFee: DEFAULT_XFX_LOGISTICS_PRICING.baseFee,
|
||
extraBottleFee: DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
|
||
boxBottles: DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
|
||
boxFee: DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
|
||
deliveryHintHtml: '',
|
||
});
|
||
setOpen(true);
|
||
}
|
||
|
||
function openEdit(row: FulfillmentProviderDto) {
|
||
setEditRow(row);
|
||
const xfx = row.xiaofeixiaConfig;
|
||
const pricing = row.pricingRules;
|
||
form.setFieldsValue({
|
||
code: row.code,
|
||
name: row.name,
|
||
type: row.type,
|
||
status: row.status,
|
||
apiUrl: xfx?.apiUrl || DEFAULT_XFX_API_URL,
|
||
mchId: xfx?.mchId || '',
|
||
apiKey: '',
|
||
signType: xfx?.signType || 'MD5',
|
||
appId: xfx?.appId || '',
|
||
bankAccountName: row.bankAccountName || '',
|
||
bankName: row.bankName || '',
|
||
bankBranch: row.bankBranch || '',
|
||
bankAccountNo: row.bankAccountNo || '',
|
||
settlementMethod: row.settlementMethod || LogisticsSettlementMethod.PREPAID,
|
||
baseBottles: pricing?.baseBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.baseBottles,
|
||
baseFee: pricing?.baseFee ?? DEFAULT_XFX_LOGISTICS_PRICING.baseFee,
|
||
extraBottleFee: pricing?.extraBottleFee ?? DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
|
||
boxBottles: pricing?.boxBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
|
||
boxFee: pricing?.boxFee ?? DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
|
||
deliveryHintHtml: row.deliveryHintHtml || '',
|
||
});
|
||
setOpen(true);
|
||
}
|
||
|
||
async function submit() {
|
||
const v = await form.validateFields();
|
||
const payload: Record<string, unknown> = {
|
||
name: v.name,
|
||
type: v.type,
|
||
status: v.status,
|
||
bankAccountName: v.bankAccountName || null,
|
||
bankName: v.bankName || null,
|
||
bankBranch: v.bankBranch || null,
|
||
bankAccountNo: v.bankAccountNo || null,
|
||
settlementMethod: v.settlementMethod,
|
||
pricingRules: {
|
||
baseBottles: Number(v.baseBottles),
|
||
baseFee: Number(v.baseFee),
|
||
extraBottleFee: Number(v.extraBottleFee),
|
||
boxBottles: v.boxBottles != null ? Number(v.boxBottles) : undefined,
|
||
boxFee: v.boxFee != null ? Number(v.boxFee) : undefined,
|
||
},
|
||
deliveryHintHtml: v.deliveryHintHtml?.trim() || null,
|
||
};
|
||
|
||
if (isXfxProviderCode(String(v.code)) && v.type === FulfillmentProviderType.API) {
|
||
payload.xiaofeixiaConfig = {
|
||
apiUrl: v.apiUrl,
|
||
mchId: v.mchId,
|
||
apiKey: v.apiKey || undefined,
|
||
signType: v.signType,
|
||
appId: v.appId || undefined,
|
||
};
|
||
}
|
||
|
||
if (editRow) {
|
||
await request(`/admin/fulfillment-providers/${editRow.id}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify(payload),
|
||
});
|
||
message.success('已更新');
|
||
} else {
|
||
await request('/admin/fulfillment-providers', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
code: v.code,
|
||
...payload,
|
||
}),
|
||
});
|
||
message.success('已创建');
|
||
}
|
||
setOpen(false);
|
||
void load();
|
||
}
|
||
|
||
async function remove(row: FulfillmentProviderDto) {
|
||
try {
|
||
await request(`/admin/fulfillment-providers/${row.id}`, { method: 'DELETE' });
|
||
message.success('已删除');
|
||
void load();
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '删除失败');
|
||
}
|
||
}
|
||
|
||
const baseColumns: ColumnsType<FulfillmentProviderDto> = [
|
||
{ title: '编码', dataIndex: 'code', width: 100 },
|
||
{
|
||
title: '名称',
|
||
dataIndex: 'name',
|
||
render: (v, row) => (
|
||
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
|
||
),
|
||
},
|
||
{
|
||
title: '类型',
|
||
dataIndex: 'type',
|
||
render: (v) => FULFILLMENT_PROVIDER_TYPE_LABELS[v as FulfillmentProviderType] || v,
|
||
},
|
||
{
|
||
title: '结算',
|
||
dataIndex: 'settlementMethod',
|
||
width: 100,
|
||
render: (v) =>
|
||
LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v || '—',
|
||
},
|
||
{
|
||
title: '充值余额',
|
||
dataIndex: 'prepaidBalance',
|
||
width: 100,
|
||
render: (v) => `¥${Number(v || 0).toFixed(2)}`,
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
render: (v) => (
|
||
<Tag color={v === 'ACTIVE' ? 'green' : 'default'}>
|
||
{FULFILLMENT_PROVIDER_STATUS_LABELS[v as FulfillmentProviderStatus] || v}
|
||
</Tag>
|
||
),
|
||
},
|
||
{
|
||
title: '接口配置',
|
||
render: (_, row) => {
|
||
if (isXfxProviderCode(row.code)) {
|
||
const cfg = row.xiaofeixiaConfig;
|
||
if (!cfg?.apiUrl) return <Tag>未配置</Tag>;
|
||
return (
|
||
<span title={cfg.apiUrl}>
|
||
{cfg.hasApiKey ? '已配置' : '缺 Key'} · {cfg.mchId || '无商户号'}
|
||
</span>
|
||
);
|
||
}
|
||
return row.hasConfig ? '已配置' : '—';
|
||
},
|
||
},
|
||
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: fmtTime },
|
||
{
|
||
title: '操作',
|
||
width: 140,
|
||
render: (_, row) => (
|
||
<Space size={0}>
|
||
<Button type="link" size="small" onClick={() => openEdit(row)}>
|
||
编辑
|
||
</Button>
|
||
<Popconfirm
|
||
title={`确认删除承运商「${row.name}」?`}
|
||
okText="删除"
|
||
okButtonProps={{ danger: true }}
|
||
onConfirm={() => void remove(row)}
|
||
>
|
||
<Button type="link" size="small" danger>
|
||
删除
|
||
</Button>
|
||
</Popconfirm>
|
||
</Space>
|
||
),
|
||
},
|
||
];
|
||
|
||
const { columns, settingsButton, settingsModal } = useAdminListColumns('fulfillment-providers', baseColumns, {
|
||
page: 1,
|
||
pageSize: rows.length || 20,
|
||
});
|
||
|
||
return (
|
||
<div>
|
||
{settingsModal}
|
||
<AdminListHeader
|
||
title="仓配管理"
|
||
description="注册承运商接口凭证,并配置物流对账用的银行账户、结算方式与计价标准"
|
||
settings={settingsButton}
|
||
actions={
|
||
<Button type="primary" onClick={openCreate}>
|
||
注册承运商
|
||
</Button>
|
||
}
|
||
/>
|
||
|
||
<Alert
|
||
type="info"
|
||
showIcon
|
||
style={{ marginBottom: 16 }}
|
||
message="小飞侠默认计价:2瓶6元,加一瓶+2元,6瓶一箱14元。财务对账见「财务 → 物流对账」。"
|
||
/>
|
||
|
||
<Table scroll={{ x: 'max-content' }} rowKey="id" loading={loading} columns={columns} dataSource={rows} pagination={false} />
|
||
|
||
<Modal
|
||
title={editRow ? '编辑承运商' : '注册承运商'}
|
||
open={open}
|
||
onCancel={() => setOpen(false)}
|
||
onOk={() => void submit()}
|
||
width={640}
|
||
destroyOnClose
|
||
>
|
||
<Form form={form} layout="vertical">
|
||
<Form.Item name="code" label="编码" rules={[{ required: true }]}>
|
||
<Input disabled={Boolean(editRow)} placeholder="如 XFX、JD、SF" />
|
||
</Form.Item>
|
||
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||
<Input placeholder="如 小飞侠、京东物流" />
|
||
</Form.Item>
|
||
<Form.Item name="type" label="类型" rules={[{ required: true }]}>
|
||
<Select options={TYPE_OPTIONS} />
|
||
</Form.Item>
|
||
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||
<Select options={STATUS_OPTIONS} />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="deliveryHintHtml"
|
||
label="配送信息提示"
|
||
extra="C 端同城送展示。支持 HTML:span/p/br/b/strong/i/em/font,style 可用 color、font-weight、font-size、font-style。回车换行会原样显示。空则回退「同城配送,预计24小时内送到」。"
|
||
>
|
||
<Input.TextArea
|
||
rows={3}
|
||
maxLength={2000}
|
||
placeholder='<span style="color:#A61D24;font-weight:700;font-size:13px">同城配送,预计24小时内送到</span>'
|
||
/>
|
||
</Form.Item>
|
||
|
||
<Divider orientation="left">物流对账</Divider>
|
||
<Form.Item name="settlementMethod" label="结算方式" rules={[{ required: true }]}>
|
||
<Select options={SETTLEMENT_OPTIONS} />
|
||
</Form.Item>
|
||
<Form.Item name="bankAccountName" label="收款户名">
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item name="bankName" label="开户银行">
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item name="bankBranch" label="开户支行">
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item name="bankAccountNo" label="银行账号">
|
||
<Input />
|
||
</Form.Item>
|
||
<Space wrap style={{ width: '100%' }}>
|
||
<Form.Item
|
||
name="baseBottles"
|
||
label="起送瓶数"
|
||
rules={[{ required: true }]}
|
||
style={{ marginBottom: 12 }}
|
||
>
|
||
<InputNumber min={1} style={{ width: 120 }} />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="baseFee"
|
||
label="起送费用(元)"
|
||
rules={[{ required: true }]}
|
||
style={{ marginBottom: 12 }}
|
||
>
|
||
<InputNumber min={0} precision={2} style={{ width: 120 }} />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="extraBottleFee"
|
||
label="加一瓶(元)"
|
||
rules={[{ required: true }]}
|
||
style={{ marginBottom: 12 }}
|
||
>
|
||
<InputNumber min={0} precision={2} style={{ width: 120 }} />
|
||
</Form.Item>
|
||
<Form.Item name="boxBottles" label="箱规瓶数" style={{ marginBottom: 12 }}>
|
||
<InputNumber min={1} style={{ width: 120 }} />
|
||
</Form.Item>
|
||
<Form.Item name="boxFee" label="一箱费用(元)" style={{ marginBottom: 12 }}>
|
||
<InputNumber min={0} precision={2} style={{ width: 120 }} />
|
||
</Form.Item>
|
||
</Space>
|
||
|
||
{showXfxFields && (
|
||
<>
|
||
<Divider orientation="left">小飞侠接口参数</Divider>
|
||
<Form.Item
|
||
name="apiUrl"
|
||
label="API 地址"
|
||
rules={[{ required: true, message: '请填写小飞侠 API 地址' }]}
|
||
extra="推单请求将发往此地址"
|
||
>
|
||
<Input placeholder={DEFAULT_XFX_API_URL} />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="mchId"
|
||
label="商户号"
|
||
rules={[{ required: true, message: '请填写商户号' }]}
|
||
>
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="apiKey"
|
||
label="API Key"
|
||
rules={editRow ? [] : [{ required: true, message: '请填写 API Key' }]}
|
||
extra={
|
||
editRow?.xiaofeixiaConfig?.hasApiKey
|
||
? '已配置密钥;留空则保持不变'
|
||
: undefined
|
||
}
|
||
>
|
||
<Input.Password placeholder={editRow ? '留空则不修改' : '请输入'} />
|
||
</Form.Item>
|
||
<Form.Item name="signType" label="签名类型" initialValue="MD5">
|
||
<Select options={SIGN_OPTIONS} />
|
||
</Form.Item>
|
||
<Form.Item name="appId" label="AppID(可选)">
|
||
<Input />
|
||
</Form.Item>
|
||
</>
|
||
)}
|
||
</Form>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
}
|