diff --git a/apps/admin-web/src/components/ProxyOrderModal.tsx b/apps/admin-web/src/components/ProxyOrderModal.tsx new file mode 100644 index 0000000..f7423e1 --- /dev/null +++ b/apps/admin-web/src/components/ProxyOrderModal.tsx @@ -0,0 +1,303 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + Alert, + Button, + Checkbox, + Form, + Input, + InputNumber, + Modal, + Radio, + Select, + Space, + Typography, + message, +} from 'antd'; +import type { + HqProxyOrderCreateRequest, + PartnerProxyDeliveryMode, + PartnerProxyOrderOptions, + PartnerProxyOrderPreviewResult, +} from '@dukang/shared-types'; +import ChinaRegionCascader from './ChinaRegionCascader'; +import { parseRegionCodes } from '../lib/china-region'; +import { request } from '../lib/api'; + +type ProxyOrderModalProps = { + open: boolean; + onClose: () => void; + onSuccess: (order: { id: string; orderNo: string }) => void; +}; + +function fmtMoney(n: number) { + return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + +export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrderModalProps) { + const [options, setOptions] = useState(null); + const [loadingOptions, setLoadingOptions] = useState(false); + const [phone, setPhone] = useState(''); + const [receiverName, setReceiverName] = useState(''); + const [regionCodes, setRegionCodes] = useState([]); + const [addressDetail, setAddressDetail] = useState(''); + const [productId, setProductId] = useState(); + const [quantity, setQuantity] = useState(2); + const [promoCodeId, setPromoCodeId] = useState(); + const [deliveryMode, setDeliveryMode] = useState('ADDRESS'); + const [autoReceive, setAutoReceive] = useState(false); + const [preview, setPreview] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const region = useMemo(() => parseRegionCodes(regionCodes), [regionCodes]); + + useEffect(() => { + if (!open) return; + setLoadingOptions(true); + request('/admin/proxy-orders/options') + .then((data) => { + setOptions(data); + if (data.products[0]) setProductId(data.products[0].id); + }) + .catch((e) => message.error(e instanceof Error ? e.message : '加载失败')) + .finally(() => setLoadingOptions(false)); + }, [open]); + + useEffect(() => { + if (!open || !productId || quantity < 1) { + setPreview(null); + return; + } + const timer = window.setTimeout(() => { + setPreviewLoading(true); + request('/admin/proxy-orders/preview', { + method: 'POST', + body: JSON.stringify({ + productId, + quantity, + deliveryMode, + receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined, + receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined, + }), + }) + .then(setPreview) + .catch(() => setPreview(null)) + .finally(() => setPreviewLoading(false)); + }, 300); + return () => window.clearTimeout(timer); + }, [open, productId, quantity, deliveryMode, region?.city, region?.district]); + + function resetForm() { + setPhone(''); + setReceiverName(''); + setRegionCodes([]); + setAddressDetail(''); + setQuantity(2); + setPromoCodeId(undefined); + setDeliveryMode('ADDRESS'); + setAutoReceive(false); + setPreview(null); + setProductId(options?.products[0]?.id); + } + + function handleClose() { + resetForm(); + onClose(); + } + + function validateForm(): string | null { + if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号'; + if (!productId) return '请选择商品'; + if (deliveryMode === 'ADDRESS') { + if (!region?.province || !region?.city || !region?.district) return '请选择省市区'; + if (!addressDetail.trim()) return '请填写详细地址'; + if (!autoReceive) return '配送到址须勾选同意自动收货'; + } else { + const product = options?.products.find((p) => p.id === productId); + if (product && product.allowOnSitePickup === false) return '该商品不支持现场提货'; + } + if (!preview) return '请等待费用计算完成'; + return null; + } + + async function submit() { + const err = validateForm(); + if (err) { + message.warning(err); + return; + } + + const payload: HqProxyOrderCreateRequest = { + phone: phone.trim(), + deliveryMode, + autoReceive: deliveryMode === 'ADDRESS' ? true : undefined, + receiverName: receiverName.trim() || undefined, + province: deliveryMode === 'ADDRESS' ? region?.province : undefined, + city: deliveryMode === 'ADDRESS' ? region?.city : undefined, + district: deliveryMode === 'ADDRESS' ? region?.district : undefined, + addressDetail: deliveryMode === 'ADDRESS' ? addressDetail.trim() : undefined, + productId: productId!, + quantity, + promoCodeId: promoCodeId || undefined, + }; + + setSubmitting(true); + try { + const order = await request<{ id: string; orderNo: string }>('/admin/proxy-orders', { + method: 'POST', + body: JSON.stringify(payload), + }); + message.success(`代下单成功:${order.orderNo}`); + resetForm(); + onSuccess(order); + } catch (e) { + message.error(e instanceof Error ? e.message : '下单失败'); + } finally { + setSubmitting(false); + } + } + + const deliveryLabel = + preview?.deliveryType === 'ON_SITE_PICKUP' + ? '现场提货' + : preview?.deliveryType === 'CROSS_CITY' + ? '跨城配送' + : '同城配送'; + + return ( + + + + + } + > + + +
+ + setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))} + /> + + + + ({ + value: p.id, + label: `${p.code} · ${p.name}`, + }))} + /> + + ) : null} + + + { + setDeliveryMode(e.target.value); + if (e.target.value !== 'ADDRESS') setAutoReceive(false); + }} + options={[ + { value: 'ADDRESS', label: '配送到址' }, + { value: 'ON_SITE_PICKUP', label: '现场提货' }, + ]} + /> + + + {deliveryMode === 'ADDRESS' ? ( + <> + + setReceiverName(e.target.value)} + /> + + + + + + setAddressDetail(e.target.value)} + /> + + + setAutoReceive(e.target.checked)}> + 同意自动收货(配送到址必选) + + + + ) : null} + +
+ 费用预览 + {previewLoading ? ( +
计算中…
+ ) : preview ? ( + +
履约:{deliveryLabel}
+
商品金额:¥{fmtMoney(preview.productAmount)}
+
实付:¥{fmtMoney(preview.payAmount)}
+
好客权益:¥{fmtMoney(preview.benefitAmount)}
+
+ ) : ( +
+ 请完善商品与数量 +
+ )} +
+
+
+ ); +} diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 829be92..f44955d 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -148,6 +148,10 @@ export type AdminOrderRow = { fulfillmentWarehouse?: { id: string; name: string } | null; fulfillmentHold?: boolean; fulfillmentHoldReason?: string | null; + orderType?: string; + isProxyOrder?: boolean; + proxyPartnerName?: string | null; + proxyPartnerPhone?: string | null; user?: { id: string; userNo: string; phone: string | null; nickname: string | null }; delivery?: { provider: string; diff --git a/apps/admin-web/src/lib/constants.ts b/apps/admin-web/src/lib/constants.ts index 48ec1ca..41e52d6 100644 --- a/apps/admin-web/src/lib/constants.ts +++ b/apps/admin-web/src/lib/constants.ts @@ -19,6 +19,17 @@ export const ORDER_STATUS_LABELS: Record = { REFUNDED: '已退款', }; +/** 订单状态流转操作人(common_event.param3) */ +export const ORDER_STATUS_OPERATOR_LABELS: Record = { + USER: '用户', + USER_ON_SITE: '用户·现场取货', + USER_WECHAT_CONFIRM: '用户·微信确认收货', + WECHAT_TRADE_MANAGE: '微信结算同步', + MOCK: 'Mock', + MOCK_PAY: 'Mock 支付', + SYSTEM: '系统', +}; + /** 订单状态 Tag 颜色(Ant Design preset) */ export const ORDER_STATUS_COLORS: Record = { PENDING_PAY: 'red', diff --git a/apps/admin-web/src/pages/OrdersPage.tsx b/apps/admin-web/src/pages/OrdersPage.tsx index f077be4..750ef83 100644 --- a/apps/admin-web/src/pages/OrdersPage.tsx +++ b/apps/admin-web/src/pages/OrdersPage.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; import { Alert, Button, @@ -24,9 +25,11 @@ import { DELIVERY_PROVIDER_LABELS, ORDER_STATUS_COLORS, ORDER_STATUS_LABELS, + ORDER_STATUS_OPERATOR_LABELS, fmtTime, } from '../lib/constants'; import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types'; +import ProxyOrderModal from '../components/ProxyOrderModal'; type ShipDefaults = { provider: string; @@ -102,7 +105,13 @@ type OrderDetail = AdminOrderRow & { listUnitPrice?: number; items?: AdminOrderItem[]; payment?: Record | null; - statusLogs?: Array<{ fromStatus: string | null; toStatus: string; createdAt: string }>; + statusLogs?: Array<{ + fromStatus: string | null; + toStatus: string; + operator?: string | null; + remark?: string | null; + createdAt: string; + }>; benefitCoupons?: Array>; redeemSummary?: OrderRedeemSummary | null; redeemRecords?: OrderRedeemRecord[]; @@ -167,6 +176,7 @@ export default function OrdersPage() { const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [batchDeleteOpen, setBatchDeleteOpen] = useState(false); const [batchDeleting, setBatchDeleting] = useState(false); + const [deletingId, setDeletingId] = useState(null); const [shipDefaults, setShipDefaults] = useState(null); const [shipping, setShipping] = useState(false); const [logisticsShipping, setLogisticsShipping] = useState(false); @@ -175,7 +185,9 @@ export default function OrdersPage() { const [shipTarget, setShipTarget] = useState(null); const [shipMode, setShipMode] = useState('EXPRESS'); const [warehouses, setWarehouses] = useState([]); + const [proxyOpen, setProxyOpen] = useState(false); const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete'); + const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders'); const selectedOrders = useMemo( () => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)), @@ -207,6 +219,7 @@ export default function OrdersPage() { const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) }); if (values.orderNo) qs.set('orderNo', values.orderNo); if (values.status) qs.set('status', values.status); + if (values.orderType) qs.set('orderType', values.orderType); if (values.cityId) qs.set('cityId', values.cityId); if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone); if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true'); @@ -379,6 +392,43 @@ export default function OrdersPage() { } } + function askDeleteOrder(id: string, orderNo?: string) { + Modal.confirm({ + title: '确认删除订单?', + content: ( +
+

将永久删除订单{orderNo ? ` ${orderNo}` : ''} 及其配送、权益券、核销、发票等关联数据。

+

此操作不可恢复,请确认不是真实线上订单。

+
+ ), + okText: '确认删除', + okType: 'danger', + cancelText: '取消', + onOk: () => deleteOrder(id), + }); + } + + async function deleteOrder(id: string) { + setDeletingId(id); + try { + const res = await request<{ deleted: number; message: string }>(`/admin/orders/${id}`, { + method: 'DELETE', + }); + message.success(res.message || '订单已删除'); + if (detail?.id === id) { + setDrawerOpen(false); + setDetail(null); + } + setSelectedRowKeys((prev) => prev.filter((k) => k !== id)); + void load(); + } catch (e) { + message.error(e instanceof Error ? e.message : '删除失败'); + throw e; + } finally { + setDeletingId(null); + } + } + const columns: ColumnsType = [ { title: '订单号', dataIndex: 'orderNo', width: 180 }, { @@ -408,6 +458,11 @@ export default function OrdersPage() { {ORDER_STATUS_LABELS[s] || s} {row.fulfillmentHold ? 大单待确认 : null} + {row.orderType === 'PROXY' || row.isProxyOrder ? ( + + 代下单 + + ) : null} ), }, @@ -415,7 +470,8 @@ export default function OrdersPage() { title: '配送', dataIndex: 'deliveryType', width: 90, - render: (v) => (v === 'LOCAL' ? '同城' : '跨城'), + render: (v) => + v === 'ON_SITE_PICKUP' ? '现场提货' : v === 'LOCAL' ? '同城' : '跨城', }, { title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` }, { title: '收货人', dataIndex: 'receiverName', width: 90 }, @@ -439,7 +495,7 @@ export default function OrdersPage() { }, { title: '操作', - width: 140, + width: 180, fixed: 'right', render: (_, row) => ( @@ -451,6 +507,17 @@ export default function OrdersPage() { 配送 )} + {canDeleteOrders && ( + + )} ), }, @@ -460,16 +527,38 @@ export default function OrdersPage() {
订单监控 - {canDeleteOrders ? ( - - ) : null} + + {canProxyOrder ? ( + + ) : null} + {canDeleteOrders ? ( + + ) : null} + + + {!canDeleteOrders && profile ? ( + + 非超管账号需在 权限分配 中勾选危险操作「删除订单」。 + + } + /> + ) : null} +
{ setPage(1); void load(); }}> @@ -477,6 +566,17 @@ export default function OrdersPage() { + ) : null} + {detail.orderType === 'PROXY' || detail.isProxyOrder ? ( + 代下单 + ) : null} + {detail.orderType === 'PROXY' || detail.isProxyOrder || detail.proxyPartnerName ? ( + + {[detail.proxyPartnerName, detail.proxyPartnerPhone].filter(Boolean).join(' / ') || '—'} + + ) : null} {detail.user?.userNo} / {detail.user?.nickname} ¥{detail.productAmount} ¥{detail.payAmount} @@ -765,13 +882,40 @@ export default function OrdersPage() { 状态流转 String(i)} pagination={false} dataSource={detail.statusLogs} columns={[ - { title: '从', dataIndex: 'fromStatus', render: (v) => v || '—' }, - { title: '到', dataIndex: 'toStatus', render: (v) => ORDER_STATUS_LABELS[v] || v }, - { title: '时间', dataIndex: 'createdAt', render: (v) => new Date(v).toLocaleString('zh-CN') }, + { + title: '从', + dataIndex: 'fromStatus', + width: 100, + render: (v) => (v ? ORDER_STATUS_LABELS[v] || v : '—'), + }, + { + title: '到', + dataIndex: 'toStatus', + width: 100, + render: (v) => ORDER_STATUS_LABELS[v] || v, + }, + { + title: '操作人', + dataIndex: 'operator', + width: 120, + render: (v) => ORDER_STATUS_OPERATOR_LABELS[v] || v || '—', + }, + { + title: '备注', + dataIndex: 'remark', + ellipsis: true, + render: (v) => v || '—', + }, + { + title: '时间', + dataIndex: 'createdAt', + width: 170, + render: (v) => new Date(v).toLocaleString('zh-CN'), + }, ]} /> @@ -1078,6 +1222,16 @@ export default function OrdersPage() { ]} /> + + setProxyOpen(false)} + onSuccess={(order) => { + setProxyOpen(false); + void load(); + void openDetail(order.id); + }} + /> ); } diff --git a/apps/admin-web/src/pages/ProductsPage.tsx b/apps/admin-web/src/pages/ProductsPage.tsx index 6ec892e..92fd63a 100644 --- a/apps/admin-web/src/pages/ProductsPage.tsx +++ b/apps/admin-web/src/pages/ProductsPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Table, Tabs, Tag, Typography, message, @@ -32,6 +32,8 @@ type Row = { status: string; sortOrder: number; allowOnSitePickup?: boolean; + visibilityWhitelistEnabled?: boolean; + visibilityPhones?: string[]; mainImageUrl?: string | null; carouselUrls?: string[]; detailImageUrls?: string[]; @@ -51,6 +53,8 @@ type ProductFormValues = { status?: string; sortOrder?: number; allowOnSitePickup?: boolean; + visibilityWhitelistEnabled?: boolean; + visibilityPhones?: string[]; coverUrl?: string; carouselUrls?: string[]; detailImageUrls?: string[]; @@ -59,6 +63,13 @@ type ProductFormValues = { features?: Array<{ icon?: string; title?: string; desc?: string }>; }; +type UserPickRow = { + id: string; + phone?: string | null; + nickname?: string | null; + userNo?: string; +}; + function mapDetailToForm(d: Record) { const detail = (d.detailContent ?? {}) as ProductDetailContentDto; return { @@ -66,6 +77,8 @@ function mapDetailToForm(d: Record) { coverUrl: (d as { mainImageUrl?: string }).mainImageUrl, carouselUrls: ((d as Row).carouselUrls?.length ? (d as Row).carouselUrls : ['']) as string[], detailImageUrls: ((d as Row).detailImageUrls?.length ? (d as Row).detailImageUrls : ['']) as string[], + visibilityWhitelistEnabled: !!(d as Row).visibilityWhitelistEnabled, + visibilityPhones: ((d as Row).visibilityPhones ?? []) as string[], storyTitle: detail.storyTitle ?? '', storyText: detail.storyText ?? '', features: detail.features?.length @@ -91,6 +104,10 @@ function buildProductPayload(v: ProductFormValues) { features: features.length ? features : undefined, }; + const visibilityPhones = (v.visibilityPhones ?? []) + .map((p) => String(p || '').replace(/\D/g, '').trim()) + .filter(Boolean); + return { skuCode: v.skuCode, barcode69: v.barcode69, @@ -103,6 +120,8 @@ function buildProductPayload(v: ProductFormValues) { status: v.status, sortOrder: v.sortOrder, allowOnSitePickup: !!v.allowOnSitePickup, + visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled, + visibilityPhones, coverUrl: v.coverUrl, carouselUrls, detailImageUrls, @@ -183,7 +202,88 @@ function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaTyp ); } -function BaseInfoFields({ mode }: { mode: 'create' | 'edit' }) { +function VisibilityWhitelistFields({ form }: { form: FormInstance }) { + const [userOptions, setUserOptions] = useState([]); + const [userSearching, setUserSearching] = useState(false); + const searchTimer = useRef | null>(null); + + async function searchUsers(keyword: string) { + const q = keyword.trim(); + if (searchTimer.current) clearTimeout(searchTimer.current); + if (!q) { + setUserOptions([]); + return; + } + searchTimer.current = setTimeout(() => { + void (async () => { + setUserSearching(true); + try { + const qs = new URLSearchParams({ page: '1', pageSize: '20', phone: q }); + const res = await request<{ items: UserPickRow[] }>(`/admin/users?${qs}`); + setUserOptions((res.items ?? []).filter((u) => !!u.phone)); + } catch { + setUserOptions([]); + } finally { + setUserSearching(false); + } + })(); + }, 300); + } + + const enabled = Form.useWatch('visibilityWhitelistEnabled', form); + + return ( + <> + + + + {enabled ? ( + <> + + ({ + value: u.phone!, + label: `${u.phone}${u.nickname ? ` · ${u.nickname}` : ''}${u.userNo ? `(${u.userNo})` : ''}`, + }))} + onSearch={searchUsers} + onSelect={(phone: string) => { + const cur = (form.getFieldValue('visibilityPhones') as string[] | undefined) ?? []; + if (!cur.includes(phone)) { + form.setFieldsValue({ visibilityPhones: [...cur, phone] }); + } + }} + notFoundContent={userSearching ? '搜索中…' : '输入手机号搜索'} + /> + + + ) : null} + + ); +} + +function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormInstance }) { return ( <> {mode === 'create' && ( @@ -223,6 +323,7 @@ function BaseInfoFields({ mode }: { mode: 'create' | 'edit' }) { + @@ -264,7 +365,7 @@ export default function ProductsPage() { } } - const columns: ColumnsType = [ + const columns: ColumnsType = useMemo(() => [ { title: 'SKU', dataIndex: 'skuCode', width: 90 }, { title: '商品名', dataIndex: 'name', width: 180, ellipsis: true }, { title: '香型', dataIndex: 'aromaType', width: 80, render: (v) => AROMA_TYPE_LABELS[v] || v }, @@ -274,6 +375,13 @@ export default function ProductsPage() { { title: '状态', dataIndex: 'status', width: 80, render: (s) => ( {PRODUCT_STATUS_LABELS[s] || s} ) }, + { + title: '白名单', + dataIndex: 'visibilityWhitelistEnabled', + width: 90, + render: (v: boolean, row) => + v ? 限{row.visibilityPhones?.length ?? 0}人 : 公开, + }, { title: '现场取货', dataIndex: 'allowOnSitePickup', @@ -305,7 +413,7 @@ export default function ProductsPage() { ), }, - ]; + ], [detail, editForm]); return (
@@ -323,7 +431,7 @@ export default function ProductsPage() { -
{ setPage(p); setPageSize(ps); } }} /> setDrawerOpen(false)} extra={detail && ( @@ -345,7 +453,7 @@ export default function ProductsPage() {
}, + { key: 'base', label: '基础信息', children: }, { key: 'detail', label: '详情页', @@ -372,11 +480,12 @@ export default function ProductsPage() { }} width={720}> }, + { key: 'base', label: '基础信息', children: }, { key: 'detail', label: '详情页', diff --git a/apps/h5-partner/src/PartnerAppRoutes.tsx b/apps/h5-partner/src/PartnerAppRoutes.tsx index 29c3ce6..240db12 100644 --- a/apps/h5-partner/src/PartnerAppRoutes.tsx +++ b/apps/h5-partner/src/PartnerAppRoutes.tsx @@ -16,6 +16,8 @@ import SettlementPage from './pages/SettlementPage'; import WeeklyReportPage from './pages/WeeklyReportPage'; import ProxyOrderPage from './pages/ProxyOrderPage'; import ProxyOrderProductsPage from './pages/ProxyOrderProductsPage'; +import ProxyOrderListPage from './pages/ProxyOrderListPage'; +import ProxyOrderDetailPage from './pages/ProxyOrderDetailPage'; import StaffListPage from './pages/StaffListPage'; import StaffCreatePage from './pages/StaffCreatePage'; import LeaderboardPage from './pages/LeaderboardPage'; @@ -33,6 +35,8 @@ function PrimaryRoutes() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/apps/h5-partner/src/components/TencentLocPickerOverlay.tsx b/apps/h5-partner/src/components/TencentLocPickerOverlay.tsx index 1ac0b05..4d9eb7c 100644 --- a/apps/h5-partner/src/components/TencentLocPickerOverlay.tsx +++ b/apps/h5-partner/src/components/TencentLocPickerOverlay.tsx @@ -1,8 +1,9 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import type { ClientRuntimeConfig } from '@dukang/shared-types'; import { request } from '../lib/api'; import { - placeToPicked, - type LbsPlaceItem, + buildTencentLocPickerUrl, + parseTencentLocPickerMessage, type TencentPickedLocation, } from '../lib/tencentLocPicker'; @@ -12,117 +13,84 @@ type Props = { onPick: (loc: TencentPickedLocation) => void; latitude?: number | null; longitude?: number | null; + /** 保留兼容,iframe 选点不使用 */ region?: string | null; }; +let cachedKey: string | null | undefined; + +async function loadTencentLbsKey(): Promise { + if (cachedKey) return cachedKey; + const cfg = await request('PARTNER_H5', '/common/client-config', { + silent: true, + }); + const key = (cfg.tencentLbsKey || '').trim(); + if (!key) { + throw new Error('未配置腾讯位置服务 Key,请联系管理员在系统设置中配置'); + } + cachedKey = key; + return key; +} + export default function TencentLocPickerOverlay({ open, onClose, onPick, latitude, longitude, - region, }: Props) { - const [keyword, setKeyword] = useState(''); - const [items, setItems] = useState([]); + const [key, setKey] = useState(cachedKey ?? null); const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + /** 选点结果仅存内存,界面不展示经纬度 */ const [pending, setPending] = useState(null); - const [hint, setHint] = useState('输入地点名称搜索'); - const seqRef = useRef(0); + const [iframeReady, setIframeReady] = useState(false); useEffect(() => { if (!open) { - setKeyword(''); - setItems([]); setPending(null); - setHint('输入地点名称搜索'); + setIframeReady(false); + setError(''); return; } - const lat = latitude != null ? Number(latitude) : NaN; - const lng = longitude != null ? Number(longitude) : NaN; - if (Number.isFinite(lat) && Number.isFinite(lng) && !(lat === 0 && lng === 0)) { - void loadNearby(lat, lng); + let cancelled = false; + if (key) return; + setLoading(true); + void loadTencentLbsKey() + .then((k) => { + if (!cancelled) setKey(k); + }) + .catch((e) => { + if (!cancelled) setError(e instanceof Error ? e.message : '加载地图配置失败'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [open, key]); + + useEffect(() => { + if (!open) return; + function onMessage(event: MessageEvent) { + const picked = parseTencentLocPickerMessage(event.data); + if (!picked) return; + setPending(picked); } - // eslint-disable-next-line react-hooks/exhaustive-deps + window.addEventListener('message', onMessage); + return () => window.removeEventListener('message', onMessage); }, [open]); - async function loadNearby(lat: number, lng: number) { - const seq = ++seqRef.current; - setLoading(true); - setHint('正在加载附近地点…'); - try { - const res = await request<{ items: LbsPlaceItem[] }>( - 'PARTNER_H5', - `/common/lbs/nearby?lat=${encodeURIComponent(String(lat))}&lng=${encodeURIComponent(String(lng))}`, - { silent: true }, - ); - if (seq !== seqRef.current) return; - setItems(res.items ?? []); - setHint(res.items?.length ? `附近 ${res.items.length} 个地点` : '附近暂无地点,请搜索'); - } catch (e) { - if (seq !== seqRef.current) return; - setItems([]); - setHint(e instanceof Error ? e.message : '加载附近地点失败'); - } finally { - if (seq === seqRef.current) setLoading(false); - } - } - - async function runSearch() { - const trimmed = keyword.trim(); - if (!trimmed) { - setHint('请输入搜索关键词'); - return; - } - const seq = ++seqRef.current; - setLoading(true); - setHint('搜索中…'); - try { - const params = new URLSearchParams({ keyword: trimmed }); - if (region?.trim()) params.set('region', region.trim()); - const lat = latitude != null ? Number(latitude) : NaN; - const lng = longitude != null ? Number(longitude) : NaN; - if (Number.isFinite(lat) && Number.isFinite(lng)) { - params.set('lat', String(lat)); - params.set('lng', String(lng)); - } - const res = await request<{ items: LbsPlaceItem[] }>( - 'PARTNER_H5', - `/common/lbs/suggest?${params.toString()}`, - { silent: true }, - ); - if (seq !== seqRef.current) return; - setItems(res.items ?? []); - setHint(res.items?.length ? `找到 ${res.items.length} 个结果` : '无匹配结果'); - } catch (e) { - if (seq !== seqRef.current) return; - setItems([]); - setHint(e instanceof Error ? e.message : '搜索失败'); - } finally { - if (seq === seqRef.current) setLoading(false); - } - } - - function useBrowserLocation() { - if (!navigator.geolocation) { - setHint('当前浏览器不支持定位'); - return; - } - setLoading(true); - navigator.geolocation.getCurrentPosition( - (pos) => { - const lat = pos.coords.latitude; - const lng = pos.coords.longitude; - setPending({ latitude: lat, longitude: lng, name: '当前位置' }); - void loadNearby(lat, lng); - }, - () => { - setLoading(false); - setHint('定位失败,请检查定位权限'); - }, - { enableHighAccuracy: true, timeout: 12000 }, - ); - } + const src = useMemo(() => { + if (!key) return ''; + const lat = latitude != null ? Number(latitude) : undefined; + const lng = longitude != null ? Number(longitude) : undefined; + return buildTencentLocPickerUrl(key, { + latitude: lat != null && Number.isFinite(lat) ? lat : undefined, + longitude: lng != null && Number.isFinite(lng) ? lng : undefined, + }); + }, [key, latitude, longitude]); if (!open) return null; @@ -134,6 +102,7 @@ export default function TencentLocPickerOverlay({ return (
- - 地图选点 - -
- -
-
- setKeyword(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') void runSearch(); - }} - placeholder="输入小区 / 写字楼 / 门店名称" - style={{ - flex: 1, - padding: '10px 12px', - border: '1px solid rgba(0,0,0,0.12)', - borderRadius: 8, - fontSize: 14, - }} - /> + 地图选点 + +
-
-
- {hint}
- {pending ? ( -

- 已选 {pending.latitude.toFixed(6)}, {pending.longitude.toFixed(6)} - {pending.name ? ` · ${pending.name}` : ''} -

- ) : null}
-
- {loading && !items.length ? ( +

+ {pending ? '已选定位置,请点右上角「确认」' : '在地图上点选或搜索地点,选中后点右上角「确认」'} +

+ +
+ {loading ? (

- 加载中… + 正在加载地图…

- ) : items.length ? ( -
    - {items.map((item) => { - const active = - pending?.latitude === item.latitude && pending?.longitude === item.longitude; - return ( -
  • - -
  • - ); - })} -
- ) : ( -

- {hint} -

- )} + ) : error ? ( +
+

+ {error} +

+

+ 请确认系统设置中已配置腾讯位置服务 Key,并白名单 apis.map.qq.com。 +

+
+ ) : src ? ( + <> + {!iframeReady ? ( +

+ 地图加载中… +

+ ) : null} +