Files
Environment-Monitoring-System/ems-frontend/ems-monitoring-system/src/components/FeedbackDetailDialog.vue
ChuXun c856666f22 debug
2026-01-29 02:55:05 +08:00

609 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<el-dialog
:model-value="visible"
title="反馈详情"
width="70%"
:before-close="handleClose"
class="feedback-detail-dialog"
top="5vh"
>
<template #header>
<div class="dialog-header">
<span class="el-dialog__title">反馈详情</span>
<el-button
type="primary"
:icon="Refresh"
circle
@click="handleRefresh"
:loading="loading"
title="刷新数据"
/>
</div>
</template>
<div v-if="loading" v-loading="loading" class="loading-container"></div>
<div v-if="!loading && currentFeedback" class="detail-container">
<el-descriptions :column="2" border>
<el-descriptions-item label="ID">{{ currentFeedback.id }}</el-descriptions-item>
<el-descriptions-item label="事件编号">{{ currentFeedback.eventId || '无' }}</el-descriptions-item>
<el-descriptions-item label="标题" :span="2">{{ currentFeedback.title }}</el-descriptions-item>
<el-descriptions-item label="污染类型">{{ formatPollutionType(currentFeedback.pollutionType) }}</el-descriptions-item>
<el-descriptions-item label="严重程度">
<el-tag :type="getSeverityTagType(currentFeedback.severityLevel)">
{{ formatSeverityLevel(currentFeedback.severityLevel) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="getStatusTagType(currentFeedback.status)">
{{ formatStatus(currentFeedback.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="提交时间">{{ formatDate(currentFeedback.createdAt) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDate(currentFeedback.updatedAt) }}</el-descriptions-item>
</el-descriptions>
<el-descriptions :column="1" border class="mt-4">
<el-descriptions-item label="详细描述">
<div class="description-content">{{ currentFeedback.description }}</div>
</el-descriptions-item>
<el-descriptions-item label="提交位置" v-if="currentFeedback.latitude && currentFeedback.longitude">
{{ currentFeedback.textAddress || `经纬度: (${currentFeedback.longitude}, ${currentFeedback.latitude})` }}
</el-descriptions-item>
<el-descriptions-item label="报告人信息" v-if="currentFeedback.user">
{{ currentFeedback.user.name }}
<el-tag size="small" style="margin-left: 8px;">{{ formatRole(currentFeedback.user.role) }}</el-tag>
<div class="mt-1">ID: {{ currentFeedback.user.id }}, 电话: {{ currentFeedback.reporterPhone || '未提供' }}</div>
<div class="user-note mt-1" v-if="currentFeedback.user.role === 'GRID_WORKER'">
<el-alert
type="warning"
:closable="false"
show-icon
title="注意:通常反馈应由公众监督员提交,此处显示为网格员可能是测试数据"
/>
</div>
</el-descriptions-item>
</el-descriptions>
<div class="task-details mt-4" v-if="currentFeedback.task">
<h4>任务详情</h4>
<el-descriptions :column="2" border>
<el-descriptions-item label="任务ID">{{ currentFeedback.task.id }}</el-descriptions-item>
<el-descriptions-item label="任务状态">
<el-tag :type="getStatusTagType(currentFeedback.task.status)">
{{ formatStatus(currentFeedback.task.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="处理人" v-if="currentFeedback.task.assignee">
{{ currentFeedback.task.assignee.name }} (ID: {{ currentFeedback.task.assignee.id }})
</el-descriptions-item>
<el-descriptions-item label="处理人电话" v-if="currentFeedback.task.assignee">
{{ currentFeedback.task.assignee.phone || '未提供' }}
</el-descriptions-item>
<el-descriptions-item label="网格坐标" :span="2" v-if="currentFeedback.task.gridX !== null && currentFeedback.task.gridY !== null">
X: {{ currentFeedback.task.gridX }}, Y: {{ currentFeedback.task.gridY }}
</el-descriptions-item>
<el-descriptions-item label="分配时间" v-if="currentFeedback.task.assignment?.assignmentTime">
{{ formatDate(currentFeedback.task.assignment.assignmentTime) }}
</el-descriptions-item>
<el-descriptions-item label="分配说明" v-if="currentFeedback.task.assignment?.remarks">
{{ currentFeedback.task.assignment.remarks }}
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 任务提交详情 -->
<div class="submission-details mt-4" v-if="currentFeedback.task && currentFeedback.task.submissionInfo">
<h4>任务提交详情</h4>
<el-descriptions :column="1" border>
<el-descriptions-item label="提交说明">
{{ currentFeedback.task.submissionInfo.comments }}
</el-descriptions-item>
<el-descriptions-item label="提交附件" v-if="currentFeedback.task.submissionInfo.attachments && currentFeedback.task.submissionInfo.attachments.length > 0">
<div v-for="att in currentFeedback.task.submissionInfo.attachments" :key="att.id">
<el-link :href="att.url" type="primary" target="_blank" :icon="Document">{{ att.fileName }}</el-link>
</div>
</el-descriptions-item>
<el-descriptions-item label="无附件" v-else>
未提交任何附件
</el-descriptions-item>
</el-descriptions>
</div>
<div class="image-gallery mt-4" v-if="currentFeedback.attachments && currentFeedback.attachments.length > 0">
<h4>相关图片</h4>
<el-image
v-for="att in currentFeedback.attachments"
:key="att.id"
:src="att.url"
:preview-src-list="currentFeedback.attachments.map(a => a.url)"
:initial-index="currentFeedback.attachments.findIndex(a => a.id === att.id)"
fit="cover"
class="feedback-image"
lazy
/>
</div>
</div>
<div v-if="!loading && !currentFeedback" class="empty-state">
<el-empty description="无法加载反馈详情" />
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">关闭</el-button>
<!-- 任务审批按钮 -->
<template v-if="currentFeedback?.task?.status === 'SUBMITTED' && canManageSubmittedTask">
<el-button type="success" @click="handleApprove" :loading="approving">批准通过</el-button>
<el-button type="danger" @click="handleRejectTask" :loading="rejecting">打回任务</el-button>
</template>
<!-- 根据状态条件性显示操作按钮 -->
<el-button type="primary" @click="handleProcess" v-if="canProcess">处理</el-button>
<el-button type="success" @click="handleAssign" v-if="canAssign">分配任务</el-button>
<el-button type="warning" @click="handleRejectFeedback" v-if="canReject">拒绝反馈</el-button>
</div>
</template>
</el-dialog>
<!-- 分配任务对话框 -->
<el-dialog
v-model="assignDialogVisible"
title="分配任务"
width="30%"
:before-close="() => assignDialogVisible = false"
>
<div v-if="loadingGridWorkers">正在加载网格员...</div>
<el-select
v-else
v-model="selectedGridWorkerId"
placeholder="请选择网格员"
style="width: 100%;"
>
<el-option
v-for="worker in gridWorkers"
:key="worker.id"
:label="`${worker.name} (ID: ${worker.id})${worker.phone ? ' - ' + worker.phone : ''}`"
:value="worker.id"
/>
</el-select>
<template #footer>
<span class="dialog-footer">
<el-button @click="assignDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmAssign" :disabled="!selectedGridWorkerId">
确认分配
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, watch, computed, onMounted, h } from 'vue';
import { useFeedbackStore } from '@/store/feedback';
import { storeToRefs } from 'pinia';
import { ElMessage, ElMessageBox, ElSelect, ElOption } from 'element-plus';
import { FeedbackStatus, PollutionType, SeverityLevel, processFeedback, assignFeedback, rejectFeedback, resolveFeedback } from '@/api/feedback';
import { approveTask, rejectTask } from '@/api/tasks';
import type { UserAccount } from '@/api/types';
import apiClient from '@/api/index';
import { Document, Refresh } from '@element-plus/icons-vue';
import { useAuthStore } from '@/stores/auth';
const props = defineProps<{
visible: boolean;
feedbackId: number | null;
}>();
const emit = defineEmits(['close', 'refresh']);
const feedbackStore = useFeedbackStore();
const { currentFeedbackDetail: currentFeedback, detailLoading: loading } = storeToRefs(feedbackStore);
const authStore = useAuthStore();
const currentUser = computed(() => authStore.user);
const approving = ref(false);
const rejecting = ref(false);
// 添加网格员列表状态
const gridWorkers = ref<UserAccount[]>([]);
const loadingGridWorkers = ref(false);
const selectedGridWorkerId = ref<number | null>(null);
const assignDialogVisible = ref(false);
const handleRefresh = () => {
if (props.feedbackId) {
feedbackStore.fetchFeedbackDetail(props.feedbackId);
}
};
// 加载网格员列表
const loadGridWorkers = async () => {
loadingGridWorkers.value = true;
try {
// 直接调用API
const response = await apiClient.get('/tasks/grid-workers');
// 根据apiClient的拦截器response已经是处理过的数据了
gridWorkers.value = response as unknown as UserAccount[];
console.log('成功获取到网格员列表:', gridWorkers.value);
if (gridWorkers.value.length === 0) {
ElMessage.info('当前没有可用的网格员。');
}
} catch (error) {
console.error('加载网格员列表失败:', error);
ElMessage.error('加载网格员列表失败,请检查后端服务是否正常。');
gridWorkers.value = []; // 失败时清空数组
} finally {
loadingGridWorkers.value = false;
}
};
// 获取模拟网格员数据
const getMockGridWorkers = (): UserAccount[] => {
return [
{
id: 1,
name: '张三',
email: 'zhangsan@example.com',
phone: '13800000001',
role: 'GRID_WORKER',
status: 'ACTIVE',
gender: 'MALE',
region: '北京市朝阳区',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: 2,
name: '李四',
email: 'lisi@example.com',
phone: '13800000002',
role: 'GRID_WORKER',
status: 'ACTIVE',
gender: 'FEMALE',
region: '北京市海淀区',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: 3,
name: '王五',
email: 'wangwu@example.com',
phone: '13800000003',
role: 'GRID_WORKER',
status: 'ACTIVE',
gender: 'MALE',
region: '北京市西城区',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
];
};
// 在对话框显示时加载网格员列表
watch(
() => props.visible,
(newVisible) => {
if (newVisible && canAssign.value) {
// 移除这里的加载,改为在点击分配按钮时加载
}
}
);
// --- Computed Properties for Action Buttons ---
const canManageSubmittedTask = computed(() => {
// Per user requirements, only ADMINs can manage tasks submitted by Grid Workers.
return authStore.user?.role === 'ADMIN';
});
const isActionableBySupervisor = computed(() => {
// This property now simply checks if the user has the authority to perform
// pre-assignment actions like processing or rejecting feedback.
if (!authStore.user) return false;
const viewerRole = authStore.user.role;
return viewerRole === 'ADMIN' || viewerRole === 'SUPERVISOR';
});
const canProcess = computed(() => {
if (!currentFeedback.value || !isActionableBySupervisor.value) return false;
const processableStatus = [
FeedbackStatus.AI_REVIEWING,
FeedbackStatus.PENDING_REVIEW,
FeedbackStatus.CONFIRMED,
];
return processableStatus.includes(currentFeedback.value.status);
});
const canAssign = computed(() => {
if (!currentFeedback.value || !authStore.user) return false;
const viewerRole = authStore.user.role;
const isEligibleRole = viewerRole === 'ADMIN' || viewerRole === 'SUPERVISOR';
if (!isEligibleRole) {
return false;
}
// As per user feedback, supervisors and admins should be able to assign tasks.
// The button should appear when the feedback is ready for assignment.
// Based on the 'handleProcess' function, this status is PENDING_ASSIGNMENT.
return currentFeedback.value.status === 'PENDING_ASSIGNMENT';
});
const canReject = computed(() => {
if (!currentFeedback.value || !isActionableBySupervisor.value) return false;
const rejectableStatus = [
FeedbackStatus.AI_REVIEWING,
FeedbackStatus.PENDING_REVIEW,
];
return rejectableStatus.includes(currentFeedback.value.status);
});
// 格式化显示内容的辅助函数
const formatPollutionType = (type: string) => {
const map: Record<string, string> = {
'PM25': 'PM2.5',
'O3': '臭氧',
'NO2': '二氧化氮',
'SO2': '二氧化硫',
'OTHER': '其他'
};
return map[type] || type;
};
const formatSeverityLevel = (level: string) => {
const map: Record<string, string> = {
'LOW': '低',
'MEDIUM': '中',
'HIGH': '高'
};
return map[level] || level;
};
const formatStatus = (status: string) => {
const map: Record<string, string> = {
'AI_REVIEWING': 'AI审核中',
'PENDING_REVIEW': '待人工审核',
'REJECTED': '已拒绝',
'CONFIRMED': '已确认',
'ASSIGNED': '已分配',
'IN_PROGRESS': '处理中',
'RESOLVED': '已处理',
'CLOSED': '已关闭',
'PENDING_ASSIGNMENT': '待分配',
'PROCESSED': '已处理',
'COMPLETED': '已处理',
'SUBMITTED': '已提交'
};
return map[status] || status;
};
const getSeverityTagType = (level: string) => {
const map: Record<string, 'info' | 'warning' | 'danger'> = {
'LOW': 'info',
'MEDIUM': 'warning',
'HIGH': 'danger'
};
return map[level] || 'info';
};
const getStatusTagType = (status: string) => {
const map: Record<string, 'info' | 'warning' | 'danger' | 'primary' | 'success'> = {
'AI_REVIEWING': 'info',
'PENDING_REVIEW': 'warning',
'REJECTED': 'danger',
'CONFIRMED': 'primary',
'ASSIGNED': 'primary',
'IN_PROGRESS': 'warning',
'RESOLVED': 'success',
'CLOSED': 'info',
'PENDING_ASSIGNMENT': 'warning',
'PROCESSED': 'success',
'COMPLETED': 'success',
'SUBMITTED': 'info'
};
return map[status] || 'info';
};
const getFeedbackDisplayStatus = (feedback: any): string => {
if (!feedback) return '未知状态';
if (feedback.task && feedback.task.status === 'COMPLETED') {
return '已处理';
}
return formatStatus(feedback.status);
};
const getFeedbackDisplayTagType = (feedback: any): 'info' | 'warning' | 'danger' | 'primary' | 'success' => {
if (!feedback) return 'info';
if (feedback.task && feedback.task.status === 'COMPLETED') {
return 'success';
}
return getStatusTagType(feedback.status);
};
const formatDate = (dateStr: string) => {
try {
return new Date(dateStr).toLocaleString();
} catch (e) {
return dateStr;
}
};
const formatRole = (role?: string) => {
const map: Record<string, string> = {
'ADMIN': '管理员',
'DECISION_MAKER': '决策人',
'SUPERVISOR': '监督员',
'GRID_WORKER': '网格员',
'PUBLIC_SUPERVISOR': '公众监督员'
};
if (!role) return '未知';
return map[role] || role;
};
watch(
() => props.feedbackId,
(newId) => {
if (newId && props.visible) {
feedbackStore.fetchFeedbackDetail(newId);
}
},
{ immediate: true }
);
const handleClose = () => {
// 清空选中的网格员
selectedGridWorkerId.value = null;
emit('close');
};
const handleProcess = async () => {
if (!currentFeedback.value) return;
try {
await ElMessageBox.confirm(
'此操作将批准反馈并创建一个待分配的任务。',
'确认处理反馈',
{
confirmButtonText: '继续',
cancelButtonText: '取消',
type: 'success',
}
);
await processFeedback(currentFeedback.value.id, {
status: FeedbackStatus.PENDING_ASSIGNMENT,
notes: '管理员已批准该反馈,准备分配任务。'
});
ElMessage.success('反馈已批准,进入待分配状态');
emit('refresh');
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('处理反馈失败');
}
}
};
const handleAssign = () => {
if (!currentFeedback.value) return;
loadGridWorkers();
assignDialogVisible.value = true;
};
const confirmAssign = async () => {
if (!currentFeedback.value || !selectedGridWorkerId.value) {
ElMessage.warning('请选择一个网格员');
return;
}
try {
await apiClient.post('/tasks/assign', {
feedbackId: currentFeedback.value.id,
assigneeId: selectedGridWorkerId.value
});
ElMessage.success('分配成功');
assignDialogVisible.value = false;
emit('refresh');
handleClose();
} catch (apiError) {
console.error('API分配失败:', apiError);
ElMessage.error('分配失败,请稍后重试');
}
};
const handleRejectTask = async () => {
const taskId = currentFeedback.value?.task?.id;
if (!taskId) {
ElMessage.error('无法获取任务ID操作取消');
return;
}
ElMessageBox.prompt('请输入打回任务的理由:', '打回任务', {
confirmButtonText: '确认打回',
cancelButtonText: '取消',
inputPattern: /.+/,
inputErrorMessage: '理由不能为空',
})
.then(async ({ value }) => {
rejecting.value = true;
try {
await rejectTask(taskId, value);
ElMessage.success('任务已打回');
emit('refresh');
handleClose();
} catch (error) {
console.error('打回任务失败:', error);
ElMessage.error('操作失败,请重试');
} finally {
rejecting.value = false;
}
})
.catch(() => {
ElMessage.info('已取消操作');
});
};
const handleApprove = async () => {
if (!currentFeedback.value?.task) return;
approving.value = true;
try {
await approveTask(currentFeedback.value.task.id);
ElMessage.success('任务已批准');
emit('refresh');
} catch (error) {
ElMessage.error('批准任务失败');
} finally {
approving.value = false;
}
};
const handleRejectFeedback = async () => {
if (!currentFeedback.value) return;
if (props.feedbackId) {
ElMessageBox.prompt('请输入拒绝此反馈的理由:', '拒绝反馈', {
confirmButtonText: '确认拒绝',
cancelButtonText: '取消',
})
.then(async ({ value }) => {
try {
await rejectFeedback(props.feedbackId!, { notes: value || '无明确理由' });
ElMessage.success('反馈已拒绝');
emit('refresh');
handleClose();
} catch (error) {
console.error('拒绝反馈失败:', error);
ElMessage.error('操作失败');
}
})
.catch(() => {
ElMessage.info('已取消拒绝操作');
});
}
};
</script>
<style scoped>
.feedback-detail-dialog .loading-container {
height: 300px;
}
.detail-container {
max-height: 75vh;
overflow-y: auto;
}
.mt-4 {
margin-top: 1.5rem;
}
.mt-1 {
margin-top: 0.25rem;
}
.description-content {
white-space: pre-wrap;
}
.image-gallery {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.feedback-image {
width: 100px;
height: 100px;
border-radius: 4px;
cursor: pointer;
}
</style>