index.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512
  1. <template>
  2. <div class="page-container">
  3. <el-card shadow="never">
  4. <template #header>
  5. <div class="card-header">
  6. <div class="left-panel">
  7. <span class="title">异常上报管理</span>
  8. </div>
  9. <div class="right-panel" style="display: flex; align-items: center; gap: 10px">
  10. <el-input v-model="searchKey" placeholder="订单号/履约者姓名" prefix-icon="Search" clearable style="width: 220px" />
  11. <el-select v-model="searchType" placeholder="异常类型" clearable style="width: 140px">
  12. <el-option label="无法联系用户" value="contact_fail" />
  13. <el-option label="地址错误" value="addr_error" />
  14. <el-option label="宠物攻击性强" value="pet_aggressive" />
  15. <el-option label="天气原因" value="weather_delay" />
  16. <el-option label="突发意外" value="accident" />
  17. </el-select>
  18. <el-select v-model="searchStatus" placeholder="审核状态" clearable style="width: 120px">
  19. <el-option label="待审核" :value="0" />
  20. <el-option label="已通过" :value="1" />
  21. <el-option label="已驳回" :value="2" />
  22. </el-select>
  23. <el-button type="primary" icon="Plus" @click="handleAdd">新增上报</el-button>
  24. </div>
  25. </div>
  26. </template>
  27. <el-table :data="filteredTableData" style="width: 100%" v-loading="loading">
  28. <el-table-column prop="orderNo" label="关联订单号" width="180">
  29. <template #default="scope">
  30. <el-link type="primary" :underline="false">{{ scope.row.orderNo }}</el-link>
  31. </template>
  32. </el-table-column>
  33. <el-table-column prop="type" label="异常类型" width="120">
  34. <template #default="scope">
  35. <el-tag :type="getExceptionTag(scope.row.type)">{{ getExceptionLabel(scope.row.type) }}</el-tag>
  36. </template>
  37. </el-table-column>
  38. <el-table-column prop="fulfillerName" label="履约者" width="150">
  39. <template #default="scope">
  40. <div>{{ scope.row.fulfillerName }}</div>
  41. <div style="font-size: 12px; color: #999">{{ scope.row.fulfillerPhone }}</div>
  42. </template>
  43. </el-table-column>
  44. <el-table-column prop="content" label="上报内容" show-overflow-tooltip />
  45. <el-table-column prop="images" label="上报图片" width="120">
  46. <template #default="scope">
  47. <div v-if="scope.row.images && scope.row.images.length">
  48. <el-image
  49. :src="scope.row.images[0]"
  50. :preview-src-list="scope.row.images"
  51. fit="cover"
  52. style="width: 40px; height: 40px; border-radius: 4px"
  53. preview-teleported
  54. >
  55. <template #error>
  56. <div class="image-slot">
  57. <el-icon><Picture /></el-icon>
  58. </div>
  59. </template>
  60. </el-image>
  61. <span v-if="scope.row.images.length > 1" style="font-size: 12px; color: #999; margin-left: 5px">+{{ scope.row.images.length }}</span>
  62. </div>
  63. <span v-else style="color: #ccc">无图</span>
  64. </template>
  65. </el-table-column>
  66. <el-table-column prop="createTime" label="提交时间" width="170" sortable />
  67. <el-table-column prop="status" label="审核状态" width="100">
  68. <template #default="scope">
  69. <el-tag v-if="scope.row.status === 0" type="warning">待审核</el-tag>
  70. <el-tag v-else-if="scope.row.status === 1" type="success">已通过</el-tag>
  71. <el-tag v-else type="danger">已驳回</el-tag>
  72. </template>
  73. </el-table-column>
  74. <el-table-column label="操作" width="200" fixed="right" align="center">
  75. <template #default="scope">
  76. <template v-if="scope.row.status === 0">
  77. <el-button link type="primary" size="small" @click="handleOpenDrawer(scope.row, 'audit')">审核</el-button>
  78. <el-button link type="primary" size="small" @click="handleEdit(scope.row)">编辑</el-button>
  79. <el-button link type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button>
  80. </template>
  81. <template v-else>
  82. <el-button link type="primary" size="small" @click="handleOpenDrawer(scope.row, 'view')">详情</el-button>
  83. <el-button link type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button>
  84. </template>
  85. </template>
  86. </el-table-column>
  87. </el-table>
  88. <div class="pagination-container">
  89. <el-pagination
  90. v-model:current-page="currentPage"
  91. v-model:page-size="pageSize"
  92. :page-sizes="[10, 20, 50, 100]"
  93. layout="total, sizes, prev, pager, next, jumper"
  94. :total="total"
  95. @size-change="handleSizeChange"
  96. @current-change="handleCurrentChange"
  97. />
  98. </div>
  99. </el-card>
  100. <!-- Audit/Detail Drawer -->
  101. <el-drawer v-model="drawerVisible" :title="drawerMode === 'audit' ? '异常上报审核' : '异常详情'" direction="rtl" size="600px">
  102. <div class="drawer-content">
  103. <!-- 1. Basic Info -->
  104. <el-descriptions title="基础信息" :column="2" border>
  105. <el-descriptions-item label="订单号">{{ currentItem.orderNo }}</el-descriptions-item>
  106. <el-descriptions-item label="提交时间">{{ currentItem.createTime }}</el-descriptions-item>
  107. <el-descriptions-item label="履约者">{{ currentItem.fulfillerName }}</el-descriptions-item>
  108. <el-descriptions-item label="联系电话">{{ currentItem.fulfillerPhone }}</el-descriptions-item>
  109. <el-descriptions-item label="异常类型">
  110. <el-tag :type="getExceptionTag(currentItem.type)">{{ getExceptionLabel(currentItem.type) }}</el-tag>
  111. </el-descriptions-item>
  112. <el-descriptions-item label="当前状态">
  113. <el-tag v-if="currentItem.status === 0" type="warning">待审核</el-tag>
  114. <el-tag v-else-if="currentItem.status === 1" type="success">已通过</el-tag>
  115. <el-tag v-else type="danger">已驳回</el-tag>
  116. </el-descriptions-item>
  117. </el-descriptions>
  118. <!-- 2. Content & Images -->
  119. <div class="section-block">
  120. <div class="section-title">上报内容</div>
  121. <div class="text-content">{{ currentItem.content }}</div>
  122. <div class="image-list" v-if="currentItem.images && currentItem.images.length">
  123. <el-image
  124. v-for="(img, idx) in currentItem.images"
  125. :key="idx"
  126. :src="img"
  127. :preview-src-list="currentItem.images"
  128. :initial-index="idx"
  129. fit="cover"
  130. class="detail-img"
  131. />
  132. </div>
  133. </div>
  134. <!-- 3. Audit Form (If Audit Mode) -->
  135. <div class="section-block audit-form" v-if="drawerMode === 'audit'">
  136. <div class="section-title">审核处理</div>
  137. <el-form :model="auditForm" label-position="top">
  138. <el-form-item label="审核结果" required>
  139. <el-radio-group v-model="auditForm.result">
  140. <el-radio :label="1" border>通过</el-radio>
  141. <el-radio :label="2" border>驳回</el-radio>
  142. </el-radio-group>
  143. </el-form-item>
  144. <el-form-item label="处理备注">
  145. <el-input v-model="auditForm.remark" type="textarea" :rows="3" placeholder="请输入审核意见 (通过或驳回均可选填)" />
  146. </el-form-item>
  147. </el-form>
  148. <div class="drawer-footer-actions">
  149. <el-button @click="drawerVisible = false">取消</el-button>
  150. <el-button type="primary" @click="submitAudit">提交审核</el-button>
  151. </div>
  152. </div>
  153. <!-- 4. Log Record (All Modes) -->
  154. <div class="section-block">
  155. <div class="section-title">处理记录</div>
  156. <el-timeline>
  157. <el-timeline-item :timestamp="currentItem.createTime" placement="top" type="primary">
  158. <el-card shadow="never" class="log-card">
  159. <h4>提交上报</h4>
  160. <p>履约者 {{ currentItem.fulfillerName }} 提交了异常上报</p>
  161. </el-card>
  162. </el-timeline-item>
  163. <el-timeline-item
  164. v-if="currentItem.status !== 0"
  165. :timestamp="currentItem.auditTime || '2026-02-05 18:00:00'"
  166. placement="top"
  167. :type="currentItem.status === 1 ? 'success' : 'danger'"
  168. >
  169. <el-card shadow="never" class="log-card">
  170. <h4>{{ currentItem.status === 1 ? '审核通过' : '审核驳回' }}</h4>
  171. <p>操作人:系统管理员</p>
  172. <p>备注:{{ currentItem.auditRemark || '无' }}</p>
  173. </el-card>
  174. </el-timeline-item>
  175. </el-timeline>
  176. </div>
  177. </div>
  178. </el-drawer>
  179. <!-- Edit/Add Dialog (Keep for Edit) -->
  180. <el-dialog v-model="dialogVisible" :title="isEdit ? '编辑异常' : '新增异常'" width="600px">
  181. <el-form :model="form" label-width="100px">
  182. <el-form-item label="关联订单" required>
  183. <el-input v-model="form.orderNo" placeholder="请输入订单号" />
  184. </el-form-item>
  185. <el-form-item label="履约者" required>
  186. <el-select v-model="form.fulfillerId" filterable placeholder="请输入姓名/电话检索" style="width: 100%" @change="handleFulfillerSelect">
  187. <el-option v-for="item in fulfillerOptions" :key="item.id" :label="item.name + ' - ' + item.phone" :value="item.id" />
  188. </el-select>
  189. </el-form-item>
  190. <el-form-item label="异常类型" required>
  191. <el-select v-model="form.type" placeholder="请选择类型" style="width: 100%">
  192. <el-option label="无法联系用户" value="contact_fail" />
  193. <el-option label="地址错误/不存在" value="addr_error" />
  194. <el-option label="宠物攻击性强" value="pet_aggressive" />
  195. <el-option label="恶劣天气延迟" value="weather_delay" />
  196. <el-option label="突发意外" value="accident" />
  197. </el-select>
  198. </el-form-item>
  199. <el-form-item label="上报内容" required>
  200. <el-input v-model="form.content" type="textarea" :rows="3" placeholder="请详细描述异常情况..." />
  201. </el-form-item>
  202. <el-form-item label="现场图片">
  203. <el-upload
  204. action="#"
  205. list-type="picture-card"
  206. :auto-upload="false"
  207. v-model:file-list="fileList"
  208. :on-change="handleFileChange"
  209. :on-remove="handleFileRemove"
  210. >
  211. <el-icon><Plus /></el-icon>
  212. </el-upload>
  213. </el-form-item>
  214. </el-form>
  215. <template #footer>
  216. <span class="dialog-footer">
  217. <el-button @click="dialogVisible = false">取消</el-button>
  218. <el-button type="primary" @click="saveData">保存</el-button>
  219. </span>
  220. </template>
  221. </el-dialog>
  222. </div>
  223. </template>
  224. <script setup>
  225. import { ref, reactive, computed } from 'vue';
  226. import { ElMessage, ElMessageBox } from 'element-plus';
  227. const loading = ref(false);
  228. const searchKey = ref('');
  229. const searchType = ref('');
  230. const searchStatus = ref('');
  231. const currentPage = ref(1);
  232. const pageSize = ref(10);
  233. const total = ref(42);
  234. // Dialog State (For Add/Edit)
  235. const dialogVisible = ref(false);
  236. const isEdit = ref(false);
  237. const fileList = ref([]);
  238. // Mock Fulfiller Search Options
  239. const fulfillerOptions = ref([
  240. { id: 101, name: '李建国', phone: '13812341234' },
  241. { id: 102, name: '王大力', phone: '13987654321' },
  242. { id: 103, name: '张小妹', phone: '13766668888' },
  243. { id: 104, name: '刘跑跑', phone: '13611112222' }
  244. ]);
  245. const handleFulfillerSelect = (id) => {
  246. const target = fulfillerOptions.value.find((item) => item.id === id);
  247. if (target) {
  248. form.fulfillerName = target.name;
  249. form.fulfillerPhone = target.phone;
  250. }
  251. };
  252. // Drawer State (For Audit/View)
  253. const drawerVisible = ref(false);
  254. const drawerMode = ref('view'); // 'audit' or 'view'
  255. const currentItem = ref({});
  256. const auditForm = reactive({ result: 1, remark: '' });
  257. const form = reactive({
  258. id: '',
  259. orderNo: '',
  260. fulfillerId: '',
  261. fulfillerName: '',
  262. fulfillerPhone: '',
  263. type: '',
  264. content: '',
  265. images: [],
  266. status: 0,
  267. auditRemark: ''
  268. });
  269. // Mock Data
  270. const tableData = ref([
  271. {
  272. id: 1,
  273. orderNo: 'ORD202402048805',
  274. fulfillerName: '李建国',
  275. fulfillerPhone: '13812341234',
  276. type: 'contact_fail',
  277. content: '到达指定地点后,多次拨打宠主电话无人接听,敲门无响应超过15分钟。',
  278. images: [
  279. 'https://cube.elemecdn.com/6/94/4d3ea53c084bad6931a56d5158a48jpeg.jpeg',
  280. 'https://cube.elemecdn.com/e/fd/0fc7d20532fdaf769a25683617711png.png'
  281. ],
  282. status: 0,
  283. createTime: '2026-02-04 14:30:00',
  284. auditRemark: '',
  285. auditTime: ''
  286. },
  287. {
  288. id: 2,
  289. orderNo: 'ORD202402048802',
  290. fulfillerName: '王大力',
  291. fulfillerPhone: '13987654321',
  292. type: 'pet_aggressive',
  293. content: '进门时金毛表现出强烈护食行为,试图攻击,无法进行喂食操作。',
  294. images: [],
  295. status: 1,
  296. auditRemark: '已确认,取消本次订单,按此单结算跑腿费。',
  297. createTime: '2026-02-04 10:15:00',
  298. auditTime: '2026-02-04 11:00:00'
  299. }
  300. ]);
  301. const filteredTableData = computed(() => {
  302. return tableData.value.filter((item) => {
  303. const matchKey = !searchKey.value || item.orderNo.includes(searchKey.value) || item.fulfillerName.includes(searchKey.value);
  304. const matchType = !searchType.value || item.type === searchType.value;
  305. const matchStatus = searchStatus.value === '' || item.status === searchStatus.value;
  306. return matchKey && matchType && matchStatus;
  307. });
  308. });
  309. // Helpers
  310. const getExceptionLabel = (type) => {
  311. const map = {
  312. 'contact_fail': '无法联系用户',
  313. 'addr_error': '地址错误',
  314. 'pet_aggressive': '宠物攻击性强',
  315. 'weather_delay': '恶劣天气延迟',
  316. 'accident': '突发意外'
  317. };
  318. return map[type] || '其他';
  319. };
  320. const getExceptionTag = (type) => {
  321. const map = {
  322. 'contact_fail': 'warning',
  323. 'addr_error': 'info',
  324. 'pet_aggressive': 'danger',
  325. 'weather_delay': 'primary',
  326. 'accident': 'danger'
  327. };
  328. return map[type] || 'info';
  329. };
  330. // Actions
  331. const handleAdd = () => {
  332. isEdit.value = false;
  333. fileList.value = [];
  334. Object.assign(form, {
  335. id: '',
  336. orderNo: '',
  337. fulfillerId: '',
  338. fulfillerName: '',
  339. fulfillerPhone: '',
  340. type: '',
  341. content: '',
  342. images: [],
  343. status: 0,
  344. auditRemark: ''
  345. });
  346. dialogVisible.value = true;
  347. };
  348. const handleEdit = (row) => {
  349. isEdit.value = true;
  350. Object.assign(form, JSON.parse(JSON.stringify(row)));
  351. fileList.value = (row.images || []).map((url, i) => ({ name: `img${i}`, url: url }));
  352. dialogVisible.value = true;
  353. };
  354. const handleDelete = (row) => {
  355. ElMessageBox.confirm('确定删除该条记录吗?', '警告', { type: 'warning' }).then(() => {
  356. tableData.value = tableData.value.filter((item) => item.id !== row.id);
  357. ElMessage.success('删除成功');
  358. });
  359. };
  360. // Open Drawer for Audit or View
  361. const handleOpenDrawer = (row, mode) => {
  362. drawerMode.value = mode;
  363. currentItem.value = JSON.parse(JSON.stringify(row));
  364. // Reset audit form
  365. if (mode === 'audit') {
  366. auditForm.result = 1;
  367. auditForm.remark = '';
  368. }
  369. drawerVisible.value = true;
  370. };
  371. const submitAudit = () => {
  372. const target = tableData.value.find((i) => i.id === currentItem.value.id);
  373. if (target) {
  374. target.status = auditForm.result;
  375. target.auditRemark = auditForm.remark;
  376. target.auditTime = new Date().toLocaleString();
  377. ElMessage.success('审核已提交');
  378. drawerVisible.value = false;
  379. }
  380. };
  381. const saveData = () => {
  382. if (!form.orderNo || !form.type) return ElMessage.warning('请填写必填项');
  383. form.images = fileList.value.map((f) => f.url || 'https://placeholder.com/img');
  384. if (isEdit.value) {
  385. const idx = tableData.value.findIndex((i) => i.id === form.id);
  386. if (idx !== -1) Object.assign(tableData.value[idx], form);
  387. } else {
  388. tableData.value.unshift({
  389. id: Date.now(),
  390. ...form,
  391. createTime: new Date().toLocaleString()
  392. });
  393. }
  394. ElMessage.success('保存成功');
  395. dialogVisible.value = false;
  396. };
  397. const handleFileChange = (uploadFile, uploadFiles) => {
  398. fileList.value = uploadFiles;
  399. };
  400. const handleFileRemove = (uploadFile, uploadFiles) => {
  401. fileList.value = uploadFiles;
  402. };
  403. const handleSizeChange = (val) => {};
  404. const handleCurrentChange = (val) => {};
  405. </script>
  406. <style scoped>
  407. .page-container {
  408. padding: 20px;
  409. }
  410. .card-header {
  411. display: flex;
  412. justify-content: space-between;
  413. align-items: center;
  414. }
  415. .title {
  416. font-weight: bold;
  417. }
  418. .pagination-container {
  419. margin-top: 20px;
  420. display: flex;
  421. justify-content: flex-end;
  422. }
  423. /* Drawer Styles */
  424. .drawer-content {
  425. padding: 0 10px;
  426. }
  427. .section-block {
  428. margin-top: 25px;
  429. }
  430. .section-title {
  431. font-weight: bold;
  432. border-left: 4px solid #409eff;
  433. padding-left: 10px;
  434. margin-bottom: 15px;
  435. font-size: 15px;
  436. color: #303133;
  437. }
  438. .text-content {
  439. background: #f8f9fa;
  440. padding: 12px;
  441. border-radius: 4px;
  442. color: #606266;
  443. line-height: 1.6;
  444. }
  445. .image-list {
  446. margin-top: 15px;
  447. display: flex;
  448. gap: 10px;
  449. flex-wrap: wrap;
  450. }
  451. .detail-img {
  452. width: 100px;
  453. height: 100px;
  454. border-radius: 6px;
  455. cursor: pointer;
  456. }
  457. .audit-form {
  458. background: #f0f9eb;
  459. padding: 15px;
  460. border-radius: 8px;
  461. border: 1px solid #e1f3d8;
  462. }
  463. .drawer-footer-actions {
  464. display: flex;
  465. justify-content: flex-end;
  466. margin-top: 15px;
  467. grid-gap: 10px;
  468. }
  469. .log-card {
  470. margin-bottom: 5px;
  471. }
  472. .log-card h4 {
  473. margin: 0 0 5px;
  474. font-size: 14px;
  475. color: #303133;
  476. }
  477. .log-card p {
  478. margin: 0;
  479. font-size: 12px;
  480. color: #909399;
  481. line-height: 1.4;
  482. }
  483. </style>