fix(report): optimize wechat toast length limit and add admin violation reports management center

这个提交包含在:
xcbyy
2026-10-13 17:08:25 +08:00
父节点 56e672219e
当前提交 9e2c19088b
共有 10 个文件被更改,包括 455 次插入 和 4 次删除

查看文件

@@ -47,6 +47,7 @@ export default {
'menu.finance.coupons': 'Coupons Management',
'menu.safety': 'Safety & Risk Control',
'menu.safety.places': 'Approved Venues',
'menu.safety.reports': 'Violation Reports',
'menu.safety.auditLog': 'Risk Intercept Log',
'menu.safety.platformConfig': 'Platform & Support Config',
'menu.banner': 'Banner & Operations',

查看文件

@@ -47,6 +47,7 @@ export default {
'menu.finance.coupons': '平台优惠券管理',
'menu.safety': '履约安全与风控',
'menu.safety.places': '线下商圈场所库',
'menu.safety.reports': '违规举报工单处置',
'menu.safety.auditLog': '场所与内容拦截日志',
'menu.safety.platformConfig': '平台配置与协议规范 (用户协议/隐私/客服)',
'menu.banner': '首页轮播与运营',

查看文件

@@ -22,6 +22,16 @@ const SAFETY: AppRouteRecordRaw = {
roles: ['*'],
},
},
{
path: 'reports',
name: 'SafetyReports',
component: () => import('@/views/safety/reports/index.vue'),
meta: {
locale: 'menu.safety.reports',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'audit-log',
name: 'SafetyAuditLog',

查看文件

@@ -0,0 +1,299 @@
<template>
<div class="container">
<a-breadcrumb class="breadcrumb">
<a-breadcrumb-item>履约安全与风控</a-breadcrumb-item>
<a-breadcrumb-item>违规举报工单处理</a-breadcrumb-item>
</a-breadcrumb>
<a-card class="general-card" title="不良违规行为举报与风控处置中心">
<template #extra>
<a-space>
<a-button @click="fetchData">
<template #icon><icon-refresh /></template>
刷新工单
</a-button>
</a-space>
</template>
<a-alert type="warning" style="margin-bottom: 16px">
违规处理原则:认真核验用户提交的违规描述与聊天凭证截图。如属实存在涉黄、涉赌、诱导线下脱离平台私下交易、辱骂挑衅或虚假核销等不良行为,应从严予以警告、扣减信用分或下架处罚;如凭证不足或属误报,请予以驳回。
</a-alert>
<!-- 筛选栏 -->
<a-row :gutter="16" style="margin-bottom: 16px">
<a-col :span="6">
<a-select v-model="filterStatus" placeholder="按工单处理状态筛选" allow-clear>
<a-option value="">全部状态</a-option>
<a-option value="待处理">待处理</a-option>
<a-option value="已核实处罚">已核实处罚</a-option>
<a-option value="已驳回">已驳回</a-option>
</a-select>
</a-col>
<a-col :span="8">
<a-input
v-model="searchKeyword"
placeholder="搜索举报人ID/被举报人/违规原因"
allow-clear
>
<template #prefix><icon-search /></template>
</a-input>
</a-col>
</a-row>
<a-table
:data="filteredList"
:loading="loading"
:pagination="{ pageSize: 10, showTotal: true }"
row-key="id"
>
<template #columns>
<a-table-column title="工单号" data-index="id" :width="150">
<template #cell="{ record }">
<span class="code-font">{{ record.id }}</span>
</template>
</a-table-column>
<a-table-column title="举报人" :width="120">
<template #cell="{ record }">
<span class="code-font">UID: {{ record.reporterId }}</span>
</template>
</a-table-column>
<a-table-column title="被举报对象" :width="170">
<template #cell="{ record }">
<div>
<div style="font-weight: 600">{{ record.targetName || '未知对象' }}</div>
<div style="font-size: 12px; color: var(--color-text-3)">
{{ record.targetType }} (ID: {{ record.targetId }})
</div>
</div>
</template>
</a-table-column>
<a-table-column title="违规类型" :width="150">
<template #cell="{ record }">
<a-tag color="red">{{ record.reason }}</a-tag>
</template>
</a-table-column>
<a-table-column title="详情描述与举报陈述" :width="260">
<template #cell="{ record }">
<div class="desc-text">{{ record.description || '举报人未提供文字陈述' }}</div>
</template>
</a-table-column>
<a-table-column title="线索凭证截图" :width="150">
<template #cell="{ record }">
<div v-if="parseEvidence(record.evidence).length > 0">
<a-image-preview-group>
<a-space wrap :size="4">
<a-image
v-for="(img, idx) in parseEvidence(record.evidence)"
:key="idx"
:src="img"
width="38"
height="38"
fit="cover"
style="border-radius: 4px; border: 1px solid var(--color-border-2)"
/>
</a-space>
</a-image-preview-group>
</div>
<span v-else style="color: var(--color-text-4); font-size: 12px">无上传截图</span>
</template>
</a-table-column>
<a-table-column title="提交时间" data-index="createdAt" :width="160" />
<a-table-column title="工单状态" :width="120">
<template #cell="{ record }">
<a-tag :color="getStatusColor(record.status)">
{{ record.status }}
</a-tag>
</template>
</a-table-column>
<a-table-column title="处理结果与备注" :width="200">
<template #cell="{ record }">
<div v-if="record.handleResult" style="font-size: 12px">
<div style="font-weight: 600">{{ record.handleResult }}</div>
<div style="color: var(--color-text-3)">{{ record.handleNote }}</div>
</div>
<span v-else style="color: var(--color-text-4); font-size: 12px">待人工介入处置</span>
</template>
</a-table-column>
<a-table-column title="操作" :width="150" fixed="right" align="center">
<template #cell="{ record }">
<a-space v-if="record.status === '待处理'">
<a-button
type="primary"
status="danger"
size="small"
@click="openHandleModal(record, 'punish')"
>
违规处分
</a-button>
<a-button
type="outline"
size="small"
@click="openHandleModal(record, 'reject')"
>
驳回
</a-button>
</a-space>
<span v-else style="color: var(--color-text-3); font-size: 12px">已结案</span>
</template>
</a-table-column>
</template>
</a-table>
</a-card>
<!-- 举报处置对话框 -->
<a-modal
v-model:visible="handleModalVisible"
:title="handleAction === 'punish' ? '举报属实 - 违规处分判定' : '驳回违规举报工单'"
@ok="submitHandle"
@cancel="handleModalVisible = false"
>
<a-form :model="handleForm" layout="vertical">
<a-form-item label="被举报对象">
<a-input :model-value="`${currentRecord?.targetName || ''} (ID: ${currentRecord?.targetId || ''})`" readonly />
</a-form-item>
<a-form-item label="违规原因">
<a-input :model-value="currentRecord?.reason || ''" readonly />
</a-form-item>
<a-form-item v-if="handleAction === 'punish'" label="处分措施" required>
<a-radio-group v-model="handleForm.penalty">
<a-radio value="warning">严重警告并扣除信誉分 (扣 0.2)</a-radio>
<a-radio value="punish_only">仅下发违规整改通知</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item
:label="handleAction === 'punish' ? '处置备注与依据' : '驳回原因说明'"
required
>
<a-textarea
v-model="handleForm.note"
:placeholder="handleAction === 'punish' ? '例如:经核验聊天记录属实存在诱导私下交易行为,从严扣分警告' : '例如:线索证据不足,聊天记录未发现明显违规事实'"
:max-length="200"
show-word-limit
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue';
import axios from 'axios';
import { Message } from '@arco-design/web-vue';
const loading = ref(false);
const rawList = ref<any[]>([]);
const filterStatus = ref('');
const searchKeyword = ref('');
const handleModalVisible = ref(false);
const currentRecord = ref<any>(null);
const handleAction = ref<'punish' | 'reject'>('punish');
const handleForm = ref({
penalty: 'warning',
note: '',
});
const fetchData = async () => {
loading.value = true;
try {
const res: any = await axios.get('/api/reports');
if (res && res.data) {
rawList.value = Array.isArray(res.data) ? res.data : (res.data.data || []);
}
} catch (err: any) {
Message.error('获取举报工单列表失败');
} finally {
loading.value = false;
}
};
const filteredList = computed(() => {
return rawList.value.filter((item) => {
const matchStatus = !filterStatus.value || item.status === filterStatus.value;
const kw = searchKeyword.value.trim().toLowerCase();
const matchKeyword =
!kw ||
String(item.id).toLowerCase().includes(kw) ||
String(item.reporterId).toLowerCase().includes(kw) ||
String(item.targetName || '').toLowerCase().includes(kw) ||
String(item.reason || '').toLowerCase().includes(kw);
return matchStatus && matchKeyword;
});
});
const parseEvidence = (evidence: any): string[] => {
if (!evidence) return [];
if (Array.isArray(evidence)) return evidence;
try {
const parsed = JSON.parse(evidence);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
const getStatusColor = (status: string) => {
if (status === '待处理') return 'orange';
if (status === '已核实处罚') return 'red';
if (status === '已驳回') return 'gray';
return 'blue';
};
const openHandleModal = (record: any, action: 'punish' | 'reject') => {
currentRecord.value = record;
handleAction.value = action;
handleForm.value.note = action === 'punish'
? '经平台安全团队核验,违规事实清楚,给予处分并记录违规档案。'
: '经核验未发现明显违规事实,证据不足,暂予驳回。';
handleModalVisible.value = true;
};
const submitHandle = async () => {
if (!handleForm.value.note.trim()) {
Message.warning('请填写处理备注说明');
return;
}
try {
const res: any = await axios.post(`/api/reports/${currentRecord.value.id}/handle`, {
action: handleAction.value,
handleNote: handleForm.value.note.trim(),
penaltyOptions: {
deductCredit: handleForm.value.penalty === 'warning' ? 5 : 0
}
});
Message.success(res.data?.message || '举报工单处置成功');
handleModalVisible.value = false;
fetchData();
} catch (err: any) {
Message.error(err.response?.data?.message || err.message || '处置失败');
}
};
onMounted(() => {
fetchData();
});
</script>
<style scoped lang="less">
.container {
padding: 16px 20px;
}
.breadcrumb {
margin-bottom: 16px;
}
.code-font {
font-family: monospace;
font-weight: 600;
}
.desc-text {
font-size: 12px;
line-height: 1.5;
color: var(--color-text-2);
background: var(--color-fill-2);
padding: 6px 10px;
border-radius: 4px;
white-space: pre-wrap;
word-break: break-all;
}
</style>

查看文件

@@ -492,7 +492,15 @@ const submitReportTicket = async () => {
});
uni.hideLoading();
closeReportModal();
uni.showToast({ title: '举报已受理,平台将在24小时内核实处置', icon: 'success' });
uni.showToast({ title: '举报提交成功', icon: 'success' });
setTimeout(() => {
uni.showModal({
title: '举报受理通知',
content: '感谢您的监督!平台安全团队将在 24 小时内核查聊天记录与履约凭据,违规属实将从严处罚。',
showCancel: false,
confirmText: '我知道了'
});
}, 400);
} catch (err) {
uni.hideLoading();
uni.showToast({ title: err.message || '举报提交失败', icon: 'none' });

查看文件

@@ -731,7 +731,15 @@ const submitReportTicket = async () => {
});
uni.hideLoading();
closeReportModal();
uni.showToast({ title: '举报已提交,平台将在24小时内核实处置', icon: 'success' });
uni.showToast({ title: '举报提交成功', icon: 'success' });
setTimeout(() => {
uni.showModal({
title: '举报受理通知',
content: '感谢您的监督!平台安全团队将在 24 小时内核查聊天记录与履约凭据,违规属实将从严处罚。',
showCancel: false,
confirmText: '我知道了'
});
}, 400);
} catch (err) {
uni.hideLoading();
uni.showToast({ title: err.message || '举报提交失败', icon: 'none' });

查看文件

@@ -469,11 +469,24 @@ export async function initDatabase(): Promise<void> {
description VARCHAR(500) DEFAULT '',
evidence JSON,
status VARCHAR(32) NOT NULL DEFAULT '待处理',
handle_result VARCHAR(64) DEFAULT '',
handle_note VARCHAR(500) DEFAULT '',
handled_at DATETIME DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_target (target_type, target_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
try {
await conn.query('ALTER TABLE reports ADD COLUMN handle_result VARCHAR(64) DEFAULT ""');
} catch (_) {}
try {
await conn.query('ALTER TABLE reports ADD COLUMN handle_note VARCHAR(500) DEFAULT ""');
} catch (_) {}
try {
await conn.query('ALTER TABLE reports ADD COLUMN handled_at DATETIME DEFAULT NULL');
} catch (_) {}
// 22. 陪玩收藏表 (收藏喜欢的陪玩,实时高亮在接单状态)
await conn.query(`
CREATE TABLE IF NOT EXISTS favorites (

查看文件

@@ -1669,8 +1669,19 @@ export class MysqlRepository {
async getReports(): Promise<any[]> {
const [rows]: any = await pool.query(`
SELECT
id, reporter_id as reporterId, target_type as targetType, target_id as targetId,
target_name as targetName, reason, description, evidence, status, created_at as createdAt
id,
reporter_id as reporterId,
target_type as targetType,
target_id as targetId,
target_name as targetName,
reason,
description,
evidence,
status,
COALESCE(handle_result, '') as handleResult,
COALESCE(handle_note, '') as handleNote,
handled_at as handledAt,
created_at as createdAt
FROM reports
ORDER BY created_at DESC
LIMIT 200
@@ -1678,6 +1689,46 @@ export class MysqlRepository {
return rows;
}
async handleReport(
id: string,
action: 'punish' | 'reject',
handleNote?: string,
penaltyOptions?: { deductCredit?: number; banDays?: number }
): Promise<any> {
const status = action === 'punish' ? '已核实处罚' : '已驳回';
const handleResult = action === 'punish' ? '违规属实,已给予处分' : '证据不足,予以驳回';
const note = handleNote || (action === 'punish' ? '经平台安全与风控团队核查,违规行为属实,已给予处分并记录违规档案。' : '经安全团队核验,现有证据不足以判定违规,予以驳回。');
await pool.query(`
UPDATE reports
SET status = ?, handle_result = ?, handle_note = ?, handled_at = NOW()
WHERE id = ?
`, [status, handleResult, note, id]);
try {
const [rows]: any = await pool.query('SELECT * FROM reports WHERE id = ?', [id]);
const report = rows && rows[0];
if (report && action === 'punish' && report.target_type === 'EXPERT') {
const expertId = Number(report.target_id);
if (expertId > 0) {
await pool.query(`
UPDATE companions
SET rating = GREATEST(1.0, rating - 0.2)
WHERE id = ?
`, [expertId]);
}
}
} catch (_) {}
return {
id,
status,
handleResult,
handleNote: note,
handledAt: new Date().toISOString()
};
}
// 财务相关
async getFinanceRecords(): Promise<any[]> {
const [rows]: any = await pool.query(`

查看文件

@@ -1337,6 +1337,25 @@ app.get('/api/reports', async (_req: Request, res: Response) => {
}
});
// 管理员处置不良违规举报工单
app.post('/api/reports/:id/handle', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const { action, handleNote, penaltyOptions } = req.body || {};
if (!action || !['punish', 'reject'].includes(action)) {
return res.status(400).json({ code: 40000, message: '请指定合法的处理动作 (punish 或 reject)' });
}
const result = await repo.handleReport(id, action, handleNote, penaltyOptions);
res.json({
code: 20000,
data: result,
message: action === 'punish' ? '已核实举报并执行违规处置' : '已驳回该违规举报工单'
});
} catch (err: any) {
res.status(500).json({ code: 50000, message: err.message });
}
});
app.get('/api/arbitrations', async (_req: Request, res: Response) => {
try {
const arbitrations = await repo.getArbitrations();

查看文件

@@ -35,6 +35,47 @@ describe('ClientFeatures (TDD 测试套件: 用户端功能闭环)', () => {
expect(checkResult.code).toBe(87014);
expect(checkResult.suggest).toBe('block');
});
it('后端 repository 必须实现 handleReport 处置流转并支持处分与驳回', () => {
const repoPath = path.join(serverSrcDir, 'db', 'repository.ts');
const content = fs.readFileSync(repoPath, 'utf-8');
expect(content).toContain('handleReport');
expect(content).toContain('handle_result');
expect(content).toContain('handle_note');
expect(content).toContain('handled_at');
});
it('后端 index.ts 必须暴露 POST /api/reports/:id/handle 路由', () => {
const indexPath = path.join(serverSrcDir, 'index.ts');
const content = fs.readFileSync(indexPath, 'utf-8');
expect(content).toContain("app.post('/api/reports/:id/handle'");
expect(content).toContain('repo.handleReport');
});
it('管理后台必须挂载 SafetyReports 路由且存在对应处理页面视图', () => {
const routePath = path.join(rootDir, 'admin', 'arco-design-pro-vite', 'src', 'router', 'routes', 'modules', 'safety.ts');
const routeContent = fs.readFileSync(routePath, 'utf-8');
expect(routeContent).toContain("path: 'reports'");
expect(routeContent).toContain("name: 'SafetyReports'");
const viewPath = path.join(rootDir, 'admin', 'arco-design-pro-vite', 'src', 'views', 'safety', 'reports', 'index.vue');
expect(fs.existsSync(viewPath)).toBe(true);
const viewContent = fs.readFileSync(viewPath, 'utf-8');
expect(viewContent).toContain('/api/reports');
expect(viewContent).toContain('handleAction');
});
it('小程序端 Toast 提示必须在 7 汉字以内以防被微信小程序方块图标截断', () => {
const orderListPath = path.join(uniPagesDir, 'order', 'list.vue');
const orderContent = fs.readFileSync(orderListPath, 'utf-8');
expect(orderContent).toContain("title: '举报提交成功'");
const detailPath = path.join(uniPagesDir, 'expert', 'detail.vue');
const detailContent = fs.readFileSync(detailPath, 'utf-8');
expect(detailContent).toContain("title: '举报提交成功'");
});
});
describe('2. 订单待支付倒计时与退款仲裁工单闭环验证', () => {