Files
dukang/apps/h5-partner/src/pages/StoreCreatePage.tsx
T
jacy 5deffbdbfb fix(h5-partner): allow reopening WeChat image picker after cancel
Avoid resetting JSSDK on each pick and unlock cancel paths so camera/album can be opened again.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 17:11:19 +08:00

1157 lines
27 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 { request } from '../lib/api';
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 {
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;
};
function isPhoneConflictMessage(message: string) {
return message.includes('手机号已绑定');
}
function isPhoneValidationMessage(message: string) {
return 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 [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
const [mapPickerOpen, setMapPickerOpen] = 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(() => {
if (step !== 2 || !isWechatEnv()) return;
void weixinSdk.init().catch(() => {
/* OssUploadField 点击时会再次初始化 */
});
}, [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) {
setFieldErrors((prev) => ({ ...prev, phone: undefined }));
}
setForm((prev) => ({ ...prev, ...patch }));
}
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 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)) {
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(),
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,
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('');
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 ? 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 }}
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' }}>至少 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>
</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>
<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>
);
}