城市合伙人端的修改(后台)
This commit is contained in:
@@ -7,8 +7,6 @@ import UsersPage from './pages/UsersPage';
|
||||
import OrdersPage from './pages/OrdersPage';
|
||||
import StoresPage from './pages/StoresPage';
|
||||
import StoreAccountsPage from './pages/StoreAccountsPage';
|
||||
import PartnersPage from './pages/PartnersPage';
|
||||
import PartnerAccountsPage from './pages/PartnerAccountsPage';
|
||||
import BenefitCouponsPage from './pages/BenefitCouponsPage';
|
||||
import BenefitLedgersPage from './pages/BenefitLedgersPage';
|
||||
import RedeemRecordsPage from './pages/RedeemRecordsPage';
|
||||
@@ -17,6 +15,8 @@ import DeliveriesPage from './pages/DeliveriesPage';
|
||||
import XiaofeixiaTestPage from './pages/XiaofeixiaTestPage';
|
||||
import HqAccountsPage from './pages/HqAccountsPage';
|
||||
import CitiesPage from './pages/CitiesPage';
|
||||
import CityPartnersPage from './pages/CityPartnersPage';
|
||||
import CityWarehousesPage from './pages/CityWarehousesPage';
|
||||
import StoreMediaPage from './pages/StoreMediaPage';
|
||||
import ProductsPage from './pages/ProductsPage';
|
||||
import ProductDetailTemplatesPage from './pages/ProductDetailTemplatesPage';
|
||||
@@ -58,9 +58,11 @@ export default function App() {
|
||||
<Route path="/store-accounts" element={<StoreAccountsPage />} />
|
||||
<Route path="/store-media" element={<StoreMediaPage />} />
|
||||
<Route path="/resources" element={<ResourcesPage />} />
|
||||
<Route path="/partners" element={<PartnersPage />} />
|
||||
<Route path="/partners" element={<Navigate to="/city-partners" replace />} />
|
||||
<Route path="/cities" element={<CitiesPage />} />
|
||||
<Route path="/partner-accounts" element={<PartnerAccountsPage />} />
|
||||
<Route path="/city-partners" element={<CityPartnersPage />} />
|
||||
<Route path="/city-warehouses" element={<CityWarehousesPage />} />
|
||||
<Route path="/partner-accounts" element={<Navigate to="/city-partners" replace />} />
|
||||
<Route path="/benefit/coupons" element={<BenefitCouponsPage />} />
|
||||
<Route path="/benefit/ledgers" element={<BenefitLedgersPage />} />
|
||||
<Route path="/redeem-records" element={<RedeemRecordsPage />} />
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Cascader } from 'antd';
|
||||
import type { DefaultOptionType } from 'antd/es/cascader';
|
||||
import { PROVINCE_CITY_OPTIONS } from '../lib/china-region';
|
||||
|
||||
type ChinaProvinceCityCascaderProps = {
|
||||
value?: string[];
|
||||
onChange?: (codes: string[]) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export default function ChinaProvinceCityCascader({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder = '请选择省 / 市',
|
||||
}: ChinaProvinceCityCascaderProps) {
|
||||
return (
|
||||
<Cascader
|
||||
options={PROVINCE_CITY_OPTIONS as DefaultOptionType[]}
|
||||
value={value}
|
||||
onChange={(codes) => onChange?.((codes ?? []) as string[])}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
showSearch={{
|
||||
filter: (input, path) =>
|
||||
path.some((option) =>
|
||||
String(option.label ?? '').toLowerCase().includes(input.toLowerCase()),
|
||||
),
|
||||
}}
|
||||
changeOnSelect={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Cascader,
|
||||
Checkbox,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
CITY_PARTNER_SCOPE_LABELS,
|
||||
CITY_PARTNER_STATUS_LABELS,
|
||||
CityPartnerScopeType,
|
||||
CityPartnerStatus,
|
||||
PARTNER_PERMISSION_KEYS,
|
||||
PARTNER_PERMISSION_LABELS,
|
||||
PARTNER_STAFF_ROLE_LABELS,
|
||||
type PartnerPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { CHINA_REGION_OPTIONS } from '../lib/china-region';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import PartnerSubAccountList from './PartnerSubAccountList';
|
||||
|
||||
type PartnerRow = {
|
||||
id: string;
|
||||
companyName: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
scopeType?: string;
|
||||
orderCommissionRate?: number;
|
||||
redeemCommissionRate?: number;
|
||||
accountCount: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type PartnerDetail = PartnerRow & {
|
||||
address?: string;
|
||||
districtCodes?: string[] | null;
|
||||
bindingStatus?: string;
|
||||
bankAccountName?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
bankBranch?: string | null;
|
||||
managedWarehouseId?: string | null;
|
||||
managedWarehouseName?: string | null;
|
||||
contactPhone?: string | null;
|
||||
children?: Array<{
|
||||
id: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
staffRole?: string;
|
||||
permissions?: string[];
|
||||
status: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
cityId: string;
|
||||
maxPartnerCommissionRate?: number;
|
||||
onChanged?: () => void;
|
||||
};
|
||||
|
||||
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
|
||||
const BINDING_OPTIONS = Object.entries(CITY_PARTNER_STATUS_LABELS).map(([value, label]) => ({ value, label }));
|
||||
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({
|
||||
value: k,
|
||||
label: PARTNER_PERMISSION_LABELS[k],
|
||||
}));
|
||||
const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS)
|
||||
.filter(([value]) => value !== 'PARTNER')
|
||||
.map(([value, label]) => ({ value, label }));
|
||||
|
||||
function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
|
||||
if (!values?.length) return [];
|
||||
if (Array.isArray(values[0])) {
|
||||
return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean);
|
||||
}
|
||||
return values as string[];
|
||||
}
|
||||
|
||||
function commissionSumError(
|
||||
orderPercent: number,
|
||||
redeemPercent: number,
|
||||
maxRate: number,
|
||||
): string | null {
|
||||
const sum = orderPercent / 100 + redeemPercent / 100;
|
||||
if (sum > maxRate + 1e-9) {
|
||||
return `订单佣金与核销佣金合计不得超过 ${(maxRate * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0.05, onChanged }: Props) {
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [subForm] = Form.useForm();
|
||||
const [subEditForm] = Form.useForm();
|
||||
const [partners, setPartners] = useState<PartnerRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detail, setDetail] = useState<PartnerDetail | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [subOpen, setSubOpen] = useState(false);
|
||||
const [subEditOpen, setSubEditOpen] = useState(false);
|
||||
const [subEditId, setSubEditId] = useState<string | null>(null);
|
||||
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
|
||||
const loadPartners = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request<Paginated<PartnerRow>>(`/admin/partners?cityId=${cityId}&pageSize=100`);
|
||||
setPartners(res.items);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [cityId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPartners();
|
||||
}, [loadPartners]);
|
||||
|
||||
async function openPartner(id: string) {
|
||||
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
|
||||
setDetail(d);
|
||||
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
|
||||
editForm.setFieldsValue({
|
||||
name: d.name,
|
||||
phone: d.phone,
|
||||
companyName: d.companyName,
|
||||
contactPhone: d.contactPhone ?? d.phone,
|
||||
address: d.address ?? '',
|
||||
scopeType: d.scopeType,
|
||||
districtCodes: d.districtCodes ?? [],
|
||||
orderCommissionRate: (d.orderCommissionRate ?? 0) * 100,
|
||||
redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100,
|
||||
bindingStatus: d.bindingStatus ?? CityPartnerStatus.ACTIVE,
|
||||
bankAccountName: d.bankAccountName ?? '',
|
||||
bankAccountNo: d.bankAccountNo ?? '',
|
||||
bankBranch: d.bankBranch ?? '',
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
async function savePartner() {
|
||||
if (!detail) return;
|
||||
const v = await editForm.validateFields();
|
||||
const err = commissionSumError(
|
||||
Number(v.orderCommissionRate ?? 0),
|
||||
Number(v.redeemCommissionRate ?? 0),
|
||||
maxPartnerCommissionRate,
|
||||
);
|
||||
if (err) {
|
||||
message.error(err);
|
||||
return;
|
||||
}
|
||||
await request(`/admin/partners/${detail.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void loadPartners();
|
||||
onChanged?.();
|
||||
}
|
||||
|
||||
async function deleteSubAccount(subId: string) {
|
||||
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
|
||||
message.success('子账号已删除');
|
||||
if (detail) void openPartner(detail.id);
|
||||
void loadPartners();
|
||||
onChanged?.();
|
||||
}
|
||||
|
||||
const columns: ColumnsType<PartnerRow> = [
|
||||
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
|
||||
{ title: '主账号', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '管辖',
|
||||
dataIndex: 'scopeType',
|
||||
width: 100,
|
||||
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
|
||||
},
|
||||
{
|
||||
title: '佣金',
|
||||
width: 120,
|
||||
render: (_, row) => `${Math.round((row.orderCommissionRate ?? 0) * 100)}% / ${Math.round((row.redeemCommissionRate ?? 0.03) * 100)}%`,
|
||||
},
|
||||
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
|
||||
管理
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ marginBottom: 12 }}
|
||||
onClick={() => {
|
||||
createForm.resetFields();
|
||||
createForm.setFieldsValue({
|
||||
cityId,
|
||||
orderCommissionRate: 0,
|
||||
redeemCommissionRate: 3,
|
||||
scopeType: CityPartnerScopeType.CITY_WIDE,
|
||||
});
|
||||
setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
新建合伙人
|
||||
</Button>
|
||||
<Table rowKey="id" size="small" loading={loading} columns={columns} dataSource={partners} pagination={false} />
|
||||
|
||||
<Drawer
|
||||
title="城市合伙人"
|
||||
width={640}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={<Button type="primary" onClick={() => void savePartner()}>保存</Button>}
|
||||
>
|
||||
{detail && (
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'info',
|
||||
label: '主账号',
|
||||
children: (
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
|
||||
<Select options={BINDING_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
|
||||
</Form.Item>
|
||||
{editScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item name="districtCodes" label="区县">
|
||||
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
</Space>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||
订单 + 核销合计不得超过 {(maxPartnerCommissionRate * 100).toFixed(2)}%(可在城市基本信息中调整)
|
||||
</Typography.Text>
|
||||
<Form.Item label="管仓仓库">
|
||||
<Input
|
||||
disabled
|
||||
value={detail.managedWarehouseName ?? '未分配(请在仓库管理中设置)'}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="bankAccountName" label="户名"><Input /></Form.Item>
|
||||
<Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item>
|
||||
<Form.Item name="bankBranch" label="开户行"><Input /></Form.Item>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'staff',
|
||||
label: `子账号 (${Math.max(0, (detail.accountCount ?? 1) - 1)})`,
|
||||
children: (
|
||||
<PartnerSubAccountList
|
||||
subs={detail.children ?? []}
|
||||
onAdd={() => {
|
||||
subForm.resetFields();
|
||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
|
||||
setSubOpen(true);
|
||||
}}
|
||||
onEdit={(row) => {
|
||||
setSubEditId(row.id);
|
||||
subEditForm.setFieldsValue({
|
||||
name: row.name,
|
||||
phone: row.phone,
|
||||
staffRole: row.staffRole ?? 'INTERNAL',
|
||||
permissions: row.permissions ?? [],
|
||||
status: row.status,
|
||||
});
|
||||
setSubEditOpen(true);
|
||||
}}
|
||||
onDelete={(subId) => void deleteSubAccount(subId)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal title="新建城市合伙人" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
const err = commissionSumError(
|
||||
Number(v.orderCommissionRate ?? 0),
|
||||
Number(v.redeemCommissionRate ?? 3),
|
||||
maxPartnerCommissionRate,
|
||||
);
|
||||
if (err) {
|
||||
message.error(err);
|
||||
return;
|
||||
}
|
||||
await request('/admin/partners', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
cityId,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
void loadPartners();
|
||||
onChanged?.();
|
||||
}}>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
||||
</Form.Item>
|
||||
{createScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item name="districtCodes" label="区县" rules={[{ required: true }]}>
|
||||
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
</Space>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||
订单 + 核销合计不得超过 {(maxPartnerCommissionRate * 100).toFixed(2)}%
|
||||
</Typography.Text>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="添加子账号" open={subOpen} onCancel={() => setSubOpen(false)} onOk={async () => {
|
||||
if (!detail) return;
|
||||
const v = await subForm.validateFields();
|
||||
await request('/admin/partner-accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...v, parentAccountId: detail.id }),
|
||||
});
|
||||
message.success('已创建');
|
||||
setSubOpen(false);
|
||||
void openPartner(detail.id);
|
||||
void loadPartners();
|
||||
onChanged?.();
|
||||
}}>
|
||||
<Form form={subForm} layout="vertical">
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="staffRole" label="角色" initialValue="INTERNAL">
|
||||
<Select options={STAFF_ROLE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="permissions" label="权限">
|
||||
<Checkbox.Group options={PERM_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="编辑子账号" open={subEditOpen} onCancel={() => setSubEditOpen(false)} onOk={async () => {
|
||||
if (!subEditId || !detail) return;
|
||||
const v = await subEditForm.validateFields();
|
||||
await request(`/admin/partner-accounts/${subEditId}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
message.success('已更新');
|
||||
setSubEditOpen(false);
|
||||
void openPartner(detail.id);
|
||||
}}>
|
||||
<Form form={subEditForm} layout="vertical">
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="staffRole" label="角色"><Select options={STAFF_ROLE_OPTIONS} /></Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={[{ value: 'ACTIVE', label: '启用' }, { value: 'DISABLED', label: '停用' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="permissions" label="权限">
|
||||
<Checkbox.Group options={PERM_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Button, Popconfirm, Space, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
PARTNER_PERMISSION_LABELS,
|
||||
PARTNER_STAFF_ROLE_LABELS,
|
||||
type PartnerPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
|
||||
export type PartnerSubAccountRow = {
|
||||
id: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
staffRole?: string;
|
||||
permissions?: string[];
|
||||
status: string;
|
||||
};
|
||||
|
||||
function formatPermissions(permissions?: string[]) {
|
||||
return permissions?.map((k) => PARTNER_PERMISSION_LABELS[k as PartnerPermissionKey] || k).join('、') || '—';
|
||||
}
|
||||
|
||||
type Props = {
|
||||
subs: PartnerSubAccountRow[];
|
||||
onAdd: () => void;
|
||||
onEdit: (sub: PartnerSubAccountRow) => void;
|
||||
onDelete: (subId: string) => void;
|
||||
};
|
||||
|
||||
export default function PartnerSubAccountList({ subs, onAdd, onEdit, onDelete }: Props) {
|
||||
const columns: ColumnsType<PartnerSubAccountRow> = [
|
||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'staffRole',
|
||||
width: 90,
|
||||
render: (v) =>
|
||||
v ? PARTNER_STAFF_ROLE_LABELS[v as keyof typeof PARTNER_STAFF_ROLE_LABELS] || v : '—',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render: (s) => (
|
||||
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{s === 'ACTIVE' ? '启用' : '停用'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '权限',
|
||||
dataIndex: 'permissions',
|
||||
ellipsis: true,
|
||||
render: (p: string[] | undefined) => formatPermissions(p),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
render: (_, row) => (
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" onClick={() => onEdit(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm title="确定删除该子账号?" onConfirm={() => onDelete(row.id)}>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
子账号({subs.length})· 仅主账号可添加,不可多级
|
||||
</Typography.Text>
|
||||
<Button size="small" type="primary" onClick={onAdd}>
|
||||
添加子账号
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
columns={columns}
|
||||
dataSource={subs}
|
||||
locale={{ emptyText: '暂无子账号' }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -51,9 +51,9 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
icon: <TeamOutlined />,
|
||||
label: '开城',
|
||||
children: [
|
||||
{ key: '/partners', label: '开城合伙人' },
|
||||
{ key: '/cities', label: '开城城市' },
|
||||
{ key: '/partner-accounts', label: '开城合伙人账户' },
|
||||
{ key: '/cities', label: '城市' },
|
||||
{ key: '/city-partners', label: '城市合伙人' },
|
||||
{ key: '/city-warehouses', label: '仓库' },
|
||||
{ key: '/partner-bills', label: '合伙人结算' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -2,14 +2,35 @@ import { codeToText, regionData } from 'element-china-area-data';
|
||||
|
||||
export { regionData as CHINA_REGION_OPTIONS };
|
||||
|
||||
export type ProvinceCityOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
children?: Array<{ value: string; label: string }>;
|
||||
};
|
||||
|
||||
/** 省 / 市二级(不含区县) */
|
||||
export const PROVINCE_CITY_OPTIONS: ProvinceCityOption[] = regionData.map((province) => ({
|
||||
value: province.value,
|
||||
label: province.label,
|
||||
children: province.children?.map((city) => ({
|
||||
value: city.value,
|
||||
label: city.label,
|
||||
})),
|
||||
}));
|
||||
|
||||
export type OpenCityRef = {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
partnerId?: string | null;
|
||||
partner?: { id: string };
|
||||
partnerBindings?: Array<{ partnerAccountId: string }>;
|
||||
boundPartnerIds?: string[];
|
||||
};
|
||||
|
||||
function cityBoundPartnerIds(city: OpenCityRef): string[] {
|
||||
if (city.boundPartnerIds?.length) return city.boundPartnerIds.map(String);
|
||||
return (city.partnerBindings ?? []).map((b) => String(b.partnerAccountId));
|
||||
}
|
||||
|
||||
export type ParsedChinaRegion = {
|
||||
province: string;
|
||||
city: string;
|
||||
@@ -19,6 +40,39 @@ export type ParsedChinaRegion = {
|
||||
districtCode: string;
|
||||
};
|
||||
|
||||
export type ParsedProvinceCity = {
|
||||
province: string;
|
||||
city: string;
|
||||
provinceCode: string;
|
||||
cityCode: string;
|
||||
};
|
||||
|
||||
/** element-china-area-data 市级码(如 4101)→ 国标 6 位 adcode(410100) */
|
||||
export function normalizeCityAdcode(code: string): string {
|
||||
const raw = code.trim();
|
||||
if (/^\d{6}$/.test(raw)) return raw;
|
||||
if (/^\d{4}$/.test(raw)) return `${raw}00`;
|
||||
if (/^\d{2}$/.test(raw)) return `${raw}0100`;
|
||||
return raw.padEnd(6, '0').slice(0, 6);
|
||||
}
|
||||
|
||||
export function parseProvinceCityCodes(codes?: string[]): ParsedProvinceCity | null {
|
||||
if (!codes || codes.length < 2) return null;
|
||||
const [provinceCode, cityCode] = codes;
|
||||
const province = codeToText[provinceCode];
|
||||
let city = codeToText[cityCode];
|
||||
if (!province || !city) return null;
|
||||
if (city === '市辖区' || city === '县' || city === '省直辖县级行政区划') {
|
||||
city = province.endsWith('市') ? province : city;
|
||||
}
|
||||
return {
|
||||
province,
|
||||
city,
|
||||
provinceCode,
|
||||
cityCode: normalizeCityAdcode(cityCode),
|
||||
};
|
||||
}
|
||||
|
||||
/** 区县 adcode → 地级市 adcode(如 410105 → 410100) */
|
||||
export function districtCodeToCityCode(districtCode: string): string {
|
||||
if (districtCode.length < 6) return districtCode;
|
||||
@@ -42,14 +96,11 @@ export function formatRegionLabel(region: ParsedChinaRegion): string {
|
||||
export function matchOpenCityId(
|
||||
cities: OpenCityRef[],
|
||||
districtCode: string,
|
||||
partnerId?: string,
|
||||
partnerAccountId?: string,
|
||||
): string | undefined {
|
||||
const cityCode = districtCodeToCityCode(districtCode);
|
||||
const scoped = partnerId
|
||||
? cities.filter((c) => {
|
||||
const pid = c.partnerId ?? c.partner?.id;
|
||||
return !pid || String(pid) === partnerId;
|
||||
})
|
||||
const scoped = partnerAccountId
|
||||
? cities.filter((c) => cityBoundPartnerIds(c).includes(String(partnerAccountId)))
|
||||
: cities;
|
||||
return (
|
||||
scoped.find((c) => c.code === cityCode)?.id
|
||||
@@ -61,11 +112,11 @@ export function matchOpenCityId(
|
||||
export function resolveRegionBinding(
|
||||
codes: string[],
|
||||
cities: OpenCityRef[],
|
||||
partnerId?: string,
|
||||
partnerAccountId?: string,
|
||||
) {
|
||||
const region = parseRegionCodes(codes);
|
||||
if (!region) return null;
|
||||
const cityId = matchOpenCityId(cities, region.districtCode, partnerId);
|
||||
const cityId = matchOpenCityId(cities, region.districtCode, partnerAccountId);
|
||||
const matchedCity = cityId ? cities.find((c) => c.id === cityId) : undefined;
|
||||
return {
|
||||
region,
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
export const HQ_OPERATION_ACTION_OPTIONS = [
|
||||
{ value: 'CITY_CREATE', label: '新增开城城市' },
|
||||
{ value: 'CITY_UPDATE', label: '编辑开城城市' },
|
||||
{ value: 'CITY_DELETE', label: '删除开城城市' },
|
||||
{ value: 'CITY_PARTNER_BIND', label: '绑定城市合伙人' },
|
||||
{ value: 'CITY_PARTNER_UPDATE', label: '编辑城市合伙人绑定' },
|
||||
{ value: 'CITY_PARTNER_UNBIND', label: '解绑城市合伙人' },
|
||||
{ value: 'WAREHOUSE_CREATE', label: '新增城市仓库' },
|
||||
{ value: 'WAREHOUSE_UPDATE', label: '编辑城市仓库' },
|
||||
{ value: 'WAREHOUSE_DELETE', label: '删除城市仓库' },
|
||||
{ value: 'PARTNER_CREATE', label: '新增城市合伙人' },
|
||||
{ value: 'PARTNER_UPDATE', label: '编辑城市合伙人' },
|
||||
{ value: 'PARTNER_ACCOUNT_CREATE', label: '新增合伙人账户' },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type StoreCreateForm = {
|
||||
partnerId: string;
|
||||
partnerAccountId: string;
|
||||
cityId: string;
|
||||
regionCodes?: string[];
|
||||
province?: string;
|
||||
@@ -16,17 +16,18 @@ export type StoreCreateForm = {
|
||||
bankAccountName: string;
|
||||
bankAccountNo: string;
|
||||
bankBranch: string;
|
||||
settlementRate?: number;
|
||||
};
|
||||
|
||||
const PHONE_RE = /^1\d{10}$/;
|
||||
const BANK_RE = /^\d{16,19}$/;
|
||||
|
||||
export function validateStoreCreateStep1(
|
||||
form: Pick<StoreCreateForm, 'partnerId' | 'cityId' | 'regionCodes' | 'name' | 'phone' | 'address' | 'intro'>,
|
||||
form: Pick<StoreCreateForm, 'partnerAccountId' | 'cityId' | 'regionCodes' | 'name' | 'phone' | 'address' | 'intro'>,
|
||||
): string | null {
|
||||
if (!form.partnerId) return '请选择开城合伙人';
|
||||
if (!form.partnerAccountId) return '请选择开城合伙人';
|
||||
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
|
||||
if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 开城城市」配置对应区划';
|
||||
if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 城市」配置对应区划';
|
||||
if (!form.name?.trim()) return '请填写门店名称';
|
||||
if (!form.phone?.trim()) return '请填写门店手机号';
|
||||
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
|
||||
|
||||
@@ -1,24 +1,63 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Descriptions, Drawer, Form, Input, InputNumber, Modal, Select, Space, Table, Tag, Typography, message,
|
||||
Button,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { WAREHOUSE_MANAGER_LABELS, WarehouseManagerType } from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { parseProvinceCityCodes, type ParsedProvinceCity } from '../lib/china-region';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, CITY_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import CityPartnersPanel from '../components/CityPartnersPanel';
|
||||
import ChinaProvinceCityCascader from '../components/ChinaProvinceCityCascader';
|
||||
|
||||
type Row = {
|
||||
id: string; code: string; name: string; province: string; status: string;
|
||||
storeCount: number; orderCount: number; createdAt: string;
|
||||
partner?: { id: string; companyName: string };
|
||||
type WarehouseRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
contactName: string;
|
||||
contactPhone: string;
|
||||
managerType: WarehouseManagerType;
|
||||
partnerAccountId: string | null;
|
||||
partnerCompanyName?: string | null;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type PartnerOption = { id: string; companyName: string };
|
||||
type Row = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
province: string;
|
||||
status: string;
|
||||
storeCount: number;
|
||||
orderCount: number;
|
||||
partnerBindingCount?: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type PartnerOption = { id: string; companyName: string; cityId?: string | null };
|
||||
|
||||
const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label }));
|
||||
|
||||
export default function CitiesPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [warehouseForm] = Form.useForm();
|
||||
const [warehouseEditForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/cities',
|
||||
@@ -32,37 +71,132 @@ export default function CitiesPage() {
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [warehouses, setWarehouses] = useState<WarehouseRow[]>([]);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [warehouseOpen, setWarehouseOpen] = useState(false);
|
||||
const [warehouseEditOpen, setWarehouseEditOpen] = useState(false);
|
||||
const [warehouseEditId, setWarehouseEditId] = useState<string | null>(null);
|
||||
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||
const [createRegionPreview, setCreateRegionPreview] = useState<ParsedProvinceCity | null>(null);
|
||||
const createRegionCodes = Form.useWatch('regionCodes', createForm);
|
||||
const [warehouseManagerType, setWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
|
||||
const [editWarehouseManagerType, setEditWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
|
||||
|
||||
async function loadPartners() {
|
||||
const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||
const loadPartners = useCallback(async (cityId: string) => {
|
||||
const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&cityId=${cityId}`);
|
||||
setPartners(res.items);
|
||||
}, []);
|
||||
|
||||
const loadWarehouses = useCallback(async (cityId: string) => {
|
||||
const wh = await request<WarehouseRow[]>(`/admin/cities/${cityId}/warehouses`);
|
||||
setWarehouses(wh);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!createRegionCodes?.length) {
|
||||
setCreateRegionPreview(null);
|
||||
return;
|
||||
}
|
||||
const parsed = parseProvinceCityCodes(createRegionCodes as string[]);
|
||||
createForm.setFieldsValue({
|
||||
code: parsed?.cityCode,
|
||||
name: parsed?.city,
|
||||
province: parsed?.province,
|
||||
});
|
||||
setCreateRegionPreview(parsed);
|
||||
}, [createRegionCodes, createForm]);
|
||||
|
||||
function openCreateModal() {
|
||||
createForm.resetFields();
|
||||
createForm.setFieldsValue({ status: 'PENDING' });
|
||||
setCreateRegionPreview(null);
|
||||
setCreateOpen(true);
|
||||
}
|
||||
|
||||
const openDetail = async (row: Row) => {
|
||||
const d = await request<Record<string, unknown>>(`/admin/cities/${row.id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue({
|
||||
...d,
|
||||
maxPartnerCommissionPercent:
|
||||
d.maxPartnerCommissionRate != null
|
||||
? Number(d.maxPartnerCommissionRate) * 100
|
||||
: 5,
|
||||
});
|
||||
await loadPartners(row.id);
|
||||
await loadWarehouses(row.id);
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '编码', dataIndex: 'code', width: 90 },
|
||||
{ title: '城市', dataIndex: 'name', width: 100 },
|
||||
{ title: '省份', dataIndex: 'province', width: 90 },
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{CITY_STATUS_LABELS[s] || s}</Tag> },
|
||||
{ title: '开城合伙人', dataIndex: ['partner', 'companyName'], width: 140, ellipsis: true },
|
||||
{ title: '合伙人', dataIndex: 'partnerBindingCount', width: 90 },
|
||||
{ title: '门店', dataIndex: 'storeCount', width: 70 },
|
||||
{ title: '订单', dataIndex: 'orderCount', width: 70 },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
const d = await request<Record<string, unknown>>(`/admin/cities/${row.id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue({
|
||||
...d,
|
||||
partnerId: (d.partner as { id?: string } | null)?.id ?? (d as { partnerId?: string }).partnerId,
|
||||
});
|
||||
void loadPartners();
|
||||
setDrawerOpen(true);
|
||||
}}>编辑</Button>
|
||||
<Button type="link" size="small" onClick={() => void openDetail(row)}>
|
||||
管理
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const warehouseColumns: ColumnsType<WarehouseRow> = [
|
||||
{ title: '仓库名', dataIndex: 'name' },
|
||||
{ title: '地址', dataIndex: 'address', ellipsis: true },
|
||||
{ title: '联系人', dataIndex: 'contactName', width: 90 },
|
||||
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
|
||||
{
|
||||
title: '管仓',
|
||||
dataIndex: 'managerType',
|
||||
width: 100,
|
||||
render: (v: WarehouseManagerType) => WAREHOUSE_MANAGER_LABELS[v] || v,
|
||||
},
|
||||
{ title: '合伙人', dataIndex: 'partnerCompanyName', ellipsis: true, render: (v) => v || '—' },
|
||||
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{s}</Tag> },
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setWarehouseEditId(row.id);
|
||||
setEditWarehouseManagerType(row.managerType);
|
||||
warehouseEditForm.setFieldsValue(row);
|
||||
setWarehouseEditOpen(true);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: '确认删除仓库?',
|
||||
onOk: async () => {
|
||||
await request(`/admin/city-warehouses/${row.id}`, { method: 'DELETE' });
|
||||
message.success('已删除');
|
||||
if (detail?.id) await loadWarehouses(String(detail.id));
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -70,8 +204,8 @@ export default function CitiesPage() {
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>开城城市</Typography.Title>
|
||||
<Button type="primary" onClick={() => { void loadPartners(); setCreateOpen(true); }}>新建城市</Button>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>城市</Typography.Title>
|
||||
<Button type="primary" onClick={openCreateModal}>新建城市</Button>
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="name" label="城市"><Input allowClear /></Form.Item>
|
||||
@@ -81,59 +215,197 @@ export default function CitiesPage() {
|
||||
</Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1000 }}
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="编辑开城城市" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (
|
||||
<Button type="primary" onClick={async () => {
|
||||
const v = await editForm.validateFields();
|
||||
await request(`/admin/cities/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
}}>保存</Button>
|
||||
)}>
|
||||
|
||||
<Drawer title="城市管理" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="编码">{String(detail.code)}</Descriptions.Item>
|
||||
<Descriptions.Item label="门店数">{String(detail.storeCount ?? (detail as { _count?: { stores?: number } })._count?.stores ?? '—')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="name" label="城市名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="province" label="省份" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="partnerId" label="开城合伙人">
|
||||
<Select allowClear placeholder="选择合伙人" options={partners.map((p) => ({ value: p.id, label: p.companyName }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="localMinQty" label="同城起订量"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="crossMinQty" label="跨城起订量"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'basic',
|
||||
label: '基本信息',
|
||||
children: (
|
||||
<>
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="编码">{String(detail.code)}</Descriptions.Item>
|
||||
<Descriptions.Item label="合伙人数">{String(detail.partnerBindingCount ?? '—')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="name" label="城市名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="province" label="省份" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="localMinQty" label="同城起购"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="crossMinQty" label="跨城起购"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item
|
||||
name="maxPartnerCommissionPercent"
|
||||
label="合伙人佣金合计上限 %"
|
||||
extra="订单佣金 + 核销佣金不得超过此比例;不填默认 5%"
|
||||
>
|
||||
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} placeholder="5" />
|
||||
</Form.Item>
|
||||
<Button type="primary" onClick={async () => {
|
||||
const v = await editForm.validateFields();
|
||||
const { maxPartnerCommissionPercent, ...rest } = v;
|
||||
await request(`/admin/cities/${detail.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
...rest,
|
||||
maxPartnerCommissionRate:
|
||||
maxPartnerCommissionPercent != null && maxPartnerCommissionPercent !== ''
|
||||
? Number(maxPartnerCommissionPercent) / 100
|
||||
: 0.05,
|
||||
}),
|
||||
});
|
||||
message.success('已保存');
|
||||
const refreshed = await request<Record<string, unknown>>(`/admin/cities/${detail.id}`);
|
||||
setDetail(refreshed);
|
||||
void reload();
|
||||
}}>保存</Button>
|
||||
</Form>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'partners',
|
||||
label: '合伙人',
|
||||
children: detail?.id ? (
|
||||
<CityPartnersPanel
|
||||
cityId={String(detail.id)}
|
||||
maxPartnerCommissionRate={
|
||||
detail.maxPartnerCommissionRate != null
|
||||
? Number(detail.maxPartnerCommissionRate)
|
||||
: 0.05
|
||||
}
|
||||
onChanged={() => {
|
||||
void reload();
|
||||
void loadPartners(String(detail.id));
|
||||
}}
|
||||
/>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
key: 'warehouses',
|
||||
label: '仓库管理',
|
||||
children: (
|
||||
<>
|
||||
<Button type="primary" style={{ marginBottom: 12 }} onClick={() => {
|
||||
warehouseForm.resetFields();
|
||||
warehouseForm.setFieldsValue({ managerType: WarehouseManagerType.HQ, status: 'ACTIVE' });
|
||||
setWarehouseManagerType(WarehouseManagerType.HQ);
|
||||
setWarehouseOpen(true);
|
||||
}}>新增仓库</Button>
|
||||
<Table rowKey="id" size="small" columns={warehouseColumns} dataSource={warehouses} pagination={false} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal title="新建开城城市" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
await request('/admin/cities', { method: 'POST', body: JSON.stringify(v) });
|
||||
if (!v.code || !v.name || !v.province) {
|
||||
message.error('请选择省 / 市');
|
||||
return;
|
||||
}
|
||||
await request('/admin/cities', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
code: v.code,
|
||||
name: v.name,
|
||||
province: v.province,
|
||||
status: v.status,
|
||||
}),
|
||||
});
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
setCreateRegionPreview(null);
|
||||
void reload();
|
||||
}}>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="code" label="城市编码" rules={[{ required: true }]}><Input placeholder="如 ZZ" /></Form.Item>
|
||||
<Form.Item name="name" label="城市名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="province" label="省份" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="partnerId" label="开城合伙人">
|
||||
<Select allowClear placeholder="选择合伙人" options={partners.map((p) => ({ value: p.id, label: p.companyName }))} />
|
||||
<Form.Item
|
||||
name="regionCodes"
|
||||
label="所在地区"
|
||||
rules={[{ required: true, message: '请选择省 / 市' }]}
|
||||
>
|
||||
<ChinaProvinceCityCascader />
|
||||
</Form.Item>
|
||||
{createRegionPreview ? (
|
||||
<Descriptions column={1} size="small" bordered style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="城市编码">{createRegionPreview.cityCode}</Descriptions.Item>
|
||||
<Descriptions.Item label="省份">{createRegionPreview.province}</Descriptions.Item>
|
||||
<Descriptions.Item label="城市名">{createRegionPreview.city}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
) : (
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
|
||||
选择省 / 市后,城市编码将自动填入(不可编辑)
|
||||
</Typography.Text>
|
||||
)}
|
||||
<Form.Item name="code" hidden rules={[{ required: true, message: '请选择省 / 市' }]}><Input /></Form.Item>
|
||||
<Form.Item name="name" hidden rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="province" hidden rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="status" label="状态" initialValue="PENDING">
|
||||
<Select options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="新增仓库" open={warehouseOpen} onCancel={() => setWarehouseOpen(false)} onOk={async () => {
|
||||
const v = await warehouseForm.validateFields();
|
||||
await request(`/admin/cities/${detail?.id}/warehouses`, { method: 'POST', body: JSON.stringify(v) });
|
||||
message.success('已创建');
|
||||
setWarehouseOpen(false);
|
||||
if (detail?.id) await loadWarehouses(String(detail.id));
|
||||
}}>
|
||||
<Form form={warehouseForm} 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) => setWarehouseManagerType(v)} />
|
||||
</Form.Item>
|
||||
{warehouseManagerType === WarehouseManagerType.PARTNER && (
|
||||
<Form.Item name="partnerAccountId" label="管仓合伙人" rules={[{ required: true }]}>
|
||||
<Select options={partners.map((p) => ({ value: p.id, label: p.companyName }))} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="status" label="状态" initialValue="ACTIVE">
|
||||
<Select options={[{ value: 'ACTIVE', label: '启用' }, { value: 'PAUSED', label: '暂停' }]} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="编辑仓库" open={warehouseEditOpen} onCancel={() => setWarehouseEditOpen(false)} onOk={async () => {
|
||||
const v = await warehouseEditForm.validateFields();
|
||||
await request(`/admin/city-warehouses/${warehouseEditId}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
message.success('已更新');
|
||||
setWarehouseEditOpen(false);
|
||||
if (detail?.id) await loadWarehouses(String(detail.id));
|
||||
}}>
|
||||
<Form form={warehouseEditForm} 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) => setEditWarehouseManagerType(v)} />
|
||||
</Form.Item>
|
||||
{editWarehouseManagerType === WarehouseManagerType.PARTNER && (
|
||||
<Form.Item name="partnerAccountId" label="管仓合伙人" rules={[{ required: true }]}>
|
||||
<Select options={partners.map((p) => ({ value: p.id, label: p.companyName }))} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={[{ value: 'ACTIVE', label: '启用' }, { value: 'PAUSED', label: '暂停' }]} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,645 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Cascader,
|
||||
Checkbox,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
CITY_PARTNER_SCOPE_LABELS,
|
||||
CITY_PARTNER_STATUS_LABELS,
|
||||
CityPartnerScopeType,
|
||||
CityPartnerStatus,
|
||||
PARTNER_PERMISSION_KEYS,
|
||||
PARTNER_PERMISSION_LABELS,
|
||||
PARTNER_STAFF_ROLE_LABELS,
|
||||
type PartnerPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { CHINA_REGION_OPTIONS } from '../lib/china-region';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList';
|
||||
|
||||
type SubRow = PartnerSubAccountRow;
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
companyName: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
contactPhone?: string | null;
|
||||
cityId?: string | null;
|
||||
cityName?: string | null;
|
||||
scopeType?: string;
|
||||
orderCommissionRate?: number;
|
||||
redeemCommissionRate?: number;
|
||||
bindingStatus?: string;
|
||||
managedWarehouseId?: string | null;
|
||||
managedWarehouseName?: string | null;
|
||||
maxPartnerCommissionRate?: number | null;
|
||||
storeCount: number;
|
||||
accountCount: number;
|
||||
subAccounts?: SubRow[];
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type PartnerDetail = Row & {
|
||||
address?: string;
|
||||
districtCodes?: string[] | null;
|
||||
bankAccountName?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
bankBranch?: string | null;
|
||||
/** 详情接口仍返回 children */
|
||||
children?: SubRow[];
|
||||
};
|
||||
|
||||
function toTableRows(items: Row[]): Row[] {
|
||||
return items.map((row) => {
|
||||
const { children, subAccounts, ...rest } = row as Row & { children?: SubRow[] };
|
||||
return { ...rest, subAccounts: subAccounts ?? children ?? [] };
|
||||
});
|
||||
}
|
||||
|
||||
type CityOption = { id: string; name: string; code: string };
|
||||
|
||||
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
|
||||
const BINDING_OPTIONS = Object.entries(CITY_PARTNER_STATUS_LABELS).map(([value, label]) => ({ value, label }));
|
||||
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({
|
||||
value: k,
|
||||
label: PARTNER_PERMISSION_LABELS[k],
|
||||
}));
|
||||
const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS)
|
||||
.filter(([value]) => value !== 'PARTNER')
|
||||
.map(([value, label]) => ({ value, label }));
|
||||
|
||||
function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
|
||||
if (!values?.length) return [];
|
||||
if (Array.isArray(values[0])) {
|
||||
return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean);
|
||||
}
|
||||
return values as string[];
|
||||
}
|
||||
|
||||
function commissionSumError(orderPercent: number, redeemPercent: number, maxRate: number): string | null {
|
||||
const sum = orderPercent / 100 + redeemPercent / 100;
|
||||
if (sum > maxRate + 1e-9) {
|
||||
return `订单佣金与核销佣金合计不得超过 ${(maxRate * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function CityPartnersPage() {
|
||||
const [filterForm] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [subForm] = Form.useForm();
|
||||
const [subEditForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/partners',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.companyName) qs.set('companyName', filters.companyName);
|
||||
if (filters.phone) qs.set('phone', filters.phone);
|
||||
if (filters.cityId) qs.set('cityId', filters.cityId);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<PartnerDetail | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [subOpen, setSubOpen] = useState(false);
|
||||
const [subEditOpen, setSubEditOpen] = useState(false);
|
||||
const [subEditId, setSubEditId] = useState<string | null>(null);
|
||||
const [subEditParentId, setSubEditParentId] = useState<string | null>(null);
|
||||
const [cities, setCities] = useState<CityOption[]>([]);
|
||||
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
const [maxCommissionRate, setMaxCommissionRate] = useState(0.05);
|
||||
const [createMaxRate, setCreateMaxRate] = useState(0.05);
|
||||
|
||||
const loadCities = useCallback(async () => {
|
||||
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||
setCities(res.items);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCities();
|
||||
}, [loadCities]);
|
||||
|
||||
async function openPartner(id: string) {
|
||||
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
|
||||
setDetail(d);
|
||||
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
|
||||
setMaxCommissionRate(d.maxPartnerCommissionRate ?? 0.05);
|
||||
editForm.setFieldsValue({
|
||||
name: d.name,
|
||||
phone: d.phone,
|
||||
companyName: d.companyName,
|
||||
contactPhone: d.contactPhone ?? d.phone,
|
||||
address: d.address ?? '',
|
||||
scopeType: d.scopeType,
|
||||
districtCodes: d.districtCodes ?? [],
|
||||
orderCommissionRate: (d.orderCommissionRate ?? 0) * 100,
|
||||
redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100,
|
||||
bindingStatus: d.bindingStatus ?? CityPartnerStatus.ACTIVE,
|
||||
bankAccountName: d.bankAccountName ?? '',
|
||||
bankAccountNo: d.bankAccountNo ?? '',
|
||||
bankBranch: d.bankBranch ?? '',
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
return d;
|
||||
}
|
||||
|
||||
async function savePartner() {
|
||||
if (!detail) return;
|
||||
const v = await editForm.validateFields();
|
||||
const err = commissionSumError(
|
||||
Number(v.orderCommissionRate ?? 0),
|
||||
Number(v.redeemCommissionRate ?? 0),
|
||||
maxCommissionRate,
|
||||
);
|
||||
if (err) {
|
||||
message.error(err);
|
||||
return;
|
||||
}
|
||||
await request(`/admin/partners/${detail.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
}
|
||||
|
||||
async function refreshPartnerContext(parentId: string) {
|
||||
if (detail?.id === parentId) {
|
||||
await openPartner(parentId);
|
||||
}
|
||||
void reload();
|
||||
}
|
||||
|
||||
async function deleteSubAccount(subId: string, parentId: string) {
|
||||
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
|
||||
message.success('子账号已删除');
|
||||
await refreshPartnerContext(parentId);
|
||||
}
|
||||
|
||||
function openSubEdit(row: SubRow, parentId: string) {
|
||||
setSubEditId(row.id);
|
||||
setSubEditParentId(parentId);
|
||||
subEditForm.setFieldsValue({
|
||||
name: row.name,
|
||||
phone: row.phone,
|
||||
staffRole: row.staffRole ?? 'INTERNAL',
|
||||
permissions: row.permissions ?? [],
|
||||
status: row.status,
|
||||
});
|
||||
setSubEditOpen(true);
|
||||
}
|
||||
|
||||
async function openAddSubAccount(parentId: string) {
|
||||
await openPartner(parentId);
|
||||
subForm.resetFields();
|
||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
|
||||
setSubOpen(true);
|
||||
}
|
||||
|
||||
async function onCreateCityChange(cityId: string) {
|
||||
const cityRes = await request<{ maxPartnerCommissionRate?: number }>(`/admin/cities/${cityId}`);
|
||||
setCreateMaxRate(cityRes.maxPartnerCommissionRate ?? 0.05);
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
|
||||
{ title: '城市', dataIndex: 'cityName', width: 90 },
|
||||
{ title: '主账号姓名', dataIndex: 'name', width: 100, ellipsis: true },
|
||||
{ title: '登录手机', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '管辖',
|
||||
dataIndex: 'scopeType',
|
||||
width: 90,
|
||||
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
|
||||
},
|
||||
{
|
||||
title: '佣金',
|
||||
width: 110,
|
||||
render: (_, row) =>
|
||||
`${Math.round((row.orderCommissionRate ?? 0) * 100)}% / ${Math.round((row.redeemCommissionRate ?? 0.03) * 100)}%`,
|
||||
},
|
||||
{
|
||||
title: '绑定状态',
|
||||
dataIndex: 'bindingStatus',
|
||||
width: 80,
|
||||
render: (v) => (
|
||||
<Tag color={v === CityPartnerStatus.ACTIVE ? 'green' : 'default'}>
|
||||
{CITY_PARTNER_STATUS_LABELS[v as CityPartnerStatus] || v || '—'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '管仓仓库',
|
||||
dataIndex: 'managedWarehouseName',
|
||||
width: 110,
|
||||
ellipsis: true,
|
||||
render: (v) => v || '—',
|
||||
},
|
||||
{ title: '门店', dataIndex: 'storeCount', width: 60 },
|
||||
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
|
||||
管理
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
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();
|
||||
createForm.setFieldsValue({
|
||||
orderCommissionRate: 0,
|
||||
redeemCommissionRate: 3,
|
||||
scopeType: CityPartnerScopeType.CITY_WIDE,
|
||||
});
|
||||
setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
|
||||
setCreateMaxRate(0.05);
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
新建合伙人
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={
|
||||
<>
|
||||
账号结构仅两级:主账号 → 子账号(子账号不可再添加下级)。管仓仓库请在{' '}
|
||||
<Link to="/city-warehouses">仓库管理</Link> 中分配。
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Form
|
||||
form={filterForm}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) => {
|
||||
setFilters(v);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="companyName" label="公司">
|
||||
<Input allowClear placeholder="公司名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="手机">
|
||||
<Input allowClear placeholder="登录手机" />
|
||||
</Form.Item>
|
||||
<Form.Item name="cityId" label="城市">
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: 160 }}
|
||||
placeholder="全部城市"
|
||||
options={cities.map((c) => ({ value: c.id, label: c.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
filterForm.resetFields();
|
||||
setFilters({});
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
className="admin-table-nowrap"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={toTableRows(data?.items ?? [])}
|
||||
scroll={{ x: 1200 }}
|
||||
childrenColumnName="__noTreeChildren__"
|
||||
expandable={{
|
||||
expandedRowRender: (record) => (
|
||||
<PartnerSubAccountList
|
||||
subs={record.subAccounts ?? []}
|
||||
onAdd={() => void openAddSubAccount(record.id)}
|
||||
onEdit={(sub) => openSubEdit(sub, record.id)}
|
||||
onDelete={(subId) => void deleteSubAccount(subId, record.id)}
|
||||
/>
|
||||
),
|
||||
rowExpandable: () => true,
|
||||
columnWidth: 40,
|
||||
}}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
title="城市合伙人"
|
||||
width={640}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={
|
||||
<Button type="primary" onClick={() => void savePartner()}>
|
||||
保存
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="城市">{detail.cityName ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="门店数">{detail.storeCount ?? 0}</Descriptions.Item>
|
||||
<Descriptions.Item label="管仓仓库" span={2}>
|
||||
{detail.managedWarehouseName ?? (
|
||||
<Typography.Text type="secondary">
|
||||
未分配(请前往 <Link to="/city-warehouses">仓库管理</Link> 设置)
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'info',
|
||||
label: '主账号',
|
||||
children: (
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="contactPhone" label="业务联系电话">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
|
||||
<Select options={BINDING_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
|
||||
</Form.Item>
|
||||
{editScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item name="districtCodes" label="区县">
|
||||
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
<Form.Item name="orderCommissionRate" label="订单佣金 %">
|
||||
<InputNumber min={0} max={100} precision={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="redeemCommissionRate" label="核销佣金 %">
|
||||
<InputNumber min={0} max={100} precision={2} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||
订单 + 核销合计不得超过 {(maxCommissionRate * 100).toFixed(2)}%
|
||||
</Typography.Text>
|
||||
<Form.Item name="bankAccountName" label="户名">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankAccountNo" label="账号">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankBranch" label="开户行">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'staff',
|
||||
label: `子账号 (${Math.max(0, (detail.accountCount ?? 1) - 1)})`,
|
||||
children: (
|
||||
<PartnerSubAccountList
|
||||
subs={detail.children ?? []}
|
||||
onAdd={() => {
|
||||
subForm.resetFields();
|
||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
|
||||
setSubOpen(true);
|
||||
}}
|
||||
onEdit={(sub) => openSubEdit(sub, detail.id)}
|
||||
onDelete={(subId) => void deleteSubAccount(subId, detail.id)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title="新建城市合伙人"
|
||||
open={createOpen}
|
||||
width={560}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
const err = commissionSumError(
|
||||
Number(v.orderCommissionRate ?? 0),
|
||||
Number(v.redeemCommissionRate ?? 3),
|
||||
createMaxRate,
|
||||
);
|
||||
if (err) {
|
||||
message.error(err);
|
||||
return;
|
||||
}
|
||||
await request('/admin/partners', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes:
|
||||
createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
}),
|
||||
});
|
||||
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) => void onCreateCityChange(id)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
||||
</Form.Item>
|
||||
{createScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item name="districtCodes" label="区县" rules={[{ required: true }]}>
|
||||
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
<Form.Item name="orderCommissionRate" label="订单佣金 %">
|
||||
<InputNumber min={0} max={100} precision={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="redeemCommissionRate" label="核销佣金 %">
|
||||
<InputNumber min={0} max={100} precision={2} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Typography.Text type="secondary" style={{ display: 'block' }}>
|
||||
订单 + 核销合计不得超过 {(createMaxRate * 100).toFixed(2)}%(由所选城市配置决定)
|
||||
</Typography.Text>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={detail ? `添加子账号 · ${detail.companyName}` : '添加子账号'}
|
||||
open={subOpen}
|
||||
onCancel={() => setSubOpen(false)}
|
||||
onOk={async () => {
|
||||
if (!detail) return;
|
||||
const v = await subForm.validateFields();
|
||||
await request('/admin/partner-accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...v, parentAccountId: detail.id }),
|
||||
});
|
||||
message.success('已创建');
|
||||
setSubOpen(false);
|
||||
await refreshPartnerContext(detail.id);
|
||||
}}
|
||||
>
|
||||
<Form form={subForm} layout="vertical">
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="staffRole" label="角色" initialValue="INTERNAL">
|
||||
<Select options={STAFF_ROLE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="permissions" label="权限">
|
||||
<Checkbox.Group options={PERM_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="编辑子账号"
|
||||
open={subEditOpen}
|
||||
onCancel={() => setSubEditOpen(false)}
|
||||
onOk={async () => {
|
||||
if (!subEditId) return;
|
||||
const parentId = detail?.id ?? subEditParentId;
|
||||
if (!parentId) return;
|
||||
const v = await subEditForm.validateFields();
|
||||
await request(`/admin/partner-accounts/${subEditId}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
message.success('已更新');
|
||||
setSubEditOpen(false);
|
||||
await refreshPartnerContext(parentId);
|
||||
}}
|
||||
>
|
||||
<Form form={subEditForm} layout="vertical">
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="staffRole" label="角色">
|
||||
<Select options={STAFF_ROLE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'ACTIVE', label: '启用' },
|
||||
{ value: 'DISABLED', label: '停用' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="permissions" label="权限">
|
||||
<Checkbox.Group options={PERM_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Descriptions,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
WAREHOUSE_MANAGER_LABELS,
|
||||
WAREHOUSE_STATUS_LABELS,
|
||||
WarehouseManagerType,
|
||||
WarehouseStatus,
|
||||
} 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;
|
||||
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 }));
|
||||
|
||||
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 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();
|
||||
}, [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,
|
||||
});
|
||||
setEditOpen(true);
|
||||
}
|
||||
|
||||
function onManagerTypeChange(
|
||||
type: WarehouseManagerType,
|
||||
form: typeof createForm | typeof editForm,
|
||||
setType: (v: WarehouseManagerType) => void,
|
||||
) {
|
||||
setType(type);
|
||||
if (type !== WarehouseManagerType.PARTNER) {
|
||||
form.setFieldValue('partnerAccountId', undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '仓库名', dataIndex: 'name', ellipsis: true, width: 140 },
|
||||
{
|
||||
title: '城市',
|
||||
width: 100,
|
||||
render: (_, row) => (
|
||||
<span title={row.cityCode}>
|
||||
{row.cityName}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ title: '地址', dataIndex: 'address', ellipsis: true },
|
||||
{ title: '联系人', dataIndex: 'contactName', width: 90 },
|
||||
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
|
||||
{
|
||||
title: '管仓类型',
|
||||
dataIndex: 'managerType',
|
||||
width: 100,
|
||||
render: (v) => WAREHOUSE_MANAGER_LABELS[v as WarehouseManagerType] || v,
|
||||
},
|
||||
{
|
||||
title: '管仓合伙人',
|
||||
dataIndex: 'partnerCompanyName',
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
render: (v, row) =>
|
||||
v && row.partnerAccountId ? (
|
||||
<Link to="/city-partners">{v}</Link>
|
||||
) : (
|
||||
'—'
|
||||
),
|
||||
},
|
||||
{
|
||||
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();
|
||||
createForm.setFieldsValue({ managerType: WarehouseManagerType.HQ, status: WarehouseStatus.ACTIVE });
|
||||
setCreateManagerType(WarehouseManagerType.HQ);
|
||||
setCreateCityId(undefined);
|
||||
setPartners([]);
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
新建仓库
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="管仓合伙人仅可在此页面分配;城市合伙人详情中的管仓仓库为只读展示。"
|
||||
/>
|
||||
|
||||
<Form
|
||||
form={filterForm}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) => {
|
||||
setFilters(v);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="name" label="仓库名">
|
||||
<Input allowClear placeholder="仓库名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="cityId" label="城市">
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: 140 }}
|
||||
placeholder="全部城市"
|
||||
options={cities.map((c) => ({ value: c.id, label: c.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="managerType" label="管仓类型">
|
||||
<Select allowClear style={{ width: 120 }} placeholder="全部" options={MANAGER_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear style={{ width: 100 }} placeholder="全部" options={STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
filterForm.resetFields();
|
||||
setFilters({});
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
className="admin-table-nowrap"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
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>
|
||||
</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>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tabs, Tag, Typography, message,
|
||||
Button, Checkbox, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tabs, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { PARTNER_STAFF_ROLE_LABELS } from '@dukang/shared-types';
|
||||
import { PARTNER_PERMISSION_KEYS, PARTNER_PERMISSION_LABELS, PARTNER_STAFF_ROLE_LABELS, type PartnerPermissionKey } from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { AdminCellLine } from '../components/AdminCellLine';
|
||||
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
@@ -18,11 +18,12 @@ type AccountTreeRow = {
|
||||
status: string;
|
||||
isPrimary: number;
|
||||
staffRole?: string | null;
|
||||
permissions?: string[] | null;
|
||||
parentAccountId?: string | null;
|
||||
parent?: ParentAccount | null;
|
||||
createdAt: string;
|
||||
lastLoginAt?: string | null;
|
||||
partner?: { id: string; companyName: string };
|
||||
companyName?: string | null;
|
||||
children?: AccountTreeRow[];
|
||||
};
|
||||
|
||||
@@ -43,6 +44,11 @@ const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS).map(([value
|
||||
label,
|
||||
}));
|
||||
|
||||
const PERMISSION_OPTIONS = PARTNER_PERMISSION_KEYS.map((key: PartnerPermissionKey) => ({
|
||||
value: key,
|
||||
label: PARTNER_PERMISSION_LABELS[key],
|
||||
}));
|
||||
|
||||
function filterTree(rows: AccountTreeRow[], phone?: string, status?: string): AccountTreeRow[] {
|
||||
const phoneQ = phone?.trim();
|
||||
const match = (row: AccountTreeRow) => {
|
||||
@@ -65,10 +71,6 @@ function filterTree(rows: AccountTreeRow[], phone?: string, status?: string): Ac
|
||||
return walk(rows);
|
||||
}
|
||||
|
||||
function accountTypeLabel(isPrimary: number) {
|
||||
return isPrimary === 1 ? '主账号' : '子账号';
|
||||
}
|
||||
|
||||
function staffRoleLabel(role?: string | null) {
|
||||
if (!role) return '-';
|
||||
return PARTNER_STAFF_ROLE_LABELS[role as keyof typeof PARTNER_STAFF_ROLE_LABELS] ?? role;
|
||||
@@ -77,7 +79,6 @@ function staffRoleLabel(role?: string | null) {
|
||||
export default function PartnerAccountsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [subForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<{ phone?: string; status?: string; partnerId?: string }>({});
|
||||
const [treeData, setTreeData] = useState<AccountTreeRow[]>([]);
|
||||
@@ -85,7 +86,6 @@ export default function PartnerAccountsPage() {
|
||||
const [expandedRowKeys, setExpandedRowKeys] = useState<string[]>([]);
|
||||
const [detail, setDetail] = useState<Detail | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [subOpen, setSubOpen] = useState(false);
|
||||
const [subParent, setSubParent] = useState<AccountTreeRow | null>(null);
|
||||
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||
@@ -117,6 +117,7 @@ export default function PartnerAccountsPage() {
|
||||
name: detail.name,
|
||||
phone: detail.phone,
|
||||
status: detail.status,
|
||||
permissions: detail.permissions ?? [],
|
||||
});
|
||||
}, [drawerOpen, detail, editForm]);
|
||||
|
||||
@@ -153,7 +154,7 @@ export default function PartnerAccountsPage() {
|
||||
function openAddSub(parent: AccountTreeRow) {
|
||||
setSubParent(parent);
|
||||
subForm.resetFields();
|
||||
subForm.setFieldsValue({ staffRole: 'INTERNAL' });
|
||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
|
||||
setSubOpen(true);
|
||||
}
|
||||
|
||||
@@ -179,9 +180,7 @@ export default function PartnerAccountsPage() {
|
||||
secondary={
|
||||
row.parentAccountId
|
||||
? `子账号 · ${staffRoleLabel(row.staffRole)}`
|
||||
: row.isPrimary === 1
|
||||
? '主账号'
|
||||
: '合伙人账号'
|
||||
: '主账号'
|
||||
}
|
||||
/>
|
||||
),
|
||||
@@ -191,15 +190,14 @@ export default function PartnerAccountsPage() {
|
||||
title: '开城合伙人',
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
render: (_, row) => row.partner?.companyName || '—',
|
||||
render: (_, row) => row.companyName || row.parent?.name || '—',
|
||||
},
|
||||
{
|
||||
title: '账号类型',
|
||||
dataIndex: 'isPrimary',
|
||||
width: 90,
|
||||
render: (v, row) => (
|
||||
<Tag color={row.parentAccountId ? 'default' : v === 1 ? 'blue' : 'default'}>
|
||||
{row.parentAccountId ? '子账号' : accountTypeLabel(v)}
|
||||
render: (_, row) => (
|
||||
<Tag color={row.parentAccountId ? 'default' : 'blue'}>
|
||||
{row.parentAccountId ? '子账号' : '主账号'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
@@ -213,9 +211,9 @@ export default function PartnerAccountsPage() {
|
||||
title: '所属主账号',
|
||||
width: 140,
|
||||
render: (_, row) => (
|
||||
row.isPrimary === 1 || !row.parentAccountId
|
||||
? '-'
|
||||
: (row.parent ? `${row.parent.name} / ${row.parent.phone}` : '-')
|
||||
row.parentAccountId && row.parent
|
||||
? `${row.parent.name} / ${row.parent.phone}`
|
||||
: '-'
|
||||
),
|
||||
},
|
||||
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> },
|
||||
@@ -226,7 +224,7 @@ export default function PartnerAccountsPage() {
|
||||
render: (_, row) => (
|
||||
<Space size={0} wrap onClick={(e) => e.stopPropagation()}>
|
||||
<Button type="link" size="small" onClick={() => void openAccount(row.id)}>详情</Button>
|
||||
{!row.parentAccountId && row.isPrimary === 1 ? (
|
||||
{!row.parentAccountId ? (
|
||||
<Button type="link" size="small" onClick={() => openAddSub(row)}>添加子账号</Button>
|
||||
) : null}
|
||||
{row.parentAccountId ? (
|
||||
@@ -260,10 +258,11 @@ export default function PartnerAccountsPage() {
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>开城合伙人账户</Typography.Title>
|
||||
<Typography.Text type="secondary">主账号下可展开/收起子账号,支持添加与删除子账号</Typography.Text>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>合伙人子账号</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
主账号在「开城合伙人」创建;此处仅管理子账号树与权限
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Button type="primary" onClick={() => { void loadPartners(); setCreateOpen(true); }}>新建账户</Button>
|
||||
</Space>
|
||||
<Form
|
||||
form={form}
|
||||
@@ -315,7 +314,7 @@ export default function PartnerAccountsPage() {
|
||||
}}
|
||||
/>
|
||||
<Drawer
|
||||
title={detail?.parentAccountId ? '合伙人子账号详情' : '开城合伙人账户详情'}
|
||||
title={detail?.parentAccountId ? '子账号详情' : '主账号详情(只读)'}
|
||||
width={720}
|
||||
open={drawerOpen}
|
||||
onClose={() => {
|
||||
@@ -323,7 +322,11 @@ export default function PartnerAccountsPage() {
|
||||
setDetail(null);
|
||||
}}
|
||||
destroyOnClose
|
||||
extra={<Button type="primary" onClick={() => void saveAccount()}>保存</Button>}
|
||||
extra={
|
||||
detail?.parentAccountId
|
||||
? <Button type="primary" onClick={() => void saveAccount()}>保存</Button>
|
||||
: null
|
||||
}
|
||||
>
|
||||
{detail && (
|
||||
<Tabs items={[
|
||||
@@ -339,32 +342,28 @@ export default function PartnerAccountsPage() {
|
||||
name: detail.name,
|
||||
phone: detail.phone,
|
||||
status: detail.status,
|
||||
permissions: detail.permissions ?? [],
|
||||
}}
|
||||
>
|
||||
<Form.Item label="开城合伙人">
|
||||
<Input value={detail.partner?.companyName} disabled />
|
||||
<Input value={detail.companyName || detail.parent?.name || '—'} disabled />
|
||||
</Form.Item>
|
||||
<Form.Item label="账号类型">
|
||||
<Input
|
||||
value={
|
||||
detail.parentAccountId
|
||||
? `子账号 · ${staffRoleLabel(detail.staffRole)}`
|
||||
: accountTypeLabel(detail.isPrimary)
|
||||
: '主账号'
|
||||
}
|
||||
disabled
|
||||
/>
|
||||
</Form.Item>
|
||||
{detail.staffRole ? (
|
||||
<Form.Item label="角色">
|
||||
<Input value={staffRoleLabel(detail.staffRole)} disabled />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{detail.parentAccountId && detail.parent ? (
|
||||
<Form.Item label="所属主账号">
|
||||
<Input value={`${detail.parent.name} / ${detail.parent.phone}`} disabled />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input disabled={!detail.parentAccountId} /></Form.Item>
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label="登录手机"
|
||||
@@ -373,14 +372,25 @@ export default function PartnerAccountsPage() {
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' },
|
||||
]}
|
||||
>
|
||||
<Input maxLength={11} />
|
||||
<Input maxLength={11} disabled={!detail.parentAccountId} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||
<Select options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
<Select disabled={!detail.parentAccountId} options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Typography.Text type="secondary">
|
||||
合伙人 H5 登录使用「登录手机」,与开城合伙人主体的「联系电话」可不同。
|
||||
</Typography.Text>
|
||||
{detail.parentAccountId ? (
|
||||
<Form.Item name="permissions" label="权限">
|
||||
<Checkbox.Group options={PERMISSION_OPTIONS} />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{!detail.parentAccountId ? (
|
||||
<Typography.Text type="secondary">
|
||||
主账号请在「开城合伙人」页面编辑。
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text type="secondary">
|
||||
合伙人 H5 登录使用「登录手机」,与主账号联系电话可不同。
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
@@ -403,31 +413,6 @@ export default function PartnerAccountsPage() {
|
||||
]} />
|
||||
)}
|
||||
</Drawer>
|
||||
<Modal title="新建开城合伙人账户" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
await request('/admin/partner-accounts', { method: 'POST', body: JSON.stringify(v) });
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void loadTree();
|
||||
}}>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="partnerId" label="开城合伙人" rules={[{ required: true }]}>
|
||||
<Select showSearch optionFilterProp="label" options={partners.map((p) => ({ value: p.id, label: p.companyName }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label="登录手机"
|
||||
rules={[
|
||||
{ required: true },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' },
|
||||
]}
|
||||
>
|
||||
<Input maxLength={11} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={subParent ? `添加子账号 · ${subParent.name}` : '添加子账号'}
|
||||
open={subOpen}
|
||||
@@ -438,11 +423,11 @@ export default function PartnerAccountsPage() {
|
||||
await request('/admin/partner-accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
partnerId: subParent.partner?.id,
|
||||
parentAccountId: subParent.id,
|
||||
name: v.name,
|
||||
phone: v.phone,
|
||||
staffRole: v.staffRole,
|
||||
permissions: v.permissions,
|
||||
}),
|
||||
});
|
||||
message.success('子账号已创建');
|
||||
@@ -466,6 +451,9 @@ export default function PartnerAccountsPage() {
|
||||
<Form.Item name="staffRole" label="角色" rules={[{ required: true }]}>
|
||||
<Select options={STAFF_ROLE_OPTIONS.filter((o) => o.value !== 'PARTNER')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="permissions" label="权限">
|
||||
<Checkbox.Group options={PERMISSION_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -1,39 +1,86 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Descriptions, Drawer, Form, Input, Modal, Space, Table, Typography, message,
|
||||
Button,
|
||||
Cascader,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import {
|
||||
CITY_PARTNER_SCOPE_LABELS,
|
||||
CityPartnerScopeType,
|
||||
PARTNER_PERMISSION_KEYS,
|
||||
PARTNER_PERMISSION_LABELS,
|
||||
type PartnerPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { CHINA_REGION_OPTIONS } from '../lib/china-region';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string; companyName: string; contactPhone: string; address: string;
|
||||
storeCount: number; accountCount: number; cityCount: number; createdAt: string;
|
||||
id: string;
|
||||
companyName: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
contactPhone?: string | null;
|
||||
cityId?: string | null;
|
||||
cityName?: string | null;
|
||||
scopeType?: string;
|
||||
orderCommissionRate?: number;
|
||||
redeemCommissionRate?: number;
|
||||
storeCount: number;
|
||||
accountCount: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type PartnerDetail = Row & {
|
||||
address?: string;
|
||||
districtCodes?: string[] | null;
|
||||
bindingStatus?: string;
|
||||
bankAccountName?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
bankBranch?: string | null;
|
||||
cities?: Array<{ name: string; code: string; status: string }>;
|
||||
managedWarehouseId?: string | null;
|
||||
children?: Array<{
|
||||
id: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
staffRole?: string;
|
||||
permissions?: string[];
|
||||
status: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
function pickPartnerFormValues(d: PartnerDetail) {
|
||||
return {
|
||||
companyName: d.companyName,
|
||||
contactPhone: d.contactPhone,
|
||||
address: d.address,
|
||||
bankAccountName: d.bankAccountName ?? '',
|
||||
bankAccountNo: d.bankAccountNo ?? '',
|
||||
bankBranch: d.bankBranch ?? '',
|
||||
};
|
||||
type CityOption = { id: string; name: string; code: string };
|
||||
type WarehouseOption = { id: string; name: string };
|
||||
|
||||
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
|
||||
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({ value: k, label: PARTNER_PERMISSION_LABELS[k] }));
|
||||
|
||||
function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
|
||||
if (!values?.length) return [];
|
||||
if (Array.isArray(values[0])) {
|
||||
return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean);
|
||||
}
|
||||
return values as string[];
|
||||
}
|
||||
|
||||
export default function PartnersPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [subForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/partners',
|
||||
@@ -48,37 +95,86 @@ export default function PartnersPage() {
|
||||
const [detail, setDetail] = useState<PartnerDetail | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [subOpen, setSubOpen] = useState(false);
|
||||
const [cities, setCities] = useState<CityOption[]>([]);
|
||||
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
|
||||
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
const [createCityId, setCreateCityId] = useState<string | undefined>();
|
||||
|
||||
const loadCities = useCallback(async () => {
|
||||
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||
setCities(res.items);
|
||||
}, []);
|
||||
|
||||
const loadWarehouses = useCallback(async (cityId: string) => {
|
||||
const rows = await request<WarehouseOption[]>(`/admin/cities/${cityId}/warehouses`);
|
||||
setWarehouses(rows.map((w) => ({ id: w.id, name: (w as { name: string }).name })));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCities();
|
||||
}, [loadCities]);
|
||||
|
||||
async function openPartner(id: string) {
|
||||
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue(pickPartnerFormValues(d));
|
||||
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
|
||||
if (d.cityId) await loadWarehouses(d.cityId);
|
||||
editForm.setFieldsValue({
|
||||
name: d.name,
|
||||
phone: d.phone,
|
||||
companyName: d.companyName,
|
||||
contactPhone: d.contactPhone ?? d.phone,
|
||||
address: d.address ?? '',
|
||||
scopeType: d.scopeType,
|
||||
districtCodes: d.districtCodes ?? [],
|
||||
orderCommissionRate: (d.orderCommissionRate ?? 0) * 100,
|
||||
redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100,
|
||||
bindingStatus: d.bindingStatus,
|
||||
managedWarehouseId: d.managedWarehouseId,
|
||||
bankAccountName: d.bankAccountName ?? '',
|
||||
bankAccountNo: d.bankAccountNo ?? '',
|
||||
bankBranch: d.bankBranch ?? '',
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
async function savePartner() {
|
||||
if (!detail) return;
|
||||
const v = await editForm.validateFields();
|
||||
await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
const body = {
|
||||
...v,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
};
|
||||
await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify(body) });
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '公司名', dataIndex: 'companyName' },
|
||||
{ title: '联系电话', dataIndex: 'contactPhone', width: 130 },
|
||||
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
|
||||
{ title: '城市', dataIndex: 'cityName', width: 100 },
|
||||
{ title: '主账号', dataIndex: 'phone', width: 130 },
|
||||
{
|
||||
title: '管辖',
|
||||
dataIndex: 'scopeType',
|
||||
width: 100,
|
||||
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
|
||||
},
|
||||
{ title: '门店', dataIndex: 'storeCount', width: 70 },
|
||||
{ title: '账号', dataIndex: 'accountCount', width: 70 },
|
||||
{ title: '开城城市', dataIndex: 'cityCount', width: 90 },
|
||||
{ title: '子账号', dataIndex: 'accountCount', width: 80, render: (n) => Math.max(0, n - 1) },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 120,
|
||||
title: '操作',
|
||||
width: 120,
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>详情</Button>
|
||||
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>编辑</Button>
|
||||
</Space>
|
||||
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
|
||||
管理
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -87,54 +183,178 @@ export default function PartnersPage() {
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>开城合伙人</Typography.Title>
|
||||
<Button type="primary" onClick={() => setCreateOpen(true)}>新建开城合伙人</Button>
|
||||
<Button type="primary" onClick={() => { setCreateOpen(true); createForm.resetFields(); setCreateScopeType(CityPartnerScopeType.CITY_WIDE); }}>
|
||||
新建城市合伙人
|
||||
</Button>
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="companyName" label="公司"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="contactPhone" label="电话"><Input allowClear /></Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Table
|
||||
rowKey="id"
|
||||
className="admin-table-nowrap"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
title="编辑开城合伙人"
|
||||
width={560}
|
||||
title="城市合伙人"
|
||||
width={640}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={<Button type="primary" onClick={() => void savePartner()}>保存</Button>}
|
||||
>
|
||||
{detail && (
|
||||
<>
|
||||
{Array.isArray(detail.cities) && detail.cities.length > 0 && (
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }} title="关联开城城市">
|
||||
{detail.cities.map((c) => (
|
||||
<Descriptions.Item key={c.code} label={c.code}>{c.name} ({c.status})</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
)}
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="contactPhone" label="联系电话" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="bankAccountName" label="户名"><Input /></Form.Item>
|
||||
<Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item>
|
||||
<Form.Item name="bankBranch" label="开户行"><Input /></Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'info',
|
||||
label: '基本信息',
|
||||
children: (
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
|
||||
</Form.Item>
|
||||
{editScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item name="districtCodes" label="区县">
|
||||
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="managedWarehouseId" label="管仓仓库">
|
||||
<Select allowClear options={warehouses.map((w) => ({ value: w.id, label: w.name }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankAccountName" label="户名"><Input /></Form.Item>
|
||||
<Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item>
|
||||
<Form.Item name="bankBranch" label="开户行"><Input /></Form.Item>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'staff',
|
||||
label: '子账号',
|
||||
children: (
|
||||
<>
|
||||
<Button type="primary" style={{ marginBottom: 12 }} onClick={() => { subForm.resetFields(); setSubOpen(true); }}>
|
||||
添加子账号
|
||||
</Button>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
dataSource={detail.children ?? []}
|
||||
columns={[
|
||||
{ title: '姓名', dataIndex: 'name' },
|
||||
{ title: '手机', dataIndex: 'phone' },
|
||||
{ title: '状态', dataIndex: 'status', render: (s) => <Tag>{s}</Tag> },
|
||||
{
|
||||
title: '权限',
|
||||
dataIndex: 'permissions',
|
||||
render: (p: string[] | undefined) => p?.map((k) => PARTNER_PERMISSION_LABELS[k as keyof typeof PARTNER_PERMISSION_LABELS] || k).join('、') || '—',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
<Modal title="新建开城合伙人" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
await request('/admin/partners', { method: 'POST', body: JSON.stringify(v) });
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void reload();
|
||||
}}>
|
||||
<Form form={createForm} layout="vertical">
|
||||
|
||||
<Modal
|
||||
title="新建城市合伙人"
|
||||
open={createOpen}
|
||||
width={560}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
await request('/admin/partners', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void reload();
|
||||
}}
|
||||
>
|
||||
<Form form={createForm} layout="vertical" initialValues={{ orderCommissionRate: 0, redeemCommissionRate: 3, scopeType: CityPartnerScopeType.CITY_WIDE }}>
|
||||
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))}
|
||||
onChange={(id) => { setCreateCityId(id); void loadWarehouses(id); }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="contactPhone" label="联系电话" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
||||
</Form.Item>
|
||||
{createScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item name="districtCodes" label="区县" rules={[{ required: true }]}>
|
||||
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
</Space>
|
||||
{createCityId && (
|
||||
<Form.Item name="managedWarehouseId" label="管仓仓库(可选)">
|
||||
<Select allowClear options={warehouses.map((w) => ({ value: w.id, label: w.name }))} />
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="添加子账号"
|
||||
open={subOpen}
|
||||
onCancel={() => setSubOpen(false)}
|
||||
onOk={async () => {
|
||||
if (!detail) return;
|
||||
const v = await subForm.validateFields();
|
||||
await request('/admin/partner-accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...v, parentAccountId: detail.id }),
|
||||
});
|
||||
message.success('已创建');
|
||||
setSubOpen(false);
|
||||
void openPartner(detail.id);
|
||||
}}
|
||||
>
|
||||
<Form form={subForm} layout="vertical">
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="permissions" label="权限">
|
||||
<Select mode="multiple" options={PERM_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Form,
|
||||
Image,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
@@ -57,8 +58,7 @@ type CityOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
partnerId?: string | null;
|
||||
partner?: { id: string; companyName: string };
|
||||
partnerBindings?: Array<{ partnerAccountId: string; partnerCompanyName?: string }>;
|
||||
};
|
||||
|
||||
export default function StoresPage() {
|
||||
@@ -87,12 +87,12 @@ export default function StoresPage() {
|
||||
const [cities, setCities] = useState<CityOption[]>([]);
|
||||
const [optionsLoading, setOptionsLoading] = useState(false);
|
||||
|
||||
const selectedPartnerId = Form.useWatch('partnerId', createForm);
|
||||
const selectedPartnerId = Form.useWatch('partnerAccountId', createForm);
|
||||
const selectedRegionCodes = Form.useWatch('regionCodes', createForm);
|
||||
const selectedCityId = Form.useWatch('cityId', createForm);
|
||||
|
||||
function bindRegionSelection(codes: string[], partnerId?: string) {
|
||||
const binding = resolveRegionBinding(codes, cities, partnerId ?? selectedPartnerId);
|
||||
function bindRegionSelection(codes: string[], partnerAccountId?: string) {
|
||||
const binding = resolveRegionBinding(codes, cities, partnerAccountId ?? selectedPartnerId);
|
||||
if (!binding) {
|
||||
createForm.setFieldsValue({ regionCodes: codes, cityId: undefined });
|
||||
return;
|
||||
@@ -114,7 +114,7 @@ export default function StoresPage() {
|
||||
if (binding.cityId && binding.matchedCity) {
|
||||
return `已匹配开城城市:${binding.matchedCity.name}(区划 ${binding.cityCode},区县 ${binding.region.districtCode})`;
|
||||
}
|
||||
return `区划 ${binding.cityCode} 暂未开城,请先在「开城 → 开城城市」添加`;
|
||||
return `区划 ${binding.cityCode} 暂未开城,请先在「开城 → 城市」添加`;
|
||||
}, [selectedRegionCodes, cities, selectedPartnerId]);
|
||||
|
||||
async function loadOptions() {
|
||||
@@ -127,8 +127,8 @@ export default function StoresPage() {
|
||||
]);
|
||||
setPartners(p.items);
|
||||
setCities(c.items);
|
||||
if (!p.items.length) message.warning('暂无开城合伙人,请先在「开城 → 开城合伙人」中创建');
|
||||
if (!c.items.length) message.warning('暂无开城城市,请先在「开城 → 开城城市」中创建');
|
||||
if (!p.items.length) message.warning('暂无开城合伙人,请先在「开城 → 城市」详情中创建合伙人');
|
||||
if (!c.items.length) message.warning('暂无开城城市,请先在「开城 → 城市」中创建');
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载合伙人/城市失败');
|
||||
} finally {
|
||||
@@ -147,6 +147,7 @@ export default function StoresPage() {
|
||||
void loadOptions();
|
||||
createForm.setFieldsValue({
|
||||
envPhotoUrls: ['', '', ''],
|
||||
settlementRate: 60,
|
||||
});
|
||||
setCreateStep(0);
|
||||
setCreateError('');
|
||||
@@ -162,7 +163,7 @@ export default function StoresPage() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createForm.validateFields(['partnerId', 'regionCodes', 'cityId', 'name', 'phone', 'address']);
|
||||
await createForm.validateFields(['partnerAccountId', 'regionCodes', 'cityId', 'name', 'phone', 'address']);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
@@ -184,7 +185,7 @@ export default function StoresPage() {
|
||||
await request('/admin/stores', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
partnerId: values.partnerId,
|
||||
partnerAccountId: values.partnerAccountId,
|
||||
cityId: values.cityId,
|
||||
province: values.province,
|
||||
city: values.city,
|
||||
@@ -199,6 +200,7 @@ export default function StoresPage() {
|
||||
bankAccountName: values.bankAccountName.trim(),
|
||||
bankAccountNo: values.bankAccountNo.replace(/\s/g, ''),
|
||||
bankBranch: values.bankBranch.trim(),
|
||||
settlementRate: Number(values.settlementRate ?? 60) / 100,
|
||||
}),
|
||||
});
|
||||
message.success('门店已创建');
|
||||
@@ -208,7 +210,7 @@ export default function StoresPage() {
|
||||
if (e && typeof e === 'object' && 'errorFields' in e) {
|
||||
const fields = e as { errorFields?: Array<{ name: string[] }> };
|
||||
const first = fields.errorFields?.[0]?.name?.[0];
|
||||
if (first === 'partnerId' || first === 'cityId' || first === 'regionCodes' || first === 'name' || first === 'phone') {
|
||||
if (first === 'partnerAccountId' || first === 'cityId' || first === 'regionCodes' || first === 'name' || first === 'phone') {
|
||||
setCreateStep(0);
|
||||
}
|
||||
return;
|
||||
@@ -239,7 +241,15 @@ export default function StoresPage() {
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
const d = await request<Record<string, unknown>>(`/admin/stores/${row.id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue({ name: d.name, phone: d.phone, intro: d.intro, coverUrl: d.coverUrl, address: d.address, district: d.district });
|
||||
editForm.setFieldsValue({
|
||||
name: d.name,
|
||||
phone: d.phone,
|
||||
intro: d.intro,
|
||||
coverUrl: d.coverUrl,
|
||||
address: d.address,
|
||||
district: d.district,
|
||||
settlementRate: d.settlementRate != null ? Number(d.settlementRate) * 100 : 60,
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
}}>详情</Button>
|
||||
),
|
||||
@@ -279,7 +289,11 @@ export default function StoresPage() {
|
||||
}} />
|
||||
<Button type="primary" onClick={async () => {
|
||||
const v = await editForm.validateFields();
|
||||
await request(`/admin/stores/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
const payload = {
|
||||
...v,
|
||||
settlementRate: v.settlementRate != null ? Number(v.settlementRate) / 100 : undefined,
|
||||
};
|
||||
await request(`/admin/stores/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||
message.success('已保存');
|
||||
setDetail({ ...detail, ...v });
|
||||
void reload();
|
||||
@@ -291,6 +305,9 @@ export default function StoresPage() {
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
||||
<Descriptions.Item label="地址">{String(detail.province)}{String(detail.cityName)}{String(detail.district)}{String(detail.address)}</Descriptions.Item>
|
||||
<Descriptions.Item label="核销结算比例">
|
||||
{detail.settlementRate != null ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '60%'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="核销数">{String(detail.redeemCount ?? 0)}</Descriptions.Item>
|
||||
<Descriptions.Item label="操作">
|
||||
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => navigate(`/logs/stores?storeId=${detail.id}`)}>
|
||||
@@ -312,6 +329,9 @@ export default function StoresPage() {
|
||||
<Form.Item name="intro" label="介绍"><Input.TextArea rows={4} /></Form.Item>
|
||||
<Form.Item name="district" label="区县"><Input /></Form.Item>
|
||||
<Form.Item name="address" label="详细地址"><Input /></Form.Item>
|
||||
<Form.Item name="settlementRate" label="核销结算比例 %" initialValue={60} rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} addonAfter="%" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
@@ -339,16 +359,16 @@ export default function StoresPage() {
|
||||
)}
|
||||
<Form form={createForm} layout="vertical" preserve>
|
||||
<div style={{ display: createStep === 0 ? 'block' : 'none' }}>
|
||||
<Form.Item name="partnerId" label="开城合伙人" rules={[{ required: true, message: '请选择开城合伙人' }]}>
|
||||
<Form.Item name="partnerAccountId" label="开城合伙人" rules={[{ required: true, message: '请选择开城合伙人' }]}>
|
||||
<Select
|
||||
showSearch
|
||||
loading={optionsLoading}
|
||||
optionFilterProp="label"
|
||||
placeholder={optionsLoading ? '加载中…' : '请选择开城合伙人'}
|
||||
options={partners.map((p) => ({ value: p.id, label: p.companyName }))}
|
||||
onChange={(partnerId) => {
|
||||
onChange={(partnerAccountId) => {
|
||||
const codes = createForm.getFieldValue('regionCodes') as string[] | undefined;
|
||||
if (codes?.length === 3) bindRegionSelection(codes, partnerId);
|
||||
if (codes?.length === 3) bindRegionSelection(codes, partnerAccountId);
|
||||
else createForm.setFieldValue('cityId', undefined);
|
||||
}}
|
||||
/>
|
||||
@@ -418,6 +438,9 @@ export default function StoresPage() {
|
||||
<Form.Item name="bankBranch" label="开户支行" rules={[{ required: true, message: '请填写开户支行' }]}>
|
||||
<Input placeholder="例如:中国工商银行洛阳分行" />
|
||||
</Form.Item>
|
||||
<Form.Item name="settlementRate" label="核销结算比例 %" initialValue={60} rules={[{ required: true, message: '请填写结算比例' }]}>
|
||||
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} addonAfter="%" />
|
||||
</Form.Item>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
|
||||
Reference in New Issue
Block a user