From f2175b83f770482c1e07f926b80237d1b7526559 Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Fri, 3 Jul 2026 14:53:21 +0800 Subject: [PATCH] =?UTF-8?q?=E9=97=A8=E5=BA=97=E5=BC=80=E9=80=9A=E6=B5=81?= =?UTF-8?q?=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin-web/src/pages/StoreMediaPage.tsx | 4 +- apps/admin-web/src/pages/StoresPage.tsx | 16 +- apps/h5-partner/package.json | 2 + .../src/components/ChinaRegionPicker.tsx | 178 ++++++++++++ .../src/components/OssUploadField.tsx | 136 +++++++++ apps/h5-partner/src/lib/china-region.ts | 59 ++++ apps/h5-partner/src/lib/storeDraft.ts | 56 +++- apps/h5-partner/src/lib/storeStatus.ts | 16 ++ apps/h5-partner/src/lib/upload.ts | 57 ++++ apps/h5-partner/src/lib/weixin.ts | 9 + apps/h5-partner/src/pages/HomePage.tsx | 41 +-- apps/h5-partner/src/pages/StoreCreatePage.tsx | 190 +++++++++---- apps/h5-partner/src/pages/StoreDetailPage.tsx | 163 ++++++++--- apps/h5-partner/src/pages/StoreListPage.tsx | 106 ++++--- apps/h5-partner/src/styles.css | 265 +++++++++++++++++- packages/weixin-sdk/src/chooseImage.ts | 83 ++++++ packages/weixin-sdk/src/index.ts | 5 + packages/weixin-sdk/src/types.ts | 14 + pnpm-lock.yaml | 6 + .../integrations/oss/oss.aliyun.provider.ts | 5 +- .../src/integrations/oss/oss.key.util.ts | 15 +- .../wechat/wechat.api.provider.ts | 2 +- .../src/modules/store/store.controller.ts | 28 ++ .../src/modules/store/store.service.ts | 181 +++++++++++- 24 files changed, 1451 insertions(+), 186 deletions(-) create mode 100644 apps/h5-partner/src/components/ChinaRegionPicker.tsx create mode 100644 apps/h5-partner/src/components/OssUploadField.tsx create mode 100644 apps/h5-partner/src/lib/china-region.ts create mode 100644 apps/h5-partner/src/lib/storeStatus.ts create mode 100644 apps/h5-partner/src/lib/upload.ts create mode 100644 apps/h5-partner/src/lib/weixin.ts create mode 100644 packages/weixin-sdk/src/chooseImage.ts diff --git a/apps/admin-web/src/pages/StoreMediaPage.tsx b/apps/admin-web/src/pages/StoreMediaPage.tsx index c293c8b..5549185 100644 --- a/apps/admin-web/src/pages/StoreMediaPage.tsx +++ b/apps/admin-web/src/pages/StoreMediaPage.tsx @@ -106,7 +106,7 @@ export default function StoreMediaPage() { prev.mediaType !== cur.mediaType}> {({ getFieldValue }) => ( - + )} @@ -126,7 +126,7 @@ export default function StoreMediaPage() { prev.mediaType !== cur.mediaType}> {({ getFieldValue }) => ( - + )} diff --git a/apps/admin-web/src/pages/StoresPage.tsx b/apps/admin-web/src/pages/StoresPage.tsx index 5800d91..15c71f8 100644 --- a/apps/admin-web/src/pages/StoresPage.tsx +++ b/apps/admin-web/src/pages/StoresPage.tsx @@ -249,16 +249,20 @@ export default function StoresPage() { return (
- 门店 + + 门店 + 共 {data?.total ?? 0} 家门店(含合伙人录入) +
{ setFilters(v); setPage(1); }}> - ({ value, label }))} /> +
{ setPage(p); setPageSize(ps); } }} /> @@ -298,7 +302,7 @@ export default function StoresPage() { - + @@ -387,7 +391,7 @@ export default function StoresPage() { preV1 照片上传为选填,可直接下一步(与合伙人端一致)。 - + 环境照片 @@ -395,14 +399,14 @@ export default function StoresPage() {
{fields.map((field, index) => ( - + ))}
)}
- +
diff --git a/apps/h5-partner/package.json b/apps/h5-partner/package.json index d10412d..ca1ff80 100644 --- a/apps/h5-partner/package.json +++ b/apps/h5-partner/package.json @@ -11,6 +11,8 @@ "dependencies": { "@dukang/shared-ui": "workspace:*", "@dukang/shared-types": "workspace:*", + "@dukang/weixin-sdk": "workspace:*", + "element-china-area-data": "^6.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.0" diff --git a/apps/h5-partner/src/components/ChinaRegionPicker.tsx b/apps/h5-partner/src/components/ChinaRegionPicker.tsx new file mode 100644 index 0000000..da79c9c --- /dev/null +++ b/apps/h5-partner/src/components/ChinaRegionPicker.tsx @@ -0,0 +1,178 @@ +import { createPortal } from 'react-dom'; +import { useEffect, useMemo, useState } from 'react'; +import { codeToText } from 'element-china-area-data'; +import { CHINA_REGION_OPTIONS, formatRegionLabel, parseRegionCodes } from '../lib/china-region'; + +type RegionNode = { + value: string; + label: string; + children?: RegionNode[]; +}; + +type ChinaRegionPickerProps = { + value?: string[]; + onChange?: (codes: string[]) => void; + disabled?: boolean; +}; + +const STEP_LABELS = ['选择省份', '选择城市', '选择区县'] as const; + +function lockBodyScroll(lock: boolean) { + if (typeof document === 'undefined') return; + const body = document.body; + if (lock) { + const scrollY = window.scrollY; + body.dataset.dukangScrollY = String(scrollY); + body.style.position = 'fixed'; + body.style.top = `-${scrollY}px`; + body.style.left = '0'; + body.style.right = '0'; + body.style.overflow = 'hidden'; + } else { + const scrollY = Number(body.dataset.dukangScrollY ?? '0'); + body.style.position = ''; + body.style.top = ''; + body.style.left = ''; + body.style.right = ''; + body.style.overflow = ''; + delete body.dataset.dukangScrollY; + window.scrollTo(0, scrollY); + } +} + +export default function ChinaRegionPicker({ value = [], onChange, disabled }: ChinaRegionPickerProps) { + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState(value); + const [step, setStep] = useState(0); + + const options = CHINA_REGION_OPTIONS as RegionNode[]; + const parsed = parseRegionCodes(value); + const display = parsed ? formatRegionLabel(parsed) : '请选择省 / 市 / 区县'; + + const provinces = options; + const cities = useMemo(() => { + const p = provinces.find((item) => item.value === draft[0]); + return p?.children ?? []; + }, [draft, provinces]); + + const districts = useMemo(() => { + const c = cities.find((item) => item.value === draft[1]); + return c?.children ?? []; + }, [draft, cities]); + + const listItems = step === 0 ? provinces : step === 1 ? cities : districts; + const activeCode = draft[step]; + + useEffect(() => { + if (!open) return; + lockBodyScroll(true); + return () => lockBodyScroll(false); + }, [open]); + + function openPicker() { + if (disabled) return; + const next = value.length === 3 ? [...value] : []; + setDraft(next); + setStep(next.length >= 3 ? 2 : next.length); + setOpen(true); + } + + function closePicker() { + setOpen(false); + } + + function selectItem(code: string) { + const next = [...draft.slice(0, step), code]; + setDraft(next); + if (step < 2) { + setStep(step + 1); + return; + } + onChange?.(next); + closePicker(); + } + + function confirm() { + if (draft.length < 3) return; + onChange?.(draft); + closePicker(); + } + + const modal = open ? ( +
+
e.stopPropagation()} + onTouchMove={(e) => e.stopPropagation()} + > +
+ + 选择地区 + +
+ +
+ {STEP_LABELS.map((label, index) => ( + + ))} +
+ + {draft[0] && ( +

+ {codeToText[draft[0]]} + {draft[1] ? ` / ${codeToText[draft[1]]}` : ''} + {draft[2] ? ` / ${codeToText[draft[2]]}` : ''} +

+ )} + +
+ {listItems.map((item) => ( + + ))} +
+
+
+ ) : null; + + return ( + <> + + {typeof document !== 'undefined' ? createPortal(modal, document.body) : null} + + ); +} \ No newline at end of file diff --git a/apps/h5-partner/src/components/OssUploadField.tsx b/apps/h5-partner/src/components/OssUploadField.tsx new file mode 100644 index 0000000..a14e3eb --- /dev/null +++ b/apps/h5-partner/src/components/OssUploadField.tsx @@ -0,0 +1,136 @@ +import { useId, useRef, useState } from 'react'; +import { uploadFileToOss, type OssMediaType } from '../lib/upload'; +import { isWechatEnv, weixinSdk } from '../lib/weixin'; + +type OssUploadFieldProps = { + value?: string; + onChange?: (url: string) => void; + bizType: string; + mediaType?: OssMediaType; + accept?: string; + wide?: boolean; + compact?: boolean; + label?: string; +}; + +const DEFAULT_MAX_MB = 10; + +export default function OssUploadField({ + value, + onChange, + bizType, + mediaType = 'IMAGE', + accept, + wide, + compact, + label, +}: OssUploadFieldProps) { + const inputId = useId(); + const inputRef = useRef(null); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(''); + + const resolvedAccept = + accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? 'image/*,.pdf' : 'image/*'); + const useWechatPicker = isWechatEnv() && mediaType !== 'VIDEO'; + + async function uploadSelectedFile(file: File) { + if (file.size > DEFAULT_MAX_MB * 1024 * 1024) { + setError(`文件不能超过 ${DEFAULT_MAX_MB}MB`); + return; + } + setUploading(true); + setError(''); + try { + const result = await uploadFileToOss(file, { bizType, mediaType }); + onChange?.(result.url); + } catch (e) { + setError(e instanceof Error ? e.message : '上传失败'); + } finally { + setUploading(false); + if (inputRef.current) inputRef.current.value = ''; + } + } + + async function pickFile() { + if (uploading) return; + setError(''); + + if (useWechatPicker) { + try { + const files = await weixinSdk.chooseImages({ + count: 1, + sourceType: ['album', 'camera'], + }); + if (files?.[0]) { + await uploadSelectedFile(files[0]); + return; + } + } catch (e) { + const msg = e instanceof Error ? e.message : '无法打开相册'; + if (!/cancel/i.test(msg)) setError(msg); + return; + } + } + + inputRef.current?.click(); + } + + const isImage = mediaType === 'IMAGE' && value; + const isFile = mediaType === 'FILE' && value; + + const triggerProps = { + type: 'button' as const, + disabled: uploading, + onClick: () => void pickFile(), + }; + + return ( +
+ { + const file = e.target.files?.[0]; + if (file) void uploadSelectedFile(file); + }} + /> + {isImage ? ( + + ) : isFile ? ( + + ) : ( + + )} + {error &&

{error}

} +
+ ); +} diff --git a/apps/h5-partner/src/lib/china-region.ts b/apps/h5-partner/src/lib/china-region.ts new file mode 100644 index 0000000..98e2ad3 --- /dev/null +++ b/apps/h5-partner/src/lib/china-region.ts @@ -0,0 +1,59 @@ +import { codeToText, regionData } from 'element-china-area-data'; + +export { regionData as CHINA_REGION_OPTIONS }; + +export type OpenCityRef = { + id: string; + name: string; + code: string; + partnerId?: string | null; +}; +export type ParsedChinaRegion = { + province: string; + city: string; + district: string; + provinceCode: string; + cityCode: string; + districtCode: string; +}; + +export function districtCodeToCityCode(districtCode: string): string { + if (districtCode.length < 6) return districtCode; + return `${districtCode.slice(0, 4)}00`; +} + +export function parseRegionCodes(codes?: string[]): ParsedChinaRegion | null { + if (!codes || codes.length < 3) return null; + const [provinceCode, cityCode, districtCode] = codes; + const province = codeToText[provinceCode]; + const city = codeToText[cityCode]; + const district = codeToText[districtCode]; + if (!province || !city || !district) return null; + return { province, city, district, provinceCode, cityCode, districtCode }; +} + +export function formatRegionLabel(region: ParsedChinaRegion): string { + return `${region.province} / ${region.city} / ${region.district}`; +} + +export function matchOpenCityId(cities: OpenCityRef[], districtCode: string): string | undefined { + const cityCode = districtCodeToCityCode(districtCode); + return ( + cities.find((c) => c.code === cityCode)?.id + ?? cities.find((c) => c.code === districtCode)?.id + ?? cities.find((c) => districtCode.startsWith(c.code.slice(0, 4)))?.id + ); +} + +export function resolveRegionBinding(codes: string[], cities: OpenCityRef[]) { + const region = parseRegionCodes(codes); + if (!region) return null; + const cityId = matchOpenCityId(cities, region.districtCode); + const matchedCity = cityId ? cities.find((c) => c.id === cityId) : undefined; + return { + region, + cityId, + matchedCity, + cityCode: districtCodeToCityCode(region.districtCode), + }; +} diff --git a/apps/h5-partner/src/lib/storeDraft.ts b/apps/h5-partner/src/lib/storeDraft.ts index 90bb5ed..f480ce6 100644 --- a/apps/h5-partner/src/lib/storeDraft.ts +++ b/apps/h5-partner/src/lib/storeDraft.ts @@ -1,9 +1,18 @@ export type StoreDraftForm = { + regionCodes: string[]; + cityId: string; + province: string; + city: string; + district: string; name: string; phone: string; - district: string; address: string; intro: string; + accountPhone: string; + accountName: string; + coverUrl: string; + envPhotoUrls: string[]; + contractUrl: string; bankAccountName: string; bankAccountNo: string; bankBranch: string; @@ -14,27 +23,55 @@ export type StoreDraft = { form: StoreDraftForm; }; -export const STORE_DRAFT_KEY = 'partner_store_draft_v1'; +export const STORE_DRAFT_KEY = 'partner_store_draft_v2'; export const defaultStoreForm = (): StoreDraftForm => ({ + regionCodes: [], + cityId: '', + province: '', + city: '', + district: '', name: '', phone: '', - district: '金水区', address: '', intro: '', + accountPhone: '', + accountName: '', + coverUrl: '', + envPhotoUrls: ['', '', ''], + contractUrl: '', bankAccountName: '', bankAccountNo: '', bankBranch: '', }); +function normalizeStringArray(raw: unknown, length: number): string[] { + if (!Array.isArray(raw)) return Array.from({ length }, () => ''); + const items = raw.map((item) => String(item ?? '')); + while (items.length < length) items.push(''); + return items.slice(0, length); +} + function normalizeForm(raw: Record): StoreDraftForm { const base = defaultStoreForm(); + const regionCodes = Array.isArray(raw.regionCodes) + ? raw.regionCodes.map((code) => String(code)) + : base.regionCodes; return { + regionCodes, + cityId: String(raw.cityId ?? base.cityId), + province: String(raw.province ?? base.province), + city: String(raw.city ?? base.city), + district: String(raw.district ?? base.district), name: String(raw.name ?? base.name), phone: String(raw.phone ?? base.phone), - district: String(raw.district ?? base.district), address: String(raw.address ?? base.address), intro: String(raw.intro ?? base.intro), + accountPhone: String(raw.accountPhone ?? base.accountPhone), + accountName: String(raw.accountName ?? base.accountName), + coverUrl: String(raw.coverUrl ?? base.coverUrl), + envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, 3), + contractUrl: String(raw.contractUrl ?? base.contractUrl), bankAccountName: String(raw.bankAccountName ?? base.bankAccountName), bankAccountNo: String(raw.bankAccountNo ?? base.bankAccountNo), bankBranch: String(raw.bankBranch ?? base.bankBranch), @@ -69,11 +106,14 @@ export function clearStoreDraft() { const PHONE_RE = /^1\d{10}$/; const BANK_RE = /^\d{16,19}$/; -export function validateStoreStep1(form: StoreDraftForm): string | null { +export function validateStoreStep1( + form: Pick, +): string | null { + if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县'; + if (!form.cityId) return '所选地区未匹配到开城城市,请联系总部配置开城区划'; if (!form.name.trim()) return '请填写门店名称'; if (!form.phone.trim()) return '请填写联系电话'; if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号'; - if (!form.district.trim()) return '请填写区县'; if (!form.address.trim()) return '请填写详细地址'; if (form.intro.trim()) { const len = form.intro.trim().length; @@ -82,7 +122,9 @@ export function validateStoreStep1(form: StoreDraftForm): string | null { return null; } -export function validateStoreStep3(form: StoreDraftForm): string | null { +export function validateStoreStep3( + form: Pick, +): string | null { if (!form.bankAccountName.trim()) return '请填写户主姓名'; if (!form.bankAccountNo.trim()) return '请填写银行卡号'; if (!BANK_RE.test(form.bankAccountNo.replace(/\s/g, ''))) return '银行卡号须为 16~19 位数字'; diff --git a/apps/h5-partner/src/lib/storeStatus.ts b/apps/h5-partner/src/lib/storeStatus.ts new file mode 100644 index 0000000..80d8780 --- /dev/null +++ b/apps/h5-partner/src/lib/storeStatus.ts @@ -0,0 +1,16 @@ +export type StoreStatusValue = 'OPEN' | 'PAUSED' | 'CLOSED'; + +export function storeStatusLabel(status: string): string { + const s = String(status).toUpperCase(); + if (s === 'OPEN') return '营业中'; + if (s === 'PAUSED') return '暂时闭店'; + if (s === 'CLOSED') return '关闭'; + return status; +} + +export function storeStatusPillClass(status: string): string { + const s = String(status).toUpperCase(); + if (s === 'OPEN') return 'partner-status-pill--open'; + if (s === 'PAUSED') return 'partner-status-pill--paused'; + return 'partner-status-pill--closed'; +} diff --git a/apps/h5-partner/src/lib/upload.ts b/apps/h5-partner/src/lib/upload.ts new file mode 100644 index 0000000..9ff01c6 --- /dev/null +++ b/apps/h5-partner/src/lib/upload.ts @@ -0,0 +1,57 @@ +import { apiBase, request } from './api'; + +export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE'; + +export type UploadFileResult = { + url: string; + ossKey: string; + bucket: string; + mock: boolean; +}; + +/** 经 API 服务端转存 OSS,避免浏览器直传跨域 */ +export async function uploadFileToOss( + file: File, + options: { bizType: string; mediaType?: OssMediaType }, +): Promise { + const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE'); + const formData = new FormData(); + formData.append('file', file); + formData.append('bizType', options.bizType); + formData.append('mediaType', mediaType); + + const token = localStorage.getItem('accessToken'); + const headers: Record = { 'X-Client-App': 'PARTNER_H5' }; + if (token) headers.Authorization = `Bearer ${token}`; + + const res = await fetch(`${apiBase}/common/resources/upload`, { + method: 'POST', + headers, + body: formData, + }); + const json = await res.json(); + if (json.code === 401) { + localStorage.removeItem('accessToken'); + throw new Error('未登录'); + } + if (json.code !== 0) throw new Error(json.message || '上传失败'); + + const data = json.data as UploadFileResult; + return { + url: data.url, + ossKey: data.ossKey, + bucket: data.bucket, + mock: data.mock, + }; +} + +export type OpenCityOption = { + id: string; + name: string; + code: string; + province: string; +}; + +export async function fetchPartnerCities(): Promise { + return request('PARTNER_H5', '/partner/stores/cities'); +} diff --git a/apps/h5-partner/src/lib/weixin.ts b/apps/h5-partner/src/lib/weixin.ts new file mode 100644 index 0000000..4af1596 --- /dev/null +++ b/apps/h5-partner/src/lib/weixin.ts @@ -0,0 +1,9 @@ +import { createWeixinSdk, isWechatEnv } from '@dukang/weixin-sdk'; + +export const weixinSdk = createWeixinSdk({ + apiBase: '/api/v1', + clientApp: 'PARTNER_H5', + getAccessToken: () => localStorage.getItem('accessToken'), +}); + +export { isWechatEnv }; diff --git a/apps/h5-partner/src/pages/HomePage.tsx b/apps/h5-partner/src/pages/HomePage.tsx index 00ea2ed..9dbc058 100644 --- a/apps/h5-partner/src/pages/HomePage.tsx +++ b/apps/h5-partner/src/pages/HomePage.tsx @@ -1,27 +1,13 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { isLoggedIn, request } from '../lib/api'; import { LEADERBOARD_MOCK } from '../lib/leaderboard'; +import { storeStatusLabel } from '../lib/storeStatus'; function fmtMoney(n: number) { return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } -function auditStatus(store: Record) { - const audits = store.audits as Array<{ status?: string }> | undefined; - return String(audits?.[0]?.status ?? '').toUpperCase(); -} - -function storeStatusLabel(store: Record) { - const audit = auditStatus(store); - if (audit === 'PENDING') return '待审核'; - const s = String(store.status).toUpperCase(); - if (s === 'OPEN') return '营业中'; - if (s === 'PAUSED') return '暂时闭店'; - if (s === 'CLOSED') return '已关闭'; - return s; -} - export default function HomePage() { const navigate = useNavigate(); const [dash, setDash] = useState | null>(null); @@ -37,10 +23,6 @@ export default function HomePage() { const orderCount = Number(dash?.orderCount || 0); const activeStores = stores.filter((s) => String(s.status).toUpperCase() === 'OPEN').length; const abnormalStores = Math.max(0, storeCount - activeStores); - const pendingStores = useMemo( - () => stores.filter((s) => auditStatus(s) === 'PENDING'), - [stores], - ); const revenue = orderCount * 128.45; const profit = revenue * 0.25; const pendingShip = Math.ceil(orderCount * 0.04); @@ -173,25 +155,8 @@ export default function HomePage() { {monthNew}
-
-

待审核门店

-

{pendingStores.length}

-
- {pendingStores.length > 0 && ( -
-

待审核门店名称

-
    - {pendingStores.map((s) => ( -
  • - {String(s.name || '未命名门店')} -
  • - ))} -
-
- )} - {displayStores.length > 0 && ( diff --git a/apps/h5-partner/src/pages/StoreCreatePage.tsx b/apps/h5-partner/src/pages/StoreCreatePage.tsx index 9aeb050..5803c16 100644 --- a/apps/h5-partner/src/pages/StoreCreatePage.tsx +++ b/apps/h5-partner/src/pages/StoreCreatePage.tsx @@ -1,7 +1,11 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import PageHeader from '@dukang/shared-ui/PageHeader'; +import ChinaRegionPicker from '../components/ChinaRegionPicker'; +import OssUploadField from '../components/OssUploadField'; import { request } from '../lib/api'; +import { resolveRegionBinding } from '../lib/china-region'; +import { fetchPartnerCities, type OpenCityOption } from '../lib/upload'; import { clearStoreDraft, defaultStoreForm, @@ -20,6 +24,9 @@ export default function StoreCreatePage() { const saved = loadStoreDraft(); const [form, setForm] = useState(saved?.form ?? defaultStoreForm()); const [error, setError] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [cities, setCities] = useState([]); + const [citiesError, setCitiesError] = useState(''); const stepFromUrl = Number(params.get('step') || 0); const step = stepFromUrl >= 1 && stepFromUrl <= 3 ? stepFromUrl : (saved?.step ?? 1); @@ -34,11 +41,45 @@ export default function StoreCreatePage() { saveStoreDraft({ step, form }); }, [step, form]); + useEffect(() => { + void fetchPartnerCities() + .then((items) => { + setCities(items); + if (!items.length) setCitiesError('暂无开城城市,请联系总部在后台配置'); + }) + .catch((e) => setCitiesError(e instanceof Error ? e.message : '加载开城城市失败')); + }, []); + + 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) { setForm((prev) => ({ ...prev, ...patch })); setError(''); } + 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) }); setError(''); @@ -58,21 +99,38 @@ export default function StoreCreatePage() { const step1Msg = validateStoreStep1(form); if (step1Msg) { setError(step1Msg); return; } - await request('PARTNER_H5', '/partner/stores', { - method: 'POST', - body: JSON.stringify({ - name: form.name.trim(), - phone: form.phone.trim(), - district: form.district.trim(), - address: form.address.trim(), - intro: form.intro.trim() || undefined, - bankAccountName: form.bankAccountName.trim(), - bankAccountNo: form.bankAccountNo.replace(/\s/g, ''), - bankBranch: form.bankBranch.trim(), - }), - }); - clearStoreDraft(); - navigate('/stores'); + setSubmitting(true); + setError(''); + try { + const envPhotoUrls = form.envPhotoUrls.map((u) => u.trim()).filter(Boolean); + 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(), + 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(), + accountPhone: form.accountPhone.trim() || undefined, + accountName: form.accountName.trim() || undefined, + }), + }); + clearStoreDraft(); + navigate(`/stores/${result.store.id}`); + } catch (e) { + setError(e instanceof Error ? e.message : '提交失败'); + } finally { + setSubmitting(false); + } } const progress = step === 1 ? 0 : step === 2 ? 50 : 100; @@ -102,8 +160,8 @@ export default function StoreCreatePage() { - {error && ( -

{error}

+ {(error || citiesError) && ( +

{error || citiesError}

)} {step === 1 && ( @@ -113,6 +171,15 @@ export default function StoreCreatePage() {

门店基本信息

+
+ + + {regionBindingHint && ( +

+ {regionBindingHint} +

+ )} +
@@ -127,13 +194,6 @@ export default function StoreCreatePage() { patchForm({ phone: e.target.value })} />
-
- -
- location_on - patchForm({ district: e.target.value })} /> -
-