front/src/views/api/upstreamInterface/components/map-service-provider.vue
2026-04-27 09:09:31 +08:00

802 lines
23 KiB
Vue

<script lang="ts" setup>
import { ref } from 'vue';
import {
postDelete,
getGetMapList,
} from '@/api/InterfaceMappingServices/interfaceMap';
import {
getGetPublishCompare,
postPublish,
} from '@/api/InterfaceMappingServices/interfaceMapPublish';
import useModal from '@/components/ModalPro/useModal';
import { useI18n } from 'vue-i18n';
import { useRequest } from 'vue-hooks-plus';
import dayjs from 'dayjs';
import { $baseMessage, $baseConfirm } from '@/utils';
import { diffJson } from 'diff';
// import { mapserviceproviderColumns } from '../models/tableColumns';
import RollBack from './RollBack.vue';
import MonacoCom from './monacoCom.vue';
// import { IconSearch } from '@arco-design/web-vue/es/icon';
const { t } = useI18n();
defineOptions({
name: 'MapServiceProvider',
});
type IProps = {
id: number;
};
const props = defineProps<IProps>();
const emit = defineEmits(['edit-map', 'row-click']);
const tableRef = ref<any>();
const list = ref<any[]>([]);
const loading = ref(false);
const searchKeyword = ref('');
const selectedId = ref<number | string | null>(null);
function getRowKey(row: any, index: number) {
return row?.interfaceMapId ?? row?.id ?? index;
}
function handleSelectRow(row: any, index: number) {
selectedId.value = getRowKey(row, index);
emit('row-click', row);
}
async function loadList() {
try {
loading.value = true;
const requestParams: any = {
InterfaceId: props.id,
NameOrCode: searchKeyword.value || undefined,
PageIndex: 1,
PageSize: 1000,
};
const response: any = await getGetMapList(requestParams);
// 兼容各种返回结构,规避 TS 报错
const raw: any = response || {};
const data: any = raw.items ?? raw.data ?? raw.list ?? raw.records ?? raw;
list.value = Array.isArray(data) ? data : [];
// 仅当当前选中项在新列表中不存在时才清空,避免“点击一次又掉选中”
if (
selectedId.value !== null &&
!list.value.some((row: any, index: number) => getRowKey(row, index) === selectedId.value)
) {
selectedId.value = null;
}
} catch (e) {
list.value = [];
selectedId.value = null;
} finally {
loading.value = false;
}
}
watch(() => props.id, () => {
if (props.id) loadList();
}, { immediate: true });
// 暴露与旧用法兼容的 reload 接口
tableRef.value = { reload: loadList };
function setSearchKeyword(val: string) {
searchKeyword.value = val || '';
loadList();
}
defineExpose({ onRelease, tableRef, setSearchKeyword });
// 右侧内容
const modifiedModel = ref<string>();
const originalValue = ref<string | undefined>();
const differenceContent = ref('');
// 删除
function delRow(row: any) {
$baseConfirm({
title: t('system.interface.modal.warningTitle'),
content: t('system.interface.mapping.modal.deleteConfirm'),
onOk: async () => {
await postDelete({
interfaceId: row.systemInterfaceId,
serviceProviderId: row.serviceProviderId,
}).then(() => {
$baseMessage(t('system.interface.message.deleteSuccess'), 'success');
loadList();
});
},
});
}
// 发布
const [registerRelease, registerReleaseMethods] = useModal() as [any, any];
const codeRef = ref();
// 差异数量
const diffLength = ref(0);
// 接口映射id
const interfaceMapId = ref(0);
// 服务商名称接口名称
const serverInfo = {
serviceProviderName: '',
interfaceName: '',
};
const commsData = ref<any>({});
const getOriginalValue = computed(() => {
if (originalValue.value) {
return JSON.stringify(JSON.parse(originalValue.value || '{}'), null, 2);
}
return undefined;
});
const getModifiedModel = computed(() => {
if (modifiedModel.value) {
return JSON.stringify(JSON.parse(modifiedModel.value || '{}'), null, 2);
}
return '';
});
// 简单差异行列表(无语法高亮,仅标记增删)
const simpleDiffLines = computed(() => {
try {
const originObj = JSON.parse(originalValue.value || '{}');
const newObj = JSON.parse(modifiedModel.value || '{}');
const diffParts =
diffJson(originObj, newObj)?.filter((p) => p && p.value !== undefined) ||
[];
const lines: Array<{ text: string; added?: boolean; removed?: boolean }> =
[];
diffParts.forEach((part) => {
const segments = (part.value || '').split('\n');
segments.forEach((seg, idx) => {
// 跳过最后一个空行
if (idx === segments.length - 1 && seg === '') return;
lines.push({ text: seg, added: part.added, removed: part.removed });
});
});
return lines;
} catch (err) {
return [];
}
});
const showOnlyDiff = ref(false);
// 将 jsdiff 输出转成行级结构,并尝试把“删+增”配对为“修改”
const structuredDiff = computed(() => {
try {
const originObj = JSON.parse(originalValue.value || '{}');
const newObj = JSON.parse(modifiedModel.value || '{}');
const parts =
diffJson(originObj, newObj)?.filter((p) => p && p.value !== undefined) ||
[];
const flat: Array<{ text: string; status: 'add' | 'remove' | 'same' }> = [];
parts.forEach((p) => {
const status = p.added ? 'add' : p.removed ? 'remove' : 'same';
const lines = (p.value || '').split('\n');
lines.forEach((line, idx) => {
if (idx === lines.length - 1 && line === '') return;
flat.push({ text: line, status });
});
});
const merged: Array<
| { type: 'add' | 'remove' | 'same'; text: string }
| { type: 'change'; oldText: string; newText: string }
> = [];
let i = 0;
while (i < flat.length) {
const curr = flat[i];
const next = flat[i + 1];
if (curr?.status === 'remove' && next?.status === 'add') {
merged.push({ type: 'change', oldText: curr.text, newText: next.text });
i += 2;
} else {
merged.push({ type: curr.status, text: curr.text });
i += 1;
}
}
return merged;
} catch (err) {
return [];
}
});
const visibleDiffRows = computed(() => {
if (!showOnlyDiff.value) return structuredDiff.value;
return structuredDiff.value.filter((row) => row.type !== 'same');
});
async function onRelease(row: any) {
commsData.value = row;
interfaceMapId.value = row.interfaceMapId!;
serverInfo.serviceProviderName = row.serviceProviderName;
serverInfo.interfaceName = row.interfaceName;
try {
const resp = await getGetPublishCompare({
serviceProviderId: row.serviceProviderId!,
interfaceCode: row.systemInterfaceCode,
});
const res = (resp as any)?.data || resp || {};
modifiedModel.value = res.dbContent || '';
originalValue.value = res.publishedContent || '';
differenceContent.value = res.differenceContent || '';
// 先打开弹窗
registerReleaseMethods.openModal();
// 等待弹窗渲染完成
await nextTick();
// MonacoCom 组件会自动响应 props 变化,无需手动初始化
// 计算差异数量
const len =
diffJson(
JSON.parse(originalValue.value || '{}'),
JSON.parse(modifiedModel.value || '{}')
)?.filter((item) => item.added || item.removed).length || 0;
diffLength.value = len;
} catch (error) {
$baseMessage(t('system.interface.mapping.message.getPublishCompareFailed'), 'error');
}
}
const visible = ref(false);
const description = ref('');
async function sendMapOk() {
await run({
interfaceCode: commsData.value.systemInterfaceCode,
serviceProviderId: commsData.value.serviceProviderId,
dbContent: modifiedModel.value || '',
differenceContent: differenceContent.value,
description: description.value,
});
description.value = '';
visible.value = false;
}
function handlerSendMap() {
if (!diffLength.value)
return $baseMessage(t('system.interface.mapping.message.noContentToPublish'), 'warning');
visible.value = true;
}
const { run } = useRequest(postPublish, {
manual: true,
onSuccess() {
$baseMessage(
t('system.interface.mapping.message.publishCacheSuccess', {
providerName: serverInfo.serviceProviderName,
interfaceName: serverInfo.interfaceName,
count: diffLength.value,
}),
'success'
);
registerReleaseMethods.closeModal();
},
});
/**
* @description: 切换下一个差异
* @return {*}
*/
function changeNext() {
codeRef.value.handleNext();
}
/**
* @description: 切换上一个差异
* @return {*}
*/
function changePre() {
codeRef.value.handlePre();
}
// 回滚
const rollBackRef = ref();
/**
* @description: 打开回滚弹窗
* @return {*}
*/
function onBack(row: any) {
interfaceMapId.value = row.interfaceMapId;
rollBackRef.value?.handleOpenBack(row);
}
// 处理搜索
function handleSearch() {
// 使用 reload 方法重新加载数据
// searchInfo 是计算属性,会自动响应 searchKeyword 的变化
if (tableRef.value) {
tableRef.value.reload();
}
}
// 处理取消操作
function handleCancel() {
visible.value = false;
}
</script>
<template>
<div class="main-container pb-0">
<div>
<div class="provider-list">
<div v-if="!loading && list && list.length > 0">
<a-spin :loading="loading">
<div
v-for="(row, index) in list"
:key="getRowKey(row, index)"
class="provider-item"
:class="{ selected: selectedId === getRowKey(row, index) }"
@click="handleSelectRow(row, index)"
>
<table class="provider-table">
<tbody>
<tr>
<td class="provider-content-cell">
<div class="provider-content">
<div class="provider-header">
<span v-if="selectedId === getRowKey(row, index)" class="selected-dot"></span>
<span class="provider-number">{{ index + 1 }}</span>
<div class="provider-name">{{ row.serviceProviderName }}</div>
<span v-if="row.serviceProviderCode" class="provider-code-inline">{{ row.serviceProviderCode }}</span>
</div>
</div>
</td>
<td class="provider-actions-cell">
<div class="provider-actions" @click.stop>
<a-dropdown trigger="click">
<a-button
type="text"
size="small"
class="action-btn vertical-dots"
:title="t('system.interface.mapping.actions.actions')"
@click="handleSelectRow(row, index)"
>
<div class="vertical-dots-icon">
<span></span>
<span></span>
<span></span>
</div>
</a-button>
<template #content>
<a-doption @click="delRow(row)">{{ t('system.interface.mapping.actions.delete') }}</a-doption>
<a-doption @click="onRelease(row)">{{ t('system.interface.mapping.actions.publish') }}</a-doption>
<a-doption @click="onBack(row)">{{ t('system.interface.mapping.actions.rollback') }}</a-doption>
</template>
</a-dropdown>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</a-spin>
</div>
<div v-else-if="!loading && (!list || list.length === 0)" class="empty">
<a-empty :description="t('system.interface.mapping.empty.noProvider')" />
</div>
<div v-else class="loading-container">
<a-spin :loading="loading" />
</div>
</div>
<ModalPro
:title="t('system.interface.mapping.modal.publishTitle')"
:width="1280"
:footer="false"
@register="registerRelease"
>
<div class="release-content slim">
<div class="diff-header">
<div class="diff-title">{{ t('system.interface.mapping.modal.updateCache') }}</div>
<div class="diff-actions">
<a-button
type="primary"
size="small"
@click="handlerSendMap"
:disabled="diffLength === 0"
>
{{ t('system.interface.mapping.actions.publish') }}
</a-button>
<a-checkbox v-model="showOnlyDiff" size="small">
只看差异
</a-checkbox>
</div>
</div>
<div class="diff-boxes">
<div class="diff-box">
<div class="diff-box-title">{{ t('system.interface.mapping.modal.onlineCache') }}</div>
<pre class="diff-pre">{{ getOriginalValue || '{}' }}</pre>
</div>
<div class="diff-box">
<div class="diff-box-title">{{ t('system.interface.mapping.modal.updateCache', { count: diffLength }) }}</div>
<pre class="diff-pre">{{ getModifiedModel || '{}' }}</pre>
</div>
</div>
<div class="legend">
<span class="legend-item add">🟢 新增</span>
<span class="legend-item remove">🔴 删除</span>
<span class="legend-item change">🟡 修改</span>
<span class="legend-item same">⚪ 不变</span>
</div>
<div class="merged-diff">
<div class="diff-box-title">{{ t('system.interface.mapping.modal.updateCache') }}</div>
<div class="merged-diff-body">
<template v-for="(row, idx) in visibleDiffRows" :key="idx">
<template v-if="row.type === 'change'">
<div class="merged-line change">
<span class="line-mark">🟡</span>
<span class="line-text">
{{ row.oldText }} → {{ row.newText }}
</span>
</div>
</template>
<template v-else>
<div
class="merged-line"
:class="{
added: row.type === 'add',
removed: row.type === 'remove',
same: row.type === 'same'
}"
>
<span class="line-mark">
{{ row.type === 'add' ? '🟢' : row.type === 'remove' ? '🔴' : '⚪' }}
</span>
<span class="line-text">{{ row.text }}</span>
</div>
</template>
</template>
<div v-if="!visibleDiffRows.length" class="diff-empty">
{{ t('system.interface.mapping.message.noContentToPublish') }}
</div>
</div>
</div>
</div>
</ModalPro>
<RollBack ref="rollBackRef" :server-info="serverInfo" />
<a-modal
v-model:visible="visible"
:title="t('system.interface.modal.warningTitle')"
@cancel="handleCancel"
@ok="sendMapOk"
>
<div class="mb-15px">{{ t('system.interface.mapping.modal.publishWarning') }}</div>
<a-input v-model="description" :placeholder="t('system.interface.mapping.modal.versionRemark')" />
</a-modal>
</div>
</div>
</template>
<style lang="less" scoped>
/* 去掉外围容器可能带来的默认左右内边距 */
.main-container { padding-left: 0 !important; padding-right: 0 !important; }
.bg-white { padding-left: 0 !important; padding-right: 0 !important; }
/* 接近下游服务商列表样式:紧凑、仅关键信息 */
:deep(.vxe-table) {
font-size: 12px;
}
:deep(.vxe-body--row) {
height: 36px;
}
.sp-code {
color: #8c8c8c;
}
.main-box {
padding-top: 0;
}
.toolbar-buttons {
display: flex;
justify-content: space-between;
width: 100%;
}
.span-style {
margin-top: 5px;
font-size: 17px;
font-weight: 900;
}
.text-style {
display: flex;
}
.search-box {
padding: 16px;
border-bottom: 1px solid #e8e8e8;
:deep(.arco-input-wrapper) {
width: 300px !important;
max-width: 300px !important;
}
:deep(.arco-input) {
width: 100% !important;
}
}
.mapped-list { padding: 0; }
.provider-list {
padding: 8px 0;
display: flex;
flex-direction: column;
flex: 1;
color: #000;
background: transparent;
border-radius: 16px;
overflow-y: auto;
padding-right: 4px;
}
.provider-list::-webkit-scrollbar {
width: 6px;
}
.provider-list::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.08);
border-radius: 8px;
}
.provider-list::-webkit-scrollbar-thumb {
background: rgba(86, 144, 236, 0.65);
border-radius: 8px;
}
.provider-list::-webkit-scrollbar-thumb:hover {
background: rgba(59, 97, 148, 0.85);
}
.loading-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.provider-item {
width: 100%;
box-sizing: border-box;
border: 1px solid rgba(0, 0, 0, 0.1);
padding: 10px 12px;
margin: 8px 0;
border-radius: 8px;
transition: background-color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
cursor: pointer;
background: #fff;
display: flex;
justify-content: space-between;
align-items: flex-start;
&:hover {
background-color: rgba(0, 0, 0, 0.05);
border-color: rgba(0, 0, 0, 0.2);
transform: translateX(4px);
}
&.selected {
background: #FFF394 !important;
box-shadow: none;
border-color: #FFF394 !important;
color: #000 !important;
.provider-number,
.provider-name,
.provider-code,
.provider-code-inline {
color: #000 !important;
}
}
}
.provider-table { width: 100%; border-collapse: collapse; table-layout: fixed; }
.provider-content-cell { text-align: left; padding: 0; width: auto; }
.provider-actions-cell { text-align: right; padding: 0; width: 30px; vertical-align: middle; }
.provider-code-cell { text-align: left; padding: 0; width: auto; padding-left: 28px; }
.provider-content { cursor: pointer; }
.provider-actions { opacity: 1; transition: opacity 0.2s; margin-top: 2px; }
.action-btn { width: 24px; height: 24px; padding: 0; display: flex; align-items: center; justify-content: center; border: none; background: transparent; cursor: pointer; }
.action-btn:hover { background-color: #f0f8ff; border-radius: 4px; }
.vertical-dots-icon { display: flex; flex-direction: column; gap: 2px; }
.vertical-dots-icon span { width: 3px; height: 3px; background-color: #8c8c8c; border-radius: 50%; display: block; }
.provider-header { display: flex; align-items: center; margin-bottom: 4px; }
.provider-number { color: rgba(0, 0, 0, 0.6); font-size: 12px; font-weight: normal; margin-right: 8px; display: inline-block; min-width: 16px; text-align: center; flex-shrink: 0; }
.provider-name { font-size: 14px; font-weight: 600; color: #000; flex-shrink: 0; }
.provider-code { font-size: 12px; color: rgba(0, 0, 0, 0.65); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.provider-code-inline { font-size: 11px; color: rgba(0, 0, 0, 0.65); margin-left: 6px; }
/* 选中态与下游一致 */
.selected-dot { display: none; }
.empty {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
}
/* 发布弹窗样式 */
.release-header {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
font-size: 12px;
color: #232A33;
}
.release-title {
flex: 1;
text-align: center;
}
.release-content.slim {
display: flex;
flex-direction: column;
gap: 12px;
}
.diff-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.diff-title {
font-size: 13px;
color: #232A33;
}
.diff-actions {
display: flex;
gap: 8px;
}
.diff-boxes {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.diff-box {
display: flex;
flex-direction: column;
gap: 8px;
}
.diff-box-title {
font-size: 12px;
color: #666;
}
.diff-pre {
margin: 0;
padding: 10px 12px;
background: #f7f8fa;
border: 1px solid #e8e8e8;
border-radius: 6px;
min-height: 160px;
max-height: 280px;
overflow: auto;
font-family: Menlo, Consolas, monospace;
font-size: 12px;
line-height: 18px;
white-space: pre-wrap;
word-break: break-all;
}
.diff-actions :deep(.arco-btn) {
padding: 0 8px;
min-width: 32px;
}
.merged-diff {
display: flex;
flex-direction: column;
gap: 8px;
}
.merged-diff-body {
border: 1px solid #e8e8e8;
border-radius: 6px;
background: #fff;
max-height: 600px;
overflow: auto;
font-family: Menlo, Consolas, monospace;
font-size: 12px;
line-height: 18px;
padding: 10px 12px;
white-space: pre-wrap;
word-break: break-all;
}
.merged-seg.added {
background: #e6ffed;
}
.merged-seg.removed {
background: #ffecec;
text-decoration: line-through;
}
.merged-line {
display: flex;
gap: 8px;
padding: 4px 0;
white-space: pre-wrap;
word-break: break-all;
}
.merged-line.added {
background: #e6ffed;
}
.merged-line.removed {
background: #ffecec;
text-decoration: line-through;
}
.merged-line.change {
background: #fffbe6;
}
.merged-line.same {
color: #666;
}
.legend {
display: flex;
gap: 12px;
font-size: 12px;
}
.legend-item.add { color: #16a34a; }
.legend-item.remove { color: #dc2626; }
.legend-item.change { color: #ca8a04; }
.legend-item.same { color: #666; }
.diff-controls {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 20px;
}
.control-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #e8e8e8;
border-radius: 4px;
background-color: #fff;
&:hover {
border-color: var(--app-primary-color);
color: var(--app-primary-color);
}
}
</style>