5 changed files with 883 additions and 0 deletions
@ -0,0 +1,75 @@ |
|||||
|
import request from '@/utils/request'; |
||||
|
|
||||
|
/** |
||||
|
* 手工触发指定表增量同步 |
||||
|
*/ |
||||
|
export const triggerSync = (configKey: string) => { |
||||
|
return request({ |
||||
|
url: '/web/biz-sync/trigger/' + configKey, |
||||
|
method: 'post' |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
/** |
||||
|
* 手工触发全部表增量同步 |
||||
|
*/ |
||||
|
export const triggerAllSync = () => { |
||||
|
return request({ |
||||
|
url: '/web/biz-sync/trigger-all', |
||||
|
method: 'post' |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
/** |
||||
|
* 强制全量同步(PK 游标全表扫描) |
||||
|
*/ |
||||
|
export const triggerForceSync = () => { |
||||
|
return request({ |
||||
|
url: '/web/biz-sync/trigger-force', |
||||
|
method: 'post' |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
/** |
||||
|
* 分页查询同步任务 |
||||
|
*/ |
||||
|
export const getTaskList = (params: { configKey?: string; pageNum?: number; pageSize?: number }) => { |
||||
|
return request({ |
||||
|
url: '/web/biz-sync/task/list', |
||||
|
method: 'get', |
||||
|
params |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
/** |
||||
|
* 分页查询同步日志 |
||||
|
*/ |
||||
|
export const getLogList = (params: { taskId?: string; configKey?: string; pageNum?: number; pageSize?: number }) => { |
||||
|
return request({ |
||||
|
url: '/web/biz-sync/log/list', |
||||
|
method: 'get', |
||||
|
params |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
/** |
||||
|
* 分页查询行级影子记录 |
||||
|
*/ |
||||
|
export const getRecordList = (params: { configKey?: string; status?: string; pageNum?: number; pageSize?: number }) => { |
||||
|
return request({ |
||||
|
url: '/web/biz-sync/record/list', |
||||
|
method: 'get', |
||||
|
params |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
/** |
||||
|
* 重试死信记录 |
||||
|
*/ |
||||
|
export const retryRecord = (recordId: number) => { |
||||
|
return request({ |
||||
|
url: '/web/biz-sync/record/retry', |
||||
|
method: 'post', |
||||
|
params: { recordId } |
||||
|
}); |
||||
|
}; |
||||
@ -0,0 +1,54 @@ |
|||||
|
import request from '@/utils/request'; |
||||
|
|
||||
|
/** HTTP 方法类型 */ |
||||
|
export type InterfaceMethod = 'get' | 'post' | 'put' | 'delete'; |
||||
|
|
||||
|
/** 接口调用配置(与页面中的 JSON 定义一致) */ |
||||
|
export interface InterfaceItem { |
||||
|
/** 显示名称 */ |
||||
|
name: string; |
||||
|
/** 分组(通常填 Controller 名称) */ |
||||
|
group: string; |
||||
|
/** 请求路径,支持 {占位符} 形式的路径参数,例如 /reserve/check/{targetPlanId} */ |
||||
|
url: string; |
||||
|
/** 请求方法 */ |
||||
|
method: InterfaceMethod; |
||||
|
/** 描述说明 */ |
||||
|
description?: string; |
||||
|
/** 默认参数(get 拼 query,post/put 作 body) */ |
||||
|
params?: Record<string, any>; |
||||
|
/** 路径参数默认值,key 与 url 中的 {xxx} 对应 */ |
||||
|
pathParams?: Record<string, any>; |
||||
|
/** 是否需要二次确认 */ |
||||
|
confirm?: boolean; |
||||
|
} |
||||
|
|
||||
|
/** 将 URL 中的 {xxx} 占位符替换为实际值 */ |
||||
|
function fillPathParams(url: string, pathParams?: Record<string, any>): string { |
||||
|
if (!pathParams) return url; |
||||
|
return url.replace(/\{(\w+)\}/g, (_, key) => { |
||||
|
const v = pathParams[key]; |
||||
|
return v == null ? '' : encodeURIComponent(String(v)); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 通用调用后端接口 |
||||
|
* @param item 接口配置 |
||||
|
* @param overrideParams 本次调用的参数(覆盖 item.params) |
||||
|
* @param overridePathParams 本次调用的路径参数(覆盖 item.pathParams) |
||||
|
*/ |
||||
|
export const invokeInterface = ( |
||||
|
item: InterfaceItem, |
||||
|
overrideParams?: Record<string, any>, |
||||
|
overridePathParams?: Record<string, any> |
||||
|
) => { |
||||
|
const method = (item.method || 'get').toLowerCase() as InterfaceMethod; |
||||
|
const url = fillPathParams(item.url, overridePathParams ?? item.pathParams); |
||||
|
const payload = overrideParams ?? item.params ?? {}; |
||||
|
|
||||
|
if (method === 'get' || method === 'delete') { |
||||
|
return request({ url, method, params: payload }); |
||||
|
} |
||||
|
return request({ url, method, data: payload }); |
||||
|
}; |
||||
@ -0,0 +1,365 @@ |
|||||
|
<script setup lang="ts"> |
||||
|
import { ref, reactive, onMounted } from 'vue'; |
||||
|
import { ElMessage, ElMessageBox } from 'element-plus'; |
||||
|
import { |
||||
|
triggerSync, |
||||
|
triggerAllSync, |
||||
|
triggerForceSync, |
||||
|
getTaskList, |
||||
|
getLogList |
||||
|
} from '@/api/efcode/biz'; |
||||
|
|
||||
|
/** |
||||
|
* 同步配置项列表 |
||||
|
* 对应后端 resources/businessdatasyn/*.json 中的每一个配置文件 |
||||
|
* 后续新增同步表时,只需在此数组中追加一条即可 |
||||
|
*/ |
||||
|
const syncConfigs = ref([ |
||||
|
{ |
||||
|
configKey: 'enforcement_registration', |
||||
|
description: '执法登记记录表双向同步', |
||||
|
v1Table: 'enforcement_registration', |
||||
|
v2Table: 'enforcement_registration', |
||||
|
syncDirection: '双向', |
||||
|
enabled: true |
||||
|
}, |
||||
|
{ |
||||
|
configKey: 'enterprise_information', |
||||
|
description: '企业信息表双向同步', |
||||
|
v1Table: 'enterprise_information', |
||||
|
v2Table: 'enterprise_information', |
||||
|
syncDirection: '双向', |
||||
|
enabled: false |
||||
|
} |
||||
|
]); |
||||
|
|
||||
|
const loading = ref(false); |
||||
|
|
||||
|
// ============ 任务记录弹窗 ============ |
||||
|
const taskDialogVisible = ref(false); |
||||
|
const taskLoading = ref(false); |
||||
|
const currentConfigKey = ref(''); |
||||
|
const taskList = ref<any[]>([]); |
||||
|
const taskTotal = ref(0); |
||||
|
const taskQuery = reactive({ |
||||
|
configKey: '', |
||||
|
pageNum: 1, |
||||
|
pageSize: 10 |
||||
|
}); |
||||
|
|
||||
|
// ============ 日志弹窗 ============ |
||||
|
const logDialogVisible = ref(false); |
||||
|
const logLoading = ref(false); |
||||
|
const currentTaskId = ref(''); |
||||
|
const logList = ref<any[]>([]); |
||||
|
const logTotal = ref(0); |
||||
|
const logQuery = reactive({ |
||||
|
taskId: '', |
||||
|
configKey: '', |
||||
|
pageNum: 1, |
||||
|
pageSize: 10 |
||||
|
}); |
||||
|
|
||||
|
/** 触发单个表增量同步 */ |
||||
|
const handleTrigger = async (row: any) => { |
||||
|
try { |
||||
|
await ElMessageBox.confirm( |
||||
|
`确认触发【${row.description}】增量同步?`, |
||||
|
'确认', |
||||
|
{ type: 'warning' } |
||||
|
); |
||||
|
loading.value = true; |
||||
|
const res: any = await triggerSync(row.configKey); |
||||
|
ElMessage.success(res.msg || '触发成功'); |
||||
|
} catch (e: any) { |
||||
|
if (e !== 'cancel' && e?.action !== 'cancel') { |
||||
|
ElMessage.error(e?.msg || '触发失败'); |
||||
|
} |
||||
|
} finally { |
||||
|
loading.value = false; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
/** 触发全部同步 */ |
||||
|
const handleTriggerAll = async () => { |
||||
|
try { |
||||
|
await ElMessageBox.confirm('确认触发全部表的增量同步?', '确认', { type: 'warning' }); |
||||
|
loading.value = true; |
||||
|
const res: any = await triggerAllSync(); |
||||
|
ElMessage.success(res.msg || '全部触发成功'); |
||||
|
} catch (e: any) { |
||||
|
if (e !== 'cancel' && e?.action !== 'cancel') { |
||||
|
ElMessage.error(e?.msg || '触发失败'); |
||||
|
} |
||||
|
} finally { |
||||
|
loading.value = false; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
/** 强制全量同步 */ |
||||
|
const handleForceSync = async () => { |
||||
|
try { |
||||
|
await ElMessageBox.confirm( |
||||
|
'强制全量同步将扫描全部数据,耗时较长,确认执行?', |
||||
|
'警告', |
||||
|
{ type: 'error', confirmButtonText: '确定执行', cancelButtonText: '取消' } |
||||
|
); |
||||
|
loading.value = true; |
||||
|
const res: any = await triggerForceSync(); |
||||
|
ElMessage.success(res.msg || '强制全量触发成功'); |
||||
|
} catch (e: any) { |
||||
|
if (e !== 'cancel' && e?.action !== 'cancel') { |
||||
|
ElMessage.error(e?.msg || '触发失败'); |
||||
|
} |
||||
|
} finally { |
||||
|
loading.value = false; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
/** 查看任务记录 */ |
||||
|
const handleViewTask = (row: any) => { |
||||
|
currentConfigKey.value = row.configKey; |
||||
|
taskQuery.configKey = row.configKey; |
||||
|
taskQuery.pageNum = 1; |
||||
|
taskDialogVisible.value = true; |
||||
|
loadTaskList(); |
||||
|
}; |
||||
|
|
||||
|
/** 加载任务列表 */ |
||||
|
const loadTaskList = async () => { |
||||
|
taskLoading.value = true; |
||||
|
try { |
||||
|
const res: any = await getTaskList(taskQuery); |
||||
|
const data = res.data; |
||||
|
taskList.value = data?.records || []; |
||||
|
taskTotal.value = data?.total || 0; |
||||
|
} catch (e) { |
||||
|
console.error('加载任务列表失败', e); |
||||
|
} finally { |
||||
|
taskLoading.value = false; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
/** 查看某个任务的执行日志明细 */ |
||||
|
const handleViewLog = (task: any) => { |
||||
|
currentTaskId.value = task.taskId; |
||||
|
logQuery.taskId = task.taskId; |
||||
|
logQuery.configKey = ''; |
||||
|
logQuery.pageNum = 1; |
||||
|
logDialogVisible.value = true; |
||||
|
loadLogList(); |
||||
|
}; |
||||
|
|
||||
|
/** 加载日志列表 */ |
||||
|
const loadLogList = async () => { |
||||
|
logLoading.value = true; |
||||
|
try { |
||||
|
const res: any = await getLogList(logQuery); |
||||
|
const data = res.data; |
||||
|
logList.value = data?.records || []; |
||||
|
logTotal.value = data?.total || 0; |
||||
|
} catch (e) { |
||||
|
console.error('加载日志列表失败', e); |
||||
|
} finally { |
||||
|
logLoading.value = false; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
/** 任务状态标签类型 */ |
||||
|
const taskStatusType = (status: string) => { |
||||
|
switch (status) { |
||||
|
case 'SUCCESS': return 'success'; |
||||
|
case 'RUNNING': return 'warning'; |
||||
|
case 'PARTIAL_FAIL': return 'danger'; |
||||
|
case 'FAIL': return 'danger'; |
||||
|
case 'ABORTED': return 'info'; |
||||
|
default: return 'info'; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
/** action 标签类型 */ |
||||
|
const actionTagType = (action: string) => { |
||||
|
if (!action) return 'info'; |
||||
|
if (action.startsWith('WRITE_')) return 'success'; |
||||
|
if (action.startsWith('DELETE_')) return 'danger'; |
||||
|
if (action.startsWith('CONFLICT_')) return 'warning'; |
||||
|
return 'info'; |
||||
|
}; |
||||
|
|
||||
|
/** 格式化时间 */ |
||||
|
const formatTime = (val: string) => { |
||||
|
if (!val) return '-'; |
||||
|
return val.replace('T', ' ').substring(0, 19); |
||||
|
}; |
||||
|
|
||||
|
onMounted(() => {}); |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<div class="app-container"> |
||||
|
<el-card shadow="never"> |
||||
|
<template #header> |
||||
|
<div class="card-header"> |
||||
|
<div class="header-title"> |
||||
|
<span>业务数据同步管理</span> |
||||
|
<el-tag type="info" size="small" style="margin-left: 8px"> |
||||
|
共 {{ syncConfigs.length }} 个配置 |
||||
|
</el-tag> |
||||
|
</div> |
||||
|
<div class="header-actions"> |
||||
|
<el-button type="primary" :loading="loading" @click="handleTriggerAll"> |
||||
|
全部增量同步 |
||||
|
</el-button> |
||||
|
<el-button type="danger" :loading="loading" @click="handleForceSync"> |
||||
|
强制全量同步 |
||||
|
</el-button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<el-alert type="info" :closable="false" style="margin-bottom: 16px"> |
||||
|
<template #title> |
||||
|
<span> |
||||
|
单个同步配置说明:每条记录对应后端 resources/businessdatasyn/*.json 中的一个 JSON 配置文件, |
||||
|
实际的字段映射/主键/哈希列在 JSON 中定义。此页仅提供触发入口。 |
||||
|
</span> |
||||
|
</template> |
||||
|
</el-alert> |
||||
|
|
||||
|
<el-table :data="syncConfigs" border stripe style="width: 100%"> |
||||
|
<el-table-column label="配置Key" prop="configKey" width="240" /> |
||||
|
<el-table-column label="描述" prop="description" min-width="200" /> |
||||
|
<el-table-column label="V1表名" prop="v1Table" width="200" /> |
||||
|
<el-table-column label="V2表名" prop="v2Table" width="200" /> |
||||
|
<el-table-column label="同步方向" prop="syncDirection" width="90" align="center" /> |
||||
|
<el-table-column label="状态" width="80" align="center"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-tag :type="row.enabled ? 'success' : 'info'" size="small"> |
||||
|
{{ row.enabled ? '启用' : '禁用' }} |
||||
|
</el-tag> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="操作" width="220" align="center" fixed="right"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-button |
||||
|
type="primary" |
||||
|
link |
||||
|
size="small" |
||||
|
:disabled="!row.enabled" |
||||
|
@click="handleTrigger(row)" |
||||
|
> |
||||
|
增量同步 |
||||
|
</el-button> |
||||
|
<el-button type="info" link size="small" @click="handleViewTask(row)"> |
||||
|
任务记录 |
||||
|
</el-button> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
</el-table> |
||||
|
</el-card> |
||||
|
|
||||
|
<!-- 任务记录弹窗 --> |
||||
|
<el-dialog |
||||
|
v-model="taskDialogVisible" |
||||
|
:title="`同步任务记录 - ${currentConfigKey}`" |
||||
|
width="1000px" |
||||
|
append-to-body |
||||
|
> |
||||
|
<el-table v-loading="taskLoading" :data="taskList" border stripe size="small"> |
||||
|
<el-table-column label="任务ID" prop="taskId" width="220" show-overflow-tooltip /> |
||||
|
<el-table-column label="开始时间" width="160"> |
||||
|
<template #default="{ row }">{{ formatTime(row.startTime) }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="结束时间" width="160"> |
||||
|
<template #default="{ row }">{{ formatTime(row.endTime) }}</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="候选" prop="totalCandidates" width="70" align="center" /> |
||||
|
<el-table-column label="成功" prop="successCount" width="70" align="center" /> |
||||
|
<el-table-column label="冲突" prop="conflictCount" width="70" align="center" /> |
||||
|
<el-table-column label="失败" prop="failCount" width="70" align="center" /> |
||||
|
<el-table-column label="状态" width="110" align="center"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-tag :type="taskStatusType(row.status)" size="small">{{ row.status }}</el-tag> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="触发方式" prop="triggerType" width="90" align="center" /> |
||||
|
<el-table-column label="操作" width="80" align="center" fixed="right"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-button type="primary" link size="small" @click="handleViewLog(row)"> |
||||
|
日志 |
||||
|
</el-button> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
</el-table> |
||||
|
<el-pagination |
||||
|
v-model:current-page="taskQuery.pageNum" |
||||
|
v-model:page-size="taskQuery.pageSize" |
||||
|
:total="taskTotal" |
||||
|
:page-sizes="[10, 20, 50]" |
||||
|
layout="total, sizes, prev, pager, next" |
||||
|
class="mt-4" |
||||
|
@size-change="loadTaskList" |
||||
|
@current-change="loadTaskList" |
||||
|
/> |
||||
|
</el-dialog> |
||||
|
|
||||
|
<!-- 日志明细弹窗 --> |
||||
|
<el-dialog |
||||
|
v-model="logDialogVisible" |
||||
|
:title="`同步日志明细 - ${currentTaskId}`" |
||||
|
width="1100px" |
||||
|
append-to-body |
||||
|
> |
||||
|
<el-table v-loading="logLoading" :data="logList" border stripe size="small"> |
||||
|
<el-table-column label="主键值" prop="pkValue" width="200" show-overflow-tooltip /> |
||||
|
<el-table-column label="动作" width="140" align="center"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-tag :type="actionTagType(row.action)" size="small">{{ row.action }}</el-tag> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="归属方" prop="ownerSide" width="80" align="center" /> |
||||
|
<el-table-column label="成功" width="70" align="center"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-tag :type="row.success ? 'success' : 'danger'" size="small"> |
||||
|
{{ row.success ? '✓' : '✗' }} |
||||
|
</el-tag> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="错误信息" prop="errorMsg" min-width="200" show-overflow-tooltip /> |
||||
|
<el-table-column label="耗时(ms)" prop="costMs" width="90" align="center" /> |
||||
|
<el-table-column label="时间" width="160"> |
||||
|
<template #default="{ row }">{{ formatTime(row.createTime) }}</template> |
||||
|
</el-table-column> |
||||
|
</el-table> |
||||
|
<el-pagination |
||||
|
v-model:current-page="logQuery.pageNum" |
||||
|
v-model:page-size="logQuery.pageSize" |
||||
|
:total="logTotal" |
||||
|
:page-sizes="[10, 20, 50]" |
||||
|
layout="total, sizes, prev, pager, next" |
||||
|
class="mt-4" |
||||
|
@size-change="loadLogList" |
||||
|
@current-change="loadLogList" |
||||
|
/> |
||||
|
</el-dialog> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<style scoped lang="scss"> |
||||
|
.app-container { |
||||
|
padding: 16px; |
||||
|
} |
||||
|
.card-header { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
align-items: center; |
||||
|
.header-title { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
font-weight: bold; |
||||
|
} |
||||
|
} |
||||
|
.mt-4 { |
||||
|
margin-top: 16px; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,386 @@ |
|||||
|
<script setup lang="ts" name="EfcodeInterfaceInvoker"> |
||||
|
import { ref, computed, reactive } from 'vue'; |
||||
|
import { ElMessage, ElMessageBox } from 'element-plus'; |
||||
|
import { invokeInterface, type InterfaceItem } from '@/api/efcode/interface'; |
||||
|
|
||||
|
/** |
||||
|
* 后端接口调用配置列表 |
||||
|
* |
||||
|
* 每一项对应一个可手动触发的后端接口。新增接口时,在此数组追加一条即可。 |
||||
|
* 字段说明见 @/api/efcode/interface 中的 InterfaceItem 定义。 |
||||
|
* |
||||
|
* 当前示例:AppEnfCpPlanController(/app-enf/cpPlan) |
||||
|
*/ |
||||
|
const interfaceConfigs = ref<InterfaceItem[]>([ |
||||
|
{ |
||||
|
name: '我的计划列表', |
||||
|
group: 'AppEnfCpPlanController', |
||||
|
url: '/app-enf/cpPlan/list', |
||||
|
method: 'get', |
||||
|
description: '查询当前登录用户创建的计划(分页)', |
||||
|
params: { pageNum: 1, pageSize: 10 } |
||||
|
}, |
||||
|
{ |
||||
|
name: '其他计划列表', |
||||
|
group: 'AppEnfCpPlanController', |
||||
|
url: '/app-enf/cpPlan/otherList', |
||||
|
method: 'get', |
||||
|
description: '本单位全部 + 其他单位公开(仅待预约)', |
||||
|
params: { pageNum: 1, pageSize: 10 } |
||||
|
}, |
||||
|
{ |
||||
|
name: '预约前置检查', |
||||
|
group: 'AppEnfCpPlanController', |
||||
|
url: '/app-enf/cpPlan/reserve/check/{targetPlanId}', |
||||
|
method: 'get', |
||||
|
description: '预约前置检查,需填写 targetPlanId', |
||||
|
pathParams: { targetPlanId: '' } |
||||
|
}, |
||||
|
{ |
||||
|
name: '执行预约', |
||||
|
group: 'AppEnfCpPlanController', |
||||
|
url: '/app-enf/cpPlan/reserve/do', |
||||
|
method: 'post', |
||||
|
description: '执行预约,提交 targetPlanId、opMessage', |
||||
|
params: { targetPlanId: '', opMessage: '' }, |
||||
|
confirm: true |
||||
|
}, |
||||
|
{ |
||||
|
name: '计划自动匹配预约', |
||||
|
group: 'AppEnfCpPlanController', |
||||
|
url: '/app-enf/cpPlan/autoMatchReserve', |
||||
|
method: 'get', |
||||
|
description: '手动触发,强制匹配并落库', |
||||
|
confirm: true |
||||
|
} |
||||
|
]); |
||||
|
|
||||
|
// ============ 列表过滤 ============ |
||||
|
const searchKeyword = ref(''); |
||||
|
const filteredConfigs = computed(() => { |
||||
|
const kw = searchKeyword.value.trim().toLowerCase(); |
||||
|
if (!kw) return interfaceConfigs.value; |
||||
|
return interfaceConfigs.value.filter( |
||||
|
(i) => |
||||
|
i.name.toLowerCase().includes(kw) || |
||||
|
i.group.toLowerCase().includes(kw) || |
||||
|
i.url.toLowerCase().includes(kw) || |
||||
|
(i.description ?? '').toLowerCase().includes(kw) |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
// ============ 调用参数弹窗 ============ |
||||
|
const invokeDialogVisible = ref(false); |
||||
|
const invokeLoading = ref(false); |
||||
|
const currentItem = ref<InterfaceItem | null>(null); |
||||
|
const currentParams = ref(''); |
||||
|
const currentPathParams = ref(''); |
||||
|
const currentResponse = ref(''); |
||||
|
const currentResponseStatus = ref<'success' | 'error' | ''>(''); |
||||
|
|
||||
|
/** 请求方法对应的标签颜色 */ |
||||
|
const methodTagType = (method: string) => { |
||||
|
switch ((method || '').toLowerCase()) { |
||||
|
case 'get': return 'success'; |
||||
|
case 'post': return 'primary'; |
||||
|
case 'put': return 'warning'; |
||||
|
case 'delete': return 'danger'; |
||||
|
default: return 'info'; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
/** 打开调用弹窗(可编辑参数后再触发) */ |
||||
|
const openInvoke = (row: InterfaceItem) => { |
||||
|
currentItem.value = row; |
||||
|
currentParams.value = row.params ? JSON.stringify(row.params, null, 2) : ''; |
||||
|
currentPathParams.value = row.pathParams ? JSON.stringify(row.pathParams, null, 2) : ''; |
||||
|
currentResponse.value = ''; |
||||
|
currentResponseStatus.value = ''; |
||||
|
invokeDialogVisible.value = true; |
||||
|
}; |
||||
|
|
||||
|
/** 安全解析 JSON 字符串(空串返回 undefined) */ |
||||
|
const parseJsonSafe = (text: string, label: string): Record<string, any> | undefined => { |
||||
|
const s = (text || '').trim(); |
||||
|
if (!s) return undefined; |
||||
|
try { |
||||
|
const obj = JSON.parse(s); |
||||
|
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) { |
||||
|
throw new Error('必须是 JSON 对象'); |
||||
|
} |
||||
|
return obj; |
||||
|
} catch (e: any) { |
||||
|
throw new Error(`${label} JSON 解析失败:${e?.message || e}`); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
/** 执行调用 */ |
||||
|
const doInvoke = async () => { |
||||
|
if (!currentItem.value) return; |
||||
|
const item = currentItem.value; |
||||
|
|
||||
|
let params: Record<string, any> | undefined; |
||||
|
let pathParams: Record<string, any> | undefined; |
||||
|
try { |
||||
|
params = parseJsonSafe(currentParams.value, '请求参数'); |
||||
|
pathParams = parseJsonSafe(currentPathParams.value, '路径参数'); |
||||
|
} catch (e: any) { |
||||
|
ElMessage.error(e.message); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (item.confirm) { |
||||
|
try { |
||||
|
await ElMessageBox.confirm(`确认调用【${item.name}】?`, '确认', { type: 'warning' }); |
||||
|
} catch { |
||||
|
return; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
invokeLoading.value = true; |
||||
|
currentResponse.value = ''; |
||||
|
currentResponseStatus.value = ''; |
||||
|
try { |
||||
|
const res: any = await invokeInterface(item, params, pathParams); |
||||
|
currentResponse.value = JSON.stringify(res, null, 2); |
||||
|
currentResponseStatus.value = 'success'; |
||||
|
ElMessage.success(res?.msg || '调用成功'); |
||||
|
} catch (e: any) { |
||||
|
currentResponse.value = |
||||
|
typeof e === 'string' ? e : JSON.stringify({ message: e?.message, ...e }, null, 2); |
||||
|
currentResponseStatus.value = 'error'; |
||||
|
} finally { |
||||
|
invokeLoading.value = false; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
// ============ 查看/编辑原始 JSON 配置 ============ |
||||
|
const rawDialogVisible = ref(false); |
||||
|
const rawJsonText = ref(''); |
||||
|
|
||||
|
const openRawDialog = () => { |
||||
|
rawJsonText.value = JSON.stringify(interfaceConfigs.value, null, 2); |
||||
|
rawDialogVisible.value = true; |
||||
|
}; |
||||
|
|
||||
|
/** 保存(仅当前会话生效,不做持久化) */ |
||||
|
const applyRawJson = () => { |
||||
|
try { |
||||
|
const arr = JSON.parse(rawJsonText.value); |
||||
|
if (!Array.isArray(arr)) throw new Error('配置必须是数组'); |
||||
|
interfaceConfigs.value = arr; |
||||
|
ElMessage.success('已应用(仅当前会话生效)'); |
||||
|
rawDialogVisible.value = false; |
||||
|
} catch (e: any) { |
||||
|
ElMessage.error('JSON 解析失败:' + (e?.message || e)); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const copyRawJson = async () => { |
||||
|
try { |
||||
|
await navigator.clipboard.writeText(rawJsonText.value); |
||||
|
ElMessage.success('已复制到剪贴板'); |
||||
|
} catch { |
||||
|
ElMessage.warning('复制失败,请手动复制'); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
/** 完整请求 URL 预览(仅展示相对路径) */ |
||||
|
const previewUrl = computed(() => { |
||||
|
if (!currentItem.value) return ''; |
||||
|
let url = currentItem.value.url; |
||||
|
try { |
||||
|
const pp = parseJsonSafe(currentPathParams.value, '路径参数'); |
||||
|
if (pp) { |
||||
|
url = url.replace(/\{(\w+)\}/g, (_, k) => (pp[k] == null ? `{${k}}` : String(pp[k]))); |
||||
|
} |
||||
|
} catch { |
||||
|
// 忽略解析异常,原样预览 |
||||
|
} |
||||
|
return url; |
||||
|
}); |
||||
|
|
||||
|
const responseAlertType = computed(() => |
||||
|
currentResponseStatus.value === 'error' ? 'error' : 'success' |
||||
|
); |
||||
|
</script> |
||||
|
|
||||
|
<template> |
||||
|
<div class="app-container"> |
||||
|
<el-card shadow="never"> |
||||
|
<template #header> |
||||
|
<div class="card-header"> |
||||
|
<div class="header-title"> |
||||
|
<span>后端接口调用</span> |
||||
|
<el-tag type="info" size="small" style="margin-left: 8px"> |
||||
|
共 {{ interfaceConfigs.length }} 个接口 |
||||
|
</el-tag> |
||||
|
</div> |
||||
|
<div class="header-actions"> |
||||
|
<el-input |
||||
|
v-model="searchKeyword" |
||||
|
placeholder="按名称/分组/URL 过滤" |
||||
|
clearable |
||||
|
style="width: 260px; margin-right: 8px" |
||||
|
/> |
||||
|
<el-button type="primary" @click="openRawDialog">查看/编辑 JSON 配置</el-button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<el-alert type="info" :closable="false" style="margin-bottom: 16px"> |
||||
|
<template #title> |
||||
|
<span> |
||||
|
本页用于手动调用后端接口。所有接口在 <code>interfaceConfigs</code> 数组中以 JSON 形式定义, |
||||
|
新增接口时追加一条即可。字段:name/group/url/method/params/pathParams/confirm。 |
||||
|
</span> |
||||
|
</template> |
||||
|
</el-alert> |
||||
|
|
||||
|
<el-table :data="filteredConfigs" border stripe style="width: 100%"> |
||||
|
<el-table-column label="名称" prop="name" min-width="160" show-overflow-tooltip /> |
||||
|
<el-table-column label="分组" prop="group" width="220" show-overflow-tooltip /> |
||||
|
<el-table-column label="方法" width="80" align="center"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-tag :type="methodTagType(row.method)" size="small"> |
||||
|
{{ (row.method || 'get').toUpperCase() }} |
||||
|
</el-tag> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
<el-table-column label="URL" prop="url" min-width="280" show-overflow-tooltip /> |
||||
|
<el-table-column label="描述" prop="description" min-width="200" show-overflow-tooltip /> |
||||
|
<el-table-column label="操作" width="120" align="center" fixed="right"> |
||||
|
<template #default="{ row }"> |
||||
|
<el-button type="primary" link size="small" @click="openInvoke(row)"> |
||||
|
调用 |
||||
|
</el-button> |
||||
|
</template> |
||||
|
</el-table-column> |
||||
|
</el-table> |
||||
|
</el-card> |
||||
|
|
||||
|
<!-- 调用弹窗 --> |
||||
|
<el-dialog |
||||
|
v-model="invokeDialogVisible" |
||||
|
:title="currentItem ? `调用接口 - ${currentItem.name}` : '调用接口'" |
||||
|
width="820px" |
||||
|
append-to-body |
||||
|
:close-on-click-modal="false" |
||||
|
> |
||||
|
<template v-if="currentItem"> |
||||
|
<el-descriptions :column="1" border size="small" style="margin-bottom: 12px"> |
||||
|
<el-descriptions-item label="分组">{{ currentItem.group }}</el-descriptions-item> |
||||
|
<el-descriptions-item label="方法"> |
||||
|
<el-tag :type="methodTagType(currentItem.method)" size="small"> |
||||
|
{{ (currentItem.method || 'get').toUpperCase() }} |
||||
|
</el-tag> |
||||
|
</el-descriptions-item> |
||||
|
<el-descriptions-item label="URL"> |
||||
|
<span style="font-family: Consolas, monospace">{{ previewUrl }}</span> |
||||
|
</el-descriptions-item> |
||||
|
<el-descriptions-item v-if="currentItem.description" label="描述"> |
||||
|
{{ currentItem.description }} |
||||
|
</el-descriptions-item> |
||||
|
</el-descriptions> |
||||
|
|
||||
|
<el-form label-width="100px" label-position="top"> |
||||
|
<el-form-item v-if="currentItem.pathParams" label="路径参数 (JSON)"> |
||||
|
<el-input |
||||
|
v-model="currentPathParams" |
||||
|
type="textarea" |
||||
|
:autosize="{ minRows: 3, maxRows: 8 }" |
||||
|
placeholder='例如: { "targetPlanId": "xxx" }' |
||||
|
spellcheck="false" |
||||
|
/> |
||||
|
</el-form-item> |
||||
|
|
||||
|
<el-form-item |
||||
|
:label=" |
||||
|
(currentItem.method || 'get').toLowerCase() === 'get' |
||||
|
? '查询参数 (JSON)' |
||||
|
: '请求体 (JSON)' |
||||
|
" |
||||
|
> |
||||
|
<el-input |
||||
|
v-model="currentParams" |
||||
|
type="textarea" |
||||
|
:autosize="{ minRows: 4, maxRows: 12 }" |
||||
|
placeholder='例如: { "pageNum": 1, "pageSize": 10 }' |
||||
|
spellcheck="false" |
||||
|
/> |
||||
|
</el-form-item> |
||||
|
|
||||
|
<el-form-item v-if="currentResponse" label="响应结果"> |
||||
|
<el-alert |
||||
|
:type="responseAlertType" |
||||
|
:closable="false" |
||||
|
:title="currentResponseStatus === 'error' ? '调用失败' : '调用成功'" |
||||
|
style="margin-bottom: 8px" |
||||
|
/> |
||||
|
<el-input |
||||
|
:model-value="currentResponse" |
||||
|
type="textarea" |
||||
|
:autosize="{ minRows: 6, maxRows: 20 }" |
||||
|
readonly |
||||
|
spellcheck="false" |
||||
|
/> |
||||
|
</el-form-item> |
||||
|
</el-form> |
||||
|
</template> |
||||
|
|
||||
|
<template #footer> |
||||
|
<el-button @click="invokeDialogVisible = false">关闭</el-button> |
||||
|
<el-button type="primary" :loading="invokeLoading" @click="doInvoke"> |
||||
|
发起调用 |
||||
|
</el-button> |
||||
|
</template> |
||||
|
</el-dialog> |
||||
|
|
||||
|
<!-- 原始 JSON 配置弹窗 --> |
||||
|
<el-dialog |
||||
|
v-model="rawDialogVisible" |
||||
|
title="接口配置 JSON" |
||||
|
width="820px" |
||||
|
append-to-body |
||||
|
:close-on-click-modal="false" |
||||
|
> |
||||
|
<el-alert type="warning" :closable="false" style="margin-bottom: 8px"> |
||||
|
<template #title> |
||||
|
此处修改仅在当前会话生效,刷新后恢复。要长期新增/修改,请编辑 |
||||
|
<code>src/views/efcode/interface/index.vue</code> 中的 <code>interfaceConfigs</code>。 |
||||
|
</template> |
||||
|
</el-alert> |
||||
|
<el-input |
||||
|
v-model="rawJsonText" |
||||
|
type="textarea" |
||||
|
:autosize="{ minRows: 16, maxRows: 30 }" |
||||
|
spellcheck="false" |
||||
|
/> |
||||
|
<template #footer> |
||||
|
<el-button @click="copyRawJson">复制</el-button> |
||||
|
<el-button @click="rawDialogVisible = false">关闭</el-button> |
||||
|
<el-button type="primary" @click="applyRawJson">应用</el-button> |
||||
|
</template> |
||||
|
</el-dialog> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<style scoped lang="scss"> |
||||
|
.app-container { |
||||
|
padding: 16px; |
||||
|
} |
||||
|
.card-header { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
align-items: center; |
||||
|
.header-title { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
font-weight: bold; |
||||
|
} |
||||
|
.header-actions { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
} |
||||
|
} |
||||
|
</style> |
||||
Loading…
Reference in new issue