1288 lines
30 KiB
TypeScript
1288 lines
30 KiB
TypeScript
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 { request } from '../lib/api';
|
||
import { toastError, toastSuccess } from '../lib/toast';
|
||
|
||
import { resolveRegionBinding } from '../lib/china-region';
|
||
|
||
import { checkStorePhoneAvailable, sendStorePhoneSms } from '../lib/storePhone';
|
||
|
||
import { formatStoreCoords, locateStorePosition } from '../lib/storeLocate';
|
||
|
||
import { fetchPartnerCities, type OpenCityOption } from '../lib/upload';
|
||
|
||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||
|
||
import {
|
||
|
||
clearAllStoreDrafts,
|
||
|
||
defaultStoreForm,
|
||
|
||
loadStoreDraft,
|
||
|
||
saveStoreDraft,
|
||
|
||
type StoreDraftForm,
|
||
|
||
validateStoreStep1,
|
||
|
||
validateStoreStep2,
|
||
|
||
validateStoreStep3,
|
||
|
||
patchEnvPhotoAt,
|
||
|
||
} from '../lib/storeDraft';
|
||
|
||
|
||
|
||
const STEPS = ['基本信息', '照片上传', '结算资质'] as const;
|
||
|
||
type StoreCategoryNode = {
|
||
id: string;
|
||
name: string;
|
||
children?: StoreCategoryNode[];
|
||
};
|
||
|
||
type FieldErrors = {
|
||
phone?: string;
|
||
storeSmsCode?: string;
|
||
};
|
||
|
||
|
||
|
||
function isPhoneConflictMessage(message: string) {
|
||
|
||
return message.includes('手机号已绑定');
|
||
|
||
}
|
||
|
||
|
||
|
||
function isPhoneValidationMessage(message: string) {
|
||
|
||
return message.includes('联系电话') || message.includes('手机号') || message.includes('验证码');
|
||
|
||
}
|
||
|
||
|
||
|
||
export default function StoreCreatePage() {
|
||
|
||
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 [cities, setCities] = useState<OpenCityOption[]>([]);
|
||
|
||
const [citiesError, setCitiesError] = useState('');
|
||
|
||
const [smsCooldown, setSmsCooldown] = useState(0);
|
||
|
||
const [smsHint, setSmsHint] = useState('');
|
||
|
||
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
|
||
|
||
const [locating, setLocating] = useState(false);
|
||
|
||
const draftSaveDisabledRef = useRef(false);
|
||
|
||
function reportFormError(message: string) {
|
||
setSubmitError(message);
|
||
}
|
||
|
||
const stepFromUrl = Number(params.get('step') || 0);
|
||
|
||
const step = stepFromUrl >= 1 && stepFromUrl <= 3 ? 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(() => {
|
||
|
||
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('');
|
||
|
||
let nextPatch = patch;
|
||
|
||
if ('phone' in patch) {
|
||
|
||
setFieldErrors((prev) => ({ ...prev, phone: undefined, storeSmsCode: undefined }));
|
||
|
||
setSmsHint('');
|
||
|
||
nextPatch = { ...patch, storeSmsCode: '' };
|
||
|
||
}
|
||
|
||
if ('storeSmsCode' in patch) {
|
||
|
||
setFieldErrors((prev) => ({ ...prev, storeSmsCode: undefined }));
|
||
|
||
}
|
||
|
||
setForm((prev) => ({ ...prev, ...nextPatch }));
|
||
|
||
}
|
||
|
||
|
||
|
||
function patchEnvPhotoUrl(index: number, url: string) {
|
||
|
||
setForm((prev) => ({
|
||
|
||
...prev,
|
||
|
||
envPhotoUrls: patchEnvPhotoAt(prev.envPhotoUrls, index, url),
|
||
|
||
}));
|
||
|
||
setSubmitError('');
|
||
|
||
}
|
||
|
||
|
||
|
||
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 sendStorePhoneCode() {
|
||
|
||
const phone = form.phone.trim();
|
||
|
||
if (!/^1\d{10}$/.test(phone)) {
|
||
|
||
setFieldErrors({ phone: '请先填写正确的11位手机号' });
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
setSmsHint('');
|
||
|
||
setFieldErrors((prev) => ({ ...prev, phone: undefined }));
|
||
|
||
try {
|
||
|
||
const phoneCheck = await checkStorePhoneAvailable(phone);
|
||
|
||
if (!phoneCheck.available) {
|
||
|
||
setFieldErrors({ phone: phoneCheck.message ?? '该手机号不可用于门店账号' });
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
const res = await sendStorePhoneSms(phone);
|
||
|
||
setSmsHint(`验证码已发送至 ${res.maskedPhone}`);
|
||
|
||
setSmsCooldown(60);
|
||
|
||
const timer = setInterval(() => {
|
||
|
||
setSmsCooldown((s) => {
|
||
|
||
if (s <= 1) {
|
||
|
||
clearInterval(timer);
|
||
|
||
return 0;
|
||
|
||
}
|
||
|
||
return s - 1;
|
||
|
||
});
|
||
|
||
}, 1000);
|
||
|
||
} catch (e) {
|
||
|
||
const msg = e instanceof Error ? e.message : '验证码发送失败';
|
||
|
||
setFieldErrors({ phone: msg });
|
||
|
||
}
|
||
|
||
}
|
||
|
||
|
||
|
||
async function handleNext() {
|
||
|
||
if (step === 1) {
|
||
|
||
const msg = validateStoreStep1(form);
|
||
|
||
if (msg) {
|
||
reportFormError(msg);
|
||
return;
|
||
}
|
||
|
||
}
|
||
|
||
if (step === 2) {
|
||
|
||
const msg = validateStoreStep2(form);
|
||
|
||
if (msg) {
|
||
reportFormError(msg);
|
||
return;
|
||
}
|
||
|
||
}
|
||
|
||
goStep(step + 1);
|
||
|
||
}
|
||
|
||
|
||
|
||
async function submit() {
|
||
|
||
const msg = validateStoreStep3(form);
|
||
|
||
if (msg) {
|
||
if (isPhoneValidationMessage(msg)) {
|
||
if (msg.includes('验证码')) {
|
||
setFieldErrors({ storeSmsCode: msg });
|
||
} else {
|
||
setFieldErrors({ phone: msg });
|
||
}
|
||
return;
|
||
}
|
||
reportFormError(msg);
|
||
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, 3);
|
||
|
||
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(),
|
||
|
||
smsCode: form.storeSmsCode.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,
|
||
|
||
coverUrl: form.coverUrl.trim() || undefined,
|
||
|
||
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
|
||
|
||
contractUrl: form.contractUrl.trim() || undefined,
|
||
|
||
bankAccountName: form.bankAccountName.trim(),
|
||
|
||
bankAccountNo: form.bankAccountNo.replace(/\s/g, ''),
|
||
|
||
bankBranch: form.bankBranch.trim(),
|
||
|
||
...(confirmBindExisting ? { confirmBindExisting: true } : {}),
|
||
|
||
}),
|
||
|
||
});
|
||
|
||
draftSaveDisabledRef.current = true;
|
||
clearAllStoreDrafts(accountId);
|
||
setForm(defaultStoreForm());
|
||
setFieldErrors({});
|
||
setSubmitError('');
|
||
setSmsHint('');
|
||
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;
|
||
}
|
||
if (/验证码/.test(message)) {
|
||
setFieldErrors({ storeSmsCode: message });
|
||
goStep(3);
|
||
return;
|
||
}
|
||
setSubmitError(message);
|
||
toastError(message);
|
||
} finally {
|
||
|
||
setSubmitting(false);
|
||
|
||
}
|
||
|
||
}
|
||
|
||
|
||
|
||
const progress = step === 1 ? 0 : step === 2 ? 50 : 100;
|
||
|
||
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 }}
|
||
disabled={locating}
|
||
onClick={() => {
|
||
void (async () => {
|
||
setLocating(true);
|
||
try {
|
||
const pos = await locateStorePosition();
|
||
patchForm({
|
||
latitude: String(pos.latitude),
|
||
longitude: String(pos.longitude),
|
||
});
|
||
toastSuccess('已获取当前坐标');
|
||
} catch (e) {
|
||
toastError(e instanceof Error ? e.message : '定位失败');
|
||
} finally {
|
||
setLocating(false);
|
||
}
|
||
})();
|
||
}}
|
||
>
|
||
{locating ? '定位中…' : '获取当前位置'}
|
||
</button>
|
||
<span className="label-md text-muted">
|
||
{formatStoreCoords(form.latitude, form.longitude)
|
||
? `坐标:${formatStoreCoords(form.latitude, form.longitude)}`
|
||
: '未定位(提交后可按地址自动解析)'}
|
||
</span>
|
||
</div>
|
||
|
||
</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:00,17: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>
|
||
|
||
</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' }}>至少 3 张,展示店内整洁环境</p>
|
||
|
||
<div className="partner-upload-grid">
|
||
|
||
{form.envPhotoUrls.map((url, index) => (
|
||
|
||
<OssUploadField
|
||
|
||
key={index}
|
||
|
||
compact
|
||
|
||
bizType="STORE_ENV"
|
||
|
||
mediaType="IMAGE"
|
||
|
||
value={url}
|
||
|
||
onChange={(nextUrl) => patchEnvPhotoUrl(index, nextUrl)}
|
||
|
||
/>
|
||
|
||
))}
|
||
|
||
</div>
|
||
|
||
</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' }}>上传签约协议首页与盖章页</p>
|
||
|
||
<OssUploadField
|
||
|
||
bizType="STORE_CONTRACT"
|
||
|
||
mediaType="FILE"
|
||
|
||
accept="image/*,.pdf"
|
||
|
||
value={form.contractUrl}
|
||
|
||
onChange={(contractUrl) => patchForm({ contractUrl })}
|
||
|
||
label="上传合同副本"
|
||
|
||
/>
|
||
|
||
</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-input-row">
|
||
|
||
<div className="partner-input-wrap" style={{ flex: 1 }}>
|
||
|
||
<span className="material-symbols-outlined partner-input-icon">shield</span>
|
||
|
||
<input
|
||
|
||
className="partner-input"
|
||
|
||
type="text"
|
||
|
||
inputMode="numeric"
|
||
|
||
maxLength={6}
|
||
|
||
placeholder="请输入短信验证码"
|
||
|
||
value={form.storeSmsCode}
|
||
|
||
onChange={(e) => patchForm({ storeSmsCode: e.target.value.replace(/\D/g, '') })}
|
||
|
||
/>
|
||
|
||
</div>
|
||
|
||
<button
|
||
|
||
type="button"
|
||
|
||
className="partner-code-btn"
|
||
|
||
disabled={smsCooldown > 0 || submitting}
|
||
|
||
onClick={() => void sendStorePhoneCode()}
|
||
|
||
>
|
||
|
||
{smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'}
|
||
|
||
</button>
|
||
|
||
</div>
|
||
|
||
{smsHint && (
|
||
|
||
<p className="label-md text-muted" style={{ marginTop: 8 }}>{smsHint}</p>
|
||
|
||
)}
|
||
|
||
{fieldErrors.storeSmsCode && (
|
||
|
||
<p className="partner-field-error" role="alert">{fieldErrors.storeSmsCode}</p>
|
||
|
||
)}
|
||
|
||
</div>
|
||
|
||
</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 < 3 ? (
|
||
|
||
<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-primary" onClick={() => void submit()} disabled={submitting}>
|
||
|
||
{submitting ? '提交中…' : '提交'}
|
||
|
||
</button>
|
||
|
||
)}
|
||
|
||
</footer>
|
||
|
||
</div>
|
||
|
||
);
|
||
|
||
}
|
||
|
||
|