3076 lines
66 KiB
Markdown
3076 lines
66 KiB
Markdown
# 湖南12301文旅综合平台 — 第一迭代实施计划
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 搭建用户端分包架构和基础层,实现年卡浏览→购买→激活→入园码的核心业务流程。
|
||
|
||
**Architecture:** 由底向上分两阶段实施。Phase 1 搭建分包路由、API 层、Store 和共享组件;Phase 2 实现 8 个用户端页面(首页/年卡列表/详情/购买/激活/入园码/绑定/个人中心)。小程序启动默认进入用户端,微信授权登录,模拟支付。
|
||
|
||
**Tech Stack:** uni-app + Vue 3 + TypeScript + Pinia + uni-ui + Vite
|
||
|
||
---
|
||
|
||
## 文件结构
|
||
|
||
### 新增文件
|
||
| 文件路径 | 职责 |
|
||
|----------|------|
|
||
| `src/config/index.ts` | 环境配置(baseURL 读取) |
|
||
| `.env.local` | 本地开发环境变量 |
|
||
| `.env.dev` | 开发环境变量 |
|
||
| `.env.uat` | 预发布环境变量 |
|
||
| `src/api/types.ts` | 共享 TypeScript 类型定义 |
|
||
| `src/api/card.ts` | 年卡接口 |
|
||
| `src/api/order.ts` | 订单/支付接口 |
|
||
| `src/store/card.ts` | 年卡 Pinia Store |
|
||
| `src/components/card-item.vue` | 年卡卡片共享组件 |
|
||
| `src/components/empty-state.vue` | 空状态共享组件 |
|
||
| `src/packages/user/pages/index/index.vue` | 用户端首页(宫格式) |
|
||
| `src/packages/user/pages/card/list.vue` | 年卡列表 |
|
||
| `src/packages/user/pages/card/detail.vue` | 年卡详情 |
|
||
| `src/packages/user/pages/card/buy.vue` | 确认购买(模拟支付) |
|
||
| `src/packages/user/pages/card/activate.vue` | 激活年卡 |
|
||
| `src/packages/user/pages/card/qrcode.vue` | 入园码(卡片式) |
|
||
| `src/packages/user/pages/card/bind.vue` | 绑定年卡 |
|
||
| `src/packages/user/pages/profile/index.vue` | 用户端个人中心 |
|
||
| `src/static/tabbar/home.png` | TabBar 首页图标(占位) |
|
||
| `src/static/tabbar/home-active.png` | TabBar 首页选中图标(占位) |
|
||
| `src/static/tabbar/profile.png` | TabBar 我的图标(占位) |
|
||
| `src/static/tabbar/profile-active.png` | TabBar 我的选中图标(占位) |
|
||
|
||
### 修改文件
|
||
| 文件路径 | 改动 |
|
||
|----------|------|
|
||
| `src/api/request.ts` | 重写:baseURL 从 config 读取,清理错误处理 |
|
||
| `src/api/user.ts` | 改造:新增 wxLogin,调整类型 |
|
||
| `src/api/index.ts` | 更新 barrel 导出 |
|
||
| `src/store/user.ts` | 改造:新增 wxLogin action,新增 refreshToken |
|
||
| `src/pages.json` | 重写:分包路由 + TabBar 指向用户端 |
|
||
| `src/pages/index/index.vue` | 改造:自动跳转到用户端首页 |
|
||
| `src/pages/login/login.vue` | 改造:微信授权登录 |
|
||
| `src/App.vue` | 改造:启动时检查登录状态 |
|
||
| `vite.config.ts` | 新增环境变量相关配置 |
|
||
|
||
### 删除文件(可选,不影响功能)
|
||
| 文件路径 | 原因 |
|
||
|----------|------|
|
||
| `src/pages/profile/profile.vue` | 被用户端个人中心替代 |
|
||
| `src/pages/logs/logs.vue` | 非业务页面,不再需要 |
|
||
|
||
---
|
||
|
||
## Task 1:环境配置
|
||
|
||
**Files:**
|
||
- Create: `src/config/index.ts`
|
||
- Create: `.env.local`
|
||
- Create: `.env.dev`
|
||
- Create: `.env.uat`
|
||
- Modify: `vite.config.ts`
|
||
|
||
- [ ] **Step 1:创建环境配置文件**
|
||
|
||
创建 `.env.local`:
|
||
```
|
||
VITE_API_BASE_URL=http://localhost:80
|
||
VITE_APP_TITLE=湖南12301文旅专区
|
||
```
|
||
|
||
创建 `.env.dev`:
|
||
```
|
||
VITE_API_BASE_URL=https://dscp-dev.hn12301.net
|
||
VITE_APP_TITLE=湖南12301文旅专区
|
||
```
|
||
|
||
创建 `.env.uat`:
|
||
```
|
||
VITE_API_BASE_URL=https://dscp-uat.hn12301.net
|
||
VITE_APP_TITLE=湖南12301文旅专区
|
||
```
|
||
|
||
- [ ] **Step 2:创建 config/index.ts**
|
||
|
||
创建 `src/config/index.ts`:
|
||
```typescript
|
||
/**
|
||
* 环境配置
|
||
* 根据 Vite 环境变量读取 API 基础地址等配置
|
||
*/
|
||
|
||
/** API 基础地址(含 /manage-api 前缀) */
|
||
export const BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:80'
|
||
|
||
/** API 接口前缀 */
|
||
export const API_PREFIX = '/manage-api'
|
||
|
||
/** 完整 API 基础路径 */
|
||
export const API_BASE = `${BASE_URL}${API_PREFIX}`
|
||
|
||
/** 应用标题 */
|
||
export const APP_TITLE = import.meta.env.VITE_APP_TITLE || '湖南12301文旅专区'
|
||
|
||
/** Token 存储键名 */
|
||
export const TOKEN_KEY = 'app_token'
|
||
|
||
/** Refresh Token 存储键名 */
|
||
export const REFRESH_TOKEN_KEY = 'app_refresh_token'
|
||
```
|
||
|
||
- [ ] **Step 3:添加 env.d.ts 类型声明**
|
||
|
||
在 `src/env.d.ts` 中追加 Vite 环境变量类型声明(如果文件已有内容则追加):
|
||
```typescript
|
||
/// <reference types="vite/client" />
|
||
|
||
interface ImportMetaEnv {
|
||
readonly VITE_API_BASE_URL: string
|
||
readonly VITE_APP_TITLE: string
|
||
}
|
||
|
||
interface ImportMeta {
|
||
readonly env: ImportMetaEnv
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4:验证编译**
|
||
|
||
运行:
|
||
```bash
|
||
cd zhy-test && npx vue-tsc --noEmit 2>&1 | head -20
|
||
```
|
||
预期:无新增类型错误(config/index.ts 编译通过)。
|
||
|
||
- [ ] **Step 5:提交**
|
||
|
||
```bash
|
||
git add src/config/ .env.local .env.dev .env.uat src/env.d.ts
|
||
git commit -m "feat: 添加多环境配置(local/dev/uat)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2:重写 API 请求层
|
||
|
||
**Files:**
|
||
- Modify: `src/api/request.ts`(完全重写)
|
||
|
||
- [ ] **Step 1:重写 request.ts**
|
||
|
||
将 `src/api/request.ts` 替换为以下内容:
|
||
```typescript
|
||
import { API_BASE, TOKEN_KEY } from '../config'
|
||
|
||
/** 请求选项 */
|
||
interface RequestOptions {
|
||
url: string
|
||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||
data?: any
|
||
header?: Record<string, string>
|
||
needToken?: boolean
|
||
}
|
||
|
||
/** 后端统一响应格式 */
|
||
interface ResponseData<T = any> {
|
||
code: number
|
||
msg: string
|
||
data: T
|
||
errcode: number
|
||
errmsg: string
|
||
}
|
||
|
||
/** 从本地存储获取 token */
|
||
function getToken(): string {
|
||
return uni.getStorageSync(TOKEN_KEY) || ''
|
||
}
|
||
|
||
/** 保存 token 到本地存储 */
|
||
function setToken(token: string): void {
|
||
uni.setStorageSync(TOKEN_KEY, token)
|
||
}
|
||
|
||
/** 移除本地存储中的 token */
|
||
function removeToken(): void {
|
||
uni.removeStorageSync(TOKEN_KEY)
|
||
}
|
||
|
||
/** 通用请求函数 */
|
||
function request<T = any>(options: RequestOptions): Promise<T> {
|
||
return new Promise((resolve, reject) => {
|
||
const { url, method = 'GET', data, needToken = true } = options
|
||
|
||
const header: Record<string, string> = {
|
||
'Content-Type': 'application/json',
|
||
...options.header
|
||
}
|
||
|
||
// 自动注入 Bearer Token
|
||
if (needToken) {
|
||
const token = getToken()
|
||
if (token) {
|
||
header['Authorization'] = `Bearer ${token}`
|
||
}
|
||
}
|
||
|
||
// 拼接完整 URL
|
||
const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`
|
||
|
||
uni.request({
|
||
url: fullUrl,
|
||
method,
|
||
data,
|
||
header,
|
||
success: (res: any) => {
|
||
const result = res.data as ResponseData<T>
|
||
|
||
// HTTP 状态码检查
|
||
if (res.statusCode === 401 || result?.code === 401) {
|
||
removeToken()
|
||
uni.showToast({ title: '登录已过期,请重新登录', icon: 'none' })
|
||
// 延迟跳转,让 toast 先显示
|
||
setTimeout(() => {
|
||
uni.navigateTo({ url: '/pages/login/login' })
|
||
}, 1500)
|
||
reject(new Error('未授权'))
|
||
return
|
||
}
|
||
|
||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||
// 业务状态码检查:code === 200 或 errcode === 0 均视为成功
|
||
if (result.code === 200 || result.errcode === 0) {
|
||
resolve(result.data as T)
|
||
} else {
|
||
const errMsg = result.msg || result.errmsg || '请求失败'
|
||
uni.showToast({ title: errMsg, icon: 'none' })
|
||
reject(new Error(errMsg))
|
||
}
|
||
} else {
|
||
uni.showToast({ title: '网络错误', icon: 'none' })
|
||
reject(new Error(`HTTP ${res.statusCode}`))
|
||
}
|
||
},
|
||
fail: (err: any) => {
|
||
uni.showToast({ title: '网络连接失败', icon: 'none' })
|
||
reject(err)
|
||
}
|
||
})
|
||
})
|
||
}
|
||
|
||
function get<T = any>(url: string, data?: any, needToken = true): Promise<T> {
|
||
return request<T>({ url, method: 'GET', data, needToken })
|
||
}
|
||
|
||
function post<T = any>(url: string, data?: any, needToken = true): Promise<T> {
|
||
return request<T>({ url, method: 'POST', data, needToken })
|
||
}
|
||
|
||
function put<T = any>(url: string, data?: any, needToken = true): Promise<T> {
|
||
return request<T>({ url, method: 'PUT', data, needToken })
|
||
}
|
||
|
||
function del<T = any>(url: string, data?: any, needToken = true): Promise<T> {
|
||
return request<T>({ url, method: 'DELETE', data, needToken })
|
||
}
|
||
|
||
export {
|
||
request,
|
||
get,
|
||
post,
|
||
put,
|
||
del,
|
||
getToken,
|
||
setToken,
|
||
removeToken
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2:验证编译**
|
||
|
||
运行:
|
||
```bash
|
||
cd zhy-test && npx vue-tsc --noEmit 2>&1 | head -20
|
||
```
|
||
预期:无类型错误。
|
||
|
||
- [ ] **Step 3:提交**
|
||
|
||
```bash
|
||
git add src/api/request.ts
|
||
git commit -m "refactor: 重写 request.ts,baseURL 从环境配置读取"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3:定义共享类型 + 改造用户 API
|
||
|
||
**Files:**
|
||
- Create: `src/api/types.ts`
|
||
- Modify: `src/api/user.ts`
|
||
- Modify: `src/api/index.ts`
|
||
|
||
- [ ] **Step 1:创建共享类型定义**
|
||
|
||
创建 `src/api/types.ts`:
|
||
```typescript
|
||
/** 用户信息 */
|
||
export interface UserInfo {
|
||
id: number
|
||
nickname: string
|
||
avatarUrl: string
|
||
phone?: string
|
||
}
|
||
|
||
/** 微信登录结果 */
|
||
export interface WxLoginResult {
|
||
accessToken: string
|
||
refreshToken: string
|
||
userId: number
|
||
}
|
||
|
||
/** 账号密码登录参数(降级方案) */
|
||
export interface LoginParams {
|
||
username: string
|
||
password: string
|
||
}
|
||
|
||
/** 年卡基本信息 */
|
||
export interface CardInfo {
|
||
id: string
|
||
name: string
|
||
price: number
|
||
originalPrice: number
|
||
coverImage: string
|
||
description: string
|
||
scenicCount: number
|
||
}
|
||
|
||
/** 年卡详情 */
|
||
export interface CardDetail extends CardInfo {
|
||
benefits: string[]
|
||
scenicSpots: ScenicSpot[]
|
||
validDays: number
|
||
detail: string
|
||
}
|
||
|
||
/** 景区信息 */
|
||
export interface ScenicSpot {
|
||
id: string
|
||
name: string
|
||
image: string
|
||
address?: string
|
||
}
|
||
|
||
/** 我的年卡 */
|
||
export interface MyCard {
|
||
id: string
|
||
cardId: string
|
||
cardName: string
|
||
coverImage: string
|
||
userName: string
|
||
expireDate: string
|
||
status: 'active' | 'expired' | 'inactive'
|
||
}
|
||
|
||
/** 激活年卡参数 */
|
||
export interface ActivateParams {
|
||
realName: string
|
||
idCard: string
|
||
phone: string
|
||
}
|
||
|
||
/** 入园码信息 */
|
||
export interface QrCodeInfo {
|
||
qrCode: string
|
||
expireTime: string
|
||
cardName: string
|
||
userName: string
|
||
expireDate: string
|
||
}
|
||
|
||
/** 订单信息 */
|
||
export interface OrderInfo {
|
||
id: string
|
||
cardId: string
|
||
cardName: string
|
||
price: number
|
||
status: 'pending' | 'paid' | 'cancelled'
|
||
createTime: string
|
||
}
|
||
|
||
/** 创建订单参数 */
|
||
export interface CreateOrderParams {
|
||
cardId: string
|
||
}
|
||
|
||
/** 模拟支付结果 */
|
||
export interface MockPayResult {
|
||
success: boolean
|
||
orderId: string
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2:改造用户 API**
|
||
|
||
将 `src/api/user.ts` 替换为:
|
||
```typescript
|
||
import { get, post } from './request'
|
||
import type { UserInfo, WxLoginResult, LoginParams } from './types'
|
||
|
||
/** 微信授权登录 */
|
||
export function wxLogin(code: string): Promise<WxLoginResult> {
|
||
return post<WxLoginResult>('/auth/wx-login', { code }, false)
|
||
}
|
||
|
||
/** 账号密码登录(降级方案) */
|
||
export function login(params: LoginParams): Promise<WxLoginResult> {
|
||
return post<WxLoginResult>('/auth/login', params, false)
|
||
}
|
||
|
||
/** 获取当前用户信息 */
|
||
export function getUserInfo(): Promise<UserInfo> {
|
||
return get<UserInfo>('/user/info')
|
||
}
|
||
|
||
export type { UserInfo, WxLoginResult, LoginParams }
|
||
```
|
||
|
||
- [ ] **Step 3:更新 barrel 导出**
|
||
|
||
将 `src/api/index.ts` 替换为:
|
||
```typescript
|
||
export * from './types'
|
||
export * from './user'
|
||
export * from './card'
|
||
export * from './order'
|
||
export { get, post, put, del, getToken, setToken, removeToken } from './request'
|
||
```
|
||
|
||
注意:`card` 和 `order` 模块在后续 Task 中创建,此处先写好导出,暂时会导致编译警告,Task 4/5 完成后即可消除。
|
||
|
||
- [ ] **Step 4:验证编译**
|
||
|
||
运行:
|
||
```bash
|
||
cd zhy-test && npx vue-tsc --noEmit 2>&1 | head -20
|
||
```
|
||
预期:可能有关于 card/order 模块未找到的警告(Task 4/5 会消除)。
|
||
|
||
- [ ] **Step 5:提交**
|
||
|
||
```bash
|
||
git add src/api/types.ts src/api/user.ts src/api/index.ts
|
||
git commit -m "feat: 定义共享类型,改造用户 API(新增 wxLogin)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4:创建年卡 API 模块
|
||
|
||
**Files:**
|
||
- Create: `src/api/card.ts`
|
||
|
||
- [ ] **Step 1:创建 card.ts**
|
||
|
||
创建 `src/api/card.ts`:
|
||
```typescript
|
||
import { get, post } from './request'
|
||
import type { CardInfo, CardDetail, MyCard, ActivateParams, QrCodeInfo } from './types'
|
||
|
||
/** 获取年卡列表 */
|
||
export function getCardList(params?: { keyword?: string; category?: string }): Promise<CardInfo[]> {
|
||
return get<CardInfo[]>('/card/list', params)
|
||
}
|
||
|
||
/** 获取年卡详情 */
|
||
export function getCardDetail(id: string): Promise<CardDetail> {
|
||
return get<CardDetail>('/card/detail', { id })
|
||
}
|
||
|
||
/** 激活年卡 */
|
||
export function activateCard(cardId: string, data: ActivateParams): Promise<boolean> {
|
||
return post<boolean>('/card/activate', { cardId, ...data })
|
||
}
|
||
|
||
/** 绑定年卡 */
|
||
export function bindCard(cardNo: string): Promise<boolean> {
|
||
return post<boolean>('/card/bind', { cardNo })
|
||
}
|
||
|
||
/** 获取我的年卡列表 */
|
||
export function getMyCards(): Promise<MyCard[]> {
|
||
return get<MyCard[]>('/card/my-list')
|
||
}
|
||
|
||
/** 获取入园码 */
|
||
export function getEntryQrCode(cardId: string): Promise<QrCodeInfo> {
|
||
return get<QrCodeInfo>('/card/qrcode', { cardId })
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2:提交**
|
||
|
||
```bash
|
||
git add src/api/card.ts
|
||
git commit -m "feat: 新增年卡 API 模块"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5:创建订单 API 模块
|
||
|
||
**Files:**
|
||
- Create: `src/api/order.ts`
|
||
|
||
- [ ] **Step 1:创建 order.ts**
|
||
|
||
创建 `src/api/order.ts`:
|
||
```typescript
|
||
import { get, post } from './request'
|
||
import type { OrderInfo, CreateOrderParams, MockPayResult } from './types'
|
||
|
||
/** 创建订单 */
|
||
export function createOrder(data: CreateOrderParams): Promise<OrderInfo> {
|
||
return post<OrderInfo>('/order/create', data)
|
||
}
|
||
|
||
/** 模拟支付(第一版替代微信支付) */
|
||
export function mockPay(orderId: string): Promise<MockPayResult> {
|
||
return post<MockPayResult>('/order/mock-pay', { orderId })
|
||
}
|
||
|
||
/** 获取订单列表 */
|
||
export function getOrderList(params?: { status?: string }): Promise<OrderInfo[]> {
|
||
return get<OrderInfo[]>('/order/list', params)
|
||
}
|
||
|
||
/** 获取订单详情 */
|
||
export function getOrderDetail(id: string): Promise<OrderInfo> {
|
||
return get<OrderInfo>('/order/detail', { id })
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2:验证编译**
|
||
|
||
运行:
|
||
```bash
|
||
cd zhy-test && npx vue-tsc --noEmit 2>&1 | head -20
|
||
```
|
||
预期:API 相关类型错误全部消除。
|
||
|
||
- [ ] **Step 3:提交**
|
||
|
||
```bash
|
||
git add src/api/order.ts
|
||
git commit -m "feat: 新增订单 API 模块(含模拟支付)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6:改造 User Store + 创建 Card Store
|
||
|
||
**Files:**
|
||
- Modify: `src/store/user.ts`(重写)
|
||
- Create: `src/store/card.ts`
|
||
|
||
- [ ] **Step 1:重写 user store**
|
||
|
||
将 `src/store/user.ts` 替换为:
|
||
```typescript
|
||
import { defineStore } from 'pinia'
|
||
import { ref, computed } from 'vue'
|
||
import { getToken, setToken, removeToken } from '../api/request'
|
||
import { wxLogin as apiWxLogin, login as apiLogin, getUserInfo as apiGetUserInfo } from '../api/user'
|
||
import type { UserInfo, LoginParams } from '../api/types'
|
||
import { TOKEN_KEY, REFRESH_TOKEN_KEY } from '../config'
|
||
|
||
export const useUserStore = defineStore('user', () => {
|
||
const token = ref<string>(getToken())
|
||
const refreshToken = ref<string>(uni.getStorageSync(REFRESH_TOKEN_KEY) || '')
|
||
const userInfo = ref<UserInfo | null>(null)
|
||
|
||
const isLogin = computed(() => !!token.value)
|
||
|
||
/** 微信授权登录 */
|
||
async function wxLogin() {
|
||
// #ifdef MP-WEIXIN
|
||
const loginRes = await new Promise<UniApp.LoginRes>((resolve, reject) => {
|
||
uni.login({
|
||
provider: 'weixin',
|
||
success: resolve,
|
||
fail: reject
|
||
})
|
||
})
|
||
const result = await apiWxLogin(loginRes.code)
|
||
token.value = result.accessToken
|
||
refreshToken.value = result.refreshToken
|
||
setToken(result.accessToken)
|
||
uni.setStorageSync(REFRESH_TOKEN_KEY, result.refreshToken)
|
||
return result
|
||
// #endif
|
||
|
||
// #ifdef H5
|
||
throw new Error('H5 登录暂未开放,请在微信小程序中使用')
|
||
// #endif
|
||
}
|
||
|
||
/** 账号密码登录(降级方案) */
|
||
async function login(params: LoginParams) {
|
||
const result = await apiLogin(params)
|
||
token.value = result.accessToken
|
||
refreshToken.value = result.refreshToken
|
||
setToken(result.accessToken)
|
||
uni.setStorageSync(REFRESH_TOKEN_KEY, result.refreshToken)
|
||
return result
|
||
}
|
||
|
||
/** 获取用户信息 */
|
||
async function fetchUserInfo() {
|
||
if (!token.value) return
|
||
const info = await apiGetUserInfo()
|
||
userInfo.value = info
|
||
return info
|
||
}
|
||
|
||
/** 退出登录 */
|
||
function logout() {
|
||
token.value = ''
|
||
refreshToken.value = ''
|
||
userInfo.value = null
|
||
removeToken()
|
||
uni.removeStorageSync(REFRESH_TOKEN_KEY)
|
||
}
|
||
|
||
return {
|
||
token,
|
||
refreshToken,
|
||
userInfo,
|
||
isLogin,
|
||
wxLogin,
|
||
login,
|
||
fetchUserInfo,
|
||
logout
|
||
}
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2:创建 card store**
|
||
|
||
创建 `src/store/card.ts`:
|
||
```typescript
|
||
import { defineStore } from 'pinia'
|
||
import { ref } from 'vue'
|
||
import * as cardApi from '../api/card'
|
||
import type { CardInfo, CardDetail, MyCard, ActivateParams, QrCodeInfo } from '../api/types'
|
||
|
||
export const useCardStore = defineStore('card', () => {
|
||
const cardList = ref<CardInfo[]>([])
|
||
const currentCard = ref<CardDetail | null>(null)
|
||
const myCards = ref<MyCard[]>([])
|
||
const loading = ref(false)
|
||
|
||
/** 获取年卡列表 */
|
||
async function fetchCardList(params?: { keyword?: string; category?: string }) {
|
||
loading.value = true
|
||
try {
|
||
cardList.value = await cardApi.getCardList(params)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
/** 获取年卡详情 */
|
||
async function fetchCardDetail(id: string) {
|
||
loading.value = true
|
||
try {
|
||
currentCard.value = await cardApi.getCardDetail(id)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
/** 获取我的年卡 */
|
||
async function fetchMyCards() {
|
||
myCards.value = await cardApi.getMyCards()
|
||
}
|
||
|
||
/** 激活年卡 */
|
||
async function activateCard(cardId: string, data: ActivateParams) {
|
||
return await cardApi.activateCard(cardId, data)
|
||
}
|
||
|
||
/** 绑定年卡 */
|
||
async function bindCard(cardNo: string) {
|
||
return await cardApi.bindCard(cardNo)
|
||
}
|
||
|
||
/** 获取入园码 */
|
||
async function getEntryQrCode(cardId: string): Promise<QrCodeInfo> {
|
||
return await cardApi.getEntryQrCode(cardId)
|
||
}
|
||
|
||
return {
|
||
cardList,
|
||
currentCard,
|
||
myCards,
|
||
loading,
|
||
fetchCardList,
|
||
fetchCardDetail,
|
||
fetchMyCards,
|
||
activateCard,
|
||
bindCard,
|
||
getEntryQrCode
|
||
}
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 3:验证编译**
|
||
|
||
运行:
|
||
```bash
|
||
cd zhy-test && npx vue-tsc --noEmit 2>&1 | head -20
|
||
```
|
||
预期:Store 相关文件无类型错误。
|
||
|
||
- [ ] **Step 4:提交**
|
||
|
||
```bash
|
||
git add src/store/user.ts src/store/card.ts
|
||
git commit -m "feat: 改造 user store(微信登录),新增 card store"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7:创建共享组件
|
||
|
||
**Files:**
|
||
- Create: `src/components/card-item.vue`
|
||
- Create: `src/components/empty-state.vue`
|
||
|
||
- [ ] **Step 1:创建 card-item 组件**
|
||
|
||
创建 `src/components/card-item.vue`:
|
||
```vue
|
||
<template>
|
||
<view class="card-item" @click="$emit('click', card)">
|
||
<image class="card-cover" :src="card.coverImage" mode="aspectFill" />
|
||
<view class="card-body">
|
||
<text class="card-name">{{ card.name }}</text>
|
||
<text class="card-desc">{{ card.description }}</text>
|
||
<view class="card-footer">
|
||
<view class="price-wrap">
|
||
<text class="price">¥{{ card.price }}</text>
|
||
<text class="original-price" v-if="card.originalPrice">¥{{ card.originalPrice }}</text>
|
||
</view>
|
||
<text class="scenic-count">{{ card.scenicCount }}个景区</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import type { CardInfo } from '../api/types'
|
||
|
||
defineProps<{
|
||
card: CardInfo
|
||
}>()
|
||
|
||
defineEmits<{
|
||
(e: 'click', card: CardInfo): void
|
||
}>()
|
||
</script>
|
||
|
||
<style scoped>
|
||
.card-item {
|
||
display: flex;
|
||
background: #fff;
|
||
border-radius: 16rpx;
|
||
overflow: hidden;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.card-cover {
|
||
width: 200rpx;
|
||
height: 160rpx;
|
||
flex-shrink: 0;
|
||
background: #eee;
|
||
}
|
||
|
||
.card-body {
|
||
flex: 1;
|
||
padding: 16rpx 20rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.card-name {
|
||
font-size: 30rpx;
|
||
font-weight: bold;
|
||
color: #333;
|
||
}
|
||
|
||
.card-desc {
|
||
font-size: 24rpx;
|
||
color: #999;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.card-footer {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.price-wrap {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 8rpx;
|
||
}
|
||
|
||
.price {
|
||
font-size: 32rpx;
|
||
color: #E74C3C;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.original-price {
|
||
font-size: 22rpx;
|
||
color: #ccc;
|
||
text-decoration: line-through;
|
||
}
|
||
|
||
.scenic-count {
|
||
font-size: 22rpx;
|
||
color: #4A90D9;
|
||
background: #E8F0FE;
|
||
padding: 4rpx 12rpx;
|
||
border-radius: 20rpx;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 2:创建 empty-state 组件**
|
||
|
||
创建 `src/components/empty-state.vue`:
|
||
```vue
|
||
<template>
|
||
<view class="empty-state">
|
||
<text class="empty-icon">{{ icon }}</text>
|
||
<text class="empty-text">{{ text }}</text>
|
||
<button v-if="buttonText" class="empty-btn" @click="$emit('action')">
|
||
{{ buttonText }}
|
||
</button>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
defineProps<{
|
||
icon?: string
|
||
text: string
|
||
buttonText?: string
|
||
}>()
|
||
|
||
defineEmits<{
|
||
(e: 'action'): void
|
||
}>()
|
||
</script>
|
||
|
||
<style scoped>
|
||
.empty-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 80rpx 40rpx;
|
||
}
|
||
|
||
.empty-icon {
|
||
font-size: 80rpx;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.empty-text {
|
||
font-size: 28rpx;
|
||
color: #999;
|
||
margin-bottom: 30rpx;
|
||
}
|
||
|
||
.empty-btn {
|
||
font-size: 28rpx;
|
||
color: #4A90D9;
|
||
background: #E8F0FE;
|
||
border-radius: 30rpx;
|
||
padding: 12rpx 40rpx;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 3:提交**
|
||
|
||
```bash
|
||
git add src/components/
|
||
git commit -m "feat: 新增 card-item 和 empty-state 共享组件"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8:创建 TabBar 图标 + 重写路由配置
|
||
|
||
**Files:**
|
||
- Create: `src/static/tabbar/home.png`
|
||
- Create: `src/static/tabbar/home-active.png`
|
||
- Create: `src/static/tabbar/profile.png`
|
||
- Create: `src/static/tabbar/profile-active.png`
|
||
- Modify: `src/pages.json`(完全重写)
|
||
|
||
- [ ] **Step 1:生成 TabBar 占位图标**
|
||
|
||
TabBar 图标必须是图片文件。使用以下命令生成 4 个简单的 SVG 占位图标并转换为 PNG(或直接用在线占位图服务):
|
||
|
||
```bash
|
||
mkdir -p src/static/tabbar
|
||
|
||
# 使用 base64 编码的最小 PNG 图标(81x81 像素,灰色/蓝色)
|
||
# home.png - 灰色首页图标
|
||
echo "iVBORw0KGgoAAAANSUhEUgAAAFEAAABRCAYAAACqj0o2AAAAKElEQVR42u3BAQEAAACCIP+vbkhAAQAAAAAAAAAAAAAAAAAAAADcGlJRAAH0fVWIAAAAAElFTkSuQmCC" | base64 -d > src/static/tabbar/home.png
|
||
|
||
# home-active.png - 蓝色首页图标
|
||
echo "iVBORw0KGgoAAAANSUhEUgAAAFEAAABRCAYAAACqj0o2AAAAKElEQVR42u3BAQEAAACCIP+vbkhAAQAAAAAAAAAAAAAAAAAAAADcGlJRAAH0fVWIAAAAAElFTkSuQmCC" | base64 -d > src/static/tabbar/home-active.png
|
||
|
||
# profile.png - 灰色我的图标
|
||
cp src/static/tabbar/home.png src/static/tabbar/profile.png
|
||
# profile-active.png - 蓝色我的图标
|
||
cp src/static/tabbar/home-active.png src/static/tabbar/profile-active.png
|
||
```
|
||
|
||
> **注意**:以上是占位 PNG。正式环境中应替换为设计师提供的图标文件(建议 81×81px)。
|
||
|
||
- [ ] **Step 2:重写 pages.json**
|
||
|
||
将 `src/pages.json` 替换为:
|
||
```json
|
||
{
|
||
"easycom": {
|
||
"autoscan": true,
|
||
"custom": {
|
||
"^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue"
|
||
}
|
||
},
|
||
"pages": [
|
||
{
|
||
"path": "pages/index/index",
|
||
"style": {
|
||
"navigationBarTitleText": "湖南12301"
|
||
}
|
||
},
|
||
{
|
||
"path": "pages/login/login",
|
||
"style": {
|
||
"navigationBarTitleText": "登录"
|
||
}
|
||
}
|
||
],
|
||
"subPackages": [
|
||
{
|
||
"root": "packages/user",
|
||
"pages": [
|
||
{
|
||
"path": "pages/index/index",
|
||
"style": {
|
||
"navigationBarTitleText": "文旅专区"
|
||
}
|
||
},
|
||
{
|
||
"path": "pages/card/list",
|
||
"style": {
|
||
"navigationBarTitleText": "年卡列表"
|
||
}
|
||
},
|
||
{
|
||
"path": "pages/card/detail",
|
||
"style": {
|
||
"navigationBarTitleText": "年卡详情"
|
||
}
|
||
},
|
||
{
|
||
"path": "pages/card/buy",
|
||
"style": {
|
||
"navigationBarTitleText": "确认购买"
|
||
}
|
||
},
|
||
{
|
||
"path": "pages/card/activate",
|
||
"style": {
|
||
"navigationBarTitleText": "激活年卡"
|
||
}
|
||
},
|
||
{
|
||
"path": "pages/card/qrcode",
|
||
"style": {
|
||
"navigationBarTitleText": "入园码"
|
||
}
|
||
},
|
||
{
|
||
"path": "pages/card/bind",
|
||
"style": {
|
||
"navigationBarTitleText": "绑定年卡"
|
||
}
|
||
},
|
||
{
|
||
"path": "pages/profile/index",
|
||
"style": {
|
||
"navigationBarTitleText": "个人中心"
|
||
}
|
||
}
|
||
]
|
||
}
|
||
],
|
||
"globalStyle": {
|
||
"navigationBarTextStyle": "black",
|
||
"navigationBarTitleText": "湖南12301",
|
||
"navigationBarBackgroundColor": "#FFFFFF",
|
||
"backgroundColor": "#F5F5F5"
|
||
},
|
||
"tabBar": {
|
||
"color": "#7A7E83",
|
||
"selectedColor": "#4A90D9",
|
||
"borderStyle": "white",
|
||
"backgroundColor": "#ffffff",
|
||
"list": [
|
||
{
|
||
"pagePath": "packages/user/pages/index/index",
|
||
"text": "首页",
|
||
"iconPath": "static/tabbar/home.png",
|
||
"selectedIconPath": "static/tabbar/home-active.png"
|
||
},
|
||
{
|
||
"pagePath": "packages/user/pages/profile/index",
|
||
"text": "我的",
|
||
"iconPath": "static/tabbar/profile.png",
|
||
"selectedIconPath": "static/tabbar/profile-active.png"
|
||
}
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3:验证编译**
|
||
|
||
运行:
|
||
```bash
|
||
cd zhy-test && npx uni -p mp-weixin 2>&1 | head -30
|
||
```
|
||
预期:编译启动成功,无路由配置错误。(可用 Ctrl+C 停止)
|
||
|
||
- [ ] **Step 4:提交**
|
||
|
||
```bash
|
||
git add src/pages.json src/static/tabbar/
|
||
git commit -m "feat: 重写路由配置,添加分包和 TabBar"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9:改造入口页和登录页
|
||
|
||
**Files:**
|
||
- Modify: `src/pages/index/index.vue`(重写为自动跳转)
|
||
- Modify: `src/pages/login/login.vue`(改造为微信登录)
|
||
- Modify: `src/App.vue`(添加启动登录检查)
|
||
|
||
- [ ] **Step 1:改造入口页为自动跳转**
|
||
|
||
将 `src/pages/index/index.vue` 替换为:
|
||
```vue
|
||
<template>
|
||
<view class="splash">
|
||
<image class="logo" src="/static/logo.png" mode="aspectFit" />
|
||
<text class="title">湖南12301文旅专区</text>
|
||
<text class="subtitle">加载中...</text>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { onMounted } from 'vue'
|
||
import { useUserStore } from '../../store/user'
|
||
|
||
const userStore = useUserStore()
|
||
|
||
onMounted(() => {
|
||
// 检查登录状态,决定跳转目标
|
||
setTimeout(() => {
|
||
if (userStore.isLogin) {
|
||
uni.switchTab({ url: '/packages/user/pages/index/index' })
|
||
} else {
|
||
uni.navigateTo({ url: '/pages/login/login' })
|
||
}
|
||
}, 800)
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.splash {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 100vh;
|
||
background: #fff;
|
||
}
|
||
|
||
.logo {
|
||
width: 160rpx;
|
||
height: 160rpx;
|
||
margin-bottom: 30rpx;
|
||
}
|
||
|
||
.title {
|
||
font-size: 40rpx;
|
||
font-weight: bold;
|
||
color: #333;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.subtitle {
|
||
font-size: 26rpx;
|
||
color: #999;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 2:改造登录页为微信授权登录**
|
||
|
||
将 `src/pages/login/login.vue` 替换为:
|
||
```vue
|
||
<template>
|
||
<view class="container">
|
||
<view class="header">
|
||
<image class="logo" src="/static/logo.png" mode="aspectFit" />
|
||
<text class="app-name">湖南12301文旅专区</text>
|
||
<text class="app-desc">年卡购买 · 景区预约 · 权益查看</text>
|
||
</view>
|
||
|
||
<view class="login-area">
|
||
<!-- #ifdef MP-WEIXIN -->
|
||
<button class="btn-wx-login" @click="handleWxLogin" :disabled="loading">
|
||
{{ loading ? '登录中...' : '微信一键登录' }}
|
||
</button>
|
||
<!-- #endif -->
|
||
|
||
<!-- #ifdef H5 -->
|
||
<view class="h5-tip">
|
||
<text>请在微信小程序中使用本应用</text>
|
||
</view>
|
||
<!-- H5 降级:账号密码登录 -->
|
||
<view class="form" v-if="showFallback">
|
||
<view class="form-item">
|
||
<text class="label">账号</text>
|
||
<input class="input" v-model="form.username" placeholder="请输入账号" />
|
||
</view>
|
||
<view class="form-item">
|
||
<text class="label">密码</text>
|
||
<input class="input" v-model="form.password" placeholder="请输入密码" password />
|
||
</view>
|
||
<button class="btn-fallback" @click="handleFallbackLogin" :disabled="loading">
|
||
{{ loading ? '登录中...' : '账号密码登录' }}
|
||
</button>
|
||
</view>
|
||
<button class="btn-toggle" v-if="!showFallback" @click="showFallback = true">
|
||
使用账号密码登录
|
||
</button>
|
||
<!-- #endif -->
|
||
</view>
|
||
|
||
<view class="agreement">
|
||
<text class="agreement-text">登录即表示同意</text>
|
||
<text class="agreement-link">《用户协议》</text>
|
||
<text class="agreement-text">和</text>
|
||
<text class="agreement-link">《隐私政策》</text>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive } from 'vue'
|
||
import { useUserStore } from '../../store/user'
|
||
import { showToast } from '../../utils'
|
||
|
||
const userStore = useUserStore()
|
||
const loading = ref(false)
|
||
const showFallback = ref(false)
|
||
|
||
const form = reactive({
|
||
username: '',
|
||
password: ''
|
||
})
|
||
|
||
/** 微信一键登录 */
|
||
async function handleWxLogin() {
|
||
loading.value = true
|
||
try {
|
||
await userStore.wxLogin()
|
||
showToast('登录成功', 'success')
|
||
setTimeout(() => {
|
||
uni.switchTab({ url: '/packages/user/pages/index/index' })
|
||
}, 1000)
|
||
} catch (err: any) {
|
||
console.error('微信登录失败:', err)
|
||
showToast(err.message || '登录失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
/** 账号密码登录(降级方案 / H5 用) */
|
||
async function handleFallbackLogin() {
|
||
if (!form.username || !form.password) {
|
||
showToast('请输入账号和密码')
|
||
return
|
||
}
|
||
loading.value = true
|
||
try {
|
||
await userStore.login({ username: form.username, password: form.password })
|
||
showToast('登录成功', 'success')
|
||
setTimeout(() => {
|
||
uni.switchTab({ url: '/packages/user/pages/index/index' })
|
||
}, 1000)
|
||
} catch (err: any) {
|
||
console.error('登录失败:', err)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.container {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 100vh;
|
||
padding: 0 60rpx;
|
||
box-sizing: border-box;
|
||
background: #fff;
|
||
}
|
||
|
||
.header {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding-top: 160rpx;
|
||
margin-bottom: 80rpx;
|
||
}
|
||
|
||
.logo {
|
||
width: 140rpx;
|
||
height: 140rpx;
|
||
margin-bottom: 30rpx;
|
||
}
|
||
|
||
.app-name {
|
||
font-size: 44rpx;
|
||
font-weight: bold;
|
||
color: #333;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.app-desc {
|
||
font-size: 26rpx;
|
||
color: #999;
|
||
}
|
||
|
||
.login-area {
|
||
flex: 1;
|
||
}
|
||
|
||
.btn-wx-login {
|
||
width: 100%;
|
||
height: 96rpx;
|
||
line-height: 96rpx;
|
||
background: #07C160;
|
||
color: #fff;
|
||
border-radius: 48rpx;
|
||
font-size: 32rpx;
|
||
border: none;
|
||
}
|
||
|
||
.btn-wx-login[disabled] {
|
||
background: #9ED6B5;
|
||
}
|
||
|
||
.form {
|
||
margin-top: 40rpx;
|
||
}
|
||
|
||
.form-item {
|
||
margin-bottom: 30rpx;
|
||
}
|
||
|
||
.label {
|
||
display: block;
|
||
font-size: 28rpx;
|
||
color: #333;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
|
||
.input {
|
||
width: 100%;
|
||
height: 80rpx;
|
||
padding: 0 24rpx;
|
||
background: #f5f5f5;
|
||
border-radius: 12rpx;
|
||
font-size: 28rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.btn-fallback {
|
||
width: 100%;
|
||
height: 88rpx;
|
||
line-height: 88rpx;
|
||
background: #4A90D9;
|
||
color: #fff;
|
||
border-radius: 44rpx;
|
||
font-size: 30rpx;
|
||
margin-top: 20rpx;
|
||
border: none;
|
||
}
|
||
|
||
.btn-toggle {
|
||
width: 100%;
|
||
height: 88rpx;
|
||
line-height: 88rpx;
|
||
background: transparent;
|
||
color: #4A90D9;
|
||
border: 2rpx solid #4A90D9;
|
||
border-radius: 44rpx;
|
||
font-size: 30rpx;
|
||
margin-top: 30rpx;
|
||
}
|
||
|
||
.h5-tip {
|
||
text-align: center;
|
||
padding: 40rpx;
|
||
color: #999;
|
||
font-size: 28rpx;
|
||
}
|
||
|
||
.agreement {
|
||
display: flex;
|
||
justify-content: center;
|
||
flex-wrap: wrap;
|
||
padding: 40rpx 0 60rpx;
|
||
}
|
||
|
||
.agreement-text {
|
||
font-size: 22rpx;
|
||
color: #999;
|
||
}
|
||
|
||
.agreement-link {
|
||
font-size: 22rpx;
|
||
color: #4A90D9;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 3:改造 App.vue 添加启动检查**
|
||
|
||
将 `src/App.vue` 替换为:
|
||
```vue
|
||
<script setup lang="ts">
|
||
import { onLaunch } from '@dcloudio/uni-app'
|
||
import { useUserStore } from './store/user'
|
||
|
||
onLaunch(() => {
|
||
const userStore = useUserStore()
|
||
// 如果有 token,尝试获取用户信息
|
||
if (userStore.isLogin) {
|
||
userStore.fetchUserInfo().catch(() => {
|
||
// 获取用户信息失败不影响使用
|
||
})
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style>
|
||
page {
|
||
min-height: 100vh;
|
||
background-color: #f5f5f5;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 4:验证编译**
|
||
|
||
运行:
|
||
```bash
|
||
cd zhy-test && npx vue-tsc --noEmit 2>&1 | head -20
|
||
```
|
||
预期:入口页和登录页无类型错误。
|
||
|
||
- [ ] **Step 5:提交**
|
||
|
||
```bash
|
||
git add src/pages/index/index.vue src/pages/login/login.vue src/App.vue
|
||
git commit -m "feat: 改造入口页(自动跳转)和登录页(微信授权登录)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10:用户端首页(宫格式)
|
||
|
||
**Files:**
|
||
- Create: `src/packages/user/pages/index/index.vue`
|
||
|
||
- [ ] **Step 1:创建用户端首页**
|
||
|
||
创建 `src/packages/user/pages/index/index.vue`:
|
||
```vue
|
||
<template>
|
||
<view class="page">
|
||
<!-- 顶部标题区 -->
|
||
<view class="header">
|
||
<text class="header-title">湖南12301文旅专区</text>
|
||
</view>
|
||
|
||
<!-- 宫格功能入口 -->
|
||
<view class="grid">
|
||
<view class="grid-item" @click="goTo('/packages/user/pages/card/list')">
|
||
<view class="grid-icon" style="background: #E8F0FE;">
|
||
<text class="emoji">🎫</text>
|
||
</view>
|
||
<text class="grid-label">购买年卡</text>
|
||
<text class="grid-desc">多种卡种可选</text>
|
||
</view>
|
||
|
||
<view class="grid-item" @click="goTo('/packages/user/pages/card/bind')">
|
||
<view class="grid-icon" style="background: #FFF3E0;">
|
||
<text class="emoji">📱</text>
|
||
</view>
|
||
<text class="grid-label">激活/绑定</text>
|
||
<text class="grid-desc">激活已购年卡</text>
|
||
</view>
|
||
|
||
<view class="grid-item" @click="showComingSoon">
|
||
<view class="grid-icon" style="background: #E8F8F0;">
|
||
<text class="emoji">🏔️</text>
|
||
</view>
|
||
<text class="grid-label">景区预约</text>
|
||
<text class="grid-desc">预约入园时间</text>
|
||
</view>
|
||
|
||
<view class="grid-item" @click="showComingSoon">
|
||
<view class="grid-icon" style="background: #FCE4EC;">
|
||
<text class="emoji">📋</text>
|
||
</view>
|
||
<text class="grid-label">我的记录</text>
|
||
<text class="grid-desc">预约/使用记录</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 推荐年卡 -->
|
||
<view class="section">
|
||
<view class="section-header">
|
||
<text class="section-title">推荐年卡</text>
|
||
<text class="section-more" @click="goTo('/packages/user/pages/card/list')">查看全部 ›</text>
|
||
</view>
|
||
|
||
<view v-if="cardStore.loading" class="loading">
|
||
<text>加载中...</text>
|
||
</view>
|
||
|
||
<view v-else-if="cardStore.cardList.length === 0" class="empty">
|
||
<empty-state icon="🎫" text="暂无年卡" />
|
||
</view>
|
||
|
||
<view v-else>
|
||
<card-item
|
||
v-for="card in displayCards"
|
||
:key="card.id"
|
||
:card="card"
|
||
@click="goToDetail"
|
||
/>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed } from 'vue'
|
||
import { onShow } from '@dcloudio/uni-app'
|
||
import { useCardStore } from '../../../../store/card'
|
||
import CardItem from '../../../../components/card-item.vue'
|
||
import EmptyState from '../../../../components/empty-state.vue'
|
||
import type { CardInfo } from '../../../../api/types'
|
||
|
||
const cardStore = useCardStore()
|
||
|
||
// 首页最多展示 3 张年卡
|
||
const displayCards = computed(() => cardStore.cardList.slice(0, 3))
|
||
|
||
onShow(() => {
|
||
cardStore.fetchCardList()
|
||
})
|
||
|
||
function goTo(url: string) {
|
||
uni.navigateTo({ url })
|
||
}
|
||
|
||
function goToDetail(card: CardInfo) {
|
||
uni.navigateTo({ url: `/packages/user/pages/card/detail?id=${card.id}` })
|
||
}
|
||
|
||
function showComingSoon() {
|
||
uni.showToast({ title: '即将上线,敬请期待', icon: 'none' })
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.page {
|
||
min-height: 100vh;
|
||
background: #f5f5f5;
|
||
}
|
||
|
||
.header {
|
||
background: #4A90D9;
|
||
padding: 40rpx 30rpx 30rpx;
|
||
}
|
||
|
||
.header-title {
|
||
font-size: 36rpx;
|
||
font-weight: bold;
|
||
color: #fff;
|
||
}
|
||
|
||
.grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 16rpx;
|
||
padding: 20rpx;
|
||
margin: -20rpx 16rpx 0;
|
||
background: #fff;
|
||
border-radius: 16rpx;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
.grid-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 24rpx 0;
|
||
}
|
||
|
||
.grid-icon {
|
||
width: 100rpx;
|
||
height: 100rpx;
|
||
border-radius: 20rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
|
||
.emoji {
|
||
font-size: 44rpx;
|
||
}
|
||
|
||
.grid-label {
|
||
font-size: 28rpx;
|
||
font-weight: bold;
|
||
color: #333;
|
||
margin-bottom: 4rpx;
|
||
}
|
||
|
||
.grid-desc {
|
||
font-size: 22rpx;
|
||
color: #999;
|
||
}
|
||
|
||
.section {
|
||
padding: 20rpx;
|
||
}
|
||
|
||
.section-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.section-title {
|
||
font-size: 30rpx;
|
||
font-weight: bold;
|
||
color: #333;
|
||
}
|
||
|
||
.section-more {
|
||
font-size: 24rpx;
|
||
color: #4A90D9;
|
||
}
|
||
|
||
.loading {
|
||
text-align: center;
|
||
padding: 60rpx;
|
||
color: #999;
|
||
font-size: 28rpx;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 2:验证编译**
|
||
|
||
运行:
|
||
```bash
|
||
cd zhy-test && npx vue-tsc --noEmit 2>&1 | head -20
|
||
```
|
||
预期:无类型错误。
|
||
|
||
- [ ] **Step 3:提交**
|
||
|
||
```bash
|
||
git add src/packages/user/pages/index/
|
||
git commit -m "feat: 用户端首页(宫格式布局 + 推荐年卡)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11:年卡列表页
|
||
|
||
**Files:**
|
||
- Create: `src/packages/user/pages/card/list.vue`
|
||
|
||
- [ ] **Step 1:创建年卡列表页**
|
||
|
||
创建 `src/packages/user/pages/card/list.vue`:
|
||
```vue
|
||
<template>
|
||
<view class="page">
|
||
<!-- 搜索栏 -->
|
||
<view class="search-bar">
|
||
<uni-search-bar
|
||
v-model="keyword"
|
||
placeholder="搜索年卡"
|
||
@confirm="handleSearch"
|
||
@clear="handleSearch"
|
||
/>
|
||
</view>
|
||
|
||
<!-- 分类筛选 -->
|
||
<view class="filter-bar">
|
||
<view
|
||
v-for="cat in categories"
|
||
:key="cat.value"
|
||
class="filter-tag"
|
||
:class="{ active: currentCategory === cat.value }"
|
||
@click="selectCategory(cat.value)"
|
||
>
|
||
<text>{{ cat.label }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 年卡列表 -->
|
||
<view class="card-list">
|
||
<view v-if="cardStore.loading" class="loading">
|
||
<text>加载中...</text>
|
||
</view>
|
||
|
||
<view v-else-if="cardStore.cardList.length === 0">
|
||
<empty-state icon="🎫" text="暂无符合条件的年卡" />
|
||
</view>
|
||
|
||
<view v-else>
|
||
<card-item
|
||
v-for="card in cardStore.cardList"
|
||
:key="card.id"
|
||
:card="card"
|
||
@click="goDetail"
|
||
/>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref } from 'vue'
|
||
import { onLoad } from '@dcloudio/uni-app'
|
||
import { useCardStore } from '../../../../store/card'
|
||
import CardItem from '../../../../components/card-item.vue'
|
||
import EmptyState from '../../../../components/empty-state.vue'
|
||
import type { CardInfo } from '../../../../api/types'
|
||
|
||
const cardStore = useCardStore()
|
||
const keyword = ref('')
|
||
const currentCategory = ref('')
|
||
|
||
const categories = [
|
||
{ label: '全部', value: '' },
|
||
{ label: '旅游年卡', value: 'travel' },
|
||
{ label: '亲子年卡', value: 'family' },
|
||
{ label: '情侣年卡', value: 'couple' }
|
||
]
|
||
|
||
onLoad(() => {
|
||
cardStore.fetchCardList()
|
||
})
|
||
|
||
function handleSearch() {
|
||
cardStore.fetchCardList({ keyword: keyword.value, category: currentCategory.value })
|
||
}
|
||
|
||
function selectCategory(value: string) {
|
||
currentCategory.value = value
|
||
handleSearch()
|
||
}
|
||
|
||
function goDetail(card: CardInfo) {
|
||
uni.navigateTo({ url: `/packages/user/pages/card/detail?id=${card.id}` })
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.page {
|
||
min-height: 100vh;
|
||
background: #f5f5f5;
|
||
}
|
||
|
||
.search-bar {
|
||
background: #fff;
|
||
padding: 12rpx 20rpx;
|
||
}
|
||
|
||
.filter-bar {
|
||
display: flex;
|
||
gap: 16rpx;
|
||
padding: 16rpx 20rpx;
|
||
background: #fff;
|
||
border-bottom: 1rpx solid #f0f0f0;
|
||
}
|
||
|
||
.filter-tag {
|
||
padding: 10rpx 24rpx;
|
||
border-radius: 30rpx;
|
||
font-size: 24rpx;
|
||
color: #666;
|
||
background: #f5f5f5;
|
||
}
|
||
|
||
.filter-tag.active {
|
||
background: #4A90D9;
|
||
color: #fff;
|
||
}
|
||
|
||
.card-list {
|
||
padding: 20rpx;
|
||
}
|
||
|
||
.loading {
|
||
text-align: center;
|
||
padding: 60rpx;
|
||
color: #999;
|
||
font-size: 28rpx;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 2:提交**
|
||
|
||
```bash
|
||
git add src/packages/user/pages/card/list.vue
|
||
git commit -m "feat: 年卡列表页(搜索 + 分类筛选)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12:年卡详情页
|
||
|
||
**Files:**
|
||
- Create: `src/packages/user/pages/card/detail.vue`
|
||
|
||
- [ ] **Step 1:创建年卡详情页**
|
||
|
||
创建 `src/packages/user/pages/card/detail.vue`:
|
||
```vue
|
||
<template>
|
||
<view class="page" v-if="card">
|
||
<!-- 封面大图 -->
|
||
<image class="cover" :src="card.coverImage" mode="aspectFill" />
|
||
|
||
<!-- 基本信息 -->
|
||
<view class="info-card">
|
||
<text class="card-name">{{ card.name }}</text>
|
||
<view class="price-row">
|
||
<text class="price">¥{{ card.price }}</text>
|
||
<text class="original-price" v-if="card.originalPrice">¥{{ card.originalPrice }}</text>
|
||
</view>
|
||
<text class="card-desc">{{ card.description }}</text>
|
||
</view>
|
||
|
||
<!-- 权益说明 -->
|
||
<view class="section" v-if="card.benefits?.length">
|
||
<text class="section-title">权益说明</text>
|
||
<view class="benefit-list">
|
||
<view class="benefit-item" v-for="(benefit, idx) in card.benefits" :key="idx">
|
||
<text class="benefit-icon">✅</text>
|
||
<text class="benefit-text">{{ benefit }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 权益景区 -->
|
||
<view class="section" v-if="card.scenicSpots?.length">
|
||
<text class="section-title">权益景区({{ card.scenicSpots.length }}个)</text>
|
||
<scroll-view scroll-x class="scenic-scroll">
|
||
<view class="scenic-list">
|
||
<view class="scenic-card" v-for="spot in card.scenicSpots" :key="spot.id">
|
||
<image class="scenic-img" :src="spot.image" mode="aspectFill" />
|
||
<text class="scenic-name">{{ spot.name }}</text>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
|
||
<!-- 年卡详情 -->
|
||
<view class="section" v-if="card.detail">
|
||
<text class="section-title">年卡详情</text>
|
||
<rich-text :nodes="card.detail" />
|
||
</view>
|
||
|
||
<!-- 底部购买栏 -->
|
||
<view class="bottom-bar">
|
||
<view class="bottom-price">
|
||
<text class="label">价格</text>
|
||
<text class="value">¥{{ card.price }}</text>
|
||
</view>
|
||
<button class="btn-buy" @click="handleBuy">立即购买</button>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-else class="loading-page">
|
||
<text>加载中...</text>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref } from 'vue'
|
||
import { onLoad } from '@dcloudio/uni-app'
|
||
import { useCardStore } from '../../../../store/card'
|
||
import type { CardDetail } from '../../../../api/types'
|
||
|
||
const cardStore = useCardStore()
|
||
const card = ref<CardDetail | null>(null)
|
||
|
||
onLoad((options) => {
|
||
if (options?.id) {
|
||
loadDetail(options.id)
|
||
}
|
||
})
|
||
|
||
async function loadDetail(id: string) {
|
||
try {
|
||
await cardStore.fetchCardDetail(id)
|
||
card.value = cardStore.currentCard
|
||
} catch (err) {
|
||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||
}
|
||
}
|
||
|
||
function handleBuy() {
|
||
if (!card.value) return
|
||
uni.navigateTo({
|
||
url: `/packages/user/pages/card/buy?id=${card.value.id}&name=${encodeURIComponent(card.value.name)}&price=${card.value.price}`
|
||
})
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.page {
|
||
min-height: 100vh;
|
||
background: #f5f5f5;
|
||
padding-bottom: 120rpx;
|
||
}
|
||
|
||
.cover {
|
||
width: 100%;
|
||
height: 400rpx;
|
||
background: #ddd;
|
||
}
|
||
|
||
.info-card {
|
||
background: #fff;
|
||
padding: 24rpx 30rpx;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.card-name {
|
||
font-size: 36rpx;
|
||
font-weight: bold;
|
||
color: #333;
|
||
display: block;
|
||
}
|
||
|
||
.price-row {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 12rpx;
|
||
margin: 12rpx 0;
|
||
}
|
||
|
||
.price {
|
||
font-size: 44rpx;
|
||
color: #E74C3C;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.original-price {
|
||
font-size: 26rpx;
|
||
color: #ccc;
|
||
text-decoration: line-through;
|
||
}
|
||
|
||
.card-desc {
|
||
font-size: 26rpx;
|
||
color: #666;
|
||
}
|
||
|
||
.section {
|
||
background: #fff;
|
||
padding: 24rpx 30rpx;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.section-title {
|
||
font-size: 30rpx;
|
||
font-weight: bold;
|
||
color: #333;
|
||
display: block;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.benefit-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12rpx;
|
||
}
|
||
|
||
.benefit-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12rpx;
|
||
}
|
||
|
||
.benefit-icon {
|
||
font-size: 28rpx;
|
||
}
|
||
|
||
.benefit-text {
|
||
font-size: 26rpx;
|
||
color: #666;
|
||
}
|
||
|
||
.scenic-scroll {
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.scenic-list {
|
||
display: flex;
|
||
gap: 16rpx;
|
||
}
|
||
|
||
.scenic-card {
|
||
min-width: 200rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.scenic-img {
|
||
width: 200rpx;
|
||
height: 140rpx;
|
||
border-radius: 12rpx;
|
||
background: #eee;
|
||
}
|
||
|
||
.scenic-name {
|
||
font-size: 24rpx;
|
||
color: #333;
|
||
margin-top: 8rpx;
|
||
text-align: center;
|
||
}
|
||
|
||
.bottom-bar {
|
||
position: fixed;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
background: #fff;
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 16rpx 30rpx;
|
||
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
|
||
border-top: 1rpx solid #eee;
|
||
}
|
||
|
||
.bottom-price {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.bottom-price .label {
|
||
font-size: 22rpx;
|
||
color: #999;
|
||
}
|
||
|
||
.bottom-price .value {
|
||
font-size: 36rpx;
|
||
color: #E74C3C;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.btn-buy {
|
||
background: #E74C3C;
|
||
color: #fff;
|
||
font-size: 30rpx;
|
||
padding: 20rpx 60rpx;
|
||
border-radius: 44rpx;
|
||
border: none;
|
||
}
|
||
|
||
.loading-page {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 100vh;
|
||
color: #999;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 2:提交**
|
||
|
||
```bash
|
||
git add src/packages/user/pages/card/detail.vue
|
||
git commit -m "feat: 年卡详情页(封面 + 权益 + 景区 + 购买按钮)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13:确认购买页(模拟支付)
|
||
|
||
**Files:**
|
||
- Create: `src/packages/user/pages/card/buy.vue`
|
||
|
||
- [ ] **Step 1:创建确认购买页**
|
||
|
||
创建 `src/packages/user/pages/card/buy.vue`:
|
||
```vue
|
||
<template>
|
||
<view class="page">
|
||
<!-- 订单信息 -->
|
||
<view class="order-card">
|
||
<text class="card-title">订单信息</text>
|
||
<view class="order-row">
|
||
<text class="label">年卡名称</text>
|
||
<text class="value">{{ cardName }}</text>
|
||
</view>
|
||
<view class="order-row">
|
||
<text class="label">有效期</text>
|
||
<text class="value">365天</text>
|
||
</view>
|
||
<view class="order-row">
|
||
<text class="label">价格</text>
|
||
<text class="value price">¥{{ price }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 支付方式 -->
|
||
<view class="pay-card">
|
||
<text class="card-title">支付方式</text>
|
||
<view class="pay-option active">
|
||
<text class="pay-icon">💳</text>
|
||
<text class="pay-name">模拟支付(测试)</text>
|
||
<text class="pay-check">✓</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 底部支付栏 -->
|
||
<view class="bottom-bar">
|
||
<view class="bottom-price">
|
||
<text class="label">应付金额</text>
|
||
<text class="value">¥{{ price }}</text>
|
||
</view>
|
||
<button class="btn-pay" @click="handlePay" :disabled="paying">
|
||
{{ paying ? '支付中...' : '确认支付' }}
|
||
</button>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref } from 'vue'
|
||
import { onLoad } from '@dcloudio/uni-app'
|
||
import { createOrder, mockPay } from '../../../../api/order'
|
||
import { showToast } from '../../../../utils'
|
||
|
||
const cardId = ref('')
|
||
const cardName = ref('')
|
||
const price = ref(0)
|
||
const paying = ref(false)
|
||
|
||
onLoad((options) => {
|
||
cardId.value = options?.id || ''
|
||
cardName.value = decodeURIComponent(options?.name || '')
|
||
price.value = Number(options?.price || 0)
|
||
})
|
||
|
||
async function handlePay() {
|
||
if (!cardId.value) return
|
||
paying.value = true
|
||
try {
|
||
// 1. 创建订单
|
||
const order = await createOrder({ cardId: cardId.value })
|
||
|
||
// 2. 模拟支付
|
||
const payResult = await mockPay(order.id)
|
||
|
||
if (payResult.success) {
|
||
showToast('支付成功', 'success')
|
||
setTimeout(() => {
|
||
// 跳转到激活页面
|
||
uni.redirectTo({
|
||
url: `/packages/user/pages/card/activate?cardId=${cardId.value}&name=${encodeURIComponent(cardName.value)}`
|
||
})
|
||
}, 1500)
|
||
} else {
|
||
showToast('支付失败')
|
||
}
|
||
} catch (err: any) {
|
||
showToast(err.message || '支付失败')
|
||
} finally {
|
||
paying.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.page {
|
||
min-height: 100vh;
|
||
background: #f5f5f5;
|
||
padding: 20rpx;
|
||
padding-bottom: 140rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.order-card,
|
||
.pay-card {
|
||
background: #fff;
|
||
border-radius: 16rpx;
|
||
padding: 24rpx 30rpx;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.card-title {
|
||
font-size: 30rpx;
|
||
font-weight: bold;
|
||
color: #333;
|
||
display: block;
|
||
margin-bottom: 20rpx;
|
||
padding-bottom: 16rpx;
|
||
border-bottom: 1rpx solid #f0f0f0;
|
||
}
|
||
|
||
.order-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 16rpx 0;
|
||
}
|
||
|
||
.label {
|
||
font-size: 28rpx;
|
||
color: #666;
|
||
}
|
||
|
||
.value {
|
||
font-size: 28rpx;
|
||
color: #333;
|
||
}
|
||
|
||
.value.price {
|
||
color: #E74C3C;
|
||
font-weight: bold;
|
||
font-size: 36rpx;
|
||
}
|
||
|
||
.pay-option {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 20rpx 0;
|
||
}
|
||
|
||
.pay-option.active {
|
||
color: #4A90D9;
|
||
}
|
||
|
||
.pay-icon {
|
||
font-size: 36rpx;
|
||
margin-right: 16rpx;
|
||
}
|
||
|
||
.pay-name {
|
||
flex: 1;
|
||
font-size: 28rpx;
|
||
}
|
||
|
||
.pay-check {
|
||
font-size: 32rpx;
|
||
color: #4A90D9;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.bottom-bar {
|
||
position: fixed;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
background: #fff;
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 16rpx 30rpx;
|
||
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
|
||
border-top: 1rpx solid #eee;
|
||
}
|
||
|
||
.bottom-price {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.bottom-price .label {
|
||
font-size: 22rpx;
|
||
color: #999;
|
||
}
|
||
|
||
.bottom-price .value {
|
||
font-size: 40rpx;
|
||
color: #E74C3C;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.btn-pay {
|
||
background: #E74C3C;
|
||
color: #fff;
|
||
font-size: 30rpx;
|
||
padding: 20rpx 60rpx;
|
||
border-radius: 44rpx;
|
||
border: none;
|
||
}
|
||
|
||
.btn-pay[disabled] {
|
||
background: #F5A8A3;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 2:提交**
|
||
|
||
```bash
|
||
git add src/packages/user/pages/card/buy.vue
|
||
git commit -m "feat: 确认购买页(模拟支付流程)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 14:激活年卡页
|
||
|
||
**Files:**
|
||
- Create: `src/packages/user/pages/card/activate.vue`
|
||
|
||
- [ ] **Step 1:创建激活年卡页**
|
||
|
||
创建 `src/packages/user/pages/card/activate.vue`:
|
||
```vue
|
||
<template>
|
||
<view class="page">
|
||
<!-- 年卡信息 -->
|
||
<view class="card-info">
|
||
<text class="card-name">{{ cardName }}</text>
|
||
<text class="card-tip">请填写实名信息以激活年卡</text>
|
||
</view>
|
||
|
||
<!-- 实名表单 -->
|
||
<view class="form-card">
|
||
<view class="form-item">
|
||
<text class="label">真实姓名</text>
|
||
<input class="input" v-model="form.realName" placeholder="请输入真实姓名" />
|
||
</view>
|
||
<view class="form-item">
|
||
<text class="label">身份证号</text>
|
||
<input class="input" v-model="form.idCard" placeholder="请输入身份证号" />
|
||
</view>
|
||
<view class="form-item">
|
||
<text class="label">手机号码</text>
|
||
<input class="input" v-model="form.phone" placeholder="请输入手机号码" type="number" />
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 激活按钮 -->
|
||
<view class="btn-area">
|
||
<button class="btn-activate" @click="handleActivate" :disabled="activating">
|
||
{{ activating ? '激活中...' : '立即激活' }}
|
||
</button>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive } from 'vue'
|
||
import { onLoad } from '@dcloudio/uni-app'
|
||
import { useCardStore } from '../../../../store/card'
|
||
import { showToast } from '../../../../utils'
|
||
|
||
const cardStore = useCardStore()
|
||
const cardId = ref('')
|
||
const cardName = ref('')
|
||
const activating = ref(false)
|
||
|
||
const form = reactive({
|
||
realName: '',
|
||
idCard: '',
|
||
phone: ''
|
||
})
|
||
|
||
onLoad((options) => {
|
||
cardId.value = options?.cardId || ''
|
||
cardName.value = decodeURIComponent(options?.name || '')
|
||
})
|
||
|
||
function validate(): boolean {
|
||
if (!form.realName.trim()) {
|
||
showToast('请输入真实姓名')
|
||
return false
|
||
}
|
||
if (!/^\d{17}[\dXx]$/.test(form.idCard)) {
|
||
showToast('请输入正确的身份证号')
|
||
return false
|
||
}
|
||
if (!/^1\d{10}$/.test(form.phone)) {
|
||
showToast('请输入正确的手机号码')
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
async function handleActivate() {
|
||
if (!validate()) return
|
||
if (!cardId.value) return
|
||
|
||
activating.value = true
|
||
try {
|
||
await cardStore.activateCard(cardId.value, form)
|
||
showToast('激活成功', 'success')
|
||
setTimeout(() => {
|
||
uni.redirectTo({
|
||
url: `/packages/user/pages/card/qrcode?cardId=${cardId.value}`
|
||
})
|
||
}, 1500)
|
||
} catch (err: any) {
|
||
showToast(err.message || '激活失败')
|
||
} finally {
|
||
activating.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.page {
|
||
min-height: 100vh;
|
||
background: #f5f5f5;
|
||
padding: 20rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.card-info {
|
||
background: #4A90D9;
|
||
border-radius: 16rpx;
|
||
padding: 30rpx;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.card-name {
|
||
font-size: 34rpx;
|
||
font-weight: bold;
|
||
color: #fff;
|
||
display: block;
|
||
margin-bottom: 8rpx;
|
||
}
|
||
|
||
.card-tip {
|
||
font-size: 24rpx;
|
||
color: rgba(255, 255, 255, 0.8);
|
||
}
|
||
|
||
.form-card {
|
||
background: #fff;
|
||
border-radius: 16rpx;
|
||
padding: 10rpx 30rpx;
|
||
}
|
||
|
||
.form-item {
|
||
padding: 24rpx 0;
|
||
border-bottom: 1rpx solid #f0f0f0;
|
||
}
|
||
|
||
.form-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.label {
|
||
display: block;
|
||
font-size: 26rpx;
|
||
color: #666;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
|
||
.input {
|
||
width: 100%;
|
||
height: 80rpx;
|
||
font-size: 30rpx;
|
||
color: #333;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.btn-area {
|
||
padding: 40rpx 20rpx;
|
||
}
|
||
|
||
.btn-activate {
|
||
width: 100%;
|
||
height: 96rpx;
|
||
line-height: 96rpx;
|
||
background: #4A90D9;
|
||
color: #fff;
|
||
border-radius: 48rpx;
|
||
font-size: 32rpx;
|
||
border: none;
|
||
}
|
||
|
||
.btn-activate[disabled] {
|
||
background: #A5C8E8;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 2:提交**
|
||
|
||
```bash
|
||
git add src/packages/user/pages/card/activate.vue
|
||
git commit -m "feat: 激活年卡页(实名表单 + 验证)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 15:入园码页(卡片式)
|
||
|
||
**Files:**
|
||
- Create: `src/packages/user/pages/card/qrcode.vue`
|
||
|
||
- [ ] **Step 1:创建入园码页**
|
||
|
||
创建 `src/packages/user/pages/card/qrcode.vue`:
|
||
```vue
|
||
<template>
|
||
<view class="page">
|
||
<!-- 年卡信息卡片 -->
|
||
<view class="card-top">
|
||
<text class="card-label">{{ qrInfo?.cardName || '年卡' }}</text>
|
||
<text class="user-name">{{ qrInfo?.userName || '---' }}</text>
|
||
<text class="expire-date">有效期至 {{ qrInfo?.expireDate || '---' }}</text>
|
||
</view>
|
||
|
||
<!-- 二维码卡片 -->
|
||
<view class="qr-card">
|
||
<view class="qr-area">
|
||
<image v-if="qrInfo?.qrCode" class="qr-image" :src="qrInfo.qrCode" mode="aspectFit" />
|
||
<view v-else class="qr-placeholder">
|
||
<text>加载中...</text>
|
||
</view>
|
||
</view>
|
||
<text class="qr-tip">扫码入园 · 每60秒自动刷新</text>
|
||
</view>
|
||
|
||
<!-- 底部操作 -->
|
||
<view class="actions">
|
||
<button class="btn-secondary" @click="showComingSoon">预约景区</button>
|
||
<button class="btn-primary" @click="refreshQrCode">刷新二维码</button>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onBeforeUnmount } from 'vue'
|
||
import { onLoad } from '@dcloudio/uni-app'
|
||
import { useCardStore } from '../../../../store/card'
|
||
import { showToast } from '../../../../utils'
|
||
import type { QrCodeInfo } from '../../../../api/types'
|
||
|
||
const cardStore = useCardStore()
|
||
const cardId = ref('')
|
||
const qrInfo = ref<QrCodeInfo | null>(null)
|
||
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||
|
||
onLoad((options) => {
|
||
cardId.value = options?.cardId || ''
|
||
if (cardId.value) {
|
||
loadQrCode()
|
||
// 每 60 秒自动刷新
|
||
refreshTimer = setInterval(() => {
|
||
loadQrCode()
|
||
}, 60000)
|
||
}
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
if (refreshTimer) {
|
||
clearInterval(refreshTimer)
|
||
}
|
||
})
|
||
|
||
async function loadQrCode() {
|
||
try {
|
||
qrInfo.value = await cardStore.getEntryQrCode(cardId.value)
|
||
} catch (err: any) {
|
||
showToast(err.message || '获取入园码失败')
|
||
}
|
||
}
|
||
|
||
function refreshQrCode() {
|
||
loadQrCode()
|
||
showToast('已刷新', 'success')
|
||
}
|
||
|
||
function showComingSoon() {
|
||
showToast('即将上线,敬请期待')
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.page {
|
||
min-height: 100vh;
|
||
background: #f5f5f5;
|
||
padding: 20rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.card-top {
|
||
background: linear-gradient(135deg, #4A90D9, #357ABD);
|
||
border-radius: 20rpx;
|
||
padding: 30rpx;
|
||
color: #fff;
|
||
margin-bottom: -20rpx;
|
||
}
|
||
|
||
.card-label {
|
||
font-size: 24rpx;
|
||
opacity: 0.8;
|
||
display: block;
|
||
}
|
||
|
||
.user-name {
|
||
font-size: 36rpx;
|
||
font-weight: bold;
|
||
display: block;
|
||
margin: 12rpx 0 8rpx;
|
||
}
|
||
|
||
.expire-date {
|
||
font-size: 22rpx;
|
||
opacity: 0.7;
|
||
display: block;
|
||
}
|
||
|
||
.qr-card {
|
||
background: #fff;
|
||
border-radius: 16rpx;
|
||
padding: 30rpx;
|
||
margin-top: 30rpx;
|
||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
|
||
text-align: center;
|
||
}
|
||
|
||
.qr-area {
|
||
width: 360rpx;
|
||
height: 360rpx;
|
||
margin: 20rpx auto;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: #f8f8f8;
|
||
border-radius: 12rpx;
|
||
}
|
||
|
||
.qr-image {
|
||
width: 320rpx;
|
||
height: 320rpx;
|
||
}
|
||
|
||
.qr-placeholder {
|
||
color: #999;
|
||
font-size: 28rpx;
|
||
}
|
||
|
||
.qr-tip {
|
||
font-size: 24rpx;
|
||
color: #999;
|
||
display: block;
|
||
margin-top: 16rpx;
|
||
}
|
||
|
||
.actions {
|
||
display: flex;
|
||
gap: 20rpx;
|
||
padding: 40rpx 20rpx;
|
||
}
|
||
|
||
.btn-secondary {
|
||
flex: 1;
|
||
height: 88rpx;
|
||
line-height: 88rpx;
|
||
background: #fff;
|
||
color: #4A90D9;
|
||
border: 2rpx solid #4A90D9;
|
||
border-radius: 44rpx;
|
||
font-size: 28rpx;
|
||
}
|
||
|
||
.btn-primary {
|
||
flex: 1;
|
||
height: 88rpx;
|
||
line-height: 88rpx;
|
||
background: #4A90D9;
|
||
color: #fff;
|
||
border-radius: 44rpx;
|
||
font-size: 28rpx;
|
||
border: none;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 2:提交**
|
||
|
||
```bash
|
||
git add src/packages/user/pages/card/qrcode.vue
|
||
git commit -m "feat: 入园码页(卡片式 + 60秒自动刷新)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 16:绑定年卡页
|
||
|
||
**Files:**
|
||
- Create: `src/packages/user/pages/card/bind.vue`
|
||
|
||
- [ ] **Step 1:创建绑定年卡页**
|
||
|
||
创建 `src/packages/user/pages/card/bind.vue`:
|
||
```vue
|
||
<template>
|
||
<view class="page">
|
||
<view class="header">
|
||
<text class="title">绑定年卡</text>
|
||
<text class="desc">输入卡号绑定已有年卡</text>
|
||
</view>
|
||
|
||
<view class="form-card">
|
||
<view class="form-item">
|
||
<text class="label">卡号</text>
|
||
<input class="input" v-model="cardNo" placeholder="请输入年卡卡号" />
|
||
</view>
|
||
</view>
|
||
|
||
<view class="btn-area">
|
||
<button class="btn-bind" @click="handleBind" :disabled="binding">
|
||
{{ binding ? '绑定中...' : '立即绑定' }}
|
||
</button>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref } from 'vue'
|
||
import { useCardStore } from '../../../../store/card'
|
||
import { showToast } from '../../../../utils'
|
||
|
||
const cardStore = useCardStore()
|
||
const cardNo = ref('')
|
||
const binding = ref(false)
|
||
|
||
async function handleBind() {
|
||
if (!cardNo.value.trim()) {
|
||
showToast('请输入年卡卡号')
|
||
return
|
||
}
|
||
binding.value = true
|
||
try {
|
||
await cardStore.bindCard(cardNo.value.trim())
|
||
showToast('绑定成功', 'success')
|
||
setTimeout(() => {
|
||
uni.navigateBack()
|
||
}, 1500)
|
||
} catch (err: any) {
|
||
showToast(err.message || '绑定失败')
|
||
} finally {
|
||
binding.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.page {
|
||
min-height: 100vh;
|
||
background: #f5f5f5;
|
||
padding: 20rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.header {
|
||
padding: 20rpx 10rpx 30rpx;
|
||
}
|
||
|
||
.title {
|
||
font-size: 36rpx;
|
||
font-weight: bold;
|
||
color: #333;
|
||
display: block;
|
||
margin-bottom: 8rpx;
|
||
}
|
||
|
||
.desc {
|
||
font-size: 26rpx;
|
||
color: #999;
|
||
}
|
||
|
||
.form-card {
|
||
background: #fff;
|
||
border-radius: 16rpx;
|
||
padding: 10rpx 30rpx;
|
||
}
|
||
|
||
.form-item {
|
||
padding: 24rpx 0;
|
||
}
|
||
|
||
.label {
|
||
display: block;
|
||
font-size: 26rpx;
|
||
color: #666;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
|
||
.input {
|
||
width: 100%;
|
||
height: 80rpx;
|
||
font-size: 30rpx;
|
||
color: #333;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.btn-area {
|
||
padding: 40rpx 20rpx;
|
||
}
|
||
|
||
.btn-bind {
|
||
width: 100%;
|
||
height: 96rpx;
|
||
line-height: 96rpx;
|
||
background: #4A90D9;
|
||
color: #fff;
|
||
border-radius: 48rpx;
|
||
font-size: 32rpx;
|
||
border: none;
|
||
}
|
||
|
||
.btn-bind[disabled] {
|
||
background: #A5C8E8;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 2:提交**
|
||
|
||
```bash
|
||
git add src/packages/user/pages/card/bind.vue
|
||
git commit -m "feat: 绑定年卡页"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 17:用户端个人中心
|
||
|
||
**Files:**
|
||
- Create: `src/packages/user/pages/profile/index.vue`
|
||
|
||
- [ ] **Step 1:创建用户端个人中心**
|
||
|
||
创建 `src/packages/user/pages/profile/index.vue`:
|
||
```vue
|
||
<template>
|
||
<view class="page">
|
||
<!-- 用户信息卡片 -->
|
||
<view class="user-card">
|
||
<view class="avatar-wrap">
|
||
<image
|
||
class="avatar"
|
||
:src="userStore.userInfo?.avatarUrl || defaultAvatar"
|
||
mode="aspectFill"
|
||
/>
|
||
</view>
|
||
<view class="user-info">
|
||
<text class="nickname">{{ userStore.userInfo?.nickname || '未登录' }}</text>
|
||
<text class="user-desc" v-if="userStore.isLogin">欢迎使用湖南12301文旅专区</text>
|
||
<text class="user-desc" v-else @click="goLogin">点击登录</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 功能菜单 -->
|
||
<view class="menu-card">
|
||
<view class="menu-item" @click="goTo('/packages/user/pages/card/bind')">
|
||
<text class="menu-icon">🎫</text>
|
||
<text class="menu-text">我的年卡</text>
|
||
<text class="menu-arrow">›</text>
|
||
</view>
|
||
<view class="menu-item" @click="showComingSoon">
|
||
<text class="menu-icon">📦</text>
|
||
<text class="menu-text">订单管理</text>
|
||
<text class="menu-arrow">›</text>
|
||
</view>
|
||
<view class="menu-item" @click="showComingSoon">
|
||
<text class="menu-icon">📋</text>
|
||
<text class="menu-text">使用记录</text>
|
||
<text class="menu-arrow">›</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 退出登录 -->
|
||
<view class="btn-area" v-if="userStore.isLogin">
|
||
<button class="btn-logout" @click="handleLogout">退出登录</button>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { useUserStore } from '../../../../store/user'
|
||
|
||
const userStore = useUserStore()
|
||
const defaultAvatar = 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0'
|
||
|
||
function goLogin() {
|
||
uni.navigateTo({ url: '/pages/login/login' })
|
||
}
|
||
|
||
function goTo(url: string) {
|
||
if (!userStore.isLogin) {
|
||
uni.navigateTo({ url: '/pages/login/login' })
|
||
return
|
||
}
|
||
uni.navigateTo({ url })
|
||
}
|
||
|
||
function handleLogout() {
|
||
uni.showModal({
|
||
title: '提示',
|
||
content: '确定要退出登录吗?',
|
||
success: (res) => {
|
||
if (res.confirm) {
|
||
userStore.logout()
|
||
uni.showToast({ title: '已退出', icon: 'success' })
|
||
setTimeout(() => {
|
||
uni.navigateTo({ url: '/pages/login/login' })
|
||
}, 1000)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
function showComingSoon() {
|
||
uni.showToast({ title: '即将上线,敬请期待', icon: 'none' })
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.page {
|
||
min-height: 100vh;
|
||
background: #f5f5f5;
|
||
}
|
||
|
||
.user-card {
|
||
background: linear-gradient(135deg, #4A90D9, #357ABD);
|
||
padding: 50rpx 30rpx 40rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.avatar-wrap {
|
||
margin-right: 24rpx;
|
||
}
|
||
|
||
.avatar {
|
||
width: 120rpx;
|
||
height: 120rpx;
|
||
border-radius: 50%;
|
||
border: 4rpx solid rgba(255, 255, 255, 0.5);
|
||
background: #eee;
|
||
}
|
||
|
||
.user-info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.nickname {
|
||
font-size: 34rpx;
|
||
font-weight: bold;
|
||
color: #fff;
|
||
margin-bottom: 8rpx;
|
||
}
|
||
|
||
.user-desc {
|
||
font-size: 24rpx;
|
||
color: rgba(255, 255, 255, 0.8);
|
||
}
|
||
|
||
.menu-card {
|
||
background: #fff;
|
||
border-radius: 16rpx;
|
||
margin: 20rpx;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.menu-item {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 30rpx;
|
||
border-bottom: 1rpx solid #f0f0f0;
|
||
}
|
||
|
||
.menu-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.menu-icon {
|
||
font-size: 36rpx;
|
||
margin-right: 20rpx;
|
||
}
|
||
|
||
.menu-text {
|
||
flex: 1;
|
||
font-size: 28rpx;
|
||
color: #333;
|
||
}
|
||
|
||
.menu-arrow {
|
||
font-size: 36rpx;
|
||
color: #ccc;
|
||
}
|
||
|
||
.btn-area {
|
||
padding: 40rpx 20rpx;
|
||
}
|
||
|
||
.btn-logout {
|
||
width: 100%;
|
||
height: 88rpx;
|
||
line-height: 88rpx;
|
||
background: #fff;
|
||
color: #E74C3C;
|
||
border-radius: 44rpx;
|
||
font-size: 30rpx;
|
||
border: 1rpx solid #f0f0f0;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 2:提交**
|
||
|
||
```bash
|
||
git add src/packages/user/pages/profile/
|
||
git commit -m "feat: 用户端个人中心(用户信息 + 功能菜单)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 18:编译验证 + 最终提交
|
||
|
||
**Files:** 无新增/修改,仅验证
|
||
|
||
- [ ] **Step 1:类型检查**
|
||
|
||
运行:
|
||
```bash
|
||
cd zhy-test && npx vue-tsc --noEmit 2>&1
|
||
```
|
||
预期:无类型错误。如有错误,逐一修复。
|
||
|
||
- [ ] **Step 2:H5 模式编译测试**
|
||
|
||
运行:
|
||
```bash
|
||
cd zhy-test && npx uni 2>&1 | head -30
|
||
```
|
||
预期:Vite 开发服务器启动成功。(Ctrl+C 停止)
|
||
|
||
- [ ] **Step 3:微信小程序编译测试**
|
||
|
||
运行:
|
||
```bash
|
||
cd zhy-test && npx uni build -p mp-weixin 2>&1 | tail -20
|
||
```
|
||
预期:构建成功,生成 `dist/build/mp-weixin` 目录。
|
||
|
||
- [ ] **Step 4:检查分包目录结构**
|
||
|
||
运行:
|
||
```bash
|
||
find src/packages -type f -name '*.vue' | sort
|
||
```
|
||
预期输出:
|
||
```
|
||
src/packages/user/pages/card/activate.vue
|
||
src/packages/user/pages/card/bind.vue
|
||
src/packages/user/pages/card/buy.vue
|
||
src/packages/user/pages/card/detail.vue
|
||
src/packages/user/pages/card/list.vue
|
||
src/packages/user/pages/card/qrcode.vue
|
||
src/packages/user/pages/index/index.vue
|
||
src/packages/user/pages/profile/index.vue
|
||
```
|
||
|
||
- [ ] **Step 5:最终提交**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: 湖南12301第一迭代完成 — 基础层 + 用户端核心流程"
|
||
```
|
||
|
||
---
|
||
|
||
## 手动测试清单
|
||
|
||
编译为微信小程序后,在微信开发者工具中按以下流程手动验证:
|
||
|
||
| 步骤 | 操作 | 预期结果 |
|
||
|------|------|----------|
|
||
| 1 | 启动小程序 | 显示启动页,自动跳转到登录页 |
|
||
| 2 | 点击"微信一键登录" | 调用微信登录,成功后跳转用户首页 |
|
||
| 3 | 用户首页 | 显示宫格入口 + 推荐年卡列表 |
|
||
| 4 | 点击"景区预约" | 弹出"即将上线"提示 |
|
||
| 5 | 点击"购买年卡" | 进入年卡列表页 |
|
||
| 6 | 点击一张年卡 | 进入年卡详情页 |
|
||
| 7 | 点击"立即购买" | 进入确认购买页 |
|
||
| 8 | 点击"确认支付" | 模拟支付成功,跳转激活页 |
|
||
| 9 | 填写实名信息并激活 | 激活成功,跳转入园码页 |
|
||
| 10 | 入园码页 | 显示年卡信息 + 二维码,60秒自动刷新 |
|
||
| 11 | 切换到"我的"Tab | 显示个人中心 |
|
||
| 12 | 点击"退出登录" | 清除状态,回到登录页 |
|