登录页面

This commit is contained in:
2026-07-12 15:17:54 +08:00
parent becf91da52
commit bfac6c6663
63 changed files with 4662 additions and 431 deletions
@@ -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>
);
}
+166 -19
View File
@@ -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>
);
}
+24 -23
View File
@@ -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: '登录',
});
-89
View File
@@ -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;
}
+221 -64
View File
@@ -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>
);
}
+196 -31
View File
@@ -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: '我的订单',
});
+96
View File
@@ -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: '收银台',
});
+47
View File
@@ -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: '权益核销',
});
+71
View File
@@ -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>
);
}
+95 -18
View File
@@ -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>
);
}