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