Files
XsinfoApi/src/components/TreeNode.vue
T
2026-07-02 23:51:05 +08:00

183 lines
5.4 KiB
Vue

<template>
<div class="tn">
<div
v-if="node.type === 'folder'"
class="folder"
:style="{ paddingLeft: 8 + depth * 14 + 'px' }"
@click="open = !open"
>
<svg :class="{ open }" width="10" height="10" viewBox="0 0 10 10"><path d="M3 1l5 4-5 4" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z" stroke="currentColor" stroke-width="1.5"/></svg>
<span class="label">{{ node.label }}</span>
<span class="count" v-if="node.children?.length">{{ countApis(node) }}</span>
<div class="row-actions">
<button class="xs-btn ghost icon" @click.stop="addApiHere" title="新增接口">+</button>
<button class="xs-btn ghost icon" @click.stop="addFolderHere" title="新增子分组"></button>
</div>
</div>
<div
v-else-if="node.type === 'api'"
class="api"
:class="{ active: node.filePath === selectedFile, hidden: !match }"
:style="{ paddingLeft: 8 + depth * 14 + 'px' }"
@click="onClick"
>
<span class="method" :class="'m-' + (node.method || 'GET').toLowerCase()">{{ node.method }}</span>
<span class="name">{{ node.label }}</span>
<span v-if="node.status === 'developing'" class="status dot" title="开发中"></span>
</div>
<template v-if="node.type === 'folder' && open">
<TreeNode
v-for="c in node.children"
:key="c.id"
:node="c"
:depth="depth + 1"
:keyword="keyword"
:selected-file="selectedFile"
@select-api="$emit('select-api', $event)"
/>
<div
v-if="!node.children?.length"
class="empty-folder"
:style="{ paddingLeft: 12 + (depth + 1) * 14 + 'px' }"
>
空白分组
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useWorkspaceStore } from '@/stores/workspace'
import type { ApiTreeNode } from '@/types'
const props = defineProps<{
node: ApiTreeNode
depth: number
keyword: string
selectedFile?: string | null
}>()
const emit = defineEmits<{
(e: 'select-api', n: ApiTreeNode): void
}>()
const workspaceStore = useWorkspaceStore()
const open = ref(props.depth < 2)
const match = computed(() => {
if (!props.keyword) return true
return props.node.label.toLowerCase().includes(props.keyword.toLowerCase())
})
function countApis(n: ApiTreeNode): number {
if (n.type === 'api') return 1
return (n.children ?? []).reduce((sum, c) => sum + countApis(c), 0)
}
async function addApiHere() {
if (!workspaceStore.rootDir) return
const name = prompt('接口名称', '新建接口')
if (!name) return
const created = await window.xs.api.create(workspaceStore.rootDir, '', {
name, method: 'GET', url: '/'
})
const apiNode: ApiTreeNode = {
type: 'api',
id: created.id,
label: name,
method: 'GET',
filePath: created.filePath
}
if (props.node.type === 'folder') {
props.node.children = props.node.children ?? []
props.node.children.push(apiNode)
await workspaceStore.saveProject()
}
}
async function addFolderHere() {
const name = prompt('分组名称', '新子分组')
if (!name) return
if (props.node.type === 'folder') {
props.node.children = props.node.children ?? []
props.node.children.push({
type: 'folder', id: `f-${Date.now()}`, label: name, children: []
})
await workspaceStore.saveProject()
}
}
function onClick() {
emit('select-api', props.node)
}
</script>
<style lang="scss" scoped>
.tn { user-select: none; }
.folder, .api {
display: flex; align-items: center; gap: 6px;
padding: 5px 8px;
font-size: $font-sm;
cursor: pointer;
border-radius: $radius-sm;
color: $text-secondary;
&:hover { background: $bg-hover; color: $text-primary; }
.row-actions { display: none; gap: 2px; margin-left: auto; }
&:hover .row-actions { display: flex; }
}
.folder {
svg:first-child { transition: transform .15s ease; color: $text-tertiary; }
svg.open { transform: rotate(90deg); }
svg:nth-child(2) { color: $primary; opacity: .8; }
.label { font-weight: 500; }
.count {
margin-left: 4px;
font-size: $font-xs;
color: $text-tertiary;
background: $bg-elevated;
padding: 0 5px;
border-radius: 8px;
}
}
.api {
&.hidden { opacity: .3; pointer-events: none; }
&.active {
background: linear-gradient(90deg, $primary-bg, transparent);
color: $text-primary;
border-left: 2px solid $primary;
.name { color: $text-primary; }
}
.method {
font-size: 9px;
padding: 1px 4px;
border-radius: 3px;
font-weight: 700;
color: #0a1428;
letter-spacing: .5px;
width: 42px;
text-align: center;
}
.m-get { background: $m-get; }
.m-post { background: $m-post; }
.m-put { background: $m-put; }
.m-delete { background: $m-delete; }
.m-patch { background: $m-patch; }
.m-head, .m-options { background: #6b85b3; color: #fff; }
.name { color: $text-primary; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.status { color: $primary; font-size: 10px; }
}
.empty-folder {
font-size: $font-xs;
color: $text-tertiary;
padding: 4px 0;
font-style: italic;
}
.icon { padding: 2px 6px; font-size: 12px; line-height: 1; }
</style>