钉钉小程序开发实战:投诉管理系统

钉钉小程序开发实战:投诉管理系统

前言

随着移动互联网技术的快速发展,企业内部管理系统的移动化已成为必然趋势。钉钉作为国内主流的企业级沟通与协作平台,提供了丰富的小程序开发能力,使得企业可以快速构建轻量级的移动应用。本文将以一个实际的医院投诉管理小程序为例,详细讲解钉钉小程序的开发流程、核心功能实现以及关键技术点。


一、项目概述与架构设计

1.1 项目背景与功能简介

本项目是一个基于钉钉平台的投诉管理小程序,主要面向医院内部员工和患者家属,提供以下核心功能:

  • 投诉提交:用户可以在线提交投诉表单,包括投诉科室、涉事人员、事件经过等信息
  • 投诉查询:用户可以查看自己提交的所有投诉记录及其处理状态
  • 详情查看:用户可以查看投诉的详细处理过程和结果反馈
  • 身份认证:通过钉钉OAuth2实现用户身份的自动识别和认证

1.2 技术栈选型

本项目采用以下技术方案:

技术选型 说明
框架 钉钉小程序(E企融合版)
UI组件库 mini-ali-ui(阿里官方小程序UI组件库)
样式语言 ACSS(增强版CSS)
模板语言 AXML(钉钉小程序专属模板)
逻辑脚本 JavaScript
网络请求 dd.httpRequest
本地存储 dd.setStorage / dd.getStorageSync

1.3 项目目录结构

复制代码
TSGL/
├── app.js              # 应用入口文件
├── app.json            # 应用全局配置
├── app.acss            # 应用全局样式
├── package.json        # 项目依赖配置
├── components/         # 自定义组件目录
│   └── water/          # 水印组件
│       ├── water.js    # 组件逻辑
│       ├── water.axml  # 组件模板
│       ├── water.acss  # 组件样式
│       └── water.json  # 组件配置
├── pages/              # 页面目录
│   ├── index/          # 首页(投诉表单)
│   │   ├── index.js
│   │   ├── index.axml
│   │   ├── index.acss
│   │   └── index.json
│   ├── my/             # 我的页面(投诉列表)
│   │   ├── my.js
│   │   ├── my.axml
│   │   ├── my.acss
│   │   └── my.json
│   ├── OK/             # 提交成功页
│   │   └── OK.js
│   └── error/          # 提交失败页
│       └── error.js
└── image/              # 静态资源目录

1.4 整体业务架构流程

复制代码
┌─────────────────────────────────────────────────────────────────┐
│                         用户访问小程序                          │
└─────────────────────────────────────────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────┐
│                    onLoad 页面加载                              │
│  ┌─────────────────────────────────────────────────────────┐    │
│  │ 1. 获取access_token(OAuth2认证)                       │    │
│  │ 2. 获取钉钉授权码authCode                               │    │
│  │ 3. 调用getUserinfo获取用户信息                          │    │
│  │ 4. 预加载科室列表和员工列表                             │    │
│  └─────────────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────────────┘
                                │
                ┌───────────────┼───────────────┐
                ▼               ▼               ▼
        ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
        │  首页       │ │  我的       │ │  其他页面   │
        │  投诉表单   │ │  投诉列表   │ │  成功/失败  │
        └─────────────┘ └─────────────┘ └─────────────┘
                │               │
                ▼               ▼
        ┌─────────────────┐ ┌─────────────────┐
        │  表单提交       │ │  分页查询       │
        │AddComplaintFrom │ │getComplaintList │
        └─────────────────┘ └─────────────────┘

二、核心功能模块分析

2.1 首页投诉表单模块

首页是投诉系统的核心入口,包含用户信息展示、投诉表单填写和提交等功能。

2.1.1 表单数据结构设计

表单采用表单组件实现,包含以下字段:

字段名 说明 数据类型 验证要求
deptId 投诉科室ID string 必填
deptName 投诉科室名称 string 必填
empId 涉事人员工号 string 必填
empName 涉事人员姓名 string 必填
occuTime 事件发生时间 string 必填
eventDetail 事件经过 string 必填
appealDetail 投诉人诉求 string 必填
addEmpId 投诉人工号 string 自动填充
addEmpName 投诉人姓名 string 自动填充
mobile 投诉人手机 string 自动填充
2.1.2 科室/人员选择器实现

科室和人员选择是本系统的特色功能之一,采用弹窗+搜索的方式实现。核心实现思路如下:

1. 弹窗显示控制

javascript 复制代码
// 打开科室选择弹窗
onSelectDept() {
  this.setData({ showDeptSelect: true })
}

// 关闭弹窗
onPopDeptClose() {
  this.setData({ showDeptSelect: false })
}

2. 拼音检索实现

系统预先在数据库中存储了每个科室和人员的拼音助记码(zjm字段),前端通过输入的拼音字母进行实时过滤匹配:

javascript 复制代码
onSearchDeptOninput(value) {
  let that = this;
  let zjm = value;
  my.getStorage({
    key: 'depts',
    success: (result) => {
      // 使用filter方法进行拼音助记码匹配
      var filter_depts = result.data.filter((item) => {
        return item.zjm.includes(zjm.toUpperCase())
      });
      that.setData({ depts: filter_depts })
    }
  });
}

3. 多选支持

系统支持选择多个科室和涉事人员,通过数组存储选择的ID和名称:

javascript 复制代码
onDeptSelected(e) {
  let deptNames = this.data.deptNameList;
  let deptIds = this.data.deptIdList;

  // 防止重复选择
  if (deptIds.includes(e.target.dataset.id)) {
    this.setData({ showDeptSelect: false })
    return false;
  }

  deptNames.push(e.target.dataset.name);
  deptIds.push(e.target.dataset.id)

  // 最终以逗号分隔存储
  this.setData({
    deptId: deptIds.join(","),
    deptName: deptNames.join(",")
  })
  this.setData({ showDeptSelect: false })
}
2.1.3 时间选择器集成

时间选择使用钉钉原生datePicker组件,支持设置时间范围:

javascript 复制代码
datePickerYMDHMS() {
  let that = this;
  var date = new Date();
  var year = date.getFullYear();
  var pyear = date.getFullYear() - 11;  // 可选11年前的日期
  var month = date.getMonth() + 1;
  var pmonth = date.getMonth();
  var nmonth = date.getMonth() + 2;    // 可选2个月后的日期
  var day = date.getDate();
  var hour = date.getHours();
  var minute = date.getMinutes();

  // 补零处理
  if (month >= 1 && month <= 9) { month = "0" + month; }
  if (pmonth >= 1 && pmonth <= 9) { pmonth = "0" + pmonth; }
  if (nmonth >= 1 && nmonth <= 9) { nmonth = "0" + nmonth; }
  if (day >= 0 && day <= 9) { day = "0" + day; }

  var currentDate = year + '-' + month + '-' + day + ' ' + hour + ':' + minute;
  var startDate = pyear + '-' + pmonth + '-' + day + ' ' + hour + ':' + minute;
  var endDate = year + '-' + nmonth + '-' + day + ' ' + hour + ':' + minute;

  my.datePicker({
    format: 'yyyy-MM-dd HH:mm',
    currentDate: currentDate,
    startDate: startDate,
    endDate: endDate,
    success: (res) => {
      that.setData({ occuTime: res.date })
    },
  });
}
2.1.4 表单验证与提交

表单提交前进行完整的客户端验证:

javascript 复制代码
onSubmit(e) {
  let that = this;
  let formData = e.detail.value;

  // 科室验证
  if (formData.deptId == '' || formData.deptName == '') {
    my.showToast({ content: '投诉科室不能为空', type: 'fail', duration: 4000 });
    return false;
  }

  // 涉事人员验证
  if (formData.empId == '' || formData.empName == '') {
    my.showToast({ content: '涉事人员不能为空', type: 'fail', duration: 4000 });
    return false;
  }

  // 时间验证
  if (formData.occuTime == '') {
    my.showToast({ content: '事情发生时间不能为空', type: 'fail', duration: 4000 });
    return false;
  }

  // 事件经过验证
  if (formData.eventDetail == '') {
    my.showToast({ content: '事情经过不能为空', type: 'fail', duration: 4000 });
    return false;
  }

  // 诉求验证
  if (formData.appealDetail == '') {
    my.showToast({ content: '诉讼人诉求不能为空', type: 'fail', duration: 4000 });
    return false;
  }

  // 提交到服务器
  dd.httpRequest({
    url: 'https://api.example.com/JdrmyyCloud/Complaint/AddComplaintFrom',
    method: 'POST',
    headers: { authorization: authorization },
    data: formData,
    success: function(res) {
      that.onReset();
      if (res.data.resultCode == '000000') {
        my.reLaunch({ url: '../OK/OK' });  // 成功页面
      } else {
        my.reLaunch({ url: '../error/error' });  // 失败页面
      }
    }
  });
}

2.2 我的投诉列表模块

该模块实现用户投诉记录的查询和展示功能。

2.2.1 分页加载实现

采用触底加载的分页方式,每次加载6条记录:

javascript 复制代码
async QueryComplaintList(page = 1) {
  let that = this;
  let list = this.data.list;

  my.getStorage({
    key: 'userInfo',
    success: (result) => {
      let jobnumber = result.data.Jobnumber;
      dd.httpRequest({
        url: 'https://api.example.com/JdrmyyCloud/Complaint/getComplaintList',
        method: 'POST',
        headers: { authorization: authorization },
        data: {
          "page": page,
          "limit": 6,
          "addEmpId": jobnumber  // 按投诉人ID筛选
        },
        success: function(res) {
          let data = res.data.data;
          listTotal = res.data.count;

          setTimeout(() => {
            if (listTotal == 0) {
              that.setData({ showEmpty: true });
            }
            for (let i = 0; i < data.length; i++) {
              list.push(data[i]);
            }
            that.setData({ list, page, show: false });
          }, 2000);
        }
      })
    }
  });
}
2.2.2 触底加载事件处理

通过scroll-view组件的onScrollToLower事件实现触底检测:

javascript 复制代码
async scrollMytrip() {
  try {
    const { page, list } = this.data;
    if (list.length < listTotal) {  // 判断是否还有更多数据
      this.setData({ show: true });
      const newPage = page + 1;
      this.QueryComplaintList(newPage);
    }
  } catch (e) {
    this.setData({ show: false });
    console.log('scrollMytrip执行异常:', e);
  }
}

对应的AXML结构:

xml 复制代码
<scroll-view scroll-y="{{true}}" onScrollToLower="scrollMytrip" class="schedule-scroll">
  <view a:for="{{list}}" class="schedule-detail" onTap="openDetail">
    <!-- 列表项内容 -->
  </view>
  <!-- 加载动画 -->
  <view class="spinner" style="{{show ? '' : 'display:none'}}">
    <view class="bounce1 bounce"></view>
    <view class="bounce2 bounce"></view>
    <view class="bounce3 bounce"></view>
  </view>
</scroll-view>
2.2.3 投诉详情弹窗

点击列表项可查看投诉详情,包括处理过程和建议:

javascript 复制代码
openDetail(e) {
  let that = this;
  let cpId = e.currentTarget.dataset.id;
  dd.httpRequest({
    url: 'https://api.example.com/JdrmyyCloud/Complaint/getComplaint?cpId=' + cpId,
    method: 'GET',
    headers: { authorization: authorization },
    success: function(res) {
      if (res.data.data[0].status == 0) {
        my.alert({ content: "请耐心等待处理结果" });
      } else {
        that.setData({ popShow: true, cpdetail: res.data.data[0] });
      }
    }
  })
}

2.3 自定义水印组件

为防止投诉信息泄露,系统在每个页面添加了自定义水印组件。

2.3.1 Canvas水印绘制原理

水印组件使用Canvas进行绘制,核心原理如下:

javascript 复制代码
Component({
  props: {
    fillText: '侵权必究'
  },
  didMount() {
    this.ctx = my.createCanvasContext('canvas');
    this.drawWater();
  },
  methods: {
    drawWater() {
      const { fillText } = this.props;
      this.ctx.rotate(18 * Math.PI / 180);  // 文字旋转18度

      // 绘制斜向水印网格
      for (let j = 1; j < 10; j++) {
        this.fill(fillText, 0, 90 * j);
        for (let i = 1; i < 10; i++) {
          this.fill(fillText, 130 * i, 80 * j);
        }
      }

      // 镜像绘制覆盖另一侧区域
      for (let j = 0; j < 10; j++) {
        this.fill(fillText, 0, -80 * j);
        for (let i = 1; i < 10; i++) {
          this.fill(fillText, 130 * i, -80 * j);
        }
      }
      this.ctx.draw();
    },
    fill(text, x, y) {
      this.ctx.beginPath();
      this.ctx.setFontSize(20);
      this.ctx.setFillStyle('rgba(169,169,169,.2)');  // 半透明灰色
      this.ctx.fillText(text, x, y);
    }
  }
});
2.3.2 组件使用方式

在页面中引用水印组件非常简洁:

xml 复制代码
<water-mark fillText="{{waterMarkText}}"></water-mark>

三、关键技术点深度剖析

3.1 钉钉小程序OAuth2认证流程

钉钉小程序采用OAuth2协议进行用户身份认证,流程如下:

复制代码
┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│   小程序     │     │    钉钉      │     │   后台API    │
└──────────────┘     └──────────────┘     └──────────────┘
        │                   │                   │
        │  1. dd.getAuthCode()                  │
        │─────────────────> │                   │
        │  返回authCode     │                   │
        │<───────────────── │                   │
        │                   │                   │
        │  2. 发送authCode到后台                │
        │─────────────────────────────────────> │
        │                    │   3. 验证authCode│
        │                    │<─────────────────│
        │                    │   返回userInfo   │
        │  4. 返回用户信息   │<─────────────────│
        │<───────────────────────────────────── │
3.1.1 access_token获取
javascript 复制代码
dd.httpRequest({
  url: "https://api.example.com/token",
  method: 'POST',
  data: "UserName=YOUR_USERNAME&Password=YOUR_PASSWORD&grant_type=password",
  success: function(res) {
    authorization = 'Bearer ' + res.data.access_token;
    // 存储到本地
    dd.setStorage({
      key: 'access_token',
      data: res.data.access_token,
    });
  }
});
3.1.2 用户身份获取
javascript 复制代码
dd.getAuthCode({
  success: function(res) {
    let authCode = res.authCode;
    dd.httpRequest({
      url: 'https://api.example.com/JdrmyyCloud/Complaint/getUserinfo?authCode=' + authCode,
      method: 'GET',
      headers: { authorization: authorization },
      success: function(res) {
        let resObj = JSON.parse(res.data);
        my.setStorage({ key: 'userInfo', data: resObj });
      }
    });
  }
});

3.2 HTTP请求封装与统一处理

项目中的HTTP请求都遵循统一的格式规范:

javascript 复制代码
dd.httpRequest({
  url: 'API地址',
  method: 'GET/POST',
  headers: {
    authorization: 'Bearer ' + access_token  // Bearer Token认证
  },
  data: { /* 请求参数 */ },
  dataType: 'json',
  success: function(res) {
    // 统一处理响应
    if (res.status == 200) {
      // 业务逻辑处理
    }
  },
  fail: function(err) {
    console.error('请求失败:', err);
  }
});

关键配置说明:

参数 说明
method 请求方法,GET用于查询,POST用于提交
headers 请求头,携带Bearer Token认证信息
dataType 响应数据类型
data 请求数据,POST时为JSON对象

3.3 拼音检索技术方案

本系统采用拼音助记码(zjm字段)实现快速检索功能。这种方案的优势在于:

  1. 查询效率高:在数据库层面已经建立索引,直接使用LIKE匹配
  2. 实现简单:前端只需进行字符串包含判断
  3. 用户体验好:支持模糊匹配,输入即可见结果
javascript 复制代码
// 拼音匹配算法
var filter_depts = result.data.filter((item) => {
  return item.zjm.includes(zjm.toUpperCase())
});

zjm字段示例:

科室名称 拼音助记码
急诊科 JZK
门诊部 MZB
住院部 ZYB

用户输入"jz"即可匹配到"急诊科",输入"mzb"可精确匹配"门诊部"。

3.4 分页加载与性能优化

3.4.1 分页参数设计
参数 说明 示例值
page 当前页码 1
limit 每页记录数 6
addEmpId 筛选条件(投诉人ID) 10001
3.4.2 数据聚合策略
javascript 复制代码
// 分页数据聚合
for (let i = 0; i < data.length; i++) {
  emps.push(data[i]);  // 将新数据追加到现有数组
}

that.setData({
  emps,           // 更新数据源
  page,           // 更新当前页码
  show: false     // 隐藏加载动画
});
3.4.3 加载动画实现

采用三个圆点跳动的动画效果:

css 复制代码
.spinner .bounce {
  width: 13rpx;
  height: 21rpx;
  display: inline-block;
  animation-fill-mode: both;
  transform: skewX(-15deg);
}

.spinner .bounce1 { animation: bouncedelay1 2.1s infinite linear; }
.spinner .bounce2 { animation: bouncedelay2 2.1s infinite linear; }
.spinner .bounce3 { animation: bouncedelay3 2.1s infinite linear; }

@keyframes bouncedelay1 {
  0% { background: #108EE9; }
  50% { background: #9DCDEF; }
  100% { background: #EAECF3; }
}

四、完整代码实现

4.1 app.json 全局配置

json 复制代码
{
  "pages": [
    "pages/index/index",
    "pages/OK/OK",
    "pages/error/error",
    "pages/my/my"
  ],
  "window": {
    "defaultTitle": "投诉管理V2.1"
  },
  "tabBar": {
    "textColor": "#404040",
    "selectedColor": "#108ee9",
    "backgroundColor": "#F5F5F9",
    "items": [
      {
        "pagePath": "pages/index/index",
        "icon": "image/icon_shouye2.png",
        "activeIcon": "image/icon_shouye1.png",
        "name": "首页"
      },
      {
        "pagePath": "pages/my/my",
        "icon": "image/my1.png",
        "activeIcon": "image/my.png",
        "name": "我的"
      }
    ]
  }
}

配置说明:

配置项 说明
pages 小程序包含的所有页面路径
window.defaultTitle 默认页面标题
tabBar 底部导航栏配置,包含首页和我的两个Tab

4.2 投诉表单页面完整实现

index.js
javascript 复制代码
let listTotal = 0;
let authorization = ''

Page({
  data: {
    waterMarkText: '示例医院',
    showDeptSelect: false,
    showEmpSelect: false,
    deptId: '',
    deptName: '',
    empId: '',
    empName: '',
    occuTime: '',
    eventDetail: '',
    appealDetail: '',
    depts: [],
    emps: [],
    searchEmpInput: '',
    userinfo: {
      name: '',
      mobile: '',
      jobnumber: '',
      avatar: '',
      deptname: ''
    },
    deptNameList: [],
    deptIdList: [],
    empNameList: [],
    empIdList: [],
  },

  onLoad(query) {
    let that = this;

    // 第一步:获取access_token
    dd.httpRequest({
      url: "https://api.example.com/token",
      method: 'POST',
      data: "UserName=YOUR_USERNAME&Password=YOUR_PASSWORD&grant_type=password",
      success: function(res) {
        authorization = 'Bearer ' + res.data.access_token;

        // 存储token
        dd.setStorage({
          key: 'access_token',
          data: res.data.access_token,
        });

        // 第二步:获取钉钉授权码
        dd.getAuthCode({
          success: function(res) {
            let authCode = res.authCode;

            // 第三步:根据authCode获取用户信息
            dd.httpRequest({
              url: 'https://api.example.com/JdrmyyCloud/Complaint/getUserinfo?authCode=' + authCode,
              method: 'GET',
              headers: { authorization: authorization },
              success: function(res) {
                let resObj = JSON.parse(res.data);
                my.setStorage({ key: 'userInfo', data: resObj });

                // 获取员工所属科室信息
                dd.httpRequest({
                  url: 'https://api.example.com/JdrmyyCloud/Complaint/getEmpInfo?EmpCode=' + resObj.Jobnumber,
                  method: 'GET',
                  headers: { authorization: authorization },
                  success: function(res) {
                    that.setData({
                      userinfo: {
                        name: resObj.Name,
                        mobile: resObj.Mobile,
                        jobnumber: resObj.Jobnumber,
                        avatar: resObj.Avatar,
                        deptname: '【' + res.data[0].deptName + '】'
                      }
                    });
                  }
                });
              }
            });
          }
        });

        // 第四步:预加载科室列表
        dd.httpRequest({
          url: 'https://api.example.com/JdrmyyCloud/Complaint/getDepts',
          method: 'GET',
          headers: { authorization: authorization },
          success: function(res) {
            my.setStorage({ key: 'depts', data: res.data });
            that.setData({ depts: res.data })
          }
        });

        // 第五步:预加载员工列表
        dd.httpRequest({
          url: 'https://api.example.com/JdrmyyCloud/Complaint/getEmps',
          method: 'GET',
          headers: { authorization: authorization },
          success: function(res) {
            my.setStorage({ key: 'emps', data: res.data });
          }
        });

        // 第六步:分页加载员工列表(用于选择涉事人员)
        that.QueryEmpList();
      }
    });
  },

  // 选择投诉科室
  onSelectDept() {
    this.setData({ showDeptSelect: true })
  },

  // 关闭科室选择弹窗
  onPopDeptClose() {
    this.setData({ showDeptSelect: false })
  },

  // 科室选择确认
  onDeptSelected(e) {
    let deptNames = this.data.deptNameList;
    let deptIds = this.data.deptIdList;

    if (deptIds.includes(e.target.dataset.id)) {
      this.setData({ showDeptSelect: false })
      return false;
    }

    deptNames.push(e.target.dataset.name);
    deptIds.push(e.target.dataset.id)
    this.setData({
      deptId: deptIds.join(","),
      deptName: deptNames.join(",")
    })
    this.setData({ showDeptSelect: false })
  },

  // 清除科室选择
  onSearchDeptCancel() {
    this.setData({ deptId: '', deptName: '', deptIdList: [], deptNameList: [] })
    this.setData({ showDeptSelect: false })
  },

  // 科室搜索输入
  onSearchDeptOninput(value) {
    let that = this;
    let zjm = value;
    my.getStorage({
      key: 'depts',
      success: (result) => {
        var filter_depts = result.data.filter((item) => {
          return item.zjm.includes(zjm.toUpperCase())
        });
        that.setData({ depts: filter_depts })
      }
    });
  },

  // 触底加载更多员工
  async scrollMytrip() {
    try {
      const { page, emps } = this.data;
      if (emps.length < listTotal) {
        this.setData({ show: true });
        const newPage = page + 1;
        this.QueryEmpList(newPage);
      }
    } catch (e) {
      this.setData({ show: false });
    }
  },

  // 分页获取员工列表
  async QueryEmpList(page = 1) {
    let that = this;
    let emps = this.data.emps;
    let access_token = dd.getStorageSync({ key: 'access_token' }).data;
    let authorization = "Bearer " + access_token;

    dd.httpRequest({
      url: 'https://api.example.com/JdrmyyCloud/Complaint/getEmpList',
      method: 'POST',
      headers: { authorization: authorization },
      data: { "page": page, "limit": 7 },
      success: function(res) {
        let data = res.data.data;
        listTotal = res.data.count;
        setTimeout(() => {
          for (let i = 0; i < data.length; i++) {
            emps.push(data[i]);
          }
          that.setData({ emps, page, show: false });
        }, 2000);
      }
    })
  },

  // 选择涉事人员
  onSelectEmp() {
    this.setData({ showEmpSelect: true })
  },

  onPopEmpClose() {
    this.setData({ showEmpSelect: false })
  },

  onEmpSelected(e) {
    let empnames = this.data.empNameList;
    let empIds = this.data.empIdList;
    if (empIds.includes(e.target.dataset.id)) {
      this.setData({ showEmpSelect: false })
      return false;
    }
    empnames.push(e.target.dataset.name);
    empIds.push(e.target.dataset.id)
    this.setData({
      empId: empIds.join(","),
      empName: empnames.join(",")
    })
    this.setData({ showEmpSelect: false })
  },

  onSearchEmpCancel() {
    this.setData({ empId: '', empName: '', empIdList: [], empNameList: [] })
    this.setData({ showEmpSelect: false })
  },

  onSearchEmpOninput(value) {
    let that = this;
    let zjm = value;
    my.getStorage({
      key: 'emps',
      success: (result) => {
        var filter_emps = result.data.filter((item) => {
          return item.zjm.includes(zjm.toUpperCase())
        });
        that.setData({ emps: filter_emps })
      }
    });
  },

  // 日期时间选择
  datePickerYMDHMS() {
    let that = this;
    var date = new Date();
    var year = date.getFullYear();
    var pyear = date.getFullYear() - 11;
    var month = date.getMonth() + 1;
    var pmonth = date.getMonth();
    var nmonth = date.getMonth() + 2;
    var day = date.getDate();
    var hour = date.getHours();
    var minute = date.getMinutes();

    if (month >= 1 && month <= 9) { month = "0" + month; }
    if (pmonth >= 1 && pmonth <= 9) { pmonth = "0" + pmonth; }
    if (nmonth >= 1 && nmonth <= 9) { nmonth = "0" + nmonth; }
    if (day >= 0 && day <= 9) { day = "0" + day; }

    var currentDate = year + '-' + month + '-' + day + ' ' + hour + ':' + minute;
    var startDate = pyear + '-' + pmonth + '-' + day + ' ' + hour + ':' + minute;
    var endDate = year + '-' + nmonth + '-' + day + ' ' + hour + ':' + minute;

    my.datePicker({
      format: 'yyyy-MM-dd HH:mm',
      currentDate: currentDate,
      startDate: startDate,
      endDate: endDate,
      success: (res) => {
        that.setData({ occuTime: res.date })
      },
    });
  },

  // 表单提交
  onSubmit(e) {
    let that = this;
    let formData = e.detail.value;

    // 完整验证逻辑...
    // 验证通过后提交到服务器

    let access_token = dd.getStorageSync({ key: 'access_token' }).data;
    let authorization = "Bearer " + access_token;

    dd.httpRequest({
      url: 'https://api.example.com/JdrmyyCloud/Complaint/AddComplaintFrom',
      method: 'POST',
      headers: { authorization: authorization },
      data: formData,
      success: function(res) {
        that.onReset();
        if (res.data.resultCode == '000000') {
          my.reLaunch({ url: '../OK/OK' });
        } else {
          my.reLaunch({ url: '../error/error' });
        }
      }
    });
  },

  // 表单重置
  onReset() {
    this.setData({
      deptNameList: [],
      deptIdList: [],
      empNameList: [],
      empIdList: [],
      occuTime: '',
      deptId: '',
      deptName: '',
      empId: '',
      empName: ''
    });
  },

  // 分享配置
  onShareAppMessage() {
    return {
      title: '在线投诉',
      desc: '某医院在线投诉钉钉小程序版',
      path: 'pages/index/index',
    };
  },
});
index.axml
xml 复制代码
<water-mark fillText="{{waterMarkText}}"></water-mark>
<view class="page" style="margin:0px;">
  <!-- 用户信息卡片 -->
  <view style="margin-bottom:20px;">
    <am-card thumb="{{userinfo.avatar}}"
             title="{{userinfo.name}} {{userinfo.deptname}}"
             subTitle="{{userinfo.jobnumber}}"
             bgImg="../image/card_bg1.jpg"></am-card>
  </view>

  <view class="page-description">投诉表单</view>
  <form onSubmit="onSubmit" onReset="onReset">
    <!-- 投诉科室 -->
    <view class="page-section">
      <view class="page-section-title">投诉科室</view>
      <view class="page-section-demo">
        <view class="row">
          <view class="row-extra">
            <view hidden="true">
              <input placeholder="科室ID" name="deptId" type="text" value="{{deptId}}"></input>
              <input placeholder="投诉科室" name="deptName" type="text" value="{{deptName}}"></input>
            </view>
            <view class="extra-info">{{deptName}}</view>
            <button size="default" type="primary" onTap="onSelectDept">选择投诉科室</button>
          </view>
        </view>
      </view>
    </view>

    <!-- 涉事人员 -->
    <view class="page-section">
      <view class="page-section-title">涉事人员</view>
      <view class="page-section-demo">
        <view class="row">
          <view class="row-extra">
            <view hidden="true">
              <input placeholder="涉事人员工号" name="empId" type="text" value="{{empId}}"></input>
              <input placeholder="涉事人员科室" name="empName" type="text" value="{{empName}}"></input>
            </view>
            <view class="extra-info">{{empName}}</view>
            <button size="default" type="primary" onTap="onSelectEmp">选择涉事人员</button>
          </view>
        </view>
      </view>
    </view>

    <!-- 事件发生时间 -->
    <view class="page-section">
      <view class="page-section-title">事件发生时间</view>
      <view class="page-section-demo">
        <view class="row">
          <view class="row-extra">
            <view hidden="true">
              <input placeholder="事件发生时间" name="occuTime" type="text" value="{{occuTime}}"></input>
            </view>
            <view class="extra-info">{{occuTime}}</view>
            <button size="default" type="primary" onTap="datePickerYMDHMS">事件发生时间</button>
          </view>
        </view>
      </view>
    </view>

    <!-- 事件经过 -->
    <view class="page-section">
      <view class="page-section-title">事件经过</view>
      <view class="page-section-demo">
        <view class="row">
          <view class="row-extra">
            <textarea name="eventDetail" value="{{eventDetail}}" placeholder="请输入事件经过"></textarea>
          </view>
        </view>
      </view>
    </view>

    <!-- 投诉人诉求 -->
    <view class="page-section">
      <view class="page-section-title">投诉人诉求</view>
      <view class="page-section-demo">
        <view class="row">
          <view class="row-extra">
            <textarea name="appealDetail" value="{{appealDetail}}" placeholder="请输入投诉人诉求"></textarea>
          </view>
        </view>
      </view>
      <!-- 隐藏字段:自动填充用户信息 -->
      <view hidden="true">
        <input placeholder="工号" name="addEmpId" type="text" value="{{userinfo.jobnumber}}"></input>
        <input placeholder="姓名" name="addEmpName" type="text" value="{{userinfo.name}}"></input>
        <input placeholder="手机号码" name="mobile" type="text" value="{{userinfo.mobile}}"></input>
      </view>
      <!-- 提交按钮 -->
      <view class="page-section-btns">
        <view>
          <button type="ghost" size="mini" form-type="reset">重置</button>
        </view>
        <view>
          <button type="primary" size="mini" formType="submit">提交</button>
        </view>
      </view>
    </view>
  </form>
</view>

<!-- 科室选择弹窗 -->
<am-popup show="{{showDeptSelect}}" position="bottom" mask="{{true}}" animation="{{true}}" onClose="onPopDeptClose" disableScroll="{{false}}" zIndex="1000">
  <view style="background: #fff;text-align:center;">
    <am-search-bar placeholder="输入拼音助记码检索科室" showCancelButton="{{true}}" onCancel="onSearchDeptCancel" onInput="onSearchDeptOninput"></am-search-bar>
    <scroll-view scroll-y="{{true}}" style="height:300px;">
      <am-list>
        <am-list-item a:for="{{depts}}" index="{{item.id}}" arrow="{{false}}" onClick="onDeptSelected" data-id="{{item.id}}" data-name="{{item.deptName}}">{{item.deptName}}</am-list-item>
      </am-list>
    </scroll-view>
  </view>
</am-popup>

<!-- 人员选择弹窗 -->
<am-popup show="{{showEmpSelect}}" position="bottom" mask="{{true}}" animation="{{true}}" onClose="onPopEmpClose" disableScroll="{{false}}" zIndex="1000">
  <view style="background: #fff;text-align:center;">
    <am-search-bar placeholder="输入拼音助记码检索人员" showCancelButton="{{true}}" onCancel="onSearchEmpCancel" onInput="onSearchEmpOninput" value="{{searchEmpInput}}"></am-search-bar>
    <scroll-view scroll-y="{{true}}" onScrollToLower="scrollMytrip" style="height:300px;">
      <am-list>
        <am-list-item a:for="{{emps}}" index="{{item.empId}}" arrow="{{true}}" onClick="onEmpSelected" data-id="{{item.empId}}" data-name="{{item.empName}}">{{item.empName}}</am-list-item>
      </am-list>
      <view class="spinner" style="{{show ? '' : 'display:none'}}">
        <view class="bounce1 bounce"></view>
        <view class="bounce2 bounce"></view>
        <view class="bounce3 bounce"></view>
        <view style="margin:20rpx 0 0 20rpx;color:#666666;">加载中...</view>
      </view>
    </scroll-view>
  </view>
</am-popup>

4.3 投诉列表页面完整实现

my.js
javascript 复制代码
let listTotal = 0;
let access_token = dd.getStorageSync({ key: 'access_token' }).data;
let authorization = "Bearer " + access_token;

Page({
  data: {
    waterMarkText: '示例医院',
    show: false,
    page: 1,
    list: [],
    popShow: false,
    cpdetail: {},
    showEmpty: false,
  },

  onLoad() {
    this.QueryComplaintList();
  },

  // 触底加载更多
  async scrollMytrip() {
    try {
      const { page, list } = this.data;
      if (list.length < listTotal) {
        this.setData({ show: true });
        const newPage = page + 1;
        this.QueryComplaintList(newPage);
      }
    } catch (e) {
      this.setData({ show: false });
    }
  },

  // 查看投诉详情
  openDetail(e) {
    let that = this;
    let cpId = e.currentTarget.dataset.id;
    dd.httpRequest({
      url: 'https://api.example.com/JdrmyyCloud/Complaint/getComplaint?cpId=' + cpId,
      method: 'GET',
      headers: { authorization: authorization },
      success: function(res) {
        if (res.data.data[0].status == 0) {
          my.alert({ content: "请耐心等待处理结果" });
        } else {
          that.setData({ popShow: true, cpdetail: res.data.data[0] });
        }
      }
    })
  },

  // 关闭详情弹窗
  onPopClose() {
    this.setData({ popShow: false })
  },

  // 分页查询投诉列表
  async QueryComplaintList(page = 1) {
    let that = this;
    let list = this.data.list;

    my.getStorage({
      key: 'userInfo',
      success: (result) => {
        let jobnumber = result.data.Jobnumber;
        dd.httpRequest({
          url: 'https://api.example.com/JdrmyyCloud/Complaint/getComplaintList',
          method: 'POST',
          headers: { authorization: authorization },
          data: { "page": page, "limit": 6, "addEmpId": jobnumber },
          success: function(res) {
            let data = res.data.data;
            listTotal = res.data.count;

            list = [];
            setTimeout(() => {
              if (listTotal == 0) {
                that.setData({ showEmpty: true });
              }
              for (let i = 0; i < data.length; i++) {
                list.push(data[i]);
              }
              that.setData({ list, page, show: false });
            }, 2000);
          }
        })
      }
    });
  }
});
my.axml
xml 复制代码
<water-mark fillText="{{waterMarkText}}"></water-mark>

<!-- 空状态提示 -->
<am-page-result a:if="{{showEmpty}}" type="empty" title="空空如也" brief="目前没有参与投诉"></am-page-result>

<view a:if="{{showEmpty == false}}">
  <view class="schedule-container">
    <scroll-view scroll-y="{{true}}" onScrollToLower="scrollMytrip" class="schedule-scroll">
      <view a:for="{{list}}" class="schedule-detail" onTap="openDetail" data-id="{{item.cpId}}" data-status="{{item.status}}">
        <view class="schedule-place">编号:{{item.cpId}}</view>
        <view class="schedule-trainNumber padd font">投诉科室:{{item.deptName}}</view>
        <view class="schedule-time padd font">投诉时间:{{item.addTime}}</view>
        <view class="schedule-time padd status">{{item.status}}</view>
      </view>

      <!-- 加载动画 -->
      <view class="spinner" style="{{show ? '' : 'display:none'}}">
        <view class="bounce1 bounce"></view>
        <view class="bounce2 bounce"></view>
        <view class="bounce3 bounce"></view>
        <view style="margin:20rpx 0 0 20rpx;color:#666666;">加载中...</view>
      </view>
    </scroll-view>
  </view>
</view>

<!-- 详情弹窗 -->
<am-popup show="{{popShow}}" position="bottom" onClose="onPopClose">
  <view class="page-section" style="padding:0px;margin:1px;">
    <view class="page-section-title">接管部门处理过程</view>
    <view class="page-section-demo">
      <view class="row">
        <view class="row-extra">
          <textarea name="process" value="{{cpdetail.process}}" disabled="{{true}}" show-count="{{false}}"></textarea>
        </view>
      </view>
    </view>
  </view>
  <view class="page-section" style="padding:0px;margin:1px;">
    <view class="page-section-title">职能科室处理建议</view>
    <view class="page-section-demo">
      <view class="row">
        <view class="row-extra">
          <textarea name="advise" value="{{cpdetail.advise}}" disabled="{{true}}" show-count="{{false}}"></textarea>
        </view>
      </view>
    </view>
  </view>
</am-popup>

4.4 水印组件完整实现

water.js
javascript 复制代码
Component({
  props: {
    fillText: '侵权必究'
  },

  didMount() {
    this.ctx = my.createCanvasContext('canvas');
    this.drawWater();
  },

  didUpdate() {
    this.drawWater();
  },

  methods: {
    drawWater() {
      const { fillText } = this.props;
      this.ctx.rotate(18 * Math.PI / 180);

      // 左下区域水印
      for (let j = 1; j < 10; j++) {
        this.fill(fillText, 0, 90 * j);
        for (let i = 1; i < 10; i++) {
          this.fill(fillText, 130 * i, 80 * j);
        }
      }

      // 右上区域水印
      for (let j = 0; j < 10; j++) {
        this.fill(fillText, 0, -80 * j);
        for (let i = 1; i < 10; i++) {
          this.fill(fillText, 130 * i, -80 * j);
        }
      }
      this.ctx.draw();
    },

    fill(text, x, y) {
      this.ctx.beginPath();
      this.ctx.setFontSize(20);
      this.ctx.setFillStyle('rgba(169,169,169,.2)');
      this.ctx.fillText(text, x, y);
    }
  }
});
water.axml
xml 复制代码
<canvas id="canvas" class="water-canvas"></canvas>
water.acss
css 复制代码
.water-canvas {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  z-index: -1;
}

五、API接口设计

本系统后台提供以下RESTful API接口:

接口名称 请求方式 接口地址 说明
获取Token POST /token OAuth2密码模式认证
获取用户信息 GET /JdrmyyCloud/Complaint/getUserinfo 根据authCode获取
获取员工信息 GET /JdrmyyCloud/Complaint/getEmpInfo 根据工号获取
获取科室列表 GET /JdrmyyCloud/Complaint/getDepts 获取所有科室
获取员工列表 GET /JdrmyyCloud/Complaint/getEmps 获取所有员工
分页获取员工 POST /JdrmyyCloud/Complaint/getEmpList 分页查询员工
提交投诉 POST /JdrmyyCloud/Complaint/AddComplaintFrom 提交投诉表单
获取投诉列表 POST /JdrmyyCloud/Complaint/getComplaintList 分页获取投诉
获取投诉详情 GET /JdrmyyCloud/Complaint/getComplaint 根据ID获取详情

API调用示例

提交投诉表单:

javascript 复制代码
dd.httpRequest({
  url: 'https://api.example.com/JdrmyyCloud/Complaint/AddComplaintFrom',
  method: 'POST',
  headers: {
    'Authorization': 'Bearer ' + access_token,
    'Content-Type': 'application/json'
  },
  data: {
    deptId: '001',
    deptName: '急诊科',
    empId: '******',
    empName: '张三',
    occuTime: '2024-01-15 14:30',
    eventDetail: '患者反映急诊等候时间过长',
    appealDetail: '希望改进急诊流程',
    addEmpId: '******',
    addEmpName: '李四',
    mobile: '138****8000'
  },
  success: (res) => {
    if (res.data.resultCode == '000000') {
      console.log('投诉提交成功');
    }
  }
});

六、开发注意事项与最佳实践

6.1 钉钉小程序与微信小程序的差异

差异点 钉钉小程序 微信小程序
授权API dd.getAuthCode wx.login
弹窗组件 am-popup wx.showModal
请求API dd.httpRequest wx.request
存储API dd.setStorage wx.setStorage
页面跳转 dd.reLaunch wx.reLaunch

6.2 UI组件选型建议

本项目选用的mini-ali-ui组件库具有以下优势:

  • 体积小:按需加载,不影响首屏性能
  • 风格统一:与钉钉原生UI保持一致
  • 文档完善:阿里官方维护,有详细示例

推荐使用的组件:

组件 用途
am-card 用户信息卡片展示
am-popup 底部弹窗选择器
am-search-bar 搜索输入框
am-list 列表展示
am-page-result 空状态提示

6.4 性能优化建议

1. 数据预加载

在页面onLoad时提前加载科室和员工数据,避免用户选择时出现白屏:

javascript 复制代码
onLoad() {
  // 预加载数据到本地缓存
  this.preloadData();
}

2. 分页加载

对于列表数据采用分页加载,避免一次请求过多数据:

javascript 复制代码
data: {
  page: 1,
  limit: 10  // 每页10条
}

3. 延迟显示

在数据量较大时使用setTimeout延迟渲染,避免卡顿:

javascript 复制代码
setTimeout(() => {
  that.setData({ list: newList });
}, 100);

4. 组件按需引入

在page的json配置中只引入需要的组件:

json 复制代码
{
  "usingComponents": {
    "am-card": "mini-ali-ui/es/card/index",
    "am-popup": "mini-ali-ui/es/popup/index"
  }
}

七、总结与扩展

7.1 项目总结

本文详细介绍了基于钉钉小程序的投诉管理系统的完整实现过程,主要涉及以下技术要点:

  1. OAuth2认证流程:通过钉钉授权获取用户身份信息,实现自动登录
  2. 表单设计与验证:实现完整的投诉表单,包括科室/人员选择、日期选择、多字段验证
  3. 拼音检索功能:利用拼音助记码实现高效的搜索体验
  4. 分页加载优化:实现平滑的触底加载效果,提升大数据量展示性能
  5. 自定义组件开发:封装可复用的水印组件,保护敏感信息
  6. UI组件集成:合理使用mini-ali-ui组件库,快速构建原生体验

结语

本文通过对一个实际医院投诉管理小程序的完整分析,展示了钉钉小程序开发的核心技术和最佳实践。希望能为正在学习或准备开发钉钉小程序的开发者提供有价值的参考。如有任何问题或建议,欢迎在评论区交流讨论。

相关推荐
灵机一物5 小时前
灵机一物AI原生电商小程序(已上线)-从“48 小时失联”到“长期可触达”:一套小程序公众号关注引导 + 订阅消息授权的产品化设计
小程序
碎像5 小时前
掌握uniapp发布微信小程序、App(Android)
微信小程序·小程序·uni-app
CHU72903521 小时前
生鲜团购商城小程序:新鲜触手可及的便捷购物新体验
小程序
医疗信息化王工1 天前
钉钉小程序开发实战:手术查询小程序
小程序·钉钉·手术查询
二进喵1 天前
OpenClaw 接入钉钉完整指南
钉钉
Teable任意门互动1 天前
多维表格本地化部署实践解析 企业如何实现数据自主可控路径
数据库·excel·钉钉·飞书·开源软件
软件开发技术1 天前
新版点微同城主题源码34.7+全套插件+小程序前后端 源文件
小程序·php
mon_star°2 天前
消防安全培训小程序项目亮点与功能清单
小程序
编程迪2 天前
基于Java和Vue开发的在线问诊系统医疗咨询小程序APP
小程序