|
|
@@ -0,0 +1,1649 @@
|
|
|
+<template>
|
|
|
+ <div class="p-2 supervision-page">
|
|
|
+ <!-- 页面标题 -->
|
|
|
+ <div class="page-header">
|
|
|
+ <h2 class="page-title">监督抽检 / 平台客诉</h2>
|
|
|
+ <p class="page-desc">门店抽检登记 · 不合格案件处理 · 平台客诉处理</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- ① 顶部 4 张数据看板 -->
|
|
|
+ <el-row :gutter="20" class="mb-2">
|
|
|
+ <el-col :span="6">
|
|
|
+ <el-card shadow="hover" class="stat-card">
|
|
|
+ <div class="stat-card-content">
|
|
|
+ <div class="stat-info">
|
|
|
+ <div class="stat-title">今日待审核抽检</div>
|
|
|
+ <div class="stat-value">{{ stats.todayPending || 0 }}</div>
|
|
|
+ <div class="stat-extra">
|
|
|
+ <span>当日所有门店抽检单据总量</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="stat-icon stat-icon-blue">
|
|
|
+ <el-icon><Document /></el-icon>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+ </el-col>
|
|
|
+ <el-col :span="6">
|
|
|
+ <el-card shadow="hover" class="stat-card">
|
|
|
+ <div class="stat-card-content">
|
|
|
+ <div class="stat-info">
|
|
|
+ <div class="stat-title">不合格待处理案件</div>
|
|
|
+ <div class="stat-value">{{ stats.pendingCases || 0 }}</div>
|
|
|
+ <div class="stat-extra stat-extra-warning">
|
|
|
+ <span>处于各处置节点的未闭环案件</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="stat-icon stat-icon-red">
|
|
|
+ <el-icon><Warning /></el-icon>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+ </el-col>
|
|
|
+ <el-col :span="6">
|
|
|
+ <el-card shadow="hover" class="stat-card">
|
|
|
+ <div class="stat-card-content">
|
|
|
+ <div class="stat-info">
|
|
|
+ <div class="stat-title">待处理客诉移交</div>
|
|
|
+ <div class="stat-value">{{ stats.pendingComplaints || 0 }}</div>
|
|
|
+ <div class="stat-extra">
|
|
|
+ <span>待处理的消费者客诉单据</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="stat-icon stat-icon-orange">
|
|
|
+ <el-icon><Phone /></el-icon>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+ </el-col>
|
|
|
+ <el-col :span="6">
|
|
|
+ <el-card shadow="hover" class="stat-card">
|
|
|
+ <div class="stat-card-content">
|
|
|
+ <div class="stat-info">
|
|
|
+ <div class="stat-title">本月抽检合格率</div>
|
|
|
+ <div class="stat-value">{{ (stats.monthPassRate?.passRate ?? 0).toFixed(2) }}<span class="unit">%</span></div>
|
|
|
+ <div class="stat-extra stat-extra-up">
|
|
|
+ <span>合格 {{ stats.monthPassRate?.passCount || 0 }} / 总数 {{ stats.monthPassRate?.totalCount || 0 }} ({{ stats.monthPassRate?.month || '' }})</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="stat-icon stat-icon-green">
|
|
|
+ <el-icon><DataAnalysis /></el-icon>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+ </el-col>
|
|
|
+ </el-row>
|
|
|
+
|
|
|
+ <!-- ② 门店抽检登记 -->
|
|
|
+ <el-card shadow="hover" class="mb-2">
|
|
|
+ <template #header>
|
|
|
+ <div class="section-header">
|
|
|
+ <span class="section-title">门店抽检登记</span>
|
|
|
+ <el-button type="primary" :icon="Plus" @click="handleAddInspection">新增抽检单</el-button>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <el-form ref="inspQueryFormRef" :model="inspQuery" :inline="true" class="filter-bar">
|
|
|
+ <el-form-item label="关键词">
|
|
|
+ <el-input
|
|
|
+ v-model="inspQuery.keyword"
|
|
|
+ placeholder="搜索抽检单号/门店/商品"
|
|
|
+ clearable
|
|
|
+ style="width: 240px"
|
|
|
+ @keyup.enter="handleInspQuery"
|
|
|
+ />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="抽检结果">
|
|
|
+ <el-select
|
|
|
+ v-model="inspQuery.inspectionResult"
|
|
|
+ placeholder="全部"
|
|
|
+ clearable
|
|
|
+ style="width: 160px"
|
|
|
+ @change="handleInspQuery"
|
|
|
+ >
|
|
|
+ <el-option label="全部" value="" />
|
|
|
+ <el-option
|
|
|
+ v-for="d in inspectionResultDict"
|
|
|
+ :key="d.value"
|
|
|
+ :label="d.label"
|
|
|
+ :value="d.value"
|
|
|
+ />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="状态">
|
|
|
+ <el-select
|
|
|
+ v-model="inspQuery.status"
|
|
|
+ placeholder="全部"
|
|
|
+ clearable
|
|
|
+ style="width: 160px"
|
|
|
+ @change="handleInspQuery"
|
|
|
+ >
|
|
|
+ <el-option label="全部" value="" />
|
|
|
+ <el-option
|
|
|
+ v-for="d in inspectionStatusDict"
|
|
|
+ :key="d.value"
|
|
|
+ :label="d.label"
|
|
|
+ :value="d.value"
|
|
|
+ />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item>
|
|
|
+ <el-button @click="resetInspQuery">重置</el-button>
|
|
|
+ <el-button type="primary" @click="handleInspQuery">搜索</el-button>
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+
|
|
|
+ <el-table v-loading="inspLoading" :data="inspectionList" border stripe>
|
|
|
+ <el-table-column label="抽检单号" align="center" prop="inspectionCode" width="160" :show-overflow-tooltip="true" />
|
|
|
+ <el-table-column label="门店名称" align="center" prop="storeName" min-width="160" :show-overflow-tooltip="true" />
|
|
|
+ <el-table-column label="抽检时间" align="center" prop="inspectionTime" width="170">
|
|
|
+ <template #default="scope">{{ formatDateTime(scope.row.inspectionTime) }}</template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="抽检商品" align="center" prop="productName" min-width="160" :show-overflow-tooltip="true" />
|
|
|
+ <el-table-column label="抽检结果" align="center" width="110">
|
|
|
+ <template #default="scope">
|
|
|
+ <el-tag :type="scope.row.inspectionResult === '抽检合格' ? 'success' : 'danger'">
|
|
|
+ {{ scope.row.inspectionResult || '-' }}
|
|
|
+ </el-tag>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="不合格原因" align="center" prop="failReason" min-width="160" :show-overflow-tooltip="true" />
|
|
|
+ <el-table-column label="审核状态" align="center" width="100">
|
|
|
+ <template #default="scope">
|
|
|
+ <el-tag :type="scope.row.status === '已审核' ? 'success' : 'warning'">{{ scope.row.status || '-' }}</el-tag>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="抽检人" align="center" prop="inspectorName" width="100" />
|
|
|
+ <el-table-column label="操作" align="center" width="280" fixed="right" class-name="small-padding fixed-width">
|
|
|
+ <template #default="scope">
|
|
|
+ <el-button link type="primary" @click="viewInspection(scope.row)">查看详情</el-button>
|
|
|
+ <el-button link type="warning" v-if="scope.row.inspectionResult === '抽检不合格' && !scope.row._hasCase" @click="openCreateCaseFromInspection(scope.row)">案件处理</el-button>
|
|
|
+ <el-button link type="success" v-if="scope.row.status === '待审核'" @click="auditInspection(scope.row)">审核</el-button>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+
|
|
|
+ <pagination
|
|
|
+ v-show="inspTotal > 0"
|
|
|
+ v-model:page="inspQuery.pageNum"
|
|
|
+ v-model:limit="inspQuery.pageSize"
|
|
|
+ :total="inspTotal"
|
|
|
+ @pagination="getInspectionList"
|
|
|
+ />
|
|
|
+ </el-card>
|
|
|
+
|
|
|
+ <!-- ③ 不合格案件处理 -->
|
|
|
+ <el-card shadow="hover" class="mb-2">
|
|
|
+ <template #header>
|
|
|
+ <div class="section-header">
|
|
|
+ <span class="section-title">不合格案件处理</span>
|
|
|
+ <el-tag size="small">5 节点流程:风险控制 → 异议复检 → 调查取证 → 调查报告 → 闭环管理</el-tag>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <el-form ref="caseQueryFormRef" :model="caseQuery" :inline="true" class="filter-bar">
|
|
|
+ <el-form-item label="关键词">
|
|
|
+ <el-input
|
|
|
+ v-model="caseQuery.keyword"
|
|
|
+ placeholder="搜索案件编号/抽检单号/门店/产品"
|
|
|
+ clearable
|
|
|
+ style="width: 260px"
|
|
|
+ @keyup.enter="handleCaseQuery"
|
|
|
+ />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="状态">
|
|
|
+ <el-select
|
|
|
+ v-model="caseQuery.status"
|
|
|
+ placeholder="全部"
|
|
|
+ clearable
|
|
|
+ style="width: 160px"
|
|
|
+ @change="handleCaseQuery"
|
|
|
+ >
|
|
|
+ <el-option label="全部" value="" />
|
|
|
+ <el-option
|
|
|
+ v-for="d in caseStatusDict"
|
|
|
+ :key="d.value"
|
|
|
+ :label="d.label"
|
|
|
+ :value="d.value"
|
|
|
+ />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item>
|
|
|
+ <el-button @click="resetCaseQuery">重置</el-button>
|
|
|
+ <el-button type="primary" @click="handleCaseQuery">搜索</el-button>
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+
|
|
|
+ <el-table v-loading="caseLoading" :data="caseList" border stripe>
|
|
|
+ <el-table-column label="案件编号" align="center" prop="caseCode" width="160" :show-overflow-tooltip="true" />
|
|
|
+ <el-table-column label="关联抽检单" align="center" prop="inspectionCode" width="160" :show-overflow-tooltip="true" />
|
|
|
+ <el-table-column label="涉事门店" align="center" prop="storeName" min-width="160" :show-overflow-tooltip="true" />
|
|
|
+ <el-table-column label="不合格产品" align="center" prop="productName" min-width="160" :show-overflow-tooltip="true" />
|
|
|
+ <el-table-column label="风险等级" align="center" width="110">
|
|
|
+ <template #default="scope">
|
|
|
+ <el-tag :type="getRiskLevelTag(scope.row.riskLevel)">{{ scope.row.riskLevel || '-' }}</el-tag>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="当前节点" align="center" width="180">
|
|
|
+ <template #default="scope">
|
|
|
+ <span>{{ scope.row.currentNode || '-' }} / 5 · {{ scope.row.nodeName || '' }}</span>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="创建时间" align="center" width="170">
|
|
|
+ <template #default="scope">{{ formatDateTime(scope.row.createTime) }}</template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="状态" align="center" width="100">
|
|
|
+ <template #default="scope">
|
|
|
+ <el-tag :type="scope.row.status === '已闭环' ? 'success' : 'warning'">{{ scope.row.status || '-' }}</el-tag>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="操作" align="center" width="200" fixed="right" class-name="small-padding fixed-width">
|
|
|
+ <template #default="scope">
|
|
|
+ <el-button link type="primary" @click="viewCaseDetail(scope.row)">查看详情</el-button>
|
|
|
+ <el-button
|
|
|
+ link
|
|
|
+ type="success"
|
|
|
+ v-if="canProcessCase(scope.row)"
|
|
|
+ @click="openProcessCase(scope.row)"
|
|
|
+ >处理</el-button>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+
|
|
|
+ <pagination
|
|
|
+ v-show="caseTotal > 0"
|
|
|
+ v-model:page="caseQuery.pageNum"
|
|
|
+ v-model:limit="caseQuery.pageSize"
|
|
|
+ :total="caseTotal"
|
|
|
+ @pagination="getCaseList"
|
|
|
+ />
|
|
|
+ </el-card>
|
|
|
+
|
|
|
+ <!-- ④ 移交监管部门平台客诉处理 -->
|
|
|
+ <el-card shadow="hover" class="mb-2">
|
|
|
+ <template #header>
|
|
|
+ <div class="section-header">
|
|
|
+ <span class="section-title">移交监管部门平台客诉处理</span>
|
|
|
+ <div>
|
|
|
+ <el-tag size="small" class="mr-1">7 节点流程:风险控制 → 调查取证 → 调查判断 → 部门协同 → 依规处理 → 溯源整改 → 闭环管理</el-tag>
|
|
|
+ <el-button type="primary" :icon="Plus" @click="handleAddComplaint">新增客诉</el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <el-form ref="cptQueryFormRef" :model="cptQuery" :inline="true" class="filter-bar">
|
|
|
+ <el-form-item label="关键词">
|
|
|
+ <el-input
|
|
|
+ v-model="cptQuery.keyword"
|
|
|
+ placeholder="搜索客诉编号/投诉人/门店/内容"
|
|
|
+ clearable
|
|
|
+ style="width: 260px"
|
|
|
+ @keyup.enter="handleCptQuery"
|
|
|
+ />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="状态">
|
|
|
+ <el-select
|
|
|
+ v-model="cptQuery.status"
|
|
|
+ placeholder="全部"
|
|
|
+ clearable
|
|
|
+ style="width: 160px"
|
|
|
+ @change="handleCptQuery"
|
|
|
+ >
|
|
|
+ <el-option label="全部" value="" />
|
|
|
+ <el-option
|
|
|
+ v-for="d in complaintStatusDict"
|
|
|
+ :key="d.value"
|
|
|
+ :label="d.label"
|
|
|
+ :value="d.value"
|
|
|
+ />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item>
|
|
|
+ <el-button @click="resetCptQuery">重置</el-button>
|
|
|
+ <el-button type="primary" @click="handleCptQuery">搜索</el-button>
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+
|
|
|
+ <el-table v-loading="cptLoading" :data="complaintList" border stripe>
|
|
|
+ <el-table-column label="客诉编号" align="center" prop="complaintCode" width="160" :show-overflow-tooltip="true" />
|
|
|
+ <el-table-column label="客诉来源" align="center" width="120">
|
|
|
+ <template #default="scope">{{ getDictLabel(complaintSourceDict, scope.row.source) }}</template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="投诉人" align="center" prop="complainantName" width="100" />
|
|
|
+ <el-table-column label="涉事门店" align="center" prop="storeName" min-width="160" :show-overflow-tooltip="true" />
|
|
|
+ <el-table-column label="客诉内容" align="center" prop="complaintContent" min-width="200" :show-overflow-tooltip="true" />
|
|
|
+ <el-table-column label="移交时间" align="center" prop="transferTime" width="170">
|
|
|
+ <template #default="scope">{{ formatDateTime(scope.row.transferTime) }}</template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="当前节点" align="center" width="180">
|
|
|
+ <template #default="scope">
|
|
|
+ <span>{{ scope.row.currentNode || '-' }} / 7 · {{ scope.row.nodeName || '' }}</span>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="状态" align="center" width="100">
|
|
|
+ <template #default="scope">
|
|
|
+ <el-tag :type="scope.row.status === '已闭环' ? 'success' : 'warning'">{{ scope.row.status || '-' }}</el-tag>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="操作" align="center" width="200" fixed="right" class-name="small-padding fixed-width">
|
|
|
+ <template #default="scope">
|
|
|
+ <el-button link type="primary" @click="viewComplaintDetail(scope.row)">查看详情</el-button>
|
|
|
+ <el-button
|
|
|
+ link
|
|
|
+ type="success"
|
|
|
+ v-if="scope.row.status === '处置中'"
|
|
|
+ @click="openProcessComplaint(scope.row)"
|
|
|
+ >处理</el-button>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+
|
|
|
+ <pagination
|
|
|
+ v-show="cptTotal > 0"
|
|
|
+ v-model:page="cptQuery.pageNum"
|
|
|
+ v-model:limit="cptQuery.pageSize"
|
|
|
+ :total="cptTotal"
|
|
|
+ @pagination="getComplaintList"
|
|
|
+ />
|
|
|
+ </el-card>
|
|
|
+
|
|
|
+ <!-- ⑤ 新增抽检单弹窗 -->
|
|
|
+ <el-dialog
|
|
|
+ v-model="inspDialog.visible"
|
|
|
+ :title="inspDialog.title"
|
|
|
+ width="720px"
|
|
|
+ append-to-body
|
|
|
+ :close-on-click-modal="false"
|
|
|
+ @close="closeInspDialog"
|
|
|
+ >
|
|
|
+ <el-form ref="inspFormRef" :model="inspForm" :rules="inspRules" label-width="100px">
|
|
|
+ <el-row :gutter="20">
|
|
|
+ <el-col :span="12">
|
|
|
+ <el-form-item label="门店名称" prop="storeName">
|
|
|
+ <el-input v-model="inspForm.storeName" placeholder="请输入门店名称" maxlength="200" />
|
|
|
+ </el-form-item>
|
|
|
+ </el-col>
|
|
|
+ <el-col :span="12">
|
|
|
+ <el-form-item label="抽检时间" prop="inspectionTime">
|
|
|
+ <el-date-picker
|
|
|
+ v-model="inspForm.inspectionTime"
|
|
|
+ type="datetime"
|
|
|
+ placeholder="请选择抽检时间"
|
|
|
+ value-format="YYYY-MM-DD HH:mm:ss"
|
|
|
+ format="YYYY-MM-DD HH:mm"
|
|
|
+ style="width: 100%"
|
|
|
+ />
|
|
|
+ </el-form-item>
|
|
|
+ </el-col>
|
|
|
+ </el-row>
|
|
|
+ <el-form-item label="抽检商品" prop="productName">
|
|
|
+ <el-input v-model="inspForm.productName" placeholder="请输入抽检商品" maxlength="200" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="抽检结果" prop="inspectionResult">
|
|
|
+ <el-radio-group v-model="inspForm.inspectionResult">
|
|
|
+ <el-radio
|
|
|
+ v-for="d in inspectionResultDict"
|
|
|
+ :key="d.value"
|
|
|
+ :value="d.value"
|
|
|
+ >{{ d.label }}</el-radio>
|
|
|
+ </el-radio-group>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item v-if="inspForm.inspectionResult === '抽检不合格'" label="不合格原因" prop="failReason">
|
|
|
+ <el-input
|
|
|
+ v-model="inspForm.failReason"
|
|
|
+ type="textarea"
|
|
|
+ :rows="3"
|
|
|
+ placeholder="请输入不合格原因"
|
|
|
+ maxlength="1000"
|
|
|
+ show-word-limit
|
|
|
+ />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="抽检人" prop="inspectorName">
|
|
|
+ <el-input v-model="inspForm.inspectorName" placeholder="请输入抽检人姓名" maxlength="50" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="备注" prop="remark">
|
|
|
+ <el-input v-model="inspForm.remark" placeholder="备注(选填)" maxlength="500" />
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ <template #footer>
|
|
|
+ <div class="dialog-footer">
|
|
|
+ <el-button @click="inspDialog.visible = false">取 消</el-button>
|
|
|
+ <el-button type="primary" @click="submitInspForm">保 存</el-button>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+
|
|
|
+ <!-- ⑥ 抽检详情只读弹窗 -->
|
|
|
+ <el-dialog
|
|
|
+ v-model="inspDetailDialog.visible"
|
|
|
+ title="抽检单详情"
|
|
|
+ width="640px"
|
|
|
+ append-to-body
|
|
|
+ :close-on-click-modal="false"
|
|
|
+ >
|
|
|
+ <el-descriptions :column="2" border size="default">
|
|
|
+ <el-descriptions-item label="抽检单号">{{ inspDetail.inspectionCode }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="抽检时间">{{ formatDateTime(inspDetail.inspectionTime) }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="门店名称">{{ inspDetail.storeName }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="抽检人">{{ inspDetail.inspectorName }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="抽检商品" :span="2">{{ inspDetail.productName }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="抽检结果">
|
|
|
+ <el-tag :type="inspDetail.inspectionResult === '抽检合格' ? 'success' : 'danger'">
|
|
|
+ {{ inspDetail.inspectionResult }}
|
|
|
+ </el-tag>
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="审核状态">
|
|
|
+ <el-tag :type="inspDetail.status === '已审核' ? 'success' : 'warning'">{{ inspDetail.status }}</el-tag>
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item v-if="inspDetail.inspectionResult === '抽检不合格'" label="不合格原因" :span="2">
|
|
|
+ {{ inspDetail.failReason || '—' }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="备注" :span="2">{{ inspDetail.remark || '—' }}</el-descriptions-item>
|
|
|
+ </el-descriptions>
|
|
|
+ <template #footer>
|
|
|
+ <div class="dialog-footer">
|
|
|
+ <el-button @click="inspDetailDialog.visible = false">关 闭</el-button>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+
|
|
|
+ <!-- ⑦ 案件处理详情弹窗(5 节点步骤条 + 当前节点处理 + 历史节点时间线) -->
|
|
|
+ <el-dialog
|
|
|
+ v-model="caseDialog.visible"
|
|
|
+ :title="caseDialog.title"
|
|
|
+ width="960px"
|
|
|
+ append-to-body
|
|
|
+ :close-on-click-modal="false"
|
|
|
+ @close="closeCaseDialog"
|
|
|
+ >
|
|
|
+ <div v-loading="caseDialog.loading">
|
|
|
+ <!-- 案件基本信息 -->
|
|
|
+ <el-descriptions :column="3" border size="small" class="mb-2">
|
|
|
+ <el-descriptions-item label="案件编号">{{ caseDetail.caseCode }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="关联抽检单">{{ caseDetail.inspectionCode }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="状态">
|
|
|
+ <el-tag :type="caseDetail.status === '已闭环' ? 'success' : 'warning'">{{ caseDetail.status }}</el-tag>
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="涉事门店">{{ caseDetail.storeName }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="不合格产品">{{ caseDetail.productName }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="风险等级">
|
|
|
+ <el-tag :type="getRiskLevelTag(caseDetail.riskLevel)">{{ caseDetail.riskLevel }}</el-tag>
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="处理人">{{ caseDetail.handlerName }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="创建时间">{{ formatDateTime(caseDetail.createTime) }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="风险描述" :span="3">{{ caseDetail.riskDesc }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="控制措施" :span="3">{{ caseDetail.controlMeasures }}</el-descriptions-item>
|
|
|
+ </el-descriptions>
|
|
|
+
|
|
|
+ <!-- 5 节点步骤条 -->
|
|
|
+ <el-steps :active="getCaseActive()" finish-status="success" align-center class="mb-2">
|
|
|
+ <el-step v-for="(name, idx) in CASE_NODE_NAMES" :key="idx" :title="`${idx + 1}. ${name}`" />
|
|
|
+ </el-steps>
|
|
|
+
|
|
|
+ <!-- 当前节点处理表单 -->
|
|
|
+ <el-card v-if="caseDetail.status === '处置中'" shadow="never" class="mb-2">
|
|
|
+ <template #header>
|
|
|
+ <span class="section-title">当前节点处理 · {{ caseDetail.nodeName }}</span>
|
|
|
+ </template>
|
|
|
+ <el-form ref="processCaseFormRef" :model="processCaseForm" :rules="processCaseRules" label-width="100px">
|
|
|
+ <el-form-item label="处理意见" prop="opinion">
|
|
|
+ <el-input
|
|
|
+ v-model="processCaseForm.opinion"
|
|
|
+ type="textarea"
|
|
|
+ :rows="3"
|
|
|
+ placeholder="请输入当前节点处理意见"
|
|
|
+ maxlength="2000"
|
|
|
+ show-word-limit
|
|
|
+ />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="凭证附件">
|
|
|
+ <el-upload
|
|
|
+ v-model:file-list="caseFileList"
|
|
|
+ :http-request="customUpload"
|
|
|
+ :limit="1"
|
|
|
+ :accept="'.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png'"
|
|
|
+ :before-upload="beforeAttachmentUpload"
|
|
|
+ :on-success="handleAttachmentSuccess"
|
|
|
+ :on-error="handleUploadError"
|
|
|
+ :on-exceed="handleAttachmentExceed"
|
|
|
+ :on-remove="handleAttachmentRemove"
|
|
|
+ >
|
|
|
+ <el-button type="primary" plain>选择凭证附件</el-button>
|
|
|
+ <template #tip>
|
|
|
+ <div class="el-upload__tip">支持 PDF/Word/Excel/PPT/图片,单个文件不超过 50MB</div>
|
|
|
+ </template>
|
|
|
+ </el-upload>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item>
|
|
|
+ <el-button type="primary" :loading="caseDialog.processing" @click="submitProcessCase">提交并流转下一节点</el-button>
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ </el-card>
|
|
|
+
|
|
|
+ <!-- 历史节点时间线 -->
|
|
|
+ <el-card shadow="never">
|
|
|
+ <template #header>
|
|
|
+ <span class="section-title">历史节点记录</span>
|
|
|
+ </template>
|
|
|
+ <el-empty v-if="!caseDetail.nodeRecords || caseDetail.nodeRecords.length === 0" description="暂无节点记录" :image-size="60" />
|
|
|
+ <el-timeline v-else>
|
|
|
+ <el-timeline-item
|
|
|
+ v-for="n in caseDetail.nodeRecords"
|
|
|
+ :key="n.id"
|
|
|
+ :timestamp="formatDateTime(n.handleTime)"
|
|
|
+ placement="top"
|
|
|
+ :type="n.nodeCode === caseDetail.currentNode ? 'primary' : 'success'"
|
|
|
+ >
|
|
|
+ <div class="timeline-node">
|
|
|
+ <div class="timeline-title">节点{{ n.nodeCode }} · {{ n.nodeName }}</div>
|
|
|
+ <div class="timeline-meta">处理人:{{ n.handlerName || '—' }}</div>
|
|
|
+ <div class="timeline-opinion">{{ n.opinion }}</div>
|
|
|
+ <div v-if="n.attachmentUrl" class="timeline-attach">
|
|
|
+ <el-link :href="n.attachmentUrl" target="_blank" type="primary">{{ n.attachmentName || '查看附件' }}</el-link>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-timeline-item>
|
|
|
+ </el-timeline>
|
|
|
+ </el-card>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <template #footer>
|
|
|
+ <div class="dialog-footer">
|
|
|
+ <el-button @click="caseDialog.visible = false">关 闭</el-button>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+
|
|
|
+ <!-- ⑧ 案件处理弹窗(从不合格抽检单发起) -->
|
|
|
+ <el-dialog
|
|
|
+ v-model="createCaseDialog.visible"
|
|
|
+ title="发起案件处理"
|
|
|
+ width="680px"
|
|
|
+ append-to-body
|
|
|
+ :close-on-click-modal="false"
|
|
|
+ >
|
|
|
+ <el-form ref="createCaseFormRef" :model="createCaseForm" :rules="createCaseRules" label-width="100px">
|
|
|
+ <el-form-item label="关联抽检单">
|
|
|
+ <span class="readonly-text">{{ createCaseForm.inspectionCode }} · {{ createCaseForm.storeName }} · {{ createCaseForm.productName }}</span>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="风险等级" prop="riskLevel">
|
|
|
+ <el-select v-model="createCaseForm.riskLevel" placeholder="请选择风险等级" style="width: 100%">
|
|
|
+ <el-option
|
|
|
+ v-for="d in riskLevelDict"
|
|
|
+ :key="d.value"
|
|
|
+ :label="d.label"
|
|
|
+ :value="d.value"
|
|
|
+ />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="风险描述" prop="riskDesc">
|
|
|
+ <el-input v-model="createCaseForm.riskDesc" type="textarea" :rows="3" placeholder="请输入风险描述" maxlength="2000" show-word-limit />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="控制措施" prop="controlMeasures">
|
|
|
+ <el-input v-model="createCaseForm.controlMeasures" type="textarea" :rows="3" placeholder="请输入控制措施" maxlength="2000" show-word-limit />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="处理人" prop="handlerName">
|
|
|
+ <el-input v-model="createCaseForm.handlerName" placeholder="请输入处理人姓名" maxlength="50" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="初始意见" prop="opinion">
|
|
|
+ <el-input v-model="createCaseForm.opinion" type="textarea" :rows="3" placeholder="请输入风险控制节点处理意见" maxlength="2000" show-word-limit />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="凭证附件">
|
|
|
+ <el-upload
|
|
|
+ v-model:file-list="caseFileList"
|
|
|
+ :http-request="customUpload"
|
|
|
+ :limit="1"
|
|
|
+ :accept="'.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png'"
|
|
|
+ :before-upload="beforeAttachmentUpload"
|
|
|
+ :on-success="handleAttachmentSuccess"
|
|
|
+ :on-error="handleUploadError"
|
|
|
+ :on-exceed="handleAttachmentExceed"
|
|
|
+ :on-remove="handleAttachmentRemove"
|
|
|
+ >
|
|
|
+ <el-button type="primary" plain>选择凭证附件</el-button>
|
|
|
+ <template #tip>
|
|
|
+ <div class="el-upload__tip">支持 PDF/Word/Excel/PPT/图片,单个文件不超过 50MB</div>
|
|
|
+ </template>
|
|
|
+ </el-upload>
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ <template #footer>
|
|
|
+ <div class="dialog-footer">
|
|
|
+ <el-button @click="createCaseDialog.visible = false">取 消</el-button>
|
|
|
+ <el-button type="primary" :loading="createCaseDialog.submitting" @click="submitCreateCase">发起案件</el-button>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+
|
|
|
+ <!-- ⑨ 客诉处理详情弹窗(7 节点步骤条 + 当前节点处理 + 历史节点时间线) -->
|
|
|
+ <el-dialog
|
|
|
+ v-model="cptDialog.visible"
|
|
|
+ title="客诉处理详情"
|
|
|
+ width="960px"
|
|
|
+ append-to-body
|
|
|
+ :close-on-click-modal="false"
|
|
|
+ @close="closeCptDialog"
|
|
|
+ >
|
|
|
+ <div v-loading="cptDialog.loading">
|
|
|
+ <el-descriptions :column="3" border size="small" class="mb-2">
|
|
|
+ <el-descriptions-item label="客诉编号">{{ cptDetail.complaintCode }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="客诉来源">{{ getDictLabel(complaintSourceDict, cptDetail.source) }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="状态">
|
|
|
+ <el-tag :type="cptDetail.status === '已闭环' ? 'success' : 'warning'">{{ cptDetail.status }}</el-tag>
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="投诉人">{{ cptDetail.complainantName }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="联系电话">{{ cptDetail.complainantPhone }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="涉事门店">{{ cptDetail.storeName }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="移交时间" :span="2">{{ formatDateTime(cptDetail.transferTime) }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="客诉内容" :span="3">{{ cptDetail.complaintContent }}</el-descriptions-item>
|
|
|
+ </el-descriptions>
|
|
|
+
|
|
|
+ <el-steps :active="getCptActive()" finish-status="success" align-center class="mb-2">
|
|
|
+ <el-step v-for="(name, idx) in COMPLAINT_NODE_NAMES" :key="idx" :title="`${idx + 1}. ${name}`" />
|
|
|
+ </el-steps>
|
|
|
+
|
|
|
+ <el-card v-if="cptDetail.status === '处置中'" shadow="never" class="mb-2">
|
|
|
+ <template #header>
|
|
|
+ <span class="section-title">当前节点处理 · {{ cptDetail.nodeName }}</span>
|
|
|
+ </template>
|
|
|
+ <el-form ref="processCptFormRef" :model="processCptForm" :rules="processCptRules" label-width="100px">
|
|
|
+ <el-form-item label="处理意见" prop="opinion">
|
|
|
+ <el-input
|
|
|
+ v-model="processCptForm.opinion"
|
|
|
+ type="textarea"
|
|
|
+ :rows="3"
|
|
|
+ placeholder="请输入当前环节处理意见"
|
|
|
+ maxlength="2000"
|
|
|
+ show-word-limit
|
|
|
+ />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="凭证附件">
|
|
|
+ <el-upload
|
|
|
+ v-model:file-list="cptFileList"
|
|
|
+ :http-request="customUpload"
|
|
|
+ :limit="1"
|
|
|
+ :accept="'.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png'"
|
|
|
+ :before-upload="beforeAttachmentUpload"
|
|
|
+ :on-success="handleCptAttachmentSuccess"
|
|
|
+ :on-error="handleUploadError"
|
|
|
+ :on-exceed="handleAttachmentExceed"
|
|
|
+ :on-remove="handleCptAttachmentRemove"
|
|
|
+ >
|
|
|
+ <el-button type="primary" plain>选择凭证附件</el-button>
|
|
|
+ <template #tip>
|
|
|
+ <div class="el-upload__tip">支持 PDF/Word/Excel/PPT/图片,单个文件不超过 50MB</div>
|
|
|
+ </template>
|
|
|
+ </el-upload>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item>
|
|
|
+ <el-button type="primary" :loading="cptDialog.processing" @click="submitProcessComplaint">提交并流转下一环节</el-button>
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ </el-card>
|
|
|
+
|
|
|
+ <el-card shadow="never">
|
|
|
+ <template #header>
|
|
|
+ <span class="section-title">历史节点记录</span>
|
|
|
+ </template>
|
|
|
+ <el-empty v-if="!cptDetail.nodeRecords || cptDetail.nodeRecords.length === 0" description="暂无节点记录" :image-size="60" />
|
|
|
+ <el-timeline v-else>
|
|
|
+ <el-timeline-item
|
|
|
+ v-for="n in cptDetail.nodeRecords"
|
|
|
+ :key="n.id"
|
|
|
+ :timestamp="formatDateTime(n.handleTime)"
|
|
|
+ placement="top"
|
|
|
+ :type="n.nodeCode === cptDetail.currentNode ? 'primary' : 'success'"
|
|
|
+ >
|
|
|
+ <div class="timeline-node">
|
|
|
+ <div class="timeline-title">节点{{ n.nodeCode }} · {{ n.nodeName }}</div>
|
|
|
+ <div class="timeline-meta">处理人:{{ n.handlerName || '—' }}</div>
|
|
|
+ <div class="timeline-opinion">{{ n.opinion }}</div>
|
|
|
+ <div v-if="n.attachmentUrl" class="timeline-attach">
|
|
|
+ <el-link :href="n.attachmentUrl" target="_blank" type="primary">{{ n.attachmentName || '查看附件' }}</el-link>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-timeline-item>
|
|
|
+ </el-timeline>
|
|
|
+ </el-card>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <template #footer>
|
|
|
+ <div class="dialog-footer">
|
|
|
+ <el-button @click="cptDialog.visible = false">关 闭</el-button>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+
|
|
|
+ <!-- ⑩ 新增客诉弹窗 -->
|
|
|
+ <el-dialog
|
|
|
+ v-model="addCptDialog.visible"
|
|
|
+ title="新增客诉"
|
|
|
+ width="720px"
|
|
|
+ append-to-body
|
|
|
+ :close-on-click-modal="false"
|
|
|
+ @close="closeAddCptDialog"
|
|
|
+ >
|
|
|
+ <el-form ref="addCptFormRef" :model="addCptForm" :rules="addCptRules" label-width="100px">
|
|
|
+ <el-row :gutter="20">
|
|
|
+ <el-col :span="12">
|
|
|
+ <el-form-item label="客诉来源" prop="source">
|
|
|
+ <el-select v-model="addCptForm.source" placeholder="请选择客诉来源" style="width: 100%">
|
|
|
+ <el-option
|
|
|
+ v-for="d in complaintSourceDict"
|
|
|
+ :key="d.value"
|
|
|
+ :label="d.label"
|
|
|
+ :value="d.value"
|
|
|
+ />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ </el-col>
|
|
|
+ <el-col :span="12">
|
|
|
+ <el-form-item label="移交时间" prop="transferTime">
|
|
|
+ <el-date-picker
|
|
|
+ v-model="addCptForm.transferTime"
|
|
|
+ type="datetime"
|
|
|
+ placeholder="请选择移交时间"
|
|
|
+ value-format="YYYY-MM-DD HH:mm:ss"
|
|
|
+ format="YYYY-MM-DD HH:mm"
|
|
|
+ style="width: 100%"
|
|
|
+ />
|
|
|
+ </el-form-item>
|
|
|
+ </el-col>
|
|
|
+ </el-row>
|
|
|
+ <el-row :gutter="20">
|
|
|
+ <el-col :span="12">
|
|
|
+ <el-form-item label="投诉人姓名" prop="complainantName">
|
|
|
+ <el-input v-model="addCptForm.complainantName" placeholder="请输入投诉人姓名" maxlength="50" />
|
|
|
+ </el-form-item>
|
|
|
+ </el-col>
|
|
|
+ <el-col :span="12">
|
|
|
+ <el-form-item label="联系电话" prop="complainantPhone">
|
|
|
+ <el-input v-model="addCptForm.complainantPhone" placeholder="请输入联系电话" maxlength="20" />
|
|
|
+ </el-form-item>
|
|
|
+ </el-col>
|
|
|
+ </el-row>
|
|
|
+ <el-form-item label="涉事门店" prop="storeName">
|
|
|
+ <el-input v-model="addCptForm.storeName" placeholder="请输入涉事门店名称" maxlength="200" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="客诉内容" prop="complaintContent">
|
|
|
+ <el-input v-model="addCptForm.complaintContent" type="textarea" :rows="4" placeholder="请输入客诉内容" maxlength="2000" show-word-limit />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="备注" prop="remark">
|
|
|
+ <el-input v-model="addCptForm.remark" placeholder="备注(选填)" maxlength="500" />
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ <template #footer>
|
|
|
+ <div class="dialog-footer">
|
|
|
+ <el-button @click="addCptDialog.visible = false">取 消</el-button>
|
|
|
+ <el-button type="primary" :loading="addCptDialog.submitting" @click="submitAddComplaint">保 存</el-button>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup name="Supervision" lang="ts">
|
|
|
+import { ref, reactive, onMounted, getCurrentInstance } from 'vue';
|
|
|
+import type { ComponentInternalInstance } from 'vue';
|
|
|
+import {
|
|
|
+ listInspection,
|
|
|
+ getInspection,
|
|
|
+ addInspection,
|
|
|
+ auditInspection as apiAuditInspection
|
|
|
+} from '@/api/risk/supervisionInspection';
|
|
|
+import {
|
|
|
+ listCase,
|
|
|
+ getCase,
|
|
|
+ addCase,
|
|
|
+ processCase
|
|
|
+} from '@/api/risk/supervisionCase';
|
|
|
+import {
|
|
|
+ listComplaint,
|
|
|
+ getComplaint,
|
|
|
+ addComplaint,
|
|
|
+ processComplaint
|
|
|
+} from '@/api/risk/supervisionComplaint';
|
|
|
+import { getSupervisionStats } from '@/api/risk/supervision';
|
|
|
+import { uploadFile } from '@/api/risk/file';
|
|
|
+import { getDicts } from '@/api/system/dict/data';
|
|
|
+import type { DictDataVO } from '@/api/system/dict/data/types';
|
|
|
+import type {
|
|
|
+ RiskSupervisionInspectionBo,
|
|
|
+ RiskSupervisionInspectionVo,
|
|
|
+ RiskSupervisionCaseBo,
|
|
|
+ RiskSupervisionCaseVo,
|
|
|
+ RiskSupervisionComplaintBo,
|
|
|
+ RiskSupervisionComplaintVo,
|
|
|
+ SupervisionStatsVo
|
|
|
+} from '@/api/risk/types';
|
|
|
+import { ElMessage, FormInstance, UploadUserFile } from 'element-plus';
|
|
|
+import {
|
|
|
+ Document,
|
|
|
+ Warning,
|
|
|
+ Phone,
|
|
|
+ DataAnalysis,
|
|
|
+ Plus
|
|
|
+} from '@element-plus/icons-vue';
|
|
|
+
|
|
|
+const { proxy } = getCurrentInstance() as ComponentInternalInstance;
|
|
|
+
|
|
|
+// ============ 常量 ============
|
|
|
+const CASE_NODE_NAMES = ['风险控制', '异议复检', '调查取证', '调查报告', '闭环管理'];
|
|
|
+const COMPLAINT_NODE_NAMES = ['风险控制', '调查取证', '调查判断', '部门协同', '依规处理', '溯源整改', '闭环管理'];
|
|
|
+
|
|
|
+// ============ 字典 ============
|
|
|
+const inspectionResultDict = ref<Array<{ label: string; value: string }>>([]);
|
|
|
+const inspectionStatusDict = ref<Array<{ label: string; value: string }>>([]);
|
|
|
+const riskLevelDict = ref<Array<{ label: string; value: string }>>([]);
|
|
|
+const complaintSourceDict = ref<Array<{ label: string; value: string }>>([]);
|
|
|
+const caseStatusDict = ref<Array<{ label: string; value: string }>>([]);
|
|
|
+const complaintStatusDict = ref<Array<{ label: string; value: string }>>([]);
|
|
|
+
|
|
|
+const mapDictData = (list: DictDataVO[]) =>
|
|
|
+ (list || [])
|
|
|
+ .filter((d: any) => d.status === '0' || d.status === undefined)
|
|
|
+ .sort((a, b) => (a.dictSort || 0) - (b.dictSort || 0))
|
|
|
+ .map((d) => ({ label: d.dictLabel, value: d.dictValue }));
|
|
|
+
|
|
|
+const loadDicts = async () => {
|
|
|
+ try {
|
|
|
+ const [
|
|
|
+ resultRes,
|
|
|
+ statusRes,
|
|
|
+ levelRes,
|
|
|
+ sourceRes,
|
|
|
+ caseStRes,
|
|
|
+ cptStRes
|
|
|
+ ] = await Promise.all([
|
|
|
+ getDicts('risk_supervision_inspection_result'),
|
|
|
+ getDicts('risk_supervision_inspection_status'),
|
|
|
+ getDicts('risk_supervision_risk_level'),
|
|
|
+ getDicts('risk_supervision_complaint_source'),
|
|
|
+ getDicts('risk_supervision_case_status'),
|
|
|
+ getDicts('risk_supervision_complaint_status')
|
|
|
+ ]);
|
|
|
+ inspectionResultDict.value = mapDictData(resultRes.data || []);
|
|
|
+ inspectionStatusDict.value = mapDictData(statusRes.data || []);
|
|
|
+ riskLevelDict.value = mapDictData(levelRes.data || []);
|
|
|
+ complaintSourceDict.value = mapDictData(sourceRes.data || []);
|
|
|
+ caseStatusDict.value = mapDictData(caseStRes.data || []);
|
|
|
+ complaintStatusDict.value = mapDictData(cptStRes.data || []);
|
|
|
+ } catch (e) {
|
|
|
+ console.error('加载监督抽检字典失败', e);
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const getDictLabel = (opts: Array<{ label: string; value: string }>, val: string) => {
|
|
|
+ if (!val) return '';
|
|
|
+ const found = opts.find((o) => o.value === val);
|
|
|
+ return found?.label || val;
|
|
|
+};
|
|
|
+
|
|
|
+// ============ 顶部看板 ============
|
|
|
+const stats = ref<SupervisionStatsVo>({
|
|
|
+ todayPending: 0,
|
|
|
+ pendingCases: 0,
|
|
|
+ pendingComplaints: 0,
|
|
|
+ monthPassRate: { month: '', passCount: 0, totalCount: 0, passRate: 0 }
|
|
|
+});
|
|
|
+
|
|
|
+const loadStats = async () => {
|
|
|
+ try {
|
|
|
+ const res: any = await getSupervisionStats();
|
|
|
+ if (res?.data) {
|
|
|
+ stats.value = { ...stats.value, ...res.data };
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('加载看板失败', e);
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// ============ 抽检单列表 ============
|
|
|
+const inspectionList = ref<RiskSupervisionInspectionVo[]>([]);
|
|
|
+const inspTotal = ref(0);
|
|
|
+const inspLoading = ref(false);
|
|
|
+const inspQuery = ref({
|
|
|
+ pageNum: 1,
|
|
|
+ pageSize: 10,
|
|
|
+ keyword: '',
|
|
|
+ inspectionResult: '',
|
|
|
+ status: ''
|
|
|
+});
|
|
|
+
|
|
|
+const getInspectionList = async () => {
|
|
|
+ inspLoading.value = true;
|
|
|
+ try {
|
|
|
+ const res: any = await listInspection(inspQuery.value);
|
|
|
+ inspectionList.value = res.rows || [];
|
|
|
+ inspTotal.value = res.total || 0;
|
|
|
+ } finally {
|
|
|
+ inspLoading.value = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const handleInspQuery = () => {
|
|
|
+ inspQuery.value.pageNum = 1;
|
|
|
+ getInspectionList();
|
|
|
+};
|
|
|
+
|
|
|
+const resetInspQuery = () => {
|
|
|
+ inspQuery.value = {
|
|
|
+ pageNum: 1,
|
|
|
+ pageSize: 10,
|
|
|
+ keyword: '',
|
|
|
+ inspectionResult: '',
|
|
|
+ status: ''
|
|
|
+ };
|
|
|
+ getInspectionList();
|
|
|
+};
|
|
|
+
|
|
|
+// ============ 案件列表 ============
|
|
|
+const caseList = ref<RiskSupervisionCaseVo[]>([]);
|
|
|
+const caseTotal = ref(0);
|
|
|
+const caseLoading = ref(false);
|
|
|
+const caseQuery = ref({
|
|
|
+ pageNum: 1,
|
|
|
+ pageSize: 10,
|
|
|
+ keyword: '',
|
|
|
+ status: ''
|
|
|
+});
|
|
|
+
|
|
|
+const getCaseList = async () => {
|
|
|
+ caseLoading.value = true;
|
|
|
+ try {
|
|
|
+ const res: any = await listCase(caseQuery.value);
|
|
|
+ caseList.value = res.rows || [];
|
|
|
+ caseTotal.value = res.total || 0;
|
|
|
+ } finally {
|
|
|
+ caseLoading.value = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const handleCaseQuery = () => {
|
|
|
+ caseQuery.value.pageNum = 1;
|
|
|
+ getCaseList();
|
|
|
+};
|
|
|
+
|
|
|
+const resetCaseQuery = () => {
|
|
|
+ caseQuery.value = { pageNum: 1, pageSize: 10, keyword: '', status: '' };
|
|
|
+ getCaseList();
|
|
|
+};
|
|
|
+
|
|
|
+// ============ 客诉列表 ============
|
|
|
+const complaintList = ref<RiskSupervisionComplaintVo[]>([]);
|
|
|
+const cptTotal = ref(0);
|
|
|
+const cptLoading = ref(false);
|
|
|
+const cptQuery = ref({
|
|
|
+ pageNum: 1,
|
|
|
+ pageSize: 10,
|
|
|
+ keyword: '',
|
|
|
+ status: ''
|
|
|
+});
|
|
|
+
|
|
|
+const getComplaintList = async () => {
|
|
|
+ cptLoading.value = true;
|
|
|
+ try {
|
|
|
+ const res: any = await listComplaint(cptQuery.value);
|
|
|
+ complaintList.value = res.rows || [];
|
|
|
+ cptTotal.value = res.total || 0;
|
|
|
+ } finally {
|
|
|
+ cptLoading.value = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const handleCptQuery = () => {
|
|
|
+ cptQuery.value.pageNum = 1;
|
|
|
+ getComplaintList();
|
|
|
+};
|
|
|
+
|
|
|
+const resetCptQuery = () => {
|
|
|
+ cptQuery.value = { pageNum: 1, pageSize: 10, keyword: '', status: '' };
|
|
|
+ getComplaintList();
|
|
|
+};
|
|
|
+
|
|
|
+// ============ 新增抽检单 ============
|
|
|
+const inspDialog = reactive({ visible: false, title: '新增抽检单' });
|
|
|
+const inspFormRef = ref<FormInstance>();
|
|
|
+const inspForm = ref<RiskSupervisionInspectionBo>({
|
|
|
+ id: undefined,
|
|
|
+ inspectionCode: '',
|
|
|
+ storeName: '',
|
|
|
+ inspectionTime: '',
|
|
|
+ productName: '',
|
|
|
+ inspectionResult: '抽检合格',
|
|
|
+ failReason: '',
|
|
|
+ inspectorName: '',
|
|
|
+ remark: ''
|
|
|
+});
|
|
|
+const inspRules = {
|
|
|
+ storeName: [{ required: true, message: '请输入门店名称', trigger: 'blur' }],
|
|
|
+ inspectionTime: [{ required: true, message: '请选择抽检时间', trigger: 'change' }],
|
|
|
+ productName: [{ required: true, message: '请输入抽检商品', trigger: 'blur' }],
|
|
|
+ inspectionResult: [{ required: true, message: '请选择抽检结果', trigger: 'change' }],
|
|
|
+ failReason: [{
|
|
|
+ validator(_: any, val: any, cb: any) {
|
|
|
+ if (inspForm.value.inspectionResult === '抽检不合格' && !val) {
|
|
|
+ cb(new Error('请输入不合格原因'));
|
|
|
+ } else {
|
|
|
+ cb();
|
|
|
+ }
|
|
|
+ },
|
|
|
+ trigger: 'blur'
|
|
|
+ }],
|
|
|
+ inspectorName: [{ required: true, message: '请输入抽检人姓名', trigger: 'blur' }]
|
|
|
+};
|
|
|
+
|
|
|
+const resetInspForm = () => {
|
|
|
+ inspForm.value = {
|
|
|
+ id: undefined,
|
|
|
+ inspectionCode: '',
|
|
|
+ storeName: '',
|
|
|
+ inspectionTime: '',
|
|
|
+ productName: '',
|
|
|
+ inspectionResult: '抽检合格',
|
|
|
+ failReason: '',
|
|
|
+ inspectorName: '',
|
|
|
+ remark: ''
|
|
|
+ };
|
|
|
+ inspFormRef.value?.resetFields();
|
|
|
+};
|
|
|
+
|
|
|
+const handleAddInspection = () => {
|
|
|
+ resetInspForm();
|
|
|
+ inspDialog.title = '新增抽检单';
|
|
|
+ inspDialog.visible = true;
|
|
|
+};
|
|
|
+
|
|
|
+const closeInspDialog = () => {
|
|
|
+ inspDialog.visible = false;
|
|
|
+ resetInspForm();
|
|
|
+};
|
|
|
+
|
|
|
+const submitInspForm = () => {
|
|
|
+ inspFormRef.value?.validate(async (valid: boolean) => {
|
|
|
+ if (!valid) return;
|
|
|
+ try {
|
|
|
+ await addInspection(inspForm.value);
|
|
|
+ ElMessage.success('新增成功');
|
|
|
+ inspDialog.visible = false;
|
|
|
+ await getInspectionList();
|
|
|
+ await loadStats();
|
|
|
+ } catch (e: any) {
|
|
|
+ ElMessage.error(e?.msg || '新增失败');
|
|
|
+ }
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+// ============ 抽检详情(只读) ============
|
|
|
+const inspDetailDialog = reactive({ visible: false });
|
|
|
+const inspDetail = ref<RiskSupervisionInspectionVo>({} as any);
|
|
|
+
|
|
|
+const viewInspection = async (row: RiskSupervisionInspectionVo) => {
|
|
|
+ try {
|
|
|
+ const res: any = await getInspection(row.id);
|
|
|
+ inspDetail.value = res.data || {};
|
|
|
+ inspDetailDialog.visible = true;
|
|
|
+ } catch (e) {
|
|
|
+ ElMessage.error('获取抽检单详情失败');
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// ============ 审核抽检单 ============
|
|
|
+const auditInspection = async (row: RiskSupervisionInspectionVo) => {
|
|
|
+ try {
|
|
|
+ await proxy?.$modal.confirm(`确认审核抽检单 [${row.inspectionCode}] 吗?`);
|
|
|
+ await apiAuditInspection(row.id);
|
|
|
+ ElMessage.success('审核成功');
|
|
|
+ await getInspectionList();
|
|
|
+ await loadStats();
|
|
|
+ } catch (e) {
|
|
|
+ // 取消
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// ============ 案件处理详情弹窗 ============
|
|
|
+const caseDialog = reactive({ visible: false, title: '案件处理详情', loading: false, processing: false });
|
|
|
+const caseDetail = ref<RiskSupervisionCaseVo>({} as any);
|
|
|
+const processCaseFormRef = ref<FormInstance>();
|
|
|
+const processCaseForm = ref<RiskSupervisionCaseBo>({
|
|
|
+ id: undefined,
|
|
|
+ opinion: '',
|
|
|
+ attachmentUrl: '',
|
|
|
+ attachmentName: ''
|
|
|
+});
|
|
|
+const processCaseRules = {
|
|
|
+ opinion: [{ required: true, message: '请输入处理意见', trigger: 'blur' }]
|
|
|
+};
|
|
|
+
|
|
|
+const viewCaseDetail = async (row: RiskSupervisionCaseVo) => {
|
|
|
+ caseDialog.title = '案件处理详情';
|
|
|
+ caseDialog.visible = true;
|
|
|
+ caseDialog.loading = true;
|
|
|
+ try {
|
|
|
+ const res: any = await getCase(row.id);
|
|
|
+ caseDetail.value = res.data || {};
|
|
|
+ resetProcessCaseForm();
|
|
|
+ } finally {
|
|
|
+ caseDialog.loading = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const openProcessCase = async (row: RiskSupervisionCaseVo) => {
|
|
|
+ caseDialog.title = `案件处理 · 节点${row.currentNode} · ${row.nodeName}`;
|
|
|
+ caseDialog.visible = true;
|
|
|
+ caseDialog.loading = true;
|
|
|
+ try {
|
|
|
+ const res: any = await getCase(row.id);
|
|
|
+ caseDetail.value = res.data || {};
|
|
|
+ resetProcessCaseForm();
|
|
|
+ } finally {
|
|
|
+ caseDialog.loading = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const resetProcessCaseForm = () => {
|
|
|
+ processCaseForm.value = { id: caseDetail.value?.id, opinion: '', attachmentUrl: '', attachmentName: '' };
|
|
|
+ caseFileList.value = [];
|
|
|
+ processCaseFormRef.value?.resetFields();
|
|
|
+};
|
|
|
+
|
|
|
+const getCaseActive = (): number => {
|
|
|
+ // 已闭环:全部完成 → 5;处置中:currentNode - 1 (el-steps active 是已完成数)
|
|
|
+ if (!caseDetail.value) return 0;
|
|
|
+ if (caseDetail.value.status === '已闭环') return CASE_NODE_NAMES.length;
|
|
|
+ return (caseDetail.value.currentNode || 1) - 1;
|
|
|
+};
|
|
|
+
|
|
|
+const canProcessCase = (row: RiskSupervisionCaseVo): boolean => {
|
|
|
+ if (row.status !== '处置中') return false;
|
|
|
+ // 异议复检 / 闭环管理 节点不展示处理按钮(异议复检为流程节点, 闭环管理为已完成)
|
|
|
+ const noProcessNodes = ['异议复检', '闭环管理'];
|
|
|
+ return !noProcessNodes.includes(row.nodeName || '');
|
|
|
+};
|
|
|
+
|
|
|
+const closeCaseDialog = () => {
|
|
|
+ caseDialog.visible = false;
|
|
|
+ caseDetail.value = {} as any;
|
|
|
+ caseFileList.value = [];
|
|
|
+};
|
|
|
+
|
|
|
+const submitProcessCase = () => {
|
|
|
+ processCaseFormRef.value?.validate(async (valid: boolean) => {
|
|
|
+ if (!valid) return;
|
|
|
+ caseDialog.processing = true;
|
|
|
+ try {
|
|
|
+ processCaseForm.value.id = caseDetail.value.id;
|
|
|
+ await processCase(processCaseForm.value);
|
|
|
+ ElMessage.success('节点处理成功,已流转下一节点');
|
|
|
+ caseDialog.processing = false;
|
|
|
+ // 刷新详情
|
|
|
+ const res: any = await getCase(caseDetail.value.id);
|
|
|
+ caseDetail.value = res.data || {};
|
|
|
+ caseFileList.value = [];
|
|
|
+ processCaseForm.value = { id: caseDetail.value.id, opinion: '', attachmentUrl: '', attachmentName: '' };
|
|
|
+ await getCaseList();
|
|
|
+ await loadStats();
|
|
|
+ } catch (e: any) {
|
|
|
+ caseDialog.processing = false;
|
|
|
+ ElMessage.error(e?.msg || '节点处理失败');
|
|
|
+ }
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+// ============ 从不合格抽检单发起案件 ============
|
|
|
+const createCaseDialog = reactive({ visible: false, submitting: false });
|
|
|
+const createCaseFormRef = ref<FormInstance>();
|
|
|
+const createCaseForm = ref<RiskSupervisionCaseBo>({
|
|
|
+ inspectionId: undefined,
|
|
|
+ inspectionCode: '',
|
|
|
+ storeName: '',
|
|
|
+ productName: '',
|
|
|
+ riskLevel: '',
|
|
|
+ riskDesc: '',
|
|
|
+ controlMeasures: '',
|
|
|
+ handlerName: '',
|
|
|
+ opinion: '',
|
|
|
+ attachmentUrl: '',
|
|
|
+ attachmentName: ''
|
|
|
+});
|
|
|
+const createCaseRules = {
|
|
|
+ riskLevel: [{ required: true, message: '请选择风险等级', trigger: 'change' }],
|
|
|
+ riskDesc: [{ required: true, message: '请输入风险描述', trigger: 'blur' }],
|
|
|
+ controlMeasures: [{ required: true, message: '请输入控制措施', trigger: 'blur' }],
|
|
|
+ handlerName: [{ required: true, message: '请输入处理人姓名', trigger: 'blur' }],
|
|
|
+ opinion: [{ required: true, message: '请输入风险控制节点处理意见', trigger: 'blur' }]
|
|
|
+};
|
|
|
+
|
|
|
+const openCreateCaseFromInspection = async (row: RiskSupervisionInspectionVo) => {
|
|
|
+ try {
|
|
|
+ const res: any = await getInspection(row.id);
|
|
|
+ const data = res.data || {};
|
|
|
+ createCaseForm.value = {
|
|
|
+ inspectionId: data.id,
|
|
|
+ inspectionCode: data.inspectionCode,
|
|
|
+ storeName: data.storeName,
|
|
|
+ productName: data.productName,
|
|
|
+ riskLevel: '',
|
|
|
+ riskDesc: '',
|
|
|
+ controlMeasures: '',
|
|
|
+ handlerName: '',
|
|
|
+ opinion: '',
|
|
|
+ attachmentUrl: '',
|
|
|
+ attachmentName: ''
|
|
|
+ };
|
|
|
+ caseFileList.value = [];
|
|
|
+ createCaseDialog.visible = true;
|
|
|
+ } catch (e) {
|
|
|
+ ElMessage.error('加载抽检单失败');
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const submitCreateCase = () => {
|
|
|
+ createCaseFormRef.value?.validate(async (valid: boolean) => {
|
|
|
+ if (!valid) return;
|
|
|
+ createCaseDialog.submitting = true;
|
|
|
+ try {
|
|
|
+ await addCase(createCaseForm.value);
|
|
|
+ ElMessage.success('案件已发起,初始节点:风险控制');
|
|
|
+ createCaseDialog.submitting = false;
|
|
|
+ createCaseDialog.visible = false;
|
|
|
+ caseFileList.value = [];
|
|
|
+ await getCaseList();
|
|
|
+ await getInspectionList();
|
|
|
+ await loadStats();
|
|
|
+ } catch (e: any) {
|
|
|
+ createCaseDialog.submitting = false;
|
|
|
+ ElMessage.error(e?.msg || '案件发起失败');
|
|
|
+ }
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+// ============ 客诉处理详情 ============
|
|
|
+const cptDialog = reactive({ visible: false, loading: false, processing: false });
|
|
|
+const cptDetail = ref<RiskSupervisionComplaintVo>({} as any);
|
|
|
+const processCptFormRef = ref<FormInstance>();
|
|
|
+const processCptForm = ref<RiskSupervisionComplaintBo>({
|
|
|
+ id: undefined,
|
|
|
+ opinion: '',
|
|
|
+ processAttachmentUrl: '',
|
|
|
+ processAttachmentName: ''
|
|
|
+});
|
|
|
+const processCptRules = {
|
|
|
+ opinion: [{ required: true, message: '请输入处理意见', trigger: 'blur' }]
|
|
|
+};
|
|
|
+
|
|
|
+const viewComplaintDetail = async (row: RiskSupervisionComplaintVo) => {
|
|
|
+ cptDialog.visible = true;
|
|
|
+ cptDialog.loading = true;
|
|
|
+ try {
|
|
|
+ const res: any = await getComplaint(row.id);
|
|
|
+ cptDetail.value = res.data || {};
|
|
|
+ resetProcessCptForm();
|
|
|
+ } finally {
|
|
|
+ cptDialog.loading = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const openProcessComplaint = async (row: RiskSupervisionComplaintVo) => {
|
|
|
+ cptDialog.visible = true;
|
|
|
+ cptDialog.loading = true;
|
|
|
+ try {
|
|
|
+ const res: any = await getComplaint(row.id);
|
|
|
+ cptDetail.value = res.data || {};
|
|
|
+ resetProcessCptForm();
|
|
|
+ } finally {
|
|
|
+ cptDialog.loading = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const resetProcessCptForm = () => {
|
|
|
+ processCptForm.value = {
|
|
|
+ id: cptDetail.value?.id,
|
|
|
+ opinion: '',
|
|
|
+ processAttachmentUrl: '',
|
|
|
+ processAttachmentName: ''
|
|
|
+ };
|
|
|
+ cptFileList.value = [];
|
|
|
+ processCptFormRef.value?.resetFields();
|
|
|
+};
|
|
|
+
|
|
|
+const getCptActive = (): number => {
|
|
|
+ if (!cptDetail.value) return 0;
|
|
|
+ if (cptDetail.value.status === '已闭环') return COMPLAINT_NODE_NAMES.length;
|
|
|
+ return (cptDetail.value.currentNode || 1) - 1;
|
|
|
+};
|
|
|
+
|
|
|
+const closeCptDialog = () => {
|
|
|
+ cptDialog.visible = false;
|
|
|
+ cptDetail.value = {} as any;
|
|
|
+ cptFileList.value = [];
|
|
|
+};
|
|
|
+
|
|
|
+const submitProcessComplaint = () => {
|
|
|
+ processCptFormRef.value?.validate(async (valid: boolean) => {
|
|
|
+ if (!valid) return;
|
|
|
+ cptDialog.processing = true;
|
|
|
+ try {
|
|
|
+ processCptForm.value.id = cptDetail.value.id;
|
|
|
+ await processComplaint(processCptForm.value);
|
|
|
+ ElMessage.success('客诉环节处理成功,已流转下一环节');
|
|
|
+ cptDialog.processing = false;
|
|
|
+ const res: any = await getComplaint(cptDetail.value.id);
|
|
|
+ cptDetail.value = res.data || {};
|
|
|
+ cptFileList.value = [];
|
|
|
+ processCptForm.value = {
|
|
|
+ id: cptDetail.value.id,
|
|
|
+ opinion: '',
|
|
|
+ processAttachmentUrl: '',
|
|
|
+ processAttachmentName: ''
|
|
|
+ };
|
|
|
+ await getComplaintList();
|
|
|
+ await loadStats();
|
|
|
+ } catch (e: any) {
|
|
|
+ cptDialog.processing = false;
|
|
|
+ ElMessage.error(e?.msg || '客诉节点处理失败');
|
|
|
+ }
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+// ============ 新增客诉 ============
|
|
|
+const addCptDialog = reactive({ visible: false, submitting: false });
|
|
|
+const addCptFormRef = ref<FormInstance>();
|
|
|
+const addCptForm = ref<RiskSupervisionComplaintBo>({
|
|
|
+ source: '',
|
|
|
+ complainantName: '',
|
|
|
+ complainantPhone: '',
|
|
|
+ storeName: '',
|
|
|
+ transferTime: '',
|
|
|
+ complaintContent: '',
|
|
|
+ remark: ''
|
|
|
+});
|
|
|
+const addCptRules = {
|
|
|
+ source: [{ required: true, message: '请选择客诉来源', trigger: 'change' }],
|
|
|
+ complainantName: [{ required: true, message: '请输入投诉人姓名', trigger: 'blur' }],
|
|
|
+ storeName: [{ required: true, message: '请输入涉事门店', trigger: 'blur' }],
|
|
|
+ complaintContent: [{ required: true, message: '请输入客诉内容', trigger: 'blur' }]
|
|
|
+};
|
|
|
+
|
|
|
+const resetAddCptForm = () => {
|
|
|
+ addCptForm.value = {
|
|
|
+ source: '',
|
|
|
+ complainantName: '',
|
|
|
+ complainantPhone: '',
|
|
|
+ storeName: '',
|
|
|
+ transferTime: '',
|
|
|
+ complaintContent: '',
|
|
|
+ remark: ''
|
|
|
+ };
|
|
|
+ addCptFormRef.value?.resetFields();
|
|
|
+};
|
|
|
+
|
|
|
+const handleAddComplaint = () => {
|
|
|
+ resetAddCptForm();
|
|
|
+ addCptDialog.visible = true;
|
|
|
+};
|
|
|
+
|
|
|
+const closeAddCptDialog = () => {
|
|
|
+ addCptDialog.visible = false;
|
|
|
+ resetAddCptForm();
|
|
|
+};
|
|
|
+
|
|
|
+const submitAddComplaint = () => {
|
|
|
+ addCptFormRef.value?.validate(async (valid: boolean) => {
|
|
|
+ if (!valid) return;
|
|
|
+ addCptDialog.submitting = true;
|
|
|
+ try {
|
|
|
+ await addComplaint(addCptForm.value);
|
|
|
+ ElMessage.success('新增客诉成功');
|
|
|
+ addCptDialog.submitting = false;
|
|
|
+ addCptDialog.visible = false;
|
|
|
+ await getComplaintList();
|
|
|
+ await loadStats();
|
|
|
+ } catch (e: any) {
|
|
|
+ addCptDialog.submitting = false;
|
|
|
+ ElMessage.error(e?.msg || '新增客诉失败');
|
|
|
+ }
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+// ============ 文件上传 ============
|
|
|
+const ATTACH_EXTS = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'jpg', 'jpeg', 'png'];
|
|
|
+const ATTACH_MAX_SIZE = 50 * 1024 * 1024;
|
|
|
+const caseFileList = ref<UploadUserFile[]>([]);
|
|
|
+const cptFileList = ref<UploadUserFile[]>([]);
|
|
|
+
|
|
|
+const customUpload = async (options: any) => {
|
|
|
+ try {
|
|
|
+ const res: any = await uploadFile(options.file);
|
|
|
+ if (res && res.code === 200 && res.data) {
|
|
|
+ options.file.url = res.data.url;
|
|
|
+ options.onSuccess(res, options.file);
|
|
|
+ } else {
|
|
|
+ options.onError(new Error(res?.msg || '上传失败'));
|
|
|
+ }
|
|
|
+ } catch (err: any) {
|
|
|
+ options.onError(err);
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const beforeAttachmentUpload = (file: any) => {
|
|
|
+ const ext = (file.name.split('.').pop() || '').toLowerCase();
|
|
|
+ if (!ATTACH_EXTS.includes(ext)) {
|
|
|
+ ElMessage.warning(`不支持 .${ext},仅允许: ${ATTACH_EXTS.join('/')}`);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ if (file.size > ATTACH_MAX_SIZE) {
|
|
|
+ ElMessage.warning(`${file.name} 超过 50MB 上限`);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ return true;
|
|
|
+};
|
|
|
+
|
|
|
+const handleAttachmentSuccess = (response: any, file: any) => {
|
|
|
+ if (response && response.code === 200) {
|
|
|
+ ElMessage.success(`${file.name} 上传成功`);
|
|
|
+ const url = response.data?.url || file.url;
|
|
|
+ const name = response.data?.originalName || file.name;
|
|
|
+ if (createCaseDialog.visible) {
|
|
|
+ createCaseForm.value.attachmentUrl = url;
|
|
|
+ createCaseForm.value.attachmentName = name;
|
|
|
+ } else {
|
|
|
+ processCaseForm.value.attachmentUrl = url;
|
|
|
+ processCaseForm.value.attachmentName = name;
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ ElMessage.error(`${file.name} 上传失败: ${response?.msg || '未知错误'}`);
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const handleCptAttachmentSuccess = (response: any, file: any) => {
|
|
|
+ if (response && response.code === 200) {
|
|
|
+ ElMessage.success(`${file.name} 上传成功`);
|
|
|
+ const url = response.data?.url || file.url;
|
|
|
+ const name = response.data?.originalName || file.name;
|
|
|
+ processCptForm.value.processAttachmentUrl = url;
|
|
|
+ processCptForm.value.processAttachmentName = name;
|
|
|
+ } else {
|
|
|
+ ElMessage.error(`${file.name} 上传失败: ${response?.msg || '未知错误'}`);
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const handleUploadError = (error: any) => {
|
|
|
+ console.error('文件上传失败', error);
|
|
|
+ ElMessage.error(`文件上传失败: ${error?.message || '网络异常'}`);
|
|
|
+};
|
|
|
+
|
|
|
+const handleAttachmentExceed = () => {
|
|
|
+ ElMessage.warning('最多只能上传 1 个附件,如需更换请先删除已有附件');
|
|
|
+};
|
|
|
+
|
|
|
+const handleAttachmentRemove = () => {
|
|
|
+ if (createCaseDialog.visible) {
|
|
|
+ createCaseForm.value.attachmentUrl = '';
|
|
|
+ createCaseForm.value.attachmentName = '';
|
|
|
+ } else {
|
|
|
+ processCaseForm.value.attachmentUrl = '';
|
|
|
+ processCaseForm.value.attachmentName = '';
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const handleCptAttachmentRemove = () => {
|
|
|
+ processCptForm.value.processAttachmentUrl = '';
|
|
|
+ processCptForm.value.processAttachmentName = '';
|
|
|
+};
|
|
|
+
|
|
|
+// ============ 工具 ============
|
|
|
+const getRiskLevelTag = (level: string): 'primary' | 'success' | 'warning' | 'info' | 'danger' => {
|
|
|
+ const map: Record<string, 'primary' | 'success' | 'warning' | 'info' | 'danger'> = {
|
|
|
+ 高风险: 'danger',
|
|
|
+ 中风险: 'warning',
|
|
|
+ 低风险: 'info'
|
|
|
+ };
|
|
|
+ return map[level] || 'info';
|
|
|
+};
|
|
|
+
|
|
|
+const formatDateTime = (val: any): string => {
|
|
|
+ if (!val) return '';
|
|
|
+ const d = new Date(val);
|
|
|
+ if (isNaN(d.getTime())) return String(val);
|
|
|
+ const pad = (n: number) => String(n).padStart(2, '0');
|
|
|
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
|
+};
|
|
|
+
|
|
|
+// ============ 生命周期 ============
|
|
|
+onMounted(async () => {
|
|
|
+ await loadDicts();
|
|
|
+ await Promise.all([loadStats(), getInspectionList(), getCaseList(), getComplaintList()]);
|
|
|
+});
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+.supervision-page {
|
|
|
+ padding: 16px;
|
|
|
+ height: 100%;
|
|
|
+ overflow-y: auto;
|
|
|
+ box-sizing: border-box;
|
|
|
+}
|
|
|
+.page-header {
|
|
|
+ margin-bottom: 16px;
|
|
|
+ .page-title {
|
|
|
+ font-size: 20px;
|
|
|
+ font-weight: 600;
|
|
|
+ margin: 0;
|
|
|
+ color: #303133;
|
|
|
+ }
|
|
|
+ .page-desc {
|
|
|
+ font-size: 13px;
|
|
|
+ color: #909399;
|
|
|
+ margin: 4px 0 0;
|
|
|
+ }
|
|
|
+}
|
|
|
+.mb-2 { margin-bottom: 16px; }
|
|
|
+.mr-1 { margin-right: 8px; }
|
|
|
+.section-header {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 12px;
|
|
|
+ flex-wrap: wrap;
|
|
|
+}
|
|
|
+.section-title {
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #303133;
|
|
|
+}
|
|
|
+.filter-bar {
|
|
|
+ margin-bottom: 16px;
|
|
|
+}
|
|
|
+.stat-card {
|
|
|
+ border-radius: 8px;
|
|
|
+ :deep(.el-card__body) { padding: 20px; }
|
|
|
+}
|
|
|
+.stat-card-content {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+}
|
|
|
+.stat-info { flex: 1; min-width: 0; }
|
|
|
+.stat-title {
|
|
|
+ font-size: 13px;
|
|
|
+ color: #909399;
|
|
|
+ margin-bottom: 8px;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+.stat-value {
|
|
|
+ font-size: 26px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: #303133;
|
|
|
+ line-height: 1.2;
|
|
|
+ margin-bottom: 8px;
|
|
|
+ .unit {
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 400;
|
|
|
+ margin-left: 2px;
|
|
|
+ color: #606266;
|
|
|
+ }
|
|
|
+}
|
|
|
+.stat-extra {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 2px;
|
|
|
+}
|
|
|
+.stat-extra-up { color: #67c23a; }
|
|
|
+.stat-extra-warning {
|
|
|
+ color: #e6a23c;
|
|
|
+ font-weight: 600;
|
|
|
+}
|
|
|
+.stat-icon {
|
|
|
+ width: 48px;
|
|
|
+ height: 48px;
|
|
|
+ border-radius: 8px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ font-size: 24px;
|
|
|
+ color: #fff;
|
|
|
+}
|
|
|
+.stat-icon-blue { background: #ecf5ff; color: #409EFF; }
|
|
|
+.stat-icon-red { background: #fef0f0; color: #f56c6c; }
|
|
|
+.stat-icon-orange { background: #fdf6ec; color: #E6A23C; }
|
|
|
+.stat-icon-green { background: #f0f9eb; color: #67C23A; }
|
|
|
+.readonly-text {
|
|
|
+ color: #606266;
|
|
|
+ font-size: 14px;
|
|
|
+}
|
|
|
+.muted { color: #909399; font-size: 12px; }
|
|
|
+.dialog-footer { text-align: right; }
|
|
|
+.el-upload__tip {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
+ line-height: 1.5;
|
|
|
+ margin-top: 4px;
|
|
|
+}
|
|
|
+.timeline-node {
|
|
|
+ .timeline-title {
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #303133;
|
|
|
+ margin-bottom: 4px;
|
|
|
+ }
|
|
|
+ .timeline-meta {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
+ margin-bottom: 6px;
|
|
|
+ }
|
|
|
+ .timeline-opinion {
|
|
|
+ font-size: 13px;
|
|
|
+ color: #606266;
|
|
|
+ line-height: 1.6;
|
|
|
+ white-space: pre-wrap;
|
|
|
+ word-break: break-all;
|
|
|
+ margin-bottom: 6px;
|
|
|
+ }
|
|
|
+ .timeline-attach {
|
|
|
+ font-size: 12px;
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|