style(store): 统一门店套餐页 UI 与拓店校验弹窗

管理后台套餐 Tab 对齐 Ant Design 表单;合伙人/门店端套餐表单与基本信息页风格一致;拓店下一步校验增加弹窗提示。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 19:59:44 +08:00
parent 4e7079c221
commit 3ba7b179e8
8 changed files with 637 additions and 235 deletions
@@ -0,0 +1,108 @@
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
import { emptyPackage, type PackageFormItem } from '../lib/storePackages';
type Props = {
items: PackageFormItem[];
onChange: (items: PackageFormItem[]) => void;
disabled?: boolean;
};
export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
function updateAt(index: number, patch: Partial<PackageFormItem>) {
onChange(items.map((item, i) => (i === index ? { ...item, ...patch } : item)));
}
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="shop-packages-form">
{list.map((item, index) => (
<section key={index} className="shop-packages-card">
<div className="shop-packages-card-head">
<h3 className="shop-packages-card-title"> {index + 1}</h3>
{!disabled && list.length > 1 ? (
<button type="button" className="shop-packages-remove" onClick={() => removeAt(index)}>
</button>
) : null}
</div>
<label className="shop-packages-field">
<span className="shop-packages-label"> *</span>
<input
className="shop-packages-input"
placeholder="如:套餐A"
value={item.name}
disabled={disabled}
onChange={(e) => updateAt(index, { name: e.target.value })}
/>
</label>
<label className="shop-packages-field">
<span className="shop-packages-label"> *</span>
<input
className="shop-packages-input"
type="number"
min={0}
step={0.01}
placeholder="198"
value={item.price}
disabled={disabled}
onChange={(e) => updateAt(index, { price: e.target.value })}
/>
</label>
<label className="shop-packages-field">
<span className="shop-packages-label"> *</span>
<textarea
className="shop-packages-textarea"
rows={3}
placeholder="红烧肉、红烧鱼、油焖茄子"
value={item.dishes}
disabled={disabled}
onChange={(e) => updateAt(index, { dishes: e.target.value })}
/>
</label>
<label className="shop-packages-field">
<span className="shop-packages-label">使</span>
<input
className="shop-packages-input"
placeholder="节假日除外"
value={item.usableTime || ''}
disabled={disabled}
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
/>
</label>
<label className="shop-packages-field">
<span className="shop-packages-label"></span>
<input
className="shop-packages-input"
placeholder="不可叠加"
value={item.otherNotes || ''}
disabled={disabled}
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
/>
</label>
</section>
))}
{!disabled && list.length < STORE_PACKAGE_MAX_COUNT ? (
<button type="button" className="shop-packages-add" onClick={addItem}>
<span className="material-symbols-outlined">add_circle</span>
</button>
) : null}
</div>
);
}
+87 -118
View File
@@ -1,7 +1,8 @@
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 PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import ShopPackagesForm from '../components/ShopPackagesForm';
import { request } from '../lib/api';
import {
emptyPackage,
@@ -16,49 +17,43 @@ export default function PackagesPage() {
const [pending, setPending] = useState<StorePackagesResponse['pendingRequest']>(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [msg, setMsg] = useState('');
async function load() {
setLoading(true);
try {
const data = await request<StorePackagesResponse>('SHOP_H5', '/shop/store/packages');
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);
setMsg('');
} catch (e) {
setMsg(e instanceof Error ? e.message : '加载失败');
} finally {
setLoading(false);
}
}
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));
void load();
}, []);
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);
const validationMsg = validatePackageFormItems(items);
if (validationMsg) {
setMsg(validationMsg);
return;
}
if (pending?.status === 'PENDING') {
setError('已有套餐变更审核中,请等待总部处理');
setMsg('已有套餐变更审核中,请等待总部处理');
return;
}
setSubmitting(true);
setError('');
setMsg('');
try {
const packages = normalizePackageFormItems(items).map((p) => ({
...p,
@@ -68,105 +63,79 @@ export default function PackagesPage() {
method: 'PUT',
body: JSON.stringify({ packages }),
});
navigate('/mine');
setMsg('已提交审核,请等待总部处理');
await load();
} catch (e) {
setError(e instanceof Error ? e.message : '提交失败');
setMsg(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')}>
<PullToRefresh onRefresh={load} className="shop-records-page shop-packages-page">
<header className="shop-records-header" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
type="button"
className="shop-withdraw-back"
onClick={() => navigate('/mine')}
aria-label="返回"
>
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>
arrow_back
</span>
</button>
<h1></h1>
<h1 className="app-page-title" style={{ margin: 0 }}>
</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}
<div className="shop-records-main">
{loading ? <p className="shop-records-empty"></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}
{!loading && pending?.status === 'PENDING' ? (
<section className="shop-packages-notice">
<span className="material-symbols-outlined shop-fill-icon" style={{ fontSize: 18 }}>
hourglass_top
</span>
<p></p>
</section>
) : null}
{!disabled && list.length < STORE_PACKAGE_MAX_COUNT ? (
<button type="button" className="shop-btn-outline" onClick={addItem}>
+
</button>
) : null}
{!loading && pending?.status === 'REJECTED' && pending.rejectReason ? (
<section className="shop-packages-notice shop-packages-notice--warn">
<span className="material-symbols-outlined shop-fill-icon" style={{ fontSize: 18 }}>
error
</span>
<p>{pending.rejectReason}</p>
</section>
) : null}
<button
type="button"
className="shop-btn-primary"
style={{ marginTop: 16 }}
disabled={disabled}
onClick={() => void submitAudit()}
>
{submitting ? '提交中…' : '提交审核'}
</button>
</div>
{!loading ? (
<>
<p className="shop-packages-intro">
</p>
<ShopPackagesForm items={items} onChange={setItems} disabled={disabled} />
</>
) : null}
{msg ? <p className="shop-withdraw-msg">{msg}</p> : null}
{!loading ? (
<button
type="button"
className="shop-withdraw-btn"
style={{ marginTop: 16 }}
disabled={disabled}
onClick={() => void submitAudit()}
>
{submitting ? '提交中…' : '提交审核'}
</button>
) : null}
</div>
</PullToRefresh>
);
}
+135
View File
@@ -1920,6 +1920,141 @@
text-align: center;
}
/* ─── 门店套餐 ─── */
.shop-packages-page .shop-records-main {
padding-bottom: 24px;
}
.shop-packages-intro {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.5;
color: var(--color-subtle-gray);
}
.shop-packages-notice {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 12px;
padding: 12px 14px;
border-radius: var(--radius-md);
background: rgba(46, 125, 50, 0.08);
border: 1px solid rgba(46, 125, 50, 0.15);
font-size: 13px;
line-height: 1.5;
color: var(--color-on-surface);
}
.shop-packages-notice--warn {
background: rgba(180, 35, 24, 0.06);
border-color: rgba(180, 35, 24, 0.12);
}
.shop-packages-notice p {
margin: 0;
}
.shop-packages-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.shop-packages-card {
background: var(--color-card);
border-radius: var(--radius-md);
padding: 16px;
box-shadow: var(--shadow-card);
border: 1px solid rgba(0, 0, 0, 0.04);
}
.shop-packages-card-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.shop-packages-card-title {
margin: 0;
font-size: 16px;
font-weight: 700;
color: var(--color-on-surface);
}
.shop-packages-remove {
border: none;
background: transparent;
color: var(--color-heritage-red);
font-size: 13px;
padding: 0;
}
.shop-packages-field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
}
.shop-packages-field:last-child {
margin-bottom: 0;
}
.shop-packages-label {
font-size: 12px;
color: var(--color-subtle-gray);
}
.shop-packages-input,
.shop-packages-textarea {
width: 100%;
box-sizing: border-box;
padding: 12px;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: var(--radius-sm);
background: var(--color-surface-container-low, #fafafa);
font-size: 14px;
color: var(--color-on-surface);
}
.shop-packages-textarea {
resize: vertical;
min-height: 72px;
}
.shop-packages-input:focus,
.shop-packages-textarea:focus {
outline: none;
border-color: var(--color-heritage-red);
box-shadow: 0 0 0 1px rgba(166, 29, 36, 0.12);
}
.shop-packages-input:disabled,
.shop-packages-textarea:disabled {
opacity: 0.6;
}
.shop-packages-add {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
min-height: 48px;
border: 1px dashed rgba(166, 29, 36, 0.35);
border-radius: var(--radius-md);
background: rgba(166, 29, 36, 0.04);
color: var(--color-heritage-red);
font-size: 14px;
font-weight: 600;
}
.shop-packages-add .material-symbols-outlined {
font-size: 20px;
}
.shop-record-amounts {
display: grid;
grid-template-columns: 1fr 1fr;