增加品牌方案管理
This commit is contained in:
+371
@@ -0,0 +1,371 @@
|
||||
# 品牌方案 — 小程序端更新说明
|
||||
|
||||
## 概述
|
||||
|
||||
后台管理系统配置的品牌方案(Logo、主色调、辅助色、背景图、品牌名、口号)通过 REST API 实时同步至会员端和教练端 UniApp 小程序。小程序冷启动、页面显示、下拉刷新时自动拉取最新配置。
|
||||
|
||||
---
|
||||
|
||||
## 架构总览
|
||||
|
||||
```
|
||||
┌──────────────────────┐ ┌──────────────────────┐
|
||||
│ 后台管理端 (web) │ │ 小程序端 (uniapp) │
|
||||
│ │ │ │
|
||||
│ BrandManagement.vue │ │ App.vue onLaunch │
|
||||
│ 保存品牌配置 ──────────┐ │ │ │
|
||||
│ │ │ │ brandStore │
|
||||
│ POST /api/brand/logo│ │ │ .fetchConfig() │
|
||||
│ PUT /api/brand/color │ │ │ │
|
||||
│ PUT /api/brand/info│ │ │ GET /api/brand │
|
||||
│ ↓ │ │ │ /config (JWT) │
|
||||
│ ┌─────────────┐ │ │ │ │ │
|
||||
│ │ PostgreSQL │◄────┼──────┼────┼────┘ │
|
||||
│ │ brand_config│ │ │ │ brandStore.config │
|
||||
│ └─────────────┘ │ │ │ → 各页面动态绑定 │
|
||||
│ │ │ │ │
|
||||
└──────────────────────┘ │ └──────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 接口
|
||||
|
||||
### GET /api/brand/config
|
||||
|
||||
获取当前租户的品牌配置。
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| **方法** | `GET` |
|
||||
| **路径** | `/api/brand/config` |
|
||||
| **认证** | 需要 JWT Token(`Authorization: Bearer <token>`) |
|
||||
| **租户识别** | 从 JWT Claims 中的 `tenantId` 自动提取 |
|
||||
| **签名** | 需要签名头(项目统一签名机制) |
|
||||
|
||||
### 响应数据结构
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"tenantId": "default",
|
||||
"logoUrl": "http://localhost:8084/api/files/preview/brand/logo/logo_xxx.jpeg",
|
||||
"backgroundImageUrl": null,
|
||||
"primaryColor": "#00E676",
|
||||
"primaryColorRgb": "0,230,118",
|
||||
"secondaryColor": "#1A1A1A",
|
||||
"secondaryColorRgb": "26,26,26",
|
||||
"fontFamily": null,
|
||||
"brandName": "Novalon健身房",
|
||||
"slogan": "专业健身,从这里开始",
|
||||
"createdAt": "2026-07-23T17:37:36",
|
||||
"updatedAt": "2026-07-23T17:37:59"
|
||||
}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | Long | 记录 ID |
|
||||
| `tenantId` | String | 租户 ID |
|
||||
| `logoUrl` | String | Logo 图片完整 URL |
|
||||
| `backgroundImageUrl` | String | 背景图完整 URL(可为 null) |
|
||||
| `primaryColor` | String | 主色调 HEX 值,如 `#00E676` |
|
||||
| `primaryColorRgb` | String | 主色调 RGB 值,如 `0,230,118` |
|
||||
| `secondaryColor` | String | 辅助色 HEX 值,如 `#1A1A1A` |
|
||||
| `secondaryColorRgb` | String | 辅助色 RGB 值,如 `26,26,26` |
|
||||
| `fontFamily` | String | 字体族(预留) |
|
||||
| `brandName` | String | 品牌名称,最长 100 字符 |
|
||||
| `slogan` | String | 品牌口号,最长 200 字符 |
|
||||
| `createdAt` | String | 创建时间(ISO 格式) |
|
||||
| `updatedAt` | String | 最后更新时间(ISO 格式),用作版本号 |
|
||||
|
||||
---
|
||||
|
||||
## 小程序端接入指南
|
||||
|
||||
### 文件结构
|
||||
|
||||
两个小程序项目结构相同:
|
||||
|
||||
```
|
||||
项目根目录/
|
||||
├── api/
|
||||
│ └── brand.js ← 品牌配置 API 层
|
||||
├── store/
|
||||
│ └── brand.js ← 品牌配置 Store(含缓存逻辑)
|
||||
├── App.vue ← 入口,冷启动/前台切换时拉取配置
|
||||
└── pages/
|
||||
├── index/index.vue ← 首页
|
||||
├── login/login.vue ← 登录页
|
||||
├── profile/profile.vue← 个人中心
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 1. api/brand.js — API 调用层
|
||||
|
||||
```js
|
||||
const request = require('../utils/request')
|
||||
|
||||
module.exports = {
|
||||
getBrandConfig() {
|
||||
return request('/brand/config', { method: 'GET' })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 复用项目现有的 `luch-request` 封装,自动注入 JWT Token 和签名头
|
||||
- 返回 `Promise<Object>`,即 `BrandConfig` 对象
|
||||
|
||||
### 2. store/brand.js — 品牌配置 Store
|
||||
|
||||
```js
|
||||
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: ''
|
||||
}
|
||||
|
||||
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) {}
|
||||
return { ...DEFAULT_CONFIG }
|
||||
},
|
||||
|
||||
async fetchConfig() {
|
||||
let cached = null
|
||||
try { cached = uni.getStorageSync(BRAND_CACHE_KEY) } catch (e) {}
|
||||
|
||||
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
|
||||
uni.setStorageSync(BRAND_CACHE_KEY, newConfig)
|
||||
this._loaded = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[BrandStore] 获取品牌配置失败:', e)
|
||||
}
|
||||
this._loaded = true
|
||||
return false
|
||||
},
|
||||
|
||||
applyTabBar() {
|
||||
const cfg = this.config
|
||||
uni.setTabBarStyle({
|
||||
color: '#7A7E84',
|
||||
selectedColor: cfg.primaryColor,
|
||||
backgroundColor: cfg.secondaryColor,
|
||||
borderStyle: 'black'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = brandStore
|
||||
```
|
||||
|
||||
### 3. App.vue — 应用入口
|
||||
|
||||
```html
|
||||
<script>
|
||||
const brandStore = require('./store/brand')
|
||||
|
||||
export default {
|
||||
onLaunch: function() {
|
||||
console.log('App Launch')
|
||||
brandStore.fetchConfig().then(changed => {
|
||||
if (changed) brandStore.applyTabBar()
|
||||
})
|
||||
setTimeout(() => { brandStore.applyTabBar() }, 300)
|
||||
},
|
||||
onShow: function() {
|
||||
console.log('App Show')
|
||||
brandStore.fetchConfig().then(changed => {
|
||||
if (changed) brandStore.applyTabBar()
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### 4. 页面模板接入示例
|
||||
|
||||
以首页为例,将硬编码颜色替换为动态绑定:
|
||||
|
||||
```html
|
||||
<!-- 导航栏:辅助色背景 -->
|
||||
<view class="header-wrap" :style="{ backgroundColor: brandConfig.secondaryColor }">
|
||||
<view class="nav-bar" :style="{ backgroundColor: brandConfig.secondaryColor }">
|
||||
<text class="nav-title">✦ 今日训练</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 品牌展示:动态名称/Slogan/Logo -->
|
||||
<view class="brand-section">
|
||||
<image class="brand-logo" :src="brandConfig.logoUrl || '/static/logo.png'" mode="aspectFit" />
|
||||
<view class="brand-text">
|
||||
<text class="brand-name">{{ brandConfig.brandName }}</text>
|
||||
<text class="brand-slogan">{{ brandConfig.slogan }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 问候卡片:辅助色背景 + 背景图 -->
|
||||
<view class="greeting-card" :style="{
|
||||
backgroundColor: brandConfig.secondaryColor,
|
||||
backgroundImage: brandConfig.backgroundImageUrl ? 'url(' + brandConfig.backgroundImageUrl + ')' : 'none',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center'
|
||||
}">
|
||||
<!-- ... -->
|
||||
</view>
|
||||
|
||||
<!-- 主色调元素 -->
|
||||
<view class="checkin-badge" :style="{
|
||||
backgroundColor: 'rgba(' + brandConfig.primaryColorRgb + ',0.2)',
|
||||
color: brandConfig.primaryColor
|
||||
}">
|
||||
✓ 今日已签到
|
||||
</view>
|
||||
|
||||
<text class="stat-number" :style="{ color: brandConfig.primaryColor }">128</text>
|
||||
```
|
||||
|
||||
对应的 script 部分:
|
||||
|
||||
```js
|
||||
const brandStore = require('../../store/brand')
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
brandConfig() { return brandStore.config }
|
||||
},
|
||||
onShow() {
|
||||
brandStore.fetchConfig()
|
||||
// ... other data loading
|
||||
},
|
||||
onPullDownRefresh() {
|
||||
brandStore.fetchConfig().finally(() => { uni.stopPullDownRefresh() })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 品牌字段与页面元素映射表
|
||||
|
||||
| 品牌字段 | 应用的元素 |
|
||||
|---------|-----------|
|
||||
| `primaryColor` | 按钮背景色、选中标签背景色、价格文字、难度星星高亮、签到成功标记、功能入口图标 |
|
||||
| `primaryColorRgb` | 半透明遮罩背景(如签到徽章 `rgba(xxx, 0.2)`)、功能入口圆形容器背景 |
|
||||
| `secondaryColor` | 自定义导航栏背景、问候卡片背景、统计卡片背景 |
|
||||
| `secondaryColorRgb` | 半透明副色遮罩 |
|
||||
| `logoUrl` | 首页品牌展示 Logo 图片 `src` 属性 |
|
||||
| `backgroundImageUrl` | 问候卡片背景图(CSS `background-image`) |
|
||||
| `brandName` | 首页品牌名称文本、登录页应用名称 |
|
||||
| `slogan` | 首页品牌口号文本、登录页口号文本 |
|
||||
|
||||
---
|
||||
|
||||
## 更新时机
|
||||
|
||||
| 时机 | 触发方式 | 说明 |
|
||||
|------|---------|------|
|
||||
| **冷启动** | `App.vue → onLaunch` | 先读本地缓存立即渲染,后台异步拉取,对比 `updatedAt` 决定是否更新 |
|
||||
| **切回前台** | `App.vue → onShow` | 用户从后台切回时拉取最新配置 |
|
||||
| **下拉刷新** | 各页面 `onPullDownRefresh` | 手动刷新时同步拉取品牌配置 |
|
||||
| **TabBar 切换** | `App.vue → onShow` + 各页面 `onShow` | Tab 页切换时所在页触发 |
|
||||
|
||||
---
|
||||
|
||||
## 缓存策略
|
||||
|
||||
```
|
||||
本地缓存 Key: brand_config_cache
|
||||
|
||||
存储数据: { brandName, slogan, primaryColor, primaryColorRgb,
|
||||
secondaryColor, secondaryColorRgb, logoUrl,
|
||||
backgroundImageUrl, updatedAt }
|
||||
|
||||
更新策略:
|
||||
1. 启动时读取缓存 → 立即渲染
|
||||
2. 后台调 API GET /api/brand/config
|
||||
3. 对比 API 返回的 updatedAt 与缓存的 updatedAt
|
||||
- 相同 → 不更新,节省渲染开销
|
||||
- 不同 → 更新内存 + 本地缓存,触发 UI 刷新
|
||||
4. 如果 API 调用失败 → 保持缓存值不变,不影响用户体验
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 涉及的页面清单
|
||||
|
||||
### 会员端 (`gym-manage-uniapp`)
|
||||
|
||||
| 页面 | 文件 | 动态元素 |
|
||||
|------|------|---------|
|
||||
| 首页 | `pages/index/index.vue` | 品牌展示(名称/口号/Logo)、导航栏背景、问候卡背景/背景图、签到徽章、统计数据、课程卡片价格、查看全部链接 |
|
||||
| 团课搜索 | `pages/search/search.vue` | 导航栏背景、搜索区背景、时间段筛选、类型筛选、排序激活态、预约按钮、课程价格 |
|
||||
| 我的课程 | `pages/my-courses/my-courses.vue` | 导航栏背景、空状态跳转链接 |
|
||||
| 课程详情 | `pages/course-detail/course-detail.vue` | 导航栏背景、封面渐变、预约按钮 |
|
||||
| 个人中心 | `pages/profile/profile.vue` | 导航栏背景、统计卡片背景、签到数字、会员标签、签到标记、保存按钮 |
|
||||
| 登录 | `pages/login/login.vue` | 品牌名称/口号、Logo 图标、微信登录按钮、协议链接 |
|
||||
|
||||
### 教练端 (`gym-manage-coach-uniapp`)
|
||||
|
||||
| 页面 | 文件 | 动态元素 |
|
||||
|------|------|---------|
|
||||
| 首页 | `pages/index/index.vue` | 导航栏背景、欢迎卡片、教练名、统计数字、功能入口图标 |
|
||||
| 课程列表 | `pages/course-list/course-list.vue` | 导航栏背景、Tab 筛选、状态标签 |
|
||||
| 课程详情 | `pages/course-detail/course-detail.vue` | 导航栏背景、封面渐变、开课按钮、价格文字 |
|
||||
| 个人中心 | `pages/profile/profile.vue` | 导航栏背景、头像容器、标签颜色、统计卡片、评分徽章、公式按钮 |
|
||||
| 登录 | `pages/login/login.vue` | 品牌名称/口号、Logo 图标、登录按钮 |
|
||||
|
||||
---
|
||||
|
||||
## Q&A
|
||||
|
||||
### Q: 后端不可用时怎么办?
|
||||
A: `brandStore` 内置默认配置(与原硬编码值完全一致),API 失败时自动回退到默认值,用户无感知。
|
||||
|
||||
### Q: 如何确认品牌配置已生效?
|
||||
A: 后台管理端修改配色/Logo/名称并保存后,小程序重新打开或下拉刷新即可看到变化。冷启动首次渲染使用缓存值(上次成功拉取的),后台静默更新。
|
||||
|
||||
### Q: 不同租户会互相影响吗?
|
||||
A: 不会。`GET /api/brand/config` 从 JWT Token 中提取 `tenantId`,只返回当前租户的配置。数据库层面也有唯一索引 `UNIQUE(tenant_id) WHERE deleted_at IS NULL` 保证隔离。
|
||||
Reference in New Issue
Block a user