fix(admin): city-scoped district pick and clearer overlap errors
CI / verify (pull_request) Has been cancelled

Region partners only select districts under the chosen city; overlap errors name the occupying partner.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-22 18:14:54 +08:00
parent 85f2c156b0
commit 8c4419e04e
9 changed files with 338 additions and 140 deletions
@@ -0,0 +1,42 @@
import { useMemo } from 'react';
import { Select } from 'antd';
import { getDistrictOptionsByCityCode } from '../lib/china-region';
type Props = {
cityCode?: string | null;
value?: string[];
onChange?: (value: string[]) => void;
placeholder?: string;
disabled?: boolean;
};
/** 已选开城城市后,仅多选该市下区县(不再选省/市) */
export default function CityDistrictMultiSelect({
cityCode,
value,
onChange,
placeholder,
disabled,
}: Props) {
const options = useMemo(() => getDistrictOptionsByCityCode(cityCode), [cityCode]);
const ready = Boolean(cityCode) && options.length > 0;
return (
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
value={value}
onChange={onChange}
disabled={disabled || !ready}
placeholder={
placeholder ??
(cityCode ? (ready ? '请选择区县(可多选)' : '该城市暂无区县数据') : '请先选择开城城市')
}
options={options}
style={{ width: '100%' }}
maxTagCount="responsive"
/>
);
}
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { import {
Button, Button,
Cascader,
Checkbox, Checkbox,
Drawer, Drawer,
Form, Form,
@@ -27,8 +26,9 @@ import {
type PartnerPermissionKey, type PartnerPermissionKey,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { CHINA_REGION_OPTIONS } from '../lib/china-region'; import { districtCodeLabel } from '../lib/china-region';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import CityDistrictMultiSelect from './CityDistrictMultiSelect';
import PartnerSubAccountList from './PartnerSubAccountList'; import PartnerSubAccountList from './PartnerSubAccountList';
type PartnerRow = { type PartnerRow = {
@@ -65,6 +65,7 @@ type PartnerDetail = PartnerRow & {
type Props = { type Props = {
cityId: string; cityId: string;
cityCode?: string;
maxPartnerCommissionRate?: number; maxPartnerCommissionRate?: number;
onChanged?: () => void; onChanged?: () => void;
}; };
@@ -102,7 +103,21 @@ function commissionSumError(
return null; return null;
} }
export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0.05, onChanged }: Props) { function formatApiError(err: unknown): string | null {
if (err && typeof err === 'object' && 'errorFields' in err) return null;
if (!(err instanceof Error)) return '操作失败';
return err.message.replace(/\b(\d{6})\b/g, (code) => {
const label = districtCodeLabel(code);
return label !== code ? `${label}(${code})` : code;
});
}
export default function CityPartnersPanel({
cityId,
cityCode,
maxPartnerCommissionRate = 0.05,
onChanged,
}: Props) {
const [editForm] = Form.useForm(); const [editForm] = Form.useForm();
const [createForm] = Form.useForm(); const [createForm] = Form.useForm();
const [subForm] = Form.useForm(); const [subForm] = Form.useForm();
@@ -156,29 +171,34 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
async function savePartner() { async function savePartner() {
if (!detail) return; if (!detail) return;
const v = await editForm.validateFields(); try {
const err = commissionSumError( const v = await editForm.validateFields();
Number(v.orderCommissionRate ?? 0), const err = commissionSumError(
Number(v.redeemCommissionRate ?? 0), Number(v.orderCommissionRate ?? 0),
maxPartnerCommissionRate, Number(v.redeemCommissionRate ?? 0),
); maxPartnerCommissionRate,
if (err) { );
message.error(err); if (err) {
return; 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?.();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
} }
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) { async function deleteSubAccount(subId: string) {
@@ -264,8 +284,12 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} /> <Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
</Form.Item> </Form.Item>
{editScopeType === CityPartnerScopeType.DISTRICT && ( {editScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item name="districtCodes" label="区县"> <Form.Item
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect /> name="districtCodes"
label="区县"
extra="仅可选当前开城城市下的区县;不可与其他区域合伙人重合"
>
<CityDistrictMultiSelect cityCode={cityCode} />
</Form.Item> </Form.Item>
)} )}
<Space style={{ width: '100%' }} size="large"> <Space style={{ width: '100%' }} size="large">
@@ -319,30 +343,35 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
</Drawer> </Drawer>
<Modal title="新建城市合伙人" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => { <Modal title="新建城市合伙人" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => {
const v = await createForm.validateFields(); try {
const err = commissionSumError( const v = await createForm.validateFields();
Number(v.orderCommissionRate ?? 0), const err = commissionSumError(
Number(v.redeemCommissionRate ?? 3), Number(v.orderCommissionRate ?? 0),
maxPartnerCommissionRate, Number(v.redeemCommissionRate ?? 3),
); maxPartnerCommissionRate,
if (err) { );
message.error(err); if (err) {
return; 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?.();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
} }
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 form={createForm} layout="vertical">
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
@@ -353,8 +382,13 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} /> <Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
</Form.Item> </Form.Item>
{createScopeType === CityPartnerScopeType.DISTRICT && ( {createScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item name="districtCodes" label="区县" rules={[{ required: true }]}> <Form.Item
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect /> name="districtCodes"
label="区县"
rules={[{ required: true, message: '请选择至少一个区县' }]}
extra="仅可选当前开城城市下的区县;不可与其他区域合伙人重合"
>
<CityDistrictMultiSelect cityCode={cityCode} />
</Form.Item> </Form.Item>
)} )}
<Space style={{ width: '100%' }} size="large"> <Space style={{ width: '100%' }} size="large">
+27
View File
@@ -79,6 +79,33 @@ export function districtCodeToCityCode(districtCode: string): string {
return `${districtCode.slice(0, 4)}00`; return `${districtCode.slice(0, 4)}00`;
} }
export type DistrictOption = { value: string; label: string };
/** 按开城城市 code(4 位或 6 位)取该市下区县列表 */
export function getDistrictOptionsByCityCode(cityCode?: string | null): DistrictOption[] {
if (!cityCode?.trim()) return [];
const normalized = normalizeCityAdcode(cityCode.trim());
const prefix4 = normalized.slice(0, 4);
for (const province of regionData) {
for (const city of province.children ?? []) {
const cityValue = String(city.value);
const cityNorm = normalizeCityAdcode(cityValue);
if (cityNorm === normalized || cityValue === prefix4 || cityValue === cityCode.trim()) {
return (city.children ?? []).map((d) => ({
value: String(d.value),
label: String(d.label),
}));
}
}
}
return [];
}
export function districtCodeLabel(code: string): string {
return codeToText[code] || code;
}
export function parseRegionCodes(codes?: string[]): ParsedChinaRegion | null { export function parseRegionCodes(codes?: string[]): ParsedChinaRegion | null {
if (!codes || codes.length < 3) return null; if (!codes || codes.length < 3) return null;
const [provinceCode, cityCode, districtCode] = codes; const [provinceCode, cityCode, districtCode] = codes;
+1
View File
@@ -274,6 +274,7 @@ export default function CitiesPage() {
children: detail?.id ? ( children: detail?.id ? (
<CityPartnersPanel <CityPartnersPanel
cityId={String(detail.id)} cityId={String(detail.id)}
cityCode={detail.code != null ? String(detail.code) : undefined}
maxPartnerCommissionRate={ maxPartnerCommissionRate={
detail.maxPartnerCommissionRate != null detail.maxPartnerCommissionRate != null
? Number(detail.maxPartnerCommissionRate) ? Number(detail.maxPartnerCommissionRate)
+86 -49
View File
@@ -3,7 +3,6 @@ import { Link } from 'react-router-dom';
import { import {
Alert, Alert,
Button, Button,
Cascader,
Checkbox, Checkbox,
Descriptions, Descriptions,
Drawer, Drawer,
@@ -32,9 +31,10 @@ import {
type PartnerPermissionKey, type PartnerPermissionKey,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { CHINA_REGION_OPTIONS } from '../lib/china-region'; import { districtCodeLabel } from '../lib/china-region';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList'; import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList';
type SubRow = PartnerSubAccountRow; type SubRow = PartnerSubAccountRow;
@@ -108,6 +108,15 @@ function commissionSumError(orderPercent: number, redeemPercent: number, maxRate
return null; return null;
} }
function formatApiError(err: unknown): string | null {
if (err && typeof err === 'object' && 'errorFields' in err) return null;
if (!(err instanceof Error)) return '操作失败';
return err.message.replace(/\b(\d{6})\b/g, (code) => {
const label = districtCodeLabel(code);
return label !== code ? `${label}(${code})` : code;
});
}
export default function CityPartnersPage() { export default function CityPartnersPage() {
const [filterForm] = Form.useForm(); const [filterForm] = Form.useForm();
const [editForm] = Form.useForm(); const [editForm] = Form.useForm();
@@ -138,6 +147,7 @@ export default function CityPartnersPage() {
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE); const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [maxCommissionRate, setMaxCommissionRate] = useState(0.05); const [maxCommissionRate, setMaxCommissionRate] = useState(0.05);
const [createMaxRate, setCreateMaxRate] = useState(0.05); const [createMaxRate, setCreateMaxRate] = useState(0.05);
const [createCityCode, setCreateCityCode] = useState<string | undefined>();
const loadCities = useCallback(async () => { const loadCities = useCallback(async () => {
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
@@ -148,6 +158,10 @@ export default function CityPartnersPage() {
void loadCities(); void loadCities();
}, [loadCities]); }, [loadCities]);
const editCityCode = detail?.cityId
? cities.find((c) => c.id === detail.cityId)?.code
: undefined;
async function openPartner(id: string) { async function openPartner(id: string) {
const d = await request<PartnerDetail>(`/admin/partners/${id}`); const d = await request<PartnerDetail>(`/admin/partners/${id}`);
setDetail(d); setDetail(d);
@@ -174,28 +188,33 @@ export default function CityPartnersPage() {
async function savePartner() { async function savePartner() {
if (!detail) return; if (!detail) return;
const v = await editForm.validateFields(); try {
const err = commissionSumError( const v = await editForm.validateFields();
Number(v.orderCommissionRate ?? 0), const err = commissionSumError(
Number(v.redeemCommissionRate ?? 0), Number(v.orderCommissionRate ?? 0),
maxCommissionRate, Number(v.redeemCommissionRate ?? 0),
); maxCommissionRate,
if (err) { );
message.error(err); if (err) {
return; 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();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
} }
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) { async function refreshPartnerContext(parentId: string) {
@@ -232,6 +251,9 @@ export default function CityPartnersPage() {
} }
async function onCreateCityChange(cityId: string) { async function onCreateCityChange(cityId: string) {
createForm.setFieldsValue({ districtCodes: undefined });
const matched = cities.find((c) => c.id === cityId);
setCreateCityCode(matched?.code);
const cityRes = await request<{ maxPartnerCommissionRate?: number }>(`/admin/cities/${cityId}`); const cityRes = await request<{ maxPartnerCommissionRate?: number }>(`/admin/cities/${cityId}`);
setCreateMaxRate(Number(cityRes.maxPartnerCommissionRate ?? 0.05)); setCreateMaxRate(Number(cityRes.maxPartnerCommissionRate ?? 0.05));
} }
@@ -302,6 +324,7 @@ export default function CityPartnersPage() {
}); });
setCreateScopeType(CityPartnerScopeType.CITY_WIDE); setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
setCreateMaxRate(0.05); setCreateMaxRate(0.05);
setCreateCityCode(undefined);
setCreateOpen(true); setCreateOpen(true);
}} }}
> >
@@ -450,8 +473,12 @@ export default function CityPartnersPage() {
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} /> <Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
</Form.Item> </Form.Item>
{editScopeType === CityPartnerScopeType.DISTRICT && ( {editScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item name="districtCodes" label="区县"> <Form.Item
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect /> name="districtCodes"
label="区县"
extra="仅可选当前开城城市下的区县;不可与其他区域合伙人重合"
>
<CityDistrictMultiSelect cityCode={editCityCode} />
</Form.Item> </Form.Item>
)} )}
<Space style={{ width: '100%' }} size="large"> <Space style={{ width: '100%' }} size="large">
@@ -505,29 +532,34 @@ export default function CityPartnersPage() {
width={560} width={560}
onCancel={() => setCreateOpen(false)} onCancel={() => setCreateOpen(false)}
onOk={async () => { onOk={async () => {
const v = await createForm.validateFields(); try {
const err = commissionSumError( const v = await createForm.validateFields();
Number(v.orderCommissionRate ?? 0), const err = commissionSumError(
Number(v.redeemCommissionRate ?? 3), Number(v.orderCommissionRate ?? 0),
createMaxRate, Number(v.redeemCommissionRate ?? 3),
); createMaxRate,
if (err) { );
message.error(err); if (err) {
return; 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();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
} }
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 form={createForm} layout="vertical">
@@ -555,8 +587,13 @@ export default function CityPartnersPage() {
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} /> <Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
</Form.Item> </Form.Item>
{createScopeType === CityPartnerScopeType.DISTRICT && ( {createScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item name="districtCodes" label="区县" rules={[{ required: true }]}> <Form.Item
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect /> name="districtCodes"
label="区县"
rules={[{ required: true, message: '请选择至少一个区县' }]}
extra="仅可选当前开城城市下的区县;不可与其他区域合伙人重合"
>
<CityDistrictMultiSelect cityCode={createCityCode} />
</Form.Item> </Form.Item>
)} )}
<Space style={{ width: '100%' }} size="large"> <Space style={{ width: '100%' }} size="large">
+67 -32
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { import {
Button, Button,
Cascader,
Drawer, Drawer,
Form, Form,
Input, Input,
@@ -24,9 +23,10 @@ import {
type PartnerPermissionKey, type PartnerPermissionKey,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { CHINA_REGION_OPTIONS } from '../lib/china-region'; import { districtCodeLabel } from '../lib/china-region';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
type Row = { type Row = {
id: string; id: string;
@@ -102,6 +102,18 @@ export default function PartnersPage() {
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE); const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [createCityId, setCreateCityId] = useState<string | undefined>(); const [createCityId, setCreateCityId] = useState<string | undefined>();
const createCityCode = createCityId ? cities.find((c) => c.id === createCityId)?.code : undefined;
const editCityCode = detail?.cityId ? cities.find((c) => c.id === detail.cityId)?.code : undefined;
function formatApiError(err: unknown): string | null {
if (err && typeof err === 'object' && 'errorFields' in err) return null;
if (!(err instanceof Error)) return '操作失败';
return err.message.replace(/\b(\d{6})\b/g, (code) => {
const label = districtCodeLabel(code);
return label !== code ? `${label}(${code})` : code;
});
}
const loadCities = useCallback(async () => { const loadCities = useCallback(async () => {
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setCities(res.items); setCities(res.items);
@@ -142,17 +154,22 @@ export default function PartnersPage() {
async function savePartner() { async function savePartner() {
if (!detail) return; if (!detail) return;
const v = await editForm.validateFields(); try {
const body = { const v = await editForm.validateFields();
...v, const body = {
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100, ...v,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100, orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined, 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('已保存'); await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify(body) });
setDrawerOpen(false); message.success('已保存');
void reload(); setDrawerOpen(false);
void reload();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
}
} }
const columns: ColumnsType<Row> = [ const columns: ColumnsType<Row> = [
@@ -231,8 +248,12 @@ export default function PartnersPage() {
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} /> <Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
</Form.Item> </Form.Item>
{editScopeType === CityPartnerScopeType.DISTRICT && ( {editScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item name="districtCodes" label="区县"> <Form.Item
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect /> name="districtCodes"
label="区县"
extra="仅可选当前开城城市下的区县;不可与其他区域合伙人重合"
>
<CityDistrictMultiSelect cityCode={editCityCode} />
</Form.Item> </Form.Item>
)} )}
<Space style={{ width: '100%' }} size="large"> <Space style={{ width: '100%' }} size="large">
@@ -286,27 +307,36 @@ export default function PartnersPage() {
width={560} width={560}
onCancel={() => setCreateOpen(false)} onCancel={() => setCreateOpen(false)}
onOk={async () => { onOk={async () => {
const v = await createForm.validateFields(); try {
await request('/admin/partners', { const v = await createForm.validateFields();
method: 'POST', await request('/admin/partners', {
body: JSON.stringify({ method: 'POST',
...v, body: JSON.stringify({
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100, ...v,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100, orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined, redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
}), districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}); }),
message.success('已创建'); });
setCreateOpen(false); message.success('已创建');
createForm.resetFields(); setCreateOpen(false);
void reload(); createForm.resetFields();
void reload();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
}
}} }}
> >
<Form form={createForm} layout="vertical" initialValues={{ orderCommissionRate: 0, redeemCommissionRate: 3, scopeType: CityPartnerScopeType.CITY_WIDE }}> <Form form={createForm} layout="vertical" initialValues={{ orderCommissionRate: 0, redeemCommissionRate: 3, scopeType: CityPartnerScopeType.CITY_WIDE }}>
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}> <Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
<Select <Select
options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))} options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))}
onChange={(id) => { setCreateCityId(id); void loadWarehouses(id); }} onChange={(id) => {
setCreateCityId(id);
createForm.setFieldsValue({ districtCodes: undefined });
void loadWarehouses(id);
}}
/> />
</Form.Item> </Form.Item>
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
@@ -317,8 +347,13 @@ export default function PartnersPage() {
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} /> <Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
</Form.Item> </Form.Item>
{createScopeType === CityPartnerScopeType.DISTRICT && ( {createScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item name="districtCodes" label="区县" rules={[{ required: true }]}> <Form.Item
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect /> name="districtCodes"
label="区县"
rules={[{ required: true, message: '请选择至少一个区县' }]}
extra="仅可选当前开城城市下的区县;不可与其他区域合伙人重合"
>
<CityDistrictMultiSelect cityCode={createCityCode} />
</Form.Item> </Form.Item>
)} )}
<Space style={{ width: '100%' }} size="large"> <Space style={{ width: '100%' }} size="large">
+12 -1
View File
@@ -63,10 +63,21 @@ describe('validatePartnerCityBinding', () => {
it('rejects overlapping district codes', () => { it('rejects overlapping district codes', () => {
const result = validatePartnerCityBinding( const result = validatePartnerCityBinding(
[{ id: '1', partnerAccountId: '10', scopeType: 'DISTRICT', districtCodes: ['410105'] }], [
{
id: '1',
partnerAccountId: '10',
scopeType: 'DISTRICT',
districtCodes: ['410105'],
companyName: '甲公司',
},
],
{ partnerAccountId: '11', scopeType: 'DISTRICT', districtCodes: ['410105'] }, { partnerAccountId: '11', scopeType: 'DISTRICT', districtCodes: ['410105'] },
); );
expect(result.ok).toBe(false); expect(result.ok).toBe(false);
expect(result.message).toContain('区域重合');
expect(result.message).toContain('甲公司');
expect(result.occupiedCodes).toEqual(['410105']);
}); });
it('allows valid district binding', () => { it('allows valid district binding', () => {
+16 -6
View File
@@ -7,6 +7,8 @@ export interface PartnerCityBindingInput {
scopeType: CityPartnerScopeType; scopeType: CityPartnerScopeType;
districtCodes?: string[] | null; districtCodes?: string[] | null;
bindingStatus?: CityPartnerStatus; bindingStatus?: CityPartnerStatus;
/** 仅用于重合报错文案 */
companyName?: string | null;
} }
export interface PartnerCityResolveRef { export interface PartnerCityResolveRef {
@@ -20,6 +22,7 @@ export interface PartnerCityResolveRef {
export interface PartnerCityValidationResult { export interface PartnerCityValidationResult {
ok: boolean; ok: boolean;
message?: string; message?: string;
occupiedCodes?: string[];
} }
function normalizeDistrictCodes(codes?: string[] | null): string[] { function normalizeDistrictCodes(codes?: string[] | null): string[] {
@@ -98,18 +101,25 @@ export function validatePartnerCityBinding(
return { ok: false, message: '区域合伙人须至少选择一个区县' }; return { ok: false, message: '区域合伙人须至少选择一个区县' };
} }
const occupied = new Set<string>(); const occupiedBy = new Map<string, string>();
for (const row of others) { for (const row of others) {
if (row.scopeType !== 'DISTRICT') continue; if (row.scopeType !== 'DISTRICT') continue;
const owner = row.companyName?.trim() || '其他区域合伙人';
for (const code of normalizeDistrictCodes(row.districtCodes)) { for (const code of normalizeDistrictCodes(row.districtCodes)) {
occupied.add(code); if (!occupiedBy.has(code)) occupiedBy.set(code, owner);
} }
} }
for (const code of districts) { const overlaps = districts.filter((code) => occupiedBy.has(code));
if (occupied.has(code)) { if (overlaps.length) {
return { ok: false, message: `区县 ${code} 已被其他区域合伙人占用` }; const detail = overlaps
} .map((code) => `${code}${occupiedBy.get(code)}`)
.join('、');
return {
ok: false,
message: `区域重合:以下区县已被占用,请改选其他区县 — ${detail}`,
occupiedCodes: overlaps,
};
} }
return { ok: true }; return { ok: true };
@@ -87,6 +87,7 @@ export class PartnerCityService {
partnerAccountId: r.id.toString(), partnerAccountId: r.id.toString(),
scopeType: r.scopeType as CityPartnerScopeType, scopeType: r.scopeType as CityPartnerScopeType,
districtCodes: this.parseDistrictCodes(r.districtCodes), districtCodes: this.parseDistrictCodes(r.districtCodes),
companyName: r.companyName,
})), })),
{ {
partnerAccountId: input.partnerAccountId ?? 'new', partnerAccountId: input.partnerAccountId ?? 'new',