feat(safety): support full CRUD for approved commercial places and dynamic sync to uniapp
这个提交包含在:
@@ -58,14 +58,28 @@
|
||||
</a-table-column>
|
||||
<a-table-column title="营业状态" :width="120">
|
||||
<template #cell="{ record }">
|
||||
<a-badge status="success" :text="record.status" />
|
||||
<a-badge :status="record.status === '正常营业' ? 'success' : 'danger'" :text="record.status" />
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="准入状态" :width="130">
|
||||
<a-table-column title="准入状态" :width="110">
|
||||
<template #cell="{ record }">
|
||||
<a-tag color="blue">白名单准入</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="管理操作" :width="160" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-space>
|
||||
<a-button type="text" size="small" @click="openEditModal(record)">
|
||||
编辑
|
||||
</a-button>
|
||||
<a-popconfirm content="确定要从合规白名单库中移除该场所吗?" @ok="handleDelete(record.id)">
|
||||
<a-button type="text" status="danger" size="small">
|
||||
移除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
@@ -94,6 +108,37 @@
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 编辑场所 Modal -->
|
||||
<a-modal
|
||||
v-model:visible="editModalVisible"
|
||||
title="编辑合规商圈场所"
|
||||
@ok="handleEditSubmit"
|
||||
@cancel="editModalVisible = false"
|
||||
>
|
||||
<a-form :model="editForm" layout="vertical">
|
||||
<a-form-item label="场所实体名称" required>
|
||||
<a-input v-model="editForm.name" placeholder="如:三里屯太二酸菜鱼、工体纯KTV" />
|
||||
</a-form-item>
|
||||
<a-form-item label="所属公开商圈" required>
|
||||
<a-input v-model="editForm.area" placeholder="如:朝阳区三里屯商圈、海淀中关村商圈" />
|
||||
</a-form-item>
|
||||
<a-form-item label="场所业态类别" required>
|
||||
<a-select v-model="editForm.category">
|
||||
<a-option value="餐饮美食">餐饮美食</a-option>
|
||||
<a-option value="量贩KTV">量贩KTV</a-option>
|
||||
<a-option value="商圈清吧">商圈清吧</a-option>
|
||||
<a-option value="电竞赛馆">电竞赛馆</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="营业状态" required>
|
||||
<a-select v-model="editForm.status">
|
||||
<a-option value="正常营业">正常营业</a-option>
|
||||
<a-option value="暂停营业">暂停营业</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -162,7 +207,53 @@
|
||||
addModalVisible.value = false;
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
Message.error(err.message || '录入失败');
|
||||
Message.error(err.response?.data?.message || err.message || '录入失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 编辑弹窗
|
||||
const editModalVisible = ref(false);
|
||||
const currentEditId = ref('');
|
||||
const editForm = ref({
|
||||
name: '',
|
||||
area: '',
|
||||
category: '餐饮美食',
|
||||
status: '正常营业',
|
||||
});
|
||||
|
||||
const openEditModal = (record: any) => {
|
||||
currentEditId.value = record.id;
|
||||
editForm.value = {
|
||||
name: record.name,
|
||||
area: record.area,
|
||||
category: record.category,
|
||||
status: record.status,
|
||||
};
|
||||
editModalVisible.value = true;
|
||||
};
|
||||
|
||||
const handleEditSubmit = async () => {
|
||||
if (!editForm.value.name || !editForm.value.area) {
|
||||
Message.warning('请填写完整的场所名称与商圈信息');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await axios.put(`/api/safety/places/${currentEditId.value}`, editForm.value);
|
||||
Message.success('场所信息修改成功');
|
||||
editModalVisible.value = false;
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
Message.error(err.response?.data?.message || err.message || '修改失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await axios.delete(`/api/safety/places/${id}`);
|
||||
Message.success('场所已从合规库中移除');
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
Message.error(err.response?.data?.message || err.message || '移除失败');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -750,3 +750,16 @@ export async function getPlatformConfigs() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取平台合规商圈场所白名单库
|
||||
*/
|
||||
export async function getSafetyPlaces() {
|
||||
try {
|
||||
const res = await http.get('/safety/places');
|
||||
return res;
|
||||
} catch (err) {
|
||||
console.warn('[API] 获取合规商圈场所降级:', err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -288,7 +288,7 @@
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import { ICONS } from '@/common/icons.js';
|
||||
import { createActivity } from '@/common/api.js';
|
||||
import { createActivity, getSafetyPlaces } from '@/common/api.js';
|
||||
import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js';
|
||||
|
||||
const games = ['美食陪吃', '同城陪唱', '下午茶陪喝', '同城微醺', '同城桌游', '热门电竞'];
|
||||
@@ -303,11 +303,13 @@ onMounted(() => {
|
||||
currentThemeKey.value = getCurrentThemeKey();
|
||||
applyThemeToNativeUI(currentThemeKey.value);
|
||||
uni.$on('theme-change', onThemeChanged);
|
||||
loadLivePlaces();
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
currentThemeKey.value = getCurrentThemeKey();
|
||||
applyThemeToNativeUI(currentThemeKey.value);
|
||||
loadLivePlaces();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -340,12 +342,25 @@ const calcTotalFee = computed(() => {
|
||||
});
|
||||
|
||||
const riskModalVisible = ref(false);
|
||||
const recommendPublicZones = [
|
||||
const recommendPublicZones = ref([
|
||||
'北京市朝阳区三里屯太古里 (AAA公开商圈)',
|
||||
'北京市朝阳区工体纯K量贩KTV (正规量贩)',
|
||||
'北京市朝阳区国贸CBD商业街 (阳光示范商圈)',
|
||||
'北京市海淀区五道口购物中心 (高校商圈)'
|
||||
];
|
||||
]);
|
||||
|
||||
const loadLivePlaces = async () => {
|
||||
try {
|
||||
const list = await getSafetyPlaces();
|
||||
if (Array.isArray(list) && list.length > 0) {
|
||||
recommendPublicZones.value = list
|
||||
.filter((p) => p.status === '正常营业')
|
||||
.map((p) => `${p.area} · ${p.name}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[ActivityCreate] 加载合规场所异常降级:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const applyRecommendZone = (zone) => {
|
||||
form.value.location = zone;
|
||||
|
||||
@@ -1439,6 +1439,40 @@ export class MysqlRepository {
|
||||
return { id, ...data };
|
||||
}
|
||||
|
||||
async updateSafetyPlace(id: string, data: Partial<{ name: string; area: string; category: string; status: string }>): Promise<any> {
|
||||
const fields: string[] = [];
|
||||
const values: any[] = [];
|
||||
if (data.name !== undefined) {
|
||||
fields.push('name = ?');
|
||||
values.push(data.name);
|
||||
}
|
||||
if (data.area !== undefined) {
|
||||
fields.push('area = ?');
|
||||
values.push(data.area);
|
||||
}
|
||||
if (data.category !== undefined) {
|
||||
fields.push('category = ?');
|
||||
values.push(data.category);
|
||||
}
|
||||
if (data.status !== undefined) {
|
||||
fields.push('status = ?');
|
||||
values.push(data.status);
|
||||
}
|
||||
|
||||
if (fields.length > 0) {
|
||||
values.push(id);
|
||||
await pool.query(`UPDATE safety_places SET ${fields.join(', ')} WHERE id = ?`, values);
|
||||
}
|
||||
|
||||
const [rows]: any = await pool.query('SELECT * FROM safety_places WHERE id = ?', [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async deleteSafetyPlace(id: string): Promise<boolean> {
|
||||
const [res]: any = await pool.query('DELETE FROM safety_places WHERE id = ?', [id]);
|
||||
return res.affectedRows > 0;
|
||||
}
|
||||
|
||||
async getSafetyAuditLogs(): Promise<any[]> {
|
||||
const [rows]: any = await pool.query(`
|
||||
SELECT
|
||||
|
||||
@@ -1260,6 +1260,14 @@ app.get('/api/safety/places', async (_req: Request, res: Response) => {
|
||||
|
||||
app.post('/api/safety/places', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { name, area, category } = req.body;
|
||||
if (!name || !area) {
|
||||
return res.status(400).json({ code: 40000, message: '场所名称和商圈不能为空' });
|
||||
}
|
||||
const sec = contentSecurityService.checkText(`${name} ${area}`);
|
||||
if (!sec.pass) {
|
||||
return res.status(400).json({ code: sec.code, message: sec.message });
|
||||
}
|
||||
const place = await repo.createSafetyPlace(req.body);
|
||||
res.json({
|
||||
code: 20000,
|
||||
@@ -1271,6 +1279,41 @@ app.post('/api/safety/places', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 管理后台: 修改场所信息
|
||||
app.put('/api/safety/places/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const placeId = String(req.params.id);
|
||||
const { name, area, category, status } = req.body;
|
||||
if (name || area) {
|
||||
const sec = contentSecurityService.checkText(`${name || ''} ${area || ''}`);
|
||||
if (!sec.pass) {
|
||||
return res.status(400).json({ code: sec.code, message: sec.message });
|
||||
}
|
||||
}
|
||||
const updated = await repo.updateSafetyPlace(placeId, { name, area, category, status });
|
||||
if (!updated) {
|
||||
return res.status(404).json({ code: 40400, message: '场所不存在' });
|
||||
}
|
||||
res.json({ code: 20000, data: updated, message: '场所信息已更新' });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ code: 40000, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 管理后台: 删除场所
|
||||
app.delete('/api/safety/places/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const placeId = String(req.params.id);
|
||||
const ok = await repo.deleteSafetyPlace(placeId);
|
||||
if (!ok) {
|
||||
return res.status(404).json({ code: 40400, message: '场所不存在或已被移除' });
|
||||
}
|
||||
res.json({ code: 20000, data: null, message: '场所已移除' });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ code: 40000, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/safety/audit-logs', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const logs = await repo.getSafetyAuditLogs();
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// 已读取AGENTS.md
|
||||
import { ContentSecurityService } from '../src/core/security/content-security.service';
|
||||
|
||||
describe('场所白名单准入与敏感词内容安全审查 TDD 自动化测试', () => {
|
||||
const secService = new ContentSecurityService();
|
||||
|
||||
it('1. 合规公开商圈场所白名单准入', () => {
|
||||
const validPlace = {
|
||||
name: '三里屯太二酸菜鱼',
|
||||
area: '朝阳区三里屯商圈',
|
||||
category: '餐饮美食',
|
||||
safetyLevel: 'AAA级合规认证',
|
||||
status: '正常营业'
|
||||
};
|
||||
|
||||
expect(validPlace.name).toBe('三里屯太二酸菜鱼');
|
||||
expect(validPlace.safetyLevel).toBe('AAA级合规认证');
|
||||
});
|
||||
|
||||
it('2. 录入合规商业场所时内容安全审查放行', () => {
|
||||
const check = secService.checkText('工体纯KTV 朝阳区工人体育场商圈 量贩KTV');
|
||||
expect(check.pass).toBe(true);
|
||||
expect(check.suggest).toBe('pass');
|
||||
});
|
||||
|
||||
it('3. 录入涉黄低俗敏感词场所时系统自动拦截', () => {
|
||||
const check = secService.checkText('某某丝袜会所 上门特殊服务');
|
||||
expect(check.pass).toBe(false);
|
||||
expect(check.code).toBe(87014);
|
||||
expect(check.label).toBe('VULGAR_PORN');
|
||||
});
|
||||
|
||||
it('4. 场所更新支持正常营业与暂停营业状态变更', () => {
|
||||
const place = {
|
||||
id: 'place_001',
|
||||
name: '三里屯聚宝源铜锅涮肉',
|
||||
status: '正常营业'
|
||||
};
|
||||
|
||||
const updateStatus = (p: typeof place, newStatus: string) => {
|
||||
return { ...p, status: newStatus };
|
||||
};
|
||||
|
||||
const updated = updateStatus(place, '暂停营业');
|
||||
expect(updated.status).toBe('暂停营业');
|
||||
});
|
||||
|
||||
it('5. 移动端动态拉取合规商圈场所列表过滤正常营业项', () => {
|
||||
const rawPlaces = [
|
||||
{ id: '1', name: '太二酸菜鱼', area: '朝阳大悦城', status: '正常营业' },
|
||||
{ id: '2', name: '纯KTV', area: '工体商圈', status: '暂停营业' },
|
||||
{ id: '3', name: '跳海酒馆', area: '望京麒麟社', status: '正常营业' }
|
||||
];
|
||||
|
||||
const activeZones = rawPlaces
|
||||
.filter((p) => p.status === '正常营业')
|
||||
.map((p) => `${p.area} · ${p.name}`);
|
||||
|
||||
expect(activeZones.length).toBe(2);
|
||||
expect(activeZones).toContain('朝阳大悦城 · 太二酸菜鱼');
|
||||
expect(activeZones).toContain('望京麒麟社 · 跳海酒馆');
|
||||
expect(activeZones).not.toContain('工体商圈 · 纯KTV');
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户