1657 lines
66 KiB
TypeScript
1657 lines
66 KiB
TypeScript
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('银行卡号须为 16–19 位数字');
|
||
}
|
||
},
|
||
},
|
||
]}
|
||
>
|
||
<Input placeholder="16–19 位数字" 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>
|
||
);
|
||
}
|