158 lines
10 KiB
Vue
158 lines
10 KiB
Vue
<template>
|
||
<el-drawer class="sw-dialog" v-model="visible" append-to-body destroy-on-close title="预警处理 / 详情" size="min(560px, 100vw)" :close-on-click-modal="!busy" :close-on-press-escape="!busy" :show-close="!busy" @closed="reset">
|
||
<div v-loading="loading" tabindex="0" @paste="pasteImages">
|
||
<template v-if="current">
|
||
<h3 class="mb-3">渠道:{{ current.targetPromoteName.replace(/^渠道[::]\s*/, '') || '—' }} — {{ current.ruleName || '预警' }}</h3>
|
||
<el-descriptions :column="1" class="mb-4">
|
||
<el-descriptions-item label="归属商务">{{ current.businessName || '—' }}</el-descriptions-item>
|
||
<el-descriptions-item label="触发时间">{{ shortTime(current.createdAt) }}</el-descriptions-item>
|
||
<el-descriptions-item label="当前状态"><el-tag :type="statusTone(current.status)">{{ current.statusLabel || statuses[current.status] }}</el-tag></el-descriptions-item>
|
||
<el-descriptions-item label="预警内容">{{ current.alertContent || '—' }}</el-descriptions-item>
|
||
</el-descriptions>
|
||
<section v-if="canNote" class="mb-5">
|
||
<p class="mb-2">处理备注与图片附件</p>
|
||
<el-input v-model="note" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="填写处理备注..." :disabled="busy" />
|
||
<div class="alert-upload-zone" @dragover.prevent @drop.prevent="dropImages">
|
||
<el-button :disabled="busy" @click="fileInput.click()">上传图片(可多选)</el-button>
|
||
<input ref="fileInput" class="hidden" type="file" accept="image/*" multiple @change="pickImages" />
|
||
<p class="upload-hint">支持多选、拖拽或在备注区粘贴截图;图片先暂存,点击「添加备注」上传并提交。</p>
|
||
<div class="alert-images">
|
||
<div v-for="(item, index) in pending" :key="item.url" class="pending-image">
|
||
<el-image :src="item.url" :alt="item.file.name" :preview-src-list="pending.map(image => image.url)" :initial-index="index" preview-teleported fit="cover" />
|
||
<el-button size="small" :disabled="busy" @click="removeImage(index)">移除</el-button>
|
||
</div>
|
||
</div>
|
||
<el-button type="primary" link :loading="savingNote" :disabled="busy && !savingNote" @click="addNote">添加备注</el-button>
|
||
</div>
|
||
</section>
|
||
<p class="mb-4">操作记录(全流程追溯 · 不可删除)</p>
|
||
<el-empty v-if="!logs.length" description="暂无操作记录" :image-size="70" />
|
||
<el-timeline v-else>
|
||
<el-timeline-item v-for="log in logs" :key="log.id" :timestamp="shortTime(log.createdAt)" placement="top" type="primary">
|
||
<strong>{{ log.operatorName || '—' }}</strong>
|
||
<p>{{ actions[log.actionDetail] || log.logTypeLabel || log.actionDetail }}</p>
|
||
<p v-if="log.noteContent" class="alert-note">备注:{{ log.noteContent }}</p>
|
||
<div v-if="log.attachments.length" class="alert-images">
|
||
<el-image v-for="(url, index) in log.attachments" :key="index" :src="fileURL(url)" alt="附件" :preview-src-list="log.attachments.map(fileURL)" :initial-index="index" preview-teleported fit="cover" />
|
||
</div>
|
||
</el-timeline-item>
|
||
</el-timeline>
|
||
</template>
|
||
<el-empty v-else-if="!loading" description="暂时无法读取预警详情" />
|
||
</div>
|
||
<template #footer>
|
||
<el-button :disabled="busy" @click="visible = false">关闭</el-button>
|
||
<template v-if="current && !loading">
|
||
<el-button v-if="canProcess && current.status === 1" type="primary" :disabled="busy" @click="process('startProcess')">开始处理</el-button>
|
||
<el-button v-if="canProcess && current.status === 2" type="primary" :disabled="busy" @click="process('submitAudit')">提交审核</el-button>
|
||
<template v-if="auditAllowed && current.status === 3">
|
||
<el-button type="danger" plain :disabled="busy" @click="process('reject')">审核驳回</el-button>
|
||
<el-button type="primary" :disabled="busy" @click="process('approve')">审核通过</el-button>
|
||
</template>
|
||
<el-button v-if="canProcess && current.status === 4" type="primary" :disabled="busy" @click="process('reProcess')">重新处理</el-button>
|
||
</template>
|
||
</template>
|
||
</el-drawer>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { getAlertRecord, processAlertRecord, addAlertNote } from '@/api/alert'
|
||
import { uploadFile } from '@/api/fileUploadAndDownload'
|
||
import { getUrl } from '@/utils/image'
|
||
import { alertStatuses as statuses, alertStatusTone as statusTone, alertShortTime as shortTime, alertActions as actions } from '../alert-display'
|
||
|
||
const props = defineProps({ modelValue: Boolean, alertId: { type: [String, Number], default: '' }, canProcess: Boolean, canAudit: Boolean })
|
||
const emit = defineEmits(['update:modelValue', 'success'])
|
||
const visible = computed({ get: () => props.modelValue, set: (value) => emit('update:modelValue', value) })
|
||
const current = ref(null)
|
||
const logs = ref([])
|
||
const detailCanAudit = ref(false)
|
||
const auditAllowed = computed(() => props.canAudit && detailCanAudit.value)
|
||
const canNote = computed(() => props.canProcess && current.value && ([1, 2, 4].includes(current.value.status) || (current.value.status === 3 && auditAllowed.value)))
|
||
const loading = ref(false)
|
||
const savingNote = ref(false)
|
||
const processing = ref(false)
|
||
const busy = computed(() => savingNote.value || processing.value)
|
||
const note = ref('')
|
||
const pending = ref([])
|
||
const fileInput = ref()
|
||
let requestVersion = 0
|
||
// 图片只能作为图片地址展示,不将历史附件内容作为 HTML 或脚本执行。
|
||
const fileURL = (url) => /^https?:\/\//i.test(url) || !/^[a-z][a-z\d+.-]*:/i.test(url) ? getUrl(url) : ''
|
||
const clearImages = () => { pending.value.forEach(item => URL.revokeObjectURL(item.url)); pending.value = [] }
|
||
const reset = () => { ++requestVersion; clearImages(); note.value = ''; current.value = null; logs.value = []; detailCanAudit.value = false; loading.value = false }
|
||
const load = async () => {
|
||
const version = ++requestVersion
|
||
loading.value = true
|
||
try {
|
||
const res = await getAlertRecord(props.alertId)
|
||
if (version !== requestVersion) return
|
||
if (res.code === 0) { current.value = res.data.alertRecord; logs.value = res.data.operationLogs; detailCanAudit.value = res.data.canAudit === true }
|
||
else { current.value = null; logs.value = [] }
|
||
} catch { if (version === requestVersion) { current.value = null; logs.value = [] } } finally { if (version === requestVersion) loading.value = false }
|
||
}
|
||
const addImages = (files) => {
|
||
if (busy.value || !canNote.value) return
|
||
const images = Array.from(files).filter(file => file.type.startsWith('image/'))
|
||
if (!images.length) { ElMessage.warning('请选择图片文件'); return }
|
||
pending.value.push(...images.map(file => ({ file, url: URL.createObjectURL(file), uploaded: '' })))
|
||
}
|
||
const pickImages = (event) => { addImages(event.target.files); event.target.value = '' }
|
||
const dropImages = (event) => addImages(event.dataTransfer.files)
|
||
const pasteImages = (event) => {
|
||
const files = Array.from(event.clipboardData?.items || []).filter(item => item.type.startsWith('image/')).map(item => item.getAsFile()).filter(Boolean)
|
||
if (files.length && canNote.value) { event.preventDefault(); addImages(files) }
|
||
}
|
||
const removeImage = (index) => { const [image] = pending.value.splice(index, 1); URL.revokeObjectURL(image.url) }
|
||
const addNote = async () => {
|
||
if (busy.value || !canNote.value) return
|
||
if (!note.value.trim()) { ElMessage.warning('请填写处理备注后再提交附件'); return }
|
||
savingNote.value = true
|
||
try {
|
||
for (const item of pending.value) {
|
||
if (item.uploaded) continue
|
||
const data = new FormData()
|
||
data.append('file', item.file)
|
||
const res = await uploadFile(data)
|
||
const file = res.data?.file
|
||
if (res.code !== 0 || !file?.url) throw new Error('图片上传失败')
|
||
// 保留已经上传的结果,备注失败重试时不重复上传文件。
|
||
item.uploaded = file.ID ? String(file.ID) : file.url
|
||
}
|
||
const res = await addAlertNote({ alertId: props.alertId, noteContent: note.value.trim(), attachmentIds: pending.value.map(item => item.uploaded) })
|
||
if (res.code !== 0) return
|
||
clearImages(); note.value = ''; ElMessage.success('备注已添加'); await load()
|
||
} catch { ElMessage.error('备注提交失败,请重试') } finally { savingNote.value = false }
|
||
}
|
||
const confirmations = {
|
||
startProcess: '确认发起处理?预警将转为「处理中」状态。',
|
||
submitAudit: '确认提交审核?预警将转为「待审核」状态,等待一级商务审核。',
|
||
reject: '确认审核驳回?预警需补充处理后再提交。',
|
||
reProcess: '确认重新处理?预警将重新转为「处理中」状态。'
|
||
}
|
||
const process = async (action) => {
|
||
if (busy.value) return
|
||
if (pending.value.length) { ElMessage.warning('请先点击「添加备注」上传图片,或移除暂存图片后再操作'); return }
|
||
processing.value = true
|
||
try {
|
||
if (confirmations[action] && !await ElMessageBox.confirm(confirmations[action], '操作确认', { confirmButtonText: '确定', cancelButtonText: '取消' }).then(() => true).catch(() => false)) return
|
||
const res = await processAlertRecord(action, { alertId: props.alertId, note: note.value.trim() || undefined })
|
||
if (res.code !== 0) { await load(); return }
|
||
note.value = ''; ElMessage.success('操作成功'); emit('success'); await load()
|
||
} finally { processing.value = false }
|
||
}
|
||
watch(() => [props.modelValue, props.alertId], ([open]) => { if (open) { reset(); void load() } })
|
||
onBeforeUnmount(reset)
|
||
</script>
|
||
|
||
<style scoped>
|
||
.alert-upload-zone { margin-top: 12px; padding: 12px; border: 1px dashed var(--el-border-color); border-radius: 6px; }
|
||
.upload-hint { margin: 10px 0; font-size: 12px; line-height: 1.6; color: var(--el-text-color-secondary); }
|
||
.alert-images { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0; }
|
||
.alert-images .el-image { width: 72px; height: 72px; border-radius: 6px; }
|
||
.pending-image { display: flex; flex-direction: column; gap: 4px; }
|
||
.alert-note { white-space: pre-wrap; overflow-wrap: anywhere; padding: 10px; background: var(--el-fill-color-light); border-radius: 6px; }
|
||
</style>
|