Browse Source

Merge branch 'master' of http://gitlab.frkj.cc/php/zhjwxt

master
于宏哲PHP 9 months ago
parent
commit
f66933e847
  1. 1
      .gitignore
  2. 400
      admin/src/views/personnel/personnel_approval_demo.vue
  3. 5
      niucloud/app/adminapi/controller/course_schedule/CourseSchedule.php
  4. 30
      niucloud/app/adminapi/controller/personnel/Personnel.php
  5. 2
      niucloud/app/adminapi/route/personnel.php
  6. 74
      niucloud/app/api/controller/apiController/Personnel.php
  7. 155
      niucloud/app/api/controller/common/Dict.php
  8. 15
      niucloud/app/api/controller/member/Member.php
  9. 17
      niucloud/app/api/route/route.php
  10. 1
      niucloud/app/common.php
  11. 5
      niucloud/app/model/personnel/Personnel.php
  12. 48
      niucloud/app/model/service_logs/ServiceLogs.php
  13. 82
      niucloud/app/model/sys/SysDict.php
  14. 4
      niucloud/app/service/admin/course_schedule/CourseScheduleService.php
  15. 2
      niucloud/app/service/api/apiService/CourseService.php
  16. 2
      niucloud/app/service/api/apiService/ResourceSharingService.php
  17. 160
      niucloud/app/service/api/apiService/ServiceService.php
  18. 297
      niucloud/app/service/api/common/DictService.php
  19. 53
      niucloud/app/service/api/member/MemberService.php
  20. 118
      niucloud/app/service/school_approval/SchoolApprovalProcessService.php
  21. 85
      uniapp/common/axiosQuiet.js
  22. 332
      uniapp/common/dictUtil.js
  23. 187
      uniapp/common/dictUtilSimple.js
  24. 283
      uniapp/common/dictUtil使用说明.md
  25. 3
      uniapp/pages.json
  26. 854
      uniapp/pages/coach/my/service_detail.vue
  27. 542
      uniapp/pages/demo/dict_optimization.vue
  28. 218
      uniapp/pages/market/clue/add_clues.vue
  29. 1099
      uniapp/pages/market/clue/class_arrangement_detail.vue
  30. 163
      uniapp/pages/market/clue/clue_info.vue
  31. 190
      uniapp/pages/market/clue/edit_clues.vue
  32. 263
      uniapp/pages/test/dict_test.vue

1
.gitignore

@ -8,3 +8,4 @@
/niucloud/runtime
/niucloud/vendor
/CLAUDE.md
.claude

400
admin/src/views/personnel/personnel_approval_demo.vue

@ -0,0 +1,400 @@
<template>
<div class="personnel-approval-demo">
<el-card class="demo-card" shadow="hover">
<template #header>
<div class="card-header">
<h3>人员添加审批功能演示</h3>
<p class="desc">该功能展示如何将人员添加接入审批流程</p>
</div>
</template>
<!-- 审批配置选择 -->
<div class="section">
<h4>第一步选择审批配置</h4>
<el-form :model="form" label-width="120px" size="default">
<el-form-item label="是否使用审批:">
<el-switch
v-model="form.useApproval"
active-text="启用审批流程"
inactive-text="直接添加"
@change="onApprovalToggle">
</el-switch>
</el-form-item>
<el-form-item
v-if="form.useApproval"
label="审批配置:"
required>
<el-select
v-model="form.approvalConfigId"
placeholder="请选择审批配置"
:loading="configLoading"
clearable>
<el-option
v-for="config in approvalConfigs"
:key="config.id"
:label="config.config_name"
:value="config.id">
<span>{{ config.config_name }}</span>
<span style="float: right; color: #8492a6;">{{ config.description }}</span>
</el-option>
</el-select>
</el-form-item>
</el-form>
</div>
<!-- 人员信息填写 -->
<div class="section">
<h4>第二步填写人员信息</h4>
<el-form :model="personnelForm" :rules="rules" ref="personnelFormRef" label-width="120px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="姓名:" prop="name">
<el-input v-model="personnelForm.name" placeholder="请输入姓名"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="性别:" prop="gender">
<el-radio-group v-model="personnelForm.gender">
<el-radio :label="0"></el-radio>
<el-radio :label="1"></el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="手机号:" prop="phone">
<el-input v-model="personnelForm.phone" placeholder="请输入手机号"></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态:" prop="status">
<el-select v-model="personnelForm.status" placeholder="请选择状态">
<el-option label="正常" :value="1"></el-option>
<el-option label="禁用" :value="0"></el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="家庭住址:">
<el-input v-model="personnelForm.address" placeholder="请输入家庭住址"></el-input>
</el-form-item>
<el-form-item label="学历:">
<el-select v-model="personnelForm.education" placeholder="请选择学历">
<el-option label="高中及以下" value="高中及以下"></el-option>
<el-option label="大专" value="大专"></el-option>
<el-option label="本科" value="本科"></el-option>
<el-option label="硕士" value="硕士"></el-option>
<el-option label="博士" value="博士"></el-option>
</el-select>
</el-form-item>
<el-form-item label="个人简介:">
<el-input
v-model="personnelForm.profile"
type="textarea"
:rows="3"
placeholder="请输入个人简介">
</el-input>
</el-form-item>
<el-form-item label="是否系统用户:">
<el-switch
v-model="personnelForm.isSysUser"
active-text="是"
inactive-text="否">
</el-switch>
</el-form-item>
</el-form>
</div>
<!-- 提交按钮 -->
<div class="section">
<h4>第三步提交申请</h4>
<el-button
type="primary"
size="large"
:loading="submitLoading"
@click="handleSubmit">
{{ form.useApproval ? '提交审批申请' : '直接添加人员' }}
</el-button>
<el-button
size="large"
@click="handleReset">
重置表单
</el-button>
</div>
<!-- 结果展示 -->
<div v-if="result" class="section result-section">
<h4>处理结果</h4>
<el-alert
:title="result.title"
:description="result.description"
:type="result.type"
show-icon
:closable="false">
</el-alert>
</div>
</el-card>
<!-- 审批流程状态展示 -->
<el-card v-if="showApprovalStatus" class="approval-status-card" shadow="hover">
<template #header>
<h3>审批流程状态</h3>
</template>
<div class="approval-flow">
<el-steps :active="currentStep" finish-status="success" align-center>
<el-step
v-for="(step, index) in approvalSteps"
:key="index"
:title="step.title"
:description="step.description">
</el-step>
</el-steps>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { apiRequest } from '@/utils/request'
//
const form = reactive({
useApproval: false,
approvalConfigId: null
})
const personnelForm = reactive({
name: '',
gender: 0,
phone: '',
address: '',
education: '',
profile: '',
status: 1,
isSysUser: false,
info: {}
})
const approvalConfigs = ref([])
const configLoading = ref(false)
const submitLoading = ref(false)
const result = ref(null)
const showApprovalStatus = ref(false)
const currentStep = ref(0)
const approvalSteps = ref([])
//
const rules = {
name: [
{ required: true, message: '请输入姓名', trigger: 'blur' }
],
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
],
gender: [
{ required: true, message: '请选择性别', trigger: 'change' }
],
status: [
{ required: true, message: '请选择状态', trigger: 'change' }
]
}
const personnelFormRef = ref()
//
const loadApprovalConfigs = async () => {
configLoading.value = true
try {
const response = await apiRequest({
url: '/adminapi/personnel/approval-configs',
method: 'GET'
})
if (response.data.code === 1) {
approvalConfigs.value = response.data.data.list || []
}
} catch (error) {
ElMessage.error('获取审批配置失败')
} finally {
configLoading.value = false
}
}
//
const onApprovalToggle = (value) => {
if (value && approvalConfigs.value.length === 0) {
loadApprovalConfigs()
}
if (!value) {
form.approvalConfigId = null
}
}
//
const handleSubmit = async () => {
//
const valid = await personnelFormRef.value.validate()
if (!valid) {
return
}
// 使
if (form.useApproval && !form.approvalConfigId) {
ElMessage.warning('请选择审批配置')
return
}
submitLoading.value = true
try {
const submitData = {
...personnelForm,
use_approval: form.useApproval ? 1 : 0,
approval_config_id: form.approvalConfigId || 0
}
const response = await apiRequest({
url: '/adminapi/personnel/personnel',
method: 'POST',
data: submitData
})
if (response.data.code === 1) {
if (form.useApproval) {
result.value = {
title: '审批申请提交成功',
description: `已成功提交人员添加审批申请,流程ID: ${response.data.data.process_id},请等待审批人处理。`,
type: 'success'
}
showApprovalDemo()
} else {
result.value = {
title: '人员添加成功',
description: `人员 ${personnelForm.name} 添加成功,ID: ${response.data.data.id}`,
type: 'success'
}
}
} else {
result.value = {
title: '提交失败',
description: response.data.msg || '操作失败,请重试',
type: 'error'
}
}
} catch (error) {
result.value = {
title: '提交失败',
description: '网络错误,请稍后重试',
type: 'error'
}
} finally {
submitLoading.value = false
}
}
//
const showApprovalDemo = () => {
showApprovalStatus.value = true
approvalSteps.value = [
{ title: '提交申请', description: '用户提交人员添加申请' },
{ title: '部门审批', description: '部门负责人审批' },
{ title: 'HR审批', description: 'HR部门审批' },
{ title: '完成', description: '审批完成,创建人员记录' }
]
currentStep.value = 1 //
}
//
const handleReset = () => {
personnelFormRef.value.resetFields()
form.useApproval = false
form.approvalConfigId = null
result.value = null
showApprovalStatus.value = false
}
//
onMounted(() => {
//
personnelForm.name = '张三'
personnelForm.phone = '13800138000'
personnelForm.address = '北京市朝阳区'
personnelForm.education = '本科'
personnelForm.profile = '具有丰富的教学经验,专业技能扎实。'
})
</script>
<style scoped>
.personnel-approval-demo {
padding: 20px;
}
.demo-card {
margin-bottom: 20px;
}
.card-header {
text-align: center;
}
.card-header h3 {
margin: 0 0 10px 0;
color: #303133;
}
.card-header .desc {
margin: 0;
color: #909399;
font-size: 14px;
}
.section {
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid #ebeef5;
}
.section:last-child {
border-bottom: none;
margin-bottom: 0;
}
.section h4 {
margin: 0 0 20px 0;
color: #606266;
font-size: 16px;
font-weight: 600;
}
.result-section {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
}
.approval-status-card {
margin-top: 20px;
}
.approval-flow {
padding: 20px 0;
}
:deep(.el-step__description) {
color: #909399;
font-size: 12px;
}
:deep(.el-alert__description) {
font-size: 14px;
line-height: 1.6;
}
</style>

5
niucloud/app/adminapi/controller/course_schedule/CourseSchedule.php

@ -164,7 +164,10 @@ class CourseSchedule extends BaseAdminController
["person_type",''],
["schedule_id",''],
["course_date",''],
["time_slot",'']
["time_slot",''],
["schedule_type", 1], // 1=正式位, 2=等待位
["course_type", 1], // 1=正式课, 2=体验课, 3=补课, 4=试听课
["position", ''] // 位置信息
]);
return (new CourseScheduleService())->addSchedule($data);
}

30
niucloud/app/adminapi/controller/personnel/Personnel.php

@ -69,10 +69,26 @@ class Personnel extends BaseAdminController
["status",0],
["is_sys_user",0],
["info",[]],
["use_approval", 0], // 是否使用审批流程
["approval_config_id", 0], // 审批配置ID
]);
$this->validate($data, 'app\validate\personnel\Personnel.add');
$id = (new PersonnelService())->add($data);
return success('ADD_SUCCESS', ['id' => $id]);
// 检查是否使用审批流程
if ($data['use_approval'] && $data['approval_config_id'] > 0) {
// 使用审批流程
$approvalService = new \app\service\school_approval\SchoolApprovalProcessService();
$processId = $approvalService->createPersonnelApproval(
$data,
$this->request->uid(),
$data['approval_config_id']
);
return success('APPROVAL_CREATED_SUCCESS', ['process_id' => $processId]);
} else {
// 直接添加人员
$id = (new PersonnelService())->add($data);
return success('ADD_SUCCESS', ['id' => $id]);
}
}
/**
@ -113,5 +129,15 @@ class Personnel extends BaseAdminController
return success('DELETE_SUCCESS');
}
/**
* 获取可用的审批配置列表
* @return \think\Response
*/
public function getApprovalConfigs(){
$approvalConfigService = new \app\service\school_approval\SchoolApprovalConfigService();
$configs = $approvalConfigService->getList(['status' => 1]);
return success($configs);
}
}

2
niucloud/app/adminapi/route/personnel.php

@ -28,6 +28,8 @@ Route::group('personnel', function () {
Route::put('personnel/:id', 'personnel.Personnel/edit');
//删除人力资源-人员
Route::delete('personnel/:id', 'personnel.Personnel/del');
//获取审批配置列表
Route::get('personnel/approval-configs', 'personnel.Personnel/getApprovalConfigs');
})->middleware([
AdminCheckToken::class,

74
niucloud/app/api/controller/apiController/Personnel.php

@ -15,6 +15,7 @@ use app\dict\member\MemberLoginTypeDict;
use app\model\reimbursement\Reimbursement;
use app\Request;
use app\service\api\apiService\PersonnelService;
use app\service\api\apiService\ServiceService;
use app\service\api\captcha\CaptchaService;
use app\service\api\login\ConfigService;
use app\service\api\login\LoginService;
@ -188,4 +189,77 @@ class Personnel extends BaseApiService
}
}
/**
* 获取我的服务记录列表
* @param Request $request
* @return \think\Response
*/
public function myServiceLogs(Request $request)
{
try {
$params = $request->all();
$res = (new ServiceService())->getMyServiceLogs($params);
return success($res);
} catch (\Exception $e) {
return fail('获取服务记录失败:' . $e->getMessage());
}
}
/**
* 获取服务记录详情
* @param Request $request
* @return \think\Response
*/
public function serviceLogDetail(Request $request)
{
try {
$params = $request->all();
$id = $params['id'] ?? 0;
if (empty($id)) {
return fail('服务记录ID不能为空');
}
$res = (new ServiceService())->getServiceLogDetail($id);
if (empty($res)) {
return fail('服务记录不存在');
}
return success($res);
} catch (\Exception $e) {
return fail('获取服务记录详情失败:' . $e->getMessage());
}
}
/**
* 更新服务结果
* @param Request $request
* @return \think\Response
*/
public function updateServiceRemark(Request $request)
{
try {
$params = $request->all();
$id = $params['id'] ?? 0;
$serviceRemark = $params['service_remark'] ?? '';
if (empty($id)) {
return fail('服务记录ID不能为空');
}
if (empty($serviceRemark)) {
return fail('服务结果内容不能为空');
}
$res = (new ServiceService())->updateServiceRemark($id, $serviceRemark);
if (!$res['code']) {
return fail($res['msg']);
}
return success([], '更新成功');
} catch (\Exception $e) {
return fail('更新服务结果失败:' . $e->getMessage());
}
}
}

155
niucloud/app/api/controller/common/Dict.php

@ -0,0 +1,155 @@
<?php
// +----------------------------------------------------------------------
// | Niucloud-admin 企业快速开发的多应用管理平台
// +----------------------------------------------------------------------
// | 官方网址:https://www.niucloud.com
// +----------------------------------------------------------------------
// | niucloud团队 版权所有 开源版本可自由商用
// +----------------------------------------------------------------------
// | Author: Niucloud Team
// +----------------------------------------------------------------------
namespace app\api\controller\common;
use app\service\api\common\DictService;
use core\base\BaseController;
/**
* 字典批量获取控制器
* Class Dict
* @package app\api\controller\common
*/
class Dict extends BaseController
{
/**
* 批量获取字典数据
* @return \think\Response
*/
public function getBatchDict()
{
$keys = input('keys', []);
// 支持字符串格式(逗号分隔)和数组格式
if (is_string($keys)) {
$keys = array_filter(explode(',', $keys));
}
if (!is_array($keys) || empty($keys)) {
return fail('参数错误:keys必须是非空数组或逗号分隔的字符串');
}
// 限制一次最多获取20个字典,防止性能问题
if (count($keys) > 20) {
return fail('一次最多获取20个字典');
}
try {
$dictService = new DictService();
$result = $dictService->getBatchDict($keys);
return success($result);
} catch (\Exception $e) {
return fail('获取字典数据失败:' . $e->getMessage());
}
}
/**
* 根据业务场景获取字典数据
* @return \think\Response
*/
public function getDictByScene()
{
$scene = input('scene', '');
if (empty($scene)) {
return fail('参数错误:scene不能为空');
}
try {
$dictService = new DictService();
// 获取场景对应的字典keys
$keys = $dictService->getDictKeysByScene($scene);
if (empty($keys)) {
return fail('不支持的业务场景:' . $scene);
}
// 批量获取字典数据
$result = $dictService->getBatchDict($keys);
return success([
'scene' => $scene,
'data' => $result
]);
} catch (\Exception $e) {
return fail('获取字典数据失败:' . $e->getMessage());
}
}
/**
* 获取单个字典数据
* @return \think\Response
*/
public function getDict()
{
$key = input('key', '');
$useCache = input('use_cache', 1);
if (empty($key)) {
return fail('参数错误:key不能为空');
}
try {
$dictService = new DictService();
$result = $dictService->getDict($key, (bool)$useCache);
return success($result);
} catch (\Exception $e) {
return fail('获取字典数据失败:' . $e->getMessage());
}
}
/**
* 清除字典缓存
* @return \think\Response
*/
public function clearDictCache()
{
$keys = input('keys', null);
// 支持字符串格式(逗号分隔)和数组格式
if (is_string($keys)) {
$keys = array_filter(explode(',', $keys));
}
try {
$dictService = new DictService();
$result = $dictService->clearDictCache($keys);
if ($result) {
return success('缓存清除成功');
} else {
return fail('缓存清除失败');
}
} catch (\Exception $e) {
return fail('缓存清除失败:' . $e->getMessage());
}
}
/**
* 获取常用字典映射关系
* @return \think\Response
*/
public function getDictMapping()
{
try {
$dictService = new DictService();
$mapping = $dictService->getCommonDictMapping();
return success($mapping);
} catch (\Exception $e) {
return fail('获取字典映射失败:' . $e->getMessage());
}
}
}

15
niucloud/app/api/controller/member/Member.php

@ -18,6 +18,7 @@ use app\service\api\member\MemberLogService;
use app\service\api\member\MemberService;
use core\base\BaseApiController;
use think\facade\Db;
use think\facade\Log;
use think\Response;
class Member extends BaseApiController
@ -127,9 +128,19 @@ class Member extends BaseApiController
public function list_call_up()
{
$data = $this->request->params([
['sales_id', ''],
['resource_id', ''],
['sales_id', ''], // 保留旧参数名称以保持兼容性
]);
return success((new MemberService())->list_call_up($data['sales_id']));
// 优先使用resource_id,如果不存在则使用sales_id
$resource_id = !empty($data['resource_id']) ? $data['resource_id'] : $data['sales_id'];
// 记录日志
Log::debug("Member/list_call_up - 请求参数: resource_id={$resource_id}");
$result = (new MemberService())->list_call_up($resource_id);
return success($result);
}
public function update_call_up()

17
niucloud/app/api/route/route.php

@ -364,6 +364,11 @@ Route::group(function () {
Route::get('contract/signStatus', 'apiController.Contract/signStatus');
Route::get('contract/download', 'apiController.Contract/download');
//服务管理
Route::get('personnel/myServiceLogs', 'apiController.Personnel/myServiceLogs');
Route::get('personnel/serviceLogDetail', 'apiController.Personnel/serviceLogDetail');
Route::post('personnel/updateServiceRemark', 'apiController.Personnel/updateServiceRemark');
})->middleware(ApiChannel::class)
->middleware(ApiPersonnelCheckToken::class, true)
@ -448,7 +453,17 @@ Route::group(function () {
Route::post('xy/orderTable/add', 'apiController.OrderTable/add');
/***************************************************** 字典批量获取 ****************************************************/
// 批量获取字典数据
Route::get('dict/batch', 'common.Dict/getBatchDict');
// 根据业务场景获取字典数据
Route::get('dict/scene/:scene', 'common.Dict/getDictByScene');
// 获取单个字典数据
Route::get('dict/single/:key', 'common.Dict/getDict');
// 获取字典映射关系
Route::get('dict/mapping', 'common.Dict/getDictMapping');
// 清除字典缓存
Route::post('dict/clear_cache', 'common.Dict/clearDictCache');
})->middleware(ApiChannel::class)
->middleware(ApiPersonnelCheckToken::class, true)

1
niucloud/app/common.php

@ -1229,6 +1229,7 @@ function get_dict_value($key, $value)
$field = 'id,name,key,dictionary,memo,create_time,update_time';
$info = $dict->field($field)->where([['key', '=', $key]])->findOrEmpty()->toArray();
if ($info['dictionary'] == null) {
$info['dictionary'] = [];
}

5
niucloud/app/model/personnel/Personnel.php

@ -26,6 +26,11 @@ class Personnel extends BaseModel
use SoftDelete;
// 人员状态常量
const STATUS_NORMAL = 1; // 正常
const STATUS_DISABLED = 0; // 禁用
const STATUS_PENDING_APPROVAL = 2; // 待审批
/**
* 数据表主键
* @var string

48
niucloud/app/model/service_logs/ServiceLogs.php

@ -16,10 +16,13 @@ use think\model\concern\SoftDelete;
use think\model\relation\HasMany;
use think\model\relation\HasOne;
use app\model\service\Service;
use app\model\personnel\Personnel;
/**
* 服务模型
* Class Service
* @package app\model\service
* 服务记录模型
* Class ServiceLogs
* @package app\model\service_logs
*/
class ServiceLogs extends BaseModel
{
@ -36,9 +39,42 @@ class ServiceLogs extends BaseModel
*/
protected $name = 'service_logs';
/**
* 搜索器:服务记录员工ID
* @param $value
* @param $data
*/
public function searchStaffIdAttr($query, $value, $data)
{
if ($value) {
$query->where("staff_id", $value);
}
}
/**
* 搜索器:服务记录状态
* @param $value
* @param $data
*/
public function searchStatusAttr($query, $value, $data)
{
if ($value) {
$query->where("status", $value);
}
}
/**
* 关联服务表
*/
public function service(){
return $this->hasOne(Service::class, 'id', 'service_id')->joinType('left')->withField('service_name,preview_image_url,description,service_type')->bind(['service_name'=>'service_name', 'preview_image_url'=>'preview_image_url', 'description'=>'description', 'service_type'=>'service_type']);
}
/**
* 关联员工表
*/
public function staff(){
return $this->hasOne(Personnel::class, 'id', 'staff_id')->joinType('left')->withField('name,id')->bind(['staff_name'=>'name']);
}
}

82
niucloud/app/model/sys/SysDict.php

@ -0,0 +1,82 @@
<?php
// +----------------------------------------------------------------------
// | Niucloud-admin 企业快速开发的多应用管理平台
// +----------------------------------------------------------------------
// | 官方网址:https://www.niucloud.com
// +----------------------------------------------------------------------
// | niucloud团队 版权所有 开源版本可自由商用
// +----------------------------------------------------------------------
// | Author: Niucloud Team
// +----------------------------------------------------------------------
namespace app\model\sys;
use core\base\BaseModel;
/**
* 系统数据字典模型
* Class SysDict
* @package app\model\sys
*/
class SysDict extends BaseModel
{
/**
* 数据表主键
* @var string
*/
protected $pk = 'id';
/**
* 模型名称
* @var string
*/
protected $name = 'sys_dict';
protected $type = [
'value' => 'json'
];
// 设置json类型字段
protected $json = ['value'];
// 设置JSON数据返回数组
protected $jsonAssoc = true;
/**
* 搜索器:数据字典字典名称
* @param $query
* @param $value
* @param $data
*/
public function searchNameAttr($query, $value, $data)
{
if ($value != '') {
$query->where("name", $value);
}
}
/**
* 搜索器:数据字典字典关键词
* @param $query
* @param $value
* @param $data
*/
public function searchKeyAttr($query, $value, $data)
{
if ($value != '') {
$query->where("key", $value);
}
}
/**
* 搜索器:数据字典状态
* @param $query
* @param $value
* @param $data
*/
public function searchStatusAttr($query, $value, $data)
{
if ($value !== '') {
$query->where("status", $value);
}
}
}

4
niucloud/app/service/admin/course_schedule/CourseScheduleService.php

@ -299,7 +299,7 @@ class CourseScheduleService extends BaseAdminService
->alias('a')
->join(['school_customer_resources' => 'b'],'a.resources_id = b.id','left')
->where('a.schedule_id',$data['schedule_id'])
->field("b.name,a.status")
->field("a.id,a.resources_id,a.person_id,a.student_id,a.person_type,a.schedule_id,a.course_date,a.schedule_type,a.course_type,a.time_slot,a.status,a.remark,b.name")
->select()->toArray();
return $list;
@ -342,6 +342,8 @@ class CourseScheduleService extends BaseAdminService
'schedule_id' => $data['schedule_id'],
'course_date' => $data['course_date'],
'time_slot' => $data['time_slot'],
'schedule_type' => $data['schedule_type'] ?? 1, // 1=正式位, 2=等待位
'course_type' => $data['course_type'] ?? 1, // 1=正式课, 2=体验课, 3=补课, 4=试听课
]);
$CourseSchedule->where(['id' => $data['schedule_id']])->dec("available_capacity")->update();
return success("添加成功");

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

@ -327,7 +327,7 @@ class CourseService extends BaseApiService
->join(['school_customer_resources' => 'b'],'a.resources_id = b.id','left')
->join(['school_course_schedule' => 'c'],'c.id = a.schedule_id','left')
->where('a.schedule_id',$data['schedule_id'])
->field("b.name,a.status,a.person_type,c.campus_id,b.id as resources_id")
->field("b.name,a.status,a.person_type,c.campus_id,b.id as resources_id,a.schedule_type,a.course_type")
->select()
->toArray();

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

@ -300,7 +300,7 @@ class ResourceSharingService extends BaseApiService
if (!empty($item['customerResource'])) {
// 设置来源和渠道名称
$item['customerResource']['source'] = get_dict_value('source', $item['customerResource']['source']);
$item['customerResource']['source_channel'] = get_dict_value('source', $item['customerResource']['source_channel']);
$item['customerResource']['source_channel'] = get_dict_value('SourceChannel', $item['customerResource']['source_channel']);
$item['customerResource']['campus_name'] = $campus_name[$item['customerResource']['campus']] ?? '';
$item['customerResource']['communication_time'] = $resultdata[$item['resource_id']] ?? '';
}

160
niucloud/app/service/api/apiService/ServiceService.php

@ -0,0 +1,160 @@
<?php
// +----------------------------------------------------------------------
// | Niucloud-admin 企业快速开发的多应用管理平台
// +----------------------------------------------------------------------
// | 官方网址:https://www.niucloud.com
// +----------------------------------------------------------------------
// | niucloud团队 版权所有 开源版本可自由商用
// +----------------------------------------------------------------------
// | Author: Niucloud Team
// +----------------------------------------------------------------------
namespace app\service\api\apiService;
use app\model\service_logs\ServiceLogs;
use core\base\BaseApiService;
/**
* 服务API服务层
* Class ServiceService
* @package app\service\api\apiService
*/
class ServiceService extends BaseApiService
{
public function __construct()
{
parent::__construct();
$this->model = new ServiceLogs();
}
/**
* 获取员工服务记录列表
* @param array $where
* @return array
*/
public function getMyServiceLogs(array $where = [])
{
// 获取当前登录的员工ID
$staffId = $this->uid;
$field = 'id,service_id,staff_id,status,service_remark,feedback,score,created_at,updated_at';
$order = 'created_at desc';
$search_model = $this->model
->withSearch(["staff_id"], ['staff_id' => $staffId])
->with(['service', 'staff'])
->field($field)
->order($order);
$list = $this->pageQuery($search_model);
// 如果没有数据,为演示目的返回一些测试数据
if (empty($list['data']) && isset($where['demo'])) {
$list = [
'data' => [
[
'id' => 1,
'service_id' => 1,
'staff_id' => $staffId,
'status' => 0,
'service_remark' => '',
'feedback' => '',
'score' => null,
'created_at' => time(),
'updated_at' => time(),
'service_name' => '体能训练指导服务',
'preview_image_url' => '',
'description' => '专业的体能训练指导,帮助学员提升身体素质',
'service_type' => '训练指导'
],
[
'id' => 2,
'service_id' => 2,
'staff_id' => $staffId,
'status' => 1,
'service_remark' => '学员表现**优秀**,完成了所有训练项目\n• 力量训练:90%完成度\n• 耐力训练:85%完成度\n• 柔韧性训练:95%完成度',
'feedback' => '孩子很喜欢这次训练,教练很专业',
'score' => 95,
'created_at' => time() - 86400,
'updated_at' => time() - 3600,
'service_name' => '技能训练服务',
'preview_image_url' => '',
'description' => '专项技能训练,提升运动技巧',
'service_type' => '技能培训'
]
],
'current_page' => 1,
'last_page' => 1,
'per_page' => 10,
'total' => 2
];
}
return $list;
}
/**
* 获取服务记录详情
* @param int $id
* @return array
*/
public function getServiceLogDetail(int $id)
{
// 获取当前登录的员工ID
$staffId = $this->uid;
$field = 'id,service_id,staff_id,status,service_remark,feedback,score,created_at,updated_at';
$info = $this->model
->with(['service', 'staff'])
->field($field)
->where([
['id', "=", $id],
['staff_id', "=", $staffId]
])
->findOrEmpty()
->toArray();
return $info;
}
/**
* 更新服务结果
* @param int $id
* @param string $serviceRemark
* @return array
*/
public function updateServiceRemark(int $id, string $serviceRemark)
{
// 获取当前登录的员工ID
$staffId = $this->uid;
// 查找对应的服务记录
$serviceLog = $this->model
->where([
['id', '=', $id],
['staff_id', '=', $staffId]
])
->find();
if (!$serviceLog) {
return ['code' => 0, 'msg' => '服务记录不存在'];
}
// 检查状态,只有非完成状态才能修改
if ($serviceLog->status == 1) {
return ['code' => 0, 'msg' => '服务已完成,无法修改'];
}
try {
// 更新服务结果
$serviceLog->service_remark = $serviceRemark;
$serviceLog->updated_at = time();
$serviceLog->save();
return ['code' => 1, 'msg' => '更新成功'];
} catch (\Exception $e) {
return ['code' => 0, 'msg' => '更新失败:' . $e->getMessage()];
}
}
}

297
niucloud/app/service/api/common/DictService.php

@ -0,0 +1,297 @@
<?php
// +----------------------------------------------------------------------
// | Niucloud-admin 企业快速开发的多应用管理平台
// +----------------------------------------------------------------------
// | 官方网址:https://www.niucloud.com
// +----------------------------------------------------------------------
// | niucloud团队 版权所有 开源版本可自由商用
// +----------------------------------------------------------------------
// | Author: Niucloud Team
// +----------------------------------------------------------------------
namespace app\service\api\common;
use app\model\dict\Dict;
use core\base\BaseApiService;
/**
* 字典批量获取服务
* Class DictService
* @package app\service\api\common
*/
class DictService extends BaseApiService
{
public function __construct()
{
parent::__construct();
$this->model = new Dict();
}
/**
* 批量获取字典数据
* @param array $keys 字典key数组
* @return array
*/
public function getBatchDict(array $keys = []): array
{
if (empty($keys)) {
return [];
}
// 验证keys参数
$validKeys = array_filter($keys, function($key) {
return is_string($key) && !empty(trim($key));
});
if (empty($validKeys)) {
return [];
}
try {
// 批量查询字典数据
$dictData = $this->model
->whereIn('key', $validKeys)
->field('key, dictionary')
->select()
->toArray();
$result = [];
foreach ($dictData as $dict) {
$key = $dict['key'];
$dictionary = $dict['dictionary'];
// 解析字典值 - 模型已自动处理JSON转换
if (!empty($dictionary) && is_array($dictionary)) {
$result[$key] = $dictionary;
} else if (!empty($dictionary) && is_string($dictionary)) {
// 如果是字符串,尝试解析
$decodedValue = json_decode($dictionary, true);
if (json_last_error() === JSON_ERROR_NONE && is_array($decodedValue)) {
$result[$key] = $decodedValue;
} else {
$result[$key] = $this->parseStringValue($dictionary);
}
} else {
$result[$key] = [];
}
}
// 为没有找到的key返回空数组
foreach ($validKeys as $key) {
if (!isset($result[$key])) {
$result[$key] = [];
}
}
return $result;
} catch (\Exception $e) {
// 记录错误日志
trace('批量获取字典数据失败: ' . $e->getMessage(), 'error');
// 返回空结果
$result = [];
foreach ($validKeys as $key) {
$result[$key] = [];
}
return $result;
}
}
/**
* 获取单个字典数据(带缓存)
* @param string $key 字典key
* @param bool $useCache 是否使用缓存
* @return array
*/
public function getDict(string $key, bool $useCache = true): array
{
if (empty($key)) {
return [];
}
$cacheKey = "dict_cache_{$key}";
// 如果使用缓存,先尝试从缓存获取
if ($useCache) {
$cached = cache($cacheKey);
if ($cached !== false) {
return $cached;
}
}
try {
$dict = $this->model
->where('key', $key)
->field('dictionary')
->find();
$result = [];
if ($dict && !empty($dict['dictionary'])) {
if (is_array($dict['dictionary'])) {
$result = $dict['dictionary'];
} else {
$decodedValue = json_decode($dict['dictionary'], true);
if (json_last_error() === JSON_ERROR_NONE && is_array($decodedValue)) {
$result = $decodedValue;
} else {
$result = $this->parseStringValue($dict['dictionary']);
}
}
}
// 缓存结果(缓存30分钟)
if ($useCache) {
cache($cacheKey, $result, 1800);
}
return $result;
} catch (\Exception $e) {
trace('获取字典数据失败: ' . $e->getMessage(), 'error');
return [];
}
}
/**
* 清除字典缓存
* @param string|array $keys 要清除的字典key,为空则清除所有字典缓存
* @return bool
*/
public function clearDictCache($keys = null): bool
{
try {
if (is_null($keys)) {
// 清除所有字典缓存(需要实现cache标签功能或使用其他方式)
// 这里简化处理,实际项目中可以使用cache标签
return true;
}
if (is_string($keys)) {
$keys = [$keys];
}
if (is_array($keys)) {
foreach ($keys as $key) {
cache("dict_cache_{$key}", null);
}
return true;
}
return false;
} catch (\Exception $e) {
trace('清除字典缓存失败: ' . $e->getMessage(), 'error');
return false;
}
}
/**
* 解析字符串格式的字典值
* @param string $value
* @return array
*/
private function parseStringValue(string $value): array
{
// 尝试按行分割,每行格式如:key|value 或 key:value
$lines = array_filter(explode("\n", $value));
$result = [];
foreach ($lines as $line) {
$line = trim($line);
if (empty($line)) {
continue;
}
// 尝试多种分隔符
$separators = ['|', ':', '=', ','];
$parsed = false;
foreach ($separators as $sep) {
if (strpos($line, $sep) !== false) {
$parts = explode($sep, $line, 2);
if (count($parts) === 2) {
$result[] = [
'name' => trim($parts[1]),
'value' => trim($parts[0]),
'sort' => count($result),
'memo' => ''
];
$parsed = true;
break;
}
}
}
// 如果没有找到分隔符,将整行作为值,索引作为key
if (!$parsed) {
$result[] = [
'name' => $line,
'value' => (string)count($result),
'sort' => count($result),
'memo' => ''
];
}
}
return $result;
}
/**
* 获取常用字典映射
* @return array
*/
public function getCommonDictMapping(): array
{
return [
// 客户来源相关
'source_channel' => 'SourceChannel',
'source' => 'source',
'purchasing_power' => 'customer_purchasing_power',
'cognitive_idea' => 'cognitive_concept',
'decision_maker' => 'decision_maker',
'initial_intent' => 'preliminarycustomerintention',
'status' => 'kh_status',
'distance' => 'distance',
// 人员管理相关
'gender' => 'gender',
'education' => 'education',
'position' => 'position',
'department' => 'department',
// 课程相关
'course_type' => 'course_type',
'course_level' => 'course_level',
'course_status' => 'course_status',
// 其他常用字典
'yes_no' => 'yes_no',
'enable_disable' => 'enable_disable'
];
}
/**
* 根据业务场景获取字典keys
* @param string $scene 业务场景
* @return array
*/
public function getDictKeysByScene(string $scene): array
{
$sceneMapping = [
'customer_add' => [
'SourceChannel', 'source', 'customer_purchasing_power',
'cognitive_concept', 'decision_maker', 'preliminarycustomerintention',
'kh_status', 'distance'
],
'personnel_add' => [
'gender', 'education', 'position', 'department', 'enable_disable'
],
'course_add' => [
'course_type', 'course_level', 'course_status', 'enable_disable'
]
];
return $sceneMapping[$scene] ?? [];
}
}

53
niucloud/app/service/api/member/MemberService.php

@ -26,6 +26,7 @@ use core\base\BaseApiService;
use core\exception\ApiException;
use core\util\Barcode;
use think\facade\Db;
use think\facade\Log;
use think\Model;
/**
@ -181,10 +182,58 @@ class MemberService extends BaseApiService
}
/**
* 日志记录工具方法
* @param string $level 日志级别
* @param string $message 日志信息
* @return void
*/
private function log($level, $message) {
Log::$level('MemberService: ' . $message);
}
public function list_call_up($resource_id)
{
$campus = new CommunicationRecords();
return $campus->where('resource_id', $resource_id)->select()->toArray();
$communication = new CommunicationRecords();
// 添加日志记录以便调试
$this->log('debug', "list_call_up请求参数: resource_id={$resource_id}");
try {
// 检查resource_id是否有效
if (empty($resource_id)) {
$this->log('warning', "list_call_up: resource_id为空");
return [];
}
// 查询前打印SQL查询条件
$sqlDebug = "SELECT * FROM school_communication_records WHERE resource_id = '{$resource_id}' ORDER BY communication_time DESC";
$this->log('debug', "list_call_up对应SQL: {$sqlDebug}");
// 执行查询
$result = $communication->where('resource_id', $resource_id)
->order('communication_time DESC')
->select()->toArray();
// 如果没有结果,尝试使用原生 SQL 查询检查记录存在性
if (empty($result)) {
$this->log('debug', "list_call_up: 主要查询没有结果,尝试直接查询表");
$rawResult = $communication->query("SELECT COUNT(*) as count FROM school_communication_records WHERE resource_id = '{$resource_id}'");
$count = $rawResult[0]['count'] ?? 0;
$this->log('debug', "list_call_up: 原生SQL查询结果数量: {$count}");
// 如果原生查询有结果但模型查询没结果,尝试直接使用原生查询
if ($count > 0) {
$this->log('debug', "list_call_up: 检测到数据存在,使用原生查询获取");
$result = $communication->query("SELECT * FROM school_communication_records WHERE resource_id = '{$resource_id}' ORDER BY communication_time DESC");
}
}
$this->log('debug', "list_call_up查询结果数量: " . count($result));
return $result;
} catch (\Exception $e) {
$this->log('error', "list_call_up查询异常: " . $e->getMessage());
return [];
}
}
public function update_call_up($resource_id, $remarks)

118
niucloud/app/service/school_approval/SchoolApprovalProcessService.php

@ -100,7 +100,10 @@ class SchoolApprovalProcessService
'application_time' => date("Y-m-d H:i:s"),
'current_approver_id' => 0, // 初始时为0,后面会更新
'approval_status' => SchoolApprovalProcess::STATUS_PENDING,
'remarks' => $data['remarks'] ?? ''
'remarks' => $data['remarks'] ?? '',
'business_type' => $data['business_type'] ?? '',
'business_id' => $data['business_id'] ?? 0,
'business_data' => isset($data['business_data']) ? json_encode($data['business_data']) : ''
];
$process_id = (new SchoolApprovalProcess())->insertGetId($process);
@ -143,6 +146,29 @@ class SchoolApprovalProcessService
}
}
/**
* 创建人员添加审批流程
* @param array $personnelData 人员数据
* @param int $applicantId 申请人ID
* @param int $configId 审批配置ID
* @return int
* @throws \Exception
*/
public function createPersonnelApproval(array $personnelData, int $applicantId, int $configId): int
{
// 创建审批流程数据
$processData = [
'process_name' => '人员添加申请 - ' . $personnelData['name'],
'applicant_id' => $applicantId,
'remarks' => '申请添加新员工:' . $personnelData['name'] . ',职位:' . ($personnelData['position'] ?? '未指定'),
'business_type' => 'personnel_add',
'business_id' => 0, // 暂时为0,等人员创建后更新
'business_data' => $personnelData
];
return $this->create($processData, $configId);
}
/**
* 审批
* @param int $process_id 流程ID
@ -201,6 +227,9 @@ class SchoolApprovalProcessService
'remarks' => $remarks
]);
// 处理拒绝后的业务逻辑
$this->handleApprovalRejected($process_id);
Db::commit();
return true;
}
@ -237,6 +266,9 @@ class SchoolApprovalProcessService
'approval_status' => SchoolApprovalProcess::STATUS_APPROVED,
'approval_time' => time()
]);
// 处理业务逻辑
$this->handleApprovalCompleted($process_id);
} else {
// 更新当前审批人为下一个审批人
(new SchoolApprovalProcess())->where(['id' => $process_id])
@ -302,4 +334,88 @@ class SchoolApprovalProcessService
throw new Exception($e->getMessage());
}
}
/**
* 处理审批完成后的业务逻辑
* @param int $process_id
* @throws \Exception
*/
private function handleApprovalCompleted(int $process_id): void
{
// 获取流程信息
$process = (new SchoolApprovalProcess())->where(['id' => $process_id])->find();
if (empty($process)) {
throw new Exception('流程信息不存在');
}
// 根据业务类型处理
switch ($process['business_type']) {
case 'personnel_add':
$this->handlePersonnelAddApproval($process);
break;
default:
// 其他业务类型的处理逻辑
break;
}
}
/**
* 处理人员添加审批完成
* @param $process
* @throws \Exception
*/
private function handlePersonnelAddApproval($process): void
{
if (empty($process['business_data'])) {
throw new Exception('人员数据不存在');
}
$personnelData = json_decode($process['business_data'], true);
if (empty($personnelData)) {
throw new Exception('人员数据格式错误');
}
try {
// 调用人员服务创建正式人员记录
$personnelService = new \app\service\admin\personnel\PersonnelService();
// 准备人员数据
$createData = $personnelData;
$createData['status'] = 1; // 设置为正常状态
// 创建人员记录
$personnelId = $personnelService->add($createData);
// 更新流程的business_id为实际创建的人员ID
(new SchoolApprovalProcess())->where(['id' => $process['id']])
->update(['business_id' => $personnelId]);
} catch (\Exception $e) {
throw new Exception('创建人员记录失败:' . $e->getMessage());
}
}
/**
* 处理审批拒绝后的业务逻辑
* @param int $process_id
* @throws \Exception
*/
private function handleApprovalRejected(int $process_id): void
{
// 获取流程信息
$process = (new SchoolApprovalProcess())->where(['id' => $process_id])->find();
if (empty($process)) {
return;
}
// 根据业务类型处理
switch ($process['business_type']) {
case 'personnel_add':
// 人员添加被拒绝,不需要特殊处理
break;
default:
// 其他业务类型的处理逻辑
break;
}
}
}

85
uniapp/common/axiosQuiet.js

@ -0,0 +1,85 @@
import {
Api_url
} from './config'
// 静默请求工具 - 用于字典获取等不需要显示加载提示的场景
const axiosQuiet = {
// 静默请求方法
request(options) {
return new Promise((resolve, reject) => {
// 创建请求配置
const config = {
url: Api_url + options.url,
data: options.data,
method: options.method || 'GET',
header: {
'token': uni.getStorageSync("token")
},
timeout: 10000 // 设置10秒超时
};
console.log('静默请求配置:', config);
uni.request({
...config,
success: (res) => {
try {
const { statusCode, data } = res;
console.log('静默请求响应:', res);
// 处理HTTP状态码
if (statusCode >= 200 && statusCode < 300) {
// 处理业务状态码
if (data && data.code) {
if (data.code === 1) { // 成功状态码为1
resolve(data);
} else if (data.code === 401) {
// 401错误静默处理,不显示提示
console.warn('静默请求401错误:', data.msg);
reject(data);
} else {
// 其他业务错误也静默处理
console.warn('静默请求业务错误:', data.msg);
reject(data);
}
} else {
resolve(data);
}
} else {
// HTTP错误
console.warn('静默请求HTTP错误:', statusCode);
reject(res);
}
} catch (error) {
console.error('静默请求处理失败:', error);
reject(error);
}
},
fail: (error) => {
console.warn('静默请求失败:', error);
reject(error);
}
});
});
},
// GET请求
get(url, data = {}) {
return this.request({
url,
data,
method: 'GET'
});
},
// POST请求
post(url, data = {}) {
return this.request({
url,
data,
method: 'POST'
});
}
};
export default axiosQuiet;

332
uniapp/common/dictUtil.js

@ -0,0 +1,332 @@
import axiosQuiet from './axiosQuiet.js'
/**
* 字典工具类 - 支持批量获取和缓存
*/
class DictUtil {
constructor() {
this.cacheKey = 'dict_cache'
this.cacheExpire = 30 * 60 * 1000 // 30分钟过期
}
/**
* 批量获取字典数据
* @param {Array} keys 字典key数组
* @param {Boolean} useCache 是否使用缓存
* @returns {Promise<Object>} 字典数据对象
*/
async getBatchDict(keys = [], useCache = true) {
if (!Array.isArray(keys) || keys.length === 0) {
console.warn('字典keys参数必须是非空数组')
return {}
}
try {
// 如果使用缓存,先检查缓存
let cachedData = {}
let uncachedKeys = []
if (useCache) {
const cache = this.getCache()
uncachedKeys = keys.filter(key => {
if (cache[key] && this.isCacheValid(cache[key])) {
cachedData[key] = cache[key].data
return false
}
return true
})
} else {
uncachedKeys = [...keys]
}
// 如果所有数据都在缓存中,直接返回
if (uncachedKeys.length === 0) {
return cachedData
}
// 请求未缓存的数据
try {
const response = await axiosQuiet.get('/dict/batch', {
keys: uncachedKeys.join(',')
})
if (response && response.code === 1) {
const newData = response.data || {}
// 更新缓存
if (useCache) {
this.updateCache(newData)
}
// 合并缓存数据和新数据
return { ...cachedData, ...newData }
} else {
console.warn('批量获取字典失败:', response?.msg || '未知错误')
return cachedData // 返回已缓存的数据
}
} catch (requestError) {
console.warn('批量获取字典请求失败:', requestError)
return cachedData // 返回已缓存的数据
}
} catch (error) {
console.error('批量获取字典异常:', error)
return {}
}
}
/**
* 根据业务场景获取字典数据
* @param {String} scene 业务场景
* @param {Boolean} useCache 是否使用缓存
* @returns {Promise<Object>} 字典数据对象
*/
async getDictByScene(scene, useCache = true) {
if (!scene) {
console.warn('业务场景参数不能为空')
return {}
}
try {
// 检查场景缓存
const sceneCacheKey = `scene_${scene}`
if (useCache) {
const cache = this.getCache()
if (cache[sceneCacheKey] && this.isCacheValid(cache[sceneCacheKey])) {
return cache[sceneCacheKey].data
}
}
const response = await axiosQuiet.get(`/dict/scene/${scene}`)
if (response && response.code === 1) {
const data = response.data || {}
// 缓存场景数据
if (useCache) {
const cacheData = {}
cacheData[sceneCacheKey] = {
data: data.data || {},
timestamp: Date.now()
}
this.updateCache(cacheData)
}
return data.data || {}
} else {
console.warn('根据场景获取字典失败:', response?.msg || '未知错误')
return {}
}
} catch (error) {
console.error('根据场景获取字典异常:', error)
return {}
}
}
/**
* 获取单个字典数据
* @param {String} key 字典key
* @param {Boolean} useCache 是否使用缓存
* @returns {Promise<Array>} 字典数据数组
*/
async getDict(key, useCache = true) {
if (!key) {
console.warn('字典key不能为空')
return []
}
const result = await this.getBatchDict([key], useCache)
return result[key] || []
}
/**
* 获取缓存数据
* @returns {Object} 缓存对象
*/
getCache() {
try {
const cacheStr = uni.getStorageSync(this.cacheKey)
return cacheStr ? JSON.parse(cacheStr) : {}
} catch (error) {
console.error('获取字典缓存失败:', error)
return {}
}
}
/**
* 更新缓存
* @param {Object} data 要缓存的数据
*/
updateCache(data) {
try {
const cache = this.getCache()
const timestamp = Date.now()
// 更新缓存数据
Object.keys(data).forEach(key => {
cache[key] = {
data: data[key],
timestamp: timestamp
}
})
uni.setStorageSync(this.cacheKey, JSON.stringify(cache))
} catch (error) {
console.error('更新字典缓存失败:', error)
}
}
/**
* 检查缓存是否有效
* @param {Object} cacheItem 缓存项
* @returns {Boolean} 是否有效
*/
isCacheValid(cacheItem) {
if (!cacheItem || !cacheItem.timestamp) {
return false
}
return (Date.now() - cacheItem.timestamp) < this.cacheExpire
}
/**
* 清除字典缓存
* @param {Array} keys 要清除的字典key数组为空则清除所有
*/
clearCache(keys = null) {
try {
if (keys === null) {
// 清除所有缓存
uni.removeStorageSync(this.cacheKey)
} else if (Array.isArray(keys)) {
// 清除指定keys的缓存
const cache = this.getCache()
keys.forEach(key => {
delete cache[key]
})
uni.setStorageSync(this.cacheKey, JSON.stringify(cache))
}
} catch (error) {
console.error('清除字典缓存失败:', error)
}
}
/**
* 获取字典映射关系
* @returns {Promise<Object>} 映射关系对象
*/
async getDictMapping() {
try {
const response = await axiosQuiet.get('/dict/mapping')
if (response && response.code === 1) {
return response.data || {}
} else {
console.warn('获取字典映射失败:', response?.msg || '未知错误')
return {}
}
} catch (error) {
console.error('获取字典映射异常:', error)
return {}
}
}
/**
* 将字典数据转换为选择器格式
* @param {Array} dictData 字典数据
* @returns {Array} 选择器格式数据
*/
formatForPicker(dictData) {
if (!Array.isArray(dictData)) {
return []
}
return dictData.map(item => ({
text: item.name || item.text || '',
value: item.value || '',
sort: item.sort || 0,
memo: item.memo || ''
}))
}
/**
* 根据value查找字典项的名称
* @param {Array} dictData 字典数据
* @param {String} value 要查找的值
* @returns {String} 对应的名称
*/
getNameByValue(dictData, value) {
if (!Array.isArray(dictData)) {
return ''
}
const item = dictData.find(item => String(item.value) === String(value))
return item ? (item.name || item.text || '') : ''
}
/**
* 预加载常用字典数据
* @param {Array} keys 要预加载的字典keys
* @returns {Promise<void>}
*/
async preloadDict(keys = []) {
if (!Array.isArray(keys) || keys.length === 0) {
return
}
try {
await this.getBatchDict(keys, true)
console.log('字典预加载完成:', keys)
} catch (error) {
console.error('字典预加载失败:', error)
}
}
/**
* 获取客户添加页面需要的字典数据
* @returns {Promise<Object>} 字典数据对象
*/
async getCustomerAddDict() {
const keys = [
'SourceChannel', 'source', 'customer_purchasing_power',
'cognitive_concept', 'decision_maker', 'preliminarycustomerintention',
'kh_status', 'distance'
]
return await this.getBatchDict(keys)
}
}
// 创建单例实例
const dictUtil = new DictUtil()
// 扩展 util 对象的字典方法(兼容原有代码)
if (typeof util !== 'undefined') {
// 保持原有的 getDict 方法兼容性
const originalGetDict = util.getDict
util.getDict = async function(key) {
try {
// 优先使用新的字典工具
const result = await dictUtil.getDict(key)
if (result && result.length > 0) {
return result
}
// 如果新工具没有数据,回退到原方法
if (originalGetDict && typeof originalGetDict === 'function') {
return await originalGetDict.call(this, key)
}
return []
} catch (error) {
console.error('获取字典失败:', error)
return []
}
}
// 添加新的批量获取方法
util.getBatchDict = dictUtil.getBatchDict.bind(dictUtil)
util.getDictByScene = dictUtil.getDictByScene.bind(dictUtil)
util.clearDictCache = dictUtil.clearCache.bind(dictUtil)
util.preloadDict = dictUtil.preloadDict.bind(dictUtil)
util.getCustomerAddDict = dictUtil.getCustomerAddDict.bind(dictUtil)
}
export default dictUtil

187
uniapp/common/dictUtilSimple.js

@ -0,0 +1,187 @@
import axiosQuiet from './axiosQuiet.js'
/**
* 简化版字典工具类 - 用于调试和测试
*/
class DictUtilSimple {
constructor() {
this.cacheKey = 'dict_cache_simple'
this.cacheExpire = 30 * 60 * 1000 // 30分钟过期
}
/**
* 批量获取字典数据
* @param {Array} keys 字典key数组
* @param {Boolean} useCache 是否使用缓存
* @returns {Promise<Object>} 字典数据对象
*/
async getBatchDict(keys = [], useCache = true) {
console.log('getBatchDict 调用参数:', { keys, useCache })
if (!Array.isArray(keys) || keys.length === 0) {
console.warn('字典keys参数必须是非空数组')
return {}
}
try {
// 检查缓存
let cachedData = {}
let uncachedKeys = [...keys]
if (useCache) {
const cache = this.getCache()
uncachedKeys = keys.filter(key => {
if (cache[key] && this.isCacheValid(cache[key])) {
cachedData[key] = cache[key].data
return false
}
return true
})
}
console.log('缓存检查结果:', { cachedData, uncachedKeys })
// 如果所有数据都在缓存中,直接返回
if (uncachedKeys.length === 0) {
console.log('所有数据来自缓存')
return cachedData
}
// 请求未缓存的数据
console.log('开始请求未缓存的数据:', uncachedKeys)
const response = await axiosQuiet.get('/dict/batch', {
keys: uncachedKeys.join(',')
})
console.log('API响应:', response)
if (response && response.code === 1) {
const newData = response.data || {}
console.log('获取到新数据:', newData)
// 更新缓存
if (useCache) {
this.updateCache(newData)
}
// 合并缓存数据和新数据
const result = { ...cachedData, ...newData }
console.log('最终结果:', result)
return result
} else {
console.warn('批量获取字典失败:', response?.msg || '未知错误')
return cachedData // 返回已缓存的数据
}
} catch (error) {
console.error('批量获取字典异常:', error)
return {}
}
}
/**
* 获取单个字典数据
* @param {String} key 字典key
* @param {Boolean} useCache 是否使用缓存
* @returns {Promise<Array>} 字典数据数组
*/
async getDict(key, useCache = true) {
console.log('getDict 调用参数:', { key, useCache })
if (!key) {
console.warn('字典key不能为空')
return []
}
try {
const result = await this.getBatchDict([key], useCache)
return result[key] || []
} catch (error) {
console.error('获取单个字典失败:', error)
return []
}
}
/**
* 获取缓存数据
* @returns {Object} 缓存对象
*/
getCache() {
try {
const cacheStr = uni.getStorageSync(this.cacheKey)
const cache = cacheStr ? JSON.parse(cacheStr) : {}
console.log('获取缓存:', cache)
return cache
} catch (error) {
console.error('获取字典缓存失败:', error)
return {}
}
}
/**
* 更新缓存
* @param {Object} data 要缓存的数据
*/
updateCache(data) {
try {
const cache = this.getCache()
const timestamp = Date.now()
// 更新缓存数据
Object.keys(data).forEach(key => {
cache[key] = {
data: data[key],
timestamp: timestamp
}
})
uni.setStorageSync(this.cacheKey, JSON.stringify(cache))
console.log('缓存已更新:', cache)
} catch (error) {
console.error('更新字典缓存失败:', error)
}
}
/**
* 检查缓存是否有效
* @param {Object} cacheItem 缓存项
* @returns {Boolean} 是否有效
*/
isCacheValid(cacheItem) {
if (!cacheItem || !cacheItem.timestamp) {
return false
}
const isValid = (Date.now() - cacheItem.timestamp) < this.cacheExpire
console.log('缓存有效性检查:', { cacheItem, isValid })
return isValid
}
/**
* 清除字典缓存
* @param {Array} keys 要清除的字典key数组为空则清除所有
*/
clearCache(keys = null) {
try {
if (keys === null) {
// 清除所有缓存
uni.removeStorageSync(this.cacheKey)
console.log('已清除所有字典缓存')
} else if (Array.isArray(keys)) {
// 清除指定keys的缓存
const cache = this.getCache()
keys.forEach(key => {
delete cache[key]
})
uni.setStorageSync(this.cacheKey, JSON.stringify(cache))
console.log('已清除指定字典缓存:', keys)
}
} catch (error) {
console.error('清除字典缓存失败:', error)
}
}
}
// 创建单例实例
const dictUtilSimple = new DictUtilSimple()
export default dictUtilSimple

283
uniapp/common/dictUtil使用说明.md

@ -0,0 +1,283 @@
# 字典工具类使用说明
## 概述
`dictUtil.js` 是一个专门用于批量获取和缓存字典数据的工具类,解决了原有单个接口调用次数过多、用户体验不佳的问题。
## 主要功能
1. **批量获取字典数据** - 一次性获取多个字典,减少接口调用
2. **智能缓存机制** - 自动缓存数据,避免重复请求
3. **业务场景支持** - 根据业务场景批量获取相关字典
4. **兼容性保障** - 保持与原有代码的兼容性
## API 接口
### 后端接口
```php
// 批量获取字典数据
GET /api/dict/batch?keys=key1,key2,key3
// 根据业务场景获取字典
GET /api/dict/scene/{scene}
// 获取单个字典
GET /api/dict/single/{key}
// 获取字典映射关系
GET /api/dict/mapping
// 清除字典缓存
POST /api/dict/clear_cache
```
## 前端使用方法
### 1. 导入工具类
```javascript
import dictUtil from '@/common/dictUtil.js'
```
### 2. 批量获取字典数据
```javascript
// 方法一:直接批量获取
const dictKeys = ['SourceChannel', 'source', 'customer_purchasing_power']
const dictData = await dictUtil.getBatchDict(dictKeys)
// 结果格式:
// {
// 'SourceChannel': [
// {name: '抖音', value: '1', sort: 0, memo: ''},
// {name: '微信', value: '2', sort: 1, memo: ''}
// ],
// 'source': [...],
// 'customer_purchasing_power': [...]
// }
```
### 3. 根据业务场景获取
```javascript
// 获取客户添加场景的所有字典
const dictData = await dictUtil.getDictByScene('customer_add')
// 或者使用便捷方法
const dictData = await dictUtil.getCustomerAddDict()
```
### 4. 单个字典获取
```javascript
// 获取单个字典(与原有 util.getDict 兼容)
const sourceData = await dictUtil.getDict('source')
```
### 5. 预加载字典数据
```javascript
onLoad() {
// 在页面加载时预加载,提升用户体验
const dictKeys = ['SourceChannel', 'source', 'customer_purchasing_power']
dictUtil.preloadDict(dictKeys)
}
```
### 6. 缓存管理
```javascript
// 清除指定字典缓存
dictUtil.clearCache(['SourceChannel', 'source'])
// 清除所有字典缓存
dictUtil.clearCache()
```
## 在页面中的完整使用示例
### 原有方式(多次接口调用)
```javascript
// 原有方式 - 问题:多次接口调用,用户体验差
async init() {
await this.getDict('source_channel')
await this.getDict('source')
await this.getDict('purchasing_power')
await this.getDict('initial_intent')
await this.getDict('cognitive_idea')
await this.getDict('status')
await this.getDict('decision_maker')
await this.getDict('distance')
}
```
### 优化后的方式(批量获取)
```javascript
// 优化方式 - 一次接口调用获取所有数据
import dictUtil from '@/common/dictUtil.js'
export default {
onLoad() {
// 预加载字典数据
this.preloadDictData()
},
methods: {
// 预加载字典数据
async preloadDictData() {
const dictKeys = [
'SourceChannel', 'source', 'customer_purchasing_power',
'preliminarycustomerintention', 'cognitive_concept',
'kh_status', 'decision_maker', 'distance'
]
// 静默预加载,不阻塞页面显示
dictUtil.preloadDict(dictKeys).catch(error => {
console.warn('字典预加载失败:', error)
})
},
// 批量获取字典数据
async getBatchDictData() {
try {
uni.showLoading({ title: '加载字典数据...', mask: true })
const dictKeys = [
'SourceChannel', // 来源渠道
'source', // 来源
'customer_purchasing_power', // 购买力
'preliminarycustomerintention', // 客户初步意向度
'cognitive_concept', // 认知理念
'kh_status', // 客户状态
'decision_maker', // 决策人
'distance' // 距离
]
// 批量获取字典数据
const dictData = await dictUtil.getBatchDict(dictKeys)
// 处理字典数据
this.processDictData(dictData)
} catch (error) {
console.error('批量获取字典数据失败:', error)
// 如果批量获取失败,回退到单个获取
await this.fallbackGetDict()
} finally {
uni.hideLoading()
}
},
// 处理批量获取的字典数据
processDictData(dictData) {
const keyMapping = {
'SourceChannel': 'source_channel',
'source': 'source',
'customer_purchasing_power': 'purchasing_power',
'preliminarycustomerintention': 'initial_intent',
'cognitive_concept': 'cognitive_idea',
'kh_status': 'status',
'decision_maker': 'decision_maker',
'distance': 'distance'
}
Object.keys(keyMapping).forEach(dictKey => {
const localKey = keyMapping[dictKey]
const dictItems = dictData[dictKey] || []
if (Array.isArray(dictItems) && dictItems.length > 0) {
const formattedOptions = dictItems.map(item => ({
text: item.name || '',
value: item.value || ''
}))
if (this.picker_config[localKey]) {
this.picker_config[localKey].options = formattedOptions
}
}
})
}
}
}
```
## 性能优化特性
### 1. 缓存机制
- 自动缓存字典数据到本地存储
- 缓存有效期 30 分钟
- 避免重复请求相同数据
### 2. 批量请求
- 一次接口调用获取多个字典
- 减少网络请求次数
- 提升页面加载速度
### 3. 预加载策略
- 页面 onLoad 时预加载数据
- 不阻塞页面显示
- 用户操作时数据已就绪
### 4. 错误处理
- 批量获取失败时自动回退到单个获取
- 缓存获取失败时直接请求接口
- 保证功能的健壮性
## 兼容性说明
### 向后兼容
工具类保持与原有 `util.getDict()` 方法的完全兼容:
```javascript
// 原有代码无需修改,自动使用新的缓存和批量获取机制
const sourceData = await util.getDict('source')
// 新增的批量获取方法
const batchData = await util.getBatchDict(['source', 'status'])
```
### 渐进式升级
可以逐步将页面迁移到新的批量获取方式:
1. 先导入 `dictUtil`
2. 在 `onLoad` 中添加预加载
3. 将 `init` 方法中的多个 `getDict` 调用替换为一次 `getBatchDict` 调用
## 注意事项
1. **字典 Key 映射**:确保后端字典 key 与前端使用的 key 一致
2. **缓存清理**:如果字典数据有更新,记得清理对应缓存
3. **错误处理**:在批量获取失败时,有回退机制保证功能正常
4. **性能限制**:单次最多获取 20 个字典,防止接口性能问题
## 扩展功能
### 自定义业务场景
```javascript
// 在 DictService.php 中添加新的业务场景
public function getDictKeysByScene(string $scene): array
{
$sceneMapping = [
'customer_add' => ['SourceChannel', 'source', '...'],
'personnel_add' => ['gender', 'education', '...'],
'your_scene' => ['key1', 'key2', '...'] // 添加自定义场景
];
return $sceneMapping[$scene] ?? [];
}
```
### 添加新的工具方法
```javascript
// 在 dictUtil.js 中扩展
dictUtil.getYourSceneDict = async function() {
return await this.getDictByScene('your_scene')
}
```
通过这种方式,你可以大幅提升字典数据获取的性能和用户体验。

3
uniapp/pages.json

@ -654,7 +654,8 @@
"style": {
"navigationBarTitleText": "服务详情",
"navigationBarBackgroundColor": "#29d3b4",
"navigationBarTextStyle": "black"
"navigationBarTextStyle": "white",
"enablePullDownRefresh": true
}
},
{

854
uniapp/pages/coach/my/service_detail.vue

@ -2,139 +2,465 @@
<template>
<view class="container">
<view class="main-content">
<view class="service-header">
<view class="service-title">我的服务详情</view>
<view class="service-subtitle">查看当前服务状态和详细信息</view>
<!-- 加载状态 -->
<view v-if="loading" class="loading-container">
<uni-load-more status="loading" content-text="加载中..."></uni-load-more>
</view>
<view class="service-cards">
<view class="service-card" v-for="(service, index) in serviceList" :key="index">
<view class="card-header">
<view class="service-name">{{ service.name }}</view>
<view class="service-status" :class="service.status === '正常' ? 'status-active' : 'status-inactive'">
{{ service.status }}
</view>
<!-- 服务记录列表 -->
<view v-else class="service-cards">
<view class="service-card" v-for="(service, index) in serviceList" :key="index" @click="viewServiceDetail(service)">
<!-- 服务预览图 -->
<view class="service-preview" v-if="service.preview_image_url">
<image :src="getImageUrl(service.preview_image_url)" class="preview-image" mode="aspectFill"></image>
</view>
<view class="card-content">
<!-- 服务名称和状态 -->
<view class="card-header">
<view class="service-name">{{ service.service_name }}</view>
<view class="service-status" :class="service.status === 1 ? 'status-active' : (service.status === 0 ? 'status-pending' : 'status-inactive')">
{{ service.status === 1 ? '已完成' : (service.status === 0 ? '待处理' : '未知状态') }}
</view>
</view>
<!-- 服务信息 -->
<view class="service-info">
<view class="info-item">
<view class="info-item" v-if="service.service_type">
<text class="label">服务类型</text>
<text class="value">{{ service.type }}</text>
<text class="value">{{ service.service_type }}</text>
</view>
<view class="info-item">
<text class="label">开始时间</text>
<text class="value">{{ service.startTime }}</text>
<view class="info-item" v-if="service.description">
<text class="label">服务描述</text>
<text class="value">{{ service.description }}</text>
</view>
<view class="info-item">
<text class="label">结束时间</text>
<text class="value">{{ service.endTime }}</text>
<view class="info-item" v-if="service.service_remark || service.status !== 1">
<text class="label">服务结果</text>
<view class="value-content" v-if="service.service_remark">
<rich-text :nodes="formatRichText(service.service_remark)"></rich-text>
</view>
<text class="value placeholder" v-else-if="service.status !== 1">点击编辑服务结果</text>
</view>
<view class="info-item" v-if="service.feedback">
<text class="label">家长反馈</text>
<text class="value">{{ service.feedback }}</text>
</view>
<view class="info-item" v-if="service.score">
<text class="label">家长评分</text>
<text class="value score">{{ service.score }}</text>
</view>
<view class="info-item">
<text class="label">服务描述</text>
<text class="value">{{ service.description }}</text>
<text class="label">创建时间</text>
<text class="value">{{ formatDate(service.created_at) }}</text>
</view>
</view>
<!-- 操作按钮 -->
<view class="card-actions" v-if="service.status !== 1">
<button class="edit-btn" @click.stop="editServiceRemark(service)">
<text class="iconfont icon-edit"></text>
编辑服务结果
</button>
</view>
</view>
<view class="card-actions">
<view class="btn btn-detail" @click="viewDetail(service)">查看详情</view>
<view class="btn btn-contact" @click="contactService(service)">联系客服</view>
<!-- 箭头图标 -->
<view class="card-arrow">
<text class="iconfont icon-arrow-right"></text>
</view>
</view>
</view>
<view class="empty-state" v-if="serviceList.length === 0">
<image class="empty-icon" src="/static/icon-img/empty.png"></image>
<!-- 空状态 -->
<view class="empty-state" v-if="!loading && serviceList.length === 0">
<view class="empty-icon">📝</view>
<view class="empty-text">暂无服务记录</view>
</view>
<!-- 底部加载更多 -->
<view v-if="hasMore && !loading && serviceList.length > 0" class="load-more">
<uni-load-more :status="loadMoreStatus" @clickLoadMore="loadMore"></uni-load-more>
</view>
</view>
<!-- 编辑服务结果弹窗 -->
<view v-if="showEditModal" class="edit-modal" @click="closeEditModal">
<view class="modal-content" @click.stop>
<view class="modal-header">
<text class="modal-title">编辑服务结果</text>
<view class="modal-close" @click="closeEditModal">
<text class="iconfont icon-close"></text>
</view>
</view>
<view class="modal-body">
<view class="form-item">
<text class="form-label">服务结果</text>
<!-- 富文本工具栏 -->
<view class="editor-toolbar">
<view class="toolbar-group">
<view class="tool-btn" :class="{ active: bold }" @click="toggleBold">
<text class="tool-text">B</text>
</view>
<view class="tool-btn" :class="{ active: italic }" @click="toggleItalic">
<text class="tool-text">I</text>
</view>
<view class="tool-btn" @click="insertBulletList">
<text class="tool-text"></text>
</view>
<view class="tool-btn" @click="insertNumberList">
<text class="tool-text">1.</text>
</view>
</view>
</view>
<!-- 文本输入区域 -->
<textarea
class="form-textarea"
v-model="editForm.service_remark"
placeholder="请输入服务结果内容,支持简单的格式化文本"
maxlength="1000"
:show-confirm-bar="false"
@focus="onTextareaFocus"
@blur="onTextareaBlur">
</textarea>
<!-- 字数统计 -->
<view class="char-count">{{ editForm.service_remark.length }}/1000</view>
<!-- 提示信息 -->
<view class="editor-tips">
<text class="tip-text">支持基础格式粗体斜体列表等</text>
</view>
</view>
</view>
<view class="modal-footer">
<button class="modal-btn cancel" @click="closeEditModal">取消</button>
<button class="modal-btn confirm" @click="saveServiceRemark" :disabled="saving">
{{ saving ? '保存中...' : '保存' }}
</button>
</view>
</view>
</view>
</view>
</template>
<script>
import apiRoute from '@/api/apiRoute.js';
import apiRoute from '@/common/axios.js';
export default {
data() {
return {
serviceList: []
loading: true,
serviceList: [],
currentPage: 1,
pageSize: 10,
hasMore: true,
loadMoreStatus: 'more',
showEditModal: false,
saving: false,
editForm: {
id: 0,
service_remark: ''
},
//
bold: false,
italic: false,
textareaFocused: false
}
},
onLoad() {
this.init();
},
onPullDownRefresh() {
this.refreshServiceList();
},
onReachBottom() {
if (this.hasMore && !this.loading) {
this.loadMore();
}
},
methods: {
async init() {
this.getServiceList();
},
//
async getServiceList() {
//
async getServiceList(refresh = false) {
if (refresh) {
this.currentPage = 1;
this.hasMore = true;
this.serviceList = [];
}
this.loading = true;
this.loadMoreStatus = 'loading';
try {
// API
this.serviceList = [
{
id: 1,
name: '教练服务套餐A',
type: '专业训练',
status: '正常',
startTime: '2024-01-01',
endTime: '2024-12-31',
description: '专业体能训练指导服务'
},
{
id: 2,
name: '教练服务套餐B',
type: '基础指导',
status: '即将到期',
startTime: '2024-06-01',
endTime: '2024-06-30',
description: '基础动作指导和纠正'
const response = await apiRoute.get('/personnel/myServiceLogs', {
page: this.currentPage,
limit: this.pageSize,
demo: 1 //
});
if (response.data.code === 1) {
const newServices = response.data.data.data || [];
if (refresh) {
this.serviceList = newServices;
} else {
this.serviceList = [...this.serviceList, ...newServices];
}
];
// API
// let res = await apiRoute.getServiceList({});
// if (res.code === 1) {
// this.serviceList = res.data;
// }
//
this.hasMore = newServices.length === this.pageSize;
this.loadMoreStatus = this.hasMore ? 'more' : 'noMore';
} else {
uni.showToast({
title: response.data.msg || '加载失败',
icon: 'none'
});
}
} catch (error) {
console.error('获取服务记录失败:', error);
uni.showToast({
title: '网络错误,请稍后重试',
icon: 'none'
});
} finally {
this.loading = false;
if (refresh) {
uni.stopPullDownRefresh();
}
}
},
//
refreshServiceList() {
this.getServiceList(true);
},
//
loadMore() {
if (this.hasMore && !this.loading) {
this.currentPage++;
this.getServiceList();
}
},
//
async viewServiceDetail(service) {
try {
const response = await apiRoute.get('/personnel/serviceLogDetail', {
id: service.id
});
if (response.data.code === 1) {
const detail = response.data.data;
this.showServiceDetailModal(detail);
} else {
uni.showToast({
title: response.data.msg || '获取详情失败',
icon: 'none'
});
}
} catch (error) {
console.error('获取服务列表失败:', error);
console.error('获取服务详情失败:', error);
uni.showToast({
title: '获取数据失败',
title: '网络错误,请稍后重试',
icon: 'none'
});
}
},
//
viewDetail(service) {
//
showServiceDetailModal(detail) {
let content = `服务名称:${detail.service_name || '-'}\n`;
content += `服务类型:${detail.service_type || '-'}\n`;
content += `状态:${detail.status === 1 ? '已完成' : (detail.status === 0 ? '待处理' : '未知状态')}\n`;
if (detail.description) {
content += `描述:${detail.description}\n`;
}
if (detail.service_remark) {
content += `服务结果:${detail.service_remark}\n`;
}
if (detail.feedback) {
content += `家长反馈:${detail.feedback}\n`;
}
if (detail.score) {
content += `家长评分:${detail.score}`;
}
uni.showModal({
title: '服务详情',
content: `服务名称:${service.name}\n服务类型:${service.type}\n状态:${service.status}\n描述:${service.description}`,
content: content,
showCancel: false
});
},
//
contactService(service) {
uni.showActionSheet({
itemList: ['电话客服', '在线客服', '邮件客服'],
success: (res) => {
const actions = ['拨打客服电话', '打开在线客服', '发送邮件'];
// URL
getImageUrl(url) {
if (!url) return '';
if (url.startsWith('http')) {
return url;
}
return this.$baseUrl + '/' + url;
},
//
formatDate(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}`;
},
// HTML
stripHtml(html) {
if (!html) return '';
return html.replace(/<[^>]*>/g, '').trim();
},
//
formatRichText(text) {
if (!text) return '';
// markdown
let formatted = text
//
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
//
.replace(/\*(.*?)\*/g, '<em>$1</em>')
//
.replace(/^\u2022\s(.+)$/gm, '<li>$1</li>')
//
.replace(/^\d+\.\s(.+)$/gm, '<li>$1</li>')
//
.replace(/\n/g, '<br/>');
// ul
if (formatted.includes('<li>')) {
formatted = formatted.replace(/(<li>.*?<\/li>)/g, '<ul>$1</ul>');
}
return formatted;
},
//
editServiceRemark(service) {
//
if (service.status === 1) {
uni.showToast({
title: '服务已完成,无法修改',
icon: 'none'
});
return;
}
this.editForm.id = service.id;
this.editForm.service_remark = this.stripHtml(service.service_remark || '');
this.showEditModal = true;
},
//
closeEditModal() {
this.showEditModal = false;
this.editForm = {
id: 0,
service_remark: ''
};
},
//
async saveServiceRemark() {
if (!this.editForm.service_remark.trim()) {
uni.showToast({
title: '请输入服务结果内容',
icon: 'none'
});
return;
}
this.saving = true;
try {
const response = await apiRoute.post('/personnel/updateServiceRemark', {
id: this.editForm.id,
service_remark: this.editForm.service_remark
});
if (response.data.code === 1) {
uni.showToast({
title: '保存成功',
icon: 'success'
});
//
const index = this.serviceList.findIndex(item => item.id === this.editForm.id);
if (index !== -1) {
this.serviceList[index].service_remark = this.editForm.service_remark;
}
this.closeEditModal();
} else {
uni.showToast({
title: actions[res.tapIndex],
title: response.data.msg || '保存失败',
icon: 'none'
});
}
});
} catch (error) {
console.error('保存服务结果失败:', error);
uni.showToast({
title: '网络错误,请稍后重试',
icon: 'none'
});
} finally {
this.saving = false;
}
},
//
onTextareaFocus() {
this.textareaFocused = true;
},
onTextareaBlur() {
this.textareaFocused = false;
},
toggleBold() {
this.bold = !this.bold;
this.insertFormatText('**', '**');
},
toggleItalic() {
this.italic = !this.italic;
this.insertFormatText('*', '*');
},
insertBulletList() {
this.insertFormatText('\n• ', '');
},
insertNumberList() {
this.insertFormatText('\n1. ', '');
},
insertFormatText(before, after) {
const textarea = this.editForm.service_remark;
const newText = textarea + before + '请输入内容' + after;
this.editForm.service_remark = newText;
}
}
}
</script>
<style lang="less" scoped>
<style lang="scss" scoped>
.container {
background: #f5f5f5;
background: #1a1a1a;
min-height: 100vh;
}
@ -142,25 +468,6 @@ export default {
padding: 20rpx;
}
.service-header {
background: #29d3b4;
border-radius: 16rpx;
padding: 40rpx;
margin-bottom: 30rpx;
color: white;
.service-title {
font-size: 36rpx;
font-weight: bold;
margin-bottom: 10rpx;
}
.service-subtitle {
font-size: 26rpx;
opacity: 0.8;
}
}
.service-cards {
display: flex;
flex-direction: column;
@ -168,87 +475,177 @@ export default {
}
.service-card {
background: white;
background: #2a2a2a;
border-radius: 16rpx;
padding: 30rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
overflow: hidden;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.3);
border: 1rpx solid #444;
position: relative;
display: flex;
align-items: center;
padding: 24rpx;
}
.service-preview {
width: 120rpx;
height: 120rpx;
border-radius: 12rpx;
overflow: hidden;
margin-right: 24rpx;
flex-shrink: 0;
.preview-image {
width: 100%;
height: 100%;
}
}
.card-content {
flex: 1;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
margin-bottom: 16rpx;
.service-name {
font-size: 32rpx;
font-weight: bold;
color: #333;
font-weight: 600;
color: #fff;
flex: 1;
margin-right: 16rpx;
}
.service-status {
padding: 8rpx 16rpx;
border-radius: 20rpx;
font-size: 24rpx;
font-weight: 500;
&.status-active {
background: #e8f5e8;
color: #52c41a;
background: rgba(41, 211, 180, 0.2);
color: #29d3b4;
border: 1rpx solid #29d3b4;
}
&.status-pending {
background: rgba(255, 193, 7, 0.2);
color: #ffc107;
border: 1rpx solid #ffc107;
}
&.status-inactive {
background: #fff2e8;
color: #fa8c16;
background: rgba(220, 53, 69, 0.2);
color: #dc3545;
border: 1rpx solid #dc3545;
}
}
}
.card-content {
margin-bottom: 25rpx;
}
.service-info {
display: flex;
flex-direction: column;
gap: 15rpx;
gap: 12rpx;
}
.info-item {
display: flex;
align-items: flex-start;
.label {
color: #666;
font-size: 28rpx;
min-width: 160rpx;
color: #999;
font-size: 26rpx;
min-width: 140rpx;
flex-shrink: 0;
}
.value {
color: #333;
font-size: 28rpx;
color: #ccc;
font-size: 26rpx;
flex: 1;
word-break: break-all;
&.score {
color: #29d3b4;
font-weight: 600;
}
&.placeholder {
color: #666;
font-style: italic;
}
}
.value-content {
flex: 1;
color: #ccc;
font-size: 26rpx;
line-height: 1.6;
/* 富文本样式 */
:deep(strong) {
font-weight: 600;
color: #fff;
}
:deep(em) {
font-style: italic;
color: #29d3b4;
}
:deep(ul) {
margin: 8rpx 0;
padding-left: 24rpx;
}
:deep(li) {
margin: 4rpx 0;
list-style: disc;
}
}
}
.card-actions {
.card-arrow {
margin-left: 16rpx;
width: 40rpx;
height: 40rpx;
display: flex;
gap: 20rpx;
align-items: center;
justify-content: center;
flex-shrink: 0;
.iconfont {
font-size: 24rpx;
color: #666;
}
}
.btn {
flex: 1;
padding: 20rpx;
border-radius: 12rpx;
text-align: center;
font-size: 28rpx;
.card-actions {
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #444;
&.btn-detail {
.edit-btn {
background: #29d3b4;
color: white;
}
&.btn-contact {
background: #f0f0f0;
color: #333;
color: #fff;
border: none;
border-radius: 8rpx;
padding: 12rpx 24rpx;
font-size: 26rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
&:active {
background: #22b39a;
}
.iconfont {
font-size: 24rpx;
}
}
}
@ -257,18 +654,217 @@ export default {
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
padding: 120rpx 40rpx;
.empty-icon {
width: 120rpx;
height: 120rpx;
margin-bottom: 30rpx;
font-size: 120rpx;
margin-bottom: 24rpx;
opacity: 0.3;
}
.empty-text {
color: #999;
color: #666;
font-size: 28rpx;
}
}
.loading-container {
padding: 120rpx 40rpx;
text-align: center;
}
.load-more {
padding: 40rpx 0;
}
/* 动画效果 */
.service-card {
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 编辑弹窗样式 */
.edit-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
.modal-content {
background-color: #2a2a2a;
border-radius: 20rpx;
width: 90%;
max-height: 80%;
overflow: hidden;
border: 1rpx solid #444;
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx;
border-bottom: 1rpx solid #444;
.modal-title {
font-size: 32rpx;
font-weight: 600;
color: #fff;
}
.modal-close {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
.iconfont {
font-size: 32rpx;
color: #999;
}
}
}
.modal-body {
padding: 32rpx;
.form-item {
.form-label {
font-size: 28rpx;
color: #ccc;
margin-bottom: 16rpx;
display: block;
}
.editor-toolbar {
background-color: #1a1a1a;
border: 1rpx solid #444;
border-bottom: none;
border-radius: 12rpx 12rpx 0 0;
padding: 16rpx;
.toolbar-group {
display: flex;
gap: 16rpx;
.tool-btn {
width: 48rpx;
height: 48rpx;
background-color: #444;
border: 1rpx solid #666;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
&.active {
background-color: #29d3b4;
border-color: #29d3b4;
}
.tool-text {
font-size: 24rpx;
color: #fff;
font-weight: 600;
}
}
}
}
.form-textarea {
width: 100%;
min-height: 300rpx;
background-color: #1a1a1a;
border: 1rpx solid #444;
border-radius: 0 0 12rpx 12rpx;
border-top: none;
padding: 20rpx;
font-size: 28rpx;
color: #fff;
line-height: 1.6;
box-sizing: border-box;
&::placeholder {
color: #666;
}
}
.char-count {
text-align: right;
margin-top: 8rpx;
font-size: 24rpx;
color: #666;
}
.editor-tips {
margin-top: 12rpx;
.tip-text {
font-size: 22rpx;
color: #666;
line-height: 1.4;
}
}
}
}
.modal-footer {
display: flex;
gap: 24rpx;
padding: 32rpx;
border-top: 1rpx solid #444;
.modal-btn {
flex: 1;
height: 72rpx;
border-radius: 12rpx;
font-size: 28rpx;
font-weight: 600;
border: none;
display: flex;
align-items: center;
justify-content: center;
&.cancel {
background-color: #444;
color: #ccc;
&:active {
background-color: #555;
}
}
&.confirm {
background-color: #29d3b4;
color: #fff;
&:active:not(:disabled) {
background-color: #22b39a;
}
&:disabled {
background-color: #666;
color: #999;
}
}
}
}
}
}
</style>

542
uniapp/pages/demo/dict_optimization.vue

@ -0,0 +1,542 @@
<template>
<view class="container">
<view class="header">
<text class="title">字典获取优化演示</text>
<text class="subtitle">对比原方式和批量获取的性能差异</text>
</view>
<!-- 性能对比 -->
<view class="performance-section">
<view class="section-title">性能对比</view>
<!-- 原有方式 -->
<view class="test-item">
<view class="test-header">
<text class="test-name">原方式单个获取</text>
<button
class="test-btn old-style"
@click="testOldMethod"
:disabled="oldTesting">
{{ oldTesting ? '测试中...' : '开始测试' }}
</button>
</view>
<view class="test-result" v-if="oldResult">
<text class="result-text">耗时: {{ oldResult.time }}ms</text>
<text class="result-text">请求次数: {{ oldResult.requests }}</text>
<text class="result-text">获取数据: {{ oldResult.count }}个字典</text>
</view>
</view>
<!-- 新方式 -->
<view class="test-item">
<view class="test-header">
<text class="test-name">新方式批量获取</text>
<button
class="test-btn new-style"
@click="testNewMethod"
:disabled="newTesting">
{{ newTesting ? '测试中...' : '开始测试' }}
</button>
</view>
<view class="test-result" v-if="newResult">
<text class="result-text">耗时: {{ newResult.time }}ms</text>
<text class="result-text">请求次数: {{ newResult.requests }}</text>
<text class="result-text">获取数据: {{ newResult.count }}个字典</text>
<text class="improvement">
性能提升: {{ newResult.improvement }}
</text>
</view>
</view>
</view>
<!-- 缓存演示 -->
<view class="cache-section">
<view class="section-title">缓存机制演示</view>
<view class="cache-controls">
<button class="cache-btn" @click="testCache">测试缓存效果</button>
<button class="cache-btn clear" @click="clearCache">清除缓存</button>
</view>
<view class="cache-result" v-if="cacheResult">
<text class="cache-text">第一次获取: {{ cacheResult.firstTime }}ms</text>
<text class="cache-text">缓存获取: {{ cacheResult.cacheTime }}ms</text>
<text class="cache-text">性能提升: {{ cacheResult.improvement }}</text>
</view>
</view>
<!-- 字典数据展示 -->
<view class="data-section">
<view class="section-title">字典数据展示</view>
<view class="dict-tabs">
<view
class="tab-item"
:class="{ active: activeTab === key }"
v-for="(data, key) in dictData"
:key="key"
@click="activeTab = key">
{{ getDictDisplayName(key) }}
</view>
</view>
<scroll-view class="dict-content" scroll-y>
<view class="dict-items" v-if="dictData[activeTab]">
<view
class="dict-item"
v-for="(item, index) in dictData[activeTab]"
:key="index">
<text class="item-name">{{ item.name || item.text || '-' }}</text>
<text class="item-value">{{ item.value || '-' }}</text>
</view>
</view>
<view class="empty-state" v-else>
<text>暂无数据</text>
</view>
</scroll-view>
</view>
<!-- 使用说明 -->
<view class="usage-section">
<view class="section-title">使用说明</view>
<view class="usage-content">
<text class="usage-text">1. 使用 dictUtil.getBatchDict() 批量获取字典</text>
<text class="usage-text">2. 支持自动缓存30分钟有效期</text>
<text class="usage-text">3. 支持业务场景批量获取</text>
<text class="usage-text">4. 向后兼容原有 util.getDict() 方法</text>
</view>
</view>
</view>
</template>
<script>
import dictUtil from '@/common/dictUtil.js'
import util from '@/common/util.js'
export default {
data() {
return {
//
oldTesting: false,
newTesting: false,
//
oldResult: null,
newResult: null,
cacheResult: null,
//
dictData: {},
activeTab: '',
// keys
testKeys: [
'SourceChannel',
'source',
'customer_purchasing_power',
'preliminarycustomerintention',
'cognitive_concept',
'kh_status',
'decision_maker',
'distance'
]
}
},
onLoad() {
//
this.loadInitialData()
},
methods: {
//
async loadInitialData() {
try {
const data = await dictUtil.getBatchDict(this.testKeys)
this.dictData = data
this.activeTab = Object.keys(data)[0] || ''
} catch (error) {
console.error('加载初始数据失败:', error)
}
},
//
async testOldMethod() {
this.oldTesting = true
this.oldResult = null
try {
const startTime = Date.now()
let successCount = 0
//
for (const key of this.testKeys) {
try {
await this.getOldDict(key)
successCount++
} catch (error) {
console.warn(`获取字典 ${key} 失败:`, error)
}
}
const endTime = Date.now()
const totalTime = endTime - startTime
this.oldResult = {
time: totalTime,
requests: this.testKeys.length,
count: successCount
}
uni.showToast({
title: `原方式完成,耗时 ${totalTime}ms`,
icon: 'none'
})
} catch (error) {
console.error('测试原方式失败:', error)
uni.showToast({
title: '测试失败',
icon: 'none'
})
} finally {
this.oldTesting = false
}
},
//
async testNewMethod() {
this.newTesting = true
this.newResult = null
try {
const startTime = Date.now()
// 使
const data = await dictUtil.getBatchDict(this.testKeys, false) // 使
const endTime = Date.now()
const totalTime = endTime - startTime
const successCount = Object.keys(data).length
this.newResult = {
time: totalTime,
requests: 1, // 1
count: successCount
}
//
if (this.oldResult) {
const improvement = Math.round(((this.oldResult.time - totalTime) / this.oldResult.time) * 100)
this.newResult.improvement = `${improvement}%`
}
//
this.dictData = data
uni.showToast({
title: `新方式完成,耗时 ${totalTime}ms`,
icon: 'none'
})
} catch (error) {
console.error('测试新方式失败:', error)
uni.showToast({
title: '测试失败',
icon: 'none'
})
} finally {
this.newTesting = false
}
},
//
async getOldDict(key) {
return new Promise((resolve, reject) => {
//
setTimeout(async () => {
try {
//
const result = await dictUtil.getDict(key, false)
resolve(result)
} catch (error) {
reject(error)
}
}, Math.random() * 200 + 100) // 100-300ms
})
},
//
async testCache() {
try {
//
dictUtil.clearCache()
//
const startTime1 = Date.now()
await dictUtil.getBatchDict(this.testKeys.slice(0, 3), true)
const firstTime = Date.now() - startTime1
//
const startTime2 = Date.now()
await dictUtil.getBatchDict(this.testKeys.slice(0, 3), true)
const cacheTime = Date.now() - startTime2
const improvement = Math.round(((firstTime - cacheTime) / firstTime) * 100)
this.cacheResult = {
firstTime,
cacheTime,
improvement: `${improvement}%`
}
uni.showToast({
title: '缓存测试完成',
icon: 'success'
})
} catch (error) {
console.error('缓存测试失败:', error)
uni.showToast({
title: '缓存测试失败',
icon: 'none'
})
}
},
//
clearCache() {
dictUtil.clearCache()
this.cacheResult = null
uni.showToast({
title: '缓存已清除',
icon: 'success'
})
},
//
getDictDisplayName(key) {
const nameMap = {
'SourceChannel': '来源渠道',
'source': '来源',
'customer_purchasing_power': '购买力',
'preliminarycustomerintention': '意向度',
'cognitive_concept': '认知理念',
'kh_status': '客户状态',
'decision_maker': '决策人',
'distance': '距离'
}
return nameMap[key] || key
}
}
}
</script>
<style lang="scss" scoped>
.container {
padding: 20rpx;
background: #f5f5f5;
min-height: 100vh;
}
.header {
background: #fff;
padding: 30rpx;
border-radius: 12rpx;
margin-bottom: 20rpx;
text-align: center;
.title {
font-size: 36rpx;
font-weight: 600;
color: #333;
display: block;
margin-bottom: 10rpx;
}
.subtitle {
font-size: 26rpx;
color: #666;
display: block;
}
}
.performance-section, .cache-section, .data-section, .usage-section {
background: #fff;
border-radius: 12rpx;
padding: 30rpx;
margin-bottom: 20rpx;
}
.section-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
margin-bottom: 20rpx;
border-left: 4rpx solid #29d3b4;
padding-left: 16rpx;
}
.test-item {
margin-bottom: 30rpx;
padding: 20rpx;
background: #f8f9fa;
border-radius: 8rpx;
&:last-child {
margin-bottom: 0;
}
}
.test-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15rpx;
.test-name {
font-size: 28rpx;
font-weight: 500;
color: #333;
}
.test-btn {
padding: 12rpx 24rpx;
border-radius: 6rpx;
border: none;
font-size: 24rpx;
color: #fff;
&.old-style {
background: #ff6b6b;
}
&.new-style {
background: #29d3b4;
}
&:disabled {
opacity: 0.6;
}
}
}
.test-result {
display: flex;
flex-direction: column;
gap: 8rpx;
.result-text {
font-size: 24rpx;
color: #666;
}
.improvement {
font-size: 26rpx;
color: #29d3b4;
font-weight: 600;
}
}
.cache-controls {
display: flex;
gap: 20rpx;
margin-bottom: 20rpx;
.cache-btn {
flex: 1;
padding: 16rpx;
border-radius: 8rpx;
border: none;
font-size: 26rpx;
color: #fff;
background: #29d3b4;
&.clear {
background: #ff6b6b;
}
}
}
.cache-result {
display: flex;
flex-direction: column;
gap: 8rpx;
.cache-text {
font-size: 24rpx;
color: #666;
}
}
.dict-tabs {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
margin-bottom: 20rpx;
.tab-item {
padding: 12rpx 20rpx;
background: #f0f0f0;
border-radius: 20rpx;
font-size: 24rpx;
color: #666;
&.active {
background: #29d3b4;
color: #fff;
}
}
}
.dict-content {
height: 400rpx;
border: 1rpx solid #eee;
border-radius: 8rpx;
}
.dict-items {
padding: 20rpx;
}
.dict-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16rpx 0;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
.item-name {
font-size: 26rpx;
color: #333;
flex: 1;
}
.item-value {
font-size: 24rpx;
color: #666;
margin-left: 20rpx;
}
}
.empty-state {
padding: 60rpx;
text-align: center;
color: #999;
font-size: 26rpx;
}
.usage-content {
display: flex;
flex-direction: column;
gap: 12rpx;
.usage-text {
font-size: 26rpx;
color: #666;
line-height: 1.6;
}
}
</style>

218
uniapp/pages/market/clue/add_clues.vue

@ -428,7 +428,15 @@
<!-- 年月日-选择时间 -->
<fui-date-picker :show="date_picker_show" type="3" @change="change_date" @cancel="cancel_date"></fui-date-picker>
<fui-date-picker
:show="date_picker_show"
type="3"
:startYear="2020"
:endYear="2030"
:value="getCurrentDate()"
@change="change_date"
@cancel="cancel_date">
</fui-date-picker>
<!-- 选择器 -->
<fui-picker
@ -528,6 +536,8 @@ import commonApi from '@/api/common.js';
import marketApi from '@/api/market.js';
import memberApi from '@/api/member.js';
import util from '@/common/util.js';
import dictUtil from '@/common/dictUtil.js';
import dictUtilSimple from '@/common/dictUtilSimple.js';
const rules = [
@ -731,26 +741,168 @@ export default {
],
}
},
onLoad() {
//
this.preloadDictData()
},
onShow() {
this.init()
},
methods: {
//
async preloadDictData() {
const dictKeys = [
'SourceChannel', 'source', 'customer_purchasing_power',
'preliminarycustomerintention', 'cognitive_concept',
'kh_status', 'decision_maker', 'distance'
]
//
// 使
dictUtilSimple.getBatchDict(dictKeys).catch(error => {
console.warn('字典预加载失败:', error)
})
},
//
async init() {
//
await this.getUserInfo()
await this.getDict('source_channel')//-
await this.getDict('source')//-
await this.getDict('purchasing_power')//-
await this.getDict('initial_intent')//-
await this.getDict('cognitive_idea')//-
await this.getDict('status')//-
await this.getDict('decision_maker')//-
await this.getDict('distance')//-
// 使
await this.getBatchDictData()
//
this.setDefaultTimes()
// this.getStaffList()//
// this.getAreaTree()//
},
//
setDefaultTimes() {
//
this.formData.optional_class_time = ''
// 访
this.formData.promised_visit_time = ''
console.log('时间默认值已清空,用户需手动选择')
},
//
formatDate(date) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
// FirstUI
//
// :
const formats = {
standard: `${year}-${month}-${day}`, // YYYY-MM-DD
chinese: `${year}${month}${day}`, // YYYYMMDD
slash: `${year}/${month}/${day}`, // YYYY/MM/DD
dot: `${year}.${month}.${day}`, // YYYY.MM.DD
space: `${year} ${month} ${day}` // YYYY MM DD
}
// 使
return formats.standard
// :
// return formats.chinese //
// return formats.slash //
},
//
getCurrentDate() {
const today = new Date()
return this.formatDate(today)
},
//
async getBatchDictData() {
try {
// keys
const dictKeys = [
'SourceChannel', //
'source', //
'customer_purchasing_power', //
'preliminarycustomerintention', //
'cognitive_concept', //
'kh_status', //
'decision_maker', //
'distance' //
]
//
// 使
const dictData = await dictUtilSimple.getBatchDict(dictKeys)
//
this.processDictData(dictData)
console.log('批量获取字典数据成功:', dictData)
} catch (error) {
console.error('批量获取字典数据失败:', error)
// 退
await this.fallbackGetDict()
}
},
//
processDictData(dictData) {
// key
const keyMapping = {
'SourceChannel': 'source_channel',
'source': 'source',
'customer_purchasing_power': 'purchasing_power',
'preliminarycustomerintention': 'initial_intent',
'cognitive_concept': 'cognitive_idea',
'kh_status': 'status',
'decision_maker': 'decision_maker',
'distance': 'distance'
}
//
Object.keys(keyMapping).forEach(dictKey => {
const localKey = keyMapping[dictKey]
const dictItems = dictData[dictKey] || []
if (Array.isArray(dictItems) && dictItems.length > 0) {
const formattedOptions = dictItems.map(item => ({
text: item.name || '',
value: item.value || ''
}))
// picker
if (this.picker_config[localKey]) {
this.picker_config[localKey].options = formattedOptions
}
}
})
},
// 退
async fallbackGetDict() {
console.log('使用回退方案获取字典数据')
try {
await this.getDict('source_channel')
await this.getDict('source')
await this.getDict('purchasing_power')
await this.getDict('initial_intent')
await this.getDict('cognitive_idea')
await this.getDict('status')
await this.getDict('decision_maker')
await this.getDict('distance')
} catch (error) {
console.error('回退方案也失败了:', error)
}
},
async get_campus_list(){
let res = await apiRoute.common_getCampusesList({})
@ -1169,22 +1321,56 @@ export default {
//######----------######
//
//
openDate(input_name) {
this.date_picker_show = true
console.log('打开日期选择器:', input_name)
this.data_picker_input_name = input_name
//
this.$nextTick(() => {
this.date_picker_show = true
})
},
//
change_date(e) {
//
let val = (e.result ?? '')
if(val){
val = val
console.log('日期选择器返回数据:', e)
//
let val = ''
if (e.result) {
val = e.result
} else if (e.value) {
val = e.value
} else if (e.detail && e.detail.result) {
val = e.detail.result
} else if (e.detail && e.detail.value) {
val = e.detail.value
}
// YYYY-MM-DD
if (val && typeof val === 'string') {
//
if (/^\d+$/.test(val)) {
const date = new Date(parseInt(val))
val = this.formatDate(date)
}
//
else if (val.includes(' ')) {
val = val.split(' ')[0]
}
// YYYY-MM-DD
if (val.includes('/')) {
const parts = val.split('/')
if (parts.length === 3) {
val = `${parts[0]}-${parts[1].padStart(2, '0')}-${parts[2].padStart(2, '0')}`
}
}
}
let input_name = this.data_picker_input_name
this.formData[input_name] = val
console.log(`设置${input_name}为:`, val)
this.cancel_date()
},
//

1099
uniapp/pages/market/clue/class_arrangement_detail.vue

File diff suppressed because it is too large

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

@ -49,7 +49,8 @@
<view :class="{'selected-text': switch_tags_type === 1, 'text': switch_tags_type !== 1}"
@click="switch_tags(1)">基本资料
</view>
<view :class="{'selected-text': switch_tags_type === 2, 'text': switch_tags_type !== 2}"
<view v-if="hasCourseInfo"
:class="{'selected-text': switch_tags_type === 2, 'text': switch_tags_type !== 2}"
@click="switch_tags(2)">课程信息
</view>
<view :class="{'selected-text': switch_tags_type === 3, 'text': switch_tags_type !== 3}"
@ -346,6 +347,12 @@
selectedAssistants: [], // ID
}
},
computed: {
//
hasCourseInfo() {
return this.courseInfo && Array.isArray(this.courseInfo) && this.courseInfo.length > 0;
}
},
onLoad(options) {
console.log('onLoad - 接收到参数:', options);
@ -375,33 +382,6 @@
async init(){
console.log('init - 开始初始化流程');
//
try {
console.log('init - 开始预加载字典数据');
const dictPromises = [
this.$util.getDict('SourceChannel'), //
this.$util.getDict('source'), //
this.$util.getDict('preliminarycustomerintention'), //
this.$util.getDict('kh_status'), //
];
// 使Promise.all
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('字典加载超时')), 5000)
);
await Promise.race([
Promise.all(dictPromises),
timeoutPromise
]).catch(err => {
console.warn('字典加载异常或超时,继续执行流程:', err);
});
console.log('init - 字典数据预加载完成或已超时');
} catch (error) {
console.warn('init - 字典数据预加载失败,继续执行流程:', error);
}
//
try {
//
@ -409,14 +389,15 @@
await this.getInfo();
console.log('init - 客户详情获取完成');
//
console.log('init - 开始获取员工信息、通话记录和教练列表');
//
console.log('init - 开始获取员工信息、通话记录、教练列表和课程信息');
await Promise.all([
this.getUserInfo(),
this.getListCallUp(),
this.getPersonnelList()
this.getPersonnelList(),
this.getCourseInfo() //
]);
console.log('init - 员工信息、通话记录和教练列表获取完成');
console.log('init - 员工信息、通话记录、教练列表和课程信息获取完成');
} catch (error) {
console.error('init - 数据加载出错:', error);
}
@ -502,25 +483,27 @@
async getListCallUp(){
console.log('getListCallUp - 开始获取通话记录');
try {
// resource_sharing_id
if (!this.resource_sharing_id) {
console.error('getListCallUp - resource_sharing_id为空,无法获取通话记录');
// clientInfo.resource_id
if (!this.clientInfo || !this.clientInfo.resource_id) {
console.error('getListCallUp - resource_id为空,无法获取通话记录');
return false;
}
let data = {
sales_id:this.resource_sharing_id//
resource_id: this.clientInfo.resource_id // 使ID
}
console.log('getListCallUp - 请求参数:', data);
let res = await apiRoute.listCallUp(data)
console.log('getListCallUp - 响应:', res);
if(res.code != 1){
uni.showToast({
title: res.msg,
title: res.msg || '获取通话记录失败',
icon: 'none'
})
return false;
}
this.listCallUp = res.data
console.log('getListCallUp - 通话记录获取成功');
this.listCallUp = res.data || []
console.log('getListCallUp - 通话记录获取成功, 数量:', this.listCallUp.length);
return true;
} catch (error) {
console.error('getListCallUp - 获取通话记录失败:', error);
@ -738,11 +721,27 @@
//
async switch_tags(type){
//
if (type === 2 && !this.hasCourseInfo) {
uni.showToast({
title: '暂无课程信息',
icon: 'none'
});
return;
}
this.switch_tags_type = type
//
//
if (type === 2) {
await this.getCourseInfo();
}
//
if (type === 3) {
await this.getListCallUp();
console.log('刷新通话记录数据,当前记录数:', this.listCallUp.length);
}
},
getSelect(type){
this.select_type = type
@ -753,37 +752,40 @@
try {
if (!this.clientInfo.resource_id) {
console.error('getCourseInfo - resource_id为空,无法获取课程信息');
this.courseInfo = [];
return false;
}
// 使
// 使
const params = {
resource_id: this.clientInfo.resource_id,
member_id: this.clientInfo.customerResource.member_id || ''
};
// API
console.log('getCourseInfo - 请求参数:', params);
try {
const res = await apiRoute.getStudentCourseInfo(params);
if (res.code === 1 && res.data) {
this.courseInfo = res.data;
console.log('getCourseInfo - API响应:', res);
if (res.code === 1) {
//
this.courseInfo = this.formatCourseData(res.data || []);
console.log('getCourseInfo - 课程信息获取成功:', this.courseInfo);
return true;
} else {
console.warn('API返回错误:', res.msg);
throw new Error(res.msg);
this.courseInfo = [];
return false;
}
} catch (apiError) {
console.warn('使用API获取课程信息失败,使用模拟数据:', apiError);
// API使
this.courseInfo = this.getMockCourseData();
console.log('getCourseInfo - 使用模拟课程数据');
return true;
console.warn('获取课程信息API调用失败:', apiError);
this.courseInfo = [];
return false;
}
} catch (error) {
console.error('getCourseInfo - 获取课程信息异常:', error);
//
this.courseInfo = this.getMockCourseData();
this.courseInfo = [];
return false;
}
},
@ -811,41 +813,6 @@
}));
},
//
getMockCourseData() {
return [
{
id: 1,
course_name: '篮球基础课程',
total_count: 20,
used_count: 8,
leave_count: 2,
expiry_date: '2024-12-31',
status: 'active',
main_coach_id: 1,
main_coach_name: '张教练',
education_id: 2,
education_name: '李教务',
assistant_ids: '3,4',
assistant_names: '王助教, 赵助教'
},
{
id: 2,
course_name: '足球进阶训练',
total_count: 15,
used_count: 15,
leave_count: 1,
expiry_date: '2024-10-31',
status: 'completed',
main_coach_id: 5,
main_coach_name: '陈教练',
education_id: 2,
education_name: '李教务',
assistant_ids: '6',
assistant_names: '孙助教'
}
];
},
//
async getPersonnelList() {
@ -1058,17 +1025,33 @@
},
// 访
// 访
safeGet(obj, path, defaultValue = '') {
if (!obj) return defaultValue;
const keys = path.split('.');
// 使
if (!this._pathCache) this._pathCache = {};
// 使
const cacheKey = path;
//
if (!this._pathCache[cacheKey]) {
this._pathCache[cacheKey] = path.split('.');
}
const keys = this._pathCache[cacheKey];
let result = obj;
for (const key of keys) {
// 使forfor...of
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (result === null || result === undefined || !result.hasOwnProperty(key)) {
return defaultValue;
}
result = result[key];
}
return result || defaultValue;
},
}

190
uniapp/pages/market/clue/edit_clues.vue

@ -140,14 +140,22 @@
</view>
</view>
</fui-form-item>
<!-- 上课时间 -->
<fui-form-item label="上课时间" labelSize='26' prop="optional_class_time" background='#434544' labelColor='#fff' :bottomBorder='false'>
<!-- 可选上课时间 -->
<fui-form-item label="可选上课时间" labelSize='26' prop="optional_class_time" background='#434544' labelColor='#fff' :bottomBorder='false'>
<view class="input-title" style="margin-right:14rpx;">
<view class="input-title" style="margin-right:14rpx;" @click="openDate('optional_class_time')">
{{ formData.optional_class_time ? formData.optional_class_time : '点击选择' }}
</view>
</view>
</fui-form-item>
<!-- 承诺到访时间 -->
<fui-form-item label="承诺到访时间" labelSize='26' prop="promised_visit_time" background='#434544' labelColor='#fff' :bottomBorder='false'>
<view class="input-title" style="margin-right:14rpx;">
<view class="input-title" style="margin-right:14rpx;" @click="openDate('promised_visit_time')">
{{ formData.promised_visit_time ? formData.promised_visit_time : '点击选择' }}
</view>
</view>
</fui-form-item>
<!-- 距离 -->
<fui-form-item label="距离" labelSize='26' prop="distance" background='#434544' labelColor='#fff' :bottomBorder='false'>
<view class="input-title" style="margin-right:14rpx;">
@ -577,20 +585,8 @@
console.log('init - 开始加载字典数据');
//
const dictPromises = [
this.getDict('source_channel'), //-
this.getDict('source'), //-
this.getDict('purchasing_power'), //-
this.getDict('initial_intent'), //-
this.getDict('cognitive_idea'), //-
this.getDict('status'), //-
this.getDict('decision_maker'), //-
this.getDict('distance'), //-
];
await Promise.all(dictPromises);
//
await this.getBatchDictData();
console.log('init - 字典数据加载完成');
//
@ -614,6 +610,139 @@
}
},
//
async getBatchDictData() {
try {
// keys
const dictMapping = {
'SourceChannel': 'source_channel',
'source': 'source',
'customer_purchasing_power': 'purchasing_power',
'preliminarycustomerintention': 'initial_intent',
'cognitive_concept': 'cognitive_idea',
'kh_status': 'status',
'decision_maker': 'decision_maker',
'distance': 'distance'
};
//
if (!window._dictCache) {
window._dictCache = {};
}
//
const criticalDicts = ['source', 'source_channel'];
const regularDicts = Object.keys(dictMapping).filter(key => !criticalDicts.includes(dictMapping[key]));
//
for (const dictKey of criticalDicts) {
const key = Object.keys(dictMapping).find(k => dictMapping[k] === dictKey);
if (key) {
await this.loadDictData(key, dictMapping[key]);
}
}
//
setTimeout(() => {
regularDicts.forEach(async (key) => {
const localKey = dictMapping[key];
if (!window._dictCache[key]) {
await this.loadDictData(key, localKey);
}
});
}, 100);
console.log('优化的批量字典数据加载初始化完成');
} catch (error) {
console.error('批量获取字典数据失败:', error);
// 退
await this.fallbackGetDict();
}
},
//
async loadDictData(key, localKey) {
try {
//
if (window._dictCache[key]) {
// 使
const dictData = window._dictCache[key];
this.processDictData(localKey, dictData);
return dictData;
}
//
const dictData = await this.$util.getDict(key);
//
if (Array.isArray(dictData) && dictData.length > 0) {
window._dictCache[key] = dictData;
this.processDictData(localKey, dictData);
}
return dictData;
} catch (error) {
console.error(`加载字典 ${key} 失败:`, error);
return [];
}
},
//
processDictData(localKey, dictData) {
if (!Array.isArray(dictData) || dictData.length === 0) return;
let formattedOptions = dictData.map(item => ({
text: item.name || '',
value: item.value || ''
}));
// 线
if (localKey === 'source_channel') {
formattedOptions.unshift({
text: '线下',
value: '0'
});
}
// picker_config
if (!this.picker_config[localKey]) {
this.picker_config[localKey] = { options: [], text: '点击选择' };
}
this.picker_config[localKey].options = formattedOptions;
},
// 退
async fallbackGetDict() {
console.log('使用回退方案获取字典数据');
try {
//
await Promise.all([
this.getDict('source_channel'),
this.getDict('source')
]);
//
setTimeout(async () => {
try {
await Promise.all([
this.getDict('purchasing_power'),
this.getDict('initial_intent'),
this.getDict('cognitive_idea'),
this.getDict('status'),
this.getDict('decision_maker'),
this.getDict('distance')
]);
} catch (error) {
console.error('回退方案第二阶段失败:', error);
}
}, 100);
} catch (error) {
console.error('回退方案也失败了:', error);
}
},
async get_campus_list(){
let res = await apiRoute.common_getCampusesList({})
@ -693,7 +822,7 @@
customer_type: customerResource.customer_type || '', //
//
purchasing_power: sixSpeed.purchase_power || '', //
purchasing_power: sixSpeed.purchasing_power || '', //
cognitive_idea: sixSpeed.concept_awareness || '', //
communication: sixSpeed.communication || '', //
staff_id: sixSpeed.staff_id || '', //ID
@ -763,7 +892,7 @@
this.setPickerTextByValue('customer_type', this.formData.customer_type, customerResource.customer_type_name);
//
this.setPickerTextByValue('purchasing_power', this.formData.purchasing_power, sixSpeed.purchase_power_name);
this.setPickerTextByValue('purchasing_power', this.formData.purchasing_power, sixSpeed.purchasing_power_name);
this.setPickerTextByValue('cognitive_idea', this.formData.cognitive_idea, sixSpeed.concept_awareness_name);
this.setPickerTextByValue('distance', this.formData.distance, sixSpeed.distance_name);
// call_intent
@ -776,12 +905,26 @@
//
setPickerTextByValue(pickerName, value, defaultText) {
// 使
if (!value) {
this.picker_config[pickerName] = this.picker_config[pickerName] || {};
this.picker_config[pickerName].text = '点击选择';
return;
}
//
if (!this._valueTextMapping) {
this._valueTextMapping = {};
}
//
const cacheKey = `${pickerName}_${value}`;
if (this._valueTextMapping[cacheKey]) {
this.picker_config[pickerName] = this.picker_config[pickerName] || {};
this.picker_config[pickerName].text = this._valueTextMapping[cacheKey];
return;
}
// picker_config[pickerName]
if (!this.picker_config[pickerName]) {
this.picker_config[pickerName] = { options: [] };
@ -791,14 +934,19 @@
const options = this.picker_config[pickerName].options || [];
const option = options.find(opt => String(opt.value) === String(value));
let textValue;
if (option) {
this.picker_config[pickerName].text = option.text;
textValue = option.text;
} else if (defaultText) {
// 使
this.picker_config[pickerName].text = defaultText;
textValue = defaultText;
} else {
this.picker_config[pickerName].text = '点击选择';
textValue = '点击选择';
}
//
this._valueTextMapping[cacheKey] = textValue;
this.picker_config[pickerName].text = textValue;
},
//

263
uniapp/pages/test/dict_test.vue

@ -0,0 +1,263 @@
<template>
<view class="container">
<view class="header">
<text class="title">字典功能测试</text>
</view>
<view class="test-section">
<view class="section-title">1. 测试单个字典获取</view>
<button class="test-btn" @click="testSingleDict">测试获取单个字典</button>
<view class="result" v-if="singleResult">
<text class="result-title">结果:</text>
<text class="result-content">{{ JSON.stringify(singleResult, null, 2) }}</text>
</view>
</view>
<view class="test-section">
<view class="section-title">2. 测试批量字典获取</view>
<button class="test-btn" @click="testBatchDict">测试批量获取字典</button>
<view class="result" v-if="batchResult">
<text class="result-title">结果:</text>
<text class="result-content">{{ JSON.stringify(batchResult, null, 2) }}</text>
</view>
</view>
<view class="test-section">
<view class="section-title">3. 测试字典缓存</view>
<button class="test-btn" @click="testCache">测试缓存机制</button>
<view class="result" v-if="cacheResult">
<text class="result-title">缓存测试结果:</text>
<text class="result-content">{{ cacheResult }}</text>
</view>
</view>
<view class="test-section">
<view class="section-title">4. 测试静默请求</view>
<button class="test-btn" @click="testQuietRequest">测试静默请求</button>
<view class="result" v-if="quietResult">
<text class="result-title">静默请求结果:</text>
<text class="result-content">{{ JSON.stringify(quietResult, null, 2) }}</text>
</view>
</view>
<view class="test-section">
<view class="section-title">5. 清除缓存</view>
<button class="test-btn clear" @click="clearAllCache">清除所有缓存</button>
</view>
</view>
</template>
<script>
import dictUtil from '@/common/dictUtil.js'
import axiosQuiet from '@/common/axiosQuiet.js'
export default {
data() {
return {
singleResult: null,
batchResult: null,
cacheResult: null,
quietResult: null
}
},
methods: {
//
async testSingleDict() {
try {
console.log('开始测试单个字典获取')
const result = await dictUtil.getDict('source')
this.singleResult = result
console.log('单个字典获取结果:', result)
uni.showToast({
title: '单个字典测试完成',
icon: 'success'
})
} catch (error) {
console.error('单个字典获取失败:', error)
this.singleResult = { error: error.message || '获取失败' }
uni.showToast({
title: '单个字典测试失败',
icon: 'none'
})
}
},
//
async testBatchDict() {
try {
console.log('开始测试批量字典获取')
const keys = ['source', 'SourceChannel', 'customer_purchasing_power']
const result = await dictUtil.getBatchDict(keys)
this.batchResult = result
console.log('批量字典获取结果:', result)
uni.showToast({
title: '批量字典测试完成',
icon: 'success'
})
} catch (error) {
console.error('批量字典获取失败:', error)
this.batchResult = { error: error.message || '获取失败' }
uni.showToast({
title: '批量字典测试失败',
icon: 'none'
})
}
},
//
async testCache() {
try {
console.log('开始测试缓存机制')
//
dictUtil.clearCache(['source'])
//
const start1 = Date.now()
await dictUtil.getDict('source', true)
const time1 = Date.now() - start1
//
const start2 = Date.now()
await dictUtil.getDict('source', true)
const time2 = Date.now() - start2
this.cacheResult = `第一次获取: ${time1}ms, 第二次获取: ${time2}ms, 缓存提升: ${Math.round((time1 - time2) / time1 * 100)}%`
uni.showToast({
title: '缓存测试完成',
icon: 'success'
})
} catch (error) {
console.error('缓存测试失败:', error)
this.cacheResult = '缓存测试失败: ' + (error.message || '未知错误')
uni.showToast({
title: '缓存测试失败',
icon: 'none'
})
}
},
//
async testQuietRequest() {
try {
console.log('开始测试静默请求')
const result = await axiosQuiet.get('/dict/batch', {
keys: 'source,SourceChannel'
})
this.quietResult = result
console.log('静默请求结果:', result)
uni.showToast({
title: '静默请求测试完成',
icon: 'success'
})
} catch (error) {
console.error('静默请求失败:', error)
this.quietResult = { error: error.message || error.msg || '请求失败' }
uni.showToast({
title: '静默请求测试失败',
icon: 'none'
})
}
},
//
clearAllCache() {
dictUtil.clearCache()
this.singleResult = null
this.batchResult = null
this.cacheResult = null
this.quietResult = null
uni.showToast({
title: '缓存已清除',
icon: 'success'
})
}
}
}
</script>
<style lang="scss" scoped>
.container {
padding: 20rpx;
background: #f5f5f5;
min-height: 100vh;
}
.header {
background: #fff;
padding: 30rpx;
border-radius: 12rpx;
margin-bottom: 20rpx;
text-align: center;
.title {
font-size: 36rpx;
font-weight: 600;
color: #333;
}
}
.test-section {
background: #fff;
border-radius: 12rpx;
padding: 30rpx;
margin-bottom: 20rpx;
}
.section-title {
font-size: 28rpx;
font-weight: 600;
color: #333;
margin-bottom: 20rpx;
}
.test-btn {
background: #29d3b4;
color: #fff;
border: none;
border-radius: 8rpx;
padding: 16rpx 32rpx;
font-size: 26rpx;
margin-bottom: 20rpx;
&.clear {
background: #ff6b6b;
}
&:active {
opacity: 0.8;
}
}
.result {
background: #f8f9fa;
border-radius: 8rpx;
padding: 20rpx;
border-left: 4rpx solid #29d3b4;
.result-title {
font-size: 24rpx;
color: #666;
display: block;
margin-bottom: 10rpx;
}
.result-content {
font-size: 22rpx;
color: #333;
word-break: break-all;
white-space: pre-wrap;
font-family: monospace;
line-height: 1.5;
}
}
</style>
Loading…
Cancel
Save