新增后台管理系统(答辩用
This commit is contained in:
@@ -0,0 +1,824 @@
|
||||
<template>
|
||||
<view class="course-detail-page">
|
||||
<scroll-view scroll-y class="page-scroll">
|
||||
<!-- 骨架屏 -->
|
||||
<view v-if="detailLoading">
|
||||
<view class="detail-card">
|
||||
<view class="flex-between" style="margin-bottom:20px">
|
||||
<view class="skeleton sk-line" style="width:55%;height:22px"></view>
|
||||
<view class="skeleton sk-line" style="width:50px;height:22px;border-radius:20px"></view>
|
||||
</view>
|
||||
<view class="detail-grid">
|
||||
<view v-for="i in 4" :key="'dg-'+i" class="detail-item">
|
||||
<view class="skeleton sk-line sk-line-sm" style="width:32px;height:10px;margin-bottom:6px"></view>
|
||||
<view class="skeleton sk-line" style="width:80%;height:14px"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="tab-bar" style="margin-top:12px">
|
||||
<view v-for="t in tabs" :key="'st-'+t.key" class="tab-item">
|
||||
<view class="skeleton sk-line" style="width:48px;height:14px;margin:0 auto"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-for="i in 4" :key="'ssk-'+i" class="sk-row" style="flex-direction:column;align-items:stretch;gap:10px;margin:10px 16px 0">
|
||||
<view class="flex-between">
|
||||
<view class="skeleton sk-line" style="width:35%;height:16px"></view>
|
||||
<view class="skeleton sk-line" style="width:44px;height:18px;border-radius:20px"></view>
|
||||
</view>
|
||||
<view class="skeleton sk-line" style="width:50%;height:12px"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 真实内容 -->
|
||||
<template v-else>
|
||||
<!-- 课程基本信息 -->
|
||||
<view class="detail-card card-fade-in">
|
||||
<view class="detail-header">
|
||||
<view class="flex-between">
|
||||
<text class="detail-title">{{ course.courseName || '--' }}</text>
|
||||
<view :class="['tag', getCourseStatusClass(course.status, course.deletedAt)]">
|
||||
{{ getCourseStatusLabel(course.status, course.deletedAt) }}
|
||||
</view>
|
||||
</view>
|
||||
<text class="detail-type" v-if="typeName">{{ typeName }}</text>
|
||||
</view>
|
||||
|
||||
<view class="detail-grid">
|
||||
<view class="detail-item">
|
||||
<text class="d-label">日期</text>
|
||||
<text class="d-value">{{ formatDate(course.startTime) }}</text>
|
||||
</view>
|
||||
<view class="detail-item">
|
||||
<text class="d-label">时间</text>
|
||||
<text class="d-value">
|
||||
{{ formatTimeStr(course.actualStartTime || course.startTime) }} — {{ formatTimeStr(course.actualEndTime || course.endTime) }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="detail-item">
|
||||
<text class="d-label">地点</text>
|
||||
<text class="d-value">{{ course.location || '未设置' }}</text>
|
||||
</view>
|
||||
<view class="detail-item">
|
||||
<text class="d-label">人数</text>
|
||||
<text class="d-value">{{ course.currentMembers || 0 }} / {{ course.maxMembers || 0 }}人</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 实际时间记录 -->
|
||||
<view v-if="course.actualStartTime || course.actualEndTime" class="actual-time-section">
|
||||
<text class="at-title">实际时间记录</text>
|
||||
<view class="at-row" v-if="course.actualStartTime">
|
||||
<text class="at-label">实际开课</text>
|
||||
<text class="at-value">{{ formatDateTime(course.actualStartTime) }}</text>
|
||||
</view>
|
||||
<view class="at-row" v-if="course.actualEndTime">
|
||||
<text class="at-label">实际结课</text>
|
||||
<text class="at-value">{{ formatDateTime(course.actualEndTime) }}</text>
|
||||
</view>
|
||||
<view class="at-note" v-if="course.actualEndTime">
|
||||
<text>{{ isAutoEnded(course) ? '系统自动结课(教练未在课程结束后10分钟内点击结课)' : '教练手动结课' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="course.description" class="detail-desc">
|
||||
<text class="d-label">课程描述</text>
|
||||
<text class="d-desc-text">{{ course.description }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 开课/结课操作按钮 -->
|
||||
<view v-if="showCourseActions" class="course-actions">
|
||||
<view v-if="showStartButton" class="action-hint">
|
||||
<text class="hint-text">{{ startButtonHint }}</text>
|
||||
</view>
|
||||
<view v-if="showEndButton" class="action-hint">
|
||||
<text class="hint-text">{{ endButtonHint }}</text>
|
||||
</view>
|
||||
<view class="action-buttons">
|
||||
<button
|
||||
v-if="showStartButton"
|
||||
class="action-btn start-btn"
|
||||
:class="{ 'btn-disabled': !canStartCourse }"
|
||||
:loading="startLoading"
|
||||
:disabled="!canStartCourse || startLoading"
|
||||
@tap="handleStartCourse"
|
||||
>
|
||||
<text v-if="!startLoading">开始上课</text>
|
||||
<text v-else>开课中...</text>
|
||||
</button>
|
||||
<button
|
||||
v-if="showEndButton"
|
||||
class="action-btn end-btn"
|
||||
:class="{ 'btn-disabled': !canEndCourse }"
|
||||
:loading="endLoading"
|
||||
:disabled="!canEndCourse || endLoading"
|
||||
@tap="handleEndCourse"
|
||||
>
|
||||
<text v-if="!endLoading">结束上课</text>
|
||||
<text v-else>结课中...</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Tab切换 -->
|
||||
<view class="tab-bar">
|
||||
<view
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === tab.key }"
|
||||
@tap="switchTab(tab.key)"
|
||||
>
|
||||
<text>{{ tab.label }}</text>
|
||||
<view v-if="activeTab === tab.key" class="tab-indicator"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 时间表 -->
|
||||
<view v-if="activeTab === 'schedule'" class="tab-content fade-in-up">
|
||||
<view class="schedule-card card">
|
||||
<view v-for="(item, idx) in scheduleItems" :key="'s-'+idx" class="schedule-item list-item-enter" :style="{ animationDelay: (idx * 0.05) + 's' }">
|
||||
<text class="s-label">{{ item.label }}</text>
|
||||
<text :class="['s-value', item.highlight ? 's-highlight' : '']">{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 签到情况 -->
|
||||
<view v-if="activeTab === 'signin'" class="tab-content">
|
||||
<!-- 签到骨架屏 -->
|
||||
<view v-if="signinLoading">
|
||||
<view v-for="i in 4" :key="'sik-'+i" class="sk-row" style="margin:0 16px 8px">
|
||||
<view style="flex:1">
|
||||
<view class="skeleton sk-line" style="width:40%;height:15px;margin-bottom:6px"></view>
|
||||
<view class="skeleton sk-line" style="width:55%;height:12px"></view>
|
||||
</view>
|
||||
<view class="skeleton sk-line" style="width:44px;height:20px;border-radius:20px"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="signinRecords.length === 0" class="empty-state">
|
||||
<text>暂无签到记录</text>
|
||||
</view>
|
||||
<view v-else class="signin-list">
|
||||
<view
|
||||
v-for="(record, index) in signinRecords"
|
||||
:key="record.id || index"
|
||||
class="signin-item card list-item-enter"
|
||||
:style="{ animationDelay: (index * 0.06) + 's' }"
|
||||
>
|
||||
<view class="flex-between">
|
||||
<view class="signin-left">
|
||||
<text class="member-name">{{ record.memberName || '会员' + record.memberId }}</text>
|
||||
<text class="signin-time">{{ formatDateTime(record.signInTime) }}</text>
|
||||
</view>
|
||||
<view class="tag tag-green">{{ record.signInStatus === 'SUCCESS' ? '已签到' : '未签到' }}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 预约会员 -->
|
||||
<view v-if="activeTab === 'bookings'" class="tab-content">
|
||||
<!-- 预约骨架屏 -->
|
||||
<view v-if="bookingLoading">
|
||||
<view v-for="i in 4" :key="'bik-'+i" class="sk-row" style="margin:0 16px 8px">
|
||||
<view style="flex:1">
|
||||
<view class="skeleton sk-line" style="width:40%;height:15px;margin-bottom:6px"></view>
|
||||
<view class="skeleton sk-line" style="width:60%;height:12px"></view>
|
||||
</view>
|
||||
<view class="skeleton sk-line" style="width:48px;height:20px;border-radius:20px"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="bookings.length === 0" class="empty-state">
|
||||
<text>暂无预约会员</text>
|
||||
</view>
|
||||
<view v-else class="booking-list">
|
||||
<view class="flex-between" style="padding:0 16px;margin-bottom:8px">
|
||||
<text class="stat-text">共 {{ bookings.length }} 人预约</text>
|
||||
<text class="stat-text">{{ attendedCount }} 人已签到</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="(b, index) in bookings"
|
||||
:key="b.id || index"
|
||||
class="booking-item card list-item-enter"
|
||||
:style="{ animationDelay: (index * 0.06) + 's' }"
|
||||
>
|
||||
<view class="flex-between">
|
||||
<view class="booking-left">
|
||||
<text class="member-name">{{ b.memberName || '会员' + b.memberId }}</text>
|
||||
<text class="booking-time">预约时间: {{ formatDateTime(b.createdAt) }}</text>
|
||||
</view>
|
||||
<view :class="['tag', getBookingStatusClass(b.status)]">
|
||||
{{ getBookingStatusLabel(b.status) }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view class="safe-area-bottom" style="height:80px"></view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getCourseDetail, getBookingsByCourseId, startCourse, endCourse } from '@/api/course.js'
|
||||
import { getSignInRecords } from '@/api/checkin.js'
|
||||
import {
|
||||
formatDate,
|
||||
formatTime,
|
||||
formatDateTime,
|
||||
getCourseStatusLabel,
|
||||
getCourseStatusClass
|
||||
} from '@/utils/index.js'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
courseId: null,
|
||||
course: {},
|
||||
typeName: '',
|
||||
detailLoading: true,
|
||||
activeTab: 'schedule',
|
||||
|
||||
signinRecords: [],
|
||||
signinLoading: false,
|
||||
|
||||
bookings: [],
|
||||
bookingLoading: false,
|
||||
|
||||
// 开课/结课
|
||||
startLoading: false,
|
||||
endLoading: false,
|
||||
|
||||
tabs: [
|
||||
{ key: 'schedule', label: '时间表' },
|
||||
{ key: 'signin', label: '签到情况' },
|
||||
{ key: 'bookings', label: '预约会员' }
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
/** 是否显示操作区域:状态为正常(0) 或 进行中(3),且未被删除 */
|
||||
showCourseActions() {
|
||||
const c = this.course
|
||||
if (c.deletedAt) return false
|
||||
const s = Number(c.status)
|
||||
return s === 0 || s === 3
|
||||
},
|
||||
/** 是否已开课 */
|
||||
isStarted() {
|
||||
return !!this.course.actualStartTime
|
||||
},
|
||||
/** 是否已结课 */
|
||||
isEnded() {
|
||||
return !!this.course.actualEndTime
|
||||
},
|
||||
/** 是否可以开课:状态为正常(0),未开课,当前时间在 startTime ~ startTime+10分钟内 */
|
||||
canStartCourse() {
|
||||
const c = this.course
|
||||
if (!c.startTime) return false
|
||||
if (Number(c.status) !== 0) return false
|
||||
if (c.actualStartTime) return false
|
||||
const now = Date.now()
|
||||
const start = new Date(c.startTime).getTime()
|
||||
const deadline = start + 10 * 60 * 1000
|
||||
return now >= start && now <= deadline
|
||||
},
|
||||
/** 是否可以结课:已开课且未结课,当前时间在 endTime-10分钟 ~ endTime+10分钟内 */
|
||||
canEndCourse() {
|
||||
const c = this.course
|
||||
if (!c.actualStartTime) return false
|
||||
if (c.actualEndTime) return false
|
||||
if (!c.endTime) return false
|
||||
const now = Date.now()
|
||||
const end = new Date(c.endTime).getTime()
|
||||
const earliest = end - 10 * 60 * 1000
|
||||
const latest = end + 10 * 60 * 1000
|
||||
return now >= earliest && now <= latest
|
||||
},
|
||||
/** 开课按钮是否可显示:状态正常(0) 且 未开课 */
|
||||
showStartButton() {
|
||||
const c = this.course
|
||||
if (!c.startTime) return false
|
||||
if (Number(c.status) !== 0) return false
|
||||
if (c.actualStartTime) return false
|
||||
return true
|
||||
},
|
||||
/** 结课按钮是否可显示:已开课 且 未结课 */
|
||||
showEndButton() {
|
||||
const c = this.course
|
||||
if (!c.actualStartTime) return false
|
||||
if (c.actualEndTime) return false
|
||||
return true
|
||||
},
|
||||
/** 开课按钮提示文字 */
|
||||
startButtonHint() {
|
||||
if (!this.course.startTime) return ''
|
||||
if (Number(this.course.status) !== 0) return '课程状态不允许开课'
|
||||
if (this.course.actualStartTime) return '已开课'
|
||||
const now = Date.now()
|
||||
const start = new Date(this.course.startTime).getTime()
|
||||
if (now < start) {
|
||||
const remain = Math.ceil((start - now) / 60000)
|
||||
return `距离开课还有${this.formatDuration(remain)}`
|
||||
}
|
||||
const deadline = start + 10 * 60 * 1000
|
||||
if (now > deadline) return '已超过开课时限(开课后10分钟)'
|
||||
const remain = Math.ceil((deadline - now) / 60000)
|
||||
return `请在 ${remain} 分钟内开课`
|
||||
},
|
||||
/** 结课按钮提示文字 */
|
||||
endButtonHint() {
|
||||
if (!this.course.endTime) return ''
|
||||
if (!this.course.actualStartTime) return '尚未开课'
|
||||
if (this.course.actualEndTime) return '已结课'
|
||||
const now = Date.now()
|
||||
const end = new Date(this.course.endTime).getTime()
|
||||
const earliest = end - 10 * 60 * 1000
|
||||
const latest = end + 10 * 60 * 1000
|
||||
if (now < earliest) {
|
||||
const remain = Math.ceil((earliest - now) / 60000)
|
||||
return `距离开放结课还有${this.formatDuration(remain)}`
|
||||
}
|
||||
if (now > latest) return '已超过结课时限(结课后10分钟)'
|
||||
const remain = Math.ceil((latest - now) / 60000)
|
||||
return `请在 ${remain} 分钟内结课`
|
||||
},
|
||||
scheduleItems() {
|
||||
const items = [
|
||||
{ label: '课程名称', value: this.course.courseName || '--' },
|
||||
{ label: '课程日期', value: this.formatDate(this.course.startTime) },
|
||||
{ label: '上课时间', value: this.formatTimeStr(this.course.startTime) + ' - ' + this.formatTimeStr(this.course.endTime), highlight: true },
|
||||
{ label: '上课地点', value: this.course.location || '未设置' },
|
||||
{ label: '课程类型', value: this.typeName || '未知' },
|
||||
{ label: '人数限制', value: (this.course.currentMembers || 0) + ' / ' + (this.course.maxMembers || 0) + '人' }
|
||||
]
|
||||
if (this.course.actualStartTime) {
|
||||
items.push({ label: '实际开课', value: this.formatDateTime(this.course.actualStartTime) })
|
||||
}
|
||||
if (this.course.actualEndTime) {
|
||||
items.push({ label: '实际结课', value: this.formatDateTime(this.course.actualEndTime) })
|
||||
items.push({
|
||||
label: '结课方式',
|
||||
value: this.isAutoEnded(this.course) ? '系统自动结课' : '教练手动结课'
|
||||
})
|
||||
}
|
||||
return items
|
||||
},
|
||||
attendedCount() {
|
||||
return this.bookings.filter(b => b.status === 2 || b.status === 'ATTENDED').length
|
||||
}
|
||||
},
|
||||
onLoad(options) {
|
||||
this.courseId = Number(options.id)
|
||||
if (this.courseId) {
|
||||
this.loadCourseDetail()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
formatDate,
|
||||
formatTimeStr: formatTime,
|
||||
formatDateTime,
|
||||
getCourseStatusLabel,
|
||||
getCourseStatusClass,
|
||||
|
||||
async loadCourseDetail() {
|
||||
this.detailLoading = true
|
||||
try {
|
||||
const detail = await getCourseDetail(this.courseId)
|
||||
this.course = detail
|
||||
this.typeName = detail.courseType?.typeName || detail.typeName || ''
|
||||
if (detail.description) {
|
||||
this.course.description = detail.description
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载课程详情失败:', e)
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
this.detailLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
switchTab(key) {
|
||||
this.activeTab = key
|
||||
if (key === 'signin' && this.signinRecords.length === 0) {
|
||||
this.loadSignInRecords()
|
||||
} else if (key === 'bookings' && this.bookings.length === 0) {
|
||||
this.loadBookings()
|
||||
}
|
||||
},
|
||||
|
||||
async loadSignInRecords() {
|
||||
this.signinLoading = true
|
||||
try {
|
||||
const courseDate = this.course.startTime
|
||||
? new Date(this.course.startTime).toISOString().slice(0, 10)
|
||||
: new Date().toISOString().slice(0, 10)
|
||||
const res = await getSignInRecords({
|
||||
startDate: courseDate,
|
||||
endDate: courseDate
|
||||
})
|
||||
this.signinRecords = Array.isArray(res?.data) ? res.data : (Array.isArray(res) ? res : [])
|
||||
} catch (e) {
|
||||
console.error('加载签到记录失败:', e)
|
||||
this.signinRecords = []
|
||||
} finally {
|
||||
this.signinLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
async loadBookings() {
|
||||
this.bookingLoading = true
|
||||
try {
|
||||
const list = await getBookingsByCourseId(this.courseId)
|
||||
this.bookings = Array.isArray(list) ? list : []
|
||||
} catch (e) {
|
||||
console.error('加载预约列表失败:', e)
|
||||
this.bookings = []
|
||||
} finally {
|
||||
this.bookingLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
getBookingStatusLabel(status) {
|
||||
const n = Number(status)
|
||||
if (n === 0 || status === 'PENDING') return '待确认'
|
||||
if (n === 1 || status === 'CONFIRMED') return '已确认'
|
||||
if (n === 2 || status === 'ATTENDED') return '已签到'
|
||||
if (n === -1 || status === 'CANCELLED') return '已取消'
|
||||
return '未知'
|
||||
},
|
||||
|
||||
getBookingStatusClass(status) {
|
||||
const n = Number(status)
|
||||
if (n === 0 || status === 'PENDING') return 'tag-orange'
|
||||
if (n === 1 || status === 'CONFIRMED') return 'tag-blue'
|
||||
if (n === 2 || status === 'ATTENDED') return 'tag-green'
|
||||
if (n === -1 || status === 'CANCELLED') return 'tag-gray'
|
||||
return 'tag-gray'
|
||||
},
|
||||
|
||||
isAutoEnded(course) {
|
||||
// 自动结课:已结束 且 实际结束时间与理论结束时间相同(调度器自动设置)
|
||||
return !!(course && course.status === 2 && course.actualEndTime && course.endTime && course.actualEndTime === course.endTime)
|
||||
},
|
||||
|
||||
/** 格式化倒计时:总分钟数 -> 天/小时/分钟 */
|
||||
formatDuration(totalMinutes) {
|
||||
if (totalMinutes <= 0) return ''
|
||||
const days = Math.floor(totalMinutes / (24 * 60))
|
||||
const hours = Math.floor((totalMinutes % (24 * 60)) / 60)
|
||||
const mins = totalMinutes % 60
|
||||
const parts = []
|
||||
if (days > 0) parts.push(`${days} 天`)
|
||||
if (hours > 0) parts.push(`${hours} 小时`)
|
||||
if (mins > 0) parts.push(`${mins} 分钟`)
|
||||
// 确保至少输出一部分(极端情况:恰好是天/小时的整数倍时,分钟为0)
|
||||
if (parts.length === 0) parts.push('0 分钟')
|
||||
return parts.join('')
|
||||
},
|
||||
|
||||
/** 格式化当前时间为后端需要的 yyyy-MM-ddTHH:mm:ss(本地时间) */
|
||||
formatLocalISO(date) {
|
||||
const d = date || new Date()
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
},
|
||||
|
||||
async handleStartCourse() {
|
||||
const hint = this.startButtonHint
|
||||
const confirmRes = await new Promise((resolve) => {
|
||||
uni.showModal({
|
||||
title: '确认开课',
|
||||
content: `点击确认后将以当前时间作为实际开课时间。${hint}`,
|
||||
success: (r) => resolve(r.confirm),
|
||||
fail: () => resolve(false)
|
||||
})
|
||||
})
|
||||
if (!confirmRes) return
|
||||
|
||||
this.startLoading = true
|
||||
try {
|
||||
const now = this.formatLocalISO()
|
||||
const res = await startCourse(this.courseId, now)
|
||||
if (res.success) {
|
||||
uni.showToast({ title: '开课成功', icon: 'success' })
|
||||
await this.loadCourseDetail()
|
||||
} else {
|
||||
uni.showToast({ title: res.message || '开课失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e?.data?.message || e?.message || '开课失败'
|
||||
uni.showToast({ title: msg, icon: 'none' })
|
||||
} finally {
|
||||
this.startLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
async handleEndCourse() {
|
||||
const hint = this.endButtonHint
|
||||
const confirmRes = await new Promise((resolve) => {
|
||||
uni.showModal({
|
||||
title: '确认结课',
|
||||
content: `点击确认后将以当前时间作为实际结课时间。${hint}`,
|
||||
success: (r) => resolve(r.confirm),
|
||||
fail: () => resolve(false)
|
||||
})
|
||||
})
|
||||
if (!confirmRes) return
|
||||
|
||||
this.endLoading = true
|
||||
try {
|
||||
const now = this.formatLocalISO()
|
||||
const res = await endCourse(this.courseId, now)
|
||||
if (res.success) {
|
||||
uni.showToast({ title: '结课成功', icon: 'success' })
|
||||
await this.loadCourseDetail()
|
||||
} else {
|
||||
uni.showToast({ title: res.message || '结课失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e?.data?.message || e?.message || '结课失败'
|
||||
uni.showToast({ title: msg, icon: 'none' })
|
||||
} finally {
|
||||
this.endLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.course-detail-page {
|
||||
min-height: 100vh;
|
||||
background: #f6f8fc;
|
||||
}
|
||||
|
||||
.page-scroll { height: 100vh; }
|
||||
|
||||
.card-fade-in {
|
||||
animation: cardFadeIn 0.4s ease-out both;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
background: #ffffff;
|
||||
margin: 12px 16px;
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
.detail-header { margin-bottom: 20px; }
|
||||
|
||||
.detail-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #0b1a33;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.detail-type {
|
||||
font-size: 13px;
|
||||
color: #4f7cff;
|
||||
margin-top: 6px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px 20px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.d-label { font-size: 12px; color: #94a3b8; }
|
||||
.d-value { font-size: 14px; color: #334155; font-weight: 500; }
|
||||
|
||||
.detail-desc {
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f3f8;
|
||||
}
|
||||
|
||||
.d-desc-text {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
line-height: 1.6;
|
||||
margin-top: 6px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Tab */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
background: #ffffff;
|
||||
margin: 0 16px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 14px 0;
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
position: relative;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.tab-item:active { background: #f4f7fd; }
|
||||
|
||||
.tab-item.active {
|
||||
color: #4f7cff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab-indicator {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 28px;
|
||||
height: 3px;
|
||||
background: #4f7cff;
|
||||
border-radius: 2px;
|
||||
animation: tabSlideIn 0.25s ease-out;
|
||||
}
|
||||
|
||||
@keyframes tabSlideIn {
|
||||
from { width: 0; }
|
||||
to { width: 28px; }
|
||||
}
|
||||
|
||||
.tab-content { padding-top: 12px; }
|
||||
|
||||
/* 时间表 */
|
||||
.schedule-card { border-radius: 14px; }
|
||||
|
||||
.schedule-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f3f8;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.schedule-item:active { background: #fafbff; }
|
||||
|
||||
.schedule-item:last-child { border-bottom: none; }
|
||||
|
||||
.s-label { font-size: 14px; color: #94a3b8; }
|
||||
.s-value { font-size: 14px; color: #334155; font-weight: 500; }
|
||||
.s-highlight { color: #4f7cff; font-weight: 600; }
|
||||
|
||||
/* 签到/预约列表 */
|
||||
.signin-list, .booking-list { padding: 0; }
|
||||
|
||||
.signin-item, .booking-item {
|
||||
border-radius: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #1e293b;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.signin-time, .booking-time {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.stat-text { font-size: 13px; color: #64748b; }
|
||||
|
||||
/* ---- 实际时间记录 ---- */
|
||||
.actual-time-section {
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
margin: 0 16px 12px;
|
||||
}
|
||||
|
||||
.at-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #92400e;
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.at-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.at-label { font-size: 13px; color: #a16207; }
|
||||
.at-value { font-size: 13px; color: #92400e; font-weight: 500; }
|
||||
|
||||
.at-note {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed #fde68a;
|
||||
font-size: 12px;
|
||||
color: #92400e;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ---- 开课/结课操作按钮 ---- */
|
||||
.course-actions {
|
||||
margin: 0 16px 12px;
|
||||
background: #ffffff;
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
.action-hint {
|
||||
margin-bottom: 10px;
|
||||
padding: 6px 10px;
|
||||
background: #eff6ff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
font-size: 12px;
|
||||
color: #3b82f6;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
height: 46px;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn::after { border: none; }
|
||||
|
||||
.start-btn {
|
||||
background: linear-gradient(135deg, #4f7cff, #6366f1);
|
||||
color: white;
|
||||
box-shadow: 0 4px 14px rgba(79,124,255,0.35);
|
||||
}
|
||||
|
||||
.start-btn:active {
|
||||
transform: scale(0.97);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.action-btn[disabled] {
|
||||
opacity: 0.5;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-disabled {
|
||||
opacity: 0.45 !important;
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.end-btn {
|
||||
background: linear-gradient(135deg, #ef4444, #f97316);
|
||||
color: white;
|
||||
box-shadow: 0 4px 14px rgba(239,68,68,0.3);
|
||||
}
|
||||
|
||||
.end-btn:active {
|
||||
transform: scale(0.97);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.end-btn[disabled] {
|
||||
opacity: 0.5;
|
||||
transform: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,377 @@
|
||||
<template>
|
||||
<view class="course-list-page">
|
||||
<!-- 搜索栏 -->
|
||||
<view class="search-bar">
|
||||
<input
|
||||
class="search-input"
|
||||
v-model="keyword"
|
||||
type="text"
|
||||
placeholder="搜索课程名称..."
|
||||
@confirm="handleSearch"
|
||||
/>
|
||||
<text class="search-btn" @tap="handleSearch">搜索</text>
|
||||
</view>
|
||||
|
||||
<!-- 状态筛选 -->
|
||||
<scroll-view scroll-x class="filter-bar">
|
||||
<view
|
||||
v-for="tab in filterTabs"
|
||||
:key="tab.key"
|
||||
class="filter-tab"
|
||||
:class="{ active: activeFilter === tab.key }"
|
||||
@tap="changeFilter(tab.key)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 时间图例 -->
|
||||
<view class="time-legend">
|
||||
<view class="legend-item">
|
||||
<view class="legend-dot theory"></view>
|
||||
<text class="legend-text">理论时间</text>
|
||||
</view>
|
||||
<view class="legend-item">
|
||||
<view class="legend-dot actual"></view>
|
||||
<text class="legend-text">实际时间</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 骨架屏 -->
|
||||
<view v-if="loading">
|
||||
<view v-for="i in 5" :key="'sk-'+i" class="sk-row skeleton-pulse" style="flex-direction:column;align-items:stretch;gap:10px">
|
||||
<view class="flex-between">
|
||||
<view class="skeleton sk-line" style="width:50%;height:18px"></view>
|
||||
<view class="skeleton sk-line" style="width:48px;height:20px;border-radius:20px"></view>
|
||||
</view>
|
||||
<view class="skeleton sk-line sk-line-long" style="height:13px"></view>
|
||||
<view class="skeleton sk-line sk-line-medium" style="height:12px"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 真实列表 -->
|
||||
<scroll-view v-else scroll-y class="course-scroll" @scrolltolower="loadMore">
|
||||
<view v-if="filteredCourses.length === 0" class="empty-state">
|
||||
<text>暂无相关团课</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="course-list">
|
||||
<view
|
||||
v-for="(course, index) in filteredCourses"
|
||||
:key="course.id"
|
||||
class="course-item list-item-enter"
|
||||
:style="{ animationDelay: (index * 0.06) + 's' }"
|
||||
@tap="goDetail(course.id)"
|
||||
>
|
||||
<view class="item-left">
|
||||
<view class="item-title-row">
|
||||
<text class="item-title">{{ course.courseName }}</text>
|
||||
<view :class="['tag', 'tag-sm', getCourseStatusClass(course.status, course.deletedAt)]">
|
||||
{{ getCourseStatusLabel(course.status, course.deletedAt) }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="item-info">
|
||||
<text class="info-text">时间</text>
|
||||
<text
|
||||
:class="['info-value', hasActualTime(course) ? 'time-badge-actual' : 'time-badge-theory']"
|
||||
>
|
||||
{{ formatDate(course.actualStartTime || course.startTime) }}
|
||||
{{ formatTimeShort(course.actualStartTime || course.startTime) }}
|
||||
—
|
||||
{{ formatTimeShort(course.actualEndTime || course.endTime) }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="item-info">
|
||||
<text class="info-text">地点</text>
|
||||
<text class="info-value">{{ course.location || '未设置' }}</text>
|
||||
</view>
|
||||
<view class="item-info">
|
||||
<text class="info-text">人数</text>
|
||||
<text class="info-value">{{ course.currentMembers || 0 }}/{{ course.maxMembers }}人</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="item-right">
|
||||
<text class="arrow">></text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="safe-area-bottom" style="height:60px"></view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useAuthStore } from '@/store/auth.js'
|
||||
import { getCoachCourses } from '@/api/course.js'
|
||||
import {
|
||||
formatDate, formatTime, formatTimeShort,
|
||||
getCourseStatusLabel, getCourseStatusClass,
|
||||
hasActualTime
|
||||
} from '@/utils/index.js'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
allCourses: [],
|
||||
loading: true,
|
||||
keyword: '',
|
||||
activeFilter: 'all',
|
||||
filterTabs: [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: '0', label: '正常' },
|
||||
{ key: '3', label: '进行中' },
|
||||
{ key: '2', label: '已结束' },
|
||||
{ key: '1', label: '已取消' },
|
||||
{ key: '4', label: '超时' }
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredCourses() {
|
||||
let list = this.allCourses
|
||||
|
||||
if (this.activeFilter !== 'all') {
|
||||
list = list.filter(
|
||||
c => (c.deletedAt == null || c.deletedAt === '') && Number(c.status) === Number(this.activeFilter)
|
||||
)
|
||||
}
|
||||
|
||||
if (this.keyword.trim()) {
|
||||
const kw = this.keyword.trim().toLowerCase()
|
||||
list = list.filter(
|
||||
c =>
|
||||
c.courseName?.toLowerCase().includes(kw) ||
|
||||
String(c.id).includes(kw) ||
|
||||
(c.location && c.location.toLowerCase().includes(kw))
|
||||
)
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
this.loadCourses()
|
||||
},
|
||||
methods: {
|
||||
formatDate,
|
||||
formatTimeStr: formatTime,
|
||||
formatTimeShort,
|
||||
getCourseStatusLabel,
|
||||
getCourseStatusClass,
|
||||
hasActualTime,
|
||||
|
||||
async loadCourses() {
|
||||
const authStore = useAuthStore()
|
||||
if (!authStore.state.isLoggedIn) {
|
||||
uni.reLaunch({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
try {
|
||||
const courses = await getCoachCourses()
|
||||
this.allCourses = Array.isArray(courses) ? courses : []
|
||||
} catch (e) {
|
||||
console.error('加载团课失败:', e)
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
this.allCourses = []
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
handleSearch() {},
|
||||
changeFilter(key) { this.activeFilter = key },
|
||||
goDetail(id) { uni.navigateTo({ url: `/pages/course/detail?id=${id}` }) },
|
||||
loadMore() {}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.course-list-page {
|
||||
min-height: 100vh;
|
||||
background: #f6f8fc;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #e9edf4;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
height: 38px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 10px;
|
||||
padding: 0 14px;
|
||||
font-size: 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: #4f7cff;
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
font-size: 14px;
|
||||
color: #4f7cff;
|
||||
font-weight: 500;
|
||||
padding: 8px 4px;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
white-space: nowrap;
|
||||
padding: 12px 16px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #e9edf4;
|
||||
}
|
||||
|
||||
.filter-tab {
|
||||
display: inline-block;
|
||||
padding: 6px 16px;
|
||||
margin-right: 8px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
background: #f1f5f9;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.filter-tab:active { transform: scale(0.95); }
|
||||
|
||||
.filter-tab.active {
|
||||
background: #eef3ff;
|
||||
color: #4f7cff;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 2px 8px rgba(79, 124, 255, 0.1);
|
||||
}
|
||||
|
||||
.course-scroll {
|
||||
height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.course-list {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.course-item {
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.04);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.course-item:active {
|
||||
background: #f8f9ff;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.item-left { flex: 1; }
|
||||
|
||||
.item-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.tag-sm {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.item-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.item-right { margin-left: 8px; }
|
||||
.arrow { font-size: 16px; color: #c0c8d4; }
|
||||
|
||||
/* ---- 时间样式 ---- */
|
||||
.time-badge-theory {
|
||||
background: #f1f5f9;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.time-badge-actual {
|
||||
background: #fef3c7;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
/* ---- 时间图例 ---- */
|
||||
.time-legend {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
padding: 4px 16px 0;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.legend-dot.theory {
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.legend-dot.actual {
|
||||
background: #fef3c7;
|
||||
border: 1px solid #f59e0b;
|
||||
}
|
||||
|
||||
.legend-text {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user