From a0f4d8c18f6d5baccab1e8ce81c9fc34e28efb28 Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Thu, 2 Jul 2026 17:23:13 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E6=94=AF=E4=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin-web/package.json | 1 + .../src/components/ChinaRegionCascader.tsx | 34 +++ apps/admin-web/src/lib/china-region.ts | 76 +++++++ apps/admin-web/src/lib/constants.ts | 3 + apps/admin-web/src/lib/storeCreate.ts | 14 +- apps/admin-web/src/pages/CitiesPage.tsx | 4 +- .../src/pages/PartnerAccountsPage.tsx | 4 +- .../admin-web/src/pages/StoreAccountsPage.tsx | 4 +- apps/admin-web/src/pages/StoreMediaPage.tsx | 4 +- apps/admin-web/src/pages/StoresPage.tsx | 206 +++++++++++------- apps/h5-user/src/pages/StoreListPage.tsx | 65 ++++-- pnpm-lock.yaml | 3 + 12 files changed, 320 insertions(+), 98 deletions(-) create mode 100644 apps/admin-web/src/components/ChinaRegionCascader.tsx create mode 100644 apps/admin-web/src/lib/china-region.ts diff --git a/apps/admin-web/package.json b/apps/admin-web/package.json index dd1f8e9..04ab1b0 100644 --- a/apps/admin-web/package.json +++ b/apps/admin-web/package.json @@ -13,6 +13,7 @@ "@dukang/shared-types": "workspace:*", "antd": "^5.22.0", "dayjs": "^1.11.13", + "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/admin-web/src/components/ChinaRegionCascader.tsx b/apps/admin-web/src/components/ChinaRegionCascader.tsx new file mode 100644 index 0000000..f0c28da --- /dev/null +++ b/apps/admin-web/src/components/ChinaRegionCascader.tsx @@ -0,0 +1,34 @@ +import { Cascader } from 'antd'; +import type { DefaultOptionType } from 'antd/es/cascader'; +import { CHINA_REGION_OPTIONS } from '../lib/china-region'; + +type ChinaRegionCascaderProps = { + value?: string[]; + onChange?: (codes: string[]) => void; + disabled?: boolean; + placeholder?: string; +}; + +export default function ChinaRegionCascader({ + value, + onChange, + disabled, + placeholder = '请选择省 / 市 / 区县', +}: ChinaRegionCascaderProps) { + return ( + onChange?.((codes ?? []) as string[])} + disabled={disabled} + placeholder={placeholder} + showSearch={{ + filter: (input, path) => + path.some((option) => + String(option.label ?? '').toLowerCase().includes(input.toLowerCase()), + ), + }} + changeOnSelect={false} + /> + ); +} diff --git a/apps/admin-web/src/lib/china-region.ts b/apps/admin-web/src/lib/china-region.ts new file mode 100644 index 0000000..cd9019f --- /dev/null +++ b/apps/admin-web/src/lib/china-region.ts @@ -0,0 +1,76 @@ +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; + partner?: { id: string }; +}; + +export type ParsedChinaRegion = { + province: string; + city: string; + district: string; + provinceCode: string; + cityCode: string; + districtCode: string; +}; + +/** 区县 adcode → 地级市 adcode(如 410105 → 410100) */ +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, + partnerId?: string, +): string | undefined { + const cityCode = districtCodeToCityCode(districtCode); + const scoped = partnerId + ? cities.filter((c) => { + const pid = c.partnerId ?? c.partner?.id; + return !pid || String(pid) === partnerId; + }) + : cities; + return ( + scoped.find((c) => c.code === cityCode)?.id + ?? scoped.find((c) => c.code === districtCode)?.id + ?? scoped.find((c) => districtCode.startsWith(c.code.slice(0, 4)))?.id + ); +} + +export function resolveRegionBinding( + codes: string[], + cities: OpenCityRef[], + partnerId?: string, +) { + const region = parseRegionCodes(codes); + if (!region) return null; + const cityId = matchOpenCityId(cities, region.districtCode, partnerId); + const matchedCity = cityId ? cities.find((c) => c.id === cityId) : undefined; + return { + region, + cityId, + matchedCity, + cityCode: districtCodeToCityCode(region.districtCode), + }; +} diff --git a/apps/admin-web/src/lib/constants.ts b/apps/admin-web/src/lib/constants.ts index 53cd729..bd4cb84 100644 --- a/apps/admin-web/src/lib/constants.ts +++ b/apps/admin-web/src/lib/constants.ts @@ -1,3 +1,6 @@ +/** 与后端 PaginationQueryDto @Max(100) 一致,下拉选项拉取勿超过此值 */ +export const ADMIN_OPTIONS_PAGE_SIZE = 100; + export const ORDER_STATUS_LABELS: Record = { PENDING_PAY: '待付款', PENDING_SHIP: '待发货', diff --git a/apps/admin-web/src/lib/storeCreate.ts b/apps/admin-web/src/lib/storeCreate.ts index 982e8ae..bd0df31 100644 --- a/apps/admin-web/src/lib/storeCreate.ts +++ b/apps/admin-web/src/lib/storeCreate.ts @@ -1,9 +1,13 @@ export type StoreCreateForm = { partnerId: string; cityId: string; + regionCodes?: string[]; + province?: string; + city?: string; + district: string; + districtCode?: string; name: string; phone: string; - district: string; address: string; intro?: string; coverUrl?: string; @@ -19,13 +23,15 @@ export type StoreCreateForm = { const PHONE_RE = /^1\d{10}$/; const BANK_RE = /^\d{16,19}$/; -export function validateStoreCreateStep1(form: Pick): string | null { +export function validateStoreCreateStep1( + form: Pick, +): string | null { if (!form.partnerId) return '请选择开城合伙人'; - if (!form.cityId) return '请选择开城城市'; + 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; diff --git a/apps/admin-web/src/pages/CitiesPage.tsx b/apps/admin-web/src/pages/CitiesPage.tsx index 161d550..fd9324f 100644 --- a/apps/admin-web/src/pages/CitiesPage.tsx +++ b/apps/admin-web/src/pages/CitiesPage.tsx @@ -4,7 +4,7 @@ import { } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { request, type Paginated } from '../lib/api'; -import { CITY_STATUS_LABELS, fmtTime } from '../lib/constants'; +import { ADMIN_OPTIONS_PAGE_SIZE, CITY_STATUS_LABELS, fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; type Row = { @@ -37,7 +37,7 @@ export default function CitiesPage() { const [partners, setPartners] = useState([]); async function loadPartners() { - const res = await request>('/admin/partners?pageSize=200'); + const res = await request>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); setPartners(res.items); } diff --git a/apps/admin-web/src/pages/PartnerAccountsPage.tsx b/apps/admin-web/src/pages/PartnerAccountsPage.tsx index a5aa5f7..a090f5b 100644 --- a/apps/admin-web/src/pages/PartnerAccountsPage.tsx +++ b/apps/admin-web/src/pages/PartnerAccountsPage.tsx @@ -4,7 +4,7 @@ import { } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { request, type Paginated } from '../lib/api'; -import { ACCOUNT_STATUS_LABELS, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants'; +import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; type Row = { @@ -46,7 +46,7 @@ export default function PartnerAccountsPage() { const [partners, setPartners] = useState([]); async function loadPartners() { - const res = await request>('/admin/partners?pageSize=200'); + const res = await request>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); setPartners(res.items); } diff --git a/apps/admin-web/src/pages/StoreAccountsPage.tsx b/apps/admin-web/src/pages/StoreAccountsPage.tsx index bbbcf7b..8fbd3b1 100644 --- a/apps/admin-web/src/pages/StoreAccountsPage.tsx +++ b/apps/admin-web/src/pages/StoreAccountsPage.tsx @@ -4,7 +4,7 @@ import { } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { request, type Paginated } from '../lib/api'; -import { ACCOUNT_STATUS_LABELS, STORE_STATUS_LABELS, fmtTime } from '../lib/constants'; +import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; type Row = { @@ -34,7 +34,7 @@ export default function StoreAccountsPage() { const [stores, setStores] = useState([]); async function loadStores() { - const res = await request>('/admin/stores?pageSize=200'); + const res = await request>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); setStores(res.items); } diff --git a/apps/admin-web/src/pages/StoreMediaPage.tsx b/apps/admin-web/src/pages/StoreMediaPage.tsx index b04805b..c293c8b 100644 --- a/apps/admin-web/src/pages/StoreMediaPage.tsx +++ b/apps/admin-web/src/pages/StoreMediaPage.tsx @@ -4,7 +4,7 @@ import { } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { request, type Paginated } from '../lib/api'; -import { MEDIA_TYPE_LABELS, fmtTime } from '../lib/constants'; +import { ADMIN_OPTIONS_PAGE_SIZE, MEDIA_TYPE_LABELS, fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; import OssUpload from '../components/OssUpload'; @@ -36,7 +36,7 @@ export default function StoreMediaPage() { const [stores, setStores] = useState([]); async function loadStores() { - const res = await request>('/admin/stores?pageSize=200'); + const res = await request>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); setStores(res.items); } diff --git a/apps/admin-web/src/pages/StoresPage.tsx b/apps/admin-web/src/pages/StoresPage.tsx index c3167ac..5800d91 100644 --- a/apps/admin-web/src/pages/StoresPage.tsx +++ b/apps/admin-web/src/pages/StoresPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { Alert, Button, @@ -18,13 +18,15 @@ import { } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { request, type Paginated } from '../lib/api'; -import { STORE_STATUS_LABELS, fmtTime } from '../lib/constants'; +import { ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants'; import { validateStoreCreateStep1, validateStoreCreateStep3, type StoreCreateForm, } from '../lib/storeCreate'; import { useAdminList } from '../lib/useAdminList'; +import { resolveRegionBinding } from '../lib/china-region'; +import ChinaRegionCascader from '../components/ChinaRegionCascader'; import OssUpload from '../components/OssUpload'; const CREATE_STEPS = [ @@ -50,7 +52,13 @@ type StoreRow = { }; type PartnerOption = { id: string; companyName: string }; -type CityOption = { id: string; name: string; code: string; partnerId?: string | null }; +type CityOption = { + id: string; + name: string; + code: string; + partnerId?: string | null; + partner?: { id: string; companyName: string }; +}; export default function StoresPage() { const [form] = Form.useForm(); @@ -75,16 +83,55 @@ export default function StoresPage() { const [createError, setCreateError] = useState(''); const [partners, setPartners] = useState([]); const [cities, setCities] = useState([]); + const [optionsLoading, setOptionsLoading] = useState(false); const selectedPartnerId = Form.useWatch('partnerId', createForm); + const selectedRegionCodes = Form.useWatch('regionCodes', createForm); + const selectedCityId = Form.useWatch('cityId', createForm); + + function bindRegionSelection(codes: string[], partnerId?: string) { + const binding = resolveRegionBinding(codes, cities, partnerId ?? selectedPartnerId); + if (!binding) { + createForm.setFieldsValue({ regionCodes: codes, cityId: undefined }); + return; + } + createForm.setFieldsValue({ + regionCodes: codes, + province: binding.region.province, + city: binding.region.city, + district: binding.region.district, + districtCode: binding.region.districtCode, + cityId: binding.cityId, + }); + } + + const regionBindingHint = useMemo(() => { + if (!selectedRegionCodes?.length) return null; + const binding = resolveRegionBinding(selectedRegionCodes, cities, selectedPartnerId); + if (!binding) return null; + if (binding.cityId && binding.matchedCity) { + return `已匹配开城城市:${binding.matchedCity.name}(区划 ${binding.cityCode},区县 ${binding.region.districtCode})`; + } + return `区划 ${binding.cityCode} 暂未开城,请先在「开城 → 开城城市」添加`; + }, [selectedRegionCodes, cities, selectedPartnerId]); async function loadOptions() { - const [p, c] = await Promise.all([ - request>('/admin/partners?pageSize=200'), - request>('/admin/cities?pageSize=200'), - ]); - setPartners(p.items); - setCities(c.items); + setOptionsLoading(true); + try { + const qs = `pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`; + const [p, c] = await Promise.all([ + request>(`/admin/partners?${qs}`), + request>(`/admin/cities?${qs}`), + ]); + setPartners(p.items); + setCities(c.items); + if (!p.items.length) message.warning('暂无开城合伙人,请先在「开城 → 开城合伙人」中创建'); + if (!c.items.length) message.warning('暂无开城城市,请先在「开城 → 开城城市」中创建'); + } catch (e) { + message.error(e instanceof Error ? e.message : '加载合伙人/城市失败'); + } finally { + setOptionsLoading(false); + } } function closeCreateModal() { @@ -113,7 +160,7 @@ export default function StoresPage() { return; } try { - await createForm.validateFields(['partnerId', 'cityId', 'name', 'phone', 'district', 'address']); + await createForm.validateFields(['partnerId', 'regionCodes', 'cityId', 'name', 'phone', 'address']); } catch { return; } @@ -123,49 +170,53 @@ export default function StoresPage() { } async function handleCreateSubmit() { - const values = createForm.getFieldsValue(); - const step1Msg = validateStoreCreateStep1(values); - if (step1Msg) { - setCreateError(step1Msg); - setCreateStep(0); - return; - } - const step3Msg = validateStoreCreateStep3(values); - if (step3Msg) { - setCreateError(step3Msg); - return; - } + try { + const values = await createForm.validateFields(); + const step3Msg = validateStoreCreateStep3(values); + if (step3Msg) { + setCreateError(step3Msg); + return; + } - const envPhotoUrls = (values.envPhotoUrls ?? []).map((u) => u?.trim()).filter(Boolean) as string[]; - await request('/admin/stores', { - method: 'POST', - body: JSON.stringify({ - partnerId: values.partnerId, - cityId: values.cityId, - name: values.name.trim(), - phone: values.phone.trim(), - district: values.district.trim(), - address: values.address.trim(), - intro: values.intro?.trim() || undefined, - coverUrl: values.coverUrl?.trim() || undefined, - envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined, - contractUrl: values.contractUrl?.trim() || undefined, - bankAccountName: values.bankAccountName.trim(), - bankAccountNo: values.bankAccountNo.replace(/\s/g, ''), - bankBranch: values.bankBranch.trim(), - accountPhone: values.accountPhone?.trim() || undefined, - accountName: values.accountName?.trim() || undefined, - }), - }); - message.success('门店已创建'); - closeCreateModal(); - void reload(); + const envPhotoUrls = (values.envPhotoUrls ?? []).map((u: string) => u?.trim()).filter(Boolean) as string[]; + await request('/admin/stores', { + method: 'POST', + body: JSON.stringify({ + partnerId: values.partnerId, + cityId: values.cityId, + province: values.province, + city: values.city, + name: values.name.trim(), + phone: values.phone.trim(), + district: values.district.trim(), + address: values.address.trim(), + intro: values.intro?.trim() || undefined, + coverUrl: values.coverUrl?.trim() || undefined, + envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined, + contractUrl: values.contractUrl?.trim() || undefined, + bankAccountName: values.bankAccountName.trim(), + bankAccountNo: values.bankAccountNo.replace(/\s/g, ''), + bankBranch: values.bankBranch.trim(), + accountPhone: values.accountPhone?.trim() || undefined, + accountName: values.accountName?.trim() || undefined, + }), + }); + message.success('门店已创建'); + closeCreateModal(); + void reload(); + } catch (e) { + if (e && typeof e === 'object' && 'errorFields' in e) { + const fields = e as { errorFields?: Array<{ name: string[] }> }; + const first = fields.errorFields?.[0]?.name?.[0]; + if (first === 'partnerId' || first === 'cityId' || first === 'regionCodes' || first === 'name' || first === 'phone') { + setCreateStep(0); + } + return; + } + message.error(e instanceof Error ? e.message : '创建失败'); + } } - const filteredCities = selectedPartnerId - ? cities.filter((c) => String(c.partnerId ?? '') === selectedPartnerId) - : cities; - const columns: ColumnsType = [ { title: '封面', dataIndex: 'coverUrl', width: 72, @@ -277,33 +328,47 @@ export default function StoresPage() { {createError && ( )} -
- {createStep === 0 && ( - <> + +
({ value: c.id, label: `${c.name} (${c.code})` }))} - /> + + {regionBindingHint} + + ) : null} + > + bindRegionSelection(codes)} /> + + + + + - - - @@ -316,10 +381,8 @@ export default function StoresPage() { - - )} - {createStep === 1 && ( - <> +
+
preV1 照片上传为选填,可直接下一步(与合伙人端一致)。 @@ -341,10 +404,8 @@ export default function StoresPage() { - - )} - {createStep === 2 && ( - <> +
+
@@ -359,8 +420,7 @@ export default function StoresPage() { showIcon message="请确保银行卡信息准确,以免影响门店餐费结算。" /> - - )} +
diff --git a/apps/h5-user/src/pages/StoreListPage.tsx b/apps/h5-user/src/pages/StoreListPage.tsx index 858a8a1..8e39532 100644 --- a/apps/h5-user/src/pages/StoreListPage.tsx +++ b/apps/h5-user/src/pages/StoreListPage.tsx @@ -11,6 +11,12 @@ import { type RegionSelection, } from '../lib/region-data'; +type OpenCity = { + code: string; + name: string; + province: string; +}; + type StoreItem = { id: string; name: string; @@ -25,6 +31,11 @@ type StoreItem = { category?: { name: string } | null; }; +function resolveCityCode(region: RegionSelection, cities: OpenCity[]): string | undefined { + if (region.province === REGION_ALL || region.city === REGION_ALL) return undefined; + return cities.find((c) => c.province === region.province && c.name === region.city)?.code; +} + const CATEGORY_TABS = ['全部', '火锅', '地方菜', '高端餐饮', '烧烤烤肉', '西餐'] as const; const MOCK_DISTANCES = ['800m', '1.2km', '3.5km', '1.5km', '2.0km']; @@ -43,29 +54,56 @@ function formatHours(store: StoreItem) { export default function StoreListPage() { const navigate = useNavigate(); + const [cities, setCities] = useState([]); const [stores, setStores] = useState([]); + const [loading, setLoading] = useState(false); const [categoryTab, setCategoryTab] = useState('全部'); const [keyword, setKeyword] = useState(''); const [region, setRegion] = useState(DEFAULT_REGION); const [regionPickerOpen, setRegionPickerOpen] = useState(false); + const cityCode = useMemo(() => resolveCityCode(region, cities), [region, cities]); + useEffect(() => { - request('USER_H5', '/stores?cityCode=410100').then(setStores); + request('USER_H5', '/catalog/cities').then(setCities); }, []); + useEffect(() => { + let cancelled = false; + setLoading(true); + const qs = cityCode ? `?cityCode=${encodeURIComponent(cityCode)}` : ''; + request('USER_H5', `/stores${qs}`) + .then((data) => { + if (!cancelled) setStores(data); + }) + .catch(() => { + if (!cancelled) setStores([]); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [cityCode]); + const filtered = useMemo(() => { let list = stores; if (categoryTab !== '全部') { list = list.filter((s) => s.category?.name === categoryTab); } - list = list.filter((s) => { - const province = s.province ?? '河南省'; - const city = s.cityName ?? '郑州市'; - if (region.province !== REGION_ALL && province !== region.province) return false; - if (region.city !== REGION_ALL && city !== region.city) return false; - if (region.district !== REGION_ALL && s.district !== region.district) return false; - return true; - }); + if (!cityCode) { + list = list.filter((s) => { + const province = s.province ?? '河南省'; + const city = s.cityName ?? '郑州市'; + if (region.province !== REGION_ALL && province !== region.province) return false; + if (region.city !== REGION_ALL && city !== region.city) return false; + if (region.district !== REGION_ALL && s.district !== region.district) return false; + return true; + }); + } else if (region.district !== REGION_ALL) { + list = list.filter((s) => s.district === region.district); + } const q = keyword.trim().toLowerCase(); if (q) { list = list.filter( @@ -76,7 +114,7 @@ export default function StoreListPage() { ); } return list; - }, [stores, categoryTab, keyword, region]); + }, [stores, categoryTab, keyword, region, cityCode]); const regionLabel = formatRegion(region.province, region.city, region.district); @@ -123,7 +161,8 @@ export default function StoreListPage() {
- {filtered.map((s, index) => ( + {loading &&
加载门店中...
} + {!loading && filtered.map((s, index) => (
))} - {filtered.length === 0 && ( + {!loading && filtered.length === 0 && (
{stores.length === 0 ? '暂无门店' : '未找到匹配门店'}
)} - {filtered.length > 0 && ( + {!loading && filtered.length > 0 && (

没有更多门店了

)}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc92cfd..ac4c679 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,9 @@ importers: dayjs: specifier: ^1.11.13 version: 1.11.21 + element-china-area-data: + specifier: ^6.1.0 + version: 6.1.0 react: specifier: ^18.3.1 version: 18.3.1