增加品牌方案管理

This commit is contained in:
2026-07-23 18:56:35 +08:00
parent 4c07ec5455
commit b689656faf
67 changed files with 5572 additions and 231 deletions
+4
View File
@@ -1,10 +1,14 @@
<script>
const brandStore = require('./store/brand')
export default {
onLaunch: function() {
console.log('Coach App Launch')
brandStore.fetchConfig()
},
onShow: function() {
console.log('Coach App Show')
brandStore.fetchConfig()
},
onHide: function() {
console.log('Coach App Hide')
+11
View File
@@ -0,0 +1,11 @@
const http = require('../utils/request')
module.exports = {
/**
* 获取品牌配置(需 JWT 登录态,自动按 tenantId 返回对应租户配置)
* @returns {Promise<Object>} 品牌配置对象
*/
getBrandConfig() {
return http.get('/brand')
}
}
@@ -1,6 +1,6 @@
<template>
<view class="detail-page">
<view class="header-wrap" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="header-wrap" :style="headerStyle">
<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
<text class="back-btn" @click="goBack">&#8592;</text>
<text class="nav-title">课程详情</text>
@@ -11,7 +11,7 @@
<view class="cover-area">
<image v-if="course.coverImage && !loading" class="cover-img" :src="course.coverImage" mode="aspectFill"
@error="course.coverError = true" />
<view v-if="!course.coverImage || course.coverError || loading" class="cover-bg">
<view v-if="!course.coverImage || course.coverError || loading" class="cover-bg" :style="{ background: coverGradient }">
<text class="cover-text">{{ (course.typeName ? course.typeName.slice(0, 2) : '团课') }}</text>
</view>
<view class="cover-status" :class="courseStatusClass" v-if="!loading">
@@ -47,7 +47,7 @@
</view>
<view class="info-item" v-if="course.storedValueAmount">
<text class="info-label">消耗</text>
<text class="info-value price-text">¥{{ course.storedValueAmount }}</text>
<text class="info-value price-text" :style="{ color: brandConfig.primaryColor }">¥{{ course.storedValueAmount }}</text>
</view>
</view>
@@ -63,6 +63,7 @@
v-if="course.status == 0"
class="action-btn start-btn"
:class="{ disabled: !canStartCourse }"
:style="startBtnStyle"
:loading="actionLoading"
:disabled="actionLoading || !canStartCourse"
@click="handleStartCourse"
@@ -100,6 +101,7 @@
<script>
const coachApi = require('../../api/coach')
const store = require('../../store/index')
const brandStore = require('../../store/brand')
const { resolveCoverUrl } = require('../../utils/request')
export default {
@@ -113,11 +115,18 @@
actionLoading: false,
course: {},
now: Date.now(),
realMemberCount: 0
realMemberCount: 0,
brandConfig: brandStore.config
}
},
computed: {
duration() {
headerStyle() { return 'padding-top:' + this.statusBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor },
startBtnStyle() {
if (!this.canStartCourse) return ''
return 'background-color:' + this.brandConfig.primaryColor + ';color:' + this.brandConfig.secondaryColor
},
coverGradient() { return 'linear-gradient(135deg, ' + this.brandConfig.primaryColor + ', #00BFA5)' },
duration() {
if (!this.course.startTime || !this.course.endTime) return '--'
const start = new Date(this.course.startTime).getTime()
const end = new Date(this.course.endTime).getTime()
@@ -158,6 +167,11 @@
this.courseId = options.id
this.loadDetail()
}
uni.$on('brand:updated', (config) => { this.brandConfig = config })
},
onUnload() {
uni.$off('brand:updated')
},
onShow() {
if (!store.isLoggedIn) {
@@ -1,6 +1,6 @@
<template>
<view class="course-list-page">
<view class="header-wrap" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="header-wrap" :style="headerStyle">
<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
<text class="back-btn" @click="goBack">&#8592;</text>
<text class="nav-title">我的团课</text>
@@ -12,6 +12,7 @@
:key="idx"
class="tab-item"
:class="{ active: currentTab === tab.value }"
:style="currentTab === tab.value ? tabActiveStyle : ''"
@click="switchTab(tab.value)"
>
<text>{{ tab.label }}</text>
@@ -33,7 +34,7 @@
<view v-for="course in filteredCourses" :key="course.id" class="course-card" :class="{ grayed: !course._isToday }" @click="goToDetail(course.id)">
<view class="card-header">
<text class="course-name">{{ course.courseName }}</text>
<view class="status-tag" :class="course._statusClass">
<view class="status-tag" :class="course._statusClass" :style="course._statusClass === 'normal' ? statusNormalStyle : ''">
<text>{{ course._statusLabel }}</text>
</view>
</view>
@@ -62,6 +63,7 @@
<script>
const coachApi = require('../../api/coach')
const store = require('../../store/index')
const brandStore = require('../../store/brand')
export default {
data() {
@@ -78,10 +80,14 @@
{ label: '待开课', value: 'pending' },
{ label: '进行中', value: 'in_progress' },
{ label: '已结束', value: 'ended' }
]
],
brandConfig: brandStore.config
}
},
computed: {
headerStyle() { return 'padding-top:' + this.statusBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor },
tabActiveStyle() { return 'background-color:' + this.brandConfig.primaryColor + ';color:' + this.brandConfig.secondaryColor },
statusNormalStyle() { return 'background-color:rgba(' + this.brandConfig.primaryColorRgb + ',0.15);color:' + this.brandConfig.primaryColor },
filteredCourses() {
switch (this.currentTab) {
case 'pending':
@@ -109,14 +115,24 @@
this.navBarHeight = navBarHeight
// 状态栏 + 导航栏 + tab行高度
this.totalHeaderHeight = statusBarHeight + navBarHeight + 44
uni.$on('brand:updated', (config) => { this.brandConfig = config })
},
onUnload() {
uni.$off('brand:updated')
},
onShow() {
brandStore.fetchConfig()
if (!store.isLoggedIn) {
uni.reLaunch({ url: '/pages/login/login' })
return
}
this.loadCourses()
},
onPullDownRefresh() {
brandStore.fetchConfig()
this.loadCourses().finally(() => { uni.stopPullDownRefresh() })
},
methods: {
goBack() { uni.navigateBack() },
goToDetail(id) {
+23 -13
View File
@@ -1,7 +1,7 @@
<template>
<view class="home-page">
<view class="header-wrap" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
<view class="header-wrap" :style="headerStyle">
<view class="nav-bar" :style="navStyle">
<text class="nav-title"> Novalon 教练端</text>
</view>
</view>
@@ -9,10 +9,10 @@
<scroll-view class="content" scroll-y :style="{ height: 'calc(100vh - ' + totalHeaderHeight + 'px)' }">
<view class="content-inner">
<!-- 教练欢迎卡片 -->
<view class="greeting-card">
<view class="greeting-card" :style="{ backgroundColor: brandConfig.secondaryColor }">
<view class="greeting-text">
<text class="greeting-label">{{ greetingLabel }}</text>
<text class="coach-name">{{ coachName }} 教练</text>
<text class="coach-name" :style="{ color: brandConfig.primaryColor }">{{ coachName }} 教练</text>
</view>
<view class="greeting-desc">
<text>祝您今日授课顺利</text>
@@ -22,17 +22,17 @@
<!-- 今日统计 -->
<view class="stats-card">
<view class="stat-item">
<text class="stat-number">{{ todayStats.pending }}</text>
<text class="stat-number" :style="{ color: brandConfig.primaryColor }">{{ todayStats.pending }}</text>
<text class="stat-label">待开课</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-number">{{ todayStats.inProgress }}</text>
<text class="stat-number" :style="{ color: brandConfig.primaryColor }">{{ todayStats.inProgress }}</text>
<text class="stat-label">进行中</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-number">{{ todayStats.ended }}</text>
<text class="stat-number" :style="{ color: brandConfig.primaryColor }">{{ todayStats.ended }}</text>
<text class="stat-label">已结束</text>
</view>
</view>
@@ -43,15 +43,15 @@
</view>
<view class="entry-grid">
<view class="entry-card" @click="goToCourseList">
<view class="entry-icon-wrap green-bg">
<text class="entry-icon">&#9776;</text>
<view class="entry-icon-wrap green-bg" :style="{ backgroundColor: 'rgba(' + brandConfig.primaryColorRgb + ',0.15)' }">
<text class="entry-icon" :style="{ color: brandConfig.primaryColor }">&#9776;</text>
</view>
<text class="entry-name">我的团课</text>
<text class="entry-desc">查看和管理课程</text>
</view>
<view class="entry-card" @click="goToProfile">
<view class="entry-icon-wrap dark-bg">
<text class="entry-icon">&#9786;</text>
<view class="entry-icon-wrap dark-bg" :style="{ backgroundColor: 'rgba(' + brandConfig.secondaryColorRgb + ',0.1)' }">
<text class="entry-icon" :style="{ color: brandConfig.primaryColor }">&#9786;</text>
</view>
<text class="entry-name">个人中心</text>
<text class="entry-desc">信息与违规记录</text>
@@ -67,6 +67,7 @@
<script>
const store = require('../../store/index')
const coachApi = require('../../api/coach')
const brandStore = require('../../store/brand')
export default {
data() {
@@ -79,10 +80,13 @@
pending: 0,
inProgress: 0,
ended: 0
}
},
brandConfig: brandStore.config
}
},
computed: {
headerStyle() { return 'padding-top:' + this.statusBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor },
navStyle() { return 'height:' + this.navBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor },
greetingLabel() {
const hour = new Date().getHours()
if (hour < 6) return '夜深了,'
@@ -104,6 +108,11 @@
this.statusBarHeight = statusBarHeight
this.navBarHeight = navBarHeight
this.totalHeaderHeight = statusBarHeight + navBarHeight
uni.$on('brand:updated', (config) => { this.brandConfig = config })
},
onUnload() {
uni.$off('brand:updated')
},
onShow() {
if (!store.isLoggedIn) {
@@ -114,7 +123,8 @@
this.loadStats()
},
onPullDownRefresh() {
this.loadStats().finally(() => { uni.stopPullDownRefresh() })
Promise.all([this.loadStats(), brandStore.fetchConfig()])
.finally(() => { uni.stopPullDownRefresh() })
},
methods: {
goToCourseList() {
+30 -31
View File
@@ -1,11 +1,12 @@
<template>
<view class="login-page" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="login-page" :style="{ paddingTop: statusBarHeight + 'px', backgroundColor: brandConfig.secondaryColor }">
<view class="brand-area">
<view class="logo-icon">
<text class="logo-symbol"></text>
<view class="logo-wrap" :style="{ backgroundColor: brandConfig.logoUrl ? 'transparent' : 'rgba(' + brandConfig.primaryColorRgb + ',0.15)' }">
<image v-if="brandConfig.logoUrl" class="logo-img" :src="brandConfig.logoUrl" mode="aspectFit" />
<text v-else class="logo-symbol" :style="{ color: brandConfig.primaryColor }"></text>
</view>
<text class="app-name">Novalon 教练端</text>
<text class="app-slogan">高效管理 · 轻松授课</text>
<text class="app-name">{{ brandConfig.brandName }}</text>
<text class="app-slogan" v-if="brandConfig.slogan">{{ brandConfig.slogan }}</text>
</view>
<view class="login-form">
@@ -33,13 +34,13 @@
</text>
</view>
<button class="login-btn" :loading="loading" :disabled="loading || !username || !password" @click="handleLogin">
<button class="login-btn" :style="loginBtnStyle" :loading="loading" :disabled="loading || !username || !password" @click="handleLogin">
{{ loading ? '登录中...' : ' ' }}
</button>
</view>
<view class="footer-tip">
<text>Novalon 健身房管理系统</text>
<text>{{ brandConfig.brandName }}</text>
</view>
</view>
</template>
@@ -47,6 +48,7 @@
<script>
const coachApi = require('../../api/coach')
const store = require('../../store/index')
const brandStore = require('../../store/brand')
export default {
data() {
@@ -55,9 +57,13 @@
username: '',
password: '',
showPassword: false,
loading: false
loading: false,
brandConfig: brandStore.config
}
},
computed: {
loginBtnStyle() { return 'background-color:' + this.brandConfig.primaryColor + ';color:' + this.brandConfig.secondaryColor }
},
onLoad() {
const systemInfo = uni.getSystemInfoSync()
this.statusBarHeight = systemInfo.statusBarHeight || 20
@@ -65,6 +71,11 @@
if (store.isLoggedIn) {
uni.reLaunch({ url: '/pages/index/index' })
}
uni.$on('brand:updated', (config) => { this.brandConfig = config })
},
onUnload() {
uni.$off('brand:updated')
},
methods: {
async handleLogin() {
@@ -113,7 +124,7 @@
<style scoped>
.login-page {
min-height: 100vh;
background: #F5F7FA;
background: #1A1A1A;
display: flex;
flex-direction: column;
align-items: center;
@@ -128,29 +139,18 @@
margin-top: 80px;
margin-bottom: 60px;
}
.logo-icon {
width: 80px;
height: 80px;
background: rgba(0,230,118,0.15);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
}
.logo-symbol {
font-size: 36px;
color: #00C853;
}
.logo-wrap { width: 120px; height: 120px; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin-bottom: 24px; overflow: hidden; }
.logo-img { width: 100%; height: 100%; }
.logo-symbol { font-size: 52px; color: #00C853; }
.app-name {
font-size: 28px;
font-weight: 700;
color: #1E1E1E;
color: #FFFFFF;
letter-spacing: 1px;
}
.app-slogan {
font-size: 14px;
color: #7A7E84;
color: rgba(255,255,255,0.6);
margin-top: 8px;
letter-spacing: 2px;
}
@@ -164,28 +164,27 @@
.input-group {
width: 100%;
height: 52px;
background: #FFFFFF;
background: rgba(255,255,255,0.1);
border-radius: 40px;
display: flex;
align-items: center;
padding: 0 18px;
margin-bottom: 14px;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
}
.input-icon {
font-size: 18px;
margin-right: 10px;
color: #7A7E84;
color: rgba(255,255,255,0.5);
}
.login-input {
flex: 1;
height: 100%;
font-size: 15px;
color: #1E1E1E;
color: #FFFFFF;
}
.toggle-pwd {
font-size: 18px;
color: #7A7E84;
color: rgba(255,255,255,0.5);
padding-left: 8px;
}
@@ -212,6 +211,6 @@
position: absolute;
bottom: 60px;
font-size: 12px;
color: #BDBDBD;
color: rgba(255,255,255,0.3);
}
</style>
@@ -1,6 +1,6 @@
<template>
<view class="profile-page">
<view class="header-wrap" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="header-wrap" :style="headerStyle">
<view class="nav-bar" :style="{ height: navBarHeight + 'px' }">
<text class="back-btn" @click="goBack">&#8592;</text>
<text class="nav-title">个人中心</text>
@@ -12,13 +12,13 @@
<!-- 教练信息卡片 -->
<view class="user-card">
<view class="user-header">
<view class="avatar">
<text class="avatar-text">CC</text>
<view class="avatar" :style="{ backgroundColor: 'rgba(' + brandConfig.primaryColorRgb + ',0.15)' }">
<text class="avatar-text" :style="{ color: brandConfig.primaryColor }">CC</text>
</view>
<view class="user-info">
<text class="user-name">{{ coachInfo.username }}</text>
<view class="user-tag">
<text>教练</text>
<text :style="{ color: brandConfig.primaryColor }">教练</text>
</view>
</view>
</view>
@@ -49,26 +49,26 @@
<text class="perf-number">{{ performance.attendanceRate != null ? performance.attendanceRate + '%' : 'N/A' }}</text>
<text class="perf-label">出勤率 </text>
<view v-if="performance.attendanceRate != null" class="perf-bar-wrap">
<view class="perf-bar" :style="{ width: performance.attendanceRate + '%', background: getRateColor(performance.attendanceRate) }"></view>
<view class="perf-bar" :style="attendanceBarStyle"></view>
</view>
</view>
<view class="perf-item" @click="showFormula('fullness')">
<text class="perf-number">{{ performance.fullnessRate != null ? performance.fullnessRate + '%' : 'N/A' }}</text>
<text class="perf-label">满员率 </text>
<view v-if="performance.fullnessRate != null" class="perf-bar-wrap">
<view class="perf-bar" :style="{ width: performance.fullnessRate + '%', background: getRateColor(performance.fullnessRate) }"></view>
<view class="perf-bar" :style="fullnessBarStyle"></view>
</view>
</view>
</view>
<view class="score-row" @click="showFormula('composite')">
<text class="score-label">综合评分 </text>
<text class="score-badge" :class="scoreClass">{{ scoreText }}</text>
<text class="score-badge" :class="scoreClass" :style="scoreClass === 'score-great' ? scoreGreatStyle : ''">{{ scoreText }}</text>
<text v-if="scoreText !== '--'" class="score-icon"></text>
</view>
</view>
<!-- 违规统计 -->
<view v-if="violations.length > 0" class="stats-card">
<view v-if="violations.length > 0" class="stats-card" :style="{ backgroundColor: brandConfig.secondaryColor }">
<view class="stat-item">
<text class="stat-number">{{ violations.length }}</text>
<text class="stat-label">违规次数</text>
@@ -105,7 +105,7 @@
<text class="formula-example">{{ formulaExample }}</text>
</view>
<view class="formula-footer">
<button class="formula-btn" @click="closeFormula">知道了</button>
<button class="formula-btn" :style="{ backgroundColor: brandConfig.primaryColor }" @click="closeFormula">知道了</button>
</view>
</view>
</view>
@@ -124,6 +124,7 @@
<script>
const store = require('../../store/index')
const coachApi = require('../../api/coach')
const brandStore = require('../../store/brand')
export default {
data() {
@@ -150,9 +151,16 @@
formulaVisible: false,
formulaTitle: '',
formulaContent: '',
formulaExample: ''
formulaExample: '',
brandConfig: brandStore.config
}
},
computed: {
headerStyle() { return 'padding-top:' + this.statusBarHeight + 'px;background-color:' + this.brandConfig.secondaryColor },
scoreGreatStyle() { return 'color:' + this.brandConfig.primaryColor + ';background-color:rgba(' + this.brandConfig.primaryColorRgb + ',0.15)' },
attendanceBarStyle() { return { width: this.performance.attendanceRate + '%', background: this.getRateColor(this.performance.attendanceRate) } },
fullnessBarStyle() { return { width: this.performance.fullnessRate + '%', background: this.getRateColor(this.performance.fullnessRate) } }
},
onLoad() {
const systemInfo = uni.getSystemInfoSync()
const statusBarHeight = systemInfo.statusBarHeight || 20
@@ -166,8 +174,14 @@
this.statusBarHeight = statusBarHeight
this.navBarHeight = navBarHeight
this.totalHeaderHeight = statusBarHeight + navBarHeight
uni.$on('brand:updated', (config) => { this.brandConfig = config })
},
onUnload() {
uni.$off('brand:updated')
},
onShow() {
brandStore.fetchConfig()
if (!store.isLoggedIn) {
uni.reLaunch({ url: '/pages/login/login' })
return
@@ -178,6 +192,12 @@
},
methods: {
goBack() { uni.navigateBack() },
getRateColor(rate) {
if (rate == null) return '#CCCCCC'
if (rate >= 80) return this.brandConfig.primaryColor
if (rate >= 60) return '#FF9800'
return '#F44336'
},
async loadPerformance() {
try {
+86
View File
@@ -0,0 +1,86 @@
const brandApi = require('../api/brand')
const BRAND_CACHE_KEY = 'brand_config_cache'
const DEFAULT_CONFIG = {
brandName: 'Novalon教练端',
slogan: '高效管理 · 轻松授课',
primaryColor: '#00E676',
primaryColorRgb: '0,230,118',
secondaryColor: '#1A1A1A',
secondaryColorRgb: '26,26,26',
logoUrl: '',
backgroundImageUrl: '',
updatedAt: ''
}
/**
* 品牌方案 Store
*
* 响应式策略:fetchConfig 更新后通过 uni.$emit('brand:updated', config) 广播,
* 页面通过 uni.$on 监听并在 data 中维护副本,确保 UI 自动刷新。
*/
const brandStore = {
_config: null,
_loaded: false,
/** 获取当前品牌配置(优先内存,其次缓存,最后默认值) */
get config() {
if (this._config) return this._config
try {
const cached = uni.getStorageSync(BRAND_CACHE_KEY)
if (cached && cached.primaryColor) {
this._config = cached
return cached
}
} catch (e) { /* ignore */ }
return { ...DEFAULT_CONFIG }
},
/** 是否已从后端加载完成 */
get isLoaded() {
return this._loaded
},
/**
* 从后端拉取品牌配置
* 策略:对比 updatedAt,有变化时更新缓存并广播事件通知全部页面
* @returns {Promise<boolean>} 是否有变化
*/
async fetchConfig() {
try {
const res = await brandApi.getBrandConfig()
if (res) {
const newConfig = {
brandName: res.brandName || DEFAULT_CONFIG.brandName,
slogan: res.slogan || DEFAULT_CONFIG.slogan,
primaryColor: res.primaryColor || DEFAULT_CONFIG.primaryColor,
primaryColorRgb: res.primaryColorRgb || DEFAULT_CONFIG.primaryColorRgb,
secondaryColor: res.secondaryColor || DEFAULT_CONFIG.secondaryColor,
secondaryColorRgb: res.secondaryColorRgb || DEFAULT_CONFIG.secondaryColorRgb,
logoUrl: res.logoUrl || '',
backgroundImageUrl: res.backgroundImageUrl || '',
updatedAt: res.updatedAt || ''
}
if (!this._config || this._config.updatedAt !== newConfig.updatedAt) {
this._config = newConfig
try {
uni.setStorageSync(BRAND_CACHE_KEY, newConfig)
} catch (e) { /* ignore */ }
this._loaded = true
// 广播事件通知所有页面刷新
uni.$emit('brand:updated', newConfig)
return true
}
}
} catch (e) {
console.error('[BrandStore] 获取品牌配置失败:', e)
}
this._loaded = true
return false
}
}
module.exports = brandStore