183 lines
5.4 KiB
Vue
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>
|