feat(admin): complete post management, finance csv export, and wechat message template config
这个提交包含在:
@@ -52,6 +52,8 @@ export default {
|
|||||||
'menu.banner': 'Banner & Operations',
|
'menu.banner': 'Banner & Operations',
|
||||||
'menu.banner.manage': 'Banner Management',
|
'menu.banner.manage': 'Banner Management',
|
||||||
'menu.banner.navCategory': 'Category Navigation',
|
'menu.banner.navCategory': 'Category Navigation',
|
||||||
|
'menu.community': 'Community & Content',
|
||||||
|
'menu.community.postManage': 'Post & Comment Audit',
|
||||||
'menu.server.dashboard': 'Dashboard-Server',
|
'menu.server.dashboard': 'Dashboard-Server',
|
||||||
'menu.server.workplace': 'Workplace-Server',
|
'menu.server.workplace': 'Workplace-Server',
|
||||||
'menu.server.monitor': 'Monitor-Server',
|
'menu.server.monitor': 'Monitor-Server',
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ export default {
|
|||||||
'menu.banner': '首页轮播与运营',
|
'menu.banner': '首页轮播与运营',
|
||||||
'menu.banner.manage': '轮播图管理',
|
'menu.banner.manage': '轮播图管理',
|
||||||
'menu.banner.navCategory': '金刚区导航品类',
|
'menu.banner.navCategory': '金刚区导航品类',
|
||||||
|
'menu.community': '友圈与内容管理',
|
||||||
|
'menu.community.postManage': '动态与评论监管',
|
||||||
'menu.server.dashboard': '仪表盘-服务端',
|
'menu.server.dashboard': '仪表盘-服务端',
|
||||||
'menu.server.workplace': '工作台-服务端',
|
'menu.server.workplace': '工作台-服务端',
|
||||||
'menu.server.monitor': '实时监控-服务端',
|
'menu.server.monitor': '实时监控-服务端',
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { DEFAULT_LAYOUT } from '../base';
|
||||||
|
import { AppRouteRecordRaw } from '../types';
|
||||||
|
|
||||||
|
const COMMUNITY: AppRouteRecordRaw = {
|
||||||
|
path: '/community',
|
||||||
|
name: 'community',
|
||||||
|
component: DEFAULT_LAYOUT,
|
||||||
|
meta: {
|
||||||
|
locale: 'menu.community',
|
||||||
|
requiresAuth: true,
|
||||||
|
icon: 'icon-message',
|
||||||
|
order: 7,
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'post-manage',
|
||||||
|
name: 'PostManage',
|
||||||
|
component: () => import('@/views/community/post-manage/index.vue'),
|
||||||
|
meta: {
|
||||||
|
locale: 'menu.community.postManage',
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ['*'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default COMMUNITY;
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
<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-row :gutter="16" style="margin-bottom: 16px">
|
||||||
|
<a-col :span="6">
|
||||||
|
<a-input-search
|
||||||
|
v-model="searchKeyword"
|
||||||
|
placeholder="搜索作者昵称、动态正文或商圈地点"
|
||||||
|
allow-clear
|
||||||
|
@search="fetchData"
|
||||||
|
@press-enter="fetchData"
|
||||||
|
/>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="5">
|
||||||
|
<a-select
|
||||||
|
v-model="selectedCategory"
|
||||||
|
placeholder="动态品类筛选"
|
||||||
|
allow-clear
|
||||||
|
@change="fetchData"
|
||||||
|
>
|
||||||
|
<a-option value="all">全部分类</a-option>
|
||||||
|
<a-option value="partner">同城搭子</a-option>
|
||||||
|
<a-option value="game">开黑电竞</a-option>
|
||||||
|
<a-option value="food">美食探店</a-option>
|
||||||
|
<a-option value="ktv">K歌欢唱</a-option>
|
||||||
|
</a-select>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="5">
|
||||||
|
<a-select
|
||||||
|
v-model="selectedStatus"
|
||||||
|
placeholder="状态筛选"
|
||||||
|
allow-clear
|
||||||
|
@change="fetchData"
|
||||||
|
>
|
||||||
|
<a-option value="all">全部状态</a-option>
|
||||||
|
<a-option value="正常">正常显示</a-option>
|
||||||
|
<a-option value="已隐藏">违规隐藏</a-option>
|
||||||
|
</a-select>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<!-- 表格区域 -->
|
||||||
|
<a-table
|
||||||
|
:data="postList"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="{ pageSize: 10, showTotal: true }"
|
||||||
|
row-key="id"
|
||||||
|
>
|
||||||
|
<template #columns>
|
||||||
|
<a-table-column title="作者" :width="160">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<a-space>
|
||||||
|
<a-avatar :size="36" shape="circle">
|
||||||
|
<img :src="record.avatar" alt="avatar" />
|
||||||
|
</a-avatar>
|
||||||
|
<div>
|
||||||
|
<div style="font-weight: 600">{{ record.author }}</div>
|
||||||
|
<div style="font-size: 12px; color: var(--color-text-3)">UID: {{ record.userId }}</div>
|
||||||
|
</div>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column title="分类与话题" :width="160">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<div style="margin-bottom: 4px">
|
||||||
|
<a-tag color="arcoblue">{{ record.tag || '同城搭子' }}</a-tag>
|
||||||
|
</div>
|
||||||
|
<div v-if="record.topics && record.topics.length > 0" class="topics-wrap">
|
||||||
|
<a-tag
|
||||||
|
v-for="(tp, idx) in record.topics"
|
||||||
|
:key="idx"
|
||||||
|
size="small"
|
||||||
|
color="gray"
|
||||||
|
style="margin-right: 4px; margin-bottom: 2px"
|
||||||
|
>
|
||||||
|
#{{ tp }}
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column title="动态正文内容" :width="320">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<div class="post-content-cell">{{ record.content }}</div>
|
||||||
|
<div class="post-location-tag" v-if="record.location">
|
||||||
|
<icon-location style="margin-right: 4px" />
|
||||||
|
{{ record.location }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column title="配图" :width="160">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<a-image-preview-group v-if="record.images && record.images.length > 0">
|
||||||
|
<a-space wrap :size="4">
|
||||||
|
<a-image
|
||||||
|
v-for="(img, idx) in record.images"
|
||||||
|
:key="idx"
|
||||||
|
:src="img"
|
||||||
|
width="44"
|
||||||
|
height="44"
|
||||||
|
fit="cover"
|
||||||
|
style="border-radius: 4px; cursor: pointer"
|
||||||
|
/>
|
||||||
|
</a-space>
|
||||||
|
</a-image-preview-group>
|
||||||
|
<span v-else style="color: var(--color-text-4); font-size: 12px">纯文本无配图</span>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column title="互动数据" :width="150">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<div class="stats-row">
|
||||||
|
<span>点赞: <b>{{ record.likes || 0 }}</b></span>
|
||||||
|
<span style="margin-left: 8px">评论: <b>{{ record.comments || 0 }}</b></span>
|
||||||
|
</div>
|
||||||
|
<div class="stats-row" style="margin-top: 2px; color: var(--color-text-3)">
|
||||||
|
<span>浏览量: {{ record.views || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column title="状态 / 置顶" :width="130">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<div style="margin-bottom: 4px">
|
||||||
|
<a-tag v-if="record.isTop" color="red">
|
||||||
|
<template #icon><icon-star /></template>
|
||||||
|
官方置顶
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
<a-badge
|
||||||
|
:status="record.status === '正常' ? 'success' : 'danger'"
|
||||||
|
:text="record.status || '正常'"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
|
||||||
|
<a-table-column title="发布时间" data-index="createdAt" :width="160" />
|
||||||
|
|
||||||
|
<a-table-column title="管理操作" :width="200" fixed="right">
|
||||||
|
<template #cell="{ record }">
|
||||||
|
<a-space :size="8">
|
||||||
|
<!-- 置顶切换 -->
|
||||||
|
<a-button
|
||||||
|
size="small"
|
||||||
|
:type="record.isTop ? 'outline' : 'text'"
|
||||||
|
:status="record.isTop ? 'warning' : 'normal'"
|
||||||
|
@click="handleToggleTop(record)"
|
||||||
|
>
|
||||||
|
{{ record.isTop ? '取消置顶' : '置顶' }}
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<!-- 显示/隐藏切换 -->
|
||||||
|
<a-button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
:status="record.status === '正常' ? 'warning' : 'success'"
|
||||||
|
@click="handleToggleStatus(record)"
|
||||||
|
>
|
||||||
|
{{ record.status === '正常' ? '隐藏' : '显示' }}
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<!-- 评论管理 -->
|
||||||
|
<a-button
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
@click="openCommentsDrawer(record)"
|
||||||
|
>
|
||||||
|
评论
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<!-- 违规删除 -->
|
||||||
|
<a-popconfirm
|
||||||
|
content="确定彻底删除该条动态及其所有评论吗?此操作不可逆。"
|
||||||
|
type="warning"
|
||||||
|
ok-text="确认删除"
|
||||||
|
cancel-text="取消"
|
||||||
|
@ok="handleDeletePost(record.id)"
|
||||||
|
>
|
||||||
|
<a-button size="small" type="text" status="danger">
|
||||||
|
删除
|
||||||
|
</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</a-table-column>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<!-- 评论互动抽屉 (Drawer) -->
|
||||||
|
<a-drawer
|
||||||
|
v-model:visible="commentsDrawerVisible"
|
||||||
|
:width="520"
|
||||||
|
:title="`动态评论管理 - ${currentPost ? currentPost.author : ''}`"
|
||||||
|
:footer="false"
|
||||||
|
>
|
||||||
|
<div v-if="loadingComments" style="text-align: center; padding: 40px 0">
|
||||||
|
<a-spin />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="commentsList.length > 0">
|
||||||
|
<a-list :bordered="false">
|
||||||
|
<a-list-item v-for="cmt in commentsList" :key="cmt.id">
|
||||||
|
<a-list-item-meta
|
||||||
|
:title="cmt.author"
|
||||||
|
:description="cmt.createdAt"
|
||||||
|
>
|
||||||
|
<template #avatar>
|
||||||
|
<a-avatar :size="32">
|
||||||
|
<img :src="cmt.avatar" alt="avatar" />
|
||||||
|
</a-avatar>
|
||||||
|
</template>
|
||||||
|
</a-list-item-meta>
|
||||||
|
<div class="comment-content-body">{{ cmt.content }}</div>
|
||||||
|
<template #actions>
|
||||||
|
<a-popconfirm
|
||||||
|
content="确定删除该条不当评论吗?"
|
||||||
|
type="warning"
|
||||||
|
ok-text="确认"
|
||||||
|
cancel-text="取消"
|
||||||
|
@ok="handleDeleteComment(cmt.id)"
|
||||||
|
>
|
||||||
|
<a-button size="mini" type="text" status="danger">删除评论</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
</template>
|
||||||
|
</a-list-item>
|
||||||
|
</a-list>
|
||||||
|
</div>
|
||||||
|
<div v-else style="text-align: center; padding: 60px 0; color: var(--color-text-3)">
|
||||||
|
该动态暂无任何评论互动
|
||||||
|
</div>
|
||||||
|
</a-drawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { Message } from '@arco-design/web-vue';
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const postList = ref<any[]>([]);
|
||||||
|
const searchKeyword = ref('');
|
||||||
|
const selectedCategory = ref('all');
|
||||||
|
const selectedStatus = ref('all');
|
||||||
|
|
||||||
|
// 评论抽屉
|
||||||
|
const commentsDrawerVisible = ref(false);
|
||||||
|
const currentPost = ref<any>(null);
|
||||||
|
const loadingComments = ref(false);
|
||||||
|
const commentsList = ref<any[]>([]);
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const params: any = {};
|
||||||
|
if (searchKeyword.value) params.keyword = searchKeyword.value;
|
||||||
|
if (selectedCategory.value && selectedCategory.value !== 'all') params.category = selectedCategory.value;
|
||||||
|
if (selectedStatus.value && selectedStatus.value !== 'all') params.status = selectedStatus.value;
|
||||||
|
|
||||||
|
const res: any = await axios.get('/api/admin/posts', { params });
|
||||||
|
if (res && res.data) {
|
||||||
|
postList.value = res.data;
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
Message.error('获取动态列表失败');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleTop = async (record: any) => {
|
||||||
|
try {
|
||||||
|
const nextTop = !record.isTop;
|
||||||
|
const res: any = await axios.post(`/api/admin/posts/${record.id}/top`, { isTop: nextTop });
|
||||||
|
Message.success(res.data?.message || (nextTop ? '置顶成功' : '已取消置顶'));
|
||||||
|
record.isTop = nextTop;
|
||||||
|
fetchData();
|
||||||
|
} catch (err: any) {
|
||||||
|
Message.error('置顶状态修改失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleStatus = async (record: any) => {
|
||||||
|
try {
|
||||||
|
const nextStatus = record.status === '正常' ? '已隐藏' : '正常';
|
||||||
|
const res: any = await axios.post(`/api/admin/posts/${record.id}/status`, { status: nextStatus });
|
||||||
|
Message.success(res.data?.message || '状态更新成功');
|
||||||
|
record.status = nextStatus;
|
||||||
|
} catch (err: any) {
|
||||||
|
Message.error('状态更新失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePost = async (postId: string) => {
|
||||||
|
try {
|
||||||
|
const res: any = await axios.delete(`/api/admin/posts/${postId}`);
|
||||||
|
Message.success(res.data?.message || '动态已删除');
|
||||||
|
fetchData();
|
||||||
|
} catch (err: any) {
|
||||||
|
Message.error('删除动态失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCommentsDrawer = async (record: any) => {
|
||||||
|
currentPost.value = record;
|
||||||
|
commentsDrawerVisible.value = true;
|
||||||
|
loadingComments.value = true;
|
||||||
|
try {
|
||||||
|
const res: any = await axios.get(`/api/posts/${record.id}/comments`);
|
||||||
|
commentsList.value = res.data || [];
|
||||||
|
} catch (err) {
|
||||||
|
commentsList.value = [];
|
||||||
|
} finally {
|
||||||
|
loadingComments.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteComment = async (commentId: string) => {
|
||||||
|
try {
|
||||||
|
const res: any = await axios.delete(`/api/admin/posts/comments/${commentId}`);
|
||||||
|
Message.success(res.data?.message || '评论已删除');
|
||||||
|
if (currentPost.value) {
|
||||||
|
openCommentsDrawer(currentPost.value);
|
||||||
|
}
|
||||||
|
fetchData();
|
||||||
|
} catch (err: any) {
|
||||||
|
Message.error('删除评论失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchData();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.container {
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
.breadcrumb {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.post-content-cell {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
word-break: break-all;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.post-location-tag {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.topics-wrap {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.stats-row {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.comment-content-body {
|
||||||
|
padding: 6px 0 12px 40px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,6 +8,10 @@
|
|||||||
<a-card class="general-card" title="平台全量交易与分账流水账本">
|
<a-card class="general-card" title="平台全量交易与分账流水账本">
|
||||||
<template #extra>
|
<template #extra>
|
||||||
<a-space>
|
<a-space>
|
||||||
|
<a-button type="outline" status="success" @click="exportCSV">
|
||||||
|
<template #icon><icon-download /></template>
|
||||||
|
导出对账明细 (CSV)
|
||||||
|
</a-button>
|
||||||
<a-button @click="fetchData">
|
<a-button @click="fetchData">
|
||||||
<template #icon><icon-refresh /></template>
|
<template #icon><icon-refresh /></template>
|
||||||
刷新流水
|
刷新流水
|
||||||
@@ -95,6 +99,38 @@
|
|||||||
return 'arcoblue';
|
return 'arcoblue';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const exportCSV = () => {
|
||||||
|
if (!rawList.value || rawList.value.length === 0) {
|
||||||
|
Message.warning('暂无流水数据可导出');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const headers = ['流水号', '交易类型', '金额(元)', '资金方向', '交易对手方', '关联业务ID', '记账时间'];
|
||||||
|
const rows = rawList.value.map(item => [
|
||||||
|
item.recordNo || '',
|
||||||
|
item.type || '',
|
||||||
|
Number(item.amount || 0).toFixed(2),
|
||||||
|
item.direction === 'IN' ? '收入' : '支出',
|
||||||
|
item.counterparty || '',
|
||||||
|
item.relatedId || '',
|
||||||
|
item.createdAt || ''
|
||||||
|
]);
|
||||||
|
|
||||||
|
const csvContent = '\uFEFF' + [headers, ...rows]
|
||||||
|
.map(e => e.map(val => `"${String(val).replace(/"/g, '""')}"`).join(','))
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
link.setAttribute('href', url);
|
||||||
|
link.setAttribute('download', `平台交易资金对账流水_${new Date().toISOString().slice(0, 10)}.csv`);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
Message.success('财务对账流水已成功导出');
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
<a-card class="general-card" title="大神收益提现人工审核与打款通道">
|
<a-card class="general-card" title="大神收益提现人工审核与打款通道">
|
||||||
<template #extra>
|
<template #extra>
|
||||||
<a-space>
|
<a-space>
|
||||||
|
<a-button type="outline" status="success" @click="exportCSV">
|
||||||
|
<template #icon><icon-download /></template>
|
||||||
|
导出提现明细 (CSV)
|
||||||
|
</a-button>
|
||||||
<a-button @click="fetchData">
|
<a-button @click="fetchData">
|
||||||
<template #icon><icon-refresh /></template>
|
<template #icon><icon-refresh /></template>
|
||||||
刷新提现列表
|
刷新提现列表
|
||||||
@@ -164,6 +168,40 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const exportCSV = () => {
|
||||||
|
if (!rawList.value || rawList.value.length === 0) {
|
||||||
|
Message.warning('暂无提现审核记录可导出');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const headers = ['提现单号', '申请大神', '大神ID', '提现金额(元)', '账户余额(元)', '结算渠道', '申请时间', '审核状态', '驳回说明'];
|
||||||
|
const rows = rawList.value.map(item => [
|
||||||
|
item.withdrawNo || '',
|
||||||
|
item.companionName || '',
|
||||||
|
item.companionId || '',
|
||||||
|
Number(item.amount || 0).toFixed(2),
|
||||||
|
Number(item.balance || 0).toFixed(2),
|
||||||
|
item.channel || '',
|
||||||
|
item.applyTime || '',
|
||||||
|
item.status || '',
|
||||||
|
item.rejectReason || item.reason || ''
|
||||||
|
]);
|
||||||
|
|
||||||
|
const csvContent = '\uFEFF' + [headers, ...rows]
|
||||||
|
.map(e => e.map(val => `"${String(val).replace(/"/g, '""')}"`).join(','))
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
link.setAttribute('href', url);
|
||||||
|
link.setAttribute('download', `大神收益提现审核对账表_${new Date().toISOString().slice(0, 10)}.csv`);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
Message.success('提现审核对账表已成功导出');
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -594,6 +594,117 @@
|
|||||||
</a-card>
|
</a-card>
|
||||||
</div>
|
</div>
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
|
|
||||||
|
<!-- Tab 11: 微信服务通知与模板消息 -->
|
||||||
|
<a-tab-pane key="message_template_rules" title="微信服务通知与模板">
|
||||||
|
<div class="tab-pane-content">
|
||||||
|
<a-alert type="info" style="margin-bottom: 20px">
|
||||||
|
在此维护微信小程序官方订阅消息(模板消息)的 Template ID 与推送总开关。用于在订单履约状态变更、组局活动到场、佣金提现到账及现场出示4位核销码时,向微信用户推送官方服务通知。
|
||||||
|
</a-alert>
|
||||||
|
|
||||||
|
<a-card title="微信小程序模板消息配置" :bordered="false" style="background: var(--color-fill-2); margin-bottom: 20px;">
|
||||||
|
<a-form :model="messageTemplateForm.valueJson" layout="vertical">
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="全局订阅消息推送开关">
|
||||||
|
<a-switch
|
||||||
|
:checked-value="1"
|
||||||
|
:unchecked-value="0"
|
||||||
|
v-model="messageTemplateForm.valueJson.enablePush"
|
||||||
|
checked-text="开启微信通知"
|
||||||
|
unchecked-text="停用通知"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="配置描述说明">
|
||||||
|
<a-input v-model="messageTemplateForm.valueJson.remark" placeholder="如:微信小程序订阅消息与服务通知模版ID配置" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="1. 订单履约状态变动通知模板 ID (接单/开始/完成)">
|
||||||
|
<a-input v-model="messageTemplateForm.valueJson.orderStatusTemplateId" placeholder="例如:tmpl_order_status_update_01" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="2. 组局搭子报名与成局通知模板 ID">
|
||||||
|
<a-input v-model="messageTemplateForm.valueJson.activityJoinTemplateId" placeholder="例如:tmpl_activity_joined_02" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="3. 大神佣金提现打款到账通知模板 ID">
|
||||||
|
<a-input v-model="messageTemplateForm.valueJson.withdrawSuccessTemplateId" placeholder="例如:tmpl_withdraw_success_03" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="4. 现场 4 位数字核销码签到提醒模板 ID">
|
||||||
|
<a-input v-model="messageTemplateForm.valueJson.verifyCodeTemplateId" placeholder="例如:tmpl_verify_code_notice_04" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<div style="margin-top: 16px">
|
||||||
|
<a-button type="primary" :loading="saving" @click="saveMessageTemplateConfig">
|
||||||
|
保存微信模板消息配置
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</a-form>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<!-- 模拟测试推送控制台 -->
|
||||||
|
<a-card title="微信订阅通知模拟测试推送" :bordered="false" style="background: var(--color-fill-2)">
|
||||||
|
<a-form :model="testMsgForm" layout="vertical">
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="8">
|
||||||
|
<a-form-item label="测试模板类型">
|
||||||
|
<a-select v-model="testMsgForm.templateType" placeholder="选择要测试的消息类型">
|
||||||
|
<a-option value="order_status">订单履约状态变动通知</a-option>
|
||||||
|
<a-option value="activity_join">组局搭子成局/报名通知</a-option>
|
||||||
|
<a-option value="withdraw_success">提现零钱打款到账通知</a-option>
|
||||||
|
<a-option value="verify_code">现场 4 位核销码签到提醒</a-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="8">
|
||||||
|
<a-form-item label="目标测试用户 ID (UID)">
|
||||||
|
<a-input-number v-model="testMsgForm.targetUserId" placeholder="例如:1001" :min="1" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="8">
|
||||||
|
<a-form-item label="消息通知标题">
|
||||||
|
<a-input v-model="testMsgForm.previewTitle" placeholder="例如:订单履约开始提醒" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
|
||||||
|
<a-form-item label="通知文本详情预览">
|
||||||
|
<a-textarea v-model="testMsgForm.previewDetail" placeholder="输入模拟推送给用户的详细内容" :auto-size="{ minRows: 2, maxRows: 4 }" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<a-space>
|
||||||
|
<a-button type="primary" status="success" :loading="testSending" @click="sendTestMessage">
|
||||||
|
<template #icon><icon-send /></template>
|
||||||
|
发送模拟微信服务通知
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="testResult" style="margin-top: 16px;">
|
||||||
|
<a-alert type="success" :title="`测试推送发送成功 [消息ID: ${testResult.wechatMsgId}]`">
|
||||||
|
推送时间: {{ testResult.sendTime }} | 目标UID: {{ testResult.targetUserId }} | 状态: {{ testResult.status }}
|
||||||
|
</a-alert>
|
||||||
|
</div>
|
||||||
|
</a-form>
|
||||||
|
</a-card>
|
||||||
|
</div>
|
||||||
|
</a-tab-pane>
|
||||||
</a-tabs>
|
</a-tabs>
|
||||||
</a-card>
|
</a-card>
|
||||||
</div>
|
</div>
|
||||||
@@ -891,6 +1002,56 @@ const saveActivityRulesConfig = () => {
|
|||||||
saveConfig('activity_rules', activityRulesForm);
|
saveConfig('activity_rules', activityRulesForm);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Tab 11 微信服务通知与模板消息
|
||||||
|
const messageTemplateForm = reactive({
|
||||||
|
name: '微信服务通知与模板消息配置',
|
||||||
|
description: '微信订阅消息通知与状态模板配置',
|
||||||
|
status: 1,
|
||||||
|
valueJson: {
|
||||||
|
orderStatusTemplateId: 'tmpl_order_status_update_01',
|
||||||
|
activityJoinTemplateId: 'tmpl_activity_joined_02',
|
||||||
|
withdrawSuccessTemplateId: 'tmpl_withdraw_success_03',
|
||||||
|
verifyCodeTemplateId: 'tmpl_verify_code_notice_04',
|
||||||
|
enablePush: 1,
|
||||||
|
remark: '微信小程序订阅消息与服务通知模版ID配置'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const testSending = ref(false);
|
||||||
|
const testResult = ref<any>(null);
|
||||||
|
const testMsgForm = reactive({
|
||||||
|
templateType: 'order_status',
|
||||||
|
targetUserId: 1001,
|
||||||
|
previewTitle: '订单履约开始提醒',
|
||||||
|
previewDetail: '您的同城生活陪伴订单已开始服务,请出示4位核销码完成现场确认。'
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveMessageTemplateConfig = () => {
|
||||||
|
saveConfig('message_template_rules', messageTemplateForm);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendTestMessage = async () => {
|
||||||
|
testSending.value = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
templateType: testMsgForm.templateType,
|
||||||
|
targetUserId: testMsgForm.targetUserId,
|
||||||
|
previewData: {
|
||||||
|
title: testMsgForm.previewTitle,
|
||||||
|
detail: testMsgForm.previewDetail
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const res: any = await axios.post('/api/admin/message/test-send', payload);
|
||||||
|
const data = res?.data?.data || res?.data || res;
|
||||||
|
testResult.value = data;
|
||||||
|
Message.success('测试服务通知推送成功');
|
||||||
|
} catch (err: any) {
|
||||||
|
Message.error(err.response?.data?.message || err.message || '测试推送异常');
|
||||||
|
} finally {
|
||||||
|
testSending.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchConfigs = async () => {
|
const fetchConfigs = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -959,6 +1120,11 @@ const fetchConfigs = async () => {
|
|||||||
if (Array.isArray(activityRulesForm.valueJson.presetTotalFees)) {
|
if (Array.isArray(activityRulesForm.valueJson.presetTotalFees)) {
|
||||||
presetTotalFeesText.value = activityRulesForm.valueJson.presetTotalFees.join(', ');
|
presetTotalFeesText.value = activityRulesForm.valueJson.presetTotalFees.join(', ');
|
||||||
}
|
}
|
||||||
|
} else if (item.configKey === 'message_template_rules') {
|
||||||
|
messageTemplateForm.name = item.name;
|
||||||
|
messageTemplateForm.description = item.description;
|
||||||
|
messageTemplateForm.status = item.status;
|
||||||
|
messageTemplateForm.valueJson = { ...messageTemplateForm.valueJson, ...(item.valueJson || {}) };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -436,6 +436,14 @@ export async function initDatabase(): Promise<void> {
|
|||||||
`);
|
`);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
|
// 扩展 posts 表结构: is_top (置顶), status (正常/隐藏)
|
||||||
|
try {
|
||||||
|
await conn.query('ALTER TABLE posts ADD COLUMN is_top TINYINT DEFAULT 0');
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
await conn.query("ALTER TABLE posts ADD COLUMN status VARCHAR(32) DEFAULT '正常'");
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
// 21. 用户举报工单表 (涉黄/涉赌/线下欺诈等不良陪玩举报)
|
// 21. 用户举报工单表 (涉黄/涉赌/线下欺诈等不良陪玩举报)
|
||||||
await conn.query(`
|
await conn.query(`
|
||||||
CREATE TABLE IF NOT EXISTS reports (
|
CREATE TABLE IF NOT EXISTS reports (
|
||||||
@@ -814,6 +822,15 @@ async function seedInitialDataIfEmpty(conn: any): Promise<void> {
|
|||||||
defaultAaFee: 50
|
defaultAaFee: 50
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const messageTemplateRulesVal = JSON.stringify({
|
||||||
|
orderStatusTemplateId: 'tmpl_order_status_update_01',
|
||||||
|
activityJoinTemplateId: 'tmpl_activity_joined_02',
|
||||||
|
withdrawSuccessTemplateId: 'tmpl_withdraw_success_03',
|
||||||
|
verifyCodeTemplateId: 'tmpl_verify_code_notice_04',
|
||||||
|
enablePush: 1,
|
||||||
|
remark: '微信小程序订阅消息与服务通知模版ID配置'
|
||||||
|
});
|
||||||
|
|
||||||
const [cfgItemRows]: any = await conn.query('SELECT COUNT(*) as count FROM platform_configs');
|
const [cfgItemRows]: any = await conn.query('SELECT COUNT(*) as count FROM platform_configs');
|
||||||
if (cfgItemRows[0].count === 0) {
|
if (cfgItemRows[0].count === 0) {
|
||||||
const customerServiceVal = JSON.stringify({
|
const customerServiceVal = JSON.stringify({
|
||||||
@@ -916,6 +933,12 @@ async function seedInitialDataIfEmpty(conn: any): Promise<void> {
|
|||||||
VALUES ('activity_rules', '同城组局发单与预算预设规范', ?, '招募席位上下限、快捷单人赏金预设与包场预算梯队', 1)
|
VALUES ('activity_rules', '同城组局发单与预算预设规范', ?, '招募席位上下限、快捷单人赏金预设与包场预算梯队', 1)
|
||||||
`, [activityRulesVal]);
|
`, [activityRulesVal]);
|
||||||
}
|
}
|
||||||
|
if (!existingKeys.has('message_template_rules')) {
|
||||||
|
await conn.query(`
|
||||||
|
INSERT INTO platform_configs (config_key, name, value_json, description, status)
|
||||||
|
VALUES ('message_template_rules', '微信服务通知与模板消息配置', ?, '微信订阅消息通知与状态模板配置', 1)
|
||||||
|
`, [messageTemplateRulesVal]);
|
||||||
|
}
|
||||||
if (existingKeys.has('platform_protocol')) {
|
if (existingKeys.has('platform_protocol')) {
|
||||||
const [protoRows]: any = await conn.query("SELECT value_json FROM platform_configs WHERE config_key = 'platform_protocol'");
|
const [protoRows]: any = await conn.query("SELECT value_json FROM platform_configs WHERE config_key = 'platform_protocol'");
|
||||||
if (protoRows && protoRows.length > 0) {
|
if (protoRows && protoRows.length > 0) {
|
||||||
|
|||||||
@@ -2194,20 +2194,34 @@ export class MysqlRepository {
|
|||||||
|
|
||||||
|
|
||||||
// 友圈动态相关
|
// 友圈动态相关
|
||||||
async getPosts(category?: string): Promise<any[]> {
|
async getPosts(category?: string, keyword?: string, status?: string): Promise<any[]> {
|
||||||
let sql = `
|
let sql = `
|
||||||
SELECT
|
SELECT
|
||||||
id, user_id as userId, author, avatar, tag, tag_type as tagType,
|
id, user_id as userId, author, avatar, tag, tag_type as tagType,
|
||||||
location, distance, content, topics, images, action_text as actionText,
|
location, distance, content, topics, images, action_text as actionText,
|
||||||
likes, comments, views, category, created_at as createdAt
|
likes, comments, views, category, is_top as isTop, status, created_at as createdAt
|
||||||
FROM posts
|
FROM posts
|
||||||
`;
|
`;
|
||||||
const params: any[] = [];
|
const params: any[] = [];
|
||||||
|
const where: string[] = [];
|
||||||
|
|
||||||
if (category && category !== 'all') {
|
if (category && category !== 'all') {
|
||||||
sql += ' WHERE category = ?';
|
where.push('category = ?');
|
||||||
params.push(category);
|
params.push(category);
|
||||||
}
|
}
|
||||||
sql += ' ORDER BY created_at DESC';
|
if (keyword) {
|
||||||
|
where.push('(content LIKE ? OR author LIKE ? OR location LIKE ?)');
|
||||||
|
params.push(`%${keyword}%`, `%${keyword}%`, `%${keyword}%`);
|
||||||
|
}
|
||||||
|
if (status && status !== 'all') {
|
||||||
|
where.push('status = ?');
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (where.length > 0) {
|
||||||
|
sql += ' WHERE ' + where.join(' AND ');
|
||||||
|
}
|
||||||
|
sql += ' ORDER BY is_top DESC, created_at DESC';
|
||||||
|
|
||||||
const [rows]: any = await pool.query(sql, params);
|
const [rows]: any = await pool.query(sql, params);
|
||||||
return rows.map((r: any) => {
|
return rows.map((r: any) => {
|
||||||
@@ -2222,12 +2236,45 @@ export class MysqlRepository {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...r,
|
...r,
|
||||||
|
isTop: Boolean(r.isTop),
|
||||||
|
status: r.status || '正常',
|
||||||
topics: topicsArr,
|
topics: topicsArr,
|
||||||
images: imagesArr,
|
images: imagesArr,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 管理端切换置顶
|
||||||
|
async togglePostTop(postId: string, isTop: boolean): Promise<boolean> {
|
||||||
|
const [result]: any = await pool.query(`
|
||||||
|
UPDATE posts SET is_top = ? WHERE id = ?
|
||||||
|
`, [isTop ? 1 : 0, postId]);
|
||||||
|
return result.affectedRows > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理端切换状态 (正常 / 隐藏)
|
||||||
|
async updatePostStatus(postId: string, status: string): Promise<boolean> {
|
||||||
|
const [result]: any = await pool.query(`
|
||||||
|
UPDATE posts SET status = ? WHERE id = ?
|
||||||
|
`, [status, postId]);
|
||||||
|
return result.affectedRows > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理端彻底删除动态及下属评论
|
||||||
|
async adminDeletePost(postId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await pool.query('DELETE FROM post_comments WHERE post_id = ?', [postId]);
|
||||||
|
} catch (_) {}
|
||||||
|
const [result]: any = await pool.query('DELETE FROM posts WHERE id = ?', [postId]);
|
||||||
|
return result.affectedRows > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理端删除特定违规评论
|
||||||
|
async adminDeletePostComment(commentId: string): Promise<boolean> {
|
||||||
|
const [result]: any = await pool.query('DELETE FROM post_comments WHERE id = ?', [commentId]);
|
||||||
|
return result.affectedRows > 0;
|
||||||
|
}
|
||||||
|
|
||||||
async createPost(data: {
|
async createPost(data: {
|
||||||
userId?: number;
|
userId?: number;
|
||||||
author?: string;
|
author?: string;
|
||||||
|
|||||||
@@ -1686,6 +1686,84 @@ app.get('/api/posts', async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 管理端:全量动态管理与检索
|
||||||
|
app.get('/api/admin/posts', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const category = req.query.category ? String(req.query.category) : undefined;
|
||||||
|
const keyword = req.query.keyword ? String(req.query.keyword) : undefined;
|
||||||
|
const status = req.query.status ? String(req.query.status) : undefined;
|
||||||
|
const posts = await repo.getPosts(category, keyword, status);
|
||||||
|
res.json({ code: 20000, data: posts });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ code: 50000, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 管理端:切换置顶状态
|
||||||
|
app.post('/api/admin/posts/:id/top', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const postId = String(req.params.id);
|
||||||
|
const { isTop } = req.body;
|
||||||
|
await repo.togglePostTop(postId, Boolean(isTop));
|
||||||
|
res.json({ code: 20000, message: isTop ? '置顶成功' : '已取消置顶' });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(400).json({ code: 40000, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 管理端:切换显示/隐藏状态
|
||||||
|
app.post('/api/admin/posts/:id/status', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const postId = String(req.params.id);
|
||||||
|
const { status } = req.body;
|
||||||
|
await repo.updatePostStatus(postId, String(status));
|
||||||
|
res.json({ code: 20000, message: '动态状态更新成功' });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(400).json({ code: 40000, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 管理端:违规下架彻底删除
|
||||||
|
app.delete('/api/admin/posts/:id', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const postId = String(req.params.id);
|
||||||
|
await repo.adminDeletePost(postId);
|
||||||
|
res.json({ code: 20000, message: '违规动态及关联评论已彻底删除' });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(400).json({ code: 40000, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 管理端:删除单条不当评论
|
||||||
|
app.delete('/api/admin/posts/comments/:commentId', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const commentId = String(req.params.commentId);
|
||||||
|
await repo.adminDeletePostComment(commentId);
|
||||||
|
res.json({ code: 20000, message: '违规评论已删除' });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(400).json({ code: 40000, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 管理端:微信订阅消息与服务通知模拟测试推送接口
|
||||||
|
app.post('/api/admin/message/test-send', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { templateType, targetUserId, previewData } = req.body;
|
||||||
|
// 模拟微信服务器发送模版消息
|
||||||
|
const result = {
|
||||||
|
templateType: templateType || 'order_status',
|
||||||
|
targetUserId: targetUserId || 1001,
|
||||||
|
sendTime: new Date().toISOString(),
|
||||||
|
status: 'SUCCESS',
|
||||||
|
wechatMsgId: `wx_msg_${Date.now()}`,
|
||||||
|
previewData: previewData || { title: '订单履约提醒', detail: '您的同城生活陪伴订单已开始服务' }
|
||||||
|
};
|
||||||
|
res.json({ code: 20000, data: result, message: '微信模板消息模拟发送成功' });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(400).json({ code: 40000, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/posts', async (req: Request, res: Response) => {
|
app.post('/api/posts', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { content } = req.body || {};
|
const { content } = req.body || {};
|
||||||
|
|||||||
138
server/tests/admin-features.spec.ts
普通文件
138
server/tests/admin-features.spec.ts
普通文件
@@ -0,0 +1,138 @@
|
|||||||
|
// 已读取AGENTS.md
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
describe('PC总后台管理端核心业务与闭环能力自动化测试 (Post Manage, Finance CSV Export, WeChat Template Config)', () => {
|
||||||
|
const rootDir = path.resolve(__dirname, '../../');
|
||||||
|
const serverIndexPath = path.join(rootDir, 'server/src/index.ts');
|
||||||
|
const serverRepoPath = path.join(rootDir, 'server/src/db/repository.ts');
|
||||||
|
const serverMysqlPath = path.join(rootDir, 'server/src/db/mysql.ts');
|
||||||
|
const adminPostManageVuePath = path.join(rootDir, 'admin/arco-design-pro-vite/src/views/community/post-manage/index.vue');
|
||||||
|
const adminFinanceRecordsVuePath = path.join(rootDir, 'admin/arco-design-pro-vite/src/views/finance/records/index.vue');
|
||||||
|
const adminFinanceWithdrawVuePath = path.join(rootDir, 'admin/arco-design-pro-vite/src/views/finance/withdraw/index.vue');
|
||||||
|
const adminPlatformConfigVuePath = path.join(rootDir, 'admin/arco-design-pro-vite/src/views/safety/platform-config/index.vue');
|
||||||
|
const adminCommunityRouterPath = path.join(rootDir, 'admin/arco-design-pro-vite/src/router/routes/modules/community.ts');
|
||||||
|
|
||||||
|
describe('1. 友圈广场动态管理与置顶/状态流转/级联删除闭环验证', () => {
|
||||||
|
it('数据库层必须扩展 posts 表结构并包含 is_top 与 status 字段', () => {
|
||||||
|
const mysqlCode = fs.readFileSync(serverMysqlPath, 'utf-8');
|
||||||
|
expect(mysqlCode).toContain('ALTER TABLE posts ADD COLUMN is_top');
|
||||||
|
expect(mysqlCode).toContain('ALTER TABLE posts ADD COLUMN status');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('仓储层 MysqlRepository 必须实现动态多维筛选、一键置顶、状态切换与评论管理', () => {
|
||||||
|
const repoCode = fs.readFileSync(serverRepoPath, 'utf-8');
|
||||||
|
expect(repoCode).toContain('async getPosts(');
|
||||||
|
expect(repoCode).toContain('ORDER BY is_top DESC, created_at DESC');
|
||||||
|
expect(repoCode).toContain('async togglePostTop(');
|
||||||
|
expect(repoCode).toContain('async updatePostStatus(');
|
||||||
|
expect(repoCode).toContain('async adminDeletePost(');
|
||||||
|
expect(repoCode).toContain('async adminDeletePostComment(');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('后端服务 index.ts 必须注册动态管控的核心管理端 API 路由', () => {
|
||||||
|
const serverCode = fs.readFileSync(serverIndexPath, 'utf-8');
|
||||||
|
expect(serverCode).toContain("app.get('/api/admin/posts'");
|
||||||
|
expect(serverCode).toContain("app.post('/api/admin/posts/:id/top'");
|
||||||
|
expect(serverCode).toContain("app.post('/api/admin/posts/:id/status'");
|
||||||
|
expect(serverCode).toContain("app.delete('/api/admin/posts/:id'");
|
||||||
|
expect(serverCode).toContain("app.delete('/api/admin/posts/comments/:commentId'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('动态管理前端视图 post-manage/index.vue 必须具备完整交互与多图预览', () => {
|
||||||
|
const postManageCode = fs.readFileSync(adminPostManageVuePath, 'utf-8');
|
||||||
|
expect(postManageCode).toContain('handleToggleTop');
|
||||||
|
expect(postManageCode).toContain('handleToggleStatus');
|
||||||
|
expect(postManageCode).toContain('handleDeletePost');
|
||||||
|
expect(postManageCode).toContain('handleDeleteComment');
|
||||||
|
expect(postManageCode).toContain('a-image-preview-group');
|
||||||
|
expect(postManageCode).toContain('a-drawer');
|
||||||
|
expect(postManageCode).toContain('/api/admin/posts');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('2. 财务管理对账明细导出 CSV 规范校验', () => {
|
||||||
|
it('资金交易流水导出必须包含 UTF-8 BOM 与标准字段', () => {
|
||||||
|
const recordsFile = fs.readFileSync(adminFinanceRecordsVuePath, 'utf-8');
|
||||||
|
expect(recordsFile).toContain('exportCSV');
|
||||||
|
expect(recordsFile).toContain('\\uFEFF');
|
||||||
|
expect(recordsFile).toContain('流水号');
|
||||||
|
expect(recordsFile).toContain('交易类型');
|
||||||
|
expect(recordsFile).toContain('金额(元)');
|
||||||
|
expect(recordsFile).toContain('资金方向');
|
||||||
|
expect(recordsFile).toContain('交易对手方');
|
||||||
|
expect(recordsFile).toContain('关联业务ID');
|
||||||
|
expect(recordsFile).toContain('记账时间');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('提现审核列表导出必须包含 UTF-8 BOM 与标准字段', () => {
|
||||||
|
const withdrawFile = fs.readFileSync(adminFinanceWithdrawVuePath, 'utf-8');
|
||||||
|
expect(withdrawFile).toContain('exportCSV');
|
||||||
|
expect(withdrawFile).toContain('\\uFEFF');
|
||||||
|
expect(withdrawFile).toContain('提现单号');
|
||||||
|
expect(withdrawFile).toContain('申请大神');
|
||||||
|
expect(withdrawFile).toContain('大神ID');
|
||||||
|
expect(withdrawFile).toContain('提现金额(元)');
|
||||||
|
expect(withdrawFile).toContain('账户余额(元)');
|
||||||
|
expect(withdrawFile).toContain('结算渠道');
|
||||||
|
expect(withdrawFile).toContain('申请时间');
|
||||||
|
expect(withdrawFile).toContain('审核状态');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('3. 微信小程序订阅消息与服务通知管理配置及模拟推送', () => {
|
||||||
|
it('平台配置种子与增量迁移中必须包含 message_template_rules', () => {
|
||||||
|
const mysqlCode = fs.readFileSync(serverMysqlPath, 'utf-8');
|
||||||
|
expect(mysqlCode).toContain('message_template_rules');
|
||||||
|
expect(mysqlCode).toContain('orderStatusTemplateId');
|
||||||
|
expect(mysqlCode).toContain('activityJoinTemplateId');
|
||||||
|
expect(mysqlCode).toContain('withdrawSuccessTemplateId');
|
||||||
|
expect(mysqlCode).toContain('verifyCodeTemplateId');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('后台平台配置面板已集成微信服务通知与模板消息 Tab 及测试推送控制台', () => {
|
||||||
|
const configViewCode = fs.readFileSync(adminPlatformConfigVuePath, 'utf-8');
|
||||||
|
expect(configViewCode).toContain('message_template_rules');
|
||||||
|
expect(configViewCode).toContain('orderStatusTemplateId');
|
||||||
|
expect(configViewCode).toContain('activityJoinTemplateId');
|
||||||
|
expect(configViewCode).toContain('withdrawSuccessTemplateId');
|
||||||
|
expect(configViewCode).toContain('verifyCodeTemplateId');
|
||||||
|
expect(configViewCode).toContain('sendTestMessage');
|
||||||
|
expect(configViewCode).toContain('/api/admin/message/test-send');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('后端必须提供模拟测试推送端点并正确返回结构化结果', () => {
|
||||||
|
const serverCode = fs.readFileSync(serverIndexPath, 'utf-8');
|
||||||
|
expect(serverCode).toContain("app.post('/api/admin/message/test-send'");
|
||||||
|
expect(serverCode).toContain('wechatMsgId');
|
||||||
|
expect(serverCode).toContain('status');
|
||||||
|
expect(serverCode).toContain('sendTime');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('管理后台路由正确注册 community 模块并挂载 post-manage', () => {
|
||||||
|
expect(fs.existsSync(adminCommunityRouterPath)).toBe(true);
|
||||||
|
const routerCode = fs.readFileSync(adminCommunityRouterPath, 'utf-8');
|
||||||
|
expect(routerCode).toContain('/community');
|
||||||
|
expect(routerCode).toContain('post-manage');
|
||||||
|
expect(routerCode).toContain('icon-message');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('4. 全流程无 Emoji 门禁合规校验', () => {
|
||||||
|
it('新增的前后端代码中严禁包含任何系统 Emoji 图标字符', () => {
|
||||||
|
const filesToCheck = [
|
||||||
|
adminCommunityRouterPath,
|
||||||
|
adminPostManageVuePath,
|
||||||
|
adminFinanceRecordsVuePath,
|
||||||
|
adminFinanceWithdrawVuePath,
|
||||||
|
adminPlatformConfigVuePath
|
||||||
|
];
|
||||||
|
|
||||||
|
const emojiRegex = /[\uD83C-\uDBFF\uDC00-\uDFFF\u2600-\u26FF\u2700-\u27BF]/;
|
||||||
|
for (const file of filesToCheck) {
|
||||||
|
const content = fs.readFileSync(file, 'utf-8');
|
||||||
|
expect(emojiRegex.test(content)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户