feat(admin): link city-partner store count; fix benefitUsageRule null string
CI / verify (pull_request) Has been cancelled
CI / verify (pull_request) Has been cancelled
City partner storeCount jumps to /stores?partnerId=; normalize empty/null text so usage rule is not saved as literal "null. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -301,7 +301,16 @@ export default function CityPartnersPage() {
|
|||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
render: (v) => v || '—',
|
render: (v) => v || '—',
|
||||||
},
|
},
|
||||||
{ title: '门店', dataIndex: 'storeCount', width: 60 },
|
{
|
||||||
|
title: '门店',
|
||||||
|
dataIndex: 'storeCount',
|
||||||
|
width: 60,
|
||||||
|
render: (n: number, row) => (
|
||||||
|
<Link to={`/stores?partnerId=${row.id}`} title={`查看「${row.companyName || row.phone}」门店`}>
|
||||||
|
{n ?? 0}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
|
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
|
||||||
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
||||||
{
|
{
|
||||||
@@ -444,7 +453,11 @@ export default function CityPartnersPage() {
|
|||||||
<>
|
<>
|
||||||
<Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}>
|
<Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}>
|
||||||
<Descriptions.Item label="城市">{detail.cityName ?? '—'}</Descriptions.Item>
|
<Descriptions.Item label="城市">{detail.cityName ?? '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="门店数">{detail.storeCount ?? 0}</Descriptions.Item>
|
<Descriptions.Item label="门店数">
|
||||||
|
<Link to={`/stores?partnerId=${detail.id}`} title="查看该城市合伙人门店">
|
||||||
|
{detail.storeCount ?? 0}
|
||||||
|
</Link>
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="管仓仓库" span={2}>
|
<Descriptions.Item label="管仓仓库" span={2}>
|
||||||
{detail.managedWarehouseName ?? (
|
{detail.managedWarehouseName ?? (
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
|
|||||||
@@ -324,12 +324,16 @@ export default function StoresPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const initialCityId = searchParams.get('cityId') ?? '';
|
const initialCityId = searchParams.get('cityId') ?? '';
|
||||||
|
const initialPartnerId = searchParams.get('partnerId') ?? '';
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [editForm] = Form.useForm();
|
const [editForm] = Form.useForm();
|
||||||
const [createForm] = Form.useForm<StoreCreateForm>();
|
const [createForm] = Form.useForm<StoreCreateForm>();
|
||||||
const [filters, setFilters] = useState<Record<string, string>>(() =>
|
const [filters, setFilters] = useState<Record<string, string>>(() => {
|
||||||
initialCityId ? { cityId: initialCityId } : {},
|
const init: Record<string, string> = {};
|
||||||
);
|
if (initialCityId) init.cityId = initialCityId;
|
||||||
|
if (initialPartnerId) init.partnerId = initialPartnerId;
|
||||||
|
return init;
|
||||||
|
});
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<StoreRow>(
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<StoreRow>(
|
||||||
'/admin/stores',
|
'/admin/stores',
|
||||||
() => {
|
() => {
|
||||||
@@ -339,11 +343,13 @@ export default function StoresPage() {
|
|||||||
if (filters.auditStatus) qs.set('auditStatus', filters.auditStatus);
|
if (filters.auditStatus) qs.set('auditStatus', filters.auditStatus);
|
||||||
if (filters.phone) qs.set('phone', filters.phone);
|
if (filters.phone) qs.set('phone', filters.phone);
|
||||||
if (filters.cityId) qs.set('cityId', filters.cityId);
|
if (filters.cityId) qs.set('cityId', filters.cityId);
|
||||||
|
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
|
||||||
return qs;
|
return qs;
|
||||||
},
|
},
|
||||||
[filters],
|
[filters],
|
||||||
);
|
);
|
||||||
const [filterCities, setFilterCities] = useState<CityOption[]>([]);
|
const [filterCities, setFilterCities] = useState<CityOption[]>([]);
|
||||||
|
const [filterPartners, setFilterPartners] = useState<PartnerOption[]>([]);
|
||||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [rejectOpen, setRejectOpen] = useState(false);
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
@@ -366,19 +372,34 @@ export default function StoresPage() {
|
|||||||
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
.then((res) => setFilterCities(res.items))
|
.then((res) => setFilterCities(res.items))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
|
.then((res) => setFilterPartners(res.items))
|
||||||
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cityId = searchParams.get('cityId') ?? '';
|
const cityId = searchParams.get('cityId') ?? '';
|
||||||
|
const partnerId = searchParams.get('partnerId') ?? '';
|
||||||
|
let changed = false;
|
||||||
setFilters((prev) => {
|
setFilters((prev) => {
|
||||||
if ((prev.cityId ?? '') === cityId) return prev;
|
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
|
if ((prev.cityId ?? '') !== cityId) {
|
||||||
|
changed = true;
|
||||||
if (cityId) next.cityId = cityId;
|
if (cityId) next.cityId = cityId;
|
||||||
else delete next.cityId;
|
else delete next.cityId;
|
||||||
return next;
|
}
|
||||||
|
if ((prev.partnerId ?? '') !== partnerId) {
|
||||||
|
changed = true;
|
||||||
|
if (partnerId) next.partnerId = partnerId;
|
||||||
|
else delete next.partnerId;
|
||||||
|
}
|
||||||
|
return changed ? next : prev;
|
||||||
});
|
});
|
||||||
form.setFieldsValue({ cityId: cityId || undefined });
|
form.setFieldsValue({
|
||||||
if (cityId) setPage(1);
|
cityId: cityId || undefined,
|
||||||
|
partnerId: partnerId || undefined,
|
||||||
|
});
|
||||||
|
if (cityId || partnerId) setPage(1);
|
||||||
}, [searchParams, form, setPage]);
|
}, [searchParams, form, setPage]);
|
||||||
|
|
||||||
const selectedPartnerId = Form.useWatch('partnerAccountId', createForm);
|
const selectedPartnerId = Form.useWatch('partnerAccountId', createForm);
|
||||||
@@ -452,7 +473,12 @@ export default function StoresPage() {
|
|||||||
// 以门店手机号为准保存;若与账号登录号不一致,保存时会强制同步到登录账号
|
// 以门店手机号为准保存;若与账号登录号不一致,保存时会强制同步到登录账号
|
||||||
phone: storePhone || loginPhone,
|
phone: storePhone || loginPhone,
|
||||||
intro: d.intro,
|
intro: d.intro,
|
||||||
benefitUsageRule: d.benefitUsageRule,
|
benefitUsageRule:
|
||||||
|
d.benefitUsageRule != null &&
|
||||||
|
String(d.benefitUsageRule).trim() &&
|
||||||
|
!/^null$/i.test(String(d.benefitUsageRule).trim())
|
||||||
|
? String(d.benefitUsageRule)
|
||||||
|
: '',
|
||||||
coverUrl: d.coverUrl,
|
coverUrl: d.coverUrl,
|
||||||
province: d.province,
|
province: d.province,
|
||||||
city: d.cityName,
|
city: d.cityName,
|
||||||
@@ -490,7 +516,12 @@ export default function StoresPage() {
|
|||||||
phone: v.phone,
|
phone: v.phone,
|
||||||
coverUrl: v.coverUrl,
|
coverUrl: v.coverUrl,
|
||||||
intro: v.intro,
|
intro: v.intro,
|
||||||
benefitUsageRule: v.benefitUsageRule ?? null,
|
benefitUsageRule:
|
||||||
|
typeof v.benefitUsageRule === 'string' &&
|
||||||
|
v.benefitUsageRule.trim() &&
|
||||||
|
!/^null$/i.test(v.benefitUsageRule.trim())
|
||||||
|
? v.benefitUsageRule.trim()
|
||||||
|
: null,
|
||||||
categoryId: v.categoryId,
|
categoryId: v.categoryId,
|
||||||
province: v.province,
|
province: v.province,
|
||||||
city: v.city,
|
city: v.city,
|
||||||
@@ -761,6 +792,16 @@ export default function StoresPage() {
|
|||||||
options={filterCities.map((c) => ({ value: c.id, label: c.name }))}
|
options={filterCities.map((c) => ({ value: c.id, label: c.name }))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="partnerId" label="城市合伙人">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
style={{ width: 180 }}
|
||||||
|
placeholder="全部"
|
||||||
|
options={filterPartners.map((p) => ({ value: p.id, label: p.companyName || p.id }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
<Form.Item name="status" label="营业状态">
|
<Form.Item name="status" label="营业状态">
|
||||||
<Select allowClear style={{ width: 100 }} placeholder="全部" options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
<Select allowClear style={{ width: 100 }} placeholder="全部" options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -779,7 +820,9 @@ export default function StoresPage() {
|
|||||||
form.resetFields();
|
form.resetFields();
|
||||||
setFilters({});
|
setFilters({});
|
||||||
setPage(1);
|
setPage(1);
|
||||||
if (searchParams.has('cityId')) navigate('/stores', { replace: true });
|
if (searchParams.has('cityId') || searchParams.has('partnerId')) {
|
||||||
|
navigate('/stores', { replace: true });
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
重置
|
重置
|
||||||
|
|||||||
@@ -67,7 +67,10 @@ export default function StoreDetailPage() {
|
|||||||
phone: String(data.phone || ''),
|
phone: String(data.phone || ''),
|
||||||
address: String(data.address || ''),
|
address: String(data.address || ''),
|
||||||
intro: String(data.intro || ''),
|
intro: String(data.intro || ''),
|
||||||
benefitUsageRule: String(data.benefitUsageRule || ''),
|
benefitUsageRule: (() => {
|
||||||
|
const raw = String(data.benefitUsageRule || '').trim();
|
||||||
|
return raw && !/^null$/i.test(raw) ? raw : '';
|
||||||
|
})(),
|
||||||
latitude: data.latitude != null && data.latitude !== '' ? String(data.latitude) : '',
|
latitude: data.latitude != null && data.latitude !== '' ? String(data.latitude) : '',
|
||||||
longitude: data.longitude != null && data.longitude !== '' ? String(data.longitude) : '',
|
longitude: data.longitude != null && data.longitude !== '' ? String(data.longitude) : '',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -174,7 +174,9 @@ export default function StoreDetailPage() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const intro = store.intro?.trim() || '';
|
const intro = store.intro?.trim() || '';
|
||||||
const benefitRule = store.benefitUsageRule?.trim() || '';
|
const benefitRuleRaw = store.benefitUsageRule?.trim() || '';
|
||||||
|
const benefitRule =
|
||||||
|
benefitRuleRaw && !/^null$/i.test(benefitRuleRaw) ? benefitRuleRaw : '';
|
||||||
|
|
||||||
function previewEnv(index: number) {
|
function previewEnv(index: number) {
|
||||||
if (!envPhotos.length) return;
|
if (!envPhotos.length) return;
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ import type {
|
|||||||
UpdateStoreStatusDto,
|
UpdateStoreStatusDto,
|
||||||
} from './dto/admin-mutate.dto';
|
} from './dto/admin-mutate.dto';
|
||||||
|
|
||||||
|
/** 选填文案:空 / null / "null" 一律存库为 null,避免 String(null)==="null" */
|
||||||
|
function normalizeStoreOptionalText(value: unknown): string | null {
|
||||||
|
if (value == null) return null;
|
||||||
|
const s = String(value).trim();
|
||||||
|
if (!s || /^null$/i.test(s) || /^undefined$/i.test(s)) return null;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminStoresService {
|
export class AdminStoresService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -286,7 +294,7 @@ export class AdminStoresService {
|
|||||||
...(normalizedPhone !== undefined ? { phone: normalizedPhone } : {}),
|
...(normalizedPhone !== undefined ? { phone: normalizedPhone } : {}),
|
||||||
...(dto.intro !== undefined ? { intro: dto.intro } : {}),
|
...(dto.intro !== undefined ? { intro: dto.intro } : {}),
|
||||||
...(dto.benefitUsageRule !== undefined
|
...(dto.benefitUsageRule !== undefined
|
||||||
? { benefitUsageRule: String(dto.benefitUsageRule).trim() || null }
|
? { benefitUsageRule: normalizeStoreOptionalText(dto.benefitUsageRule) }
|
||||||
: {}),
|
: {}),
|
||||||
...(dto.address !== undefined ? { address: dto.address.trim() } : {}),
|
...(dto.address !== undefined ? { address: dto.address.trim() } : {}),
|
||||||
...(dto.province !== undefined ? { province: dto.province.trim() } : {}),
|
...(dto.province !== undefined ? { province: dto.province.trim() } : {}),
|
||||||
@@ -417,7 +425,7 @@ export class AdminStoresService {
|
|||||||
if (intro && (intro.length < 2 || intro.length > 500)) {
|
if (intro && (intro.length < 2 || intro.length > 500)) {
|
||||||
throw new BadRequestException('门店简介须为 2~500 字');
|
throw new BadRequestException('门店简介须为 2~500 字');
|
||||||
}
|
}
|
||||||
const benefitUsageRule = dto.benefitUsageRule?.trim() || null;
|
const benefitUsageRule = normalizeStoreOptionalText(dto.benefitUsageRule);
|
||||||
if (benefitUsageRule && benefitUsageRule.length > 1000) {
|
if (benefitUsageRule && benefitUsageRule.length > 1000) {
|
||||||
throw new BadRequestException('好客权益券使用规则最多 1000 字');
|
throw new BadRequestException('好客权益券使用规则最多 1000 字');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number)
|
|||||||
return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)));
|
return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 选填文案:空 / null / "null" 存库为 null,避免 String(null)==="null" */
|
||||||
|
function normalizeOptionalTextField(value: unknown): string | null {
|
||||||
|
if (value == null) return null;
|
||||||
|
const s = String(value).trim();
|
||||||
|
if (!s || /^null$/i.test(s) || /^undefined$/i.test(s)) return null;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
function parseOptionalCoord(value: unknown, kind: 'lat' | 'lng' = 'lng'): number | null {
|
function parseOptionalCoord(value: unknown, kind: 'lat' | 'lng' = 'lng'): number | null {
|
||||||
if (value == null || value === '') return null;
|
if (value == null || value === '') return null;
|
||||||
const n = typeof value === 'number' ? value : Number(value);
|
const n = typeof value === 'number' ? value : Number(value);
|
||||||
@@ -301,9 +309,8 @@ export class StoreService {
|
|||||||
if (introRaw && (introRaw.length < 2 || introRaw.length > 500)) {
|
if (introRaw && (introRaw.length < 2 || introRaw.length > 500)) {
|
||||||
throw new BadRequestException('门店简介须为 2~500 字');
|
throw new BadRequestException('门店简介须为 2~500 字');
|
||||||
}
|
}
|
||||||
const benefitUsageRuleRaw =
|
const benefitUsageRuleRaw = normalizeOptionalTextField(body.benefitUsageRule);
|
||||||
body.benefitUsageRule != null ? String(body.benefitUsageRule).trim() : '';
|
if (benefitUsageRuleRaw && benefitUsageRuleRaw.length > 1000) {
|
||||||
if (benefitUsageRuleRaw.length > 1000) {
|
|
||||||
throw new BadRequestException('好客权益券使用规则最多 1000 字');
|
throw new BadRequestException('好客权益券使用规则最多 1000 字');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,7 +332,7 @@ export class StoreService {
|
|||||||
district: String(body.district ?? ''),
|
district: String(body.district ?? ''),
|
||||||
address: String(body.address),
|
address: String(body.address),
|
||||||
intro: introRaw || null,
|
intro: introRaw || null,
|
||||||
benefitUsageRule: benefitUsageRuleRaw || null,
|
benefitUsageRule: benefitUsageRuleRaw,
|
||||||
avgPrice: avgPriceRaw,
|
avgPrice: avgPriceRaw,
|
||||||
openTime,
|
openTime,
|
||||||
closeTime,
|
closeTime,
|
||||||
@@ -522,7 +529,9 @@ export class StoreService {
|
|||||||
const address = body.address !== undefined ? String(body.address).trim() : undefined;
|
const address = body.address !== undefined ? String(body.address).trim() : undefined;
|
||||||
const introRaw = body.intro !== undefined ? String(body.intro).trim() : undefined;
|
const introRaw = body.intro !== undefined ? String(body.intro).trim() : undefined;
|
||||||
const benefitUsageRuleRaw =
|
const benefitUsageRuleRaw =
|
||||||
body.benefitUsageRule !== undefined ? String(body.benefitUsageRule).trim() : undefined;
|
body.benefitUsageRule !== undefined
|
||||||
|
? normalizeOptionalTextField(body.benefitUsageRule)
|
||||||
|
: undefined;
|
||||||
const latitude =
|
const latitude =
|
||||||
body.latitude !== undefined ? parseOptionalCoord(body.latitude, 'lat') : undefined;
|
body.latitude !== undefined ? parseOptionalCoord(body.latitude, 'lat') : undefined;
|
||||||
const longitude =
|
const longitude =
|
||||||
@@ -536,7 +545,7 @@ export class StoreService {
|
|||||||
if (introRaw && (introRaw.length < 2 || introRaw.length > 500)) {
|
if (introRaw && (introRaw.length < 2 || introRaw.length > 500)) {
|
||||||
throw new BadRequestException('门店简介须为 2~500 字');
|
throw new BadRequestException('门店简介须为 2~500 字');
|
||||||
}
|
}
|
||||||
if (benefitUsageRuleRaw !== undefined && benefitUsageRuleRaw.length > 1000) {
|
if (benefitUsageRuleRaw != null && benefitUsageRuleRaw.length > 1000) {
|
||||||
throw new BadRequestException('好客权益券使用规则最多 1000 字');
|
throw new BadRequestException('好客权益券使用规则最多 1000 字');
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -561,7 +570,7 @@ export class StoreService {
|
|||||||
...(hasCoordsUpdate ? { latitude, longitude } : {}),
|
...(hasCoordsUpdate ? { latitude, longitude } : {}),
|
||||||
...(introRaw !== undefined ? { intro: introRaw || null } : {}),
|
...(introRaw !== undefined ? { intro: introRaw || null } : {}),
|
||||||
...(benefitUsageRuleRaw !== undefined
|
...(benefitUsageRuleRaw !== undefined
|
||||||
? { benefitUsageRule: benefitUsageRuleRaw || null }
|
? { benefitUsageRule: benefitUsageRuleRaw }
|
||||||
: {}),
|
: {}),
|
||||||
...(resubmitAudit
|
...(resubmitAudit
|
||||||
? {
|
? {
|
||||||
|
|||||||
Reference in New Issue
Block a user