front/src/components/CustomLeftMenu/index.vue
2026-03-21 15:20:52 +08:00

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>