feat(view): 新增搜索页面功能

美化动态页面内容
This commit is contained in:
nongyehong 2024-07-13 18:53:55 +08:00
parent 85b6cad03f
commit 866ba89b93
12 changed files with 422 additions and 35 deletions

View File

@ -16,6 +16,7 @@
:class="{ 'right-1px': activeItem.type === RoomTypeEnum.SINGLE }"
class="relative h-100vh"
:ignore-item-resize="true"
:item-resizable="true"
:padding-top="10"
:item-size="itemSize"
:items="chatMessageList">
@ -50,7 +51,7 @@
</template>
</div>
<!-- 好友或者群聊的信息 -->
<article
<div
v-else
class="flex flex-col w-full"
:class="[{ 'items-end': item.fromUser.uid === userUid }, chatStore.isGroup ? 'gap-18px' : 'gap-2px']">
@ -275,7 +276,7 @@
</n-flex>
</n-flex>
</div>
</article>
</div>
</n-flex>
</template>
</n-virtual-list>

View File

@ -24,8 +24,8 @@ export const useMsgInput = (messageInputDom: Ref) => {
const chatKey = ref(chat.value.sendKey)
const msgInput = ref('')
const ait = ref(false)
/** 临时消息id */
const tempMessageId = ref(0)
// /** 临时消息id */
// const tempMessageId = ref(0)
/** 艾特后的关键字的key */
const aitKey = ref('')
/** 是否正在输入拼音 */
@ -199,16 +199,13 @@ export const useMsgInput = (messageInputDom: Ref) => {
msgType: msg.type,
body: { content: msg.content, replyMsgId: msg.reply !== 0 ? msg.reply : undefined }
})
.then((res) => {
.then(async (res) => {
if (res.data.message.type === MsgEnum.TEXT) {
chatStore.pushMsg(res.data)
// 发完消息就要刷新会话列表,
// FIXME 如果当前会话已经置顶了,可以不用刷新
chatStore.updateSessionLastActiveTime(globalStore.currentSession.roomId)
} else {
// 更新上传状态下的消息
chatStore.updateMsg(tempMessageId.value, res.data)
await chatStore.pushMsg(res.data)
}
// 发完消息就要刷新会话列表,
// FIXME 如果当前会话已经置顶了,可以不用刷新
chatStore.updateSessionLastActiveTime(globalStore.currentSession.roomId)
})
msgInput.value = ''
messageInputDom.value.innerHTML = ''

220
src/hooks/useUpload.ts Normal file
View File

@ -0,0 +1,220 @@
import { ref } from 'vue'
import { createEventHook } from '@vueuse/core'
import apis from '@/services/apis'
/** 文件信息类型 */
export type FileInfoType = {
name: string
type: string
size: number
suffix: string
width?: number
height?: number
downloadUrl?: string
second?: number
thumbWidth?: number
thumbHeight?: number
thumbUrl?: string
}
const Max = 100 // 单位M
const MAX_FILE_SIZE = Max * 1024 * 1024 // 最大上传限制
/**
* Hook
*/
export const useUpload = () => {
const isUploading = ref(false) // 是否正在上传
const progress = ref(0) // 进度
const fileInfo = ref<FileInfoType | null>(null) // 文件信息
const { on: onChange, trigger } = createEventHook()
const onStart = createEventHook()
/**
*
* @param url
* @param file
* @param inner
*/
const upload = async (url: string, file: File, inner?: boolean) => {
isUploading.value = true
const xhr = new XMLHttpRequest()
xhr.open('PUT', url, true)
xhr.setRequestHeader('Content-Type', file.type)
xhr.upload.onprogress = function (e) {
if (!inner) {
progress.value = Math.round((e.loaded / e.total) * 100)
}
}
xhr.onload = function () {
isUploading.value = false
if (inner) return
if (xhr.status === 200) {
trigger('success')
} else {
trigger('fail')
}
}
xhr.send(file)
}
/**
*
*/
const getVideoCover = (file: File) => {
return new Promise((resolve, reject) => {
const video = document.createElement('video')
const tempUrl = URL.createObjectURL(file)
video.src = tempUrl
video.crossOrigin = 'anonymous' // 视频跨域
video.currentTime = 2 // 第2帧
video.oncanplay = () => {
const canvas = document.createElement('canvas')
canvas.width = video.videoWidth
canvas.height = video.videoHeight
canvas.getContext('2d')?.drawImage(video, 0, 0, canvas.width, canvas.height)
// 将canvas转为图片file
canvas.toBlob((blob) => {
if (!blob) return
// 时间戳生成唯一文件名
const name = Date.now() + 'thumb.jpg'
const thumbFile = new File([blob], name, { type: 'image/jpeg' })
// 转成File对象 并上传
apis.getUploadUrl({ fileName: name, scene: '1' }).then(async (res) => {
if (res.data.uploadUrl && res.data.downloadUrl) {
await upload(res.data.uploadUrl, thumbFile, true)
// 等待上传完成
const timer = setInterval(() => {
if (!isUploading.value) {
clearInterval(timer)
resolve({
thumbWidth: canvas.width,
thumbHeight: canvas.height,
thumbUrl: res.data.downloadUrl,
thumbSize: thumbFile.size,
tempUrl
})
}
})
}
})
})
}
video.onerror = function () {
URL.revokeObjectURL(tempUrl) // 释放临时URL资源
reject({ width: 0, height: 0, url: null })
}
})
}
/**
*
*/
const getImgWH = (file: File) => {
const img = new Image()
const tempUrl = URL.createObjectURL(file)
img.src = tempUrl
return new Promise((resolve, reject) => {
img.onload = function () {
resolve({ width: img.width, height: img.height, tempUrl })
}
img.onerror = function () {
URL.revokeObjectURL(tempUrl) // 释放临时URL资源
reject({ width: 0, height: 0, url: null })
}
})
}
/**
*
*/
const getAudioDuration = (file: File) => {
return new Promise((resolve, reject) => {
const audio = new Audio()
const tempUrl = URL.createObjectURL(file)
audio.src = tempUrl
// 计算音频的时长
const countAudioTime = async () => {
while (isNaN(audio.duration) || audio.duration === Infinity) {
// 防止浏览器卡死
await new Promise((resolve) => setTimeout(resolve, 100))
// 随机进度条位置
audio.currentTime = 100000 * Math.random()
}
// 取整
const second = Math.round(audio.duration || 0)
resolve({ second, tempUrl })
}
countAudioTime()
audio.onerror = function () {
reject({ second: 0, tempUrl })
}
})
}
/**
*
* @param file
* @param addParams
* @returns ...
*/
const parseFile = async (file: File, addParams: Record<string, any> = {}) => {
const { name, size, type } = file
const suffix = name.split('.').pop()?.trim().toLowerCase() || ''
const baseInfo = { name, size, type, suffix, ...addParams }
if (type.includes('image')) {
const { width, height, tempUrl } = (await getImgWH(file)) as any
return { ...baseInfo, width, height, tempUrl }
}
if (type.includes('audio')) {
const { second, tempUrl } = (await getAudioDuration(file)) as any
return { second, tempUrl, ...baseInfo }
}
// 如果是视频
if (type.includes('video')) {
const { thumbWidth, thumbHeight, tempUrl, thumbTempUrl, thumbUrl, thumbSize } = (await getVideoCover(file)) as any
return { ...baseInfo, thumbWidth, thumbHeight, tempUrl, thumbTempUrl, thumbUrl, thumbSize }
}
return baseInfo
}
/**
*
* @param file
* @param addParams
*/
const uploadFile = async (file: File, addParams?: Record<string, any>) => {
if (isUploading.value || !file) return
const info = await parseFile(file, addParams)
// 限制文件大小
if (info.size > MAX_FILE_SIZE) {
window.$message.error(`文件大小不能超过 ${Max}MB`)
return
}
const { downloadUrl, uploadUrl } = (await apis.getUploadUrl({ fileName: info.name, scene: '1' })).data
if (uploadUrl && downloadUrl) {
fileInfo.value = { ...info, downloadUrl }
await onStart.trigger(fileInfo)
await upload(uploadUrl, file)
} else {
await trigger('fail')
}
}
return {
fileInfo,
isUploading,
progress,
onStart: onStart.on,
onChange,
uploadFile
}
}

View File

@ -2,6 +2,10 @@ import { WebviewWindow } from '@tauri-apps/api/window'
import { EventEnum } from '@/enums'
import { emit } from '@tauri-apps/api/event'
/**
*
* @param label
*/
export const useWindowState = (label: string) => {
const win = WebviewWindow.getByLabel(label)

View File

@ -1,19 +1,13 @@
<template>
<main
data-tauri-drag-region
id="center"
class="resizable select-none flex flex-col shadow-inner"
:style="{ width: `${initWidth}px` }">
<main id="center" class="resizable select-none flex flex-col shadow-inner" :style="{ width: `${initWidth}px` }">
<div class="resize-handle" @mousedown="initDrag"></div>
<ActionBar
class="absolute right-0"
class="absolute right-0 w-full"
v-if="shrinkStatus"
:shrink-status="!shrinkStatus"
:max-w="false"
:current-label="appWindow.label" />
<!-- <div class="resize-handle" @mousedown="initDrag"></div>-->
<!-- 顶部搜索栏 -->
<header
style="box-shadow: 0 2px 4px var(--box-shadow-color)"
@ -22,7 +16,7 @@
<n-input
id="search"
@focus="() => router.push('/searchDetails')"
class="rounded-6px w-full"
class="rounded-6px w-full relative"
style="background: var(--search-bg-color)"
:maxlength="20"
clearable
@ -32,16 +26,28 @@
<svg class="w-12px h-12px"><use href="#search"></use></svg>
</template>
</n-input>
<n-button size="small" secondary style="padding: 0 5px">
<n-button @click="addPanels.show = !addPanels.show" size="small" secondary style="padding: 0 5px">
<template #icon>
<svg class="w-24px h-24px"><use href="#plus"></use></svg>
</template>
</n-button>
<!-- 添加面板 -->
<div v-if="addPanels.show" class="add-item">
<div class="menu-list">
<div v-for="(item, index) in addPanels.list" :key="index">
<div class="menu-item" @click="() => item.click()">
<svg><use :href="`#${item.icon}`"></use></svg>
{{ item.label }}
</div>
</div>
</div>
</div>
</div>
</header>
<!-- 列表 -->
<div id="centerList">
<div id="centerList" class="h-full">
<router-view />
</div>
</main>
@ -66,6 +72,26 @@ const { width } = useWindowSize()
const isDrag = ref(true)
/** 当前消息 */
const currentMsg = ref()
/** 添加面板是否显示 */
const addPanels = ref({
show: false,
list: [
{
label: '发起群聊',
icon: 'launch',
click: () => {
console.log('发起群聊')
}
},
{
label: '加好友/群',
icon: 'people-plus',
click: () => {
console.log('加好友/群')
}
}
]
})
const startX = ref()
const startWidth = ref()
@ -96,6 +122,9 @@ const closeMenu = (event: Event) => {
if (!e.matches('#search, #search *, #centerList *, #centerList') && route === '/searchDetails') {
router.go(-1)
}
if (!e.matches('.add-item')) {
addPanels.value.show = false
}
}
/** 定义一个函数,在鼠标拖动时调用 */

View File

@ -15,3 +15,10 @@
z-index: 9999;
background-color: var(--split-color);
}
.add-item {
@include menu-item-style(absolute);
top: 60px;
right: 30px;
@include menu-list();
}

View File

@ -25,7 +25,7 @@
<!-- 该选项有提示时展示 -->
<n-popover style="padding: 12px" v-else-if="item.tip" trigger="manual" v-model:show="tipShow" placement="right">
<template #trigger>
<n-badge :max="99" :value="item.badge" :dot="dotShow">
<n-badge :max="99" :value="item.badge" dot :show="dotShow">
<svg class="size-22px" @click="handleTipShow">
<use
:href="`#${activeUrl === item.url || openWindowsList.has(item.url) ? item.iconAction : item.icon}`"></use>

View File

@ -47,3 +47,13 @@ export const dynamicList = Array.from({ length: 10 }, (_, i) => {
isAuth: i % 2 === 0
}
})
/** 动态评论 */
export const dynamicCommentList = Array.from({ length: 50 }, (_, i) => {
return {
id: i,
avatar: `${avatars}?${i}`,
user: `泰勒斯威夫特${i}`,
content: '点赞了你的动态'
}
})

View File

@ -99,9 +99,9 @@
</n-flex>
<n-flex v-if="!isLogining && !isWrongPassword" justify="space-around" align="center" :size="0" class="options">
<p class="text-(14px #f1f1f1)" @click="isUnlockPage = false">返回</p>
<p class="text-(14px #e3e3e3)" @click="logout">退出登录</p>
<p class="text-(14px #e3e3e3)">忘记密码</p>
<p class="text-(14px #fefefe)" @click="isUnlockPage = false">返回</p>
<p class="text-(14px #fefefe)" @click="logout">退出登录</p>
<p class="text-(14px #fefefe)">忘记密码</p>
<p class="text-(14px #fff)" @click="unlock">进入系统</p>
</n-flex>
</n-flex>

View File

@ -1,9 +1,38 @@
<template>
<main class="size-full bg-[--right-bg-color]">
<ActionBar :shrink="false" :max-w="false" :top-win-label="appWindow.label" :current-label="appWindow.label" />
<article class="flex flex-col items-center text-[--text-color] size-full bg-[--right-bg-color]">
<!-- 头部用户信息栏 -->
<n-flex
align="center"
justify="center"
:size="20"
class="relative bg-[--left-active-color] h-160px w-full select-none">
<n-avatar :size="120" round bordered :src="login.accountInfo.avatar" />
<n-flex vertical justify="center" :size="20">
<p class="text-(24px [--chat-text-color]) font-bold">{{ login.accountInfo.name }}</p>
<n-flex align="center" justify="space-between" :size="30" class="mt-5px">
<template v-for="item in titleList" :key="item.label">
<n-flex vertical align="center" class="cursor-pointer">
<p class="text-[--text-color]">{{ item.total }}</p>
<p class="text-(16px #808080)">{{ item.label }}</p>
</n-flex>
</template>
</n-flex>
</n-flex>
<div class="absolute top-30px right-30px cursor-pointer" @click="handleInfoTip">
<n-badge :value="infoTip.value" :max="100" :show="infoTip.show">
<svg class="size-24px color-[--text-color]"><use href="#remind"></use></svg>
</n-badge>
</div>
</n-flex>
<!-- 动态列表 -->
<div class="flex flex-col items-center text-[--text-color] size-full bg-[--right-bg-color]">
<n-scrollbar
style="max-height: calc(100vh - 20px)"
style="max-height: calc(100vh - 180px)"
class="w-full bg-[--center-bg-color] border-(solid 1px [--line-color]) h-full p-[10px_0] box-border rounded-4px">
<n-flex justify="center">
<!-- 动态内容框 -->
@ -58,13 +87,67 @@
</n-flex>
</n-flex>
</n-scrollbar>
</article>
</div>
<!-- 弹出框 -->
<n-modal v-model:show="infoTip.modalShow" class="w-450px border-rd-8px">
<div class="bg-[--bg-popover] h-full p-6px box-border flex flex-col">
<svg @click="infoTip.modalShow = false" class="w-12px h-12px ml-a cursor-pointer select-none">
<use href="#close"></use>
</svg>
<n-virtual-list
:items="dynamicCommentList"
:item-size="40"
class="max-h-500px w-full p-10px box-border select-none">
<template #default="{ item }">
<n-flex align="center" justify="space-between" class="mt-18px">
<n-flex align="center">
<n-avatar :size="36" round bordered :src="item.avatar" />
<p>{{ item.user }}</p>
<p class="text-(12px #707070)">{{ item.content }}</p>
</n-flex>
<p class="text-(12px #707070)">2021-01-01</p>
</n-flex>
</template>
</n-virtual-list>
</div>
</n-modal>
</main>
</template>
<script setup lang="ts">
import { dynamicList } from '@/mock'
import { dynamicList, dynamicCommentList } from '@/mock'
import { appWindow } from '@tauri-apps/api/window'
import { useWindowState } from '@/hooks/useWindowState.ts'
import { setting } from '@/stores/setting.ts'
import { storeToRefs } from 'pinia'
useWindowState(appWindow.label)
const settingStore = setting()
const { login } = storeToRefs(settingStore)
const infoTip = ref({
value: dynamicCommentList.length,
show: true,
modalShow: false
})
const titleList = [
{
label: '动态',
total: 43
},
{
label: '关注',
total: 443
},
{
label: '点赞',
total: 99
}
]
/** 处理信息提示 */
const handleInfoTip = () => {
infoTip.value.show = false
infoTip.value.modalShow = true
}
</script>

View File

@ -7,7 +7,7 @@
<ContextMenu @contextmenu="showMenu($event)" @select="handleSelect($event.label)" :menu="menuList">
<n-collapse-item title="我的好友" name="1">
<template #header-extra>
<span class="text-(10px #707070)">1/1</span>
<span class="text-(10px #707070)">0/0</span>
</template>
<!-- 用户框 多套一层div来移除默认的右键事件然后覆盖掉因为margin空隙而导致右键可用 -->

View File

@ -1,6 +1,42 @@
<template>
<div>查找用户</div>
<n-flex :size="14" vertical justify="center" class="p-14px text-(12px #909090)">
<p>搜索建议</p>
<n-flex align="center" class="text-(12px #909090)">
<p class="p-6px bg-#eee rounded-8px cursor-pointer">@</p>
<p class="p-6px bg-#eee rounded-8px cursor-pointer">特别关心</p>
</n-flex>
<span class="w-full h-1px bg-[--line-color]"></span>
<n-flex align="center" justify="space-between">
<p class="text-(12px #909090)">历史记录</p>
<p class="cursor-pointer text-(12px #13987f)">清除</p>
</n-flex>
<template v-for="(item, _index) in historyList" :key="_index">
<n-flex align="center" :size="14" class="p-6px cursor-pointer rounded-8px hover:bg-[--bg-group-hover]">
<n-avatar :size="38" round bordered :src="item.avatar" />
<p class="text-(16px [--text-color])">{{ item.name }}</p>
</n-flex>
</template>
</n-flex>
</template>
<script setup lang="ts"></script>
<script setup lang="ts">
const historyList = [
{
avatar: 'https://picsum.photos/140?1',
name: '小瘪三'
},
{
avatar: 'https://picsum.photos/140?2',
name: '号啊玉'
},
{
avatar: 'https://picsum.photos/140?3',
name: '张三'
}
]
</script>
<style scoped></style>