feat(store): dual business hours, avg price, and status lock
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:
2026-07-26 10:16:22 +08:00
parent 94d2a88581
commit 92cf51ba3d
25 changed files with 560 additions and 146 deletions
+2 -2
View File
@@ -34,8 +34,8 @@ export const ORDER_STATUS_COLORS: Record<string, string> = {
export const STORE_STATUS_LABELS: Record<string, string> = {
OPEN: '营业中',
PAUSED: '暂停',
CLOSED: '关闭',
PAUSED: '临时闭店',
CLOSED: '永久关闭',
};
export const STORE_AUDIT_STATUS_LABELS: Record<string, string> = {
+68 -1
View File
@@ -262,7 +262,20 @@ type StoreRow = {
createdAt: string;
cityRef?: { name: string; code: 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;
};
@@ -525,6 +538,11 @@ export default function StoresPage() {
address: d.address,
district: d.district,
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);
}}></Button>
@@ -640,9 +658,39 @@ export default function StoresPage() {
<Descriptions.Item label="驳回原因">{String(detail.rejectReason || '—')}</Descriptions.Item>
) : null}
<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="核销结算比例">
{detail.settlementRate != null ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '60%'}
</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="操作">
<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="district" 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 }]}>
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} addonAfter="%" />
</Form.Item>
+58 -57
View File
@@ -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;
+2 -2
View File
@@ -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;
}
+86 -2
View File
@@ -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:0022:00
1 09:00-22:00 2 09:00-14:0017: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>
+11 -5
View File
@@ -44,8 +44,14 @@ export default function HomePage() {
const store = dash?.store as Record<string, unknown> | undefined;
const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || [];
const openTime = String(store?.openTime || '10:00');
const closeTime = String(store?.closeTime || '22:00');
const status = String(store?.status || '');
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 (
<PullToRefresh onRefresh={loadDashboard} className="shop-home-page">
@@ -91,12 +97,12 @@ export default function HomePage() {
</div>
<div>
<p className="shop-home-status-title"></p>
<p className="shop-home-status-sub">{open ? '当前正在营业中' : '当前已停止营业'}</p>
<p className="shop-home-status-sub">: {openTime} - {closeTime}</p>
<p className="shop-home-status-sub">{statusText}</p>
<p className="shop-home-status-sub">: {hoursText}</p>
</div>
</div>
<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" />
</label>
</section>
+5 -3
View File
@@ -58,8 +58,10 @@ export default function MinePage() {
.catch((e) => setBindMsg(e instanceof Error ? e.message : '微信绑定失败'));
}, [applySession, searchParams, setSearchParams, wxAuthorize]);
const openTime = String(store?.openTime || '09:30');
const closeTime = String(store?.closeTime || '22:00');
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('') : '09:30 - 22:00';
const multiStore = (profile?.stores?.length ?? 0) > 1;
const showBindWechat = wxAuthorize && hasWechat === false;
@@ -126,7 +128,7 @@ export default function MinePage() {
<div className="shop-mine-info-row">
<div>
<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>
<span className="material-symbols-outlined shop-mine-lock">lock</span>
</div>
+2 -1
View File
@@ -63,7 +63,8 @@ export default function SelectStorePage() {
function statusLabel(status: string) {
if (status === 'OPEN') return '营业中';
if (status === 'PAUSED') return '暂停营业';
if (status === 'PAUSED') return '临时闭店';
if (status === 'CLOSED') return '永久关闭';
return status || '门店';
}
+61 -29
View File
@@ -3,20 +3,35 @@ import { useNavigate } from 'react-router-dom';
import { useStoreSession } from '../contexts/StoreSessionContext';
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() {
const navigate = useNavigate();
const { resetSession } = useStoreSession();
const [open, setOpen] = useState(true);
const [permanentlyClosed, setPermanentlyClosed] = useState(false);
const [store, setStore] = useState<Record<string, unknown> | null>(null);
const [lastUpdate, setLastUpdate] = useState('');
const [showModal, setShowModal] = useState(false);
const [pendingOpen, setPendingOpen] = useState<boolean | null>(null);
const [error, setError] = useState('');
useEffect(() => {
request('SHOP_H5', '/shop/dashboard').then((d) => {
const s = d.store as Record<string, unknown>;
setStore(s);
setOpen(String(s?.status) === 'OPEN');
const status = String(s?.status || '');
setPermanentlyClosed(status === 'CLOSED');
setOpen(status === 'OPEN');
if (s?.updatedAt) {
setLastUpdate(new Date(String(s.updatedAt)).toLocaleString('zh-CN'));
}
@@ -24,31 +39,33 @@ export default function StatusPage() {
}, []);
function requestToggle(next: boolean) {
if (permanentlyClosed) return;
if (next === open) return;
setPendingOpen(next);
setShowModal(true);
}
async function confirmToggle() {
if (pendingOpen === null) return;
if (pendingOpen === null || permanentlyClosed) return;
const next = pendingOpen ? 'OPEN' : 'PAUSED';
try {
setError('');
await request('SHOP_H5', '/shop/store/status', {
method: 'PUT',
body: JSON.stringify({ status: next }),
});
setOpen(pendingOpen);
setLastUpdate(new Date().toLocaleString('zh-CN'));
} catch {
/* keep current state */
} catch (e) {
setError(e instanceof Error ? e.message : '状态切换失败');
} finally {
setShowModal(false);
setPendingOpen(null);
}
}
const openTime = String(store?.openTime || '09:30');
const closeTime = String(store?.closeTime || '22:00');
const hoursText = formatShopHours(store);
const statusLabel = permanentlyClosed ? '永久关闭' : open ? '营业中' : '临时闭店';
return (
<div className="shop-status-page">
@@ -70,46 +87,61 @@ export default function StatusPage() {
<div className="shop-status-content">
<section className="shop-status-card">
<div className="shop-status-icon-wrap">
<div className={`shop-status-icon-outer${open ? ' open' : ' closed'}`}>
<div className={`shop-status-icon-inner${open ? ' open' : ' closed'}`}>
<div className={`shop-status-icon-outer${open && !permanentlyClosed ? ' open' : ' closed'}`}>
<div className={`shop-status-icon-inner${open && !permanentlyClosed ? ' open' : ' closed'}`}>
<span className="material-symbols-outlined">storefront</span>
</div>
</div>
<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)' }}>
{open ? 'check_circle' : 'cancel'}
<span
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>
</div>
<h2 className={`shop-status-label${open ? ' open' : ' closed'}`}>
{open ? '营业中' : '临时闭店'}
<h2 className={`shop-status-label${open && !permanentlyClosed ? ' open' : ' closed'}`}>
{statusLabel}
</h2>
<label className={`shop-status-switch${open ? ' open' : ' closed'}`}>
<input
type="checkbox"
checked={open}
onChange={(e) => requestToggle(e.target.checked)}
aria-label={open ? '切换为临时闭店' : '切换为营业中'}
/>
<span className="shop-status-switch-track" />
<span className="shop-status-switch-caption">
{open ? '点击可临时闭店' : '点击恢复营业'}
</span>
</label>
{permanentlyClosed ? (
<p className="shop-status-switch-caption" style={{ marginTop: 12 }}>
</p>
) : (
<label className={`shop-status-switch${open ? ' open' : ' closed'}`}>
<input
type="checkbox"
checked={open}
onChange={(e) => requestToggle(e.target.checked)}
aria-label={open ? '切换为临时闭店' : '切换为营业中'}
/>
<span className="shop-status-switch-track" />
<span className="shop-status-switch-caption">
{open ? '点击可临时闭店' : '点击恢复营业'}
</span>
</label>
)}
<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>}
{error ? <p className="shop-status-updated" style={{ color: 'var(--color-error, #c62828)' }}>{error}</p> : null}
</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>
<p>
{open
? '当前处于营业状态,用户可在您的门店核销餐券。'
: '当前处于休息状态,用户将无法看到您的门店或进行核销。'}
{permanentlyClosed
? '门店已永久关闭。如需重新营业,请联系总部或合伙人处理。'
: open
? '当前处于营业状态,用户可在您的门店核销餐券。'
: '当前处于临时闭店状态,用户将无法看到您的门店或进行核销。'}
</p>
</div>
</div>
+7 -4
View File
@@ -14,11 +14,14 @@ const TABS = [
const STATUS_LABEL: Record<string, string> = {
PENDING_PAY: '待付款',
PENDING_SHIP: '已付款',
OUT_WAREHOUSE: '已付款',
SHIPPING: '已付款',
PENDING_RECEIVE: '已付款',
PENDING_SHIP: '待发货',
OUT_WAREHOUSE: '已出库',
SHIPPING: '配送中',
PENDING_RECEIVE: '待收货',
COMPLETED: '已完成',
CANCELLED: '已取消',
REFUNDING: '退款中',
REFUNDED: '已退款',
};
function isReshipOrder(order: Record<string, unknown>) {
+10 -2
View File
@@ -24,6 +24,9 @@ type StoreDetail = {
status: string;
openTime?: string | null;
closeTime?: string | null;
openTime2?: string | null;
closeTime2?: string | null;
avgPrice?: number | null;
category?: { name: string } | null;
media?: StoreMedia[];
};
@@ -41,8 +44,10 @@ const DEFAULT_INTRO =
'作为本地优质餐饮合作伙伴,门店融合地域饮食文化与高端社交场景,设有杜康文化体验区,让宾客在用餐之余领略中华酒祖的千年传承。主打精品地方菜与创意融合菜,氛围庄重而不失亲和力,是商务宴请、亲友小聚以及文化交流的理想场所。';
function formatHours(store: StoreDetail) {
if (store.openTime && store.closeTime) return `${store.openTime} - ${store.closeTime}`;
return '09:30 - 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('') : '09:30-22:00';
}
function fullAddress(store: StoreDetail) {
@@ -135,6 +140,9 @@ export default function StoreDetailPage() {
<div>
<h2 className="store-detail-name">{store.name}</h2>
<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 && (
<span className="store-detail-category">{store.category.name}</span>
)}
+10 -4
View File
@@ -32,6 +32,9 @@ type StoreItem = {
status: string;
openTime?: string | null;
closeTime?: string | null;
openTime2?: string | null;
closeTime2?: string | null;
avgPrice?: number | null;
category?: { name: string } | null;
};
@@ -50,10 +53,10 @@ function storeCover(store: StoreItem, index: number) {
}
function formatHours(store: StoreItem) {
if (store.openTime && store.closeTime) {
return `营业时间: ${store.openTime}-${store.closeTime}`;
}
return '营业时间: 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';
}
export default function StoreListPage() {
@@ -238,6 +241,9 @@ export default function StoreListPage() {
<div className="store-card-meta">
<span className="status-open"></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 className="store-card-bottom">
@@ -35,7 +35,7 @@ type OrderPreview = {
export default function OrderConfirmPickupPage() {
const router = useRouter();
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 [previewLoading, setPreviewLoading] = useState(false);
const [loading, setLoading] = useState(false);
@@ -70,12 +70,12 @@ export default function OrderConfirmPickupPage() {
};
}, [productId, quantity]);
const minQty = preview?.minQty ?? 1;
const minQty = preview?.minQty ?? 2;
const quantityOk = preview ? preview.quantityOk !== false && quantity >= minQty : false;
const canSubmit = !!preview && quantityOk && !loading && !previewLoading;
function updateQuantity(next: number) {
if (next < 1) return;
if (next < minQty) return;
setQuantity(next);
}
@@ -95,7 +95,7 @@ export default function OrderConfirmPickupPage() {
async function submit() {
if (!canSubmit) {
if (!quantityOk) setMsg(`现场货至少购买 ${minQty}`);
if (!quantityOk) setMsg(`现场货至少购买 ${minQty}`);
return;
}
@@ -185,8 +185,8 @@ export default function OrderConfirmPickupPage() {
<Text></Text>
<View className="order-qty-controls">
<View
className={`order-qty-btn${quantity <= 1 ? ' order-qty-btn--disabled' : ''}`}
onClick={() => updateQuantity(quantity - 1)}
className={`order-qty-btn${quantity <= minQty ? ' order-qty-btn--disabled' : ''}`}
onClick={() => updateQuantity(Math.max(minQty, quantity - 1))}
>
<Text></Text>
</View>
+3 -11
View File
@@ -6,6 +6,8 @@ import SubPageHeader from '../../components/SubPageHeader';
import { request, toast } from '../../lib/api';
import { buildPayUrl } from '../../lib/checkout-nav';
import { ORDER_STATUS_LABELS } from '@dukang/shared-types';
const TABS = [
{ key: 'all', label: '全部订单' },
{ key: 'pending_pay', label: '待付款' },
@@ -13,22 +15,12 @@ const TABS = [
{ key: 'completed', label: '已完成' },
] 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 {
if (tab !== 'all') {
return TABS.find((t) => t.key === tab)?.label || status || '';
}
if (!status) return '';
return STATUS_LABELS[status] || status;
return ORDER_STATUS_LABELS[status] || status;
}
type OrderItem = {
@@ -23,6 +23,9 @@ type Store = {
carouselUrls?: string[] | null;
openTime?: string | null;
closeTime?: string | null;
openTime2?: string | null;
closeTime2?: string | null;
avgPrice?: number | null;
category?: { name: string } | null;
};
@@ -112,8 +115,17 @@ export default function StoreDetailPage() {
{store.address || '地址待完善'}
</Text>
<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>
{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}
<View className="store-detail-tags">
{store.category?.name ? (
+10 -4
View File
@@ -31,6 +31,9 @@ type Store = {
coverUrl?: string | null;
openTime?: string | null;
closeTime?: string | null;
openTime2?: string | null;
closeTime2?: string | null;
avgPrice?: number | null;
status?: string;
categoryId?: string | null;
category?: { id?: string; name?: string; parentId?: string | null } | null;
@@ -142,10 +145,10 @@ export default function StoresPage() {
}
function formatHours(store: Store) {
if (store.openTime && store.closeTime) {
return `营业时间: ${store.openTime}-${store.closeTime}`;
}
return '营业时间: 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';
}
return (
@@ -204,6 +207,9 @@ export default function StoresPage() {
{s.address || '地址待完善'}
</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">
<Text className="store-card-distance">{MOCK_DISTANCES[index % MOCK_DISTANCES.length]}</Text>
<Text
+43 -2
View File
@@ -6,6 +6,8 @@ import {
calcOrderBoxCount,
shouldHoldAutoCourierDispatch,
validateMinPurchase,
validateBusinessHours,
formatBusinessHours,
validateRedeemAmount,
allocateBenefitCoupons,
calcBenefitSummary,
@@ -36,9 +38,48 @@ describe('validateMinPurchase', () => {
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', 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:0017:00-21:00');
});
});
+53 -3
View File
@@ -14,8 +14,9 @@ export function validateMinPurchase(
crossMinQty: number,
): { ok: boolean; message?: string } {
if (deliveryType === 'ON_SITE_PICKUP') {
if (quantity < 1) {
return { ok: false, message: '现场取货至少购买 1 瓶' };
const min = localMinQty > 0 ? localMinQty : 2;
if (quantity < min) {
return { ok: false, message: `现场提货至少购买 ${min}` };
}
return { ok: true };
}
@@ -95,7 +96,56 @@ export function calcRedeemSettleAmount(amount: number, settlementRate: number):
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;
}
/** 校验 12 段营业时间,例如 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;
/** 小飞侠自动推单上限箱数:达到该箱数起拦截,需总部确认后推单或自配送 */
+19
View File
@@ -106,6 +106,25 @@ export enum StoreStatus {
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 {
PENDING = 'PENDING',
APPROVED = 'APPROVED',
+2
View File
@@ -1035,6 +1035,8 @@ model Store {
auditedAt DateTime? @map("audited_at") @db.DateTime(3)
openTime String? @map("open_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)
createdAt DateTime @default(now()) @map("created_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.district !== undefined ? { district: dto.district } : {}),
...(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 ?? '',
address: dto.address,
intro: dto.intro ?? null,
openTime: '10:00',
closeTime: '22:00',
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,
status: 'OPEN',
auditStatus: 'APPROVED',
auditedAt: new Date(),
@@ -94,6 +94,27 @@ export class CreateStoreDto {
@IsNumber()
@Min(0)
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 {
@@ -125,6 +146,27 @@ export class UpdateStoreDto {
@IsNumber()
@Min(0)
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 {
@@ -6,6 +6,7 @@ import {
} from '@nestjs/common';
import { loadAppConfig, ClientApp, SmsScene } 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 { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { mapStoreCompat } from '../../common/compat/v31-compat';
@@ -180,6 +181,21 @@ export class StoreService {
const categoryId = parseBigIntParam(body.categoryId, '分类ID');
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({
data: {
cityId: city.id,
@@ -192,8 +208,11 @@ export class StoreService {
district: String(body.district ?? ''),
address: String(body.address),
intro: body.intro ? String(body.intro) : null,
openTime: body.openTime ? String(body.openTime) : '10:00',
closeTime: body.closeTime ? String(body.closeTime) : '22:00',
avgPrice: avgPriceRaw,
openTime,
closeTime,
openTime2: openTime2 || null,
closeTime2: closeTime2 || null,
status: this.config.autoApproveStore ? 'OPEN' : 'PAUSED',
auditStatus: this.config.autoApproveStore ? 'APPROVED' : 'PENDING',
auditedAt: this.config.autoApproveStore ? new Date() : null,
@@ -549,6 +568,9 @@ export class StoreService {
where: { storeAccountId_storeId: { storeAccountId, storeId } },
include: { store: true },
});
if (binding.store.status === 'CLOSED') {
throw new BadRequestException('门店已永久关闭,无法在门店端开启或调整营业状态');
}
if (status === 'OPEN' && binding.store.auditStatus !== 'APPROVED') {
throw new BadRequestException(
binding.store.auditStatus === 'REJECTED'
@@ -96,7 +96,7 @@ export class TradeService {
const freightPayType: FreightPayType | null = deliveryType === 'CROSS_CITY' ? 'COD' : null;
const minQty =
deliveryType === 'ON_SITE_PICKUP'
? 1
? city.localMinQty
: deliveryType === 'LOCAL'
? city.localMinQty
: city.crossMinQty;
+12 -2
View File
@@ -103,7 +103,7 @@
|------|----------|
| **同城** | 已开城城市;起购 **≥2 瓶**;免运费;小飞侠/仓配履约;送达后确认或 24h 自动完成 |
| **跨城** | 未开通城市;起购 **≥1 箱(6 瓶)**;总部物流 **到付**;订单佣金归总部 |
| **现场提货** | 隐藏入口(推广码场景);支付后直接 **已完成** 并发权益 |
| **现场提货** | 隐藏入口(推广码场景);起购 **≥2 瓶**(同同城起购);支付后直接 **已完成** 并发权益 |
订单列表 Tab(V3):**待付款 | 已付款 | 已完成**。
@@ -118,8 +118,18 @@
- 门店列表 / 详情 / 搜索 / 省市区筛选
- C 端仅展示 **营业中(OPEN** 门店
- 营业时间支持 **1 段或 2 段**(如 `09:00-22:00``09:00-14:0017:00-21:00`
- 入驻可选填 **人均费用**,用户端门店列表/详情展示
- 支持「立即核销」跳转出码
门店状态(三态):
| 状态 | 含义 | C 端 | 门店端 |
|------|------|------|--------|
| OPEN | 营业中 | 可见可核销 | 可切为临时闭店 |
| PAUSED | 临时闭店 | 不可见 | 可恢复营业 |
| CLOSED | 永久关闭 | 不可见 | **不可**自行开启;需总部/合伙人处理 |
### 2.5 售后、发票与增长
| 能力 | 说明 |
@@ -175,7 +185,7 @@
### 3.4 营业状态
主账号可切换营业状态;仅营业中门店对 C 端可见。
门店三态:**营业中 / 临时闭店 / 永久关闭**。门店端仅可在营业中 ↔ 临时闭店之间切换;总部设为永久关闭后,门店端不可自行开启。仅营业中门店对 C 端可见。
---