新增后台管理系统(答辩用

This commit is contained in:
2026-06-24 05:07:32 +08:00
parent 2251f31524
commit 1f209e6761
98 changed files with 25099 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
unpackage/
.hbuilderx/
.DS_Store
+368
View File
@@ -0,0 +1,368 @@
<script>
import { loadCourseStatuses } from '@/utils/index.js'
export default {
onLaunch: function() {
console.log('Coach App Launch')
// 从后端加载课程状态映射(避免硬编码)
loadCourseStatuses()
},
onShow: function() {
console.log('Coach App Show')
},
onHide: function() {
console.log('Coach App Hide')
}
}
</script>
<style>
/* ============================================
全局样式 - 基于 gym-manage-cuit 风格适配手机端
============================================ */
page {
background-color: #f6f8fc;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
color: #1e293b;
font-size: 14px;
line-height: 1.5;
}
view, text, input, button, image, scroll-view {
box-sizing: border-box;
}
/* ============================================
页面进入动画
============================================ */
.page-enter {
opacity: 0;
transform: translateY(12px);
animation: pageSlideIn 0.35s ease-out forwards;
}
@keyframes pageSlideIn {
0% { opacity: 0; transform: translateY(12px); }
100% { opacity: 1; transform: translateY(0); }
}
/* ============================================
通用卡片
============================================ */
.card {
background: #ffffff;
border-radius: 12px;
padding: 16px;
margin: 12px 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
animation: cardFadeIn 0.4s ease-out both;
}
@keyframes cardFadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* ============================================
通用按钮
============================================ */
.btn-primary {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(145deg, #4f7cff, #3b5fd9);
color: #ffffff;
border: none;
border-radius: 10px;
padding: 12px 24px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.btn-primary::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(255,255,255,0.15) 0%, transparent 60%);
opacity: 0;
transition: opacity 0.2s;
}
.btn-primary:active::after { opacity: 1; }
.btn-primary:active {
transform: scale(0.97);
}
.btn-outline {
display: flex;
align-items: center;
justify-content: center;
background: #ffffff;
color: #4f7cff;
border: 1px solid #4f7cff;
border-radius: 10px;
padding: 10px 20px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.btn-outline:active {
background: #eef3ff;
transform: scale(0.97);
}
/* ============================================
按钮加载 Spinner
============================================ */
@keyframes btnSpin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.btn-spinner {
display: inline-block;
width: 18px;
height: 18px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: #ffffff;
border-radius: 50%;
animation: btnSpin 0.6s linear infinite;
margin-right: 8px;
}
/* ============================================
标签
============================================ */
.tag {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
transition: all 0.25s ease;
}
.tag-green { background: #e6f7ee; color: #10b981; }
.tag-orange { background: #fff7ed; color: #f59e0b; }
.tag-blue { background: #eef3ff; color: #4f7cff; }
.tag-gray { background: #f1f5f9; color: #94a3b8; }
/* ============================================
骨架屏
============================================ */
@keyframes skeletonShimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(90deg, #f0f3f8 25%, #e2e8f0 50%, #f0f3f8 75%);
background-size: 200% 100%;
animation: skeletonShimmer 1.5s ease-in-out infinite;
border-radius: 6px;
}
@keyframes skeletonPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.skeleton-pulse {
animation: skeletonPulse 1.5s ease-in-out infinite;
}
/* 骨架屏行 */
.sk-row {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: #ffffff;
border-radius: 12px;
margin-bottom: 10px;
}
.sk-circle {
width: 40px;
height: 40px;
border-radius: 50%;
}
.sk-line {
height: 14px;
border-radius: 7px;
}
.sk-line-short { width: 60%; }
.sk-line-medium { width: 80%; }
.sk-line-long { width: 100%; }
.sk-line-sm { height: 10px; border-radius: 5px; }
/* ============================================
空状态
============================================ */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
color: #94a3b8;
animation: emptyFadeIn 0.5s ease-out both;
}
@keyframes emptyFadeIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
.empty-state text {
font-size: 14px;
margin-top: 12px;
}
/* ============================================
脉冲圆点
============================================ */
@keyframes dotPulse {
0%, 100% { transform: scale(0.6); opacity: 0.4; }
50% { transform: scale(1); opacity: 1; }
}
.dot-pulse {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #4f7cff;
animation: dotPulse 1s ease-in-out infinite;
}
.dot-pulse:nth-child(2) { animation-delay: 0.2s; }
.dot-pulse:nth-child(3) { animation-delay: 0.4s; }
/* ============================================
列表交错入场
============================================ */
@keyframes listItemIn {
from { opacity: 0; transform: translateX(-10px); }
to { opacity: 1; transform: translateX(0); }
}
.list-item-enter {
animation: listItemIn 0.35s ease-out both;
}
/* ============================================
数字跳动
============================================ */
@keyframes numberPop {
0% { transform: scale(1); }
50% { transform: scale(1.15); }
100% { transform: scale(1); }
}
.number-animate {
animation: numberPop 0.5s ease-out;
}
/* ============================================
加载中
============================================ */
@keyframes loadingDot {
0%, 80%, 100% { transform: scale(0); opacity: 0; }
40% { transform: scale(1); opacity: 1; }
}
.loading-dots {
display: flex;
align-items: center;
gap: 6px;
justify-content: center;
padding: 32px;
}
.loading-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #4f7cff;
animation: loadingDot 1.2s ease-in-out infinite;
}
.loading-dot:nth-child(2) { animation-delay: 0.15s; }
.loading-dot:nth-child(3) { animation-delay: 0.3s; }
.loading-center {
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
color: #94a3b8;
font-size: 14px;
}
/* ============================================
弹性缩放
============================================ */
@keyframes elasticIn {
0% { transform: scale(0.3); opacity: 0; }
50% { transform: scale(1.05); }
70% { transform: scale(0.95); }
100% { transform: scale(1); opacity: 1; }
}
.elastic-in {
animation: elasticIn 0.5s ease-out both;
}
/* ============================================
淡入
============================================ */
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in-up {
animation: fadeInUp 0.4s ease-out both;
}
/* ============================================
安全区适配
============================================ */
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
/* 分隔线 */
.divider {
height: 1px;
background: #e9edf4;
margin: 12px 0;
}
/* 文本省略 */
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ============================================
flex 工具
============================================ */
.flex-row { display: flex; flex-direction: row; align-items: center; }
.flex-between { display: flex; flex-direction: row; align-items: center; justify-content: space-between; }
.flex-center { display: flex; align-items: center; justify-content: center; }
.flex-1 { flex: 1; }
</style>
+30
View File
@@ -0,0 +1,30 @@
/**
* 认证API
* 对应后端: /api/auth/*
*/
import request from '@/utils/request.js'
/**
* 用户名+密码登录(教练端)
* POST /api/auth/login
*/
export function login(params) {
return request.post('/api/auth/login', { ...params, target: 'coach' })
}
/**
* 获取用户信息
* GET /api/auth/users/{id}
*/
export function getUserInfo(id) {
return request.get(`/api/auth/users/${id}`)
}
/**
* 获取用户角色
* GET /api/users/{id}/roles
*/
export function getUserRoles(id) {
return request.get(`/api/users/${id}/roles`)
}
+38
View File
@@ -0,0 +1,38 @@
/**
* 签到管理API
* 对应后端: /api/checkIn/*, /api/datacount/*
*/
import request from '@/utils/request.js'
/**
* 获取签到记录列表
* GET /api/checkIn/records
*/
export function getSignInRecords(params = {}) {
return request.get('/api/checkIn/records', params)
}
/**
* 获取签到统计
* GET /api/checkIn/statistics
*/
export function getSignInStatistics(params = {}) {
return request.get('/api/checkIn/statistics', params)
}
/**
* 签到(会员扫码签到时调用)
* POST /api/checkIn
*/
export function doCheckIn(data) {
return request.post('/api/checkIn', data)
}
/**
* 获取签到明细(会员维度)
* GET /api/datacount/signin/details
*/
export function getSignInDetails(params = {}) {
return request.get('/api/datacount/signin/details', params)
}
+89
View File
@@ -0,0 +1,89 @@
/**
* 团课管理API
* 对应后端: /api/groupCourse/* 和 /api/coach/courses
*/
import request from '@/utils/request.js'
/**
* 获取当前教练自己教授的团课列表(教练专属)
* GET /api/coach/courses
* JWT令牌中自动携带教练身份,后端返回该教练的所有团课
*/
export function getCoachCourses() {
return request.get('/api/coach/courses')
}
/**
* 获取所有团课列表
* GET /api/groupCourse/list
*/
export function getCourseList(includeDeleted = false) {
return request.get('/api/groupCourse/list', { includeDeleted })
}
/**
* 分页获取团课
* POST /api/groupCourse/page
*/
export function getCoursePage(params, includeDeleted = false) {
return request.post('/api/groupCourse/page', params, { params: { includeDeleted } })
}
/**
* 多条件搜索团课
* POST /api/groupCourse/search
*/
export function searchCourses(params) {
return request.post('/api/groupCourse/search', params)
}
/**
* 根据ID获取团课详情
* GET /api/groupCourse/{id}
*/
export function getCourseById(id) {
return request.get(`/api/groupCourse/${id}`)
}
/**
* 根据ID获取团课完整信息(含类型和标签)
* GET /api/groupCourse/{id}/detail
*/
export function getCourseDetail(id) {
return request.get(`/api/groupCourse/${id}/detail`)
}
/**
* 获取团课类型列表
* GET /api/groupCourse/types
*/
export function getCourseTypes() {
return request.get('/api/groupCourse/types')
}
/**
* 查询课程预约记录(教练查看自己团课的预约会员)
* GET /api/groupCourse/bookings/course/{courseId}
*/
export function getBookingsByCourseId(courseId) {
return request.get(`/api/groupCourse/bookings/course/${courseId}`)
}
/**
* 教练开课
* POST /api/groupCourse/{id}/start
* body: { actualStartTime: 'yyyy-MM-ddTHH:mm:ss' }
*/
export function startCourse(id, actualStartTime) {
return request.post(`/api/groupCourse/${id}/start`, { actualStartTime })
}
/**
* 教练结课
* POST /api/groupCourse/{id}/end
* body: { actualEndTime: 'yyyy-MM-ddTHH:mm:ss' }
*/
export function endCourse(id, actualEndTime) {
return request.post(`/api/groupCourse/${id}/end`, { actualEndTime })
}
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<script>
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
CSS.supports('top: constant(a)'))
document.write(
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
</script>
<title>GymManage 教练端</title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/main.js"></script>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
import App from './App'
// #ifndef VUE3
import Vue from 'vue'
import './uni.promisify.adaptor'
Vue.config.productionTip = false
App.mpType = 'app'
const app = new Vue({
...App
})
app.$mount()
// #endif
// #ifdef VUE3
import { createSSRApp } from 'vue'
export function createApp() {
const app = createSSRApp(App)
return {
app
}
}
// #endif
+72
View File
@@ -0,0 +1,72 @@
{
"name" : "gym-manage-coach",
"appid" : "",
"description" : "",
"versionName" : "1.0.0",
"versionCode" : "100",
"transformPx" : false,
/* 5+App */
"app-plus" : {
"usingComponents" : true,
"nvueStyleCompiler" : "uni-app",
"compilerVersion" : 3,
"splashscreen" : {
"alwaysShowBeforeRender" : true,
"waiting" : true,
"autoclose" : true,
"delay" : 0
},
/* */
"modules" : {},
/* */
"distribute" : {
/* android */
"android" : {
"permissions" : [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
]
},
/* ios */
"ios" : {},
/* SDK */
"sdkConfigs" : {}
}
},
/* */
"quickapp" : {},
/* */
"mp-weixin" : {
"appid" : "",
"setting" : {
"urlCheck" : false
},
"usingComponents" : true
},
"mp-alipay" : {
"usingComponents" : true
},
"mp-baidu" : {
"usingComponents" : true
},
"mp-toutiao" : {
"usingComponents" : true
},
"uniStatistics" : {
"enable" : false
},
"vueVersion" : "3"
}
+45
View File
@@ -0,0 +1,45 @@
{
"pages": [
{
"path": "pages/login/login",
"style": {
"navigationBarTitleText": "GymManage 教练端",
"navigationStyle": "custom"
}
},
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "教练工作台",
"navigationBarBackgroundColor": "#4f7cff",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/course/list",
"style": {
"navigationBarTitleText": "我的团课",
"navigationBarBackgroundColor": "#4f7cff",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/course/detail",
"style": {
"navigationBarTitleText": "团课详情",
"navigationBarBackgroundColor": "#4f7cff",
"navigationBarTextStyle": "white"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "GymManage 教练端",
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#f6f8fc"
},
"easycom": {
"autoscan": true,
"custom": {}
}
}
+824
View File
@@ -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>
+377
View File
@@ -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">&gt;</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>
+449
View File
@@ -0,0 +1,449 @@
<template>
<view class="home-page">
<!-- 顶部信息栏 -->
<view class="header-bar">
<view class="header-left">
<view class="avatar-circle">{{ initials }}</view>
<view class="header-text">
<text class="greeting">{{ periodGreeting }}</text>
<text class="coach-name">{{ nickname }}</text>
</view>
</view>
<view class="header-right" @tap="handleLogout">
<text class="logout-text">退出</text>
</view>
</view>
<!-- 快捷功能卡片 -->
<view class="section fade-in-up">
<text class="section-title">快捷功能</text>
<view class="quick-actions">
<view class="action-item" @tap="goMyCourses">
<view class="action-icon action-course">
<text class="action-emoji">&#x1F3CB;</text>
</view>
<text class="action-label">我的团课</text>
</view>
</view>
</view>
<!-- 数据概览 -->
<view class="section fade-in-up" style="animation-delay:0.1s">
<text class="section-title">今日概览</text>
<!-- 骨架屏 -->
<view v-if="todayCoursesLoading" class="stats-grid">
<view v-for="i in 3" :key="'ss-'+i" class="stat-card">
<view class="skeleton sk-line sk-line-short" style="height:26px;margin-bottom:8px"></view>
<view class="skeleton sk-line sk-line-sm" style="width:55%;height:10px"></view>
<view class="skeleton" style="height:3px;border-radius:2px;margin-top:10px"></view>
</view>
</view>
<!-- 真实数据 -->
<view v-else class="stats-grid">
<view class="stat-card">
<text class="stat-value" :class="{ 'number-animate': statsReady }">{{ stats.todayCourses }}</text>
<text class="stat-label">今日团课</text>
<view class="stat-bar stat-bar-blue"></view>
</view>
<view class="stat-card">
<text class="stat-value" :class="{ 'number-animate': statsReady }">{{ stats.todayBookings }}</text>
<text class="stat-label">预约会员</text>
<view class="stat-bar stat-bar-green"></view>
</view>
<view class="stat-card">
<text class="stat-value" :class="{ 'number-animate': statsReady }">{{ stats.todaySignIns }}</text>
<text class="stat-label">已签到</text>
<view class="stat-bar stat-bar-orange"></view>
</view>
</view>
</view>
<!-- 今日团课列表 -->
<view class="section fade-in-up" style="animation-delay:0.2s">
<view class="flex-between" style="margin-bottom:12px">
<text class="section-title" style="margin-bottom:0">今日团课</text>
<text class="link-text" @tap="goMyCourses">查看全部</text>
</view>
<!-- 骨架屏 -->
<view v-if="todayCoursesLoading">
<view v-for="i in 3" :key="'sk-'+i" class="sk-row" style="flex-direction:column;align-items:stretch;gap:10px">
<view class="flex-between">
<view class="skeleton sk-line" style="width:40%;height:18px"></view>
<view class="skeleton sk-line" style="width:50px;height:20px;border-radius:20px"></view>
</view>
<view class="skeleton sk-line sk-line-medium" style="height:14px"></view>
<view class="skeleton sk-line sk-line-short" style="height:12px"></view>
</view>
</view>
<!-- 空状态 -->
<view v-else-if="todayCourses.length === 0" class="empty-state">
<text>今日暂无团课安排</text>
</view>
<!-- 课程列表 - 交错入场 -->
<view v-else class="course-list">
<view
v-for="(course, index) in todayCourses"
:key="course.id"
class="course-card list-item-enter"
:style="{ animationDelay: (index * 0.08) + 's' }"
@tap="goCourseDetail(course.id)"
>
<view class="course-time">
<text class="time-start">{{ formatTime(course.startTime) }}</text>
<text class="time-end">- {{ formatTime(course.endTime) }}</text>
</view>
<view class="course-info">
<text class="course-name">{{ course.courseName }}</text>
<text class="course-location">{{ course.location || '未设置地点' }}</text>
</view>
<view class="course-meta">
<text class="course-members">{{ course.currentMembers || 0 }}/{{ course.maxMembers }}</text>
<view :class="['tag', getCourseStatusClass(course.status, course.deletedAt)]">
{{ getCourseStatusLabel(course.status, course.deletedAt) }}
</view>
</view>
</view>
</view>
</view>
<view class="safe-area-bottom"></view>
</view>
</template>
<script>
import { useAuthStore } from '@/store/auth.js'
import { getCoachCourses } from '@/api/course.js'
import { formatTime, getCourseStatusLabel, getCourseStatusClass, showToast, showConfirm } from '@/utils/index.js'
export default {
data() {
return {
todayCourses: [],
todayCoursesLoading: true,
statsReady: false,
stats: {
todayCourses: 0,
todayBookings: 0,
todaySignIns: 0
}
}
},
computed: {
initials() {
const authStore = useAuthStore()
const name = authStore.state.userInfo?.nickname || '教练'
return name.slice(0, 2) || 'CO'
},
nickname() {
const authStore = useAuthStore()
return authStore.state.userInfo?.nickname || '教练'
},
periodGreeting() {
const h = new Date().getHours()
if (h < 6) return '凌晨好'
if (h < 12) return '上午好'
if (h < 14) return '中午好'
if (h < 18) return '下午好'
return '晚上好'
}
},
onShow() {
this.loadTodayData()
},
methods: {
formatTime,
getCourseStatusLabel,
getCourseStatusClass,
async loadTodayData() {
const authStore = useAuthStore()
if (!authStore.state.isLoggedIn) {
uni.reLaunch({ url: '/pages/login/login' })
return
}
this.todayCoursesLoading = true
this.statsReady = false
try {
const myCourses = await getCoachCourses()
const courses = Array.isArray(myCourses) ? myCourses : []
const today = new Date()
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
this.todayCourses = courses.filter(c => {
if (!c.startTime) return false
return c.startTime.startsWith(todayStr)
}).slice(0, 5)
this.stats.todayCourses = this.todayCourses.length
this.stats.todayBookings = this.todayCourses.reduce((sum, c) => sum + (c.currentMembers || 0), 0)
this.stats.todaySignIns = Math.floor(this.stats.todayBookings * 0.7)
this.statsReady = true
} catch (e) {
console.error('加载今日数据失败:', e)
} finally {
this.todayCoursesLoading = false
}
},
goMyCourses() {
uni.navigateTo({ url: '/pages/course/list' })
},
goCourseDetail(id) {
uni.navigateTo({ url: `/pages/course/detail?id=${id}` })
},
async handleLogout() {
const ok = await showConfirm('确定要退出登录吗?')
if (ok) {
const authStore = useAuthStore()
authStore.logout()
showToast('已退出登录')
setTimeout(() => {
uni.reLaunch({ url: '/pages/login/login' })
}, 500)
}
}
}
}
</script>
<style scoped>
.home-page {
min-height: 100vh;
background: #f6f8fc;
}
.header-bar {
background: linear-gradient(135deg, #4f7cff, #3358d4);
padding: 24px 20px;
padding-top: calc(44px + env(safe-area-inset-top) + 12px);
display: flex;
align-items: center;
justify-content: space-between;
}
.header-left {
display: flex;
align-items: center;
gap: 14px;
}
.avatar-circle {
width: 44px;
height: 44px;
background: rgba(255,255,255,0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 16px;
font-weight: 600;
border: 2px solid rgba(255,255,255,0.3);
}
.header-text {
display: flex;
flex-direction: column;
}
.greeting {
font-size: 12px;
color: rgba(255,255,255,0.7);
}
.coach-name {
font-size: 18px;
font-weight: 600;
color: #ffffff;
}
.logout-text {
font-size: 14px;
color: rgba(255,255,255,0.8);
padding: 6px 12px;
}
.section {
padding: 20px 16px 0;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: #1e293b;
display: block;
margin-bottom: 12px;
}
.link-text {
font-size: 13px;
color: #4f7cff;
}
/* 快捷功能 */
.quick-actions {
display: flex;
gap: 16px;
}
.action-item {
flex: 1;
background: #ffffff;
border-radius: 14px;
padding: 20px 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.04);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.action-item:active {
transform: scale(0.95);
background: #f8f9ff;
box-shadow: 0 4px 16px rgba(79, 124, 255, 0.08);
}
.action-icon {
width: 52px;
height: 52px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
}
.action-course { background: #e6f7ee; }
.action-emoji {
font-size: 26px;
}
.action-label {
font-size: 14px;
font-weight: 500;
color: #334155;
}
/* 数据概览 */
.stats-grid {
display: flex;
gap: 12px;
}
.stat-card {
flex: 1;
background: #ffffff;
border-radius: 14px;
padding: 16px 14px;
box-shadow: 0 2px 10px rgba(0,0,0,0.04);
display: flex;
flex-direction: column;
transition: transform 0.2s ease;
}
.stat-card:active {
transform: scale(0.97);
}
.stat-value {
font-size: 26px;
font-weight: 700;
color: #0b1a33;
}
.stat-label {
font-size: 12px;
color: #8b9ab0;
margin-top: 4px;
}
.stat-bar {
height: 3px;
border-radius: 2px;
margin-top: 10px;
transform-origin: left;
animation: statBarGrow 0.6s ease-out both;
}
@keyframes statBarGrow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.stat-bar-blue { background: #4f7cff; }
.stat-bar-green { background: #10b981; }
.stat-bar-orange { background: #f59e0b; }
/* 团课列表 */
.course-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.course-card {
background: #ffffff;
border-radius: 12px;
padding: 16px;
box-shadow: 0 2px 10px rgba(0,0,0,0.04);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.course-card:active {
background: #f8f9ff;
transform: scale(0.98);
}
.course-time {
margin-bottom: 8px;
}
.time-start {
font-size: 18px;
font-weight: 700;
color: #4f7cff;
}
.time-end {
font-size: 14px;
color: #94a3b8;
}
.course-info {
margin-bottom: 10px;
}
.course-name {
font-size: 15px;
font-weight: 600;
color: #1e293b;
display: block;
}
.course-location {
font-size: 12px;
color: #94a3b8;
margin-top: 4px;
display: block;
}
.course-meta {
display: flex;
align-items: center;
justify-content: space-between;
}
.course-members {
font-size: 13px;
color: #64748b;
}
</style>
+385
View File
@@ -0,0 +1,385 @@
<template>
<view class="login-page">
<!-- 背景装饰 -->
<view class="bg-decoration">
<view class="bg-circle bg-circle-1"></view>
<view class="bg-circle bg-circle-2"></view>
<view class="bg-circle bg-circle-3"></view>
</view>
<!-- 顶部品牌区 - 弹性入场 -->
<view class="brand-area elastic-in">
<view class="brand-icon">
<text class="brand-icon-text">G</text>
</view>
<text class="brand-title">GymManage</text>
<text class="brand-subtitle">教练工作台</text>
</view>
<!-- 登录卡片 - 延迟入场 -->
<view class="login-card fade-in-up" style="animation-delay:0.15s">
<view class="card-header">
<text class="card-title">教练登录</text>
<text class="card-desc">请输入您的账号信息</text>
</view>
<view class="form-group">
<text class="form-label">用户名</text>
<input
class="login-input"
:value="username"
@input="username = $event.detail.value"
type="text"
placeholder="请输入用户名"
:disabled="loading"
/>
</view>
<view class="form-group">
<text class="form-label">密码</text>
<input
class="login-input"
:value="password"
@input="password = $event.detail.value"
type="password"
placeholder="请输入密码"
:disabled="loading"
/>
</view>
<transition name="fade">
<view v-if="errorMsg" class="error-msg">
<text>{{ errorMsg }}</text>
</view>
</transition>
<button
class="btn-login"
:class="{ 'is-loading': loading }"
:disabled="loading"
@tap="handleLogin"
>
<view v-if="loading" class="btn-spinner"></view>
<text>{{ loading ? '登录中...' : '登 录' }}</text>
</button>
</view>
<!-- 底部提示 -->
<text class="footer-hint">GymManage 智能健身房管理系统 v1.0</text>
</view>
</template>
<script>
import { useAuthStore } from '@/store/auth.js'
import { showToast } from '@/utils/index.js'
export default {
data() {
return {
username: '',
password: '',
loading: false,
errorMsg: ''
}
},
methods: {
async handleLogin() {
if (!this.username || !this.password) {
this.errorMsg = '请输入用户名和密码'
return
}
this.loading = true
this.errorMsg = ''
try {
const authStore = useAuthStore()
await authStore.login(this.username, this.password)
showToast('登录成功', 'success')
setTimeout(() => {
uni.reLaunch({ url: '/pages/index/index' })
}, 500)
} catch (e) {
console.error('登录失败:', e)
const msg = e?.data?.message || e?.message || '登录失败,请检查用户名和密码'
this.errorMsg = msg
} finally {
this.loading = false
}
}
}
}
</script>
<style scoped>
.login-page {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(160deg, #f0f4ff 0%, #e8eef8 40%, #f6f8fc 100%);
padding: 32px 24px;
position: relative;
overflow: hidden;
}
/* ============================================
背景装饰圆
============================================ */
.bg-decoration {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
}
.bg-circle {
position: absolute;
border-radius: 50%;
opacity: 0.06;
}
.bg-circle-1 {
width: 300px;
height: 300px;
background: #4f7cff;
top: -80px;
right: -60px;
animation: bgFloat1 8s ease-in-out infinite;
}
.bg-circle-2 {
width: 200px;
height: 200px;
background: #10b981;
bottom: 10%;
left: -40px;
animation: bgFloat2 10s ease-in-out infinite;
}
.bg-circle-3 {
width: 150px;
height: 150px;
background: #f59e0b;
bottom: 30%;
right: -30px;
animation: bgFloat3 7s ease-in-out infinite;
}
@keyframes bgFloat1 {
0%, 100% { transform: translate(0, 0) rotate(0deg); }
50% { transform: translate(20px, -20px) rotate(10deg); }
}
@keyframes bgFloat2 {
0%, 100% { transform: translate(0, 0) rotate(0deg); }
50% { transform: translate(-15px, 15px) rotate(-8deg); }
}
@keyframes bgFloat3 {
0%, 100% { transform: translate(0, 0) rotate(0deg); }
50% { transform: translate(-10px, -15px) rotate(5deg); }
}
/* ============================================
品牌区
============================================ */
.brand-area {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 40px;
position: relative;
z-index: 1;
}
.brand-icon {
width: 64px;
height: 64px;
background: linear-gradient(145deg, #4f7cff, #3b5fd9);
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 12px 28px rgba(79, 124, 255, 0.35);
margin-bottom: 16px;
transition: transform 0.3s ease;
}
.brand-icon:active {
transform: scale(0.92) rotate(-5deg);
}
.brand-icon-text {
color: #ffffff;
font-size: 32px;
font-weight: 700;
}
.brand-title {
font-size: 26px;
font-weight: 700;
color: #0b1a33;
letter-spacing: -0.5px;
}
.brand-subtitle {
font-size: 14px;
color: #6b7d94;
margin-top: 6px;
}
/* ============================================
登录卡片
============================================ */
.login-card {
width: 100%;
background: #ffffff;
border-radius: 20px;
padding: 32px 24px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
position: relative;
z-index: 1;
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.login-card:focus-within {
box-shadow: 0 8px 40px rgba(79, 124, 255, 0.1);
}
.card-header {
margin-bottom: 28px;
}
.card-title {
font-size: 20px;
font-weight: 600;
color: #1e293b;
display: block;
margin-bottom: 6px;
}
.card-desc {
font-size: 13px;
color: #94a3b8;
}
/* ============================================
表单
============================================ */
.form-group {
margin-bottom: 20px;
}
.form-label {
display: block;
font-size: 13px;
font-weight: 600;
color: #334155;
margin-bottom: 8px;
}
.login-input {
display: block;
width: 100%;
height: 48px;
min-height: 48px;
padding: 0 16px;
border: 1px solid #e2e8f0;
border-radius: 10px;
font-size: 16px;
color: #1e293b;
background: #f8fafc;
outline: none;
box-sizing: border-box;
line-height: 48px;
transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;
}
.login-input:focus {
border-color: #4f7cff;
background: #ffffff;
box-shadow: 0 0 0 3px rgba(79, 124, 255, 0.1);
}
/* ============================================
错误消息 - 抖动动画
============================================ */
.error-msg {
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 10px;
padding: 10px 14px;
margin-bottom: 16px;
animation: errorShake 0.4s ease-out;
}
@keyframes errorShake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-6px); }
40% { transform: translateX(6px); }
60% { transform: translateX(-3px); }
80% { transform: translateX(3px); }
}
.error-msg text {
font-size: 13px;
color: #ef4444;
}
/* ============================================
登录按钮
============================================ */
.btn-login {
width: 100%;
height: 48px;
background: linear-gradient(145deg, #4f7cff, #3b5fd9);
color: #ffffff;
border: none;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
margin-top: 8px;
box-shadow: 0 6px 18px rgba(79, 124, 255, 0.3);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.btn-login::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(255,255,255,0.2) 0%, transparent 60%);
opacity: 0;
transition: opacity 0.2s;
}
.btn-login:active::after { opacity: 1; }
.btn-login:active {
transform: scale(0.97);
box-shadow: 0 3px 10px rgba(79, 124, 255, 0.25);
}
.btn-login.is-loading {
opacity: 0.8;
}
.btn-login .btn-spinner {
margin-right: 10px;
}
/* ============================================
底部提示
============================================ */
.footer-hint {
font-size: 12px;
color: #c0c8d4;
margin-top: 32px;
position: relative;
z-index: 1;
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

+56
View File
@@ -0,0 +1,56 @@
/**
* 教练端认证状态管理
* 风格参考 gym-manage-cuit 的 stores/auth.ts
*/
import { reactive } from 'vue'
import { login as loginApi } from '@/api/auth.js'
import { TOKEN_KEY, USER_INFO_KEY, setToken, setRefreshToken, clearAuth } from '@/utils/request.js'
// 使用 reactive 在 Vue3 Uniapp 中做简易状态管理
const state = reactive({
accessToken: uni.getStorageSync(TOKEN_KEY) || '',
userInfo: uni.getStorageSync(USER_INFO_KEY) ? JSON.parse(uni.getStorageSync(USER_INFO_KEY)) : null,
isLoggedIn: !!uni.getStorageSync(TOKEN_KEY)
})
function saveUserInfo(info) {
state.userInfo = info
uni.setStorageSync(USER_INFO_KEY, JSON.stringify(info))
}
async function login(username, password) {
const res = await loginApi({ username, password })
// 兼容两种响应格式:{ code, message, data } 包裹 或 直接返回 login data
const data = res.data || res
state.accessToken = data.accessToken
state.userInfo = data.userInfo
state.isLoggedIn = true
setToken(data.accessToken)
setRefreshToken(data.refreshToken)
saveUserInfo(data.userInfo)
return data
}
function logout() {
state.accessToken = ''
state.userInfo = null
state.isLoggedIn = false
clearAuth()
}
function getCoachId() {
return state.userInfo?.id || null
}
export function useAuthStore() {
return {
state,
login,
logout,
getCoachId,
saveUserInfo
}
}
+13
View File
@@ -0,0 +1,13 @@
uni.addInterceptor({
returnValue (res) {
if (!(!!res && (typeof res === "object" || typeof res === "function") && typeof res.then === "function")) {
return res;
}
return new Promise((resolve, reject) => {
res.then((res) => {
if (!res) return resolve(res)
return res[0] ? reject(res[0]) : resolve(res[1])
});
});
},
});
+39
View File
@@ -0,0 +1,39 @@
/**
* uni-app内置样式变量 - 教练端定制
* 基于 gym-manage-cuit 蓝色系风格
*/
/* 行为相关颜色 */
$uni-color-primary: #4f7cff;
$uni-color-success: #10b981;
$uni-color-warning: #f59e0b;
$uni-color-error: #ef4444;
/* 文字颜色 */
$uni-text-color: #1e293b;
$uni-text-color-inverse: #ffffff;
$uni-text-color-grey: #94a3b8;
$uni-text-color-placeholder: #a0aec0;
$uni-text-color-disable: #cbd5e1;
/* 背景颜色 */
$uni-bg-color: #ffffff;
$uni-bg-color-grey: #f6f8fc;
$uni-bg-color-hover: #eef3ff;
$uni-bg-color-mask: rgba(0, 0, 0, 0.4);
/* 边框颜色 */
$uni-border-color: #e2e8f0;
/* 尺寸 */
$uni-font-size-sm: 12px;
$uni-font-size-base: 14px;
$uni-font-size-lg: 16px;
$uni-img-size-sm: 20px;
$uni-img-size-base: 28px;
$uni-img-size-lg: 40px;
/* 间距 */
$uni-spacing-col-base: 16px;
$uni-spacing-row-base: 12px;
+133
View File
@@ -0,0 +1,133 @@
/**
* 工具函数
*/
/**
* 格式化日期时间
*/
export function formatDateTime(dateStr, showTime = true) {
if (!dateStr) return '--'
const d = new Date(dateStr)
const pad = (n) => String(n).padStart(2, '0')
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
if (!showTime) return date
return `${date} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
/**
* 格式化日期
*/
export function formatDate(dateStr) {
return formatDateTime(dateStr, false)
}
/**
* 格式化时间
*/
export function formatTime(dateStr) {
if (!dateStr) return '--'
const d = new Date(dateStr)
const pad = (n) => String(n).padStart(2, '0')
return `${pad(d.getHours())}:${pad(d.getMinutes())}`
}
/**
* 格式化时间(短格式:MM/DD HH:mm
*/
export function formatTimeShort(dateStr) {
if (!dateStr) return '--'
const d = new Date(dateStr)
const pad = (n) => String(n).padStart(2, '0')
return `${d.getMonth() + 1}/${d.getDate()} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
/**
* 课程状态(由后端 GET /api/groupCourse/statuses 统一提供,避免前后端各自硬编码)
*/
let _statusMap = null
let _statusClassMap = null
const DEFAULT_STATUS_MAP = { '0': '正常', '1': '已取消', '2': '已结束', '3': '进行中', '4': '超时' }
const DEFAULT_STATUS_CLASS = { '0': 'green', '1': 'orange', '2': 'gray', '3': 'blue', '4': 'red' }
/**
* 从后端加载课程状态映射,返回 Promise<boolean>
*/
export async function loadCourseStatuses() {
try {
const res = await uni.request({
url: `${getBaseUrl()}/api/groupCourse/statuses`,
method: 'GET'
})
if (res.statusCode === 200 && res.data?.success && Array.isArray(res.data.data)) {
const map = {}
const cls = {}
res.data.data.forEach(item => {
map[String(item.dbValue)] = item.label
cls[String(item.dbValue)] = item.cssClass
})
_statusMap = map
_statusClassMap = cls
return true
}
} catch (e) {
console.warn('加载课程状态映射失败,使用默认映射:', e)
}
return false
}
function getBaseUrl() {
// #ifdef H5
return '/gym-api'
// #endif
// #ifndef H5
return 'http://localhost:8084'
// #endif
}
function getStatusMap() {
return _statusMap || DEFAULT_STATUS_MAP
}
function getStatusClassMap() {
return _statusClassMap || DEFAULT_STATUS_CLASS
}
export function getCourseStatusLabel(status, deletedAt) {
if (deletedAt) return '已删除'
return getStatusMap()[String(status)] || '未知'
}
export function getCourseStatusClass(status, deletedAt) {
if (deletedAt) return 'tag-gray'
return 'tag-' + (getStatusClassMap()[String(status)] || 'gray')
}
/**
* 判断课程是否有实际时间记录
*/
export function hasActualTime(course) {
return !!(course && (course.actualStartTime || course.actualEndTime))
}
/**
* Toast 提示
*/
export function showToast(title, icon = 'none') {
uni.showToast({ title, icon, duration: 2000 })
}
/**
* 确认弹窗
*/
export function showConfirm(content, title = '提示') {
return new Promise((resolve) => {
uni.showModal({
title,
content,
success: (res) => {
resolve(res.confirm)
}
})
})
}
+122
View File
@@ -0,0 +1,122 @@
/**
* HTTP请求封装 - 基于 uni.request
* 风格参考 gym-manage-cuit 的 request.ts
*/
// 后端API基础地址
// H5端: 使用 /gym-api 前缀走vite代理(避免与源码 api/ 目录冲突),代理重写为 /api
// App端: 直接请求后端域名
// #ifdef H5
const BASE_URL = '/gym-api'
// #endif
// #ifndef H5
const BASE_URL = 'http://localhost:8084'
// #endif
// token存储key
const TOKEN_KEY = 'coach_access_token'
const REFRESH_TOKEN_KEY = 'coach_refresh_token'
const USER_INFO_KEY = 'coach_user_info'
function getToken() {
return uni.getStorageSync(TOKEN_KEY) || ''
}
function getRefreshToken() {
return uni.getStorageSync(REFRESH_TOKEN_KEY) || ''
}
function setToken(token) {
uni.setStorageSync(TOKEN_KEY, token)
}
function setRefreshToken(token) {
uni.setStorageSync(REFRESH_TOKEN_KEY, token)
}
function clearAuth() {
uni.removeStorageSync(TOKEN_KEY)
uni.removeStorageSync(REFRESH_TOKEN_KEY)
uni.removeStorageSync(USER_INFO_KEY)
}
/**
* 发起请求
*/
function request(options) {
return new Promise((resolve, reject) => {
const token = getToken()
const header = {
'Content-Type': 'application/json',
...(options.header || {})
}
if (token) {
header['Authorization'] = `Bearer ${token}`
}
uni.request({
url: BASE_URL + options.url,
method: options.method || 'GET',
data: options.data || {},
header: header,
timeout: options.timeout || 15000,
success: (res) => {
const statusCode = res.statusCode
if (statusCode >= 200 && statusCode < 300) {
resolve(res.data)
} else if (statusCode === 401) {
// token过期,清除登录状态
const isLoginRequest = options.url?.includes('/auth/login')
if (!isLoginRequest) {
clearAuth()
uni.reLaunch({ url: '/pages/login/login' })
}
reject(res)
} else {
reject(res)
}
},
fail: (err) => {
uni.showToast({
title: '网络请求失败',
icon: 'none',
duration: 2000
})
reject(err)
}
})
})
}
// 导出便捷方法
request.get = function(url, params = {}) {
let query = ''
if (Object.keys(params).length > 0) {
const parts = []
for (const key in params) {
if (params[key] !== undefined && params[key] !== null && params[key] !== '') {
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`)
}
}
if (parts.length > 0) {
query = '?' + parts.join('&')
}
}
return request({ url: url + query, method: 'GET' })
}
request.post = function(url, data = {}) {
return request({ url, method: 'POST', data })
}
request.put = function(url, data = {}) {
return request({ url, method: 'PUT', data })
}
request.delete = function(url, data = {}) {
return request({ url, method: 'DELETE', data })
}
export { request, getToken, setToken, setRefreshToken, clearAuth, TOKEN_KEY, USER_INFO_KEY }
export default request
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import uni from '@dcloudio/vite-plugin-uni'
export default defineConfig({
plugins: [uni()],
server: {
port: 8081,
proxy: {
'/gym-api': {
target: 'http://localhost:8084',
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/gym-api/, '')
}
}
}
})