From cc9933d1ced99cd551475595dc56a6aaab82d1d6 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Sun, 26 Jul 2026 10:19:58 +0800 Subject: [PATCH] feat(admin): require business hours when creating stores HQ create-store wizard now collects 1-2 hour segments and avgPrice, and API validates them on create/update. Co-authored-by: Cursor --- apps/admin-web/src/lib/storeCreate.ts | 37 +++++++++++++++++++ apps/admin-web/src/pages/StoresPage.tsx | 32 ++++++++++++++++ .../src/modules/ops/admin-stores.service.ts | 37 ++++++++++++++++--- 3 files changed, 101 insertions(+), 5 deletions(-) diff --git a/apps/admin-web/src/lib/storeCreate.ts b/apps/admin-web/src/lib/storeCreate.ts index a1ed5b9..7073cbb 100644 --- a/apps/admin-web/src/lib/storeCreate.ts +++ b/apps/admin-web/src/lib/storeCreate.ts @@ -12,6 +12,11 @@ export type StoreCreateForm = { phone: string; address: string; intro?: string; + openTime?: string; + closeTime?: string; + openTime2?: string; + closeTime2?: string; + avgPrice?: number | null; coverUrl?: string; envPhotoUrls?: string[]; contractUrl?: string; @@ -23,6 +28,12 @@ export type StoreCreateForm = { const PHONE_RE = /^1\d{10}$/; const BANK_RE = /^\d{16,19}$/; +const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/; + +function timeToMinutes(hhmm: string): number { + const [h, m] = hhmm.split(':').map(Number); + return h * 60 + m; +} export function validateStoreCreateStep1( form: Pick< @@ -35,6 +46,11 @@ export function validateStoreCreateStep1( | 'phone' | 'address' | 'intro' + | 'openTime' + | 'closeTime' + | 'openTime2' + | 'closeTime2' + | 'avgPrice' >, ): string | null { if (!form.partnerAccountId) return '请选择开城合伙人'; @@ -45,6 +61,27 @@ export function validateStoreCreateStep1( if (!form.phone?.trim()) return '请填写门店手机号'; if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号'; if (!form.address?.trim()) return '请填写详细地址'; + + const openTime = String(form.openTime || '').trim(); + const closeTime = String(form.closeTime || '').trim(); + if (!openTime || !closeTime) return '请填写营业时间'; + if (!TIME_RE.test(openTime) || !TIME_RE.test(closeTime)) return '营业时间格式须为 HH:MM'; + if (timeToMinutes(openTime) >= timeToMinutes(closeTime)) return '营业结束时间须晚于开始时间'; + + const openTime2 = String(form.openTime2 || '').trim(); + const closeTime2 = String(form.closeTime2 || '').trim(); + if (openTime2 || closeTime2) { + if (!openTime2 || !closeTime2) return '请完整填写第二段营业时间'; + if (!TIME_RE.test(openTime2) || !TIME_RE.test(closeTime2)) return '第二段时间格式须为 HH:MM'; + if (timeToMinutes(openTime2) >= timeToMinutes(closeTime2)) return '第二段结束时间须晚于开始时间'; + if (timeToMinutes(closeTime) >= timeToMinutes(openTime2)) return '第二段开始时间须晚于第一段结束时间'; + } + + if (form.avgPrice != null && String(form.avgPrice).trim() !== '') { + const n = Number(form.avgPrice); + if (Number.isNaN(n) || n < 0) return '人均费用须为非负数字'; + } + if (form.intro?.trim()) { const len = form.intro.trim().length; if (len < 10 || len > 500) return '门店简介须为 10~500 字'; diff --git a/apps/admin-web/src/pages/StoresPage.tsx b/apps/admin-web/src/pages/StoresPage.tsx index 8d58e8b..9af91c5 100644 --- a/apps/admin-web/src/pages/StoresPage.tsx +++ b/apps/admin-web/src/pages/StoresPage.tsx @@ -405,6 +405,11 @@ export default function StoresPage() { createForm.setFieldsValue({ envPhotoUrls: ['', '', ''], settlementRate: 60, + openTime: '10:00', + closeTime: '22:00', + openTime2: undefined, + closeTime2: undefined, + avgPrice: undefined, }); setCreateStep(0); setCreateError(''); @@ -461,6 +466,14 @@ export default function StoresPage() { district: values.district.trim(), address: values.address.trim(), intro: values.intro?.trim() || undefined, + openTime: values.openTime?.trim() || '10:00', + closeTime: values.closeTime?.trim() || '22:00', + ...(values.openTime2?.trim() && values.closeTime2?.trim() + ? { openTime2: values.openTime2.trim(), closeTime2: values.closeTime2.trim() } + : {}), + ...(values.avgPrice != null && values.avgPrice !== '' + ? { avgPrice: Number(values.avgPrice) } + : {}), coverUrl: values.coverUrl?.trim() || undefined, envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined, contractUrl: values.contractUrl?.trim() || undefined, @@ -841,6 +854,25 @@ export default function StoresPage() { + + + + + + + + + + + + + + + + + + + diff --git a/server/dukang-api/src/modules/ops/admin-stores.service.ts b/server/dukang-api/src/modules/ops/admin-stores.service.ts index 387f4dd..4183248 100644 --- a/server/dukang-api/src/modules/ops/admin-stores.service.ts +++ b/server/dukang-api/src/modules/ops/admin-stores.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; +import { validateBusinessHours } from '@dukang/domain'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { mapStoreCompat } from '../../common/compat/v31-compat'; @@ -176,6 +177,23 @@ export class AdminStoresService { } async updateStore(id: bigint, dto: UpdateStoreDto) { + const current = await this.prisma.store.findUnique({ where: { id } }); + if (!current) throw new NotFoundException('门店不存在'); + + const openTime = dto.openTime !== undefined ? dto.openTime?.trim() || '' : current.openTime || ''; + const closeTime = dto.closeTime !== undefined ? dto.closeTime?.trim() || '' : current.closeTime || ''; + const openTime2 = + dto.openTime2 !== undefined ? dto.openTime2?.trim() || '' : current.openTime2 || ''; + const closeTime2 = + dto.closeTime2 !== undefined ? dto.closeTime2?.trim() || '' : current.closeTime2 || ''; + if (dto.openTime !== undefined || dto.closeTime !== undefined || dto.openTime2 !== undefined || dto.closeTime2 !== undefined) { + const hoursCheck = validateBusinessHours([ + { open: openTime || '10:00', close: closeTime || '22:00' }, + ...(openTime2 || closeTime2 ? [{ open: openTime2, close: closeTime2 }] : []), + ]); + if (!hoursCheck.ok) throw new BadRequestException(hoursCheck.message); + } + const store = await this.prisma.store.update({ where: { id }, data: { @@ -194,7 +212,6 @@ export class AdminStoresService { }); if (dto.coverUrl) { - const current = await this.prisma.store.findUniqueOrThrow({ where: { id } }); if (current.coverResourceId) { await this.prisma.commonResource.update({ where: { id: current.coverResourceId }, @@ -251,6 +268,16 @@ export class AdminStoresService { const categoryId = BigInt(dto.categoryId); await this.storeCategoryService.assertLeafCategoryId(categoryId); + const openTime = dto.openTime?.trim() || '10:00'; + const closeTime = dto.closeTime?.trim() || '22:00'; + const openTime2 = dto.openTime2?.trim() || ''; + const closeTime2 = dto.closeTime2?.trim() || ''; + const hoursCheck = validateBusinessHours([ + { open: openTime, close: closeTime }, + ...(openTime2 || closeTime2 ? [{ open: openTime2, close: closeTime2 }] : []), + ]); + if (!hoursCheck.ok) throw new BadRequestException(hoursCheck.message); + const store = await this.prisma.store.create({ data: { cityId: city.id, @@ -265,10 +292,10 @@ export class AdminStoresService { address: dto.address, intro: dto.intro ?? null, avgPrice: dto.avgPrice ?? null, - openTime: dto.openTime?.trim() || '10:00', - closeTime: dto.closeTime?.trim() || '22:00', - openTime2: dto.openTime2?.trim() || null, - closeTime2: dto.closeTime2?.trim() || null, + openTime, + closeTime, + openTime2: openTime2 || null, + closeTime2: closeTime2 || null, status: 'OPEN', auditStatus: 'APPROVED', auditedAt: new Date(),