ResourceSidebar.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. <script setup lang="ts">
  2. import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
  3. import AppIcon from '@/components/common/AppIcon.vue'
  4. import type { SidebarItem } from './types'
  5. const SIDEBAR_SEARCH_EVENT = 'hermes-yaoyao:sidebar-search'
  6. const SIDEBAR_SEARCH_CLOSE_EVENT = 'hermes-yaoyao:sidebar-search-close'
  7. const props = withDefaults(defineProps<{
  8. items: SidebarItem[]
  9. activeId?: string
  10. loading?: boolean
  11. searchable?: boolean
  12. externalSearch?: boolean
  13. search?: string
  14. searchPlaceholder?: string
  15. emptyTitle?: string
  16. emptyDescription?: string
  17. singleLine?: boolean
  18. hasMore?: boolean
  19. loadingMore?: boolean
  20. }>(), {
  21. activeId: '',
  22. loading: false,
  23. searchable: true,
  24. externalSearch: false,
  25. search: '',
  26. searchPlaceholder: '搜索',
  27. emptyTitle: '暂无内容',
  28. emptyDescription: '新建一项开始使用。',
  29. singleLine: false,
  30. hasMore: false,
  31. loadingMore: false,
  32. })
  33. const emit = defineEmits<{
  34. select: [id: string]
  35. search: [value: string]
  36. more: [id: string, event: MouseEvent]
  37. contextMenu: [id: string, event: MouseEvent]
  38. toggle: [id: string]
  39. loadMore: []
  40. }>()
  41. const searchInput = ref<HTMLInputElement | null>(null)
  42. const searchOpen = ref(Boolean(props.search))
  43. const sectionCounts = computed(() => props.items.reduce<Record<string, number>>((counts, item) => {
  44. if (item.section && !item.nested) counts[item.section] = (counts[item.section] ?? 0) + 1
  45. return counts
  46. }, {}))
  47. const sidebarRows = computed(() => props.items.map((item, index) => ({
  48. item,
  49. section: item.section && item.section !== props.items[index - 1]?.section ? item.section : '',
  50. sectionLabel: item.section === '已置顶' ? `已置顶 ${sectionCounts.value[item.section] ?? 0}` : item.section || '',
  51. })))
  52. async function focusSearch() {
  53. if (!props.searchable) return
  54. searchOpen.value = true
  55. await nextTick()
  56. const input = searchInput.value
  57. if (!input || input.offsetParent === null) return
  58. input.focus()
  59. input.select()
  60. }
  61. function handleSearchInput(event: Event) {
  62. const value = (event.target as HTMLInputElement).value
  63. emit('search', value)
  64. if (!value) searchOpen.value = false
  65. }
  66. function closeSearch(clear = false) {
  67. if (clear) emit('search', '')
  68. searchOpen.value = false
  69. document.dispatchEvent(new CustomEvent(SIDEBAR_SEARCH_CLOSE_EVENT))
  70. }
  71. function handleSearchRequest() { if (!props.externalSearch) void focusSearch() }
  72. function handleSearchCloseRequest() { searchOpen.value = false }
  73. function handleListScroll(event: Event) {
  74. const element = event.currentTarget as HTMLElement
  75. if (props.hasMore && !props.loadingMore && element.scrollHeight - element.scrollTop - element.clientHeight < 96) {
  76. emit('loadMore')
  77. }
  78. }
  79. watch(() => props.search, value => {
  80. if (value) searchOpen.value = true
  81. })
  82. onMounted(() => {
  83. document.addEventListener(SIDEBAR_SEARCH_EVENT, handleSearchRequest)
  84. document.addEventListener(SIDEBAR_SEARCH_CLOSE_EVENT, handleSearchCloseRequest)
  85. })
  86. onBeforeUnmount(() => {
  87. document.removeEventListener(SIDEBAR_SEARCH_EVENT, handleSearchRequest)
  88. document.removeEventListener(SIDEBAR_SEARCH_CLOSE_EVENT, handleSearchCloseRequest)
  89. })
  90. defineExpose({ focusSearch })
  91. </script>
  92. <template>
  93. <div class="resource-sidebar">
  94. <Transition name="search-reveal">
  95. <label v-if="searchable && !externalSearch && searchOpen" class="sidebar-search">
  96. <AppIcon name="search" :size="15" />
  97. <input
  98. ref="searchInput"
  99. :value="search"
  100. type="search"
  101. :placeholder="searchPlaceholder"
  102. @input="handleSearchInput"
  103. @keydown.esc.prevent="closeSearch(true)"
  104. @blur="!search && closeSearch()"
  105. />
  106. </label>
  107. </Transition>
  108. <div class="sidebar-list" role="listbox" aria-label="资源列表" @scroll.passive="handleListScroll">
  109. <div v-if="loading && !items.length" class="sidebar-loading" aria-label="正在加载">
  110. <span v-for="n in 7" :key="n" class="sidebar-skeleton" :style="{ opacity: 1 - n * .08 }" />
  111. </div>
  112. <template v-else>
  113. <template v-for="row in sidebarRows" :key="row.item.id">
  114. <div v-if="row.section" class="sidebar-section-label" :class="{ 'sidebar-section-label--pinned': row.section === '已置顶' }" role="presentation"><span>{{ row.sectionLabel }}</span></div>
  115. <div
  116. class="sidebar-item"
  117. :class="{ active: row.item.id === activeId || row.item.active, 'sidebar-item--single-line': singleLine, 'sidebar-item--nested': row.item.nested, 'sidebar-item--expandable': row.item.expandable }"
  118. role="option"
  119. tabindex="0"
  120. :aria-selected="row.item.id === activeId || row.item.active"
  121. :data-sidebar-id="row.item.id"
  122. @click="emit('select', row.item.id)"
  123. @contextmenu.prevent.stop="emit('contextMenu', row.item.id, $event)"
  124. @keydown.enter.prevent="emit('select', row.item.id)"
  125. @keydown.space.prevent="emit('select', row.item.id)"
  126. >
  127. <button
  128. v-if="row.item.expandable"
  129. class="sidebar-item__expand"
  130. :class="{ 'sidebar-item__expand--collapsed': !row.item.expanded }"
  131. type="button"
  132. :aria-label="`${row.item.expanded ? '收起' : '展开'} ${row.item.title} 的话题`"
  133. :aria-expanded="row.item.expanded"
  134. @click.stop="emit('toggle', row.item.id)"
  135. >
  136. <AppIcon name="chevron-down" :size="13" />
  137. </button>
  138. <span v-if="!singleLine" class="sidebar-item__icon" :class="{ 'sidebar-item__icon--avatar': !row.item.icon }">
  139. <AppIcon v-if="row.item.icon" :name="row.item.icon" :size="15" />
  140. <template v-else>{{ row.item.title.slice(0, 1).toUpperCase() }}</template>
  141. <span v-if="row.item.status" class="presence" :class="`presence--${row.item.status}`" />
  142. </span>
  143. <span class="sidebar-item__copy">
  144. <span class="sidebar-item__row">
  145. <strong><AppIcon v-if="row.item.pinned" name="pin-filled" :size="12" />{{ row.item.title }}</strong>
  146. <small v-if="row.item.meta">{{ row.item.meta }}</small>
  147. </span>
  148. <span v-if="!singleLine && row.item.subtitle" class="sidebar-item__row sidebar-item__row--secondary">
  149. <span>{{ row.item.subtitle }}</span>
  150. <b v-if="row.item.unread">{{ row.item.unread > 99 ? '99+' : row.item.unread }}</b>
  151. </span>
  152. </span>
  153. <button v-if="row.item.showMore !== false" class="sidebar-item__more" type="button" aria-label="更多操作" @click.stop="emit('more', row.item.id, $event)">
  154. <AppIcon name="dots" :size="16" />
  155. </button>
  156. </div>
  157. </template>
  158. </template>
  159. <button v-if="hasMore" class="sidebar-load-more" type="button" :disabled="loadingMore" @click="emit('loadMore')">
  160. {{ loadingMore ? '正在加载更多…' : '继续加载会话' }}
  161. </button>
  162. <div v-if="!loading && !items.length" class="sidebar-empty">
  163. <span><AppIcon name="chat" :size="20" /></span>
  164. <strong>{{ emptyTitle }}</strong>
  165. <p>{{ emptyDescription }}</p>
  166. </div>
  167. </div>
  168. </div>
  169. </template>
  170. <style scoped>
  171. .resource-sidebar { display: flex; height: 100%; min-height: 0; flex-direction: column; }
  172. .sidebar-search { display: flex; align-items: center; gap: 8px; min-height: 35px; margin: 0 11px 10px; padding: 0 10px; border: 1px solid var(--line); border-radius: 10px; color: var(--text-muted); background: var(--surface-soft); }
  173. .sidebar-search:focus-within { border-color: var(--line-strong); background: var(--surface-raised); box-shadow: 0 0 0 3px var(--focus-ring); }
  174. .sidebar-search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: var(--text-primary); font-size: 12px; }
  175. .sidebar-search input::placeholder { color: var(--text-muted); }
  176. .search-reveal-enter-active, .search-reveal-leave-active { transition: opacity 120ms ease, transform 120ms ease; }
  177. .search-reveal-enter-from, .search-reveal-leave-to { opacity: 0; transform: translateY(-3px); }
  178. .sidebar-list { min-height: 0; flex: 1; overflow: auto; padding: 0 7px 14px; scrollbar-gutter: stable; }
  179. .sidebar-section-label { display: flex; align-items: center; gap: 8px; min-height: 25px; padding: 7px 8px 3px; color: var(--text-muted); font-size: 9px; font-weight: 560; }
  180. .sidebar-section-label::after { height: 1px; flex: 1; background: var(--line); content: ''; }
  181. .sidebar-section-label--pinned { min-height: 28px; padding-top: 9px; color: var(--text-secondary); font-size: 10px; font-weight: 650; }
  182. .sidebar-section-label--pinned::after { background: transparent; }
  183. .sidebar-item { position: relative; display: flex; align-items: center; gap: 7px; width: 100%; min-height: 42px; padding: 4px 7px; border: 0; border-radius: 8px; background: transparent; color: var(--text-primary); cursor: pointer; text-align: left; transition: background-color 120ms ease, color 120ms ease; }
  184. .sidebar-item:hover { background: var(--surface-soft); }
  185. .sidebar-item.active { background: var(--surface-hover); }
  186. .sidebar-item--single-line { min-height: 31px; padding-block: 1px; }
  187. .sidebar-item--single-line .sidebar-item__copy { display: block; }
  188. .sidebar-item--single-line .sidebar-item__row { min-height: 27px; }
  189. .sidebar-item--single-line .sidebar-item__row strong { font-size: 11.5px; font-weight: 450; }
  190. .sidebar-item--single-line .sidebar-item__more { top: 1px; }
  191. .sidebar-item--nested { min-height: 32px; margin-left: 17px; width: calc(100% - 17px); padding-block: 1px; }
  192. .sidebar-item--nested .sidebar-item__icon { width: 19px; height: 19px; flex-basis: 19px; border-radius: 6px; color: var(--text-muted); }
  193. .sidebar-item--nested .sidebar-item__row { min-height: 25px; }
  194. .sidebar-item--nested .sidebar-item__row strong { font-size: 10.5px; font-weight: 500; }
  195. .sidebar-item--nested .sidebar-item__row--secondary { display: none; }
  196. .sidebar-item--nested .sidebar-item__more { display: none; }
  197. .sidebar-item__expand { display: grid; width: 18px; height: 24px; flex: 0 0 18px; place-items: center; padding: 0; border: 0; border-radius: 5px; background: transparent; color: var(--text-muted); cursor: pointer; }.sidebar-item__expand:hover, .sidebar-item__expand:focus-visible { outline: 0; background: var(--surface-hover); color: var(--text-primary); }.sidebar-item__expand :deep(.app-icon) { transition: transform 120ms ease; }.sidebar-item__expand--collapsed :deep(.app-icon) { transform: rotate(-90deg); }
  198. .sidebar-item__icon { position: relative; display: grid; place-items: center; width: 23px; height: 23px; flex: 0 0 23px; border: 0; border-radius: 7px; color: var(--text-muted); background: transparent; }
  199. .sidebar-item.active .sidebar-item__icon, .sidebar-item:hover .sidebar-item__icon { color: var(--text-secondary); }
  200. .sidebar-item__icon--avatar { background: var(--surface-hover); color: var(--text-secondary); font-size: 9px; font-weight: 700; }
  201. .presence { position: absolute; right: -2px; bottom: -2px; width: 8px; height: 8px; border: 2px solid var(--surface); border-radius: 50%; background: var(--text-muted); }
  202. .presence--online { background: var(--success); }.presence--working { background: var(--warning); }.presence--offline { background: var(--text-muted); }
  203. .sidebar-item__copy { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 1px; }
  204. .sidebar-item__row { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-width: 0; }
  205. .sidebar-item__row strong, .sidebar-item__row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  206. .sidebar-item__row strong { display: flex; min-width: 0; align-items: center; gap: 4px; font-size: 11.5px; font-weight: 570; }
  207. .sidebar-item__row strong :deep(.app-icon) { color: var(--text-muted); }
  208. .sidebar-item__row small { flex: 0 0 auto; color: var(--text-muted); font-size: 8.5px; font-variant-numeric: tabular-nums; }
  209. .sidebar-item__row--secondary { color: var(--text-muted); font-size: 9.5px; line-height: 13px; }
  210. .sidebar-item__row b { display: grid; place-items: center; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 999px; background: var(--accent); color: var(--text-on-solid); font-size: 8px; }
  211. .sidebar-item__more { display: none; position: absolute; right: 5px; top: 7px; place-items: center; width: 28px; height: 28px; padding: 0; border: 0; border-radius: 7px; background: var(--surface-raised); color: var(--text-secondary); cursor: pointer; box-shadow: 0 1px 5px rgba(0,0,0,.07); }
  212. .sidebar-item:hover .sidebar-item__more, .sidebar-item:focus-within .sidebar-item__more { display: grid; }
  213. .sidebar-load-more { display: block; width: calc(100% - 14px); min-height: 32px; margin: 8px 7px 2px; border: 0; border-radius: 8px; background: var(--surface-soft); color: var(--text-secondary); cursor: pointer; font: 10px var(--font-ui); }
  214. .sidebar-load-more:hover:not(:disabled) { background: var(--surface-hover); color: var(--text-primary); }.sidebar-load-more:disabled { cursor: progress; opacity: .7; }
  215. .sidebar-loading { display: flex; flex-direction: column; gap: 4px; padding: 2px; }
  216. .sidebar-skeleton { height: 38px; border-radius: 8px; background: linear-gradient(90deg, var(--surface-soft), var(--surface-hover), var(--surface-soft)); background-size: 200% 100%; animation: shimmer 1.5s linear infinite; }
  217. @keyframes shimmer { to { background-position: -200% 0; } }
  218. .sidebar-empty { display: grid; place-items: center; padding: 45px 22px; color: var(--text-muted); text-align: center; }
  219. .sidebar-empty > span { display: grid; place-items: center; width: 38px; height: 38px; margin-bottom: 10px; border-radius: 12px; background: var(--surface-soft); }
  220. .sidebar-empty strong { color: var(--text-secondary); font-size: 12px; }.sidebar-empty p { margin: 5px 0 0; font-size: 10px; line-height: 1.5; }
  221. @media (prefers-reduced-motion: reduce) { .sidebar-item, .search-reveal-enter-active, .search-reveal-leave-active { transition: none; } }
  222. </style>