diff --git a/apps/h5-user/package.json b/apps/h5-user/package.json index 77dfe85..dfce210 100644 --- a/apps/h5-user/package.json +++ b/apps/h5-user/package.json @@ -9,8 +9,9 @@ "lint": "echo ok" }, "dependencies": { - "@dukang/shared-ui": "workspace:*", "@dukang/shared-types": "workspace:*", + "@dukang/shared-ui": "workspace:*", + "element-china-area-data": "^6.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.0" diff --git a/apps/h5-user/src/components/AppToast.tsx b/apps/h5-user/src/components/AppToast.tsx new file mode 100644 index 0000000..8446ca5 --- /dev/null +++ b/apps/h5-user/src/components/AppToast.tsx @@ -0,0 +1,8 @@ +type AppToastProps = { + message: string; +}; + +export default function AppToast({ message }: AppToastProps) { + if (!message) return null; + return
{message}
; +} diff --git a/apps/h5-user/src/components/RegionPicker.tsx b/apps/h5-user/src/components/RegionPicker.tsx new file mode 100644 index 0000000..c046a9c --- /dev/null +++ b/apps/h5-user/src/components/RegionPicker.tsx @@ -0,0 +1,173 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { + REGION_ALL, + getCities, + getCitiesForPicker, + getDistricts, + getDistrictsForPicker, + getProvincesForPicker, + normalizeRegionSelection, + type RegionSelection, +} from '../lib/region-data'; + +type RegionPickerProps = { + open: boolean; + value: RegionSelection; + onClose: () => void; + onConfirm: (region: RegionSelection) => void; +}; + +type PickerLevel = 'province' | 'city' | 'district'; + +const TABS: Array<{ key: PickerLevel; label: string }> = [ + { key: 'province', label: '省份' }, + { key: 'city', label: '城市' }, + { key: 'district', label: '区县' }, +]; + +export default function RegionPicker({ open, value, onClose, onConfirm }: RegionPickerProps) { + const [draft, setDraft] = useState(value); + const [activeTab, setActiveTab] = useState('province'); + const listRef = useRef(null); + + useEffect(() => { + if (!open) return; + setDraft(normalizeRegionSelection(value)); + setActiveTab('province'); + }, [open, value]); + + const listItems = useMemo(() => { + if (activeTab === 'province') return getProvincesForPicker(); + if (activeTab === 'city') return getCitiesForPicker(draft.province); + return getDistrictsForPicker(draft.province, draft.city); + }, [activeTab, draft.province, draft.city]); + + const selectedValue = + activeTab === 'province' ? draft.province : activeTab === 'city' ? draft.city : draft.district; + + const canConfirm = Boolean(draft.province && draft.city && draft.district); + + useEffect(() => { + if (!open) return; + scrollActiveIntoView(listRef.current, selectedValue); + }, [open, activeTab, selectedValue, listItems.length]); + + if (!open) return null; + + function scrollActiveIntoView(container: HTMLDivElement | null, label: string) { + if (!container || !label) return; + const active = container.querySelector(`[data-label="${CSS.escape(label)}"]`); + active?.scrollIntoView({ block: 'nearest' }); + } + + function selectProvince(province: string) { + if (province === REGION_ALL) { + setDraft({ province: REGION_ALL, city: REGION_ALL, district: REGION_ALL }); + setActiveTab('city'); + return; + } + const nextCities = getCities(province); + const city = nextCities[0] ?? ''; + const nextDistricts = getDistricts(province, city); + setDraft({ + province, + city, + district: nextDistricts[0] ?? '', + }); + setActiveTab('city'); + } + + function selectCity(city: string) { + if (city === REGION_ALL) { + setDraft({ ...draft, city: REGION_ALL, district: REGION_ALL }); + setActiveTab('district'); + return; + } + const nextDistricts = getDistricts(draft.province, city); + setDraft({ + ...draft, + city, + district: nextDistricts[0] ?? '', + }); + setActiveTab('district'); + } + + function selectDistrict(district: string) { + setDraft({ ...draft, district }); + } + + function onSelectItem(item: string) { + if (activeTab === 'province') selectProvince(item); + else if (activeTab === 'city') selectCity(item); + else selectDistrict(item); + } + + function onTabClick(tab: PickerLevel) { + if (tab === 'city' && !draft.province) return; + if (tab === 'district' && (!draft.province || !draft.city)) return; + setActiveTab(tab); + } + + function handleConfirm() { + if (!canConfirm) return; + onConfirm(normalizeRegionSelection(draft)); + } + + return ( +
+
e.stopPropagation()} + > +
+
+ {TABS.map((tab) => { + const disabled = + (tab.key === 'city' && !draft.province) || + (tab.key === 'district' && (!draft.province || !draft.city)); + return ( + + ); + })} +
+ +
+ +
+ {listItems.map((item) => ( + + ))} +
+
+
+ ); +} diff --git a/apps/h5-user/src/lib/phone.ts b/apps/h5-user/src/lib/phone.ts new file mode 100644 index 0000000..0edbaf7 --- /dev/null +++ b/apps/h5-user/src/lib/phone.ts @@ -0,0 +1,19 @@ +const MOBILE_PHONE_RE = /^1[3-9]\d{9}$/; + +export function normalizePhoneInput(value: string): string { + return value.replace(/\D/g, '').slice(0, 11); +} + +export function validateMobilePhone(phone: string): { ok: boolean; message?: string } { + const trimmed = phone.trim(); + if (!trimmed) { + return { ok: false, message: '请输入手机号码' }; + } + if (trimmed.length !== 11) { + return { ok: false, message: '手机号码须为 11 位' }; + } + if (!MOBILE_PHONE_RE.test(trimmed)) { + return { ok: false, message: '请输入正确的手机号码' }; + } + return { ok: true }; +} diff --git a/apps/h5-user/src/lib/region-data.ts b/apps/h5-user/src/lib/region-data.ts new file mode 100644 index 0000000..51bdf00 --- /dev/null +++ b/apps/h5-user/src/lib/region-data.ts @@ -0,0 +1,100 @@ +import { regionData } from 'element-china-area-data'; + +export type RegionTree = Record>; + +/** 由国家标准省市区数据构建三级树 */ +function buildRegionTree(): RegionTree { + const tree: RegionTree = {}; + for (const province of regionData) { + const cities: Record = {}; + for (const city of province.children ?? []) { + cities[city.label] = (city.children ?? []).map((district) => district.label); + } + tree[province.label] = cities; + } + return tree; +} + +export const REGION_TREE: RegionTree = buildRegionTree(); + +export const PROVINCES = Object.keys(REGION_TREE); + +/** 三级选择「全市」选项(省/市/区列表首项) */ +export const REGION_ALL = '全市'; + +export function getCities(province: string): string[] { + if (province === REGION_ALL) return []; + return Object.keys(REGION_TREE[province] ?? {}); +} + +export function getDistricts(province: string, city: string): string[] { + if (province === REGION_ALL || city === REGION_ALL) return []; + return REGION_TREE[province]?.[city] ?? []; +} + +/** 省份列表(含全市) */ +export function getProvincesForPicker(): string[] { + return [...PROVINCES]; +} + +/** 城市列表(含全市) */ +export function getCitiesForPicker(province: string): string[] { + if (province === REGION_ALL) return [REGION_ALL]; + return [...getCities(province)]; +} + +/** 区县列表(含全市) */ +export function getDistrictsForPicker(province: string, city: string): string[] { + if (province === REGION_ALL || city === REGION_ALL) return [REGION_ALL]; + return [REGION_ALL, ...getDistricts(province, city)]; +} + +export function formatRegion(province: string, city: string, district: string): string { + if (!province) return ''; + if (province === REGION_ALL) return REGION_ALL; + if (city === REGION_ALL) return `${province} ${REGION_ALL}`; + if (district === REGION_ALL) return `${province} ${city} ${REGION_ALL}`; + if (!city || !district) return ''; + return `${province} ${city} ${district}`; +} + +export type RegionSelection = { + province: string; + city: string; + district: string; +}; + +export const DEFAULT_REGION: RegionSelection = { + province: '河南省', + city: '郑州市', + district: '金水区', +}; + +/** 校验已选地区是否仍存在于数据源中 */ +export function normalizeRegionSelection(selection: RegionSelection): RegionSelection { + if (selection.province === REGION_ALL) { + return { province: REGION_ALL, city: REGION_ALL, district: REGION_ALL }; + } + + const province = PROVINCES.includes(selection.province) + ? selection.province + : DEFAULT_REGION.province; + + if (selection.city === REGION_ALL) { + return { province, city: REGION_ALL, district: REGION_ALL }; + } + + const cities = getCities(province); + const city = cities.includes(selection.city) ? selection.city : (cities[0] ?? DEFAULT_REGION.city); + + if (selection.district === REGION_ALL) { + return { province, city, district: REGION_ALL }; + } + + const districts = getDistricts(province, city); + const district = districts.includes(selection.district) + ? selection.district + : (districts[0] ?? DEFAULT_REGION.district); + + return { province, city, district }; +} diff --git a/apps/h5-user/src/pages/AddressEditPage.tsx b/apps/h5-user/src/pages/AddressEditPage.tsx index 3286d49..f8f4207 100644 --- a/apps/h5-user/src/pages/AddressEditPage.tsx +++ b/apps/h5-user/src/pages/AddressEditPage.tsx @@ -1,8 +1,11 @@ import { useEffect, useState } from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import SubPageHeader from '../components/SubPageHeader'; +import RegionPicker from '../components/RegionPicker'; import { request } from '../lib/api'; import { buildAddressListUrl, readCheckoutContext } from '../lib/navigation'; +import { DEFAULT_REGION, formatRegion } from '../lib/region-data'; +import { normalizePhoneInput, validateMobilePhone } from '../lib/phone'; type AddressForm = { receiverName: string; @@ -14,35 +17,20 @@ type AddressForm = { isDefault: boolean; }; -const REGION_OPTIONS = [ - { province: '河南省', city: '郑州市', district: '金水区', label: '河南省 郑州市 金水区' }, - { province: '河南省', city: '郑州市', district: '二七区', label: '河南省 郑州市 二七区' }, - { province: '河南省', city: '洛阳市', district: '涧西区', label: '河南省 洛阳市 涧西区' }, - { province: '河南省', city: '洛阳市', district: '洛龙区', label: '河南省 洛阳市 洛龙区' }, - { province: '北京市', city: '北京市', district: '东城区', label: '北京市 东城区' }, - { province: '上海市', city: '上海市', district: '黄浦区', label: '上海市 黄浦区' }, - { province: '陕西省', city: '西安市', district: '雁塔区', label: '陕西省 西安市 雁塔区' }, -] as const; - -function regionLabel(form: Pick) { - if (!form.province || !form.city || !form.district) return ''; - return `${form.province} ${form.city} ${form.district}`; -} - export default function AddressEditPage() { const { id } = useParams(); const [params] = useSearchParams(); const isEdit = Boolean(id); const navigate = useNavigate(); const [pickerOpen, setPickerOpen] = useState(false); - const [pickerDraft, setPickerDraft] = useState(null); const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); const [form, setForm] = useState({ receiverName: '', - phone: '13800000001', - province: '河南省', - city: params.get('city') || '郑州市', - district: '金水区', + phone: '', + province: DEFAULT_REGION.province, + city: params.get('city') || DEFAULT_REGION.city, + district: DEFAULT_REGION.district, detail: '', isDefault: true, }); @@ -71,35 +59,26 @@ export default function AddressEditPage() { }); }, [id]); - const regionText = regionLabel(form); + const regionText = formatRegion(form.province, form.city, form.district); - function openPicker() { - const current = - REGION_OPTIONS.find( - (r) => r.province === form.province && r.city === form.city && r.district === form.district, - ) ?? null; - setPickerDraft(current); - setPickerOpen(true); - } - - function confirmRegion() { - if (pickerDraft) { - setForm((f) => ({ - ...f, - province: pickerDraft.province, - city: pickerDraft.city, - district: pickerDraft.district, - })); - } - setPickerOpen(false); + function validateForm(): string | null { + if (!form.receiverName.trim()) return '请输入收货人姓名'; + const phoneCheck = validateMobilePhone(form.phone); + if (!phoneCheck.ok) return phoneCheck.message ?? '请输入正确的手机号码'; + if (!form.province || !form.city || !form.district) return '请选择所在地区'; + if (!form.detail.trim()) return '请输入详细地址'; + return null; } async function save() { - if (!form.receiverName.trim()) return; - if (!form.phone.trim()) return; - if (!regionText) return; - if (!form.detail.trim()) return; + const validationError = validateForm(); + if (validationError) { + setError(validationError); + return; + } + setSaving(true); + setError(''); try { if (isEdit && id) { await request('USER_H5', `/user/addresses/${id}`, { @@ -135,7 +114,10 @@ export default function AddressEditPage() { className="address-edit-input" placeholder="请输入姓名" value={form.receiverName} - onChange={(e) => setForm({ ...form, receiverName: e.target.value })} + onChange={(e) => { + setForm({ ...form, receiverName: e.target.value }); + setError(''); + }} /> person @@ -149,20 +131,23 @@ export default function AddressEditPage() { type="tel" className="address-edit-input" placeholder="请输入手机号" + maxLength={11} + inputMode="numeric" value={form.phone} - onChange={(e) => setForm({ ...form, phone: e.target.value })} + onChange={(e) => { + setForm({ ...form, phone: normalizePhoneInput(e.target.value) }); + setError(''); + }} /> smartphone - @@ -174,7 +159,10 @@ export default function AddressEditPage() { placeholder="街道、门牌号、小区名称等" rows={3} value={form.detail} - onChange={(e) => setForm({ ...form, detail: e.target.value })} + onChange={(e) => { + setForm({ ...form, detail: e.target.value }); + setError(''); + }} /> @@ -201,6 +189,8 @@ export default function AddressEditPage() { + {error &&

{error}

} +
location_on 已通过杜康云安全加密处理 @@ -223,51 +213,21 @@ export default function AddressEditPage() { - {pickerOpen && ( -
setPickerOpen(false)} - > -
e.stopPropagation()} - > -
-

选择地区

- -
-
- {REGION_OPTIONS.map((r) => { - const selected = - pickerDraft?.province === r.province && - pickerDraft?.city === r.city && - pickerDraft?.district === r.district; - return ( - - ); - })} -
- -
-
- )} + setPickerOpen(false)} + onConfirm={(region) => { + setForm((f) => ({ + ...f, + province: region.province, + city: region.city, + district: region.district, + })); + setPickerOpen(false); + setError(''); + }} + />
); } diff --git a/apps/h5-user/src/pages/BenefitPage.tsx b/apps/h5-user/src/pages/BenefitPage.tsx index d08d1d1..f56fff9 100644 --- a/apps/h5-user/src/pages/BenefitPage.tsx +++ b/apps/h5-user/src/pages/BenefitPage.tsx @@ -34,6 +34,15 @@ function usagePercent(coupon: CouponItem) { return Math.min(100, Math.round((Number(coupon.usedAmount) / total) * 100)); } +function buildRedeemUrl(coupon?: CouponItem) { + if (!coupon) return '/redeem'; + const params = new URLSearchParams({ + couponId: coupon.id, + amount: String(coupon.balance), + }); + return `/redeem?${params.toString()}`; +} + export default function BenefitPage() { const navigate = useNavigate(); const listRef = useRef(null); @@ -71,6 +80,10 @@ export default function BenefitPage() { listRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); } + function goRedeem(coupon?: CouponItem) { + navigate(buildRedeemUrl(coupon)); + } + return (
@@ -110,8 +123,8 @@ export default function BenefitPage() {
-
@@ -136,7 +149,21 @@ export default function BenefitPage() { {visible.length > 0 ? (
{visible.map((c) => ( -
+
0 ? ' benefit-card--clickable' : ''}`} + role={tab === 'available' && c.balance > 0 ? 'button' : undefined} + tabIndex={tab === 'available' && c.balance > 0 ? 0 : undefined} + onClick={() => { + if (tab === 'available' && c.balance > 0) goRedeem(c); + }} + onKeyDown={(e) => { + if (tab === 'available' && c.balance > 0 && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + goRedeem(c); + } + }} + >
好客权益 @@ -167,16 +194,25 @@ export default function BenefitPage() {
- + e.stopPropagation()} + > {formatCouponNo(c.couponNo)} - + {tab === 'available' && c.balance > 0 && ( + + )}
diff --git a/apps/h5-user/src/pages/HomePage.tsx b/apps/h5-user/src/pages/HomePage.tsx index bde0f49..8640db5 100644 --- a/apps/h5-user/src/pages/HomePage.tsx +++ b/apps/h5-user/src/pages/HomePage.tsx @@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'; import { request } from '../lib/api'; import ProductCarousel from '../components/ProductCarousel'; import TabMainHeader from '../components/TabMainHeader'; +import AppToast from '../components/AppToast'; import { getProductImages } from '../lib/product-images'; import CouponBadge from '@dukang/shared-ui/CouponBadge'; @@ -19,19 +20,33 @@ type Product = { }; const AROMA_TABS = [ - { key: 'QINGXIANG', label: '清香型', disabled: false }, - { key: 'JIANGXIANG', label: '酱香型', disabled: true }, - { key: 'NONGXIANG', label: '浓香型', disabled: true }, + { key: 'QINGXIANG', label: '清香型', open: true }, + { key: 'JIANGXIANG', label: '酱香型', open: false }, + { key: 'NONGXIANG', label: '浓香型', open: false }, ]; export default function HomePage() { const [tab, setTab] = useState('QINGXIANG'); const [products, setProducts] = useState([]); + const [toast, setToast] = useState(''); useEffect(() => { request('USER_H5', '/catalog/products').then(setProducts); }, []); + function showToast(message: string) { + setToast(message); + window.setTimeout(() => setToast(''), 2200); + } + + function onAromaTabClick(key: string, open: boolean) { + if (!open) { + showToast('暂未开放'); + return; + } + setTab(key); + } + const filtered = products.filter((p) => p.aromaType === tab); const onSale = tab === 'QINGXIANG'; @@ -52,9 +67,8 @@ export default function HomePage() { @@ -66,23 +80,29 @@ export default function HomePage() { {onSale && filtered.map((p, index) => (
- -
-
-

{p.name}

- ¥{p.price} -
-

{p.subtitle}

-
- - - 立即购买 - + + +
+
+

{p.name}

+ ¥{p.price} +
+

{p.subtitle}

+
+ +
+ +
+ + 立即购买 +
))} + + ); } diff --git a/apps/h5-user/src/pages/LoginPage.tsx b/apps/h5-user/src/pages/LoginPage.tsx index 0458551..d7773b7 100644 --- a/apps/h5-user/src/pages/LoginPage.tsx +++ b/apps/h5-user/src/pages/LoginPage.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import AppImage from '@dukang/shared-ui/AppImage'; import { request, saveAuth } from '../lib/api'; +import { normalizePhoneInput, validateMobilePhone } from '../lib/phone'; export default function LoginPage() { const navigate = useNavigate(); @@ -22,6 +23,11 @@ export default function LoginPage() { async function sendCode() { if (!ensureAgreed()) return; + const phoneCheck = validateMobilePhone(phone); + if (!phoneCheck.ok) { + setMsg(phoneCheck.message ?? '请输入正确的手机号码'); + return; + } setMsg(''); await request('USER_H5', '/auth/sms/send', { method: 'POST', @@ -42,6 +48,15 @@ export default function LoginPage() { async function login() { if (!ensureAgreed()) return; + const phoneCheck = validateMobilePhone(phone); + if (!phoneCheck.ok) { + setMsg(phoneCheck.message ?? '请输入正确的手机号码'); + return; + } + if (!code.trim()) { + setMsg('请输入验证码'); + return; + } setLoading(true); setMsg(''); try { @@ -77,17 +92,6 @@ export default function LoginPage() {
- - -
- - 或者 - -
-

手机验证码登录

@@ -96,8 +100,13 @@ export default function LoginPage() { type="tel" className="login-field-input" placeholder="请输入手机号" + maxLength={11} + inputMode="numeric" value={phone} - onChange={(e) => setPhone(e.target.value)} + onChange={(e) => { + setPhone(normalizePhoneInput(e.target.value)); + setMsg(''); + }} />
@@ -128,6 +137,17 @@ export default function LoginPage() { {loading ? '登录中...' : '登录'}
+ +
+ + 或者 + +
+ +