微信支付
This commit is contained in:
@@ -13,6 +13,7 @@
|
|||||||
"@dukang/shared-types": "workspace:*",
|
"@dukang/shared-types": "workspace:*",
|
||||||
"antd": "^5.22.0",
|
"antd": "^5.22.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
|
"element-china-area-data": "^6.1.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.26.0"
|
"react-router-dom": "^6.26.0"
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Cascader
|
||||||
|
options={CHINA_REGION_OPTIONS as DefaultOptionType[]}
|
||||||
|
value={value}
|
||||||
|
onChange={(codes) => onChange?.((codes ?? []) as string[])}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={placeholder}
|
||||||
|
showSearch={{
|
||||||
|
filter: (input, path) =>
|
||||||
|
path.some((option) =>
|
||||||
|
String(option.label ?? '').toLowerCase().includes(input.toLowerCase()),
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
changeOnSelect={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/** 与后端 PaginationQueryDto @Max(100) 一致,下拉选项拉取勿超过此值 */
|
||||||
|
export const ADMIN_OPTIONS_PAGE_SIZE = 100;
|
||||||
|
|
||||||
export const ORDER_STATUS_LABELS: Record<string, string> = {
|
export const ORDER_STATUS_LABELS: Record<string, string> = {
|
||||||
PENDING_PAY: '待付款',
|
PENDING_PAY: '待付款',
|
||||||
PENDING_SHIP: '待发货',
|
PENDING_SHIP: '待发货',
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
export type StoreCreateForm = {
|
export type StoreCreateForm = {
|
||||||
partnerId: string;
|
partnerId: string;
|
||||||
cityId: string;
|
cityId: string;
|
||||||
|
regionCodes?: string[];
|
||||||
|
province?: string;
|
||||||
|
city?: string;
|
||||||
|
district: string;
|
||||||
|
districtCode?: string;
|
||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
district: string;
|
|
||||||
address: string;
|
address: string;
|
||||||
intro?: string;
|
intro?: string;
|
||||||
coverUrl?: string;
|
coverUrl?: string;
|
||||||
@@ -19,13 +23,15 @@ export type StoreCreateForm = {
|
|||||||
const PHONE_RE = /^1\d{10}$/;
|
const PHONE_RE = /^1\d{10}$/;
|
||||||
const BANK_RE = /^\d{16,19}$/;
|
const BANK_RE = /^\d{16,19}$/;
|
||||||
|
|
||||||
export function validateStoreCreateStep1(form: Pick<StoreCreateForm, 'partnerId' | 'cityId' | 'name' | 'phone' | 'district' | 'address' | 'intro'>): string | null {
|
export function validateStoreCreateStep1(
|
||||||
|
form: Pick<StoreCreateForm, 'partnerId' | 'cityId' | 'regionCodes' | 'name' | 'phone' | 'address' | 'intro'>,
|
||||||
|
): string | null {
|
||||||
if (!form.partnerId) return '请选择开城合伙人';
|
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.name?.trim()) return '请填写门店名称';
|
||||||
if (!form.phone?.trim()) return '请填写联系电话';
|
if (!form.phone?.trim()) return '请填写联系电话';
|
||||||
if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号';
|
if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号';
|
||||||
if (!form.district?.trim()) return '请填写区县';
|
|
||||||
if (!form.address?.trim()) return '请填写详细地址';
|
if (!form.address?.trim()) return '请填写详细地址';
|
||||||
if (form.intro?.trim()) {
|
if (form.intro?.trim()) {
|
||||||
const len = form.intro.trim().length;
|
const len = form.intro.trim().length;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { request, type Paginated } from '../lib/api';
|
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';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -37,7 +37,7 @@ export default function CitiesPage() {
|
|||||||
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||||
|
|
||||||
async function loadPartners() {
|
async function loadPartners() {
|
||||||
const res = await request<Paginated<PartnerOption>>('/admin/partners?pageSize=200');
|
const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||||
setPartners(res.items);
|
setPartners(res.items);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { request, type Paginated } from '../lib/api';
|
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';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -46,7 +46,7 @@ export default function PartnerAccountsPage() {
|
|||||||
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||||
|
|
||||||
async function loadPartners() {
|
async function loadPartners() {
|
||||||
const res = await request<Paginated<PartnerOption>>('/admin/partners?pageSize=200');
|
const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||||
setPartners(res.items);
|
setPartners(res.items);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { request, type Paginated } from '../lib/api';
|
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';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -34,7 +34,7 @@ export default function StoreAccountsPage() {
|
|||||||
const [stores, setStores] = useState<StoreOption[]>([]);
|
const [stores, setStores] = useState<StoreOption[]>([]);
|
||||||
|
|
||||||
async function loadStores() {
|
async function loadStores() {
|
||||||
const res = await request<Paginated<StoreOption>>('/admin/stores?pageSize=200');
|
const res = await request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||||
setStores(res.items);
|
setStores(res.items);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { request, type Paginated } from '../lib/api';
|
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 { useAdminList } from '../lib/useAdminList';
|
||||||
import OssUpload from '../components/OssUpload';
|
import OssUpload from '../components/OssUpload';
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ export default function StoreMediaPage() {
|
|||||||
const [stores, setStores] = useState<StoreOption[]>([]);
|
const [stores, setStores] = useState<StoreOption[]>([]);
|
||||||
|
|
||||||
async function loadStores() {
|
async function loadStores() {
|
||||||
const res = await request<Paginated<StoreOption>>('/admin/stores?pageSize=200');
|
const res = await request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||||
setStores(res.items);
|
setStores(res.items);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
@@ -18,13 +18,15 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { request, type Paginated } from '../lib/api';
|
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 {
|
import {
|
||||||
validateStoreCreateStep1,
|
validateStoreCreateStep1,
|
||||||
validateStoreCreateStep3,
|
validateStoreCreateStep3,
|
||||||
type StoreCreateForm,
|
type StoreCreateForm,
|
||||||
} from '../lib/storeCreate';
|
} from '../lib/storeCreate';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { resolveRegionBinding } from '../lib/china-region';
|
||||||
|
import ChinaRegionCascader from '../components/ChinaRegionCascader';
|
||||||
import OssUpload from '../components/OssUpload';
|
import OssUpload from '../components/OssUpload';
|
||||||
|
|
||||||
const CREATE_STEPS = [
|
const CREATE_STEPS = [
|
||||||
@@ -50,7 +52,13 @@ type StoreRow = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type PartnerOption = { id: string; companyName: string };
|
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() {
|
export default function StoresPage() {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
@@ -75,16 +83,55 @@ export default function StoresPage() {
|
|||||||
const [createError, setCreateError] = useState('');
|
const [createError, setCreateError] = useState('');
|
||||||
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||||
const [cities, setCities] = useState<CityOption[]>([]);
|
const [cities, setCities] = useState<CityOption[]>([]);
|
||||||
|
const [optionsLoading, setOptionsLoading] = useState(false);
|
||||||
|
|
||||||
const selectedPartnerId = Form.useWatch('partnerId', createForm);
|
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() {
|
async function loadOptions() {
|
||||||
const [p, c] = await Promise.all([
|
setOptionsLoading(true);
|
||||||
request<Paginated<PartnerOption>>('/admin/partners?pageSize=200'),
|
try {
|
||||||
request<Paginated<CityOption>>('/admin/cities?pageSize=200'),
|
const qs = `pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`;
|
||||||
]);
|
const [p, c] = await Promise.all([
|
||||||
setPartners(p.items);
|
request<Paginated<PartnerOption>>(`/admin/partners?${qs}`),
|
||||||
setCities(c.items);
|
request<Paginated<CityOption>>(`/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() {
|
function closeCreateModal() {
|
||||||
@@ -113,7 +160,7 @@ export default function StoresPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await createForm.validateFields(['partnerId', 'cityId', 'name', 'phone', 'district', 'address']);
|
await createForm.validateFields(['partnerId', 'regionCodes', 'cityId', 'name', 'phone', 'address']);
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -123,49 +170,53 @@ export default function StoresPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreateSubmit() {
|
async function handleCreateSubmit() {
|
||||||
const values = createForm.getFieldsValue();
|
try {
|
||||||
const step1Msg = validateStoreCreateStep1(values);
|
const values = await createForm.validateFields();
|
||||||
if (step1Msg) {
|
const step3Msg = validateStoreCreateStep3(values);
|
||||||
setCreateError(step1Msg);
|
if (step3Msg) {
|
||||||
setCreateStep(0);
|
setCreateError(step3Msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const step3Msg = validateStoreCreateStep3(values);
|
|
||||||
if (step3Msg) {
|
|
||||||
setCreateError(step3Msg);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const envPhotoUrls = (values.envPhotoUrls ?? []).map((u) => u?.trim()).filter(Boolean) as string[];
|
const envPhotoUrls = (values.envPhotoUrls ?? []).map((u: string) => u?.trim()).filter(Boolean) as string[];
|
||||||
await request('/admin/stores', {
|
await request('/admin/stores', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
partnerId: values.partnerId,
|
partnerId: values.partnerId,
|
||||||
cityId: values.cityId,
|
cityId: values.cityId,
|
||||||
name: values.name.trim(),
|
province: values.province,
|
||||||
phone: values.phone.trim(),
|
city: values.city,
|
||||||
district: values.district.trim(),
|
name: values.name.trim(),
|
||||||
address: values.address.trim(),
|
phone: values.phone.trim(),
|
||||||
intro: values.intro?.trim() || undefined,
|
district: values.district.trim(),
|
||||||
coverUrl: values.coverUrl?.trim() || undefined,
|
address: values.address.trim(),
|
||||||
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
|
intro: values.intro?.trim() || undefined,
|
||||||
contractUrl: values.contractUrl?.trim() || undefined,
|
coverUrl: values.coverUrl?.trim() || undefined,
|
||||||
bankAccountName: values.bankAccountName.trim(),
|
envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined,
|
||||||
bankAccountNo: values.bankAccountNo.replace(/\s/g, ''),
|
contractUrl: values.contractUrl?.trim() || undefined,
|
||||||
bankBranch: values.bankBranch.trim(),
|
bankAccountName: values.bankAccountName.trim(),
|
||||||
accountPhone: values.accountPhone?.trim() || undefined,
|
bankAccountNo: values.bankAccountNo.replace(/\s/g, ''),
|
||||||
accountName: values.accountName?.trim() || undefined,
|
bankBranch: values.bankBranch.trim(),
|
||||||
}),
|
accountPhone: values.accountPhone?.trim() || undefined,
|
||||||
});
|
accountName: values.accountName?.trim() || undefined,
|
||||||
message.success('门店已创建');
|
}),
|
||||||
closeCreateModal();
|
});
|
||||||
void reload();
|
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<StoreRow> = [
|
const columns: ColumnsType<StoreRow> = [
|
||||||
{
|
{
|
||||||
title: '封面', dataIndex: 'coverUrl', width: 72,
|
title: '封面', dataIndex: 'coverUrl', width: 72,
|
||||||
@@ -277,33 +328,47 @@ export default function StoresPage() {
|
|||||||
{createError && (
|
{createError && (
|
||||||
<Alert type="error" message={createError} showIcon style={{ marginBottom: 16 }} />
|
<Alert type="error" message={createError} showIcon style={{ marginBottom: 16 }} />
|
||||||
)}
|
)}
|
||||||
<Form form={createForm} layout="vertical">
|
<Form form={createForm} layout="vertical" preserve>
|
||||||
{createStep === 0 && (
|
<div style={{ display: createStep === 0 ? 'block' : 'none' }}>
|
||||||
<>
|
|
||||||
<Form.Item name="partnerId" label="开城合伙人" rules={[{ required: true, message: '请选择开城合伙人' }]}>
|
<Form.Item name="partnerId" label="开城合伙人" rules={[{ required: true, message: '请选择开城合伙人' }]}>
|
||||||
<Select
|
<Select
|
||||||
showSearch
|
showSearch
|
||||||
|
loading={optionsLoading}
|
||||||
optionFilterProp="label"
|
optionFilterProp="label"
|
||||||
|
placeholder={optionsLoading ? '加载中…' : '请选择开城合伙人'}
|
||||||
options={partners.map((p) => ({ value: p.id, label: p.companyName }))}
|
options={partners.map((p) => ({ value: p.id, label: p.companyName }))}
|
||||||
onChange={() => createForm.setFieldValue('cityId', undefined)}
|
onChange={(partnerId) => {
|
||||||
|
const codes = createForm.getFieldValue('regionCodes') as string[] | undefined;
|
||||||
|
if (codes?.length === 3) bindRegionSelection(codes, partnerId);
|
||||||
|
else createForm.setFieldValue('cityId', undefined);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="cityId" label="开城城市" rules={[{ required: true, message: '请选择开城城市' }]}>
|
<Form.Item
|
||||||
<Select
|
name="regionCodes"
|
||||||
showSearch
|
label="所在地区"
|
||||||
optionFilterProp="label"
|
rules={[{ required: true, message: '请选择省 / 市 / 区县' }]}
|
||||||
options={filteredCities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))}
|
extra={regionBindingHint ? (
|
||||||
/>
|
<Typography.Text type={selectedCityId ? 'secondary' : 'warning'}>
|
||||||
|
{regionBindingHint}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
>
|
||||||
|
<ChinaRegionCascader onChange={(codes) => bindRegionSelection(codes)} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="cityId" hidden rules={[{ required: true, message: '请选择所在地区以匹配开城城市' }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="province" hidden><Input /></Form.Item>
|
||||||
|
<Form.Item name="city" hidden><Input /></Form.Item>
|
||||||
|
<Form.Item name="district" hidden><Input /></Form.Item>
|
||||||
|
<Form.Item name="districtCode" hidden><Input /></Form.Item>
|
||||||
<Form.Item name="name" label="门店名称" rules={[{ required: true, message: '请填写门店名称' }]}>
|
<Form.Item name="name" label="门店名称" rules={[{ required: true, message: '请填写门店名称' }]}>
|
||||||
<Input placeholder="请输入门店名称" />
|
<Input placeholder="请输入门店名称" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="phone" label="联系电话" rules={[{ required: true, message: '请填写联系电话' }]}>
|
<Form.Item name="phone" label="联系电话" rules={[{ required: true, message: '请填写联系电话' }]}>
|
||||||
<Input placeholder="11位手机号" />
|
<Input placeholder="11位手机号" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="district" label="区县" rules={[{ required: true, message: '请填写区县' }]}>
|
|
||||||
<Input placeholder="例如:金水区" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="address" label="详细地址" rules={[{ required: true, message: '请填写详细地址' }]}>
|
<Form.Item name="address" label="详细地址" rules={[{ required: true, message: '请填写详细地址' }]}>
|
||||||
<Input.TextArea rows={2} placeholder="请输入详细门牌号" />
|
<Input.TextArea rows={2} placeholder="请输入详细门牌号" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -316,10 +381,8 @@ export default function StoresPage() {
|
|||||||
<Form.Item name="accountName" label="店长姓名">
|
<Form.Item name="accountName" label="店长姓名">
|
||||||
<Input placeholder="默认同门店名" />
|
<Input placeholder="默认同门店名" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</>
|
</div>
|
||||||
)}
|
<div style={{ display: createStep === 1 ? 'block' : 'none' }}>
|
||||||
{createStep === 1 && (
|
|
||||||
<>
|
|
||||||
<Typography.Paragraph type="secondary">
|
<Typography.Paragraph type="secondary">
|
||||||
preV1 照片上传为选填,可直接下一步(与合伙人端一致)。
|
preV1 照片上传为选填,可直接下一步(与合伙人端一致)。
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
@@ -341,10 +404,8 @@ export default function StoresPage() {
|
|||||||
<Form.Item name="contractUrl" label="签约合同">
|
<Form.Item name="contractUrl" label="签约合同">
|
||||||
<OssUpload bizType="CONTRACT" mediaType="FILE" accept="image/*,.pdf" />
|
<OssUpload bizType="CONTRACT" mediaType="FILE" accept="image/*,.pdf" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</>
|
</div>
|
||||||
)}
|
<div style={{ display: createStep === 2 ? 'block' : 'none' }}>
|
||||||
{createStep === 2 && (
|
|
||||||
<>
|
|
||||||
<Form.Item name="bankAccountName" label="户主姓名" rules={[{ required: true, message: '请填写户主姓名' }]}>
|
<Form.Item name="bankAccountName" label="户主姓名" rules={[{ required: true, message: '请填写户主姓名' }]}>
|
||||||
<Input placeholder="银行卡实名姓名" />
|
<Input placeholder="银行卡实名姓名" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -359,8 +420,7 @@ export default function StoresPage() {
|
|||||||
showIcon
|
showIcon
|
||||||
message="请确保银行卡信息准确,以免影响门店餐费结算。"
|
message="请确保银行卡信息准确,以免影响门店餐费结算。"
|
||||||
/>
|
/>
|
||||||
</>
|
</div>
|
||||||
)}
|
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ import {
|
|||||||
type RegionSelection,
|
type RegionSelection,
|
||||||
} from '../lib/region-data';
|
} from '../lib/region-data';
|
||||||
|
|
||||||
|
type OpenCity = {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
province: string;
|
||||||
|
};
|
||||||
|
|
||||||
type StoreItem = {
|
type StoreItem = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -25,6 +31,11 @@ type StoreItem = {
|
|||||||
category?: { name: string } | null;
|
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 CATEGORY_TABS = ['全部', '火锅', '地方菜', '高端餐饮', '烧烤烤肉', '西餐'] as const;
|
||||||
const MOCK_DISTANCES = ['800m', '1.2km', '3.5km', '1.5km', '2.0km'];
|
const MOCK_DISTANCES = ['800m', '1.2km', '3.5km', '1.5km', '2.0km'];
|
||||||
|
|
||||||
@@ -43,29 +54,56 @@ function formatHours(store: StoreItem) {
|
|||||||
|
|
||||||
export default function StoreListPage() {
|
export default function StoreListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [cities, setCities] = useState<OpenCity[]>([]);
|
||||||
const [stores, setStores] = useState<StoreItem[]>([]);
|
const [stores, setStores] = useState<StoreItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
const [categoryTab, setCategoryTab] = useState<string>('全部');
|
const [categoryTab, setCategoryTab] = useState<string>('全部');
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [region, setRegion] = useState<RegionSelection>(DEFAULT_REGION);
|
const [region, setRegion] = useState<RegionSelection>(DEFAULT_REGION);
|
||||||
const [regionPickerOpen, setRegionPickerOpen] = useState(false);
|
const [regionPickerOpen, setRegionPickerOpen] = useState(false);
|
||||||
|
|
||||||
|
const cityCode = useMemo(() => resolveCityCode(region, cities), [region, cities]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
request<StoreItem[]>('USER_H5', '/stores?cityCode=410100').then(setStores);
|
request<OpenCity[]>('USER_H5', '/catalog/cities').then(setCities);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
const qs = cityCode ? `?cityCode=${encodeURIComponent(cityCode)}` : '';
|
||||||
|
request<StoreItem[]>('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(() => {
|
const filtered = useMemo(() => {
|
||||||
let list = stores;
|
let list = stores;
|
||||||
if (categoryTab !== '全部') {
|
if (categoryTab !== '全部') {
|
||||||
list = list.filter((s) => s.category?.name === categoryTab);
|
list = list.filter((s) => s.category?.name === categoryTab);
|
||||||
}
|
}
|
||||||
list = list.filter((s) => {
|
if (!cityCode) {
|
||||||
const province = s.province ?? '河南省';
|
list = list.filter((s) => {
|
||||||
const city = s.cityName ?? '郑州市';
|
const province = s.province ?? '河南省';
|
||||||
if (region.province !== REGION_ALL && province !== region.province) return false;
|
const city = s.cityName ?? '郑州市';
|
||||||
if (region.city !== REGION_ALL && city !== region.city) return false;
|
if (region.province !== REGION_ALL && province !== region.province) return false;
|
||||||
if (region.district !== REGION_ALL && s.district !== region.district) return false;
|
if (region.city !== REGION_ALL && city !== region.city) return false;
|
||||||
return true;
|
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();
|
const q = keyword.trim().toLowerCase();
|
||||||
if (q) {
|
if (q) {
|
||||||
list = list.filter(
|
list = list.filter(
|
||||||
@@ -76,7 +114,7 @@ export default function StoreListPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return list;
|
return list;
|
||||||
}, [stores, categoryTab, keyword, region]);
|
}, [stores, categoryTab, keyword, region, cityCode]);
|
||||||
|
|
||||||
const regionLabel = formatRegion(region.province, region.city, region.district);
|
const regionLabel = formatRegion(region.province, region.city, region.district);
|
||||||
|
|
||||||
@@ -123,7 +161,8 @@ export default function StoreListPage() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="store-list">
|
<main className="store-list">
|
||||||
{filtered.map((s, index) => (
|
{loading && <div className="store-empty">加载门店中...</div>}
|
||||||
|
{!loading && filtered.map((s, index) => (
|
||||||
<article
|
<article
|
||||||
key={s.id}
|
key={s.id}
|
||||||
className="store-card"
|
className="store-card"
|
||||||
@@ -170,11 +209,11 @@ export default function StoreListPage() {
|
|||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{filtered.length === 0 && (
|
{!loading && filtered.length === 0 && (
|
||||||
<div className="store-empty">{stores.length === 0 ? '暂无门店' : '未找到匹配门店'}</div>
|
<div className="store-empty">{stores.length === 0 ? '暂无门店' : '未找到匹配门店'}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filtered.length > 0 && (
|
{!loading && filtered.length > 0 && (
|
||||||
<p className="store-list-end">没有更多门店了</p>
|
<p className="store-list-end">没有更多门店了</p>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
Generated
+3
@@ -22,6 +22,9 @@ importers:
|
|||||||
dayjs:
|
dayjs:
|
||||||
specifier: ^1.11.13
|
specifier: ^1.11.13
|
||||||
version: 1.11.21
|
version: 1.11.21
|
||||||
|
element-china-area-data:
|
||||||
|
specifier: ^6.1.0
|
||||||
|
version: 6.1.0
|
||||||
react:
|
react:
|
||||||
specifier: ^18.3.1
|
specifier: ^18.3.1
|
||||||
version: 18.3.1
|
version: 18.3.1
|
||||||
|
|||||||
Reference in New Issue
Block a user