小程序修改
This commit is contained in:
@@ -1,25 +1,129 @@
|
||||
import { useState } from 'react';
|
||||
import { View, Text, Input, Textarea } from '@tarojs/components';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text, Input, Textarea, Switch } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import { toast } from '../../lib/api';
|
||||
import RegionPicker from '../../components/RegionPicker';
|
||||
import { buildAddressListUrl, readCheckoutContext } from '../../lib/checkout-nav';
|
||||
import { DEFAULT_REGION, formatRegion, type RegionSelection } from '../../lib/region-data';
|
||||
import { normalizePhoneInput, validateMobilePhone } from '../../lib/phone';
|
||||
import { getStoredUserPhone } from '../../lib/user-phone';
|
||||
import { request, toast, type UserProfile } from '../../lib/api';
|
||||
|
||||
type AddressForm = {
|
||||
receiverName: string;
|
||||
phone: string;
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
detail: string;
|
||||
isDefault: boolean;
|
||||
};
|
||||
|
||||
export default function AddressEditPage() {
|
||||
const router = useRouter();
|
||||
const isEdit = !!router.params.id;
|
||||
const [name, setName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [region, setRegion] = useState('河南省 郑州市');
|
||||
const [detail, setDetail] = useState('');
|
||||
const id = router.params.id;
|
||||
const isEdit = !!id;
|
||||
const checkoutCtx = readCheckoutContext(router.params);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [form, setForm] = useState<AddressForm>({
|
||||
receiverName: '',
|
||||
phone: '',
|
||||
province: DEFAULT_REGION.province,
|
||||
city: DEFAULT_REGION.city,
|
||||
district: DEFAULT_REGION.district,
|
||||
detail: '',
|
||||
isDefault: true,
|
||||
});
|
||||
|
||||
function save() {
|
||||
if (!name.trim() || !phone.trim() || !detail.trim()) {
|
||||
toast('请完善地址信息');
|
||||
useEffect(() => {
|
||||
if (id) return;
|
||||
request<UserProfile>('/auth/me')
|
||||
.then((me) => {
|
||||
if (!me.phoneVerified) return;
|
||||
const stored = getStoredUserPhone();
|
||||
if (!stored) return;
|
||||
setForm((prev) => (prev.phone ? prev : { ...prev, phone: stored }));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
request<Array<Record<string, unknown>>>('/user/addresses').then((list) => {
|
||||
const found = list.find((a) => String(a.id) === id);
|
||||
if (found) {
|
||||
setForm({
|
||||
receiverName: String(found.receiverName ?? ''),
|
||||
phone: String(found.phone ?? ''),
|
||||
province: String(found.province ?? DEFAULT_REGION.province),
|
||||
city: String(found.city ?? DEFAULT_REGION.city),
|
||||
district: String(found.district ?? DEFAULT_REGION.district),
|
||||
detail: String(found.detail ?? ''),
|
||||
isDefault: found.isDefault === 1 || found.isDefault === true,
|
||||
});
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, [id]);
|
||||
|
||||
const regionText = formatRegion(form.province, form.city, form.district);
|
||||
|
||||
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() {
|
||||
const validationError = validateForm();
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
toast(isEdit ? '地址已更新(UI 壳)' : '地址已新增(UI 壳)', 'success');
|
||||
setTimeout(() => Taro.navigateBack(), 600);
|
||||
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const payload = {
|
||||
receiverName: form.receiverName.trim(),
|
||||
phone: form.phone.trim(),
|
||||
province: form.province,
|
||||
city: form.city,
|
||||
district: form.district,
|
||||
detail: form.detail.trim(),
|
||||
isDefault: form.isDefault,
|
||||
};
|
||||
if (isEdit && id) {
|
||||
await request(`/user/addresses/${id}`, { method: 'PUT', data: payload });
|
||||
toast('地址已更新', 'success');
|
||||
} else {
|
||||
await request('/user/addresses', { method: 'POST', data: payload });
|
||||
toast('地址已新增', 'success');
|
||||
}
|
||||
setTimeout(() => {
|
||||
Taro.redirectTo({ url: buildAddressListUrl(checkoutCtx) }).catch(() => {
|
||||
Taro.navigateBack();
|
||||
});
|
||||
}, 400);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onRegionConfirm(region: RegionSelection) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
province: region.province,
|
||||
city: region.city,
|
||||
district: region.district,
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -31,8 +135,8 @@ export default function AddressEditPage() {
|
||||
<Input
|
||||
className="address-form-input"
|
||||
placeholder="请输入姓名"
|
||||
value={name}
|
||||
onInput={(e) => setName(e.detail.value)}
|
||||
value={form.receiverName}
|
||||
onInput={(e) => setForm((prev) => ({ ...prev, receiverName: e.detail.value }))}
|
||||
/>
|
||||
</View>
|
||||
<View className="address-form-field">
|
||||
@@ -42,8 +146,10 @@ export default function AddressEditPage() {
|
||||
type="number"
|
||||
maxlength={11}
|
||||
placeholder="请输入手机号"
|
||||
value={phone}
|
||||
onInput={(e) => setPhone(e.detail.value)}
|
||||
value={form.phone}
|
||||
onInput={(e) =>
|
||||
setForm((prev) => ({ ...prev, phone: normalizePhoneInput(e.detail.value) }))
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<View className="address-form-field">
|
||||
@@ -51,9 +157,9 @@ export default function AddressEditPage() {
|
||||
<View
|
||||
className="address-form-input"
|
||||
style={{ display: 'flex', alignItems: 'center' }}
|
||||
onClick={() => toast('区域选择器后续接入')}
|
||||
onClick={() => setPickerOpen(true)}
|
||||
>
|
||||
<Text>{region || '请选择省市区'}</Text>
|
||||
<Text>{regionText || '请选择省市区'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="address-form-field">
|
||||
@@ -61,14 +167,31 @@ export default function AddressEditPage() {
|
||||
<Textarea
|
||||
className="address-form-textarea"
|
||||
placeholder="街道门牌号等"
|
||||
value={detail}
|
||||
onInput={(e) => setDetail(e.detail.value)}
|
||||
value={form.detail}
|
||||
onInput={(e) => setForm((prev) => ({ ...prev, detail: e.detail.value }))}
|
||||
/>
|
||||
</View>
|
||||
<View className="address-form-row">
|
||||
<Text>设为默认地址</Text>
|
||||
<Switch
|
||||
checked={form.isDefault}
|
||||
color="#A61D24"
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, isDefault: e.detail.value }))}
|
||||
/>
|
||||
</View>
|
||||
{error ? <Text className="address-form-error">{error}</Text> : null}
|
||||
</View>
|
||||
<View className="address-fab" onClick={save}>
|
||||
<Text>保存</Text>
|
||||
<View className="address-fab" onClick={() => !saving && void save()}>
|
||||
<Text>{saving ? '保存中…' : '保存'}</Text>
|
||||
</View>
|
||||
|
||||
<RegionPicker
|
||||
open={pickerOpen}
|
||||
value={{ province: form.province, city: form.city, district: form.district }}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onConfirm={onRegionConfirm}
|
||||
levels={3}
|
||||
/>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,72 +1,126 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import Taro, { useDidShow, useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import {
|
||||
buildAddressEditUrl,
|
||||
buildOrderConfirmUrl,
|
||||
readCheckoutContext,
|
||||
} from '../../lib/checkout-nav';
|
||||
import { request, toast } from '../../lib/api';
|
||||
|
||||
type Address = {
|
||||
id: string;
|
||||
receiverName: string;
|
||||
phone: string;
|
||||
province?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
detail: string;
|
||||
isDefault?: boolean;
|
||||
isDefault?: number | boolean;
|
||||
};
|
||||
|
||||
function formatAddress(a: Address) {
|
||||
return `${a.province}${a.city}${a.district}${a.detail}`;
|
||||
}
|
||||
|
||||
export default function AddressesPage() {
|
||||
const router = useRouter();
|
||||
const checkoutCtx = readCheckoutContext(router.params);
|
||||
const selectMode = checkoutCtx.select === true;
|
||||
const [list, setList] = useState<Address[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadList = useCallback(() => {
|
||||
setLoading(true);
|
||||
request<Address[]>('/user/addresses')
|
||||
.then((data) => setList(Array.isArray(data) ? data : []))
|
||||
.catch(() => setList([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useDidShow(() => {
|
||||
loadList();
|
||||
});
|
||||
|
||||
function selectAddress(addr: Address) {
|
||||
if (!selectMode) return;
|
||||
Taro.redirectTo({
|
||||
url: buildOrderConfirmUrl({
|
||||
productId: checkoutCtx.productId,
|
||||
qty: checkoutCtx.qty,
|
||||
addressId: addr.id,
|
||||
cross: checkoutCtx.cross,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function removeAddress(id: string) {
|
||||
const res = await Taro.showModal({
|
||||
title: '删除地址',
|
||||
content: '确定删除该收货地址吗?',
|
||||
});
|
||||
if (!res.confirm) return;
|
||||
try {
|
||||
await request(`/user/addresses/${id}`, { method: 'DELETE' });
|
||||
toast('已删除', 'success');
|
||||
loadList();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="address-page" hasFixedFooter>
|
||||
<SubPageHeader title="地址管理" />
|
||||
<SubPageHeader title={selectMode ? '选择收货地址' : '地址管理'} />
|
||||
<View className="sub-page-body" style={{ paddingBottom: 80 }}>
|
||||
{loading ? <View className="u-empty">加载中…</View> : null}
|
||||
{!loading && list.length === 0 ? (
|
||||
<View className="u-empty">暂无收货地址</View>
|
||||
) : null}
|
||||
{list.map((a) => (
|
||||
<View key={a.id} className="address-item">
|
||||
<View
|
||||
key={a.id}
|
||||
className="address-item"
|
||||
onClick={() => selectAddress(a)}
|
||||
>
|
||||
<View className="address-item-head">
|
||||
<Text className="address-item-name">{a.receiverName}</Text>
|
||||
<Text className="address-item-phone">{a.phone}</Text>
|
||||
{a.isDefault ? <Text className="address-default-tag">默认</Text> : null}
|
||||
</View>
|
||||
<Text className="address-item-detail">
|
||||
{[a.province, a.city, a.district, a.detail].filter(Boolean).join(' ')}
|
||||
</Text>
|
||||
<View className="address-item-actions">
|
||||
<Text
|
||||
className="address-action"
|
||||
onClick={() =>
|
||||
Taro.navigateTo({ url: `/pages/address-edit/index?id=${a.id}` })
|
||||
}
|
||||
>
|
||||
编辑
|
||||
</Text>
|
||||
<Text
|
||||
className="address-action"
|
||||
onClick={() => toast('删除功能后续接入')}
|
||||
>
|
||||
删除
|
||||
</Text>
|
||||
{a.isDefault === 1 || a.isDefault === true ? (
|
||||
<Text className="address-default-tag">默认</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Text className="address-item-detail">{formatAddress(a)}</Text>
|
||||
{!selectMode ? (
|
||||
<View className="address-item-actions">
|
||||
<Text
|
||||
className="address-action"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
Taro.navigateTo({ url: buildAddressEditUrl(a.id, checkoutCtx) });
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Text>
|
||||
<Text
|
||||
className="address-action"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void removeAddress(a.id);
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<View
|
||||
className="address-fab"
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/address-edit/index' })}
|
||||
onClick={() => Taro.navigateTo({ url: buildAddressEditUrl(undefined, checkoutCtx) })}
|
||||
>
|
||||
<Text>新增地址</Text>
|
||||
</View>
|
||||
|
||||
@@ -1,46 +1,30 @@
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { CUSTOMER_SERVICE_PHONE } from '@dukang/shared-types';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import { toast } from '../../lib/api';
|
||||
|
||||
const QUICK = ['如何核销权益?', '订单多久发货?', '如何修改地址?', '联系人工客服'];
|
||||
const DIAL_PHONE = CUSTOMER_SERVICE_PHONE.replace(/-/g, '');
|
||||
|
||||
export default function CustomerServicePage() {
|
||||
return (
|
||||
<PageShell variant="sub" className="cs-page">
|
||||
<SubPageHeader title="联系客服" />
|
||||
<View className="sub-page-body inset-page">
|
||||
<View className="cs-bubble cs-bubble--bot">
|
||||
<Text>您好,我是杜康好客小助手。请问有什么可以帮您?</Text>
|
||||
<View className="sub-page-body inset-page cs-body">
|
||||
<Text className="cs-title">客服热线</Text>
|
||||
<Text className="cs-phone">{CUSTOMER_SERVICE_PHONE}</Text>
|
||||
<Text className="cs-hint">工作时间:9:00 - 21:00</Text>
|
||||
<View
|
||||
className="cs-call-btn"
|
||||
onClick={() => {
|
||||
Taro.makePhoneCall({ phoneNumber: DIAL_PHONE }).catch(() =>
|
||||
toast('无法拨打电话'),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Text>拨打客服电话</Text>
|
||||
</View>
|
||||
<View className="cs-bubble cs-bubble--user">
|
||||
<Text>我想了解好客权益怎么用</Text>
|
||||
</View>
|
||||
<View className="cs-bubble cs-bubble--bot">
|
||||
<Text>
|
||||
购酒后获得的好客权益可在签约门店到店核销。进入「好客权益」选择可用券,输入金额生成核销码即可。
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="cs-quick">
|
||||
{QUICK.map((q) => (
|
||||
<Text
|
||||
key={q}
|
||||
className="cs-quick-item"
|
||||
onClick={() => {
|
||||
if (q === '联系人工客服') {
|
||||
Taro.makePhoneCall({ phoneNumber: '4008000000' }).catch(() =>
|
||||
toast('客服热线后续配置'),
|
||||
);
|
||||
} else {
|
||||
toast(q);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{q}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import TabMainHeader from '../../components/TabMainHeader';
|
||||
@@ -7,8 +7,8 @@ import CouponBadge from '../../components/CouponBadge';
|
||||
import ProductCarousel from '../../components/ProductCarousel';
|
||||
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { FALLBACK_CITY_CODE, getProductImages } from '../../lib/product-images';
|
||||
|
||||
import { getProductImages } from '../../lib/product-images';
|
||||
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location';
|
||||
type Product = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -30,10 +30,15 @@ export default function HomePage() {
|
||||
const [tab, setTab] = useState('QINGXIANG');
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const cityCode = FALLBACK_CITY_CODE;
|
||||
const [displayCity, setDisplayCity] = useState('郑州市');
|
||||
const [cityCode, setCityCode] = useState('410100');
|
||||
|
||||
useDidShow(() => {
|
||||
syncTabBarSelected(0);
|
||||
void resolveUserCity().then((resolved) => {
|
||||
setDisplayCity(resolved.displayCity);
|
||||
setCityCode(getCityCodeForCatalog(resolved));
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -61,26 +66,21 @@ export default function HomePage() {
|
||||
|
||||
return (
|
||||
<PageShell variant="tab" className="home-page">
|
||||
<TabMainHeader
|
||||
title="杜康好客"
|
||||
extra={(
|
||||
<View className="tab-main-header__extra-inner">
|
||||
<View className="tab-main-city-pin" />
|
||||
<Text className="tab-main-city-label">郑州市</Text>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
<TabMainHeader title="杜康好客" />
|
||||
|
||||
<View className="home-aroma-nav">
|
||||
{AROMA_TABS.map((t) => (
|
||||
<Text
|
||||
key={t.key}
|
||||
className={`home-aroma-tab${tab === t.key ? ' home-aroma-tab--active' : ''}${!t.open ? ' home-aroma-tab--muted' : ''}`}
|
||||
onClick={() => onAromaTabClick(t.key, t.open)}
|
||||
>
|
||||
{t.label}
|
||||
</Text>
|
||||
))}
|
||||
<View className="home-aroma-tabs">
|
||||
{AROMA_TABS.map((t) => (
|
||||
<Text
|
||||
key={t.key}
|
||||
className={`home-aroma-tab${tab === t.key ? ' home-aroma-tab--active' : ''}${!t.open ? ' home-aroma-tab--muted' : ''}`}
|
||||
onClick={() => onAromaTabClick(t.key, t.open)}
|
||||
>
|
||||
{t.label}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
<Text className="home-aroma-city">{displayCity}</Text>
|
||||
</View>
|
||||
|
||||
<View className="home-product-list">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text, Input, Button } from '@tarojs/components';
|
||||
import { View, Text, Input, Button, Image } from '@tarojs/components';
|
||||
import { useRouter } from '@tarojs/taro';
|
||||
import {
|
||||
SmsScene,
|
||||
@@ -9,8 +9,12 @@ import {
|
||||
} from '@dukang/shared-types';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import WechatLoginButton from '../../components/WechatLoginButton';
|
||||
import { BRAND_LOGO_WIDE_URL } from '@dukang/shared-types';
|
||||
import { finishLoginNavigate } from '../../lib/auth-nav';
|
||||
import { request, saveAuth, toast, type SessionPayload } from '../../lib/api';
|
||||
import { bindWechatForUser } from '../../lib/wechat-auth';
|
||||
import { fetchUserProfile } from '../../lib/pay-wechat';
|
||||
import { saveUserPhone } from '../../lib/user-phone';
|
||||
import { isLoggedIn, request, saveAuth, toast, type SessionPayload } from '../../lib/api';
|
||||
import { loginWithWechat } from '../../lib/wechat-auth';
|
||||
|
||||
function normalizePhone(value: string) {
|
||||
@@ -24,6 +28,10 @@ function isValidPhone(phone: string) {
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const returnTo = router.params.return || '';
|
||||
const needPhone = router.params.needPhone === '1';
|
||||
const needWechat = router.params.needWechat === '1';
|
||||
const initialBindMode = router.params.bindMode === '1';
|
||||
const initialWxSessionKey = router.params.wxSessionKey || null;
|
||||
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
@@ -34,9 +42,10 @@ export default function LoginPage() {
|
||||
const [agreed, setAgreed] = useState(true);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [sentHint, setSentHint] = useState('');
|
||||
const [bindMode, setBindMode] = useState(false);
|
||||
const [wxSessionKey, setWxSessionKey] = useState<string | null>(null);
|
||||
const [bindMode, setBindMode] = useState(initialBindMode);
|
||||
const [wxSessionKey, setWxSessionKey] = useState<string | null>(initialWxSessionKey);
|
||||
const [wxAuthorize, setWxAuthorize] = useState(true);
|
||||
const [completeMode, setCompleteMode] = useState<'phone' | 'wechat' | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
request<ClientRuntimeConfig>('/common/client-config')
|
||||
@@ -44,6 +53,30 @@ export default function LoginPage() {
|
||||
.catch(() => setWxAuthorize(true));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) {
|
||||
setCompleteMode(null);
|
||||
return;
|
||||
}
|
||||
fetchUserProfile()
|
||||
.then((me) => {
|
||||
if (needPhone && !me.phoneVerified) {
|
||||
setCompleteMode('phone');
|
||||
return;
|
||||
}
|
||||
if (needWechat && !me.hasWechat) {
|
||||
setCompleteMode('wechat');
|
||||
return;
|
||||
}
|
||||
if (needPhone || needWechat) {
|
||||
finishLoginNavigate(returnTo);
|
||||
return;
|
||||
}
|
||||
setCompleteMode(null);
|
||||
})
|
||||
.catch(() => setCompleteMode(null));
|
||||
}, [needPhone, needWechat, returnTo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cooldown <= 0) return;
|
||||
const timer = setTimeout(() => setCooldown((c) => Math.max(0, c - 1)), 1000);
|
||||
@@ -58,8 +91,9 @@ export default function LoginPage() {
|
||||
return true;
|
||||
}
|
||||
|
||||
function applySessionAndLeave(data: SessionPayload | WechatLoginResult) {
|
||||
function applySessionAndLeave(data: SessionPayload | WechatLoginResult, phone?: string) {
|
||||
if (!data.accessToken) return;
|
||||
if (phone) saveUserPhone(phone);
|
||||
saveAuth({
|
||||
accessToken: data.accessToken,
|
||||
refreshToken: data.refreshToken,
|
||||
@@ -95,12 +129,11 @@ export default function LoginPage() {
|
||||
setSentHint('');
|
||||
setSending(true);
|
||||
try {
|
||||
const scene =
|
||||
bindMode || completeMode === 'phone' ? SmsScene.BIND_PHONE : SmsScene.USER_LOGIN;
|
||||
await request('/auth/sms/send', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
phone: normalized,
|
||||
scene: bindMode ? SmsScene.BIND_PHONE : SmsScene.USER_LOGIN,
|
||||
},
|
||||
data: { phone: normalized, scene },
|
||||
});
|
||||
setCooldown(60);
|
||||
setSentHint('验证码已发送');
|
||||
@@ -132,13 +165,24 @@ export default function LoginPage() {
|
||||
data: { wxSessionKey, phone: normalized, code: code.trim() },
|
||||
});
|
||||
handleWechatLoginResult(data);
|
||||
saveUserPhone(normalized);
|
||||
return;
|
||||
}
|
||||
if (completeMode === 'phone' && isLoggedIn()) {
|
||||
await request('/auth/phone/bind', {
|
||||
method: 'POST',
|
||||
data: { phone: normalized, code: code.trim() },
|
||||
});
|
||||
saveUserPhone(normalized);
|
||||
toast('手机号验证成功', 'success');
|
||||
finishLoginNavigate(returnTo);
|
||||
return;
|
||||
}
|
||||
const data = await request<SessionPayload>('/auth/login/sms', {
|
||||
method: 'POST',
|
||||
data: { phone: normalized, code: code.trim() },
|
||||
});
|
||||
applySessionAndLeave(data);
|
||||
applySessionAndLeave(data, normalized);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '登录失败');
|
||||
} finally {
|
||||
@@ -152,6 +196,22 @@ export default function LoginPage() {
|
||||
setSentHint('');
|
||||
setWxLoading(true);
|
||||
try {
|
||||
if (completeMode === 'wechat' && isLoggedIn()) {
|
||||
const result = await bindWechatForUser();
|
||||
if (!result.ok && result.needBindPhone) {
|
||||
setBindMode(true);
|
||||
setWxSessionKey(result.wxSessionKey);
|
||||
setCompleteMode('phone');
|
||||
setMsg('请绑定手机号完成认证');
|
||||
return;
|
||||
}
|
||||
if (result.ok) {
|
||||
toast('微信授权成功', 'success');
|
||||
finishLoginNavigate(returnTo);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const result = await loginWithWechat();
|
||||
handleWechatLoginResult(result);
|
||||
} catch (e) {
|
||||
@@ -168,89 +228,120 @@ export default function LoginPage() {
|
||||
const displayMsg = msg || sentHint;
|
||||
const codeDisabled = cooldown > 0 || sending;
|
||||
const showWechatLogin =
|
||||
!bindMode && (process.env.TARO_ENV === 'weapp' || wxAuthorize);
|
||||
(completeMode === 'wechat' || (!bindMode && !completeMode)) &&
|
||||
(process.env.TARO_ENV === 'weapp' || wxAuthorize);
|
||||
const showSmsForm = completeMode !== 'wechat';
|
||||
const cardTitle =
|
||||
completeMode === 'phone'
|
||||
? '验证手机号'
|
||||
: bindMode
|
||||
? '绑定手机号'
|
||||
: completeMode === 'wechat'
|
||||
? '微信授权'
|
||||
: '手机验证码登录';
|
||||
|
||||
return (
|
||||
<PageShell variant="plain" className="login-page">
|
||||
<View className="login-header">
|
||||
<View className="login-logo-wrap">
|
||||
<View className="login-logo">
|
||||
<Text>康</Text>
|
||||
<Image className="login-logo-img" src={BRAND_LOGO_WIDE_URL} mode="aspectFit" />
|
||||
</View>
|
||||
<Text className="login-logo-badge">官方</Text>
|
||||
</View>
|
||||
<View className="login-welcome">
|
||||
<Text className="login-welcome-title">欢迎来到杜康好客</Text>
|
||||
<Text className="login-welcome-sub">买美酒,享好礼</Text>
|
||||
<Text className="login-welcome-title">
|
||||
{completeMode === 'phone'
|
||||
? '完成手机验证'
|
||||
: completeMode === 'wechat'
|
||||
? '完成微信授权'
|
||||
: '欢迎来到杜康好客'}
|
||||
</Text>
|
||||
<Text className="login-welcome-sub">
|
||||
{completeMode ? '完成后将返回继续支付' : '买美酒,享好礼'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="login-main">
|
||||
<View className="login-card">
|
||||
<Text className="login-card-title">
|
||||
{bindMode ? '绑定手机号' : '手机验证码登录'}
|
||||
</Text>
|
||||
|
||||
<View className="login-field">
|
||||
<Text className="login-field-prefix">+86</Text>
|
||||
<Input
|
||||
className="login-field-input"
|
||||
type="number"
|
||||
maxlength={11}
|
||||
placeholder="请输入手机号"
|
||||
value={phone}
|
||||
onInput={(e) => {
|
||||
setPhone(normalizePhone(e.detail.value));
|
||||
setMsg('');
|
||||
setSentHint('');
|
||||
}}
|
||||
/>
|
||||
{completeMode === 'wechat' ? (
|
||||
<View className="login-card">
|
||||
<Text className="login-card-title">微信一键授权</Text>
|
||||
<Text className="login-msg login-msg--hint" style={{ marginBottom: 16 }}>
|
||||
使用微信支付前需授权微信账号
|
||||
</Text>
|
||||
{showWechatLogin ? (
|
||||
<WechatLoginButton loading={wxLoading} onClick={() => void wechatLogin()} />
|
||||
) : null}
|
||||
</View>
|
||||
) : (
|
||||
<View className="login-card">
|
||||
<Text className="login-card-title">{cardTitle}</Text>
|
||||
|
||||
<View className="login-field">
|
||||
<Input
|
||||
className="login-field-input"
|
||||
type="number"
|
||||
maxlength={6}
|
||||
placeholder="请输入验证码"
|
||||
value={code}
|
||||
onInput={(e) => setCode(e.detail.value.replace(/\D/g, '').slice(0, 6))}
|
||||
/>
|
||||
<Text
|
||||
className={`login-get-code${codeDisabled ? ' login-get-code--disabled' : ''}`}
|
||||
onClick={() => void onSendCode()}
|
||||
<View className="login-field">
|
||||
<Text className="login-field-prefix">+86</Text>
|
||||
<Input
|
||||
className="login-field-input"
|
||||
type="number"
|
||||
maxlength={11}
|
||||
placeholder="请输入手机号"
|
||||
value={phone}
|
||||
onInput={(e) => {
|
||||
setPhone(normalizePhone(e.detail.value));
|
||||
setMsg('');
|
||||
setSentHint('');
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="login-field">
|
||||
<Input
|
||||
className="login-field-input"
|
||||
type="number"
|
||||
maxlength={6}
|
||||
placeholder="请输入验证码"
|
||||
value={code}
|
||||
onInput={(e) => setCode(e.detail.value.replace(/\D/g, '').slice(0, 6))}
|
||||
/>
|
||||
<Text
|
||||
className={`login-get-code${codeDisabled ? ' login-get-code--disabled' : ''}`}
|
||||
onClick={() => void onSendCode()}
|
||||
>
|
||||
{sending ? '发送中...' : cooldown > 0 ? `${cooldown}s 后重新获取` : '获取验证码'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{displayMsg ? (
|
||||
<Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}>
|
||||
{displayMsg}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
className="login-sms-btn"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={() => void login()}
|
||||
>
|
||||
{sending ? '发送中...' : cooldown > 0 ? `${cooldown}s 后重新获取` : '获取验证码'}
|
||||
</Text>
|
||||
{loading
|
||||
? '处理中...'
|
||||
: completeMode === 'phone'
|
||||
? '完成验证'
|
||||
: bindMode
|
||||
? '绑定并登录'
|
||||
: '登录'}
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{displayMsg ? (
|
||||
<Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}>
|
||||
{displayMsg}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
className="login-sms-btn"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={() => void login()}
|
||||
>
|
||||
{loading ? '登录中...' : bindMode ? '绑定并登录' : '登录'}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{showWechatLogin ? (
|
||||
{showWechatLogin && completeMode !== 'wechat' ? (
|
||||
<>
|
||||
<View className="login-divider">
|
||||
<View className="login-divider-line" />
|
||||
<Text className="login-divider-text">或者</Text>
|
||||
<View className="login-divider-line" />
|
||||
</View>
|
||||
<WechatLoginButton
|
||||
loading={wxLoading}
|
||||
onClick={() => void wechatLogin()}
|
||||
/>
|
||||
<WechatLoginButton loading={wxLoading} onClick={() => void wechatLogin()} />
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import { isWxAuthorizeEnabled, type ClientRuntimeConfig } from '@dukang/shared-types';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import TabMainHeader from '../../components/TabMainHeader';
|
||||
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
||||
import { BRAND_LOGO_MARK_URL } from '@dukang/shared-types';
|
||||
import { goLogin } from '../../lib/auth-nav';
|
||||
import { bindWechatForUser } from '../../lib/wechat-auth';
|
||||
import { isLoggedIn, logout, request, toast, type UserProfile } from '../../lib/api';
|
||||
|
||||
const ORDER_SHORTCUTS = [
|
||||
@@ -29,12 +32,17 @@ export default function MinePage() {
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [benefitBalance, setBenefitBalance] = useState(0);
|
||||
const [orderCounts, setOrderCounts] = useState<Record<string, number>>({});
|
||||
const [wxAuthorize, setWxAuthorize] = useState(true);
|
||||
const [bindingWx, setBindingWx] = useState(false);
|
||||
|
||||
useDidShow(() => {
|
||||
syncTabBarSelected(3);
|
||||
if (isLoggedIn()) {
|
||||
loadProfile();
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function loadProfile() {
|
||||
if (!loggedIn) return;
|
||||
Promise.all([
|
||||
request<UserProfile>('/auth/me'),
|
||||
@@ -57,8 +65,42 @@ export default function MinePage() {
|
||||
setOrderCounts(counts);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
request<ClientRuntimeConfig>('/common/client-config')
|
||||
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
|
||||
.catch(() => setWxAuthorize(true));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadProfile();
|
||||
}, [loggedIn]);
|
||||
|
||||
async function handleAvatarTap() {
|
||||
if (!loggedIn) {
|
||||
goLogin('/pages/mine/index');
|
||||
return;
|
||||
}
|
||||
if (profile?.hasWechat || !wxAuthorize || process.env.TARO_ENV !== 'weapp') return;
|
||||
setBindingWx(true);
|
||||
try {
|
||||
const result = await bindWechatForUser();
|
||||
if (!result.ok && result.needBindPhone) {
|
||||
goLogin('/pages/mine/index', { bindMode: '1', wxSessionKey: result.wxSessionKey });
|
||||
return;
|
||||
}
|
||||
if (result.ok) {
|
||||
loadProfile();
|
||||
toast('微信授权成功', 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '微信授权失败');
|
||||
} finally {
|
||||
setBindingWx(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleService(item: (typeof SERVICES)[number]) {
|
||||
if ('url' in item && item.url) {
|
||||
Taro.navigateTo({ url: item.url });
|
||||
@@ -73,6 +115,13 @@ export default function MinePage() {
|
||||
}
|
||||
}
|
||||
|
||||
function renderAvatarContent(profile: UserProfile | null) {
|
||||
if (profile?.avatarUrl) {
|
||||
return <Image className="mine-avatar-img" src={profile.avatarUrl} mode="aspectFill" />;
|
||||
}
|
||||
return <Image className="mine-avatar-img" src={BRAND_LOGO_MARK_URL} mode="aspectFit" />;
|
||||
}
|
||||
|
||||
if (!loggedIn) {
|
||||
return (
|
||||
<PageShell variant="tab" className="mine-page">
|
||||
@@ -81,7 +130,7 @@ export default function MinePage() {
|
||||
<View className="mine-header-texture" />
|
||||
<View className="mine-profile">
|
||||
<View className="mine-avatar">
|
||||
<Text>客</Text>
|
||||
<Image className="mine-avatar-img" src={BRAND_LOGO_MARK_URL} mode="aspectFit" />
|
||||
</View>
|
||||
<View>
|
||||
<Text className="mine-profile-name">未登录</Text>
|
||||
@@ -104,7 +153,8 @@ export default function MinePage() {
|
||||
}
|
||||
|
||||
const nickname = profile?.nickname || '用户';
|
||||
const avatarUrl = profile?.avatarUrl;
|
||||
const hasWechat = !!profile?.hasWechat;
|
||||
const memberLabel = hasWechat ? '微信会员' : '未授权微信';
|
||||
|
||||
return (
|
||||
<PageShell variant="tab" className="mine-page">
|
||||
@@ -112,16 +162,22 @@ export default function MinePage() {
|
||||
<View className="mine-header">
|
||||
<View className="mine-header-texture" />
|
||||
<View className="mine-profile">
|
||||
<View className="mine-avatar">
|
||||
{avatarUrl ? (
|
||||
<Image className="mine-avatar-img" src={avatarUrl} mode="aspectFill" />
|
||||
) : (
|
||||
<Text>{nickname.slice(0, 1)}</Text>
|
||||
)}
|
||||
<View className="mine-avatar-wrap" onClick={() => void handleAvatarTap()}>
|
||||
<View className="mine-avatar">
|
||||
{renderAvatarContent(profile)}
|
||||
</View>
|
||||
{!hasWechat && wxAuthorize && process.env.TARO_ENV === 'weapp' ? (
|
||||
<Text className="mine-avatar-badge">{bindingWx ? '授权中' : '授权'}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View>
|
||||
<Text className="mine-profile-name">{nickname}</Text>
|
||||
<Text className="mine-member-tag">{profile?.phone || '好客会员'}</Text>
|
||||
<Text className={`mine-member-tag${hasWechat ? ' mine-member-tag--wechat' : ''}`}>
|
||||
{memberLabel}
|
||||
</Text>
|
||||
{!hasWechat && wxAuthorize && process.env.TARO_ENV === 'weapp' ? (
|
||||
<Text className="mine-wechat-hint">点击头像完成微信授权</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -209,7 +265,7 @@ export default function MinePage() {
|
||||
</View>
|
||||
|
||||
<View className="mine-footer">
|
||||
<Text className="mine-version">杜康好客 mini-user v0.1.0</Text>
|
||||
<Text className="mine-version">杜康好客</Text>
|
||||
<Text className="mine-logout" onClick={() => logout()}>
|
||||
退出登录
|
||||
</Text>
|
||||
|
||||
@@ -3,126 +3,289 @@ import { View, Text, Image } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import { buildAddressListUrl, buildPayUrl, readCheckoutContext } from '../../lib/checkout-nav';
|
||||
import { maskPhone } from '../../lib/phone';
|
||||
import { ensurePayReady } from '../../lib/pay-ready';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { getProductMainImage } from '../../lib/product-images';
|
||||
|
||||
type Product = {
|
||||
type Address = {
|
||||
id: string;
|
||||
receiverName: string;
|
||||
phone: string;
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
detail: string;
|
||||
isDefault?: number | boolean;
|
||||
};
|
||||
|
||||
type PreviewProduct = {
|
||||
id: string;
|
||||
name: string;
|
||||
spec?: string;
|
||||
subtitle?: string;
|
||||
price: number;
|
||||
mainImageUrl?: string | null;
|
||||
carouselUrls?: string[] | null;
|
||||
benefitDisplay?: number;
|
||||
};
|
||||
|
||||
type OrderPreview = {
|
||||
product: PreviewProduct;
|
||||
quantity: number;
|
||||
deliveryType: 'LOCAL' | 'CROSS_CITY';
|
||||
productAmount: number;
|
||||
freightPayType: 'COD' | null;
|
||||
payAmount: number;
|
||||
benefitAmount: number;
|
||||
city?: { localMinQty: number; crossMinQty: number };
|
||||
};
|
||||
|
||||
function formatAddress(a: Address) {
|
||||
return `${a.province}${a.city}${a.district}${a.detail}`;
|
||||
}
|
||||
|
||||
export default function OrderConfirmPage() {
|
||||
const router = useRouter();
|
||||
const productId = router.params.productId ?? '';
|
||||
const initialQty = Math.max(2, Number(router.params.qty || 2));
|
||||
const [product, setProduct] = useState<Product | null>(null);
|
||||
const [qty, setQty] = useState(initialQty);
|
||||
const checkoutCtx = readCheckoutContext(router.params);
|
||||
const productId = checkoutCtx.productId ?? '';
|
||||
const forceCross = checkoutCtx.cross === true;
|
||||
const [quantity, setQuantity] = useState(Math.max(2, Number(checkoutCtx.qty || 2)));
|
||||
const [addresses, setAddresses] = useState<Address[]>([]);
|
||||
const [addressId, setAddressId] = useState(checkoutCtx.addressId || '');
|
||||
const [preview, setPreview] = useState<OrderPreview | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
request<Address[]>('/user/addresses')
|
||||
.then((list) => {
|
||||
setAddresses(list);
|
||||
const fromUrl = checkoutCtx.addressId;
|
||||
if (fromUrl && list.some((a) => String(a.id) === fromUrl)) {
|
||||
setAddressId(fromUrl);
|
||||
return;
|
||||
}
|
||||
const def = list.find((a) => a.isDefault === 1 || a.isDefault === true) || list[0];
|
||||
if (def) setAddressId(String(def.id));
|
||||
})
|
||||
.catch(() => setAddresses([]));
|
||||
}, [checkoutCtx.addressId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!productId) return;
|
||||
request<Product>(`/catalog/products/${productId}`)
|
||||
.then(setProduct)
|
||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
||||
}, [productId]);
|
||||
let cancelled = false;
|
||||
setPreviewLoading(true);
|
||||
const body: { productId: string; quantity: number; addressId?: string } = {
|
||||
productId,
|
||||
quantity,
|
||||
};
|
||||
if (addressId) body.addressId = addressId;
|
||||
|
||||
const total = useMemo(() => {
|
||||
if (!product) return 0;
|
||||
return Number(product.price) * qty;
|
||||
}, [product, qty]);
|
||||
request<OrderPreview>('/trade/orders/preview', { method: 'POST', data: body })
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setPreview(data);
|
||||
setMsg('');
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) {
|
||||
setPreview(null);
|
||||
setMsg(e instanceof Error ? e.message : '加载失败');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setPreviewLoading(false);
|
||||
});
|
||||
|
||||
const benefit = useMemo(() => {
|
||||
if (!product) return 0;
|
||||
return Number(product.benefitDisplay ?? product.price) * qty;
|
||||
}, [product, qty]);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [productId, quantity, addressId]);
|
||||
|
||||
function changeQty(delta: number) {
|
||||
setQty((q) => Math.max(2, q + delta));
|
||||
const selectedAddress = useMemo(
|
||||
() => addresses.find((a) => String(a.id) === addressId),
|
||||
[addresses, addressId],
|
||||
);
|
||||
|
||||
const isCross = forceCross || preview?.deliveryType === 'CROSS_CITY';
|
||||
const minQty = isCross ? (preview?.city?.crossMinQty ?? 6) : (preview?.city?.localMinQty ?? 2);
|
||||
|
||||
function updateQuantity(next: number) {
|
||||
if (next < minQty) {
|
||||
setMsg(
|
||||
!isCross
|
||||
? `同城配送至少购买 ${minQty} 瓶`
|
||||
: `跨城配送至少购买 ${minQty} 瓶(1箱)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setMsg('');
|
||||
setQuantity(next);
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (!productId) return;
|
||||
Taro.navigateTo({
|
||||
url: `/pages/pay/index?productId=${productId}&qty=${qty}&amount=${total.toFixed(2)}`,
|
||||
async function doSubmit() {
|
||||
const order = await request<{ id: string }>('/trade/orders', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
productId,
|
||||
quantity,
|
||||
addressId,
|
||||
},
|
||||
});
|
||||
Taro.redirectTo({
|
||||
url: buildPayUrl({
|
||||
orderId: order.id,
|
||||
productId,
|
||||
qty: String(quantity),
|
||||
addressId,
|
||||
cross: forceCross,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!addressId) {
|
||||
setMsg('请选择收货地址');
|
||||
return;
|
||||
}
|
||||
|
||||
const returnPath = `/pages/order-confirm/index?productId=${productId}&qty=${quantity}&addressId=${addressId}${forceCross ? '&cross=1' : ''}`;
|
||||
const ready = await ensurePayReady(returnPath);
|
||||
if (!ready) return;
|
||||
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
await doSubmit();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '下单失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const productImage = preview?.product ? getProductMainImage(preview.product) : '';
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="order-confirm-page" hasFixedFooter>
|
||||
<SubPageHeader title="确认订单" />
|
||||
<View className="sub-page-body">
|
||||
<View
|
||||
className="order-card"
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/addresses/index' })}
|
||||
onClick={() =>
|
||||
Taro.navigateTo({
|
||||
url: buildAddressListUrl({
|
||||
productId,
|
||||
qty: String(quantity),
|
||||
addressId,
|
||||
cross: forceCross,
|
||||
}),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Text className="order-card-title">收货地址</Text>
|
||||
<Text className="u-muted">点击选择收货地址(同城起购 2 瓶)</Text>
|
||||
{selectedAddress ? (
|
||||
<View>
|
||||
<View style={{ display: 'flex', gap: '8px', marginBottom: 4 }}>
|
||||
<Text className="order-card-title" style={{ fontSize: 15 }}>{selectedAddress.receiverName}</Text>
|
||||
<Text className="u-muted">{maskPhone(selectedAddress.phone)}</Text>
|
||||
</View>
|
||||
<Text className="u-muted">{formatAddress(selectedAddress)}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text className="u-muted">点击选择收货地址</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">商品信息</Text>
|
||||
{product ? (
|
||||
<View>
|
||||
{isCross ? (
|
||||
<View className="order-card">
|
||||
<Text className="u-muted">
|
||||
该地址超出同城配送范围,将由总部物流发货,运费到付。
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{preview ? (
|
||||
<>
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">商品信息</Text>
|
||||
<View className="order-product-row">
|
||||
<View className="order-product-thumb">
|
||||
{getProductMainImage(product) ? (
|
||||
{productImage ? (
|
||||
<Image
|
||||
className="order-product-thumb-img"
|
||||
src={getProductMainImage(product)}
|
||||
src={productImage}
|
||||
mode="aspectFill"
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text className="order-product-name">{product.name}</Text>
|
||||
<Text className="order-product-price">¥{Number(product.price).toFixed(2)}</Text>
|
||||
<Text className="order-product-name">{preview.product.name}</Text>
|
||||
<Text className="order-product-price">¥{Number(preview.product.price).toFixed(2)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="order-qty-row">
|
||||
<Text>购买数量</Text>
|
||||
<View className="order-qty-controls">
|
||||
<View className="order-qty-btn" onClick={() => changeQty(-1)}>
|
||||
<View
|
||||
className="order-qty-btn"
|
||||
onClick={() => updateQuantity(quantity - 1)}
|
||||
>
|
||||
<Text>−</Text>
|
||||
</View>
|
||||
<Text className="order-qty-value">{qty}</Text>
|
||||
<View className="order-qty-btn" onClick={() => changeQty(1)}>
|
||||
<Text className="order-qty-value">{quantity}</Text>
|
||||
<View
|
||||
className="order-qty-btn"
|
||||
onClick={() => updateQuantity(quantity + 1)}
|
||||
>
|
||||
<Text>+</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View className="u-empty">加载中…</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">费用明细</Text>
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">商品金额</Text>
|
||||
<Text className="order-row-value">¥{total.toFixed(2)}</Text>
|
||||
</View>
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">好客权益</Text>
|
||||
<Text className="order-row-value--price">¥{benefit.toFixed(2)}</Text>
|
||||
</View>
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">运费</Text>
|
||||
<Text className="order-row-value">到付</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">费用明细</Text>
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">商品金额</Text>
|
||||
<Text className="order-row-value">¥{preview.productAmount.toFixed(2)}</Text>
|
||||
</View>
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">好客权益</Text>
|
||||
<Text className="order-row-value--price">¥{preview.benefitAmount.toFixed(2)}</Text>
|
||||
</View>
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">运费</Text>
|
||||
<Text className="order-row-value">{isCross ? '到付' : '免运费'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{previewLoading && !preview && productId ? (
|
||||
<View className="u-empty">加载订单信息…</View>
|
||||
) : null}
|
||||
{!previewLoading && !preview && productId ? (
|
||||
<View className="u-empty">无法加载商品信息</View>
|
||||
) : null}
|
||||
{msg ? <Text className="u-muted" style={{ display: 'block', marginTop: 8 }}>{msg}</Text> : null}
|
||||
</View>
|
||||
|
||||
<View className="order-confirm-bar">
|
||||
<View className="order-confirm-total">
|
||||
<Text className="order-confirm-total-label">应付合计</Text>
|
||||
<Text className="order-confirm-total-value">¥{total.toFixed(2)}</Text>
|
||||
<Text className="order-confirm-total-value">
|
||||
¥{preview ? preview.payAmount.toFixed(2) : '—'}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="order-confirm-submit" onClick={submit}>
|
||||
<Text>提交订单</Text>
|
||||
<View
|
||||
className="order-confirm-submit"
|
||||
onClick={() => !loading && void submit()}
|
||||
>
|
||||
<Text>{loading ? '提交中…' : !addressId ? '请选择地址' : '提交订单'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</PageShell>
|
||||
|
||||
@@ -6,11 +6,30 @@ import SubPageHeader from '../../components/SubPageHeader';
|
||||
import { request, toast } from '../../lib/api';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'all', label: '全部订单' },
|
||||
{ key: 'pending_pay', label: '待付款' },
|
||||
{ key: 'paid', label: '已付款' },
|
||||
{ key: 'completed', label: '已完成' },
|
||||
] as const;
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
PENDING_PAY: '待付款',
|
||||
PENDING_SHIP: '已付款',
|
||||
OUT_WAREHOUSE: '已付款',
|
||||
SHIPPED: '已付款',
|
||||
DELIVERED: '已付款',
|
||||
COMPLETED: '已完成',
|
||||
CANCELLED: '已取消',
|
||||
};
|
||||
|
||||
function orderStatusLabel(tab: string, status?: string): string {
|
||||
if (tab !== 'all') {
|
||||
return TABS.find((t) => t.key === tab)?.label || status || '';
|
||||
}
|
||||
if (!status) return '';
|
||||
return STATUS_LABELS[status] || status;
|
||||
}
|
||||
|
||||
type OrderRow = {
|
||||
id: string;
|
||||
orderNo?: string;
|
||||
@@ -23,19 +42,19 @@ type OrderRow = {
|
||||
|
||||
export default function OrdersPage() {
|
||||
const router = useRouter();
|
||||
const initialTab = (router.params.tab as string) || 'pending_pay';
|
||||
const initialTab = (router.params.tab as string) || 'all';
|
||||
const [tab, setTab] = useState(initialTab);
|
||||
const [orders, setOrders] = useState<OrderRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
request<{ items?: OrderRow[]; total?: number } | OrderRow[]>(
|
||||
request<{ list?: OrderRow[]; items?: OrderRow[]; total?: number } | OrderRow[]>(
|
||||
`/trade/orders?tab=${encodeURIComponent(tab)}&pageSize=20`,
|
||||
)
|
||||
.then((data) => {
|
||||
if (Array.isArray(data)) setOrders(data);
|
||||
else setOrders(Array.isArray(data?.items) ? data.items : []);
|
||||
else setOrders(Array.isArray(data?.list) ? data.list : Array.isArray(data?.items) ? data.items : []);
|
||||
})
|
||||
.catch((e) => {
|
||||
toast(e instanceof Error ? e.message : '加载失败');
|
||||
@@ -71,7 +90,7 @@ export default function OrdersPage() {
|
||||
<View className="order-list-head">
|
||||
<Text className="order-list-no">{o.orderNo || o.id}</Text>
|
||||
<Text className="order-list-status">
|
||||
{TABS.find((t) => t.key === tab)?.label || o.status || ''}
|
||||
{orderStatusLabel(tab, o.status)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="order-list-body">
|
||||
|
||||
@@ -1,18 +1,110 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import { toast } from '../../lib/api';
|
||||
import { ensurePayReady } from '../../lib/pay-ready';
|
||||
import {
|
||||
fetchClientConfig,
|
||||
fetchUserProfile,
|
||||
isWechatAuthRequiredError,
|
||||
needsWechatAuthForPay,
|
||||
payOrder,
|
||||
} from '../../lib/pay-wechat';
|
||||
import { request, toast } from '../../lib/api';
|
||||
|
||||
export default function PayPage() {
|
||||
const router = useRouter();
|
||||
const amount = router.params.amount ?? '0.00';
|
||||
const orderId = router.params.orderId ?? '';
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [mockMode, setMockMode] = useState(true);
|
||||
const [needsWechatAuth, setNeedsWechatAuth] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [orderNo, setOrderNo] = useState('');
|
||||
const [payAmount, setPayAmount] = useState('—');
|
||||
|
||||
function mockPay() {
|
||||
toast('支付成功(Mock)', 'success');
|
||||
setTimeout(() => {
|
||||
const returnPath = orderId
|
||||
? `/pages/pay/index?orderId=${orderId}`
|
||||
: '/pages/pay/index';
|
||||
|
||||
useEffect(() => {
|
||||
if (!orderId) return;
|
||||
void ensurePayReady(returnPath);
|
||||
}, [orderId, returnPath]);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]);
|
||||
setMockMode(config.mockPay);
|
||||
setNeedsWechatAuth(needsWechatAuthForPay(config, profile));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!orderId) {
|
||||
setOrderNo('');
|
||||
setPayAmount('—');
|
||||
return;
|
||||
}
|
||||
request<{ orderNo?: string; payAmount?: number | string; totalAmount?: number | string }>(
|
||||
`/trade/orders/${orderId}`,
|
||||
)
|
||||
.then((order) => {
|
||||
setOrderNo(order.orderNo || '');
|
||||
const amount = Number(order.payAmount ?? order.totalAmount ?? 0);
|
||||
if (Number.isFinite(amount) && amount > 0) {
|
||||
setPayAmount(amount.toFixed(2));
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
setOrderNo('');
|
||||
toast(e instanceof Error ? e.message : '加载订单失败');
|
||||
});
|
||||
}, [orderId]);
|
||||
|
||||
async function pay() {
|
||||
if (!orderId) {
|
||||
toast('订单不存在');
|
||||
return;
|
||||
}
|
||||
if (needsWechatAuth) {
|
||||
setMsg('请先完成微信授权后再支付');
|
||||
const ready = await ensurePayReady(returnPath);
|
||||
if (!ready) return;
|
||||
return;
|
||||
}
|
||||
|
||||
const ready = await ensurePayReady(returnPath);
|
||||
if (!ready) return;
|
||||
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const status = await payOrder(orderId);
|
||||
if (status === 'pending') {
|
||||
toast('支付结果确认中,请稍后在订单列表查看');
|
||||
} else {
|
||||
toast('支付成功', 'success');
|
||||
}
|
||||
Taro.redirectTo({ url: '/pages/orders/index?tab=paid' });
|
||||
}, 800);
|
||||
} catch (e) {
|
||||
if (isWechatAuthRequiredError(e)) {
|
||||
setNeedsWechatAuth(true);
|
||||
setMsg('微信支付需要先完成微信授权');
|
||||
await ensurePayReady(returnPath);
|
||||
return;
|
||||
}
|
||||
const message = e instanceof Error ? e.message : '支付失败';
|
||||
setMsg(message);
|
||||
toast(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -23,23 +115,36 @@ export default function PayPage() {
|
||||
<View className="pay-status-icon">
|
||||
<Text>¥</Text>
|
||||
</View>
|
||||
<Text className="pay-status-title">待支付</Text>
|
||||
<Text className="pay-status-amount">¥{amount}</Text>
|
||||
<Text className="pay-status-title">
|
||||
{needsWechatAuth ? '需完成微信授权' : '待支付'}
|
||||
</Text>
|
||||
<Text className="pay-status-amount">¥{payAmount}</Text>
|
||||
</View>
|
||||
<View className="order-card">
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">订单号</Text>
|
||||
<Text className="order-row-value">{orderNo || '—'}</Text>
|
||||
</View>
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">支付方式</Text>
|
||||
<Text className="order-row-value">微信支付</Text>
|
||||
</View>
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">说明</Text>
|
||||
<Text className="order-row-value">小程序支付后续接入</Text>
|
||||
<Text className="order-row-value">
|
||||
{mockMode ? 'Mock 模式由服务端直接标记已付款' : '将调起微信收银台'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{msg ? <Text className="u-muted" style={{ display: 'block', marginTop: 12 }}>{msg}</Text> : null}
|
||||
</View>
|
||||
<View className="pay-bar">
|
||||
<View className="order-confirm-submit" style={{ flex: 1 }} onClick={mockPay}>
|
||||
<Text>立即支付</Text>
|
||||
<View
|
||||
className="order-confirm-submit"
|
||||
style={{ flex: 1, opacity: loading ? 0.7 : 1 }}
|
||||
onClick={() => !loading && void pay()}
|
||||
>
|
||||
<Text>{loading ? '支付中…' : needsWechatAuth ? '去授权' : '立即支付'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</PageShell>
|
||||
|
||||
@@ -5,7 +5,9 @@ import type { ProductDetailContentDto } from '@dukang/shared-types';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import PageNavBar from '../../components/PageNavBar';
|
||||
import ProductCarousel from '../../components/ProductCarousel';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { goLogin } from '../../lib/auth-nav';
|
||||
import { ensurePayReady } from '../../lib/pay-ready';
|
||||
import { isLoggedIn, request, toast } from '../../lib/api';
|
||||
import {
|
||||
getProductCarouselImages,
|
||||
getProductDetailImages,
|
||||
@@ -53,9 +55,16 @@ export default function ProductDetailPage() {
|
||||
Taro.switchTab({ url: '/pages/home/index' });
|
||||
}
|
||||
|
||||
function goBuy() {
|
||||
async function goBuy() {
|
||||
if (!productId) return;
|
||||
Taro.navigateTo({ url: `/pages/order-confirm/index?productId=${productId}&qty=2` });
|
||||
const returnPath = `/pages/order-confirm/index?productId=${productId}&qty=2`;
|
||||
if (!isLoggedIn()) {
|
||||
goLogin(returnPath);
|
||||
return;
|
||||
}
|
||||
const ready = await ensurePayReady(returnPath);
|
||||
if (!ready) return;
|
||||
Taro.navigateTo({ url: returnPath });
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
@@ -168,7 +177,7 @@ export default function ProductDetailPage() {
|
||||
<Image className="product-detail-bar-home-icon" src={iconHome} mode="aspectFit" />
|
||||
<Text className="product-detail-bar-home-label">首页</Text>
|
||||
</View>
|
||||
<View className="product-detail-buy-btn" onClick={goBuy}>
|
||||
<View className="product-detail-buy-btn" onClick={() => void goBuy()}>
|
||||
<Text className="product-detail-buy-btn-text">立即购买</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -105,19 +105,7 @@ export default function StoreDetailPage() {
|
||||
|
||||
<View className="store-detail-bar">
|
||||
<View
|
||||
className="store-detail-bar-btn store-detail-bar-btn--ghost"
|
||||
onClick={() => {
|
||||
if (store.phone) {
|
||||
Taro.makePhoneCall({ phoneNumber: store.phone }).catch(() => toast('无法拨打'));
|
||||
} else {
|
||||
toast('暂无联系电话');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text>联系门店</Text>
|
||||
</View>
|
||||
<View
|
||||
className="store-detail-bar-btn store-detail-bar-btn--primary"
|
||||
className="store-detail-bar-btn store-detail-bar-btn--primary store-detail-bar-btn--full"
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
|
||||
>
|
||||
<Text>去核销</Text>
|
||||
|
||||
@@ -4,13 +4,23 @@ import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import TabMainHeader from '../../components/TabMainHeader';
|
||||
import RegionPicker from '../../components/RegionPicker';
|
||||
import {
|
||||
DEFAULT_REGION,
|
||||
formatRegionLabel,
|
||||
matchesRegionFilter,
|
||||
type RegionSelection,
|
||||
} from '../../lib/region-data';
|
||||
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
||||
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location';
|
||||
import { FALLBACK_CITY_CODE } from '../../lib/product-images';
|
||||
import { request, toast } from '../../lib/api';
|
||||
|
||||
type Store = {
|
||||
id: string;
|
||||
name: string;
|
||||
address?: string;
|
||||
province?: string;
|
||||
cityName?: string;
|
||||
district?: string;
|
||||
coverUrl?: string | null;
|
||||
openTime?: string | null;
|
||||
@@ -26,21 +36,30 @@ export default function StoresPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [categoryTab, setCategoryTab] = useState<string>('全部');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [regionLabel, setRegionLabel] = useState('郑州市 · 全部区域');
|
||||
const [region, setRegion] = useState<RegionSelection>(DEFAULT_REGION);
|
||||
const [regionOpen, setRegionOpen] = useState(false);
|
||||
const [cityCode, setCityCode] = useState<string>(FALLBACK_CITY_CODE);
|
||||
const regionLabel = formatRegionLabel(region);
|
||||
|
||||
useDidShow(() => {
|
||||
syncTabBarSelected(1);
|
||||
void resolveUserCity().then((resolved) => {
|
||||
setRegion(resolved.region);
|
||||
setCityCode(getCityCodeForCatalog(resolved));
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
request<Store[]>('/stores')
|
||||
setLoading(true);
|
||||
const path = cityCode ? `/stores?cityCode=${encodeURIComponent(cityCode)}` : '/stores';
|
||||
request<Store[]>(path)
|
||||
.then((list) => setStores(Array.isArray(list) ? list : []))
|
||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
}, [cityCode]);
|
||||
|
||||
const filtered = stores.filter((s) => {
|
||||
if (!matchesRegionFilter(s, region)) return false;
|
||||
if (!keyword.trim()) return true;
|
||||
const q = keyword.trim();
|
||||
return s.name.includes(q) || (s.address ?? '').includes(q) || (s.district ?? '').includes(q);
|
||||
@@ -59,11 +78,11 @@ export default function StoresPage() {
|
||||
<View className="store-toolbar">
|
||||
<View className="store-location" onClick={() => setRegionOpen(true)}>
|
||||
<View className="store-location-pin" />
|
||||
<Text>{regionLabel} ▾</Text>
|
||||
<Text className="store-location-text">{regionLabel} ▾</Text>
|
||||
</View>
|
||||
<Input
|
||||
className="store-search"
|
||||
placeholder="搜索门店名称或地址"
|
||||
placeholder="搜索门店"
|
||||
value={keyword}
|
||||
onInput={(e) => setKeyword(e.detail.value)}
|
||||
/>
|
||||
@@ -123,9 +142,10 @@ export default function StoresPage() {
|
||||
{shouldRenderPageTabBar() ? <UserTabBar selected={1} /> : null}
|
||||
<RegionPicker
|
||||
open={regionOpen}
|
||||
valueLabel="郑州市"
|
||||
value={region}
|
||||
levels={3}
|
||||
onClose={() => setRegionOpen(false)}
|
||||
onConfirm={(label) => setRegionLabel(`${label} · 全部区域`)}
|
||||
onConfirm={(next) => setRegion(next)}
|
||||
/>
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user