feat: migrate comprehensive venue module
@@ -0,0 +1,44 @@
|
|||||||
|
import { authStorage, request } from './request'
|
||||||
|
import { toNumber } from './mappers'
|
||||||
|
|
||||||
|
const toMember = (backendMember = {}) => ({
|
||||||
|
id: backendMember.id ?? null,
|
||||||
|
openid: backendMember.openid || '',
|
||||||
|
unionid: backendMember.unionid || '',
|
||||||
|
nickname: backendMember.nickname || '',
|
||||||
|
phone: backendMember.phone || '',
|
||||||
|
avatar_url: backendMember.avatar_url || '',
|
||||||
|
balance: backendMember.balance || '0.00',
|
||||||
|
is_active: backendMember.is_active === true,
|
||||||
|
created_at: backendMember.created_at || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const toWechatLogin = (backendData = {}) => ({
|
||||||
|
access_token: backendData.access_token || '',
|
||||||
|
token_type: backendData.token_type || 'Bearer',
|
||||||
|
expires_in: toNumber(backendData.expires_in),
|
||||||
|
member: toMember(backendData.member)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
async wechatLogin(payload = {}) {
|
||||||
|
const data = typeof payload === 'string' ? { code: payload } : payload
|
||||||
|
const loginData = await request({
|
||||||
|
url: '/api/auth/wechat-login',
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
code: data.code,
|
||||||
|
nickname: data.nickname || '',
|
||||||
|
avatar_url: data.avatar_url || ''
|
||||||
|
}
|
||||||
|
}).then(toWechatLogin)
|
||||||
|
|
||||||
|
authStorage.setToken(loginData.access_token)
|
||||||
|
return loginData
|
||||||
|
},
|
||||||
|
logout() {
|
||||||
|
authStorage.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { authStorage }
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { request } from './request'
|
||||||
|
import { toArray, toNumber, toPageQuery, toPagination } from './mappers'
|
||||||
|
import { toOrderBrief } from './order'
|
||||||
|
|
||||||
|
const toBookingCourse = (backendCourse = {}) => ({
|
||||||
|
id: backendCourse.id ?? null,
|
||||||
|
title: backendCourse.title || '',
|
||||||
|
cover_image: backendCourse.cover_image || '',
|
||||||
|
coach_name: backendCourse.coach_name || '',
|
||||||
|
start_at: backendCourse.start_at || '',
|
||||||
|
end_at: backendCourse.end_at || '',
|
||||||
|
capacity: toNumber(backendCourse.capacity),
|
||||||
|
booked_count: toNumber(backendCourse.booked_count),
|
||||||
|
remaining_count: toNumber(backendCourse.remaining_count),
|
||||||
|
price: backendCourse.price || '0.00',
|
||||||
|
courts: toArray(backendCourse.courts)
|
||||||
|
})
|
||||||
|
|
||||||
|
const toPrivateCourt = (backendCourt = {}) => ({
|
||||||
|
id: backendCourt.id ?? null,
|
||||||
|
name: backendCourt.name || '',
|
||||||
|
court_type: backendCourt.court_type || '',
|
||||||
|
court_type_label: backendCourt.court_type_label || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const toCourseBooking = (backendBooking = {}) => ({
|
||||||
|
id: backendBooking.id ?? null,
|
||||||
|
course_id: backendBooking.course_id ?? null,
|
||||||
|
order_id: backendBooking.order_id ?? null,
|
||||||
|
status: backendBooking.status || '',
|
||||||
|
status_label: backendBooking.status_label || backendBooking.status || '',
|
||||||
|
expire_at: backendBooking.expire_at || null,
|
||||||
|
checked_in_at: backendBooking.checked_in_at || null,
|
||||||
|
cancelled_at: backendBooking.cancelled_at || null,
|
||||||
|
cancel_by: backendBooking.cancel_by || '',
|
||||||
|
cancel_reason: backendBooking.cancel_reason || '',
|
||||||
|
can_cancel: backendBooking.can_cancel === true,
|
||||||
|
cancel_deadline_at: backendBooking.cancel_deadline_at || null,
|
||||||
|
created_at: backendBooking.created_at || '',
|
||||||
|
course: toBookingCourse(backendBooking.course),
|
||||||
|
order: toOrderBrief(backendBooking.order)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const toPrivateBooking = (backendBooking = {}) => ({
|
||||||
|
id: backendBooking.id ?? null,
|
||||||
|
order_id: backendBooking.order_id ?? null,
|
||||||
|
status: backendBooking.status || '',
|
||||||
|
status_label: backendBooking.status_label || backendBooking.status || '',
|
||||||
|
expire_at: backendBooking.expire_at || null,
|
||||||
|
start_at: backendBooking.start_at || null,
|
||||||
|
end_at: backendBooking.end_at || null,
|
||||||
|
courts: toArray(backendBooking.courts).map(toPrivateCourt),
|
||||||
|
checked_in_at: backendBooking.checked_in_at || null,
|
||||||
|
cancelled_at: backendBooking.cancelled_at || null,
|
||||||
|
cancel_by: backendBooking.cancel_by || '',
|
||||||
|
cancel_reason: backendBooking.cancel_reason || '',
|
||||||
|
created_at: backendBooking.created_at || '',
|
||||||
|
order: toOrderBrief(backendBooking.order)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const bookingApi = {
|
||||||
|
getCourseBookings(params = {}) {
|
||||||
|
return request({
|
||||||
|
url: '/api/bookings/courses',
|
||||||
|
data: toPageQuery(params)
|
||||||
|
}).then((data) => toPagination(data, toCourseBooking))
|
||||||
|
},
|
||||||
|
getCourseBookingDetail(id) {
|
||||||
|
return request({
|
||||||
|
url: `/api/bookings/courses/${id}`
|
||||||
|
}).then(toCourseBooking)
|
||||||
|
},
|
||||||
|
cancelCourseBooking(id) {
|
||||||
|
return request({
|
||||||
|
url: `/api/bookings/courses/${id}/cancel`,
|
||||||
|
method: 'POST'
|
||||||
|
}).then(toCourseBooking)
|
||||||
|
},
|
||||||
|
getPrivateBookings(params = {}) {
|
||||||
|
return request({
|
||||||
|
url: '/api/bookings/private',
|
||||||
|
data: toPageQuery(params)
|
||||||
|
}).then((data) => toPagination(data, toPrivateBooking))
|
||||||
|
},
|
||||||
|
getPrivateBookingDetail(id) {
|
||||||
|
return request({
|
||||||
|
url: `/api/bookings/private/${id}`
|
||||||
|
}).then(toPrivateBooking)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { request } from './request'
|
||||||
|
import { compactParams, toArray, toNumber, toPageQuery, toPagination } from './mappers'
|
||||||
|
import { toCourse } from './course'
|
||||||
|
|
||||||
|
const toBanner = (backendBanner = {}) => ({
|
||||||
|
id: backendBanner.id ?? null,
|
||||||
|
title: backendBanner.title || '',
|
||||||
|
image: backendBanner.image || '',
|
||||||
|
sort_order: toNumber(backendBanner.sort_order)
|
||||||
|
})
|
||||||
|
|
||||||
|
const toArticle = (backendArticle = {}) => ({
|
||||||
|
id: backendArticle.id ?? null,
|
||||||
|
title: backendArticle.title || '',
|
||||||
|
cover_image: backendArticle.cover_image || '',
|
||||||
|
published_at: backendArticle.published_at || null,
|
||||||
|
created_at: backendArticle.created_at || '',
|
||||||
|
content_html: backendArticle.content_html || '',
|
||||||
|
updated_at: backendArticle.updated_at || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const toHomeContent = (backendData = {}) => ({
|
||||||
|
banners: toArray(backendData.banners).map(toBanner),
|
||||||
|
articles: toArray(backendData.articles).map(toArticle),
|
||||||
|
upcoming_courses: toArray(backendData.upcoming_courses).map(toCourse),
|
||||||
|
venue: {
|
||||||
|
name: backendData.venue?.name || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const contentApi = {
|
||||||
|
getHomeContent(params = {}) {
|
||||||
|
return request({
|
||||||
|
url: '/api/contents/home',
|
||||||
|
data: compactParams({
|
||||||
|
banner_limit: params.banner_limit || params.bannerLimit,
|
||||||
|
article_limit: params.article_limit || params.articleLimit
|
||||||
|
})
|
||||||
|
}).then(toHomeContent)
|
||||||
|
},
|
||||||
|
getArticles(params = {}) {
|
||||||
|
return request({
|
||||||
|
url: '/api/contents/articles',
|
||||||
|
data: toPageQuery(params)
|
||||||
|
}).then((data) => toPagination(data, toArticle))
|
||||||
|
},
|
||||||
|
getArticleDetail(id) {
|
||||||
|
return request({
|
||||||
|
url: `/api/contents/articles/${id}`
|
||||||
|
}).then(toArticle)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { request } from './request'
|
||||||
|
import { compactParams, toArray, toNumber, toPageQuery, toPagination } from './mappers'
|
||||||
|
|
||||||
|
const toCourseCourt = (backendCourt = {}) => ({
|
||||||
|
id: backendCourt.id ?? null,
|
||||||
|
name: backendCourt.name || '',
|
||||||
|
court_type: backendCourt.court_type || '',
|
||||||
|
court_type_label: backendCourt.court_type_label || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
export const toCourse = (backendCourse = {}) => ({
|
||||||
|
id: backendCourse.id ?? null,
|
||||||
|
title: backendCourse.title || '',
|
||||||
|
cover_image: backendCourse.cover_image || '',
|
||||||
|
coach_id: backendCourse.coach_id ?? null,
|
||||||
|
coach_name: backendCourse.coach_name || '',
|
||||||
|
start_at: backendCourse.start_at || '',
|
||||||
|
end_at: backendCourse.end_at || '',
|
||||||
|
capacity: toNumber(backendCourse.capacity),
|
||||||
|
booked_count: toNumber(backendCourse.booked_count),
|
||||||
|
remaining_count: toNumber(backendCourse.remaining_count),
|
||||||
|
price: backendCourse.price || '0.00',
|
||||||
|
status: backendCourse.status || '',
|
||||||
|
status_label: backendCourse.status_label || backendCourse.status || '',
|
||||||
|
courts: toArray(backendCourse.courts).map(toCourseCourt),
|
||||||
|
detail_html: backendCourse.detail_html || '',
|
||||||
|
created_at: backendCourse.created_at || '',
|
||||||
|
updated_at: backendCourse.updated_at || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const toCourseQuery = (params = {}) => compactParams({
|
||||||
|
...toPageQuery(params),
|
||||||
|
date: params.date,
|
||||||
|
coach_id: params.coachId || params.coach_id,
|
||||||
|
court_type: params.courtType || params.court_type
|
||||||
|
})
|
||||||
|
|
||||||
|
export const courseApi = {
|
||||||
|
getCourses(params = {}) {
|
||||||
|
return request({
|
||||||
|
url: '/api/courses',
|
||||||
|
data: toCourseQuery(params)
|
||||||
|
}).then((data) => toPagination(data, toCourse))
|
||||||
|
},
|
||||||
|
getCourseDetail(id) {
|
||||||
|
return request({
|
||||||
|
url: `/api/courses/${id}`
|
||||||
|
}).then(toCourse)
|
||||||
|
},
|
||||||
|
createCourseBooking(courseId) {
|
||||||
|
return request({
|
||||||
|
url: '/api/bookings/course',
|
||||||
|
method: 'POST',
|
||||||
|
data: { course_id: courseId }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export const toNumber = (value, defaultValue = 0) => {
|
||||||
|
const numberValue = Number(value)
|
||||||
|
return Number.isFinite(numberValue) ? numberValue : defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toArray = (value) => (Array.isArray(value) ? value : [])
|
||||||
|
|
||||||
|
export const compactParams = (params = {}) => {
|
||||||
|
return Object.entries(params).reduce((result, [key, value]) => {
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
result[key] = value
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toPageQuery = (params = {}) => compactParams({
|
||||||
|
page: params.page || 1,
|
||||||
|
page_size: params.pageSize || params.page_size || 20
|
||||||
|
})
|
||||||
|
|
||||||
|
export const toPagination = (backendData = {}, itemMapper = (item) => item) => ({
|
||||||
|
items: toArray(backendData.items).map(itemMapper),
|
||||||
|
total: toNumber(backendData.total),
|
||||||
|
page: toNumber(backendData.page, 1),
|
||||||
|
page_size: toNumber(backendData.page_size, 20)
|
||||||
|
})
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { request, uploadFile } from './request'
|
||||||
|
import { toNumber, toPageQuery, toPagination } from './mappers'
|
||||||
|
|
||||||
|
const toMember = (backendMember = {}) => ({
|
||||||
|
id: backendMember.id ?? null,
|
||||||
|
openid: backendMember.openid || '',
|
||||||
|
unionid: backendMember.unionid || '',
|
||||||
|
nickname: backendMember.nickname || '',
|
||||||
|
phone: backendMember.phone || '',
|
||||||
|
avatar_url: backendMember.avatar_url || '',
|
||||||
|
balance: backendMember.balance || '0.00',
|
||||||
|
is_active: backendMember.is_active === true,
|
||||||
|
created_at: backendMember.created_at || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const toBalance = (backendData = {}) => ({
|
||||||
|
member_id: backendData.member_id ?? null,
|
||||||
|
balance: backendData.balance || '0.00'
|
||||||
|
})
|
||||||
|
|
||||||
|
const toBalanceRecord = (backendRecord = {}) => ({
|
||||||
|
id: backendRecord.id ?? null,
|
||||||
|
amount: backendRecord.amount || '0.00',
|
||||||
|
kind: backendRecord.kind || '',
|
||||||
|
kind_label: backendRecord.kind_label || backendRecord.kind || '',
|
||||||
|
order_id: backendRecord.order_id ?? null,
|
||||||
|
remark: backendRecord.remark || '',
|
||||||
|
created_at: backendRecord.created_at || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
export const memberApi = {
|
||||||
|
getMemberMe() {
|
||||||
|
return request({
|
||||||
|
url: '/api/members/me'
|
||||||
|
}).then(toMember)
|
||||||
|
},
|
||||||
|
updateMemberProfile(payload = {}) {
|
||||||
|
return request({
|
||||||
|
url: '/api/members/profile',
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
nickname: payload.nickname || null,
|
||||||
|
avatar_url: payload.avatar_url || null
|
||||||
|
}
|
||||||
|
}).then(toMember)
|
||||||
|
},
|
||||||
|
uploadAvatar(filePath) {
|
||||||
|
return uploadFile({
|
||||||
|
url: '/api/members/avatar',
|
||||||
|
filePath
|
||||||
|
}).then((data) => ({
|
||||||
|
avatar_url: data.avatar_url || '',
|
||||||
|
member: toMember(data.member)
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
bindMemberPhone(code) {
|
||||||
|
return request({
|
||||||
|
url: '/api/members/phone/bind',
|
||||||
|
method: 'POST',
|
||||||
|
data: { code }
|
||||||
|
}).then(toMember)
|
||||||
|
},
|
||||||
|
getBalance() {
|
||||||
|
return request({
|
||||||
|
url: '/api/members/balance'
|
||||||
|
}).then(toBalance)
|
||||||
|
},
|
||||||
|
getBalanceRecords(params = {}) {
|
||||||
|
return request({
|
||||||
|
url: '/api/members/balance-records',
|
||||||
|
data: toPageQuery(params)
|
||||||
|
}).then((data) => {
|
||||||
|
const pagination = toPagination(data, toBalanceRecord)
|
||||||
|
return {
|
||||||
|
...pagination,
|
||||||
|
total: toNumber(pagination.total)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { request } from './request'
|
||||||
|
import { compactParams, toArray, toNumber, toPageQuery, toPagination } from './mappers'
|
||||||
|
|
||||||
|
export const toOrderBrief = (backendOrder = {}) => ({
|
||||||
|
id: backendOrder.id ?? null,
|
||||||
|
order_no: backendOrder.order_no || '',
|
||||||
|
business_type: backendOrder.business_type || '',
|
||||||
|
business_type_label: backendOrder.business_type_label || backendOrder.business_type || '',
|
||||||
|
amount: backendOrder.amount || '0.00',
|
||||||
|
status: backendOrder.status || '',
|
||||||
|
status_label: backendOrder.status_label || backendOrder.status || '',
|
||||||
|
expire_at: backendOrder.expire_at || null,
|
||||||
|
created_at: backendOrder.created_at || '',
|
||||||
|
updated_at: backendOrder.updated_at || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const toPayment = (backendPayment = {}) => ({
|
||||||
|
id: backendPayment.id ?? null,
|
||||||
|
order_id: backendPayment.order_id ?? null,
|
||||||
|
method: backendPayment.method || '',
|
||||||
|
method_label: backendPayment.method_label || backendPayment.method || '',
|
||||||
|
amount: backendPayment.amount || '0.00',
|
||||||
|
status: backendPayment.status || '',
|
||||||
|
status_label: backendPayment.status_label || backendPayment.status || '',
|
||||||
|
external_trade_no: backendPayment.external_trade_no || '',
|
||||||
|
paid_at: backendPayment.paid_at || null,
|
||||||
|
created_at: backendPayment.created_at || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const toRefund = (backendRefund = {}) => ({
|
||||||
|
id: backendRefund.id ?? null,
|
||||||
|
payment_id: backendRefund.payment_id ?? null,
|
||||||
|
refund_no: backendRefund.refund_no || '',
|
||||||
|
amount: backendRefund.amount || '0.00',
|
||||||
|
status: backendRefund.status || '',
|
||||||
|
status_label: backendRefund.status_label || backendRefund.status || '',
|
||||||
|
external_refund_no: backendRefund.external_refund_no || '',
|
||||||
|
reason: backendRefund.reason || '',
|
||||||
|
refunded_at: backendRefund.refunded_at || null,
|
||||||
|
created_at: backendRefund.created_at || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const toOrderDetail = (backendOrder = {}) => ({
|
||||||
|
...toOrderBrief(backendOrder),
|
||||||
|
payments: toArray(backendOrder.payments).map(toPayment),
|
||||||
|
refunds: toArray(backendOrder.refunds).map(toRefund),
|
||||||
|
updated_at: backendOrder.updated_at || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const toWechatPayment = (backendData = {}) => ({
|
||||||
|
payment: toPayment(backendData.payment),
|
||||||
|
wechat: {
|
||||||
|
timeStamp: backendData.wechat?.timeStamp || '',
|
||||||
|
nonceStr: backendData.wechat?.nonceStr || '',
|
||||||
|
package: backendData.wechat?.package || '',
|
||||||
|
signType: backendData.wechat?.signType || 'RSA',
|
||||||
|
paySign: backendData.wechat?.paySign || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const toOrderQuery = (params = {}) => compactParams({
|
||||||
|
...toPageQuery(params),
|
||||||
|
business_type: params.businessType || params.business_type,
|
||||||
|
status: params.status
|
||||||
|
})
|
||||||
|
|
||||||
|
export const orderApi = {
|
||||||
|
getOrders(params = {}) {
|
||||||
|
return request({
|
||||||
|
url: '/api/orders',
|
||||||
|
data: toOrderQuery(params)
|
||||||
|
}).then((data) => toPagination(data, toOrderBrief))
|
||||||
|
},
|
||||||
|
getOrderDetail(id) {
|
||||||
|
return request({
|
||||||
|
url: `/api/orders/${id}`
|
||||||
|
}).then(toOrderDetail)
|
||||||
|
},
|
||||||
|
closeOrder(id) {
|
||||||
|
return request({
|
||||||
|
url: `/api/orders/${id}/close`,
|
||||||
|
method: 'POST'
|
||||||
|
}).then(toOrderDetail)
|
||||||
|
},
|
||||||
|
payOrderByBalance(id) {
|
||||||
|
return request({
|
||||||
|
url: `/api/orders/${id}/pay/balance`,
|
||||||
|
method: 'POST'
|
||||||
|
}).then((payment) => ({
|
||||||
|
...payment,
|
||||||
|
amount: payment?.amount || '0.00',
|
||||||
|
id: payment?.id ?? null,
|
||||||
|
order_id: payment?.order_id ?? toNumber(id)
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
payOrderByWechat(id) {
|
||||||
|
return request({
|
||||||
|
url: `/api/orders/${id}/pay/wechat`,
|
||||||
|
method: 'POST'
|
||||||
|
}).then(toWechatPayment)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { request } from './request'
|
||||||
|
import { compactParams, toArray, toNumber } from './mappers'
|
||||||
|
import { toPrivateBooking } from './booking'
|
||||||
|
import { toOrderBrief } from './order'
|
||||||
|
|
||||||
|
const toQuoteCourt = (backendCourt = {}) => ({
|
||||||
|
id: backendCourt.id ?? null,
|
||||||
|
name: backendCourt.name || '',
|
||||||
|
court_type: backendCourt.court_type || '',
|
||||||
|
court_type_label: backendCourt.court_type_label || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const toPrivateQuote = (backendQuote = {}) => ({
|
||||||
|
amount: backendQuote.amount || '0.00',
|
||||||
|
unit_count: toNumber(backendQuote.unit_count),
|
||||||
|
start_at: backendQuote.start_at || '',
|
||||||
|
end_at: backendQuote.end_at || '',
|
||||||
|
courts: toArray(backendQuote.courts).map(toQuoteCourt)
|
||||||
|
})
|
||||||
|
|
||||||
|
const toPrivateCreateResult = (backendData = {}) => ({
|
||||||
|
booking: toPrivateBooking(backendData.booking),
|
||||||
|
order: toOrderBrief(backendData.order)
|
||||||
|
})
|
||||||
|
|
||||||
|
const toPrivateTimeOption = (backendOption = {}) => ({
|
||||||
|
start_at: backendOption.start_at || '',
|
||||||
|
end_at: backendOption.end_at || '',
|
||||||
|
available_court_count: toNumber(backendOption.available_court_count),
|
||||||
|
available_courts: toArray(backendOption.available_courts).map(toQuoteCourt)
|
||||||
|
})
|
||||||
|
|
||||||
|
const toPrivateBookingOptions = (backendData = {}) => ({
|
||||||
|
date: backendData.date || '',
|
||||||
|
court_type: backendData.court_type || '',
|
||||||
|
court_type_label: backendData.court_type_label || '',
|
||||||
|
open_time: backendData.open_time || '',
|
||||||
|
close_time: backendData.close_time || '',
|
||||||
|
unit_price: backendData.unit_price || '0.00',
|
||||||
|
duration_units: toNumber(backendData.duration_units, 2),
|
||||||
|
duration_minutes: toNumber(backendData.duration_minutes, 60),
|
||||||
|
amount_per_court: backendData.amount_per_court || '0.00',
|
||||||
|
total_court_count: toNumber(backendData.total_court_count),
|
||||||
|
time_options: toArray(backendData.time_options).map(toPrivateTimeOption)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const privateBookingApi = {
|
||||||
|
getPrivateBookingOptions(params = {}) {
|
||||||
|
return request({
|
||||||
|
url: '/api/bookings/private/options',
|
||||||
|
data: compactParams({
|
||||||
|
date: params.date,
|
||||||
|
court_type: params.courtType || params.court_type,
|
||||||
|
duration_units: params.durationUnits || params.duration_units
|
||||||
|
})
|
||||||
|
}).then(toPrivateBookingOptions)
|
||||||
|
},
|
||||||
|
quotePrivateBooking(payload = {}) {
|
||||||
|
return request({
|
||||||
|
url: '/api/bookings/private/quote',
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
court_ids: toArray(payload.court_ids),
|
||||||
|
start_at: payload.start_at,
|
||||||
|
end_at: payload.end_at
|
||||||
|
}
|
||||||
|
}).then(toPrivateQuote)
|
||||||
|
},
|
||||||
|
createPrivateBooking(payload = {}) {
|
||||||
|
return request({
|
||||||
|
url: '/api/bookings/private',
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
court_ids: toArray(payload.court_ids),
|
||||||
|
start_at: payload.start_at,
|
||||||
|
end_at: payload.end_at
|
||||||
|
}
|
||||||
|
}).then(toPrivateCreateResult)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
const KEY_TOKEN = 'COMPREHENSIVE_VENUE_TOKEN'
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_COMPREHENSIVE_API_BASE_URL || 'http://127.0.0.1:8001'
|
||||||
|
|
||||||
|
const normalizeError = (res, fallbackMessage = '请求服务失败') => {
|
||||||
|
const body = res?.data || res || {}
|
||||||
|
return {
|
||||||
|
code: body.code || res?.statusCode || 50000,
|
||||||
|
message: body.message || fallbackMessage,
|
||||||
|
errors: body.errors || null,
|
||||||
|
statusCode: res?.statusCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authStorage = {
|
||||||
|
setToken(token) {
|
||||||
|
uni.setStorageSync(KEY_TOKEN, token)
|
||||||
|
},
|
||||||
|
getToken() {
|
||||||
|
return uni.getStorageSync(KEY_TOKEN)
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
uni.removeStorageSync(KEY_TOKEN)
|
||||||
|
},
|
||||||
|
isLoggedIn() {
|
||||||
|
return !!uni.getStorageSync(KEY_TOKEN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 === 401 || body?.code === 40001) {
|
||||||
|
authStorage.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300 && body?.code === 0) {
|
||||||
|
resolve(body.data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = normalizeError(res, `请求错误 ${res.statusCode}`)
|
||||||
|
uni.showToast({
|
||||||
|
title: error.message,
|
||||||
|
icon: 'none'
|
||||||
|
})
|
||||||
|
reject(error)
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
const error = normalizeError(err, '网络连接失败,请检查服务')
|
||||||
|
uni.showToast({
|
||||||
|
title: error.message,
|
||||||
|
icon: 'none'
|
||||||
|
})
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadFile(options = {}) {
|
||||||
|
const token = authStorage.getToken()
|
||||||
|
const header = {
|
||||||
|
...(options.header || {})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
header.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
uni.uploadFile({
|
||||||
|
url: options.url.startsWith('http') ? options.url : `${API_BASE_URL}${options.url}`,
|
||||||
|
filePath: options.filePath,
|
||||||
|
name: options.name || 'file',
|
||||||
|
formData: options.formData || {},
|
||||||
|
header,
|
||||||
|
success: (res) => {
|
||||||
|
let body = {}
|
||||||
|
try {
|
||||||
|
body = JSON.parse(res.data || '{}')
|
||||||
|
} catch (error) {
|
||||||
|
body = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.statusCode === 401 || body?.code === 40001) {
|
||||||
|
authStorage.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300 && body?.code === 0) {
|
||||||
|
resolve(body.data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadError = normalizeError({ ...res, data: body }, `上传错误 ${res.statusCode}`)
|
||||||
|
uni.showToast({
|
||||||
|
title: uploadError.message,
|
||||||
|
icon: 'none'
|
||||||
|
})
|
||||||
|
reject(uploadError)
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
const uploadError = normalizeError(err, '文件上传失败,请检查服务')
|
||||||
|
uni.showToast({
|
||||||
|
title: uploadError.message,
|
||||||
|
icon: 'none'
|
||||||
|
})
|
||||||
|
reject(uploadError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { request } from './request'
|
||||||
|
import { toArray, toNumber, toPageQuery } from './mappers'
|
||||||
|
import { toOrderBrief } from './order'
|
||||||
|
|
||||||
|
const toTicketProduct = (backendProduct = {}) => ({
|
||||||
|
id: backendProduct.id ?? null,
|
||||||
|
unit_price: backendProduct.unit_price || '0.00',
|
||||||
|
is_active: backendProduct.is_active === true,
|
||||||
|
created_at: backendProduct.created_at || '',
|
||||||
|
updated_at: backendProduct.updated_at || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const toTicketRecord = (backendRecord = {}) => ({
|
||||||
|
id: backendRecord.id ?? null,
|
||||||
|
order_id: backendRecord.order_id ?? null,
|
||||||
|
order_no: backendRecord.order_no || '',
|
||||||
|
order_status: backendRecord.order_status || '',
|
||||||
|
status: backendRecord.status || '',
|
||||||
|
status_label: backendRecord.status_label || backendRecord.status || '',
|
||||||
|
checked_in_at: backendRecord.checked_in_at || null,
|
||||||
|
cancelled_at: backendRecord.cancelled_at || null,
|
||||||
|
cancel_reason: backendRecord.cancel_reason || '',
|
||||||
|
created_at: backendRecord.created_at || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const toTicketAccount = (backendAccount = {}) => ({
|
||||||
|
remaining_count: toNumber(backendAccount.remaining_count),
|
||||||
|
records: toArray(backendAccount.records).map(toTicketRecord),
|
||||||
|
total: toNumber(backendAccount.total),
|
||||||
|
page: toNumber(backendAccount.page, 1),
|
||||||
|
page_size: toNumber(backendAccount.page_size, 20)
|
||||||
|
})
|
||||||
|
|
||||||
|
const toTicketOrder = (backendData = {}) => ({
|
||||||
|
order: toOrderBrief(backendData.order),
|
||||||
|
product: toTicketProduct(backendData.product),
|
||||||
|
quantity: toNumber(backendData.quantity)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ticketApi = {
|
||||||
|
getTicketProduct() {
|
||||||
|
return request({
|
||||||
|
url: '/api/tickets/product'
|
||||||
|
}).then(toTicketProduct)
|
||||||
|
},
|
||||||
|
getTicketAccount(params = {}) {
|
||||||
|
return request({
|
||||||
|
url: '/api/tickets/account',
|
||||||
|
data: toPageQuery(params)
|
||||||
|
}).then(toTicketAccount)
|
||||||
|
},
|
||||||
|
createTicketOrder(quantity) {
|
||||||
|
return request({
|
||||||
|
url: '/api/tickets/orders',
|
||||||
|
method: 'POST',
|
||||||
|
data: { quantity }
|
||||||
|
}).then(toTicketOrder)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { request } from './request'
|
||||||
|
import { toArray } from './mappers'
|
||||||
|
|
||||||
|
const toCoach = (backendCoach = {}) => ({
|
||||||
|
id: backendCoach.id ?? null,
|
||||||
|
name: backendCoach.name || '',
|
||||||
|
phone: backendCoach.phone || '',
|
||||||
|
is_active: backendCoach.is_active === true
|
||||||
|
})
|
||||||
|
|
||||||
|
export const venueApi = {
|
||||||
|
getCoaches() {
|
||||||
|
return request({
|
||||||
|
url: '/api/venues/coaches'
|
||||||
|
}).then((data = {}) => ({
|
||||||
|
items: toArray(data.items).map(toCoach)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { authApi, authStorage } from '@/modules/comprehensive/api/auth'
|
||||||
|
import { memberApi } from '@/modules/comprehensive/api/member'
|
||||||
|
|
||||||
|
export const useComprehensiveSessionStore = defineStore('comprehensiveVenueSession', {
|
||||||
|
state: () => ({
|
||||||
|
isReady: false,
|
||||||
|
isLoggedIn: false,
|
||||||
|
member: null
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
async init() {
|
||||||
|
this.isLoggedIn = authStorage.isLoggedIn()
|
||||||
|
if (this.isLoggedIn) {
|
||||||
|
try {
|
||||||
|
this.member = await memberApi.getMemberMe()
|
||||||
|
} catch (error) {
|
||||||
|
if ([40001, 40002, 401, 403].includes(error.code) || [401, 403].includes(error.statusCode)) {
|
||||||
|
authStorage.clear()
|
||||||
|
this.member = null
|
||||||
|
this.isLoggedIn = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.isReady = true
|
||||||
|
},
|
||||||
|
async login() {
|
||||||
|
const loginResult = await loginByWechat()
|
||||||
|
const data = await authApi.wechatLogin({ code: loginResult.code })
|
||||||
|
this.member = data.member
|
||||||
|
this.isLoggedIn = true
|
||||||
|
},
|
||||||
|
logout() {
|
||||||
|
authApi.logout()
|
||||||
|
this.member = null
|
||||||
|
this.isLoggedIn = false
|
||||||
|
},
|
||||||
|
async refreshMember() {
|
||||||
|
if (!authStorage.isLoggedIn()) return
|
||||||
|
this.member = await memberApi.getMemberMe()
|
||||||
|
this.isLoggedIn = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const loginByWechat = () => new Promise((resolve, reject) => {
|
||||||
|
uni.login({
|
||||||
|
provider: 'weixin',
|
||||||
|
success: (res) => {
|
||||||
|
if (!res.code) {
|
||||||
|
reject(new Error('微信登录未返回 code'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve(res)
|
||||||
|
},
|
||||||
|
fail: reject
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
export const formatDate = (value, fallback = '-') => {
|
||||||
|
if (!value) return fallback
|
||||||
|
return dayjs(value).format('YYYY-MM-DD')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatDateTime = (value, fallback = '-') => {
|
||||||
|
if (!value) return fallback
|
||||||
|
return dayjs(value).format('MM-DD HH:mm')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatTime = (value, fallback = '-') => {
|
||||||
|
if (!value) return fallback
|
||||||
|
return dayjs(value).format('HH:mm')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatTimeRange = (startAt, endAt) => {
|
||||||
|
if (!startAt || !endAt) return '-'
|
||||||
|
const start = dayjs(startAt)
|
||||||
|
const end = dayjs(endAt)
|
||||||
|
if (start.format('YYYY-MM-DD') === end.format('YYYY-MM-DD')) {
|
||||||
|
return `${start.format('MM-DD HH:mm')} - ${end.format('HH:mm')}`
|
||||||
|
}
|
||||||
|
return `${start.format('MM-DD HH:mm')} - ${end.format('MM-DD HH:mm')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatDuration = (startAt, endAt) => {
|
||||||
|
if (!startAt || !endAt) return '-'
|
||||||
|
const minutes = dayjs(endAt).diff(dayjs(startAt), 'minute')
|
||||||
|
if (minutes <= 0) return '-'
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
const rest = minutes % 60
|
||||||
|
if (hours && rest) return `${hours}小时${rest}分钟`
|
||||||
|
if (hours) return `${hours}小时`
|
||||||
|
return `${rest}分钟`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const todayString = () => dayjs().format('YYYY-MM-DD')
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export const toAmountNumber = (value) => {
|
||||||
|
const amount = Number(value)
|
||||||
|
return Number.isFinite(amount) ? amount : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatAmount = (value) => toAmountNumber(value).toFixed(2)
|
||||||
|
|
||||||
|
export const multiplyAmount = (unitPrice, quantity) => {
|
||||||
|
return formatAmount(toAmountNumber(unitPrice) * Number(quantity || 0))
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export const statusBadgeClass = (status) => {
|
||||||
|
if (['paid', 'booked', 'available', 'success', 'published'].includes(status)) return 'success'
|
||||||
|
if (['pending_pay', 'pending'].includes(status)) return 'warning'
|
||||||
|
if (['cancelled', 'closed', 'expired', 'refunded'].includes(status)) return 'danger'
|
||||||
|
return 'muted'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const courtTypeLabel = (type) => {
|
||||||
|
const map = {
|
||||||
|
badminton: '羽毛球',
|
||||||
|
basketball: '篮球'
|
||||||
|
}
|
||||||
|
return map[type] || type
|
||||||
|
}
|
||||||
@@ -15,6 +15,127 @@
|
|||||||
"navigationBarBackgroundColor": "#FFFFFF",
|
"navigationBarBackgroundColor": "#FFFFFF",
|
||||||
"navigationBarTextStyle": "black"
|
"navigationBarTextStyle": "black"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/comprehensive/home/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "首页",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/comprehensive/courses/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "课程",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/comprehensive/bookings/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "我的预约",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/comprehensive/profile/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "会员中心"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/comprehensive/activities/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "活动公告",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/comprehensive/activities/detail",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "活动详情"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/comprehensive/courses/detail",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "课程详情"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/comprehensive/courses/confirm",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "确认课程预约"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/comprehensive/private/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "包场预约"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/comprehensive/private/confirm",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "确认包场"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/comprehensive/tickets/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "篮球门票"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/comprehensive/pay/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "订单支付"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/comprehensive/orders/detail",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "订单详情"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/comprehensive/bookings/course-detail",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "课程预约详情"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/comprehensive/bookings/private-detail",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "包场预约详情"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/comprehensive/profile/balance",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "余额流水",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/comprehensive/profile/tickets",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "我的门票",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/comprehensive/profile/orders",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "我的订单",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/comprehensive/profile/detail",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "个人信息"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"globalStyle": {
|
"globalStyle": {
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<template>
|
||||||
|
<view class="article-detail container" v-if="article">
|
||||||
|
<text class="title">{{ article.title }}</text>
|
||||||
|
<text class="date">{{ formatDate(article.published_at || article.created_at) }}</text>
|
||||||
|
<image v-if="article.cover_image" :src="article.cover_image" mode="aspectFill" class="cover" />
|
||||||
|
<view class="content-card card">
|
||||||
|
<rich-text :nodes="article.content_html"></rich-text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import { contentApi } from '@/modules/comprehensive/api/content'
|
||||||
|
import { formatDate } from '@/modules/comprehensive/utils/date'
|
||||||
|
|
||||||
|
const article = ref(null)
|
||||||
|
|
||||||
|
onLoad(async (query) => {
|
||||||
|
article.value = await contentApi.getArticleDetail(query.id)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.title,
|
||||||
|
.date {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 42rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
margin-top: $spacing-sm;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 360rpx;
|
||||||
|
margin: $spacing-lg 0;
|
||||||
|
border-radius: $border-radius-lg;
|
||||||
|
background: $bg-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-card {
|
||||||
|
color: $text-color-regular;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<template>
|
||||||
|
<view class="activity-page container">
|
||||||
|
<view class="page-title-block">
|
||||||
|
<text class="page-title">活动公告</text>
|
||||||
|
<text class="page-subtitle">场馆运营通知、课程活动与权益说明</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="article-list">
|
||||||
|
<view v-for="article in articles" :key="article.id" class="article-card card" @tap="goDetail(article.id)">
|
||||||
|
<image :src="article.cover_image" mode="aspectFill" class="cover" />
|
||||||
|
<view class="article-info">
|
||||||
|
<text class="title">{{ article.title }}</text>
|
||||||
|
<text class="date">{{ formatDate(article.published_at || article.created_at) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="isLoading" class="load-more-tip">加载中...</view>
|
||||||
|
<view v-else-if="articles.length === 0" class="empty-state">暂无活动公告</view>
|
||||||
|
<view v-else-if="!hasMore" class="load-more-tip">已加载全部</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||||
|
import { contentApi } from '@/modules/comprehensive/api/content'
|
||||||
|
import { formatDate } from '@/modules/comprehensive/utils/date'
|
||||||
|
|
||||||
|
const articles = ref([])
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = 10
|
||||||
|
const total = ref(0)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const isLoadingMore = ref(false)
|
||||||
|
const hasMore = computed(() => articles.value.length < total.value)
|
||||||
|
|
||||||
|
onMounted(() => loadArticles(true))
|
||||||
|
|
||||||
|
onPullDownRefresh(() => {
|
||||||
|
loadArticles(true).finally(() => uni.stopPullDownRefresh())
|
||||||
|
})
|
||||||
|
|
||||||
|
onReachBottom(() => {
|
||||||
|
if (!hasMore.value || isLoadingMore.value) return
|
||||||
|
page.value += 1
|
||||||
|
loadArticles(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadArticles = async (reset) => {
|
||||||
|
if (reset) {
|
||||||
|
page.value = 1
|
||||||
|
isLoading.value = true
|
||||||
|
} else {
|
||||||
|
isLoadingMore.value = true
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await contentApi.getArticles({ page: page.value, pageSize })
|
||||||
|
articles.value = reset ? data.items : [...articles.value, ...data.items]
|
||||||
|
total.value = data.total
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
isLoadingMore.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const goDetail = (id) => {
|
||||||
|
uni.navigateTo({ url: `/pages/comprehensive/activities/detail?id=${id}` })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page-title-block {
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title,
|
||||||
|
.page-subtitle,
|
||||||
|
.title,
|
||||||
|
.date {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 42rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
margin-top: $spacing-xs;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-card {
|
||||||
|
display: flex;
|
||||||
|
gap: $spacing-md;
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
width: 190rpx;
|
||||||
|
height: 136rpx;
|
||||||
|
border-radius: $border-radius-base;
|
||||||
|
background: $bg-color-hover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
margin-top: $spacing-md;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
<template>
|
||||||
|
<view class="booking-detail container page-bottom-safe" v-if="booking">
|
||||||
|
<view class="status-card card">
|
||||||
|
<text class="status-title">{{ booking.status_label }}</text>
|
||||||
|
<text class="status-desc">{{ statusDesc }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card course-card">
|
||||||
|
<image :src="booking.course.cover_image" mode="aspectFill" class="cover" />
|
||||||
|
<view class="course-info">
|
||||||
|
<text class="course-title">{{ booking.course.title }}</text>
|
||||||
|
<text class="meta primary">{{ formatTimeRange(booking.course.start_at, booking.course.end_at) }}</text>
|
||||||
|
<text class="meta">{{ booking.course.coach_name }} · {{ booking.course.courts.map(item => item.name).join('、') }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<text class="card-title">订单信息</text>
|
||||||
|
<view class="info-row">
|
||||||
|
<text>订单号</text>
|
||||||
|
<text>{{ booking.order.order_no }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text>订单金额</text>
|
||||||
|
<text>¥{{ booking.order.amount }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text>订单状态</text>
|
||||||
|
<text>{{ booking.order.status_label }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<text class="card-title">预约时间线</text>
|
||||||
|
<view class="timeline-item">
|
||||||
|
<text class="dot"></text>
|
||||||
|
<view>
|
||||||
|
<text class="timeline-title">创建预约</text>
|
||||||
|
<text class="timeline-time">{{ formatDateTime(booking.created_at) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="booking.expire_at" class="timeline-item">
|
||||||
|
<text class="dot warning"></text>
|
||||||
|
<view>
|
||||||
|
<text class="timeline-title">支付截止</text>
|
||||||
|
<text class="timeline-time">{{ formatDateTime(booking.expire_at) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="booking.cancel_deadline_at" class="timeline-item">
|
||||||
|
<text class="dot"></text>
|
||||||
|
<view>
|
||||||
|
<text class="timeline-title">取消截止</text>
|
||||||
|
<text class="timeline-time">{{ formatDateTime(booking.cancel_deadline_at) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="booking.cancelled_at" class="timeline-item">
|
||||||
|
<text class="dot danger"></text>
|
||||||
|
<view>
|
||||||
|
<text class="timeline-title">已取消</text>
|
||||||
|
<text class="timeline-time">{{ formatDateTime(booking.cancelled_at) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="booking.status === 'pending_pay' || booking.can_cancel" class="fixed-action-bar">
|
||||||
|
<button v-if="booking.can_cancel" class="ghost-button action-btn" @tap="cancelBooking">取消预约</button>
|
||||||
|
<button v-if="booking.status === 'pending_pay'" class="primary-button action-btn" @tap="goPay">去支付</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import { bookingApi } from '@/modules/comprehensive/api/booking'
|
||||||
|
import { formatDateTime, formatTimeRange } from '@/modules/comprehensive/utils/date'
|
||||||
|
|
||||||
|
const bookingId = ref('')
|
||||||
|
const booking = ref(null)
|
||||||
|
|
||||||
|
const statusDesc = computed(() => {
|
||||||
|
if (!booking.value) return ''
|
||||||
|
if (booking.value.status === 'pending_pay') return '订单待支付,完成支付后预约生效。'
|
||||||
|
if (booking.value.status === 'booked') return '预约已生效,到馆后按场馆流程核销。'
|
||||||
|
if (booking.value.status === 'cancelled') return booking.value.cancel_reason || '预约已取消。'
|
||||||
|
return '预约记录已结束。'
|
||||||
|
})
|
||||||
|
|
||||||
|
onLoad(async (query) => {
|
||||||
|
bookingId.value = query.id
|
||||||
|
await loadBooking()
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadBooking = async () => {
|
||||||
|
booking.value = await bookingApi.getCourseBookingDetail(bookingId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const goPay = () => {
|
||||||
|
uni.navigateTo({ url: `/pages/comprehensive/pay/index?order_id=${booking.value.order_id}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelBooking = async () => {
|
||||||
|
await bookingApi.cancelCourseBooking(booking.value.id)
|
||||||
|
uni.showToast({ title: '预约已取消', icon: 'none' })
|
||||||
|
await loadBooking()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.status-card,
|
||||||
|
.course-card,
|
||||||
|
.card {
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-title,
|
||||||
|
.status-desc,
|
||||||
|
.course-title,
|
||||||
|
.meta,
|
||||||
|
.card-title,
|
||||||
|
.timeline-title,
|
||||||
|
.timeline-time {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-title {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 42rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-desc {
|
||||||
|
margin: $spacing-sm 0 $spacing-md;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-card {
|
||||||
|
display: flex;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
width: 180rpx;
|
||||||
|
height: 150rpx;
|
||||||
|
border-radius: $border-radius-base;
|
||||||
|
background: $bg-color-hover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-title,
|
||||||
|
.card-title {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
margin-top: $spacing-xs;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
margin-top: $spacing-sm;
|
||||||
|
color: $color-primary;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
min-height: 70rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-md;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 25rpx;
|
||||||
|
border-bottom: 1rpx solid $bg-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row text:last-child {
|
||||||
|
flex: 1;
|
||||||
|
color: $text-color-main;
|
||||||
|
font-weight: 800;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
display: flex;
|
||||||
|
gap: $spacing-md;
|
||||||
|
padding: $spacing-md 0;
|
||||||
|
border-bottom: 1rpx solid $bg-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 18rpx;
|
||||||
|
height: 18rpx;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $color-success;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.warning {
|
||||||
|
background: $color-warning;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.danger {
|
||||||
|
background: $color-danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-title {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-time {
|
||||||
|
margin-top: 4rpx;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
<template>
|
||||||
|
<view class="bookings-page">
|
||||||
|
<view v-if="!session.isLoggedIn" class="unlogged">
|
||||||
|
<text class="brand">星河综合运动中心</text>
|
||||||
|
<text class="desc">登录后查看课程预约、包场预约与篮球门票权益。</text>
|
||||||
|
<button class="primary-button login-btn" @tap="login">微信一键登录</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<block v-else>
|
||||||
|
<view class="sticky-tabs">
|
||||||
|
<view class="business-tabs">
|
||||||
|
<view v-for="tab in businessTabs" :key="tab.value" class="business-tab" :class="{ active: businessType === tab.value }" @tap="selectBusiness(tab.value)">
|
||||||
|
{{ tab.label }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="list">
|
||||||
|
<view v-for="item in bookedItems" :key="`${businessType}-${item.id}`" class="booking-card card" @tap="goDetail(item)">
|
||||||
|
<view class="card-top">
|
||||||
|
<text class="item-title">{{ getItemTitle(item) }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="item-meta primary">{{ getItemTime(item) }}</text>
|
||||||
|
<text class="item-meta">{{ getItemDesc(item) }}</text>
|
||||||
|
<view class="card-bottom">
|
||||||
|
<text class="item-meta">订单金额</text>
|
||||||
|
<text class="amount">¥{{ item.order?.amount || '--' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="!isLoading && bookedItems.length === 0" class="empty-state">暂无已预约记录</view>
|
||||||
|
<view v-if="isLoading" class="load-more-tip">加载中...</view>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { bookingApi } from '@/modules/comprehensive/api/booking'
|
||||||
|
import { ticketApi } from '@/modules/comprehensive/api/ticket'
|
||||||
|
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
||||||
|
import { formatDateTime, formatTimeRange } from '@/modules/comprehensive/utils/date'
|
||||||
|
|
||||||
|
const session = useComprehensiveSessionStore()
|
||||||
|
const businessType = ref('course')
|
||||||
|
const items = ref([])
|
||||||
|
const isLoading = ref(false)
|
||||||
|
|
||||||
|
const businessTabs = [
|
||||||
|
{ label: '课程', value: 'course' },
|
||||||
|
{ label: '包场', value: 'private' },
|
||||||
|
{ label: '门票', value: 'ticket' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const bookedItems = computed(() => {
|
||||||
|
return items.value.filter((item) => ['booked', 'available'].includes(item.status))
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await session.init()
|
||||||
|
if (session.isLoggedIn) loadItems()
|
||||||
|
})
|
||||||
|
|
||||||
|
const login = async () => {
|
||||||
|
await session.login()
|
||||||
|
loadItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectBusiness = (value) => {
|
||||||
|
businessType.value = value
|
||||||
|
loadItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadItems = async () => {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
if (businessType.value === 'course') {
|
||||||
|
const data = await bookingApi.getCourseBookings({ page: 1, pageSize: 50 })
|
||||||
|
items.value = data.items
|
||||||
|
} else if (businessType.value === 'private') {
|
||||||
|
const data = await bookingApi.getPrivateBookings({ page: 1, pageSize: 50 })
|
||||||
|
items.value = data.items
|
||||||
|
} else {
|
||||||
|
const data = await ticketApi.getTicketAccount({ page: 1, pageSize: 50 })
|
||||||
|
items.value = data.records.map((record) => ({
|
||||||
|
...record,
|
||||||
|
order: {
|
||||||
|
id: record.order_id,
|
||||||
|
order_no: record.order_no,
|
||||||
|
amount: record.order_id ? '80.00' : '--'
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getItemTitle = (item) => {
|
||||||
|
if (businessType.value === 'course') return item.course?.title || '课程预约'
|
||||||
|
if (businessType.value === 'private') return item.courts?.map((court) => court.name).join('、') || '包场预约'
|
||||||
|
return `篮球门票 ${item.order_no}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getItemTime = (item) => {
|
||||||
|
if (businessType.value === 'course') return formatTimeRange(item.course?.start_at, item.course?.end_at)
|
||||||
|
if (businessType.value === 'private') return formatTimeRange(item.start_at, item.end_at)
|
||||||
|
return item.checked_in_at ? `核销时间 ${formatDateTime(item.checked_in_at)}` : `生成时间 ${formatDateTime(item.created_at)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getItemDesc = (item) => {
|
||||||
|
if (businessType.value === 'course') return `${item.course?.coach_name || ''} · ${item.course?.courts?.map((court) => court.name).join('、') || ''}`
|
||||||
|
if (businessType.value === 'private') return `共 ${item.courts?.length || 0} 个场地`
|
||||||
|
return item.status === 'available' ? '可到馆核销使用' : item.status_label
|
||||||
|
}
|
||||||
|
|
||||||
|
const goDetail = (item) => {
|
||||||
|
if (businessType.value === 'course') {
|
||||||
|
uni.navigateTo({ url: `/pages/comprehensive/bookings/course-detail?id=${item.id}` })
|
||||||
|
} else if (businessType.value === 'private') {
|
||||||
|
uni.navigateTo({ url: `/pages/comprehensive/bookings/private-detail?id=${item.id}` })
|
||||||
|
} else if (item.order_id) {
|
||||||
|
uni.navigateTo({ url: `/pages/comprehensive/orders/detail?id=${item.order_id}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.bookings-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $bg-color-soft;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unlogged {
|
||||||
|
min-height: 80vh;
|
||||||
|
padding: 180rpx $spacing-lg 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 42rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
margin-top: $spacing-md;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn {
|
||||||
|
width: 520rpx;
|
||||||
|
margin-top: 100rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticky-tabs {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
padding: $spacing-md $spacing-md $spacing-sm;
|
||||||
|
background: $bg-color-main;
|
||||||
|
border-bottom: 1rpx solid $border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.business-tabs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.business-tab {
|
||||||
|
height: 64rpx;
|
||||||
|
border-radius: $border-radius-base;
|
||||||
|
background: $bg-color-hover;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.business-tab.active {
|
||||||
|
background: $text-color-main;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
padding: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.booking-card {
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-top,
|
||||||
|
.card-bottom {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-title {
|
||||||
|
flex: 1;
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-meta {
|
||||||
|
display: block;
|
||||||
|
margin-top: $spacing-xs;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
margin-top: $spacing-md;
|
||||||
|
color: $color-primary;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-bottom {
|
||||||
|
margin-top: $spacing-md;
|
||||||
|
padding-top: $spacing-md;
|
||||||
|
border-top: 1rpx solid $bg-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
<template>
|
||||||
|
<view class="booking-detail container page-bottom-safe" v-if="booking">
|
||||||
|
<view class="status-card card">
|
||||||
|
<text class="status-title">{{ booking.status_label }}</text>
|
||||||
|
<text class="status-desc">{{ statusDesc }}</text>
|
||||||
|
<text class="badge" :class="statusBadgeClass(booking.status)">{{ booking.status_label }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<text class="card-title">包场信息</text>
|
||||||
|
<view class="info-row">
|
||||||
|
<text>预约场地</text>
|
||||||
|
<text>{{ booking.courts.map(item => item.name).join('、') }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text>预约时间</text>
|
||||||
|
<text>{{ formatTimeRange(booking.start_at, booking.end_at) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text>预约时长</text>
|
||||||
|
<text>{{ formatDuration(booking.start_at, booking.end_at) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<text class="card-title">订单信息</text>
|
||||||
|
<view class="info-row">
|
||||||
|
<text>订单号</text>
|
||||||
|
<text>{{ booking.order.order_no }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text>订单金额</text>
|
||||||
|
<text>¥{{ booking.order.amount }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text>订单状态</text>
|
||||||
|
<text>{{ booking.order.status_label }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<text class="card-title">预约时间线</text>
|
||||||
|
<view class="timeline-item">
|
||||||
|
<text class="dot"></text>
|
||||||
|
<view>
|
||||||
|
<text class="timeline-title">创建包场预约</text>
|
||||||
|
<text class="timeline-time">{{ formatDateTime(booking.created_at) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="booking.expire_at" class="timeline-item">
|
||||||
|
<text class="dot warning"></text>
|
||||||
|
<view>
|
||||||
|
<text class="timeline-title">支付截止</text>
|
||||||
|
<text class="timeline-time">{{ formatDateTime(booking.expire_at) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="booking.cancelled_at" class="timeline-item">
|
||||||
|
<text class="dot danger"></text>
|
||||||
|
<view>
|
||||||
|
<text class="timeline-title">取消记录</text>
|
||||||
|
<text class="timeline-time">{{ booking.cancel_reason || '已取消' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="booking.status === 'pending_pay'" class="fixed-action-bar">
|
||||||
|
<button class="primary-button action-btn" @tap="goPay">去支付</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import { bookingApi } from '@/modules/comprehensive/api/booking'
|
||||||
|
import { formatDateTime, formatDuration, formatTimeRange } from '@/modules/comprehensive/utils/date'
|
||||||
|
import { statusBadgeClass } from '@/modules/comprehensive/utils/status'
|
||||||
|
|
||||||
|
const bookingId = ref('')
|
||||||
|
const booking = ref(null)
|
||||||
|
|
||||||
|
const statusDesc = computed(() => {
|
||||||
|
if (!booking.value) return ''
|
||||||
|
if (booking.value.status === 'pending_pay') return '订单待支付,完成支付后包场生效。'
|
||||||
|
if (booking.value.status === 'booked') return '包场预约已生效,请按预约时间到馆。'
|
||||||
|
return '包场预约记录已结束。'
|
||||||
|
})
|
||||||
|
|
||||||
|
onLoad(async (query) => {
|
||||||
|
bookingId.value = query.id
|
||||||
|
booking.value = await bookingApi.getPrivateBookingDetail(bookingId.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const goPay = () => {
|
||||||
|
uni.navigateTo({ url: `/pages/comprehensive/pay/index?order_id=${booking.value.order_id}` })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.status-card,
|
||||||
|
.card {
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-title,
|
||||||
|
.status-desc,
|
||||||
|
.card-title,
|
||||||
|
.timeline-title,
|
||||||
|
.timeline-time {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-title {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 42rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-desc {
|
||||||
|
margin: $spacing-sm 0 $spacing-md;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
min-height: 70rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-md;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 25rpx;
|
||||||
|
border-bottom: 1rpx solid $bg-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row text:last-child {
|
||||||
|
flex: 1;
|
||||||
|
color: $text-color-main;
|
||||||
|
font-weight: 800;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
display: flex;
|
||||||
|
gap: $spacing-md;
|
||||||
|
padding: $spacing-md 0;
|
||||||
|
border-bottom: 1rpx solid $bg-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 18rpx;
|
||||||
|
height: 18rpx;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $color-success;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.warning {
|
||||||
|
background: $color-warning;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.danger {
|
||||||
|
background: $color-danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-title {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-time {
|
||||||
|
margin-top: 4rpx;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<template>
|
||||||
|
<view class="confirm-page container page-bottom-safe" v-if="course">
|
||||||
|
<view class="summary-card card">
|
||||||
|
<text class="card-title">{{ course.title }}</text>
|
||||||
|
<view class="summary-row">
|
||||||
|
<text>上课时间</text>
|
||||||
|
<text>{{ formatTimeRange(course.start_at, course.end_at) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="summary-row">
|
||||||
|
<text>授课教练</text>
|
||||||
|
<text>{{ course.coach_name }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="summary-row">
|
||||||
|
<text>上课场地</text>
|
||||||
|
<text>{{ course.courts.map(item => item.name).join('、') }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="summary-row">
|
||||||
|
<text>课程价格</text>
|
||||||
|
<text class="strong">¥{{ course.price }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="fixed-action-bar">
|
||||||
|
<view class="bar-price">
|
||||||
|
<text class="bar-label">应付金额</text>
|
||||||
|
<text class="price-text">¥{{ course.price }}</text>
|
||||||
|
</view>
|
||||||
|
<button class="primary-button submit-btn" :disabled="isSubmitting || !canSubmit" @tap="submit">
|
||||||
|
提交预约
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import { courseApi } from '@/modules/comprehensive/api/course'
|
||||||
|
import { formatTimeRange } from '@/modules/comprehensive/utils/date'
|
||||||
|
|
||||||
|
const course = ref(null)
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
const canSubmit = computed(() => course.value?.status === 'published' && course.value?.remaining_count > 0)
|
||||||
|
|
||||||
|
onLoad(async (query) => {
|
||||||
|
course.value = await courseApi.getCourseDetail(query.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (isSubmitting.value || !course.value || !canSubmit.value) return
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const data = await courseApi.createCourseBooking(course.value.id)
|
||||||
|
uni.navigateTo({ url: `/pages/comprehensive/pay/index?order_id=${data.order.id}` })
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.summary-card {
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-row {
|
||||||
|
min-height: 70rpx;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-md;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 26rpx;
|
||||||
|
border-bottom: 1rpx solid $bg-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-row text:last-child {
|
||||||
|
flex: 1;
|
||||||
|
color: $text-color-main;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strong {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-price {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-label {
|
||||||
|
display: block;
|
||||||
|
color: $text-color-light;
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
width: 340rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<template>
|
||||||
|
<view class="detail-page page-bottom-safe" v-if="course">
|
||||||
|
<image :src="course.cover_image" mode="aspectFill" class="hero-image" />
|
||||||
|
<view class="content">
|
||||||
|
<view class="title-card card">
|
||||||
|
<view class="title-row">
|
||||||
|
<text class="course-title">{{ course.title }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="price-row">
|
||||||
|
<text class="price-text">¥{{ course.price }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="info-card card">
|
||||||
|
<view v-for="item in infoItems" :key="item.label" class="info-row">
|
||||||
|
<text class="info-label">{{ item.label }}</text>
|
||||||
|
<text class="info-value">{{ item.value }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">课程详情</text>
|
||||||
|
</view>
|
||||||
|
<view class="rich-card card">
|
||||||
|
<rich-text :nodes="course.detail_html"></rich-text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="fixed-action-bar">
|
||||||
|
<button class="primary-button submit-btn" :disabled="!canBook" @tap="goConfirm">
|
||||||
|
{{ canBook ? '立即预约' : '暂不可约' }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import { courseApi } from '@/modules/comprehensive/api/course'
|
||||||
|
import { formatTimeRange } from '@/modules/comprehensive/utils/date'
|
||||||
|
|
||||||
|
const course = ref(null)
|
||||||
|
|
||||||
|
const canBook = computed(() => {
|
||||||
|
return course.value && course.value.remaining_count > 0 && course.value.status === 'published'
|
||||||
|
})
|
||||||
|
|
||||||
|
const infoItems = computed(() => {
|
||||||
|
if (!course.value) return []
|
||||||
|
return [
|
||||||
|
{ label: '上课时间', value: formatTimeRange(course.value.start_at, course.value.end_at) },
|
||||||
|
{ label: '授课教练', value: course.value.coach_name },
|
||||||
|
{ label: '上课场地', value: course.value.courts.map((item) => item.name).join('、') }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
onLoad(async (query) => {
|
||||||
|
course.value = await courseApi.getCourseDetail(query.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
const goConfirm = () => {
|
||||||
|
if (!canBook.value) return
|
||||||
|
uni.navigateTo({ url: `/pages/comprehensive/courses/confirm?id=${course.value.id}` })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.detail-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $bg-color-soft;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 420rpx;
|
||||||
|
background: $bg-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-card {
|
||||||
|
margin-top: -54rpx;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-row,
|
||||||
|
.price-row,
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-title {
|
||||||
|
flex: 1;
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 38rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-row {
|
||||||
|
margin-top: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card,
|
||||||
|
.rich-card {
|
||||||
|
margin-top: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
min-height: 72rpx;
|
||||||
|
border-bottom: 1rpx solid $bg-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
flex: 1;
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-card {
|
||||||
|
color: $text-color-regular;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
<template>
|
||||||
|
<view class="courses-page">
|
||||||
|
<view class="filter-bar">
|
||||||
|
<picker mode="date" :value="selectedDate" @change="onDateChange" class="filter-item">
|
||||||
|
<view class="filter-inner">
|
||||||
|
<text class="filter-label">日期</text>
|
||||||
|
<text class="filter-value">{{ selectedDate || '全部日期' }}</text>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
<picker mode="selector" :range="coachOptions" range-key="name" :value="selectedCoachIndex" @change="onCoachChange" class="filter-item">
|
||||||
|
<view class="filter-inner">
|
||||||
|
<text class="filter-label">教练</text>
|
||||||
|
<text class="filter-value">{{ selectedCoachName }}</text>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
<picker mode="selector" :range="courtTypeOptions" range-key="label" :value="selectedCourtTypeIndex" @change="onCourtTypeChange" class="filter-item">
|
||||||
|
<view class="filter-inner">
|
||||||
|
<text class="filter-label">类型</text>
|
||||||
|
<text class="filter-value">{{ selectedCourtTypeLabel }}</text>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="course-list">
|
||||||
|
<view v-for="course in courses" :key="course.id" class="course-card" @tap="goDetail(course.id)">
|
||||||
|
<image :src="course.cover_image" mode="aspectFill" class="course-cover" />
|
||||||
|
<view class="course-info">
|
||||||
|
<view class="course-top">
|
||||||
|
<text class="course-title">{{ course.title }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="time-line">{{ formatTimeRange(course.start_at, course.end_at) }}</text>
|
||||||
|
<text class="meta">{{ course.coach_name }}</text>
|
||||||
|
<view class="course-bottom">
|
||||||
|
<text class="meta">已约 {{ course.booked_count }}/{{ course.capacity }}</text>
|
||||||
|
<text class="course-price">¥{{ course.price }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="isLoading" class="load-more-tip">加载中...</view>
|
||||||
|
<view v-else-if="courses.length === 0" class="empty-state">当前筛选条件下暂无课程</view>
|
||||||
|
<view v-else-if="!hasMore" class="load-more-tip">已加载全部</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||||
|
import { courseApi } from '@/modules/comprehensive/api/course'
|
||||||
|
import { venueApi } from '@/modules/comprehensive/api/venue'
|
||||||
|
import { formatTimeRange, todayString } from '@/modules/comprehensive/utils/date'
|
||||||
|
|
||||||
|
const selectedDate = ref(todayString())
|
||||||
|
const selectedCoachIndex = ref(0)
|
||||||
|
const selectedCourtTypeIndex = ref(0)
|
||||||
|
const courses = ref([])
|
||||||
|
const coaches = ref([])
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = 20
|
||||||
|
const total = ref(0)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const isLoadingMore = ref(false)
|
||||||
|
|
||||||
|
const courtTypeOptions = [
|
||||||
|
{ label: '全部', value: '' },
|
||||||
|
{ label: '羽毛球', value: 'badminton' },
|
||||||
|
{ label: '篮球', value: 'basketball' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const coachOptions = computed(() => [{ id: '', name: '全部教练' }, ...coaches.value])
|
||||||
|
const selectedCoachId = computed(() => coachOptions.value[selectedCoachIndex.value]?.id || '')
|
||||||
|
const selectedCoachName = computed(() => coachOptions.value[selectedCoachIndex.value]?.name || '全部教练')
|
||||||
|
const selectedCourtType = computed(() => courtTypeOptions[selectedCourtTypeIndex.value]?.value || '')
|
||||||
|
const selectedCourtTypeLabel = computed(() => courtTypeOptions[selectedCourtTypeIndex.value]?.label || '全部')
|
||||||
|
const hasMore = computed(() => courses.value.length < total.value)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const coachData = await venueApi.getCoaches()
|
||||||
|
coaches.value = coachData.items
|
||||||
|
loadCourses(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
onPullDownRefresh(() => {
|
||||||
|
loadCourses(true).finally(() => uni.stopPullDownRefresh())
|
||||||
|
})
|
||||||
|
|
||||||
|
onReachBottom(() => {
|
||||||
|
if (!hasMore.value || isLoadingMore.value) return
|
||||||
|
page.value += 1
|
||||||
|
loadCourses(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadCourses = async (reset) => {
|
||||||
|
if (reset) {
|
||||||
|
page.value = 1
|
||||||
|
isLoading.value = true
|
||||||
|
} else {
|
||||||
|
isLoadingMore.value = true
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await courseApi.getCourses({
|
||||||
|
page: page.value,
|
||||||
|
pageSize,
|
||||||
|
date: selectedDate.value,
|
||||||
|
coachId: selectedCoachId.value,
|
||||||
|
courtType: selectedCourtType.value
|
||||||
|
})
|
||||||
|
courses.value = reset ? data.items : [...courses.value, ...data.items]
|
||||||
|
total.value = data.total
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
isLoadingMore.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDateChange = (e) => {
|
||||||
|
selectedDate.value = e.detail.value
|
||||||
|
loadCourses(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCoachChange = (e) => {
|
||||||
|
selectedCoachIndex.value = Number(e.detail.value)
|
||||||
|
loadCourses(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCourtTypeChange = (e) => {
|
||||||
|
selectedCourtTypeIndex.value = Number(e.detail.value)
|
||||||
|
loadCourses(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const goDetail = (id) => {
|
||||||
|
uni.navigateTo({ url: `/pages/comprehensive/courses/detail?id=${id}` })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.courses-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $bg-color-soft;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
height: 94rpx;
|
||||||
|
background: $bg-color-main;
|
||||||
|
border-bottom: 1rpx solid $border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-inner {
|
||||||
|
height: 94rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-label {
|
||||||
|
color: $text-color-light;
|
||||||
|
font-size: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-value {
|
||||||
|
margin-top: 4rpx;
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-md;
|
||||||
|
padding: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-card {
|
||||||
|
@include minimal-card;
|
||||||
|
display: flex;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-cover {
|
||||||
|
width: 180rpx;
|
||||||
|
height: 172rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: $border-radius-base;
|
||||||
|
background: $bg-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-top,
|
||||||
|
.course-bottom {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-title {
|
||||||
|
flex: 1;
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-line {
|
||||||
|
display: block;
|
||||||
|
margin-top: $spacing-sm;
|
||||||
|
color: $color-primary;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6rpx;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 23rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-price {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
<template>
|
||||||
|
<view class="home-container">
|
||||||
|
<view class="banner-wrapper">
|
||||||
|
<swiper
|
||||||
|
v-if="banners.length"
|
||||||
|
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 in banners" :key="banner.id">
|
||||||
|
<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 v-if="upcomingCourses.length" class="course-list">
|
||||||
|
<view v-for="course in upcomingCourses" :key="course.id" class="course-item" @tap="go(`/pages/comprehensive/courses/detail?id=${course.id}`)">
|
||||||
|
<image :src="course.cover_image" mode="aspectFill" class="course-cover" />
|
||||||
|
<view class="course-info-wrap">
|
||||||
|
<view class="course-title-row">
|
||||||
|
<text class="course-name">{{ course.title }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="course-time highlight-blue">时间:{{ formatTimeRange(course.start_at, course.end_at) }}</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>
|
||||||
|
<view v-else class="empty-placeholder">今日暂无课程排期</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section-container last-section">
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">公告活动</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="articles.length" class="activity-grid">
|
||||||
|
<view v-for="article in articles" :key="article.id" class="activity-card" @tap="go(`/pages/comprehensive/activities/detail?id=${article.id}`)">
|
||||||
|
<image :src="article.cover_image" mode="aspectFill" class="activity-cover" />
|
||||||
|
<view class="activity-info">
|
||||||
|
<text class="activity-title">{{ article.title }}</text>
|
||||||
|
<text class="activity-date">{{ formatDate(article.published_at || article.created_at) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-else class="empty-placeholder">暂无公告活动</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
||||||
|
import { contentApi } from '@/modules/comprehensive/api/content'
|
||||||
|
import { formatDate, formatTimeRange } from '@/modules/comprehensive/utils/date'
|
||||||
|
|
||||||
|
const banners = ref([])
|
||||||
|
const upcomingCourses = ref([])
|
||||||
|
const articles = ref([])
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadPage()
|
||||||
|
})
|
||||||
|
|
||||||
|
onPullDownRefresh(() => {
|
||||||
|
loadPage().finally(() => uni.stopPullDownRefresh())
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadPage = async () => {
|
||||||
|
const home = await contentApi.getHomeContent({ banner_limit: 5, article_limit: 3 })
|
||||||
|
banners.value = home.banners || []
|
||||||
|
upcomingCourses.value = (home.upcoming_courses || []).slice(0, 2)
|
||||||
|
articles.value = home.articles || []
|
||||||
|
}
|
||||||
|
|
||||||
|
const go = (url) => {
|
||||||
|
uni.navigateTo({ url })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.home-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: $spacing-lg $spacing-md;
|
||||||
|
background-color: $bg-color-soft;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
padding: $spacing-md;
|
||||||
|
background: linear-gradient(to top, rgba(15, 23, 42, 0.6), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-title {
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-container {
|
||||||
|
margin-bottom: $spacing-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-container.last-section {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
padding: 0 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
position: relative;
|
||||||
|
padding-left: $spacing-sm;
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 10%;
|
||||||
|
bottom: 10%;
|
||||||
|
width: 4rpx;
|
||||||
|
background-color: $text-color-main;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-list,
|
||||||
|
.activity-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-item,
|
||||||
|
.activity-card {
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1rpx solid $bg-color-hover;
|
||||||
|
border-radius: $border-radius-base;
|
||||||
|
background-color: $bg-color-main;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-item:active,
|
||||||
|
.activity-card:active {
|
||||||
|
background-color: $bg-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-cover {
|
||||||
|
width: 180rpx;
|
||||||
|
height: 130rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background-color: $bg-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-info-wrap {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: $spacing-sm $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-title-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-name {
|
||||||
|
flex: 1;
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-time {
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-time.highlight-blue {
|
||||||
|
color: $color-primary;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-bottom-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-meta-text {
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-cover {
|
||||||
|
width: 180rpx;
|
||||||
|
height: 130rpx;
|
||||||
|
background-color: $bg-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-info {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: $spacing-sm $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-title {
|
||||||
|
display: block;
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-date {
|
||||||
|
color: $text-color-light;
|
||||||
|
font-size: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-placeholder {
|
||||||
|
padding: $spacing-xl 0;
|
||||||
|
color: $text-color-light;
|
||||||
|
font-size: 24rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<template>
|
||||||
|
<view class="order-detail container page-bottom-safe" v-if="order">
|
||||||
|
<view class="status-card card">
|
||||||
|
<text class="amount">¥{{ order.amount }}</text>
|
||||||
|
<text class="badge" :class="statusBadgeClass(order.status)">{{ order.status_label }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<text class="card-title">订单信息</text>
|
||||||
|
<view class="info-row">
|
||||||
|
<text>订单号</text>
|
||||||
|
<text>{{ order.order_no }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text>创建时间</text>
|
||||||
|
<text>{{ formatDateTime(order.created_at) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text>更新时间</text>
|
||||||
|
<text>{{ formatDateTime(order.updated_at) }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="order.expire_at" class="info-row">
|
||||||
|
<text>支付过期</text>
|
||||||
|
<text>{{ formatDateTime(order.expire_at) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card list-card">
|
||||||
|
<text class="card-title">支付记录</text>
|
||||||
|
<view v-if="order.payments.length === 0" class="placeholder">暂无支付记录</view>
|
||||||
|
<view v-for="payment in order.payments" :key="`${payment.method}-${payment.paid_at}`" class="record-row">
|
||||||
|
<view>
|
||||||
|
<text class="record-title">{{ payment.method_label || payment.method }}</text>
|
||||||
|
<text class="record-time">{{ formatDateTime(payment.paid_at || payment.created_at) }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="record-amount">¥{{ payment.amount }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card list-card">
|
||||||
|
<text class="card-title">退款记录</text>
|
||||||
|
<view v-if="order.refunds.length === 0" class="placeholder">暂无退款记录</view>
|
||||||
|
<view v-for="refund in order.refunds" :key="refund.refund_no" class="record-row">
|
||||||
|
<view>
|
||||||
|
<text class="record-title">{{ refund.reason }}</text>
|
||||||
|
<text class="record-time">{{ formatDateTime(refund.refunded_at) }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="record-amount">¥{{ refund.amount }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="order.status === 'pending_pay'" class="fixed-action-bar">
|
||||||
|
<button class="ghost-button action-btn" @tap="closeCurrentOrder">关闭订单</button>
|
||||||
|
<button class="primary-button action-btn" @tap="goPay">去支付</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import { orderApi } from '@/modules/comprehensive/api/order'
|
||||||
|
import { formatDateTime } from '@/modules/comprehensive/utils/date'
|
||||||
|
import { statusBadgeClass } from '@/modules/comprehensive/utils/status'
|
||||||
|
|
||||||
|
const orderId = ref('')
|
||||||
|
const order = ref(null)
|
||||||
|
|
||||||
|
onLoad(async (query) => {
|
||||||
|
orderId.value = query.id
|
||||||
|
await loadOrder()
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadOrder = async () => {
|
||||||
|
order.value = await orderApi.getOrderDetail(orderId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const goPay = () => {
|
||||||
|
uni.navigateTo({ url: `/pages/comprehensive/pay/index?order_id=${order.value.id}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeCurrentOrder = async () => {
|
||||||
|
await orderApi.closeOrder(order.value.id)
|
||||||
|
uni.showToast({ title: '订单已关闭', icon: 'none' })
|
||||||
|
await loadOrder()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.status-card {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 64rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row,
|
||||||
|
.record-row {
|
||||||
|
min-height: 70rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-md;
|
||||||
|
border-bottom: 1rpx solid $bg-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row:last-child,
|
||||||
|
.record-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 25rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row text:last-child {
|
||||||
|
flex: 1;
|
||||||
|
color: $text-color-main;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
padding: $spacing-md 0;
|
||||||
|
color: $text-color-light;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-title,
|
||||||
|
.record-time {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-title {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-time {
|
||||||
|
margin-top: 4rpx;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-amount {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
<template>
|
||||||
|
<view class="pay-page container page-bottom-safe" v-if="order">
|
||||||
|
<view class="status-card card">
|
||||||
|
<text class="business">{{ order.business_type_label }}</text>
|
||||||
|
<text class="amount">¥{{ order.amount }}</text>
|
||||||
|
<text v-if="order.expire_at" class="expire">{{ paymentCountdownText }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card order-card">
|
||||||
|
<view class="info-row">
|
||||||
|
<text>订单号</text>
|
||||||
|
<text>{{ order.order_no }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text>创建时间</text>
|
||||||
|
<text>{{ formatDateTime(order.created_at) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text>订单状态</text>
|
||||||
|
<text>{{ order.status_label }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card method-card">
|
||||||
|
<text class="card-title">支付方式</text>
|
||||||
|
<view
|
||||||
|
class="method-item"
|
||||||
|
:class="{ active: payMethod === 'balance', disabled: !canUseBalance }"
|
||||||
|
@tap="selectMethod('balance')"
|
||||||
|
>
|
||||||
|
<view>
|
||||||
|
<text class="method-title">余额支付</text>
|
||||||
|
<text class="method-sub">当前余额 ¥{{ balance.balance }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="method-check">{{ payMethod === 'balance' ? '已选' : '' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="method-item" :class="{ active: payMethod === 'wechat' }" @tap="selectMethod('wechat')">
|
||||||
|
<view>
|
||||||
|
<text class="method-title">微信支付</text>
|
||||||
|
<text class="method-sub">使用微信支付完成订单</text>
|
||||||
|
</view>
|
||||||
|
<text class="method-check">{{ payMethod === 'wechat' ? '已选' : '' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<button v-if="order.status === 'pending_pay'" class="ghost-button cancel-btn" @tap="closeCurrentOrder">取消订单</button>
|
||||||
|
|
||||||
|
<view class="fixed-action-bar">
|
||||||
|
<view class="bar-price">
|
||||||
|
<text class="bar-label">待支付</text>
|
||||||
|
<text class="price-text">¥{{ order.amount }}</text>
|
||||||
|
</view>
|
||||||
|
<button class="primary-button submit-btn" :disabled="!canPay || isSubmitting" @tap="pay">
|
||||||
|
{{ payButtonText }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onUnmounted, ref } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import { memberApi } from '@/modules/comprehensive/api/member'
|
||||||
|
import { orderApi } from '@/modules/comprehensive/api/order'
|
||||||
|
import { formatDateTime } from '@/modules/comprehensive/utils/date'
|
||||||
|
import { toAmountNumber } from '@/modules/comprehensive/utils/money'
|
||||||
|
|
||||||
|
const orderId = ref('')
|
||||||
|
const order = ref(null)
|
||||||
|
const balance = ref({ balance: '0.00' })
|
||||||
|
const payMethod = ref('balance')
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
const nowTimestamp = ref(Date.now())
|
||||||
|
let countdownTimer = null
|
||||||
|
|
||||||
|
const canUseBalance = computed(() => toAmountNumber(balance.value.balance) >= toAmountNumber(order.value?.amount))
|
||||||
|
const canPay = computed(() => order.value?.status === 'pending_pay' && (payMethod.value !== 'balance' || canUseBalance.value))
|
||||||
|
const payButtonText = computed(() => {
|
||||||
|
if (order.value?.status !== 'pending_pay') return '订单不可支付'
|
||||||
|
return payMethod.value === 'balance' ? '余额支付' : '微信支付'
|
||||||
|
})
|
||||||
|
const paymentRemainingSeconds = computed(() => {
|
||||||
|
if (!order.value?.expire_at) return 0
|
||||||
|
return Math.max(0, Math.ceil((new Date(order.value.expire_at).getTime() - nowTimestamp.value) / 1000))
|
||||||
|
})
|
||||||
|
const paymentCountdownText = computed(() => {
|
||||||
|
const seconds = paymentRemainingSeconds.value
|
||||||
|
if (seconds <= 0) return '支付倒计时 00:00:00'
|
||||||
|
const hours = Math.floor(seconds / 3600)
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60)
|
||||||
|
const remainSeconds = seconds % 60
|
||||||
|
return `支付倒计时 ${padTime(hours)}:${padTime(minutes)}:${padTime(remainSeconds)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
onLoad(async (query) => {
|
||||||
|
orderId.value = query.order_id || query.id
|
||||||
|
await loadPage()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopCountdown()
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadPage = async () => {
|
||||||
|
const [orderData, balanceData] = await Promise.all([
|
||||||
|
orderApi.getOrderDetail(orderId.value),
|
||||||
|
memberApi.getBalance()
|
||||||
|
])
|
||||||
|
order.value = orderData
|
||||||
|
balance.value = balanceData
|
||||||
|
if (!canUseBalance.value) payMethod.value = 'wechat'
|
||||||
|
updateCountdownTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateCountdownTimer = () => {
|
||||||
|
nowTimestamp.value = Date.now()
|
||||||
|
stopCountdown()
|
||||||
|
if (!order.value?.expire_at || order.value.status !== 'pending_pay') return
|
||||||
|
countdownTimer = setInterval(() => {
|
||||||
|
nowTimestamp.value = Date.now()
|
||||||
|
if (paymentRemainingSeconds.value <= 0) stopCountdown()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopCountdown = () => {
|
||||||
|
if (!countdownTimer) return
|
||||||
|
clearInterval(countdownTimer)
|
||||||
|
countdownTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const padTime = (value) => String(value).padStart(2, '0')
|
||||||
|
|
||||||
|
const selectMethod = (method) => {
|
||||||
|
if (method === 'balance' && !canUseBalance.value) {
|
||||||
|
uni.showToast({ title: '余额不足', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payMethod.value = method
|
||||||
|
}
|
||||||
|
|
||||||
|
const pay = async () => {
|
||||||
|
if (!canPay.value || isSubmitting.value) return
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
if (payMethod.value === 'balance') {
|
||||||
|
await orderApi.payOrderByBalance(order.value.id)
|
||||||
|
uni.showToast({ title: '支付成功', icon: 'success' })
|
||||||
|
} else {
|
||||||
|
const paymentData = await orderApi.payOrderByWechat(order.value.id)
|
||||||
|
await requestWechatPayment(paymentData.wechat)
|
||||||
|
uni.showToast({ title: '支付已提交', icon: 'success' })
|
||||||
|
}
|
||||||
|
await loadPage()
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.redirectTo({ url: `/pages/comprehensive/orders/detail?id=${order.value.id}` })
|
||||||
|
}, 350)
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeCurrentOrder = async () => {
|
||||||
|
await orderApi.closeOrder(order.value.id)
|
||||||
|
uni.showToast({ title: '订单已取消', icon: 'none' })
|
||||||
|
await loadPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestWechatPayment = (wechatPayload) => new Promise((resolve, reject) => {
|
||||||
|
uni.requestPayment({
|
||||||
|
...wechatPayload,
|
||||||
|
success: resolve,
|
||||||
|
fail: reject
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.status-card {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.business {
|
||||||
|
display: block;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount {
|
||||||
|
display: block;
|
||||||
|
margin: $spacing-sm 0;
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 68rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expire {
|
||||||
|
display: block;
|
||||||
|
margin-top: $spacing-md;
|
||||||
|
color: $color-warning;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-card,
|
||||||
|
.method-card {
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
min-height: 68rpx;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-md;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 25rpx;
|
||||||
|
border-bottom: 1rpx solid $bg-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row text:last-child {
|
||||||
|
flex: 1;
|
||||||
|
color: $text-color-main;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-item {
|
||||||
|
min-height: 110rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: $spacing-md 0;
|
||||||
|
border-bottom: 1rpx solid $bg-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-item.active .method-title {
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-item.disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-title,
|
||||||
|
.method-sub {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-title {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-sub,
|
||||||
|
.method-check {
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-check {
|
||||||
|
color: $color-primary;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn {
|
||||||
|
margin-top: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-price {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-label {
|
||||||
|
display: block;
|
||||||
|
color: $text-color-light;
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
width: 320rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<template>
|
||||||
|
<view class="confirm-page container page-bottom-safe" v-if="quote">
|
||||||
|
<view class="card summary-card">
|
||||||
|
<text class="card-title">{{ courtTypeLabel(payload.court_type) }}包场</text>
|
||||||
|
<view class="summary-row">
|
||||||
|
<text>预约场地</text>
|
||||||
|
<text>{{ quote.courts.map(item => item.name).join('、') }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="summary-row">
|
||||||
|
<text>预约时间</text>
|
||||||
|
<text>{{ formatTimeRange(quote.start_at, quote.end_at) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="summary-row">
|
||||||
|
<text>预约时长</text>
|
||||||
|
<text>{{ formatDuration(quote.start_at, quote.end_at) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card fee-card">
|
||||||
|
<text class="card-title">费用明细</text>
|
||||||
|
<view class="summary-row">
|
||||||
|
<text>半小时段</text>
|
||||||
|
<text>{{ quote.unit_count }} 段</text>
|
||||||
|
</view>
|
||||||
|
<view class="summary-row">
|
||||||
|
<text>场地数量</text>
|
||||||
|
<text>{{ quote.courts.length }} 个</text>
|
||||||
|
</view>
|
||||||
|
<view class="summary-row total">
|
||||||
|
<text>合计金额</text>
|
||||||
|
<text>¥{{ quote.amount }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="fixed-action-bar">
|
||||||
|
<view class="bar-price">
|
||||||
|
<text class="bar-label">应付金额</text>
|
||||||
|
<text class="price-text">¥{{ quote.amount }}</text>
|
||||||
|
</view>
|
||||||
|
<button class="primary-button submit-btn" :disabled="isSubmitting" @tap="submit">提交订单</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import { privateBookingApi } from '@/modules/comprehensive/api/privateBooking'
|
||||||
|
import { courtTypeLabel } from '@/modules/comprehensive/utils/status'
|
||||||
|
import { formatDuration, formatTimeRange } from '@/modules/comprehensive/utils/date'
|
||||||
|
|
||||||
|
const payload = ref({})
|
||||||
|
const quote = ref(null)
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
|
||||||
|
onLoad(async (query) => {
|
||||||
|
payload.value = JSON.parse(decodeURIComponent(query.payload || '{}'))
|
||||||
|
quote.value = await privateBookingApi.quotePrivateBooking(payload.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (isSubmitting.value) return
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const data = await privateBookingApi.createPrivateBooking(payload.value)
|
||||||
|
uni.navigateTo({ url: `/pages/comprehensive/pay/index?order_id=${data.order.id}` })
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.summary-card,
|
||||||
|
.fee-card {
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-row {
|
||||||
|
min-height: 72rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-md;
|
||||||
|
border-bottom: 1rpx solid $bg-color-hover;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-row text:last-child {
|
||||||
|
flex: 1;
|
||||||
|
color: $text-color-main;
|
||||||
|
font-weight: 800;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total text:last-child {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-price {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-label {
|
||||||
|
display: block;
|
||||||
|
color: $text-color-light;
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
width: 320rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
<template>
|
||||||
|
<view class="private-page page-bottom-safe">
|
||||||
|
<view class="top-panel">
|
||||||
|
<view class="type-tabs">
|
||||||
|
<view
|
||||||
|
v-for="item in typeOptions"
|
||||||
|
:key="item.value"
|
||||||
|
class="type-tab"
|
||||||
|
:class="{ active: courtType === item.value }"
|
||||||
|
@tap="selectType(item.value)"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="venue-meta">
|
||||||
|
<text>{{ businessHoursText }}</text>
|
||||||
|
<text>{{ options ? `¥${options.unit_price}/半小时` : '加载价格中' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="date-section">
|
||||||
|
<scroll-view scroll-x class="date-scroll" :show-scrollbar="false">
|
||||||
|
<view class="date-list">
|
||||||
|
<view
|
||||||
|
v-for="item in dateOptions"
|
||||||
|
:key="item.value"
|
||||||
|
class="date-chip"
|
||||||
|
:class="{ active: selectedDate === item.value }"
|
||||||
|
@tap="selectDate(item.value)"
|
||||||
|
>
|
||||||
|
<text class="date-week">{{ item.week }}</text>
|
||||||
|
<text class="date-day">{{ item.day }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="duration-section section-card">
|
||||||
|
<view class="section-header compact">
|
||||||
|
<text class="section-title">预约时长</text>
|
||||||
|
</view>
|
||||||
|
<view class="duration-control">
|
||||||
|
<button class="step-btn" :disabled="durationUnits <= 1" @tap="decreaseDuration">-</button>
|
||||||
|
<view class="duration-value">
|
||||||
|
<text class="duration-main">{{ durationText }}</text>
|
||||||
|
<text class="duration-sub">{{ options ? `单场 ¥${options.amount_per_court}` : '按选择时长试算' }}</text>
|
||||||
|
</view>
|
||||||
|
<button class="step-btn" :disabled="durationUnits >= MAX_DURATION_UNITS" @tap="increaseDuration">+</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="time-section section-card">
|
||||||
|
<view class="section-header compact">
|
||||||
|
<text class="section-title">开始时间</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="isLoading" class="state-box">正在加载可预约时段</view>
|
||||||
|
<view v-else-if="loadError" class="state-box">{{ loadError }}</view>
|
||||||
|
<view v-else-if="!timeOptions.length" class="state-box">当前日期暂无可预约时段</view>
|
||||||
|
<view v-else class="time-grid">
|
||||||
|
<view
|
||||||
|
v-for="item in timeOptions"
|
||||||
|
:key="item.start_at"
|
||||||
|
class="time-chip"
|
||||||
|
:class="{ active: selectedTime?.start_at === item.start_at, disabled: !item.available_court_count }"
|
||||||
|
@tap="selectTime(item)"
|
||||||
|
>
|
||||||
|
<text class="time-main">{{ formatTime(item.start_at) }}</text>
|
||||||
|
<text class="time-sub">{{ item.available_court_count ? `余 ${item.available_court_count} 场` : '已满' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="court-section section-card">
|
||||||
|
<view class="section-header compact">
|
||||||
|
<text class="section-title">选择场地</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="!selectedTime" class="state-box">请先选择开始时间</view>
|
||||||
|
<view v-else-if="!availableCourts.length" class="state-box">该时段无可预约场地</view>
|
||||||
|
<view v-else class="court-list">
|
||||||
|
<view
|
||||||
|
v-for="court in availableCourts"
|
||||||
|
:key="court.id"
|
||||||
|
class="court-chip"
|
||||||
|
:class="{ active: selectedCourtIds.includes(court.id) }"
|
||||||
|
@tap="toggleCourt(court.id)"
|
||||||
|
>
|
||||||
|
<text>{{ court.name }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="fixed-action-bar">
|
||||||
|
<view class="selection-info">
|
||||||
|
<text class="selection-main">{{ selectionTitle }}</text>
|
||||||
|
<text class="selection-sub">{{ selectionSubText }}</text>
|
||||||
|
</view>
|
||||||
|
<button class="primary-button submit-btn" :disabled="!canConfirm" @tap="goConfirm">确认预约</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { privateBookingApi } from '@/modules/comprehensive/api/privateBooking'
|
||||||
|
import { formatAmount, toAmountNumber } from '@/modules/comprehensive/utils/money'
|
||||||
|
import { formatTime, todayString } from '@/modules/comprehensive/utils/date'
|
||||||
|
|
||||||
|
const ADVANCE_DAYS = 7
|
||||||
|
const MAX_DURATION_UNITS = 8
|
||||||
|
|
||||||
|
const typeOptions = [
|
||||||
|
{ label: '羽毛球', value: 'badminton' },
|
||||||
|
{ label: '篮球', value: 'basketball' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const courtType = ref('badminton')
|
||||||
|
const selectedDate = ref(todayString())
|
||||||
|
const durationUnits = ref(2)
|
||||||
|
const options = ref(null)
|
||||||
|
const selectedTime = ref(null)
|
||||||
|
const selectedCourtIds = ref([])
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const loadError = ref('')
|
||||||
|
let loadRequestId = 0
|
||||||
|
|
||||||
|
const dateOptions = computed(() => {
|
||||||
|
const today = dayjs()
|
||||||
|
const weekLabels = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||||
|
return Array.from({ length: ADVANCE_DAYS }).map((_, index) => {
|
||||||
|
const date = today.add(index, 'day')
|
||||||
|
const week = index === 0 ? '今天' : index === 1 ? '明天' : weekLabels[date.day()]
|
||||||
|
return {
|
||||||
|
value: date.format('YYYY-MM-DD'),
|
||||||
|
week,
|
||||||
|
day: date.format('MM/DD')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const timeOptions = computed(() => options.value?.time_options || [])
|
||||||
|
const availableCourts = computed(() => selectedTime.value?.available_courts || [])
|
||||||
|
const businessHoursText = computed(() => {
|
||||||
|
if (!options.value) return '营业时间加载中'
|
||||||
|
return `营业 ${normalizeClock(options.value.open_time)}-${normalizeClock(options.value.close_time)}`
|
||||||
|
})
|
||||||
|
const durationText = computed(() => {
|
||||||
|
const minutes = durationUnits.value * 30
|
||||||
|
if (minutes % 60 === 0) return `${minutes / 60} 小时`
|
||||||
|
if (minutes > 60) return `${Math.floor(minutes / 60)} 小时 ${minutes % 60} 分钟`
|
||||||
|
return `${minutes} 分钟`
|
||||||
|
})
|
||||||
|
const canConfirm = computed(() => selectedTime.value && selectedCourtIds.value.length > 0)
|
||||||
|
const estimatedAmount = computed(() => {
|
||||||
|
return formatAmount(toAmountNumber(options.value?.amount_per_court) * selectedCourtIds.value.length)
|
||||||
|
})
|
||||||
|
const selectionTitle = computed(() => {
|
||||||
|
if (!selectedTime.value) return '请选择开始时间'
|
||||||
|
if (!selectedCourtIds.value.length) return `${formatTime(selectedTime.value.start_at)}-${formatTime(selectedTime.value.end_at)}`
|
||||||
|
return `${selectedCourtIds.value.length} 个场地 · ¥${estimatedAmount.value}`
|
||||||
|
})
|
||||||
|
const selectionSubText = computed(() => {
|
||||||
|
if (!selectedTime.value) return '选择时长后展示可预约时间'
|
||||||
|
if (!selectedCourtIds.value.length) return '请选择至少一个场地'
|
||||||
|
return `${formatTime(selectedTime.value.start_at)}-${formatTime(selectedTime.value.end_at)} · ${durationText.value}`
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(loadOptions)
|
||||||
|
|
||||||
|
const loadOptions = async () => {
|
||||||
|
const requestId = ++loadRequestId
|
||||||
|
isLoading.value = true
|
||||||
|
loadError.value = ''
|
||||||
|
selectedTime.value = null
|
||||||
|
selectedCourtIds.value = []
|
||||||
|
try {
|
||||||
|
const data = await privateBookingApi.getPrivateBookingOptions({
|
||||||
|
date: selectedDate.value,
|
||||||
|
courtType: courtType.value,
|
||||||
|
durationUnits: durationUnits.value
|
||||||
|
})
|
||||||
|
if (requestId !== loadRequestId) return
|
||||||
|
options.value = data
|
||||||
|
selectFirstAvailableTime()
|
||||||
|
} catch (error) {
|
||||||
|
if (requestId !== loadRequestId) return
|
||||||
|
options.value = null
|
||||||
|
loadError.value = error?.message || '加载失败,请稍后重试'
|
||||||
|
} finally {
|
||||||
|
if (requestId === loadRequestId) {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectFirstAvailableTime = () => {
|
||||||
|
const first = timeOptions.value.find((item) => item.available_court_count > 0)
|
||||||
|
if (first) {
|
||||||
|
selectTime(first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectType = (type) => {
|
||||||
|
if (courtType.value === type) return
|
||||||
|
courtType.value = type
|
||||||
|
loadOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectDate = (date) => {
|
||||||
|
if (selectedDate.value === date) return
|
||||||
|
selectedDate.value = date
|
||||||
|
loadOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
const decreaseDuration = () => {
|
||||||
|
if (durationUnits.value <= 1) return
|
||||||
|
durationUnits.value -= 1
|
||||||
|
loadOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
const increaseDuration = () => {
|
||||||
|
if (durationUnits.value >= MAX_DURATION_UNITS) return
|
||||||
|
durationUnits.value += 1
|
||||||
|
loadOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectTime = (timeOption) => {
|
||||||
|
if (!timeOption.available_court_count) return
|
||||||
|
selectedTime.value = timeOption
|
||||||
|
selectedCourtIds.value = timeOption.available_courts.slice(0, 1).map((court) => court.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleCourt = (id) => {
|
||||||
|
if (selectedCourtIds.value.includes(id)) {
|
||||||
|
selectedCourtIds.value = selectedCourtIds.value.filter((item) => item !== id)
|
||||||
|
} else {
|
||||||
|
selectedCourtIds.value = [...selectedCourtIds.value, id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeClock = (value) => {
|
||||||
|
if (!value) return '--:--'
|
||||||
|
return value.slice(0, 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
const goConfirm = () => {
|
||||||
|
if (!canConfirm.value) return
|
||||||
|
const payload = encodeURIComponent(JSON.stringify({
|
||||||
|
court_type: courtType.value,
|
||||||
|
court_ids: selectedCourtIds.value,
|
||||||
|
start_at: selectedTime.value.start_at,
|
||||||
|
end_at: selectedTime.value.end_at
|
||||||
|
}))
|
||||||
|
uni.navigateTo({ url: `/pages/comprehensive/private/confirm?payload=${payload}` })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.private-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: $spacing-md;
|
||||||
|
background: $bg-color-soft;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-panel,
|
||||||
|
.section-card {
|
||||||
|
@include minimal-card;
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.court-section {
|
||||||
|
margin-bottom: 180rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tabs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: $spacing-sm;
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tab,
|
||||||
|
.date-chip,
|
||||||
|
.time-chip,
|
||||||
|
.court-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: $border-radius-base;
|
||||||
|
background: $bg-color-hover;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tab {
|
||||||
|
height: 72rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tab.active,
|
||||||
|
.date-chip.active,
|
||||||
|
.time-chip.active,
|
||||||
|
.court-chip.active {
|
||||||
|
background: $text-color-main;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.venue-meta {
|
||||||
|
min-height: 54rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-md;
|
||||||
|
border-top: 1rpx solid $bg-color-hover;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-section {
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-scroll {
|
||||||
|
width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-list {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
padding-right: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-chip {
|
||||||
|
width: 128rpx;
|
||||||
|
height: 92rpx;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6rpx;
|
||||||
|
background: $bg-color-main;
|
||||||
|
border: 1rpx solid $bg-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-week {
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-day {
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact {
|
||||||
|
margin: 0 0 $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-btn {
|
||||||
|
width: 88rpx;
|
||||||
|
height: 76rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: $border-radius-base;
|
||||||
|
background: $text-color-main;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 38rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-btn[disabled] {
|
||||||
|
background: $bg-color-hover;
|
||||||
|
color: $text-color-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-value {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-main,
|
||||||
|
.duration-sub,
|
||||||
|
.time-main,
|
||||||
|
.time-sub,
|
||||||
|
.selection-main,
|
||||||
|
.selection-sub {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-main {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-sub {
|
||||||
|
margin-top: 4rpx;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-box {
|
||||||
|
min-height: 140rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: $text-color-light;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-chip {
|
||||||
|
height: 94rpx;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-chip.disabled {
|
||||||
|
color: $text-color-light;
|
||||||
|
background: rgba(241, 245, 249, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-main {
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-sub {
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.court-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.court-chip {
|
||||||
|
min-height: 76rpx;
|
||||||
|
padding: 0 $spacing-sm;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-main {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-sub {
|
||||||
|
margin-top: 4rpx;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
width: 260rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<template>
|
||||||
|
<view class="balance-page container">
|
||||||
|
<view class="balance-card card">
|
||||||
|
<text class="label">当前余额</text>
|
||||||
|
<text class="amount">¥{{ balance.balance }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">流水明细</text>
|
||||||
|
</view>
|
||||||
|
<view v-for="record in records" :key="record.id" class="record-card card">
|
||||||
|
<view class="record-top">
|
||||||
|
<text class="kind">{{ record.kind_label }}</text>
|
||||||
|
<text class="record-amount" :class="{ income: Number(record.amount) > 0 }">{{ record.amount }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="remark">{{ record.remark }}</text>
|
||||||
|
<text class="time">{{ formatDateTime(record.created_at) }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="records.length === 0" class="empty-state">暂无余额流水</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
||||||
|
import { memberApi } from '@/modules/comprehensive/api/member'
|
||||||
|
import { formatDateTime } from '@/modules/comprehensive/utils/date'
|
||||||
|
|
||||||
|
const balance = ref({ balance: '0.00' })
|
||||||
|
const records = ref([])
|
||||||
|
|
||||||
|
onMounted(loadPage)
|
||||||
|
|
||||||
|
onPullDownRefresh(() => {
|
||||||
|
loadPage().finally(() => uni.stopPullDownRefresh())
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadPage = async () => {
|
||||||
|
const [balanceData, recordData] = await Promise.all([
|
||||||
|
memberApi.getBalance(),
|
||||||
|
memberApi.getBalanceRecords({ page: 1, pageSize: 30 })
|
||||||
|
])
|
||||||
|
balance.value = balanceData
|
||||||
|
records.value = recordData.items
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.balance-card {
|
||||||
|
min-height: 220rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label,
|
||||||
|
.remark,
|
||||||
|
.time {
|
||||||
|
display: block;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount {
|
||||||
|
margin-top: $spacing-sm;
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 64rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-card {
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-amount {
|
||||||
|
color: $color-danger;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-amount.income {
|
||||||
|
color: $color-success;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remark {
|
||||||
|
margin-top: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
margin-top: $spacing-xs;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
<template>
|
||||||
|
<view class="profile-detail page-bottom-safe" v-if="form">
|
||||||
|
<view class="profile-header">
|
||||||
|
<button class="avatar-button" open-type="chooseAvatar" @chooseavatar="chooseAvatar">
|
||||||
|
<image v-if="form.avatar_url" :src="form.avatar_url" mode="aspectFill" class="avatar" />
|
||||||
|
<view v-else class="avatar placeholder">
|
||||||
|
<text>{{ avatarInitial }}</text>
|
||||||
|
</view>
|
||||||
|
</button>
|
||||||
|
<text class="avatar-tip">更换头像</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section-card">
|
||||||
|
<view class="field-row">
|
||||||
|
<view class="field-main">
|
||||||
|
<text class="field-label">昵称</text>
|
||||||
|
<input
|
||||||
|
v-model="form.nickname"
|
||||||
|
class="nickname-input"
|
||||||
|
type="nickname"
|
||||||
|
placeholder="请输入昵称"
|
||||||
|
placeholder-class="input-placeholder"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="field-row">
|
||||||
|
<view class="field-main">
|
||||||
|
<text class="field-label">手机号</text>
|
||||||
|
<text class="field-value">{{ form.phone || '未绑定' }}</text>
|
||||||
|
</view>
|
||||||
|
<button class="inline-button" open-type="getPhoneNumber" @getphonenumber="bindPhone">
|
||||||
|
{{ form.phone ? '更换' : '绑定' }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="fixed-action-bar">
|
||||||
|
<button class="primary-button save-button" :disabled="isSaving || !canSave" @tap="saveProfile">
|
||||||
|
{{ isSaving ? '保存中...' : '保存资料' }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { memberApi } from '@/modules/comprehensive/api/member'
|
||||||
|
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
||||||
|
|
||||||
|
const session = useComprehensiveSessionStore()
|
||||||
|
const form = ref(null)
|
||||||
|
const isSaving = ref(false)
|
||||||
|
const isUploadingAvatar = ref(false)
|
||||||
|
|
||||||
|
const avatarInitial = computed(() => (form.value?.nickname || '用户').slice(0, 1))
|
||||||
|
const canSave = computed(() => {
|
||||||
|
return !!form.value?.nickname?.trim() || !!form.value?.avatar_url?.trim()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await session.init()
|
||||||
|
form.value = { ...(session.member || await memberApi.getMemberMe()) }
|
||||||
|
})
|
||||||
|
|
||||||
|
const chooseAvatar = async (event) => {
|
||||||
|
const avatarUrl = event.detail?.avatarUrl
|
||||||
|
if (!avatarUrl) {
|
||||||
|
uni.showToast({ title: '未获取到头像', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isUploadingAvatar.value) return
|
||||||
|
|
||||||
|
isUploadingAvatar.value = true
|
||||||
|
try {
|
||||||
|
const data = await memberApi.uploadAvatar(avatarUrl)
|
||||||
|
form.value = { ...data.member }
|
||||||
|
await session.refreshMember()
|
||||||
|
uni.showToast({ title: '头像已更新', icon: 'success' })
|
||||||
|
} finally {
|
||||||
|
isUploadingAvatar.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveProfile = async () => {
|
||||||
|
if (!canSave.value || isSaving.value) return
|
||||||
|
isSaving.value = true
|
||||||
|
try {
|
||||||
|
const data = await memberApi.updateMemberProfile({
|
||||||
|
nickname: form.value.nickname,
|
||||||
|
avatar_url: form.value.avatar_url
|
||||||
|
})
|
||||||
|
form.value = { ...data }
|
||||||
|
await session.refreshMember()
|
||||||
|
uni.showToast({ title: '已保存', icon: 'success' })
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindPhone = async (event) => {
|
||||||
|
const code = event.detail?.code
|
||||||
|
if (!code) {
|
||||||
|
uni.showToast({ title: '未获取到手机号授权', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = await memberApi.bindMemberPhone(code)
|
||||||
|
form.value = { ...data }
|
||||||
|
await session.refreshMember()
|
||||||
|
uni.showToast({ title: '手机号已绑定', icon: 'success' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.profile-detail {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: $spacing-md;
|
||||||
|
background: $bg-color-soft;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header {
|
||||||
|
min-height: 360rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-button {
|
||||||
|
width: 184rpx;
|
||||||
|
height: 184rpx;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 184rpx;
|
||||||
|
height: 184rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $bg-color-hover;
|
||||||
|
border: 6rpx solid $bg-color-main;
|
||||||
|
box-shadow: 0 12rpx 36rpx rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar.placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 56rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
background: $color-primary-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-tip {
|
||||||
|
margin-top: $spacing-md;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-card {
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
padding: 0 $spacing-md;
|
||||||
|
border: 1rpx solid $border-color;
|
||||||
|
border-radius: $border-radius-base;
|
||||||
|
background: $bg-color-main;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
min-height: 124rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-md;
|
||||||
|
border-bottom: 1rpx solid $bg-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label,
|
||||||
|
.field-value {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 23rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nickname-input,
|
||||||
|
.field-value {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 54rpx;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 31rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-placeholder {
|
||||||
|
color: $text-color-light;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-button {
|
||||||
|
min-width: 112rpx;
|
||||||
|
height: 58rpx;
|
||||||
|
padding: 0 $spacing-md;
|
||||||
|
border-radius: $border-radius-base;
|
||||||
|
background: $text-color-main;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
<template>
|
||||||
|
<view class="profile-page container">
|
||||||
|
<view v-if="!session.isLoggedIn" class="unlogged">
|
||||||
|
<text class="brand">星河综合运动中心</text>
|
||||||
|
<text class="desc">登录后查看余额、订单、门票权益与个人资料。</text>
|
||||||
|
<button class="primary-button login-btn" @tap="login">微信一键登录</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<block v-else>
|
||||||
|
<view class="user-profile-hero" @tap="go('/pages/comprehensive/profile/detail')">
|
||||||
|
<view class="hero-left">
|
||||||
|
<image v-if="session.member?.avatar_url" :src="session.member.avatar_url" mode="aspectFill" class="hero-avatar" />
|
||||||
|
<view v-else class="hero-avatar placeholder">
|
||||||
|
<text>{{ avatarInitial }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="hero-meta">
|
||||||
|
<text class="hero-name">{{ session.member?.nickname }}</text>
|
||||||
|
<view v-if="session.member?.phone" class="hero-sub-row">
|
||||||
|
<text class="hero-phone">{{ session.member.phone }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="hero-right">
|
||||||
|
<view class="hero-arrow" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="menu-group-card">
|
||||||
|
<view v-for="item in menuItems" :key="item.title" class="menu-item" @tap="go(item.url)">
|
||||||
|
<view class="item-left">
|
||||||
|
<view class="item-icon-container" :class="item.theme">
|
||||||
|
<view class="icon-order" v-if="item.icon === 'order'">
|
||||||
|
<view class="icon-order-line" />
|
||||||
|
<view class="icon-order-line short" />
|
||||||
|
</view>
|
||||||
|
<view class="icon-private" v-else-if="item.icon === 'private'">
|
||||||
|
<view class="icon-private-net" />
|
||||||
|
</view>
|
||||||
|
<view class="icon-ticket" v-else-if="item.icon === 'ticket'">
|
||||||
|
<view class="icon-ticket-dot" />
|
||||||
|
</view>
|
||||||
|
<view class="icon-profile" v-else>
|
||||||
|
<view class="icon-profile-head" />
|
||||||
|
<view class="icon-profile-body" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="menu-title">{{ item.title }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="item-right">
|
||||||
|
<view class="menu-arrow" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="menu-item logout" @tap="logout">
|
||||||
|
<view class="item-left">
|
||||||
|
<view class="item-icon-container logout-theme">
|
||||||
|
<view class="icon-logout">
|
||||||
|
<view class="icon-logout-door" />
|
||||||
|
<view class="icon-logout-arrow" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="menu-title">退出登录</text>
|
||||||
|
</view>
|
||||||
|
<view class="item-right">
|
||||||
|
<view class="menu-arrow" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
||||||
|
|
||||||
|
const session = useComprehensiveSessionStore()
|
||||||
|
const avatarInitial = computed(() => (session.member?.nickname || '用户').slice(0, 1))
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ title: '包场预约', url: '/pages/comprehensive/private/index', theme: 'private-theme', icon: 'private' },
|
||||||
|
{ title: '篮球门票', url: '/pages/comprehensive/tickets/index', theme: 'ticket-theme', icon: 'ticket' },
|
||||||
|
{ title: '我的订单', url: '/pages/comprehensive/profile/orders', theme: 'order-theme', icon: 'order' },
|
||||||
|
{ title: '个人信息', url: '/pages/comprehensive/profile/detail', theme: 'profile-theme', icon: 'profile' },
|
||||||
|
{ title: '切换场馆', url: '/pages/venue-select/index', theme: 'profile-theme', icon: 'profile' }
|
||||||
|
]
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await session.init()
|
||||||
|
})
|
||||||
|
|
||||||
|
const login = async () => {
|
||||||
|
await session.login()
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
session.logout()
|
||||||
|
uni.showToast({ title: '已退出', icon: 'none' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const go = (url) => {
|
||||||
|
uni.navigateTo({ url })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.unlogged {
|
||||||
|
min-height: 80vh;
|
||||||
|
padding-top: 180rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 42rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
margin-top: $spacing-md;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn {
|
||||||
|
width: 520rpx;
|
||||||
|
margin-top: 100rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-profile-hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 160rpx;
|
||||||
|
padding: $spacing-lg;
|
||||||
|
border: 1rpx solid rgba(241, 245, 249, 0.9);
|
||||||
|
border-radius: $border-radius-lg;
|
||||||
|
background-color: $bg-color-main;
|
||||||
|
box-shadow: 0 10rpx 40rpx rgba(15, 23, 42, 0.02);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-profile-hero:active {
|
||||||
|
opacity: 0.96;
|
||||||
|
transform: scale(0.99);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-md;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-avatar {
|
||||||
|
width: 104rpx;
|
||||||
|
height: 104rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $bg-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-avatar.placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
background: $color-primary-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6rpx;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-name,
|
||||||
|
.hero-phone,
|
||||||
|
.menu-title {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-name {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-phone {
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-arrow,
|
||||||
|
.menu-arrow {
|
||||||
|
width: 18rpx;
|
||||||
|
height: 18rpx;
|
||||||
|
border-top: 3rpx solid $text-color-light;
|
||||||
|
border-right: 3rpx solid $text-color-light;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-group-card {
|
||||||
|
margin-top: $spacing-lg;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1rpx solid rgba(241, 245, 249, 0.9);
|
||||||
|
border-radius: $border-radius-lg;
|
||||||
|
background-color: $bg-color-main;
|
||||||
|
box-shadow: 0 10rpx 40rpx rgba(15, 23, 42, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
min-height: 112rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 $spacing-lg;
|
||||||
|
border-bottom: 1rpx solid rgba(241, 245, 249, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-left,
|
||||||
|
.item-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-left {
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-icon-container {
|
||||||
|
width: 58rpx;
|
||||||
|
height: 58rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: $border-radius-base;
|
||||||
|
background: $bg-color-soft;
|
||||||
|
color: $text-color-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-icon-container.order-theme {
|
||||||
|
background: $color-primary-light;
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-icon-container.profile-theme {
|
||||||
|
background: $color-success-light;
|
||||||
|
color: $color-success;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-icon-container.private-theme {
|
||||||
|
background: rgba(14, 165, 233, 0.12);
|
||||||
|
color: #0284C7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-icon-container.ticket-theme {
|
||||||
|
background: rgba(245, 158, 11, 0.14);
|
||||||
|
color: #D97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-icon-container.logout-theme {
|
||||||
|
background: $color-danger-light;
|
||||||
|
color: $color-danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-order,
|
||||||
|
.icon-private,
|
||||||
|
.icon-ticket,
|
||||||
|
.icon-profile,
|
||||||
|
.icon-logout {
|
||||||
|
position: relative;
|
||||||
|
width: 32rpx;
|
||||||
|
height: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-order {
|
||||||
|
border: 3rpx solid currentColor;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-order::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -8rpx;
|
||||||
|
left: 7rpx;
|
||||||
|
width: 14rpx;
|
||||||
|
height: 10rpx;
|
||||||
|
border: 3rpx solid currentColor;
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: 4rpx 4rpx 0 0;
|
||||||
|
background: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-order-line {
|
||||||
|
position: absolute;
|
||||||
|
left: 7rpx;
|
||||||
|
right: 7rpx;
|
||||||
|
top: 10rpx;
|
||||||
|
height: 3rpx;
|
||||||
|
border-radius: 2rpx;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-order-line.short {
|
||||||
|
top: 19rpx;
|
||||||
|
right: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-private {
|
||||||
|
border: 3rpx solid currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-private::before,
|
||||||
|
.icon-private::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 5rpx;
|
||||||
|
right: 5rpx;
|
||||||
|
height: 3rpx;
|
||||||
|
border-radius: 2rpx;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-private::before {
|
||||||
|
top: 9rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-private::after {
|
||||||
|
bottom: 9rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-private-net {
|
||||||
|
position: absolute;
|
||||||
|
top: 3rpx;
|
||||||
|
bottom: 3rpx;
|
||||||
|
left: 12rpx;
|
||||||
|
width: 4rpx;
|
||||||
|
border-radius: 2rpx;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-ticket {
|
||||||
|
border: 3rpx solid currentColor;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
transform: rotate(-8deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-ticket::before,
|
||||||
|
.icon-ticket::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 9rpx;
|
||||||
|
bottom: 9rpx;
|
||||||
|
width: 3rpx;
|
||||||
|
border-radius: 2rpx;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-ticket::before {
|
||||||
|
left: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-ticket::after {
|
||||||
|
right: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-ticket-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: 12rpx;
|
||||||
|
left: 13rpx;
|
||||||
|
width: 6rpx;
|
||||||
|
height: 6rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-profile-head {
|
||||||
|
position: absolute;
|
||||||
|
top: 3rpx;
|
||||||
|
left: 10rpx;
|
||||||
|
width: 12rpx;
|
||||||
|
height: 12rpx;
|
||||||
|
border: 3rpx solid currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-profile-body {
|
||||||
|
position: absolute;
|
||||||
|
left: 5rpx;
|
||||||
|
right: 5rpx;
|
||||||
|
bottom: 3rpx;
|
||||||
|
height: 12rpx;
|
||||||
|
border: 3rpx solid currentColor;
|
||||||
|
border-radius: 16rpx 16rpx 4rpx 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-logout-door {
|
||||||
|
position: absolute;
|
||||||
|
left: 4rpx;
|
||||||
|
top: 4rpx;
|
||||||
|
width: 15rpx;
|
||||||
|
height: 24rpx;
|
||||||
|
border: 3rpx solid currentColor;
|
||||||
|
border-right: none;
|
||||||
|
border-radius: 4rpx 0 0 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-logout-arrow {
|
||||||
|
position: absolute;
|
||||||
|
right: 2rpx;
|
||||||
|
top: 14rpx;
|
||||||
|
width: 20rpx;
|
||||||
|
height: 3rpx;
|
||||||
|
border-radius: 2rpx;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-logout-arrow::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: -6rpx;
|
||||||
|
width: 10rpx;
|
||||||
|
height: 10rpx;
|
||||||
|
border-top: 3rpx solid currentColor;
|
||||||
|
border-right: 3rpx solid currentColor;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-title {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout .menu-title {
|
||||||
|
color: $color-danger;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
<template>
|
||||||
|
<view class="orders-page">
|
||||||
|
<view class="filter-panel">
|
||||||
|
<scroll-view scroll-x class="filter-scroll">
|
||||||
|
<view class="filter-row">
|
||||||
|
<view v-for="item in businessOptions" :key="item.value" class="filter-chip" :class="{ active: businessType === item.value }" @tap="selectBusiness(item.value)">
|
||||||
|
{{ item.label }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
<scroll-view scroll-x class="filter-scroll second">
|
||||||
|
<view class="filter-row">
|
||||||
|
<view v-for="item in statusOptions" :key="item.value" class="filter-chip" :class="{ active: orderStatus === item.value }" @tap="selectStatus(item.value)">
|
||||||
|
{{ item.label }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="list">
|
||||||
|
<view v-for="order in orders" :key="order.id" class="order-card card" @tap="goDetail(order.id)">
|
||||||
|
<view class="order-top">
|
||||||
|
<text class="business">{{ order.business_type_label }}</text>
|
||||||
|
<text class="badge" :class="statusBadgeClass(order.status)">{{ order.status_label }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="order-no">{{ order.order_no }}</text>
|
||||||
|
<view class="order-bottom">
|
||||||
|
<text class="time">{{ formatDateTime(order.created_at) }}</text>
|
||||||
|
<text class="amount">¥{{ order.amount }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="order.status === 'pending_pay'" class="actions" @tap.stop>
|
||||||
|
<button class="ghost-button small-btn" @tap="closeOrder(order.id)">取消订单</button>
|
||||||
|
<button class="primary-button small-btn" @tap="goPay(order.id)">去支付</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="isLoading" class="load-more-tip">加载中...</view>
|
||||||
|
<view v-else-if="orders.length === 0" class="empty-state">暂无订单</view>
|
||||||
|
<view v-else-if="!hasMore" class="load-more-tip">已加载全部</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||||
|
import { orderApi } from '@/modules/comprehensive/api/order'
|
||||||
|
import { formatDateTime } from '@/modules/comprehensive/utils/date'
|
||||||
|
import { statusBadgeClass } from '@/modules/comprehensive/utils/status'
|
||||||
|
|
||||||
|
const businessOptions = [
|
||||||
|
{ label: '全部', value: '' },
|
||||||
|
{ label: '课程', value: 'course' },
|
||||||
|
{ label: '包场', value: 'private_booking' },
|
||||||
|
{ label: '门票', value: 'ticket' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: '全部', value: '' },
|
||||||
|
{ label: '待支付', value: 'pending_pay' },
|
||||||
|
{ label: '已支付', value: 'paid' },
|
||||||
|
{ label: '已关闭', value: 'closed' },
|
||||||
|
{ label: '已退款', value: 'refunded' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const businessType = ref('')
|
||||||
|
const orderStatus = ref('')
|
||||||
|
const orders = ref([])
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = 20
|
||||||
|
const total = ref(0)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const isLoadingMore = ref(false)
|
||||||
|
|
||||||
|
const hasMore = computed(() => orders.value.length < total.value)
|
||||||
|
|
||||||
|
onMounted(() => loadOrders(true))
|
||||||
|
|
||||||
|
onPullDownRefresh(() => {
|
||||||
|
loadOrders(true).finally(() => uni.stopPullDownRefresh())
|
||||||
|
})
|
||||||
|
|
||||||
|
onReachBottom(() => {
|
||||||
|
if (!hasMore.value || isLoadingMore.value) return
|
||||||
|
page.value += 1
|
||||||
|
loadOrders(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadOrders = async (reset) => {
|
||||||
|
if (reset) {
|
||||||
|
page.value = 1
|
||||||
|
isLoading.value = true
|
||||||
|
} else {
|
||||||
|
isLoadingMore.value = true
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await orderApi.getOrders({
|
||||||
|
page: page.value,
|
||||||
|
pageSize,
|
||||||
|
businessType: businessType.value,
|
||||||
|
status: orderStatus.value
|
||||||
|
})
|
||||||
|
orders.value = reset ? data.items : [...orders.value, ...data.items]
|
||||||
|
total.value = data.total
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
isLoadingMore.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectBusiness = (value) => {
|
||||||
|
businessType.value = value
|
||||||
|
loadOrders(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectStatus = (value) => {
|
||||||
|
orderStatus.value = value
|
||||||
|
loadOrders(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const goDetail = (id) => {
|
||||||
|
uni.navigateTo({ url: `/pages/comprehensive/orders/detail?id=${id}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
const goPay = (id) => {
|
||||||
|
uni.navigateTo({ url: `/pages/comprehensive/pay/index?order_id=${id}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeOrder = async (id) => {
|
||||||
|
await orderApi.closeOrder(id)
|
||||||
|
uni.showToast({ title: '订单已取消', icon: 'none' })
|
||||||
|
loadOrders(true)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.orders-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $bg-color-soft;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-panel {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
padding: $spacing-md;
|
||||||
|
background: $bg-color-main;
|
||||||
|
border-bottom: 1rpx solid $border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-scroll {
|
||||||
|
width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-scroll.second {
|
||||||
|
margin-top: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-row {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip {
|
||||||
|
min-width: 118rpx;
|
||||||
|
height: 62rpx;
|
||||||
|
padding: 0 $spacing-sm;
|
||||||
|
border-radius: $border-radius-base;
|
||||||
|
background: $bg-color-hover;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip.active {
|
||||||
|
background: $text-color-main;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
padding: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-card {
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-top,
|
||||||
|
.order-bottom,
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.business {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-no,
|
||||||
|
.time {
|
||||||
|
display: block;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-no {
|
||||||
|
margin-top: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-bottom {
|
||||||
|
margin-top: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: $spacing-md;
|
||||||
|
padding-top: $spacing-md;
|
||||||
|
border-top: 1rpx solid $bg-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small-btn {
|
||||||
|
width: 180rpx;
|
||||||
|
height: 64rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<template>
|
||||||
|
<view class="tickets-page container">
|
||||||
|
<view class="remaining-card card">
|
||||||
|
<text class="label">当前可核销次数</text>
|
||||||
|
<text class="remaining">{{ account.remaining_count }} 次</text>
|
||||||
|
<button class="primary-button buy-btn" @tap="goBuy">购买篮球门票</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">权益记录</text>
|
||||||
|
</view>
|
||||||
|
<view v-for="record in account.records" :key="record.id" class="record-card card" @tap="goOrder(record)">
|
||||||
|
<view class="record-top">
|
||||||
|
<text class="order-no">{{ record.order_no }}</text>
|
||||||
|
<text class="badge" :class="statusBadgeClass(record.status)">{{ record.status_label }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="meta">{{ record.checked_in_at ? `核销时间 ${formatDateTime(record.checked_in_at)}` : `生成时间 ${formatDateTime(record.created_at)}` }}</text>
|
||||||
|
<text class="meta">订单状态:{{ record.order_status }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="account.records.length === 0" class="empty-state">暂无门票权益</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
||||||
|
import { ticketApi } from '@/modules/comprehensive/api/ticket'
|
||||||
|
import { formatDateTime } from '@/modules/comprehensive/utils/date'
|
||||||
|
import { statusBadgeClass } from '@/modules/comprehensive/utils/status'
|
||||||
|
|
||||||
|
const account = ref({ remaining_count: 0, records: [] })
|
||||||
|
|
||||||
|
onMounted(loadPage)
|
||||||
|
|
||||||
|
onPullDownRefresh(() => {
|
||||||
|
loadPage().finally(() => uni.stopPullDownRefresh())
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadPage = async () => {
|
||||||
|
account.value = await ticketApi.getTicketAccount({ page: 1, pageSize: 50 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const goBuy = () => {
|
||||||
|
uni.navigateTo({ url: '/pages/comprehensive/tickets/index' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const goOrder = (record) => {
|
||||||
|
if (!record.order_id) return
|
||||||
|
uni.navigateTo({ url: `/pages/comprehensive/orders/detail?id=${record.order_id}` })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.remaining-card {
|
||||||
|
min-height: 250rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label,
|
||||||
|
.meta {
|
||||||
|
display: block;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remaining {
|
||||||
|
display: block;
|
||||||
|
margin: $spacing-sm 0 $spacing-lg;
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 58rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buy-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-card {
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-no {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
margin-top: $spacing-sm;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<template>
|
||||||
|
<view class="tickets-page container page-bottom-safe" v-if="product">
|
||||||
|
<view class="product-card card">
|
||||||
|
<view class="product-head">
|
||||||
|
<view>
|
||||||
|
<text class="product-title">篮球门票</text>
|
||||||
|
<text class="product-subtitle">购买后进入会员权益账户,到馆由前台核销。</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="price-block">
|
||||||
|
<text class="unit-price">¥{{ product.unit_price }}</text>
|
||||||
|
<text class="unit-label">/ 次</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card count-card">
|
||||||
|
<text class="card-title">购买次数</text>
|
||||||
|
<view class="stepper-row">
|
||||||
|
<button class="step-btn" @tap="decrease">-</button>
|
||||||
|
<text class="quantity">{{ quantity }}</text>
|
||||||
|
<button class="step-btn" @tap="increase">+</button>
|
||||||
|
</view>
|
||||||
|
<view class="amount-row">
|
||||||
|
<text>合计金额</text>
|
||||||
|
<text>¥{{ totalAmount }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="fixed-action-bar">
|
||||||
|
<view class="bar-price">
|
||||||
|
<text class="bar-label">合计</text>
|
||||||
|
<text class="price-text">¥{{ totalAmount }}</text>
|
||||||
|
</view>
|
||||||
|
<button class="primary-button submit-btn" :disabled="isSubmitting || !product.is_active" @tap="submit">
|
||||||
|
立即购买
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { ticketApi } from '@/modules/comprehensive/api/ticket'
|
||||||
|
import { multiplyAmount } from '@/modules/comprehensive/utils/money'
|
||||||
|
|
||||||
|
const product = ref(null)
|
||||||
|
const quantity = ref(1)
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
|
||||||
|
const totalAmount = computed(() => product.value ? multiplyAmount(product.value.unit_price, quantity.value) : '0.00')
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
product.value = await ticketApi.getTicketProduct()
|
||||||
|
})
|
||||||
|
|
||||||
|
const decrease = () => {
|
||||||
|
quantity.value = Math.max(1, quantity.value - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const increase = () => {
|
||||||
|
quantity.value += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (isSubmitting.value || !product.value?.is_active) return
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const data = await ticketApi.createTicketOrder(quantity.value)
|
||||||
|
uni.navigateTo({ url: `/pages/comprehensive/pay/index?order_id=${data.order.id}` })
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.product-card,
|
||||||
|
.count-card {
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-head,
|
||||||
|
.amount-row,
|
||||||
|
.stepper-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-title,
|
||||||
|
.card-title {
|
||||||
|
display: block;
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-subtitle {
|
||||||
|
display: block;
|
||||||
|
margin-top: $spacing-xs;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-block {
|
||||||
|
margin-top: $spacing-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-price {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 64rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-label {
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepper-row {
|
||||||
|
margin: $spacing-lg 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-btn {
|
||||||
|
width: 86rpx;
|
||||||
|
height: 70rpx;
|
||||||
|
border-radius: $border-radius-base;
|
||||||
|
background: $text-color-main;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 38rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quantity {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 42rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-row {
|
||||||
|
padding-top: $spacing-md;
|
||||||
|
border-top: 1rpx solid $bg-color-hover;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-row text:last-child {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-price {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-label {
|
||||||
|
display: block;
|
||||||
|
color: $text-color-light;
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
width: 320rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
After Width: | Height: | Size: 433 B |
|
After Width: | Height: | Size: 429 B |
|
After Width: | Height: | Size: 350 B |
|
After Width: | Height: | Size: 347 B |
|
After Width: | Height: | Size: 496 B |
|
After Width: | Height: | Size: 493 B |
|
After Width: | Height: | Size: 469 B |
|
After Width: | Height: | Size: 466 B |