H5端开店增加获取当前店铺位置
This commit is contained in:
@@ -11,6 +11,8 @@ export type StoreCreateForm = {
|
|||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
address: string;
|
address: string;
|
||||||
|
latitude?: number | null;
|
||||||
|
longitude?: number | null;
|
||||||
intro?: string;
|
intro?: string;
|
||||||
openTime?: string;
|
openTime?: string;
|
||||||
closeTime?: string;
|
closeTime?: string;
|
||||||
|
|||||||
@@ -319,6 +319,7 @@ export default function StoresPage() {
|
|||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [createStep, setCreateStep] = useState(0);
|
const [createStep, setCreateStep] = useState(0);
|
||||||
const [createError, setCreateError] = useState('');
|
const [createError, setCreateError] = useState('');
|
||||||
|
const [locating, setLocating] = useState(false);
|
||||||
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||||
const [cities, setCities] = useState<CityOption[]>([]);
|
const [cities, setCities] = useState<CityOption[]>([]);
|
||||||
const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]);
|
const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]);
|
||||||
@@ -328,6 +329,8 @@ export default function StoresPage() {
|
|||||||
const selectedRegionCodes = Form.useWatch('regionCodes', createForm);
|
const selectedRegionCodes = Form.useWatch('regionCodes', createForm);
|
||||||
const selectedCityId = Form.useWatch('cityId', createForm);
|
const selectedCityId = Form.useWatch('cityId', createForm);
|
||||||
const selectedCategoryParentId = Form.useWatch('categoryParentId', createForm);
|
const selectedCategoryParentId = Form.useWatch('categoryParentId', createForm);
|
||||||
|
const watchedLat = Form.useWatch('latitude', createForm);
|
||||||
|
const watchedLng = Form.useWatch('longitude', createForm);
|
||||||
|
|
||||||
const categoryParentOptions = useMemo(
|
const categoryParentOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -465,6 +468,15 @@ export default function StoresPage() {
|
|||||||
phone: values.phone.trim(),
|
phone: values.phone.trim(),
|
||||||
district: values.district.trim(),
|
district: values.district.trim(),
|
||||||
address: values.address.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,
|
intro: values.intro?.trim() || undefined,
|
||||||
openTime: values.openTime?.trim() || '10:00',
|
openTime: values.openTime?.trim() || '10:00',
|
||||||
closeTime: values.closeTime?.trim() || '22:00',
|
closeTime: values.closeTime?.trim() || '22:00',
|
||||||
@@ -854,6 +866,51 @@ export default function StoresPage() {
|
|||||||
<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>
|
||||||
|
<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%' }}>
|
<Space wrap style={{ width: '100%' }}>
|
||||||
<Form.Item name="openTime" label="营业开始" rules={[{ required: true, message: '请填写营业开始时间' }]}>
|
<Form.Item name="openTime" label="营业开始" rules={[{ required: true, message: '请填写营业开始时间' }]}>
|
||||||
<Input type="time" style={{ width: 140 }} />
|
<Input type="time" style={{ width: 140 }} />
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ export type StoreDraftForm = {
|
|||||||
phone: string;
|
phone: string;
|
||||||
storeSmsCode: string;
|
storeSmsCode: string;
|
||||||
address: string;
|
address: string;
|
||||||
|
/** 门店坐标(定位或地理编码) */
|
||||||
|
latitude: string;
|
||||||
|
longitude: string;
|
||||||
openTime: string;
|
openTime: string;
|
||||||
closeTime: string;
|
closeTime: string;
|
||||||
/** 是否启用第二段营业时间 */
|
/** 是否启用第二段营业时间 */
|
||||||
@@ -47,6 +50,8 @@ export const defaultStoreForm = (): StoreDraftForm => ({
|
|||||||
phone: '',
|
phone: '',
|
||||||
storeSmsCode: '',
|
storeSmsCode: '',
|
||||||
address: '',
|
address: '',
|
||||||
|
latitude: '',
|
||||||
|
longitude: '',
|
||||||
openTime: '10:00',
|
openTime: '10:00',
|
||||||
closeTime: '22:00',
|
closeTime: '22:00',
|
||||||
dualHours: false,
|
dualHours: false,
|
||||||
@@ -87,6 +92,8 @@ export function normalizeStoreDraftForm(raw: Partial<StoreDraftForm> | null | un
|
|||||||
...raw,
|
...raw,
|
||||||
regionCodes: Array.isArray(raw.regionCodes) ? raw.regionCodes.map(String) : base.regionCodes,
|
regionCodes: Array.isArray(raw.regionCodes) ? raw.regionCodes.map(String) : base.regionCodes,
|
||||||
cityId: String(raw.cityId ?? base.cityId),
|
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),
|
openTime: String(raw.openTime ?? base.openTime),
|
||||||
closeTime: String(raw.closeTime ?? base.closeTime),
|
closeTime: String(raw.closeTime ?? base.closeTime),
|
||||||
dualHours: Boolean(raw.dualHours),
|
dualHours: Boolean(raw.dualHours),
|
||||||
|
|||||||
@@ -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 { checkStorePhoneAvailable, sendStorePhoneSms } from '../lib/storePhone';
|
||||||
|
|
||||||
|
import { formatStoreCoords, locateStorePosition } from '../lib/storeLocate';
|
||||||
|
|
||||||
import { fetchPartnerCities, type OpenCityOption } from '../lib/upload';
|
import { fetchPartnerCities, type OpenCityOption } from '../lib/upload';
|
||||||
|
|
||||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
@@ -102,6 +104,8 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
|
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
|
||||||
|
|
||||||
|
const [locating, setLocating] = useState(false);
|
||||||
|
|
||||||
const draftSaveDisabledRef = useRef(false);
|
const draftSaveDisabledRef = useRef(false);
|
||||||
|
|
||||||
function reportFormError(message: string) {
|
function reportFormError(message: string) {
|
||||||
@@ -508,6 +512,13 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
address: form.address.trim(),
|
address: form.address.trim(),
|
||||||
|
|
||||||
|
...(form.latitude.trim() && form.longitude.trim()
|
||||||
|
? {
|
||||||
|
latitude: Number(form.latitude),
|
||||||
|
longitude: Number(form.longitude),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
|
||||||
openTime: form.openTime.trim(),
|
openTime: form.openTime.trim(),
|
||||||
|
|
||||||
closeTime: form.closeTime.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 })} />
|
<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>
|
||||||
|
|
||||||
<div className="partner-field">
|
<div className="partner-field">
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import AppImage from '@dukang/shared-ui/AppImage';
|
import AppImage from '@dukang/shared-ui/AppImage';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { toastSuccess } from '../lib/toast';
|
import { toastError, toastSuccess } from '../lib/toast';
|
||||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
import { canManagePartnerStore } from '../lib/partnerAccess';
|
import { canManagePartnerStore } from '../lib/partnerAccess';
|
||||||
import { normalizeStringArray, patchEnvPhotoAt } from '../lib/storeDraft';
|
import { normalizeStringArray, patchEnvPhotoAt } from '../lib/storeDraft';
|
||||||
|
import { formatStoreCoords, locateStorePosition } from '../lib/storeLocate';
|
||||||
import OssUploadField from '../components/OssUploadField';
|
import OssUploadField from '../components/OssUploadField';
|
||||||
import {
|
import {
|
||||||
canPartnerOpenStore,
|
canPartnerOpenStore,
|
||||||
@@ -38,13 +39,21 @@ export default function StoreDetailPage() {
|
|||||||
const canMutate = canManagePartnerStore(account);
|
const canMutate = canManagePartnerStore(account);
|
||||||
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
||||||
const [loadError, setLoadError] = useState('');
|
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 [coverUrl, setCoverUrl] = useState('');
|
||||||
const [envPhotoUrls, setEnvPhotoUrls] = useState<string[]>(['', '', '']);
|
const [envPhotoUrls, setEnvPhotoUrls] = useState<string[]>(['', '', '']);
|
||||||
const [status, setStatus] = useState<StoreStatusValue>('OPEN');
|
const [status, setStatus] = useState<StoreStatusValue>('OPEN');
|
||||||
const [statusSaving, setStatusSaving] = useState(false);
|
const [statusSaving, setStatusSaving] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [mediaSaving, setMediaSaving] = useState(false);
|
const [mediaSaving, setMediaSaving] = useState(false);
|
||||||
|
const [locating, setLocating] = useState(false);
|
||||||
const [actionError, setActionError] = useState('');
|
const [actionError, setActionError] = useState('');
|
||||||
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false);
|
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false);
|
||||||
|
|
||||||
@@ -55,6 +64,8 @@ export default function StoreDetailPage() {
|
|||||||
phone: String(data.phone || ''),
|
phone: String(data.phone || ''),
|
||||||
address: String(data.address || ''),
|
address: String(data.address || ''),
|
||||||
intro: String(data.intro || ''),
|
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);
|
setStatus(String(data.status || 'OPEN').toUpperCase() as StoreStatusValue);
|
||||||
setCoverUrl(String(data.coverUrl || ''));
|
setCoverUrl(String(data.coverUrl || ''));
|
||||||
@@ -149,6 +160,12 @@ export default function StoreDetailPage() {
|
|||||||
phone: form.phone.trim(),
|
phone: form.phone.trim(),
|
||||||
address: form.address.trim(),
|
address: form.address.trim(),
|
||||||
intro: form.intro.trim(),
|
intro: form.intro.trim(),
|
||||||
|
...(form.latitude.trim() && form.longitude.trim()
|
||||||
|
? {
|
||||||
|
latitude: Number(form.latitude),
|
||||||
|
longitude: Number(form.longitude),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
applyStore(data);
|
applyStore(data);
|
||||||
@@ -319,6 +336,48 @@ export default function StoreDetailPage() {
|
|||||||
<div className="partner-field">
|
<div className="partner-field">
|
||||||
<label>门店地址</label>
|
<label>门店地址</label>
|
||||||
<textarea disabled={readOnly} rows={2} value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
|
<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>
|
||||||
<div className="partner-field">
|
<div className="partner-field">
|
||||||
<label>门店简介</label>
|
<label>门店简介</label>
|
||||||
|
|||||||
@@ -268,6 +268,18 @@ export class AdminStoresService {
|
|||||||
const categoryId = BigInt(dto.categoryId);
|
const categoryId = BigInt(dto.categoryId);
|
||||||
await this.storeCategoryService.assertLeafCategoryId(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 openTime = dto.openTime?.trim() || '10:00';
|
||||||
const closeTime = dto.closeTime?.trim() || '22:00';
|
const closeTime = dto.closeTime?.trim() || '22:00';
|
||||||
const openTime2 = dto.openTime2?.trim() || '';
|
const openTime2 = dto.openTime2?.trim() || '';
|
||||||
@@ -296,6 +308,7 @@ export class AdminStoresService {
|
|||||||
closeTime,
|
closeTime,
|
||||||
openTime2: openTime2 || null,
|
openTime2: openTime2 || null,
|
||||||
closeTime2: closeTime2 || null,
|
closeTime2: closeTime2 || null,
|
||||||
|
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
auditStatus: 'APPROVED',
|
auditStatus: 'APPROVED',
|
||||||
auditedAt: new Date(),
|
auditedAt: new Date(),
|
||||||
|
|||||||
@@ -57,6 +57,14 @@ export class CreateStoreDto {
|
|||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
address: string;
|
address: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
latitude?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
longitude?: number;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
intro?: string;
|
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)));
|
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()
|
@Injectable()
|
||||||
export class StoreService {
|
export class StoreService {
|
||||||
private readonly config = loadAppConfig();
|
private readonly config = loadAppConfig();
|
||||||
@@ -296,6 +305,12 @@ export class StoreService {
|
|||||||
throw new BadRequestException('门店简介须为 2~500 字');
|
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({
|
const store = await this.prisma.store.create({
|
||||||
data: {
|
data: {
|
||||||
cityId: city.id,
|
cityId: city.id,
|
||||||
@@ -313,6 +328,7 @@ export class StoreService {
|
|||||||
closeTime,
|
closeTime,
|
||||||
openTime2: openTime2 || null,
|
openTime2: openTime2 || null,
|
||||||
closeTime2: closeTime2 || null,
|
closeTime2: closeTime2 || null,
|
||||||
|
...(latitude != null && longitude != null ? { latitude, longitude } : {}),
|
||||||
status: this.config.autoApproveStore ? 'OPEN' : 'PAUSED',
|
status: this.config.autoApproveStore ? 'OPEN' : 'PAUSED',
|
||||||
auditStatus: this.config.autoApproveStore ? 'APPROVED' : 'PENDING',
|
auditStatus: this.config.autoApproveStore ? 'APPROVED' : 'PENDING',
|
||||||
auditedAt: this.config.autoApproveStore ? new Date() : null,
|
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';
|
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 phone = body.phone !== undefined ? String(body.phone).trim() : undefined;
|
||||||
const address = body.address !== undefined ? String(body.address).trim() : undefined;
|
const address = body.address !== undefined ? String(body.address).trim() : undefined;
|
||||||
const introRaw = body.intro !== undefined ? String(body.intro).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 (name !== undefined && !name) throw new BadRequestException('请填写门店名称');
|
||||||
if (phone !== undefined && !/^1\d{10}$/.test(phone)) {
|
if (phone !== undefined && !/^1\d{10}$/.test(phone)) {
|
||||||
@@ -509,16 +531,26 @@ export class StoreService {
|
|||||||
if (introRaw && (introRaw.length < 2 || introRaw.length > 500)) {
|
if (introRaw && (introRaw.length < 2 || introRaw.length > 500)) {
|
||||||
throw new BadRequestException('门店简介须为 2~500 字');
|
throw new BadRequestException('门店简介须为 2~500 字');
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
(latitude !== undefined || longitude !== undefined) &&
|
||||||
|
(latitude == null || longitude == null)
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('经纬度须同时提供');
|
||||||
|
}
|
||||||
|
|
||||||
const resubmitAudit = store.auditStatus === 'REJECTED';
|
const resubmitAudit = store.auditStatus === 'REJECTED';
|
||||||
|
const hasCoordsUpdate = latitude != null && longitude != null;
|
||||||
const updated = await this.prisma.store.update({
|
const updated = await this.prisma.store.update({
|
||||||
where: { id: storeId },
|
where: { id: storeId },
|
||||||
data: {
|
data: {
|
||||||
...(name !== undefined ? { name } : {}),
|
...(name !== undefined ? { name } : {}),
|
||||||
...(phone !== undefined ? { phone } : {}),
|
...(phone !== undefined ? { phone } : {}),
|
||||||
...(address !== undefined
|
...(address !== undefined
|
||||||
? { address, latitude: null, longitude: null }
|
? hasCoordsUpdate
|
||||||
|
? { address }
|
||||||
|
: { address, latitude: null, longitude: null }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(hasCoordsUpdate ? { latitude, longitude } : {}),
|
||||||
...(introRaw !== undefined ? { intro: introRaw || null } : {}),
|
...(introRaw !== undefined ? { intro: introRaw || null } : {}),
|
||||||
...(resubmitAudit
|
...(resubmitAudit
|
||||||
? {
|
? {
|
||||||
@@ -531,7 +563,7 @@ export class StoreService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (address !== undefined) {
|
if (address !== undefined && !hasCoordsUpdate) {
|
||||||
await this.ensureStoreCoordinates(updated);
|
await this.ensureStoreCoordinates(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user