Files
pingxiangcard/docs/superpowers/plans/2026-06-13-hunan12301-first-iteration.md

3076 lines
66 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 湖南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 | 点击"退出登录" | 清除状态,回到登录页 |