Files
AlphaPulse/web/src/components/SkillTree.vue
T
eezd 916a2dee73 Restyle the web UI in a light theme, step 2: remaining pages
- News: show the list and the article detail side by side; the drawer
  becomes a persistent detail panel with a revision picker and +/- line
  counts.
- Crawler console: one card per source instead of tabs, plus a merged
  live log panel filterable by source and level.
- Chat: segmented Chat / CONTEXT.md switch, message cards with times,
  bordered tool-call rows and a context usage bar.
- Login, Skills and Settings move to the panel style; diff and highlight
  colors follow the new palette. The market K-line area stays dark.
2026-10-15 22:26:04 +08:00

167 lines
6.3 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 type Node from "element-plus/es/components/tree/src/model/node"
import type { AllowDropType, NodeDropType } from "element-plus/es/components/tree/src/tree.type"
import type { NodeKind, SkillNode } from "@/apis/skills"
import { createSkillNode, deleteSkillNode, moveSkillNode, patchSkillNode } from "@/apis/skills"
/** skills 树:Skills 页可编辑(拖拽、增删改),Chat 侧边栏只读 */
const props = defineProps<{
nodes: SkillNode[]
editable?: boolean
selectedId?: number | null
/** 需要高亮的节点(如 Chat 刚生成草稿的 skill) */
highlightIds?: number[]
}>()
const emit = defineEmits<{
select: [node: SkillNode]
changed: []
}>()
interface TreeItem {
id: number
label: string
data: SkillNode
children: TreeItem[]
}
const treeData = computed(() => {
const items = new Map<number, TreeItem>(props.nodes.map(n => [n.id, { id: n.id, label: n.name, data: n, children: [] }]))
const roots: TreeItem[] = []
// 后端已按 parent_id、sort_order 排好序
for (const n of props.nodes) {
const item = items.get(n.id)!
if (n.parent_id === null)
roots.push(item)
else
items.get(n.parent_id)?.children.push(item)
}
return roots
})
// 新出现的文件夹默认展开,保留用户手动折叠的状态
const expanded = ref<number[]>([])
const seenFolders = new Set<number>()
watch(() => props.nodes, (nodes) => {
for (const n of nodes) {
if (n.kind === "folder" && !seenFolders.has(n.id)) {
seenFolders.add(n.id)
expanded.value.push(n.id)
}
}
}, { immediate: true })
function allowDrop(_dragging: Node, drop: Node, type: AllowDropType) {
return type !== "inner" || (drop.data as TreeItem).data.kind === "folder"
}
async function onDrop(dragging: Node, drop: Node, type: NodeDropType) {
const dragged = (dragging.data as TreeItem).data
const target = (drop.data as TreeItem).data
const parentId = type === "inner" ? target.id : target.parent_id
const siblings = props.nodes.filter(n => n.parent_id === parentId && n.id !== dragged.id)
const index = type === "inner" ? siblings.length : siblings.findIndex(n => n.id === target.id) + (type === "after" ? 1 : 0)
try {
await moveSkillNode(dragged.id, parentId, index)
} finally {
// 失败时也刷新,让树回到服务端的真实顺序
emit("changed")
}
}
const NAME_RULE = (v: string) => (!!v?.trim() && !v.includes("/")) || "名称不能为空,也不能包含 /"
async function create(parentId: number | null, kind: NodeKind) {
const { value } = await ElMessageBox.prompt(kind === "folder" ? "文件夹名称" : "skill 名称", kind === "folder" ? "新建文件夹" : "新建 skill", {
inputValidator: NAME_RULE
})
const node = await createSkillNode({ parent_id: parentId, kind, name: value.trim() })
emit("changed")
if (kind === "skill")
emit("select", node)
}
async function rename(node: SkillNode) {
const { value } = await ElMessageBox.prompt("新名称", "重命名", { inputValue: node.name, inputValidator: NAME_RULE })
await patchSkillNode(node.id, { name: value.trim() })
emit("changed")
}
async function remove(node: SkillNode) {
const message = node.kind === "folder" ? `删除文件夹「${node.path}」?(必须为空)` : `删除 skill「${node.path}」及其全部版本?此操作不可恢复。`
await ElMessageBox.confirm(message, "删除确认", { type: "warning" })
await deleteSkillNode(node.id)
ElMessage.success("已删除")
emit("changed")
}
function onExpand(item: TreeItem) {
if (!expanded.value.includes(item.id))
expanded.value.push(item.id)
}
function onCollapse(item: TreeItem) {
expanded.value = expanded.value.filter(id => id !== item.id)
}
</script>
<template>
<div class="h-full flex flex-col">
<div v-if="editable" class="mb-2 flex gap-2">
<el-button size="small" @click="create(null, 'folder')">
新建文件夹
</el-button>
<el-button size="small" @click="create(null, 'skill')">
新建 skill
</el-button>
</div>
<el-scrollbar class="min-h-0 flex-1">
<el-empty v-if="nodes.length === 0" description="还没有 skill" :image-size="60" />
<el-tree
v-else
:data="treeData" node-key="id" :props="{ label: 'label', children: 'children' }"
:default-expanded-keys="expanded" :current-node-key="selectedId ?? undefined" highlight-current
:draggable="editable" :allow-drop="allowDrop" :expand-on-click-node="false"
@node-drop="onDrop" @node-click="(item: TreeItem) => emit('select', item.data)"
@node-expand="onExpand" @node-collapse="onCollapse"
>
<template #default="{ data }">
<div
class="group w-full flex items-center gap-1 overflow-hidden rounded pr-1 text-sm"
:class="{ 'opacity-50': !data.data.enabled, 'bg-amber-100': highlightIds?.includes(data.id) }"
>
<span>{{ data.data.kind === "folder" ? "📁" : "📄" }}</span>
<span class="truncate">{{ data.data.name }}</span>
<template v-if="data.data.kind === 'skill'">
<el-tag size="small" effect="plain" class="shrink-0">
P{{ data.data.priority }}
</el-tag>
<el-tag v-if="data.data.published_version_no === null" size="small" type="info" class="shrink-0">
未发布
</el-tag>
<el-tag v-else-if="data.data.has_draft" size="small" type="warning" class="shrink-0">
草稿
</el-tag>
</template>
<div v-if="editable" class="ml-auto hidden shrink-0 gap-1 group-hover:flex" @click.stop>
<template v-if="data.data.kind === 'folder'">
<el-button link size="small" @click="create(data.id, 'skill')">
+skill
</el-button>
<el-button link size="small" @click="create(data.id, 'folder')">
+目录
</el-button>
</template>
<el-button link size="small" @click="rename(data.data)">
改名
</el-button>
<el-button link size="small" type="danger" @click="remove(data.data)">
删除
</el-button>
</div>
</div>
</template>
</el-tree>
</el-scrollbar>
</div>
</template>