feat: 响应式支持移动端 (#49)

* feat: 响应式兼容 h5

* feat: 补充空状态

* feat: thinking

* chore: @vueuse/core 导致的类型检查错误

* chore: version 2.4.0
This commit is contained in:
Redon
2023-02-17 10:57:06 +08:00
committed by GitHub
parent 405aeaa2a5
commit 3f4cb5c900
16 changed files with 358 additions and 87 deletions

View File

@@ -1,5 +1,6 @@
<script setup lang='ts'>
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { MessageReactive } from 'naive-ui'
import { NButton, NInput, useMessage } from 'naive-ui'
import { Message } from './components'
import { Layout } from './layout'
@@ -14,6 +15,8 @@ const ms = useMessage()
const historyStore = useHistoryStore()
let messageReactive: MessageReactive | null = null
const scrollRef = ref<HTMLDivElement>()
const { addChat, clearChat } = useChat()
@@ -51,6 +54,7 @@ async function handleSubmit() {
try {
loading.value = true
createMessage()
const { data } = await fetchChatAPI(message, options, controller.signal)
addMessage(data?.text ?? '', { options: { conversationId: data.conversationId, parentMessageId: data.id } })
}
@@ -60,6 +64,7 @@ async function handleSubmit() {
}
finally {
loading.value = false
removeMessage()
}
}
@@ -89,12 +94,32 @@ function handleCancel() {
controller.abort()
controller = new AbortController()
loading.value = false
removeMessage()
}
function createMessage() {
if (!messageReactive) {
messageReactive = ms.loading('Thinking...', {
duration: 0,
})
}
}
function removeMessage() {
if (messageReactive) {
messageReactive.destroy()
messageReactive = null
}
}
onMounted(() => {
scrollToBottom()
})
onBeforeUnmount(() => {
handleCancel()
})
watch(
heartbeat,
() => {
@@ -119,12 +144,20 @@ watch(
<div class="flex flex-col h-full">
<main class="flex-1 overflow-hidden">
<div ref="scrollRef" class="h-full p-4 overflow-hidden overflow-y-auto">
<div>
<Message
v-for="(item, index) of list" :key="index" :date-time="item.dateTime" :message="item.message"
:reversal="item.reversal" :error="item.error"
/>
</div>
<template v-if="!list.length">
<div class="flex items-center justify-center mt-4 text-center text-neutral-300">
<SvgIcon icon="ri:bubble-chart-fill" class="mr-2 text-3xl" />
<span>Aha~</span>
</div>
</template>
<template v-else>
<div>
<Message
v-for="(item, index) of list" :key="index" :date-time="item.dateTime" :message="item.message"
:reversal="item.reversal" :error="item.error"
/>
</div>
</template>
</div>
</main>
<footer class="p-4">
@@ -135,7 +168,7 @@ watch(
</span>
</HoverButton>
<NInput v-model:value="prompt" placeholder="Type a message..." @keypress="handleEnter" />
<NButton type="primary" :loading="loading" @click="handleSubmit">
<NButton type="primary" :disabled="loading" @click="handleSubmit">
<template #icon>
<SvgIcon icon="ri:send-plane-fill" />
</template>

View File

@@ -1,15 +1,42 @@
<script setup lang='ts'>
import { computed } from 'vue'
import { NLayout, NLayoutContent } from 'naive-ui'
import Sider from './sider/index.vue'
import Header from './header/index.vue'
import { useBasicLayout } from '@/hooks/useBasicLayout'
import { useAppStore } from '@/store'
const appStore = useAppStore()
const { isMobile } = useBasicLayout()
const collapsed = computed(() => appStore.siderCollapsed)
const getMobileClass = computed(() => {
if (isMobile.value)
return ['rounded-none', 'shadow-none']
return ['border', 'rounded-md', 'shadow-md']
})
const getContainerClass = computed(() => {
return [
'h-full',
{ 'pt-14': isMobile.value },
{ 'pl-[260px]': !isMobile.value && !collapsed.value },
]
})
</script>
<template>
<div class="h-full overflow-hidden border rounded-md shadow-md">
<NLayout class="h-full" has-sider>
<Sider />
<NLayoutContent class="h-full">
<slot />
</NLayoutContent>
</NLayout>
<div class="h-screen p-4" :class="[{ 'p-0': isMobile }]">
<div class="h-full overflow-hidden" :class="getMobileClass">
<NLayout class="z-40 transition" :class="getContainerClass" has-sider>
<Sider />
<Header v-if="isMobile" />
<NLayoutContent class="h-full">
<slot />
</NLayoutContent>
</NLayout>
</div>
</div>
</template>

View File

@@ -0,0 +1,36 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { SvgIcon } from '@/components/common'
import { useAppStore, useHistoryStore } from '@/store'
const appStore = useAppStore()
const historyStore = useHistoryStore()
const collapsed = computed(() => appStore.siderCollapsed)
function handleAdd() {
historyStore.addHistory({
title: 'New Chat',
isEdit: false,
data: [],
})
}
function handleUpdateCollapsed() {
appStore.setSiderCollapsed(!collapsed.value)
}
</script>
<template>
<header class="fixed top-0 left-0 right-0 z-50 border-b bg-white/80 backdrop-blur">
<div class="relative flex items-center justify-between px-4 h-14">
<button class="flex items-center justify-center w-11 h-11" @click="handleUpdateCollapsed">
<SvgIcon v-if="collapsed" class="text-2xl" icon="ri:align-justify" />
<SvgIcon v-else class="text-2xl" icon="ri:align-right" />
</button>
<button class="flex items-center justify-center w-11 h-11" @click="handleAdd">
<SvgIcon class="text-2xl" icon="ri:add-fill" />
</button>
</div>
</header>
</template>

View File

@@ -1,14 +0,0 @@
<script setup lang='ts'>
import { HoverButton, SvgIcon, UserAvatar } from '@/components/common'
</script>
<template>
<footer class="flex items-center justify-between min-w-0 p-4 overflow-hidden border-t h-[70px]">
<UserAvatar class="flex-1" />
<HoverButton tooltip="Setting">
<span class="text-xl text-[#4f555e]">
<SvgIcon icon="ri:settings-4-line" />
</span>
</HoverButton>
</footer>
</template>

View File

@@ -33,39 +33,47 @@ function handleEnter(index: number, isEdit: boolean, event: KeyboardEvent) {
<template>
<NScrollbar class="px-4">
<div class="flex flex-col gap-2 text-sm">
<div v-for="(item, index) of dataSources" :key="index">
<a
class="relative flex items-center gap-3 px-3 py-3 break-all border rounded-md cursor-pointer hover:bg-neutral-100 group"
:class="historyStore.active === index && ['border-[#4b9e5f]', 'bg-neutral-100', 'text-[#4b9e5f]', 'pr-14']"
@click="handleSelect(index)"
>
<span>
<SvgIcon icon="ri:message-3-line" />
</span>
<div class="relative flex-1 overflow-hidden break-all text-ellipsis whitespace-nowrap">
<NInput
v-if="item.isEdit" v-model:value="item.title" size="tiny"
@keypress="handleEnter(index, false, $event)"
/>
<span v-else>{{ item.title }}</span>
</div>
<div v-if="historyStore.active === index" class="absolute z-10 flex visible right-1">
<template v-if="item.isEdit">
<button class="p-1" @click="handleEdit(index, false, $event)">
<SvgIcon icon="ri:save-line" />
</button>
</template>
<template v-else>
<button class="p-1">
<SvgIcon icon="ri:edit-line" @click="handleEdit(index, true, $event)" />
</button>
<button class="p-1" @click="handleRemove(index, $event)">
<SvgIcon icon="ri:delete-bin-line" />
</button>
</template>
</div>
</a>
</div>
<template v-if="!dataSources.length">
<div class="flex flex-col items-center mt-4 text-center text-neutral-300">
<SvgIcon icon="ri:inbox-line" class="mb-2 text-3xl" />
<span>No history</span>
</div>
</template>
<template v-else>
<div v-for="(item, index) of dataSources" :key="index">
<a
class="relative flex items-center gap-3 px-3 py-3 break-all border rounded-md cursor-pointer hover:bg-neutral-100 group"
:class="historyStore.active === index && ['border-[#4b9e5f]', 'bg-neutral-100', 'text-[#4b9e5f]', 'pr-14']"
@click="handleSelect(index)"
>
<span>
<SvgIcon icon="ri:message-3-line" />
</span>
<div class="relative flex-1 overflow-hidden break-all text-ellipsis whitespace-nowrap">
<NInput
v-if="item.isEdit" v-model:value="item.title" size="tiny"
@keypress="handleEnter(index, false, $event)"
/>
<span v-else>{{ item.title }}</span>
</div>
<div v-if="historyStore.active === index" class="absolute z-10 flex visible right-1">
<template v-if="item.isEdit">
<button class="p-1" @click="handleEdit(index, false, $event)">
<SvgIcon icon="ri:save-line" />
</button>
</template>
<template v-else>
<button class="p-1">
<SvgIcon icon="ri:edit-line" @click="handleEdit(index, true, $event)" />
</button>
<button class="p-1" @click="handleRemove(index, $event)">
<SvgIcon icon="ri:delete-bin-line" />
</button>
</template>
</div>
</a>
</div>
</template>
</div>
</NScrollbar>
</template>

View File

@@ -1,14 +1,16 @@
<script setup lang='ts'>
import { ref } from 'vue'
import { computed, watch } from 'vue'
import { NButton, NLayoutSider } from 'naive-ui'
import List from './List.vue'
import Footer from './Footer.vue'
import { HoverButton, SvgIcon, UserAvatar } from '@/components/common'
import { useAppStore, useHistoryStore } from '@/store'
import { useBasicLayout } from '@/hooks/useBasicLayout'
const appStore = useAppStore()
const historyStore = useHistoryStore()
const { isMobile } = useBasicLayout()
const collapsed = ref(appStore.siderCollapsed ?? false)
const collapsed = computed(() => appStore.siderCollapsed)
function handleAdd() {
historyStore.addHistory({
@@ -18,10 +20,17 @@ function handleAdd() {
})
}
function handleCollapsed() {
collapsed.value = !collapsed.value
appStore.setSiderCollapsed(collapsed.value)
function handleUpdateCollapsed() {
appStore.setSiderCollapsed(!collapsed.value)
}
watch(
isMobile,
(val) => {
appStore.setSiderCollapsed(val)
},
{ flush: 'post' },
)
</script>
<template>
@@ -29,12 +38,13 @@ function handleCollapsed() {
:collapsed="collapsed"
:collapsed-width="0"
:width="260"
:show-trigger="isMobile ? false : 'arrow-circle'"
collapse-mode="transform"
show-trigger="arrow-circle"
position="absolute"
bordered
@update:collapsed="handleCollapsed"
@update-collapsed="handleUpdateCollapsed"
>
<div class="flex flex-col h-full">
<div class="flex flex-col h-full" :class="[{ 'pt-14': isMobile }]">
<main class="flex-1 min-h-0 overflow-hidden">
<div class="p-4">
<NButton dashed block @click="handleAdd">
@@ -43,7 +53,14 @@ function handleCollapsed() {
</div>
<List />
</main>
<Footer />
<footer class="flex items-center justify-between min-w-0 p-4 overflow-hidden border-t h-[70px]">
<UserAvatar />
<HoverButton tooltip="Setting">
<span class="text-xl text-[#4f555e]">
<SvgIcon icon="ri:settings-4-line" />
</span>
</HoverButton>
</footer>
</div>
</NLayoutSider>
</template>

View File

@@ -17,7 +17,3 @@ import { GithubSite } from '@/components/custom'
</div>
</div>
</template>
<style>
</style>

View File

@@ -0,0 +1,8 @@
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
export function useBasicLayout() {
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('sm')
return { isMobile }
}

View File

@@ -3,7 +3,5 @@ import { Chat } from '@/components/business'
</script>
<template>
<div class="h-full p-4 overflow-hidden">
<Chat />
</div>
<Chat />
</template>