| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381 |
- <template>
- <aside class="ai-sessions">
- <div class="ai-sessions__head">
- <a-input v-model:value="localKeyword" class="ai-sessions__search" allow-clear placeholder="搜索会话">
- <template #prefix><Icon icon="mdi:magnify" :size="14" /></template>
- </a-input>
- <a-button class="ai-sessions__new" type="primary" block @click="emit('create')">
- <template #icon><Icon icon="mdi:plus" :size="14" /></template>
- 新建对话
- </a-button>
- </div>
- <div class="ai-sessions__list ai-scroll">
- <div v-if="loading" class="ai-sessions__loading">
- <div v-for="n in 5" :key="n" class="ai-skeleton" :style="{ height: '38px' }"></div>
- </div>
- <template v-else>
- <section v-if="filteredPinned.length" class="ai-sessions__group">
- <div class="ai-sessions__group-title">已置顶</div>
- <SessionRow
- v-for="item in filteredPinned"
- :key="item.id"
- :session="item"
- :active="item.id === activeId"
- @select="emit('select', item.id)"
- @rename="startRename(item.id, item.title)"
- @pin="emit('pin', item.id)"
- @remove="emit('remove', item.id)"
- />
- </section>
- <section v-if="filteredNormal.length" class="ai-sessions__group">
- <div v-if="filteredPinned.length" class="ai-sessions__group-title">全部会话</div>
- <SessionRow
- v-for="item in filteredNormal"
- :key="item.id"
- :session="item"
- :active="item.id === activeId"
- @select="emit('select', item.id)"
- @rename="startRename(item.id, item.title)"
- @pin="emit('pin', item.id)"
- @remove="emit('remove', item.id)"
- />
- </section>
- <div v-if="!filteredPinned.length && !filteredNormal.length" class="ai-empty-tip">
- {{ keyword ? '没有匹配的会话' : '还没有会话,开始第一次提问吧' }}
- </div>
- </template>
- </div>
- <!-- 重命名:统一走弹窗,不再做行内编辑(行内输入在窄侧栏里不好点、也容易误触) -->
- <Modal
- v-model:open="renameVisible"
- title="重命名会话"
- :width="420"
- ok-text="保存"
- cancel-text="取消"
- :ok-button-props="{ disabled: !canRename }"
- @ok="handleRenameOk"
- @cancel="handleRenameCancel"
- >
- <a-input
- ref="renameInputRef"
- v-model:value="renameDraft"
- :maxlength="TITLE_MAX_LENGTH"
- allow-clear
- placeholder="请输入会话标题"
- @press-enter="handleRenameOk"
- />
- <div class="ai-rename__count">{{ renameDraft.length }} / {{ TITLE_MAX_LENGTH }}</div>
- </Modal>
- </aside>
- </template>
- <script lang="ts" setup>
- /**
- * 会话列表(侧栏)
- *
- * 搜索在前端做(会话量级为个人级,无需服务端分页),
- * 置顶会话单独成组,其余按最近消息时间排序。
- */
- import { computed, defineComponent, h, nextTick, ref, watch } from 'vue';
- import { Modal } from 'ant-design-vue';
- import Icon from '@/core/components/Icon/src/Icon.vue';
- import type { ChatSession } from '../api/types';
- import { fromNow, sessionTitle as resolveTitle } from '../utils/parseMessageContent';
- /** 单行会话;重命名由父级弹窗处理,这里只负责派发意图 */
- const SessionRow = defineComponent({
- name: 'AiSessionRow',
- props: {
- session: { type: Object as () => ChatSession, required: true },
- active: { type: Boolean, default: false },
- },
- emits: ['select', 'rename', 'pin', 'remove'],
- setup(props, { emit }) {
- return () => {
- const s = props.session;
- const title = resolveTitle(s);
- const meta = fromNow(s.lastMessageAt || s.updateAt || s.createAt);
- return h(
- 'div',
- {
- class: ['ai-session', { 'is-active': props.active }],
- onClick: () => emit('select'),
- },
- [
- h('div', { class: 'ai-session__main' }, [
- h('span', { class: 'ai-session__title', title }, title),
- h('div', { class: 'ai-session__meta' }, [
- s.messageCount ? `${s.messageCount} 条` : '',
- s.messageCount && meta ? ' · ' : '',
- meta,
- ]),
- ]),
- h('div', { class: 'ai-session__ops' }, [
- h(Icon, {
- icon: s.pinned ? 'mdi:pin' : 'mdi:pin-outline',
- size: 14,
- class: 'ai-session__op',
- title: s.pinned ? '取消置顶' : '置顶',
- onClick: (e: Event) => {
- e.stopPropagation();
- emit('pin');
- },
- }),
- h(Icon, {
- icon: 'mdi:pencil-outline',
- size: 14,
- class: 'ai-session__op',
- title: '重命名',
- onClick: (e: Event) => {
- e.stopPropagation();
- emit('rename');
- },
- }),
- h(Icon, {
- icon: 'mdi:trash-can-outline',
- size: 14,
- class: 'ai-session__op is-danger',
- title: '删除',
- onClick: (e: Event) => {
- e.stopPropagation();
- Modal.confirm({
- title: '删除会话',
- content: `确认删除「${title}」?该会话的消息记录将一并删除,且不可恢复。`,
- okText: '删除',
- okType: 'primary',
- okButtonProps: { danger: true },
- cancelText: '取消',
- onOk: () => emit('remove'),
- });
- },
- }),
- ]),
- ],
- );
- };
- },
- });
- const props = withDefaults(
- defineProps<{
- sessions: ChatSession[];
- /** 选中会话ID(雪花 ID 字符串) */
- activeId?: string | null;
- loading?: boolean;
- keyword?: string;
- }>(),
- { activeId: null, loading: false, keyword: '' },
- );
- const emit = defineEmits<{
- create: [];
- select: [id: string];
- rename: [id: string, title: string];
- pin: [id: string];
- remove: [id: string];
- }>();
- const localKeyword = ref(props.keyword);
- /* ---------------- 重命名弹窗 ---------------- */
- /** 会话标题列是 VARCHAR(128),前端按同长度硬限制 */
- const TITLE_MAX_LENGTH = 128;
- const renameVisible = ref(false);
- const renameTargetId = ref<string | null>(null);
- const renameDraft = ref('');
- const renameInputRef = ref<{ focus?: () => void; select?: () => void } | null>(null);
- const canRename = computed(() => renameDraft.value.trim().length > 0);
- watch(
- () => props.keyword,
- (v) => (localKeyword.value = v),
- );
- /**
- * 弹窗打开后自动聚焦并全选,方便直接覆盖输入。
- *
- * <p>ant-design-vue 4.2.6 的 Modal **没有** `afterOpenChange` 事件(只有 `afterClose`),
- * 写 `@after-open-change` 不会触发,所以这里自己用 watch + nextTick 控。
- */
- watch(renameVisible, async (open) => {
- if (!open) return;
- await nextTick();
- renameInputRef.value?.focus?.();
- renameInputRef.value?.select?.();
- });
- function match(s: ChatSession) {
- const kw = localKeyword.value.trim().toLowerCase();
- if (!kw) return true;
- return (s.title || '').toLowerCase().includes(kw);
- }
- const filteredPinned = computed(() => props.sessions.filter((s) => s.pinned).filter(match));
- const filteredNormal = computed(() => props.sessions.filter((s) => !s.pinned).filter(match));
- function startRename(id: string, title?: string) {
- renameTargetId.value = id;
- renameDraft.value = title || '';
- renameVisible.value = true;
- }
- function handleRenameOk() {
- const title = renameDraft.value.trim();
- const id = renameTargetId.value;
- if (!title || !id) return;
- renameVisible.value = false;
- renameTargetId.value = null;
- emit('rename', id, title);
- }
- function handleRenameCancel() {
- renameVisible.value = false;
- renameTargetId.value = null;
- }
- </script>
- <style lang="less" scoped>
- .ai-sessions {
- display: flex;
- flex-direction: column;
- height: 100%;
- min-height: 0;
- &__head {
- flex: 0 0 auto;
- display: flex;
- flex-direction: column;
- gap: var(--ai-sp-2);
- padding: var(--ai-sp-3);
- border-bottom: 1px solid var(--ai-border);
- }
- &__list {
- flex: 1 1 auto;
- min-height: 0;
- overflow-y: auto;
- padding: var(--ai-sp-2);
- }
- &__loading {
- display: flex;
- flex-direction: column;
- gap: var(--ai-sp-2);
- }
- &__group {
- margin-bottom: var(--ai-sp-3);
- }
- &__group-title {
- padding: var(--ai-sp-1) var(--ai-sp-2);
- font-size: 11px;
- font-weight: 600;
- letter-spacing: 0.06em;
- text-transform: uppercase;
- color: var(--ai-text-2);
- }
- }
- </style>
- <style lang="less">
- /* SessionRow 由 render 函数产出,样式需全局(内容在 scoped 内无法命中) */
- .ai-session {
- display: flex;
- align-items: center;
- gap: var(--ai-sp-2);
- padding: var(--ai-sp-2);
- border-radius: var(--ai-radius);
- cursor: pointer;
- transition: background 0.15s ease-out;
- &:hover {
- background: var(--ai-bg-hover);
- .ai-session__ops {
- opacity: 1;
- }
- }
- &.is-active {
- background: var(--ai-bg-active);
- .ai-session__title {
- color: var(--ai-primary);
- font-weight: 600;
- }
- }
- &__main {
- flex: 1 1 auto;
- min-width: 0;
- }
- &__title {
- display: block;
- font-size: 13px;
- line-height: 1.5;
- color: var(--ai-text);
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
- &__meta {
- margin-top: 1px;
- font-size: 11px;
- line-height: 1.4;
- color: var(--ai-text-2);
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- min-height: 15px;
- }
- &__ops {
- flex: 0 0 auto;
- display: flex;
- align-items: center;
- gap: 2px;
- opacity: 0;
- transition: opacity 0.15s ease-out;
- }
- &__op {
- padding: 2px;
- color: var(--ai-text-2);
- border-radius: var(--ai-radius-sm);
- cursor: pointer;
- &:hover {
- color: var(--ai-primary);
- background: var(--ai-primary-soft);
- }
- &.is-danger:hover {
- color: @error-color;
- background: fade(@error-color, 8%);
- }
- }
- }
- /*
- * 重命名弹窗的字数提示。
- * Modal 会被 teleport 到 body,scoped 样式命不中,所以放在全局块里。
- * 注:ant-design-vue 4.2.6 的 show-count 只在 TextArea 上实现,单行 Input 不渲染计数,故自行实现。
- */
- .ai-rename__count {
- margin-top: 6px;
- font-size: 12px;
- line-height: 1.4;
- color: var(--ai-text-2);
- text-align: right;
- }
- </style>
|