Merge branch 'xuchao'
This commit is contained in:
@@ -4,72 +4,111 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## 项目定位
|
## 项目定位
|
||||||
|
|
||||||
## 项目定位
|
**爱上萍乡**用户端微信小程序(C端),基于 uni-app + Vue 3 + TypeScript + Pinia。仅包含用户端功能,不含管理端。
|
||||||
|
|
||||||
**湖南12301文旅专区**用户端微信小程序(C端),基于 uni-app + Vue 3 + TypeScript + Pinia。仅包含用户端功能,不含管理端。
|
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
| 技术 | 版本 | 说明 |
|
| 技术 | 版本 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| Vue | 3.5.x | 核心框架 |
|
| Vue | 3.5.x | 核心框架 |
|
||||||
| uni-app | 3.0.0 | 跨平台框架(当前仅编译微信小程序) |
|
| uni-app | 3.0.0-alpha | 跨平台框架(当前仅编译微信小程序) |
|
||||||
| TypeScript | 4.9.x | 类型系统 |
|
| TypeScript | 4.9.x | 类型系统 |
|
||||||
| Pinia | 3.x | 状态管理(仅 user store) |
|
| Pinia | 3.x | 状态管理(仅 user store) |
|
||||||
| Vite | 5.2.8 | 构建工具 |
|
| Vite | 5.2.8 | 构建工具 |
|
||||||
| @dcloudio/uni-ui | 1.5.12 | UI 组件库(easycom 自动导入) |
|
| @dcloudio/uni-ui | 1.5.x | UI 组件库(easycom 自动导入) |
|
||||||
|
| sass | 1.101.x | 样式预处理 |
|
||||||
|
|
||||||
## 微信小程序配置
|
## 运行命令
|
||||||
|
|
||||||
- **AppID**: `wx636bc46a6570e9fd`
|
```bash
|
||||||
- **后端接口前缀**: `/mp-api`(对应后端 `RouteChannel.MP_API`)
|
pnpm install # 安装依赖
|
||||||
- **API 基础地址**: 由 `.env.*` 文件的 `VITE_API_BASE_URL` 控制
|
pnpm run dev:mp-weixin # 微信小程序开发构建
|
||||||
|
pnpm run build:mp-weixin # 微信小程序生产构建
|
||||||
|
pnpm run type-check # TypeScript 类型检查(vue-tsc --noEmit)
|
||||||
|
```
|
||||||
|
|
||||||
|
构建产物在 `dist/build/mp-weixin/`,用微信开发者工具导入此目录。
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|
||||||
```
|
```
|
||||||
zhy-test/
|
src/
|
||||||
├── src/
|
├── api/ # API 接口层
|
||||||
│ ├── api/ # API 接口层
|
│ ├── request.ts # HTTP 封装(uni.request + Token 注入 + 401 处理)
|
||||||
│ │ ├── request.ts # HTTP 封装(uni.request + Token 注入 + 401 处理)
|
│ ├── types.ts # 公共类型定义
|
||||||
│ │ ├── types.ts # TypeScript 类型定义
|
│ ├── user.ts # 微信登录(两种方式)
|
||||||
│ │ ├── user.ts # 微信登录 + 用户信息
|
│ ├── card.ts # 年卡接口
|
||||||
│ │ ├── card.ts # 年卡列表/详情/激活/绑定/入园码
|
│ └── order.ts # 订单接口
|
||||||
│ │ └── order.ts # 订单创建/支付/查询
|
├── components/ # 公共组件
|
||||||
│ ├── components/ # 公共组件
|
│ ├── mc-glass.vue # 毛玻璃卡片
|
||||||
│ │ ├── McGlass.vue # 毛玻璃卡片
|
│ ├── card-item.vue # 年卡列表项
|
||||||
│ │ ├── card-item.vue # 年卡列表项
|
│ ├── empty-state.vue # 空状态占位
|
||||||
│ │ └── empty-state.vue # 空状态占位
|
│ ├── announcement-modal.vue # 公告弹窗
|
||||||
│ ├── config/
|
│ ├── confirm-modal.vue # 确认弹窗
|
||||||
│ │ └── index.ts # API_BASE / TOKEN_KEY / REFRESH_TOKEN_KEY
|
│ ├── design-button.vue # 设计系统按钮
|
||||||
│ ├── pages/ # 主包页面
|
│ ├── design-card.vue # 设计系统卡片
|
||||||
│ │ ├── index/index.vue # 首页(轮播 + 热门景区 + 精选活动)
|
│ ├── design-input.vue # 设计系统输入框
|
||||||
│ │ ├── login/login.vue # 登录页(微信手机号授权登录)
|
│ └── section-label.vue # 区块标题
|
||||||
│ │ └── profile/index.vue # 个人中心(tabBar)
|
├── config/
|
||||||
│ ├── packages/user/pages/card/ # 分包:年卡功能(减小主包体积)
|
│ └── index.ts # API_BASE / TOKEN_KEY / 环境变量
|
||||||
│ │ ├── list.vue # 年卡列表(搜索 + 分类筛选)
|
├── mock/ # Mock 数据(VITE_USE_MOCK=true 时启用)
|
||||||
│ │ ├── detail.vue # 年卡详情(权益 + 景区 + 富文本)
|
│ ├── index.ts # USE_MOCK 开关
|
||||||
│ │ ├── buy.vue # 购买确认 + 模拟支付
|
│ ├── images.ts # 图片 URL
|
||||||
│ │ ├── activate.vue # 实名激活(姓名 + 身份证 + 手机)
|
│ ├── notice.ts # 公告数据
|
||||||
│ │ ├── qrcode.vue # 入园码(60秒自动刷新)
|
│ ├── scenic.ts # 景区列表数据
|
||||||
│ │ └── bind.vue # 卡号绑定
|
│ └── scenic-detail.ts # 景区详情数据
|
||||||
│ ├── static/ # 静态资源(logo + tabBar 图标)
|
├── pages/ # 主包页面
|
||||||
│ ├── store/
|
│ ├── index/index.vue # 首页(轮播 + 热门景区 + 精选活动)
|
||||||
│ │ └── user.ts # 用户状态(token 持久化 + 登录判断)
|
│ ├── qrcode/index.vue # 入园码(tabBar 旅游卡)
|
||||||
│ ├── utils/
|
│ ├── login/login.vue # 登录页(微信手机号授权)
|
||||||
│ │ └── index.ts # 工具函数(showToast / navigateTo 等)
|
│ ├── login/agreement.vue # 服务条款
|
||||||
│ ├── App.vue # 根组件(隐私协议检查 + 自动获取用户信息)
|
│ ├── login/privacy.vue # 隐私条款
|
||||||
│ ├── main.ts # 入口(createSSRApp + Pinia)
|
│ └── profile/index.vue # 个人中心(tabBar)
|
||||||
│ ├── manifest.json # uni-app 配置(AppID)
|
├── packages/user/ # 分包:用户相关(年卡、订单、预约)
|
||||||
│ ├── pages.json # 路由 + tabBar + 分包 + easycom
|
│ ├── api/
|
||||||
│ └── uni.scss # 全局 SCSS 变量
|
│ │ ├── card.ts # 年卡 API
|
||||||
├── .env.local # 本地开发环境
|
│ │ └── order.ts # 订单 API
|
||||||
├── .env.dev # 开发环境
|
│ ├── pages/
|
||||||
├── .env.uat # UAT 环境
|
│ │ ├── card/list.vue # 年卡列表
|
||||||
├── package.json
|
│ │ ├── card/detail.vue # 年卡详情
|
||||||
├── project.config.json # 微信开发者工具配置
|
│ │ ├── card/buy.vue # 年卡购买
|
||||||
├── vite.config.ts
|
│ │ ├── card/activate.vue # 年卡激活
|
||||||
└── tsconfig.json
|
│ │ ├── card/qrcode.vue # 入园码
|
||||||
|
│ │ ├── card/bind.vue # 年卡绑定
|
||||||
|
│ │ ├── bind.vue # 绑卡激活
|
||||||
|
│ │ ├── buy.vue # 超级玩家卡购买
|
||||||
|
│ │ ├── union-buy.vue # 工会购买
|
||||||
|
│ │ ├── card-notice.vue # 用卡须知
|
||||||
|
│ │ ├── order/list.vue # 订单列表
|
||||||
|
│ │ ├── order/detail.vue # 订单详情
|
||||||
|
│ │ ├── reserve/index.vue # 景区预约
|
||||||
|
│ │ ├── reserve/success.vue # 预约成功/详情
|
||||||
|
│ │ └── reserve/list.vue # 预约/使用记录
|
||||||
|
│ └── mock/
|
||||||
|
│ └── order.ts # 订单 mock 数据
|
||||||
|
├── packages/content/ # 分包:内容展示(景区、公告、帮助)
|
||||||
|
│ └── pages/
|
||||||
|
│ ├── scenic-list.vue # 景区列表(custom navbar)
|
||||||
|
│ ├── scenic-detail.vue # 景区详情
|
||||||
|
│ ├── notice-list.vue # 通知公告
|
||||||
|
│ ├── usage-guide.vue # 使用说明
|
||||||
|
│ └── faq.vue # 常见问题
|
||||||
|
├── store/
|
||||||
|
│ └── user.ts # 用户状态(token 持久化 + 登录判断)
|
||||||
|
├── styles/ # 设计系统样式
|
||||||
|
│ ├── main.scss # 入口(全局样式)
|
||||||
|
│ ├── _tokens.scss # 设计令牌(对齐 uni.scss)
|
||||||
|
│ ├── _mixins.scss # 混入(text-display / text-body / text-mono 等)
|
||||||
|
│ ├── _animations.scss # 动画
|
||||||
|
│ └── _utilities.scss # 工具类
|
||||||
|
├── static/ # 静态资源(tabBar 图标、logo 等)
|
||||||
|
├── utils/
|
||||||
|
│ └── index.ts # 工具函数(showToast / navigateTo 等)
|
||||||
|
├── App.vue # 根组件(隐私协议检查)
|
||||||
|
├── main.ts # 入口(createSSRApp + Pinia)
|
||||||
|
├── manifest.json # uni-app 配置(AppID)
|
||||||
|
├── pages.json # 路由 + tabBar + 分包 + easycom
|
||||||
|
└── uni.scss # 全局 SCSS 变量(自动注入到每个组件)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 架构设计
|
## 架构设计
|
||||||
@@ -77,136 +116,92 @@ zhy-test/
|
|||||||
### 分层
|
### 分层
|
||||||
|
|
||||||
```
|
```
|
||||||
页面(.vue) → API 层(api/*.ts) → HTTP 封装(request.ts) → uni.request → 后端
|
页面(.vue) → API 层(api/*.ts) → request.ts → uni.request → 后端
|
||||||
↕
|
↕
|
||||||
Store(store/user.ts) ← 仅管理 token + 登录状态
|
Store(store/user.ts) ← 仅管理 token + 登录状态
|
||||||
```
|
```
|
||||||
|
|
||||||
- **页面直接调用 API**,不经过 store(年卡、订单等模块)
|
- **页面直接调用 API**,不经过 store(年卡、订单等模块)
|
||||||
- **只有 user 模块使用 store**,因为需要跨页面共享 token 和登录状态
|
- **只有 user 模块使用 store**,因为需要跨页面共享 token 和登录状态
|
||||||
- **request.ts** 统一处理 Token 注入、401 跳转、响应格式解析
|
- **request.ts** 统一处理 Token 注入、401 跳转、响应格式解析(兼容 `code`/`errcode` 两种业务状态码)
|
||||||
|
|
||||||
|
### 路由
|
||||||
|
|
||||||
|
- 3 个 tabBar 页面:首页、旅游卡(入园码)、我的
|
||||||
|
- 主包:首页、登录、隐私/服务条款、个人中心
|
||||||
|
- 分包 `packages/user`:年卡、订单、预约功能
|
||||||
|
- 分包 `packages/content`:景区列表/详情、公告、使用说明、FAQ
|
||||||
|
|
||||||
|
### 微信登录
|
||||||
|
|
||||||
|
两种登录方式并存:
|
||||||
|
|
||||||
|
1. **OAuth2 登录**(当前使用):`wx.login()` → `encryptedData` + `iv` → `POST /open-api/auth/oauth2` → 返回 `openId` 作为 token
|
||||||
|
2. **手机号登录**(旧方式):`button[open-type=getPhoneNumber]` → `phoneCode` + `loginCode` → `POST /mp-api/auth/wx-login`
|
||||||
|
|
||||||
### 后端对接
|
### 后端对接
|
||||||
|
|
||||||
- 后端项目:`jxxx-dscp`(Spring Boot 2.7 + MyBatis-Plus)
|
- 后端项目:`jxxx-dscp`(Spring Boot 2.7 + MyBatis-Plus)
|
||||||
- 路由渠道:`RouteChannel.MP_API`(`/mp-api`)
|
- 路由渠道:`RouteChannel.MP_API`(`/mp-api`)
|
||||||
- 认证方式:Bearer Token(`Authorization: Bearer {accessToken}`)
|
- 认证方式:Bearer Token(`Authorization: Bearer {accessToken}`)
|
||||||
- 响应格式:`{ errcode, errmsg, data, traceid }`(框架自动包装 `ResDto`)
|
- 响应格式:`{ code, msg, data, errcode, errmsg }`(`code === 200` 或 `errcode === 0` 视为成功)
|
||||||
- 用户表:`member` 表,`channel = 99`(Channel_CY,产研小程序)
|
- 用户表:`member` 表,`channel = 99`(Channel_CY,产研小程序)
|
||||||
- Token 表:`system_oauth2_access_token`,`clientId = "mp-99"`
|
- Token 表:`system_oauth2_access_token`,`clientId = "mp-99"`
|
||||||
|
|
||||||
### 微信登录流程
|
### Mock 系统
|
||||||
|
|
||||||
```
|
通过环境变量 `VITE_USE_MOCK=true` 启用。各页面在 `onLoad` 中检查 `USE_MOCK` 标志,为 true 时直接从 mock 文件渲染数据,不发起网络请求。
|
||||||
前端 button[open-type=getPhoneNumber]
|
|
||||||
→ phoneCode + wx.login() → loginCode
|
|
||||||
→ POST /mp-api/auth/wx-login { loginCode, phoneCode }
|
|
||||||
→ 后端 jscode2session → openId
|
|
||||||
→ 后端 getuserphonenumber → phoneNumber
|
|
||||||
→ 查找/创建 member → 创建 token
|
|
||||||
→ 返回 LoginResponse { accessToken, refreshToken, userId, openId, mobile }
|
|
||||||
```
|
|
||||||
|
|
||||||
## 运行命令
|
## 设计系统
|
||||||
|
|
||||||
```bash
|
样式体系基于 **uv-ui 设计系统**(`uv-*` 变量命名),通过 `uni.scss` 全局注入 SCSS 变量,每个组件无需额外 import 即可使用。
|
||||||
# 安装依赖
|
|
||||||
pnpm install
|
|
||||||
|
|
||||||
# 微信小程序开发构建
|
### 核心变量
|
||||||
pnpm run dev:mp-weixin
|
|
||||||
|
|
||||||
# 微信小程序生产构建
|
| 类别 | 变量前缀 | 示例 |
|
||||||
pnpm run build:mp-weixin
|
|------|----------|------|
|
||||||
|
| 品牌色 | `$uv-primary` / `$color-accent` | `#3c9cff` |
|
||||||
|
| 语义色 | `$uv-error` / `$uv-success` / `$uv-warning` | 红/绿/橙 |
|
||||||
|
| 中性色 | `$uv-main-color` / `$uv-bg-color` / `$uv-border-color` | 黑/灰/白 |
|
||||||
|
| 字体 | `$font-family-display` / `$font-family-body` | 思源宋体 / PingFang |
|
||||||
|
| 字号 | `$font-size-*` | `xxs: 18rpx` ~ `display: 72rpx` |
|
||||||
|
| 间距 | `$spacing-*` | `0` ~ `160rpx` |
|
||||||
|
| 圆角 | `$radius-*` | `sm: 8rpx` ~ `full: 9999rpx` |
|
||||||
|
| 阴影 | `$shadow-*` | `sm` ~ `xl` + `accent` |
|
||||||
|
|
||||||
# TypeScript 类型检查
|
### 字体
|
||||||
pnpm run type-check
|
|
||||||
|
|
||||||
# 构建产物目录
|
- 展示字体:`SourceHanSerifCN`(思源宋体),用于标题
|
||||||
dist/build/mp-weixin/ # 用微信开发者工具导入此目录
|
- 正文字体:系统字体栈(`-apple-system, PingFang SC, Helvetica Neue`)
|
||||||
```
|
- 等宽字体:`Menlo, Monaco, Courier New`
|
||||||
|
|
||||||
|
### 样式文件
|
||||||
|
|
||||||
|
- `uni.scss` — **source of truth**,自动注入到每个组件
|
||||||
|
- `src/styles/_tokens.scss` — 兼容层,对齐 `uni.scss` 的值,供 `main.scss` 使用
|
||||||
|
- `src/styles/main.scss` — 全局样式入口,在 `App.vue` 中通过 `@use` 导入
|
||||||
|
- 另外还有 `_mixins.scss`、`_animations.scss`、`_utilities.scss`
|
||||||
|
|
||||||
## 环境配置
|
## 环境配置
|
||||||
|
|
||||||
| 文件 | API 地址 | 用途 |
|
| 文件 | API 地址 | 用途 |
|
||||||
|------|---------|------|
|
|------|---------|------|
|
||||||
| `.env.local` | `http://localhost:48080` | 本地开发(配合后端 `--spring.profiles.active=local`) |
|
| `.env.local` | `http://localhost:48080` | 本地开发(配合后端 local profile) |
|
||||||
| `.env.dev` | `https://dscp-dev.hn12301.net` | 开发环境 |
|
| `.env.dev` | `https://dscp-dev.hn12301.net` | 开发环境 |
|
||||||
| `.env.uat` | `https://dscp-uat.hn12301.net` | UAT 测试 |
|
| `.env.uat` | `https://dscp-uat.hn12301.net` | UAT 测试 |
|
||||||
|
|
||||||
## 数据库环境
|
## Git 远程
|
||||||
|
|
||||||
| 环境 | 地址 | 数据库 |
|
- `gitee` → `https://gitee.com/jacobxu666/zhy-mp-test.git`(唯一远程,`main` 跟踪此分支)
|
||||||
|------|------|--------|
|
- 当前分支:`xuchao`
|
||||||
| 本地(local profile) | `localhost:3306` | `dscp_business_dev` |
|
|
||||||
| 开发(dev) | `120.26.254.65:3306` | `dscp_business_dev` |
|
|
||||||
| UAT | `47.99.164.129:3306` | `dscp_business` |
|
|
||||||
|
|
||||||
## 开发注意事项
|
## 开发注意事项
|
||||||
|
|
||||||
1. **API 前缀**:所有接口走 `/mp-api`(不是 `/manage-api`,那是管理后台)
|
1. **API 前缀**:所有接口走 `/mp-api`(不是 `/manage-api`,那是管理后台)
|
||||||
2. **Token 管理**:`app_token` / `app_refresh_token` 存在 `uni.storage` 中
|
2. **Token 管理**:`app_token` 存在 `uni.storage` 中,通过 `request.ts` 的 `getToken/setToken/removeToken` 管理
|
||||||
3. **分包策略**:年卡功能放分包(`packages/user`),首页/登录/个人中心在主包
|
3. **分包策略**:年卡/订单/预约放 `packages/user`,景区内容放 `packages/content`,主包仅放首页/登录/个人中心/入园码
|
||||||
4. **条件编译**:`#ifdef MP-WEIXIN` 包裹微信特有逻辑
|
4. **条件编译**:`#ifdef MP-WEIXIN` 包裹微信特有逻辑
|
||||||
5. **隐私协议**:`App.vue` 中 `wx.getPrivacySetting` 检查,未同意前不能调授权 API
|
5. **隐私协议**:`App.vue` 中 `wx.getPrivacySetting` 检查,未同意前不能调授权 API
|
||||||
6. **微信开发者工具调试**:需勾选「不校验合法域名」才能请求 localhost
|
6. **微信开发者工具调试**:需勾选「不校验合法域名」才能请求 localhost
|
||||||
|
7. **Mock 模式**:设置 `VITE_USE_MOCK=true` 可离线开发,不依赖后端
|
||||||
<frontend_aesthetics>
|
8. **样式对齐**:`uni.scss` 是设计令牌的 source of truth,`src/styles/_tokens.scss` 仅供 `main.scss` 兼容使用,新增样式变量优先在 `uni.scss` 中定义
|
||||||
You tend to converge toward generic, "on distribution" outputs.
|
|
||||||
In frontend design, this creates what users call the "AI slop"
|
|
||||||
aesthetic. Avoid this: make creative, distinctive frontends that
|
|
||||||
surprise and delight. Focus on:
|
|
||||||
|
|
||||||
Typography: Choose fonts that are beautiful, unique, and interesting.
|
|
||||||
Avoid generic fonts like Arial and Inter; opt instead for distinctive
|
|
||||||
choices that elevate the frontend's aesthetics.
|
|
||||||
|
|
||||||
Color & Theme: Commit to a cohesive aesthetic. Use CSS variables for
|
|
||||||
consistency. Dominant colors with sharp accents outperform timid,
|
|
||||||
evenly-distributed palettes.
|
|
||||||
|
|
||||||
Motion: Use animations for effects and micro-interactions.
|
|
||||||
Focus on high-impact moments: one well-orchestrated page load
|
|
||||||
with staggered reveals creates more delight than scattered
|
|
||||||
micro-interactions.
|
|
||||||
|
|
||||||
Backgrounds: Create atmosphere and depth rather than defaulting
|
|
||||||
to solid colors. Layer CSS gradients, use geometric patterns,
|
|
||||||
or add contextual effects.
|
|
||||||
|
|
||||||
Avoid generic AI-generated aesthetics:
|
|
||||||
- Overused font families (Inter, Roboto, Arial, system fonts)
|
|
||||||
- Clichéd color schemes (particularly purple gradients on white)
|
|
||||||
- Predictable layouts and component patterns
|
|
||||||
- Cookie-cutter design that lacks context-specific character
|
|
||||||
|
|
||||||
Interpret creatively and make unexpected choices that feel
|
|
||||||
genuinely designed for the context.
|
|
||||||
</frontend_aesthetics>
|
|
||||||
|
|
||||||
<use_interesting_fonts>
|
|
||||||
Typography instantly signals quality. Avoid boring, generic fonts.
|
|
||||||
|
|
||||||
Never use: Inter, Roboto, Open Sans, Lato, default system fonts
|
|
||||||
|
|
||||||
Impact choices:
|
|
||||||
- Code aesthetic: JetBrains Mono, Fira Code, Space Grotesk
|
|
||||||
- Editorial: Playfair Display, Crimson Pro, Fraunces
|
|
||||||
- Startup: Clash Display, Satoshi, Cabinet Grotesk
|
|
||||||
- Technical: IBM Plex family, Source Sans 3
|
|
||||||
- Distinctive: Bricolage Grotesque, Obviously, Newsreader
|
|
||||||
|
|
||||||
Pairing principle: High contrast = interesting.
|
|
||||||
Display + monospace, serif + geometric sans.
|
|
||||||
|
|
||||||
Use extremes: 100/200 weight vs 800/900, not 400 vs 600.
|
|
||||||
Size jumps of 3x+, not 1.5x.
|
|
||||||
</use_interesting_fonts>
|
|
||||||
|
|
||||||
# Design System
|
|
||||||
|
|
||||||
Always refer to DESIGN.md when generating or modifying any UI component.
|
|
||||||
Use only colors, fonts, and spacing values defined in DESIGN.md.
|
|
||||||
Do not invent new values or use defaults from any framework.
|
|
||||||
Match component states (hover, focus, active, disabled) to patterns
|
|
||||||
in DESIGN.md.
|
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// xuchao: 抖音核销接口层。接口契约依赖后端 /mp-api/douyin/*;后端尚未提供时由 DOUYIN_BACKEND_READY 控制直接返回模拟数据以便测试。
|
||||||
|
// xuchao: 后端就绪后只需将 DOUYIN_BACKEND_READY 置为 true,即可走真实请求(无需改其它代码)。
|
||||||
|
import { post } from '../../../api/request'
|
||||||
|
|
||||||
|
// xuchao: 后端 /mp-api/douyin/* 是否已就绪。false=用模拟数据测试;true=走真实后端。
|
||||||
|
const DOUYIN_BACKEND_READY = false
|
||||||
|
|
||||||
|
/** 券码实名信息(用于回填绑卡表单) */
|
||||||
|
export interface VoucherRealnameInfo {
|
||||||
|
voucher_id: string
|
||||||
|
realname: string
|
||||||
|
card_id: string
|
||||||
|
/** 证件类型文案,如「身份证」 */
|
||||||
|
card_type: string
|
||||||
|
mobile: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单张券码(对齐小泥人 voucher-list 的 ret 元素) */
|
||||||
|
export interface VoucherItem {
|
||||||
|
id: string
|
||||||
|
order_id: string
|
||||||
|
/** 券码,列表展示与提交均使用此值 */
|
||||||
|
code: string
|
||||||
|
/** 1=未使用 3=已使用 5=已取消 */
|
||||||
|
status: number
|
||||||
|
mobile: string
|
||||||
|
product_name: string
|
||||||
|
merchant_name: string
|
||||||
|
voucherRealnameInfo?: VoucherRealnameInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryVoucherListParams {
|
||||||
|
mobile: string
|
||||||
|
/** 1=未使用,默认查未使用 */
|
||||||
|
status?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivateAndBindParams {
|
||||||
|
voucherId: string
|
||||||
|
voucherCode: string
|
||||||
|
quantity: number
|
||||||
|
mobile: string
|
||||||
|
userName: string
|
||||||
|
idType: string
|
||||||
|
idNo: string
|
||||||
|
faceImg: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
|
||||||
|
|
||||||
|
// xuchao: 模拟券码数据(后端未就绪时使用),含一条带实名信息用于验证回填。
|
||||||
|
const MOCK_VOUCHERS: VoucherItem[] = [
|
||||||
|
{
|
||||||
|
id: '1001',
|
||||||
|
order_id: 'DY202607280001',
|
||||||
|
code: '20260728192093019',
|
||||||
|
status: 1,
|
||||||
|
mobile: '',
|
||||||
|
product_name: '赣湘全域旅游年卡',
|
||||||
|
merchant_name: '爱上萍乡',
|
||||||
|
voucherRealnameInfo: [
|
||||||
|
{ voucher_id: '1001', realname: '张三', card_id: '360302199001011234', card_type: '身份证', mobile: '' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '1002',
|
||||||
|
order_id: 'DY202607280002',
|
||||||
|
code: '20260728192093011',
|
||||||
|
status: 1,
|
||||||
|
mobile: '',
|
||||||
|
product_name: '赣湘全域旅游年卡',
|
||||||
|
merchant_name: '爱上萍乡',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* xuchao: 查询当前手机号可核销的券码列表。
|
||||||
|
* 后端接口:POST /mp-api/douyin/voucherList,入参 { mobile, status }。
|
||||||
|
*/
|
||||||
|
export async function queryVoucherList(params: QueryVoucherListParams): Promise<VoucherItem[]> {
|
||||||
|
if (!DOUYIN_BACKEND_READY) {
|
||||||
|
await delay(300)
|
||||||
|
return MOCK_VOUCHERS.map((v) => ({ ...v, mobile: params.mobile }))
|
||||||
|
}
|
||||||
|
return post<VoucherItem[]>('/mp-api/douyin/voucherList', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* xuchao: 激活且绑定(核销)。
|
||||||
|
* 后端接口:POST /mp-api/douyin/activateAndBind,入参见 ActivateAndBindParams。
|
||||||
|
*/
|
||||||
|
export async function activateAndBind(params: ActivateAndBindParams): Promise<void> {
|
||||||
|
if (!DOUYIN_BACKEND_READY) {
|
||||||
|
await delay(800)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await post<null>('/mp-api/douyin/activateAndBind', params)
|
||||||
|
}
|
||||||
@@ -0,0 +1,863 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<!-- 头部:装饰渐变 + 标题 -->
|
||||||
|
<view class="hero">
|
||||||
|
<text class="hero-title">抖音购卡订单</text>
|
||||||
|
<view class="hero-pill">
|
||||||
|
<text class="hero-pill-text">订单核销</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<scroll-view scroll-y class="body">
|
||||||
|
<!-- 第 1 步:确认抖音下单信息 -->
|
||||||
|
<view class="section">
|
||||||
|
<view class="step-head">
|
||||||
|
<text class="step-no">第 1 步:</text>
|
||||||
|
<text class="step-title">确认抖音下单信息</text>
|
||||||
|
</view>
|
||||||
|
<text class="step-desc">
|
||||||
|
请确认在抖音平台认证的号码与微信手机号一致,如号码不一致请在抖音订单详情页面复制券号,并粘贴在券号输入框
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<view class="field">
|
||||||
|
<text class="field-label">当前微信手机号:</text>
|
||||||
|
<view class="field-box">
|
||||||
|
<text class="field-value">{{ maskedPhone }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="field">
|
||||||
|
<text class="field-label">抖音的订单券号:</text>
|
||||||
|
|
||||||
|
<!-- 有券号:单选列表 -->
|
||||||
|
<template v-if="hasVoucher">
|
||||||
|
<text class="hint-red">当前微信手机号,可核销券号如下。请选择券号核销</text>
|
||||||
|
<view
|
||||||
|
v-for="item in vouchers"
|
||||||
|
:key="item.id"
|
||||||
|
class="field-box field-box--row"
|
||||||
|
@tap="selectVoucher(item)"
|
||||||
|
>
|
||||||
|
<text class="code-text">{{ item.code }}</text>
|
||||||
|
<view class="radio" :class="{ 'radio--on': selectedId === item.id }">
|
||||||
|
<view v-if="selectedId === item.id" class="radio-dot" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 无券号:手输券号 -->
|
||||||
|
<template v-else>
|
||||||
|
<text class="hint-red">无可核销订单!请输入订单券号核销</text>
|
||||||
|
<view class="field-box">
|
||||||
|
<input
|
||||||
|
class="field-input"
|
||||||
|
v-model="manualCode"
|
||||||
|
type="number"
|
||||||
|
placeholder="请粘贴抖音订单券号"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 第 2 步:填写绑卡信息 -->
|
||||||
|
<view class="section">
|
||||||
|
<view class="step-head">
|
||||||
|
<text class="step-no">第 2 步:</text>
|
||||||
|
<text class="step-title">填写绑卡信息</text>
|
||||||
|
</view>
|
||||||
|
<text class="step-desc">只能绑定当前微信手机号,若需绑定其他请使用其他号码登录</text>
|
||||||
|
|
||||||
|
<view class="field">
|
||||||
|
<text class="field-label">绑卡人姓名:</text>
|
||||||
|
<view class="field-box">
|
||||||
|
<input
|
||||||
|
class="field-input"
|
||||||
|
v-model="form.userName"
|
||||||
|
placeholder="请输入持卡人真实姓名"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="field">
|
||||||
|
<text class="field-label">证件类型:</text>
|
||||||
|
<view class="field-box field-box--row" @tap="showIdTypePicker = true">
|
||||||
|
<text class="field-value field-value--black">{{ idTypeLabel }}</text>
|
||||||
|
<view class="chevron-down" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="field">
|
||||||
|
<text class="field-label">证件号码:</text>
|
||||||
|
<view class="field-box">
|
||||||
|
<input
|
||||||
|
class="field-input"
|
||||||
|
v-model="form.idNo"
|
||||||
|
:placeholder="idPlaceholder"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
:maxlength="docType === '1' ? 18 : 30"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="field">
|
||||||
|
<text class="field-label">人脸信息:</text>
|
||||||
|
<view class="face-row">
|
||||||
|
<view class="face-box" @tap="faceImg ? previewFace() : chooseImage()">
|
||||||
|
<image v-if="faceImg" class="face-preview" :src="faceImg" mode="aspectFill" />
|
||||||
|
<text v-else class="face-plus">+</text>
|
||||||
|
<view v-if="faceImg" class="face-remove" @tap.stop="faceImg = ''">
|
||||||
|
<view class="face-remove__x" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="face-tips">
|
||||||
|
<text>录入人脸仅用于园区入园身份核验。</text>
|
||||||
|
<text>禁止上传艺术写真、美颜滤镜修图照片。</text>
|
||||||
|
<text>建议拍摄光线均匀、五官完整的正面照。</text>
|
||||||
|
<text>面部无遮挡,不要逆光、画面昏暗。</text>
|
||||||
|
<text>照片不达标会导致入园识别失败。</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 核销&绑定规则 -->
|
||||||
|
<view class="rules">
|
||||||
|
<view class="rules-line" />
|
||||||
|
<text class="rules-title">核销&绑定规则</text>
|
||||||
|
<view class="rules-body">
|
||||||
|
<text>1.旅游年卡核销成功后,券码失效,不可再次兑换不可退款。</text>
|
||||||
|
<text>2.核销绑定只能绑定在当前微信手机号。</text>
|
||||||
|
<text>3.若需要绑定其他手机号,请用其他号码登录。</text>
|
||||||
|
<text>4.人脸照片请上传人脸正面照片,不要使用艺术照。</text>
|
||||||
|
<text>5.如有疑问请联系客服:0731-88912301</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="bottom-placeholder" />
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- 底部固定按钮 -->
|
||||||
|
<view class="footer-bar">
|
||||||
|
<view class="footer-btn" :class="{ 'footer-btn--disabled': submitting }" @tap="onConfirmVerify">
|
||||||
|
<text class="footer-btn-text">确认核销</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 购卡须知弹窗:内容为单张可滑动图片 -->
|
||||||
|
<view v-if="showNotice" class="modal-mask" @tap="showNotice = false">
|
||||||
|
<view class="modal-panel" @tap.stop>
|
||||||
|
<text class="modal-title">购卡须知</text>
|
||||||
|
<scroll-view scroll-y class="modal-scroll">
|
||||||
|
<image
|
||||||
|
class="modal-img"
|
||||||
|
src="https://aliyunoss.hn12301.net/pxmall/aspx.png"
|
||||||
|
mode="widthFix"
|
||||||
|
@tap="previewNotice"
|
||||||
|
/>
|
||||||
|
</scroll-view>
|
||||||
|
<view class="modal-divider" />
|
||||||
|
<view class="modal-footer">
|
||||||
|
<view class="modal-foot-btn" @tap="showNotice = false">
|
||||||
|
<text class="modal-foot-text">取消</text>
|
||||||
|
</view>
|
||||||
|
<view class="modal-foot-split" />
|
||||||
|
<view class="modal-foot-btn" @tap="doActivate">
|
||||||
|
<text class="modal-foot-text modal-foot-text--red">确认核销</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 证件类型选择(与绑卡页一致) -->
|
||||||
|
<uni-popup
|
||||||
|
ref="idTypePopup"
|
||||||
|
type="bottom"
|
||||||
|
:mask-click="true"
|
||||||
|
mask-background="transparent"
|
||||||
|
@change="(e: any) => (showIdTypePicker = e.show)"
|
||||||
|
>
|
||||||
|
<view class="popup-sheet">
|
||||||
|
<view class="popup-header">
|
||||||
|
<text class="popup-title">选择证件类型</text>
|
||||||
|
<view class="popup-close" @tap="showIdTypePicker = false">
|
||||||
|
<view class="popup-close__icon" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="id-type-list">
|
||||||
|
<view
|
||||||
|
v-for="item in idTypeOptions"
|
||||||
|
:key="item.value"
|
||||||
|
class="id-type-item"
|
||||||
|
:class="{ 'id-type-item--active': docType === item.value }"
|
||||||
|
@tap="selectIdType(item.value)"
|
||||||
|
>
|
||||||
|
<text class="id-type-item__text">{{ item.label }}</text>
|
||||||
|
<view v-if="docType === item.value" class="id-type-item__check" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</uni-popup>
|
||||||
|
|
||||||
|
<!-- 隐藏 canvas,用于图片压缩 -->
|
||||||
|
<canvas
|
||||||
|
canvas-id="dyCanvas"
|
||||||
|
id="dyCanvas"
|
||||||
|
style="position:absolute;left:0;top:-10000rpx;width:375px;height:375px;z-index:-1;opacity:0"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed, watch } from 'vue'
|
||||||
|
import { onShow } from '@dcloudio/uni-app'
|
||||||
|
import { useUserStore } from '../../../store/user'
|
||||||
|
import { uploadImage } from '../api/card'
|
||||||
|
import {
|
||||||
|
queryVoucherList,
|
||||||
|
activateAndBind,
|
||||||
|
type VoucherItem,
|
||||||
|
} from '../api/douyin'
|
||||||
|
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const phone = ref((uni.getStorageSync('phone') as string) || '')
|
||||||
|
const maskedPhone = computed(() =>
|
||||||
|
phone.value ? phone.value.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : '-'
|
||||||
|
)
|
||||||
|
|
||||||
|
const vouchers = ref<VoucherItem[]>([])
|
||||||
|
const selectedId = ref('')
|
||||||
|
const manualCode = ref('')
|
||||||
|
const hasVoucher = computed(() => vouchers.value.length > 0)
|
||||||
|
const selectedVoucher = computed(() => vouchers.value.find((v) => v.id === selectedId.value) || null)
|
||||||
|
|
||||||
|
const form = reactive({ userName: '', idNo: '' })
|
||||||
|
const faceImg = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
|
const showNotice = ref(false)
|
||||||
|
|
||||||
|
// xuchao: 证件类型与绑卡页 bind.vue 保持一致
|
||||||
|
const idTypeOptions = [
|
||||||
|
{ value: '1', label: '身份证' },
|
||||||
|
{ value: '2', label: '护照' },
|
||||||
|
{ value: '3', label: '港澳居民来往内地通行证' },
|
||||||
|
{ value: '4', label: '台湾居民来往大陆通行证' },
|
||||||
|
]
|
||||||
|
const docType = ref('1')
|
||||||
|
const idTypeLabel = computed(() => idTypeOptions.find((i) => i.value === docType.value)?.label || '')
|
||||||
|
const idPlaceholder = computed(() => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'1': '请输入身份证号码',
|
||||||
|
'2': '请输入护照号码',
|
||||||
|
'3': '请输入通行证号码',
|
||||||
|
'4': '请输入台胞证号码',
|
||||||
|
}
|
||||||
|
return map[docType.value] || '请输入证件号码'
|
||||||
|
})
|
||||||
|
const showIdTypePicker = ref(false)
|
||||||
|
const idTypePopup = ref<any>(null)
|
||||||
|
watch(showIdTypePicker, (v) => (v ? idTypePopup.value?.open() : idTypePopup.value?.close()))
|
||||||
|
function selectIdType(value: string) {
|
||||||
|
docType.value = value
|
||||||
|
showIdTypePicker.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
phone.value = (uni.getStorageSync('phone') as string) || ''
|
||||||
|
if (!userStore.isLogin) {
|
||||||
|
uni.navigateTo({ url: '/pages/login/login' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fetchVouchers()
|
||||||
|
})
|
||||||
|
|
||||||
|
/** xuchao: 按当前手机号查询可核销券码(status=1 未使用) */
|
||||||
|
async function fetchVouchers() {
|
||||||
|
if (!phone.value) {
|
||||||
|
vouchers.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
vouchers.value = await queryVoucherList({ mobile: phone.value, status: 1 })
|
||||||
|
} catch {
|
||||||
|
vouchers.value = []
|
||||||
|
}
|
||||||
|
selectedId.value = ''
|
||||||
|
manualCode.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** xuchao: 选中券码;若带实名信息则回填绑卡表单(可编辑) */
|
||||||
|
function selectVoucher(item: VoucherItem) {
|
||||||
|
selectedId.value = item.id
|
||||||
|
const info = item.voucherRealnameInfo?.[0]
|
||||||
|
if (info) {
|
||||||
|
if (info.realname) form.userName = info.realname
|
||||||
|
if (info.card_id) form.idNo = info.card_id
|
||||||
|
const matched = idTypeOptions.find((o) => o.label === info.card_type)
|
||||||
|
if (matched) docType.value = matched.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前用于提交的券码:有券取选中项,无券取手输 */
|
||||||
|
function currentCode(): string {
|
||||||
|
return hasVoucher.value ? selectedVoucher.value?.code || '' : manualCode.value.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** xuchao: 校验表单,返回错误文案,空串表示通过 */
|
||||||
|
function validate(): string {
|
||||||
|
if (hasVoucher.value) {
|
||||||
|
if (!selectedId.value) return '请选择要核销的券号'
|
||||||
|
} else {
|
||||||
|
const code = manualCode.value.trim()
|
||||||
|
if (!code) return '请输入订单券号'
|
||||||
|
if (!/^\d+$/.test(code)) return '券号格式不正确,仅限数字'
|
||||||
|
}
|
||||||
|
if (!form.userName.trim()) return '请输入绑卡人姓名'
|
||||||
|
if (!form.idNo.trim()) return '请输入证件号码'
|
||||||
|
if (docType.value === '1' && !/^\d{17}[\dXx]$/.test(form.idNo.trim())) return '身份证号格式不正确'
|
||||||
|
if (!faceImg.value) return '请上传人脸照片'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击底部确认核销:校验通过后弹出购卡须知 */
|
||||||
|
function onConfirmVerify() {
|
||||||
|
if (submitting.value) return
|
||||||
|
const err = validate()
|
||||||
|
if (err) {
|
||||||
|
uni.showToast({ title: err, icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showNotice.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 弹窗内确认核销:调用激活且绑定接口 */
|
||||||
|
async function doActivate() {
|
||||||
|
if (submitting.value) return
|
||||||
|
submitting.value = true
|
||||||
|
uni.showLoading({ title: '核销中...', mask: true })
|
||||||
|
try {
|
||||||
|
await activateAndBind({
|
||||||
|
voucherId: selectedVoucher.value?.id || '',
|
||||||
|
voucherCode: currentCode(),
|
||||||
|
quantity: 1,
|
||||||
|
mobile: phone.value,
|
||||||
|
userName: form.userName.trim(),
|
||||||
|
idType: docType.value,
|
||||||
|
idNo: form.idNo.trim(),
|
||||||
|
faceImg: faceImg.value,
|
||||||
|
})
|
||||||
|
uni.hideLoading()
|
||||||
|
showNotice.value = false
|
||||||
|
uni.showToast({ title: '核销成功', icon: 'success' })
|
||||||
|
setTimeout(() => uni.switchTab({ url: '/pages/qrcode/index' }), 1200)
|
||||||
|
} catch (e) {
|
||||||
|
// xuchao: 真实请求的错误提示由 request.ts 统一弹出,此处仅收尾,避免重复 toast
|
||||||
|
console.error('[douyin] activateAndBind failed:', e)
|
||||||
|
uni.hideLoading()
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 选择人脸照片 */
|
||||||
|
function chooseImage() {
|
||||||
|
uni.chooseImage({
|
||||||
|
count: 1,
|
||||||
|
sizeType: ['original'],
|
||||||
|
sourceType: ['album', 'camera'],
|
||||||
|
success: (res) => handleImage(res.tempFilePaths[0]),
|
||||||
|
fail: (err) => {
|
||||||
|
if (!err.errMsg.includes('cancel')) console.log('选择图片失败:', err)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImage(filePath: string) {
|
||||||
|
uni.showLoading({ title: '处理中...' })
|
||||||
|
try {
|
||||||
|
const compressed = await compressImage(filePath)
|
||||||
|
uni.showLoading({ title: '上传中...' })
|
||||||
|
faceImg.value = await uploadImage(compressed)
|
||||||
|
uni.showToast({ title: '上传成功', icon: 'success' })
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '上传失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
uni.hideLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** xuchao: 等比压缩到 375px 内,复用绑卡页思路 */
|
||||||
|
function compressImage(filePath: string): Promise<string> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const maxSize = 375
|
||||||
|
uni.getImageInfo({
|
||||||
|
src: filePath,
|
||||||
|
success: (info) => {
|
||||||
|
let { width, height } = info
|
||||||
|
if (width <= maxSize && height <= maxSize) {
|
||||||
|
resolve(filePath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (width > height) {
|
||||||
|
height = Math.round((height / width) * maxSize)
|
||||||
|
width = maxSize
|
||||||
|
} else {
|
||||||
|
width = Math.round((width / height) * maxSize)
|
||||||
|
height = maxSize
|
||||||
|
}
|
||||||
|
const ctx = uni.createCanvasContext('dyCanvas')
|
||||||
|
ctx.drawImage(filePath, 0, 0, width, height)
|
||||||
|
ctx.draw(false, () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.canvasToTempFilePath({
|
||||||
|
canvasId: 'dyCanvas',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
destWidth: width,
|
||||||
|
destHeight: height,
|
||||||
|
quality: 0.8,
|
||||||
|
success: (r) => resolve(r.tempFilePath),
|
||||||
|
fail: () => resolve(filePath),
|
||||||
|
})
|
||||||
|
}, 200)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
fail: () => resolve(filePath),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewFace() {
|
||||||
|
if (faceImg.value) uni.previewImage({ urls: [faceImg.value] })
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewNotice() {
|
||||||
|
uni.previewImage({ urls: ['https://aliyunoss.hn12301.net/pxmall/aspx.png'] })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #fff;
|
||||||
|
font-family: $font-family-body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Hero ===== */
|
||||||
|
.hero {
|
||||||
|
padding: 48rpx 40rpx 56rpx;
|
||||||
|
background: linear-gradient(180deg, #fff4e6 0%, #fdeede 55%, #fff 100%);
|
||||||
|
}
|
||||||
|
.hero-title {
|
||||||
|
display: block;
|
||||||
|
font-family: $font-family-display;
|
||||||
|
font-size: 48rpx;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
color: $uv-main-color;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.hero-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
padding: 12rpx 24rpx;
|
||||||
|
border-radius: 30rpx 0 0 10rpx;
|
||||||
|
background: linear-gradient(90deg, #fac28a 0%, #ff4c17 100%);
|
||||||
|
}
|
||||||
|
.hero-pill-text {
|
||||||
|
font-family: $font-family-display;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Body / Section ===== */
|
||||||
|
.body {
|
||||||
|
height: calc(100vh - 240rpx);
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
padding: 8rpx 32rpx 24rpx;
|
||||||
|
}
|
||||||
|
.step-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
.step-no {
|
||||||
|
font-family: $font-family-display;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
color: $uv-primary;
|
||||||
|
}
|
||||||
|
.step-title {
|
||||||
|
font-family: $font-family-display;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
color: $uv-main-color;
|
||||||
|
}
|
||||||
|
.step-desc {
|
||||||
|
display: block;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $uv-tips-color;
|
||||||
|
line-height: 36rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Fields ===== */
|
||||||
|
.field {
|
||||||
|
margin-top: 28rpx;
|
||||||
|
}
|
||||||
|
.field-label {
|
||||||
|
display: block;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $uv-main-color;
|
||||||
|
line-height: 48rpx;
|
||||||
|
}
|
||||||
|
.hint-red {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6rpx;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: #ff383c;
|
||||||
|
line-height: 48rpx;
|
||||||
|
}
|
||||||
|
.field-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 80rpx;
|
||||||
|
padding: 16rpx 24rpx;
|
||||||
|
background: $uv-bg-color;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
}
|
||||||
|
.field-box + .field-box {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
.field-box--row {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.field-value {
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $uv-tips-color;
|
||||||
|
}
|
||||||
|
.field-value--black {
|
||||||
|
color: $uv-main-color;
|
||||||
|
}
|
||||||
|
.field-input {
|
||||||
|
flex: 1;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $uv-main-color;
|
||||||
|
}
|
||||||
|
.placeholder {
|
||||||
|
color: $uv-placeholder-color;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
}
|
||||||
|
.code-text {
|
||||||
|
flex: 1;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $uv-tips-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Radio ===== */
|
||||||
|
.radio {
|
||||||
|
width: 34rpx;
|
||||||
|
height: 34rpx;
|
||||||
|
border: 2rpx solid $uv-light-color;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.radio--on {
|
||||||
|
border-color: $uv-primary;
|
||||||
|
}
|
||||||
|
.radio-dot {
|
||||||
|
width: 18rpx;
|
||||||
|
height: 18rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $uv-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Chevron down ===== */
|
||||||
|
.chevron-down {
|
||||||
|
width: 14rpx;
|
||||||
|
height: 14rpx;
|
||||||
|
border-top: 2rpx solid $uv-placeholder-color;
|
||||||
|
border-right: 2rpx solid $uv-placeholder-color;
|
||||||
|
transform: rotate(135deg);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Face upload ===== */
|
||||||
|
.face-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
.face-box {
|
||||||
|
position: relative;
|
||||||
|
width: 170rpx;
|
||||||
|
height: 170rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: $uv-bg-color;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.face-plus {
|
||||||
|
font-size: 56rpx;
|
||||||
|
color: $uv-placeholder-color;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.face-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.face-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: 8rpx;
|
||||||
|
right: 8rpx;
|
||||||
|
width: 36rpx;
|
||||||
|
height: 36rpx;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.face-remove__x {
|
||||||
|
position: relative;
|
||||||
|
width: 16rpx;
|
||||||
|
height: 16rpx;
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 16rpx;
|
||||||
|
height: 2rpx;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
&::before {
|
||||||
|
transform: translate(-50%, -50%) rotate(45deg);
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
transform: translate(-50%, -50%) rotate(-45deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.face-tips {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
text {
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $uv-tips-color;
|
||||||
|
line-height: 36rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Rules ===== */
|
||||||
|
.rules {
|
||||||
|
margin: 16rpx 32rpx 0;
|
||||||
|
}
|
||||||
|
.rules-line {
|
||||||
|
height: 1rpx;
|
||||||
|
background: $uv-border-color;
|
||||||
|
}
|
||||||
|
.rules-title {
|
||||||
|
display: block;
|
||||||
|
margin-top: 24rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-family: $font-family-display;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
color: $uv-main-color;
|
||||||
|
}
|
||||||
|
.rules-body {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
text {
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $uv-tips-color;
|
||||||
|
line-height: 40rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-placeholder {
|
||||||
|
height: 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Footer bar ===== */
|
||||||
|
.footer-bar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: $z-sticky;
|
||||||
|
background: $uv-primary;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
.footer-btn {
|
||||||
|
height: 88rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.footer-btn--disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.footer-btn-text {
|
||||||
|
font-family: $font-family-display;
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Notice modal ===== */
|
||||||
|
.modal-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: $z-modal;
|
||||||
|
background: rgba(120, 120, 120, 0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.modal-panel {
|
||||||
|
width: 662rpx;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.modal-title {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
font-family: $font-family-display;
|
||||||
|
font-size: $font-size-lg;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
color: $uv-main-color;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
.modal-scroll {
|
||||||
|
height: 700rpx;
|
||||||
|
}
|
||||||
|
.modal-img {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.modal-divider {
|
||||||
|
height: 1rpx;
|
||||||
|
background: $uv-border-color;
|
||||||
|
margin-top: 24rpx;
|
||||||
|
}
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.modal-foot-btn {
|
||||||
|
flex: 1;
|
||||||
|
height: 88rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.modal-foot-split {
|
||||||
|
width: 1rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
background: $uv-border-color;
|
||||||
|
}
|
||||||
|
.modal-foot-text {
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $uv-main-color;
|
||||||
|
&--red {
|
||||||
|
color: #ff383c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== ID type popup ===== */
|
||||||
|
.popup-sheet {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 24rpx 24rpx 0 0;
|
||||||
|
max-height: 70vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.popup-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 32rpx;
|
||||||
|
border-bottom: 1rpx solid $uv-border-color;
|
||||||
|
}
|
||||||
|
.popup-title {
|
||||||
|
font-size: $font-size-base;
|
||||||
|
font-weight: $font-weight-bold;
|
||||||
|
color: $uv-main-color;
|
||||||
|
}
|
||||||
|
.popup-close {
|
||||||
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.popup-close__icon {
|
||||||
|
position: relative;
|
||||||
|
width: 20rpx;
|
||||||
|
height: 20rpx;
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 20rpx;
|
||||||
|
height: 3rpx;
|
||||||
|
background: $uv-tips-color;
|
||||||
|
}
|
||||||
|
&::before {
|
||||||
|
transform: translate(-50%, -50%) rotate(45deg);
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
transform: translate(-50%, -50%) rotate(-45deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.id-type-list {
|
||||||
|
padding: 16rpx 0;
|
||||||
|
}
|
||||||
|
.id-type-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 96rpx;
|
||||||
|
padding: 0 32rpx;
|
||||||
|
&--active {
|
||||||
|
background: rgba(60, 156, 255, 0.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.id-type-item__text {
|
||||||
|
font-size: $font-size-sm;
|
||||||
|
color: $uv-content-color;
|
||||||
|
}
|
||||||
|
.id-type-item--active .id-type-item__text {
|
||||||
|
color: $uv-primary;
|
||||||
|
}
|
||||||
|
.id-type-item__check {
|
||||||
|
width: 24rpx;
|
||||||
|
height: 24rpx;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%233c9cff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -137,6 +137,15 @@
|
|||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "预约/使用记录"
|
"navigationBarTitleText": "预约/使用记录"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/douyin-verify",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "抖音核销",
|
||||||
|
"backgroundColor": "#FFFFFF",
|
||||||
|
"backgroundColorTop": "#FFFFFF",
|
||||||
|
"backgroundColorBottom": "#FFFFFF"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -213,10 +213,11 @@ function onBannerClick(banner: BannerItem) {
|
|||||||
|
|
||||||
/** 金刚区操作项 */
|
/** 金刚区操作项 */
|
||||||
const quickActions = [
|
const quickActions = [
|
||||||
{ label: '我要绑卡', icon: 'https://aliyunoss.hntgov.net/pxmall/card.png' },
|
{ label: '我要绑卡', icon: 'https://aliyunoss.hn12301.net/pxmall/card.png' },
|
||||||
{ label: '我要预约', icon: 'https://aliyunoss.hntgov.net/pxmall/reserve.png' },
|
{ label: '我要预约', icon: 'https://aliyunoss.hn12301.net/pxmall/reserve.png' },
|
||||||
{ label: '我要入园', icon: 'https://aliyunoss.hntgov.net/pxmall/check.png' },
|
{ label: '我要入园', icon: 'https://aliyunoss.hn12301.net/pxmall/check.png' },
|
||||||
{ label: '常见问题', icon: 'https://aliyunoss.hntgov.net/pxmall/faq.png' },
|
{ label: '常见问题', icon: 'https://aliyunoss.hn12301.net/pxmall/faq.png' },
|
||||||
|
{ label: '抖音核销', icon: 'https://aliyunoss.hn12301.net/pxmall/douyin.png' },
|
||||||
]
|
]
|
||||||
|
|
||||||
/** 处理金刚区点击 */
|
/** 处理金刚区点击 */
|
||||||
@@ -241,6 +242,14 @@ function onQuickAction(item: { label: string }) {
|
|||||||
uni.navigateTo({ url: '/packages/content/pages/faq' })
|
uni.navigateTo({ url: '/packages/content/pages/faq' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (item.label === '抖音核销') {
|
||||||
|
if (!userStore.isLogin) {
|
||||||
|
uni.navigateTo({ url: '/pages/login/login' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uni.navigateTo({ url: '/packages/user/pages/douyin-verify' })
|
||||||
|
return
|
||||||
|
}
|
||||||
uni.showToast({ title: `${item.label}功能开发中`, icon: 'none' })
|
uni.showToast({ title: `${item.label}功能开发中`, icon: 'none' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user