Files
dukang/apps/h5-partner/src/pages/StoreCreatePage.tsx
T
jacy fc2e5b65de
CI / verify (pull_request) Has been cancelled
v3.5.3版本更新1
2026-08-20 18:54:15 +08:00

1345 lines
35 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 ChinaRegionPicker from '../components/ChinaRegionPicker';
import OssUploadField from '../components/OssUploadField';
import MultiOssUploadField from '../components/MultiOssUploadField';
import { request } from '../lib/api';
import { fetchClientConfig } from '../lib/wechat-auth';
import { toastError, toastSuccess } from '../lib/toast';
import { resolveRegionBinding } from '../lib/china-region';
import { checkStorePhoneAvailable } from '../lib/storePhone';
import { fetchPartnerCities, type OpenCityOption } from '../lib/upload';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import TencentLocPickerOverlay from '../components/TencentLocPickerOverlay';
import { usePartnerPageView } from '../lib/usePageView';
import {
clearAllStoreDrafts,
defaultStoreForm,
loadStoreDraft,
saveStoreDraft,
type StoreDraftForm,
validateStoreStep1,
validateStoreStep2,
validateStoreStep3,
MIN_ENV_PHOTO_COUNT,
MAX_CONTRACT_COUNT,
} from '../lib/storeDraft';
import StorePackagesForm from '../components/StorePackagesForm';
import { normalizePackageFormItems, validatePackageFormItems } from '../lib/storePackages';
const STEPS = ['基本信息', '照片上传', '结算资质', '门店套餐'] as const;
const DEFAULT_PARTNER_ONBOARD_CS_HINT =
'使用问题、提现问题等随时可联系【杜康好客】客服';
type StoreCategoryNode = {
id: string;
name: string;
children?: StoreCategoryNode[];
};
type FieldErrors = {
phone?: string;
contactPhone?: string;
};
function isPhoneConflictMessage(message: string) {
return message.includes('手机号已绑定');
}
function isPhoneValidationMessage(message: string) {
return message.includes('联系电话') || message.includes('手机号');
}
export default function StoreCreatePage() {
usePartnerPageView('partner_store_create_view');
const navigate = useNavigate();
const { account } = usePartnerSession();
const accountId = account?.id;
const [params, setParams] = useSearchParams();
const saved = loadStoreDraft(accountId);
const [form, setForm] = useState<StoreDraftForm>(saved?.form ?? defaultStoreForm());
const [submitError, setSubmitError] = useState('');
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [submitting, setSubmitting] = useState(false);
const [csAdded, setCsAdded] = useState(false);
const [csQrUrl, setCsQrUrl] = useState<string | null>(null);
const [csHint, setCsHint] = useState(DEFAULT_PARTNER_ONBOARD_CS_HINT);
const [csConfigLoading, setCsConfigLoading] = useState(false);
const [cities, setCities] = useState<OpenCityOption[]>([]);
const [citiesError, setCitiesError] = useState('');
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
const [mapPickerOpen, setMapPickerOpen] = useState(false);
const [stepErrorModal, setStepErrorModal] = useState<string | null>(null);
const draftSaveDisabledRef = useRef(false);
function reportFormError(message: string) {
setSubmitError(message);
}
function reportStepError(message: string) {
setSubmitError(message);
setStepErrorModal(message);
}
const stepFromUrl = Number(params.get('step') || 0);
const step = stepFromUrl >= 1 && stepFromUrl <= 4 ? stepFromUrl : (saved?.step ?? 1);
useEffect(() => {
if (!stepFromUrl && saved?.step) {
setParams({ step: String(saved.step) }, { replace: true });
}
}, [stepFromUrl, saved?.step, setParams]);
useEffect(() => {
if (draftSaveDisabledRef.current) return;
saveStoreDraft({ step, form }, accountId);
}, [step, form, accountId]);
useEffect(() => {
if (step !== 2 || !isWechatEnv()) return;
void weixinSdk.init().catch(() => {
/* OssUploadField 点击时会再次初始化 */
});
}, [step]);
useEffect(() => {
if (step !== 4) return;
let cancelled = false;
setCsConfigLoading(true);
void fetchClientConfig()
.then((cfg) => {
if (cancelled) return;
setCsQrUrl((cfg.partnerOnboardCsQrUrl ?? '').trim() || null);
setCsHint((cfg.partnerOnboardCsHint ?? '').trim() || DEFAULT_PARTNER_ONBOARD_CS_HINT);
})
.catch(() => {
if (cancelled) return;
setCsQrUrl(null);
setCsHint(DEFAULT_PARTNER_ONBOARD_CS_HINT);
})
.finally(() => {
if (!cancelled) setCsConfigLoading(false);
});
return () => {
cancelled = true;
};
}, [step]);
useEffect(() => {
void fetchPartnerCities()
.then((items) => {
setCities(items);
if (!items.length) setCitiesError('暂无开城城市,请联系总部在后台配置');
})
.catch((e) => {
const msg = e instanceof Error ? e.message : '加载开城城市失败';
setCitiesError(msg);
});
}, []);
useEffect(() => {
void request<StoreCategoryNode[]>('PARTNER_H5', '/partner/store-categories')
.then((list) => {
const tree = Array.isArray(list) ? list : [];
setCategoryTree(tree);
if (form.categoryId && !form.categoryParentId) {
const parent = tree.find((root) =>
(root.children ?? []).some((child) => child.id === form.categoryId),
);
if (parent) patchForm({ categoryParentId: parent.id });
}
})
.catch(() => setCategoryTree([]));
}, []);
const categoryChildren = useMemo(() => {
const parent = categoryTree.find((item) => item.id === form.categoryParentId);
return Array.isArray(parent?.children) ? parent!.children! : [];
}, [categoryTree, form.categoryParentId]);
useEffect(() => {
if (!cities.length || form.regionCodes.length < 3 || form.cityId) return;
const binding = resolveRegionBinding(form.regionCodes, cities);
if (!binding) return;
patchForm({
cityId: binding.cityId ?? '',
province: binding.region.province,
city: binding.region.city,
district: binding.region.district,
});
}, [cities, form.regionCodes, form.cityId]);
const regionBindingHint = useMemo(() => {
if (!form.regionCodes.length) return null;
const binding = resolveRegionBinding(form.regionCodes, cities);
if (!binding) return null;
if (binding.cityId && binding.matchedCity) {
return `已匹配开城城市:${binding.matchedCity.name}`;
}
return '所选地区暂未开城,请联系总部配置对应区划';
}, [form.regionCodes, cities]);
function patchForm(patch: Partial<StoreDraftForm>) {
setSubmitError('');
if ('phone' in patch || 'contactPhone' in patch) {
setFieldErrors((prev) => ({
...prev,
...('phone' in patch ? { phone: undefined } : {}),
...('contactPhone' in patch ? { contactPhone: undefined } : {}),
}));
}
setForm((prev) => ({ ...prev, ...patch }));
}
function bindRegionSelection(codes: string[]) {
const binding = resolveRegionBinding(codes, cities);
if (!binding) {
patchForm({ regionCodes: codes, cityId: '', province: '', city: '', district: '' });
return;
}
patchForm({
regionCodes: codes,
cityId: binding.cityId ?? '',
province: binding.region.province,
city: binding.region.city,
district: binding.region.district,
});
}
function goStep(n: number) {
setParams({ step: String(n) });
setSubmitError('');
}
async function handleNext() {
if (step === 1) {
const msg = validateStoreStep1(form);
if (msg) {
reportStepError(msg);
return;
}
}
if (step === 2) {
const msg = validateStoreStep2(form);
if (msg) {
reportStepError(msg);
return;
}
}
if (step === 3) {
const msg = validateStoreStep3(form);
if (msg) {
if (isPhoneValidationMessage(msg)) {
setFieldErrors(
msg.includes('联系电话') ? { contactPhone: msg } : { phone: msg },
);
reportStepError(msg);
return;
}
reportStepError(msg);
return;
}
}
goStep(step + 1);
}
async function submit(skipPackages = false) {
const qr = (csQrUrl ?? '').trim();
if (!qr) {
reportFormError('客服二维码暂未配置,请联系总部');
return;
}
if (!csAdded) {
reportFormError('请先勾选「我已添加【杜康好客】客服」');
return;
}
const msg = validateStoreStep3(form);
if (msg) {
if (isPhoneValidationMessage(msg)) {
setFieldErrors(
msg.includes('联系电话') ? { contactPhone: msg } : { phone: msg },
);
return;
}
reportFormError(msg);
return;
}
const packagesToSubmit = skipPackages ? [] : form.packages || [];
if (!skipPackages) {
const pkgMsg = validatePackageFormItems(packagesToSubmit);
if (pkgMsg) {
reportFormError(pkgMsg);
return;
}
}
const step1Msg = validateStoreStep1(form);
if (step1Msg) {
reportFormError(step1Msg);
goStep(1);
return;
}
if (!/^\d+$/.test(form.cityId.trim())) {
reportFormError('所选地区未匹配到开城城市,请重新选择');
goStep(1);
return;
}
const step2Msg = validateStoreStep2(form);
if (step2Msg) {
reportFormError(step2Msg);
goStep(2);
return;
}
setSubmitting(true);
setSubmitError('');
setFieldErrors({});
let confirmBindExisting = false;
try {
const phoneCheck = await checkStorePhoneAvailable(form.phone.trim());
if (!phoneCheck.available) {
setFieldErrors({
phone: phoneCheck.message ?? '该手机号已绑定门店,请更换',
});
setSubmitting(false);
return;
}
if (phoneCheck.needConfirm) {
const ok = window.confirm(
phoneCheck.message ??
`该手机号已是门店主账号(已绑 ${phoneCheck.existingStoreCount ?? 0} 家店),确认后将追加绑定新店。是否继续?`,
);
if (!ok) {
setFieldErrors({ phone: '已取消绑定已有主账号,请更换手机号或确认后继续' });
setSubmitting(false);
return;
}
confirmBindExisting = true;
}
} catch (e) {
reportFormError(e instanceof Error ? e.message : '手机号校验失败');
setSubmitting(false);
return;
}
try {
const envPhotoUrls = Array.from(
new Set(form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)),
).slice(0, 20);
const contractUrls = Array.from(
new Set((form.contractUrls ?? []).map((u) => u.trim()).filter(Boolean)),
).slice(0, MAX_CONTRACT_COUNT);
const result = await request<{ store: { id: string } }>('PARTNER_H5', '/partner/stores', {
method: 'POST',
body: JSON.stringify({
cityId: form.cityId,
province: form.province,
city: form.city,
name: form.name.trim(),
phone: form.phone.trim(),
contactPhone: form.contactPhone.trim() || form.phone.trim(),
district: form.district.trim(),
address: form.address.trim(),
...(form.latitude.trim() && form.longitude.trim()
? {
latitude: Number(form.latitude),
longitude: Number(form.longitude),
}
: {}),
openTime: form.openTime.trim(),
closeTime: form.closeTime.trim(),
...(form.dualHours
? { openTime2: form.openTime2.trim(), closeTime2: form.closeTime2.trim() }
: {}),
...(form.avgPrice.trim() ? { avgPrice: Number(form.avgPrice) } : {}),
categoryId: form.categoryId.trim(),
intro: form.intro.trim() || undefined,
benefitUsageRule: form.benefitUsageRule.trim() || undefined,
coverUrl: form.coverUrl.trim() || undefined,
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
contractUrls: contractUrls.length ? contractUrls : undefined,
bankAccountName: form.bankAccountName.trim(),
bankAccountNo: form.bankAccountNo.replace(/\s/g, ''),
bankBranch: form.bankBranch.trim(),
...(confirmBindExisting ? { confirmBindExisting: true } : {}),
}),
});
draftSaveDisabledRef.current = true;
clearAllStoreDrafts(accountId);
const packages = normalizePackageFormItems(packagesToSubmit);
if (packages.length) {
await request('PARTNER_H5', `/partner/stores/${result.store.id}/packages`, {
method: 'PUT',
body: JSON.stringify({
packages: packages.map((p) => ({ ...p, price: Number(p.price).toFixed(2) })),
}),
});
}
setForm(defaultStoreForm());
setFieldErrors({});
setSubmitError('');
setParams({ step: '1' }, { replace: true });
toastSuccess('门店录入成功');
navigate(`/stores/${result.store.id}`);
} catch (e) {
const message = e instanceof Error ? e.message : '提交失败';
if (isPhoneConflictMessage(message)) {
setFieldErrors({ phone: message });
return;
}
setSubmitError(message);
toastError(message);
} finally {
setSubmitting(false);
}
}
const progress = step === 1 ? 0 : step === 2 ? 33 : step === 3 ? 66 : 100;
const canSubmitOnboard = !!csQrUrl?.trim() && csAdded && !submitting && !csConfigLoading;
const nextDisabled = submitting;
return (
<div className="partner-page-sticky partner-home--flush-top">
<nav className="partner-stepper">
<div className="partner-stepper-inner">
<div className="partner-stepper-line" aria-hidden>
<div className="partner-stepper-line-fill" style={{ width: `${progress}%` }} />
</div>
{STEPS.map((label, i) => {
const n = i + 1;
const done = step > n;
const active = step === n;
return (
<div key={label} className="partner-step">
<div className={`partner-step-circle${done ? ' partner-step-circle--done' : ''}${active ? ' partner-step-circle--active' : ''}`}>
{done ? <span className="material-symbols-outlined" style={{ fontSize: 16, fontVariationSettings: "'FILL' 1" }}>check</span> : n}
</div>
<span className={`partner-step-label${active || done ? ' partner-step-label--active' : ''}`}>{label}</span>
</div>
);
})}
</div>
</nav>
{(submitError || citiesError) && (
<p className="partner-form-error" role="alert">{submitError || citiesError}</p>
)}
{step === 1 && (
<>
<section className="partner-form-card">
<div className="partner-section-title">
<div className="partner-section-bar" />
<h2 className="headline-md">门店基本信息</h2>
</div>
<div className="partner-field">
<label>所在地区 <span className="text-primary">*</span></label>
<ChinaRegionPicker value={form.regionCodes} onChange={bindRegionSelection} />
{regionBindingHint && (
<p className={`label-md${form.cityId ? ' text-muted' : ' text-primary'}`} style={{ marginTop: 8 }}>
{regionBindingHint}
</p>
)}
</div>
<div className="partner-field">
<label>门店名称 <span className="text-primary">*</span></label>
<div className="partner-field-input">
<span className="material-symbols-outlined">store</span>
<input placeholder="请输入门店名称" value={form.name} onChange={(e) => patchForm({ name: e.target.value })} />
</div>
</div>
<div className="partner-field">
<label>店铺类型 <span className="text-primary">*</span></label>
<div className="partner-input-row" style={{ gap: 8 }}>
<select
className="partner-field-input partner-field-input--block"
value={form.categoryParentId}
onChange={(e) => patchForm({ categoryParentId: e.target.value, categoryId: '' })}
aria-label="一级店铺类型"
>
<option value="">选择大类</option>
{categoryTree.map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
<select
className="partner-field-input partner-field-input--block"
value={form.categoryId}
onChange={(e) => patchForm({ categoryId: e.target.value })}
disabled={!form.categoryParentId}
aria-label="二级店铺类型"
>
<option value="">{form.categoryParentId ? '选择细类' : '请先选大类'}</option>
{categoryChildren.map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
</div>
</div>
<div className="partner-field">
<label>详细地址 <span className="text-primary">*</span></label>
<textarea rows={2} placeholder="请输入详细门牌号" value={form.address} onChange={(e) => patchForm({ address: e.target.value })} />
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>
<button
type="button"
className="partner-btn-outline"
style={{ padding: '8px 12px', fontSize: 13 }}
onClick={() => setMapPickerOpen(true)}
>
地图选点
</button>
<span className="label-md text-muted">
{form.latitude.trim() && form.longitude.trim() ? '已选点' : '未选点'}
</span>
</div>
{/* 经纬度仅内存/提交用,界面不展示 */}
<input type="hidden" name="latitude" value={form.latitude} readOnly />
<input type="hidden" name="longitude" value={form.longitude} readOnly />
</div>
<div className="partner-field">
<label>营业时间 <span className="text-primary">*</span></label>
<div className="partner-input-row" style={{ alignItems: 'center', gap: 8, marginBottom: 8 }}>
<input
className="partner-field-input partner-field-input--block"
type="time"
value={form.openTime}
onChange={(e) => patchForm({ openTime: e.target.value })}
aria-label="营业开始时间"
/>
<span className="label-md text-muted"></span>
<input
className="partner-field-input partner-field-input--block"
type="time"
value={form.closeTime}
onChange={(e) => patchForm({ closeTime: e.target.value })}
aria-label="营业结束时间"
/>
</div>
<label className="partner-checkbox-row" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<input
type="checkbox"
checked={form.dualHours}
onChange={(e) => patchForm({ dualHours: e.target.checked })}
/>
<span className="label-md">启用第二段营业时间(如午市/晚市)</span>
</label>
{form.dualHours ? (
<div className="partner-input-row" style={{ alignItems: 'center', gap: 8 }}>
<input
className="partner-field-input partner-field-input--block"
type="time"
value={form.openTime2}
onChange={(e) => patchForm({ openTime2: e.target.value })}
aria-label="第二段开始"
/>
<span className="label-md text-muted"></span>
<input
className="partner-field-input partner-field-input--block"
type="time"
value={form.closeTime2}
onChange={(e) => patchForm({ closeTime2: e.target.value })}
aria-label="第二段结束"
/>
</div>
) : null}
<p className="label-md text-muted" style={{ marginTop: 8 }}>
支持 1 段(如 09:00-22:00)或 2 段(如 09:00-14:0017:00-21:00)。
</p>
</div>
<div className="partner-field">
<label>人均费用(选填)</label>
<input
className="partner-field-input partner-field-input--block"
type="number"
min={0}
step={1}
placeholder="如 88,用户端门店页展示"
value={form.avgPrice}
onChange={(e) => patchForm({ avgPrice: e.target.value })}
/>
</div>
<div className="partner-field">
<label>门店简介</label>
<textarea rows={4} placeholder="请输入门店简介 (2-500字)" value={form.intro} onChange={(e) => patchForm({ intro: e.target.value })} />
<div style={{ textAlign: 'right', marginTop: 4 }}>
<span className="label-md text-muted">{form.intro.length} / 500</span>
</div>
</div>
<div className="partner-field">
<label>好客权益券使用规则</label>
<textarea
rows={4}
placeholder="选填,展示在用户端门店详情,最多1000字"
maxLength={1000}
value={form.benefitUsageRule}
onChange={(e) => patchForm({ benefitUsageRule: e.target.value })}
/>
<div style={{ textAlign: 'right', marginTop: 4 }}>
<span className="label-md text-muted">{form.benefitUsageRule.length} / 1000</span>
</div>
</div>
</section>
<div className="partner-info-banner">
<div className="partner-bills-icon">
<span className="material-symbols-outlined">verified_user</span>
</div>
<div>
<h3 className="body-md text-primary" style={{ fontWeight: 700 }}>杜康合伙人身份认证</h3>
<p className="label-md text-variant" style={{ marginTop: 4, lineHeight: 1.4 }}>
填写内容将自动保存,退出后可继续录入。
</p>
</div>
</div>
</>
)}
{step === 2 && (
<>
<div className="partner-section-title" style={{ padding: '0 20px', marginBottom: 16 }}>
<div className="partner-bills-icon" style={{ background: '#ffdad7', borderRadius: 8, width: 40, height: 40 }}>
<span className="material-symbols-outlined text-primary">photo_library</span>
</div>
<h2 className="headline-md">门店图片上传</h2>
</div>
<section className="partner-form-card">
<h3 className="headline-md">门头照 <span className="text-primary">*</span></h3>
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>至少 1 张,需包含完整招牌</p>
<OssUploadField
wide
bizType="STORE_TITLE"
mediaType="IMAGE"
value={form.coverUrl}
onChange={(coverUrl) => patchForm({ coverUrl })}
label="拍照 / 从相册选择"
/>
</section>
<section className="partner-form-card">
<h3 className="headline-md">环境照片 <span className="text-primary">*</span></h3>
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>至少 {MIN_ENV_PHOTO_COUNT} 张,支持批量上传,最多 20 </p>
<MultiOssUploadField
bizType="STORE_ENV"
maxCount={20}
variant="grid"
value={form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)}
onChange={(urls) => setForm((prev) => ({ ...prev, envPhotoUrls: urls.length ? urls : [''] }))}
/>
</section>
<section className="partner-form-card">
<h3 className="headline-md">签约合同 <span className="text-primary">*</span></h3>
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>
拍照上传签约协议首页与盖章页,支持多张,最多 {MAX_CONTRACT_COUNT}
</p>
<MultiOssUploadField
bizType="STORE_CONTRACT"
mediaType="FILE"
accept="image/*,.pdf"
unit="个"
maxCount={MAX_CONTRACT_COUNT}
value={form.contractUrls ?? []}
onChange={(urls) => patchForm({ contractUrls: urls })}
label={`批量上传合同(${(form.contractUrls ?? []).length}/${MAX_CONTRACT_COUNT}`}
/>
</section>
<div className="partner-info-banner" style={{ background: 'rgba(254,214,91,0.2)', borderColor: 'rgba(254,214,91,0.5)' }}>
<span className="material-symbols-outlined text-secondary" style={{ fontSize: 18 }}>info</span>
<p className="label-md" style={{ lineHeight: 1.5, color: 'var(--color-on-secondary-container)' }}>
温馨提示:请确保照片清晰无反光,避免遮挡关键信息。如上传失败,请检查网络或联系客户经理。
</p>
</div>
</>
)}
{step === 3 && (
<>
<div className="partner-section-title" style={{ padding: '0 20px', marginBottom: 16 }}>
<span className="material-symbols-outlined text-primary">account_balance</span>
<h2 className="headline-md">结算信息配置</h2>
</div>
<section className="partner-form-card">
<div className="partner-field">
<label>户主姓名 *</label>
<input className="partner-field-input partner-field-input--block" placeholder="请输入银行卡实名姓名" value={form.bankAccountName} onChange={(e) => patchForm({ bankAccountName: e.target.value })} />
</div>
<div className="partner-field">
<label>银行卡号 *</label>
<input
className="partner-field-input partner-field-input--block"
inputMode="numeric"
autoComplete="cc-number"
maxLength={19}
placeholder="请输入16-19位银行卡号"
value={form.bankAccountNo}
onChange={(e) => patchForm({ bankAccountNo: e.target.value.replace(/\D/g, '').slice(0, 19) })}
/>
</div>
<div className="partner-field">
<label>开户支行 *</label>
<input className="partner-field-input partner-field-input--block" placeholder="例如:中国工商银行洛阳分行" value={form.bankBranch} onChange={(e) => patchForm({ bankBranch: e.target.value })} />
</div>
</section>
<div className="partner-info-banner" style={{ background: 'rgba(254,214,91,0.15)', borderColor: 'rgba(254,214,91,0.3)' }}>
<span className="material-symbols-outlined text-secondary">info</span>
<p className="body-md" style={{ color: 'var(--color-on-secondary-container)' }}>
请确保银行卡信息准确,以免影响每月的餐费结算。
</p>
</div>
<section className="partner-form-card">
<div className="partner-field">
<label>门店登录手机号 <span className="text-primary">*</span></label>
<div className="partner-field-input">
<span className="material-symbols-outlined">call</span>
<input
type="tel"
placeholder="请输入11位手机号"
value={form.phone}
onChange={(e) => patchForm({ phone: e.target.value })}
/>
</div>
{fieldErrors.phone && (
<p className="partner-field-error" role="alert">{fieldErrors.phone}</p>
)}
<p className="label-md text-muted" style={{ marginTop: 8 }}>
老板手机号,作为门店端主账号登录凭证。
</p>
</div>
<div className="partner-field">
<label>联系电话 <span className="text-primary">*</span></label>
<div className="partner-field-input">
<span className="material-symbols-outlined">phone_in_talk</span>
<input
type="tel"
placeholder="手机号或座机,如 0379-8888888"
value={form.contactPhone}
onChange={(e) => patchForm({ contactPhone: e.target.value })}
/>
</div>
{fieldErrors.contactPhone && (
<p className="partner-field-error" role="alert">{fieldErrors.contactPhone}</p>
)}
<p className="label-md text-muted" style={{ marginTop: 8 }}>
用户端门店详情展示与拨号使用此号码,可与登录号不同,支持座机(如 0379-8888888)。
</p>
</div>
</section>
</>
)}
{step === 4 && (
<>
<section className="partner-form-card partner-onboard-cs-card">
<div className="partner-section-title">
<div className="partner-section-bar" />
<h2 className="headline-md">添加客服</h2>
</div>
<p className="label-md text-muted" style={{ marginBottom: 16, lineHeight: 1.5 }}>
{csHint}
</p>
{csConfigLoading ? (
<p className="body-md text-muted">加载客服二维码…</p>
) : csQrUrl ? (
<img
className="partner-onboard-cs-qr"
src={csQrUrl}
alt="杜康好客企微客服二维码"
/>
) : (
<p className="partner-form-error" role="alert">
客服二维码暂未配置,请联系总部
</p>
)}
<label className="partner-onboard-cs-check">
<input
type="checkbox"
checked={csAdded}
disabled={!csQrUrl || submitting}
onChange={(e) => setCsAdded(e.target.checked)}
/>
<span>我已添加【杜康好客】客服</span>
</label>
</section>
<section className="partner-form-card">
<div className="partner-section-title">
<div className="partner-section-bar" />
<h2 className="headline-md">门店套餐</h2>
</div>
<p className="label-md text-muted" style={{ marginBottom: 16 }}>
可选填写;提交后将进入总部审核。也可在最后一页点击「跳过」。
</p>
<StorePackagesForm
items={form.packages?.length ? form.packages : [{ name: '', price: '', dishes: '', usableTime: '', otherNotes: '', sortOrder: 0 }]}
onChange={(packages) => setForm((prev) => ({ ...prev, packages }))}
disabled={submitting}
embedded
/>
</section>
</>
)}
<footer className="partner-sticky-footer">
{step === 1 ? (
<button type="button" className="partner-btn-outline" onClick={() => navigate('/stores')} disabled={nextDisabled}>返回</button>
) : (
<button type="button" className="partner-btn-outline" onClick={() => goStep(step - 1)} disabled={nextDisabled}>上一步</button>
)}
{step < 4 ? (
<button type="button" className="partner-btn-primary" onClick={() => void handleNext()} disabled={nextDisabled}>
<span>下一步</span>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>navigate_next</span>
</button>
) : (
<>
<button
type="button"
className="partner-btn-outline"
onClick={() => void submit(true)}
disabled={!canSubmitOnboard}
>
跳过
</button>
<button
type="button"
className="partner-btn-primary"
onClick={() => void submit(false)}
disabled={!canSubmitOnboard}
>
{submitting ? '提交中…' : '提交'}
</button>
</>
)}
</footer>
{stepErrorModal ? (
<div className="partner-ship-modal-backdrop" role="presentation" onClick={() => setStepErrorModal(null)}>
<div className="partner-ship-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
<h3 className="headline-md" style={{ marginBottom: 8 }}>请完善信息</h3>
<p className="body-md text-muted" style={{ lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>
{stepErrorModal}
</p>
<div className="partner-ship-actions">
<button type="button" className="partner-btn-primary" onClick={() => setStepErrorModal(null)}>
我知道了
</button>
</div>
</div>
</div>
) : null}
<TencentLocPickerOverlay
open={mapPickerOpen}
onClose={() => setMapPickerOpen(false)}
latitude={form.latitude ? Number(form.latitude) : undefined}
longitude={form.longitude ? Number(form.longitude) : undefined}
onPick={(loc) => {
patchForm({
latitude: String(loc.latitude),
longitude: String(loc.longitude),
...(loc.address && !form.address.trim() ? { address: loc.address } : {}),
});
toastSuccess(loc.name ? `已选定:${loc.name}` : '位置已选定');
}}
/>
</div>
);
}