Skip to content

Commit

Permalink
Merge pull request #506 from sugarforever/feat/chatResponsive
Browse files Browse the repository at this point in the history
聊天界面适配移动端以及会话列表布局调整
  • Loading branch information
sugarforever authored Jun 3, 2024
2 parents a42ba32 + 36d4f4c commit 8d0cf33
Show file tree
Hide file tree
Showing 15 changed files with 1,156 additions and 932 deletions.
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
"groq",
"jina",
"knowledgebase",
"nuxt"
"nuxt",
"Slideover"
],
"i18n-ally.localesPaths": [
"locales"
Expand Down
1 change: 1 addition & 0 deletions app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<USlideovers />
<UModals />
</div>
</template>
2 changes: 2 additions & 0 deletions assets/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ body {
samp {
font-family: inherit;
}

--top-height: 48px;
}

.md-body {
Expand Down
8 changes: 5 additions & 3 deletions components/Chat.vue
Original file line number Diff line number Diff line change
Expand Up @@ -275,14 +275,16 @@ async function saveMessage(data: Omit<ChatHistory, 'sessionId'>) {
</script>

<template>
<div class="flex flex-col box-border dark:text-gray-300 -mx-4">
<div class="flex flex-col box-border dark:text-gray-300 md:-mx-4">
<div class="px-4 border-b border-gray-200 dark:border-gray-700 box-border h-[57px] flex items-center">
<slot name="left-menu-btn"></slot>
<ChatConfigInfo v-if="instructionInfo" icon="i-iconoir-terminal"
:title="instructionInfo.name"
:description="instructionInfo.instruction" />
:description="instructionInfo.instruction"
class="hidden md:block" />
<ChatConfigInfo v-if="knowledgeBaseInfo" icon="i-heroicons-book-open"
:title="knowledgeBaseInfo.name"
class="mx-2" />
class="mx-2 hidden md:block" />
<div class="mx-auto px-4 text-center">
<h2 class="line-clamp-1">{{ sessionInfo?.title || t('chat.untitled') }}</h2>
<div class="text-xs text-muted line-clamp-1">{{ instructionInfo?.name }}</div>
Expand Down
10 changes: 6 additions & 4 deletions components/ChatInputBox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@ const emits = defineEmits<{
stop: []
}>()
const { isMobile } = useMediaBreakpoints()
const { t } = useI18n()
const submitMode = useStorage<SubmitMode>('sendMode', 'enter')
const state = reactive<ChatBoxFormData>({
content: '',
})
const tip = computed(() => {
const s = sendModeList.value[0].find(el => el.value === submitMode.value)?.label || ''
return `(${s})`
return ` (${s})`
})
const isFocus = ref(false)
const sendModeList = computed(() => {
Expand All @@ -37,6 +38,7 @@ const sendModeList = computed(() => {
const disabledBtn = computed(() => {
return props.disabled || (!props.loading && !state.content.trim())
})
const btnTip = computed(() => props.loading ? t('chat.stop') : (isMobile.value ? '' : tip.value))
defineExpose({
reset: onReset
Expand Down Expand Up @@ -76,11 +78,11 @@ function onReset() {
<slot></slot>
<div class="flex items-center ml-auto">
<ClientOnly>
<UButton type="submit" :disabled="disabledBtn" class="send-btn"
<UButton type="submit" :disabled="disabledBtn" :class="{ 'send-btn': !isMobile }"
:icon="loading ? 'i-iconoir-square' : 'i-iconoir-send-diagonal'" @click="onStop">
<span class="text-xs tip-text">{{ loading ? ' Stop' : tip }}</span>
<span class="text-xs tip-text" v-show="btnTip">{{ btnTip }}</span>
</UButton>
<UDropdown :items="sendModeList" :popper="{ placement: 'top-end' }">
<UDropdown v-if="!isMobile" :items="sendModeList" :popper="{ placement: 'top-end' }">
<UButton trailing-icon="i-heroicons-chevron-down-20-solid" class="arrow-btn" />
</UDropdown>
</ClientOnly>
Expand Down
99 changes: 47 additions & 52 deletions components/ChatSessionList.vue
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
<script lang="ts" setup>
import { useStorage } from '@vueuse/core'
interface ChatSessionInfo extends ChatSession {
count: number
}
import { USlideover } from '#components'
const emits = defineEmits<{
select: [sessionId: number]
closePanel: []
}>()
const { t } = useI18n()
const createChatSession = useCreateChatSession()
const { isMobile } = useMediaBreakpoints()
const sessionList = ref<ChatSessionInfo[]>([])
const sessionList = ref<ChatSession[]>([])
const currentSessionId = useStorage<number>('currentSessionId', 0)
const confirm = useDialog('confirm')
Expand All @@ -27,7 +26,7 @@ onMounted(async () => {
}
})
defineExpose({ updateMessageCount, updateSessionInfo, createChat: onNewChat })
defineExpose({ updateSessionInfo, createChat: onNewChat })
async function onNewChat() {
const data = await createChatSession()
Expand All @@ -40,7 +39,7 @@ function onSelectChat(sessionId: number) {
emits('select', sessionId)
}
async function onTopChat(item: ChatSessionInfo, direction: string) {
async function onTopChat(item: ChatSession, direction: string) {
// 设置clientDB中 chatSessions 的isTop字段为true
clientDB.chatSessions.update(item.id!, { isTop: direction == 'up' ? Date.now() : 0 })
sessionList.value = await getSessionList()
Expand Down Expand Up @@ -70,32 +69,26 @@ function onDeleteChat(data: ChatSession) {
}
async function getSessionList() {
const list: ChatSessionInfo[] = []
const list: ChatSession[] = []
const result = await clientDB.chatSessions.orderBy('updateTime').reverse().toArray()
for (const item of result) {
const count = await clientDB.chatHistories.where('sessionId').equals(item.id!).count()
list.push({ ...item, isTop: item.isTop || 0, count })
list.push({ ...item, isTop: item.isTop || 0 })
}
return sortSessionList(list)
}
function sortSessionList(data: ChatSessionInfo[]) {
const pinTopList: ChatSessionInfo[] = []
const list: ChatSessionInfo[] = []
function sortSessionList(data: ChatSession[]) {
const pinTopList: ChatSession[] = []
const list: ChatSession[] = []
data.forEach(el => el.isTop > 0 ? pinTopList.push(el) : list.push(el))
pinTopList.sort((a, b) => b.isTop - a.isTop)
list.sort((a, b) => b.updateTime - a.updateTime)
return [...pinTopList, ...list]
}
async function updateMessageCount(offset: number) {
const currentSession = sessionList.value.find(el => el.id === currentSessionId.value)!
currentSession.count = currentSession.count + offset
}
async function updateSessionInfo(data: Partial<Omit<ChatSession, 'id' | 'createTime'> & { forceUpdateTitle: boolean }>) {
const currentSession = sessionList.value.find(el => el.id === currentSessionId.value)!
let savedData: Partial<ChatSession>
Expand All @@ -115,64 +108,66 @@ async function updateSessionInfo(data: Partial<Omit<ChatSession, 'id' | 'createT
</script>

<template>
<div class="h-full box-border bg-gray-100 dark:bg-gray-900 border-r dark:border-gray-800">
<Component :is="isMobile ? USlideover : 'div'"
:class="isMobile ? 'w-[80vw] max-w-[400px] h-full' : 'border-r dark:border-gray-800'"
class="h-full box-border">
<div class="p-3 border-b border-primary-400/30 flex items-center">
<h3 class="text-primary-600 dark:text-primary-300 mr-auto">{{ t("chat.allChats") }} ({{ sessionList.length }})</h3>
<UTooltip :text="t('chat.newChat')" :popper="{ placement: 'top' }">
<UButton icon="i-material-symbols-add" color="primary" square @click="onNewChat"></UButton>
</UTooltip>
<UButton icon="i-material-symbols-close-rounded" color="gray" class="md:hidden ml-4" @click="emits('closePanel')"></UButton>
</div>
<TransitionGroup tag="div" name="list" class="h-[calc(100%-57px)] overflow-auto">
<div v-for="item in sessionList" :key="item.id"
class="session-item dark:text-gray-300 hover:bg-primary-100 dark:hover:bg-primary-700/30 p-3 cursor-pointer border-b border-gray-200 dark:border-gray-800 flex items-center"

:class="{ 'bg-slate-200 dark:bg-slate-700/30': currentSessionId !== item.id ? item.isTop : '', 'bg-primary-100 dark:bg-primary-700/30 activated ': currentSessionId === item.id }"
class="session-item relative box-border p-2 cursor-pointer dark:text-gray-300 border-b border-gray-100 dark:border-gray-100/5"
:class="item.isTop ? 'bg-primary-300/10 dark:bg-primary-800/10' : 'hover:bg-gray-50 dark:hover:bg-gray-700/30'"
@click="onSelectChat(item.id!)">
<div class="grow overflow-hidden">
<div class="line-clamp-1">{{ item.title || `${t("chat.newChat")} ${item.id}` }}</div>

<div class="flex justify-between w-100">
<div class="text-sm text-muted line-clamp-1">{{ t("chat.messagesCount", [item.count]) }}
</div>
<div>
<UButton v-if="!item.isTop" icon="i-material-symbols-vertical-align-top" size="2xs" color="blue" class="mx-1 btn-delete"
@click.stop="onTopChat(item, 'up')"></UButton>
<UButton v-if="item.isTop" icon="i-material-symbols-vertical-align-bottom-rounded" size="2xs" color="blue" class="mx-1 btn-delete"
@click.stop="onTopChat(item, 'down')"></UButton>
<UButton icon="i-material-symbols-delete-outline" size="2xs" color="red" class="btn-delete"
@click.stop="onDeleteChat(item)"></UButton>
</div>
</div>
<div class="w-full flex items-center text-sm h-[32px]">
<div class="line-clamp-1 grow opacity-80"
:class="currentSessionId === item.id ? 'text-pink-700 dark:text-pink-400 font-bold' : 'opacity-80'">{{ item.title || `${t("chat.newChat")} ${item.id}` }}</div>
<ChatSessionListActionMore :data="item"
class="action-more"
@pin="onTopChat(item, 'up')"
@unpin="onTopChat(item, 'down')"
@delete="onDeleteChat(item)" />
</div>

<div v-if="item.isTop" class="triangle"></div>
</div>
</TransitionGroup>
</div>
</Component>
</template>

<style lang="scss" scoped>
.session-item {
overflow: hidden;
&.activated {
border-left: 2px solid rgb(var(--color-primary-500));
}
.btn-delete {
transition: all 0.3s;
transform-origin: right center;
transform: translateX(calc(100% + 0.75rem)) scale(0);
opacity: 0;
:deep() {
@media (pointer: fine) {
.action-more {
display: none;
}
}
}
&:hover {
.btn-delete {
transform: translateX(0) scale(1);
opacity: 1;
:deep() .action-more {
display: block;
}
}
}
.triangle {
$size: 6px;
width: 0;
height: 0;
position: absolute;
top: -$size;
left: -$size;
border: $size solid transparent;
border-top-color: rgba(var(--color-primary-500) / 0.6);
transform: rotate(135deg);
}
.list-enter-active,
.list-leave-active {
transform-origin: left center;
Expand Down
71 changes: 71 additions & 0 deletions components/ChatSessionListActionMore.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<script lang="ts" setup>
import { useMediaQuery } from '@vueuse/core'
const props = defineProps<{
data: ChatSession
}>()
const emits = defineEmits<{
pin: [data: ChatSession]
unpin: [data: ChatSession]
delete: [data: ChatSession]
}>()
const { t } = useI18n()
const isTouchDevice = useMediaQuery('(hover: none) and (pointer: coarse)')
const open = ref(false)
const buttons = computed(() => {
return [
{ label: t('chat.pin'), type: 'pin', icon: 'i-material-symbols-keep-outline', color: 'gray', class: '', visible: !props.data.isTop },
{ label: t('chat.unpin'), type: 'unpin', icon: 'i-material-symbols-keep-off-outline', color: 'gray', class: '', visible: props.data.isTop },
{ label: t('chat.deleteChat'), type: 'delete', icon: 'i-material-symbols-delete-outline', color: 'red', class: '', visible: true },
] as const
})
function onClick(type: 'pin' | 'unpin' | 'delete') {
open.value = false
switch (type) {
case 'pin':
emits('pin', props.data)
break
case 'unpin':
emits('unpin', props.data)
break
case 'delete':
emits('delete', props.data)
break
}
}
</script>

<template>
<div v-if="isTouchDevice" class="flex">
<template v-for="item in buttons" :key="item.type">
<UButton v-if="item.visible"
:icon="item.icon"
color="gray"
variant="ghost"
size="sm"
class="opacity-50 mx-2"
@click="onClick(item.type)"></UButton>
</template>
</div>
<UPopover v-else v-model:open="open" mode="hover">
<UButton icon="i-material-symbols-more-vert" variant="link" color="gray" size="sm" class="opacity-50"></UButton>
<template #panel>
<div class="flex flex-col py-2 opacity-90">
<template v-for="item, i in buttons" :key="item.type">
<UButton v-if="item.visible"
:icon="item.icon"
:color="item.color"
variant="ghost"
:class="{ 'border-t dark:border-gray-800': i > 0 }"
class="rounded-none px-4"
@click="onClick(item.type)">{{ item.label }}</UButton>
</template>
</div>
</template>
</UPopover>
</template>
25 changes: 20 additions & 5 deletions composables/useDialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,11 @@ export function useDialog(type: 'modal' | 'confirm' | 'alert') {
h(LazyUButton, {
icon: 'i-material-symbols-close-rounded',
color: 'gray',
onClick: () => {
onClick: props.onClose,
onTouchstart: (e: TouchEvent) => {
e.stopPropagation()
props.onClose()
props.onDone()
}
},
})
]),
default: props.component
Expand All @@ -77,9 +78,23 @@ export function useDialog(type: 'modal' | 'confirm' | 'alert') {
? undefined
: () => h('div', { class: 'flex justify-end gap-2' }, [
type === 'confirm'
? h(LazyUButton, { color: 'gray', class: 'mr-2', onClick: props.onClose }, { default: () => props.cancelText })
? h(LazyUButton, {
color: 'gray',
class: 'mr-2',
onClick: props.onClose,
onTouchstart: (e: TouchEvent) => {
e.stopPropagation()
props.onClose()
},
}, { default: () => props.cancelText })
: null,
h(LazyUButton, { onClick: props.onDone }, { default: () => props.confirmText }),
h(LazyUButton, {
onClick: props.onDone,
onTouchstart: (e: TouchEvent) => {
e.stopPropagation()
props.onDone()
},
}, { default: () => props.confirmText }),
])
})
]
Expand Down
9 changes: 9 additions & 0 deletions composables/useMediaBreakpoints.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'

export function useMediaBreakpoints() {
const breakpoints = useBreakpoints(breakpointsTailwind)

const isMobile = computed(() => breakpoints.smaller('md').value)

return { isMobile }
}
Loading

0 comments on commit 8d0cf33

Please sign in to comment.