钉钉小程序开发实战:投诉管理系统
前言
随着移动互联网技术的快速发展,企业内部管理系统的移动化已成为必然趋势。钉钉作为国内主流的企业级沟通与协作平台,提供了丰富的小程序开发能力,使得企业可以快速构建轻量级的移动应用。本文将以一个实际的医院投诉管理小程序为例,详细讲解钉钉小程序的开发流程、核心功能实现以及关键技术点。
一、项目概述与架构设计
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字段)实现快速检索功能。这种方案的优势在于:
- 查询效率高:在数据库层面已经建立索引,直接使用LIKE匹配
- 实现简单:前端只需进行字符串包含判断
- 用户体验好:支持模糊匹配,输入即可见结果
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 项目总结
本文详细介绍了基于钉钉小程序的投诉管理系统的完整实现过程,主要涉及以下技术要点:
- OAuth2认证流程:通过钉钉授权获取用户身份信息,实现自动登录
- 表单设计与验证:实现完整的投诉表单,包括科室/人员选择、日期选择、多字段验证
- 拼音检索功能:利用拼音助记码实现高效的搜索体验
- 分页加载优化:实现平滑的触底加载效果,提升大数据量展示性能
- 自定义组件开发:封装可复用的水印组件,保护敏感信息
- UI组件集成:合理使用mini-ali-ui组件库,快速构建原生体验
结语
本文通过对一个实际医院投诉管理小程序的完整分析,展示了钉钉小程序开发的核心技术和最佳实践。希望能为正在学习或准备开发钉钉小程序的开发者提供有价值的参考。如有任何问题或建议,欢迎在评论区交流讨论。