feat: security hardening, wechat pay v3 platform cert integration, companion certification revocation, and audit closed-loop

这个提交包含在:
xcbyy
2026-10-16 17:38:17 +08:00
父节点 3bf38db8b6
当前提交 78117bb4e1
共有 58 个文件被更改,包括 12971 次插入 和 952 次删除

查看文件

@@ -176,6 +176,16 @@
<a-form-item label="线下公开商圈集合地点" required>
<a-input v-model="createForm.location" placeholder="必须为商圈公共餐厅/KTV等公开场所" />
</a-form-item>
<a-form-item label="活动时段" required>
<a-date-picker
v-model="createForm.startTime"
show-time
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DD HH:mm"
placeholder="请选择活动开始时间"
style="width: 100%"
/>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="付费模式">
@@ -240,6 +250,7 @@
title: '',
gameCategory: '同城陪吃',
location: '',
startTime: '',
payType: 1,
maxParticipants: 4,
perPersonFee: 100,
@@ -307,6 +318,7 @@
title: '',
gameCategory: '同城陪吃',
location: '',
startTime: '',
payType: 1,
maxParticipants: 4,
perPersonFee: 100,
@@ -319,12 +331,17 @@
Message.warning('请填写完整的组局标题与公开地点');
return;
}
if (!createForm.value.startTime) {
Message.warning('请选择活动时段');
return;
}
try {
await axios.post('/api/activities', {
creatorId: 1001,
title: createForm.value.title,
gameCategory: createForm.value.gameCategory,
location: createForm.value.location,
startTime: createForm.value.startTime,
payType: createForm.value.payType,
maxParticipants: createForm.value.maxParticipants,
perPersonFeeCents: createForm.value.perPersonFee * 100,

查看文件

@@ -355,14 +355,14 @@
try {
if (isEdit.value && editingId.value) {
const res: any = await axios.put(`/api/admin/banners/${editingId.value}`, formData.value);
if (res && (res.data.code === 20000 || res.status === 200)) {
if (res && res.code === 20000) {
Message.success('轮播图修改成功');
modalVisible.value = false;
fetchData();
}
} else {
const res: any = await axios.post('/api/admin/banners', formData.value);
if (res && (res.data.code === 20000 || res.status === 200)) {
if (res && res.code === 20000) {
Message.success('轮播图创建成功,已同步至小程序端');
modalVisible.value = false;
fetchData();
@@ -380,7 +380,7 @@
const res: any = await axios.post(`/api/admin/banners/${record.id}/status`, {
status: newStatus,
});
if (res && (res.data.code === 20000 || res.status === 200)) {
if (res && res.code === 20000) {
record.status = newStatus;
Message.success(newStatus === 1 ? '已上架至小程序端' : '已下线隐藏');
}
@@ -394,7 +394,7 @@
const handleDelete = async (id: number) => {
try {
const res: any = await axios.delete(`/api/admin/banners/${id}`);
if (res && (res.data.code === 20000 || res.status === 200)) {
if (res && res.code === 20000) {
Message.success('轮播图已删除');
fetchData();
}

查看文件

@@ -311,14 +311,14 @@
try {
if (isEdit.value && editingId.value) {
const res: any = await axios.put(`/api/admin/nav-categories/${editingId.value}`, formData.value);
if (res && (res.data.code === 20000 || res.status === 200)) {
if (res && res.code === 20000) {
Message.success('品类导航修改成功');
modalVisible.value = false;
fetchData();
}
} else {
const res: any = await axios.post('/api/admin/nav-categories', formData.value);
if (res && (res.data.code === 20000 || res.status === 200)) {
if (res && res.code === 20000) {
Message.success('品类导航新增成功,已同步至小程序首页');
modalVisible.value = false;
fetchData();
@@ -336,7 +336,7 @@
const res: any = await axios.post(`/api/admin/nav-categories/${record.id}/status`, {
status: newStatus,
});
if (res && (res.data.code === 20000 || res.status === 200)) {
if (res && res.code === 20000) {
record.status = newStatus;
Message.success(newStatus === 1 ? '品类已上架至小程序' : '品类已下线隐藏');
}
@@ -350,7 +350,7 @@
const handleDelete = async (id: number) => {
try {
const res: any = await axios.delete(`/api/admin/nav-categories/${id}`);
if (res && (res.data.code === 20000 || res.status === 200)) {
if (res && res.code === 20000) {
Message.success('品类导航已彻底删除');
fetchData();
}

查看文件

@@ -78,15 +78,21 @@
</template>
</a-table-column>
<a-table-column title="申请时间" data-index="applyTime" :width="160" />
<a-table-column title="当前状态" :width="110">
<a-table-column title="当前状态" :width="140">
<template #cell="{ record }">
<a-badge
:status="record.auditStatus === '已通过' ? 'success' : record.auditStatus === '已驳回' ? 'danger' : 'warning'"
:text="record.auditStatus"
/>
<div
v-if="record.auditStatus === '已驳回' && record.rejectReason"
style="font-size: 12px; color: var(--color-text-3); margin-top: 2px"
>
原因:{{ record.rejectReason }}
</div>
</template>
</a-table-column>
<a-table-column title="审核操作" :width="180" fixed="right">
<a-table-column title="审核操作" :width="200" fixed="right">
<template #cell="{ record }">
<a-space v-if="record.auditStatus === '待审核'">
<a-button
@@ -101,30 +107,51 @@
type="outline"
status="danger"
size="small"
@click="openRejectModal(record)"
@click="openRejectModal(record, 'reject')"
>
驳回
</a-button>
</a-space>
<span v-else style="color: var(--color-text-3); font-size: 12px">已审核结案</span>
<a-space v-else-if="record.auditStatus === '已通过'">
<span style="color: var(--color-text-3); font-size: 12px">已审核结案</span>
<a-button
type="outline"
status="danger"
size="mini"
@click="openRejectModal(record, 'revoke')"
>
解除认证
</a-button>
</a-space>
<a-space v-else>
<span style="color: var(--color-text-3); font-size: 12px">已审核结案</span>
<a-button
type="outline"
status="success"
size="mini"
@click="handleAudit(record.id, 'approve')"
>
恢复认证
</a-button>
</a-space>
</template>
</a-table-column>
</template>
</a-table>
</a-card>
<!-- 驳回原因 Modal -->
<!-- 驳回/解除认证原因 Modal -->
<a-modal
v-model:visible="rejectModalVisible"
title="填写资质审核驳回原因"
:title="rejectActionType === 'revoke' ? '填写解除大神认证原因' : '填写资质审核驳回原因'"
@ok="handleRejectSubmit"
@cancel="rejectModalVisible = false"
>
<a-form :model="rejectForm" layout="vertical">
<a-form-item label="驳回原因说明" required>
<a-form-item :label="rejectActionType === 'revoke' ? '解除认证原因说明' : '驳回原因说明'" required>
<a-textarea
v-model="rejectForm.reason"
placeholder="例如:段位凭证与本人信息不符,或真人语音包含背景嘈杂噪音,请重新录制提交。"
:placeholder="rejectActionType === 'revoke' ? '例如:服务评分低于平台标准、多次被用户投诉违约,现解除大神认证资质。' : '例如:段位凭证与本人信息不符,或真人语音包含背景嘈杂噪音,请重新录制提交。'"
:auto-size="{ minRows: 3, maxRows: 5 }"
/>
</a-form-item>
@@ -143,9 +170,10 @@
const statusTab = ref('ALL');
const playingId = ref<number | null>(null);
// 驳回弹窗
// 驳回/解除认证弹窗
const rejectModalVisible = ref(false);
const currentRejectItem = ref<any>(null);
const rejectActionType = ref<'reject' | 'revoke'>('reject');
const rejectForm = ref({
reason: '',
});
@@ -195,32 +223,39 @@
}, 3000);
};
const handleAudit = async (id: number, action: 'approve' | 'reject', reason?: string) => {
const handleAudit = async (id: number, action: 'approve' | 'reject' | 'revoke', reason?: string) => {
try {
await axios.post(`/api/companions/${id}/audit`, {
action,
reason,
});
Message.success(action === 'approve' ? '审核通过,已开通接单资质' : '已成功驳回');
Message.success(
action === 'approve'
? '审核通过,已开通接单资质'
: action === 'revoke'
? '已解除大神认证资质'
: '已成功驳回'
);
fetchData();
} catch (err: any) {
Message.error(err.message || '操作失败');
}
};
const openRejectModal = (record: any) => {
const openRejectModal = (record: any, actionType: 'reject' | 'revoke' = 'reject') => {
currentRejectItem.value = record;
rejectActionType.value = actionType;
rejectForm.value.reason = '';
rejectModalVisible.value = true;
};
const handleRejectSubmit = () => {
if (!rejectForm.value.reason) {
Message.warning('请填写具体的驳回原因');
Message.warning(rejectActionType.value === 'revoke' ? '请填写解除认证的原因' : '请填写具体的驳回原因');
return;
}
if (currentRejectItem.value) {
handleAudit(currentRejectItem.value.id, 'reject', rejectForm.value.reason);
handleAudit(currentRejectItem.value.id, rejectActionType.value, rejectForm.value.reason);
rejectModalVisible.value = false;
}
};

查看文件

@@ -131,20 +131,64 @@
/>
</template>
</a-table-column>
<a-table-column title="操作" :width="130" fixed="right">
<a-table-column title="操作" :width="210" fixed="right">
<template #cell="{ record }">
<a-button
size="small"
:type="record.status === '接单中' ? 'outline' : 'primary'"
@click="toggleStatus(record)"
>
{{ record.status === '接单中' ? '设为休息' : '恢复接单' }}
</a-button>
<a-space>
<a-button
size="small"
:type="record.status === '接单中' ? 'outline' : 'primary'"
@click="toggleStatus(record)"
>
{{ record.status === '接单中' ? '设为休息' : '恢复接单' }}
</a-button>
<a-button
size="small"
type="outline"
status="danger"
@click="openRevokeModal(record)"
>
解除认证
</a-button>
</a-space>
</template>
</a-table-column>
</template>
</a-table>
</a-card>
<!-- 解除大神认证资质 Modal -->
<a-modal
v-model:visible="revokeModalVisible"
title="解除大神认证资质"
ok-text="确认解除认证"
:ok-button-props="{ status: 'danger' }"
@ok="handleRevokeSubmit"
@cancel="revokeModalVisible = false"
>
<a-alert type="warning" style="margin-bottom: 16px">
解除【{{ currentRevokeItem?.name }}】的大神认证资质后,该大神将立即从小程序大神列表下架,并退回为普通玩家身份(如需恢复接单须重新提交资质审核或由运营在审核列表恢复)。
</a-alert>
<a-form :model="revokeForm" layout="vertical">
<a-form-item label="解除认证原因说明" required>
<a-space wrap style="margin-bottom: 8px">
<a-tag
v-for="preset in revokePresets"
:key="preset"
checkable
:checked="revokeForm.reason === preset"
@check="revokeForm.reason = preset"
>
{{ preset }}
</a-tag>
</a-space>
<a-textarea
v-model="revokeForm.reason"
placeholder="请填写解除大神认证的具体原因(将同步展示在用户端认证状态说明中)"
:auto-size="{ minRows: 3, maxRows: 5 }"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
@@ -160,6 +204,18 @@
const statusFilter = ref('ALL');
const playingId = ref<number | null>(null);
const revokeModalVisible = ref(false);
const currentRevokeItem = ref<any>(null);
const revokeForm = ref({
reason: '',
});
const revokePresets = [
'服务评分低于平台考核标准,暂停接单资质',
'多次收到用户违约或服务态度投诉,清退整改',
'实名身份或语音资料复核不符,需重新提交认证',
'大神本人主动申请退网解约',
];
const fetchData = async () => {
loading.value = true;
try {
@@ -216,9 +272,43 @@
}
};
const toggleStatus = (record: any) => {
record.status = record.status === '接单中' ? '休息中' : '接单中';
Message.success(`大神【${record.name}】状态已切换为【${record.status}】`);
const toggleStatus = async (record: any) => {
const nextStatus = record.status === '接单中' ? '休息中' : '接单中';
try {
await axios.put(`/api/companions/${record.id}`, {
status: nextStatus,
});
record.status = nextStatus;
Message.success(`大神【${record.name}】状态已切换为【${nextStatus}】`);
} catch (err: any) {
Message.error(err.message || '切换接单状态失败');
}
};
const openRevokeModal = (record: any) => {
currentRevokeItem.value = record;
revokeForm.value.reason = '';
revokeModalVisible.value = true;
};
const handleRevokeSubmit = async () => {
const reason = revokeForm.value.reason.trim();
if (!reason) {
Message.warning('请填写或选择解除认证的原因');
return;
}
if (!currentRevokeItem.value) return;
try {
await axios.post(`/api/companions/${currentRevokeItem.value.id}/audit`, {
action: 'revoke',
reason,
});
Message.success(`已解除【${currentRevokeItem.value.name}】的大神认证资质`);
revokeModalVisible.value = false;
fetchData();
} catch (err: any) {
Message.error(err.message || '解除认证失败');
}
};
onMounted(() => {

查看文件

@@ -1,10 +1,13 @@
<template>
<div class="action-hub-container">
<a-alert v-if="loadFailed" type="warning" style="margin-bottom: 12px">
待办数据加载失败,"需要人工介入"计数可能显示为 0,请勿据此判断当前无待办;请刷新页面或检查服务端接口。
</a-alert>
<div class="hub-header">
<div class="hub-title">
<icon-thunderbolt style="color: #ff7d00; margin-right: 6px; font-size: 16px" />
<span>实时待办与决策中心</span>
<span class="hub-badge">需要人工介入 ({{ totalUrgentCount }})</span>
<span class="hub-badge">需要人工介入 ({{ loadFailed ? '加载失败' : totalUrgentCount }})</span>
</div>
<div class="hub-subtitle">
跨角色协同决策流:运营现场核验、风控资质审核、争议仲裁裁决、财务提现打款
@@ -127,16 +130,18 @@
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import axios from 'axios';
import { Message } from '@arco-design/web-vue';
const router = useRouter();
const loadFailed = ref(false);
const todos = ref({
pendingAuditsCount: 2,
pendingArbitrationsCount: 1,
pendingWithdrawalsCount: 1,
pendingWithdrawalAmount: 1250,
pendingReportsCount: 1,
riskInterceptionsCount: 3,
pendingAuditsCount: 0,
pendingArbitrationsCount: 0,
pendingWithdrawalsCount: 0,
pendingWithdrawalAmount: 0,
pendingReportsCount: 0,
riskInterceptionsCount: 0,
});
const totalUrgentCount = computed(() => {
@@ -153,9 +158,15 @@
const res: any = await axios.get('/api/dashboard/todos');
if (res && res.data) {
todos.value = res.data;
loadFailed.value = false;
} else {
loadFailed.value = true;
Message.error('待办数据加载失败,请刷新重试');
}
} catch {
// 保持兜底状态
// 不再静默兜底为 0:显式标记失败,避免管理员误判"当前无待办"
loadFailed.value = true;
Message.error('待办数据加载失败,请刷新重试');
}
};

查看文件

@@ -11,7 +11,10 @@
<template #extra>
<a-link>{{ $t('workplace.viewMore') }}</a-link>
</template>
<Chart height="289px" :option="chartOption" />
<a-alert v-if="loadFailed" type="error" style="margin-bottom: 12px">
近 7 日成交走势加载失败,图表暂不可用;请刷新页面或检查服务端接口。
</a-alert>
<Chart v-else height="289px" :option="chartOption" />
</a-card>
</a-spin>
</template>
@@ -19,6 +22,7 @@
<script lang="ts" setup>
import { ref } from 'vue';
import axios from 'axios';
import { Message } from '@arco-design/web-vue';
import { graphic } from 'echarts';
import useLoading from '@/hooks/loading';
import { queryContentData, ContentDataRecord } from '@/api/dashboard';
@@ -40,6 +44,7 @@
};
}
const { loading, setLoading } = useLoading(true);
const loadFailed = ref(false);
const xAxis = ref<string[]>([]);
const chartsData = ref<number[]>([]);
const graphicElements = ref([
@@ -179,35 +184,36 @@
setLoading(true);
try {
const res: any = await axios.get('/api/dashboard/stats');
let trendData = [
{ x: '09-22', y: 32000 },
{ x: '09-23', y: 38500 },
{ x: '09-24', y: 42100 },
{ x: '09-25', y: 49800 },
{ x: '09-26', y: 53200 },
{ x: '09-27', y: 56400 },
{ x: '09-28', y: 58920 },
];
if (res && res.data && res.data.recentGmvTrend) {
trendData = res.data.recentGmvTrend.map((item: any) => ({
x: item.date,
y: item.gmv,
}));
const trend =
res && res.data && Array.isArray(res.data.recentGmvTrend)
? res.data.recentGmvTrend
: [];
if (trend.length === 0) {
// 不再以硬编码假走势兜底
loadFailed.value = true;
xAxis.value = [];
chartsData.value = [];
Message.error('近 7 日成交走势暂无可展示数据,请刷新重试');
return;
}
loadFailed.value = false;
xAxis.value = [];
chartsData.value = [];
trendData.forEach((el: any, idx: number) => {
xAxis.value.push(el.x);
chartsData.value.push(el.y);
trend.forEach((el: any, idx: number) => {
xAxis.value.push(el.date);
chartsData.value.push(el.gmv);
if (idx === 0) {
graphicElements.value[0].style.text = el.x;
graphicElements.value[0].style.text = el.date;
}
if (idx === trendData.length - 1) {
graphicElements.value[1].style.text = el.x;
if (idx === trend.length - 1) {
graphicElements.value[1].style.text = el.date;
}
});
} catch (err) {
// fallback
loadFailed.value = true;
xAxis.value = [];
chartsData.value = [];
Message.error('近 7 日成交走势加载失败,请刷新重试');
} finally {
setLoading(false);
}

查看文件

@@ -1,5 +1,10 @@
<template>
<a-grid :cols="24" :row-gap="16" class="panel">
<a-grid-item v-if="loadFailed" :span="24">
<a-alert type="error" style="margin-bottom: 8px">
核心经营指标加载失败,以下数字为占位值,请勿据此对外汇报或决策;请刷新页面或检查服务端接口。
</a-alert>
</a-grid-item>
<a-grid-item
class="panel-col"
:span="{ xs: 12, sm: 12, md: 12, lg: 12, xl: 12, xxl: 6 }"
@@ -100,6 +105,7 @@
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
import axios from 'axios';
import { Message } from '@arco-design/web-vue';
withDefaults(
defineProps<{
@@ -110,21 +116,28 @@
}
);
const loadFailed = ref(false);
const stats = ref({
todayGmv: 58920,
todayOrders: 142,
onlineCompanions: 86,
activeUsers: 1250,
todayGmv: 0,
todayOrders: 0,
onlineCompanions: 0,
activeUsers: 0,
});
onMounted(async () => {
try {
const res: any = await axios.get('/api/dashboard/stats');
if (res && res.data && res.data.todayGmv) {
if (res && res.data && res.data.todayGmv !== undefined) {
stats.value = res.data;
loadFailed.value = false;
} else {
loadFailed.value = true;
Message.error('核心经营指标加载失败,请刷新重试');
}
} catch {
// 保持兜底真实指标
// 不再用伪造数字兜底:显式标记失败并提示,避免管理员误判经营数据
loadFailed.value = true;
Message.error('核心经营指标加载失败,请刷新重试');
}
});
</script>

查看文件

@@ -1,5 +1,8 @@
<template>
<a-card class="general-card" title="重点待办极速处理" :header-style="{ paddingBottom: '0' }">
<a-alert v-if="loadFailed" type="warning" style="margin-bottom: 12px">
重点待办加载失败,列表为空并不代表无待办;请刷新页面或检查服务端接口。
</a-alert>
<div class="todo-list">
<!-- 1. 待审大神 -->
<div v-for="audit in pendingAudits" :key="audit.id" class="todo-item audit-item">
@@ -99,6 +102,7 @@
const pendingWithdrawals = ref<any[]>([]);
const pendingArbitrations = ref<any[]>([]);
const pendingReports = ref<any[]>([]);
const loadFailed = ref(false);
const fetchQuickTodos = async () => {
try {
@@ -108,9 +112,15 @@
pendingWithdrawals.value = (res.data.pendingWithdrawals || []).slice(0, 1);
pendingArbitrations.value = (res.data.pendingArbitrations || []).slice(0, 1);
pendingReports.value = (res.data.pendingReports || []).slice(0, 1);
loadFailed.value = false;
} else {
loadFailed.value = true;
Message.error('重点待办加载失败,请刷新重试');
}
} catch {
// 保持兜底
// 不再静默兜底为空列表:显式标记失败,避免管理员漏处理待办
loadFailed.value = true;
Message.error('重点待办加载失败,请刷新重试');
}
};

查看文件

@@ -6,6 +6,9 @@
</router-link>
</template>
<div class="verifier-box">
<a-alert v-if="activitiesFailed" type="warning" style="margin-bottom: 10px">
组局活动列表加载失败,暂无法选择要核销的活动;请刷新页面或检查服务端接口后再试。
</a-alert>
<div class="tip-text">现场用户出示4位纯数字即可即时到场校验</div>
<a-select
v-model="selectedActivityId"
@@ -55,6 +58,7 @@
const selectedActivityId = ref('');
const activityList = ref<any[]>([]);
const lastVerifiedResult = ref('');
const activitiesFailed = ref(false);
const fetchActivities = async () => {
try {
@@ -63,8 +67,11 @@
activityList.value = res.data;
selectedActivityId.value = res.data[0].id;
}
activitiesFailed.value = false;
} catch {
// 保持兜底
// 不再静默兜底为空活动列表:显式标记失败,避免现场核销误判"无活动可选"
activitiesFailed.value = true;
Message.error('组局活动列表加载失败,暂无法核销,请刷新重试');
}
};

查看文件

@@ -5,6 +5,15 @@
<a-breadcrumb-item>分销与抽成配置</a-breadcrumb-item>
</a-breadcrumb>
<a-alert
v-if="!configLoaded || !wxPayLoaded"
type="warning"
style="margin-bottom: 20px"
title="配置加载未成功,保存按钮已锁定"
>
检测到配置读取失败。为避免用页面默认值覆盖线上正确配置,保存与自检按钮已暂时禁用;请刷新页面或检查服务端接口后重试。
</a-alert>
<a-card class="general-card" title="平台抽成比例与二级分销返佣参数策略">
<a-alert type="info" style="margin-bottom: 20px">
参数全局生效说明:修改抽成与分销比例后,新生成的订单将即刻采用最新费率进行自动清结算分账;老订单按照下单时的历史固定抽成费率履约。
@@ -91,13 +100,240 @@
</a-form-item>
<a-form-item style="margin-top: 24px">
<a-button type="primary" :loading="saving" @click="handleSave">
<a-button type="primary" :loading="saving" :disabled="!configLoaded" @click="handleSave">
<template #icon><icon-save /></template>
保存并实时生效
</a-button>
</a-form-item>
</a-form>
</a-card>
<!-- 微信支付 V3 商户号与 API 证书配置卡片 -->
<a-card
class="general-card"
style="margin-top: 20px"
title="微信小程序支付商户号与 APIv3 证书配置 (WeChat Pay JSAPI)"
>
<template #extra>
<a-space>
<a-tag :color="wxPayEffectiveMode === 'LIVE' ? 'green' : 'arcoblue'" size="large">
{{
wxPayEffectiveMode === 'LIVE'
? '当前运行状态:微信支付实盘模式 (LIVE)'
: '当前运行状态:沙箱收银台模式 (SANDBOX)'
}}
</a-tag>
<a-button type="outline" size="small" :loading="wxPayTesting" :disabled="!wxPayLoaded" @click="handleTestWxPay">
配置自检与签名验证
</a-button>
</a-space>
</template>
<a-alert type="info" style="margin-bottom: 20px">
当您申请下来微信支付商户号后,在此填入商户号 (mchid)、APIv3 密钥及商户私钥证书 (apiclient_key.pem) 并保存,小程序端下单与钱包充值即可无缝切换为真实微信支付收银台。未配置完整前默认运行于沙箱模拟收银台模式,不影响功能验收。
</a-alert>
<a-form :model="wxPayForm" layout="vertical" style="max-width: 820px">
<a-row :gutter="24">
<a-col :span="12">
<a-form-item
label="支付通道运行模式"
help="推荐使用自动模式:填齐下方商户号、32位APIv3密钥与私钥后自动启用真实微信支付"
>
<a-select v-model="wxPayForm.mode">
<a-option value="AUTO">自动识别切换 (填齐证书即开启实盘,未填齐走沙箱)</a-option>
<a-option value="LIVE">强制微信支付实盘模式 (LIVE - 生产环境)</a-option>
<a-option value="SANDBOX">强制沙箱收银台模式 (SANDBOX - 演示测试)</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="微信小程序 AppID (appId)"
help="微信公众平台分配的小程序 AppID,如 wx0c929f7e54024e67"
>
<a-input v-model="wxPayForm.appId" placeholder="请输入 wx 开头的小程序 AppID" allow-clear />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item
label="微信小程序 AppSecret (小程序密钥)"
help="微信公众平台-开发管理中生成的 32 位 AppSecret,用于换取用户 openid"
>
<a-input-password
v-model="wxPayForm.appSecret"
placeholder="请输入 32 位 AppSecret 密钥"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="微信支付直连商户号 (mchId)"
help="微信支付商户平台分配的 10 位纯数字商户号"
>
<a-input v-model="wxPayForm.mchId" placeholder="例如:1600000000" allow-clear />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item
label="商户 APIv3 密钥 (apiV3Key)"
help="在【微信商户平台 -> 账户中心 -> API安全】中设置的 32 位 APIv3 密钥"
>
<a-input-password
v-model="wxPayForm.apiV3Key"
placeholder="请输入 32 位 APIv3 密钥"
allow-clear
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item
label="商户 API 证书序列号 (serialNo)"
help="在【账户中心 -> API安全 -> API证书 -> 查看证书】中复制 40 位证书序列号"
>
<a-input
v-model="wxPayForm.serialNo"
placeholder="例如:444F4864EA9B34415..."
allow-clear
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="微信支付异步通知回调地址 (notifyUrl)"
help="微信支付成功后的 V3 Webhook 异步通知接收地址"
>
<a-input
v-model="wxPayForm.notifyUrl"
placeholder="https://peiww.gynhc.com/api/pay/wechat/notify"
/>
</a-form-item>
</a-col>
</a-row>
<a-divider orientation="left">商户 API 证书与私钥内容 (PEM 格式)</a-divider>
<a-form-item
label="商户 API 私钥内容 (apiclient_key.pem)"
help="支持直接粘贴 apiclient_key.pem 全文,或点击右侧按钮从本地电脑选择 apiclient_key.pem 文件自动读取"
>
<div style="width: 100%">
<div style="margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 12px; color: var(--color-text-3)">
包含 -----BEGIN PRIVATE KEY----- 至 -----END PRIVATE KEY-----
</span>
<a-button type="outline" size="mini" @click="triggerPemFileSelect('privateKey')">
读取本地 apiclient_key.pem 文件
</a-button>
</div>
<a-textarea
v-model="wxPayForm.privateKey"
placeholder="-----BEGIN PRIVATE KEY-----&#10;MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC...&#10;-----END PRIVATE KEY-----"
:auto-size="{ minRows: 4, maxRows: 8 }"
style="font-family: monospace; font-size: 12px"
/>
</div>
</a-form-item>
<a-form-item
label="商户 API 证书内容 (apiclient_cert.pem,选填)"
help="支持直接粘贴 apiclient_cert.pem 全文,或点击右侧按钮读取本地文件"
>
<div style="width: 100%">
<div style="margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 12px; color: var(--color-text-3)">
包含 -----BEGIN CERTIFICATE----- 至 -----END CERTIFICATE-----
</span>
<a-button type="outline" size="mini" @click="triggerPemFileSelect('certPem')">
读取本地 apiclient_cert.pem 文件
</a-button>
</div>
<a-textarea
v-model="wxPayForm.certPem"
placeholder="-----BEGIN CERTIFICATE-----&#10;MIID3DCCAsSgAwIBAgIUN...&#10;-----END CERTIFICATE-----"
:auto-size="{ minRows: 3, maxRows: 6 }"
style="font-family: monospace; font-size: 12px"
/>
</div>
</a-form-item>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item
label="微信支付平台公钥 ID (PUB_KEY_ID,选填)"
help="用于微信支付回调验签的公钥 ID(如 PUB_KEY_ID_01...)"
>
<a-input
v-model="wxPayForm.platformPublicKeyId"
placeholder="选填:PUB_KEY_ID_..."
allow-clear
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="配置备注说明">
<a-input v-model="wxPayForm.remark" placeholder="备注说明" />
</a-form-item>
</a-col>
</a-row>
<input
ref="pemFileInputRef"
type="file"
accept=".pem,.crt,.key,.txt"
style="display: none"
@change="handlePemFileChange"
/>
<!-- 自检诊断结果展示 -->
<div v-if="wxPayTestResult" style="margin-bottom: 20px">
<a-alert
:type="wxPayTestResult.isReadyForLive ? 'success' : 'warning'"
:title="
wxPayTestResult.isReadyForLive
? '微信支付实盘参数与 RSA-SHA256 签名验证全部通过,可正常发起真实微信支付'
: '当前尚未满足实盘完整条件(将继续使用沙箱收银台模式兜底)'
"
>
<div style="margin-top: 8px">
<div
v-for="(chk, idx) in wxPayTestResult.checks"
:key="idx"
style="font-size: 13px; line-height: 24px"
>
<a-tag :color="chk.pass ? 'green' : 'orange'" size="small" style="margin-right: 8px">
{{ chk.pass ? '通过' : '待完善' }}
</a-tag>
<strong>{{ chk.item }}:</strong>
<span>{{ chk.detail }}</span>
</div>
</div>
</a-alert>
</div>
<a-form-item style="margin-top: 16px">
<a-space size="medium">
<a-button type="primary" :loading="wxPaySaving" :disabled="!wxPayLoaded" @click="handleSaveWxPay">
<template #icon><icon-save /></template>
保存微信支付商户配置
</a-button>
<a-button type="secondary" :loading="wxPayTesting" :disabled="!wxPayLoaded" @click="handleTestWxPay">
执行参数与证书签名自检
</a-button>
</a-space>
</a-form-item>
</a-form>
</a-card>
</div>
</template>
@@ -107,6 +343,9 @@
import { Message } from '@arco-design/web-vue';
const saving = ref(false);
// 配置读取是否成功:失败时锁定保存,避免用页面默认值覆盖线上配置
const configLoaded = ref(false);
const wxPayLoaded = ref(false);
const form = ref({
platformRatePercent: 10,
firstReferralPercent: 5,
@@ -116,18 +355,67 @@
withdrawFeePercent: 0,
});
// 微信支付 V3 商户配置表单
const wxPaySaving = ref(false);
const wxPayTesting = ref(false);
const wxPayEffectiveMode = ref<'LIVE' | 'SANDBOX'>('SANDBOX');
const wxPayTestResult = ref<any>(null);
const pemFileInputRef = ref<HTMLInputElement | null>(null);
const currentPemTargetField = ref<'privateKey' | 'certPem'>('privateKey');
const wxPayForm = ref({
mode: 'AUTO',
appId: '',
appSecret: '',
mchId: '',
apiV3Key: '',
serialNo: '',
privateKey: '',
certPem: '',
platformPublicKeyId: '',
platformPublicKey: '',
notifyUrl: 'https://peiww.gynhc.com/api/pay/wechat/notify',
remark: '微信支付V3直连商户参数配置'
});
const fetchData = async () => {
try {
const res: any = await axios.get('/api/finance/config');
if (res && res.data) {
form.value = res.data;
}
configLoaded.value = true;
} catch {
// 保持兜底
// 加载失败绝不能用页面默认值兜底,否则保存会覆盖线上正确费率
configLoaded.value = false;
Message.error('财务抽成配置加载失败,已锁定保存按钮,避免以默认值覆盖线上配置');
}
try {
const wxRes: any = await axios.get('/api/admin/pay/wechat/config');
const data = wxRes?.data?.data || wxRes?.data || wxRes;
if (data && typeof data === 'object') {
wxPayForm.value = {
...wxPayForm.value,
...data
};
if (data.effectiveMode) {
wxPayEffectiveMode.value = data.effectiveMode;
}
}
wxPayLoaded.value = true;
} catch {
// 加载失败绝不能展示空表单,否则保存会清空线上商户号/密钥/证书
wxPayLoaded.value = false;
Message.error('微信支付商户配置加载失败,已锁定保存按钮,避免清空线上商户参数');
}
};
const handleSave = async () => {
if (!configLoaded.value) {
Message.warning('配置尚未成功加载,请刷新页面后再保存,避免覆盖线上费率');
return;
}
saving.value = true;
try {
await axios.post('/api/finance/config', form.value);
@@ -139,6 +427,75 @@
}
};
const triggerPemFileSelect = (field: 'privateKey' | 'certPem') => {
currentPemTargetField.value = field;
if (pemFileInputRef.value) {
pemFileInputRef.value.value = '';
pemFileInputRef.value.click();
}
};
const handlePemFileChange = (e: Event) => {
const input = e.target as HTMLInputElement;
const file = input?.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const content = String(reader.result || '').trim();
wxPayForm.value[currentPemTargetField.value] = content;
Message.success(`已成功读取证书文件: ${file.name}`);
};
reader.onerror = () => {
Message.error('读取证书文件失败,请手动复制粘贴文本内容');
};
reader.readAsText(file, 'utf-8');
};
const handleSaveWxPay = async () => {
if (!wxPayLoaded.value) {
Message.warning('商户配置尚未成功加载,请刷新页面后再保存,避免清空线上商户参数');
return;
}
wxPaySaving.value = true;
try {
const res: any = await axios.post('/api/admin/pay/wechat/config', wxPayForm.value);
const data = res?.data?.data || res?.data || res;
if (data && data.effectiveMode) {
wxPayEffectiveMode.value = data.effectiveMode;
}
Message.success(
wxPayEffectiveMode.value === 'LIVE'
? '微信支付商户配置已保存,实盘支付模式 (LIVE) 已激活生效'
: '微信支付配置已保存,当前运行于沙箱收银台模式 (SANDBOX)'
);
} catch (err: any) {
Message.error(err.response?.data?.message || err.message || '保存微信支付配置失败');
} finally {
wxPaySaving.value = false;
}
};
const handleTestWxPay = async () => {
if (!wxPayLoaded.value) {
Message.warning('商户配置尚未成功加载,请刷新页面后再自检');
return;
}
wxPayTesting.value = true;
try {
const res: any = await axios.post('/api/admin/pay/wechat/test', wxPayForm.value);
const data = res?.data?.data || res?.data || res;
wxPayTestResult.value = data;
if (data && data.effectiveMode) {
wxPayEffectiveMode.value = data.effectiveMode;
}
Message.success('微信支付配置与签名自检完成');
} catch (err: any) {
Message.error(err.response?.data?.message || err.message || '配置自检失败');
} finally {
wxPayTesting.value = false;
}
};
onMounted(() => {
fetchData();
});

查看文件

@@ -285,14 +285,14 @@
try {
if (isEdit.value && editingId.value) {
const res: any = await axios.put(`/api/admin/coupons/${editingId.value}`, formData.value);
if (res && (res.data.code === 20000 || res.status === 200)) {
if (res && res.code === 20000) {
Message.success('优惠券修改成功');
modalVisible.value = false;
fetchData();
}
} else {
const res: any = await axios.post('/api/admin/coupons', formData.value);
if (res && (res.data.code === 20000 || res.status === 200)) {
if (res && res.code === 20000) {
Message.success('优惠券创建成功,已同步至小程序');
modalVisible.value = false;
fetchData();
@@ -310,7 +310,7 @@
const res: any = await axios.put(`/api/admin/coupons/${record.id}`, {
status: newStatus,
});
if (res && (res.data.code === 20000 || res.status === 200)) {
if (res && res.code === 20000) {
record.status = newStatus;
Message.success(newStatus === 1 ? '优惠券已上架发放' : '优惠券已停用下架');
}
@@ -324,7 +324,7 @@
const handleDelete = async (id: number) => {
try {
const res: any = await axios.delete(`/api/admin/coupons/${id}`);
if (res && (res.data.code === 20000 || res.status === 200)) {
if (res && res.code === 20000) {
Message.success('优惠券已彻底删除');
fetchData();
}

查看文件

@@ -215,14 +215,14 @@
try {
if (isEdit.value && editingId.value) {
const res: any = await axios.put(`/api/admin/recharge-tiers/${editingId.value}`, formData.value);
if (res && (res.data.code === 20000 || res.status === 200)) {
if (res && res.code === 20000) {
Message.success('充值阶梯修改成功');
modalVisible.value = false;
fetchData();
}
} else {
const res: any = await axios.post('/api/admin/recharge-tiers', formData.value);
if (res && (res.data.code === 20000 || res.status === 200)) {
if (res && res.code === 20000) {
Message.success('充值阶梯创建成功,已同步至小程序');
modalVisible.value = false;
fetchData();
@@ -240,7 +240,7 @@
const res: any = await axios.put(`/api/admin/recharge-tiers/${record.id}`, {
status: newStatus,
});
if (res && (res.data.code === 20000 || res.status === 200)) {
if (res && res.code === 20000) {
record.status = newStatus;
Message.success(newStatus === 1 ? '充值档位已上架' : '充值档位已下线');
}
@@ -254,7 +254,7 @@
const handleDelete = async (id: number) => {
try {
const res: any = await axios.delete(`/api/admin/recharge-tiers/${id}`);
if (res && (res.data.code === 20000 || res.status === 200)) {
if (res && res.code === 20000) {
Message.success('充值阶梯已彻底删除');
fetchData();
}

查看文件

@@ -705,6 +705,311 @@
</a-card>
</div>
</a-tab-pane>
<!-- Tab 12: 微信支付商户号与 APIv3 证书配置 -->
<a-tab-pane key="wechat_pay_config" title="微信支付商户配置">
<div class="tab-pane-content">
<a-alert type="info" style="margin-bottom: 20px">
在此配置微信小程序支付 (JSAPI) 所需的直连商户号 (mchid)、APIv3 密钥、商户证书序列号及私钥 (apiclient_key.pem)。保存后无需重启服务器即可实时生效;未配置完整前自动运行于沙箱模拟收银台模式。
</a-alert>
<a-card
title="微信支付 V3 商户号与证书参数"
:bordered="false"
style="background: var(--color-fill-2)"
>
<template #extra>
<a-space>
<a-tag :color="wxPayTestResult?.isReadyForLive ? 'green' : 'arcoblue'">
{{
wxPayForm.valueJson.mchId && wxPayForm.valueJson.apiV3Key && wxPayForm.valueJson.privateKey && wxPayForm.valueJson.mode !== 'SANDBOX'
? '已满足实盘支付条件 (LIVE)'
: '当前运行:沙箱收银台模式 (SANDBOX)'
}}
</a-tag>
<a-button type="outline" size="small" :loading="wxPayTesting" @click="testWechatPayConfig">
配置与签名自检
</a-button>
</a-space>
</template>
<a-form :model="wxPayForm.valueJson" layout="vertical" style="max-width: 820px">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="支付通道运行模式">
<a-select v-model="wxPayForm.valueJson.mode">
<a-option value="AUTO">自动识别切换 (填齐商户号与证书即开启实盘)</a-option>
<a-option value="LIVE">强制微信支付实盘模式 (LIVE - 生产环境)</a-option>
<a-option value="SANDBOX">强制沙箱收银台模式 (SANDBOX - 演示测试)</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="微信小程序 AppID (appId)">
<a-input v-model="wxPayForm.valueJson.appId" placeholder="例如:wx0c929f7e54024e67" allow-clear />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="微信小程序 AppSecret (小程序密钥)">
<a-input-password v-model="wxPayForm.valueJson.appSecret" placeholder="32位AppSecret,用于换取用户 openid" allow-clear />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="微信支付直连商户号 (mchId)">
<a-input v-model="wxPayForm.valueJson.mchId" placeholder="10位数字商户号,如:1600000000" allow-clear />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="商户 APIv3 密钥 (apiV3Key - 32位字符)">
<a-input-password v-model="wxPayForm.valueJson.apiV3Key" placeholder="请输入微信商户平台设置的32位APIv3密钥" allow-clear />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="商户 API 证书序列号 (serialNo)">
<a-input
v-model="wxPayForm.valueJson.serialNo"
placeholder="40位十六进制证书序列号(上传 apiclient_cert.pem 可自动提取)"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="支付结果异步通知回调地址 (notifyUrl)">
<a-input v-model="wxPayForm.valueJson.notifyUrl" placeholder="https://peiww.gynhc.com/api/pay/wechat/notify" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="商户 API 私钥内容 (apiclient_key.pem)">
<div style="width: 100%">
<div style="margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 12px; color: var(--color-text-3)">
支持直接粘贴文本,或点击右侧按钮读取本地 apiclient_key.pem 文件
</span>
<a-button type="outline" size="mini" @click="triggerPemSelect('privateKey')">
读取本地 apiclient_key.pem 文件
</a-button>
</div>
<a-textarea
v-model="wxPayForm.valueJson.privateKey"
placeholder="-----BEGIN PRIVATE KEY-----&#10;...&#10;-----END PRIVATE KEY-----"
:auto-size="{ minRows: 4, maxRows: 8 }"
style="font-family: monospace; font-size: 12px"
/>
</div>
</a-form-item>
<a-form-item label="商户 API 证书内容 (apiclient_cert.pem,读取后自动提取40位证书序列号)">
<div style="width: 100%">
<div style="margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 12px; color: var(--color-text-3)">
支持直接粘贴文本,或点击右侧按钮读取本地 apiclient_cert.pem 文件并自动解析序列号
</span>
<a-space>
<a-button
v-if="wxPayForm.valueJson.certPem"
type="secondary"
size="mini"
@click="extractCertSerialNo"
>
从证书提取序列号
</a-button>
<a-button type="outline" size="mini" @click="triggerPemSelect('certPem')">
读取本地 apiclient_cert.pem 文件
</a-button>
</a-space>
</div>
<a-textarea
v-model="wxPayForm.valueJson.certPem"
placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----"
:auto-size="{ minRows: 3, maxRows: 6 }"
style="font-family: monospace; font-size: 12px"
/>
</div>
</a-form-item>
<a-divider orientation="left" style="margin: 16px 0 12px">
微信支付平台公钥配置(用于商家转账到零钱实名加密与异步回调验签,选填)
</a-divider>
<a-row :gutter="16">
<a-col :span="24">
<a-form-item label="微信支付平台公钥 ID (platformPublicKeyId,选填)">
<a-input
v-model="wxPayForm.valueJson.platformPublicKeyId"
placeholder="在【微信商户平台 -> API安全 -> 微信支付公钥】中获取,如:PUB_KEY_ID_011001577984..."
allow-clear
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="微信支付平台公钥内容 (pub_key.pem,选填)">
<div style="width: 100%">
<div style="margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center;">
<span style="font-size: 12px; color: var(--color-text-3)">
支持直接粘贴文本,或点击右侧按钮读取从微信商户后台下载的本地 pub_key.pem 公钥文件
</span>
<a-button type="outline" size="mini" @click="triggerPemSelect('platformPublicKey')">
读取本地 pub_key.pem 文件
</a-button>
</div>
<a-textarea
v-model="wxPayForm.valueJson.platformPublicKey"
placeholder="-----BEGIN PUBLIC KEY-----&#10;...&#10;-----END PUBLIC KEY-----"
:auto-size="{ minRows: 3, maxRows: 6 }"
style="font-family: monospace; font-size: 12px"
/>
</div>
</a-form-item>
<input
ref="pemInputRef"
type="file"
accept=".pem,.crt,.key,.txt"
style="display: none"
@change="onPemFileSelected"
/>
<div v-if="wxPayTestResult" style="margin-bottom: 16px">
<a-alert
:type="wxPayTestResult.isReadyForLive ? 'success' : 'warning'"
:title="
wxPayTestResult.isReadyForLive
? '实盘参数与 RSA-SHA256 签名自检全部通过'
: '当前尚未满足实盘完整条件(未填齐前自动运行于沙箱收银台模式)'
"
>
<div
v-for="(chk, idx) in wxPayTestResult.checks"
:key="idx"
style="font-size: 13px; line-height: 24px"
>
<a-tag :color="chk.pass ? 'green' : 'orange'" size="small" style="margin-right: 8px">
{{ chk.pass ? '通过' : '待完善' }}
</a-tag>
<strong>{{ chk.item }}:</strong>
<span>{{ chk.detail }}</span>
</div>
</a-alert>
</div>
<div style="margin-top: 16px">
<a-space size="medium">
<a-button type="primary" :loading="saving" @click="saveWechatPayConfig">
保存微信支付商户配置
</a-button>
<a-button type="secondary" :loading="wxPayTesting" @click="testWechatPayConfig">
执行参数与证书签名自检
</a-button>
</a-space>
</div>
</a-form>
</a-card>
</div>
</a-tab-pane>
<!-- Tab 13: 地图定位与LBS服务配置 -->
<a-tab-pane key="map_location_config" title="地图定位服务">
<div class="tab-pane-content">
<a-alert type="info" style="margin-bottom: 20px">
在此维护微信小程序的地图定位服务参数(腾讯位置服务 Tencent LBS Key)。用于同城组局发布时的地图选点(chooseLocation)、经纬度逆地址解析及附近搭子/活动测距。
<div style="margin-top: 6px; font-size: 12px; color: var(--color-text-3);">
配置指引:登录腾讯位置服务控制台 (lbs.qq.com) -> 创建应用并添加 Key -> 勾选 WebServiceAPI 与微信小程序授权(填入小程序 AppID: wx0c929f7e54024e67)。
</div>
</a-alert>
<a-card title="腾讯位置服务 (Tencent LBS) 配置" :bordered="false" style="background: var(--color-fill-2); margin-bottom: 20px;">
<a-form :model="mapLocationForm.valueJson" layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="地图服务商">
<a-input v-model="mapLocationForm.valueJson.serviceName" disabled />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="默认定位/示范商圈城市">
<a-input v-model="mapLocationForm.valueJson.defaultCity" placeholder="如:北京市" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="24">
<a-form-item label="腾讯地图开发密钥 (Key)" required>
<a-input-password
v-model="mapLocationForm.valueJson.mapKey"
placeholder="请输入腾讯位置服务 Key (如:XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX)"
allow-clear
/>
<template #extra>
需在腾讯位置服务后台开通 WebServiceAPI,并授权绑定小程序 AppID: <strong>wx0c929f7e54024e67</strong>。
</template>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="24">
<a-form-item label="WebServiceAPI 签名私钥 (Secret Key)">
<a-input-password
v-model="mapLocationForm.valueJson.secretKey"
placeholder="若在腾讯位置服务后台启用了签名校验,可在此填写 Secret Key(选填)"
allow-clear
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="启用地图选点与位置搜索">
<a-switch
:checked-value="1"
:unchecked-value="0"
v-model="mapLocationForm.valueJson.enableChooseLocation"
checked-text="开启"
unchecked-text="停用"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="启用同城距离动态计算">
<a-switch
:checked-value="1"
:unchecked-value="0"
v-model="mapLocationForm.valueJson.enableDistanceCalculation"
checked-text="开启"
unchecked-text="停用"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="配置描述与备注">
<a-input v-model="mapLocationForm.valueJson.remark" placeholder="如:腾讯位置服务 Key 与同城组局选点配置" />
</a-form-item>
<div style="margin-top: 16px">
<a-space size="medium">
<a-button type="primary" :loading="saving" @click="saveMapLocationConfig">
保存地图定位配置
</a-button>
</a-space>
</div>
</a-form>
</a-card>
</div>
</a-tab-pane>
</a-tabs>
</a-card>
</div>
@@ -1030,6 +1335,117 @@ const saveMessageTemplateConfig = () => {
saveConfig('message_template_rules', messageTemplateForm);
};
// Tab 12 微信支付商户号与证书配置
const wxPayForm = reactive({
name: '微信支付商户号与证书配置',
description: '微信支付V3商户号、APIv3密钥与证书参数配置',
status: 1,
valueJson: {
mode: 'AUTO',
appId: '',
appSecret: '',
mchId: '',
apiV3Key: '',
serialNo: '',
privateKey: '',
certPem: '',
platformPublicKeyId: '',
platformPublicKey: '',
notifyUrl: 'https://peiww.gynhc.com/api/pay/wechat/notify',
remark: '微信支付V3直连商户参数配置'
}
});
const wxPayTesting = ref(false);
const wxPayTestResult = ref<any>(null);
const pemInputRef = ref<HTMLInputElement | null>(null);
const activePemField = ref<'privateKey' | 'certPem' | 'platformPublicKey'>('privateKey');
const triggerPemSelect = (field: 'privateKey' | 'certPem' | 'platformPublicKey') => {
activePemField.value = field;
if (pemInputRef.value) {
pemInputRef.value.value = '';
pemInputRef.value.click();
}
};
const extractCertSerialNo = async () => {
if (!wxPayForm.valueJson.certPem) {
Message.warning('请先读取或粘贴 apiclient_cert.pem 证书内容');
return;
}
try {
const res: any = await axios.post('/api/admin/pay/wechat/parse-cert', {
certPem: wxPayForm.valueJson.certPem
});
const data = res?.data?.data || res?.data || res;
if (data && data.serialNo) {
wxPayForm.valueJson.serialNo = data.serialNo;
Message.success(`已自动提取 40 位证书序列号: ${data.serialNo}`);
}
} catch (err: any) {
Message.warning(err.response?.data?.message || err.message || '未能自动提取序列号,请手动填写');
}
};
const onPemFileSelected = (e: Event) => {
const input = e.target as HTMLInputElement;
const file = input?.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async () => {
const content = String(reader.result || '').trim();
wxPayForm.valueJson[activePemField.value] = content;
Message.success(`已读取证书文件: ${file.name}`);
if (activePemField.value === 'certPem' && content.includes('BEGIN CERTIFICATE')) {
await extractCertSerialNo();
}
};
reader.readAsText(file, 'utf-8');
};
const saveWechatPayConfig = () => {
saveConfig('wechat_pay_config', wxPayForm);
};
// Tab 13 地图定位与LBS服务配置
const mapLocationForm = reactive({
name: '地图定位与LBS服务配置',
description: '腾讯位置服务Key、选点与周边测距参数配置',
status: 1,
valueJson: {
provider: 'tencent',
mapKey: '',
secretKey: '',
serviceName: '腾讯位置服务 (Tencent LBS)',
enableChooseLocation: 1,
enableDistanceCalculation: 1,
defaultCity: '北京市',
remark: '腾讯位置服务 Key(需在 lbs.qq.com 申请并勾选 WebServiceAPI 与微信小程序 AppID: wx0c929f7e54024e67)'
}
});
const saveMapLocationConfig = () => {
saveConfig('map_location_config', mapLocationForm);
};
const testWechatPayConfig = async () => {
wxPayTesting.value = true;
try {
const res: any = await axios.post('/api/admin/pay/wechat/test', wxPayForm.valueJson);
const data = res?.data?.data || res?.data || res;
wxPayTestResult.value = data;
if (data?.serialNo && !wxPayForm.valueJson.serialNo) {
wxPayForm.valueJson.serialNo = data.serialNo;
}
Message.success('微信支付配置与签名自检完成');
} catch (err: any) {
Message.error(err.response?.data?.message || err.message || '自检请求失败');
} finally {
wxPayTesting.value = false;
}
};
const sendTestMessage = async () => {
testSending.value = true;
try {
@@ -1125,6 +1541,16 @@ const fetchConfigs = async () => {
messageTemplateForm.description = item.description;
messageTemplateForm.status = item.status;
messageTemplateForm.valueJson = { ...messageTemplateForm.valueJson, ...(item.valueJson || {}) };
} else if (item.configKey === 'wechat_pay_config') {
wxPayForm.name = item.name;
wxPayForm.description = item.description;
wxPayForm.status = item.status;
wxPayForm.valueJson = { ...wxPayForm.valueJson, ...(item.valueJson || {}) };
} else if (item.configKey === 'map_location_config') {
mapLocationForm.name = item.name;
mapLocationForm.description = item.description;
mapLocationForm.status = item.status;
mapLocationForm.valueJson = { ...mapLocationForm.valueJson, ...(item.valueJson || {}) };
}
});
}

查看文件

@@ -77,6 +77,20 @@
<a-card class="general-card" title="平台注册用户综合管理列表">
<template #extra>
<a-space>
<a-popconfirm
v-if="selectedRowKeys.length > 0"
:content="`确定要批量删除已选中的 ${selectedRowKeys.length} 位用户吗?删除后将彻底清理相关档案数据且不可恢复!`"
ok-text="确认批量删除"
cancel-text="取消"
type="warning"
@ok="handleBatchDelete"
>
<a-button type="primary" status="danger" size="small">
<template #icon><icon-delete /></template>
批量删除 ({{ selectedRowKeys.length }})
</a-button>
</a-popconfirm>
<a-button type="primary" size="small" @click="fetchUsers">
<template #icon><icon-refresh /></template>
刷新列表
@@ -128,6 +142,8 @@
<!-- 数据表格 -->
<a-table
v-model:selected-keys="selectedRowKeys"
:row-selection="{ type: 'checkbox', showCheckedAll: true }"
:data="filteredUsers"
:loading="loading"
:pagination="{ pageSize: 8, showTotal: true, showJumper: true }"
@@ -202,7 +218,7 @@
</template>
</a-table-column>
<a-table-column title="管理操作" :width="200" fixed="right">
<a-table-column title="管理操作" :width="230" fixed="right">
<template #cell="{ record }">
<a-space>
<a-button type="primary" size="small" @click.stop="openUserDetail(record)">
@@ -225,6 +241,24 @@
{{ record.status === '正常' ? '封禁' : '解封' }}
</a-button>
</a-popconfirm>
<a-popconfirm
:content="`确定要永久删除用户 [${record.nickname}] (UID: ${record.id}) 吗?此操作将彻底清理该用户档案且不可恢复!`"
ok-text="确认删除"
cancel-text="取消"
type="warning"
@ok="handleDeleteUser(record)"
>
<a-button
type="text"
size="small"
status="danger"
@click.stop
>
<template #icon><icon-delete /></template>
删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</a-table-column>
@@ -292,6 +326,20 @@
{{ userDetail.status === '正常' ? '封禁该用户账号' : '解除该用户封禁' }}
</a-button>
</a-popconfirm>
<a-popconfirm
:content="`确定要永久删除用户 [${userDetail.nickname}] (UID: ${userDetail.id}) 吗?此操作将彻底清理该用户档案且不可恢复!`"
ok-text="确认删除"
cancel-text="取消"
type="warning"
@ok="handleDeleteUser(userDetail)"
>
<a-button type="outline" status="danger" size="small">
<template #icon><icon-delete /></template>
删除该用户档案
</a-button>
</a-popconfirm>
<a-button size="small" @click="fetchUserDetail(userDetail.id)">
<template #icon><icon-refresh /></template>
刷新数据
@@ -566,6 +614,7 @@
const searchKeyword = ref('');
const roleFilter = ref('ALL');
const statusFilter = ref('ALL');
const selectedRowKeys = ref<number[]>([]);
// 360° 侧滑全景抽屉
const detailDrawerVisible = ref(false);
@@ -652,6 +701,7 @@
searchKeyword.value = '';
roleFilter.value = 'ALL';
statusFilter.value = 'ALL';
selectedRowKeys.value = [];
};
// 支持点击整行直接查看360°全景画像
@@ -685,6 +735,42 @@
}
};
const handleDeleteUser = async (record: any) => {
try {
const res: any = await axios.delete(`/api/users/${record.id}`);
if (res && (res.code === 20000 || res.data || res.status === 200)) {
Message.success(`用户 [${record.nickname}] (UID: ${record.id}) 已成功删除`);
if (detailDrawerVisible.value && currentUser.value && currentUser.value.id === record.id) {
detailDrawerVisible.value = false;
}
rawUsers.value = rawUsers.value.filter((u) => u.id !== record.id);
selectedRowKeys.value = selectedRowKeys.value.filter((k) => k !== record.id);
}
} catch (err: any) {
Message.error(err.response?.data?.message || err.message || '删除用户失败');
}
};
const handleBatchDelete = async () => {
if (selectedRowKeys.value.length === 0) return;
try {
const res: any = await axios.post('/api/users/batch-delete', {
ids: selectedRowKeys.value,
});
if (res && (res.code === 20000 || res.data || res.status === 200)) {
const count = selectedRowKeys.value.length;
Message.success(`已成功批量删除 ${count} 位用户`);
rawUsers.value = rawUsers.value.filter((u) => !selectedRowKeys.value.includes(u.id));
if (detailDrawerVisible.value && currentUser.value && selectedRowKeys.value.includes(currentUser.value.id)) {
detailDrawerVisible.value = false;
}
selectedRowKeys.value = [];
}
} catch (err: any) {
Message.error(err.response?.data?.message || err.message || '批量删除用户失败');
}
};
onMounted(() => {
fetchUsers();
});