feat(admin): require store category on HQ store create
CI / verify (pull_request) Has been cancelled

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-26 07:13:56 +08:00
parent 2a208d5dea
commit ac1e0a9f13
4 changed files with 109 additions and 7 deletions
+14 -1
View File
@@ -6,6 +6,8 @@ export type StoreCreateForm = {
city?: string; city?: string;
district: string; district: string;
districtCode?: string; districtCode?: string;
categoryParentId?: string;
categoryId: string;
name: string; name: string;
phone: string; phone: string;
address: string; address: string;
@@ -23,11 +25,22 @@ const PHONE_RE = /^1\d{10}$/;
const BANK_RE = /^\d{16,19}$/; const BANK_RE = /^\d{16,19}$/;
export function validateStoreCreateStep1( export function validateStoreCreateStep1(
form: Pick<StoreCreateForm, 'partnerAccountId' | 'cityId' | 'regionCodes' | 'name' | 'phone' | 'address' | 'intro'>, form: Pick<
StoreCreateForm,
| 'partnerAccountId'
| 'cityId'
| 'regionCodes'
| 'categoryId'
| 'name'
| 'phone'
| 'address'
| 'intro'
>,
): string | null { ): string | null {
if (!form.partnerAccountId) return '请选择开城合伙人'; if (!form.partnerAccountId) return '请选择开城合伙人';
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县'; if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 城市」配置对应区划'; if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 城市」配置对应区划';
if (!form.categoryId?.trim()) return '请选择门店分类(细类)';
if (!form.name?.trim()) return '请填写门店名称'; if (!form.name?.trim()) return '请填写门店名称';
if (!form.phone?.trim()) return '请填写门店手机号'; if (!form.phone?.trim()) return '请填写门店手机号';
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号'; if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
+83 -3
View File
@@ -263,6 +263,7 @@ type StoreRow = {
cityRef?: { name: string; code: string }; cityRef?: { name: string; code: string };
partner?: { companyName: string }; partner?: { companyName: string };
account?: { phone: string; name: string; status: string }; account?: { phone: string; name: string; status: string };
category?: { id: string; name: string; parentId?: string | null } | null;
}; };
type PartnerOption = { id: string; companyName: string }; type PartnerOption = { id: string; companyName: string };
@@ -272,6 +273,12 @@ type CityOption = {
code: string; code: string;
partnerBindings?: Array<{ partnerAccountId: string; partnerCompanyName?: string }>; partnerBindings?: Array<{ partnerAccountId: string; partnerCompanyName?: string }>;
}; };
type CategoryNode = {
id: string;
name: string;
status?: string;
children?: CategoryNode[];
};
export default function StoresPage() { export default function StoresPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -301,11 +308,27 @@ export default function StoresPage() {
const [createError, setCreateError] = useState(''); const [createError, setCreateError] = useState('');
const [partners, setPartners] = useState<PartnerOption[]>([]); const [partners, setPartners] = useState<PartnerOption[]>([]);
const [cities, setCities] = useState<CityOption[]>([]); const [cities, setCities] = useState<CityOption[]>([]);
const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]);
const [optionsLoading, setOptionsLoading] = useState(false); const [optionsLoading, setOptionsLoading] = useState(false);
const selectedPartnerId = Form.useWatch('partnerAccountId', createForm); const selectedPartnerId = Form.useWatch('partnerAccountId', createForm);
const selectedRegionCodes = Form.useWatch('regionCodes', createForm); const selectedRegionCodes = Form.useWatch('regionCodes', createForm);
const selectedCityId = Form.useWatch('cityId', createForm); const selectedCityId = Form.useWatch('cityId', createForm);
const selectedCategoryParentId = Form.useWatch('categoryParentId', createForm);
const categoryParentOptions = useMemo(
() =>
categoryTree
.filter((n) => n.status !== 'INACTIVE')
.map((n) => ({ value: n.id, label: n.name })),
[categoryTree],
);
const categoryChildOptions = useMemo(() => {
const parent = categoryTree.find((n) => n.id === selectedCategoryParentId);
return (parent?.children ?? [])
.filter((n) => n.status !== 'INACTIVE')
.map((n) => ({ value: n.id, label: n.name }));
}, [categoryTree, selectedCategoryParentId]);
function bindRegionSelection(codes: string[], partnerAccountId?: string) { function bindRegionSelection(codes: string[], partnerAccountId?: string) {
const binding = resolveRegionBinding(codes, cities, partnerAccountId ?? selectedPartnerId); const binding = resolveRegionBinding(codes, cities, partnerAccountId ?? selectedPartnerId);
@@ -337,16 +360,21 @@ export default function StoresPage() {
setOptionsLoading(true); setOptionsLoading(true);
try { try {
const qs = `pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`; const qs = `pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`;
const [p, c] = await Promise.all([ const [p, c, cats] = await Promise.all([
request<Paginated<PartnerOption>>(`/admin/partners?${qs}`), request<Paginated<PartnerOption>>(`/admin/partners?${qs}`),
request<Paginated<CityOption>>(`/admin/cities?${qs}`), request<Paginated<CityOption>>(`/admin/cities?${qs}`),
request<CategoryNode[]>('/admin/store-categories'),
]); ]);
setPartners(p.items); setPartners(p.items);
setCities(c.items); setCities(c.items);
setCategoryTree(Array.isArray(cats) ? cats : []);
if (!p.items.length) message.warning('暂无开城合伙人,请先在「开城 → 城市」详情中创建合伙人'); if (!p.items.length) message.warning('暂无开城合伙人,请先在「开城 → 城市」详情中创建合伙人');
if (!c.items.length) message.warning('暂无开城城市,请先在「开城 → 城市」中创建'); if (!c.items.length) message.warning('暂无开城城市,请先在「开城 → 城市」中创建');
if (!Array.isArray(cats) || !cats.length) {
message.warning('暂无门店分类,请先在「门店 → 门店分类」中配置');
}
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '加载合伙人/城市失败'); message.error(e instanceof Error ? e.message : '加载合伙人/城市/分类失败');
} finally { } finally {
setOptionsLoading(false); setOptionsLoading(false);
} }
@@ -379,7 +407,16 @@ export default function StoresPage() {
return; return;
} }
try { try {
await createForm.validateFields(['partnerAccountId', 'regionCodes', 'cityId', 'name', 'phone', 'address']); await createForm.validateFields([
'partnerAccountId',
'regionCodes',
'cityId',
'categoryParentId',
'categoryId',
'name',
'phone',
'address',
]);
} catch { } catch {
return; return;
} }
@@ -403,6 +440,7 @@ export default function StoresPage() {
body: JSON.stringify({ body: JSON.stringify({
partnerAccountId: values.partnerAccountId, partnerAccountId: values.partnerAccountId,
cityId: values.cityId, cityId: values.cityId,
categoryId: values.categoryId,
province: values.province, province: values.province,
city: values.city, city: values.city,
name: values.name.trim(), name: values.name.trim(),
@@ -441,6 +479,11 @@ export default function StoresPage() {
render: (url) => url ? <Image src={url} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '—', render: (url) => url ? <Image src={url} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '—',
}, },
{ title: '门店名', dataIndex: 'name', width: 140 }, { title: '门店名', dataIndex: 'name', width: 140 },
{
title: '分类',
width: 100,
render: (_, row) => row.category?.name || '—',
},
{ title: '城市', dataIndex: 'cityName', width: 80 }, { title: '城市', dataIndex: 'cityName', width: 80 },
{ title: '电话', dataIndex: 'phone', width: 120 }, { title: '电话', dataIndex: 'phone', width: 120 },
{ {
@@ -580,6 +623,11 @@ export default function StoresPage() {
<StoreAuditMediaSection detail={detail} /> <StoreAuditMediaSection detail={detail} />
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}> <Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item> <Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
<Descriptions.Item label="门店分类">
{detail.category && typeof detail.category === 'object' && 'name' in detail.category
? String((detail.category as { name?: string }).name || '—')
: '—'}
</Descriptions.Item>
<Descriptions.Item label="审核状态"> <Descriptions.Item label="审核状态">
<Tag color={ <Tag color={
String(detail.auditStatus) === 'PENDING' ? 'orange' String(detail.auditStatus) === 'PENDING' ? 'orange'
@@ -685,6 +733,38 @@ export default function StoresPage() {
<Form.Item name="city" hidden><Input /></Form.Item> <Form.Item name="city" hidden><Input /></Form.Item>
<Form.Item name="district" hidden><Input /></Form.Item> <Form.Item name="district" hidden><Input /></Form.Item>
<Form.Item name="districtCode" hidden><Input /></Form.Item> <Form.Item name="districtCode" hidden><Input /></Form.Item>
<Form.Item
name="categoryParentId"
label="门店分类(大类)"
rules={[{ required: true, message: '请选择门店大类' }]}
>
<Select
showSearch
loading={optionsLoading}
optionFilterProp="label"
placeholder={optionsLoading ? '加载中…' : '选择大类'}
options={categoryParentOptions}
onChange={() => createForm.setFieldValue('categoryId', undefined)}
/>
</Form.Item>
<Form.Item
name="categoryId"
label="门店分类(细类)"
rules={[{ required: true, message: '请选择门店细类' }]}
extra={
<Typography.Link onClick={() => navigate('/store-categories')}>
</Typography.Link>
}
>
<Select
showSearch
optionFilterProp="label"
placeholder={selectedCategoryParentId ? '选择细类' : '请先选大类'}
disabled={!selectedCategoryParentId}
options={categoryChildOptions}
/>
</Form.Item>
<Form.Item name="name" label="门店名称" rules={[{ required: true, message: '请填写门店名称' }]}> <Form.Item name="name" label="门店名称" rules={[{ required: true, message: '请填写门店名称' }]}>
<Input placeholder="请输入门店名称" /> <Input placeholder="请输入门店名称" />
</Form.Item> </Form.Item>
@@ -5,6 +5,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
import { mapStoreCompat } from '../../common/compat/v31-compat'; import { mapStoreCompat } from '../../common/compat/v31-compat';
import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto'; import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto';
import { PartnerCityService } from '../city-scope/partner-city.service'; import { PartnerCityService } from '../city-scope/partner-city.service';
import { StoreCategoryService } from '../store/store-category.service';
import type { import type {
CreateStoreAccountDto, CreateStoreAccountDto,
CreateStoreDto, CreateStoreDto,
@@ -20,6 +21,7 @@ export class AdminStoresService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly partnerCityService: PartnerCityService, private readonly partnerCityService: PartnerCityService,
private readonly storeCategoryService: StoreCategoryService,
) {} ) {}
async listStores(query: AdminStoresQueryDto) { async listStores(query: AdminStoresQueryDto) {
@@ -44,6 +46,7 @@ export class AdminStoresService {
include: { include: {
cityRef: { select: { id: true, name: true, code: true } }, cityRef: { select: { id: true, name: true, code: true } },
partnerAccount: { select: { id: true, companyName: true } }, partnerAccount: { select: { id: true, companyName: true } },
category: { select: { id: true, name: true, parentId: true } },
bindings: { bindings: {
where: { storeAccount: { isPrimary: 1 } }, where: { storeAccount: { isPrimary: 1 } },
take: 1, take: 1,
@@ -237,12 +240,18 @@ export class AdminStoresService {
if (!city) throw new BadRequestException('开城城市不存在'); if (!city) throw new BadRequestException('开城城市不存在');
await this.partnerCityService.assertPartnerAccountBoundToCity(partnerAccountId, city.id); await this.partnerCityService.assertPartnerAccountBoundToCity(partnerAccountId, city.id);
if (!dto.categoryId?.trim()) {
throw new BadRequestException('请选择门店分类');
}
const categoryId = BigInt(dto.categoryId);
await this.storeCategoryService.assertLeafCategoryId(categoryId);
const store = await this.prisma.store.create({ const store = await this.prisma.store.create({
data: { data: {
cityId: city.id, cityId: city.id,
partnerAccountId, partnerAccountId,
settlementRate: dto.settlementRate ?? 0.6, settlementRate: dto.settlementRate ?? 0.6,
categoryId: dto.categoryId ? BigInt(dto.categoryId) : null, categoryId,
name: dto.name, name: dto.name,
phone: normalizedPhone, phone: normalizedPhone,
province: dto.province ?? city.province, province: dto.province ?? city.province,
@@ -37,9 +37,9 @@ export class CreateStoreDto {
@IsNotEmpty() @IsNotEmpty()
phone: string; phone: string;
@IsOptional()
@IsString() @IsString()
categoryId?: string; @IsNotEmpty()
categoryId: string;
@IsOptional() @IsOptional()
@IsString() @IsString()