66 KiB
湖南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 | 点击"退出登录" | 清除状态,回到登录页 |