- 同步版本号:VERSION / backend pom / admin-web / miniapp 均升至 1.1.0 - 合并本轮代码审查修复(详见 docs/CODE-REVIEW-2026-09-24.md): - 后端:微信登录真实实现 RealWechatLoginProvider、实名核验真实实现 RealIdentityVerificationProvider(本地强校验 + 可配置第三方 + fail-closed/fail-fast) - 照片直传落库端点 POST /me/photos/confirm(fileKey 归属校验 + 对象存在性校验后 savePhoto) - 刷新 Token Redis TTL 修正(getExpirationMs()*2 的 4h → refreshExpirationMs 的 14 天) - 管理端 token 改为 HttpOnly Cookie + silent-refresh,移除 localStorage 明文存储与 30+ 处手工注入 - 小程序:红娘接单鉴权头错位修复、serviceType 中文映射、生产域名可配置 - 隔离本地产物(.workbuddy/、*.bundle、*.dump、secrets、application-dev.yml 等已被 .gitignore 覆盖) 未验证项(打标签 ≠ 生产发布): - 后端编译 / 单测未在本环境执行(Maven 慢、Docker DNS 受限) - 管理端 登录→刷新→登出 端到端、小程序真机走查待人工验收 - 生产部署需注入 WECHAT_APP_ID / WECHAT_APP_SECRET、VERIFICATION_API_URL / VERIFICATION_API_KEY 与小程序生产域名
197 lines
8.5 KiB
Vue
197 lines
8.5 KiB
Vue
<template>
|
|
<div class="page-container">
|
|
<el-row :gutter="16">
|
|
<!-- 统计卡片 -->
|
|
<el-col :span="6" v-for="s in stats" :key="s.key">
|
|
<el-card shadow="hover" class="stat-card">
|
|
<div class="stat-value" :style="{ color: s.color }">{{ 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-radio-group v-model="statusFilter" size="small" @change="loadData">
|
|
<el-radio-button label="pending">待处理</el-radio-button>
|
|
<el-radio-button label="processing">处理中</el-radio-button>
|
|
<el-radio-button label="resolved">已处理</el-radio-button>
|
|
<el-radio-button label="dismissed">已驳回</el-radio-button>
|
|
</el-radio-group>
|
|
</div>
|
|
</template>
|
|
|
|
<el-table :data="tableData" v-loading="loading" border stripe>
|
|
<el-table-column prop="reasonText" label="举报原因" width="140" />
|
|
<el-table-column prop="reportedUserId" label="被举报人ID" width="140" />
|
|
<el-table-column prop="description" label="说明" min-width="200" show-overflow-tooltip />
|
|
<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'||row.status==='processing'"
|
|
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-empty v-if="!loading && tableData.length===0" description="暂无举报记录" />
|
|
</el-card>
|
|
|
|
<!-- 举报详情抽屉 -->
|
|
<el-drawer v-model="drawerVisible" :title="`举报详情 #${currentReportId?.slice(0,8)||''}`" size="560px">
|
|
<div v-if="detail">
|
|
<el-descriptions :column="2" border>
|
|
<el-descriptions-item label="举报原因">{{ detail.report?.reasonText }}</el-descriptions-item>
|
|
<el-descriptions-item label="状态">
|
|
<el-tag :type="statusTagType(detail.report?.status)">{{ statusText(detail.report?.status) }}</el-tag>
|
|
</el-descriptions-item>
|
|
<el-descriptions-item label="补充说明" :span="2">{{ detail.report?.description || '无' }}</el-descriptions-item>
|
|
</el-descriptions>
|
|
|
|
<h4 style="margin:20px 0 12px;color:var(--admin-coral-strong)">被举报人风险画像</h4>
|
|
<el-descriptions :column="2" border v-if="detail.riskProfile">
|
|
<el-descriptions-item label="用户">{{ detail.riskProfile.displayName }}</el-descriptions-item>
|
|
<el-descriptions-item label="风险分数">{{ detail.riskProfile.totalRiskScore }}</el-descriptions-item>
|
|
<el-descriptions-item label="风险等级">
|
|
<el-tag :type="riskLevelTag(detail.riskProfile.riskLevel)">
|
|
{{ riskLevelText(detail.riskProfile.riskLevel) }}
|
|
</el-tag>
|
|
</el-descriptions-item>
|
|
<el-descriptions-item label="历史举报数">{{ detail.riskProfile.totalReportsCount }}</el-descriptions-item>
|
|
</el-descriptions>
|
|
|
|
<h4 style="margin:20px 0 12px;color:var(--admin-coral-strong)">处置操作</h4>
|
|
<el-form label-width="100px">
|
|
<el-form-item label="处理结果">
|
|
<el-select v-model="processResult" style="width:100%">
|
|
<el-option label="举报不成立(无处置)" value="no_action" />
|
|
<el-option label="警告" value="warning" />
|
|
<el-option label="限制功能(7天)" value="feature_limit" />
|
|
<el-option label="暂停账号(30天)" value="temporary_suspend" />
|
|
<el-option label="封禁账号" value="ban" />
|
|
<el-option label="转人工进一步调查" value="manual_review" />
|
|
</el-select>
|
|
</el-form-item>
|
|
<el-form-item label="处理备注">
|
|
<el-input v-model="processNote" 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>
|
|
</div>
|
|
</el-drawer>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { getAccessToken } from '@/utils/token'
|
|
import { ref, onMounted } from 'vue'
|
|
import { get, post } from '@/api/client'
|
|
import { token as tokenColor } from '@/theme/tokens'
|
|
|
|
const loading = ref(false)
|
|
const processing = ref(false)
|
|
const statusFilter = ref('pending')
|
|
const tableData = ref<any[]>([])
|
|
const stats = ref<any[]>([])
|
|
const drawerVisible = ref(false)
|
|
const currentReportId = ref('')
|
|
const detail = ref<any>(null)
|
|
const processResult = ref('no_action')
|
|
const processNote = ref('')
|
|
|
|
const STATUS_MAP: Record<string, { text: string; type: string }> = {
|
|
pending: { text: '待处理', type: 'warning' },
|
|
processing: { text: '处理中', type: 'warning' },
|
|
resolved: { text: '已处理', type: 'success' },
|
|
rejected: { text: '未受理', type: 'info' },
|
|
dismissed: { text: '已驳回', type: 'default' }
|
|
}
|
|
|
|
async function loadData() {
|
|
loading.value = true
|
|
try {
|
|
const token = getAccessToken()
|
|
const res: any = await get(`/operator/risk/reports/${statusFilter.value}`, {}, { Authorization: `Bearer ${token}` })
|
|
tableData.value = res.data || []
|
|
loadStats()
|
|
} catch (e) { console.error(e) }
|
|
finally { loading.value = false }
|
|
}
|
|
|
|
async function loadStats() {
|
|
try {
|
|
const token = getAccessToken()
|
|
const res: any = await get('/operator/risk/reports/stats', {}, { Authorization: `Bearer ${token}` })
|
|
const data = res.data || {}
|
|
stats.value = [
|
|
{ key: 'pending', label: '待处理', value: data.pendingCount || 0, color: tokenColor('--color-primary') },
|
|
{ key: 'processing', label: '处理中', value: data.processingCount || 0, color: tokenColor('--color-warning') },
|
|
{ key: 'resolved', label: '已处理', value: data.resolvedCount || 0, color: tokenColor('--color-success') },
|
|
{ key: 'dismissed', label: '已驳回', value: data.dismissedCount || 0, color: tokenColor('--color-neutral') }
|
|
]
|
|
} catch {}
|
|
}
|
|
|
|
async function openDetail(reportId: string) {
|
|
currentReportId.value = reportId
|
|
drawerVisible.value = true
|
|
processResult.value = 'no_action'
|
|
processNote.value = ''
|
|
try {
|
|
const token = getAccessToken()
|
|
const res: any = await get(`/operator/risk/reports/${reportId}`, {}, { Authorization: `Bearer ${token}` })
|
|
detail.value = res.data
|
|
} catch (e) { console.error(e) }
|
|
}
|
|
|
|
async function handleProcess() {
|
|
if (!processResult.value) return
|
|
processing.value = true
|
|
try {
|
|
const token = getAccessToken()
|
|
await post(`/operator/risk/reports/${currentReportId.value}/process`,
|
|
{ processResult: processResult.value, processNote: processNote.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 riskLevelTag(l: string) {
|
|
return { normal: 'info', watch: '', high: 'warning', critical: 'danger' }[l] || 'info'
|
|
}
|
|
function riskLevelText(l: string) {
|
|
return { normal: '正常', watch: '关注', high: '高风险', critical: '极高风险' }[l] || l
|
|
}
|
|
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: var(--font-size-5xl); font-weight: var(--font-weight-bold); }
|
|
.stat-label { font-size: var(--font-size-base); color: var(--color-text-tertiary); margin-top: 8px; }
|
|
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
|
</style>
|