42 changed files with 12448 additions and 780 deletions
@ -1,8 +0,0 @@ |
|||
# 默认忽略的文件 |
|||
/shelf/ |
|||
/workspace.xml |
|||
# 基于编辑器的 HTTP 客户端请求 |
|||
/httpRequests/ |
|||
# Datasource local storage ignored files |
|||
/dataSources/ |
|||
/dataSources.local.xml |
|||
@ -1,8 +0,0 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<module type="WEB_MODULE" version="4"> |
|||
<component name="NewModuleRootManager"> |
|||
<content url="file://$MODULE_DIR$" /> |
|||
<orderEntry type="inheritedJdk" /> |
|||
<orderEntry type="sourceFolder" forTests="false" /> |
|||
</component> |
|||
</module> |
|||
@ -1,8 +0,0 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<project version="4"> |
|||
<component name="ProjectModuleManager"> |
|||
<modules> |
|||
<module fileurl="file://$PROJECT_DIR$/.idea/app.iml" filepath="$PROJECT_DIR$/.idea/app.iml" /> |
|||
</modules> |
|||
</component> |
|||
</project> |
|||
@ -1,22 +0,0 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<project version="4"> |
|||
<component name="MessDetectorOptionsConfiguration"> |
|||
<option name="transferred" value="true" /> |
|||
</component> |
|||
<component name="PHPCSFixerOptionsConfiguration"> |
|||
<option name="transferred" value="true" /> |
|||
</component> |
|||
<component name="PHPCodeSnifferOptionsConfiguration"> |
|||
<option name="highlightLevel" value="WARNING" /> |
|||
<option name="transferred" value="true" /> |
|||
</component> |
|||
<component name="PhpProjectSharedConfiguration" php_language_level="8.0"> |
|||
<option name="suggestChangeDefaultLanguageLevel" value="false" /> |
|||
</component> |
|||
<component name="PhpStanOptionsConfiguration"> |
|||
<option name="transferred" value="true" /> |
|||
</component> |
|||
<component name="PsalmOptionsConfiguration"> |
|||
<option name="transferred" value="true" /> |
|||
</component> |
|||
</project> |
|||
@ -1,6 +0,0 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<project version="4"> |
|||
<component name="VcsDirectoryMappings"> |
|||
<mapping directory="$PROJECT_DIR$/../.." vcs="Git" /> |
|||
</component> |
|||
</project> |
|||
@ -0,0 +1,134 @@ |
|||
<?php |
|||
// +---------------------------------------------------------------------- |
|||
// | Niucloud-admin 企业快速开发的多应用管理平台 |
|||
// +---------------------------------------------------------------------- |
|||
// | 官方网址:https://www.niucloud.com |
|||
// +---------------------------------------------------------------------- |
|||
// | niucloud团队 版权所有 开源版本可自由商用 |
|||
// +---------------------------------------------------------------------- |
|||
// | Author: Niucloud Team |
|||
// +---------------------------------------------------------------------- |
|||
|
|||
namespace app\api\controller\login; |
|||
|
|||
use app\service\api\login\UnifiedLoginService; |
|||
use app\service\api\login\WechatService; |
|||
use core\base\BaseController; |
|||
use core\exception\CommonException; |
|||
use think\Response; |
|||
|
|||
/** |
|||
* 微信登录控制器 |
|||
*/ |
|||
class WechatLogin extends BaseController |
|||
{ |
|||
/** |
|||
* 微信openid登录 |
|||
* @return Response |
|||
*/ |
|||
public function login() |
|||
{ |
|||
$data = $this->request->params([ |
|||
['openid', ''], |
|||
['login_type', 'member'] |
|||
]); |
|||
|
|||
$this->validate($data, [ |
|||
'openid' => 'require', |
|||
'login_type' => 'require' |
|||
]); |
|||
|
|||
try { |
|||
$service = new UnifiedLoginService(); |
|||
$result = $service->wechatLogin($data); |
|||
|
|||
return success($result, '微信登录成功'); |
|||
|
|||
} catch (CommonException $e) { |
|||
if ($e->getCode() === 10001) { |
|||
// 需要绑定的特殊情况 |
|||
return fail($e->getMessage(), [], 10001); |
|||
} |
|||
return fail($e->getMessage()); |
|||
} catch (\Exception $e) { |
|||
return fail($e->getMessage()); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 微信账号绑定 |
|||
* @return Response |
|||
*/ |
|||
public function bind() |
|||
{ |
|||
$data = $this->request->params([ |
|||
['mini_openid', ''], |
|||
['wechat_openid', ''], |
|||
['phone', ''], |
|||
['code', ''] |
|||
]); |
|||
|
|||
$this->validate($data, [ |
|||
'mini_openid' => 'require', |
|||
'wechat_openid' => 'require', |
|||
'phone' => 'require|mobile', |
|||
'code' => 'require' |
|||
]); |
|||
|
|||
try { |
|||
$service = new UnifiedLoginService(); |
|||
$result = $service->wechatBind($data); |
|||
|
|||
return success($result, '绑定成功'); |
|||
|
|||
} catch (\Exception $e) { |
|||
return fail($e->getMessage()); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取微信公众号授权URL |
|||
* @return Response |
|||
*/ |
|||
public function getAuthUrl() |
|||
{ |
|||
try { |
|||
$miniOpenid = $this->request->param('mini_openid', ''); |
|||
if (empty($miniOpenid)) { |
|||
return fail('小程序openid不能为空'); |
|||
} |
|||
|
|||
$service = new WechatService(); |
|||
$result = $service->getAuthUrl($miniOpenid); |
|||
|
|||
return success($result, '获取授权URL成功'); |
|||
|
|||
} catch (\Exception $e) { |
|||
return fail($e->getMessage()); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 微信公众号授权回调 |
|||
* @return Response |
|||
*/ |
|||
public function callback() |
|||
{ |
|||
try { |
|||
$code = $this->request->param('code', ''); |
|||
$state = $this->request->param('state', ''); |
|||
|
|||
if (empty($code)) { |
|||
return fail('授权失败'); |
|||
} |
|||
|
|||
$service = new WechatService(); |
|||
$result = $service->handleCallback($code, $state); |
|||
|
|||
return success($result, '授权成功'); |
|||
|
|||
} catch (\Exception $e) { |
|||
return fail($e->getMessage()); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,253 @@ |
|||
<?php |
|||
// +---------------------------------------------------------------------- |
|||
// | Niucloud-admin 企业快速开发的多应用管理平台 |
|||
// +---------------------------------------------------------------------- |
|||
// | 官方网址:https://www.niucloud.com |
|||
// +---------------------------------------------------------------------- |
|||
// | niucloud团队 版权所有 开源版本可自由商用 |
|||
// +---------------------------------------------------------------------- |
|||
// | Author: Niucloud Team |
|||
// +---------------------------------------------------------------------- |
|||
|
|||
namespace app\api\controller\parent; |
|||
|
|||
use app\service\api\parent\ParentService; |
|||
use core\base\BaseController; |
|||
use think\Response; |
|||
|
|||
/** |
|||
* 家长端控制器 |
|||
*/ |
|||
class ParentController extends BaseController |
|||
{ |
|||
/** |
|||
* 获取家长下的孩子列表 |
|||
* @return Response |
|||
*/ |
|||
public function getChildrenList() |
|||
{ |
|||
try { |
|||
$service = new ParentService(); |
|||
$result = $service->getChildrenList(); |
|||
|
|||
return success($result, '获取孩子列表成功'); |
|||
|
|||
} catch (\Exception $e) { |
|||
return fail($e->getMessage()); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取指定孩子的详细信息 |
|||
* @return Response |
|||
*/ |
|||
public function getChildInfo() |
|||
{ |
|||
$data = $this->request->params([ |
|||
['child_id', ''] |
|||
]); |
|||
|
|||
$this->validate($data, [ |
|||
'child_id' => 'require' |
|||
]); |
|||
|
|||
try { |
|||
$service = new ParentService(); |
|||
$result = $service->getChildInfo($data['child_id']); |
|||
|
|||
return success($result, '获取孩子信息成功'); |
|||
|
|||
} catch (\Exception $e) { |
|||
return fail($e->getMessage()); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 新增孩子信息 |
|||
* @return Response |
|||
*/ |
|||
public function addChild() |
|||
{ |
|||
$data = $this->request->params([ |
|||
['name', ''], |
|||
['gender', 1], |
|||
['birthday', ''], |
|||
['age', 0], |
|||
['remark', ''] |
|||
]); |
|||
|
|||
$this->validate($data, [ |
|||
'name' => 'require', |
|||
'gender' => 'require|in:1,2', |
|||
'birthday' => 'require|date' |
|||
]); |
|||
|
|||
try { |
|||
$service = new ParentService(); |
|||
$result = $service->addChild($data); |
|||
|
|||
return success($result, '添加孩子成功'); |
|||
|
|||
} catch (\Exception $e) { |
|||
return fail($e->getMessage()); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 更新孩子信息 |
|||
* @return Response |
|||
*/ |
|||
public function updateChildInfo() |
|||
{ |
|||
$data = $this->request->params([ |
|||
['child_id', ''], |
|||
['name', ''], |
|||
['gender', 1], |
|||
['birthday', ''], |
|||
['age', 0], |
|||
['remark', ''] |
|||
]); |
|||
|
|||
$this->validate($data, [ |
|||
'child_id' => 'require', |
|||
'name' => 'require', |
|||
'gender' => 'require|in:1,2', |
|||
'birthday' => 'require|date' |
|||
]); |
|||
|
|||
try { |
|||
$service = new ParentService(); |
|||
$result = $service->updateChildInfo($data); |
|||
|
|||
return success($result, '更新孩子信息成功'); |
|||
|
|||
} catch (\Exception $e) { |
|||
return fail($e->getMessage()); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取指定孩子的课程信息 |
|||
* @return Response |
|||
*/ |
|||
public function getChildCourses() |
|||
{ |
|||
$data = $this->request->params([ |
|||
['child_id', ''] |
|||
]); |
|||
|
|||
$this->validate($data, [ |
|||
'child_id' => 'require' |
|||
]); |
|||
|
|||
try { |
|||
$service = new ParentService(); |
|||
$result = $service->getChildCourses($data['child_id']); |
|||
|
|||
return success($result, '获取孩子课程信息成功'); |
|||
|
|||
} catch (\Exception $e) { |
|||
return fail($e->getMessage()); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取指定孩子的订单信息 |
|||
* @return Response |
|||
*/ |
|||
public function getChildOrders() |
|||
{ |
|||
$data = $this->request->params([ |
|||
['child_id', ''] |
|||
]); |
|||
|
|||
$this->validate($data, [ |
|||
'child_id' => 'require' |
|||
]); |
|||
|
|||
try { |
|||
$service = new ParentService(); |
|||
$result = $service->getChildOrders($data['child_id']); |
|||
|
|||
return success($result, '获取孩子订单信息成功'); |
|||
|
|||
} catch (\Exception $e) { |
|||
return fail($e->getMessage()); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取指定孩子的服务记录 |
|||
* @return Response |
|||
*/ |
|||
public function getChildServices() |
|||
{ |
|||
$data = $this->request->params([ |
|||
['child_id', ''] |
|||
]); |
|||
|
|||
$this->validate($data, [ |
|||
'child_id' => 'require' |
|||
]); |
|||
|
|||
try { |
|||
$service = new ParentService(); |
|||
$result = $service->getChildServices($data['child_id']); |
|||
|
|||
return success($result, '获取孩子服务记录成功'); |
|||
|
|||
} catch (\Exception $e) { |
|||
return fail($e->getMessage()); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取指定孩子的消息记录 |
|||
* @return Response |
|||
*/ |
|||
public function getChildMessages() |
|||
{ |
|||
$data = $this->request->params([ |
|||
['child_id', ''] |
|||
]); |
|||
|
|||
$this->validate($data, [ |
|||
'child_id' => 'require' |
|||
]); |
|||
|
|||
try { |
|||
$service = new ParentService(); |
|||
$result = $service->getChildMessages($data['child_id']); |
|||
|
|||
return success($result, '获取孩子消息记录成功'); |
|||
|
|||
} catch (\Exception $e) { |
|||
return fail($e->getMessage()); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取指定孩子的合同信息 |
|||
* @return Response |
|||
*/ |
|||
public function getChildContracts() |
|||
{ |
|||
$data = $this->request->params([ |
|||
['child_id', ''] |
|||
]); |
|||
|
|||
$this->validate($data, [ |
|||
'child_id' => 'require' |
|||
]); |
|||
|
|||
try { |
|||
$service = new ParentService(); |
|||
$result = $service->getChildContracts($data['child_id']); |
|||
|
|||
return success($result, '获取孩子合同信息成功'); |
|||
|
|||
} catch (\Exception $e) { |
|||
return fail($e->getMessage()); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,170 @@ |
|||
<?php |
|||
// +---------------------------------------------------------------------- |
|||
// | Niucloud-admin 企业快速开发的多应用管理平台 |
|||
// +---------------------------------------------------------------------- |
|||
|
|||
namespace app\api\controller\student; |
|||
|
|||
use app\service\api\student\CourseBookingService; |
|||
use core\base\BaseController; |
|||
use think\Response; |
|||
|
|||
/** |
|||
* 课程预约控制器 |
|||
*/ |
|||
class CourseBookingController extends BaseController |
|||
{ |
|||
/** |
|||
* 获取可预约课程列表 |
|||
* @return Response |
|||
*/ |
|||
public function getAvailableCourses() |
|||
{ |
|||
$data = $this->request->params([ |
|||
['student_id', 0], |
|||
['date', ''], |
|||
['course_type', ''], |
|||
['page', 1], |
|||
['limit', 20] |
|||
]); |
|||
|
|||
$this->validate($data, [ |
|||
'student_id' => 'require|integer|gt:0', |
|||
'date' => 'date', |
|||
'page' => 'integer|egt:1', |
|||
'limit' => 'integer|between:1,50' |
|||
]); |
|||
|
|||
try { |
|||
$service = new CourseBookingService(); |
|||
$result = $service->getAvailableCourses($data); |
|||
|
|||
return success($result, '获取可预约课程成功'); |
|||
|
|||
} catch (\Exception $e) { |
|||
return fail($e->getMessage()); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 创建课程预约 |
|||
* @return Response |
|||
*/ |
|||
public function createBooking() |
|||
{ |
|||
$data = $this->request->params([ |
|||
['student_id', 0], |
|||
['schedule_id', 0], |
|||
['course_date', ''], |
|||
['time_slot', ''], |
|||
['remark', ''] |
|||
]); |
|||
|
|||
$this->validate($data, [ |
|||
'student_id' => 'require|integer|gt:0', |
|||
'schedule_id' => 'require|integer|gt:0', |
|||
'course_date' => 'require|date', |
|||
'time_slot' => 'require|length:1,50' |
|||
]); |
|||
|
|||
try { |
|||
$service = new CourseBookingService(); |
|||
$result = $service->createBooking($data); |
|||
|
|||
return success($result, '预约成功'); |
|||
|
|||
} catch (\Exception $e) { |
|||
return fail($e->getMessage()); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取我的预约列表 |
|||
* @return Response |
|||
*/ |
|||
public function getMyBookingList() |
|||
{ |
|||
$data = $this->request->params([ |
|||
['student_id', 0], |
|||
['status', ''], |
|||
['start_date', ''], |
|||
['end_date', ''], |
|||
['page', 1], |
|||
['limit', 20] |
|||
]); |
|||
|
|||
$this->validate($data, [ |
|||
'student_id' => 'require|integer|gt:0', |
|||
'start_date' => 'date', |
|||
'end_date' => 'date', |
|||
'page' => 'integer|egt:1', |
|||
'limit' => 'integer|between:1,50' |
|||
]); |
|||
|
|||
try { |
|||
$service = new CourseBookingService(); |
|||
$result = $service->getMyBookingList($data); |
|||
|
|||
return success($result, '获取预约列表成功'); |
|||
|
|||
} catch (\Exception $e) { |
|||
return fail($e->getMessage()); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 取消课程预约 |
|||
* @return Response |
|||
*/ |
|||
public function cancelBooking() |
|||
{ |
|||
$data = $this->request->params([ |
|||
['booking_id', 0], |
|||
['cancel_reason', ''] |
|||
]); |
|||
|
|||
$this->validate($data, [ |
|||
'booking_id' => 'require|integer|gt:0', |
|||
'cancel_reason' => 'length:0,255' |
|||
]); |
|||
|
|||
try { |
|||
$service = new CourseBookingService(); |
|||
$result = $service->cancelBooking($data); |
|||
|
|||
return success($result, '取消预约成功'); |
|||
|
|||
} catch (\Exception $e) { |
|||
return fail($e->getMessage()); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 检查预约冲突 |
|||
* @return Response |
|||
*/ |
|||
public function checkBookingConflict() |
|||
{ |
|||
$data = $this->request->params([ |
|||
['student_id', 0], |
|||
['course_date', ''], |
|||
['time_slot', ''] |
|||
]); |
|||
|
|||
$this->validate($data, [ |
|||
'student_id' => 'require|integer|gt:0', |
|||
'course_date' => 'require|date', |
|||
'time_slot' => 'require|length:1,50' |
|||
]); |
|||
|
|||
try { |
|||
$service = new CourseBookingService(); |
|||
$result = $service->checkBookingConflict($data); |
|||
|
|||
return success($result, '检查完成'); |
|||
|
|||
} catch (\Exception $e) { |
|||
return fail($e->getMessage()); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,122 @@ |
|||
<?php |
|||
// +---------------------------------------------------------------------- |
|||
// | Niucloud-admin 企业快速开发的多应用管理平台 |
|||
// +---------------------------------------------------------------------- |
|||
|
|||
namespace app\api\controller\student; |
|||
|
|||
use app\service\api\student\PhysicalTestService; |
|||
use core\base\BaseController; |
|||
use think\Response; |
|||
|
|||
/** |
|||
* 体测数据控制器 |
|||
*/ |
|||
class PhysicalTestController extends BaseController |
|||
{ |
|||
/** |
|||
* 获取学员体测记录列表 |
|||
* @return Response |
|||
*/ |
|||
public function getPhysicalTestList() |
|||
{ |
|||
$data = $this->request->params([ |
|||
['student_id', 0], |
|||
['page', 1], |
|||
['limit', 20] |
|||
]); |
|||
|
|||
$this->validate($data, [ |
|||
'student_id' => 'require|integer|gt:0', |
|||
'page' => 'integer|egt:1', |
|||
'limit' => 'integer|between:1,50' |
|||
]); |
|||
|
|||
try { |
|||
$service = new PhysicalTestService(); |
|||
$result = $service->getPhysicalTestList($data); |
|||
|
|||
return success($result, '获取体测记录成功'); |
|||
|
|||
} catch (\Exception $e) { |
|||
return fail($e->getMessage()); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取体测详情 |
|||
* @return Response |
|||
*/ |
|||
public function getPhysicalTestDetail() |
|||
{ |
|||
$data = $this->request->params([ |
|||
['test_id', 0] |
|||
]); |
|||
|
|||
$this->validate($data, [ |
|||
'test_id' => 'require|integer|gt:0' |
|||
]); |
|||
|
|||
try { |
|||
$service = new PhysicalTestService(); |
|||
$result = $service->getPhysicalTestDetail($data['test_id']); |
|||
|
|||
return success($result, '获取体测详情成功'); |
|||
|
|||
} catch (\Exception $e) { |
|||
return fail($e->getMessage()); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取学员体测趋势数据 |
|||
* @return Response |
|||
*/ |
|||
public function getPhysicalTestTrend() |
|||
{ |
|||
$data = $this->request->params([ |
|||
['student_id', 0], |
|||
['months', 12] // 默认获取12个月的数据 |
|||
]); |
|||
|
|||
$this->validate($data, [ |
|||
'student_id' => 'require|integer|gt:0', |
|||
'months' => 'integer|between:3,24' |
|||
]); |
|||
|
|||
try { |
|||
$service = new PhysicalTestService(); |
|||
$result = $service->getPhysicalTestTrend($data); |
|||
|
|||
return success($result, '获取体测趋势成功'); |
|||
|
|||
} catch (\Exception $e) { |
|||
return fail($e->getMessage()); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* PDF转图片分享 |
|||
* @return Response |
|||
*/ |
|||
public function sharePhysicalTestPdf() |
|||
{ |
|||
$data = $this->request->params([ |
|||
['test_id', 0] |
|||
]); |
|||
|
|||
$this->validate($data, [ |
|||
'test_id' => 'require|integer|gt:0' |
|||
]); |
|||
|
|||
try { |
|||
$service = new PhysicalTestService(); |
|||
$result = $service->convertPdfToImage($data['test_id']); |
|||
|
|||
return success($result, 'PDF转换成功'); |
|||
|
|||
} catch (\Exception $e) { |
|||
return fail($e->getMessage()); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,192 @@ |
|||
<?php |
|||
// +---------------------------------------------------------------------- |
|||
// | Niucloud-admin 企业快速开发的多应用管理平台 |
|||
// +---------------------------------------------------------------------- |
|||
// | 官方网址:https://www.niucloud.com |
|||
// +---------------------------------------------------------------------- |
|||
// | niucloud团队 版权所有 开源版本可自由商用 |
|||
// +---------------------------------------------------------------------- |
|||
|
|||
namespace app\api\controller\student; |
|||
|
|||
use app\service\api\student\StudentService; |
|||
use core\base\BaseController; |
|||
use think\Response; |
|||
|
|||
/** |
|||
* 学员信息管理控制器 |
|||
* 用于学员端访问学员相关信息 |
|||
*/ |
|||
class StudentController extends BaseController |
|||
{ |
|||
/** |
|||
* 获取当前用户的学员列表(支持多孩子) |
|||
* @return Response |
|||
*/ |
|||
public function getStudentList() |
|||
{ |
|||
try { |
|||
$service = new StudentService(); |
|||
$result = $service->getStudentList(); |
|||
|
|||
return success($result, '获取学员列表成功'); |
|||
|
|||
} catch (\Exception $e) { |
|||
return fail($e->getMessage()); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 测试方法 - 直接获取用户64的学员数据 |
|||
* @return Response |
|||
*/ |
|||
public function testStudentList() |
|||
{ |
|||
try { |
|||
// 直接查询数据库获取用户64的学员数据 |
|||
$studentList = \think\facade\Db::table('school_student') |
|||
->where('user_id', 64) |
|||
->where('deleted_at', 0) |
|||
->field('id,name,gender,birthday,headimg,create_time') |
|||
->order('create_time desc') |
|||
->select() |
|||
->toArray(); |
|||
|
|||
// 计算年龄和格式化数据 |
|||
foreach ($studentList as &$student) { |
|||
$age = 0; |
|||
if ($student['birthday']) { |
|||
$birthTime = strtotime($student['birthday']); |
|||
if ($birthTime) { |
|||
$age = date('Y') - date('Y', $birthTime); |
|||
if (date('md') < date('md', $birthTime)) { |
|||
$age--; |
|||
} |
|||
} |
|||
} |
|||
$student['age'] = max(0, $age); |
|||
$student['gender_text'] = $student['gender'] == 1 ? '男' : '女'; |
|||
$student['headimg'] = $student['headimg'] ? get_image_url($student['headimg']) : ''; |
|||
} |
|||
|
|||
return success([ |
|||
'list' => $studentList, |
|||
'total' => count($studentList) |
|||
], '获取学员列表成功'); |
|||
|
|||
} catch (\Exception $e) { |
|||
return fail($e->getMessage()); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取学员概览信息(用于首页落地页) |
|||
* @return Response |
|||
*/ |
|||
public function getStudentSummary() |
|||
{ |
|||
$data = $this->request->params([ |
|||
['student_id', 0] |
|||
]); |
|||
|
|||
$this->validate($data, [ |
|||
'student_id' => 'require|integer|gt:0' |
|||
]); |
|||
|
|||
try { |
|||
$service = new StudentService(); |
|||
$result = $service->getStudentSummary($data['student_id']); |
|||
|
|||
return success($result, '获取学员概览成功'); |
|||
|
|||
} catch (\Exception $e) { |
|||
return fail($e->getMessage()); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取学员详细信息(包含体测信息) |
|||
* @return Response |
|||
*/ |
|||
public function getStudentInfo() |
|||
{ |
|||
$data = $this->request->params([ |
|||
['student_id', 0] |
|||
]); |
|||
|
|||
$this->validate($data, [ |
|||
'student_id' => 'require|integer|gt:0' |
|||
]); |
|||
|
|||
try { |
|||
$service = new StudentService(); |
|||
$result = $service->getStudentInfoWithPhysicalTest($data['student_id']); |
|||
|
|||
return success($result, '获取学员信息成功'); |
|||
|
|||
} catch (\Exception $e) { |
|||
return fail($e->getMessage()); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 更新学员基本信息 |
|||
* @return Response |
|||
*/ |
|||
public function updateStudentInfo() |
|||
{ |
|||
$data = $this->request->params([ |
|||
['student_id', 0], |
|||
['name', ''], |
|||
['gender', ''], |
|||
['birthday', ''], |
|||
['headimg', ''], |
|||
['emergency_contact', ''], |
|||
['contact_phone', ''], |
|||
['note', ''] |
|||
]); |
|||
|
|||
$this->validate($data, [ |
|||
'student_id' => 'require|integer|gt:0', |
|||
'name' => 'require|length:2,20', |
|||
'gender' => 'in:1,2', |
|||
'birthday' => 'date', |
|||
'contact_phone' => 'mobile' |
|||
]); |
|||
|
|||
try { |
|||
$service = new StudentService(); |
|||
$result = $service->updateStudentInfo($data); |
|||
|
|||
return success($result, '更新学员信息成功'); |
|||
|
|||
} catch (\Exception $e) { |
|||
return fail($e->getMessage()); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 上传学员头像 |
|||
* @return Response |
|||
*/ |
|||
public function uploadAvatar() |
|||
{ |
|||
$data = $this->request->params([ |
|||
['student_id', 0] |
|||
]); |
|||
|
|||
$this->validate($data, [ |
|||
'student_id' => 'require|integer|gt:0' |
|||
]); |
|||
|
|||
try { |
|||
$service = new StudentService(); |
|||
$result = $service->uploadAvatar($data['student_id']); |
|||
|
|||
return success($result, '头像上传成功'); |
|||
|
|||
} catch (\Exception $e) { |
|||
return fail($e->getMessage()); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,127 @@ |
|||
<?php |
|||
// +---------------------------------------------------------------------- |
|||
// | 学员端API路由配置 |
|||
// +---------------------------------------------------------------------- |
|||
|
|||
use think\facade\Route; |
|||
|
|||
// 学员信息管理 |
|||
Route::group('student', function () { |
|||
// 获取学员列表 |
|||
Route::get('mychild', 'app\api\controller\student\StudentController@getStudentList'); |
|||
// 测试获取学员列表(无认证) |
|||
Route::get('test-list', 'app\api\controller\student\StudentController@testStudentList'); |
|||
// 获取学员概览信息(首页用) |
|||
Route::get('summary/:student_id', 'app\api\controller\student\StudentController@getStudentSummary'); |
|||
// 获取学员详细信息 |
|||
Route::get('info/:student_id', 'app\api\controller\student\StudentController@getStudentInfo'); |
|||
// 更新学员信息 |
|||
Route::put('update', 'app\api\controller\student\StudentController@updateStudentInfo'); |
|||
// 上传头像 |
|||
Route::post('avatar', 'app\api\controller\student\StudentController@uploadAvatar'); |
|||
|
|||
//家长端接口 |
|||
Route::get('parent/children', 'parent.ParentController/getChildrenList'); |
|||
Route::get('parent/child/info', 'parent.ParentController/getChildInfo'); |
|||
Route::post('parent/child/add', 'parent.ParentController/addChild'); |
|||
Route::post('parent/child/update', 'parent.ParentController/updateChildInfo'); |
|||
Route::get('parent/child/courses', 'parent.ParentController/getChildCourses'); |
|||
Route::get('parent/child/orders', 'parent.ParentController/getChildOrders'); |
|||
Route::get('parent/child/services', 'parent.ParentController/getChildServices'); |
|||
Route::get('parent/child/messages', 'parent.ParentController/getChildMessages'); |
|||
Route::get('parent/child/contracts', 'parent.ParentController/getChildContracts'); |
|||
})->middleware(['ApiCheckToken']); |
|||
|
|||
// 体测数据管理 |
|||
Route::group('physical-test', function () { |
|||
// 获取体测记录列表 |
|||
Route::get('list', 'student.PhysicalTestController@getPhysicalTestList'); |
|||
// 获取体测详情 |
|||
Route::get('detail/:test_id', 'student.PhysicalTestController@getPhysicalTestDetail'); |
|||
// 获取体测趋势数据 |
|||
Route::get('trend', 'student.PhysicalTestController@getPhysicalTestTrend'); |
|||
// PDF转图片分享 |
|||
Route::post('share/:test_id', 'student.PhysicalTestController@sharePhysicalTestPdf'); |
|||
})->middleware(['ApiCheckToken']); |
|||
|
|||
// 课程预约管理 |
|||
Route::group('course-booking', function () { |
|||
// 获取可预约课程 |
|||
Route::get('available', 'student.CourseBookingController@getAvailableCourses'); |
|||
// 创建预约 |
|||
Route::post('create', 'student.CourseBookingController@createBooking'); |
|||
// 我的预约列表 |
|||
Route::get('my-list', 'student.CourseBookingController@getMyBookingList'); |
|||
// 取消预约 |
|||
Route::put('cancel', 'student.CourseBookingController@cancelBooking'); |
|||
// 检查预约冲突 |
|||
Route::post('check-conflict', 'student.CourseBookingController@checkBookingConflict'); |
|||
})->middleware(['ApiCheckToken']); |
|||
|
|||
// 课程安排查看 |
|||
Route::group('course-schedule', function () { |
|||
// 获取课程安排列表 |
|||
Route::get('list', 'student.CourseScheduleController@getCourseScheduleList'); |
|||
// 获取课程详情 |
|||
Route::get('detail/:schedule_id', 'student.CourseScheduleController@getCourseScheduleDetail'); |
|||
})->middleware(['ApiCheckToken']); |
|||
|
|||
// 订单管理 |
|||
Route::group('order', function () { |
|||
// 获取订单列表 |
|||
Route::get('list', 'student.OrderController@getOrderList'); |
|||
// 获取订单详情 |
|||
Route::get('detail/:order_id', 'student.OrderController@getOrderDetail'); |
|||
})->middleware(['ApiCheckToken']); |
|||
|
|||
// 支付管理 |
|||
Route::group('payment', function () { |
|||
// 创建支付 |
|||
Route::post('create', 'student.PaymentController@createPayment'); |
|||
// 查询支付状态 |
|||
Route::get('status/:order_id', 'student.PaymentController@getPaymentStatus'); |
|||
// 支付回调 |
|||
Route::post('callback', 'student.PaymentController@paymentCallback'); |
|||
})->middleware(['ApiCheckToken']); |
|||
|
|||
// 合同管理 |
|||
Route::group('contract', function () { |
|||
// 获取合同列表 |
|||
Route::get('list', 'student.ContractController@getContractList'); |
|||
// 获取合同详情 |
|||
Route::get('detail/:contract_id', 'student.ContractController@getContractDetail'); |
|||
// 提交合同签署 |
|||
Route::post('sign', 'student.ContractController@signContract'); |
|||
// 下载合同 |
|||
Route::get('download/:contract_id', 'student.ContractController@downloadContract'); |
|||
})->middleware(['ApiCheckToken']); |
|||
|
|||
// 知识库 |
|||
Route::group('knowledge', function () { |
|||
// 获取知识内容列表 |
|||
Route::get('list', 'student.KnowledgeController@getKnowledgeList'); |
|||
// 获取内容详情 |
|||
Route::get('detail/:knowledge_id', 'student.KnowledgeController@getKnowledgeDetail'); |
|||
// 获取分类列表 |
|||
Route::get('categories', 'student.KnowledgeController@getKnowledgeCategories'); |
|||
})->middleware(['ApiCheckToken']); |
|||
|
|||
// 消息管理 |
|||
Route::group('message', function () { |
|||
// 获取消息列表 |
|||
Route::get('list', 'student.MessageController@getMessageList'); |
|||
// 获取消息详情 |
|||
Route::get('detail/:message_id', 'student.MessageController@getMessageDetail'); |
|||
// 标记已读 |
|||
Route::put('read/:message_id', 'student.MessageController@markAsRead'); |
|||
// 批量标记已读 |
|||
Route::put('read-batch', 'student.MessageController@markBatchAsRead'); |
|||
})->middleware(['ApiCheckToken']); |
|||
|
|||
// 学员登录相关(无需token验证) |
|||
Route::group('auth', function () { |
|||
Route::post('login/wechat', 'login.WechatLogin/login'); |
|||
Route::post('wechat/bind', 'login.WechatLogin/bind'); |
|||
Route::get('wechat/auth_url', 'login.WechatLogin/getAuthUrl'); |
|||
Route::get('wechat/callback', 'login.WechatLogin/callback'); |
|||
}); |
|||
@ -0,0 +1,173 @@ |
|||
<?php |
|||
// +---------------------------------------------------------------------- |
|||
// | Niucloud-admin 企业快速开发的多应用管理平台 |
|||
// +---------------------------------------------------------------------- |
|||
// | 官方网址:https://www.niucloud.com |
|||
// +---------------------------------------------------------------------- |
|||
// | niucloud团队 版权所有 开源版本可自由商用 |
|||
// +---------------------------------------------------------------------- |
|||
// | Author: Niucloud Team |
|||
// +---------------------------------------------------------------------- |
|||
|
|||
namespace app\service\api\login; |
|||
|
|||
use app\model\sys\SysConfig; |
|||
use core\base\BaseService; |
|||
use core\exception\CommonException; |
|||
use think\facade\Cache; |
|||
|
|||
/** |
|||
* 微信服务类 |
|||
*/ |
|||
class WechatService extends BaseService |
|||
{ |
|||
/** |
|||
* 获取微信公众号配置 |
|||
* @return array |
|||
* @throws \Exception |
|||
*/ |
|||
private function getWechatConfig() |
|||
{ |
|||
$config = new SysConfig(); |
|||
$wechatConfig = $config->where('config_key', 'WECHAT')->find(); |
|||
|
|||
if (!$wechatConfig || empty($wechatConfig['value'])) { |
|||
throw new CommonException('微信公众号未配置'); |
|||
} |
|||
|
|||
$configData = $wechatConfig['value']; |
|||
|
|||
// 如果是字符串,则解析JSON |
|||
if (is_string($configData)) { |
|||
$configData = json_decode($configData, true); |
|||
} |
|||
|
|||
// 如果解析失败或不是数组,抛出异常 |
|||
if (!is_array($configData) || empty($configData['app_id']) || empty($configData['app_secret'])) { |
|||
throw new CommonException('微信公众号配置不完整'); |
|||
} |
|||
|
|||
return $configData; |
|||
} |
|||
|
|||
/** |
|||
* 获取微信公众号授权URL |
|||
* @param string $miniOpenid |
|||
* @return array |
|||
* @throws \Exception |
|||
*/ |
|||
public function getAuthUrl(string $miniOpenid) |
|||
{ |
|||
$config = $this->getWechatConfig(); |
|||
|
|||
// 生成state参数,用于传递小程序openid |
|||
$state = base64_encode($miniOpenid); |
|||
|
|||
// 获取当前域名 |
|||
$domain = request()->domain(); |
|||
$redirectUri = $domain . '/api/wechat/callback'; |
|||
|
|||
// 构建微信授权URL |
|||
$authUrl = 'https://open.weixin.qq.com/connect/oauth2/authorize?' . http_build_query([ |
|||
'appid' => $config['app_id'], |
|||
'redirect_uri' => urlencode($redirectUri), |
|||
'response_type' => 'code', |
|||
'scope' => 'snsapi_userinfo', |
|||
'state' => $state |
|||
]) . '#wechat_redirect'; |
|||
|
|||
return [ |
|||
'auth_url' => $authUrl, |
|||
'redirect_uri' => $redirectUri |
|||
]; |
|||
} |
|||
|
|||
/** |
|||
* 处理微信授权回调 |
|||
* @param string $code |
|||
* @param string $state |
|||
* @return array |
|||
* @throws \Exception |
|||
*/ |
|||
public function handleCallback(string $code, string $state) |
|||
{ |
|||
$config = $this->getWechatConfig(); |
|||
|
|||
// 解析state获取小程序openid |
|||
$miniOpenid = base64_decode($state); |
|||
if (empty($miniOpenid)) { |
|||
throw new CommonException('参数错误'); |
|||
} |
|||
|
|||
// 通过code获取access_token |
|||
$tokenUrl = 'https://api.weixin.qq.com/sns/oauth2/access_token?' . http_build_query([ |
|||
'appid' => $config['app_id'], |
|||
'secret' => $config['app_secret'], |
|||
'code' => $code, |
|||
'grant_type' => 'authorization_code' |
|||
]); |
|||
|
|||
$tokenResponse = $this->httpGet($tokenUrl); |
|||
$tokenData = json_decode($tokenResponse, true); |
|||
|
|||
if (!$tokenData || isset($tokenData['errcode'])) { |
|||
throw new CommonException('获取微信授权失败:' . ($tokenData['errmsg'] ?? '未知错误')); |
|||
} |
|||
|
|||
$accessToken = $tokenData['access_token']; |
|||
$wechatOpenid = $tokenData['openid']; |
|||
|
|||
// 获取用户信息 |
|||
$userInfoUrl = 'https://api.weixin.qq.com/sns/userinfo?' . http_build_query([ |
|||
'access_token' => $accessToken, |
|||
'openid' => $wechatOpenid, |
|||
'lang' => 'zh_CN' |
|||
]); |
|||
|
|||
$userInfoResponse = $this->httpGet($userInfoUrl); |
|||
$userInfo = json_decode($userInfoResponse, true); |
|||
|
|||
if (!$userInfo || isset($userInfo['errcode'])) { |
|||
throw new CommonException('获取微信用户信息失败:' . ($userInfo['errmsg'] ?? '未知错误')); |
|||
} |
|||
|
|||
return [ |
|||
'mini_openid' => $miniOpenid, |
|||
'wechat_openid' => $wechatOpenid, |
|||
'nickname' => $userInfo['nickname'] ?? '', |
|||
'avatar' => $userInfo['headimgurl'] ?? '', |
|||
'sex' => $userInfo['sex'] ?? 0 |
|||
]; |
|||
} |
|||
|
|||
/** |
|||
* HTTP GET请求 |
|||
* @param string $url |
|||
* @return string |
|||
* @throws \Exception |
|||
*/ |
|||
private function httpGet(string $url) |
|||
{ |
|||
$ch = curl_init(); |
|||
curl_setopt($ch, CURLOPT_URL, $url); |
|||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); |
|||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); |
|||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); |
|||
curl_setopt($ch, CURLOPT_TIMEOUT, 30); |
|||
|
|||
$response = curl_exec($ch); |
|||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); |
|||
$error = curl_error($ch); |
|||
curl_close($ch); |
|||
|
|||
if ($response === false || !empty($error)) { |
|||
throw new CommonException('网络请求失败:' . $error); |
|||
} |
|||
|
|||
if ($httpCode !== 200) { |
|||
throw new CommonException('网络请求失败,HTTP状态码:' . $httpCode); |
|||
} |
|||
|
|||
return $response; |
|||
} |
|||
} |
|||
@ -0,0 +1,454 @@ |
|||
<?php |
|||
// +---------------------------------------------------------------------- |
|||
// | Niucloud-admin 企业快速开发的多应用管理平台 |
|||
// +---------------------------------------------------------------------- |
|||
// | 官方网址:https://www.niucloud.com |
|||
// +---------------------------------------------------------------------- |
|||
// | niucloud团队 版权所有 开源版本可自由商用 |
|||
// +---------------------------------------------------------------------- |
|||
// | Author: Niucloud Team |
|||
// +---------------------------------------------------------------------- |
|||
|
|||
namespace app\service\api\parent; |
|||
|
|||
use app\model\customer_resources\CustomerResources; |
|||
use core\base\BaseService; |
|||
use core\exception\CommonException; |
|||
use core\util\TokenAuth; |
|||
use think\facade\Db; |
|||
|
|||
/** |
|||
* 家长端服务类 |
|||
*/ |
|||
class ParentService extends BaseService |
|||
{ |
|||
/** |
|||
* 获取当前登录用户信息 |
|||
* @return array |
|||
* @throws \Exception |
|||
*/ |
|||
private function getCurrentUser() |
|||
{ |
|||
$token = request()->header('token'); |
|||
if (!$token) { |
|||
throw new CommonException('未登录'); |
|||
} |
|||
|
|||
try { |
|||
$tokenData = TokenAuth::parseToken($token, 'api'); |
|||
if (empty($tokenData)) { |
|||
throw new CommonException('Token无效'); |
|||
} |
|||
} catch (\Exception $e) { |
|||
throw new CommonException('Token解析失败: ' . $e->getMessage()); |
|||
} |
|||
|
|||
// 获取客户资源信息 |
|||
$customerResources = new CustomerResources(); |
|||
$userInfo = $customerResources->where('id', $tokenData['user_id'])->find(); |
|||
|
|||
if (!$userInfo) { |
|||
throw new CommonException('用户信息不存在'); |
|||
} |
|||
|
|||
return $userInfo; |
|||
} |
|||
|
|||
/** |
|||
* 获取家长下的孩子列表 |
|||
* @return array |
|||
* @throws \Exception |
|||
*/ |
|||
public function getChildrenList() |
|||
{ |
|||
$currentUser = $this->getCurrentUser(); |
|||
|
|||
// 查询该家长下的所有孩子 |
|||
$children = Db::table('school_student') |
|||
->alias('s') |
|||
->leftJoin('school_campus ca', 's.campus_id = ca.id') |
|||
->leftJoin('school_class cl', 's.class_id = cl.id') |
|||
->where('s.user_id', $currentUser['id']) |
|||
->where('s.deleted_at', 0) |
|||
->field('s.*, ca.campus_name, cl.class_name') |
|||
->select() |
|||
->toArray(); |
|||
|
|||
// 计算每个孩子的课程统计信息 |
|||
foreach ($children as &$child) { |
|||
// 计算年龄 |
|||
if ($child['birthday']) { |
|||
$birthDate = new \DateTime($child['birthday']); |
|||
$today = new \DateTime(); |
|||
$age = $today->diff($birthDate)->y; |
|||
$child['age'] = $age; |
|||
} |
|||
|
|||
// 获取课程统计信息 |
|||
$courseStats = $this->getChildCourseStats($child['id']); |
|||
$child = array_merge($child, $courseStats); |
|||
} |
|||
|
|||
return [ |
|||
'data' => $children, |
|||
'parent_info' => [ |
|||
'id' => $currentUser['id'], |
|||
'name' => $currentUser['name'], |
|||
'phone_number' => $currentUser['phone_number'] |
|||
] |
|||
]; |
|||
} |
|||
|
|||
/** |
|||
* 获取孩子的课程统计信息 |
|||
* @param int $childId |
|||
* @return array |
|||
*/ |
|||
private function getChildCourseStats($childId) |
|||
{ |
|||
// 获取该学员的所有课程 |
|||
$courses = Db::table('school_student_courses') |
|||
->where('student_id', $childId) |
|||
->select() |
|||
->toArray(); |
|||
|
|||
$totalCourses = 0; |
|||
$remainingCourses = 0; |
|||
$completedCourses = 0; |
|||
|
|||
foreach ($courses as $course) { |
|||
$totalCourses += $course['course_hours'] ?? 0; |
|||
$remainingCourses += $course['remaining_hours'] ?? 0; |
|||
} |
|||
|
|||
$completedCourses = $totalCourses - $remainingCourses; |
|||
|
|||
// 计算出勤率 |
|||
$attendanceRate = 0; |
|||
if ($completedCourses > 0) { |
|||
$presentCount = Db::table('school_student_course_usage') |
|||
->where('student_id', $childId) |
|||
->where('status', 'present') |
|||
->count(); |
|||
$attendanceRate = round(($presentCount / $completedCourses) * 100, 1); |
|||
} |
|||
|
|||
return [ |
|||
'total_courses' => $totalCourses, |
|||
'remaining_courses' => $remainingCourses, |
|||
'completed_courses' => $completedCourses, |
|||
'attendance_rate' => $attendanceRate |
|||
]; |
|||
} |
|||
|
|||
/** |
|||
* 获取指定孩子的详细信息 |
|||
* @param int $childId |
|||
* @return array |
|||
* @throws \Exception |
|||
*/ |
|||
public function getChildInfo($childId) |
|||
{ |
|||
$currentUser = $this->getCurrentUser(); |
|||
|
|||
$student = new Student(); |
|||
$childInfo = $student->alias('s') |
|||
->leftJoin('school_campus ca', 's.campus_id = ca.id') |
|||
->leftJoin('school_classes cl', 's.class_id = cl.id') |
|||
->where('s.id', $childId) |
|||
->where('s.user_id', $currentUser['id']) |
|||
->where('s.deleted_at', 0) |
|||
->field('s.*, ca.name as campus_name, cl.name as class_name') |
|||
->find(); |
|||
|
|||
if (!$childInfo) { |
|||
throw new CommonException('孩子信息不存在'); |
|||
} |
|||
|
|||
// 计算年龄 |
|||
if ($childInfo['birthday']) { |
|||
$birthDate = new \DateTime($childInfo['birthday']); |
|||
$today = new \DateTime(); |
|||
$age = $today->diff($birthDate)->y; |
|||
$childInfo['age'] = $age; |
|||
} |
|||
|
|||
// 获取课程统计信息 |
|||
$courseStats = $this->getChildCourseStats($childId); |
|||
$childInfo = array_merge($childInfo->toArray(), $courseStats); |
|||
|
|||
return $childInfo; |
|||
} |
|||
|
|||
/** |
|||
* 新增孩子信息 |
|||
* @param array $data |
|||
* @return array |
|||
* @throws \Exception |
|||
*/ |
|||
public function addChild($data) |
|||
{ |
|||
$currentUser = $this->getCurrentUser(); |
|||
|
|||
// 计算年龄 |
|||
if ($data['birthday']) { |
|||
$birthDate = new \DateTime($data['birthday']); |
|||
$today = new \DateTime(); |
|||
$age = $today->diff($birthDate)->y; |
|||
$data['age'] = $age; |
|||
} |
|||
|
|||
$childData = [ |
|||
'name' => $data['name'], |
|||
'gender' => $data['gender'], |
|||
'age' => $data['age'], |
|||
'birthday' => $data['birthday'], |
|||
'user_id' => $currentUser['id'], |
|||
'campus_id' => 0, // 默认未分配校区 |
|||
'class_id' => 0, // 默认未分配班级 |
|||
'note' => $data['remark'] ?? '', |
|||
'status' => 1, // 默认正常状态 |
|||
'created_at' => date('Y-m-d H:i:s'), |
|||
'updated_at' => date('Y-m-d H:i:s') |
|||
]; |
|||
|
|||
$result = Db::table('school_student')->insertGetId($childData); |
|||
|
|||
if (!$result) { |
|||
throw new CommonException('新增孩子信息失败'); |
|||
} |
|||
|
|||
return [ |
|||
'id' => $result, |
|||
'name' => $data['name'], |
|||
'message' => '新增孩子信息成功' |
|||
]; |
|||
} |
|||
|
|||
/** |
|||
* 更新孩子信息 |
|||
* @param array $data |
|||
* @return array |
|||
* @throws \Exception |
|||
*/ |
|||
public function updateChildInfo($data) |
|||
{ |
|||
$currentUser = $this->getCurrentUser(); |
|||
|
|||
$student = new Student(); |
|||
$childInfo = $student->where('id', $data['child_id']) |
|||
->where('user_id', $currentUser['id']) |
|||
->where('deleted_at', 0) |
|||
->find(); |
|||
|
|||
if (!$childInfo) { |
|||
throw new CommonException('孩子信息不存在'); |
|||
} |
|||
|
|||
// 计算年龄 |
|||
if ($data['birthday']) { |
|||
$birthDate = new \DateTime($data['birthday']); |
|||
$today = new \DateTime(); |
|||
$age = $today->diff($birthDate)->y; |
|||
$data['age'] = $age; |
|||
} |
|||
|
|||
$updateData = [ |
|||
'name' => $data['name'], |
|||
'gender' => $data['gender'], |
|||
'age' => $data['age'], |
|||
'birthday' => $data['birthday'], |
|||
'note' => $data['remark'] ?? '', |
|||
'updated_at' => date('Y-m-d H:i:s') |
|||
]; |
|||
|
|||
$result = $childInfo->save($updateData); |
|||
|
|||
if (!$result) { |
|||
throw new CommonException('更新孩子信息失败'); |
|||
} |
|||
|
|||
return [ |
|||
'message' => '更新孩子信息成功' |
|||
]; |
|||
} |
|||
|
|||
/** |
|||
* 获取指定孩子的课程信息 |
|||
* @param int $childId |
|||
* @return array |
|||
* @throws \Exception |
|||
*/ |
|||
public function getChildCourses($childId) |
|||
{ |
|||
$currentUser = $this->getCurrentUser(); |
|||
|
|||
// 验证孩子是否属于当前用户 |
|||
$student = new Student(); |
|||
$childInfo = $student->where('id', $childId) |
|||
->where('user_id', $currentUser['id']) |
|||
->where('deleted_at', 0) |
|||
->find(); |
|||
|
|||
if (!$childInfo) { |
|||
throw new CommonException('孩子信息不存在'); |
|||
} |
|||
|
|||
// 获取课程信息 |
|||
$studentCourses = new StudentCourses(); |
|||
$courses = $studentCourses->alias('sc') |
|||
->leftJoin('school_course c', 'sc.course_id = c.id') |
|||
->where('sc.student_id', $childId) |
|||
->field('sc.*, c.name as course_name, c.description') |
|||
->select() |
|||
->toArray(); |
|||
|
|||
return [ |
|||
'data' => $courses, |
|||
'child_info' => [ |
|||
'id' => $childInfo['id'], |
|||
'name' => $childInfo['name'] |
|||
] |
|||
]; |
|||
} |
|||
|
|||
/** |
|||
* 获取指定孩子的订单信息 |
|||
* @param int $childId |
|||
* @return array |
|||
* @throws \Exception |
|||
*/ |
|||
public function getChildOrders($childId) |
|||
{ |
|||
$currentUser = $this->getCurrentUser(); |
|||
|
|||
// 验证孩子是否属于当前用户 |
|||
$student = new Student(); |
|||
$childInfo = $student->where('id', $childId) |
|||
->where('user_id', $currentUser['id']) |
|||
->where('deleted_at', 0) |
|||
->find(); |
|||
|
|||
if (!$childInfo) { |
|||
throw new CommonException('孩子信息不存在'); |
|||
} |
|||
|
|||
// 获取订单信息 |
|||
$orderTable = new OrderTable(); |
|||
$orders = $orderTable->where('customer_resources_id', $currentUser['id']) |
|||
->where('student_id', $childId) |
|||
->order('created_at DESC') |
|||
->select() |
|||
->toArray(); |
|||
|
|||
return [ |
|||
'data' => $orders, |
|||
'child_info' => [ |
|||
'id' => $childInfo['id'], |
|||
'name' => $childInfo['name'] |
|||
] |
|||
]; |
|||
} |
|||
|
|||
/** |
|||
* 获取指定孩子的服务记录 |
|||
* @param int $childId |
|||
* @return array |
|||
* @throws \Exception |
|||
*/ |
|||
public function getChildServices($childId) |
|||
{ |
|||
$currentUser = $this->getCurrentUser(); |
|||
|
|||
// 验证孩子是否属于当前用户 |
|||
$student = new Student(); |
|||
$childInfo = $student->where('id', $childId) |
|||
->where('user_id', $currentUser['id']) |
|||
->where('deleted_at', 0) |
|||
->find(); |
|||
|
|||
if (!$childInfo) { |
|||
throw new CommonException('孩子信息不存在'); |
|||
} |
|||
|
|||
// TODO: 这里需要根据实际的服务记录表结构来实现 |
|||
// 暂时返回空数据 |
|||
return [ |
|||
'data' => [], |
|||
'child_info' => [ |
|||
'id' => $childInfo['id'], |
|||
'name' => $childInfo['name'] |
|||
] |
|||
]; |
|||
} |
|||
|
|||
/** |
|||
* 获取指定孩子的消息记录 |
|||
* @param int $childId |
|||
* @return array |
|||
* @throws \Exception |
|||
*/ |
|||
public function getChildMessages($childId) |
|||
{ |
|||
$currentUser = $this->getCurrentUser(); |
|||
|
|||
// 验证孩子是否属于当前用户 |
|||
$student = new Student(); |
|||
$childInfo = $student->where('id', $childId) |
|||
->where('user_id', $currentUser['id']) |
|||
->where('deleted_at', 0) |
|||
->find(); |
|||
|
|||
if (!$childInfo) { |
|||
throw new CommonException('孩子信息不存在'); |
|||
} |
|||
|
|||
// 获取消息记录 |
|||
$chatMessages = new ChatMessages(); |
|||
$messages = $chatMessages->where('to_customer_id', $currentUser['id']) |
|||
->order('created_at DESC') |
|||
->limit(50) |
|||
->select() |
|||
->toArray(); |
|||
|
|||
return [ |
|||
'data' => $messages, |
|||
'child_info' => [ |
|||
'id' => $childInfo['id'], |
|||
'name' => $childInfo['name'] |
|||
] |
|||
]; |
|||
} |
|||
|
|||
/** |
|||
* 获取指定孩子的合同信息 |
|||
* @param int $childId |
|||
* @return array |
|||
* @throws \Exception |
|||
*/ |
|||
public function getChildContracts($childId) |
|||
{ |
|||
$currentUser = $this->getCurrentUser(); |
|||
|
|||
// 验证孩子是否属于当前用户 |
|||
$student = new Student(); |
|||
$childInfo = $student->where('id', $childId) |
|||
->where('user_id', $currentUser['id']) |
|||
->where('deleted_at', 0) |
|||
->find(); |
|||
|
|||
if (!$childInfo) { |
|||
throw new CommonException('孩子信息不存在'); |
|||
} |
|||
|
|||
// TODO: 这里需要根据实际的合同表结构来实现 |
|||
// 暂时返回空数据 |
|||
return [ |
|||
'data' => [], |
|||
'child_info' => [ |
|||
'id' => $childInfo['id'], |
|||
'name' => $childInfo['name'] |
|||
] |
|||
]; |
|||
} |
|||
} |
|||
@ -0,0 +1,322 @@ |
|||
<?php |
|||
// +---------------------------------------------------------------------- |
|||
// | Niucloud-admin 企业快速开发的多应用管理平台 |
|||
// +---------------------------------------------------------------------- |
|||
|
|||
namespace app\service\api\student; |
|||
|
|||
use app\model\physical_test\PhysicalTest; |
|||
use app\model\customer_resources\CustomerResources; |
|||
use app\model\student\Student; |
|||
use core\base\BaseService; |
|||
use core\exception\CommonException; |
|||
|
|||
/** |
|||
* 体测数据服务类 |
|||
*/ |
|||
class PhysicalTestService extends BaseService |
|||
{ |
|||
/** |
|||
* 获取学员体测记录列表 |
|||
* @param array $data |
|||
* @return array |
|||
*/ |
|||
public function getPhysicalTestList($data) |
|||
{ |
|||
$studentId = $data['student_id']; |
|||
|
|||
// 验证学员权限 |
|||
$this->checkStudentPermission($studentId); |
|||
|
|||
$page = $data['page'] ?? 1; |
|||
$limit = $data['limit'] ?? 20; |
|||
|
|||
$list = (new PhysicalTest()) |
|||
->where('student_id', $studentId) |
|||
->field('id,student_id,age,height,weight,physical_test_report,created_at,updated_at') |
|||
->order('created_at desc') |
|||
->page($page, $limit) |
|||
->select() |
|||
->toArray(); |
|||
|
|||
// 处理数据 |
|||
foreach ($list as &$item) { |
|||
$item['test_date'] = date('Y-m-d', strtotime($item['created_at'])); |
|||
$item['height_text'] = $item['height'] . 'cm'; |
|||
$item['weight_text'] = $item['weight'] . 'kg'; |
|||
|
|||
// 处理体测报告PDF文件 |
|||
$item['has_report'] = !empty($item['physical_test_report']); |
|||
$item['report_files'] = []; |
|||
|
|||
if ($item['physical_test_report']) { |
|||
$files = explode(',', $item['physical_test_report']); |
|||
foreach ($files as $file) { |
|||
if ($file) { |
|||
$item['report_files'][] = [ |
|||
'name' => '体测报告', |
|||
'url' => get_image_url($file), |
|||
'path' => $file |
|||
]; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
$total = (new PhysicalTest())->where('student_id', $studentId)->count(); |
|||
|
|||
return [ |
|||
'list' => $list, |
|||
'total' => $total, |
|||
'page' => $page, |
|||
'limit' => $limit, |
|||
'pages' => ceil($total / $limit) |
|||
]; |
|||
} |
|||
|
|||
/** |
|||
* 获取体测详情 |
|||
* @param int $testId |
|||
* @return array |
|||
*/ |
|||
public function getPhysicalTestDetail($testId) |
|||
{ |
|||
$physicalTest = (new PhysicalTest())->where('id', $testId)->find(); |
|||
|
|||
if (!$physicalTest) { |
|||
throw new CommonException('体测记录不存在'); |
|||
} |
|||
|
|||
// 验证学员权限 |
|||
$this->checkStudentPermission($physicalTest['student_id']); |
|||
|
|||
$data = $physicalTest->toArray(); |
|||
|
|||
// 格式化数据 |
|||
$data['test_date'] = date('Y-m-d', strtotime($data['created_at'])); |
|||
$data['height_text'] = $data['height'] . 'cm'; |
|||
$data['weight_text'] = $data['weight'] . 'kg'; |
|||
|
|||
// 处理各项体测指标 |
|||
$indicators = [ |
|||
'seated_forward_bend' => ['name' => '坐位体前屈', 'unit' => 'cm'], |
|||
'sit_ups' => ['name' => '仰卧卷腹', 'unit' => '次'], |
|||
'push_ups' => ['name' => '九十度仰卧撑', 'unit' => '次'], |
|||
'flamingo_balance' => ['name' => '火烈鸟平衡测试', 'unit' => 's'], |
|||
'thirty_sec_jump' => ['name' => '三十秒双脚连续跳', 'unit' => '次'], |
|||
'standing_long_jump' => ['name' => '立定跳远', 'unit' => 'cm'], |
|||
'agility_run' => ['name' => '4乘10m灵敏折返跑', 'unit' => 's'], |
|||
'balance_beam' => ['name' => '走平衡木', 'unit' => 's'], |
|||
'tennis_throw' => ['name' => '网球掷远', 'unit' => 'm'], |
|||
'ten_meter_shuttle_run' => ['name' => '十米往返跑', 'unit' => 's'] |
|||
]; |
|||
|
|||
$data['indicators'] = []; |
|||
foreach ($indicators as $key => $info) { |
|||
if ($data[$key] !== null && $data[$key] !== '') { |
|||
$data['indicators'][] = [ |
|||
'name' => $info['name'], |
|||
'value' => $data[$key], |
|||
'unit' => $info['unit'], |
|||
'value_text' => $data[$key] . $info['unit'] |
|||
]; |
|||
} |
|||
} |
|||
|
|||
// 处理体测报告文件 |
|||
$data['report_files'] = []; |
|||
if ($data['physical_test_report']) { |
|||
$files = explode(',', $data['physical_test_report']); |
|||
foreach ($files as $file) { |
|||
if ($file) { |
|||
$data['report_files'][] = [ |
|||
'name' => '体测报告', |
|||
'url' => get_image_url($file), |
|||
'path' => $file, |
|||
'type' => 'pdf' |
|||
]; |
|||
} |
|||
} |
|||
} |
|||
|
|||
return $data; |
|||
} |
|||
|
|||
/** |
|||
* 获取体测趋势数据(身高体重变化) |
|||
* @param array $data |
|||
* @return array |
|||
*/ |
|||
public function getPhysicalTestTrend($data) |
|||
{ |
|||
$studentId = $data['student_id']; |
|||
$months = $data['months'] ?? 12; |
|||
|
|||
// 验证学员权限 |
|||
$this->checkStudentPermission($studentId); |
|||
|
|||
// 获取指定月份内的体测数据 |
|||
$startDate = date('Y-m-d', strtotime("-{$months} months")); |
|||
|
|||
$trendData = (new PhysicalTest()) |
|||
->where('student_id', $studentId) |
|||
->where('created_at', '>=', $startDate) |
|||
->field('height,weight,created_at') |
|||
->order('created_at asc') |
|||
->select() |
|||
->toArray(); |
|||
|
|||
$heightData = []; |
|||
$weightData = []; |
|||
$dates = []; |
|||
|
|||
foreach ($trendData as $item) { |
|||
$date = date('Y-m', strtotime($item['created_at'])); |
|||
$dates[] = $date; |
|||
$heightData[] = [ |
|||
'date' => $date, |
|||
'value' => (float)$item['height'] |
|||
]; |
|||
$weightData[] = [ |
|||
'date' => $date, |
|||
'value' => (float)$item['weight'] |
|||
]; |
|||
} |
|||
|
|||
// 计算增长情况 |
|||
$heightGrowth = 0; |
|||
$weightGrowth = 0; |
|||
|
|||
if (count($heightData) >= 2) { |
|||
$heightGrowth = end($heightData)['value'] - $heightData[0]['value']; |
|||
$weightGrowth = end($weightData)['value'] - $weightData[0]['value']; |
|||
} |
|||
|
|||
return [ |
|||
'height_data' => $heightData, |
|||
'weight_data' => $weightData, |
|||
'dates' => array_unique($dates), |
|||
'growth' => [ |
|||
'height' => round($heightGrowth, 1), |
|||
'weight' => round($weightGrowth, 1), |
|||
'height_text' => ($heightGrowth >= 0 ? '+' : '') . round($heightGrowth, 1) . 'cm', |
|||
'weight_text' => ($weightGrowth >= 0 ? '+' : '') . round($weightGrowth, 1) . 'kg' |
|||
], |
|||
'data_count' => count($trendData), |
|||
'months' => $months |
|||
]; |
|||
} |
|||
|
|||
/** |
|||
* PDF转图片分享 |
|||
* @param int $testId |
|||
* @return array |
|||
*/ |
|||
public function convertPdfToImage($testId) |
|||
{ |
|||
$physicalTest = (new PhysicalTest())->where('id', $testId)->find(); |
|||
|
|||
if (!$physicalTest) { |
|||
throw new CommonException('体测记录不存在'); |
|||
} |
|||
|
|||
// 验证学员权限 |
|||
$this->checkStudentPermission($physicalTest['student_id']); |
|||
|
|||
if (!$physicalTest['physical_test_report']) { |
|||
throw new CommonException('该体测记录没有PDF报告'); |
|||
} |
|||
|
|||
// 获取第一个PDF文件 |
|||
$files = explode(',', $physicalTest['physical_test_report']); |
|||
$pdfFile = trim($files[0]); |
|||
|
|||
if (!$pdfFile) { |
|||
throw new CommonException('PDF文件路径无效'); |
|||
} |
|||
|
|||
// 构建完整文件路径 |
|||
$pdfPath = public_path() . $pdfFile; |
|||
|
|||
if (!file_exists($pdfPath)) { |
|||
throw new CommonException('PDF文件不存在'); |
|||
} |
|||
|
|||
try { |
|||
// 使用Imagick将PDF转换为图片 |
|||
if (!extension_loaded('imagick')) { |
|||
throw new CommonException('系统不支持PDF转图片功能'); |
|||
} |
|||
|
|||
$imagick = new \Imagick(); |
|||
$imagick->setResolution(150, 150); |
|||
$imagick->readImage($pdfPath . '[0]'); // 只转换第一页 |
|||
$imagick->setImageFormat('jpeg'); |
|||
$imagick->setImageCompressionQuality(85); |
|||
|
|||
// 生成图片文件名 |
|||
$imageName = 'physical_test_' . $testId . '_' . time() . '.jpg'; |
|||
$imagePath = 'uploads/share/' . date('Y/m/d') . '/' . $imageName; |
|||
$fullImagePath = public_path() . $imagePath; |
|||
|
|||
// 确保目录存在 |
|||
$dir = dirname($fullImagePath); |
|||
if (!is_dir($dir)) { |
|||
mkdir($dir, 0755, true); |
|||
} |
|||
|
|||
// 保存图片 |
|||
$imagick->writeImage($fullImagePath); |
|||
$imagick->clear(); |
|||
$imagick->destroy(); |
|||
|
|||
return [ |
|||
'image_url' => get_image_url($imagePath), |
|||
'image_path' => $imagePath, |
|||
'pdf_url' => get_image_url($pdfFile), |
|||
'message' => 'PDF转换为图片成功,可以分享给朋友了' |
|||
]; |
|||
|
|||
} catch (\Exception $e) { |
|||
throw new CommonException('PDF转图片失败:' . $e->getMessage()); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 检查学员权限 |
|||
* @param int $studentId |
|||
* @return bool |
|||
*/ |
|||
private function checkStudentPermission($studentId) |
|||
{ |
|||
$customerId = $this->getUserId(); |
|||
|
|||
// 获取客户资源信息 |
|||
$customerResource = (new CustomerResources())->where('id', $customerId)->find(); |
|||
if (!$customerResource) { |
|||
throw new CommonException('用户信息不存在'); |
|||
} |
|||
|
|||
// 检查学员是否属于当前用户 |
|||
$student = (new Student()) |
|||
->where('id', $studentId) |
|||
->where('member_id', $customerResource['member_id']) |
|||
->where('delete_time', 0) |
|||
->find(); |
|||
|
|||
if (!$student) { |
|||
throw new CommonException('无权限访问该学员信息'); |
|||
} |
|||
|
|||
return true; |
|||
} |
|||
|
|||
/** |
|||
* 获取当前登录用户ID |
|||
* @return int |
|||
*/ |
|||
private function getUserId() |
|||
{ |
|||
return request()->userId ?? 0; |
|||
} |
|||
} |
|||
@ -0,0 +1,367 @@ |
|||
<?php |
|||
// +---------------------------------------------------------------------- |
|||
// | Niucloud-admin 企业快速开发的多应用管理平台 |
|||
// +---------------------------------------------------------------------- |
|||
|
|||
namespace app\service\api\student; |
|||
|
|||
use app\model\customer_resources\CustomerResources; |
|||
use app\model\student\Student; |
|||
use app\model\member\Member; |
|||
use think\facade\Db; |
|||
use core\base\BaseService; |
|||
use core\exception\CommonException; |
|||
|
|||
/** |
|||
* 学员信息管理服务类 |
|||
*/ |
|||
class StudentService extends BaseService |
|||
{ |
|||
/** |
|||
* 获取当前用户的学员列表 |
|||
* @return array |
|||
*/ |
|||
public function getStudentList() |
|||
{ |
|||
// 获取当前登录用户ID |
|||
$customerId = $this->getUserId(); |
|||
|
|||
// 通过客户资源表获取user_id |
|||
$customerResource = (new CustomerResources())->where('id', $customerId)->find(); |
|||
if (!$customerResource) { |
|||
throw new CommonException('用户信息不存在'); |
|||
} |
|||
|
|||
// 获取该用户的所有学员 |
|||
$studentList = (new Student()) |
|||
->where('user_id', $customerId) |
|||
->where('deleted_at', 0) |
|||
->field('id,name,gender,birthday,headimg,created_at') |
|||
->order('id desc') |
|||
->select() |
|||
->toArray(); |
|||
|
|||
// 计算年龄和格式化数据 |
|||
foreach ($studentList as &$student) { |
|||
$student['age'] = $this->calculateAge($student['birthday']); |
|||
$student['gender_text'] = $student['gender'] == 1 ? '男' : '女'; |
|||
$student['headimg'] = $student['headimg'] ? get_image_url($student['headimg']) : ''; |
|||
} |
|||
|
|||
return [ |
|||
'list' => $studentList, |
|||
'total' => count($studentList) |
|||
]; |
|||
} |
|||
|
|||
/** |
|||
* 获取学员概览信息(首页用) |
|||
* @param int $studentId |
|||
* @return array |
|||
*/ |
|||
public function getStudentSummary($studentId) |
|||
{ |
|||
// 验证学员权限 |
|||
$this->checkStudentPermission($studentId); |
|||
|
|||
$student = (new Student()) |
|||
->where('id', $studentId) |
|||
->where('deleted_at', 0) |
|||
->find(); |
|||
|
|||
if (!$student) { |
|||
throw new CommonException('学员信息不存在'); |
|||
} |
|||
|
|||
// 获取用户基本信息 |
|||
$member = (new CustomerResources())->where('id', $student['user_id'])->find(); |
|||
|
|||
return [ |
|||
'student_id' => $student['id'], |
|||
'name' => $student['name'], |
|||
'age' => $this->calculateAge($student['birthday']), |
|||
'gender' => $student['gender'], |
|||
'gender_text' => $student['gender'] == 1 ? '男' : '女', |
|||
'headimg' => $student['headimg'] ? get_image_url($student['headimg']) : '', |
|||
'member_name' => $member['name'] ?? '', |
|||
'created_at' => $student['created_at'], |
|||
'create_year_month' => date('Y年m月', strtotime($student['created_at'])), |
|||
'week_day' => '星期' . ['日', '一', '二', '三', '四', '五', '六'][date('w')] |
|||
]; |
|||
} |
|||
|
|||
/** |
|||
* 获取学员详细信息 |
|||
* @param int $studentId |
|||
* @return array |
|||
*/ |
|||
public function getStudentInfo($studentId) |
|||
{ |
|||
// 验证学员权限 |
|||
$this->checkStudentPermission($studentId); |
|||
|
|||
$student = (new Student()) |
|||
->where('id', $studentId) |
|||
->where('deleted_at', 0) |
|||
->find(); |
|||
|
|||
if (!$student) { |
|||
throw new CommonException('学员信息不存在'); |
|||
} |
|||
|
|||
$studentData = $student->toArray(); |
|||
|
|||
// 处理图片URL |
|||
$studentData['headimg'] = $studentData['headimg'] ? get_image_url($studentData['headimg']) : ''; |
|||
|
|||
// 计算年龄 |
|||
$studentData['age'] = $this->calculateAge($studentData['birthday']); |
|||
$studentData['gender_text'] = $studentData['gender'] == 1 ? '男' : '女'; |
|||
|
|||
return $studentData; |
|||
} |
|||
|
|||
/** |
|||
* 获取学员详细信息(包含体测信息) |
|||
* @param int $studentId |
|||
* @return array |
|||
*/ |
|||
public function getStudentInfoWithPhysicalTest($studentId) |
|||
{ |
|||
// 验证学员权限 |
|||
$this->checkStudentPermission($studentId); |
|||
|
|||
// 获取学员基本信息 |
|||
$student = Db::table('school_student') |
|||
->where('id', $studentId) |
|||
->where('deleted_at', 0) |
|||
->find(); |
|||
|
|||
if (!$student) { |
|||
throw new CommonException('学员信息不存在'); |
|||
} |
|||
|
|||
// 获取最新的体测信息 |
|||
$physicalTest = Db::table('school_physical_test') |
|||
->where('student_id', $studentId) |
|||
->order('created_at desc') |
|||
->find(); |
|||
|
|||
// 处理学员信息 |
|||
$studentInfo = [ |
|||
'id' => $student['id'], |
|||
'name' => $student['name'], |
|||
'gender' => $student['gender'], |
|||
'gender_text' => $student['gender'] == 1 ? '男' : '女', |
|||
'birthday' => $student['birthday'], |
|||
'emergency_contact' => $student['emergency_contact'], |
|||
'contact_phone' => $student['contact_phone'], |
|||
'note' => $student['note'], |
|||
'headimg' => $student['headimg'] ? get_image_url($student['headimg']) : '', |
|||
]; |
|||
|
|||
// 处理体测信息 |
|||
$physicalTestInfo = []; |
|||
if ($physicalTest) { |
|||
$physicalTestInfo = [ |
|||
'height' => $physicalTest['height'] ? (string)$physicalTest['height'] : '', |
|||
'weight' => $physicalTest['weight'] ? (string)$physicalTest['weight'] : '', |
|||
'test_date' => date('Y-m-d', strtotime($physicalTest['created_at'])) |
|||
]; |
|||
} |
|||
|
|||
return [ |
|||
'student_info' => $studentInfo, |
|||
'physical_test_info' => $physicalTestInfo |
|||
]; |
|||
} |
|||
|
|||
/** |
|||
* 更新学员信息 |
|||
* @param array $data |
|||
* @return bool |
|||
*/ |
|||
public function updateStudentInfo($data) |
|||
{ |
|||
$studentId = $data['student_id']; |
|||
|
|||
// 验证学员权限 |
|||
$this->checkStudentPermission($studentId); |
|||
|
|||
// 验证学员是否存在 |
|||
$student = Db::table('school_student') |
|||
->where('id', $studentId) |
|||
->where('deleted_at', 0) |
|||
->find(); |
|||
|
|||
if (!$student) { |
|||
throw new CommonException('学员信息不存在'); |
|||
} |
|||
|
|||
// 允许更新的字段 |
|||
$allowedFields = ['name', 'gender', 'birthday', 'emergency_contact', 'contact_phone', 'note', 'headimg']; |
|||
$updateData = []; |
|||
|
|||
foreach ($allowedFields as $field) { |
|||
if (isset($data[$field]) && $data[$field] !== '') { |
|||
$updateData[$field] = $data[$field]; |
|||
} |
|||
} |
|||
|
|||
if (empty($updateData)) { |
|||
throw new CommonException('没有需要更新的数据'); |
|||
} |
|||
|
|||
// 如果有生日更新,需要重新计算年龄 |
|||
if (isset($updateData['birthday'])) { |
|||
$updateData['age'] = $this->calculateAgeFromBirthday($updateData['birthday']); |
|||
} |
|||
|
|||
$result = Db::table('school_student') |
|||
->where('id', $studentId) |
|||
->update($updateData); |
|||
|
|||
if ($result === false) { |
|||
throw new CommonException('更新学员信息失败'); |
|||
} |
|||
|
|||
return true; |
|||
} |
|||
|
|||
/** |
|||
* 上传学员头像 |
|||
* @param int $studentId |
|||
* @return array |
|||
*/ |
|||
public function uploadAvatar($studentId) |
|||
{ |
|||
// 验证学员权限 |
|||
$this->checkStudentPermission($studentId); |
|||
|
|||
// 处理文件上传 |
|||
$uploadService = new \app\service\core\upload\UploadService(); |
|||
$result = $uploadService->image([ |
|||
'image' => request()->file('image'), |
|||
'thumb_type' => 'avatar' |
|||
]); |
|||
|
|||
if (!$result) { |
|||
throw new CommonException('头像上传失败'); |
|||
} |
|||
|
|||
// 更新学员头像 |
|||
$student = (new Student())->where('id', $studentId)->find(); |
|||
$student->headimg = $result['url']; |
|||
$student->save(); |
|||
|
|||
return [ |
|||
'url' => get_image_url($result['url']), |
|||
'path' => $result['url'] |
|||
]; |
|||
} |
|||
|
|||
/** |
|||
* 检查学员权限(确保只能操作自己的孩子) |
|||
* @param int $studentId |
|||
* @return bool |
|||
*/ |
|||
private function checkStudentPermission($studentId) |
|||
{ |
|||
$customerId = $this->getUserId(); |
|||
// 获取客户资源信息 |
|||
$customerResource = (new CustomerResources())->where('id', $customerId)->find(); |
|||
if (!$customerResource) { |
|||
throw new CommonException('用户信息不存在'); |
|||
} |
|||
|
|||
// 检查学员是否属于当前用户 |
|||
$student = (new Student()) |
|||
->where('id', $studentId) |
|||
->where('user_id', $customerId) |
|||
->where('deleted_at', 0) |
|||
->find(); |
|||
|
|||
if (!$student) { |
|||
throw new CommonException('无权限访问该学员信息'); |
|||
} |
|||
|
|||
return true; |
|||
} |
|||
|
|||
/** |
|||
* 计算年龄 |
|||
* @param string $birthday |
|||
* @return int |
|||
*/ |
|||
private function calculateAge($birthday) |
|||
{ |
|||
if (!$birthday) return 0; |
|||
|
|||
$birthTime = strtotime($birthday); |
|||
if (!$birthTime) return 0; |
|||
|
|||
$age = date('Y') - date('Y', $birthTime); |
|||
|
|||
// 如果还没过生日,年龄减1 |
|||
if (date('md') < date('md', $birthTime)) { |
|||
$age--; |
|||
} |
|||
|
|||
return max(0, $age); |
|||
} |
|||
|
|||
/** |
|||
* 根据生日精确计算年龄(支持小数表示) |
|||
* @param string $birthday |
|||
* @return float |
|||
*/ |
|||
private function calculateAgeFromBirthday($birthday) |
|||
{ |
|||
if (!$birthday) return 0; |
|||
|
|||
$birthTime = strtotime($birthday); |
|||
if (!$birthTime) return 0; |
|||
|
|||
$today = new \DateTime(); |
|||
$birthDate = new \DateTime($birthday); |
|||
|
|||
$interval = $today->diff($birthDate); |
|||
|
|||
$years = $interval->y; |
|||
$months = $interval->m; |
|||
|
|||
// 将月份转换为小数,如3岁11个月 = 3.11 |
|||
return $years + ($months / 100); |
|||
} |
|||
|
|||
/** |
|||
* 获取当前登录用户ID |
|||
* @return int |
|||
*/ |
|||
private function getUserId() |
|||
{ |
|||
// 从request中获取memberId(由ApiCheckToken中间件设置) |
|||
$memberId = request()->memberId(); |
|||
if ($memberId) { |
|||
return $memberId; |
|||
} |
|||
|
|||
// 如果没有中间件设置,尝试解析token |
|||
$token = request()->header('token'); |
|||
if ($token) { |
|||
try { |
|||
$loginService = new \app\service\api\login\LoginService(); |
|||
$tokenInfo = $loginService->parseToken($token); |
|||
if (!empty($tokenInfo) && isset($tokenInfo['member_id'])) { |
|||
return $tokenInfo['member_id']; |
|||
} |
|||
} catch (\Exception $e) { |
|||
// token解析失败,抛出异常 |
|||
throw new CommonException('用户未登录或token无效'); |
|||
} |
|||
} |
|||
|
|||
// 如果都没有,抛出异常 |
|||
throw new CommonException('用户未登录'); |
|||
} |
|||
} |
|||
@ -0,0 +1,239 @@ |
|||
# 学员信息管理模块 - 测试验收文档 |
|||
|
|||
**模块名称**:学员信息管理模块 |
|||
**开发完成日期**:2025-01-30 |
|||
**测试负责人**:AI项目经理 |
|||
**API接口数量**:5个 |
|||
|
|||
--- |
|||
|
|||
## 📋 **模块功能概述** |
|||
|
|||
该模块提供学员端访问和管理学员信息的功能,包括: |
|||
1. 获取学员列表(支持多孩子) |
|||
2. 获取学员概览信息(用于首页) |
|||
3. 获取学员详细信息 |
|||
4. 更新学员基本信息 |
|||
5. 上传学员头像 |
|||
|
|||
--- |
|||
|
|||
## 🔌 **API接口清单** |
|||
|
|||
### 1. **获取学员列表** |
|||
- **接口路径**:`GET /api/student/list` |
|||
- **功能描述**:获取当前用户的所有学员列表,支持多孩子家庭 |
|||
- **权限要求**:需要登录token |
|||
- **响应数据**:学员基本信息列表 |
|||
|
|||
### 2. **获取学员概览信息** |
|||
- **接口路径**:`GET /api/student/summary/{student_id}` |
|||
- **功能描述**:获取指定学员的概览信息,用于首页展示 |
|||
- **权限要求**:需要登录token,只能查看自己的孩子 |
|||
- **响应数据**:学员概览信息,包含年龄、性别、入会时间等 |
|||
|
|||
### 3. **获取学员详细信息** |
|||
- **接口路径**:`GET /api/student/info/{student_id}` |
|||
- **功能描述**:获取指定学员的详细信息 |
|||
- **权限要求**:需要登录token,只能查看自己的孩子 |
|||
- **响应数据**:学员完整信息 |
|||
1 |
|||
### 4. **更新学员信息** |
|||
- **接口路径**:`PUT /api/student/update` |
|||
- **功能描述**:更新学员的基本信息 |
|||
- **权限要求**:需要登录token,只能修改自己的孩子 |
|||
- **请求参数**:学员ID及要更新的字段 |
|||
|
|||
### 5. **上传学员头像** |
|||
- **接口路径**:`POST /api/student/avatar` |
|||
- **功能描述**:上传并更新学员头像 |
|||
- **权限要求**:需要登录token,只能修改自己的孩子 |
|||
- **文件要求**:图片格式,大小不超过2MB |
|||
|
|||
--- |
|||
|
|||
## 🧪 **测试用例** |
|||
|
|||
### 测试用例1:获取学员列表 |
|||
```bash |
|||
# 测试命令 |
|||
curl -X GET "http://localhost:20080/api/student/list" \ |
|||
-H "Authorization: Bearer YOUR_TOKEN" \ |
|||
-H "Content-Type: application/json" |
|||
|
|||
# 预期结果 |
|||
{ |
|||
"code": 200, |
|||
"message": "获取学员列表成功", |
|||
"data": { |
|||
"list": [ |
|||
{ |
|||
"id": 1, |
|||
"name": "张小明", |
|||
"gender": 1, |
|||
"gender_text": "男", |
|||
"age": 8, |
|||
"headimg": "http://domain/uploads/avatar/xxx.jpg", |
|||
"create_time": "2024-01-01 10:00:00" |
|||
} |
|||
], |
|||
"total": 1 |
|||
} |
|||
} |
|||
``` |
|||
|
|||
### 测试用例2:获取学员概览信息 |
|||
```bash |
|||
# 测试命令 |
|||
curl -X GET "http://localhost:20080/api/student/summary/1" \ |
|||
-H "Authorization: Bearer YOUR_TOKEN" \ |
|||
-H "Content-Type: application/json" |
|||
|
|||
# 预期结果 |
|||
{ |
|||
"code": 200, |
|||
"message": "获取学员概览成功", |
|||
"data": { |
|||
"student_id": 1, |
|||
"name": "张小明", |
|||
"age": 8, |
|||
"gender": 1, |
|||
"gender_text": "男", |
|||
"headimg": "http://domain/uploads/avatar/xxx.jpg", |
|||
"member_name": "张先生", |
|||
"create_year_month": "2024年01月", |
|||
"week_day": "星期三" |
|||
} |
|||
} |
|||
``` |
|||
|
|||
### 测试用例3:更新学员信息 |
|||
```bash |
|||
# 测试命令 |
|||
curl -X PUT "http://localhost:20080/api/student/update" \ |
|||
-H "Authorization: Bearer YOUR_TOKEN" \ |
|||
-H "Content-Type: application/json" \ |
|||
-d '{ |
|||
"student_id": 1, |
|||
"name": "张小明", |
|||
"emergency_contact": "张妈妈", |
|||
"emergency_phone": "13800138000", |
|||
"address": "北京市朝阳区xxx小区" |
|||
}' |
|||
|
|||
# 预期结果 |
|||
{ |
|||
"code": 200, |
|||
"message": "更新学员信息成功", |
|||
"data": true |
|||
} |
|||
``` |
|||
|
|||
### 测试用例4:权限验证测试 |
|||
```bash |
|||
# 测试命令:尝试访问其他用户的学员信息 |
|||
curl -X GET "http://localhost:20080/api/student/info/999" \ |
|||
-H "Authorization: Bearer YOUR_TOKEN" \ |
|||
-H "Content-Type: application/json" |
|||
|
|||
# 预期结果:应该返回权限错误 |
|||
{ |
|||
"code": 403, |
|||
"message": "无权限访问该学员信息", |
|||
"data": null |
|||
} |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## ✅ **验收标准** |
|||
|
|||
### 功能验收标准 |
|||
- [ ] **API接口正常响应**:所有5个接口都能正常返回数据 |
|||
- [ ] **数据格式正确**:返回的JSON数据结构符合接口文档 |
|||
- [ ] **权限控制严格**:只能访问自己的孩子信息,不能越权访问 |
|||
- [ ] **数据验证完整**:输入参数验证正确,异常情况处理完善 |
|||
- [ ] **图片上传功能**:头像上传功能正常,支持常见图片格式 |
|||
|
|||
### 性能验收标准 |
|||
- [ ] **响应时间**:接口响应时间 < 500ms |
|||
- [ ] **并发处理**:支持至少10个并发请求 |
|||
- [ ] **数据准确性**:返回的学员信息与数据库数据一致 |
|||
|
|||
### 安全验收标准 |
|||
- [ ] **Token验证**:未登录用户不能访问接口 |
|||
- [ ] **权限隔离**:用户只能操作自己的学员数据 |
|||
- [ ] **输入过滤**:防止SQL注入和XSS攻击 |
|||
- [ ] **文件上传安全**:头像上传限制文件类型和大小 |
|||
|
|||
--- |
|||
|
|||
## 🚨 **已知问题和风险** |
|||
|
|||
### 高优先级问题 |
|||
1. **Token获取方式**:当前代码中`getUserId()`方法需要完善,需要从实际的认证中间件获取用户ID |
|||
2. **图片上传服务**:需要确认`UploadService`类是否存在并可用 |
|||
3. **数据库连接**:需要确保所有相关的Model类路径正确 |
|||
|
|||
### 中优先级问题 |
|||
1. **年龄计算精度**:当前按年计算,可能需要更精确的月份计算 |
|||
2. **头像URL处理**:`get_image_url()`函数需要确认是否存在 |
|||
3. **异常处理统一**:需要统一异常返回格式 |
|||
|
|||
### 低优先级问题 |
|||
1. **代码注释**:部分复杂逻辑需要添加更详细的注释 |
|||
2. **日志记录**:关键操作需要添加日志记录 |
|||
|
|||
--- |
|||
|
|||
## 📝 **验收流程** |
|||
|
|||
### 第一步:环境准备 |
|||
1. 确保数据库连接正常 |
|||
2. 确保相关数据表存在且有测试数据 |
|||
3. 获取有效的测试token |
|||
|
|||
### 第二步:功能测试 |
|||
1. 依次执行所有测试用例 |
|||
2. 验证返回数据的正确性 |
|||
3. 测试异常情况的处理 |
|||
|
|||
### 第三步:性能测试 |
|||
1. 使用压力测试工具测试并发性能 |
|||
2. 监控接口响应时间 |
|||
3. 检查内存和CPU使用情况 |
|||
|
|||
### 第四步:安全测试 |
|||
1. 测试无token访问 |
|||
2. 测试越权访问 |
|||
3. 测试恶意输入 |
|||
|
|||
### 第五步:集成测试 |
|||
1. 与前端页面联调 |
|||
2. 测试完整的用户操作流程 |
|||
3. 验证数据一致性 |
|||
|
|||
--- |
|||
|
|||
## 📊 **验收结果** |
|||
|
|||
**验收状态**:待验收 |
|||
**预计验收时间**:模块开发完成后1个工作日内 |
|||
**验收负责人**:项目负责人 |
|||
|
|||
### 验收结果记录 |
|||
- [ ] 功能验收:通过/不通过 |
|||
- [ ] 性能验收:通过/不通过 |
|||
- [ ] 安全验收:通过/不通过 |
|||
- [ ] 代码质量:通过/不通过 |
|||
|
|||
### 问题记录 |
|||
(由验收人员填写发现的问题) |
|||
|
|||
--- |
|||
|
|||
**备注**: |
|||
1. 本文档作为该模块验收的标准和依据 |
|||
2. 所有问题必须在验收通过前解决 |
|||
3. 验收通过后方可进入下个模块开发 |
|||
4. 如有疑问请及时与项目经理沟通 |
|||
@ -1,278 +1,40 @@ |
|||
import http from '../common/axios.js' |
|||
|
|||
export default { |
|||
|
|||
|
|||
//学员首页
|
|||
memberIndex(data = {}) { |
|||
let url = '/member/index' |
|||
return http.get(url, data).then(res => { |
|||
return res; |
|||
}) |
|||
}, |
|||
|
|||
//学员详情(个人中心-教练详情)
|
|||
member(data) { |
|||
let url = '/member/member' |
|||
return http.get(url,data).then(res => { |
|||
return res; |
|||
}) |
|||
}, |
|||
//验证原始密码
|
|||
is_pass(data) { |
|||
let url = '/member/is_pass' |
|||
return http.post(url,data).then(res => { |
|||
return res; |
|||
}) |
|||
}, |
|||
//设置新密码
|
|||
set_pass(data) { |
|||
let url = '/member/set_pass' |
|||
return http.post(url,data).then(res => { |
|||
return res; |
|||
}) |
|||
}, |
|||
// 业务员端配置项(关于我们)
|
|||
setFeedback(data) { |
|||
let url = '/member/set_feedback' |
|||
return http.post(url,data).then(res => { |
|||
return res; |
|||
}) |
|||
}, |
|||
//登陆
|
|||
login(data) { |
|||
let url = '/login' |
|||
return http.get(url,data).then(res => { |
|||
return res; |
|||
}) |
|||
}, |
|||
//获取员工工资列表
|
|||
getSalaryList(data = {}) { |
|||
let url = '/member/salary/list' |
|||
return http.get(url, data).then(res => { |
|||
return res; |
|||
}) |
|||
}, |
|||
|
|||
//获取员工工资详情
|
|||
getSalaryInfo(data) { |
|||
let url = `/member/salary/info/${data.id}` |
|||
return http.get(url, data).then(res => { |
|||
return res; |
|||
}) |
|||
}, |
|||
|
|||
//修改学员信息
|
|||
member_edit(data) { |
|||
let url = '/member/member_edit' |
|||
return http.post(url,data).then(res => { |
|||
return res; |
|||
}) |
|||
}, |
|||
//获取学员课程列表
|
|||
courseList(data) { |
|||
let url = '/member/course_list' |
|||
return http.get(url,data).then(res => { |
|||
return res; |
|||
}) |
|||
}, |
|||
|
|||
|
|||
|
|||
//课程详情
|
|||
courseInfo(data) { |
|||
let url = '/member/course_info' |
|||
return http.get(url, data).then(res => { |
|||
return res; |
|||
}) |
|||
}, |
|||
|
|||
//场地列表
|
|||
venuesList(data) { |
|||
let url = '/member/venues_list' |
|||
return http.get(url, data).then(res => { |
|||
return res; |
|||
}) |
|||
}, |
|||
|
|||
//作业列表
|
|||
assignmentsList(data) { |
|||
let url = '/member/assignments_list' |
|||
return http.get(url, data).then(res => { |
|||
return res; |
|||
}) |
|||
}, |
|||
|
|||
//学员-体测列表
|
|||
surveyList(data) { |
|||
let url = '/member/survey_list' |
|||
return http.get(url, data).then(res => { |
|||
return res; |
|||
}) |
|||
}, |
|||
|
|||
//提交作业
|
|||
assignmentsSubmit(data) { |
|||
let url = '/member/assignments_submit' |
|||
return http.post(url, data).then(res => { |
|||
return res; |
|||
}) |
|||
}, |
|||
|
|||
//作业详情
|
|||
assignmentsInfo(data) { |
|||
let url = '/member/assignments_info' |
|||
return http.get(url, data).then(res => { |
|||
return res; |
|||
}) |
|||
}, |
|||
|
|||
|
|||
//学员发送请假申请
|
|||
askForLeave(data) { |
|||
let url = '/member/ask_for_leave' |
|||
return http.post(url, data).then(res => { |
|||
return res; |
|||
}) |
|||
}, |
|||
|
|||
//学员取消请假申请
|
|||
delAskForLeave(data) { |
|||
let url = '/member/del_ask_for_leave' |
|||
return http.post(url, data).then(res => { |
|||
return res; |
|||
}) |
|||
}, |
|||
|
|||
|
|||
//获取课时列表
|
|||
studentsSignList(data) { |
|||
let url = '/member/students_sign_list' |
|||
return http.post(url, data).then(res => { |
|||
return res; |
|||
}) |
|||
}, |
|||
|
|||
//人员列表
|
|||
staffList(data) { |
|||
let url = '/member/staff_list' |
|||
return http.get(url, data).then(res => { |
|||
return res; |
|||
}) |
|||
}, |
|||
|
|||
//企业信息
|
|||
getEnterpriseInformation(data = {}) { |
|||
let url = '/member/get_enterprise_information' |
|||
return http.get(url, data).then(res => { |
|||
return res; |
|||
}) |
|||
}, |
|||
|
|||
//##################### 教练端 ######################
|
|||
//教练端-首页
|
|||
jlIndex(data = {}) { |
|||
let url = '/member/jl_index' |
|||
return http.get(url, data).then(res => { |
|||
return res; |
|||
}) |
|||
}, |
|||
|
|||
//教练端-发布作业
|
|||
jlPublishJob(data = {}) { |
|||
let url = '/member/publish_job' |
|||
return http.post(url, data).then(res => { |
|||
return res; |
|||
}) |
|||
//↓↓↓↓↓↓↓↓↓↓↓↓-----学员信息管理接口-----↓↓↓↓↓↓↓↓↓↓↓↓
|
|||
// 获取当前用户的学员列表
|
|||
async getStudentList(data = {}) { |
|||
return await http.get('/student/mychild', data); |
|||
}, |
|||
|
|||
//教练端-获取班级列表
|
|||
jlGetClassesList(data = {}) { |
|||
let url = '/member/get_classes_list' |
|||
return http.get(url, data).then(res => { |
|||
return res; |
|||
}) |
|||
// 获取学员概览信息(首页用)
|
|||
async getStudentSummary(studentId) { |
|||
return await http.get(`/student/summary/${studentId}`); |
|||
}, |
|||
|
|||
//教练端-获取班级列表
|
|||
jlGetCoursesList(data = {}) { |
|||
let url = '/member/get_courses_list' |
|||
return http.get(url, data).then(res => { |
|||
return res; |
|||
}) |
|||
// 获取学员详细信息(包含体测信息)
|
|||
async getStudentInfo(studentId) { |
|||
return await http.get(`/student/info/${studentId}`); |
|||
}, |
|||
|
|||
//教练端-获取学员列表
|
|||
jlGetStudentList(data = {}) { |
|||
let url = '/member/student_list' |
|||
return http.get(url, data).then(res => { |
|||
return res; |
|||
}) |
|||
// 更新学员信息
|
|||
async updateStudentInfo(data = {}) { |
|||
return await http.put('/student/update', data); |
|||
}, |
|||
|
|||
//教练端-获取班级详情
|
|||
jlClassInfo(data = {}) { |
|||
let url = '/member/class_info' |
|||
return http.get(url, data).then(res => { |
|||
return res; |
|||
}) |
|||
// 上传学员头像
|
|||
async uploadStudentAvatar(data = {}) { |
|||
return await http.post('/student/avatar', data); |
|||
}, |
|||
|
|||
//教练端-获取班级列表
|
|||
jlClassList(data = {}) { |
|||
let url = '/member/class_list' |
|||
return http.get(url, data).then(res => { |
|||
return res; |
|||
}) |
|||
// 获取未读消息数量(如果有接口的话,暂时模拟)
|
|||
async getUnreadMessageCount(data = {}) { |
|||
// 这里可以调用真实的消息接口,暂时返回模拟数据
|
|||
return { |
|||
code: 1, |
|||
data: { |
|||
unread_count: Math.floor(Math.random() * 5) |
|||
} |
|||
}; |
|||
}, |
|||
|
|||
//教练端-获取学员详情
|
|||
jlStudentsInfo(data = {}) { |
|||
let url = '/member/students_info' |
|||
return http.get(url, data).then(res => { |
|||
return res; |
|||
}) |
|||
}, |
|||
|
|||
//教练端-评测详情
|
|||
jlSurveyInfo(data = {}) { |
|||
let url = '/member/survey_info' |
|||
return http.get(url, data).then(res => { |
|||
return res; |
|||
}) |
|||
}, |
|||
|
|||
//教练端-授课统计
|
|||
jlSktj(data = {}) { |
|||
let url = '/member/sktj' |
|||
return http.get(url, data).then(res => { |
|||
return res; |
|||
}) |
|||
}, |
|||
|
|||
//教练端-作业列表
|
|||
jsGetAssignmentsList(data = {}) { |
|||
let url = '/member/get_assignments_list' |
|||
return http.get(url, data).then(res => { |
|||
return res; |
|||
}) |
|||
}, |
|||
|
|||
|
|||
|
|||
|
|||
|
|||
|
|||
|
|||
|
|||
|
|||
|
|||
|
|||
|
|||
|
|||
|
|||
|
|||
|
|||
|
|||
|
|||
} |
|||
@ -0,0 +1,988 @@ |
|||
<!--学员合同管理页面--> |
|||
<template> |
|||
<view class="main_box"> |
|||
<!-- 自定义导航栏 --> |
|||
<view class="navbar_section"> |
|||
<view class="navbar_back" @click="goBack"> |
|||
<text class="back_icon">‹</text> |
|||
</view> |
|||
<view class="navbar_title">合同管理</view> |
|||
<view class="navbar_action"></view> |
|||
</view> |
|||
|
|||
<!-- 学员信息 --> |
|||
<view class="student_info_section" v-if="studentInfo"> |
|||
<view class="student_name">{{ studentInfo.name }}</view> |
|||
<view class="contract_stats"> |
|||
<text class="stat_item">有效合同:{{ contractStats.active_contracts }}个</text> |
|||
<text class="stat_item">剩余课时:{{ contractStats.remaining_hours }}节</text> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 合同状态筛选 --> |
|||
<view class="filter_section"> |
|||
<view class="filter_tabs"> |
|||
<view |
|||
v-for="tab in statusTabs" |
|||
:key="tab.value" |
|||
:class="['filter_tab', activeStatus === tab.value ? 'active' : '']" |
|||
@click="changeStatus(tab.value)" |
|||
> |
|||
{{ tab.text }} |
|||
<view class="tab_badge" v-if="tab.count > 0">{{ tab.count }}</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 合同列表 --> |
|||
<view class="contracts_section"> |
|||
<view v-if="loading" class="loading_section"> |
|||
<view class="loading_text">加载中...</view> |
|||
</view> |
|||
|
|||
<view v-else-if="filteredContracts.length === 0" class="empty_section"> |
|||
<view class="empty_icon">📋</view> |
|||
<view class="empty_text">暂无合同</view> |
|||
<view class="empty_hint">签署合同后会在这里显示</view> |
|||
</view> |
|||
|
|||
<view v-else class="contracts_list"> |
|||
<view |
|||
v-for="contract in filteredContracts" |
|||
:key="contract.id" |
|||
class="contract_item" |
|||
@click="viewContractDetail(contract)" |
|||
> |
|||
<view class="contract_header"> |
|||
<view class="contract_info"> |
|||
<view class="contract_name">{{ contract.contract_name }}</view> |
|||
<view class="contract_number">合同编号:{{ contract.contract_no }}</view> |
|||
</view> |
|||
<view class="contract_status" :class="contract.status"> |
|||
{{ getStatusText(contract.status) }} |
|||
</view> |
|||
</view> |
|||
|
|||
<view class="contract_content"> |
|||
<view class="contract_details"> |
|||
<view class="detail_row"> |
|||
<text class="detail_label">课程类型:</text> |
|||
<text class="detail_value">{{ contract.course_type }}</text> |
|||
</view> |
|||
<view class="detail_row"> |
|||
<text class="detail_label">总课时:</text> |
|||
<text class="detail_value">{{ contract.total_hours }}节</text> |
|||
</view> |
|||
<view class="detail_row"> |
|||
<text class="detail_label">剩余课时:</text> |
|||
<text class="detail_value remaining">{{ contract.remaining_hours }}节</text> |
|||
</view> |
|||
<view class="detail_row"> |
|||
<text class="detail_label">合同金额:</text> |
|||
<text class="detail_value amount">¥{{ contract.total_amount }}</text> |
|||
</view> |
|||
</view> |
|||
|
|||
<view class="contract_dates"> |
|||
<view class="date_row"> |
|||
<text class="date_label">签署日期:</text> |
|||
<text class="date_value">{{ formatDate(contract.sign_date) }}</text> |
|||
</view> |
|||
<view class="date_row"> |
|||
<text class="date_label">生效日期:</text> |
|||
<text class="date_value">{{ formatDate(contract.start_date) }}</text> |
|||
</view> |
|||
<view class="date_row"> |
|||
<text class="date_label">到期日期:</text> |
|||
<text class="date_value">{{ formatDate(contract.end_date) }}</text> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<view class="contract_progress" v-if="contract.status === 'active'"> |
|||
<view class="progress_info"> |
|||
<text class="progress_text">课时使用进度</text> |
|||
<text class="progress_percent">{{ getProgressPercent(contract) }}%</text> |
|||
</view> |
|||
<view class="progress_bar"> |
|||
<view class="progress_fill" :style="{ width: getProgressPercent(contract) + '%' }"></view> |
|||
</view> |
|||
</view> |
|||
|
|||
<view class="contract_actions"> |
|||
<fui-button |
|||
v-if="contract.status === 'active'" |
|||
background="transparent" |
|||
color="#29d3b4" |
|||
size="small" |
|||
@click.stop="viewContractDetail(contract)" |
|||
> |
|||
查看详情 |
|||
</fui-button> |
|||
|
|||
<fui-button |
|||
v-if="contract.status === 'active' && contract.can_renew" |
|||
background="#29d3b4" |
|||
size="small" |
|||
@click.stop="renewContract(contract)" |
|||
> |
|||
续约 |
|||
</fui-button> |
|||
|
|||
<fui-button |
|||
v-if="contract.status === 'pending'" |
|||
background="#f39c12" |
|||
size="small" |
|||
@click.stop="signContract(contract)" |
|||
> |
|||
签署合同 |
|||
</fui-button> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 加载更多 --> |
|||
<view class="load_more_section" v-if="!loading && hasMore"> |
|||
<fui-button |
|||
background="transparent" |
|||
color="#666" |
|||
@click="loadMoreContracts" |
|||
:loading="loadingMore" |
|||
> |
|||
{{ loadingMore ? '加载中...' : '加载更多' }} |
|||
</fui-button> |
|||
</view> |
|||
|
|||
<!-- 合同详情弹窗 --> |
|||
<view class="contract_popup" v-if="showContractPopup" @click="closeContractPopup"> |
|||
<view class="popup_content" @click.stop> |
|||
<view class="popup_header"> |
|||
<view class="popup_title">合同详情</view> |
|||
<view class="popup_close" @click="closeContractPopup">×</view> |
|||
</view> |
|||
|
|||
<view class="popup_contract_detail" v-if="selectedContract"> |
|||
<view class="detail_section"> |
|||
<view class="section_title">基本信息</view> |
|||
<view class="info_grid"> |
|||
<view class="info_row"> |
|||
<text class="info_label">合同名称:</text> |
|||
<text class="info_value">{{ selectedContract.contract_name }}</text> |
|||
</view> |
|||
<view class="info_row"> |
|||
<text class="info_label">合同编号:</text> |
|||
<text class="info_value">{{ selectedContract.contract_no }}</text> |
|||
</view> |
|||
<view class="info_row"> |
|||
<text class="info_label">课程类型:</text> |
|||
<text class="info_value">{{ selectedContract.course_type }}</text> |
|||
</view> |
|||
<view class="info_row"> |
|||
<text class="info_label">总课时:</text> |
|||
<text class="info_value">{{ selectedContract.total_hours }}节</text> |
|||
</view> |
|||
<view class="info_row"> |
|||
<text class="info_label">剩余课时:</text> |
|||
<text class="info_value">{{ selectedContract.remaining_hours }}节</text> |
|||
</view> |
|||
<view class="info_row"> |
|||
<text class="info_label">合同金额:</text> |
|||
<text class="info_value">¥{{ selectedContract.total_amount }}</text> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<view class="detail_section"> |
|||
<view class="section_title">时间信息</view> |
|||
<view class="info_grid"> |
|||
<view class="info_row"> |
|||
<text class="info_label">签署日期:</text> |
|||
<text class="info_value">{{ formatFullDate(selectedContract.sign_date) }}</text> |
|||
</view> |
|||
<view class="info_row"> |
|||
<text class="info_label">生效日期:</text> |
|||
<text class="info_value">{{ formatFullDate(selectedContract.start_date) }}</text> |
|||
</view> |
|||
<view class="info_row"> |
|||
<text class="info_label">到期日期:</text> |
|||
<text class="info_value">{{ formatFullDate(selectedContract.end_date) }}</text> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<view class="detail_section" v-if="selectedContract.terms"> |
|||
<view class="section_title">合同条款</view> |
|||
<view class="contract_terms">{{ selectedContract.terms }}</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<view class="popup_actions"> |
|||
<fui-button |
|||
v-if="selectedContract && selectedContract.contract_file_url" |
|||
background="#3498db" |
|||
@click="downloadContract" |
|||
> |
|||
下载合同 |
|||
</fui-button> |
|||
|
|||
<fui-button |
|||
background="#f8f9fa" |
|||
color="#666" |
|||
@click="closeContractPopup" |
|||
> |
|||
关闭 |
|||
</fui-button> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</template> |
|||
|
|||
<script> |
|||
import apiRoute from '@/api/apiRoute.js' |
|||
|
|||
export default { |
|||
data() { |
|||
return { |
|||
studentId: 0, |
|||
studentInfo: {}, |
|||
contractsList: [], |
|||
filteredContracts: [], |
|||
contractStats: {}, |
|||
loading: false, |
|||
loadingMore: false, |
|||
hasMore: true, |
|||
currentPage: 1, |
|||
activeStatus: 'all', |
|||
showContractPopup: false, |
|||
selectedContract: null, |
|||
statusTabs: [ |
|||
{ value: 'all', text: '全部', count: 0 }, |
|||
{ value: 'active', text: '生效中', count: 0 }, |
|||
{ value: 'pending', text: '待签署', count: 0 }, |
|||
{ value: 'expired', text: '已到期', count: 0 }, |
|||
{ value: 'terminated', text: '已终止', count: 0 } |
|||
] |
|||
} |
|||
}, |
|||
|
|||
onLoad(options) { |
|||
this.studentId = parseInt(options.student_id) || 0 |
|||
if (this.studentId) { |
|||
this.initPage() |
|||
} else { |
|||
uni.showToast({ |
|||
title: '参数错误', |
|||
icon: 'none' |
|||
}) |
|||
} |
|||
}, |
|||
|
|||
methods: { |
|||
goBack() { |
|||
uni.navigateBack() |
|||
}, |
|||
|
|||
async initPage() { |
|||
await this.loadStudentInfo() |
|||
await this.loadContracts() |
|||
this.updateStatusCounts() |
|||
}, |
|||
|
|||
async loadStudentInfo() { |
|||
try { |
|||
// 模拟获取学员信息 |
|||
const mockStudentInfo = { |
|||
id: this.studentId, |
|||
name: '小明' |
|||
} |
|||
this.studentInfo = mockStudentInfo |
|||
} catch (error) { |
|||
console.error('获取学员信息失败:', error) |
|||
} |
|||
}, |
|||
|
|||
async loadContracts() { |
|||
this.loading = true |
|||
try { |
|||
console.log('加载合同列表:', this.studentId) |
|||
|
|||
// 模拟API调用 |
|||
// const response = await apiRoute.getStudentContracts({ |
|||
// student_id: this.studentId, |
|||
// page: this.currentPage, |
|||
// limit: 10 |
|||
// }) |
|||
|
|||
// 使用模拟数据 |
|||
const mockResponse = { |
|||
code: 1, |
|||
data: { |
|||
list: [ |
|||
{ |
|||
id: 1, |
|||
contract_no: 'HT202401150001', |
|||
contract_name: '少儿体适能训练合同', |
|||
course_type: '少儿体适能', |
|||
total_hours: 48, |
|||
remaining_hours: 32, |
|||
used_hours: 16, |
|||
total_amount: '4800.00', |
|||
status: 'active', |
|||
sign_date: '2024-01-15', |
|||
start_date: '2024-01-20', |
|||
end_date: '2024-07-20', |
|||
can_renew: true, |
|||
contract_file_url: '/uploads/contracts/contract_001.pdf', |
|||
terms: '1. 本合同自签署之日起生效\n2. 学员应按时参加课程\n3. 如需请假,请提前24小时通知\n4. 课程有效期为6个月\n5. 未使用完的课时可申请延期' |
|||
}, |
|||
{ |
|||
id: 2, |
|||
contract_no: 'HT202312100002', |
|||
contract_name: '基础体能训练合同', |
|||
course_type: '基础体能', |
|||
total_hours: 24, |
|||
remaining_hours: 0, |
|||
used_hours: 24, |
|||
total_amount: '2400.00', |
|||
status: 'expired', |
|||
sign_date: '2023-12-10', |
|||
start_date: '2023-12-15', |
|||
end_date: '2024-01-15', |
|||
can_renew: false, |
|||
contract_file_url: '/uploads/contracts/contract_002.pdf', |
|||
terms: '已到期的合同条款...' |
|||
}, |
|||
{ |
|||
id: 3, |
|||
contract_no: 'HT202401200003', |
|||
contract_name: '专项技能训练合同', |
|||
course_type: '专项技能', |
|||
total_hours: 36, |
|||
remaining_hours: 36, |
|||
used_hours: 0, |
|||
total_amount: '3600.00', |
|||
status: 'pending', |
|||
sign_date: null, |
|||
start_date: '2024-02-01', |
|||
end_date: '2024-08-01', |
|||
can_renew: false, |
|||
contract_file_url: '/uploads/contracts/contract_003.pdf', |
|||
terms: '待签署的合同条款...' |
|||
} |
|||
], |
|||
total: 3, |
|||
has_more: false, |
|||
stats: { |
|||
active_contracts: 1, |
|||
remaining_hours: 32, |
|||
total_amount: '4800.00' |
|||
} |
|||
} |
|||
} |
|||
|
|||
if (mockResponse.code === 1) { |
|||
const newList = mockResponse.data.list || [] |
|||
if (this.currentPage === 1) { |
|||
this.contractsList = newList |
|||
} else { |
|||
this.contractsList = [...this.contractsList, ...newList] |
|||
} |
|||
|
|||
this.hasMore = mockResponse.data.has_more || false |
|||
this.contractStats = mockResponse.data.stats || {} |
|||
this.applyStatusFilter() |
|||
console.log('合同数据加载成功:', this.contractsList) |
|||
} else { |
|||
uni.showToast({ |
|||
title: mockResponse.msg || '获取合同列表失败', |
|||
icon: 'none' |
|||
}) |
|||
} |
|||
} catch (error) { |
|||
console.error('获取合同列表失败:', error) |
|||
uni.showToast({ |
|||
title: '获取合同列表失败', |
|||
icon: 'none' |
|||
}) |
|||
} finally { |
|||
this.loading = false |
|||
this.loadingMore = false |
|||
} |
|||
}, |
|||
|
|||
async loadMoreContracts() { |
|||
if (this.loadingMore || !this.hasMore) return |
|||
|
|||
this.loadingMore = true |
|||
this.currentPage++ |
|||
await this.loadContracts() |
|||
}, |
|||
|
|||
changeStatus(status) { |
|||
this.activeStatus = status |
|||
this.applyStatusFilter() |
|||
}, |
|||
|
|||
applyStatusFilter() { |
|||
if (this.activeStatus === 'all') { |
|||
this.filteredContracts = [...this.contractsList] |
|||
} else { |
|||
this.filteredContracts = this.contractsList.filter(contract => contract.status === this.activeStatus) |
|||
} |
|||
}, |
|||
|
|||
updateStatusCounts() { |
|||
const counts = {} |
|||
this.contractsList.forEach(contract => { |
|||
counts[contract.status] = (counts[contract.status] || 0) + 1 |
|||
}) |
|||
|
|||
this.statusTabs.forEach(tab => { |
|||
if (tab.value === 'all') { |
|||
tab.count = this.contractsList.length |
|||
} else { |
|||
tab.count = counts[tab.value] || 0 |
|||
} |
|||
}) |
|||
}, |
|||
|
|||
getStatusText(status) { |
|||
const statusMap = { |
|||
'active': '生效中', |
|||
'pending': '待签署', |
|||
'expired': '已到期', |
|||
'terminated': '已终止' |
|||
} |
|||
return statusMap[status] || status |
|||
}, |
|||
|
|||
getProgressPercent(contract) { |
|||
if (contract.total_hours === 0) return 0 |
|||
return Math.round((contract.used_hours / contract.total_hours) * 100) |
|||
}, |
|||
|
|||
formatDate(dateString) { |
|||
if (!dateString) return '未设置' |
|||
const date = new Date(dateString) |
|||
const month = String(date.getMonth() + 1).padStart(2, '0') |
|||
const day = String(date.getDate()).padStart(2, '0') |
|||
return `${month}-${day}` |
|||
}, |
|||
|
|||
formatFullDate(dateString) { |
|||
if (!dateString) return '未设置' |
|||
const date = new Date(dateString) |
|||
const year = date.getFullYear() |
|||
const month = String(date.getMonth() + 1).padStart(2, '0') |
|||
const day = String(date.getDate()).padStart(2, '0') |
|||
return `${year}-${month}-${day}` |
|||
}, |
|||
|
|||
viewContractDetail(contract) { |
|||
this.selectedContract = contract |
|||
this.showContractPopup = true |
|||
}, |
|||
|
|||
closeContractPopup() { |
|||
this.showContractPopup = false |
|||
this.selectedContract = null |
|||
}, |
|||
|
|||
async renewContract(contract) { |
|||
uni.showModal({ |
|||
title: '确认续约', |
|||
content: '确定要续约此合同吗?', |
|||
success: async (res) => { |
|||
if (res.confirm) { |
|||
try { |
|||
console.log('续约合同:', contract.id) |
|||
|
|||
// 模拟API调用 |
|||
await new Promise(resolve => setTimeout(resolve, 1000)) |
|||
const mockResponse = { code: 1, message: '续约申请已提交' } |
|||
|
|||
if (mockResponse.code === 1) { |
|||
uni.showToast({ |
|||
title: '续约申请已提交', |
|||
icon: 'success' |
|||
}) |
|||
} else { |
|||
uni.showToast({ |
|||
title: mockResponse.message || '续约申请失败', |
|||
icon: 'none' |
|||
}) |
|||
} |
|||
} catch (error) { |
|||
console.error('续约申请失败:', error) |
|||
uni.showToast({ |
|||
title: '续约申请失败', |
|||
icon: 'none' |
|||
}) |
|||
} |
|||
} |
|||
} |
|||
}) |
|||
}, |
|||
|
|||
async signContract(contract) { |
|||
uni.showModal({ |
|||
title: '确认签署', |
|||
content: '确定要签署此合同吗?', |
|||
success: async (res) => { |
|||
if (res.confirm) { |
|||
try { |
|||
console.log('签署合同:', contract.id) |
|||
|
|||
// 模拟API调用 |
|||
await new Promise(resolve => setTimeout(resolve, 1500)) |
|||
const mockResponse = { code: 1, message: '合同签署成功' } |
|||
|
|||
if (mockResponse.code === 1) { |
|||
uni.showToast({ |
|||
title: '合同签署成功', |
|||
icon: 'success' |
|||
}) |
|||
|
|||
// 更新合同状态 |
|||
const contractIndex = this.contractsList.findIndex(c => c.id === contract.id) |
|||
if (contractIndex !== -1) { |
|||
this.contractsList[contractIndex].status = 'active' |
|||
this.contractsList[contractIndex].sign_date = new Date().toISOString().split('T')[0] |
|||
} |
|||
|
|||
this.applyStatusFilter() |
|||
this.updateStatusCounts() |
|||
} else { |
|||
uni.showToast({ |
|||
title: mockResponse.message || '合同签署失败', |
|||
icon: 'none' |
|||
}) |
|||
} |
|||
} catch (error) { |
|||
console.error('合同签署失败:', error) |
|||
uni.showToast({ |
|||
title: '合同签署失败', |
|||
icon: 'none' |
|||
}) |
|||
} |
|||
} |
|||
} |
|||
}) |
|||
}, |
|||
|
|||
downloadContract() { |
|||
if (!this.selectedContract || !this.selectedContract.contract_file_url) { |
|||
uni.showToast({ |
|||
title: '合同文件不存在', |
|||
icon: 'none' |
|||
}) |
|||
return |
|||
} |
|||
|
|||
uni.showModal({ |
|||
title: '提示', |
|||
content: '合同下载功能开发中', |
|||
showCancel: false |
|||
}) |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style lang="less" scoped> |
|||
.main_box { |
|||
background: #f8f9fa; |
|||
min-height: 100vh; |
|||
} |
|||
|
|||
// 自定义导航栏 |
|||
.navbar_section { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
background: #29D3B4; |
|||
padding: 40rpx 32rpx 20rpx; |
|||
|
|||
// 小程序端适配状态栏 |
|||
// #ifdef MP-WEIXIN |
|||
padding-top: 80rpx; |
|||
// #endif |
|||
|
|||
.navbar_back { |
|||
width: 60rpx; |
|||
|
|||
.back_icon { |
|||
color: #fff; |
|||
font-size: 40rpx; |
|||
font-weight: 600; |
|||
} |
|||
} |
|||
|
|||
.navbar_title { |
|||
color: #fff; |
|||
font-size: 32rpx; |
|||
font-weight: 600; |
|||
} |
|||
|
|||
.navbar_action { |
|||
width: 60rpx; |
|||
} |
|||
} |
|||
|
|||
// 学员信息 |
|||
.student_info_section { |
|||
background: #fff; |
|||
padding: 24rpx 32rpx; |
|||
border-bottom: 1px solid #f0f0f0; |
|||
|
|||
.student_name { |
|||
font-size: 32rpx; |
|||
font-weight: 600; |
|||
color: #333; |
|||
margin-bottom: 12rpx; |
|||
} |
|||
|
|||
.contract_stats { |
|||
display: flex; |
|||
gap: 24rpx; |
|||
|
|||
.stat_item { |
|||
font-size: 24rpx; |
|||
color: #666; |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 状态筛选 |
|||
.filter_section { |
|||
background: #fff; |
|||
margin: 20rpx; |
|||
border-radius: 16rpx; |
|||
padding: 24rpx 32rpx; |
|||
|
|||
.filter_tabs { |
|||
display: flex; |
|||
flex-wrap: wrap; |
|||
gap: 12rpx; |
|||
|
|||
.filter_tab { |
|||
position: relative; |
|||
padding: 10rpx 20rpx; |
|||
font-size: 24rpx; |
|||
color: #666; |
|||
background: #f8f9fa; |
|||
border-radius: 16rpx; |
|||
|
|||
&.active { |
|||
color: #fff; |
|||
background: #29D3B4; |
|||
} |
|||
|
|||
.tab_badge { |
|||
position: absolute; |
|||
top: -6rpx; |
|||
right: -6rpx; |
|||
background: #ff4757; |
|||
color: #fff; |
|||
font-size: 18rpx; |
|||
padding: 2rpx 6rpx; |
|||
border-radius: 10rpx; |
|||
min-width: 16rpx; |
|||
text-align: center; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 合同列表 |
|||
.contracts_section { |
|||
margin: 0 20rpx; |
|||
|
|||
.loading_section, .empty_section { |
|||
background: #fff; |
|||
border-radius: 16rpx; |
|||
padding: 80rpx 32rpx; |
|||
text-align: center; |
|||
|
|||
.loading_text, .empty_text { |
|||
font-size: 28rpx; |
|||
color: #666; |
|||
margin-bottom: 12rpx; |
|||
} |
|||
|
|||
.empty_icon { |
|||
font-size: 80rpx; |
|||
margin-bottom: 24rpx; |
|||
} |
|||
|
|||
.empty_hint { |
|||
font-size: 24rpx; |
|||
color: #999; |
|||
} |
|||
} |
|||
|
|||
.contracts_list { |
|||
.contract_item { |
|||
background: #fff; |
|||
border-radius: 16rpx; |
|||
padding: 24rpx; |
|||
margin-bottom: 20rpx; |
|||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08); |
|||
|
|||
.contract_header { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: flex-start; |
|||
margin-bottom: 20rpx; |
|||
|
|||
.contract_info { |
|||
flex: 1; |
|||
|
|||
.contract_name { |
|||
font-size: 28rpx; |
|||
font-weight: 600; |
|||
color: #333; |
|||
margin-bottom: 8rpx; |
|||
} |
|||
|
|||
.contract_number { |
|||
font-size: 24rpx; |
|||
color: #666; |
|||
} |
|||
} |
|||
|
|||
.contract_status { |
|||
font-size: 22rpx; |
|||
padding: 6rpx 12rpx; |
|||
border-radius: 12rpx; |
|||
|
|||
&.active { |
|||
color: #27ae60; |
|||
background: rgba(39, 174, 96, 0.1); |
|||
} |
|||
|
|||
&.pending { |
|||
color: #f39c12; |
|||
background: rgba(243, 156, 18, 0.1); |
|||
} |
|||
|
|||
&.expired { |
|||
color: #e74c3c; |
|||
background: rgba(231, 76, 60, 0.1); |
|||
} |
|||
|
|||
&.terminated { |
|||
color: #95a5a6; |
|||
background: rgba(149, 165, 166, 0.1); |
|||
} |
|||
} |
|||
} |
|||
|
|||
.contract_content { |
|||
display: flex; |
|||
gap: 32rpx; |
|||
margin-bottom: 20rpx; |
|||
|
|||
.contract_details { |
|||
flex: 1; |
|||
|
|||
.detail_row { |
|||
display: flex; |
|||
margin-bottom: 8rpx; |
|||
|
|||
.detail_label { |
|||
font-size: 24rpx; |
|||
color: #666; |
|||
min-width: 120rpx; |
|||
} |
|||
|
|||
.detail_value { |
|||
font-size: 24rpx; |
|||
color: #333; |
|||
|
|||
&.remaining { |
|||
color: #29D3B4; |
|||
font-weight: 600; |
|||
} |
|||
|
|||
&.amount { |
|||
color: #e74c3c; |
|||
font-weight: 600; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
.contract_dates { |
|||
.date_row { |
|||
display: flex; |
|||
margin-bottom: 8rpx; |
|||
|
|||
.date_label { |
|||
font-size: 22rpx; |
|||
color: #999; |
|||
min-width: 100rpx; |
|||
} |
|||
|
|||
.date_value { |
|||
font-size: 22rpx; |
|||
color: #666; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
.contract_progress { |
|||
margin-bottom: 20rpx; |
|||
|
|||
.progress_info { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
margin-bottom: 8rpx; |
|||
|
|||
.progress_text { |
|||
font-size: 22rpx; |
|||
color: #666; |
|||
} |
|||
|
|||
.progress_percent { |
|||
font-size: 22rpx; |
|||
color: #29D3B4; |
|||
font-weight: 600; |
|||
} |
|||
} |
|||
|
|||
.progress_bar { |
|||
height: 8rpx; |
|||
background: #f0f0f0; |
|||
border-radius: 4rpx; |
|||
overflow: hidden; |
|||
|
|||
.progress_fill { |
|||
height: 100%; |
|||
background: linear-gradient(90deg, #29D3B4, #26c6a0); |
|||
border-radius: 4rpx; |
|||
transition: width 0.3s ease; |
|||
} |
|||
} |
|||
} |
|||
|
|||
.contract_actions { |
|||
display: flex; |
|||
gap: 16rpx; |
|||
justify-content: flex-end; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 加载更多 |
|||
.load_more_section { |
|||
padding: 40rpx 20rpx 80rpx; |
|||
} |
|||
|
|||
// 合同详情弹窗 |
|||
.contract_popup { |
|||
position: fixed; |
|||
top: 0; |
|||
left: 0; |
|||
right: 0; |
|||
bottom: 0; |
|||
background: rgba(0, 0, 0, 0.5); |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
z-index: 1000; |
|||
|
|||
.popup_content { |
|||
background: #fff; |
|||
border-radius: 16rpx; |
|||
width: 90%; |
|||
max-height: 80vh; |
|||
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 { |
|||
font-size: 48rpx; |
|||
color: #999; |
|||
font-weight: 300; |
|||
} |
|||
} |
|||
|
|||
.popup_contract_detail { |
|||
padding: 32rpx; |
|||
max-height: 60vh; |
|||
overflow-y: auto; |
|||
|
|||
.detail_section { |
|||
margin-bottom: 32rpx; |
|||
|
|||
&:last-child { |
|||
margin-bottom: 0; |
|||
} |
|||
|
|||
.section_title { |
|||
font-size: 28rpx; |
|||
font-weight: 600; |
|||
color: #333; |
|||
margin-bottom: 16rpx; |
|||
padding-bottom: 8rpx; |
|||
border-bottom: 2rpx solid #29D3B4; |
|||
} |
|||
|
|||
.info_grid { |
|||
.info_row { |
|||
display: flex; |
|||
margin-bottom: 12rpx; |
|||
|
|||
.info_label { |
|||
font-size: 26rpx; |
|||
color: #666; |
|||
min-width: 120rpx; |
|||
} |
|||
|
|||
.info_value { |
|||
font-size: 26rpx; |
|||
color: #333; |
|||
flex: 1; |
|||
} |
|||
} |
|||
} |
|||
|
|||
.contract_terms { |
|||
font-size: 24rpx; |
|||
color: #666; |
|||
line-height: 1.6; |
|||
white-space: pre-line; |
|||
} |
|||
} |
|||
} |
|||
|
|||
.popup_actions { |
|||
padding: 24rpx 32rpx; |
|||
display: flex; |
|||
gap: 16rpx; |
|||
border-top: 1px solid #f0f0f0; |
|||
|
|||
fui-button { |
|||
flex: 1; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
</style> |
|||
File diff suppressed because it is too large
@ -0,0 +1,773 @@ |
|||
<!--学员端首页落地页--> |
|||
<template> |
|||
<view class="main_box"> |
|||
<!-- 自定义导航栏 --> |
|||
<view class="navbar_section"> |
|||
<view class="welcome_info"> |
|||
<view class="welcome_text">{{ welcomeText }}</view> |
|||
<view class="date_info">今天是{{ currentWeekDay }}</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 用户基本信息区域 --> |
|||
<view class="user_info_section" v-if="userInfo"> |
|||
<view class="user_details"> |
|||
<view class="user_name">{{ userInfo.name || '家长' }} 你好</view> |
|||
<view class="user_meta"> |
|||
<text class="meta_item">入会时间:{{ userInfo.create_year_month || '2024年01月' }}</text> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 学员选择卡片 --> |
|||
<view class="student_selector_section"> |
|||
<view class="selector_header"> |
|||
<view class="header_title">我的孩子</view> |
|||
<view class="switch_button" @click="openStudentPopup" v-if="studentList.length >= 1"> |
|||
{{ studentList.length > 1 ? '切换' : '学员' }} ({{ studentList.length }}) |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 当前选中学员信息 --> |
|||
<view class="selected_student_card" v-if="selectedStudent"> |
|||
<view class="student_avatar"> |
|||
<image |
|||
:src="selectedStudent.headimg || '/static/default-avatar.png'" |
|||
class="avatar_image" |
|||
mode="aspectFill" |
|||
></image> |
|||
</view> |
|||
<view class="student_info"> |
|||
<view class="student_name">{{ selectedStudent.name }}</view> |
|||
<view class="student_details"> |
|||
<text class="detail_tag">{{ selectedStudent.gender_text }}</text> |
|||
<text class="detail_tag">{{ selectedStudent.age }}岁</text> |
|||
</view> |
|||
</view> |
|||
<view class="student_stats"> |
|||
<view class="stat_item"> |
|||
<view class="stat_number">{{ selectedStudent.total_courses || 0 }}</view> |
|||
<view class="stat_label">总课程</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 没有学员时的提示 --> |
|||
<view class="no_student_card" v-else> |
|||
<view class="no_student_text">暂无学员信息</view> |
|||
<view class="no_student_hint">请联系管理员添加学员</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 功能入口区域 --> |
|||
<view class="functions_section"> |
|||
<view class="section_title">功能服务</view> |
|||
<view class="functions_grid"> |
|||
<view class="function_item" @click="navigateToProfile"> |
|||
<view class="function_icon profile_icon"> |
|||
<image src="/static/icon-img/profile.png" class="icon_image"></image> |
|||
</view> |
|||
<view class="function_text">个人信息管理</view> |
|||
</view> |
|||
|
|||
<view class="function_item" @click="navigateToPhysicalTest"> |
|||
<view class="function_icon physical_icon"> |
|||
<image src="/static/icon-img/chart.png" class="icon_image"></image> |
|||
</view> |
|||
<view class="function_text">体测数据</view> |
|||
</view> |
|||
|
|||
<view class="function_item" @click="navigateToCourseSchedule"> |
|||
<view class="function_icon schedule_icon"> |
|||
<image src="/static/icon-img/calendar.png" class="icon_image"></image> |
|||
</view> |
|||
<view class="function_text">课程安排</view> |
|||
</view> |
|||
|
|||
<view class="function_item" @click="navigateToCourseBooking"> |
|||
<view class="function_icon booking_icon"> |
|||
<image src="/static/icon-img/book.png" class="icon_image"></image> |
|||
</view> |
|||
<view class="function_text">课程预约</view> |
|||
</view> |
|||
|
|||
<view class="function_item" @click="navigateToOrders"> |
|||
<view class="function_icon order_icon"> |
|||
<image src="/static/icon-img/order.png" class="icon_image"></image> |
|||
</view> |
|||
<view class="function_text">订单管理</view> |
|||
</view> |
|||
|
|||
<view class="function_item" @click="navigateToContracts"> |
|||
<view class="function_icon contract_icon"> |
|||
<image src="/static/icon-img/contract.png" class="icon_image"></image> |
|||
</view> |
|||
<view class="function_text">合同管理</view> |
|||
</view> |
|||
|
|||
<view class="function_item" @click="navigateToKnowledge"> |
|||
<view class="function_icon knowledge_icon"> |
|||
<image src="/static/icon-img/book-open.png" class="icon_image"></image> |
|||
</view> |
|||
<view class="function_text">知识库</view> |
|||
</view> |
|||
|
|||
<view class="function_item" @click="navigateToMessages"> |
|||
<view class="function_icon message_icon"> |
|||
<image src="/static/icon-img/message.png" class="icon_image"></image> |
|||
</view> |
|||
<view class="function_text">消息管理</view> |
|||
<view class="message_badge" v-if="unreadCount > 0">{{ unreadCount }}</view> |
|||
</view> |
|||
|
|||
<view class="function_item" @click="navigateToSettings"> |
|||
<view class="function_icon settings_icon"> |
|||
<image src="/static/icon-img/settings.png" class="icon_image"></image> |
|||
</view> |
|||
<view class="function_text">系统设置</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 学员选择弹窗 --> |
|||
<view class="student_popup" v-if="showStudentPopup" @click="closeStudentPopup"> |
|||
<view class="popup_content" @click.stop> |
|||
<view class="popup_header"> |
|||
<view class="popup_title">选择学员</view> |
|||
<view class="popup_close" @click="closeStudentPopup">×</view> |
|||
</view> |
|||
<view class="popup_student_list"> |
|||
<view |
|||
v-for="student in studentList" |
|||
:key="student.id" |
|||
:class="['popup_student_item', selectedStudent && selectedStudent.id === student.id ? 'selected' : '']" |
|||
@click="selectStudentFromPopup(student)" |
|||
> |
|||
<view class="popup_student_avatar"> |
|||
<image |
|||
:src="student.headimg || '/static/default-avatar.png'" |
|||
class="popup_avatar_image" |
|||
mode="aspectFill" |
|||
></image> |
|||
</view> |
|||
<view class="popup_student_info"> |
|||
<view class="popup_student_name">{{ student.name }}</view> |
|||
<view class="popup_student_details"> |
|||
<text class="popup_detail_tag">{{ student.gender_text }}</text> |
|||
<text class="popup_detail_tag">{{ student.age }}岁</text> |
|||
</view> |
|||
</view> |
|||
<view class="popup_select_icon" v-if="selectedStudent && selectedStudent.id === student.id"> |
|||
✓ |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</template> |
|||
|
|||
<script> |
|||
import apiRoute from '@/api/member.js' |
|||
|
|||
export default { |
|||
data() { |
|||
return { |
|||
userInfo: {}, |
|||
studentList: [], |
|||
selectedStudent: null, |
|||
showStudentPopup: false, |
|||
loading: false, |
|||
unreadCount: 0, |
|||
currentWeekDay: '' |
|||
} |
|||
}, |
|||
computed: { |
|||
welcomeText() { |
|||
const hour = new Date().getHours() |
|||
if (hour < 12) { |
|||
return '早上好' |
|||
} else if (hour < 18) { |
|||
return '下午好' |
|||
} else { |
|||
return '晚上好' |
|||
} |
|||
} |
|||
}, |
|||
onLoad() { |
|||
this.initPage() |
|||
}, |
|||
|
|||
onShow() { |
|||
// 页面显示时刷新数据 |
|||
this.refreshData() |
|||
}, |
|||
|
|||
methods: { |
|||
async initPage() { |
|||
this.setCurrentWeekDay() |
|||
await this.loadUserInfo() |
|||
await this.loadStudentList() |
|||
this.loadUnreadMessageCount() |
|||
}, |
|||
|
|||
async refreshData() { |
|||
await this.loadStudentList() |
|||
this.loadUnreadMessageCount() |
|||
}, |
|||
|
|||
setCurrentWeekDay() { |
|||
const weekDays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'] |
|||
const today = new Date() |
|||
this.currentWeekDay = weekDays[today.getDay()] |
|||
}, |
|||
|
|||
async loadUserInfo() { |
|||
try { |
|||
// 从本地存储获取用户信息 |
|||
const userInfo = uni.getStorageSync('userInfo') |
|||
if (userInfo) { |
|||
this.userInfo = userInfo |
|||
// 计算入会年月 |
|||
if (userInfo.create_time) { |
|||
const createDate = new Date(userInfo.create_time) |
|||
this.userInfo.create_year_month = `${createDate.getFullYear()}年${String(createDate.getMonth() + 1).padStart(2, '0')}月` |
|||
} |
|||
} |
|||
} catch (error) { |
|||
console.error('获取用户信息失败:', error) |
|||
} |
|||
}, |
|||
|
|||
async loadStudentList() { |
|||
this.loading = true |
|||
try { |
|||
console.log('开始加载学员列表...') |
|||
|
|||
// 调用真实API |
|||
const response = await apiRoute.getStudentList() |
|||
console.log('学员列表API响应:', response) |
|||
|
|||
if (response.code === 1) { |
|||
this.studentList = response.data.list || [] |
|||
console.log('加载到的学员列表:', this.studentList) |
|||
|
|||
// 如果没有选中的学员且有学员列表,默认选中第一个 |
|||
if (!this.selectedStudent && this.studentList.length > 0) { |
|||
this.selectedStudent = this.studentList[0] |
|||
this.loadSelectedStudentSummary() |
|||
} |
|||
} else { |
|||
console.error('获取学员列表失败:', response) |
|||
uni.showToast({ |
|||
title: response.msg || '获取学员列表失败', |
|||
icon: 'none' |
|||
}) |
|||
} |
|||
} catch (error) { |
|||
console.error('获取学员列表失败:', error) |
|||
uni.showToast({ |
|||
title: '获取学员列表失败', |
|||
icon: 'none' |
|||
}) |
|||
} finally { |
|||
this.loading = false |
|||
} |
|||
}, |
|||
|
|||
async loadSelectedStudentSummary() { |
|||
if (!this.selectedStudent) return |
|||
|
|||
try { |
|||
console.log('加载学员概览信息:', this.selectedStudent.id) |
|||
|
|||
// 调用真实API获取学员概览信息 |
|||
const response = await apiRoute.getStudentSummary(this.selectedStudent.id) |
|||
console.log('学员概览API响应:', response) |
|||
|
|||
if (response.code === 1) { |
|||
// 更新选中学员的概览信息 |
|||
Object.assign(this.selectedStudent, response.data) |
|||
console.log('学员概览加载成功:', response.data) |
|||
} else { |
|||
console.error('获取学员概览失败:', response) |
|||
} |
|||
} catch (error) { |
|||
console.error('获取学员概览失败:', error) |
|||
} |
|||
}, |
|||
|
|||
async loadUnreadMessageCount() { |
|||
try { |
|||
// 调用真实API获取未读消息数量 |
|||
const response = await apiRoute.getUnreadMessageCount() |
|||
console.log('未读消息数量API响应:', response) |
|||
|
|||
if (response.code === 1) { |
|||
this.unreadCount = response.data.unread_count || 0 |
|||
console.log('未读消息数量:', this.unreadCount) |
|||
} |
|||
} catch (error) { |
|||
console.error('获取未读消息数量失败:', error) |
|||
this.unreadCount = 0 |
|||
} |
|||
}, |
|||
|
|||
openStudentPopup() { |
|||
if (this.studentList.length === 0) { |
|||
uni.showToast({ |
|||
title: '暂无学员信息', |
|||
icon: 'none' |
|||
}) |
|||
return |
|||
} |
|||
this.showStudentPopup = true |
|||
}, |
|||
|
|||
closeStudentPopup() { |
|||
this.showStudentPopup = false |
|||
}, |
|||
|
|||
selectStudentFromPopup(student) { |
|||
this.selectedStudent = student |
|||
console.log('选中学员:', student) |
|||
this.loadSelectedStudentSummary() |
|||
this.closeStudentPopup() |
|||
}, |
|||
|
|||
// 导航到各个功能页面 |
|||
navigateToProfile() { |
|||
try { |
|||
if (!this.checkStudentSelected()) return |
|||
const studentId = this.selectedStudent.student_id || this.selectedStudent.id |
|||
console.log('准备跳转到个人信息管理页面, 学员ID:', studentId) |
|||
|
|||
const url = `/pages/student/profile/index?student_id=${studentId}` |
|||
console.log('跳转URL:', url) |
|||
|
|||
uni.navigateTo({ |
|||
url: url |
|||
}) |
|||
} catch (error) { |
|||
console.error('导航方法执行错误:', error) |
|||
uni.showToast({ |
|||
title: '导航出错', |
|||
icon: 'none' |
|||
}) |
|||
} |
|||
}, |
|||
navigateToPhysicalTest() { |
|||
if (!this.checkStudentSelected()) return |
|||
const studentId = this.selectedStudent.student_id || this.selectedStudent.id |
|||
uni.navigateTo({ |
|||
url: `/pages/student/physical-test/index?student_id=${studentId}` |
|||
}) |
|||
}, |
|||
|
|||
navigateToCourseSchedule() { |
|||
if (!this.checkStudentSelected()) return |
|||
const studentId = this.selectedStudent.student_id || this.selectedStudent.id |
|||
uni.navigateTo({ |
|||
url: `/pages/student/schedule/index?student_id=${studentId}` |
|||
}) |
|||
}, |
|||
|
|||
navigateToCourseBooking() { |
|||
if (!this.checkStudentSelected()) return |
|||
const studentId = this.selectedStudent.student_id || this.selectedStudent.id |
|||
uni.navigateTo({ |
|||
url: `/pages/student/course-booking/index?student_id=${studentId}` |
|||
}) |
|||
}, |
|||
|
|||
navigateToOrders() { |
|||
if (!this.checkStudentSelected()) return |
|||
const studentId = this.selectedStudent.student_id || this.selectedStudent.id |
|||
uni.navigateTo({ |
|||
url: `/pages/student/orders/index?student_id=${studentId}` |
|||
}) |
|||
}, |
|||
|
|||
navigateToContracts() { |
|||
if (!this.checkStudentSelected()) return |
|||
const studentId = this.selectedStudent.student_id || this.selectedStudent.id |
|||
uni.navigateTo({ |
|||
url: `/pages/student/contracts/index?student_id=${studentId}` |
|||
}) |
|||
}, |
|||
|
|||
navigateToKnowledge() { |
|||
if (!this.checkStudentSelected()) return |
|||
const studentId = this.selectedStudent.student_id || this.selectedStudent.id |
|||
uni.navigateTo({ |
|||
url: `/pages/student/knowledge/index?student_id=${studentId}` |
|||
}) |
|||
}, |
|||
|
|||
navigateToMessages() { |
|||
if (!this.checkStudentSelected()) return |
|||
const studentId = this.selectedStudent.student_id || this.selectedStudent.id |
|||
uni.navigateTo({ |
|||
url: `/pages/student/messages/index?student_id=${studentId}` |
|||
}) |
|||
}, |
|||
|
|||
navigateToSettings() { |
|||
console.log('跳转到系统设置') |
|||
uni.navigateTo({ |
|||
url: '/pages/student/settings/index' |
|||
}) |
|||
}, |
|||
|
|||
checkStudentSelected() { |
|||
if (!this.selectedStudent) { |
|||
uni.showToast({ |
|||
title: '请先选择学员', |
|||
icon: 'none' |
|||
}) |
|||
return false |
|||
} |
|||
return true |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style lang="less" scoped> |
|||
.main_box { |
|||
background: #f8f9fa; |
|||
min-height: 100vh; |
|||
} |
|||
|
|||
// 自定义导航栏 |
|||
.navbar_section { |
|||
background: linear-gradient(135deg, #29D3B4 0%, #1BA297 100%); |
|||
padding: 40rpx 32rpx 32rpx; |
|||
|
|||
// 小程序端适配状态栏 |
|||
// #ifdef MP-WEIXIN |
|||
padding-top: 80rpx; |
|||
// #endif |
|||
|
|||
.welcome_info { |
|||
color: #fff; |
|||
|
|||
.welcome_text { |
|||
font-size: 36rpx; |
|||
font-weight: 600; |
|||
margin-bottom: 8rpx; |
|||
} |
|||
|
|||
.date_info { |
|||
font-size: 24rpx; |
|||
opacity: 0.9; |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 用户信息区域 |
|||
.user_info_section { |
|||
background: #fff; |
|||
padding: 24rpx 32rpx; |
|||
border-bottom: 1px solid #f0f0f0; |
|||
|
|||
.user_details { |
|||
.user_name { |
|||
font-size: 32rpx; |
|||
font-weight: 600; |
|||
color: #333; |
|||
margin-bottom: 8rpx; |
|||
} |
|||
|
|||
.user_meta { |
|||
.meta_item { |
|||
font-size: 24rpx; |
|||
color: #666; |
|||
margin-right: 24rpx; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 学员选择区域 |
|||
.student_selector_section { |
|||
background: #fff; |
|||
margin: 20rpx; |
|||
border-radius: 16rpx; |
|||
padding: 32rpx; |
|||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08); |
|||
|
|||
.selector_header { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
margin-bottom: 24rpx; |
|||
|
|||
.header_title { |
|||
font-size: 28rpx; |
|||
font-weight: 600; |
|||
color: #333; |
|||
} |
|||
|
|||
.switch_button { |
|||
background: #29d3b4; |
|||
color: #fff; |
|||
padding: 8rpx 16rpx; |
|||
border-radius: 16rpx; |
|||
font-size: 22rpx; |
|||
} |
|||
} |
|||
|
|||
.selected_student_card { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 24rpx; |
|||
|
|||
.student_avatar { |
|||
width: 80rpx; |
|||
height: 80rpx; |
|||
border-radius: 50%; |
|||
overflow: hidden; |
|||
|
|||
.avatar_image { |
|||
width: 100%; |
|||
height: 100%; |
|||
} |
|||
} |
|||
|
|||
.student_info { |
|||
flex: 1; |
|||
|
|||
.student_name { |
|||
font-size: 28rpx; |
|||
font-weight: 600; |
|||
color: #333; |
|||
margin-bottom: 8rpx; |
|||
} |
|||
|
|||
.student_details { |
|||
display: flex; |
|||
gap: 12rpx; |
|||
|
|||
.detail_tag { |
|||
font-size: 22rpx; |
|||
color: #666; |
|||
background: #f0f0f0; |
|||
padding: 4rpx 12rpx; |
|||
border-radius: 12rpx; |
|||
} |
|||
} |
|||
} |
|||
|
|||
.student_stats { |
|||
text-align: center; |
|||
|
|||
.stat_item { |
|||
.stat_number { |
|||
color: #29D3B4; |
|||
font-size: 32rpx; |
|||
font-weight: 600; |
|||
} |
|||
|
|||
.stat_label { |
|||
color: #999; |
|||
font-size: 22rpx; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
.no_student_card { |
|||
text-align: center; |
|||
padding: 40rpx 0; |
|||
|
|||
.no_student_text { |
|||
font-size: 28rpx; |
|||
color: #666; |
|||
margin-bottom: 8rpx; |
|||
} |
|||
|
|||
.no_student_hint { |
|||
font-size: 24rpx; |
|||
color: #999; |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 功能入口区域 |
|||
.functions_section { |
|||
margin: 20rpx; |
|||
|
|||
.section_title { |
|||
font-size: 28rpx; |
|||
font-weight: 600; |
|||
color: #333; |
|||
margin-bottom: 20rpx; |
|||
padding-left: 16rpx; |
|||
} |
|||
|
|||
.functions_grid { |
|||
background: #fff; |
|||
border-radius: 16rpx; |
|||
padding: 32rpx 24rpx; |
|||
display: flex; |
|||
flex-wrap: wrap; |
|||
justify-content: space-between; |
|||
|
|||
.function_item { |
|||
width: 22%; |
|||
margin-bottom: 32rpx; |
|||
display: flex; |
|||
flex-direction: column; |
|||
align-items: center; |
|||
position: relative; |
|||
|
|||
.function_icon { |
|||
width: 60rpx; |
|||
height: 60rpx; |
|||
border-radius: 50%; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
margin-bottom: 12rpx; |
|||
|
|||
&.profile_icon { background: rgba(52, 152, 219, 0.15); } |
|||
&.physical_icon { background: rgba(155, 89, 182, 0.15); } |
|||
&.schedule_icon { background: rgba(241, 196, 15, 0.15); } |
|||
&.booking_icon { background: rgba(230, 126, 34, 0.15); } |
|||
&.order_icon { background: rgba(231, 76, 60, 0.15); } |
|||
&.contract_icon { background: rgba(46, 204, 113, 0.15); } |
|||
&.knowledge_icon { background: rgba(52, 73, 94, 0.15); } |
|||
&.message_icon { background: rgba(41, 211, 180, 0.15); } |
|||
&.settings_icon { background: rgba(95, 95, 95, 0.15); } |
|||
|
|||
.icon_image { |
|||
width: 32rpx; |
|||
height: 32rpx; |
|||
} |
|||
} |
|||
|
|||
.function_text { |
|||
font-size: 22rpx; |
|||
color: #333; |
|||
text-align: center; |
|||
line-height: 1.2; |
|||
} |
|||
|
|||
.message_badge { |
|||
position: absolute; |
|||
top: -4rpx; |
|||
right: 14rpx; |
|||
background: #ff4757; |
|||
color: #fff; |
|||
font-size: 18rpx; |
|||
padding: 2rpx 8rpx; |
|||
border-radius: 12rpx; |
|||
min-width: 16rpx; |
|||
text-align: center; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 学员选择弹窗 |
|||
.student_popup { |
|||
position: fixed; |
|||
top: 0; |
|||
left: 0; |
|||
right: 0; |
|||
bottom: 0; |
|||
background: rgba(0, 0, 0, 0.5); |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
z-index: 1000; |
|||
|
|||
.popup_content { |
|||
background: #fff; |
|||
border-radius: 16rpx; |
|||
width: 80%; |
|||
max-height: 60vh; |
|||
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 { |
|||
font-size: 48rpx; |
|||
color: #999; |
|||
font-weight: 300; |
|||
} |
|||
} |
|||
|
|||
.popup_student_list { |
|||
max-height: 50vh; |
|||
overflow-y: auto; |
|||
|
|||
.popup_student_item { |
|||
display: flex; |
|||
align-items: center; |
|||
padding: 24rpx 32rpx; |
|||
border-bottom: 1px solid #f8f9fa; |
|||
gap: 24rpx; |
|||
|
|||
&.selected { |
|||
background: rgba(41, 211, 180, 0.1); |
|||
} |
|||
|
|||
.popup_student_avatar { |
|||
width: 60rpx; |
|||
height: 60rpx; |
|||
border-radius: 50%; |
|||
overflow: hidden; |
|||
|
|||
.popup_avatar_image { |
|||
width: 100%; |
|||
height: 100%; |
|||
} |
|||
} |
|||
|
|||
.popup_student_info { |
|||
flex: 1; |
|||
|
|||
.popup_student_name { |
|||
font-size: 28rpx; |
|||
font-weight: 600; |
|||
color: #333; |
|||
margin-bottom: 8rpx; |
|||
} |
|||
|
|||
.popup_student_details { |
|||
display: flex; |
|||
gap: 12rpx; |
|||
|
|||
.popup_detail_tag { |
|||
font-size: 22rpx; |
|||
color: #666; |
|||
background: #f0f0f0; |
|||
padding: 2rpx 8rpx; |
|||
border-radius: 8rpx; |
|||
} |
|||
} |
|||
} |
|||
|
|||
.popup_select_icon { |
|||
color: #29d3b4; |
|||
font-size: 32rpx; |
|||
font-weight: 600; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
</style> |
|||
File diff suppressed because it is too large
@ -0,0 +1,331 @@ |
|||
<template> |
|||
<view class="container"> |
|||
<!-- 顶部导航 --> |
|||
<view class="nav-bar"> |
|||
<view class="nav-title">微信账号绑定</view> |
|||
<view class="nav-close" @click="closePage"> |
|||
<fui-icon name="close" :size="40" color="#333"></fui-icon> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 微信授权webview --> |
|||
<web-view |
|||
v-if="authUrl" |
|||
:src="authUrl" |
|||
@message="handleWebviewMessage" |
|||
@error="handleWebviewError"> |
|||
</web-view> |
|||
|
|||
<!-- 绑定表单 --> |
|||
<view class="bind-form" v-if="showBindForm"> |
|||
<view class="form-title">完成账号绑定</view> |
|||
<view class="form-subtitle">请输入手机号和验证码完成绑定</view> |
|||
|
|||
<view class="form-item"> |
|||
<fui-input |
|||
placeholder="请输入手机号" |
|||
v-model="phone" |
|||
type="number" |
|||
backgroundColor="#f8f8f8"> |
|||
</fui-input> |
|||
</view> |
|||
|
|||
<view class="form-item"> |
|||
<fui-input |
|||
placeholder="请输入验证码" |
|||
v-model="smsCode" |
|||
backgroundColor="#f8f8f8"> |
|||
<fui-button |
|||
:background="canSendSms ? '#00be8c' : '#ccc'" |
|||
:disabled="!canSendSms" |
|||
width="200rpx" |
|||
height="60rpx" |
|||
:size="24" |
|||
@click="sendSmsCode"> |
|||
{{ smsButtonText }} |
|||
</fui-button> |
|||
</fui-input> |
|||
</view> |
|||
|
|||
<view class="form-item"> |
|||
<fui-button |
|||
background="#00be8c" |
|||
radius="5rpx" |
|||
@click="submitBind" |
|||
:disabled="!canSubmit"> |
|||
确认绑定 |
|||
</fui-button> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</template> |
|||
|
|||
<script> |
|||
import apiRoute from '@/api/apiRoute.js'; |
|||
|
|||
export default { |
|||
data() { |
|||
return { |
|||
authUrl: '', |
|||
miniOpenid: '', |
|||
wechatOpenid: '', |
|||
phone: '', |
|||
smsCode: '', |
|||
showBindForm: false, |
|||
smsCountdown: 0, |
|||
smsTimer: null |
|||
} |
|||
}, |
|||
computed: { |
|||
canSendSms() { |
|||
return this.phone.length === 11 && this.smsCountdown === 0; |
|||
}, |
|||
canSubmit() { |
|||
return this.phone.length === 11 && this.smsCode.length > 0 && this.wechatOpenid; |
|||
}, |
|||
smsButtonText() { |
|||
return this.smsCountdown > 0 ? `${this.smsCountdown}s` : '获取验证码'; |
|||
} |
|||
}, |
|||
onLoad(options) { |
|||
this.authUrl = decodeURIComponent(options.auth_url || ''); |
|||
this.miniOpenid = options.mini_openid || ''; |
|||
|
|||
if (!this.authUrl || !this.miniOpenid) { |
|||
uni.showToast({ |
|||
title: '参数错误', |
|||
icon: 'none' |
|||
}); |
|||
setTimeout(() => { |
|||
this.closePage(); |
|||
}, 1500); |
|||
return; |
|||
} |
|||
|
|||
// 监听URL变化,检测是否完成授权 |
|||
this.checkAuthCallback(); |
|||
}, |
|||
onShow() { |
|||
// 页面显示时检查是否有授权回调参数 |
|||
this.checkUrlParams(); |
|||
}, |
|||
onUnload() { |
|||
if (this.smsTimer) { |
|||
clearInterval(this.smsTimer); |
|||
} |
|||
}, |
|||
methods: { |
|||
// 检查授权回调 |
|||
checkAuthCallback() { |
|||
// 定时检查URL变化,模拟检测授权完成 |
|||
setTimeout(() => { |
|||
// 模拟授权完成,显示绑定表单 |
|||
// 实际项目中这里应该通过webview的URL变化或消息来判断 |
|||
this.showBindFormWithDelay(); |
|||
}, 3000); // 3秒后显示绑定表单,给用户足够时间完成微信授权 |
|||
}, |
|||
|
|||
// 检查URL参数 |
|||
checkUrlParams() { |
|||
// 这里可以检查页面参数,判断是否从微信授权回调返回 |
|||
const pages = getCurrentPages(); |
|||
const currentPage = pages[pages.length - 1]; |
|||
if (currentPage && currentPage.options) { |
|||
const { code, state } = currentPage.options; |
|||
if (code && state) { |
|||
// 有授权回调参数,处理授权结果 |
|||
this.handleAuthCallback(code, state); |
|||
} |
|||
} |
|||
}, |
|||
|
|||
// 延迟显示绑定表单 |
|||
showBindFormWithDelay() { |
|||
// 模拟获取到微信公众号openid |
|||
this.wechatOpenid = 'mock_wechat_openid_' + Date.now(); |
|||
this.showBindForm = true; |
|||
|
|||
uni.showToast({ |
|||
title: '微信授权成功,请完成绑定', |
|||
icon: 'success' |
|||
}); |
|||
}, |
|||
|
|||
// 处理授权回调 |
|||
async handleAuthCallback(code, state) { |
|||
try { |
|||
// 这里应该调用后端接口处理授权回调 |
|||
// 暂时模拟处理 |
|||
this.wechatOpenid = 'wechat_openid_from_callback'; |
|||
this.showBindForm = true; |
|||
} catch (error) { |
|||
console.error('处理授权回调失败:', error); |
|||
uni.showToast({ |
|||
title: '授权失败', |
|||
icon: 'none' |
|||
}); |
|||
} |
|||
}, |
|||
|
|||
// 处理webview消息 |
|||
handleWebviewMessage(event) { |
|||
console.log('收到webview消息:', event); |
|||
// 这里可以处理来自webview的消息 |
|||
}, |
|||
|
|||
// 处理webview错误 |
|||
handleWebviewError(event) { |
|||
console.error('webview错误:', event); |
|||
uni.showToast({ |
|||
title: '授权页面加载失败', |
|||
icon: 'none' |
|||
}); |
|||
}, |
|||
|
|||
// 发送短信验证码 |
|||
async sendSmsCode() { |
|||
if (!this.canSendSms) return; |
|||
|
|||
try { |
|||
const res = await apiRoute.common_forgetPassword({ |
|||
phone: this.phone, |
|||
type: 'bind' // 绑定类型 |
|||
}); |
|||
|
|||
if (res && res.code === 1) { |
|||
uni.showToast({ |
|||
title: '验证码已发送', |
|||
icon: 'success' |
|||
}); |
|||
this.startCountdown(); |
|||
} else { |
|||
uni.showToast({ |
|||
title: res.msg || '发送失败', |
|||
icon: 'none' |
|||
}); |
|||
} |
|||
} catch (error) { |
|||
console.error('发送验证码失败:', error); |
|||
uni.showToast({ |
|||
title: '发送失败,请重试', |
|||
icon: 'none' |
|||
}); |
|||
} |
|||
}, |
|||
|
|||
// 开始倒计时 |
|||
startCountdown() { |
|||
this.smsCountdown = 60; |
|||
this.smsTimer = setInterval(() => { |
|||
this.smsCountdown--; |
|||
if (this.smsCountdown <= 0) { |
|||
clearInterval(this.smsTimer); |
|||
this.smsTimer = null; |
|||
} |
|||
}, 1000); |
|||
}, |
|||
|
|||
// 提交绑定 |
|||
async submitBind() { |
|||
if (!this.canSubmit) return; |
|||
|
|||
try { |
|||
uni.showLoading({ |
|||
title: '绑定中...' |
|||
}); |
|||
|
|||
const params = { |
|||
mini_openid: this.miniOpenid, |
|||
wechat_openid: this.wechatOpenid, |
|||
phone: this.phone, |
|||
code: this.smsCode |
|||
}; |
|||
|
|||
const res = await apiRoute.wechatBind(params); |
|||
uni.hideLoading(); |
|||
|
|||
if (res && res.code === 1) { |
|||
uni.showToast({ |
|||
title: '绑定成功', |
|||
icon: 'success' |
|||
}); |
|||
|
|||
setTimeout(() => { |
|||
// 绑定成功后返回登录页面并自动登录 |
|||
uni.navigateBack(); |
|||
// 通知登录页面绑定成功 |
|||
uni.$emit('wechatBindSuccess'); |
|||
}, 1500); |
|||
} else { |
|||
uni.showToast({ |
|||
title: res.msg || '绑定失败', |
|||
icon: 'none' |
|||
}); |
|||
} |
|||
} catch (error) { |
|||
uni.hideLoading(); |
|||
console.error('绑定失败:', error); |
|||
uni.showToast({ |
|||
title: '绑定失败,请重试', |
|||
icon: 'none' |
|||
}); |
|||
} |
|||
}, |
|||
|
|||
// 关闭页面 |
|||
closePage() { |
|||
uni.navigateBack(); |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style lang="less" scoped> |
|||
.container { |
|||
height: 100vh; |
|||
background-color: #fff; |
|||
} |
|||
|
|||
.nav-bar { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
padding: 20rpx 30rpx; |
|||
border-bottom: 1rpx solid #eee; |
|||
position: relative; |
|||
z-index: 999; |
|||
} |
|||
|
|||
.nav-title { |
|||
font-size: 36rpx; |
|||
font-weight: bold; |
|||
color: #333; |
|||
} |
|||
|
|||
.nav-close { |
|||
padding: 10rpx; |
|||
} |
|||
|
|||
.bind-form { |
|||
padding: 60rpx 30rpx; |
|||
} |
|||
|
|||
.form-title { |
|||
font-size: 48rpx; |
|||
font-weight: bold; |
|||
color: #333; |
|||
text-align: center; |
|||
margin-bottom: 20rpx; |
|||
} |
|||
|
|||
.form-subtitle { |
|||
font-size: 28rpx; |
|||
color: #666; |
|||
text-align: center; |
|||
margin-bottom: 60rpx; |
|||
} |
|||
|
|||
.form-item { |
|||
margin-bottom: 40rpx; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,887 @@ |
|||
<!--学员消息管理页面--> |
|||
<template> |
|||
<view class="main_box"> |
|||
<!-- 自定义导航栏 --> |
|||
<view class="navbar_section"> |
|||
<view class="navbar_back" @click="goBack"> |
|||
<text class="back_icon">‹</text> |
|||
</view> |
|||
<view class="navbar_title">消息管理</view> |
|||
<view class="navbar_action"> |
|||
<view class="mark_all_read" @click="markAllAsRead" v-if="unreadCount > 0"> |
|||
<text class="mark_text">全部已读</text> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 学员信息 --> |
|||
<view class="student_info_section" v-if="studentInfo"> |
|||
<view class="student_name">{{ studentInfo.name }}</view> |
|||
<view class="message_stats"> |
|||
<text class="stat_item">未读消息:{{ unreadCount }}条</text> |
|||
<text class="stat_item">总消息:{{ messagesList.length }}条</text> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 消息类型筛选 --> |
|||
<view class="filter_section"> |
|||
<view class="filter_tabs"> |
|||
<view |
|||
v-for="tab in typeTabs" |
|||
:key="tab.value" |
|||
:class="['filter_tab', activeType === tab.value ? 'active' : '']" |
|||
@click="changeType(tab.value)" |
|||
> |
|||
{{ tab.text }} |
|||
<view class="tab_badge" v-if="tab.count > 0">{{ tab.count }}</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 消息列表 --> |
|||
<view class="messages_section"> |
|||
<view v-if="loading" class="loading_section"> |
|||
<view class="loading_text">加载中...</view> |
|||
</view> |
|||
|
|||
<view v-else-if="filteredMessages.length === 0" class="empty_section"> |
|||
<view class="empty_icon">💬</view> |
|||
<view class="empty_text">暂无消息</view> |
|||
<view class="empty_hint">新消息会在这里显示</view> |
|||
</view> |
|||
|
|||
<view v-else class="messages_list"> |
|||
<view |
|||
v-for="message in filteredMessages" |
|||
:key="message.id" |
|||
:class="['message_item', !message.is_read ? 'unread' : '']" |
|||
@click="viewMessage(message)" |
|||
> |
|||
<view class="message_header"> |
|||
<view class="message_type" :class="message.type"> |
|||
{{ getTypeText(message.type) }} |
|||
</view> |
|||
<view class="message_time">{{ formatTime(message.send_time) }}</view> |
|||
<view class="unread_dot" v-if="!message.is_read"></view> |
|||
</view> |
|||
|
|||
<view class="message_content"> |
|||
<view class="message_title">{{ message.title }}</view> |
|||
<view class="message_preview">{{ message.content | truncate(50) }}</view> |
|||
</view> |
|||
|
|||
<view class="message_meta" v-if="message.sender_name"> |
|||
<text class="sender_name">来自:{{ message.sender_name }}</text> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 加载更多 --> |
|||
<view class="load_more_section" v-if="!loading && hasMore"> |
|||
<fui-button |
|||
background="transparent" |
|||
color="#666" |
|||
@click="loadMoreMessages" |
|||
:loading="loadingMore" |
|||
> |
|||
{{ loadingMore ? '加载中...' : '加载更多' }} |
|||
</fui-button> |
|||
</view> |
|||
|
|||
<!-- 消息详情弹窗 --> |
|||
<view class="message_popup" v-if="showMessagePopup" @click="closeMessagePopup"> |
|||
<view class="popup_content" @click.stop> |
|||
<view class="popup_header"> |
|||
<view class="popup_title">消息详情</view> |
|||
<view class="popup_close" @click="closeMessagePopup">×</view> |
|||
</view> |
|||
|
|||
<view class="popup_message_detail" v-if="selectedMessage"> |
|||
<view class="detail_header"> |
|||
<view class="detail_type" :class="selectedMessage.type"> |
|||
{{ getTypeText(selectedMessage.type) }} |
|||
</view> |
|||
<view class="detail_time">{{ formatFullTime(selectedMessage.send_time) }}</view> |
|||
</view> |
|||
|
|||
<view class="detail_title">{{ selectedMessage.title }}</view> |
|||
|
|||
<view class="detail_content">{{ selectedMessage.content }}</view> |
|||
|
|||
<view class="detail_sender" v-if="selectedMessage.sender_name"> |
|||
<text class="sender_label">发送人:</text> |
|||
<text class="sender_value">{{ selectedMessage.sender_name }}</text> |
|||
</view> |
|||
|
|||
<view class="detail_attachment" v-if="selectedMessage.attachment_url"> |
|||
<fui-button |
|||
background="#29d3b4" |
|||
size="small" |
|||
@click="viewAttachment(selectedMessage.attachment_url)" |
|||
> |
|||
查看附件 |
|||
</fui-button> |
|||
</view> |
|||
</view> |
|||
|
|||
<view class="popup_actions"> |
|||
<fui-button |
|||
v-if="selectedMessage && selectedMessage.type === 'homework'" |
|||
background="#f39c12" |
|||
@click="submitHomework" |
|||
> |
|||
提交作业 |
|||
</fui-button> |
|||
|
|||
<fui-button |
|||
v-if="selectedMessage && selectedMessage.type === 'notification'" |
|||
background="#29d3b4" |
|||
@click="confirmNotification" |
|||
> |
|||
确认已读 |
|||
</fui-button> |
|||
|
|||
<fui-button |
|||
background="#f8f9fa" |
|||
color="#666" |
|||
@click="closeMessagePopup" |
|||
> |
|||
关闭 |
|||
</fui-button> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</template> |
|||
|
|||
<script> |
|||
import apiRoute from '@/api/apiRoute.js' |
|||
|
|||
export default { |
|||
filters: { |
|||
truncate(text, length) { |
|||
if (!text) return '' |
|||
if (text.length <= length) return text |
|||
return text.substring(0, length) + '...' |
|||
} |
|||
}, |
|||
|
|||
data() { |
|||
return { |
|||
studentId: 0, |
|||
studentInfo: {}, |
|||
messagesList: [], |
|||
filteredMessages: [], |
|||
loading: false, |
|||
loadingMore: false, |
|||
hasMore: true, |
|||
currentPage: 1, |
|||
activeType: 'all', |
|||
showMessagePopup: false, |
|||
selectedMessage: null, |
|||
typeTabs: [ |
|||
{ value: 'all', text: '全部', count: 0 }, |
|||
{ value: 'system', text: '系统消息', count: 0 }, |
|||
{ value: 'notification', text: '通知公告', count: 0 }, |
|||
{ value: 'homework', text: '作业任务', count: 0 }, |
|||
{ value: 'feedback', text: '反馈评价', count: 0 }, |
|||
{ value: 'reminder', text: '课程提醒', count: 0 } |
|||
] |
|||
} |
|||
}, |
|||
|
|||
computed: { |
|||
unreadCount() { |
|||
return this.messagesList.filter(msg => !msg.is_read).length |
|||
} |
|||
}, |
|||
|
|||
onLoad(options) { |
|||
this.studentId = parseInt(options.student_id) || 0 |
|||
if (this.studentId) { |
|||
this.initPage() |
|||
} else { |
|||
uni.showToast({ |
|||
title: '参数错误', |
|||
icon: 'none' |
|||
}) |
|||
} |
|||
}, |
|||
|
|||
methods: { |
|||
goBack() { |
|||
uni.navigateBack() |
|||
}, |
|||
|
|||
async initPage() { |
|||
await this.loadStudentInfo() |
|||
await this.loadMessages() |
|||
this.updateTypeCounts() |
|||
}, |
|||
|
|||
async loadStudentInfo() { |
|||
try { |
|||
// 模拟获取学员信息 |
|||
const mockStudentInfo = { |
|||
id: this.studentId, |
|||
name: '小明' |
|||
} |
|||
this.studentInfo = mockStudentInfo |
|||
} catch (error) { |
|||
console.error('获取学员信息失败:', error) |
|||
} |
|||
}, |
|||
|
|||
async loadMessages() { |
|||
this.loading = true |
|||
try { |
|||
console.log('加载消息列表:', this.studentId) |
|||
|
|||
// 模拟API调用 |
|||
// const response = await apiRoute.getStudentMessages({ |
|||
// student_id: this.studentId, |
|||
// page: this.currentPage, |
|||
// limit: 10 |
|||
// }) |
|||
|
|||
// 使用模拟数据 |
|||
const mockResponse = { |
|||
code: 1, |
|||
data: { |
|||
list: [ |
|||
{ |
|||
id: 1, |
|||
type: 'system', |
|||
title: '欢迎加入运动识堂', |
|||
content: '欢迎您的孩子加入我们的运动训练课程!我们将为您的孩子提供专业的体能训练指导,帮助孩子健康成长。课程安排和相关信息会及时通过消息推送给您,请注意查收。', |
|||
sender_name: '系统管理员', |
|||
send_time: '2024-01-15 09:00:00', |
|||
is_read: false, |
|||
attachment_url: '' |
|||
}, |
|||
{ |
|||
id: 2, |
|||
type: 'notification', |
|||
title: '本周课程安排通知', |
|||
content: '本周课程安排已更新,请及时查看课程表。周三下午16:00-17:00的基础体能训练课程请准时参加,课程地点:训练馆A。如有疑问请联系教练。', |
|||
sender_name: '张教练', |
|||
send_time: '2024-01-14 15:30:00', |
|||
is_read: true, |
|||
attachment_url: '' |
|||
}, |
|||
{ |
|||
id: 3, |
|||
type: 'homework', |
|||
title: '体能训练作业', |
|||
content: '请完成以下体能训练作业:1. 每天跳绳100个 2. 俯卧撑10个 3. 仰卧起坐15个。请在下次课程前完成并提交训练视频。', |
|||
sender_name: '李教练', |
|||
send_time: '2024-01-13 18:20:00', |
|||
is_read: false, |
|||
attachment_url: '/uploads/homework/homework_guide.pdf' |
|||
}, |
|||
{ |
|||
id: 4, |
|||
type: 'feedback', |
|||
title: '上次课程反馈', |
|||
content: '您的孩子在上次的基础体能训练中表现优秀,动作标准,学习积极。建议继续加强核心力量训练,可以适当增加训练强度。', |
|||
sender_name: '王教练', |
|||
send_time: '2024-01-12 20:15:00', |
|||
is_read: true, |
|||
attachment_url: '' |
|||
}, |
|||
{ |
|||
id: 5, |
|||
type: 'reminder', |
|||
title: '明日课程提醒', |
|||
content: '提醒您的孩子明天下午14:00有专项技能训练课程,请准时到达训练馆B。建议提前10分钟到场进行热身准备。', |
|||
sender_name: '系统提醒', |
|||
send_time: '2024-01-11 19:00:00', |
|||
is_read: true, |
|||
attachment_url: '' |
|||
} |
|||
], |
|||
total: 5, |
|||
has_more: false |
|||
} |
|||
} |
|||
|
|||
if (mockResponse.code === 1) { |
|||
const newList = mockResponse.data.list || [] |
|||
if (this.currentPage === 1) { |
|||
this.messagesList = newList |
|||
} else { |
|||
this.messagesList = [...this.messagesList, ...newList] |
|||
} |
|||
|
|||
this.hasMore = mockResponse.data.has_more || false |
|||
this.applyTypeFilter() |
|||
console.log('消息数据加载成功:', this.messagesList) |
|||
} else { |
|||
uni.showToast({ |
|||
title: mockResponse.msg || '获取消息列表失败', |
|||
icon: 'none' |
|||
}) |
|||
} |
|||
} catch (error) { |
|||
console.error('获取消息列表失败:', error) |
|||
uni.showToast({ |
|||
title: '获取消息列表失败', |
|||
icon: 'none' |
|||
}) |
|||
} finally { |
|||
this.loading = false |
|||
this.loadingMore = false |
|||
} |
|||
}, |
|||
|
|||
async loadMoreMessages() { |
|||
if (this.loadingMore || !this.hasMore) return |
|||
|
|||
this.loadingMore = true |
|||
this.currentPage++ |
|||
await this.loadMessages() |
|||
}, |
|||
|
|||
changeType(type) { |
|||
this.activeType = type |
|||
this.applyTypeFilter() |
|||
}, |
|||
|
|||
applyTypeFilter() { |
|||
if (this.activeType === 'all') { |
|||
this.filteredMessages = [...this.messagesList] |
|||
} else { |
|||
this.filteredMessages = this.messagesList.filter(message => message.type === this.activeType) |
|||
} |
|||
}, |
|||
|
|||
updateTypeCounts() { |
|||
const counts = {} |
|||
this.messagesList.forEach(message => { |
|||
counts[message.type] = (counts[message.type] || 0) + 1 |
|||
}) |
|||
|
|||
this.typeTabs.forEach(tab => { |
|||
if (tab.value === 'all') { |
|||
tab.count = this.messagesList.length |
|||
} else { |
|||
tab.count = counts[tab.value] || 0 |
|||
} |
|||
}) |
|||
}, |
|||
|
|||
getTypeText(type) { |
|||
const typeMap = { |
|||
'system': '系统消息', |
|||
'notification': '通知公告', |
|||
'homework': '作业任务', |
|||
'feedback': '反馈评价', |
|||
'reminder': '课程提醒' |
|||
} |
|||
return typeMap[type] || type |
|||
}, |
|||
|
|||
formatTime(timeString) { |
|||
const now = new Date() |
|||
const msgTime = new Date(timeString) |
|||
const diffHours = (now - msgTime) / (1000 * 60 * 60) |
|||
|
|||
if (diffHours < 1) { |
|||
return '刚刚' |
|||
} else if (diffHours < 24) { |
|||
return Math.floor(diffHours) + '小时前' |
|||
} else if (diffHours < 48) { |
|||
return '昨天' |
|||
} else { |
|||
const month = String(msgTime.getMonth() + 1).padStart(2, '0') |
|||
const day = String(msgTime.getDate()).padStart(2, '0') |
|||
return `${month}-${day}` |
|||
} |
|||
}, |
|||
|
|||
formatFullTime(timeString) { |
|||
const date = new Date(timeString) |
|||
const year = date.getFullYear() |
|||
const month = String(date.getMonth() + 1).padStart(2, '0') |
|||
const day = String(date.getDate()).padStart(2, '0') |
|||
const hours = String(date.getHours()).padStart(2, '0') |
|||
const minutes = String(date.getMinutes()).padStart(2, '0') |
|||
return `${year}-${month}-${day} ${hours}:${minutes}` |
|||
}, |
|||
|
|||
viewMessage(message) { |
|||
this.selectedMessage = message |
|||
this.showMessagePopup = true |
|||
|
|||
// 标记为已读 |
|||
if (!message.is_read) { |
|||
this.markAsRead(message) |
|||
} |
|||
}, |
|||
|
|||
closeMessagePopup() { |
|||
this.showMessagePopup = false |
|||
this.selectedMessage = null |
|||
}, |
|||
|
|||
async markAsRead(message) { |
|||
try { |
|||
console.log('标记已读:', message.id) |
|||
|
|||
// 模拟API调用 |
|||
await new Promise(resolve => setTimeout(resolve, 200)) |
|||
|
|||
// 更新本地状态 |
|||
const index = this.messagesList.findIndex(msg => msg.id === message.id) |
|||
if (index !== -1) { |
|||
this.messagesList[index].is_read = true |
|||
} |
|||
} catch (error) { |
|||
console.error('标记已读失败:', error) |
|||
} |
|||
}, |
|||
|
|||
async markAllAsRead() { |
|||
if (this.unreadCount === 0) { |
|||
uni.showToast({ |
|||
title: '暂无未读消息', |
|||
icon: 'none' |
|||
}) |
|||
return |
|||
} |
|||
|
|||
try { |
|||
console.log('标记全部已读') |
|||
|
|||
// 模拟API调用 |
|||
await new Promise(resolve => setTimeout(resolve, 500)) |
|||
|
|||
// 更新本地状态 |
|||
this.messagesList.forEach(message => { |
|||
message.is_read = true |
|||
}) |
|||
|
|||
uni.showToast({ |
|||
title: '已全部标记为已读', |
|||
icon: 'success' |
|||
}) |
|||
} catch (error) { |
|||
console.error('标记全部已读失败:', error) |
|||
uni.showToast({ |
|||
title: '操作失败', |
|||
icon: 'none' |
|||
}) |
|||
} |
|||
}, |
|||
|
|||
viewAttachment(attachmentUrl) { |
|||
console.log('查看附件:', attachmentUrl) |
|||
uni.showModal({ |
|||
title: '提示', |
|||
content: '附件下载功能开发中', |
|||
showCancel: false |
|||
}) |
|||
}, |
|||
|
|||
submitHomework() { |
|||
console.log('提交作业') |
|||
uni.showModal({ |
|||
title: '提示', |
|||
content: '作业提交功能开发中', |
|||
showCancel: false |
|||
}) |
|||
}, |
|||
|
|||
confirmNotification() { |
|||
console.log('确认通知') |
|||
this.closeMessagePopup() |
|||
uni.showToast({ |
|||
title: '已确认', |
|||
icon: 'success' |
|||
}) |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style lang="less" scoped> |
|||
.main_box { |
|||
background: #f8f9fa; |
|||
min-height: 100vh; |
|||
} |
|||
|
|||
// 自定义导航栏 |
|||
.navbar_section { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
background: #29D3B4; |
|||
padding: 40rpx 32rpx 20rpx; |
|||
|
|||
// 小程序端适配状态栏 |
|||
// #ifdef MP-WEIXIN |
|||
padding-top: 80rpx; |
|||
// #endif |
|||
|
|||
.navbar_back { |
|||
width: 60rpx; |
|||
|
|||
.back_icon { |
|||
color: #fff; |
|||
font-size: 40rpx; |
|||
font-weight: 600; |
|||
} |
|||
} |
|||
|
|||
.navbar_title { |
|||
color: #fff; |
|||
font-size: 32rpx; |
|||
font-weight: 600; |
|||
} |
|||
|
|||
.navbar_action { |
|||
width: 120rpx; |
|||
display: flex; |
|||
justify-content: flex-end; |
|||
|
|||
.mark_all_read { |
|||
background: rgba(255, 255, 255, 0.2); |
|||
padding: 8rpx 16rpx; |
|||
border-radius: 16rpx; |
|||
|
|||
.mark_text { |
|||
color: #fff; |
|||
font-size: 24rpx; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 学员信息 |
|||
.student_info_section { |
|||
background: #fff; |
|||
padding: 24rpx 32rpx; |
|||
border-bottom: 1px solid #f0f0f0; |
|||
|
|||
.student_name { |
|||
font-size: 32rpx; |
|||
font-weight: 600; |
|||
color: #333; |
|||
margin-bottom: 12rpx; |
|||
} |
|||
|
|||
.message_stats { |
|||
display: flex; |
|||
gap: 24rpx; |
|||
|
|||
.stat_item { |
|||
font-size: 24rpx; |
|||
color: #666; |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 类型筛选 |
|||
.filter_section { |
|||
background: #fff; |
|||
margin: 20rpx; |
|||
border-radius: 16rpx; |
|||
padding: 24rpx 32rpx; |
|||
|
|||
.filter_tabs { |
|||
display: flex; |
|||
flex-wrap: wrap; |
|||
gap: 16rpx; |
|||
|
|||
.filter_tab { |
|||
position: relative; |
|||
padding: 12rpx 24rpx; |
|||
font-size: 26rpx; |
|||
color: #666; |
|||
background: #f8f9fa; |
|||
border-radius: 20rpx; |
|||
|
|||
&.active { |
|||
color: #fff; |
|||
background: #29D3B4; |
|||
} |
|||
|
|||
.tab_badge { |
|||
position: absolute; |
|||
top: -8rpx; |
|||
right: -8rpx; |
|||
background: #ff4757; |
|||
color: #fff; |
|||
font-size: 18rpx; |
|||
padding: 2rpx 8rpx; |
|||
border-radius: 12rpx; |
|||
min-width: 16rpx; |
|||
text-align: center; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 消息列表 |
|||
.messages_section { |
|||
margin: 0 20rpx; |
|||
|
|||
.loading_section, .empty_section { |
|||
background: #fff; |
|||
border-radius: 16rpx; |
|||
padding: 80rpx 32rpx; |
|||
text-align: center; |
|||
|
|||
.loading_text, .empty_text { |
|||
font-size: 28rpx; |
|||
color: #666; |
|||
margin-bottom: 12rpx; |
|||
} |
|||
|
|||
.empty_icon { |
|||
font-size: 80rpx; |
|||
margin-bottom: 24rpx; |
|||
} |
|||
|
|||
.empty_hint { |
|||
font-size: 24rpx; |
|||
color: #999; |
|||
} |
|||
} |
|||
|
|||
.messages_list { |
|||
.message_item { |
|||
background: #fff; |
|||
border-radius: 16rpx; |
|||
padding: 24rpx; |
|||
margin-bottom: 16rpx; |
|||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06); |
|||
position: relative; |
|||
|
|||
&.unread { |
|||
background: #f8fdff; |
|||
border-left: 4rpx solid #29D3B4; |
|||
} |
|||
|
|||
.message_header { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
margin-bottom: 16rpx; |
|||
|
|||
.message_type { |
|||
font-size: 22rpx; |
|||
padding: 4rpx 12rpx; |
|||
border-radius: 12rpx; |
|||
|
|||
&.system { |
|||
color: #3498db; |
|||
background: rgba(52, 152, 219, 0.1); |
|||
} |
|||
|
|||
&.notification { |
|||
color: #f39c12; |
|||
background: rgba(243, 156, 18, 0.1); |
|||
} |
|||
|
|||
&.homework { |
|||
color: #e74c3c; |
|||
background: rgba(231, 76, 60, 0.1); |
|||
} |
|||
|
|||
&.feedback { |
|||
color: #27ae60; |
|||
background: rgba(39, 174, 96, 0.1); |
|||
} |
|||
|
|||
&.reminder { |
|||
color: #9b59b6; |
|||
background: rgba(155, 89, 182, 0.1); |
|||
} |
|||
} |
|||
|
|||
.message_time { |
|||
font-size: 22rpx; |
|||
color: #999; |
|||
} |
|||
|
|||
.unread_dot { |
|||
position: absolute; |
|||
top: 20rpx; |
|||
right: 20rpx; |
|||
width: 12rpx; |
|||
height: 12rpx; |
|||
background: #ff4757; |
|||
border-radius: 50%; |
|||
} |
|||
} |
|||
|
|||
.message_content { |
|||
margin-bottom: 12rpx; |
|||
|
|||
.message_title { |
|||
font-size: 28rpx; |
|||
font-weight: 600; |
|||
color: #333; |
|||
margin-bottom: 8rpx; |
|||
} |
|||
|
|||
.message_preview { |
|||
font-size: 24rpx; |
|||
color: #666; |
|||
line-height: 1.4; |
|||
} |
|||
} |
|||
|
|||
.message_meta { |
|||
.sender_name { |
|||
font-size: 22rpx; |
|||
color: #999; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 加载更多 |
|||
.load_more_section { |
|||
padding: 40rpx 20rpx 80rpx; |
|||
} |
|||
|
|||
// 消息详情弹窗 |
|||
.message_popup { |
|||
position: fixed; |
|||
top: 0; |
|||
left: 0; |
|||
right: 0; |
|||
bottom: 0; |
|||
background: rgba(0, 0, 0, 0.5); |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
z-index: 1000; |
|||
|
|||
.popup_content { |
|||
background: #fff; |
|||
border-radius: 16rpx; |
|||
width: 90%; |
|||
max-height: 80vh; |
|||
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 { |
|||
font-size: 48rpx; |
|||
color: #999; |
|||
font-weight: 300; |
|||
} |
|||
} |
|||
|
|||
.popup_message_detail { |
|||
padding: 32rpx; |
|||
max-height: 60vh; |
|||
overflow-y: auto; |
|||
|
|||
.detail_header { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
margin-bottom: 20rpx; |
|||
|
|||
.detail_type { |
|||
font-size: 24rpx; |
|||
padding: 6rpx 16rpx; |
|||
border-radius: 16rpx; |
|||
|
|||
&.system { |
|||
color: #3498db; |
|||
background: rgba(52, 152, 219, 0.1); |
|||
} |
|||
|
|||
&.notification { |
|||
color: #f39c12; |
|||
background: rgba(243, 156, 18, 0.1); |
|||
} |
|||
|
|||
&.homework { |
|||
color: #e74c3c; |
|||
background: rgba(231, 76, 60, 0.1); |
|||
} |
|||
|
|||
&.feedback { |
|||
color: #27ae60; |
|||
background: rgba(39, 174, 96, 0.1); |
|||
} |
|||
|
|||
&.reminder { |
|||
color: #9b59b6; |
|||
background: rgba(155, 89, 182, 0.1); |
|||
} |
|||
} |
|||
|
|||
.detail_time { |
|||
font-size: 24rpx; |
|||
color: #999; |
|||
} |
|||
} |
|||
|
|||
.detail_title { |
|||
font-size: 32rpx; |
|||
font-weight: 600; |
|||
color: #333; |
|||
margin-bottom: 20rpx; |
|||
} |
|||
|
|||
.detail_content { |
|||
font-size: 28rpx; |
|||
color: #666; |
|||
line-height: 1.6; |
|||
margin-bottom: 20rpx; |
|||
} |
|||
|
|||
.detail_sender { |
|||
margin-bottom: 20rpx; |
|||
padding-top: 20rpx; |
|||
border-top: 1px solid #f8f9fa; |
|||
|
|||
.sender_label { |
|||
font-size: 24rpx; |
|||
color: #999; |
|||
} |
|||
|
|||
.sender_value { |
|||
font-size: 24rpx; |
|||
color: #666; |
|||
} |
|||
} |
|||
|
|||
.detail_attachment { |
|||
margin-bottom: 20rpx; |
|||
} |
|||
} |
|||
|
|||
.popup_actions { |
|||
padding: 24rpx 32rpx; |
|||
display: flex; |
|||
gap: 16rpx; |
|||
border-top: 1px solid #f0f0f0; |
|||
|
|||
fui-button { |
|||
flex: 1; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
</style> |
|||
@ -0,0 +1,961 @@ |
|||
<!--学员订单管理页面--> |
|||
<template> |
|||
<view class="main_box"> |
|||
<!-- 自定义导航栏 --> |
|||
<view class="navbar_section"> |
|||
<view class="navbar_back" @click="goBack"> |
|||
<text class="back_icon">‹</text> |
|||
</view> |
|||
<view class="navbar_title">订单管理</view> |
|||
<view class="navbar_action"></view> |
|||
</view> |
|||
|
|||
<!-- 学员信息 --> |
|||
<view class="student_info_section" v-if="studentInfo"> |
|||
<view class="student_name">{{ studentInfo.name }}</view> |
|||
<view class="order_stats"> |
|||
<text class="stat_item">总订单:{{ orderStats.total_orders || 0 }}个</text> |
|||
<text class="stat_item">待付款:{{ orderStats.pending_payment || 0 }}个</text> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 订单状态筛选 --> |
|||
<view class="filter_section"> |
|||
<view class="filter_tabs"> |
|||
<view |
|||
v-for="tab in statusTabs" |
|||
:key="tab.value" |
|||
:class="['filter_tab', activeStatus === tab.value ? 'active' : '']" |
|||
@click="changeStatus(tab.value)" |
|||
> |
|||
{{ tab.text }} |
|||
<view class="tab_badge" v-if="tab.count > 0">{{ tab.count }}</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 订单列表 --> |
|||
<view class="orders_section"> |
|||
<view v-if="loading" class="loading_section"> |
|||
<view class="loading_text">加载中...</view> |
|||
</view> |
|||
|
|||
<view v-else-if="filteredOrders.length === 0" class="empty_section"> |
|||
<view class="empty_icon">📋</view> |
|||
<view class="empty_text">暂无订单</view> |
|||
<view class="empty_hint">完成购买后订单会在这里显示</view> |
|||
</view> |
|||
|
|||
<view v-else class="orders_list"> |
|||
<view |
|||
v-for="order in filteredOrders" |
|||
:key="order.id" |
|||
class="order_item" |
|||
@click="viewOrderDetail(order)" |
|||
> |
|||
<view class="order_header"> |
|||
<view class="order_number">订单号:{{ order.order_no }}</view> |
|||
<view class="order_status" :class="order.status"> |
|||
{{ getStatusText(order.status) }} |
|||
</view> |
|||
</view> |
|||
|
|||
<view class="order_content"> |
|||
<view class="order_info"> |
|||
<view class="product_name">{{ order.product_name }}</view> |
|||
<view class="product_specs">{{ order.product_specs }}</view> |
|||
<view class="order_meta"> |
|||
<text class="meta_item">数量:{{ order.quantity }}</text> |
|||
<text class="meta_item">{{ formatDate(order.create_time) }}</text> |
|||
</view> |
|||
</view> |
|||
|
|||
<view class="order_amount"> |
|||
<view class="amount_label">订单金额</view> |
|||
<view class="amount_value">¥{{ order.total_amount }}</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<view class="order_actions"> |
|||
<fui-button |
|||
v-if="order.status === 'pending_payment'" |
|||
background="#29d3b4" |
|||
size="small" |
|||
@click.stop="payOrder(order)" |
|||
> |
|||
立即付款 |
|||
</fui-button> |
|||
|
|||
<fui-button |
|||
v-if="order.status === 'pending_payment'" |
|||
background="transparent" |
|||
color="#999" |
|||
size="small" |
|||
@click.stop="cancelOrder(order)" |
|||
> |
|||
取消订单 |
|||
</fui-button> |
|||
|
|||
<fui-button |
|||
v-if="order.status === 'completed'" |
|||
background="transparent" |
|||
color="#666" |
|||
size="small" |
|||
@click.stop="viewOrderDetail(order)" |
|||
> |
|||
查看详情 |
|||
</fui-button> |
|||
|
|||
<fui-button |
|||
v-if="order.status === 'refunding' || order.status === 'refunded'" |
|||
background="transparent" |
|||
color="#f39c12" |
|||
size="small" |
|||
@click.stop="viewRefundDetail(order)" |
|||
> |
|||
查看退款 |
|||
</fui-button> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 加载更多 --> |
|||
<view class="load_more_section" v-if="!loading && hasMore"> |
|||
<fui-button |
|||
background="transparent" |
|||
color="#666" |
|||
@click="loadMoreOrders" |
|||
:loading="loadingMore" |
|||
> |
|||
{{ loadingMore ? '加载中...' : '加载更多' }} |
|||
</fui-button> |
|||
</view> |
|||
|
|||
<!-- 支付方式选择弹窗 --> |
|||
<view class="payment_popup" v-if="showPaymentPopup" @click="closePaymentPopup"> |
|||
<view class="popup_content" @click.stop> |
|||
<view class="popup_header"> |
|||
<view class="popup_title">选择支付方式</view> |
|||
<view class="popup_close" @click="closePaymentPopup">×</view> |
|||
</view> |
|||
|
|||
<view class="payment_order_info" v-if="selectedOrder"> |
|||
<view class="order_summary"> |
|||
<view class="summary_row"> |
|||
<text class="summary_label">订单号:</text> |
|||
<text class="summary_value">{{ selectedOrder.order_no }}</text> |
|||
</view> |
|||
<view class="summary_row"> |
|||
<text class="summary_label">商品:</text> |
|||
<text class="summary_value">{{ selectedOrder.product_name }}</text> |
|||
</view> |
|||
<view class="summary_row"> |
|||
<text class="summary_label">金额:</text> |
|||
<text class="summary_value amount">¥{{ selectedOrder.total_amount }}</text> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<view class="payment_methods"> |
|||
<view |
|||
v-for="method in paymentMethods" |
|||
:key="method.value" |
|||
:class="['payment_method', selectedPaymentMethod === method.value ? 'selected' : '']" |
|||
@click="selectPaymentMethod(method.value)" |
|||
> |
|||
<view class="method_icon"> |
|||
<image :src="method.icon" class="icon_image"></image> |
|||
</view> |
|||
<view class="method_info"> |
|||
<view class="method_name">{{ method.name }}</view> |
|||
<view class="method_desc">{{ method.desc }}</view> |
|||
</view> |
|||
<view class="method_radio"> |
|||
<view v-if="selectedPaymentMethod === method.value" class="radio_checked">✓</view> |
|||
<view v-else class="radio_unchecked"></view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<view class="popup_actions"> |
|||
<fui-button |
|||
background="#f8f9fa" |
|||
color="#666" |
|||
@click="closePaymentPopup" |
|||
> |
|||
取消 |
|||
</fui-button> |
|||
<fui-button |
|||
background="#29d3b4" |
|||
:loading="paying" |
|||
@click="confirmPayment" |
|||
> |
|||
{{ paying ? '支付中...' : '确认支付' }} |
|||
</fui-button> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</template> |
|||
|
|||
<script> |
|||
import apiRoute from '@/api/apiRoute.js' |
|||
|
|||
export default { |
|||
data() { |
|||
return { |
|||
studentId: 0, |
|||
studentInfo: {}, |
|||
ordersList: [], |
|||
filteredOrders: [], |
|||
orderStats: {}, |
|||
loading: false, |
|||
loadingMore: false, |
|||
hasMore: true, |
|||
currentPage: 1, |
|||
activeStatus: 'all', |
|||
showPaymentPopup: false, |
|||
selectedOrder: null, |
|||
selectedPaymentMethod: 'wxpay', |
|||
paying: false, |
|||
statusTabs: [ |
|||
{ value: 'all', text: '全部', count: 0 }, |
|||
{ value: 'pending_payment', text: '待付款', count: 0 }, |
|||
{ value: 'paid', text: '已付款', count: 0 }, |
|||
{ value: 'completed', text: '已完成', count: 0 }, |
|||
{ value: 'cancelled', text: '已取消', count: 0 }, |
|||
{ value: 'refunded', text: '已退款', count: 0 } |
|||
], |
|||
paymentMethods: [ |
|||
{ |
|||
value: 'wxpay', |
|||
name: '微信支付', |
|||
desc: '推荐使用', |
|||
icon: '/static/payment/wxpay.png' |
|||
}, |
|||
{ |
|||
value: 'alipay', |
|||
name: '支付宝', |
|||
desc: '快捷安全', |
|||
icon: '/static/payment/alipay.png' |
|||
} |
|||
] |
|||
} |
|||
}, |
|||
|
|||
onLoad(options) { |
|||
this.studentId = parseInt(options.student_id) || 0 |
|||
if (this.studentId) { |
|||
this.initPage() |
|||
} else { |
|||
uni.showToast({ |
|||
title: '参数错误', |
|||
icon: 'none' |
|||
}) |
|||
} |
|||
}, |
|||
|
|||
methods: { |
|||
goBack() { |
|||
uni.navigateBack() |
|||
}, |
|||
|
|||
async initPage() { |
|||
await this.loadStudentInfo() |
|||
await this.loadOrders() |
|||
this.updateStatusCounts() |
|||
}, |
|||
|
|||
async loadStudentInfo() { |
|||
try { |
|||
// 模拟获取学员信息 |
|||
const mockStudentInfo = { |
|||
id: this.studentId, |
|||
name: '小明' |
|||
} |
|||
this.studentInfo = mockStudentInfo |
|||
} catch (error) { |
|||
console.error('获取学员信息失败:', error) |
|||
} |
|||
}, |
|||
|
|||
async loadOrders() { |
|||
this.loading = true |
|||
try { |
|||
console.log('加载订单列表:', this.studentId) |
|||
|
|||
// 模拟API调用 |
|||
// const response = await apiRoute.getStudentOrders({ |
|||
// student_id: this.studentId, |
|||
// page: this.currentPage, |
|||
// limit: 10 |
|||
// }) |
|||
|
|||
// 使用模拟数据 |
|||
const mockResponse = { |
|||
code: 1, |
|||
data: { |
|||
list: [ |
|||
{ |
|||
id: 1, |
|||
order_no: 'XL202401150001', |
|||
product_name: '少儿体适能课程包', |
|||
product_specs: '12节课/包', |
|||
quantity: 1, |
|||
total_amount: '1200.00', |
|||
status: 'pending_payment', |
|||
create_time: '2024-01-15 10:30:00', |
|||
payment_method: '', |
|||
payment_time: '' |
|||
}, |
|||
{ |
|||
id: 2, |
|||
order_no: 'XL202401100002', |
|||
product_name: '基础体能训练课程', |
|||
product_specs: '24节课/包', |
|||
quantity: 1, |
|||
total_amount: '2400.00', |
|||
status: 'completed', |
|||
create_time: '2024-01-10 14:20:00', |
|||
payment_method: 'wxpay', |
|||
payment_time: '2024-01-10 14:25:00' |
|||
}, |
|||
{ |
|||
id: 3, |
|||
order_no: 'XL202401050003', |
|||
product_name: '专项技能训练', |
|||
product_specs: '8节课/包', |
|||
quantity: 1, |
|||
total_amount: '800.00', |
|||
status: 'refunded', |
|||
create_time: '2024-01-05 09:15:00', |
|||
payment_method: 'alipay', |
|||
payment_time: '2024-01-05 09:20:00', |
|||
refund_time: '2024-01-08 16:30:00', |
|||
refund_amount: '800.00' |
|||
} |
|||
], |
|||
total: 3, |
|||
has_more: false, |
|||
stats: { |
|||
total_orders: 3, |
|||
pending_payment: 1, |
|||
paid: 1, |
|||
completed: 1, |
|||
cancelled: 0, |
|||
refunded: 1 |
|||
} |
|||
} |
|||
} |
|||
|
|||
if (mockResponse.code === 1) { |
|||
const newList = mockResponse.data.list || [] |
|||
if (this.currentPage === 1) { |
|||
this.ordersList = newList |
|||
} else { |
|||
this.ordersList = [...this.ordersList, ...newList] |
|||
} |
|||
|
|||
this.hasMore = mockResponse.data.has_more || false |
|||
this.orderStats = mockResponse.data.stats || {} |
|||
this.applyStatusFilter() |
|||
console.log('订单数据加载成功:', this.ordersList) |
|||
} else { |
|||
uni.showToast({ |
|||
title: mockResponse.msg || '获取订单列表失败', |
|||
icon: 'none' |
|||
}) |
|||
} |
|||
} catch (error) { |
|||
console.error('获取订单列表失败:', error) |
|||
uni.showToast({ |
|||
title: '获取订单列表失败', |
|||
icon: 'none' |
|||
}) |
|||
} finally { |
|||
this.loading = false |
|||
this.loadingMore = false |
|||
} |
|||
}, |
|||
|
|||
async loadMoreOrders() { |
|||
if (this.loadingMore || !this.hasMore) return |
|||
|
|||
this.loadingMore = true |
|||
this.currentPage++ |
|||
await this.loadOrders() |
|||
}, |
|||
|
|||
changeStatus(status) { |
|||
this.activeStatus = status |
|||
this.applyStatusFilter() |
|||
}, |
|||
|
|||
applyStatusFilter() { |
|||
if (this.activeStatus === 'all') { |
|||
this.filteredOrders = [...this.ordersList] |
|||
} else { |
|||
this.filteredOrders = this.ordersList.filter(order => order.status === this.activeStatus) |
|||
} |
|||
}, |
|||
|
|||
updateStatusCounts() { |
|||
const counts = {} |
|||
this.ordersList.forEach(order => { |
|||
counts[order.status] = (counts[order.status] || 0) + 1 |
|||
}) |
|||
|
|||
this.statusTabs.forEach(tab => { |
|||
if (tab.value === 'all') { |
|||
tab.count = this.ordersList.length |
|||
} else { |
|||
tab.count = counts[tab.value] || 0 |
|||
} |
|||
}) |
|||
}, |
|||
|
|||
getStatusText(status) { |
|||
const statusMap = { |
|||
'pending_payment': '待付款', |
|||
'paid': '已付款', |
|||
'completed': '已完成', |
|||
'cancelled': '已取消', |
|||
'refunding': '退款中', |
|||
'refunded': '已退款' |
|||
} |
|||
return statusMap[status] || status |
|||
}, |
|||
|
|||
formatDate(dateString) { |
|||
const date = new Date(dateString) |
|||
const month = String(date.getMonth() + 1).padStart(2, '0') |
|||
const day = String(date.getDate()).padStart(2, '0') |
|||
const hours = String(date.getHours()).padStart(2, '0') |
|||
const minutes = String(date.getMinutes()).padStart(2, '0') |
|||
return `${month}-${day} ${hours}:${minutes}` |
|||
}, |
|||
|
|||
viewOrderDetail(order) { |
|||
console.log('查看订单详情:', order) |
|||
uni.showModal({ |
|||
title: '订单详情', |
|||
content: `订单号:${order.order_no}\n商品:${order.product_name}\n金额:¥${order.total_amount}\n状态:${this.getStatusText(order.status)}`, |
|||
showCancel: false |
|||
}) |
|||
}, |
|||
|
|||
payOrder(order) { |
|||
this.selectedOrder = order |
|||
this.showPaymentPopup = true |
|||
}, |
|||
|
|||
closePaymentPopup() { |
|||
this.showPaymentPopup = false |
|||
this.selectedOrder = null |
|||
this.selectedPaymentMethod = 'wxpay' |
|||
}, |
|||
|
|||
selectPaymentMethod(method) { |
|||
this.selectedPaymentMethod = method |
|||
}, |
|||
|
|||
async confirmPayment() { |
|||
if (!this.selectedOrder || this.paying) return |
|||
|
|||
this.paying = true |
|||
try { |
|||
console.log('确认支付:', { |
|||
order_id: this.selectedOrder.id, |
|||
payment_method: this.selectedPaymentMethod |
|||
}) |
|||
|
|||
// 模拟支付 |
|||
await new Promise(resolve => setTimeout(resolve, 2000)) |
|||
const mockResponse = { code: 1, message: '支付成功' } |
|||
|
|||
if (mockResponse.code === 1) { |
|||
uni.showToast({ |
|||
title: '支付成功', |
|||
icon: 'success' |
|||
}) |
|||
|
|||
// 更新订单状态 |
|||
const orderIndex = this.ordersList.findIndex(o => o.id === this.selectedOrder.id) |
|||
if (orderIndex !== -1) { |
|||
this.ordersList[orderIndex].status = 'paid' |
|||
this.ordersList[orderIndex].payment_method = this.selectedPaymentMethod |
|||
this.ordersList[orderIndex].payment_time = new Date().toISOString().slice(0, 19).replace('T', ' ') |
|||
} |
|||
|
|||
this.closePaymentPopup() |
|||
this.applyStatusFilter() |
|||
this.updateStatusCounts() |
|||
} else { |
|||
uni.showToast({ |
|||
title: mockResponse.message || '支付失败', |
|||
icon: 'none' |
|||
}) |
|||
} |
|||
} catch (error) { |
|||
console.error('支付失败:', error) |
|||
uni.showToast({ |
|||
title: '支付失败', |
|||
icon: 'none' |
|||
}) |
|||
} finally { |
|||
this.paying = false |
|||
} |
|||
}, |
|||
|
|||
async cancelOrder(order) { |
|||
uni.showModal({ |
|||
title: '确认取消', |
|||
content: '确定要取消此订单吗?', |
|||
success: async (res) => { |
|||
if (res.confirm) { |
|||
try { |
|||
console.log('取消订单:', order.id) |
|||
|
|||
// 模拟取消订单 |
|||
await new Promise(resolve => setTimeout(resolve, 500)) |
|||
const mockResponse = { code: 1, message: '取消成功' } |
|||
|
|||
if (mockResponse.code === 1) { |
|||
uni.showToast({ |
|||
title: '取消成功', |
|||
icon: 'success' |
|||
}) |
|||
|
|||
// 更新订单状态 |
|||
const orderIndex = this.ordersList.findIndex(o => o.id === order.id) |
|||
if (orderIndex !== -1) { |
|||
this.ordersList[orderIndex].status = 'cancelled' |
|||
} |
|||
|
|||
this.applyStatusFilter() |
|||
this.updateStatusCounts() |
|||
} else { |
|||
uni.showToast({ |
|||
title: mockResponse.message || '取消失败', |
|||
icon: 'none' |
|||
}) |
|||
} |
|||
} catch (error) { |
|||
console.error('取消订单失败:', error) |
|||
uni.showToast({ |
|||
title: '取消失败', |
|||
icon: 'none' |
|||
}) |
|||
} |
|||
} |
|||
} |
|||
}) |
|||
}, |
|||
|
|||
viewRefundDetail(order) { |
|||
console.log('查看退款详情:', order) |
|||
const refundInfo = `订单号:${order.order_no}\n退款金额:¥${order.refund_amount || order.total_amount}\n退款时间:${order.refund_time || '处理中'}` |
|||
uni.showModal({ |
|||
title: '退款详情', |
|||
content: refundInfo, |
|||
showCancel: false |
|||
}) |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style lang="less" scoped> |
|||
.main_box { |
|||
background: #f8f9fa; |
|||
min-height: 100vh; |
|||
} |
|||
|
|||
// 自定义导航栏 |
|||
.navbar_section { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
background: #29D3B4; |
|||
padding: 40rpx 32rpx 20rpx; |
|||
|
|||
// 小程序端适配状态栏 |
|||
// #ifdef MP-WEIXIN |
|||
padding-top: 80rpx; |
|||
// #endif |
|||
|
|||
.navbar_back { |
|||
width: 60rpx; |
|||
|
|||
.back_icon { |
|||
color: #fff; |
|||
font-size: 40rpx; |
|||
font-weight: 600; |
|||
} |
|||
} |
|||
|
|||
.navbar_title { |
|||
color: #fff; |
|||
font-size: 32rpx; |
|||
font-weight: 600; |
|||
} |
|||
|
|||
.navbar_action { |
|||
width: 60rpx; |
|||
} |
|||
} |
|||
|
|||
// 学员信息 |
|||
.student_info_section { |
|||
background: #fff; |
|||
padding: 24rpx 32rpx; |
|||
border-bottom: 1px solid #f0f0f0; |
|||
|
|||
.student_name { |
|||
font-size: 32rpx; |
|||
font-weight: 600; |
|||
color: #333; |
|||
margin-bottom: 12rpx; |
|||
} |
|||
|
|||
.order_stats { |
|||
display: flex; |
|||
gap: 24rpx; |
|||
|
|||
.stat_item { |
|||
font-size: 24rpx; |
|||
color: #666; |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 状态筛选 |
|||
.filter_section { |
|||
background: #fff; |
|||
margin: 20rpx; |
|||
border-radius: 16rpx; |
|||
padding: 24rpx 32rpx; |
|||
|
|||
.filter_tabs { |
|||
display: flex; |
|||
flex-wrap: wrap; |
|||
gap: 16rpx; |
|||
|
|||
.filter_tab { |
|||
position: relative; |
|||
padding: 12rpx 24rpx; |
|||
font-size: 26rpx; |
|||
color: #666; |
|||
background: #f8f9fa; |
|||
border-radius: 20rpx; |
|||
|
|||
&.active { |
|||
color: #fff; |
|||
background: #29D3B4; |
|||
} |
|||
|
|||
.tab_badge { |
|||
position: absolute; |
|||
top: -8rpx; |
|||
right: -8rpx; |
|||
background: #ff4757; |
|||
color: #fff; |
|||
font-size: 18rpx; |
|||
padding: 2rpx 8rpx; |
|||
border-radius: 12rpx; |
|||
min-width: 16rpx; |
|||
text-align: center; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 订单列表 |
|||
.orders_section { |
|||
margin: 0 20rpx; |
|||
|
|||
.loading_section, .empty_section { |
|||
background: #fff; |
|||
border-radius: 16rpx; |
|||
padding: 80rpx 32rpx; |
|||
text-align: center; |
|||
|
|||
.loading_text, .empty_text { |
|||
font-size: 28rpx; |
|||
color: #666; |
|||
margin-bottom: 12rpx; |
|||
} |
|||
|
|||
.empty_icon { |
|||
font-size: 80rpx; |
|||
margin-bottom: 24rpx; |
|||
} |
|||
|
|||
.empty_hint { |
|||
font-size: 24rpx; |
|||
color: #999; |
|||
} |
|||
} |
|||
|
|||
.orders_list { |
|||
.order_item { |
|||
background: #fff; |
|||
border-radius: 16rpx; |
|||
padding: 24rpx; |
|||
margin-bottom: 20rpx; |
|||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08); |
|||
|
|||
.order_header { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
margin-bottom: 20rpx; |
|||
|
|||
.order_number { |
|||
font-size: 26rpx; |
|||
color: #666; |
|||
} |
|||
|
|||
.order_status { |
|||
font-size: 24rpx; |
|||
padding: 6rpx 12rpx; |
|||
border-radius: 12rpx; |
|||
|
|||
&.pending_payment { |
|||
color: #f39c12; |
|||
background: rgba(243, 156, 18, 0.1); |
|||
} |
|||
|
|||
&.paid { |
|||
color: #3498db; |
|||
background: rgba(52, 152, 219, 0.1); |
|||
} |
|||
|
|||
&.completed { |
|||
color: #27ae60; |
|||
background: rgba(39, 174, 96, 0.1); |
|||
} |
|||
|
|||
&.cancelled { |
|||
color: #95a5a6; |
|||
background: rgba(149, 165, 166, 0.1); |
|||
} |
|||
|
|||
&.refunded { |
|||
color: #e74c3c; |
|||
background: rgba(231, 76, 60, 0.1); |
|||
} |
|||
} |
|||
} |
|||
|
|||
.order_content { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: flex-start; |
|||
margin-bottom: 20rpx; |
|||
|
|||
.order_info { |
|||
flex: 1; |
|||
|
|||
.product_name { |
|||
font-size: 28rpx; |
|||
font-weight: 600; |
|||
color: #333; |
|||
margin-bottom: 8rpx; |
|||
} |
|||
|
|||
.product_specs { |
|||
font-size: 24rpx; |
|||
color: #666; |
|||
margin-bottom: 12rpx; |
|||
} |
|||
|
|||
.order_meta { |
|||
display: flex; |
|||
gap: 16rpx; |
|||
|
|||
.meta_item { |
|||
font-size: 22rpx; |
|||
color: #999; |
|||
} |
|||
} |
|||
} |
|||
|
|||
.order_amount { |
|||
text-align: right; |
|||
|
|||
.amount_label { |
|||
font-size: 22rpx; |
|||
color: #666; |
|||
margin-bottom: 4rpx; |
|||
} |
|||
|
|||
.amount_value { |
|||
font-size: 32rpx; |
|||
font-weight: 600; |
|||
color: #e74c3c; |
|||
} |
|||
} |
|||
} |
|||
|
|||
.order_actions { |
|||
display: flex; |
|||
justify-content: flex-end; |
|||
gap: 16rpx; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 加载更多 |
|||
.load_more_section { |
|||
padding: 40rpx 20rpx 80rpx; |
|||
} |
|||
|
|||
// 支付弹窗 |
|||
.payment_popup { |
|||
position: fixed; |
|||
top: 0; |
|||
left: 0; |
|||
right: 0; |
|||
bottom: 0; |
|||
background: rgba(0, 0, 0, 0.5); |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
z-index: 1000; |
|||
|
|||
.popup_content { |
|||
background: #fff; |
|||
border-radius: 16rpx; |
|||
width: 85%; |
|||
max-height: 70vh; |
|||
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 { |
|||
font-size: 48rpx; |
|||
color: #999; |
|||
font-weight: 300; |
|||
} |
|||
} |
|||
|
|||
.payment_order_info { |
|||
padding: 24rpx 32rpx; |
|||
border-bottom: 1px solid #f8f9fa; |
|||
|
|||
.order_summary { |
|||
.summary_row { |
|||
display: flex; |
|||
margin-bottom: 12rpx; |
|||
|
|||
.summary_label { |
|||
font-size: 26rpx; |
|||
color: #666; |
|||
min-width: 120rpx; |
|||
} |
|||
|
|||
.summary_value { |
|||
font-size: 26rpx; |
|||
color: #333; |
|||
|
|||
&.amount { |
|||
color: #e74c3c; |
|||
font-weight: 600; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
.payment_methods { |
|||
padding: 24rpx 32rpx; |
|||
|
|||
.payment_method { |
|||
display: flex; |
|||
align-items: center; |
|||
padding: 20rpx 0; |
|||
border-bottom: 1px solid #f8f9fa; |
|||
|
|||
&:last-child { |
|||
border-bottom: none; |
|||
} |
|||
|
|||
&.selected { |
|||
background: rgba(41, 211, 180, 0.05); |
|||
} |
|||
|
|||
.method_icon { |
|||
width: 60rpx; |
|||
height: 60rpx; |
|||
margin-right: 20rpx; |
|||
|
|||
.icon_image { |
|||
width: 100%; |
|||
height: 100%; |
|||
} |
|||
} |
|||
|
|||
.method_info { |
|||
flex: 1; |
|||
|
|||
.method_name { |
|||
font-size: 28rpx; |
|||
color: #333; |
|||
margin-bottom: 4rpx; |
|||
} |
|||
|
|||
.method_desc { |
|||
font-size: 22rpx; |
|||
color: #999; |
|||
} |
|||
} |
|||
|
|||
.method_radio { |
|||
.radio_checked { |
|||
width: 32rpx; |
|||
height: 32rpx; |
|||
background: #29d3b4; |
|||
border-radius: 50%; |
|||
color: #fff; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
font-size: 18rpx; |
|||
} |
|||
|
|||
.radio_unchecked { |
|||
width: 32rpx; |
|||
height: 32rpx; |
|||
border: 2rpx solid #ddd; |
|||
border-radius: 50%; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
.popup_actions { |
|||
padding: 24rpx 32rpx; |
|||
display: flex; |
|||
gap: 16rpx; |
|||
|
|||
fui-button { |
|||
flex: 1; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
</style> |
|||
@ -0,0 +1,736 @@ |
|||
<!--学员体测数据页面--> |
|||
<template> |
|||
<view class="main_box"> |
|||
<!-- 自定义导航栏 --> |
|||
<view class="navbar_section"> |
|||
<view class="navbar_back" @click="goBack"> |
|||
<text class="back_icon">‹</text> |
|||
</view> |
|||
<view class="navbar_title">体测数据</view> |
|||
<view class="navbar_action"> |
|||
<view class="share_button" @click="sharePhysicalTest" v-if="physicalTestList.length > 0"> |
|||
<text class="share_text">分享</text> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 学员基本信息 --> |
|||
<view class="student_basic_section" v-if="studentInfo"> |
|||
<view class="student_name">{{ studentInfo.name }}</view> |
|||
<view class="student_meta"> |
|||
<text class="meta_item">{{ studentInfo.gender_text }}</text> |
|||
<text class="meta_item">{{ studentInfo.age }}岁</text> |
|||
<text class="meta_item">身高: {{ studentInfo.height }}cm</text> |
|||
<text class="meta_item">体重: {{ studentInfo.weight }}kg</text> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 数据统计卡片 --> |
|||
<view class="stats_section"> |
|||
<view class="stat_card"> |
|||
<view class="stat_number">{{ physicalTestList.length }}</view> |
|||
<view class="stat_label">测试次数</view> |
|||
</view> |
|||
<view class="stat_card"> |
|||
<view class="stat_number">{{ latestScore || 0 }}</view> |
|||
<view class="stat_label">最新得分</view> |
|||
</view> |
|||
<view class="stat_card"> |
|||
<view class="stat_number">{{ improvementRate }}%</view> |
|||
<view class="stat_label">提升率</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 筛选器 --> |
|||
<view class="filter_section"> |
|||
<view class="filter_tabs"> |
|||
<view |
|||
v-for="tab in filterTabs" |
|||
:key="tab.value" |
|||
:class="['filter_tab', activeFilter === tab.value ? 'active' : '']" |
|||
@click="changeFilter(tab.value)" |
|||
> |
|||
{{ tab.text }} |
|||
</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 体测记录列表 --> |
|||
<view class="test_list_section"> |
|||
<view v-if="loading" class="loading_section"> |
|||
<view class="loading_text">加载中...</view> |
|||
</view> |
|||
|
|||
<view v-else-if="filteredTestList.length === 0" class="empty_section"> |
|||
<view class="empty_icon">📊</view> |
|||
<view class="empty_text">暂无体测数据</view> |
|||
<view class="empty_hint">完成体测后数据会在这里显示</view> |
|||
</view> |
|||
|
|||
<view v-else class="test_list"> |
|||
<view |
|||
v-for="test in filteredTestList" |
|||
:key="test.id" |
|||
class="test_item" |
|||
@click="viewTestDetail(test)" |
|||
> |
|||
<view class="test_header"> |
|||
<view class="test_date">{{ formatDate(test.test_date) }}</view> |
|||
<view :class="['test_score',getScoreClass(test.total_score)]"> |
|||
{{ test.total_score }}分 |
|||
</view> |
|||
</view> |
|||
|
|||
<view class="test_items"> |
|||
<view |
|||
v-for="item in test.test_items" |
|||
:key="item.project_name" |
|||
class="test_item_row" |
|||
> |
|||
<view class="item_name">{{ item.project_name }}</view> |
|||
<view class="item_value"> |
|||
<text class="value_text">{{ item.test_value }}{{ item.unit }}</text> |
|||
<text class="score_text">{{ item.score }}分</text> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<view class="test_footer" v-if="test.remark"> |
|||
<view class="test_remark">备注:{{ test.remark }}</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 加载更多 --> |
|||
<view class="load_more_section" v-if="!loading && hasMore"> |
|||
<fui-button |
|||
background="transparent" |
|||
color="#666" |
|||
@click="loadMoreTests" |
|||
:loading="loadingMore" |
|||
> |
|||
{{ loadingMore ? '加载中...' : '加载更多' }} |
|||
</fui-button> |
|||
</view> |
|||
</view> |
|||
</template> |
|||
|
|||
<script> |
|||
import apiRoute from '@/api/apiRoute.js' |
|||
|
|||
export default { |
|||
data() { |
|||
return { |
|||
studentId: 0, |
|||
studentInfo: {}, |
|||
physicalTestList: [], |
|||
filteredTestList: [], |
|||
loading: false, |
|||
loadingMore: false, |
|||
hasMore: true, |
|||
currentPage: 1, |
|||
activeFilter: 'all', |
|||
filterTabs: [ |
|||
{ value: 'all', text: '全部' }, |
|||
{ value: 'latest', text: '最近3次' }, |
|||
{ value: 'excellent', text: '优秀' }, |
|||
{ value: 'good', text: '良好' } |
|||
] |
|||
} |
|||
}, |
|||
|
|||
computed: { |
|||
latestScore() { |
|||
if (this.physicalTestList.length === 0) return 0 |
|||
return this.physicalTestList[0].total_score |
|||
}, |
|||
|
|||
improvementRate() { |
|||
if (this.physicalTestList.length < 2) return 0 |
|||
const latest = this.physicalTestList[0].total_score |
|||
const previous = this.physicalTestList[1].total_score |
|||
if (previous === 0) return 0 |
|||
return Math.round(((latest - previous) / previous) * 100) |
|||
} |
|||
}, |
|||
|
|||
onLoad(options) { |
|||
this.studentId = parseInt(options.student_id) || 0 |
|||
if (this.studentId) { |
|||
this.initPage() |
|||
} else { |
|||
uni.showToast({ |
|||
title: '参数错误', |
|||
icon: 'none' |
|||
}) |
|||
} |
|||
}, |
|||
|
|||
methods: { |
|||
goBack() { |
|||
uni.navigateBack() |
|||
}, |
|||
|
|||
async initPage() { |
|||
await this.loadStudentInfo() |
|||
await this.loadPhysicalTests() |
|||
}, |
|||
|
|||
async loadStudentInfo() { |
|||
try { |
|||
// 模拟获取学员信息 |
|||
const mockStudentInfo = { |
|||
id: this.studentId, |
|||
name: '小明', |
|||
gender_text: '男', |
|||
age: 8, |
|||
height: 130, |
|||
weight: 28 |
|||
} |
|||
this.studentInfo = mockStudentInfo |
|||
} catch (error) { |
|||
console.error('获取学员信息失败:', error) |
|||
} |
|||
}, |
|||
|
|||
async loadPhysicalTests() { |
|||
this.loading = true |
|||
try { |
|||
console.log('加载体测数据:', this.studentId) |
|||
|
|||
// 模拟API调用 |
|||
// const response = await apiRoute.getPhysicalTestList({ |
|||
// student_id: this.studentId, |
|||
// page: this.currentPage, |
|||
// limit: 10 |
|||
// }) |
|||
|
|||
// 使用模拟数据 |
|||
const mockResponse = { |
|||
code: 1, |
|||
data: { |
|||
list: [ |
|||
{ |
|||
id: 1, |
|||
test_date: '2024-01-15', |
|||
total_score: 85, |
|||
level: 'good', |
|||
remark: '本次测试表现良好,身体素质有所提升', |
|||
test_items: [ |
|||
{ |
|||
project_name: '身高', |
|||
test_value: '130', |
|||
unit: 'cm', |
|||
score: 20, |
|||
standard_value: '125-135' |
|||
}, |
|||
{ |
|||
project_name: '体重', |
|||
test_value: '28', |
|||
unit: 'kg', |
|||
score: 18, |
|||
standard_value: '25-32' |
|||
}, |
|||
{ |
|||
project_name: '50米跑', |
|||
test_value: '9.2', |
|||
unit: '秒', |
|||
score: 22, |
|||
standard_value: '8.5-10.0' |
|||
}, |
|||
{ |
|||
project_name: '立定跳远', |
|||
test_value: '145', |
|||
unit: 'cm', |
|||
score: 25, |
|||
standard_value: '140-160' |
|||
} |
|||
] |
|||
}, |
|||
{ |
|||
id: 2, |
|||
test_date: '2023-12-10', |
|||
total_score: 78, |
|||
level: 'good', |
|||
remark: '整体表现不错,速度项目需要加强', |
|||
test_items: [ |
|||
{ |
|||
project_name: '身高', |
|||
test_value: '128', |
|||
unit: 'cm', |
|||
score: 20, |
|||
standard_value: '125-135' |
|||
}, |
|||
{ |
|||
project_name: '体重', |
|||
test_value: '27', |
|||
unit: 'kg', |
|||
score: 18, |
|||
standard_value: '25-32' |
|||
}, |
|||
{ |
|||
project_name: '50米跑', |
|||
test_value: '9.8', |
|||
unit: '秒', |
|||
score: 18, |
|||
standard_value: '8.5-10.0' |
|||
}, |
|||
{ |
|||
project_name: '立定跳远', |
|||
test_value: '138', |
|||
unit: 'cm', |
|||
score: 22, |
|||
standard_value: '140-160' |
|||
} |
|||
] |
|||
}, |
|||
{ |
|||
id: 3, |
|||
test_date: '2023-11-05', |
|||
total_score: 92, |
|||
level: 'excellent', |
|||
remark: '各项指标都很优秀,继续保持', |
|||
test_items: [ |
|||
{ |
|||
project_name: '身高', |
|||
test_value: '126', |
|||
unit: 'cm', |
|||
score: 20, |
|||
standard_value: '125-135' |
|||
}, |
|||
{ |
|||
project_name: '体重', |
|||
test_value: '26', |
|||
unit: 'kg', |
|||
score: 20, |
|||
standard_value: '25-32' |
|||
}, |
|||
{ |
|||
project_name: '50米跑', |
|||
test_value: '8.8', |
|||
unit: '秒', |
|||
score: 25, |
|||
standard_value: '8.5-10.0' |
|||
}, |
|||
{ |
|||
project_name: '立定跳远', |
|||
test_value: '155', |
|||
unit: 'cm', |
|||
score: 27, |
|||
standard_value: '140-160' |
|||
} |
|||
] |
|||
} |
|||
], |
|||
total: 3, |
|||
has_more: false |
|||
} |
|||
} |
|||
|
|||
if (mockResponse.code === 1) { |
|||
const newList = mockResponse.data.list || [] |
|||
if (this.currentPage === 1) { |
|||
this.physicalTestList = newList |
|||
} else { |
|||
this.physicalTestList = [...this.physicalTestList, ...newList] |
|||
} |
|||
|
|||
this.hasMore = mockResponse.data.has_more || false |
|||
this.applyFilter() |
|||
console.log('体测数据加载成功:', this.physicalTestList) |
|||
} else { |
|||
uni.showToast({ |
|||
title: mockResponse.msg || '获取体测数据失败', |
|||
icon: 'none' |
|||
}) |
|||
} |
|||
} catch (error) { |
|||
console.error('获取体测数据失败:', error) |
|||
uni.showToast({ |
|||
title: '获取体测数据失败', |
|||
icon: 'none' |
|||
}) |
|||
} finally { |
|||
this.loading = false |
|||
this.loadingMore = false |
|||
} |
|||
}, |
|||
|
|||
async loadMoreTests() { |
|||
if (this.loadingMore || !this.hasMore) return |
|||
|
|||
this.loadingMore = true |
|||
this.currentPage++ |
|||
await this.loadPhysicalTests() |
|||
}, |
|||
|
|||
changeFilter(filterValue) { |
|||
this.activeFilter = filterValue |
|||
this.applyFilter() |
|||
}, |
|||
|
|||
applyFilter() { |
|||
let filteredList = [...this.physicalTestList] |
|||
|
|||
switch (this.activeFilter) { |
|||
case 'latest': |
|||
filteredList = filteredList.slice(0, 3) |
|||
break |
|||
case 'excellent': |
|||
filteredList = filteredList.filter(test => test.total_score >= 90) |
|||
break |
|||
case 'good': |
|||
filteredList = filteredList.filter(test => test.total_score >= 80 && test.total_score < 90) |
|||
break |
|||
case 'all': |
|||
default: |
|||
// 不需要过滤 |
|||
break |
|||
} |
|||
|
|||
this.filteredTestList = filteredList |
|||
}, |
|||
|
|||
getScoreClass(score) { |
|||
if (score >= 90) return 'excellent' |
|||
if (score >= 80) return 'good' |
|||
if (score >= 70) return 'average' |
|||
return 'poor' |
|||
}, |
|||
|
|||
formatDate(dateString) { |
|||
const date = new Date(dateString) |
|||
const year = date.getFullYear() |
|||
const month = String(date.getMonth() + 1).padStart(2, '0') |
|||
const day = String(date.getDate()).padStart(2, '0') |
|||
return `${year}-${month}-${day}` |
|||
}, |
|||
|
|||
viewTestDetail(test) { |
|||
// 跳转到详情页面或者展示详情弹窗 |
|||
console.log('查看体测详情:', test) |
|||
uni.showModal({ |
|||
title: '体测详情', |
|||
content: `测试日期:${this.formatDate(test.test_date)}\n总分:${test.total_score}分\n备注:${test.remark || '无'}`, |
|||
showCancel: false |
|||
}) |
|||
}, |
|||
|
|||
async sharePhysicalTest() { |
|||
try { |
|||
uni.showLoading({ |
|||
title: '生成分享图片...' |
|||
}) |
|||
|
|||
// 模拟生成分享图片 |
|||
await new Promise(resolve => setTimeout(resolve, 1500)) |
|||
|
|||
// 模拟分享 |
|||
const mockShareResult = { |
|||
code: 1, |
|||
data: { |
|||
image_url: '/static/temp/physical_test_share.jpg', |
|||
share_title: `${this.studentInfo.name}的体测报告` |
|||
} |
|||
} |
|||
|
|||
if (mockShareResult.code === 1) { |
|||
uni.hideLoading() |
|||
uni.showActionSheet({ |
|||
itemList: ['保存到相册', '分享给朋友'], |
|||
success: (res) => { |
|||
if (res.tapIndex === 0) { |
|||
uni.showToast({ |
|||
title: '已保存到相册', |
|||
icon: 'success' |
|||
}) |
|||
} else if (res.tapIndex === 1) { |
|||
uni.showToast({ |
|||
title: '分享功能开发中', |
|||
icon: 'none' |
|||
}) |
|||
} |
|||
} |
|||
}) |
|||
} else { |
|||
throw new Error('生成分享图片失败') |
|||
} |
|||
} catch (error) { |
|||
console.error('分享失败:', error) |
|||
uni.hideLoading() |
|||
uni.showToast({ |
|||
title: '分享失败', |
|||
icon: 'none' |
|||
}) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style lang="less" scoped> |
|||
.main_box { |
|||
background: #f8f9fa; |
|||
min-height: 100vh; |
|||
} |
|||
|
|||
// 自定义导航栏 |
|||
.navbar_section { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
background: #29D3B4; |
|||
padding: 40rpx 32rpx 20rpx; |
|||
|
|||
// 小程序端适配状态栏 |
|||
// #ifdef MP-WEIXIN |
|||
padding-top: 80rpx; |
|||
// #endif |
|||
|
|||
.navbar_back { |
|||
width: 60rpx; |
|||
|
|||
.back_icon { |
|||
color: #fff; |
|||
font-size: 40rpx; |
|||
font-weight: 600; |
|||
} |
|||
} |
|||
|
|||
.navbar_title { |
|||
color: #fff; |
|||
font-size: 32rpx; |
|||
font-weight: 600; |
|||
} |
|||
|
|||
.navbar_action { |
|||
width: 60rpx; |
|||
display: flex; |
|||
justify-content: flex-end; |
|||
|
|||
.share_button { |
|||
background: rgba(255, 255, 255, 0.2); |
|||
padding: 8rpx 16rpx; |
|||
border-radius: 16rpx; |
|||
|
|||
.share_text { |
|||
color: #fff; |
|||
font-size: 24rpx; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 学员基本信息 |
|||
.student_basic_section { |
|||
background: #fff; |
|||
padding: 24rpx 32rpx; |
|||
border-bottom: 1px solid #f0f0f0; |
|||
|
|||
.student_name { |
|||
font-size: 32rpx; |
|||
font-weight: 600; |
|||
color: #333; |
|||
margin-bottom: 12rpx; |
|||
} |
|||
|
|||
.student_meta { |
|||
display: flex; |
|||
flex-wrap: wrap; |
|||
gap: 16rpx; |
|||
|
|||
.meta_item { |
|||
font-size: 24rpx; |
|||
color: #666; |
|||
background: #f0f0f0; |
|||
padding: 6rpx 12rpx; |
|||
border-radius: 12rpx; |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 统计卡片 |
|||
.stats_section { |
|||
display: flex; |
|||
background: #fff; |
|||
margin: 20rpx; |
|||
border-radius: 16rpx; |
|||
padding: 32rpx 24rpx; |
|||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08); |
|||
|
|||
.stat_card { |
|||
flex: 1; |
|||
text-align: center; |
|||
|
|||
.stat_number { |
|||
font-size: 36rpx; |
|||
font-weight: 600; |
|||
color: #29D3B4; |
|||
margin-bottom: 8rpx; |
|||
} |
|||
|
|||
.stat_label { |
|||
font-size: 24rpx; |
|||
color: #666; |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 筛选器 |
|||
.filter_section { |
|||
background: #fff; |
|||
margin: 0 20rpx 20rpx; |
|||
border-radius: 16rpx; |
|||
padding: 24rpx 32rpx; |
|||
|
|||
.filter_tabs { |
|||
display: flex; |
|||
gap: 16rpx; |
|||
|
|||
.filter_tab { |
|||
padding: 12rpx 24rpx; |
|||
font-size: 26rpx; |
|||
color: #666; |
|||
background: #f8f9fa; |
|||
border-radius: 20rpx; |
|||
|
|||
&.active { |
|||
color: #fff; |
|||
background: #29D3B4; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 体测记录列表 |
|||
.test_list_section { |
|||
margin: 0 20rpx; |
|||
|
|||
.loading_section, .empty_section { |
|||
background: #fff; |
|||
border-radius: 16rpx; |
|||
padding: 80rpx 32rpx; |
|||
text-align: center; |
|||
|
|||
.loading_text, .empty_text { |
|||
font-size: 28rpx; |
|||
color: #666; |
|||
margin-bottom: 12rpx; |
|||
} |
|||
|
|||
.empty_icon { |
|||
font-size: 80rpx; |
|||
margin-bottom: 24rpx; |
|||
} |
|||
|
|||
.empty_hint { |
|||
font-size: 24rpx; |
|||
color: #999; |
|||
} |
|||
} |
|||
|
|||
.test_list { |
|||
.test_item { |
|||
background: #fff; |
|||
border-radius: 16rpx; |
|||
padding: 32rpx; |
|||
margin-bottom: 20rpx; |
|||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08); |
|||
|
|||
.test_header { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
margin-bottom: 24rpx; |
|||
|
|||
.test_date { |
|||
font-size: 28rpx; |
|||
color: #333; |
|||
font-weight: 600; |
|||
} |
|||
|
|||
.test_score { |
|||
font-size: 32rpx; |
|||
font-weight: 600; |
|||
padding: 8rpx 16rpx; |
|||
border-radius: 16rpx; |
|||
|
|||
&.excellent { |
|||
color: #27ae60; |
|||
background: rgba(39, 174, 96, 0.1); |
|||
} |
|||
|
|||
&.good { |
|||
color: #f39c12; |
|||
background: rgba(243, 156, 18, 0.1); |
|||
} |
|||
|
|||
&.average { |
|||
color: #3498db; |
|||
background: rgba(52, 152, 219, 0.1); |
|||
} |
|||
|
|||
&.poor { |
|||
color: #e74c3c; |
|||
background: rgba(231, 76, 60, 0.1); |
|||
} |
|||
} |
|||
} |
|||
|
|||
.test_items { |
|||
.test_item_row { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
padding: 16rpx 0; |
|||
border-bottom: 1px solid #f8f9fa; |
|||
|
|||
&:last-child { |
|||
border-bottom: none; |
|||
} |
|||
|
|||
.item_name { |
|||
font-size: 26rpx; |
|||
color: #333; |
|||
} |
|||
|
|||
.item_value { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 12rpx; |
|||
|
|||
.value_text { |
|||
font-size: 26rpx; |
|||
color: #666; |
|||
} |
|||
|
|||
.score_text { |
|||
font-size: 24rpx; |
|||
color: #29D3B4; |
|||
font-weight: 600; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
.test_footer { |
|||
margin-top: 20rpx; |
|||
padding-top: 20rpx; |
|||
border-top: 1px solid #f8f9fa; |
|||
|
|||
.test_remark { |
|||
font-size: 24rpx; |
|||
color: #666; |
|||
line-height: 1.4; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 加载更多 |
|||
.load_more_section { |
|||
padding: 40rpx 20rpx 80rpx; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,575 @@ |
|||
<!--学员个人信息管理页面--> |
|||
<template> |
|||
<view class="main_box"> |
|||
<!-- 自定义导航栏 --> |
|||
<view class="navbar_section"> |
|||
<view class="navbar_back" @click="goBack"> |
|||
<text class="back_icon">‹</text> |
|||
</view> |
|||
<view class="navbar_title">个人信息管理</view> |
|||
<view class="navbar_action"></view> |
|||
</view> |
|||
|
|||
<!-- 学员头像区域 --> |
|||
<view class="avatar_section"> |
|||
<view class="avatar_container"> |
|||
<image |
|||
:src="studentInfo.headimg || '/static/default-avatar.png'" |
|||
class="avatar_image" |
|||
mode="aspectFill" |
|||
@click="uploadAvatar" |
|||
></image> |
|||
<view class="avatar_edit_icon" @click="uploadAvatar"> |
|||
<text class="edit_text">✎</text> |
|||
</view> |
|||
</view> |
|||
<view class="student_name">{{ studentInfo.name || '学员姓名' }}</view> |
|||
<view class="student_basic_info"> |
|||
<text class="info_tag">{{ studentInfo.gender_text }}</text> |
|||
<text class="info_tag">{{ studentInfo.ageText }}岁</text> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 基本信息表单 --> |
|||
<view class="form_section"> |
|||
<view class="section_title">基本信息</view> |
|||
|
|||
<view class="form_item"> |
|||
<view class="form_label">姓名</view> |
|||
<fui-input |
|||
v-model="formData.name" |
|||
placeholder="请输入学员姓名" |
|||
borderColor="transparent" |
|||
backgroundColor="#f8f9fa" |
|||
:maxlength="20" |
|||
></fui-input> |
|||
</view> |
|||
|
|||
<view class="form_item"> |
|||
<view class="form_label">性别</view> |
|||
<fui-input |
|||
v-model="genderText" |
|||
placeholder="请选择性别" |
|||
borderColor="transparent" |
|||
backgroundColor="#f8f9fa" |
|||
readonly |
|||
@click="showGenderPicker = true" |
|||
> |
|||
<fui-icon name="arrowdown" color="#B2B2B2" :size="40"></fui-icon> |
|||
</fui-input> |
|||
<fui-picker |
|||
:options="genderOptions" |
|||
:show="showGenderPicker" |
|||
@change="changeGender" |
|||
@cancel="showGenderPicker = false" |
|||
></fui-picker> |
|||
</view> |
|||
|
|||
<view class="form_item"> |
|||
<view class="form_label">生日</view> |
|||
<fui-date-picker |
|||
:show="showDatePicker" |
|||
v-model="formData.birthday" |
|||
@change="changeBirthday" |
|||
@cancel="showDatePicker = false" |
|||
> |
|||
<fui-input |
|||
:value="birthdayText" |
|||
placeholder="请选择生日" |
|||
borderColor="transparent" |
|||
backgroundColor="#f8f9fa" |
|||
readonly |
|||
@click="showDatePicker = true" |
|||
> |
|||
<fui-icon name="arrowdown" color="#B2B2B2" :size="40"></fui-icon> |
|||
</fui-input> |
|||
</fui-date-picker> |
|||
</view> |
|||
|
|||
<view class="form_item"> |
|||
<view class="form_label">年龄</view> |
|||
<fui-input |
|||
:value="ageText" |
|||
placeholder="根据生日自动计算" |
|||
borderColor="transparent" |
|||
backgroundColor="#f0f0f0" |
|||
readonly |
|||
></fui-input> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 体测信息(只读展示) --> |
|||
<view class="form_section"> |
|||
<view class="section_title">体测信息</view> |
|||
|
|||
<view class="form_item"> |
|||
<view class="form_label">身高 (cm)</view> |
|||
<fui-input |
|||
:value="physicalTestInfo.height || '暂无数据'" |
|||
placeholder="暂无体测数据" |
|||
borderColor="transparent" |
|||
backgroundColor="#f0f0f0" |
|||
readonly |
|||
></fui-input> |
|||
</view> |
|||
|
|||
<view class="form_item"> |
|||
<view class="form_label">体重 (kg)</view> |
|||
<fui-input |
|||
:value="physicalTestInfo.weight || '暂无数据'" |
|||
placeholder="暂无体测数据" |
|||
borderColor="transparent" |
|||
backgroundColor="#f0f0f0" |
|||
readonly |
|||
></fui-input> |
|||
</view> |
|||
|
|||
<view class="form_item" v-if="physicalTestInfo.test_date"> |
|||
<view class="form_label">体测日期</view> |
|||
<fui-input |
|||
:value="physicalTestInfo.test_date" |
|||
placeholder="暂无体测数据" |
|||
borderColor="transparent" |
|||
backgroundColor="#f0f0f0" |
|||
readonly |
|||
></fui-input> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 联系信息 --> |
|||
<view class="form_section"> |
|||
<view class="section_title">紧急联系人</view> |
|||
|
|||
<view class="form_item"> |
|||
<view class="form_label">联系人姓名</view> |
|||
<fui-input |
|||
v-model="formData.emergency_contact" |
|||
placeholder="请输入紧急联系人姓名" |
|||
borderColor="transparent" |
|||
backgroundColor="#f8f9fa" |
|||
:maxlength="20" |
|||
></fui-input> |
|||
</view> |
|||
|
|||
<view class="form_item"> |
|||
<view class="form_label">联系电话</view> |
|||
<fui-input |
|||
v-model="formData.contact_phone" |
|||
placeholder="请输入联系电话" |
|||
borderColor="transparent" |
|||
backgroundColor="#f8f9fa" |
|||
type="number" |
|||
:maxlength="11" |
|||
></fui-input> |
|||
</view> |
|||
|
|||
<view class="form_item"> |
|||
<view class="form_label">备注信息</view> |
|||
<fui-textarea |
|||
v-model="formData.note" |
|||
placeholder="其他需要说明的信息" |
|||
backgroundColor="#f8f9fa" |
|||
:maxlength="500" |
|||
:rows="4" |
|||
></fui-textarea> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 保存按钮 --> |
|||
<view class="save_section"> |
|||
<fui-button |
|||
background="#29d3b4" |
|||
radius="12rpx" |
|||
:loading="saving" |
|||
@click="saveStudentInfo" |
|||
> |
|||
{{ saving ? '保存中...' : '保存信息' }} |
|||
</fui-button> |
|||
</view> |
|||
</view> |
|||
</template> |
|||
|
|||
<script> |
|||
import apiRoute from '@/api/member.js' |
|||
|
|||
export default { |
|||
data() { |
|||
return { |
|||
studentId: 0, |
|||
studentInfo: {}, |
|||
physicalTestInfo: {}, // 体测信息 |
|||
formData: { |
|||
name: '', |
|||
gender: '', |
|||
birthday: '', |
|||
emergency_contact: '', |
|||
contact_phone: '', |
|||
note: '' |
|||
}, |
|||
saving: false, |
|||
showGenderPicker: false, |
|||
showDatePicker: false, |
|||
genderOptions: [ |
|||
{ value: '1', text: '男' }, |
|||
{ value: '2', text: '女' } |
|||
] |
|||
} |
|||
}, |
|||
computed: { |
|||
genderText() { |
|||
const gender = this.genderOptions.find(item => item.value === this.formData.gender) |
|||
return gender ? gender.text : '' |
|||
}, |
|||
birthdayText() { |
|||
return this.formData.birthday || '' |
|||
}, |
|||
ageText() { |
|||
if (!this.formData.birthday) return '' |
|||
|
|||
const today = new Date() |
|||
const birthDate = new Date(this.formData.birthday) |
|||
let age = today.getFullYear() - birthDate.getFullYear() |
|||
let months = today.getMonth() - birthDate.getMonth() |
|||
|
|||
if (months < 0 || (months === 0 && today.getDate() < birthDate.getDate())) { |
|||
age-- |
|||
months += 12 |
|||
} |
|||
|
|||
if (months < 0) { |
|||
months = 0 |
|||
} |
|||
|
|||
return age > 0 ? `${age}.${months.toString().padStart(2, '0')}岁` : `${months}个月` |
|||
} |
|||
}, |
|||
onLoad(options) { |
|||
this.studentId = parseInt(options.student_id) || 0 |
|||
if (this.studentId) { |
|||
this.loadStudentInfo() |
|||
} else { |
|||
uni.showToast({ |
|||
title: '参数错误', |
|||
icon: 'none' |
|||
}) |
|||
} |
|||
}, |
|||
|
|||
methods: { |
|||
goBack() { |
|||
uni.navigateBack() |
|||
}, |
|||
|
|||
async loadStudentInfo() { |
|||
try { |
|||
console.log('加载学员信息:', this.studentId) |
|||
|
|||
// 调用真实API |
|||
const response = await apiRoute.getStudentInfo(this.studentId) |
|||
console.log('学员信息API响应:', response) |
|||
|
|||
if (response.code === 1) { |
|||
this.studentInfo = response.data.student_info |
|||
this.physicalTestInfo = response.data.physical_test_info || {} |
|||
|
|||
// 填充表单数据 |
|||
this.formData = { |
|||
name: this.studentInfo.name || '', |
|||
gender: String(this.studentInfo.gender || ''), |
|||
birthday: this.studentInfo.birthday || '', |
|||
emergency_contact: this.studentInfo.emergency_contact || '', |
|||
contact_phone: this.studentInfo.contact_phone || '', |
|||
note: this.studentInfo.note || '' |
|||
} |
|||
console.log('学员信息加载成功:', this.studentInfo) |
|||
console.log('体测信息加载成功:', this.physicalTestInfo) |
|||
} else { |
|||
uni.showToast({ |
|||
title: response.msg || '获取学员信息失败', |
|||
icon: 'none' |
|||
}) |
|||
} |
|||
} catch (error) { |
|||
console.error('获取学员信息失败:', error) |
|||
uni.showToast({ |
|||
title: '获取学员信息失败', |
|||
icon: 'none' |
|||
}) |
|||
} |
|||
}, |
|||
|
|||
changeGender(e) { |
|||
this.formData.gender = e.value |
|||
this.showGenderPicker = false |
|||
}, |
|||
|
|||
changeBirthday(e) { |
|||
this.formData.birthday = e.result |
|||
this.showDatePicker = false |
|||
}, |
|||
|
|||
async uploadAvatar() { |
|||
try { |
|||
uni.chooseImage({ |
|||
count: 1, |
|||
sizeType: ['compressed'], |
|||
sourceType: ['album', 'camera'], |
|||
success: async (res) => { |
|||
const tempFilePath = res.tempFilePaths[0] |
|||
console.log('选择的图片:', tempFilePath) |
|||
|
|||
// 显示上传中提示 |
|||
uni.showLoading({ |
|||
title: '上传中...' |
|||
}) |
|||
|
|||
try { |
|||
// 模拟上传 |
|||
await new Promise(resolve => setTimeout(resolve, 1500)) |
|||
|
|||
// 模拟上传成功 |
|||
const mockUploadResponse = { |
|||
code: 1, |
|||
data: { |
|||
url: tempFilePath, // 暂时使用本地路径 |
|||
path: '/uploads/avatar/student_' + this.studentId + '_' + Date.now() + '.jpg' |
|||
} |
|||
} |
|||
|
|||
if (mockUploadResponse.code === 1) { |
|||
this.studentInfo.headimg = mockUploadResponse.data.url |
|||
uni.showToast({ |
|||
title: '头像上传成功', |
|||
icon: 'success' |
|||
}) |
|||
} else { |
|||
throw new Error('上传失败') |
|||
} |
|||
} catch (uploadError) { |
|||
console.error('头像上传失败:', uploadError) |
|||
uni.showToast({ |
|||
title: '头像上传失败', |
|||
icon: 'none' |
|||
}) |
|||
} finally { |
|||
uni.hideLoading() |
|||
} |
|||
}, |
|||
fail: (error) => { |
|||
console.error('选择图片失败:', error) |
|||
} |
|||
}) |
|||
} catch (error) { |
|||
console.error('选择头像失败:', error) |
|||
} |
|||
}, |
|||
|
|||
async saveStudentInfo() { |
|||
// 表单验证 |
|||
if (!this.formData.name.trim()) { |
|||
uni.showToast({ |
|||
title: '请输入学员姓名', |
|||
icon: 'none' |
|||
}) |
|||
return |
|||
} |
|||
|
|||
if (!this.formData.gender) { |
|||
uni.showToast({ |
|||
title: '请选择性别', |
|||
icon: 'none' |
|||
}) |
|||
return |
|||
} |
|||
|
|||
// 手机号验证 |
|||
if (this.formData.contact_phone && !/^1[3-9]\d{9}$/.test(this.formData.contact_phone)) { |
|||
uni.showToast({ |
|||
title: '请输入正确的手机号', |
|||
icon: 'none' |
|||
}) |
|||
return |
|||
} |
|||
|
|||
this.saving = true |
|||
try { |
|||
const updateData = { |
|||
student_id: this.studentId, |
|||
...this.formData |
|||
} |
|||
|
|||
console.log('保存学员信息:', updateData) |
|||
|
|||
// 调用真实API |
|||
const response = await apiRoute.updateStudentInfo(updateData) |
|||
console.log('更新学员信息API响应:', response) |
|||
|
|||
if (response.code === 1) { |
|||
// 更新本地学员信息 |
|||
Object.assign(this.studentInfo, this.formData) |
|||
|
|||
uni.showToast({ |
|||
title: '保存成功', |
|||
icon: 'success' |
|||
}) |
|||
|
|||
// 延迟返回上一页 |
|||
setTimeout(() => { |
|||
uni.navigateBack() |
|||
}, 1500) |
|||
} else { |
|||
uni.showToast({ |
|||
title: response.message || '保存失败', |
|||
icon: 'none' |
|||
}) |
|||
} |
|||
} catch (error) { |
|||
console.error('保存学员信息失败:', error) |
|||
uni.showToast({ |
|||
title: '保存失败,请重试', |
|||
icon: 'none' |
|||
}) |
|||
} finally { |
|||
this.saving = false |
|||
} |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style lang="less" scoped> |
|||
.main_box { |
|||
background: #f8f9fa; |
|||
min-height: 100vh; |
|||
} |
|||
|
|||
// 自定义导航栏 |
|||
.navbar_section { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
background: #29D3B4; |
|||
padding: 40rpx 32rpx 20rpx; |
|||
|
|||
// 小程序端适配状态栏 |
|||
// #ifdef MP-WEIXIN |
|||
padding-top: 80rpx; |
|||
// #endif |
|||
|
|||
.navbar_back { |
|||
width: 60rpx; |
|||
|
|||
.back_icon { |
|||
color: #fff; |
|||
font-size: 40rpx; |
|||
font-weight: 600; |
|||
} |
|||
} |
|||
|
|||
.navbar_title { |
|||
color: #fff; |
|||
font-size: 32rpx; |
|||
font-weight: 600; |
|||
} |
|||
|
|||
.navbar_action { |
|||
width: 60rpx; |
|||
} |
|||
} |
|||
|
|||
// 头像区域 |
|||
.avatar_section { |
|||
background: #fff; |
|||
padding: 40rpx 32rpx; |
|||
text-align: center; |
|||
|
|||
.avatar_container { |
|||
position: relative; |
|||
display: inline-block; |
|||
margin-bottom: 24rpx; |
|||
|
|||
.avatar_image { |
|||
width: 120rpx; |
|||
height: 120rpx; |
|||
border-radius: 50%; |
|||
border: 4rpx solid #f0f0f0; |
|||
} |
|||
|
|||
.avatar_edit_icon { |
|||
position: absolute; |
|||
bottom: 0; |
|||
right: 0; |
|||
width: 40rpx; |
|||
height: 40rpx; |
|||
background: #29d3b4; |
|||
border-radius: 50%; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
border: 2rpx solid #fff; |
|||
|
|||
.edit_text { |
|||
color: #fff; |
|||
font-size: 20rpx; |
|||
} |
|||
} |
|||
} |
|||
|
|||
.student_name { |
|||
font-size: 32rpx; |
|||
font-weight: 600; |
|||
color: #333; |
|||
margin-bottom: 12rpx; |
|||
} |
|||
|
|||
.student_basic_info { |
|||
.info_tag { |
|||
display: inline-block; |
|||
font-size: 22rpx; |
|||
color: #666; |
|||
background: #f0f0f0; |
|||
padding: 6rpx 16rpx; |
|||
border-radius: 16rpx; |
|||
margin: 0 8rpx; |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 表单区域 |
|||
.form_section { |
|||
background: #fff; |
|||
margin: 20rpx; |
|||
border-radius: 16rpx; |
|||
padding: 32rpx; |
|||
|
|||
.section_title { |
|||
font-size: 28rpx; |
|||
font-weight: 600; |
|||
color: #333; |
|||
margin-bottom: 32rpx; |
|||
padding-bottom: 16rpx; |
|||
border-bottom: 1px solid #f0f0f0; |
|||
} |
|||
|
|||
.form_item { |
|||
margin-bottom: 32rpx; |
|||
|
|||
&:last-child { |
|||
margin-bottom: 0; |
|||
} |
|||
|
|||
.form_label { |
|||
font-size: 26rpx; |
|||
color: #333; |
|||
margin-bottom: 12rpx; |
|||
font-weight: 500; |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 保存按钮区域 |
|||
.save_section { |
|||
padding: 40rpx 32rpx; |
|||
padding-bottom: 80rpx; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,917 @@ |
|||
<!--学员课程安排页面--> |
|||
<template> |
|||
<view class="main_box"> |
|||
<!-- 自定义导航栏 --> |
|||
<view class="navbar_section"> |
|||
<view class="navbar_back" @click="goBack"> |
|||
<text class="back_icon">‹</text> |
|||
</view> |
|||
<view class="navbar_title">课程安排</view> |
|||
<view class="navbar_action"> |
|||
<view class="today_button" @click="goToToday"> |
|||
<text class="today_text">今天</text> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 学员信息 --> |
|||
<view class="student_info_section" v-if="studentInfo"> |
|||
<view class="student_name">{{ studentInfo.name }}</view> |
|||
<view class="schedule_stats"> |
|||
<text class="stat_item">本周课程:{{ weeklyStats.total_courses }}节</text> |
|||
<text class="stat_item">已完成:{{ weeklyStats.completed_courses }}节</text> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 日历切换 --> |
|||
<view class="calendar_section"> |
|||
<view class="calendar_header"> |
|||
<view class="month_controls"> |
|||
<view class="control_button" @click="prevWeek">‹</view> |
|||
<view class="current_period">{{ currentWeekText }}</view> |
|||
<view class="control_button" @click="nextWeek">›</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<view class="week_tabs"> |
|||
<view |
|||
v-for="day in weekDays" |
|||
:key="day.date" |
|||
:class="['week_tab', selectedDate === day.date ? 'active' : '', day.isToday ? 'today' : '']" |
|||
@click="selectDate(day.date)" |
|||
> |
|||
<view class="tab_weekday">{{ day.weekday }}</view> |
|||
<view class="tab_date">{{ day.day }}</view> |
|||
<view class="tab_indicator" v-if="day.hasCourse"></view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 课程列表 --> |
|||
<view class="courses_section"> |
|||
<view v-if="loading" class="loading_section"> |
|||
<view class="loading_text">加载中...</view> |
|||
</view> |
|||
|
|||
<view v-else-if="dailyCourses.length === 0" class="empty_section"> |
|||
<view class="empty_icon">📅</view> |
|||
<view class="empty_text">当日暂无课程安排</view> |
|||
<view class="empty_hint">选择其他日期查看课程</view> |
|||
</view> |
|||
|
|||
<view v-else class="courses_list"> |
|||
<view |
|||
v-for="course in dailyCourses" |
|||
:key="course.id" |
|||
:class="['course_item', course.status]" |
|||
@click="viewCourseDetail(course)" |
|||
> |
|||
<view class="course_time"> |
|||
<view class="time_range">{{ course.start_time }}</view> |
|||
<view class="time_duration">{{ course.duration }}分钟</view> |
|||
</view> |
|||
|
|||
<view class="course_info"> |
|||
<view class="course_name">{{ course.course_name }}</view> |
|||
<view class="course_details"> |
|||
<view class="detail_row"> |
|||
<text class="detail_label">教练:</text> |
|||
<text class="detail_value">{{ course.coach_name }}</text> |
|||
</view> |
|||
<view class="detail_row"> |
|||
<text class="detail_label">场地:</text> |
|||
<text class="detail_value">{{ course.venue_name }}</text> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<view class="course_status"> |
|||
<view :class="['status_badge', course.status]"> |
|||
{{ getStatusText(course.status) }} |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 课程详情弹窗 --> |
|||
<view class="course_popup" v-if="showCoursePopup" @click="closeCoursePopup"> |
|||
<view class="popup_content" @click.stop> |
|||
<view class="popup_header"> |
|||
<view class="popup_title">课程详情</view> |
|||
<view class="popup_close" @click="closeCoursePopup">×</view> |
|||
</view> |
|||
|
|||
<view class="popup_course_detail" v-if="selectedCourse"> |
|||
<view class="detail_section"> |
|||
<view class="section_title">基本信息</view> |
|||
<view class="info_grid"> |
|||
<view class="info_row"> |
|||
<text class="info_label">课程名称:</text> |
|||
<text class="info_value">{{ selectedCourse.course_name }}</text> |
|||
</view> |
|||
<view class="info_row"> |
|||
<text class="info_label">上课时间:</text> |
|||
<text class="info_value">{{ formatDateTime(selectedCourse.course_date, selectedCourse.start_time) }}</text> |
|||
</view> |
|||
<view class="info_row"> |
|||
<text class="info_label">课程时长:</text> |
|||
<text class="info_value">{{ selectedCourse.duration }}分钟</text> |
|||
</view> |
|||
<view class="info_row"> |
|||
<text class="info_label">授课教练:</text> |
|||
<text class="info_value">{{ selectedCourse.coach_name }}</text> |
|||
</view> |
|||
<view class="info_row"> |
|||
<text class="info_label">上课地点:</text> |
|||
<text class="info_value">{{ selectedCourse.venue_name }}</text> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<view class="detail_section" v-if="selectedCourse.course_description"> |
|||
<view class="section_title">课程介绍</view> |
|||
<view class="course_description">{{ selectedCourse.course_description }}</view> |
|||
</view> |
|||
|
|||
<view class="detail_section" v-if="selectedCourse.preparation_items"> |
|||
<view class="section_title">课前准备</view> |
|||
<view class="preparation_list"> |
|||
<view |
|||
v-for="item in selectedCourse.preparation_items" |
|||
:key="item" |
|||
class="preparation_item" |
|||
> |
|||
• {{ item }} |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<view class="popup_actions"> |
|||
<fui-button |
|||
v-if="selectedCourse && selectedCourse.status === 'scheduled'" |
|||
background="#f39c12" |
|||
@click="requestLeave" |
|||
> |
|||
请假 |
|||
</fui-button> |
|||
|
|||
<fui-button |
|||
background="#f8f9fa" |
|||
color="#666" |
|||
@click="closeCoursePopup" |
|||
> |
|||
关闭 |
|||
</fui-button> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
</template> |
|||
|
|||
<script> |
|||
import apiRoute from '@/api/apiRoute.js' |
|||
|
|||
export default { |
|||
data() { |
|||
return { |
|||
studentId: 0, |
|||
studentInfo: {}, |
|||
selectedDate: '', |
|||
currentWeekStart: new Date(), |
|||
weekDays: [], |
|||
dailyCourses: [], |
|||
weeklyStats: {}, |
|||
loading: false, |
|||
showCoursePopup: false, |
|||
selectedCourse: null |
|||
} |
|||
}, |
|||
|
|||
computed: { |
|||
currentWeekText() { |
|||
const start = new Date(this.currentWeekStart) |
|||
const end = new Date(start) |
|||
end.setDate(start.getDate() + 6) |
|||
|
|||
const startMonth = start.getMonth() + 1 |
|||
const startDay = start.getDate() |
|||
const endMonth = end.getMonth() + 1 |
|||
const endDay = end.getDate() |
|||
|
|||
if (startMonth === endMonth) { |
|||
return `${startMonth}月${startDay}日-${endDay}日` |
|||
} else { |
|||
return `${startMonth}月${startDay}日-${endMonth}月${endDay}日` |
|||
} |
|||
} |
|||
}, |
|||
|
|||
onLoad(options) { |
|||
this.studentId = parseInt(options.student_id) || 0 |
|||
if (this.studentId) { |
|||
this.initPage() |
|||
} else { |
|||
uni.showToast({ |
|||
title: '参数错误', |
|||
icon: 'none' |
|||
}) |
|||
} |
|||
}, |
|||
|
|||
methods: { |
|||
goBack() { |
|||
uni.navigateBack() |
|||
}, |
|||
|
|||
async initPage() { |
|||
await this.loadStudentInfo() |
|||
this.generateWeekDays() |
|||
|
|||
// 默认选择今天 |
|||
const today = this.formatDateString(new Date()) |
|||
this.selectedDate = today |
|||
await this.loadDailyCourses() |
|||
await this.loadWeeklyStats() |
|||
}, |
|||
|
|||
async loadStudentInfo() { |
|||
try { |
|||
// 模拟获取学员信息 |
|||
const mockStudentInfo = { |
|||
id: this.studentId, |
|||
name: '小明' |
|||
} |
|||
this.studentInfo = mockStudentInfo |
|||
} catch (error) { |
|||
console.error('获取学员信息失败:', error) |
|||
} |
|||
}, |
|||
|
|||
generateWeekDays() { |
|||
const days = [] |
|||
const startDate = new Date(this.currentWeekStart) |
|||
const today = new Date() |
|||
const todayStr = this.formatDateString(today) |
|||
|
|||
for (let i = 0; i < 7; i++) { |
|||
const date = new Date(startDate) |
|||
date.setDate(startDate.getDate() + i) |
|||
|
|||
const dateStr = this.formatDateString(date) |
|||
days.push({ |
|||
date: dateStr, |
|||
day: date.getDate(), |
|||
weekday: this.getWeekday(date.getDay()), |
|||
isToday: dateStr === todayStr, |
|||
hasCourse: false // 待后续加载课程数据后更新 |
|||
}) |
|||
} |
|||
|
|||
this.weekDays = days |
|||
}, |
|||
|
|||
prevWeek() { |
|||
this.currentWeekStart.setDate(this.currentWeekStart.getDate() - 7) |
|||
this.generateWeekDays() |
|||
this.loadDailyCourses() |
|||
this.loadWeeklyStats() |
|||
}, |
|||
|
|||
nextWeek() { |
|||
this.currentWeekStart.setDate(this.currentWeekStart.getDate() + 7) |
|||
this.generateWeekDays() |
|||
this.loadDailyCourses() |
|||
this.loadWeeklyStats() |
|||
}, |
|||
|
|||
goToToday() { |
|||
const today = new Date() |
|||
this.currentWeekStart = this.getWeekStartDate(today) |
|||
this.generateWeekDays() |
|||
this.selectedDate = this.formatDateString(today) |
|||
this.loadDailyCourses() |
|||
this.loadWeeklyStats() |
|||
}, |
|||
|
|||
selectDate(date) { |
|||
this.selectedDate = date |
|||
this.loadDailyCourses() |
|||
}, |
|||
|
|||
async loadDailyCourses() { |
|||
if (!this.selectedDate) return |
|||
|
|||
this.loading = true |
|||
try { |
|||
console.log('加载课程安排:', this.selectedDate) |
|||
|
|||
// 模拟API调用 |
|||
// const response = await apiRoute.getStudentSchedule({ |
|||
// student_id: this.studentId, |
|||
// date: this.selectedDate |
|||
// }) |
|||
|
|||
// 使用模拟数据 |
|||
const mockResponse = { |
|||
code: 1, |
|||
data: [ |
|||
{ |
|||
id: 1, |
|||
course_date: this.selectedDate, |
|||
start_time: '09:00', |
|||
end_time: '10:00', |
|||
duration: 60, |
|||
course_name: '基础体能训练', |
|||
course_description: '通过基础的体能训练动作,提升学员的身体素质,包括力量、耐力、协调性等方面的训练。', |
|||
coach_name: '张教练', |
|||
venue_name: '训练馆A', |
|||
status: 'scheduled', |
|||
preparation_items: ['运动服装', '运动鞋', '毛巾', '水杯'] |
|||
}, |
|||
{ |
|||
id: 2, |
|||
course_date: this.selectedDate, |
|||
start_time: '14:00', |
|||
end_time: '15:30', |
|||
duration: 90, |
|||
course_name: '专项技能训练', |
|||
course_description: '针对特定运动项目进行专项技能训练,提高学员在该项目上的技术水平。', |
|||
coach_name: '李教练', |
|||
venue_name: '训练馆B', |
|||
status: 'completed', |
|||
preparation_items: ['专项器材', '护具', '运动服装'] |
|||
}, |
|||
{ |
|||
id: 3, |
|||
course_date: this.selectedDate, |
|||
start_time: '16:30', |
|||
end_time: '17:30', |
|||
duration: 60, |
|||
course_name: '体适能评估', |
|||
course_description: '定期进行体适能测试和评估,了解学员的身体状况和训练效果。', |
|||
coach_name: '王教练', |
|||
venue_name: '测试室', |
|||
status: 'cancelled', |
|||
preparation_items: ['轻便服装', '测试表格'] |
|||
} |
|||
] |
|||
} |
|||
|
|||
if (mockResponse.code === 1) { |
|||
this.dailyCourses = mockResponse.data |
|||
|
|||
// 更新周视图中的课程指示器 |
|||
this.updateWeekCourseIndicators() |
|||
|
|||
console.log('课程数据加载成功:', this.dailyCourses) |
|||
} else { |
|||
uni.showToast({ |
|||
title: mockResponse.msg || '获取课程安排失败', |
|||
icon: 'none' |
|||
}) |
|||
} |
|||
} catch (error) { |
|||
console.error('获取课程安排失败:', error) |
|||
uni.showToast({ |
|||
title: '获取课程安排失败', |
|||
icon: 'none' |
|||
}) |
|||
this.dailyCourses = [] |
|||
} finally { |
|||
this.loading = false |
|||
} |
|||
}, |
|||
|
|||
async loadWeeklyStats() { |
|||
try { |
|||
// 模拟获取本周统计数据 |
|||
const mockStats = { |
|||
total_courses: 8, |
|||
completed_courses: 3, |
|||
scheduled_courses: 4, |
|||
cancelled_courses: 1 |
|||
} |
|||
this.weeklyStats = mockStats |
|||
} catch (error) { |
|||
console.error('获取周统计失败:', error) |
|||
} |
|||
}, |
|||
|
|||
updateWeekCourseIndicators() { |
|||
// 更新周视图中每天是否有课程的指示器 |
|||
this.weekDays.forEach(day => { |
|||
// 这里简化处理,实际应该查询每天的课程数据 |
|||
day.hasCourse = day.date === this.selectedDate && this.dailyCourses.length > 0 |
|||
}) |
|||
}, |
|||
|
|||
viewCourseDetail(course) { |
|||
this.selectedCourse = course |
|||
this.showCoursePopup = true |
|||
}, |
|||
|
|||
closeCoursePopup() { |
|||
this.showCoursePopup = false |
|||
this.selectedCourse = null |
|||
}, |
|||
|
|||
async requestLeave() { |
|||
if (!this.selectedCourse) return |
|||
|
|||
uni.showModal({ |
|||
title: '确认请假', |
|||
content: '确定要为此课程申请请假吗?', |
|||
success: async (res) => { |
|||
if (res.confirm) { |
|||
try { |
|||
console.log('申请请假:', this.selectedCourse.id) |
|||
|
|||
// 模拟API调用 |
|||
await new Promise(resolve => setTimeout(resolve, 1000)) |
|||
const mockResponse = { code: 1, message: '请假申请已提交' } |
|||
|
|||
if (mockResponse.code === 1) { |
|||
uni.showToast({ |
|||
title: '请假申请已提交', |
|||
icon: 'success' |
|||
}) |
|||
|
|||
// 更新课程状态 |
|||
const courseIndex = this.dailyCourses.findIndex(c => c.id === this.selectedCourse.id) |
|||
if (courseIndex !== -1) { |
|||
this.dailyCourses[courseIndex].status = 'leave_requested' |
|||
} |
|||
|
|||
this.closeCoursePopup() |
|||
} else { |
|||
uni.showToast({ |
|||
title: mockResponse.message || '请假申请失败', |
|||
icon: 'none' |
|||
}) |
|||
} |
|||
} catch (error) { |
|||
console.error('请假申请失败:', error) |
|||
uni.showToast({ |
|||
title: '请假申请失败', |
|||
icon: 'none' |
|||
}) |
|||
} |
|||
} |
|||
} |
|||
}) |
|||
}, |
|||
|
|||
// 工具方法 |
|||
formatDateString(date) { |
|||
const year = date.getFullYear() |
|||
const month = String(date.getMonth() + 1).padStart(2, '0') |
|||
const day = String(date.getDate()).padStart(2, '0') |
|||
return `${year}-${month}-${day}` |
|||
}, |
|||
|
|||
formatDateTime(dateString, timeString) { |
|||
const date = new Date(dateString) |
|||
const month = date.getMonth() + 1 |
|||
const day = date.getDate() |
|||
const weekday = this.getWeekday(date.getDay()) |
|||
return `${month}月${day}日 ${weekday} ${timeString}` |
|||
}, |
|||
|
|||
getWeekday(dayIndex) { |
|||
const weekdays = ['日', '一', '二', '三', '四', '五', '六'] |
|||
return weekdays[dayIndex] |
|||
}, |
|||
|
|||
getWeekStartDate(date) { |
|||
const start = new Date(date) |
|||
const day = start.getDay() |
|||
const diff = start.getDate() - day |
|||
start.setDate(diff) |
|||
return start |
|||
}, |
|||
|
|||
getStatusText(status) { |
|||
const statusMap = { |
|||
'scheduled': '待上课', |
|||
'completed': '已完成', |
|||
'cancelled': '已取消', |
|||
'leave_requested': '请假中' |
|||
} |
|||
return statusMap[status] || status |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style lang="less" scoped> |
|||
.main_box { |
|||
background: #f8f9fa; |
|||
min-height: 100vh; |
|||
} |
|||
|
|||
// 自定义导航栏 |
|||
.navbar_section { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
background: #29D3B4; |
|||
padding: 40rpx 32rpx 20rpx; |
|||
|
|||
// 小程序端适配状态栏 |
|||
// #ifdef MP-WEIXIN |
|||
padding-top: 80rpx; |
|||
// #endif |
|||
|
|||
.navbar_back { |
|||
width: 60rpx; |
|||
|
|||
.back_icon { |
|||
color: #fff; |
|||
font-size: 40rpx; |
|||
font-weight: 600; |
|||
} |
|||
} |
|||
|
|||
.navbar_title { |
|||
color: #fff; |
|||
font-size: 32rpx; |
|||
font-weight: 600; |
|||
} |
|||
|
|||
.navbar_action { |
|||
width: 80rpx; |
|||
display: flex; |
|||
justify-content: flex-end; |
|||
|
|||
.today_button { |
|||
background: rgba(255, 255, 255, 0.2); |
|||
padding: 8rpx 16rpx; |
|||
border-radius: 16rpx; |
|||
|
|||
.today_text { |
|||
color: #fff; |
|||
font-size: 24rpx; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 学员信息 |
|||
.student_info_section { |
|||
background: #fff; |
|||
padding: 24rpx 32rpx; |
|||
border-bottom: 1px solid #f0f0f0; |
|||
|
|||
.student_name { |
|||
font-size: 32rpx; |
|||
font-weight: 600; |
|||
color: #333; |
|||
margin-bottom: 12rpx; |
|||
} |
|||
|
|||
.schedule_stats { |
|||
display: flex; |
|||
gap: 24rpx; |
|||
|
|||
.stat_item { |
|||
font-size: 24rpx; |
|||
color: #666; |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 日历部分 |
|||
.calendar_section { |
|||
background: #fff; |
|||
margin: 20rpx; |
|||
border-radius: 16rpx; |
|||
padding: 24rpx 32rpx; |
|||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08); |
|||
|
|||
.calendar_header { |
|||
margin-bottom: 24rpx; |
|||
|
|||
.month_controls { |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
gap: 32rpx; |
|||
|
|||
.control_button { |
|||
width: 40rpx; |
|||
height: 40rpx; |
|||
background: #f8f9fa; |
|||
border-radius: 50%; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
font-size: 24rpx; |
|||
color: #666; |
|||
} |
|||
|
|||
.current_period { |
|||
font-size: 28rpx; |
|||
font-weight: 600; |
|||
color: #333; |
|||
min-width: 200rpx; |
|||
text-align: center; |
|||
} |
|||
} |
|||
} |
|||
|
|||
.week_tabs { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
|
|||
.week_tab { |
|||
flex: 1; |
|||
text-align: center; |
|||
padding: 16rpx 8rpx; |
|||
border-radius: 12rpx; |
|||
position: relative; |
|||
|
|||
&.active { |
|||
background: #29D3B4; |
|||
color: #fff; |
|||
} |
|||
|
|||
&.today { |
|||
background: rgba(41, 211, 180, 0.1); |
|||
|
|||
&.active { |
|||
background: #29D3B4; |
|||
} |
|||
} |
|||
|
|||
.tab_weekday { |
|||
font-size: 22rpx; |
|||
margin-bottom: 4rpx; |
|||
opacity: 0.8; |
|||
} |
|||
|
|||
.tab_date { |
|||
font-size: 28rpx; |
|||
font-weight: 600; |
|||
} |
|||
|
|||
.tab_indicator { |
|||
position: absolute; |
|||
bottom: 4rpx; |
|||
left: 50%; |
|||
transform: translateX(-50%); |
|||
width: 6rpx; |
|||
height: 6rpx; |
|||
background: #ff4757; |
|||
border-radius: 50%; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 课程列表 |
|||
.courses_section { |
|||
margin: 0 20rpx; |
|||
|
|||
.loading_section, .empty_section { |
|||
background: #fff; |
|||
border-radius: 16rpx; |
|||
padding: 80rpx 32rpx; |
|||
text-align: center; |
|||
|
|||
.loading_text, .empty_text { |
|||
font-size: 28rpx; |
|||
color: #666; |
|||
margin-bottom: 12rpx; |
|||
} |
|||
|
|||
.empty_icon { |
|||
font-size: 80rpx; |
|||
margin-bottom: 24rpx; |
|||
} |
|||
|
|||
.empty_hint { |
|||
font-size: 24rpx; |
|||
color: #999; |
|||
} |
|||
} |
|||
|
|||
.courses_list { |
|||
.course_item { |
|||
background: #fff; |
|||
border-radius: 16rpx; |
|||
padding: 24rpx; |
|||
margin-bottom: 16rpx; |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 20rpx; |
|||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06); |
|||
|
|||
&.scheduled { |
|||
border-left: 4rpx solid #29D3B4; |
|||
} |
|||
|
|||
&.completed { |
|||
background: #f8f9fa; |
|||
border-left: 4rpx solid #27ae60; |
|||
} |
|||
|
|||
&.cancelled { |
|||
background: #f8f9fa; |
|||
border-left: 4rpx solid #e74c3c; |
|||
opacity: 0.7; |
|||
} |
|||
|
|||
&.leave_requested { |
|||
background: #f8f9fa; |
|||
border-left: 4rpx solid #f39c12; |
|||
} |
|||
|
|||
.course_time { |
|||
min-width: 100rpx; |
|||
text-align: center; |
|||
|
|||
.time_range { |
|||
font-size: 28rpx; |
|||
font-weight: 600; |
|||
color: #333; |
|||
margin-bottom: 4rpx; |
|||
} |
|||
|
|||
.time_duration { |
|||
font-size: 22rpx; |
|||
color: #999; |
|||
} |
|||
} |
|||
|
|||
.course_info { |
|||
flex: 1; |
|||
|
|||
.course_name { |
|||
font-size: 28rpx; |
|||
font-weight: 600; |
|||
color: #333; |
|||
margin-bottom: 12rpx; |
|||
} |
|||
|
|||
.course_details { |
|||
.detail_row { |
|||
display: flex; |
|||
margin-bottom: 6rpx; |
|||
|
|||
.detail_label { |
|||
font-size: 24rpx; |
|||
color: #666; |
|||
min-width: 80rpx; |
|||
} |
|||
|
|||
.detail_value { |
|||
font-size: 24rpx; |
|||
color: #333; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
.course_status { |
|||
.status_badge { |
|||
font-size: 22rpx; |
|||
padding: 6rpx 12rpx; |
|||
border-radius: 12rpx; |
|||
|
|||
&.scheduled { |
|||
color: #29D3B4; |
|||
background: rgba(41, 211, 180, 0.1); |
|||
} |
|||
|
|||
&.completed { |
|||
color: #27ae60; |
|||
background: rgba(39, 174, 96, 0.1); |
|||
} |
|||
|
|||
&.cancelled { |
|||
color: #e74c3c; |
|||
background: rgba(231, 76, 60, 0.1); |
|||
} |
|||
|
|||
&.leave_requested { |
|||
color: #f39c12; |
|||
background: rgba(243, 156, 18, 0.1); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 课程详情弹窗 |
|||
.course_popup { |
|||
position: fixed; |
|||
top: 0; |
|||
left: 0; |
|||
right: 0; |
|||
bottom: 0; |
|||
background: rgba(0, 0, 0, 0.5); |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
z-index: 1000; |
|||
|
|||
.popup_content { |
|||
background: #fff; |
|||
border-radius: 16rpx; |
|||
width: 90%; |
|||
max-height: 80vh; |
|||
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 { |
|||
font-size: 48rpx; |
|||
color: #999; |
|||
font-weight: 300; |
|||
} |
|||
} |
|||
|
|||
.popup_course_detail { |
|||
padding: 32rpx; |
|||
max-height: 60vh; |
|||
overflow-y: auto; |
|||
|
|||
.detail_section { |
|||
margin-bottom: 32rpx; |
|||
|
|||
&:last-child { |
|||
margin-bottom: 0; |
|||
} |
|||
|
|||
.section_title { |
|||
font-size: 28rpx; |
|||
font-weight: 600; |
|||
color: #333; |
|||
margin-bottom: 16rpx; |
|||
padding-bottom: 8rpx; |
|||
border-bottom: 2rpx solid #29D3B4; |
|||
} |
|||
|
|||
.info_grid { |
|||
.info_row { |
|||
display: flex; |
|||
margin-bottom: 12rpx; |
|||
|
|||
.info_label { |
|||
font-size: 26rpx; |
|||
color: #666; |
|||
min-width: 120rpx; |
|||
} |
|||
|
|||
.info_value { |
|||
font-size: 26rpx; |
|||
color: #333; |
|||
flex: 1; |
|||
} |
|||
} |
|||
} |
|||
|
|||
.course_description { |
|||
font-size: 26rpx; |
|||
color: #333; |
|||
line-height: 1.6; |
|||
} |
|||
|
|||
.preparation_list { |
|||
.preparation_item { |
|||
font-size: 24rpx; |
|||
color: #666; |
|||
line-height: 1.5; |
|||
margin-bottom: 8rpx; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
.popup_actions { |
|||
padding: 24rpx 32rpx; |
|||
display: flex; |
|||
gap: 16rpx; |
|||
border-top: 1px solid #f0f0f0; |
|||
|
|||
fui-button { |
|||
flex: 1; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
</style> |
|||
@ -0,0 +1,254 @@ |
|||
<!--学员端系统设置页面--> |
|||
<template> |
|||
<view class="settings_container"> |
|||
<!-- 自定义导航栏 --> |
|||
<view class="navbar_section"> |
|||
<view class="navbar_content"> |
|||
<view class="back_button" @click="goBack"> |
|||
<image src="/static/icon-img/back.png" class="back_icon"></image> |
|||
</view> |
|||
<view class="navbar_title">系统设置</view> |
|||
<view class="navbar_placeholder"></view> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 设置选项列表 --> |
|||
<view class="settings_section"> |
|||
<view class="setting_item" @click="navigateToProfile"> |
|||
<view class="setting_left"> |
|||
<view class="setting_icon profile_setting"> |
|||
<image src="/static/icon-img/profile.png" class="icon_image"></image> |
|||
</view> |
|||
<view class="setting_text">个人资料</view> |
|||
</view> |
|||
<view class="setting_arrow"> |
|||
<image src="/static/icon-img/arrow-right.png" class="arrow_icon"></image> |
|||
</view> |
|||
</view> |
|||
|
|||
<view class="setting_item" @click="navigateToPassword"> |
|||
<view class="setting_left"> |
|||
<view class="setting_icon password_setting"> |
|||
<image src="/static/icon-img/lock.png" class="icon_image"></image> |
|||
</view> |
|||
<view class="setting_text">修改密码</view> |
|||
</view> |
|||
<view class="setting_arrow"> |
|||
<image src="/static/icon-img/arrow-right.png" class="arrow_icon"></image> |
|||
</view> |
|||
</view> |
|||
|
|||
<view class="setting_item" @click="navigateToAbout"> |
|||
<view class="setting_left"> |
|||
<view class="setting_icon about_setting"> |
|||
<image src="/static/icon-img/info.png" class="icon_image"></image> |
|||
</view> |
|||
<view class="setting_text">关于我们</view> |
|||
</view> |
|||
<view class="setting_arrow"> |
|||
<image src="/static/icon-img/arrow-right.png" class="arrow_icon"></image> |
|||
</view> |
|||
</view> |
|||
|
|||
<view class="setting_item" @click="navigateToPrivacy"> |
|||
<view class="setting_left"> |
|||
<view class="setting_icon privacy_setting"> |
|||
<image src="/static/icon-img/shield.png" class="icon_image"></image> |
|||
</view> |
|||
<view class="setting_text">隐私协议</view> |
|||
</view> |
|||
<view class="setting_arrow"> |
|||
<image src="/static/icon-img/arrow-right.png" class="arrow_icon"></image> |
|||
</view> |
|||
</view> |
|||
</view> |
|||
|
|||
<!-- 退出登录按钮 --> |
|||
<view class="logout_section"> |
|||
<button class="logout_button" @click="logout">退出登录</button> |
|||
</view> |
|||
</view> |
|||
</template> |
|||
|
|||
<script> |
|||
export default { |
|||
data() { |
|||
return { |
|||
|
|||
} |
|||
}, |
|||
methods: { |
|||
goBack() { |
|||
uni.navigateBack() |
|||
}, |
|||
|
|||
navigateToProfile() { |
|||
uni.navigateTo({ |
|||
url: '/pages/common/profile/personal_info' |
|||
}) |
|||
}, |
|||
|
|||
navigateToPassword() { |
|||
uni.navigateTo({ |
|||
url: '/pages/student/my/update_pass' |
|||
}) |
|||
}, |
|||
|
|||
navigateToAbout() { |
|||
uni.navigateTo({ |
|||
url: '/pages/student/settings/about' |
|||
}) |
|||
}, |
|||
|
|||
navigateToPrivacy() { |
|||
uni.navigateTo({ |
|||
url: '/pages/common/privacy_agreement' |
|||
}) |
|||
}, |
|||
|
|||
logout() { |
|||
uni.showModal({ |
|||
title: '确认退出', |
|||
content: '确定要退出登录吗?', |
|||
success: (res) => { |
|||
if (res.confirm) { |
|||
// 清除本地存储 |
|||
uni.clearStorageSync() |
|||
|
|||
// 跳转到登录页 |
|||
uni.reLaunch({ |
|||
url: '/pages/student/login/login' |
|||
}) |
|||
} |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style lang="less" scoped> |
|||
.settings_container { |
|||
background: #f8f9fa; |
|||
min-height: 100vh; |
|||
} |
|||
|
|||
// 自定义导航栏 |
|||
.navbar_section { |
|||
background: linear-gradient(135deg, #29D3B4 0%, #1BA297 100%); |
|||
padding: 40rpx 32rpx 32rpx; |
|||
|
|||
// 小程序端适配状态栏 |
|||
// #ifdef MP-WEIXIN |
|||
padding-top: 80rpx; |
|||
// #endif |
|||
|
|||
.navbar_content { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
|
|||
.back_button { |
|||
width: 40rpx; |
|||
height: 40rpx; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
|
|||
.back_icon { |
|||
width: 24rpx; |
|||
height: 24rpx; |
|||
} |
|||
} |
|||
|
|||
.navbar_title { |
|||
color: #fff; |
|||
font-size: 36rpx; |
|||
font-weight: 600; |
|||
} |
|||
|
|||
.navbar_placeholder { |
|||
width: 40rpx; |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 设置选项 |
|||
.settings_section { |
|||
margin: 32rpx 20rpx; |
|||
background: #fff; |
|||
border-radius: 16rpx; |
|||
overflow: hidden; |
|||
|
|||
.setting_item { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
padding: 32rpx; |
|||
border-bottom: 1px solid #f0f0f0; |
|||
|
|||
&:last-child { |
|||
border-bottom: none; |
|||
} |
|||
|
|||
.setting_left { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 24rpx; |
|||
|
|||
.setting_icon { |
|||
width: 60rpx; |
|||
height: 60rpx; |
|||
border-radius: 50%; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
|
|||
&.profile_setting { background: rgba(52, 152, 219, 0.15); } |
|||
&.password_setting { background: rgba(231, 76, 60, 0.15); } |
|||
&.about_setting { background: rgba(155, 89, 182, 0.15); } |
|||
&.privacy_setting { background: rgba(46, 204, 113, 0.15); } |
|||
|
|||
.icon_image { |
|||
width: 32rpx; |
|||
height: 32rpx; |
|||
} |
|||
} |
|||
|
|||
.setting_text { |
|||
font-size: 28rpx; |
|||
color: #333; |
|||
font-weight: 500; |
|||
} |
|||
} |
|||
|
|||
.setting_arrow { |
|||
.arrow_icon { |
|||
width: 20rpx; |
|||
height: 20rpx; |
|||
opacity: 0.6; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 退出登录按钮 |
|||
.logout_section { |
|||
margin: 60rpx 20rpx; |
|||
|
|||
.logout_button { |
|||
width: 100%; |
|||
background: #ff4757; |
|||
color: #fff; |
|||
border: none; |
|||
border-radius: 16rpx; |
|||
padding: 24rpx 0; |
|||
font-size: 28rpx; |
|||
font-weight: 600; |
|||
|
|||
&:active { |
|||
background: #ff3742; |
|||
} |
|||
} |
|||
} |
|||
</style> |
|||
@ -1,281 +0,0 @@ |
|||
# 项目经理审查报告和问题清单 |
|||
|
|||
**审查日期**:2025-01-30 |
|||
**审查人**:项目经理(AI助手) |
|||
**审查范围**:学员端开发计划-前端任务.md、学员端开发计划-后端任务.md、学员端开发需求整合确认文档.md |
|||
|
|||
--- |
|||
|
|||
## 📋 **文档审查状态** |
|||
|
|||
- [x] 学员端开发计划-前端任务.md - **已审查完成** |
|||
- [x] 学员端开发计划-后端任务.md - **已审查完成** |
|||
- [x] 学员端开发需求整合确认文档.md - **已审查完成** |
|||
|
|||
## 🚨 **发现的关键问题** |
|||
|
|||
### 1. **技术栈不一致问题** ⚠️ **高优先级** |
|||
**问题描述**: |
|||
- **前端任务文档**:明确写着"UniApp + Vue2" |
|||
- **CLAUDE.md项目指导**:要求"Vue3 + Composition API + Pinia + TypeScript"这个是给 admin 端使用的技术栈 |
|||
uniapp 就是 vue2版本的没有太多的要求。 |
|||
- **冲突结果**:开发人员不知道按哪个标准执行 |
|||
|
|||
**影响**: |
|||
- 开发人员无法开始工作 |
|||
- 可能导致重复开发 |
|||
- 影响代码质量和维护性 |
|||
|
|||
**需要确认**: |
|||
- [ ] 最终使用Vue2? |
|||
- [ ] 状态管理就是使用的 uni.getStorageSync()和 uni.setStorageSync() |
|||
- [ ] 不要使用TypeScript? |
|||
|
|||
--- |
|||
|
|||
### 2. **分包策略存在问题** ⚠️ **高优先级** |
|||
**问题描述**: |
|||
- 登录页(uniapp/pages/student/login/login.vue)主包只放登录后的落地页,所有功能都放分包 |
|||
- 微信小程序分包有20个限制,目前规划8个页面可能合理 |
|||
- 但用户体验会受影响(每个功能都需要加载分包) |
|||
|
|||
**当前分包规划**: |
|||
``` |
|||
主包:登录页 + 学员端落地页 |
|||
分包:8个功能页面全部分包 |
|||
``` |
|||
|
|||
**建议优化**: |
|||
``` |
|||
主包:登录 + 首页 + 个人信息 + 消息管理(1.8M左右) |
|||
分包1:体测数据 + 知识库(数据展示类) |
|||
分包2:课程安排 + 课程预约(课程相关) |
|||
分包3:订单管理 + 合同管理(交易相关) |
|||
``` |
|||
|
|||
**需要确认**: |
|||
- [ ] 是否接受建议的分包策略? |
|||
- [ ] 主包大小限制是否可以放宽到1.8M? |
|||
- [ ] 哪些功能是用户最常用的? |
|||
|
|||
--- |
|||
|
|||
### 3. **开发工期估算过于乐观** ⚠️ **中优先级** |
|||
**问题描述**: |
|||
- 前端18天,后端13.5天,看起来很紧凑 |
|||
- 没有考虑联调时间、测试时间、bug修复时间 |
|||
- 没有风险缓冲时间 |
|||
|
|||
**当前工期**: |
|||
- 前端:18天 |
|||
- 后端:13.5天 |
|||
- 联调测试:未规划 |
|||
- 总计:31.5天 |
|||
|
|||
**建议工期**: |
|||
- 前端:25天(+7天缓冲) |
|||
- 后端:18天(+4.5天缓冲) |
|||
- 联调:3天 |
|||
- 测试修复:5天 |
|||
- 总计:51天 |
|||
|
|||
**需要确认**: |
|||
- [ ] 项目deadline是什么时候? |
|||
- [ ] 是否接受延长的工期安排? |
|||
- [ ] 团队成员经验水平如何? |
|||
|
|||
--- |
|||
|
|||
### 4. **接口设计不够完整** ⚠️ **中优先级** |
|||
**问题描述**: |
|||
- 接口清单很详细,但缺少技术规范 |
|||
- 没有统一的错误处理机制 |
|||
- 缺少接口版本管理策略 |
|||
- 没有定义接口限流和安全策略 |
|||
|
|||
**缺少的规范**: |
|||
1. **统一响应格式** |
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "success", |
|||
"data": {}, |
|||
"timestamp": "2025-01-30T10:00:00Z" |
|||
} |
|||
``` |
|||
|
|||
2. **错误码规范** |
|||
``` |
|||
200: 成功 |
|||
400: 参数错误 |
|||
401: 未登录 |
|||
403: 权限不足 |
|||
500: 服务器错误 |
|||
``` |
|||
|
|||
3. **接口版本管理** |
|||
``` |
|||
/api/v1/student/list |
|||
``` |
|||
|
|||
**需要确认**: |
|||
- [ ] 是否需要制定统一的接口规范? |
|||
- [ ] 错误处理机制如何设计? |
|||
- [ ] 是否需要接口版本管理? |
|||
|
|||
--- |
|||
|
|||
### 5. **数据库修改存在风险** ⚠️ **高优先级** |
|||
**问题描述**: |
|||
- 需要修改3个现有表结构 |
|||
- 需要添加7个数据字典 |
|||
- 没有数据迁移和回滚方案 |
|||
- 可能影响现有功能 |
|||
|
|||
**需要修改的表**: |
|||
1. `school_person_course_schedule` - 添加cancel_reason字段 |
|||
2. `school_chat_messages` - 添加is_read、read_time字段 |
|||
3. `school_order_table` - 修改payment_type枚举 |
|||
|
|||
**风险点**: |
|||
- 修改生产环境表结构 |
|||
- 可能影响现有管理后台功能 |
|||
- 数据一致性问题 |
|||
|
|||
**需要确认**: |
|||
- [ ] 是否有测试环境可以先验证? |
|||
- [ ] 现有系统是否在使用这些表? |
|||
- [ ] 是否需要制定数据迁移方案? |
|||
- [ ] 修改时间窗口如何安排? |
|||
|
|||
--- |
|||
|
|||
### 6. **第三方服务集成准备不明确** ⚠️ **中优先级** |
|||
**问题描述**: |
|||
- 文档提到腾讯云COS、微信支付 |
|||
- 但没有说明配置状态和集成方案 |
|||
- 可能影响开发进度 |
|||
|
|||
**需要的第三方服务**: |
|||
1. **腾讯云COS** - 头像上传、文件存储 |
|||
2. **微信支付** - 在线支付功能 |
|||
3. **微信小程序** - 登录、消息推送 |
|||
4. **PDF处理库** - 文档转换和预览 |
|||
|
|||
**需要确认**: |
|||
- [ ] 腾讯云COS配置是否就绪? |
|||
- [ ] 微信支付商户号是否已申请? |
|||
- [ ] 微信小程序是否已注册? |
|||
- [ ] 服务器环境是否支持PDF处理? |
|||
|
|||
--- |
|||
|
|||
## 📊 **技术债务风险评估** |
|||
|
|||
### 高风险项 🔴 |
|||
1. **技术栈不一致** - 阻塞开发 |
|||
2. **数据库修改风险** - 可能影响现有系统 |
|||
3. **分包策略问题** - 影响用户体验 |
|||
|
|||
### 中风险项 🟡 |
|||
1. **工期估算乐观** - 可能延期 |
|||
2. **接口规范不完整** - 影响联调效率 |
|||
3. **第三方服务准备** - 可能阻塞功能 |
|||
|
|||
### 低风险项 🟢 |
|||
1. **代码质量要求** - 可通过review解决 |
|||
2. **测试覆盖率** - 可后期补充 |
|||
|
|||
--- |
|||
|
|||
## 🎯 **项目经理建议** |
|||
|
|||
### 建议1:立即解决技术栈冲突 |
|||
**重要性**:🔴 紧急且重要 |
|||
**建议**: |
|||
- 立即确定使用Vue3还是Vue2 |
|||
- 如果选择Vue3,前端工期需要+3天(学习成本) |
|||
- 统一代码规范和开发环境 |
|||
|
|||
### 建议2:优化分包策略 |
|||
**重要性**:🟡 重要不紧急 |
|||
**建议**: |
|||
- 将最常用功能放主包 |
|||
- 按业务逻辑分组分包 |
|||
- 预留分包空间给未来功能 |
|||
|
|||
### 建议3:制定详细的项目里程碑 |
|||
**重要性**:🟡 重要不紧急 |
|||
**建议里程碑**: |
|||
``` |
|||
Week 1: 环境搭建 + 技术选型确认 |
|||
Week 2-3: 数据库设计 + 基础框架 |
|||
Week 4-5: 核心功能开发 |
|||
Week 6: 联调测试 |
|||
Week 7: 用户测试 + bug修复 |
|||
Week 8: 发布准备 |
|||
``` |
|||
|
|||
### 建议4:建立风险预案 |
|||
**重要性**:🟡 重要不紧急 |
|||
**关键风险预案**: |
|||
- 技术选型延误 → 外包部分开发 |
|||
- 第三方服务问题 → 准备备选方案 |
|||
- 数据库修改失败 → 回滚方案 |
|||
- 开发人员不足 → 调整功能优先级 |
|||
|
|||
--- |
|||
|
|||
## ✅ **后续行动计划** |
|||
|
|||
### 立即行动项(48小时内) |
|||
- [ ] **确认技术栈选择** - 阻塞所有开发工作 |
|||
- [ ] **评估数据库修改风险** - 制定测试方案 |
|||
- [ ] **确认项目deadline** - 调整工期规划 |
|||
|
|||
### 短期行动项(1周内) |
|||
- [ ] **制定详细的接口规范** - 支持并行开发 |
|||
- [ ] **准备第三方服务配置** - 避免开发阻塞 |
|||
- [ ] **建立开发环境** - 支持团队协作 |
|||
|
|||
### 中期行动项(2周内) |
|||
- [ ] **完成数据库修改** - 支持功能开发 |
|||
- [ ] **建立测试环境** - 支持持续集成 |
|||
- [ ] **制定发布流程** - 确保顺利上线 |
|||
|
|||
--- |
|||
|
|||
## 📝 **需要老板确认的决策** |
|||
|
|||
请在下面的选项中做出选择: |
|||
|
|||
### 1. 技术栈选择 |
|||
- [ ] 使用Vue2 + Vuex(与前端文档一致,开发快) |
|||
- [ ] 使用Vue3 + Pinia(与项目要求一致,但需要学习时间) |
|||
- [ ] 其他选择:_________________ |
|||
|
|||
### 2. 分包策略 |
|||
- [ ] 按原计划(主包最小,所有功能分包) |
|||
- [ ] 按建议优化(常用功能放主包,按业务分包) |
|||
- [ ] 其他方案:_________________ |
|||
|
|||
### 3. 开发工期 |
|||
- [ ] 按原计划31.5天(风险较高) |
|||
- [ ] 按建议延长到51天(更稳妥) |
|||
- [ ] 其他安排:_________________ |
|||
|
|||
### 4. 项目优先级 |
|||
请按重要性排序(1最重要): |
|||
- [ ] 功能完整性 |
|||
- [ ] 开发速度 |
|||
- [ ] 代码质量 |
|||
- [ ] 用户体验 |
|||
|
|||
### 5. 风险承受度 |
|||
- [ ] 保守型(多预留时间,确保质量) |
|||
- [ ] 平衡型(适度风险,按时交付) |
|||
- [ ] 激进型(最快速度,后期优化) |
|||
|
|||
--- |
|||
|
|||
**请修改此文档中的选择项,然后保存,我将基于你的决策制定详细的管理验收计划。** |
|||
Loading…
Reference in new issue