小程序优化,商铺定位等
This commit is contained in:
@@ -84,7 +84,7 @@ export function validateStoreCreateStep1(
|
||||
|
||||
if (form.intro?.trim()) {
|
||||
const len = form.intro.trim().length;
|
||||
if (len < 10 || len > 500) return '门店简介须为 10~500 字';
|
||||
if (len < 2 || len > 500) return '门店简介须为 2~500 字';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -874,7 +874,7 @@ export default function StoresPage() {
|
||||
<InputNumber min={0} precision={0} style={{ width: '100%' }} addonAfter="元" placeholder="用户端展示" />
|
||||
</Form.Item>
|
||||
<Form.Item name="intro" label="门店简介">
|
||||
<Input.TextArea rows={3} placeholder="选填,10~500字" showCount maxLength={500} />
|
||||
<Input.TextArea rows={3} placeholder="选填,2~500字" showCount maxLength={500} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: createStep === 1 ? 'block' : 'none' }}>
|
||||
|
||||
@@ -180,7 +180,7 @@ export function validateStoreStep1(
|
||||
if (!form.categoryId.trim()) return '请选择店铺类型';
|
||||
if (form.intro.trim()) {
|
||||
const len = form.intro.trim().length;
|
||||
if (len < 10 || len > 500) return '门店简介须为 10~500 字';
|
||||
if (len < 2 || len > 500) return '门店简介须为 2~500 字';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -870,7 +870,7 @@ export default function StoreCreatePage() {
|
||||
|
||||
<label>门店简介</label>
|
||||
|
||||
<textarea rows={4} placeholder="请输入门店简介 (10-500字)" value={form.intro} onChange={(e) => patchForm({ intro: e.target.value })} />
|
||||
<textarea rows={4} placeholder="请输入门店简介 (2-500字)" value={form.intro} onChange={(e) => patchForm({ intro: e.target.value })} />
|
||||
|
||||
<div style={{ textAlign: 'right', marginTop: 4 }}>
|
||||
|
||||
|
||||
@@ -322,7 +322,7 @@ export default function StoreDetailPage() {
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>门店简介</label>
|
||||
<textarea disabled={readOnly} rows={4} placeholder="请输入门店简介(10-500字)" value={form.intro} onChange={(e) => setForm({ ...form, intro: e.target.value })} />
|
||||
<textarea disabled={readOnly} rows={4} placeholder="请输入门店简介(2-500字)" value={form.intro} onChange={(e) => setForm({ ...form, intro: e.target.value })} />
|
||||
<div style={{ textAlign: 'right', marginTop: 4 }}>
|
||||
<span className="label-md text-muted">{form.intro.length} / 500</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/** 球面距离(米) */
|
||||
export function haversineMeters(
|
||||
lat1: number,
|
||||
lng1: number,
|
||||
lat2: number,
|
||||
lng2: number,
|
||||
): number {
|
||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||
const R = 6371000;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLng = toRad(lng2 - lng1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
}
|
||||
|
||||
export function formatDistanceMeters(meters: number | null | undefined): string {
|
||||
if (meters == null || !Number.isFinite(meters) || meters < 0) return '—';
|
||||
if (meters < 1000) return `${Math.max(1, Math.round(meters))}m`;
|
||||
const km = meters / 1000;
|
||||
return `${km < 10 ? km.toFixed(1) : Math.round(km)}km`;
|
||||
}
|
||||
@@ -105,7 +105,7 @@ export function normalizeRegionSelection(selection: RegionSelection): RegionSele
|
||||
export const DEFAULT_REGION: RegionSelection = {
|
||||
province: '河南省',
|
||||
city: '郑州市',
|
||||
district: '金水区',
|
||||
district: REGION_ALL,
|
||||
};
|
||||
|
||||
export function regionFromGeo(province: string, city: string, district?: string): RegionSelection {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { getWechatLocationDetailed } from '@dukang/weixin-sdk';
|
||||
import { API_BASE, CLIENT_APP, getToken, request } from './api';
|
||||
import { DEFAULT_REGION, regionFromGeo, type RegionSelection } from './region-data';
|
||||
import { DEFAULT_REGION, REGION_ALL, regionFromGeo, type RegionSelection } from './region-data';
|
||||
import { FALLBACK_CITY_CODE } from './product-images';
|
||||
|
||||
export const GPS_CITY_STORAGE_KEY = 'dukang_gps_city';
|
||||
const USER_COORDS_KEY = 'dukang_user_coords';
|
||||
/** 用户拒绝定位后持久化,避免首页/门店每次 useDidShow 再弹授权 */
|
||||
const LOCATION_DENIED_KEY = 'dukang_location_denied';
|
||||
|
||||
@@ -19,12 +20,14 @@ export type ResolvedUserCity = {
|
||||
displayCity: string;
|
||||
};
|
||||
|
||||
export type UserCoords = { latitude: number; longitude: number };
|
||||
|
||||
type GpsCityCache = ResolvedUserCity & { timestamp: number };
|
||||
|
||||
const FALLBACK_CITY: ResolvedUserCity = {
|
||||
province: DEFAULT_REGION.province,
|
||||
city: DEFAULT_REGION.city,
|
||||
district: DEFAULT_REGION.district,
|
||||
district: REGION_ALL,
|
||||
cityCode: FALLBACK_CITY_CODE,
|
||||
cityName: '郑州市',
|
||||
openCity: true,
|
||||
@@ -85,6 +88,39 @@ function writeCache(data: ResolvedUserCity) {
|
||||
}
|
||||
}
|
||||
|
||||
export function writeUserCoords(latitude: number, longitude: number) {
|
||||
try {
|
||||
Taro.setStorageSync(
|
||||
USER_COORDS_KEY,
|
||||
JSON.stringify({ latitude, longitude, timestamp: Date.now() }),
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function readCachedUserCoords(): UserCoords | null {
|
||||
try {
|
||||
const raw = Taro.getStorageSync(USER_COORDS_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(String(raw)) as UserCoords & { timestamp?: number };
|
||||
if (parsed.timestamp && Date.now() - parsed.timestamp > 30 * 60 * 1000) return null;
|
||||
if (!Number.isFinite(parsed.latitude) || !Number.isFinite(parsed.longitude)) return null;
|
||||
return { latitude: parsed.latitude, longitude: parsed.longitude };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 门店列表默认用市级全市筛选 */
|
||||
export function toCityWideRegion(region: RegionSelection): RegionSelection {
|
||||
return {
|
||||
province: region.province,
|
||||
city: region.city,
|
||||
district: REGION_ALL,
|
||||
};
|
||||
}
|
||||
|
||||
/** 拒绝或失败后写入兜底城市,避免短时间内反复调起定位 */
|
||||
function cacheFallbackAndMaybeDeny(denied: boolean) {
|
||||
if (denied) markLocationDenied();
|
||||
@@ -167,12 +203,12 @@ async function resolveViaH5Jssdk(): Promise<ResolvedUserCity | null> {
|
||||
status: 'fail',
|
||||
errMsg: outcome.errMsg,
|
||||
}).catch(() => {});
|
||||
// 失败一律缓存兜底,避免首页/门店每次进入再次调起微信定位弹窗
|
||||
cacheFallbackAndMaybeDeny(denied);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
writeUserCoords(outcome.location.latitude, outcome.location.longitude);
|
||||
const data = await reportLocationToServer({
|
||||
latitude: outcome.location.latitude,
|
||||
longitude: outcome.location.longitude,
|
||||
@@ -213,6 +249,7 @@ export async function resolveUserCity(force = false): Promise<ResolvedUserCity>
|
||||
|
||||
try {
|
||||
const loc = await getMiniLocation();
|
||||
writeUserCoords(loc.latitude, loc.longitude);
|
||||
const data = await reportLocationToServer({
|
||||
latitude: loc.latitude,
|
||||
longitude: loc.longitude,
|
||||
|
||||
@@ -17,8 +17,12 @@ type Store = {
|
||||
id: string;
|
||||
name: string;
|
||||
address?: string;
|
||||
province?: string;
|
||||
cityName?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
phone?: string;
|
||||
intro?: string | null;
|
||||
coverUrl?: string | null;
|
||||
carouselUrls?: string[] | null;
|
||||
openTime?: string | null;
|
||||
@@ -26,9 +30,16 @@ type Store = {
|
||||
openTime2?: string | null;
|
||||
closeTime2?: string | null;
|
||||
avgPrice?: number | null;
|
||||
latitude?: number | string | null;
|
||||
longitude?: number | string | null;
|
||||
category?: { name: string } | null;
|
||||
};
|
||||
|
||||
function fullAddress(store: Store) {
|
||||
const city = store.cityName || store.city || '';
|
||||
return `${store.province || ''}${city}${store.district || ''}${store.address || ''}`.trim();
|
||||
}
|
||||
|
||||
export default function StoreDetailPage() {
|
||||
const router = useRouter();
|
||||
const storeId = router.params.id ?? '';
|
||||
@@ -57,7 +68,7 @@ export default function StoreDetailPage() {
|
||||
const sharePayload = useMemo(
|
||||
() => ({
|
||||
title: store?.name || DEFAULT_SHARE_TITLE,
|
||||
desc: store?.address || DEFAULT_SHARE_DESC,
|
||||
desc: store?.intro?.trim() || store?.address || DEFAULT_SHARE_DESC,
|
||||
path: `/pages/store-detail/index?id=${storeId}`,
|
||||
imgUrl: store?.coverUrl || store?.carouselUrls?.[0] || undefined,
|
||||
}),
|
||||
@@ -77,6 +88,44 @@ export default function StoreDetailPage() {
|
||||
else Taro.switchTab({ url: '/pages/stores/index' });
|
||||
}
|
||||
|
||||
function callStore() {
|
||||
if (!store?.phone) {
|
||||
toast('暂无门店电话');
|
||||
return;
|
||||
}
|
||||
Taro.makePhoneCall({ phoneNumber: store.phone }).catch(() => toast('无法拨打电话'));
|
||||
}
|
||||
|
||||
function openMap() {
|
||||
if (!store) return;
|
||||
const lat = store.latitude != null ? Number(store.latitude) : NaN;
|
||||
const lng = store.longitude != null ? Number(store.longitude) : NaN;
|
||||
const address = fullAddress(store) || store.address || store.name;
|
||||
|
||||
if (Number.isFinite(lat) && Number.isFinite(lng)) {
|
||||
Taro.openLocation({
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
name: store.name,
|
||||
address,
|
||||
scale: 16,
|
||||
}).catch(() => {
|
||||
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
|
||||
window.location.href = `https://uri.amap.com/marker?position=${lng},${lat}&name=${encodeURIComponent(store.name)}&address=${encodeURIComponent(address)}`;
|
||||
return;
|
||||
}
|
||||
toast('无法打开地图导航');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined' && address) {
|
||||
window.location.href = `https://uri.amap.com/search?keyword=${encodeURIComponent(address)}&src=dukang`;
|
||||
return;
|
||||
}
|
||||
toast('门店位置待完善,暂无法导航');
|
||||
}
|
||||
|
||||
if (!store) {
|
||||
return (
|
||||
<PageShell variant="scroll" className="store-detail-page">
|
||||
@@ -93,6 +142,8 @@ export default function StoreDetailPage() {
|
||||
? [store.coverUrl]
|
||||
: []) as string[];
|
||||
|
||||
const intro = store.intro?.trim() || '';
|
||||
|
||||
return (
|
||||
<PageShell variant="scroll" className="store-detail-page" hasFixedFooter>
|
||||
<WechatShareReady payload={sharePayload} />
|
||||
@@ -110,10 +161,17 @@ export default function StoreDetailPage() {
|
||||
|
||||
<View className="store-detail-info-card">
|
||||
<Text className="store-detail-name">{store.name}</Text>
|
||||
<Text className="store-detail-meta">
|
||||
|
||||
<View className="store-detail-row">
|
||||
<Text className="store-detail-meta store-detail-meta--flex">
|
||||
{store.district ? `${store.district} · ` : ''}
|
||||
{store.address || '地址待完善'}
|
||||
</Text>
|
||||
<Text className="store-detail-action" onClick={openMap}>
|
||||
导航
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text className="store-detail-meta">
|
||||
营业时间:{' '}
|
||||
{(() => {
|
||||
@@ -126,7 +184,16 @@ export default function StoreDetailPage() {
|
||||
{store.avgPrice != null && Number(store.avgPrice) > 0 ? (
|
||||
<Text className="store-detail-meta">人均约 ¥{Number(store.avgPrice).toFixed(0)}</Text>
|
||||
) : null}
|
||||
{store.phone ? <Text className="store-detail-meta">电话: {store.phone}</Text> : null}
|
||||
|
||||
{store.phone ? (
|
||||
<View className="store-detail-row">
|
||||
<Text className="store-detail-meta store-detail-meta--flex">电话: {store.phone}</Text>
|
||||
<Text className="store-detail-action" onClick={callStore}>
|
||||
拨打
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View className="store-detail-tags">
|
||||
{store.category?.name ? (
|
||||
<Text className="store-detail-tag">{store.category.name}</Text>
|
||||
@@ -136,8 +203,18 @@ export default function StoreDetailPage() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{intro ? (
|
||||
<View className="store-detail-section">
|
||||
<Text className="store-detail-section-title">门店详情</Text>
|
||||
<Text className="store-detail-intro">{intro}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View className="store-detail-bar">
|
||||
<View className="u-btn u-btn--block" onClick={() => toast('核销请前往「好客权益」')}>
|
||||
<View
|
||||
className="u-btn u-btn--block"
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
|
||||
>
|
||||
<Text>到店核销</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -17,8 +17,15 @@ import {
|
||||
type RegionSelection,
|
||||
} from '../../lib/region-data';
|
||||
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
||||
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location';
|
||||
import {
|
||||
getCityCodeForCatalog,
|
||||
readCachedUserCoords,
|
||||
resolveUserCity,
|
||||
toCityWideRegion,
|
||||
type UserCoords,
|
||||
} from '../../lib/user-location';
|
||||
import { FALLBACK_CITY_CODE } from '../../lib/product-images';
|
||||
import { formatDistanceMeters } from '../../lib/geo';
|
||||
import { request, toast } from '../../lib/api';
|
||||
|
||||
type Store = {
|
||||
@@ -37,10 +44,11 @@ type Store = {
|
||||
status?: string;
|
||||
categoryId?: string | null;
|
||||
category?: { id?: string; name?: string; parentId?: string | null } | null;
|
||||
latitude?: number | string | null;
|
||||
longitude?: number | string | null;
|
||||
distanceMeters?: number | null;
|
||||
};
|
||||
|
||||
const MOCK_DISTANCES = ['800m', '1.2km', '3.5km', '1.5km', '2.0km'];
|
||||
|
||||
export default function StoresPage() {
|
||||
const [stores, setStores] = useState<Store[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -52,6 +60,7 @@ export default function StoresPage() {
|
||||
const [categoryOpen, setCategoryOpen] = useState(false);
|
||||
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
|
||||
const [cityCode, setCityCode] = useState<string>(FALLBACK_CITY_CODE);
|
||||
const [userCoords, setUserCoords] = useState<UserCoords | null>(() => readCachedUserCoords());
|
||||
const regionLabel = formatRegionLabel(region);
|
||||
const categoryLabel = formatCategoryLabel(category);
|
||||
|
||||
@@ -69,8 +78,9 @@ export default function StoresPage() {
|
||||
useDidShow(() => {
|
||||
syncTabBarSelected(1);
|
||||
void resolveUserCity().then((resolved) => {
|
||||
setRegion(resolved.region);
|
||||
setRegion(toCityWideRegion(resolved.region));
|
||||
setCityCode(getCityCodeForCatalog(resolved));
|
||||
setUserCoords(readCachedUserCoords());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,12 +92,19 @@ export default function StoresPage() {
|
||||
|
||||
const loadStores = useCallback(() => {
|
||||
setLoading(true);
|
||||
const path = cityCode ? `/stores?cityCode=${encodeURIComponent(cityCode)}` : '/stores';
|
||||
const qs = new URLSearchParams();
|
||||
if (cityCode) qs.set('cityCode', cityCode);
|
||||
const coords = userCoords ?? readCachedUserCoords();
|
||||
if (coords) {
|
||||
qs.set('lat', String(coords.latitude));
|
||||
qs.set('lng', String(coords.longitude));
|
||||
}
|
||||
const path = qs.toString() ? `/stores?${qs}` : '/stores';
|
||||
return request<Store[]>(path)
|
||||
.then((list) => setStores(Array.isArray(list) ? list : []))
|
||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [cityCode]);
|
||||
}, [cityCode, userCoords]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadStores();
|
||||
@@ -96,12 +113,20 @@ export default function StoresPage() {
|
||||
usePullDownRefresh(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const resolved = await resolveUserCity();
|
||||
setRegion(resolved.region);
|
||||
const resolved = await resolveUserCity(true);
|
||||
setRegion(toCityWideRegion(resolved.region));
|
||||
const nextCode = getCityCodeForCatalog(resolved);
|
||||
setCityCode(nextCode);
|
||||
const coords = readCachedUserCoords();
|
||||
setUserCoords(coords);
|
||||
setLoading(true);
|
||||
const path = nextCode ? `/stores?cityCode=${encodeURIComponent(nextCode)}` : '/stores';
|
||||
const qs = new URLSearchParams();
|
||||
if (nextCode) qs.set('cityCode', nextCode);
|
||||
if (coords) {
|
||||
qs.set('lat', String(coords.latitude));
|
||||
qs.set('lng', String(coords.longitude));
|
||||
}
|
||||
const path = qs.toString() ? `/stores?${qs}` : '/stores';
|
||||
const list = await request<Store[]>(path);
|
||||
setStores(Array.isArray(list) ? list : []);
|
||||
} catch (e) {
|
||||
@@ -189,7 +214,7 @@ export default function StoresPage() {
|
||||
{loading ? <View className="u-empty">加载中…</View> : null}
|
||||
{!loading && filtered.length === 0 ? <View className="u-empty">暂无营业中门店</View> : null}
|
||||
{!loading &&
|
||||
filtered.map((s, index) => (
|
||||
filtered.map((s) => (
|
||||
<View
|
||||
key={s.id}
|
||||
className="store-card"
|
||||
@@ -211,7 +236,9 @@ export default function StoresPage() {
|
||||
<Text className="store-card-meta">人均¥{Number(s.avgPrice).toFixed(0)}</Text>
|
||||
) : null}
|
||||
<View className="store-card-footer">
|
||||
<Text className="store-card-distance">{MOCK_DISTANCES[index % MOCK_DISTANCES.length]}</Text>
|
||||
<Text className="store-card-distance">
|
||||
{formatDistanceMeters(s.distanceMeters)}
|
||||
</Text>
|
||||
<Text
|
||||
className="store-card-cta"
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -79,6 +79,38 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.store-detail-meta--flex {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.store-detail-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.store-detail-action {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(166, 29, 36, 0.08);
|
||||
color: var(--color-heritage-red);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.store-detail-intro {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: var(--color-on-surface);
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.store-detail-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -10,6 +10,12 @@ export type ReverseGeocodeResult = {
|
||||
logId: bigint;
|
||||
};
|
||||
|
||||
export type GeocodeAddressResult = {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
logId: bigint;
|
||||
};
|
||||
|
||||
function normalizeCityName(name: string) {
|
||||
return name.replace(/市$/, '').trim();
|
||||
}
|
||||
@@ -25,6 +31,83 @@ export class TencentLbsProvider {
|
||||
return !!this.config.tencentLbsKey;
|
||||
}
|
||||
|
||||
/** 地址 → 坐标(正向地理编码) */
|
||||
async geocodeAddress(
|
||||
address: string,
|
||||
actorRef?: WechatActorRef,
|
||||
): Promise<GeocodeAddressResult | null> {
|
||||
const trimmed = address.replace(/\s+/g, '').trim();
|
||||
const baseLog = {
|
||||
provider: 'WECHAT_MAP' as const,
|
||||
scene: 'GEOCODE',
|
||||
refType: actorRef?.refType,
|
||||
refId: actorRef?.refId,
|
||||
requestUrl: 'https://apis.map.qq.com/ws/geocoder/v1/',
|
||||
requestBody: { address: trimmed.slice(0, 200) },
|
||||
};
|
||||
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (!this.isEnabled()) {
|
||||
await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
...baseLog,
|
||||
status: 'FAILED',
|
||||
errorMessage: 'TENCENT_LBS_KEY 未配置',
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = new URL('https://apis.map.qq.com/ws/geocoder/v1/');
|
||||
url.searchParams.set('address', trimmed);
|
||||
url.searchParams.set('key', this.config.tencentLbsKey);
|
||||
|
||||
try {
|
||||
const res = await fetch(url.toString());
|
||||
const data = (await res.json()) as {
|
||||
status?: number;
|
||||
message?: string;
|
||||
result?: { location?: { lat?: number; lng?: number } };
|
||||
};
|
||||
const loc = data.result?.location;
|
||||
const ok =
|
||||
data.status === 0 &&
|
||||
typeof loc?.lat === 'number' &&
|
||||
typeof loc?.lng === 'number' &&
|
||||
Number.isFinite(loc.lat) &&
|
||||
Number.isFinite(loc.lng);
|
||||
|
||||
const log = await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
...baseLog,
|
||||
responseBody: {
|
||||
status: data.status,
|
||||
message: data.message,
|
||||
lat: loc?.lat,
|
||||
lng: loc?.lng,
|
||||
},
|
||||
status: ok ? 'SUCCESS' : 'FAILED',
|
||||
errorMessage: ok ? undefined : data.message ?? '地理编码失败',
|
||||
},
|
||||
});
|
||||
|
||||
if (!ok || !loc) return null;
|
||||
return { latitude: loc.lat!, longitude: loc.lng!, logId: log.id };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`Tencent LBS geocode failed: ${message}`);
|
||||
await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
...baseLog,
|
||||
status: 'FAILED',
|
||||
errorMessage: message.slice(0, 512),
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async reverseGeocode(
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
|
||||
@@ -14,8 +14,14 @@ export class PublicStoreController {
|
||||
constructor(private readonly storeService: StoreService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query('cityCode') cityCode?: string) {
|
||||
return this.storeService.listOpenStores(cityCode);
|
||||
list(
|
||||
@Query('cityCode') cityCode?: string,
|
||||
@Query('lat') lat?: string,
|
||||
@Query('lng') lng?: string,
|
||||
) {
|
||||
const userLat = lat != null && lat !== '' ? Number(lat) : undefined;
|
||||
const userLng = lng != null && lng !== '' ? Number(lng) : undefined;
|
||||
return this.storeService.listOpenStores(cityCode, userLat, userLng);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
||||
@@ -3,6 +3,7 @@ import { IamModule } from '../iam/iam.module';
|
||||
import { RedeemModule } from '../redeem/redeem.module';
|
||||
import { AnalyticsModule } from '../analytics/analytics.module';
|
||||
import { CityScopeModule } from '../city-scope/city-scope.module';
|
||||
import { IntegrationsModule } from '../../integrations/integrations.module';
|
||||
import { StoreService } from './store.service';
|
||||
import { StoreCategoryService } from './store-category.service';
|
||||
import {
|
||||
@@ -17,7 +18,13 @@ import {
|
||||
} from './store.controller';
|
||||
|
||||
@Module({
|
||||
imports: [IamModule, AnalyticsModule, CityScopeModule, forwardRef(() => RedeemModule)],
|
||||
imports: [
|
||||
IamModule,
|
||||
AnalyticsModule,
|
||||
CityScopeModule,
|
||||
IntegrationsModule,
|
||||
forwardRef(() => RedeemModule),
|
||||
],
|
||||
controllers: [
|
||||
PublicStoreController,
|
||||
PublicStoreCategoriesController,
|
||||
|
||||
@@ -15,6 +15,18 @@ import { AnalyticsService } from '../analytics/analytics.service';
|
||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||
import { AuthService } from '../iam/auth.service';
|
||||
import { StoreCategoryService } from './store-category.service';
|
||||
import { TencentLbsProvider } from '../../integrations/map/tencent-lbs.provider';
|
||||
|
||||
function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||
const R = 6371000;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLng = toRad(lng2 - lng1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class StoreService {
|
||||
@@ -26,9 +38,48 @@ export class StoreService {
|
||||
private readonly partnerCityService: PartnerCityService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly storeCategoryService: StoreCategoryService,
|
||||
private readonly tencentLbs: TencentLbsProvider,
|
||||
) {}
|
||||
|
||||
async listOpenStores(cityCode?: string) {
|
||||
private storeAddressText(store: {
|
||||
province?: string | null;
|
||||
cityName?: string | null;
|
||||
district?: string | null;
|
||||
address?: string | null;
|
||||
}) {
|
||||
return `${store.province ?? ''}${store.cityName ?? ''}${store.district ?? ''}${store.address ?? ''}`.trim();
|
||||
}
|
||||
|
||||
/** 缺坐标时用地址正向地理编码并回写 */
|
||||
private async ensureStoreCoordinates(store: {
|
||||
id: bigint;
|
||||
latitude?: unknown;
|
||||
longitude?: unknown;
|
||||
province?: string | null;
|
||||
cityName?: string | null;
|
||||
district?: string | null;
|
||||
address?: string | null;
|
||||
}): Promise<{ latitude: number; longitude: number } | null> {
|
||||
const lat = store.latitude != null ? Number(store.latitude) : NaN;
|
||||
const lng = store.longitude != null ? Number(store.longitude) : NaN;
|
||||
if (Number.isFinite(lat) && Number.isFinite(lng) && !(lat === 0 && lng === 0)) {
|
||||
return { latitude: lat, longitude: lng };
|
||||
}
|
||||
const address = this.storeAddressText(store);
|
||||
if (!address) return null;
|
||||
const geo = await this.tencentLbs.geocodeAddress(address, {
|
||||
refType: 'STORE',
|
||||
refId: store.id,
|
||||
});
|
||||
if (!geo) return null;
|
||||
await this.prisma.store.update({
|
||||
where: { id: store.id },
|
||||
data: { latitude: geo.latitude, longitude: geo.longitude },
|
||||
});
|
||||
return { latitude: geo.latitude, longitude: geo.longitude };
|
||||
}
|
||||
|
||||
async listOpenStores(cityCode?: string, userLat?: number, userLng?: number) {
|
||||
const where: Record<string, unknown> = { status: 'OPEN' };
|
||||
if (cityCode) {
|
||||
const city = await this.prisma.commonCity.findFirst({ where: { code: cityCode } });
|
||||
@@ -39,7 +90,43 @@ export class StoreService {
|
||||
include: { category: true, coverResource: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return serializeBigInt(stores.map(mapStoreCompat));
|
||||
|
||||
const hasUser =
|
||||
userLat != null &&
|
||||
userLng != null &&
|
||||
Number.isFinite(userLat) &&
|
||||
Number.isFinite(userLng);
|
||||
|
||||
type StoreListItem = ReturnType<typeof mapStoreCompat> & {
|
||||
distanceMeters: number | null;
|
||||
latitude?: unknown;
|
||||
longitude?: unknown;
|
||||
};
|
||||
|
||||
const items: StoreListItem[] = [];
|
||||
for (const store of stores) {
|
||||
const coords = await this.ensureStoreCoordinates(store);
|
||||
const mapped = mapStoreCompat({
|
||||
...store,
|
||||
latitude: coords?.latitude ?? store.latitude,
|
||||
longitude: coords?.longitude ?? store.longitude,
|
||||
});
|
||||
const distanceMeters =
|
||||
hasUser && coords
|
||||
? Math.round(haversineMeters(userLat!, userLng!, coords.latitude, coords.longitude))
|
||||
: null;
|
||||
items.push({ ...mapped, distanceMeters });
|
||||
}
|
||||
|
||||
if (hasUser) {
|
||||
items.sort((a, b) => {
|
||||
const da = a.distanceMeters ?? Number.POSITIVE_INFINITY;
|
||||
const db = b.distanceMeters ?? Number.POSITIVE_INFINITY;
|
||||
return da - db;
|
||||
});
|
||||
}
|
||||
|
||||
return serializeBigInt(items);
|
||||
}
|
||||
|
||||
async getStore(id: bigint) {
|
||||
@@ -48,11 +135,19 @@ export class StoreService {
|
||||
include: { category: true, coverResource: true },
|
||||
});
|
||||
if (!store) throw new NotFoundException('门店不存在');
|
||||
const coords = await this.ensureStoreCoordinates(store);
|
||||
const media = await this.prisma.commonResource.findMany({
|
||||
where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE', bizType: 'ENV' },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
return serializeBigInt(mapStoreCompat({ ...store, media }));
|
||||
return serializeBigInt(
|
||||
mapStoreCompat({
|
||||
...store,
|
||||
latitude: coords?.latitude ?? store.latitude,
|
||||
longitude: coords?.longitude ?? store.longitude,
|
||||
media,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async resolvePartnerScope(actorAccountId: bigint) {
|
||||
@@ -196,6 +291,11 @@ export class StoreService {
|
||||
throw new BadRequestException('人均费用须为非负数字');
|
||||
}
|
||||
|
||||
const introRaw = body.intro != null ? String(body.intro).trim() : '';
|
||||
if (introRaw && (introRaw.length < 2 || introRaw.length > 500)) {
|
||||
throw new BadRequestException('门店简介须为 2~500 字');
|
||||
}
|
||||
|
||||
const store = await this.prisma.store.create({
|
||||
data: {
|
||||
cityId: city.id,
|
||||
@@ -207,7 +307,7 @@ export class StoreService {
|
||||
cityName: String(body.city ?? city.name ?? '郑州市'),
|
||||
district: String(body.district ?? ''),
|
||||
address: String(body.address),
|
||||
intro: body.intro ? String(body.intro) : null,
|
||||
intro: introRaw || null,
|
||||
avgPrice: avgPriceRaw,
|
||||
openTime,
|
||||
closeTime,
|
||||
@@ -220,6 +320,8 @@ export class StoreService {
|
||||
},
|
||||
});
|
||||
|
||||
await this.ensureStoreCoordinates(store);
|
||||
|
||||
const ossBucket = process.env.OSS_BUCKET ?? 'legacy';
|
||||
|
||||
if (coverUrl) {
|
||||
@@ -404,17 +506,19 @@ export class StoreService {
|
||||
throw new BadRequestException('联系电话须为11位手机号');
|
||||
}
|
||||
if (address !== undefined && !address) throw new BadRequestException('请填写详细地址');
|
||||
if (introRaw && (introRaw.length < 10 || introRaw.length > 500)) {
|
||||
throw new BadRequestException('门店简介须为 10~500 字');
|
||||
if (introRaw && (introRaw.length < 2 || introRaw.length > 500)) {
|
||||
throw new BadRequestException('门店简介须为 2~500 字');
|
||||
}
|
||||
|
||||
const resubmitAudit = store.auditStatus === 'REJECTED';
|
||||
await this.prisma.store.update({
|
||||
const updated = await this.prisma.store.update({
|
||||
where: { id: storeId },
|
||||
data: {
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(phone !== undefined ? { phone } : {}),
|
||||
...(address !== undefined ? { address } : {}),
|
||||
...(address !== undefined
|
||||
? { address, latitude: null, longitude: null }
|
||||
: {}),
|
||||
...(introRaw !== undefined ? { intro: introRaw || null } : {}),
|
||||
...(resubmitAudit
|
||||
? {
|
||||
@@ -427,6 +531,10 @@ export class StoreService {
|
||||
},
|
||||
});
|
||||
|
||||
if (address !== undefined) {
|
||||
await this.ensureStoreCoordinates(updated);
|
||||
}
|
||||
|
||||
if (resubmitAudit) {
|
||||
await this.prisma.commonEvent.create({
|
||||
data: {
|
||||
|
||||
Reference in New Issue
Block a user