Files
Enterprise-Knowledge-Base/frontend/src/components/ListSpaceSidebar.vue
T
wizardchen dbc7c32874 feat: update theme and UI styles across components
- Refactored CSS variables for consistent theming, replacing hardcoded colors with CSS variables for better maintainability.
- Updated various components including `AgentAvatar`, `AgentSelector`, and `AgentShareSettings` to utilize new theme variables for colors, backgrounds, and shadows.
- Enhanced SVG icons to use `currentColor` for better adaptability to theme changes.
- Improved overall UI consistency by aligning styles with the new theming approach, enhancing user experience across the application.

These changes significantly improve the visual coherence and maintainability of the UI, ensuring a more seamless user experience.
2026-03-05 18:14:14 +08:00

522 lines
15 KiB
Vue

<template>
<div
ref="sidebarRef"
class="list-space-sidebar"
:class="{ expanded: isExpanded, dragging: isDragging }"
:style="{ width: isDragging ? `${dragWidth}px` : undefined }"
>
<!-- Collapsed: icon strip -->
<div v-if="!isExpanded" class="icon-strip">
<template v-if="mode === 'resource'">
<t-tooltip v-if="!hideAll" :content="tooltipText($t('listSpaceSidebar.all'), countAll)" placement="right" :show-arrow="false">
<div class="icon-item-labeled" :class="{ active: selected === 'all' }" @click="select('all')">
<t-icon name="layers" size="16px" />
<span class="icon-label">{{ $t('listSpaceSidebar.all') }}</span>
</div>
</t-tooltip>
<t-tooltip :content="tooltipText($t('listSpaceSidebar.mine'), countMine)" placement="right" :show-arrow="false">
<div class="icon-item-labeled" :class="{ active: selected === 'mine' }" @click="select('mine')">
<t-icon name="user" size="16px" />
<span class="icon-label">{{ $t('listSpaceSidebar.mine') }}</span>
</div>
</t-tooltip>
<t-tooltip v-if="!hideShared" :content="tooltipText($t('listSpaceSidebar.sharedToMe'), countShared)" placement="right" :show-arrow="false">
<div class="icon-item-labeled" :class="{ active: selected === 'shared' }" @click="select('shared')">
<t-icon name="share" size="16px" />
<span class="icon-label">{{ $t('listSpaceSidebar.sharedToMe') }}</span>
</div>
</t-tooltip>
<template v-if="organizationsWithCount.length">
<div class="icon-strip-divider" />
<t-tooltip v-for="org in organizationsWithCount" :key="org.id" :content="tooltipText(org.name, getOrgCount(org.id))" placement="right" :show-arrow="false">
<div class="icon-item-labeled" :class="{ active: selected === org.id }" @click="select(org.id)">
<SpaceAvatar :name="org.name" :avatar="org.avatar" size="small" />
<span class="icon-label">{{ truncateLabel(org.name) }}</span>
</div>
</t-tooltip>
</template>
</template>
<template v-else>
<t-tooltip :content="tooltipText($t('listSpaceSidebar.all'), countAll)" placement="right" :show-arrow="false">
<div class="icon-item-labeled" :class="{ active: selected === 'all' }" @click="select('all')">
<t-icon name="layers" size="16px" />
<span class="icon-label">{{ $t('listSpaceSidebar.all') }}</span>
</div>
</t-tooltip>
<t-tooltip :content="tooltipText($t('organization.createdByMe'), countCreated)" placement="right" :show-arrow="false">
<div class="icon-item-labeled" :class="{ active: selected === 'created' }" @click="select('created')">
<t-icon name="usergroup-add" size="16px" />
<span class="icon-label">{{ $t('organization.createdByMe') }}</span>
</div>
</t-tooltip>
<t-tooltip :content="tooltipText($t('organization.joinedByMe'), countJoined)" placement="right" :show-arrow="false">
<div class="icon-item-labeled" :class="{ active: selected === 'joined' }" @click="select('joined')">
<t-icon name="usergroup" size="16px" />
<span class="icon-label">{{ $t('organization.joinedByMe') }}</span>
</div>
</t-tooltip>
</template>
</div>
<!-- Expanded: full nav panel -->
<nav v-else class="expanded-panel">
<div
v-if="!hideAll"
class="sidebar-item"
:class="{ active: selected === 'all' }"
@click="select('all')"
>
<div class="item-left">
<t-icon name="layers" class="item-icon" />
<span class="item-label">{{ $t('listSpaceSidebar.all') }}</span>
</div>
<span v-if="countAll !== undefined" class="item-count">{{ countAll }}</span>
</div>
<template v-if="mode === 'resource'">
<div
class="sidebar-item"
:class="{ active: selected === 'mine' }"
@click="select('mine')"
>
<div class="item-left">
<t-icon name="user" class="item-icon" />
<span class="item-label">{{ $t('listSpaceSidebar.mine') }}</span>
</div>
<span v-if="countMine !== undefined" class="item-count">{{ countMine }}</span>
</div>
<div
v-if="!hideShared"
class="sidebar-item"
:class="{ active: selected === 'shared' }"
@click="select('shared')"
>
<div class="item-left">
<t-icon name="share" class="item-icon" />
<span class="item-label">{{ $t('listSpaceSidebar.sharedToMe') }}</span>
</div>
<span v-if="countShared !== undefined && countShared > 0" class="item-count">{{ countShared }}</span>
</div>
<template v-if="organizationsWithCount.length">
<div class="sidebar-section">
<span class="section-title">{{ $t('listSpaceSidebar.spaces') }}</span>
</div>
<div
v-for="org in organizationsWithCount"
:key="org.id"
class="sidebar-item org-item"
:class="{ active: selected === org.id }"
@click="select(org.id)"
>
<div class="item-left">
<SpaceAvatar :name="org.name" :avatar="org.avatar" size="small" class="item-avatar" />
<span class="item-label" :title="org.name">{{ org.name }}</span>
</div>
<span v-if="getOrgCount(org.id) !== undefined" class="item-count">{{ getOrgCount(org.id) }}</span>
</div>
</template>
</template>
<template v-else>
<div
class="sidebar-item"
:class="{ active: selected === 'created' }"
@click="select('created')"
>
<div class="item-left">
<t-icon name="usergroup-add" class="item-icon" />
<span class="item-label">{{ $t('organization.createdByMe') }}</span>
</div>
<span v-if="countCreated !== undefined" class="item-count">{{ countCreated }}</span>
</div>
<div
class="sidebar-item"
:class="{ active: selected === 'joined' }"
@click="select('joined')"
>
<div class="item-left">
<t-icon name="usergroup" class="item-icon" />
<span class="item-label">{{ $t('organization.joinedByMe') }}</span>
</div>
<span v-if="countJoined !== undefined" class="item-count">{{ countJoined }}</span>
</div>
</template>
</nav>
<!-- Drag handle on the right edge -->
<div
class="resize-handle"
@mousedown.prevent="onDragStart"
>
<div class="resize-handle-line" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { Icon as TIcon } from 'tdesign-vue-next'
import SpaceAvatar from './SpaceAvatar.vue'
import { useOrganizationStore } from '@/stores/organization'
const COLLAPSED_WIDTH = 56
const EXPANDED_WIDTH = 208
const SNAP_THRESHOLD = 120
const props = withDefaults(
defineProps<{
mode?: 'resource' | 'organization'
modelValue: string
collapsedKey?: string
countAll?: number
countMine?: number
countShared?: number
countByOrg?: Record<string, number>
countCreated?: number
countJoined?: number
hideAll?: boolean
hideShared?: boolean
}>(),
{ mode: 'resource', collapsedKey: 'sidebar-collapsed-list', countAll: undefined, countMine: undefined, countShared: undefined, countByOrg: () => ({}), countCreated: undefined, countJoined: undefined, hideAll: false, hideShared: false }
)
const storageKey = props.collapsedKey + '-expanded'
const sidebarRef = ref<HTMLElement | null>(null)
const isExpanded = ref(localStorage.getItem(storageKey) === 'true')
const isDragging = ref(false)
const dragWidth = ref(isExpanded.value ? EXPANDED_WIDTH : COLLAPSED_WIDTH)
let startX = 0
let startWidth = 0
function onDragStart(e: MouseEvent) {
isDragging.value = true
startX = e.clientX
startWidth = isExpanded.value ? EXPANDED_WIDTH : COLLAPSED_WIDTH
dragWidth.value = startWidth
document.addEventListener('mousemove', onDragMove)
document.addEventListener('mouseup', onDragEnd)
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
}
function onDragMove(e: MouseEvent) {
const delta = e.clientX - startX
const newWidth = Math.max(COLLAPSED_WIDTH, Math.min(EXPANDED_WIDTH + 20, startWidth + delta))
dragWidth.value = newWidth
}
function onDragEnd() {
document.removeEventListener('mousemove', onDragMove)
document.removeEventListener('mouseup', onDragEnd)
document.body.style.cursor = ''
document.body.style.userSelect = ''
const shouldExpand = dragWidth.value >= SNAP_THRESHOLD
isExpanded.value = shouldExpand
localStorage.setItem(storageKey, String(shouldExpand))
isDragging.value = false
dragWidth.value = shouldExpand ? EXPANDED_WIDTH : COLLAPSED_WIDTH
}
function tooltipText(name: string, count?: number): string {
return count !== undefined ? `${name} (${count})` : name
}
function truncateLabel(text: string, max = 4): string {
return text.length > max ? text.slice(0, max) : text
}
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const orgStore = useOrganizationStore()
const selected = computed({
get: () => props.modelValue,
set: (v: string) => emit('update:modelValue', v)
})
const organizations = computed(() => orgStore.organizations || [])
const organizationsWithCount = computed(() => {
if (props.mode !== 'resource') return organizations.value
return organizations.value.filter((org) => (props.countByOrg?.[org.id] ?? 0) > 0)
})
function select(value: string) {
selected.value = value
}
function getOrgCount(orgId: string): number | undefined {
const n = props.countByOrg?.[orgId]
return n === undefined ? undefined : n
}
onMounted(() => {
orgStore.fetchOrganizations()
})
onBeforeUnmount(() => {
document.removeEventListener('mousemove', onDragMove)
document.removeEventListener('mouseup', onDragEnd)
})
</script>
<style scoped lang="less">
.list-space-sidebar {
width: 56px;
flex-shrink: 0;
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
z-index: 10;
transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1);
&.expanded {
width: 208px;
margin-right: 0;
}
&.dragging {
transition: none;
}
}
/* ========== Drag handle ========== */
.resize-handle {
position: absolute;
top: 0;
right: -6px;
bottom: 0;
width: 12px;
cursor: col-resize;
z-index: 12;
display: flex;
align-items: center;
justify-content: center;
&:hover .resize-handle-line,
.dragging & .resize-handle-line {
opacity: 1;
background: var(--td-brand-color);
}
}
.resize-handle-line {
width: 2px;
height: 40px;
border-radius: 1px;
background: var(--td-bg-color-component-disabled);
opacity: 0.45;
transition: opacity 0.2s ease, background 0.2s ease;
}
/* ========== Icon strip (collapsed) ========== */
.icon-strip {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: 56px;
padding: 16px 0 8px;
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.icon-item-labeled {
width: 46px;
padding: 6px 0 3px;
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
cursor: pointer;
color: var(--td-text-color-secondary);
transition: all 0.15s ease;
flex-shrink: 0;
&:hover {
background: var(--td-bg-color-secondarycontainer);
color: var(--td-text-color-primary);
}
&.active {
background: var(--td-success-color-light);
color: var(--td-brand-color);
&:hover {
background: var(--td-success-color-light);
}
.icon-label {
color: var(--td-brand-color);
font-weight: 520;
}
}
:deep(.space-avatar) {
width: 20px;
height: 20px;
font-size: 10px;
}
}
.icon-label {
font-size: 10px;
line-height: 1.2;
color: var(--td-text-color-secondary);
max-width: 44px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
transition: color 0.15s ease;
}
.icon-strip-divider {
width: 24px;
height: 1px;
background: var(--td-bg-color-secondarycontainer);
margin: 4px 0;
flex-shrink: 0;
}
/* ========== Expanded panel ========== */
.expanded-panel {
display: flex;
flex-direction: column;
gap: 2px;
padding: 16px 10px;
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none;
border-right: 1px solid var(--td-component-stroke);
&::-webkit-scrollbar {
display: none;
}
}
/* ========== Nav items inside expanded panel ========== */
.sidebar-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
border-radius: 7px;
color: var(--td-text-color-primary);
cursor: pointer;
transition: all 0.15s ease;
font-family: "PingFang SC", -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 14px;
-webkit-font-smoothing: antialiased;
.item-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.item-icon {
flex-shrink: 0;
color: var(--td-text-color-secondary);
font-size: 14px;
transition: color 0.15s ease;
}
.item-avatar {
flex-shrink: 0;
}
.item-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 430;
line-height: 1.4;
letter-spacing: 0.01em;
}
.item-count {
font-size: 12px;
color: var(--td-text-color-secondary);
font-weight: 500;
padding: 2px 7px;
border-radius: 8px;
background: var(--td-bg-color-secondarycontainer);
margin-left: 6px;
flex-shrink: 0;
transition: all 0.15s ease;
}
&:hover {
background: var(--td-bg-color-secondarycontainer);
color: var(--td-text-color-primary);
.item-icon {
color: var(--td-text-color-primary);
}
.item-count {
background: var(--td-bg-color-secondarycontainer);
color: var(--td-text-color-primary);
}
}
&.active {
background: var(--td-success-color-light);
color: var(--td-brand-color);
.item-icon {
color: var(--td-brand-color);
}
.item-label {
font-weight: 500;
}
.item-count {
background: var(--td-success-color-light);
color: var(--td-brand-color);
font-weight: 520;
}
&:hover {
background: var(--td-success-color-light);
}
}
}
.sidebar-section {
padding: 10px 8px 3px;
margin-top: 2px;
border-top: 1px solid var(--td-component-stroke);
.section-title {
font-size: 12px;
color: var(--td-text-color-secondary);
font-weight: 600;
line-height: 1.4;
}
}
</style>