diff --git a/apps/mini-user/README.md b/apps/mini-user/README.md
index 5db4bbf..75d1abc 100644
--- a/apps/mini-user/README.md
+++ b/apps/mini-user/README.md
@@ -1,6 +1,6 @@
# @dukang/mini-user
-杜康好客 C 端微信小程序(Taro 4 + React)。
+杜康好客 C 端微信小程序(Taro 4 + React)。UI 对齐 `apps/h5-user`。
## 启动
@@ -19,27 +19,22 @@ pnpm --filter @dukang/mini-user dev:weapp
| 环节 | 文件 | 说明 |
|------|------|------|
| **编译注入** | `config/index.ts` → `defineConstants.TARO_APP_API_ORIGIN` | 默认 `https://dkapi.runxian.top`;可用环境变量 `VITE_API_TARGET` 覆盖 |
-| **运行时拼装** | `src/lib/api.ts` → `resolveApiBase()` | `{origin}/api/v1`,例如 `https://dkapi.runxian.top/api/v1` |
+| **运行时拼装** | `src/lib/api.ts` → `resolveApiBase()` | `{origin}/api/v1` |
-本地联调 API(`localhost:3000`)时,启动前设置环境变量并重新编译:
+## 页面结构(18 页)
-```powershell
-$env:VITE_API_TARGET = "http://localhost:3000"
-pnpm dev:mini-user:weapp
-```
+**Tab**:首页 / 门店 / 好客权益 / 我的
-微信小程序还需在公众平台配置 request 合法域名:`dkapi.runxian.top`。
+**栈页**:商品详情、门店详情、确认订单、收银台、订单列表/详情、地址列表/编辑、客服、权益明细、核销/核销码/成功、登录
+
+## 布局约定
+
+- 统一 `PageShell`(`tab` / `scroll` / `sub` / `plain`)注入刘海屏 CSS 变量
+- Tab 顶栏:`TabMainHeader`;详情滚动顶栏:`PageNavBar`;子页:`SubPageHeader`
+- 样式:`src/styles/*.css` 按业务域拆分,入口 `app.css`
## 约定
-- `X-Client-App: USER_MINI`(与 `USER_H5` 共用用户端 API;JWT 与 Header 一致)
-- 功能对照源:`apps/h5-user` + `pages/user/` 原型
-- 底栏使用本地 PNG 图标(weapp 无法加载 Google Material 字体)
-- Tab 页:`navigationStyle: custom` + `TabMainHeader`,对齐 h5 浅色顶栏
-
-## 迁移路线(建议)
-
-1. 登录(短信 → 小程序 `wx.login` + 手机号组件)
-2. 商品详情 / 下单 / 支付
-3. 订单 3 Tab、权益、核销码
-4. 地址管理、门店详情
+- `X-Client-App: USER_MINI`
+- 底栏原生 `tabBar` + PNG(weapp);H5 内嵌 `UserTabBar`
+- 交易/核销页面以 UI 壳为主,部分 API 可后续加深
diff --git a/apps/mini-user/config/dev.ts b/apps/mini-user/config/dev.ts
deleted file mode 100644
index 92f3462..0000000
--- a/apps/mini-user/config/dev.ts
+++ /dev/null
@@ -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;
diff --git a/apps/mini-user/config/index.ts b/apps/mini-user/config/index.ts
index eb9731b..571a2c6 100644
--- a/apps/mini-user/config/index.ts
+++ b/apps/mini-user/config/index.ts
@@ -39,6 +39,8 @@ export default defineConfig(async () => ({
enable: false,
},
mini: {
+ /** dev:weapp 预览模式需开启,否则 React hooks 在 Vite 下会失效 */
+ debugReact: process.env.NODE_ENV !== 'production',
postcss: {
pxtransform: { enable: true, config: {} },
cssModules: { enable: false },
diff --git a/apps/mini-user/package.json b/apps/mini-user/package.json
index accfa60..0e6e508 100644
--- a/apps/mini-user/package.json
+++ b/apps/mini-user/package.json
@@ -5,7 +5,7 @@
"description": "杜康好客 · C 端用户微信小程序(Taro)",
"scripts": {
"dev": "taro build --type h5 --watch",
- "dev:weapp": "taro build --type weapp --watch",
+ "dev:weapp": "taro build --type weapp --watch --mode development",
"build": "taro build --type h5",
"build:weapp": "taro build --type weapp",
"lint": "echo ok"
diff --git a/apps/mini-user/src/app.config.ts b/apps/mini-user/src/app.config.ts
index 25c1ed6..57d7d6c 100644
--- a/apps/mini-user/src/app.config.ts
+++ b/apps/mini-user/src/app.config.ts
@@ -4,6 +4,19 @@ export default defineAppConfig({
'pages/stores/index',
'pages/benefit/index',
'pages/mine/index',
+ 'pages/product-detail/index',
+ 'pages/store-detail/index',
+ 'pages/order-confirm/index',
+ 'pages/pay/index',
+ 'pages/orders/index',
+ 'pages/order-detail/index',
+ 'pages/addresses/index',
+ 'pages/address-edit/index',
+ 'pages/customer-service/index',
+ 'pages/benefit-detail/index',
+ 'pages/redeem/index',
+ 'pages/redeem-code/index',
+ 'pages/redeem-success/index',
'pages/login/index',
],
window: {
diff --git a/apps/mini-user/src/app.css b/apps/mini-user/src/app.css
index 66f3c0b..eab96a0 100644
--- a/apps/mini-user/src/app.css
+++ b/apps/mini-user/src/app.css
@@ -1,5 +1,16 @@
@import './styles/tokens.css';
+@import './styles/base.css';
+@import './styles/nav-bar.css';
@import './styles/home.css';
+@import './styles/stores.css';
+@import './styles/benefit.css';
+@import './styles/mine.css';
+@import './styles/product-detail.css';
+@import './styles/store-detail.css';
+@import './styles/login.css';
+@import './styles/order.css';
+@import './styles/address.css';
+@import './styles/redeem.css';
page,
body {
@@ -8,95 +19,3 @@ body {
font-family: var(--font-body);
margin: 0;
}
-
-.u-page {
- min-height: 100vh;
- padding-bottom: calc(24px + env(safe-area-inset-bottom, 0px));
- background: var(--color-background);
- box-sizing: border-box;
-}
-
-.u-page--tab {
- padding-bottom: calc(88px + env(safe-area-inset-bottom, 0px));
-}
-
-.u-card {
- background: var(--color-card);
- border-radius: var(--radius-lg);
- margin: 12px var(--space-page);
- padding: 16px;
- box-shadow: var(--shadow-card);
- box-sizing: border-box;
-}
-
-.u-muted {
- color: var(--color-subtle-gray);
- font-size: 13px;
-}
-
-.u-title {
- font-family: var(--font-headline);
- font-size: 18px;
- font-weight: 600;
- margin: 0 0 8px;
- color: var(--color-on-surface);
-}
-
-.u-btn {
- display: flex;
- align-items: center;
- justify-content: center;
- border: none;
- border-radius: var(--radius-md);
- padding: 12px 18px;
- font-size: 15px;
- font-weight: 600;
- line-height: 1.2;
- background: var(--color-heritage-red);
- color: #fff;
-}
-
-.u-btn--block {
- width: 100%;
-}
-
-.u-btn:active {
- opacity: 0.85;
-}
-
-.u-empty {
- text-align: center;
- padding: 48px 24px;
- color: var(--color-subtle-gray);
- font-size: 14px;
-}
-
-.u-store-row {
- display: flex;
- gap: 12px;
- align-items: center;
- padding: 14px 0;
- border-bottom: 1px solid var(--color-surface-container);
-}
-
-.u-store-row:last-child {
- border-bottom: none;
-}
-
-.u-store-avatar {
- width: 48px;
- height: 48px;
- border-radius: 12px;
- background: rgba(166, 29, 36, 0.08);
- flex-shrink: 0;
-}
-
-.u-store-name {
- font-size: 15px;
- font-weight: 600;
- color: var(--color-on-surface);
-}
-
-.u-safe-top {
- padding-top: env(safe-area-inset-top, 0px);
-}
diff --git a/apps/mini-user/src/components/PageNavBar.tsx b/apps/mini-user/src/components/PageNavBar.tsx
new file mode 100644
index 0000000..11e8992
--- /dev/null
+++ b/apps/mini-user/src/components/PageNavBar.tsx
@@ -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 (
+
+
+ {onBack ? (
+
+ ‹
+
+ ) : (
+
+ )}
+
+ {title}
+
+ {right ?? }
+
+
+ );
+}
diff --git a/apps/mini-user/src/components/PageShell.tsx b/apps/mini-user/src/components/PageShell.tsx
new file mode 100644
index 0000000..a30666d
--- /dev/null
+++ b/apps/mini-user/src/components/PageShell.tsx
@@ -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 (
+
+ {children as ReactNode}
+
+ );
+}
diff --git a/apps/mini-user/src/components/ProductCarousel.tsx b/apps/mini-user/src/components/ProductCarousel.tsx
new file mode 100644
index 0000000..16ea93f
--- /dev/null
+++ b/apps/mini-user/src/components/ProductCarousel.tsx
@@ -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 (
+
+ 1}
+ onChange={(e) => setActiveIndex(e.detail.current)}
+ >
+ {slides.map((src, index) => (
+
+ {src ? (
+
+ ) : (
+
+ )}
+
+ ))}
+
+ {slides.length > 1 ? (
+
+ {slides.map((_, index) => (
+
+ ))}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/mini-user/src/components/RegionPicker.tsx b/apps/mini-user/src/components/RegionPicker.tsx
new file mode 100644
index 0000000..91e180e
--- /dev/null
+++ b/apps/mini-user/src/components/RegionPicker.tsx
@@ -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 (
+
+ e.stopPropagation()}>
+
+
+ 取消
+
+ 选择区域
+ {
+ onConfirm?.(valueLabel);
+ onClose();
+ }}
+ >
+ 确定
+
+
+
+ {valueLabel}
+
+ 完整省市区级联后续接入
+
+
+
+
+ );
+}
diff --git a/apps/mini-user/src/components/SubPageHeader.tsx b/apps/mini-user/src/components/SubPageHeader.tsx
new file mode 100644
index 0000000..9fe7762
--- /dev/null
+++ b/apps/mini-user/src/components/SubPageHeader.tsx
@@ -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 (
+
+
+
+ ‹
+
+ {title}
+ {right ? {right} : null}
+
+
+ );
+}
diff --git a/apps/mini-user/src/components/TabMainHeader.tsx b/apps/mini-user/src/components/TabMainHeader.tsx
index 5c9d35a..131e0dc 100644
--- a/apps/mini-user/src/components/TabMainHeader.tsx
+++ b/apps/mini-user/src/components/TabMainHeader.tsx
@@ -1,16 +1,25 @@
import type { ReactNode } from 'react';
import { View, Text } from '@tarojs/components';
+import { navBarStyle, useNavBarMetrics } from '../lib/nav-bar';
type TabMainHeaderProps = {
title: string;
extra?: ReactNode;
};
+/** Tab 页顶栏:适配刘海屏 + 微信胶囊避让 */
export default function TabMainHeader({ title, extra }: TabMainHeaderProps) {
+ const metrics = useNavBarMetrics();
+
return (
-
- {title}
- {extra ? {extra} : null}
+
+
+ {title}
+ {extra ? {extra} : null}
+
);
}
diff --git a/apps/mini-user/src/custom-tab-bar/index.tsx b/apps/mini-user/src/custom-tab-bar/index.tsx
deleted file mode 100644
index 4b4c65d..0000000
--- a/apps/mini-user/src/custom-tab-bar/index.tsx
+++ /dev/null
@@ -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 ;
- }
-}
diff --git a/apps/mini-user/src/lib/api.ts b/apps/mini-user/src/lib/api.ts
index 2a7dec1..acb9dea 100644
--- a/apps/mini-user/src/lib/api.ts
+++ b/apps/mini-user/src/lib/api.ts
@@ -1,5 +1,6 @@
import Taro from '@tarojs/taro';
import { ClientApp } from '@dukang/shared-types';
+import { goLogin } from './auth-nav';
function resolveApiBase(): string {
const origin =
@@ -52,7 +53,7 @@ export function isLoggedIn(): boolean {
}
export function redirectToLogin() {
- Taro.navigateTo({ url: '/pages/login/index' });
+ goLogin();
}
export function logout() {
@@ -119,4 +120,5 @@ export type UserProfile = {
id: string;
phone?: string | null;
nickname?: string | null;
+ avatarUrl?: string | null;
};
diff --git a/apps/mini-user/src/lib/auth-nav.ts b/apps/mini-user/src/lib/auth-nav.ts
new file mode 100644
index 0000000..e8497c1
--- /dev/null
+++ b/apps/mini-user/src/lib/auth-nav.ts
@@ -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 }
+ | 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' });
+}
diff --git a/apps/mini-user/src/lib/nav-bar.ts b/apps/mini-user/src/lib/nav-bar.ts
new file mode 100644
index 0000000..9d79c8b
--- /dev/null
+++ b/apps/mini-user/src/lib/nav-bar.ts
@@ -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 {
+ 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 {
+ return {
+ paddingTop: `${metrics.statusBarHeight}px`,
+ height: `${metrics.navBarHeight}px`,
+ paddingRight: `${metrics.navBarPaddingRight}px`,
+ ...pageShellCssVars(metrics),
+ };
+}
diff --git a/apps/mini-user/src/lib/product-images.ts b/apps/mini-user/src/lib/product-images.ts
index c7a5644..4e5274b 100644
--- a/apps/mini-user/src/lib/product-images.ts
+++ b/apps/mini-user/src/lib/product-images.ts
@@ -30,4 +30,16 @@ export function getProductImages(source?: ProductImageSource | null): string[] {
return PRODUCT_IMAGE_FALLBACK ? [PRODUCT_IMAGE_FALLBACK] : [];
}
+/** 详情页顶部轮播 */
+export function getProductCarouselImages(source?: ProductImageSource | null): string[] {
+ const carousel = uniqueUrls(source?.carouselUrls ?? []);
+ if (carousel.length > 0) return carousel;
+ return getProductImages(source);
+}
+
+/** 详情页图文长图 */
+export function getProductDetailImages(source?: ProductImageSource | null): string[] {
+ return uniqueUrls(source?.detailImageUrls ?? []);
+}
+
export const FALLBACK_CITY_CODE = '410100';
diff --git a/apps/mini-user/src/lib/wechat-auth.ts b/apps/mini-user/src/lib/wechat-auth.ts
new file mode 100644
index 0000000..d2ded0c
--- /dev/null
+++ b/apps/mini-user/src/lib/wechat-auth.ts
@@ -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 {
+ const res = await Taro.login();
+ if (!res.code) {
+ throw new Error(res.errMsg || '微信登录失败,未获取到 code');
+ }
+ return request('/auth/login/wechat', {
+ method: 'POST',
+ data: { code: res.code, platform: 'mini' },
+ });
+}
diff --git a/apps/mini-user/src/pages/address-edit/index.config.ts b/apps/mini-user/src/pages/address-edit/index.config.ts
new file mode 100644
index 0000000..434bbce
--- /dev/null
+++ b/apps/mini-user/src/pages/address-edit/index.config.ts
@@ -0,0 +1,4 @@
+export default definePageConfig({
+ navigationStyle: 'custom',
+ navigationBarTitleText: '编辑地址',
+});
diff --git a/apps/mini-user/src/pages/address-edit/index.tsx b/apps/mini-user/src/pages/address-edit/index.tsx
new file mode 100644
index 0000000..40eaf89
--- /dev/null
+++ b/apps/mini-user/src/pages/address-edit/index.tsx
@@ -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 (
+
+
+
+
+ 收货人
+ setName(e.detail.value)}
+ />
+
+
+ 手机号
+ setPhone(e.detail.value)}
+ />
+
+
+ 所在地区
+ toast('区域选择器后续接入')}
+ >
+ {region || '请选择省市区'}
+
+
+
+ 详细地址
+
+
+
+ 保存
+
+
+ );
+}
diff --git a/apps/mini-user/src/pages/addresses/index.config.ts b/apps/mini-user/src/pages/addresses/index.config.ts
new file mode 100644
index 0000000..20e9799
--- /dev/null
+++ b/apps/mini-user/src/pages/addresses/index.config.ts
@@ -0,0 +1,4 @@
+export default definePageConfig({
+ navigationStyle: 'custom',
+ navigationBarTitleText: '地址管理',
+});
diff --git a/apps/mini-user/src/pages/addresses/index.tsx b/apps/mini-user/src/pages/addresses/index.tsx
new file mode 100644
index 0000000..495deb1
--- /dev/null
+++ b/apps/mini-user/src/pages/addresses/index.tsx
@@ -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([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ request('/user/addresses')
+ .then((data) => setList(Array.isArray(data) ? data : []))
+ .catch(() => setList([]))
+ .finally(() => setLoading(false));
+ }, []);
+
+ return (
+
+
+
+ {loading ? 加载中… : null}
+ {!loading && list.length === 0 ? (
+ 暂无收货地址
+ ) : null}
+ {list.map((a) => (
+
+
+ {a.receiverName}
+ {a.phone}
+ {a.isDefault ? 默认 : null}
+
+
+ {[a.province, a.city, a.district, a.detail].filter(Boolean).join(' ')}
+
+
+
+ Taro.navigateTo({ url: `/pages/address-edit/index?id=${a.id}` })
+ }
+ >
+ 编辑
+
+ toast('删除功能后续接入')}
+ >
+ 删除
+
+
+
+ ))}
+
+ Taro.navigateTo({ url: '/pages/address-edit/index' })}
+ >
+ 新增地址
+
+
+ );
+}
diff --git a/apps/mini-user/src/pages/benefit-detail/index.config.ts b/apps/mini-user/src/pages/benefit-detail/index.config.ts
new file mode 100644
index 0000000..d3ea07a
--- /dev/null
+++ b/apps/mini-user/src/pages/benefit-detail/index.config.ts
@@ -0,0 +1,4 @@
+export default definePageConfig({
+ navigationStyle: 'custom',
+ navigationBarTitleText: '权益明细',
+});
diff --git a/apps/mini-user/src/pages/benefit-detail/index.tsx b/apps/mini-user/src/pages/benefit-detail/index.tsx
new file mode 100644
index 0000000..463b1c1
--- /dev/null
+++ b/apps/mini-user/src/pages/benefit-detail/index.tsx
@@ -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([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ request('/benefit/ledger')
+ .then((data) => setItems(Array.isArray(data) ? data : []))
+ .catch(() => {
+ // UI shell fallback demo rows when API missing
+ setItems([]);
+ })
+ .finally(() => setLoading(false));
+ }, []);
+
+ return (
+
+
+
+
+ {loading ? 加载中… : null}
+ {!loading && items.length === 0 ? (
+ 暂无明细记录
+ ) : null}
+ {items.map((item) => {
+ const isOut = Number(item.amount) < 0 || item.type === 'REDEEM';
+ return (
+
+
+ {item.title || (isOut ? '门店核销' : '购酒入账')}
+
+ {item.createdAt ? String(item.createdAt).slice(0, 19).replace('T', ' ') : '-'}
+
+
+
+ {isOut ? '' : '+'}
+ {Number(item.amount).toFixed(2)}
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/apps/mini-user/src/pages/benefit/index.tsx b/apps/mini-user/src/pages/benefit/index.tsx
index ce84aca..a908d45 100644
--- a/apps/mini-user/src/pages/benefit/index.tsx
+++ b/apps/mini-user/src/pages/benefit/index.tsx
@@ -1,35 +1,182 @@
-import { View, Text, Button } from '@tarojs/components';
+import { useEffect, useState } from 'react';
+import { View, Text } from '@tarojs/components';
import Taro, { useDidShow } from '@tarojs/taro';
-import TabMainHeader from '../../components/TabMainHeader';
+import PageShell from '../../components/PageShell';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
-import { isLoggedIn } from '../../lib/api';
+import { goLogin } from '../../lib/auth-nav';
+import { isLoggedIn, request, toast } from '../../lib/api';
+import { navBarStyle, useNavBarMetrics } from '../../lib/nav-bar';
+
+type BenefitSummary = {
+ totalBalance: number;
+ maxRedeemAmount: number;
+ activeCouponCount: number;
+};
+
+type CouponItem = {
+ id: string;
+ couponNo: string;
+ totalAmount: number;
+ usedAmount: number;
+ balance: number;
+ status: string;
+ sourceProduct: string;
+};
+
+function formatMoney(amount: number) {
+ return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+}
+
+function usagePercent(coupon: CouponItem) {
+ const total = Number(coupon.totalAmount);
+ if (total <= 0) return 0;
+ return Math.min(100, Math.round((Number(coupon.usedAmount) / total) * 100));
+}
export default function BenefitPage() {
+ const metrics = useNavBarMetrics();
const loggedIn = isLoggedIn();
+ const [summary, setSummary] = useState(null);
+ const [coupons, setCoupons] = useState([]);
+ const [tab, setTab] = useState<'available' | 'history'>('available');
useDidShow(() => {
syncTabBarSelected(2);
});
+ useEffect(() => {
+ if (!loggedIn) return;
+ Promise.all([
+ request('/benefit/summary'),
+ request('/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 (
-
-
-
- {loggedIn ? (
- 权益列表将在后续迭代接入
- ) : (
-
- 登录后查看好客权益余额
-
+
+
+
+
+
+ 郑州市
- )}
+ 好客权益
+ toast('消息通知即将开放')}>
+ 🔔
+
+
+
+ {!loggedIn ? (
+
+ 登录后查看好客权益余额
+ goLogin('/pages/benefit/index')}
+ >
+ 去登录
+
+
+ ) : (
+
+
+
+
+ 当前好客权益余额
+
+ ¥
+
+ {summary ? formatMoney(summary.totalBalance) : '--'}
+
+
+
+
+ 康
+
+
+
+ Taro.navigateTo({ url: '/pages/benefit-detail/index' })}
+ >
+ 查看权益明细 ›
+
+
+ Taro.navigateTo({ url: '/pages/redeem/index' })}
+ >
+ 去使用
+
+
+
+
+ setTab('available')}
+ >
+ 可用权益
+
+ setTab('history')}
+ >
+ 历史记录
+
+
+
+ {visible.length === 0 ? (
+ {tab === 'available' ? '暂无可用权益' : '暂无历史记录'}
+ ) : (
+ visible.map((c) => (
+
+
+
+
+ {c.sourceProduct || '好客权益'}
+ ¥{formatMoney(c.balance)}
+
+ NO. {c.couponNo}
+
+
+
+
+
+ 已用 ¥{formatMoney(c.usedAmount)} / 总额 ¥{formatMoney(c.totalAmount)}
+
+ {tab === 'available' ? (
+
+ Taro.navigateTo({
+ url: `/pages/redeem/index?couponId=${c.id}&amount=${c.balance}`,
+ })
+ }
+ >
+ 立即核销
+
+ ) : null}
+
+
+ ))
+ )}
+
+ )}
+
{shouldRenderPageTabBar() ? : null}
-
+
);
}
diff --git a/apps/mini-user/src/pages/customer-service/index.config.ts b/apps/mini-user/src/pages/customer-service/index.config.ts
new file mode 100644
index 0000000..3e5eb8b
--- /dev/null
+++ b/apps/mini-user/src/pages/customer-service/index.config.ts
@@ -0,0 +1,4 @@
+export default definePageConfig({
+ navigationStyle: 'custom',
+ navigationBarTitleText: '联系客服',
+});
diff --git a/apps/mini-user/src/pages/customer-service/index.tsx b/apps/mini-user/src/pages/customer-service/index.tsx
new file mode 100644
index 0000000..e41e552
--- /dev/null
+++ b/apps/mini-user/src/pages/customer-service/index.tsx
@@ -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 (
+
+
+
+
+ 您好,我是杜康好客小助手。请问有什么可以帮您?
+
+
+ 我想了解好客权益怎么用
+
+
+
+ 购酒后获得的好客权益可在签约门店到店核销。进入「好客权益」选择可用券,输入金额生成核销码即可。
+
+
+
+
+ {QUICK.map((q) => (
+ {
+ if (q === '联系人工客服') {
+ Taro.makePhoneCall({ phoneNumber: '4008000000' }).catch(() =>
+ toast('客服热线后续配置'),
+ );
+ } else {
+ toast(q);
+ }
+ }}
+ >
+ {q}
+
+ ))}
+
+
+ );
+}
diff --git a/apps/mini-user/src/pages/home/index.tsx b/apps/mini-user/src/pages/home/index.tsx
index 2ccd214..43cb994 100644
--- a/apps/mini-user/src/pages/home/index.tsx
+++ b/apps/mini-user/src/pages/home/index.tsx
@@ -1,11 +1,13 @@
import { useEffect, useState } from 'react';
import { View, Text, Image } from '@tarojs/components';
-import { useDidShow } from '@tarojs/taro';
+import Taro, { useDidShow } from '@tarojs/taro';
+import PageShell from '../../components/PageShell';
import TabMainHeader from '../../components/TabMainHeader';
import CouponBadge from '../../components/CouponBadge';
+import ProductCarousel from '../../components/ProductCarousel';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
import { request, toast } from '../../lib/api';
-import { FALLBACK_CITY_CODE, getProductMainImage } from '../../lib/product-images';
+import { FALLBACK_CITY_CODE, getProductImages } from '../../lib/product-images';
type Product = {
id: string;
@@ -50,15 +52,19 @@ export default function HomePage() {
setTab(key);
}
+ function openProductDetail(id: string) {
+ Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` });
+ }
+
const filtered = products.filter((p) => p.aromaType === tab);
const onSale = tab === 'QINGXIANG';
return (
-
+
+
郑州市
@@ -86,29 +92,24 @@ export default function HomePage() {
{!loading &&
onSale &&
filtered.map((p) => {
- const cover = getProductMainImage(p);
+ const images = getProductImages(p);
return (
- {cover ? (
-
- ) : (
-
- )}
-
-
- {p.name}
- ¥{Number(p.price).toFixed(2)}
-
- {p.subtitle ? {p.subtitle} : null}
-
-
+ openProductDetail(p.id)}>
+
+
+
+ {p.name}
+ ¥{Number(p.price).toFixed(2)}
+
+ {p.subtitle ? {p.subtitle} : null}
+
+
+
- toast('详情页开发中')}
- >
+ openProductDetail(p.id)}>
立即购买
@@ -118,6 +119,6 @@ export default function HomePage() {
{shouldRenderPageTabBar() ? : null}
-
+
);
}
diff --git a/apps/mini-user/src/pages/login/index.config.ts b/apps/mini-user/src/pages/login/index.config.ts
index d8d09bd..97b3227 100644
--- a/apps/mini-user/src/pages/login/index.config.ts
+++ b/apps/mini-user/src/pages/login/index.config.ts
@@ -1,3 +1,4 @@
export default definePageConfig({
+ navigationStyle: 'custom',
navigationBarTitleText: '登录',
});
diff --git a/apps/mini-user/src/pages/login/index.css b/apps/mini-user/src/pages/login/index.css
deleted file mode 100644
index 948d4b4..0000000
--- a/apps/mini-user/src/pages/login/index.css
+++ /dev/null
@@ -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;
-}
diff --git a/apps/mini-user/src/pages/login/index.tsx b/apps/mini-user/src/pages/login/index.tsx
index 69e8180..859d1d9 100644
--- a/apps/mini-user/src/pages/login/index.tsx
+++ b/apps/mini-user/src/pages/login/index.tsx
@@ -1,49 +1,119 @@
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import { View, Text, Input, Button } from '@tarojs/components';
-import Taro from '@tarojs/taro';
-import { SmsScene } from '@dukang/shared-types';
+import { useRouter } from '@tarojs/taro';
+import {
+ SmsScene,
+ isWxAuthorizeEnabled,
+ type ClientRuntimeConfig,
+ type WechatLoginResult,
+} from '@dukang/shared-types';
+import PageShell from '../../components/PageShell';
+import { finishLoginNavigate } from '../../lib/auth-nav';
import { request, saveAuth, toast, type SessionPayload } from '../../lib/api';
-import './index.css';
+import { loginWithWechat } from '../../lib/wechat-auth';
+
+function normalizePhone(value: string) {
+ return value.replace(/\D/g, '').slice(0, 11);
+}
+
+function isValidPhone(phone: string) {
+ return /^1[3-9]\d{9}$/.test(phone);
+}
export default function LoginPage() {
- const [phone, setPhone] = useState('');
- const [code, setCode] = useState(process.env.TARO_ENV === 'h5' ? '123456' : '');
- const [cooldown, setCooldown] = useState(0);
- const [loading, setLoading] = useState(false);
- const [msg, setMsg] = useState('');
+ const router = useRouter();
+ const returnTo = router.params.return || '';
- async function sendCode() {
- if (cooldown > 0) return;
+ const [phone, setPhone] = useState('');
+ const [code, setCode] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [wxLoading, setWxLoading] = useState(false);
+ const [sending, setSending] = useState(false);
+ const [cooldown, setCooldown] = useState(0);
+ const [agreed, setAgreed] = useState(true);
+ const [msg, setMsg] = useState('');
+ const [sentHint, setSentHint] = useState('');
+ const [bindMode, setBindMode] = useState(false);
+ const [wxSessionKey, setWxSessionKey] = useState(null);
+ const [wxAuthorize, setWxAuthorize] = useState(true);
+
+ useEffect(() => {
+ request('/common/client-config')
+ .then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
+ .catch(() => setWxAuthorize(true));
+ }, []);
+
+ useEffect(() => {
+ if (cooldown <= 0) return;
+ const timer = setTimeout(() => setCooldown((c) => Math.max(0, c - 1)), 1000);
+ return () => clearTimeout(timer);
+ }, [cooldown]);
+
+ function ensureAgreed() {
+ if (!agreed) {
+ setMsg('请先勾选并同意用户协议');
+ return false;
+ }
+ return true;
+ }
+
+ function applySessionAndLeave(data: SessionPayload | WechatLoginResult) {
+ if (!data.accessToken) return;
+ saveAuth({
+ accessToken: data.accessToken,
+ refreshToken: data.refreshToken,
+ });
+ toast('登录成功', 'success');
+ finishLoginNavigate(returnTo);
+ }
+
+ function handleWechatLoginResult(result: WechatLoginResult) {
+ if (result.needBindPhone && result.wxSessionKey) {
+ setBindMode(true);
+ setWxSessionKey(result.wxSessionKey);
+ setMsg('微信授权成功,请绑定手机号完成登录');
+ setSentHint('');
+ return;
+ }
+ if (result.accessToken) {
+ applySessionAndLeave(result);
+ return;
+ }
+ setMsg('微信登录未完成,请重试或使用手机号登录');
+ }
+
+ async function onSendCode() {
+ if (!ensureAgreed()) return;
+ if (cooldown > 0 || sending) return;
const normalized = phone.trim();
- if (!/^1[3-9]\d{9}$/.test(normalized)) {
+ if (!isValidPhone(normalized)) {
setMsg('请输入正确的手机号');
return;
}
setMsg('');
+ setSentHint('');
+ setSending(true);
try {
await request('/auth/sms/send', {
method: 'POST',
- data: { phone: normalized, scene: SmsScene.USER_LOGIN },
+ data: {
+ phone: normalized,
+ scene: bindMode ? SmsScene.BIND_PHONE : SmsScene.USER_LOGIN,
+ },
});
- toast('验证码已发送');
setCooldown(60);
- const timer = setInterval(() => {
- setCooldown((c) => {
- if (c <= 1) {
- clearInterval(timer);
- return 0;
- }
- return c - 1;
- });
- }, 1000);
+ setSentHint('验证码已发送');
} catch (e) {
setMsg(e instanceof Error ? e.message : '发送失败');
+ } finally {
+ setSending(false);
}
}
async function login() {
+ if (!ensureAgreed()) return;
const normalized = phone.trim();
- if (!/^1[3-9]\d{9}$/.test(normalized)) {
+ if (!isValidPhone(normalized)) {
setMsg('请输入正确的手机号');
return;
}
@@ -53,14 +123,21 @@ export default function LoginPage() {
}
setLoading(true);
setMsg('');
+ setSentHint('');
try {
+ if (bindMode && wxSessionKey) {
+ const data = await request('/auth/wechat/bind-phone', {
+ method: 'POST',
+ data: { wxSessionKey, phone: normalized, code: code.trim() },
+ });
+ handleWechatLoginResult(data);
+ return;
+ }
const data = await request('/auth/login/sms', {
method: 'POST',
data: { phone: normalized, code: code.trim() },
});
- saveAuth(data);
- toast('登录成功', 'success');
- Taro.switchTab({ url: '/pages/home/index' });
+ applySessionAndLeave(data);
} catch (e) {
setMsg(e instanceof Error ? e.message : '登录失败');
} finally {
@@ -68,47 +145,127 @@ export default function LoginPage() {
}
}
+ async function wechatLogin() {
+ if (!ensureAgreed()) return;
+ setMsg('');
+ setSentHint('');
+ setWxLoading(true);
+ try {
+ const result = await loginWithWechat();
+ handleWechatLoginResult(result);
+ } catch (e) {
+ setMsg(e instanceof Error ? e.message : '微信登录失败');
+ } finally {
+ setWxLoading(false);
+ }
+ }
+
+ const displayMsg = msg || sentHint;
+ const codeDisabled = cooldown > 0 || sending;
+
return (
-
-
- 杜康好客
- 用户端小程序
+
+
+
+
+ 康
+
+ 官方
+
+
+ 欢迎来到杜康好客
+ 买美酒,享好礼
+
-
- 手机号登录
- setPhone(e.detail.value)}
- />
-
- setCode(e.detail.value)}
- />
-
+
+
+ setAgreed((v) => !v)}>
+
+ {agreed ? ✓ : null}
+
+
+ 我已阅读并同意
+ 《用户协议》
+ 和
+ 《隐私政策》
+
+
+
+
);
}
diff --git a/apps/mini-user/src/pages/mine/index.tsx b/apps/mini-user/src/pages/mine/index.tsx
index c379c7d..0f632fe 100644
--- a/apps/mini-user/src/pages/mine/index.tsx
+++ b/apps/mini-user/src/pages/mine/index.tsx
@@ -1,13 +1,34 @@
import { useEffect, useState } from 'react';
-import { View, Text, Button } from '@tarojs/components';
+import { View, Text, Image } from '@tarojs/components';
import Taro, { useDidShow } from '@tarojs/taro';
+import PageShell from '../../components/PageShell';
import TabMainHeader from '../../components/TabMainHeader';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
+import { goLogin } from '../../lib/auth-nav';
import { isLoggedIn, logout, request, toast, type UserProfile } from '../../lib/api';
+const ORDER_SHORTCUTS = [
+ { tab: 'pending_pay', icon: '付', label: '待付款' },
+ { tab: 'paid', icon: '包', label: '已付款' },
+ { tab: 'completed', icon: '成', label: '已完成' },
+] as const;
+
+const SERVICES = [
+ { icon: '址', label: '地址管理', url: '/pages/addresses/index' },
+ { icon: '店', label: '可用门店', tab: '/pages/stores/index' },
+ { icon: '服', label: '联系客服', url: '/pages/customer-service/index' },
+ { icon: '关', label: '关于我们', action: 'about' as const },
+] as const;
+
+function formatMoney(amount: number) {
+ return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+}
+
export default function MinePage() {
- const [profile, setProfile] = useState(null);
const loggedIn = isLoggedIn();
+ const [profile, setProfile] = useState(null);
+ const [benefitBalance, setBenefitBalance] = useState(0);
+ const [orderCounts, setOrderCounts] = useState>({});
useDidShow(() => {
syncTabBarSelected(3);
@@ -15,43 +36,187 @@ export default function MinePage() {
useEffect(() => {
if (!loggedIn) return;
- request('/auth/me')
- .then(setProfile)
- .catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
+ Promise.all([
+ request('/auth/me'),
+ request>>('/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>).reduce((sum, c) => {
+ if (String(c.status) === 'ACTIVE') return sum + Number(c.balance || 0);
+ return sum;
+ }, 0);
+ setBenefitBalance(balance);
+ const counts: Record = {};
+ ORDER_SHORTCUTS.forEach((s, i) => {
+ counts[s.tab] = (totals[i] as { total: number })?.total ?? 0;
+ });
+ setOrderCounts(counts);
+ })
+ .catch(() => {});
}, [loggedIn]);
+ function handleService(item: (typeof SERVICES)[number]) {
+ if ('url' in item && item.url) {
+ Taro.navigateTo({ url: item.url });
+ return;
+ }
+ if ('tab' in item && item.tab) {
+ Taro.switchTab({ url: item.tab });
+ return;
+ }
+ if ('action' in item && item.action === 'about') {
+ toast('杜康好客 · 传承千年酒文化');
+ }
+ }
+
+ if (!loggedIn) {
+ return (
+
+
+
+
+
+
+ 客
+
+
+ 未登录
+ 好客会员
+
+
+
+
+ 登录后管理订单与个人信息
+ goLogin('/pages/mine/index')}
+ >
+ 去登录
+
+
+ {shouldRenderPageTabBar() ? : null}
+
+ );
+ }
+
+ const nickname = profile?.nickname || '用户';
+ const avatarUrl = profile?.avatarUrl;
+
return (
-
+
-
- {loggedIn ? (
-
-
- {profile?.nickname || '用户'}
-
-
- {profile?.phone || '未绑定手机'}
-
-
- 订单 / 地址 / 核销码等页面后续从 h5-user 迁移
-
- logout()}>
- 退出登录
-
+
+
+
+
+ {avatarUrl ? (
+
+ ) : (
+ {nickname.slice(0, 1)}
+ )}
- ) : (
- 登录后管理订单与个人信息
- Taro.navigateTo({ url: '/pages/login/index' })}
- >
- 去登录
-
+ {nickname}
+ {profile?.phone || '好客会员'}
- )}
+
+
+
+
+
+ 我的资产
+ Taro.switchTab({ url: '/pages/benefit/index' })}
+ >
+ 查看明细 ›
+
+
+
+
+ 好客权益余额
+
+ ¥
+ {formatMoney(benefitBalance)}
+
+
+ Taro.navigateTo({ url: '/pages/redeem/index' })}
+ >
+ 去使用
+
+
+
+
+
+
+ 我的订单
+ Taro.navigateTo({ url: '/pages/orders/index' })}
+ >
+ 全部订单 ›
+
+
+
+ {ORDER_SHORTCUTS.map((item) => {
+ const count = orderCounts[item.tab] ?? 0;
+ return (
+
+ Taro.navigateTo({ url: `/pages/orders/index?tab=${item.tab}` })
+ }
+ >
+
+ {item.icon}
+
+ {count > 0 ? (
+ {count > 99 ? '99+' : count}
+ ) : null}
+ {item.label}
+
+ );
+ })}
+
+
+
+
+
+ 常用服务
+
+
+ {SERVICES.map((item) => (
+ handleService(item)}
+ >
+
+ {item.icon}
+
+ {item.label}
+
+ ))}
+
+
+
+
+ 杜康好客 mini-user v0.1.0
+ logout()}>
+ 退出登录
+
+
+
+
{shouldRenderPageTabBar() ? : null}
-
+
);
}
diff --git a/apps/mini-user/src/pages/order-confirm/index.config.ts b/apps/mini-user/src/pages/order-confirm/index.config.ts
new file mode 100644
index 0000000..f837e1e
--- /dev/null
+++ b/apps/mini-user/src/pages/order-confirm/index.config.ts
@@ -0,0 +1,4 @@
+export default definePageConfig({
+ navigationStyle: 'custom',
+ navigationBarTitleText: '确认订单',
+});
diff --git a/apps/mini-user/src/pages/order-confirm/index.tsx b/apps/mini-user/src/pages/order-confirm/index.tsx
new file mode 100644
index 0000000..0db0044
--- /dev/null
+++ b/apps/mini-user/src/pages/order-confirm/index.tsx
@@ -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(null);
+ const [qty, setQty] = useState(initialQty);
+
+ useEffect(() => {
+ if (!productId) return;
+ request(`/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 (
+
+
+
+ Taro.navigateTo({ url: '/pages/addresses/index' })}
+ >
+ 收货地址
+ 点击选择收货地址(同城起购 2 瓶)
+
+
+
+ 商品信息
+ {product ? (
+
+
+
+ {getProductMainImage(product) ? (
+
+ ) : null}
+
+
+ {product.name}
+ ¥{Number(product.price).toFixed(2)}
+
+
+
+ 购买数量
+
+ changeQty(-1)}>
+ −
+
+ {qty}
+ changeQty(1)}>
+ +
+
+
+
+
+ ) : (
+ 加载中…
+ )}
+
+
+
+ 费用明细
+
+ 商品金额
+ ¥{total.toFixed(2)}
+
+
+ 好客权益
+ ¥{benefit.toFixed(2)}
+
+
+ 运费
+ 到付
+
+
+
+
+
+
+ 应付合计
+ ¥{total.toFixed(2)}
+
+
+ 提交订单
+
+
+
+ );
+}
diff --git a/apps/mini-user/src/pages/order-detail/index.config.ts b/apps/mini-user/src/pages/order-detail/index.config.ts
new file mode 100644
index 0000000..58da757
--- /dev/null
+++ b/apps/mini-user/src/pages/order-detail/index.config.ts
@@ -0,0 +1,4 @@
+export default definePageConfig({
+ navigationStyle: 'custom',
+ navigationBarTitleText: '订单详情',
+});
diff --git a/apps/mini-user/src/pages/order-detail/index.tsx b/apps/mini-user/src/pages/order-detail/index.tsx
new file mode 100644
index 0000000..ca56b54
--- /dev/null
+++ b/apps/mini-user/src/pages/order-detail/index.tsx
@@ -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(null);
+
+ useEffect(() => {
+ if (!orderId) return;
+ request(`/trade/orders/${orderId}`)
+ .then(setOrder)
+ .catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
+ }, [orderId]);
+
+ return (
+
+
+
+ {!order ? (
+ 加载中…
+ ) : (
+ <>
+
+ 订单状态
+ {order.status || '处理中'}
+
+
+ 商品信息
+
+ {order.productName || '杜康商品'}
+ x{order.qty ?? 1}
+
+
+ 实付金额
+
+ ¥{Number(order.payAmount ?? 0).toFixed(2)}
+
+
+
+
+ 收货信息
+
+ {order.addressSnapshot || '地址信息待完善'}
+
+
+
+ 订单信息
+
+ 订单编号
+ {order.orderNo || order.id}
+
+
+ 下单时间
+
+ {order.createdAt ? String(order.createdAt).slice(0, 19).replace('T', ' ') : '-'}
+
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/mini-user/src/pages/orders/index.config.ts b/apps/mini-user/src/pages/orders/index.config.ts
new file mode 100644
index 0000000..a28bb6e
--- /dev/null
+++ b/apps/mini-user/src/pages/orders/index.config.ts
@@ -0,0 +1,4 @@
+export default definePageConfig({
+ navigationStyle: 'custom',
+ navigationBarTitleText: '我的订单',
+});
diff --git a/apps/mini-user/src/pages/orders/index.tsx b/apps/mini-user/src/pages/orders/index.tsx
new file mode 100644
index 0000000..034709d
--- /dev/null
+++ b/apps/mini-user/src/pages/orders/index.tsx
@@ -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([]);
+ 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 (
+
+
+
+ {TABS.map((t) => (
+ setTab(t.key)}
+ >
+ {t.label}
+
+ ))}
+
+
+ {loading ? 加载中… : null}
+ {!loading && orders.length === 0 ? 暂无订单 : null}
+ {!loading &&
+ orders.map((o) => (
+ Taro.navigateTo({ url: `/pages/order-detail/index?id=${o.id}` })}
+ >
+
+ {o.orderNo || o.id}
+
+ {TABS.find((t) => t.key === tab)?.label || o.status || ''}
+
+
+
+
+
+ {o.productName || '杜康商品'}
+
+ 数量 {o.qty ?? 1} · {o.createdAt ? String(o.createdAt).slice(0, 10) : ''}
+
+
+
+
+ 实付
+
+ ¥{Number(o.payAmount ?? 0).toFixed(2)}
+
+
+
+ ))}
+
+ );
+}
diff --git a/apps/mini-user/src/pages/pay/index.config.ts b/apps/mini-user/src/pages/pay/index.config.ts
new file mode 100644
index 0000000..0a5748a
--- /dev/null
+++ b/apps/mini-user/src/pages/pay/index.config.ts
@@ -0,0 +1,4 @@
+export default definePageConfig({
+ navigationStyle: 'custom',
+ navigationBarTitleText: '收银台',
+});
diff --git a/apps/mini-user/src/pages/pay/index.tsx b/apps/mini-user/src/pages/pay/index.tsx
new file mode 100644
index 0000000..41a85c6
--- /dev/null
+++ b/apps/mini-user/src/pages/pay/index.tsx
@@ -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 (
+
+
+
+
+
+ ¥
+
+ 待支付
+ ¥{amount}
+
+
+
+ 支付方式
+ 微信支付
+
+
+ 说明
+ 小程序支付后续接入
+
+
+
+
+
+ 立即支付
+
+
+
+ );
+}
diff --git a/apps/mini-user/src/pages/product-detail/index.config.ts b/apps/mini-user/src/pages/product-detail/index.config.ts
new file mode 100644
index 0000000..0603472
--- /dev/null
+++ b/apps/mini-user/src/pages/product-detail/index.config.ts
@@ -0,0 +1,4 @@
+export default definePageConfig({
+ navigationStyle: 'custom',
+ navigationBarTitleText: '商品详情',
+});
diff --git a/apps/mini-user/src/pages/product-detail/index.tsx b/apps/mini-user/src/pages/product-detail/index.tsx
new file mode 100644
index 0000000..f300ce7
--- /dev/null
+++ b/apps/mini-user/src/pages/product-detail/index.tsx
@@ -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(null);
+ const [headerSolid, setHeaderSolid] = useState(false);
+
+ usePageScroll(({ scrollTop }) => {
+ setHeaderSolid(scrollTop > 100);
+ });
+
+ useEffect(() => {
+ if (!productId) return;
+ request(`/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 (
+
+
+ 加载中…
+
+ );
+ }
+
+ 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 (
+
+ toast('分享功能开发中')}>
+ ⤴
+
+ )}
+ />
+
+
+
+
+
+
+
+
+ ¥
+ {Number(product.price).toFixed(2)}
+
+ {product.name}
+ {product.subtitle ? (
+ {product.subtitle}
+ ) : null}
+
+
+
+
+
+ 惠
+
+
+ 买杜康美酒 · 享全城好客礼遇
+ ¥{benefit}
+
+
+
+ 购酒即享本城专属好客权益,为您安排一场地道饭局。权益金可直接用于本地签约门店消费,到店享用,醇香美酒配佳肴。
+
+
+
+
+
+
+
+ 商品详情
+
+
+ {detailImages.map((src, index) => (
+
+ ))}
+
+ {(detail.storyTitle || detail.storyText || features.length > 0) ? (
+
+ {(detail.storyTitle || detail.storyText) ? (
+
+ {detail.storyTitle ? (
+ {detail.storyTitle}
+ ) : null}
+ {detail.storyText ? (
+ {detail.storyText}
+ ) : null}
+
+ ) : null}
+
+ {features.length > 0 ? (
+
+ {features.map((f) => (
+
+ ★
+ {f.title}
+ {f.desc}
+
+ ))}
+
+ ) : null}
+
+ ) : null}
+
+
+
+
+
+
+ 首页
+
+
+ 立即购买
+
+
+
+ );
+}
diff --git a/apps/mini-user/src/pages/redeem-code/index.config.ts b/apps/mini-user/src/pages/redeem-code/index.config.ts
new file mode 100644
index 0000000..591bc19
--- /dev/null
+++ b/apps/mini-user/src/pages/redeem-code/index.config.ts
@@ -0,0 +1,4 @@
+export default definePageConfig({
+ navigationStyle: 'custom',
+ navigationBarTitleText: '核销码',
+});
diff --git a/apps/mini-user/src/pages/redeem-code/index.tsx b/apps/mini-user/src/pages/redeem-code/index.tsx
new file mode 100644
index 0000000..d3bc11a
--- /dev/null
+++ b/apps/mini-user/src/pages/redeem-code/index.tsx
@@ -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 (
+
+
+
+
+ 核销金额 ¥{Number(amount).toFixed(2)}
+ {code}
+ 剩余有效时间 {mm}:{ss}
+
+ 请向门店店员出示此码完成核销
+
+
+
+ Taro.redirectTo({
+ url: `/pages/redeem-success/index?amount=${amount}`,
+ })
+ }
+ >
+ 模拟核销成功
+
+
+
+ );
+}
diff --git a/apps/mini-user/src/pages/redeem-success/index.config.ts b/apps/mini-user/src/pages/redeem-success/index.config.ts
new file mode 100644
index 0000000..7a4956f
--- /dev/null
+++ b/apps/mini-user/src/pages/redeem-success/index.config.ts
@@ -0,0 +1,4 @@
+export default definePageConfig({
+ navigationStyle: 'custom',
+ navigationBarTitleText: '核销成功',
+});
diff --git a/apps/mini-user/src/pages/redeem-success/index.tsx b/apps/mini-user/src/pages/redeem-success/index.tsx
new file mode 100644
index 0000000..59bd1cf
--- /dev/null
+++ b/apps/mini-user/src/pages/redeem-success/index.tsx
@@ -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 (
+
+
+
+
+ ✓
+
+ 核销成功
+
+ 已核销好客权益 ¥{Number(amount).toFixed(2)}
+
+ Taro.switchTab({ url: '/pages/benefit/index' })}
+ >
+ 返回权益
+
+ Taro.switchTab({ url: '/pages/home/index' })}
+ >
+ 回到首页
+
+
+
+ );
+}
diff --git a/apps/mini-user/src/pages/redeem/index.config.ts b/apps/mini-user/src/pages/redeem/index.config.ts
new file mode 100644
index 0000000..4cb922d
--- /dev/null
+++ b/apps/mini-user/src/pages/redeem/index.config.ts
@@ -0,0 +1,4 @@
+export default definePageConfig({
+ navigationStyle: 'custom',
+ navigationBarTitleText: '权益核销',
+});
diff --git a/apps/mini-user/src/pages/redeem/index.tsx b/apps/mini-user/src/pages/redeem/index.tsx
new file mode 100644
index 0000000..6f9d767
--- /dev/null
+++ b/apps/mini-user/src/pages/redeem/index.tsx
@@ -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('/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 (
+
+
+
+
+ 可用余额
+ ¥{balance.toFixed(2)}
+
+
+ setAmount(e.detail.value)}
+ />
+
+
+ 直接核销:金额须大于 0 且不超过全部可用权益余额。核销码有效期 3 分钟,请到店出示。
+
+
+ 生成核销码
+
+
+
+ );
+}
diff --git a/apps/mini-user/src/pages/store-detail/index.config.ts b/apps/mini-user/src/pages/store-detail/index.config.ts
new file mode 100644
index 0000000..e0bc464
--- /dev/null
+++ b/apps/mini-user/src/pages/store-detail/index.config.ts
@@ -0,0 +1,4 @@
+export default definePageConfig({
+ navigationStyle: 'custom',
+ navigationBarTitleText: '门店详情',
+});
diff --git a/apps/mini-user/src/pages/store-detail/index.tsx b/apps/mini-user/src/pages/store-detail/index.tsx
new file mode 100644
index 0000000..c81f563
--- /dev/null
+++ b/apps/mini-user/src/pages/store-detail/index.tsx
@@ -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(null);
+ const [headerSolid, setHeaderSolid] = useState(false);
+
+ usePageScroll(({ scrollTop }) => {
+ setHeaderSolid(scrollTop > 100);
+ });
+
+ useEffect(() => {
+ if (!storeId) return;
+ request(`/stores/${storeId}`)
+ .then(setStore)
+ .catch(() => {
+ request('/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 (
+
+
+ 加载中…
+
+ );
+ }
+
+ const images =
+ (store.carouselUrls && store.carouselUrls.length > 0
+ ? store.carouselUrls
+ : store.coverUrl
+ ? [store.coverUrl]
+ : []) as string[];
+
+ return (
+
+
+
+
+
+
+
+
+ {store.name}
+
+ {store.district ? `${store.district} · ` : ''}
+ {store.address || '地址待完善'}
+
+
+ 营业时间: {store.openTime && store.closeTime ? `${store.openTime}-${store.closeTime}` : '10:00-22:00'}
+
+ {store.phone ? 电话: {store.phone} : null}
+
+ {store.category?.name ? (
+ {store.category.name}
+ ) : null}
+ 可核销
+ 好客门店
+
+
+
+
+ 门店服务
+ 支持好客权益到店核销,部分门店提供包间预约。
+
+
+
+ {
+ if (store.phone) {
+ Taro.makePhoneCall({ phoneNumber: store.phone }).catch(() => toast('无法拨打'));
+ } else {
+ toast('暂无联系电话');
+ }
+ }}
+ >
+ 联系门店
+
+ Taro.navigateTo({ url: '/pages/redeem/index' })}
+ >
+ 去核销
+
+
+
+ );
+}
diff --git a/apps/mini-user/src/pages/stores/index.tsx b/apps/mini-user/src/pages/stores/index.tsx
index 448b49b..05337c5 100644
--- a/apps/mini-user/src/pages/stores/index.tsx
+++ b/apps/mini-user/src/pages/stores/index.tsx
@@ -1,7 +1,9 @@
import { useEffect, useState } from 'react';
-import { View, Text } from '@tarojs/components';
-import { useDidShow } from '@tarojs/taro';
+import { View, Text, Image, Input } from '@tarojs/components';
+import Taro, { useDidShow } from '@tarojs/taro';
+import PageShell from '../../components/PageShell';
import TabMainHeader from '../../components/TabMainHeader';
+import RegionPicker from '../../components/RegionPicker';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
import { request, toast } from '../../lib/api';
@@ -9,12 +11,23 @@ type Store = {
id: string;
name: string;
address?: string;
+ district?: string;
+ coverUrl?: string | null;
+ openTime?: string | null;
+ closeTime?: string | null;
status?: string;
};
+const CATEGORY_TABS = ['全部', '火锅', '地方菜', '高端餐饮', '烧烤烤肉', '西餐'] as const;
+const MOCK_DISTANCES = ['800m', '1.2km', '3.5km', '1.5km', '2.0km'];
+
export default function StoresPage() {
const [stores, setStores] = useState([]);
const [loading, setLoading] = useState(true);
+ const [categoryTab, setCategoryTab] = useState('全部');
+ const [keyword, setKeyword] = useState('');
+ const [regionLabel, setRegionLabel] = useState('郑州市 · 全部区域');
+ const [regionOpen, setRegionOpen] = useState(false);
useDidShow(() => {
syncTabBarSelected(1);
@@ -27,29 +40,93 @@ export default function StoresPage() {
.finally(() => setLoading(false));
}, []);
+ const filtered = stores.filter((s) => {
+ if (!keyword.trim()) return true;
+ const q = keyword.trim();
+ return s.name.includes(q) || (s.address ?? '').includes(q) || (s.district ?? '').includes(q);
+ });
+
+ function formatHours(store: Store) {
+ if (store.openTime && store.closeTime) {
+ return `营业时间: ${store.openTime}-${store.closeTime}`;
+ }
+ return '营业时间: 10:00-22:00';
+ }
+
return (
-
+
-
- {loading ? (
- 加载中…
- ) : stores.length === 0 ? (
- 暂无营业中门店
- ) : (
- stores.map((s) => (
-
-
-
- {s.name}
-
+
+ setRegionOpen(true)}>
+
+ {regionLabel} ▾
+
+ setKeyword(e.detail.value)}
+ />
+
+
+
+ {CATEGORY_TABS.map((tab) => (
+ setCategoryTab(tab)}
+ >
+ {tab}
+
+ ))}
+
+
+
+ {loading ? 加载中… : null}
+ {!loading && filtered.length === 0 ? 暂无营业中门店 : null}
+ {!loading &&
+ filtered.map((s, index) => (
+ Taro.navigateTo({ url: `/pages/store-detail/index?id=${s.id}` })}
+ >
+ {s.coverUrl ? (
+
+ ) : (
+
+ )}
+
+ {s.name}
+
+ {s.district ? `${s.district} · ` : ''}
{s.address || '地址待完善'}
+ {formatHours(s)}
+
+ {MOCK_DISTANCES[index % MOCK_DISTANCES.length]}
+ {
+ e.stopPropagation();
+ Taro.navigateTo({ url: '/pages/redeem/index' });
+ }}
+ >
+ 去核销
+
+
- ))
- )}
+ ))}
+
{shouldRenderPageTabBar() ? : null}
-
+ setRegionOpen(false)}
+ onConfirm={(label) => setRegionLabel(`${label} · 全部区域`)}
+ />
+
);
}
diff --git a/apps/mini-user/src/styles/address.css b/apps/mini-user/src/styles/address.css
new file mode 100644
index 0000000..4b3658a
--- /dev/null
+++ b/apps/mini-user/src/styles/address.css
@@ -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;
+}
diff --git a/apps/mini-user/src/styles/base.css b/apps/mini-user/src/styles/base.css
new file mode 100644
index 0000000..a9d893f
--- /dev/null
+++ b/apps/mini-user/src/styles/base.css
@@ -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);
+}
diff --git a/apps/mini-user/src/styles/benefit.css b/apps/mini-user/src/styles/benefit.css
new file mode 100644
index 0000000..95ccf54
--- /dev/null
+++ b/apps/mini-user/src/styles/benefit.css
@@ -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;
+}
diff --git a/apps/mini-user/src/styles/home.css b/apps/mini-user/src/styles/home.css
index 30d434b..17b8f7d 100644
--- a/apps/mini-user/src/styles/home.css
+++ b/apps/mini-user/src/styles/home.css
@@ -1,42 +1,4 @@
-/* Tab 主页面顶栏 + 首页(对齐 h5-user) */
-.tab-main-header {
- position: sticky;
- top: 0;
- z-index: 50;
- display: flex;
- align-items: center;
- justify-content: center;
- height: 56px;
- padding: env(safe-area-inset-top, 0px) var(--space-page) 0;
- padding-top: calc(env(safe-area-inset-top, 0px));
- min-height: calc(56px + env(safe-area-inset-top, 0px));
- background: var(--color-background);
- box-shadow: var(--shadow-card);
- box-sizing: border-box;
-}
-
-.tab-main-header__title {
- font-family: var(--font-headline);
- font-size: 18px;
- font-weight: 700;
- color: var(--color-heritage-red);
- line-height: 56px;
-}
-
-.tab-main-header__extra {
- position: absolute;
- right: var(--space-page);
- top: calc(env(safe-area-inset-top, 0px) + 28px);
- transform: translateY(-50%);
- display: flex;
- align-items: center;
- gap: 4px;
- color: var(--color-heritage-red);
- font-size: 12px;
- font-weight: 500;
- max-width: 46vw;
-}
-
+/* 首页(Tab 顶栏样式见 styles/nav-bar.css) */
.tab-main-city-pin {
width: 14px;
height: 14px;
@@ -78,7 +40,7 @@
background: rgba(250, 249, 247, 0.95);
border-bottom: 1px solid var(--color-surface-container);
position: sticky;
- top: calc(56px + env(safe-area-inset-top, 0px));
+ top: var(--nav-bar-height, 56px);
z-index: 40;
}
@@ -118,18 +80,52 @@
box-shadow: var(--shadow-card);
}
-.home-product-cover {
+.home-carousel-wrap {
+ position: relative;
width: 100%;
- height: 200px;
+ aspect-ratio: 1;
background: var(--color-surface-container);
- display: block;
+ overflow: hidden;
}
-.home-product-cover--empty {
- height: 200px;
+.home-carousel {
+ width: 100%;
+ height: 100%;
+}
+
+.home-carousel-item,
+.home-carousel-image,
+.home-carousel-placeholder {
+ width: 100%;
+ height: 100%;
+}
+
+.home-carousel-placeholder {
background: var(--color-surface-container);
}
+.home-carousel-dots {
+ position: absolute;
+ bottom: 12px;
+ left: 0;
+ right: 0;
+ display: flex;
+ justify-content: center;
+}
+
+.home-carousel-dot {
+ height: 4px;
+ width: 6px;
+ margin: 0 3px;
+ border-radius: 999px;
+ background: rgba(153, 153, 153, 0.35);
+}
+
+.home-carousel-dot--active {
+ width: 20px;
+ background: var(--color-heritage-red);
+}
+
.home-product-body {
padding: var(--space-gutter);
display: flex;
diff --git a/apps/mini-user/src/styles/login.css b/apps/mini-user/src/styles/login.css
new file mode 100644
index 0000000..e8d84fb
--- /dev/null
+++ b/apps/mini-user/src/styles/login.css
@@ -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);
+}
diff --git a/apps/mini-user/src/styles/mine.css b/apps/mini-user/src/styles/mine.css
new file mode 100644
index 0000000..9eb5c12
--- /dev/null
+++ b/apps/mini-user/src/styles/mine.css
@@ -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;
+}
diff --git a/apps/mini-user/src/styles/nav-bar.css b/apps/mini-user/src/styles/nav-bar.css
new file mode 100644
index 0000000..df674b5
--- /dev/null
+++ b/apps/mini-user/src/styles/nav-bar.css
@@ -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;
+}
diff --git a/apps/mini-user/src/styles/order.css b/apps/mini-user/src/styles/order.css
new file mode 100644
index 0000000..16e3505
--- /dev/null
+++ b/apps/mini-user/src/styles/order.css
@@ -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;
+}
diff --git a/apps/mini-user/src/styles/product-detail.css b/apps/mini-user/src/styles/product-detail.css
new file mode 100644
index 0000000..9ca8194
--- /dev/null
+++ b/apps/mini-user/src/styles/product-detail.css
@@ -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;
+}
diff --git a/apps/mini-user/src/styles/redeem.css b/apps/mini-user/src/styles/redeem.css
new file mode 100644
index 0000000..98717ff
--- /dev/null
+++ b/apps/mini-user/src/styles/redeem.css
@@ -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);
+}
diff --git a/apps/mini-user/src/styles/store-detail.css b/apps/mini-user/src/styles/store-detail.css
new file mode 100644
index 0000000..1c3efdf
--- /dev/null
+++ b/apps/mini-user/src/styles/store-detail.css
@@ -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;
+}
diff --git a/apps/mini-user/src/styles/stores.css b/apps/mini-user/src/styles/stores.css
new file mode 100644
index 0000000..a01c3fd
--- /dev/null
+++ b/apps/mini-user/src/styles/stores.css
@@ -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;
+}