Files
dukang/apps/admin-web/src/pages/StoresPage.tsx
T
jacy 26334ed072
CI / verify (pull_request) Has been cancelled
v3.5.3版本更新2
2026-08-20 20:09:05 +08:00

1657 lines
66 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, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Alert,
Badge,
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 { isStoreContactPhone, STORE_CONTACT_PHONE_HINT } from '@dukang/domain';
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 MultiImageUpload from '../components/MultiImageUpload';
import TencentLocPickerModal from '../components/TencentLocPickerModal';
import AdminStorePackagesSection, {
type AdminStorePackagesHandle,
} from '../components/AdminStorePackagesSection';
import StorePackageAuditPanel, {
auditStorePackageRequest,
} from '../components/StorePackageAuditPanel';
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>
<Form.Item
name="envPhotoUrls"
label="环境照片"
extra="建议至少 3 张;支持批量上传,最多 20 张。可逐张删除后保存。"
>
<MultiImageUpload
bizType="STORE_ENV"
mediaType="IMAGE"
maxCount={20}
tip="环境照支持一次选择多张批量上传"
/>
</Form.Item>
<Form.Item
name="contractUrls"
label="签约合同"
style={{ marginTop: 16 }}
extra="支持多张合同照片(如首页、盖章页),也可上传 PDF,最多 20 个。"
>
<MultiImageUpload
bizType="STORE_CONTRACT"
mediaType="FILE"
accept="image/*,.pdf"
maxCount={20}
buttonText="批量上传合同"
tip="合同支持一次选择多张照片批量上传,最多 20 个"
/>
</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[];
/** 该门店当前待审核套餐变更的 requestId(无则为空) */
pendingPackageAuditId?: string | null;
/** 该门店当前待审核信息变更的 requestId(无则为空) */
pendingInfoChangeId?: string | null;
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;
sortOrder?: 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 initialAuditStatus = searchParams.get('auditStatus') ?? '';
const initialStoreId = searchParams.get('storeId') ?? '';
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;
if (initialAuditStatus) init.auditStatus = initialAuditStatus;
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 [detailTab, setDetailTab] = useState('basic');
const packagesRef = useRef<AdminStorePackagesHandle>(null);
const [packageRejectOpen, setPackageRejectOpen] = useState(false);
const [packageRejectReason, setPackageRejectReason] = useState('');
const [packageAuditing, setPackageAuditing] = 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);
const deepLinkStoreOpenedRef = useRef(false);
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(() => {
if (initialAuditStatus) {
form.setFieldsValue({ auditStatus: initialAuditStatus });
}
}, [form, initialAuditStatus]);
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, opts?: { tab?: string }) {
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,
pendingPackageAuditId: row.pendingPackageAuditId ?? d.pendingPackageAuditId,
pendingInfoChangeId: row.pendingInfoChangeId ?? d.pendingInfoChangeId,
});
setDetailTab(opts?.tab || 'basic');
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 contactPhone =
(typeof d.contactPhone === 'string' && d.contactPhone.trim()) ||
storePhone ||
loginPhone ||
'';
const phoneMismatchNow = !!(loginPhone && storePhone && loginPhone !== storePhone);
setPhoneMismatch(phoneMismatchNow ? String(loginPhone) : null);
editForm.setFieldsValue({
name: d.name,
// 登录手机号(老板);与 StoreAccount 同步
phone: loginPhone || storePhone,
contactPhone,
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;
})(),
contractUrls: (() => {
const { contracts } = collectMediaUrls(d);
return contracts.map((item) => item.url).filter(Boolean);
})(),
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,
sortOrder: d.sortOrder != null ? Number(d.sortOrder) : 0,
});
setDrawerOpen(true);
}
useEffect(() => {
if (!initialStoreId || deepLinkStoreOpenedRef.current || loading) return;
const row = data?.items?.find((s) => String(s.id) === initialStoreId);
if (row) {
deepLinkStoreOpenedRef.current = true;
void openStoreDetail(row);
} else if (data && (data.items?.length ?? 0) >= 0) {
// 列表无该店时仍尝试直拉详情
deepLinkStoreOpenedRef.current = true;
void openStoreDetail({ id: initialStoreId } as StoreRow);
}
}, [data, initialStoreId, loading]);
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,
contactPhone: String(v.contactPhone || '').trim() || v.phone,
coverUrl: v.coverUrl ?? '',
envPhotoUrls: Array.isArray(v.envPhotoUrls)
? v.envPhotoUrls.map((u: string) => String(u || '').trim()).filter(Boolean)
: [],
contractUrls: Array.isArray(v.contractUrls)
? v.contractUrls.map((u: string) => String(u || '').trim()).filter(Boolean)
: [],
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,
sortOrder: v.sortOrder != null ? Number(v.sortOrder) : 0,
...(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),
});
const packagesResult = await packagesRef.current?.saveIfLoaded({ quiet: true });
message.success(
packagesResult?.skipped === false ? '门店信息与套餐已保存' : '门店信息已保存',
);
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,
sortOrder: 0,
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[];
const contractUrls = (values.contractUrls ?? []).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(),
contactPhone: String(values.contactPhone || 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,
contractUrls: contractUrls.length ? contractUrls : undefined,
bankAccountName: values.bankAccountName.trim(),
bankAccountNo: values.bankAccountNo.replace(/\s/g, ''),
bankBranch: values.bankBranch.trim(),
settlementRate: Number(values.settlementRate ?? 60) / 100,
sortOrder: values.sortOrder != null ? Number(values.sortOrder) : 0,
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: 180,
ellipsis: { showTitle: false },
render: (v: string, row) => {
const name = v || '—';
return (
<Space size={4} style={{ maxWidth: '100%' }} wrap={false}>
<Typography.Text ellipsis={{ tooltip: name }} style={{ maxWidth: row.isTest ? 110 : 160 }}>
{name}
</Typography.Text>
{row.isTest ? <Tag color="orange">测试</Tag> : null}
</Space>
);
},
},
{
title: '分类',
width: 100,
ellipsis: true,
render: (_, row) => row.category?.name || '—',
},
{ title: '城市', dataIndex: 'cityName', width: 80, ellipsis: true },
{ title: '登录号', dataIndex: 'phone', width: 120 },
{
title: '联系电话',
dataIndex: 'contactPhone',
width: 120,
render: (v: string | null | undefined, row) => v || row.phone,
},
{
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} style={{ maxWidth: '100%' }}>
<Tag color={color}>{STORE_AUDIT_STATUS_LABELS[status] || status}</Tag>
{status === 'REJECTED' && row.rejectReason ? (
<Typography.Text type="secondary" style={{ fontSize: 12, maxWidth: 88 }} ellipsis={{ tooltip: row.rejectReason }}>
{row.rejectReason}
</Typography.Text>
) : null}
</Space>
);
},
},
{
title: '开城合伙人',
dataIndex: 'partner',
width: 140,
ellipsis: { showTitle: false },
render: (partner: StoreRow['partner']) => {
if (!partner) return '—';
const label = partnerOptionLabel({ id: partner.id ?? '', ...partner });
return (
<Typography.Text ellipsis={{ tooltip: label }} style={{ maxWidth: 124 }}>
{label}
</Typography.Text>
);
},
},
{
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: 'sortOrder', width: 70 },
{ title: '店长', dataIndex: ['account', 'name'], width: 90, ellipsis: true, render: (v) => v || '—' },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 280,
fixed: 'right',
render: (_, row) => (
<Space size={0} wrap>
<Button type="link" size="small" onClick={() => void openStoreDetail(row)}>详情</Button>
<Button type="link" size="small" onClick={() => navigate(`/store-ratings?storeId=${row.id}`)}>评价</Button>
{row.pendingPackageAuditId ? (
<Button
type="link"
size="small"
onClick={() => void openStoreDetail(row, { tab: 'packages' })}
>
审核套餐
</Button>
) : null}
{row.pendingInfoChangeId ? (
<>
<Button
type="link"
size="small"
onClick={() => navigate(`/store-package-audits?tab=info&infoRequestId=${row.pendingInfoChangeId}`)}
>
审核信息
</Button>
<Button
type="link"
size="small"
onClick={() => navigate(`/store-package-audits?tab=info&infoRequestId=${row.pendingInfoChangeId}`)}
>
对比
</Button>
</>
) : null}
<Button
type="link"
size="small"
onClick={() => navigate(`/finance/store-bills?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: 1720 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Drawer title="门店详情" width={880} 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}
{detail.pendingPackageAuditId ? (
<>
<Button
type="primary"
ghost
loading={packageAuditing}
onClick={async () => {
setPackageAuditing(true);
try {
await auditStorePackageRequest(String(detail.pendingPackageAuditId), 'APPROVE');
message.success('套餐已通过');
setDetail({ ...detail, pendingPackageAuditId: null });
void reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '套餐审核失败');
} finally {
setPackageAuditing(false);
}
}}
>
通过套餐
</Button>
<Button
danger
ghost
loading={packageAuditing}
onClick={() => {
setPackageRejectReason('');
setPackageRejectOpen(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
activeKey={detailTab}
onChange={setDetailTab}
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="contactPhone"
label="联系电话(店长/对外)"
rules={[
{ required: true, message: '请填写对外联系电话' },
{
validator: (_, value) =>
isStoreContactPhone(String(value || ''))
? Promise.resolve()
: Promise.reject(new Error(STORE_CONTACT_PHONE_HINT)),
},
]}
extra="用户端门店详情展示与拨号使用此号码,可与登录号不同,支持座机"
>
<Input placeholder="手机号或座机,如 0379-8888888" />
</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>
<Form.Item
name="sortOrder"
label="排序"
extra="数值越小越靠前;同排序时按距离(有定位)或创建时间"
>
<InputNumber min={0} precision={0} style={{ width: '100%' }} placeholder="0" />
</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: detail.pendingPackageAuditId ? (
<Badge dot offset={[4, 0]}>
套餐
</Badge>
) : (
'套餐'
),
forceRender: true,
children: (
<>
{detail.pendingPackageAuditId ? (
<div style={{ marginBottom: 24 }}>
<Typography.Title level={5} style={{ marginTop: 0 }}>
待审核套餐变更
</Typography.Title>
<StorePackageAuditPanel
requestId={String(detail.pendingPackageAuditId)}
showActions
onAudited={() => {
setDetail({ ...detail, pendingPackageAuditId: null });
void reload();
}}
/>
</div>
) : null}
<AdminStorePackagesSection ref={packagesRef} storeId={String(detail.id)} />
</>
),
},
]}
/>
</Form>
)}
</Drawer>
<Modal
title="驳回套餐变更"
open={packageRejectOpen}
confirmLoading={packageAuditing}
onCancel={() => setPackageRejectOpen(false)}
onOk={async () => {
if (!detail?.pendingPackageAuditId) return;
if (!packageRejectReason.trim()) {
message.warning('请填写驳回原因');
return;
}
setPackageAuditing(true);
try {
await auditStorePackageRequest(
String(detail.pendingPackageAuditId),
'REJECT',
packageRejectReason.trim(),
);
message.success('套餐已驳回');
setPackageRejectOpen(false);
setDetail({ ...detail, pendingPackageAuditId: null });
void reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '套餐驳回失败');
} finally {
setPackageAuditing(false);
}
}}
>
<Input.TextArea
rows={3}
value={packageRejectReason}
placeholder="驳回原因"
onChange={(e) => setPackageRejectReason(e.target.value)}
/>
</Modal>
<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="门店端主账号登录" />
</Form.Item>
<Form.Item
name="contactPhone"
label="联系电话(店长/对外)"
extra="用户端拨号展示;留空则与登录号相同。支持座机"
rules={[
{
validator: (_, value) => {
const raw = String(value || '').trim();
if (!raw || isStoreContactPhone(raw)) return Promise.resolve();
return Promise.reject(new Error(STORE_CONTACT_PHONE_HINT));
},
},
]}
>
<Input placeholder="手机号或座机,如 0379-8888888" />
</Form.Item>
<Form.Item name="sortOrder" label="排序" extra="数值越小越靠前">
<InputNumber min={0} precision={0} style={{ width: '100%' }} placeholder="0" />
</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>
<Form.Item
name="envPhotoUrls"
label="环境照片"
extra="选填;支持批量上传,最多 20 张"
>
<MultiImageUpload
bizType="STORE_ENV"
mediaType="IMAGE"
maxCount={20}
tip="环境照支持一次选择多张批量上传"
/>
</Form.Item>
<Form.Item
name="contractUrls"
label="签约合同"
extra="选填;支持多张合同照片或 PDF,最多 20 个"
>
<MultiImageUpload
bizType="STORE_CONTRACT"
mediaType="FILE"
accept="image/*,.pdf"
maxCount={20}
buttonText="批量上传合同"
tip="合同支持一次选择多张照片批量上传,最多 20 个"
/>
</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>
);
}