Files
gym-manage/gym-manage-uniapp/pages/search/search.vue
T

520 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="search-page">
<view class="header-wrap" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
<text class="nav-title">&#8981; 团课搜索</text>
</view>
<view class="search-area">
<view class="search-box">
<text class="search-symbol">&#8981;</text>
<input class="search-input" v-model="searchKeyword" placeholder="搜索课程名称、教练、场地..."
placeholder-style="color: rgba(255,255,255,0.35);" @input="onSearchInput" />
<text v-if="searchKeyword" class="clear-btn" @click="clearSearch">&#10005;</text>
</view>
<view class="date-range-row">
<picker mode="date" :value="startDate" @change="onStartDateChange" :end="endDate || '2099-12-31'">
<view class="date-range-item">
<text class="range-label">开始</text>
<text class="range-value" :class="{ placeholder: !startDate }">{{ startDate || '选择日期' }}</text>
</view>
</picker>
<text class="range-sep"></text>
<picker mode="date" :value="endDate" @change="onEndDateChange" :start="startDate || '2020-01-01'">
<view class="date-range-item">
<text class="range-label">结束</text>
<text class="range-value" :class="{ placeholder: !endDate }">{{ endDate || '选择日期' }}</text>
</view>
</picker>
<text v-if="startDate || endDate" class="date-clear" @click="clearDateRange">&#10005;</text>
</view>
<view class="period-row">
<view v-for="(period, idx) in periods" :key="idx" class="period-item"
:class="{ active: currentPeriod === period.value }" @click="selectPeriod(period.value)">
<text>{{ period.label }}</text>
</view>
</view>
</view>
</view>
<view class="type-section">
<view class="type-all-row">
<view class="filter-tab" :class="{ active: currentFilter === 'all' }" @click="switchFilter('all')">全部</view>
</view>
<view class="type-grid" :class="{ expanded: typesExpanded }">
<view v-for="(row, ri) in typeRows" :key="ri" class="type-row" :class="{ 'row-visible': isRowVisible(ri) }">
<view v-for="(tab, ti) in row" :key="ti" class="filter-tab"
:class="{ active: currentFilter === tab.value }" @click="selectType(tab)">
{{ tab.label }}
</view>
</view>
</view>
<view v-if="typeRows.length > 1" class="type-expand-row">
<text class="type-expand-btn" @click="typesExpanded = !typesExpanded">
{{ typesExpanded ? '收起 ' : '展开更多 ' }}
</text>
</view>
</view>
<view class="sort-bar">
<text class="result-count"> {{ filteredCourses.length }} 个课程</text>
<view class="sort-options">
<text v-for="(sort, idx) in sortOptions" :key="idx" class="sort-item"
:class="{ active: currentSort === sort.value }" @click="switchSort(sort.value)">
{{ sort.label }}
</text>
</view>
</view>
<scroll-view class="course-list" scroll-y :style="{ height: 'calc(100vh - ' + totalHeaderHeight + 'px - 104px)' }">
<view class="course-list-inner">
<view v-for="(course, idx) in filteredCourses" :key="idx" class="course-item" @click="goToDetail(course)">
<view class="course-card">
<view class="course-cover">
<view class="cover-skeleton" v-if="course.coverImage && !course.coverLoaded && !course.coverError"></view>
<image v-if="course.coverImage" class="cover-img" :class="{ loaded: course.coverLoaded }" :src="course.coverImage" mode="aspectFill" @load="onCoverLoad(course)" @error="onCoverError(course)"></image>
<view class="cover-placeholder" v-if="(!course.coverImage || course.coverError)">
<text class="cover-icon">&#9963;</text>
<text class="cover-type-name">{{ course.typeName }}</text>
</view>
<view class="cover-overlay">
<view class="cover-badge diff-badge" :class="'diff-' + course.difficultyLevel">
<text class="diff-badge-star" v-for="s in course.difficultyStars" :key="s">&#9733;</text>
<text class="diff-badge-label">{{ course.difficultyLabel }}</text>
</view>
</view>
</view>
<view class="course-body">
<view class="course-top">
<text class="course-name">{{ course.courseName }}</text>
<text class="course-price" :class="{ free: course.storedValueAmount === 0 }">
{{ course.storedValueAmount > 0 ? '¥' + course.storedValueAmount + '/次' : '免费' }}
</text>
</view>
<view class="course-labels" v-if="course.labels && course.labels.length">
<text v-for="(label, li) in course.labels" :key="li" class="course-label"
:style="{ background: label.color + '1a', color: label.color }">{{ label.labelName }}</text>
</view>
<view class="course-meta">
<text class="meta-icon">&#128339;</text>
<text class="course-date">{{ course.shortDate }}</text>
<text class="course-time">{{ course.shortTime }} - {{ course.endShortTime }}</text>
<text class="course-duration" v-if="course.duration">{{ course.duration }}</text>
</view>
<view class="course-meta">
<text class="meta-icon">&#128205;</text>
<text class="course-location">{{ course.location }}</text>
</view>
<view class="course-bottom">
<text class="course-capacity">已预约 {{ course.currentMembers }}/{{ course.maxMembers }}</text>
<button class="btn-book" :class="{ disabled: !course.canBook }" :disabled="!course.canBook"
@click.stop="bookCourse(course)">
{{ course.canBook ? '立即预约' : course.statusLabel }}
</button>
</view>
</view>
</view>
</view>
<view v-if="filteredCourses.length === 0" class="empty-state">
<text class="empty-symbol">&#8857;</text>
<text class="empty-text">暂无匹配的课程</text>
</view>
<view class="bottom-safe"></view>
</view>
</scroll-view>
</view>
</template>
<script>
const courseApi = require('../../api/course')
const bookingApi = require('../../api/booking')
const store = require('../../store/index')
const { resolveCoverUrl } = require('../../utils/request')
export default {
data() {
return {
statusBarHeight: 0,
navBarHeight: 44,
totalHeaderHeight: 44,
loading: true,
searchKeyword: '', startDate: '', endDate: '',
currentPeriod: 'all', currentFilter: 'all', currentSort: 'time',
periods: [
{ label: '全部', value: 'all' },
{ label: '早晨', value: 'morning' },
{ label: '上午', value: 'forenoon' },
{ label: '下午', value: 'afternoon' },
{ label: '晚间', value: 'evening' }
],
courseTypes: [], // 从后端获取的团课类型列表
typesExpanded: false, // 类型列表是否展开
visibleRowStart: 0, // 折叠时显示的起始行索引
COLS_PER_ROW: 4, // 每行类型数量
sortOptions: [
{ label: '时间', value: 'time' },
{ label: '难度', value: 'difficulty' },
{ label: '热度', value: 'popular' }
],
courses: [],
activeBookedCourseIds: new Set() // 用户当前已预约(status=0)的课程ID集合
}
},
onLoad() {
const systemInfo = uni.getSystemInfoSync()
const statusBarHeight = systemInfo.statusBarHeight || 20
let navBarHeight = 44
// #ifdef MP-WEIXIN
const menuButton = uni.getMenuButtonBoundingClientRect()
if (menuButton) {
navBarHeight = (menuButton.top - statusBarHeight) * 2 + menuButton.height
}
// #endif
this.statusBarHeight = statusBarHeight
this.navBarHeight = navBarHeight
this.totalHeaderHeight = statusBarHeight + navBarHeight
this.loadCourses()
this.loadTypes()
},
onShow() {
this.loadActiveBookings()
},
onPullDownRefresh() {
Promise.all([this.loadCourses(), this.loadTypes(), this.loadActiveBookings()])
.finally(() => { uni.stopPullDownRefresh() })
},
computed: {
// 将类型列表按每行 COLS_PER_ROW 个拆分为二维数组
typeRows() {
const tabs = this.courseTypes.map(t => ({
label: t.typeName,
value: String(t.id)
}))
const rows = []
for (let i = 0; i < tabs.length; i += this.COLS_PER_ROW) {
rows.push(tabs.slice(i, i + this.COLS_PER_ROW))
}
return rows
},
filteredCourses() {
let list = this.courses.map(c => {
const bd = this.getTypeDifficulty(c.courseType)
const st = c.startTime || ''
const et = c.endTime || ''
// 兼容 "2026-07-15T16:45:00" 和 "2026-07-15 16:45:00" 两种格式
const dt = st ? new Date(st.replace(/ /, 'T')) : null
const edt = et ? new Date(et.replace(/ /, 'T')) : null
const hh = dt && !isNaN(dt) ? String(dt.getHours()).padStart(2, '0') : '--'
const mm = dt && !isNaN(dt) ? String(dt.getMinutes()).padStart(2, '0') : '--'
const md = dt && !isNaN(dt) ? String(dt.getMonth() + 1).padStart(2, '0') : '--'
const dd = dt && !isNaN(dt) ? String(dt.getDate()).padStart(2, '0') : '--'
const ehh = edt && !isNaN(edt) ? String(edt.getHours()).padStart(2, '0') : '--'
const emm = edt && !isNaN(edt) ? String(edt.getMinutes()).padStart(2, '0') : '--'
// 计算持续时间(分钟)
let durationStr = ''
if (dt && edt && !isNaN(dt) && !isNaN(edt)) {
const diffMin = Math.round((edt - dt) / 60000)
if (diffMin > 0) {
const hours = Math.floor(diffMin / 60)
const mins = diffMin % 60
durationStr = hours > 0 ? hours + '小时' + (mins > 0 ? mins + '分钟' : '') : diffMin + '分钟'
}
}
const statusStr = c.status != null ? Number(c.status) : 0
const curMembers = c.currentMembers != null ? c.currentMembers : 0
const maxMems = c.maxMembers != null ? c.maxMembers : 0
const isFull = maxMems > 0 && curMembers >= maxMems
let statusLabel = '正常'
let canBook = statusStr === 0 && !isFull
if (statusStr === 1) statusLabel = '已取消'
else if (statusStr === 2) statusLabel = '已结束'
else if (statusStr !== 0) statusLabel = '已关闭'
if (!canBook && statusStr === 0 && isFull) statusLabel = '已约满'
const diffLevel = bd <= 3 ? 'low' : bd <= 6 ? 'mid' : 'high'
const diffLabel = bd <= 3 ? '入门' : bd <= 6 ? '中等' : '困难'
const diffColor = diffLevel === 'low' ? '#00E676' : diffLevel === 'mid' ? '#FFA502' : '#FF5252'
const diffStars = Math.min(Math.max(Math.ceil(bd / 2), 1), 5)
return {
...c,
currentMembers: curMembers,
maxMembers: maxMems,
isFull: isFull,
canBook: canBook,
difficultyLevel: diffLevel,
difficultyLabel: diffLabel,
difficultyColor: diffColor,
difficultyStars: diffStars,
difficultyPercent: Math.round(bd * 10),
baseDifficulty: bd,
shortDate: md + '-' + dd,
shortTime: hh + ':' + mm,
endShortTime: ehh + ':' + emm,
duration: durationStr,
period: dt && !isNaN(dt) ? (hh < 8 ? 'morning' : hh < 12 ? 'forenoon' : hh < 17 ? 'afternoon' : 'evening') : 'all',
category: c.category || c.typeCategory || '',
typeName: c.typeName || (c.typeInfo && c.typeInfo.typeName) || '',
coachName: c.coachName || (c.typeInfo && c.typeInfo.coachName) || '',
location: c.location || '',
status: statusStr,
statusLabel: statusLabel,
storedValueAmount: c.storedValueAmount != null ? c.storedValueAmount : 0,
labels: this.getTypeLabels(c.courseType),
coverImage: resolveCoverUrl(c.coverImage),
}
})
if (this.searchKeyword) {
const kw = this.searchKeyword.toLowerCase()
list = list.filter(c =>
(c.courseName || '').toLowerCase().includes(kw) ||
(c.coachName || '').toLowerCase().includes(kw) ||
(c.location || '').toLowerCase().includes(kw)
)
}
if (this.currentFilter !== 'all') list = list.filter(c => String(c.courseType) === this.currentFilter)
if (this.currentPeriod !== 'all') list = list.filter(c => c.period === this.currentPeriod)
// 日期范围过滤:根据课程 startTime 的日期部分进行筛选
if (this.startDate || this.endDate) {
list = list.filter(c => {
const courseDate = (c.startTime || '').substring(0, 10)
if (!courseDate) return false
if (this.startDate && courseDate < this.startDate) return false
if (this.endDate && courseDate > this.endDate) return false
return true
})
}
if (this.currentSort === 'difficulty') list = [...list].sort((a, b) => a.baseDifficulty - b.baseDifficulty)
else if (this.currentSort === 'popular') list = [...list].sort((a, b) => (b.currentMembers || 0) - (a.currentMembers || 0))
else if (this.currentSort === 'time') {
list = [...list].sort((a, b) => {
const ta = a.startTime || ''
const tb = b.startTime || ''
return ta < tb ? -1 : ta > tb ? 1 : 0
})
}
// 只显示可预约的团课,且排除用户已主动预约的课程
list = list.filter(c => c.canBook && !this.activeBookedCourseIds.has(c.id))
return list
}
},
methods: {
// 从已加载的课程类型中匹配标签
getTypeLabels(courseType) {
const type = this.getTypeInfo(courseType)
return type && type.labels ? type.labels : []
},
// 从已加载的课程类型中匹配难度
getTypeDifficulty(courseType) {
const type = this.getTypeInfo(courseType)
return type && type.baseDifficulty != null ? type.baseDifficulty : 5
},
// 查找课程类型信息
getTypeInfo(courseType) {
return this.courseTypes.find(t => String(t.id) === String(courseType))
},
// 判断某行是否可见:展开时全部可见,折叠时只有 visibleRowStart 行可见
isRowVisible(rowIndex) {
if (this.typesExpanded) return true
return rowIndex === this.visibleRowStart
},
async loadCourses() {
this.loading = true
try {
const res = await courseApi.getCoursesByPage({ page: 0, size: 50 })
// 兼容 PageResponse 格式 { content: [...], totalElements, ... } 或直接数组
const list = Array.isArray(res) ? res : (res.content || [])
this.courses = list
} catch (e) {
console.error('加载课程列表失败:', e)
} finally {
this.loading = false
}
},
async loadTypes() {
try {
const types = await courseApi.getCourseTypes()
// 按 id 排序
this.courseTypes = (Array.isArray(types) ? types : []).sort((a, b) => (a.id || 0) - (b.id || 0))
} catch (e) {
console.error('加载课程类型失败:', e)
}
},
async loadActiveBookings() {
try {
const memberInfo = store.getMemberInfo()
const memberId = memberInfo ? (memberInfo.memberId || memberInfo.id) : null
if (!memberId) {
this.activeBookedCourseIds = new Set()
return
}
const list = await bookingApi.getMemberBookings(memberId)
// 只记录状态为"0"(已预约)的课程IDstatus=1(已取消)、2(已出席)、3(缺席)不挡
const bookings = Array.isArray(list) ? list : (list.content || [])
this.activeBookedCourseIds = new Set(
bookings.filter(b => String(b.status) === '0').map(b => b.courseId)
)
} catch (e) {
console.error('加载预约记录失败:', e)
}
},
onSearchInput() {},
clearSearch() { this.searchKeyword = '' },
onCoverLoad(course) { course.coverLoaded = true },
onCoverError(course) { course.coverError = true },
onStartDateChange(e) { this.startDate = e.detail.value },
onEndDateChange(e) { this.endDate = e.detail.value },
clearDateRange() { this.startDate = ''; this.endDate = '' },
selectPeriod(value) { this.currentPeriod = value },
switchFilter(value) { this.currentFilter = value },
selectType(tab) {
this.currentFilter = tab.value
// 找到该类型所在的行索引,折叠到那一行
const idx = this.typeRows.findIndex(row => row.some(t => t.value === tab.value))
if (idx >= 0) this.visibleRowStart = idx
this.typesExpanded = false
},
switchSort(value) { this.currentSort = value },
goToDetail(course) { uni.navigateTo({ url: '/pages/course-detail/course-detail?id=' + course.id }) },
async bookCourse(course) {
if (!course.canBook) return
const memberInfo = store.getMemberInfo()
const memberId = memberInfo ? (memberInfo.memberId || memberInfo.id) : null
if (!memberId) {
uni.showToast({ title: '请先登录', icon: 'none' })
return
}
uni.showLoading({ title: '预约中...' })
try {
await bookingApi.bookCourse({
courseId: course.id,
memberId: memberId
})
uni.hideLoading()
uni.showToast({ title: '预约成功', icon: 'success' })
// 跳转到「我的课程」页面
setTimeout(() => { uni.switchTab({ url: '/pages/my-courses/my-courses' }) }, 1000)
} catch (e) {
uni.hideLoading()
console.error('预约失败:', e)
const msg = (e.data && e.data.message) || '预约失败,请重试'
uni.showToast({ title: msg, icon: 'none' })
}
}
}
}
</script>
<style scoped>
.search-page { min-height: 100vh; background: #F5F7FA; overflow-x: hidden; }
.header-wrap { background: #1A1A1A; }
.nav-bar { background: #1A1A1A; padding: 0 20px; display: flex; align-items: center; }
.nav-title { font-size: 18px; font-weight: 700; color: #FFFFFF; }
.search-area { background: #1A1A1A; padding: 18px 16px 20px; }
.search-box { background: rgba(255,255,255,0.12); border-radius: 40px; padding: 0 16px; display: flex; align-items: center; height: 42px; margin-bottom: 14px; }
.search-symbol { font-size: 15px; color: rgba(255,255,255,0.5); flex-shrink: 0; margin-right: 8px; }
.search-input { flex: 1; font-size: 14px; color: #FFFFFF; height: 100%; }
.clear-btn { font-size: 15px; color: rgba(255,255,255,0.5); padding: 4px; flex-shrink: 0; }
.date-range-row { display: flex; align-items: center; margin-bottom: 14px; }
.date-range-row picker { flex: 1; margin-right: 10px; }
.date-range-item { background: rgba(255,255,255,0.1); border-radius: 12px; padding: 10px 14px; }
.range-label { font-size: 11px; color: rgba(255,255,255,0.5); display: block; margin-bottom: 2px; }
.range-value { font-size: 14px; font-weight: 600; color: #FFFFFF; }
.range-value.placeholder { color: rgba(255,255,255,0.35); font-weight: 400; }
.range-sep { color: rgba(255,255,255,0.5); font-size: 14px; flex-shrink: 0; margin-right: 10px; }
.date-clear { font-size: 16px; color: rgba(255,255,255,0.4); padding: 8px; flex-shrink: 0; }
.period-row { display: flex; }
.period-item { flex: 1; text-align: center; padding: 7px 0; border-radius: 40px; font-size: 13px; color: rgba(255,255,255,0.7); background: rgba(255,255,255,0.08); margin: 0 3px; }
.period-item.active { background: #00E676; color: #1A1A1A; font-weight: 600; }
.type-section { background: #FFFFFF; padding: 12px 16px 14px; border-bottom: 1px solid #EEEEEE; }
.type-all-row { margin-bottom: 6px; }
.type-grid { }
.type-row {
display: flex; flex-wrap: wrap;
max-height: 0; opacity: 0;
margin-bottom: 0; overflow: hidden;
transition: max-height 0.25s ease, opacity 0.25s ease, margin-bottom 0.25s ease;
}
.type-row.row-visible { max-height: 100px; opacity: 1; margin-bottom: 6px; }
.type-grid.expanded .type-row { max-height: 100px; opacity: 1; margin-bottom: 6px; }
.filter-tab { display: inline-block; padding: 6px 16px; border-radius: 40px; font-size: 13px; color: #7A7E84; background: #F5F7FA; margin-right: 8px; margin-bottom: 6px; }
.filter-tab.active { background: #00E676; color: #1A1A1A; font-weight: 600; }
.type-expand-row { text-align: center; padding-top: 2px; }
.type-expand-btn { font-size: 12px; color: #00C853; font-weight: 500; }
.sort-bar { display: flex; justify-content: space-between; align-items: center; padding: 10px 16px; background: #FFFFFF; border-bottom: 1px solid #EEEEEE; }
.result-count { font-size: 13px; color: #7A7E84; }
.sort-options { display: flex; }
.sort-item { font-size: 13px; color: #7A7E84; margin-right: 16px; }
.sort-item.active { color: #00C853; font-weight: 600; }
.course-list-inner { padding: 12px 16px 0; }
.course-item { margin-bottom: 14px; border-radius: 16px; box-shadow: 0 4px 16px rgba(0,0,0,0.07); overflow: hidden; }
.course-card { background: #FFFFFF; border-radius: 16px; display: flex; flex-direction: column; }
/* ---- 封面区域 ---- */
.course-cover { width: 100%; height: 170px; background: linear-gradient(135deg, #E8F5E9 0%, #C8E6C9 100%); display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden; }
.cover-skeleton { position: absolute; inset: 0; background: linear-gradient(90deg, #E8E8E8 25%, #F5F5F5 50%, #E8E8E8 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; z-index: 1; }
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
.cover-img { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; transition: opacity 0.3s; z-index: 0; }
.cover-img.loaded { opacity: 1; z-index: 2; }
/* 无封面时的占位 */
.cover-placeholder { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 0; }
.cover-icon { font-size: 36px; margin-bottom: 4px; }
.cover-type-name { font-size: 16px; font-weight: 700; color: #00C853; }
/* 封面覆盖层 - 难度角标 */
.cover-overlay { position: absolute; top: 10px; right: 10px; z-index: 5; }
.diff-badge { display: flex; align-items: center; padding: 4px 10px; border-radius: 20px; background: rgba(0,0,0,0.55); backdrop-filter: blur(4px); }
.diff-badge-star { font-size: 11px; margin-right: 1px; }
.diff-badge-label { font-size: 11px; font-weight: 600; margin-left: 4px; }
.diff-badge.diff-low .diff-badge-star { color: #00E676; }
.diff-badge.diff-low .diff-badge-label { color: #69F0AE; }
.diff-badge.diff-mid .diff-badge-star { color: #FFA502; }
.diff-badge.diff-mid .diff-badge-label { color: #FFBE4D; }
.diff-badge.diff-high .diff-badge-star { color: #FF5252; }
.diff-badge.diff-high .diff-badge-label { color: #FF8A80; }
/* ---- 卡片内容 ---- */
.course-body { padding: 14px 16px 16px; display: flex; flex-direction: column; }
.course-top { display: flex; justify-content: space-between; align-items: center; }
.course-name { font-size: 17px; font-weight: 700; color: #1E1E1E; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.course-price { font-size: 20px; font-weight: 700; color: #00C853; flex-shrink: 0; margin-left: 8px; }
.course-price.free { color: #00C853; }
/* 标签 */
.course-labels { display: flex; flex-wrap: wrap; margin-top: 8px; }
.course-label { font-size: 11px; padding: 2px 8px; border-radius: 10px; margin-right: 6px; margin-bottom: 4px; }
/* 元数据行 */
.course-meta { display: flex; align-items: center; font-size: 13px; color: #7A7E84; margin-top: 6px; }
.meta-icon { font-size: 12px; margin-right: 4px; flex-shrink: 0; }
.course-date { flex-shrink: 0; }
.course-time { margin-left: 8px; }
.course-duration { margin-left: 8px; color: #00C853; font-weight: 500; }
.course-location { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* 底部预约行 */
.course-bottom { display: flex; align-items: center; margin-top: 12px; padding-top: 10px; border-top: 1px solid #F0F0F0; }
.course-capacity { font-size: 13px; color: #7A7E84; flex: 1; }
.btn-book { background: #00E676; color: #1A1A1A; border: none; padding: 10px 28px; border-radius: 40px; font-weight: 700; font-size: 15px; line-height: 1.2; flex-shrink: 0; margin-left: auto; text-align: center; box-shadow: 0 2px 8px rgba(0,230,118,0.3); }
.btn-book::after { border: none; }
.btn-book.disabled { background: #E0E0E0; color: #BDBDBD; box-shadow: none; }
.empty-state { display: flex; flex-direction: column; align-items: center; padding: 60px 0; }
.empty-symbol { font-size: 40px; color: #BDBDBD; }
.empty-text { font-size: 14px; color: #BDBDBD; }
.bottom-safe { height: 30px; }
</style>