Files
dukang/apps/admin-web/src/pages/StoresPage.tsx
T
2026-07-07 12:12:12 +08:00

473 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Button,
Descriptions,
Drawer,
Form,
Image,
Input,
Modal,
Select,
Space,
Steps,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
import {
validateStoreCreateStep1,
validateStoreCreateStep3,
type StoreCreateForm,
} from '../lib/storeCreate';
import { useAdminList } from '../lib/useAdminList';
import { resolveRegionBinding } from '../lib/china-region';
import ChinaRegionCascader from '../components/ChinaRegionCascader';
import OssUpload from '../components/OssUpload';
const CREATE_STEPS = [
{ title: '基本信息' },
{ title: '照片上传' },
{ title: '结算资质' },
];
type StoreRow = {
id: string;
name: string;
phone: string;
status: string;
cityName: string;
district: string;
address: string;
intro: string | null;
coverUrl: string | null;
createdAt: string;
cityRef?: { name: string; code: string };
partner?: { companyName: string };
account?: { phone: string; name: string; status: string };
};
type PartnerOption = { id: string; companyName: string };
type CityOption = {
id: string;
name: string;
code: string;
partnerId?: string | null;
partner?: { id: string; companyName: string };
};
export default function StoresPage() {
const navigate = useNavigate();
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [createForm] = Form.useForm<StoreCreateForm>();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<StoreRow>(
'/admin/stores',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.status) qs.set('status', filters.status);
if (filters.phone) qs.set('phone', filters.phone);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [createStep, setCreateStep] = useState(0);
const [createError, setCreateError] = useState('');
const [smsCooldown, setSmsCooldown] = useState(0);
const [partners, setPartners] = useState<PartnerOption[]>([]);
const [cities, setCities] = useState<CityOption[]>([]);
const [optionsLoading, setOptionsLoading] = useState(false);
const selectedPartnerId = Form.useWatch('partnerId', createForm);
const selectedRegionCodes = Form.useWatch('regionCodes', createForm);
const selectedCityId = Form.useWatch('cityId', createForm);
function bindRegionSelection(codes: string[], partnerId?: string) {
const binding = resolveRegionBinding(codes, cities, partnerId ?? selectedPartnerId);
if (!binding) {
createForm.setFieldsValue({ regionCodes: codes, cityId: undefined });
return;
}
createForm.setFieldsValue({
regionCodes: codes,
province: binding.region.province,
city: binding.region.city,
district: binding.region.district,
districtCode: binding.region.districtCode,
cityId: binding.cityId,
});
}
const regionBindingHint = useMemo(() => {
if (!selectedRegionCodes?.length) return null;
const binding = resolveRegionBinding(selectedRegionCodes, cities, selectedPartnerId);
if (!binding) return null;
if (binding.cityId && binding.matchedCity) {
return `已匹配开城城市:${binding.matchedCity.name}(区划 ${binding.cityCode},区县 ${binding.region.districtCode}`;
}
return `区划 ${binding.cityCode} 暂未开城,请先在「开城 → 开城城市」添加`;
}, [selectedRegionCodes, cities, selectedPartnerId]);
async function loadOptions() {
setOptionsLoading(true);
try {
const qs = `pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`;
const [p, c] = await Promise.all([
request<Paginated<PartnerOption>>(`/admin/partners?${qs}`),
request<Paginated<CityOption>>(`/admin/cities?${qs}`),
]);
setPartners(p.items);
setCities(c.items);
if (!p.items.length) message.warning('暂无开城合伙人,请先在「开城 → 开城合伙人」中创建');
if (!c.items.length) message.warning('暂无开城城市,请先在「开城 → 开城城市」中创建');
} catch (e) {
message.error(e instanceof Error ? e.message : '加载合伙人/城市失败');
} finally {
setOptionsLoading(false);
}
}
function closeCreateModal() {
setCreateOpen(false);
setCreateStep(0);
setCreateError('');
setSmsCooldown(0);
createForm.resetFields();
}
async function sendCreateSms() {
const phone = String(createForm.getFieldValue('phone') ?? '').trim();
if (!/^1\d{10}$/.test(phone)) {
message.error('请先填写正确的11位门店手机号');
return;
}
if (smsCooldown > 0) return;
try {
await request('/admin/stores/phone/sms/send', {
method: 'POST',
body: JSON.stringify({ phone }),
});
message.success('验证码已发送');
setSmsCooldown(60);
const timer = setInterval(() => {
setSmsCooldown((s) => {
if (s <= 1) {
clearInterval(timer);
return 0;
}
return s - 1;
});
}, 1000);
} catch (e) {
message.error(e instanceof Error ? e.message : '发送失败');
}
}
function openCreateModal() {
void loadOptions();
createForm.setFieldsValue({
envPhotoUrls: ['', '', ''],
});
setCreateStep(0);
setCreateError('');
setCreateOpen(true);
}
async function handleCreateNext() {
const values = createForm.getFieldsValue();
if (createStep === 0) {
const msg = validateStoreCreateStep1(values);
if (msg) {
setCreateError(msg);
return;
}
try {
await createForm.validateFields(['partnerId', 'regionCodes', 'cityId', 'name', 'phone', 'smsCode', 'address']);
} catch {
return;
}
}
setCreateError('');
setCreateStep((s) => s + 1);
}
async function handleCreateSubmit() {
try {
const values = await createForm.validateFields();
const step3Msg = validateStoreCreateStep3(values);
if (step3Msg) {
setCreateError(step3Msg);
return;
}
const envPhotoUrls = (values.envPhotoUrls ?? []).map((u: string) => u?.trim()).filter(Boolean) as string[];
await request('/admin/stores', {
method: 'POST',
body: JSON.stringify({
partnerId: values.partnerId,
cityId: values.cityId,
province: values.province,
city: values.city,
name: values.name.trim(),
phone: values.phone.trim(),
smsCode: values.smsCode.trim(),
district: values.district.trim(),
address: values.address.trim(),
intro: values.intro?.trim() || undefined,
coverUrl: values.coverUrl?.trim() || undefined,
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
contractUrl: values.contractUrl?.trim() || undefined,
bankAccountName: values.bankAccountName.trim(),
bankAccountNo: values.bankAccountNo.replace(/\s/g, ''),
bankBranch: values.bankBranch.trim(),
}),
});
message.success('门店已创建');
closeCreateModal();
void reload();
} catch (e) {
if (e && typeof e === 'object' && 'errorFields' in e) {
const fields = e as { errorFields?: Array<{ name: string[] }> };
const first = fields.errorFields?.[0]?.name?.[0];
if (first === 'partnerId' || first === 'cityId' || first === 'regionCodes' || first === 'name' || first === 'phone' || first === 'smsCode') {
setCreateStep(0);
}
return;
}
message.error(e instanceof Error ? e.message : '创建失败');
}
}
const columns: ColumnsType<StoreRow> = [
{
title: '封面', dataIndex: 'coverUrl', width: 72,
render: (url) => url ? <Image src={url} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '—',
},
{ title: '门店名', dataIndex: 'name', width: 140 },
{ title: '城市', dataIndex: 'cityName', width: 80 },
{ title: '电话', dataIndex: 'phone', width: 120 },
{
title: '状态', dataIndex: 'status', width: 90,
render: (s) => <Tag>{STORE_STATUS_LABELS[s] || s}</Tag>,
},
{ title: '开城合伙人', dataIndex: ['partner', 'companyName'], width: 120 },
{ title: '介绍', dataIndex: 'intro', width: 160, ellipsis: true, render: (v) => v || '—' },
{ title: '店长', dataIndex: ['account', 'name'], width: 90, render: (v) => v || '—' },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作', width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={async () => {
const d = await request<Record<string, unknown>>(`/admin/stores/${row.id}`);
setDetail(d);
editForm.setFieldsValue({ name: d.name, phone: d.phone, intro: d.intro, coverUrl: d.coverUrl, address: d.address, district: d.district });
setDrawerOpen(true);
}}>详情</Button>
),
},
];
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"> {data?.total ?? 0} 家门店(含合伙人录入)</Typography.Text>
</Space>
<Button type="primary" onClick={openCreateModal}>新建门店</Button>
</Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
<Form.Item name="phone" label="电话"><Input allowClear /></Form.Item>
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 100 }} placeholder="全部" options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setPage(1); }}>重置</Button></Form.Item>
</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)}
extra={detail && (
<Space>
<Select defaultValue={String(detail.status)} style={{ width: 120 }}
options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
onChange={async (status) => {
await request(`/admin/stores/${detail.id}/status`, { method: 'PUT', body: JSON.stringify({ status }) });
message.success('状态已更新');
setDetail({ ...detail, status });
void reload();
}} />
<Button type="primary" onClick={async () => {
const v = await editForm.validateFields();
await request(`/admin/stores/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
message.success('已保存');
setDetail({ ...detail, ...v });
void reload();
}}>保存</Button>
</Space>
)}>
{detail && (
<>
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
<Descriptions.Item label="地址">{String(detail.province)}{String(detail.cityName)}{String(detail.district)}{String(detail.address)}</Descriptions.Item>
<Descriptions.Item label="核销数">{String(detail.redeemCount ?? 0)}</Descriptions.Item>
<Descriptions.Item label="操作">
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => navigate(`/logs/stores?storeId=${detail.id}`)}>
查看商户日志
</Button>
</Descriptions.Item>
{detail.coverUrl ? (
<Descriptions.Item label="封面">
<Image src={String(detail.coverUrl)} width={120} />
</Descriptions.Item>
) : null}
</Descriptions>
<Form form={editForm} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="电话" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="coverUrl" label="封面图">
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
</Form.Item>
<Form.Item name="intro" label="介绍"><Input.TextArea rows={4} /></Form.Item>
<Form.Item name="district" label="区县"><Input /></Form.Item>
<Form.Item name="address" label="详细地址"><Input /></Form.Item>
</Form>
</>
)}
</Drawer>
<Modal
title="新建门店"
open={createOpen}
width={640}
onCancel={closeCreateModal}
destroyOnClose
footer={(
<Space>
{createStep > 0 && <Button onClick={() => { setCreateError(''); setCreateStep((s) => s - 1); }}>上一步</Button>}
{createStep < 2 ? (
<Button type="primary" onClick={() => void handleCreateNext()}>下一步</Button>
) : (
<Button type="primary" onClick={() => void handleCreateSubmit()}>提交</Button>
)}
</Space>
)}
>
<Steps current={createStep} items={CREATE_STEPS} style={{ marginBottom: 24 }} />
{createError && (
<Alert type="error" message={createError} showIcon style={{ marginBottom: 16 }} />
)}
<Form form={createForm} layout="vertical" preserve>
<div style={{ display: createStep === 0 ? 'block' : 'none' }}>
<Form.Item name="partnerId" label="开城合伙人" rules={[{ required: true, message: '请选择开城合伙人' }]}>
<Select
showSearch
loading={optionsLoading}
optionFilterProp="label"
placeholder={optionsLoading ? '加载中…' : '请选择开城合伙人'}
options={partners.map((p) => ({ value: p.id, label: p.companyName }))}
onChange={(partnerId) => {
const codes = createForm.getFieldValue('regionCodes') as string[] | undefined;
if (codes?.length === 3) bindRegionSelection(codes, partnerId);
else createForm.setFieldValue('cityId', undefined);
}}
/>
</Form.Item>
<Form.Item
name="regionCodes"
label="所在地区"
rules={[{ required: true, message: '请选择省 / 市 / 区县' }]}
extra={regionBindingHint ? (
<Typography.Text type={selectedCityId ? 'secondary' : 'warning'}>
{regionBindingHint}
</Typography.Text>
) : null}
>
<ChinaRegionCascader onChange={(codes) => bindRegionSelection(codes)} />
</Form.Item>
<Form.Item name="cityId" hidden rules={[{ required: true, message: '请选择所在地区以匹配开城城市' }]}>
<Input />
</Form.Item>
<Form.Item name="province" hidden><Input /></Form.Item>
<Form.Item name="city" hidden><Input /></Form.Item>
<Form.Item name="district" hidden><Input /></Form.Item>
<Form.Item name="districtCode" hidden><Input /></Form.Item>
<Form.Item name="name" label="门店名称" rules={[{ required: true, message: '请填写门店名称' }]}>
<Input placeholder="请输入门店名称" />
</Form.Item>
<Form.Item name="phone" label="门店手机号(登录账号)" rules={[{ required: true, message: '请填写门店手机号' }]}>
<Input placeholder="11位手机号" />
</Form.Item>
<Form.Item label="短信验证" required>
<Space.Compact style={{ width: '100%' }}>
<Form.Item name="smsCode" noStyle rules={[{ required: true, message: '请填写验证码' }]}>
<Input placeholder="验证码" maxLength={6} />
</Form.Item>
<Button disabled={smsCooldown > 0} onClick={() => void sendCreateSms()}>
{smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'}
</Button>
</Space.Compact>
</Form.Item>
<Form.Item name="address" label="详细地址" rules={[{ required: true, message: '请填写详细地址' }]}>
<Input.TextArea rows={2} placeholder="请输入详细门牌号" />
</Form.Item>
<Form.Item name="intro" label="门店简介">
<Input.TextArea rows={3} placeholder="选填,10~500字" showCount maxLength={500} />
</Form.Item>
</div>
<div style={{ display: createStep === 1 ? 'block' : 'none' }}>
<Typography.Paragraph type="secondary">
preV1 照片上传为选填,可直接下一步(与合伙人端一致)。
</Typography.Paragraph>
<Form.Item name="coverUrl" label="门头照">
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
</Form.Item>
<Typography.Text strong>环境照片</Typography.Text>
<Form.List name="envPhotoUrls">
{(fields) => (
<div style={{ marginTop: 8 }}>
{fields.map((field, index) => (
<Form.Item key={field.key} name={field.name} label={`环境图 ${index + 1}`}>
<OssUpload bizType="STORE_ENV" mediaType="IMAGE" />
</Form.Item>
))}
</div>
)}
</Form.List>
<Form.Item name="contractUrl" label="签约合同">
<OssUpload bizType="STORE_CONTRACT" mediaType="FILE" accept="image/*,.pdf" />
</Form.Item>
</div>
<div style={{ display: createStep === 2 ? 'block' : 'none' }}>
<Form.Item name="bankAccountName" label="户主姓名" rules={[{ required: true, message: '请填写户主姓名' }]}>
<Input placeholder="银行卡实名姓名" />
</Form.Item>
<Form.Item name="bankAccountNo" label="银行卡号" rules={[{ required: true, message: '请填写银行卡号' }]}>
<Input placeholder="16~19位银行卡号" />
</Form.Item>
<Form.Item name="bankBranch" label="开户支行" rules={[{ required: true, message: '请填写开户支行' }]}>
<Input placeholder="例如:中国工商银行洛阳分行" />
</Form.Item>
<Alert
type="info"
showIcon
message="请确保银行卡信息准确,以免影响门店餐费结算。"
/>
</div>
</Form>
</Modal>
</div>
);
}