SessionList.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. <template>
  2. <aside class="ai-sessions">
  3. <div class="ai-sessions__head">
  4. <a-input v-model:value="localKeyword" class="ai-sessions__search" allow-clear placeholder="搜索会话">
  5. <template #prefix><Icon icon="mdi:magnify" :size="14" /></template>
  6. </a-input>
  7. <a-button class="ai-sessions__new" type="primary" block @click="emit('create')">
  8. <template #icon><Icon icon="mdi:plus" :size="14" /></template>
  9. 新建对话
  10. </a-button>
  11. </div>
  12. <div class="ai-sessions__list ai-scroll">
  13. <div v-if="loading" class="ai-sessions__loading">
  14. <div v-for="n in 5" :key="n" class="ai-skeleton" :style="{ height: '38px' }"></div>
  15. </div>
  16. <template v-else>
  17. <section v-if="filteredPinned.length" class="ai-sessions__group">
  18. <div class="ai-sessions__group-title">已置顶</div>
  19. <SessionRow
  20. v-for="item in filteredPinned"
  21. :key="item.id"
  22. :session="item"
  23. :active="item.id === activeId"
  24. @select="emit('select', item.id)"
  25. @rename="startRename(item.id, item.title)"
  26. @pin="emit('pin', item.id)"
  27. @remove="emit('remove', item.id)"
  28. />
  29. </section>
  30. <section v-if="filteredNormal.length" class="ai-sessions__group">
  31. <div v-if="filteredPinned.length" class="ai-sessions__group-title">全部会话</div>
  32. <SessionRow
  33. v-for="item in filteredNormal"
  34. :key="item.id"
  35. :session="item"
  36. :active="item.id === activeId"
  37. @select="emit('select', item.id)"
  38. @rename="startRename(item.id, item.title)"
  39. @pin="emit('pin', item.id)"
  40. @remove="emit('remove', item.id)"
  41. />
  42. </section>
  43. <div v-if="!filteredPinned.length && !filteredNormal.length" class="ai-empty-tip">
  44. {{ keyword ? '没有匹配的会话' : '还没有会话,开始第一次提问吧' }}
  45. </div>
  46. </template>
  47. </div>
  48. <!-- 重命名:统一走弹窗,不再做行内编辑(行内输入在窄侧栏里不好点、也容易误触) -->
  49. <Modal
  50. v-model:open="renameVisible"
  51. title="重命名会话"
  52. :width="420"
  53. ok-text="保存"
  54. cancel-text="取消"
  55. :ok-button-props="{ disabled: !canRename }"
  56. @ok="handleRenameOk"
  57. @cancel="handleRenameCancel"
  58. >
  59. <a-input
  60. ref="renameInputRef"
  61. v-model:value="renameDraft"
  62. :maxlength="TITLE_MAX_LENGTH"
  63. allow-clear
  64. placeholder="请输入会话标题"
  65. @press-enter="handleRenameOk"
  66. />
  67. <div class="ai-rename__count">{{ renameDraft.length }} / {{ TITLE_MAX_LENGTH }}</div>
  68. </Modal>
  69. </aside>
  70. </template>
  71. <script lang="ts" setup>
  72. /**
  73. * 会话列表(侧栏)
  74. *
  75. * 搜索在前端做(会话量级为个人级,无需服务端分页),
  76. * 置顶会话单独成组,其余按最近消息时间排序。
  77. */
  78. import { computed, defineComponent, h, nextTick, ref, watch } from 'vue';
  79. import { Modal } from 'ant-design-vue';
  80. import Icon from '@/core/components/Icon/src/Icon.vue';
  81. import type { ChatSession } from '../api/types';
  82. import { fromNow, sessionTitle as resolveTitle } from '../utils/parseMessageContent';
  83. /** 单行会话;重命名由父级弹窗处理,这里只负责派发意图 */
  84. const SessionRow = defineComponent({
  85. name: 'AiSessionRow',
  86. props: {
  87. session: { type: Object as () => ChatSession, required: true },
  88. active: { type: Boolean, default: false },
  89. },
  90. emits: ['select', 'rename', 'pin', 'remove'],
  91. setup(props, { emit }) {
  92. return () => {
  93. const s = props.session;
  94. const title = resolveTitle(s);
  95. const meta = fromNow(s.lastMessageAt || s.updateAt || s.createAt);
  96. return h(
  97. 'div',
  98. {
  99. class: ['ai-session', { 'is-active': props.active }],
  100. onClick: () => emit('select'),
  101. },
  102. [
  103. h('div', { class: 'ai-session__main' }, [
  104. h('span', { class: 'ai-session__title', title }, title),
  105. h('div', { class: 'ai-session__meta' }, [
  106. s.messageCount ? `${s.messageCount} 条` : '',
  107. s.messageCount && meta ? ' · ' : '',
  108. meta,
  109. ]),
  110. ]),
  111. h('div', { class: 'ai-session__ops' }, [
  112. h(Icon, {
  113. icon: s.pinned ? 'mdi:pin' : 'mdi:pin-outline',
  114. size: 14,
  115. class: 'ai-session__op',
  116. title: s.pinned ? '取消置顶' : '置顶',
  117. onClick: (e: Event) => {
  118. e.stopPropagation();
  119. emit('pin');
  120. },
  121. }),
  122. h(Icon, {
  123. icon: 'mdi:pencil-outline',
  124. size: 14,
  125. class: 'ai-session__op',
  126. title: '重命名',
  127. onClick: (e: Event) => {
  128. e.stopPropagation();
  129. emit('rename');
  130. },
  131. }),
  132. h(Icon, {
  133. icon: 'mdi:trash-can-outline',
  134. size: 14,
  135. class: 'ai-session__op is-danger',
  136. title: '删除',
  137. onClick: (e: Event) => {
  138. e.stopPropagation();
  139. Modal.confirm({
  140. title: '删除会话',
  141. content: `确认删除「${title}」?该会话的消息记录将一并删除,且不可恢复。`,
  142. okText: '删除',
  143. okType: 'primary',
  144. okButtonProps: { danger: true },
  145. cancelText: '取消',
  146. onOk: () => emit('remove'),
  147. });
  148. },
  149. }),
  150. ]),
  151. ],
  152. );
  153. };
  154. },
  155. });
  156. const props = withDefaults(
  157. defineProps<{
  158. sessions: ChatSession[];
  159. /** 选中会话ID(雪花 ID 字符串) */
  160. activeId?: string | null;
  161. loading?: boolean;
  162. keyword?: string;
  163. }>(),
  164. { activeId: null, loading: false, keyword: '' },
  165. );
  166. const emit = defineEmits<{
  167. create: [];
  168. select: [id: string];
  169. rename: [id: string, title: string];
  170. pin: [id: string];
  171. remove: [id: string];
  172. }>();
  173. const localKeyword = ref(props.keyword);
  174. /* ---------------- 重命名弹窗 ---------------- */
  175. /** 会话标题列是 VARCHAR(128),前端按同长度硬限制 */
  176. const TITLE_MAX_LENGTH = 128;
  177. const renameVisible = ref(false);
  178. const renameTargetId = ref<string | null>(null);
  179. const renameDraft = ref('');
  180. const renameInputRef = ref<{ focus?: () => void; select?: () => void } | null>(null);
  181. const canRename = computed(() => renameDraft.value.trim().length > 0);
  182. watch(
  183. () => props.keyword,
  184. (v) => (localKeyword.value = v),
  185. );
  186. /**
  187. * 弹窗打开后自动聚焦并全选,方便直接覆盖输入。
  188. *
  189. * <p>ant-design-vue 4.2.6 的 Modal **没有** `afterOpenChange` 事件(只有 `afterClose`),
  190. * 写 `@after-open-change` 不会触发,所以这里自己用 watch + nextTick 控。
  191. */
  192. watch(renameVisible, async (open) => {
  193. if (!open) return;
  194. await nextTick();
  195. renameInputRef.value?.focus?.();
  196. renameInputRef.value?.select?.();
  197. });
  198. function match(s: ChatSession) {
  199. const kw = localKeyword.value.trim().toLowerCase();
  200. if (!kw) return true;
  201. return (s.title || '').toLowerCase().includes(kw);
  202. }
  203. const filteredPinned = computed(() => props.sessions.filter((s) => s.pinned).filter(match));
  204. const filteredNormal = computed(() => props.sessions.filter((s) => !s.pinned).filter(match));
  205. function startRename(id: string, title?: string) {
  206. renameTargetId.value = id;
  207. renameDraft.value = title || '';
  208. renameVisible.value = true;
  209. }
  210. function handleRenameOk() {
  211. const title = renameDraft.value.trim();
  212. const id = renameTargetId.value;
  213. if (!title || !id) return;
  214. renameVisible.value = false;
  215. renameTargetId.value = null;
  216. emit('rename', id, title);
  217. }
  218. function handleRenameCancel() {
  219. renameVisible.value = false;
  220. renameTargetId.value = null;
  221. }
  222. </script>
  223. <style lang="less" scoped>
  224. .ai-sessions {
  225. display: flex;
  226. flex-direction: column;
  227. height: 100%;
  228. min-height: 0;
  229. &__head {
  230. flex: 0 0 auto;
  231. display: flex;
  232. flex-direction: column;
  233. gap: var(--ai-sp-2);
  234. padding: var(--ai-sp-3);
  235. border-bottom: 1px solid var(--ai-border);
  236. }
  237. &__list {
  238. flex: 1 1 auto;
  239. min-height: 0;
  240. overflow-y: auto;
  241. padding: var(--ai-sp-2);
  242. }
  243. &__loading {
  244. display: flex;
  245. flex-direction: column;
  246. gap: var(--ai-sp-2);
  247. }
  248. &__group {
  249. margin-bottom: var(--ai-sp-3);
  250. }
  251. &__group-title {
  252. padding: var(--ai-sp-1) var(--ai-sp-2);
  253. font-size: 11px;
  254. font-weight: 600;
  255. letter-spacing: 0.06em;
  256. text-transform: uppercase;
  257. color: var(--ai-text-2);
  258. }
  259. }
  260. </style>
  261. <style lang="less">
  262. /* SessionRow 由 render 函数产出,样式需全局(内容在 scoped 内无法命中) */
  263. .ai-session {
  264. display: flex;
  265. align-items: center;
  266. gap: var(--ai-sp-2);
  267. padding: var(--ai-sp-2);
  268. border-radius: var(--ai-radius);
  269. cursor: pointer;
  270. transition: background 0.15s ease-out;
  271. &:hover {
  272. background: var(--ai-bg-hover);
  273. .ai-session__ops {
  274. opacity: 1;
  275. }
  276. }
  277. &.is-active {
  278. background: var(--ai-bg-active);
  279. .ai-session__title {
  280. color: var(--ai-primary);
  281. font-weight: 600;
  282. }
  283. }
  284. &__main {
  285. flex: 1 1 auto;
  286. min-width: 0;
  287. }
  288. &__title {
  289. display: block;
  290. font-size: 13px;
  291. line-height: 1.5;
  292. color: var(--ai-text);
  293. overflow: hidden;
  294. white-space: nowrap;
  295. text-overflow: ellipsis;
  296. }
  297. &__meta {
  298. margin-top: 1px;
  299. font-size: 11px;
  300. line-height: 1.4;
  301. color: var(--ai-text-2);
  302. overflow: hidden;
  303. white-space: nowrap;
  304. text-overflow: ellipsis;
  305. min-height: 15px;
  306. }
  307. &__ops {
  308. flex: 0 0 auto;
  309. display: flex;
  310. align-items: center;
  311. gap: 2px;
  312. opacity: 0;
  313. transition: opacity 0.15s ease-out;
  314. }
  315. &__op {
  316. padding: 2px;
  317. color: var(--ai-text-2);
  318. border-radius: var(--ai-radius-sm);
  319. cursor: pointer;
  320. &:hover {
  321. color: var(--ai-primary);
  322. background: var(--ai-primary-soft);
  323. }
  324. &.is-danger:hover {
  325. color: @error-color;
  326. background: fade(@error-color, 8%);
  327. }
  328. }
  329. }
  330. /*
  331. * 重命名弹窗的字数提示。
  332. * Modal 会被 teleport 到 body,scoped 样式命不中,所以放在全局块里。
  333. * 注:ant-design-vue 4.2.6 的 show-count 只在 TextArea 上实现,单行 Input 不渲染计数,故自行实现。
  334. */
  335. .ai-rename__count {
  336. margin-top: 6px;
  337. font-size: 12px;
  338. line-height: 1.4;
  339. color: var(--ai-text-2);
  340. text-align: right;
  341. }
  342. </style>