feat(store): 门店套餐 v3.4.10 全端实现
新增 StorePackage 与变更审核流,覆盖总部直存、合伙/门店提审、C 端展示与套餐异议工单;同步 PRD/开发文档与 REQ 索引。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import SubAccountLayout from './layouts/SubAccountLayout';
|
||||
import HomePage from './pages/HomePage';
|
||||
import StoreListPage from './pages/StoreListPage';
|
||||
import StoreDetailPage from './pages/StoreDetailPage';
|
||||
import StorePackagesPage from './pages/StorePackagesPage';
|
||||
import StoreCreatePage from './pages/StoreCreatePage';
|
||||
import OrderListPage from './pages/OrderListPage';
|
||||
import OrderDetailPage from './pages/OrderDetailPage';
|
||||
@@ -31,6 +32,7 @@ function PrimaryRoutes() {
|
||||
<Route path="/center" element={<CenterPage />} />
|
||||
</Route>
|
||||
<Route path="/stores/new" element={<StoreCreatePage />} />
|
||||
<Route path="/stores/:id/packages" element={<StorePackagesPage />} />
|
||||
<Route path="/center/bills" element={<BillsPage />} />
|
||||
<Route path="/center/settlement" element={<SettlementPage />} />
|
||||
<Route path="/center/staff" element={<StaffListPage />} />
|
||||
@@ -68,6 +70,7 @@ function SubAccountRoutes() {
|
||||
{!isWarehouse && (
|
||||
<>
|
||||
<Route path="/stores/new" element={<StoreCreatePage />} />
|
||||
<Route path="/stores/:id/packages" element={<StorePackagesPage />} />
|
||||
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useState } from 'react';
|
||||
import type { PackageFormItem } from '../lib/storePackages';
|
||||
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
||||
import { emptyPackage } from '../lib/storePackages';
|
||||
|
||||
type Props = {
|
||||
items: PackageFormItem[];
|
||||
onChange: (items: PackageFormItem[]) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export default function StorePackagesForm({ items, onChange, disabled }: Props) {
|
||||
function updateAt(index: number, patch: Partial<PackageFormItem>) {
|
||||
const next = items.map((item, i) => (i === index ? { ...item, ...patch } : item));
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
if (items.length >= STORE_PACKAGE_MAX_COUNT) return;
|
||||
onChange([...items, emptyPackage(items.length)]);
|
||||
}
|
||||
|
||||
function removeAt(index: number) {
|
||||
onChange(items.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i })));
|
||||
}
|
||||
|
||||
const list = items.length ? items : [emptyPackage(0)];
|
||||
|
||||
return (
|
||||
<div className="partner-packages-form">
|
||||
{list.map((item, index) => (
|
||||
<div key={index} className="partner-card" style={{ marginBottom: 12 }}>
|
||||
<div className="partner-card-title" style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>套餐 {index + 1}</span>
|
||||
{!disabled && list.length > 1 ? (
|
||||
<button type="button" className="partner-link-btn" onClick={() => removeAt(index)}>
|
||||
删除
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<label className="partner-field">
|
||||
<span className="partner-label">套餐名称</span>
|
||||
<input
|
||||
className="partner-input"
|
||||
value={item.name}
|
||||
disabled={disabled}
|
||||
placeholder="如:套餐A"
|
||||
onChange={(e) => updateAt(index, { name: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="partner-field">
|
||||
<span className="partner-label">价格(元)</span>
|
||||
<input
|
||||
className="partner-input"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={item.price}
|
||||
disabled={disabled}
|
||||
placeholder="198"
|
||||
onChange={(e) => updateAt(index, { price: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="partner-field">
|
||||
<span className="partner-label">菜品</span>
|
||||
<textarea
|
||||
className="partner-input"
|
||||
rows={2}
|
||||
value={item.dishes}
|
||||
disabled={disabled}
|
||||
placeholder="红烧肉、红烧鱼、油焖茄子"
|
||||
onChange={(e) => updateAt(index, { dishes: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="partner-field">
|
||||
<span className="partner-label">使用时间</span>
|
||||
<input
|
||||
className="partner-input"
|
||||
value={item.usableTime || ''}
|
||||
disabled={disabled}
|
||||
placeholder="节假日除外"
|
||||
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="partner-field">
|
||||
<span className="partner-label">其他说明</span>
|
||||
<input
|
||||
className="partner-input"
|
||||
value={item.otherNotes || ''}
|
||||
disabled={disabled}
|
||||
placeholder="不可叠加"
|
||||
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
{!disabled && list.length < STORE_PACKAGE_MAX_COUNT ? (
|
||||
<button type="button" className="partner-btn-outline" onClick={addItem}>
|
||||
+ 添加套餐
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,8 @@ export type StoreDraftForm = {
|
||||
bankAccountName: string;
|
||||
bankAccountNo: string;
|
||||
bankBranch: string;
|
||||
/** 门店套餐(拓店第 4 步,可空) */
|
||||
packages: import('@dukang/shared-types').StorePackageItemDto[];
|
||||
};
|
||||
|
||||
export type StoreDraft = {
|
||||
@@ -68,6 +70,7 @@ export const defaultStoreForm = (): StoreDraftForm => ({
|
||||
bankAccountName: '',
|
||||
bankAccountNo: '',
|
||||
bankBranch: '',
|
||||
packages: [],
|
||||
});
|
||||
|
||||
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||
@@ -102,6 +105,16 @@ export function normalizeStoreDraftForm(raw: Partial<StoreDraftForm> | null | un
|
||||
closeTime2: String(raw.closeTime2 ?? base.closeTime2),
|
||||
avgPrice: String(raw.avgPrice ?? base.avgPrice),
|
||||
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, 3),
|
||||
packages: Array.isArray(raw.packages)
|
||||
? raw.packages.map((p, i) => ({
|
||||
name: String((p as { name?: string }).name ?? ''),
|
||||
price: String((p as { price?: string | number }).price ?? ''),
|
||||
dishes: String((p as { dishes?: string }).dishes ?? ''),
|
||||
usableTime: (p as { usableTime?: string }).usableTime ?? '',
|
||||
otherNotes: (p as { otherNotes?: string }).otherNotes ?? '',
|
||||
sortOrder: i,
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { StorePackageItemDto } from '@dukang/shared-types';
|
||||
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
||||
|
||||
export type PackageFormItem = StorePackageItemDto;
|
||||
|
||||
export function emptyPackage(index = 0): PackageFormItem {
|
||||
return {
|
||||
name: '',
|
||||
price: '',
|
||||
dishes: '',
|
||||
usableTime: '',
|
||||
otherNotes: '',
|
||||
sortOrder: index,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePackageFormItems(raw: PackageFormItem[]): PackageFormItem[] {
|
||||
return raw
|
||||
.map((item, index) => ({
|
||||
name: item.name.trim(),
|
||||
price: item.price.trim(),
|
||||
dishes: item.dishes.trim(),
|
||||
usableTime: item.usableTime?.trim() || '',
|
||||
otherNotes: item.otherNotes?.trim() || '',
|
||||
sortOrder: index,
|
||||
}))
|
||||
.filter((item) => item.name || item.price || item.dishes || item.usableTime || item.otherNotes);
|
||||
}
|
||||
|
||||
export function validatePackageFormItems(items: PackageFormItem[]): string | null {
|
||||
const filled = normalizePackageFormItems(items);
|
||||
if (filled.length > STORE_PACKAGE_MAX_COUNT) {
|
||||
return `套餐最多 ${STORE_PACKAGE_MAX_COUNT} 条`;
|
||||
}
|
||||
for (let i = 0; i < filled.length; i++) {
|
||||
const item = filled[i];
|
||||
if (!item.name) return `第 ${i + 1} 条套餐名称不能为空`;
|
||||
const price = Number(item.price);
|
||||
if (!Number.isFinite(price) || price < 0) return `第 ${i + 1} 条套餐价格须为非负数字`;
|
||||
if (!item.dishes) return `第 ${i + 1} 条套餐菜品不能为空`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatPackagePrice(price: string | number) {
|
||||
const n = typeof price === 'number' ? price : Number(price);
|
||||
if (!Number.isFinite(n)) return String(price);
|
||||
return n % 1 === 0 ? String(n) : n.toFixed(2);
|
||||
}
|
||||
@@ -42,10 +42,10 @@ import {
|
||||
patchEnvPhotoAt,
|
||||
|
||||
} from '../lib/storeDraft';
|
||||
import StorePackagesForm from '../components/StorePackagesForm';
|
||||
import { normalizePackageFormItems, validatePackageFormItems } from '../lib/storePackages';
|
||||
|
||||
|
||||
|
||||
const STEPS = ['基本信息', '照片上传', '结算资质'] as const;
|
||||
const STEPS = ['基本信息', '照片上传', '结算资质', '门店套餐'] as const;
|
||||
|
||||
type StoreCategoryNode = {
|
||||
id: string;
|
||||
@@ -111,7 +111,7 @@ export default function StoreCreatePage() {
|
||||
|
||||
const stepFromUrl = Number(params.get('step') || 0);
|
||||
|
||||
const step = stepFromUrl >= 1 && stepFromUrl <= 3 ? stepFromUrl : (saved?.step ?? 1);
|
||||
const step = stepFromUrl >= 1 && stepFromUrl <= 4 ? stepFromUrl : (saved?.step ?? 1);
|
||||
|
||||
|
||||
|
||||
@@ -318,13 +318,25 @@ export default function StoreCreatePage() {
|
||||
|
||||
}
|
||||
|
||||
if (step === 3) {
|
||||
const msg = validateStoreStep3(form);
|
||||
if (msg) {
|
||||
if (isPhoneValidationMessage(msg)) {
|
||||
setFieldErrors({ phone: msg });
|
||||
return;
|
||||
}
|
||||
reportFormError(msg);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
goStep(step + 1);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function submit() {
|
||||
async function submit(skipPackages = false) {
|
||||
|
||||
const msg = validateStoreStep3(form);
|
||||
|
||||
@@ -337,6 +349,15 @@ export default function StoreCreatePage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const packagesToSubmit = skipPackages ? [] : form.packages || [];
|
||||
if (!skipPackages) {
|
||||
const pkgMsg = validatePackageFormItems(packagesToSubmit);
|
||||
if (pkgMsg) {
|
||||
reportFormError(pkgMsg);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const step1Msg = validateStoreStep1(form);
|
||||
|
||||
if (step1Msg) {
|
||||
@@ -472,6 +493,15 @@ export default function StoreCreatePage() {
|
||||
|
||||
draftSaveDisabledRef.current = true;
|
||||
clearAllStoreDrafts(accountId);
|
||||
const packages = normalizePackageFormItems(packagesToSubmit);
|
||||
if (packages.length) {
|
||||
await request('PARTNER_H5', `/partner/stores/${result.store.id}/packages`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
packages: packages.map((p) => ({ ...p, price: Number(p.price).toFixed(2) })),
|
||||
}),
|
||||
});
|
||||
}
|
||||
setForm(defaultStoreForm());
|
||||
setFieldErrors({});
|
||||
setSubmitError('');
|
||||
@@ -496,7 +526,7 @@ export default function StoreCreatePage() {
|
||||
|
||||
|
||||
|
||||
const progress = step === 1 ? 0 : step === 2 ? 50 : 100;
|
||||
const progress = step === 1 ? 0 : step === 2 ? 33 : step === 3 ? 66 : 100;
|
||||
|
||||
const nextDisabled = submitting;
|
||||
|
||||
@@ -1096,6 +1126,20 @@ export default function StoreCreatePage() {
|
||||
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<>
|
||||
<section className="partner-section">
|
||||
<h2 className="partner-section-title">门店套餐(可选)</h2>
|
||||
<p className="partner-form-hint">可不填;提交后将进入总部审核。也可跳过此步。</p>
|
||||
<StorePackagesForm
|
||||
items={form.packages?.length ? form.packages : [{ name: '', price: '', dishes: '', usableTime: '', otherNotes: '', sortOrder: 0 }]}
|
||||
onChange={(packages) => setForm((prev) => ({ ...prev, packages }))}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
<footer className="partner-sticky-footer">
|
||||
@@ -1110,7 +1154,7 @@ export default function StoreCreatePage() {
|
||||
|
||||
)}
|
||||
|
||||
{step < 3 ? (
|
||||
{step < 4 ? (
|
||||
|
||||
<button type="button" className="partner-btn-primary" onClick={() => void handleNext()} disabled={nextDisabled}>
|
||||
|
||||
@@ -1122,11 +1166,16 @@ export default function StoreCreatePage() {
|
||||
|
||||
) : (
|
||||
|
||||
<button type="button" className="partner-btn-primary" onClick={() => void submit()} disabled={submitting}>
|
||||
<>
|
||||
<button type="button" className="partner-btn-outline" onClick={() => void submit(true)} disabled={submitting}>
|
||||
跳过
|
||||
</button>
|
||||
<button type="button" className="partner-btn-primary" onClick={() => void submit(false)} disabled={submitting}>
|
||||
|
||||
{submitting ? '提交中…' : '提交'}
|
||||
{submitting ? '提交中…' : '提交'}
|
||||
|
||||
</button>
|
||||
</button>
|
||||
</>
|
||||
|
||||
)}
|
||||
|
||||
|
||||
@@ -277,6 +277,18 @@ export default function StoreDetailPage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{canMutate && !auditPending && status !== 'CLOSED' && (
|
||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}>门店套餐</h3>
|
||||
<button type="button" className="partner-btn-outline" onClick={() => navigate(`/stores/${id}/packages`)}>
|
||||
编辑套餐
|
||||
</button>
|
||||
</div>
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>维护可核销套餐信息,提交后由总部审核。</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{canMutate && (
|
||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import type { StorePackagesResponse } from '@dukang/shared-types';
|
||||
import StorePackagesForm from '../components/StorePackagesForm';
|
||||
import { request } from '../lib/api';
|
||||
import { toastError, toastSuccess } from '../lib/toast';
|
||||
import {
|
||||
emptyPackage,
|
||||
normalizePackageFormItems,
|
||||
validatePackageFormItems,
|
||||
type PackageFormItem,
|
||||
} from '../lib/storePackages';
|
||||
|
||||
export default function StorePackagesPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<PackageFormItem[]>([emptyPackage()]);
|
||||
const [pending, setPending] = useState<StorePackagesResponse['pendingRequest']>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
request<StorePackagesResponse>('PARTNER_H5', `/partner/stores/${id}/packages`)
|
||||
.then((data) => {
|
||||
const base = data.pendingRequest?.packages?.length
|
||||
? data.pendingRequest.packages
|
||||
: data.live?.length
|
||||
? data.live
|
||||
: [emptyPackage()];
|
||||
setItems(base.map((p, i) => ({ ...p, price: String(p.price), sortOrder: i })));
|
||||
setPending(data.pendingRequest ?? null);
|
||||
})
|
||||
.catch((e) => setError(e instanceof Error ? e.message : '加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
async function submitAudit() {
|
||||
if (!id) return;
|
||||
const msg = validatePackageFormItems(items);
|
||||
if (msg) {
|
||||
toastError(msg);
|
||||
return;
|
||||
}
|
||||
if (pending?.status === 'PENDING') {
|
||||
toastError('已有套餐变更审核中,请等待总部处理');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const packages = normalizePackageFormItems(items).map((p) => ({
|
||||
...p,
|
||||
price: Number(p.price).toFixed(2),
|
||||
}));
|
||||
await request('PARTNER_H5', `/partner/stores/${id}/packages`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ packages }),
|
||||
});
|
||||
toastSuccess('已提交审核');
|
||||
navigate(`/stores/${id}`);
|
||||
} catch (e) {
|
||||
toastError(e instanceof Error ? e.message : '提交失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="partner-page">
|
||||
<p className="partner-muted">加载中…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="partner-page partner-page-sticky">
|
||||
<header className="partner-subheader">
|
||||
<button type="button" className="partner-back" onClick={() => navigate(`/stores/${id}`)}>
|
||||
返回
|
||||
</button>
|
||||
<h1 className="partner-subheader-title">门店套餐</h1>
|
||||
</header>
|
||||
|
||||
{error ? <p className="partner-form-error">{error}</p> : null}
|
||||
{pending?.status === 'PENDING' ? (
|
||||
<p className="partner-form-hint">套餐变更审核中,C 端仍展示上一版生效套餐。</p>
|
||||
) : null}
|
||||
{pending?.status === 'REJECTED' && pending.rejectReason ? (
|
||||
<p className="partner-form-error">上次驳回:{pending.rejectReason}</p>
|
||||
) : null}
|
||||
|
||||
<StorePackagesForm
|
||||
items={items}
|
||||
onChange={setItems}
|
||||
disabled={pending?.status === 'PENDING' || submitting}
|
||||
/>
|
||||
|
||||
<div className="partner-sticky-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-primary"
|
||||
disabled={submitting || pending?.status === 'PENDING'}
|
||||
onClick={() => void submitAudit()}
|
||||
>
|
||||
{submitting ? '提交中…' : '提交审核'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user