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.
 
 
 
 
 
 

910 lines
24 KiB

<template>
<div class="detail-materials-tab">
<!-- <template v-if="taskItemList.length === 0">
<div class="empty-state">
<van-image :src="noDataImg" width="151" height="120" />
<div class="empty-text-main">暂无资料上传</div>
<div class="empty-text-sub">暂无材料要求</div>
</div>
</template> -->
<template v-if="Number(taskStatus) === 5 && !taskItemList.some(item => (item.itemValue || []).length > 0)">
<div class="empty-state">
<van-image :src="noDataImg" width="151" height="120" />
<div class="empty-text-main">暂无资料上传</div>
<div class="empty-text-sub"></div>
</div>
<div class="result-card">
<div class="result-title">检查结果</div>
<div class="readonly-result-text">检查结果:已办结(<span class="overdue-text">超期</span>)</div>
<div class="readonly-result-text" >检查时间:{{ inspectResult.checkTime }}</div>
</div>
</template>
<template v-else>
<div v-for="(item, index) in taskItemList" :key="index" class="material-card">
<div class="material-title">
<van-icon v-if="Number(item.pass) === 0" name="info-o" class="required-mark" />
<span v-else class="required-mark">*</span>
{{ item.itemName }}
<span class="material-desc">(小于200M/个,最多30个文件)</span>
</div>
<div class="material-media" v-if="!isEdit">
<template v-for="(url, fileIndex) in item.itemValue" :key="fileIndex">
<div v-if="isVideo(url)" class="media-item media-item--video" @click="onPreview(url, item.itemValue)">
<video :src="resolveMediaUrl(url)" muted preload="metadata" class="video-thumb"></video>
<van-icon name="play-circle-o" class="play-icon" />
</div>
<div v-else-if="isPdf(url)" class="media-item media-item--pdf" @click="onPreview(url, item.itemValue)">
<div class="pdf-icon-wrapper">
<van-icon name="description" class="pdf-icon" />
<span class="pdf-text">PDF</span>
</div>
</div>
<van-image v-else :src="resolveMediaUrl(url)" fit="cover" class="media-item" @click="onPreview(url, item.itemValue)" />
</template>
</div>
<div class="material-media-edit" v-else>
<!-- 开发环境:原 van-uploader 不变 -->
<van-uploader
v-if="!isProd"
v-model="item.fileList"
multiple
:max-count="20"
:max-size="200 * 1024 * 1024"
accept="image/*,video/*,.pdf"
:after-read="onAfterReadImage"
:before-read="onBeforeRead"
@oversize="onOversize"
>
<template #default>
<div class="uploader-custom-btn">
<van-icon name="photograph" size="24" color="#dcdee0" />
</div>
</template>
</van-uploader>
<!-- 生产环境:自定义上传按钮,点击弹出 action-sheet -->
<div v-else class="uploader-custom-btn" @click="onOpenUploadAction(item)">
<van-icon name="photograph" size="24" color="#dcdee0" />
</div>
</div>
<div class="objection-card" v-if="Number(item.pass) === 0">
<div class="objection-row">
<span class="objection-label">审核:</span>
<span class="objection-value">不通过</span>
</div>
<div class="objection-row" v-if="item.reason">
<span class="objection-label">原因:</span>
<span class="objection-value">{{ item.reason }}</span>
</div>
</div>
</div>
<div class="result-card" v-if="Number(taskStatus) >= 2">
<div class="result-title">检查结果</div>
<div class="readonly-result-text" style="padding-top: 4px">
检查结果:{{
TASK_STATUS_TEXT[Number(taskStatus)]
}}
</div>
<div class="readonly-result-text">检查时间:{{ inspectResult.checkTime }}</div>
<div class="readonly-result-text" v-if="inspectResult.checkResult === 2 && inspectResult.deadline">
补正时限:{{ inspectResult.deadline }}
</div>
</div>
<!-- 展示云笔录 -->
<div class="cloud-record-card" v-if="Number(taskStatus) === 4 && Number(digitalVoiceLoggingStatus) !== 0">
<div class="cloud-record-row">
<span class="cloud-record-label">云笔录</span>
<van-button class="btn-view" size="small" plain type="primary" @click="onViewCloudRecord"> 查看 </van-button>
</div>
</div>
<div class="evaluate-card" v-if="Number(taskStatus) === 4">
<div class="card-title">反馈</div>
<!-- 表情选择 -->
<div class="emoji-section">
<div
class="emoji-item"
:class="{
active: evaluateForm.satisfactionLevel === 3,
disabled: readonly
}"
@click="!readonly && (evaluateForm.satisfactionLevel = 3)"
>
<img class="emoji-img" :src="evaluateForm.satisfactionLevel === 3 ? veryGoodImg : veryGoodDisImg" alt="满意" />
<span class="emoji-label">满意</span>
</div>
<div
class="emoji-item"
:class="{
active: evaluateForm.satisfactionLevel === 2,
disabled: readonly
}"
@click="!readonly && (evaluateForm.satisfactionLevel = 2)"
>
<img class="emoji-img" :src="evaluateForm.satisfactionLevel === 2 ? goodImg : goodDisImg" alt="一般" />
<span class="emoji-label">一般</span>
</div>
<div
class="emoji-item"
:class="{
active: evaluateForm.satisfactionLevel === 1,
disabled: readonly
}"
@click="!readonly && (evaluateForm.satisfactionLevel = 1)"
>
<img class="emoji-img" :src="evaluateForm.satisfactionLevel === 1 ? badImg : badDisImg" alt="不满意" />
<span class="emoji-label">不满意</span>
</div>
</div>
<!-- 反馈原因 -->
<div class="reason-section" v-if="evaluateForm.satisfactionLevel === 1 || evaluateForm.satisfactionLevel === 2">
<div class="reason-row">
<span class="reason-label"><span class="required-mark" v-if="!readonly">*</span>原因:</span>
<div class="reason-input-wrap">
<van-field
v-model="evaluateForm.commentReason"
type="textarea"
rows="3"
maxlength="300"
:show-word-limit="!readonly"
placeholder="请输入"
:readonly="readonly"
class="reason-field"
/>
</div>
</div>
</div>
<div class="submit-btn-wrap" v-if="!readonly">
<van-button class="btn-submit-eval" type="primary" block @click="onSubmitEvaluate">提交反馈</van-button>
</div>
</div>
</template>
<van-popup v-model:show="showDatePicker" round position="bottom" teleport="body">
<van-date-picker v-model="pickerDate" title="选择补正时限" :min-date="minDate" @confirm="onDateConfirm" @cancel="showDatePicker = false" />
</van-popup>
<!-- 视频预览弹窗 -->
<van-popup v-model:show="showVideoPreview" class="video-preview-popup" teleport="body" closeable @closed="previewVideoUrl = ''">
<video v-if="previewVideoUrl" :src="previewVideoUrl" controls autoplay playsinline webkit-playsinline class="preview-video"></video>
</van-popup>
<!-- 生产环境:上传方式选择 -->
<van-action-sheet
v-if="isEdit"
v-model:show="showUploadAction"
:actions="uploadActions"
cancel-text="取消"
close-on-click-action
teleport="body"
@select="onSelectUploadAction"
/>
</div>
</template>
<script setup name="NosDetailMaterialsTab">
import { showImagePreview, showToast, showSuccessToast } from 'vant'
import { useRoute } from 'vue-router'
import noDataImg from '@/assets/images/nos/no-data.png'
import { uploadFile } from '@/util/fileUpload'
import { toFullFileUrl } from '@/util/fileUrl'
import { nosTaskCommentAPi } from '@/api/nos/index'
import { appEnv } from '@/config/env'
import { $globalStore } from '@/common/globalStore'
import {
takePhotoWithWaterMark,
startVideoRecorder,
uploadLocalFileToBackend,
} from '@/util/w6sMedia'
import 'vant/es/toast/style';
import veryGoodImg from '@/assets/images/eval-verygood.png'
import veryGoodDisImg from '@/assets/images/eval-verygood-dis.png'
import goodImg from '@/assets/images/eval-good.png'
import goodDisImg from '@/assets/images/eval-good-dis.png'
import badImg from '@/assets/images/eval-bad.png'
import badDisImg from '@/assets/images/eval-bad-dis.png'
const resolveMediaUrl = (url) => toFullFileUrl(url)
// 与 NosListPanel STATUS_OPTIONS 保持一致
const TASK_STATUS_TEXT = {
1: '待处理',
2: '待修改',
3: '待审核',
4: '已办结',
5: '超期'
}
const props = defineProps({
taskStatus: {
type: [Number, String],
default: ''
},
isEdit: {
type: Boolean,
default: false
},
evaluateData: {
type: Object,
default: null
}
})
const emit = defineEmits(['view-cloud-record'])
const onViewCloudRecord = () => {
emit('view-cloud-record')
}
const taskItemList = defineModel('taskItemList', {
type: Array,
default: () => []
})
const inspectResult = defineModel('inspectResult', {
type: Object,
default: () => ({
checkResult: null,
deadline: '',
checkTime: ''
})
})
const digitalVoiceLoggingStatus = defineModel('digitalVoiceLoggingStatus', {
type: [Number, String],
default: ''
})
const route = useRoute()
const taskId = route.query.id
// ========== 反馈相关 ==========
const hasEvaluated = ref(false)
const readonly = computed(() => hasEvaluated.value)
const evaluateForm = ref({
satisfactionLevel: 3, // 3: 满意, 2: 一般, 1: 不满意
commentReason: ''
})
watch(
() => props.evaluateData,
(val) => {
if (val && val.satisfaction !== undefined) {
const satMap = { 0: 3, 1: 2, 2: 1 }
evaluateForm.value.satisfactionLevel = satMap[val.satisfaction] !== undefined ? satMap[val.satisfaction] : 3
if (val.commentReason) {
evaluateForm.value.commentReason = val.commentReason
}
hasEvaluated.value = true
}
},
{ immediate: true, deep: true }
)
const onSubmitEvaluate = async () => {
if ((evaluateForm.value.satisfactionLevel === 1 || evaluateForm.value.satisfactionLevel === 2) && !evaluateForm.value.commentReason?.trim()) {
showToast('请输入原因')
return
}
try {
const params = {
taskId: taskId,
satisfaction: evaluateForm.value.satisfactionLevel === 3 ? 0 : evaluateForm.value.satisfactionLevel === 2 ? 1 : 2,
reason: evaluateForm.value.commentReason?.trim()
}
const res = await nosTaskCommentAPi(params)
if (res && res.code === 200) {
showSuccessToast('反馈提交成功')
hasEvaluated.value = true
router.back()
} else {
showToast(res.msg || '反馈提交失败')
}
} catch (err) {
console.error(err)
// showToast("反馈提交失败");
}
}
const showDatePicker = ref(false)
const minDate = new Date()
const showVideoPreview = ref(false)
const previewVideoUrl = ref('')
// ========== 上传入口(生产环境用 w6s SDK) ==========
const isProd = computed(() => appEnv === 'prod')
const showUploadAction = ref(false)
const activeUploadItem = ref(null)
const uploadActions = [
{ name: '拍照' },
{ name: '录制视频' },
]
const pickerDate = ref([String(minDate.getFullYear()), String(minDate.getMonth() + 1).padStart(2, '0'), String(minDate.getDate()).padStart(2, '0')])
const onOversize = () => {
showToast('文件大小不能超过 200MB')
}
const isVideo = (url) => {
if (!url) return false
return /\.(mp4|webm|ogg|mov)$/i.test(url)
}
const isPdf = (url) => {
if (!url) return false
return /\.pdf$/i.test(url)
}
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp)$/i
const VIDEO_EXT_RE = /\.(mp4|webm|ogg|mov)$/i
const PDF_EXT_RE = /\.pdf$/i
const isAllowedUploadFile = (file) => {
const name = (file.name || '').toLowerCase()
const type = (file.type || '').toLowerCase()
if (type.startsWith('image/') || IMAGE_EXT_RE.test(name)) return true
if (type.startsWith('video/') || VIDEO_EXT_RE.test(name)) return true
if (type === 'application/pdf' || PDF_EXT_RE.test(name)) return true
return false
}
// 附件上传前校验,限制图片、视频、PDF文件
const onBeforeRead = (file) => {
const files = Array.isArray(file) ? file : [file]
for (const item of files) {
if (!isAllowedUploadFile(item)) {
showToast('仅支持上传图片、视频、PDF 格式文件')
return false
}
}
return true
}
const onAfterReadImage = (file) => {
const files = Array.isArray(file) ? file : [file]
files.forEach((f) => {
uploadFile(f, (res) => {
const data = res && res.data ? res.data : res
const filePath = typeof data === 'string' ? data : (data && (data.data || data.url || data.fileUrl || data.path)) || ''
if (filePath) {
f.url = toFullFileUrl(filePath)
if (isVideo(filePath) || isPdf(filePath)) {
f.isImage = false
}
f.status = 'done'
} else {
f.status = 'failed'
f.message = '上传失败'
}
})
})
}
// ========== 生产环境:w6s SDK 上传(仅现场拍摄/录制) ==========
const onOpenUploadAction = (item) => {
if ((item.fileList?.length || 0) >= 20) {
showToast('已达到最多 20 个文件上限')
return
}
activeUploadItem.value = item
showUploadAction.value = true
}
const onSelectUploadAction = ({ name }) => {
const item = activeUploadItem.value
if (!item) return
if (name === '拍照') onTakePhoto(item)
else if (name === '录制视频') onRecordVideo(item)
}
// 拍照带水印(时间戳 + 经纬度自动叠加)
const onTakePhoto = async (item) => {
console.log('[NosDetail] onTakePhoto 开始, item:', item, 'fileList:', item?.fileList)
try {
const watermarkContent = $globalStore.useCommon?.enterpriseName || '深圳市行政执法监督码'
const res = await takePhotoWithWaterMark({ content: watermarkContent })
console.log('[NosDetail] 拍照返回 res:', res, 'imageURL:', res?.imageURL, 'key:', res?.key)
const localPath = res.imageURL || res.key
console.log('[NosDetail] 解析出的 localPath:', localPath)
await pushSdkFileToFileList(item, {
localPath,
fileName: `photo-${Date.now()}.jpg`,
mimeType: 'image/jpeg',
isImage: true,
})
} catch (err) {
console.error('[NosDetail] w6s 拍照失败', err)
showToast('拍照失败或已取消')
}
}
// 录制视频
const onRecordVideo = async (item) => {
try {
const res = await startVideoRecorder({ duration: 60, quality: 1 })
const info = res.info || res
await pushSdkFileToFileList(item, {
localPath: info.video_path,
fileName: `video-${Date.now()}.mp4`,
mimeType: 'video/mp4',
isImage: false,
})
} catch (err) {
console.error('w6s 录制失败', err)
showToast('录制失败或已取消')
}
}
// 通用:把 SDK 返回的本地文件上传后端 → 写入 fileList
const pushSdkFileToFileList = async (item, { localPath, fileName, mimeType, isImage }) => {
console.log('[NosDetail] pushSdkFileToFileList 开始, 入参:', { localPath, fileName, mimeType, isImage })
if (!localPath) {
console.warn('[NosDetail] pushSdkFileToFileList localPath 为空, 直接返回')
return
}
const fileObj = {
url: '',
isImage,
status: 'uploading',
message: '上传中...',
}
item.fileList.push(fileObj)
console.log('[NosDetail] 已 push 到 fileList, 当前 fileList 长度:', item.fileList.length, 'fileObj:', fileObj)
try {
const res = await uploadLocalFileToBackend({ fileURL: localPath, fileName, mimeType })
console.log('[NosDetail] 上传后端返回 res:', res)
const data = res && res.data ? res.data : res
const filePath = typeof data === 'string'
? data
: (data && (data.data || data.url || data.fileUrl || data.path)) || ''
console.log('[NosDetail] 解析出的 filePath:', filePath, 'data:', data)
if (filePath) {
fileObj.url = toFullFileUrl(filePath)
fileObj.status = 'done'
fileObj.message = ''
console.log('[NosDetail] 上传成功, fileObj.url:', fileObj.url)
} else {
fileObj.status = 'failed'
fileObj.message = '上传失败'
console.warn('[NosDetail] 上传失败: filePath 为空')
}
} catch (err) {
console.error('[NosDetail] w6s 上传后端失败:', err)
fileObj.status = 'failed'
fileObj.message = '上传失败'
}
}
watch(
() => taskItemList.value,
(newList) => {
if (newList && newList.length > 0) {
newList.forEach((item) => {
if (!item.fileList) {
// Initialize fileList from itemValue if editing is possible
item.fileList = (item.itemValue || []).map((url) => ({
url: toFullFileUrl(url),
isImage: !isVideo(url) && !isPdf(url)
}))
}
})
}
},
{ immediate: true, deep: true }
)
const onAuditChange = (item) => {
if (Number(item.pass) !== 0) {
item.reason = ''
}
}
const onResultTypeChange = () => {
if (inspectResult.value.checkResult !== 2) {
inspectResult.value.deadline = ''
}
}
const onDateConfirm = ({ selectedValues }) => {
inspectResult.value.deadline = selectedValues.join('-')
showDatePicker.value = false
}
const onPreview = (url, itemValue) => {
if (!url) return
const fullUrl = resolveMediaUrl(url)
if (isVideo(url)) {
previewVideoUrl.value = fullUrl
showVideoPreview.value = true
return
}
if (isPdf(url)) {
window.open(fullUrl, '_blank')
return
}
const images = itemValue.filter((u) => !isVideo(u) && !isPdf(u)).map((u) => resolveMediaUrl(u))
const start = images.indexOf(fullUrl)
showImagePreview({
images,
startPosition: start >= 0 ? start : 0
})
}
</script>
<style lang="less" scoped>
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 100px;
.empty-text-main {
margin-top: 16px;
font-size: 16px;
color: #323233;
font-weight: 500;
}
.empty-text-sub {
margin-top: 8px;
font-size: 14px;
color: #969799;
}
}
.detail-materials-tab {
padding-bottom: 80px;
}
.material-card,
.result-card {
background: #fff;
margin-bottom: 8px;
padding: 14px 16px;
}
.material-title,
.result-title {
font-size: 15px;
font-weight: 600;
color: #323233;
margin-bottom: 12px;
}
.required-mark {
color: #ee0a24;
margin-right: 2px;
}
.material-desc {
font-size: 12px;
color: #969799;
font-weight: normal;
}
.material-media,
.material-media-edit {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.uploader-custom-btn {
width: 72px;
height: 72px;
background-color: #f7f8fa;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
border: 1px dashed #ebedf0;
}
.media-item {
position: relative;
flex: none;
width: 72px;
height: 72px;
border-radius: 4px;
overflow: hidden;
:deep(.van-image__img) {
width: 100%;
height: 100%;
}
&--video {
cursor: pointer;
.video-thumb {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
background-color: #000;
}
.play-icon {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
font-size: 28px;
color: rgba(255, 255, 255, 0.9);
}
}
&--pdf {
background-color: #f7f8fa;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border: 1px solid #ebedf0;
.pdf-icon-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #ee0a24;
.pdf-icon {
font-size: 28px;
}
.pdf-text {
font-size: 12px;
margin-top: 4px;
font-weight: 500;
}
}
}
}
.audit-field {
padding: 8px 0;
:deep(.van-field__label) {
width: 48px;
color: #323233;
}
:deep(.van-radio-group) {
flex: 1;
justify-content: flex-end;
gap: 16px;
}
:deep(.van-radio) {
margin-right: 0;
}
:deep(.van-radio__label) {
font-size: 14px;
}
}
.reason-field {
padding: 8px 0 0;
:deep(.van-field__label) {
width: 48px;
color: #646566;
}
}
.result-options {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 8px;
:deep(.van-radio) {
align-items: center;
}
:deep(.van-radio__label) {
line-height: 1.5;
color: #323233;
font-size: 14px;
}
}
.deadline-field {
padding: 8px 0 0;
:deep(.van-field__label) {
width: 72px;
color: #323233;
}
}
.objection-card {
background-color: #fffbe8;
border-radius: 4px;
padding: 12px;
margin-top: 8px;
}
.objection-row {
display: flex;
font-size: 13px;
line-height: 1.5;
color: #ed6a0c;
margin-bottom: 4px;
&:last-child {
margin-bottom: 0;
}
}
.objection-label {
flex: none;
}
.objection-value {
flex: 1;
word-break: break-all;
}
.readonly-result-text {
color: #323233;
font-size: 14px;
line-height: 1.5;
padding-bottom: 8px;
}
.video-preview-popup {
width: 100vw;
height: 100vh;
max-width: none !important;
margin: 0 !important;
background-color: #000;
display: flex;
align-items: center;
justify-content: center;
.preview-video {
width: 100%;
max-height: 100%;
object-fit: cover;
outline: none;
}
:deep(.van-popup__close-icon) {
color: #fff;
font-size: 24px;
z-index: 2;
}
}
.evaluate-card {
background: #fff;
margin-top: 8px;
padding: 14px 16px;
border-radius: 4px;
.card-title {
font-size: 15px;
font-weight: 600;
color: #323233;
margin-bottom: 16px;
}
.emoji-section {
display: flex;
justify-content: space-around;
padding: 10px 0 24px;
border-bottom: 1px solid #f5f6f7;
.emoji-item {
display: flex;
flex-direction: column;
align-items: center;
opacity: 0.6;
transition: all 0.3s;
&.active {
opacity: 1;
transform: scale(1.1);
.emoji-label {
color: #fca000;
font-weight: 600;
}
}
&.disabled {
cursor: not-allowed;
}
.emoji-img {
width: 48px;
height: 48px;
margin-bottom: 8px;
}
.emoji-label {
font-size: 13px;
color: #969799;
}
}
}
.reason-section {
padding-top: 20px;
.reason-row {
display: flex;
align-items: flex-start;
margin-bottom: 16px;
.reason-label {
width: 52px;
font-size: 14px;
color: #323233;
margin-top: 8px;
flex: none;
}
.reason-input-wrap {
flex: 1;
min-width: 0;
}
.reason-field {
padding: 8px;
background: #f7f8fa;
border-radius: 4px;
}
}
}
.submit-btn-wrap {
margin-top: 24px;
.btn-submit-eval {
height: 44px;
border-radius: 22px;
font-size: 16px;
}
}
}
.cloud-record-card {
background: #fff;
margin-top: 8px;
padding: 14px 16px;
border-radius: 4px;
}
.cloud-record-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.cloud-record-label {
font-size: 15px;
font-weight: 600;
color: #323233;
}
.btn-view {
height: 28px;
padding: 0 12px;
font-size: 12px;
}
</style>