SessionOutline.vue 4.9 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667
  1. <script setup lang="ts">
  2. import { computed } from 'vue'
  3. import type { UiMessage } from './types'
  4. import { buildSessionOutline, type SessionOutlineItem } from '@/utils/sessionOutline'
  5. const props = defineProps<{ messages: UiMessage[]; hasOlder?: boolean }>()
  6. const emit = defineEmits<{ navigate: [target: { messageId: string; anchorId: string }] }>()
  7. const items = computed(() => buildSessionOutline(props.messages))
  8. function formatTime(value?: UiMessage['createdAt']) {
  9. if (!value) return ''
  10. const date = value instanceof Date ? value : new Date(value)
  11. if (Number.isNaN(date.getTime())) return ''
  12. return new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' }).format(date)
  13. }
  14. function navigate(item: SessionOutlineItem) {
  15. emit('navigate', { messageId: item.messageId, anchorId: item.anchorId })
  16. }
  17. </script>
  18. <template>
  19. <nav class="session-outline" aria-label="会话大纲">
  20. <header>
  21. <div><strong>会话大纲</strong><span>{{ items.length }} 项</span></div>
  22. <p>按问题与回答标题快速定位</p>
  23. </header>
  24. <div class="session-outline__content">
  25. <template v-if="items.length">
  26. <button
  27. v-for="item in items"
  28. :key="item.id"
  29. type="button"
  30. class="outline-item"
  31. :class="[`outline-item--${item.type}`, `outline-item--level-${item.level}`]"
  32. :aria-label="`跳转到 ${item.content}`"
  33. @click="navigate(item)"
  34. >
  35. <template v-if="item.type === 'user'">
  36. <b>Q</b><span>{{ item.content }}</span><time v-if="formatTime(item.createdAt)">{{ formatTime(item.createdAt) }}</time>
  37. </template>
  38. <template v-else>
  39. <i aria-hidden="true" /><span>{{ item.content }}</span>
  40. </template>
  41. </button>
  42. </template>
  43. <div v-else class="session-outline__empty">
  44. <strong>还没有大纲</strong>
  45. <p>发送问题,或让回答使用 Markdown 标题后会显示在这里。</p>
  46. </div>
  47. </div>
  48. <footer v-if="hasOlder">当前大纲基于已加载的消息</footer>
  49. </nav>
  50. </template>
  51. <style scoped>
  52. .session-outline { display: flex; height: 100%; min-height: 0; flex-direction: column; background: var(--surface); }
  53. .session-outline > header { flex: 0 0 auto; min-height: 60px; padding: 13px 48px 10px 15px; border-bottom: 1px solid var(--line); }.session-outline > header > div { display: flex; align-items: baseline; gap: 8px; }.session-outline > header strong { font-size: 13px; font-weight: 620; }.session-outline > header span { color: var(--text-muted); font-size: 9px; }.session-outline > header p { margin: 4px 0 0; color: var(--text-muted); font-size: 9px; }
  54. .session-outline__content { min-height: 0; flex: 1; padding: 10px; overflow-y: auto; }
  55. .outline-item { display: flex; width: 100%; min-width: 0; align-items: flex-start; gap: 7px; margin: 0 0 3px; padding: 6px 8px; border: 0; border-radius: 7px; background: transparent; color: var(--text-secondary); cursor: pointer; font: inherit; text-align: left; transition: background-color 100ms ease, color 100ms ease; }.outline-item:hover, .outline-item:focus-visible { outline: 0; background: var(--surface-soft); color: var(--text-primary); }.outline-item span { min-width: 0; overflow: hidden; font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; }.outline-item time { flex: 0 0 auto; margin-left: auto; color: var(--text-muted); font-size: 8px; font-variant-numeric: tabular-nums; }
  56. .outline-item--user { margin: 6px 0; padding: 8px 9px; border-radius: 9px; background: var(--surface-soft); color: var(--text-primary); }.outline-item--user:first-child { margin-top: 0; }.outline-item--user b { display: grid; width: 19px; height: 19px; flex: 0 0 19px; place-items: center; border-radius: 6px; background: var(--surface-raised); color: var(--text-secondary); font-size: 9px; font-weight: 650; }.outline-item--user span { font-size: 11.5px; font-weight: 520; }
  57. .outline-item--heading i { width: 4px; height: 4px; flex: 0 0 4px; margin-top: 6px; border-radius: 50%; background: currentColor; opacity: .45; }.outline-item--level-1 { padding-left: 10px; color: var(--text-primary); }.outline-item--level-1 span { font-weight: 560; }.outline-item--level-2 { padding-left: 22px; }.outline-item--level-3 { padding-left: 34px; color: var(--text-muted); }.outline-item--level-3 span { font-size: 10.5px; }
  58. .session-outline__empty { display: flex; min-height: 220px; align-items: center; justify-content: center; padding: 24px; flex-direction: column; color: var(--text-muted); text-align: center; }.session-outline__empty strong { color: var(--text-secondary); font-size: 12px; }.session-outline__empty p { max-width: 220px; margin: 6px 0 0; font-size: 10px; line-height: 1.55; }
  59. .session-outline > footer { flex: 0 0 auto; padding: 8px 12px; border-top: 1px solid var(--line); color: var(--text-muted); font-size: 8px; text-align: center; }
  60. @media (prefers-reduced-motion: reduce) { .outline-item { transition: none; } }
  61. </style>