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:
@@ -34,8 +34,8 @@ export const ORDER_STATUS_COLORS: Record<string, string> = {
|
|||||||
|
|
||||||
export const STORE_STATUS_LABELS: Record<string, string> = {
|
export const STORE_STATUS_LABELS: Record<string, string> = {
|
||||||
OPEN: '营业中',
|
OPEN: '营业中',
|
||||||
PAUSED: '暂停',
|
PAUSED: '临时闭店',
|
||||||
CLOSED: '已关闭',
|
CLOSED: '永久关闭',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const STORE_AUDIT_STATUS_LABELS: Record<string, string> = {
|
export const STORE_AUDIT_STATUS_LABELS: Record<string, string> = {
|
||||||
|
|||||||
@@ -262,7 +262,20 @@ type StoreRow = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
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;
|
||||||
|
bankAccountName?: string | null;
|
||||||
|
bankAccountNo?: string | null;
|
||||||
|
bankBranch?: string | null;
|
||||||
|
};
|
||||||
|
openTime?: string | null;
|
||||||
|
closeTime?: string | null;
|
||||||
|
openTime2?: string | null;
|
||||||
|
closeTime2?: string | null;
|
||||||
|
avgPrice?: number | null;
|
||||||
|
settlementRate?: number;
|
||||||
category?: { id: string; name: string; parentId?: string | null } | null;
|
category?: { id: string; name: string; parentId?: string | null } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -525,6 +538,11 @@ export default function StoresPage() {
|
|||||||
address: d.address,
|
address: d.address,
|
||||||
district: d.district,
|
district: d.district,
|
||||||
settlementRate: d.settlementRate != null ? Number(d.settlementRate) * 100 : 60,
|
settlementRate: d.settlementRate != null ? Number(d.settlementRate) * 100 : 60,
|
||||||
|
openTime: d.openTime || '10:00',
|
||||||
|
closeTime: d.closeTime || '22:00',
|
||||||
|
openTime2: d.openTime2 || undefined,
|
||||||
|
closeTime2: d.closeTime2 || undefined,
|
||||||
|
avgPrice: d.avgPrice != null ? Number(d.avgPrice) : undefined,
|
||||||
});
|
});
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
}}>详情</Button>
|
}}>详情</Button>
|
||||||
@@ -640,9 +658,39 @@ export default function StoresPage() {
|
|||||||
<Descriptions.Item label="驳回原因">{String(detail.rejectReason || '—')}</Descriptions.Item>
|
<Descriptions.Item label="驳回原因">{String(detail.rejectReason || '—')}</Descriptions.Item>
|
||||||
) : null}
|
) : null}
|
||||||
<Descriptions.Item label="地址">{String(detail.province)}{String(detail.cityName)}{String(detail.district)}{String(detail.address)}</Descriptions.Item>
|
<Descriptions.Item label="地址">{String(detail.province)}{String(detail.cityName)}{String(detail.district)}{String(detail.address)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="营业时间">
|
||||||
|
{[
|
||||||
|
detail.openTime && detail.closeTime
|
||||||
|
? `${String(detail.openTime)}-${String(detail.closeTime)}`
|
||||||
|
: null,
|
||||||
|
detail.openTime2 && detail.closeTime2
|
||||||
|
? `${String(detail.openTime2)}-${String(detail.closeTime2)}`
|
||||||
|
: null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(',') || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="人均费用">
|
||||||
|
{detail.avgPrice != null ? `¥${Number(detail.avgPrice).toFixed(0)}` : '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="核销结算比例">
|
<Descriptions.Item label="核销结算比例">
|
||||||
{detail.settlementRate != null ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '60%'}
|
{detail.settlementRate != null ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '60%'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="结算户名">
|
||||||
|
{detail.account && typeof detail.account === 'object' && 'bankAccountName' in detail.account
|
||||||
|
? String((detail.account as { bankAccountName?: string | null }).bankAccountName || '—')
|
||||||
|
: '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="开户银行">
|
||||||
|
{detail.account && typeof detail.account === 'object' && 'bankBranch' in detail.account
|
||||||
|
? String((detail.account as { bankBranch?: string | null }).bankBranch || '—')
|
||||||
|
: '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="银行卡号">
|
||||||
|
{detail.account && typeof detail.account === 'object' && 'bankAccountNo' in detail.account
|
||||||
|
? String((detail.account as { bankAccountNo?: string | null }).bankAccountNo || '—')
|
||||||
|
: '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="核销数">{String(detail.redeemCount ?? 0)}</Descriptions.Item>
|
<Descriptions.Item label="核销数">{String(detail.redeemCount ?? 0)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="操作">
|
<Descriptions.Item label="操作">
|
||||||
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => navigate(`/logs/stores?storeId=${detail.id}`)}>
|
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => navigate(`/logs/stores?storeId=${detail.id}`)}>
|
||||||
@@ -670,6 +718,25 @@ export default function StoresPage() {
|
|||||||
<Form.Item name="intro" label="介绍"><Input.TextArea rows={4} /></Form.Item>
|
<Form.Item name="intro" label="介绍"><Input.TextArea rows={4} /></Form.Item>
|
||||||
<Form.Item name="district" label="区县"><Input /></Form.Item>
|
<Form.Item name="district" label="区县"><Input /></Form.Item>
|
||||||
<Form.Item name="address" label="详细地址"><Input /></Form.Item>
|
<Form.Item name="address" label="详细地址"><Input /></Form.Item>
|
||||||
|
<Form.Item name="avgPrice" label="人均费用(选填)">
|
||||||
|
<InputNumber min={0} precision={0} style={{ width: '100%' }} addonAfter="元" placeholder="用户端展示" />
|
||||||
|
</Form.Item>
|
||||||
|
<Space wrap style={{ width: '100%' }}>
|
||||||
|
<Form.Item name="openTime" label="营业开始" rules={[{ required: true }]}>
|
||||||
|
<Input type="time" style={{ width: 140 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="closeTime" label="营业结束" rules={[{ required: true }]}>
|
||||||
|
<Input type="time" style={{ width: 140 }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
<Space wrap style={{ width: '100%' }}>
|
||||||
|
<Form.Item name="openTime2" label="第二段开始(选填)">
|
||||||
|
<Input type="time" style={{ width: 140 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="closeTime2" label="第二段结束">
|
||||||
|
<Input type="time" style={{ width: 140 }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
<Form.Item name="settlementRate" label="核销结算比例 %" initialValue={60} rules={[{ required: true }]}>
|
<Form.Item name="settlementRate" label="核销结算比例 %" initialValue={60} rules={[{ required: true }]}>
|
||||||
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} addonAfter="%" />
|
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} addonAfter="%" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ export type StoreDraftForm = {
|
|||||||
address: string;
|
address: string;
|
||||||
openTime: string;
|
openTime: string;
|
||||||
closeTime: string;
|
closeTime: string;
|
||||||
|
/** 是否启用第二段营业时间 */
|
||||||
|
dualHours: boolean;
|
||||||
|
openTime2: string;
|
||||||
|
closeTime2: string;
|
||||||
|
/** 人均费用(选填) */
|
||||||
|
avgPrice: string;
|
||||||
categoryParentId: string;
|
categoryParentId: string;
|
||||||
categoryId: string;
|
categoryId: string;
|
||||||
intro: string;
|
intro: string;
|
||||||
@@ -43,6 +49,10 @@ export const defaultStoreForm = (): StoreDraftForm => ({
|
|||||||
address: '',
|
address: '',
|
||||||
openTime: '10:00',
|
openTime: '10:00',
|
||||||
closeTime: '22:00',
|
closeTime: '22:00',
|
||||||
|
dualHours: false,
|
||||||
|
openTime2: '17:00',
|
||||||
|
closeTime2: '21:00',
|
||||||
|
avgPrice: '',
|
||||||
categoryParentId: '',
|
categoryParentId: '',
|
||||||
categoryId: '',
|
categoryId: '',
|
||||||
intro: '',
|
intro: '',
|
||||||
@@ -54,62 +64,48 @@ export const defaultStoreForm = (): StoreDraftForm => ({
|
|||||||
bankBranch: '',
|
bankBranch: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
export function normalizeStringArray(raw: unknown, length: number): string[] {
|
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||||
if (!Array.isArray(raw)) return Array.from({ length }, () => '');
|
const PHONE_RE = /^1[3-9]\d{9}$/;
|
||||||
const items = raw.map((item) => String(item ?? ''));
|
const BANK_RE = /^\d{16,19}$/;
|
||||||
while (items.length < length) items.push('');
|
|
||||||
return items.slice(0, length);
|
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 base = defaultStoreForm();
|
||||||
const regionCodes = Array.isArray(raw.regionCodes) && raw.regionCodes.length >= 3
|
if (!raw) return base;
|
||||||
? raw.regionCodes.map((code) => String(code))
|
|
||||||
: base.regionCodes;
|
|
||||||
return {
|
return {
|
||||||
regionCodes,
|
...base,
|
||||||
|
...raw,
|
||||||
|
regionCodes: Array.isArray(raw.regionCodes) ? raw.regionCodes.map(String) : base.regionCodes,
|
||||||
cityId: String(raw.cityId ?? base.cityId),
|
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),
|
openTime: String(raw.openTime ?? base.openTime),
|
||||||
closeTime: String(raw.closeTime ?? base.closeTime),
|
closeTime: String(raw.closeTime ?? base.closeTime),
|
||||||
categoryParentId: String(raw.categoryParentId ?? base.categoryParentId),
|
dualHours: Boolean(raw.dualHours),
|
||||||
categoryId: String(raw.categoryId ?? base.categoryId),
|
openTime2: String(raw.openTime2 ?? base.openTime2),
|
||||||
intro: String(raw.intro ?? base.intro),
|
closeTime2: String(raw.closeTime2 ?? base.closeTime2),
|
||||||
coverUrl: String(raw.coverUrl ?? base.coverUrl),
|
avgPrice: String(raw.avgPrice ?? base.avgPrice),
|
||||||
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, 3),
|
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 {
|
export function loadStoreDraft(accountId?: string): StoreDraft | null {
|
||||||
try {
|
try {
|
||||||
const key = storeDraftKey(accountId);
|
const raw = localStorage.getItem(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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
const parsed = JSON.parse(raw) as StoreDraft;
|
||||||
if (parsed.form && typeof parsed.form === 'object') {
|
|
||||||
return {
|
return {
|
||||||
step: Math.min(3, Math.max(1, Number(parsed.step) || 1)),
|
step: Number(parsed.step) || 0,
|
||||||
form: normalizeForm(parsed.form as Record<string, unknown>),
|
form: normalizeStoreDraftForm(parsed.form),
|
||||||
};
|
};
|
||||||
}
|
|
||||||
return { step: 1, form: normalizeForm(parsed) };
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -123,21 +119,6 @@ export function clearStoreDraft(accountId?: string) {
|
|||||||
localStorage.removeItem(storeDraftKey(accountId));
|
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(
|
export function validateStoreStep1(
|
||||||
form: Pick<
|
form: Pick<
|
||||||
StoreDraftForm,
|
StoreDraftForm,
|
||||||
@@ -147,6 +128,10 @@ export function validateStoreStep1(
|
|||||||
| 'address'
|
| 'address'
|
||||||
| 'openTime'
|
| 'openTime'
|
||||||
| 'closeTime'
|
| 'closeTime'
|
||||||
|
| 'dualHours'
|
||||||
|
| 'openTime2'
|
||||||
|
| 'closeTime2'
|
||||||
|
| 'avgPrice'
|
||||||
| 'categoryId'
|
| 'categoryId'
|
||||||
| 'intro'
|
| 'intro'
|
||||||
>,
|
>,
|
||||||
@@ -162,6 +147,22 @@ export function validateStoreStep1(
|
|||||||
if (timeToMinutes(form.openTime.trim()) >= timeToMinutes(form.closeTime.trim())) {
|
if (timeToMinutes(form.openTime.trim()) >= timeToMinutes(form.closeTime.trim())) {
|
||||||
return '营业结束时间须晚于开始时间';
|
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.categoryId.trim()) return '请选择店铺类型';
|
||||||
if (form.intro.trim()) {
|
if (form.intro.trim()) {
|
||||||
const len = form.intro.trim().length;
|
const len = form.intro.trim().length;
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ export type StoreStatusValue = 'OPEN' | 'PAUSED' | 'CLOSED';
|
|||||||
export function storeStatusLabel(status: string): string {
|
export function storeStatusLabel(status: string): string {
|
||||||
const s = String(status).toUpperCase();
|
const s = String(status).toUpperCase();
|
||||||
if (s === 'OPEN') return '营业中';
|
if (s === 'OPEN') return '营业中';
|
||||||
if (s === 'PAUSED') return '暂时闭店';
|
if (s === 'PAUSED') return '临时闭店';
|
||||||
if (s === 'CLOSED') return '关闭';
|
if (s === 'CLOSED') return '永久关闭';
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -512,6 +512,12 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
closeTime: form.closeTime.trim(),
|
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(),
|
categoryId: form.categoryId.trim(),
|
||||||
|
|
||||||
intro: form.intro.trim() || undefined,
|
intro: form.intro.trim() || undefined,
|
||||||
@@ -740,7 +746,7 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
<label>营业时间 <span className="text-primary">*</span></label>
|
<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
|
<input
|
||||||
|
|
||||||
@@ -774,14 +780,92 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
</div>
|
</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 }}>
|
<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>
|
</p>
|
||||||
|
|
||||||
</div>
|
</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">
|
<div className="partner-field">
|
||||||
|
|
||||||
<label>门店简介</label>
|
<label>门店简介</label>
|
||||||
|
|||||||
@@ -44,8 +44,14 @@ export default function HomePage() {
|
|||||||
|
|
||||||
const store = dash?.store as Record<string, unknown> | undefined;
|
const store = dash?.store as Record<string, unknown> | undefined;
|
||||||
const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || [];
|
const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || [];
|
||||||
const openTime = String(store?.openTime || '10:00');
|
const status = String(store?.status || '');
|
||||||
const closeTime = String(store?.closeTime || '22:00');
|
const open = status === 'OPEN';
|
||||||
|
const hoursParts: string[] = [];
|
||||||
|
if (store?.openTime && store?.closeTime) hoursParts.push(`${store.openTime} - ${store.closeTime}`);
|
||||||
|
if (store?.openTime2 && store?.closeTime2) hoursParts.push(`${store.openTime2} - ${store.closeTime2}`);
|
||||||
|
const hoursText = hoursParts.length ? hoursParts.join(',') : '10:00 - 22:00';
|
||||||
|
const statusText =
|
||||||
|
status === 'CLOSED' ? '永久关闭' : open ? '当前正在营业中' : '当前临时闭店';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PullToRefresh onRefresh={loadDashboard} className="shop-home-page">
|
<PullToRefresh onRefresh={loadDashboard} className="shop-home-page">
|
||||||
@@ -91,12 +97,12 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="shop-home-status-title">营业状态</p>
|
<p className="shop-home-status-title">营业状态</p>
|
||||||
<p className="shop-home-status-sub">{open ? '当前正在营业中' : '当前已停止营业'}</p>
|
<p className="shop-home-status-sub">{statusText}</p>
|
||||||
<p className="shop-home-status-sub">营业时间: {openTime} - {closeTime}</p>
|
<p className="shop-home-status-sub">营业时间: {hoursText}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label className="shop-home-switch" onClick={() => navigate('/status')}>
|
<label className="shop-home-switch" onClick={() => navigate('/status')}>
|
||||||
<input type="checkbox" checked={open} readOnly tabIndex={-1} />
|
<input type="checkbox" checked={open && status !== 'CLOSED'} readOnly tabIndex={-1} />
|
||||||
<span className="shop-home-switch-track" />
|
<span className="shop-home-switch-track" />
|
||||||
</label>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -58,8 +58,10 @@ export default function MinePage() {
|
|||||||
.catch((e) => setBindMsg(e instanceof Error ? e.message : '微信绑定失败'));
|
.catch((e) => setBindMsg(e instanceof Error ? e.message : '微信绑定失败'));
|
||||||
}, [applySession, searchParams, setSearchParams, wxAuthorize]);
|
}, [applySession, searchParams, setSearchParams, wxAuthorize]);
|
||||||
|
|
||||||
const openTime = String(store?.openTime || '09:30');
|
const hoursParts: string[] = [];
|
||||||
const closeTime = String(store?.closeTime || '22:00');
|
if (store?.openTime && store?.closeTime) hoursParts.push(`${store.openTime} - ${store.closeTime}`);
|
||||||
|
if (store?.openTime2 && store?.closeTime2) hoursParts.push(`${store.openTime2} - ${store.closeTime2}`);
|
||||||
|
const hoursText = hoursParts.length ? hoursParts.join(',') : '09:30 - 22:00';
|
||||||
const multiStore = (profile?.stores?.length ?? 0) > 1;
|
const multiStore = (profile?.stores?.length ?? 0) > 1;
|
||||||
const showBindWechat = wxAuthorize && hasWechat === false;
|
const showBindWechat = wxAuthorize && hasWechat === false;
|
||||||
|
|
||||||
@@ -126,7 +128,7 @@ export default function MinePage() {
|
|||||||
<div className="shop-mine-info-row">
|
<div className="shop-mine-info-row">
|
||||||
<div>
|
<div>
|
||||||
<p className="shop-mine-info-label">营业时间</p>
|
<p className="shop-mine-info-label">营业时间</p>
|
||||||
<p className="shop-mine-info-value">{openTime} - {closeTime}</p>
|
<p className="shop-mine-info-value">{hoursText}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="material-symbols-outlined shop-mine-lock">lock</span>
|
<span className="material-symbols-outlined shop-mine-lock">lock</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -63,7 +63,8 @@ export default function SelectStorePage() {
|
|||||||
|
|
||||||
function statusLabel(status: string) {
|
function statusLabel(status: string) {
|
||||||
if (status === 'OPEN') return '营业中';
|
if (status === 'OPEN') return '营业中';
|
||||||
if (status === 'PAUSED') return '暂停营业';
|
if (status === 'PAUSED') return '临时闭店';
|
||||||
|
if (status === 'CLOSED') return '永久关闭';
|
||||||
return status || '门店';
|
return status || '门店';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,20 +3,35 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
|
|
||||||
|
function formatShopHours(store: Record<string, unknown> | null) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
const openTime = store?.openTime ? String(store.openTime) : '';
|
||||||
|
const closeTime = store?.closeTime ? String(store.closeTime) : '';
|
||||||
|
const openTime2 = store?.openTime2 ? String(store.openTime2) : '';
|
||||||
|
const closeTime2 = store?.closeTime2 ? String(store.closeTime2) : '';
|
||||||
|
if (openTime && closeTime) parts.push(`${openTime} - ${closeTime}`);
|
||||||
|
if (openTime2 && closeTime2) parts.push(`${openTime2} - ${closeTime2}`);
|
||||||
|
return parts.length ? parts.join(',') : '09:30 - 22:00';
|
||||||
|
}
|
||||||
|
|
||||||
export default function StatusPage() {
|
export default function StatusPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { resetSession } = useStoreSession();
|
const { resetSession } = useStoreSession();
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
|
const [permanentlyClosed, setPermanentlyClosed] = useState(false);
|
||||||
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
||||||
const [lastUpdate, setLastUpdate] = useState('');
|
const [lastUpdate, setLastUpdate] = useState('');
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [pendingOpen, setPendingOpen] = useState<boolean | null>(null);
|
const [pendingOpen, setPendingOpen] = useState<boolean | null>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
request('SHOP_H5', '/shop/dashboard').then((d) => {
|
request('SHOP_H5', '/shop/dashboard').then((d) => {
|
||||||
const s = d.store as Record<string, unknown>;
|
const s = d.store as Record<string, unknown>;
|
||||||
setStore(s);
|
setStore(s);
|
||||||
setOpen(String(s?.status) === 'OPEN');
|
const status = String(s?.status || '');
|
||||||
|
setPermanentlyClosed(status === 'CLOSED');
|
||||||
|
setOpen(status === 'OPEN');
|
||||||
if (s?.updatedAt) {
|
if (s?.updatedAt) {
|
||||||
setLastUpdate(new Date(String(s.updatedAt)).toLocaleString('zh-CN'));
|
setLastUpdate(new Date(String(s.updatedAt)).toLocaleString('zh-CN'));
|
||||||
}
|
}
|
||||||
@@ -24,31 +39,33 @@ export default function StatusPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function requestToggle(next: boolean) {
|
function requestToggle(next: boolean) {
|
||||||
|
if (permanentlyClosed) return;
|
||||||
if (next === open) return;
|
if (next === open) return;
|
||||||
setPendingOpen(next);
|
setPendingOpen(next);
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmToggle() {
|
async function confirmToggle() {
|
||||||
if (pendingOpen === null) return;
|
if (pendingOpen === null || permanentlyClosed) return;
|
||||||
const next = pendingOpen ? 'OPEN' : 'PAUSED';
|
const next = pendingOpen ? 'OPEN' : 'PAUSED';
|
||||||
try {
|
try {
|
||||||
|
setError('');
|
||||||
await request('SHOP_H5', '/shop/store/status', {
|
await request('SHOP_H5', '/shop/store/status', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ status: next }),
|
body: JSON.stringify({ status: next }),
|
||||||
});
|
});
|
||||||
setOpen(pendingOpen);
|
setOpen(pendingOpen);
|
||||||
setLastUpdate(new Date().toLocaleString('zh-CN'));
|
setLastUpdate(new Date().toLocaleString('zh-CN'));
|
||||||
} catch {
|
} catch (e) {
|
||||||
/* keep current state */
|
setError(e instanceof Error ? e.message : '状态切换失败');
|
||||||
} finally {
|
} finally {
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
setPendingOpen(null);
|
setPendingOpen(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openTime = String(store?.openTime || '09:30');
|
const hoursText = formatShopHours(store);
|
||||||
const closeTime = String(store?.closeTime || '22:00');
|
const statusLabel = permanentlyClosed ? '永久关闭' : open ? '营业中' : '临时闭店';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shop-status-page">
|
<div className="shop-status-page">
|
||||||
@@ -70,22 +87,33 @@ export default function StatusPage() {
|
|||||||
<div className="shop-status-content">
|
<div className="shop-status-content">
|
||||||
<section className="shop-status-card">
|
<section className="shop-status-card">
|
||||||
<div className="shop-status-icon-wrap">
|
<div className="shop-status-icon-wrap">
|
||||||
<div className={`shop-status-icon-outer${open ? ' open' : ' closed'}`}>
|
<div className={`shop-status-icon-outer${open && !permanentlyClosed ? ' open' : ' closed'}`}>
|
||||||
<div className={`shop-status-icon-inner${open ? ' open' : ' closed'}`}>
|
<div className={`shop-status-icon-inner${open && !permanentlyClosed ? ' open' : ' closed'}`}>
|
||||||
<span className="material-symbols-outlined">storefront</span>
|
<span className="material-symbols-outlined">storefront</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="shop-status-check">
|
<span className="shop-status-check">
|
||||||
<span className={`material-symbols-outlined shop-fill-icon${open ? '' : ''}`} style={{ fontSize: 14, color: open ? 'var(--color-success-green)' : 'var(--color-subtle-gray)' }}>
|
<span
|
||||||
{open ? 'check_circle' : 'cancel'}
|
className="material-symbols-outlined shop-fill-icon"
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: open && !permanentlyClosed ? 'var(--color-success-green)' : 'var(--color-subtle-gray)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{open && !permanentlyClosed ? 'check_circle' : 'cancel'}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className={`shop-status-label${open ? ' open' : ' closed'}`}>
|
<h2 className={`shop-status-label${open && !permanentlyClosed ? ' open' : ' closed'}`}>
|
||||||
{open ? '营业中' : '临时闭店'}
|
{statusLabel}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
{permanentlyClosed ? (
|
||||||
|
<p className="shop-status-switch-caption" style={{ marginTop: 12 }}>
|
||||||
|
总部已永久关闭本店,门店端无法自行恢复营业
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
<label className={`shop-status-switch${open ? ' open' : ' closed'}`}>
|
<label className={`shop-status-switch${open ? ' open' : ' closed'}`}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -98,18 +126,22 @@ export default function StatusPage() {
|
|||||||
{open ? '点击可临时闭店' : '点击恢复营业'}
|
{open ? '点击可临时闭店' : '点击恢复营业'}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="shop-status-hours-label">营业时间</p>
|
<p className="shop-status-hours-label">营业时间</p>
|
||||||
<p className="shop-status-hours">{openTime} - {closeTime}</p>
|
<p className="shop-status-hours">{hoursText}</p>
|
||||||
{lastUpdate && <p className="shop-status-updated">最后修改于 {lastUpdate}</p>}
|
{lastUpdate && <p className="shop-status-updated">最后修改于 {lastUpdate}</p>}
|
||||||
|
{error ? <p className="shop-status-updated" style={{ color: 'var(--color-error, #c62828)' }}>{error}</p> : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className={`shop-status-hint${open ? ' open' : ' closed'}`}>
|
<div className={`shop-status-hint${open && !permanentlyClosed ? ' open' : ' closed'}`}>
|
||||||
<span className="material-symbols-outlined">info</span>
|
<span className="material-symbols-outlined">info</span>
|
||||||
<p>
|
<p>
|
||||||
{open
|
{permanentlyClosed
|
||||||
|
? '门店已永久关闭。如需重新营业,请联系总部或合伙人处理。'
|
||||||
|
: open
|
||||||
? '当前处于营业状态,用户可在您的门店核销餐券。'
|
? '当前处于营业状态,用户可在您的门店核销餐券。'
|
||||||
: '当前处于休息状态,用户将无法看到您的门店或进行核销。'}
|
: '当前处于临时闭店状态,用户将无法看到您的门店或进行核销。'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,11 +14,14 @@ const TABS = [
|
|||||||
|
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
PENDING_PAY: '待付款',
|
PENDING_PAY: '待付款',
|
||||||
PENDING_SHIP: '已付款',
|
PENDING_SHIP: '待发货',
|
||||||
OUT_WAREHOUSE: '已付款',
|
OUT_WAREHOUSE: '已出库',
|
||||||
SHIPPING: '已付款',
|
SHIPPING: '配送中',
|
||||||
PENDING_RECEIVE: '已付款',
|
PENDING_RECEIVE: '待收货',
|
||||||
COMPLETED: '已完成',
|
COMPLETED: '已完成',
|
||||||
|
CANCELLED: '已取消',
|
||||||
|
REFUNDING: '退款中',
|
||||||
|
REFUNDED: '已退款',
|
||||||
};
|
};
|
||||||
|
|
||||||
function isReshipOrder(order: Record<string, unknown>) {
|
function isReshipOrder(order: Record<string, unknown>) {
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ type StoreDetail = {
|
|||||||
status: string;
|
status: string;
|
||||||
openTime?: string | null;
|
openTime?: string | null;
|
||||||
closeTime?: string | null;
|
closeTime?: string | null;
|
||||||
|
openTime2?: string | null;
|
||||||
|
closeTime2?: string | null;
|
||||||
|
avgPrice?: number | null;
|
||||||
category?: { name: string } | null;
|
category?: { name: string } | null;
|
||||||
media?: StoreMedia[];
|
media?: StoreMedia[];
|
||||||
};
|
};
|
||||||
@@ -41,8 +44,10 @@ const DEFAULT_INTRO =
|
|||||||
'作为本地优质餐饮合作伙伴,门店融合地域饮食文化与高端社交场景,设有杜康文化体验区,让宾客在用餐之余领略中华酒祖的千年传承。主打精品地方菜与创意融合菜,氛围庄重而不失亲和力,是商务宴请、亲友小聚以及文化交流的理想场所。';
|
'作为本地优质餐饮合作伙伴,门店融合地域饮食文化与高端社交场景,设有杜康文化体验区,让宾客在用餐之余领略中华酒祖的千年传承。主打精品地方菜与创意融合菜,氛围庄重而不失亲和力,是商务宴请、亲友小聚以及文化交流的理想场所。';
|
||||||
|
|
||||||
function formatHours(store: StoreDetail) {
|
function formatHours(store: StoreDetail) {
|
||||||
if (store.openTime && store.closeTime) return `${store.openTime} - ${store.closeTime}`;
|
const parts: string[] = [];
|
||||||
return '09:30 - 22:00';
|
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
|
||||||
|
if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`);
|
||||||
|
return parts.length ? parts.join(',') : '09:30-22:00';
|
||||||
}
|
}
|
||||||
|
|
||||||
function fullAddress(store: StoreDetail) {
|
function fullAddress(store: StoreDetail) {
|
||||||
@@ -135,6 +140,9 @@ export default function StoreDetailPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="store-detail-name">{store.name}</h2>
|
<h2 className="store-detail-name">{store.name}</h2>
|
||||||
<p className="store-detail-hours">营业时间:{formatHours(store)}</p>
|
<p className="store-detail-hours">营业时间:{formatHours(store)}</p>
|
||||||
|
{store.avgPrice != null && Number(store.avgPrice) > 0 ? (
|
||||||
|
<p className="store-detail-hours">人均约 ¥{Number(store.avgPrice).toFixed(0)}</p>
|
||||||
|
) : null}
|
||||||
{store.category?.name && (
|
{store.category?.name && (
|
||||||
<span className="store-detail-category">{store.category.name}</span>
|
<span className="store-detail-category">{store.category.name}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ type StoreItem = {
|
|||||||
status: string;
|
status: string;
|
||||||
openTime?: string | null;
|
openTime?: string | null;
|
||||||
closeTime?: string | null;
|
closeTime?: string | null;
|
||||||
|
openTime2?: string | null;
|
||||||
|
closeTime2?: string | null;
|
||||||
|
avgPrice?: number | null;
|
||||||
category?: { name: string } | null;
|
category?: { name: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -50,10 +53,10 @@ function storeCover(store: StoreItem, index: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatHours(store: StoreItem) {
|
function formatHours(store: StoreItem) {
|
||||||
if (store.openTime && store.closeTime) {
|
const parts: string[] = [];
|
||||||
return `营业时间: ${store.openTime}-${store.closeTime}`;
|
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
|
||||||
}
|
if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`);
|
||||||
return '营业时间: 10:00-22:00';
|
return parts.length ? `营业时间: ${parts.join(',')}` : '营业时间: 10:00-22:00';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StoreListPage() {
|
export default function StoreListPage() {
|
||||||
@@ -238,6 +241,9 @@ export default function StoreListPage() {
|
|||||||
<div className="store-card-meta">
|
<div className="store-card-meta">
|
||||||
<span className="status-open">营业中</span>
|
<span className="status-open">营业中</span>
|
||||||
<span className="store-card-hours">{formatHours(s)}</span>
|
<span className="store-card-hours">{formatHours(s)}</span>
|
||||||
|
{s.avgPrice != null && Number(s.avgPrice) > 0 ? (
|
||||||
|
<span className="store-card-hours">人均¥{Number(s.avgPrice).toFixed(0)}</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="store-card-bottom">
|
<div className="store-card-bottom">
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ type OrderPreview = {
|
|||||||
export default function OrderConfirmPickupPage() {
|
export default function OrderConfirmPickupPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const productId = router.params.productId ?? '';
|
const productId = router.params.productId ?? '';
|
||||||
const [quantity, setQuantity] = useState(Math.max(1, Number(router.params.qty || 1)));
|
const [quantity, setQuantity] = useState(Math.max(2, Number(router.params.qty || 2)));
|
||||||
const [preview, setPreview] = useState<OrderPreview | null>(null);
|
const [preview, setPreview] = useState<OrderPreview | null>(null);
|
||||||
const [previewLoading, setPreviewLoading] = useState(false);
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -70,12 +70,12 @@ export default function OrderConfirmPickupPage() {
|
|||||||
};
|
};
|
||||||
}, [productId, quantity]);
|
}, [productId, quantity]);
|
||||||
|
|
||||||
const minQty = preview?.minQty ?? 1;
|
const minQty = preview?.minQty ?? 2;
|
||||||
const quantityOk = preview ? preview.quantityOk !== false && quantity >= minQty : false;
|
const quantityOk = preview ? preview.quantityOk !== false && quantity >= minQty : false;
|
||||||
const canSubmit = !!preview && quantityOk && !loading && !previewLoading;
|
const canSubmit = !!preview && quantityOk && !loading && !previewLoading;
|
||||||
|
|
||||||
function updateQuantity(next: number) {
|
function updateQuantity(next: number) {
|
||||||
if (next < 1) return;
|
if (next < minQty) return;
|
||||||
setQuantity(next);
|
setQuantity(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ export default function OrderConfirmPickupPage() {
|
|||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!canSubmit) {
|
if (!canSubmit) {
|
||||||
if (!quantityOk) setMsg(`现场取货至少购买 ${minQty} 瓶`);
|
if (!quantityOk) setMsg(`现场提货至少购买 ${minQty} 瓶`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,8 +185,8 @@ export default function OrderConfirmPickupPage() {
|
|||||||
<Text>购买数量</Text>
|
<Text>购买数量</Text>
|
||||||
<View className="order-qty-controls">
|
<View className="order-qty-controls">
|
||||||
<View
|
<View
|
||||||
className={`order-qty-btn${quantity <= 1 ? ' order-qty-btn--disabled' : ''}`}
|
className={`order-qty-btn${quantity <= minQty ? ' order-qty-btn--disabled' : ''}`}
|
||||||
onClick={() => updateQuantity(quantity - 1)}
|
onClick={() => updateQuantity(Math.max(minQty, quantity - 1))}
|
||||||
>
|
>
|
||||||
<Text>−</Text>
|
<Text>−</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import SubPageHeader from '../../components/SubPageHeader';
|
|||||||
import { request, toast } from '../../lib/api';
|
import { request, toast } from '../../lib/api';
|
||||||
import { buildPayUrl } from '../../lib/checkout-nav';
|
import { buildPayUrl } from '../../lib/checkout-nav';
|
||||||
|
|
||||||
|
import { ORDER_STATUS_LABELS } from '@dukang/shared-types';
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ key: 'all', label: '全部订单' },
|
{ key: 'all', label: '全部订单' },
|
||||||
{ key: 'pending_pay', label: '待付款' },
|
{ key: 'pending_pay', label: '待付款' },
|
||||||
@@ -13,22 +15,12 @@ const TABS = [
|
|||||||
{ key: 'completed', label: '已完成' },
|
{ key: 'completed', label: '已完成' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
|
||||||
PENDING_PAY: '待付款',
|
|
||||||
PENDING_SHIP: '已付款',
|
|
||||||
OUT_WAREHOUSE: '已付款',
|
|
||||||
SHIPPED: '已付款',
|
|
||||||
DELIVERED: '已付款',
|
|
||||||
COMPLETED: '已完成',
|
|
||||||
CANCELLED: '已取消',
|
|
||||||
};
|
|
||||||
|
|
||||||
function orderStatusLabel(tab: string, status?: string): string {
|
function orderStatusLabel(tab: string, status?: string): string {
|
||||||
if (tab !== 'all') {
|
if (tab !== 'all') {
|
||||||
return TABS.find((t) => t.key === tab)?.label || status || '';
|
return TABS.find((t) => t.key === tab)?.label || status || '';
|
||||||
}
|
}
|
||||||
if (!status) return '';
|
if (!status) return '';
|
||||||
return STATUS_LABELS[status] || status;
|
return ORDER_STATUS_LABELS[status] || status;
|
||||||
}
|
}
|
||||||
|
|
||||||
type OrderItem = {
|
type OrderItem = {
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ type Store = {
|
|||||||
carouselUrls?: string[] | null;
|
carouselUrls?: string[] | null;
|
||||||
openTime?: string | null;
|
openTime?: string | null;
|
||||||
closeTime?: string | null;
|
closeTime?: string | null;
|
||||||
|
openTime2?: string | null;
|
||||||
|
closeTime2?: string | null;
|
||||||
|
avgPrice?: number | null;
|
||||||
category?: { name: string } | null;
|
category?: { name: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -112,8 +115,17 @@ export default function StoreDetailPage() {
|
|||||||
{store.address || '地址待完善'}
|
{store.address || '地址待完善'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="store-detail-meta">
|
<Text className="store-detail-meta">
|
||||||
营业时间: {store.openTime && store.closeTime ? `${store.openTime}-${store.closeTime}` : '10:00-22:00'}
|
营业时间:{' '}
|
||||||
|
{(() => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
|
||||||
|
if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`);
|
||||||
|
return parts.length ? parts.join(',') : '10:00-22:00';
|
||||||
|
})()}
|
||||||
</Text>
|
</Text>
|
||||||
|
{store.avgPrice != null && Number(store.avgPrice) > 0 ? (
|
||||||
|
<Text className="store-detail-meta">人均约 ¥{Number(store.avgPrice).toFixed(0)}</Text>
|
||||||
|
) : null}
|
||||||
{store.phone ? <Text className="store-detail-meta">电话: {store.phone}</Text> : null}
|
{store.phone ? <Text className="store-detail-meta">电话: {store.phone}</Text> : null}
|
||||||
<View className="store-detail-tags">
|
<View className="store-detail-tags">
|
||||||
{store.category?.name ? (
|
{store.category?.name ? (
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ type Store = {
|
|||||||
coverUrl?: string | null;
|
coverUrl?: string | null;
|
||||||
openTime?: string | null;
|
openTime?: string | null;
|
||||||
closeTime?: string | null;
|
closeTime?: string | null;
|
||||||
|
openTime2?: string | null;
|
||||||
|
closeTime2?: string | null;
|
||||||
|
avgPrice?: number | null;
|
||||||
status?: string;
|
status?: string;
|
||||||
categoryId?: string | null;
|
categoryId?: string | null;
|
||||||
category?: { id?: string; name?: string; parentId?: string | null } | null;
|
category?: { id?: string; name?: string; parentId?: string | null } | null;
|
||||||
@@ -142,10 +145,10 @@ export default function StoresPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatHours(store: Store) {
|
function formatHours(store: Store) {
|
||||||
if (store.openTime && store.closeTime) {
|
const parts: string[] = [];
|
||||||
return `营业时间: ${store.openTime}-${store.closeTime}`;
|
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
|
||||||
}
|
if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`);
|
||||||
return '营业时间: 10:00-22:00';
|
return parts.length ? `营业时间: ${parts.join(',')}` : '营业时间: 10:00-22:00';
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -204,6 +207,9 @@ export default function StoresPage() {
|
|||||||
{s.address || '地址待完善'}
|
{s.address || '地址待完善'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="store-card-meta">{formatHours(s)}</Text>
|
<Text className="store-card-meta">{formatHours(s)}</Text>
|
||||||
|
{s.avgPrice != null && Number(s.avgPrice) > 0 ? (
|
||||||
|
<Text className="store-card-meta">人均¥{Number(s.avgPrice).toFixed(0)}</Text>
|
||||||
|
) : null}
|
||||||
<View className="store-card-footer">
|
<View className="store-card-footer">
|
||||||
<Text className="store-card-distance">{MOCK_DISTANCES[index % MOCK_DISTANCES.length]}</Text>
|
<Text className="store-card-distance">{MOCK_DISTANCES[index % MOCK_DISTANCES.length]}</Text>
|
||||||
<Text
|
<Text
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
calcOrderBoxCount,
|
calcOrderBoxCount,
|
||||||
shouldHoldAutoCourierDispatch,
|
shouldHoldAutoCourierDispatch,
|
||||||
validateMinPurchase,
|
validateMinPurchase,
|
||||||
|
validateBusinessHours,
|
||||||
|
formatBusinessHours,
|
||||||
validateRedeemAmount,
|
validateRedeemAmount,
|
||||||
allocateBenefitCoupons,
|
allocateBenefitCoupons,
|
||||||
calcBenefitSummary,
|
calcBenefitSummary,
|
||||||
@@ -36,9 +38,48 @@ describe('validateMinPurchase', () => {
|
|||||||
expect(validateMinPurchase('CROSS_CITY', 6, 2, 6).ok).toBe(true);
|
expect(validateMinPurchase('CROSS_CITY', 6, 2, 6).ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('on-site pickup requires at least 1 bottle', () => {
|
it('on-site pickup requires at least local min (2 bottles)', () => {
|
||||||
expect(validateMinPurchase('ON_SITE_PICKUP', 0, 2, 6).ok).toBe(false);
|
expect(validateMinPurchase('ON_SITE_PICKUP', 0, 2, 6).ok).toBe(false);
|
||||||
expect(validateMinPurchase('ON_SITE_PICKUP', 1, 2, 6).ok).toBe(true);
|
expect(validateMinPurchase('ON_SITE_PICKUP', 1, 2, 6).ok).toBe(false);
|
||||||
|
expect(validateMinPurchase('ON_SITE_PICKUP', 2, 2, 6).ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateBusinessHours', () => {
|
||||||
|
it('accepts one segment', () => {
|
||||||
|
expect(validateBusinessHours([{ open: '09:00', close: '22:00' }]).ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts two non-overlapping segments', () => {
|
||||||
|
expect(
|
||||||
|
validateBusinessHours([
|
||||||
|
{ open: '09:00', close: '14:00' },
|
||||||
|
{ open: '17:00', close: '21:00' },
|
||||||
|
]).ok,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects overlapping second segment', () => {
|
||||||
|
expect(
|
||||||
|
validateBusinessHours([
|
||||||
|
{ open: '09:00', close: '14:00' },
|
||||||
|
{ open: '13:00', close: '21:00' },
|
||||||
|
]).ok,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatBusinessHours', () => {
|
||||||
|
it('formats one or two segments', () => {
|
||||||
|
expect(formatBusinessHours({ openTime: '9:00', closeTime: '22:00' })).toBe('9:00-22:00');
|
||||||
|
expect(
|
||||||
|
formatBusinessHours({
|
||||||
|
openTime: '09:00',
|
||||||
|
closeTime: '14:00',
|
||||||
|
openTime2: '17:00',
|
||||||
|
closeTime2: '21:00',
|
||||||
|
}),
|
||||||
|
).toBe('09:00-14:00,17:00-21:00');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ export function validateMinPurchase(
|
|||||||
crossMinQty: number,
|
crossMinQty: number,
|
||||||
): { ok: boolean; message?: string } {
|
): { ok: boolean; message?: string } {
|
||||||
if (deliveryType === 'ON_SITE_PICKUP') {
|
if (deliveryType === 'ON_SITE_PICKUP') {
|
||||||
if (quantity < 1) {
|
const min = localMinQty > 0 ? localMinQty : 2;
|
||||||
return { ok: false, message: '现场取货至少购买 1 瓶' };
|
if (quantity < min) {
|
||||||
|
return { ok: false, message: `现场提货至少购买 ${min} 瓶` };
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
@@ -95,7 +96,56 @@ export function calcRedeemSettleAmount(amount: number, settlementRate: number):
|
|||||||
return Math.round(amount * settlementRate * 100) / 100;
|
return Math.round(amount * settlementRate * 100) / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 箱规默认瓶数(跨城起购 / 物流计价 / 大单拦截) */
|
const TIME_HM_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||||
|
|
||||||
|
export type BusinessHoursSegment = { open: string; close: string };
|
||||||
|
|
||||||
|
export function timeToMinutes(hhmm: string): number {
|
||||||
|
const [h, m] = hhmm.split(':').map(Number);
|
||||||
|
return h * 60 + m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验 1~2 段营业时间,例如 09:00-22:00 或 09:00-14:00 + 17:00-21:00 */
|
||||||
|
export function validateBusinessHours(
|
||||||
|
segments: BusinessHoursSegment[],
|
||||||
|
): { ok: boolean; message?: string } {
|
||||||
|
const cleaned = segments
|
||||||
|
.map((s) => ({ open: String(s.open || '').trim(), close: String(s.close || '').trim() }))
|
||||||
|
.filter((s) => s.open || s.close);
|
||||||
|
if (cleaned.length < 1) return { ok: false, message: '请设置营业时间' };
|
||||||
|
if (cleaned.length > 2) return { ok: false, message: '营业时间最多支持 2 段' };
|
||||||
|
for (let i = 0; i < cleaned.length; i++) {
|
||||||
|
const s = cleaned[i];
|
||||||
|
if (!TIME_HM_RE.test(s.open) || !TIME_HM_RE.test(s.close)) {
|
||||||
|
return { ok: false, message: `第 ${i + 1} 段时间格式须为 HH:MM` };
|
||||||
|
}
|
||||||
|
if (timeToMinutes(s.open) >= timeToMinutes(s.close)) {
|
||||||
|
return { ok: false, message: `第 ${i + 1} 段结束时间须晚于开始时间` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cleaned.length === 2 && timeToMinutes(cleaned[0].close) >= timeToMinutes(cleaned[1].open)) {
|
||||||
|
return { ok: false, message: '第二段开始时间须晚于第一段结束时间' };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBusinessHours(store: {
|
||||||
|
openTime?: string | null;
|
||||||
|
closeTime?: string | null;
|
||||||
|
openTime2?: string | null;
|
||||||
|
closeTime2?: string | null;
|
||||||
|
}): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (store.openTime && store.closeTime) {
|
||||||
|
parts.push(`${store.openTime}-${store.closeTime}`);
|
||||||
|
}
|
||||||
|
if (store.openTime2 && store.closeTime2) {
|
||||||
|
parts.push(`${store.openTime2}-${store.closeTime2}`);
|
||||||
|
}
|
||||||
|
return parts.length ? parts.join(',') : '10:00-22:00';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 物流(快递)按瓶计价规则,如小飞侠:2瓶6元、加一瓶+2元、6瓶一箱14元 */
|
||||||
export const BOTTLES_PER_BOX = 6;
|
export const BOTTLES_PER_BOX = 6;
|
||||||
|
|
||||||
/** 小飞侠自动推单上限箱数:达到该箱数起拦截,需总部确认后推单或自配送 */
|
/** 小飞侠自动推单上限箱数:达到该箱数起拦截,需总部确认后推单或自配送 */
|
||||||
|
|||||||
@@ -106,6 +106,25 @@ export enum StoreStatus {
|
|||||||
CLOSED = 'CLOSED',
|
CLOSED = 'CLOSED',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const STORE_STATUS_LABELS: Record<StoreStatus, string> = {
|
||||||
|
[StoreStatus.OPEN]: '营业中',
|
||||||
|
[StoreStatus.PAUSED]: '临时闭店',
|
||||||
|
[StoreStatus.CLOSED]: '永久关闭',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** C 端订单列表展示用(细粒度中文) */
|
||||||
|
export const ORDER_STATUS_LABELS: Record<string, string> = {
|
||||||
|
PENDING_PAY: '待付款',
|
||||||
|
PENDING_SHIP: '待发货',
|
||||||
|
OUT_WAREHOUSE: '已出库',
|
||||||
|
SHIPPING: '配送中',
|
||||||
|
PENDING_RECEIVE: '待收货',
|
||||||
|
COMPLETED: '已完成',
|
||||||
|
CANCELLED: '已取消',
|
||||||
|
REFUNDING: '退款中',
|
||||||
|
REFUNDED: '已退款',
|
||||||
|
};
|
||||||
|
|
||||||
export enum StoreAuditStatus {
|
export enum StoreAuditStatus {
|
||||||
PENDING = 'PENDING',
|
PENDING = 'PENDING',
|
||||||
APPROVED = 'APPROVED',
|
APPROVED = 'APPROVED',
|
||||||
|
|||||||
@@ -1035,6 +1035,8 @@ model Store {
|
|||||||
auditedAt DateTime? @map("audited_at") @db.DateTime(3)
|
auditedAt DateTime? @map("audited_at") @db.DateTime(3)
|
||||||
openTime String? @map("open_time") @db.VarChar(8)
|
openTime String? @map("open_time") @db.VarChar(8)
|
||||||
closeTime String? @map("close_time") @db.VarChar(8)
|
closeTime String? @map("close_time") @db.VarChar(8)
|
||||||
|
openTime2 String? @map("open_time_2") @db.VarChar(8)
|
||||||
|
closeTime2 String? @map("close_time_2") @db.VarChar(8)
|
||||||
settlementRate Decimal @default(0.60) @map("settlement_rate") @db.Decimal(5, 4)
|
settlementRate Decimal @default(0.60) @map("settlement_rate") @db.Decimal(5, 4)
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
|
|||||||
@@ -185,6 +185,11 @@ export class AdminStoresService {
|
|||||||
...(dto.address !== undefined ? { address: dto.address } : {}),
|
...(dto.address !== undefined ? { address: dto.address } : {}),
|
||||||
...(dto.district !== undefined ? { district: dto.district } : {}),
|
...(dto.district !== undefined ? { district: dto.district } : {}),
|
||||||
...(dto.settlementRate !== undefined ? { settlementRate: dto.settlementRate } : {}),
|
...(dto.settlementRate !== undefined ? { settlementRate: dto.settlementRate } : {}),
|
||||||
|
...(dto.openTime !== undefined ? { openTime: dto.openTime } : {}),
|
||||||
|
...(dto.closeTime !== undefined ? { closeTime: dto.closeTime } : {}),
|
||||||
|
...(dto.openTime2 !== undefined ? { openTime2: dto.openTime2 || null } : {}),
|
||||||
|
...(dto.closeTime2 !== undefined ? { closeTime2: dto.closeTime2 || null } : {}),
|
||||||
|
...(dto.avgPrice !== undefined ? { avgPrice: dto.avgPrice } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -259,8 +264,11 @@ export class AdminStoresService {
|
|||||||
district: dto.district ?? '',
|
district: dto.district ?? '',
|
||||||
address: dto.address,
|
address: dto.address,
|
||||||
intro: dto.intro ?? null,
|
intro: dto.intro ?? null,
|
||||||
openTime: '10:00',
|
avgPrice: dto.avgPrice ?? null,
|
||||||
closeTime: '22:00',
|
openTime: dto.openTime?.trim() || '10:00',
|
||||||
|
closeTime: dto.closeTime?.trim() || '22:00',
|
||||||
|
openTime2: dto.openTime2?.trim() || null,
|
||||||
|
closeTime2: dto.closeTime2?.trim() || null,
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
auditStatus: 'APPROVED',
|
auditStatus: 'APPROVED',
|
||||||
auditedAt: new Date(),
|
auditedAt: new Date(),
|
||||||
|
|||||||
@@ -94,6 +94,27 @@ export class CreateStoreDto {
|
|||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
settlementRate?: number;
|
settlementRate?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
openTime?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
closeTime?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
openTime2?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
closeTime2?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
avgPrice?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateStoreDto {
|
export class UpdateStoreDto {
|
||||||
@@ -125,6 +146,27 @@ export class UpdateStoreDto {
|
|||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
settlementRate?: number;
|
settlementRate?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
openTime?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
closeTime?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
openTime2?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
closeTime2?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
avgPrice?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CreateStoreAccountDto {
|
export class CreateStoreAccountDto {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { loadAppConfig, ClientApp, SmsScene } from '@dukang/shared-types';
|
import { loadAppConfig, ClientApp, SmsScene } from '@dukang/shared-types';
|
||||||
import { PARTNER_STAFF_ROLE_LABELS, PartnerStaffRole, type PartnerLeaderboardPeriod } from '@dukang/shared-types';
|
import { PARTNER_STAFF_ROLE_LABELS, PartnerStaffRole, type PartnerLeaderboardPeriod } from '@dukang/shared-types';
|
||||||
|
import { validateBusinessHours } from '@dukang/domain';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
||||||
@@ -180,6 +181,21 @@ export class StoreService {
|
|||||||
const categoryId = parseBigIntParam(body.categoryId, '分类ID');
|
const categoryId = parseBigIntParam(body.categoryId, '分类ID');
|
||||||
await this.storeCategoryService.assertLeafCategoryId(categoryId);
|
await this.storeCategoryService.assertLeafCategoryId(categoryId);
|
||||||
|
|
||||||
|
const openTime = body.openTime ? String(body.openTime).trim() : '10:00';
|
||||||
|
const closeTime = body.closeTime ? String(body.closeTime).trim() : '22:00';
|
||||||
|
const openTime2 = body.openTime2 ? String(body.openTime2).trim() : '';
|
||||||
|
const closeTime2 = body.closeTime2 ? String(body.closeTime2).trim() : '';
|
||||||
|
const hoursCheck = validateBusinessHours([
|
||||||
|
{ open: openTime, close: closeTime },
|
||||||
|
...(openTime2 || closeTime2 ? [{ open: openTime2, close: closeTime2 }] : []),
|
||||||
|
]);
|
||||||
|
if (!hoursCheck.ok) throw new BadRequestException(hoursCheck.message);
|
||||||
|
|
||||||
|
const avgPriceRaw = body.avgPrice != null && body.avgPrice !== '' ? Number(body.avgPrice) : null;
|
||||||
|
if (avgPriceRaw != null && (Number.isNaN(avgPriceRaw) || avgPriceRaw < 0)) {
|
||||||
|
throw new BadRequestException('人均费用须为非负数字');
|
||||||
|
}
|
||||||
|
|
||||||
const store = await this.prisma.store.create({
|
const store = await this.prisma.store.create({
|
||||||
data: {
|
data: {
|
||||||
cityId: city.id,
|
cityId: city.id,
|
||||||
@@ -192,8 +208,11 @@ export class StoreService {
|
|||||||
district: String(body.district ?? ''),
|
district: String(body.district ?? ''),
|
||||||
address: String(body.address),
|
address: String(body.address),
|
||||||
intro: body.intro ? String(body.intro) : null,
|
intro: body.intro ? String(body.intro) : null,
|
||||||
openTime: body.openTime ? String(body.openTime) : '10:00',
|
avgPrice: avgPriceRaw,
|
||||||
closeTime: body.closeTime ? String(body.closeTime) : '22:00',
|
openTime,
|
||||||
|
closeTime,
|
||||||
|
openTime2: openTime2 || null,
|
||||||
|
closeTime2: closeTime2 || null,
|
||||||
status: this.config.autoApproveStore ? 'OPEN' : 'PAUSED',
|
status: this.config.autoApproveStore ? 'OPEN' : 'PAUSED',
|
||||||
auditStatus: this.config.autoApproveStore ? 'APPROVED' : 'PENDING',
|
auditStatus: this.config.autoApproveStore ? 'APPROVED' : 'PENDING',
|
||||||
auditedAt: this.config.autoApproveStore ? new Date() : null,
|
auditedAt: this.config.autoApproveStore ? new Date() : null,
|
||||||
@@ -549,6 +568,9 @@ export class StoreService {
|
|||||||
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
||||||
include: { store: true },
|
include: { store: true },
|
||||||
});
|
});
|
||||||
|
if (binding.store.status === 'CLOSED') {
|
||||||
|
throw new BadRequestException('门店已永久关闭,无法在门店端开启或调整营业状态');
|
||||||
|
}
|
||||||
if (status === 'OPEN' && binding.store.auditStatus !== 'APPROVED') {
|
if (status === 'OPEN' && binding.store.auditStatus !== 'APPROVED') {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
binding.store.auditStatus === 'REJECTED'
|
binding.store.auditStatus === 'REJECTED'
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export class TradeService {
|
|||||||
const freightPayType: FreightPayType | null = deliveryType === 'CROSS_CITY' ? 'COD' : null;
|
const freightPayType: FreightPayType | null = deliveryType === 'CROSS_CITY' ? 'COD' : null;
|
||||||
const minQty =
|
const minQty =
|
||||||
deliveryType === 'ON_SITE_PICKUP'
|
deliveryType === 'ON_SITE_PICKUP'
|
||||||
? 1
|
? city.localMinQty
|
||||||
: deliveryType === 'LOCAL'
|
: deliveryType === 'LOCAL'
|
||||||
? city.localMinQty
|
? city.localMinQty
|
||||||
: city.crossMinQty;
|
: city.crossMinQty;
|
||||||
|
|||||||
+12
-2
@@ -103,7 +103,7 @@
|
|||||||
|------|----------|
|
|------|----------|
|
||||||
| **同城** | 已开城城市;起购 **≥2 瓶**;免运费;小飞侠/仓配履约;送达后确认或 24h 自动完成 |
|
| **同城** | 已开城城市;起购 **≥2 瓶**;免运费;小飞侠/仓配履约;送达后确认或 24h 自动完成 |
|
||||||
| **跨城** | 未开通城市;起购 **≥1 箱(6 瓶)**;总部物流 **到付**;订单佣金归总部 |
|
| **跨城** | 未开通城市;起购 **≥1 箱(6 瓶)**;总部物流 **到付**;订单佣金归总部 |
|
||||||
| **现场提货** | 隐藏入口(推广码场景);支付后直接 **已完成** 并发权益 |
|
| **现场提货** | 隐藏入口(推广码场景);起购 **≥2 瓶**(同同城起购);支付后直接 **已完成** 并发权益 |
|
||||||
|
|
||||||
订单列表 Tab(V3):**待付款 | 已付款 | 已完成**。
|
订单列表 Tab(V3):**待付款 | 已付款 | 已完成**。
|
||||||
|
|
||||||
@@ -118,8 +118,18 @@
|
|||||||
|
|
||||||
- 门店列表 / 详情 / 搜索 / 省市区筛选
|
- 门店列表 / 详情 / 搜索 / 省市区筛选
|
||||||
- C 端仅展示 **营业中(OPEN)** 门店
|
- C 端仅展示 **营业中(OPEN)** 门店
|
||||||
|
- 营业时间支持 **1 段或 2 段**(如 `09:00-22:00` 或 `09:00-14:00,17:00-21:00`)
|
||||||
|
- 入驻可选填 **人均费用**,用户端门店列表/详情展示
|
||||||
- 支持「立即核销」跳转出码
|
- 支持「立即核销」跳转出码
|
||||||
|
|
||||||
|
门店状态(三态):
|
||||||
|
|
||||||
|
| 状态 | 含义 | C 端 | 门店端 |
|
||||||
|
|------|------|------|--------|
|
||||||
|
| OPEN | 营业中 | 可见可核销 | 可切为临时闭店 |
|
||||||
|
| PAUSED | 临时闭店 | 不可见 | 可恢复营业 |
|
||||||
|
| CLOSED | 永久关闭 | 不可见 | **不可**自行开启;需总部/合伙人处理 |
|
||||||
|
|
||||||
### 2.5 售后、发票与增长
|
### 2.5 售后、发票与增长
|
||||||
|
|
||||||
| 能力 | 说明 |
|
| 能力 | 说明 |
|
||||||
@@ -175,7 +185,7 @@
|
|||||||
|
|
||||||
### 3.4 营业状态
|
### 3.4 营业状态
|
||||||
|
|
||||||
主账号可切换营业状态;仅营业中门店对 C 端可见。
|
门店三态:**营业中 / 临时闭店 / 永久关闭**。门店端仅可在营业中 ↔ 临时闭店之间切换;总部设为永久关闭后,门店端不可自行开启。仅营业中门店对 C 端可见。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user