Merge branch 'xuchao'

This commit is contained in:
jacobxu666
2026-07-30 11:17:59 +08:00
5 changed files with 1124 additions and 150 deletions
+141 -146
View File
@@ -4,72 +4,111 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## 项目定位
## 项目定位
**湖南12301文旅专区**用户端微信小程序(C端),基于 uni-app + Vue 3 + TypeScript + Pinia。仅包含用户端功能,不含管理端。
**爱上萍乡**用户端微信小程序(C端),基于 uni-app + Vue 3 + TypeScript + Pinia。仅包含用户端功能,不含管理端。
## 技术栈
| 技术 | 版本 | 说明 |
|------|------|------|
| Vue | 3.5.x | 核心框架 |
| uni-app | 3.0.0 | 跨平台框架(当前仅编译微信小程序) |
| uni-app | 3.0.0-alpha | 跨平台框架(当前仅编译微信小程序) |
| TypeScript | 4.9.x | 类型系统 |
| Pinia | 3.x | 状态管理(仅 user store) |
| 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`
- **后端接口前缀**: `/mp-api`(对应后端 `RouteChannel.MP_API`)
- **API 基础地址**: 由 `.env.*` 文件的 `VITE_API_BASE_URL` 控制
```bash
pnpm install # 安装依赖
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/
│ ├── api/ # API 接口层
│ │ ├── request.ts # HTTP 封装(uni.request + Token 注入 + 401 处理)
│ │ ├── types.ts # TypeScript 类型定义
│ │ ├── user.ts # 微信登录 + 用户信息
│ │ ├── card.ts # 年卡列表/详情/激活/绑定/入园码
│ │ └── order.ts # 订单创建/支付/查询
│ ├── components/ # 公共组件
│ │ ├── McGlass.vue # 毛玻璃卡片
│ │ ├── card-item.vue # 年卡列表项
│ │ └── empty-state.vue # 空状态占位
│ ├── config/
│ │ └── index.ts # API_BASE / TOKEN_KEY / REFRESH_TOKEN_KEY
│ ├── pages/ # 主包页面
│ │ ├── index/index.vue # 首页(轮播 + 热门景区 + 精选活动)
│ │ ├── login/login.vue # 登录页(微信手机号授权登录)
│ │ └── profile/index.vue # 个人中心(tabBar)
│ ├── packages/user/pages/card/ # 分包:年卡功能(减小主包体积)
│ │ ├── list.vue # 年卡列表(搜索 + 分类筛选)
│ │ ├── detail.vue # 年卡详情(权益 + 景区 + 富文本)
│ │ ├── buy.vue # 购买确认 + 模拟支付
│ │ ├── activate.vue # 实名激活(姓名 + 身份证 + 手机)
│ │ ├── qrcode.vue # 入园码(60秒自动刷新)
│ │ └── bind.vue # 卡号绑定
│ ├── static/ # 静态资源(logo + tabBar 图标)
│ ├── store/
│ │ └── user.ts # 用户状态(token 持久化 + 登录判断)
│ ├── utils/
│ │ └── index.ts # 工具函数(showToast / navigateTo 等)
│ ├── App.vue # 根组件(隐私协议检查 + 自动获取用户信息)
│ ├── main.ts # 入口(createSSRApp + Pinia)
│ ├── manifest.json # uni-app 配置(AppID)
│ ├── pages.json # 路由 + tabBar + 分包 + easycom
│ └── uni.scss # 全局 SCSS 变量
├── .env.local # 本地开发环境
├── .env.dev # 开发环境
├── .env.uat # UAT 环境
├── package.json
├── project.config.json # 微信开发者工具配置
├── vite.config.ts
└── tsconfig.json
src/
├── api/ # API 接口层
│ ├── request.ts # HTTP 封装(uni.request + Token 注入 + 401 处理)
│ ├── types.ts # 公共类型定义
│ ├── user.ts # 微信登录(两种方式)
│ ├── card.ts # 年卡接口
│ └── order.ts # 订单接口
├── components/ # 公共组件
│ ├── mc-glass.vue # 毛玻璃卡片
│ ├── card-item.vue # 年卡列表项
│ ├── empty-state.vue # 空状态占位
│ ├── announcement-modal.vue # 公告弹窗
│ ├── confirm-modal.vue # 确认弹窗
│ ├── design-button.vue # 设计系统按钮
│ ├── design-card.vue # 设计系统卡片
│ ├── design-input.vue # 设计系统输入框
│ └── section-label.vue # 区块标题
├── config/
│ └── index.ts # API_BASE / TOKEN_KEY / 环境变量
├── mock/ # Mock 数据(VITE_USE_MOCK=true 时启用)
│ ├── index.ts # USE_MOCK 开关
│ ├── images.ts # 图片 URL
│ ├── notice.ts # 公告数据
│ ├── scenic.ts # 景区列表数据
│ └── scenic-detail.ts # 景区详情数据
├── pages/ # 主包页面
│ ├── index/index.vue # 首页(轮播 + 热门景区 + 精选活动)
│ ├── qrcode/index.vue # 入园码(tabBar 旅游卡)
│ ├── login/login.vue # 登录页(微信手机号授权)
│ ├── login/agreement.vue # 服务条款
│ ├── login/privacy.vue # 隐私条款
│ └── profile/index.vue # 个人中心(tabBar)
├── packages/user/ # 分包:用户相关(年卡、订单、预约)
│ ├── api/
│ │ ├── card.ts # 年卡 API
│ │ └── order.ts # 订单 API
│ ├── pages/
│ │ ├── card/list.vue # 年卡列表
│ │ ├── card/detail.vue # 年卡详情
│ │ ├── card/buy.vue # 年卡购买
│ │ ├── card/activate.vue # 年卡激活
│ │ ├── 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 + 登录状态
```
- **页面直接调用 API**,不经过 store(年卡、订单等模块)
- **只有 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)
- 路由渠道:`RouteChannel.MP_API`(`/mp-api`)
- 认证方式:Bearer Token(`Authorization: Bearer {accessToken}`)
- 响应格式:`{ errcode, errmsg, data, traceid }`(框架自动包装 `ResDto`)
- 响应格式:`{ code, msg, data, errcode, errmsg }`(`code === 200` 或 `errcode === 0` 视为成功)
- 用户表:`member` 表,`channel = 99`(Channel_CY,产研小程序)
- Token 表:`system_oauth2_access_token`,`clientId = "mp-99"`
### 微信登录流程
### 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 }
```
通过环境变量 `VITE_USE_MOCK=true` 启用。各页面在 `onLoad` 中检查 `USE_MOCK` 标志,为 true 时直接从 mock 文件渲染数据,不发起网络请求。
## 运行命令
## 设计系统
```bash
# 安装依赖
pnpm install
样式体系基于 **uv-ui 设计系统**(`uv-*` 变量命名),通过 `uni.scss` 全局注入 SCSS 变量,每个组件无需额外 import 即可使用。
# 微信小程序开发构建
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
### 字体
# 构建产物目录
dist/build/mp-weixin/ # 用微信开发者工具导入此目录
```
- 展示字体:`SourceHanSerifCN`(思源宋体),用于标题
- 正文字体:系统字体栈(`-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 地址 | 用途 |
|------|---------|------|
| `.env.local` | `http://localhost:48080` | 本地开发(配合后端 `--spring.profiles.active=local`) |
| `.env.local` | `http://localhost:48080` | 本地开发(配合后端 local profile) |
| `.env.dev` | `https://dscp-dev.hn12301.net` | 开发环境 |
| `.env.uat` | `https://dscp-uat.hn12301.net` | UAT 测试 |
## 数据库环境
## Git 远程
| 环境 | 地址 | 数据库 |
|------|------|--------|
| 本地(local profile) | `localhost:3306` | `dscp_business_dev` |
| 开发(dev) | `120.26.254.65:3306` | `dscp_business_dev` |
| UAT | `47.99.164.129:3306` | `dscp_business` |
- `gitee` → `https://gitee.com/jacobxu666/zhy-mp-test.git`(唯一远程,`main` 跟踪此分支)
- 当前分支:`xuchao`
## 开发注意事项
1. **API 前缀**:所有接口走 `/mp-api`(不是 `/manage-api`,那是管理后台)
2. **Token 管理**:`app_token` / `app_refresh_token` 存在 `uni.storage` 中
3. **分包策略**:年卡功能放分包(`packages/user`),首页/登录/个人中心在主包
2. **Token 管理**:`app_token` 存在 `uni.storage` 中,通过 `request.ts` 的 `getToken/setToken/removeToken` 管理
3. **分包策略**:年卡/订单/预约放 `packages/user`,景区内容放 `packages/content`,主包仅放首页/登录/个人中心/入园码
4. **条件编译**:`#ifdef MP-WEIXIN` 包裹微信特有逻辑
5. **隐私协议**:`App.vue` 中 `wx.getPrivacySetting` 检查,未同意前不能调授权 API
6. **微信开发者工具调试**:需勾选「不校验合法域名」才能请求 localhost
<frontend_aesthetics>
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.
7. **Mock 模式**:设置 `VITE_USE_MOCK=true` 可离线开发,不依赖后端
8. **样式对齐**:`uni.scss` 是设计令牌的 source of truth,`src/styles/_tokens.scss` 仅供 `main.scss` 兼容使用,新增样式变量优先在 `uni.scss` 中定义
+98
View File
@@ -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)
}
+863
View File
@@ -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">核销&amp;绑定规则</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>
+9
View File
@@ -137,6 +137,15 @@
"style": {
"navigationBarTitleText": "预约/使用记录"
}
},
{
"path": "pages/douyin-verify",
"style": {
"navigationBarTitleText": "抖音核销",
"backgroundColor": "#FFFFFF",
"backgroundColorTop": "#FFFFFF",
"backgroundColorBottom": "#FFFFFF"
}
}
]
},
+13 -4
View File
@@ -213,10 +213,11 @@ function onBannerClick(banner: BannerItem) {
/** 金刚区操作项 */
const quickActions = [
{ label: '我要绑卡', icon: 'https://aliyunoss.hntgov.net/pxmall/card.png' },
{ label: '我要预约', icon: 'https://aliyunoss.hntgov.net/pxmall/reserve.png' },
{ label: '我要入园', icon: 'https://aliyunoss.hntgov.net/pxmall/check.png' },
{ label: '常见问题', icon: 'https://aliyunoss.hntgov.net/pxmall/faq.png' },
{ label: '我要绑卡', icon: 'https://aliyunoss.hn12301.net/pxmall/card.png' },
{ label: '我要预约', icon: 'https://aliyunoss.hn12301.net/pxmall/reserve.png' },
{ label: '我要入园', icon: 'https://aliyunoss.hn12301.net/pxmall/check.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' })
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' })
}