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

66 KiB
Raw Blame History

湖南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:

/**
 * 环境配置
 * 根据 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 环境变量类型声明(如果文件已有内容则追加):

/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_API_BASE_URL: string
  readonly VITE_APP_TITLE: string
}

interface ImportMeta {
  readonly env: ImportMetaEnv
}
  • Step 4:验证编译

运行:

cd zhy-test && npx vue-tsc --noEmit 2>&1 | head -20

预期:无新增类型错误(config/index.ts 编译通过)。

  • Step 5:提交
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 替换为以下内容:

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:验证编译

运行:

cd zhy-test && npx vue-tsc --noEmit 2>&1 | head -20

预期:无类型错误。

  • Step 3:提交
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:

/** 用户信息 */
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 替换为:

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 替换为:

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:验证编译

运行:

cd zhy-test && npx vue-tsc --noEmit 2>&1 | head -20

预期:可能有关于 card/order 模块未找到的警告(Task 4/5 会消除)。

  • Step 5:提交
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:

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:提交
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:

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:验证编译

运行:

cd zhy-test && npx vue-tsc --noEmit 2>&1 | head -20

预期:API 相关类型错误全部消除。

  • Step 3:提交
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 替换为:

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:

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:验证编译

运行:

cd zhy-test && npx vue-tsc --noEmit 2>&1 | head -20

预期:Store 相关文件无类型错误。

  • Step 4:提交
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:

<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:

<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:提交
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(或直接用在线占位图服务):

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 替换为:

{
  "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:验证编译

运行:

cd zhy-test && npx uni -p mp-weixin 2>&1 | head -30

预期:编译启动成功,无路由配置错误。(可用 Ctrl+C 停止)

  • Step 4:提交
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 替换为:

<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 替换为:

<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 替换为:

<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:验证编译

运行:

cd zhy-test && npx vue-tsc --noEmit 2>&1 | head -20

预期:入口页和登录页无类型错误。

  • Step 5:提交
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:

<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:验证编译

运行:

cd zhy-test && npx vue-tsc --noEmit 2>&1 | head -20

预期:无类型错误。

  • Step 3:提交
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:

<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:提交
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:

<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:提交
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:

<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:提交
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:

<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:提交
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:

<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:提交
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:

<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:提交
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:

<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:提交
git add src/packages/user/pages/profile/
git commit -m "feat: 用户端个人中心(用户信息 + 功能菜单)"

Task 18:编译验证 + 最终提交

Files: 无新增/修改,仅验证

  • Step 1:类型检查

运行:

cd zhy-test && npx vue-tsc --noEmit 2>&1

预期:无类型错误。如有错误,逐一修复。

  • Step 2:H5 模式编译测试

运行:

cd zhy-test && npx uni 2>&1 | head -30

预期:Vite 开发服务器启动成功。(Ctrl+C 停止)

  • Step 3:微信小程序编译测试

运行:

cd zhy-test && npx uni build -p mp-weixin 2>&1 | tail -20

预期:构建成功,生成 dist/build/mp-weixin 目录。

  • Step 4:检查分包目录结构

运行:

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:最终提交
git add -A
git commit -m "feat: 湖南12301第一迭代完成 — 基础层 + 用户端核心流程"

手动测试清单

编译为微信小程序后,在微信开发者工具中按以下流程手动验证:

步骤 操作 预期结果
1 启动小程序 显示启动页,自动跳转到登录页
2 点击"微信一键登录" 调用微信登录,成功后跳转用户首页
3 用户首页 显示宫格入口 + 推荐年卡列表
4 点击"景区预约" 弹出"即将上线"提示
5 点击"购买年卡" 进入年卡列表页
6 点击一张年卡 进入年卡详情页
7 点击"立即购买" 进入确认购买页
8 点击"确认支付" 模拟支付成功,跳转激活页
9 填写实名信息并激活 激活成功,跳转入园码页
10 入园码页 显示年卡信息 + 二维码,60秒自动刷新
11 切换到"我的"Tab 显示个人中心
12 点击"退出登录" 清除状态,回到登录页