feat: 技术支持工单/企微权限/开发版本管理/消息推送等迭代
@@ -1,72 +0,0 @@
|
||||
# @dukang/mini-user
|
||||
|
||||
杜康好客 C 端微信小程序(Taro 4 + React)。UI 对齐 `apps/h5-user`。
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
# H5 预览(联调 API)
|
||||
pnpm --filter @dukang/mini-user dev
|
||||
# → http://localhost:5177
|
||||
|
||||
# 微信小程序(本地联调,development)
|
||||
pnpm --filter @dukang/mini-user dev:weapp
|
||||
|
||||
# 微信小程序(生产构建 → API: https://api.dukanghaoke.com)
|
||||
pnpm build:mini-user:weapp
|
||||
# 用微信开发者工具打开 apps/mini-user(miniprogramRoot = dist/)
|
||||
```
|
||||
|
||||
## API 地址
|
||||
|
||||
| 环节 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| **编译注入** | `config/index.ts` → `defineConstants.TARO_APP_API_ORIGIN` | `dev:weapp` / watch → `localhost:3000`;`build:weapp`(`--mode production`)→ `https://api.dukanghaoke.com`;可用 `VITE_API_TARGET` 覆盖 |
|
||||
| **运行时拼装** | `src/lib/api.ts` → `resolveApiBase()` | `{origin}/api/v1` |
|
||||
|
||||
### 微信登录 `invalid code`
|
||||
|
||||
`wx.login` 的 code 只能由**与小程序 appid 匹配**的后端 `jscode2session` 兑换。
|
||||
|
||||
| 项 | 当前值 |
|
||||
|----|--------|
|
||||
| 小程序 appid | `project.config.json` → `wxda31c8e8e85051e7` |
|
||||
| 后端须配置 | `WX_MINI_APP_ID` / `WX_MINI_APP_SECRET`(与上表一致) |
|
||||
|
||||
**本地联调(不接真实微信)**:保持默认即可(API → `localhost:3000`),并开启 Mock:
|
||||
|
||||
```bash
|
||||
# 终端 1
|
||||
pnpm dev:api # server/.env 保持 MOCK_SMS=true / MOCK_WECHAT=true
|
||||
|
||||
# 终端 2
|
||||
pnpm dev:mini-user # H5 预览 :5177
|
||||
# 或
|
||||
pnpm --filter @dukang/mini-user dev:weapp
|
||||
```
|
||||
|
||||
**连远程 API**:
|
||||
|
||||
```bash
|
||||
$env:VITE_API_TARGET="https://api.dukanghaoke.com"; pnpm --filter @dukang/mini-user dev
|
||||
```
|
||||
|
||||
并在 `api.dukanghaoke.com` 所在服务器配置 `WX_MINI_APP_ID=wxda31c8e8e85051e7` 及对应 AppSecret。
|
||||
|
||||
## 页面结构(18 页)
|
||||
|
||||
**Tab**:首页 / 门店 / 好客权益 / 我的
|
||||
|
||||
**栈页**:商品详情、门店详情、确认订单、收银台、订单列表/详情、地址列表/编辑、客服、权益明细、核销/核销码/成功、登录
|
||||
|
||||
## 布局约定
|
||||
|
||||
- 统一 `PageShell`(`tab` / `scroll` / `sub` / `plain`)注入刘海屏 CSS 变量
|
||||
- Tab 顶栏:`TabMainHeader`;详情滚动顶栏:`PageNavBar`;子页:`SubPageHeader`
|
||||
- 样式:`src/styles/*.css` 按业务域拆分,入口 `app.css`
|
||||
|
||||
## 约定
|
||||
|
||||
- `X-Client-App: USER_MINI`
|
||||
- 底栏原生 `tabBar` + PNG(weapp);H5 内嵌 `UserTabBar`
|
||||
- 交易/核销页面以 UI 壳为主,部分 API 可后续加深
|
||||
@@ -1,5 +0,0 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
['taro', { framework: 'react', ts: true, compiler: 'vite' }],
|
||||
],
|
||||
};
|
||||
@@ -1,134 +0,0 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { defineConfig } from '@tarojs/cli';
|
||||
|
||||
/** watch / --mode development 视为本地联调;其余(含 build:weapp)走生产 */
|
||||
const isDevMode =
|
||||
process.env.NODE_ENV === 'development' ||
|
||||
process.argv.includes('--watch') ||
|
||||
process.argv.includes('development');
|
||||
|
||||
/** 小程序/H5 请求的后端 origin(不含 /api);本地默认本机,生产构建默认远程 */
|
||||
const API_ORIGIN =
|
||||
process.env.VITE_API_TARGET ??
|
||||
(isDevMode ? 'http://localhost:3000' : 'https://api.dukanghaoke.com');
|
||||
|
||||
const requireFromApp = createRequire(path.resolve(__dirname, '../package.json'));
|
||||
|
||||
/** 解析包目录/文件,避免 pnpm/Vite 把同一 runtime 打成两份 → useDidShow 读到空 reactMeta */
|
||||
function resolvePkgFile(id: string): string {
|
||||
return requireFromApp.resolve(id);
|
||||
}
|
||||
|
||||
function resolvePkgDir(id: string): string {
|
||||
return path.dirname(requireFromApp.resolve(`${id}/package.json`));
|
||||
}
|
||||
|
||||
const TARO_FRAMEWORK_RUNTIME = resolvePkgFile('@tarojs/plugin-framework-react/dist/runtime.js');
|
||||
const REACT_DIR = resolvePkgDir('react');
|
||||
const REACT_DOM_DIR = resolvePkgDir('react-dom');
|
||||
const TARO_RUNTIME_DIR = resolvePkgDir('@tarojs/runtime');
|
||||
const TARO_SHARED_DIR = resolvePkgDir('@tarojs/shared');
|
||||
|
||||
export default defineConfig(async () => ({
|
||||
projectName: 'mini-user',
|
||||
date: '2026-7-12',
|
||||
designWidth: 375,
|
||||
deviceRatio: {
|
||||
640: 2.34 / 2,
|
||||
750: 1,
|
||||
375: 2,
|
||||
828: 1.81 / 2,
|
||||
},
|
||||
sourceRoot: 'src',
|
||||
outputRoot: 'dist',
|
||||
plugins: ['@tarojs/plugin-framework-react', '@tarojs/plugin-html'],
|
||||
alias: {
|
||||
// 指向包目录(不是 index.js),否则 react-dom/client 会变成 index.js/client
|
||||
react: REACT_DIR,
|
||||
'react-dom': REACT_DOM_DIR,
|
||||
'@tarojs/runtime': TARO_RUNTIME_DIR,
|
||||
'@tarojs/shared': TARO_SHARED_DIR,
|
||||
'@tarojs/plugin-framework-react/dist/runtime': TARO_FRAMEWORK_RUNTIME,
|
||||
'@tarojs/plugin-framework-react/dist/runtime.js': TARO_FRAMEWORK_RUNTIME,
|
||||
},
|
||||
defineConstants: {
|
||||
TARO_APP_API_ORIGIN: JSON.stringify(API_ORIGIN),
|
||||
},
|
||||
copy: {
|
||||
patterns: [
|
||||
{ from: 'src/assets/', to: 'assets/' },
|
||||
],
|
||||
options: {},
|
||||
},
|
||||
framework: 'react',
|
||||
compiler: {
|
||||
type: 'vite',
|
||||
vitePlugins: [
|
||||
{
|
||||
name: 'dukang-mini-user-vite-memory',
|
||||
config() {
|
||||
return {
|
||||
// H5 Vite watch 预构建 Stencil 组件时峰值极易 >8GB;noDiscovery 降低冷启动成本
|
||||
optimizeDeps: {
|
||||
noDiscovery: true,
|
||||
include: ['react', 'react-dom', 'react/jsx-runtime'],
|
||||
},
|
||||
resolve: {
|
||||
// 防止 reactMeta(useDidShow 依赖)在 vendors 里出现两份
|
||||
dedupe: [
|
||||
'react',
|
||||
'react-dom',
|
||||
'@tarojs/runtime',
|
||||
'@tarojs/shared',
|
||||
'@tarojs/plugin-framework-react',
|
||||
],
|
||||
},
|
||||
build: {
|
||||
sourcemap: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
cache: {
|
||||
enable: true,
|
||||
},
|
||||
mini: {
|
||||
/** dev:weapp 预览模式需开启,否则 React hooks 在 Vite 下会失效 */
|
||||
debugReact: isDevMode,
|
||||
postcss: {
|
||||
pxtransform: { enable: true, config: {} },
|
||||
cssModules: { enable: false },
|
||||
},
|
||||
},
|
||||
h5: {
|
||||
// staging:user-test.dukanghaoke.com 根路径;生产:remote-release 注入 /user/
|
||||
publicPath: process.env.TARO_H5_PUBLIC_PATH || '/',
|
||||
...(process.env.TARO_H5_ROUTER_BASENAME
|
||||
? {
|
||||
router: {
|
||||
mode: 'browser' as const,
|
||||
basename: process.env.TARO_H5_ROUTER_BASENAME,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
staticDirectory: 'static',
|
||||
devServer: {
|
||||
port: 5177,
|
||||
host: '0.0.0.0',
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: API_ORIGIN,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
postcss: {
|
||||
autoprefixer: { enable: true, config: {} },
|
||||
pxtransform: { enable: true, config: {} },
|
||||
cssModules: { enable: false },
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -1,63 +0,0 @@
|
||||
{
|
||||
"name": "@dukang/mini-user",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "杜康好客 · C 端用户微信小程序(Taro)",
|
||||
"scripts": {
|
||||
"dev": "node ../../scripts/dev-mini-user-h5.mjs",
|
||||
"dev:vite": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type h5 --watch",
|
||||
"dev:weapp": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type weapp --watch --mode development",
|
||||
"build": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type h5 --mode production",
|
||||
"build:weapp": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type weapp --mode production",
|
||||
"lint": "echo ok"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.24.4",
|
||||
"@dukang/client-logging": "workspace:*",
|
||||
"@dukang/shared-types": "workspace:*",
|
||||
"@dukang/shared-ui": "workspace:*",
|
||||
"@dukang/weixin-sdk": "workspace:*",
|
||||
"element-china-area-data": "^6.1.0",
|
||||
"@tarojs/components": "4.2.0",
|
||||
"@tarojs/helper": "4.2.0",
|
||||
"@tarojs/plugin-framework-react": "4.2.0",
|
||||
"@tarojs/plugin-html": "4.2.0",
|
||||
"@tarojs/plugin-platform-h5": "4.2.0",
|
||||
"@tarojs/plugin-platform-weapp": "4.2.0",
|
||||
"@tarojs/react": "4.2.0",
|
||||
"@tarojs/router": "4.2.0",
|
||||
"@tarojs/runtime": "4.2.0",
|
||||
"@tarojs/shared": "4.2.0",
|
||||
"@tarojs/taro": "4.2.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.4",
|
||||
"@babel/plugin-proposal-decorators": "^7.24.1",
|
||||
"@babel/plugin-transform-class-properties": "^7.24.1",
|
||||
"@babel/preset-react": "^7.24.1",
|
||||
"@tarojs/cli": "4.2.0",
|
||||
"@tarojs/vite-runner": "4.2.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"babel-preset-taro": "4.2.0",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.4.0"
|
||||
},
|
||||
"browserslist": {
|
||||
"development": [
|
||||
"defaults and fully supports es6-module",
|
||||
"maintained node versions",
|
||||
"Android >= 4.1",
|
||||
"ios >= 8"
|
||||
],
|
||||
"production": [
|
||||
"defaults and fully supports es6-module",
|
||||
"maintained node versions",
|
||||
"Android >= 4.1",
|
||||
"ios >= 8"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"miniprogramRoot": "dist/",
|
||||
"projectname": "mini-user",
|
||||
"description": "杜康好客用户端",
|
||||
"appid": "wxda31c8e8e85051e7",
|
||||
"setting": {
|
||||
"urlCheck": true,
|
||||
"es6": false,
|
||||
"enhance": false,
|
||||
"postcss": false,
|
||||
"minified": false
|
||||
},
|
||||
"compileType": "miniprogram",
|
||||
"preloadBackgroundData": false
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"cleanUrls": false,
|
||||
"trailingSlash": false,
|
||||
"rewrites": [
|
||||
{ "source": "**", "destination": "/index.html" }
|
||||
]
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
export default defineAppConfig({
|
||||
pages: [
|
||||
'pages/home/index',
|
||||
'pages/stores/index',
|
||||
'pages/benefit/index',
|
||||
'pages/mine/index',
|
||||
'pages/product-detail/index',
|
||||
'pages/store-detail/index',
|
||||
'pages/order-confirm/index',
|
||||
'pages/order-confirm-pickup/index',
|
||||
'pages/pay/index',
|
||||
'pages/orders/index',
|
||||
'pages/order-detail/index',
|
||||
'pages/pickup-receive/index',
|
||||
'pages/addresses/index',
|
||||
'pages/address-edit/index',
|
||||
'pages/customer-service/index',
|
||||
'pages/benefit-detail/index',
|
||||
'pages/redeem/index',
|
||||
'pages/redeem-code/index',
|
||||
'pages/redeem-success/index',
|
||||
'pages/login/index',
|
||||
'pages/user-agreement/index',
|
||||
'pages/privacy-policy/index',
|
||||
],
|
||||
window: {
|
||||
backgroundTextStyle: 'light',
|
||||
navigationBarBackgroundColor: '#FAF9F7',
|
||||
navigationBarTitleText: '杜康好客',
|
||||
navigationBarTextStyle: 'black',
|
||||
backgroundColor: '#FAF9F7',
|
||||
},
|
||||
permission: {
|
||||
'scope.userLocation': {
|
||||
desc: '用于展示您所在城市的商品与门店',
|
||||
},
|
||||
},
|
||||
requiredPrivateInfos: ['getLocation'],
|
||||
tabBar: {
|
||||
custom: false,
|
||||
color: '#999999',
|
||||
selectedColor: '#A61D24',
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderStyle: 'black',
|
||||
list: [
|
||||
{
|
||||
pagePath: 'pages/home/index',
|
||||
text: '首页',
|
||||
iconPath: 'assets/tabbar/home.png',
|
||||
selectedIconPath: 'assets/tabbar/home-active.png',
|
||||
},
|
||||
{
|
||||
pagePath: 'pages/stores/index',
|
||||
text: '门店',
|
||||
iconPath: 'assets/tabbar/store.png',
|
||||
selectedIconPath: 'assets/tabbar/store-active.png',
|
||||
},
|
||||
{
|
||||
pagePath: 'pages/benefit/index',
|
||||
text: '好客权益',
|
||||
iconPath: 'assets/tabbar/benefit.png',
|
||||
selectedIconPath: 'assets/tabbar/benefit-active.png',
|
||||
},
|
||||
{
|
||||
pagePath: 'pages/mine/index',
|
||||
text: '我的',
|
||||
iconPath: 'assets/tabbar/mine.png',
|
||||
selectedIconPath: 'assets/tabbar/mine-active.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
@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/legal.css';
|
||||
@import './styles/order.css';
|
||||
@import './styles/address.css';
|
||||
@import './styles/redeem.css';
|
||||
|
||||
page,
|
||||
body {
|
||||
background: var(--color-background);
|
||||
color: var(--color-on-surface);
|
||||
font-family: var(--font-body);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 全局隐藏滚动条(H5 + 小程序均可滚动,仅隐藏轨道) */
|
||||
html,
|
||||
page,
|
||||
body,
|
||||
#app,
|
||||
.taro_page,
|
||||
.taro_router,
|
||||
.taro-tabbar__panel {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* legacy Edge */
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar,
|
||||
page::-webkit-scrollbar,
|
||||
body::-webkit-scrollbar,
|
||||
#app::-webkit-scrollbar,
|
||||
.taro_page::-webkit-scrollbar,
|
||||
.taro_router::-webkit-scrollbar,
|
||||
.taro-tabbar__panel::-webkit-scrollbar {
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
display: none !important;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* H5:页面内已渲染 UserTabBar,隐藏 Taro 自带底栏,避免双层 Tab */
|
||||
.taro-tabbar__tabbar,
|
||||
.taro-tabbar__border {
|
||||
display: none !important;
|
||||
height: 0 !important;
|
||||
visibility: hidden !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.taro-tabbar__container,
|
||||
.taro-tabbar__panel {
|
||||
padding-bottom: 0 !important;
|
||||
bottom: 0 !important;
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import './lib/intl-polyfill';
|
||||
import './lib/text-encoding-polyfill';
|
||||
import { PropsWithChildren, useRef } from 'react';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import WechatShareBootstrap from './components/WechatShareBootstrap';
|
||||
import { patchTaroH5Hooks } from './lib/patch-taro-h5-hooks';
|
||||
import { handleWechatOrderConfirmShow } from './lib/wechat-order-confirm';
|
||||
import { installClientErrorReporting } from './lib/client-error';
|
||||
import './app.css';
|
||||
|
||||
// H5:在首屏 page hooks 执行前,把 Taro.useDidShow 等绑到与 createReactApp 同一份 runtime
|
||||
patchTaroH5Hooks();
|
||||
installClientErrorReporting();
|
||||
|
||||
function App({ children }: PropsWithChildren) {
|
||||
const handlingRef = useRef(false);
|
||||
|
||||
useDidShow((options?: {
|
||||
referrerInfo?: {
|
||||
appId?: string;
|
||||
extraData?: { status?: string; errormsg?: string; req_extradata?: Record<string, string> };
|
||||
};
|
||||
}) => {
|
||||
if (process.env.TARO_ENV !== 'weapp') return;
|
||||
if (handlingRef.current) return;
|
||||
|
||||
const referrerInfo =
|
||||
options?.referrerInfo ||
|
||||
(typeof Taro.getEnterOptionsSync === 'function'
|
||||
? (
|
||||
Taro.getEnterOptionsSync() as {
|
||||
referrerInfo?: {
|
||||
appId?: string;
|
||||
extraData?: {
|
||||
status?: string;
|
||||
errormsg?: string;
|
||||
req_extradata?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
}
|
||||
).referrerInfo
|
||||
: undefined);
|
||||
if (!referrerInfo?.appId) return;
|
||||
|
||||
handlingRef.current = true;
|
||||
void handleWechatOrderConfirmShow({ referrerInfo })
|
||||
.then((result) => {
|
||||
if (result.redirectUrl) {
|
||||
Taro.redirectTo({ url: result.redirectUrl }).catch(() => {
|
||||
Taro.reLaunch({ url: result.redirectUrl! });
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!result.handled || !result.orderId) return;
|
||||
const pages = Taro.getCurrentPages();
|
||||
const cur = pages[pages.length - 1] as { route?: string } | undefined;
|
||||
const route = cur?.route || '';
|
||||
if (route.includes('pickup-receive')) {
|
||||
Taro.redirectTo({ url: '/pages/orders/index?tab=all' }).catch(() => {});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
handlingRef.current = false;
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<WechatShareBootstrap />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
Before Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 207 B |
|
Before Width: | Height: | Size: 224 B |
|
Before Width: | Height: | Size: 241 B |
|
Before Width: | Height: | Size: 271 B |
|
Before Width: | Height: | Size: 225 B |
|
Before Width: | Height: | Size: 261 B |
|
Before Width: | Height: | Size: 184 B |
|
Before Width: | Height: | Size: 188 B |
@@ -1,159 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
|
||||
export type StoreCategoryNode = {
|
||||
id: string;
|
||||
name: string;
|
||||
children?: StoreCategoryNode[];
|
||||
};
|
||||
|
||||
export type CategorySelection = {
|
||||
parentId: string;
|
||||
parentName: string;
|
||||
childId: string;
|
||||
childName: string;
|
||||
};
|
||||
|
||||
export const EMPTY_CATEGORY: CategorySelection = {
|
||||
parentId: '',
|
||||
parentName: '',
|
||||
childId: '',
|
||||
childName: '',
|
||||
};
|
||||
|
||||
export function formatCategoryLabel(sel: CategorySelection): string {
|
||||
if (sel.childName) return sel.childName;
|
||||
if (sel.parentName) return sel.parentName;
|
||||
return '全部分类';
|
||||
}
|
||||
|
||||
type CategoryPickerProps = {
|
||||
open: boolean;
|
||||
tree: StoreCategoryNode[];
|
||||
value: CategorySelection;
|
||||
onClose: () => void;
|
||||
onConfirm: (next: CategorySelection) => void;
|
||||
};
|
||||
|
||||
type TabKey = 'parent' | 'child';
|
||||
|
||||
export default function CategoryPicker({
|
||||
open,
|
||||
tree,
|
||||
value,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: CategoryPickerProps) {
|
||||
const [draft, setDraft] = useState<CategorySelection>(value);
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('parent');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setDraft(value);
|
||||
setActiveTab(value.parentId ? 'child' : 'parent');
|
||||
}, [open, value]);
|
||||
|
||||
const children = useMemo(() => {
|
||||
const parent = tree.find((n) => n.id === draft.parentId);
|
||||
return parent?.children ?? [];
|
||||
}, [tree, draft.parentId]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
function selectParent(node: StoreCategoryNode | null) {
|
||||
if (!node) {
|
||||
setDraft(EMPTY_CATEGORY);
|
||||
return;
|
||||
}
|
||||
setDraft({
|
||||
parentId: node.id,
|
||||
parentName: node.name,
|
||||
childId: '',
|
||||
childName: '',
|
||||
});
|
||||
setActiveTab('child');
|
||||
}
|
||||
|
||||
function selectChild(node: StoreCategoryNode | null) {
|
||||
if (!node) {
|
||||
setDraft((prev) => ({ ...prev, childId: '', childName: '' }));
|
||||
return;
|
||||
}
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
childId: node.id,
|
||||
childName: node.name,
|
||||
}));
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
onConfirm(draft);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="region-picker-overlay" onClick={onClose}>
|
||||
<View className="region-picker-sheet" onClick={(e) => e.stopPropagation()}>
|
||||
<View className="region-picker-toolbar">
|
||||
<View className="region-picker-tabs">
|
||||
<Text
|
||||
className={`region-picker-tab${activeTab === 'parent' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('parent')}
|
||||
>
|
||||
{draft.parentName || '大类'}
|
||||
</Text>
|
||||
<Text
|
||||
className={`region-picker-tab${activeTab === 'child' ? ' active' : ''}${!draft.parentId ? ' disabled' : ''}`}
|
||||
onClick={() => draft.parentId && setActiveTab('child')}
|
||||
>
|
||||
{draft.childName || '细类'}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="region-picker-confirm ready" onClick={handleConfirm}>
|
||||
确定
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView className="region-picker-list" scrollY showScrollbar={false}>
|
||||
{activeTab === 'parent' ? (
|
||||
<>
|
||||
<View
|
||||
className={`region-picker-option${!draft.parentId ? ' selected' : ''} region-picker-option--all`}
|
||||
onClick={() => selectParent(null)}
|
||||
>
|
||||
<Text>全部分类</Text>
|
||||
</View>
|
||||
{tree.map((item) => (
|
||||
<View
|
||||
key={item.id}
|
||||
className={`region-picker-option${draft.parentId === item.id ? ' selected' : ''}`}
|
||||
onClick={() => selectParent(item)}
|
||||
>
|
||||
<Text>{item.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<View
|
||||
className={`region-picker-option${!draft.childId ? ' selected' : ''} region-picker-option--all`}
|
||||
onClick={() => selectChild(null)}
|
||||
>
|
||||
<Text>全部细类</Text>
|
||||
</View>
|
||||
{children.map((item) => (
|
||||
<View
|
||||
key={item.id}
|
||||
className={`region-picker-option${draft.childId === item.id ? ' selected' : ''}`}
|
||||
onClick={() => selectChild(item)}
|
||||
>
|
||||
<Text>{item.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { Button, Text } from '@tarojs/components';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type ContactCsSessionContext = {
|
||||
orderId?: string;
|
||||
orderNo?: string;
|
||||
from?: string;
|
||||
};
|
||||
|
||||
type ContactCsButtonProps = {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
/** 客服会话来源上下文,便于客服后台识别 */
|
||||
session?: ContactCsSessionContext;
|
||||
};
|
||||
|
||||
const isWeapp = process.env.TARO_ENV === 'weapp';
|
||||
|
||||
/** 组装 session-from(微信限制约 1000 字符) */
|
||||
export function buildCsSessionFrom(session?: ContactCsSessionContext): string {
|
||||
if (!session) return 'dukang|from=mini-user';
|
||||
const parts = ['dukang'];
|
||||
if (session.from) parts.push(`from=${session.from}`);
|
||||
if (session.orderNo) parts.push(`orderNo=${session.orderNo}`);
|
||||
if (session.orderId) parts.push(`orderId=${session.orderId}`);
|
||||
return parts.join('|');
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信小程序客服入口(open-type=contact)。
|
||||
* 非 weapp 环境不渲染,由调用方走电话等兜底。
|
||||
*/
|
||||
export default function ContactCsButton({
|
||||
className = '',
|
||||
children = '联系在线客服',
|
||||
session,
|
||||
}: ContactCsButtonProps) {
|
||||
if (!isWeapp) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={className}
|
||||
openType="contact"
|
||||
sessionFrom={buildCsSessionFrom(session)}
|
||||
hoverClass="none"
|
||||
>
|
||||
{typeof children === 'string' ? <Text>{children}</Text> : children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Text } from '@tarojs/components';
|
||||
|
||||
type CouponBadgeProps = {
|
||||
amount: number | string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
/** Taro 友好版权益角标(对齐 shared-ui CouponBadge) */
|
||||
export default function CouponBadge({ amount, label = '好客权益' }: CouponBadgeProps) {
|
||||
const n = Number(amount);
|
||||
const display = Number.isFinite(n) ? (Number.isInteger(n) ? String(n) : n.toFixed(0)) : String(amount);
|
||||
return (
|
||||
<Text className="coupon-badge">
|
||||
享 ¥{display} {label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { subPageNavBarStyle, subPageNavContentStyle, useNavBarMetrics } from '../lib/nav-bar';
|
||||
|
||||
type PageNavBarProps = {
|
||||
title: string;
|
||||
solid?: boolean;
|
||||
titleVisible?: boolean;
|
||||
onBack?: () => void;
|
||||
right?: ReactNode;
|
||||
};
|
||||
|
||||
const isH5 = process.env.TARO_ENV === 'h5';
|
||||
|
||||
/** 内页自定义导航栏(返回 + 标题 + 右侧操作),适配刘海屏 */
|
||||
export default function PageNavBar({
|
||||
title,
|
||||
solid = false,
|
||||
titleVisible = true,
|
||||
onBack,
|
||||
right,
|
||||
}: PageNavBarProps) {
|
||||
const metrics = useNavBarMetrics();
|
||||
const showTitle = !isH5 && titleVisible;
|
||||
|
||||
return (
|
||||
<View
|
||||
className={`page-nav-bar${solid ? ' page-nav-bar--solid' : ''}`}
|
||||
style={subPageNavBarStyle(metrics)}
|
||||
aria-label={title}
|
||||
>
|
||||
<Text
|
||||
className={`page-nav-bar__title${showTitle ? ' page-nav-bar__title--visible' : ''}`}
|
||||
>
|
||||
{showTitle ? title : ''}
|
||||
</Text>
|
||||
<View
|
||||
className="page-nav-bar__content"
|
||||
style={subPageNavContentStyle(metrics)}
|
||||
>
|
||||
{onBack ? (
|
||||
<View className="page-nav-bar__btn page-nav-bar__btn--back" onClick={onBack}>
|
||||
<Text className="page-nav-bar__icon">‹</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className="page-nav-bar__btn page-nav-bar__btn--back page-nav-bar__btn--placeholder" />
|
||||
)}
|
||||
{right ? (
|
||||
<View className="page-nav-bar__right-slot">{right}</View>
|
||||
) : (
|
||||
<View className="page-nav-bar__btn page-nav-bar__btn--right page-nav-bar__btn--placeholder" />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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' : '',
|
||||
variant === 'tab' && process.env.TARO_ENV === 'weapp' ? 'page-shell--native-tabbar' : '',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<View className={classes} style={pageShellCssVars(metrics)}>
|
||||
{children as ReactNode}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { Button, Text, View } from '@tarojs/components';
|
||||
|
||||
type PhoneQuickLoginButtonProps = {
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
/** 须已主动勾选协议后才挂载 getPhoneNumber,避免未同意即拉起授权 */
|
||||
agreed: boolean;
|
||||
onRequireAgree: () => void;
|
||||
onGetPhoneNumber: (phoneCode: string) => void;
|
||||
onFail?: (message: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 小程序手机号快捷登录(open-type=getPhoneNumber)。
|
||||
* 文案不得使用「微信」字样或仿官方图标,以符合审核要求。
|
||||
*/
|
||||
export default function PhoneQuickLoginButton({
|
||||
loading = false,
|
||||
disabled = false,
|
||||
agreed,
|
||||
onRequireAgree,
|
||||
onGetPhoneNumber,
|
||||
onFail,
|
||||
}: PhoneQuickLoginButtonProps) {
|
||||
const inactive = loading || disabled;
|
||||
const className = `login-phone-quick-btn${inactive ? ' login-phone-quick-btn--disabled' : ''}`;
|
||||
const label = loading ? '登录中...' : '手机号快捷登录';
|
||||
|
||||
if (!agreed) {
|
||||
return (
|
||||
<View className={className} onClick={inactive ? undefined : onRequireAgree}>
|
||||
<Text className="login-phone-quick-btn__text">{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={className}
|
||||
openType={inactive ? undefined : 'getPhoneNumber'}
|
||||
hoverClass="none"
|
||||
onGetPhoneNumber={(e) => {
|
||||
if (inactive) return;
|
||||
const detail = e.detail as {
|
||||
errMsg?: string;
|
||||
code?: string;
|
||||
errno?: number;
|
||||
};
|
||||
if (!detail?.code) {
|
||||
const denied =
|
||||
detail?.errMsg?.includes('deny') ||
|
||||
detail?.errMsg?.includes('cancel') ||
|
||||
detail?.errno === 103;
|
||||
onFail?.(denied ? '已取消手机号授权' : detail?.errMsg || '获取手机号失败');
|
||||
return;
|
||||
}
|
||||
onGetPhoneNumber(detail.code);
|
||||
}}
|
||||
>
|
||||
<Text className="login-phone-quick-btn__text">{label}</Text>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { View, Image, Swiper, SwiperItem } from '@tarojs/components';
|
||||
|
||||
type ProductCarouselProps = {
|
||||
images: string[];
|
||||
alt: string;
|
||||
variant?: 'home' | 'detail' | 'store';
|
||||
};
|
||||
|
||||
/** 商品/门店轮播(对齐 h5 ProductCarousel 三变体) */
|
||||
export default function ProductCarousel({ images, alt, variant = 'detail' }: ProductCarouselProps) {
|
||||
const slides = images.length > 0 ? images : [''];
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const prefix =
|
||||
variant === 'store' ? 'store-detail-carousel' : variant === 'home' ? 'home-carousel' : 'detail-carousel';
|
||||
|
||||
return (
|
||||
<View className={`${prefix}-wrap`}>
|
||||
<Swiper
|
||||
className={prefix}
|
||||
circular={slides.length > 1}
|
||||
onChange={(e) => setActiveIndex(e.detail.current)}
|
||||
>
|
||||
{slides.map((src, index) => (
|
||||
<SwiperItem key={`${src}-${index}`} className={`${prefix}-item`}>
|
||||
{src ? (
|
||||
<Image className={`${prefix}-image`} src={src} mode="aspectFill" alt={alt} />
|
||||
) : (
|
||||
<View className={`${prefix}-placeholder`} />
|
||||
)}
|
||||
</SwiperItem>
|
||||
))}
|
||||
</Swiper>
|
||||
{slides.length > 1 ? (
|
||||
<View className={`${prefix}-dots`}>
|
||||
{slides.map((_, index) => (
|
||||
<View
|
||||
key={index}
|
||||
className={`${prefix}-dot${index === activeIndex ? ` ${prefix}-dot--active` : ''}`}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Canvas } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import {
|
||||
REDEEM_QR_DISPLAY_SIZE,
|
||||
buildRedeemQrDataUrl,
|
||||
drawRedeemQrOnCanvas,
|
||||
} from '../lib/redeem-qr';
|
||||
|
||||
const CANVAS_ID = 'redeem-qr-canvas';
|
||||
|
||||
type RedeemQrCodeProps = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
function drawOnWeappCanvas(token: string) {
|
||||
const page = Taro.getCurrentInstance().page;
|
||||
const query = page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery();
|
||||
query
|
||||
.select(`#${CANVAS_ID}`)
|
||||
.fields({ node: true, size: true })
|
||||
.exec((res) => {
|
||||
const item = res[0] as { node?: WechatMiniprogram.Canvas; width?: number; height?: number } | undefined;
|
||||
const canvas = item?.node;
|
||||
if (!canvas) return;
|
||||
|
||||
const layoutW = item.width || REDEEM_QR_DISPLAY_SIZE;
|
||||
const layoutH = item.height || REDEEM_QR_DISPLAY_SIZE;
|
||||
const drawSize = Math.min(layoutW, layoutH);
|
||||
|
||||
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
|
||||
const dpr = Taro.getSystemInfoSync().pixelRatio || 2;
|
||||
canvas.width = layoutW * dpr;
|
||||
canvas.height = layoutH * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
drawRedeemQrOnCanvas(ctx, token, drawSize);
|
||||
});
|
||||
}
|
||||
|
||||
export default function RedeemQrCode({ token }: RedeemQrCodeProps) {
|
||||
const [imgSrc, setImgSrc] = useState('');
|
||||
const isWeapp = process.env.TARO_ENV === 'weapp';
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setImgSrc('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isWeapp) {
|
||||
const timer = setTimeout(() => drawOnWeappCanvas(token), 120);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void buildRedeemQrDataUrl(token).then((url) => {
|
||||
if (!cancelled) setImgSrc(url);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token, isWeapp]);
|
||||
|
||||
return (
|
||||
<View className="redeem-qr-box">
|
||||
<View className="redeem-qr-placeholder" />
|
||||
{isWeapp ? (
|
||||
<Canvas type="2d" id={CANVAS_ID} canvasId={CANVAS_ID} className="redeem-qr-canvas" />
|
||||
) : imgSrc ? (
|
||||
<View className="redeem-qr-img" style={{ backgroundImage: `url(${imgSrc})` }} />
|
||||
) : null}
|
||||
<View className="redeem-qr-scanline" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import {
|
||||
REGION_ALL,
|
||||
getCities,
|
||||
getCitiesForPicker,
|
||||
getDistricts,
|
||||
getDistrictsForPicker,
|
||||
getProvincesForPicker,
|
||||
normalizeRegionSelection,
|
||||
toCityLevelRegion,
|
||||
type RegionSelection,
|
||||
} from '../lib/region-data';
|
||||
|
||||
type RegionPickerProps = {
|
||||
open: boolean;
|
||||
value: RegionSelection;
|
||||
onClose: () => void;
|
||||
onConfirm: (region: RegionSelection) => void;
|
||||
levels?: 2 | 3;
|
||||
};
|
||||
|
||||
type PickerLevel = 'province' | 'city' | 'district';
|
||||
|
||||
const ALL_TABS: Array<{ key: PickerLevel; label: string }> = [
|
||||
{ key: 'province', label: '省份' },
|
||||
{ key: 'city', label: '城市' },
|
||||
{ key: 'district', label: '区县' },
|
||||
];
|
||||
|
||||
function initialTab(value: RegionSelection, levels: 2 | 3): PickerLevel {
|
||||
const normalized = levels === 2 ? toCityLevelRegion(value) : normalizeRegionSelection(value);
|
||||
if (levels === 2) {
|
||||
return normalized.province && normalized.province !== REGION_ALL ? 'city' : 'province';
|
||||
}
|
||||
if (normalized.district && normalized.district !== REGION_ALL) return 'district';
|
||||
if (normalized.city && normalized.city !== REGION_ALL) return 'city';
|
||||
return 'province';
|
||||
}
|
||||
|
||||
function tabLabel(tab: PickerLevel, draft: RegionSelection, fallback: string) {
|
||||
if (tab === 'province') {
|
||||
return draft.province && draft.province !== REGION_ALL ? draft.province : fallback;
|
||||
}
|
||||
if (tab === 'city') {
|
||||
return draft.city && draft.city !== REGION_ALL ? draft.city : fallback;
|
||||
}
|
||||
return draft.district && draft.district !== REGION_ALL ? draft.district : fallback;
|
||||
}
|
||||
|
||||
export default function RegionPicker({
|
||||
open,
|
||||
value,
|
||||
onClose,
|
||||
onConfirm,
|
||||
levels = 3,
|
||||
}: RegionPickerProps) {
|
||||
const [draft, setDraft] = useState<RegionSelection>(value);
|
||||
const [activeTab, setActiveTab] = useState<PickerLevel>('province');
|
||||
|
||||
const tabs = levels === 2 ? ALL_TABS.slice(0, 2) : ALL_TABS;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const normalized = levels === 2 ? toCityLevelRegion(value) : normalizeRegionSelection(value);
|
||||
setDraft(normalized);
|
||||
setActiveTab(initialTab(value, levels));
|
||||
}, [open, value, levels]);
|
||||
|
||||
const listItems = useMemo(() => {
|
||||
if (activeTab === 'province') return getProvincesForPicker();
|
||||
if (activeTab === 'city') return getCitiesForPicker(draft.province);
|
||||
return getDistrictsForPicker(draft.province, draft.city);
|
||||
}, [activeTab, draft.province, draft.city]);
|
||||
|
||||
const selectedValue =
|
||||
activeTab === 'province' ? draft.province : activeTab === 'city' ? draft.city : draft.district;
|
||||
|
||||
const canConfirm =
|
||||
levels === 2
|
||||
? Boolean(draft.province && draft.city)
|
||||
: Boolean(draft.province && draft.city && draft.district);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
function selectProvince(province: string) {
|
||||
if (province === REGION_ALL) {
|
||||
setDraft({ province: REGION_ALL, city: REGION_ALL, district: REGION_ALL });
|
||||
setActiveTab('city');
|
||||
return;
|
||||
}
|
||||
const nextCities = getCities(province);
|
||||
const city = nextCities[0] ?? '';
|
||||
if (levels === 2) {
|
||||
setDraft({ province, city, district: REGION_ALL });
|
||||
setActiveTab('city');
|
||||
return;
|
||||
}
|
||||
const nextDistricts = getDistricts(province, city);
|
||||
setDraft({ province, city, district: nextDistricts[0] ?? '' });
|
||||
setActiveTab('city');
|
||||
}
|
||||
|
||||
function selectCity(city: string) {
|
||||
if (city === REGION_ALL) {
|
||||
setDraft({ ...draft, city: REGION_ALL, district: REGION_ALL });
|
||||
if (levels === 3) setActiveTab('district');
|
||||
return;
|
||||
}
|
||||
if (levels === 2) {
|
||||
setDraft({ ...draft, city, district: REGION_ALL });
|
||||
return;
|
||||
}
|
||||
const nextDistricts = getDistricts(draft.province, city);
|
||||
setDraft({ ...draft, city, district: nextDistricts[0] ?? '' });
|
||||
setActiveTab('district');
|
||||
}
|
||||
|
||||
function selectDistrict(district: string) {
|
||||
setDraft({ ...draft, district });
|
||||
}
|
||||
|
||||
function onSelectItem(item: string) {
|
||||
if (activeTab === 'province') selectProvince(item);
|
||||
else if (activeTab === 'city') selectCity(item);
|
||||
else selectDistrict(item);
|
||||
}
|
||||
|
||||
function onTabClick(tab: PickerLevel) {
|
||||
if (tab === 'city' && !draft.province) return;
|
||||
if (tab === 'district' && (!draft.province || !draft.city)) return;
|
||||
setActiveTab(tab);
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (!canConfirm) return;
|
||||
const next = levels === 2 ? toCityLevelRegion(draft) : normalizeRegionSelection(draft);
|
||||
onConfirm(next);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="region-picker-overlay" onClick={onClose}>
|
||||
<View className="region-picker-sheet" onClick={(e) => e.stopPropagation()}>
|
||||
<View className="region-picker-toolbar">
|
||||
<View className="region-picker-tabs">
|
||||
{tabs.map((tab) => {
|
||||
const disabled =
|
||||
(tab.key === 'city' && !draft.province) ||
|
||||
(tab.key === 'district' && (!draft.province || !draft.city));
|
||||
return (
|
||||
<Text
|
||||
key={tab.key}
|
||||
className={`region-picker-tab${activeTab === tab.key ? ' active' : ''}${disabled ? ' disabled' : ''}`}
|
||||
onClick={() => !disabled && onTabClick(tab.key)}
|
||||
>
|
||||
{tabLabel(tab.key, draft, tab.label)}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<Text
|
||||
className={`region-picker-confirm${canConfirm ? ' ready' : ''}`}
|
||||
onClick={() => canConfirm && handleConfirm()}
|
||||
>
|
||||
确定
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView className="region-picker-list" scrollY showScrollbar={false}>
|
||||
{listItems.map((item) => (
|
||||
<View
|
||||
key={item}
|
||||
className={`region-picker-option${selectedValue === item ? ' selected' : ''}${
|
||||
item === REGION_ALL ? ' region-picker-option--all' : ''
|
||||
}`}
|
||||
onClick={() => onSelectItem(item)}
|
||||
>
|
||||
<Text>{item}</Text>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
.share-guide {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
padding: 12px 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.share-guide__arrow {
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin-right: 18px;
|
||||
border-left: 10px solid transparent;
|
||||
border-right: 10px solid transparent;
|
||||
border-bottom: 14px solid #fff;
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.12));
|
||||
}
|
||||
|
||||
.share-guide__card {
|
||||
margin-top: 0;
|
||||
margin-right: 8px;
|
||||
max-width: 260px;
|
||||
padding: 16px 18px;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.18);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.share-guide__title {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.share-guide__desc {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.share-guide__ok {
|
||||
display: block;
|
||||
margin-top: 14px;
|
||||
text-align: right;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #a61d24;
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Text, View } from '@tarojs/components';
|
||||
import { handleShareButtonClick, type PageSharePayload } from '../lib/wechat-share';
|
||||
import './ShareGuide.css';
|
||||
|
||||
type ShareNavButtonProps = {
|
||||
payload?: PageSharePayload;
|
||||
};
|
||||
|
||||
/**
|
||||
* 顶栏分享:
|
||||
* - 小程序:open-type=share 弹出微信分享面板
|
||||
* - H5 微信:JSSDK share / 右上角引导蒙层
|
||||
*/
|
||||
export default function ShareNavButton({ payload }: ShareNavButtonProps) {
|
||||
const [guideVisible, setGuideVisible] = useState(false);
|
||||
|
||||
if (process.env.TARO_ENV === 'weapp') {
|
||||
return (
|
||||
<Button className="page-nav-bar__btn page-nav-bar__share-btn" openType="share" hoverClass="none">
|
||||
<Text className="page-nav-bar__icon page-nav-bar__icon--share">⤴</Text>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<View
|
||||
className="page-nav-bar__btn"
|
||||
onClick={() => {
|
||||
void handleShareButtonClick(payload).then((res) => {
|
||||
if (res.showGuide) setGuideVisible(true);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Text className="page-nav-bar__icon page-nav-bar__icon--share">⤴</Text>
|
||||
</View>
|
||||
{guideVisible ? (
|
||||
<View className="share-guide" onClick={() => setGuideVisible(false)}>
|
||||
<View className="share-guide__arrow" />
|
||||
<View className="share-guide__card">
|
||||
<Text className="share-guide__title">分享给好友</Text>
|
||||
<Text className="share-guide__desc">请点击右上角 ··· 选择「发送给朋友」或「分享到朋友圈」</Text>
|
||||
<Text className="share-guide__ok">我知道了</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
|
||||
type StoreRedeemMarqueeProps = {
|
||||
lines: string[];
|
||||
};
|
||||
|
||||
const FLY_SPEED = 56;
|
||||
const MIN_FLY_MS = 2400;
|
||||
const PAUSE_MIN_MS = 1000;
|
||||
const PAUSE_MAX_MS = 5000;
|
||||
const TICK_MS = 16;
|
||||
/** 全文滚出视口后,再向左多走 10px */
|
||||
const EXTRA_AFTER_EXIT_PX = 10;
|
||||
|
||||
function estimateTextWidth(text: string): number {
|
||||
let w = 0;
|
||||
for (const ch of text) {
|
||||
w += /[^\x00-\xff]/.test(ch) ? 12 : 7;
|
||||
}
|
||||
return Math.max(Math.ceil(w), 80);
|
||||
}
|
||||
|
||||
function randomPauseMs() {
|
||||
return PAUSE_MIN_MS + Math.floor(Math.random() * (PAUSE_MAX_MS - PAUSE_MIN_MS + 1));
|
||||
}
|
||||
|
||||
/** 容器宽兜底(不依赖 DOM 测量,小程序首帧即可用) */
|
||||
function getBoxWidthFallback(): number {
|
||||
try {
|
||||
const sys = Taro.getSystemInfoSync();
|
||||
const screenW = Number(sys.windowWidth || sys.screenWidth || 375);
|
||||
// 与 section 同宽:左右 var(--space-page)
|
||||
return Math.max(220, Math.floor(screenW - 32));
|
||||
} catch {
|
||||
return 300;
|
||||
}
|
||||
}
|
||||
|
||||
function measureBoxWidth(selector: string, fallback: number): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
Taro.nextTick(() => {
|
||||
try {
|
||||
const page = Taro.getCurrentInstance().page;
|
||||
const query = page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery();
|
||||
query
|
||||
.select(selector)
|
||||
.boundingClientRect()
|
||||
.exec((res) => {
|
||||
const w = Number(res?.[0]?.width || 0);
|
||||
resolve(w > 8 ? Math.ceil(w) : fallback);
|
||||
});
|
||||
} catch {
|
||||
resolve(fallback);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function measureTextWidth(selector: string, text: string): Promise<number> {
|
||||
const fallback = estimateTextWidth(text);
|
||||
return new Promise((resolve) => {
|
||||
Taro.nextTick(() => {
|
||||
try {
|
||||
const page = Taro.getCurrentInstance().page;
|
||||
const query = page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery();
|
||||
query
|
||||
.select(selector)
|
||||
.boundingClientRect()
|
||||
.exec((res) => {
|
||||
const w = Number(res?.[0]?.width || 0);
|
||||
if (w > 8 && w < fallback * 3) resolve(Math.ceil(w));
|
||||
else resolve(fallback);
|
||||
});
|
||||
} catch {
|
||||
resolve(fallback);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 核销走马灯:单条从右向左位移飞出,间隔 1~5 秒随机再播下一条。
|
||||
*
|
||||
* 小程序注意:
|
||||
* - 不用 useReady(子组件内不触发 → opacity 永远 0)
|
||||
* - 不用 Text + transform(支持差),改用 View + left
|
||||
* - 字宽用估算,避免屏外元素测宽失败
|
||||
*/
|
||||
export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) {
|
||||
const items = useMemo(
|
||||
() =>
|
||||
lines
|
||||
.map((s) => String(s || '').trim())
|
||||
.filter(Boolean),
|
||||
[lines],
|
||||
);
|
||||
|
||||
const rootIdRef = useRef(`smr${Math.random().toString(36).slice(2, 10)}`);
|
||||
const textIdRef = useRef(`smt${Math.random().toString(36).slice(2, 10)}`);
|
||||
const indexRef = useRef(0);
|
||||
const boxWidthRef = useRef(getBoxWidthFallback());
|
||||
const itemsKey = items.join('\n');
|
||||
|
||||
const [displayIndex, setDisplayIndex] = useState(0);
|
||||
const [leftPx, setLeftPx] = useState(() => boxWidthRef.current);
|
||||
|
||||
useEffect(() => {
|
||||
if (!items.length) return;
|
||||
|
||||
let cancelled = false;
|
||||
const waiters = new Set<ReturnType<typeof setTimeout>>();
|
||||
let tickTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const sleep = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const id = setTimeout(() => {
|
||||
waiters.delete(id);
|
||||
resolve();
|
||||
}, ms);
|
||||
waiters.add(id);
|
||||
});
|
||||
|
||||
const clearTick = () => {
|
||||
if (tickTimer) {
|
||||
clearInterval(tickTimer);
|
||||
tickTimer = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const fly = (from: number, to: number, durationMs: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const began = Date.now();
|
||||
setLeftPx(from);
|
||||
clearTick();
|
||||
tickTimer = setInterval(() => {
|
||||
if (cancelled) {
|
||||
clearTick();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const t = Math.min(1, (Date.now() - began) / durationMs);
|
||||
setLeftPx(from + (to - from) * t);
|
||||
if (t >= 1) {
|
||||
clearTick();
|
||||
resolve();
|
||||
}
|
||||
}, TICK_MS);
|
||||
});
|
||||
|
||||
const loop = async () => {
|
||||
indexRef.current = 0;
|
||||
setDisplayIndex(0);
|
||||
|
||||
const measured = await measureBoxWidth(`#${rootIdRef.current}`, boxWidthRef.current);
|
||||
boxWidthRef.current = measured;
|
||||
if (cancelled) return;
|
||||
|
||||
while (!cancelled && items.length) {
|
||||
const idx = indexRef.current % items.length;
|
||||
const text = items[idx];
|
||||
const box = boxWidthRef.current;
|
||||
|
||||
setDisplayIndex(idx);
|
||||
|
||||
const from = box;
|
||||
setLeftPx(from);
|
||||
await sleep(48);
|
||||
if (cancelled) break;
|
||||
|
||||
const textW = await measureTextWidth(`#${textIdRef.current}`, text);
|
||||
// 全文 left 边缘移出容器左边界后再走 10px
|
||||
const to = -(textW + EXTRA_AFTER_EXIT_PX);
|
||||
const distance = from - to;
|
||||
const durationMs = Math.max(MIN_FLY_MS, Math.round((distance / FLY_SPEED) * 1000));
|
||||
|
||||
await sleep(32);
|
||||
if (cancelled) break;
|
||||
|
||||
await fly(from, to, durationMs);
|
||||
if (cancelled) break;
|
||||
|
||||
await sleep(randomPauseMs());
|
||||
if (cancelled) break;
|
||||
|
||||
indexRef.current = (idx + 1) % items.length;
|
||||
}
|
||||
};
|
||||
|
||||
void loop();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTick();
|
||||
waiters.forEach(clearTimeout);
|
||||
waiters.clear();
|
||||
};
|
||||
}, [itemsKey, items]);
|
||||
|
||||
if (!items.length) return null;
|
||||
|
||||
const current = items[displayIndex] || items[0];
|
||||
const innerStyle: CSSProperties = { left: `${leftPx}px` };
|
||||
|
||||
return (
|
||||
<View id={rootIdRef.current} className="store-detail-marquee">
|
||||
<View className="store-detail-marquee-inner" style={innerStyle}>
|
||||
<Text id={textIdRef.current} className="store-detail-marquee-text">
|
||||
{current}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { subPageNavBarStyle, subPageNavContentStyle, useNavBarMetrics } from '../lib/nav-bar';
|
||||
|
||||
type SubPageHeaderProps = {
|
||||
title: string;
|
||||
onBack?: () => void;
|
||||
right?: ReactNode;
|
||||
};
|
||||
|
||||
const isH5 = process.env.TARO_ENV === 'h5';
|
||||
|
||||
/** 子页面固定实底顶栏(确认订单 / 地址 / 支付 / 订单列表等) */
|
||||
export default function SubPageHeader({ title, onBack, right }: SubPageHeaderProps) {
|
||||
const metrics = useNavBarMetrics();
|
||||
|
||||
function handleBack() {
|
||||
if (onBack) {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
const pages = Taro.getCurrentPages();
|
||||
if (pages.length > 1) {
|
||||
Taro.navigateBack();
|
||||
return;
|
||||
}
|
||||
Taro.switchTab({ url: '/pages/home/index' });
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="sub-page-header" style={subPageNavBarStyle(metrics)} aria-label={title}>
|
||||
{!isH5 ? <Text className="sub-page-header__title">{title}</Text> : null}
|
||||
<View
|
||||
className="sub-page-header__content"
|
||||
style={subPageNavContentStyle(metrics)}
|
||||
>
|
||||
<View className="sub-page-header__back" onClick={handleBack}>
|
||||
<Text className="sub-page-header__back-icon">‹</Text>
|
||||
</View>
|
||||
{right ? <View className="sub-page-header__right">{right}</View> : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { navBarStyle, tabNavContentStyle, useNavBarMetrics } from '../lib/nav-bar';
|
||||
|
||||
type TabMainHeaderProps = {
|
||||
title: string;
|
||||
extra?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tab 页顶栏:仅在有右侧扩展内容时渲染。
|
||||
* 小程序 / H5 标题走系统导航栏,避免自定义顶栏造成顶部留白。
|
||||
*/
|
||||
export default function TabMainHeader({ title, extra }: TabMainHeaderProps) {
|
||||
const metrics = useNavBarMetrics();
|
||||
|
||||
if (!extra) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="tab-main-header" style={navBarStyle(metrics)} aria-label={title}>
|
||||
{process.env.TARO_ENV !== 'h5' ? (
|
||||
<Text className="tab-main-header__title">{title}</Text>
|
||||
) : null}
|
||||
<View className="tab-main-header__content" style={tabNavContentStyle(metrics)}>
|
||||
<View className="tab-main-header__extra">{extra}</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
.u-tabbar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
padding: 8px 12px calc(8px + env(safe-area-inset-bottom, 0px));
|
||||
background: var(--color-card, #fff);
|
||||
border-top: 1px solid rgba(226, 190, 188, 0.3);
|
||||
box-shadow: var(--shadow-tabbar, 0 -4px 20px rgba(0, 0, 0, 0.06));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.u-tabbar__item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
color: var(--color-subtle-gray, #999);
|
||||
}
|
||||
|
||||
.u-tabbar__item--active {
|
||||
color: var(--color-heritage-red, #a61d24);
|
||||
}
|
||||
|
||||
.u-tabbar__icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.u-tabbar__label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import './UserTabBar.css';
|
||||
|
||||
import iconHome from '../assets/tabbar/home.png';
|
||||
import iconHomeActive from '../assets/tabbar/home-active.png';
|
||||
import iconStore from '../assets/tabbar/store.png';
|
||||
import iconStoreActive from '../assets/tabbar/store-active.png';
|
||||
import iconBenefit from '../assets/tabbar/benefit.png';
|
||||
import iconBenefitActive from '../assets/tabbar/benefit-active.png';
|
||||
import iconMine from '../assets/tabbar/mine.png';
|
||||
import iconMineActive from '../assets/tabbar/mine-active.png';
|
||||
|
||||
export const USER_TABS = [
|
||||
{
|
||||
pagePath: '/pages/home/index',
|
||||
text: '首页',
|
||||
icon: iconHome,
|
||||
iconActive: iconHomeActive,
|
||||
},
|
||||
{
|
||||
pagePath: '/pages/stores/index',
|
||||
text: '门店',
|
||||
icon: iconStore,
|
||||
iconActive: iconStoreActive,
|
||||
},
|
||||
{
|
||||
pagePath: '/pages/benefit/index',
|
||||
text: '好客权益',
|
||||
icon: iconBenefit,
|
||||
iconActive: iconBenefitActive,
|
||||
},
|
||||
{
|
||||
pagePath: '/pages/mine/index',
|
||||
text: '我的',
|
||||
icon: iconMine,
|
||||
iconActive: iconMineActive,
|
||||
},
|
||||
] as const;
|
||||
|
||||
type UserTabBarProps = {
|
||||
selected: number;
|
||||
};
|
||||
|
||||
/** C 端底栏:本地 PNG 图标(weapp 无法加载 Material 字体) */
|
||||
export default function UserTabBar({ selected }: UserTabBarProps) {
|
||||
return (
|
||||
<View className="u-tabbar">
|
||||
{USER_TABS.map((tab, index) => {
|
||||
const active = selected === index;
|
||||
return (
|
||||
<View
|
||||
key={tab.pagePath}
|
||||
className={`u-tabbar__item${active ? ' u-tabbar__item--active' : ''}`}
|
||||
onClick={() => {
|
||||
if (!active) Taro.switchTab({ url: tab.pagePath });
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
className="u-tabbar__icon"
|
||||
src={active ? tab.iconActive : tab.icon}
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<Text className="u-tabbar__label">{tab.text}</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** weapp custom-tab-bar 选中态同步;H5 无 getTabBar 时忽略 */
|
||||
export function syncTabBarSelected(index: number) {
|
||||
try {
|
||||
const page = Taro.getCurrentInstance().page as
|
||||
| { getTabBar?: () => { setSelected?: (i: number) => void } }
|
||||
| undefined;
|
||||
page?.getTabBar?.()?.setSelected?.(index);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldRenderPageTabBar(): boolean {
|
||||
return process.env.TARO_ENV === 'h5';
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { View, Text } from '@tarojs/components';
|
||||
|
||||
type WechatLoginButtonProps = {
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
/** 默认「授权登录」,避免使用「微信」字样与官方风格图标 */
|
||||
label?: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
/** 授权登录按钮(无微信品牌元素,满足小程序审核) */
|
||||
export default function WechatLoginButton({
|
||||
loading = false,
|
||||
disabled = false,
|
||||
label = '授权登录',
|
||||
onClick,
|
||||
}: WechatLoginButtonProps) {
|
||||
const inactive = loading || disabled;
|
||||
|
||||
return (
|
||||
<View
|
||||
className={`login-wechat-btn${inactive ? ' login-wechat-btn--disabled' : ''}`}
|
||||
onClick={inactive ? undefined : onClick}
|
||||
>
|
||||
<Text className="login-wechat-btn__text">{loading ? '授权中...' : label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { captureIosJssdkEntryUrl } from '@dukang/weixin-sdk';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { finishLoginNavigate, forceReloadAfterAccountMerge, goLogin } from '../lib/auth-nav';
|
||||
import { toast } from '../lib/api';
|
||||
import { capturePromoSceneAndTouchScan } from '../lib/promo';
|
||||
import { saveWechatLoginResult } from '../lib/pay-wechat';
|
||||
import { applyWechatShare } from '../lib/wechat-share';
|
||||
import { handleWechatAuthCallback } from '../lib/wechat-auth';
|
||||
import { isWechatEnv } from '../lib/weixin';
|
||||
|
||||
function currentPagePathWithQuery(): string {
|
||||
const pages = Taro.getCurrentPages();
|
||||
const cur = pages[pages.length - 1] as
|
||||
| { route?: string; options?: Record<string, string | undefined> }
|
||||
| undefined;
|
||||
if (!cur?.route) {
|
||||
if (typeof window !== 'undefined') {
|
||||
return `${window.location.pathname}${window.location.search}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
const path = cur.route.startsWith('/') ? cur.route : `/${cur.route}`;
|
||||
const opts = cur.options ?? {};
|
||||
const qs = Object.entries(opts)
|
||||
.filter(([k, v]) => v != null && v !== '' && k !== 'code' && k !== 'state')
|
||||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
|
||||
.join('&');
|
||||
return qs ? `${path}?${qs}` : path;
|
||||
}
|
||||
|
||||
/**
|
||||
* H5 App 根节点:iOS 签名 URL + 默认分享 + OAuth code 回调。
|
||||
* 小程序:冷启动时捕获推广码 scene 并回传扫码埋点。
|
||||
*/
|
||||
export default function WechatShareBootstrap() {
|
||||
const handlingCode = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
void capturePromoSceneAndTouchScan();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (process.env.TARO_ENV !== 'h5') return;
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
captureIosJssdkEntryUrl();
|
||||
|
||||
function refreshShare() {
|
||||
void applyWechatShare().catch(() => {});
|
||||
}
|
||||
|
||||
function tryHandleOAuth() {
|
||||
if (!isWechatEnv()) return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (!params.get('code')) return;
|
||||
if (handlingCode.current) return;
|
||||
handlingCode.current = true;
|
||||
|
||||
const returnFromLogin = (() => {
|
||||
const path = currentPagePathWithQuery();
|
||||
if (path.includes('/pages/login/')) {
|
||||
try {
|
||||
return decodeURIComponent(params.get('return') || '') || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
handleWechatAuthCallback()
|
||||
.then((result) => {
|
||||
if (!result) return;
|
||||
if (saveWechatLoginResult(result)) {
|
||||
toast('微信授权成功', 'success');
|
||||
const ret = returnFromLogin || params.get('return') || undefined;
|
||||
if (result.accountMerged) {
|
||||
forceReloadAfterAccountMerge(ret);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
returnFromLogin !== undefined ||
|
||||
currentPagePathWithQuery().includes('/pages/login/')
|
||||
) {
|
||||
finishLoginNavigate(ret);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 兼容旧接口:仅 needBindPhone 时引导可选绑定,不阻塞浏览
|
||||
if (result.needBindPhone && result.wxSessionKey) {
|
||||
goLogin(returnFromLogin, {
|
||||
bindMode: '1',
|
||||
wxSessionKey: result.wxSessionKey,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
toast(e instanceof Error ? e.message : '微信授权失败');
|
||||
})
|
||||
.finally(() => {
|
||||
handlingCode.current = false;
|
||||
});
|
||||
}
|
||||
|
||||
refreshShare();
|
||||
tryHandleOAuth();
|
||||
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') refreshShare();
|
||||
};
|
||||
const onLocation = () => {
|
||||
refreshShare();
|
||||
tryHandleOAuth();
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
window.addEventListener('popstate', onLocation);
|
||||
|
||||
const { pushState, replaceState } = window.history;
|
||||
window.history.pushState = function (...args) {
|
||||
const ret = pushState.apply(this, args);
|
||||
window.dispatchEvent(new Event('dukang-h5-route'));
|
||||
return ret;
|
||||
};
|
||||
window.history.replaceState = function (...args) {
|
||||
const ret = replaceState.apply(this, args);
|
||||
window.dispatchEvent(new Event('dukang-h5-route'));
|
||||
return ret;
|
||||
};
|
||||
window.addEventListener('dukang-h5-route', onLocation);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
window.removeEventListener('popstate', onLocation);
|
||||
window.removeEventListener('dukang-h5-route', onLocation);
|
||||
window.history.pushState = pushState;
|
||||
window.history.replaceState = replaceState;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import { captureIosJssdkEntryUrl } from '@dukang/weixin-sdk';
|
||||
import { applyWechatShare, type PageSharePayload } from '../lib/wechat-share';
|
||||
|
||||
/**
|
||||
* H5:进入页面时刷新微信分享卡片;
|
||||
* 小程序:开启右上角分享菜单。
|
||||
*/
|
||||
export default function WechatShareReady({ payload }: { payload?: PageSharePayload }) {
|
||||
useEffect(() => {
|
||||
if (process.env.TARO_ENV === 'h5') {
|
||||
captureIosJssdkEntryUrl();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useDidShow(() => {
|
||||
if (process.env.TARO_ENV === 'weapp') {
|
||||
void Taro.showShareMenu({
|
||||
withShareTicket: true,
|
||||
showShareItems: ['shareAppMessage', 'shareTimeline'],
|
||||
}).catch(() => {
|
||||
void Taro.showShareMenu({ withShareTicket: true }).catch(() => {});
|
||||
});
|
||||
return;
|
||||
}
|
||||
void applyWechatShare({
|
||||
title: payload?.title,
|
||||
desc: payload?.desc,
|
||||
imgUrl: payload?.imgUrl,
|
||||
link: payload?.link,
|
||||
}).catch(() => {});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (process.env.TARO_ENV !== 'h5') return;
|
||||
void applyWechatShare({
|
||||
title: payload?.title,
|
||||
desc: payload?.desc,
|
||||
imgUrl: payload?.imgUrl,
|
||||
link: payload?.link,
|
||||
}).catch(() => {});
|
||||
}, [payload?.title, payload?.desc, payload?.imgUrl, payload?.link]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<title>杜康好客</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script><%= htmlWebpackPlugin.options.script %></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,37 +0,0 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { createUserTracker, getSessionId } from '@dukang/client-logging';
|
||||
import { API_BASE, CLIENT_APP, getToken } from './api';
|
||||
|
||||
function currentPagePath(): string | undefined {
|
||||
try {
|
||||
const pages = Taro.getCurrentPages();
|
||||
const cur = pages[pages.length - 1] as { route?: string; $taroPath?: string } | undefined;
|
||||
return cur?.$taroPath || cur?.route || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const tracker = createUserTracker({
|
||||
apiBase: API_BASE,
|
||||
clientApp: CLIENT_APP,
|
||||
getToken,
|
||||
getPagePath: currentPagePath,
|
||||
postJson: ({ url, headers, body }) => {
|
||||
void Taro.request({ url, method: 'POST', header: headers, data: body }).catch(() => {});
|
||||
},
|
||||
});
|
||||
|
||||
export { getSessionId };
|
||||
|
||||
export function track(eventName: string, params?: Record<string, unknown>) {
|
||||
tracker.track(eventName, params);
|
||||
}
|
||||
|
||||
export function trackPageView(eventName: string, params?: Record<string, unknown>) {
|
||||
tracker.trackPageView(eventName, params);
|
||||
}
|
||||
|
||||
export function initUserAnalytics() {
|
||||
tracker.trackSessionStart();
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { ClientApp } from '@dukang/shared-types';
|
||||
import { forceReloadAfterAccountMerge } from './auth-nav';
|
||||
import { resetStoresSessionBootstrap } from './stores-session';
|
||||
import { resetHomeCatalogBootstrap } from './home-catalog-session';
|
||||
|
||||
function resolveApiBase(): string {
|
||||
const origin =
|
||||
typeof TARO_APP_API_ORIGIN !== 'undefined' && TARO_APP_API_ORIGIN
|
||||
? TARO_APP_API_ORIGIN
|
||||
: process.env.TARO_ENV === 'h5'
|
||||
? 'http://localhost:3000'
|
||||
: '';
|
||||
if (origin) {
|
||||
return `${origin.replace(/\/$/, '')}/api/v1`;
|
||||
}
|
||||
return '/api/v1';
|
||||
}
|
||||
|
||||
export const API_BASE = resolveApiBase();
|
||||
const TOKEN_KEY = 'user_access_token';
|
||||
const REFRESH_KEY = 'user_refresh_token';
|
||||
/** H5 产物走公众号体系(USER_H5);小程序原生走 USER_MINI。勿混用,否则 JSAPI 会出现 appid 与 openid 不匹配 */
|
||||
export const CLIENT_APP =
|
||||
process.env.TARO_ENV === 'h5' ? ClientApp.USER_H5 : ClientApp.USER_MINI;
|
||||
|
||||
export function getToken(): string {
|
||||
try {
|
||||
return Taro.getStorageSync(TOKEN_KEY) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function getRefreshToken(): string {
|
||||
try {
|
||||
return Taro.getStorageSync(REFRESH_KEY) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function saveAuth(data: { accessToken: string; refreshToken?: string }) {
|
||||
Taro.setStorageSync(TOKEN_KEY, data.accessToken);
|
||||
if (data.refreshToken) {
|
||||
Taro.setStorageSync(REFRESH_KEY, data.refreshToken);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAuth() {
|
||||
Taro.removeStorageSync(TOKEN_KEY);
|
||||
Taro.removeStorageSync(REFRESH_KEY);
|
||||
}
|
||||
|
||||
export function isLoggedIn(): boolean {
|
||||
return !!getToken();
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
clearAuth();
|
||||
// 主动退出才重置门店/首页「当次登录」会话;401 清 token 不要打断筛选
|
||||
resetStoresSessionBootstrap();
|
||||
resetHomeCatalogBootstrap();
|
||||
Taro.reLaunch({ url: '/pages/home/index' });
|
||||
}
|
||||
|
||||
type ReqOptions = {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||
data?: Record<string, unknown> | unknown;
|
||||
auth?: boolean;
|
||||
};
|
||||
|
||||
function parseBody(data: unknown): { code?: number; message?: string } {
|
||||
if (data && typeof data === 'object') {
|
||||
return data as { code?: number; message?: string };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/** 统一请求:注入 CLIENT_APP + Bearer,解包 { code, message, data } */
|
||||
export async function request<T = unknown>(path: string, options: ReqOptions = {}): Promise<T> {
|
||||
const header: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': CLIENT_APP,
|
||||
};
|
||||
const token = getToken();
|
||||
if (token) header.Authorization = `Bearer ${token}`;
|
||||
|
||||
const res = await Taro.request({
|
||||
url: `${API_BASE}${path}`,
|
||||
method: options.method ?? 'GET',
|
||||
data: options.data as Record<string, unknown>,
|
||||
header,
|
||||
});
|
||||
|
||||
const status = res.statusCode;
|
||||
const body = parseBody(res.data);
|
||||
|
||||
if (status === 401 || body?.code === 401) {
|
||||
// 仅清掉「发起本请求时」仍在使用的 token,避免登录页旧 /auth/me 竞态清掉刚写入的新 token
|
||||
const stillCurrent = !!token && getToken() === token;
|
||||
if (stillCurrent) {
|
||||
clearAuth();
|
||||
const mergedMsg = body?.message || '';
|
||||
if (/账号已合并/.test(mergedMsg)) {
|
||||
// 合并后旧 JWT 失效:强制刷新,不引导「重新登录」
|
||||
forceReloadAfterAccountMerge();
|
||||
}
|
||||
}
|
||||
throw new Error(body?.message || '登录已过期,请重新登录');
|
||||
}
|
||||
if (status === 404 && body.code === undefined) {
|
||||
throw new Error('接口不可达,请确认 API 服务已启动');
|
||||
}
|
||||
if (status >= 400 || body.code !== 0) {
|
||||
throw new Error(body?.message || `请求失败(${status})`);
|
||||
}
|
||||
return (res.data as { data: T }).data;
|
||||
}
|
||||
|
||||
export function toast(title: string, icon: 'success' | 'error' | 'none' = 'none') {
|
||||
Taro.showToast({ title, icon, duration: 1800 });
|
||||
}
|
||||
|
||||
export type SessionPayload = {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
phoneVerified?: boolean;
|
||||
accountMerged?: boolean;
|
||||
};
|
||||
|
||||
export type UserProfile = {
|
||||
id: string;
|
||||
phone?: string | null;
|
||||
nickname?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
phoneVerified?: boolean;
|
||||
hasWechat?: boolean;
|
||||
};
|
||||
@@ -1,130 +0,0 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
|
||||
const TAB_PAGES = new Set([
|
||||
'/pages/home/index',
|
||||
'/pages/stores/index',
|
||||
'/pages/benefit/index',
|
||||
'/pages/mine/index',
|
||||
]);
|
||||
|
||||
let loginNavigationPending = false;
|
||||
|
||||
function isLoginPageActive(): boolean {
|
||||
const pages = Taro.getCurrentPages();
|
||||
const current = pages[pages.length - 1] as { route?: string } | undefined;
|
||||
return !!current?.route?.includes('pages/login/');
|
||||
}
|
||||
|
||||
function currentPagePath(): string {
|
||||
const pages = Taro.getCurrentPages();
|
||||
const cur = pages[pages.length - 1] as
|
||||
| { route?: string; options?: Record<string, string | undefined> }
|
||||
| undefined;
|
||||
if (!cur?.route) return '';
|
||||
const path = cur.route.startsWith('/') ? cur.route : `/${cur.route}`;
|
||||
if (path.includes('/pages/login/')) return '';
|
||||
const opts = cur.options ?? {};
|
||||
const qs = Object.entries(opts)
|
||||
.filter(([, v]) => v != null && v !== '')
|
||||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
|
||||
.join('&');
|
||||
return qs ? `${path}?${qs}` : path;
|
||||
}
|
||||
|
||||
/** 跳转登录页;默认带回当前页作为 return */
|
||||
export function goLogin(returnPath?: string, extras?: Record<string, string>) {
|
||||
if (loginNavigationPending || isLoginPageActive()) return;
|
||||
const returnTo = returnPath ?? currentPagePath();
|
||||
const parts: string[] = [];
|
||||
if (returnTo) parts.push(`return=${encodeURIComponent(returnTo)}`);
|
||||
if (extras) {
|
||||
for (const [key, value] of Object.entries(extras)) {
|
||||
if (value) parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
|
||||
}
|
||||
}
|
||||
const url = parts.length ? `/pages/login/index?${parts.join('&')}` : '/pages/login/index';
|
||||
loginNavigationPending = true;
|
||||
void Taro.navigateTo({ url })
|
||||
.catch(() => Taro.redirectTo({ url }))
|
||||
.finally(() => {
|
||||
// 等路由栈稳定后再释放,拦截同一轮请求触发的重复登录跳转。
|
||||
setTimeout(() => {
|
||||
loginNavigationPending = false;
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
|
||||
/** 登录成功后回到 return 页,或回退 / 首页 */
|
||||
export function finishLoginNavigate(returnTo?: string) {
|
||||
const raw = (returnTo || '').trim();
|
||||
let target = '';
|
||||
try {
|
||||
target = raw ? decodeURIComponent(raw) : '';
|
||||
} catch {
|
||||
target = raw;
|
||||
}
|
||||
// 防止 return 仍指向登录页造成死循环
|
||||
const pathOnly = target.split('?')[0];
|
||||
if (!pathOnly || pathOnly.includes('/pages/login')) {
|
||||
Taro.reLaunch({ url: '/pages/home/index' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (TAB_PAGES.has(pathOnly)) {
|
||||
Taro.switchTab({ url: pathOnly }).catch(() => {
|
||||
Taro.reLaunch({ url: pathOnly });
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pathOnly.startsWith('/pages/')) {
|
||||
Taro.redirectTo({ url: target }).catch(() => {
|
||||
Taro.reLaunch({ url: pathOnly });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Taro.reLaunch({ url: '/pages/home/index' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号合并后强制整页刷新,避免旧会话栈 / 旧用户缓存继续提示绑定手机号。
|
||||
* H5:按当前路径推算出落地 URL 后 location.replace;小程序:reLaunch。
|
||||
*/
|
||||
export function forceReloadAfterAccountMerge(returnTo?: string) {
|
||||
const raw = (returnTo || '').trim();
|
||||
let target = '';
|
||||
try {
|
||||
target = raw ? decodeURIComponent(raw) : '';
|
||||
} catch {
|
||||
target = raw;
|
||||
}
|
||||
const pathOnly = target.split('?')[0];
|
||||
const safePath =
|
||||
pathOnly && pathOnly.startsWith('/pages/') && !pathOnly.includes('/pages/login')
|
||||
? target
|
||||
: '/pages/home/index';
|
||||
const launchPath = safePath.split('?')[0];
|
||||
|
||||
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
|
||||
const { origin, pathname, search } = window.location;
|
||||
const marker = '/pages/';
|
||||
const idx = pathname.indexOf(marker);
|
||||
let href: string;
|
||||
if (idx >= 0) {
|
||||
href = `${origin}${pathname.slice(0, idx)}${safePath}`;
|
||||
} else if (window.location.hash.includes('/pages/')) {
|
||||
href = `${origin}${pathname}${search}#${safePath}`;
|
||||
} else {
|
||||
const base = pathname.replace(/\/$/, '') || '';
|
||||
href = `${origin}${base}${safePath.startsWith('/') ? safePath : `/${safePath}`}`;
|
||||
}
|
||||
window.location.replace(href);
|
||||
return;
|
||||
}
|
||||
|
||||
if (TAB_PAGES.has(launchPath)) {
|
||||
Taro.reLaunch({ url: launchPath });
|
||||
return;
|
||||
}
|
||||
Taro.reLaunch({ url: safePath.startsWith('/') ? safePath : `/${safePath}` });
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
export type CheckoutContext = {
|
||||
productId?: string;
|
||||
qty?: string;
|
||||
addressId?: string;
|
||||
cross?: boolean;
|
||||
select?: boolean;
|
||||
};
|
||||
|
||||
export function buildQuery(ctx: CheckoutContext): string {
|
||||
const parts: string[] = [];
|
||||
if (ctx.productId) parts.push(`productId=${encodeURIComponent(ctx.productId)}`);
|
||||
if (ctx.qty) parts.push(`qty=${encodeURIComponent(ctx.qty)}`);
|
||||
if (ctx.addressId) parts.push(`addressId=${encodeURIComponent(ctx.addressId)}`);
|
||||
if (ctx.cross) parts.push('cross=1');
|
||||
if (ctx.select) parts.push('select=1');
|
||||
return parts.join('&');
|
||||
}
|
||||
|
||||
export function buildOrderConfirmUrl(ctx: CheckoutContext): string {
|
||||
const qs = buildQuery(ctx);
|
||||
return qs ? `/pages/order-confirm/index?${qs}` : '/pages/order-confirm/index';
|
||||
}
|
||||
|
||||
export function buildAddressListUrl(ctx: CheckoutContext): string {
|
||||
const qs = buildQuery({ ...ctx, select: true });
|
||||
return qs ? `/pages/addresses/index?${qs}` : '/pages/addresses/index';
|
||||
}
|
||||
|
||||
export function buildAddressEditUrl(id: string | undefined, ctx: CheckoutContext): string {
|
||||
const base = id ? `/pages/address-edit/index?id=${encodeURIComponent(id)}` : '/pages/address-edit/index';
|
||||
const extra = buildQuery(ctx);
|
||||
if (!extra) return base;
|
||||
return `${base}${base.includes('?') ? '&' : '?'}${extra}`;
|
||||
}
|
||||
|
||||
export function buildPayUrl(params: {
|
||||
orderId: string;
|
||||
productId?: string;
|
||||
qty?: string;
|
||||
addressId?: string;
|
||||
cross?: boolean;
|
||||
}): string {
|
||||
const parts = [`orderId=${encodeURIComponent(params.orderId)}`];
|
||||
if (params.productId) parts.push(`productId=${encodeURIComponent(params.productId)}`);
|
||||
if (params.qty) parts.push(`qty=${encodeURIComponent(params.qty)}`);
|
||||
if (params.addressId) parts.push(`addressId=${encodeURIComponent(params.addressId)}`);
|
||||
if (params.cross) parts.push('cross=1');
|
||||
return `/pages/pay/index?${parts.join('&')}`;
|
||||
}
|
||||
|
||||
export function readCheckoutContext(params: Record<string, string | undefined>): CheckoutContext {
|
||||
return {
|
||||
productId: params.productId,
|
||||
qty: params.qty,
|
||||
addressId: params.addressId,
|
||||
cross: params.cross === '1',
|
||||
select: params.select === '1',
|
||||
};
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { CLIENT_APP, getToken, API_BASE } from './api';
|
||||
|
||||
export type ClientErrorLevel = 'fatal' | 'error' | 'warn';
|
||||
export type ClientErrorCategory =
|
||||
| 'js_error'
|
||||
| 'unhandled_rejection'
|
||||
| 'api_error'
|
||||
| 'network'
|
||||
| 'render'
|
||||
| 'bridge'
|
||||
| 'other';
|
||||
|
||||
export type ClientErrorPayload = {
|
||||
level: ClientErrorLevel;
|
||||
category: ClientErrorCategory;
|
||||
message: string;
|
||||
stack?: string;
|
||||
pagePath?: string;
|
||||
extra?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function currentPagePath(): string | undefined {
|
||||
try {
|
||||
const pages = Taro.getCurrentPages();
|
||||
const cur = pages[pages.length - 1] as { route?: string; $taroPath?: string } | undefined;
|
||||
return cur?.$taroPath || cur?.route || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** 上报客户端错误(失败静默,避免递归) */
|
||||
export function reportClientError(payload: ClientErrorPayload): void {
|
||||
const body = {
|
||||
level: payload.level,
|
||||
category: payload.category,
|
||||
message: String(payload.message || 'unknown').slice(0, 1000),
|
||||
stack: payload.stack ? String(payload.stack).slice(0, 4000) : undefined,
|
||||
pagePath: payload.pagePath || currentPagePath(),
|
||||
clientApp: CLIENT_APP,
|
||||
extra: payload.extra,
|
||||
};
|
||||
|
||||
const header: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': CLIENT_APP,
|
||||
};
|
||||
const token = getToken();
|
||||
if (token) header.Authorization = `Bearer ${token}`;
|
||||
|
||||
void Taro.request({
|
||||
url: `${API_BASE}/common/client-errors`,
|
||||
method: 'POST',
|
||||
data: body,
|
||||
header,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
let installed = false;
|
||||
|
||||
/** 安装小程序/H5 全局未捕获错误钩子(幂等) */
|
||||
export function installClientErrorReporting(): void {
|
||||
if (installed) return;
|
||||
installed = true;
|
||||
|
||||
try {
|
||||
Taro.onError?.((msg) => {
|
||||
reportClientError({
|
||||
level: 'fatal',
|
||||
category: 'js_error',
|
||||
message: typeof msg === 'string' ? msg : String(msg),
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
try {
|
||||
Taro.onUnhandledRejection?.((res) => {
|
||||
const reason = (res as { reason?: unknown })?.reason;
|
||||
const message =
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: typeof reason === 'string'
|
||||
? reason
|
||||
: JSON.stringify(reason);
|
||||
const stack = reason instanceof Error ? reason.stack : undefined;
|
||||
reportClientError({
|
||||
level: 'error',
|
||||
category: 'unhandled_rejection',
|
||||
message: message || 'UnhandledRejection',
|
||||
stack,
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('error', (ev) => {
|
||||
reportClientError({
|
||||
level: 'fatal',
|
||||
category: 'js_error',
|
||||
message: ev.message || 'window.error',
|
||||
stack: ev.error instanceof Error ? ev.error.stack : undefined,
|
||||
extra: { filename: ev.filename, lineno: ev.lineno, colno: ev.colno },
|
||||
});
|
||||
});
|
||||
window.addEventListener('unhandledrejection', (ev) => {
|
||||
const reason = ev.reason;
|
||||
reportClientError({
|
||||
level: 'error',
|
||||
category: 'unhandled_rejection',
|
||||
message:
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: typeof reason === 'string'
|
||||
? reason
|
||||
: 'unhandledrejection',
|
||||
stack: reason instanceof Error ? reason.stack : undefined,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { weixinSdk } from './weixin';
|
||||
|
||||
export type ClientGpsLocation = {
|
||||
province?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
address?: string;
|
||||
};
|
||||
|
||||
/** 尝试获取客户端 GPS(微信 JSSDK / 浏览器 Geolocation),失败返回 null 不阻塞下单 */
|
||||
export async function tryGetClientGpsLocation(): Promise<ClientGpsLocation | null> {
|
||||
if (process.env.TARO_ENV === 'weapp') {
|
||||
try {
|
||||
const Taro = (await import('@tarojs/taro')).default;
|
||||
const loc = await new Promise<{ latitude: number; longitude: number }>((resolve, reject) => {
|
||||
Taro.getLocation({
|
||||
type: 'gcj02',
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
});
|
||||
});
|
||||
return { latitude: loc.latitude, longitude: loc.longitude };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const loc = await weixinSdk.getLocation();
|
||||
if (!loc) return null;
|
||||
return {
|
||||
latitude: loc.latitude,
|
||||
longitude: loc.longitude,
|
||||
};
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
/** 格式化为 Asia/Shanghai:2026-08-03 15:14:30(不依赖 Intl,兼容微信小程序) */
|
||||
export function formatShanghaiDateTime(input?: string | Date | null): string {
|
||||
if (input == null || input === '') return '—';
|
||||
if (typeof input === 'string') {
|
||||
const s = input.trim();
|
||||
if (/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}/.test(s)) {
|
||||
return s.slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
}
|
||||
const d = input instanceof Date ? input : new Date(input);
|
||||
if (Number.isNaN(d.getTime())) return '—';
|
||||
// 用 UTC 读数 + 固定东八区偏移,避免依赖 Intl / 设备时区 API 差异
|
||||
const sh = new Date(d.getTime() + 8 * 60 * 60 * 1000);
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${sh.getUTCFullYear()}-${p(sh.getUTCMonth() + 1)}-${p(sh.getUTCDate())} ${p(sh.getUTCHours())}:${p(sh.getUTCMinutes())}:${p(sh.getUTCSeconds())}`;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
/** 球面距离(米) */
|
||||
export function haversineMeters(
|
||||
lat1: number,
|
||||
lng1: number,
|
||||
lat2: number,
|
||||
lng2: number,
|
||||
): number {
|
||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||
const R = 6371000;
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLng = toRad(lng2 - lng1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
}
|
||||
|
||||
export function formatDistanceMeters(meters: number | null | undefined): string {
|
||||
if (meters == null || !Number.isFinite(meters) || meters < 0) return '—';
|
||||
if (meters < 1000) return `${Math.max(1, Math.round(meters))}m`;
|
||||
const km = meters / 1000;
|
||||
return `${km < 10 ? km.toFixed(1) : Math.round(km)}km`;
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* 首页商品列表「当次登录」会话 —— 切 tab 不重复拉商品;
|
||||
* 城市 / 登录态变化或下拉刷新时再请求。logout 时 clear。
|
||||
*/
|
||||
|
||||
import Taro from '@tarojs/taro';
|
||||
|
||||
export type HomeCatalogCache = {
|
||||
cityCode: string;
|
||||
authKey: string;
|
||||
products: unknown[];
|
||||
};
|
||||
|
||||
type HomeSession = {
|
||||
bootstrapped: boolean;
|
||||
cache: HomeCatalogCache | null;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'dukang_home_catalog_session_v1';
|
||||
|
||||
let memory: HomeSession | null = null;
|
||||
|
||||
function emptySession(): HomeSession {
|
||||
return { bootstrapped: false, cache: null };
|
||||
}
|
||||
|
||||
function readSession(): HomeSession {
|
||||
if (memory) return memory;
|
||||
try {
|
||||
const raw = Taro.getStorageSync(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
memory = emptySession();
|
||||
return memory;
|
||||
}
|
||||
const parsed = (typeof raw === 'string' ? JSON.parse(raw) : raw) as Partial<HomeSession>;
|
||||
memory = {
|
||||
bootstrapped: !!parsed.bootstrapped,
|
||||
cache: (parsed.cache as HomeCatalogCache | null) ?? null,
|
||||
};
|
||||
return memory;
|
||||
} catch {
|
||||
memory = emptySession();
|
||||
return memory;
|
||||
}
|
||||
}
|
||||
|
||||
function writeSession(next: HomeSession) {
|
||||
memory = next;
|
||||
try {
|
||||
Taro.setStorageSync(STORAGE_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* ignore quota */
|
||||
}
|
||||
}
|
||||
|
||||
export function isHomeCatalogBootstrapped(): boolean {
|
||||
return readSession().bootstrapped;
|
||||
}
|
||||
|
||||
export function getHomeCatalogCache(): HomeCatalogCache | null {
|
||||
return readSession().cache;
|
||||
}
|
||||
|
||||
export function setHomeCatalogCache(cache: HomeCatalogCache): void {
|
||||
writeSession({ bootstrapped: true, cache });
|
||||
}
|
||||
|
||||
export function resetHomeCatalogBootstrap(): void {
|
||||
memory = emptySession();
|
||||
try {
|
||||
Taro.removeStorageSync(STORAGE_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* 微信小程序基础库无 Intl。业务代码已避免依赖,此处仅作兜底,
|
||||
* 防止旧包 / 依赖偶发 `new Intl.DateTimeFormat` 直接白屏。
|
||||
*/
|
||||
function pad(n: number) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
function shanghaiParts(date: Date) {
|
||||
const sh = new Date(date.getTime() + 8 * 60 * 60 * 1000);
|
||||
return {
|
||||
year: String(sh.getUTCFullYear()),
|
||||
month: pad(sh.getUTCMonth() + 1),
|
||||
day: pad(sh.getUTCDate()),
|
||||
hour: pad(sh.getUTCHours()),
|
||||
minute: pad(sh.getUTCMinutes()),
|
||||
second: pad(sh.getUTCSeconds()),
|
||||
};
|
||||
}
|
||||
|
||||
function installIntlStub() {
|
||||
const root = (typeof globalThis !== 'undefined'
|
||||
? globalThis
|
||||
: typeof global !== 'undefined'
|
||||
? global
|
||||
: typeof wx !== 'undefined'
|
||||
? wx
|
||||
: {}) as typeof globalThis & { Intl?: typeof Intl };
|
||||
|
||||
if (typeof root.Intl !== 'undefined' && typeof root.Intl.DateTimeFormat === 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
class MiniDateTimeFormat {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
constructor(_locales?: string | string[], _options?: Record<string, unknown>) {}
|
||||
|
||||
format(date?: Date | number) {
|
||||
const d = date instanceof Date ? date : new Date(date ?? Date.now());
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const p = shanghaiParts(d);
|
||||
return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}:${p.second}`;
|
||||
}
|
||||
|
||||
formatToParts(date?: Date | number) {
|
||||
const d = date instanceof Date ? date : new Date(date ?? Date.now());
|
||||
if (Number.isNaN(d.getTime())) return [];
|
||||
const p = shanghaiParts(d);
|
||||
return [
|
||||
{ type: 'year', value: p.year },
|
||||
{ type: 'literal', value: '-' },
|
||||
{ type: 'month', value: p.month },
|
||||
{ type: 'literal', value: '-' },
|
||||
{ type: 'day', value: p.day },
|
||||
{ type: 'literal', value: ' ' },
|
||||
{ type: 'hour', value: p.hour },
|
||||
{ type: 'literal', value: ':' },
|
||||
{ type: 'minute', value: p.minute },
|
||||
{ type: 'literal', value: ':' },
|
||||
{ type: 'second', value: p.second },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
root.Intl = {
|
||||
DateTimeFormat: MiniDateTimeFormat,
|
||||
} as unknown as typeof Intl;
|
||||
}
|
||||
|
||||
installIntlStub();
|
||||
|
||||
export {};
|
||||
@@ -1,157 +0,0 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import type { UserProfile } from './api';
|
||||
|
||||
export type MiniWechatProfile = {
|
||||
nickname?: string;
|
||||
avatarUrl?: string;
|
||||
};
|
||||
|
||||
export type MiniWechatProfileUpdate = MiniWechatProfile & {
|
||||
avatarResourceId?: string;
|
||||
};
|
||||
|
||||
export type UploadedAvatarResource = {
|
||||
resourceId: string;
|
||||
url: string;
|
||||
bucket: string;
|
||||
ossKey: string;
|
||||
};
|
||||
|
||||
const WX_PROFILE_CACHE_KEY = 'wx_mini_profile_cache';
|
||||
|
||||
export function cacheWxProfile(info: MiniWechatProfile) {
|
||||
if (!info.nickname && !info.avatarUrl) return;
|
||||
try {
|
||||
Taro.setStorageSync(WX_PROFILE_CACHE_KEY, JSON.stringify(info));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedWxProfile(): MiniWechatProfile | null {
|
||||
try {
|
||||
const raw = Taro.getStorageSync(WX_PROFILE_CACHE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(String(raw)) as MiniWechatProfile;
|
||||
if (!parsed?.nickname && !parsed?.avatarUrl) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isDefaultMiniNickname(nickname?: string | null): boolean {
|
||||
if (!nickname || nickname === '访客' || nickname === '微信用户' || nickname === '用户') return true;
|
||||
return /^用户\d{4}$/.test(nickname);
|
||||
}
|
||||
|
||||
/** 是否缺少可展示的微信头像/昵称(需走 chooseAvatar + nickname 填写) */
|
||||
export function needsWxProfileFill(profile: UserProfile | null | undefined): boolean {
|
||||
if (!profile) return true;
|
||||
return !profile.avatarUrl || isDefaultMiniNickname(profile.nickname);
|
||||
}
|
||||
|
||||
export function mergeWxDisplayProfile(profile: UserProfile): UserProfile {
|
||||
const cached = getCachedWxProfile();
|
||||
if (!cached && !profile.hasWechat) return profile;
|
||||
const nickname =
|
||||
(!isDefaultMiniNickname(profile.nickname) ? profile.nickname : undefined) ||
|
||||
cached?.nickname ||
|
||||
profile.nickname ||
|
||||
'微信用户';
|
||||
return {
|
||||
...profile,
|
||||
nickname,
|
||||
avatarUrl: profile.avatarUrl || cached?.avatarUrl || null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 上传 chooseAvatar 临时文件到 OSS,并返回已登记到当前用户的真实资源。 */
|
||||
export async function uploadAvatarTempFile(tempFilePath: string): Promise<UploadedAvatarResource> {
|
||||
const { API_BASE, CLIENT_APP, getToken } = await import('./api');
|
||||
const token = getToken();
|
||||
if (!token) throw new Error('请先登录');
|
||||
|
||||
const res = await Taro.uploadFile({
|
||||
url: `${API_BASE}/common/resources/upload`,
|
||||
filePath: tempFilePath,
|
||||
name: 'file',
|
||||
formData: {
|
||||
bizType: 'AVATAR',
|
||||
mediaType: 'IMAGE',
|
||||
},
|
||||
header: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-Client-App': CLIENT_APP,
|
||||
},
|
||||
});
|
||||
|
||||
let body: {
|
||||
code?: number;
|
||||
message?: string;
|
||||
data?: { resourceId?: string; url?: string; bucket?: string; ossKey?: string };
|
||||
} = {};
|
||||
try {
|
||||
body = JSON.parse(String(res.data || '{}')) as typeof body;
|
||||
} catch {
|
||||
throw new Error('头像上传响应异常');
|
||||
}
|
||||
if (res.statusCode === 401 || body.code === 401) {
|
||||
throw new Error(body.message || '登录已过期,请重新登录');
|
||||
}
|
||||
if (
|
||||
res.statusCode >= 400 ||
|
||||
body.code !== 0 ||
|
||||
!body.data?.resourceId ||
|
||||
!body.data.url ||
|
||||
!body.data.bucket ||
|
||||
!body.data.ossKey
|
||||
) {
|
||||
throw new Error(body.message || '头像上传失败');
|
||||
}
|
||||
return {
|
||||
resourceId: body.data.resourceId,
|
||||
url: body.data.url,
|
||||
bucket: body.data.bucket,
|
||||
ossKey: body.data.ossKey,
|
||||
};
|
||||
}
|
||||
|
||||
export async function uploadMiniWechatProfile(info: MiniWechatProfileUpdate): Promise<UserProfile | null> {
|
||||
if (!info.nickname && !info.avatarUrl && !info.avatarResourceId) return null;
|
||||
const { request } = await import('./api');
|
||||
const updated = await request<UserProfile>('/auth/wechat/mini-profile', {
|
||||
method: 'POST',
|
||||
data: info,
|
||||
});
|
||||
cacheWxProfile({
|
||||
nickname: updated?.nickname ?? info.nickname,
|
||||
avatarUrl: updated?.avatarUrl ?? info.avatarUrl,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容旧调用:getUserProfile 已无法拿到真实头像昵称。
|
||||
* 始终导出为函数,避免循环依赖/旧包出现 “is not a function”。
|
||||
*/
|
||||
export async function fetchMiniWechatUserInfo(): Promise<MiniWechatProfile> {
|
||||
const cached = getCachedWxProfile();
|
||||
if (cached?.nickname || cached?.avatarUrl) {
|
||||
return cached;
|
||||
}
|
||||
// 不再弹 getUserProfile;引导走「我的」页 chooseAvatar / nickname
|
||||
throw new Error('请在「我的」页点击头像完善微信头像和昵称');
|
||||
}
|
||||
|
||||
/** 绑定后上报微信资料(优先使用已拉取的信息) */
|
||||
export async function syncMiniWechatProfile(
|
||||
prefetched?: MiniWechatProfile | null,
|
||||
): Promise<MiniWechatProfile | null> {
|
||||
if (process.env.TARO_ENV !== 'weapp') return null;
|
||||
const info = prefetched?.nickname || prefetched?.avatarUrl ? prefetched : getCachedWxProfile();
|
||||
if (!info?.nickname && !info?.avatarUrl) return null;
|
||||
// 缓存头像 URL 可能来自历史微信资料,未经过当前 OSS 上传登记;这里只同步昵称。
|
||||
if (info.nickname) await uploadMiniWechatProfile({ nickname: info.nickname });
|
||||
return info;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
/** 金额展示(不依赖 Intl / toLocaleString,兼容微信小程序) */
|
||||
export function formatMoney(amount: number | string): string {
|
||||
const n = typeof amount === 'number' ? amount : Number(amount);
|
||||
if (!Number.isFinite(n)) return '0.00';
|
||||
const fixed = n.toFixed(2);
|
||||
const [intPart, dec] = fixed.split('.');
|
||||
const withComma = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
return `${withComma}.${dec}`;
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import Taro from '@tarojs/taro';
|
||||
|
||||
export type NavBarMetrics = {
|
||||
/** 状态栏高度(刘海/灵动岛上方) */
|
||||
statusBarHeight: number;
|
||||
/** 导航栏总高度 = 状态栏 + 内容区 */
|
||||
navBarHeight: number;
|
||||
/** 标题栏内容区高度(与胶囊对齐) */
|
||||
navContentHeight: number;
|
||||
/** 右侧留白,避免与微信胶囊按钮重叠 */
|
||||
navBarPaddingRight: number;
|
||||
/** 左侧留白,与右侧对称以实现标题视觉居中 */
|
||||
navBarPaddingLeft: number;
|
||||
};
|
||||
|
||||
const H5_FALLBACK: NavBarMetrics = {
|
||||
statusBarHeight: 0,
|
||||
navBarHeight: 56,
|
||||
navContentHeight: 56,
|
||||
/** 避开微信内置浏览器右上角 ··· / 设置入口(约一颗胶囊宽) */
|
||||
navBarPaddingRight: 96,
|
||||
navBarPaddingLeft: 16,
|
||||
};
|
||||
|
||||
const WEAPP_FALLBACK: NavBarMetrics = {
|
||||
statusBarHeight: 20,
|
||||
navBarHeight: 64,
|
||||
navContentHeight: 44,
|
||||
navBarPaddingRight: 96,
|
||||
navBarPaddingLeft: 96,
|
||||
};
|
||||
|
||||
/** 计算小程序自定义导航栏尺寸(对齐微信胶囊按钮) */
|
||||
export function getNavBarMetrics(): NavBarMetrics {
|
||||
if (process.env.TARO_ENV === 'h5') {
|
||||
// H5:在微信浏览器内额外避让右上角菜单;非微信保持较小右侧留白
|
||||
const inWechat =
|
||||
typeof navigator !== 'undefined' && /MicroMessenger/i.test(navigator.userAgent || '');
|
||||
return {
|
||||
...H5_FALLBACK,
|
||||
navBarPaddingRight: inWechat ? 96 : 16,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
navBarPaddingLeft: navBarPaddingRight,
|
||||
};
|
||||
} catch {
|
||||
return WEAPP_FALLBACK;
|
||||
}
|
||||
}
|
||||
|
||||
export function useNavBarMetrics(): NavBarMetrics {
|
||||
return useMemo(() => getNavBarMetrics(), []);
|
||||
}
|
||||
|
||||
/** 仅注入 CSS 变量,供 PageShell / sticky 子元素使用(不加 height/padding) */
|
||||
export function pageShellCssVars(metrics: NavBarMetrics): Record<string, string> {
|
||||
return {
|
||||
'--nav-bar-height': `${metrics.navBarHeight}px`,
|
||||
'--nav-content-height': `${metrics.navContentHeight}px`,
|
||||
'--nav-status-bar-height': `${metrics.statusBarHeight}px`,
|
||||
'--nav-padding-right': `${metrics.navBarPaddingRight}px`,
|
||||
'--nav-padding-left': `${metrics.navBarPaddingLeft}px`,
|
||||
};
|
||||
}
|
||||
|
||||
/** 顶栏自身样式:statusBar padding + 总高 + CSS 变量 */
|
||||
export function navBarStyle(metrics: NavBarMetrics): Record<string, string | number> {
|
||||
return {
|
||||
paddingTop: `${metrics.statusBarHeight}px`,
|
||||
height: `${metrics.navBarHeight}px`,
|
||||
...pageShellCssVars(metrics),
|
||||
};
|
||||
}
|
||||
|
||||
/** Tab 顶栏内容行:左右留白(标题单独全屏居中) */
|
||||
export function tabNavContentStyle(metrics: NavBarMetrics): Record<string, string | number> {
|
||||
return {
|
||||
height: `${metrics.navContentHeight}px`,
|
||||
paddingLeft: '20px',
|
||||
paddingRight: `${metrics.navBarPaddingRight}px`,
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
}
|
||||
|
||||
/** 子页/内页顶栏:标题全屏居中,内容区单独留白 */
|
||||
export function subPageNavBarStyle(metrics: NavBarMetrics): Record<string, string | number> {
|
||||
const pagePad = 20;
|
||||
// 标题两侧取「右侧避让胶囊」宽度,保证相对屏幕视觉居中
|
||||
const titlePad = Math.max(metrics.navBarPaddingRight, 72);
|
||||
return {
|
||||
paddingTop: `${metrics.statusBarHeight}px`,
|
||||
height: `${metrics.navBarHeight}px`,
|
||||
'--nav-bar-height': `${metrics.navBarHeight}px`,
|
||||
'--nav-content-height': `${metrics.navContentHeight}px`,
|
||||
'--nav-status-bar-height': `${metrics.statusBarHeight}px`,
|
||||
'--nav-padding-left': `${pagePad}px`,
|
||||
'--nav-padding-right': `${titlePad}px`,
|
||||
};
|
||||
}
|
||||
|
||||
/** 子页顶栏内容行:左侧页边距 + 右侧避让胶囊 */
|
||||
export function subPageNavContentStyle(metrics: NavBarMetrics): Record<string, string | number> {
|
||||
return {
|
||||
height: `${metrics.navContentHeight}px`,
|
||||
paddingLeft: '20px',
|
||||
paddingRight: `${metrics.navBarPaddingRight}px`,
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* H5:Taro Vite 偶发把 @tarojs/plugin-framework-react/dist/runtime 打成两份,
|
||||
* 导致 createReactApp 初始化了 B 份 reactMeta,而 Taro.useDidShow 仍绑定 A 份(R={}),
|
||||
* 页面一进就报 n.useContext is not a function。
|
||||
*
|
||||
* 在 App 渲染前,用与 createReactApp 同一份 runtime 的 hooks 覆盖到 Taro 上。
|
||||
*/
|
||||
import Taro from '@tarojs/taro';
|
||||
import {
|
||||
useAddToFavorites,
|
||||
useDidHide,
|
||||
useDidShow,
|
||||
useError,
|
||||
useKeyboardHeight,
|
||||
useLaunch,
|
||||
useLoad,
|
||||
useOptionMenuClick,
|
||||
usePageNotFound,
|
||||
usePageScroll,
|
||||
usePullDownRefresh,
|
||||
usePullIntercept,
|
||||
useReachBottom,
|
||||
useReady,
|
||||
useResize,
|
||||
useRouter,
|
||||
useSaveExitState,
|
||||
useScope,
|
||||
useShareAppMessage,
|
||||
useShareTimeline,
|
||||
useTabItemTap,
|
||||
useTitleClick,
|
||||
useUnhandledRejection,
|
||||
useUnload,
|
||||
} from '@tarojs/plugin-framework-react/dist/runtime';
|
||||
|
||||
const HOOKS = {
|
||||
useAddToFavorites,
|
||||
useDidHide,
|
||||
useDidShow,
|
||||
useError,
|
||||
useKeyboardHeight,
|
||||
useLaunch,
|
||||
useLoad,
|
||||
useOptionMenuClick,
|
||||
usePageNotFound,
|
||||
usePageScroll,
|
||||
usePullDownRefresh,
|
||||
usePullIntercept,
|
||||
useReachBottom,
|
||||
useReady,
|
||||
useResize,
|
||||
useRouter,
|
||||
useSaveExitState,
|
||||
useScope,
|
||||
useShareAppMessage,
|
||||
useShareTimeline,
|
||||
useTabItemTap,
|
||||
useTitleClick,
|
||||
useUnhandledRejection,
|
||||
useUnload,
|
||||
} as const;
|
||||
|
||||
export function patchTaroH5Hooks() {
|
||||
if (process.env.TARO_ENV !== 'h5') return;
|
||||
const target = Taro as unknown as Record<string, unknown>;
|
||||
for (const [key, fn] of Object.entries(HOOKS)) {
|
||||
target[key] = fn;
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { goLogin } from './auth-nav';
|
||||
import { isLoggedIn } from './api';
|
||||
import { ensureWechatAuthForPay } from './wechat-auth';
|
||||
import { fetchClientConfig, fetchUserProfile, needsWechatAuthForPay } from './pay-wechat';
|
||||
|
||||
/**
|
||||
* 支付前门禁:
|
||||
* - 未登录 → 跳转登录页(微信授权即可,不强制手机号)
|
||||
* - H5 微信内缺 openId → 尝试 OAuth(可能跳转微信授权页)
|
||||
* - 小程序缺绑定 → 跳转登录页 needWechat
|
||||
*/
|
||||
export async function ensurePayReady(returnPath: string): Promise<boolean> {
|
||||
if (!isLoggedIn()) {
|
||||
goLogin(returnPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]);
|
||||
if (!needsWechatAuthForPay(config, profile)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (process.env.TARO_ENV === 'h5') {
|
||||
const auth = await ensureWechatAuthForPay();
|
||||
if (auth.ok) return true;
|
||||
// 旧版 needBindPhone 已不再返回;缺 openId 时走登录补微信绑定
|
||||
if ('needBindPhone' in auth && auth.needBindPhone) {
|
||||
goLogin(returnPath, { needWechat: '1' });
|
||||
return false;
|
||||
}
|
||||
// redirecting:正在跳转微信 OAuth
|
||||
return false;
|
||||
}
|
||||
|
||||
goLogin(returnPath, { needWechat: '1' });
|
||||
return false;
|
||||
} catch (e) {
|
||||
// 仅会话失效时踢回登录;网络/业务错误不误清登录态
|
||||
const msg = e instanceof Error ? e.message : '';
|
||||
if (/登录已过期|重新登录|401/.test(msg) || !isLoggedIn()) {
|
||||
goLogin(returnPath);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import type {
|
||||
ClientRuntimeConfig,
|
||||
WechatJsapiPrepayParams,
|
||||
WechatLoginResult,
|
||||
WechatPayOrderResult,
|
||||
} from '@dukang/shared-types';
|
||||
import { WECHAT_AUTH_REQUIRED, isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import { invokeWechatPay } from '@dukang/weixin-sdk';
|
||||
import { request, saveAuth, type UserProfile } from './api';
|
||||
import { isWechatEnv, weixinSdk } from './weixin';
|
||||
|
||||
export function isMiniWechatEnv(): boolean {
|
||||
return process.env.TARO_ENV === 'weapp';
|
||||
}
|
||||
|
||||
export function isWechatAuthRequiredError(err: unknown): boolean {
|
||||
return err instanceof Error && err.message === WECHAT_AUTH_REQUIRED;
|
||||
}
|
||||
|
||||
export async function fetchClientConfig(): Promise<ClientRuntimeConfig> {
|
||||
return request<ClientRuntimeConfig>('/common/client-config');
|
||||
}
|
||||
|
||||
export async function fetchUserProfile(): Promise<UserProfile> {
|
||||
return request<UserProfile>('/auth/me');
|
||||
}
|
||||
|
||||
/** 真实微信支付且未绑定微信时需要授权(小程序 / H5 微信内) */
|
||||
export function needsWechatAuthForPay(
|
||||
config: ClientRuntimeConfig,
|
||||
profile: UserProfile | null,
|
||||
): boolean {
|
||||
if (!isWxAuthorizeEnabled(config)) return false;
|
||||
return !config.mockPay && config.wechatPayEnabled && isWechatEnv() && !profile?.hasWechat;
|
||||
}
|
||||
|
||||
export function saveWechatLoginResult(result: WechatLoginResult): boolean {
|
||||
if (!result.accessToken) return false;
|
||||
saveAuth({
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** H5:发起公众号 OAuth(可能直接跳转);小程序请用 bindWechatForUser */
|
||||
export async function authorizeWechatForPay(): Promise<WechatLoginResult | void> {
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) return;
|
||||
if (!isWechatEnv()) {
|
||||
throw new Error('请在微信内打开以完成授权');
|
||||
}
|
||||
if (process.env.TARO_ENV === 'h5') {
|
||||
return weixinSdk.login();
|
||||
}
|
||||
throw new Error('请使用小程序微信授权');
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export async function waitOrderPaid(orderId: string, maxAttempts = 15): Promise<boolean> {
|
||||
for (let i = 0; i < maxAttempts; i += 1) {
|
||||
const order = await request<{ payStatus?: string }>(`/trade/orders/${orderId}`);
|
||||
if (order.payStatus === 'PAID') return true;
|
||||
await sleep(2000);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function payOrder(orderId: string): Promise<'paid' | 'pending'> {
|
||||
const result = await request<WechatPayOrderResult>(`/trade/orders/${orderId}/pay`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (result.mode === 'jsapi' && result.prepay) {
|
||||
const prepay = result.prepay as WechatJsapiPrepayParams;
|
||||
if (process.env.TARO_ENV === 'h5') {
|
||||
await weixinSdk.pay(prepay);
|
||||
} else {
|
||||
await invokeWechatPay(prepay, { platform: 'mini' });
|
||||
}
|
||||
const paid = await waitOrderPaid(orderId);
|
||||
return paid ? 'paid' : 'pending';
|
||||
}
|
||||
|
||||
return 'paid';
|
||||
}
|
||||
|
||||
export type WechatBindResult =
|
||||
| { ok: true; profile?: UserProfile }
|
||||
| { ok: false; needBindPhone: true; wxSessionKey: string }
|
||||
| { ok: false; redirecting: true };
|
||||
@@ -1,23 +0,0 @@
|
||||
const MOBILE_PHONE_RE = /^1[3-9]\d{9}$/;
|
||||
|
||||
export function normalizePhoneInput(value: string): string {
|
||||
return value.replace(/\D/g, '').slice(0, 11);
|
||||
}
|
||||
|
||||
export function validateMobilePhone(phone: string): { ok: boolean; message?: string } {
|
||||
const trimmed = phone.trim();
|
||||
if (!trimmed) {
|
||||
return { ok: false, message: '请输入手机号码' };
|
||||
}
|
||||
if (trimmed.length !== 11) {
|
||||
return { ok: false, message: '手机号码须为 11 位' };
|
||||
}
|
||||
if (!MOBILE_PHONE_RE.test(trimmed)) {
|
||||
return { ok: false, message: '请输入正确的手机号码' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function maskPhone(phone: string) {
|
||||
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
/** 商品履约能力(与 HQ / 交易硬闸一致) */
|
||||
|
||||
export type FulfillmentFlags = {
|
||||
allowOnlinePurchase?: boolean | null;
|
||||
allowOnSitePickup?: boolean | null;
|
||||
allowCrossCityDelivery?: boolean | null;
|
||||
};
|
||||
|
||||
/** 未返回时默认允许线上(存量商品) */
|
||||
export function canBuyOnline(p: FulfillmentFlags): boolean {
|
||||
return p.allowOnlinePurchase !== false;
|
||||
}
|
||||
|
||||
/** 仅显式开启才展示现场取货 */
|
||||
export function canPickupOnSite(p: FulfillmentFlags): boolean {
|
||||
return p.allowOnSitePickup === true;
|
||||
}
|
||||
|
||||
export function canCrossCity(p: FulfillmentFlags): boolean {
|
||||
return p.allowCrossCityDelivery !== false;
|
||||
}
|
||||
|
||||
export function normalizeFulfillmentFlags<T extends FulfillmentFlags>(p: T): T {
|
||||
return {
|
||||
...p,
|
||||
allowOnlinePurchase: canBuyOnline(p),
|
||||
allowOnSitePickup: canPickupOnSite(p),
|
||||
allowCrossCityDelivery: canCrossCity(p),
|
||||
};
|
||||
}
|
||||
|
||||
/** 与交易侧一致:收货市 ≠ 开城市且 ≠ 郑州 → 跨城 */
|
||||
export function isCrossCityAddress(
|
||||
addressCity: string | null | undefined,
|
||||
openCityName: string | null | undefined,
|
||||
): boolean {
|
||||
const addr = (addressCity || '').trim();
|
||||
const open = (openCityName || '').trim();
|
||||
if (!addr) return false;
|
||||
if (addr === '郑州市') return false;
|
||||
if (open && addr === open) return false;
|
||||
// 尚无开城信息时,非郑州地址先按可能跨城处理(由预览接口最终裁定)
|
||||
if (!open) return addr !== '郑州市';
|
||||
return true;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
/** 商品无图时的占位(小程序端无本地兜底图时用空串由 UI 显示灰底) */
|
||||
export const PRODUCT_IMAGE_FALLBACK = '';
|
||||
|
||||
export type ProductImageSource = {
|
||||
mainImageUrl?: string | null;
|
||||
carouselUrls?: string[] | null;
|
||||
detailImageUrls?: string[] | null;
|
||||
};
|
||||
|
||||
function uniqueUrls(urls: Array<string | null | undefined>) {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const url of urls) {
|
||||
if (!url || seen.has(url)) continue;
|
||||
seen.add(url);
|
||||
result.push(url);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getProductMainImage(source?: ProductImageSource | null): string {
|
||||
return source?.mainImageUrl ?? source?.carouselUrls?.[0] ?? PRODUCT_IMAGE_FALLBACK;
|
||||
}
|
||||
|
||||
export function getProductImages(source?: ProductImageSource | null): string[] {
|
||||
const carousel = uniqueUrls(source?.carouselUrls ?? []);
|
||||
if (carousel.length > 0) return carousel;
|
||||
const main = source?.mainImageUrl;
|
||||
if (main) return [main];
|
||||
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';
|
||||
@@ -1,129 +0,0 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { request } from './api';
|
||||
|
||||
const PROMO_ID_KEY = 'dukang_promo_id';
|
||||
|
||||
/** 同一次进入只 touch 一次扫码计数,避免首页反复 onShow 刷量 */
|
||||
let lastScanTouchKey = '';
|
||||
|
||||
function safeDecode(raw: string): string {
|
||||
try {
|
||||
return decodeURIComponent(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePromoId(raw: unknown): string | null {
|
||||
if (raw == null || raw === '') return null;
|
||||
const s = safeDecode(String(raw)).trim();
|
||||
// 小程序码 scene 写入的是推广活动数字 ID
|
||||
if (!/^\d+$/.test(s)) return null;
|
||||
return s;
|
||||
}
|
||||
|
||||
type EnterOptionsLike = {
|
||||
scene?: string | number;
|
||||
query?: Record<string, string | undefined>;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
/** 从启动/进入参数解析推广活动 ID(优先 query.scene,与 getwxacodeunlimit 一致) */
|
||||
export function extractPromoIdFromEnterOptions(opts?: EnterOptionsLike | null): string | null {
|
||||
if (!opts) return null;
|
||||
const q = opts.query ?? {};
|
||||
return (
|
||||
normalizePromoId(q.scene) ||
|
||||
normalizePromoId(q.promoId) ||
|
||||
normalizePromoId(q.pid) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function getStoredPromoId(): string | null {
|
||||
try {
|
||||
const v = Taro.getStorageSync(PROMO_ID_KEY);
|
||||
return normalizePromoId(v);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setStoredPromoId(promoId: string) {
|
||||
const id = normalizePromoId(promoId);
|
||||
if (!id) return;
|
||||
try {
|
||||
Taro.setStorageSync(PROMO_ID_KEY, id);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function readEnterOptions(): EnterOptionsLike | null {
|
||||
try {
|
||||
if (typeof Taro.getEnterOptionsSync === 'function') {
|
||||
return Taro.getEnterOptionsSync() as EnterOptionsLike;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
if (typeof Taro.getLaunchOptionsSync === 'function') {
|
||||
return Taro.getLaunchOptionsSync() as EnterOptionsLike;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// H5:从 URL query 读取
|
||||
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return {
|
||||
query: {
|
||||
scene: params.get('scene') || undefined,
|
||||
promoId: params.get('promoId') || undefined,
|
||||
pid: params.get('pid') || undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主页面进入时:取出 scene(活动 ID)本地缓存,并回传 /promo/touch 累加扫码次数。
|
||||
* 同一进入会话只计一次扫码。
|
||||
*/
|
||||
export async function capturePromoSceneAndTouchScan(): Promise<void> {
|
||||
const opts = readEnterOptions();
|
||||
const fromEnter = extractPromoIdFromEnterOptions(opts);
|
||||
if (fromEnter) {
|
||||
setStoredPromoId(fromEnter);
|
||||
const touchKey = `${fromEnter}|${opts?.path || ''}|${JSON.stringify(opts?.query || {})}|${String(opts?.scene ?? '')}`;
|
||||
if (touchKey === lastScanTouchKey) return;
|
||||
lastScanTouchKey = touchKey;
|
||||
await touchPromo({ promoId: fromEnter, countScan: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// 无新 scene 时不重复扫码计数
|
||||
}
|
||||
|
||||
/** 登录成功后:用已缓存的活动 ID 做归因(不重复加扫码次数) */
|
||||
export async function touchStoredPromoAfterLogin(): Promise<void> {
|
||||
const promoId = getStoredPromoId();
|
||||
if (!promoId) return;
|
||||
await touchPromo({ promoId, countScan: false });
|
||||
}
|
||||
|
||||
async function touchPromo(input: { promoId: string; countScan: boolean }): Promise<void> {
|
||||
try {
|
||||
await request('/promo/touch', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
promoId: input.promoId,
|
||||
countScan: input.countScan,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
/* 静默失败,不阻断浏览 */
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import './text-encoding-polyfill';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
const QR_SIZE = 240;
|
||||
const QR_OPTIONS = {
|
||||
width: QR_SIZE * 2,
|
||||
margin: 0,
|
||||
color: { dark: '#1f1a17', light: '#ffffff' },
|
||||
} as const;
|
||||
|
||||
/** 在 2d Canvas 上绘制二维码(小程序 / H5 通用) */
|
||||
export function drawRedeemQrOnCanvas(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
token: string,
|
||||
sizePx = QR_SIZE,
|
||||
) {
|
||||
const qr = QRCode.create(token, { errorCorrectionLevel: 'M' });
|
||||
const count = qr.modules.size;
|
||||
const cell = sizePx / count;
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, sizePx, sizePx);
|
||||
ctx.fillStyle = '#1f1a17';
|
||||
for (let row = 0; row < count; row++) {
|
||||
for (let col = 0; col < count; col++) {
|
||||
if (qr.modules.get(row, col)) {
|
||||
ctx.fillRect(col * cell, row * cell, cell, cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** H5:Data URL;失败时回退到与 h5-user 相同的外部 QR 服务 */
|
||||
export async function buildRedeemQrDataUrl(token: string): Promise<string> {
|
||||
try {
|
||||
return await QRCode.toDataURL(token, QR_OPTIONS);
|
||||
} catch {
|
||||
return `https://api.qrserver.com/v1/create-qr-code/?size=${QR_SIZE * 2}x${QR_SIZE * 2}&data=${encodeURIComponent(token)}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const REDEEM_QR_DISPLAY_SIZE = QR_SIZE;
|
||||
@@ -1,166 +0,0 @@
|
||||
import { regionData } from 'element-china-area-data';
|
||||
|
||||
export type RegionTree = Record<string, Record<string, string[]>>;
|
||||
|
||||
function buildRegionTree(): RegionTree {
|
||||
const tree: RegionTree = {};
|
||||
for (const province of regionData) {
|
||||
const cities: Record<string, string[]> = {};
|
||||
for (const city of province.children ?? []) {
|
||||
cities[city.label] = (city.children ?? []).map((district) => district.label);
|
||||
}
|
||||
tree[province.label] = cities;
|
||||
}
|
||||
return tree;
|
||||
}
|
||||
|
||||
export const REGION_TREE: RegionTree = buildRegionTree();
|
||||
export const PROVINCES = Object.keys(REGION_TREE);
|
||||
export const REGION_ALL = '全市';
|
||||
|
||||
export function getCities(province: string): string[] {
|
||||
if (province === REGION_ALL) return [];
|
||||
return Object.keys(REGION_TREE[province] ?? {});
|
||||
}
|
||||
|
||||
export function getDistricts(province: string, city: string): string[] {
|
||||
if (province === REGION_ALL || city === REGION_ALL) return [];
|
||||
return REGION_TREE[province]?.[city] ?? [];
|
||||
}
|
||||
|
||||
export function getProvincesForPicker(): string[] {
|
||||
return [...PROVINCES];
|
||||
}
|
||||
|
||||
export function getCitiesForPicker(province: string): string[] {
|
||||
if (province === REGION_ALL) return [REGION_ALL];
|
||||
return [...getCities(province)];
|
||||
}
|
||||
|
||||
export function getDistrictsForPicker(province: string, city: string): string[] {
|
||||
if (province === REGION_ALL || city === REGION_ALL) return [REGION_ALL];
|
||||
return [REGION_ALL, ...getDistricts(province, city)];
|
||||
}
|
||||
|
||||
export function formatRegion(province: string, city: string, district: string): string {
|
||||
if (!province) return '';
|
||||
if (province === REGION_ALL) return REGION_ALL;
|
||||
if (city === REGION_ALL) return `${province} ${REGION_ALL}`;
|
||||
if (district === REGION_ALL) return `${province} ${city} ${REGION_ALL}`;
|
||||
if (!city || !district) return '';
|
||||
return `${province} ${city} ${district}`;
|
||||
}
|
||||
|
||||
export function formatRegionCity(province: string, city: string): string {
|
||||
if (!province) return '';
|
||||
if (province === REGION_ALL) return REGION_ALL;
|
||||
if (city === REGION_ALL) return `${province} ${REGION_ALL}`;
|
||||
if (!city) return province;
|
||||
return `${province} ${city}`;
|
||||
}
|
||||
|
||||
export function toCityLevelRegion(selection: RegionSelection): RegionSelection {
|
||||
const normalized = normalizeRegionSelection(selection);
|
||||
return {
|
||||
province: normalized.province,
|
||||
city: normalized.city,
|
||||
district: REGION_ALL,
|
||||
};
|
||||
}
|
||||
|
||||
export type RegionSelection = {
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
};
|
||||
|
||||
export function normalizeRegionSelection(selection: RegionSelection): RegionSelection {
|
||||
if (selection.province === REGION_ALL) {
|
||||
return { province: REGION_ALL, city: REGION_ALL, district: REGION_ALL };
|
||||
}
|
||||
|
||||
const province = PROVINCES.includes(selection.province)
|
||||
? selection.province
|
||||
: DEFAULT_REGION.province;
|
||||
|
||||
if (selection.city === REGION_ALL) {
|
||||
return { province, city: REGION_ALL, district: REGION_ALL };
|
||||
}
|
||||
|
||||
const cities = getCities(province);
|
||||
const city = cities.includes(selection.city) ? selection.city : (cities[0] ?? DEFAULT_REGION.city);
|
||||
|
||||
if (selection.district === REGION_ALL) {
|
||||
return { province, city, district: REGION_ALL };
|
||||
}
|
||||
|
||||
const districts = getDistricts(province, city);
|
||||
const district = districts.includes(selection.district)
|
||||
? selection.district
|
||||
: (districts[0] ?? DEFAULT_REGION.district);
|
||||
|
||||
return { province, city, district };
|
||||
}
|
||||
|
||||
export const DEFAULT_REGION: RegionSelection = {
|
||||
province: '河南省',
|
||||
city: '郑州市',
|
||||
district: REGION_ALL,
|
||||
};
|
||||
|
||||
export function regionFromGeo(province: string, city: string, district?: string): RegionSelection {
|
||||
const cityName = city.endsWith('市') ? city : `${city}市`;
|
||||
const provinceInTree = PROVINCES.includes(province) ? province : DEFAULT_REGION.province;
|
||||
const cities = getCities(provinceInTree);
|
||||
const matchedCity = cities.includes(cityName)
|
||||
? cityName
|
||||
: cities.find((c) => c.replace(/市$/, '') === city.replace(/市$/, '')) ?? cityName;
|
||||
const districts = getDistricts(provinceInTree, matchedCity);
|
||||
const districtName =
|
||||
district && districts.includes(district) ? district : REGION_ALL;
|
||||
return normalizeRegionSelection({
|
||||
province: provinceInTree,
|
||||
city: cities.includes(matchedCity) ? matchedCity : matchedCity,
|
||||
district: districtName,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeCityName(name: string) {
|
||||
return name.replace(/市$/, '').trim();
|
||||
}
|
||||
|
||||
type RegionFilterStore = {
|
||||
province?: string;
|
||||
cityName?: string;
|
||||
district?: string;
|
||||
};
|
||||
|
||||
/** 门店列表按省市区筛选(支持 REGION_ALL) */
|
||||
export function matchesRegionFilter(store: RegionFilterStore, region: RegionSelection): boolean {
|
||||
const normalized = normalizeRegionSelection(region);
|
||||
if (normalized.province !== REGION_ALL) {
|
||||
if ((store.province ?? '') !== normalized.province) return false;
|
||||
}
|
||||
if (normalized.city !== REGION_ALL) {
|
||||
const storeCity = store.cityName ?? '';
|
||||
const cityNorm = normalizeCityName(normalized.city);
|
||||
if (
|
||||
storeCity !== normalized.city &&
|
||||
normalizeCityName(storeCity) !== cityNorm
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (normalized.district !== REGION_ALL) {
|
||||
if ((store.district ?? '') !== normalized.district) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function formatRegionLabel(region: RegionSelection): string {
|
||||
const normalized = normalizeRegionSelection(region);
|
||||
if (normalized.province === REGION_ALL) return REGION_ALL;
|
||||
if (normalized.city === REGION_ALL) return normalized.province;
|
||||
if (normalized.district === REGION_ALL) return `${normalized.city}`;
|
||||
return normalized.district;
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
/**
|
||||
* 门店列表「当次登录」会话 —— 用 Taro Storage 持久化,
|
||||
* 避免模块多实例 / globalThis 不可靠导致切 tab 后当成首次进入。
|
||||
* 仅 logout 时 clear。
|
||||
*/
|
||||
|
||||
import Taro from '@tarojs/taro';
|
||||
|
||||
export type StoresSessionRegion = {
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
};
|
||||
|
||||
export type StoresSessionCategory = {
|
||||
parentId: string;
|
||||
parentName: string;
|
||||
childId: string;
|
||||
childName: string;
|
||||
};
|
||||
|
||||
export type StoresListCache = {
|
||||
cityKey: string;
|
||||
cityCode: string;
|
||||
/** 登录态指纹:token 变化时需重新拉取(白名单) */
|
||||
authKey: string;
|
||||
listRegion: StoresSessionRegion;
|
||||
items: unknown[];
|
||||
filterRegion: StoresSessionRegion;
|
||||
keyword: string;
|
||||
keywordInput: string;
|
||||
category: StoresSessionCategory;
|
||||
};
|
||||
|
||||
type StoresSession = {
|
||||
bootstrapped: boolean;
|
||||
cache: StoresListCache | null;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'dukang_stores_session_v1';
|
||||
|
||||
let memory: StoresSession | null = null;
|
||||
|
||||
function emptySession(): StoresSession {
|
||||
return { bootstrapped: false, cache: null };
|
||||
}
|
||||
|
||||
function readSession(): StoresSession {
|
||||
if (memory) return memory;
|
||||
try {
|
||||
const raw = Taro.getStorageSync(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
memory = emptySession();
|
||||
return memory;
|
||||
}
|
||||
const parsed = (typeof raw === 'string' ? JSON.parse(raw) : raw) as Partial<StoresSession>;
|
||||
memory = {
|
||||
bootstrapped: !!parsed.bootstrapped,
|
||||
cache: (parsed.cache as StoresListCache | null) ?? null,
|
||||
};
|
||||
return memory;
|
||||
} catch {
|
||||
memory = emptySession();
|
||||
return memory;
|
||||
}
|
||||
}
|
||||
|
||||
function writeSession(next: StoresSession) {
|
||||
memory = next;
|
||||
try {
|
||||
Taro.setStorageSync(STORAGE_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* ignore quota */
|
||||
}
|
||||
}
|
||||
|
||||
export function isStoresSessionBootstrapped(): boolean {
|
||||
return readSession().bootstrapped;
|
||||
}
|
||||
|
||||
export function markStoresSessionBootstrapped(): void {
|
||||
const cur = readSession();
|
||||
writeSession({ ...cur, bootstrapped: true });
|
||||
}
|
||||
|
||||
export function getStoresListCache(): StoresListCache | null {
|
||||
return readSession().cache;
|
||||
}
|
||||
|
||||
export function setStoresListCache(cache: StoresListCache | null): void {
|
||||
const cur = readSession();
|
||||
writeSession({ ...cur, bootstrapped: true, cache });
|
||||
}
|
||||
|
||||
export function patchStoresFilterCache(
|
||||
patch: Partial<
|
||||
Pick<StoresListCache, 'filterRegion' | 'keyword' | 'keywordInput' | 'category'>
|
||||
>,
|
||||
): void {
|
||||
const cur = readSession();
|
||||
if (!cur.cache) {
|
||||
// 列表尚未写入时也要记下用户筛选,避免切回丢失
|
||||
writeSession({
|
||||
bootstrapped: true,
|
||||
cache: {
|
||||
cityKey: '',
|
||||
cityCode: '',
|
||||
listRegion: patch.filterRegion ?? { province: '', city: '', district: '' },
|
||||
items: [],
|
||||
filterRegion: patch.filterRegion ?? { province: '', city: '', district: '' },
|
||||
keyword: patch.keyword ?? '',
|
||||
keywordInput: patch.keywordInput ?? '',
|
||||
category: patch.category ?? {
|
||||
parentId: '',
|
||||
parentName: '',
|
||||
childId: '',
|
||||
childName: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
writeSession({
|
||||
...cur,
|
||||
bootstrapped: true,
|
||||
cache: { ...cur.cache, ...patch },
|
||||
});
|
||||
}
|
||||
|
||||
export function resetStoresSessionBootstrap(): void {
|
||||
memory = emptySession();
|
||||
try {
|
||||
Taro.removeStorageSync(STORAGE_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
/** 微信小程序基础库未内置 TextEncoder,qrcode 库依赖它编码 payload */
|
||||
function installTextEncodingPolyfill() {
|
||||
const root = (typeof globalThis !== 'undefined'
|
||||
? globalThis
|
||||
: typeof global !== 'undefined'
|
||||
? global
|
||||
: typeof wx !== 'undefined'
|
||||
? wx
|
||||
: {}) as typeof globalThis & { TextEncoder?: typeof TextEncoder };
|
||||
|
||||
if (typeof root.TextEncoder !== 'undefined') return;
|
||||
|
||||
class MiniTextEncoder implements TextEncoder {
|
||||
readonly encoding = 'utf-8';
|
||||
|
||||
encode(input?: string): Uint8Array {
|
||||
const str = input ?? '';
|
||||
const bytes: number[] = [];
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
let code = str.charCodeAt(i);
|
||||
if (code < 0x80) {
|
||||
bytes.push(code);
|
||||
} else if (code < 0x800) {
|
||||
bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
|
||||
} else if (code >= 0xd800 && code <= 0xdbff && i + 1 < str.length) {
|
||||
const next = str.charCodeAt(i + 1);
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
i += 1;
|
||||
code = 0x10000 + ((code - 0xd800) << 10) + (next - 0xdc00);
|
||||
bytes.push(
|
||||
0xf0 | (code >> 18),
|
||||
0x80 | ((code >> 12) & 0x3f),
|
||||
0x80 | ((code >> 6) & 0x3f),
|
||||
0x80 | (code & 0x3f),
|
||||
);
|
||||
} else {
|
||||
bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
|
||||
}
|
||||
} else {
|
||||
bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
|
||||
}
|
||||
}
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
root.TextEncoder = MiniTextEncoder as unknown as typeof TextEncoder;
|
||||
}
|
||||
|
||||
installTextEncodingPolyfill();
|
||||
|
||||
export {};
|
||||
@@ -1,11 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { trackPageView } from './analytics';
|
||||
|
||||
export function usePageView(eventName: string, params?: Record<string, unknown>) {
|
||||
const fired = useRef(false);
|
||||
useEffect(() => {
|
||||
if (fired.current) return;
|
||||
fired.current = true;
|
||||
trackPageView(eventName, params);
|
||||
}, [eventName, params]);
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { getWechatLocationDetailed } from '@dukang/weixin-sdk';
|
||||
import { API_BASE, CLIENT_APP, getToken, request } from './api';
|
||||
import { DEFAULT_REGION, REGION_ALL, regionFromGeo, type RegionSelection } from './region-data';
|
||||
import { FALLBACK_CITY_CODE } from './product-images';
|
||||
|
||||
export const GPS_CITY_STORAGE_KEY = 'dukang_gps_city';
|
||||
const USER_COORDS_KEY = 'dukang_user_coords';
|
||||
/** 用户拒绝定位后持久化,避免首页/门店每次 useDidShow 再弹授权 */
|
||||
const LOCATION_DENIED_KEY = 'dukang_location_denied';
|
||||
|
||||
export type ResolvedUserCity = {
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
cityCode?: string;
|
||||
cityName?: string;
|
||||
openCity: boolean;
|
||||
region: RegionSelection;
|
||||
displayCity: string;
|
||||
};
|
||||
|
||||
export type UserCoords = { latitude: number; longitude: number };
|
||||
|
||||
type GpsCityCache = ResolvedUserCity & { timestamp: number };
|
||||
|
||||
const FALLBACK_CITY: ResolvedUserCity = {
|
||||
province: DEFAULT_REGION.province,
|
||||
city: DEFAULT_REGION.city,
|
||||
district: REGION_ALL,
|
||||
cityCode: FALLBACK_CITY_CODE,
|
||||
cityName: '郑州市',
|
||||
openCity: true,
|
||||
region: DEFAULT_REGION,
|
||||
displayCity: '郑州市',
|
||||
};
|
||||
|
||||
function isLocationDenied(): boolean {
|
||||
try {
|
||||
return Taro.getStorageSync(LOCATION_DENIED_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function markLocationDenied() {
|
||||
try {
|
||||
Taro.setStorageSync(LOCATION_DENIED_KEY, '1');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function clearLocationDenied() {
|
||||
try {
|
||||
Taro.removeStorageSync(LOCATION_DENIED_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function isDenyMessage(errMsg?: string): boolean {
|
||||
return /auth deny|authorize|permission|denied|拒绝|用户拒绝|getLocation:fail/i.test(
|
||||
errMsg || '',
|
||||
);
|
||||
}
|
||||
|
||||
function readCache(): GpsCityCache | null {
|
||||
try {
|
||||
const raw = Taro.getStorageSync(GPS_CITY_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(String(raw)) as GpsCityCache;
|
||||
if (Date.now() - parsed.timestamp > 30 * 60 * 1000) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(data: ResolvedUserCity) {
|
||||
try {
|
||||
Taro.setStorageSync(
|
||||
GPS_CITY_STORAGE_KEY,
|
||||
JSON.stringify({ ...data, timestamp: Date.now() } satisfies GpsCityCache),
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function writeUserCoords(latitude: number, longitude: number) {
|
||||
try {
|
||||
Taro.setStorageSync(
|
||||
USER_COORDS_KEY,
|
||||
JSON.stringify({ latitude, longitude, timestamp: Date.now() }),
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function readCachedUserCoords(): UserCoords | null {
|
||||
try {
|
||||
const raw = Taro.getStorageSync(USER_COORDS_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(String(raw)) as UserCoords & { timestamp?: number };
|
||||
if (parsed.timestamp && Date.now() - parsed.timestamp > 30 * 60 * 1000) return null;
|
||||
if (!Number.isFinite(parsed.latitude) || !Number.isFinite(parsed.longitude)) return null;
|
||||
return { latitude: parsed.latitude, longitude: parsed.longitude };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 门店列表默认用市级全市筛选 */
|
||||
export function toCityWideRegion(region: RegionSelection): RegionSelection {
|
||||
return {
|
||||
province: region.province,
|
||||
city: region.city,
|
||||
district: REGION_ALL,
|
||||
};
|
||||
}
|
||||
|
||||
/** 拒绝或失败后写入兜底城市,避免短时间内反复调起定位 */
|
||||
function cacheFallbackAndMaybeDeny(denied: boolean) {
|
||||
if (denied) markLocationDenied();
|
||||
writeCache(FALLBACK_CITY);
|
||||
}
|
||||
|
||||
async function reportLocationToServer(payload: {
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
sdk: 'jssdk' | 'geolocation';
|
||||
status: 'success' | 'fail';
|
||||
errMsg?: string;
|
||||
}) {
|
||||
return request<{
|
||||
province?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
cityCode?: string;
|
||||
cityName?: string;
|
||||
openCity?: boolean;
|
||||
}>('/common/wechat/location', {
|
||||
method: 'POST',
|
||||
data: payload,
|
||||
});
|
||||
}
|
||||
|
||||
function toResolved(data: {
|
||||
province?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
cityCode?: string;
|
||||
cityName?: string;
|
||||
openCity?: boolean;
|
||||
}): ResolvedUserCity | null {
|
||||
if (!data.province || !data.city) return null;
|
||||
const region = regionFromGeo(data.province, data.city, data.district);
|
||||
const displayCity = data.cityName ?? (data.city.endsWith('市') ? data.city : `${data.city}市`);
|
||||
return {
|
||||
province: data.province,
|
||||
city: data.city,
|
||||
district: data.district ?? '',
|
||||
cityCode: data.cityCode,
|
||||
cityName: data.cityName,
|
||||
openCity: !!data.openCity,
|
||||
region,
|
||||
displayCity,
|
||||
};
|
||||
}
|
||||
|
||||
async function promptLocationAuthOnce() {
|
||||
await Taro.showModal({
|
||||
title: '位置授权',
|
||||
content: '需要获取您的位置以展示所在城市的商品与门店。拒绝后将默认使用郑州市,不会再次弹窗。',
|
||||
confirmText: '知道了',
|
||||
showCancel: false,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function getMiniLocation(): Promise<Taro.getLocation.SuccessCallbackResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
Taro.getLocation({
|
||||
type: 'gcj02',
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveViaH5Jssdk(): Promise<ResolvedUserCity | null> {
|
||||
const outcome = await getWechatLocationDetailed({
|
||||
apiBase: API_BASE,
|
||||
clientApp: CLIENT_APP,
|
||||
getAccessToken: () => getToken() || null,
|
||||
});
|
||||
|
||||
if (!outcome.location) {
|
||||
const denied = isDenyMessage(outcome.errMsg);
|
||||
await reportLocationToServer({
|
||||
sdk: outcome.sdk,
|
||||
status: 'fail',
|
||||
errMsg: outcome.errMsg,
|
||||
}).catch(() => {});
|
||||
cacheFallbackAndMaybeDeny(denied);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
writeUserCoords(outcome.location.latitude, outcome.location.longitude);
|
||||
const data = await reportLocationToServer({
|
||||
latitude: outcome.location.latitude,
|
||||
longitude: outcome.location.longitude,
|
||||
sdk: outcome.sdk,
|
||||
status: 'success',
|
||||
});
|
||||
const resolved = toResolved(data);
|
||||
if (resolved) {
|
||||
clearLocationDenied();
|
||||
writeCache(resolved);
|
||||
}
|
||||
return resolved;
|
||||
} catch {
|
||||
cacheFallbackAndMaybeDeny(false);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取并解析用户当前城市;失败返回郑州市兜底 */
|
||||
export async function resolveUserCity(force = false): Promise<ResolvedUserCity> {
|
||||
if (!force) {
|
||||
if (isLocationDenied()) {
|
||||
const cached = readCache();
|
||||
return cached ?? FALLBACK_CITY;
|
||||
}
|
||||
const cached = readCache();
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
if (process.env.TARO_ENV === 'h5') {
|
||||
const fromJssdk = await resolveViaH5Jssdk();
|
||||
return fromJssdk ?? FALLBACK_CITY;
|
||||
}
|
||||
|
||||
if (process.env.TARO_ENV !== 'weapp') {
|
||||
return FALLBACK_CITY;
|
||||
}
|
||||
|
||||
try {
|
||||
const loc = await getMiniLocation();
|
||||
writeUserCoords(loc.latitude, loc.longitude);
|
||||
const data = await reportLocationToServer({
|
||||
latitude: loc.latitude,
|
||||
longitude: loc.longitude,
|
||||
sdk: 'jssdk',
|
||||
status: 'success',
|
||||
});
|
||||
const resolved = toResolved(data);
|
||||
if (resolved) {
|
||||
clearLocationDenied();
|
||||
writeCache(resolved);
|
||||
return resolved;
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
const denied = isDenyMessage(errMsg);
|
||||
if (denied && !isLocationDenied()) {
|
||||
await promptLocationAuthOnce();
|
||||
}
|
||||
await reportLocationToServer({
|
||||
sdk: 'jssdk',
|
||||
status: 'fail',
|
||||
errMsg: errMsg.slice(0, 200),
|
||||
}).catch(() => {});
|
||||
cacheFallbackAndMaybeDeny(denied);
|
||||
}
|
||||
|
||||
return FALLBACK_CITY;
|
||||
}
|
||||
|
||||
export function getCityCodeForCatalog(resolved: ResolvedUserCity): string {
|
||||
return resolved.openCity && resolved.cityCode ? resolved.cityCode : FALLBACK_CITY_CODE;
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { normalizePhoneInput } from './phone';
|
||||
|
||||
const USER_PHONE_KEY = 'user_phone';
|
||||
|
||||
function isValidMobile(phone: string) {
|
||||
return /^1[3-9]\d{9}$/.test(phone);
|
||||
}
|
||||
|
||||
export function saveUserPhone(phone: string) {
|
||||
const normalized = normalizePhoneInput(phone);
|
||||
if (!isValidMobile(normalized)) return;
|
||||
try {
|
||||
Taro.setStorageSync(USER_PHONE_KEY, normalized);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoredUserPhone(): string {
|
||||
try {
|
||||
const value = Taro.getStorageSync(USER_PHONE_KEY);
|
||||
return typeof value === 'string' ? value : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** 新增地址等场景:优先本地缓存,其次资料里的已验证手机号 */
|
||||
export function resolveDefaultUserPhone(profile?: { phone?: string | null; phoneVerified?: boolean } | null) {
|
||||
const stored = getStoredUserPhone();
|
||||
if (isValidMobile(stored)) return stored;
|
||||
if (!profile?.phoneVerified || !profile.phone) return '';
|
||||
const fromProfile = normalizePhoneInput(profile.phone);
|
||||
if (!isValidMobile(fromProfile)) return '';
|
||||
saveUserPhone(fromProfile);
|
||||
return fromProfile;
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import type { WechatLoginResult } from '@dukang/shared-types';
|
||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { saveAuth } from './api';
|
||||
import {
|
||||
fetchMiniWechatUserInfo,
|
||||
mergeWxDisplayProfile,
|
||||
needsWxProfileFill,
|
||||
syncMiniWechatProfile,
|
||||
} from './mini-wechat-profile';
|
||||
|
||||
/**
|
||||
* 兼容旧分包对资料 helper 的引用,避免 tree-shake 后出现 is not a function
|
||||
*(开发者工具热更新时常见旧页 + 新 common 混用)
|
||||
*/
|
||||
export { fetchMiniWechatUserInfo, mergeWxDisplayProfile, needsWxProfileFill };
|
||||
|
||||
/** 强制保留导出绑定,防止打包器删掉未引用的 re-export */
|
||||
const _wxProfileCompat = {
|
||||
fetchMiniWechatUserInfo,
|
||||
mergeWxDisplayProfile,
|
||||
needsWxProfileFill,
|
||||
};
|
||||
if (
|
||||
typeof _wxProfileCompat.needsWxProfileFill !== 'function' ||
|
||||
typeof _wxProfileCompat.fetchMiniWechatUserInfo !== 'function' ||
|
||||
typeof _wxProfileCompat.mergeWxDisplayProfile !== 'function'
|
||||
) {
|
||||
throw new Error('mini-wechat-profile helpers missing');
|
||||
}
|
||||
import {
|
||||
authorizeWechatForPay,
|
||||
fetchClientConfig,
|
||||
fetchUserProfile,
|
||||
needsWechatAuthForPay,
|
||||
saveWechatLoginResult,
|
||||
type WechatBindResult,
|
||||
} from './pay-wechat';
|
||||
import { isWechatEnv, weixinSdk } from './weixin';
|
||||
|
||||
export type WechatAuthEnsureResult =
|
||||
| { ok: true }
|
||||
| { ok: false; redirecting: true }
|
||||
| { ok: false; needBindPhone: true; wxSessionKey: string };
|
||||
|
||||
/** 小程序:Taro.login → /auth/login/wechat;H5:公众号 OAuth */
|
||||
export async function loginWithWechat(): Promise<WechatLoginResult | void> {
|
||||
if (process.env.TARO_ENV === 'h5') {
|
||||
return loginWithWechatSdk();
|
||||
}
|
||||
const res = await Taro.login();
|
||||
if (!res.code) {
|
||||
throw new Error(res.errMsg || '微信登录失败,未获取到 code');
|
||||
}
|
||||
const { request } = await import('./api');
|
||||
return request<WechatLoginResult>('/auth/login/wechat', {
|
||||
method: 'POST',
|
||||
data: { code: res.code, platform: 'mini' },
|
||||
});
|
||||
}
|
||||
|
||||
export async function checkNeedsWechatAuth(): Promise<boolean> {
|
||||
const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]);
|
||||
return needsWechatAuthForPay(config, profile);
|
||||
}
|
||||
|
||||
/** 真实微信支付前确保已绑定微信;OAuth 跳转时返回 redirecting */
|
||||
export async function ensureWechatAuthForPay(): Promise<WechatAuthEnsureResult> {
|
||||
if (!isWechatEnv()) return { ok: true };
|
||||
if (!(await checkNeedsWechatAuth())) return { ok: true };
|
||||
|
||||
const result = await authorizeWechatForPay();
|
||||
if (!result) return { ok: false, redirecting: true };
|
||||
|
||||
if (result.needBindPhone && result.wxSessionKey) {
|
||||
return { ok: false, needBindPhone: true, wxSessionKey: result.wxSessionKey };
|
||||
}
|
||||
|
||||
if (saveWechatLoginResult(result)) {
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false, redirecting: true };
|
||||
}
|
||||
|
||||
/** 处理 URL 中 OAuth ?code= 回调(H5 公众号) */
|
||||
export async function handleWechatAuthCallback(): Promise<WechatLoginResult | null> {
|
||||
if (process.env.TARO_ENV !== 'h5') return null;
|
||||
if (!isWechatEnv()) return null;
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) return null;
|
||||
const result = await weixinSdk.handleOAuthCallback();
|
||||
if (result) stripOAuthParamsFromLocation();
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function loginWithWechatSdk(): Promise<WechatLoginResult | void> {
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) return;
|
||||
if (!isWechatEnv()) {
|
||||
throw new Error('请在微信内打开以使用微信一键授权');
|
||||
}
|
||||
return weixinSdk.login();
|
||||
}
|
||||
|
||||
export function applyWechatLoginResult(result: WechatLoginResult): boolean {
|
||||
return saveWechatLoginResult(result);
|
||||
}
|
||||
|
||||
/** 已登录用户绑定微信:小程序 code;H5 走公众号 OAuth(带 JWT 时服务端会 attach) */
|
||||
export async function bindWechatForUser(
|
||||
prefetchedWxProfile?: { nickname?: string; avatarUrl?: string } | null,
|
||||
): Promise<WechatBindResult> {
|
||||
if (process.env.TARO_ENV === 'h5') {
|
||||
const result = await authorizeWechatForPay();
|
||||
if (!result) {
|
||||
return { ok: false, redirecting: true };
|
||||
}
|
||||
if (result.needBindPhone && result.wxSessionKey) {
|
||||
return { ok: false, needBindPhone: true, wxSessionKey: result.wxSessionKey };
|
||||
}
|
||||
if (result.accessToken) {
|
||||
saveAuth({
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
});
|
||||
}
|
||||
const profile = await fetchUserProfile();
|
||||
return { ok: true, profile };
|
||||
}
|
||||
|
||||
const res = await Taro.login();
|
||||
if (!res.code) {
|
||||
throw new Error(res.errMsg || '微信授权失败');
|
||||
}
|
||||
const { request } = await import('./api');
|
||||
const data = await request<WechatLoginResult>('/auth/wechat/bind', {
|
||||
method: 'POST',
|
||||
data: { code: res.code, platform: 'mini' },
|
||||
});
|
||||
if (data.needBindPhone && data.wxSessionKey) {
|
||||
return { ok: false, needBindPhone: true, wxSessionKey: data.wxSessionKey };
|
||||
}
|
||||
await syncMiniWechatProfile(prefetchedWxProfile);
|
||||
const profile = await fetchUserProfile();
|
||||
return { ok: true, profile };
|
||||
}
|
||||
|
||||
export type { WechatBindResult };
|
||||
@@ -1,299 +0,0 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { request, toast } from './api';
|
||||
import { fetchClientConfig } from './pay-wechat';
|
||||
|
||||
/** 微信确认收货组件来源 AppId(官方固定) */
|
||||
export const WECHAT_ORDER_CONFIRM_APPID = 'wx1183b055aeec94d1';
|
||||
|
||||
const PENDING_KEY = 'pending_wechat_order_confirm';
|
||||
|
||||
export type WechatConfirmPayload = {
|
||||
merchantId?: string | null;
|
||||
merchantTradeNo?: string | null;
|
||||
transactionId?: string | null;
|
||||
};
|
||||
|
||||
type PendingConfirm = {
|
||||
orderId: string;
|
||||
redirectUrl?: string;
|
||||
};
|
||||
|
||||
type OpenBusinessViewOptions = {
|
||||
businessType: string;
|
||||
extraData: Record<string, string>;
|
||||
success?: () => void;
|
||||
fail?: (err: { errMsg?: string }) => void;
|
||||
complete?: () => void;
|
||||
};
|
||||
|
||||
type MiniWx = {
|
||||
openBusinessView?: (opts: OpenBusinessViewOptions) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 取小程序原生 wx.openBusinessView。
|
||||
* 官方兼容写法:`if (wx.openBusinessView) { ... }`(不要用 canIUse 挡业务组件)。
|
||||
* Taro 未封装该 API;模块作用域下可能读不到全局 wx,需多重回退。
|
||||
*/
|
||||
function getOpenBusinessView(): ((opts: OpenBusinessViewOptions) => void) | null {
|
||||
if (process.env.TARO_ENV !== 'weapp') return null;
|
||||
|
||||
const candidates: Array<MiniWx | null | undefined> = [];
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
if (typeof wx !== 'undefined') candidates.push(wx as MiniWx);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
const g = globalThis as typeof globalThis & { wx?: MiniWx };
|
||||
candidates.push(g.wx);
|
||||
|
||||
try {
|
||||
// 跳出 bundler 模块作用域,读微信运行时全局
|
||||
const fromRuntime = new Function(
|
||||
'return typeof wx !== "undefined" ? wx : null',
|
||||
)() as MiniWx | null;
|
||||
candidates.push(fromRuntime);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
for (const api of candidates) {
|
||||
if (api && typeof api.openBusinessView === 'function') {
|
||||
return api.openBusinessView.bind(api);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function savePendingWechatOrderConfirm(pending: PendingConfirm) {
|
||||
Taro.setStorageSync(PENDING_KEY, JSON.stringify(pending));
|
||||
}
|
||||
|
||||
export function takePendingWechatOrderConfirm(): PendingConfirm | null {
|
||||
try {
|
||||
const raw = Taro.getStorageSync(PENDING_KEY);
|
||||
if (!raw) return null;
|
||||
Taro.removeStorageSync(PENDING_KEY);
|
||||
return typeof raw === 'string' ? (JSON.parse(raw) as PendingConfirm) : (raw as PendingConfirm);
|
||||
} catch {
|
||||
Taro.removeStorageSync(PENDING_KEY);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePayload(payload?: WechatConfirmPayload | null): WechatConfirmPayload {
|
||||
return {
|
||||
merchantId: payload?.merchantId?.trim() || undefined,
|
||||
merchantTradeNo: payload?.merchantTradeNo?.trim() || undefined,
|
||||
transactionId: payload?.transactionId?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveConfirmPayload(
|
||||
orderId: string,
|
||||
hint?: WechatConfirmPayload | null,
|
||||
): Promise<WechatConfirmPayload> {
|
||||
const fromHint = normalizePayload(hint);
|
||||
if (fromHint.transactionId || (fromHint.merchantId && fromHint.merchantTradeNo)) {
|
||||
return fromHint;
|
||||
}
|
||||
|
||||
const order = await request<{
|
||||
orderNo?: string;
|
||||
payExternalNo?: string | null;
|
||||
payment?: { externalNo?: string | null } | null;
|
||||
wechatConfirm?: WechatConfirmPayload | null;
|
||||
}>(`/trade/orders/${orderId}`);
|
||||
|
||||
const fromApi = normalizePayload(order.wechatConfirm);
|
||||
if (fromApi.transactionId || (fromApi.merchantId && fromApi.merchantTradeNo)) {
|
||||
return fromApi;
|
||||
}
|
||||
|
||||
const transactionId =
|
||||
order.payExternalNo?.trim() || order.payment?.externalNo?.trim() || undefined;
|
||||
return normalizePayload({
|
||||
transactionId,
|
||||
merchantTradeNo: order.orderNo,
|
||||
merchantId: fromApi.merchantId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉起微信「确认收货」半屏组件。
|
||||
* @see https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/order-shipping/order-shipping-half.html
|
||||
*/
|
||||
export function openWechatOrderConfirm(opts: {
|
||||
orderId: string;
|
||||
payload: WechatConfirmPayload;
|
||||
redirectUrl?: string;
|
||||
}): Promise<'opened' | 'unsupported' | 'missing_pay_ref'> {
|
||||
const open = getOpenBusinessView();
|
||||
if (!open) {
|
||||
console.warn('[wechat-order-confirm] openBusinessView unavailable', {
|
||||
taroEnv: process.env.TARO_ENV,
|
||||
});
|
||||
return Promise.resolve('unsupported');
|
||||
}
|
||||
|
||||
const payload = normalizePayload(opts.payload);
|
||||
const transactionId = payload.transactionId;
|
||||
const merchantId = payload.merchantId;
|
||||
const merchantTradeNo = payload.merchantTradeNo;
|
||||
if (!transactionId && !(merchantId && merchantTradeNo)) {
|
||||
console.warn('[wechat-order-confirm] missing pay ref', payload);
|
||||
return Promise.resolve('missing_pay_ref');
|
||||
}
|
||||
|
||||
const extraData: Record<string, string> = {};
|
||||
if (transactionId) extraData.transaction_id = transactionId;
|
||||
if (merchantId) extraData.merchant_id = merchantId;
|
||||
if (merchantTradeNo) extraData.merchant_trade_no = merchantTradeNo;
|
||||
|
||||
savePendingWechatOrderConfirm({
|
||||
orderId: opts.orderId,
|
||||
redirectUrl: opts.redirectUrl,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const done = (mode: 'opened' | 'unsupported' | 'missing_pay_ref') => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(mode);
|
||||
};
|
||||
|
||||
try {
|
||||
open({
|
||||
businessType: 'weappOrderConfirm',
|
||||
extraData,
|
||||
success: () => done('opened'),
|
||||
fail: (err) => {
|
||||
Taro.removeStorageSync(PENDING_KEY);
|
||||
console.error('[wechat-order-confirm] openBusinessView fail', err, extraData);
|
||||
toast(err?.errMsg || '无法打开微信确认收货,请稍后重试');
|
||||
done('unsupported');
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
Taro.removeStorageSync(PENDING_KEY);
|
||||
console.error('[wechat-order-confirm] openBusinessView throw', err);
|
||||
toast('无法打开微信确认收货组件');
|
||||
done('unsupported');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
type ReferrerExtra = {
|
||||
status?: string;
|
||||
errormsg?: string;
|
||||
req_extradata?: Record<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理确认收货组件回跳(App/页面 onShow)。
|
||||
* 成功则调用后端同步订单,并返回是否已处理。
|
||||
*/
|
||||
export async function handleWechatOrderConfirmShow(options?: {
|
||||
referrerInfo?: { appId?: string; extraData?: ReferrerExtra };
|
||||
}): Promise<{ handled: boolean; orderId?: string; redirectUrl?: string }> {
|
||||
const info = options?.referrerInfo;
|
||||
if (!info?.appId || info.appId !== WECHAT_ORDER_CONFIRM_APPID) {
|
||||
return { handled: false };
|
||||
}
|
||||
|
||||
const pending = takePendingWechatOrderConfirm();
|
||||
const status = info.extraData?.status;
|
||||
if (status === 'cancel') {
|
||||
toast('已取消确认收货');
|
||||
return { handled: true, orderId: pending?.orderId };
|
||||
}
|
||||
if (status === 'fail') {
|
||||
toast(info.extraData?.errormsg || '微信确认收货失败');
|
||||
return { handled: true, orderId: pending?.orderId };
|
||||
}
|
||||
if (status !== 'success' || !pending?.orderId) {
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
try {
|
||||
await request(`/trade/orders/${pending.orderId}/confirm-receive`, {
|
||||
method: 'POST',
|
||||
data: { source: 'WECHAT_COMPONENT' },
|
||||
});
|
||||
toast('确认收货成功', 'success');
|
||||
return {
|
||||
handled: true,
|
||||
orderId: pending.orderId,
|
||||
redirectUrl: pending.redirectUrl,
|
||||
};
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '同步订单失败');
|
||||
return { handled: true, orderId: pending.orderId };
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmLocally(opts: {
|
||||
orderId: string;
|
||||
onSitePickup?: boolean;
|
||||
onLocalSuccess?: () => void | Promise<void>;
|
||||
}): Promise<'local'> {
|
||||
await request(`/trade/orders/${opts.orderId}/confirm-receive`, {
|
||||
method: 'POST',
|
||||
data: {
|
||||
onSitePickup: !!opts.onSitePickup,
|
||||
source: 'USER',
|
||||
},
|
||||
});
|
||||
await opts.onLocalSuccess?.();
|
||||
return 'local';
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一入口:
|
||||
* - 小程序 + 真实支付:必须拉起 weappOrderConfirm,禁止静默降级
|
||||
* - Mock / H5:本地确认
|
||||
*/
|
||||
export async function confirmOrderReceive(opts: {
|
||||
orderId: string;
|
||||
wechatConfirm?: WechatConfirmPayload | null;
|
||||
onSitePickup?: boolean;
|
||||
redirectUrl?: string;
|
||||
onLocalSuccess?: () => void | Promise<void>;
|
||||
}): Promise<'wechat' | 'local'> {
|
||||
const isWeapp = process.env.TARO_ENV === 'weapp';
|
||||
|
||||
if (!isWeapp) {
|
||||
return confirmLocally(opts);
|
||||
}
|
||||
|
||||
let mockPay = false;
|
||||
try {
|
||||
const cfg = await fetchClientConfig();
|
||||
mockPay = !!cfg.mockPay;
|
||||
} catch {
|
||||
mockPay = false;
|
||||
}
|
||||
|
||||
if (mockPay) {
|
||||
return confirmLocally(opts);
|
||||
}
|
||||
|
||||
const payload = await resolveConfirmPayload(opts.orderId, opts.wechatConfirm);
|
||||
const mode = await openWechatOrderConfirm({
|
||||
orderId: opts.orderId,
|
||||
payload,
|
||||
redirectUrl: opts.redirectUrl,
|
||||
});
|
||||
|
||||
if (mode === 'opened') return 'wechat';
|
||||
|
||||
if (mode === 'missing_pay_ref') {
|
||||
throw new Error('缺少微信支付单号,无法打开确认收货组件');
|
||||
}
|
||||
throw new Error('当前环境无法打开微信确认收货组件,请用微信最新版打开小程序后重试');
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { BRAND_LOGO_URL } from '@dukang/shared-types';
|
||||
import type { WechatShareData } from '@dukang/weixin-sdk';
|
||||
import { getWechatShareLink, isWechatBrowser, isMiniProgram } from '@dukang/weixin-sdk';
|
||||
import { toast } from './api';
|
||||
import { isWechatEnv, weixinSdk } from './weixin';
|
||||
|
||||
export const DEFAULT_SHARE_TITLE = '你吃饭,杜康买单';
|
||||
export const DEFAULT_SHARE_DESC = '杜康好客 · 买美酒,享好礼,全城门店可用';
|
||||
export const WECHAT_SHARE_HINT = '请点击右上角 ··· 分享给好友';
|
||||
|
||||
export function getDefaultShareImageUrl(): string {
|
||||
return BRAND_LOGO_URL;
|
||||
}
|
||||
|
||||
export function buildDefaultShareData(
|
||||
overrides?: Partial<WechatShareData>,
|
||||
): WechatShareData {
|
||||
let link = overrides?.link;
|
||||
if (!link) {
|
||||
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
|
||||
link = getWechatShareLink();
|
||||
} else {
|
||||
link = '';
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: overrides?.title ?? DEFAULT_SHARE_TITLE,
|
||||
desc: overrides?.desc ?? DEFAULT_SHARE_DESC,
|
||||
link,
|
||||
imgUrl: overrides?.imgUrl ?? getDefaultShareImageUrl(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 配置 H5 微信内右上角分享卡片 */
|
||||
export async function applyWechatShare(
|
||||
overrides?: Partial<WechatShareData>,
|
||||
): Promise<void> {
|
||||
if (process.env.TARO_ENV !== 'h5') return;
|
||||
if (!isWechatBrowser()) return;
|
||||
await weixinSdk.setShare(buildDefaultShareData(overrides));
|
||||
}
|
||||
|
||||
export type PageSharePayload = {
|
||||
title?: string;
|
||||
desc?: string;
|
||||
/** 小程序分享 path,如 /pages/product-detail/index?id=1 */
|
||||
path?: string;
|
||||
imgUrl?: string;
|
||||
/** H5 自定义分享 link,默认当前页 */
|
||||
link?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 点击「分享」按钮。
|
||||
* H5:走微信 JSSDK 配置分享卡片,并尝试 invoke 调起面板;否则返回需展示引导蒙层。
|
||||
*/
|
||||
export async function handleShareButtonClick(
|
||||
payload?: PageSharePayload,
|
||||
): Promise<{ showGuide: boolean }> {
|
||||
if (process.env.TARO_ENV === 'weapp' || isMiniProgram()) {
|
||||
try {
|
||||
await Taro.showShareMenu({ withShareTicket: true, showShareItems: ['shareAppMessage', 'shareTimeline'] });
|
||||
} catch {
|
||||
try {
|
||||
await Taro.showShareMenu({ withShareTicket: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
toast(WECHAT_SHARE_HINT);
|
||||
return { showGuide: false };
|
||||
}
|
||||
|
||||
if (!isWechatEnv()) {
|
||||
toast('请在微信内打开后分享');
|
||||
return { showGuide: false };
|
||||
}
|
||||
|
||||
const data = buildDefaultShareData({
|
||||
title: payload?.title,
|
||||
desc: payload?.desc,
|
||||
imgUrl: payload?.imgUrl,
|
||||
link: payload?.link,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await weixinSdk.share(data);
|
||||
if (result.invoked) {
|
||||
return { showGuide: false };
|
||||
}
|
||||
toast(WECHAT_SHARE_HINT);
|
||||
return { showGuide: true };
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '分享配置失败,请刷新后重试');
|
||||
return { showGuide: true };
|
||||
}
|
||||
}
|
||||
|
||||
/** 供 useShareAppMessage 使用的标题/路径/图 */
|
||||
export function toWeappShareMessage(payload?: PageSharePayload) {
|
||||
return {
|
||||
title: payload?.title || DEFAULT_SHARE_TITLE,
|
||||
path: payload?.path || '/pages/home/index',
|
||||
imageUrl: payload?.imgUrl || getDefaultShareImageUrl(),
|
||||
};
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { createWeixinSdk, isWechatEnv } from '@dukang/weixin-sdk';
|
||||
import { API_BASE, CLIENT_APP, getToken } from './api';
|
||||
|
||||
/**
|
||||
* H5 与业务 API 共用 CLIENT_APP:
|
||||
* - H5 → USER_H5(公众号 OAuth openId + 公众号支付 appId)
|
||||
* - 小程序 → USER_MINI
|
||||
* 须与 JWT 签发一致,才能同时避免「反复授权」与「appid/openid 不匹配」。
|
||||
*/
|
||||
export const weixinSdk = createWeixinSdk({
|
||||
apiBase: API_BASE,
|
||||
clientApp: CLIENT_APP,
|
||||
getAccessToken: () => getToken() || null,
|
||||
});
|
||||
|
||||
export { isWechatEnv };
|
||||
@@ -1,4 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '编辑地址',
|
||||
});
|
||||
@@ -1,253 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text, Input, Textarea, Switch } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import RegionPicker from '../../components/RegionPicker';
|
||||
import { buildAddressListUrl, readCheckoutContext } from '../../lib/checkout-nav';
|
||||
import {
|
||||
DEFAULT_REGION,
|
||||
REGION_ALL,
|
||||
formatRegion,
|
||||
type RegionSelection,
|
||||
} from '../../lib/region-data';
|
||||
import { normalizePhoneInput, validateMobilePhone } from '../../lib/phone';
|
||||
import { getStoredUserPhone, resolveDefaultUserPhone } from '../../lib/user-phone';
|
||||
import { resolveUserCity } from '../../lib/user-location';
|
||||
import { request, toast, type UserProfile } from '../../lib/api';
|
||||
|
||||
type AddressForm = {
|
||||
receiverName: string;
|
||||
phone: string;
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
detail: string;
|
||||
isDefault: boolean;
|
||||
};
|
||||
|
||||
export default function AddressEditPage() {
|
||||
const router = useRouter();
|
||||
const id = router.params.id;
|
||||
const isEdit = !!id;
|
||||
const checkoutCtx = readCheckoutContext(router.params);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [locating, setLocating] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [form, setForm] = useState<AddressForm>(() => ({
|
||||
receiverName: '',
|
||||
phone: id ? '' : getStoredUserPhone(),
|
||||
province: DEFAULT_REGION.province,
|
||||
city: DEFAULT_REGION.city,
|
||||
district: DEFAULT_REGION.district,
|
||||
detail: '',
|
||||
isDefault: true,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (id) return;
|
||||
request<UserProfile>('/auth/me')
|
||||
.then((me) => {
|
||||
const phone = resolveDefaultUserPhone(me);
|
||||
if (!phone) return;
|
||||
setForm((prev) => (prev.phone ? prev : { ...prev, phone }));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) return;
|
||||
let cancelled = false;
|
||||
setLocating(true);
|
||||
void resolveUserCity(true)
|
||||
.then((resolved) => {
|
||||
if (cancelled) return;
|
||||
const district =
|
||||
resolved.region.district && resolved.region.district !== REGION_ALL
|
||||
? resolved.region.district
|
||||
: resolved.district && resolved.district !== REGION_ALL
|
||||
? resolved.district
|
||||
: DEFAULT_REGION.district;
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
province: resolved.region.province || prev.province,
|
||||
city: resolved.region.city || prev.city,
|
||||
district: district || prev.district,
|
||||
}));
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLocating(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
request<Array<Record<string, unknown>>>('/user/addresses').then((list) => {
|
||||
const found = list.find((a) => String(a.id) === id);
|
||||
if (found) {
|
||||
setForm({
|
||||
receiverName: String(found.receiverName ?? ''),
|
||||
phone: String(found.phone ?? ''),
|
||||
province: String(found.province ?? DEFAULT_REGION.province),
|
||||
city: String(found.city ?? DEFAULT_REGION.city),
|
||||
district: String(found.district ?? DEFAULT_REGION.district),
|
||||
detail: String(found.detail ?? ''),
|
||||
isDefault: found.isDefault === 1 || found.isDefault === true,
|
||||
});
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, [id]);
|
||||
|
||||
const regionText = formatRegion(form.province, form.city, form.district);
|
||||
|
||||
function validateForm(): string | null {
|
||||
if (!form.receiverName.trim()) return '请输入收货人姓名';
|
||||
const phoneCheck = validateMobilePhone(form.phone);
|
||||
if (!phoneCheck.ok) return phoneCheck.message ?? '请输入正确的手机号码';
|
||||
if (!form.province || !form.city || !form.district) return '请选择所在地区';
|
||||
if (!form.detail.trim()) return '请输入详细地址';
|
||||
return null;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const validationError = validateForm();
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
toast(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const payload = {
|
||||
receiverName: form.receiverName.trim(),
|
||||
phone: form.phone.trim(),
|
||||
province: form.province,
|
||||
city: form.city,
|
||||
district: form.district,
|
||||
detail: form.detail.trim(),
|
||||
isDefault: form.isDefault,
|
||||
};
|
||||
if (isEdit && id) {
|
||||
await request(`/user/addresses/${id}`, { method: 'PUT', data: payload });
|
||||
toast('地址已更新', 'success');
|
||||
} else {
|
||||
await request('/user/addresses', { method: 'POST', data: payload });
|
||||
toast('地址已新增', 'success');
|
||||
}
|
||||
setTimeout(() => {
|
||||
Taro.redirectTo({ url: buildAddressListUrl(checkoutCtx) }).catch(() => {
|
||||
Taro.navigateBack();
|
||||
});
|
||||
}, 400);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '保存失败';
|
||||
setError(msg);
|
||||
toast(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onRegionConfirm(region: RegionSelection) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
province: region.province,
|
||||
city: region.city,
|
||||
district: region.district,
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="address-page" hasFixedFooter>
|
||||
<SubPageHeader title={isEdit ? '编辑地址' : '新增地址'} />
|
||||
<View className="sub-page-body" style={{ paddingBottom: 80 }}>
|
||||
<View className="address-form-field">
|
||||
<Text className="address-form-label">收货人</Text>
|
||||
<Input
|
||||
className="address-form-input"
|
||||
placeholder="请输入姓名"
|
||||
value={form.receiverName}
|
||||
onInput={(e) => setForm((prev) => ({ ...prev, receiverName: e.detail.value }))}
|
||||
/>
|
||||
</View>
|
||||
<View className="address-form-field">
|
||||
<Text className="address-form-label">手机号</Text>
|
||||
<Input
|
||||
className="address-form-input"
|
||||
type="number"
|
||||
maxlength={11}
|
||||
placeholder="请输入手机号"
|
||||
value={form.phone}
|
||||
onInput={(e) =>
|
||||
setForm((prev) => ({ ...prev, phone: normalizePhoneInput(e.detail.value) }))
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<View className="address-form-field">
|
||||
<Text className="address-form-label">所在地区</Text>
|
||||
<View
|
||||
className="address-form-input"
|
||||
style={{ display: 'flex', alignItems: 'center' }}
|
||||
onClick={() => setPickerOpen(true)}
|
||||
>
|
||||
<Text>
|
||||
{locating && !isEdit
|
||||
? '定位中…'
|
||||
: regionText || '请选择省市区'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="address-form-field">
|
||||
<Text className="address-form-label">详细地址</Text>
|
||||
{process.env.TARO_ENV === 'h5' ? (
|
||||
<textarea
|
||||
className="address-form-textarea address-form-textarea--native"
|
||||
placeholder="街道门牌号等"
|
||||
rows={3}
|
||||
value={form.detail}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setForm((prev) => ({ ...prev, detail: value }));
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Textarea
|
||||
className="address-form-textarea"
|
||||
placeholder="街道门牌号等"
|
||||
value={form.detail}
|
||||
maxlength={200}
|
||||
onInput={(e) => setForm((prev) => ({ ...prev, detail: e.detail.value }))}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<View className="address-form-row">
|
||||
<Text>设为默认地址</Text>
|
||||
<Switch
|
||||
checked={form.isDefault}
|
||||
color="#A61D24"
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, isDefault: e.detail.value }))}
|
||||
/>
|
||||
</View>
|
||||
{error ? <Text className="address-form-error">{error}</Text> : null}
|
||||
</View>
|
||||
<View className="address-fab" onClick={() => !saving && void save()}>
|
||||
<Text>{saving ? '保存中…' : '保存'}</Text>
|
||||
</View>
|
||||
|
||||
<RegionPicker
|
||||
open={pickerOpen}
|
||||
value={{ province: form.province, city: form.city, district: form.district }}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onConfirm={onRegionConfirm}
|
||||
levels={3}
|
||||
/>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '地址管理',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark',
|
||||
});
|
||||
@@ -1,139 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useDidShow, usePullDownRefresh, useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import {
|
||||
buildAddressEditUrl,
|
||||
buildOrderConfirmUrl,
|
||||
readCheckoutContext,
|
||||
} from '../../lib/checkout-nav';
|
||||
import { request, toast } from '../../lib/api';
|
||||
|
||||
type Address = {
|
||||
id: string;
|
||||
receiverName: string;
|
||||
phone: string;
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
detail: string;
|
||||
isDefault?: number | boolean;
|
||||
};
|
||||
|
||||
function formatAddress(a: Address) {
|
||||
return `${a.province}${a.city}${a.district}${a.detail}`;
|
||||
}
|
||||
|
||||
export default function AddressesPage() {
|
||||
const router = useRouter();
|
||||
const checkoutCtx = readCheckoutContext(router.params);
|
||||
const selectMode = checkoutCtx.select === true;
|
||||
const [list, setList] = useState<Address[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadList = useCallback(() => {
|
||||
setLoading(true);
|
||||
request<Address[]>('/user/addresses')
|
||||
.then((data) => setList(Array.isArray(data) ? data : []))
|
||||
.catch(() => setList([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useDidShow(() => {
|
||||
loadList();
|
||||
});
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
void Promise.resolve(loadList()).finally(() => Taro.stopPullDownRefresh());
|
||||
});
|
||||
|
||||
function selectAddress(addr: Address) {
|
||||
if (!selectMode) return;
|
||||
Taro.redirectTo({
|
||||
url: buildOrderConfirmUrl({
|
||||
productId: checkoutCtx.productId,
|
||||
qty: checkoutCtx.qty,
|
||||
addressId: addr.id,
|
||||
cross: checkoutCtx.cross,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function removeAddress(id: string) {
|
||||
const res = await Taro.showModal({
|
||||
title: '删除地址',
|
||||
content: '确定删除该收货地址吗?',
|
||||
});
|
||||
if (!res.confirm) return;
|
||||
try {
|
||||
await request(`/user/addresses/${id}`, { method: 'DELETE' });
|
||||
toast('已删除', 'success');
|
||||
loadList();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="address-page" hasFixedFooter>
|
||||
<SubPageHeader title={selectMode ? '选择收货地址' : '地址管理'} />
|
||||
<View className="sub-page-body" style={{ paddingBottom: 80 }}>
|
||||
{loading ? <View className="u-empty">加载中…</View> : null}
|
||||
{!loading && list.length === 0 ? (
|
||||
<View className="u-empty">暂无收货地址</View>
|
||||
) : null}
|
||||
{list.map((a) => (
|
||||
<View
|
||||
key={a.id}
|
||||
className="address-item"
|
||||
onClick={() => selectAddress(a)}
|
||||
>
|
||||
<View className="address-item-head">
|
||||
<Text className="address-item-name">{a.receiverName}</Text>
|
||||
<Text className="address-item-phone">{a.phone}</Text>
|
||||
{a.isDefault === 1 || a.isDefault === true ? (
|
||||
<Text className="address-default-tag">默认</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Text className="address-item-detail">{formatAddress(a)}</Text>
|
||||
{!selectMode ? (
|
||||
<View className="address-item-actions">
|
||||
<Text
|
||||
className="address-action"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
Taro.navigateTo({ url: buildAddressEditUrl(a.id, checkoutCtx) }).catch((err) => {
|
||||
toast(err instanceof Error ? err.message : '无法打开编辑页');
|
||||
});
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Text>
|
||||
<Text
|
||||
className="address-action"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void removeAddress(a.id);
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<View
|
||||
className="address-fab"
|
||||
onClick={() => {
|
||||
Taro.navigateTo({ url: buildAddressEditUrl(undefined, checkoutCtx) }).catch((e) => {
|
||||
toast(e instanceof Error ? e.message : '无法打开新增地址页');
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Text>新增地址</Text>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '权益明细',
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
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 } from '../../lib/api';
|
||||
|
||||
type LedgerItem = {
|
||||
id: string;
|
||||
title?: string;
|
||||
amount: number;
|
||||
createdAt?: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
export default function BenefitDetailPage() {
|
||||
const [items, setItems] = useState<LedgerItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
request<LedgerItem[]>('/benefit/ledger')
|
||||
.then((data) => setItems(Array.isArray(data) ? data : []))
|
||||
.catch(() => {
|
||||
// UI shell fallback demo rows when API missing
|
||||
setItems([]);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="benefit-detail-page">
|
||||
<SubPageHeader
|
||||
title="权益明细"
|
||||
onBack={() => {
|
||||
const pages = Taro.getCurrentPages();
|
||||
if (pages.length > 1) {
|
||||
Taro.navigateBack();
|
||||
return;
|
||||
}
|
||||
Taro.switchTab({ url: '/pages/mine/index' });
|
||||
}}
|
||||
/>
|
||||
<View className="sub-page-body">
|
||||
<View className="order-card">
|
||||
{loading ? <View className="u-empty">加载中…</View> : null}
|
||||
{!loading && items.length === 0 ? (
|
||||
<View className="u-empty">暂无明细记录</View>
|
||||
) : null}
|
||||
{items.map((item) => {
|
||||
const isOut = Number(item.amount) < 0 || item.type === 'REDEEM';
|
||||
return (
|
||||
<View key={item.id} className="ledger-item">
|
||||
<View>
|
||||
<Text className="ledger-title">{item.title || (isOut ? '门店核销' : '购酒入账')}</Text>
|
||||
<Text className="ledger-time">
|
||||
{item.createdAt ? String(item.createdAt).slice(0, 19).replace('T', ' ') : '-'}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className={`ledger-amount ${isOut ? 'ledger-amount--out' : 'ledger-amount--in'}`}>
|
||||
{isOut ? '' : '+'}
|
||||
{Number(item.amount).toFixed(2)}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '好客权益',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark',
|
||||
enableShareAppMessage: true,
|
||||
enableShareTimeline: true,
|
||||
});
|
||||
@@ -1,251 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import WechatShareReady from '../../components/WechatShareReady';
|
||||
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
||||
import { goLogin } from '../../lib/auth-nav';
|
||||
import { isLoggedIn, request, toast } from '../../lib/api';
|
||||
import { navBarStyle, tabNavContentStyle, useNavBarMetrics } from '../../lib/nav-bar';
|
||||
import {
|
||||
DEFAULT_SHARE_DESC,
|
||||
DEFAULT_SHARE_TITLE,
|
||||
toWeappShareMessage,
|
||||
} from '../../lib/wechat-share';
|
||||
import { formatMoney } from '../../lib/money';
|
||||
import iconBenefit from '../../assets/tabbar/benefit-active.png';
|
||||
|
||||
type BenefitSummary = {
|
||||
totalBalance: number;
|
||||
maxRedeemAmount: number;
|
||||
activeCouponCount: number;
|
||||
};
|
||||
|
||||
type CouponItem = {
|
||||
id: string;
|
||||
couponNo: string;
|
||||
totalAmount: number;
|
||||
usedAmount: number;
|
||||
balance: number;
|
||||
status: string;
|
||||
sourceProduct: string;
|
||||
};
|
||||
|
||||
type RedeemHistoryItem = {
|
||||
id: string;
|
||||
redeemNo: string;
|
||||
amount: number;
|
||||
storeName: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
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, setLoggedIn] = useState(() => isLoggedIn());
|
||||
const [summary, setSummary] = useState<BenefitSummary | null>(null);
|
||||
const [coupons, setCoupons] = useState<CouponItem[]>([]);
|
||||
const [redeemHistory, setRedeemHistory] = useState<RedeemHistoryItem[]>([]);
|
||||
const [tab, setTab] = useState<'available' | 'history'>('available');
|
||||
|
||||
const resetGuestState = useCallback(() => {
|
||||
setSummary(null);
|
||||
setCoupons([]);
|
||||
setRedeemHistory([]);
|
||||
}, []);
|
||||
|
||||
const loadBenefit = useCallback(() => {
|
||||
if (!isLoggedIn()) return Promise.resolve();
|
||||
return Promise.all([
|
||||
request<BenefitSummary>('/benefit/summary'),
|
||||
request<CouponItem[]>('/benefit/coupons'),
|
||||
request<{ list?: RedeemHistoryItem[] } | RedeemHistoryItem[]>('/redeem/records?page=1&pageSize=50'),
|
||||
])
|
||||
.then(([s, list, records]) => {
|
||||
setSummary(s);
|
||||
setCoupons(Array.isArray(list) ? list : []);
|
||||
const hist = Array.isArray(records)
|
||||
? records
|
||||
: Array.isArray(records?.list)
|
||||
? records.list
|
||||
: [];
|
||||
setRedeemHistory(hist);
|
||||
})
|
||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
||||
}, []);
|
||||
|
||||
useDidShow(() => {
|
||||
syncTabBarSelected(2);
|
||||
const loggedInNow = isLoggedIn();
|
||||
setLoggedIn(loggedInNow);
|
||||
if (loggedInNow) {
|
||||
void loadBenefit();
|
||||
} else {
|
||||
resetGuestState();
|
||||
}
|
||||
});
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
const loggedInNow = isLoggedIn();
|
||||
setLoggedIn(loggedInNow);
|
||||
if (!loggedInNow) {
|
||||
resetGuestState();
|
||||
Taro.stopPullDownRefresh();
|
||||
return;
|
||||
}
|
||||
void loadBenefit().finally(() => Taro.stopPullDownRefresh());
|
||||
});
|
||||
|
||||
const available = coupons.filter((c) => c.status === 'ACTIVE' && c.balance > 0);
|
||||
|
||||
const sharePayload = useMemo(
|
||||
() => ({
|
||||
title: '好客权益 · 杜康好客',
|
||||
desc: DEFAULT_SHARE_DESC,
|
||||
path: '/pages/benefit/index',
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
||||
useShareTimeline(() => ({
|
||||
title: sharePayload.title || DEFAULT_SHARE_TITLE,
|
||||
query: '',
|
||||
imageUrl: sharePayload.imgUrl,
|
||||
}));
|
||||
|
||||
return (
|
||||
<PageShell variant="tab" className="benefit-page">
|
||||
<WechatShareReady payload={sharePayload} />
|
||||
<View className="benefit-header" style={navBarStyle(metrics)} aria-label="好客权益">
|
||||
{process.env.TARO_ENV !== 'h5' ? (
|
||||
<Text className="benefit-header-title">好客权益</Text>
|
||||
) : null}
|
||||
<View
|
||||
className="benefit-header__content"
|
||||
style={tabNavContentStyle(metrics)}
|
||||
>
|
||||
<View className="benefit-header-city">
|
||||
<View className="benefit-header-city-pin" />
|
||||
<Text>郑州市</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{!loggedIn ? (
|
||||
<View className="benefit-login-gate">
|
||||
<View className="u-empty">登录后查看好客权益余额</View>
|
||||
<View
|
||||
className="u-btn u-btn--block"
|
||||
style={{ maxWidth: 240, margin: '0 auto' }}
|
||||
onClick={() => goLogin('/pages/benefit/index')}
|
||||
>
|
||||
<Text>去登录</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View className="benefit-main">
|
||||
<View className="benefit-hero">
|
||||
<View className="benefit-hero-top">
|
||||
<View>
|
||||
<Text className="benefit-hero-label">当前好客权益余额</Text>
|
||||
<View className="benefit-hero-amount">
|
||||
<Text className="benefit-hero-symbol">¥</Text>
|
||||
<Text className="benefit-hero-value">
|
||||
{summary ? formatMoney(summary.totalBalance) : '--'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="benefit-hero-logo">
|
||||
<Image className="benefit-hero-logo-img" src={iconBenefit} mode="aspectFit" />
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
className="benefit-hero-cta"
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
|
||||
>
|
||||
<Text>去使用</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="benefit-tabs">
|
||||
<Text
|
||||
className={`benefit-tab${tab === 'available' ? ' benefit-tab--active' : ''}`}
|
||||
onClick={() => setTab('available')}
|
||||
>
|
||||
可用权益
|
||||
</Text>
|
||||
<Text
|
||||
className={`benefit-tab${tab === 'history' ? ' benefit-tab--active' : ''}`}
|
||||
onClick={() => setTab('history')}
|
||||
>
|
||||
历史记录
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{tab === 'available' ? (
|
||||
available.length === 0 ? (
|
||||
<View className="u-empty">暂无可用权益</View>
|
||||
) : (
|
||||
available.map((c) => (
|
||||
<View key={c.id} className="benefit-coupon">
|
||||
<View className="benefit-coupon-notch" />
|
||||
<View className="benefit-coupon-notch benefit-coupon-notch--right" />
|
||||
<View className="benefit-coupon-head">
|
||||
<Text className="benefit-coupon-name">{c.sourceProduct || '好客权益'}</Text>
|
||||
<Text className="benefit-coupon-balance">¥{formatMoney(c.balance)}</Text>
|
||||
</View>
|
||||
<Text className="benefit-coupon-no">NO. {c.couponNo}</Text>
|
||||
<View className="benefit-progress">
|
||||
<View className="benefit-progress-bar" style={{ width: `${usagePercent(c)}%` }} />
|
||||
</View>
|
||||
<View className="benefit-coupon-footer">
|
||||
<Text className="benefit-coupon-meta">
|
||||
已用 ¥{formatMoney(c.usedAmount)} / 总额 ¥{formatMoney(c.totalAmount)}
|
||||
</Text>
|
||||
<Text
|
||||
className="benefit-coupon-btn"
|
||||
onClick={() =>
|
||||
Taro.navigateTo({
|
||||
url: `/pages/redeem/index?couponId=${c.id}&amount=${c.balance}`,
|
||||
})
|
||||
}
|
||||
>
|
||||
立即核销
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
)
|
||||
) : redeemHistory.length === 0 ? (
|
||||
<View className="u-empty">暂无核销记录</View>
|
||||
) : (
|
||||
redeemHistory.map((r) => (
|
||||
<View key={r.id} className="benefit-coupon">
|
||||
<View className="benefit-coupon-notch" />
|
||||
<View className="benefit-coupon-notch benefit-coupon-notch--right" />
|
||||
<View className="benefit-coupon-head">
|
||||
<Text className="benefit-coupon-name">{r.storeName || '门店核销'}</Text>
|
||||
<Text className="benefit-coupon-balance">-¥{formatMoney(Number(r.amount))}</Text>
|
||||
</View>
|
||||
<Text className="benefit-coupon-no">NO. {r.redeemNo}</Text>
|
||||
<View className="benefit-coupon-footer">
|
||||
<Text className="benefit-coupon-meta">
|
||||
{r.createdAt ? String(r.createdAt).slice(0, 19).replace('T', ' ') : '-'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{shouldRenderPageTabBar() ? <UserTabBar selected={2} /> : null}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '联系客服',
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { CUSTOMER_SERVICE_PHONE } from '@dukang/shared-types';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import ContactCsButton from '../../components/ContactCsButton';
|
||||
import { toast } from '../../lib/api';
|
||||
|
||||
const isWeapp = process.env.TARO_ENV === 'weapp';
|
||||
const DIAL_PHONE = CUSTOMER_SERVICE_PHONE.replace(/-/g, '');
|
||||
|
||||
function dialPhone() {
|
||||
Taro.makePhoneCall({ phoneNumber: DIAL_PHONE }).catch(() => toast('无法拨打电话'));
|
||||
}
|
||||
|
||||
export default function CustomerServicePage() {
|
||||
return (
|
||||
<PageShell variant="sub" className="cs-page">
|
||||
<SubPageHeader title="联系客服" />
|
||||
<View className="sub-page-body inset-page cs-body">
|
||||
<Text className="cs-brand">杜康好客客服</Text>
|
||||
<Text className="cs-hint">
|
||||
{isWeapp
|
||||
? '点击下方按钮,进入小程序在线客服会话'
|
||||
: '请在微信小程序内打开以使用在线客服,或拨打客服电话'}
|
||||
</Text>
|
||||
<Text className="cs-hours">工作时间:9:00 - 21:00</Text>
|
||||
|
||||
{isWeapp ? (
|
||||
<ContactCsButton className="cs-online-btn" session={{ from: 'customer-service' }} />
|
||||
) : null}
|
||||
|
||||
<View className={isWeapp ? 'cs-phone-link' : 'cs-call-btn'} onClick={dialPhone}>
|
||||
<Text>
|
||||
{isWeapp ? `或拨打客服电话 ${CUSTOMER_SERVICE_PHONE}` : '拨打客服电话'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{!isWeapp ? (
|
||||
<Text className="cs-phone-display">{CUSTOMER_SERVICE_PHONE}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '杜康好客',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark',
|
||||
enableShareAppMessage: true,
|
||||
enableShareTimeline: true,
|
||||
});
|
||||
@@ -1,401 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { View, Text, Image, Swiper, SwiperItem } from '@tarojs/components';
|
||||
import Taro, {
|
||||
useDidShow,
|
||||
usePageScroll,
|
||||
usePullDownRefresh,
|
||||
useShareAppMessage,
|
||||
useShareTimeline,
|
||||
} from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import TabMainHeader from '../../components/TabMainHeader';
|
||||
import CouponBadge from '../../components/CouponBadge';
|
||||
import WechatShareReady from '../../components/WechatShareReady';
|
||||
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
||||
import { goLogin } from '../../lib/auth-nav';
|
||||
import { getToken, isLoggedIn, request, toast } from '../../lib/api';
|
||||
import {
|
||||
getHomeCatalogCache,
|
||||
isHomeCatalogBootstrapped,
|
||||
setHomeCatalogCache,
|
||||
} from '../../lib/home-catalog-session';
|
||||
import { ensurePayReady } from '../../lib/pay-ready';
|
||||
import { capturePromoSceneAndTouchScan } from '../../lib/promo';
|
||||
import { getProductMainImage } from '../../lib/product-images';
|
||||
import {
|
||||
canBuyOnline,
|
||||
canPickupOnSite,
|
||||
normalizeFulfillmentFlags,
|
||||
} from '../../lib/product-fulfillment';
|
||||
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location';
|
||||
import {
|
||||
DEFAULT_SHARE_DESC,
|
||||
DEFAULT_SHARE_TITLE,
|
||||
toWeappShareMessage,
|
||||
} from '../../lib/wechat-share';
|
||||
import { trackPageView } from '../../lib/analytics';
|
||||
type Product = {
|
||||
id: string;
|
||||
name: string;
|
||||
subtitle?: string;
|
||||
spec?: string;
|
||||
price: number;
|
||||
benefitDisplay?: number;
|
||||
mainImageUrl?: string | null;
|
||||
carouselUrls?: string[] | null;
|
||||
aromaType: string;
|
||||
allowOnSitePickup?: boolean;
|
||||
allowOnlinePurchase?: boolean;
|
||||
allowCrossCityDelivery?: boolean;
|
||||
};
|
||||
|
||||
type MiniHomeConfig = {
|
||||
banners: string[];
|
||||
footerUrl: string | null;
|
||||
};
|
||||
|
||||
const AROMA_TABS = [
|
||||
{ key: 'QINGXIANG', label: '清香型' },
|
||||
{ key: 'JIANGXIANG', label: '酱香型' },
|
||||
{ key: 'NONGXIANG', label: '浓香型' },
|
||||
] as const;
|
||||
|
||||
type AromaKey = (typeof AROMA_TABS)[number]['key'];
|
||||
|
||||
/** sticky 香型导航高度(与 CSS 大致一致),锚点滚动时预留 */
|
||||
const AROMA_NAV_OFFSET_PX = 44;
|
||||
|
||||
function aromaSectionId(key: AromaKey) {
|
||||
return `aroma-section-${key}`;
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const [activeAroma, setActiveAroma] = useState<AromaKey>('QINGXIANG');
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [displayCity, setDisplayCity] = useState('郑州市');
|
||||
const [cityCode, setCityCode] = useState('410100');
|
||||
const [miniHome, setMiniHome] = useState<MiniHomeConfig>({ banners: [], footerUrl: null });
|
||||
const scrollingToRef = useRef<AromaKey | null>(null);
|
||||
const scrollLockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastScrollSyncAtRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
trackPageView('home_view', { pagePath: '/pages/home/index', cityCode });
|
||||
}, [cityCode]);
|
||||
|
||||
const loadMiniHome = useCallback(() => {
|
||||
return request<{ miniHome?: MiniHomeConfig }>('/common/client-config')
|
||||
.then((cfg) => {
|
||||
const banners = Array.isArray(cfg.miniHome?.banners)
|
||||
? cfg.miniHome!.banners.filter((u) => typeof u === 'string' && !!u.trim())
|
||||
: [];
|
||||
const footerUrl =
|
||||
typeof cfg.miniHome?.footerUrl === 'string' && cfg.miniHome.footerUrl.trim()
|
||||
? cfg.miniHome.footerUrl.trim()
|
||||
: null;
|
||||
setMiniHome({ banners, footerUrl });
|
||||
})
|
||||
.catch(() => {
|
||||
/* 首页装饰图失败不阻断商品列表 */
|
||||
});
|
||||
}, []);
|
||||
|
||||
const applyProductList = useCallback((list: Product[], nextCode: string, authKey: string) => {
|
||||
const normalized = Array.isArray(list) ? list.map((p) => normalizeFulfillmentFlags(p)) : [];
|
||||
setProducts(normalized);
|
||||
setHomeCatalogCache({ cityCode: nextCode, authKey, products: normalized });
|
||||
}, []);
|
||||
|
||||
const fetchProducts = useCallback(
|
||||
(nextCode: string, authKey: string) => {
|
||||
setLoading(true);
|
||||
return request<Product[]>(`/catalog/products?cityCode=${encodeURIComponent(nextCode)}`)
|
||||
.then((list) => applyProductList(list, nextCode, authKey))
|
||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
},
|
||||
[applyProductList],
|
||||
);
|
||||
|
||||
/**
|
||||
* 首次进入 / 城市或登录态变化:拉商品。
|
||||
* 同次再切 tab:只同步选中态,不重复请求(对齐门店页)。
|
||||
*/
|
||||
useDidShow(() => {
|
||||
syncTabBarSelected(0);
|
||||
void capturePromoSceneAndTouchScan();
|
||||
void loadMiniHome();
|
||||
|
||||
const authKey = getToken() || '';
|
||||
void (async () => {
|
||||
const resolved = await resolveUserCity();
|
||||
const nextCode = getCityCodeForCatalog(resolved);
|
||||
setDisplayCity(resolved.displayCity);
|
||||
setCityCode(nextCode);
|
||||
|
||||
const cache = getHomeCatalogCache();
|
||||
if (
|
||||
isHomeCatalogBootstrapped() &&
|
||||
cache &&
|
||||
cache.cityCode === nextCode &&
|
||||
cache.authKey === authKey &&
|
||||
Array.isArray(cache.products)
|
||||
) {
|
||||
setProducts(cache.products as Product[]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await fetchProducts(nextCode, authKey);
|
||||
})();
|
||||
});
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const authKey = getToken() || '';
|
||||
const resolved = await resolveUserCity();
|
||||
setDisplayCity(resolved.displayCity);
|
||||
const nextCode = getCityCodeForCatalog(resolved);
|
||||
setCityCode(nextCode);
|
||||
setLoading(true);
|
||||
const [list] = await Promise.all([
|
||||
request<Product[]>(`/catalog/products?cityCode=${encodeURIComponent(nextCode)}`),
|
||||
loadMiniHome(),
|
||||
]);
|
||||
applyProductList(
|
||||
Array.isArray(list) ? list : [],
|
||||
nextCode,
|
||||
authKey,
|
||||
);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
Taro.stopPullDownRefresh();
|
||||
}
|
||||
})();
|
||||
});
|
||||
function openProductDetail(id: string) {
|
||||
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` });
|
||||
}
|
||||
|
||||
async function goOnSitePickup(productId: string) {
|
||||
const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=1`;
|
||||
if (!isLoggedIn()) {
|
||||
goLogin(returnPath);
|
||||
return;
|
||||
}
|
||||
const ready = await ensurePayReady(returnPath);
|
||||
if (!ready) return;
|
||||
Taro.navigateTo({ url: returnPath });
|
||||
}
|
||||
|
||||
const productsByAroma = useMemo(() => {
|
||||
const map: Record<AromaKey, Product[]> = {
|
||||
QINGXIANG: [],
|
||||
JIANGXIANG: [],
|
||||
NONGXIANG: [],
|
||||
};
|
||||
for (const p of products) {
|
||||
const key = p.aromaType as AromaKey;
|
||||
if (key in map) map[key].push(p);
|
||||
}
|
||||
return map;
|
||||
}, [products]);
|
||||
|
||||
const visibleAromaTabs = useMemo(
|
||||
() => AROMA_TABS.filter((t) => productsByAroma[t.key].length > 0),
|
||||
[productsByAroma],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || visibleAromaTabs.length === 0) return;
|
||||
if (!visibleAromaTabs.some((t) => t.key === activeAroma)) {
|
||||
setActiveAroma(visibleAromaTabs[0].key);
|
||||
}
|
||||
}, [loading, visibleAromaTabs, activeAroma]);
|
||||
|
||||
const banners = miniHome.banners;
|
||||
const footerUrl = miniHome.footerUrl;
|
||||
|
||||
const sharePayload = useMemo(
|
||||
() => ({
|
||||
title: DEFAULT_SHARE_TITLE,
|
||||
desc: DEFAULT_SHARE_DESC,
|
||||
path: '/pages/home/index',
|
||||
imgUrl: banners[0] || undefined,
|
||||
}),
|
||||
[banners],
|
||||
);
|
||||
|
||||
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
||||
useShareTimeline(() => ({
|
||||
title: sharePayload.title || DEFAULT_SHARE_TITLE,
|
||||
query: '',
|
||||
imageUrl: sharePayload.imgUrl,
|
||||
}));
|
||||
|
||||
function scrollToAroma(key: AromaKey) {
|
||||
setActiveAroma(key);
|
||||
scrollingToRef.current = key;
|
||||
if (scrollLockTimerRef.current) clearTimeout(scrollLockTimerRef.current);
|
||||
scrollLockTimerRef.current = setTimeout(() => {
|
||||
scrollingToRef.current = null;
|
||||
}, 450);
|
||||
|
||||
const query = Taro.createSelectorQuery();
|
||||
query.select(`#${aromaSectionId(key)}`).boundingClientRect();
|
||||
query.selectViewport().scrollOffset();
|
||||
query.exec((res) => {
|
||||
const rect = res?.[0] as { top?: number } | undefined;
|
||||
const viewport = res?.[1] as { scrollTop?: number } | undefined;
|
||||
if (rect?.top == null || viewport?.scrollTop == null) return;
|
||||
const scrollTop = Math.max(0, viewport.scrollTop + rect.top - AROMA_NAV_OFFSET_PX);
|
||||
void Taro.pageScrollTo({ scrollTop, duration: 280 });
|
||||
});
|
||||
}
|
||||
|
||||
usePageScroll(() => {
|
||||
if (scrollingToRef.current) return;
|
||||
const now = Date.now();
|
||||
if (now - lastScrollSyncAtRef.current < 80) return;
|
||||
lastScrollSyncAtRef.current = now;
|
||||
const query = Taro.createSelectorQuery();
|
||||
visibleAromaTabs.forEach((t) => {
|
||||
query.select(`#${aromaSectionId(t.key)}`).boundingClientRect();
|
||||
});
|
||||
query.exec((rects) => {
|
||||
if (!Array.isArray(rects) || rects.length === 0) return;
|
||||
let next: AromaKey = visibleAromaTabs[0]?.key ?? AROMA_TABS[0].key;
|
||||
for (let i = 0; i < visibleAromaTabs.length; i++) {
|
||||
const rect = rects[i] as { top?: number } | null;
|
||||
if (!rect || rect.top == null) continue;
|
||||
// 区块顶进入导航下方一带时视为当前香型
|
||||
if (rect.top <= AROMA_NAV_OFFSET_PX + 24) {
|
||||
next = visibleAromaTabs[i].key;
|
||||
}
|
||||
}
|
||||
setActiveAroma((prev) => (prev === next ? prev : next));
|
||||
});
|
||||
});
|
||||
|
||||
function renderProductCard(p: Product) {
|
||||
const thumb = getProductMainImage(p);
|
||||
const spec = p.subtitle || p.spec || '';
|
||||
return (
|
||||
<View key={p.id} className="home-product-card">
|
||||
<View className="home-product-card-inner" onClick={() => openProductDetail(p.id)}>
|
||||
<View className="home-product-thumb-wrap">
|
||||
{thumb ? (
|
||||
<Image className="home-product-thumb" src={thumb} mode="aspectFill" />
|
||||
) : (
|
||||
<View className="home-product-thumb home-product-thumb--empty" />
|
||||
)}
|
||||
</View>
|
||||
<View className="home-product-main">
|
||||
<View className="home-product-row">
|
||||
<Text className="home-product-name">{p.name}</Text>
|
||||
<Text className="home-product-price">¥{Number(p.price).toFixed(0)}</Text>
|
||||
</View>
|
||||
{spec ? <Text className="home-product-sub">{spec}</Text> : null}
|
||||
<View className="home-product-footer">
|
||||
<CouponBadge amount={p.benefitDisplay ?? p.price} label="好客权益" />
|
||||
</View>
|
||||
<View className="home-product-actions">
|
||||
{canPickupOnSite(p) ? (
|
||||
<Text
|
||||
className="home-pickup-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation?.();
|
||||
void goOnSitePickup(p.id);
|
||||
}}
|
||||
>
|
||||
现场取货
|
||||
</Text>
|
||||
) : null}
|
||||
{canBuyOnline(p) ? (
|
||||
<Text
|
||||
className="home-buy-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation?.();
|
||||
openProductDetail(p.id);
|
||||
}}
|
||||
>
|
||||
立即购买
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell variant="tab" className="home-page no-tab-header">
|
||||
<WechatShareReady payload={sharePayload} />
|
||||
<TabMainHeader title="杜康好客" />
|
||||
|
||||
{banners.length > 0 ? (
|
||||
<View className="home-promo-banner">
|
||||
<Swiper
|
||||
className="home-promo-banner-swiper"
|
||||
indicatorDots={banners.length > 1}
|
||||
autoplay={banners.length > 1}
|
||||
circular={banners.length > 1}
|
||||
interval={2500}
|
||||
>
|
||||
{banners.map((url) => (
|
||||
<SwiperItem key={url}>
|
||||
<Image className="home-promo-banner-img" src={url} mode="aspectFill" />
|
||||
</SwiperItem>
|
||||
))}
|
||||
</Swiper>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View className="home-aroma-nav">
|
||||
<View className="home-aroma-tabs">
|
||||
{visibleAromaTabs.map((t) => (
|
||||
<Text
|
||||
key={t.key}
|
||||
className={`home-aroma-tab${activeAroma === t.key ? ' home-aroma-tab--active' : ''}`}
|
||||
onClick={() => scrollToAroma(t.key)}
|
||||
>
|
||||
{t.label}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
<Text className="home-aroma-city">{displayCity}</Text>
|
||||
</View>
|
||||
|
||||
<View className="home-product-list">
|
||||
{loading ? <View className="home-empty">加载中…</View> : null}
|
||||
{!loading && products.length === 0 ? (
|
||||
<View className="home-empty">当前城市暂无在售商品</View>
|
||||
) : null}
|
||||
{!loading &&
|
||||
products.length > 0 &&
|
||||
visibleAromaTabs.map((t) => {
|
||||
const list = productsByAroma[t.key];
|
||||
return (
|
||||
<View key={t.key} id={aromaSectionId(t.key)} className="home-aroma-section">
|
||||
<Text className="home-aroma-section-title">{t.label}</Text>
|
||||
{list.map((p) => renderProductCard(p))}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{footerUrl ? (
|
||||
<View className="home-promo-footer">
|
||||
<Image className="home-promo-footer-img" src={footerUrl} mode="aspectFill" />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{shouldRenderPageTabBar() ? <UserTabBar selected={0} /> : null}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '登录',
|
||||
});
|
||||
@@ -1,555 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text, Input, Image } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import {
|
||||
SmsScene,
|
||||
isWxAuthorizeEnabled,
|
||||
type ClientRuntimeConfig,
|
||||
type WechatLoginResult,
|
||||
} from '@dukang/shared-types';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import WechatLoginButton from '../../components/WechatLoginButton';
|
||||
import PhoneQuickLoginButton from '../../components/PhoneQuickLoginButton';
|
||||
import { BRAND_LOGO_WIDE_URL } from '@dukang/shared-types';
|
||||
import { finishLoginNavigate, forceReloadAfterAccountMerge } from '../../lib/auth-nav';
|
||||
import {
|
||||
bindWechatForUser,
|
||||
loginWithWechat,
|
||||
} from '../../lib/wechat-auth';
|
||||
import { fetchUserProfile } from '../../lib/pay-wechat';
|
||||
import { saveUserPhone, resolveDefaultUserPhone } from '../../lib/user-phone';
|
||||
import {
|
||||
getCachedWxProfile,
|
||||
syncMiniWechatProfile,
|
||||
type MiniWechatProfile,
|
||||
} from '../../lib/mini-wechat-profile';
|
||||
import { isLoggedIn, request, saveAuth, toast, type SessionPayload } from '../../lib/api';
|
||||
import { touchStoredPromoAfterLogin } from '../../lib/promo';
|
||||
|
||||
const IS_WEAPP = process.env.TARO_ENV === 'weapp';
|
||||
|
||||
function normalizePhone(value: string) {
|
||||
return value.replace(/\D/g, '').slice(0, 11);
|
||||
}
|
||||
|
||||
function isValidPhone(phone: string) {
|
||||
return /^1[3-9]\d{9}$/.test(phone);
|
||||
}
|
||||
|
||||
function AgreementRow({
|
||||
agreed,
|
||||
onToggle,
|
||||
}: {
|
||||
agreed: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<View className="login-agreement" onClick={onToggle}>
|
||||
<View className={`login-agreement-check${agreed ? ' login-agreement-check--on' : ''}`}>
|
||||
{agreed ? <Text>✓</Text> : null}
|
||||
</View>
|
||||
<Text className="login-agreement-text">
|
||||
请阅读并勾选同意
|
||||
<Text
|
||||
className="login-agreement-link"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
Taro.navigateTo({ url: '/pages/user-agreement/index' });
|
||||
}}
|
||||
>
|
||||
《用户服务协议》
|
||||
</Text>
|
||||
和
|
||||
<Text
|
||||
className="login-agreement-link"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
Taro.navigateTo({ url: '/pages/privacy-policy/index' });
|
||||
}}
|
||||
>
|
||||
《隐私政策》
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const returnTo = router.params.return || '';
|
||||
const needPhone = router.params.needPhone === '1';
|
||||
const needWechat = router.params.needWechat === '1';
|
||||
const initialBindMode = router.params.bindMode === '1';
|
||||
const initialWxSessionKey = router.params.wxSessionKey || null;
|
||||
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [wxLoading, setWxLoading] = useState(false);
|
||||
const [phoneQuickLoading, setPhoneQuickLoading] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [cooldown, setCooldown] = useState(0);
|
||||
/** 须用户主动勾选,禁止默认同意 */
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [sentHint, setSentHint] = useState('');
|
||||
const [bindMode, setBindMode] = useState(initialBindMode);
|
||||
const [wxSessionKey, setWxSessionKey] = useState<string | null>(initialWxSessionKey);
|
||||
const [wxAuthorize, setWxAuthorize] = useState(true);
|
||||
const [completeMode, setCompleteMode] = useState<'phone' | 'wechat' | null>(null);
|
||||
const [showSmsForm, setShowSmsForm] = useState(!IS_WEAPP);
|
||||
|
||||
useEffect(() => {
|
||||
request<ClientRuntimeConfig>('/common/client-config')
|
||||
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
|
||||
.catch(() => setWxAuthorize(true));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) {
|
||||
setCompleteMode(null);
|
||||
return;
|
||||
}
|
||||
if (!needPhone && !needWechat) {
|
||||
setCompleteMode(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
fetchUserProfile()
|
||||
.then((me) => {
|
||||
if (cancelled) return;
|
||||
if (needPhone && !me.phoneVerified) {
|
||||
setCompleteMode('phone');
|
||||
setShowSmsForm(true);
|
||||
return;
|
||||
}
|
||||
if (needWechat && !me.hasWechat) {
|
||||
setCompleteMode('wechat');
|
||||
return;
|
||||
}
|
||||
finishLoginNavigate(returnTo);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setCompleteMode(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [needPhone, needWechat, returnTo]);
|
||||
|
||||
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 cancelLogin() {
|
||||
const pages = Taro.getCurrentPages();
|
||||
if (pages.length > 1) {
|
||||
Taro.navigateBack().catch(() => {
|
||||
Taro.switchTab({ url: '/pages/home/index' });
|
||||
});
|
||||
return;
|
||||
}
|
||||
Taro.switchTab({ url: '/pages/home/index' }).catch(() => {
|
||||
Taro.reLaunch({ url: '/pages/home/index' });
|
||||
});
|
||||
}
|
||||
|
||||
function applySessionAndLeave(
|
||||
data: SessionPayload | WechatLoginResult,
|
||||
phoneValue?: string,
|
||||
wxInfo?: MiniWechatProfile | null,
|
||||
successToast = '登录成功',
|
||||
) {
|
||||
if (!data.accessToken) return;
|
||||
if (phoneValue) saveUserPhone(phoneValue);
|
||||
saveAuth({
|
||||
accessToken: data.accessToken,
|
||||
refreshToken: data.refreshToken,
|
||||
});
|
||||
void syncMiniWechatProfile(wxInfo ?? getCachedWxProfile());
|
||||
void touchStoredPromoAfterLogin();
|
||||
if (!phoneValue) {
|
||||
void fetchUserProfile()
|
||||
.then((me) => resolveDefaultUserPhone(me))
|
||||
.catch(() => {});
|
||||
}
|
||||
toast(successToast, 'success');
|
||||
if (data.accountMerged) {
|
||||
forceReloadAfterAccountMerge(returnTo);
|
||||
return;
|
||||
}
|
||||
finishLoginNavigate(returnTo);
|
||||
}
|
||||
|
||||
function handleWechatLoginResult(result: WechatLoginResult, wxInfo?: MiniWechatProfile | null) {
|
||||
if (result.accessToken) {
|
||||
applySessionAndLeave(result, undefined, wxInfo);
|
||||
return;
|
||||
}
|
||||
if (result.needBindPhone && result.wxSessionKey) {
|
||||
setBindMode(true);
|
||||
setWxSessionKey(result.wxSessionKey);
|
||||
setShowSmsForm(true);
|
||||
setMsg('授权成功,可绑定手机号(也可稍后在下单时再绑定)');
|
||||
setSentHint('');
|
||||
return;
|
||||
}
|
||||
setMsg('登录未完成,请重试或使用手机号登录');
|
||||
}
|
||||
|
||||
async function onPhoneQuickLogin(phoneCode: string) {
|
||||
if (!ensureAgreed()) return;
|
||||
setPhoneQuickLoading(true);
|
||||
setMsg('');
|
||||
setSentHint('');
|
||||
try {
|
||||
let loginCode: string | undefined;
|
||||
try {
|
||||
const loginRes = await Taro.login();
|
||||
loginCode = loginRes.code || undefined;
|
||||
} catch {
|
||||
/* openId 绑定失败不阻断手机号登录 */
|
||||
}
|
||||
const data = await request<WechatLoginResult>('/auth/login/wechat-phone', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
phoneCode,
|
||||
...(loginCode ? { loginCode } : {}),
|
||||
platform: 'mini',
|
||||
},
|
||||
});
|
||||
if (!data?.accessToken) {
|
||||
setMsg('登录成功但未返回令牌,请重试');
|
||||
return;
|
||||
}
|
||||
const profilePhone =
|
||||
typeof data.user === 'object' && data.user && 'phone' in data.user
|
||||
? String((data.user as { phone?: string }).phone || '')
|
||||
: '';
|
||||
applySessionAndLeave(data, profilePhone || undefined);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '手机号快捷登录失败');
|
||||
} finally {
|
||||
setPhoneQuickLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onSendCode() {
|
||||
if (!ensureAgreed()) return;
|
||||
if (cooldown > 0 || sending) return;
|
||||
const normalized = phone.trim();
|
||||
if (!isValidPhone(normalized)) {
|
||||
setMsg('请输入正确的手机号');
|
||||
return;
|
||||
}
|
||||
setMsg('');
|
||||
setSentHint('');
|
||||
setSending(true);
|
||||
try {
|
||||
const scene =
|
||||
bindMode || completeMode === 'phone' ? SmsScene.BIND_PHONE : SmsScene.USER_LOGIN;
|
||||
await request('/auth/sms/send', {
|
||||
method: 'POST',
|
||||
data: { phone: normalized, scene },
|
||||
});
|
||||
setCooldown(60);
|
||||
setSentHint('验证码已发送');
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '发送失败');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function login() {
|
||||
if (!ensureAgreed()) return;
|
||||
const normalized = phone.trim();
|
||||
if (!isValidPhone(normalized)) {
|
||||
setMsg('请输入正确的手机号');
|
||||
return;
|
||||
}
|
||||
if (!code.trim()) {
|
||||
setMsg('请输入验证码');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
setSentHint('');
|
||||
try {
|
||||
if (bindMode && wxSessionKey) {
|
||||
const data = await request<WechatLoginResult>('/auth/wechat/bind-phone', {
|
||||
method: 'POST',
|
||||
data: { wxSessionKey, phone: normalized, code: code.trim() },
|
||||
});
|
||||
handleWechatLoginResult(data);
|
||||
saveUserPhone(normalized);
|
||||
return;
|
||||
}
|
||||
if (completeMode === 'phone' && isLoggedIn()) {
|
||||
const data = await request<SessionPayload>('/auth/phone/bind', {
|
||||
method: 'POST',
|
||||
data: { phone: normalized, code: code.trim() },
|
||||
});
|
||||
if (!data?.accessToken) {
|
||||
setMsg('手机号验证成功但会话未返回,请重新登录');
|
||||
return;
|
||||
}
|
||||
applySessionAndLeave(data, normalized, null, '手机号验证成功');
|
||||
return;
|
||||
}
|
||||
const data = await request<SessionPayload>('/auth/login/sms', {
|
||||
method: 'POST',
|
||||
data: { phone: normalized, code: code.trim() },
|
||||
});
|
||||
if (!data?.accessToken) {
|
||||
setMsg('登录成功但未返回令牌,请重试');
|
||||
return;
|
||||
}
|
||||
applySessionAndLeave(data, normalized);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '登录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function wechatLogin() {
|
||||
if (!ensureAgreed()) return;
|
||||
setMsg('');
|
||||
setSentHint('');
|
||||
setWxLoading(true);
|
||||
try {
|
||||
const wxInfo = getCachedWxProfile();
|
||||
|
||||
if (completeMode === 'wechat' && isLoggedIn()) {
|
||||
const result = await bindWechatForUser(wxInfo);
|
||||
if (!result.ok && 'redirecting' in result && result.redirecting) {
|
||||
return;
|
||||
}
|
||||
if (!result.ok && 'needBindPhone' in result && result.needBindPhone) {
|
||||
setBindMode(true);
|
||||
setWxSessionKey(result.wxSessionKey);
|
||||
setCompleteMode('phone');
|
||||
setShowSmsForm(true);
|
||||
setMsg('请绑定手机号完成认证');
|
||||
return;
|
||||
}
|
||||
if (result.ok) {
|
||||
if (wxInfo) await syncMiniWechatProfile(wxInfo);
|
||||
toast('授权成功', 'success');
|
||||
finishLoginNavigate(returnTo);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const result = await loginWithWechat();
|
||||
if (result) handleWechatLoginResult(result, wxInfo);
|
||||
} catch (e) {
|
||||
const raw = e instanceof Error ? e.message : '授权登录失败';
|
||||
const hint = /invalid code/i.test(raw)
|
||||
? process.env.TARO_ENV === 'weapp'
|
||||
? '授权失败:请确认后端小程序 AppID 配置正确'
|
||||
: '授权失败:请确认公众号网页授权域名配置正确'
|
||||
: raw;
|
||||
setMsg(hint);
|
||||
} finally {
|
||||
setWxLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const displayMsg = msg || sentHint;
|
||||
const codeDisabled = cooldown > 0 || sending;
|
||||
const showAuthLogin =
|
||||
(completeMode === 'wechat' || (!IS_WEAPP && !bindMode && !completeMode)) &&
|
||||
(IS_WEAPP || wxAuthorize);
|
||||
const showPhoneQuick =
|
||||
IS_WEAPP && completeMode !== 'wechat' && !bindMode && completeMode !== 'phone';
|
||||
const cardTitle =
|
||||
completeMode === 'phone'
|
||||
? '验证手机号'
|
||||
: bindMode
|
||||
? '绑定手机号'
|
||||
: completeMode === 'wechat'
|
||||
? '授权登录'
|
||||
: '手机号快捷登录';
|
||||
|
||||
return (
|
||||
<PageShell variant="plain" className="login-page">
|
||||
<View className="login-nav">
|
||||
<View className="login-nav-back" onClick={cancelLogin}>
|
||||
<Text className="login-nav-back-icon">‹</Text>
|
||||
<Text>返回</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="login-header">
|
||||
<View className="login-logo-wrap">
|
||||
<View className="login-logo">
|
||||
<Image className="login-logo-img" src={BRAND_LOGO_WIDE_URL} mode="aspectFit" />
|
||||
</View>
|
||||
<Text className="login-logo-badge">官方</Text>
|
||||
</View>
|
||||
<View className="login-welcome">
|
||||
<Text className="login-welcome-title">
|
||||
{completeMode === 'phone'
|
||||
? '建议绑定手机号'
|
||||
: completeMode === 'wechat'
|
||||
? '完成授权登录'
|
||||
: '欢迎来到杜康好客'}
|
||||
</Text>
|
||||
<Text className="login-welcome-sub">
|
||||
{completeMode === 'phone'
|
||||
? '便于订单通知与售后,也可稍后绑定'
|
||||
: completeMode === 'wechat'
|
||||
? '完成后将返回继续支付'
|
||||
: '买美酒,享好礼'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="login-main">
|
||||
{completeMode === 'wechat' ? (
|
||||
<View className="login-card">
|
||||
<Text className="login-card-title">授权登录</Text>
|
||||
<Text className="login-msg login-msg--hint" style={{ marginBottom: 16 }}>
|
||||
使用支付功能前需完成授权登录
|
||||
</Text>
|
||||
<AgreementRow agreed={agreed} onToggle={() => setAgreed((v) => !v)} />
|
||||
{displayMsg ? (
|
||||
<Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}>
|
||||
{displayMsg}
|
||||
</Text>
|
||||
) : null}
|
||||
{showAuthLogin ? (
|
||||
<WechatLoginButton loading={wxLoading} label="授权登录" onClick={() => void wechatLogin()} />
|
||||
) : null}
|
||||
</View>
|
||||
) : (
|
||||
<View className="login-card">
|
||||
<Text className="login-card-title">{cardTitle}</Text>
|
||||
|
||||
<AgreementRow agreed={agreed} onToggle={() => setAgreed((v) => !v)} />
|
||||
|
||||
{showPhoneQuick ? (
|
||||
<PhoneQuickLoginButton
|
||||
loading={phoneQuickLoading}
|
||||
agreed={agreed}
|
||||
onRequireAgree={() => ensureAgreed()}
|
||||
onGetPhoneNumber={(phoneCode) => void onPhoneQuickLogin(phoneCode)}
|
||||
onFail={(message) => setMsg(message)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showPhoneQuick ? (
|
||||
<View className="login-divider" style={{ marginTop: 20 }}>
|
||||
<View className="login-divider-line" />
|
||||
<Text
|
||||
className="login-divider-text"
|
||||
onClick={() => setShowSmsForm((v) => !v)}
|
||||
>
|
||||
{showSmsForm ? '收起验证码登录' : '使用验证码登录'}
|
||||
</Text>
|
||||
<View className="login-divider-line" />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{(showSmsForm || !showPhoneQuick) && (
|
||||
<>
|
||||
<View className="login-field" style={showPhoneQuick ? { marginTop: 8 } : undefined}>
|
||||
<Text className="login-field-prefix">+86</Text>
|
||||
<Input
|
||||
className="login-field-input"
|
||||
type="number"
|
||||
maxlength={11}
|
||||
placeholder="请输入手机号"
|
||||
value={phone}
|
||||
onInput={(e) => {
|
||||
setPhone(normalizePhone(e.detail.value));
|
||||
setMsg('');
|
||||
setSentHint('');
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="login-field">
|
||||
<Input
|
||||
className="login-field-input"
|
||||
type="number"
|
||||
maxlength={6}
|
||||
placeholder="请输入验证码"
|
||||
value={code}
|
||||
onInput={(e) => setCode(e.detail.value.replace(/\D/g, '').slice(0, 6))}
|
||||
/>
|
||||
<Text
|
||||
className={`login-get-code${codeDisabled ? ' login-get-code--disabled' : ''}`}
|
||||
onClick={() => void onSendCode()}
|
||||
>
|
||||
{sending ? '发送中...' : cooldown > 0 ? `${cooldown}s 后重新获取` : '获取验证码'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View
|
||||
className={`login-sms-btn${loading ? ' login-sms-btn--disabled' : ''}`}
|
||||
onClick={loading ? undefined : () => void login()}
|
||||
>
|
||||
<Text className="login-sms-btn__text">
|
||||
{loading
|
||||
? '处理中...'
|
||||
: completeMode === 'phone'
|
||||
? '完成验证'
|
||||
: bindMode
|
||||
? '绑定并登录'
|
||||
: '验证码登录'}
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
{displayMsg ? (
|
||||
<Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}>
|
||||
{displayMsg}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{completeMode === 'phone' ? (
|
||||
<View
|
||||
className="login-skip-bind"
|
||||
onClick={() => finishLoginNavigate(returnTo)}
|
||||
style={{ marginTop: 12, textAlign: 'center' }}
|
||||
>
|
||||
<Text className="u-muted" style={{ fontSize: 14 }}>
|
||||
暂不绑定,继续下单
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{showAuthLogin && completeMode !== 'wechat' ? (
|
||||
<>
|
||||
<View className="login-divider">
|
||||
<View className="login-divider-line" />
|
||||
<Text className="login-divider-text">或者</Text>
|
||||
<View className="login-divider-line" />
|
||||
</View>
|
||||
<WechatLoginButton loading={wxLoading} label="授权登录" onClick={() => void wechatLogin()} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<View className="login-cancel-btn" onClick={cancelLogin}>
|
||||
<Text>暂不登录,继续浏览</Text>
|
||||
</View>
|
||||
<Text className="login-cancel-hint">无需登录也可浏览商品和门店</Text>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '我的',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark',
|
||||
enableShareAppMessage: true,
|
||||
enableShareTimeline: true,
|
||||
});
|
||||
@@ -1,591 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { View, Text, Image, Button, Input, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
|
||||
import {
|
||||
BRAND_LOGO_MARK_URL,
|
||||
QUALIFICATION_DISCLOSURE_URL,
|
||||
isWxAuthorizeEnabled,
|
||||
type ClientRuntimeConfig,
|
||||
} from '@dukang/shared-types';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import TabMainHeader from '../../components/TabMainHeader';
|
||||
import WechatShareReady from '../../components/WechatShareReady';
|
||||
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
||||
import { goLogin } from '../../lib/auth-nav';
|
||||
import { bindWechatForUser } from '../../lib/wechat-auth';
|
||||
import {
|
||||
fetchMiniWechatUserInfo,
|
||||
isDefaultMiniNickname,
|
||||
mergeWxDisplayProfile,
|
||||
needsWxProfileFill,
|
||||
uploadAvatarTempFile,
|
||||
uploadMiniWechatProfile,
|
||||
} from '../../lib/mini-wechat-profile';
|
||||
import { isLoggedIn, logout, request, toast, type UserProfile } from '../../lib/api';
|
||||
import { isWechatEnv } from '../../lib/weixin';
|
||||
import { maskPhone } from '../../lib/phone';
|
||||
import {
|
||||
DEFAULT_SHARE_DESC,
|
||||
DEFAULT_SHARE_TITLE,
|
||||
toWeappShareMessage,
|
||||
} from '../../lib/wechat-share';
|
||||
import iconPendingPay from '../../assets/icons/待付款.png';
|
||||
import iconPaid from '../../assets/icons/已付款.png';
|
||||
import iconCompleted from '../../assets/icons/已完成.png';
|
||||
import iconAddress from '../../assets/icons/地址管理.png';
|
||||
import iconStores from '../../assets/icons/可用门店.png';
|
||||
import iconCs from '../../assets/icons/联系客服.png';
|
||||
import iconQualification from '../../assets/icons/资质公示.png';
|
||||
import iconAbout from '../../assets/icons/关于我们.png';
|
||||
import { formatMoney } from '../../lib/money';
|
||||
|
||||
const ORDER_SHORTCUTS = [
|
||||
{ tab: 'pending_pay', icon: iconPendingPay, label: '待付款' },
|
||||
{ tab: 'paid', icon: iconPaid, label: '已付款' },
|
||||
{ tab: 'completed', icon: iconCompleted, label: '已完成' },
|
||||
] as const;
|
||||
|
||||
const SERVICES = [
|
||||
{ icon: iconAddress, label: '地址管理', url: '/pages/addresses/index' },
|
||||
{ icon: iconStores, label: '可用门店', tab: '/pages/stores/index' },
|
||||
{ icon: iconCs, label: '联系客服', url: '/pages/customer-service/index' },
|
||||
{ icon: iconQualification, label: '资质公示', action: 'qualification' as const },
|
||||
{ icon: iconAbout, label: '关于我们', action: 'about' as const },
|
||||
] as const;
|
||||
|
||||
const isWeapp = process.env.TARO_ENV === 'weapp';
|
||||
|
||||
export default function MinePage() {
|
||||
const [authed, setAuthed] = useState(() => isLoggedIn());
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [benefitBalance, setBenefitBalance] = useState(0);
|
||||
const [orderCounts, setOrderCounts] = useState<Record<string, number>>({});
|
||||
const [wxAuthorize, setWxAuthorize] = useState(true);
|
||||
const [bindingWx, setBindingWx] = useState(false);
|
||||
const [profileSheetOpen, setProfileSheetOpen] = useState(false);
|
||||
const [draftAvatarTemp, setDraftAvatarTemp] = useState('');
|
||||
const [draftAvatarUrl, setDraftAvatarUrl] = useState('');
|
||||
const [draftNickname, setDraftNickname] = useState('');
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
const [profileLoadError, setProfileLoadError] = useState('');
|
||||
const [qualificationOpen, setQualificationOpen] = useState(false);
|
||||
|
||||
function resetGuestState() {
|
||||
setProfile(null);
|
||||
setBenefitBalance(0);
|
||||
setOrderCounts({});
|
||||
setProfileLoadError('');
|
||||
}
|
||||
|
||||
function applyProfile(me: UserProfile) {
|
||||
setProfile(mergeWxDisplayProfile(me));
|
||||
}
|
||||
|
||||
function loadProfile() {
|
||||
if (!isLoggedIn()) return Promise.resolve();
|
||||
setProfileLoadError('');
|
||||
return Promise.all([
|
||||
request<UserProfile>('/auth/me'),
|
||||
request<Array<Record<string, unknown>>>('/benefit/coupons').catch(() => []),
|
||||
...ORDER_SHORTCUTS.map((s) =>
|
||||
request<{ total: number }>(`/trade/orders?tab=${s.tab}&pageSize=1`).catch(() => ({ total: 0 })),
|
||||
),
|
||||
])
|
||||
.then(([me, coupons, ...totals]) => {
|
||||
applyProfile(me);
|
||||
const balance = (coupons as Array<Record<string, unknown>>).reduce((sum, c) => {
|
||||
if (String(c.status) === 'ACTIVE') return sum + Number(c.balance || 0);
|
||||
return sum;
|
||||
}, 0);
|
||||
setBenefitBalance(balance);
|
||||
const counts: Record<string, number> = {};
|
||||
ORDER_SHORTCUTS.forEach((s, i) => {
|
||||
counts[s.tab] = (totals[i] as { total: number })?.total ?? 0;
|
||||
});
|
||||
setOrderCounts(counts);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!isLoggedIn()) {
|
||||
setAuthed(false);
|
||||
resetGuestState();
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : '个人资料加载失败';
|
||||
setProfileLoadError(message);
|
||||
toast('个人资料加载失败,请点击重试');
|
||||
});
|
||||
}
|
||||
|
||||
useDidShow(() => {
|
||||
syncTabBarSelected(3);
|
||||
const loggedInNow = isLoggedIn();
|
||||
setAuthed(loggedInNow);
|
||||
if (loggedInNow) {
|
||||
loadProfile();
|
||||
} else {
|
||||
resetGuestState();
|
||||
}
|
||||
});
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
const loggedInNow = isLoggedIn();
|
||||
setAuthed(loggedInNow);
|
||||
if (!loggedInNow) {
|
||||
resetGuestState();
|
||||
Taro.stopPullDownRefresh();
|
||||
return;
|
||||
}
|
||||
void loadProfile().finally(() => Taro.stopPullDownRefresh());
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
request<ClientRuntimeConfig>('/common/client-config')
|
||||
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
|
||||
.catch(() => setWxAuthorize(true));
|
||||
}, []);
|
||||
|
||||
const sharePayload = useMemo(
|
||||
() => ({
|
||||
title: '杜康好客 · 我的',
|
||||
desc: DEFAULT_SHARE_DESC,
|
||||
path: '/pages/mine/index',
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
||||
useShareTimeline(() => ({
|
||||
title: sharePayload.title || DEFAULT_SHARE_TITLE,
|
||||
query: '',
|
||||
}));
|
||||
|
||||
async function ensureWechatBound(): Promise<boolean> {
|
||||
if (profile?.hasWechat) return true;
|
||||
if (!wxAuthorize) {
|
||||
toast('当前环境未开启微信授权');
|
||||
return false;
|
||||
}
|
||||
setBindingWx(true);
|
||||
try {
|
||||
if (!isWeapp) {
|
||||
if (!isWechatEnv()) {
|
||||
toast('请在微信内打开后授权');
|
||||
return false;
|
||||
}
|
||||
const result = await bindWechatForUser();
|
||||
if (!result.ok && 'redirecting' in result && result.redirecting) return false;
|
||||
if (!result.ok && 'needBindPhone' in result && result.needBindPhone) {
|
||||
goLogin('/pages/mine/index', {
|
||||
bindMode: '1',
|
||||
wxSessionKey: result.wxSessionKey,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (result.ok && result.profile) {
|
||||
applyProfile({ ...result.profile, hasWechat: true });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await bindWechatForUser(null);
|
||||
if (!result.ok && 'needBindPhone' in result && result.needBindPhone) {
|
||||
goLogin('/pages/mine/index', { bindMode: '1', wxSessionKey: result.wxSessionKey });
|
||||
return false;
|
||||
}
|
||||
if (result.ok && result.profile) {
|
||||
applyProfile({ ...result.profile, hasWechat: true });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '授权失败');
|
||||
return false;
|
||||
} finally {
|
||||
setBindingWx(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openProfileSheet(me?: UserProfile | null) {
|
||||
const base = mergeWxDisplayProfile(
|
||||
me || profile || { id: '', nickname: null, avatarUrl: null, hasWechat: false },
|
||||
);
|
||||
setDraftAvatarTemp('');
|
||||
setDraftAvatarUrl(base.avatarUrl || '');
|
||||
setDraftNickname(isDefaultMiniNickname(base.nickname) ? '' : base.nickname || '');
|
||||
setProfileSheetOpen(true);
|
||||
}
|
||||
|
||||
/** 点击头像:绑定账号后用 chooseAvatar + nickname 获取头像昵称 */
|
||||
async function handleAvatarTap() {
|
||||
if (bindingWx || savingProfile) {
|
||||
toast(savingProfile ? '资料保存中…' : '请稍候…');
|
||||
return;
|
||||
}
|
||||
if (!isLoggedIn()) {
|
||||
goLogin('/pages/mine/index');
|
||||
return;
|
||||
}
|
||||
if (isWeapp) {
|
||||
openProfileSheet();
|
||||
return;
|
||||
}
|
||||
if (!profile?.hasWechat) {
|
||||
const ok = await ensureWechatBound();
|
||||
if (ok) loadProfile();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
async function onChooseAvatar(e: { detail?: { avatarUrl?: string } }) {
|
||||
const tempPath = e.detail?.avatarUrl?.trim();
|
||||
if (!tempPath) {
|
||||
toast('未获取到头像,请重试');
|
||||
return;
|
||||
}
|
||||
setDraftAvatarTemp(tempPath);
|
||||
setDraftAvatarUrl(tempPath);
|
||||
}
|
||||
|
||||
async function saveWxProfile() {
|
||||
const nickname = draftNickname.trim();
|
||||
if (!nickname) {
|
||||
toast('请填写昵称');
|
||||
return;
|
||||
}
|
||||
if (!draftAvatarTemp && !draftAvatarUrl) {
|
||||
toast('请选择头像');
|
||||
return;
|
||||
}
|
||||
setSavingProfile(true);
|
||||
try {
|
||||
let avatarUrl = draftAvatarUrl;
|
||||
let avatarResourceId: string | undefined;
|
||||
if (draftAvatarTemp) {
|
||||
const uploaded = await uploadAvatarTempFile(draftAvatarTemp);
|
||||
avatarUrl = uploaded.url;
|
||||
avatarResourceId = uploaded.resourceId;
|
||||
}
|
||||
const updated = await uploadMiniWechatProfile({
|
||||
nickname,
|
||||
...(avatarResourceId ? { avatarUrl, avatarResourceId } : {}),
|
||||
});
|
||||
if (updated) applyProfile(updated);
|
||||
setProfileSheetOpen(false);
|
||||
toast('头像昵称已更新', 'success');
|
||||
loadProfile();
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : '保存失败');
|
||||
} finally {
|
||||
setSavingProfile(false);
|
||||
}
|
||||
}
|
||||
|
||||
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 === 'qualification') {
|
||||
setQualificationOpen(true);
|
||||
return;
|
||||
}
|
||||
if ('action' in item && item.action === 'about') {
|
||||
toast('杜康好客 · 传承千年酒文化');
|
||||
}
|
||||
}
|
||||
|
||||
function renderAvatarContent(displayAvatarUrl: string | null) {
|
||||
if (displayAvatarUrl) {
|
||||
return <Image className="mine-avatar-img" src={displayAvatarUrl} mode="aspectFill" />;
|
||||
}
|
||||
return <Image className="mine-avatar-img" src={BRAND_LOGO_MARK_URL} mode="aspectFit" />;
|
||||
}
|
||||
|
||||
if (!authed) {
|
||||
return (
|
||||
<PageShell variant="tab" className="mine-page no-tab-header">
|
||||
<WechatShareReady payload={sharePayload} />
|
||||
<TabMainHeader title="我的" />
|
||||
<View className="mine-header">
|
||||
<View className="mine-header-texture" />
|
||||
<View className="mine-profile">
|
||||
<View className="mine-avatar-wrap mine-avatar-wrap--action" onClick={() => goLogin('/pages/mine/index')}>
|
||||
<View className="mine-avatar mine-avatar--wx-pending">
|
||||
<Image className="mine-avatar-img" src={BRAND_LOGO_MARK_URL} mode="aspectFit" />
|
||||
</View>
|
||||
</View>
|
||||
<View>
|
||||
<Text className="mine-profile-name">未登录</Text>
|
||||
<Text className="mine-member-tag">点击头像登录</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View className="mine-login-gate">
|
||||
<View className="mine-login-gate-hint">
|
||||
登录后管理订单与个人信息;无需登录也可浏览商品和门店
|
||||
</View>
|
||||
<View className="mine-login-btn" onClick={() => goLogin('/pages/mine/index')}>
|
||||
<Text>去登录</Text>
|
||||
</View>
|
||||
</View>
|
||||
{shouldRenderPageTabBar() ? <UserTabBar selected={3} /> : null}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const hasWechat = !!profile?.hasWechat;
|
||||
const canWxAuth = wxAuthorize && (isWeapp || isWechatEnv());
|
||||
const display = mergeWxDisplayProfile(
|
||||
profile || { id: '', nickname: null, avatarUrl: null, hasWechat: false },
|
||||
);
|
||||
// 强制保留 common 导出,避免开发者工具「旧页 + 新 common」混用时报 is not a function
|
||||
if (typeof needsWxProfileFill !== 'function' || typeof fetchMiniWechatUserInfo !== 'function') {
|
||||
throw new Error('wx profile helpers missing');
|
||||
}
|
||||
const nickname = display.nickname || '用户';
|
||||
const needProfileFill = isWeapp && needsWxProfileFill(display);
|
||||
const avatarProfileReady = isWeapp ? !needProfileFill : hasWechat;
|
||||
const maskedPhone = profile?.phone ? maskPhone(String(profile.phone)) : '';
|
||||
// 昵称下优先展示脱敏手机号;无手机号时再提示完善资料/授权
|
||||
const memberLabel =
|
||||
maskedPhone ||
|
||||
(needProfileFill
|
||||
? '点击头像完善资料'
|
||||
: !isWeapp && !hasWechat && canWxAuth
|
||||
? '点击头像授权'
|
||||
: '未绑定手机');
|
||||
const avatarClickable = isWeapp || (!hasWechat && canWxAuth);
|
||||
const previewAvatar = draftAvatarUrl || display.avatarUrl;
|
||||
|
||||
return (
|
||||
<PageShell variant="tab" className="mine-page no-tab-header">
|
||||
<WechatShareReady payload={sharePayload} />
|
||||
<TabMainHeader title="我的" />
|
||||
<View className="mine-header">
|
||||
<View className="mine-header-texture" />
|
||||
<View className="mine-profile">
|
||||
<View
|
||||
className={`mine-avatar-wrap${avatarClickable ? ' mine-avatar-wrap--action' : ''}`}
|
||||
onClick={avatarClickable ? () => void handleAvatarTap() : undefined}
|
||||
>
|
||||
<View
|
||||
className={`mine-avatar${
|
||||
avatarProfileReady ? ' mine-avatar--wx-ok' : ' mine-avatar--wx-pending'
|
||||
}`}
|
||||
>
|
||||
{renderAvatarContent(display.avatarUrl)}
|
||||
</View>
|
||||
{avatarClickable ? (
|
||||
<View
|
||||
className={`mine-avatar-status${
|
||||
avatarProfileReady ? ' mine-avatar-status--ok' : ' mine-avatar-status--pending'
|
||||
}`}
|
||||
>
|
||||
<Text>
|
||||
{bindingWx ? '授权中' : avatarProfileReady ? '更换' : '去完善'}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View
|
||||
className="mine-profile-meta"
|
||||
onClick={avatarClickable ? () => void handleAvatarTap() : undefined}
|
||||
>
|
||||
<Text className="mine-profile-name">{nickname}</Text>
|
||||
<Text className={`mine-member-tag${avatarProfileReady ? ' mine-member-tag--wechat' : ''}`}>
|
||||
{memberLabel}
|
||||
</Text>
|
||||
{profileLoadError ? (
|
||||
<Text
|
||||
className="mine-profile-retry"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
loadProfile();
|
||||
}}
|
||||
>
|
||||
资料加载失败,点击重试
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="mine-main">
|
||||
<View className="mine-card">
|
||||
<View className="mine-card-head">
|
||||
<Text className="mine-card-title">我的资产</Text>
|
||||
<Text
|
||||
className="mine-card-link"
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/benefit-detail/index' })}
|
||||
>
|
||||
查看明细 ›
|
||||
</Text>
|
||||
</View>
|
||||
<View className="mine-asset-panel">
|
||||
<View>
|
||||
<Text className="mine-asset-label">好客权益余额</Text>
|
||||
<View className="mine-asset-amount">
|
||||
<Text className="mine-asset-currency">¥</Text>
|
||||
<Text className="mine-asset-value">{formatMoney(benefitBalance)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text
|
||||
className="mine-asset-cta"
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
|
||||
>
|
||||
去使用
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="mine-card">
|
||||
<View className="mine-card-head">
|
||||
<Text className="mine-card-title">我的订单</Text>
|
||||
<Text
|
||||
className="mine-card-link"
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/orders/index' })}
|
||||
>
|
||||
全部订单 ›
|
||||
</Text>
|
||||
</View>
|
||||
<View className="mine-order-grid">
|
||||
{ORDER_SHORTCUTS.map((item) => {
|
||||
const count = orderCounts[item.tab] ?? 0;
|
||||
return (
|
||||
<View
|
||||
key={item.tab}
|
||||
className="mine-order-item"
|
||||
onClick={() =>
|
||||
Taro.navigateTo({ url: `/pages/orders/index?tab=${item.tab}` })
|
||||
}
|
||||
>
|
||||
<View className="mine-order-icon">
|
||||
<Image className="mine-order-icon-img" src={item.icon} mode="aspectFit" />
|
||||
</View>
|
||||
{count > 0 ? (
|
||||
<Text className="mine-order-badge">{count > 99 ? '99+' : count}</Text>
|
||||
) : null}
|
||||
<Text className="mine-order-label">{item.label}</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="mine-card">
|
||||
<View className="mine-card-head">
|
||||
<Text className="mine-card-title">常用服务</Text>
|
||||
</View>
|
||||
<View className="mine-service-grid">
|
||||
{SERVICES.map((item) => (
|
||||
<View
|
||||
key={item.label}
|
||||
className="mine-service-item"
|
||||
onClick={() => handleService(item)}
|
||||
>
|
||||
<View className="mine-service-icon">
|
||||
<Image className="mine-service-icon-img" src={item.icon} mode="aspectFit" />
|
||||
</View>
|
||||
<Text className="mine-service-label">{item.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="mine-footer">
|
||||
<Text className="mine-version">杜康好客</Text>
|
||||
<Text className="mine-logout" onClick={() => logout()}>
|
||||
退出登录
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{shouldRenderPageTabBar() ? <UserTabBar selected={3} /> : null}
|
||||
|
||||
{profileSheetOpen ? (
|
||||
<View className="mine-profile-sheet-mask" onClick={() => !savingProfile && setProfileSheetOpen(false)}>
|
||||
<View className="mine-profile-sheet" onClick={(e) => e.stopPropagation()}>
|
||||
<Text className="mine-profile-sheet-title">完善头像与昵称</Text>
|
||||
<Text className="mine-profile-sheet-hint">
|
||||
点击头像选择,并填写昵称后保存(用于个人中心展示)
|
||||
</Text>
|
||||
<Button
|
||||
className="mine-profile-avatar-btn"
|
||||
openType="chooseAvatar"
|
||||
hoverClass="none"
|
||||
onChooseAvatar={onChooseAvatar}
|
||||
>
|
||||
<View className="mine-profile-avatar-preview">
|
||||
{previewAvatar ? (
|
||||
<Image className="mine-avatar-img" src={previewAvatar} mode="aspectFill" />
|
||||
) : (
|
||||
<Image className="mine-avatar-img" src={BRAND_LOGO_MARK_URL} mode="aspectFit" />
|
||||
)}
|
||||
</View>
|
||||
<Text className="mine-profile-avatar-tip">点击选择头像</Text>
|
||||
</Button>
|
||||
<View className="mine-profile-nickname-wrap">
|
||||
<Text className="mine-profile-nickname-label">昵称</Text>
|
||||
<Input
|
||||
className="mine-profile-nickname-input"
|
||||
type="nickname"
|
||||
maxlength={32}
|
||||
placeholder="点击填写昵称"
|
||||
value={draftNickname}
|
||||
onInput={(e) => setDraftNickname(e.detail.value)}
|
||||
onBlur={(e) => setDraftNickname(e.detail.value.trim())}
|
||||
/>
|
||||
</View>
|
||||
<View className="mine-profile-sheet-actions">
|
||||
<View
|
||||
className="mine-profile-sheet-cancel"
|
||||
onClick={() => !savingProfile && setProfileSheetOpen(false)}
|
||||
>
|
||||
<Text>取消</Text>
|
||||
</View>
|
||||
<View
|
||||
className={`mine-profile-sheet-save${savingProfile ? ' is-disabled' : ''}`}
|
||||
onClick={() => {
|
||||
if (!savingProfile) void saveWxProfile();
|
||||
}}
|
||||
>
|
||||
<Text>{savingProfile ? '保存中…' : '保存'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{qualificationOpen ? (
|
||||
<View
|
||||
className="mine-qualification-mask"
|
||||
onClick={() => setQualificationOpen(false)}
|
||||
>
|
||||
<ScrollView
|
||||
scrollY
|
||||
enableFlex
|
||||
className="mine-qualification-scroll"
|
||||
style={{ height: '100%' }}
|
||||
enhanced
|
||||
showScrollbar
|
||||
>
|
||||
<View className="mine-qualification-body">
|
||||
<Image
|
||||
className="mine-qualification-img"
|
||||
src={QUALIFICATION_DISCLOSURE_URL}
|
||||
mode="widthFix"
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
<Text className="mine-qualification-hint">点击任意处关闭</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '现场取货确认',
|
||||
});
|
||||
@@ -1,262 +0,0 @@
|
||||
import { useEffect, useRef, 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 { goLogin } from '../../lib/auth-nav';
|
||||
import { buildPayUrl } from '../../lib/checkout-nav';
|
||||
import { ensurePayReady } from '../../lib/pay-ready';
|
||||
import { fetchUserProfile } from '../../lib/pay-wechat';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { getProductMainImage } from '../../lib/product-images';
|
||||
|
||||
type PreviewProduct = {
|
||||
id: string;
|
||||
name: string;
|
||||
spec?: string;
|
||||
subtitle?: string;
|
||||
price: number;
|
||||
mainImageUrl?: string | null;
|
||||
carouselUrls?: string[] | null;
|
||||
};
|
||||
|
||||
type OrderPreview = {
|
||||
product: PreviewProduct;
|
||||
quantity: number;
|
||||
deliveryType: string;
|
||||
productAmount: number;
|
||||
payAmount: number;
|
||||
benefitAmount: number;
|
||||
quantityOk?: boolean;
|
||||
quantityMessage?: string | null;
|
||||
minQty?: number;
|
||||
};
|
||||
|
||||
export default function OrderConfirmPickupPage() {
|
||||
const router = useRouter();
|
||||
const productId = router.params.productId ?? '';
|
||||
const [quantity, setQuantity] = useState(Math.max(2, Number(router.params.qty || 2)));
|
||||
const [preview, setPreview] = useState<OrderPreview | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const phonePromptSkipped = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!productId) return;
|
||||
let cancelled = false;
|
||||
setPreviewLoading(true);
|
||||
request<OrderPreview>('/trade/orders/preview', {
|
||||
method: 'POST',
|
||||
data: { productId, quantity, onSitePickup: true },
|
||||
})
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setPreview(data);
|
||||
setMsg(data.quantityOk === false ? data.quantityMessage || '' : '');
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) {
|
||||
setPreview(null);
|
||||
setMsg(e instanceof Error ? e.message : '加载失败');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setPreviewLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [productId, quantity]);
|
||||
|
||||
const minQty = preview?.minQty ?? 2;
|
||||
const quantityOk = preview ? preview.quantityOk !== false && quantity >= minQty : false;
|
||||
const canSubmit = !!preview && quantityOk && !loading && !previewLoading;
|
||||
|
||||
function updateQuantity(next: number) {
|
||||
if (next < minQty) {
|
||||
const tip = `现场提货至少购买 ${minQty} 瓶`;
|
||||
toast(tip);
|
||||
setMsg(tip);
|
||||
if (next < 1) return;
|
||||
setQuantity(next);
|
||||
return;
|
||||
}
|
||||
setQuantity(next);
|
||||
}
|
||||
|
||||
async function doSubmit() {
|
||||
const order = await request<{ id: string }>('/trade/orders', {
|
||||
method: 'POST',
|
||||
data: { productId, quantity, onSitePickup: true },
|
||||
});
|
||||
Taro.redirectTo({
|
||||
url: buildPayUrl({
|
||||
orderId: order.id,
|
||||
productId,
|
||||
qty: String(quantity),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!canSubmit) {
|
||||
if (!quantityOk) {
|
||||
const tip = `现场提货至少购买 ${minQty} 瓶`;
|
||||
setMsg(tip);
|
||||
toast(tip);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=${quantity}`;
|
||||
|
||||
if (!phonePromptSkipped.current) {
|
||||
try {
|
||||
const profile = await fetchUserProfile();
|
||||
const phoneBound =
|
||||
!!profile.phoneVerified ||
|
||||
(!!profile.phone && /^1[3-9]\d{9}$/.test(String(profile.phone)));
|
||||
if (!phoneBound) {
|
||||
const { confirm, cancel } = await Taro.showModal({
|
||||
title: '建议绑定手机号',
|
||||
content: '绑定后便于订单通知与售后联系;也可跳过,不绑定也能继续下单。',
|
||||
confirmText: '去绑定',
|
||||
cancelText: '暂不绑定',
|
||||
});
|
||||
if (confirm) {
|
||||
goLogin(returnPath, { needPhone: '1' });
|
||||
return;
|
||||
}
|
||||
if (cancel) {
|
||||
phonePromptSkipped.current = true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 拉取档案失败不阻塞下单 */
|
||||
}
|
||||
}
|
||||
|
||||
const ready = await ensurePayReady(returnPath);
|
||||
if (!ready) return;
|
||||
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
await doSubmit();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '下单失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const productImage = preview?.product ? getProductMainImage(preview.product) : '';
|
||||
const submitLabel = loading
|
||||
? '提交中…'
|
||||
: !quantityOk
|
||||
? `至少购买 ${minQty} 瓶`
|
||||
: '提交订单';
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="order-confirm-page" hasFixedFooter>
|
||||
<SubPageHeader title="现场取货确认" />
|
||||
<View className="sub-page-body">
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">取货方式</Text>
|
||||
<Text className="u-muted">现场取货 · 无需填写收货地址 · 免运费</Text>
|
||||
</View>
|
||||
|
||||
{preview ? (
|
||||
<>
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">商品信息</Text>
|
||||
<View className="order-product-row">
|
||||
<View className="order-product-thumb">
|
||||
{productImage ? (
|
||||
<Image
|
||||
className="order-product-thumb-img"
|
||||
src={productImage}
|
||||
mode="aspectFill"
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text className="order-product-name">{preview.product.name}</Text>
|
||||
{preview.product.spec ? (
|
||||
<Text className="u-muted">{preview.product.spec}</Text>
|
||||
) : null}
|
||||
<Text className="order-product-price">
|
||||
¥{Number(preview.product.price).toFixed(2)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="order-qty-row">
|
||||
<Text>购买数量</Text>
|
||||
<View className="order-qty-controls">
|
||||
<View
|
||||
className="order-qty-btn"
|
||||
onClick={() => updateQuantity(quantity - 1)}
|
||||
>
|
||||
<Text>−</Text>
|
||||
</View>
|
||||
<Text className="order-qty-value">{quantity}</Text>
|
||||
<View className="order-qty-btn" onClick={() => updateQuantity(quantity + 1)}>
|
||||
<Text>+</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
{!quantityOk ? (
|
||||
<Text className="order-qty-hint">
|
||||
{`现场提货至少购买 ${minQty} 瓶,请调整数量`}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">费用明细</Text>
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">商品金额</Text>
|
||||
<Text className="order-row-value">¥{Number(preview.productAmount).toFixed(2)}</Text>
|
||||
</View>
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">好客权益</Text>
|
||||
<Text className="order-row-value--price">¥{Number(preview.benefitAmount).toFixed(2)}</Text>
|
||||
</View>
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">运费</Text>
|
||||
<Text className="order-row-value">免运费</Text>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
) : previewLoading ? (
|
||||
<View className="u-empty">加载订单信息…</View>
|
||||
) : null}
|
||||
|
||||
{msg ? (
|
||||
<Text className="u-muted" style={{ display: 'block', marginTop: 8, textAlign: 'center' }}>
|
||||
{msg}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View className="order-confirm-bar">
|
||||
<View className="order-confirm-total">
|
||||
<Text className="order-confirm-total-label">应付合计</Text>
|
||||
<Text className="order-confirm-total-value">
|
||||
¥{preview ? Number(preview.payAmount).toFixed(2) : '—'}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
className={`order-confirm-submit${canSubmit ? '' : ' order-confirm-submit--disabled'}`}
|
||||
onClick={() => {
|
||||
if (!canSubmit) return;
|
||||
void submit();
|
||||
}}
|
||||
>
|
||||
<Text>{submitLabel}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '确认订单',
|
||||
});
|
||||
@@ -1,428 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, 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 { goLogin } from '../../lib/auth-nav';
|
||||
import { buildAddressListUrl, buildPayUrl, readCheckoutContext } from '../../lib/checkout-nav';
|
||||
import { tryGetClientGpsLocation } from '../../lib/client-location';
|
||||
import { maskPhone } from '../../lib/phone';
|
||||
import { ensurePayReady } from '../../lib/pay-ready';
|
||||
import { fetchUserProfile } from '../../lib/pay-wechat';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { canCrossCity, isCrossCityAddress } from '../../lib/product-fulfillment';
|
||||
import { getProductMainImage } from '../../lib/product-images';
|
||||
|
||||
type Address = {
|
||||
id: string;
|
||||
receiverName: string;
|
||||
phone: string;
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
detail: string;
|
||||
isDefault?: number | boolean;
|
||||
};
|
||||
|
||||
type PreviewProduct = {
|
||||
id: string;
|
||||
name: string;
|
||||
spec?: string;
|
||||
subtitle?: string;
|
||||
price: number;
|
||||
mainImageUrl?: string | null;
|
||||
carouselUrls?: string[] | null;
|
||||
allowCrossCityDelivery?: boolean;
|
||||
allowOnlinePurchase?: boolean;
|
||||
};
|
||||
|
||||
type OrderPreview = {
|
||||
product: PreviewProduct;
|
||||
quantity: number;
|
||||
deliveryType: 'LOCAL' | 'CROSS_CITY';
|
||||
productAmount: number;
|
||||
freightPayType: 'COD' | null;
|
||||
payAmount: number;
|
||||
benefitAmount: number;
|
||||
city?: { name?: string; localMinQty: number; crossMinQty: number };
|
||||
quantityOk?: boolean;
|
||||
quantityMessage?: string | null;
|
||||
addressOk?: boolean;
|
||||
addressMessage?: string | null;
|
||||
minQty?: number;
|
||||
allowCrossCityDelivery?: boolean;
|
||||
allowOnlinePurchase?: boolean;
|
||||
};
|
||||
|
||||
const CROSS_CITY_BLOCK_MSG = '该商品不支持跨城配送,请更换为开城城市内的收货地址';
|
||||
|
||||
function formatAddress(a: Address) {
|
||||
return `${a.province}${a.city}${a.district}${a.detail}`;
|
||||
}
|
||||
|
||||
export default function OrderConfirmPage() {
|
||||
const router = useRouter();
|
||||
const checkoutCtx = readCheckoutContext(router.params);
|
||||
const productId = checkoutCtx.productId ?? '';
|
||||
const forceCross = checkoutCtx.cross === true;
|
||||
const [quantity, setQuantity] = useState(Math.max(1, Number(checkoutCtx.qty || 2)));
|
||||
const [addresses, setAddresses] = useState<Address[]>([]);
|
||||
const [addressId, setAddressId] = useState(checkoutCtx.addressId || '');
|
||||
const [preview, setPreview] = useState<OrderPreview | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const phonePromptSkipped = useRef(false);
|
||||
const toastedAddressBlockRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
request<Address[]>('/user/addresses')
|
||||
.then((list) => {
|
||||
setAddresses(list);
|
||||
const fromUrl = checkoutCtx.addressId;
|
||||
if (fromUrl && list.some((a) => String(a.id) === fromUrl)) {
|
||||
setAddressId(fromUrl);
|
||||
return;
|
||||
}
|
||||
const def = list.find((a) => a.isDefault === 1 || a.isDefault === true) || list[0];
|
||||
if (def) setAddressId(String(def.id));
|
||||
})
|
||||
.catch(() => setAddresses([]));
|
||||
}, [checkoutCtx.addressId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!productId) return;
|
||||
let cancelled = false;
|
||||
setPreviewLoading(true);
|
||||
const body: { productId: string; quantity: number; addressId?: string } = {
|
||||
productId,
|
||||
quantity,
|
||||
};
|
||||
if (addressId) body.addressId = addressId;
|
||||
|
||||
request<OrderPreview>('/trade/orders/preview', { method: 'POST', data: body })
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setPreview(data);
|
||||
const nextMsg =
|
||||
data.addressOk === false
|
||||
? data.addressMessage || CROSS_CITY_BLOCK_MSG
|
||||
: data.quantityOk === false
|
||||
? data.quantityMessage || ''
|
||||
: '';
|
||||
setMsg(nextMsg);
|
||||
if (
|
||||
data.addressOk === false &&
|
||||
addressId &&
|
||||
toastedAddressBlockRef.current !== addressId
|
||||
) {
|
||||
toastedAddressBlockRef.current = addressId;
|
||||
toast(data.addressMessage || CROSS_CITY_BLOCK_MSG);
|
||||
}
|
||||
if (data.addressOk !== false) {
|
||||
toastedAddressBlockRef.current = '';
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) {
|
||||
setPreview(null);
|
||||
setMsg(e instanceof Error ? e.message : '加载失败');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setPreviewLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [productId, quantity, addressId]);
|
||||
|
||||
const selectedAddress = useMemo(
|
||||
() => addresses.find((a) => String(a.id) === addressId),
|
||||
[addresses, addressId],
|
||||
);
|
||||
|
||||
const allowCross =
|
||||
preview?.allowCrossCityDelivery !== undefined
|
||||
? canCrossCity({ allowCrossCityDelivery: preview.allowCrossCityDelivery })
|
||||
: canCrossCity(preview?.product ?? {});
|
||||
const localCross =
|
||||
!!selectedAddress &&
|
||||
isCrossCityAddress(selectedAddress.city, preview?.city?.name);
|
||||
const isCross =
|
||||
forceCross || preview?.deliveryType === 'CROSS_CITY' || localCross;
|
||||
const crossBlocked = isCross && !allowCross;
|
||||
const addressOk = preview ? preview.addressOk !== false && !crossBlocked : !crossBlocked;
|
||||
const minQty =
|
||||
preview?.minQty ??
|
||||
(isCross ? (preview?.city?.crossMinQty ?? 6) : (preview?.city?.localMinQty ?? 2));
|
||||
const quantityOk = preview ? preview.quantityOk !== false && quantity >= minQty : false;
|
||||
const canSubmit =
|
||||
!!addressId && !!preview && quantityOk && addressOk && !loading && !previewLoading;
|
||||
|
||||
const addressHint = !addressOk
|
||||
? preview?.addressMessage || CROSS_CITY_BLOCK_MSG
|
||||
: '';
|
||||
|
||||
function updateQuantity(next: number) {
|
||||
if (next < minQty) {
|
||||
const tip = isCross
|
||||
? `跨城配送至少购买 ${minQty} 瓶(1箱)`
|
||||
: `同城配送至少购买 ${minQty} 瓶`;
|
||||
toast(tip);
|
||||
setMsg(tip);
|
||||
if (next < 1) return;
|
||||
setQuantity(next);
|
||||
return;
|
||||
}
|
||||
setQuantity(next);
|
||||
}
|
||||
|
||||
async function doSubmit() {
|
||||
let clientLocation = null;
|
||||
try {
|
||||
clientLocation = await tryGetClientGpsLocation();
|
||||
} catch {
|
||||
/* GPS 获取失败不阻塞下单 */
|
||||
}
|
||||
const order = await request<{ id: string }>('/trade/orders', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
productId,
|
||||
quantity,
|
||||
addressId,
|
||||
...(clientLocation ? { clientLocation } : {}),
|
||||
},
|
||||
});
|
||||
Taro.redirectTo({
|
||||
url: buildPayUrl({
|
||||
orderId: order.id,
|
||||
productId,
|
||||
qty: String(quantity),
|
||||
addressId,
|
||||
cross: forceCross,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!canSubmit) {
|
||||
if (!addressId) {
|
||||
setMsg('请选择收货地址');
|
||||
return;
|
||||
}
|
||||
if (!addressOk) {
|
||||
const tip = addressHint || CROSS_CITY_BLOCK_MSG;
|
||||
setMsg(tip);
|
||||
toast(tip);
|
||||
return;
|
||||
}
|
||||
if (!quantityOk) {
|
||||
const tip = isCross
|
||||
? `跨城配送至少购买 ${minQty} 瓶(1箱)`
|
||||
: `同城配送至少购买 ${minQty} 瓶`;
|
||||
setMsg(tip);
|
||||
toast(tip);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const returnPath = `/pages/order-confirm/index?productId=${productId}&qty=${quantity}&addressId=${addressId}${forceCross ? '&cross=1' : ''}`;
|
||||
|
||||
if (!phonePromptSkipped.current) {
|
||||
try {
|
||||
const profile = await fetchUserProfile();
|
||||
const phoneBound =
|
||||
!!profile.phoneVerified ||
|
||||
(!!profile.phone && /^1[3-9]\d{9}$/.test(String(profile.phone)));
|
||||
if (!phoneBound) {
|
||||
const { confirm, cancel } = await Taro.showModal({
|
||||
title: '建议绑定手机号',
|
||||
content: '绑定后便于订单通知与售后联系;也可跳过,不绑定也能继续下单。',
|
||||
confirmText: '去绑定',
|
||||
cancelText: '暂不绑定',
|
||||
});
|
||||
if (confirm) {
|
||||
goLogin(returnPath, { needPhone: '1' });
|
||||
return;
|
||||
}
|
||||
if (cancel) {
|
||||
phonePromptSkipped.current = true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 拉取档案失败不阻塞下单 */
|
||||
}
|
||||
}
|
||||
|
||||
const ready = await ensurePayReady(returnPath);
|
||||
if (!ready) return;
|
||||
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
await doSubmit();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '下单失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const productImage = preview?.product ? getProductMainImage(preview.product) : '';
|
||||
const submitLabel = loading
|
||||
? '提交中…'
|
||||
: !addressId
|
||||
? '请选择地址'
|
||||
: !addressOk
|
||||
? '请更换地址'
|
||||
: !quantityOk
|
||||
? `至少购买 ${minQty} 瓶`
|
||||
: '提交订单';
|
||||
const displayMsg = msg || addressHint;
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="order-confirm-page" hasFixedFooter>
|
||||
<SubPageHeader title="确认订单" />
|
||||
<View className="sub-page-body">
|
||||
<View
|
||||
className="order-card"
|
||||
onClick={() =>
|
||||
Taro.navigateTo({
|
||||
url: buildAddressListUrl({
|
||||
productId,
|
||||
qty: String(quantity),
|
||||
addressId,
|
||||
cross: forceCross,
|
||||
}),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Text className="order-card-title">收货地址</Text>
|
||||
{selectedAddress ? (
|
||||
<View>
|
||||
<View style={{ display: 'flex', gap: '8px', marginBottom: 4 }}>
|
||||
<Text className="order-card-title" style={{ fontSize: 15 }}>{selectedAddress.receiverName}</Text>
|
||||
<Text className="u-muted">{maskPhone(selectedAddress.phone)}</Text>
|
||||
</View>
|
||||
<Text className="u-muted">{formatAddress(selectedAddress)}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text className="u-muted">点击选择收货地址</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{!addressOk && addressId ? (
|
||||
<View className="order-card order-card--warn">
|
||||
<Text className="order-warn-text">{addressHint || CROSS_CITY_BLOCK_MSG}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{isCross && addressOk ? (
|
||||
<View className="order-card">
|
||||
<Text className="u-muted">
|
||||
该地址超出同城配送范围,将由总部物流发货,运费到付
|
||||
{quantity < minQty ? `;跨城至少购买 ${minQty} 瓶(1箱)` : ''}。
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{preview ? (
|
||||
<>
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">商品信息</Text>
|
||||
<View className="order-product-row">
|
||||
<View className="order-product-thumb">
|
||||
{productImage ? (
|
||||
<Image
|
||||
className="order-product-thumb-img"
|
||||
src={productImage}
|
||||
mode="aspectFill"
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text className="order-product-name">{preview.product.name}</Text>
|
||||
<Text className="order-product-price">¥{Number(preview.product.price).toFixed(2)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="order-qty-row">
|
||||
<Text>购买数量</Text>
|
||||
<View className="order-qty-controls">
|
||||
<View
|
||||
className="order-qty-btn"
|
||||
onClick={() => updateQuantity(quantity - 1)}
|
||||
>
|
||||
<Text>−</Text>
|
||||
</View>
|
||||
<Text className="order-qty-value">{quantity}</Text>
|
||||
<View
|
||||
className="order-qty-btn"
|
||||
onClick={() => updateQuantity(quantity + 1)}
|
||||
>
|
||||
<Text>+</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
{!quantityOk ? (
|
||||
<Text className="order-qty-hint">
|
||||
{isCross
|
||||
? `跨城配送至少购买 ${minQty} 瓶(1箱),请调整数量`
|
||||
: `同城配送至少购买 ${minQty} 瓶,请调整数量`}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">费用明细</Text>
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">商品金额</Text>
|
||||
<Text className="order-row-value">¥{preview.productAmount.toFixed(2)}</Text>
|
||||
</View>
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">好客权益</Text>
|
||||
<Text className="order-row-value--price">¥{preview.benefitAmount.toFixed(2)}</Text>
|
||||
</View>
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">运费</Text>
|
||||
<Text className="order-row-value">{isCross ? '到付' : '免运费'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{previewLoading && !preview && productId ? (
|
||||
<View className="u-empty">加载订单信息…</View>
|
||||
) : null}
|
||||
{!previewLoading && !preview && productId ? (
|
||||
<View className="u-empty">无法加载商品信息</View>
|
||||
) : null}
|
||||
{displayMsg ? (
|
||||
<Text className="order-warn-text" style={{ display: 'block', marginTop: 8, textAlign: 'center' }}>
|
||||
{displayMsg}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View className="order-confirm-bar">
|
||||
<View className="order-confirm-total">
|
||||
<Text className="order-confirm-total-label">应付合计</Text>
|
||||
<Text className="order-confirm-total-value">
|
||||
¥{preview ? preview.payAmount.toFixed(2) : '—'}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
className={`order-confirm-submit${canSubmit ? '' : ' order-confirm-submit--disabled'}`}
|
||||
onClick={() => {
|
||||
if (!canSubmit) return;
|
||||
void submit();
|
||||
}}
|
||||
>
|
||||
<Text>{submitLabel}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '订单详情',
|
||||
enableShareAppMessage: true,
|
||||
enableShareTimeline: true,
|
||||
});
|
||||
@@ -1,313 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useDidShow, useRouter, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import ShareNavButton from '../../components/ShareNavButton';
|
||||
import WechatShareReady from '../../components/WechatShareReady';
|
||||
import ContactCsButton from '../../components/ContactCsButton';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { buildPayUrl } from '../../lib/checkout-nav';
|
||||
import { maskPhone } from '../../lib/phone';
|
||||
import { confirmOrderReceive, type WechatConfirmPayload } from '../../lib/wechat-order-confirm';
|
||||
import {
|
||||
DEFAULT_SHARE_DESC,
|
||||
DEFAULT_SHARE_TITLE,
|
||||
toWeappShareMessage,
|
||||
} from '../../lib/wechat-share';
|
||||
import { usePageView } from '../../lib/usePageView';
|
||||
|
||||
const isWeapp = process.env.TARO_ENV === 'weapp';
|
||||
|
||||
type OrderItem = {
|
||||
productName?: string;
|
||||
productSpec?: string;
|
||||
quantity?: number;
|
||||
};
|
||||
|
||||
type OrderDetail = {
|
||||
id: string;
|
||||
orderNo?: string;
|
||||
status?: string;
|
||||
payAmount?: number;
|
||||
productName?: string;
|
||||
quantity?: number;
|
||||
qty?: number;
|
||||
receiverName?: string;
|
||||
receiverPhone?: string;
|
||||
receiverProvince?: string;
|
||||
receiverCity?: string;
|
||||
receiverDistrict?: string;
|
||||
receiverAddress?: string;
|
||||
createdAt?: string;
|
||||
originOrderId?: string | null;
|
||||
orderType?: string;
|
||||
isProxyOrder?: boolean;
|
||||
proxyPartnerName?: string | null;
|
||||
items?: OrderItem[];
|
||||
wechatConfirm?: WechatConfirmPayload | null;
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
PENDING_PAY: '待付款',
|
||||
PENDING_SHIP: '待发货',
|
||||
OUT_WAREHOUSE: '出库中',
|
||||
SHIPPING: '配送中',
|
||||
SHIPPED: '配送中',
|
||||
PENDING_RECEIVE: '待签收',
|
||||
DELIVERED: '待签收',
|
||||
COMPLETED: '已完成',
|
||||
CANCELLED: '已取消',
|
||||
REFUNDING: '退款中',
|
||||
REFUNDED: '已退款',
|
||||
};
|
||||
|
||||
function fullReceiverAddress(order: OrderDetail) {
|
||||
const detail = (order.receiverAddress || '').trim();
|
||||
const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict]
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
if (!region && !detail) return '';
|
||||
if (region && detail.startsWith(region)) return detail;
|
||||
return `${region}${detail}`;
|
||||
}
|
||||
|
||||
export default function OrderDetailPage() {
|
||||
const router = useRouter();
|
||||
const orderId = router.params.id ?? '';
|
||||
usePageView('order_detail_view', orderId ? { orderId } : undefined);
|
||||
const [order, setOrder] = useState<OrderDetail | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!orderId) return;
|
||||
request<OrderDetail>(`/trade/orders/${orderId}`)
|
||||
.then(setOrder)
|
||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
||||
}, [orderId]);
|
||||
|
||||
useDidShow(() => {
|
||||
if (!orderId) return;
|
||||
// 从微信确认收货组件返回后刷新
|
||||
request<OrderDetail>(`/trade/orders/${orderId}`)
|
||||
.then(setOrder)
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
const isReship = !!order?.originOrderId;
|
||||
const isProxy = !!order && (order.isProxyOrder || order.orderType === 'PROXY');
|
||||
const canPay = !!order && order.status === 'PENDING_PAY' && !isReship;
|
||||
const canConfirmReceive =
|
||||
!!order && !isReship && !isProxy && ['PENDING_RECEIVE', 'DELIVERED'].includes(order.status || '');
|
||||
|
||||
const item = order?.items?.[0];
|
||||
const productName = item?.productName || order?.productName || '杜康商品';
|
||||
const quantity = item?.quantity ?? order?.quantity ?? order?.qty ?? 1;
|
||||
const addressText = order ? fullReceiverAddress(order) : '';
|
||||
const receiverLine = order
|
||||
? [order.receiverName, order.receiverPhone ? maskPhone(String(order.receiverPhone)) : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
: '';
|
||||
|
||||
const sharePayload = useMemo(
|
||||
() => ({
|
||||
title: productName !== '杜康商品' ? `我买了${productName} · 杜康好客` : DEFAULT_SHARE_TITLE,
|
||||
desc: DEFAULT_SHARE_DESC,
|
||||
path: orderId ? `/pages/order-detail/index?id=${orderId}` : '/pages/home/index',
|
||||
}),
|
||||
[productName, orderId],
|
||||
);
|
||||
|
||||
useShareAppMessage(() => toWeappShareMessage(sharePayload));
|
||||
useShareTimeline(() => ({
|
||||
title: sharePayload.title || DEFAULT_SHARE_TITLE,
|
||||
query: orderId ? `id=${orderId}` : '',
|
||||
}));
|
||||
|
||||
function goPay() {
|
||||
if (!order) return;
|
||||
Taro.navigateTo({ url: buildPayUrl({ orderId: order.id }) });
|
||||
}
|
||||
|
||||
function goCustomerService() {
|
||||
Taro.navigateTo({ url: '/pages/customer-service/index' });
|
||||
}
|
||||
|
||||
async function confirmReceive() {
|
||||
if (!order || !canConfirmReceive || confirming) return;
|
||||
|
||||
const { confirm } = await Taro.showModal({
|
||||
title: '确认收货?',
|
||||
content: isWeapp
|
||||
? '将打开微信确认收货,完成后订单即完结,无需再点服务通知。'
|
||||
: '请确认已收到商品。确认后订单将完成,好客权益可正常使用。',
|
||||
confirmText: '确认收货',
|
||||
cancelText: '再想想',
|
||||
});
|
||||
if (!confirm) return;
|
||||
|
||||
setConfirming(true);
|
||||
try {
|
||||
const mode = await confirmOrderReceive({
|
||||
orderId: order.id,
|
||||
wechatConfirm: order.wechatConfirm,
|
||||
onLocalSuccess: async () => {
|
||||
const updated = await request<OrderDetail>(`/trade/orders/${order.id}`);
|
||||
setOrder(updated);
|
||||
toast('已确认收货');
|
||||
},
|
||||
});
|
||||
if (mode === 'wechat') {
|
||||
// 回跳后由 App.onShow / 本页 useDidShow 处理
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '确认收货失败');
|
||||
} finally {
|
||||
setConfirming(false);
|
||||
}
|
||||
}
|
||||
|
||||
const pageClass = [
|
||||
'order-detail-page',
|
||||
order ? 'order-detail-page--with-actions' : '',
|
||||
canPay || canConfirmReceive ? 'order-detail-page--with-pay' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className={pageClass}>
|
||||
<WechatShareReady payload={sharePayload} />
|
||||
<SubPageHeader
|
||||
title="订单详情"
|
||||
onBack={() => {
|
||||
// 支付完成后 reLaunch 进详情:栈仅一页时 navigateBack 会退出小程序,统一回首页
|
||||
const fromPay = String(router.params.from || '') === 'pay';
|
||||
if (fromPay || Taro.getCurrentPages().length <= 1) {
|
||||
Taro.switchTab({ url: '/pages/home/index' }).catch(() => {
|
||||
Taro.reLaunch({ url: '/pages/home/index' });
|
||||
});
|
||||
return;
|
||||
}
|
||||
Taro.navigateBack();
|
||||
}}
|
||||
right={<ShareNavButton payload={sharePayload} />}
|
||||
/>
|
||||
<View className="sub-page-body">
|
||||
{!order ? (
|
||||
<View className="u-empty">加载中…</View>
|
||||
) : (
|
||||
<>
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">订单状态</Text>
|
||||
<View className="order-status-row">
|
||||
<Text className="order-list-status">
|
||||
{STATUS_LABELS[order.status || ''] || order.status || '处理中'}
|
||||
</Text>
|
||||
{isProxy ? <Text className="order-proxy-badge">代下单</Text> : null}
|
||||
</View>
|
||||
{isProxy && order.proxyPartnerName ? (
|
||||
<Text className="order-proxy-hint">由合伙人 {order.proxyPartnerName} 代下</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">商品信息</Text>
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">{productName}</Text>
|
||||
<Text className="order-row-value">x{quantity}</Text>
|
||||
</View>
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">实付金额</Text>
|
||||
<Text className="order-row-value--price">
|
||||
¥{Number(order.payAmount ?? 0).toFixed(2)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">收货信息</Text>
|
||||
{receiverLine || addressText ? (
|
||||
<>
|
||||
{receiverLine ? (
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">收货人</Text>
|
||||
<Text className="order-row-value">{receiverLine}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{addressText ? (
|
||||
<View className="order-row order-row--address">
|
||||
<Text className="order-row-label">收货地址</Text>
|
||||
<Text className="order-row-value order-row-value--wrap">{addressText}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<Text className="u-muted">地址信息待完善</Text>
|
||||
)}
|
||||
</View>
|
||||
<View className="order-card">
|
||||
<Text className="order-card-title">订单信息</Text>
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">订单编号</Text>
|
||||
<Text className="order-row-value">{order.orderNo || order.id}</Text>
|
||||
</View>
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">下单时间</Text>
|
||||
<Text className="order-row-value">
|
||||
{order.createdAt ? String(order.createdAt).slice(0, 19).replace('T', ' ') : '-'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{order ? (
|
||||
<View
|
||||
className={`order-detail-actionbar${
|
||||
canPay || canConfirmReceive ? ' order-detail-actionbar--with-pay' : ''
|
||||
}`}
|
||||
>
|
||||
{isWeapp ? (
|
||||
<ContactCsButton
|
||||
className="order-detail-cs-btn"
|
||||
session={{
|
||||
from: 'order-detail',
|
||||
orderId: order.id,
|
||||
orderNo: order.orderNo,
|
||||
}}
|
||||
>
|
||||
联系客服
|
||||
</ContactCsButton>
|
||||
) : (
|
||||
<View className="order-detail-cs-btn" onClick={goCustomerService}>
|
||||
<Text>联系客服</Text>
|
||||
</View>
|
||||
)}
|
||||
{canPay ? (
|
||||
<>
|
||||
<View className="order-confirm-total">
|
||||
<Text className="order-confirm-total-label">待支付</Text>
|
||||
<Text className="order-confirm-total-value">
|
||||
¥{Number(order.payAmount ?? 0).toFixed(2)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="order-confirm-submit" onClick={goPay}>
|
||||
去付款
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
{canConfirmReceive ? (
|
||||
<View
|
||||
className={`order-confirm-submit${confirming ? ' order-confirm-submit--disabled' : ''}`}
|
||||
onClick={confirming ? undefined : () => void confirmReceive()}
|
||||
>
|
||||
{confirming ? '提交中…' : '确认收货'}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '我的订单',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark',
|
||||
});
|
||||