Files
ai-agent-admin/web/apps/web-ele/src/components/wiki/WikiHomeSidebar.vue
T
2026-06-08 18:14:59 +08:00

904 lines
22 KiB
Vue

<script setup lang="ts">
import { ref, computed, nextTick, watch } from 'vue'
import { useTableStore } from '#/store/zq-smart-table'
import { SmartItemType, type Table } from '#/types/zq-smart-table/table'
import { useI18n } from '@vben/locales'
import { useRouter, useRoute } from 'vue-router'
import {
Grid,
FileText,
Search,
ChevronDown,
ChevronRight,
Ellipsis,
Pencil,
Trash2,
FilePlus2,
BookOpen,
} from '@vben/icons'
import { ElMessageBox } from 'element-plus'
type TreeNode = Table & { children: TreeNode[] }
interface FlatNode {
id: string
name: string
type: SmartItemType
depth: number
hasChildren: boolean
parentId: string | null | undefined
}
const emit = defineEmits<{
selectSpace: [spaceId: string]
selectDoc: [tableId: string]
showSpaces: []
}>()
const { t } = useI18n()
const tableStore = useTableStore()
const router = useRouter()
const route = useRoute()
const SIDEBAR_DEFAULT = 256
const SIDEBAR_MIN = 200
const SIDEBAR_MAX = 480
const sidebarWidth = ref(SIDEBAR_DEFAULT)
const isResizing = ref(false)
const searchText = ref('')
function onResizeStart(e: MouseEvent) {
e.preventDefault()
isResizing.value = true
const startX = e.clientX
const startW = sidebarWidth.value
function onMove(ev: MouseEvent) {
const newW = startW + (ev.clientX - startX)
sidebarWidth.value = Math.max(SIDEBAR_MIN, Math.min(SIDEBAR_MAX, newW))
}
function onUp() {
isResizing.value = false
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}
function onResizeDblClick() {
sidebarWidth.value = SIDEBAR_DEFAULT
}
// Active state
const activeView = computed(() => {
const tableId = route.params.tableId as string | undefined
if (tableId) return { type: 'doc' as const, id: tableId }
return { type: 'spaces' as const, id: null }
})
// ==================== My Documents Tree ====================
const expandedIds = ref<Set<string>>(new Set())
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 buildTree(items: Table[]): TreeNode[] {
const map = new Map<string, TreeNode>()
const roots: TreeNode[] = []
for (const item of items) {
map.set(item.id, { ...item, children: [] })
}
for (const node of map.values()) {
const pid = node.parentId
if (pid && map.has(pid)) {
map.get(pid)!.children.push(node)
} else {
roots.push(node)
}
}
return roots
}
function filterTree(nodes: TreeNode[], q: string): TreeNode[] {
if (!q) return nodes
const result: TreeNode[] = []
for (const node of nodes) {
const childMatch = filterTree(node.children, q)
if (node.name.toLowerCase().includes(q) || childMatch.length > 0) {
result.push({ ...node, children: childMatch })
}
}
return result
}
function flattenTree(nodes: TreeNode[], depth = 0): FlatNode[] {
const result: FlatNode[] = []
for (const node of nodes) {
result.push({
id: node.id,
name: node.name,
type: node.type,
depth,
hasChildren: node.children.length > 0,
parentId: node.parentId,
})
if (node.children.length > 0 && expandedIds.value.has(node.id)) {
result.push(...flattenTree(node.children, depth + 1))
}
}
return result
}
const tree = computed(() => buildTree(tableStore.tables))
const filteredTree = computed(() => {
const q = searchText.value.trim().toLowerCase()
return filterTree(tree.value, q)
})
const flatList = computed(() => flattenTree(filteredTree.value))
const hasSearchResults = computed(() => flatList.value.length > 0)
watch(() => tableStore.tables, () => {
if (expandedIds.value.size === 0 && tableStore.tables.length > 0) {
const ids = new Set<string>()
function walk(nodes: TreeNode[]) {
for (const n of nodes) {
if (n.children.length > 0) {
ids.add(n.id)
walk(n.children)
}
}
}
walk(tree.value as TreeNode[])
expandedIds.value = ids
}
}, { immediate: true })
// Drag state
const dragId = ref<string | null>(null)
const dropTargetId = ref<string | null>(null)
const dropPosition = ref<'inside' | 'before' | 'after' | null>(null)
function onDragStart(e: DragEvent, id: string) {
dragId.value = id
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', id)
}
}
function onDragOver(e: DragEvent, id: string) {
e.preventDefault()
if (dragId.value === id) return
dropTargetId.value = id
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const y = e.clientY - rect.top
const h = rect.height
if (y < h * 0.25) dropPosition.value = 'before'
else if (y > h * 0.75) dropPosition.value = 'after'
else dropPosition.value = 'inside'
}
function onDragLeave() {
dropTargetId.value = null
dropPosition.value = null
}
function onDrop(e: DragEvent) {
e.preventDefault()
if (!dragId.value || !dropTargetId.value || dragId.value === dropTargetId.value) {
resetDrag()
return
}
const targetItem = tableStore.tables.find(t => t.id === dropTargetId.value)
if (!targetItem) { resetDrag(); return }
const draggedId = dragId.value
if (dropPosition.value === 'inside') {
const children = tableStore.tables.filter(
(t) => (t.parentId ?? null) === dropTargetId.value && t.id !== draggedId,
)
const lastChild = children.length > 0 ? children[children.length - 1] : null
tableStore.moveTable(draggedId, dropTargetId.value, lastChild?.id ?? null)
expandedIds.value = new Set([...expandedIds.value, dropTargetId.value])
} else {
const newParentId = targetItem.parentId ?? null
const siblings = tableStore.tables.filter(
(t) => (t.parentId ?? null) === newParentId && t.id !== draggedId,
)
const targetIdx = siblings.findIndex((s) => s.id === dropTargetId.value)
let afterId: string | null = null
if (dropPosition.value === 'before') {
afterId = targetIdx > 0 ? (siblings[targetIdx - 1]?.id ?? null) : null
} else {
afterId = dropTargetId.value
}
tableStore.moveTable(draggedId, newParentId, afterId)
}
resetDrag()
}
function resetDrag() {
dragId.value = null
dropTargetId.value = null
dropPosition.value = null
}
function getItemDropClass(nodeId: string) {
if (dropTargetId.value !== nodeId) return ''
if (dropPosition.value === 'inside') return 'drop-inside'
if (dropPosition.value === 'before') return 'drop-before'
if (dropPosition.value === 'after') return 'drop-after'
return ''
}
// Rename
const renamingId = ref<string | null>(null)
const renameValue = ref('')
const renameInputEl = ref<HTMLInputElement | null>(null)
function setRenameRef(el: any) { renameInputEl.value = el }
function startRename(tableId: string, currentName: string) {
renamingId.value = tableId
renameValue.value = currentName
nextTick(() => {
renameInputEl.value?.focus()
renameInputEl.value?.select()
})
}
async function confirmRename(tableId: string) {
if (renameValue.value.trim()) {
const table = tableStore.tables.find((item) => item.id === tableId)
if (table) {
table.name = renameValue.value.trim()
const { updateTableApi } = await import('#/api/smart-table')
updateTableApi(tableId, { name: table.name }).catch(() => {})
}
}
renamingId.value = null
}
function handleDeleteTable(tableId: string, tableName: string) {
ElMessageBox.confirm(
t('sidebar.deleteTableConfirm', { name: tableName }),
t('sidebar.deleteTable'),
{
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning',
},
).then(async () => {
await tableStore.deleteTable(tableId)
if (activeView.value.type === 'doc' && activeView.value.id === tableId) {
router.push('/wiki')
}
}).catch(() => {})
}
function handleContextCommand(command: string, tableId: string, tableName: string) {
if (command === 'rename') startRename(tableId, tableName)
else if (command === 'delete') handleDeleteTable(tableId, tableName)
else if (command === 'addSubPage') handleAddSubPage(tableId)
}
// Actions
async function handleAddDocument() {
const name = t('document.untitled')
const newId = await tableStore.addDocument(name)
if (newId) {
await tableStore.loadTableFull(newId)
router.push(`/wiki/doc/${newId}`)
emit('selectDoc', newId)
}
}
async function handleAddSubPage(parentId: string) {
const name = t('document.untitled')
const newId = await tableStore.addSubPage(parentId, name)
if (newId) {
expandedIds.value = new Set([...expandedIds.value, parentId])
await tableStore.loadTableFull(newId)
router.push(`/wiki/doc/${newId}`)
emit('selectDoc', newId)
}
}
function handleSelectDoc(tableId: string) {
if (renamingId.value === tableId) return
tableStore.activeTableId = tableId
router.push(`/wiki/doc/${tableId}`)
emit('selectDoc', tableId)
}
function handleShowSpaces() {
router.push('/wiki')
emit('showSpaces')
}
function getNodeIcon(type: SmartItemType) {
return type === SmartItemType.Document ? FileText : Grid
}
</script>
<template>
<div
class="zq-sidebar"
:class="{ 'is-resizing': isResizing }"
:style="{ width: `${sidebarWidth}px` }"
>
<div class="zq-sidebar__inner" :style="{ width: `${sidebarWidth}px` }">
<!-- Header -->
<div class="zq-sidebar__header">
<div class="zq-sidebar__brand">
<div class="zq-sidebar__logo">
<BookOpen class="w-4 h-4" />
</div>
<span class="zq-sidebar__brand-title">{{ t('wiki.title') }}</span>
</div>
</div>
<!-- Search -->
<div class="zq-sidebar__search">
<Search class="zq-sidebar__search-icon" />
<input
v-model="searchText"
class="zq-sidebar__search-input"
:placeholder="t('wiki.searchDocPlaceholder')"
/>
</div>
<!-- Scrollable content -->
<div class="zq-sidebar__content">
<!-- Wiki Spaces entry -->
<div class="zq-sidebar__section">
<div
class="zq-sidebar__section-header"
:class="{ 'is-active': activeView.type === 'spaces' }"
@click="handleShowSpaces"
>
<BookOpen class="zq-sidebar__section-icon" />
<span class="zq-sidebar__section-title">{{ t('wiki.wikiSpaces') }}</span>
</div>
</div>
<!-- Divider -->
<div class="zq-sidebar__divider" />
<!-- My Documents Section -->
<div class="zq-sidebar__section">
<div class="zq-sidebar__section-header zq-sidebar__section-header--docs">
<FileText class="zq-sidebar__section-icon" />
<span class="zq-sidebar__section-title">{{ t('wiki.myDocuments') }}</span>
</div>
<!-- Document tree -->
<template v-if="flatList.length > 0">
<div
v-for="node in flatList"
:key="node.id"
class="zq-sidebar__item"
:style="{ '--depth': node.depth }"
:class="[
{
'is-active': activeView.type === 'doc' && activeView.id === node.id,
'is-dragging': dragId === node.id,
},
getItemDropClass(node.id),
]"
draggable="true"
@dragstart="onDragStart($event, node.id)"
@dragover="onDragOver($event, node.id)"
@dragleave="onDragLeave"
@drop="onDrop($event)"
@dragend="resetDrag"
@click="handleSelectDoc(node.id)"
>
<button
v-if="node.hasChildren"
class="zq-sidebar__expand-toggle"
@click.stop="toggleExpand(node.id)"
>
<component
:is="expandedIds.has(node.id) ? ChevronDown : ChevronRight"
class="w-3.5 h-3.5"
/>
</button>
<span v-else class="zq-sidebar__expand-spacer" />
<component :is="getNodeIcon(node.type)" class="zq-sidebar__item-icon" />
<input
v-if="renamingId === node.id"
:ref="setRenameRef"
v-model="renameValue"
class="zq-sidebar__rename-input"
@blur="confirmRename(node.id)"
@keyup.enter="confirmRename(node.id)"
@keyup.escape="renamingId = null"
@click.stop
/>
<span v-else class="zq-sidebar__item-name">{{ node.name }}</span>
<el-dropdown
v-if="renamingId !== node.id"
trigger="click"
class="zq-sidebar__item-actions"
@command="(cmd: string) => handleContextCommand(cmd, node.id, node.name)"
>
<button class="zq-sidebar__more-btn" @click.stop>
<Ellipsis class="w-3.5 h-3.5" />
</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="addSubPage">
<div class="flex items-center gap-2">
<FilePlus2 class="w-3.5 h-3.5" />
<span>{{ t('sidebar.newSubPage') }}</span>
</div>
</el-dropdown-item>
<el-dropdown-item command="rename">
<div class="flex items-center gap-2">
<Pencil class="w-3.5 h-3.5" />
<span>{{ t('common.rename') }}</span>
</div>
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<div class="flex items-center gap-2" style="color: var(--zq-danger-color)">
<Trash2 class="w-3.5 h-3.5" />
<span>{{ t('common.delete') }}</span>
</div>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<!-- Empty my docs -->
<div v-if="!searchText && flatList.length === 0 && !tableStore.loading" class="zq-sidebar__empty">
{{ t('wiki.emptyMyDocs') }}
</div>
<!-- Search no results -->
<div v-if="searchText && !hasSearchResults" class="zq-sidebar__no-results">
<Search class="w-6 h-6" style="color: var(--zq-text-placeholder)" />
<span>{{ t('wiki.noResults') }}</span>
</div>
</div>
</div>
<!-- Bottom actions -->
<div class="zq-sidebar__footer">
<button class="zq-sidebar__footer-btn" @click="handleAddDocument">
<FilePlus2 class="w-4 h-4" />
<span>{{ t('sidebar.newDocument') }}</span>
</button>
</div>
</div>
<!-- Resize handle -->
<div
class="zq-sidebar__resize-handle"
@mousedown="onResizeStart"
@dblclick="onResizeDblClick"
/>
</div>
</template>
<style scoped>
.zq-sidebar {
position: relative;
flex-shrink: 0;
transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1);
overflow: visible;
z-index: 20;
}
.zq-sidebar.is-resizing {
transition: none;
}
.zq-sidebar__inner {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--zq-bg-sidebar);
border-right: 1px solid var(--zq-border-color-light);
}
/* Resize handle */
.zq-sidebar__resize-handle {
position: absolute;
top: 0;
right: -3px;
width: 3px;
height: 100%;
cursor: col-resize;
z-index: 30;
transition: background-color 0.15s;
}
.zq-sidebar__resize-handle:hover,
.zq-sidebar.is-resizing .zq-sidebar__resize-handle {
background-color: var(--zq-brand-color);
}
/* Header */
.zq-sidebar__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 12px 8px;
flex-shrink: 0;
}
.zq-sidebar__brand {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.zq-sidebar__logo {
width: 30px;
height: 30px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background: var(--zq-brand-color);
flex-shrink: 0;
}
.zq-sidebar__brand-title {
font-size: 14px;
font-weight: 600;
color: var(--zq-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Search */
.zq-sidebar__search {
display: flex;
align-items: center;
gap: 8px;
margin: 4px 12px 8px;
padding: 7px 10px;
border-radius: 8px;
background: var(--zq-bg-primary);
border: 1px solid transparent;
transition: all 0.15s;
}
.zq-sidebar__search:focus-within {
border-color: var(--zq-brand-color);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--zq-brand-color) 15%, transparent);
}
.zq-sidebar__search-icon {
width: 14px;
height: 14px;
color: var(--zq-text-placeholder);
flex-shrink: 0;
}
.zq-sidebar__search-input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
font-size: 13px;
color: var(--zq-text-primary);
line-height: 1;
}
.zq-sidebar__search-input::placeholder {
color: var(--zq-text-placeholder);
}
/* Scrollable content */
.zq-sidebar__content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 4px 0 16px;
}
.zq-sidebar__content::-webkit-scrollbar {
width: 4px;
}
.zq-sidebar__content::-webkit-scrollbar-thumb {
background: var(--zq-border-color);
border-radius: 4px;
}
.zq-sidebar__content::-webkit-scrollbar-thumb:hover {
background: var(--zq-text-placeholder);
}
/* Section */
.zq-sidebar__section {
padding: 0 8px;
}
.zq-sidebar__section-header {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
margin: 2px 0;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
color: var(--zq-text-secondary);
transition: all 0.15s;
}
.zq-sidebar__section-header:hover {
background: var(--zq-bg-cell-hover);
color: var(--zq-text-primary);
}
.zq-sidebar__section-header.is-active {
background: var(--zq-brand-color-light);
color: var(--zq-brand-color);
}
.zq-sidebar__section-header--docs {
cursor: default;
}
.zq-sidebar__section-header--docs:hover {
background: transparent;
color: var(--zq-text-secondary);
}
.zq-sidebar__section-icon {
width: 15px;
height: 15px;
flex-shrink: 0;
}
.zq-sidebar__section-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.zq-sidebar__section-add {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 4px;
border: none;
background: transparent;
color: var(--zq-text-placeholder);
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
}
.zq-sidebar__section-add:hover {
background: var(--zq-bg-cell-hover);
color: var(--zq-brand-color);
}
/* Divider */
.zq-sidebar__divider {
height: 1px;
margin: 6px 12px;
background: var(--zq-border-color-light);
}
/* Tree items (same as ZqTableSidebar) */
.zq-sidebar__item {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 8px 5px calc(8px + var(--depth, 0) * 16px);
margin: 1px 0;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: var(--zq-text-primary);
transition: background-color 0.15s, color 0.15s;
position: relative;
user-select: none;
}
.zq-sidebar__item:hover {
background: var(--zq-bg-cell-hover);
}
.zq-sidebar__item.is-active {
background: var(--zq-brand-color-light);
color: var(--zq-brand-color);
}
.zq-sidebar__item.is-active::before {
content: '';
position: absolute;
left: 0;
top: 6px;
bottom: 6px;
width: 3px;
border-radius: 0 2px 2px 0;
background: var(--zq-brand-color);
}
.zq-sidebar__item.is-dragging {
opacity: 0.4;
}
.zq-sidebar__item.drop-inside {
background: color-mix(in srgb, var(--zq-brand-color) 12%, transparent);
outline: 2px solid var(--zq-brand-color);
outline-offset: -2px;
border-radius: 6px;
}
.zq-sidebar__item.drop-before {
box-shadow: inset 0 2px 0 0 var(--zq-brand-color);
}
.zq-sidebar__item.drop-after {
box-shadow: inset 0 -2px 0 0 var(--zq-brand-color);
}
.zq-sidebar__expand-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 4px;
border: none;
background: transparent;
color: var(--zq-text-placeholder);
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s;
}
.zq-sidebar__expand-toggle:hover {
background: var(--zq-bg-cell-hover);
color: var(--zq-text-secondary);
}
.zq-sidebar__expand-spacer {
width: 18px;
flex-shrink: 0;
}
.zq-sidebar__item-icon {
width: 15px;
height: 15px;
flex-shrink: 0;
opacity: 0.7;
}
.zq-sidebar__item.is-active .zq-sidebar__item-icon {
opacity: 1;
}
.zq-sidebar__item-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.4;
}
.zq-sidebar__rename-input {
flex: 1;
min-width: 0;
font-size: 13px;
line-height: 1.4;
padding: 1px 6px;
border-radius: 4px;
border: 1px solid var(--zq-brand-color);
outline: none;
background: var(--zq-bg-primary);
color: var(--zq-text-primary);
}
.zq-sidebar__item-actions {
flex-shrink: 0;
margin-left: auto;
}
.zq-sidebar__more-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 4px;
border: none;
background: transparent;
color: var(--zq-text-placeholder);
cursor: pointer;
opacity: 0;
transition: all 0.15s;
flex-shrink: 0;
}
.zq-sidebar__item:hover .zq-sidebar__more-btn {
opacity: 1;
}
.zq-sidebar__more-btn:hover {
background: var(--zq-bg-secondary);
color: var(--zq-text-secondary);
}
/* Empty & No Results */
.zq-sidebar__empty {
padding: 16px;
font-size: 12px;
color: var(--zq-text-placeholder);
text-align: center;
}
.zq-sidebar__no-results {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 24px 16px;
font-size: 13px;
color: var(--zq-text-placeholder);
}
/* Footer */
.zq-sidebar__footer {
flex-shrink: 0;
border-top: 1px solid var(--zq-border-color-light);
padding: 8px;
display: flex;
flex-direction: column;
gap: 2px;
}
.zq-sidebar__footer-btn {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 12px;
border-radius: 6px;
border: none;
background: transparent;
color: var(--zq-text-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.zq-sidebar__footer-btn:hover {
background: var(--zq-bg-cell-hover);
color: var(--zq-text-primary);
}
</style>