front/src/components/CustomLeftMenu/index.vue
zzh 9922d20141
All checks were successful
Build and Deploy Front / deploy (push) Successful in 6m0s
feat: 默认页面背景联动加载页,官网入口与线路面板体验优化
页面背景默认改为默认灰并同步启动屏;顶栏/登录头可回官网;线路卡片与话题面板顾问入口改为图标。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 15:08:57 +08:00

1532 lines
40 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts" setup>
import { ref, computed, watch, useSlots } from 'vue';
import {
IconSearch,
IconDelete,
IconLeft,
IconRight,
IconRefresh,
IconPlus,
} from '@arco-design/web-vue/es/icon';
import type { CustomLeftMenuTreeNode } from './types';
import {
buildPaginatedTree,
collectPaginateEntries,
getPaginationMeta,
} from './pagination';
const props = withDefaults(
defineProps<{
treeData: CustomLeftMenuTreeNode[];
selectedKeys?: string[];
expandedKeys?: string[];
loading?: boolean;
searchKeyword?: string;
searchPlaceholder?: string;
showSearch?: boolean;
showRefresh?: boolean;
showAdd?: boolean;
refreshTitle?: string;
addTitle?: string;
loadingText?: string;
showNodeIndex?: boolean;
showMethod?: boolean;
methodLabel?: string;
showMapCount?: boolean;
showNodeActions?: boolean;
/** 非叶子节点(如分组)是否显示操作下拉 */
showBranchNodeActions?: boolean;
showNodeCode?: boolean;
actionsTitle?: string;
deleteLabel?: string;
width?: string | number;
showPagination?: boolean;
pageSize?: number;
currentPage?: number;
/** 树形层级展示(保留缩进/展开箭头,适用于组织等多级树) */
hierarchical?: boolean;
/** 列表为空时的提示文案 */
emptyText?: string;
}>(),
{
selectedKeys: () => [],
expandedKeys: () => [],
loading: false,
searchKeyword: '',
searchPlaceholder: '请输入',
showSearch: true,
showRefresh: true,
showAdd: true,
refreshTitle: '刷新列表',
addTitle: '新增',
loadingText: '正在加载...',
showNodeIndex: true,
showMethod: true,
methodLabel: 'POST',
showMapCount: true,
showNodeActions: true,
showBranchNodeActions: false,
showNodeCode: true,
actionsTitle: '操作',
deleteLabel: '删除',
width: '280px',
showPagination: true,
pageSize: 15,
currentPage: 1,
hierarchical: false,
emptyText: '暂无数据',
}
);
const emit = defineEmits<{
(e: 'update:selectedKeys', value: string[]): void;
(e: 'update:expandedKeys', value: string[]): void;
(e: 'update:searchKeyword', value: string): void;
(e: 'update:currentPage', value: number): void;
(e: 'search', keyword: string): void;
(e: 'refresh'): void;
(e: 'add'): void;
(e: 'select', keys: string[], info: any): void;
(e: 'expand', keys: string[]): void;
(e: 'actionSelect', value: string, option: any): void;
(e: 'deleteNode', nodeData: any): void;
(e: 'nodeClick', key: string, nodeData: any): void;
(e: 'pageChange', page: number): void;
}>();
const slots = useSlots();
const innerSelectedKeys = ref<string[]>(props.selectedKeys?.length ? [...props.selectedKeys] : []);
const innerExpandedKeys = ref<string[]>(props.expandedKeys?.length ? [...props.expandedKeys] : []);
const innerSearchKeyword = ref(props.searchKeyword ?? '');
const innerCurrentPage = ref(props.currentPage ?? 1);
const paginateEntries = computed(() => collectPaginateEntries(props.treeData));
const paginationMeta = computed(() => getPaginationMeta(
paginateEntries.value.length,
props.pageSize,
innerCurrentPage.value,
));
/** key → node,避免模板内反复树遍历 */
const nodeByKey = computed(() => {
const map = new Map<string, CustomLeftMenuTreeNode>();
const walk = (nodes: CustomLeftMenuTreeNode[]) => {
for (const node of nodes) {
map.set(node.key, node);
if (node.children?.length) walk(node.children);
}
};
walk(props.treeData || []);
return map;
});
const paginatedTreeData = computed(() => {
if (!props.showPagination) return props.treeData;
const { start, end } = paginationMeta.value;
const slice = paginateEntries.value.slice(start, end);
return buildPaginatedTree(slice);
});
const isTreeEmpty = computed(
() => !props.loading && (!paginatedTreeData.value || paginatedTreeData.value.length === 0),
);
const visiblePageNumbers = computed(() => {
const { totalPages, currentPage } = paginationMeta.value;
if (totalPages <= 5) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const pages = new Set<number>([1, totalPages, currentPage]);
if (currentPage > 1) pages.add(currentPage - 1);
if (currentPage < totalPages) pages.add(currentPage + 1);
return Array.from(pages).sort((a, b) => a - b);
});
const currentPageModel = computed({
get: () => paginationMeta.value.currentPage,
set: (page: number) => {
setCurrentPage(page);
},
});
function setCurrentPage(page: number) {
const nextPage = Math.min(
Math.max(1, page),
paginationMeta.value.totalPages,
);
innerCurrentPage.value = nextPage;
emit('update:currentPage', nextPage);
emit('pageChange', nextPage);
}
function goPrevPage() {
setCurrentPage(currentPageModel.value - 1);
}
function goNextPage() {
setCurrentPage(currentPageModel.value + 1);
}
const selectedKeysModel = computed({
get: () => (props.selectedKeys && props.selectedKeys.length >= 0 ? props.selectedKeys : innerSelectedKeys.value),
set: (v: string[]) => {
innerSelectedKeys.value = v;
emit('update:selectedKeys', v);
},
});
const expandedKeysModel = computed({
get: () => (props.expandedKeys && props.expandedKeys.length >= 0 ? props.expandedKeys : innerExpandedKeys.value),
set: (v: string[]) => {
innerExpandedKeys.value = v;
emit('update:expandedKeys', v);
},
});
const searchKeywordModel = computed({
get: () => props.searchKeyword !== undefined ? props.searchKeyword : innerSearchKeyword.value,
set: (v: string) => {
innerSearchKeyword.value = v;
emit('update:searchKeyword', v);
},
});
watch(
() => props.selectedKeys,
(val) => {
if (val && val.length >= 0) innerSelectedKeys.value = [...val];
},
{ deep: true }
);
watch(
() => props.expandedKeys,
(val) => {
if (val && val.length >= 0) innerExpandedKeys.value = [...val];
},
{ deep: true }
);
watch(
() => props.searchKeyword,
(val) => {
if (val !== undefined) innerSearchKeyword.value = val;
}
);
watch(
() => props.currentPage,
(val) => {
if (val !== undefined && val !== innerCurrentPage.value) {
innerCurrentPage.value = val;
}
},
);
watch(
() => props.searchKeyword,
() => {
setCurrentPage(1);
},
);
watch(
() => props.pageSize,
() => {
setCurrentPage(1);
},
);
/** 条目数变化(搜索过滤、增删类型)时才回到第一页;节点字段更新(如 mapCount)不重置 */
watch(
() => paginateEntries.value.length,
(len, prevLen) => {
if (prevLen !== undefined && len !== prevLen) {
setCurrentPage(1);
}
},
);
watch(
() => paginationMeta.value.totalPages,
(totalPages) => {
if (innerCurrentPage.value > totalPages) {
setCurrentPage(totalPages);
}
},
);
function getNodeData(key: string): CustomLeftMenuTreeNode | null {
if (!key) return null;
return nodeByKey.value.get(key) ?? null;
}
/** 二级菜单序号:按父级分组,每组从 1 重新开始 */
function getNodeIndex(key: string): number | '' {
if (!key || !props.treeData?.length) return '';
for (const category of props.treeData) {
const children = category.children || [];
for (let i = 0; i < children.length; i++) {
if (children[i].key === key) return i + 1;
}
}
// 扁平叶子列表(无分组)时按顶层叶子序号
const topLeaves = props.treeData.filter((n) => n.isLeaf);
const idx = topLeaves.findIndex((n) => n.key === key);
return idx >= 0 ? idx + 1 : '';
}
function handleSearch() {
emit('search', searchKeywordModel.value);
}
function handleRefresh() {
emit('refresh');
}
function handleAdd() {
emit('add');
}
function handleTreeSelect(keys: string[], info: any) {
emit('select', keys, info);
}
function handleTreeExpand(keys: string[]) {
expandedKeysModel.value = keys;
emit('expand', keys);
}
function handleActionSelect(value: string, option: any) {
emit('actionSelect', value, option);
}
function handleDeleteNode(nodeData: any) {
emit('deleteNode', nodeData);
}
function selectTreeNode(key: string) {
if (!key) return;
selectedKeysModel.value = [key];
const nodeData = getNodeData(key);
emit('select', [key], { node: nodeData });
if (nodeData) emit('nodeClick', key, nodeData);
}
defineExpose({
getNodeData,
getNodeIndex,
});
</script>
<template>
<div
class="custom-left-menu"
:style="{ width: typeof width === 'number' ? `${width}px` : width }"
>
<div class="sidebar-header">
<div class="search-box">
<a-input
v-if="showSearch"
v-model="searchKeywordModel"
:placeholder="searchPlaceholder"
allow-clear
size="small"
class="search-input"
@change="handleSearch"
@clear="handleSearch"
>
<template #prefix>
<icon-search />
</template>
</a-input>
<div class="search-actions">
<a-button
v-if="showRefresh"
type="text"
size="small"
class="action-icon-btn refresh-btn"
:title="refreshTitle"
@click="handleRefresh"
>
<icon-refresh :size="18" />
</a-button>
<a-button
v-if="showAdd"
type="text"
size="small"
class="action-icon-btn add-btn"
:title="addTitle"
@click="handleAdd"
>
<icon-plus :size="18" />
</a-button>
</div>
</div>
</div>
<div
class="interface-tree"
:class="{ 'interface-tree--hierarchical': hierarchical }"
>
<div v-if="loading" class="loading-state">
<a-spin />
<p>{{ loadingText }}</p>
</div>
<div v-else-if="isTreeEmpty" class="empty-state">
<p>{{ emptyText }}</p>
</div>
<a-tree
v-else
:data="paginatedTreeData"
:selected-keys="selectedKeysModel"
:expanded-keys="expandedKeysModel"
show-line
block-node
@select="handleTreeSelect"
@expand="handleTreeExpand"
>
<template #title="{ key, title }">
<div
class="tree-node"
:class="{
selected: selectedKeysModel.includes(key),
'tree-node--hierarchical': hierarchical,
'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 || (getNodeData(key)?.isLeaf ? 2 : 1)}`]: true,
}"
@click="hierarchical && selectTreeNode(key)"
>
<table class="node-table">
<tbody>
<tr>
<td class="node-content-cell">
<div class="node-main-row">
<span v-if="getNodeData(key)?.isLeaf && showNodeIndex && !hierarchical" class="node-number">{{ getNodeIndex(key) }}</span>
<span
v-if="getNodeData(key)?.isLeaf && showMethod"
class="post-method"
:class="`post-method--${String(getNodeData(key)?.method || methodLabel || 'POST').toLowerCase()}`"
>{{ getNodeData(key)?.method || methodLabel }}</span>
<span class="node-title" :title="title">{{ title }}</span>
<span v-if="getNodeData(key)?.isLeaf && showMapCount" class="map-count">
{{ getNodeData(key)?.mapCount ?? 0 }}
</span>
</div>
</td>
<td
v-if="getNodeData(key)?.isLeaf && showNodeActions"
class="node-actions-cell"
>
<a-dropdown
trigger="click"
:popup-max-height="false"
@select="handleActionSelect"
>
<a-button
type="text"
size="small"
class="action-btn vertical-dots"
:title="actionsTitle"
@click.stop
>
<div class="vertical-dots-icon">
<span></span>
<span></span>
<span></span>
</div>
</a-button>
<template #content>
<slot name="node-menu" :node-data="getNodeData(key)" :node-key="key">
<a-doption
value="delete"
:data="getNodeData(key)"
@click="handleDeleteNode(getNodeData(key))"
>
<icon-delete />
{{ deleteLabel }}
</a-doption>
</slot>
</template>
</a-dropdown>
</td>
<td
v-else-if="showBranchNodeActions && getNodeData(key)?.isLeaf !== true"
class="node-actions-cell branch-node-actions-cell"
>
<div class="branch-node-actions-row">
<span
v-if="slots['branch-node-inline-before']"
class="branch-node-inline-before"
@click.stop
>
<slot name="branch-node-inline-before" :node-data="getNodeData(key)" :node-key="key"></slot>
</span>
<a-dropdown
trigger="click"
:popup-max-height="false"
@select="handleActionSelect"
>
<a-button
type="text"
size="small"
class="action-btn vertical-dots"
:title="actionsTitle"
@click.stop
>
<div class="vertical-dots-icon">
<span></span>
<span></span>
<span></span>
</div>
</a-button>
<template #content>
<slot name="branch-node-menu" :node-data="getNodeData(key)" :node-key="key"></slot>
</template>
</a-dropdown>
</div>
</td>
<td
v-else-if="getNodeData(key)?.isLeaf !== true && slots['branch-node-extra']"
class="node-actions-cell branch-node-extra-cell"
>
<span class="branch-node-extra" @click.stop>
<slot name="branch-node-extra" :node-data="getNodeData(key)" :node-key="key"></slot>
</span>
</td>
</tr>
<!-- 第二行始终占位,保证单行/双行卡片高度一致(以两行为准) -->
<tr class="node-second-row">
<template
v-if="
(hierarchical || getNodeData(key)?.isLeaf !== true) &&
(getNodeData(key)?.subtitle || $slots['branch-node-subtitle'])
"
>
<td colspan="2" class="node-subtitle-cell">
<slot name="branch-node-subtitle" :node-data="getNodeData(key)" :node-key="key">
<span v-if="getNodeData(key)?.subtitle" class="node-subtitle">
{{ getNodeData(key)?.subtitle }}
</span>
</slot>
</td>
</template>
<template
v-else-if="
getNodeData(key)?.isLeaf &&
((showNodeCode && getNodeData(key)?.code) || !!$slots['node-second-row-extra'])
"
>
<td v-if="showNodeCode && getNodeData(key)?.code" class="node-code-cell">
<span class="interface-code">{{ getNodeData(key)?.code }}</span>
</td>
<td
:class="['node-actions-cell', 'node-second-row-extra-cell']"
:colspan="(showNodeCode && getNodeData(key)?.code) ? 1 : 2"
>
<span class="node-second-row-extra-slot">
<slot name="node-second-row-extra" :node-data="getNodeData(key)" :node-key="key"></slot>
</span>
</td>
</template>
<template v-else>
<td colspan="2" class="node-second-row-spacer-cell" aria-hidden="true" />
</template>
</tr>
</tbody>
</table>
</div>
</template>
</a-tree>
</div>
<div
v-if="showPagination && paginationMeta.showPagination && !loading"
class="menu-pagination"
>
<div class="menu-pagination-capsule">
<button
type="button"
class="page-nav-btn"
:disabled="currentPageModel <= 1"
aria-label="上一页"
@click="goPrevPage"
>
<icon-left />
</button>
<div class="page-number-group">
<template v-for="(page, index) in visiblePageNumbers" :key="page">
<span
v-if="index > 0 && page - visiblePageNumbers[index - 1] > 1"
class="page-ellipsis"
>…</span>
<button
type="button"
class="page-number-btn"
:class="{ active: page === currentPageModel }"
@click="setCurrentPage(page)"
>
{{ page }}
</button>
</template>
</div>
<button
type="button"
class="page-nav-btn"
:disabled="currentPageModel >= paginationMeta.totalPages"
aria-label="下一页"
@click="goNextPage"
>
<icon-right />
</button>
</div>
</div>
</div>
</template>
<style scoped lang="less">
/*
* Linear 风格资源侧栏(浅色面板)
* 白底、圆角选中块、弱边框、次要信息降噪
*/
.custom-left-menu {
--clm-bg: #ffffff;
--clm-text: #1a1c20;
--clm-muted: #8a8f98;
--clm-faint: #b0b4ba;
--clm-hover: rgba(15, 16, 17, 0.04);
--clm-selected: var(--app-primary-color-light);
--clm-selected-text: var(--app-primary-color);
--clm-selected-border: color-mix(in srgb, var(--app-primary-color) 32%, transparent);
--clm-border: rgba(15, 16, 17, 0.08);
--clm-radius: 8px;
/* 列表卡片统一高度 */
--clm-node-title-line-height: 18px;
--clm-node-second-line-height: 16px;
--clm-node-padding-y: 5px;
--clm-node-height: 60px;
/* 菜单项垂直间距 */
--clm-node-gap: 8px;
position: relative;
display: flex;
flex-direction: column;
flex-shrink: 0;
min-height: 0;
height: 100%;
padding: 10px 8px;
overflow: hidden;
color: var(--clm-text);
background: var(--clm-bg);
border: 1px solid var(--clm-border);
border-radius: 12px;
box-shadow: none;
}
.sidebar-header {
padding: 2px 4px 10px;
border-bottom: 1px solid var(--clm-border);
min-height: 40px;
display: flex;
align-items: center;
flex-shrink: 0;
}
.search-box {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
}
.search-input {
flex: 1;
min-width: 0;
:deep(.arco-input-wrapper) {
background: var(--color-fill-2) !important;
border-color: transparent !important;
border-radius: 7px;
box-shadow: 0 0 0 1px var(--clm-border);
transition: box-shadow 0.15s ease, background-color 0.15s ease;
&:hover {
background: var(--color-fill-3) !important;
box-shadow: 0 0 0 1px var(--color-border-3);
}
&.arco-input-focus {
background: var(--app-card-bg) !important;
border-color: transparent !important;
box-shadow: 0 0 0 1.5px var(--app-primary-color) !important;
}
}
:deep(.arco-input) {
color: var(--clm-text) !important;
font-size: 13px;
&::placeholder {
color: var(--clm-faint);
}
}
:deep(.arco-input-prefix),
:deep(.arco-input-clear-btn) {
color: var(--clm-muted);
}
}
.search-actions {
display: flex;
gap: 2px;
flex-shrink: 0;
}
.action-icon-btn {
width: 28px;
height: 28px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--clm-muted);
border: none;
border-radius: 7px;
font-size: 14px;
font-weight: 510;
transition: background-color 0.12s ease, color 0.12s ease;
:deep(.arco-icon) {
font-size: 14px;
}
&:hover {
color: var(--clm-text);
background-color: var(--clm-hover);
}
&.add-btn,
&.refresh-btn {
color: var(--clm-muted);
:deep(.arco-icon),
:deep(svg) {
font-size: 18px !important;
width: 18px !important;
height: 18px !important;
min-width: 18px !important;
min-height: 18px !important;
}
&:hover {
color: var(--clm-text);
background-color: var(--clm-hover);
}
}
}
.interface-tree {
flex: 1;
min-height: 0;
padding: 6px 2px;
overflow-x: hidden;
overflow-y: auto;
background: transparent;
&:not(.interface-tree--hierarchical) {
:deep(.arco-tree),
:deep(.arco-tree-show-line),
:deep(.arco-tree-size-medium),
:deep(.arco-tree-node),
:deep(.arco-tree-node-content),
:deep(.arco-tree-node-content-wrapper) {
display: contents !important;
padding: 0 !important;
margin: 0 !important;
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
:deep(.arco-tree-node-title),
:deep(.arco-tree-node-title-text) {
display: block !important;
width: 100%;
padding: 0 !important;
margin: 0 !important;
line-height: 1.25;
min-height: 0;
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
:deep(.arco-tree-node-icon),
:deep(.arco-tree-node-indent),
:deep(.arco-tree-node-switcher),
:deep(.arco-icon),
:deep(.arco-tree-node-title .arco-icon),
:deep(.arco-tree-node-line),
:deep(.arco-tree-node-line-top),
:deep(.arco-tree-node-line-bottom) {
display: none !important;
}
:deep(.branch-node-extra .arco-icon) {
display: inline-block !important;
width: 14px !important;
height: 14px !important;
min-width: 14px !important;
flex: none !important;
font-size: 14px;
vertical-align: middle;
}
:deep(.arco-tree-node-wrapper),
:deep(.arco-tree-node-selected),
:deep(.arco-tree-node-selected > .arco-tree-node-title) {
display: contents !important;
padding: 0 !important;
margin: 0 !important;
background: transparent !important;
border: none !important;
}
:deep(.arco-tree-node-content) {
display: block !important;
}
:deep(.arco-tree-list),
:deep(.arco-tree-list-holder) {
display: contents !important;
padding: 0 !important;
margin: 0 !important;
}
}
&.interface-tree--hierarchical {
padding-right: 2px;
:deep(.arco-tree-node) {
align-items: flex-start;
}
:deep(.arco-tree-node-switcher) {
display: inline-flex !important;
align-items: center;
justify-content: center;
height: 28px;
color: var(--clm-muted);
margin-top: 4px;
}
:deep(.arco-tree-node-indent-block) {
display: inline-block !important;
}
:deep(.arco-tree-node-title),
:deep(.arco-tree-node-title-text),
:deep(.arco-tree-node-content),
:deep(.arco-tree-node-content-wrapper) {
flex: 1;
min-width: 0;
width: 100%;
padding: 0 !important;
background: transparent !important;
}
:deep(.arco-tree-node-title) {
cursor: pointer;
padding: 0 !important;
line-height: 1.25;
min-height: 0;
}
:deep(.arco-tree-node-title-text) {
display: block;
width: 100%;
line-height: 1.25;
min-height: 0;
}
:deep(.arco-tree-node-selected .arco-tree-node-title),
:deep(.arco-tree-node-selected .arco-tree-node-content) {
background: transparent !important;
}
:deep(.arco-tree-node-line),
:deep(.arco-tree-node-line-top),
:deep(.arco-tree-node-line-bottom) {
border-color: rgba(15, 16, 17, 0.08) !important;
}
.tree-node {
margin: calc(var(--clm-node-gap) / 2) 0;
padding: var(--clm-node-padding-y) 8px;
border-radius: var(--clm-radius);
border: 1px solid rgba(15, 16, 17, 0.06);
background: transparent;
box-sizing: border-box;
height: var(--clm-node-height);
min-height: var(--clm-node-height);
max-height: var(--clm-node-height);
overflow: hidden;
display: flex;
align-items: stretch;
&.tree-node--with-code {
min-height: var(--clm-node-height);
}
&.tree-node--branch {
height: auto;
min-height: 34px;
max-height: none;
margin: calc(var(--clm-node-gap) + 4px) 0 calc(var(--clm-node-gap) / 2);
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;
color: var(--clm-text) !important;
box-shadow: none;
.node-title {
color: var(--clm-selected-text) !important;
font-weight: 560;
}
.node-subtitle {
color: var(--clm-muted) !important;
}
&.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.tree-node--branch .node-title {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--clm-muted);
}
.tree-node--depth-2 .node-title {
font-size: 12px;
font-weight: 500;
}
.tree-node--depth-3,
.tree-node--depth-4,
.tree-node--depth-5,
.tree-node--depth-6 {
.node-title {
font-size: 11px;
font-weight: 450;
}
}
.tree-node--branch .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-muted);
font-size: 10px;
}
}
}
.interface-tree::-webkit-scrollbar {
width: 4px;
}
.interface-tree::-webkit-scrollbar-thumb {
background: rgba(15, 16, 17, 0.12);
border-radius: 4px;
}
.interface-tree::-webkit-scrollbar-track {
background: transparent;
}
.menu-pagination {
flex-shrink: 0;
padding: 8px 4px 2px;
display: flex;
justify-content: center;
border-top: 1px solid var(--clm-border);
}
.menu-pagination-capsule {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px;
border-radius: var(--clm-radius);
background: transparent;
border: none;
box-shadow: none;
}
.page-nav-btn,
.page-number-btn {
border: none;
background: transparent;
color: var(--clm-muted);
cursor: pointer;
transition: background-color 0.12s ease, color 0.12s ease;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
line-height: 1;
font-weight: 500;
}
.page-nav-btn {
width: 26px;
height: 26px;
border-radius: 6px;
&:hover:not(:disabled) {
background: var(--clm-hover);
color: var(--clm-text);
}
&:disabled {
opacity: 0.35;
cursor: not-allowed;
}
}
.page-number-group {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 0 2px;
}
.page-number-btn {
min-width: 26px;
height: 26px;
padding: 0 6px;
border-radius: 6px;
font-weight: 500;
&:hover {
background: var(--clm-hover);
color: var(--clm-text);
}
&.active {
background: var(--clm-selected);
color: var(--clm-selected-text);
font-weight: 600;
box-shadow: none;
}
}
.page-ellipsis {
min-width: 16px;
text-align: center;
color: var(--clm-faint);
font-size: 12px;
user-select: none;
}
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 120px;
color: var(--clm-muted);
p {
margin: 8px 0 0;
font-size: 12px;
font-weight: 450;
}
}
.empty-state p {
margin-top: 0;
}
.tree-node {
width: 100%;
box-sizing: border-box;
border: 1px solid rgba(15, 16, 17, 0.06);
padding: var(--clm-node-padding-y) 8px;
margin: calc(var(--clm-node-gap) / 2) 0;
border-radius: var(--clm-radius);
transition: background-color 0.12s ease, border-color 0.12s ease;
cursor: pointer;
background: transparent;
position: relative;
height: var(--clm-node-height);
min-height: var(--clm-node-height);
max-height: var(--clm-node-height);
overflow: hidden;
display: flex;
align-items: stretch;
&.tree-node--with-code {
min-height: var(--clm-node-height);
}
/* 分组头:轻量、矮一些,和可点击实体卡片拉开层级 */
&.tree-node--branch {
height: auto;
min-height: 34px;
max-height: none;
margin: calc(var(--clm-node-gap) + 4px) 0 calc(var(--clm-node-gap) / 2);
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);
align-items: center;
}
&:hover {
background: var(--clm-hover);
border-color: rgba(15, 16, 17, 0.1);
transform: none;
box-shadow: none;
.action-btn,
.vertical-dots-icon {
opacity: 1;
}
}
&.tree-node--leaf:hover {
background: var(--clm-hover);
}
}
.tree-node.selected {
background: var(--clm-selected);
border-color: var(--clm-selected-border);
box-shadow: none;
color: var(--clm-text);
.node-number {
color: var(--clm-muted);
}
.node-title {
color: var(--clm-selected-text);
font-weight: 560;
}
.interface-code {
color: var(--clm-muted);
}
.map-count {
color: var(--clm-muted);
background: rgba(15, 16, 17, 0.04);
}
.node-second-row-extra-slot {
color: var(--clm-muted) !important;
.create-time,
.update-time {
color: var(--clm-muted) !important;
}
}
.action-btn,
.vertical-dots-icon {
opacity: 1;
}
}
.node-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
height: auto;
}
.node-second-row > td {
padding-top: 2px;
}
.node-content-cell {
text-align: left;
padding: 0;
width: auto;
vertical-align: middle;
min-width: 0;
}
.node-main-row {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
width: 100%;
height: var(--clm-node-title-line-height);
min-height: var(--clm-node-title-line-height);
}
.node-code-cell {
text-align: left;
padding: 2px 0 0;
width: auto;
padding-left: 20px;
height: var(--clm-node-second-line-height);
vertical-align: middle;
overflow: hidden;
}
.node-actions-cell {
text-align: right;
padding: 0;
width: 28px;
vertical-align: middle;
}
.branch-node-actions-cell {
width: auto;
min-width: 52px;
white-space: nowrap;
.branch-node-actions-row {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 2px;
}
.branch-node-inline-before {
display: inline-flex;
align-items: center;
}
}
.branch-node-extra-cell {
width: 28px;
.branch-node-extra {
display: inline-flex;
align-items: center;
justify-content: flex-end;
:deep(.arco-btn) {
width: 22px !important;
min-width: 22px !important;
height: 22px !important;
min-height: 22px !important;
padding: 0 !important;
color: var(--clm-muted);
border-radius: 5px;
&:hover {
color: var(--clm-text);
background: var(--clm-hover);
}
}
}
}
.node-second-row-extra-cell {
text-align: right;
padding: 2px 0 0;
min-width: 50px;
width: auto;
height: var(--clm-node-second-line-height);
vertical-align: middle;
}
.node-second-row-spacer-cell {
height: var(--clm-node-second-line-height);
padding: 2px 0 0;
line-height: var(--clm-node-second-line-height);
border: none;
}
.node-second-row-extra-slot {
display: inline-flex;
align-items: center;
justify-content: flex-end;
color: var(--clm-muted);
font-size: 11px;
}
.node-title {
flex: 1;
min-width: 0;
font-size: 12px;
color: var(--clm-text);
font-weight: 510;
letter-spacing: -0.01em;
line-height: var(--clm-node-title-line-height);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.map-count {
flex-shrink: 0;
font-size: 10px;
font-weight: 500;
color: var(--clm-muted);
min-width: 16px;
height: 16px;
padding: 0 5px;
border-radius: 999px;
background: rgba(15, 16, 17, 0.05);
text-align: center;
line-height: 16px;
font-variant-numeric: tabular-nums;
}
.node-number {
color: var(--clm-faint);
font-size: 11px;
font-weight: 500;
margin-right: 0;
display: inline-block;
min-width: 14px;
text-align: center;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.post-method {
font-weight: 600;
font-size: 10px;
margin-right: 0;
display: inline-block;
padding: 0 4px;
border-radius: 3px;
border: none;
line-height: 14px;
letter-spacing: 0.02em;
flex-shrink: 0;
color: #d97706;
background: rgba(217, 119, 6, 0.12);
&--get {
color: #059669;
background: rgba(5, 150, 105, 0.12);
}
&--post {
color: #d97706;
background: rgba(217, 119, 6, 0.12);
}
&--put {
color: #2563eb;
background: rgba(37, 99, 235, 0.12);
}
&--patch {
color: #7c3aed;
background: rgba(124, 58, 237, 0.12);
}
&--delete {
color: #dc2626;
background: rgba(220, 38, 38, 0.12);
}
}
.interface-code {
color: var(--clm-muted);
font-size: 11px;
font-weight: 450;
display: inline-block;
line-height: var(--clm-node-second-line-height);
letter-spacing: -0.01em;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-subtitle-cell {
text-align: left;
padding: 1px 0 0;
height: var(--clm-node-second-line-height);
vertical-align: middle;
overflow: hidden;
}
.node-subtitle {
display: block;
font-size: 10px;
font-weight: 450;
color: var(--clm-muted);
line-height: var(--clm-node-second-line-height);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tree-node.selected .node-subtitle {
color: var(--clm-muted);
}
.action-btn {
padding: 0;
min-width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.12s ease;
border-radius: 5px;
&:hover {
background: rgba(15, 16, 17, 0.06);
}
:deep(.arco-icon) {
font-size: 12px;
}
}
.vertical-dots-icon {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
width: 6px;
height: 14px;
opacity: 0;
transition: opacity 0.12s ease;
}
.tree-node:hover .vertical-dots-icon,
.tree-node.selected .vertical-dots-icon,
.tree-node:hover .action-btn,
.tree-node.selected .action-btn {
opacity: 1;
}
.vertical-dots-icon span {
width: 3px;
height: 3px;
background-color: var(--clm-muted);
border-radius: 50%;
display: block;
}
.tree-node.selected .vertical-dots-icon span {
background-color: var(--clm-muted);
}
</style>