front/src/views/api/upstreamInterface/components/map-service-provider.vue
zzh 25445bc146 feat: 完善日夜间主题与揽收下单体验
统一暗色模式页面/顶栏/Handsontable 选中色,调整下单到仓方式文案与提示,CustomLeftMenu 卡片统一 60px;补充揽收单、问题件处理与 H5 相关页面。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 11:49:23 +08:00

692 lines
19 KiB
Vue

<script lang="ts" setup>
import { ref, computed } 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 CustomLeftMenu from '@/components/CustomLeftMenu/index.vue';
import type { CustomLeftMenuTreeNode } from '@/components/CustomLeftMenu/types';
// 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', 'add']);
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;
}
const mappingTreeData = computed<CustomLeftMenuTreeNode[]>(() =>
(list.value || []).map((row: any, index: number) => ({
key: `map-${getRowKey(row, index)}`,
title: row.serviceProviderName || '-',
isLeaf: true,
id: getRowKey(row, index),
name: row.serviceProviderName,
code: row.serviceProviderCode || '',
raw: row,
index,
})),
);
const selectedKeys = computed(() =>
selectedId.value != null ? [`map-${selectedId.value}`] : [],
);
function handleSelectRow(row: any, index: number) {
selectedId.value = getRowKey(row, index);
emit('row-click', row);
}
function handleMenuSelect(keys: string[]) {
const key = keys?.[0];
if (!key?.startsWith('map-')) return;
const id = key.slice(4);
const index = list.value.findIndex(
(row: any, i: number) => String(getRowKey(row, i)) === String(id),
);
if (index < 0) return;
handleSelectRow(list.value[index], index);
}
function handleNodeClick(key: string) {
handleMenuSelect([key]);
}
function resolveRow(nodeData: any) {
if (!nodeData) return null;
if (nodeData.raw) return nodeData.raw;
const id = nodeData.id;
return (
list.value.find(
(row: any, i: number) => String(getRowKey(row, i)) === String(id),
) || null
);
}
function handleDeleteNode(nodeData: any) {
const row = resolveRow(nodeData);
if (row) delRow(row);
}
function handlePublishNode(nodeData: any) {
const row = resolveRow(nodeData);
if (row) onRelease(row);
}
function handleRollbackNode(nodeData: any) {
const row = resolveRow(nodeData);
if (row) onBack(row);
}
function handleAdd() {
emit('add');
}
function handleSearch() {
loadList();
}
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 handleCancel() {
visible.value = false;
}
</script>
<template>
<div class="map-service-provider">
<CustomLeftMenu
class="mapping-menu"
width="100%"
:tree-data="mappingTreeData"
:selected-keys="selectedKeys"
:loading="loading"
v-model:search-keyword="searchKeyword"
:search-placeholder="t('system.interface.search.placeholder.providerName')"
:refresh-title="t('system.interface.actions.refresh')"
:add-title="t('system.interface.modal.addMapping')"
:actions-title="t('system.interface.mapping.actions.actions')"
:delete-label="t('system.interface.mapping.actions.delete')"
:show-method="false"
:show-map-count="false"
:show-pagination="false"
:show-node-index="true"
:show-node-code="true"
@search="handleSearch"
@refresh="loadList"
@add="handleAdd"
@select="handleMenuSelect"
@node-click="handleNodeClick"
@delete-node="handleDeleteNode"
>
<template #node-menu="{ nodeData }">
<a-doption @click="handleDeleteNode(nodeData)">
{{ t('system.interface.mapping.actions.delete') }}
</a-doption>
<a-doption @click="handlePublishNode(nodeData)">
{{ t('system.interface.mapping.actions.publish') }}
</a-doption>
<a-doption @click="handleRollbackNode(nodeData)">
{{ t('system.interface.mapping.actions.rollback') }}
</a-doption>
</template>
</CustomLeftMenu>
<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>
</template>
<style lang="less" scoped>
.map-service-provider {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
}
.mapping-menu {
width: 100% !important;
height: 100%;
flex: 1;
min-height: 0;
}
.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: var(--color-fill-2);
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: var(--app-card-bg);
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: var(--app-card-bg);
&:hover {
border-color: var(--app-primary-color);
color: var(--app-primary-color);
}
}
</style>