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

5 months ago
<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>