4 Commits

Author SHA1 Message Date
jacy 15b878f2a4 fix(admin,mini-user): product on-sale tag, store coords inputs, home UX
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 22:40:57 +08:00
jacy 09eda9a11e H5端开店增加获取当前店铺位置 2026-07-27 22:37:15 +08:00
jacy 23c0cc7a5f 小程序优化,商铺定位等 2026-07-27 22:33:00 +08:00
jacy 1725b9e4b4 小程序加上公司资质
CI / verify (pull_request) Has been cancelled
2026-07-26 13:31:21 +08:00
24 changed files with 765 additions and 50 deletions
+3 -1
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;
@@ -84,7 +86,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;
}
+3 -1
View File
@@ -271,7 +271,9 @@ export default function ProductsPage() {
{ title: '规格', dataIndex: 'spec', width: 120, ellipsis: true },
{ title: '售价', dataIndex: 'price', width: 80, render: (v) => `¥${v}` },
{ title: '权益额', dataIndex: 'benefitAmount', width: 80, render: (v) => `¥${v}` },
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{PRODUCT_STATUS_LABELS[s] || s}</Tag> },
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => (
<Tag color={s === 'ON_SALE' ? 'green' : undefined}>{PRODUCT_STATUS_LABELS[s] || s}</Tag>
) },
{
title: '现场取货',
dataIndex: 'allowOnSitePickup',
+63 -1
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[]>([]);
@@ -465,6 +466,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 +864,58 @@ export default function StoresPage() {
<Form.Item name="address" label="详细地址" rules={[{ required: true, message: '请填写详细地址' }]}>
<Input.TextArea rows={2} placeholder="请输入详细门牌号" />
</Form.Item>
<Space wrap style={{ width: '100%' }}>
<Form.Item name="latitude" label="纬度" style={{ marginBottom: 8 }}>
<InputNumber
style={{ width: 180 }}
precision={7}
step={0.000001}
placeholder="如 34.7466000"
/>
</Form.Item>
<Form.Item name="longitude" label="经度" style={{ marginBottom: 8 }}>
<InputNumber
style={{ width: 180 }}
precision={7}
step={0.000001}
placeholder="如 113.6253000"
/>
</Form.Item>
</Space>
<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">
便
</Typography.Text>
</Space>
<Space wrap style={{ width: '100%' }}>
<Form.Item name="openTime" label="营业开始" rules={[{ required: true, message: '请填写营业开始时间' }]}>
<Input type="time" style={{ width: 140 }} />
@@ -874,7 +936,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' }}>
+11 -4
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),
@@ -178,10 +185,10 @@ export function validateStoreStep1(
if (Number.isNaN(n) || n < 0) return '人均费用须为非负数字';
}
if (!form.categoryId.trim()) return '请选择店铺类型';
if (form.intro.trim()) {
const len = form.intro.trim().length;
if (len < 10 || len > 500) return '门店简介须为 10~500 字';
}
if (form.intro.trim()) {
const len = form.intro.trim().length;
if (len < 2 || len > 500) return '门店简介须为 2~500 字';
}
return null;
}
+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)}`;
}
+45 -1
View File
@@ -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">
@@ -870,7 +914,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 }}>
+62 -3
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,10 +336,52 @@ 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>
<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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 518 KiB

+23
View File
@@ -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`;
}
+1 -1
View File
@@ -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 {
+40 -3
View File
@@ -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,
+1 -1
View File
@@ -150,7 +150,7 @@ export default function HomePage() {
indicatorDots={banners.length > 1}
autoplay={banners.length > 1}
circular={banners.length > 1}
interval={4000}
interval={2500}
>
{banners.map((url) => (
<SwiperItem key={url}>
+28 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { View, Text, Image, Button, Input } from '@tarojs/components';
import { View, Text, Image, Button, Input, ScrollView } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
import { isWxAuthorizeEnabled, type ClientRuntimeConfig } from '@dukang/shared-types';
import PageShell from '../../components/PageShell';
@@ -18,6 +18,7 @@ import {
} from '../../lib/mini-wechat-profile';
import { isLoggedIn, logout, request, toast, type UserProfile } from '../../lib/api';
import { isWechatEnv } from '../../lib/weixin';
import qualificationDisclosureImg from '../../assets/qualification-disclosure.png';
const ORDER_SHORTCUTS = [
{ tab: 'pending_pay', icon: '付', label: '待付款' },
@@ -29,6 +30,7 @@ const SERVICES = [
{ icon: '址', label: '地址管理', url: '/pages/addresses/index' },
{ icon: '店', label: '可用门店', tab: '/pages/stores/index' },
{ icon: '服', label: '联系客服', url: '/pages/customer-service/index' },
{ icon: '资', label: '资质公示', action: 'qualification' as const },
{ icon: '关', label: '关于我们', action: 'about' as const },
] as const;
@@ -51,6 +53,7 @@ export default function MinePage() {
const [draftNickname, setDraftNickname] = useState('');
const [savingProfile, setSavingProfile] = useState(false);
const [profileLoadError, setProfileLoadError] = useState('');
const [qualificationOpen, setQualificationOpen] = useState(false);
function resetGuestState() {
setProfile(null);
@@ -254,6 +257,10 @@ export default function MinePage() {
Taro.switchTab({ url: item.tab });
return;
}
if ('action' in item && item.action === 'qualification') {
setQualificationOpen(true);
return;
}
if ('action' in item && item.action === 'about') {
toast('杜康好客 · 传承千年酒文化');
}
@@ -507,14 +514,32 @@ export default function MinePage() {
</View>
<View
className={`mine-profile-sheet-save${savingProfile ? ' is-disabled' : ''}`}
onClick={savingProfile ? undefined : () => void saveWxProfile()}
onClick={() => {
if (!savingProfile) void saveWxProfile();
}}
>
<Text>{savingProfile ? '保存中...' : '保存'}</Text>
<Text>{savingProfile ? '保存中' : '保存'}</Text>
</View>
</View>
</View>
</View>
) : null}
{qualificationOpen ? (
<View
className="mine-qualification-mask"
onClick={() => setQualificationOpen(false)}
>
<ScrollView scrollY className="mine-qualification-scroll" enhanced showScrollbar>
<Image
className="mine-qualification-img"
src={qualificationDisclosureImg}
mode="widthFix"
/>
</ScrollView>
<Text className="mine-qualification-hint"></Text>
</View>
) : null}
</PageShell>
);
}
@@ -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">
{store.district ? `${store.district} · ` : ''}
{store.address || '地址待完善'}
</Text>
<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>
+38 -11
View File
@@ -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) => {
+2 -2
View File
@@ -205,8 +205,8 @@
justify-content: center;
padding: 4px 10px;
border-radius: var(--radius-full);
background: #2e7d32;
color: #fff;
background: var(--color-aged-amber);
color: #5c4500;
font-size: 11px;
font-weight: 600;
line-height: 16px;
+35
View File
@@ -498,3 +498,38 @@
opacity: 0.65;
pointer-events: none;
}
.mine-qualification-mask {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 24px 16px;
background: rgba(0, 0, 0, 0.72);
box-sizing: border-box;
}
.mine-qualification-scroll {
width: 100%;
max-width: 420px;
max-height: calc(86vh - 36px);
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.mine-qualification-img {
display: block;
width: 100%;
pointer-events: none;
}
.mine-qualification-hint {
text-align: center;
font-size: 12px;
color: rgba(255, 255, 255, 0.82);
}
@@ -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,
@@ -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;
@@ -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,27 @@ 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)));
}
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 {
@@ -26,9 +47,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 +99,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 +144,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 +300,17 @@ 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 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,
@@ -207,12 +322,13 @@ 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,
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,
@@ -220,6 +336,10 @@ export class StoreService {
},
});
if (latitude == null || longitude == null) {
await this.ensureStoreCoordinates(store);
}
const ossBucket = process.env.OSS_BUCKET ?? 'legacy';
if (coverUrl) {
@@ -398,23 +518,39 @@ 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)) {
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 字');
}
if (
(latitude !== undefined || longitude !== undefined) &&
(latitude == null || longitude == null)
) {
throw new BadRequestException('经纬度须同时提供');
}
const resubmitAudit = store.auditStatus === 'REJECTED';
await this.prisma.store.update({
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 } : {}),
...(address !== undefined
? hasCoordsUpdate
? { address }
: { address, latitude: null, longitude: null }
: {}),
...(hasCoordsUpdate ? { latitude, longitude } : {}),
...(introRaw !== undefined ? { intro: introRaw || null } : {}),
...(resubmitAudit
? {
@@ -427,6 +563,10 @@ export class StoreService {
},
});
if (address !== undefined && !hasCoordsUpdate) {
await this.ensureStoreCoordinates(updated);
}
if (resubmitAudit) {
await this.prisma.commonEvent.create({
data: {