698 lines
18 KiB
Vue
698 lines
18 KiB
Vue
<script lang="ts" setup>
|
|
import { ref, computed, watch } from 'vue';
|
|
import { IconSearch, IconDelete } from '@arco-design/web-vue/es/icon';
|
|
import type { CustomLeftMenuTreeNode } from './types';
|
|
|
|
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;
|
|
showNodeCode?: boolean;
|
|
actionsTitle?: string;
|
|
deleteLabel?: string;
|
|
width?: string | number;
|
|
}>(),
|
|
{
|
|
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,
|
|
showNodeCode: true,
|
|
actionsTitle: '操作',
|
|
deleteLabel: '删除',
|
|
width: '280px',
|
|
}
|
|
);
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'update:selectedKeys', value: string[]): void;
|
|
(e: 'update:expandedKeys', value: string[]): void;
|
|
(e: 'update:searchKeyword', value: string): 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;
|
|
}>();
|
|
|
|
const innerSelectedKeys = ref<string[]>(props.selectedKeys?.length ? [...props.selectedKeys] : []);
|
|
const innerExpandedKeys = ref<string[]>(props.expandedKeys?.length ? [...props.expandedKeys] : []);
|
|
const innerSearchKeyword = ref(props.searchKeyword ?? '');
|
|
|
|
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;
|
|
}
|
|
);
|
|
|
|
function getNodeData(key: string): CustomLeftMenuTreeNode | null {
|
|
if (!key || !props.treeData?.length) return null;
|
|
function findNode(nodes: CustomLeftMenuTreeNode[]): CustomLeftMenuTreeNode | null {
|
|
for (const node of nodes) {
|
|
if (node.key === key) return node;
|
|
if (node.children?.length) {
|
|
const found = findNode(node.children);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
return findNode(props.treeData);
|
|
}
|
|
|
|
/** 二级菜单序号:按父级分组,每组从 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;
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
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 onNodeClick(key: string) {
|
|
const nodeData = getNodeData(key);
|
|
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"
|
|
>
|
|
↻
|
|
</a-button>
|
|
<a-button
|
|
v-if="showAdd"
|
|
type="text"
|
|
size="small"
|
|
class="action-icon-btn add-btn"
|
|
:title="addTitle"
|
|
@click="handleAdd"
|
|
>
|
|
+
|
|
</a-button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="interface-tree">
|
|
<div v-if="loading" class="loading-state">
|
|
<a-spin />
|
|
<p>{{ loadingText }}</p>
|
|
</div>
|
|
<a-tree
|
|
v-else
|
|
:data="treeData"
|
|
: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) }"
|
|
>
|
|
<table class="node-table">
|
|
<tr>
|
|
<td class="node-content-cell">
|
|
<span v-if="selectedKeysModel.includes(key)" class="selected-dot"></span>
|
|
<span v-if="getNodeData(key)?.isLeaf && showNodeIndex" class="node-number">{{ getNodeIndex(key) }}</span>
|
|
<span v-if="getNodeData(key)?.isLeaf && showMethod" class="post-method">{{ methodLabel }}</span>
|
|
<span class="node-title">{{ title }}</span>
|
|
<span v-if="getNodeData(key)?.isLeaf && showMapCount" class="map-count">
|
|
<span class="bracket">[</span>
|
|
<span class="count-num">{{ getNodeData(key)?.mapCount ?? 0 }}</span>
|
|
<span class="bracket">]</span>
|
|
</span>
|
|
<span
|
|
v-if="getNodeData(key)?.isLeaf && showMapCount && (getNodeData(key)?.mapCount ?? 0) > 0"
|
|
class="status-dot"
|
|
></span>
|
|
</td>
|
|
<td v-if="getNodeData(key)?.isLeaf && showNodeActions" class="node-actions-cell">
|
|
<a-dropdown trigger="click" @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>
|
|
</tr>
|
|
<tr v-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>
|
|
</tr>
|
|
</table>
|
|
</div>
|
|
</template>
|
|
</a-tree>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped lang="less">
|
|
.custom-left-menu {
|
|
background: var(--app-sidebar-bg);
|
|
backdrop-filter: blur(20px) saturate(180%);
|
|
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
|
border-top: 1px solid rgba(255, 255, 255, 0.25);
|
|
border-radius: 24px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex-shrink: 0;
|
|
height: 100%;
|
|
padding: 12px;
|
|
box-shadow: none;
|
|
overflow: hidden;
|
|
color: #fff;
|
|
position: relative;
|
|
}
|
|
|
|
.custom-left-menu::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: 1px;
|
|
background: linear-gradient(
|
|
90deg,
|
|
transparent 0%,
|
|
rgba(255, 255, 255, 0.3) 50%,
|
|
transparent 100%
|
|
);
|
|
pointer-events: none;
|
|
z-index: 1;
|
|
}
|
|
|
|
.sidebar-header {
|
|
padding: 10px 12px !important;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
|
min-height: 48px !important;
|
|
display: flex !important;
|
|
align-items: center !important;
|
|
color: #fff;
|
|
}
|
|
|
|
.search-box {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
width: 100%;
|
|
}
|
|
|
|
.search-input {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.search-actions {
|
|
display: flex;
|
|
gap: 4px;
|
|
}
|
|
|
|
.action-icon-btn {
|
|
width: 28px;
|
|
height: 28px;
|
|
padding: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: #8c8c8c;
|
|
border: 1px solid transparent;
|
|
border-radius: 4px;
|
|
font-size: 16px;
|
|
font-weight: bold;
|
|
|
|
&:hover {
|
|
background-color: #f0f8ff;
|
|
border-color: #d6e4ff;
|
|
}
|
|
|
|
&.add-btn {
|
|
color: var(--app-primary-color) !important;
|
|
background-color: var(--app-primary-color-light) !important;
|
|
border: 1px solid var(--app-primary-color) !important;
|
|
border-radius: 50% !important;
|
|
width: 28px !important;
|
|
height: 28px !important;
|
|
min-width: 28px !important;
|
|
max-width: 28px !important;
|
|
min-height: 28px !important;
|
|
max-height: 28px !important;
|
|
padding: 0 !important;
|
|
line-height: 28px !important;
|
|
|
|
&:hover {
|
|
color: #ffffff !important;
|
|
background-color: var(--app-primary-color) !important;
|
|
border-color: var(--app-primary-color) !important;
|
|
}
|
|
}
|
|
|
|
&.refresh-btn {
|
|
color: #8c8c8c;
|
|
&:hover {
|
|
color: #666;
|
|
}
|
|
}
|
|
}
|
|
|
|
.interface-tree {
|
|
flex: 1;
|
|
padding: 8px 0 0;
|
|
overflow-y: auto;
|
|
color: #fff;
|
|
background: transparent;
|
|
border-radius: 16px;
|
|
padding-right: 4px;
|
|
|
|
:deep(.arco-tree),
|
|
:deep(.arco-tree-show-line),
|
|
:deep(.arco-tree-size-medium),
|
|
:deep(.arco-tree-node),
|
|
:deep(.arco-tree-node-title),
|
|
:deep(.arco-tree-node-title-text),
|
|
: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-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(.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::-webkit-scrollbar {
|
|
width: 6px;
|
|
}
|
|
|
|
.interface-tree::-webkit-scrollbar-track {
|
|
background: rgba(0, 0, 0, 0.08);
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.interface-tree::-webkit-scrollbar-thumb {
|
|
background: rgba(86, 144, 236, 0.65);
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.interface-tree::-webkit-scrollbar-thumb:hover {
|
|
background: rgba(59, 97, 148, 0.85);
|
|
}
|
|
|
|
.loading-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 120px;
|
|
color: #666;
|
|
|
|
p {
|
|
margin-top: 8px;
|
|
font-size: 12px;
|
|
}
|
|
}
|
|
|
|
.tree-node {
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
padding: 10px 12px;
|
|
margin: 8px 0;
|
|
border-radius: 18px;
|
|
transition: background-color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
|
|
cursor: pointer;
|
|
background: rgba(255, 255, 255, 0.02);
|
|
|
|
&:hover {
|
|
background-color: rgba(255, 255, 255, 0.12);
|
|
border-color: rgba(255, 255, 255, 0.2);
|
|
transform: translateX(4px);
|
|
}
|
|
}
|
|
|
|
.tree-node.selected {
|
|
background: #fff394;
|
|
box-shadow: none;
|
|
border-color: #fff394 !important;
|
|
color: #000 !important;
|
|
|
|
.node-number {
|
|
color: #000 !important;
|
|
}
|
|
|
|
.node-title,
|
|
.interface-code,
|
|
.post-method {
|
|
color: #000 !important;
|
|
}
|
|
|
|
.map-count .bracket,
|
|
.map-count .count-num {
|
|
color: #000 !important;
|
|
}
|
|
|
|
/* 选中时第二行插槽(如创建时间)保持深色,不变白 */
|
|
.node-second-row-extra-slot {
|
|
color: #666 !important;
|
|
|
|
.create-time {
|
|
color: #666 !important;
|
|
}
|
|
}
|
|
}
|
|
|
|
.node-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
table-layout: fixed;
|
|
}
|
|
|
|
.node-content-cell {
|
|
text-align: left;
|
|
padding: 0;
|
|
width: auto;
|
|
}
|
|
|
|
.node-code-cell {
|
|
text-align: left;
|
|
padding: 0;
|
|
width: auto;
|
|
padding-left: 20px;
|
|
}
|
|
|
|
.node-actions-cell {
|
|
text-align: right;
|
|
padding: 0;
|
|
width: 30px;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
.node-second-row-extra-cell {
|
|
text-align: right;
|
|
min-width: 50px;
|
|
width: auto;
|
|
}
|
|
|
|
.node-second-row-extra-slot {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.node-title {
|
|
font-size: 14px;
|
|
color: #fff;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.map-count {
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: #3f9370;
|
|
padding: 0;
|
|
min-width: auto;
|
|
text-align: left;
|
|
|
|
.bracket {
|
|
color: rgba(255, 255, 255, 0.4);
|
|
}
|
|
.count-num {
|
|
color: #3f9370;
|
|
}
|
|
}
|
|
|
|
.status-dot {
|
|
width: 6px;
|
|
height: 6px;
|
|
background-color: #38f0a2;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.selected-dot {
|
|
display: none;
|
|
}
|
|
|
|
.node-number {
|
|
color: rgba(255, 255, 255, 0.6);
|
|
font-size: 12px;
|
|
font-weight: normal;
|
|
margin-right: 8px;
|
|
display: inline-block;
|
|
min-width: 16px;
|
|
text-align: center;
|
|
}
|
|
|
|
.tree-node.selected .node-number {
|
|
color: #000 !important;
|
|
}
|
|
|
|
.post-method {
|
|
color: #ffc896;
|
|
font-weight: bold;
|
|
font-size: 10px;
|
|
margin-right: 6px;
|
|
display: inline-block;
|
|
padding: 0 4px;
|
|
border-radius: 4px;
|
|
border: none;
|
|
}
|
|
|
|
.interface-code {
|
|
color: rgba(255, 255, 255, 0.65);
|
|
font-size: 12px;
|
|
font-weight: normal;
|
|
display: inline-block;
|
|
}
|
|
|
|
.action-btn {
|
|
padding: 0;
|
|
min-width: 20px;
|
|
height: 20px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
|
|
: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;
|
|
}
|
|
|
|
.vertical-dots-icon span {
|
|
width: 3px;
|
|
height: 3px;
|
|
background-color: #666;
|
|
border-radius: 50%;
|
|
display: block;
|
|
}
|
|
</style>
|