本地 RAG 知识库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

122 lines
7.6 KiB

<template>
<t-card title="🏷️ 分类管理" :bordered="false">
<!-- 创建新分类 -->
<div class="create-bar card-box">
<h3 class="create-title">创建新分类</h3>
<t-space size="small" style="flex-wrap:wrap;">
<t-select v-model="newParentId" :options="parentOpts" placeholder="顶级分类" size="small" style="width:180px;" />
<t-input v-model="newName" placeholder="分类名称" size="small" style="width:160px;" @enter="create" />
<t-input v-model="newDescription" placeholder="描述(可选)" size="small" style="width:180px;" />
<t-input-number v-model="newSortOrder" placeholder="排序" size="small" style="width:80px;" :min="0" />
<t-button theme="success" size="small" @click="create">创建</t-button>
</t-space>
</div>
<!-- 树形列表 -->
<div class="tree-header">
<span class="tree-count">共 {{ categoryStore.categories.length }} 个分类</span>
<t-button variant="outline" size="small" @click="categoryStore.loadCategories()">刷新</t-button>
</div>
<t-empty v-if="flatTree.length===0" description="暂无分类" />
<div v-else>
<div v-for="node in flatTree" :key="node.id" class="tree-row" :style="{paddingLeft:(node.level*24+12)+'px'}">
<div class="tree-left">
<span v-if="node.hasChildren" @click="toggleExpand(node.id)" class="tree-arrow">{{ expandedIds.has(node.id)?'▼':'▶' }}</span>
<span v-else style="width:16px;" />
<strong class="tree-name">{{ node.name }}</strong>
<span v-if="node.description" class="tree-desc">{{ node.description }}</span>
</div>
<t-space :size="4">
<t-button size="small" variant="text" @click="openEdit(node)">编辑</t-button>
<t-button size="small" variant="text" theme="danger" @click="remove(node)">删除</t-button>
</t-space>
</div>
</div>
<!-- 编辑弹窗 -->
<t-dialog v-model:visible="editModal.visible" header="编辑分类" width="440px" :footer="false">
<t-form label-align="top">
<t-form-item label="分类名称"><t-input v-model="editModal.name" placeholder="分类名称" /></t-form-item>
<t-form-item label="描述"><t-input v-model="editModal.description" placeholder="描述(可选)" /></t-form-item>
<t-form-item label="排序权重"><t-input-number v-model="editModal.sortOrder" :min="0" style="width:120px;" /></t-form-item>
</t-form>
<div class="dialog-footer">
<t-button variant="outline" @click="closeEdit">取消</t-button>
<t-button theme="primary" @click="saveEdit">保存</t-button>
</div>
</t-dialog>
</t-card>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useCategoryStore } from '@/stores/category'
import { createCategory, updateCategory, deleteCategory } from '@/api/category'
import { toast } from '@/utils/toast'
import { useConfirm } from '@/composables/useConfirm'
const { confirm } = useConfirm()
const categoryStore = useCategoryStore()
const newName=ref(''); const newDescription=ref(''); const newParentId=ref(''); const newSortOrder=ref(0)
const parentOpts = computed(() => {
const opts = [{label:'顶级分类',value:''}]
function walk(cats: any[], level: number) { for(const c of cats){ opts.push({label:'─'.repeat(level)+' '+c.name,value:String(c.id)}); if(c.children?.length && level<2) walk(c.children,level+1) } }
walk(categoryTree.value,0); return opts
})
const expandedIds = ref(new Set<string>())
const editModal = ref({visible:false,id:null as any,name:'',description:'',sortOrder:0})
const categoryTree = computed(() => {
const cats = categoryStore.categories||[]; const m=new Map<string,any>(); const roots:any[]=[]
for(const c of cats){ m.set(String(c.id),{...c,children:[],level:0}) }
for(const node of m.values()){
const pid = String(node.parentId || '')
if (pid && pid !== '0') { const p = m.get(pid); if (p) p.children.push(node); else roots.push(node) }
else roots.push(node)
}
function sort(nodes:any[]){nodes.sort((a,b)=>(a.sortOrder||0)-(b.sortOrder||0));nodes.forEach(n=>{if(n.children?.length)sort(n.children)})}
sort(roots); return roots
})
const flatTree = computed(() => {
const r:any[]=[]
function walk(nodes:any[],level:number){for(const n of nodes){r.push({...n,level,hasChildren:!!(n.children&&n.children.length)});if(n.children&&n.children.length&&expandedIds.value.has(String(n.id))) walk(n.children,level+1)}}
walk(categoryTree.value,0); return r
})
function toggleExpand(id:string){const s=new Set(expandedIds.value);if(s.has(id))s.delete(id);else s.add(id);expandedIds.value=s}
function expandAll(){const s=new Set<string>();function walk(nodes:any[]){for(const n of nodes){if(n.children?.length){s.add(String(n.id));walk(n.children)}}};walk(categoryTree.value);expandedIds.value=s}
onMounted(async () => { await categoryStore.loadCategories(); setTimeout(expandAll,100) })
async function create(){if(!newName.value.trim()){toast('请输入分类名称','error');return}
try{const r=await createCategory({name:newName.value.trim(),description:newDescription.value.trim(),parentId:newParentId.value||null,sortOrder:newSortOrder.value})
if(r.success){toast('分类创建成功','success');newName.value='';newDescription.value='';newParentId.value='';newSortOrder.value=0;await categoryStore.loadCategories();setTimeout(expandAll,100)}else toast(r.message||'创建失败','error')}catch(e:any){toast('创建失败:'+e.message,'error')}}
function openEdit(node:any){editModal.value={visible:true,id:node.id,name:node.name,description:node.description||'',sortOrder:node.sortOrder||0}}
function closeEdit(){editModal.value={visible:false,id:null,name:'',description:'',sortOrder:0}}
async function saveEdit(){if(!editModal.value.name.trim()){toast('请输入分类名称','error');return}
try{const r=await updateCategory(editModal.value.id,{name:editModal.value.name.trim(),description:editModal.value.description.trim(),sortOrder:editModal.value.sortOrder})
if(r.success){toast('分类更新成功','success');closeEdit();await categoryStore.loadCategories();setTimeout(expandAll,100)}else toast(r.message||'更新失败','error')}catch(e:any){toast('更新失败:'+e.message,'error')}}
async function remove(node:any){const cc=countDescendants(node);const m=cc>0?`\n注意:下有 ${cc} 个子分类`:''
if(!await confirm(`确定删除「${node.name}」?关联文档将变为未分类${m}`))return
try{const r=await deleteCategory(node.id);if(r.success){toast('分类已删除','success');await categoryStore.loadCategories();setTimeout(expandAll,100)}else toast(r.message||'删除失败','error')}catch(e:any){toast('删除失败:'+e.message,'error')}}
function countDescendants(node:any):number{if(!node.children?.length)return 0;let c=node.children.length;for(const ch of node.children)c+=countDescendants(ch);return c}
</script>
<style scoped>
.create-title{font-size:13px;font-weight:600;margin-bottom:8px;}
.tree-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;}.tree-count{font-size:13px;color:var(--color-text-tertiary);}
.tree-row{padding:10px 12px;border-bottom:1px solid var(--color-border);display:flex;justify-content:space-between;align-items:center;}
.tree-left{display:flex;align-items:center;gap:6px;min-width:0;flex:1;}
.tree-arrow{cursor:pointer;user-select:none;font-size:12px;width:16px;text-align:center;color:var(--color-text-tertiary);}
.tree-name{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.tree-desc{color:var(--color-text-tertiary);font-size:12px;margin-left:8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
</style>