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
+2
View File
@@ -5,6 +5,7 @@ import LoginPage from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage';
import UsersPage from './pages/UsersPage';
import OrdersPage from './pages/OrdersPage';
import StorePackageAuditsPage from './pages/StorePackageAuditsPage';
import StoresPage from './pages/StoresPage';
import StoreRatingsPage from './pages/StoreRatingsPage';
import StoreAccountsPage from './pages/StoreAccountsPage';
@@ -79,6 +80,7 @@ export default function App() {
<Route path="/products" element={<ProductsPage />} />
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
<Route path="/stores" element={<StoresPage />} />
<Route path="/store-package-audits" element={<StorePackageAuditsPage />} />
<Route path="/store-ratings" element={<StoreRatingsPage />} />
<Route path="/store-categories" element={<StoreCategoriesPage />} />
<Route path="/store-accounts" element={<StoreAccountsPage />} />
@@ -0,0 +1,154 @@
import { useEffect, useState } from 'react';
import { Button, Space, Typography, message } from 'antd';
import type { StorePackageItemDto } from '@dukang/shared-types';
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
import { request } from '../lib/api';
type PackageRow = StorePackageItemDto;
function emptyRow(index = 0): PackageRow {
return { name: '', price: '0', dishes: '', usableTime: '', otherNotes: '', sortOrder: index };
}
export default function AdminStorePackagesSection({ storeId }: { storeId: string }) {
const [items, setItems] = useState<PackageRow[]>([emptyRow()]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
setLoading(true);
request<{ live: PackageRow[] }>(`/admin/stores/${storeId}/packages`)
.then((data) => {
setItems(
data.live?.length
? data.live.map((p, i) => ({ ...p, price: String(p.price), sortOrder: i }))
: [emptyRow()],
);
})
.catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败'))
.finally(() => setLoading(false));
}, [storeId]);
function updateAt(index: number, patch: Partial<PackageRow>) {
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
}
function addRow() {
if (items.length >= STORE_PACKAGE_MAX_COUNT) return;
setItems((prev) => [...prev, emptyRow(prev.length)]);
}
function removeAt(index: number) {
setItems((prev) => prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i })));
}
async function save() {
const filled = items
.map((item, index) => ({
name: item.name.trim(),
price: item.price.trim(),
dishes: item.dishes.trim(),
usableTime: item.usableTime?.trim() || null,
otherNotes: item.otherNotes?.trim() || null,
sortOrder: index,
}))
.filter((item) => item.name || item.dishes || item.price);
for (let i = 0; i < filled.length; i++) {
const item = filled[i];
if (!item.name) {
message.warning(`${i + 1} 条套餐名称不能为空`);
return;
}
if (!item.dishes) {
message.warning(`${i + 1} 条套餐菜品不能为空`);
return;
}
const price = Number(item.price);
if (!Number.isFinite(price) || price < 0) {
message.warning(`${i + 1} 条套餐价格须为非负数字`);
return;
}
}
setSaving(true);
try {
await request(`/admin/stores/${storeId}/packages`, {
method: 'PUT',
body: JSON.stringify({
packages: filled.map((p) => ({ ...p, price: Number(p.price).toFixed(2) })),
}),
});
message.success('套餐已保存并生效');
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
if (loading) return <Typography.Text type="secondary"></Typography.Text>;
return (
<div>
<Typography.Paragraph type="secondary">
{STORE_PACKAGE_MAX_COUNT}
</Typography.Paragraph>
{items.map((item, index) => (
<div key={index} style={{ border: '1px solid #f0f0f0', borderRadius: 8, padding: 12, marginBottom: 12 }}>
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 8 }}>
<Typography.Text strong> {index + 1}</Typography.Text>
{items.length > 1 ? (
<Button type="link" danger onClick={() => removeAt(index)}>
</Button>
) : null}
</Space>
<Space direction="vertical" style={{ width: '100%' }}>
<input
placeholder="套餐名称"
value={item.name}
onChange={(e) => updateAt(index, { name: e.target.value })}
style={{ width: '100%', padding: 8 }}
/>
<input
placeholder="价格(元)"
type="number"
min={0}
value={item.price}
onChange={(e) => updateAt(index, { price: e.target.value })}
style={{ width: '100%', padding: 8 }}
/>
<textarea
placeholder="菜品"
rows={2}
value={item.dishes}
onChange={(e) => updateAt(index, { dishes: e.target.value })}
style={{ width: '100%', padding: 8 }}
/>
<input
placeholder="使用时间"
value={item.usableTime || ''}
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
style={{ width: '100%', padding: 8 }}
/>
<input
placeholder="其他说明"
value={item.otherNotes || ''}
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
style={{ width: '100%', padding: 8 }}
/>
</Space>
</div>
))}
{items.length < STORE_PACKAGE_MAX_COUNT ? (
<Button onClick={addRow} style={{ marginBottom: 12 }}>
</Button>
) : null}
<Button type="primary" loading={saving} onClick={() => void save()}>
</Button>
</div>
);
}
@@ -0,0 +1,148 @@
import { useEffect, useState } from 'react';
import { Button, Input, Modal, Space, Table, Tag, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { StorePackageChangeRequestDto } from '@dukang/shared-types';
import { STORE_PACKAGE_CHANGE_STATUS_LABELS } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { fmtTime } from '../lib/constants';
export default function StorePackageAuditsPage() {
const [loading, setLoading] = useState(false);
const [items, setItems] = useState<StorePackageChangeRequestDto[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [status, setStatus] = useState<string>('PENDING');
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const [activeId, setActiveId] = useState<string | null>(null);
async function reload(nextPage = page, nextStatus = status) {
setLoading(true);
try {
const qs = new URLSearchParams({
page: String(nextPage),
pageSize: '20',
});
if (nextStatus) qs.set('status', nextStatus);
const data = await request<Paginated<StorePackageChangeRequestDto>>(
`/admin/store-package-audits?${qs}`,
);
setItems(data.items);
setTotal(data.total);
setPage(data.page);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
void reload(1, status);
}, [status]);
async function audit(id: string, action: 'APPROVE' | 'REJECT', reason?: string) {
try {
await request(`/admin/store-package-audits/${id}/audit`, {
method: 'PUT',
body: JSON.stringify(
action === 'REJECT' ? { action, rejectReason: reason } : { action },
),
});
message.success(action === 'APPROVE' ? '已通过' : '已驳回');
void reload(page, status);
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败');
}
}
const columns: ColumnsType<StorePackageChangeRequestDto> = [
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
{
title: '状态',
dataIndex: 'status',
render: (v: StorePackageChangeRequestDto['status']) => (
<Tag>{STORE_PACKAGE_CHANGE_STATUS_LABELS[v] ?? v}</Tag>
),
},
{
title: '套餐数',
render: (_, row) => row.packages?.length ?? 0,
},
{
title: '提交方',
render: (_, row) => (row.submitterType === 'PARTNER' ? '合伙人' : '门店'),
},
{ title: '提交时间', dataIndex: 'createdAt', render: (v) => fmtTime(String(v)) },
{
title: '操作',
render: (_, row) =>
row.status === 'PENDING' ? (
<Space>
<Button type="link" onClick={() => void audit(row.id, 'APPROVE')}>
</Button>
<Button
type="link"
danger
onClick={() => {
setActiveId(row.id);
setRejectReason('');
setRejectOpen(true);
}}
>
</Button>
</Space>
) : (
row.rejectReason || '—'
),
},
];
return (
<div>
<Typography.Title level={4}></Typography.Title>
<Space style={{ marginBottom: 16 }}>
{(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => (
<Button key={s || 'all'} type={status === s ? 'primary' : 'default'} onClick={() => setStatus(s)}>
{s ? STORE_PACKAGE_CHANGE_STATUS_LABELS[s as keyof typeof STORE_PACKAGE_CHANGE_STATUS_LABELS] : '全部'}
</Button>
))}
</Space>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={items}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => void reload(p, status),
}}
/>
<Modal
title="驳回套餐变更"
open={rejectOpen}
onCancel={() => setRejectOpen(false)}
onOk={() => {
if (!activeId) return;
if (!rejectReason.trim()) {
message.warning('请填写驳回原因');
return;
}
void audit(activeId, 'REJECT', rejectReason.trim());
setRejectOpen(false);
}}
>
<Input.TextArea
rows={3}
value={rejectReason}
placeholder="驳回原因"
onChange={(e) => setRejectReason(e.target.value)}
/>
</Modal>
</div>
);
}
+6
View File
@@ -41,6 +41,7 @@ import { resolveRegionBinding } from '../lib/china-region';
import ChinaRegionCascader from '../components/ChinaRegionCascader';
import OssUpload from '../components/OssUpload';
import TencentLocPickerModal from '../components/TencentLocPickerModal';
import AdminStorePackagesSection from '../components/AdminStorePackagesSection';
const CREATE_STEPS = [
{ title: '基本信息' },
@@ -1214,6 +1215,11 @@ export default function StoresPage() {
label: '审核材料',
children: <StoreAuditMediaSection detail={detail} />,
},
{
key: 'packages',
label: '套餐',
children: <AdminStorePackagesSection storeId={String(detail.id)} />,
},
]}
/>
</Form>
+1
View File
@@ -159,6 +159,7 @@ export default function TicketsPage() {
{ value: 'RESHIPMENT', label: '破损补发' },
{ value: 'DAMAGE_RETURN', label: '破损退货' },
{ value: 'RETURN_REFUND', label: '退货退款' },
{ value: 'PACKAGE_DISPUTE', label: '套餐异议' },
{ value: 'ALERT', label: '异常' },
]}
/>
+3
View File
@@ -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>
);
}
+13
View File
@@ -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,
}))
: [],
};
}
+49
View File
@@ -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);
}
+59 -10
View File
@@ -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>
);
}
+2 -1
View File
@@ -12,7 +12,7 @@ import RecordsPage from './pages/RecordsPage';
import StatusPage from './pages/StatusPage';
import MinePage from './pages/MinePage';
import StaffPage from './pages/StaffPage';
import WithdrawPage from './pages/WithdrawPage';
import PackagesPage from './pages/PackagesPage';
export default function App() {
return (
@@ -24,6 +24,7 @@ export default function App() {
<Route path="/select-store" element={<SelectStorePage />} />
<Route path="/staff" element={<StaffPage />} />
<Route path="/withdraw" element={<WithdrawPage />} />
<Route path="/packages" element={<PackagesPage />} />
<Route path="/redeem" element={<RedeemConfirmPage />} />
<Route path="/redeem/phone" element={<PhoneRedeemPage />} />
<Route path="/redeem/success" element={<RedeemSuccessPage />} />
+43
View File
@@ -0,0 +1,43 @@
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;
}
+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>
);
}
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { View, Text, Image } from '@tarojs/components';
import { View, Text, Image, Textarea } from '@tarojs/components';
import Taro, {
useDidShow,
useLoad,
@@ -28,6 +28,15 @@ type StoreMedia = {
mediaType?: string | null;
};
type StorePackage = {
name: string;
price: string | number;
dishes: string;
usableTime?: string | null;
otherNotes?: string | null;
sortOrder?: number;
};
type Store = {
id: string;
name: string;
@@ -42,6 +51,7 @@ type Store = {
coverUrl?: string | null;
carouselUrls?: string[] | null;
media?: StoreMedia[] | null;
packages?: StorePackage[] | null;
openTime?: string | null;
closeTime?: string | null;
openTime2?: string | null;
@@ -142,6 +152,9 @@ export default function StoreDetailPage() {
const [recentRedeems, setRecentRedeems] = useState<RecentRedeem[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const [disputeOpen, setDisputeOpen] = useState(false);
const [disputeRemark, setDisputeRemark] = useState('');
const [disputeSubmitting, setDisputeSubmitting] = useState(false);
const [headerSolid, setHeaderSolid] = useState(false);
const storeRef = useRef<Store | null>(null);
storeRef.current = store;
@@ -263,6 +276,27 @@ export default function StoreDetailPage() {
Taro.makePhoneCall({ phoneNumber: store.phone }).catch(() => toast('无法拨打电话'));
}
async function submitPackageDispute() {
if (!storeId) return;
setDisputeSubmitting(true);
try {
await request('/trade/package-disputes', {
method: 'POST',
body: JSON.stringify({
storeId,
remark: disputeRemark.trim() || '用户对门店套餐有异议',
}),
});
toast('已提交套餐异议');
setDisputeOpen(false);
setDisputeRemark('');
} catch (e) {
toast(e instanceof Error ? e.message : '提交失败');
} finally {
setDisputeSubmitting(false);
}
}
function openMap() {
if (!store) return;
const lat = store.latitude != null ? Number(store.latitude) : NaN;
@@ -389,6 +423,29 @@ export default function StoreDetailPage() {
</View>
) : null}
{store.packages && store.packages.length > 0 ? (
<View className="store-detail-section">
<Text className="store-detail-section-title"></Text>
{store.packages.map((pkg, index) => (
<View key={`${pkg.name}-${index}`} className="store-detail-package-card">
<Text className="store-detail-package-name">{pkg.name}</Text>
<Text className="store-detail-package-body">
{formatRedeemAmountYuan(pkg.price)} · {pkg.dishes}
</Text>
{pkg.usableTime ? (
<Text className="store-detail-package-meta">使{pkg.usableTime}</Text>
) : null}
{pkg.otherNotes ? (
<Text className="store-detail-package-meta">{pkg.otherNotes}</Text>
) : null}
</View>
))}
<Text className="store-detail-package-dispute" onClick={() => setDisputeOpen(true)}>
</Text>
</View>
) : null}
{intro ? (
<View className="store-detail-section">
<Text className="store-detail-section-title"></Text>
@@ -420,6 +477,33 @@ export default function StoreDetailPage() {
</View>
) : null}
{disputeOpen ? (
<View className="store-detail-dispute-mask" onClick={() => !disputeSubmitting && setDisputeOpen(false)}>
<View className="store-detail-dispute-panel" onClick={(e) => e.stopPropagation()}>
<Text className="store-detail-section-title"></Text>
<Text className="store-detail-package-meta"></Text>
<Textarea
className="store-detail-dispute-input"
value={disputeRemark}
maxlength={500}
placeholder="请填写异议说明(选填)"
onInput={(e) => setDisputeRemark(e.detail.value)}
/>
<View className="store-detail-dispute-actions">
<View className="u-btn u-btn--ghost" onClick={() => setDisputeOpen(false)}>
<Text></Text>
</View>
<View
className="u-btn"
onClick={() => !disputeSubmitting && void submitPackageDispute()}
>
<Text>{disputeSubmitting ? '提交中…' : '提交'}</Text>
</View>
</View>
</View>
</View>
) : null}
<View className="store-detail-bar">
<View
className="u-btn u-btn--block"
@@ -234,3 +234,85 @@
flex: 1;
margin-right: 0;
}
.store-detail-package-card {
padding: 12px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.store-detail-package-card:last-of-type {
border-bottom: none;
}
.store-detail-package-name {
display: block;
font-size: 16px;
font-weight: 700;
color: var(--color-text-primary, #1a1a1a);
margin-bottom: 6px;
}
.store-detail-package-body {
display: block;
font-size: 14px;
color: var(--color-text-secondary, #666);
line-height: 1.5;
}
.store-detail-package-meta {
display: block;
font-size: 13px;
color: var(--color-text-tertiary, #999);
margin-top: 4px;
}
.store-detail-package-dispute {
display: block;
margin-top: 12px;
font-size: 13px;
color: var(--color-heritage-red);
}
.store-detail-dispute-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 1000;
display: flex;
align-items: flex-end;
justify-content: center;
}
.store-detail-dispute-panel {
width: 100%;
max-width: 480px;
background: #fff;
border-radius: 16px 16px 0 0;
padding: 20px 16px calc(20px + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.store-detail-dispute-input {
width: 100%;
min-height: 96px;
margin: 12px 0 16px;
padding: 12px;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 8px;
box-sizing: border-box;
font-size: 14px;
}
.store-detail-dispute-actions {
display: flex;
gap: 12px;
}
.store-detail-dispute-actions .u-btn {
flex: 1;
}
.store-detail-dispute-actions .u-btn--ghost {
background: rgba(166, 29, 36, 0.08);
color: var(--color-heritage-red);
}