diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index 268d91a..669356b 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -13,6 +13,7 @@ import BenefitCouponsPage from './pages/BenefitCouponsPage'; import BenefitLedgersPage from './pages/BenefitLedgersPage'; import RedeemRecordsPage from './pages/RedeemRecordsPage'; import DeliveriesPage from './pages/DeliveriesPage'; +import XiaofeixiaTestPage from './pages/XiaofeixiaTestPage'; import HqAccountsPage from './pages/HqAccountsPage'; import CitiesPage from './pages/CitiesPage'; import StoreMediaPage from './pages/StoreMediaPage'; @@ -21,6 +22,7 @@ import ResourcesPage from './pages/ResourcesPage'; import StorePayoutsPage from './pages/StorePayoutsPage'; import PartnerBillsPage from './pages/PartnerBillsPage'; import TicketsPage from './pages/TicketsPage'; +import UserLogsPage from './pages/UserLogsPage'; import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage'; function RequireAuth({ children }: { children: React.ReactNode }) { @@ -56,8 +58,10 @@ export default function App() { } /> } /> } /> - } /> + } /> + } /> } /> + } /> } /> } /> diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index e6f5e98..ee61bec 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -13,6 +13,7 @@ import { SafetyOutlined, LogoutOutlined, CloudUploadOutlined, + FileTextOutlined, } from '@ant-design/icons'; import { clearAuth, request, type HqProfile } from '../lib/api'; @@ -57,8 +58,24 @@ const MENU_ITEMS: MenuProps['items'] = [ }, { key: '/partner-bills', icon: , label: '合伙人结算' }, { key: '/tickets', icon: , label: '工单中心' }, - { key: '/third-party-logs', icon: , label: '第三方日志' }, - { key: '/deliveries', icon: , label: '配送单' }, + { + key: 'logs-group', + icon: , + label: '日志', + children: [ + { key: '/logs/users', label: '用户日志' }, + { key: '/logs/third-party', label: '第三方日志' }, + ], + }, + { + key: 'deliveries-group', + icon: , + label: '配送单', + children: [ + { key: '/deliveries', label: '配送单列表' }, + { key: '/deliveries/xiaofeixia', label: '小飞侠联调' }, + ], + }, { key: '/hq-accounts', icon: , label: 'HQ账户' }, ]; @@ -86,7 +103,7 @@ export default function AdminLayout() { theme="dark" mode="inline" selectedKeys={[selectedKey]} - defaultOpenKeys={['stores-group', 'partners-group', 'benefit-group']} + defaultOpenKeys={['stores-group', 'partners-group', 'benefit-group', 'logs-group', 'deliveries-group']} items={MENU_ITEMS} onClick={({ key }) => { if (key.startsWith('/')) navigate(key); diff --git a/apps/admin-web/src/lib/user-log.ts b/apps/admin-web/src/lib/user-log.ts new file mode 100644 index 0000000..9114728 --- /dev/null +++ b/apps/admin-web/src/lib/user-log.ts @@ -0,0 +1,45 @@ +export type UserLogCategory = + | 'login' + | 'browse_product' + | 'order' + | 'pay' + | 'wechat_auth' + | 'browse_store' + | 'redeem' + | 'profile'; + +const USER_LOG_EVENT_CATEGORIES: Record = { + login: ['login_success', 'sms_login', 'sms_send', 'sms_verify_fail'], + browse_product: ['home_view', 'product_click', 'product_detail_view'], + order: ['order_confirm_view', 'order_submit'], + pay: ['pay_success', 'pay_fail'], + wechat_auth: ['wechat_login', 'wechat_phone', 'wechat_location', 'wechat_album'], + browse_store: ['store_list_view', 'store_detail_view'], + redeem: ['benefit_redeem_start', 'benefit_redeem_success'], + profile: ['profile_update', 'bind_phone'], +}; + +export const USER_LOG_CATEGORY_OPTIONS: Array<{ value: UserLogCategory | ''; label: string }> = [ + { value: '', label: '全部' }, + { value: 'login', label: '登录' }, + { value: 'browse_product', label: '浏览商品' }, + { value: 'order', label: '下单' }, + { value: 'pay', label: '支付' }, + { value: 'wechat_auth', label: '微信授权' }, + { value: 'browse_store', label: '浏览门店' }, + { value: 'redeem', label: '核销' }, + { value: 'profile', label: '信息修改' }, +]; + +export function resolveUserLogCategory(eventName: string): UserLogCategory | null { + for (const [category, events] of Object.entries(USER_LOG_EVENT_CATEGORIES) as Array< + [UserLogCategory, readonly string[]] + >) { + if (events.includes(eventName)) return category; + } + return null; +} + +export const USER_LOG_CATEGORY_LABELS = Object.fromEntries( + USER_LOG_CATEGORY_OPTIONS.filter((o) => o.value).map((o) => [o.value, o.label]), +) as Record; diff --git a/apps/admin-web/src/pages/ProductsPage.tsx b/apps/admin-web/src/pages/ProductsPage.tsx index 117c7ea..fdedc3b 100644 --- a/apps/admin-web/src/pages/ProductsPage.tsx +++ b/apps/admin-web/src/pages/ProductsPage.tsx @@ -1,13 +1,21 @@ import { useState } from 'react'; import { - Button, Descriptions, Drawer, Form, Input, InputNumber, Modal, Select, Space, Table, Tag, Typography, message, + Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Select, Space, + Table, Tabs, Tag, Typography, message, } from 'antd'; +import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; -import { request, type Paginated } from '../lib/api'; +import { request } from '../lib/api'; import { AROMA_TYPE_LABELS, PRODUCT_STATUS_LABELS, fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; import OssUpload from '../components/OssUpload'; +type ProductDetailContentDto = { + storyTitle?: string; + storyText?: string; + features?: Array<{ icon: string; title: string; desc: string }>; +}; + type Row = { id: string; skuCode: string; @@ -21,9 +29,196 @@ type Row = { status: string; sortOrder: number; mainImageUrl?: string | null; + carouselUrls?: string[]; + detailImageUrls?: string[]; + detailContent?: ProductDetailContentDto | null; createdAt: string; }; +type ProductFormValues = { + skuCode?: string; + barcode69?: string; + name: string; + subtitle?: string; + aromaType?: string; + spec: string; + price: number; + benefitAmount?: number; + status?: string; + sortOrder?: number; + coverUrl?: string; + carouselUrls?: string[]; + detailImageUrls?: string[]; + storyTitle?: string; + storyText?: string; + features?: Array<{ icon?: string; title?: string; desc?: string }>; +}; + +function mapDetailToForm(d: Record) { + const detail = (d.detailContent ?? {}) as ProductDetailContentDto; + return { + ...d, + coverUrl: (d as { mainImageUrl?: string }).mainImageUrl, + carouselUrls: ((d as Row).carouselUrls?.length ? (d as Row).carouselUrls : ['']) as string[], + detailImageUrls: ((d as Row).detailImageUrls?.length ? (d as Row).detailImageUrls : ['']) as string[], + storyTitle: detail.storyTitle ?? '', + storyText: detail.storyText ?? '', + features: detail.features?.length + ? detail.features + : [{ icon: 'water_drop', title: '', desc: '' }], + }; +} + +function buildProductPayload(v: ProductFormValues) { + const carouselUrls = (v.carouselUrls ?? []).map((u) => u?.trim()).filter(Boolean); + const detailImageUrls = (v.detailImageUrls ?? []).map((u) => u?.trim()).filter(Boolean); + const features = (v.features ?? []) + .filter((f) => f?.title?.trim() || f?.desc?.trim()) + .map((f) => ({ + icon: f.icon?.trim() || 'star', + title: f.title?.trim() || '', + desc: f.desc?.trim() || '', + })); + + const detailContent: ProductDetailContentDto = { + storyTitle: v.storyTitle?.trim() || undefined, + storyText: v.storyText?.trim() || undefined, + features: features.length ? features : undefined, + }; + + return { + skuCode: v.skuCode, + barcode69: v.barcode69, + name: v.name, + subtitle: v.subtitle, + aromaType: v.aromaType, + spec: v.spec, + price: v.price, + benefitAmount: v.benefitAmount, + status: v.status, + sortOrder: v.sortOrder, + coverUrl: v.coverUrl, + carouselUrls, + detailImageUrls, + detailContent, + }; +} + +function ImageUrlList({ name, label, bizType }: { name: string; label: string; bizType: string }) { + return ( + + {(fields, { add, remove }) => ( + <> + {fields.map((field) => ( + + + + + {fields.length > 1 && ( + remove(field.name)} style={{ marginTop: 8 }} /> + )} + + ))} + + + )} + + ); +} + +function ProductDetailFields() { + return ( + <> + 详情页轮播(CAROUSEL) + + + 详情长图(DETAIL) + + + + + + + + + 卖点特色 + + {(fields, { add, remove }) => ( + <> + {fields.map((field) => ( + + + + + + + + + {fields.length > 1 && ( + remove(field.name)} style={{ marginTop: 30 }} /> + )} + + + + + + ))} + + + )} + + + ); +} + +function BaseInfoFields({ mode }: { mode: 'create' | 'edit' }) { + return ( + <> + {mode === 'create' && ( + <> + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - + + + + + + + { setPage(p); setPageSize(ps); } }} /> + setDrawerOpen(false)}> + {detail && ( + + {String(detail.id)} + {String(detail.nickname || detail.userNo || detail.userId || '—')} + {String(detail.phone || '—')} + {CATEGORY_LABELS[String(detail.category ?? '')] || String(detail.category || '—')} + {String(detail.eventName)} + {String(detail.clientApp || '—')} + {detail.refType ? `${String(detail.refType)}#${String(detail.refId)}` : '—'} + {fmtTime(String(detail.createdAt))} + +
+                {JSON.stringify(detail.extraJson ?? {}, null, 2)}
+              
+
+
+ )} +
+ + ); +} diff --git a/apps/admin-web/src/pages/UsersPage.tsx b/apps/admin-web/src/pages/UsersPage.tsx index 95a3bf2..695e172 100644 --- a/apps/admin-web/src/pages/UsersPage.tsx +++ b/apps/admin-web/src/pages/UsersPage.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { Button, Descriptions, @@ -24,6 +25,7 @@ type UserDetail = AdminUserRow & { }; export default function UsersPage() { + const navigate = useNavigate(); const [form] = Form.useForm(); const [data, setData] = useState | null>(null); const [loading, setLoading] = useState(false); @@ -99,11 +101,16 @@ export default function UsersPage() { }, { title: '操作', - width: 80, + width: 140, render: (_, row) => ( - + + + + ), }, ]; @@ -198,6 +205,13 @@ export default function UsersPage() { /> )} + )} diff --git a/apps/admin-web/src/pages/XiaofeixiaTestPage.tsx b/apps/admin-web/src/pages/XiaofeixiaTestPage.tsx new file mode 100644 index 0000000..7da077e --- /dev/null +++ b/apps/admin-web/src/pages/XiaofeixiaTestPage.tsx @@ -0,0 +1,290 @@ +import { useEffect, useState } from 'react'; +import { + Alert, Button, Card, Col, Descriptions, Form, Input, InputNumber, Row, Select, Space, + Tabs, Tag, Typography, message, +} from 'antd'; +import { request } from '../lib/api'; + +type XfxConfig = { + provider: string; + activeProvider: string; + apiUrl: string; + appId: string | null; + mchId: string | null; + mchIdMasked: string | null; + hasApiKey: boolean; + signType: string; + ready: boolean; +}; + +type ApiResult = { + ok: boolean; + elapsedMs: number; + data?: unknown; + error?: string; + code?: string; + provider?: string; + raw?: unknown; +}; + +const CREATE_DEFAULTS = { + outNumber: `TEST-${Date.now()}`, + fromName: '杜康仓库', + fromMobile: '13800000000', + fromAddress: '河南省郑州市金水区', + fromAddressDetail: '杜康酒业仓', + fromLng: 113.665, + fromLat: 34.757, + toName: '测试收货人', + toMobile: '13800000001', + toAddress: '河南省郑州市二七区', + toAddressDetail: '测试路 1 号', + toLng: 113.640, + toLat: 34.720, + goodsName: '杜康酒', + goodsNum: 2, + weight: 2, + payMode: '1', + remark: 'Admin 联调测试', +}; + +function ResultPanel({ result }: { result: ApiResult | null }) { + if (!result) return 点击「调用接口」后在此显示响应; + return ( +
+ + {result.ok ? '成功' : '失败'} + {result.elapsedMs} ms + {result.code && code: {result.code}} + +
+        {JSON.stringify(result, null, 2)}
+      
+
+ ); +} + +export default function XiaofeixiaTestPage() { + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + + const [freightForm] = Form.useForm(); + const [coverageForm] = Form.useForm(); + const [createForm] = Form.useForm(); + const [queryForm] = Form.useForm(); + const [batchForm] = Form.useForm(); + const [trackForm] = Form.useForm(); + const [cancelForm] = Form.useForm(); + + useEffect(() => { + void request('/admin/courier/xiaofeixia/config').then(setConfig); + createForm.setFieldsValue(CREATE_DEFAULTS); + freightForm.setFieldsValue({ weight: 2 }); + coverageForm.setFieldsValue({ toAddress: '河南省郑州市二七区测试路1号' }); + }, [createForm, freightForm, coverageForm]); + + async function invoke(path: string, body: unknown) { + setLoading(true); + setResult(null); + try { + const res = await request(path, { + method: 'POST', + body: JSON.stringify(body), + }); + setResult(res); + if (res.ok) message.success('调用成功'); + else message.warning(res.error || '调用失败'); + } catch (e) { + const err = e instanceof Error ? e.message : String(e); + message.error(err); + setResult({ ok: false, elapsedMs: 0, error: err }); + } finally { + setLoading(false); + } + } + + function parseList(text?: string) { + return (text ?? '').split(/[,,\s]+/).map((s) => s.trim()).filter(Boolean); + } + + return ( +
+ 小飞侠接口联调 + + 通过 HQ 后台直接调用后端封装的小飞侠 API(cmd 100101~100301)。请确保服务器已配置 + {' '}XIAOFEIXIA_MCH_ID、XIAOFEIXIA_API_KEY。 + + + {config && ( + + + {config.activeProvider} + {config.apiUrl} + {config.mchIdMasked || '—'} + {config.signType} + + {config.hasApiKey ? 已配置 : 未配置} + + + {config.ready ? 可调用 : 配置不完整} + + + {!config.ready && ( + + )} + + )} + + +
+ void invoke('/admin/courier/xiaofeixia/estimate-freight', v)}> + + + + + + ), + }, + { + key: 'coverage', + label: '配送范围 (100301)', + children: ( +
void invoke('/admin/courier/xiaofeixia/check-coverage', v)}> + + + + + + ), + }, + { + key: 'create', + label: '创建订单 (100101)', + children: ( +
void invoke('/admin/courier/xiaofeixia/create-shipment', v)}> + + + + +
+ 寄件人 + + + + + + + + + + + 收件人 + + + + + + + + + + + + + + + + + + + + + + + ), + }, + { + key: 'query', + label: '查询订单 (100104)', + children: ( + void invoke('/admin/courier/xiaofeixia/get-shipment', v)}> + + + + + ), + }, + { + key: 'batch', + label: '批量查询 (100106)', + children: ( + void invoke('/admin/courier/xiaofeixia/batch-get-shipments', { + trackingNumbers: parseList(v.trackingNumbersText), + outNumbers: parseList(v.outNumbersText), + })}> + + + + + + + + + ), + }, + { + key: 'track', + label: '路由查询 (100102)', + children: ( + void invoke('/admin/courier/xiaofeixia/get-track', v)}> + + + + + ), + }, + { + key: 'cancel', + label: '取消订单 (100103)', + children: ( + void invoke('/admin/courier/xiaofeixia/cancel-shipment', v)}> + + + + + ), + }, + ]} + /> + + + + + + + + + ); +} diff --git a/apps/h5-user/src/App.tsx b/apps/h5-user/src/App.tsx index 65af6ba..78703af 100644 --- a/apps/h5-user/src/App.tsx +++ b/apps/h5-user/src/App.tsx @@ -17,6 +17,7 @@ import RedeemPage from './pages/RedeemPage'; import RedeemCodePage from './pages/RedeemCodePage'; import RedeemSuccessPage from './pages/RedeemSuccessPage'; import PayPage from './pages/PayPage'; +import CustomerServicePage from './pages/CustomerServicePage'; import { UserSessionProvider } from './contexts/UserSessionContext'; export default function App() { @@ -34,6 +35,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/h5-user/src/components/ContactCustomerSheet.tsx b/apps/h5-user/src/components/ContactCustomerSheet.tsx new file mode 100644 index 0000000..212f2bf --- /dev/null +++ b/apps/h5-user/src/components/ContactCustomerSheet.tsx @@ -0,0 +1,70 @@ +import { useNavigate } from 'react-router-dom'; +import { CUSTOMER_SERVICE_PHONE } from '@dukang/shared-types'; +import { track } from '../lib/analytics'; + +type ContactCustomerSheetProps = { + orderId?: string; + orderNo?: string; + onClose: () => void; +}; + +export default function ContactCustomerSheet({ orderId, orderNo, onClose }: ContactCustomerSheetProps) { + const navigate = useNavigate(); + const tel = CUSTOMER_SERVICE_PHONE.replace(/-/g, ''); + + function openPhone() { + track('cs_contact', { type: 'phone', orderId }); + window.location.href = `tel:${tel}`; + onClose(); + } + + function openChat() { + track('cs_contact', { type: 'chat', orderId }); + const qs = new URLSearchParams(); + if (orderId) qs.set('orderId', orderId); + if (orderNo) qs.set('orderNo', orderNo); + onClose(); + navigate(`/customer-service?${qs.toString()}`); + } + + return ( +
+
e.stopPropagation()}> +
+

联系客服

+ +
+ +
+ + + +
+ + +
+
+ ); +} diff --git a/apps/h5-user/src/components/PhoneVerifySheet.tsx b/apps/h5-user/src/components/PhoneVerifySheet.tsx index 1cb2032..17e236b 100644 --- a/apps/h5-user/src/components/PhoneVerifySheet.tsx +++ b/apps/h5-user/src/components/PhoneVerifySheet.tsx @@ -1,6 +1,9 @@ import { useEffect, useState } from 'react'; -import { bindPhone, request, saveSession } from '../lib/api'; +import { SmsScene } from '@dukang/shared-types'; +import { bindPhone, type SessionPayload } from '../lib/api'; import { normalizePhoneInput, validateMobilePhone } from '../lib/phone'; +import { useSmsCode } from '../lib/use-sms-code'; +import { useUserSession } from '../contexts/UserSessionContext'; type PhoneVerifySheetProps = { open: boolean; @@ -16,18 +19,18 @@ export default function PhoneVerifySheet({ onClose, onSuccess, }: PhoneVerifySheetProps) { + const { applySession } = useUserSession(); const [phone, setPhone] = useState(''); const [code, setCode] = useState(''); - const [msg, setMsg] = useState(''); const [loading, setLoading] = useState(false); - const [codeCooldown, setCodeCooldown] = useState(0); + const { sendCode, sending, codeCooldown, sentHint, error, setError, clearMessages } = useSmsCode(); useEffect(() => { if (!open) { setPhone(''); setCode(''); - setMsg(''); - setCodeCooldown(0); + setError(''); + clearMessages(); return; } if (defaultPhone) { @@ -36,51 +39,32 @@ export default function PhoneVerifySheet({ setPhone(normalized); } } - }, [open, defaultPhone]); + }, [open, defaultPhone, clearMessages, setError]); - async function sendCode() { - const phoneCheck = validateMobilePhone(phone); - if (!phoneCheck.ok) { - setMsg(phoneCheck.message ?? '请输入正确的手机号码'); - return; - } - setMsg(''); - await request('USER_H5', '/auth/sms/send', { - method: 'POST', - body: JSON.stringify({ phone, scene: 'BIND_PHONE' }), - }); - setMsg('验证码已发送(Mock: 123456)'); - setCodeCooldown(60); - const timer = setInterval(() => { - setCodeCooldown((c) => { - if (c <= 1) { - clearInterval(timer); - return 0; - } - return c - 1; - }); - }, 1000); + async function onSendCode() { + clearMessages(); + await sendCode(phone, SmsScene.BIND_PHONE); } async function submit() { const phoneCheck = validateMobilePhone(phone); if (!phoneCheck.ok) { - setMsg(phoneCheck.message ?? '请输入正确的手机号码'); + setError(phoneCheck.message ?? '请输入正确的手机号码'); return; } if (!code.trim()) { - setMsg('请输入验证码'); + setError('请输入验证码'); return; } setLoading(true); - setMsg(''); + setError(''); try { const session = await bindPhone(phone, code); - saveSession(session); + applySession(session as SessionPayload); onSuccess(); onClose(); } catch (e) { - setMsg(e instanceof Error ? e.message : '验证失败'); + setError(e instanceof Error ? e.message : '验证失败'); } finally { setLoading(false); } @@ -105,7 +89,8 @@ export default function PhoneVerifySheet({ value={phone} onChange={(e) => { setPhone(normalizePhoneInput(e.target.value)); - setMsg(''); + setError(''); + clearMessages(); }} /> @@ -115,19 +100,26 @@ export default function PhoneVerifySheet({ inputMode="numeric" className="login-field-input" placeholder="请输入验证码" + maxLength={6} value={code} - onChange={(e) => setCode(e.target.value)} + onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} /> - {msg &&

{msg}

} + {(error || sentHint) && ( +

{error || sentHint}

+ )} diff --git a/apps/h5-user/src/contexts/UserSessionContext.tsx b/apps/h5-user/src/contexts/UserSessionContext.tsx index ca5a156..92955a1 100644 --- a/apps/h5-user/src/contexts/UserSessionContext.tsx +++ b/apps/h5-user/src/contexts/UserSessionContext.tsx @@ -13,6 +13,7 @@ import { ensureSession, getDeviceKey, request, + saveSession, type SessionPayload, type UserProfile, } from '../lib/api'; @@ -21,6 +22,7 @@ type UserSessionContextValue = { ready: boolean; profile: UserProfile | null; phoneVerified: boolean; + applySession: (session: SessionPayload) => void; refreshProfile: () => Promise; resetSession: () => Promise; }; @@ -33,6 +35,7 @@ export function UserSessionProvider({ children }: { children: ReactNode }) { const [phoneVerified, setPhoneVerified] = useState(false); const applySession = useCallback((session: SessionPayload) => { + saveSession(session); if (session.user) setProfile(session.user); setPhoneVerified(!!session.phoneVerified || !!session.user?.phoneVerified); }, []); @@ -83,10 +86,11 @@ export function UserSessionProvider({ children }: { children: ReactNode }) { ready, profile, phoneVerified, + applySession, refreshProfile, resetSession, }), - [ready, profile, phoneVerified, refreshProfile, resetSession], + [ready, profile, phoneVerified, applySession, refreshProfile, resetSession], ); if (!ready) { diff --git a/apps/h5-user/src/lib/analytics.ts b/apps/h5-user/src/lib/analytics.ts new file mode 100644 index 0000000..81adcaf --- /dev/null +++ b/apps/h5-user/src/lib/analytics.ts @@ -0,0 +1,16 @@ +import { apiBase } from './api'; + +export function track(eventName: string, params?: Record) { + const token = localStorage.getItem('accessToken'); + if (!token) return; + + void fetch(`${apiBase}/analytics/events`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + 'X-Client-App': 'USER_H5', + }, + body: JSON.stringify({ events: [{ eventName, params }] }), + }).catch(() => {}); +} diff --git a/apps/h5-user/src/lib/region-data.ts b/apps/h5-user/src/lib/region-data.ts index 51bdf00..55dc928 100644 --- a/apps/h5-user/src/lib/region-data.ts +++ b/apps/h5-user/src/lib/region-data.ts @@ -70,6 +70,31 @@ export const DEFAULT_REGION: RegionSelection = { district: '金水区', }; +export const FALLBACK_CITY_REGION: RegionSelection = { + province: '河南省', + city: '郑州市', + district: REGION_ALL, +}; + +export function regionFromGeo(province: string, city: string, district?: string): RegionSelection { + const cityName = city.endsWith('市') ? city : `${city}市`; + const provinceInTree = PROVINCES.includes(province) ? province : DEFAULT_REGION.province; + const cities = getCities(provinceInTree); + const matchedCity = cities.includes(cityName) + ? cityName + : cities.find((c) => c.replace(/市$/, '') === city.replace(/市$/, '')) ?? cityName; + const districts = getDistricts(provinceInTree, matchedCity); + const districtName = + district && districts.includes(district) + ? district + : REGION_ALL; + return normalizeRegionSelection({ + province: provinceInTree, + city: cities.includes(matchedCity) ? matchedCity : matchedCity, + district: districtName, + }); +} + /** 校验已选地区是否仍存在于数据源中 */ export function normalizeRegionSelection(selection: RegionSelection): RegionSelection { if (selection.province === REGION_ALL) { diff --git a/apps/h5-user/src/lib/use-sms-code.ts b/apps/h5-user/src/lib/use-sms-code.ts new file mode 100644 index 0000000..d9c8600 --- /dev/null +++ b/apps/h5-user/src/lib/use-sms-code.ts @@ -0,0 +1,81 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { SmsScene } from '@dukang/shared-types'; +import { request } from './api'; +import { fetchClientConfig } from './pay-wechat'; +import { validateMobilePhone } from './phone'; + +export function useSmsCode() { + const [codeCooldown, setCodeCooldown] = useState(0); + const [sending, setSending] = useState(false); + const [sentHint, setSentHint] = useState(''); + const [error, setError] = useState(''); + const [mockSms, setMockSms] = useState(true); + const timerRef = useRef | null>(null); + + useEffect(() => { + fetchClientConfig() + .then((cfg) => setMockSms(cfg.mockSms)) + .catch(() => {}); + return () => { + if (timerRef.current) clearInterval(timerRef.current); + }; + }, []); + + const startCooldown = useCallback(() => { + setCodeCooldown(60); + if (timerRef.current) clearInterval(timerRef.current); + timerRef.current = setInterval(() => { + setCodeCooldown((c) => { + if (c <= 1) { + if (timerRef.current) clearInterval(timerRef.current); + return 0; + } + return c - 1; + }); + }, 1000); + }, []); + + const sendCode = useCallback( + async (phone: string, scene: SmsScene) => { + const phoneCheck = validateMobilePhone(phone); + if (!phoneCheck.ok) { + setError(phoneCheck.message ?? '请输入正确的手机号码'); + return false; + } + setSending(true); + setError(''); + setSentHint(''); + try { + await request('USER_H5', '/auth/sms/send', { + method: 'POST', + body: JSON.stringify({ phone, scene }), + }); + setSentHint(mockSms ? '验证码已发送(开发模式)' : '验证码已发送,请注意查收'); + startCooldown(); + return true; + } catch (e) { + setError(e instanceof Error ? e.message : '发送失败'); + return false; + } finally { + setSending(false); + } + }, + [mockSms, startCooldown], + ); + + const clearMessages = useCallback(() => { + setError(''); + setSentHint(''); + }, []); + + return { + sendCode, + sending, + codeCooldown, + sentHint, + error, + setError, + clearMessages, + mockSms, + }; +} diff --git a/apps/h5-user/src/lib/wechat-location.ts b/apps/h5-user/src/lib/wechat-location.ts new file mode 100644 index 0000000..d47f26e --- /dev/null +++ b/apps/h5-user/src/lib/wechat-location.ts @@ -0,0 +1,144 @@ +import { getWechatLocationDetailed } from '@dukang/weixin-sdk'; +import { apiBase } from './api'; +import { weixinSdk } from './weixin'; +import { regionFromGeo, type RegionSelection } from './region-data'; + +export const GPS_CITY_STORAGE_KEY = 'dukang_gps_city'; +export const CITY_STORAGE_KEY = 'dukang_selected_city'; +export const FALLBACK_CITY_CODE = '410100'; + +export type ResolvedUserCity = { + province: string; + city: string; + district: string; + cityCode?: string; + cityName?: string; + openCity: boolean; + region: RegionSelection; + displayCity: string; +}; + +type GpsCityCache = ResolvedUserCity & { timestamp: number }; + +function readCache(): GpsCityCache | null { + try { + const raw = sessionStorage.getItem(GPS_CITY_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as GpsCityCache; + if (Date.now() - parsed.timestamp > 30 * 60 * 1000) return null; + return parsed; + } catch { + return null; + } +} + +function writeCache(data: ResolvedUserCity) { + sessionStorage.setItem( + GPS_CITY_STORAGE_KEY, + JSON.stringify({ ...data, timestamp: Date.now() } satisfies GpsCityCache), + ); +} + +async function reportLocationToServer(payload: { + latitude?: number; + longitude?: number; + sdk: 'jssdk' | 'geolocation'; + status: 'success' | 'fail'; + errMsg?: string; +}) { + const token = localStorage.getItem('accessToken'); + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Client-App': 'USER_H5', + }; + if (token) headers.Authorization = `Bearer ${token}`; + + const res = await fetch(`${apiBase}/common/wechat/location`, { + method: 'POST', + headers, + body: JSON.stringify(payload), + }); + const json = await res.json(); + if (json.code !== 0) { + throw new Error(json.message || '定位上报失败'); + } + return json.data as { + province?: string; + city?: string; + district?: string; + cityCode?: string; + cityName?: string; + openCity?: boolean; + }; +} + +function toResolved(data: { + province?: string; + city?: string; + district?: string; + cityCode?: string; + cityName?: string; + openCity?: boolean; +}): ResolvedUserCity | null { + if (!data.province || !data.city) return null; + const region = regionFromGeo(data.province, data.city, data.district); + const displayCity = data.cityName ?? (data.city.endsWith('市') ? data.city : `${data.city}市`); + return { + province: data.province, + city: data.city, + district: data.district ?? '', + cityCode: data.cityCode, + cityName: data.cityName, + openCity: !!data.openCity, + region, + displayCity, + }; +} + +/** 获取并解析用户当前城市(微信 JSSDK 优先),失败返回 null */ +export async function resolveUserCity(force = false): Promise { + if (!force) { + const cached = readCache(); + if (cached) return cached; + } + + const outcome = await getWechatLocationDetailed({ + apiBase, + clientApp: 'USER_H5', + getAccessToken: () => localStorage.getItem('accessToken'), + }); + + if (!outcome.location) { + await reportLocationToServer({ + sdk: outcome.sdk, + status: 'fail', + errMsg: outcome.errMsg, + }).catch(() => {}); + return null; + } + + try { + const data = await reportLocationToServer({ + latitude: outcome.location.latitude, + longitude: outcome.location.longitude, + sdk: outcome.sdk, + status: 'success', + }); + const resolved = toResolved(data); + if (resolved) { + writeCache(resolved); + if (resolved.openCity && resolved.cityCode) { + localStorage.setItem(CITY_STORAGE_KEY, resolved.cityCode); + } + } + return resolved; + } catch { + return null; + } +} + +export function syncCityCodeFromGps(resolved: ResolvedUserCity) { + if (resolved.openCity && resolved.cityCode) { + localStorage.setItem(CITY_STORAGE_KEY, resolved.cityCode); + } +} diff --git a/apps/h5-user/src/pages/AddressListPage.tsx b/apps/h5-user/src/pages/AddressListPage.tsx index 177c1fd..7f17d39 100644 --- a/apps/h5-user/src/pages/AddressListPage.tsx +++ b/apps/h5-user/src/pages/AddressListPage.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import SubPageHeader from '../components/SubPageHeader'; import { request } from '../lib/api'; +import { useUserSession } from '../contexts/UserSessionContext'; import { buildAddressEditUrl, buildAddressListUrl, buildOrderConfirmUrl, hasCheckoutContext, readCheckoutContext } from '../lib/navigation'; type Address = { @@ -24,6 +25,7 @@ function formatAddress(a: Address) { } export default function AddressListPage() { + const { profile } = useUserSession(); const [list, setList] = useState([]); const [pendingAddress, setPendingAddress] = useState
(null); const [savingOrderAddress, setSavingOrderAddress] = useState(false); @@ -42,7 +44,7 @@ export default function AddressListPage() { useEffect(() => { loadList(); - }, [loadList]); + }, [loadList, profile?.id]); function selectAddress(addr: Address) { if (!selectMode) return; @@ -137,7 +139,7 @@ export default function AddressListPage() {
{list.map((a) => { - const isDefault = a.isDefault === 1; + const isDefault = Number(a.isDefault) === 1; const isSelected = selectMode && currentAddressId === String(a.id); return (
String(n).padStart(2, '0'); + return `${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +function agentReply(userText: string, orderNo?: string) { + if (/订单|DK\d+/i.test(userText) || orderNo) { + return '已收到您的订单信息,客服将在工作时间 9:00-18:00 内为您处理,请保持电话畅通。'; + } + if (userText.includes('破损') || userText.includes('补发')) { + return '非常抱歉给您带来不便。请提供订单号并描述破损情况,我们将尽快安排补发。'; + } + if (userText.includes('退款')) { + return '请提供订单号与退款原因,客服将为您核实订单状态并协助办理。'; + } + if (userText.includes('地址')) { + return '待发货/出库中的订单可在订单详情修改收货地址;已发货订单请联系客服协助处理。'; + } + if (userText.includes('物流')) { + return '您可在订单详情查看配送进度;如有异常请提供订单号,我们为您查询。'; + } + return '您好,杜康客服已收到您的消息,请稍候,我们将尽快回复。'; +} + +export default function CustomerServicePage() { + const navigate = useNavigate(); + const [params] = useSearchParams(); + const orderId = params.get('orderId') || ''; + const orderNo = params.get('orderNo') || ''; + const [input, setInput] = useState(''); + const [messages, setMessages] = useState([]); + const [sending, setSending] = useState(false); + const listRef = useRef(null); + + useEffect(() => { + const welcome: ChatMessage[] = [ + { id: 'sys-1', role: 'system', text: nowLabel(), time: nowLabel() }, + { + id: 'agent-welcome', + role: 'agent', + text: orderNo + ? `您好,我是杜康好客客服。已为您关联订单 ${orderNo},请问有什么可以帮您?` + : '您好,我是杜康好客客服。请问有什么可以帮您?', + }, + ]; + setMessages(welcome); + }, [orderNo]); + + useEffect(() => { + listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: 'smooth' }); + }, [messages]); + + function pushMessage(role: ChatMessage['role'], text: string) { + setMessages((prev) => [...prev, { id: `${Date.now()}-${prev.length}`, role, text }]); + } + + async function sendText(text: string) { + const trimmed = text.trim(); + if (!trimmed || sending) return; + setSending(true); + pushMessage('user', trimmed); + setInput(''); + window.setTimeout(() => { + pushMessage('agent', agentReply(trimmed, orderNo)); + setSending(false); + }, 600); + } + + async function loadOrderContext() { + if (!orderId) return null; + try { + return await request>('USER_H5', `/trade/orders/${orderId}`); + } catch { + return null; + } + } + + useEffect(() => { + if (!orderId) return; + loadOrderContext().then((order) => { + if (!order) return; + const no = String(order.orderNo || orderNo); + if (no && !orderNo) { + pushMessage('system', `已关联订单 ${no}`); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [orderId]); + + return ( +
+ navigate(-1)} /> + + {orderNo && ( +
+ receipt_long +
+

当前咨询订单

+

{orderNo}

+
+
+ )} + +
+ {messages.map((m) => { + if (m.role === 'system') { + return ( +
+ {m.text} +
+ ); + } + const isUser = m.role === 'user'; + return ( +
+ {!isUser && ( +
+ support_agent +
+ )} +
{m.text}
+
+ ); + })} +
+ +
+ {QUICK_QUESTIONS.map((q) => ( + + ))} +
+ +
+ setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void sendText(input); + }} + /> + +
+
+ ); +} diff --git a/apps/h5-user/src/pages/HomePage.tsx b/apps/h5-user/src/pages/HomePage.tsx index 945d764..0905d06 100644 --- a/apps/h5-user/src/pages/HomePage.tsx +++ b/apps/h5-user/src/pages/HomePage.tsx @@ -5,6 +5,12 @@ import ProductCarousel from '../components/ProductCarousel'; import TabMainHeader from '../components/TabMainHeader'; import AppToast from '../components/AppToast'; import { getProductImages } from '../lib/product-images'; +import { track } from '../lib/analytics'; +import { + CITY_STORAGE_KEY, + FALLBACK_CITY_CODE, + resolveUserCity, +} from '../lib/wechat-location'; import CouponBadge from '@dukang/shared-ui/CouponBadge'; type Product = { @@ -33,15 +39,19 @@ const AROMA_TABS = [ { key: 'NONGXIANG', label: '浓香型', open: false }, ]; -const CITY_STORAGE_KEY = 'dukang_selected_city'; - export default function HomePage() { const [tab, setTab] = useState('QINGXIANG'); const [products, setProducts] = useState([]); const [cities, setCities] = useState([]); - const [cityCode, setCityCode] = useState(() => localStorage.getItem(CITY_STORAGE_KEY) || '410100'); + const [cityCode, setCityCode] = useState(() => localStorage.getItem(CITY_STORAGE_KEY) || FALLBACK_CITY_CODE); + const [locatedCityLabel, setLocatedCityLabel] = useState(''); + const [citySource, setCitySource] = useState<'auto' | 'manual'>('auto'); const [toast, setToast] = useState(''); + useEffect(() => { + track('home_view', { pagePath: '/', cityCode }); + }, [cityCode]); + useEffect(() => { request('USER_H5', '/catalog/cities').then((list) => { setCities(list); @@ -51,6 +61,21 @@ export default function HomePage() { }); }, []); + useEffect(() => { + if (citySource !== 'auto') return; + resolveUserCity().then((resolved) => { + if (!resolved) return; + setLocatedCityLabel(resolved.displayCity); + if (resolved.openCity && resolved.cityCode) { + setCityCode(resolved.cityCode); + } else { + setCityCode(FALLBACK_CITY_CODE); + setToast('当前城市暂未开城,已展示郑州商品'); + window.setTimeout(() => setToast(''), 2200); + } + }); + }, [citySource]); + useEffect(() => { if (!cityCode) return; localStorage.setItem(CITY_STORAGE_KEY, cityCode); @@ -71,6 +96,7 @@ export default function HomePage() { } const selectedCity = cities.find((c) => c.code === cityCode); + const headerCityLabel = locatedCityLabel || selectedCity?.name || '郑州市'; const filtered = products.filter((p) => p.aromaType === tab); const onSale = tab === 'QINGXIANG'; @@ -81,10 +107,15 @@ export default function HomePage() { extra={(
location_on + {headerCityLabel}