You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

602 lines
18 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<view>
<cpn-navbar title="质控回访" :is-back="true"></cpn-navbar>
<view class="container">
<u-form :model="form" ref="uForm" :label-width="200" :error-type="['message', 'border-bottom']">
<u-form-item label="日期" prop="date" label-position="left">
<u-input v-model="form.date" disabled type="text"></u-input>
</u-form-item>
<u-form-item label="客户" prop="customer_name" label-position="left">
<u-input v-model="form.customer_name" disabled type="text"></u-input>
</u-form-item>
<u-form-item label="护理员" prop="nurse_name" label-position="left">
<u-input v-model="form.nurse_name" type="text" placeholder="请输入护理员"></u-input>
</u-form-item>
<u-form-item :required="item.type==='checkbox'?false:true" v-for="(item,index) in form.forms" :label="item.ask" :prop="index" label-position="top">
<u-checkbox-group v-if="item.type==='checkbox'" ref="serve" @change="e => checkboxChange(e)">
<u-checkbox v-for="(item1, index1) in item.options" :key="index1" :name="item1.name"
v-model="item1.checked">
{{ item1.name }}
</u-checkbox>
</u-checkbox-group>
<u-input v-else @blur="blurInput(item)" v-model="item.score" type="number" placeholder="请输入分值"></u-input>
</u-form-item>
<u-form-item label="总分(得90分及以上为及格)" prop="total_score" label-position="top">
<u-input v-model="form.total_score" disabled type="text"></u-input>
</u-form-item>
<u-form-item label="对护理员哪些方面要求改进" prop="tip" label-position="top">
<u-input v-model="form.tip" type="textarea"></u-input>
</u-form-item>
<u-form-item label="对护理员有哪些方面肯定" prop="sure" label-position="top">
<u-input v-model="form.sure" type="textarea"></u-input>
</u-form-item>
<u-form-item label="备注" prop="remark" label-position="top">
<u-input v-model="form.remark" type="textarea"></u-input>
</u-form-item>
<u-form-item label="回访图片">
<u-upload ref="uUpload" :custom-btn="true" :action="action" :file-list="fileList"
:source-type="['camera']">
<view slot="addBtn" class="slot-btn" hover-class="slot-btn__hover" hover-stay-time="150">
<u-icon name="camera" size="60" color="#c0c4cc" label="点击拍摄" label-pos="bottom"></u-icon>
</view>
</u-upload>
</u-form-item>
<u-form-item label="服务对象/家属签名" required prop="sign_image_id">
<view>
<u-button type="primary" size="mini" :throttle-time="3000"
@click="pageTo('/pages/sign/sign?key=vuex_sign_image')">点击签名</u-button>
<image v-if="vuex_sign_image || (detail.sign_image && detail.sign_image.url)"
:src="vuex_sign_image || (detail.sign_image && detail.sign_image.url)"
style="width: 260rpx;object-fit: cover;transform: rotate(270deg);"
@click="showimg(vuex_sign_image || (detail.sign_image && detail.sign_image.url))"></image>
</view>
</u-form-item>
<u-form-item label="调查人员签名" required prop="admin_sign_image_id">
<view>
<u-button type="primary" size="mini" :throttle-time="3000"
@click="pageTo('/pages/sign/sign?key=vuex_admin_sign_image')">点击签名</u-button>
<image v-if="vuex_admin_sign_image || (detail.admin_sign_image && detail.admin_sign_image.url)"
:src="vuex_admin_sign_image || (detail.admin_sign_image && detail.admin_sign_image.url)"
style="width: 260rpx;object-fit: cover;transform: rotate(270deg);"
@click="showimg(vuex_admin_sign_image || (detail.admin_sign_image && detail.admin_sign_image.url))">
</image>
</view>
</u-form-item>
<u-form-item label="所在位置">
<view>
<u-button type="primary" size="mini" :throttle-time="3000" @click="getLoaction">获取位置</u-button>
<view style="word-break: break-all;">
{{ `${(form.longitude || form.latitude) ? ('(' + form.longitude + ',' + form.latitude + ')') : ''}${form.address}` }}
</view>
</view>
</u-form-item>
</u-form>
<u-button type="primary" @click="submit">提交</u-button>
</view>
</view>
</template>
<script>
import QQMapWX from '@/libs/qqmap-wx-jssdk.js'
import {
ROOTPATH
} from "@/common/config"
import moment from "@/libs/moment.min";
export default {
data() {
return {
action: `${ROOTPATH}/api/admin/upload-file`,
fileList: [],
id: "",
type: "add",
detail: {},
form: {
customer_id: "",
customer_name:'',
date: '',
nurse_name: '',
forms: [{
ask: '执证上岗(工作证完整无损且佩戴在身)(2分)',
max: 2,
min: 0,
score: ''
}, {
ask: '工作服整洁、穿戴整齐(3分)',
max: 3,
min: 0,
score: ''
}, {
ask: '仪容仪表(指甲、束发)(2分)',
max: 2,
min: 0,
score: ''
}, {
ask: '对应本次服务所需工具齐全、性能完好(3分)',
max: 3,
min: 0,
score: ''
}, {
ask: '上门核对客户信息正确;地址、电话正确(5分)',
max: 5,
min: 0,
score: ''
}, {
ask: '告知客户可以提供的服务项目及本次上门服务的时长(5分)',
max: 3,
min: 0,
score: ''
}, {
ask: '客户选择本次服务项目(合法合规)(5分)',
max: 5,
min: 0,
score: ''
}, {
ask: '服务态度积极主动,不推托,不生硬拒绝(微笑服务)(5分)',
max: 5,
min: 0,
score: ''
}, {
ask: '服务内容、纸质工单、系统派单三者一致(个性化服务最后)(5分)',
max: 5,
min: 0,
score: ''
}, {
ask: '工单服务项目能保质保量完成(质量标准参考入户检查表)(50分)',
max: 50,
min: 0,
score: ''
}, {
ask: '抽查背诵一项服务流程(5分)',
max: 5,
min: 0,
score: ''
}, {
ask: '评估现场服务流程准确(5分)',
max: 5,
min: 0,
score: ''
}, {
ask: '能准确知晓并说出公司全称(每次上门先介绍)(5分)',
max: 5,
min: 0,
score: ''
}, {
ask: '每次上门服务前都提前预约(至少提前一天预约)(5分)',
max: 5,
min: 0,
score: ''
}, {
ask: '服务前后对比明显,所需部位消毒及时准确(5分)',
max: 5,
min: 0,
score: ''
}, {
ask: '所需服务技能娴熟欠缺一项扣2分扣完为止(10分)',
max: 10,
min: 0,
score: ''
}, {
ask: '其他(涉及此类一律按照 0 分处理)',
max: 10,
type: 'checkbox',
min: 0,
score: '',
options: [{
name: '服务中存在人身及环境安全',
checked: false,
}, {
name: '服务中脱岗',
checked: false,
}, {
name: '无正当理由不提供服务',
checked: false,
}, {
name: '恶意消耗工时、虚假工单、重叠工单、人卡不符、多卡一户现象',
checked: false,
}, {
name: '拒绝客户提出的正当要求',
checked: false,
}, {
name: '替代客户签名',
checked: false,
}, {
name: '存在向客户推销非官方商品、索要物品或货币行为',
checked: false,
}, {
name: '不积极配合公司稽查',
checked: false,
}, {
name: '违反公司原则性规章制度行为',
checked: false,
}]
}],
total_score: '',
tip: '',
sure: "",
remark: '',
file_ids: [],
sign_image_id: "",
admin_sign_image_id: "",
latitude: "",
longitude: "",
address: "",
},
rules: {
sign_image_id: [{
validator: (rule, value, callback) => {
if (this.vuex_sign_image || value) {
callback()
} else {
callback(new Error('请签名'))
}
}
}],
admin_sign_image_id: [{
validator: (rule, value, callback) => {
if (this.vuex_admin_sign_image || value) {
callback()
} else {
callback(new Error('请签名'))
}
}
}]
},
};
},
watch: {
// 深度监听 forms 数组的变化(包括元素新增、删除、属性修改)
'form.forms': {
handler() {
this.calculateTotalScore();
},
deep: true, // 深度监听
immediate: true // 初始化时立即执行一次
}
},
onLoad(option) {
this.form.customer_id = option.customer_id;
this.id = option.id;
this.type = option.id ? 'edit' : 'add';
if (this.type === 'edit') {
this.getDetail()
}else{
this.form.date = moment().format('YYYY-MM-DD')
this.form.customer_name = option.customer_name?option.customer_name:''
}
},
onReady() {
this.load();
this.$refs.uForm.setRules(this.rules);
},
methods: {
showimg(url) {
if (url)
this.$showimg({
imgs: [url],
current: 0
})
},
pageTo(url) {
uni.navigateTo({
url
})
},
load() {
this.qqmapsdk = new QQMapWX({
key: 'I5FBZ-LMN33-BK63F-OGUO7-XE3JK-WJBP5'
});
},
getLoaction() {
return new Promise((resolve, reject) => {
uni.getLocation({
type: 'gcj02',
isHighAccuracy: true
}).then(res => {
if (res[1]) {
this.form.latitude = res[1]?.latitude
this.form.longitude = res[1]?.longitude
this.qqmapsdk.reverseGeocoder({
location: {
latitude: this.form.latitude,
longitude: this.form.longitude
},
success: (res) => {
this.form.address = res.result.address + res.result
.formatted_addresses.recommend
console.log(this.form)
resolve(res)
},
fail: (err) => {
reject(err)
}
})
} else {
uni.showToast({
icon: 'none',
title: '操作频繁,请稍后再试'
})
reject(res)
console.log(res);
}
})
})
},
checkboxChange(e, item) {
let arr = e.filter(i => i?.trim())
console.log("arr", e, arr)
// this.$set(item, 'score', arr)
},
blurInput(item){
console.log("blur",item)
if(!this.validateNumberRange(item.score,item.min,item.max)){
uni.showToast({
title: "请输入正确的分值",
icon: "none"
})
item.score = 0
}
},
// 计算总分
calculateTotalScore(form) {
// 处理 forms 不存在或非数组的情况
if (!Array.isArray(this.form.forms)) {
this.form.total_score = 0;
return;
}
// 检查是否存在任何一个选项的 checked 为 true
const hasCheckedOption = this.form.forms.some(item => {
// 如果 item.options 存在且包含 checked 为 true 的项
return Array.isArray(item.options) &&
item.options.some(option => option.checked === true);
});
// 如果存在 checked 为 true 的选项,直接返回 0 分
if (hasCheckedOption) {
this.form.total_score = 0;
this.form.forms.map(item=>item.score=0)
return;
}
// 否则,正常累加所有项的分数
const total = this.form.forms.reduce((sum, item) => {
// 将 item.score 转换为数字,非数字默认 0
const score = Number(item.score) || 0;
return sum + score;
}, 0);
this.form.total_score = total;
},
// 判断是否为有效数子
validateNumberRange(value, min, max) {
// 步骤1判断值是否为有效数字处理字符串形式的数字
let num;
// 如果已经是数字类型,直接使用
if (typeof value === 'number') {
num = value;
}
// 如果是字符串,尝试转换为数字
else if (typeof value === 'string') {
// 去除首尾空格
const trimmed = value.trim();
// 使用 parseFloat 转换,并验证是否完全匹配数字格式
num = parseFloat(trimmed);
// 如果转换后不是NaN且字符串完全等于转换后的数字字符串确保没有多余字符
if (isNaN(num) || trimmed !== String(num)) {
return false;
}
}
// 其他类型(如 null、object 等)直接判定为无效
else {
return false;
}
// 步骤2检查数字是否在范围内包含边界
return num >= min && num <= max;
},
/**
* 验证表单数据
* @param {Array} forms - 表单数据数组
* @returns {Object} - 验证结果 { isValid: Boolean, errorMsg: String }
*/
validateForm(forms) {
// 1. 检查是否存在特殊项(包含 options 且有 checked=true
const specialItem = forms.find(item =>
Array.isArray(item.options) &&
item.options.some(option => option.checked === true)
);
// 如果存在特殊项被勾选直接返回总分0
if (specialItem) {
forms.map(item=>item.score = 0)
return { isValid: true, totalScore: 0, errorMsg: '' };
}
// 2. 验证其他项的分数是否有效(在 min 和 max 之间)
for (const item of forms) {
// 跳过特殊项
if (Array.isArray(item.options)) continue;
const { score, min, max } = item;
const parsedScore = parseFloat(score);
// 检查分数是否为有效数字
if (isNaN(parsedScore) || !isFinite(parsedScore)) {
return { isValid: false, totalScore: 0, errorMsg: `请输入有效的分数:${item.ask}` };
}
// 检查分数是否在范围内
if (parsedScore < min || parsedScore > max) {
return { isValid: false, totalScore: 0, errorMsg: `分数超出范围:${item.ask} (${min}-${max}分)` };
}
}
// 3. 计算总分(当所有项都有效时)
const totalScore = forms.reduce((sum, item) => {
// 跳过特殊项(已处理)
if (Array.isArray(item.options)) return sum;
// 累加有效分数
return sum + parseFloat(item.score);
}, 0);
return { isValid: true, totalScore, errorMsg: '' };
},
submit() {
console.log("this.form", this.form)
const res = this.validateForm(this.form.forms)
if(!res.isValid){
uni.showToast({
title:res.errorMsg,
icon:'none',
})
return
}
const uploadSignImage = () => {
return new Promise((resolve, reject) => {
if (this.vuex_sign_image) {
uni.uploadFile({
url: `${ROOTPATH}/api/admin/upload-file`,
header: {
Authorization: `Bearer ${this.vuex_token}`
},
filePath: this.vuex_sign_image,
name: 'file',
success: (res) => {
if (res.statusCode === 200) {
const response = JSON.parse(res.data)
resolve(response)
} else {
reject(res.data)
}
},
fail: (err) => {
reject(err)
}
})
} else {
resolve()
}
})
}
const uploadAdminSignImage = () => {
return new Promise((resolve, reject) => {
if (this.vuex_admin_sign_image) {
uni.uploadFile({
url: `${ROOTPATH}/api/admin/upload-file`,
header: {
Authorization: `Bearer ${this.vuex_token}`
},
filePath: this.vuex_admin_sign_image,
name: 'file',
success: (res) => {
if (res.statusCode === 200) {
const response = JSON.parse(res.data)
resolve(response)
} else {
reject(res.data)
}
},
fail: (err) => {
reject(err)
}
})
} else {
resolve()
}
})
}
this.$refs.uForm.validate(valid => {
if (valid) {
Promise.all([uploadSignImage(), uploadAdminSignImage()]).then(res => {
console.log(res)
if (res[0]?.id) {
this.form.sign_image_id = res[0].id
}
if (res[1]?.id) {
this.form.admin_sign_image_id = res[1].id
}
this.form.file_ids = this.$refs.uUpload.lists.filter(i => i.progress === 100)
.map(i => i.response?.id).filter(i => i)
if (this.type === 'add') {
delete this.form.id
} else {
this.form.id = this.id
}
this.$u.api.adminSaveQuality(this.form).then(res => {
uni.showToast({
icon: 'success',
title: '保存成功',
})
setTimeout(() => {
if(this.type==='edit'){
uni.redirectTo({
url:'/package_sub/pages/quality/qualityHistory'
})
}else{
uni.navigateBack()
}
this.$u.vuex('vuex_admin_sign_image', '')
this.$u.vuex('vuex_sign_image', '')
}, 1500)
})
}).catch(err => {
console.error(err)
uni.showToast({
title: "签名保存失败",
icon: "none"
})
})
}
})
},
async getDetail() {
const res = await this.$u.api.adminQualityDetail(this.id)
this.detail = res;
for (let key in this.form) {
this.form[key] = res[key]
}
this.form.customer_name = res.customer.name
console.log(this.form)
this.fileList = res.files.map(i => ({
url: i.url,
response: i
}))
}
},
}
</script>
<style lang="scss">
.container {
border-radius: 10rpx;
background: #FFFFFF;
box-shadow: 0rpx 4rpx 10rpx 0rpx rgba(219, 218, 218, 0.5);
margin: 20rpx;
padding: 20rpx;
}
.slot-btn {
width: 200rpx;
height: 200rpx;
display: flex;
justify-content: center;
align-items: center;
background: rgb(244, 245, 246);
border: 2rpx #108cff solid;
border-radius: 10rpx;
box-sizing: content-box;
filter: drop-shadow(0 0 4rpx #0fc7ff) drop-shadow(0 0 6rpx #00eaff);
}
.slot-btn__hover {
background-color: rgb(235, 236, 238);
}
</style>