Files
dukang/apps/admin-web/src/pages/StoresPage.tsx
T
2026-07-14 15:25:17 +08:00

576 lines
25 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,
InputNumber,
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_AUDIT_STATUS_LABELS, 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;
auditStatus?: string;
rejectReason?: string | null;
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;
partnerBindings?: Array<{ partnerAccountId: string; partnerCompanyName?: 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.auditStatus) qs.set('auditStatus', filters.auditStatus);
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 [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const [auditing, setAuditing] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [createStep, setCreateStep] = useState(0);
const [createError, setCreateError] = useState('');
const [partners, setPartners] = useState<PartnerOption[]>([]);
const [cities, setCities] = useState<CityOption[]>([]);
const [optionsLoading, setOptionsLoading] = useState(false);
const selectedPartnerId = Form.useWatch('partnerAccountId', createForm);
const selectedRegionCodes = Form.useWatch('regionCodes', createForm);
const selectedCityId = Form.useWatch('cityId', createForm);
function bindRegionSelection(codes: string[], partnerAccountId?: string) {
const binding = resolveRegionBinding(codes, cities, partnerAccountId ?? 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('');
createForm.resetFields();
}
function openCreateModal() {
void loadOptions();
createForm.setFieldsValue({
envPhotoUrls: ['', '', ''],
settlementRate: 60,
});
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(['partnerAccountId', 'regionCodes', 'cityId', 'name', 'phone', '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({
partnerAccountId: values.partnerAccountId,
cityId: values.cityId,
province: values.province,
city: values.city,
name: values.name.trim(),
phone: values.phone.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(),
settlementRate: Number(values.settlementRate ?? 60) / 100,
}),
});
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 === 'partnerAccountId' || first === 'cityId' || first === 'regionCodes' || first === 'name' || first === 'phone') {
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: 'auditStatus', width: 100,
render: (s, row) => {
const status = s || 'APPROVED';
const color = status === 'PENDING' ? 'orange' : status === 'REJECTED' ? 'red' : 'green';
return (
<Space direction="vertical" size={0}>
<Tag color={color}>{STORE_AUDIT_STATUS_LABELS[status] || status}</Tag>
{status === 'REJECTED' && row.rejectReason ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }} ellipsis>
{row.rejectReason}
</Typography.Text>
) : null}
</Space>
);
},
},
{ 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,
settlementRate: d.settlementRate != null ? Number(d.settlementRate) * 100 : 60,
});
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 name="auditStatus" label="审核">
<Select
allowClear
style={{ width: 110 }}
placeholder="全部"
options={Object.entries(STORE_AUDIT_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 wrap>
{String(detail.auditStatus || '') === 'PENDING' || String(detail.auditStatus || '') === 'REJECTED' ? (
<>
<Button
type="primary"
loading={auditing}
onClick={async () => {
setAuditing(true);
try {
const updated = await request<Record<string, unknown>>(`/admin/stores/${detail.id}/audit`, {
method: 'PUT',
body: JSON.stringify({ approved: true, remark: '审核通过' }),
});
message.success('已通过审核,合伙人可开门营业');
setDetail({ ...detail, ...updated, auditStatus: 'APPROVED', rejectReason: null });
void reload();
} finally {
setAuditing(false);
}
}}
>
通过
</Button>
<Button
danger
loading={auditing}
onClick={() => {
setRejectReason('');
setRejectOpen(true);
}}
>
驳回
</Button>
</>
) : null}
<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();
const payload = {
...v,
settlementRate: v.settlementRate != null ? Number(v.settlementRate) / 100 : undefined,
};
await request(`/admin/stores/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
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="审核状态">
<Tag color={
String(detail.auditStatus) === 'PENDING' ? 'orange'
: String(detail.auditStatus) === 'REJECTED' ? 'red' : 'green'
}>
{STORE_AUDIT_STATUS_LABELS[String(detail.auditStatus || 'APPROVED')] || String(detail.auditStatus)}
</Tag>
</Descriptions.Item>
{String(detail.auditStatus) === 'REJECTED' ? (
<Descriptions.Item label="驳回原因">{String(detail.rejectReason || '—')}</Descriptions.Item>
) : null}
<Descriptions.Item label="地址">{String(detail.province)}{String(detail.cityName)}{String(detail.district)}{String(detail.address)}</Descriptions.Item>
<Descriptions.Item label="核销结算比例">
{detail.settlementRate != null ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '60%'}
</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}
{Array.isArray(detail.audits) && (detail.audits as Array<Record<string, unknown>>).length > 0 ? (
<Descriptions.Item label="审核记录">
<Space direction="vertical" size={4} style={{ width: '100%' }}>
{(detail.audits as Array<Record<string, unknown>>).map((a) => (
<Typography.Text key={String(a.id)} style={{ fontSize: 12 }}>
{fmtTime(String(a.createdAt))} · {String(a.status)} · {String(a.remark || '—')}
</Typography.Text>
))}
</Space>
</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.Item name="settlementRate" label="核销结算比例 %" initialValue={60} rules={[{ required: true }]}>
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} addonAfter="%" />
</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="partnerAccountId" label="开城合伙人" rules={[{ required: true, message: '请选择开城合伙人' }]}>
<Select
showSearch
loading={optionsLoading}
optionFilterProp="label"
placeholder={optionsLoading ? '加载中…' : '请选择开城合伙人'}
options={partners.map((p) => ({ value: p.id, label: p.companyName }))}
onChange={(partnerAccountId) => {
const codes = createForm.getFieldValue('regionCodes') as string[] | undefined;
if (codes?.length === 3) bindRegionSelection(codes, partnerAccountId);
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 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>
<Form.Item name="settlementRate" label="核销结算比例 %" initialValue={60} rules={[{ required: true, message: '请填写结算比例' }]}>
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} addonAfter="%" />
</Form.Item>
<Alert
type="info"
showIcon
message="请确保银行卡信息准确,以免影响门店餐费结算。"
/>
</div>
</Form>
</Modal>
<Modal
title="驳回门店审核"
open={rejectOpen}
okText="确认驳回"
okButtonProps={{ danger: true, loading: auditing, disabled: !rejectReason.trim() }}
onCancel={() => setRejectOpen(false)}
onOk={async () => {
if (!detail || !rejectReason.trim()) return;
setAuditing(true);
try {
const updated = await request<Record<string, unknown>>(`/admin/stores/${detail.id}/audit`, {
method: 'PUT',
body: JSON.stringify({ approved: false, remark: rejectReason.trim() }),
});
message.success('已驳回,原因已同步合伙人端');
setDetail({ ...detail, ...updated, auditStatus: 'REJECTED', rejectReason: rejectReason.trim() });
setRejectOpen(false);
void reload();
} finally {
setAuditing(false);
}
}}
>
<Typography.Paragraph type="secondary">驳回原因将展示给合伙人,请说明需修改的内容。</Typography.Paragraph>
<Input.TextArea
rows={4}
maxLength={200}
showCount
placeholder="请填写驳回原因"
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
/>
</Modal>
</div>
);
}