feat: 优化上下游接口明细交互与左侧树层级
All checks were successful
Build and Deploy Front / deploy (push) Successful in 6m21s

完善保存后引导发布、顶栏操作与参数区图标化,并区分分组与实体节点样式。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-09-14 20:18:26 +08:00
parent 33f696bc42
commit a0fea7d001
16 changed files with 825 additions and 211 deletions

View File

@ -141,7 +141,10 @@ axiosInstance.interceptors.response.use(
silentError?: boolean
}
const userStore = useRbacUserStore()
const silentError = Boolean(originalRequest?.silentError)
// 登录接口由页面内联 errorBox 展示,避免拦截器再弹 Message
const requestUrl = String(originalRequest?.url || '')
const isAuthLoginRequest = /\/rbac\/auth\/login(?:\?|$)/.test(requestUrl)
const silentError = Boolean(originalRequest?.silentError) || isAuthLoginRequest
if (error.response) {
const status = error.response.status

View File

@ -73,8 +73,9 @@ function getLangSupport(lang: string) {
const langSupport = computed(() => getLangSupport(props.language))
const scrollExtension = EditorView.theme({
'.cm-editor': { height: '100%' },
'.cm-scroller': { overflow: 'auto', minHeight: '100%' },
'&': { height: '100%', maxHeight: '100%' },
'.cm-scroller': { overflow: 'auto' },
'.cm-content': { minHeight: '100%' },
})
const themeExtension = computed(() => {
@ -163,19 +164,24 @@ defineExpose({
.codemirror-container {
width: 100%;
height: 100%;
overflow: auto;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
:deep(.vue-codemirror),
:deep(.cm-editor) {
flex: 1;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
font-size: 14px;
}
:deep(.cm-scroller) {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
overflow: auto;
overflow: auto !important;
}
}
</style>

View File

@ -386,9 +386,10 @@
:class="{
selected: selectedKeysModel.includes(key),
'tree-node--hierarchical': hierarchical,
'tree-node--branch': hierarchical && getNodeData(key)?.isLeaf !== true,
'tree-node--branch': getNodeData(key)?.isLeaf !== true,
'tree-node--leaf': getNodeData(key)?.isLeaf === true,
'tree-node--with-code': getNodeData(key)?.isLeaf && showNodeCode,
[`tree-node--depth-${getNodeData(key)?.depth || 1}`]: hierarchical,
[`tree-node--depth-${getNodeData(key)?.depth || (getNodeData(key)?.isLeaf ? 2 : 1)}`]: true,
}"
@click="hierarchical && selectTreeNode(key)"
>
@ -856,11 +857,35 @@
min-height: var(--clm-node-height);
}
&.tree-node--branch {
height: auto;
min-height: 34px;
max-height: none;
margin: 8px 0 2px;
padding: 6px 8px;
border-color: transparent;
background: transparent;
overflow: visible;
.node-second-row-spacer-cell {
display: none;
}
}
&.tree-node--leaf {
background: var(--color-fill-1);
}
&:hover {
background: var(--clm-hover);
border-color: rgba(15, 16, 17, 0.1);
}
&.tree-node--branch:hover {
background: transparent;
border-color: transparent;
}
&.selected {
background: var(--clm-selected) !important;
border-color: var(--clm-selected-border) !important;
@ -876,16 +901,27 @@
color: var(--clm-muted) !important;
}
&.tree-node--branch .node-title::before {
background: var(--clm-selected-text);
&.tree-node--branch {
background: transparent !important;
border-color: transparent !important;
.node-title {
color: var(--clm-muted) !important;
font-weight: 600;
}
.node-title::before {
background: var(--app-primary-color);
}
}
}
}
.tree-node--depth-1 .node-title {
.tree-node--depth-1.tree-node--branch .node-title {
font-size: 12px;
font-weight: 560;
letter-spacing: -0.01em;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--clm-muted);
}
.tree-node--depth-2 .node-title {
@ -906,13 +942,12 @@
.tree-node--branch .node-title::before {
content: '';
display: inline-block;
width: 5px;
height: 5px;
margin-right: 6px;
border-radius: 1.5px;
background: var(--clm-faint);
vertical-align: middle;
transform: translateY(-1px);
width: 3px;
height: 12px;
margin-right: 8px;
border-radius: 2px;
background: color-mix(in srgb, var(--app-primary-color) 55%, var(--clm-faint));
vertical-align: -2px;
}
.node-subtitle {
@ -1062,6 +1097,81 @@
min-height: var(--clm-node-height);
}
/* 分组头:轻量、矮一些,和可点击实体卡片拉开层级 */
&.tree-node--branch {
height: auto;
min-height: 34px;
max-height: none;
margin: 10px 0 4px;
padding: 6px 8px;
border-color: transparent;
background: transparent;
cursor: default;
overflow: visible;
.node-title {
font-size: 12px;
font-weight: 600;
color: var(--clm-muted);
letter-spacing: 0.02em;
}
.node-title::before {
content: '';
display: inline-block;
width: 3px;
height: 12px;
margin-right: 8px;
border-radius: 2px;
background: color-mix(in srgb, var(--app-primary-color) 55%, var(--clm-faint));
vertical-align: -2px;
}
.node-subtitle {
color: var(--clm-faint);
font-size: 11px;
}
.node-second-row-spacer-cell {
display: none;
}
.node-subtitle-cell {
padding-top: 2px;
padding-left: 11px;
}
&:hover {
background: transparent;
border-color: transparent;
}
&.selected {
background: transparent !important;
border-color: transparent !important;
box-shadow: none !important;
.node-title {
color: var(--clm-muted) !important;
font-weight: 600;
}
.node-subtitle {
color: var(--clm-faint) !important;
}
.node-title::before {
background: var(--app-primary-color);
}
}
}
/* 实体叶子:保持卡片感 */
&.tree-node--leaf {
background: var(--color-fill-1);
border-color: rgba(15, 16, 17, 0.07);
}
&:hover {
background: var(--clm-hover);
border-color: rgba(15, 16, 17, 0.1);
@ -1073,6 +1183,10 @@
opacity: 1;
}
}
&.tree-node--leaf:hover {
background: var(--clm-hover);
}
}
.tree-node.selected {

View File

@ -15,6 +15,8 @@ export interface CustomLeftMenuTreeNode {
method?: string;
/** 分组等非叶子节点第二行副标题 */
subtitle?: string;
/** 树深度:1 分组 / 2+ 实体等 */
depth?: number;
[key: string]: any;
}

View File

@ -1,6 +1,6 @@
<script lang="ts" setup>
import { ref, reactive, watch, nextTick, onMounted, onUnmounted, computed } from 'vue';
import { IconDragArrow, IconPlus, IconDelete, IconCopy } from '@arco-design/web-vue/es/icon';
import { IconDragArrow, IconPlus, IconDelete, IconCopy, IconSettings, IconImport, IconEraser } from '@arco-design/web-vue/es/icon';
import FormPro from '@/components/FormPro/index.vue';
import TablePro from '@/components/TablePro/index.vue';
import CodeMirror from '@/components/CodeMirror/index.vue';
@ -142,7 +142,7 @@
},
});
const emit = defineEmits(['add-query', 'add-request', 'add-response', 'parse-request', 'parse-response', 'update:basicFormData']);
const emit = defineEmits(['add-query', 'add-request', 'add-response', 'parse-request', 'parse-response', 'update:basicFormData', 'return-config-saved']);
const refBasicForm = ref();
const queryTableRef = ref();
@ -198,7 +198,7 @@
title: '操作',
fixed: 'right',
align: 'center',
width: 128,
width: 140,
slots: { default: 'action' },
},
];
@ -540,6 +540,20 @@
responseTreeExpanded.value = next;
}
/** 设置面板:一次展开 / 折叠 Request Body、Mapping Body、Response 参数树 */
function setAllDetailTreesExpand(expanded: string | number | boolean) {
const next = !!expanded;
setBodyTreeExpandAll(next);
setMappingBodyTreeExpandAll(next);
setResponseTreeExpandAll(next);
}
const allDetailTreesExpanded = computed(
() => bodyTreeExpanded.value && mappingBodyTreeExpanded.value && responseTreeExpanded.value,
);
const detailSettingsVisible = ref(false);
watch(
() => props.interfaceId,
(id, oldId) => {
@ -702,6 +716,21 @@
const responseExample = ref<string>(''); // Demo内容
const responseExampleEditorRef = ref<any>(); // Demo编辑器引用
function relayoutExampleEditors() {
nextTick(() => {
requestExampleEditorRef.value?.getEditor?.()?.requestMeasure?.();
responseExampleEditorRef.value?.getEditor?.()?.requestMeasure?.();
});
}
watch(panelLayout, () => relayoutExampleEditors());
watch(requestTab, (tab) => {
if (tab === 'demo') relayoutExampleEditors();
});
watch(responseTab, (tab) => {
if (tab === 'demo') relayoutExampleEditors();
});
watch([requestExample, responseExample], () => refreshDirtyState());
// 解析弹窗相关状态
@ -1288,6 +1317,14 @@
return ['query', 'header', 'fixed', 'body', 'mappingBody', 'demo'].includes(requestTab.value);
});
/** Body 参数树(非 KV)把「清空」放在根节点操作列,不再占顶栏 */
const isBodyTreeClearInRoot = computed(
() =>
requestTab.value === 'body'
&& !isNoneBodyContentType.value
&& !isBodyKvMode.value,
);
const canClearCurrentRequestTab = computed(() => {
if (!showRequestTabClearAction.value) return false;
if (requestTab.value === 'query') return queryTabCount.value > 0;
@ -1302,45 +1339,6 @@
return false;
});
/** 当前 Request Tab 空态提示(展示在顶部 Tab 行正中) */
const requestHeaderEmptyTip = computed(() => {
if (requestTab.value === 'query' && queryTabCount.value === 0) {
return '暂无 Query 参数,可点击右侧「新增」';
}
if (requestTab.value === 'header' && headerTabCount.value === 0) {
return '暂无 Header 参数,可点击右侧「新增」';
}
if (requestTab.value === 'body' && isNoneBodyContentType.value) {
return '当前为 none,无 HTTP Body';
}
if (requestTab.value === 'body' && isBodyKvMode.value && isBodyKvEmpty.value) {
return '暂无 Body 字段,可点击右侧「新增」';
}
if (
requestTab.value === 'body'
&& !isBodyKvMode.value
&& !isNoneBodyContentType.value
&& isBodyTreeEmpty.value
) {
return '暂无 Body 字段,可「新增」或「导入」';
}
if (requestTab.value === 'mappingBody' && isMappingBodyEmpty.value) {
return '暂无 Mapping Body 字段,可「新增」或「导入」';
}
if (requestTab.value === 'demo') {
return '可复制 cURL,或点顶栏「调试」自动带入';
}
return '';
});
/** 当前 Response Tab 空态提示(展示在顶部 Tab 行正中) */
const responseHeaderEmptyTip = computed(() => {
if (responseTab.value === 'body' && responseBodyTabCount.value === 0) {
return '暂无 Response 字段,可「新增」或「导入」';
}
return '';
});
// 监听 queryParams prop 变化,同步到表格数据
watch(
() => props.queryParams,
@ -1539,18 +1537,26 @@
}
// 保存返回配置
async function saveReturnConfig() {
// 保存返回配置;emitSaved 用于按钮保存后引导发布(切 tab 自动保存不引导)
async function saveReturnConfig(options?: { emitSaved?: boolean }) {
if (returnConfigRef.value?.save) {
try {
await returnConfigRef.value.save();
const ok = await returnConfigRef.value.save({
skipSuccessToast: !!options?.emitSaved,
});
if (ok === false) return false;
returnConfigHasChanges.value = false;
// 成功提示由 ReturnConfig 统一发出
if (options?.emitSaved) {
emit('return-config-saved');
}
return true;
} catch (error) {
// ReturnConfig 内部吞掉错误时可能无提示;仅兜底
$baseMessage('保存失败', 'error');
return false;
}
}
return false;
}
// 新增返回配置项
@ -3333,76 +3339,111 @@
<div class="section-header-right">
<div class="section-actions">
<a-button
v-if="showRequestTabClearAction"
v-if="showRequestTabClearAction && !isBodyTreeClearInRoot"
size="small"
class="section-icon-btn"
:disabled="!canClearCurrentRequestTab"
title="清空当前 Tab 内容"
@click="confirmClearCurrentRequestTab"
>
清空
<IconEraser />
</a-button>
<template v-if="requestTab === 'query'">
<a-button size="small" @click="addNewQueryParam">新增</a-button>
<a-button size="small" class="section-icon-btn" title="新增" @click="addNewQueryParam">
<IconPlus />
</a-button>
</template>
<template v-else-if="requestTab === 'header'">
<a-button size="small" @click="addNewHeaderParam">新增</a-button>
<a-button size="small" class="section-icon-btn" title="新增" @click="addNewHeaderParam">
<IconPlus />
</a-button>
</template>
<template v-else-if="requestTab === 'fixed'">
<a-button size="small" @click="addFixedParam">新增</a-button>
<a-button size="small" class="section-icon-btn" title="新增" @click="addFixedParam">
<IconPlus />
</a-button>
<a-button size="small" @click="saveFixedParams">保存固定配置</a-button>
</template>
<template v-else-if="requestTab === 'body' && isBodyKvMode">
<a-button size="small" @click="addNewBodyFormParam">新增</a-button>
<a-button size="small" class="section-icon-btn" title="新增" @click="addNewBodyFormParam">
<IconPlus />
</a-button>
</template>
<template v-else-if="requestTab === 'body' && !isNoneBodyContentType">
<a-button size="small" @click="addNewRequestParam">新增</a-button>
<a-button size="small" @click="drawerClick">导入</a-button>
<a-button size="small" class="section-icon-btn" title="新增" @click="addNewRequestParam">
<IconPlus />
</a-button>
<a-button size="small" class="section-icon-btn" title="导入" @click="drawerClick">
<IconImport />
</a-button>
</template>
<template v-else-if="requestTab === 'mappingBody'">
<a-button size="small" @click="addNewRequestParam">新增</a-button>
<a-button size="small" @click="drawerClick">导入</a-button>
<a-button size="small" class="section-icon-btn" title="新增" @click="addNewRequestParam">
<IconPlus />
</a-button>
<a-button size="small" class="section-icon-btn" title="导入" @click="drawerClick">
<IconImport />
</a-button>
</template>
<template v-else-if="requestTab === 'demo'">
<a-button size="small" @click="copyRequestExampleContent">复制</a-button>
<a-button
size="small"
class="section-icon-btn"
title="复制"
@click="copyRequestExampleContent"
>
<IconCopy />
</a-button>
</template>
</div>
<div class="header-switches">
<a-switch
v-if="requestTab === 'body' && !isNoneBodyContentType && !isBodyKvMode"
class="tree-expand-switch"
:model-value="bodyTreeExpanded"
checked-text="展开"
unchecked-text="折叠"
title="展开 / 折叠全部节点"
@change="setBodyTreeExpandAll"
/>
<a-switch
v-else-if="requestTab === 'mappingBody'"
class="tree-expand-switch"
:model-value="mappingBodyTreeExpanded"
checked-text="展开"
unchecked-text="折叠"
title="展开 / 折叠全部节点"
@change="setMappingBodyTreeExpandAll"
/>
<a-switch
class="layout-switch"
:model-value="panelLayout === 'vertical'"
checked-text="上下"
unchecked-text="左右"
title="切换请求 / 响应布局"
@change="onPanelLayoutSwitch"
/>
<a-popover
v-model:popup-visible="detailSettingsVisible"
trigger="click"
position="br"
content-class="detail-settings-popover"
>
<a-button
size="small"
class="detail-settings-btn"
title="布局与节点展开设置"
>
<IconSettings />
</a-button>
<template #content>
<div class="detail-settings-panel">
<div class="detail-settings-row">
<span class="detail-settings-label">请求 / 响应布局</span>
<a-switch
class="layout-switch"
:model-value="panelLayout === 'vertical'"
checked-text="上下"
unchecked-text="左右"
title="切换请求 / 响应布局"
@change="onPanelLayoutSwitch"
/>
</div>
<div class="detail-settings-row">
<span class="detail-settings-label">参数树节点</span>
<a-switch
class="tree-expand-switch"
:model-value="allDetailTreesExpanded"
checked-text="展开"
unchecked-text="折叠"
title="展开 / 折叠全部参数树节点(含 Response)"
@change="setAllDetailTreesExpand"
/>
</div>
</div>
</template>
</a-popover>
</div>
</div>
</div>
<div class="section-content" :class="{ 'section-content-fixed-tab': requestTab === 'fixed' }">
<div
v-if="(showContentTypeSelectors && requestTab === 'body') || requestHeaderEmptyTip"
v-if="showContentTypeSelectors && requestTab === 'body'"
class="content-type-bar"
>
<SlidingTabs
v-if="showContentTypeSelectors && requestTab === 'body'"
size="mini"
class="content-type-tabs"
:model-value="String(basicFormData?.contentType ?? '')"
@ -3410,13 +3451,6 @@
aria-label="Request content type"
@change="updateContentType"
/>
<div
v-if="requestHeaderEmptyTip"
class="content-type-empty-tip"
:title="requestHeaderEmptyTip"
>
{{ requestHeaderEmptyTip }}
</div>
</div>
<!-- Query tab 内容 -->
<div v-show="requestTab === 'query'" class="query-tab-pane">
@ -3920,12 +3954,24 @@
<template #action="{ row }">
<a-space :size="2">
<a-button type="text" class="row-action-btn" title="新增子节点" @click="addRequestRow(row)">
<a-button
v-if="isBodyRootRow(row)"
type="text"
size="mini"
class="row-action-btn row-clear-btn"
title="清空当前 Tab 内容"
:disabled="!canClearCurrentRequestTab"
@click="confirmClearCurrentRequestTab"
>
<IconEraser />
</a-button>
<a-button type="text" size="mini" class="row-action-btn" title="新增子节点" @click="addRequestRow(row)">
<IconPlus />
</a-button>
<a-button
v-if="!isBodyRootRow(row)"
type="text"
size="mini"
class="row-action-btn"
title="复制行"
@click="copyBodyTreeRow(row)"
@ -3937,7 +3983,7 @@
content="确定删除该参数?"
@ok="deleteRequestRow(row)"
>
<a-button type="text" class="row-action-btn" title="删除">
<a-button type="text" size="mini" class="row-action-btn" title="删除">
<IconDelete />
</a-button>
</a-popconfirm>
@ -4076,14 +4122,14 @@
<template #action="{ row }">
<a-space :size="2">
<a-button type="text" class="row-action-btn" title="新增子节点" @click="addRequestRow(row)">
<a-button type="text" size="mini" class="row-action-btn" title="新增子节点" @click="addRequestRow(row)">
<IconPlus />
</a-button>
<a-button type="text" class="row-action-btn" title="复制行" @click="copyBodyTreeRow(row)">
<a-button type="text" size="mini" class="row-action-btn" title="复制行" @click="copyBodyTreeRow(row)">
<IconCopy />
</a-button>
<a-popconfirm content="确定删除该参数?" @ok="deleteRequestRow(row)">
<a-button type="text" class="row-action-btn" title="删除">
<a-button type="text" size="mini" class="row-action-btn" title="删除">
<IconDelete />
</a-button>
</a-popconfirm>
@ -4108,7 +4154,7 @@
scrollBeyondLastLine: false,
wordWrap: 'on',
}"
:height="600"
height="100%"
/>
</div>
</div>
@ -4128,33 +4174,28 @@
<div class="section-header-right">
<div class="section-actions">
<template v-if="responseTab === 'body'">
<a-button size="small" @click="addNewResponseParam">新增</a-button>
<a-button size="small" @click="drawerClick1">导入</a-button>
<a-button size="small" class="section-icon-btn" title="新增" @click="addNewResponseParam">
<IconPlus />
</a-button>
<a-button size="small" class="section-icon-btn" title="导入" @click="drawerClick1">
<IconImport />
</a-button>
</template>
<template v-if="responseTab === 'returnConfig'">
<a-button size="small" @click="addReturnConfigItem">新增</a-button>
<a-button size="small" @click="saveReturnConfig">保存返回配置</a-button>
<a-button size="small" class="section-icon-btn" title="新增" @click="addReturnConfigItem">
<IconPlus />
</a-button>
<a-button size="small" @click="saveReturnConfig({ emitSaved: true })">保存返回配置</a-button>
</template>
</div>
<div v-if="responseTab === 'body'" class="header-switches">
<a-switch
class="tree-expand-switch"
:model-value="responseTreeExpanded"
checked-text="展开"
unchecked-text="折叠"
title="展开 / 折叠全部节点"
@change="setResponseTreeExpandAll"
/>
</div>
</div>
</div>
<div class="section-content">
<div
v-if="(showContentTypeSelectors && responseTab === 'body') || responseHeaderEmptyTip"
v-if="showContentTypeSelectors && responseTab === 'body'"
class="content-type-bar"
>
<SlidingTabs
v-if="showContentTypeSelectors && responseTab === 'body'"
size="mini"
class="content-type-tabs"
:model-value="String(basicFormData?.responseContentType ?? '')"
@ -4162,13 +4203,6 @@
aria-label="Response content type"
@change="updateResponseContentType"
/>
<div
v-if="responseHeaderEmptyTip"
class="content-type-empty-tip"
:title="responseHeaderEmptyTip"
>
{{ responseHeaderEmptyTip }}
</div>
</div>
<!-- Response body tab 内容 -->
<div v-show="responseTab === 'body'" class="table-content">
@ -4318,14 +4352,14 @@
<template #action="{ row }">
<a-space :size="2">
<a-button type="text" class="row-action-btn" title="新增子节点" @click="addResponseRow(row)">
<a-button type="text" size="mini" class="row-action-btn" title="新增子节点" @click="addResponseRow(row)">
<IconPlus />
</a-button>
<a-button type="text" class="row-action-btn" title="复制行" @click="copyResponseRow(row)">
<a-button type="text" size="mini" class="row-action-btn" title="复制行" @click="copyResponseRow(row)">
<IconCopy />
</a-button>
<a-popconfirm content="确定删除该参数?" @ok="deleteResponseRow(row)">
<a-button type="text" class="row-action-btn" title="删除">
<a-button type="text" size="mini" class="row-action-btn" title="删除">
<IconDelete />
</a-button>
</a-popconfirm>
@ -4357,7 +4391,7 @@
scrollBeyondLastLine: false,
wordWrap: 'on',
}"
:height="600"
height="100%"
/>
</div>
</div>
@ -4694,7 +4728,9 @@
&.layout-vertical {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(220px, 1.15fr) minmax(180px, 1fr);
/* minmax(0, fr):允许面板被压缩,编辑器在内部滚动,避免撑破后被 overflow:hidden 裁切 */
grid-template-rows: auto minmax(0, 1.2fr) minmax(0, 1fr);
overflow: hidden;
}
}
@ -4714,6 +4750,24 @@
flex-shrink: 0;
}
.detail-settings-btn {
padding: 0 8px !important;
color: var(--color-text-2);
:deep(.arco-icon) {
font-size: 16px;
}
}
.section-icon-btn {
padding: 0 8px !important;
min-width: 32px;
:deep(.arco-icon) {
font-size: 16px;
}
}
.layout-switch,
.tree-expand-switch {
flex-shrink: 0;
@ -4840,24 +4894,6 @@
.content-type-tabs {
flex-shrink: 0;
}
.content-type-empty-tip {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -55%);
z-index: 1;
max-width: min(46%, 360px);
padding: 0 8px;
font-size: 12px;
line-height: 20px;
color: var(--color-text-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
text-align: center;
}
}
/* Apifox 式虚拟根节点(仅 Json/Xml 展示,导出剥离) */
@ -5252,8 +5288,10 @@
.request-example-content {
display: flex;
flex-direction: column;
flex: 1;
height: 100%;
min-height: 0;
overflow: hidden;
}
.request-example-container {
@ -5262,13 +5300,23 @@
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--color-border-2);
display: flex;
flex-direction: column;
:deep(.codemirror-container) {
flex: 1;
min-height: 0;
height: 100%;
}
}
.response-example-content {
display: flex;
flex-direction: column;
flex: 1;
height: 100%;
min-height: 0;
overflow: hidden;
}
.response-example-container {
@ -5277,6 +5325,14 @@
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--color-border-2);
display: flex;
flex-direction: column;
:deep(.codemirror-container) {
flex: 1;
min-height: 0;
height: 100%;
}
}
.response-return-config-content {
@ -5521,6 +5577,16 @@
:deep(.row-action-btn) {
color: var(--color-text-3) !important;
width: 24px !important;
height: 24px !important;
min-width: 24px !important;
padding: 0 !important;
line-height: 24px !important;
:deep(.arco-icon),
.arco-icon {
font-size: 14px;
}
&:hover,
&:focus {
@ -5529,6 +5595,39 @@
}
}
/* 操作列:避免按钮偏大撑出单元格滚动条(右下角黑点) */
:deep(.vxe-body--column.col--last .vxe-cell),
:deep(.vxe-header--column.col--last .vxe-cell) {
overflow: hidden !important;
scrollbar-width: none;
}
:deep(.vxe-body--column.col--last .vxe-cell::-webkit-scrollbar),
:deep(.vxe-header--column.col--last .vxe-cell::-webkit-scrollbar) {
width: 0 !important;
height: 0 !important;
display: none !important;
}
:deep(.vxe-body--column.col--dirty:before) {
display: none !important;
}
:deep(.row-clear-btn) {
padding: 0 4px !important;
color: var(--color-text-3) !important;
&:hover:not(:disabled),
&:focus:not(:disabled) {
color: #cf1322 !important;
background-color: color-mix(in srgb, #cf1322 8%, transparent) !important;
}
&:disabled {
color: var(--color-text-4) !important;
}
}
.btn-app-outline {
color: var(--app-primary-color) !important;
border-color: var(--app-primary-color) !important;
@ -5648,4 +5747,82 @@
}
}
}
.detail-settings-popover {
padding: 10px 12px !important;
.detail-settings-panel {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 220px;
}
.detail-settings-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.detail-settings-label {
font-size: 13px;
color: var(--color-text-2);
white-space: nowrap;
}
.layout-switch,
.tree-expand-switch {
flex-shrink: 0;
min-width: 58px !important;
width: auto;
height: 28px !important;
line-height: 28px !important;
border-radius: 14px !important;
padding: 0;
background-color: var(--color-fill-3) !important;
&:hover {
background-color: var(--color-fill-4) !important;
}
.arco-switch-handle {
top: 4px;
left: 4px;
width: 20px;
height: 20px;
background-color: #fff;
}
.arco-switch-text-holder {
margin: 0 10px 0 30px;
font-size: 12px;
font-weight: 400;
}
.arco-switch-text {
left: 30px;
font-size: 12px;
font-weight: 400;
color: var(--color-text-2) !important;
}
&.arco-switch-checked {
background-color: var(--color-fill-3) !important;
}
&.arco-switch-checked .arco-switch-handle {
left: calc(100% - 24px);
}
&.arco-switch-checked .arco-switch-text-holder {
margin: 0 30px 0 10px;
}
&.arco-switch-checked .arco-switch-text {
left: 10px;
color: var(--color-text-2) !important;
}
}
}
</style>

View File

@ -4672,9 +4672,14 @@ export default {
'channel.salesProducts.unsaved.discard': 'Discard',
'channel.salesProducts.unsaved.stay': 'Stay',
'channel.salesProducts.saveConfirm.title': 'Save confirmation',
'channel.salesProducts.saveConfirm.message': 'After saving, the publish preview opens. Publish only after you confirm the diff.',
'channel.salesProducts.saveConfirm.message':
'After saving, the publish preview opens. Publish only after you confirm the diff.\nIf allocation / downstream settings changed: also publish service-channel rules; existing orders need reallocation before forecast uses the new binding.',
'channel.salesProducts.saveConfirm.cancel': 'Cancel',
'channel.salesProducts.messages.saveDraftSuccess': 'Draft saved (not published)',
'channel.salesProducts.messages.downstreamSyncTip':
'Tip: allocation/downstream drafts take effect only after rules publish. If channel downstream changed, publish service-channel rules too; reallocate existing orders.',
'channel.salesProducts.publish.downstreamSyncTip':
'After rules publish, new orders use the new snapshot. If downstream changed, ensure related service channels are published; existing orders need reallocation to refresh Downstream fields.',
'channel.salesProducts.messages.saveFailed': 'Save failed',
'channel.salesProducts.messages.toggleDisableTitle': 'Confirm disable',
'channel.salesProducts.messages.toggleDisableConfirm': 'This product will not be available for new orders. Disable "{name}"?',
@ -5059,11 +5064,23 @@ export default {
'channel.serviceChannels.context.app': 'Application',
'channel.serviceChannels.context.apiProvider': 'API provider',
'channel.serviceChannels.context.unsaved': 'Unsaved',
'channel.serviceChannels.context.rulesPublishPending': 'Rules pending publish',
'channel.serviceChannels.saveConfirm.title': 'Save confirmation',
'channel.serviceChannels.saveConfirm.message': 'Save only keeps a draft; save & publish pushes channel rules to the live cache.',
'channel.serviceChannels.saveConfirm.message':
'Save only keeps a draft; save & publish pushes channel rules to the live cache.\nIf downstream credentials are affected: also publish related sales-product rules; existing orders need reallocation before forecast uses the new config.',
'channel.serviceChannels.saveConfirm.saveOnly': 'Save only',
'channel.serviceChannels.saveConfirm.saveAndPublish': 'Save & publish',
'channel.serviceChannels.saveConfirm.cancel': 'Cancel',
'channel.serviceChannels.messages.downstreamSyncTip':
'Tip: channel rules must be published to affect forecast credentials. Also publish related sales-product rules; reallocate existing orders.',
'channel.serviceChannels.editProvider.downstreamConfirmTitle': 'Downstream settings changed',
'channel.serviceChannels.editProvider.downstreamConfirmOk': 'Confirm save',
'channel.serviceChannels.editProvider.downstreamConfirmContent':
'App or API provider was changed. After save, please: ① publish rules for each channel under this group; ② publish related sales-product rules; ③ reallocate existing orders. Otherwise forecast may still use old snapshots.',
'channel.serviceChannels.editProvider.downstreamSavedTip':
'Provider downstream settings saved. Next: publish service-channel rules under this group → publish related sales-product rules → reallocate existing orders.',
'channel.serviceChannels.publish.downstreamSyncTip':
'Channel rules publish freezes AppKey/AppSecret/supplierCode. Also publish related sales-product rules; existing orders need reallocation to refresh Downstream bindings.',
'channel.serviceChannels.unsaved.title': 'Unsaved changes',
'channel.serviceChannels.unsaved.message': 'Channel config has unsaved changes. Switching will discard them. Continue?',
'channel.serviceChannels.unsaved.discard': 'Discard',

View File

@ -4720,9 +4720,14 @@ export default {
'channel.salesProducts.unsaved.discard': '放弃修改',
'channel.salesProducts.unsaved.stay': '留在此页',
'channel.salesProducts.saveConfirm.title': '保存确认',
'channel.salesProducts.saveConfirm.message': '保存后将打开发布预览,确认差异后才会发布到线上缓存。',
'channel.salesProducts.saveConfirm.message':
'保存后将打开发布预览,确认差异后才会发布到线上缓存。\n若排货涉及服务渠道下游对接变更:请同步在「服务渠道」发布规则;已有订单需重排货后,预报才会用上新配置。',
'channel.salesProducts.saveConfirm.cancel': '取消',
'channel.salesProducts.messages.saveDraftSuccess': '草稿已保存(未发布)',
'channel.salesProducts.messages.downstreamSyncTip':
'提醒:排货/下游相关草稿需「规则发布」后生效;若改了服务渠道对接,请同步发布服务渠道规则,存量订单请重排货。',
'channel.salesProducts.publish.downstreamSyncTip':
'规则发布后,新单按新快照排货。若改了下游对接,请确认对应服务渠道也已发布规则;存量订单需重排货才会刷新 Downstream 绑定。',
'channel.salesProducts.messages.saveFailed': '保存失败',
'channel.salesProducts.messages.toggleDisableTitle': '确认禁用',
'channel.salesProducts.messages.toggleDisableConfirm': '禁用后该产品将无法用于新订单,是否确认禁用「{name}」?',
@ -5107,11 +5112,23 @@ export default {
'channel.serviceChannels.context.app': '应用',
'channel.serviceChannels.context.apiProvider': 'API 服务商',
'channel.serviceChannels.context.unsaved': '未保存',
'channel.serviceChannels.context.rulesPublishPending': '待发布规则',
'channel.serviceChannels.saveConfirm.title': '保存确认',
'channel.serviceChannels.saveConfirm.message': '仅保存不会发布到线上缓存;保存并发布会立即发布渠道规则。',
'channel.serviceChannels.saveConfirm.message':
'仅保存不会发布到线上缓存;保存并发布会立即发布渠道规则。\n若变更影响下游对接:请同步在「销售产品」发布规则;已有订单需重排货后,预报才会用上新配置。',
'channel.serviceChannels.saveConfirm.saveOnly': '仅保存',
'channel.serviceChannels.saveConfirm.saveAndPublish': '保存并发布',
'channel.serviceChannels.saveConfirm.cancel': '取消',
'channel.serviceChannels.messages.downstreamSyncTip':
'提醒:渠道规则需发布后才影响预报凭证;请同步发布相关销售产品规则,存量订单请重排货。',
'channel.serviceChannels.editProvider.downstreamConfirmTitle': '下游对接已变更',
'channel.serviceChannels.editProvider.downstreamConfirmOk': '确认保存',
'channel.serviceChannels.editProvider.downstreamConfirmContent':
'应用或 API 服务商已修改。保存后请按顺序:① 对本分组下各服务渠道「保存并发布规则」;② 发布相关销售产品规则;③ 存量订单重排货。否则预报可能仍用旧快照。',
'channel.serviceChannels.editProvider.downstreamSavedTip':
'服务商下游配置已保存。请尽快:发布本分组下服务渠道规则 → 发布相关销售产品规则 → 存量订单重排货。',
'channel.serviceChannels.publish.downstreamSyncTip':
'渠道规则发布会固化下游 AppKey/AppSecret/supplierCode。请同步发布相关销售产品规则;存量订单需重排货后才会刷新订单上的下游绑定。',
'channel.serviceChannels.unsaved.title': '未保存的更改',
'channel.serviceChannels.unsaved.message': '渠道配置有未保存的修改,切换后将丢失。是否继续?',
'channel.serviceChannels.unsaved.discard': '放弃修改',

View File

@ -295,6 +295,7 @@ export const useRbacUserStore = defineStore('rbacUser', () => {
}
// 登录(OMS 管理端默认 type=User)
// 失败不弹全局 Message,由登录页内联 errorBox 展示
async function login(account: string, password: string, loginType: OmsLoginType = OmsLoginType.User) {
try {
const result = await AuthService.postRbacAuthLogin({
@ -307,8 +308,7 @@ export const useRbacUserStore = defineStore('rbacUser', () => {
enableKeepAlive()
await hydrateUserAfterLogin(result)
return true
} catch (error) {
handleApiError(error)
} catch {
return false
}
}

View File

@ -19,7 +19,7 @@
validateInterfaceSavePayload,
} from '@/utils/interfaceParameterModel';
const emit = defineEmits(['close', 'saved']);
const emit = defineEmits(['close', 'saved', 'return-config-saved']);
const { t } = useI18n();
@ -378,8 +378,8 @@
}
// 保存函数
async function saveInterface() {
// 保存函数;skipSuccessToast 用于父级自行弹出「是否去发布」确认框时避免重复提示
async function saveInterface(options?: { skipSuccessToast?: boolean }) {
try {
if (!interfaceDetailRef.value) {
notifySaveError('接口明细组件未初始化');
@ -436,7 +436,9 @@
}
await postInterfaceSaveInterface(saveData);
$baseMessage('保存成功,如需生效请去发布', 'success');
if (!options?.skipSuccessToast) {
$baseMessage('保存成功,如需生效请去发布', 'success');
}
interfaceDetailRef.value?.captureDirtyBaseline?.();
emit('saved');
emit('close');
@ -570,9 +572,10 @@
:interface-id="tableForm.id"
:service-provider-id="tableForm.serviceProviderId"
@update:basic-form-data="(val) => Object.assign(tableForm, val)"
@return-config-saved="emit('return-config-saved')"
>
<template #headerActions>
<a-button size="small" @click="openSmartFillModal">智能填充</a-button>
<a-button size="small" title="智能填充" @click="openSmartFillModal">AI</a-button>
<slot name="headerActions" />
</template>
</InterfaceDetail>

View File

@ -104,11 +104,11 @@
return listData.value;
}
async function addMapClick() {
handlerCode();
async function addMapClick(options?: { skipSuccessToast?: boolean }) {
return await handlerCode(options);
}
async function handlerCode() {
async function handlerCode(options?: { skipSuccessToast?: boolean }) {
try {
const actionRows = getActionRows();
@ -147,8 +147,13 @@
items: tableData,
});
$baseMessage('保存成功,如需生效请去发布', 'success');
} catch {}
if (!options?.skipSuccessToast) {
$baseMessage('保存成功,如需生效请去发布', 'success');
}
return true;
} catch {
return false;
}
}
async function addClick() {
@ -258,8 +263,8 @@
return valueList.value.find((item) => item.key === key)?.value ?? key;
}
async function save() {
return await addMapClick();
async function save(options?: { skipSuccessToast?: boolean }) {
return await addMapClick(options);
}
function hasUnsavedChanges() {

View File

@ -1366,7 +1366,7 @@
interfaceModalVisible.value = false;
}
// 接口保存成功(提示由子组件统一发出,这里只关弹窗并刷新)
// 接口保存成功(关弹窗、刷新,并引导发布)
function handleInterfaceSaved() {
interfaceModalVisible.value = false;
loadInterfaceList();
@ -1384,13 +1384,14 @@
});
});
}
askGoPublish(selectedProvider.value);
}
// ModalPro确定按钮点击
async function handleInterfaceSave() {
if (interfaceModalRef.value) {
try {
await interfaceModalRef.value.saveInterface();
await interfaceModalRef.value.saveInterface({ skipSuccessToast: true });
return true; // 返回 true 表示成功,允许弹窗关闭
} catch (error) {
return false; // 返回 false 表示失败,阻止弹窗关闭
@ -1557,9 +1558,9 @@
async function handleInterfaceDetailSave() {
if (!interfaceDetailModalRef.value) return;
// 成功提示 / 列表刷新由 saveInterface → @saved 统一处理,避免连弹 2~3 次
// 发布确认已含「保存成功」;刷新 / 引导发布由 saveInterface → @saved 统一处理
try {
await interfaceDetailModalRef.value.saveInterface();
await interfaceDetailModalRef.value.saveInterface({ skipSuccessToast: true });
} catch {
/* 子组件已提示错误 */
}
@ -1731,6 +1732,7 @@
:key="`interface-detail-${selectedInterface?.id}-${detailRefreshKey}`"
ref="interfaceDetailModalRef"
@saved="handleInterfaceSaved"
@return-config-saved="askGoPublish"
>
<template #headerActions>
<a-button
@ -1738,9 +1740,13 @@
title="将当前 cURL 带入 API 调试"
@click="handleDebugClick"
>
调试
发送
</a-button>
<a-button size="small" type="primary" @click="handleInterfaceDetailSave">
<a-button
size="small"
class="app-btn-primary-soft"
@click="handleInterfaceDetailSave"
>
{{ t('system.serviceProviders.detail.actions.save') }}
</a-button>
</template>
@ -1799,6 +1805,7 @@
ref="interfaceModalRef"
@close="handleInterfaceModalClose"
@saved="handleInterfaceSaved"
@return-config-saved="askGoPublish"
/>
</ModalPro>
<ParameterConfig

View File

@ -212,8 +212,8 @@
}
}
// 保存函数
async function saveInterface() {
// 保存函数;skipSuccessToast 用于父级自行弹出「是否去发布」确认框时避免重复提示
async function saveInterface(options?: { skipSuccessToast?: boolean }) {
try {
if (!interfaceDetailRef.value) {
notifySaveError('接口明细组件未初始化');
@ -259,7 +259,9 @@
}
await postInterfaceSaveInterface(saveData as any);
$baseMessage('保存成功,如需生效请去发布', 'success');
if (!options?.skipSuccessToast) {
$baseMessage('保存成功,如需生效请去发布', 'success');
}
interfaceDetailRef.value?.captureDirtyBaseline?.();
emit('saved');
return true;
@ -365,7 +367,7 @@
@update:basic-form-data="(val) => Object.assign(tableForm, val)"
>
<template #headerActions>
<a-button size="small" @click="openSmartFillModal">智能填充</a-button>
<a-button size="small" title="智能填充" @click="openSmartFillModal">AI</a-button>
<slot name="headerActions" />
</template>
</InterfaceDetail>

View File

@ -986,6 +986,22 @@ import { IconClose, IconEdit, IconPlus, IconSearch, IconMore, IconMinus, IconRef
});
}
/** 保存成功后引导打开发布弹窗(与映射明细保存一致) */
function askPublishAfterSave() {
$baseConfirm({
title: t('system.interface.modal.warningTitle'),
content: t('system.interface.modal.publishConfirm'),
onOk: async () => {
const currentRow = MapServiceProviderROW.value;
if (currentRow) {
(unref(MapServiceProviderRef) as any)?.onRelease?.(currentRow);
return;
}
$baseMessage('请先在中间选择服务商映射后再发布', 'warning');
},
});
}
// 统一的保存函数,根据当前tab调用不同的接口
async function handleTabSave() {
const currentTab = currentActiveTab.value;
@ -995,8 +1011,8 @@ import { IconClose, IconEdit, IconPlus, IconSearch, IconMore, IconMinus, IconRef
await saveModelEnter();
} else if (currentTab === 'detail') {
if (interfaceDetailModalRef.value && typeof interfaceDetailModalRef.value.saveInterface === 'function') {
// 成功提示由子组件统一发出;刷新走 @saved / 本地 init
await interfaceDetailModalRef.value.saveInterface();
// 发布确认框已含「保存成功」,跳过子组件 toast
await interfaceDetailModalRef.value.saveInterface({ skipSuccessToast: true });
if (selectedInterface.value && interfaceDetailModalRef.value) {
await nextTick();
interfaceDetailModalRef.value.init({
@ -1005,6 +1021,7 @@ import { IconClose, IconEdit, IconPlus, IconSearch, IconMore, IconMinus, IconRef
forceRefresh: true,
});
}
askPublishAfterSave();
} else {
$baseMessage('接口配置组件未初始化', 'warning');
}
@ -1339,6 +1356,7 @@ import { IconClose, IconEdit, IconPlus, IconSearch, IconMore, IconMinus, IconRef
key: `item-${item.id}`,
title: item.name,
isLeaf: true,
depth: 2,
id: item.id,
name: item.name,
code: item.code || item.name,
@ -1357,6 +1375,9 @@ import { IconClose, IconEdit, IconPlus, IconSearch, IconMore, IconMinus, IconRef
const categoryNode = {
key: `category-${categoryIndex}`,
title: category,
isLeaf: false,
depth: 1,
subtitle: `${items.length} 个接口`,
children: items
};
treeData.push(categoryNode);
@ -1518,9 +1539,13 @@ import { IconClose, IconEdit, IconPlus, IconSearch, IconMore, IconMinus, IconRef
title="将当前 cURL 带入 API 调试"
@click="handleDetailDebugClick"
>
调试
发送
</a-button>
<a-button size="small" type="primary" @click="handleTabSave">
<a-button
size="small"
class="app-btn-primary-soft"
@click="handleTabSave"
>
{{ $t('system.interface.actions.save') }}
</a-button>
</template>

View File

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { ref, reactive, computed, nextTick, onMounted, watch, onBeforeUnmount } from 'vue';
import { ref, reactive, computed, nextTick, onMounted, watch, onBeforeUnmount, h } from 'vue';
import {
createSalesProduct,
createSalesProductPrice,
@ -1603,7 +1603,12 @@
if (!configFormDirty.value) return;
Modal.confirm({
title: t('channel.salesProducts.saveConfirm.title'),
content: t('channel.salesProducts.saveConfirm.message'),
content: () =>
h(
'div',
{ style: 'white-space: pre-line; line-height: 1.6;' },
t('channel.salesProducts.saveConfirm.message'),
),
okText: t('channel.salesProducts.actions.saveAndPublish'),
cancelText: t('channel.salesProducts.saveConfirm.cancel'),
onOk: async () => {
@ -1631,6 +1636,10 @@
try {
await persistSalesProductAllocationDraft(pid);
Message.success(t('channel.salesProducts.messages.saveDraftSuccess'));
Message.warning({
content: t('channel.salesProducts.messages.downstreamSyncTip'),
duration: 8000,
});
} catch (e: any) {
Message.error(e?.message || t('channel.salesProducts.messages.saveFailed'));
} finally {
@ -1647,7 +1656,12 @@
if (!rulesFormDirty.value) return;
Modal.confirm({
title: t('channel.salesProducts.saveConfirm.title'),
content: t('channel.salesProducts.saveConfirm.message'),
content: () =>
h(
'div',
{ style: 'white-space: pre-line; line-height: 1.6;' },
t('channel.salesProducts.saveConfirm.message'),
),
okText: t('channel.salesProducts.actions.saveAndPublish'),
cancelText: t('channel.salesProducts.saveConfirm.cancel'),
onOk: async () => {
@ -1829,6 +1843,12 @@
operatorType === 'price' ? pricePublishAiReport.value.trim() || undefined : undefined,
});
Message.success(t('channel.salesProducts.publish.success'));
if (operatorType === 'rules') {
Message.warning({
content: t('channel.salesProducts.publish.downstreamSyncTip'),
duration: 8000,
});
}
publishConfirmVisible.value = false;
publishDescription.value = '';
publishConfirmDate.value = dayjs();
@ -5185,6 +5205,14 @@
@cancel="handleCancelPublishConfirm"
@ok="handleConfirmPublish"
>
<a-alert
v-if="publishOperatorType === 'rules'"
type="warning"
:show-icon="true"
style="margin-bottom: 12px"
>
{{ t('channel.salesProducts.publish.downstreamSyncTip') }}
</a-alert>
<div class="mb-15px">{{ t('channel.salesProducts.publish.effectiveTimeHint') }}</div>
<div class="mb-15px">
<a-input

View File

@ -97,7 +97,7 @@
type="primary"
size="small"
:loading="savingConfig"
:disabled="!configFormDirty"
:disabled="!canSaveAndPublish"
@click="handleSaveAndPublish"
>
{{ t('channel.serviceChannels.actions.saveAndPublish') }}
@ -131,6 +131,14 @@
<a-tag v-if="configFormDirty && activeTab === 'config'" color="orangered" size="small" class="context-dirty-tag">
{{ t('channel.serviceChannels.context.unsaved') }}
</a-tag>
<a-tag
v-else-if="selectedChannelNeedsRulesPublish && activeTab === 'config'"
color="orangered"
size="small"
class="context-dirty-tag"
>
{{ t('channel.serviceChannels.context.rulesPublishPending') }}
</a-tag>
</div>
<div v-if="!selectedChannel" class="empty-state-panel">
@ -940,6 +948,14 @@
@cancel="handleCancelPublishConfirm"
@ok="handleConfirmPublish"
>
<a-alert
v-if="publishOperatorType === 'rules'"
type="warning"
:show-icon="true"
style="margin-bottom: 12px"
>
{{ t('channel.serviceChannels.publish.downstreamSyncTip') }}
</a-alert>
<div class="mb-15px">请选择生效的日期时间,不选默认立即生效</div>
<div class="mb-15px">
<a-input
@ -1367,7 +1383,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, nextTick, onMounted } from 'vue'
import { ref, reactive, computed, watch, nextTick, onMounted, h } from 'vue'
import { useI18n } from 'vue-i18n'
import { IconPlus, IconDelete, IconSearch, IconEdit, IconDragArrow, IconQuestionCircleFill, IconQuestionCircle, IconFolder, IconMore } from '@arco-design/web-vue/es/icon'
import { Message, Modal, Input } from '@arco-design/web-vue'
@ -2073,6 +2089,79 @@ const configFormDirty = computed(() => {
)
})
/** 编辑服务商后,该分组下渠道需重新发布规则快照(按分组 Id) */
const pendingRulesPublishGroupIds = ref<Set<number>>(new Set())
/** 本轮待发布中,已完成规则发布的渠道(同组其他渠道仍可点) */
const rulesPublishDoneChannelIds = ref<Set<number>>(new Set())
function getSelectedChannelGroupId(): number {
const ch = selectedChannel.value as any
if (!ch) return 0
const fromChannel = Number(ch.groupId ?? ch.GroupId ?? 0)
if (Number.isFinite(fromChannel) && fromChannel > 0) return fromChannel
for (const g of channelGroups.value) {
if ((g.channels || []).some((c: any) => Number(c.id) === Number(ch.id))) {
return Number((g as any).id) || 0
}
}
return 0
}
const selectedChannelNeedsRulesPublish = computed(() => {
const ch = selectedChannel.value as any
const channelId = Number(ch?.id)
const groupId = getSelectedChannelGroupId()
if (!(groupId > 0) || !pendingRulesPublishGroupIds.value.has(groupId)) return false
if (Number.isFinite(channelId) && channelId > 0 && rulesPublishDoneChannelIds.value.has(channelId)) {
return false
}
return true
})
const canSaveAndPublish = computed(
() => configFormDirty.value || selectedChannelNeedsRulesPublish.value,
)
function markGroupRulesPublishPending(groupId: number | string | null | undefined) {
const id = Number(groupId)
if (!Number.isFinite(id) || id <= 0) return
const nextGroups = new Set(pendingRulesPublishGroupIds.value)
nextGroups.add(id)
pendingRulesPublishGroupIds.value = nextGroups
// 该分组重新进入待发布:清掉同组已发布标记
const group = channelGroups.value.find((g) => Number((g as any).id) === id)
const channelIds = (group?.channels || [])
.map((c: any) => Number(c?.id))
.filter((cid: number) => Number.isFinite(cid) && cid > 0)
if (!channelIds.length) return
const nextDone = new Set(rulesPublishDoneChannelIds.value)
channelIds.forEach((cid: number) => nextDone.delete(cid))
rulesPublishDoneChannelIds.value = nextDone
}
function markSelectedChannelRulesPublishDone() {
const ch = selectedChannel.value as any
const channelId = Number(ch?.id)
if (!Number.isFinite(channelId) || channelId <= 0) return
if (rulesPublishDoneChannelIds.value.has(channelId)) return
const next = new Set(rulesPublishDoneChannelIds.value)
next.add(channelId)
rulesPublishDoneChannelIds.value = next
// 同组渠道都发完后,去掉分组待发布标记
const groupId = getSelectedChannelGroupId()
const group = channelGroups.value.find((g) => Number((g as any).id) === groupId)
const channelIds = (group?.channels || [])
.map((c: any) => Number(c?.id))
.filter((cid: number) => Number.isFinite(cid) && cid > 0)
if (channelIds.length && channelIds.every((cid: number) => rulesPublishDoneChannelIds.value.has(cid))) {
const nextGroups = new Set(pendingRulesPublishGroupIds.value)
nextGroups.delete(groupId)
pendingRulesPublishGroupIds.value = nextGroups
}
}
const selectedChannelContext = computed(() => {
const ch = selectedChannel.value as any
if (!ch?.id) return null
@ -2189,6 +2278,35 @@ const editProviderForm = reactive({
appId: undefined as number | undefined,
serviceProviderId: undefined as number | undefined
})
const editProviderOriginal = reactive({
appId: undefined as number | undefined,
serviceProviderId: undefined as number | undefined,
})
function isEditProviderDownstreamChanged() {
return (
Number(editProviderForm.appId) !== Number(editProviderOriginal.appId) ||
Number(editProviderForm.serviceProviderId) !== Number(editProviderOriginal.serviceProviderId)
)
}
function confirmEditProviderDownstreamChange(): Promise<boolean> {
return new Promise((resolve) => {
Modal.confirm({
title: t('channel.serviceChannels.editProvider.downstreamConfirmTitle'),
content: () =>
h(
'div',
{ style: 'white-space: pre-line; line-height: 1.6;' },
t('channel.serviceChannels.editProvider.downstreamConfirmContent'),
),
okText: t('channel.serviceChannels.editProvider.downstreamConfirmOk'),
cancelText: t('channel.serviceChannels.saveConfirm.cancel'),
onOk: () => resolve(true),
onCancel: () => resolve(false),
})
})
}
// 取消编辑服务商
const handleCancelEditProvider = () => {
@ -3324,6 +3442,8 @@ const handleEditGroup = (group: any) => {
editProviderForm.appId = (group as any).appId
editProviderForm.serviceProviderId =
(group as any).apiServiceProviderId ?? (group as any).serviceProviderId
editProviderOriginal.appId = editProviderForm.appId
editProviderOriginal.serviceProviderId = editProviderForm.serviceProviderId
editProviderModalVisible.value = true
}
@ -3496,6 +3616,13 @@ async function executeChannelConfigSave(
}
await loadChannelGroups()
Message.success(t('channel.serviceChannels.messages.saveAndPublishSuccess'))
if (options.publishAfter === 'rules') {
markSelectedChannelRulesPublishDone()
Message.warning({
content: t('channel.serviceChannels.messages.downstreamSyncTip'),
duration: 8000,
})
}
} catch (e: any) {
Message.error(
t('channel.serviceChannels.messages.publishFailedAfterSave', {
@ -3506,6 +3633,10 @@ async function executeChannelConfigSave(
}
} else {
Message.success(successMessage)
Message.warning({
content: t('channel.serviceChannels.messages.downstreamSyncTip'),
duration: 8000,
})
}
return true
} catch (e: any) {
@ -3525,21 +3656,66 @@ const handleSaveDraft = async () => {
await executeChannelConfigSave(t('channel.serviceChannels.messages.saveDraftSuccess'))
}
/** 仅因服务商下游变更触发:不改渠道表单,直接发布规则快照 */
async function publishChannelRulesAfterProviderChange() {
if (!selectedChannel.value || !(selectedChannel.value as any).id) {
Message.warning('请先选择渠道')
return false
}
const savedId = (selectedChannel.value as any).id
savingConfig.value = true
try {
await publishServiceChannel({
serviceChannelId: savedId,
operatorType: 'rules',
description: '服务商下游配置变更后发布渠道规则',
})
markSelectedChannelRulesPublishDone()
await loadServiceChannelPublishRecords('rules')
await loadChannelGroups()
Message.success(t('channel.serviceChannels.messages.saveAndPublishSuccess'))
Message.warning({
content: t('channel.serviceChannels.messages.downstreamSyncTip'),
duration: 8000,
})
return true
} catch (e: any) {
Message.error(
t('channel.serviceChannels.messages.publishFailedAfterSave', {
msg: e?.message || '未知错误',
}),
)
return false
} finally {
savingConfig.value = false
}
}
const handleSaveAndPublish = async () => {
if (!selectedChannel.value || !(selectedChannel.value as any).id) {
Message.warning('请先选择渠道')
return
}
if (!configFormDirty.value) return
if (!canSaveAndPublish.value) return
const formDirty = configFormDirty.value
Modal.confirm({
title: t('channel.serviceChannels.saveConfirm.title'),
content: t('channel.serviceChannels.saveConfirm.message'),
content: () =>
h(
'div',
{ style: 'white-space: pre-line; line-height: 1.6;' },
t('channel.serviceChannels.saveConfirm.message'),
),
okText: t('channel.serviceChannels.saveConfirm.saveAndPublish'),
cancelText: t('channel.serviceChannels.saveConfirm.cancel'),
onOk: async () => {
await executeChannelConfigSave(t('channel.serviceChannels.messages.saveAndPublishSuccess'), {
publishAfter: 'rules',
})
if (formDirty) {
await executeChannelConfigSave(t('channel.serviceChannels.messages.saveAndPublishSuccess'), {
publishAfter: 'rules',
})
} else {
await publishChannelRulesAfterProviderChange()
}
},
})
}
@ -4012,16 +4188,24 @@ const handleConfirmAddProvider = async () => {
const handleConfirmEditProvider = async () => {
if (!editProviderForm.providerName || !editProviderForm.appId || !editProviderForm.serviceProviderId) {
Message.warning('请填写完整信息')
return
return Promise.reject()
}
const downstreamChanged = isEditProviderDownstreamChanged()
if (downstreamChanged) {
const confirmed = await confirmEditProviderDownstreamChange()
if (!confirmed) {
return Promise.reject()
}
}
try {
const editedGroupId = Number(editingProviderGroupId.value)
await updateServiceChannelGroup({
id: Number(editingProviderGroupId.value),
id: editedGroupId,
name: editProviderForm.providerName,
appId: Number(editProviderForm.appId),
apiServiceProviderId: Number(editProviderForm.serviceProviderId),
})
const group = channelGroups.value.find(g => Number((g as any).id) === editingProviderGroupId.value)
const group = channelGroups.value.find(g => Number((g as any).id) === editedGroupId)
if (group) {
;(group as any).name = editProviderForm.providerName
;(group as any).appId = editProviderForm.appId
@ -4032,9 +4216,26 @@ const handleConfirmEditProvider = async () => {
editingProviderGroupId.value = null
await loadServiceProviderList()
await loadChannelGroups()
// 服务商分组一保存,该分组下渠道即可「保存并发布」以刷新规则快照
markGroupRulesPublishPending(editedGroupId)
if (activeTab.value !== 'config') {
activeTab.value = 'config'
}
if (downstreamChanged) {
Message.warning({
content: t('channel.serviceChannels.editProvider.downstreamSavedTip'),
duration: 8000,
})
} else {
Message.info({
content: t('channel.serviceChannels.context.rulesPublishPending'),
duration: 5000,
})
}
} catch (error) {
console.error('编辑服务商失败:', error)
Message.error('编辑失败')
return Promise.reject(error)
}
}
@ -4261,6 +4462,13 @@ const handleConfirmPublish = async () => {
description: publishDescription.value || undefined,
})
Message.success('发布成功')
if (operatorType === 'rules') {
markSelectedChannelRulesPublishDone()
Message.warning({
content: t('channel.serviceChannels.publish.downstreamSyncTip'),
duration: 8000,
})
}
publishConfirmVisible.value = false
publishDescription.value = ''
publishConfirmDate.value = null

View File

@ -390,10 +390,10 @@ async function handleSubmit() {
});
} else {
errorBox.value = t('login.form.login.failed');
Message.error(t('login.form.login.failed'));
loginCharacters.value?.onLoginFailed();
}
} catch {
errorBox.value = t('login.form.login.failed');
loginCharacters.value?.onLoginFailed();
} finally {
loading.value = false;