feat: migrate ice venue module

This commit is contained in:
gqt
2026-06-08 21:00:32 +08:00
parent 8e1443f7e5
commit 0f4e04f4ad
28 changed files with 5684 additions and 0 deletions
@@ -0,0 +1,210 @@
<template>
<view class="state-panel" :class="[type, { compact }]">
<view class="state-mark" v-if="showMark">
<text class="state-mark-text">{{ markText }}</text>
</view>
<view class="state-copy" v-if="title || description">
<text class="state-title" v-if="title">{{ title }}</text>
<text class="state-description" v-if="description">{{ description }}</text>
</view>
<view class="state-actions" v-if="primaryText || secondaryText">
<button
v-if="primaryText"
class="state-btn primary"
:loading="loading"
:disabled="loading"
@tap="$emit('primary')"
>
{{ primaryText }}
</button>
<button
v-if="secondaryText"
class="state-btn secondary"
:disabled="loading"
@tap="$emit('secondary')"
>
{{ secondaryText }}
</button>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
title: {
type: String,
default: ''
},
description: {
type: String,
default: ''
},
primaryText: {
type: String,
default: ''
},
secondaryText: {
type: String,
default: ''
},
type: {
type: String,
default: 'default'
},
loading: {
type: Boolean,
default: false
},
compact: {
type: Boolean,
default: false
},
showMark: {
type: Boolean,
default: true
}
})
defineEmits(['primary', 'secondary'])
const markText = computed(() => {
const map = {
login: '登',
expired: '重',
profile: '档',
error: '!',
empty: '空',
loading: '...'
}
return map[props.type] || 'i'
})
</script>
<style lang="scss" scoped>
.state-panel {
min-height: 520rpx;
padding: 80rpx $spacing-lg;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: $spacing-lg;
&.compact {
min-height: 280rpx;
padding: $spacing-xl $spacing-lg;
}
}
.state-mark {
width: 88rpx;
height: 88rpx;
border-radius: 44rpx;
background-color: $color-primary-light;
display: flex;
align-items: center;
justify-content: center;
.state-mark-text {
font-size: 30rpx;
font-weight: 800;
color: $color-primary;
}
}
.state-panel.expired,
.state-panel.error {
.state-mark {
background-color: $color-danger-light;
.state-mark-text {
color: $color-danger;
}
}
}
.state-panel.profile {
.state-mark {
background-color: $color-warning-light;
.state-mark-text {
color: $color-warning;
}
}
}
.state-panel.empty {
.state-mark {
background-color: $bg-color-hover;
.state-mark-text {
color: $text-color-light;
}
}
}
.state-copy {
display: flex;
flex-direction: column;
align-items: center;
gap: $spacing-sm;
max-width: 560rpx;
.state-title {
font-size: 34rpx;
line-height: 1.35;
font-weight: 700;
color: $text-color-main;
}
.state-description {
font-size: 26rpx;
line-height: 1.6;
color: $text-color-muted;
}
}
.state-actions {
width: 100%;
max-width: 520rpx;
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.state-btn {
width: 100%;
height: 88rpx;
border-radius: $border-radius-base;
font-size: 28rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
&::after {
border: none;
}
&.primary {
color: #FFFFFF;
background-color: $text-color-main;
}
&.secondary {
color: $text-color-regular;
background-color: $bg-color-main;
border: 1rpx solid $border-color;
}
&[disabled] {
color: #FFFFFF;
background-color: $text-color-light;
}
}
</style>
@@ -0,0 +1,63 @@
import { request } from './request'
const toNumber = (value) => {
const numberValue = Number(value)
return Number.isFinite(numberValue) ? numberValue : 0
}
const toBookingCourse = (backendCourse = {}) => ({
id: backendCourse.id ?? null,
title: backendCourse.title || '',
start_time: backendCourse.start_time || '',
end_time: backendCourse.end_time || '',
coach_name: backendCourse.coach_name || '',
capacity: toNumber(backendCourse.capacity),
booked_count: toNumber(backendCourse.booked_count)
})
const toBookingCard = (backendCard) => {
if (!backendCard) return null
return {
id: backendCard.id ?? null,
card_name: backendCard.card_name || '',
remained_detail: backendCard.remained_detail || ''
}
}
const toBookingViewModel = (backendBooking = {}) => ({
id: backendBooking.id,
status: backendBooking.status || 'booked',
checked_in_at: backendBooking.checked_in_at || null,
cancelled_at: backendBooking.cancelled_at || null,
created_at: backendBooking.created_at || '',
course: toBookingCourse(backendBooking.course),
card: toBookingCard(backendBooking.card)
})
const toPaginatedBookings = (backendData = {}) => ({
items: Array.isArray(backendData.items) ? backendData.items.map(toBookingViewModel) : [],
total: toNumber(backendData.total),
page: toNumber(backendData.page) || 1,
page_size: toNumber(backendData.page_size) || 20
})
const toQuery = (params = {}) => ({
status: params.status || 'all',
page: params.page || 1,
page_size: params.pageSize || 20
})
export const bookingApi = {
getBookings(params = {}) {
return request({
url: '/api/bookings',
data: toQuery(params)
}).then(toPaginatedBookings)
},
getBookingDetail(id) {
return request({
url: `/api/bookings/${id}`
}).then(toBookingViewModel)
}
}
+61
View File
@@ -0,0 +1,61 @@
import { request } from './request'
const toNumber = (value) => {
const numberValue = Number(value)
return Number.isFinite(numberValue) ? numberValue : 0
}
const toMemberCard = (backendCard = {}) => ({
id: backendCard.id,
card_no: backendCard.card_no || '',
card_name: backendCard.card_name || '',
card_type: backendCard.card_type || 'times',
backend_card_type: backendCard.backend_card_type || '',
status: backendCard.status || 'active',
total_times: toNumber(backendCard.total_times),
remained_times: toNumber(backendCard.remained_times),
original_balance: backendCard.original_balance || '0.00',
gift_balance: backendCard.gift_balance || '0.00',
balance: backendCard.balance || '0.00',
start_date: backendCard.start_date || '',
expire_date: backendCard.expire_date || '',
remained_detail: backendCard.remained_detail || ''
})
const toCardChange = (backendChange = {}) => ({
id: backendChange.id,
change_type: backendChange.change_type || 'adjustment',
backend_change_type: backendChange.backend_change_type || '',
delta: backendChange.delta || '0',
post_times: toNumber(backendChange.post_times),
post_balance: backendChange.post_balance || '0.00',
memo: backendChange.memo || '',
created_at: backendChange.created_at || ''
})
const toCardHistory = (backendData = {}) => ({
card: toMemberCard(backendData.card),
items: Array.isArray(backendData.items) ? backendData.items.map(toCardChange) : [],
total: toNumber(backendData.total),
page: toNumber(backendData.page) || 1,
page_size: toNumber(backendData.page_size) || 20
})
export const cardApi = {
getCards(status = 'all') {
return request({
url: '/api/members/me/cards',
data: { status }
}).then((data = {}) => (Array.isArray(data.items) ? data.items.map(toMemberCard) : []))
},
getCardChanges(cardId, params = {}) {
return request({
url: `/api/members/me/cards/${cardId}/changes`,
data: {
page: params.page || 1,
page_size: params.pageSize || 20
}
}).then(toCardHistory)
}
}
@@ -0,0 +1,17 @@
import { request } from './request'
const toActivityDetail = (backendActivity = {}) => ({
id: backendActivity.id,
title: backendActivity.title || '',
cover: backendActivity.cover || '',
created_at: backendActivity.created_at || '',
content_html: backendActivity.content_html || ''
})
export const contentApi = {
getActivityDetail(id) {
return request({
url: `/api/contents/activities/${id}`
}).then(toActivityDetail)
}
}
+68
View File
@@ -0,0 +1,68 @@
import { request } from './request'
const toNumber = (value) => {
const numberValue = Number(value)
return Number.isFinite(numberValue) ? numberValue : 0
}
const toCoach = (backendCoach = {}) => ({
id: backendCoach.id ?? null,
name: backendCoach.name || ''
})
export const toCourseViewModel = (backendCourse = {}) => {
const coach = toCoach(backendCourse.coach)
return {
id: backendCourse.id,
title: backendCourse.title || '',
start_time: backendCourse.start_time || '',
end_time: backendCourse.end_time || '',
coach,
coach_name: backendCourse.coach_name || coach.name,
capacity: toNumber(backendCourse.capacity),
booked_count: toNumber(backendCourse.booked_count),
available_count: toNumber(backendCourse.available_count),
is_booked: backendCourse.is_booked === true,
my_booking_id: backendCourse.my_booking_id ?? null,
cover: backendCourse.cover || '',
detail_html: backendCourse.detail_html || '',
}
}
const toPaginatedCourses = (backendData = {}) => ({
items: Array.isArray(backendData.items) ? backendData.items.map(toCourseViewModel) : [],
total: toNumber(backendData.total),
page: toNumber(backendData.page) || 1,
page_size: toNumber(backendData.page_size) || 20
})
const toQuery = (params = {}) => {
const data = {}
if (params.date) data.date = params.date
if (params.coachId) data.coach_id = params.coachId
if (params.page) data.page = params.page
if (params.pageSize) data.page_size = params.pageSize
return data
}
export const courseApi = {
getCoaches(status = 'active') {
return request({
url: '/api/coaches',
data: { status }
}).then((data = {}) => (Array.isArray(data.items) ? data.items.map(toCoach) : []))
},
getCourses(params = {}) {
return request({
url: '/api/courses',
data: toQuery(params)
}).then(toPaginatedCourses)
},
getCourseDetail(id) {
return request({
url: `/api/courses/${id}`
}).then(toCourseViewModel)
}
}
+52
View File
@@ -0,0 +1,52 @@
import { request } from './request'
import { toCourseViewModel } from './course'
const toNumber = (value) => {
const numberValue = Number(value)
return Number.isFinite(numberValue) ? numberValue : 0
}
const toBanner = (backendBanner = {}) => ({
id: backendBanner.id,
title: backendBanner.title || '',
image: backendBanner.image || '',
link_type: backendBanner.link_type || 'none',
link_value: backendBanner.link_value || '',
sort_order: toNumber(backendBanner.sort_order)
})
const toActivitySummary = (backendActivity = {}) => ({
id: backendActivity.id,
title: backendActivity.title || '',
cover: backendActivity.cover || '',
created_at: backendActivity.created_at || ''
})
const toHomeViewModel = (backendData = {}) => ({
banners: Array.isArray(backendData.banners) ? backendData.banners.map(toBanner) : [],
upcomingCourses: Array.isArray(backendData.upcoming_courses)
? backendData.upcoming_courses.map(toCourseViewModel)
: [],
activities: Array.isArray(backendData.activities) ? backendData.activities.map(toActivitySummary) : [],
venue: {
name: backendData.venue?.name || '奥林匹克滑冰中心',
}
})
export const homeApi = {
getHome(options = {}) {
const data = {
course_limit: options.courseLimit || 2,
banner_limit: options.bannerLimit || 5
}
if (options.activityLimit !== undefined) {
data.activity_limit = options.activityLimit
}
if (options.courseDate) data.course_date = options.courseDate
return request({
url: '/api/contents/home',
data
}).then(toHomeViewModel)
}
}
+151
View File
@@ -0,0 +1,151 @@
import { authStorage, request } from './request'
// 防腐层:小程序端只在本文件感知后端字段名,页面层统一使用下方 mapper 输出的前端模型。
export const memberBackendContract = Object.freeze({
wechatLoginResponse: [
'access_token',
'token_type',
'expires_in',
'member_bound',
'member'
],
registerOrBindRequest: [
'phone_code',
'phone',
'name',
'gender',
'birthday'
],
registerOrBindResponse: [
'created',
'bound',
'member'
],
memberSummary: [
'id',
'name',
'phone',
'gender',
'birthday',
'joined_at'
],
memberProfile: [
'id',
'name',
'phone',
'gender',
'gender_label',
'birthday',
'joined_at',
'sales.id',
'sales.name',
'card_summary.total',
'card_summary.active',
'card_summary.disabled',
'card_summary.expired'
]
})
const GENDER_LABELS = Object.freeze({
male: '男',
female: '女',
unknown: '未设置'
})
const emptyCardSummary = () => ({
total: 0,
active: 0,
disabled: 0,
expired: 0
})
const toNumber = (value) => {
const numberValue = Number(value)
return Number.isFinite(numberValue) ? numberValue : 0
}
const toMemberCardSummary = (backendCardSummary = {}) => ({
total: toNumber(backendCardSummary.total),
active: toNumber(backendCardSummary.active),
disabled: toNumber(backendCardSummary.disabled),
expired: toNumber(backendCardSummary.expired)
})
const toMemberViewModel = (backendMember, options = {}) => {
if (!backendMember || !backendMember.id) return null
const cardSummary = options.withProfileFields
? toMemberCardSummary(backendMember.card_summary)
: emptyCardSummary()
return {
id: backendMember.id,
name: backendMember.name || '',
phone: backendMember.phone || '',
gender: backendMember.gender || 'unknown',
genderLabel: backendMember.gender_label || GENDER_LABELS[backendMember.gender] || GENDER_LABELS.unknown,
birthday: backendMember.birthday || '',
joinDate: backendMember.joined_at || '',
salesId: backendMember.sales?.id || null,
salesName: backendMember.sales?.name || '无',
cardSummary,
cardCount: cardSummary.total
}
}
const toWechatLoginViewModel = (backendData = {}) => ({
accessToken: backendData.access_token || '',
tokenType: backendData.token_type || 'Bearer',
expiresIn: toNumber(backendData.expires_in),
memberBound: backendData.member_bound === true,
member: toMemberViewModel(backendData.member)
})
const toRegisterOrBindPayload = (formData = {}) => ({
phone_code: formData.phoneCode || null,
phone: formData.phone || null,
name: formData.name || null,
gender: formData.gender || 'unknown',
birthday: formData.birthday || null
})
const toRegisterOrBindViewModel = (backendData = {}) => ({
created: backendData.created === true,
bound: backendData.bound === true,
member: toMemberViewModel(backendData.member)
})
const toMemberProfileViewModel = (backendData = {}) => {
return toMemberViewModel(backendData, { withProfileFields: true })
}
// 登录注册与绑定 API 接口组
export const api = {
// 1. 微信小程序登录 (用 code 换 token)
wechatLogin(code) {
return request({
url: '/api/auth/wechat-login',
method: 'POST',
data: { code }
}).then(toWechatLoginViewModel)
},
// 2. 绑定或注册会员
registerOrBind(data) {
return request({
url: '/api/auth/register-or-bind',
method: 'POST',
data: toRegisterOrBindPayload(data)
}).then(toRegisterOrBindViewModel)
},
// 3. 获取当前登录会员信息
getMemberMe() {
return request({
url: '/api/members/me',
method: 'GET'
}).then(toMemberProfileViewModel)
}
}
export { authStorage, request }
+134
View File
@@ -0,0 +1,134 @@
const KEY_TOKEN = 'ICE_MEMBER_TOKEN'
const KEY_TOKEN_EXPIRES_AT = 'ICE_MEMBER_TOKEN_EXPIRES_AT'
const API_BASE_URL = import.meta.env.VITE_ICE_API_BASE_URL || 'http://127.0.0.1:8000'
export const authStorage = {
setToken(token, expiresIn) {
uni.setStorageSync(KEY_TOKEN, token)
if (expiresIn) {
uni.setStorageSync(KEY_TOKEN_EXPIRES_AT, Date.now() + Number(expiresIn) * 1000)
}
},
getToken() {
return uni.getStorageSync(KEY_TOKEN)
},
isTokenExpired() {
const expiresAt = Number(uni.getStorageSync(KEY_TOKEN_EXPIRES_AT) || 0)
return expiresAt > 0 && Date.now() >= expiresAt
},
clear() {
uni.removeStorageSync(KEY_TOKEN)
uni.removeStorageSync(KEY_TOKEN_EXPIRES_AT)
}
}
export const normalizeApiError = (rawError = {}) => {
const statusCode = rawError.statusCode
const body = rawError.data || rawError
const code = body?.code
const backendMessage = body?.message || ''
if (statusCode === 401 || code === 40001) {
return {
type: 'auth_expired',
code,
statusCode,
message: '登录状态已过期,请重新登录',
raw: rawError
}
}
if (code === 40002) {
return {
type: 'profile_required',
code,
statusCode,
message: '请先完善会员资料',
raw: rawError
}
}
if (code === 40003) {
return {
type: 'bind_conflict',
code,
statusCode,
message: backendMessage || '该手机号已绑定其他微信账号,请联系场馆前台处理',
raw: rawError
}
}
if (!statusCode) {
return {
type: 'network_error',
code,
statusCode,
message: '网络连接失败,请检查后重试',
raw: rawError
}
}
if (statusCode === 404 || code === 40400) {
return {
type: 'not_found',
code,
statusCode,
message: backendMessage || '未找到对应信息或无访问权限',
raw: rawError
}
}
return {
type: 'api_error',
code,
statusCode,
message: backendMessage || '服务暂时不可用,请稍后重试',
raw: rawError
}
}
export function request(options = {}) {
const token = authStorage.getToken()
const header = {
'Content-Type': 'application/json',
...(options.header || {})
}
if (token) {
header.Authorization = `Bearer ${token}`
}
return new Promise((resolve, reject) => {
uni.request({
url: options.url.startsWith('http') ? options.url : `${API_BASE_URL}${options.url}`,
method: options.method || 'GET',
data: options.data,
header,
success: (res) => {
const body = res.data
if (res.statusCode >= 200 && res.statusCode < 300) {
if (body && body.code === 0) {
resolve(body.data)
return
}
reject(normalizeApiError({
statusCode: res.statusCode,
data: body
}))
return
}
reject(normalizeApiError({
statusCode: res.statusCode,
data: body
}))
},
fail: (err) => {
reject(normalizeApiError(err))
}
})
})
}
+108
View File
@@ -0,0 +1,108 @@
import { api, authStorage } from './member'
export const SESSION_STATUS = Object.freeze({
CHECKING: 'checking',
GUEST: 'guest',
EXPIRED: 'expired',
PROFILE_REQUIRED: 'profile_required',
MEMBER: 'member',
NETWORK_ERROR: 'network_error'
})
const createLoginCode = () => new Promise((resolve, reject) => {
uni.login({
provider: 'weixin',
success: (loginRes) => {
if (loginRes.code) {
resolve(loginRes.code)
return
}
reject({
type: 'login_error',
message: '微信登录没有返回有效凭证,请重新尝试'
})
},
fail: () => {
reject({
type: 'login_error',
message: '微信登录失败,请稍后重试'
})
}
})
})
export const loginAndStoreToken = async () => {
const code = await createLoginCode()
const data = await api.wechatLogin(code)
if (!data?.accessToken) {
throw {
type: 'login_error',
message: '登录失败,请稍后重试'
}
}
authStorage.setToken(data.accessToken, data.expiresIn)
return data
}
export const getMemberSession = async () => {
const token = authStorage.getToken()
if (!token) {
return { status: SESSION_STATUS.GUEST }
}
if (authStorage.isTokenExpired()) {
authStorage.clear()
return {
status: SESSION_STATUS.EXPIRED,
message: '登录状态已过期,请重新登录'
}
}
try {
const member = await api.getMemberMe()
return {
status: SESSION_STATUS.MEMBER,
member
}
} catch (err) {
if (err?.type === 'profile_required' || err?.code === 40002) {
return {
status: SESSION_STATUS.PROFILE_REQUIRED,
message: '请先完善会员资料'
}
}
if (err?.type === 'auth_expired' || err?.code === 40001) {
authStorage.clear()
return {
status: SESSION_STATUS.EXPIRED,
message: '登录状态已过期,请重新登录'
}
}
return {
status: SESSION_STATUS.NETWORK_ERROR,
message: err?.message || '暂时无法连接服务,请稍后重试',
error: err
}
}
}
export const isSessionBlockingError = (err) => {
return ['auth_expired', 'profile_required'].includes(err?.type) || [40001, 40002].includes(err?.code)
}
export const sessionStatusFromError = (err) => {
if (err?.type === 'auth_expired' || err?.code === 40001) {
authStorage.clear()
return SESSION_STATUS.EXPIRED
}
if (err?.type === 'profile_required' || err?.code === 40002) {
return SESSION_STATUS.PROFILE_REQUIRED
}
return ''
}
export const errorMessage = (err, fallback = '操作失败,请稍后重试') => {
return err?.message || fallback
}
+65
View File
@@ -136,6 +136,71 @@
"style": {
"navigationBarTitleText": "个人信息"
}
},
{
"path": "pages/ice/home/index",
"style": {
"navigationBarTitleText": "首页",
"enablePullDownRefresh": true
}
},
{
"path": "pages/ice/courses/index",
"style": {
"navigationBarTitleText": "近期课程",
"enablePullDownRefresh": true
}
},
{
"path": "pages/ice/bookings/index",
"style": {
"navigationBarTitleText": "我的预约",
"enablePullDownRefresh": true
}
},
{
"path": "pages/ice/profile/index",
"style": {
"navigationBarTitleText": "会员中心"
}
},
{
"path": "pages/ice/bookings/detail",
"style": {
"navigationBarTitleText": "预约详情"
}
},
{
"path": "pages/ice/courses/detail",
"style": {
"navigationBarTitleText": "课程详情"
}
},
{
"path": "pages/ice/profile/cards",
"style": {
"navigationBarTitleText": "我的会员卡",
"enablePullDownRefresh": true
}
},
{
"path": "pages/ice/profile/card-history",
"style": {
"navigationBarTitleText": "会员卡使用流水",
"enablePullDownRefresh": true
}
},
{
"path": "pages/ice/profile/detail",
"style": {
"navigationBarTitleText": "个人信息"
}
},
{
"path": "pages/ice/activities/detail",
"style": {
"navigationBarTitleText": "活动详情"
}
}
],
"globalStyle": {
@@ -0,0 +1,157 @@
<template>
<view class="activity-container">
<StatePanel
v-if="pageState === 'loading'"
title="正在加载活动详情"
description="请稍候"
type="loading"
/>
<StatePanel
v-else-if="pageState === 'not_found'"
title="未找到活动信息"
:description="pageMessage || '该活动不存在或已下线。'"
primary-text="返回首页"
type="empty"
@primary="goHome"
/>
<StatePanel
v-else-if="pageState === 'error'"
title="活动详情加载失败"
:description="pageMessage"
primary-text="重试"
type="error"
@primary="loadActivity"
/>
<view class="detail-card" v-else>
<view class="activity-header">
<text class="section-tag">ANNOUNCEMENT</text>
<text class="activity-title">{{ activity.title }}</text>
<text class="activity-date">发布时间: {{ formatDate(activity.created_at) }}</text>
</view>
<view class="divider"></view>
<!-- 公告大图 -->
<image :src="activity.cover" mode="widthFix" class="activity-banner-img" />
<rich-text :nodes="activity.content_html" class="activity-content"></rich-text>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { contentApi } from '@/modules/ice/api/content'
import { errorMessage } from '@/modules/ice/api/session'
import StatePanel from '@/components/ice/StatePanel.vue'
const activity = ref({
id: null,
title: '',
cover: '',
created_at: '',
content_html: ''
})
const pageState = ref('loading')
const pageMessage = ref('')
const currentActivityId = ref('')
onMounted(() => {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const options = currentPage.options || {}
currentActivityId.value = options.id || ''
loadActivity()
})
const loadActivity = async () => {
if (!currentActivityId.value) {
pageState.value = 'not_found'
pageMessage.value = '未找到要查看的活动。'
return
}
pageState.value = 'loading'
pageMessage.value = ''
try {
activity.value = await contentApi.getActivityDetail(currentActivityId.value)
pageState.value = 'ready'
} catch (err) {
if (err?.type === 'not_found') {
pageState.value = 'not_found'
pageMessage.value = '该活动不存在或已下线。'
return
}
pageState.value = 'error'
pageMessage.value = errorMessage(err, '活动详情加载失败,请稍后重试。')
}
}
const goHome = () => {
uni.reLaunch({ url: '/pages/ice/home/index' })
}
const formatDate = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`
}
</script>
<style lang="scss" scoped>
.activity-container {
min-height: 100vh;
background-color: $bg-color-soft;
padding: $spacing-md;
}
.detail-card {
background-color: $bg-color-main;
border-radius: $border-radius-lg;
border: 1rpx solid rgba(226, 232, 240, 0.8);
padding: $spacing-lg;
}
.activity-header {
display: flex;
flex-direction: column;
gap: $spacing-xs;
.section-tag {
font-size: 18rpx;
font-weight: 700;
color: $color-primary;
letter-spacing: 1.5rpx;
}
.activity-title {
font-size: 34rpx;
font-weight: 700;
color: $text-color-main;
line-height: 1.3;
}
.activity-date {
font-size: 22rpx;
color: $text-color-muted;
margin-top: 4rpx;
}
}
.activity-banner-img {
width: 100%;
border-radius: $border-radius-base;
background-color: $bg-color-soft;
margin-bottom: $spacing-lg;
}
.activity-content {
display: block;
font-size: 26rpx;
color: $text-color-regular;
line-height: 1.6;
}
</style>
@@ -0,0 +1,511 @@
<template>
<view class="detail-container">
<StatePanel
v-if="pageState === 'loading'"
title="正在加载预约信息"
description="请稍候"
type="loading"
/>
<StatePanel
v-else-if="sessionStatus === SESSION_STATUS.GUEST"
title="登录后查看预约详情"
description="请先登录并完善会员资料。"
primary-text="微信一键登录"
type="login"
:loading="isLoginSubmitting"
@primary="onWechatLogin"
/>
<StatePanel
v-else-if="sessionStatus === SESSION_STATUS.EXPIRED"
title="登录状态已过期"
description="为了保护会员资料,请重新登录后继续查看。"
primary-text="重新登录"
type="expired"
:loading="isLoginSubmitting"
@primary="onWechatLogin"
/>
<StatePanel
v-else-if="sessionStatus === SESSION_STATUS.PROFILE_REQUIRED"
title="完善会员资料后查看预约"
description="请先在会员中心完成姓名和手机号绑定,用于匹配你的预约记录。"
primary-text="去完善资料"
type="profile"
@primary="goProfile"
/>
<StatePanel
v-else-if="sessionStatus === SESSION_STATUS.NETWORK_ERROR"
title="暂时无法连接服务"
:description="pageMessage || '请检查网络后重试。'"
primary-text="重试"
type="error"
@primary="loadPage"
/>
<StatePanel
v-else-if="pageState === 'not_found'"
title="未找到预约信息"
:description="pageMessage || '该预约不存在,或你没有查看权限。'"
primary-text="返回预约列表"
type="empty"
@primary="goBookings"
/>
<StatePanel
v-else-if="pageState === 'error'"
title="预约详情加载失败"
:description="pageMessage"
primary-text="重试"
type="error"
@primary="loadPage"
/>
<block v-else>
<!-- 1. 顶部状态横幅 -->
<view class="status-banner" :class="booking.status">
<text class="status-text">{{ formatStatus(booking.status) }}</text>
</view>
<!-- 2. 课程信息卡片支持点击跳转到课程详情 -->
<view class="detail-card clickable-card" @tap="onTapCourse(booking.course?.id)">
<view class="card-section">
<text class="section-tag">关联课程</text>
<view class="title-with-arrow">
<text class="course-title">{{ booking.course?.title }}</text>
<text class="nav-arrow"></text>
</view>
</view>
<view class="divider"></view>
<view class="info-grid">
<view class="info-row">
<text class="info-label">上课时段</text>
<text class="info-val highlight-blue">时间{{ formatDateTimeRange(booking.course?.start_time, booking.course?.end_time) }}</text>
</view>
<view class="info-row">
<text class="info-label">预约人数</text>
<text class="info-val font-semibold">已约 {{ booking.course?.booked_count }}/{{ booking.course?.capacity }} </text>
</view>
</view>
</view>
<!-- 3. 会员卡信息卡片支持点击跳转到卡片详情 -->
<view class="detail-card clickable-card" v-if="booking.card" @tap="onTapCard(booking.card?.id)">
<view class="card-section">
<text class="section-tag">使用卡项</text>
<view class="title-with-arrow">
<text class="card-title">{{ booking.card.card_name }}</text>
<text class="nav-arrow"></text>
</view>
</view>
</view>
<!-- 4. 流程时间节点展示 -->
<view class="detail-card timeline-card">
<text class="section-tag">时间节点</text>
<view class="log-timeline">
<view class="timeline-item" v-if="booking.created_at">
<view class="timeline-dot active"></view>
<view class="timeline-content">
<text class="log-action">预约时间</text>
<text class="log-time">{{ formatFullDateTime(booking.created_at) }}</text>
</view>
</view>
<view class="timeline-item" v-if="booking.status === 'checked_in' && booking.checked_in_at">
<view class="timeline-dot active success"></view>
<view class="timeline-content">
<text class="log-action success">核销时间</text>
<text class="log-time">{{ formatFullDateTime(booking.checked_in_at) }}</text>
</view>
</view>
<view class="timeline-item" v-if="booking.status === 'cancelled' && booking.cancelled_at">
<view class="timeline-dot active danger"></view>
<view class="timeline-content">
<text class="log-action danger">取消时间</text>
<text class="log-time">{{ formatFullDateTime(booking.cancelled_at) }}</text>
</view>
</view>
</view>
</view>
<!-- 5. 温馨提示 -->
<view class="bottom-disclaimer">
<text class="disclaimer-title">行程变动须知</text>
<text class="disclaimer-content">所有的上课行程均由场馆统一排定展示如需取消或改约请提前联系场馆前台处理</text>
</view>
</block>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { bookingApi } from '@/modules/ice/api/booking'
import { errorMessage, getMemberSession, loginAndStoreToken, sessionStatusFromError, SESSION_STATUS } from '@/modules/ice/api/session'
import StatePanel from '@/components/ice/StatePanel.vue'
const booking = ref({
id: null,
status: '',
created_at: '',
checked_in_at: null,
cancelled_at: null,
course: {
id: null,
title: '',
start_time: '',
end_time: '',
capacity: 0,
booked_count: 0
},
card: null
})
const pageState = ref('loading')
const pageMessage = ref('')
const sessionStatus = ref(SESSION_STATUS.CHECKING)
const isLoginSubmitting = ref(false)
const currentBookingId = ref('')
onShow(() => {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const options = currentPage.options || {}
currentBookingId.value = options.id || ''
loadPage()
})
const loadPage = async () => {
if (!currentBookingId.value) {
pageState.value = 'not_found'
pageMessage.value = '未找到要查看的预约记录。'
return
}
pageState.value = 'loading'
pageMessage.value = ''
const session = await getMemberSession()
sessionStatus.value = session.status
if (session.status !== SESSION_STATUS.MEMBER) {
pageState.value = 'blocked'
pageMessage.value = session.message || ''
return
}
try {
booking.value = await bookingApi.getBookingDetail(currentBookingId.value)
pageState.value = 'ready'
} catch (err) {
const status = sessionStatusFromError(err)
if (status) {
sessionStatus.value = status
pageState.value = 'blocked'
pageMessage.value = err?.message || ''
return
}
if (err?.type === 'not_found') {
pageState.value = 'not_found'
pageMessage.value = '该预约不存在,或你没有查看权限。'
return
}
pageState.value = 'error'
pageMessage.value = errorMessage(err, '预约详情加载失败,请稍后重试。')
}
}
const onWechatLogin = async () => {
if (isLoginSubmitting.value) return
isLoginSubmitting.value = true
uni.showLoading({ title: '登录中...' })
try {
await loginAndStoreToken()
await loadPage()
if (sessionStatus.value === SESSION_STATUS.MEMBER && pageState.value === 'ready') {
uni.showToast({ title: '登录成功', icon: 'success' })
}
} catch (err) {
uni.showModal({
title: '登录失败',
content: errorMessage(err, '微信登录失败,请稍后重试'),
showCancel: false,
confirmText: '知道了'
})
} finally {
isLoginSubmitting.value = false
uni.hideLoading()
}
}
const formatStatus = (status) => {
const map = {
booked: '待核销上课',
checked_in: '已签到核销',
cancelled: '行程已取消'
}
return map[status] || status
}
const onTapCourse = (courseId) => {
if (!courseId) return
uni.navigateTo({
url: `/pages/ice/courses/detail?id=${courseId}`
})
}
const onTapCard = (cardId) => {
if (!cardId) return
uni.navigateTo({
url: `/pages/ice/profile/card-history?id=${cardId}`
})
}
const goProfile = () => {
uni.reLaunch({ url: '/pages/ice/profile/index' })
}
const goBookings = () => {
uni.reLaunch({ url: '/pages/ice/bookings/index' })
}
const formatDateTimeRange = (startStr, endStr) => {
if (!startStr || !endStr) return '-'
const start = new Date(startStr)
const end = new Date(endStr)
const pad = (n) => String(n).padStart(2, '0')
const mm = pad(start.getMonth() + 1)
const dd = pad(start.getDate())
const startHhMm = `${pad(start.getHours())}:${pad(start.getMinutes())}`
const endHhMm = `${pad(end.getHours())}:${pad(end.getMinutes())}`
return `${mm}-${dd} ${startHhMm} - ${endHhMm}`
}
const formatFullDateTime = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
const pad = (n) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
</script>
<style lang="scss" scoped>
.detail-container {
min-height: 100vh;
background-color: $bg-color-soft;
padding: $spacing-md;
display: flex;
flex-direction: column;
gap: $spacing-md;
}
/* 顶部状态横幅 */
.status-banner {
border-radius: $border-radius-lg;
padding: $spacing-lg;
color: #FFFFFF;
display: flex;
flex-direction: column;
gap: $spacing-xs;
&.booked {
background-color: $text-color-main;
}
&.checked_in {
background-color: $color-success;
}
&.cancelled {
background-color: $text-color-muted;
}
.status-text {
font-size: 36rpx;
font-weight: 700;
}
.status-desc {
font-size: 22rpx;
opacity: 0.9;
}
}
/* 详情卡片通用 */
.detail-card {
background-color: $bg-color-main;
border: 1rpx solid rgba(226, 232, 240, 0.8);
border-radius: $border-radius-lg;
padding: $spacing-lg;
&.clickable-card {
transition: all 0.2s ease;
&:active {
background-color: $bg-color-hover;
}
}
.card-section {
display: flex;
flex-direction: column;
gap: 6rpx;
}
.section-tag {
font-size: 18rpx;
font-weight: 700;
color: $color-primary;
letter-spacing: 1.5rpx;
text-transform: uppercase;
margin-bottom: 4rpx;
display: block;
}
.title-with-arrow {
display: flex;
justify-content: space-between;
align-items: center;
gap: $spacing-md;
.nav-arrow {
font-size: 30rpx;
color: $text-color-light;
font-weight: bold;
}
}
.course-title, .card-title {
font-size: 30rpx;
font-weight: 700;
color: $text-color-main;
line-height: 1.35;
flex: 1;
}
.info-grid {
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 24rpx;
}
.info-label {
color: $text-color-muted;
}
.info-val {
color: $text-color-main;
font-weight: 500;
&.font-semibold {
font-weight: 700;
}
&.highlight-blue {
color: $color-primary;
font-weight: 700;
}
}
}
/* 极简时间轴 */
.log-timeline {
display: flex;
flex-direction: column;
gap: $spacing-lg;
padding: $spacing-md 0 0 $spacing-xs;
}
.timeline-item {
display: flex;
gap: $spacing-md;
position: relative;
&::before {
content: '';
position: absolute;
left: 8rpx;
top: 20rpx;
bottom: -32rpx;
width: 2rpx;
background-color: $bg-color-hover;
}
&:last-child::before {
display: none;
}
.timeline-dot {
width: 18rpx;
height: 18rpx;
border-radius: 50%;
background-color: $border-color;
margin-top: 8rpx;
flex-shrink: 0;
&.active {
background-color: $text-color-main;
&.success {
background-color: $color-success;
}
&.danger {
background-color: $color-danger;
}
}
}
.timeline-content {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.log-action {
font-size: 24rpx;
font-weight: 600;
color: $text-color-regular;
&.success {
color: $color-success;
}
&.danger {
color: $color-danger;
}
}
.log-time {
font-size: 20rpx;
color: $text-color-light;
}
}
/* 温馨提示 */
.bottom-disclaimer {
padding: 0 $spacing-xs;
display: flex;
flex-direction: column;
gap: 8rpx;
.disclaimer-title {
font-size: 22rpx;
font-weight: 700;
color: $text-color-muted;
text-transform: uppercase;
}
.disclaimer-content {
font-size: 20rpx;
color: $text-color-light;
line-height: 1.5;
}
}
</style>
@@ -0,0 +1,544 @@
<template>
<view class="bookings-container">
<StatePanel
v-if="sessionStatus === SESSION_STATUS.CHECKING"
title="正在检查登录状态"
description="请稍候"
type="loading"
/>
<StatePanel
v-else-if="sessionStatus === SESSION_STATUS.GUEST"
primary-text="微信一键登录"
type="login"
:show-mark="false"
:loading="isLoginSubmitting"
@primary="onWechatLogin"
/>
<StatePanel
v-else-if="sessionStatus === SESSION_STATUS.EXPIRED"
title="登录状态已过期"
description="为了保护会员资料,请重新登录后继续查看预约行程。"
primary-text="重新登录"
type="expired"
:loading="isLoginSubmitting"
@primary="onWechatLogin"
/>
<StatePanel
v-else-if="sessionStatus === SESSION_STATUS.PROFILE_REQUIRED"
title="完善会员资料后查看预约"
description="请先在会员中心完成姓名和手机号绑定,用于匹配你的预约记录。"
primary-text="去完善资料"
type="profile"
@primary="goProfile"
/>
<StatePanel
v-else-if="sessionStatus === SESSION_STATUS.NETWORK_ERROR"
title="暂时无法连接服务"
:description="sessionMessage || '请检查网络后重试。'"
primary-text="重试"
type="error"
@primary="checkSessionAndLoad"
/>
<!-- 已登录正常预约列表 -->
<block v-else>
<!-- 顶部极简选项卡 -->
<view class="tab-header">
<view
v-for="tab in tabs"
:key="tab.status"
class="tab-item"
:class="{ active: currentStatus === tab.status }"
@tap="onSelectTab(tab.status)"
>
<text class="tab-label">{{ tab.label }}</text>
<view class="active-indicator"></view>
</view>
</view>
<!-- 预约列表 -->
<view class="booking-list">
<StatePanel
v-if="listState === 'error'"
title="预约行程加载失败"
:description="listErrorMessage"
primary-text="重试"
type="error"
compact
@primary="loadBookings"
/>
<!-- 列表卡片 -->
<block v-else>
<view
v-for="booking in bookings"
:key="booking.id"
class="booking-card"
:class="[booking.status]"
@tap="onViewDetail(booking.id)"
>
<view class="card-top">
<text class="course-name">{{ booking.course.title }}</text>
<text class="status-badge" :class="booking.status">{{ formatStatus(booking.status) }}</text>
</view>
<view class="card-details">
<text class="detail-timehighlight highlight-blue">时间{{ formatDateTimeRange(booking.course.start_time, booking.course.end_time) }}</text>
<text class="detail-capacity"> 人数已约 {{ booking.course.booked_count }}/{{ booking.course.capacity }}</text>
</view>
</view>
</block>
<!-- 加载更多提示 -->
<view v-if="isLoadingMore" class="load-more-tip">
<text>加载中...</text>
</view>
<view v-else-if="bookings.length > 0 && !hasMore" class="load-more-tip">
<text> 已加载全部 </text>
</view>
<!-- 极致优雅空状态 -->
<StatePanel
v-if="listState !== 'error' && !isLoading && bookings.length === 0"
title="暂无预约行程"
description="场馆暂未为你登记预约行程。"
type="empty"
compact
/>
</view>
</block>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { onPullDownRefresh, onReachBottom, onShow } from '@dcloudio/uni-app'
import { bookingApi } from '@/modules/ice/api/booking'
import { authStorage } from '@/modules/ice/api/member'
import { errorMessage, getMemberSession, loginAndStoreToken, sessionStatusFromError, SESSION_STATUS } from '@/modules/ice/api/session'
import StatePanel from '@/components/ice/StatePanel.vue'
const isLoggedIn = ref(false)
const isLoginSubmitting = ref(false)
const sessionStatus = ref(SESSION_STATUS.CHECKING)
const sessionMessage = ref('')
const currentStatus = ref('all')
const isLoading = ref(false)
const isLoadingMore = ref(false)
const listState = ref('idle')
const listErrorMessage = ref('')
const page = ref(1)
const pageSize = 20
const total = ref(0)
const hasMore = computed(() => bookings.value.length < total.value)
const tabs = [
{ label: '全部', status: 'all' },
{ label: '已预约', status: 'booked' },
{ label: '已核销', status: 'checked_in' },
{ label: '已取消', status: 'cancelled' }
]
const bookings = ref([])
onShow(() => {
checkSessionAndLoad()
})
const checkSessionAndLoad = async () => {
sessionStatus.value = SESSION_STATUS.CHECKING
const session = await getMemberSession()
sessionStatus.value = session.status
sessionMessage.value = session.message || ''
isLoggedIn.value = session.status === SESSION_STATUS.MEMBER
if (session.status === SESSION_STATUS.MEMBER) {
await loadBookings()
return
}
bookings.value = []
total.value = 0
listState.value = 'idle'
}
const onWechatLogin = async () => {
if (isLoginSubmitting.value) return
isLoginSubmitting.value = true
uni.showLoading({ title: '登录中...' })
try {
await loginAndStoreToken()
await checkSessionAndLoad()
if (sessionStatus.value === SESSION_STATUS.MEMBER) {
uni.showToast({ title: '登录成功', icon: 'success' })
}
} catch (err) {
uni.showModal({
title: '登录失败',
content: errorMessage(err, '微信登录失败,请稍后重试'),
showCancel: false,
confirmText: '知道了'
})
} finally {
isLoginSubmitting.value = false
uni.hideLoading()
}
}
onPullDownRefresh(() => {
page.value = 1
const task = sessionStatus.value === SESSION_STATUS.MEMBER ? loadBookings() : checkSessionAndLoad()
task.finally(() => {
uni.stopPullDownRefresh()
})
})
onReachBottom(() => {
if (sessionStatus.value === SESSION_STATUS.MEMBER && hasMore.value && !isLoadingMore.value) {
page.value++
loadMoreBookings()
}
})
const loadBookings = async () => {
if (isLoading.value) return
isLoading.value = true
try {
const data = await bookingApi.getBookings({
status: currentStatus.value,
page: 1,
pageSize
})
bookings.value = data.items
total.value = data.total
listState.value = 'ready'
listErrorMessage.value = ''
} catch (err) {
if (applySessionError(err)) return
listState.value = 'error'
listErrorMessage.value = errorMessage(err, '预约行程加载失败,请稍后重试。')
} finally {
isLoading.value = false
}
}
const loadMoreBookings = async () => {
if (isLoadingMore.value) return
isLoadingMore.value = true
try {
const data = await bookingApi.getBookings({
status: currentStatus.value,
page: page.value,
pageSize
})
bookings.value = [...bookings.value, ...data.items]
total.value = data.total
} catch (err) {
if (applySessionError(err)) return
page.value--
uni.showToast({ title: errorMessage(err, '加载更多失败,请重试'), icon: 'none' })
} finally {
isLoadingMore.value = false
}
}
const onSelectTab = (status) => {
currentStatus.value = status
page.value = 1
if (sessionStatus.value === SESSION_STATUS.MEMBER) {
loadBookings()
}
}
const onViewDetail = (id) => {
uni.navigateTo({
url: `/pages/ice/bookings/detail?id=${id}`
})
}
const formatStatus = (status) => {
const map = {
booked: '已预约',
checked_in: '已核销',
cancelled: '已取消'
}
return map[status] || status
}
const formatDateTimeRange = (startStr, endStr) => {
const start = new Date(startStr)
const end = new Date(endStr)
const pad = (n) => String(n).padStart(2, '0')
const mm = pad(start.getMonth() + 1)
const dd = pad(start.getDate())
const startHhMm = `${pad(start.getHours())}:${pad(start.getMinutes())}`
const endHhMm = `${pad(end.getHours())}:${pad(end.getMinutes())}`
return `${mm}-${dd} ${startHhMm} - ${endHhMm}`
}
const applySessionError = (err) => {
const status = sessionStatusFromError(err)
if (!status) return false
sessionStatus.value = status
sessionMessage.value = err?.message || ''
isLoggedIn.value = false
bookings.value = []
total.value = 0
listState.value = 'idle'
if (status === SESSION_STATUS.EXPIRED) {
authStorage.clear()
}
return true
}
const goProfile = () => {
uni.reLaunch({ url: '/pages/ice/profile/index' })
}
</script>
<style lang="scss" scoped>
.bookings-container {
min-height: 100vh;
background-color: $bg-color-soft;
padding: $spacing-lg $spacing-md;
display: flex;
flex-direction: column;
}
/* 未登录全屏占位 */
.unlogged-full-wrapper {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 160rpx 0 $spacing-xl 0;
width: 100%;
.brand-logo-area {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 120rpx;
text-align: center;
.brand-title-en {
font-size: 48rpx;
font-weight: 800;
color: $text-color-main;
letter-spacing: 6rpx;
line-height: 1.2;
}
.brand-subtitle-zh {
font-size: 24rpx;
font-weight: 500;
color: $text-color-muted;
letter-spacing: 4rpx;
margin-top: 16rpx;
}
.brand-line {
width: 40rpx;
height: 2rpx;
background-color: $text-color-main;
margin-top: 24rpx;
}
}
.btn-auth-login-only {
width: 80%;
height: 90rpx;
background-color: $text-color-main;
color: #FFFFFF;
font-size: 28rpx;
font-weight: 700;
border-radius: $border-radius-base;
display: flex;
align-items: center;
justify-content: center;
border: none;
box-shadow: 0 4rpx 15rpx rgba(15, 23, 42, 0.08);
transition: all 0.2s ease;
&::after {
border: none;
}
&:active {
opacity: 0.9;
background-color: #000000;
}
}
}
/* 顶部极简选项卡 */
.tab-header {
display: flex;
background-color: $bg-color-main;
border-bottom: 1rpx solid $bg-color-hover;
margin: (-$spacing-lg) (-$spacing-md) 0;
padding: 0 $spacing-md;
}
.tab-item {
flex: 1;
text-align: center;
padding: $spacing-md 0;
position: relative;
transition: all 0.2s ease;
.tab-label {
font-size: 26rpx;
color: $text-color-muted;
font-weight: 500;
}
.active-indicator {
position: absolute;
bottom: 0;
left: 40%;
right: 40%;
height: 4rpx;
background-color: transparent;
border-radius: 2rpx;
transition: all 0.2s ease;
}
&.active {
.tab-label {
color: $text-color-main;
font-weight: 700;
}
.active-indicator {
background-color: $text-color-main;
}
}
}
/* 预约列表 */
.booking-list {
padding: $spacing-lg 0 $spacing-md;
display: flex;
flex-direction: column;
}
.booking-card {
background-color: $bg-color-main;
border: 1rpx solid rgba(226, 232, 240, 0.8);
border-radius: $border-radius-lg;
padding: $spacing-lg;
margin-bottom: $spacing-md;
overflow: hidden;
transition: all 0.2s ease;
&:active {
background-color: $bg-color-hover;
}
/* 状态左侧极简粗边指示 */
&.booked {
border-left: 6rpx solid $color-primary;
}
&.checked_in {
border-left: 6rpx solid $color-success;
}
&.cancelled {
border-left: 6rpx solid $text-color-light;
opacity: 0.6;
}
.card-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: $spacing-sm;
min-height: 38rpx;
}
.course-name {
font-size: 28rpx;
font-weight: 700;
color: $text-color-main;
line-height: 1.35;
flex: 1;
}
.status-badge {
font-size: 22rpx;
font-weight: 700;
flex-shrink: 0;
margin-left: $spacing-xs;
&.booked {
color: $color-primary;
}
&.checked_in {
color: $color-success;
}
&.cancelled {
color: $text-color-muted;
}
}
.card-details {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.detail-timehighlight {
font-size: 22rpx;
color: $text-color-muted;
&.highlight-blue {
color: $color-primary;
font-weight: 600;
}
}
.detail-capacity {
font-size: 22rpx;
color: $text-color-muted;
}
}
/* 极致空状态 */
.empty-state {
text-align: center;
padding: 160rpx $spacing-lg;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.empty-text {
font-size: 30rpx;
font-weight: 700;
color: $text-color-main;
margin-bottom: $spacing-md;
}
.empty-sub {
font-size: 24rpx;
color: $text-color-muted;
line-height: 1.6;
max-width: 520rpx;
}
}
.load-more-tip {
text-align: center;
padding: 0 0 $spacing-md;
font-size: 22rpx;
color: $text-color-light;
}
</style>
@@ -0,0 +1,258 @@
<template>
<view class="detail-container">
<StatePanel
v-if="pageState === 'loading'"
title="正在加载课程详情"
description="请稍候"
type="loading"
/>
<StatePanel
v-else-if="pageState === 'not_found'"
title="未找到课程信息"
:description="pageMessage || '该课程不存在或已下线。'"
primary-text="返回课程列表"
type="empty"
@primary="goCourses"
/>
<StatePanel
v-else-if="pageState === 'error'"
title="课程详情加载失败"
:description="pageMessage"
primary-text="重试"
type="error"
@primary="loadCourse"
/>
<block v-else>
<!-- 1. 课程大图封面 -->
<view class="course-banner-wrap">
<image :src="course.cover" mode="aspectFill" class="course-banner" />
</view>
<!-- 2. 课程主要信息卡片 -->
<view class="detail-card main-info-card">
<view class="title-row">
<text class="course-title">{{ course.title }}</text>
<view class="badge success animate-fade" v-if="course.is_booked">我已约此课</view>
</view>
<view class="divider"></view>
<view class="info-grid">
<view class="info-row">
<text class="info-label">上课时段</text>
<text class="info-val highlight-blue">时间{{ formatDateTimeRange(course.start_time, course.end_time) }}</text>
</view>
<view class="info-row">
<text class="info-label">执教教练</text>
<text class="info-val">{{ course.coach?.name }}</text>
</view>
<view class="info-row">
<text class="info-label">预约人数</text>
<text class="info-val font-semibold">已约 {{ course.booked_count }}/{{ course.capacity }} </text>
</view>
</view>
</view>
<!-- 3. 富文本正文卡片 -->
<view class="detail-card content-card" v-if="course.detail_html">
<view class="section-title">课程详情</view>
<rich-text :nodes="course.detail_html" class="rich-text-content"></rich-text>
</view>
</block>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { courseApi } from '@/modules/ice/api/course'
import { errorMessage } from '@/modules/ice/api/session'
import StatePanel from '@/components/ice/StatePanel.vue'
const course = ref({
id: null,
title: '',
start_time: '',
end_time: '',
capacity: 0,
booked_count: 0,
available_count: 0,
is_booked: false,
cover: '',
coach: { name: '' },
detail_html: '',
})
const pageState = ref('loading')
const pageMessage = ref('')
const currentCourseId = ref('')
onMounted(() => {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const options = currentPage.options || {}
currentCourseId.value = options.id || ''
loadCourse()
})
const loadCourse = async () => {
if (!currentCourseId.value) {
pageState.value = 'not_found'
pageMessage.value = '未找到要查看的课程。'
return
}
pageState.value = 'loading'
pageMessage.value = ''
try {
course.value = await courseApi.getCourseDetail(currentCourseId.value)
pageState.value = 'ready'
} catch (err) {
if (err?.type === 'not_found') {
pageState.value = 'not_found'
pageMessage.value = '该课程不存在或已下线。'
return
}
pageState.value = 'error'
pageMessage.value = errorMessage(err, '课程详情加载失败,请稍后重试。')
}
}
const goCourses = () => {
uni.reLaunch({ url: '/pages/ice/courses/index' })
}
const formatDateTimeRange = (startStr, endStr) => {
if (!startStr || !endStr) return '-'
const start = new Date(startStr)
const end = new Date(endStr)
const pad = (n) => String(n).padStart(2, '0')
const yyyy = start.getFullYear()
const mm = pad(start.getMonth() + 1)
const dd = pad(start.getDate())
const startHhMm = `${pad(start.getHours())}:${pad(start.getMinutes())}`
const endHhMm = `${pad(end.getHours())}:${pad(end.getMinutes())}`
return `${yyyy}-${mm}-${dd} ${startHhMm} - ${endHhMm}`
}
</script>
<style lang="scss" scoped>
.detail-container {
min-height: 100vh;
background-color: $bg-color-soft;
padding-bottom: $spacing-xl;
display: flex;
flex-direction: column;
gap: $spacing-md;
}
/* 1. 课程大图封面 */
.course-banner-wrap {
width: 100%;
height: 420rpx;
background-color: $bg-color-hover;
overflow: hidden;
.course-banner {
width: 100%;
height: 100%;
}
}
/* 通用详情卡片 */
.detail-card {
background-color: $bg-color-main;
border-bottom: 1rpx solid $bg-color-hover;
padding: $spacing-lg;
margin: 0 $spacing-md;
border-radius: $border-radius-lg;
box-shadow: 0 2rpx 10rpx rgba(15, 23, 42, 0.01);
&:first-of-type {
margin-top: -$spacing-lg; // 让主要信息卡片微悬浮盖在封面底图上方,增加层次感
position: relative;
z-index: 2;
}
}
/* 主要信息 */
.main-info-card {
.title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: $spacing-md;
}
.course-title {
font-size: 34rpx;
font-weight: 700;
color: $text-color-main;
line-height: 1.35;
flex: 1;
}
.info-grid {
display: flex;
flex-direction: column;
gap: $spacing-md;
padding-top: $spacing-xs;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 24rpx;
}
.info-label {
color: $text-color-muted;
}
.info-val {
color: $text-color-main;
font-weight: 500;
&.font-semibold {
font-weight: 700;
}
&.highlight-blue {
color: $color-primary;
font-weight: 700;
}
}
}
/* 富文本内容 */
.content-card {
.section-title {
font-size: 26rpx;
font-weight: 700;
color: $text-color-main;
margin-bottom: $spacing-md;
letter-spacing: 0.5rpx;
position: relative;
padding-left: $spacing-sm;
&::before {
content: '';
position: absolute;
left: 0;
top: 15%;
bottom: 15%;
width: 4rpx;
background-color: $text-color-main;
}
}
.rich-text-content {
display: block;
word-break: break-all;
}
}
</style>
+419
View File
@@ -0,0 +1,419 @@
<template>
<view class="courses-container">
<!-- 极简原生筛选栏 -->
<view class="native-filter-bar">
<!-- 微信原生日期选择器 -->
<picker
mode="date"
:value="selectedDate"
@change="onDateChange"
class="filter-picker-item"
>
<view class="picker-inner">
<text class="picker-label">日期</text>
<text class="picker-value">{{ selectedDate ? formatDateDisplay(selectedDate) : '选择日期' }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
<view class="vertical-divider"></view>
<!-- 微信原生教练单列选择器 -->
<picker
mode="selector"
:range="coachOptions"
range-key="name"
:value="selectedCoachIndex"
@change="onCoachChange"
class="filter-picker-item"
>
<view class="picker-inner">
<text class="picker-label">教练</text>
<text class="picker-value">{{ selectedCoachName }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<!-- 课程列表双栏高质感极简卡片 -->
<view class="course-list">
<StatePanel
v-if="listState === 'error'"
title="课程加载失败"
:description="listErrorMessage"
primary-text="重试"
type="error"
compact
@primary="loadCourses"
/>
<block v-else>
<view
v-for="course in courses"
:key="course.id"
class="course-card"
@tap="onViewDetail(course.id)"
>
<image :src="course.cover" mode="aspectFill" class="course-cover" />
<view class="course-info">
<view class="card-top-row">
<text class="course-title">{{ course.title }}</text>
<view class="badge success" v-if="course.is_booked">已预约</view>
</view>
<view class="course-details">
<text class="detail-item-text highlight-blue">时间{{ formatTimeRange(course.start_time, course.end_time) }}</text>
<view class="detail-bottom-meta">
<text class="detail-item-text"><text class="label-dot"></text>教练{{ course.coach.name }}</text>
<text class="detail-item-text"><text class="label-dot"></text>人数已约 {{ course.booked_count }}/{{ course.capacity }}</text>
</view>
</view>
</view>
</view>
</block>
<!-- 加载更多提示 -->
<view v-if="isLoadingMore" class="load-more-tip">
<text>加载中...</text>
</view>
<view v-else-if="courses.length > 0 && !hasMore" class="load-more-tip">
<text> 已加载全部 </text>
</view>
<!-- 空状态 -->
<StatePanel
v-if="listState !== 'error' && !isLoading && courses.length === 0"
title="暂无排课行程"
description="当前筛选条件下暂时没有可查看的课程。"
type="empty"
compact
/>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { courseApi } from '@/modules/ice/api/course'
import { errorMessage } from '@/modules/ice/api/session'
import StatePanel from '@/components/ice/StatePanel.vue'
const selectedDate = ref('')
const selectedCoachIndex = ref(0) // 索引 0 默认是 "全部教练"
const isLoading = ref(false)
const isLoadingMore = ref(false)
const listState = ref('idle')
const listErrorMessage = ref('')
const page = ref(1)
const pageSize = 20
const total = ref(0)
const hasMore = computed(() => courses.value.length < total.value)
const coaches = ref([])
// 原生 Picker 需要的可选数组
const coachOptions = computed(() => {
return [{ id: null, name: '全部教练' }, ...coaches.value]
})
const selectedCoachId = computed(() => {
return coachOptions.value[selectedCoachIndex.value]?.id || null
})
const selectedCoachName = computed(() => {
return coachOptions.value[selectedCoachIndex.value]?.name || '全部教练'
})
const courses = ref([])
onMounted(() => {
const today = new Date()
const pad = (n) => String(n).padStart(2, '0')
selectedDate.value = `${today.getFullYear()}-${pad(today.getMonth() + 1)}-${pad(today.getDate())}`
loadInitialData()
})
onPullDownRefresh(() => {
page.value = 1
loadCourses().finally(() => {
uni.stopPullDownRefresh()
})
})
onReachBottom(() => {
if (hasMore.value && !isLoadingMore.value) {
page.value++
loadMoreCourses()
}
})
const loadInitialData = async () => {
await Promise.all([loadCoaches(), loadCourses()])
}
const loadCoaches = async () => {
try {
coaches.value = await courseApi.getCoaches()
} catch (err) {
coaches.value = []
}
}
const loadCourses = async () => {
if (isLoading.value) return
isLoading.value = true
try {
const data = await courseApi.getCourses({
date: selectedDate.value,
coachId: selectedCoachId.value,
page: 1,
pageSize
})
courses.value = data.items
total.value = data.total
listState.value = 'ready'
listErrorMessage.value = ''
} catch (err) {
courses.value = []
total.value = 0
listState.value = 'error'
listErrorMessage.value = errorMessage(err, '课程加载失败,请稍后重试。')
} finally {
isLoading.value = false
}
}
const loadMoreCourses = async () => {
if (isLoadingMore.value) return
isLoadingMore.value = true
try {
const data = await courseApi.getCourses({
date: selectedDate.value,
coachId: selectedCoachId.value,
page: page.value,
pageSize
})
courses.value = [...courses.value, ...data.items]
total.value = data.total
} catch (err) {
page.value--
uni.showToast({ title: errorMessage(err, '加载更多失败,请重试'), icon: 'none' })
} finally {
isLoadingMore.value = false
}
}
const onDateChange = (e) => {
selectedDate.value = e.detail.value
page.value = 1
loadCourses()
}
const onCoachChange = (e) => {
selectedCoachIndex.value = Number(e.detail.value)
page.value = 1
loadCourses()
}
const onViewDetail = (id) => {
uni.navigateTo({
url: `/pages/ice/courses/detail?id=${id}`
})
}
const formatDateDisplay = (dateStr) => {
if (!dateStr) return ''
const parts = dateStr.split('-')
return `${parts[1]}${parts[2]}`
}
const formatTimeRange = (startStr, endStr) => {
const start = new Date(startStr)
const end = new Date(endStr)
const pad = (n) => String(n).padStart(2, '0')
const mm = pad(start.getMonth() + 1)
const dd = pad(start.getDate())
const startHhMm = `${pad(start.getHours())}:${pad(start.getMinutes())}`
const endHhMm = `${pad(end.getHours())}:${pad(end.getMinutes())}`
return `${mm}-${dd} ${startHhMm} - ${endHhMm}`
}
</script>
<style lang="scss" scoped>
.courses-container {
min-height: 100vh;
background-color: $bg-color-soft;
padding-bottom: $spacing-xl;
}
/* 极简原生筛选栏 */
.native-filter-bar {
display: flex;
align-items: center;
background-color: $bg-color-main;
border-bottom: 1rpx solid $bg-color-hover;
position: sticky;
top: 0;
z-index: 10;
height: 90rpx;
}
.filter-picker-item {
flex: 1;
height: 100%;
}
.picker-inner {
display: flex;
align-items: center;
justify-content: center;
height: 90rpx;
font-size: 26rpx;
color: $text-color-regular;
.picker-label {
color: $text-color-muted;
}
.picker-value {
color: $text-color-main;
font-weight: 700;
}
.picker-arrow {
font-size: 16rpx;
color: $text-color-light;
margin-left: 8rpx;
margin-top: 2rpx;
}
}
.vertical-divider {
width: 1rpx;
height: 40rpx;
background-color: $bg-color-hover;
}
/* 课程卡片 */
.course-list {
padding: $spacing-md;
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.course-card {
display: flex;
background-color: $bg-color-main;
border: 1rpx solid rgba(226, 232, 240, 0.8);
border-radius: $border-radius-lg;
overflow: hidden;
transition: all 0.2s ease;
&:active {
background-color: $bg-color-hover;
}
.course-cover {
width: 180rpx;
height: 150rpx;
background-color: $bg-color-hover;
flex-shrink: 0;
}
.course-info {
flex: 1;
padding: $spacing-sm $spacing-md;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.card-top-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: $spacing-xs;
}
.course-title {
font-size: 28rpx;
font-weight: 700;
color: $text-color-main;
line-height: 1.3;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
flex: 1;
}
.course-details {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.detail-item-text {
font-size: 22rpx;
color: $text-color-muted;
display: flex;
align-items: center;
&.highlight-blue {
color: $color-primary;
font-weight: 600;
}
.label-dot {
font-size: 20rpx;
margin-right: 8rpx;
opacity: 0.8;
}
}
.detail-bottom-meta {
display: flex;
align-items: center;
gap: $spacing-xl;
margin-top: 2rpx;
}
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 160rpx 0;
display: flex;
flex-direction: column;
align-items: center;
.empty-icon {
font-size: 64rpx;
margin-bottom: $spacing-md;
opacity: 0.8;
}
.empty-text {
font-size: 28rpx;
font-weight: 600;
color: $text-color-main;
margin-bottom: $spacing-xs;
}
.empty-sub {
font-size: 22rpx;
color: $text-color-muted;
}
}
.load-more-tip {
text-align: center;
padding: $spacing-md 0;
font-size: 22rpx;
color: $text-color-light;
}
</style>
+474
View File
@@ -0,0 +1,474 @@
<template>
<view class="home-container">
<StatePanel
v-if="pageState === 'error'"
title="首页内容加载失败"
:description="pageMessage"
primary-text="重试"
type="error"
@primary="loadHome"
/>
<block v-else>
<!-- 极简无感大图轮播 -->
<view class="banner-wrapper">
<swiper
class="banner-swiper"
circular
autoplay
interval="4000"
duration="600"
indicator-dots
indicator-color="rgba(15, 23, 42, 0.1)"
indicator-active-color="#0F172A"
>
<swiper-item v-for="(banner, index) in banners" :key="index" @tap="onTapBanner(banner)">
<view class="banner-item">
<image :src="banner.image" mode="aspectFill" class="banner-img" />
<view class="banner-overlay">
<text class="banner-title">{{ banner.title }}</text>
</view>
</view>
</swiper-item>
</swiper>
</view>
<!-- 近期课程板块 (克制留白无封面纯排版) -->
<view class="section-container">
<view class="section-header">
<text class="section-title">近期课程</text>
</view>
<view class="course-list">
<view
v-for="course in upcomingCourses"
:key="course.id"
class="course-item"
@tap="navigateToDetail('/pages/ice/courses/detail?id=' + course.id)"
>
<image :src="course.cover" mode="aspectFill" class="course-cover" />
<view class="course-info-wrap">
<text class="course-name">{{ course.title }}</text>
<text class="course-time highlight-blue">时间{{ formatCourseDate(course.start_time, course.end_time) }}</text>
<view class="course-bottom-meta">
<text class="course-meta-text"> 教练{{ course.coach_name }}</text>
<text class="course-meta-text"> 人数已约 {{ course.booked_count }}/{{ course.capacity }}</text>
</view>
</view>
</view>
<view v-if="upcomingCourses.length === 0" class="empty-placeholder">
<text>今日暂无课程排期</text>
</view>
</view>
</view>
<!-- 活动资讯版块 -->
<view class="section-container last-section">
<view class="section-header">
<text class="section-title">公告活动</text>
</view>
<view class="activity-grid">
<view
v-for="activity in activities"
:key="activity.id"
class="activity-card"
@tap="navigateToDetail('/pages/ice/activities/detail?id=' + activity.id)"
>
<image :src="activity.cover" mode="aspectFill" class="activity-cover" />
<view class="activity-info">
<text class="activity-title">{{ activity.title }}</text>
<text class="activity-date">{{ formatDate(activity.created_at) }}</text>
</view>
</view>
</view>
</view>
</block>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { onPullDownRefresh } from '@dcloudio/uni-app'
import { homeApi } from '@/modules/ice/api/home'
import { errorMessage } from '@/modules/ice/api/session'
import StatePanel from '@/components/ice/StatePanel.vue'
const todayStr = ref('')
const banners = ref([])
const upcomingCourses = ref([])
const activities = ref([])
const pageState = ref('ready')
const pageMessage = ref('')
onMounted(() => {
const days = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
const now = new Date()
todayStr.value = `${now.getMonth() + 1}${now.getDate()}${days[now.getDay()]}`
loadHome()
})
onPullDownRefresh(() => {
loadHome().finally(() => {
uni.stopPullDownRefresh()
})
})
const loadHome = async () => {
try {
const data = await homeApi.getHome({
courseLimit: 2,
bannerLimit: 5
})
banners.value = data.banners
upcomingCourses.value = data.upcomingCourses
activities.value = data.activities
pageState.value = 'ready'
pageMessage.value = ''
} catch (err) {
pageState.value = 'error'
pageMessage.value = errorMessage(err, '首页内容加载失败,请稍后重试。')
}
}
const navigateToDetail = (url) => {
uni.navigateTo({ url })
}
const onTapBanner = (banner) => {
if (banner.link_type === 'course' && banner.link_value) {
navigateToDetail(`/pages/ice/courses/detail?id=${banner.link_value}`)
} else if (banner.link_type === 'activity' && banner.link_value) {
navigateToDetail(`/pages/ice/activities/detail?id=${banner.link_value}`)
}
}
const formatCourseDate = (startStr, endStr) => {
const start = new Date(startStr)
const end = new Date(endStr)
const pad = (n) => String(n).padStart(2, '0')
const mm = pad(start.getMonth() + 1)
const dd = pad(start.getDate())
const startHhMm = `${pad(start.getHours())}:${pad(start.getMinutes())}`
const endHhMm = `${pad(end.getHours())}:${pad(end.getMinutes())}`
return `${mm}-${dd} ${startHhMm} - ${endHhMm}`
}
const formatDate = (dateStr) => {
const date = new Date(dateStr)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
</script>
<style lang="scss" scoped>
.home-container {
padding: $spacing-lg $spacing-md;
background-color: $bg-color-soft;
min-height: 100vh;
}
/* 顶部场馆标题 */
.header-section {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: $spacing-lg;
padding: 0 4rpx;
.venue-info {
display: flex;
flex-direction: column;
}
.venue-title {
font-size: 38rpx;
font-weight: 700;
color: $text-color-main;
letter-spacing: -0.5rpx;
}
.venue-status-tag {
font-size: 20rpx;
color: $color-success;
font-weight: 500;
margin-top: $spacing-xs;
}
.today-date {
font-size: 24rpx;
color: $text-color-muted;
}
}
/* 无圆角,极简轮播 */
.banner-wrapper {
margin-bottom: $spacing-lg;
.banner-swiper {
height: 320rpx;
border-radius: $border-radius-base;
overflow: hidden;
}
.banner-item {
position: relative;
width: 100%;
height: 100%;
}
.banner-img {
width: 100%;
height: 100%;
background-color: $bg-color-hover;
}
.banner-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, rgba(15, 23, 42, 0.6), transparent);
padding: $spacing-md;
display: flex;
align-items: flex-end;
}
.banner-title {
color: #FFFFFF;
font-size: 24rpx;
font-weight: 400;
line-height: 1.4;
}
}
/* 核心快捷卡片入口 */
.quick-entry-grid {
display: flex;
flex-direction: column;
gap: $spacing-md;
margin-bottom: $spacing-xl;
}
.entry-card {
display: flex;
align-items: center;
background: $bg-color-main;
border: 1rpx solid rgba(226, 232, 240, 0.8);
border-radius: $border-radius-base;
padding: $spacing-md $spacing-lg;
position: relative;
transition: all 0.2s ease;
&:active {
background-color: $bg-color-hover;
}
&.primary-entry {
border-color: rgba(14, 165, 233, 0.15);
background: linear-gradient(135deg, $bg-color-main 0%, #F0F9FF 100%);
.entry-num {
color: $color-primary;
}
}
.entry-icon-wrap {
margin-right: $spacing-lg;
}
.entry-num {
font-size: 32rpx;
font-weight: 700;
color: $text-color-light;
font-family: monospace;
}
.entry-text-wrap {
display: flex;
flex-direction: column;
flex: 1;
}
.entry-title {
font-size: 30rpx;
font-weight: 600;
color: $text-color-main;
}
.entry-desc {
font-size: 22rpx;
color: $text-color-muted;
margin-top: 4rpx;
}
.entry-arrow {
font-size: 32rpx;
color: $text-color-light;
}
}
/* 板块通用头部 */
.section-container {
margin-bottom: $spacing-xl;
&.last-section {
margin-bottom: 0;
}
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-md;
padding: 0 4rpx;
.section-title {
font-size: 28rpx;
font-weight: 700;
letter-spacing: 0.5rpx;
color: $text-color-main;
position: relative;
padding-left: $spacing-sm;
&::before {
content: '';
position: absolute;
left: 0;
top: 10%;
bottom: 10%;
width: 4rpx;
background-color: $text-color-main;
}
}
.section-more {
font-size: 24rpx;
color: $text-color-muted;
}
}
/* 课程卡片 */
.course-list {
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.course-item {
display: flex;
background-color: $bg-color-main;
border-radius: $border-radius-base;
border: 1rpx solid $bg-color-hover;
overflow: hidden;
transition: all 0.2s ease;
&:active {
background-color: $bg-color-hover;
}
.course-cover {
width: 180rpx;
height: 130rpx;
background-color: $bg-color-hover;
flex-shrink: 0;
}
.course-info-wrap {
flex: 1;
padding: $spacing-sm $spacing-md;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.course-name {
font-size: 26rpx;
font-weight: 600;
color: $text-color-main;
line-height: 1.3;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
}
.course-time {
font-size: 22rpx;
color: $text-color-muted;
font-weight: 500;
&.highlight-blue {
color: $color-primary;
font-weight: 600;
}
}
.course-bottom-meta {
display: flex;
align-items: center;
gap: $spacing-xl;
}
.course-meta-text {
font-size: 22rpx;
color: $text-color-muted;
display: flex;
align-items: center;
}
}
/* 极简活动卡片(两列横向拼图排版) */
.activity-grid {
display: grid;
grid-template-columns: 1fr 1;
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.activity-card {
display: flex;
background-color: $bg-color-main;
border-radius: $border-radius-base;
border: 1rpx solid $bg-color-hover;
overflow: hidden;
.activity-cover {
width: 180rpx;
height: 130rpx;
background-color: $bg-color-hover;
}
.activity-info {
flex: 1;
padding: $spacing-sm $spacing-md;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.activity-title {
font-size: 26rpx;
font-weight: 600;
color: $text-color-main;
line-height: 1.3;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.activity-date {
font-size: 20rpx;
color: $text-color-light;
}
}
.empty-placeholder {
text-align: center;
padding: $spacing-xl 0;
color: $text-color-light;
font-size: 24rpx;
}
</style>
@@ -0,0 +1,516 @@
<template>
<view class="history-container">
<StatePanel
v-if="pageState === 'loading'"
title="正在加载会员卡流水"
description="请稍候"
type="loading"
/>
<StatePanel
v-else-if="sessionStatus === SESSION_STATUS.GUEST"
title="登录后查看会员卡流水"
description="请先登录并完善会员资料。"
primary-text="微信一键登录"
type="login"
:loading="isLoginSubmitting"
@primary="onWechatLogin"
/>
<StatePanel
v-else-if="sessionStatus === SESSION_STATUS.EXPIRED"
title="登录状态已过期"
description="为了保护会员资料,请重新登录后继续查看。"
primary-text="重新登录"
type="expired"
:loading="isLoginSubmitting"
@primary="onWechatLogin"
/>
<StatePanel
v-else-if="sessionStatus === SESSION_STATUS.PROFILE_REQUIRED"
title="完善会员资料后查看会员卡"
description="请先在会员中心完成姓名和手机号绑定,用于匹配你的会员卡。"
primary-text="去完善资料"
type="profile"
@primary="goProfile"
/>
<StatePanel
v-else-if="sessionStatus === SESSION_STATUS.NETWORK_ERROR"
title="暂时无法连接服务"
:description="pageMessage || '请检查网络后重试。'"
primary-text="重试"
type="error"
@primary="loadPage"
/>
<StatePanel
v-else-if="pageState === 'not_found'"
title="未找到会员卡"
:description="pageMessage || '该会员卡不存在,或你没有查看权限。'"
primary-text="返回会员卡列表"
type="empty"
@primary="goCards"
/>
<StatePanel
v-else-if="pageState === 'error'"
title="会员卡流水加载失败"
:description="pageMessage"
primary-text="重试"
type="error"
@primary="loadPage"
/>
<block v-else>
<!-- 头部会员卡摘要卡片 -->
<view class="card-summary-card">
<view class="summary-top">
<text class="card-title">{{ card.card_name }}</text>
<view class="badge success" v-if="card.status === 'active'">正常使用中</view>
</view>
<view class="summary-meta">
<text class="card-no">卡号: {{ card.card_no }}</text>
<text class="card-validity">截止有效期: {{ formatDate(card.expire_date) }}</text>
</view>
<view class="divider"></view>
<view class="summary-equity">
<view class="equity-cell" v-if="card.card_type === 'times'">
<text class="eq-label">当前剩余</text>
<text class="eq-val font-large">{{ card.remained_times }} <text class="unit"></text></text>
</view>
<view class="equity-cell" v-else-if="card.card_type === 'balance'">
<text class="eq-label">当前余额</text>
<text class="eq-val font-large">¥ {{ card.balance }}</text>
</view>
<view class="equity-cell" v-else>
<text class="eq-label">卡项类型</text>
<text class="eq-val">无限通滑年卡</text>
</view>
<view class="vertical-divider"></view>
<view class="equity-cell right-cell">
<text class="eq-label">累计变动</text>
<text class="eq-val">{{ historyList.length }} 次变动记录</text>
</view>
</view>
</view>
<!-- 极简历史流水列表 -->
<view class="history-section">
<view class="section-title">流水明细日志</view>
<view class="timeline-list">
<view v-for="log in historyList" :key="log.id" class="timeline-log-card">
<view class="log-card-left">
<view class="log-icon-bubble" :class="[log.change_type]">
<view class="log-dot-decor"></view>
</view>
<view class="log-meta">
<text class="log-title-text">{{ formatChangeType(log.change_type) }}</text>
<text class="log-desc">{{ log.memo }}</text>
<text class="log-time">{{ formatFullDateTime(log.created_at) }}</text>
</view>
</view>
<view class="log-card-right">
<!-- 变动值 -->
<text class="log-delta" :class="{ plus: Number(log.delta) > 0 }">
{{ Number(log.delta) > 0 ? '+' : '' }}{{ log.delta }}{{ card.card_type === 'times' ? '次' : '元' }}
</text>
<!-- 变更后结果 -->
<text class="log-post-balance" v-if="card.card_type === 'times'"> {{ log.post_times }} </text>
<text class="log-post-balance" v-else-if="card.card_type === 'balance'"> ¥{{ log.post_balance }}</text>
</view>
</view>
<!-- 无流水空状态 -->
<StatePanel
v-if="historyList.length === 0"
title="暂无使用流水"
description="这张会员卡暂时没有变动记录。"
type="empty"
compact
/>
</view>
</view>
</block>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onPullDownRefresh, onShow } from '@dcloudio/uni-app'
import { cardApi } from '@/modules/ice/api/card'
import { errorMessage, getMemberSession, loginAndStoreToken, sessionStatusFromError, SESSION_STATUS } from '@/modules/ice/api/session'
import StatePanel from '@/components/ice/StatePanel.vue'
const card = ref({
id: null,
card_no: '',
card_name: '',
card_type: 'times',
status: 'active',
remained_times: 0,
balance: '0.00',
expire_date: ''
})
const historyList = ref([])
const page = ref(1)
const pageSize = 20
const total = ref(0)
const currentCardId = ref(null)
const pageState = ref('loading')
const pageMessage = ref('')
const sessionStatus = ref(SESSION_STATUS.CHECKING)
const isLoginSubmitting = ref(false)
onShow(() => {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const options = currentPage.options || {}
const id = options.id
if (!id) {
pageState.value = 'not_found'
pageMessage.value = '未找到要查看的会员卡。'
return
}
currentCardId.value = id
loadPage()
})
onPullDownRefresh(() => {
page.value = 1
loadPage().finally(() => {
uni.stopPullDownRefresh()
})
})
const loadPage = async () => {
if (!currentCardId.value) {
pageState.value = 'not_found'
pageMessage.value = '未找到要查看的会员卡。'
return
}
pageState.value = 'loading'
pageMessage.value = ''
const session = await getMemberSession()
sessionStatus.value = session.status
if (session.status !== SESSION_STATUS.MEMBER) {
pageState.value = 'blocked'
pageMessage.value = session.message || ''
return
}
try {
const data = await cardApi.getCardChanges(currentCardId.value, {
page: page.value,
pageSize
})
card.value = data.card
historyList.value = data.items
total.value = data.total
pageState.value = 'ready'
} catch (err) {
const status = sessionStatusFromError(err)
if (status) {
sessionStatus.value = status
pageState.value = 'blocked'
pageMessage.value = err?.message || ''
return
}
if (err?.type === 'not_found') {
pageState.value = 'not_found'
pageMessage.value = '该会员卡不存在,或你没有查看权限。'
return
}
historyList.value = []
total.value = 0
pageState.value = 'error'
pageMessage.value = errorMessage(err, '会员卡流水加载失败,请稍后重试。')
}
}
const onWechatLogin = async () => {
if (isLoginSubmitting.value) return
isLoginSubmitting.value = true
uni.showLoading({ title: '登录中...' })
try {
await loginAndStoreToken()
await loadPage()
if (sessionStatus.value === SESSION_STATUS.MEMBER && pageState.value === 'ready') {
uni.showToast({ title: '登录成功', icon: 'success' })
}
} catch (err) {
uni.showModal({
title: '登录失败',
content: errorMessage(err, '微信登录失败,请稍后重试'),
showCancel: false,
confirmText: '知道了'
})
} finally {
isLoginSubmitting.value = false
uni.hideLoading()
}
}
const goProfile = () => {
uni.reLaunch({ url: '/pages/ice/profile/index' })
}
const goCards = () => {
uni.redirectTo({ url: '/pages/ice/profile/cards' })
}
const formatChangeType = (type) => {
const map = {
issue: '开卡入账',
recharge: '充值金额',
consume: '课程上课消费',
adjustment: '手工调整额度',
disable: '停用卡项',
enable: '启用卡项',
extend_validity: '延长有效期',
shorten_validity: '缩短有效期'
}
return map[type] || '其他变动'
}
const formatDate = (dateStr) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`
}
const formatFullDateTime = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
const pad = (n) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
</script>
<style lang="scss" scoped>
.history-container {
min-height: 100vh;
background-color: $bg-color-soft;
padding: $spacing-md;
display: flex;
flex-direction: column;
gap: $spacing-md;
}
/* 顶部会员卡摘要卡片 */
.card-summary-card {
background-color: $bg-color-main;
border-radius: $border-radius-lg;
border: 1rpx solid rgba(226, 232, 240, 0.8);
padding: $spacing-lg;
.summary-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-sm;
.card-title {
font-size: 32rpx;
font-weight: 700;
color: $text-color-main;
}
}
.summary-meta {
display: flex;
flex-direction: column;
gap: 4rpx;
font-size: 22rpx;
color: $text-color-muted;
}
.summary-equity {
display: flex;
align-items: center;
.equity-cell {
flex: 1;
display: flex;
flex-direction: column;
gap: 6rpx;
&.right-cell {
padding-left: $spacing-lg;
}
.eq-label {
font-size: 20rpx;
color: $text-color-light;
text-transform: uppercase;
}
.eq-val {
font-size: 24rpx;
color: $text-color-regular;
font-weight: 600;
&.font-large {
font-size: 38rpx;
font-weight: 800;
color: $text-color-main;
font-family: -apple-system, BlinkMacSystemFont, monospace;
}
.unit {
font-size: 22rpx;
font-weight: 400;
color: $text-color-muted;
margin-left: 4rpx;
}
}
}
.vertical-divider {
width: 1rpx;
height: 60rpx;
background-color: $bg-color-hover;
}
}
}
/* 历史流水列表 */
.history-section {
display: flex;
flex-direction: column;
gap: $spacing-md;
.section-title {
font-size: 24rpx;
font-weight: 700;
color: $text-color-muted;
text-transform: uppercase;
padding-left: 4rpx;
}
}
.timeline-list {
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.timeline-log-card {
background-color: $bg-color-main;
border-radius: $border-radius-base;
border: 1rpx solid $bg-color-hover;
padding: $spacing-md $spacing-lg;
display: flex;
justify-content: space-between;
align-items: center;
.log-card-left {
display: flex;
align-items: flex-start;
gap: $spacing-md;
flex: 1;
padding-right: $spacing-md;
}
.log-icon-bubble {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background-color: $bg-color-soft;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 4rpx;
.log-dot-decor {
width: 14rpx;
height: 14rpx;
border-radius: 50%;
background-color: $text-color-light;
}
&.consume {
background-color: $color-primary-light;
.log-dot-decor {
background-color: $color-primary;
}
}
&.recharge, &.issue {
background-color: $color-success-light;
.log-dot-decor {
background-color: $color-success;
}
}
&.adjustment {
background-color: $color-warning-light;
.log-dot-decor {
background-color: $color-warning;
}
}
}
.log-meta {
display: flex;
flex-direction: column;
gap: 4rpx;
.log-title-text {
font-size: 26rpx;
font-weight: 700;
color: $text-color-main;
}
.log-desc {
font-size: 22rpx;
color: $text-color-regular;
}
.log-time {
font-size: 20rpx;
color: $text-color-light;
}
}
.log-card-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4rpx;
flex-shrink: 0;
.log-delta {
font-size: 28rpx;
font-weight: 700;
color: $text-color-main;
&.plus {
color: $color-success;
}
}
.log-post-balance {
font-size: 20rpx;
color: $text-color-muted;
}
}
}
.empty-state {
text-align: center;
padding: 80rpx 0;
color: $text-color-light;
font-size: 24rpx;
}
</style>
+430
View File
@@ -0,0 +1,430 @@
<template>
<view class="cards-container">
<StatePanel
v-if="sessionStatus === SESSION_STATUS.CHECKING"
title="正在检查登录状态"
description="请稍候"
type="loading"
/>
<StatePanel
v-else-if="sessionStatus === SESSION_STATUS.GUEST"
title="登录后查看会员卡"
description="登录并完善会员资料后,可查看场馆为你登记的会员卡。"
primary-text="微信一键登录"
type="login"
:loading="isLoginSubmitting"
@primary="onWechatLogin"
/>
<StatePanel
v-else-if="sessionStatus === SESSION_STATUS.EXPIRED"
title="登录状态已过期"
description="为了保护会员资料,请重新登录后继续查看会员卡。"
primary-text="重新登录"
type="expired"
:loading="isLoginSubmitting"
@primary="onWechatLogin"
/>
<StatePanel
v-else-if="sessionStatus === SESSION_STATUS.PROFILE_REQUIRED"
title="完善会员资料后查看会员卡"
description="请先在会员中心完成姓名和手机号绑定,用于匹配你的会员卡。"
primary-text="去完善资料"
type="profile"
@primary="goProfile"
/>
<StatePanel
v-else-if="sessionStatus === SESSION_STATUS.NETWORK_ERROR"
title="暂时无法连接服务"
:description="sessionMessage || '请检查网络后重试。'"
primary-text="重试"
type="error"
@primary="checkSessionAndLoad"
/>
<!-- 会员卡列表 -->
<view class="cards-list" v-else>
<StatePanel
v-if="listState === 'error'"
title="会员卡加载失败"
:description="listErrorMessage"
primary-text="重试"
type="error"
compact
@primary="loadCards"
/>
<block v-else>
<view
v-for="card in filteredCards"
:key="card.id"
class="member-card-box"
:class="[card.status, card.card_type]"
>
<!-- 会员卡卡面 -->
<view class="card-face">
<view class="face-top">
<view class="card-brand">
<text class="card-no">{{ card.card_no }}</text>
</view>
<view class="card-type-label">
{{ formatCardType(card.card_type) }}
</view>
</view>
<view class="face-middle">
<text class="card-name">{{ card.card_name }}</text>
</view>
<view class="face-bottom">
<!-- 剩余次卡权益 -->
<view class="equity-display" v-if="card.card_type === 'times'">
<text class="equity-label">剩余次数</text>
<text class="equity-value"><text class="number">{{ card.remained_times }}</text> </text>
</view>
<!-- 剩余储值权益 -->
<view class="equity-display" v-else-if="card.card_type === 'balance'">
<text class="equity-label">剩余额度</text>
<text class="equity-value">¥ <text class="number">{{ card.balance }}</text></text>
</view>
<!-- 月卡不限次 -->
<view class="equity-display" v-else-if="card.card_type === 'month'">
<text class="equity-label">专属权益</text>
<text class="equity-value">不限次月卡</text>
</view>
<view class="card-validity">
<text class="date-range">{{ card.start_date }} {{ card.expire_date }}</text>
</view>
</view>
</view>
<!-- 卡片底部使用流水操作行 -->
<view class="card-actions">
<view class="financial-details">
<text class="fin-text" v-if="card.card_type === 'times'">总次数: {{ card.total_times }}</text>
<text class="fin-text" v-else-if="card.card_type === 'balance'">含赠送: ¥{{ card.gift_balance }}</text>
<text class="fin-text" v-else-if="card.card_type === 'month'">有效期内无限通滑</text>
</view>
<view class="action-btn" @tap="onViewHistory(card.id)">
<text>使用流水</text>
<text class="arrow"></text>
</view>
</view>
</view>
</block>
<!-- 空状态 -->
<StatePanel
v-if="listState !== 'error' && !isLoading && filteredCards.length === 0"
title="未查到会员卡"
description="场馆暂未为你登记会员卡。"
type="empty"
compact
/>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { onPullDownRefresh, onShow } from '@dcloudio/uni-app'
import { cardApi } from '@/modules/ice/api/card'
import { authStorage } from '@/modules/ice/api/member'
import { errorMessage, getMemberSession, loginAndStoreToken, sessionStatusFromError, SESSION_STATUS } from '@/modules/ice/api/session'
import StatePanel from '@/components/ice/StatePanel.vue'
const cards = ref([])
const isLoading = ref(false)
const isLoginSubmitting = ref(false)
const sessionStatus = ref(SESSION_STATUS.CHECKING)
const sessionMessage = ref('')
const listState = ref('idle')
const listErrorMessage = ref('')
const filteredCards = computed(() => {
return cards.value
})
onShow(() => {
checkSessionAndLoad()
})
onPullDownRefresh(() => {
const task = sessionStatus.value === SESSION_STATUS.MEMBER ? loadCards() : checkSessionAndLoad()
task.finally(() => {
uni.stopPullDownRefresh()
})
})
const checkSessionAndLoad = async () => {
sessionStatus.value = SESSION_STATUS.CHECKING
const session = await getMemberSession()
sessionStatus.value = session.status
sessionMessage.value = session.message || ''
if (session.status === SESSION_STATUS.MEMBER) {
await loadCards()
return
}
cards.value = []
listState.value = 'idle'
}
const loadCards = async () => {
if (isLoading.value) return
isLoading.value = true
try {
cards.value = await cardApi.getCards()
listState.value = 'ready'
listErrorMessage.value = ''
} catch (err) {
if (applySessionError(err)) return
cards.value = []
listState.value = 'error'
listErrorMessage.value = errorMessage(err, '会员卡加载失败,请稍后重试。')
} finally {
isLoading.value = false
}
}
const onViewHistory = (id) => {
uni.navigateTo({
url: `/pages/ice/profile/card-history?id=${id}`
})
}
const formatCardType = (type) => {
const map = {
times: '课时次卡',
balance: '储值卡',
month: '不限次月卡'
}
return map[type] || '普通会员卡'
}
const applySessionError = (err) => {
const status = sessionStatusFromError(err)
if (!status) return false
sessionStatus.value = status
sessionMessage.value = err?.message || ''
cards.value = []
listState.value = 'idle'
if (status === SESSION_STATUS.EXPIRED) {
authStorage.clear()
}
return true
}
const onWechatLogin = async () => {
if (isLoginSubmitting.value) return
isLoginSubmitting.value = true
uni.showLoading({ title: '登录中...' })
try {
await loginAndStoreToken()
await checkSessionAndLoad()
if (sessionStatus.value === SESSION_STATUS.MEMBER) {
uni.showToast({ title: '登录成功', icon: 'success' })
}
} catch (err) {
uni.showModal({
title: '登录失败',
content: errorMessage(err, '微信登录失败,请稍后重试'),
showCancel: false,
confirmText: '知道了'
})
} finally {
isLoginSubmitting.value = false
uni.hideLoading()
}
}
const goProfile = () => {
uni.reLaunch({ url: '/pages/ice/profile/index' })
}
</script>
<style lang="scss" scoped>
.cards-container {
min-height: 100vh;
background-color: $bg-color-soft;
padding-bottom: $spacing-xl;
}
/* 卡片列表 */
.cards-list {
padding: $spacing-md;
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.member-card-box {
background-color: $bg-color-main;
border: 1rpx solid rgba(226, 232, 240, 0.8);
border-radius: $border-radius-lg;
overflow: hidden;
box-shadow: 0 4rpx 15rpx rgba(15, 23, 42, 0.02);
&.expired {
opacity: 0.6;
.card-face {
background: linear-gradient(135deg, #F1F5F9 0%, #E2E8F0 100%);
}
}
/* 针对启用卡定制淡雅微渐变皮肤,避免刺眼大图 */
&.active {
&.times {
.card-face {
background: linear-gradient(135deg, #FFFFFF 0%, #F0F9FF 100%);
}
}
&.balance {
.card-face {
background: linear-gradient(135deg, #FFFFFF 0%, #ECFDF5 100%);
}
}
}
}
.card-face {
padding: $spacing-lg;
display: flex;
flex-direction: column;
gap: $spacing-md;
border-bottom: 1rpx solid $bg-color-hover;
.face-top {
display: flex;
justify-content: space-between;
align-items: center;
.card-no {
font-size: 20rpx;
font-family: monospace;
color: $text-color-light;
display: block;
}
.card-type-label {
font-size: 20rpx;
font-weight: 500;
color: $text-color-light;
display: block;
}
}
.face-middle {
.card-name {
font-size: 32rpx;
font-weight: 700;
color: $text-color-main;
display: block;
}
}
.face-bottom {
display: flex;
justify-content: space-between;
align-items: flex-end;
.equity-display {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.equity-label {
font-size: 20rpx;
color: $text-color-light;
text-transform: uppercase;
}
.equity-value {
font-size: 24rpx;
color: $text-color-main;
font-weight: 500;
.number {
font-size: 40rpx;
font-weight: 800;
font-family: -apple-system, BlinkMacSystemFont, monospace;
}
.sub-label {
font-size: 20rpx;
color: $text-color-muted;
margin-left: 6rpx;
}
}
.card-validity {
text-align: right;
.date-range {
font-size: 20rpx;
color: $text-color-muted;
}
}
}
}
/* 卡片下方极简操作行 */
.card-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding: $spacing-md $spacing-lg;
font-size: 22rpx;
.financial-details {
display: flex;
align-items: center;
color: $text-color-muted;
.fin-text {
&.dot {
margin: 0 8rpx;
color: $text-color-light;
}
}
}
.action-btn {
display: flex;
align-items: center;
gap: 6rpx;
color: $text-color-main;
font-weight: 600;
.arrow {
font-size: 24rpx;
}
}
}
/* 极致空状态 */
.empty-state {
text-align: center;
padding: 160rpx $spacing-lg;
.empty-text {
font-size: 28rpx;
font-weight: 700;
color: $text-color-main;
margin-bottom: $spacing-xs;
}
.empty-sub {
font-size: 22rpx;
color: $text-color-muted;
line-height: 1.5;
}
}
</style>
@@ -0,0 +1,271 @@
<template>
<view class="profile-detail-container">
<StatePanel
v-if="pageState === 'loading'"
title="正在加载个人信息"
description="请稍候"
type="loading"
/>
<StatePanel
v-else-if="sessionStatus === SESSION_STATUS.GUEST"
title="登录后查看个人信息"
description="请先登录并完善会员资料。"
primary-text="微信一键登录"
type="login"
:loading="isLoginSubmitting"
@primary="onWechatLogin"
/>
<StatePanel
v-else-if="sessionStatus === SESSION_STATUS.EXPIRED"
title="登录状态已过期"
description="为了保护会员资料,请重新登录后继续查看。"
primary-text="重新登录"
type="expired"
:loading="isLoginSubmitting"
@primary="onWechatLogin"
/>
<StatePanel
v-else-if="sessionStatus === SESSION_STATUS.PROFILE_REQUIRED"
title="请先完善会员资料"
description="完成姓名和手机号绑定后,可查看完整个人资料。"
primary-text="去完善资料"
type="profile"
@primary="goProfile"
/>
<StatePanel
v-else-if="sessionStatus === SESSION_STATUS.NETWORK_ERROR"
title="暂时无法连接服务"
:description="pageMessage || '请检查网络后重试。'"
primary-text="重试"
type="error"
@primary="loadProfile"
/>
<StatePanel
v-else-if="pageState === 'error'"
title="个人信息加载失败"
:description="pageMessage"
primary-text="重试"
type="error"
@primary="loadProfile"
/>
<block v-else>
<!-- 头部人物名片 -->
<view class="detail-header-card">
<view class="header-meta">
<text class="header-name">{{ member.name }}</text>
<text class="header-phone">{{ member.phone }}</text>
</view>
</view>
<!-- 基本资料 -->
<view class="info-card">
<text class="card-section-tag">基本资料</text>
<view class="info-row">
<text class="info-label">姓名</text>
<text class="info-val">{{ member.name || '-' }}</text>
</view>
<view class="info-row">
<text class="info-label">手机号</text>
<text class="info-val">{{ member.phone || '-' }}</text>
</view>
<view class="info-row">
<text class="info-label">性别</text>
<text class="info-val">{{ formatGender(member.gender) }}</text>
</view>
<view class="info-row">
<text class="info-label">生日</text>
<text class="info-val">{{ member.birthday || '-' }}</text>
</view>
</view>
<!-- 会员档案 -->
<view class="info-card">
<text class="card-section-tag">会员档案</text>
<view class="info-row">
<text class="info-label">入会日期</text>
<text class="info-val">{{ formatDate(member.joinDate) }}</text>
</view>
</view>
</block>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { errorMessage, getMemberSession, loginAndStoreToken, SESSION_STATUS } from '@/modules/ice/api/session'
import StatePanel from '@/components/ice/StatePanel.vue'
const member = ref({
name: '',
phone: '',
gender: 'unknown',
birthday: '',
joinDate: '',
salesName: '',
cardCount: 0
})
const pageState = ref('loading')
const pageMessage = ref('')
const sessionStatus = ref(SESSION_STATUS.CHECKING)
const isLoginSubmitting = ref(false)
onShow(() => {
loadProfile()
})
const loadProfile = async () => {
pageState.value = 'loading'
pageMessage.value = ''
try {
const session = await getMemberSession()
sessionStatus.value = session.status
if (session.status !== SESSION_STATUS.MEMBER) {
pageState.value = 'blocked'
pageMessage.value = session.message || ''
return
}
const data = session.member
if (data?.id) {
member.value = {
name: data.name || '',
phone: data.phone || '',
gender: data.gender || 'unknown',
birthday: data.birthday || '',
joinDate: data.joinDate || '',
salesName: data.salesName || '无',
cardCount: data.cardCount || 0
}
}
pageState.value = 'ready'
} catch (err) {
pageState.value = 'error'
pageMessage.value = errorMessage(err, '个人信息加载失败,请稍后重试。')
}
}
const onWechatLogin = async () => {
if (isLoginSubmitting.value) return
isLoginSubmitting.value = true
uni.showLoading({ title: '登录中...' })
try {
await loginAndStoreToken()
await loadProfile()
if (sessionStatus.value === SESSION_STATUS.MEMBER && pageState.value === 'ready') {
uni.showToast({ title: '登录成功', icon: 'success' })
}
} catch (err) {
uni.showModal({
title: '登录失败',
content: errorMessage(err, '微信登录失败,请稍后重试'),
showCancel: false,
confirmText: '知道了'
})
} finally {
isLoginSubmitting.value = false
uni.hideLoading()
}
}
const goProfile = () => {
uni.reLaunch({ url: '/pages/ice/profile/index' })
}
const formatGender = (g) => {
const map = { male: '男', female: '女', unknown: '未设置' }
return map[g] || '未设置'
}
const formatDate = (dateStr) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`
}
</script>
<style lang="scss" scoped>
.profile-detail-container {
min-height: 100vh;
background-color: $bg-color-soft;
padding: $spacing-lg $spacing-md;
display: flex;
flex-direction: column;
gap: $spacing-lg;
}
.detail-header-card {
background-color: $bg-color-main;
border-radius: $border-radius-lg;
border: 1rpx solid rgba(226, 232, 240, 0.8);
padding: $spacing-xl $spacing-lg;
display: flex;
align-items: center;
gap: $spacing-md;
.header-meta {
display: flex;
flex-direction: column;
.header-name {
font-size: 34rpx;
font-weight: 700;
color: $text-color-main;
}
.header-phone {
font-size: 24rpx;
color: $text-color-muted;
margin-top: 6rpx;
}
}
}
.info-card {
background-color: $bg-color-main;
border-radius: $border-radius-lg;
border: 1rpx solid rgba(226, 232, 240, 0.8);
padding: $spacing-lg;
display: flex;
flex-direction: column;
gap: $spacing-md;
.card-section-tag {
font-size: 22rpx;
font-weight: 700;
color: $text-color-light;
letter-spacing: 1rpx;
text-transform: uppercase;
margin-bottom: $spacing-xs;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: $spacing-sm 0;
border-bottom: 1rpx solid $bg-color-hover;
&:last-child {
border-bottom: none;
}
.info-label {
font-size: 26rpx;
color: $text-color-muted;
}
.info-val {
font-size: 26rpx;
color: $text-color-main;
font-weight: 600;
}
}
}
</style>
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 433 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 493 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 B