Files
dukang/apps/admin-web/src/pages/StoresPage.tsx
T
jacy 76375eff83 feat(v3.4.15): HQ store media edit and package images up to 20
Allow HQ to replace/delete store photos; support multi-image packages with a 20-image cap.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 19:24:07 +08:00

1389 lines
57 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 { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Alert,
Button,
Checkbox,
Descriptions,
Drawer,
Form,
Image,
Input,
InputNumber,
Modal,
Select,
Space,
Steps,
Switch,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { FormInstance } from 'antd/es/form';
import { EnvironmentOutlined } 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 {
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';
import TencentLocPickerModal from '../components/TencentLocPickerModal';
import AdminStorePackagesSection from '../components/AdminStorePackagesSection';
const CREATE_STEPS = [
{ title: '基本信息' },
{ title: '照片上传' },
{ title: '结算资质' },
];
function fillGeolocation(
setCoords: (lat: number, lng: number) => void,
setLoading: (v: boolean) => void,
) {
if (!navigator.geolocation) {
message.error('当前浏览器不支持定位');
return;
}
setLoading(true);
navigator.geolocation.getCurrentPosition(
(pos) => {
setCoords(pos.coords.latitude, pos.coords.longitude);
message.success(
`已获取坐标 ${pos.coords.latitude.toFixed(6)}, ${pos.coords.longitude.toFixed(6)}`,
);
setLoading(false);
},
(err) => {
message.error(err.message || '定位失败');
setLoading(false);
},
{ enableHighAccuracy: true, timeout: 10000 },
);
}
type StoreMediaItem = {
id?: string;
bizType?: string;
mediaType?: string;
url?: string | null;
};
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 StoreAuditMediaEditor() {
return (
<div style={{ marginBottom: 16 }}>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="可替换或删除门头照 / 环境照 / 签约合同,点击右上角「保存修改」后生效。环境照最多 20 张。"
/>
<Form.Item name="coverUrl" label="门头照">
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
</Form.Item>
<Typography.Text strong>环境照片</Typography.Text>
<Typography.Paragraph type="secondary" style={{ marginTop: 4 }}>
建议至少 3 张;可替换、删除,最多 20
</Typography.Paragraph>
<Form.List name="envPhotoUrls">
{(fields, { add, remove }) => (
<div style={{ marginTop: 8 }}>
{fields.map((field, index) => (
<div key={field.key} style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
<Form.Item
name={field.name}
label={`环境图 ${index + 1}`}
style={{ flex: 1, marginBottom: 12 }}
>
<OssUpload bizType="STORE_ENV" mediaType="IMAGE" />
</Form.Item>
<Button
type="link"
danger
style={{ marginTop: 30 }}
onClick={() => remove(field.name)}
>
删除
</Button>
</div>
))}
{fields.length < 20 ? (
<Button type="dashed" onClick={() => add('')} block>
添加环境照片
</Button>
) : (
<Typography.Text type="secondary">已达上限 20 </Typography.Text>
)}
</div>
)}
</Form.List>
<Form.Item name="contractUrl" label="签约合同" style={{ marginTop: 16 }}>
<OssUpload bizType="STORE_CONTRACT" mediaType="FILE" accept="image/*,.pdf" />
</Form.Item>
</div>
);
}
function StoreVisibilityWhitelistFields({ form }: { form: FormInstance }) {
const enabled = Form.useWatch('visibilityWhitelistEnabled', form);
return (
<>
<Form.Item
name="visibilityWhitelistEnabled"
label="可见白名单"
valuePropName="checked"
extra="开启后仅全局测试白名单内手机号在 C 端可见,用于在线测试"
>
<Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
{enabled ? (
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
可见手机号见白名单管理
</Typography.Text>
) : null}
</>
);
}
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;
visibilityWhitelistEnabled?: boolean;
visibilityPhones?: string[];
isTest?: boolean;
cityRef?: { name: string; code: string };
partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null };
account?: {
phone: string;
name: string;
status: string;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
};
openTime?: string | null;
closeTime?: string | null;
openTime2?: string | null;
closeTime2?: string | null;
avgPrice?: number | null;
settlementRate?: number;
category?: { id: string; name: string; parentId?: string | null } | null;
};
type PartnerOption = { id: string; companyName?: string | null; name?: string | null; phone?: string | null };
function partnerOptionLabel(p: PartnerOption): string {
const company = (p.companyName || '').trim();
const person = (p.name || '').trim();
const phone = (p.phone || '').trim();
if (company && person) return `${company}-${person}`;
return company || person || phone || p.id;
}
type CityOption = {
id: string;
name: string;
code: string;
partnerBindings?: Array<{ partnerAccountId: string; partnerCompanyName?: string }>;
};
type CategoryNode = {
id: string;
name: string;
status?: string;
children?: CategoryNode[];
};
export default function StoresPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const initialCityId = searchParams.get('cityId') ?? '';
const initialPartnerId = searchParams.get('partnerId') ?? '';
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [createForm] = Form.useForm<StoreCreateForm>();
const [filters, setFilters] = useState<Record<string, string | boolean>>(() => {
const init: Record<string, string | boolean> = {};
if (initialCityId) init.cityId = initialCityId;
if (initialPartnerId) init.partnerId = initialPartnerId;
return init;
});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<StoreRow>(
'/admin/stores',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', String(filters.name));
if (filters.status) qs.set('status', String(filters.status));
if (filters.auditStatus) qs.set('auditStatus', String(filters.auditStatus));
if (filters.phone) qs.set('phone', String(filters.phone));
if (filters.cityId) qs.set('cityId', String(filters.cityId));
if (filters.partnerId) qs.set('partnerId', String(filters.partnerId));
if (filters.excludeTest) qs.set('excludeTest', 'true');
return qs;
},
[filters],
);
const [filterCities, setFilterCities] = useState<CityOption[]>([]);
const [filterPartners, setFilterPartners] = useState<PartnerOption[]>([]);
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 [locating, setLocating] = useState(false);
const [mapPickerOpen, setMapPickerOpen] = useState(false);
const [mapPickerTarget, setMapPickerTarget] = useState<'create' | 'edit'>('create');
const [partners, setPartners] = useState<PartnerOption[]>([]);
const [cities, setCities] = useState<CityOption[]>([]);
const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]);
const [optionsLoading, setOptionsLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [phoneMismatch, setPhoneMismatch] = useState<string | null>(null);
useEffect(() => {
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setFilterCities(res.items))
.catch(() => {});
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setFilterPartners(res.items))
.catch(() => {});
}, []);
useEffect(() => {
const cityId = searchParams.get('cityId') ?? '';
const partnerId = searchParams.get('partnerId') ?? '';
let changed = false;
setFilters((prev) => {
const next = { ...prev };
if ((prev.cityId ?? '') !== cityId) {
changed = true;
if (cityId) next.cityId = cityId;
else delete next.cityId;
}
if ((prev.partnerId ?? '') !== partnerId) {
changed = true;
if (partnerId) next.partnerId = partnerId;
else delete next.partnerId;
}
return changed ? next : prev;
});
form.setFieldsValue({
cityId: cityId || undefined,
partnerId: partnerId || undefined,
});
if (cityId || partnerId) setPage(1);
}, [searchParams, form, setPage]);
const selectedPartnerId = Form.useWatch('partnerAccountId', createForm);
const selectedRegionCodes = Form.useWatch('regionCodes', createForm);
const selectedCityId = Form.useWatch('cityId', createForm);
const selectedCategoryParentId = Form.useWatch('categoryParentId', createForm);
const editCategoryParentId = Form.useWatch('categoryParentId', editForm);
const categoryParentOptions = useMemo(
() =>
categoryTree
.filter((n) => n.status !== 'INACTIVE')
.map((n) => ({ value: n.id, label: n.name })),
[categoryTree],
);
const categoryChildOptions = useMemo(() => {
const parent = categoryTree.find((n) => n.id === selectedCategoryParentId);
return (parent?.children ?? [])
.filter((n) => n.status !== 'INACTIVE')
.map((n) => ({ value: n.id, label: n.name }));
}, [categoryTree, selectedCategoryParentId]);
const editCategoryChildOptions = useMemo(() => {
const parent = categoryTree.find((n) => n.id === editCategoryParentId);
return (parent?.children ?? [])
.filter((n) => n.status !== 'INACTIVE')
.map((n) => ({ value: n.id, label: n.name }));
}, [categoryTree, editCategoryParentId]);
async function openStoreDetail(row: StoreRow) {
const [d, cats] = await Promise.all([
request<Record<string, unknown>>(`/admin/stores/${row.id}`),
request<CategoryNode[]>('/admin/store-categories').catch(() => [] as CategoryNode[]),
]);
setCategoryTree(Array.isArray(cats) ? cats : []);
setDetail(d);
const category = d.category && typeof d.category === 'object'
? (d.category as { id?: string; parentId?: string | null })
: null;
const account = d.account && typeof d.account === 'object'
? (d.account as {
phone?: string | null;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
})
: null;
const categoryId = category?.id != null ? String(category.id) : undefined;
let parentId = category?.parentId != null ? String(category.parentId) : undefined;
if (!parentId && categoryId) {
for (const parent of cats) {
if (parent.id === categoryId) {
parentId = parent.id;
break;
}
if ((parent.children ?? []).some((c) => c.id === categoryId)) {
parentId = parent.id;
break;
}
}
}
const loginPhone =
(typeof d.loginPhone === 'string' && d.loginPhone) ||
account?.phone ||
(typeof d.phone === 'string' ? d.phone : undefined);
const storePhone = typeof d.phone === 'string' ? d.phone : undefined;
const phoneMismatchNow = !!(loginPhone && storePhone && loginPhone !== storePhone);
setPhoneMismatch(phoneMismatchNow ? String(loginPhone) : null);
editForm.setFieldsValue({
name: d.name,
// 以门店手机号为准保存;若与账号登录号不一致,保存时会强制同步到登录账号
phone: storePhone || loginPhone,
intro: d.intro,
benefitUsageRule:
d.benefitUsageRule != null &&
String(d.benefitUsageRule).trim() &&
!/^null$/i.test(String(d.benefitUsageRule).trim())
? String(d.benefitUsageRule)
: '',
coverUrl: d.coverUrl,
envPhotoUrls: (() => {
const { envs } = collectMediaUrls(d);
const urls = envs.map((item) => item.url).filter(Boolean);
return urls.length ? urls : [''];
})(),
contractUrl: (() => {
const { contracts } = collectMediaUrls(d);
return contracts[0]?.url || '';
})(),
province: d.province,
city: d.cityName,
district: d.district,
address: d.address,
categoryParentId: parentId,
categoryId,
latitude: d.latitude != null ? Number(d.latitude) : undefined,
longitude: d.longitude != null ? Number(d.longitude) : undefined,
settlementRate: d.settlementRate != null ? Number(d.settlementRate) * 100 : 60,
openTime: d.openTime || '10:00',
closeTime: d.closeTime || '22:00',
openTime2: d.openTime2 || undefined,
closeTime2: d.closeTime2 || undefined,
avgPrice: d.avgPrice != null ? Number(d.avgPrice) : undefined,
bankAccountName: account?.bankAccountName || undefined,
bankAccountNo: account?.bankAccountNo || undefined,
bankBranch: account?.bankBranch || undefined,
visibilityWhitelistEnabled: !!d.visibilityWhitelistEnabled,
visibilityPhones: Array.isArray(d.visibilityPhones)
? (d.visibilityPhones as string[])
: [],
isTest: !!d.isTest,
});
setDrawerOpen(true);
}
async function saveStoreDetail() {
if (!detail) return;
setSaving(true);
try {
const v = await editForm.validateFields();
const hasCoords =
v.latitude != null &&
v.longitude != null &&
Number.isFinite(Number(v.latitude)) &&
Number.isFinite(Number(v.longitude));
const payload = {
name: v.name,
phone: v.phone,
coverUrl: v.coverUrl ?? '',
envPhotoUrls: Array.isArray(v.envPhotoUrls)
? v.envPhotoUrls.map((u: string) => String(u || '').trim()).filter(Boolean)
: [],
contractUrl: v.contractUrl ?? '',
intro: v.intro,
benefitUsageRule:
typeof v.benefitUsageRule === 'string' &&
v.benefitUsageRule.trim() &&
!/^null$/i.test(v.benefitUsageRule.trim())
? v.benefitUsageRule.trim()
: null,
categoryId: v.categoryId,
province: v.province,
city: v.city,
district: v.district,
address: v.address,
avgPrice: v.avgPrice,
openTime: v.openTime,
closeTime: v.closeTime,
openTime2: v.openTime2 || null,
closeTime2: v.closeTime2 || null,
settlementRate: v.settlementRate != null ? Number(v.settlementRate) / 100 : undefined,
bankAccountName: v.bankAccountName ?? null,
bankAccountNo: v.bankAccountNo ?? null,
bankBranch: v.bankBranch ?? null,
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
isTest: !!v.isTest,
...(hasCoords
? { latitude: Number(v.latitude), longitude: Number(v.longitude) }
: {}),
};
const updated = await request<Record<string, unknown>>(`/admin/stores/${detail.id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
message.success('门店信息已保存');
setDetail(updated);
setPhoneMismatch(null);
void reload();
} catch (e) {
if (e && typeof e === 'object' && 'errorFields' in e) return;
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
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(opts?: { quiet?: boolean }) {
setOptionsLoading(true);
try {
const qs = `pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`;
const [p, c, cats] = await Promise.all([
request<Paginated<PartnerOption>>(`/admin/partners?${qs}`),
request<Paginated<CityOption>>(`/admin/cities?${qs}`),
request<CategoryNode[]>('/admin/store-categories'),
]);
setPartners(p.items);
setCities(c.items);
setCategoryTree(Array.isArray(cats) ? cats : []);
if (!opts?.quiet) {
if (!p.items.length) message.warning('暂无开城合伙人,请先在「开城 → 城市」详情中创建合伙人');
if (!c.items.length) message.warning('暂无开城城市,请先在「开城 → 城市」中创建');
if (!Array.isArray(cats) || !cats.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,
openTime: '10:00',
closeTime: '22:00',
openTime2: undefined,
closeTime2: undefined,
avgPrice: undefined,
visibilityWhitelistEnabled: false,
visibilityPhones: [],
});
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',
'categoryParentId',
'categoryId',
'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,
categoryId: values.categoryId,
province: values.province,
city: values.city,
name: values.name.trim(),
phone: values.phone.trim(),
district: values.district.trim(),
address: values.address.trim(),
...(values.latitude != null &&
values.longitude != null &&
Number.isFinite(Number(values.latitude)) &&
Number.isFinite(Number(values.longitude))
? {
latitude: Number(values.latitude),
longitude: Number(values.longitude),
}
: {}),
intro: values.intro?.trim() || undefined,
benefitUsageRule: values.benefitUsageRule?.trim() || undefined,
openTime: values.openTime?.trim() || '10:00',
closeTime: values.closeTime?.trim() || '22:00',
...(values.openTime2?.trim() && values.closeTime2?.trim()
? { openTime2: values.openTime2.trim(), closeTime2: values.closeTime2.trim() }
: {}),
...(values.avgPrice != null && values.avgPrice !== ''
? { avgPrice: Number(values.avgPrice) }
: {}),
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,
visibilityWhitelistEnabled: !!values.visibilityWhitelistEnabled,
}),
});
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: 160, render: (v, row) => (
<Space size={4}>
<span>{v}</span>
{row.isTest ? <Tag color="orange">测试</Tag> : null}
</Space>
) },
{
title: '分类',
width: 100,
render: (_, row) => row.category?.name || '—',
},
{ 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',
width: 140,
render: (partner: StoreRow['partner']) =>
partner ? partnerOptionLabel({ id: partner.id ?? '', ...partner }) : '—',
},
{
title: '可见',
dataIndex: 'visibilityWhitelistEnabled',
width: 90,
render: (v) =>
v ? <Tag color="orange">限测</Tag> : <Tag>公开</Tag>,
},
{ 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: 140,
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => void openStoreDetail(row)}>详情</Button>
<Button type="link" size="small" onClick={() => navigate(`/store-ratings?storeId=${row.id}`)}>评价</Button>
</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"> {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="cityId" label="城市">
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ width: 140 }}
placeholder="全部"
options={filterCities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item>
<Form.Item name="partnerId" label="城市合伙人">
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ width: 180 }}
placeholder="全部"
options={filterPartners.map((p) => ({ value: p.id, label: partnerOptionLabel(p) }))}
/>
</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 name="excludeTest" valuePropName="checked">
<Checkbox>过滤测试账号</Checkbox>
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
<Form.Item>
<Button
onClick={() => {
form.resetFields();
setFilters({});
setPage(1);
if (searchParams.has('cityId') || searchParams.has('partnerId')) {
navigate('/stores', { replace: true });
}
}}
>
重置
</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={760} 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 value={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" loading={saving} onClick={() => void saveStoreDetail()}>保存修改</Button>
</Space>
)}>
{detail && (
<Form form={editForm} layout="vertical">
<Tabs
destroyInactiveTabPane={false}
items={[
{
key: 'basic',
label: '基本信息',
forceRender: true,
children: (
<>
<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.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>
{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>
<Typography.Title level={5} style={{ marginTop: 0 }}>编辑门店信息</Typography.Title>
{phoneMismatch ? (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message={`登录账号手机号仍为 ${phoneMismatch},与门店手机号不一致。请点击右上角「保存修改」同步,否则门店端无法用新号登录。`}
/>
) : null}
<Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item
name="phone"
label="登录手机号"
rules={[{ required: true }]}
extra="门店端短信登录使用此号码;修改后需用新号重新登录"
>
<Input />
</Form.Item>
<Form.Item
name="categoryParentId"
label="门店分类(大类)"
rules={[{ required: true, message: '请选择门店大类' }]}
>
<Select
showSearch
loading={optionsLoading}
optionFilterProp="label"
options={categoryParentOptions}
onChange={() => editForm.setFieldValue('categoryId', undefined)}
/>
</Form.Item>
<Form.Item
name="categoryId"
label="门店分类(细类)"
rules={[{ required: true, message: '请选择门店细类' }]}
>
<Select
showSearch
optionFilterProp="label"
placeholder={editCategoryParentId ? '选择细类' : '请先选大类'}
disabled={!editCategoryParentId}
options={editCategoryChildOptions}
/>
</Form.Item>
<Form.Item name="coverUrl" label="封面图 / 门头照">
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
</Form.Item>
<Form.Item name="intro" label="介绍"><Input.TextArea rows={4} showCount maxLength={500} /></Form.Item>
<Form.Item
name="benefitUsageRule"
label="好客权益券使用规则"
extra="展示在用户端门店详情「门店详情」下方"
>
<Input.TextArea rows={4} placeholder="选填,最多1000字" showCount maxLength={1000} />
</Form.Item>
<Space wrap style={{ width: '100%' }}>
<Form.Item name="province" label="省份" rules={[{ required: true }]}>
<Input style={{ width: 140 }} />
</Form.Item>
<Form.Item name="city" label="城市" rules={[{ required: true }]}>
<Input style={{ width: 140 }} />
</Form.Item>
<Form.Item name="district" label="区县" rules={[{ required: true }]}>
<Input style={{ width: 140 }} />
</Form.Item>
</Space>
<Form.Item name="address" label="详细地址" rules={[{ required: true }]}>
<Input.TextArea rows={2} />
</Form.Item>
<Space wrap style={{ width: '100%' }}>
<Form.Item name="latitude" label="纬度" style={{ marginBottom: 8 }}>
<InputNumber
style={{ width: 180 }}
precision={7}
step={0.000001}
placeholder="如 34.7466000"
/>
</Form.Item>
<Form.Item name="longitude" label="经度" style={{ marginBottom: 8 }}>
<InputNumber
style={{ width: 180 }}
precision={7}
step={0.000001}
placeholder="如 113.6253000"
/>
</Form.Item>
</Space>
<Space wrap style={{ marginBottom: 16 }}>
<Button
loading={locating}
onClick={() => {
fillGeolocation(
(lat, lng) => editForm.setFieldsValue({ latitude: lat, longitude: lng }),
setLocating,
);
}}
>
获取当前位置
</Button>
<Button
icon={<EnvironmentOutlined />}
onClick={() => {
setMapPickerTarget('edit');
setMapPickerOpen(true);
}}
>
腾讯地图选点
</Button>
</Space>
<Form.Item name="avgPrice" label="人均费用(选填)">
<InputNumber min={0} precision={0} style={{ width: '100%' }} addonAfter="元" placeholder="用户端展示" />
</Form.Item>
<Space wrap style={{ width: '100%' }}>
<Form.Item name="openTime" label="营业开始" rules={[{ required: true }]}>
<Input type="time" style={{ width: 140 }} />
</Form.Item>
<Form.Item name="closeTime" label="营业结束" rules={[{ required: true }]}>
<Input type="time" style={{ width: 140 }} />
</Form.Item>
</Space>
<Space wrap style={{ width: '100%' }}>
<Form.Item name="openTime2" label="第二段开始(选填)">
<Input type="time" style={{ width: 140 }} />
</Form.Item>
<Form.Item name="closeTime2" label="第二段结束">
<Input type="time" style={{ width: 140 }} />
</Form.Item>
</Space>
<StoreVisibilityWhitelistFields form={editForm} />
<Form.Item
name="isTest"
label="测试门店"
valuePropName="checked"
extra="测试门店核销不计入结算账单;联系电话命中全局白名单时会自动标记"
>
<Switch checkedChildren="是" unCheckedChildren="否" />
</Form.Item>
</>
),
},
{
key: 'settlement',
label: '结算资质',
forceRender: true,
children: (
<>
<Form.Item name="settlementRate" label="核销结算比例 %" initialValue={60} rules={[{ required: true }]}>
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} addonAfter="%" />
</Form.Item>
<Form.Item name="bankAccountName" label="结算户名">
<Input placeholder="开户名" />
</Form.Item>
<Form.Item name="bankBranch" label="开户银行">
<Input placeholder="如 中国工商银行某某支行" />
</Form.Item>
<Form.Item
name="bankAccountNo"
label="银行卡号"
rules={[
{
validator: async (_, value) => {
const v = String(value || '').trim();
if (!v) return;
if (!/^\d{16,19}$/.test(v)) {
throw new Error('银行卡号须为 1619 位数字');
}
},
},
]}
>
<Input placeholder="1619 位数字" maxLength={19} />
</Form.Item>
<Typography.Paragraph type="secondary">
修改后点右上角「保存修改」一并提交。
</Typography.Paragraph>
</>
),
},
{
key: 'media',
label: '审核材料',
forceRender: true,
children: <StoreAuditMediaEditor />,
},
{
key: 'packages',
label: '套餐',
children: <AdminStorePackagesSection storeId={String(detail.id)} />,
},
]}
/>
</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: partnerOptionLabel(p) }))}
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="categoryParentId"
label="门店分类(大类)"
rules={[{ required: true, message: '请选择门店大类' }]}
>
<Select
showSearch
loading={optionsLoading}
optionFilterProp="label"
placeholder={optionsLoading ? '加载中…' : '选择大类'}
options={categoryParentOptions}
onChange={() => createForm.setFieldValue('categoryId', undefined)}
/>
</Form.Item>
<Form.Item
name="categoryId"
label="门店分类(细类)"
rules={[{ required: true, message: '请选择门店细类' }]}
extra={
<Typography.Link onClick={() => navigate('/store-categories')}>
去配置门店分类
</Typography.Link>
}
>
<Select
showSearch
optionFilterProp="label"
placeholder={selectedCategoryParentId ? '选择细类' : '请先选大类'}
disabled={!selectedCategoryParentId}
options={categoryChildOptions}
/>
</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>
<Space wrap style={{ width: '100%' }}>
<Form.Item name="latitude" label="纬度" style={{ marginBottom: 8 }}>
<InputNumber
style={{ width: 180 }}
precision={7}
step={0.000001}
placeholder="如 34.7466000"
/>
</Form.Item>
<Form.Item name="longitude" label="经度" style={{ marginBottom: 8 }}>
<InputNumber
style={{ width: 180 }}
precision={7}
step={0.000001}
placeholder="如 113.6253000"
/>
</Form.Item>
</Space>
<Space wrap style={{ marginBottom: 16 }}>
<Button
loading={locating}
onClick={() => {
fillGeolocation(
(lat, lng) => createForm.setFieldsValue({ latitude: lat, longitude: lng }),
setLocating,
);
}}
>
获取当前位置
</Button>
<Button
icon={<EnvironmentOutlined />}
onClick={() => {
setMapPickerTarget('create');
setMapPickerOpen(true);
}}
>
腾讯地图选点
</Button>
<Typography.Text type="secondary">
可手动填写 / 定位 / 腾讯地图选点填入坐标
</Typography.Text>
</Space>
<Space wrap style={{ width: '100%' }}>
<Form.Item name="openTime" label="营业开始" rules={[{ required: true, message: '请填写营业开始时间' }]}>
<Input type="time" style={{ width: 140 }} />
</Form.Item>
<Form.Item name="closeTime" label="营业结束" rules={[{ required: true, message: '请填写营业结束时间' }]}>
<Input type="time" style={{ width: 140 }} />
</Form.Item>
</Space>
<Space wrap style={{ width: '100%' }}>
<Form.Item name="openTime2" label="第二段开始(选填)">
<Input type="time" style={{ width: 140 }} />
</Form.Item>
<Form.Item name="closeTime2" label="第二段结束">
<Input type="time" style={{ width: 140 }} />
</Form.Item>
</Space>
<Form.Item name="avgPrice" label="人均费用(选填)">
<InputNumber min={0} precision={0} style={{ width: '100%' }} addonAfter="元" placeholder="用户端展示" />
</Form.Item>
<Form.Item name="intro" label="门店简介">
<Input.TextArea rows={3} placeholder="选填,2~500字" showCount maxLength={500} />
</Form.Item>
<Form.Item
name="benefitUsageRule"
label="好客权益券使用规则"
extra="展示在用户端门店详情"
>
<Input.TextArea rows={3} placeholder="选填,最多1000字" showCount maxLength={1000} />
</Form.Item>
<StoreVisibilityWhitelistFields form={createForm} />
</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>
<Typography.Paragraph type="secondary" style={{ marginTop: 4 }}>
至少 3 张,可继续添加,最多 20
</Typography.Paragraph>
<Form.List name="envPhotoUrls">
{(fields, { add, remove }) => (
<div style={{ marginTop: 8 }}>
{fields.map((field, index) => (
<div key={field.key} style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
<Form.Item key={field.key} name={field.name} label={`环境图 ${index + 1}`} style={{ flex: 1 }}>
<OssUpload bizType="STORE_ENV" mediaType="IMAGE" />
</Form.Item>
<Button type="link" danger style={{ marginTop: 30 }} onClick={() => remove(field.name)}>
删除
</Button>
</div>
))}
{fields.length < 20 ? (
<Button type="dashed" onClick={() => add('')} block>
添加环境照片
</Button>
) : (
<Typography.Text type="secondary">已达上限 20 </Typography.Text>
)}
</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>
<TencentLocPickerModal
open={mapPickerOpen}
onClose={() => setMapPickerOpen(false)}
latitude={
mapPickerTarget === 'edit'
? Number(editForm.getFieldValue('latitude') ?? detail?.latitude)
: Number(createForm.getFieldValue('latitude'))
}
longitude={
mapPickerTarget === 'edit'
? Number(editForm.getFieldValue('longitude') ?? detail?.longitude)
: Number(createForm.getFieldValue('longitude'))
}
onPick={(loc) => {
if (mapPickerTarget === 'edit') {
editForm.setFieldsValue({
latitude: loc.latitude,
longitude: loc.longitude,
...(loc.address && !editForm.getFieldValue('address')
? { address: loc.address }
: {}),
});
} else {
createForm.setFieldsValue({
latitude: loc.latitude,
longitude: loc.longitude,
...(loc.address && !createForm.getFieldValue('address')
? { address: loc.address }
: {}),
});
}
message.success(
`已选点 ${loc.latitude.toFixed(6)}, ${loc.longitude.toFixed(6)}${loc.name ? `${loc.name}` : ''}`,
);
}}
/>
</div>
);
}