登录页面
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '编辑地址',
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState } from 'react';
|
||||
import { View, Text, Input, Textarea } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import { toast } from '../../lib/api';
|
||||
|
||||
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('');
|
||||
|
||||
function save() {
|
||||
if (!name.trim() || !phone.trim() || !detail.trim()) {
|
||||
toast('请完善地址信息');
|
||||
return;
|
||||
}
|
||||
toast(isEdit ? '地址已更新(UI 壳)' : '地址已新增(UI 壳)', 'success');
|
||||
setTimeout(() => Taro.navigateBack(), 600);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="address-page" hasFixedFooter>
|
||||
<SubPageHeader title={isEdit ? '编辑地址' : '新增地址'} />
|
||||
<View className="sub-page-body" style={{ paddingBottom: 80 }}>
|
||||
<View className="address-form-field">
|
||||
<Text className="address-form-label">收货人</Text>
|
||||
<Input
|
||||
className="address-form-input"
|
||||
placeholder="请输入姓名"
|
||||
value={name}
|
||||
onInput={(e) => setName(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
<View className="address-form-field">
|
||||
<Text className="address-form-label">手机号</Text>
|
||||
<Input
|
||||
className="address-form-input"
|
||||
type="number"
|
||||
maxlength={11}
|
||||
placeholder="请输入手机号"
|
||||
value={phone}
|
||||
onInput={(e) => setPhone(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
<View className="address-form-field">
|
||||
<Text className="address-form-label">所在地区</Text>
|
||||
<View
|
||||
className="address-form-input"
|
||||
style={{ display: 'flex', alignItems: 'center' }}
|
||||
onClick={() => toast('区域选择器后续接入')}
|
||||
>
|
||||
<Text>{region || '请选择省市区'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="address-form-field">
|
||||
<Text className="address-form-label">详细地址</Text>
|
||||
<Textarea
|
||||
className="address-form-textarea"
|
||||
placeholder="街道门牌号等"
|
||||
value={detail}
|
||||
onInput={(e) => setDetail(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View className="address-fab" onClick={save}>
|
||||
<Text>保存</Text>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '地址管理',
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import { request, toast } from '../../lib/api';
|
||||
|
||||
type Address = {
|
||||
id: string;
|
||||
receiverName: string;
|
||||
phone: string;
|
||||
province?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
detail: string;
|
||||
isDefault?: boolean;
|
||||
};
|
||||
|
||||
export default function AddressesPage() {
|
||||
const [list, setList] = useState<Address[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
request<Address[]>('/user/addresses')
|
||||
.then((data) => setList(Array.isArray(data) ? data : []))
|
||||
.catch(() => setList([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="address-page" hasFixedFooter>
|
||||
<SubPageHeader title="地址管理" />
|
||||
<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 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>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<View
|
||||
className="address-fab"
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/address-edit/index' })}
|
||||
>
|
||||
<Text>新增地址</Text>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '权益明细',
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import { request } from '../../lib/api';
|
||||
|
||||
type LedgerItem = {
|
||||
id: string;
|
||||
title?: string;
|
||||
amount: number;
|
||||
createdAt?: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
export default function BenefitDetailPage() {
|
||||
const [items, setItems] = useState<LedgerItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
request<LedgerItem[]>('/benefit/ledger')
|
||||
.then((data) => setItems(Array.isArray(data) ? data : []))
|
||||
.catch(() => {
|
||||
// UI shell fallback demo rows when API missing
|
||||
setItems([]);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="benefit-detail-page">
|
||||
<SubPageHeader title="权益明细" />
|
||||
<View className="sub-page-body">
|
||||
<View className="order-card">
|
||||
{loading ? <View className="u-empty">加载中…</View> : null}
|
||||
{!loading && items.length === 0 ? (
|
||||
<View className="u-empty">暂无明细记录</View>
|
||||
) : null}
|
||||
{items.map((item) => {
|
||||
const isOut = Number(item.amount) < 0 || item.type === 'REDEEM';
|
||||
return (
|
||||
<View key={item.id} className="ledger-item">
|
||||
<View>
|
||||
<Text className="ledger-title">{item.title || (isOut ? '门店核销' : '购酒入账')}</Text>
|
||||
<Text className="ledger-time">
|
||||
{item.createdAt ? String(item.createdAt).slice(0, 19).replace('T', ' ') : '-'}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className={`ledger-amount ${isOut ? 'ledger-amount--out' : 'ledger-amount--in'}`}>
|
||||
{isOut ? '' : '+'}
|
||||
{Number(item.amount).toFixed(2)}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +1,182 @@
|
||||
import { View, Text, Button } from '@tarojs/components';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import TabMainHeader from '../../components/TabMainHeader';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
||||
import { isLoggedIn } from '../../lib/api';
|
||||
import { goLogin } from '../../lib/auth-nav';
|
||||
import { isLoggedIn, request, toast } from '../../lib/api';
|
||||
import { navBarStyle, useNavBarMetrics } from '../../lib/nav-bar';
|
||||
|
||||
type BenefitSummary = {
|
||||
totalBalance: number;
|
||||
maxRedeemAmount: number;
|
||||
activeCouponCount: number;
|
||||
};
|
||||
|
||||
type CouponItem = {
|
||||
id: string;
|
||||
couponNo: string;
|
||||
totalAmount: number;
|
||||
usedAmount: number;
|
||||
balance: number;
|
||||
status: string;
|
||||
sourceProduct: string;
|
||||
};
|
||||
|
||||
function formatMoney(amount: number) {
|
||||
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function usagePercent(coupon: CouponItem) {
|
||||
const total = Number(coupon.totalAmount);
|
||||
if (total <= 0) return 0;
|
||||
return Math.min(100, Math.round((Number(coupon.usedAmount) / total) * 100));
|
||||
}
|
||||
|
||||
export default function BenefitPage() {
|
||||
const metrics = useNavBarMetrics();
|
||||
const loggedIn = isLoggedIn();
|
||||
const [summary, setSummary] = useState<BenefitSummary | null>(null);
|
||||
const [coupons, setCoupons] = useState<CouponItem[]>([]);
|
||||
const [tab, setTab] = useState<'available' | 'history'>('available');
|
||||
|
||||
useDidShow(() => {
|
||||
syncTabBarSelected(2);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!loggedIn) return;
|
||||
Promise.all([
|
||||
request<BenefitSummary>('/benefit/summary'),
|
||||
request<CouponItem[]>('/benefit/coupons'),
|
||||
])
|
||||
.then(([s, list]) => {
|
||||
setSummary(s);
|
||||
setCoupons(Array.isArray(list) ? list : []);
|
||||
})
|
||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
||||
}, [loggedIn]);
|
||||
|
||||
const available = coupons.filter((c) => c.status === 'ACTIVE' && c.balance > 0);
|
||||
const history = coupons.filter((c) => c.status === 'USED_UP' || c.status === 'VOID');
|
||||
const visible = tab === 'available' ? available : history;
|
||||
|
||||
return (
|
||||
<View className="u-page u-page--tab">
|
||||
<TabMainHeader title="好客权益" />
|
||||
<View className="u-card">
|
||||
{loggedIn ? (
|
||||
<View className="u-empty">权益列表将在后续迭代接入</View>
|
||||
) : (
|
||||
<View>
|
||||
<View className="u-empty">登录后查看好客权益余额</View>
|
||||
<Button
|
||||
className="u-btn u-btn--block"
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/login/index' })}
|
||||
>
|
||||
去登录
|
||||
</Button>
|
||||
<PageShell variant="tab" className="benefit-page">
|
||||
<View className="benefit-header" style={navBarStyle(metrics)}>
|
||||
<View
|
||||
className="benefit-header__content"
|
||||
style={{ height: `${metrics.navContentHeight}px` }}
|
||||
>
|
||||
<View className="benefit-header-city">
|
||||
<View className="benefit-header-city-pin" />
|
||||
<Text>郑州市</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text className="benefit-header-title">好客权益</Text>
|
||||
<View className="benefit-header-btn" onClick={() => toast('消息通知即将开放')}>
|
||||
<Text>🔔</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{!loggedIn ? (
|
||||
<View className="benefit-login-gate">
|
||||
<View className="u-empty">登录后查看好客权益余额</View>
|
||||
<View
|
||||
className="u-btn u-btn--block"
|
||||
style={{ maxWidth: 240, margin: '0 auto' }}
|
||||
onClick={() => goLogin('/pages/benefit/index')}
|
||||
>
|
||||
<Text>去登录</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View className="benefit-main">
|
||||
<View className="benefit-hero">
|
||||
<View className="benefit-hero-top">
|
||||
<View>
|
||||
<Text className="benefit-hero-label">当前好客权益余额</Text>
|
||||
<View className="benefit-hero-amount">
|
||||
<Text className="benefit-hero-symbol">¥</Text>
|
||||
<Text className="benefit-hero-value">
|
||||
{summary ? formatMoney(summary.totalBalance) : '--'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="benefit-hero-logo">
|
||||
<Text>康</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="benefit-hero-actions">
|
||||
<Text
|
||||
className="benefit-hero-link"
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/benefit-detail/index' })}
|
||||
>
|
||||
查看权益明细 ›
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
className="benefit-hero-cta"
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
|
||||
>
|
||||
<Text>去使用</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="benefit-tabs">
|
||||
<Text
|
||||
className={`benefit-tab${tab === 'available' ? ' benefit-tab--active' : ''}`}
|
||||
onClick={() => setTab('available')}
|
||||
>
|
||||
可用权益
|
||||
</Text>
|
||||
<Text
|
||||
className={`benefit-tab${tab === 'history' ? ' benefit-tab--active' : ''}`}
|
||||
onClick={() => setTab('history')}
|
||||
>
|
||||
历史记录
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<View className="u-empty">{tab === 'available' ? '暂无可用权益' : '暂无历史记录'}</View>
|
||||
) : (
|
||||
visible.map((c) => (
|
||||
<View key={c.id} className="benefit-coupon">
|
||||
<View className="benefit-coupon-notch" />
|
||||
<View className="benefit-coupon-notch benefit-coupon-notch--right" />
|
||||
<View className="benefit-coupon-head">
|
||||
<Text className="benefit-coupon-name">{c.sourceProduct || '好客权益'}</Text>
|
||||
<Text className="benefit-coupon-balance">¥{formatMoney(c.balance)}</Text>
|
||||
</View>
|
||||
<Text className="benefit-coupon-no">NO. {c.couponNo}</Text>
|
||||
<View className="benefit-progress">
|
||||
<View className="benefit-progress-bar" style={{ width: `${usagePercent(c)}%` }} />
|
||||
</View>
|
||||
<View className="benefit-coupon-footer">
|
||||
<Text className="benefit-coupon-meta">
|
||||
已用 ¥{formatMoney(c.usedAmount)} / 总额 ¥{formatMoney(c.totalAmount)}
|
||||
</Text>
|
||||
{tab === 'available' ? (
|
||||
<Text
|
||||
className="benefit-coupon-btn"
|
||||
onClick={() =>
|
||||
Taro.navigateTo({
|
||||
url: `/pages/redeem/index?couponId=${c.id}&amount=${c.balance}`,
|
||||
})
|
||||
}
|
||||
>
|
||||
立即核销
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{shouldRenderPageTabBar() ? <UserTabBar selected={2} /> : null}
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '联系客服',
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import { toast } from '../../lib/api';
|
||||
|
||||
const QUICK = ['如何核销权益?', '订单多久发货?', '如何修改地址?', '联系人工客服'];
|
||||
|
||||
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>
|
||||
<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,11 +1,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import { useDidShow } from '@tarojs/taro';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import TabMainHeader from '../../components/TabMainHeader';
|
||||
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, getProductMainImage } from '../../lib/product-images';
|
||||
import { FALLBACK_CITY_CODE, getProductImages } from '../../lib/product-images';
|
||||
|
||||
type Product = {
|
||||
id: string;
|
||||
@@ -50,15 +52,19 @@ export default function HomePage() {
|
||||
setTab(key);
|
||||
}
|
||||
|
||||
function openProductDetail(id: string) {
|
||||
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` });
|
||||
}
|
||||
|
||||
const filtered = products.filter((p) => p.aromaType === tab);
|
||||
const onSale = tab === 'QINGXIANG';
|
||||
|
||||
return (
|
||||
<View className="u-page u-page--tab home-page">
|
||||
<PageShell variant="tab" className="home-page">
|
||||
<TabMainHeader
|
||||
title="杜康好客"
|
||||
extra={(
|
||||
<View style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<View className="tab-main-header__extra-inner">
|
||||
<View className="tab-main-city-pin" />
|
||||
<Text className="tab-main-city-label">郑州市</Text>
|
||||
</View>
|
||||
@@ -86,29 +92,24 @@ export default function HomePage() {
|
||||
{!loading &&
|
||||
onSale &&
|
||||
filtered.map((p) => {
|
||||
const cover = getProductMainImage(p);
|
||||
const images = getProductImages(p);
|
||||
return (
|
||||
<View key={p.id} className="home-product-card">
|
||||
{cover ? (
|
||||
<Image className="home-product-cover" src={cover} mode="aspectFill" />
|
||||
) : (
|
||||
<View className="home-product-cover--empty" />
|
||||
)}
|
||||
<View className="home-product-body">
|
||||
<View className="home-product-row">
|
||||
<Text className="home-product-name">{p.name}</Text>
|
||||
<Text className="home-product-price">¥{Number(p.price).toFixed(2)}</Text>
|
||||
</View>
|
||||
{p.subtitle ? <Text className="home-product-sub">{p.subtitle}</Text> : null}
|
||||
<View className="home-product-footer">
|
||||
<CouponBadge amount={p.benefitDisplay ?? p.price} label="好客权益" />
|
||||
<View onClick={() => openProductDetail(p.id)}>
|
||||
<ProductCarousel images={images} alt={p.name} variant="home" />
|
||||
<View className="home-product-body">
|
||||
<View className="home-product-row">
|
||||
<Text className="home-product-name">{p.name}</Text>
|
||||
<Text className="home-product-price">¥{Number(p.price).toFixed(2)}</Text>
|
||||
</View>
|
||||
{p.subtitle ? <Text className="home-product-sub">{p.subtitle}</Text> : null}
|
||||
<View className="home-product-footer">
|
||||
<CouponBadge amount={p.benefitDisplay ?? p.price} label="好客权益" />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View className="home-product-actions">
|
||||
<Text
|
||||
className="home-buy-btn"
|
||||
onClick={() => toast('详情页开发中')}
|
||||
>
|
||||
<Text className="home-buy-btn" onClick={() => openProductDetail(p.id)}>
|
||||
立即购买
|
||||
</Text>
|
||||
</View>
|
||||
@@ -118,6 +119,6 @@ export default function HomePage() {
|
||||
</View>
|
||||
|
||||
{shouldRenderPageTabBar() ? <UserTabBar selected={0} /> : null}
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '登录',
|
||||
});
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: calc(64px + env(safe-area-inset-top, 0px)) 24px calc(24px + env(safe-area-inset-bottom, 0px));
|
||||
background: linear-gradient(160deg, #7a0f16 0%, #a61d24 42%, #faf9f7 42%, #faf9f7 100%);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: #fff;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
font-size: 13px;
|
||||
opacity: 0.85;
|
||||
margin-top: 4px;
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 24px 20px;
|
||||
box-shadow: 0 8px 30px rgba(93, 64, 55, 0.12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.login-card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin-bottom: 6px;
|
||||
color: var(--color-on-surface, #1a1c1b);
|
||||
}
|
||||
|
||||
.login-input {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
padding: 0 14px;
|
||||
border-radius: 12px;
|
||||
background: #faf9f7;
|
||||
font-size: 15px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.login-code-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.login-input--code {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.login-send {
|
||||
flex-shrink: 0;
|
||||
height: 48px;
|
||||
padding: 0 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(166, 29, 36, 0.08);
|
||||
color: #a61d24;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
line-height: 48px;
|
||||
}
|
||||
|
||||
.login-msg {
|
||||
color: #a61d24;
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -1,49 +1,119 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text, Input, Button } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { SmsScene } from '@dukang/shared-types';
|
||||
import { useRouter } from '@tarojs/taro';
|
||||
import {
|
||||
SmsScene,
|
||||
isWxAuthorizeEnabled,
|
||||
type ClientRuntimeConfig,
|
||||
type WechatLoginResult,
|
||||
} from '@dukang/shared-types';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import { finishLoginNavigate } from '../../lib/auth-nav';
|
||||
import { request, saveAuth, toast, type SessionPayload } from '../../lib/api';
|
||||
import './index.css';
|
||||
import { loginWithWechat } from '../../lib/wechat-auth';
|
||||
|
||||
function normalizePhone(value: string) {
|
||||
return value.replace(/\D/g, '').slice(0, 11);
|
||||
}
|
||||
|
||||
function isValidPhone(phone: string) {
|
||||
return /^1[3-9]\d{9}$/.test(phone);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState(process.env.TARO_ENV === 'h5' ? '123456' : '');
|
||||
const [cooldown, setCooldown] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const router = useRouter();
|
||||
const returnTo = router.params.return || '';
|
||||
|
||||
async function sendCode() {
|
||||
if (cooldown > 0) return;
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [wxLoading, setWxLoading] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [cooldown, setCooldown] = useState(0);
|
||||
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 [wxAuthorize, setWxAuthorize] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
request<ClientRuntimeConfig>('/common/client-config')
|
||||
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
|
||||
.catch(() => setWxAuthorize(true));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (cooldown <= 0) return;
|
||||
const timer = setTimeout(() => setCooldown((c) => Math.max(0, c - 1)), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [cooldown]);
|
||||
|
||||
function ensureAgreed() {
|
||||
if (!agreed) {
|
||||
setMsg('请先勾选并同意用户协议');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function applySessionAndLeave(data: SessionPayload | WechatLoginResult) {
|
||||
if (!data.accessToken) return;
|
||||
saveAuth({
|
||||
accessToken: data.accessToken,
|
||||
refreshToken: data.refreshToken,
|
||||
});
|
||||
toast('登录成功', 'success');
|
||||
finishLoginNavigate(returnTo);
|
||||
}
|
||||
|
||||
function handleWechatLoginResult(result: WechatLoginResult) {
|
||||
if (result.needBindPhone && result.wxSessionKey) {
|
||||
setBindMode(true);
|
||||
setWxSessionKey(result.wxSessionKey);
|
||||
setMsg('微信授权成功,请绑定手机号完成登录');
|
||||
setSentHint('');
|
||||
return;
|
||||
}
|
||||
if (result.accessToken) {
|
||||
applySessionAndLeave(result);
|
||||
return;
|
||||
}
|
||||
setMsg('微信登录未完成,请重试或使用手机号登录');
|
||||
}
|
||||
|
||||
async function onSendCode() {
|
||||
if (!ensureAgreed()) return;
|
||||
if (cooldown > 0 || sending) return;
|
||||
const normalized = phone.trim();
|
||||
if (!/^1[3-9]\d{9}$/.test(normalized)) {
|
||||
if (!isValidPhone(normalized)) {
|
||||
setMsg('请输入正确的手机号');
|
||||
return;
|
||||
}
|
||||
setMsg('');
|
||||
setSentHint('');
|
||||
setSending(true);
|
||||
try {
|
||||
await request('/auth/sms/send', {
|
||||
method: 'POST',
|
||||
data: { phone: normalized, scene: SmsScene.USER_LOGIN },
|
||||
data: {
|
||||
phone: normalized,
|
||||
scene: bindMode ? SmsScene.BIND_PHONE : SmsScene.USER_LOGIN,
|
||||
},
|
||||
});
|
||||
toast('验证码已发送');
|
||||
setCooldown(60);
|
||||
const timer = setInterval(() => {
|
||||
setCooldown((c) => {
|
||||
if (c <= 1) {
|
||||
clearInterval(timer);
|
||||
return 0;
|
||||
}
|
||||
return c - 1;
|
||||
});
|
||||
}, 1000);
|
||||
setSentHint('验证码已发送');
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '发送失败');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function login() {
|
||||
if (!ensureAgreed()) return;
|
||||
const normalized = phone.trim();
|
||||
if (!/^1[3-9]\d{9}$/.test(normalized)) {
|
||||
if (!isValidPhone(normalized)) {
|
||||
setMsg('请输入正确的手机号');
|
||||
return;
|
||||
}
|
||||
@@ -53,14 +123,21 @@ export default function LoginPage() {
|
||||
}
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
setSentHint('');
|
||||
try {
|
||||
if (bindMode && wxSessionKey) {
|
||||
const data = await request<WechatLoginResult>('/auth/wechat/bind-phone', {
|
||||
method: 'POST',
|
||||
data: { wxSessionKey, phone: normalized, code: code.trim() },
|
||||
});
|
||||
handleWechatLoginResult(data);
|
||||
return;
|
||||
}
|
||||
const data = await request<SessionPayload>('/auth/login/sms', {
|
||||
method: 'POST',
|
||||
data: { phone: normalized, code: code.trim() },
|
||||
});
|
||||
saveAuth(data);
|
||||
toast('登录成功', 'success');
|
||||
Taro.switchTab({ url: '/pages/home/index' });
|
||||
applySessionAndLeave(data);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '登录失败');
|
||||
} finally {
|
||||
@@ -68,47 +145,127 @@ export default function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function wechatLogin() {
|
||||
if (!ensureAgreed()) return;
|
||||
setMsg('');
|
||||
setSentHint('');
|
||||
setWxLoading(true);
|
||||
try {
|
||||
const result = await loginWithWechat();
|
||||
handleWechatLoginResult(result);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '微信登录失败');
|
||||
} finally {
|
||||
setWxLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const displayMsg = msg || sentHint;
|
||||
const codeDisabled = cooldown > 0 || sending;
|
||||
|
||||
return (
|
||||
<View className="login-page">
|
||||
<View className="login-brand">
|
||||
<Text className="login-title">杜康好客</Text>
|
||||
<Text className="login-subtitle">用户端小程序</Text>
|
||||
<PageShell variant="plain" className="login-page">
|
||||
<View className="login-header">
|
||||
<View className="login-logo-wrap">
|
||||
<View className="login-logo">
|
||||
<Text>康</Text>
|
||||
</View>
|
||||
<Text className="login-logo-badge">官方</Text>
|
||||
</View>
|
||||
<View className="login-welcome">
|
||||
<Text className="login-welcome-title">欢迎来到杜康好客</Text>
|
||||
<Text className="login-welcome-sub">买美酒,享好礼</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="login-card">
|
||||
<Text className="login-card-title">手机号登录</Text>
|
||||
<Input
|
||||
className="login-input"
|
||||
type="number"
|
||||
maxlength={11}
|
||||
placeholder="请输入手机号"
|
||||
value={phone}
|
||||
onInput={(e) => setPhone(e.detail.value)}
|
||||
/>
|
||||
<View className="login-code-row">
|
||||
<Input
|
||||
className="login-input login-input--code"
|
||||
type="number"
|
||||
maxlength={6}
|
||||
placeholder="验证码"
|
||||
value={code}
|
||||
onInput={(e) => setCode(e.detail.value)}
|
||||
/>
|
||||
<Button className="login-send" disabled={cooldown > 0} onClick={() => void sendCode()}>
|
||||
{cooldown > 0 ? `${cooldown}s` : '获取验证码'}
|
||||
|
||||
<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('');
|
||||
}}
|
||||
/>
|
||||
</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()}
|
||||
>
|
||||
{loading ? '登录中...' : bindMode ? '绑定并登录' : '登录'}
|
||||
</Button>
|
||||
</View>
|
||||
{msg ? <Text className="login-msg">{msg}</Text> : null}
|
||||
<Button
|
||||
className="u-btn u-btn--primary u-btn--block"
|
||||
loading={loading}
|
||||
onClick={() => void login()}
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
<Text className="u-muted" style={{ textAlign: 'center', marginTop: 8 }}>
|
||||
Mock 环境验证码一般为 123456;微信一键登录后续接入
|
||||
</Text>
|
||||
|
||||
{!bindMode && wxAuthorize ? (
|
||||
<>
|
||||
<View className="login-divider">
|
||||
<View className="login-divider-line" />
|
||||
<Text className="login-divider-text">或者</Text>
|
||||
<View className="login-divider-line" />
|
||||
</View>
|
||||
<Button
|
||||
className="login-wechat-btn"
|
||||
loading={wxLoading}
|
||||
disabled={wxLoading}
|
||||
onClick={() => void wechatLogin()}
|
||||
>
|
||||
<Text className="login-wechat-icon">微</Text>
|
||||
<Text>微信一键授权</Text>
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="login-footer">
|
||||
<View className="login-agreement" onClick={() => setAgreed((v) => !v)}>
|
||||
<View className={`login-agreement-check${agreed ? ' login-agreement-check--on' : ''}`}>
|
||||
{agreed ? <Text>✓</Text> : null}
|
||||
</View>
|
||||
<Text className="login-agreement-text">
|
||||
我已阅读并同意
|
||||
<Text className="login-agreement-link">《用户协议》</Text>
|
||||
和
|
||||
<Text className="login-agreement-link">《隐私政策》</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text, Button } from '@tarojs/components';
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import TabMainHeader from '../../components/TabMainHeader';
|
||||
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
||||
import { goLogin } from '../../lib/auth-nav';
|
||||
import { isLoggedIn, logout, request, toast, type UserProfile } from '../../lib/api';
|
||||
|
||||
const ORDER_SHORTCUTS = [
|
||||
{ tab: 'pending_pay', icon: '付', label: '待付款' },
|
||||
{ tab: 'paid', icon: '包', label: '已付款' },
|
||||
{ tab: 'completed', icon: '成', label: '已完成' },
|
||||
] as const;
|
||||
|
||||
const SERVICES = [
|
||||
{ icon: '址', label: '地址管理', url: '/pages/addresses/index' },
|
||||
{ icon: '店', label: '可用门店', tab: '/pages/stores/index' },
|
||||
{ icon: '服', label: '联系客服', url: '/pages/customer-service/index' },
|
||||
{ icon: '关', label: '关于我们', action: 'about' as const },
|
||||
] as const;
|
||||
|
||||
function formatMoney(amount: number) {
|
||||
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
export default function MinePage() {
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const loggedIn = isLoggedIn();
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [benefitBalance, setBenefitBalance] = useState(0);
|
||||
const [orderCounts, setOrderCounts] = useState<Record<string, number>>({});
|
||||
|
||||
useDidShow(() => {
|
||||
syncTabBarSelected(3);
|
||||
@@ -15,43 +36,187 @@ export default function MinePage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!loggedIn) return;
|
||||
request<UserProfile>('/auth/me')
|
||||
.then(setProfile)
|
||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
||||
Promise.all([
|
||||
request<UserProfile>('/auth/me'),
|
||||
request<Array<Record<string, unknown>>>('/benefit/coupons').catch(() => []),
|
||||
...ORDER_SHORTCUTS.map((s) =>
|
||||
request<{ total: number }>(`/trade/orders?tab=${s.tab}&pageSize=1`).catch(() => ({ total: 0 })),
|
||||
),
|
||||
])
|
||||
.then(([me, coupons, ...totals]) => {
|
||||
setProfile(me);
|
||||
const balance = (coupons as Array<Record<string, unknown>>).reduce((sum, c) => {
|
||||
if (String(c.status) === 'ACTIVE') return sum + Number(c.balance || 0);
|
||||
return sum;
|
||||
}, 0);
|
||||
setBenefitBalance(balance);
|
||||
const counts: Record<string, number> = {};
|
||||
ORDER_SHORTCUTS.forEach((s, i) => {
|
||||
counts[s.tab] = (totals[i] as { total: number })?.total ?? 0;
|
||||
});
|
||||
setOrderCounts(counts);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [loggedIn]);
|
||||
|
||||
function handleService(item: (typeof SERVICES)[number]) {
|
||||
if ('url' in item && item.url) {
|
||||
Taro.navigateTo({ url: item.url });
|
||||
return;
|
||||
}
|
||||
if ('tab' in item && item.tab) {
|
||||
Taro.switchTab({ url: item.tab });
|
||||
return;
|
||||
}
|
||||
if ('action' in item && item.action === 'about') {
|
||||
toast('杜康好客 · 传承千年酒文化');
|
||||
}
|
||||
}
|
||||
|
||||
if (!loggedIn) {
|
||||
return (
|
||||
<PageShell variant="tab" className="mine-page">
|
||||
<TabMainHeader title="我的" />
|
||||
<View className="mine-header">
|
||||
<View className="mine-header-texture" />
|
||||
<View className="mine-profile">
|
||||
<View className="mine-avatar">
|
||||
<Text>客</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text className="mine-profile-name">未登录</Text>
|
||||
<Text className="mine-member-tag">好客会员</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View className="mine-login-gate u-card">
|
||||
<View className="u-empty">登录后管理订单与个人信息</View>
|
||||
<View
|
||||
className="u-btn u-btn--block"
|
||||
onClick={() => goLogin('/pages/mine/index')}
|
||||
>
|
||||
<Text>去登录</Text>
|
||||
</View>
|
||||
</View>
|
||||
{shouldRenderPageTabBar() ? <UserTabBar selected={3} /> : null}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const nickname = profile?.nickname || '用户';
|
||||
const avatarUrl = profile?.avatarUrl;
|
||||
|
||||
return (
|
||||
<View className="u-page u-page--tab">
|
||||
<PageShell variant="tab" className="mine-page">
|
||||
<TabMainHeader title="我的" />
|
||||
<View className="u-card">
|
||||
{loggedIn ? (
|
||||
<View>
|
||||
<Text className="u-title" style={{ marginBottom: 4 }}>
|
||||
{profile?.nickname || '用户'}
|
||||
</Text>
|
||||
<Text className="u-muted" style={{ display: 'block', marginBottom: 16 }}>
|
||||
{profile?.phone || '未绑定手机'}
|
||||
</Text>
|
||||
<Text className="u-muted" style={{ display: 'block', marginBottom: 16 }}>
|
||||
订单 / 地址 / 核销码等页面后续从 h5-user 迁移
|
||||
</Text>
|
||||
<Button className="u-btn u-btn--block" onClick={() => logout()}>
|
||||
退出登录
|
||||
</Button>
|
||||
<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>
|
||||
) : (
|
||||
<View>
|
||||
<View className="u-empty">登录后管理订单与个人信息</View>
|
||||
<Button
|
||||
className="u-btn u-btn--block"
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/login/index' })}
|
||||
>
|
||||
去登录
|
||||
</Button>
|
||||
<Text className="mine-profile-name">{nickname}</Text>
|
||||
<Text className="mine-member-tag">{profile?.phone || '好客会员'}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="mine-main">
|
||||
<View className="mine-card">
|
||||
<View className="mine-card-head">
|
||||
<Text className="mine-card-title">我的资产</Text>
|
||||
<Text
|
||||
className="mine-card-link"
|
||||
onClick={() => Taro.switchTab({ url: '/pages/benefit/index' })}
|
||||
>
|
||||
查看明细 ›
|
||||
</Text>
|
||||
</View>
|
||||
<View className="mine-asset-panel">
|
||||
<View>
|
||||
<Text className="mine-asset-label">好客权益余额</Text>
|
||||
<View className="mine-asset-amount">
|
||||
<Text className="mine-asset-currency">¥</Text>
|
||||
<Text className="mine-asset-value">{formatMoney(benefitBalance)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text
|
||||
className="mine-asset-cta"
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
|
||||
>
|
||||
去使用
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="mine-card">
|
||||
<View className="mine-card-head">
|
||||
<Text className="mine-card-title">我的订单</Text>
|
||||
<Text
|
||||
className="mine-card-link"
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/orders/index' })}
|
||||
>
|
||||
全部订单 ›
|
||||
</Text>
|
||||
</View>
|
||||
<View className="mine-order-grid">
|
||||
{ORDER_SHORTCUTS.map((item) => {
|
||||
const count = orderCounts[item.tab] ?? 0;
|
||||
return (
|
||||
<View
|
||||
key={item.tab}
|
||||
className="mine-order-item"
|
||||
onClick={() =>
|
||||
Taro.navigateTo({ url: `/pages/orders/index?tab=${item.tab}` })
|
||||
}
|
||||
>
|
||||
<View className="mine-order-icon">
|
||||
<Text>{item.icon}</Text>
|
||||
</View>
|
||||
{count > 0 ? (
|
||||
<Text className="mine-order-badge">{count > 99 ? '99+' : count}</Text>
|
||||
) : null}
|
||||
<Text className="mine-order-label">{item.label}</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="mine-card">
|
||||
<View className="mine-card-head">
|
||||
<Text className="mine-card-title">常用服务</Text>
|
||||
</View>
|
||||
<View className="mine-service-grid">
|
||||
{SERVICES.map((item) => (
|
||||
<View
|
||||
key={item.label}
|
||||
className="mine-service-item"
|
||||
onClick={() => handleService(item)}
|
||||
>
|
||||
<View className="mine-service-icon">
|
||||
<Text>{item.icon}</Text>
|
||||
</View>
|
||||
<Text className="mine-service-label">{item.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="mine-footer">
|
||||
<Text className="mine-version">杜康好客 mini-user v0.1.0</Text>
|
||||
<Text className="mine-logout" onClick={() => logout()}>
|
||||
退出登录
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{shouldRenderPageTabBar() ? <UserTabBar selected={3} /> : null}
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '确认订单',
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { getProductMainImage } from '../../lib/product-images';
|
||||
|
||||
type Product = {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
mainImageUrl?: string | null;
|
||||
carouselUrls?: string[] | null;
|
||||
benefitDisplay?: number;
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
if (!productId) return;
|
||||
request<Product>(`/catalog/products/${productId}`)
|
||||
.then(setProduct)
|
||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
||||
}, [productId]);
|
||||
|
||||
const total = useMemo(() => {
|
||||
if (!product) return 0;
|
||||
return Number(product.price) * qty;
|
||||
}, [product, qty]);
|
||||
|
||||
const benefit = useMemo(() => {
|
||||
if (!product) return 0;
|
||||
return Number(product.benefitDisplay ?? product.price) * qty;
|
||||
}, [product, qty]);
|
||||
|
||||
function changeQty(delta: number) {
|
||||
setQty((q) => Math.max(2, q + delta));
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (!productId) return;
|
||||
Taro.navigateTo({
|
||||
url: `/pages/pay/index?productId=${productId}&qty=${qty}&amount=${total.toFixed(2)}`,
|
||||
});
|
||||
}
|
||||
|
||||
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' })}
|
||||
>
|
||||
<Text className="order-card-title">收货地址</Text>
|
||||
<Text className="u-muted">点击选择收货地址(同城起购 2 瓶)</Text>
|
||||
</View>
|
||||
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">商品信息</Text>
|
||||
{product ? (
|
||||
<View>
|
||||
<View className="order-product-row">
|
||||
<View className="order-product-thumb">
|
||||
{getProductMainImage(product) ? (
|
||||
<Image
|
||||
className="order-product-thumb-img"
|
||||
src={getProductMainImage(product)}
|
||||
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>
|
||||
</View>
|
||||
</View>
|
||||
<View className="order-qty-row">
|
||||
<Text>购买数量</Text>
|
||||
<View className="order-qty-controls">
|
||||
<View className="order-qty-btn" onClick={() => changeQty(-1)}>
|
||||
<Text>−</Text>
|
||||
</View>
|
||||
<Text className="order-qty-value">{qty}</Text>
|
||||
<View className="order-qty-btn" onClick={() => changeQty(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>
|
||||
|
||||
<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>
|
||||
</View>
|
||||
<View className="order-confirm-submit" onClick={submit}>
|
||||
<Text>提交订单</Text>
|
||||
</View>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '订单详情',
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import { request, toast } from '../../lib/api';
|
||||
|
||||
type OrderDetail = {
|
||||
id: string;
|
||||
orderNo?: string;
|
||||
status?: string;
|
||||
payAmount?: number;
|
||||
productName?: string;
|
||||
qty?: number;
|
||||
addressSnapshot?: string;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export default function OrderDetailPage() {
|
||||
const router = useRouter();
|
||||
const orderId = router.params.id ?? '';
|
||||
const [order, setOrder] = useState<OrderDetail | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!orderId) return;
|
||||
request<OrderDetail>(`/trade/orders/${orderId}`)
|
||||
.then(setOrder)
|
||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
||||
}, [orderId]);
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="order-detail-page">
|
||||
<SubPageHeader title="订单详情" />
|
||||
<View className="sub-page-body">
|
||||
{!order ? (
|
||||
<View className="u-empty">加载中…</View>
|
||||
) : (
|
||||
<>
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">订单状态</Text>
|
||||
<Text className="order-list-status">{order.status || '处理中'}</Text>
|
||||
</View>
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">商品信息</Text>
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">{order.productName || '杜康商品'}</Text>
|
||||
<Text className="order-row-value">x{order.qty ?? 1}</Text>
|
||||
</View>
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">实付金额</Text>
|
||||
<Text className="order-row-value--price">
|
||||
¥{Number(order.payAmount ?? 0).toFixed(2)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">收货信息</Text>
|
||||
<Text className="u-muted">
|
||||
{order.addressSnapshot || '地址信息待完善'}
|
||||
</Text>
|
||||
</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">{order.orderNo || order.id}</Text>
|
||||
</View>
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">下单时间</Text>
|
||||
<Text className="order-row-value">
|
||||
{order.createdAt ? String(order.createdAt).slice(0, 19).replace('T', ' ') : '-'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '我的订单',
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
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 { request, toast } from '../../lib/api';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'pending_pay', label: '待付款' },
|
||||
{ key: 'paid', label: '已付款' },
|
||||
{ key: 'completed', label: '已完成' },
|
||||
] as const;
|
||||
|
||||
type OrderRow = {
|
||||
id: string;
|
||||
orderNo?: string;
|
||||
status?: string;
|
||||
payAmount?: number;
|
||||
productName?: string;
|
||||
qty?: number;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export default function OrdersPage() {
|
||||
const router = useRouter();
|
||||
const initialTab = (router.params.tab as string) || 'pending_pay';
|
||||
const [tab, setTab] = useState(initialTab);
|
||||
const [orders, setOrders] = useState<OrderRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
request<{ 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 : []);
|
||||
})
|
||||
.catch((e) => {
|
||||
toast(e instanceof Error ? e.message : '加载失败');
|
||||
setOrders([]);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [tab]);
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="orders-page">
|
||||
<SubPageHeader title="我的订单" />
|
||||
<View className="order-tabs">
|
||||
{TABS.map((t) => (
|
||||
<Text
|
||||
key={t.key}
|
||||
className={`order-tab${tab === t.key ? ' order-tab--active' : ''}`}
|
||||
onClick={() => setTab(t.key)}
|
||||
>
|
||||
{t.label}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{loading ? <View className="u-empty">加载中…</View> : null}
|
||||
{!loading && orders.length === 0 ? <View className="u-empty">暂无订单</View> : null}
|
||||
{!loading &&
|
||||
orders.map((o) => (
|
||||
<View
|
||||
key={o.id}
|
||||
className="order-list-item"
|
||||
onClick={() => Taro.navigateTo({ url: `/pages/order-detail/index?id=${o.id}` })}
|
||||
>
|
||||
<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 || ''}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="order-list-body">
|
||||
<View className="order-list-thumb" />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text className="order-list-name">{o.productName || '杜康商品'}</Text>
|
||||
<Text className="order-list-meta">
|
||||
数量 {o.qty ?? 1} · {o.createdAt ? String(o.createdAt).slice(0, 10) : ''}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="order-list-footer">
|
||||
<Text className="order-list-meta">实付</Text>
|
||||
<Text className="order-product-price">
|
||||
¥{Number(o.payAmount ?? 0).toFixed(2)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '收银台',
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
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';
|
||||
|
||||
export default function PayPage() {
|
||||
const router = useRouter();
|
||||
const amount = router.params.amount ?? '0.00';
|
||||
|
||||
function mockPay() {
|
||||
toast('支付成功(Mock)', 'success');
|
||||
setTimeout(() => {
|
||||
Taro.redirectTo({ url: '/pages/orders/index?tab=paid' });
|
||||
}, 800);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="pay-page" hasFixedFooter>
|
||||
<SubPageHeader title="收银台" />
|
||||
<View className="sub-page-body">
|
||||
<View className="pay-status">
|
||||
<View className="pay-status-icon">
|
||||
<Text>¥</Text>
|
||||
</View>
|
||||
<Text className="pay-status-title">待支付</Text>
|
||||
<Text className="pay-status-amount">¥{amount}</Text>
|
||||
</View>
|
||||
<View className="order-card">
|
||||
<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>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View className="pay-bar">
|
||||
<View className="order-confirm-submit" style={{ flex: 1 }} onClick={mockPay}>
|
||||
<Text>立即支付</Text>
|
||||
</View>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '商品详情',
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import Taro, { usePageScroll, useRouter } from '@tarojs/taro';
|
||||
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 {
|
||||
getProductCarouselImages,
|
||||
getProductDetailImages,
|
||||
type ProductImageSource,
|
||||
} from '../../lib/product-images';
|
||||
import iconHome from '../../assets/tabbar/home.png';
|
||||
|
||||
type Product = ProductImageSource & {
|
||||
id: string;
|
||||
name: string;
|
||||
subtitle?: string | null;
|
||||
price: number;
|
||||
benefitAmount?: number;
|
||||
benefitDisplay?: number;
|
||||
detailContent?: ProductDetailContentDto | null;
|
||||
};
|
||||
|
||||
export default function ProductDetailPage() {
|
||||
const router = useRouter();
|
||||
const productId = router.params.id ?? '';
|
||||
const [product, setProduct] = useState<Product | null>(null);
|
||||
const [headerSolid, setHeaderSolid] = useState(false);
|
||||
|
||||
usePageScroll(({ scrollTop }) => {
|
||||
setHeaderSolid(scrollTop > 100);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!productId) return;
|
||||
request<Product>(`/catalog/products/${productId}`)
|
||||
.then(setProduct)
|
||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
||||
}, [productId]);
|
||||
|
||||
function goBack() {
|
||||
const pages = Taro.getCurrentPages();
|
||||
if (pages.length > 1) {
|
||||
Taro.navigateBack();
|
||||
return;
|
||||
}
|
||||
Taro.switchTab({ url: '/pages/home/index' });
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
Taro.switchTab({ url: '/pages/home/index' });
|
||||
}
|
||||
|
||||
function goBuy() {
|
||||
if (!productId) return;
|
||||
Taro.navigateTo({ url: `/pages/order-confirm/index?productId=${productId}&qty=2` });
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
<PageShell variant="scroll" className="product-detail-page">
|
||||
<PageNavBar title="商品详情" solid onBack={goBack} />
|
||||
<View className="page-with-nav-bar u-empty">加载中…</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const benefit = Number(product.benefitDisplay ?? product.benefitAmount ?? product.price);
|
||||
const carouselImages = getProductCarouselImages(product);
|
||||
const detailImages = getProductDetailImages(product);
|
||||
const detail = product.detailContent ?? {};
|
||||
const features = detail.features ?? [];
|
||||
|
||||
return (
|
||||
<PageShell variant="scroll" className="product-detail-page" hasFixedFooter>
|
||||
<PageNavBar
|
||||
title={product.name}
|
||||
solid={headerSolid}
|
||||
titleVisible={headerSolid}
|
||||
onBack={goBack}
|
||||
right={(
|
||||
<View className="page-nav-bar__btn" onClick={() => toast('分享功能开发中')}>
|
||||
<Text className="page-nav-bar__icon page-nav-bar__icon--share">⤴</Text>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
|
||||
<View className="product-detail-main">
|
||||
<View className="product-detail-hero full-bleed">
|
||||
<ProductCarousel images={carouselImages} alt={product.name} variant="detail" />
|
||||
</View>
|
||||
|
||||
<View className="product-detail-info">
|
||||
<View className="product-detail-price">
|
||||
<Text className="product-detail-price-symbol">¥</Text>
|
||||
<Text className="product-detail-price-value">{Number(product.price).toFixed(2)}</Text>
|
||||
</View>
|
||||
<Text className="product-detail-name">{product.name}</Text>
|
||||
{product.subtitle ? (
|
||||
<Text className="product-detail-subtitle">{product.subtitle}</Text>
|
||||
) : null}
|
||||
|
||||
<View className="product-detail-promo">
|
||||
<View className="product-detail-promo-glow" />
|
||||
<View className="product-detail-promo-head">
|
||||
<View className="product-detail-promo-icon">
|
||||
<Text className="product-detail-promo-icon-text">惠</Text>
|
||||
</View>
|
||||
<Text className="product-detail-promo-title">
|
||||
买杜康美酒 · 享全城好客礼遇
|
||||
<Text className="product-detail-promo-amount"> ¥{benefit}</Text>
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="product-detail-promo-desc">
|
||||
购酒即享本城专属好客权益,为您安排一场地道饭局。权益金可直接用于本地签约门店消费,到店享用,醇香美酒配佳肴。
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="product-detail-content">
|
||||
<View className="product-detail-section-head">
|
||||
<View className="product-detail-section-bar" />
|
||||
<Text className="product-detail-section-title">商品详情</Text>
|
||||
</View>
|
||||
|
||||
{detailImages.map((src, index) => (
|
||||
<Image
|
||||
key={`${src}-${index}`}
|
||||
className="product-detail-banner full-bleed"
|
||||
src={src}
|
||||
mode="widthFix"
|
||||
/>
|
||||
))}
|
||||
|
||||
{(detail.storyTitle || detail.storyText || features.length > 0) ? (
|
||||
<View className="product-detail-copy">
|
||||
{(detail.storyTitle || detail.storyText) ? (
|
||||
<View className="product-detail-story">
|
||||
{detail.storyTitle ? (
|
||||
<Text className="product-detail-story-title">{detail.storyTitle}</Text>
|
||||
) : null}
|
||||
{detail.storyText ? (
|
||||
<Text className="product-detail-story-text">{detail.storyText}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{features.length > 0 ? (
|
||||
<View className="product-detail-features">
|
||||
{features.map((f) => (
|
||||
<View key={`${f.title}-${f.icon}`} className="product-detail-feature">
|
||||
<Text className="product-detail-feature-icon">★</Text>
|
||||
<Text className="product-detail-feature-title">{f.title}</Text>
|
||||
<Text className="product-detail-feature-desc">{f.desc}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="product-detail-bar">
|
||||
<View className="product-detail-bar-home" onClick={goHome}>
|
||||
<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}>
|
||||
<Text className="product-detail-buy-btn-text">立即购买</Text>
|
||||
</View>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '核销码',
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
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';
|
||||
|
||||
export default function RedeemCodePage() {
|
||||
const router = useRouter();
|
||||
const amount = router.params.amount ?? '0';
|
||||
const [seconds, setSeconds] = useState(180);
|
||||
const code = `DK${String(Date.now()).slice(-8)}`;
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setSeconds((s) => {
|
||||
if (s <= 1) {
|
||||
clearInterval(timer);
|
||||
toast('核销码已过期');
|
||||
return 0;
|
||||
}
|
||||
return s - 1;
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const mm = String(Math.floor(seconds / 60)).padStart(2, '0');
|
||||
const ss = String(seconds % 60).padStart(2, '0');
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="redeem-code-page">
|
||||
<SubPageHeader title="核销码" />
|
||||
<View className="sub-page-body">
|
||||
<View className="redeem-code-panel">
|
||||
<Text className="u-muted">核销金额 ¥{Number(amount).toFixed(2)}</Text>
|
||||
<Text className="redeem-code-value">{code}</Text>
|
||||
<Text className="redeem-code-timer">剩余有效时间 {mm}:{ss}</Text>
|
||||
<Text className="u-muted" style={{ display: 'block', marginTop: 16 }}>
|
||||
请向门店店员出示此码完成核销
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
className="redeem-submit"
|
||||
onClick={() =>
|
||||
Taro.redirectTo({
|
||||
url: `/pages/redeem-success/index?amount=${amount}`,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Text>模拟核销成功</Text>
|
||||
</View>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '核销成功',
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
|
||||
export default function RedeemSuccessPage() {
|
||||
const router = useRouter();
|
||||
const amount = router.params.amount ?? '0';
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="redeem-success-page">
|
||||
<SubPageHeader title="核销成功" />
|
||||
<View className="sub-page-body">
|
||||
<View className="redeem-success-icon">
|
||||
<Text>✓</Text>
|
||||
</View>
|
||||
<Text className="redeem-success-title">核销成功</Text>
|
||||
<Text className="redeem-success-desc">
|
||||
已核销好客权益 ¥{Number(amount).toFixed(2)}
|
||||
</Text>
|
||||
<View
|
||||
className="redeem-submit"
|
||||
onClick={() => Taro.switchTab({ url: '/pages/benefit/index' })}
|
||||
>
|
||||
<Text>返回权益</Text>
|
||||
</View>
|
||||
<View
|
||||
className="u-btn u-btn--ghost u-btn--block"
|
||||
style={{ margin: '0 20px', boxSizing: 'border-box' }}
|
||||
onClick={() => Taro.switchTab({ url: '/pages/home/index' })}
|
||||
>
|
||||
<Text>回到首页</Text>
|
||||
</View>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '权益核销',
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text, Input } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import { goLogin } from '../../lib/auth-nav';
|
||||
import { isLoggedIn, request, toast } from '../../lib/api';
|
||||
|
||||
type BenefitSummary = {
|
||||
totalBalance: number;
|
||||
maxRedeemAmount: number;
|
||||
};
|
||||
|
||||
export default function RedeemPage() {
|
||||
const router = useRouter();
|
||||
const initialAmount = router.params.amount ?? '';
|
||||
const [balance, setBalance] = useState(0);
|
||||
const [amount, setAmount] = useState(initialAmount);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) {
|
||||
goLogin('/pages/redeem/index');
|
||||
return;
|
||||
}
|
||||
request<BenefitSummary>('/benefit/summary')
|
||||
.then((s) => setBalance(Number(s.maxRedeemAmount ?? s.totalBalance ?? 0)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
function submit() {
|
||||
const value = Number(amount);
|
||||
if (!(value > 0)) {
|
||||
toast('请输入核销金额');
|
||||
return;
|
||||
}
|
||||
if (value > balance && balance > 0) {
|
||||
toast('超出可用余额');
|
||||
return;
|
||||
}
|
||||
Taro.navigateTo({
|
||||
url: `/pages/redeem-code/index?amount=${value}`,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="redeem-page">
|
||||
<SubPageHeader title="权益核销" />
|
||||
<View className="sub-page-body">
|
||||
<View className="redeem-hero">
|
||||
<Text className="redeem-hero-label">可用余额</Text>
|
||||
<Text className="redeem-hero-amount">¥{balance.toFixed(2)}</Text>
|
||||
</View>
|
||||
<View className="redeem-input-wrap">
|
||||
<Input
|
||||
className="redeem-input"
|
||||
type="digit"
|
||||
placeholder="输入核销金额"
|
||||
value={amount}
|
||||
onInput={(e) => setAmount(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
<Text className="redeem-tips">
|
||||
直接核销:金额须大于 0 且不超过全部可用权益余额。核销码有效期 3 分钟,请到店出示。
|
||||
</Text>
|
||||
<View className="redeem-submit" onClick={submit}>
|
||||
<Text>生成核销码</Text>
|
||||
</View>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '门店详情',
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { usePageScroll, useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import PageNavBar from '../../components/PageNavBar';
|
||||
import ProductCarousel from '../../components/ProductCarousel';
|
||||
import { request, toast } from '../../lib/api';
|
||||
|
||||
type Store = {
|
||||
id: string;
|
||||
name: string;
|
||||
address?: string;
|
||||
district?: string;
|
||||
phone?: string;
|
||||
coverUrl?: string | null;
|
||||
carouselUrls?: string[] | null;
|
||||
openTime?: string | null;
|
||||
closeTime?: string | null;
|
||||
category?: { name: string } | null;
|
||||
};
|
||||
|
||||
export default function StoreDetailPage() {
|
||||
const router = useRouter();
|
||||
const storeId = router.params.id ?? '';
|
||||
const [store, setStore] = useState<Store | null>(null);
|
||||
const [headerSolid, setHeaderSolid] = useState(false);
|
||||
|
||||
usePageScroll(({ scrollTop }) => {
|
||||
setHeaderSolid(scrollTop > 100);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!storeId) return;
|
||||
request<Store>(`/stores/${storeId}`)
|
||||
.then(setStore)
|
||||
.catch(() => {
|
||||
request<Store[]>('/stores')
|
||||
.then((list) => {
|
||||
const found = (Array.isArray(list) ? list : []).find((s) => s.id === storeId);
|
||||
if (found) setStore(found);
|
||||
else toast('门店不存在');
|
||||
})
|
||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
||||
});
|
||||
}, [storeId]);
|
||||
|
||||
function goBack() {
|
||||
const pages = Taro.getCurrentPages();
|
||||
if (pages.length > 1) Taro.navigateBack();
|
||||
else Taro.switchTab({ url: '/pages/stores/index' });
|
||||
}
|
||||
|
||||
if (!store) {
|
||||
return (
|
||||
<PageShell variant="scroll" className="store-detail-page">
|
||||
<PageNavBar title="门店详情" solid onBack={goBack} />
|
||||
<View className="page-with-nav-bar u-empty">加载中…</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const images =
|
||||
(store.carouselUrls && store.carouselUrls.length > 0
|
||||
? store.carouselUrls
|
||||
: store.coverUrl
|
||||
? [store.coverUrl]
|
||||
: []) as string[];
|
||||
|
||||
return (
|
||||
<PageShell variant="scroll" className="store-detail-page" hasFixedFooter>
|
||||
<PageNavBar
|
||||
title={store.name}
|
||||
solid={headerSolid}
|
||||
titleVisible={headerSolid}
|
||||
onBack={goBack}
|
||||
/>
|
||||
|
||||
<View className="store-detail-hero full-bleed">
|
||||
<ProductCarousel images={images} alt={store.name} variant="store" />
|
||||
</View>
|
||||
|
||||
<View className="store-detail-info-card">
|
||||
<Text className="store-detail-name">{store.name}</Text>
|
||||
<Text className="store-detail-meta">
|
||||
{store.district ? `${store.district} · ` : ''}
|
||||
{store.address || '地址待完善'}
|
||||
</Text>
|
||||
<Text className="store-detail-meta">
|
||||
营业时间: {store.openTime && store.closeTime ? `${store.openTime}-${store.closeTime}` : '10:00-22:00'}
|
||||
</Text>
|
||||
{store.phone ? <Text className="store-detail-meta">电话: {store.phone}</Text> : null}
|
||||
<View className="store-detail-tags">
|
||||
{store.category?.name ? (
|
||||
<Text className="store-detail-tag">{store.category.name}</Text>
|
||||
) : null}
|
||||
<Text className="store-detail-tag">可核销</Text>
|
||||
<Text className="store-detail-tag">好客门店</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="store-detail-section">
|
||||
<Text className="store-detail-section-title">门店服务</Text>
|
||||
<Text className="store-detail-meta">支持好客权益到店核销,部分门店提供包间预约。</Text>
|
||||
</View>
|
||||
|
||||
<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"
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
|
||||
>
|
||||
<Text>去核销</Text>
|
||||
</View>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { useDidShow } from '@tarojs/taro';
|
||||
import { View, Text, Image, Input } from '@tarojs/components';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import TabMainHeader from '../../components/TabMainHeader';
|
||||
import RegionPicker from '../../components/RegionPicker';
|
||||
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
||||
import { request, toast } from '../../lib/api';
|
||||
|
||||
@@ -9,12 +11,23 @@ type Store = {
|
||||
id: string;
|
||||
name: string;
|
||||
address?: string;
|
||||
district?: string;
|
||||
coverUrl?: string | null;
|
||||
openTime?: string | null;
|
||||
closeTime?: string | null;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
const CATEGORY_TABS = ['全部', '火锅', '地方菜', '高端餐饮', '烧烤烤肉', '西餐'] as const;
|
||||
const MOCK_DISTANCES = ['800m', '1.2km', '3.5km', '1.5km', '2.0km'];
|
||||
|
||||
export default function StoresPage() {
|
||||
const [stores, setStores] = useState<Store[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [categoryTab, setCategoryTab] = useState<string>('全部');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [regionLabel, setRegionLabel] = useState('郑州市 · 全部区域');
|
||||
const [regionOpen, setRegionOpen] = useState(false);
|
||||
|
||||
useDidShow(() => {
|
||||
syncTabBarSelected(1);
|
||||
@@ -27,29 +40,93 @@ export default function StoresPage() {
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const filtered = stores.filter((s) => {
|
||||
if (!keyword.trim()) return true;
|
||||
const q = keyword.trim();
|
||||
return s.name.includes(q) || (s.address ?? '').includes(q) || (s.district ?? '').includes(q);
|
||||
});
|
||||
|
||||
function formatHours(store: Store) {
|
||||
if (store.openTime && store.closeTime) {
|
||||
return `营业时间: ${store.openTime}-${store.closeTime}`;
|
||||
}
|
||||
return '营业时间: 10:00-22:00';
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="u-page u-page--tab">
|
||||
<PageShell variant="tab" className="store-page">
|
||||
<TabMainHeader title="门店" />
|
||||
<View className="u-card">
|
||||
{loading ? (
|
||||
<View className="u-empty">加载中…</View>
|
||||
) : stores.length === 0 ? (
|
||||
<View className="u-empty">暂无营业中门店</View>
|
||||
) : (
|
||||
stores.map((s) => (
|
||||
<View key={s.id} className="u-store-row">
|
||||
<View className="u-store-avatar" />
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text className="u-store-name">{s.name}</Text>
|
||||
<Text className="u-muted" style={{ display: 'block', marginTop: '4px' }}>
|
||||
<View className="store-toolbar">
|
||||
<View className="store-location" onClick={() => setRegionOpen(true)}>
|
||||
<View className="store-location-pin" />
|
||||
<Text>{regionLabel} ▾</Text>
|
||||
</View>
|
||||
<Input
|
||||
className="store-search"
|
||||
placeholder="搜索门店名称或地址"
|
||||
value={keyword}
|
||||
onInput={(e) => setKeyword(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="store-category-tabs">
|
||||
{CATEGORY_TABS.map((tab) => (
|
||||
<Text
|
||||
key={tab}
|
||||
className={`store-category-tab${categoryTab === tab ? ' store-category-tab--active' : ''}`}
|
||||
onClick={() => setCategoryTab(tab)}
|
||||
>
|
||||
{tab}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View className="store-list">
|
||||
{loading ? <View className="u-empty">加载中…</View> : null}
|
||||
{!loading && filtered.length === 0 ? <View className="u-empty">暂无营业中门店</View> : null}
|
||||
{!loading &&
|
||||
filtered.map((s, index) => (
|
||||
<View
|
||||
key={s.id}
|
||||
className="store-card"
|
||||
onClick={() => Taro.navigateTo({ url: `/pages/store-detail/index?id=${s.id}` })}
|
||||
>
|
||||
{s.coverUrl ? (
|
||||
<Image className="store-card-cover" src={s.coverUrl} mode="aspectFill" />
|
||||
) : (
|
||||
<View className="store-card-cover--empty" />
|
||||
)}
|
||||
<View className="store-card-body">
|
||||
<Text className="store-card-name">{s.name}</Text>
|
||||
<Text className="store-card-meta">
|
||||
{s.district ? `${s.district} · ` : ''}
|
||||
{s.address || '地址待完善'}
|
||||
</Text>
|
||||
<Text className="store-card-meta">{formatHours(s)}</Text>
|
||||
<View className="store-card-footer">
|
||||
<Text className="store-card-distance">{MOCK_DISTANCES[index % MOCK_DISTANCES.length]}</Text>
|
||||
<Text
|
||||
className="store-card-cta"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
Taro.navigateTo({ url: '/pages/redeem/index' });
|
||||
}}
|
||||
>
|
||||
去核销
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
))}
|
||||
</View>
|
||||
|
||||
{shouldRenderPageTabBar() ? <UserTabBar selected={1} /> : null}
|
||||
</View>
|
||||
<RegionPicker
|
||||
open={regionOpen}
|
||||
valueLabel="郑州市"
|
||||
onClose={() => setRegionOpen(false)}
|
||||
onConfirm={(label) => setRegionLabel(`${label} · 全部区域`)}
|
||||
/>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user