feat: migrate ice venue module

This commit is contained in:
gqt
2026-06-08 21:00:32 +08:00
parent 8e1443f7e5
commit 0f4e04f4ad
28 changed files with 5684 additions and 0 deletions
@@ -0,0 +1,63 @@
import { request } from './request'
const toNumber = (value) => {
const numberValue = Number(value)
return Number.isFinite(numberValue) ? numberValue : 0
}
const toBookingCourse = (backendCourse = {}) => ({
id: backendCourse.id ?? null,
title: backendCourse.title || '',
start_time: backendCourse.start_time || '',
end_time: backendCourse.end_time || '',
coach_name: backendCourse.coach_name || '',
capacity: toNumber(backendCourse.capacity),
booked_count: toNumber(backendCourse.booked_count)
})
const toBookingCard = (backendCard) => {
if (!backendCard) return null
return {
id: backendCard.id ?? null,
card_name: backendCard.card_name || '',
remained_detail: backendCard.remained_detail || ''
}
}
const toBookingViewModel = (backendBooking = {}) => ({
id: backendBooking.id,
status: backendBooking.status || 'booked',
checked_in_at: backendBooking.checked_in_at || null,
cancelled_at: backendBooking.cancelled_at || null,
created_at: backendBooking.created_at || '',
course: toBookingCourse(backendBooking.course),
card: toBookingCard(backendBooking.card)
})
const toPaginatedBookings = (backendData = {}) => ({
items: Array.isArray(backendData.items) ? backendData.items.map(toBookingViewModel) : [],
total: toNumber(backendData.total),
page: toNumber(backendData.page) || 1,
page_size: toNumber(backendData.page_size) || 20
})
const toQuery = (params = {}) => ({
status: params.status || 'all',
page: params.page || 1,
page_size: params.pageSize || 20
})
export const bookingApi = {
getBookings(params = {}) {
return request({
url: '/api/bookings',
data: toQuery(params)
}).then(toPaginatedBookings)
},
getBookingDetail(id) {
return request({
url: `/api/bookings/${id}`
}).then(toBookingViewModel)
}
}
+61
View File
@@ -0,0 +1,61 @@
import { request } from './request'
const toNumber = (value) => {
const numberValue = Number(value)
return Number.isFinite(numberValue) ? numberValue : 0
}
const toMemberCard = (backendCard = {}) => ({
id: backendCard.id,
card_no: backendCard.card_no || '',
card_name: backendCard.card_name || '',
card_type: backendCard.card_type || 'times',
backend_card_type: backendCard.backend_card_type || '',
status: backendCard.status || 'active',
total_times: toNumber(backendCard.total_times),
remained_times: toNumber(backendCard.remained_times),
original_balance: backendCard.original_balance || '0.00',
gift_balance: backendCard.gift_balance || '0.00',
balance: backendCard.balance || '0.00',
start_date: backendCard.start_date || '',
expire_date: backendCard.expire_date || '',
remained_detail: backendCard.remained_detail || ''
})
const toCardChange = (backendChange = {}) => ({
id: backendChange.id,
change_type: backendChange.change_type || 'adjustment',
backend_change_type: backendChange.backend_change_type || '',
delta: backendChange.delta || '0',
post_times: toNumber(backendChange.post_times),
post_balance: backendChange.post_balance || '0.00',
memo: backendChange.memo || '',
created_at: backendChange.created_at || ''
})
const toCardHistory = (backendData = {}) => ({
card: toMemberCard(backendData.card),
items: Array.isArray(backendData.items) ? backendData.items.map(toCardChange) : [],
total: toNumber(backendData.total),
page: toNumber(backendData.page) || 1,
page_size: toNumber(backendData.page_size) || 20
})
export const cardApi = {
getCards(status = 'all') {
return request({
url: '/api/members/me/cards',
data: { status }
}).then((data = {}) => (Array.isArray(data.items) ? data.items.map(toMemberCard) : []))
},
getCardChanges(cardId, params = {}) {
return request({
url: `/api/members/me/cards/${cardId}/changes`,
data: {
page: params.page || 1,
page_size: params.pageSize || 20
}
}).then(toCardHistory)
}
}
@@ -0,0 +1,17 @@
import { request } from './request'
const toActivityDetail = (backendActivity = {}) => ({
id: backendActivity.id,
title: backendActivity.title || '',
cover: backendActivity.cover || '',
created_at: backendActivity.created_at || '',
content_html: backendActivity.content_html || ''
})
export const contentApi = {
getActivityDetail(id) {
return request({
url: `/api/contents/activities/${id}`
}).then(toActivityDetail)
}
}
+68
View File
@@ -0,0 +1,68 @@
import { request } from './request'
const toNumber = (value) => {
const numberValue = Number(value)
return Number.isFinite(numberValue) ? numberValue : 0
}
const toCoach = (backendCoach = {}) => ({
id: backendCoach.id ?? null,
name: backendCoach.name || ''
})
export const toCourseViewModel = (backendCourse = {}) => {
const coach = toCoach(backendCourse.coach)
return {
id: backendCourse.id,
title: backendCourse.title || '',
start_time: backendCourse.start_time || '',
end_time: backendCourse.end_time || '',
coach,
coach_name: backendCourse.coach_name || coach.name,
capacity: toNumber(backendCourse.capacity),
booked_count: toNumber(backendCourse.booked_count),
available_count: toNumber(backendCourse.available_count),
is_booked: backendCourse.is_booked === true,
my_booking_id: backendCourse.my_booking_id ?? null,
cover: backendCourse.cover || '',
detail_html: backendCourse.detail_html || '',
}
}
const toPaginatedCourses = (backendData = {}) => ({
items: Array.isArray(backendData.items) ? backendData.items.map(toCourseViewModel) : [],
total: toNumber(backendData.total),
page: toNumber(backendData.page) || 1,
page_size: toNumber(backendData.page_size) || 20
})
const toQuery = (params = {}) => {
const data = {}
if (params.date) data.date = params.date
if (params.coachId) data.coach_id = params.coachId
if (params.page) data.page = params.page
if (params.pageSize) data.page_size = params.pageSize
return data
}
export const courseApi = {
getCoaches(status = 'active') {
return request({
url: '/api/coaches',
data: { status }
}).then((data = {}) => (Array.isArray(data.items) ? data.items.map(toCoach) : []))
},
getCourses(params = {}) {
return request({
url: '/api/courses',
data: toQuery(params)
}).then(toPaginatedCourses)
},
getCourseDetail(id) {
return request({
url: `/api/courses/${id}`
}).then(toCourseViewModel)
}
}
+52
View File
@@ -0,0 +1,52 @@
import { request } from './request'
import { toCourseViewModel } from './course'
const toNumber = (value) => {
const numberValue = Number(value)
return Number.isFinite(numberValue) ? numberValue : 0
}
const toBanner = (backendBanner = {}) => ({
id: backendBanner.id,
title: backendBanner.title || '',
image: backendBanner.image || '',
link_type: backendBanner.link_type || 'none',
link_value: backendBanner.link_value || '',
sort_order: toNumber(backendBanner.sort_order)
})
const toActivitySummary = (backendActivity = {}) => ({
id: backendActivity.id,
title: backendActivity.title || '',
cover: backendActivity.cover || '',
created_at: backendActivity.created_at || ''
})
const toHomeViewModel = (backendData = {}) => ({
banners: Array.isArray(backendData.banners) ? backendData.banners.map(toBanner) : [],
upcomingCourses: Array.isArray(backendData.upcoming_courses)
? backendData.upcoming_courses.map(toCourseViewModel)
: [],
activities: Array.isArray(backendData.activities) ? backendData.activities.map(toActivitySummary) : [],
venue: {
name: backendData.venue?.name || '奥林匹克滑冰中心',
}
})
export const homeApi = {
getHome(options = {}) {
const data = {
course_limit: options.courseLimit || 2,
banner_limit: options.bannerLimit || 5
}
if (options.activityLimit !== undefined) {
data.activity_limit = options.activityLimit
}
if (options.courseDate) data.course_date = options.courseDate
return request({
url: '/api/contents/home',
data
}).then(toHomeViewModel)
}
}
+151
View File
@@ -0,0 +1,151 @@
import { authStorage, request } from './request'
// 防腐层:小程序端只在本文件感知后端字段名,页面层统一使用下方 mapper 输出的前端模型。
export const memberBackendContract = Object.freeze({
wechatLoginResponse: [
'access_token',
'token_type',
'expires_in',
'member_bound',
'member'
],
registerOrBindRequest: [
'phone_code',
'phone',
'name',
'gender',
'birthday'
],
registerOrBindResponse: [
'created',
'bound',
'member'
],
memberSummary: [
'id',
'name',
'phone',
'gender',
'birthday',
'joined_at'
],
memberProfile: [
'id',
'name',
'phone',
'gender',
'gender_label',
'birthday',
'joined_at',
'sales.id',
'sales.name',
'card_summary.total',
'card_summary.active',
'card_summary.disabled',
'card_summary.expired'
]
})
const GENDER_LABELS = Object.freeze({
male: '男',
female: '女',
unknown: '未设置'
})
const emptyCardSummary = () => ({
total: 0,
active: 0,
disabled: 0,
expired: 0
})
const toNumber = (value) => {
const numberValue = Number(value)
return Number.isFinite(numberValue) ? numberValue : 0
}
const toMemberCardSummary = (backendCardSummary = {}) => ({
total: toNumber(backendCardSummary.total),
active: toNumber(backendCardSummary.active),
disabled: toNumber(backendCardSummary.disabled),
expired: toNumber(backendCardSummary.expired)
})
const toMemberViewModel = (backendMember, options = {}) => {
if (!backendMember || !backendMember.id) return null
const cardSummary = options.withProfileFields
? toMemberCardSummary(backendMember.card_summary)
: emptyCardSummary()
return {
id: backendMember.id,
name: backendMember.name || '',
phone: backendMember.phone || '',
gender: backendMember.gender || 'unknown',
genderLabel: backendMember.gender_label || GENDER_LABELS[backendMember.gender] || GENDER_LABELS.unknown,
birthday: backendMember.birthday || '',
joinDate: backendMember.joined_at || '',
salesId: backendMember.sales?.id || null,
salesName: backendMember.sales?.name || '无',
cardSummary,
cardCount: cardSummary.total
}
}
const toWechatLoginViewModel = (backendData = {}) => ({
accessToken: backendData.access_token || '',
tokenType: backendData.token_type || 'Bearer',
expiresIn: toNumber(backendData.expires_in),
memberBound: backendData.member_bound === true,
member: toMemberViewModel(backendData.member)
})
const toRegisterOrBindPayload = (formData = {}) => ({
phone_code: formData.phoneCode || null,
phone: formData.phone || null,
name: formData.name || null,
gender: formData.gender || 'unknown',
birthday: formData.birthday || null
})
const toRegisterOrBindViewModel = (backendData = {}) => ({
created: backendData.created === true,
bound: backendData.bound === true,
member: toMemberViewModel(backendData.member)
})
const toMemberProfileViewModel = (backendData = {}) => {
return toMemberViewModel(backendData, { withProfileFields: true })
}
// 登录注册与绑定 API 接口组
export const api = {
// 1. 微信小程序登录 (用 code 换 token)
wechatLogin(code) {
return request({
url: '/api/auth/wechat-login',
method: 'POST',
data: { code }
}).then(toWechatLoginViewModel)
},
// 2. 绑定或注册会员
registerOrBind(data) {
return request({
url: '/api/auth/register-or-bind',
method: 'POST',
data: toRegisterOrBindPayload(data)
}).then(toRegisterOrBindViewModel)
},
// 3. 获取当前登录会员信息
getMemberMe() {
return request({
url: '/api/members/me',
method: 'GET'
}).then(toMemberProfileViewModel)
}
}
export { authStorage, request }
+134
View File
@@ -0,0 +1,134 @@
const KEY_TOKEN = 'ICE_MEMBER_TOKEN'
const KEY_TOKEN_EXPIRES_AT = 'ICE_MEMBER_TOKEN_EXPIRES_AT'
const API_BASE_URL = import.meta.env.VITE_ICE_API_BASE_URL || 'http://127.0.0.1:8000'
export const authStorage = {
setToken(token, expiresIn) {
uni.setStorageSync(KEY_TOKEN, token)
if (expiresIn) {
uni.setStorageSync(KEY_TOKEN_EXPIRES_AT, Date.now() + Number(expiresIn) * 1000)
}
},
getToken() {
return uni.getStorageSync(KEY_TOKEN)
},
isTokenExpired() {
const expiresAt = Number(uni.getStorageSync(KEY_TOKEN_EXPIRES_AT) || 0)
return expiresAt > 0 && Date.now() >= expiresAt
},
clear() {
uni.removeStorageSync(KEY_TOKEN)
uni.removeStorageSync(KEY_TOKEN_EXPIRES_AT)
}
}
export const normalizeApiError = (rawError = {}) => {
const statusCode = rawError.statusCode
const body = rawError.data || rawError
const code = body?.code
const backendMessage = body?.message || ''
if (statusCode === 401 || code === 40001) {
return {
type: 'auth_expired',
code,
statusCode,
message: '登录状态已过期,请重新登录',
raw: rawError
}
}
if (code === 40002) {
return {
type: 'profile_required',
code,
statusCode,
message: '请先完善会员资料',
raw: rawError
}
}
if (code === 40003) {
return {
type: 'bind_conflict',
code,
statusCode,
message: backendMessage || '该手机号已绑定其他微信账号,请联系场馆前台处理',
raw: rawError
}
}
if (!statusCode) {
return {
type: 'network_error',
code,
statusCode,
message: '网络连接失败,请检查后重试',
raw: rawError
}
}
if (statusCode === 404 || code === 40400) {
return {
type: 'not_found',
code,
statusCode,
message: backendMessage || '未找到对应信息或无访问权限',
raw: rawError
}
}
return {
type: 'api_error',
code,
statusCode,
message: backendMessage || '服务暂时不可用,请稍后重试',
raw: rawError
}
}
export function request(options = {}) {
const token = authStorage.getToken()
const header = {
'Content-Type': 'application/json',
...(options.header || {})
}
if (token) {
header.Authorization = `Bearer ${token}`
}
return new Promise((resolve, reject) => {
uni.request({
url: options.url.startsWith('http') ? options.url : `${API_BASE_URL}${options.url}`,
method: options.method || 'GET',
data: options.data,
header,
success: (res) => {
const body = res.data
if (res.statusCode >= 200 && res.statusCode < 300) {
if (body && body.code === 0) {
resolve(body.data)
return
}
reject(normalizeApiError({
statusCode: res.statusCode,
data: body
}))
return
}
reject(normalizeApiError({
statusCode: res.statusCode,
data: body
}))
},
fail: (err) => {
reject(normalizeApiError(err))
}
})
})
}
+108
View File
@@ -0,0 +1,108 @@
import { api, authStorage } from './member'
export const SESSION_STATUS = Object.freeze({
CHECKING: 'checking',
GUEST: 'guest',
EXPIRED: 'expired',
PROFILE_REQUIRED: 'profile_required',
MEMBER: 'member',
NETWORK_ERROR: 'network_error'
})
const createLoginCode = () => new Promise((resolve, reject) => {
uni.login({
provider: 'weixin',
success: (loginRes) => {
if (loginRes.code) {
resolve(loginRes.code)
return
}
reject({
type: 'login_error',
message: '微信登录没有返回有效凭证,请重新尝试'
})
},
fail: () => {
reject({
type: 'login_error',
message: '微信登录失败,请稍后重试'
})
}
})
})
export const loginAndStoreToken = async () => {
const code = await createLoginCode()
const data = await api.wechatLogin(code)
if (!data?.accessToken) {
throw {
type: 'login_error',
message: '登录失败,请稍后重试'
}
}
authStorage.setToken(data.accessToken, data.expiresIn)
return data
}
export const getMemberSession = async () => {
const token = authStorage.getToken()
if (!token) {
return { status: SESSION_STATUS.GUEST }
}
if (authStorage.isTokenExpired()) {
authStorage.clear()
return {
status: SESSION_STATUS.EXPIRED,
message: '登录状态已过期,请重新登录'
}
}
try {
const member = await api.getMemberMe()
return {
status: SESSION_STATUS.MEMBER,
member
}
} catch (err) {
if (err?.type === 'profile_required' || err?.code === 40002) {
return {
status: SESSION_STATUS.PROFILE_REQUIRED,
message: '请先完善会员资料'
}
}
if (err?.type === 'auth_expired' || err?.code === 40001) {
authStorage.clear()
return {
status: SESSION_STATUS.EXPIRED,
message: '登录状态已过期,请重新登录'
}
}
return {
status: SESSION_STATUS.NETWORK_ERROR,
message: err?.message || '暂时无法连接服务,请稍后重试',
error: err
}
}
}
export const isSessionBlockingError = (err) => {
return ['auth_expired', 'profile_required'].includes(err?.type) || [40001, 40002].includes(err?.code)
}
export const sessionStatusFromError = (err) => {
if (err?.type === 'auth_expired' || err?.code === 40001) {
authStorage.clear()
return SESSION_STATUS.EXPIRED
}
if (err?.type === 'profile_required' || err?.code === 40002) {
return SESSION_STATUS.PROFILE_REQUIRED
}
return ''
}
export const errorMessage = (err, fallback = '操作失败,请稍后重试') => {
return err?.message || fallback
}