feat: 迁移预警处理审核与备注附件流程
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="alert-summary-grid">
|
||||
<button v-for="(label, value) in statuses" :key="value" class="alert-summary-card" :class="{ active: query.status === Number(value) }" :aria-pressed="query.status === Number(value)" @click="selectStatus(Number(value))">
|
||||
<span>{{ label }}</span><strong>{{ available ? (counts[value] || 0) : '未接入' }}</strong>
|
||||
</button>
|
||||
</div>
|
||||
<div class="kra-search-box">
|
||||
<el-form :model="query" :inline="true" @submit.prevent="search">
|
||||
<el-form-item label="所属平台"><el-select v-model="platforms" multiple collapse-tags clearable placeholder="选择平台" style="width: 210px"><el-option v-for="(label, value) in platformOptions" :key="value" :label="label" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="规则名称"><el-input v-model="query.ruleName" placeholder="规则名称" clearable /></el-form-item>
|
||||
<el-form-item label="规则类型"><el-select v-model="query.ruleType" clearable placeholder="全部类型" style="width: 160px"><el-option v-for="type in types" :key="type" :label="type" :value="type" /></el-select></el-form-item>
|
||||
<el-form-item label="状态"><el-select v-model="query.status" clearable placeholder="全部状态" style="width: 150px"><el-option v-for="(label, value) in statuses" :key="value" :label="label" :value="Number(value)" /></el-select></el-form-item>
|
||||
<el-form-item label="归属商务"><el-input v-model="query.businessName" clearable placeholder="归属商务" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" icon="search" native-type="submit">搜索</el-button></el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="kra-table-box">
|
||||
<el-alert v-if="!available" class="mb-4" type="warning" :closable="false" title="渠道数据尚未接入,暂时无法核对预警可见范围。" />
|
||||
<el-alert v-else-if="!isAdmin" class="mb-4" type="info" :closable="false" title="仅展示当前账号自身及全部下级商务渠道产生的预警;上级、平级其他商务渠道预警不可见。" />
|
||||
<el-table v-loading="loading" :data="rows" row-key="id">
|
||||
<el-table-column prop="platformName" label="所属平台" min-width="100" />
|
||||
<el-table-column label="触发时间" min-width="120"><template #default="{ row }">{{ shortTime(row.createdAt) }}</template></el-table-column>
|
||||
<el-table-column prop="ruleName" label="规则名称" min-width="130" show-overflow-tooltip />
|
||||
<el-table-column prop="ruleType" label="规则类型" min-width="130" show-overflow-tooltip />
|
||||
<el-table-column prop="targetPromoteName" label="预警对象" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="businessName" label="归属商务" min-width="130" show-overflow-tooltip />
|
||||
<el-table-column prop="alertContent" label="预警内容" min-width="280" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="statusTone(row.status)" effect="plain">{{ row.statusLabel || statuses[row.status] }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="115" fixed="right"><template #default="{ row }"><el-button v-if="canQuery" link type="primary" @click="open(row)">{{ actionLabel(row.status) }}</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<div class="kra-pagination"><el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next, jumper" @size-change="search" @current-change="load" /></div>
|
||||
</div>
|
||||
<alert-handle-drawer v-model="drawer" :alert-id="currentID" :can-process="canProcess" :can-audit="canAudit" @success="load" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useUserStore } from '@/pinia/modules/user'
|
||||
import { useBtnAuth } from '@/utils/btnAuth'
|
||||
import { listAlertRecords, getAlertRuleTypes } from '@/api/alert'
|
||||
import AlertHandleDrawer from './components/AlertHandleDrawer.vue'
|
||||
import { alertStatuses as statuses, alertStatusTone as statusTone, alertShortTime as shortTime } from './alert-display'
|
||||
|
||||
defineOptions({ name: 'SwAlertList' })
|
||||
const user = useUserStore()
|
||||
const buttons = useBtnAuth()
|
||||
const isAdmin = computed(() => Number(user.userInfo.authorityId) === 888)
|
||||
const canProcess = computed(() => isAdmin.value || !!buttons['sw:alertRecord:process'])
|
||||
const canQuery = computed(() => isAdmin.value || !!buttons['sw:alertRecord:query'])
|
||||
const canAudit = computed(() => Number(user.userInfo.level) === 1 && (isAdmin.value || !!buttons['sw:alertRecord:audit']))
|
||||
const platformOptions = { lx: '龙翔', hw: '汇玩', yq: '游趣' }
|
||||
const platforms = ref(Object.keys(platformOptions))
|
||||
const types = ref([])
|
||||
const query = reactive({ pageNum: 1, pageSize: 10, ruleName: '', ruleType: '', status: undefined, businessName: '' })
|
||||
const rows = ref([])
|
||||
const total = ref(0)
|
||||
const counts = ref({})
|
||||
const available = ref(true)
|
||||
const loading = ref(false)
|
||||
const drawer = ref(false)
|
||||
const currentID = ref('')
|
||||
let requestVersion = 0
|
||||
const clear = () => { rows.value = []; total.value = 0; counts.value = {} }
|
||||
const load = async () => {
|
||||
const version = ++requestVersion
|
||||
if (!platforms.value.length) { clear(); available.value = true; loading.value = false; return }
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listAlertRecords({ ...query, ruleName: query.ruleName.trim(), businessName: query.businessName.trim(), platformCodes: platforms.value.join(',') })
|
||||
if (version !== requestVersion) return
|
||||
if (res.code !== 0) { clear(); return }
|
||||
rows.value = res.data.pageData.rows
|
||||
total.value = res.data.pageData.total
|
||||
counts.value = res.data.statusCount
|
||||
available.value = res.data.available !== false
|
||||
} catch { if (version === requestVersion) clear() } finally { if (version === requestVersion) loading.value = false }
|
||||
}
|
||||
const search = () => {
|
||||
if (!platforms.value.length) ElMessage.warning('请先选择平台')
|
||||
query.pageNum = 1
|
||||
return load()
|
||||
}
|
||||
const selectStatus = (status) => { query.status = query.status === status ? undefined : status; return search() }
|
||||
const actionLabel = (status) => {
|
||||
if (status === 3) return canAudit.value ? '审核' : '查看'
|
||||
if (!canProcess.value) return '查看'
|
||||
return { 1: '处理', 2: '继续处理', 4: '重新处理' }[status] || '查看'
|
||||
}
|
||||
const open = (row) => { currentID.value = row.id; drawer.value = true }
|
||||
onMounted(async () => {
|
||||
void load()
|
||||
const res = await getAlertRuleTypes()
|
||||
if (res.code === 0) types.value = res.data
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.alert-summary-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 16px; margin-bottom: 16px; }
|
||||
.alert-summary-card { display: flex; flex-direction: column; gap: 12px; text-align: left; padding: 20px; background: var(--el-bg-color); color: var(--el-text-color-regular); border: 1px solid var(--el-border-color-light); border-radius: 8px; cursor: pointer; }
|
||||
.alert-summary-card strong { font-size: 26px; color: var(--el-text-color-primary); }
|
||||
.alert-summary-card.active, .alert-summary-card:focus-visible { border-color: var(--el-color-primary); outline: 1px solid var(--el-color-primary); }
|
||||
@media (max-width: 900px) { .alert-summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
</style>
|
||||
@@ -0,0 +1,4 @@
|
||||
export const alertStatuses = { 1: '未处理', 2: '处理中', 3: '待审核', 4: '审核驳回', 5: '已处理' }
|
||||
export const alertStatusTone = (status) => ({ 1: 'danger', 2: 'warning', 3: 'info', 4: 'danger', 5: 'success' })[status] || 'info'
|
||||
export const alertShortTime = (value) => value ? value.replace('T', ' ').slice(5, 16) : '—'
|
||||
export const alertActions = { start: '开始处理', submit: '提交审核', pass: '审核通过', reject: '审核驳回', reprocess: '重新处理' }
|
||||
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<el-drawer 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>
|
||||
Reference in New Issue
Block a user