feat: security hardening, wechat pay v3 platform cert integration, companion certification revocation, and audit closed-loop
这个提交包含在:
@@ -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----- MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC... -----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----- MIID3DCCAsSgAwIBAgIUN... -----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----- ... -----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----- ... -----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----- ... -----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();
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户