登录页面
This commit is contained in:
+14
-19
@@ -1,6 +1,6 @@
|
|||||||
# @dukang/mini-user
|
# @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` 覆盖 |
|
| **编译注入** | `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
|
**Tab**:首页 / 门店 / 好客权益 / 我的
|
||||||
$env:VITE_API_TARGET = "http://localhost:3000"
|
|
||||||
pnpm dev:mini-user:weapp
|
|
||||||
```
|
|
||||||
|
|
||||||
微信小程序还需在公众平台配置 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` 共用用户端 API;JWT 与 Header 一致)
|
- `X-Client-App: USER_MINI`
|
||||||
- 功能对照源:`apps/h5-user` + `pages/user/` 原型
|
- 底栏原生 `tabBar` + PNG(weapp);H5 内嵌 `UserTabBar`
|
||||||
- 底栏使用本地 PNG 图标(weapp 无法加载 Google Material 字体)
|
- 交易/核销页面以 UI 壳为主,部分 API 可后续加深
|
||||||
- Tab 页:`navigationStyle: custom` + `TabMainHeader`,对齐 h5 浅色顶栏
|
|
||||||
|
|
||||||
## 迁移路线(建议)
|
|
||||||
|
|
||||||
1. 登录(短信 → 小程序 `wx.login` + 手机号组件)
|
|
||||||
2. 商品详情 / 下单 / 支付
|
|
||||||
3. 订单 3 Tab、权益、核销码
|
|
||||||
4. 地址管理、门店详情
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -39,6 +39,8 @@ export default defineConfig(async () => ({
|
|||||||
enable: false,
|
enable: false,
|
||||||
},
|
},
|
||||||
mini: {
|
mini: {
|
||||||
|
/** dev:weapp 预览模式需开启,否则 React hooks 在 Vite 下会失效 */
|
||||||
|
debugReact: process.env.NODE_ENV !== 'production',
|
||||||
postcss: {
|
postcss: {
|
||||||
pxtransform: { enable: true, config: {} },
|
pxtransform: { enable: true, config: {} },
|
||||||
cssModules: { enable: false },
|
cssModules: { enable: false },
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"description": "杜康好客 · C 端用户微信小程序(Taro)",
|
"description": "杜康好客 · C 端用户微信小程序(Taro)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "taro build --type h5 --watch",
|
"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": "taro build --type h5",
|
||||||
"build:weapp": "taro build --type weapp",
|
"build:weapp": "taro build --type weapp",
|
||||||
"lint": "echo ok"
|
"lint": "echo ok"
|
||||||
|
|||||||
@@ -4,6 +4,19 @@ export default defineAppConfig({
|
|||||||
'pages/stores/index',
|
'pages/stores/index',
|
||||||
'pages/benefit/index',
|
'pages/benefit/index',
|
||||||
'pages/mine/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',
|
'pages/login/index',
|
||||||
],
|
],
|
||||||
window: {
|
window: {
|
||||||
|
|||||||
+11
-92
@@ -1,5 +1,16 @@
|
|||||||
@import './styles/tokens.css';
|
@import './styles/tokens.css';
|
||||||
|
@import './styles/base.css';
|
||||||
|
@import './styles/nav-bar.css';
|
||||||
@import './styles/home.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,
|
page,
|
||||||
body {
|
body {
|
||||||
@@ -8,95 +19,3 @@ body {
|
|||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
margin: 0;
|
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 type { ReactNode } from 'react';
|
||||||
import { View, Text } from '@tarojs/components';
|
import { View, Text } from '@tarojs/components';
|
||||||
|
import { navBarStyle, useNavBarMetrics } from '../lib/nav-bar';
|
||||||
|
|
||||||
type TabMainHeaderProps = {
|
type TabMainHeaderProps = {
|
||||||
title: string;
|
title: string;
|
||||||
extra?: ReactNode;
|
extra?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Tab 页顶栏:适配刘海屏 + 微信胶囊避让 */
|
||||||
export default function TabMainHeader({ title, extra }: TabMainHeaderProps) {
|
export default function TabMainHeader({ title, extra }: TabMainHeaderProps) {
|
||||||
|
const metrics = useNavBarMetrics();
|
||||||
|
|
||||||
return (
|
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>
|
<Text className="tab-main-header__title">{title}</Text>
|
||||||
{extra ? <View className="tab-main-header__extra">{extra}</View> : null}
|
{extra ? <View className="tab-main-header__extra">{extra}</View> : null}
|
||||||
</View>
|
</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} />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import Taro from '@tarojs/taro';
|
import Taro from '@tarojs/taro';
|
||||||
import { ClientApp } from '@dukang/shared-types';
|
import { ClientApp } from '@dukang/shared-types';
|
||||||
|
import { goLogin } from './auth-nav';
|
||||||
|
|
||||||
function resolveApiBase(): string {
|
function resolveApiBase(): string {
|
||||||
const origin =
|
const origin =
|
||||||
@@ -52,7 +53,7 @@ export function isLoggedIn(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function redirectToLogin() {
|
export function redirectToLogin() {
|
||||||
Taro.navigateTo({ url: '/pages/login/index' });
|
goLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logout() {
|
export function logout() {
|
||||||
@@ -119,4 +120,5 @@ export type UserProfile = {
|
|||||||
id: string;
|
id: string;
|
||||||
phone?: string | null;
|
phone?: string | null;
|
||||||
nickname?: string | null;
|
nickname?: string | null;
|
||||||
|
avatarUrl?: string | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -30,4 +30,16 @@ export function getProductImages(source?: ProductImageSource | null): string[] {
|
|||||||
return PRODUCT_IMAGE_FALLBACK ? [PRODUCT_IMAGE_FALLBACK] : [];
|
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';
|
export const FALLBACK_CITY_CODE = '410100';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 Taro, { useDidShow } from '@tarojs/taro';
|
||||||
import TabMainHeader from '../../components/TabMainHeader';
|
import PageShell from '../../components/PageShell';
|
||||||
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
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() {
|
export default function BenefitPage() {
|
||||||
|
const metrics = useNavBarMetrics();
|
||||||
const loggedIn = isLoggedIn();
|
const loggedIn = isLoggedIn();
|
||||||
|
const [summary, setSummary] = useState<BenefitSummary | null>(null);
|
||||||
|
const [coupons, setCoupons] = useState<CouponItem[]>([]);
|
||||||
|
const [tab, setTab] = useState<'available' | 'history'>('available');
|
||||||
|
|
||||||
useDidShow(() => {
|
useDidShow(() => {
|
||||||
syncTabBarSelected(2);
|
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 (
|
return (
|
||||||
<View className="u-page u-page--tab">
|
<PageShell variant="tab" className="benefit-page">
|
||||||
<TabMainHeader title="好客权益" />
|
<View className="benefit-header" style={navBarStyle(metrics)}>
|
||||||
<View className="u-card">
|
<View
|
||||||
{loggedIn ? (
|
className="benefit-header__content"
|
||||||
<View className="u-empty">权益列表将在后续迭代接入</View>
|
style={{ height: `${metrics.navContentHeight}px` }}
|
||||||
) : (
|
|
||||||
<View>
|
|
||||||
<View className="u-empty">登录后查看好客权益余额</View>
|
|
||||||
<Button
|
|
||||||
className="u-btn u-btn--block"
|
|
||||||
onClick={() => Taro.navigateTo({ url: '/pages/login/index' })}
|
|
||||||
>
|
>
|
||||||
去登录
|
<View className="benefit-header-city">
|
||||||
</Button>
|
<View className="benefit-header-city-pin" />
|
||||||
|
<Text>郑州市</Text>
|
||||||
</View>
|
</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>
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{shouldRenderPageTabBar() ? <UserTabBar selected={2} /> : null}
|
{shouldRenderPageTabBar() ? <UserTabBar selected={2} /> : null}
|
||||||
</View>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationStyle: 'custom',
|
||||||
|
navigationBarTitleText: '联系客服',
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { View, Text } from '@tarojs/components';
|
||||||
|
import Taro from '@tarojs/taro';
|
||||||
|
import PageShell from '../../components/PageShell';
|
||||||
|
import SubPageHeader from '../../components/SubPageHeader';
|
||||||
|
import { toast } from '../../lib/api';
|
||||||
|
|
||||||
|
const QUICK = ['如何核销权益?', '订单多久发货?', '如何修改地址?', '联系人工客服'];
|
||||||
|
|
||||||
|
export default function CustomerServicePage() {
|
||||||
|
return (
|
||||||
|
<PageShell variant="sub" className="cs-page">
|
||||||
|
<SubPageHeader title="联系客服" />
|
||||||
|
<View className="sub-page-body inset-page">
|
||||||
|
<View className="cs-bubble cs-bubble--bot">
|
||||||
|
<Text>您好,我是杜康好客小助手。请问有什么可以帮您?</Text>
|
||||||
|
</View>
|
||||||
|
<View className="cs-bubble cs-bubble--user">
|
||||||
|
<Text>我想了解好客权益怎么用</Text>
|
||||||
|
</View>
|
||||||
|
<View className="cs-bubble cs-bubble--bot">
|
||||||
|
<Text>
|
||||||
|
购酒后获得的好客权益可在签约门店到店核销。进入「好客权益」选择可用券,输入金额生成核销码即可。
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className="cs-quick">
|
||||||
|
{QUICK.map((q) => (
|
||||||
|
<Text
|
||||||
|
key={q}
|
||||||
|
className="cs-quick-item"
|
||||||
|
onClick={() => {
|
||||||
|
if (q === '联系人工客服') {
|
||||||
|
Taro.makePhoneCall({ phoneNumber: '4008000000' }).catch(() =>
|
||||||
|
toast('客服热线后续配置'),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast(q);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{q}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { View, Text, Image } from '@tarojs/components';
|
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 TabMainHeader from '../../components/TabMainHeader';
|
||||||
import CouponBadge from '../../components/CouponBadge';
|
import CouponBadge from '../../components/CouponBadge';
|
||||||
|
import ProductCarousel from '../../components/ProductCarousel';
|
||||||
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
||||||
import { request, toast } from '../../lib/api';
|
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 = {
|
type Product = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -50,15 +52,19 @@ export default function HomePage() {
|
|||||||
setTab(key);
|
setTab(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openProductDetail(id: string) {
|
||||||
|
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` });
|
||||||
|
}
|
||||||
|
|
||||||
const filtered = products.filter((p) => p.aromaType === tab);
|
const filtered = products.filter((p) => p.aromaType === tab);
|
||||||
const onSale = tab === 'QINGXIANG';
|
const onSale = tab === 'QINGXIANG';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="u-page u-page--tab home-page">
|
<PageShell variant="tab" className="home-page">
|
||||||
<TabMainHeader
|
<TabMainHeader
|
||||||
title="杜康好客"
|
title="杜康好客"
|
||||||
extra={(
|
extra={(
|
||||||
<View style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
<View className="tab-main-header__extra-inner">
|
||||||
<View className="tab-main-city-pin" />
|
<View className="tab-main-city-pin" />
|
||||||
<Text className="tab-main-city-label">郑州市</Text>
|
<Text className="tab-main-city-label">郑州市</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -86,14 +92,11 @@ export default function HomePage() {
|
|||||||
{!loading &&
|
{!loading &&
|
||||||
onSale &&
|
onSale &&
|
||||||
filtered.map((p) => {
|
filtered.map((p) => {
|
||||||
const cover = getProductMainImage(p);
|
const images = getProductImages(p);
|
||||||
return (
|
return (
|
||||||
<View key={p.id} className="home-product-card">
|
<View key={p.id} className="home-product-card">
|
||||||
{cover ? (
|
<View onClick={() => openProductDetail(p.id)}>
|
||||||
<Image className="home-product-cover" src={cover} mode="aspectFill" />
|
<ProductCarousel images={images} alt={p.name} variant="home" />
|
||||||
) : (
|
|
||||||
<View className="home-product-cover--empty" />
|
|
||||||
)}
|
|
||||||
<View className="home-product-body">
|
<View className="home-product-body">
|
||||||
<View className="home-product-row">
|
<View className="home-product-row">
|
||||||
<Text className="home-product-name">{p.name}</Text>
|
<Text className="home-product-name">{p.name}</Text>
|
||||||
@@ -104,11 +107,9 @@ export default function HomePage() {
|
|||||||
<CouponBadge amount={p.benefitDisplay ?? p.price} label="好客权益" />
|
<CouponBadge amount={p.benefitDisplay ?? p.price} label="好客权益" />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
<View className="home-product-actions">
|
<View className="home-product-actions">
|
||||||
<Text
|
<Text className="home-buy-btn" onClick={() => openProductDetail(p.id)}>
|
||||||
className="home-buy-btn"
|
|
||||||
onClick={() => toast('详情页开发中')}
|
|
||||||
>
|
|
||||||
立即购买
|
立即购买
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -118,6 +119,6 @@ export default function HomePage() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{shouldRenderPageTabBar() ? <UserTabBar selected={0} /> : null}
|
{shouldRenderPageTabBar() ? <UserTabBar selected={0} /> : null}
|
||||||
</View>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export default definePageConfig({
|
export default definePageConfig({
|
||||||
|
navigationStyle: 'custom',
|
||||||
navigationBarTitleText: '登录',
|
navigationBarTitleText: '登录',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
.login-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: calc(64px + env(safe-area-inset-top, 0px)) 24px calc(24px + env(safe-area-inset-bottom, 0px));
|
|
||||||
background: linear-gradient(160deg, #7a0f16 0%, #a61d24 42%, #faf9f7 42%, #faf9f7 100%);
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-brand {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
color: #fff;
|
|
||||||
margin-bottom: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-title {
|
|
||||||
font-size: 26px;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-subtitle {
|
|
||||||
font-size: 13px;
|
|
||||||
opacity: 0.85;
|
|
||||||
margin-top: 4px;
|
|
||||||
letter-spacing: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-card {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 400px;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 20px;
|
|
||||||
padding: 24px 20px;
|
|
||||||
box-shadow: 0 8px 30px rgba(93, 64, 55, 0.12);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 14px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-card-title {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
color: var(--color-on-surface, #1a1c1b);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-input {
|
|
||||||
width: 100%;
|
|
||||||
height: 48px;
|
|
||||||
padding: 0 14px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: #faf9f7;
|
|
||||||
font-size: 15px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-code-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-input--code {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-send {
|
|
||||||
flex-shrink: 0;
|
|
||||||
height: 48px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: rgba(166, 29, 36, 0.08);
|
|
||||||
color: #a61d24;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
border: none;
|
|
||||||
line-height: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-msg {
|
|
||||||
color: #a61d24;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
@@ -1,49 +1,119 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { View, Text, Input, Button } from '@tarojs/components';
|
import { View, Text, Input, Button } from '@tarojs/components';
|
||||||
import Taro from '@tarojs/taro';
|
import { useRouter } from '@tarojs/taro';
|
||||||
import { SmsScene } from '@dukang/shared-types';
|
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 { 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() {
|
export default function LoginPage() {
|
||||||
const [phone, setPhone] = useState('');
|
const router = useRouter();
|
||||||
const [code, setCode] = useState(process.env.TARO_ENV === 'h5' ? '123456' : '');
|
const returnTo = router.params.return || '';
|
||||||
const [cooldown, setCooldown] = useState(0);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [msg, setMsg] = useState('');
|
|
||||||
|
|
||||||
async function sendCode() {
|
const [phone, setPhone] = useState('');
|
||||||
if (cooldown > 0) return;
|
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();
|
const normalized = phone.trim();
|
||||||
if (!/^1[3-9]\d{9}$/.test(normalized)) {
|
if (!isValidPhone(normalized)) {
|
||||||
setMsg('请输入正确的手机号');
|
setMsg('请输入正确的手机号');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setMsg('');
|
setMsg('');
|
||||||
|
setSentHint('');
|
||||||
|
setSending(true);
|
||||||
try {
|
try {
|
||||||
await request('/auth/sms/send', {
|
await request('/auth/sms/send', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: { phone: normalized, scene: SmsScene.USER_LOGIN },
|
data: {
|
||||||
|
phone: normalized,
|
||||||
|
scene: bindMode ? SmsScene.BIND_PHONE : SmsScene.USER_LOGIN,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
toast('验证码已发送');
|
|
||||||
setCooldown(60);
|
setCooldown(60);
|
||||||
const timer = setInterval(() => {
|
setSentHint('验证码已发送');
|
||||||
setCooldown((c) => {
|
|
||||||
if (c <= 1) {
|
|
||||||
clearInterval(timer);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return c - 1;
|
|
||||||
});
|
|
||||||
}, 1000);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(e instanceof Error ? e.message : '发送失败');
|
setMsg(e instanceof Error ? e.message : '发送失败');
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function login() {
|
async function login() {
|
||||||
|
if (!ensureAgreed()) return;
|
||||||
const normalized = phone.trim();
|
const normalized = phone.trim();
|
||||||
if (!/^1[3-9]\d{9}$/.test(normalized)) {
|
if (!isValidPhone(normalized)) {
|
||||||
setMsg('请输入正确的手机号');
|
setMsg('请输入正确的手机号');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -53,14 +123,21 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMsg('');
|
setMsg('');
|
||||||
|
setSentHint('');
|
||||||
try {
|
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', {
|
const data = await request<SessionPayload>('/auth/login/sms', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: { phone: normalized, code: code.trim() },
|
data: { phone: normalized, code: code.trim() },
|
||||||
});
|
});
|
||||||
saveAuth(data);
|
applySessionAndLeave(data);
|
||||||
toast('登录成功', 'success');
|
|
||||||
Taro.switchTab({ url: '/pages/home/index' });
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(e instanceof Error ? e.message : '登录失败');
|
setMsg(e instanceof Error ? e.message : '登录失败');
|
||||||
} finally {
|
} 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 (
|
return (
|
||||||
<View className="login-page">
|
<PageShell variant="plain" className="login-page">
|
||||||
<View className="login-brand">
|
<View className="login-header">
|
||||||
<Text className="login-title">杜康好客</Text>
|
<View className="login-logo-wrap">
|
||||||
<Text className="login-subtitle">用户端小程序</Text>
|
<View className="login-logo">
|
||||||
|
<Text>康</Text>
|
||||||
</View>
|
</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">
|
<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
|
<Input
|
||||||
className="login-input"
|
className="login-field-input"
|
||||||
type="number"
|
type="number"
|
||||||
maxlength={11}
|
maxlength={11}
|
||||||
placeholder="请输入手机号"
|
placeholder="请输入手机号"
|
||||||
value={phone}
|
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
|
<Input
|
||||||
className="login-input login-input--code"
|
className="login-field-input"
|
||||||
type="number"
|
type="number"
|
||||||
maxlength={6}
|
maxlength={6}
|
||||||
placeholder="验证码"
|
placeholder="请输入验证码"
|
||||||
value={code}
|
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()}>
|
<Text
|
||||||
{cooldown > 0 ? `${cooldown}s` : '获取验证码'}
|
className={`login-get-code${codeDisabled ? ' login-get-code--disabled' : ''}`}
|
||||||
</Button>
|
onClick={() => void onSendCode()}
|
||||||
|
>
|
||||||
|
{sending ? '发送中...' : cooldown > 0 ? `${cooldown}s 后重新获取` : '获取验证码'}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{msg ? <Text className="login-msg">{msg}</Text> : null}
|
|
||||||
|
{displayMsg ? (
|
||||||
|
<Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}>
|
||||||
|
{displayMsg}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
className="u-btn u-btn--primary u-btn--block"
|
className="login-sms-btn"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
onClick={() => void login()}
|
onClick={() => void login()}
|
||||||
>
|
>
|
||||||
登录
|
{loading ? '登录中...' : bindMode ? '绑定并登录' : '登录'}
|
||||||
</Button>
|
</Button>
|
||||||
<Text className="u-muted" style={{ textAlign: 'center', marginTop: 8 }}>
|
</View>
|
||||||
Mock 环境验证码一般为 123456;微信一键登录后续接入
|
|
||||||
|
{!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>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,34 @@
|
|||||||
import { useEffect, useState } from 'react';
|
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 Taro, { useDidShow } from '@tarojs/taro';
|
||||||
|
import PageShell from '../../components/PageShell';
|
||||||
import TabMainHeader from '../../components/TabMainHeader';
|
import TabMainHeader from '../../components/TabMainHeader';
|
||||||
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
||||||
|
import { goLogin } from '../../lib/auth-nav';
|
||||||
import { isLoggedIn, logout, request, toast, type UserProfile } from '../../lib/api';
|
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() {
|
export default function MinePage() {
|
||||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
|
||||||
const loggedIn = isLoggedIn();
|
const loggedIn = isLoggedIn();
|
||||||
|
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||||
|
const [benefitBalance, setBenefitBalance] = useState(0);
|
||||||
|
const [orderCounts, setOrderCounts] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
useDidShow(() => {
|
useDidShow(() => {
|
||||||
syncTabBarSelected(3);
|
syncTabBarSelected(3);
|
||||||
@@ -15,43 +36,187 @@ export default function MinePage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loggedIn) return;
|
if (!loggedIn) return;
|
||||||
request<UserProfile>('/auth/me')
|
Promise.all([
|
||||||
.then(setProfile)
|
request<UserProfile>('/auth/me'),
|
||||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
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]);
|
}, [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 (
|
return (
|
||||||
<View className="u-page u-page--tab">
|
<PageShell variant="tab" className="mine-page">
|
||||||
<TabMainHeader title="我的" />
|
<TabMainHeader title="我的" />
|
||||||
<View className="u-card">
|
<View className="mine-header">
|
||||||
{loggedIn ? (
|
<View className="mine-header-texture" />
|
||||||
<View>
|
<View className="mine-profile">
|
||||||
<Text className="u-title" style={{ marginBottom: 4 }}>
|
<View className="mine-avatar">
|
||||||
{profile?.nickname || '用户'}
|
<Text>客</Text>
|
||||||
</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>
|
</View>
|
||||||
) : (
|
|
||||||
<View>
|
<View>
|
||||||
|
<Text className="mine-profile-name">未登录</Text>
|
||||||
|
<Text className="mine-member-tag">好客会员</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className="mine-login-gate u-card">
|
||||||
<View className="u-empty">登录后管理订单与个人信息</View>
|
<View className="u-empty">登录后管理订单与个人信息</View>
|
||||||
<Button
|
<View
|
||||||
className="u-btn u-btn--block"
|
className="u-btn u-btn--block"
|
||||||
onClick={() => Taro.navigateTo({ url: '/pages/login/index' })}
|
onClick={() => goLogin('/pages/mine/index')}
|
||||||
>
|
>
|
||||||
去登录
|
<Text>去登录</Text>
|
||||||
</Button>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
{shouldRenderPageTabBar() ? <UserTabBar selected={3} /> : null}
|
{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>
|
||||||
|
|
||||||
|
<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: '我的订单',
|
||||||
|
});
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { View, Text } from '@tarojs/components';
|
||||||
|
import Taro, { useRouter } from '@tarojs/taro';
|
||||||
|
import PageShell from '../../components/PageShell';
|
||||||
|
import SubPageHeader from '../../components/SubPageHeader';
|
||||||
|
import { request, toast } from '../../lib/api';
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ key: 'pending_pay', label: '待付款' },
|
||||||
|
{ key: 'paid', label: '已付款' },
|
||||||
|
{ key: 'completed', label: '已完成' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type OrderRow = {
|
||||||
|
id: string;
|
||||||
|
orderNo?: string;
|
||||||
|
status?: string;
|
||||||
|
payAmount?: number;
|
||||||
|
productName?: string;
|
||||||
|
qty?: number;
|
||||||
|
createdAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function OrdersPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const initialTab = (router.params.tab as string) || 'pending_pay';
|
||||||
|
const [tab, setTab] = useState(initialTab);
|
||||||
|
const [orders, setOrders] = useState<OrderRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
request<{ items?: OrderRow[]; total?: number } | OrderRow[]>(
|
||||||
|
`/trade/orders?tab=${encodeURIComponent(tab)}&pageSize=20`,
|
||||||
|
)
|
||||||
|
.then((data) => {
|
||||||
|
if (Array.isArray(data)) setOrders(data);
|
||||||
|
else setOrders(Array.isArray(data?.items) ? data.items : []);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
toast(e instanceof Error ? e.message : '加载失败');
|
||||||
|
setOrders([]);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [tab]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell variant="sub" className="orders-page">
|
||||||
|
<SubPageHeader title="我的订单" />
|
||||||
|
<View className="order-tabs">
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<Text
|
||||||
|
key={t.key}
|
||||||
|
className={`order-tab${tab === t.key ? ' order-tab--active' : ''}`}
|
||||||
|
onClick={() => setTab(t.key)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{loading ? <View className="u-empty">加载中…</View> : null}
|
||||||
|
{!loading && orders.length === 0 ? <View className="u-empty">暂无订单</View> : null}
|
||||||
|
{!loading &&
|
||||||
|
orders.map((o) => (
|
||||||
|
<View
|
||||||
|
key={o.id}
|
||||||
|
className="order-list-item"
|
||||||
|
onClick={() => Taro.navigateTo({ url: `/pages/order-detail/index?id=${o.id}` })}
|
||||||
|
>
|
||||||
|
<View className="order-list-head">
|
||||||
|
<Text className="order-list-no">{o.orderNo || o.id}</Text>
|
||||||
|
<Text className="order-list-status">
|
||||||
|
{TABS.find((t) => t.key === tab)?.label || o.status || ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="order-list-body">
|
||||||
|
<View className="order-list-thumb" />
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text className="order-list-name">{o.productName || '杜康商品'}</Text>
|
||||||
|
<Text className="order-list-meta">
|
||||||
|
数量 {o.qty ?? 1} · {o.createdAt ? String(o.createdAt).slice(0, 10) : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className="order-list-footer">
|
||||||
|
<Text className="order-list-meta">实付</Text>
|
||||||
|
<Text className="order-product-price">
|
||||||
|
¥{Number(o.payAmount ?? 0).toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationStyle: 'custom',
|
||||||
|
navigationBarTitleText: '收银台',
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { View, Text } from '@tarojs/components';
|
||||||
|
import Taro, { useRouter } from '@tarojs/taro';
|
||||||
|
import PageShell from '../../components/PageShell';
|
||||||
|
import SubPageHeader from '../../components/SubPageHeader';
|
||||||
|
import { toast } from '../../lib/api';
|
||||||
|
|
||||||
|
export default function PayPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const amount = router.params.amount ?? '0.00';
|
||||||
|
|
||||||
|
function mockPay() {
|
||||||
|
toast('支付成功(Mock)', 'success');
|
||||||
|
setTimeout(() => {
|
||||||
|
Taro.redirectTo({ url: '/pages/orders/index?tab=paid' });
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell variant="sub" className="pay-page" hasFixedFooter>
|
||||||
|
<SubPageHeader title="收银台" />
|
||||||
|
<View className="sub-page-body">
|
||||||
|
<View className="pay-status">
|
||||||
|
<View className="pay-status-icon">
|
||||||
|
<Text>¥</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="pay-status-title">待支付</Text>
|
||||||
|
<Text className="pay-status-amount">¥{amount}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="order-card">
|
||||||
|
<View className="order-row">
|
||||||
|
<Text className="order-row-label">支付方式</Text>
|
||||||
|
<Text className="order-row-value">微信支付</Text>
|
||||||
|
</View>
|
||||||
|
<View className="order-row">
|
||||||
|
<Text className="order-row-label">说明</Text>
|
||||||
|
<Text className="order-row-value">小程序支付后续接入</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className="pay-bar">
|
||||||
|
<View className="order-confirm-submit" style={{ flex: 1 }} onClick={mockPay}>
|
||||||
|
<Text>立即支付</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationStyle: 'custom',
|
||||||
|
navigationBarTitleText: '商品详情',
|
||||||
|
});
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { View, Text, Image } from '@tarojs/components';
|
||||||
|
import Taro, { usePageScroll, useRouter } from '@tarojs/taro';
|
||||||
|
import type { ProductDetailContentDto } from '@dukang/shared-types';
|
||||||
|
import PageShell from '../../components/PageShell';
|
||||||
|
import PageNavBar from '../../components/PageNavBar';
|
||||||
|
import ProductCarousel from '../../components/ProductCarousel';
|
||||||
|
import { request, toast } from '../../lib/api';
|
||||||
|
import {
|
||||||
|
getProductCarouselImages,
|
||||||
|
getProductDetailImages,
|
||||||
|
type ProductImageSource,
|
||||||
|
} from '../../lib/product-images';
|
||||||
|
import iconHome from '../../assets/tabbar/home.png';
|
||||||
|
|
||||||
|
type Product = ProductImageSource & {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
subtitle?: string | null;
|
||||||
|
price: number;
|
||||||
|
benefitAmount?: number;
|
||||||
|
benefitDisplay?: number;
|
||||||
|
detailContent?: ProductDetailContentDto | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProductDetailPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const productId = router.params.id ?? '';
|
||||||
|
const [product, setProduct] = useState<Product | null>(null);
|
||||||
|
const [headerSolid, setHeaderSolid] = useState(false);
|
||||||
|
|
||||||
|
usePageScroll(({ scrollTop }) => {
|
||||||
|
setHeaderSolid(scrollTop > 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!productId) return;
|
||||||
|
request<Product>(`/catalog/products/${productId}`)
|
||||||
|
.then(setProduct)
|
||||||
|
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
||||||
|
}, [productId]);
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
const pages = Taro.getCurrentPages();
|
||||||
|
if (pages.length > 1) {
|
||||||
|
Taro.navigateBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Taro.switchTab({ url: '/pages/home/index' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function goHome() {
|
||||||
|
Taro.switchTab({ url: '/pages/home/index' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBuy() {
|
||||||
|
if (!productId) return;
|
||||||
|
Taro.navigateTo({ url: `/pages/order-confirm/index?productId=${productId}&qty=2` });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return (
|
||||||
|
<PageShell variant="scroll" className="product-detail-page">
|
||||||
|
<PageNavBar title="商品详情" solid onBack={goBack} />
|
||||||
|
<View className="page-with-nav-bar u-empty">加载中…</View>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const benefit = Number(product.benefitDisplay ?? product.benefitAmount ?? product.price);
|
||||||
|
const carouselImages = getProductCarouselImages(product);
|
||||||
|
const detailImages = getProductDetailImages(product);
|
||||||
|
const detail = product.detailContent ?? {};
|
||||||
|
const features = detail.features ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell variant="scroll" className="product-detail-page" hasFixedFooter>
|
||||||
|
<PageNavBar
|
||||||
|
title={product.name}
|
||||||
|
solid={headerSolid}
|
||||||
|
titleVisible={headerSolid}
|
||||||
|
onBack={goBack}
|
||||||
|
right={(
|
||||||
|
<View className="page-nav-bar__btn" onClick={() => toast('分享功能开发中')}>
|
||||||
|
<Text className="page-nav-bar__icon page-nav-bar__icon--share">⤴</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View className="product-detail-main">
|
||||||
|
<View className="product-detail-hero full-bleed">
|
||||||
|
<ProductCarousel images={carouselImages} alt={product.name} variant="detail" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="product-detail-info">
|
||||||
|
<View className="product-detail-price">
|
||||||
|
<Text className="product-detail-price-symbol">¥</Text>
|
||||||
|
<Text className="product-detail-price-value">{Number(product.price).toFixed(2)}</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="product-detail-name">{product.name}</Text>
|
||||||
|
{product.subtitle ? (
|
||||||
|
<Text className="product-detail-subtitle">{product.subtitle}</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View className="product-detail-promo">
|
||||||
|
<View className="product-detail-promo-glow" />
|
||||||
|
<View className="product-detail-promo-head">
|
||||||
|
<View className="product-detail-promo-icon">
|
||||||
|
<Text className="product-detail-promo-icon-text">惠</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="product-detail-promo-title">
|
||||||
|
买杜康美酒 · 享全城好客礼遇
|
||||||
|
<Text className="product-detail-promo-amount"> ¥{benefit}</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="product-detail-promo-desc">
|
||||||
|
购酒即享本城专属好客权益,为您安排一场地道饭局。权益金可直接用于本地签约门店消费,到店享用,醇香美酒配佳肴。
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="product-detail-content">
|
||||||
|
<View className="product-detail-section-head">
|
||||||
|
<View className="product-detail-section-bar" />
|
||||||
|
<Text className="product-detail-section-title">商品详情</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{detailImages.map((src, index) => (
|
||||||
|
<Image
|
||||||
|
key={`${src}-${index}`}
|
||||||
|
className="product-detail-banner full-bleed"
|
||||||
|
src={src}
|
||||||
|
mode="widthFix"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{(detail.storyTitle || detail.storyText || features.length > 0) ? (
|
||||||
|
<View className="product-detail-copy">
|
||||||
|
{(detail.storyTitle || detail.storyText) ? (
|
||||||
|
<View className="product-detail-story">
|
||||||
|
{detail.storyTitle ? (
|
||||||
|
<Text className="product-detail-story-title">{detail.storyTitle}</Text>
|
||||||
|
) : null}
|
||||||
|
{detail.storyText ? (
|
||||||
|
<Text className="product-detail-story-text">{detail.storyText}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{features.length > 0 ? (
|
||||||
|
<View className="product-detail-features">
|
||||||
|
{features.map((f) => (
|
||||||
|
<View key={`${f.title}-${f.icon}`} className="product-detail-feature">
|
||||||
|
<Text className="product-detail-feature-icon">★</Text>
|
||||||
|
<Text className="product-detail-feature-title">{f.title}</Text>
|
||||||
|
<Text className="product-detail-feature-desc">{f.desc}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="product-detail-bar">
|
||||||
|
<View className="product-detail-bar-home" onClick={goHome}>
|
||||||
|
<Image className="product-detail-bar-home-icon" src={iconHome} mode="aspectFit" />
|
||||||
|
<Text className="product-detail-bar-home-label">首页</Text>
|
||||||
|
</View>
|
||||||
|
<View className="product-detail-buy-btn" onClick={goBuy}>
|
||||||
|
<Text className="product-detail-buy-btn-text">立即购买</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationStyle: 'custom',
|
||||||
|
navigationBarTitleText: '核销码',
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { View, Text } from '@tarojs/components';
|
||||||
|
import Taro, { useRouter } from '@tarojs/taro';
|
||||||
|
import PageShell from '../../components/PageShell';
|
||||||
|
import SubPageHeader from '../../components/SubPageHeader';
|
||||||
|
import { toast } from '../../lib/api';
|
||||||
|
|
||||||
|
export default function RedeemCodePage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const amount = router.params.amount ?? '0';
|
||||||
|
const [seconds, setSeconds] = useState(180);
|
||||||
|
const code = `DK${String(Date.now()).slice(-8)}`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setSeconds((s) => {
|
||||||
|
if (s <= 1) {
|
||||||
|
clearInterval(timer);
|
||||||
|
toast('核销码已过期');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return s - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const mm = String(Math.floor(seconds / 60)).padStart(2, '0');
|
||||||
|
const ss = String(seconds % 60).padStart(2, '0');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell variant="sub" className="redeem-code-page">
|
||||||
|
<SubPageHeader title="核销码" />
|
||||||
|
<View className="sub-page-body">
|
||||||
|
<View className="redeem-code-panel">
|
||||||
|
<Text className="u-muted">核销金额 ¥{Number(amount).toFixed(2)}</Text>
|
||||||
|
<Text className="redeem-code-value">{code}</Text>
|
||||||
|
<Text className="redeem-code-timer">剩余有效时间 {mm}:{ss}</Text>
|
||||||
|
<Text className="u-muted" style={{ display: 'block', marginTop: 16 }}>
|
||||||
|
请向门店店员出示此码完成核销
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className="redeem-submit"
|
||||||
|
onClick={() =>
|
||||||
|
Taro.redirectTo({
|
||||||
|
url: `/pages/redeem-success/index?amount=${amount}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text>模拟核销成功</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationStyle: 'custom',
|
||||||
|
navigationBarTitleText: '核销成功',
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { View, Text } from '@tarojs/components';
|
||||||
|
import Taro, { useRouter } from '@tarojs/taro';
|
||||||
|
import PageShell from '../../components/PageShell';
|
||||||
|
import SubPageHeader from '../../components/SubPageHeader';
|
||||||
|
|
||||||
|
export default function RedeemSuccessPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const amount = router.params.amount ?? '0';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell variant="sub" className="redeem-success-page">
|
||||||
|
<SubPageHeader title="核销成功" />
|
||||||
|
<View className="sub-page-body">
|
||||||
|
<View className="redeem-success-icon">
|
||||||
|
<Text>✓</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="redeem-success-title">核销成功</Text>
|
||||||
|
<Text className="redeem-success-desc">
|
||||||
|
已核销好客权益 ¥{Number(amount).toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
className="redeem-submit"
|
||||||
|
onClick={() => Taro.switchTab({ url: '/pages/benefit/index' })}
|
||||||
|
>
|
||||||
|
<Text>返回权益</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className="u-btn u-btn--ghost u-btn--block"
|
||||||
|
style={{ margin: '0 20px', boxSizing: 'border-box' }}
|
||||||
|
onClick={() => Taro.switchTab({ url: '/pages/home/index' })}
|
||||||
|
>
|
||||||
|
<Text>回到首页</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationStyle: 'custom',
|
||||||
|
navigationBarTitleText: '权益核销',
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { View, Text, Input } from '@tarojs/components';
|
||||||
|
import Taro, { useRouter } from '@tarojs/taro';
|
||||||
|
import PageShell from '../../components/PageShell';
|
||||||
|
import SubPageHeader from '../../components/SubPageHeader';
|
||||||
|
import { goLogin } from '../../lib/auth-nav';
|
||||||
|
import { isLoggedIn, request, toast } from '../../lib/api';
|
||||||
|
|
||||||
|
type BenefitSummary = {
|
||||||
|
totalBalance: number;
|
||||||
|
maxRedeemAmount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RedeemPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const initialAmount = router.params.amount ?? '';
|
||||||
|
const [balance, setBalance] = useState(0);
|
||||||
|
const [amount, setAmount] = useState(initialAmount);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
goLogin('/pages/redeem/index');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
request<BenefitSummary>('/benefit/summary')
|
||||||
|
.then((s) => setBalance(Number(s.maxRedeemAmount ?? s.totalBalance ?? 0)))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
const value = Number(amount);
|
||||||
|
if (!(value > 0)) {
|
||||||
|
toast('请输入核销金额');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value > balance && balance > 0) {
|
||||||
|
toast('超出可用余额');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Taro.navigateTo({
|
||||||
|
url: `/pages/redeem-code/index?amount=${value}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell variant="sub" className="redeem-page">
|
||||||
|
<SubPageHeader title="权益核销" />
|
||||||
|
<View className="sub-page-body">
|
||||||
|
<View className="redeem-hero">
|
||||||
|
<Text className="redeem-hero-label">可用余额</Text>
|
||||||
|
<Text className="redeem-hero-amount">¥{balance.toFixed(2)}</Text>
|
||||||
|
</View>
|
||||||
|
<View className="redeem-input-wrap">
|
||||||
|
<Input
|
||||||
|
className="redeem-input"
|
||||||
|
type="digit"
|
||||||
|
placeholder="输入核销金额"
|
||||||
|
value={amount}
|
||||||
|
onInput={(e) => setAmount(e.detail.value)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text className="redeem-tips">
|
||||||
|
直接核销:金额须大于 0 且不超过全部可用权益余额。核销码有效期 3 分钟,请到店出示。
|
||||||
|
</Text>
|
||||||
|
<View className="redeem-submit" onClick={submit}>
|
||||||
|
<Text>生成核销码</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationStyle: 'custom',
|
||||||
|
navigationBarTitleText: '门店详情',
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { View, Text } from '@tarojs/components';
|
||||||
|
import Taro, { usePageScroll, useRouter } from '@tarojs/taro';
|
||||||
|
import PageShell from '../../components/PageShell';
|
||||||
|
import PageNavBar from '../../components/PageNavBar';
|
||||||
|
import ProductCarousel from '../../components/ProductCarousel';
|
||||||
|
import { request, toast } from '../../lib/api';
|
||||||
|
|
||||||
|
type Store = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address?: string;
|
||||||
|
district?: string;
|
||||||
|
phone?: string;
|
||||||
|
coverUrl?: string | null;
|
||||||
|
carouselUrls?: string[] | null;
|
||||||
|
openTime?: string | null;
|
||||||
|
closeTime?: string | null;
|
||||||
|
category?: { name: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StoreDetailPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const storeId = router.params.id ?? '';
|
||||||
|
const [store, setStore] = useState<Store | null>(null);
|
||||||
|
const [headerSolid, setHeaderSolid] = useState(false);
|
||||||
|
|
||||||
|
usePageScroll(({ scrollTop }) => {
|
||||||
|
setHeaderSolid(scrollTop > 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!storeId) return;
|
||||||
|
request<Store>(`/stores/${storeId}`)
|
||||||
|
.then(setStore)
|
||||||
|
.catch(() => {
|
||||||
|
request<Store[]>('/stores')
|
||||||
|
.then((list) => {
|
||||||
|
const found = (Array.isArray(list) ? list : []).find((s) => s.id === storeId);
|
||||||
|
if (found) setStore(found);
|
||||||
|
else toast('门店不存在');
|
||||||
|
})
|
||||||
|
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
||||||
|
});
|
||||||
|
}, [storeId]);
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
const pages = Taro.getCurrentPages();
|
||||||
|
if (pages.length > 1) Taro.navigateBack();
|
||||||
|
else Taro.switchTab({ url: '/pages/stores/index' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!store) {
|
||||||
|
return (
|
||||||
|
<PageShell variant="scroll" className="store-detail-page">
|
||||||
|
<PageNavBar title="门店详情" solid onBack={goBack} />
|
||||||
|
<View className="page-with-nav-bar u-empty">加载中…</View>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const images =
|
||||||
|
(store.carouselUrls && store.carouselUrls.length > 0
|
||||||
|
? store.carouselUrls
|
||||||
|
: store.coverUrl
|
||||||
|
? [store.coverUrl]
|
||||||
|
: []) as string[];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell variant="scroll" className="store-detail-page" hasFixedFooter>
|
||||||
|
<PageNavBar
|
||||||
|
title={store.name}
|
||||||
|
solid={headerSolid}
|
||||||
|
titleVisible={headerSolid}
|
||||||
|
onBack={goBack}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View className="store-detail-hero full-bleed">
|
||||||
|
<ProductCarousel images={images} alt={store.name} variant="store" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="store-detail-info-card">
|
||||||
|
<Text className="store-detail-name">{store.name}</Text>
|
||||||
|
<Text className="store-detail-meta">
|
||||||
|
{store.district ? `${store.district} · ` : ''}
|
||||||
|
{store.address || '地址待完善'}
|
||||||
|
</Text>
|
||||||
|
<Text className="store-detail-meta">
|
||||||
|
营业时间: {store.openTime && store.closeTime ? `${store.openTime}-${store.closeTime}` : '10:00-22:00'}
|
||||||
|
</Text>
|
||||||
|
{store.phone ? <Text className="store-detail-meta">电话: {store.phone}</Text> : null}
|
||||||
|
<View className="store-detail-tags">
|
||||||
|
{store.category?.name ? (
|
||||||
|
<Text className="store-detail-tag">{store.category.name}</Text>
|
||||||
|
) : null}
|
||||||
|
<Text className="store-detail-tag">可核销</Text>
|
||||||
|
<Text className="store-detail-tag">好客门店</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="store-detail-section">
|
||||||
|
<Text className="store-detail-section-title">门店服务</Text>
|
||||||
|
<Text className="store-detail-meta">支持好客权益到店核销,部分门店提供包间预约。</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="store-detail-bar">
|
||||||
|
<View
|
||||||
|
className="store-detail-bar-btn store-detail-bar-btn--ghost"
|
||||||
|
onClick={() => {
|
||||||
|
if (store.phone) {
|
||||||
|
Taro.makePhoneCall({ phoneNumber: store.phone }).catch(() => toast('无法拨打'));
|
||||||
|
} else {
|
||||||
|
toast('暂无联系电话');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>联系门店</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className="store-detail-bar-btn store-detail-bar-btn--primary"
|
||||||
|
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
|
||||||
|
>
|
||||||
|
<Text>去核销</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { View, Text } from '@tarojs/components';
|
import { View, Text, Image, Input } 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 TabMainHeader from '../../components/TabMainHeader';
|
||||||
|
import RegionPicker from '../../components/RegionPicker';
|
||||||
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
||||||
import { request, toast } from '../../lib/api';
|
import { request, toast } from '../../lib/api';
|
||||||
|
|
||||||
@@ -9,12 +11,23 @@ type Store = {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
address?: string;
|
address?: string;
|
||||||
|
district?: string;
|
||||||
|
coverUrl?: string | null;
|
||||||
|
openTime?: string | null;
|
||||||
|
closeTime?: string | null;
|
||||||
status?: string;
|
status?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CATEGORY_TABS = ['全部', '火锅', '地方菜', '高端餐饮', '烧烤烤肉', '西餐'] as const;
|
||||||
|
const MOCK_DISTANCES = ['800m', '1.2km', '3.5km', '1.5km', '2.0km'];
|
||||||
|
|
||||||
export default function StoresPage() {
|
export default function StoresPage() {
|
||||||
const [stores, setStores] = useState<Store[]>([]);
|
const [stores, setStores] = useState<Store[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [categoryTab, setCategoryTab] = useState<string>('全部');
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [regionLabel, setRegionLabel] = useState('郑州市 · 全部区域');
|
||||||
|
const [regionOpen, setRegionOpen] = useState(false);
|
||||||
|
|
||||||
useDidShow(() => {
|
useDidShow(() => {
|
||||||
syncTabBarSelected(1);
|
syncTabBarSelected(1);
|
||||||
@@ -27,29 +40,93 @@ export default function StoresPage() {
|
|||||||
.finally(() => setLoading(false));
|
.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 (
|
return (
|
||||||
<View className="u-page u-page--tab">
|
<PageShell variant="tab" className="store-page">
|
||||||
<TabMainHeader title="门店" />
|
<TabMainHeader title="门店" />
|
||||||
<View className="u-card">
|
<View className="store-toolbar">
|
||||||
{loading ? (
|
<View className="store-location" onClick={() => setRegionOpen(true)}>
|
||||||
<View className="u-empty">加载中…</View>
|
<View className="store-location-pin" />
|
||||||
) : stores.length === 0 ? (
|
<Text>{regionLabel} ▾</Text>
|
||||||
<View className="u-empty">暂无营业中门店</View>
|
</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 className="store-card-cover--empty" />
|
||||||
<View key={s.id} className="u-store-row">
|
)}
|
||||||
<View className="u-store-avatar" />
|
<View className="store-card-body">
|
||||||
<View style={{ flex: 1, minWidth: 0 }}>
|
<Text className="store-card-name">{s.name}</Text>
|
||||||
<Text className="u-store-name">{s.name}</Text>
|
<Text className="store-card-meta">
|
||||||
<Text className="u-muted" style={{ display: 'block', marginTop: '4px' }}>
|
{s.district ? `${s.district} · ` : ''}
|
||||||
{s.address || '地址待完善'}
|
{s.address || '地址待完善'}
|
||||||
</Text>
|
</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>
|
||||||
))
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
{shouldRenderPageTabBar() ? <UserTabBar selected={1} /> : null}
|
{shouldRenderPageTabBar() ? <UserTabBar selected={1} /> : null}
|
||||||
</View>
|
<RegionPicker
|
||||||
|
open={regionOpen}
|
||||||
|
valueLabel="郑州市"
|
||||||
|
onClose={() => setRegionOpen(false)}
|
||||||
|
onConfirm={(label) => setRegionLabel(`${label} · 全部区域`)}
|
||||||
|
/>
|
||||||
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,42 +1,4 @@
|
|||||||
/* Tab 主页面顶栏 + 首页(对齐 h5-user) */
|
/* 首页(Tab 顶栏样式见 styles/nav-bar.css) */
|
||||||
.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-main-city-pin {
|
.tab-main-city-pin {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
@@ -78,7 +40,7 @@
|
|||||||
background: rgba(250, 249, 247, 0.95);
|
background: rgba(250, 249, 247, 0.95);
|
||||||
border-bottom: 1px solid var(--color-surface-container);
|
border-bottom: 1px solid var(--color-surface-container);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: calc(56px + env(safe-area-inset-top, 0px));
|
top: var(--nav-bar-height, 56px);
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,18 +80,52 @@
|
|||||||
box-shadow: var(--shadow-card);
|
box-shadow: var(--shadow-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-product-cover {
|
.home-carousel-wrap {
|
||||||
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 200px;
|
aspect-ratio: 1;
|
||||||
background: var(--color-surface-container);
|
background: var(--color-surface-container);
|
||||||
display: block;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-product-cover--empty {
|
.home-carousel {
|
||||||
height: 200px;
|
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);
|
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 {
|
.home-product-body {
|
||||||
padding: var(--space-gutter);
|
padding: var(--space-gutter);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user