feat(store): 门店套餐 v3.4.10 全端实现

新增 StorePackage 与变更审核流,覆盖总部直存、合伙/门店提审、C 端展示与套餐异议工单;同步 PRD/开发文档与 REQ 索引。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 19:30:59 +08:00
parent 7e3dc131ad
commit 3c19b7dd97
35 changed files with 2128 additions and 28 deletions
+6
View File
@@ -145,6 +145,12 @@ export default function MinePage() {
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>account_balance_wallet</span>
</button>
{profile?.isPrimary ? (
<button type="button" className="shop-mine-action" onClick={() => navigate('/packages')}>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>restaurant_menu</span>
</button>
) : null}
{profile?.isPrimary ? (
<button type="button" className="shop-mine-action" onClick={() => navigate('/staff')}>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>group</span>
+172
View File
@@ -0,0 +1,172 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import type { StorePackagesResponse } from '@dukang/shared-types';
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
import { request } from '../lib/api';
import {
emptyPackage,
normalizePackageFormItems,
validatePackageFormItems,
type PackageFormItem,
} from '../lib/storePackages';
export default function PackagesPage() {
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(() => {
setLoading(true);
request<StorePackagesResponse>('SHOP_H5', '/shop/store/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));
}, []);
function updateAt(index: number, patch: Partial<PackageFormItem>) {
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
}
function addItem() {
if (items.length >= STORE_PACKAGE_MAX_COUNT) return;
setItems((prev) => [...prev, emptyPackage(prev.length)]);
}
function removeAt(index: number) {
setItems((prev) => prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i })));
}
async function submitAudit() {
const msg = validatePackageFormItems(items);
if (msg) {
setError(msg);
return;
}
if (pending?.status === 'PENDING') {
setError('已有套餐变更审核中,请等待总部处理');
return;
}
setSubmitting(true);
setError('');
try {
const packages = normalizePackageFormItems(items).map((p) => ({
...p,
price: Number(p.price).toFixed(2),
}));
await request('SHOP_H5', '/shop/store/packages', {
method: 'PUT',
body: JSON.stringify({ packages }),
});
navigate('/mine');
} catch (e) {
setError(e instanceof Error ? e.message : '提交失败');
} finally {
setSubmitting(false);
}
}
const list = items.length ? items : [emptyPackage()];
const disabled = pending?.status === 'PENDING' || submitting;
return (
<div className="shop-page">
<header className="shop-subheader">
<button type="button" className="shop-back" onClick={() => navigate('/mine')}>
</button>
<h1></h1>
</header>
{loading ? <p className="shop-muted"></p> : null}
{error ? <p className="shop-error">{error}</p> : null}
{pending?.status === 'PENDING' ? (
<p className="shop-hint"></p>
) : null}
{!loading
? list.map((item, index) => (
<div key={index} className="shop-card" style={{ marginBottom: 12 }}>
<div className="shop-card-head">
<strong> {index + 1}</strong>
{!disabled && list.length > 1 ? (
<button type="button" onClick={() => removeAt(index)}>
</button>
) : null}
</div>
<label>
<input
value={item.name}
disabled={disabled}
onChange={(e) => updateAt(index, { name: e.target.value })}
/>
</label>
<label>
<input
type="number"
min={0}
value={item.price}
disabled={disabled}
onChange={(e) => updateAt(index, { price: e.target.value })}
/>
</label>
<label>
<textarea
rows={2}
value={item.dishes}
disabled={disabled}
onChange={(e) => updateAt(index, { dishes: e.target.value })}
/>
</label>
<label>
使
<input
value={item.usableTime || ''}
disabled={disabled}
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
/>
</label>
<label>
<input
value={item.otherNotes || ''}
disabled={disabled}
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
/>
</label>
</div>
))
: null}
{!disabled && list.length < STORE_PACKAGE_MAX_COUNT ? (
<button type="button" className="shop-btn-outline" onClick={addItem}>
+
</button>
) : null}
<button
type="button"
className="shop-btn-primary"
style={{ marginTop: 16 }}
disabled={disabled}
onClick={() => void submitAudit()}
>
{submitting ? '提交中…' : '提交审核'}
</button>
</div>
);
}