Compare commits
7 Commits
dev_ljy
...
9a550cbaaf
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a550cbaaf | |||
| 823e439101 | |||
| 6c299f1a1d | |||
| 7c9827875f | |||
| ae7c63c08d | |||
| 270cebc79a | |||
| ba21cc89c6 |
@@ -20,6 +20,7 @@ import CityPartnersPage from './pages/CityPartnersPage';
|
||||
import CityWarehousesPage from './pages/CityWarehousesPage';
|
||||
import FulfillmentProvidersPage from './pages/FulfillmentProvidersPage';
|
||||
import StoreMediaPage from './pages/StoreMediaPage';
|
||||
import StoreCategoriesPage from './pages/StoreCategoriesPage';
|
||||
import PromoCodesPage from './pages/PromoCodesPage';
|
||||
import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout';
|
||||
import PromoCodeDetailPage from './pages/promo/PromoCodeDetailPage';
|
||||
@@ -69,6 +70,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-categories" element={<StoreCategoriesPage />} />
|
||||
<Route path="/store-accounts" element={<StoreAccountsPage />} />
|
||||
<Route path="/store-media" element={<StoreMediaPage />} />
|
||||
<Route path="/resources" element={<ResourcesPage />} />
|
||||
|
||||
@@ -44,6 +44,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
label: '门店',
|
||||
children: [
|
||||
{ key: '/stores', label: '门店列表' },
|
||||
{ key: '/store-categories', label: '门店分类' },
|
||||
{ key: '/store-accounts', label: '门店账户' },
|
||||
{ key: '/store-media', label: '门店资源' },
|
||||
],
|
||||
|
||||
@@ -27,6 +27,10 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
|
||||
{ value: 'STORE_AUDIT', label: '门店审核' },
|
||||
{ value: 'STORE_ACCOUNT_CREATE', label: '新增门店账户' },
|
||||
{ value: 'STORE_ACCOUNT_UPDATE', label: '编辑门店账户' },
|
||||
{ value: 'STORE_CATEGORY_CREATE', label: '新增门店分类' },
|
||||
{ value: 'STORE_CATEGORY_UPDATE', label: '编辑门店分类' },
|
||||
{ value: 'STORE_CATEGORY_DELETE', label: '删除门店分类' },
|
||||
{ value: 'STORE_CATEGORY_ENSURE', label: '初始化默认门店分类' },
|
||||
{ value: 'PRODUCT_CREATE', label: '新增商品' },
|
||||
{ value: 'PRODUCT_UPDATE', label: '编辑商品' },
|
||||
{ value: 'PRODUCT_DELETE', label: '删除商品' },
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
type CategoryNode = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
sort: number;
|
||||
parentId: string | null;
|
||||
status: string;
|
||||
children?: CategoryNode[];
|
||||
};
|
||||
|
||||
type FlatRow = CategoryNode & { level: 1 | 2; parentName?: string };
|
||||
|
||||
function flattenTree(tree: CategoryNode[]): FlatRow[] {
|
||||
const rows: FlatRow[] = [];
|
||||
for (const root of tree) {
|
||||
rows.push({ ...root, level: 1, children: undefined });
|
||||
for (const child of root.children ?? []) {
|
||||
rows.push({
|
||||
...child,
|
||||
level: 2,
|
||||
parentName: root.name,
|
||||
children: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export default function StoreCategoriesPage() {
|
||||
const [tree, setTree] = useState<CategoryNode[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<FlatRow | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const rows = useMemo(() => flattenTree(tree), [tree]);
|
||||
const rootOptions = useMemo(
|
||||
() => tree.filter((n) => n.status === 'ACTIVE').map((n) => ({ value: n.id, label: n.name })),
|
||||
[tree],
|
||||
);
|
||||
|
||||
async function reload() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await request<CategoryNode[]>('/admin/store-categories');
|
||||
setTree(Array.isArray(data) ? data : []);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, []);
|
||||
|
||||
function openCreate(parentId?: string) {
|
||||
setEditing(null);
|
||||
form.setFieldsValue({
|
||||
code: '',
|
||||
name: '',
|
||||
sort: 0,
|
||||
parentId: parentId || undefined,
|
||||
status: 'ACTIVE',
|
||||
});
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(row: FlatRow) {
|
||||
setEditing(row);
|
||||
form.setFieldsValue({
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
sort: row.sort,
|
||||
parentId: row.parentId || undefined,
|
||||
status: row.status,
|
||||
});
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const values = await form.validateFields();
|
||||
const payload = {
|
||||
code: String(values.code).trim().toUpperCase(),
|
||||
name: String(values.name).trim(),
|
||||
sort: Number(values.sort ?? 0),
|
||||
parentId: values.parentId || null,
|
||||
status: values.status || 'ACTIVE',
|
||||
};
|
||||
if (editing) {
|
||||
await request(`/admin/store-categories/${editing.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
message.success('已保存');
|
||||
} else {
|
||||
await request('/admin/store-categories', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
message.success('已创建');
|
||||
}
|
||||
setModalOpen(false);
|
||||
void reload();
|
||||
}
|
||||
|
||||
const columns: ColumnsType<FlatRow> = [
|
||||
{
|
||||
title: '层级',
|
||||
dataIndex: 'level',
|
||||
width: 80,
|
||||
render: (level) => (level === 1 ? <Tag color="blue">一级</Tag> : <Tag>二级</Tag>),
|
||||
},
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
render: (name, row) => (
|
||||
<span style={{ paddingLeft: row.level === 2 ? 24 : 0 }}>
|
||||
{row.level === 2 ? `${row.parentName || ''} / ` : ''}
|
||||
{name}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ title: '编码', dataIndex: 'code', width: 140 },
|
||||
{ title: '排序', dataIndex: 'sort', width: 80 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (s) => (
|
||||
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{s === 'ACTIVE' ? '启用' : '停用'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 220,
|
||||
render: (_, row) => (
|
||||
<Space wrap>
|
||||
<Button type="link" size="small" onClick={() => openEdit(row)}>编辑</Button>
|
||||
{row.level === 1 ? (
|
||||
<Button type="link" size="small" onClick={() => openCreate(row.id)}>加二级</Button>
|
||||
) : null}
|
||||
<Popconfirm
|
||||
title={row.level === 1 ? '删除一级分类?若有门店占用将改为停用' : '删除该分类?若有门店占用将改为停用'}
|
||||
onConfirm={async () => {
|
||||
await request(`/admin/store-categories/${row.id}`, { method: 'DELETE' });
|
||||
message.success('已处理');
|
||||
void reload();
|
||||
}}
|
||||
>
|
||||
<Button type="link" size="small" danger>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>门店分类</Typography.Title>
|
||||
<Typography.Text type="secondary">两级分类:一级(餐饮/住宿/娱乐)→ 二级业态,供合伙人开店选择</Typography.Text>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await request('/admin/store-categories/ensure-defaults', { method: 'POST' });
|
||||
message.success('已同步默认分类');
|
||||
void reload();
|
||||
}}
|
||||
>
|
||||
同步默认分类
|
||||
</Button>
|
||||
<Button type="primary" onClick={() => openCreate()}>新增一级</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
pagination={false}
|
||||
className="admin-table-nowrap"
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? '编辑分类' : '新增分类'}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={() => void submit()}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="code" label="编码" rules={[{ required: true, message: '请填写编码' }]}>
|
||||
<Input placeholder="如 DINING / HOTPOT" disabled={!!editing} />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
|
||||
<Input placeholder="分类名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="parentId" label="上级分类(空=一级)">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="不选则为一级分类"
|
||||
options={rootOptions.filter((o) => o.value !== editing?.id)}
|
||||
disabled={editing?.level === 1 && (tree.find((t) => t.id === editing.id)?.children?.length ?? 0) > 0}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="sort" label="排序" initialValue={0}>
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态" initialValue="ACTIVE">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'ACTIVE', label: '启用' },
|
||||
{ value: 'DISABLED', label: '停用' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,8 +19,15 @@ import {
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { FilePdfOutlined, LinkOutlined } from '@ant-design/icons';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, STORE_AUDIT_STATUS_LABELS, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import {
|
||||
ADMIN_OPTIONS_PAGE_SIZE,
|
||||
RESOURCE_BIZ_TYPE_LABELS,
|
||||
STORE_AUDIT_STATUS_LABELS,
|
||||
STORE_STATUS_LABELS,
|
||||
fmtTime,
|
||||
} from '../lib/constants';
|
||||
import {
|
||||
validateStoreCreateStep1,
|
||||
validateStoreCreateStep3,
|
||||
@@ -37,6 +44,209 @@ const CREATE_STEPS = [
|
||||
{ title: '结算资质' },
|
||||
];
|
||||
|
||||
type StoreMediaItem = {
|
||||
id?: string;
|
||||
bizType?: string;
|
||||
mediaType?: string;
|
||||
url?: string | null;
|
||||
};
|
||||
|
||||
function isImageMedia(url: string, mediaType?: string) {
|
||||
if (mediaType === 'IMAGE') return true;
|
||||
if (mediaType === 'VIDEO' || mediaType === 'FILE') {
|
||||
return /\.(png|jpe?g|gif|webp|bmp|heic)(\?|#|$)/i.test(url);
|
||||
}
|
||||
return /\.(png|jpe?g|gif|webp|bmp|heic)(\?|#|$)/i.test(url);
|
||||
}
|
||||
|
||||
function isPdfUrl(url: string) {
|
||||
return /\.pdf(\?|#|$)/i.test(url);
|
||||
}
|
||||
|
||||
function collectMediaUrls(detail: Record<string, unknown>) {
|
||||
const media = Array.isArray(detail.media) ? (detail.media as StoreMediaItem[]) : [];
|
||||
const byType = (bizType: string) =>
|
||||
media
|
||||
.filter((item) => String(item.bizType || '').toUpperCase() === bizType)
|
||||
.map((item) => ({
|
||||
id: String(item.id || item.url || ''),
|
||||
url: String(item.url || '').trim(),
|
||||
mediaType: item.mediaType ? String(item.mediaType) : undefined,
|
||||
}))
|
||||
.filter((item) => item.url);
|
||||
|
||||
const covers = byType('COVER');
|
||||
const coverUrl = detail.coverUrl ? String(detail.coverUrl).trim() : '';
|
||||
if (coverUrl && !covers.some((item) => item.url === coverUrl)) {
|
||||
covers.unshift({ id: 'cover', url: coverUrl, mediaType: 'IMAGE' });
|
||||
}
|
||||
|
||||
return {
|
||||
covers,
|
||||
envs: byType('ENV'),
|
||||
contracts: byType('CONTRACT'),
|
||||
};
|
||||
}
|
||||
|
||||
function StoreAuditMediaSection({ detail }: { detail: Record<string, unknown> }) {
|
||||
const { covers, envs, contracts } = collectMediaUrls(detail);
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
const gallery = [...covers, ...envs].filter((item) => isImageMedia(item.url, item.mediaType));
|
||||
|
||||
if (covers.length === 0 && envs.length === 0 && contracts.length === 0) {
|
||||
return (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="暂无门头照 / 环境照 / 签约合同,请谨慎审核"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={5} style={{ marginTop: 0, marginBottom: 12 }}>
|
||||
审核材料
|
||||
</Typography.Title>
|
||||
|
||||
{(covers.length > 0 || envs.length > 0) && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
门头照 / 环境照(点击可放大浏览)
|
||||
</Typography.Text>
|
||||
<Image.PreviewGroup>
|
||||
<Space wrap size={12}>
|
||||
{gallery.map((item) => (
|
||||
<div key={item.id} style={{ textAlign: 'center' }}>
|
||||
<Image
|
||||
src={item.url}
|
||||
width={112}
|
||||
height={84}
|
||||
style={{ objectFit: 'cover', borderRadius: 6, border: '1px solid #f0f0f0' }}
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: '#8c8c8c', marginTop: 4 }}>
|
||||
{covers.some((c) => c.id === item.id) ? '门头照' : '环境照'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Image.PreviewGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contracts.length > 0 && (
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
签约合同
|
||||
</Typography.Text>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{contracts.map((item, index) => {
|
||||
const imageLike = isImageMedia(item.url, item.mediaType);
|
||||
const pdf = isPdfUrl(item.url);
|
||||
return (
|
||||
<div
|
||||
key={item.id || `${item.url}-${index}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 8,
|
||||
background: '#fafafa',
|
||||
}}
|
||||
>
|
||||
{imageLike ? (
|
||||
<Image.PreviewGroup>
|
||||
<Image
|
||||
src={item.url}
|
||||
width={96}
|
||||
height={72}
|
||||
style={{ objectFit: 'cover', borderRadius: 6 }}
|
||||
/>
|
||||
</Image.PreviewGroup>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 96,
|
||||
height: 72,
|
||||
borderRadius: 6,
|
||||
background: '#fff',
|
||||
border: '1px dashed #d9d9d9',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#cf1322',
|
||||
}}
|
||||
>
|
||||
<FilePdfOutlined style={{ fontSize: 28 }} />
|
||||
</div>
|
||||
)}
|
||||
<Space direction="vertical" size={4} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography.Text strong>
|
||||
{RESOURCE_BIZ_TYPE_LABELS.CONTRACT || '合同'}
|
||||
{contracts.length > 1 ? ` ${index + 1}` : ''}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" ellipsis style={{ maxWidth: '100%' }}>
|
||||
{item.url}
|
||||
</Typography.Text>
|
||||
<Space wrap>
|
||||
{imageLike ? (
|
||||
<Typography.Text type="secondary">点击缩略图放大查看</Typography.Text>
|
||||
) : null}
|
||||
{pdf ? (
|
||||
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => setPdfUrl(item.url)}>
|
||||
页内预览 PDF
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<LinkOutlined />}
|
||||
style={{ padding: 0 }}
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
新窗口打开
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title="合同预览"
|
||||
open={!!pdfUrl}
|
||||
onCancel={() => setPdfUrl(null)}
|
||||
width={900}
|
||||
footer={[
|
||||
<Button key="open" href={pdfUrl || undefined} target="_blank" rel="noreferrer">
|
||||
新窗口打开
|
||||
</Button>,
|
||||
<Button key="close" type="primary" onClick={() => setPdfUrl(null)}>
|
||||
关闭
|
||||
</Button>,
|
||||
]}
|
||||
destroyOnClose
|
||||
>
|
||||
{pdfUrl ? (
|
||||
<iframe
|
||||
title="合同 PDF 预览"
|
||||
src={pdfUrl}
|
||||
style={{ width: '100%', height: '70vh', border: 'none', borderRadius: 8 }}
|
||||
/>
|
||||
) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type StoreRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -307,7 +517,7 @@ export default function StoresPage() {
|
||||
</Form>
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1200 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="门店详情" width={600} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
<Drawer title="门店详情" width={760} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (
|
||||
<Space wrap>
|
||||
{String(detail.auditStatus || '') === 'PENDING' || String(detail.auditStatus || '') === 'REJECTED' ? (
|
||||
@@ -367,6 +577,7 @@ export default function StoresPage() {
|
||||
)}>
|
||||
{detail && (
|
||||
<>
|
||||
<StoreAuditMediaSection detail={detail} />
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
||||
<Descriptions.Item label="审核状态">
|
||||
@@ -390,11 +601,6 @@ export default function StoresPage() {
|
||||
查看商户日志
|
||||
</Button>
|
||||
</Descriptions.Item>
|
||||
{detail.coverUrl ? (
|
||||
<Descriptions.Item label="封面">
|
||||
<Image src={String(detail.coverUrl)} width={120} />
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
{Array.isArray(detail.audits) && (detail.audits as Array<Record<string, unknown>>).length > 0 ? (
|
||||
<Descriptions.Item label="审核记录">
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
|
||||
@@ -8,6 +8,10 @@ export type StoreDraftForm = {
|
||||
phone: string;
|
||||
storeSmsCode: string;
|
||||
address: string;
|
||||
openTime: string;
|
||||
closeTime: string;
|
||||
categoryParentId: string;
|
||||
categoryId: string;
|
||||
intro: string;
|
||||
coverUrl: string;
|
||||
envPhotoUrls: string[];
|
||||
@@ -37,6 +41,10 @@ export const defaultStoreForm = (): StoreDraftForm => ({
|
||||
phone: '',
|
||||
storeSmsCode: '',
|
||||
address: '',
|
||||
openTime: '10:00',
|
||||
closeTime: '22:00',
|
||||
categoryParentId: '',
|
||||
categoryId: '',
|
||||
intro: '',
|
||||
coverUrl: '',
|
||||
envPhotoUrls: ['', '', ''],
|
||||
@@ -68,6 +76,10 @@ function normalizeForm(raw: Record<string, unknown>): StoreDraftForm {
|
||||
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),
|
||||
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, 3),
|
||||
@@ -119,21 +131,38 @@ export function clearAllStoreDrafts(accountId?: string) {
|
||||
|
||||
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,
|
||||
'regionCodes' | 'cityId' | 'name' | 'phone' | 'storeSmsCode' | 'address' | 'intro'
|
||||
| 'regionCodes'
|
||||
| 'cityId'
|
||||
| 'name'
|
||||
| 'address'
|
||||
| 'openTime'
|
||||
| 'closeTime'
|
||||
| 'categoryId'
|
||||
| 'intro'
|
||||
>,
|
||||
): string | null {
|
||||
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
|
||||
if (!form.cityId) return '所选地区未匹配到开城城市,请联系总部配置开城区划';
|
||||
if (!form.name.trim()) return '请填写门店名称';
|
||||
if (!form.phone.trim()) return '请填写联系电话';
|
||||
if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号';
|
||||
if (!form.storeSmsCode.trim()) return '请输入门店手机号验证码';
|
||||
if (!/^\d{4,6}$/.test(form.storeSmsCode.trim())) return '验证码格式不正确';
|
||||
if (!form.address.trim()) return '请填写详细地址';
|
||||
if (!form.openTime.trim()) return '请填写营业开始时间';
|
||||
if (!TIME_RE.test(form.openTime.trim())) return '营业开始时间格式须为 HH:MM';
|
||||
if (!form.closeTime.trim()) return '请填写营业结束时间';
|
||||
if (!TIME_RE.test(form.closeTime.trim())) return '营业结束时间格式须为 HH:MM';
|
||||
if (timeToMinutes(form.openTime.trim()) >= timeToMinutes(form.closeTime.trim())) {
|
||||
return '营业结束时间须晚于开始时间';
|
||||
}
|
||||
if (!form.categoryId.trim()) return '请选择店铺类型';
|
||||
if (form.intro.trim()) {
|
||||
const len = form.intro.trim().length;
|
||||
if (len < 10 || len > 500) return '门店简介须为 10~500 字';
|
||||
@@ -158,11 +187,18 @@ export function patchEnvPhotoAt(urls: string[], index: number, url: string): str
|
||||
}
|
||||
|
||||
export function validateStoreStep3(
|
||||
form: Pick<StoreDraftForm, 'bankAccountName' | 'bankAccountNo' | 'bankBranch'>,
|
||||
form: Pick<
|
||||
StoreDraftForm,
|
||||
'bankAccountName' | 'bankAccountNo' | 'bankBranch' | 'phone' | 'storeSmsCode'
|
||||
>,
|
||||
): string | null {
|
||||
if (!form.bankAccountName.trim()) return '请填写户主姓名';
|
||||
if (!form.bankAccountNo.trim()) return '请填写银行卡号';
|
||||
if (!BANK_RE.test(form.bankAccountNo.replace(/\s/g, ''))) return '银行卡号须为 16~19 位数字';
|
||||
if (!form.bankBranch.trim()) return '请填写开户支行';
|
||||
if (!form.phone.trim()) return '请填写联系电话';
|
||||
if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号';
|
||||
if (!form.storeSmsCode.trim()) return '请输入门店手机号验证码';
|
||||
if (!/^\d{4,6}$/.test(form.storeSmsCode.trim())) return '验证码格式不正确';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import type { PartnerBillDto } from '@dukang/shared-types';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
import { toastError, toastSuccess } from '../lib/toast';
|
||||
@@ -81,7 +82,7 @@ export default function BillsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="partner-bills-page">
|
||||
<PullToRefresh onRefresh={loadBills} className="partner-bills-page">
|
||||
<PageHeader title="账单确认" onBack={() => navigate('/center/settlement')} />
|
||||
|
||||
<div className="partner-bill-stepper">
|
||||
@@ -232,6 +233,6 @@ export default function BillsPage() {
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { PartnerBillDto } from '@dukang/shared-types';
|
||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { request } from '../lib/api';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import { contactSupport } from '../lib/contact';
|
||||
@@ -36,15 +37,24 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
|
||||
document.title = isPrimary ? '合伙人中心' : '个人中心';
|
||||
}, [isPrimary]);
|
||||
|
||||
const loadCenter = useCallback(() => {
|
||||
const tasks: Promise<unknown>[] = [Promise.resolve(refresh())];
|
||||
if (isPrimary) {
|
||||
tasks.push(
|
||||
request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills')
|
||||
.then(setBills)
|
||||
.catch(() => setBills([])),
|
||||
listPartnerStaff()
|
||||
.then((list) => setStaffCount(list.length))
|
||||
.catch(() => setStaffCount(0)),
|
||||
);
|
||||
}
|
||||
return Promise.all(tasks).then(() => undefined);
|
||||
}, [isPrimary, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPrimary) return;
|
||||
void request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills')
|
||||
.then(setBills)
|
||||
.catch(() => setBills([]));
|
||||
void listPartnerStaff()
|
||||
.then((list) => setStaffCount(list.length))
|
||||
.catch(() => setStaffCount(0));
|
||||
}, [isPrimary]);
|
||||
void loadCenter();
|
||||
}, [loadCenter]);
|
||||
|
||||
const finance = useMemo(() => {
|
||||
const pending = bills.filter((b) => b.status === 'DRAFT' || b.status === 'REJECTED');
|
||||
@@ -109,7 +119,7 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
|
||||
const badgeLabel = roleLabel || (isPrimary ? '城市合伙人' : '拓店员');
|
||||
|
||||
return (
|
||||
<div className="page partner-center-page partner-home--flush-top">
|
||||
<PullToRefresh onRefresh={loadCenter} className="page partner-center-page partner-home--flush-top">
|
||||
<section className="partner-profile-card">
|
||||
<button
|
||||
type="button"
|
||||
@@ -286,6 +296,6 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
|
||||
<div className="partner-center-footer">
|
||||
<p className="label-md text-muted">传承千年 · 杜康好客</p>
|
||||
</div>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import type { PartnerLeaderboardEntry } from '@dukang/shared-types';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
import { fetchPartnerLeaderboard } from '../lib/leaderboard';
|
||||
import {
|
||||
@@ -155,45 +156,62 @@ export default function HomePage() {
|
||||
document.title = '工作台';
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||
// 等 session 带上账号后再按权限发请求,避免无权限接口弹错
|
||||
if (!account) return;
|
||||
const loadHome = useCallback(() => {
|
||||
if (!isLoggedIn()) {
|
||||
navigate('/login');
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (!account) return Promise.resolve();
|
||||
|
||||
const tasks: Promise<unknown>[] = [];
|
||||
|
||||
if (canOrders) {
|
||||
request<{ list: Array<Record<string, unknown>> }>('PARTNER_H5', '/partner/orders', { silent: true })
|
||||
.then((data) => setOrders(Array.isArray(data.list) ? data.list : []))
|
||||
.catch(() => setOrders([]));
|
||||
tasks.push(
|
||||
request<{ list: Array<Record<string, unknown>> }>('PARTNER_H5', '/partner/orders', { silent: true })
|
||||
.then((data) => setOrders(Array.isArray(data.list) ? data.list : []))
|
||||
.catch(() => setOrders([])),
|
||||
);
|
||||
} else {
|
||||
setOrders([]);
|
||||
}
|
||||
|
||||
if (canDashboard) {
|
||||
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard', { silent: true })
|
||||
.then(setDash)
|
||||
.catch(() => setDash(null));
|
||||
tasks.push(
|
||||
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard', { silent: true })
|
||||
.then(setDash)
|
||||
.catch(() => setDash(null)),
|
||||
);
|
||||
} else {
|
||||
setDash(null);
|
||||
}
|
||||
|
||||
if (canStores) {
|
||||
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores', { silent: true })
|
||||
.then((data) => setStores(Array.isArray(data) ? data : []))
|
||||
.catch(() => setStores([]));
|
||||
tasks.push(
|
||||
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores', { silent: true })
|
||||
.then((data) => setStores(Array.isArray(data) ? data : []))
|
||||
.catch(() => setStores([])),
|
||||
);
|
||||
} else {
|
||||
setStores([]);
|
||||
}
|
||||
|
||||
// 主账号与全部子账号均可查看同团队贡献榜(后端不校验业务权限点)
|
||||
fetchPartnerLeaderboard('month')
|
||||
.then((data) => {
|
||||
setLeaderboardEntries(data.list.slice(0, LEADERBOARD_PREVIEW_LIMIT));
|
||||
})
|
||||
.catch(() => {
|
||||
setLeaderboardEntries([]);
|
||||
});
|
||||
tasks.push(
|
||||
fetchPartnerLeaderboard('month')
|
||||
.then((data) => {
|
||||
setLeaderboardEntries(data.list.slice(0, LEADERBOARD_PREVIEW_LIMIT));
|
||||
})
|
||||
.catch(() => {
|
||||
setLeaderboardEntries([]);
|
||||
}),
|
||||
);
|
||||
|
||||
return Promise.all(tasks).then(() => undefined);
|
||||
}, [navigate, account, canOrders, canDashboard, canStores]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadHome();
|
||||
}, [loadHome]);
|
||||
|
||||
const storeCount = Number(dash?.storeCount || stores.length || 0);
|
||||
const orderStats = useMemo(() => summarizeOrders(orders), [orders]);
|
||||
const pendingAuditCount = Number(dash?.pendingAuditCount || 0);
|
||||
@@ -219,7 +237,7 @@ export default function HomePage() {
|
||||
const profit = revenue * 0.25;
|
||||
|
||||
return (
|
||||
<div className="page partner-home partner-home--flush-top">
|
||||
<PullToRefresh onRefresh={loadHome} className="page partner-home partner-home--flush-top">
|
||||
<main className="partner-home-body">
|
||||
{isPrimary && (
|
||||
<>
|
||||
@@ -346,6 +364,6 @@ export default function HomePage() {
|
||||
|
||||
<LeaderboardPreview entries={leaderboardEntries} />
|
||||
</main>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import type { PartnerLeaderboardPeriod, PartnerLeaderboardResponse } from '@dukang/shared-types';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { fetchPartnerLeaderboard } from '../lib/leaderboard';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import { isPrimaryAccount } from '../lib/partnerAccess';
|
||||
@@ -33,10 +34,10 @@ export default function LeaderboardPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const loadLeaderboard = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
fetchPartnerLeaderboard(period)
|
||||
return fetchPartnerLeaderboard(period)
|
||||
.then(setData)
|
||||
.catch((e) => {
|
||||
setData(null);
|
||||
@@ -45,8 +46,12 @@ export default function LeaderboardPage() {
|
||||
.finally(() => setLoading(false));
|
||||
}, [period]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadLeaderboard();
|
||||
}, [loadLeaderboard]);
|
||||
|
||||
return (
|
||||
<div className="page-no-tab">
|
||||
<PullToRefresh onRefresh={loadLeaderboard} className="page-no-tab">
|
||||
<PageHeader title="团队贡献榜" onBack={() => navigate('/')} />
|
||||
|
||||
<div className="partner-leaderboard-tabs">
|
||||
@@ -124,6 +129,6 @@ export default function LeaderboardPage() {
|
||||
<span className="material-symbols-outlined">add</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import { hasWarehouseAccess } from '../lib/partnerAccess';
|
||||
@@ -59,14 +60,17 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
|
||||
document.title = '订单管理';
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||
const loadOrders = useCallback(() => {
|
||||
if (!isLoggedIn()) {
|
||||
navigate('/login');
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (!warehouseOk) {
|
||||
setData({ list: [], hasWarehouseAccess: false, message: '未配置仓库管理权限' });
|
||||
setLoaded(true);
|
||||
return;
|
||||
return Promise.resolve();
|
||||
}
|
||||
request<OrdersResponse>('PARTNER_H5', '/partner/orders')
|
||||
return request<OrdersResponse>('PARTNER_H5', '/partner/orders')
|
||||
.then((res) => {
|
||||
setData({
|
||||
list: Array.isArray(res.list) ? res.list : [],
|
||||
@@ -78,6 +82,10 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
|
||||
.finally(() => setLoaded(true));
|
||||
}, [navigate, warehouseOk]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadOrders();
|
||||
}, [loadOrders]);
|
||||
|
||||
const filtered = useMemo(() => data.list.filter((o) => {
|
||||
if (statusFilter === 'ALL') return true;
|
||||
const s = String(o.status).toUpperCase();
|
||||
@@ -142,7 +150,7 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`page partner-orders-page${tabRoot ? '' : ' page-no-tab'}`}>
|
||||
<PullToRefresh onRefresh={loadOrders} className={`page partner-orders-page${tabRoot ? '' : ' page-no-tab'}`}>
|
||||
{!tabRoot && <PageHeader title="订单中心" onBack={() => navigate('/')} />}
|
||||
|
||||
<div className="partner-segment">
|
||||
@@ -265,6 +273,6 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import type { PartnerBillDto } from '@dukang/shared-types';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
|
||||
@@ -47,10 +48,14 @@ export default function SettlementPage() {
|
||||
const [month, setMonth] = useState({ year: now.getFullYear(), month: now.getMonth() + 1 });
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||
|
||||
const loadBills = useCallback(() => {
|
||||
return request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills').then(setBills);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||
request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills').then(setBills);
|
||||
}, [navigate]);
|
||||
void loadBills();
|
||||
}, [navigate, loadBills]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const pending = bills.filter((b) => b.status === 'DRAFT');
|
||||
@@ -102,7 +107,7 @@ export default function SettlementPage() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="page-no-tab partner-settlement-page">
|
||||
<PullToRefresh onRefresh={loadBills} className="page-no-tab partner-settlement-page">
|
||||
<PageHeader title="财务对账中心" onBack={() => navigate('/')} />
|
||||
|
||||
<main className="partner-settlement-body">
|
||||
@@ -211,6 +216,6 @@ export default function SettlementPage() {
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import {
|
||||
AccountStatus,
|
||||
PARTNER_STAFF_ROLE_LABELS,
|
||||
@@ -86,7 +87,7 @@ export default function StaffListPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-no-tab partner-staff-page">
|
||||
<PullToRefresh onRefresh={loadStaff} className="page-no-tab partner-staff-page">
|
||||
<PageHeader title="子账号管理" onBack={() => navigate('/center')} />
|
||||
|
||||
<div style={{ padding: '0 20px 16px' }}>
|
||||
@@ -151,6 +152,6 @@ export default function StaffListPage() {
|
||||
添加子账号
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,14 +45,15 @@ import {
|
||||
|
||||
const STEPS = ['基本信息', '照片上传', '结算资质'] as const;
|
||||
|
||||
|
||||
type StoreCategoryNode = {
|
||||
id: string;
|
||||
name: string;
|
||||
children?: StoreCategoryNode[];
|
||||
};
|
||||
|
||||
type FieldErrors = {
|
||||
|
||||
phone?: string;
|
||||
|
||||
storeSmsCode?: string;
|
||||
|
||||
};
|
||||
|
||||
|
||||
@@ -93,8 +94,6 @@ export default function StoreCreatePage() {
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const [checkingPhone, setCheckingPhone] = useState(false);
|
||||
|
||||
const [cities, setCities] = useState<OpenCityOption[]>([]);
|
||||
|
||||
const [citiesError, setCitiesError] = useState('');
|
||||
@@ -103,6 +102,8 @@ export default function StoreCreatePage() {
|
||||
|
||||
const [smsHint, setSmsHint] = useState('');
|
||||
|
||||
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
|
||||
|
||||
const draftSaveDisabledRef = useRef(false);
|
||||
|
||||
function reportFormError(message: string) {
|
||||
@@ -153,6 +154,26 @@ export default function StoreCreatePage() {
|
||||
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void request<StoreCategoryNode[]>('PARTNER_H5', '/partner/store-categories')
|
||||
.then((list) => {
|
||||
const tree = Array.isArray(list) ? list : [];
|
||||
setCategoryTree(tree);
|
||||
if (form.categoryId && !form.categoryParentId) {
|
||||
const parent = tree.find((root) =>
|
||||
(root.children ?? []).some((child) => child.id === form.categoryId),
|
||||
);
|
||||
if (parent) patchForm({ categoryParentId: parent.id });
|
||||
}
|
||||
})
|
||||
.catch(() => setCategoryTree([]));
|
||||
}, []);
|
||||
|
||||
const categoryChildren = useMemo(() => {
|
||||
const parent = categoryTree.find((item) => item.id === form.categoryParentId);
|
||||
return Array.isArray(parent?.children) ? parent!.children! : [];
|
||||
}, [categoryTree, form.categoryParentId]);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
@@ -354,54 +375,10 @@ export default function StoreCreatePage() {
|
||||
const msg = validateStoreStep1(form);
|
||||
|
||||
if (msg) {
|
||||
if (isPhoneValidationMessage(msg)) {
|
||||
if (msg.includes('验证码')) {
|
||||
setFieldErrors({ storeSmsCode: msg });
|
||||
} else {
|
||||
setFieldErrors({ phone: msg });
|
||||
}
|
||||
} else {
|
||||
reportFormError(msg);
|
||||
}
|
||||
reportFormError(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
setCheckingPhone(true);
|
||||
|
||||
setSubmitError('');
|
||||
|
||||
setFieldErrors({});
|
||||
|
||||
try {
|
||||
|
||||
const phoneCheck = await checkStorePhoneAvailable(form.phone.trim());
|
||||
|
||||
if (!phoneCheck.available) {
|
||||
const phoneMsg = phoneCheck.message ?? '该手机号已绑定门店,请更换';
|
||||
setFieldErrors({ phone: phoneMsg });
|
||||
return;
|
||||
}
|
||||
if (phoneCheck.needConfirm) {
|
||||
const ok = window.confirm(
|
||||
phoneCheck.message ??
|
||||
`该手机号已是门店主账号(已绑 ${phoneCheck.existingStoreCount ?? 0} 家店),确认后将追加绑定新店。是否继续?`,
|
||||
);
|
||||
if (!ok) {
|
||||
setFieldErrors({ phone: '已取消绑定已有主账号,请更换手机号或确认后继续' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
reportFormError(e instanceof Error ? e.message : '手机号校验失败');
|
||||
return;
|
||||
|
||||
} finally {
|
||||
|
||||
setCheckingPhone(false);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (step === 2) {
|
||||
@@ -426,6 +403,14 @@ export default function StoreCreatePage() {
|
||||
const msg = validateStoreStep3(form);
|
||||
|
||||
if (msg) {
|
||||
if (isPhoneValidationMessage(msg)) {
|
||||
if (msg.includes('验证码')) {
|
||||
setFieldErrors({ storeSmsCode: msg });
|
||||
} else {
|
||||
setFieldErrors({ phone: msg });
|
||||
}
|
||||
return;
|
||||
}
|
||||
reportFormError(msg);
|
||||
return;
|
||||
}
|
||||
@@ -433,15 +418,6 @@ export default function StoreCreatePage() {
|
||||
const step1Msg = validateStoreStep1(form);
|
||||
|
||||
if (step1Msg) {
|
||||
if (isPhoneValidationMessage(step1Msg)) {
|
||||
if (step1Msg.includes('验证码')) {
|
||||
setFieldErrors({ storeSmsCode: step1Msg });
|
||||
} else {
|
||||
setFieldErrors({ phone: step1Msg });
|
||||
}
|
||||
goStep(1);
|
||||
return;
|
||||
}
|
||||
reportFormError(step1Msg);
|
||||
goStep(1);
|
||||
return;
|
||||
@@ -481,6 +457,8 @@ export default function StoreCreatePage() {
|
||||
|
||||
});
|
||||
|
||||
setSubmitting(false);
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
@@ -492,6 +470,7 @@ export default function StoreCreatePage() {
|
||||
);
|
||||
if (!ok) {
|
||||
setFieldErrors({ phone: '已取消绑定已有主账号,请更换手机号或确认后继续' });
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
confirmBindExisting = true;
|
||||
@@ -499,6 +478,7 @@ export default function StoreCreatePage() {
|
||||
|
||||
} catch (e) {
|
||||
reportFormError(e instanceof Error ? e.message : '手机号校验失败');
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -530,6 +510,12 @@ export default function StoreCreatePage() {
|
||||
|
||||
address: form.address.trim(),
|
||||
|
||||
openTime: form.openTime.trim(),
|
||||
|
||||
closeTime: form.closeTime.trim(),
|
||||
|
||||
categoryId: form.categoryId.trim(),
|
||||
|
||||
intro: form.intro.trim() || undefined,
|
||||
|
||||
coverUrl: form.coverUrl.trim() || undefined,
|
||||
@@ -567,7 +553,7 @@ export default function StoreCreatePage() {
|
||||
}
|
||||
if (/验证码/.test(message)) {
|
||||
setFieldErrors({ storeSmsCode: message });
|
||||
goStep(1);
|
||||
goStep(3);
|
||||
return;
|
||||
}
|
||||
setSubmitError(message);
|
||||
@@ -584,7 +570,7 @@ export default function StoreCreatePage() {
|
||||
|
||||
const progress = step === 1 ? 0 : step === 2 ? 50 : 100;
|
||||
|
||||
const nextDisabled = submitting || checkingPhone;
|
||||
const nextDisabled = submitting;
|
||||
|
||||
|
||||
|
||||
@@ -694,100 +680,58 @@ export default function StoreCreatePage() {
|
||||
|
||||
<div className="partner-field">
|
||||
|
||||
<label>联系电话(门店登录账号) <span className="text-primary">*</span></label>
|
||||
<label>店铺类型 <span className="text-primary">*</span></label>
|
||||
|
||||
<div className="partner-field-input">
|
||||
<div className="partner-input-row" style={{ gap: 8 }}>
|
||||
|
||||
<span className="material-symbols-outlined">call</span>
|
||||
<select
|
||||
|
||||
<input
|
||||
className="partner-field-input partner-field-input--block"
|
||||
|
||||
type="tel"
|
||||
value={form.categoryParentId}
|
||||
|
||||
placeholder="请输入11位手机号"
|
||||
onChange={(e) => patchForm({ categoryParentId: e.target.value, categoryId: '' })}
|
||||
|
||||
value={form.phone}
|
||||
|
||||
onChange={(e) => patchForm({ phone: e.target.value })}
|
||||
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
{fieldErrors.phone && (
|
||||
|
||||
<p className="partner-field-error" role="alert">{fieldErrors.phone}</p>
|
||||
|
||||
)}
|
||||
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||
|
||||
验证码将发送至该手机号,需门店负责人确认后方可录入
|
||||
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
|
||||
<label>门店账号验证码 <span className="text-primary">*</span></label>
|
||||
|
||||
<div className="partner-input-row">
|
||||
|
||||
<div className="partner-input-wrap" style={{ flex: 1 }}>
|
||||
|
||||
<span className="material-symbols-outlined partner-input-icon">shield</span>
|
||||
|
||||
<input
|
||||
|
||||
className="partner-input"
|
||||
|
||||
type="text"
|
||||
|
||||
inputMode="numeric"
|
||||
|
||||
maxLength={6}
|
||||
|
||||
placeholder="请输入短信验证码"
|
||||
|
||||
value={form.storeSmsCode}
|
||||
|
||||
onChange={(e) => patchForm({ storeSmsCode: e.target.value.replace(/\D/g, '') })}
|
||||
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
||||
type="button"
|
||||
|
||||
className="partner-code-btn"
|
||||
|
||||
disabled={smsCooldown > 0 || checkingPhone}
|
||||
|
||||
onClick={() => void sendStorePhoneCode()}
|
||||
aria-label="一级店铺类型"
|
||||
|
||||
>
|
||||
|
||||
{smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'}
|
||||
<option value="">选择大类</option>
|
||||
|
||||
</button>
|
||||
{categoryTree.map((item) => (
|
||||
|
||||
<option key={item.id} value={item.id}>{item.name}</option>
|
||||
|
||||
))}
|
||||
|
||||
</select>
|
||||
|
||||
<select
|
||||
|
||||
className="partner-field-input partner-field-input--block"
|
||||
|
||||
value={form.categoryId}
|
||||
|
||||
onChange={(e) => patchForm({ categoryId: e.target.value })}
|
||||
|
||||
disabled={!form.categoryParentId}
|
||||
|
||||
aria-label="二级店铺类型"
|
||||
|
||||
>
|
||||
|
||||
<option value="">{form.categoryParentId ? '选择细类' : '请先选大类'}</option>
|
||||
|
||||
{categoryChildren.map((item) => (
|
||||
|
||||
<option key={item.id} value={item.id}>{item.name}</option>
|
||||
|
||||
))}
|
||||
|
||||
</select>
|
||||
|
||||
</div>
|
||||
|
||||
{smsHint && (
|
||||
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>{smsHint}</p>
|
||||
|
||||
)}
|
||||
|
||||
{fieldErrors.storeSmsCode && (
|
||||
|
||||
<p className="partner-field-error" role="alert">{fieldErrors.storeSmsCode}</p>
|
||||
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
@@ -798,6 +742,52 @@ export default function StoreCreatePage() {
|
||||
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
|
||||
<label>营业时间 <span className="text-primary">*</span></label>
|
||||
|
||||
<div className="partner-input-row" style={{ alignItems: 'center', gap: 8 }}>
|
||||
|
||||
<input
|
||||
|
||||
className="partner-field-input partner-field-input--block"
|
||||
|
||||
type="time"
|
||||
|
||||
value={form.openTime}
|
||||
|
||||
onChange={(e) => patchForm({ openTime: 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.closeTime}
|
||||
|
||||
onChange={(e) => patchForm({ closeTime: e.target.value })}
|
||||
|
||||
aria-label="营业结束时间"
|
||||
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||
|
||||
将展示给用户端与门店端,默认 10:00–22:00,可按实际调整。
|
||||
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
|
||||
<label>门店简介</label>
|
||||
@@ -976,46 +966,6 @@ export default function StoreCreatePage() {
|
||||
|
||||
</div>
|
||||
|
||||
<section className="partner-form-card">
|
||||
|
||||
<div className="partner-field">
|
||||
|
||||
<label>门店登录手机号 <span className="text-primary">*</span></label>
|
||||
|
||||
<div className="partner-field-input">
|
||||
|
||||
<span className="material-symbols-outlined">call</span>
|
||||
|
||||
<input
|
||||
|
||||
type="tel"
|
||||
|
||||
placeholder="请输入11位手机号"
|
||||
|
||||
value={form.phone}
|
||||
|
||||
onChange={(e) => patchForm({ phone: e.target.value })}
|
||||
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
{fieldErrors.phone && (
|
||||
|
||||
<p className="partner-field-error" role="alert">{fieldErrors.phone}</p>
|
||||
|
||||
)}
|
||||
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||
|
||||
该手机号将作为门店端登录账号,提交前会再次校验是否已被占用。
|
||||
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<section className="partner-form-card">
|
||||
|
||||
<div className="partner-field">
|
||||
@@ -1056,6 +1006,108 @@ export default function StoreCreatePage() {
|
||||
|
||||
</div>
|
||||
|
||||
<section className="partner-form-card">
|
||||
|
||||
<div className="partner-field">
|
||||
|
||||
<label>门店登录手机号 <span className="text-primary">*</span></label>
|
||||
|
||||
<div className="partner-field-input">
|
||||
|
||||
<span className="material-symbols-outlined">call</span>
|
||||
|
||||
<input
|
||||
|
||||
type="tel"
|
||||
|
||||
placeholder="请输入11位手机号"
|
||||
|
||||
value={form.phone}
|
||||
|
||||
onChange={(e) => patchForm({ phone: e.target.value })}
|
||||
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
{fieldErrors.phone && (
|
||||
|
||||
<p className="partner-field-error" role="alert">{fieldErrors.phone}</p>
|
||||
|
||||
)}
|
||||
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||
|
||||
该手机号将作为门店端登录账号,验证码发送至该号确认后方可提交。
|
||||
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
|
||||
<label>门店账号验证码 <span className="text-primary">*</span></label>
|
||||
|
||||
<div className="partner-input-row">
|
||||
|
||||
<div className="partner-input-wrap" style={{ flex: 1 }}>
|
||||
|
||||
<span className="material-symbols-outlined partner-input-icon">shield</span>
|
||||
|
||||
<input
|
||||
|
||||
className="partner-input"
|
||||
|
||||
type="text"
|
||||
|
||||
inputMode="numeric"
|
||||
|
||||
maxLength={6}
|
||||
|
||||
placeholder="请输入短信验证码"
|
||||
|
||||
value={form.storeSmsCode}
|
||||
|
||||
onChange={(e) => patchForm({ storeSmsCode: e.target.value.replace(/\D/g, '') })}
|
||||
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
||||
type="button"
|
||||
|
||||
className="partner-code-btn"
|
||||
|
||||
disabled={smsCooldown > 0 || submitting}
|
||||
|
||||
onClick={() => void sendStorePhoneCode()}
|
||||
|
||||
>
|
||||
|
||||
{smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'}
|
||||
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
{smsHint && (
|
||||
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>{smsHint}</p>
|
||||
|
||||
)}
|
||||
|
||||
{fieldErrors.storeSmsCode && (
|
||||
|
||||
<p className="partner-field-error" role="alert">{fieldErrors.storeSmsCode}</p>
|
||||
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
</>
|
||||
|
||||
)}
|
||||
@@ -1074,7 +1126,7 @@ export default function StoreCreatePage() {
|
||||
|
||||
<button type="button" className="partner-btn-primary" onClick={() => void handleNext()} disabled={nextDisabled}>
|
||||
|
||||
<span>{checkingPhone ? '校验中…' : '下一步'}</span>
|
||||
<span>下一步</span>
|
||||
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>navigate_next</span>
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ export default function StoreDetailPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [mediaSaving, setMediaSaving] = useState(false);
|
||||
const [actionError, setActionError] = useState('');
|
||||
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false);
|
||||
|
||||
function applyStore(data: Record<string, unknown>) {
|
||||
setStore(data);
|
||||
@@ -92,8 +93,8 @@ export default function StoreDetailPage() {
|
||||
return;
|
||||
}
|
||||
if (next === 'CLOSED') {
|
||||
const ok = window.confirm('关闭后不可恢复营业,确认关闭该门店?');
|
||||
if (!ok) return;
|
||||
setCloseConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
setStatusSaving(true);
|
||||
setActionError('');
|
||||
@@ -104,7 +105,27 @@ export default function StoreDetailPage() {
|
||||
});
|
||||
setStatus(next);
|
||||
setStore((prev) => (prev ? { ...prev, ...updated, status: next } : prev));
|
||||
toastSuccess('状态已更新');
|
||||
toastSuccess(next === 'OPEN' ? '开店成功' : '状态已更新');
|
||||
} catch (e) {
|
||||
setActionError(e instanceof Error ? e.message : '状态更新失败');
|
||||
} finally {
|
||||
setStatusSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmCloseStore() {
|
||||
if (!id || statusSaving) return;
|
||||
setCloseConfirmOpen(false);
|
||||
setStatusSaving(true);
|
||||
setActionError('');
|
||||
try {
|
||||
const updated = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/status`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status: 'CLOSED' }),
|
||||
});
|
||||
setStatus('CLOSED');
|
||||
setStore((prev) => (prev ? { ...prev, ...updated, status: 'CLOSED' } : prev));
|
||||
toastSuccess('门店已关闭');
|
||||
} catch (e) {
|
||||
setActionError(e instanceof Error ? e.message : '状态更新失败');
|
||||
} finally {
|
||||
@@ -385,6 +406,25 @@ export default function StoreDetailPage() {
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
|
||||
{closeConfirmOpen && (
|
||||
<div className="partner-ship-modal-backdrop" role="presentation" onClick={() => setCloseConfirmOpen(false)}>
|
||||
<div className="partner-ship-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="headline-md" style={{ marginBottom: 8 }}>确认关闭门店?</h3>
|
||||
<p className="body-md text-muted" style={{ lineHeight: 1.5 }}>
|
||||
关闭后不可恢复营业,确认关闭该门店?
|
||||
</p>
|
||||
<div className="partner-ship-actions">
|
||||
<button type="button" className="partner-btn-secondary" onClick={() => setCloseConfirmOpen(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="partner-btn-primary" onClick={() => void confirmCloseStore()}>
|
||||
确认关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
import { toastSuccess } from '../lib/toast';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import { canCreatePartnerStore, canManagePartnerStore } from '../lib/partnerAccess';
|
||||
import {
|
||||
@@ -35,6 +37,7 @@ export default function StoreListPage() {
|
||||
const [filter, setFilter] = useState<StatusFilter>(initialFilter);
|
||||
const [updatingId, setUpdatingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [closeTarget, setCloseTarget] = useState<string | null>(null);
|
||||
|
||||
const loadStores = useCallback(() => {
|
||||
return request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores);
|
||||
@@ -66,8 +69,8 @@ export default function StoreListPage() {
|
||||
return;
|
||||
}
|
||||
if (next === 'CLOSED') {
|
||||
const ok = window.confirm('关闭后不可恢复营业,确认关闭该门店?');
|
||||
if (!ok) return;
|
||||
setCloseTarget(storeId);
|
||||
return;
|
||||
}
|
||||
setUpdatingId(storeId);
|
||||
setError('');
|
||||
@@ -77,6 +80,26 @@ export default function StoreListPage() {
|
||||
body: JSON.stringify({ status: next }),
|
||||
});
|
||||
await loadStores();
|
||||
if (next === 'OPEN') toastSuccess('开店成功');
|
||||
} catch {
|
||||
/* request 已 toast */
|
||||
} finally {
|
||||
setUpdatingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmCloseStore() {
|
||||
if (!closeTarget) return;
|
||||
const storeId = closeTarget;
|
||||
setCloseTarget(null);
|
||||
setUpdatingId(storeId);
|
||||
setError('');
|
||||
try {
|
||||
await request('PARTNER_H5', `/partner/stores/${storeId}/status`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status: 'CLOSED' }),
|
||||
});
|
||||
await loadStores();
|
||||
} catch {
|
||||
/* request 已 toast */
|
||||
} finally {
|
||||
@@ -85,7 +108,7 @@ export default function StoreListPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page partner-store-page partner-home--flush-top">
|
||||
<PullToRefresh onRefresh={loadStores} className="page partner-store-page partner-home--flush-top">
|
||||
{error && <p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>{error}</p>}
|
||||
|
||||
<div className="partner-sticky-filter">
|
||||
@@ -188,6 +211,24 @@ export default function StoreListPage() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{closeTarget && (
|
||||
<div className="partner-ship-modal-backdrop" role="presentation" onClick={() => setCloseTarget(null)}>
|
||||
<div className="partner-ship-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="headline-md" style={{ marginBottom: 8 }}>确认关闭门店?</h3>
|
||||
<p className="body-md text-muted" style={{ lineHeight: 1.5 }}>
|
||||
关闭后不可恢复营业,确认关闭该门店?
|
||||
</p>
|
||||
<div className="partner-ship-actions">
|
||||
<button type="button" className="partner-btn-secondary" onClick={() => setCloseTarget(null)}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="partner-btn-primary" onClick={() => void confirmCloseStore()}>
|
||||
确认关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { PartnerWeeklyReportResponse } from '@dukang/shared-types';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { fetchPartnerWeeklyReport } from '../lib/weeklyReport';
|
||||
|
||||
function fmtMoney(n: number) {
|
||||
@@ -20,10 +21,10 @@ export default function WeeklyReportPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const loadWeeklyReport = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
fetchPartnerWeeklyReport(selectedStart)
|
||||
return fetchPartnerWeeklyReport(selectedStart)
|
||||
.then(setData)
|
||||
.catch((e) => {
|
||||
setData(null);
|
||||
@@ -32,6 +33,10 @@ export default function WeeklyReportPage() {
|
||||
.finally(() => setLoading(false));
|
||||
}, [selectedStart]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadWeeklyReport();
|
||||
}, [loadWeeklyReport]);
|
||||
|
||||
const maxDailyGmv = useMemo(() => {
|
||||
if (!data?.dailyGmv.length) return 1;
|
||||
return Math.max(1, ...data.dailyGmv.map((item) => item.amount));
|
||||
@@ -42,7 +47,7 @@ export default function WeeklyReportPage() {
|
||||
const growthPositive = (summary?.gmvGrowthPercent ?? 0) >= 0;
|
||||
|
||||
return (
|
||||
<div className="page-no-tab">
|
||||
<PullToRefresh onRefresh={loadWeeklyReport} className="page-no-tab">
|
||||
<PageHeader title="数据周报" onBack={() => navigate('/')} />
|
||||
|
||||
<main className="partner-weekly-page">
|
||||
@@ -211,6 +216,6 @@ export default function WeeklyReportPage() {
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
function formatMoney(n: number) {
|
||||
@@ -47,7 +48,7 @@ export default function HomePage() {
|
||||
const closeTime = String(store?.closeTime || '22:00');
|
||||
|
||||
return (
|
||||
<div className="shop-home-page">
|
||||
<PullToRefresh onRefresh={loadDashboard} className="shop-home-page">
|
||||
<header className="shop-home-header">
|
||||
<h1 className="app-page-title">门店管理中心</h1>
|
||||
</header>
|
||||
@@ -127,6 +128,6 @@ export default function HomePage() {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import { getStoreProfile, request } from '../lib/api';
|
||||
@@ -24,21 +25,21 @@ export default function MinePage() {
|
||||
const [binding, setBinding] = useState(false);
|
||||
const [bindMsg, setBindMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
request('SHOP_H5', '/shop/store').then(setStore).catch(() => setStore(null));
|
||||
const loadMine = useCallback(() => {
|
||||
return Promise.all([
|
||||
request('SHOP_H5', '/shop/store').then(setStore).catch(() => setStore(null)),
|
||||
fetchClientConfig()
|
||||
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
|
||||
.catch(() => setWxAuthorize(false)),
|
||||
fetchShopAccount()
|
||||
.then((me) => setHasWechat(!!(me.hasWechat || me.wxOpenId)))
|
||||
.catch(() => setHasWechat(null)),
|
||||
]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchClientConfig()
|
||||
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
|
||||
.catch(() => setWxAuthorize(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchShopAccount()
|
||||
.then((me) => setHasWechat(!!(me.hasWechat || me.wxOpenId)))
|
||||
.catch(() => setHasWechat(null));
|
||||
}, []);
|
||||
void loadMine();
|
||||
}, [loadMine]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWechatEnv() || !wxAuthorize || !searchParams.get('code')) return;
|
||||
@@ -92,7 +93,7 @@ export default function MinePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shop-mine-page">
|
||||
<PullToRefresh onRefresh={loadMine} className="shop-mine-page">
|
||||
<header className="shop-mine-header">
|
||||
<h1 className="app-page-title">我的</h1>
|
||||
</header>
|
||||
@@ -174,6 +175,6 @@ export default function MinePage() {
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
type RangeKey = 'today' | '7d' | '30d';
|
||||
@@ -28,15 +29,21 @@ export default function RecordsPage() {
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||
const [storeName, setStoreName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
request<{ list: Array<Record<string, unknown>> }>('SHOP_H5', '/shop/redeem/records').then((d) => {
|
||||
setRecords(d.list || []);
|
||||
});
|
||||
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
|
||||
.then((s) => setStoreName(String(s.name || '')))
|
||||
.catch(() => {});
|
||||
const loadRecords = useCallback(() => {
|
||||
return Promise.all([
|
||||
request<{ list: Array<Record<string, unknown>> }>('SHOP_H5', '/shop/redeem/records').then((d) => {
|
||||
setRecords(d.list || []);
|
||||
}),
|
||||
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
|
||||
.then((s) => setStoreName(String(s.name || '')))
|
||||
.catch(() => {}),
|
||||
]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRecords();
|
||||
}, [loadRecords]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return records.filter((r) => {
|
||||
if (!inRange(String(r.createdAt), range)) return false;
|
||||
@@ -55,7 +62,7 @@ export default function RecordsPage() {
|
||||
}, [filtered]);
|
||||
|
||||
return (
|
||||
<div className="shop-records-page">
|
||||
<PullToRefresh onRefresh={loadRecords} className="shop-records-page">
|
||||
<header className="shop-records-header">
|
||||
<h1 className="app-page-title">核销记录</h1>
|
||||
</header>
|
||||
@@ -176,6 +183,6 @@ export default function RecordsPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import {
|
||||
needsStoreSelection,
|
||||
@@ -19,15 +20,19 @@ export default function SelectStorePage() {
|
||||
const currentStoreId = store?.storeId || '';
|
||||
const canGoBack = Boolean(currentStoreId);
|
||||
|
||||
const loadStores = useCallback(() => {
|
||||
return request<ShopStoreOption[]>('SHOP_H5', '/shop/auth/stores')
|
||||
.then((list) => setStores(list))
|
||||
.catch((e) => setMsg(e instanceof Error ? e.message : '加载门店失败'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authenticated) {
|
||||
navigate('/login', { replace: true });
|
||||
return;
|
||||
}
|
||||
void request<ShopStoreOption[]>('SHOP_H5', '/shop/auth/stores')
|
||||
.then((list) => setStores(list))
|
||||
.catch((e) => setMsg(e instanceof Error ? e.message : '加载门店失败'));
|
||||
}, [authenticated, navigate]);
|
||||
void loadStores();
|
||||
}, [authenticated, navigate, loadStores]);
|
||||
|
||||
async function onSelect(storeId: string) {
|
||||
if (loadingId) return;
|
||||
@@ -63,7 +68,7 @@ export default function SelectStorePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shop-select-store-page">
|
||||
<PullToRefresh onRefresh={loadStores} className="shop-select-store-page">
|
||||
<header className="shop-subpage-header">
|
||||
{canGoBack ? (
|
||||
<button
|
||||
@@ -151,7 +156,7 @@ export default function SelectStorePage() {
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { STORE_STAFF_ROLE_LABELS, type StoreStaffRole } from '@dukang/shared-types';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import { getStoreProfile, request } from '../lib/api';
|
||||
|
||||
@@ -116,7 +117,7 @@ export default function StaffPage() {
|
||||
form.storeIds.length === 0 ? ownedStores.length : form.storeIds.length;
|
||||
|
||||
return (
|
||||
<div className="shop-staff-page">
|
||||
<PullToRefresh onRefresh={reload} className="shop-staff-page">
|
||||
<header className="shop-subpage-header">
|
||||
<button
|
||||
type="button"
|
||||
@@ -322,6 +323,6 @@ export default function StaffPage() {
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '地址管理',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark',
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useDidShow, useRouter } from '@tarojs/taro';
|
||||
import Taro, { useDidShow, usePullDownRefresh, useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import {
|
||||
@@ -44,6 +44,10 @@ export default function AddressesPage() {
|
||||
loadList();
|
||||
});
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
void Promise.resolve(loadList()).finally(() => Taro.stopPullDownRefresh());
|
||||
});
|
||||
|
||||
function selectAddress(addr: Address) {
|
||||
if (!selectMode) return;
|
||||
Taro.redirectTo({
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '好客权益',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark',
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
||||
import { goLogin } from '../../lib/auth-nav';
|
||||
@@ -44,9 +44,9 @@ export default function BenefitPage() {
|
||||
syncTabBarSelected(2);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!loggedIn) return;
|
||||
Promise.all([
|
||||
const loadBenefit = useCallback(() => {
|
||||
if (!loggedIn) return Promise.resolve();
|
||||
return Promise.all([
|
||||
request<BenefitSummary>('/benefit/summary'),
|
||||
request<CouponItem[]>('/benefit/coupons'),
|
||||
])
|
||||
@@ -57,6 +57,14 @@ export default function BenefitPage() {
|
||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
||||
}, [loggedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadBenefit();
|
||||
}, [loadBenefit]);
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
void loadBenefit().finally(() => Taro.stopPullDownRefresh());
|
||||
});
|
||||
|
||||
const available = coupons.filter((c) => c.status === 'ACTIVE' && c.balance > 0);
|
||||
const history = coupons.filter((c) => c.status === 'USED_UP' || c.status === 'VOID');
|
||||
const visible = tab === 'available' ? available : history;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '杜康好客',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark',
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import TabMainHeader from '../../components/TabMainHeader';
|
||||
import CouponBadge from '../../components/CouponBadge';
|
||||
@@ -41,14 +41,39 @@ export default function HomePage() {
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const loadProducts = useCallback(() => {
|
||||
setLoading(true);
|
||||
request<Product[]>(`/catalog/products?cityCode=${encodeURIComponent(cityCode)}`)
|
||||
return request<Product[]>(`/catalog/products?cityCode=${encodeURIComponent(cityCode)}`)
|
||||
.then((list) => setProducts(Array.isArray(list) ? list : []))
|
||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [cityCode]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProducts();
|
||||
}, [loadProducts]);
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const resolved = await resolveUserCity();
|
||||
setDisplayCity(resolved.displayCity);
|
||||
const nextCode = getCityCodeForCatalog(resolved);
|
||||
setCityCode(nextCode);
|
||||
setLoading(true);
|
||||
const list = await request<Product[]>(
|
||||
`/catalog/products?cityCode=${encodeURIComponent(nextCode)}`,
|
||||
);
|
||||
setProducts(Array.isArray(list) ? list : []);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
Taro.stopPullDownRefresh();
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
const availableAromas = useMemo(
|
||||
() =>
|
||||
AROMA_TABS.filter((item) =>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '我的',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark',
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text, Image, Button, Input } from '@tarojs/components';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
|
||||
import { isWxAuthorizeEnabled, type ClientRuntimeConfig } from '@dukang/shared-types';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import TabMainHeader from '../../components/TabMainHeader';
|
||||
@@ -64,9 +64,9 @@ export default function MinePage() {
|
||||
}
|
||||
|
||||
function loadProfile() {
|
||||
if (!isLoggedIn()) return;
|
||||
if (!isLoggedIn()) return Promise.resolve();
|
||||
setProfileLoadError('');
|
||||
Promise.all([
|
||||
return Promise.all([
|
||||
request<UserProfile>('/auth/me'),
|
||||
request<Array<Record<string, unknown>>>('/benefit/coupons').catch(() => []),
|
||||
...ORDER_SHORTCUTS.map((s) =>
|
||||
@@ -109,6 +109,17 @@ export default function MinePage() {
|
||||
}
|
||||
});
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
const loggedInNow = isLoggedIn();
|
||||
setAuthed(loggedInNow);
|
||||
if (!loggedInNow) {
|
||||
resetGuestState();
|
||||
Taro.stopPullDownRefresh();
|
||||
return;
|
||||
}
|
||||
void loadProfile().finally(() => Taro.stopPullDownRefresh());
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
request<ClientRuntimeConfig>('/common/client-config')
|
||||
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '我的订单',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark',
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import Taro, { usePullDownRefresh, useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import { request, toast } from '../../lib/api';
|
||||
@@ -47,9 +47,9 @@ export default function OrdersPage() {
|
||||
const [orders, setOrders] = useState<OrderRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadOrders = useCallback(() => {
|
||||
setLoading(true);
|
||||
request<{ list?: OrderRow[]; items?: OrderRow[]; total?: number } | OrderRow[]>(
|
||||
return request<{ list?: OrderRow[]; items?: OrderRow[]; total?: number } | OrderRow[]>(
|
||||
`/trade/orders?tab=${encodeURIComponent(tab)}&pageSize=20`,
|
||||
)
|
||||
.then((data) => {
|
||||
@@ -63,6 +63,14 @@ export default function OrdersPage() {
|
||||
.finally(() => setLoading(false));
|
||||
}, [tab]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadOrders();
|
||||
}, [loadOrders]);
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
void loadOrders().finally(() => Taro.stopPullDownRefresh());
|
||||
});
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="orders-page">
|
||||
<SubPageHeader title="我的订单" />
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '门店',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark',
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { View, Text, Image, Input } from '@tarojs/components';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import TabMainHeader from '../../components/TabMainHeader';
|
||||
import RegionPicker from '../../components/RegionPicker';
|
||||
@@ -49,15 +49,39 @@ export default function StoresPage() {
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const loadStores = useCallback(() => {
|
||||
setLoading(true);
|
||||
const path = cityCode ? `/stores?cityCode=${encodeURIComponent(cityCode)}` : '/stores';
|
||||
request<Store[]>(path)
|
||||
return request<Store[]>(path)
|
||||
.then((list) => setStores(Array.isArray(list) ? list : []))
|
||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [cityCode]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadStores();
|
||||
}, [loadStores]);
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const resolved = await resolveUserCity();
|
||||
setRegion(resolved.region);
|
||||
const nextCode = getCityCodeForCatalog(resolved);
|
||||
setCityCode(nextCode);
|
||||
setLoading(true);
|
||||
const path = nextCode ? `/stores?cityCode=${encodeURIComponent(nextCode)}` : '/stores';
|
||||
const list = await request<Store[]>(path);
|
||||
setStores(Array.isArray(list) ? list : []);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
Taro.stopPullDownRefresh();
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
const filtered = stores.filter((s) => {
|
||||
if (!matchesRegionFilter(s, region)) return false;
|
||||
if (!keyword.trim()) return true;
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"./PageHeader": "./src/PageHeader.tsx",
|
||||
"./CouponBadge": "./src/CouponBadge.tsx",
|
||||
"./OrderStatusTabs": "./src/OrderStatusTabs.tsx",
|
||||
"./AppImage": "./src/AppImage.tsx"
|
||||
"./AppImage": "./src/AppImage.tsx",
|
||||
"./PullToRefresh": "./src/PullToRefresh.tsx"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1"
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import './pull-to-refresh.css';
|
||||
|
||||
const PULL_THRESHOLD = 64;
|
||||
const MAX_PULL = 96;
|
||||
|
||||
type PullToRefreshProps = {
|
||||
onRefresh: () => void | Promise<void>;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
function getScrollTop() {
|
||||
return window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0;
|
||||
}
|
||||
|
||||
export default function PullToRefresh({
|
||||
onRefresh,
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
disabled = false,
|
||||
}: PullToRefreshProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [pull, setPull] = useState(0);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const startY = useRef(0);
|
||||
const pulling = useRef(false);
|
||||
const busy = useRef(false);
|
||||
const pullRef = useRef(0);
|
||||
const disabledRef = useRef(disabled);
|
||||
const onRefreshRef = useRef(onRefresh);
|
||||
|
||||
disabledRef.current = disabled;
|
||||
onRefreshRef.current = onRefresh;
|
||||
pullRef.current = pull;
|
||||
|
||||
const finishRefresh = useCallback(async () => {
|
||||
if (busy.current) return;
|
||||
busy.current = true;
|
||||
setRefreshing(true);
|
||||
setPull(40);
|
||||
pullRef.current = 40;
|
||||
try {
|
||||
await onRefreshRef.current();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
setPull(0);
|
||||
pullRef.current = 0;
|
||||
busy.current = false;
|
||||
pulling.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = rootRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
if (disabledRef.current || busy.current) return;
|
||||
if (getScrollTop() > 2) return;
|
||||
startY.current = e.touches[0]?.clientY ?? 0;
|
||||
pulling.current = true;
|
||||
};
|
||||
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
if (!pulling.current || disabledRef.current || busy.current) return;
|
||||
if (getScrollTop() > 2) {
|
||||
pulling.current = false;
|
||||
setPull(0);
|
||||
pullRef.current = 0;
|
||||
return;
|
||||
}
|
||||
const y = e.touches[0]?.clientY ?? 0;
|
||||
const delta = y - startY.current;
|
||||
if (delta <= 0) {
|
||||
setPull(0);
|
||||
pullRef.current = 0;
|
||||
return;
|
||||
}
|
||||
const next = Math.min(MAX_PULL, delta * 0.45);
|
||||
setPull(next);
|
||||
pullRef.current = next;
|
||||
if (next > 8 && e.cancelable) e.preventDefault();
|
||||
};
|
||||
|
||||
const onTouchEnd = () => {
|
||||
if (!pulling.current || disabledRef.current) return;
|
||||
pulling.current = false;
|
||||
if (pullRef.current >= PULL_THRESHOLD) {
|
||||
void finishRefresh();
|
||||
return;
|
||||
}
|
||||
setPull(0);
|
||||
pullRef.current = 0;
|
||||
};
|
||||
|
||||
el.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||
el.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
el.addEventListener('touchend', onTouchEnd);
|
||||
el.addEventListener('touchcancel', onTouchEnd);
|
||||
return () => {
|
||||
el.removeEventListener('touchstart', onTouchStart);
|
||||
el.removeEventListener('touchmove', onTouchMove);
|
||||
el.removeEventListener('touchend', onTouchEnd);
|
||||
el.removeEventListener('touchcancel', onTouchEnd);
|
||||
};
|
||||
}, [finishRefresh]);
|
||||
|
||||
const showHint = pull > 8 || refreshing;
|
||||
const ready = pull >= PULL_THRESHOLD;
|
||||
const pad = showHint ? Math.max(pull, refreshing ? 40 : 0) : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={['dukang-ptr', className].filter(Boolean).join(' ')}
|
||||
style={{ ...style, paddingTop: pad || undefined }}
|
||||
>
|
||||
<div
|
||||
className={`dukang-ptr-indicator${showHint ? ' dukang-ptr-indicator--visible' : ''}`}
|
||||
style={{ height: pad }}
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className="dukang-ptr-text">
|
||||
{refreshing ? '刷新中…' : ready ? '松开刷新' : '下拉刷新'}
|
||||
</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
.dukang-ptr {
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dukang-ptr-indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.dukang-ptr-text {
|
||||
padding-bottom: 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
color: #8a8580;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
@@ -477,12 +477,18 @@ model CommonProductDetailTemplate {
|
||||
}
|
||||
|
||||
model CommonStoreCategory {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
code String @unique @db.VarChar(32)
|
||||
name String @db.VarChar(64)
|
||||
sort Int @default(0)
|
||||
stores Store[]
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
code String @unique @db.VarChar(32)
|
||||
name String @db.VarChar(64)
|
||||
sort Int @default(0)
|
||||
parentId BigInt? @map("parent_id") @db.UnsignedBigInt
|
||||
status String @default("ACTIVE") @db.VarChar(16)
|
||||
parent CommonStoreCategory? @relation("StoreCategoryTree", fields: [parentId], references: [id], onDelete: Restrict)
|
||||
children CommonStoreCategory[] @relation("StoreCategoryTree")
|
||||
stores Store[]
|
||||
|
||||
@@index([parentId, sort])
|
||||
@@index([status])
|
||||
@@map("common_store_category")
|
||||
}
|
||||
|
||||
|
||||
@@ -335,12 +335,18 @@ model CommonProductItem {
|
||||
}
|
||||
|
||||
model CommonStoreCategory {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
code String @unique @db.VarChar(32)
|
||||
name String @db.VarChar(64)
|
||||
sort Int @default(0)
|
||||
stores Store[]
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
code String @unique @db.VarChar(32)
|
||||
name String @db.VarChar(64)
|
||||
sort Int @default(0)
|
||||
parentId BigInt? @map("parent_id") @db.UnsignedBigInt
|
||||
status String @default("ACTIVE") @db.VarChar(16)
|
||||
parent CommonStoreCategory? @relation("StoreCategoryTree", fields: [parentId], references: [id], onDelete: Restrict)
|
||||
children CommonStoreCategory[] @relation("StoreCategoryTree")
|
||||
stores Store[]
|
||||
|
||||
@@index([parentId, sort])
|
||||
@@index([status])
|
||||
@@map("common_store_category")
|
||||
}
|
||||
|
||||
|
||||
@@ -266,13 +266,36 @@ async function main() {
|
||||
|
||||
|
||||
|
||||
const categories = await Promise.all([
|
||||
|
||||
prisma.commonStoreCategory.create({ data: { code: 'HOTPOT', name: '火锅', sort: 1 } }),
|
||||
|
||||
prisma.commonStoreCategory.create({ data: { code: 'LOCAL', name: '地方菜', sort: 2 } }),
|
||||
|
||||
]);
|
||||
const { DEFAULT_STORE_CATEGORY_TREE } = await import('../src/modules/store/store-category.defaults');
|
||||
const categoryByCode = new Map<string, { id: bigint }>();
|
||||
for (const root of DEFAULT_STORE_CATEGORY_TREE) {
|
||||
const parent = await prisma.commonStoreCategory.create({
|
||||
data: {
|
||||
code: root.code,
|
||||
name: root.name,
|
||||
sort: root.sort,
|
||||
parentId: null,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
categoryByCode.set(root.code, parent);
|
||||
for (const child of root.children) {
|
||||
const row = await prisma.commonStoreCategory.create({
|
||||
data: {
|
||||
code: child.code,
|
||||
name: child.name,
|
||||
sort: child.sort,
|
||||
parentId: parent.id,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
categoryByCode.set(child.code, row);
|
||||
}
|
||||
}
|
||||
const categories = [
|
||||
categoryByCode.get('HOTPOT')!,
|
||||
categoryByCode.get('LOCAL')!,
|
||||
];
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,10 @@ export const HqOperationAction = {
|
||||
STORE_MEDIA_CREATE: 'STORE_MEDIA_CREATE',
|
||||
STORE_MEDIA_UPDATE: 'STORE_MEDIA_UPDATE',
|
||||
STORE_MEDIA_DELETE: 'STORE_MEDIA_DELETE',
|
||||
STORE_CATEGORY_CREATE: 'STORE_CATEGORY_CREATE',
|
||||
STORE_CATEGORY_UPDATE: 'STORE_CATEGORY_UPDATE',
|
||||
STORE_CATEGORY_DELETE: 'STORE_CATEGORY_DELETE',
|
||||
STORE_CATEGORY_ENSURE: 'STORE_CATEGORY_ENSURE',
|
||||
PRODUCT_CREATE: 'PRODUCT_CREATE',
|
||||
PRODUCT_UPDATE: 'PRODUCT_UPDATE',
|
||||
PRODUCT_DELETE: 'PRODUCT_DELETE',
|
||||
@@ -94,6 +98,10 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
||||
[HqOperationAction.STORE_MEDIA_CREATE]: '新增门店资源',
|
||||
[HqOperationAction.STORE_MEDIA_UPDATE]: '编辑门店资源',
|
||||
[HqOperationAction.STORE_MEDIA_DELETE]: '删除门店资源',
|
||||
[HqOperationAction.STORE_CATEGORY_CREATE]: '新增门店分类',
|
||||
[HqOperationAction.STORE_CATEGORY_UPDATE]: '编辑门店分类',
|
||||
[HqOperationAction.STORE_CATEGORY_DELETE]: '删除门店分类',
|
||||
[HqOperationAction.STORE_CATEGORY_ENSURE]: '初始化默认门店分类',
|
||||
[HqOperationAction.PRODUCT_CREATE]: '新增商品',
|
||||
[HqOperationAction.PRODUCT_UPDATE]: '编辑商品',
|
||||
[HqOperationAction.PRODUCT_DELETE]: '删除商品',
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { StoreCategoryService } from '../store/store-category.service';
|
||||
import {
|
||||
CreateStoreCategoryDto,
|
||||
UpdateStoreCategoryDto,
|
||||
} from './dto/admin-mutate.dto';
|
||||
|
||||
@Controller('admin/store-categories')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminStoreCategoriesController {
|
||||
constructor(private readonly categories: StoreCategoryService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.categories.listTree({ includeDisabled: true, ensure: true });
|
||||
}
|
||||
|
||||
@Get('flat')
|
||||
listFlat() {
|
||||
return this.categories.listFlat({ includeDisabled: true, ensure: true });
|
||||
}
|
||||
|
||||
@Post('ensure-defaults')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.STORE_CATEGORY_ENSURE,
|
||||
refType: 'STORE_CATEGORY',
|
||||
includeBody: false,
|
||||
})
|
||||
async ensureDefaults() {
|
||||
await this.categories.ensureDefaults();
|
||||
return this.categories.listTree({ includeDisabled: true, ensure: false });
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HqOperation({
|
||||
action: HqOperationAction.STORE_CATEGORY_CREATE,
|
||||
refType: 'STORE_CATEGORY',
|
||||
refIdField: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
create(@Body() dto: CreateStoreCategoryDto) {
|
||||
return this.categories.create(dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.STORE_CATEGORY_UPDATE,
|
||||
refType: 'STORE_CATEGORY',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
update(@Param('id') id: string, @Body() dto: UpdateStoreCategoryDto) {
|
||||
return this.categories.update(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.STORE_CATEGORY_DELETE,
|
||||
refType: 'STORE_CATEGORY',
|
||||
refIdParam: 'id',
|
||||
})
|
||||
remove(@Param('id') id: string) {
|
||||
return this.categories.remove(BigInt(id));
|
||||
}
|
||||
}
|
||||
@@ -1150,3 +1150,53 @@ export class AdminBenefitGrantDto {
|
||||
@IsString()
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export class CreateStoreCategoryDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
code: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
sort?: number;
|
||||
|
||||
/** 空/不传 = 一级分类;传入一级 id = 二级分类 */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['ACTIVE', 'DISABLED'])
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export class UpdateStoreCategoryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
code?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
sort?: number;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== null && v !== undefined)
|
||||
@IsString()
|
||||
parentId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['ACTIVE', 'DISABLED'])
|
||||
status?: string;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CityScopeModule } from '../city-scope/city-scope.module';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { TradeModule } from '../trade/trade.module';
|
||||
import { FulfillmentModule } from '../fulfillment/fulfillment.module';
|
||||
import { StoreModule } from '../store/store.module';
|
||||
import { AdminDashboardController } from './admin-dashboard.controller';
|
||||
import { AdminDashboardService } from './admin-dashboard.service';
|
||||
import { AdminUsersController } from './admin-users.controller';
|
||||
@@ -10,6 +11,7 @@ import { AdminUsersService } from './admin-users.service';
|
||||
import { AdminOrdersController } from './admin-orders.controller';
|
||||
import { AdminOrdersService } from './admin-orders.service';
|
||||
import { AdminStoresController, AdminStoreAccountsController, AdminStoreMediaController } from './admin-stores.controller';
|
||||
import { AdminStoreCategoriesController } from './admin-store-categories.controller';
|
||||
import { AdminStoresService } from './admin-stores.service';
|
||||
import { AdminPartnersController, AdminPartnerAccountsController } from './admin-partners.controller';
|
||||
import { AdminPartnersService } from './admin-partners.service';
|
||||
@@ -59,7 +61,7 @@ import { AdminSystemConfigController } from './admin-system-config.controller';
|
||||
import { AdminFulfillmentProvidersController } from './admin-fulfillment-providers.controller';
|
||||
|
||||
@Module({
|
||||
imports: [CityScopeModule, IamModule, TradeModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule],
|
||||
imports: [CityScopeModule, IamModule, TradeModule, FulfillmentModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule, StoreModule],
|
||||
controllers: [
|
||||
AdminDashboardController,
|
||||
AdminDeployController,
|
||||
@@ -68,6 +70,7 @@ import { AdminFulfillmentProvidersController } from './admin-fulfillment-provide
|
||||
AdminStoresController,
|
||||
AdminStoreAccountsController,
|
||||
AdminStoreMediaController,
|
||||
AdminStoreCategoriesController,
|
||||
AdminPartnersController,
|
||||
AdminPartnerAccountsController,
|
||||
AdminCitiesController,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/** 门店两级分类默认树(一级 + 二级) */
|
||||
export const DEFAULT_STORE_CATEGORY_TREE = [
|
||||
{
|
||||
code: 'DINING',
|
||||
name: '餐饮',
|
||||
sort: 1,
|
||||
children: [
|
||||
{ code: 'LOCAL', name: '地方菜', sort: 1 },
|
||||
{ code: 'WESTERN', name: '西餐', sort: 2 },
|
||||
{ code: 'BBQ', name: '烧烤', sort: 3 },
|
||||
{ code: 'HOTPOT', name: '火锅', sort: 4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'LODGING',
|
||||
name: '住宿',
|
||||
sort: 2,
|
||||
children: [
|
||||
{ code: 'BUDGET_HOTEL', name: '快捷酒店', sort: 1 },
|
||||
{ code: 'INN', name: '旅馆', sort: 2 },
|
||||
{ code: 'LUXURY_HOTEL', name: '豪华酒店', sort: 3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'ENTERTAINMENT',
|
||||
name: '娱乐',
|
||||
sort: 3,
|
||||
children: [
|
||||
{ code: 'KTV', name: 'KTV', sort: 1 },
|
||||
{ code: 'CLUB', name: '会所', sort: 2 },
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
@@ -0,0 +1,267 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { DEFAULT_STORE_CATEGORY_TREE } from './store-category.defaults';
|
||||
|
||||
export type StoreCategoryNode = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
sort: number;
|
||||
parentId: string | null;
|
||||
status: string;
|
||||
children: StoreCategoryNode[];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class StoreCategoryService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* 仅补齐缺失的默认分类,绝不 update 已有行(避免改动线上历史数据)。
|
||||
* 已存在的 HOTPOT/LOCAL 等保持原样;缺的一级/二级才 create。
|
||||
*/
|
||||
async ensureDefaults() {
|
||||
for (const root of DEFAULT_STORE_CATEGORY_TREE) {
|
||||
let parent = await this.prisma.commonStoreCategory.findUnique({
|
||||
where: { code: root.code },
|
||||
});
|
||||
if (!parent) {
|
||||
parent = await this.prisma.commonStoreCategory.create({
|
||||
data: {
|
||||
code: root.code,
|
||||
name: root.name,
|
||||
sort: root.sort,
|
||||
parentId: null,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const child of root.children) {
|
||||
const existing = await this.prisma.commonStoreCategory.findUnique({
|
||||
where: { code: child.code },
|
||||
});
|
||||
if (existing) continue;
|
||||
await this.prisma.commonStoreCategory.create({
|
||||
data: {
|
||||
code: child.code,
|
||||
name: child.name,
|
||||
sort: child.sort,
|
||||
parentId: parent.id,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private mapNode(
|
||||
row: {
|
||||
id: bigint;
|
||||
code: string;
|
||||
name: string;
|
||||
sort: number;
|
||||
parentId: bigint | null;
|
||||
status: string;
|
||||
children?: Array<{
|
||||
id: bigint;
|
||||
code: string;
|
||||
name: string;
|
||||
sort: number;
|
||||
parentId: bigint | null;
|
||||
status: string;
|
||||
}>;
|
||||
},
|
||||
includeDisabledChildren: boolean,
|
||||
): StoreCategoryNode {
|
||||
const children = (row.children ?? [])
|
||||
.filter((c) => includeDisabledChildren || c.status === 'ACTIVE')
|
||||
.sort((a, b) => a.sort - b.sort || Number(a.id - b.id))
|
||||
.map((c) => this.mapNode(c, includeDisabledChildren));
|
||||
return {
|
||||
id: String(row.id),
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
sort: row.sort,
|
||||
parentId: row.parentId != null ? String(row.parentId) : null,
|
||||
status: row.status,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
async listTree(options?: { includeDisabled?: boolean; ensure?: boolean }) {
|
||||
if (options?.ensure !== false) {
|
||||
await this.ensureDefaults();
|
||||
}
|
||||
const includeDisabled = options?.includeDisabled === true;
|
||||
const roots = await this.prisma.commonStoreCategory.findMany({
|
||||
where: {
|
||||
parentId: null,
|
||||
...(includeDisabled ? {} : { status: 'ACTIVE' }),
|
||||
},
|
||||
include: {
|
||||
children: {
|
||||
orderBy: [{ sort: 'asc' }, { id: 'asc' }],
|
||||
},
|
||||
},
|
||||
orderBy: [{ sort: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
return serializeBigInt(roots.map((r) => this.mapNode(r, includeDisabled)));
|
||||
}
|
||||
|
||||
async listFlat(options?: { includeDisabled?: boolean; ensure?: boolean }) {
|
||||
if (options?.ensure !== false) {
|
||||
await this.ensureDefaults();
|
||||
}
|
||||
const includeDisabled = options?.includeDisabled === true;
|
||||
const rows = await this.prisma.commonStoreCategory.findMany({
|
||||
where: includeDisabled ? undefined : { status: 'ACTIVE' },
|
||||
orderBy: [{ parentId: 'asc' }, { sort: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
return serializeBigInt(
|
||||
rows.map((row) => ({
|
||||
id: String(row.id),
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
sort: row.sort,
|
||||
parentId: row.parentId != null ? String(row.parentId) : null,
|
||||
status: row.status,
|
||||
level: row.parentId == null ? 1 : 2,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async assertLeafCategoryId(categoryId: bigint) {
|
||||
const row = await this.prisma.commonStoreCategory.findUnique({ where: { id: categoryId } });
|
||||
if (!row || row.status !== 'ACTIVE') {
|
||||
throw new BadRequestException('店铺类型不存在或已停用');
|
||||
}
|
||||
if (row.parentId == null) {
|
||||
throw new BadRequestException('请选择二级店铺类型');
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
async create(dto: {
|
||||
code: string;
|
||||
name: string;
|
||||
sort?: number;
|
||||
parentId?: string | null;
|
||||
status?: string;
|
||||
}) {
|
||||
const code = dto.code.trim().toUpperCase();
|
||||
const name = dto.name.trim();
|
||||
if (!code) throw new BadRequestException('请填写分类编码');
|
||||
if (!name) throw new BadRequestException('请填写分类名称');
|
||||
let parentId: bigint | null = null;
|
||||
if (dto.parentId) {
|
||||
const parent = await this.prisma.commonStoreCategory.findUnique({
|
||||
where: { id: BigInt(dto.parentId) },
|
||||
});
|
||||
if (!parent || parent.parentId != null) {
|
||||
throw new BadRequestException('父级必须是一级分类');
|
||||
}
|
||||
parentId = parent.id;
|
||||
}
|
||||
try {
|
||||
const row = await this.prisma.commonStoreCategory.create({
|
||||
data: {
|
||||
code,
|
||||
name,
|
||||
sort: dto.sort ?? 0,
|
||||
parentId,
|
||||
status: dto.status === 'DISABLED' ? 'DISABLED' : 'ACTIVE',
|
||||
},
|
||||
});
|
||||
return serializeBigInt(row);
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new BadRequestException('分类编码已存在');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async update(
|
||||
id: bigint,
|
||||
dto: {
|
||||
code?: string;
|
||||
name?: string;
|
||||
sort?: number;
|
||||
parentId?: string | null;
|
||||
status?: string;
|
||||
},
|
||||
) {
|
||||
const existing = await this.prisma.commonStoreCategory.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('分类不存在');
|
||||
|
||||
let parentId: bigint | null | undefined = undefined;
|
||||
if (dto.parentId !== undefined) {
|
||||
if (dto.parentId == null || dto.parentId === '') {
|
||||
parentId = null;
|
||||
} else {
|
||||
if (BigInt(dto.parentId) === id) {
|
||||
throw new BadRequestException('不能将分类设为自己的子级');
|
||||
}
|
||||
const parent = await this.prisma.commonStoreCategory.findUnique({
|
||||
where: { id: BigInt(dto.parentId) },
|
||||
});
|
||||
if (!parent || parent.parentId != null) {
|
||||
throw new BadRequestException('父级必须是一级分类');
|
||||
}
|
||||
// 一级分类若已有子级,不允许变成二级
|
||||
if (existing.parentId == null) {
|
||||
const childCount = await this.prisma.commonStoreCategory.count({
|
||||
where: { parentId: id },
|
||||
});
|
||||
if (childCount > 0) {
|
||||
throw new BadRequestException('该一级分类下仍有二级分类,不能改为二级');
|
||||
}
|
||||
}
|
||||
parentId = parent.id;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const row = await this.prisma.commonStoreCategory.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.code !== undefined ? { code: dto.code.trim().toUpperCase() } : {}),
|
||||
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
|
||||
...(dto.sort !== undefined ? { sort: dto.sort } : {}),
|
||||
...(parentId !== undefined ? { parentId } : {}),
|
||||
...(dto.status !== undefined
|
||||
? { status: dto.status === 'DISABLED' ? 'DISABLED' : 'ACTIVE' }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
return serializeBigInt(row);
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new BadRequestException('分类编码已存在');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: bigint) {
|
||||
const existing = await this.prisma.commonStoreCategory.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('分类不存在');
|
||||
const childCount = await this.prisma.commonStoreCategory.count({ where: { parentId: id } });
|
||||
if (childCount > 0) {
|
||||
throw new BadRequestException('请先删除或停用下级分类');
|
||||
}
|
||||
const storeCount = await this.prisma.store.count({ where: { categoryId: id } });
|
||||
if (storeCount > 0) {
|
||||
// 软停用,避免破坏已有门店关联
|
||||
const row = await this.prisma.commonStoreCategory.update({
|
||||
where: { id },
|
||||
data: { status: 'DISABLED' },
|
||||
});
|
||||
return serializeBigInt({ ...row, softDisabled: true });
|
||||
}
|
||||
await this.prisma.commonStoreCategory.delete({ where: { id } });
|
||||
return { id: String(id), deleted: true };
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
||||
import { StoreService } from './store.service';
|
||||
import { StoreCategoryService } from './store-category.service';
|
||||
import { RedeemService } from '../redeem/redeem.service';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
|
||||
@@ -23,6 +24,17 @@ export class PublicStoreController {
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('partner/store-categories')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PartnerStoreCategoriesController {
|
||||
constructor(private readonly categories: StoreCategoryService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.categories.listTree({ includeDisabled: false, ensure: true });
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('partner/stores')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PartnerStoreController {
|
||||
|
||||
@@ -4,9 +4,11 @@ import { RedeemModule } from '../redeem/redeem.module';
|
||||
import { AnalyticsModule } from '../analytics/analytics.module';
|
||||
import { CityScopeModule } from '../city-scope/city-scope.module';
|
||||
import { StoreService } from './store.service';
|
||||
import { StoreCategoryService } from './store-category.service';
|
||||
import {
|
||||
PartnerDashboardController,
|
||||
PartnerReportController,
|
||||
PartnerStoreCategoriesController,
|
||||
PartnerStoreController,
|
||||
PublicStoreController,
|
||||
ShopDashboardController,
|
||||
@@ -17,13 +19,14 @@ import {
|
||||
imports: [IamModule, AnalyticsModule, CityScopeModule, forwardRef(() => RedeemModule)],
|
||||
controllers: [
|
||||
PublicStoreController,
|
||||
PartnerStoreCategoriesController,
|
||||
PartnerStoreController,
|
||||
PartnerDashboardController,
|
||||
PartnerReportController,
|
||||
ShopStoreController,
|
||||
ShopDashboardController,
|
||||
],
|
||||
providers: [StoreService],
|
||||
exports: [StoreService],
|
||||
providers: [StoreService, StoreCategoryService],
|
||||
exports: [StoreService, StoreCategoryService],
|
||||
})
|
||||
export class StoreModule {}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { parseBigIntParam } from '../../common/parse-bigint';
|
||||
import { AnalyticsService } from '../analytics/analytics.service';
|
||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||
import { AuthService } from '../iam/auth.service';
|
||||
import { StoreCategoryService } from './store-category.service';
|
||||
|
||||
@Injectable()
|
||||
export class StoreService {
|
||||
@@ -23,6 +24,7 @@ export class StoreService {
|
||||
private readonly analyticsService: AnalyticsService,
|
||||
private readonly partnerCityService: PartnerCityService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly storeCategoryService: StoreCategoryService,
|
||||
) {}
|
||||
|
||||
async listOpenStores(cityCode?: string) {
|
||||
@@ -172,11 +174,17 @@ export class StoreService {
|
||||
const bankAccountNo = body.bankAccountNo ? String(body.bankAccountNo) : null;
|
||||
const bankBranch = body.bankBranch ? String(body.bankBranch) : null;
|
||||
|
||||
if (!body.categoryId) {
|
||||
throw new BadRequestException('请选择店铺类型');
|
||||
}
|
||||
const categoryId = parseBigIntParam(body.categoryId, '分类ID');
|
||||
await this.storeCategoryService.assertLeafCategoryId(categoryId);
|
||||
|
||||
const store = await this.prisma.store.create({
|
||||
data: {
|
||||
cityId: city.id,
|
||||
partnerAccountId: primaryId,
|
||||
categoryId: body.categoryId ? parseBigIntParam(body.categoryId, '分类ID') : null,
|
||||
categoryId,
|
||||
name: String(body.name),
|
||||
phone: normalizedPhone,
|
||||
province: String(body.province ?? city.province ?? '河南省'),
|
||||
|
||||
Reference in New Issue
Block a user