登录页面

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
+14 -19
View File
@@ -1,6 +1,6 @@
# @dukang/mini-user
杜康好客 C 端微信小程序(Taro 4 + React)。
杜康好客 C 端微信小程序(Taro 4 + React)。UI 对齐 `apps/h5-user`
## 启动
@@ -19,27 +19,22 @@ pnpm --filter @dukang/mini-user dev:weapp
| 环节 | 文件 | 说明 |
|------|------|------|
| **编译注入** | `config/index.ts``defineConstants.TARO_APP_API_ORIGIN` | 默认 `https://dkapi.runxian.top`;可用环境变量 `VITE_API_TARGET` 覆盖 |
| **运行时拼装** | `src/lib/api.ts``resolveApiBase()` | `{origin}/api/v1`,例如 `https://dkapi.runxian.top/api/v1` |
| **运行时拼装** | `src/lib/api.ts``resolveApiBase()` | `{origin}/api/v1` |
本地联调 API`localhost:3000`)时,启动前设置环境变量并重新编译:
## 页面结构(18 页)
```powershell
$env:VITE_API_TARGET = "http://localhost:3000"
pnpm dev:mini-user:weapp
```
**Tab**:首页 / 门店 / 好客权益 / 我的
微信小程序还需在公众平台配置 request 合法域名:`dkapi.runxian.top`
**栈页**:商品详情、门店详情、确认订单、收银台、订单列表/详情、地址列表/编辑、客服、权益明细、核销/核销码/成功、登录
## 布局约定
- 统一 `PageShell``tab` / `scroll` / `sub` / `plain`)注入刘海屏 CSS 变量
- Tab 顶栏:`TabMainHeader`;详情滚动顶栏:`PageNavBar`;子页:`SubPageHeader`
- 样式:`src/styles/*.css` 按业务域拆分,入口 `app.css`
## 约定
- `X-Client-App: USER_MINI`(与 `USER_H5` 共用用户端 APIJWT 与 Header 一致)
- 功能对照源:`apps/h5-user` + `pages/user/` 原型
- 底栏使用本地 PNG 图标(weapp 无法加载 Google Material 字体)
- Tab 页:`navigationStyle: custom` + `TabMainHeader`,对齐 h5 浅色顶栏
## 迁移路线(建议)
1. 登录(短信 → 小程序 `wx.login` + 手机号组件)
2. 商品详情 / 下单 / 支付
3. 订单 3 Tab、权益、核销码
4. 地址管理、门店详情
- `X-Client-App: USER_MINI`
- 底栏原生 `tabBar` + PNGweapp);H5 内嵌 `UserTabBar`
- 交易/核销页面以 UI 壳为主,部分 API 可后续加深
-11
View File
@@ -1,11 +0,0 @@
import type { UserConfigExport } from '@tarojs/cli';
/**
* dev:weapp 下 Vite 会用 prod 模式替换 react 引用,可能导致 useState 等 hooks 失效。
* @see https://docs.taro.zone/docs/config-detail#minidebugreact
*/
export default {
mini: {
debugReact: true,
},
} satisfies UserConfigExport;
+2
View File
@@ -39,6 +39,8 @@ export default defineConfig(async () => ({
enable: false,
},
mini: {
/** dev:weapp 预览模式需开启,否则 React hooks 在 Vite 下会失效 */
debugReact: process.env.NODE_ENV !== 'production',
postcss: {
pxtransform: { enable: true, config: {} },
cssModules: { enable: false },
+1 -1
View File
@@ -5,7 +5,7 @@
"description": "杜康好客 · C 端用户微信小程序(Taro)",
"scripts": {
"dev": "taro build --type h5 --watch",
"dev:weapp": "taro build --type weapp --watch",
"dev:weapp": "taro build --type weapp --watch --mode development",
"build": "taro build --type h5",
"build:weapp": "taro build --type weapp",
"lint": "echo ok"
+13
View File
@@ -4,6 +4,19 @@ export default defineAppConfig({
'pages/stores/index',
'pages/benefit/index',
'pages/mine/index',
'pages/product-detail/index',
'pages/store-detail/index',
'pages/order-confirm/index',
'pages/pay/index',
'pages/orders/index',
'pages/order-detail/index',
'pages/addresses/index',
'pages/address-edit/index',
'pages/customer-service/index',
'pages/benefit-detail/index',
'pages/redeem/index',
'pages/redeem-code/index',
'pages/redeem-success/index',
'pages/login/index',
],
window: {
+11 -92
View File
@@ -1,5 +1,16 @@
@import './styles/tokens.css';
@import './styles/base.css';
@import './styles/nav-bar.css';
@import './styles/home.css';
@import './styles/stores.css';
@import './styles/benefit.css';
@import './styles/mine.css';
@import './styles/product-detail.css';
@import './styles/store-detail.css';
@import './styles/login.css';
@import './styles/order.css';
@import './styles/address.css';
@import './styles/redeem.css';
page,
body {
@@ -8,95 +19,3 @@ body {
font-family: var(--font-body);
margin: 0;
}
.u-page {
min-height: 100vh;
padding-bottom: calc(24px + env(safe-area-inset-bottom, 0px));
background: var(--color-background);
box-sizing: border-box;
}
.u-page--tab {
padding-bottom: calc(88px + env(safe-area-inset-bottom, 0px));
}
.u-card {
background: var(--color-card);
border-radius: var(--radius-lg);
margin: 12px var(--space-page);
padding: 16px;
box-shadow: var(--shadow-card);
box-sizing: border-box;
}
.u-muted {
color: var(--color-subtle-gray);
font-size: 13px;
}
.u-title {
font-family: var(--font-headline);
font-size: 18px;
font-weight: 600;
margin: 0 0 8px;
color: var(--color-on-surface);
}
.u-btn {
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--radius-md);
padding: 12px 18px;
font-size: 15px;
font-weight: 600;
line-height: 1.2;
background: var(--color-heritage-red);
color: #fff;
}
.u-btn--block {
width: 100%;
}
.u-btn:active {
opacity: 0.85;
}
.u-empty {
text-align: center;
padding: 48px 24px;
color: var(--color-subtle-gray);
font-size: 14px;
}
.u-store-row {
display: flex;
gap: 12px;
align-items: center;
padding: 14px 0;
border-bottom: 1px solid var(--color-surface-container);
}
.u-store-row:last-child {
border-bottom: none;
}
.u-store-avatar {
width: 48px;
height: 48px;
border-radius: 12px;
background: rgba(166, 29, 36, 0.08);
flex-shrink: 0;
}
.u-store-name {
font-size: 15px;
font-weight: 600;
color: var(--color-on-surface);
}
.u-safe-top {
padding-top: env(safe-area-inset-top, 0px);
}
@@ -0,0 +1,48 @@
import type { ReactNode } from 'react';
import { View, Text } from '@tarojs/components';
import { navBarStyle, useNavBarMetrics } from '../lib/nav-bar';
type PageNavBarProps = {
title: string;
solid?: boolean;
titleVisible?: boolean;
onBack?: () => void;
right?: ReactNode;
};
/** 内页自定义导航栏(返回 + 标题 + 右侧操作),适配刘海屏 */
export default function PageNavBar({
title,
solid = false,
titleVisible = true,
onBack,
right,
}: PageNavBarProps) {
const metrics = useNavBarMetrics();
return (
<View
className={`page-nav-bar${solid ? ' page-nav-bar--solid' : ''}`}
style={navBarStyle(metrics)}
>
<View
className="page-nav-bar__content"
style={{ height: `${metrics.navContentHeight}px` }}
>
{onBack ? (
<View className="page-nav-bar__btn" onClick={onBack}>
<Text className="page-nav-bar__icon"></Text>
</View>
) : (
<View className="page-nav-bar__btn page-nav-bar__btn--placeholder" />
)}
<Text
className={`page-nav-bar__title${titleVisible ? ' page-nav-bar__title--visible' : ''}`}
>
{title}
</Text>
{right ?? <View className="page-nav-bar__btn page-nav-bar__btn--placeholder" />}
</View>
</View>
);
}
@@ -0,0 +1,39 @@
import type { PropsWithChildren, ReactNode } from 'react';
import { View } from '@tarojs/components';
import { pageShellCssVars, useNavBarMetrics } from '../lib/nav-bar';
type PageShellVariant = 'tab' | 'scroll' | 'sub' | 'plain';
type PageShellProps = PropsWithChildren<{
variant?: PageShellVariant;
className?: string;
/** 栈页固定底栏时额外底部留白 */
hasFixedFooter?: boolean;
}>;
/**
* 页面壳:唯一注入刘海屏 CSS 变量;统一 min-height / 底栏安全区。
* 顶栏组件自行调用 navBarStyle,不要在根节点再套 navBarStyle。
*/
export default function PageShell({
variant = 'tab',
className = '',
hasFixedFooter = false,
children,
}: PageShellProps) {
const metrics = useNavBarMetrics();
const classes = [
'page-shell',
`page-shell--${variant}`,
hasFixedFooter ? 'page-shell--fixed-footer' : '',
className,
]
.filter(Boolean)
.join(' ');
return (
<View className={classes} style={pageShellCssVars(metrics)}>
{children as ReactNode}
</View>
);
}
@@ -0,0 +1,46 @@
import { useState } from 'react';
import { View, Image, Swiper, SwiperItem } from '@tarojs/components';
type ProductCarouselProps = {
images: string[];
alt: string;
variant?: 'home' | 'detail' | 'store';
};
/** 商品/门店轮播(对齐 h5 ProductCarousel 三变体) */
export default function ProductCarousel({ images, alt, variant = 'detail' }: ProductCarouselProps) {
const slides = images.length > 0 ? images : [''];
const [activeIndex, setActiveIndex] = useState(0);
const prefix =
variant === 'store' ? 'store-detail-carousel' : variant === 'home' ? 'home-carousel' : 'detail-carousel';
return (
<View className={`${prefix}-wrap`}>
<Swiper
className={prefix}
circular={slides.length > 1}
onChange={(e) => setActiveIndex(e.detail.current)}
>
{slides.map((src, index) => (
<SwiperItem key={`${src}-${index}`} className={`${prefix}-item`}>
{src ? (
<Image className={`${prefix}-image`} src={src} mode="aspectFill" alt={alt} />
) : (
<View className={`${prefix}-placeholder`} />
)}
</SwiperItem>
))}
</Swiper>
{slides.length > 1 ? (
<View className={`${prefix}-dots`}>
{slides.map((_, index) => (
<View
key={index}
className={`${prefix}-dot${index === activeIndex ? ` ${prefix}-dot--active` : ''}`}
/>
))}
</View>
) : null}
</View>
);
}
@@ -0,0 +1,49 @@
import { View, Text } from '@tarojs/components';
type RegionPickerProps = {
open: boolean;
valueLabel?: string;
onClose: () => void;
onConfirm?: (label: string) => void;
};
/**
* 区域选择弹层占位(门店/地址后续可接真实级联数据)。
* 当前提供「郑州市」确认交互,避免阻断主流程。
*/
export default function RegionPicker({
open,
valueLabel = '郑州市',
onClose,
onConfirm,
}: RegionPickerProps) {
if (!open) return null;
return (
<View className="region-picker-mask" onClick={onClose}>
<View className="region-picker-sheet" onClick={(e) => e.stopPropagation()}>
<View className="region-picker-head">
<Text className="region-picker-cancel" onClick={onClose}>
</Text>
<Text className="region-picker-title"></Text>
<Text
className="region-picker-ok"
onClick={() => {
onConfirm?.(valueLabel);
onClose();
}}
>
</Text>
</View>
<View className="region-picker-body">
<Text className="region-picker-item region-picker-item--active">{valueLabel}</Text>
<Text className="u-muted" style={{ display: 'block', marginTop: 12, textAlign: 'center' }}>
</Text>
</View>
</View>
</View>
);
}
@@ -0,0 +1,43 @@
import type { ReactNode } from 'react';
import { View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { navBarStyle, useNavBarMetrics } from '../lib/nav-bar';
type SubPageHeaderProps = {
title: string;
onBack?: () => void;
right?: ReactNode;
};
/** 子页面固定实底顶栏(确认订单 / 地址 / 支付 / 订单列表等) */
export default function SubPageHeader({ title, onBack, right }: SubPageHeaderProps) {
const metrics = useNavBarMetrics();
function handleBack() {
if (onBack) {
onBack();
return;
}
const pages = Taro.getCurrentPages();
if (pages.length > 1) {
Taro.navigateBack();
return;
}
Taro.switchTab({ url: '/pages/home/index' });
}
return (
<View className="sub-page-header" style={navBarStyle(metrics)}>
<View
className="sub-page-header__content"
style={{ height: `${metrics.navContentHeight}px` }}
>
<View className="sub-page-header__back" onClick={handleBack}>
<Text className="sub-page-header__back-icon"></Text>
</View>
<Text className="sub-page-header__title">{title}</Text>
{right ? <View className="sub-page-header__right">{right}</View> : null}
</View>
</View>
);
}
@@ -1,16 +1,25 @@
import type { ReactNode } from 'react';
import { View, Text } from '@tarojs/components';
import { navBarStyle, useNavBarMetrics } from '../lib/nav-bar';
type TabMainHeaderProps = {
title: string;
extra?: ReactNode;
};
/** Tab 页顶栏:适配刘海屏 + 微信胶囊避让 */
export default function TabMainHeader({ title, extra }: TabMainHeaderProps) {
const metrics = useNavBarMetrics();
return (
<View className="tab-main-header">
<View className="tab-main-header" style={navBarStyle(metrics)}>
<View
className="tab-main-header__content"
style={{ height: `${metrics.navContentHeight}px` }}
>
<Text className="tab-main-header__title">{title}</Text>
{extra ? <View className="tab-main-header__extra">{extra}</View> : null}
</View>
</View>
);
}
@@ -1,15 +0,0 @@
import { Component } from 'react';
import UserTabBar from '../components/UserTabBar';
/** 小程序 custom-tab-bar 入口;H5 由各 Tab 页直接渲染 UserTabBar */
export default class CustomTabBar extends Component {
state = { selected: 0 };
setSelected(index: number) {
this.setState({ selected: index });
}
render() {
return <UserTabBar selected={this.state.selected} />;
}
}
+3 -1
View File
@@ -1,5 +1,6 @@
import Taro from '@tarojs/taro';
import { ClientApp } from '@dukang/shared-types';
import { goLogin } from './auth-nav';
function resolveApiBase(): string {
const origin =
@@ -52,7 +53,7 @@ export function isLoggedIn(): boolean {
}
export function redirectToLogin() {
Taro.navigateTo({ url: '/pages/login/index' });
goLogin();
}
export function logout() {
@@ -119,4 +120,5 @@ export type UserProfile = {
id: string;
phone?: string | null;
nickname?: string | null;
avatarUrl?: string | null;
};
+60
View File
@@ -0,0 +1,60 @@
import Taro from '@tarojs/taro';
const TAB_PAGES = new Set([
'/pages/home/index',
'/pages/stores/index',
'/pages/benefit/index',
'/pages/mine/index',
]);
function currentPagePath(): string {
const pages = Taro.getCurrentPages();
const cur = pages[pages.length - 1] as
| { route?: string; options?: Record<string, string | undefined> }
| undefined;
if (!cur?.route) return '';
const path = cur.route.startsWith('/') ? cur.route : `/${cur.route}`;
if (path.includes('/pages/login/')) return '';
const opts = cur.options ?? {};
const qs = Object.entries(opts)
.filter(([, v]) => v != null && v !== '')
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
.join('&');
return qs ? `${path}?${qs}` : path;
}
/** 跳转登录页;默认带回当前页作为 return */
export function goLogin(returnPath?: string) {
const returnTo = returnPath ?? currentPagePath();
const url = returnTo
? `/pages/login/index?return=${encodeURIComponent(returnTo)}`
: '/pages/login/index';
Taro.navigateTo({ url }).catch(() => {
Taro.redirectTo({ url });
});
}
/** 登录成功后回到 return 页,或回退 / 首页 */
export function finishLoginNavigate(returnTo?: string) {
const raw = (returnTo || '').trim();
const target = raw ? decodeURIComponent(raw) : '';
const pathOnly = target.split('?')[0];
if (pathOnly && TAB_PAGES.has(pathOnly)) {
Taro.switchTab({ url: pathOnly });
return;
}
if (pathOnly && pathOnly.startsWith('/pages/')) {
Taro.redirectTo({ url: target }).catch(() => {
Taro.reLaunch({ url: pathOnly });
});
return;
}
const pages = Taro.getCurrentPages();
if (pages.length > 1) {
Taro.navigateBack();
return;
}
Taro.switchTab({ url: '/pages/home/index' });
}
+82
View File
@@ -0,0 +1,82 @@
import { useMemo } from 'react';
import Taro from '@tarojs/taro';
export type NavBarMetrics = {
/** 状态栏高度(刘海/灵动岛上方) */
statusBarHeight: number;
/** 导航栏总高度 = 状态栏 + 内容区 */
navBarHeight: number;
/** 标题栏内容区高度(与胶囊对齐) */
navContentHeight: number;
/** 右侧留白,避免与微信胶囊按钮重叠 */
navBarPaddingRight: number;
};
const H5_FALLBACK: NavBarMetrics = {
statusBarHeight: 0,
navBarHeight: 56,
navContentHeight: 56,
navBarPaddingRight: 16,
};
const WEAPP_FALLBACK: NavBarMetrics = {
statusBarHeight: 20,
navBarHeight: 64,
navContentHeight: 44,
navBarPaddingRight: 96,
};
/** 计算小程序自定义导航栏尺寸(对齐微信胶囊按钮) */
export function getNavBarMetrics(): NavBarMetrics {
if (process.env.TARO_ENV === 'h5') {
return H5_FALLBACK;
}
try {
const win = Taro.getWindowInfo?.() ?? Taro.getSystemInfoSync();
const menu = Taro.getMenuButtonBoundingClientRect();
const statusBarHeight = win.statusBarHeight ?? WEAPP_FALLBACK.statusBarHeight;
const navContentHeight =
menu.height > 0
? (menu.top - statusBarHeight) * 2 + menu.height
: WEAPP_FALLBACK.navContentHeight;
const navBarHeight = statusBarHeight + navContentHeight;
const navBarPaddingRight =
menu.width > 0
? Math.max(win.windowWidth - menu.left + 8, 16)
: WEAPP_FALLBACK.navBarPaddingRight;
return {
statusBarHeight,
navBarHeight,
navContentHeight,
navBarPaddingRight,
};
} catch {
return WEAPP_FALLBACK;
}
}
export function useNavBarMetrics(): NavBarMetrics {
return useMemo(() => getNavBarMetrics(), []);
}
/** 仅注入 CSS 变量,供 PageShell / sticky 子元素使用(不加 height/padding */
export function pageShellCssVars(metrics: NavBarMetrics): Record<string, string> {
return {
'--nav-bar-height': `${metrics.navBarHeight}px`,
'--nav-content-height': `${metrics.navContentHeight}px`,
'--nav-status-bar-height': `${metrics.statusBarHeight}px`,
'--nav-padding-right': `${metrics.navBarPaddingRight}px`,
};
}
/** 顶栏自身样式:statusBar padding + 总高 + 右侧胶囊避让 */
export function navBarStyle(metrics: NavBarMetrics): Record<string, string | number> {
return {
paddingTop: `${metrics.statusBarHeight}px`,
height: `${metrics.navBarHeight}px`,
paddingRight: `${metrics.navBarPaddingRight}px`,
...pageShellCssVars(metrics),
};
}
+12
View File
@@ -30,4 +30,16 @@ export function getProductImages(source?: ProductImageSource | null): string[] {
return PRODUCT_IMAGE_FALLBACK ? [PRODUCT_IMAGE_FALLBACK] : [];
}
/** 详情页顶部轮播 */
export function getProductCarouselImages(source?: ProductImageSource | null): string[] {
const carousel = uniqueUrls(source?.carouselUrls ?? []);
if (carousel.length > 0) return carousel;
return getProductImages(source);
}
/** 详情页图文长图 */
export function getProductDetailImages(source?: ProductImageSource | null): string[] {
return uniqueUrls(source?.detailImageUrls ?? []);
}
export const FALLBACK_CITY_CODE = '410100';
+15
View File
@@ -0,0 +1,15 @@
import type { WechatLoginResult } from '@dukang/shared-types';
import Taro from '@tarojs/taro';
import { request } from './api';
/** 小程序微信授权登录:Taro.login → /auth/login/wechat */
export async function loginWithWechat(): Promise<WechatLoginResult> {
const res = await Taro.login();
if (!res.code) {
throw new Error(res.errMsg || '微信登录失败,未获取到 code');
}
return request<WechatLoginResult>('/auth/login/wechat', {
method: 'POST',
data: { code: res.code, platform: 'mini' },
});
}
@@ -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>
);
}
+164 -17
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' })}
<PageShell variant="tab" className="benefit-page">
<View className="benefit-header" style={navBarStyle(metrics)}>
<View
className="benefit-header__content"
style={{ height: `${metrics.navContentHeight}px` }}
>
</Button>
<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>
);
}
+16 -15
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,14 +92,11 @@ 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 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>
@@ -104,11 +107,9 @@ export default function HomePage() {
<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;
}
+203 -46
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-main">
<View className="login-card">
<Text className="login-card-title"></Text>
<Text className="login-card-title">
{bindMode ? '绑定手机号' : '手机验证码登录'}
</Text>
<View className="login-field">
<Text className="login-field-prefix">+86</Text>
<Input
className="login-input"
className="login-field-input"
type="number"
maxlength={11}
placeholder="请输入手机号"
value={phone}
onInput={(e) => setPhone(e.detail.value)}
onInput={(e) => {
setPhone(normalizePhone(e.detail.value));
setMsg('');
setSentHint('');
}}
/>
<View className="login-code-row">
</View>
<View className="login-field">
<Input
className="login-input login-input--code"
className="login-field-input"
type="number"
maxlength={6}
placeholder="验证码"
placeholder="请输入验证码"
value={code}
onInput={(e) => setCode(e.detail.value)}
onInput={(e) => setCode(e.detail.value.replace(/\D/g, '').slice(0, 6))}
/>
<Button className="login-send" disabled={cooldown > 0} onClick={() => void sendCode()}>
{cooldown > 0 ? `${cooldown}s` : '获取验证码'}
</Button>
<Text
className={`login-get-code${codeDisabled ? ' login-get-code--disabled' : ''}`}
onClick={() => void onSendCode()}
>
{sending ? '发送中...' : cooldown > 0 ? `${cooldown}s 后重新获取` : '获取验证码'}
</Text>
</View>
{msg ? <Text className="login-msg">{msg}</Text> : null}
{displayMsg ? (
<Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}>
{displayMsg}
</Text>
) : null}
<Button
className="u-btn u-btn--primary u-btn--block"
className="login-sms-btn"
loading={loading}
disabled={loading}
onClick={() => void login()}
>
{loading ? '登录中...' : bindMode ? '绑定并登录' : '登录'}
</Button>
<Text className="u-muted" style={{ textAlign: 'center', marginTop: 8 }}>
Mock 123456
</View>
{!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 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>
);
}
+192 -27
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 (
<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">
<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>
<Button
<View
className="u-btn u-btn--block"
onClick={() => Taro.navigateTo({ url: '/pages/login/index' })}
onClick={() => goLogin('/pages/mine/index')}
>
</Button>
<Text></Text>
</View>
)}
</View>
{shouldRenderPageTabBar() ? <UserTabBar selected={3} /> : null}
</PageShell>
);
}
const nickname = profile?.nickname || '用户';
const avatarUrl = profile?.avatarUrl;
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">
{avatarUrl ? (
<Image className="mine-avatar-img" src={avatarUrl} mode="aspectFill" />
) : (
<Text>{nickname.slice(0, 1)}</Text>
)}
</View>
<View>
<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}
</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>
);
}
+94 -17
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>
<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" />
) : (
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-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>
);
}
+107
View File
@@ -0,0 +1,107 @@
/* 地址管理 */
.address-page {
background: var(--color-background);
min-height: 100vh;
}
.address-item {
background: var(--color-card);
border-radius: var(--radius-lg);
margin: 12px var(--space-page);
padding: 16px;
box-shadow: var(--shadow-card);
}
.address-item-head {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.address-item-name {
font-size: 15px;
font-weight: 700;
margin-right: 10px;
}
.address-item-phone {
font-size: 14px;
color: var(--color-on-surface-variant);
}
.address-default-tag {
margin-left: auto;
padding: 2px 8px;
border-radius: 999px;
background: rgba(166, 29, 36, 0.08);
color: var(--color-heritage-red);
font-size: 11px;
}
.address-item-detail {
display: block;
font-size: 13px;
color: var(--color-on-surface-variant);
line-height: 1.5;
margin-bottom: 12px;
}
.address-item-actions {
display: flex;
justify-content: flex-end;
}
.address-action {
font-size: 13px;
color: var(--color-heritage-red);
margin-left: 16px;
}
.address-fab {
position: fixed;
left: 16px;
right: 16px;
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
height: 48px;
border-radius: 999px;
background: var(--color-heritage-red);
color: #fff;
font-size: 16px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.address-form-field {
margin: 0 var(--space-page) 12px;
}
.address-form-label {
display: block;
font-size: 13px;
color: var(--color-on-surface-variant);
margin-bottom: 6px;
}
.address-form-input {
width: 100%;
height: 48px;
padding: 0 14px;
border-radius: var(--radius-md);
background: var(--color-card);
box-shadow: var(--shadow-card);
font-size: 15px;
box-sizing: border-box;
}
.address-form-textarea {
width: 100%;
min-height: 88px;
padding: 12px 14px;
border-radius: var(--radius-md);
background: var(--color-card);
box-shadow: var(--shadow-card);
font-size: 15px;
box-sizing: border-box;
}
+97
View File
@@ -0,0 +1,97 @@
/* 全局基础类(对齐 shared-ui / h5-user,适配 Taro */
.app-page-title {
margin: 0;
font-family: var(--font-headline);
font-size: 18px;
font-weight: 700;
line-height: 26px;
color: var(--color-heritage-red);
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.full-bleed {
width: 100%;
margin-left: 0;
margin-right: 0;
}
.inset-page {
padding-left: var(--space-page);
padding-right: var(--space-page);
box-sizing: border-box;
}
.u-card {
background: var(--color-card);
border-radius: var(--radius-lg);
margin: 12px var(--space-page);
padding: 16px;
box-shadow: var(--shadow-card);
box-sizing: border-box;
}
.u-muted {
color: var(--color-subtle-gray);
font-size: 13px;
}
.u-title {
font-family: var(--font-headline);
font-size: 18px;
font-weight: 600;
margin: 0 0 8px;
color: var(--color-on-surface);
}
.u-btn {
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--radius-md);
padding: 12px 18px;
font-size: 15px;
font-weight: 600;
line-height: 1.2;
background: var(--color-heritage-red);
color: #fff;
}
.u-btn--block {
width: 100%;
}
.u-btn--ghost {
background: transparent;
color: var(--color-heritage-red);
border: 1px solid var(--color-heritage-red);
}
.u-btn:active {
opacity: 0.85;
}
.u-empty {
text-align: center;
padding: 48px 24px;
color: var(--color-subtle-gray);
font-size: 14px;
}
.u-safe-top {
padding-top: env(safe-area-inset-top, 0px);
}
.text-primary {
color: var(--color-heritage-red);
}
.amount-lg {
font-family: var(--font-headline);
font-size: 24px;
font-weight: 700;
color: var(--color-heritage-red);
}
+255
View File
@@ -0,0 +1,255 @@
/* 好客权益 */
.benefit-page {
background: var(--color-background);
}
.benefit-header {
position: sticky;
top: 0;
z-index: 50;
padding-left: var(--space-page);
background: var(--color-background);
box-sizing: border-box;
}
.benefit-header__content {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.benefit-header-city {
display: flex;
align-items: center;
color: var(--color-heritage-red);
font-size: 12px;
font-weight: 500;
max-width: 30vw;
}
.benefit-header-city-pin {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-heritage-red);
margin-right: 4px;
flex-shrink: 0;
}
.benefit-header-title {
font-family: var(--font-headline);
font-size: 18px;
font-weight: 700;
color: var(--color-heritage-red);
}
.benefit-header-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-on-surface-variant);
font-size: 18px;
}
.benefit-main {
padding: 16px var(--space-page) 24px;
}
.benefit-hero {
position: relative;
overflow: hidden;
padding: 16px;
border-radius: var(--radius-lg);
background: var(--color-card);
border: 1px solid rgba(226, 190, 188, 0.2);
box-shadow: 0 4px 20px rgba(166, 29, 36, 0.05);
margin-bottom: 16px;
}
.benefit-hero-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
.benefit-hero-label {
display: block;
font-size: 12px;
color: var(--color-on-surface-variant);
margin-bottom: 4px;
}
.benefit-hero-amount {
display: flex;
align-items: baseline;
color: var(--color-heritage-red);
}
.benefit-hero-symbol {
font-family: var(--font-headline);
font-size: 28px;
font-weight: 700;
}
.benefit-hero-value {
font-family: var(--font-headline);
font-size: 40px;
font-weight: 700;
line-height: 1;
}
.benefit-hero-logo {
width: 48px;
height: 48px;
border-radius: 12px;
background: rgba(166, 29, 36, 0.08);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-heritage-red);
font-weight: 700;
font-size: 18px;
}
.benefit-hero-actions {
display: flex;
margin-top: 8px;
}
.benefit-hero-link {
font-size: 13px;
color: var(--color-heritage-red);
margin-right: 16px;
}
.benefit-hero-cta {
flex: 1;
height: 44px;
border-radius: var(--radius-full);
background: var(--color-heritage-red);
color: #fff;
font-size: 15px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.benefit-tabs {
display: flex;
margin-bottom: 12px;
border-bottom: 1px solid var(--color-surface-container);
}
.benefit-tab {
margin-right: 24px;
padding: 10px 0;
font-size: 14px;
font-weight: 600;
color: var(--color-subtle-gray);
border-bottom: 2px solid transparent;
}
.benefit-tab--active {
color: var(--color-heritage-red);
border-bottom-color: var(--color-heritage-red);
}
.benefit-coupon {
position: relative;
background: var(--color-card);
border-radius: var(--radius-lg);
padding: 16px;
margin-bottom: 12px;
box-shadow: var(--shadow-card);
overflow: hidden;
}
.benefit-coupon-notch {
position: absolute;
left: -8px;
top: 50%;
width: 16px;
height: 16px;
margin-top: -8px;
border-radius: 50%;
background: var(--color-background);
}
.benefit-coupon-notch--right {
left: auto;
right: -8px;
}
.benefit-coupon-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
}
.benefit-coupon-name {
font-family: var(--font-headline);
font-size: 15px;
font-weight: 700;
color: var(--color-on-surface);
flex: 1;
margin-right: 8px;
}
.benefit-coupon-balance {
font-family: var(--font-headline);
font-size: 20px;
font-weight: 700;
color: var(--color-heritage-red);
}
.benefit-coupon-no {
display: block;
font-size: 11px;
color: var(--color-subtle-gray);
margin-bottom: 10px;
}
.benefit-progress {
height: 6px;
border-radius: 999px;
background: var(--color-surface-container);
overflow: hidden;
margin-bottom: 12px;
}
.benefit-progress-bar {
height: 100%;
background: var(--color-heritage-red);
border-radius: 999px;
}
.benefit-coupon-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.benefit-coupon-meta {
font-size: 12px;
color: var(--color-subtle-gray);
}
.benefit-coupon-btn {
padding: 6px 14px;
border-radius: var(--radius-full);
background: var(--color-heritage-red);
color: #fff;
font-size: 12px;
font-weight: 600;
}
.benefit-login-gate {
padding: 48px 24px;
text-align: center;
}
+41 -45
View File
@@ -1,42 +1,4 @@
/* Tab 主页面顶栏 + 首页(对齐 h5-user */
.tab-main-header {
position: sticky;
top: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
height: 56px;
padding: env(safe-area-inset-top, 0px) var(--space-page) 0;
padding-top: calc(env(safe-area-inset-top, 0px));
min-height: calc(56px + env(safe-area-inset-top, 0px));
background: var(--color-background);
box-shadow: var(--shadow-card);
box-sizing: border-box;
}
.tab-main-header__title {
font-family: var(--font-headline);
font-size: 18px;
font-weight: 700;
color: var(--color-heritage-red);
line-height: 56px;
}
.tab-main-header__extra {
position: absolute;
right: var(--space-page);
top: calc(env(safe-area-inset-top, 0px) + 28px);
transform: translateY(-50%);
display: flex;
align-items: center;
gap: 4px;
color: var(--color-heritage-red);
font-size: 12px;
font-weight: 500;
max-width: 46vw;
}
/* 首页(Tab 顶栏样式见 styles/nav-bar.css */
.tab-main-city-pin {
width: 14px;
height: 14px;
@@ -78,7 +40,7 @@
background: rgba(250, 249, 247, 0.95);
border-bottom: 1px solid var(--color-surface-container);
position: sticky;
top: calc(56px + env(safe-area-inset-top, 0px));
top: var(--nav-bar-height, 56px);
z-index: 40;
}
@@ -118,18 +80,52 @@
box-shadow: var(--shadow-card);
}
.home-product-cover {
.home-carousel-wrap {
position: relative;
width: 100%;
height: 200px;
aspect-ratio: 1;
background: var(--color-surface-container);
display: block;
overflow: hidden;
}
.home-product-cover--empty {
height: 200px;
.home-carousel {
width: 100%;
height: 100%;
}
.home-carousel-item,
.home-carousel-image,
.home-carousel-placeholder {
width: 100%;
height: 100%;
}
.home-carousel-placeholder {
background: var(--color-surface-container);
}
.home-carousel-dots {
position: absolute;
bottom: 12px;
left: 0;
right: 0;
display: flex;
justify-content: center;
}
.home-carousel-dot {
height: 4px;
width: 6px;
margin: 0 3px;
border-radius: 999px;
background: rgba(153, 153, 153, 0.35);
}
.home-carousel-dot--active {
width: 20px;
background: var(--color-heritage-red);
}
.home-product-body {
padding: var(--space-gutter);
display: flex;
+283
View File
@@ -0,0 +1,283 @@
/* 登录页(对齐 h5-user login-page */
.login-page {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--color-background);
color: var(--color-on-surface);
box-sizing: border-box;
padding-top: var(--nav-status-bar-height, 20px);
}
.login-header {
padding: 48px var(--space-page) 32px;
display: flex;
flex-direction: column;
align-items: center;
}
.login-logo-wrap {
position: relative;
width: 120px;
height: 120px;
margin-bottom: 28px;
}
.login-logo {
width: 100%;
height: 100%;
border-radius: var(--radius-lg);
background: var(--color-card);
box-shadow: var(--shadow-card);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-headline);
font-size: 48px;
font-weight: 700;
color: var(--color-heritage-red);
}
.login-logo-badge {
position: absolute;
bottom: -8px;
right: -8px;
background: var(--color-heritage-red);
color: #fff;
font-size: 10px;
font-weight: 700;
padding: 2px 8px;
border-radius: 999px;
line-height: 1.4;
}
.login-welcome {
text-align: center;
}
.login-welcome-title {
display: block;
font-family: var(--font-headline);
font-size: 20px;
font-weight: 600;
line-height: 28px;
color: var(--color-ink-black, #1a1a1a);
letter-spacing: -0.01em;
}
.login-welcome-sub {
display: block;
margin-top: 8px;
font-size: 16px;
line-height: 24px;
color: var(--color-on-surface-variant);
opacity: 0.8;
}
.login-main {
flex: 1;
padding: 0 var(--space-page) 32px;
width: 100%;
box-sizing: border-box;
}
.login-card {
background: var(--color-card);
border-radius: var(--radius-xl);
padding: 24px;
box-shadow: var(--shadow-card);
}
.login-card-title {
display: block;
font-family: var(--font-headline);
font-size: 18px;
font-weight: 600;
line-height: 26px;
margin-bottom: 16px;
color: var(--color-on-surface);
}
.login-field {
display: flex;
align-items: center;
border-bottom: 1px solid var(--color-surface-variant, #e8e4de);
padding-bottom: 8px;
margin-bottom: 16px;
}
.login-field-prefix {
font-size: 16px;
font-weight: 500;
color: var(--color-on-surface);
margin-right: 12px;
flex-shrink: 0;
}
.login-field-input {
flex: 1;
border: none;
background: transparent;
padding: 0;
height: 32px;
font-size: 16px;
line-height: 24px;
color: var(--color-on-surface);
min-width: 0;
}
.login-get-code {
flex-shrink: 0;
border: none;
background: transparent;
padding: 0;
margin: 0;
font-size: 14px;
font-weight: 500;
color: var(--color-heritage-red);
line-height: 1.4;
}
.login-get-code--disabled {
color: var(--color-subtle-gray);
}
.login-msg {
display: block;
font-size: 13px;
color: var(--color-heritage-red);
margin-bottom: 12px;
}
.login-msg--hint {
color: var(--color-success, #2d6a4f);
}
.login-sms-btn {
width: 100%;
height: 48px;
margin-top: 8px;
border: 1px solid rgba(166, 29, 36, 0.2);
border-radius: var(--radius-lg);
background: transparent;
color: var(--color-heritage-red);
font-family: var(--font-headline);
font-size: 18px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
padding: 0;
}
.login-sms-btn::after {
border: none;
}
.login-sms-btn[disabled] {
opacity: 0.6;
}
.login-divider {
display: flex;
align-items: center;
gap: 16px;
margin: 32px 0;
}
.login-divider-line {
flex: 1;
height: 1px;
background: linear-gradient(
90deg,
transparent 0%,
var(--color-surface-variant, #e8e4de) 50%,
transparent 100%
);
}
.login-divider-text {
font-size: 12px;
font-weight: 500;
letter-spacing: 0.05em;
color: var(--color-subtle-gray);
flex-shrink: 0;
}
.login-wechat-btn {
width: 100%;
height: 56px;
border: none;
border-radius: var(--radius-lg);
background: var(--color-heritage-red);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
font-family: var(--font-headline);
font-size: 18px;
font-weight: 600;
line-height: 1;
padding: 0;
box-shadow: 0 8px 24px rgba(166, 29, 36, 0.1);
}
.login-wechat-btn::after {
border: none;
}
.login-wechat-btn[disabled] {
opacity: 0.7;
}
.login-wechat-icon {
font-size: 22px;
line-height: 1;
}
.login-footer {
padding: 0 var(--space-page) calc(env(safe-area-inset-bottom, 0px) + 32px);
width: 100%;
box-sizing: border-box;
}
.login-agreement {
display: flex;
align-items: flex-start;
gap: 8px;
}
.login-agreement-check {
width: 16px;
height: 16px;
margin-top: 2px;
border-radius: 4px;
border: 1px solid var(--color-outline, #c8c4be);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: #fff;
box-sizing: border-box;
}
.login-agreement-check--on {
background: var(--color-heritage-red);
border-color: var(--color-heritage-red);
}
.login-agreement-text {
flex: 1;
font-size: 12px;
font-weight: 500;
line-height: 16px;
letter-spacing: 0.05em;
color: var(--color-on-surface-variant);
}
.login-agreement-link {
color: var(--color-heritage-red);
}
+257
View File
@@ -0,0 +1,257 @@
/* 我的 */
.mine-page {
background: var(--color-background);
}
.mine-header {
position: relative;
padding: 24px var(--space-page) 80px;
background: linear-gradient(135deg, #820012 0%, var(--color-heritage-red) 40%, #d4a373 100%);
overflow: hidden;
}
.mine-header-texture {
position: absolute;
inset: 0;
opacity: 0.08;
background: repeating-linear-gradient(
45deg,
transparent,
transparent 8px,
rgba(255, 255, 255, 0.04) 8px,
rgba(255, 255, 255, 0.04) 16px
);
pointer-events: none;
}
.mine-profile {
position: relative;
display: flex;
align-items: center;
z-index: 1;
}
.mine-avatar {
width: 72px;
height: 72px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.35);
background: #fff;
overflow: hidden;
flex-shrink: 0;
margin-right: 14px;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-heritage-red);
font-size: 28px;
font-weight: 700;
}
.mine-avatar-img {
width: 100%;
height: 100%;
}
.mine-profile-name {
display: block;
font-family: var(--font-headline);
font-size: 20px;
font-weight: 600;
color: #fff;
margin-bottom: 4px;
}
.mine-member-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.15);
font-size: 10px;
color: rgba(255, 255, 255, 0.9);
}
.mine-main {
margin-top: -48px;
position: relative;
z-index: 2;
padding: 0 var(--space-page) 24px;
}
.mine-card {
background: var(--color-card);
border-radius: var(--radius-lg);
padding: 16px;
box-shadow: var(--shadow-card);
margin-bottom: 12px;
}
.mine-card-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.mine-card-title {
font-family: var(--font-headline);
font-size: 16px;
font-weight: 700;
color: var(--color-on-surface);
}
.mine-card-link {
font-size: 12px;
color: var(--color-subtle-gray);
}
.mine-asset-panel {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-radius: var(--radius-md);
background: linear-gradient(135deg, #fff9e6 0%, #fff0c2 100%);
}
.mine-asset-label {
display: block;
font-size: 12px;
color: rgba(87, 69, 0, 0.8);
margin-bottom: 4px;
}
.mine-asset-amount {
display: flex;
align-items: baseline;
color: var(--color-heritage-red);
}
.mine-asset-currency {
font-size: 16px;
font-weight: 700;
margin-right: 2px;
}
.mine-asset-value {
font-family: var(--font-headline);
font-size: 28px;
font-weight: 700;
}
.mine-asset-cta {
padding: 8px 16px;
border-radius: 999px;
background: var(--color-heritage-red);
color: #fff;
font-size: 13px;
font-weight: 600;
}
.mine-order-grid {
display: flex;
justify-content: space-around;
}
.mine-order-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px;
position: relative;
width: 33%;
}
.mine-order-icon {
width: 40px;
height: 40px;
border-radius: 12px;
background: rgba(166, 29, 36, 0.08);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-heritage-red);
font-size: 18px;
margin-bottom: 6px;
}
.mine-order-badge {
position: absolute;
top: 2px;
right: 18%;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background: var(--color-heritage-red);
color: #fff;
font-size: 10px;
line-height: 16px;
text-align: center;
}
.mine-order-label {
font-size: 12px;
color: var(--color-on-surface);
}
.mine-service-grid {
display: flex;
flex-wrap: wrap;
}
.mine-service-item {
width: 25%;
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 4px;
box-sizing: border-box;
}
.mine-service-icon {
width: 36px;
height: 36px;
border-radius: 10px;
background: var(--color-surface-container-low);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-heritage-red);
font-size: 16px;
margin-bottom: 6px;
}
.mine-service-label {
font-size: 11px;
color: var(--color-on-surface);
text-align: center;
}
.mine-footer {
text-align: center;
padding: 16px 0 8px;
}
.mine-version {
display: block;
font-size: 12px;
color: var(--color-subtle-gray);
margin-bottom: 12px;
}
.mine-logout {
display: inline-flex;
padding: 10px 32px;
border-radius: var(--radius-full);
border: 1px solid rgba(166, 29, 36, 0.3);
color: var(--color-heritage-red);
font-size: 14px;
font-weight: 600;
}
.mine-login-gate {
margin-top: -32px;
position: relative;
z-index: 2;
}
+215
View File
@@ -0,0 +1,215 @@
/* 页面壳 + 三种顶栏(刘海屏 / 胶囊适配) */
.page-shell {
min-height: 100vh;
background: var(--color-background);
box-sizing: border-box;
}
.page-shell--tab {
padding-bottom: calc(88px + env(safe-area-inset-bottom, 0px));
}
.page-shell--scroll,
.page-shell--sub,
.page-shell--plain {
padding-bottom: calc(24px + env(safe-area-inset-bottom, 0px));
}
.page-shell--fixed-footer {
padding-bottom: calc(88px + env(safe-area-inset-bottom, 0px));
}
/* ── Tab 主页面顶栏(实底 sticky) ── */
.tab-main-header {
position: sticky;
top: 0;
z-index: 50;
padding-left: var(--space-page);
background: var(--color-background);
box-shadow: var(--shadow-card);
box-sizing: border-box;
}
.tab-main-header__content {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.tab-main-header__title {
font-family: var(--font-headline);
font-size: 18px;
font-weight: 700;
color: var(--color-heritage-red);
line-height: 1.2;
max-width: 52vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tab-main-header__extra {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
max-width: 40vw;
}
.tab-main-header__extra-inner {
display: flex;
align-items: center;
color: var(--color-heritage-red);
font-size: 12px;
font-weight: 500;
}
.tab-main-header__extra-inner .tab-main-city-label {
margin-left: 4px;
}
/* ── 滚动透明顶栏(商品/门店详情) ── */
.page-nav-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 50;
padding-left: var(--space-page);
background: transparent;
box-sizing: border-box;
transition: background 0.3s, box-shadow 0.3s;
}
.page-nav-bar--solid {
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.05);
}
.page-nav-bar__content {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.page-nav-bar__btn {
width: 40px;
height: 40px;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.8);
flex-shrink: 0;
}
.page-nav-bar--solid .page-nav-bar__btn {
background: transparent;
}
.page-nav-bar__btn--placeholder {
background: transparent;
pointer-events: none;
}
.page-nav-bar__icon {
font-size: 28px;
line-height: 1;
color: var(--color-ink-black);
font-weight: 300;
}
.page-nav-bar__icon--share {
font-size: 18px;
font-weight: 600;
}
.page-nav-bar__title {
flex: 1;
opacity: 0;
padding: 0 8px;
font-family: var(--font-headline);
font-size: 16px;
font-weight: 600;
color: var(--color-ink-black);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
transition: opacity 0.3s;
}
.page-nav-bar__title--visible {
opacity: 1;
}
.page-with-nav-bar {
padding-top: var(--nav-bar-height, 56px);
box-sizing: border-box;
}
/* ── 子页面固定实底顶栏 ── */
.sub-page-header {
position: sticky;
top: 0;
z-index: 50;
padding-left: var(--space-page);
background: var(--color-background);
box-shadow: var(--shadow-card);
box-sizing: border-box;
}
.sub-page-header__content {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.sub-page-header__back {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 40px;
height: 40px;
margin-left: -8px;
display: flex;
align-items: center;
justify-content: center;
}
.sub-page-header__back-icon {
font-size: 28px;
line-height: 1;
color: var(--color-heritage-red);
font-weight: 300;
}
.sub-page-header__title {
font-family: var(--font-headline);
font-size: 18px;
font-weight: 700;
color: var(--color-heritage-red);
max-width: 60vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub-page-header__right {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
}
.sub-page-body {
padding-top: 12px;
box-sizing: border-box;
}
+280
View File
@@ -0,0 +1,280 @@
/* 订单确认 / 支付 / 订单列表 / 订单详情 */
.order-confirm-page,
.pay-page,
.orders-page,
.order-detail-page {
background: var(--color-background);
min-height: 100vh;
}
.order-card {
background: var(--color-card);
border-radius: var(--radius-lg);
margin: 0 var(--space-page) 12px;
padding: 16px;
box-shadow: var(--shadow-card);
}
.order-card-title {
display: block;
font-family: var(--font-headline);
font-size: 15px;
font-weight: 700;
margin-bottom: 12px;
color: var(--color-on-surface);
}
.order-product-row {
display: flex;
align-items: center;
}
.order-product-thumb {
width: 72px;
height: 72px;
border-radius: 12px;
background: var(--color-surface-container);
margin-right: 12px;
flex-shrink: 0;
overflow: hidden;
}
.order-product-thumb-img {
width: 100%;
height: 100%;
}
.order-product-name {
display: block;
font-size: 15px;
font-weight: 600;
margin-bottom: 4px;
}
.order-product-price {
display: block;
color: var(--color-heritage-red);
font-weight: 700;
font-size: 16px;
}
.order-qty-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--color-surface-container);
}
.order-qty-controls {
display: flex;
align-items: center;
}
.order-qty-btn {
width: 32px;
height: 32px;
border-radius: 8px;
background: var(--color-surface-container-low);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: var(--color-on-surface);
}
.order-qty-value {
margin: 0 14px;
font-size: 16px;
font-weight: 600;
min-width: 24px;
text-align: center;
}
.order-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
font-size: 14px;
}
.order-row-label {
color: var(--color-on-surface-variant);
}
.order-row-value {
color: var(--color-on-surface);
font-weight: 500;
}
.order-row-value--price {
color: var(--color-heritage-red);
font-weight: 700;
}
.order-confirm-bar,
.pay-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 50;
padding: 12px 16px calc(env(safe-area-inset-bottom, 0px) + 12px);
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.95);
border-top: 1px solid rgba(226, 190, 188, 0.1);
box-sizing: border-box;
}
.order-confirm-total {
flex: 1;
}
.order-confirm-total-label {
display: block;
font-size: 12px;
color: var(--color-subtle-gray);
}
.order-confirm-total-value {
font-family: var(--font-headline);
font-size: 22px;
font-weight: 700;
color: var(--color-heritage-red);
}
.order-confirm-submit {
height: 48px;
padding: 0 28px;
border-radius: 999px;
background: var(--color-heritage-red);
color: #fff;
font-size: 16px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.order-tabs {
display: flex;
padding: 0 var(--space-page);
border-bottom: 1px solid var(--color-surface-container);
background: var(--color-background);
position: sticky;
top: var(--nav-bar-height, 56px);
z-index: 40;
}
.order-tab {
flex: 1;
text-align: center;
padding: 12px 0;
font-size: 14px;
font-weight: 600;
color: var(--color-subtle-gray);
border-bottom: 2px solid transparent;
}
.order-tab--active {
color: var(--color-heritage-red);
border-bottom-color: var(--color-heritage-red);
}
.order-list-item {
background: var(--color-card);
border-radius: var(--radius-lg);
margin: 12px var(--space-page);
padding: 16px;
box-shadow: var(--shadow-card);
}
.order-list-head {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
}
.order-list-no {
font-size: 12px;
color: var(--color-subtle-gray);
}
.order-list-status {
font-size: 13px;
font-weight: 600;
color: var(--color-heritage-red);
}
.order-list-body {
display: flex;
align-items: center;
}
.order-list-thumb {
width: 64px;
height: 64px;
border-radius: 10px;
background: var(--color-surface-container);
margin-right: 12px;
flex-shrink: 0;
}
.order-list-name {
display: block;
font-size: 14px;
font-weight: 600;
margin-bottom: 4px;
}
.order-list-meta {
display: block;
font-size: 12px;
color: var(--color-subtle-gray);
}
.order-list-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--color-surface-container);
}
.pay-status {
text-align: center;
padding: 48px 24px;
}
.pay-status-icon {
width: 72px;
height: 72px;
border-radius: 50%;
background: rgba(166, 29, 36, 0.08);
color: var(--color-heritage-red);
font-size: 36px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
}
.pay-status-title {
display: block;
font-family: var(--font-headline);
font-size: 20px;
font-weight: 700;
margin-bottom: 8px;
}
.pay-status-amount {
display: block;
font-size: 28px;
font-weight: 700;
color: var(--color-heritage-red);
margin-bottom: 24px;
}
@@ -0,0 +1,320 @@
/* 商品详情页 — 对齐 h5-user ProductDetailPage */
.product-detail-page {
background: var(--color-background);
}
.product-detail-main {
padding-bottom: 24px;
}
.product-detail-hero {
background: var(--color-card);
}
.detail-carousel-wrap {
position: relative;
width: 100%;
aspect-ratio: 1;
overflow: hidden;
}
.detail-carousel {
width: 100%;
height: 100%;
}
.detail-carousel-item,
.detail-carousel-image,
.detail-carousel-placeholder {
width: 100%;
height: 100%;
}
.detail-carousel-placeholder {
background: var(--color-surface-container);
}
.detail-carousel-dots {
position: absolute;
bottom: 16px;
left: 0;
right: 0;
display: flex;
justify-content: center;
gap: 6px;
pointer-events: none;
}
.detail-carousel-dot {
height: 4px;
width: 6px;
border-radius: 999px;
background: rgba(153, 153, 153, 0.3);
}
.detail-carousel-dot--active {
width: 24px;
background: var(--color-heritage-red);
}
.product-detail-info {
padding: var(--space-md) var(--space-page) var(--space-lg);
}
.product-detail-price {
display: flex;
align-items: baseline;
gap: 2px;
margin-bottom: 8px;
}
.product-detail-price-symbol {
font-family: var(--font-headline);
font-size: 16px;
font-weight: 600;
color: var(--color-heritage-red);
}
.product-detail-price-value {
font-family: var(--font-headline);
font-size: 32px;
font-weight: 700;
line-height: 40px;
color: var(--color-heritage-red);
}
.product-detail-name {
display: block;
font-family: var(--font-headline);
font-size: 20px;
font-weight: 700;
line-height: 28px;
color: var(--color-ink-black);
margin-bottom: 4px;
}
.product-detail-subtitle {
display: block;
font-size: 14px;
line-height: 20px;
color: var(--color-on-surface-variant);
margin-bottom: 24px;
}
.product-detail-promo {
position: relative;
padding: 16px;
border-radius: var(--radius-lg);
background: linear-gradient(135deg, #fff9e6 0%, #fff0c2 100%);
border: 1px solid rgba(255, 191, 0, 0.1);
overflow: hidden;
}
.product-detail-promo-glow {
position: absolute;
top: -16px;
right: -16px;
width: 80px;
height: 80px;
border-radius: 50%;
background: rgba(166, 29, 36, 0.05);
}
.product-detail-promo-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
position: relative;
z-index: 1;
}
.product-detail-promo-icon {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--color-heritage-red);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.product-detail-promo-icon-text {
color: #fff;
font-size: 16px;
font-weight: 700;
}
.product-detail-promo-title {
flex: 1;
font-family: var(--font-headline);
font-size: 16px;
font-weight: 700;
line-height: 22px;
color: var(--color-on-secondary-container);
}
.product-detail-promo-amount {
color: var(--color-heritage-red);
}
.product-detail-promo-desc {
display: block;
font-size: 14px;
line-height: 22px;
color: rgba(87, 69, 0, 0.8);
padding-left: 52px;
position: relative;
z-index: 1;
}
.product-detail-content {
background: var(--color-card);
border-radius: 24px 24px 0 0;
padding-top: var(--space-lg);
padding-bottom: 24px;
}
.product-detail-section-head {
display: flex;
align-items: center;
gap: 8px;
padding: 0 var(--space-page);
margin-bottom: var(--space-md);
}
.product-detail-section-bar {
width: 4px;
height: 20px;
border-radius: 999px;
background: var(--color-heritage-red);
}
.product-detail-section-title {
font-family: var(--font-headline);
font-size: 18px;
font-weight: 600;
color: var(--color-ink-black);
}
.product-detail-banner {
width: 100%;
display: block;
}
.product-detail-copy {
padding: var(--space-lg) var(--space-page);
}
.product-detail-story {
text-align: center;
margin-bottom: 24px;
}
.product-detail-story-title {
display: block;
font-family: var(--font-headline);
font-size: 18px;
font-weight: 700;
color: var(--color-heritage-red);
margin-bottom: 8px;
}
.product-detail-story-text {
display: block;
font-size: 14px;
line-height: 22px;
color: var(--color-on-surface-variant);
}
.product-detail-features {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.product-detail-feature {
width: calc(50% - 8px);
background: var(--color-surface-container-low);
border: 1px solid rgba(226, 190, 188, 0.3);
border-radius: var(--radius-lg);
padding: 16px;
text-align: center;
box-sizing: border-box;
}
.product-detail-feature-icon {
display: block;
font-size: 20px;
color: var(--color-heritage-red);
}
.product-detail-feature-title {
display: block;
font-family: var(--font-headline);
font-size: 14px;
font-weight: 600;
color: var(--color-ink-black);
margin-top: 8px;
}
.product-detail-feature-desc {
display: block;
font-size: 12px;
color: var(--color-subtle-gray);
margin-top: 4px;
}
.product-detail-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 50;
padding: 12px 16px calc(env(safe-area-inset-bottom, 0px) + 12px);
display: flex;
align-items: center;
gap: 16px;
background: rgba(255, 255, 255, 0.95);
border-top: 1px solid rgba(226, 190, 188, 0.1);
box-sizing: border-box;
}
.product-detail-bar-home {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 0 8px;
}
.product-detail-bar-home-icon {
width: 24px;
height: 24px;
}
.product-detail-bar-home-label {
font-size: 10px;
font-weight: 500;
color: var(--color-on-surface-variant);
}
.product-detail-buy-btn {
flex: 1;
height: 48px;
border-radius: 999px;
background: var(--color-heritage-red);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 24px rgba(166, 29, 36, 0.2);
}
.product-detail-buy-btn-text {
color: #fff;
font-family: var(--font-headline);
font-size: 16px;
font-weight: 700;
}
+252
View File
@@ -0,0 +1,252 @@
/* 核销 / 核销码 / 成功 / 权益明细 */
.redeem-page,
.redeem-code-page,
.redeem-success-page,
.benefit-detail-page,
.cs-page {
background: var(--color-background);
min-height: 100vh;
}
.redeem-hero {
margin: 16px var(--space-page);
padding: 24px 20px;
border-radius: var(--radius-lg);
background: linear-gradient(135deg, #fff9e6 0%, #fff0c2 100%);
text-align: center;
}
.redeem-hero-label {
display: block;
font-size: 13px;
color: rgba(87, 69, 0, 0.8);
margin-bottom: 8px;
}
.redeem-hero-amount {
font-family: var(--font-headline);
font-size: 36px;
font-weight: 700;
color: var(--color-heritage-red);
}
.redeem-input-wrap {
margin: 0 var(--space-page) 16px;
background: var(--color-card);
border-radius: var(--radius-lg);
padding: 16px;
box-shadow: var(--shadow-card);
}
.redeem-input {
width: 100%;
height: 52px;
font-size: 24px;
font-weight: 700;
color: var(--color-heritage-red);
text-align: center;
}
.redeem-tips {
margin: 0 var(--space-page);
font-size: 12px;
color: var(--color-subtle-gray);
line-height: 1.6;
}
.redeem-submit {
margin: 24px var(--space-page);
height: 48px;
border-radius: 999px;
background: var(--color-heritage-red);
color: #fff;
font-size: 16px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.redeem-code-panel {
margin: 32px var(--space-page);
padding: 32px 20px;
background: var(--color-card);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-card);
text-align: center;
}
.redeem-code-value {
display: block;
font-family: var(--font-headline);
font-size: 36px;
font-weight: 700;
letter-spacing: 0.12em;
color: var(--color-heritage-red);
margin: 16px 0;
}
.redeem-code-timer {
display: block;
font-size: 13px;
color: var(--color-subtle-gray);
}
.redeem-success-icon {
width: 80px;
height: 80px;
border-radius: 50%;
background: rgba(45, 106, 79, 0.12);
color: var(--color-success-green);
font-size: 40px;
display: flex;
align-items: center;
justify-content: center;
margin: 48px auto 16px;
}
.redeem-success-title {
display: block;
text-align: center;
font-family: var(--font-headline);
font-size: 22px;
font-weight: 700;
margin-bottom: 8px;
}
.redeem-success-desc {
display: block;
text-align: center;
font-size: 14px;
color: var(--color-subtle-gray);
margin-bottom: 32px;
}
.ledger-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 0;
border-bottom: 1px solid var(--color-surface-container);
}
.ledger-item:last-child {
border-bottom: none;
}
.ledger-title {
display: block;
font-size: 14px;
font-weight: 600;
margin-bottom: 4px;
}
.ledger-time {
display: block;
font-size: 12px;
color: var(--color-subtle-gray);
}
.ledger-amount {
font-size: 16px;
font-weight: 700;
}
.ledger-amount--in {
color: var(--color-success-green);
}
.ledger-amount--out {
color: var(--color-heritage-red);
}
.region-picker-mask {
position: fixed;
inset: 0;
z-index: 100;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: flex-end;
}
.region-picker-sheet {
width: 100%;
background: var(--color-card);
border-radius: 20px 20px 0 0;
padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px));
}
.region-picker-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--color-surface-container);
}
.region-picker-title {
font-size: 16px;
font-weight: 700;
}
.region-picker-cancel,
.region-picker-ok {
font-size: 14px;
color: var(--color-heritage-red);
padding: 4px 8px;
}
.region-picker-body {
padding: 24px 16px;
}
.region-picker-item {
display: block;
text-align: center;
padding: 12px;
font-size: 16px;
color: var(--color-on-surface);
}
.region-picker-item--active {
color: var(--color-heritage-red);
font-weight: 700;
}
.cs-bubble {
max-width: 78%;
padding: 12px 14px;
border-radius: 16px;
margin-bottom: 12px;
font-size: 14px;
line-height: 1.5;
}
.cs-bubble--bot {
background: var(--color-card);
box-shadow: var(--shadow-card);
margin-right: auto;
}
.cs-bubble--user {
background: var(--color-heritage-red);
color: #fff;
margin-left: auto;
}
.cs-quick {
display: flex;
flex-wrap: wrap;
margin: 16px var(--space-page);
}
.cs-quick-item {
padding: 8px 12px;
margin-right: 8px;
margin-bottom: 8px;
border-radius: 999px;
background: var(--color-card);
box-shadow: var(--shadow-card);
font-size: 12px;
color: var(--color-on-surface);
}
+148
View File
@@ -0,0 +1,148 @@
/* 门店详情 */
.store-detail-page {
background: var(--color-background);
}
.store-detail-hero {
background: var(--color-card);
}
.store-detail-carousel-wrap {
position: relative;
width: 100%;
height: 280px;
overflow: hidden;
}
.store-detail-carousel {
width: 100%;
height: 100%;
}
.store-detail-carousel-item,
.store-detail-carousel-image,
.store-detail-carousel-placeholder {
width: 100%;
height: 100%;
}
.store-detail-carousel-placeholder {
background: var(--color-surface-container);
}
.store-detail-carousel-dots {
position: absolute;
bottom: 16px;
left: 0;
right: 0;
display: flex;
justify-content: center;
}
.store-detail-carousel-dot {
height: 4px;
width: 6px;
margin: 0 3px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.45);
}
.store-detail-carousel-dot--active {
width: 20px;
background: #fff;
}
.store-detail-info-card {
margin: -40px var(--space-page) 16px;
position: relative;
z-index: 2;
background: var(--color-card);
border-radius: var(--radius-lg);
padding: 20px 16px;
box-shadow: var(--shadow-card);
}
.store-detail-name {
display: block;
font-family: var(--font-headline);
font-size: 20px;
font-weight: 700;
color: var(--color-ink-black);
margin-bottom: 8px;
}
.store-detail-meta {
display: block;
font-size: 13px;
color: var(--color-on-surface-variant);
margin-bottom: 6px;
line-height: 1.5;
}
.store-detail-tags {
display: flex;
flex-wrap: wrap;
margin-top: 12px;
}
.store-detail-tag {
padding: 4px 10px;
margin-right: 8px;
margin-bottom: 8px;
border-radius: 999px;
background: rgba(166, 29, 36, 0.08);
color: var(--color-heritage-red);
font-size: 11px;
}
.store-detail-section {
background: var(--color-card);
margin: 0 var(--space-page) 16px;
border-radius: var(--radius-lg);
padding: 16px;
box-shadow: var(--shadow-card);
}
.store-detail-section-title {
display: block;
font-family: var(--font-headline);
font-size: 16px;
font-weight: 700;
margin-bottom: 10px;
color: var(--color-on-surface);
}
.store-detail-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 50;
padding: 12px 16px calc(env(safe-area-inset-bottom, 0px) + 12px);
display: flex;
background: rgba(255, 255, 255, 0.95);
border-top: 1px solid rgba(226, 190, 188, 0.1);
box-sizing: border-box;
}
.store-detail-bar-btn {
flex: 1;
height: 48px;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 700;
}
.store-detail-bar-btn--ghost {
background: rgba(166, 29, 36, 0.08);
color: var(--color-heritage-red);
margin-right: 12px;
}
.store-detail-bar-btn--primary {
background: var(--color-heritage-red);
color: #fff;
}
+125
View File
@@ -0,0 +1,125 @@
/* 门店列表 */
.store-page {
background: var(--color-background);
}
.store-toolbar {
padding: 0 var(--space-page) 12px;
}
.store-location {
display: flex;
align-items: center;
margin-bottom: 10px;
color: var(--color-on-surface-variant);
font-size: 12px;
font-weight: 500;
}
.store-location-pin {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-heritage-red);
margin-right: 6px;
}
.store-search {
height: 44px;
padding: 0 16px 0 40px;
border-radius: var(--radius-md);
background: var(--color-surface-container-low);
font-size: 14px;
color: var(--color-on-surface);
box-sizing: border-box;
width: 100%;
}
.store-category-tabs {
display: flex;
padding: 12px var(--space-page);
overflow-x: auto;
white-space: nowrap;
border-bottom: 1px solid rgba(226, 190, 188, 0.1);
}
.store-category-tab {
flex-shrink: 0;
margin-right: 24px;
font-size: 12px;
font-weight: 500;
color: var(--color-on-surface-variant);
padding-bottom: 6px;
border-bottom: 2px solid transparent;
}
.store-category-tab--active {
color: var(--color-heritage-red);
border-bottom-color: var(--color-heritage-red);
font-weight: 700;
}
.store-list {
padding: 16px var(--space-page);
}
.store-card {
background: var(--color-card);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-card);
margin-bottom: 16px;
}
.store-card-cover {
width: 100%;
height: 160px;
background: var(--color-surface-container);
display: block;
}
.store-card-cover--empty {
height: 160px;
background: var(--color-surface-container);
}
.store-card-body {
padding: 14px 16px 16px;
}
.store-card-name {
display: block;
font-family: var(--font-headline);
font-size: 16px;
font-weight: 700;
color: var(--color-on-surface);
margin-bottom: 6px;
}
.store-card-meta {
display: block;
font-size: 12px;
color: var(--color-subtle-gray);
margin-bottom: 4px;
}
.store-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12px;
}
.store-card-distance {
font-size: 12px;
color: var(--color-on-surface-variant);
}
.store-card-cta {
padding: 6px 14px;
border-radius: var(--radius-full);
background: rgba(166, 29, 36, 0.08);
color: var(--color-heritage-red);
font-size: 12px;
font-weight: 600;
}