Browse Source

临时提交

master
王泽彦 8 months ago
parent
commit
e8675dcca4
  1. 7
      niucloud/app/api/controller/apiController/Course.php
  2. 4
      niucloud/app/model/course_schedule/CourseSchedule.php
  3. 96
      niucloud/app/service/api/apiService/CourseService.php
  4. 6
      niucloud/app/service/api/apiService/ResourceSharingService.php
  5. 71
      package-lock.json
  6. 5
      package.json
  7. 167
      uniapp/common/util.js
  8. 155
      uniapp/common/utils-index.js
  9. 266
      uniapp/components/README.md
  10. 140
      uniapp/components/call-record-card/call-record-card.vue
  11. 217
      uniapp/components/client-info-card/client-info-card.vue
  12. 153
      uniapp/components/course-edit-popup/course-edit-popup.less
  13. 159
      uniapp/components/course-edit-popup/course-edit-popup.vue
  14. 163
      uniapp/components/fitness-record-card/fitness-record-card.vue
  15. 215
      uniapp/components/fitness-record-popup/fitness-record-popup.less
  16. 276
      uniapp/components/fitness-record-popup/fitness-record-popup.vue
  17. 105
      uniapp/components/index.js
  18. 228
      uniapp/components/student-edit-popup/student-edit-popup.less
  19. 324
      uniapp/components/student-edit-popup/student-edit-popup.vue
  20. 229
      uniapp/components/student-info-card/student-info-card.vue
  21. 70
      uniapp/components/tab-switcher/tab-switcher.vue
  22. 365
      uniapp/pages/market/clue/class_arrangement.vue
  23. 2204
      uniapp/pages/market/clue/clue_info.less
  24. 1408
      uniapp/pages/market/clue/clue_info.scss
  25. 3379
      uniapp/pages/market/clue/clue_info.vue
  26. 549
      uniapp/pages/market/clue/clue_info_refactored.vue
  27. 4
      uniapp/pages/market/clue/index.vue
  28. 280
      uniapp权限管理配置文档.md

7
niucloud/app/api/controller/apiController/Course.php

@ -90,7 +90,12 @@ class Course extends BaseApiService
public function courseAllList(Request $request){
$data = $this->request->params([
["schedule_date",0]
["schedule_date",0],
["start_date",""], // 开始日期
["end_date",""], // 结束日期
["teacher_name",""], // 教练姓名
["venue_number",""], // 场地编号
["time_hour",""] // 时间段
]);
return success((new CourseService())->listAll($data));
}

4
niucloud/app/model/course_schedule/CourseSchedule.php

@ -109,9 +109,5 @@ class CourseSchedule extends BaseModel
return $this->hasOne(Campus::class, 'id', 'campus_id')->joinType('left')->withField('campus_name,id')->bind(['campus_name'=>'campus_name']);
}
public function studentCourses()
{
return $this->hasOne(StudentCourses::class, 'course_id', 'course_id');
}
}

96
niucloud/app/service/api/apiService/CourseService.php

@ -319,18 +319,81 @@ class CourseService extends BaseApiService
public function listAll($data)
{
$where = [];
// 基础日期查询
if ($data['schedule_date']) {
$where[] = ['course_date','=', $data['schedule_date']];
}
// 日期范围查询
if (!empty($data['start_date'])) {
$where[] = ['course_date','>=', $data['start_date']];
}
if (!empty($data['end_date'])) {
$where[] = ['course_date','<=', $data['end_date']];
}
// 场地编号筛选 - 根据venue表的id或venue_name进行筛选
if (!empty($data['venue_number'])) {
// 先查询匹配的场地ID
$venue_ids = Db::name('venue')->where('id', $data['venue_number'])->column('id');
if (empty($venue_ids)) {
// 如果根据ID查不到,可能是根据场地名称查询
$venue_ids = Db::name('venue')->where('venue_name', 'like', '%' . $data['venue_number'] . '%')->column('id');
}
if (empty($venue_ids)) {
// 如果没有找到匹配的场地,返回空结果
return [];
}
$where[] = ['venue_id', 'in', $venue_ids];
}
// 教练姓名筛选 - 需要关联personnel表
if (!empty($data['teacher_name'])) {
// 先查询匹配的教练ID
$coach_ids = Db::name('personnel')->where('name', 'like', '%' . $data['teacher_name'] . '%')->column('id');
if (!empty($coach_ids)) {
$where[] = ['coach_id', 'in', $coach_ids];
} else {
// 如果找不到匹配的教练,返回空结果
return [];
}
}
$CourseSchedule = new CourseSchedule();
$list = $CourseSchedule
->where($where)
->with(['course' => function($query) {
$query->select();
},'venue' => function($query) {
$query->select();
},'campus','studentCourses'])
$query = $CourseSchedule->where($where);
// 时间段筛选 - 根据time_slot字段和传入的时间范围
if (!empty($data['time_hour']) && $data['time_hour'] !== '全部时间') {
$time_condition = $this->getTimeSlotCondition($data['time_hour']);
if ($time_condition) {
[$start_time, $end_time] = $time_condition;
// 查询time_slot字段中包含在指定时间范围内的课程
// time_slot格式为"HH:MM-HH:MM",我们需要检查时间段是否有重叠
$query = $query->where(function ($subQuery) use ($start_time, $end_time) {
$subQuery->where('time_slot', 'like', $start_time . '%')
->whereOr('time_slot', 'like', '%' . $start_time . '%')
->whereOr('time_slot', 'like', '%' . $end_time . '%')
->whereOr(function ($subQuery2) use ($start_time, $end_time) {
// 检查开始时间是否在范围内
$subQuery2->whereRaw("TIME(SUBSTRING_INDEX(time_slot, '-', 1)) >= ?", [$start_time])
->whereRaw("TIME(SUBSTRING_INDEX(time_slot, '-', 1)) < ?", [$end_time]);
})
->whereOr(function ($subQuery3) use ($start_time, $end_time) {
// 检查结束时间是否在范围内
$subQuery3->whereRaw("TIME(SUBSTRING_INDEX(time_slot, '-', -1)) > ?", [$start_time])
->whereRaw("TIME(SUBSTRING_INDEX(time_slot, '-', -1)) <= ?", [$end_time]);
});
});
}
}
$list = $query
->with(['course','venue','campus','coach'])
->select()->toArray();
foreach ($list as $k => $v) {
$student = Db::name('person_course_schedule')
->alias('pcs')
@ -345,6 +408,25 @@ class CourseService extends BaseApiService
return $list;
}
/**
* 根据时间选择器的值获取时间段查询条件
* @param string $timeHour
* @return array|null 返回时间范围数组 [start_hour, end_hour] 或 null
*/
private function getTimeSlotCondition($timeHour)
{
switch ($timeHour) {
case '上午(8:00-12:00)':
return ['08:00', '12:00']; // 上午时间范围
case '下午(12:00-18:00)':
return ['12:00', '18:00']; // 下午时间范围
case '晚上(18:00-22:00)':
return ['18:00', '22:00']; // 晚上时间范围
default:
return null;
}
}
public function addSchedule(array $data){
$CourseSchedule = new CourseSchedule();

6
niucloud/app/service/api/apiService/ResourceSharingService.php

@ -250,9 +250,13 @@ class ResourceSharingService extends BaseApiService
}
// 过滤已分配的资源(只显示可再分配的资源)
$model = $model->where(function ($query) {
$model = $model->when($where['shared_by'] > 0, function ($query) use ($where) {
$query->where('shared_by', $where['shared_by'])->whereOr('user_id', $where['shared_by']);
}, function ($query) {
$query->where(function ($query) {
$query->where('shared_by', 0)->whereOr('shared_by', null);
});
});
// 查询数据
$list = $model->with(['customerResource', 'sixSpeed'])

71
package-lock.json

@ -0,0 +1,71 @@
{
"name": "zhjwxt",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@playwright/test": "^1.54.1"
}
},
"node_modules/@playwright/test": {
"version": "1.54.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.1.tgz",
"integrity": "sha512-FS8hQ12acieG2dYSksmLOF7BNxnVf2afRJdCuM1eMSxj6QTSE6G4InGF7oApGgDb65MX7AwMVlIkpru0yZA4Xw==",
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.54.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.54.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.1.tgz",
"integrity": "sha512-peWpSwIBmSLi6aW2auvrUtf2DqY16YYcCMO8rTVx486jKmDTJg7UAhyrraP98GB8BoPURZP8+nxO7TSd4cPr5g==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.54.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.54.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.1.tgz",
"integrity": "sha512-Nbjs2zjj0htNhzgiy5wu+3w09YetDx5pkrpI/kZotDlDUaYk0HVA5xrBVPdow4SAUIlhgKcJeJg4GRKW6xHusA==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}

5
package.json

@ -0,0 +1,5 @@
{
"dependencies": {
"@playwright/test": "^1.54.1"
}
}

167
uniapp/common/util.js

@ -387,6 +387,163 @@ function getResourceUrl(resource) {
//如果没有 http 协议,则加上 http 协议+服务域名
return resource.indexOf('http') === -1 ? 'https://' + img_domian + resource : resource;
}
/**
* 安全访问对象属性的方法优化性能
* @param {Object} obj 目标对象
* @param {String} path 属性路径 'a.b.c'
* @param {*} defaultValue 默认值
* @returns {*} 属性值或默认值
*/
function safeGet(obj, path, defaultValue = '') {
if (!obj) return defaultValue
// 使用缓存来提高性能
if (!safeGet._pathCache) safeGet._pathCache = {}
// 使用路径作为缓存键
const cacheKey = path
// 如果这个路径没有缓存过分割结果,则计算并缓存
if (!safeGet._pathCache[cacheKey]) {
safeGet._pathCache[cacheKey] = path.split('.')
}
const keys = safeGet._pathCache[cacheKey]
let result = obj
// 使用for循环而不是for...of,更高效
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
if (result === null || result === undefined || !result.hasOwnProperty(key)) {
return defaultValue
}
result = result[key]
}
return result || defaultValue
}
/**
* 格式化文件大小
* @param {Number} bytes 字节数
* @returns {String} 格式化后的大小
*/
function formatFileSize(bytes) {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
/**
* 获取当前日期 YYYY-MM-DD 格式
* @returns {String} 当前日期字符串
*/
function getCurrentDate() {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
/**
* 格式化年龄显示
* @param {Number} age 年龄小数格式如9.05表示9岁5个月
* @returns {String} 格式化后的年龄字符串
*/
function formatAge(age) {
if (!age) return '未知年龄'
const years = Math.floor(age)
const months = Math.round((age - years) * 12)
if (months === 0) {
return `${years}`
}
return `${years}${months}个月`
}
/**
* 格式化性别显示
* @param {Number} gender 性别值12
* @returns {String} 性别字符串
*/
function formatGender(gender) {
switch (gender) {
case 1: return '男'
case 2: return '女'
default: return '未知'
}
}
/**
* 拨打电话的通用方法
* @param {String} phoneNumber 电话号码
* @param {Function} successCallback 成功回调
* @param {Function} failCallback 失败回调
*/
function makePhoneCall(phoneNumber, successCallback, failCallback) {
if (!phoneNumber) {
uni.showToast({
title: '电话号码为空',
icon: 'none'
})
return
}
uni.makePhoneCall({
phoneNumber: phoneNumber,
success: (res) => {
console.log('拨打电话成功')
if (successCallback) successCallback(res)
},
fail: (err) => {
console.error('拨打电话失败:', err)
uni.showToast({
title: '拨打电话失败',
icon: 'none'
})
if (failCallback) failCallback(err)
}
})
}
/**
* 页面跳转的通用方法
* @param {String} url 跳转路径
* @param {Object} params 跳转参数对象
*/
function navigateToPage(url, params = {}) {
let queryString = ''
// 将参数对象转换为查询字符串
if (Object.keys(params).length > 0) {
const paramArray = []
for (const key in params) {
if (params[key] !== null && params[key] !== undefined && params[key] !== '') {
paramArray.push(`${key}=${encodeURIComponent(params[key])}`)
}
}
if (paramArray.length > 0) {
queryString = '?' + paramArray.join('&')
}
}
const fullUrl = url + queryString
uni.navigateTo({
url: fullUrl,
fail: (err) => {
console.error('页面跳转失败:', err)
uni.showToast({
title: '页面跳转失败',
icon: 'none'
})
}
})
}
module.exports = {
loginOut,
openHomeView,
@ -398,5 +555,13 @@ module.exports = {
img,
formatToDateTime,
getDict,
uploadFile
uploadFile,
getResourceUrl,
safeGet,
formatFileSize,
getCurrentDate,
formatAge,
formatGender,
makePhoneCall,
navigateToPage
}

155
uniapp/common/utils-index.js

@ -0,0 +1,155 @@
/**
* 工具函数索引文件
* 统一管理项目中的可复用工具函数
*/
import util from './util.js'
// 导出所有工具函数
export const {
// 登录退出相关
loginOut,
openHomeView,
// 时间格式化
formatTime,
formatDateTime,
formatToDateTime,
dateUtils,
getCurrentDate,
// 通用格式化
formatLocation,
formatFileSize,
formatAge,
formatGender,
// 对象处理
safeGet,
// 样式相关
hexToRgba,
img,
getResourceUrl,
// 数据获取
getDict,
// 文件上传
uploadFile,
// 通信相关
makePhoneCall,
// 页面跳转
navigateToPage
} = util
// 工具函数使用说明
export const UtilsUsage = {
// 对象安全访问
safeGet: {
description: '安全访问对象属性,避免undefined错误',
params: {
obj: 'Object - 目标对象',
path: 'String - 属性路径,如 "a.b.c"',
defaultValue: 'Any - 默认值'
},
returns: 'Any - 属性值或默认值',
example: `
const name = safeGet(clientInfo, 'customerResource.name', '未知客户')
`
},
// 文件大小格式化
formatFileSize: {
description: '格式化文件大小为可读格式',
params: {
bytes: 'Number - 字节数'
},
returns: 'String - 格式化后的大小',
example: `
formatFileSize(1024000) // "1 MB"
`
},
// 年龄格式化
formatAge: {
description: '格式化年龄显示',
params: {
age: 'Number - 年龄(小数格式,如9.05表示9岁5个月)'
},
returns: 'String - 格式化后的年龄',
example: `
formatAge(9.05) // "9岁5个月"
`
},
// 性别格式化
formatGender: {
description: '格式化性别显示',
params: {
gender: 'Number - 性别值(1男,2女)'
},
returns: 'String - 性别字符串',
example: `
formatGender(1) // "男"
`
},
// 拨打电话
makePhoneCall: {
description: '拨打电话的通用方法',
params: {
phoneNumber: 'String - 电话号码',
successCallback: 'Function - 成功回调(可选)',
failCallback: 'Function - 失败回调(可选)'
},
example: `
makePhoneCall('13800138000',
() => console.log('拨打成功'),
(err) => console.error('拨打失败', err)
)
`
},
// 页面跳转
navigateToPage: {
description: '页面跳转的通用方法',
params: {
url: 'String - 跳转路径',
params: 'Object - 跳转参数对象(可选)'
},
example: `
navigateToPage('/pages/detail/index', {
id: 123,
name: '测试'
})
// 结果: /pages/detail/index?id=123&name=测试
`
},
// 获取当前日期
getCurrentDate: {
description: '获取当前日期 YYYY-MM-DD 格式',
returns: 'String - 当前日期字符串',
example: `
getCurrentDate() // "2024-01-15"
`
},
// 时间格式化
formatToDateTime: {
description: '时间格式转换',
params: {
dateTime: 'String - 时间字符串,如 "2024-05-01 01:10:21"',
fmt: 'String - 格式模板,默认 "Y-m-d H:i:s"'
},
returns: 'String - 格式化后的时间',
example: `
formatToDateTime('2024-05-01 01:10:21', 'Y-m-d H:i') // "2024-05-01 01:10"
`
}
}
export default util

266
uniapp/components/README.md

@ -0,0 +1,266 @@
# 组件和工具函数库
这是从 `clue_info.vue` 页面重构提取出来的可复用组件和工具函数集合。
## 🧩 组件列表
### 1. ClientInfoCard - 客户信息卡片
显示客户基本信息的卡片组件,包含客户头像、姓名、电话等信息。
**位置**: `components/client-info-card/client-info-card.vue`
**Props**:
- `clientInfo`: Object - 客户信息对象
**Events**:
- `call`: 拨打电话事件,参数: phoneNumber
**使用示例**:
```vue
<ClientInfoCard
:client-info="clientInfo"
@call="handleCall"
/>
```
### 2. StudentInfoCard - 学生信息卡片
显示学生信息的卡片组件,支持展开/收起操作按钮。
**位置**: `components/student-info-card/student-info-card.vue`
**Props**:
- `student`: Object - 学生信息对象
- `actions`: Array - 操作按钮配置(可选)
- `showDetails`: Boolean - 是否显示详细信息(默认true)
**Events**:
- `toggle-actions`: 切换操作面板事件
- `action`: 操作按钮点击事件,参数: { action, student }
**使用示例**:
```vue
<StudentInfoCard
:student="student"
:actions="[
{ key: 'edit', text: '编辑学生' },
{ key: 'order', text: '查看订单' }
]"
@toggle-actions="toggleActions"
@action="handleAction"
/>
```
### 3. TabSwitcher - 标签切换组件
通用的标签页切换组件。
**位置**: `components/tab-switcher/tab-switcher.vue`
**Props**:
- `tabs`: Array - 标签配置数组 [{ id, name }]
- `activeTabId`: String|Number - 当前激活标签ID
**Events**:
- `tab-change`: 标签切换事件,参数: { tabId, index, tab }
**使用示例**:
```vue
<TabSwitcher
:tabs="[
{ id: 1, name: '基本资料' },
{ id: 2, name: '课程信息' },
{ id: 3, name: '通话记录' }
]"
:active-tab-id="activeTabId"
@tab-change="handleTabChange"
/>
```
### 4. FitnessRecordCard - 体测记录卡片
显示体测记录数据和相关PDF报告的卡片组件。
**位置**: `components/fitness-record-card/fitness-record-card.vue`
**Props**:
- `record`: Object - 体测记录对象
**Events**:
- `file-click`: 文件点击事件,参数: { file, record }
**使用示例**:
```vue
<FitnessRecordCard
:record="fitnessRecord"
@file-click="handleFileClick"
/>
```
### 5. CallRecordCard - 通话记录卡片
显示通话记录详情的卡片组件。
**位置**: `components/call-record-card/call-record-card.vue`
**Props**:
- `record`: Object - 通话记录对象
**使用示例**:
```vue
<CallRecordCard :record="callRecord" />
```
## 🛠️ 工具函数库
### 核心工具函数
以下函数已添加到 `common/util.js` 中:
#### 1. safeGet(obj, path, defaultValue)
安全访问对象属性,避免undefined错误。
```javascript
const name = this.$util.safeGet(clientInfo, 'customerResource.name', '未知客户')
```
#### 2. formatFileSize(bytes)
格式化文件大小为可读格式。
```javascript
this.$util.formatFileSize(1024000) // "1 MB"
```
#### 3. formatAge(age)
格式化年龄显示(小数转年龄+月份)。
```javascript
this.$util.formatAge(9.05) // "9岁5个月"
```
#### 4. formatGender(gender)
格式化性别显示。
```javascript
this.$util.formatGender(1) // "男"
this.$util.formatGender(2) // "女"
```
#### 5. makePhoneCall(phoneNumber, successCallback, failCallback)
拨打电话的通用方法。
```javascript
this.$util.makePhoneCall('13800138000')
```
#### 6. navigateToPage(url, params)
页面跳转的通用方法,自动处理参数拼接。
```javascript
this.$util.navigateToPage('/pages/detail/index', {
id: 123,
name: '测试'
})
// 结果: /pages/detail/index?id=123&name=测试
```
#### 7. getCurrentDate()
获取当前日期 YYYY-MM-DD 格式。
```javascript
const today = this.$util.getCurrentDate() // "2024-01-15"
```
## 📁 文件结构
```
components/
├── client-info-card/
│ └── client-info-card.vue
├── student-info-card/
│ └── student-info-card.vue
├── tab-switcher/
│ └── tab-switcher.vue
├── fitness-record-card/
│ └── fitness-record-card.vue
├── call-record-card/
│ └── call-record-card.vue
├── index.js # 组件导出索引
└── README.md # 本文档
common/
├── util.js # 扩展后的工具函数库
└── utils-index.js # 工具函数导出索引
```
## 🚀 使用方式
### 在页面中使用组件
1. **直接导入使用**:
```vue
<template>
<ClientInfoCard :client-info="clientInfo" @call="handleCall" />
</template>
<script>
import ClientInfoCard from '@/components/client-info-card/client-info-card.vue'
export default {
components: {
ClientInfoCard
}
}
</script>
```
2. **批量导入**:
```javascript
// 从索引文件导入
import { ClientInfoCard, StudentInfoCard } from '@/components/index.js'
```
### 使用工具函数
工具函数已集成到全局 `$util` 对象中,可直接使用:
```vue
<template>
<view>{{ $util.safeGet(data, 'user.name', '未知') }}</view>
</template>
<script>
export default {
methods: {
handleCall() {
const phone = this.$util.safeGet(this.clientInfo, 'phone', '')
this.$util.makePhoneCall(phone)
}
}
}
</script>
```
## 💡 重构优势
1. **代码复用**: 组件和工具函数可在多个页面中复用
2. **维护性**: 集中管理,修改一处影响全局
3. **可测试性**: 独立组件易于单元测试
4. **性能优化**: 减少重复代码,提升应用性能
5. **开发效率**: 标准化组件提升开发速度
## 📋 迁移指南
`clue_info.vue` 中的代码可按以下方式迁移:
1. 替换工具函数调用:
- `this.safeGet()``this.$util.safeGet()`
- `this.formatAge()``this.$util.formatAge()`
- `this.makeCall()``this.$util.makePhoneCall()`
2. 替换组件代码:
- 客户信息展示区域 → `<ClientInfoCard>`
- 学生信息展示区域 → `<StudentInfoCard>`
- 标签切换区域 → `<TabSwitcher>`
- 体测记录展示 → `<FitnessRecordCard>`
- 通话记录展示 → `<CallRecordCard>`
3. 事件处理:
- 保持原有事件处理逻辑
- 通过组件events获取用户操作

140
uniapp/components/call-record-card/call-record-card.vue

@ -0,0 +1,140 @@
<!--通话记录卡片组件-->
<template>
<view class="call-record-card">
<view class="call-header">
<view class="call-time">{{ formatCallTime(record.call_time) }}</view>
<view class="call-duration">{{ formatDuration(record.duration) }}</view>
</view>
<view class="call-info">
<view class="info-row">
<text class="info-label">通话类型</text>
<text class="info-value">{{ getCallType(record.call_type) }}</text>
</view>
<view class="info-row" v-if="record.caller_name">
<text class="info-label">拨打人员</text>
<text class="info-value">{{ record.caller_name }}</text>
</view>
<view class="info-row" v-if="record.phone_number">
<text class="info-label">通话号码</text>
<text class="info-value">{{ record.phone_number }}</text>
</view>
</view>
<view class="call-notes" v-if="record.notes">
<text class="notes-label">通话备注</text>
<text class="notes-content">{{ record.notes }}</text>
</view>
</view>
</template>
<script>
export default {
name: 'CallRecordCard',
props: {
record: {
type: Object,
required: true
}
},
methods: {
formatCallTime(time) {
if (!time) return '未知时间'
return this.$util.formatToDateTime(time, 'Y-m-d H:i')
},
formatDuration(duration) {
if (!duration || duration <= 0) return '0秒'
const minutes = Math.floor(duration / 60)
const seconds = duration % 60
if (minutes > 0) {
return `${minutes}${seconds}`
}
return `${seconds}`
},
getCallType(type) {
const typeMap = {
1: '呼出',
2: '呼入',
3: '未接'
}
return typeMap[type] || '未知'
}
}
}
</script>
<style lang="less" scoped>
.call-record-card {
background-color: #1a1a1a;
border-radius: 15rpx;
padding: 25rpx;
margin-bottom: 20rpx;
border: 1rpx solid #333;
}
.call-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
.call-time {
color: white;
font-size: 26rpx;
font-weight: bold;
}
.call-duration {
color: #29d3b4;
font-size: 22rpx;
background-color: rgba(41, 211, 180, 0.2);
padding: 6rpx 15rpx;
border-radius: 15rpx;
}
}
.call-info {
.info-row {
display: flex;
align-items: center;
margin-bottom: 15rpx;
.info-label {
color: #999;
font-size: 22rpx;
width: 150rpx;
flex-shrink: 0;
}
.info-value {
color: white;
font-size: 22rpx;
flex: 1;
}
}
}
.call-notes {
margin-top: 15rpx;
padding-top: 15rpx;
border-top: 1rpx solid #333;
.notes-label {
color: #999;
font-size: 22rpx;
display: block;
margin-bottom: 8rpx;
}
.notes-content {
color: white;
font-size: 22rpx;
line-height: 1.5;
word-break: break-all;
}
}
</style>

217
uniapp/components/client-info-card/client-info-card.vue

@ -0,0 +1,217 @@
<!--客户信息卡片组件-->
<template>
<view class="client-info-card">
<!-- 客户基本信息区域 -->
<view class="basic-info-section">
<view class="section-header">
<view class="customer-avatar">
<text>{{ $util.safeGet(clientInfo, 'customerResource.name', '客').charAt(0) }}</text>
</view>
<view class="customer-info">
<view class="customer-name">{{ $util.safeGet(clientInfo, 'customerResource.name', '未知客户') }}</view>
<view class="customer-meta">
<text class="customer-phone">{{ $util.safeGet(clientInfo, 'customerResource.phone_number', '') }}</text>
</view>
</view>
<view class="contact-actions">
<view class="contact-btn phone-btn" @click="handleMakeCall">
<text class="contact-icon">📞</text>
</view>
<view class="action-toggle" @click="toggleActions" v-if="actions && actions.length > 0">
<text class="toggle-icon">{{ actionsExpanded ? '▲' : '▼' }}</text>
</view>
</view>
</view>
<!-- 客户详细信息 -->
<view class="customer-details">
<view class="detail-row">
<text class="info-label">渠道来源</text>
<text class="info-value">{{ $util.safeGet(clientInfo, 'customerResource.source_channel_name', '未知渠道') }}</text>
</view>
<view class="detail-row">
<text class="info-label">来源类型</text>
<text class="info-value">{{ $util.safeGet(clientInfo, 'customerResource.source_name', '未知来源') }}</text>
</view>
<view class="detail-row">
<text class="info-label">分配顾问</text>
<text class="info-value">{{ $util.safeGet(clientInfo, 'customerResource.consultant_name', '未知顾问') }}</text>
</view>
<view class="detail-row">
<text class="info-label">性别</text>
<text class="info-value">{{ $util.safeGet(clientInfo, 'customerResource.gender_name', '未知性别') }}</text>
</view>
</view>
<!-- 操作按钮区域 -->
<view class="action-panel" v-if="actionsExpanded && actions && actions.length > 0">
<view
class="action-btn"
v-for="action in actions"
:key="action.key"
@click="handleAction(action)"
>
<text class="action-text">{{ action.text }}</text>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'ClientInfoCard',
props: {
clientInfo: {
type: Object,
default: () => ({})
},
actions: {
type: Array,
default: () => ([])
}
},
data() {
return {
actionsExpanded: false
}
},
methods: {
handleMakeCall() {
const phoneNumber = this.$util.safeGet(this.clientInfo, 'customerResource.phone_number', '')
this.$util.makePhoneCall(phoneNumber)
this.$emit('call', phoneNumber)
},
toggleActions() {
this.actionsExpanded = !this.actionsExpanded
},
handleAction(action) {
this.$emit('action', { action, client: this.clientInfo })
}
}
}
</script>
<style lang="less" scoped>
.client-info-card {
background-color: #1a1a1a;
border-radius: 20rpx;
padding: 30rpx;
margin: 20rpx;
}
.basic-info-section {
.section-header {
display: flex;
align-items: center;
margin-bottom: 30rpx;
.customer-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background-color: #29d3b4;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
text {
color: white;
font-size: 32rpx;
font-weight: bold;
}
}
.customer-info {
flex: 1;
.customer-name {
color: white;
font-size: 32rpx;
font-weight: bold;
margin-bottom: 8rpx;
}
.customer-meta {
.customer-phone {
color: #999;
font-size: 24rpx;
}
}
}
.contact-actions {
display: flex;
align-items: center;
gap: 15rpx;
.contact-btn {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
background-color: #29d3b4;
display: flex;
align-items: center;
justify-content: center;
.contact-icon {
font-size: 28rpx;
}
}
.action-toggle {
padding: 10rpx;
.toggle-icon {
color: #29d3b4;
font-size: 24rpx;
}
}
}
}
}
.customer-details {
.detail-row {
display: flex;
align-items: center;
margin-bottom: 20rpx;
.info-label {
color: #999;
font-size: 24rpx;
width: 150rpx;
}
.info-value {
color: white;
font-size: 24rpx;
flex: 1;
}
}
}
.action-panel {
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #333;
display: flex;
flex-wrap: wrap;
gap: 15rpx;
.action-btn {
padding: 15rpx 25rpx;
background-color: #29d3b4;
border-radius: 25rpx;
.action-text {
color: white;
font-size: 22rpx;
font-weight: bold;
}
}
}
</style>

153
uniapp/components/course-edit-popup/course-edit-popup.less

@ -0,0 +1,153 @@
// 弹窗容器样式
.popup-container {
background: #fff;
border-radius: 15rpx;
padding: 30rpx;
margin: 20rpx;
max-height: 80vh;
overflow-y: auto;
width: 90%;
max-width: 600rpx;
}
// 弹窗头部
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30rpx;
padding-bottom: 20rpx;
border-bottom: 1px solid #eee;
}
.popup-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.popup-close {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
color: #999;
cursor: pointer;
&:hover {
color: #666;
}
}
// 课程编辑容器
.course-edit-container {
.edit-section {
margin-bottom: 30rpx;
.section-title {
font-size: 28rpx;
font-weight: bold;
color: #333;
margin-bottom: 20rpx;
}
.empty-tip {
color: #999;
font-size: 26rpx;
text-align: center;
padding: 40rpx 0;
}
.coach-list {
display: flex;
flex-wrap: wrap;
gap: 15rpx;
.coach-item {
position: relative;
background: #f5f5f5;
border: 2rpx solid #f5f5f5;
border-radius: 10rpx;
padding: 20rpx 30rpx;
min-width: 120rpx;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
&.selected {
background: #e8f5ff;
border-color: #29d3b4;
color: #29d3b4;
}
&:hover {
background: #e8f5ff;
border-color: #29d3b4;
}
.coach-name {
font-size: 26rpx;
color: inherit;
}
.coach-check {
position: absolute;
top: -5rpx;
right: -5rpx;
width: 30rpx;
height: 30rpx;
background: #29d3b4;
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20rpx;
font-weight: bold;
}
}
}
}
}
// 弹窗底部
.popup-footer {
display: flex;
justify-content: space-between;
gap: 20rpx;
margin-top: 40rpx;
padding-top: 30rpx;
border-top: 1px solid #eee;
}
.popup-btn {
flex: 1;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10rpx;
font-size: 28rpx;
cursor: pointer;
transition: all 0.3s ease;
&.cancel-btn {
background: #f5f5f5;
color: #666;
&:hover {
background: #e8e8e8;
}
}
&.confirm-btn {
background: #29d3b4;
color: #fff;
&:hover {
background: #25c4a8;
}
}
}

159
uniapp/components/course-edit-popup/course-edit-popup.vue

@ -0,0 +1,159 @@
<template>
<uni-popup ref="popup" type="center">
<view class="popup-container">
<view class="popup-header">
<view class="popup-title">修改教练配置</view>
<view class="popup-close" @click="close"></view>
</view>
<view class="course-edit-container">
<view class="edit-section">
<view class="section-title">主教练单选</view>
<view v-if="coachList.length === 0" class="empty-tip">暂无主教练数据</view>
<view class="coach-list">
<view v-for="coach in coachList" :key="coach.person_id" class="coach-item"
:class="{selected: selectedMainCoach === coach.person_id}"
@click="selectMainCoach(coach.person_id)">
<view class="coach-name">{{ coach.name }}</view>
<view class="coach-check" v-if="selectedMainCoach === coach.person_id"></view>
</view>
</view>
</view>
<view class="edit-section">
<view class="section-title">教务单选</view>
<view v-if="educationList.length === 0" class="empty-tip">暂无教务数据</view>
<view class="coach-list">
<view v-for="education in educationList" :key="education.person_id" class="coach-item"
:class="{selected: selectedEducation === education.person_id}"
@click="selectEducation(education.person_id)">
<view class="coach-name">{{ education.name }}</view>
<view class="coach-check" v-if="selectedEducation === education.person_id"></view>
</view>
</view>
</view>
<view class="edit-section">
<view class="section-title">助教多选</view>
<view v-if="assistantList.length === 0" class="empty-tip">暂无助教数据</view>
<view class="coach-list">
<view v-for="assistant in assistantList" :key="assistant.person_id" class="coach-item"
:class="{selected: selectedAssistants.includes(assistant.person_id)}"
@click="toggleAssistant(assistant.person_id)">
<view class="coach-name">{{ assistant.name }}</view>
<view class="coach-check" v-if="selectedAssistants.includes(assistant.person_id)"></view>
</view>
</view>
</view>
</view>
<view class="popup-footer">
<view class="popup-btn cancel-btn" @click="close">取消</view>
<view class="popup-btn confirm-btn" @click="confirm">确认</view>
</view>
</view>
</uni-popup>
</template>
<script>
export default {
name: 'CourseEditPopup',
components: {
uniPopup: () => import('@/components/uni-popup/uni-popup.vue')
},
props: {
//
coachList: {
type: Array,
default: () => []
},
//
educationList: {
type: Array,
default: () => []
},
//
assistantList: {
type: Array,
default: () => []
},
//
currentCourse: {
type: Object,
default: () => null
}
},
data() {
return {
selectedMainCoach: null, // ID
selectedEducation: null, // ID
selectedAssistants: [], // ID
}
},
methods: {
//
open(courseData = {}) {
console.log('CourseEditPopup - 打开弹窗,课程数据:', courseData)
//
this.selectedMainCoach = courseData.main_coach_id || null
this.selectedEducation = courseData.education_id || null
this.selectedAssistants = courseData.assistant_ids ?
courseData.assistant_ids.split(',').map(Number) : []
console.log('CourseEditPopup - 初始化选中状态:', {
selectedMainCoach: this.selectedMainCoach,
selectedEducation: this.selectedEducation,
selectedAssistants: this.selectedAssistants,
})
this.$refs.popup.open()
},
//
close() {
this.$refs.popup.close()
this.$emit('close')
},
//
selectMainCoach(personId) {
this.selectedMainCoach = personId
console.log('CourseEditPopup - 选择主教练:', personId)
},
//
selectEducation(personId) {
this.selectedEducation = personId
console.log('CourseEditPopup - 选择教务:', personId)
},
//
toggleAssistant(personId) {
const index = this.selectedAssistants.indexOf(personId)
if (index > -1) {
this.selectedAssistants.splice(index, 1)
} else {
this.selectedAssistants.push(personId)
}
console.log('CourseEditPopup - 切换助教选择:', personId, '当前选中:', this.selectedAssistants)
},
//
confirm() {
const result = {
main_coach_id: this.selectedMainCoach,
education_id: this.selectedEducation,
assistant_ids: this.selectedAssistants.join(','),
selectedAssistants: this.selectedAssistants
}
console.log('CourseEditPopup - 确认修改,返回数据:', result)
this.$emit('confirm', result)
this.close()
}
}
}
</script>
<style lang="less" scoped>
@import './course-edit-popup.less';
</style>

163
uniapp/components/fitness-record-card/fitness-record-card.vue

@ -0,0 +1,163 @@
<!--体测记录卡片组件-->
<template>
<view class="fitness-record-card">
<view class="record-header">
<view class="record-date">{{ record.test_date }}</view>
<view class="record-status">已完成</view>
</view>
<view class="record-data">
<view class="data-item">
<view class="data-label">身高</view>
<view class="data-value">{{ record.height }}cm</view>
</view>
<view class="data-item">
<view class="data-label">体重</view>
<view class="data-value">{{ record.weight }}kg</view>
</view>
</view>
<view class="record-files" v-if="record.pdf_files && record.pdf_files.length > 0">
<view class="files-title">体测报告</view>
<view class="file-list">
<view
class="file-item"
v-for="pdf in record.pdf_files"
:key="pdf.id"
@click="handleFileClick(pdf)"
>
<view class="file-icon">📄</view>
<view class="file-info">
<view class="file-name">{{ pdf.name }}</view>
<view class="file-size">{{ $util.formatFileSize(pdf.size) }}</view>
</view>
<view class="file-action">
<text class="action-text">查看</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'FitnessRecordCard',
props: {
record: {
type: Object,
required: true
}
},
methods: {
handleFileClick(file) {
this.$emit('file-click', { file, record: this.record })
}
}
}
</script>
<style lang="less" scoped>
.fitness-record-card {
background-color: #1a1a1a;
border-radius: 15rpx;
padding: 25rpx;
margin-bottom: 20rpx;
border: 1rpx solid #333;
}
.record-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
.record-date {
color: white;
font-size: 28rpx;
font-weight: bold;
}
.record-status {
color: #29d3b4;
font-size: 22rpx;
background-color: rgba(41, 211, 180, 0.2);
padding: 6rpx 15rpx;
border-radius: 15rpx;
}
}
.record-data {
display: flex;
gap: 40rpx;
margin-bottom: 25rpx;
.data-item {
flex: 1;
text-align: center;
.data-label {
color: #999;
font-size: 22rpx;
margin-bottom: 8rpx;
}
.data-value {
color: white;
font-size: 32rpx;
font-weight: bold;
}
}
}
.record-files {
.files-title {
color: white;
font-size: 24rpx;
margin-bottom: 15rpx;
font-weight: bold;
}
.file-list {
.file-item {
display: flex;
align-items: center;
padding: 15rpx;
background-color: rgba(255, 255, 255, 0.05);
border-radius: 10rpx;
margin-bottom: 10rpx;
.file-icon {
font-size: 32rpx;
margin-right: 15rpx;
}
.file-info {
flex: 1;
.file-name {
color: white;
font-size: 24rpx;
margin-bottom: 5rpx;
}
.file-size {
color: #999;
font-size: 20rpx;
}
}
.file-action {
padding: 8rpx 15rpx;
background-color: #29d3b4;
border-radius: 15rpx;
.action-text {
color: white;
font-size: 20rpx;
}
}
}
}
}
</style>

215
uniapp/components/fitness-record-popup/fitness-record-popup.less

@ -0,0 +1,215 @@
// 体测记录弹窗样式
.popup-container {
width: 90vw;
max-width: 600rpx;
background: #fff;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 10rpx 30rpx rgba(0, 0, 0, 0.2);
}
.popup-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 30rpx 40rpx;
background: #29d3b4;
color: #fff;
}
.popup-title {
font-size: 32rpx;
font-weight: bold;
}
.popup-close {
width: 50rpx;
height: 50rpx;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
font-size: 28rpx;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
}
.popup-close:active {
background: rgba(255, 255, 255, 0.3);
transform: scale(0.9);
}
.popup-footer {
display: flex;
padding: 30rpx 40rpx;
background: #f8f9fa;
gap: 20rpx;
}
.popup-btn {
flex: 1;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12rpx;
font-size: 30rpx;
font-weight: bold;
transition: all 0.3s ease;
}
.cancel-btn {
background: #e9ecef;
color: #666;
}
.cancel-btn:active {
background: #dee2e6;
transform: scale(0.95);
}
.confirm-btn {
background: #29d3b4;
color: #fff;
}
.confirm-btn:active {
background: #1ea08e;
transform: scale(0.95);
}
// 体测记录表单样式
.fitness-record-form {
width: 100%;
max-height: 60vh;
overflow-y: auto;
padding: 20rpx;
box-sizing: border-box;
}
.form-section {
width: 100%;
}
.form-item {
margin-bottom: 30rpx;
}
.form-label {
font-size: 30rpx;
color: #333;
margin-bottom: 15rpx;
font-weight: bold;
}
.form-input {
border: 2px solid #eee;
border-radius: 12rpx;
padding: 0 20rpx;
background: #fff;
transition: border-color 0.3s ease;
}
.form-input:focus-within {
border-color: #29d3b4;
}
.form-input input {
width: 100%;
height: 80rpx;
font-size: 28rpx;
color: #333;
border: none;
outline: none;
background: transparent;
}
.file-upload-area {
width: 100%;
}
.upload-btn {
display: flex;
align-items: center;
justify-content: center;
background: #f8f9fa;
border: 2px dashed #ddd;
border-radius: 12rpx;
padding: 40rpx;
margin-bottom: 20rpx;
color: #666;
transition: all 0.3s ease;
}
.upload-btn:active {
background: #e9ecef;
border-color: #29d3b4;
}
.upload-icon {
font-size: 40rpx;
margin-right: 15rpx;
}
.upload-text {
font-size: 28rpx;
}
.selected-files {
width: 100%;
}
.selected-file-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15rpx 20rpx;
background: #f8f9fa;
border-radius: 12rpx;
margin-bottom: 10rpx;
border: 1px solid #eee;
}
.file-info {
display: flex;
align-items: center;
flex: 1;
}
.file-icon {
font-size: 28rpx;
margin-right: 12rpx;
color: #666;
}
.file-name {
flex: 1;
font-size: 26rpx;
color: #333;
margin-right: 10rpx;
}
.file-size {
font-size: 22rpx;
color: #999;
}
.file-remove {
width: 40rpx;
height: 40rpx;
display: flex;
align-items: center;
justify-content: center;
background: #ff4757;
color: #fff;
border-radius: 50%;
font-size: 24rpx;
font-weight: bold;
margin-left: 15rpx;
}
.file-remove:active {
background: #ff3838;
}

276
uniapp/components/fitness-record-popup/fitness-record-popup.vue

@ -0,0 +1,276 @@
<template>
<uni-popup ref="popup" type="center">
<view class="popup-container">
<view class="popup-header">
<view class="popup-title">{{ isEditing ? '编辑体测记录' : '新增体测记录' }}</view>
<view class="popup-close" @click="close"></view>
</view>
<view class="fitness-record-form">
<view class="form-section">
<view class="form-item">
<view class="form-label">测试日期</view>
<view class="form-input">
<input type="date" v-model="recordData.test_date" placeholder="请选择测试日期" />
</view>
</view>
<view class="form-item">
<view class="form-label">身高(cm)</view>
<view class="form-input">
<input type="number" v-model="recordData.height" placeholder="请输入身高" />
</view>
</view>
<view class="form-item">
<view class="form-label">体重(kg)</view>
<view class="form-input">
<input type="number" v-model="recordData.weight" placeholder="请输入体重" />
</view>
</view>
<view class="form-item">
<view class="form-label">体测报告</view>
<view class="file-upload-area">
<view class="upload-btn" @click="selectPDFFiles">
<view class="upload-icon">📁</view>
<view class="upload-text">选择PDF文件</view>
</view>
<!-- 已选择的PDF文件列表 -->
<view v-if="recordData.pdf_files && recordData.pdf_files.length > 0"
class="selected-files">
<view v-for="(pdf, index) in recordData.pdf_files" :key="index"
class="selected-file-item">
<view class="file-info" @click="previewPDF(pdf)">
<view class="file-icon">📄</view>
<view class="file-name">{{ pdf.name }}</view>
<view class="file-size">{{ formatFileSize(pdf.size) }}</view>
</view>
<view class="file-remove" @click="removePDFFile(index)"></view>
</view>
</view>
</view>
</view>
</view>
</view>
<view class="popup-footer">
<view class="popup-btn cancel-btn" @click="close">取消</view>
<view class="popup-btn confirm-btn" @click="confirm">确认</view>
</view>
</view>
</uni-popup>
</template>
<script>
export default {
name: 'FitnessRecordPopup',
props: {
resourceId: {
type: String,
default: ''
}
},
data() {
return {
isVisible: false,
isEditing: false,
recordData: {
id: null,
test_date: '',
height: '',
weight: '',
pdf_files: []
}
}
},
methods: {
//
openAdd() {
this.isEditing = false
this.resetData()
this.recordData.test_date = this.getCurrentDate()
this.isVisible = true
this.$refs.popup.open()
},
//
openEdit(record) {
this.isEditing = true
this.recordData = {
id: record.id,
test_date: record.test_date,
height: record.height,
weight: record.weight,
pdf_files: [...(record.pdf_files || [])]
}
this.isVisible = true
this.$refs.popup.open()
},
//
close() {
this.isVisible = false
this.$refs.popup.close()
this.resetData()
this.$emit('close')
},
//
resetData() {
this.recordData = {
id: null,
test_date: '',
height: '',
weight: '',
pdf_files: []
}
},
//
async confirm() {
try {
//
if (!this.recordData.test_date) {
uni.showToast({
title: '请选择测试日期',
icon: 'none'
})
return
}
if (!this.recordData.height) {
uni.showToast({
title: '请输入身高',
icon: 'none'
})
return
}
if (!this.recordData.weight) {
uni.showToast({
title: '请输入体重',
icon: 'none'
})
return
}
uni.showLoading({
title: '保存中...',
mask: true
})
const params = {
resource_id: this.resourceId,
test_date: this.recordData.test_date,
height: this.recordData.height,
weight: this.recordData.weight,
pdf_files: this.recordData.pdf_files
}
if (this.isEditing) {
params.id = this.recordData.id
}
console.log('保存体测记录参数:', params)
//
this.$emit('confirm', {
isEditing: this.isEditing,
data: params
})
uni.hideLoading()
this.close()
} catch (error) {
console.error('保存体测记录失败:', error)
uni.showToast({
title: '保存失败,请重试',
icon: 'none'
})
uni.hideLoading()
}
},
// PDF
selectPDFFiles() {
uni.chooseFile({
count: 5,
type: 'file',
extension: ['pdf'],
success: (res) => {
console.log('选择的文件:', res.tempFiles)
res.tempFiles.forEach(file => {
if (file.type === 'application/pdf') {
const pdfFile = {
id: Date.now() + Math.random(),
name: file.name,
size: file.size,
url: file.path
}
this.recordData.pdf_files.push(pdfFile)
}
})
},
fail: (err) => {
console.error('选择文件失败:', err)
uni.showToast({
title: '选择文件失败',
icon: 'none'
})
}
})
},
// PDF
removePDFFile(index) {
this.recordData.pdf_files.splice(index, 1)
},
// PDF
previewPDF(pdf) {
console.log('预览PDF:', pdf)
// 使uni.openDocumentPDF
uni.openDocument({
filePath: pdf.url,
fileType: 'pdf',
showMenu: true,
success: (res) => {
console.log('PDF预览成功:', res)
},
fail: (err) => {
console.error('PDF预览失败:', err)
uni.showToast({
title: '预览失败',
icon: 'none'
})
}
})
},
//
getCurrentDate() {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
},
//
formatFileSize(bytes) {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
}
}
</script>
<style lang="less" scoped>
@import './fitness-record-popup.less';
</style>

105
uniapp/components/index.js

@ -0,0 +1,105 @@
/**
* 组件索引文件
* 统一管理项目中的可复用组件
*/
// 客户信息相关组件
export { default as ClientInfoCard } from './client-info-card/client-info-card.vue'
export { default as StudentInfoCard } from './student-info-card/student-info-card.vue'
// 通用UI组件
export { default as TabSwitcher } from './tab-switcher/tab-switcher.vue'
// 记录卡片组件
export { default as FitnessRecordCard } from './fitness-record-card/fitness-record-card.vue'
export { default as CallRecordCard } from './call-record-card/call-record-card.vue'
// 组件使用说明
export const ComponentUsage = {
// 客户信息卡片
ClientInfoCard: {
description: '显示客户基本信息的卡片组件',
props: {
clientInfo: 'Object - 客户信息对象'
},
events: {
call: '拨打电话事件,参数: phoneNumber'
},
example: `
<ClientInfoCard
:client-info="clientInfo"
@call="handleCall"
/>
`
},
// 学生信息卡片
StudentInfoCard: {
description: '显示学生信息的卡片组件,支持操作按钮',
props: {
student: 'Object - 学生信息对象',
actions: 'Array - 操作按钮配置',
showDetails: 'Boolean - 是否显示详细信息'
},
events: {
'toggle-actions': '切换操作面板事件',
'action': '操作按钮点击事件,参数: { action, student }'
},
example: `
<StudentInfoCard
:student="student"
:actions="actions"
:show-details="true"
@toggle-actions="toggleActions"
@action="handleAction"
/>
`
},
// 标签切换组件
TabSwitcher: {
description: '标签切换组件,支持多标签页面切换',
props: {
tabs: 'Array - 标签配置数组 [{ id, name }]',
activeTabId: 'String|Number - 当前激活标签ID'
},
events: {
'tab-change': '标签切换事件,参数: { tabId, index, tab }'
},
example: `
<TabSwitcher
:tabs="tabs"
:active-tab-id="activeTabId"
@tab-change="handleTabChange"
/>
`
},
// 体测记录卡片
FitnessRecordCard: {
description: '体测记录卡片组件,显示体测数据和报告',
props: {
record: 'Object - 体测记录对象'
},
events: {
'file-click': '文件点击事件,参数: { file, record }'
},
example: `
<FitnessRecordCard
:record="record"
@file-click="handleFileClick"
/>
`
},
// 通话记录卡片
CallRecordCard: {
description: '通话记录卡片组件,显示通话详情',
props: {
record: 'Object - 通话记录对象'
},
example: `
<CallRecordCard :record="record" />
`
}
}

228
uniapp/components/student-edit-popup/student-edit-popup.less

@ -0,0 +1,228 @@
// 学生信息编辑弹窗样式
.student-edit-popup {
width: 90vw;
max-width: 600rpx;
max-height: 80vh;
background: #fff;
border-radius: 20rpx;
overflow: hidden;
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx 40rpx;
background: #f8f9fa;
border-bottom: 1rpx solid #eee;
.popup-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.popup-close {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
color: #999;
cursor: pointer;
&:hover {
color: #666;
}
}
}
.student-form-container {
max-height: 60vh;
padding: 0 40rpx;
.form-section {
padding: 20rpx 0;
.form-group {
margin-bottom: 40rpx;
.form-group-title {
font-size: 28rpx;
font-weight: 600;
color: #333;
margin-bottom: 20rpx;
padding-bottom: 10rpx;
border-bottom: 2rpx solid #29d3b4;
}
.form-item {
display: flex;
align-items: center;
margin-bottom: 30rpx;
.form-label {
width: 160rpx;
font-size: 28rpx;
color: #666;
flex-shrink: 0;
&.required::after {
content: '*';
color: #ff4757;
margin-left: 4rpx;
}
}
.form-input {
flex: 1;
input {
width: 100%;
height: 80rpx;
padding: 0 20rpx;
border: 1rpx solid #ddd;
border-radius: 8rpx;
font-size: 28rpx;
background: #fff;
&:focus {
border-color: #29d3b4;
outline: none;
}
&::placeholder {
color: #ccc;
}
}
.picker-display {
display: flex;
align-items: center;
justify-content: space-between;
height: 80rpx;
padding: 0 20rpx;
border: 1rpx solid #ddd;
border-radius: 8rpx;
font-size: 28rpx;
background: #fff;
color: #333;
.picker-arrow {
font-size: 32rpx;
color: #999;
transform: rotate(90deg);
}
}
}
.form-textarea {
flex: 1;
textarea {
width: 100%;
min-height: 120rpx;
padding: 20rpx;
border: 1rpx solid #ddd;
border-radius: 8rpx;
font-size: 28rpx;
background: #fff;
resize: none;
&:focus {
border-color: #29d3b4;
outline: none;
}
&::placeholder {
color: #ccc;
}
}
}
}
}
}
}
.popup-footer {
display: flex;
padding: 30rpx 40rpx;
background: #f8f9fa;
border-top: 1rpx solid #eee;
gap: 20rpx;
.popup-btn {
flex: 1;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8rpx;
font-size: 28rpx;
cursor: pointer;
transition: all 0.3s ease;
&.cancel-btn {
background: #f5f5f5;
color: #666;
border: 1rpx solid #ddd;
&:hover {
background: #e9e9e9;
}
}
&.confirm-btn {
background: #29d3b4;
color: #fff;
border: 1rpx solid #29d3b4;
&:hover {
background: #26c4a6;
}
}
}
}
}
// 响应式适配
@media (max-width: 750rpx) {
.student-edit-popup {
width: 95vw;
.popup-header {
padding: 25rpx 30rpx;
.popup-title {
font-size: 30rpx;
}
}
.student-form-container {
padding: 0 30rpx;
.form-section {
.form-group {
.form-item {
flex-direction: column;
align-items: flex-start;
.form-label {
width: 100%;
margin-bottom: 10rpx;
}
.form-input,
.form-textarea {
width: 100%;
}
}
}
}
}
.popup-footer {
padding: 25rpx 30rpx;
}
}
}

324
uniapp/components/student-edit-popup/student-edit-popup.vue

@ -0,0 +1,324 @@
<!--学生信息编辑弹窗组件-->
<template>
<uni-popup ref="popup" type="center">
<view class="popup-container student-edit-popup">
<view class="popup-header">
<view class="popup-title">{{ isEditing ? '编辑学生信息' : '添加学生信息' }}</view>
<view class="popup-close" @click="close"></view>
</view>
<scroll-view class="student-form-container" scroll-y="true">
<view class="form-section">
<!-- 基本信息 -->
<view class="form-group">
<view class="form-group-title">基本信息</view>
<view class="form-item">
<view class="form-label required">姓名</view>
<view class="form-input">
<input type="text" v-model="studentData.name" placeholder="请输入学生姓名" maxlength="20" />
</view>
</view>
<view class="form-item">
<view class="form-label required">性别</view>
<view class="form-input">
<picker :range="genderOptions" range-key="label" @change="onGenderChange">
<view class="picker-display">
{{ $util.formatGender(studentData.gender) || '请选择性别' }}
<text class="picker-arrow"></text>
</view>
</picker>
</view>
</view>
<view class="form-item">
<view class="form-label required">出生日期</view>
<view class="form-input">
<picker mode="date" :value="studentData.birthday" @change="onBirthdayChange">
<view class="picker-display">
{{ studentData.birthday || '请选择出生日期' }}
<text class="picker-arrow"></text>
</view>
</picker>
</view>
</view>
<view class="form-item">
<view class="form-label">联系电话</view>
<view class="form-input">
<input type="number" v-model="studentData.contact_phone" placeholder="请输入联系电话" maxlength="20" />
</view>
</view>
<view class="form-item">
<view class="form-label">紧急联系人</view>
<view class="form-input">
<input type="text" v-model="studentData.emergency_contact" placeholder="请输入紧急联系人" maxlength="255" />
</view>
</view>
</view>
<!-- 学员信息 -->
<view class="form-group">
<view class="form-group-title">学员信息</view>
<view class="form-item">
<view class="form-label">会员标签</view>
<view class="form-input">
<input type="text" v-model="studentData.member_label" placeholder="请输入会员标签" maxlength="255" />
</view>
</view>
<view class="form-item">
<view class="form-label">体验课次数</view>
<view class="form-input">
<input type="number" v-model="studentData.trial_class_count" placeholder="体验课次数" />
</view>
</view>
<view class="form-item">
<view class="form-label">学员状态</view>
<view class="form-input">
<picker :range="statusOptions" range-key="label" @change="onStatusChange">
<view class="picker-display">
{{ studentData.status === 1 ? '有效' : '无效' }}
<text class="picker-arrow"></text>
</view>
</picker>
</view>
</view>
</view>
<!-- 其他信息 -->
<view class="form-group">
<view class="form-group-title">其他信息</view>
<view class="form-item">
<view class="form-label">备注</view>
<view class="form-textarea">
<textarea v-model="studentData.note" placeholder="请输入备注信息" maxlength="200"></textarea>
</view>
</view>
</view>
</view>
</scroll-view>
<view class="popup-footer">
<view class="popup-btn cancel-btn" @click="close">取消</view>
<view class="popup-btn confirm-btn" @click="confirm">保存</view>
</view>
</view>
</uni-popup>
</template>
<script>
export default {
name: 'StudentEditPopup',
props: {
// ID
resourceId: {
type: [String, Number],
default: 0
}
},
data() {
return {
isEditing: false, //
//
studentData: {
id: null,
name: '',
gender: 0, // : 0, 1, 2
age: 0.00, // 3.11311
birthday: null,
user_id: 0, // ID
campus_id: null,
class_id: null,
note: '', //
status: 1, // : 0, 1
emergency_contact: '', //
contact_phone: '', //
member_label: '', //
consultant_id: null,
coach_id: null,
trial_class_count: 2, // |2
actionsExpanded: false //
},
//
genderOptions: [
{ label: '未知', value: 0 },
{ label: '男', value: 1 },
{ label: '女', value: 2 }
],
statusOptions: [
{ label: '无效', value: 0 },
{ label: '有效', value: 1 }
]
}
},
methods: {
// -
openAdd() {
this.isEditing = false
this.resetStudentData()
this.studentData.user_id = this.resourceId
this.$refs.popup.open()
},
// -
openEdit(student) {
this.isEditing = true
this.studentData = {
id: student.id,
name: student.name,
gender: student.gender,
age: student.age,
birthday: student.birthday,
user_id: student.user_id,
campus_id: student.campus_id,
class_id: student.class_id,
note: student.note,
status: student.status,
emergency_contact: student.emergency_contact,
contact_phone: student.contact_phone,
member_label: student.member_label,
consultant_id: student.consultant_id,
coach_id: student.coach_id,
trial_class_count: student.trial_class_count,
actionsExpanded: student.actionsExpanded || false
}
this.$refs.popup.open()
},
//
close() {
this.$refs.popup.close()
this.resetStudentData()
this.$emit('close')
},
//
resetStudentData() {
this.studentData = {
id: null,
name: '',
gender: 0,
age: 0.00,
birthday: null,
user_id: this.resourceId || 0,
campus_id: null,
class_id: null,
note: '',
status: 1,
emergency_contact: '',
contact_phone: '',
member_label: '',
consultant_id: null,
coach_id: null,
trial_class_count: 2,
actionsExpanded: false
}
},
//
onGenderChange(e) {
const selectedGender = this.genderOptions[e.detail.value]
this.studentData.gender = selectedGender.value
},
//
onBirthdayChange(e) {
this.studentData.birthday = e.detail.value
//
if (this.studentData.birthday) {
const today = new Date()
const birthday = new Date(this.studentData.birthday)
let years = today.getFullYear() - birthday.getFullYear()
let months = today.getMonth() - birthday.getMonth()
if (today.getDate() < birthday.getDate()) {
months--
}
if (months < 0) {
years--
months += 12
}
// 311 = 3.11
const ageDecimal = years + (months / 100)
this.studentData.age = ageDecimal > 0 ? parseFloat(ageDecimal.toFixed(2)) : 0
}
},
//
onStatusChange(e) {
this.studentData.status = this.statusOptions[e.detail.value].value
},
//
confirm() {
//
if (!this.studentData.name.trim()) {
uni.showToast({
title: '请输入学生姓名',
icon: 'none'
})
return
}
if (this.studentData.gender === 0) {
uni.showToast({
title: '请选择性别',
icon: 'none'
})
return
}
if (!this.studentData.birthday) {
uni.showToast({
title: '请选择出生日期',
icon: 'none'
})
return
}
//
const params = {
name: this.studentData.name,
gender: this.studentData.gender,
age: this.studentData.age,
birthday: this.studentData.birthday,
user_id: this.resourceId, // ID
campus_id: this.studentData.campus_id,
class_id: this.studentData.class_id,
note: this.studentData.note,
status: this.studentData.status,
emergency_contact: this.studentData.emergency_contact,
contact_phone: this.studentData.contact_phone,
member_label: this.studentData.member_label,
consultant_id: this.studentData.consultant_id,
coach_id: this.studentData.coach_id,
trial_class_count: this.studentData.trial_class_count
}
if (this.isEditing) {
params.id = this.studentData.id
}
//
this.$emit('confirm', {
isEditing: this.isEditing,
studentData: params
})
}
}
}
</script>
<style lang="less" scoped>
@import './student-edit-popup.less';
</style>

229
uniapp/components/student-info-card/student-info-card.vue

@ -0,0 +1,229 @@
<!--学生信息卡片组件-->
<template>
<view class="student-info-card">
<!-- 学生基本信息 -->
<view class="student-header">
<view class="student-avatar">
<text>{{ student.name ? student.name.charAt(0) : '学' }}</text>
</view>
<view class="student-details">
<view class="student-name">{{ student.name || '未知学生' }}</view>
<view class="student-meta">
<text class="student-age">{{ formatAge(student.age) }}</text>
<text class="student-gender">{{ formatGender(student.gender) }}</text>
</view>
<view class="student-label" v-if="student.member_label">{{ student.member_label }}</view>
</view>
<view class="action-toggle" @click="toggleActions">
<text class="toggle-icon">{{ student.actionsExpanded ? '▲' : '▼' }}</text>
</view>
</view>
<!-- 学生详细信息 -->
<view class="student-info" v-if="showDetails">
<view class="info-row">
<text class="info-label">生日</text>
<text class="info-value">{{ student.birthday || '未知' }}</text>
</view>
<view class="info-row" v-if="student.emergency_contact">
<text class="info-label">紧急联系人</text>
<text class="info-value">{{ student.emergency_contact }}</text>
</view>
<view class="info-row" v-if="student.contact_phone">
<text class="info-label">联系电话</text>
<text class="info-value">{{ student.contact_phone }}</text>
</view>
<view class="info-row" v-if="student.note">
<text class="info-label">备注</text>
<text class="info-value">{{ student.note }}</text>
</view>
</view>
<!-- 操作按钮区域 -->
<view class="action-panel">
<view
class="action-btn"
v-for="action in actions"
:key="action.key"
@click="handleAction(action)"
>
<text class="action-text">{{ action.text }}</text>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'StudentInfoCard',
props: {
student: {
type: Object,
default: () => ({})
},
actions: {
type: Array,
default: () => ([
{ key: 'edit', text: '编辑学生' },
{ key: 'order', text: '查看订单' },
{ key: 'course', text: '课程安排' },
{ key: 'fitness', text: '体测记录' }
])
},
showDetails: {
type: Boolean,
default: true
}
},
methods: {
toggleActions() {
this.$emit('toggle-actions', this.student)
},
handleAction(action) {
this.$emit('action', { action, student: this.student })
},
//
formatAge(age) {
if (!age) return '未知年龄'
const years = Math.floor(age)
const months = Math.round((age - years) * 12)
if (months === 0) {
return `${years}`
}
return `${years}${months}个月`
},
//
formatGender(gender) {
switch (gender) {
case 1: return '男'
case 2: return '女'
default: return '未知'
}
}
}
}
</script>
<style lang="less" scoped>
.student-info-card {
background-color: #1a1a1a;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
border: 2rpx solid #333;
}
.student-header {
display: flex;
align-items: center;
.student-avatar {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
background-color: #29d3b4;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
text {
color: white;
font-size: 24rpx;
font-weight: bold;
}
}
.student-details {
flex: 1;
.student-name {
color: white;
font-size: 28rpx;
font-weight: bold;
margin-bottom: 8rpx;
}
.student-meta {
display: flex;
align-items: center;
gap: 20rpx;
margin-bottom: 8rpx;
.student-age,
.student-gender {
color: #999;
font-size: 22rpx;
}
}
.student-label {
color: #29d3b4;
font-size: 20rpx;
background-color: rgba(41, 211, 180, 0.2);
padding: 4rpx 12rpx;
border-radius: 10rpx;
display: inline-block;
}
}
.action-toggle {
padding: 10rpx;
.toggle-icon {
color: #29d3b4;
font-size: 24rpx;
}
}
}
.student-info {
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #333;
.info-row {
display: flex;
align-items: flex-start;
margin-bottom: 15rpx;
.info-label {
color: #999;
font-size: 22rpx;
width: 150rpx;
flex-shrink: 0;
}
.info-value {
color: white;
font-size: 22rpx;
flex: 1;
word-break: break-all;
}
}
}
.action-panel {
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #333;
display: flex;
flex-wrap: wrap;
gap: 15rpx;
.action-btn {
padding: 15rpx 25rpx;
background-color: #29d3b4;
border-radius: 25rpx;
.action-text {
color: white;
font-size: 22rpx;
font-weight: bold;
}
}
}
</style>

70
uniapp/components/tab-switcher/tab-switcher.vue

@ -0,0 +1,70 @@
<!--标签切换组件-->
<template>
<view class="tab-switcher">
<view
v-for="(tab, index) in tabs"
:key="tab.id"
:class="['tab-item', { 'selected-tab': activeTabId === tab.id }]"
@click="switchTab(tab.id, index)"
>
<text :class="['tab-text', { 'selected-text': activeTabId === tab.id }]">{{ tab.name }}</text>
</view>
</view>
</template>
<script>
export default {
name: 'TabSwitcher',
props: {
tabs: {
type: Array,
required: true,
default: () => []
},
activeTabId: {
type: [String, Number],
required: true
}
},
methods: {
switchTab(tabId, index) {
if (tabId !== this.activeTabId) {
this.$emit('tab-change', { tabId, index, tab: this.tabs[index] })
}
}
}
}
</script>
<style lang="less" scoped>
.tab-switcher {
display: flex;
align-items: center;
justify-content: space-around;
padding: 20rpx 0;
background-color: white;
border-radius: 0 0 20rpx 20rpx;
}
.tab-item {
padding: 15rpx 20rpx;
border-radius: 10rpx;
transition: all 0.3s ease;
&.selected-tab {
background-color: rgba(41, 211, 180, 0.1);
}
}
.tab-text {
font-size: 26rpx;
color: #666;
font-weight: normal;
transition: all 0.3s ease;
&.selected-text {
color: #29d3b4;
font-weight: bold;
}
}
</style>

365
uniapp/pages/market/clue/class_arrangement.vue

@ -7,10 +7,10 @@
<view class="day">{{ item.day }}</view>
</view>
</view>
<!-- "查看更多"按钮移到日期条下方 -->
<!-- "查"按钮移到日期条下方 -->
<view class="more-bar-wrapper">
<view class="more-bar" @click="openCalendar">
<text>看更多</text>
<view class="more-bar" @click="openSearchPopup">
<text></text>
<uni-icons type="arrowdown" size="18" color="#bdbdbd" />
</view>
</view>
@ -19,6 +19,71 @@
<uni-calendar @change="onCalendarChange" />
</uni-popup>
<!-- 查询弹窗 -->
<view v-if="showSearchPopup" class="search_popup_mask" @tap="showSearchPopup=false">
<view class="search_popup_content" @tap.stop>
<view class="popup_search_content">
<view class="popup_header">
<view class="popup_title">筛选</view>
<view class="popup_close" @tap="showSearchPopup=false">
<text class="close_text"></text>
</view>
</view>
<scroll-view :scroll-y="true" class="popup_scroll_view">
<!-- 筛选区域 -->
<view class="popup_filter_section">
<view class="popup_filter_row">
<view class="popup_filter_item">
<text class="popup_filter_label">时间查询</text>
<picker :value="timeIndex" :range="timeOptions" @change="onTimeChange">
<view class="popup_filter_picker">{{ timeOptions[timeIndex] }}</view>
</picker>
</view>
<view class="popup_filter_item">
<text class="popup_filter_label">班主任筛选</text>
<input class="popup_filter_input" placeholder="人员名称筛选" v-model="searchForm.teacher_name" />
</view>
</view>
<view class="popup_filter_row">
<view class="popup_filter_item">
<text class="popup_filter_label">开始日期</text>
<picker mode="date" :value="searchForm.start_date" @change="onStartDateChange">
<view class="popup_filter_input">
{{ searchForm.start_date || '选择开始日期' }}
</view>
</picker>
</view>
<view class="popup_filter_item">
<text class="popup_filter_label">结束日期</text>
<picker mode="date" :value="searchForm.end_date" @change="onEndDateChange">
<view class="popup_filter_input">
{{ searchForm.end_date || '选择结束日期' }}
</view>
</picker>
</view>
</view>
<view class="popup_filter_row">
<view class="popup_filter_item">
<text class="popup_filter_label">固定位筛选</text>
<input class="popup_filter_input" placeholder="输入固定位编号" v-model="searchForm.venue_number" type="number" />
</view>
</view>
</view>
</scroll-view>
<view class="popup_filter_buttons">
<view class="popup_filter_btn reset_btn" @click="resetSearchOnly">重置</view>
<view class="popup_filter_btn search_btn" @click="searchDataAndClose">搜索</view>
<view class="popup_filter_btn close_btn" @click="closeSearchPopup">关闭</view>
</view>
</view>
</view>
</view>
<!-- 课程卡片列表 -->
<view class="course-list">
<view class="course-card" v-for="(course, idx) in courseList" :key="idx">
@ -50,7 +115,21 @@
selectedDayIndex: 4,
date: '',
courseList: [],
resource_id:''
resource_id: '',
//
showSearchPopup: false,
searchForm: {
time_hour: '',
start_date: '',
end_date: '',
teacher_name: '',
venue_number: ''
},
//
timeIndex: 0,
timeOptions: ['全部时间', '上午(8:00-12:00)', '下午(12:00-18:00)', '晚上(18:00-22:00)']
};
},
onLoad(options) {
@ -113,6 +192,97 @@
completed: '已结束'
};
return statusMap[status] || status;
},
//
openSearchPopup() {
this.showSearchPopup = true;
},
closeSearchPopup() {
this.showSearchPopup = false;
},
//
onTimeChange(e) {
this.timeIndex = e.detail.value;
this.searchForm.time_hour = this.timeOptions[this.timeIndex];
},
//
onStartDateChange(e) {
this.searchForm.start_date = e.detail.value;
console.log('开始日期选择:', e.detail.value);
},
//
onEndDateChange(e) {
this.searchForm.end_date = e.detail.value;
console.log('结束日期选择:', e.detail.value);
},
//
searchDataAndClose() {
console.log('执行搜索,表单数据:', this.searchForm);
this.searchCourseData();
this.showSearchPopup = false;
},
//
async searchCourseData() {
try {
let searchParams = {
schedule_date: this.date
};
//
if (this.searchForm.time_hour && this.searchForm.time_hour !== '全部时间') {
searchParams.time_hour = this.searchForm.time_hour;
}
if (this.searchForm.start_date) {
searchParams.start_date = this.searchForm.start_date;
}
if (this.searchForm.end_date) {
searchParams.end_date = this.searchForm.end_date;
}
if (this.searchForm.teacher_name) {
searchParams.teacher_name = this.searchForm.teacher_name;
}
if (this.searchForm.venue_number) {
searchParams.venue_number = this.searchForm.venue_number;
}
console.log('搜索参数:', searchParams);
let data = await apiRoute.courseAllList(searchParams);
this.courseList = data.data;
uni.showToast({
title: '查询完成',
icon: 'success'
});
} catch (error) {
console.error('搜索失败:', error);
uni.showToast({
title: '搜索失败',
icon: 'none'
});
}
},
//
resetSearchOnly() {
this.searchForm = {
time_hour: '',
start_date: '',
end_date: '',
teacher_name: '',
venue_number: ''
};
this.timeIndex = 0;
//
this.getDate();
}
},
};
@ -236,4 +406,191 @@
}
}
}
//
.search_popup_mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 999;
display: flex;
flex-direction: column;
}
.search_popup_content {
background: #fff;
border-bottom-left-radius: 24rpx;
border-bottom-right-radius: 24rpx;
animation: slideDown 0.3s ease-out;
width: 100%;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
@keyframes slideDown {
from {
transform: translateY(-100%);
}
to {
transform: translateY(0);
}
}
//
.popup_search_content {
padding: 0;
background: #fff;
min-height: 60vh;
max-height: 80vh;
display: flex;
flex-direction: column;
border-bottom-left-radius: 24rpx;
border-bottom-right-radius: 24rpx;
overflow: hidden;
}
.popup_header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 32rpx;
border-bottom: 1px solid #f0f0f0;
}
.popup_title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.popup_close {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
.close_text {
font-size: 32rpx;
color: #999;
}
}
.popup_scroll_view {
flex: 1;
padding: 32rpx;
overflow-y: auto;
}
.popup_filter_section {
margin-bottom: 32rpx;
&:last-child {
margin-bottom: 0;
}
}
.popup_filter_row {
display: flex;
gap: 20rpx;
margin-bottom: 24rpx;
&:last-child {
margin-bottom: 0;
}
}
.popup_filter_item {
flex: 1;
display: flex;
flex-direction: column;
gap: 12rpx;
&.full_width {
flex: 1;
}
.popup_filter_label {
font-size: 26rpx;
color: #666;
font-weight: 500;
}
.popup_filter_input {
height: 72rpx;
line-height: 72rpx;
padding: 0 16rpx;
border: 1px solid #ddd;
border-radius: 8rpx;
font-size: 28rpx;
color: #333;
background: #fff;
&::placeholder {
color: #999;
}
}
.popup_filter_picker {
height: 72rpx;
line-height: 72rpx;
padding: 0 16rpx;
border: 1px solid #ddd;
border-radius: 8rpx;
font-size: 28rpx;
color: #333;
background: #fff;
position: relative;
&::after {
content: '▼';
position: absolute;
right: 16rpx;
font-size: 20rpx;
color: #999;
}
}
}
.popup_filter_buttons {
display: flex;
gap: 20rpx;
padding: 32rpx;
margin-top: auto;
border-top: 1px solid #f0f0f0;
background: #fff;
border-bottom-left-radius: 24rpx;
border-bottom-right-radius: 24rpx;
}
.popup_filter_btn {
flex: 1;
height: 72rpx;
line-height: 72rpx;
text-align: center;
border-radius: 8rpx;
font-size: 28rpx;
font-weight: 600;
&.search_btn {
background: #29d3b4;
color: #fff;
}
&.reset_btn {
background: #f5f5f5;
color: #666;
border: 1px solid #ddd;
}
&.close_btn {
background: #666;
color: #fff;
}
}
</style>

2204
uniapp/pages/market/clue/clue_info.less

File diff suppressed because it is too large

1408
uniapp/pages/market/clue/clue_info.scss

File diff suppressed because it is too large

3379
uniapp/pages/market/clue/clue_info.vue

File diff suppressed because it is too large

549
uniapp/pages/market/clue/clue_info_refactored.vue

@ -0,0 +1,549 @@
<!--重构后的客户详情页面 - 使用组件化方式-->
<template>
<view class="assemble">
<view class="main_box">
<view style="height: 20rpx;background: #29D3B4;"></view>
<!-- 头部信息区域 -->
<view class="count_section">
<view class="main">
<view class="course_box">
<view class="course_box_top">
<view class="course_box_top_top">
<image class="pic" :src="$util.img('/uniapp_src/static/images/index/myk.png')"></image>
<view class="name">{{ $util.safeGet(clientInfo, 'customerResource.name', '未知客户') }}</view>
</view>
<view class="course_box_top_below">
<view class="course_box_top_below-right">
<!-- 操作按钮 -->
<view class="action-buttons">
<view class="btn-item" @click="handleMakeCall">
<image class="btn-icon" :src="$util.img('/uniapp_src/static/images/index/phone.png')"></image>
</view>
<view class="btn-item" @click="handleSendMessage" v-if="$util.safeGet(clientInfo, 'customerResource.member_id')">
<image class="btn-icon" :src="$util.img('/uniapp_src/static/images/index/message.png')"></image>
</view>
</view>
</view>
</view>
</view>
<!-- 标签切换组件 -->
<TabSwitcher
:tabs="tabs"
:active-tab-id="switch_tags_type"
@tab-change="handleTabChange"
/>
</view>
</view>
</view>
<view class="bg_box bg_top"></view>
<view class="bg_box bg_bottom"></view>
</view>
<!-- 基本资料 -->
<view class="content-section" v-if="switch_tags_type == 1">
<view class="integrated-info-section">
<view class="basic-message">
<view>客户和学生信息</view>
<view class="add-student-btn" @click="openAddStudentDialog">
<view class="add-icon">+</view>
<view class="add-text">添加学生</view>
</view>
</view>
<!-- 使用客户信息卡片组件 -->
<ClientInfoCard
:client-info="clientInfo"
@call="handleMakeCall"
/>
<!-- 学生信息列表 -->
<view class="student-list" v-if="studentList.length > 0">
<StudentInfoCard
v-for="student in studentList"
:key="student.id"
:student="student"
:actions="studentActions"
@toggle-actions="toggleStudentActions"
@action="handleStudentAction"
/>
</view>
</view>
</view>
<!-- 课程信息 -->
<view class="content-section" v-else-if="switch_tags_type == 2">
<view class="course-info-section">
<text>课程信息内容...</text>
</view>
</view>
<!-- 通话记录 -->
<view class="content-section" v-else-if="switch_tags_type == 3">
<view class="call-records-section">
<CallRecordCard
v-for="record in listCallUp"
:key="record.id"
:record="record"
/>
</view>
</view>
<!-- 体测记录 -->
<view class="content-section" v-else-if="switch_tags_type == 4">
<view class="fitness-records-section">
<FitnessRecordCard
v-for="record in fitnessRecords"
:key="record.id"
:record="record"
@file-click="handleFitnessFileClick"
/>
</view>
</view>
<!-- 学习计划 -->
<view class="content-section" v-else-if="switch_tags_type == 5">
<view class="study-plan-section">
<text>学习计划内容...</text>
</view>
</view>
</view>
</template>
<script>
import apiRoute from '@/api/apiRoute.js'
import marketApi from '@/api/marketApi.js'
//
import ClientInfoCard from '@/components/client-info-card/client-info-card.vue'
import StudentInfoCard from '@/components/student-info-card/student-info-card.vue'
import TabSwitcher from '@/components/tab-switcher/tab-switcher.vue'
import FitnessRecordCard from '@/components/fitness-record-card/fitness-record-card.vue'
import CallRecordCard from '@/components/call-record-card/call-record-card.vue'
export default {
name: 'ClueInfoRefactored',
components: {
ClientInfoCard,
StudentInfoCard,
TabSwitcher,
FitnessRecordCard,
CallRecordCard
},
data() {
return {
resource_sharing_id: '',
switch_tags_type: 1, //
//
tabs: [
{ id: 1, name: '基本资料' },
{ id: 2, name: '课程信息' },
{ id: 3, name: '通话记录' },
{ id: 4, name: '体测记录' },
{ id: 5, name: '学习计划' }
],
//
studentActions: [
{ key: 'edit', text: '编辑学生' },
{ key: 'order', text: '查看订单' },
{ key: 'course', text: '课程安排' },
{ key: 'fitness', text: '体测记录' }
],
//
clientInfo: {},
userInfo: {},
studentList: [],
listCallUp: [],
fitnessRecords: [],
followList: [],
courseInfo: [],
coachList: [],
educationList: [],
assistantList: []
}
},
onLoad(option) {
console.log('页面加载参数:', option)
this.resource_sharing_id = option.resource_sharing_id || ''
this.init()
},
methods: {
async init() {
console.log('开始初始化数据...')
try {
await this.getInfo()
await Promise.all([
this.getUserInfo(),
this.getListCallUp(),
this.getStudentList(),
this.getFitnessRecords()
])
console.log('数据初始化完成')
} catch (error) {
console.error('初始化失败:', error)
}
},
//
async getInfo() {
if (!this.resource_sharing_id) {
uni.showToast({ title: '缺少必要参数', icon: 'none' })
return false
}
try {
const res = await apiRoute.xs_resourceSharingInfo({
resource_sharing_id: this.resource_sharing_id
})
if (res.code === 1) {
this.clientInfo = res.data
return true
} else {
uni.showToast({ title: res.msg, icon: 'none' })
return false
}
} catch (error) {
console.error('获取客户详情失败:', error)
return false
}
},
//
async getUserInfo() {
try {
const res = await apiRoute.getPersonnelInfo({})
if (res.code === 1) {
this.userInfo = res.data
return true
}
return false
} catch (error) {
console.error('获取员工信息失败:', error)
return false
}
},
//
async getListCallUp() {
if (!this.clientInfo.resource_id) return false
try {
const res = await apiRoute.listCallUp({
resource_id: this.clientInfo.resource_id
})
if (res.code === 1) {
this.listCallUp = res.data || []
return true
}
return false
} catch (error) {
console.error('获取通话记录失败:', error)
return false
}
},
//
async getStudentList() {
try {
if (!this.clientInfo.resource_id) {
// 使Mock
this.studentList = this.getMockStudentList()
return true
}
const res = await apiRoute.xs_getStudentList({
parent_resource_id: this.clientInfo.resource_id
})
if (res.code === 1) {
this.studentList = res.data || []
} else {
this.studentList = this.getMockStudentList()
}
return true
} catch (error) {
console.error('获取学生列表失败:', error)
this.studentList = this.getMockStudentList()
return true
}
},
//
async getFitnessRecords() {
try {
// 使Mock
this.fitnessRecords = this.getMockFitnessRecords()
return true
} catch (error) {
console.error('获取体测记录失败:', error)
return false
}
},
// Mock
getMockStudentList() {
return [
{
id: 1,
name: '张小明',
gender: 1,
age: 9.05,
birthday: '2015-05-10',
emergency_contact: '张妈妈',
contact_phone: '13800138001',
member_label: '新学员',
note: '活泼好动,喜欢运动',
actionsExpanded: false
}
]
},
getMockFitnessRecords() {
return [
{
id: 1,
test_date: '2024-01-15',
height: '165',
weight: '55',
pdf_files: [
{
id: 1,
name: '体测报告_2024-01-15.pdf',
size: 1024000,
url: '/static/mock/fitness_report_1.pdf'
}
]
}
]
},
//
handleTabChange({ tabId }) {
this.switch_tags_type = tabId
},
handleMakeCall() {
const phoneNumber = this.$util.safeGet(this.clientInfo, 'customerResource.phone_number', '')
this.$util.makePhoneCall(phoneNumber)
},
handleSendMessage() {
uni.showToast({
title: '发送消息功能待实现',
icon: 'none'
})
},
toggleStudentActions(student) {
const index = this.studentList.findIndex(s => s.id === student.id)
if (index !== -1) {
this.$set(this.studentList[index], 'actionsExpanded', !student.actionsExpanded)
}
},
handleStudentAction({ action, student }) {
console.log('学生操作:', action, student)
switch (action.key) {
case 'edit':
this.editStudent(student)
break
case 'order':
this.viewStudentOrders(student)
break
case 'course':
this.viewStudentCourse(student)
break
case 'fitness':
this.viewStudentFitness(student)
break
}
},
handleFitnessFileClick({ file, record }) {
console.log('点击体测文件:', file, record)
//
},
openAddStudentDialog() {
console.log('打开添加学生对话框')
//
},
//
editStudent(student) {
this.$util.navigateToPage('/pages/student/edit', {
student_id: student.id
})
},
viewStudentOrders(student) {
this.$util.navigateToPage('/pages/market/clue/order_list', {
resource_id: this.clientInfo.resource_id,
student_id: student.id,
student_name: student.name
})
},
viewStudentCourse(student) {
this.$util.navigateToPage('/pages/market/clue/course_arrange', {
resource_id: this.clientInfo.resource_id,
student_id: student.id,
student_name: student.name
})
},
viewStudentFitness(student) {
this.$util.navigateToPage('/pages/fitness/records', {
student_id: student.id
})
}
}
}
</script>
<style lang="less" scoped>
.assemble {
width: 100%;
height: 100vh;
overflow: auto;
background-color: #292929;
}
.main_box {
background: #292929;
min-height: 20vh;
}
.action-buttons {
display: flex;
align-items: center;
.btn-item {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
margin-left: 20rpx;
.btn-icon {
width: 40rpx;
height: 40rpx;
}
}
}
.count_section {
width: 100%;
position: relative;
.main {
width: 100%;
position: absolute;
z-index: 2;
padding: 0rpx 24rpx;
display: flex;
justify-content: center;
.course_box {
padding: 26rpx 22rpx 0 22rpx;
width: 95%;
height: 250rpx;
border-radius: 20rpx;
background-color: #fff;
}
}
.bg_top {
height: 180rpx;
background-color: #29D3B4;
}
.bg_bottom {
height: 80rpx;
background-color: #292929;
}
}
.course_box_top {
display: flex;
justify-content: space-between;
align-items: center;
.course_box_top_top {
display: flex;
align-items: center;
.pic {
width: 60rpx;
height: 60rpx;
margin-right: 20rpx;
}
.name {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
}
}
.content-section {
background-color: #292929;
min-height: 60vh;
padding: 20rpx;
}
.integrated-info-section {
.basic-message {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx 20rpx;
color: white;
font-size: 28rpx;
font-weight: bold;
.add-student-btn {
display: flex;
align-items: center;
background-color: #29d3b4;
padding: 15rpx 25rpx;
border-radius: 25rpx;
.add-icon {
color: white;
font-size: 24rpx;
margin-right: 10rpx;
}
.add-text {
color: white;
font-size: 22rpx;
}
}
}
}
.student-list,
.call-records-section,
.fitness-records-section {
padding: 20rpx 0;
}
.course-info-section,
.study-plan-section {
padding: 40rpx 20rpx;
color: white;
text-align: center;
}
</style>

4
uniapp/pages/market/clue/index.vue

@ -44,10 +44,10 @@
</view>
<!-- 到访状态标签 -->
<view class="visit-status">
<view :class="['visit-tag-',getVisitStatusClass(v.customerResource.first_visit_status)]">
<view :class="['visit-tag',getVisitStatusClass(v.customerResource.first_visit_status)]">
一访{{ v.customerResource.first_visit_status || '未到' }}
</view>
<view :class="['visit-tag-',getVisitStatusClass(v.customerResource.second_visit_status)]">
<view :class="['visit-tag',getVisitStatusClass(v.customerResource.second_visit_status)]">
二访{{ v.customerResource.second_visit_status || '未到' }}
</view>
</view>

280
uniapp权限管理配置文档.md

@ -0,0 +1,280 @@
# UniApp 权限管理配置文档
## 📋 概述
本文档详细列出了UniApp教育培训管理系统中所有页面的权限配置建议,用于实现精细化的菜单权限管理。系统支持**员工端**(市场、教练、销售)和**会员端**(学员、家长)两种登录方式。
## 🔐 权限等级说明
| 权限等级 | 说明 | 适用场景 |
|---------|------|---------|
| **公开访问** | 无需登录即可访问 | 登录页、隐私协议等 |
| **登录验证** | 需要有效Token | 大部分功能页面 |
| **角色验证** | 需要特定角色权限 | 业务功能页面 |
| **管理员权限** | 需要管理员级别权限 | 人员管理、系统配置 |
| **开发权限** | 仅开发环境可访问 | 测试页面、Demo页面 |
## 👥 用户角色定义
### 员工端角色
- **市场人员** (role_type=1): 负责客户线索管理、市场推广
- **教练** (role_type=2): 负责课程教学、学员管理
- **销售人员** (role_type=3): 负责客户转化、订单管理
### 会员端角色
- **学员** (user_type=member): 查看课程、作业、个人信息
- **家长** (user_type=parent): 管理孩子信息、课程、服务等
## 📊 页面权限配置表
### 🚪 登录认证模块
| 页面路径 | 页面名称 | 权限等级 | 市场 | 教练 | 销售 | 学员 | 家长 | 建议纳入权限管理 |
|---------|---------|---------|-----|-----|-----|-----|-----|-----------------|
| `/pages/student/login/login` | 登录页面 | 公开访问 | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
| `/pages/student/login/forgot` | 找回密码 | 公开访问 | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
### 📚 学员模块
| 页面路径 | 页面名称 | 权限等级 | 市场 | 教练 | 销售 | 学员 | 家长 | 建议纳入权限管理 |
|---------|---------|---------|-----|-----|-----|-----|-----|-----------------|
| `/pages/student/index/index` | 学员首页 | 登录验证 | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ |
| `/pages/student/index/job_list` | 作业列表 | 登录验证 | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ |
| `/pages/student/index/work_details` | 作业详情 | 登录验证 | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ |
| `/pages/student/index/physical_examination` | 体测数据 | 登录验证 | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ |
| `/pages/student/timetable/index` | 课表主页 | 登录验证 | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ |
| `/pages/student/timetable/info` | 课表详情 | 登录验证 | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ |
| `/pages/student/timetable/list` | 场馆列表 | 登录验证 | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
| `/pages/student/my/my` | 个人中心 | 登录验证 | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ |
| `/pages/student/my/my_coach` | 我的教练 | 登录验证 | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
| `/pages/student/my/my_members` | 我的成员 | 登录验证 | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
| `/pages/student/my/lesson_consumption` | 课时消耗 | 登录验证 | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ |
| `/pages/student/my/personal_data` | 个人资料 | 登录验证 | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ |
| `/pages/student/my/set_up` | 设置 | 登录验证 | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ |
| `/pages/student/my/update_pass` | 修改密码 | 登录验证 | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ |
### 👨‍🏫 教练模块
| 页面路径 | 页面名称 | 权限等级 | 市场 | 教练 | 销售 | 学员 | 家长 | 建议纳入权限管理 |
|---------|---------|---------|-----|-----|-----|-----|-----|-----------------|
| `/pages/coach/home/index` | 教练首页 | 角色验证 | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
| `/pages/coach/job/add` | 发布作业 | 角色验证 | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
| `/pages/coach/job/list` | 全部作业 | 角色验证 | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
| `/pages/coach/course/list` | 课表管理 | 角色验证 | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
| `/pages/coach/course/info` | 课时详情 | 角色验证 | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
| `/pages/coach/course/info_list` | 课时详情列表 | 角色验证 | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
| `/pages/coach/class/list` | 班级管理 | 角色验证 | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
| `/pages/coach/class/info` | 班级详情 | 角色验证 | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
| `/pages/coach/student/student_list` | 我的学员 | 角色验证 | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
| `/pages/coach/student/student_detail` | 学员详情 | 角色验证 | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
| `/pages/coach/student/info` | 学员信息 | 角色验证 | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
| `/pages/coach/student/work_details` | 作业任务 | 角色验证 | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
| `/pages/coach/student/physical_examination` | 体测数据 | 角色验证 | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
| `/pages/coach/schedule/schedule_table` | 课程安排 | 角色验证 | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
| `/pages/coach/schedule/add_schedule` | 添加课程安排 | 角色验证 | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
| `/pages/coach/schedule/adjust_course` | 调整课程安排 | 角色验证 | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
| `/pages/coach/schedule/sign_in` | 课程点名 | 角色验证 | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
| `/pages/coach/schedule/schedule_detail` | 课程详情 | 角色验证 | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
| `/pages/coach/my/index` | 个人中心 | 角色验证 | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ |
| `/pages/coach/my/info` | 个人资料 | 角色验证 | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ |
| `/pages/coach/my/set_up` | 设置 | 角色验证 | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ |
| `/pages/coach/my/update_pass` | 修改密码 | 角色验证 | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ |
| `/pages/coach/my/arrival_statistics` | 到课统计 | 角色验证 | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
| `/pages/coach/my/due_soon` | 即将到期 | 角色验证 | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
| `/pages/coach/my/schooling_statistics` | 授课统计 | 角色验证 | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
| `/pages/coach/my/teaching_management` | 教研管理列表 | 角色验证 | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
| `/pages/coach/my/teaching_management_info` | 文章详情 | 角色验证 | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
| `/pages/coach/my/gotake_exam` | 考试 | 角色验证 | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
| `/pages/coach/my/exam_results` | 考试结果 | 角色验证 | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
| `/pages/coach/my/my_attendance` | 我的考勤 | 角色验证 | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
| `/pages/coach/my/service_list` | 服务列表 | 角色验证 | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
| `/pages/coach/my/service_detail` | 服务详情 | 角色验证 | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ |
### 💼 市场模块
| 页面路径 | 页面名称 | 权限等级 | 市场 | 教练 | 销售 | 学员 | 家长 | 建议纳入权限管理 |
|---------|---------|---------|-----|-----|-----|-----|-----|-----------------|
| `/pages/market/home/index` | 市场首页 | 角色验证 | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ |
| `/pages/market/index/index` | 销售首页 | 角色验证 | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ |
| `/pages/market/clue/index` | 线索管理 | 角色验证 | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ |
| `/pages/market/clue/clue_info` | 客户详情 | 角色验证 | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ |
| `/pages/market/clue/order_list` | 订单列表 | 角色验证 | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ |
| `/pages/market/clue/add_clues` | 添加客户 | 角色验证 | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ |
| `/pages/market/clue/edit_clues` | 编辑客户 | 角色验证 | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ |
| `/pages/market/clue/edit_clues_log` | 修改记录 | 角色验证 | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ |
| `/pages/market/clue/new_task` | 新增任务 | 角色验证 | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ |
| `/pages/market/clue/writing_followUp` | 添加跟进 | 角色验证 | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ |
| `/pages/market/clue/class_arrangement` | 课程安排 | 角色验证 | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ |
| `/pages/market/clue/class_arrangement_detail` | 课程安排详情 | 角色验证 | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ |
| `/pages/market/clue/clue_table` | 数据统计 | 角色验证 | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ |
| `/pages/market/course/course_detail` | 课程详情 | 角色验证 | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ |
| `/pages/market/data/index` | 数据中心 | 角色验证 | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ |
| `/pages/market/data/statistics` | 市场数据统计 | 角色验证 | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ |
| `/pages/market/reimbursement/list` | 报销列表 | 角色验证 | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ |
| `/pages/market/reimbursement/add` | 新增报销 | 角色验证 | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ |
| `/pages/market/reimbursement/detail` | 报销详情 | 角色验证 | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ |
| `/pages/market/my/index` | 个人中心 | 角色验证 | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
| `/pages/market/my/info` | 个人资料 | 角色验证 | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
| `/pages/market/my/set_up` | 设置 | 角色验证 | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
| `/pages/market/my/update_pass` | 修改密码 | 角色验证 | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
| `/pages/market/my/signed_client_list` | 已签客户 | 角色验证 | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ |
| `/pages/market/my/my_data` | 我的数据 | 角色验证 | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ |
| `/pages/market/my/dept_data` | 部门数据 | 角色验证 | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ |
| `/pages/market/my/campus_data` | 校区数据 | 角色验证 | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ |
| `/pages/market/my/firm_info` | 企业信息 | 角色验证 | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ |
### 👥 家长模块
| 页面路径 | 页面名称 | 权限等级 | 市场 | 教练 | 销售 | 学员 | 家长 | 建议纳入权限管理 |
|---------|---------|---------|-----|-----|-----|-----|-----|-----------------|
| `/pages/parent/user-info/index` | 用户信息 | 角色验证 | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
| `/pages/parent/user-info/child-detail` | 孩子详情 | 角色验证 | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
| `/pages/parent/courses/index` | 课程管理 | 角色验证 | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
| `/pages/parent/courses/course-detail` | 课程详情 | 角色验证 | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
| `/pages/parent/materials/index` | 教学资料 | 角色验证 | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
| `/pages/parent/materials/material-detail` | 资料详情 | 角色验证 | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
| `/pages/parent/services/index` | 服务管理 | 角色验证 | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
| `/pages/parent/services/service-detail` | 服务详情 | 角色验证 | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
| `/pages/parent/orders/index` | 订单管理 | 角色验证 | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
| `/pages/parent/orders/order-detail` | 订单详情 | 角色验证 | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
| `/pages/parent/messages/index` | 消息管理 | 角色验证 | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
| `/pages/parent/messages/message-detail` | 消息详情 | 角色验证 | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
| `/pages/parent/contracts/index` | 合同管理 | 角色验证 | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
| `/pages/parent/contracts/contract-detail` | 合同详情 | 角色验证 | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
### 🛠️ 公共模块
| 页面路径 | 页面名称 | 权限等级 | 市场 | 教练 | 销售 | 学员 | 家长 | 建议纳入权限管理 |
|---------|---------|---------|-----|-----|-----|-----|-----|-----------------|
| `/pages/common/privacy_agreement` | 隐私协议 | 公开访问 | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
| `/pages/common/article_info` | 文章详情 | 登录验证 | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
| `/pages/common/feedback` | 意见反馈 | 登录验证 | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
| `/pages/common/my_message` | 我的消息 | 登录验证 | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
| `/pages/common/im_chat_info` | 聊天详情 | 登录验证 | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
| `/pages/common/sys_msg_list` | 系统消息 | 登录验证 | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
| `/pages/common/contract_list` | 订单列表 | 登录验证 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| `/pages/common/my_attendance` | 我的考勤 | 登录验证 | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
| `/pages/common/personnel/add_personnel` | 新员工信息填写 | 管理员权限 | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
| `/pages/common/contract/my_contract` | 我的合同 | 登录验证 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| `/pages/common/contract/contract_detail` | 合同详情 | 登录验证 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| `/pages/common/contract/contract_sign` | 合同签订 | 登录验证 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
### 📋 教务模块
| 页面路径 | 页面名称 | 权限等级 | 市场 | 教练 | 销售 | 学员 | 家长 | 建议纳入权限管理 |
|---------|---------|---------|-----|-----|-----|-----|-----|-----------------|
| `/pages/academic/home/index` | 教务首页 | 管理员权限 | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
### 🧪 测试模块
| 页面路径 | 页面名称 | 权限等级 | 市场 | 教练 | 销售 | 学员 | 家长 | 建议纳入权限管理 |
|---------|---------|---------|-----|-----|-----|-----|-----|-----------------|
| `/pages/demo/mock-demo` | Mock数据演示 | 开发权限 | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| `/pages/demo/dict_optimization` | 字典优化 | 开发权限 | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
## 🎯 权限管理建议
### 🔥 高优先级页面(建议优先纳入权限管理)
#### 核心业务功能
- **线索管理模块**: 客户信息、跟进记录、订单管理
- **课程教学模块**: 课表管理、作业管理、学员管理
- **数据统计模块**: 各类业务数据和报表
- **财务模块**: 报销管理、合同管理
#### 敏感数据页面
- **个人信息查看**: 学员详情、教练信息、客户资料
- **体测数据**: 学员体能测试结果
- **考勤统计**: 教练和学员的考勤记录
- **教研管理**: 教学资料和考试内容
### 🔶 中优先级页面(建议选择性纳入)
#### 查看类功能
- **课表查看**: 学员课程表、教练课程安排
- **消息通知**: 系统消息、聊天功能
- **服务管理**: 各类服务项目
### 🔷 低优先级页面(可不纳入权限管理)
#### 通用功能
- **个人设置**: 个人资料、密码修改、系统设置
- **公共信息**: 隐私协议、文章详情、意见反馈
## ⚙️ 技术实现建议
### 1. 路由守卫实现
```javascript
// router/permission.js
const routePermissions = {
'/pages/market/clue/index': ['market', 'sales'],
'/pages/coach/home/index': ['coach'],
'/pages/student/index/index': ['member', 'parent'],
// ...更多路由权限配置
}
function checkPermission(route, userRole) {
const requiredRoles = routePermissions[route]
return !requiredRoles || requiredRoles.includes(userRole)
}
```
### 2. 菜单动态生成
```javascript
// menu/menuConfig.js
const menuConfig = {
market: [
{ path: '/pages/market/home/index', name: '首页', icon: 'home' },
{ path: '/pages/market/clue/index', name: '线索管理', icon: 'clue' },
// ...市场人员菜单
],
coach: [
{ path: '/pages/coach/home/index', name: '首页', icon: 'home' },
{ path: '/pages/coach/course/list', name: '课表管理', icon: 'course' },
// ...教练菜单
],
// ...其他角色菜单
}
```
### 3. API权限验证
```php
// middleware/AuthMiddleware.php
public function handle($request, $next, $role = null) {
$user = $this->getUser($request);
if ($role && !$this->hasRole($user, $role)) {
return $this->unauthorizedResponse();
}
return $next($request);
}
```
## 📝 配置建议总结
### ✅ 强烈建议纳入权限管理的页面(58个)
1. **线索管理**: 11个页面(客户信息、跟进、订单等)
2. **教练功能**: 22个页面(课程、学员、作业等)
3. **家长功能**: 14个页面(孩子管理、服务等)
4. **数据统计**: 8个页面(各类业务报表)
5. **其他业务**: 3个页面(教务、人员、考勤等)
### 🔶 可选择纳入权限管理的页面(15个)
1. **学员查看**: 7个页面(课表、作业、体测等)
2. **公共业务**: 8个页面(合同、消息、服务等)
### ❌ 建议不纳入权限管理的页面(25个)
1. **登录认证**: 2个页面
2. **个人设置**: 12个页面
3. **公共信息**: 6个页面
4. **开发测试**: 2个页面
5. **基础功能**: 3个页面
总计98个页面中,建议将**58个核心业务页面**纳入权限管理系统,确保数据安全和角色隔离的同时,保持系统的易用性和维护性。
Loading…
Cancel
Save