H5端开店增加获取当前店铺位置

This commit is contained in:
2026-07-27 22:37:15 +08:00
parent 23c0cc7a5f
commit 09eda9a11e
9 changed files with 253 additions and 5 deletions
+2
View File
@@ -11,6 +11,8 @@ export type StoreCreateForm = {
name: string;
phone: string;
address: string;
latitude?: number | null;
longitude?: number | null;
intro?: string;
openTime?: string;
closeTime?: string;
+57
View File
@@ -319,6 +319,7 @@ export default function StoresPage() {
const [createOpen, setCreateOpen] = useState(false);
const [createStep, setCreateStep] = useState(0);
const [createError, setCreateError] = useState('');
const [locating, setLocating] = useState(false);
const [partners, setPartners] = useState<PartnerOption[]>([]);
const [cities, setCities] = useState<CityOption[]>([]);
const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]);
@@ -328,6 +329,8 @@ export default function StoresPage() {
const selectedRegionCodes = Form.useWatch('regionCodes', createForm);
const selectedCityId = Form.useWatch('cityId', createForm);
const selectedCategoryParentId = Form.useWatch('categoryParentId', createForm);
const watchedLat = Form.useWatch('latitude', createForm);
const watchedLng = Form.useWatch('longitude', createForm);
const categoryParentOptions = useMemo(
() =>
@@ -465,6 +468,15 @@ export default function StoresPage() {
phone: values.phone.trim(),
district: values.district.trim(),
address: values.address.trim(),
...(values.latitude != null &&
values.longitude != null &&
Number.isFinite(Number(values.latitude)) &&
Number.isFinite(Number(values.longitude))
? {
latitude: Number(values.latitude),
longitude: Number(values.longitude),
}
: {}),
intro: values.intro?.trim() || undefined,
openTime: values.openTime?.trim() || '10:00',
closeTime: values.closeTime?.trim() || '22:00',
@@ -854,6 +866,51 @@ export default function StoresPage() {
<Form.Item name="address" label="详细地址" rules={[{ required: true, message: '请填写详细地址' }]}>
<Input.TextArea rows={2} placeholder="请输入详细门牌号" />
</Form.Item>
<Form.Item name="latitude" hidden>
<InputNumber />
</Form.Item>
<Form.Item name="longitude" hidden>
<InputNumber />
</Form.Item>
<Space wrap style={{ marginBottom: 16 }}>
<Button
loading={locating}
onClick={() => {
if (!navigator.geolocation) {
message.error('当前浏览器不支持定位');
return;
}
setLocating(true);
navigator.geolocation.getCurrentPosition(
(pos) => {
createForm.setFieldsValue({
latitude: pos.coords.latitude,
longitude: pos.coords.longitude,
});
message.success(
`已获取坐标 ${pos.coords.latitude.toFixed(6)}, ${pos.coords.longitude.toFixed(6)}`,
);
setLocating(false);
},
(err) => {
message.error(err.message || '定位失败');
setLocating(false);
},
{ enableHighAccuracy: true, timeout: 10000 },
);
}}
>
</Button>
<Typography.Text type="secondary">
{watchedLat != null &&
watchedLng != null &&
Number.isFinite(Number(watchedLat)) &&
Number.isFinite(Number(watchedLng))
? `坐标:${Number(watchedLat).toFixed(6)}, ${Number(watchedLng).toFixed(6)}`
: '未定位(可选,便于用户端导航与距离)'}
</Typography.Text>
</Space>
<Space wrap style={{ width: '100%' }}>
<Form.Item name="openTime" label="营业开始" rules={[{ required: true, message: '请填写营业开始时间' }]}>
<Input type="time" style={{ width: 140 }} />
+7
View File
@@ -8,6 +8,9 @@ export type StoreDraftForm = {
phone: string;
storeSmsCode: string;
address: string;
/** 门店坐标(定位或地理编码) */
latitude: string;
longitude: string;
openTime: string;
closeTime: string;
/** 是否启用第二段营业时间 */
@@ -47,6 +50,8 @@ export const defaultStoreForm = (): StoreDraftForm => ({
phone: '',
storeSmsCode: '',
address: '',
latitude: '',
longitude: '',
openTime: '10:00',
closeTime: '22:00',
dualHours: false,
@@ -87,6 +92,8 @@ export function normalizeStoreDraftForm(raw: Partial<StoreDraftForm> | null | un
...raw,
regionCodes: Array.isArray(raw.regionCodes) ? raw.regionCodes.map(String) : base.regionCodes,
cityId: String(raw.cityId ?? base.cityId),
latitude: raw.latitude != null && raw.latitude !== '' ? String(raw.latitude) : base.latitude,
longitude: raw.longitude != null && raw.longitude !== '' ? String(raw.longitude) : base.longitude,
openTime: String(raw.openTime ?? base.openTime),
closeTime: String(raw.closeTime ?? base.closeTime),
dualHours: Boolean(raw.dualHours),
+26
View File
@@ -0,0 +1,26 @@
import { weixinSdk } from './weixin';
export type StorePosition = {
latitude: number;
longitude: number;
};
/** 获取当前 GPS 坐标(微信 JSSDK / 浏览器 Geolocation */
export async function locateStorePosition(): Promise<StorePosition> {
const outcome = await weixinSdk.getLocationDetailed();
if (!outcome.location) {
throw new Error(outcome.errMsg || '定位失败,请允许位置权限后重试');
}
const { latitude, longitude } = outcome.location;
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
throw new Error('定位结果无效');
}
return { latitude, longitude };
}
export function formatStoreCoords(lat?: string | number | null, lng?: string | number | null): string {
const a = lat != null && lat !== '' ? Number(lat) : NaN;
const b = lng != null && lng !== '' ? Number(lng) : NaN;
if (!Number.isFinite(a) || !Number.isFinite(b)) return '';
return `${a.toFixed(6)}, ${b.toFixed(6)}`;
}
@@ -13,6 +13,8 @@ import { resolveRegionBinding } from '../lib/china-region';
import { checkStorePhoneAvailable, sendStorePhoneSms } from '../lib/storePhone';
import { formatStoreCoords, locateStorePosition } from '../lib/storeLocate';
import { fetchPartnerCities, type OpenCityOption } from '../lib/upload';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
@@ -102,6 +104,8 @@ export default function StoreCreatePage() {
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
const [locating, setLocating] = useState(false);
const draftSaveDisabledRef = useRef(false);
function reportFormError(message: string) {
@@ -508,6 +512,13 @@ export default function StoreCreatePage() {
address: form.address.trim(),
...(form.latitude.trim() && form.longitude.trim()
? {
latitude: Number(form.latitude),
longitude: Number(form.longitude),
}
: {}),
openTime: form.openTime.trim(),
closeTime: form.closeTime.trim(),
@@ -740,6 +751,39 @@ export default function StoreCreatePage() {
<textarea rows={2} placeholder="请输入详细门牌号" value={form.address} onChange={(e) => patchForm({ address: e.target.value })} />
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>
<button
type="button"
className="partner-btn-outline"
style={{ padding: '8px 12px', fontSize: 13 }}
disabled={locating}
onClick={() => {
void (async () => {
setLocating(true);
try {
const pos = await locateStorePosition();
patchForm({
latitude: String(pos.latitude),
longitude: String(pos.longitude),
});
toastSuccess('已获取当前坐标');
} catch (e) {
toastError(e instanceof Error ? e.message : '定位失败');
} finally {
setLocating(false);
}
})();
}}
>
{locating ? '定位中…' : '获取当前位置'}
</button>
<span className="label-md text-muted">
{formatStoreCoords(form.latitude, form.longitude)
? `坐标:${formatStoreCoords(form.latitude, form.longitude)}`
: '未定位(提交后可按地址自动解析)'}
</span>
</div>
</div>
<div className="partner-field">
+61 -2
View File
@@ -2,10 +2,11 @@ import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage';
import { request } from '../lib/api';
import { toastSuccess } from '../lib/toast';
import { toastError, toastSuccess } from '../lib/toast';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { canManagePartnerStore } from '../lib/partnerAccess';
import { normalizeStringArray, patchEnvPhotoAt } from '../lib/storeDraft';
import { formatStoreCoords, locateStorePosition } from '../lib/storeLocate';
import OssUploadField from '../components/OssUploadField';
import {
canPartnerOpenStore,
@@ -38,13 +39,21 @@ export default function StoreDetailPage() {
const canMutate = canManagePartnerStore(account);
const [store, setStore] = useState<Record<string, unknown> | null>(null);
const [loadError, setLoadError] = useState('');
const [form, setForm] = useState({ name: '', phone: '', address: '', intro: '' });
const [form, setForm] = useState({
name: '',
phone: '',
address: '',
intro: '',
latitude: '',
longitude: '',
});
const [coverUrl, setCoverUrl] = useState('');
const [envPhotoUrls, setEnvPhotoUrls] = useState<string[]>(['', '', '']);
const [status, setStatus] = useState<StoreStatusValue>('OPEN');
const [statusSaving, setStatusSaving] = useState(false);
const [saving, setSaving] = useState(false);
const [mediaSaving, setMediaSaving] = useState(false);
const [locating, setLocating] = useState(false);
const [actionError, setActionError] = useState('');
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false);
@@ -55,6 +64,8 @@ export default function StoreDetailPage() {
phone: String(data.phone || ''),
address: String(data.address || ''),
intro: String(data.intro || ''),
latitude: data.latitude != null && data.latitude !== '' ? String(data.latitude) : '',
longitude: data.longitude != null && data.longitude !== '' ? String(data.longitude) : '',
});
setStatus(String(data.status || 'OPEN').toUpperCase() as StoreStatusValue);
setCoverUrl(String(data.coverUrl || ''));
@@ -149,6 +160,12 @@ export default function StoreDetailPage() {
phone: form.phone.trim(),
address: form.address.trim(),
intro: form.intro.trim(),
...(form.latitude.trim() && form.longitude.trim()
? {
latitude: Number(form.latitude),
longitude: Number(form.longitude),
}
: {}),
}),
});
applyStore(data);
@@ -319,6 +336,48 @@ export default function StoreDetailPage() {
<div className="partner-field">
<label></label>
<textarea disabled={readOnly} rows={2} value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
{!readOnly ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>
<button
type="button"
className="partner-btn-outline"
style={{ padding: '8px 12px', fontSize: 13 }}
disabled={locating}
onClick={() => {
void (async () => {
setLocating(true);
setActionError('');
try {
const pos = await locateStorePosition();
setForm((prev) => ({
...prev,
latitude: String(pos.latitude),
longitude: String(pos.longitude),
}));
toastSuccess('已获取当前坐标');
} catch (e) {
const msg = e instanceof Error ? e.message : '定位失败';
setActionError(msg);
toastError(msg);
} finally {
setLocating(false);
}
})();
}}
>
{locating ? '定位中…' : '获取当前位置'}
</button>
<span className="label-md text-muted">
{formatStoreCoords(form.latitude, form.longitude)
? `坐标:${formatStoreCoords(form.latitude, form.longitude)}`
: '未定位'}
</span>
</div>
) : formatStoreCoords(form.latitude, form.longitude) ? (
<p className="label-md text-muted" style={{ marginTop: 6 }}>
{formatStoreCoords(form.latitude, form.longitude)}
</p>
) : null}
</div>
<div className="partner-field">
<label></label>
@@ -268,6 +268,18 @@ export class AdminStoresService {
const categoryId = BigInt(dto.categoryId);
await this.storeCategoryService.assertLeafCategoryId(categoryId);
const latitude = dto.latitude != null ? Number(dto.latitude) : null;
const longitude = dto.longitude != null ? Number(dto.longitude) : null;
if ((latitude == null) !== (longitude == null)) {
throw new BadRequestException('经纬度须同时提供');
}
if (
latitude != null &&
(!Number.isFinite(latitude) || !Number.isFinite(longitude!) || latitude < -90 || latitude > 90)
) {
throw new BadRequestException('经纬度无效');
}
const openTime = dto.openTime?.trim() || '10:00';
const closeTime = dto.closeTime?.trim() || '22:00';
const openTime2 = dto.openTime2?.trim() || '';
@@ -296,6 +308,7 @@ export class AdminStoresService {
closeTime,
openTime2: openTime2 || null,
closeTime2: closeTime2 || null,
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
status: 'OPEN',
auditStatus: 'APPROVED',
auditedAt: new Date(),
@@ -57,6 +57,14 @@ export class CreateStoreDto {
@IsNotEmpty()
address: string;
@IsOptional()
@IsNumber()
latitude?: number;
@IsOptional()
@IsNumber()
longitude?: number;
@IsOptional()
@IsString()
intro?: string;
@@ -28,6 +28,15 @@ function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number)
return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)));
}
function parseOptionalCoord(value: unknown, kind: 'lat' | 'lng' = 'lng'): number | null {
if (value == null || value === '') return null;
const n = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(n)) return null;
if (kind === 'lat' && (n < -90 || n > 90)) return null;
if (kind === 'lng' && (n < -180 || n > 180)) return null;
return n;
}
@Injectable()
export class StoreService {
private readonly config = loadAppConfig();
@@ -296,6 +305,12 @@ export class StoreService {
throw new BadRequestException('门店简介须为 2~500 字');
}
const latitude = parseOptionalCoord(body.latitude, 'lat');
const longitude = parseOptionalCoord(body.longitude, 'lng');
if ((latitude == null) !== (longitude == null)) {
throw new BadRequestException('经纬度须同时提供');
}
const store = await this.prisma.store.create({
data: {
cityId: city.id,
@@ -313,6 +328,7 @@ export class StoreService {
closeTime,
openTime2: openTime2 || null,
closeTime2: closeTime2 || null,
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
status: this.config.autoApproveStore ? 'OPEN' : 'PAUSED',
auditStatus: this.config.autoApproveStore ? 'APPROVED' : 'PENDING',
auditedAt: this.config.autoApproveStore ? new Date() : null,
@@ -320,7 +336,9 @@ export class StoreService {
},
});
await this.ensureStoreCoordinates(store);
if (latitude == null || longitude == null) {
await this.ensureStoreCoordinates(store);
}
const ossBucket = process.env.OSS_BUCKET ?? 'legacy';
@@ -500,6 +518,10 @@ export class StoreService {
const phone = body.phone !== undefined ? String(body.phone).trim() : undefined;
const address = body.address !== undefined ? String(body.address).trim() : undefined;
const introRaw = body.intro !== undefined ? String(body.intro).trim() : undefined;
const latitude =
body.latitude !== undefined ? parseOptionalCoord(body.latitude, 'lat') : undefined;
const longitude =
body.longitude !== undefined ? parseOptionalCoord(body.longitude, 'lng') : undefined;
if (name !== undefined && !name) throw new BadRequestException('请填写门店名称');
if (phone !== undefined && !/^1\d{10}$/.test(phone)) {
@@ -509,16 +531,26 @@ export class StoreService {
if (introRaw && (introRaw.length < 2 || introRaw.length > 500)) {
throw new BadRequestException('门店简介须为 2~500 字');
}
if (
(latitude !== undefined || longitude !== undefined) &&
(latitude == null || longitude == null)
) {
throw new BadRequestException('经纬度须同时提供');
}
const resubmitAudit = store.auditStatus === 'REJECTED';
const hasCoordsUpdate = latitude != null && longitude != null;
const updated = await this.prisma.store.update({
where: { id: storeId },
data: {
...(name !== undefined ? { name } : {}),
...(phone !== undefined ? { phone } : {}),
...(address !== undefined
? { address, latitude: null, longitude: null }
? hasCoordsUpdate
? { address }
: { address, latitude: null, longitude: null }
: {}),
...(hasCoordsUpdate ? { latitude, longitude } : {}),
...(introRaw !== undefined ? { intro: introRaw || null } : {}),
...(resubmitAudit
? {
@@ -531,7 +563,7 @@ export class StoreService {
},
});
if (address !== undefined) {
if (address !== undefined && !hasCoordsUpdate) {
await this.ensureStoreCoordinates(updated);
}