feat: migrate comprehensive venue module

This commit is contained in:
gqt
2026-06-08 20:54:25 +08:00
parent c9650b0db1
commit 8e1443f7e5
43 changed files with 5017 additions and 0 deletions
@@ -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
}
+121
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 433 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 493 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 B