feat(admin): complete post management, finance csv export, and wechat message template config

这个提交包含在:
xcbyy
2026-10-13 11:11:40 +08:00
父节点 3bb6f5a6be
当前提交 736450973e
共有 11 个文件被更改,包括 951 次插入 和 4 次删除

查看文件

@@ -52,6 +52,8 @@ export default {
'menu.banner': 'Banner & Operations',
'menu.banner.manage': 'Banner Management',
'menu.banner.navCategory': 'Category Navigation',
'menu.community': 'Community & Content',
'menu.community.postManage': 'Post & Comment Audit',
'menu.server.dashboard': 'Dashboard-Server',
'menu.server.workplace': 'Workplace-Server',
'menu.server.monitor': 'Monitor-Server',

查看文件

@@ -52,6 +52,8 @@ export default {
'menu.banner': '首页轮播与运营',
'menu.banner.manage': '轮播图管理',
'menu.banner.navCategory': '金刚区导航品类',
'menu.community': '友圈与内容管理',
'menu.community.postManage': '动态与评论监管',
'menu.server.dashboard': '仪表盘-服务端',
'menu.server.workplace': '工作台-服务端',
'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="平台全量交易与分账流水账本">
<template #extra>
<a-space>
<a-button type="outline" status="success" @click="exportCSV">
<template #icon><icon-download /></template>
导出对账明细 (CSV)
</a-button>
<a-button @click="fetchData">
<template #icon><icon-refresh /></template>
刷新流水
@@ -95,6 +99,38 @@
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(() => {
fetchData();
});

查看文件

@@ -8,6 +8,10 @@
<a-card class="general-card" title="大神收益提现人工审核与打款通道">
<template #extra>
<a-space>
<a-button type="outline" status="success" @click="exportCSV">
<template #icon><icon-download /></template>
导出提现明细 (CSV)
</a-button>
<a-button @click="fetchData">
<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(() => {
fetchData();
});

查看文件

@@ -594,6 +594,117 @@
</a-card>
</div>
</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-card>
</div>
@@ -891,6 +1002,56 @@ const saveActivityRulesConfig = () => {
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 () => {
loading.value = true;
try {
@@ -959,6 +1120,11 @@ const fetchConfigs = async () => {
if (Array.isArray(activityRulesForm.valueJson.presetTotalFees)) {
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 (_) {}
// 扩展 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. 用户举报工单表 (涉黄/涉赌/线下欺诈等不良陪玩举报)
await conn.query(`
CREATE TABLE IF NOT EXISTS reports (
@@ -814,6 +822,15 @@ async function seedInitialDataIfEmpty(conn: any): Promise<void> {
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');
if (cfgItemRows[0].count === 0) {
const customerServiceVal = JSON.stringify({
@@ -916,6 +933,12 @@ async function seedInitialDataIfEmpty(conn: any): Promise<void> {
VALUES ('activity_rules', '同城组局发单与预算预设规范', ?, '招募席位上下限、快捷单人赏金预设与包场预算梯队', 1)
`, [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')) {
const [protoRows]: any = await conn.query("SELECT value_json FROM platform_configs WHERE config_key = 'platform_protocol'");
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 = `
SELECT
id, user_id as userId, author, avatar, tag, tag_type as tagType,
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
`;
const params: any[] = [];
const where: string[] = [];
if (category && category !== 'all') {
sql += ' WHERE category = ?';
where.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);
return rows.map((r: any) => {
@@ -2222,12 +2236,45 @@ export class MysqlRepository {
return {
...r,
isTop: Boolean(r.isTop),
status: r.status || '正常',
topics: topicsArr,
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: {
userId?: number;
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) => {
try {
const { content } = req.body || {};

查看文件

@@ -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);
}
});
});
});