chore: establish V1.1 recovery baseline
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="4" v-for="s in stats" :key="s.key">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-value">{{ s.value }}</div>
|
||||
<div class="stat-label">{{ s.label }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card shadow="never" style="margin-top:16px">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>申诉管理</span>
|
||||
<el-tag type="warning" v-if="stats.find(s=>s.key==='pending')?.value">待处理 {{ stats.find(s=>s.key==='pending')?.value }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="tableData" v-loading="loading" border stripe>
|
||||
<el-table-column prop="userName" label="用户" width="120" />
|
||||
<el-table-column prop="appealTypeText" label="申诉类型" width="120" />
|
||||
<el-table-column prop="reason" label="申诉原因" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="contactMethod" label="联系方式" width="140" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{row}">
|
||||
<el-tag :type="statusTagType(row.status)">{{ statusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="提交时间" width="180">
|
||||
<template #default="{row}">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{row}">
|
||||
<el-button v-if="row.status==='pending'" type="primary" size="small" @click="openDetail(row.id)">处理</el-button>
|
||||
<el-button size="small" @click="openDetail(row.id)">查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 申诉详情抽屉 -->
|
||||
<el-drawer v-model="drawerVisible" title="申诉详情" size="520px">
|
||||
<div v-if="detail">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="用户">{{ detail.userName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="申诉类型">{{ detail.appealTypeText }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="statusTagType(detail.status)">{{ statusText(detail.status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="联系方式">{{ detail.contactMethod || '无' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="申诉原因" :span="2">{{ detail.reason }}</el-descriptions-item>
|
||||
<el-descriptions-item label="证据" :span="2">
|
||||
<span v-if="detail.evidence">见原始证据文件</span>
|
||||
<span v-else class="text-muted">未上传</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="处置备注" :span="2" v-if="detail.decisionNote">
|
||||
{{ detail.decisionNote }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<template v-if="detail.status==='pending'||detail.status==='reviewing'">
|
||||
<h4 style="margin:20px 0 12px;color:#E96B6B">审核决策</h4>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="处理结果">
|
||||
<el-select v-model="decision" style="width:100%">
|
||||
<el-option label="通过申诉,解除限制" value="approve" />
|
||||
<el-option label="驳回申诉,维持原处置" value="reject" />
|
||||
<el-option label="升级人工进一步调查" value="escalate" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="审核说明">
|
||||
<el-input v-model="decisionNote" type="textarea" :rows="3" placeholder="请输入审核意见" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="processing" @click="handleProcess">确认提交</el-button>
|
||||
<el-button @click="drawerVisible=false">取消</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get, post } from '@/api/client'
|
||||
|
||||
const loading = ref(false)
|
||||
const processing = ref(false)
|
||||
const tableData = ref<any[]>([])
|
||||
const stats = ref<any[]>([])
|
||||
const drawerVisible = ref(false)
|
||||
const currentId = ref('')
|
||||
const detail = ref<any>(null)
|
||||
const decision = ref('approve')
|
||||
const decisionNote = ref('')
|
||||
|
||||
const STATUS_MAP: Record<string, { text: string; type: string }> = {
|
||||
pending: { text: '待审核', type: 'warning' },
|
||||
reviewing: { text: '审核中', type: 'info' },
|
||||
approved: { text: '已通过', type: 'success' },
|
||||
rejected: { text: '已驳回', type: 'danger' },
|
||||
escalated: { text: '已升级', type: 'warning' }
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const res: any = await get('/operator/appeals/pending', {}, { Authorization: `Bearer ${token}` })
|
||||
tableData.value = res || []
|
||||
loadStats()
|
||||
} catch (e) { console.error(e) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const res: any = await get('/operator/appeals/stats', {}, { Authorization: `Bearer ${token}` })
|
||||
stats.value = [
|
||||
{ key: 'pending', label: '待审核', value: res.pendingCount || 0, color: '#E96B6B' },
|
||||
{ key: 'reviewing', label: '审核中', value: res.reviewingCount || 0, color: '#E59A32' },
|
||||
{ key: 'approved', label: '已通过', value: res.approvedCount || 0, color: '#67C23A' },
|
||||
{ key: 'rejected', label: '已驳回', value: res.rejectedCount || 0, color: '#909399' },
|
||||
{ key: 'escalated', label: '已升级', value: res.escalatedCount || 0, color: '#F56C6C' }
|
||||
]
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function openDetail(id: string) {
|
||||
currentId.value = id
|
||||
drawerVisible.value = true
|
||||
decision.value = 'approve'
|
||||
decisionNote.value = ''
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const res: any = await get(`/operator/appeals/${id}`, {}, { Authorization: `Bearer ${token}` })
|
||||
detail.value = res
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function handleProcess() {
|
||||
if (!decision.value) return
|
||||
processing.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
await post(`/operator/appeals/${currentId.value}/process`,
|
||||
{ decision: decision.value, decisionNote: decisionNote.value },
|
||||
{ Authorization: `Bearer ${token}` })
|
||||
drawerVisible.value = false
|
||||
detail.value = null
|
||||
loadData()
|
||||
} catch (e) { console.error(e) }
|
||||
finally { processing.value = false }
|
||||
}
|
||||
|
||||
function statusTagType(s: string) { return STATUS_MAP[s]?.type || 'info' }
|
||||
function statusText(s: string) { return STATUS_MAP[s]?.text || s }
|
||||
function formatTime(t?: string) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container { padding: 24px; }
|
||||
.stat-card { text-align: center; }
|
||||
.stat-value { font-size: 32px; font-weight: bold; color: #409EFF; }
|
||||
.stat-label { font-size: 13px; color: #666; margin-top: 6px; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.text-muted { color: #BBB; font-size: 13px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user