feat(store): dual business hours, avg price, and status lock
CI / verify (pull_request) Has been cancelled
CI / verify (pull_request) Has been cancelled
Support 1-2 hour segments and optional avgPrice; show bank settlement on HQ store detail; enforce pickup min 2 bottles; Chinese order statuses; block shop reopen after permanent close. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -10,6 +10,12 @@ export type StoreDraftForm = {
|
||||
address: string;
|
||||
openTime: string;
|
||||
closeTime: string;
|
||||
/** 是否启用第二段营业时间 */
|
||||
dualHours: boolean;
|
||||
openTime2: string;
|
||||
closeTime2: string;
|
||||
/** 人均费用(选填) */
|
||||
avgPrice: string;
|
||||
categoryParentId: string;
|
||||
categoryId: string;
|
||||
intro: string;
|
||||
@@ -43,6 +49,10 @@ export const defaultStoreForm = (): StoreDraftForm => ({
|
||||
address: '',
|
||||
openTime: '10:00',
|
||||
closeTime: '22:00',
|
||||
dualHours: false,
|
||||
openTime2: '17:00',
|
||||
closeTime2: '21:00',
|
||||
avgPrice: '',
|
||||
categoryParentId: '',
|
||||
categoryId: '',
|
||||
intro: '',
|
||||
@@ -54,62 +64,48 @@ export const defaultStoreForm = (): StoreDraftForm => ({
|
||||
bankBranch: '',
|
||||
});
|
||||
|
||||
export function normalizeStringArray(raw: unknown, length: number): string[] {
|
||||
if (!Array.isArray(raw)) return Array.from({ length }, () => '');
|
||||
const items = raw.map((item) => String(item ?? ''));
|
||||
while (items.length < length) items.push('');
|
||||
return items.slice(0, length);
|
||||
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||
const PHONE_RE = /^1[3-9]\d{9}$/;
|
||||
const BANK_RE = /^\d{16,19}$/;
|
||||
|
||||
function timeToMinutes(hhmm: string): number {
|
||||
const [h, m] = hhmm.split(':').map(Number);
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
function normalizeForm(raw: Record<string, unknown>): StoreDraftForm {
|
||||
function normalizeStringArray(urls: unknown, minLen: number): string[] {
|
||||
const arr = Array.isArray(urls) ? urls.map((u) => String(u ?? '')) : [];
|
||||
while (arr.length < minLen) arr.push('');
|
||||
return arr;
|
||||
}
|
||||
|
||||
export function normalizeStoreDraftForm(raw: Partial<StoreDraftForm> | null | undefined): StoreDraftForm {
|
||||
const base = defaultStoreForm();
|
||||
const regionCodes = Array.isArray(raw.regionCodes) && raw.regionCodes.length >= 3
|
||||
? raw.regionCodes.map((code) => String(code))
|
||||
: base.regionCodes;
|
||||
if (!raw) return base;
|
||||
return {
|
||||
regionCodes,
|
||||
...base,
|
||||
...raw,
|
||||
regionCodes: Array.isArray(raw.regionCodes) ? raw.regionCodes.map(String) : base.regionCodes,
|
||||
cityId: String(raw.cityId ?? base.cityId),
|
||||
province: String(raw.province ?? base.province),
|
||||
city: String(raw.city ?? base.city),
|
||||
district: String(raw.district ?? base.district),
|
||||
name: String(raw.name ?? base.name),
|
||||
phone: String(raw.phone ?? base.phone),
|
||||
storeSmsCode: String(raw.storeSmsCode ?? base.storeSmsCode),
|
||||
address: String(raw.address ?? base.address),
|
||||
openTime: String(raw.openTime ?? base.openTime),
|
||||
closeTime: String(raw.closeTime ?? base.closeTime),
|
||||
categoryParentId: String(raw.categoryParentId ?? base.categoryParentId),
|
||||
categoryId: String(raw.categoryId ?? base.categoryId),
|
||||
intro: String(raw.intro ?? base.intro),
|
||||
coverUrl: String(raw.coverUrl ?? base.coverUrl),
|
||||
dualHours: Boolean(raw.dualHours),
|
||||
openTime2: String(raw.openTime2 ?? base.openTime2),
|
||||
closeTime2: String(raw.closeTime2 ?? base.closeTime2),
|
||||
avgPrice: String(raw.avgPrice ?? base.avgPrice),
|
||||
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, 3),
|
||||
contractUrl: String(raw.contractUrl ?? base.contractUrl),
|
||||
bankAccountName: String(raw.bankAccountName ?? base.bankAccountName),
|
||||
bankAccountNo: String(raw.bankAccountNo ?? base.bankAccountNo),
|
||||
bankBranch: String(raw.bankBranch ?? base.bankBranch),
|
||||
};
|
||||
}
|
||||
|
||||
export function loadStoreDraft(accountId?: string): StoreDraft | null {
|
||||
try {
|
||||
const key = storeDraftKey(accountId);
|
||||
let raw = localStorage.getItem(key);
|
||||
if (!raw && accountId) {
|
||||
raw = localStorage.getItem(STORE_DRAFT_KEY);
|
||||
if (raw) {
|
||||
localStorage.setItem(key, raw);
|
||||
localStorage.removeItem(STORE_DRAFT_KEY);
|
||||
}
|
||||
}
|
||||
const raw = localStorage.getItem(storeDraftKey(accountId));
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
if (parsed.form && typeof parsed.form === 'object') {
|
||||
return {
|
||||
step: Math.min(3, Math.max(1, Number(parsed.step) || 1)),
|
||||
form: normalizeForm(parsed.form as Record<string, unknown>),
|
||||
};
|
||||
}
|
||||
return { step: 1, form: normalizeForm(parsed) };
|
||||
const parsed = JSON.parse(raw) as StoreDraft;
|
||||
return {
|
||||
step: Number(parsed.step) || 0,
|
||||
form: normalizeStoreDraftForm(parsed.form),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -123,21 +119,6 @@ export function clearStoreDraft(accountId?: string) {
|
||||
localStorage.removeItem(storeDraftKey(accountId));
|
||||
}
|
||||
|
||||
/** 清除当前账号及遗留的全局录店草稿 */
|
||||
export function clearAllStoreDrafts(accountId?: string) {
|
||||
clearStoreDraft(accountId);
|
||||
localStorage.removeItem(STORE_DRAFT_KEY);
|
||||
}
|
||||
|
||||
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(value: string): number {
|
||||
const [h, m] = value.split(':').map(Number);
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
export function validateStoreStep1(
|
||||
form: Pick<
|
||||
StoreDraftForm,
|
||||
@@ -147,6 +128,10 @@ export function validateStoreStep1(
|
||||
| 'address'
|
||||
| 'openTime'
|
||||
| 'closeTime'
|
||||
| 'dualHours'
|
||||
| 'openTime2'
|
||||
| 'closeTime2'
|
||||
| 'avgPrice'
|
||||
| 'categoryId'
|
||||
| 'intro'
|
||||
>,
|
||||
@@ -162,6 +147,22 @@ export function validateStoreStep1(
|
||||
if (timeToMinutes(form.openTime.trim()) >= timeToMinutes(form.closeTime.trim())) {
|
||||
return '营业结束时间须晚于开始时间';
|
||||
}
|
||||
if (form.dualHours) {
|
||||
if (!form.openTime2.trim() || !form.closeTime2.trim()) return '请完整填写第二段营业时间';
|
||||
if (!TIME_RE.test(form.openTime2.trim()) || !TIME_RE.test(form.closeTime2.trim())) {
|
||||
return '第二段时间格式须为 HH:MM';
|
||||
}
|
||||
if (timeToMinutes(form.openTime2.trim()) >= timeToMinutes(form.closeTime2.trim())) {
|
||||
return '第二段结束时间须晚于开始时间';
|
||||
}
|
||||
if (timeToMinutes(form.closeTime.trim()) >= timeToMinutes(form.openTime2.trim())) {
|
||||
return '第二段开始时间须晚于第一段结束时间';
|
||||
}
|
||||
}
|
||||
if (form.avgPrice.trim()) {
|
||||
const n = Number(form.avgPrice);
|
||||
if (Number.isNaN(n) || n < 0) return '人均费用须为非负数字';
|
||||
}
|
||||
if (!form.categoryId.trim()) return '请选择店铺类型';
|
||||
if (form.intro.trim()) {
|
||||
const len = form.intro.trim().length;
|
||||
|
||||
@@ -20,8 +20,8 @@ export type StoreStatusValue = 'OPEN' | 'PAUSED' | 'CLOSED';
|
||||
export function storeStatusLabel(status: string): string {
|
||||
const s = String(status).toUpperCase();
|
||||
if (s === 'OPEN') return '营业中';
|
||||
if (s === 'PAUSED') return '暂时闭店';
|
||||
if (s === 'CLOSED') return '关闭';
|
||||
if (s === 'PAUSED') return '临时闭店';
|
||||
if (s === 'CLOSED') return '永久关闭';
|
||||
return status;
|
||||
}
|
||||
|
||||
|
||||
@@ -512,6 +512,12 @@ export default function StoreCreatePage() {
|
||||
|
||||
closeTime: form.closeTime.trim(),
|
||||
|
||||
...(form.dualHours
|
||||
? { openTime2: form.openTime2.trim(), closeTime2: form.closeTime2.trim() }
|
||||
: {}),
|
||||
|
||||
...(form.avgPrice.trim() ? { avgPrice: Number(form.avgPrice) } : {}),
|
||||
|
||||
categoryId: form.categoryId.trim(),
|
||||
|
||||
intro: form.intro.trim() || undefined,
|
||||
@@ -740,7 +746,7 @@ export default function StoreCreatePage() {
|
||||
|
||||
<label>营业时间 <span className="text-primary">*</span></label>
|
||||
|
||||
<div className="partner-input-row" style={{ alignItems: 'center', gap: 8 }}>
|
||||
<div className="partner-input-row" style={{ alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
|
||||
<input
|
||||
|
||||
@@ -774,14 +780,92 @@ export default function StoreCreatePage() {
|
||||
|
||||
</div>
|
||||
|
||||
<label className="partner-checkbox-row" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
|
||||
<input
|
||||
|
||||
type="checkbox"
|
||||
|
||||
checked={form.dualHours}
|
||||
|
||||
onChange={(e) => patchForm({ dualHours: e.target.checked })}
|
||||
|
||||
/>
|
||||
|
||||
<span className="label-md">启用第二段营业时间(如午市/晚市)</span>
|
||||
|
||||
</label>
|
||||
|
||||
{form.dualHours ? (
|
||||
|
||||
<div className="partner-input-row" style={{ alignItems: 'center', gap: 8 }}>
|
||||
|
||||
<input
|
||||
|
||||
className="partner-field-input partner-field-input--block"
|
||||
|
||||
type="time"
|
||||
|
||||
value={form.openTime2}
|
||||
|
||||
onChange={(e) => patchForm({ openTime2: e.target.value })}
|
||||
|
||||
aria-label="第二段开始"
|
||||
|
||||
/>
|
||||
|
||||
<span className="label-md text-muted">至</span>
|
||||
|
||||
<input
|
||||
|
||||
className="partner-field-input partner-field-input--block"
|
||||
|
||||
type="time"
|
||||
|
||||
value={form.closeTime2}
|
||||
|
||||
onChange={(e) => patchForm({ closeTime2: e.target.value })}
|
||||
|
||||
aria-label="第二段结束"
|
||||
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
) : null}
|
||||
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||
|
||||
将展示给用户端与门店端,默认 10:00–22:00,可按实际调整。
|
||||
支持 1 段(如 09:00-22:00)或 2 段(如 09:00-14:00,17:00-21:00)。
|
||||
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
|
||||
<label>人均费用(选填)</label>
|
||||
|
||||
<input
|
||||
|
||||
className="partner-field-input partner-field-input--block"
|
||||
|
||||
type="number"
|
||||
|
||||
min={0}
|
||||
|
||||
step={1}
|
||||
|
||||
placeholder="如 88,用户端门店页展示"
|
||||
|
||||
value={form.avgPrice}
|
||||
|
||||
onChange={(e) => patchForm({ avgPrice: e.target.value })}
|
||||
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
|
||||
<label>门店简介</label>
|
||||
|
||||
Reference in New Issue
Block a user