From fe88d99fc6be71bf45f5911ef8ef3e0205ac2928 Mon Sep 17 00:00:00 2001 From: wangdan-fit2cloud Date: Mon, 28 Sep 2026 19:33:23 +0800 Subject: [PATCH] feat: conversation siderbar left --- .../components/global/mk-collapse/index.vue | 2 +- .../components/global/mk-form-list/index.vue | 2 +- .../global/mk-table/mk-table-filter/index.vue | 2 +- ui/src/components/mk-drag-upload/index.vue | 2 +- .../components/message-input/index.ts | 9 +- .../conversation-panel/core/layout/index.vue | 86 +---- ui/src/conversation-panel/index.scss | 83 ++++ .../conversation-list/AccountMenu.vue | 84 +++++ .../left-sidebar/conversation-list/index.ts | 77 ++-- .../left-sidebar/conversation-list/index.vue | 355 ++++-------------- .../right-sidebar/execution-detail/index.vue | 2 +- ui/src/conversation-panel/view/chat/index.vue | 10 +- .../conversation-panel/view/debug/index.vue | 10 +- ui/src/layout/avatar-dropdown/index.vue | 4 +- ui/src/styles/STYLE_README.md | 9 +- ui/src/styles/variables.scss | 1 + ui/src/utils/UTILS_README.md | 8 + ui/src/utils/time.ts | 17 + .../AdvancedCreateDialog.vue | 2 +- .../views/home/components/HomeStatistics.vue | 1 - .../chat-management/portal-setting/index.vue | 6 +- .../chat-management/users/UserFromDrawer.vue | 2 +- .../system/identity/users/UserFromDrawer.vue | 2 +- .../tool/tool-form/SkillToolFormDrawer.vue | 2 +- .../trigger-form/TriggerFormDrawer.vue | 6 +- .../workflow-canvas/component/NodeControl.vue | 14 +- .../workflow-canvas/component/NodeSearch.vue | 2 +- 27 files changed, 350 insertions(+), 450 deletions(-) create mode 100644 ui/src/conversation-panel/left-sidebar/conversation-list/AccountMenu.vue diff --git a/ui/src/components/global/mk-collapse/index.vue b/ui/src/components/global/mk-collapse/index.vue index 278ea482c6b..a4a8b82b42e 100644 --- a/ui/src/components/global/mk-collapse/index.vue +++ b/ui/src/components/global/mk-collapse/index.vue @@ -49,7 +49,7 @@ const expanded = computed({ class="transition-transform text-N600!" :class="{ '-rotate-90': !expanded }" /> - + {{ title }} diff --git a/ui/src/components/global/mk-form-list/index.vue b/ui/src/components/global/mk-form-list/index.vue index 0fa6977fb40..3be9fec0a1c 100644 --- a/ui/src/components/global/mk-form-list/index.vue +++ b/ui/src/components/global/mk-form-list/index.vue @@ -86,7 +86,7 @@ function removeRow(index: number) { - + diff --git a/ui/src/components/global/mk-table/mk-table-filter/index.vue b/ui/src/components/global/mk-table/mk-table-filter/index.vue index 4f1e0a1c0e1..b5d2789d92a 100644 --- a/ui/src/components/global/mk-table/mk-table-filter/index.vue +++ b/ui/src/components/global/mk-table/mk-table-filter/index.vue @@ -85,7 +85,7 @@ function handleSelect(value: TableFilterValue) { {{ label }} - + diff --git a/ui/src/components/mk-drag-upload/index.vue b/ui/src/components/mk-drag-upload/index.vue index 35f92d5c566..555b78dbd6b 100644 --- a/ui/src/components/mk-drag-upload/index.vue +++ b/ui/src/components/mk-drag-upload/index.vue @@ -58,7 +58,7 @@ defineExpose({ clearFiles }) - + diff --git a/ui/src/conversation-panel/components/message-input/index.ts b/ui/src/conversation-panel/components/message-input/index.ts index 47babd83b1f..42f18cc6532 100644 --- a/ui/src/conversation-panel/components/message-input/index.ts +++ b/ui/src/conversation-panel/components/message-input/index.ts @@ -127,7 +127,14 @@ export function createMessageInputStore(deps: MessageInputDeps) { const isFirstMessage = messages.value.length === 0 const abstract = questionText.substring(0, 256) if (isFirstMessage && !conversations.value.some((conversation) => conversation.id === chatId)) { - conversations.value.unshift({ id: chatId, abstract: questionText ? abstract : '新对话' }) + // 本地新会话尚未由历史接口返回,使用首次发送时间展示相对时间。 + const createTime = new Date().toISOString() + conversations.value.unshift({ + id: chatId, + abstract: questionText ? abstract : '新对话', + create_time: createTime, + update_time: createTime, + }) } // 一次遍历完成附件分类,仅提交有附件的字段。 diff --git a/ui/src/conversation-panel/core/layout/index.vue b/ui/src/conversation-panel/core/layout/index.vue index 508881d56f1..11bdba2bfd3 100644 --- a/ui/src/conversation-panel/core/layout/index.vue +++ b/ui/src/conversation-panel/core/layout/index.vue @@ -14,8 +14,8 @@ const props = withDefaults( { leftMode: 'push', rightMode: 'push', - leftWidth: 260, - rightWidth: 320, + leftWidth: 280, + rightWidth: 420, }, ) @@ -31,97 +31,25 @@ const showMask = computed(() => (props.leftMode === 'drawer' && props.leftOpen)
- diff --git a/ui/src/conversation-panel/index.scss b/ui/src/conversation-panel/index.scss index 582acd82cb9..929c9429640 100644 --- a/ui/src/conversation-panel/index.scss +++ b/ui/src/conversation-panel/index.scss @@ -12,6 +12,89 @@ position: relative; } +/* 对话左右两侧 */ +.mk-conversation-side-panel { + /* 固定侧栏内容宽度,避免展开和收起时内容变形。 */ + &__inner { + height: 100%; + width: var(--mk-conversation-side-panel-width); + } + + /* 方向只负责宽度来源和外观,内容与展开模式共用同一宽度。 */ + &.left { + --mk-conversation-side-panel-width: var(--mk-chat-sidebar-width); + + background: color-mix(in srgb, var(--mk-primary) 5%, var(--mk-N100)); + } + + &.right { + background: white; + --mk-conversation-side-panel-width: var(--mk-chat-right-width); + } + + /* 抽屉侧栏覆盖主区,层级高于遮罩。 */ + &.drawer { + bottom: 0; + position: absolute; + top: 0; + transition: + transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), + box-shadow 0.25s; + width: var(--mk-conversation-side-panel-width); + z-index: 40; + + &.left { + left: 0; + + &:not(.open) { + transform: translateX(-100%); + } + } + + &.open { + box-shadow: var(--mk-box-shadow-lg); + transform: translateX(0); + } + + &.right { + right: 0; + + &:not(.open) { + transform: translateX(100%); + } + } + } + /* 挤压侧栏通过宽度动画参与布局。 */ + &.push { + min-width: 0; + transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1); + width: 0; + + &.open { + width: var(--mk-conversation-side-panel-width); + } + } + /* 抽屉保留分隔线,挤压模式仅展开时显示。 */ + &.drawer, + &.push.open { + &.left { + border-right: 1px solid var(--mk-N350); + } + + &.right { + border-left: 1px solid var(--mk-N350); + } + } +} + +/* 对话主抽屉遮罩层 */ +.mk-conversation-mask { + position: absolute; + inset: 0; + z-index: 30; + background: var(--el-overlay-color-lighter); +} + /* 对话标题栏:在中性底色上叠加浅主题色。 */ .mk-conversation-header { background: color-mix(in srgb, var(--mk-primary) 5%, var(--mk-N100)); diff --git a/ui/src/conversation-panel/left-sidebar/conversation-list/AccountMenu.vue b/ui/src/conversation-panel/left-sidebar/conversation-list/AccountMenu.vue new file mode 100644 index 00000000000..3c20a5fe24d --- /dev/null +++ b/ui/src/conversation-panel/left-sidebar/conversation-list/AccountMenu.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/ui/src/conversation-panel/left-sidebar/conversation-list/index.ts b/ui/src/conversation-panel/left-sidebar/conversation-list/index.ts index 6456fb6d8d7..7cc56600f9d 100644 --- a/ui/src/conversation-panel/left-sidebar/conversation-list/index.ts +++ b/ui/src/conversation-panel/left-sidebar/conversation-list/index.ts @@ -1,69 +1,69 @@ -import { ref, computed, inject, type InjectionKey } from 'vue' +import { computed, inject, ref, type InjectionKey } from 'vue' import { v4 as uuidv4 } from 'uuid' import type { Conversation } from '../../core/types' -// 只声明本组件真正用到的依赖(接口隔离到函数级) +// 由 Chat / Debug 入口注入本组件需要的会话接口。 export interface ConversationListDeps { - pageConversations: (page: number, size: number) => Promise - remove: (id: string) => Promise - rename: (id: string, data: { abstract: string }) => Promise + pageConversations: (page: number, size: number) => Promise<{ records?: Conversation[] } | null | undefined> + remove: (id: string) => Promise + rename: (id: string, data: { abstract: string }) => Promise } -/** - * conversation-list 组件 store:本组件独有——会话列表 + 当前会话 + 应用信息 + 左侧开合。 - */ +/** 管理左侧会话列表、当前会话和侧栏展示状态。 */ export function createConversationListStore(deps: ConversationListDeps) { const { pageConversations, remove, rename } = deps - // 切换会话时主动驱动 message-list 加载。延迟绑定(view 里 bindLoader(msgs.load)), - // 以打破「list 需要 msgs.load、msgs 需要 list 的 currentChatId」的实例化环。 - let loadMessages: () => void = () => {} - const bindLoader = (fn: () => void) => (loadMessages = fn) - - const conversations = ref([]) - const currentChatId = ref('') + // 应用信息与侧栏开合 const appInfo = ref<{ name: string; icon: string } | null>(null) const leftSideOpen = ref(true) - // composer 重置信号:新建/切换会话时自增,message-input 监听后清空暂存文件与输入 - const composerResetSignal = ref(0) - const currentConversation = computed( - () => conversations.value.find((c) => c.id === currentChatId.value) || null, - ) + const toggleLeftSide = () => (leftSideOpen.value = !leftSideOpen.value) - // 列表分页 + // 历史会话分页 + const conversations = ref([]) const pageSize = 50 - const page = ref(1) + const currentPage = ref(1) const hasMore = ref(true) - const loadConversations = async (p = 1) => { + const loadConversations = async (page = 1) => { try { - const res = await pageConversations(p, pageSize) - const records = res?.records || [] - conversations.value = p === 1 ? records : [...conversations.value, ...records] - page.value = p + const response = await pageConversations(page, pageSize) + const records = response?.records || [] + conversations.value = page === 1 ? records : [...conversations.value, ...records] + currentPage.value = page hasMore.value = records.length >= pageSize - } catch (e) { - // 历史接口在部分模式下可能不存在,静默处理 + } catch { + // 部分模式可能不提供历史接口,加载失败时保留现有列表和分页状态。 } } + const loadMore = async () => { if (!hasMore.value) return - await loadConversations(page.value + 1) + await loadConversations(currentPage.value + 1) } + // 当前会话与输入区重置 + const currentChatId = ref('') + const currentConversation = computed(() => conversations.value.find((conversation) => conversation.id === currentChatId.value) || null) + // 新建或切换会话时递增,由 message-input 监听并清空暂存文件与输入。 + const composerResetSignal = ref(0) + + // View 在两个 Store 创建后绑定加载方法,避免与 message-list 循环初始化。 + let loadMessages: () => void = () => {} + const bindLoader = (loader: () => void) => (loadMessages = loader) + const resetComposer = () => { composerResetSignal.value++ } - // 惰性取 chat_id:已有原样返回,为空才前端生成(幂等,保证上传文件 source_id 与发送一致) + // 仅在需要时生成会话 ID,保证上传文件的 source_id 与发送消息使用同一 ID。 const getChatId = () => { if (currentChatId.value) return currentChatId.value currentChatId.value = uuidv4() return currentChatId.value } - // 先写入单一数据源 currentChatId,再驱动 message-list 加载(load 内部读 currentChatId) + // 消息加载会读取 currentChatId,必须先更新 ID 再触发加载。 const selectConversation = (id: string) => { resetComposer() currentChatId.value = id @@ -76,10 +76,12 @@ export function createConversationListStore(deps: ConversationListDeps) { loadMessages() } + // 会话维护:请求成功后更新本地列表。 const deleteChat = async (id: string) => { await remove(id) - const idx = conversations.value.findIndex((c) => c.id === id) - if (idx >= 0) conversations.value.splice(idx, 1) + const conversationIndex = conversations.value.findIndex((conversation) => conversation.id === id) + if (conversationIndex >= 0) conversations.value.splice(conversationIndex, 1) + if (currentChatId.value === id) { currentChatId.value = conversations.value[0]?.id || '' loadMessages() @@ -88,8 +90,8 @@ export function createConversationListStore(deps: ConversationListDeps) { const renameChat = async (id: string, name: string) => { await rename(id, { abstract: name }) - const c = conversations.value.find((x) => x.id === id) - if (c) c.abstract = name + const conversation = conversations.value.find((conversation) => conversation.id === id) + if (conversation) conversation.abstract = name } return { @@ -109,12 +111,13 @@ export function createConversationListStore(deps: ConversationListDeps) { newConversation, deleteChat, renameChat, - toggleLeftSide: () => (leftSideOpen.value = !leftSideOpen.value), + toggleLeftSide, } } export type ConversationListStore = ReturnType +// 会话视图内共享 Store export const CONVERSATION_LIST_KEY: InjectionKey = Symbol('conversation-list') export function useConversationListStore(): ConversationListStore { diff --git a/ui/src/conversation-panel/left-sidebar/conversation-list/index.vue b/ui/src/conversation-panel/left-sidebar/conversation-list/index.vue index 05250526102..b4fe1c7164e 100644 --- a/ui/src/conversation-panel/left-sidebar/conversation-list/index.vue +++ b/ui/src/conversation-panel/left-sidebar/conversation-list/index.vue @@ -1,297 +1,76 @@ - - - + + +
暂无历史记录
+
+ +
+
+ +
+ +
+
+ diff --git a/ui/src/conversation-panel/right-sidebar/execution-detail/index.vue b/ui/src/conversation-panel/right-sidebar/execution-detail/index.vue index 26f47b5c1c2..4ebedbfc04a 100644 --- a/ui/src/conversation-panel/right-sidebar/execution-detail/index.vue +++ b/ui/src/conversation-panel/right-sidebar/execution-detail/index.vue @@ -5,7 +5,7 @@ 执行详情
diff --git a/ui/src/conversation-panel/view/chat/index.vue b/ui/src/conversation-panel/view/chat/index.vue index 3870044a364..e7764bd712f 100644 --- a/ui/src/conversation-panel/view/chat/index.vue +++ b/ui/src/conversation-panel/view/chat/index.vue @@ -56,12 +56,10 @@ onBeforeUnmount(() => { }) watch(isMobile, (m) => (bundle.list.leftSideOpen.value = !m)) -watch( - () => bundle.list.currentChatId.value, - () => { - if (isMobile.value) bundle.list.leftSideOpen.value = false - }, -) +// 新建或重新选择会话时,即使会话 ID 不变也收起左侧抽屉。 +watch([() => bundle.list.currentChatId.value, () => bundle.list.composerResetSignal.value], () => { + if (leftMode.value === 'drawer') bundle.list.leftSideOpen.value = false +})